登录后老掉线、刚扩的机器背黑锅:负载均衡会话保持踩坑全记录
背景:单节点跑得好好的业务,扩成 2 个应用节点后,用户反馈"刚登录就掉线""点两下又跳登录页"。折腾了半天才发现:不是应用有 bug,是负载均衡把同一个用户的后续请求甩给了另一台机器。
零、先把 3 个概念掰开讲(不然后面看不懂)
0.1 负载均衡到底解决什么问题?
先说结论:负载均衡只干一件事 ------ 把一堆请求"分摊"给多台机器,别让一台机器累死、别让另一台机器闲置。
打个比方。你开了一家小面馆,只有一个厨师(单节点)。生意一般,扛得住,成本也低。
后来生意火了,排队排到门外(单台扛不住了),你去雇第二个厨师,还多租了一间后厨(多台服务器 / 多节点)。
现在来了 100 个顾客(请求)。你有两种分派方式:
| 分派方式 | 实际做法 | 结果 |
|---|---|---|
| 不请人(单节点) | 100 个人全排给厨师 A | A 忙到冒烟,B 闲到长草 |
| 请了个"领位员"(负载均衡) | 领位员按某种规则把 100 人分给 A、B 两间后厨 | A、B 各自承接 50 人,谁忙谁多干 |
这个领位员,就是负载均衡(Load Balancer,缩写 LB)。它坐在所有用户和所有机器中间,收到一个请求,就替用户挑一台机器去处理。
所以负载均衡解决的不是性能问题,而是:
- 分摊压力:一台扛不住 → 多台一起扛(横向扩容的前提)
- 高可用:A 机器挂了,领位员把请求转给 B,用户基本无感
- 统一入口:对外只暴露一个 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),后厨怎么调整他不知道。
关键特性:
- VIP 属于负载均衡器,不属于任何一个应用节点
- 用户在浏览器里填的永远是 VIP,永远不直接填某台机器的 IP
- 平时 VIP 活在一个主节点上(高可用集群里通常由 keepalived 这类软件让 VIP 在主备之间漂移,VIP 换机器 = 入口地址不变,底下的应用换成另一套)
- 客户端的每一次请求都打到 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)
0.4 Cookie vs Session:钥匙 和 行李箱,别搞混
这是新手最常搞不清的一对,但用一句话就能说清:
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。这还叫掉线吗?这就是随机换人服务。
二、通俗原理:三个东西搞明白,事故就明白了
2.1 Cookie / Session / 负载均衡,三者是怎么"撞车"的
先用一句话把三者关系钉死:
- 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 亲和:只有少部分用户被挪动 - 无论哪种:被挪动的用户那一下还是会掉线
滚动发布时的标准做法:逐台上下线(摘流量 → 等存量请求结束 → 停进程 → 换新版本 → 探活 → 挂流量),一次只动一台。 会话保持让这件事比无状态情况下更微妙 ------ 摘掉一台节点时,粘在它上面的用户怎么办?
实践中的处理方式:
- 优雅摘流 + 主动踢下线:告诉被摘节点的连接"会话已迁移,请重新登录",比静默失败体验好
- 会话共享兜底(下一节的方案),这样节点摘掉时,用户换台机器照样能用
- 发布窗口放在低峰,并提前公告"可能需要重新登录一次"
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 迁移、灰度切换、监控告警都得自己搞 |
动手前一定要问的三个问题:
- 登录态的峰值数量级是多少?内存预算够吗?
- Redis 挂了业务怎么降级?没有降级方案就别上共享会话
- 现有 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 个问题
- 这个应用的登录态,存在哪? 单机内存 / 共享存储 / 客户端 Token?
- 有第 2 个实例的那一天,这个问题会出现吗? 如果会,我打算怎么修 ------ 现在就定,还是等它出事?
- 测试环境的节点数,和生产一致吗? 不一致,测试结论一律作废
- Nginx 的
upstream块现在配的是什么? 有没有ip_hash(不是会话保持)、有没有hash ... consistent、哈希键是什么? - 出问题时我能在 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、优雅摘流、多实例部署、滚动发布掉线