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 / 对象存储
(可随意增删)      (需要独立的高可用、备份、容量规划)

这带来两个后果:

  1. 应用层扩容变简单,但下游压力集中:应用从 10 台扩到 100 台,DB 连接数也涨 10 倍,连接池/最大连接数成了新瓶颈(见 34.4 优化手段)。
  2. 状态组件的可用性决定整体可用性:应用层再弹性,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 长连接网关(待补)。