Skip to content

分布式会话与状态管理:Session、JWT、Redis、粘性会话与无状态服务

分布式会话与状态管理解决的是“用户登录后,请求打到任意服务实例时,系统怎样知道他是谁、权限是否还有效、状态放在哪里、实例扩缩容或重启后是否还能继续服务”。它不是简单回答“用 JWT 无状态”或“Session 放 Redis”。

Spring Security 的认证细节见:Spring Security 总览JWT认证登录认证。本页从微服务和分布式角度讲清 Session、JWT、Redis Session、粘性会话、本地状态、WebSocket 状态和排查。

学习目标

学完本页要能回答:

  1. 为什么单机 Session 到多实例后会出问题。
  2. 粘性会话、Session复制、Redis Session、JWT 分别怎么工作。
  3. JWT 为什么不是“完全无状态”,权限变更、登出和泄露怎么处理。
  4. 什么状态可以放本地,什么状态必须外置或可重建。
  5. WebSocket、长连接、文件上传、验证码、购物车、权限缓存怎样设计。
  6. 实例扩缩容、重启、负载均衡变化后,登录态为什么会丢。
  7. 线上出现用户串号、登录反复失效、权限改了不生效时怎么排查。

一、为什么单机 Session 到微服务会出问题

单机应用中,Session 存在当前 JVM 内存里,请求一直打到这台机器就没问题。多实例后,请求可能被负载均衡分到任意实例。

mermaid
flowchart TD
    A["用户登录请求"] --> B["实例A创建Session"]
    B --> C["Session只在实例A内存"]
    D["下一次业务请求"] --> E["负载均衡到实例B"]
    E --> F["实例B找不到Session"]
    F --> G["用户被当成未登录"]

所以分布式会话要回答两个问题:

  1. 身份状态放在哪里。
  2. 任意实例怎样恢复身份和权限。

二、常见方案对比

方案原理优点缺点适合场景
粘性会话同一用户尽量路由到同一实例实现简单实例故障登录丢失,扩容不均小系统、临时方案
Session复制多实例互相复制Session对代码透明网络和内存成本高,集群大后复杂小规模集群
Redis SessionSession存到Redis,实例共享服务端可撤销,扩容友好Redis成为关键依赖传统后台、同域系统
JWT Access TokenToken携带身份声明和签名服务端横向扩展好撤销、权限变更和泄露处理复杂前后端分离、移动端、网关认证
Opaque TokenToken只是随机ID,服务端查询状态易撤销和管控每次校验要查认证中心或缓存高安全、集中管控

没有绝对最优。后台管理系统常用 Session/Redis Session;开放 API、移动端、微服务网关常用短 JWT + Refresh Token + 黑名单或 tokenVersion。

三、Redis Session 怎么工作

mermaid
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 本身必须高可用并监控延迟

风险:

  1. Redis 慢会导致登录态恢复慢。
  2. Redis 故障可能造成大面积未登录。
  3. Session 太大会增加网络和内存成本。
  4. Cookie 配置错误会导致浏览器不带 SessionId。

四、JWT 怎么工作

JWT 常见于前后端分离和网关统一认证。

mermaid
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签发时间
jtiToken唯一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内存只放可重建、允许短暂旧的数据

原则:影响正确性的状态不要只放单个实例内存。单实例内存适合临时、可重建、丢失可接受的状态。

七、粘性会话的边界

粘性会话让同一用户尽量访问同一实例。

mermaid
flowchart TD
    A["用户请求"] --> B["负载均衡按Cookie或IP哈希"]
    B --> C["固定到实例A"]
    C --> D["实例A本地Session"]
    C --> E["实例A故障或扩容重分布"]
    E --> F["请求转到实例B"]
    F --> G["本地Session丢失"]

粘性会话的问题:

  1. 实例故障时登录态丢失。
  2. 扩缩容后哈希重分布,用户可能换实例。
  3. 热用户或大租户会集中到单实例。
  4. 无法解决服务间调用和多网关实例的状态共享。

所以粘性会话可以作为优化,但不应作为关键登录态唯一方案。

八、WebSocket 和长连接状态

WebSocket 连接天然绑定到某个网关或服务实例。状态管理要分两层:

状态放哪里
TCP/WebSocket连接对象当前实例内存
用户到连接实例映射Redis、注册中心或连接路由表
离线消息MQ、数据库、专门消息存储
在线状态Redis带TTL或心跳表
mermaid
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 失效。

java
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));
    }
}

输出:

text
beforeChange=true
afterChange=false

真实系统里 tokenVersion 通常存在 Redis 或数据库中。代价是校验 Token 时要查一次版本缓存,所以 JWT 并非完全无状态。

十、生产 Runbook

10.1 用户登录后反复掉线

  1. 查 Cookie 是否携带、Domain、Path、SameSite、Secure 是否正确。
  2. 查负载均衡是否粘性变化或实例重启。
  3. 查 Redis Session 是否过期、被删除、序列化失败。
  4. 查多个应用是否使用同一 Session 命名空间导致覆盖。
  5. 查网关和后端是否同时管理不同登录态。
  6. 查客户端是否刷新 Token 失败。

10.2 权限改了但用户仍有旧权限

  1. 查权限是否写在 JWT 中且 Token 未过期。
  2. 查权限缓存是否删除。
  3. 查 tokenVersion 是否递增。
  4. 查 Gateway、本地缓存、服务缓存是否各自有旧值。
  5. 查用户是否有多个角色或多个租户身份。

10.3 用户串号

  1. 查 ThreadLocal、MDC、SecurityContextHolder 是否 finally 清理。
  2. 查异步任务是否复用了上一个请求上下文。
  3. 查 Gateway 是否清洗外部用户 Header。
  4. 查本地缓存 Key 是否缺少 userId 或 tenantId。
  5. 查日志是否错误复用了静态变量保存用户。

10.4 Redis Session 故障

  1. 查 Redis 延迟、连接池、慢命令、内存淘汰。
  2. 查 Session Key TTL 是否异常缩短。
  3. 查序列化类版本是否兼容。
  4. 查是否发布清理脚本误删 Session。
  5. 确认故障期间是否需要降级为只读或拒绝高风险写。

十一、面试标准回答

分布式会话要解决多实例下任意请求都能恢复用户身份的问题。单机 Session 放 JVM 内存,多实例后请求打到其他实例会丢登录态。常见方案有粘性会话、Session复制、Redis Session、JWT和Opaque Token。粘性会话简单但实例故障和扩缩容会丢;Redis Session 服务端可控、可撤销,但依赖 Redis 高可用;JWT 横向扩展好,但撤销、权限变更、泄露和密钥轮换需要短有效期、Refresh Token、黑名单、tokenVersion或权限版本。影响正确性的状态不要只放单实例内存,本地状态必须可重建或丢失可接受。

十二、本章小结

会话和状态管理的核心不是“有状态还是无状态”的口号,而是明确每类状态的事实源、生命周期、撤销能力、跨实例恢复方式和故障后果。登录态、权限、验证码、购物车、WebSocket、缓存、上传进度都要分别设计,不能统一塞进某个工具里。