资讯动态

从单机登录到 8 台节点共享登录态:分布式 Session 方案踩坑实录

发布时间:2026/9/23 16:32:25 来源:尧图企业网站定制
一个周五下午的报警去年我们团队把一个单体应用拆成了 8 台 Tomcat 节点挂在 Nginx 后面上线当天下午就收到客诉我明明登录了一刷新就把我踢出去了再登录又好了来回踢皮球。运维同事第一反应是应用有 bug翻了半小时日志什么都没找到。我拿账号在测试环境点了几轮之后确认了现象大约每隔几次请求就会回到登录页而且有明显的规律——凡是被负载均衡打到另一台机器上的请求Session 就丢了。原因其实一句话就能说清Session 默认存在 Tomcat 的内存里StandardManagerConcurrentHashMap8 台节点各自持有一份互不相通的 Session用户上一次请求落在节点 ASession 就写在 A 的内存里下一次请求被 ip_hash 之外的轮询策略甩到节点 BB 的内存里查不到这个 sessionId自然判定未登录。这不是什么高深的问题但它是几乎所有团队从单体走向集群时第一个必然撞上的墙。这篇文章把市面上主流的 4 种解法逐一过一遍讲清楚每种方案在 Spring Session 3.x、Redis 7.x 时代的真实写法最后重点还原一次我们因为序列化问题排查了两天的事故现场。先把问题定义清楚HTTP 是无状态协议登录态本质上是一段存在服务端的状态数据客户端只拿一把钥匙JSESSIONIDCookie。单机时代这套机制由 Servlet 容器内置实现集群化之后问题变成了Session 数据放在哪里才能让任意一台节点都能用同一把钥匙取到同一份状态围绕这个问题业界的方案可以归为四类方案核心思路适用规模主要代价Session Sticky粘性会话让同一用户永远打到同一台机器3~5 台小集群负载不均、节点宕机即丢会话容器级 Session 复制节点间广播同步 Session4~6 台内网小集群网络风暴、内存翻倍集中存储RedisSession 统一放外部存储任意规模多一次网络 IO、序列化开销干脆不用 SessionToken / JWT 无状态化前后端分离、跨端注销难、无法即时踢人下面逐个拆开看重点放在第三种因为它是当前大多数中大型团队的主流选择。方案一Session Sticky——治标不治本的止痛药最省事的做法是在 Nginx 上配 ip_hashupstream app_cluster { ip_hash; server 10.0.1.11:8080; server 10.0.1.12:8080; server 10.0.1.13:8080; }同一个客户端 IP 会被 hash 到固定的后端节点Session 天然不需要共享。但我在这里有一个非常明确的观点除非集群只有两三台机器且没有条件引入 Redis否则我不建议把 ip_hash 当成长期方案。理由有三负载必然倾斜。公司出口 NAT 场景下几百个用户可能共享同一个公网 IP全被压到一台机器上其他节点在旁边看戏。节点故障 会话雪崩。被 hash 到宕机节点的用户全部被踢下线而且 Nginx 会把这些用户 rehash 到别的节点服务看起来恢复了用户却在骂街。发布必然丢会话。滚动发布时被重启节点上的所有在线用户集体掉线。如果你的系统要求发版用户无感知Sticky 直接出局。一个可以接受的折中是ip_hash 较短的 Session 有效期比如 30 分钟让故障面可控。但它只适合当过渡方案。方案二Tomcat Session 复制——教科书里的方案生产里的坑Tomcat 集群原生支持 Session 复制在server.xml里加Cluster配置用 DeltaManager 把 Session 增量通过组播UDP multicast广播给所有节点。这个方案我在一个内部管理系统里实际用过一次4 台节点体感结论4 台节点、Session 平均 20KB、QPS 500 时复制流量已经是每秒几十 MB 的 UDP 广播每加一台机器网络开销呈 O(n) 增长总内存占用呈 n 倍增长——每个节点都存全量 Session。组播依赖交换机配置跨机房、Docker/K8s 网络下组播包经常被默默丢掉表现为部分节点 Session 不一致排查起来极其恶心。官方文档自己也写了当集群规模增长时推荐使用 BackupManager只备份到一两个节点而不是 DeltaManager。结论它更适合 4 台以内、同机房、物理网络可控的老式内网部署。在容器化和云环境为主流的今天这个方案基本只存在于面试题里。方案三集中存储 Spring Session本文主角把 Session 从容器内存搬到一个所有节点共享的存储里一般是 Redis。Java 生态里最成熟的实现是 Spring Session。先看依赖Spring Session 3.x 对应 Spring Boot 3.x注意 3.x 最低要求 JDK 17dependency groupIdorg.springframework.session/groupId artifactIdspring-session-data-redis/artifactId version3.2.2/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId version3.2.5/version /dependency然后是配置spring: data: redis: host: 10.0.2.30 port: 6379 password: ${REDIS_PASSWORD} lettuce: pool: max-active: 32 max-idle: 8 session: store-type: redis redis: namespace: mall:session flush-mode: on_save server: servlet: session: timeout: 30m注意spring.session.redis.namespace老版本叫spring.session.redis.namespace前缀配置2.x 时代是spring.session.redis.namespace3.x 迁移到了spring.session.redis组下它决定了 Redis 里所有 key 的公共前缀多套环境共用一个 Redis 集群时靠它隔离比如mall:session和admin:session。Spring Session 最魔法的一点是业务代码一行都不用改。你还是照常写PostMapping(/login) public String login(String username, HttpServletRequest request) { // 正常调用 HttpSession APISpring Session 在背后把它指向 Redis HttpSession session request.getSession(); session.setAttribute(loginUser, username); session.setAttribute(loginTime, System.currentTimeMillis()); return redirect:/home; }为什么request.getSession()拿到的就变成 Redis 里存的了看一下它的工作原理Spring Session 注册了一个SessionRepositoryFilter在 Servlet Filter 链的最前面优先级高于 Spring Security 的 Filter它把原生HttpServletRequest包装成SessionRepositoryRequestWrapper。你后续所有对 session 的读写都被拦截转交给RedisIndexedSessionRepository或者普通的RedisSessionRepository真正落库的 Redis key 长这样mall:session:7e8b6c2a-1d3f-4b1a-9f5e-2c1b3a5f7d9evalue 是 hash 结构field 是每个属性的 key并且带 TTL 自动过期默认 30 分钟每次请求会滑动续期。手写一个不依赖 Spring Session 的版本理解会更深Spring Session 屏蔽了太多细节我建议每个后端都至少手写一次用 Redis 存 Session的 Filter你才能真正理解它每一步在做什么public class RedisSessionFilter implements Filter { private final StringRedisTemplate redisTemplate; private final ObjectMapper objectMapper new ObjectMapper(); // Session 默认有效期30 分钟单位秒 private static final long SESSION_TTL_SECONDS 1800L; private static final String SESSION_COOKIE MY_SESSION_ID; private static final String REDIS_KEY_PREFIX myapp:session:; public RedisSessionFilter(StringRedisTemplate redisTemplate) { this.redisTemplate redisTemplate; } Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req (HttpServletRequest) request; HttpServletResponse resp (HttpServletResponse) response; // 1. 从 Cookie 中取 sessionId没有则生成新的 String sessionId extractSessionId(req); if (sessionId null) { sessionId UUID.randomUUID().toString(); writeSessionCookie(resp, sessionId); } // 2. 从 Redis 读出该用户的所有 Session 属性 MapString, Object attributes loadFromRedis(sessionId); // 3. 包装 request把 attributes 暴露为 attribute HttpServletRequest wrapped new SessionWrappedRequest(req, sessionId, attributes); try { chain.doFilter(wrapped, response); } finally { // 4. 请求结束后把改动写回 Redis并滑动续期 TTL if (!((SessionWrappedRequest) wrapped).isDirty()) { return; } String json objectMapper.writeValueAsString(attributes); redisTemplate.opsForValue().set( REDIS_KEY_PREFIX sessionId, json, Duration.ofSeconds(SESSION_TTL_SECONDS)); } } private MapString, Object loadFromRedis(String sessionId) { try { String json redisTemplate.opsForValue() .get(REDIS_KEY_PREFIX sessionId); if (json null) { return new ConcurrentHashMap(); } return objectMapper.readValue(json, new TypeReferenceMapString, Object() {}); } catch (Exception e) { // 反序列化失败不致命视作新会话避免拖垮请求 return new ConcurrentHashMap(); } } private String extractSessionId(HttpServletRequest req) { if (req.getCookies() null) { return null; } for (Cookie c : req.getCookies()) { if (SESSION_COOKIE.equals(c.getName())) { return c.getValue(); } } return null; } private void writeSessionCookie(HttpServletResponse resp, String sessionId) { Cookie cookie new Cookie(SESSION_COOKIE, sessionId); cookie.setHttpOnly(true); cookie.setPath(/); cookie.setMaxAge(-1); // 会话级 Cookie关浏览器即失效 resp.addCookie(cookie); } }逐段解释一下这段代码的关键设计第 1 段Cookie 处理extractSessionId遍历 Cookie 找会话钥匙取不到就用UUID.randomUUID()生成新的并writeSessionCookie写回浏览器。这里maxAge -1表示会话级 Cookie浏览器关闭即消失但 Redis 里的数据还活着直到 TTL 到期——两者生命周期解耦这是集中存储方案的天然特性也是它比容器内 Session 更可控的地方。第 2 段读 RedisloadFromRedis用opsForValue().get()一次网络往返取出整个 Session 的 JSON。注意catch (Exception)里返回空 Map 而不是抛错——如果 Redis 里这条数据损坏后面会讲我们踩过的真实坑降级成新会话让用户重新登录比抛 500 页面体验好得多。第 3 段包装 RequestSessionWrappedRequest继承HttpServletRequestWrapper重写getSession()和setAttribute()/getAttribute()setAttribute时顺带标记dirty true。这其实就是 Spring SessionSessionRepositoryRequestWrapper的简化版。第 4 段写回 续期finally块里只在dirty时才写 Redis——每次写都意味着一次网络 IO 加 TTL 重置没改就不写在高并发下能省掉可观的 Redis 压力。Duration.ofSeconds(SESSION_TTL_SECONDS)每次都重设 TTL实现了30 分钟滑动过期语义。这段代码有个刻意简化的地方它把所有属性序列化成一整个 JSON 字符串。Spring Session 的RedisSessionRepository实际是 hash 结构按 field 存的好处是改一个属性不用重写整个 Session还能配合RedisIndexedSessionRepository做过期事件监听。生产级的 Spring Session 配置示例实际项目里我们不会手写 Filter而是用 Spring Session 并做一些定制。下面是我们在电商项目里的真实配置类Spring Session 3.2.xConfiguration EnableRedisHttpSession( maxInactiveIntervalInSeconds 1800, redisNamespace mall:session, flushMode FlushMode.IMMEDIATE ) public class SessionConfig { Bean public RedisSerializerObject sessionRedisSerializer() { // 关键用 JSON 序列化替代 JDK 默认序列化 GenericJackson2JsonRedisSerializer serializer new GenericJackson2JsonRedisSerializer(); return serializer; } Bean public RedisTemplateObject, Object sessionRedisTemplate( RedisConnectionFactory connectionFactory) { RedisTemplateObject, Object template new RedisTemplate(); template.setConnectionFactory(connectionFactory); template.setKeySerializer(new StringRedisSerializer()); template.setHashKeySerializer(new StringRedisSerializer()); template.setHashValueSerializer(sessionRedisSerializer()); template.afterPropertiesSet(); return template; } Bean public CookieSerializer cookieSerializer() { // 跨子域名共享 Sessiona.mall.com 登录后 b.mall.com 也生效 DefaultCookieSerializer serializer new DefaultCookieSerializer(); serializer.setCookieName(MALL_SESSION); serializer.setDomainName(mall.com); serializer.setCookiePath(/); serializer.setUseHttpOnlyCookie(true); serializer.setSameSite(Lax); return serializer; } }三个 Bean 分别说EnableRedisHttpSession的三个参数maxInactiveIntervalInSeconds 1800即 30 分钟不活跃过期注意 Spring Session 2.x 起单位是秒1.x 的部分文档写的是分钟老资料容易看错redisNamespace是 Redis key 前缀flushMode FlushMode.IMMEDIATE表示属性一改就立刻写 Redis——默认的ON_SAVE是响应提交时统一写吞吐更好但如果你的业务里有写完 session 立刻异步通知另一个服务读取的场景IMMEDIATE 才是正确选择这是我们真金白银换来的教训。序列化器 Bean这段是最重要的下文的踩坑案例就出在这里。JDK 默认序列化JdkSerializationRedisSerializer是 Spring Session 的默认值它要求所有放进 Session 的对象都实现Serializable且所有节点类路径上有完全一致的类版本。换成GenericJackson2JsonRedisSerializer后Session 内容变成可读的 JSON跨语言、跨版本、可调试代价是性能略低JSON 反序列化比 JDK 二进制慢实测单次约慢 2~3 倍但 Session 读写频率下这点开销可以忽略。CookieSerializersetDomainName(mall.com)让 Cookie 在所有子域名下可见实现主站登录、子站免登。setSameSite(Lax)兼顾了 CSRF 防护和正常跳转。事故复盘一次排查了两天的序列化惨案讲一个我们团队 2024 年真实的线上事故比任何理论都更能说明序列化器选型这件事的分量。背景商城项目8 节点Spring Session 2.7.4当时还是 Spring Boot 2.7 系JDK 11Redis 6.2。购物车对象放进 Session类大概长这样public class Cart implements Serializable { private static final long serialVersionUID 1L; private Long userId; private ListCartItem items new ArrayList(); private LocalDateTime updatedAt; // getter / setter 省略 }现象某次迭代上线后监控出现零星的SerializationException用户反馈购物车加着加着就空了。诡异的点在于错误率只有 0.3% 左右绝大多数请求完全正常。排查过程第一反应看日志堆栈异常是java.io.InvalidClassException: com.mall.session.Cart; local class incompatible: stream classdesc serialVersionUID XXX, local class serialVersionUID YYY——典型的serialVersionUID不一致。检查代码serialVersionUID 1L明明写死了怎么会不一致用serialver工具验证了发布产物里的类确实是 1L。转机出现在问发布同事这次上线是滚动发布新旧两个版本同时在跑。再细查发现这次迭代给CartItem类加了一个新字段而且CartItem上没有显式声明serialVersionUID真相大白旧版本节点把Cart含旧结构CartItem以 JDK 序列化写进 Redis滚动发布期间新版本节点从 Redis 反序列化时CartItem没有固定 serialVersionUIDJDK 会按字段、方法签名自动计算一个 hash 当版本号——加了一个字段自动算出的 serialVersionUID 变了于是InvalidClassException。而我们手写的catch把异常吞掉返回了空购物车当时是照着反序列化失败降级写的用户看到的就是购物车清空了。为什么只有 0.3%因为只有 Session 数据恰好在新旧节点之间交叉读写的那部分用户发布窗口期活跃用户才会中招。修复措施三层保险短期给所有进 Session 的类补上显式serialVersionUID并写进团队代码规约Checkstyle 规则强制中期把序列化器从 JDK 默认换成GenericJackson2JsonRedisSerializer升级到 Spring Session 3.x 时完成JSON 序列化对新增字段天然宽容——多了忽略、少了给默认值长期把购物车这类会变的结构从 Session 里挪出去改存 Redis 独立 key 业务 ID。Session 里只放userId、角色这类极少变化的轻量字段。这次事故给我的最大教训是一句话Session 里的数据结构本质上是一个跨版本、跨节点的存储契约而绝大多数团队在定义 DTO 时根本没这个意识。JDK 序列化版本敏感的特性放大了这个风险而 JSON 序列化把它缓解了一个数量级。另一个高频坑Cookie 域顺带说一个几乎人人会踩的配置坑。我们早期配置写的是serializer.setDomainName(www.mall.com);结果用户在www.mall.com登录后跳到pay.mall.com支付页又被要求登录。原因Cookie 的 domain 精确匹配www.mall.com时pay.mall.com的请求根本不会带上这个 Cookie。正确写法是设为父域mall.com这样所有子域共享。反过来还有第二种坑本地联调时两个项目都把 Cookie 写到localhost不同应用的 Session Cookie 互相覆盖表现为登录 A 系统把 B 系统踢下线——解法是给每套应用设置不同的cookieName。方案四彻底放弃 Session——JWT 是万能药吗最后一个流派干脆消灭服务端状态登录后签发 JWT客户端每次请求放在Authorization: Bearer xxx头里服务端只验签不查库。先给一个 JWT 工具的简化实现基于 jjwt 0.12.5Component public class JwtUtil { // 生产环境从配置中心读取禁止硬编码 Value(${jwt.secret}) private String secret; private static final long EXPIRE_MILLIS 2 * 60 * 60 * 1000L; // 2 小时 private SecretKey key() { return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); } public String createToken(Long userId, String username) { return Jwts.builder() .subject(String.valueOf(userId)) .claim(username, username) .issuedAt(new Date()) .expiration(new Date(System.currentTimeMillis() EXPIRE_MILLIS)) .signWith(key()) .compact(); } public Claims parseToken(String token) { return Jwts.parser() .verifyWith(key()) .build() .parseSignedClaims(token) // 验签 校验过期失败抛异常 .getPayload(); } }逐段说明createToken里subject放用户 IDclaim放扩展信息signWith用 HMAC-SHA256 签名密钥长度必须 ≥ 256 bit否则 jjwt 直接抛异常parseToken里parseSignedClaims一步完成验签和过期校验签名被篡改或exp过期都会抛JwtException调用方统一在全局异常处理器里转成 401。JWT 的优势非常突出完全无状态水平扩容零成本天然适配 App/小程序/开放 API 等跨端场景。但我的观点是它只适合特定形态的系统盲目上 JWT 会引进更麻烦的问题注销和踢人几乎做不到。Token 签发后在过期前始终有效服务端不存东西这个优点在要把某个被盗号用户立即踢下线的需求面前变成致命缺陷。常见的补救是维护一个 Redis 黑名单——那你等于又把状态引回来了还多了一套 Token 签发逻辑。Token 体积大。一个带 5 个 claim 的 JWT 约 300~500 字节而JSESSIONIDCookie 只有几十字节。移动端弱网下每个请求都背着它不是免费的。续期体验差。传统 Session 的滑动过期是天然的JWT 需要引入 refreshToken 双 Token 机制复杂度陡增。所以我的选型判断是纯 Web 后台管理系统用 Spring Session Redis享受滑动过期和改一行配置踢人的运维便利面向 App/开放平台的开放接口层用 JWT 短有效期 刷新令牌。两者并不互斥很多系统的真实架构就是网页端 Session、API 端 JWT 双轨并行。性能与高可用别让 Redis 成为新的单点把 Session 搬进 Redis 之后容灾的重心就转移了。几个实践数字和经验单次开销一次 Session 读 一次写 2 次 Redis 往返。内网 Redis 7.x 单次 P99 在 1ms 以内对绝大多数 Web 请求本身 50ms可以忽略但如果你的接口 P99 要求 10ms 且 QPS 过 5 万就要认真评估考虑flushMode: ON_SAVE合并写、甚至本地一级缓存Caffeine 30 秒 Redis 二级。Redis 挂了怎么办Spring Session 默认直接抛异常让请求 500。我们的做法是配置哨兵Sentinel或 Cluster 模式spring.data.redis.sentinel.master等配置并且在网关层对RedisConnectionFailureException做 5 分钟静默降级降级期间新用户无法登录可接受已登录用户的请求因取不到 Session 被拒——所以更稳的做法是Redis 不可用时不强制登录态校验的白名单路由机制把损失控制在最小范围。内存容量按单 Session 5KB、100 万在线用户算约 5GB务必设置maxmemoryallkeys-lru之外更合适的策略——Session 场景建议volatile-ttl优先淘汰带 TTL 且快过期的 key避免 LRU 把活跃用户的 Session 挤掉。主流方案怎么选一张决策表┌─ 只有 2~3 台、无 Redis ──→ ip_hash 过渡 │ 集群化后的登录态 ──────┼─ 内网老系统、≤6 台 ──────→ 容器 Session 复制谨慎 │ ├─ 常规 Web 系统 ─────────→ Spring Session Redis默认推荐 │ └─ 前后端分离 / 多端 / API → JWT refreshToken我的默认推荐不确定就用 Spring Session Redis。它在改造成本低业务代码零改动和运维可控TTL、命名空间、可人工排查数据之间取得了较好的平衡而且从 Sticky、容器复制迁移过去几乎是平滑的。小结Session Sticky 是止痛药负载倾斜和发布掉线是硬伤只配当过渡方案容器 Session 复制在网络和内存上都是 O(n) 开销容器化时代基本出局Spring Session Redis 是当前中大型团队的事实标准重点盯住序列化器选型、Cookie 域配置、flushMode三个易错点我们那次 0.3% 错误率的购物车清空事故根源是 JDK 序列化对类结构变化的敏感 滚动发布的版本交叉JSON 序列化和Session 只放轻量字段是双层保险JWT 不是万能药踢人难这个需求会逼你把状态又加回来按端选型而不是按潮流选型。留一个思考题你的系统正在用 Spring Session RedisRedis 主从切换瞬间出现了 3 秒不可用。切完之后有用户反馈登录丢了但也有用户反馈没掉线。同样是 Redis 不可用为什么表现不一致提示从读写分离下 Session 写到了哪个节点lettuce 与 Jedis 对主从切换的自动重连行为差异Session 滑动续期在哪一侧发生三个角度想。如果你在迁移 Session 方案的过程中遇到过别的坑比如 K8s Ingress 的 sticky 会话注解nginx.ingress.kubernetes.io/affinity或者 SameSiteStrict 导致的支付回调丢会话评论区聊聊下一篇我打算整理一份Session 相关 20 个高频面试题生产决策清单。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价