37.2 无状态化
一句话
无状态化不是“没有状态”,而是把状态从实例内存搬到“任何实例都能访问的地方”,使任意实例随时可杀、可加、可缩;判断标准只有一条:随机杀掉一台,用户有没有感觉。
三个测试:你的服务到底有没有状态
| 测试 | 有状态的表现 | 无状态的表现 |
|---|---|---|
| 随机杀一台实例 | 部分用户掉线/重新登录/数据丢 | 用户无感,请求被其他实例接走 |
| 滚动发布(逐台重启) | 每台重启都踢掉一批用户 | 全程零中断 |
| 从 10 台缩到 5 台 | 被缩掉的实例上的会话/缓存全丢 | 只是少了一半算力 |
三个测试有一个不过,就说明状态还在实例里。扩容只是无状态化的副产品,真正的收益是“可以随时杀死任意实例”——滚动发布不中断、故障自愈、竞价实例、混沌工程,全都建立在这一条上。
状态藏在哪:五个地方
| 藏身处 | 典型写法 | 无状态化做法 | 代价 |
|---|---|---|---|
| 会话 | 内存 map[sessionID]User | Redis/DB 外置、JWT | 一次 RTT,或无法即时吊销 |
| 本地缓存 | 进程内 LRU、sync.Map | 分布式缓存;一致性哈希 + 失效广播 | 命中率下降、网络开销 |
| 本地文件 | 上传落本地盘、本地日志 | 对象存储、日志采集 sidecar | 延迟、存储成本 |
| 长连接 | 本机维护 WebSocket 连接表 | 专用网关 + 路由表 + 消息总线 | 网关自身变成有状态组件 |
| 定时任务/单例 | 每台都跑 cron、内存队列 | 分布式锁/选主、调度平台 | 复杂度、单点风险 |
还有两个隐形来源:本地限流/熔断计数器(各算各的,总量 = 单机 × N;35.7 限流)和本地生成的 ID/序号(重启后重复或跳号)。
数字一:粘性会话在滚动发布时踢掉多少人
20 台实例、10 万在线用户、会话存在进程内存、用粘性会话(同一用户固定落同一台):
| 操作 | 受影响用户 | 占比 |
|---|---|---|
| 滚动重启(逐台) | 64,151 | 64.2% |
| 单台宕机 | 5,000 | 5% |
| 换成 Redis 集中会话 | 0 | 0% |
为什么是 64.2% 而不是 100%?因为用户有 5% 概率落在“正在重启的那台”上,20 台逐台重启,至少被踢一次的概率是:
1 − (1 − 1/20)^20 = 1 − 0.95^20 = 64.2%
如果是一次性重启全部 20 台,才是 100%。滚动发布的“平滑”只对无状态服务成立;有状态服务即使逐台重启,也有 64% 的用户被打断——这不是“发布策略”能解决的,只能改状态存放位置。
数字二:扩容时多少会话要搬家
从 10 台扩到 12 台,两种路由方式:
| 路由方式 | 需要迁移的会话 | 说明 |
|---|---|---|
hash(userID) % 10 → % 12 | 83.3% | 除数一变,几乎所有 key 的归属都变 |
| 一致性哈希(每节点 200 个虚拟节点) | 15.6% | 只有落在新增节点区间上的 key 迁移 |
| 理想值 | 16.7% | 新增 2 台占 2/12 的环 |
取模的问题不是“不准”,而是稳定性差:节点数一变,映射全乱。一致性哈希把“节点数变化”的影响限制在环上相邻的一段,代价是实现复杂、需要虚拟节点解决数据倾斜。
🔑 一致性哈希不是无状态化的必需品——如果会话在 Redis,扩容根本不需要迁移会话。它真正的主场是“本地缓存/分片数据必须绑定实例”的场景。
数字三:JWT 的无状态与吊销窗口
JWT 把会话放进 token 本身:服务端不存任何东西,验签即通过。代价是无法即时吊销:
注销/封禁 → 服务端没有记录 → 旧 token 在过期前一直有效
TTL = 15 分钟,1000 个已封禁 token、每个每分钟 10 次请求
→ 窗口内约 1000 × 10 × 15 = 150,000 次请求会带着已作废的 token 通过
三种缓解,各有代价:
| 方案 | 做法 | 代价 |
|---|---|---|
| 短 TTL + refresh token | access token 5–15 分钟,refresh 存服务端 | 多一次刷新请求,refresh 本身有状态 |
| 黑名单 | 被封禁的 jti 写 Redis,验签后查一次 | 又变成有状态,且多一次 RTT |
| 版本号 | 用户表存 token_version,token 里带版本,改密/封禁时 +1 | 每次请求查一次用户(可缓存) |
没有免费的吊销:要么接受窗口,要么把状态加回来。多数系统的选择是“短 TTL + 关键操作二次校验”,把窗口控制在业务可接受的范围。
无状态 ≠ 无数据
状态没有消失,只是集中到了少数组件:
无状态的应用层 → 有状态的数据库 / Redis / MQ / 对象存储
(可随意增删) (需要独立的高可用、备份、容量规划)
这带来两个后果:
- 应用层扩容变简单,但下游压力集中:应用从 10 台扩到 100 台,DB 连接数也涨 10 倍,连接池/最大连接数成了新瓶颈(见 34.4 优化手段)。
- 状态组件的可用性决定整体可用性:应用层再弹性,Redis 挂了全站登录失败。无状态化把“N 个实例各自的状态风险”换成了“1 个状态组件的高可用风险”——这是划算的交换,因为后者可以专门做。
可运行的会话账本
package main
import (
"fmt"
"hash/fnv"
"math"
"sort"
)
const (
nodes = 20
sessions = 100_000
perNode = sessions / nodes
)
type point struct {
hash uint32
node int
}
// ---------- 一致性哈希:每个节点 200 个虚拟节点 ----------
func ring(nodeCount int) []point {
var pts []point
for n := 0; n < nodeCount; n++ {
for v := 0; v < 200; v++ {
h := fnv.New32a()
fmt.Fprintf(h, "node-%d-vnode-%d", n, v)
pts = append(pts, point{h.Sum32(), n})
}
}
sort.Slice(pts, func(i, j int) bool { return pts[i].hash < pts[j].hash })
return pts
}
func hashKey(key string) uint32 {
h := fnv.New32a()
h.Write([]byte(key))
return h.Sum32()
}
func owner(pts []point, k uint32) int {
i := sort.Search(len(pts), func(i int) bool { return pts[i].hash >= k })
if i == len(pts) {
i = 0
}
return pts[i].node
}
func remapRate(from, to int) float64 {
oldRing, newRing := ring(from), ring(to)
changed := 0
for i := 0; i < 20000; i++ {
k := hashKey(fmt.Sprintf("session-%d", i))
if owner(oldRing, k) != owner(newRing, k) {
changed++
}
}
return float64(changed) / 20000 * 100
}
func main() {
// ① 滚动重启:逐台重启,重启后本机内存里的会话全丢
affected := 1 - math.Pow(1-1.0/nodes, nodes)
fmt.Printf("① 粘性会话 + 滚动重启(%d 台逐台重启,每台存 %.0f%% 的会话)\n", nodes, 100.0/nodes)
fmt.Printf(" 至少被踢一次的用户:%.1f%%,约 %.0f 人 / %d 人\n", affected*100, affected*sessions, sessions)
fmt.Printf(" 换成 Redis 集中会话:0 人受影响,滚动重启对用户无感\n")
// ② 单节点宕机
fmt.Printf("② 单节点宕机:粘性会话丢 %d 人(%.0f%%),集中会话丢 0 人\n", perNode, 100.0/nodes)
// ③ 扩容 10 → 12:取模 vs 一致性哈希
modChanged := 0
for i := 0; i < 20000; i++ {
if i%10 != i%12 {
modChanged++
}
}
modRate := float64(modChanged) / 20000 * 100
fmt.Printf("③ 扩容 10 → 12 台:取模重映射 %.1f%%,一致性哈希重映射 %.1f%%(理想值 %.1f%%,即新增 2/12 的份额)\n",
modRate, remapRate(10, 12), 200.0/12)
// ④ JWT:没有会话丢失,但吊销只能等过期
ttlMin, revoked, qpsPerToken := 15.0, 1000.0, 10.0
fmt.Printf("④ JWT 无状态:会话丢失 0 人,但注销/封禁后旧 token 仍最长有效 %.0f 分钟\n", ttlMin)
fmt.Printf(" 假设 %0.0f 个 token 已被封禁、每个每分钟 10 次请求:TTL 窗口内约 %.0f 次请求会带着已作废的 token 通过\n",
revoked, revoked*qpsPerToken*ttlMin)
}
输出:
① 粘性会话 + 滚动重启(20 台逐台重启,每台存 5% 的会话)
至少被踢一次的用户:64.2%,约 64151 人 / 100000 人
换成 Redis 集中会话:0 人受影响,滚动重启对用户无感
② 单节点宕机:粘性会话丢 5000 人(5%),集中会话丢 0 人
③ 扩容 10 → 12 台:取模重映射 83.3%,一致性哈希重映射 15.6%(理想值 16.7%,即新增 2/12 的份额)
④ JWT 无状态:会话丢失 0 人,但注销/封禁后旧 token 仍最长有效 15 分钟
假设 1000 个 token 已被封禁、每个每分钟 10 次请求:TTL 窗口内约 150000 次请求会带着已作废的 token 通过
参数怎么给
| 你的情况 | 方案 | 注意 |
|---|---|---|
| 普通 Web/API | Redis 集中会话 | 会话加 TTL、序列化要稳定、Redis 做主从 |
| 多端/开放 API | JWT(短 TTL)+ refresh | 关键操作二次校验,接受吊销窗口 |
| 上传/导出文件 | 对象存储,不落本地盘 | 预签名 URL,避免应用层代理 |
| 长连接/IM | 网关维护路由表 | 需要断线重连退避 + 消息补偿 |
| 定时任务 | 分布式锁/选主/调度平台 | 锁要带租约,防死锁 |
| 本地缓存 | 一致性哈希 + 失效广播 | 接受短时间的缓存不一致 |
| 会话不能外置(合规) | 粘性会话 + 会话复制 | 只作为临时方案,扩容/发布仍会打断 |
反模式
| 反模式 | 后果 |
|---|---|
| 会话存内存 + 粘性会话,宣称“支持水平扩容” | 扩容时 83% 会话失效,发布会踢掉大半用户 |
| 上传文件落本地盘 | 换实例后 404,或被迫做“同机访问” |
| 每台都跑定时任务 | 任务重复执行、重复发券/重复对账 |
| 本地缓存不一致 | 用户看到旧价格/旧库存 |
| JWT TTL 设 7 天且无黑名单 | 被盗 token 一周内无法封禁 |
| 无状态化后不扩下游容量 | 应用扩 10 倍,DB 连接池先爆 |
| 把“无状态”当成“不用管状态” | 状态集中在 Redis/DB,反而成了单点 |
| 长连接服务直接水平扩 | 用户重连落到别的实例,收不到自己的消息 |
自检
Q1 20 台实例、会话存内存、滚动重启,为什么受影响用户是 64.2% 而不是 100%?什么情况下才会 100%?
▸ 答案:每个用户有 1/20 = 5% 概率落在“正在重启的那台”上;20 台逐台重启,至少被踢一次的概率是 1 − 0.95^20 = 64.2%。没被踢的 35.8% 是“每次都没赶上自己那台”的用户。如果一次性重启全部实例(或会话集中在少数几台且同时重启),受影响比例才接近 100%。这也说明:滚动发布只能减轻有状态服务的损伤,不能消除。
Q2 扩容 10 → 12 台,取模路由要迁移 83.3% 的会话,一致性哈希只要 15.6%。为什么取模这么差?为什么一致性哈希的理想值是 16.7% 而不是 0?
▸ 答案:取模的映射是 id % N,N 从 10 变 12 时,同一个 id 的余数几乎必然改变(只有 id % 60 相同的那部分不变),所以 83.3% 的 key 换主人。一致性哈希把节点映射到环上,新增节点只“抢走”环上相邻的一段,理想情况下只迁移新增节点应承担的份额:新增 2 台 / 总共 12 台 = 16.7%。实测 15.6%(虚拟节点有限,分布有偏差)。不可能为 0——新节点必须接管一部分数据,否则它没有流量。
Q3 本地缓存(进程内 LRU)算不算“有状态”?如果要保留它,怎么让它“接近无状态”?
▸ 答案:算。它的状态是“缓存内容 + 失效时序”:换一台实例,缓存是冷的(命中率骤降、回源打 DB);缩容时被删实例的缓存直接消失;不同实例的缓存可能不一致。保留的办法:① 用一致性哈希把 key 绑定到实例,让缓存命中率可预测;② 用广播失效(pub/sub)让所有实例同时失效同一个 key,接受毫秒级不一致窗口;③ 把它定位成“加速层”而不是“数据源”,任何时刻允许 miss 并回源。三者都做,才算“接近无状态”。
Q4 JWT 已经是无状态的,为什么生产环境还要加 Redis 黑名单?这不是又变回有状态了吗?
▸ 答案:因为“无状态”和“可吊销”在密码学上不可兼得:token 一旦签发,服务端没有记录就无法否定它。黑名单是用一次 Redis 查询换即时吊销能力,代价是:① 每个请求多一次 RTT(可本地缓存黑名单,接受秒级延迟);② 黑名单本身要持久化与高可用。更常见的折中是“短 TTL(5–15 分钟)+ 关键操作二次校验”,把窗口压到业务可接受,避免为每个请求加一次查询。
Q5 无状态化之后,状态去哪了?为什么说它没有“消失”,只是“集中”了?
▸ 答案:状态搬到了 Redis(会话)、数据库(业务数据)、对象存储(文件)、MQ(在途消息)、网关(连接路由)。这些组件仍然是有状态的,只是:① 数量少,可以专门做高可用(主从、集群、多副本);② 扩容路径与业务逻辑解耦(应用层随便加,状态层按自己的节奏扩);③ 故障域清晰(Redis 挂了影响登录,不影响静态页)。代价是“集中后的单点风险更大”,所以状态组件必须配冗余、备份与容量规划。
跨篇相关:扩容时数据怎么迁移见 37.3 分片与分区;读写分离与只读副本见 37.4 读写分离;滚动发布对可用性的影响见 55.1 发布策略;本地缓存与分布式缓存的分工见 41.2 本地缓存与分布式缓存;长连接网关的会话保持见 66.1 长连接网关(待补)。