分布式会话与状态管理:Session、JWT、Redis、粘性会话与无状态服务
分布式会话与状态管理解决的是“用户登录后,请求打到任意服务实例时,系统怎样知道他是谁、权限是否还有效、状态放在哪里、实例扩缩容或重启后是否还能继续服务”。它不是简单回答“用 JWT 无状态”或“Session 放 Redis”。
Spring Security 的认证细节见:Spring Security 总览、JWT认证、登录认证。本页从微服务和分布式角度讲清 Session、JWT、Redis Session、粘性会话、本地状态、WebSocket 状态和排查。
学习目标
学完本页要能回答:
- 为什么单机 Session 到多实例后会出问题。
- 粘性会话、Session复制、Redis Session、JWT 分别怎么工作。
- JWT 为什么不是“完全无状态”,权限变更、登出和泄露怎么处理。
- 什么状态可以放本地,什么状态必须外置或可重建。
- WebSocket、长连接、文件上传、验证码、购物车、权限缓存怎样设计。
- 实例扩缩容、重启、负载均衡变化后,登录态为什么会丢。
- 线上出现用户串号、登录反复失效、权限改了不生效时怎么排查。
一、为什么单机 Session 到微服务会出问题
单机应用中,Session 存在当前 JVM 内存里,请求一直打到这台机器就没问题。多实例后,请求可能被负载均衡分到任意实例。
flowchart TD
A["用户登录请求"] --> B["实例A创建Session"]
B --> C["Session只在实例A内存"]
D["下一次业务请求"] --> E["负载均衡到实例B"]
E --> F["实例B找不到Session"]
F --> G["用户被当成未登录"]所以分布式会话要回答两个问题:
- 身份状态放在哪里。
- 任意实例怎样恢复身份和权限。
二、常见方案对比
| 方案 | 原理 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|---|
| 粘性会话 | 同一用户尽量路由到同一实例 | 实现简单 | 实例故障登录丢失,扩容不均 | 小系统、临时方案 |
| Session复制 | 多实例互相复制Session | 对代码透明 | 网络和内存成本高,集群大后复杂 | 小规模集群 |
| Redis Session | Session存到Redis,实例共享 | 服务端可撤销,扩容友好 | Redis成为关键依赖 | 传统后台、同域系统 |
| JWT Access Token | Token携带身份声明和签名 | 服务端横向扩展好 | 撤销、权限变更和泄露处理复杂 | 前后端分离、移动端、网关认证 |
| Opaque Token | Token只是随机ID,服务端查询状态 | 易撤销和管控 | 每次校验要查认证中心或缓存 | 高安全、集中管控 |
没有绝对最优。后台管理系统常用 Session/Redis Session;开放 API、移动端、微服务网关常用短 JWT + Refresh Token + 黑名单或 tokenVersion。
三、Redis Session 怎么工作
flowchart TD
A["用户登录成功"] --> B["服务端生成SessionId"]
B --> C["Session数据写Redis"]
C --> D["浏览器保存Cookie"]
D --> E["后续请求携带SessionId"]
E --> F["任意实例从Redis读取Session"]
F --> G["恢复SecurityContext或用户上下文"]Redis Session 的关键点:
| 点 | 说明 |
|---|---|
| SessionId | 客户端只保存随机ID,不保存完整身份 |
| 服务端状态 | 登录态、权限摘要、过期时间在Redis |
| 主动撤销 | 登出、踢人、改密码可删除Session |
| 过期控制 | Redis TTL 控制会话生命周期 |
| 高可用 | Redis 本身必须高可用并监控延迟 |
风险:
- Redis 慢会导致登录态恢复慢。
- Redis 故障可能造成大面积未登录。
- Session 太大会增加网络和内存成本。
- Cookie 配置错误会导致浏览器不带 SessionId。
四、JWT 怎么工作
JWT 常见于前后端分离和网关统一认证。
flowchart TD
A["用户登录"] --> B["认证服务校验账号密码"]
B --> C["签发Access Token"]
C --> D["客户端保存Token"]
D --> E["请求携带Authorization"]
E --> F["Gateway或服务验签"]
F --> G["校验过期、issuer、audience"]
G --> H["构造用户上下文"]JWT 的本质是“带签名的声明”。普通 JWT 的 payload 是 Base64Url 编码,不是加密,不能放密码、身份证、手机号、密钥等敏感信息。
JWT 常见字段:
| 字段 | 含义 |
|---|---|
sub | 用户主体ID |
iss | 签发方 |
aud | 接收方 |
exp | 过期时间 |
nbf | 不早于该时间生效 |
iat | 签发时间 |
jti | Token唯一ID |
scope | 授权范围 |
tenant_id | 当前租户 |
token_version | 权限变更或改密后的版本 |
五、JWT 不是完全无状态
很多人说 JWT 无状态,是因为服务端不需要保存每个 Access Token 的完整 Session。但生产里仍然要处理撤销、刷新、权限变更、密钥轮换和设备管理,这些都需要服务端状态或版本。
| 问题 | 只用长 JWT 会怎样 | 常见方案 |
|---|---|---|
| 登出 | Token未过期前仍可用 | 短Access Token + 黑名单 |
| 改密码 | 旧Token仍可访问 | tokenVersion 或 passwordChangedAt |
| 权限收回 | 旧Token权限快照仍有效 | 短Token、权限版本、实时查权限 |
| Token泄露 | 攻击者可用到过期 | 短有效期、设备撤销、风控 |
| 密钥轮换 | 老Token验签失败或长期信任旧密钥 | kid + 新旧密钥并存窗口 |
所以更准确的说法是:JWT 可以减少中心化 Session 读取,但不会让认证授权系统完全无状态。
六、状态应该放在哪里
| 状态 | 推荐位置 | 原因 |
|---|---|---|
| 登录态 | Redis Session、JWT、认证中心 | 需要跨实例恢复 |
| 权限缓存 | Redis、本地短缓存、权限中心 | 要支持失效和版本 |
| 验证码 | Redis或专门验证码服务 | 跨实例校验 |
| 购物车 | Redis或数据库 | 用户切实例不丢 |
| 幂等记录 | 数据库或Redis原子结构 | 防重复提交 |
| 文件上传进度 | Redis、对象存储元数据 | 支持断点和多实例 |
| WebSocket连接 | 当前实例内存 + 注册表 | 连接本身绑定实例,但路由信息要外置 |
| 本地缓存 | JVM内存 | 只放可重建、允许短暂旧的数据 |
原则:影响正确性的状态不要只放单个实例内存。单实例内存适合临时、可重建、丢失可接受的状态。
七、粘性会话的边界
粘性会话让同一用户尽量访问同一实例。
flowchart TD
A["用户请求"] --> B["负载均衡按Cookie或IP哈希"]
B --> C["固定到实例A"]
C --> D["实例A本地Session"]
C --> E["实例A故障或扩容重分布"]
E --> F["请求转到实例B"]
F --> G["本地Session丢失"]粘性会话的问题:
- 实例故障时登录态丢失。
- 扩缩容后哈希重分布,用户可能换实例。
- 热用户或大租户会集中到单实例。
- 无法解决服务间调用和多网关实例的状态共享。
所以粘性会话可以作为优化,但不应作为关键登录态唯一方案。
八、WebSocket 和长连接状态
WebSocket 连接天然绑定到某个网关或服务实例。状态管理要分两层:
| 状态 | 放哪里 |
|---|---|
| TCP/WebSocket连接对象 | 当前实例内存 |
| 用户到连接实例映射 | Redis、注册中心或连接路由表 |
| 离线消息 | MQ、数据库、专门消息存储 |
| 在线状态 | Redis带TTL或心跳表 |
flowchart TD
A["用户建立WebSocket"] --> B["连接落到实例A"]
B --> C["实例A保存连接对象"]
B --> D["Redis记录userId到实例A"]
E["服务B要推送消息"] --> F["查询用户连接位置"]
F --> G["转发到实例A或写离线消息"]如果实例 A 宕机,内存连接一定丢失。系统要靠心跳、TTL、重连和离线消息恢复,而不是试图把连接对象复制到其他实例。
九、JDK 8 Demo:Token版本撤销
下面 Demo 演示权限变更或改密码后,怎样用 tokenVersion 让旧 Token 失效。
import java.util.HashMap;
import java.util.Map;
public class TokenVersionDemo {
static final class Token {
final String userId;
final int version;
Token(String userId, int version) {
this.userId = userId;
this.version = version;
}
}
static final class VersionStore {
private final Map<String, Integer> versions = new HashMap<String, Integer>();
int current(String userId) {
Integer v = versions.get(userId);
return v == null ? 1 : v.intValue();
}
void increase(String userId) {
versions.put(userId, current(userId) + 1);
}
}
static boolean verify(Token token, VersionStore store) {
return token.version == store.current(token.userId);
}
public static void main(String[] args) {
VersionStore store = new VersionStore();
Token oldToken = new Token("u1001", store.current("u1001"));
System.out.println("beforeChange=" + verify(oldToken, store));
store.increase("u1001");
System.out.println("afterChange=" + verify(oldToken, store));
}
}输出:
beforeChange=true
afterChange=false真实系统里 tokenVersion 通常存在 Redis 或数据库中。代价是校验 Token 时要查一次版本缓存,所以 JWT 并非完全无状态。
十、生产 Runbook
10.1 用户登录后反复掉线
- 查 Cookie 是否携带、Domain、Path、SameSite、Secure 是否正确。
- 查负载均衡是否粘性变化或实例重启。
- 查 Redis Session 是否过期、被删除、序列化失败。
- 查多个应用是否使用同一 Session 命名空间导致覆盖。
- 查网关和后端是否同时管理不同登录态。
- 查客户端是否刷新 Token 失败。
10.2 权限改了但用户仍有旧权限
- 查权限是否写在 JWT 中且 Token 未过期。
- 查权限缓存是否删除。
- 查 tokenVersion 是否递增。
- 查 Gateway、本地缓存、服务缓存是否各自有旧值。
- 查用户是否有多个角色或多个租户身份。
10.3 用户串号
- 查 ThreadLocal、MDC、SecurityContextHolder 是否 finally 清理。
- 查异步任务是否复用了上一个请求上下文。
- 查 Gateway 是否清洗外部用户 Header。
- 查本地缓存 Key 是否缺少 userId 或 tenantId。
- 查日志是否错误复用了静态变量保存用户。
10.4 Redis Session 故障
- 查 Redis 延迟、连接池、慢命令、内存淘汰。
- 查 Session Key TTL 是否异常缩短。
- 查序列化类版本是否兼容。
- 查是否发布清理脚本误删 Session。
- 确认故障期间是否需要降级为只读或拒绝高风险写。
十一、面试标准回答
分布式会话要解决多实例下任意请求都能恢复用户身份的问题。单机 Session 放 JVM 内存,多实例后请求打到其他实例会丢登录态。常见方案有粘性会话、Session复制、Redis Session、JWT和Opaque Token。粘性会话简单但实例故障和扩缩容会丢;Redis Session 服务端可控、可撤销,但依赖 Redis 高可用;JWT 横向扩展好,但撤销、权限变更、泄露和密钥轮换需要短有效期、Refresh Token、黑名单、tokenVersion或权限版本。影响正确性的状态不要只放单实例内存,本地状态必须可重建或丢失可接受。
十二、本章小结
会话和状态管理的核心不是“有状态还是无状态”的口号,而是明确每类状态的事实源、生命周期、撤销能力、跨实例恢复方式和故障后果。登录态、权限、验证码、购物车、WebSocket、缓存、上传进度都要分别设计,不能统一塞进某个工具里。
