从单机登录到 8 台节点共享登录态:分布式 Session 方案踩坑实录
2026/9/23 16:32:17 网站建设 项目流程

一个周五下午的报警

去年我们团队把一个单体应用拆成了 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 复制节点间广播同步 Session4~6 台内网小集群网络风暴、内存翻倍
集中存储(Redis)Session 统一放外部存储任意规模多一次网络 IO、序列化开销
干脆不用 SessionToken / 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 当成长期方案。理由有三:

  1. 负载必然倾斜。公司出口 NAT 场景下,几百个用户可能共享同一个公网 IP,全被压到一台机器上,其他节点在旁边看戏。
  2. 节点故障 = 会话雪崩。被 hash 到宕机节点的用户全部被踢下线,而且 Nginx 会把这些用户 rehash 到别的节点,服务"看起来恢复了",用户却在骂街。
  3. 发布必然丢会话。滚动发布时,被重启节点上的所有在线用户集体掉线。如果你的系统要求"发版用户无感知",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:sessionadmin: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-2c1b3a5f7d9e

value 是 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)loadFromRedisopsForValue().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 读写频率下这点开销可以忽略)。

CookieSerializersetDomainName("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% 左右,绝大多数请求完全正常。

排查过程

  1. 第一反应看日志堆栈,异常是java.io.InvalidClassException: com.mall.session.Cart; local class incompatible: stream classdesc serialVersionUID = XXX, local class serialVersionUID = YYY——典型的serialVersionUID不一致。
  2. 检查代码,serialVersionUID = 1L明明写死了,怎么会不一致?用serialver工具验证了发布产物里的类,确实是 1L。
  3. 转机出现在问发布同事:这次上线是滚动发布,新旧两个版本同时在跑。再细查发现,这次迭代给CartItem加了一个新字段,而且CartItem上没有显式声明serialVersionUID
  4. 真相大白:旧版本节点把Cart(含旧结构CartItem)以 JDK 序列化写进 Redis;滚动发布期间,新版本节点从 Redis 反序列化时,CartItem没有固定 serialVersionUID,JDK 会按字段、方法签名自动计算一个 hash 当版本号——加了一个字段,自动算出的 serialVersionUID 变了,于是InvalidClassException。而我们手写的catch把异常吞掉返回了空购物车(当时是照着"反序列化失败降级"写的),用户看到的就是"购物车清空了"。
  5. 为什么只有 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(); } }

逐段说明:createTokensubject放用户 ID,claim放扩展信息,signWith用 HMAC-SHA256 签名(密钥长度必须 ≥ 256 bit,否则 jjwt 直接抛异常);parseTokenparseSignedClaims一步完成验签和过期校验,签名被篡改或exp过期都会抛JwtException,调用方统一在全局异常处理器里转成 401。

JWT 的优势非常突出:完全无状态,水平扩容零成本,天然适配 App/小程序/开放 API 等跨端场景。但我的观点是它只适合特定形态的系统,盲目上 JWT 会引进更麻烦的问题:

  1. 注销和踢人几乎做不到。Token 签发后在过期前始终有效,服务端"不存东西"这个优点在"要把某个被盗号用户立即踢下线"的需求面前变成致命缺陷。常见的补救是维护一个 Redis 黑名单——那你等于又把状态引回来了,还多了一套 Token 签发逻辑。
  2. Token 体积大。一个带 5 个 claim 的 JWT 约 300~500 字节,而JSESSIONIDCookie 只有几十字节。移动端弱网下每个请求都背着它,不是免费的。
  3. 续期体验差。传统 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 个高频面试题+生产决策"清单。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询