一个周五下午的报警
去年我们团队把一个单体应用拆成了 8 台 Tomcat 节点挂在 Nginx 后面,上线当天下午就收到客诉:"我明明登录了,一刷新就把我踢出去了,再登录又好了,来回踢皮球。"
运维同事第一反应是应用有 bug,翻了半小时日志什么都没找到。我拿账号在测试环境点了几轮之后确认了现象:大约每隔几次请求就会回到登录页,而且有明显的规律——凡是被负载均衡打到另一台机器上的请求,Session 就丢了。
原因其实一句话就能说清:Session 默认存在 Tomcat 的内存里(StandardManager+ConcurrentHashMap),8 台节点各自持有一份互不相通的 Session,用户上一次请求落在节点 A,Session 就写在 A 的内存里;下一次请求被 ip_hash 之外的轮询策略甩到节点 B,B 的内存里查不到这个 sessionId,自然判定未登录。
这不是什么高深的问题,但它是几乎所有团队从单体走向集群时第一个必然撞上的墙。这篇文章把市面上主流的 4 种解法逐一过一遍,讲清楚每种方案在 Spring Session 3.x、Redis 7.x 时代的真实写法,最后重点还原一次我们因为序列化问题排查了两天的事故现场。
先把问题定义清楚
HTTP 是无状态协议,"登录态"本质上是一段存在服务端的状态数据,客户端只拿一把钥匙(JSESSIONIDCookie)。单机时代这套机制由 Servlet 容器内置实现,集群化之后问题变成了:
Session 数据放在哪里,才能让任意一台节点都能用同一把钥匙取到同一份状态?
围绕这个问题,业界的方案可以归为四类:
| 方案 | 核心思路 | 适用规模 | 主要代价 |
|---|---|---|---|
| Session Sticky(粘性会话) | 让同一用户永远打到同一台机器 | 3~5 台小集群 | 负载不均、节点宕机即丢会话 |
| 容器级 Session 复制 | 节点间广播同步 Session | 4~6 台内网小集群 | 网络风暴、内存翻倍 |
| 集中存储(Redis) | Session 统一放外部存储 | 任意规模 | 多一次网络 IO、序列化开销 |
| 干脆不用 Session | Token / JWT 无状态化 | 前后端分离、跨端 | 注销难、无法即时踢人 |
下面逐个拆开看,重点放在第三种,因为它是当前大多数中大型团队的主流选择。
方案一:Session Sticky——治标不治本的止痛药
最省事的做法是在 Nginx 上配 ip_hash:
upstream 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 17):
<dependency> <groupId>org.springframework.session</groupId> <artifactId>spring-session-data-redis</artifactId> <version>3.2.2</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> <version>3.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.namespace,3.x 迁移到了spring.session.redis组下),它决定了 Redis 里所有 key 的公共前缀,多套环境共用一个 Redis 集群时靠它隔离,比如mall:session和admin:session。
Spring Session 最"魔法"的一点是:业务代码一行都不用改。你还是照常写:
@PostMapping("/login") public String login(String username, HttpServletRequest request) { // 正常调用 HttpSession API,Spring 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 属性 Map<String, 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 Map<String, 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 TypeReference<Map<String, 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 段(读 Redis):loadFromRedis用opsForValue().get()一次网络往返取出整个 Session 的 JSON。注意catch (Exception)里返回空 Map 而不是抛错——如果 Redis 里这条数据损坏(后面会讲我们踩过的真实坑),降级成"新会话"让用户重新登录,比抛 500 页面体验好得多。
第 3 段(包装 Request):SessionWrappedRequest继承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.x):
@Configuration @EnableRedisHttpSession( maxInactiveIntervalInSeconds = 1800, redisNamespace = "mall:session", flushMode = FlushMode.IMMEDIATE ) public class SessionConfig { @Bean public RedisSerializer<Object> sessionRedisSerializer() { // 关键:用 JSON 序列化替代 JDK 默认序列化 GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer(); return serializer; } @Bean public RedisTemplate<Object, Object> sessionRedisTemplate( RedisConnectionFactory connectionFactory) { RedisTemplate<Object, 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() { // 跨子域名共享 Session:a.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 读写频率下这点开销可以忽略)。
CookieSerializer:setDomainName("mall.com")让 Cookie 在所有子域名下可见,实现"主站登录、子站免登"。setSameSite("Lax")兼顾了 CSRF 防护和正常跳转。
事故复盘:一次排查了两天的序列化惨案
讲一个我们团队 2024 年真实的线上事故,比任何理论都更能说明"序列化器选型"这件事的分量。
背景:商城项目,8 节点,Spring Session 2.7.4(当时还是 Spring Boot 2.7 系),JDK 11,Redis 6.2。购物车对象放进 Session,类大概长这样:
public class Cart implements Serializable { private static final long serialVersionUID = 1L; private Long userId; private List<CartItem> 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没有固定 serialVersionUID,JDK 会按字段、方法签名自动计算一个 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.5):
@Component 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放用户 ID,claim放扩展信息,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,务必设置
maxmemory+allkeys-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 + Redis,Redis 主从切换瞬间出现了 3 秒不可用。切完之后有用户反馈"登录丢了",但也有用户反馈"没掉线"。同样是 Redis 不可用,为什么表现不一致?提示:从"读写分离下 Session 写到了哪个节点""lettuce 与 Jedis 对主从切换的自动重连行为差异""Session 滑动续期在哪一侧发生"三个角度想。
如果你在迁移 Session 方案的过程中遇到过别的坑(比如 K8s Ingress 的 sticky 会话注解nginx.ingress.kubernetes.io/affinity,或者 SameSite=Strict 导致的支付回调丢会话),评论区聊聊,下一篇我打算整理一份"Session 相关 20 个高频面试题+生产决策"清单。