负载均衡会话保持-扩容后登录掉线踩坑

登录后老掉线、刚扩的机器背黑锅:负载均衡会话保持踩坑全记录

背景:单节点跑得好好的业务,扩成 2 个应用节点后,用户反馈"刚登录就掉线""点两下又跳登录页"。折腾了半天才发现:不是应用有 bug,是负载均衡把同一个用户的后续请求甩给了另一台机器。


零、先把 3 个概念掰开讲(不然后面看不懂)

0.1 负载均衡到底解决什么问题?

先说结论:负载均衡只干一件事 ------ 把一堆请求"分摊"给多台机器,别让一台机器累死、别让另一台机器闲置。

打个比方。你开了一家小面馆,只有一个厨师(单节点)。生意一般,扛得住,成本也低。

后来生意火了,排队排到门外(单台扛不住了),你去雇第二个厨师,还多租了一间后厨(多台服务器 / 多节点)。

现在来了 100 个顾客(请求)。你有两种分派方式:

分派方式 实际做法 结果
不请人(单节点) 100 个人全排给厨师 A A 忙到冒烟,B 闲到长草
请了个"领位员"(负载均衡) 领位员按某种规则把 100 人分给 A、B 两间后厨 A、B 各自承接 50 人,谁忙谁多干

这个领位员,就是负载均衡(Load Balancer,缩写 LB)。它坐在所有用户和所有机器中间,收到一个请求,就替用户挑一台机器去处理。

所以负载均衡解决的不是性能问题,而是:

  1. 分摊压力:一台扛不住 → 多台一起扛(横向扩容的前提)
  2. 高可用:A 机器挂了,领位员把请求转给 B,用户基本无感
  3. 统一入口:对外只暴露一个 IP / 域名,内部可以随便加机器、减机器,用户完全不知道

第 3 点是它最容易被忽略的价值 ------ 用户不需要知道后面有几台机器,机器怎么加减,对前端是透明的。

顺便说清一件事:有了负载均衡,你的应用不能假设"我就是唯一一台"。这个前提一破,session 这类"存在本地内存里的东西"就会出大事,本文后面的大坑就来自这句话。

0.2 物理上的层级:服务器 / 虚拟机 / 应用 / 节点,到底啥关系

这四个词经常被混着叫,实际是有层级的,用"面馆"继续类比:

术语 实际是什么 面馆类比
服务器(Server) 一台物理机,一块真硬件 整栋楼
虚拟机(VM) 物理机上用 Hypervisor 切出来的一个虚拟系统,有自己的 CPU/内存/系统 楼里隔出来的一间独立铺面,有自己的水电和门牌
应用(Application) 装在系统里跑的那段代码,比如你的 Java 服务、nginx 铺面里真正干活的那个厨师
节点(Node) 一个能对外提供服务的"应用实例" 一个厨师。同一台物理机上跑 3 个 Tomcat,就是 3 个节点

日常口语里说"节点",基本就是指"一个应用实例"。 你说"扩容到 2 个节点",翻译成人话就是:现在有两份一模一样的应用代码在跑,各自都有自己的内存和 session 空间。

划重点(后面要靠这句推导):节点是有独立内存的。A 节点和 B 节点之间,内存不共享。

0.3 VIP 是什么?------ 那个"看不见的领位员地址"

VIP = Virtual IP,虚拟 IP 地址。它是一个只存在于逻辑上、并不绑在某一台真实机器网卡上的地址。

面馆类比:VIP 就是"餐厅门口那个写在大招牌上的地址"。招牌不会因为你在后厨换人、换厨师而变。顾客永远只认招牌(VIP),后厨怎么调整他不知道。

关键特性:

  1. VIP 属于负载均衡器,不属于任何一个应用节点
  2. 用户在浏览器里填的永远是 VIP,永远不直接填某台机器的 IP
  3. 平时 VIP 活在一个主节点上(高可用集群里通常由 keepalived 这类软件让 VIP 在主备之间漂移,VIP 换机器 = 入口地址不变,底下的应用换成另一套)
  4. 客户端的每一次请求都打到 VIP → 负载均衡器拿到请求 → 选一台真实机器转发过去

所以整条链路是:

复制代码
你的浏览器
   │  请求:https://biz.xxx.com/order/list
   ▼
VIP(虚拟 IP,例如 10.0.0.100)← 用户只知道这个
   │
   ▼
负载均衡器(LB,Nginx / 云厂商 SLB / F5 / LVS......)
   │  按调度规则挑一台
   ├──► 节点 A(10.0.0.11)
   └──► 节点 B(10.0.0.12)

这是新手最常搞不清的一对,但用一句话就能说清:

Session 是行李箱,Cookie 是挂在门口、写着"行李箱编号 8848"的那张便条。

Session(服务端会话)

  • 存在服务器内存里的一个数据结构,专门记"当前这个用户是谁、登录没登录、购物车里有什么"
  • 通常是登录成功后自动生成的,key 类似 sessionId: 3f9a7c21...
  • 生命周期:服务重启 = 内存清空 = 全部 session 没了
  • 关键性质:它是状态(stateful),数据落在某一个具体进程的内存里

Cookie(客户端小纸条)

  • 存在用户浏览器 里的一小段文本,服务器返回 Set-Cookie 响应头,浏览器存下来,之后每次请求都自动带上
  • 内容可以是很多东西:登录标识、主题偏好、追踪 ID......不只用来存 session
  • Cookie 里的数据是明文可见的,所以不能把密码、身份证号这种直接塞进去

它俩是怎么配合的? 这是你问题里提到的关键点:

复制代码
1. 你 POST /login,用户名密码
2. 节点 A 校验通过 → 在自己的内存里建一份 session:
      sessionId = 3f9a7c21
      session 内容 = { userId: 10086, name: "张三" }
3. 节点 A 返回响应,带一个 Set-Cookie:
      Set-Cookie: JSESSIONID=3f9a7c21; Path=/; HttpOnly
4. 浏览器把 "JSESSIONID=3f9a7c21" 存成 Cookie
5. 你下一次访问任何页面,浏览器自动带上:
      GET /order/list
      Cookie: JSESSIONID=3f9a7c21
6. 节点 A 拿这个 sessionId 去自己内存里查 → 查到 → 知道你是张三,放行

所以准确的说法是:Cookie 里存的是 sessionId(会话编号),真正的会话数据存在服务器的 session 里。

一句话总结:

Cookie Session
存在哪 用户浏览器 服务器内存
里面装什么 一堆键值对,常装 sessionId 真正的用户状态
谁维护 浏览器按规则自动带 服务端代码读写
服务重启影响 无(浏览器还在) 全丢
多节点共享 天然可以(浏览器会发给任何节点) 默认不行(内存不共享)

最后一行加粗的"默认不行",就是本文事故的根因。

0.5 Token 是啥?和 Cookie、Session 什么关系

Token(令牌)常被当成新东西,其实它就是另一种形态的"我是谁"的凭证,和 sessionId 干的是同一件事,只是思路不同。

Session 方案:服务端记状态。浏览器只拿一张"号码牌"(sessionId),每次来查表。

Token 方案 :服务端不记 状态。登录时把用户信息 + 有效期 + 签名编码压缩成一个字符串 (比如 JWT),直接发给浏览器。浏览器每次请求把这个字符串原样带回来,服务端只做两件事 ------ 验签名 、看有没有过期,不查任何存储。

复制代码
Token 方案(JWT 举例):

eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjEwMDg2LCJuYW1lIjoi5byg5LiJIn0.x2hZ...签名
├─ Header  用了什么算法
├─ Payload 载荷:{ userId: 10086, name: 张三, exp: 过期时间 }
└─ Signature 签名:防止别人伪造 Payload

三者的关系和区别:

Cookie / Session Token(如 JWT)
服务端存状态吗 存(内存/Redis) 不存(无状态)
凭证形态 一串 sessionId 一个自解释的长字符串
能不能服务端主动踢人 能(删掉 session 就行) 难(要维护黑名单,又变有状态了)
能横向扩容吗 默认不能,得开会话保持或共享会话 能,任何节点都能验
传输方式 Cookie 自动带 也常放 Cookie,也可放 Authorization 头
典型用途 传统 B/S 系统、后台管理系统 前后分离、App、小程序、微服务

一句话:Token 是为了摆脱"必须粘在同一个节点上"而发明出来的方案。 记住这句,第五节还会回来算这笔账。

0.6 负载均衡都有哪些调度策略

先给个全景:负载均衡的活儿就是"选一台机器",选的方法就是调度策略。

策略 通俗解释 会话友好吗
轮询 Round Robin 排队叫号:1 号、2 号、1 号、2 号...... 依次来 ❌ 最伤,会话直接飞
加权轮询 Weighted RR 叫号但有偏向:A 机性能强,分配 70% 请求,B 机 30% ❌ 同样会飞
随机 Random 闭眼抓阄 ❌ 同样会飞
最少连接 Least Connections 谁手上活的顾客少就派给谁(自动避开慢节点) ❌ 同样会飞
源地址哈希 IP Hash 按"客户 IP"算哈希,永远算到同一台 ⚠️ 半个解法
一致性哈希 Consistent Hash 改进版 IP Hash,节点增减时只有少部分用户被挪动 ⚠️ 同上,比 IP Hash 稳
Cookie 亲和(Sticky Cookie) 负载均衡器给浏览器发一个专属 Cookie,记住"这个浏览器该去 A" ✅ 真正的会话保持
URL / 请求头哈希 按 URL 或 User-Agent 等特征算归属 ⚠️ 视业务而定

默认的那一个是轮询,也就是本文事故的元凶。

补充一句实战里很常见的坑:"源地址哈希"和"会话保持"经常被混为一谈,其实它们是两回事。IP Hash 依赖的是"你的出口 IP 不变",而真正稳定的浏览器标识是 Cookie。区别在 4.4 节详细说。

另外,Nginx 里还有一层常被误认的东西:upstream 块里的 ip_hash 指令只按 IP 做哈希,不看 Cookie,很多人以为它就是会话保持 ------ 它不是,后面 4.4 节会拆这个雷。


一、现象开篇:上线半天,投诉炸了

先还原现场。

某业务系统,架构很简单:

复制代码
用户 → VIP(10.0.0.100,Nginx)→ 1 个应用节点

上线前跑了大半年,一切正常。测试环境验证也过了 ------ 因为测试环境从头到尾就只有 1 个节点,怎么测都测不出问题。

某天业务量涨了,为了不宕机,运维同学把应用扩成 2 个节点:

复制代码
                     ┌──► 节点 A(10.0.0.11)
用户 → VIP(10.0.0.100)
                     └──► 节点 B(10.0.0.12)

Nginx 的配置也很常见,标准的反向代理 + 轮询:

nginx 复制代码
upstream backend {
    server 10.0.0.11;   # 节点 A
    server 10.0.0.12;   # 节点 B
    # 没配任何会话保持相关的东西 → 默认轮询
}

server {
    listen 80;
    server_name biz.xxx.com;

    location / {
        proxy_pass http://backend;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

上线 10 分钟后,用户投诉开始了:

  • "我明明刚登录,怎么又让我登录?"
  • "点两下就掉线一次,购物车里东西全没了。"
  • "刷新一下就退出登录了。"
  • "有的人一整天都在登录和退出之间反复横跳。"

而开发同学查日志、看代码、查数据库,一个 bug 都找不到:

  • 登录接口返回 200,密码校验成功
  • 查库用户密码没错
  • 前端没报错
  • 用户刷新几次又好了 ------ 因为"随机"地又被打回了原来那台机器

现象非常有迷惑性:它时好时坏、时好时坏、时好时坏。 越是这种"偶现"的故障,越难定位,因为你不一定每次都能复现。

运维后来在 Nginx 日志里加了一行,打印出每次请求被转发到了哪台机器:

nginx 复制代码
log_format lb '$time_local $remote_addr $request -> $upstream_addr';

日志长这样,一眼就看出问题:

复制代码
10.0.0.66  POST /login          -> 10.0.0.11
10.0.0.66  GET  /order/list     -> 10.0.0.12   ← 同一个用户,下一次就换机器了
10.0.0.66  GET  /order/detail   -> 10.0.0.11
10.0.0.66  POST /order/submit   -> 10.0.0.12   ← 又跳了

同一个客户端 IP,登录在 A、查询在 B、提交又在 A。这还叫掉线吗?这就是随机换人服务。


二、通俗原理:三个东西搞明白,事故就明白了

先用一句话把三者关系钉死:

  • Cookie:浏览器里的一张便条,上面写着"我的会话编号是 8848"
  • Session:某个节点内存里的一份档案,写着"8848 = 张三,已登录,购物车 3 件"
  • 负载均衡 :一个只管"发号排队"的领位员,它根本不知道也不关心便条上写的是什么

再看一遍登录后的完整链路(注意第 5 步):

复制代码
1. 浏览器  ── POST /login ──────────►  VIP → 节点 A
2. 节点 A  密码校验通过,在自己的内存里建 session:
             sessionId = 8848
             session  = { userId: 10086 }
3. 节点 A  ── Set-Cookie: JSESSIONID=8848 ──► 浏览器
4. 浏览器  存下 Cookie,此后每次请求自动带上
5. 浏览器  ── GET /order/list (Cookie: 8848) ──►  VIP → ? 节点 B  ← 事故就在这一跳
6. 节点 B  自己的内存里翻遍:没有 8848
7. 节点 B  取不到 session = 你没登录 → 返回 401 / 跳登录页 → 用户掉线

2.2 为什么"session 存在节点内存里"这件事这么致命

这是全文最关键的一张图,记住这一句就够了:

session 的数据活在一个节点的内存里,而不是活在你身上。

这意味着:登录状态不是"你的",是"A 节点欠你的"。

所以只要你的请求换了节点,就等于去敲了另一扇门,而那扇门的门卫根本不认识你。

复制代码
        节点 A 的内存                节点 B 的内存
     ┌──────────────────┐      ┌──────────────────┐
     │ 8848 → 张三 ✅   │      │ 8848 → ? ❌ 空   │
     │ 9102 → 李四 ✅   │      │ 9102 → ? ❌ 空   │
     │ ...              │      │ ...              │
     └──────────────────┘      └──────────────────┘
              ▲                         ▲
        只能服务"一直走 A"的人      只能服务"一直走 B"的人

还有两个更容易被忽略的连带影响:

① 放大效应:普通业务也会受影响,不只是登录接口。

session 里存的不只是"登录没登录",还有一堆业务状态:购物车、导航菜单权限、已填的表单、验证码......用户点一下就被踢回登录页,这只是最明显的那一个症状。

② 内存里的东西活不过重启。

回到 session 的本质 ------ 存在内存里。节点重启、扩缩容、滚动发布、进程被 OOM 杀掉,内存一清空,那个节点上所有用户的登录状态当场归零。

这引出一个必须知道的推论:

只要 session 存在单机内存里,那么"节点数量 > 1"和"用户不掉线"这两件事,在架构上就是互斥的。

单节点时代你之所以从来没觉得 session 有问题,不是因为 session 设计得好,是因为只有一台机器,别的所有问题都被"只有一台"这个条件掩盖掉了。

2.3 轮询:真的公平吗?公平,但对你要命

轮询的逻辑简单到不能再简单:发牌。

复制代码
请求 1 → A
请求 2 → B
请求 3 → A
请求 4 → B
请求 5 → A
...

从"平均分配请求数"的角度看,轮询是最公平、最朴素、最合理的策略。它没有 bug,它完美地完成了自己的职责。

但它对"会话"是灾难。

打个生活化的比方:轮询像"发牌式叫号",而你的会话状态像"牌桌上每个人手里的牌"。

复制代码
用户 1 抽到 A 号桌 → 手里的牌记在 A 桌的账本上
用户 2 抽到 B 号桌 → 手里的牌记在 B 桌的账本上
用户 1 又抽到 B 号桌 → 换桌了,B 桌账本上没有他 → 判定"这人没登记过"

轮询的"公平"是对所有请求的整体统计而言的,它完全不承诺"同一个人的请求走同一台"。 这两件事差着十万八千里。

所以必须分清两个容易混淆的词:

  • 负载均衡的调度策略:决定"这个请求交给哪台机器"
  • 会话保持(Session Affinity):额外加一条约束"同一个客户端的请求必须交给同一台机器"

默认情况下,负载均衡只有前者,没有后者。


三、根因拆解:一步步还原

现在把事故从头到尾捋一遍,每一步都有对应的日志/代码证据。

第 1 步:用户登录,请求落到节点 A。

复制代码
POST /login  → 节点 A
节点 A:校验通过 → 新建 session 8848 → Set-Cookie: JSESSIONID=8848
结果:用户"登录成功"

此刻一切正常,节点 A 的内存里有 8848。

第 2 步:浏览器存好 Cookie,后续请求自动携带。

复制代码
Cookie: JSESSIONID=8848

浏览器完全不知道有个负载均衡存在,它老老实实每次都带上。

第 3 步:下一个请求,轮询把用户发给了节点 B。

复制代码
GET /order/list  → 节点 B

对用户来说这只是个普通刷新,对负载均衡来说这只是一次计数 +1,没有任何异常。

第 4 步:节点 B 找不到这个 session。

节点 B 的代码逻辑(这在绝大多数 Java 框架里就是默认行为,Spring Session 之前的年代都是这样):

java 复制代码
// 大致逻辑
HttpSession session = request.getSession();
Object userId = session.getAttribute("userId");
if (userId == null) {
    // 没查到 → 认定未登录
    response.sendRedirect("/login");
    return;
}

节点 B 的内存里根本没有 8848 这条记录 → userId == null → 未登录,踢下线。

第 5 步:用户刷新一下,可能又"好了"。

复制代码
GET /order/list  → 节点 A   (轮询到 A 了)
节点 A:8848 还在 → 放行 → 用户又登录上了

这个"刷新一下就好了"是这类故障最气人的地方,也是排查时间被拖长的主要原因。 它让所有人(包括一线客服和开发)都觉得"这系统时好时坏,是不是网络问题"。

第 6 步:重复循环。

用户每隔几个操作就被踢一次。表现就是投诉里说的"点两下又掉线"。

第 7 步:定位到根因。

在 Nginx 里加上 $upstream_addr 打印后发现:

复制代码
同一 remote_addr,请求在 10.0.0.11 和 10.0.0.12 之间来回跳

根因一句话:

应用把登录状态(session)放在了单个节点的内存 里,而负载均衡是轮询调度的,同一个用户的请求被分到了不同节点,节点 B 拿不到该用户的 session,于是判定未登录并踢下线。

**这不是代码 bug,是架构假设被打破:**代码是按"我只有一个实例"写的,现在变成了两个。


四、会话保持:这是怎么把问题按住不动手的

4.1 什么是会话保持

一句话:给负载均衡加一条规则 ------ 同一个客户端的请求,一律送到同一个节点去。

复制代码
未开启会话保持(轮询):
请求1→A  请求2→B  请求3→A  请求4→B     ← 状态找不到

开启会话保持:
请求1→A  请求2→A  请求3→A  请求4→A     ← 一直找同一台,状态一直在

它不改变别的任何东西,节点数量、代码、部署方式都不动,只在负载均衡这一层加了个"分派规则"。

对用户来说,感知几乎为零:又没让我多输一次密码,也没让我多等一会儿,只是不再掉线了。 这也是为什么它上线快、见效快。

4.2 会话保持是怎么"记住"这个用户的

关键问题:负载均衡凭什么知道"这个用户之前去过 A"?记住一个用户,需要一个稳定不变的标识。目前只有两种:

依据 做法 代表产品 优点 坑
源 IP 对客户端 IP 做哈希 Nginx ip_hash、LVS RR/NAT、一致性哈希 配置最简单,不用下发任何东西 坑最多,见 4.4
Cookie 亲和 负载均衡器给浏览器发一个专门记录归属节点的 Cookie 云厂商 CLB/ALB 的"会话保持"、HAProxy 的 cookie insert、Nginx Plus 的 sticky 精准、可靠,NAT 场景下也不乱 少数开源 Nginx 没有现成模块(见 4.5)

关键区别在 4.4 ------ 很多事故的二次踩坑都发生在这个"IP 到底靠不靠谱"上。

4.3 我们实际怎么配的

Nginx 层(本项目)

思路:既然没有现成的 sticky 模块,就用 Nginx 自带的 hash 指令,按应用自己下发的会话 Cookie 来做哈希,效果等价于 Cookie 亲和:

nginx 复制代码
upstream backend {
    server 10.0.0.11;
    server 10.0.0.12;

    # 关键:按会话标识算哈希,替代默认轮询
    hash $cookie_JSESSIONID consistent;
}

server {
    listen 80;
    server_name biz.xxx.com;

    location / {
        proxy_pass http://backend;

        # 这两行让后端知道真实客户端信息,配合应用侧自己的 IP 会话保持
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

consistent 是一致性哈希的开关。它的实际意义:将来从 2 个节点扩到 3 个或者缩回 1 个时,只有少部分用户的归属会变,绝大多数人不会被重新洗牌。 如果用普通哈希(不加 consistent),节点一多一少,几乎所有用户归属都会变,等于全量用户当场掉线一次。

应用层

Nginx 侧和云厂商的 CLB 侧同时 开启会话保持,形成双保险。之所以要双保险:任何一层单独失效(比如 LB 会话保持规则被改、被覆盖、跨机房调度异常),另一层还能兜住。做高可用的经验是:同一个能力不要只靠一层。

云厂商负载均衡的等价配置项(不同厂商叫法不同,但都是这个意思):

厂商 / 产品 会话保持配置
阿里云 SLB / ALB 会话保持 → 开启,会话保持类型选「Cookie」或「源 IP」
腾讯云 CLB 会话保持 → 开启,会话保持类型「源 IP」/ 改写 Cookie
华为云 ELB / ALB 会话保持 → 开启,Cookie 模式
Nginx 开源版 hash $cookie_XXX consistent;(无 sticky 模块)
Nginx Plus sticky cookie srv_id expires=1h domain=.xxx.com;
HAProxy cookie SERVER insert indirect nocache

⚠️ 一个非常容易踩的坑:会话保持类型到底选"源 IP"还是"Cookie"。

选错了,问题可能"看起来好了",但没真的修好 ------ 详见下一节。

4.4 限制一:IP 会话保持是靠不住的(最隐蔽的坑)

如果你在配置里选的是"源 IP",必须知道下面这些。

用户的出口 IP 并不像你以为的那么稳定:

场景 发生了什么 后果
手机 4G/5G 切网、基站切换 出口 IP 变了 被分到新节点,重新掉线
公司 / 学校 / 家庭 NAT 出口 几百上千人共用同一个公网 IP 所有人被分到同一个节点,负载严重倾斜
VPN 频繁重连、代理切换 出口 IP 变了 又掉线
IPv6 环境下用户设备地址变动 出口地址变了 又掉线

NAT 场景是 IP Hash 最经典的翻车现场 :全公司的人被钉在同一台机器上,另一台基本闲着,负载均衡的"分摊压力"这个核心价值直接消失了。

所以经验之谈是:

能选 Cookie 就别选源 IP。 Cookie 亲和是跟着"浏览器"走的,稳;源 IP 亲和是跟着"网络出口"走的,不稳。

如果被迫用源 IP(比如某些老网关、硬件设备只支持这个),那必须至少配两个节点 + 做好健康检查,并在业务上接受"IP 变了会掉一次"。

再强调一遍那个常见误解:

nginx 复制代码
upstream backend {
    server 10.0.0.11;
    server 10.0.0.12;
    ip_hash;    # ← 这不是会话保持!
}

ip_hash 只看 IP,完全不看 Cookie。它是 NAT 场景下的重灾区,也是"我明明配了 ip_hash,为什么还是会掉线"的标准答案。

4.5 限制二:节点挂掉,粘住的用户会受伤

会话保持保证了"一直走 A",但保证不了 A 一直活着。

复制代码
用户 → 一直被粘到 A
       ↓
A 挂了 / 被滚动发布重启 / 被扩缩容摘掉
       ↓
负载均衡健康检查发现 A 不可用 → 不再往 A 发新连接
       ↓
该用户被分到 B → B 上没有他的 session → 掉线

结论:会话保持会放大单点故障的影响面。 原本"某一个用户"受影响,因为大家都分散在各节点;开了会话保持之后,"粘在这个节点上的所有用户"会同时受影响。

这个代价在平时看不出来,只在节点挂掉那一刻集中爆发。所以会话保持必须配一个东西(下面 4.7 讲怎么配),否则从一个偶发问题,换成一次集中事故。

4.6 限制三:负载不再均衡

会话保持天然带来倾斜:

  • 一个"被粘住"的重度用户(比如大报表导出、爬得比较狠的客户端)一直消耗一个节点
  • Cookie 亲和的哈希分布不如轮询均匀
  • 节点规格不一致时,固定的分配比例容易让强节点吃不饱、弱节点被打爆

所以用了会话保持之后,节点规格最好保持一致 ,并且必须配上节点级监控(QPS、CPU、内存、连接数),别等到某台被压垮了才知道。

4.7 限制四:扩缩容、发布时仍要"善后"

从 2 个节点扩到 4 个,或者滚动发布把节点换掉时:

  • IP Hash(普通哈希) :几乎所有用户的归属都会变 → 全量用户掉线一次
  • IP Hash + consistent 或 Cookie 亲和:只有少部分用户被挪动
  • 无论哪种:被挪动的用户那一下还是会掉线

滚动发布时的标准做法:逐台上下线(摘流量 → 等存量请求结束 → 停进程 → 换新版本 → 探活 → 挂流量),一次只动一台。 会话保持让这件事比无状态情况下更微妙 ------ 摘掉一台节点时,粘在它上面的用户怎么办?

实践中的处理方式:

  1. 优雅摘流 + 主动踢下线:告诉被摘节点的连接"会话已迁移,请重新登录",比静默失败体验好
  2. 会话共享兜底(下一节的方案),这样节点摘掉时,用户换台机器照样能用
  3. 发布窗口放在低峰,并提前公告"可能需要重新登录一次"

4.8 会话保持的适用边界(什么时候别用它)

会话保持是一个有明确边界的补救方案,不是"best practice"。这些场景请不要用它:

场景 为什么别用
前后分离 + Token 鉴权(前后端纯 API) 本身无状态,用会话保持是白背复杂度
负载均衡已经配了 Cookie 亲和,业务又是多实例 同上,纯冗余
大规模集群(几十上百节点) 负载倾斜和故障放大面不可接受
需要严格水平扩容能力的系统 会话保持让"随便加机器"这件事变复杂了
无状态中间件层(网关、静态资源) 没有 session,无处保持

核心判断标准只有一条:

如果你的应用把状态存在本地内存里 → 会话保持是必需的补丁。

如果你打算把状态挪到共享存储、或者干脆做成无状态(Token)→ 会话保持应该去掉。

会话保持是在为"应用有状态"这件事交租金。能不给就不给,能少给就少给。

4.9 怎么验证真的修好了(别改完就收工)

改完配置必须验证,而且不能只测一次,这类问题就是"偶现"才最坑。

① 抓包/看日志确认"粘住了"

加一行日志就能说明一切:

nginx 复制代码
log_format lb '$time_local $remote_addr cookie=$cookie_JSESSIONID -> $upstream_addr';

正确的表现:

复制代码
10.0.0.66 cookie=8848 -> 10.0.0.11
10.0.0.66 cookie=8848 -> 10.0.0.11     ← 同一个会话,一直是 A
10.0.0.66 cookie=8848 -> 10.0.0.11

② 覆盖"两次登录"和"两个用户"

  • 同一浏览器连续操作 20 次,确认不落到另一台
  • 开两个浏览器(无痕窗口)分别登录,确认各自粘住、各自独立
  • 换一种请求类型验证:POST、文件上传、跳转接口都要试(有些框架对不同方法处理不一致)

③ 覆盖边界场景

  • 目标节点手动停掉,确认用户被转到另一台时的提示是否友好
  • 会话 Cookie 过期后重新访问,确认不会被踢到奇怪的循环里
  • 节点重启后,确认重新登录顺畅

④ 压测验证(关键)

用 100 并发持续压 5 分钟的混合请求,看服务端错误率(尤其 401/302 到登录页的比例)是否为 0。这是唯一能真正暴露"偶现"的手段。


五、拓展思考:除了会话保持,还有别的路吗

会话保持是"让请求别乱跑",而下面这几条是"让状态不再属于某一个节点"。方向不同,代价也不同。

5.1 方案 A:Session 共享(存 Redis)------ 最直接的替换

思路 :session 不再存在应用内存里,统一存到一份共享的 Redis,任何节点都能查到。

复制代码
        ┌─────────────┐
请求 ──►│    Redis    │  sessionId → 用户信息(所有节点共用这一份)
        └─────────────┘
         ▲    ▲    ▲
         │    │    │
        节点A 节点B 节点C   ← 随便加机器,随便轮询,谁都能查到

收益:

  • 彻底摆脱会话保持,负载均衡用回最健康的轮询
  • 节点可以随便扩、随便缩、随便重启,用户无感(滚动发布的"全量掉线"问题也一并消失)
  • 故障面变小:不再有"某个节点内存里的数据丢了"这种问题
  • 可以主动踢人:删掉 Redis 里的 key 即可
  • 可以跨机房、跨机房容灾时共享会话

代价(必须老老实实评估):

代价 说明
多了一次网络 IO 每个请求都要查 Redis,加一次网络往返。必须有本地缓存优化,见 5.2
对象序列化 从内存里的 Java 对象变成 bytes,要处理序列化/反序列化,以及 session 里的字段能不能安全序列化
多了一个故障点 Redis 挂了 = 全站登录态受影响。所以 Redis 要高可用(主从/哨兵/集群)
重启会丢 Redis 重启未持久化时 session 全丢。要配 AOF/RDB
内存成本 所有用户 session 常驻内存,需要评估容量和过期策略(TTL)
改动有成本 不是改一行配置。需要接入 Session 存储组件,老系统改造面不小
不是"设了就生效" 存储格式、旧 session 迁移、灰度切换、监控告警都得自己搞

动手前一定要问的三个问题:

  1. 登录态的峰值数量级是多少?内存预算够吗?
  2. Redis 挂了业务怎么降级?没有降级方案就别上共享会话
  3. 现有 session 里有 不能序列化的对象 吗(比如持有文件句柄、数据库连接、Spring 的某些 Bean)?这是迁移最常见的坑------线上迁移到一半因为序列化失败,全站登录 500

怎么用(以 Spring 为例),注意那个"本地缓存"参数,它是生命线:

xml 复制代码
<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>
yaml 复制代码
spring:
  session:
    store-type: redis
    redis:
      namespace: myapp:session
    timeout: 30m
java 复制代码
// 关键:开启本地缓存,session 不必每次都走网络
// (JDK 序列化下建议配 compress;生产建议用自定义序列化替代 JDK 原生序列化)
properties 复制代码
server.servlet.session.cookie.name=JSESSIONID
server.servlet.session.cookie.http-only=true
server.servlet.session.cookie.secure=true
server.servlet.session.cookie.same-site=lax

没有本地缓存的话,每个请求都打一次 Redis,业务高峰时 Redis 会被自己的 QPS 打死,最后变成"为了修掉线引入了更大的故障"。

5.2 方案 B:Token / JWT ------ 直接把状态扔掉

思路 :不存 session,登录时签发一个自包含的令牌,服务端不查任何存储。

复制代码
登录 → 签发 JWT(Payload 里放 userId / 过期时间 / 签名)
     → 浏览器存起来
每次请求 → 带 JWT → 节点验签名 + 验过期 → 直接放行

收益:

  • 真正的无状态,节点随便扩、随便挂、随便轮询
  • 没有额外网络 IO,性能比共享 session 好(验签是纯本地计算)
  • 天然适合前后分离、小程序、App、多端
  • 横向扩容能力最强 ------ 加机器不需要考虑任何事

代价:

代价 说明
难以主动失效 令牌发出去就一直在有效期内,服务端删不掉 。要实现"强制下线"就得维护黑名单------黑名单又是有状态的了,这就尴尬了
体积大 JWT 里塞 payload(用户信息、权限)后可能几百字节到几 KB,每个请求都带着走,高 QPS 接口的开销不可忽略
密钥管理 密钥泄漏 = 任何人都能伪造登录态。要做轮转、要做多服务验签的密钥分发
内容可读 JWT 的 payload 是 base64,只是编码不是加密。绝不能放密码、身份证等敏感信息
权限变更延迟 用户权限被改了,旧令牌里的权限还在,要等过期或额外引入版本号校验
老用户的坑 存量用户的会话要从 session 平滑迁到 token,需要双轨运行期

一个折中思路(工程上很常用) :JWT 里只放"用户身份",不放"权限列表" 。权限变更不频繁、且可以接受"下次登录生效"的系统,用这个方式能把体积和敏感度都降下来。权限要求严格的系统,可以采用短有效期令牌 + 刷新令牌,或者干脆走方案 A。

5.3 方案 C:网关统一鉴权 + 应用无状态

这是更靠上游 的解法:把"登录态判断"从业务应用挪到 API 网关,业务应用根本不碰 session。

复制代码
用户 → VIP → 网关(校验 Token / 解析 JWT,把 userId 塞进请求头)
            │  业务应用之间从不共享状态,各自处理请求
            ├──► 订单服务
            ├──► 用户服务
            └──► 库存服务

收益:

  • 业务应用彻底无状态,可以任意扩容、任意部署
  • 登录逻辑只写一遍,不会每个服务都写一套不一致的判断
  • 网关可以做限流、灰度、黑白名单等统一治理

代价: 架构改动最大,需要引入网关层并推动所有服务配合;对小团队来说,"为了解决掉线而引入一套网关"通常是不划算的。这条路更适合本来就规划了微服务治理的团队。

5.4 方案 D:改造目标优先级(重点)

如果只是想止血 → 开会话保持。

代价最小,改动量最小,10 分钟内见效。适合:存量系统紧急修补、先止血再慢慢改造。

如果系统还在活跃演进、或者以后要继续扩 → 上 Session 共享(Redis)。

收益是"以后扩缩容再也不用操心",是去掉这个长期租金的正确方式。

如果本来就是前后分离 / 纯 API 架构 → 直接用 Token,不要开会话保持。

反正已经没有 session 了,加会话保持是纯粹多余。

如果是多团队、多服务、有网关规划 → 走网关统一鉴权 + 应用无状态。

如果连这些都做不到(比如只能改 Nginx,不能动代码)→ 那就老老实实开会话保持,并接受第 4 节里的全部限制。

5.5 四种方案横向对比

维度 会话保持 Session 共享(Redis) Token / JWT 网关统一鉴权
改动量 ⭐ 极小 ⭐⭐⭐ 中 ⭐⭐⭐ 中 ⭐⭐⭐⭐ 大
对应用代码侵入 无 有(接入组件) 中(有状态 → 无状态) 有(依赖网关传递的身份)
额外网络 IO 无 有(Redis,需本地缓存优化) 无 无
节点随便扩缩 ❌ 归属会变 ✅ ✅ ✅
节点重启影响 会掉线 基本无感 无感 无感
能主动踢人 ✅ ✅ ❌ 难 ⚠️ 依赖设计
负载均衡该用什么 会话保持 轮询(最健康) 轮询 轮询
故障放大 ⚠️ 会(粘住的用户集体受影响) 小 小 小
适合的阶段 紧急止血 有活跃演进的存量系统 前后分离 / 新系统 微服务治理规划中

最后再强调一遍,因为这是最容易犯的错:

不要把"会话保持"当成最终方案。 它是止血 ,不是治疗 。

只要你的 session 还躺在单机内存里,你的系统就一直背着"节点数 > 1 就掉线"这笔债。今天用会话保持把债还了,下一次扩容、发布、节点故障,你还得再还一次。


六、总结复盘:这次到底学到了什么

6.1 故障复盘

项 内容
现象 扩容成 2 节点后,用户频繁掉线、登录状态丢失,偶发、时好时坏
直接影响 用户频繁被迫重新登录,购物车/表单状态丢失,业务投诉量激增
根因 应用把登录状态(session)存在单节点内存 中,负载均衡默认轮询调度导致同一用户请求落到不同节点,另一节点查不到 session,判定未登录并踢下线
为什么没提前发现 测试环境只有 1 个节点,这个 bug 在单节点下物理上不可能复现
止血方案 在 Nginx(hash $cookie_JSESSIONID consistent)与应用/云 LB 侧同时开启会话保持(Cookie 亲和优先于源 IP)
长期方案 规划 Session 共享(Redis)或改造为 Token 无状态,把状态从节点内存里搬出去
暴露的系统问题 ① 架构假设隐含"单实例",未在设计阶段评估多实例兼容性 ② 测试环境与生产环境拓扑不一致,没有做多节点验证 ③ 可观测性不足,靠加一行 $upstream_addr 才定位到问题

6.2 什么场景最容易踩这个坑

场景 为什么容易踩
单节点跑得好好的,某天加第二台 ⚠️ 头号高发场景。 单节点下这个 bug 物理上不可能出现,测试也测不出来
测试环境是 1 个节点,生产是多节点 ⚠️ 第二大杀手。 拓扑不一致,验证结论无效
传统项目改造为前后分离 / 拆微服务 老代码 session 满天飞,拆完一拆就爆
虚拟机迁移、容器重建、Pod 重新调度 每次重建 = 内存清空 = 该节点上所有用户掉线
滚动发布 / 灰度发布 发布会把流量在节点间搬来搬去,触发同样的问题
多机房 / 多可用区部署,流量跨机房调度 跨机房 session 共享更难,IP 也更容易变
负载均衡规则被误改 有人为了"均衡"把会话保持关掉,或者改成了源 IP
移动端 / 弱网环境 网络切换导致 IP 变化,源 IP 会话保持直接失效
大规模 NAT 出口环境(企业/学校/网吧) IP Hash 把全公司用户钉在同一个节点,负载均衡形同虚设

6.3 每次上线新节点前,先问自己这 5 个问题

  1. 这个应用的登录态,存在哪? 单机内存 / 共享存储 / 客户端 Token?
  2. 有第 2 个实例的那一天,这个问题会出现吗? 如果会,我打算怎么修 ------ 现在就定,还是等它出事?
  3. 测试环境的节点数,和生产一致吗? 不一致,测试结论一律作废
  4. Nginx 的 upstream 块现在配的是什么? 有没有 ip_hash(不是会话保持)、有没有 hash ... consistent、哈希键是什么?
  5. 出问题时我能在 1 分钟内看出请求去了哪台机器吗? 日志里有没有 $upstream_addr?

6.4 架构层面的一条铁律

只要应用存在本地内存里的状态,那么"扩容到多实例"就不是一个运维操作,而是一次架构改造。

状态在本地内存 → 必须做会话保持,或者把状态挪到共享存储(Redis),或者把状态交给客户端(Token)。三选一,不能不选。

不要在"状态还在单机内存里"的前提下,就认为"多实例部署"是安全的。 这是本次故障用一次线上事故换来的教训。

6.5 一句话总结

掉线的根因不是"用户问题",也不是"代码 bug",而是 session 存在单机内存 + 负载均衡默认轮询 这个组合必然产生的结果。

止血 :开会话保持(Nginx hash $cookie_JSESSIONID consistent / 云 LB 会话保持,优先 Cookie 亲和而非源 IP)。

治本 :Session 共享到 Redis,或者干脆改成 Token 无状态,让节点变得可以随便加。

记住:会话保持是止血,不是治疗。只要状态还在单机内存里,这笔债迟早要还。


附:本文涉及的核心概念速查

概念 一句话解释
负载均衡(LB) 用户的统一入口 + 请求分派器。解决压力分摊、高可用、单一入口三个问题
VIP(虚拟 IP) 属于 LB 的逻辑地址,不绑在任一真实节点上。用户只认它
节点(Node) 一个能对外提供服务的应用实例。有独立内存,节点间不共享
服务器 / 虚拟机 / 应用 / 节点 物理机 / 物理机上的虚拟系统 / 跑在这套系统里的代码 / 一个应用实例
Cookie 浏览器里存的小段文本,服务器通过 Set-Cookie 下发。常用来存 sessionId
Session 服务端内存里的用户状态。存在单机内存 = 换节点就丢
Token / JWT 自包含的登录凭证。服务端不存状态 → 天然可多实例
轮询 Round Robin 依次分配给各节点。最公平,但对会话最致命
会话保持(Session Affinity) 强制同一客户端的请求走同一节点。实现方式:Cookie 亲和 (稳)/ 源 IP Hash(不稳)
ip_hash Nginx 按 IP 做哈希。不是会话保持,NAT 环境下会出问题
hash $cookie_X consistent 按 Cookie 做一致性哈希。开源 Nginx 实现 Cookie 亲和的常用做法
$upstream_addr Nginx 变量,打印请求被转发到了哪台。排查此类问题的救命日志
Session 共享(Redis) session 存 Redis,任何节点都能查。扩缩容无忧,有网络 IO 成本
优雅摘流 下线节点时先停止接收新请求,等存量请求结束再停进程。发布时必做

关键词:负载均衡会话保持、负载均衡掉线、session 共享、nginx 会话保持、ip_hash、会话保持 Cookie、源 IP 会话保持、NAT 负载不均、Redis 存 session、JWT 无状态、$upstream_addr、优雅摘流、多实例部署、滚动发布掉线

相关推荐
pt10431 小时前
CML网络仿真入门-1:Cisco Modeling Labs概述
运维·网络协议
谷哥的小弟1 小时前
Linux服务器网络服务与端口
linux·运维·服务器·端口
阳光九叶草LXGZXJ1 小时前
达梦数据库-学习-71-存过执行过程中重建存过影响验证
linux·运维·数据库·sql·学习
勇往直前plus1 小时前
Nginx 原理与配置 —— 通用指南
运维·nginx
Ruiery1 小时前
Linux 6.6内核 CPU 深度解析(四):中断与 IPI — 一个 CPU 怎么“喊“另一个 CPU
linux·运维·服务器
志栋智能3 小时前
安全超自动化如何支持快速安全扩容?
运维·服务器·数据库·架构·自动化
承渊政道5 小时前
星空组网+Python实战:在另一台电脑上查看CSV报表
运维·开发语言·python·csv·星空组网
木卫二号Coding10 小时前
推荐Linux统计文件大小并操作删除工具-ncdu
linux·运维·服务器
志栋智能11 小时前
超自动化运维:实现IT服务消费化的基础
运维·自动化