资讯动态

Spring Boot分布式部署Session共享实战:用Redis解决多实例会话丢失问题

发布时间:2026/10/13 14:04:37 来源:尧图企业网站定制
第 4-3 课使用 Redis 实现 Session 共享这段时间在给团队做 Spring Boot 项目改造把原来一个单节点跑得好好的系统拆成多实例部署刚拆完就翻车了——用户在 A 服务器上登录成功刷新一下被负载均衡转发到 B 服务器Session 直接丢了又重新跳回登录页。这种问题在微服务化的过程中太典型今天这篇就完整讲一遍用 Redis 实现 Session 共享的完整方案从原理到实操到踩坑一步不落。这套方案主要解决的是分布式系统里 HTTP Session 各自为政的痛点适合正在从单机架构转向多实例部署的开发团队参考也适合学完 Spring Boot 基础、想进一步了解分布式会话管理的人。1. 先说清楚一个根本问题Session 为什么会“丢”Session 在单机时代很简单一个 Tomcat 实例里存着一堆客户端会话数据用户在浏览器上留下一个 JSESSIONID 的 Cookie后续每次请求带上来服务端根据这个 ID 从内存里把对应的 Session 数据找出来。1.1 单机场景下 Session 工作的完整链路单机场景下一次登录请求经历的大概是这几步用户提交用户名密码服务端验证通过后调用request.getSession(true)创建会话HttpSession 对象被放在当前 Web 容器比如 Tomcat的内存里。服务端通过响应头Set-Cookie: JSESSIONIDxxx把这个固定 ID 发给浏览器。浏览器存下这个 Cookie之后每次请求在请求头里自动带上Cookie: JSESSIONIDxxx。服务端根据 ID 在本地内存的 Session 存储中找到对应对象整个会话期间用户状态就得到了保持。这个流程在单节点没有任何问题因为 Session 数据的存储和查找都发生在同一个 JVM 里。1.2 多实例部署下出现的三种故障表现项目部署变成多台机器之后问题就来了Nginx 默认的轮询策略会把请求轮流发给后端。第一个请求落在实例 A登录状态存进了 A 的内存第二个请求被轮询到实例 BB 的内存里根本没有这个 JSESSIONID于是容器只能新建一个空的 Session 并重新下发新的 JSESSIONID。于是用户体感就是登录成功后刷新页面莫名其妙又回到了登录页。页面上输入了验证码通过了结果等正式提交表单时提示“验证码错误或已过期”。购物车里的商品在不同服务器之间来回横跳一会儿有一会儿没因为每台机器各自维护一套数据彼此完全不通。还有一种情况值得单独提一下如果 Nginx 配置了 ip_hash 之类的会话保持策略流量确实践踏了同一个后端实例SESSION 看着“好像正常”但这个方案本质上是把 Session 绑定到了特定节点。一旦这个节点宕机或者重启该节点上的所有用户会话就全军覆没而且扩容缩容时新节点永远没有用户数据所有用户集体被踢下线。所以 ip_hash 只能说是一种规避手段并不是真正的分布式 Session 方案。1.3 Session 共享的本质是什么Session 共享的本质就是把原来放在每一个 Web 容器本地内存里的 Session 数据统一挪到一个所有实例都能访问的独立存储层中。每个后端实例在处理请求时都从这个共享存储层读取、更新会话数据。这样任何一个实例都能无缝接管任意一个用户的请求不需要关心这个用户之前是在哪台机器上登录的。Redis 之所以适合干这件事主要是因为三点它基于内存读写访问速度极快支持 Key 过期机制天然契合 Session 的过期销毁场景数据结构简单但功能齐全能满足 Session 存取、更新、过期等全部需求。有一点需要提前泼冷水Session 共享解决了会话数据存储的问题但不用然等于“无状态架构”。用了 Session 之后服务端依然依赖会话状态。要真正做到全体实例任意扩展终极方向还是把用户状态尽量往客户端比如 JWT或者更纯粹的业务数据存储里去沉淀。先把 Session 共享做好是迈向无状态架构的过渡阶段这件事本身非常有意义。2. 三条主流的 Session 共享技术路线怎么选Session 共享在 Java 生态里不是只有一条路常见的方案大概有三类我把它们都跑了一遍各有优缺点这里摊开对比一下。2.1 Spring Session Data Redis最推荐的落地方式Spring 官方提供了一个叫 Spring Session 的项目核心思想是把 Web 容器管理 HttpSession 的职责抽离出来交给统一的 Session 存储去管理。其中和 Redis 结合的就是spring-session-data-redis模块这也是目前 Spring Boot 系应用最主流的选择。Spring Session 干了一件非常聪明的事通过一组 Filter 拦截 HttpServletRequest、HttpServletResponse把原生的 Session 操作接口全部重定向到自定义的SessionRepository实现上。对业务代码而言你照样调用request.getSession()、session.setAttribute()但背后的存取目标已经是 Redis而不是 Tomcat 内存。这种无侵入式的设计特别适合改造存量项目几乎不需要动业务代码。默认情况下Spring Session 使用的是一个名为RedisIndexedSessionRepository的实现它把 Session 数据存到 Redis 的 Hash 结构中并额外维护 session 过期时间的索引支持通过 Spring 的过期任务机制做精准清理。这个后面章节会展开讲。2.2 Tomcat Session Manager 模式老牌方案但绑定容器在 Spring Session 出现之前很多人用的是修改 Tomcat 配置把 Session 存储从默认的 StandardManager 换成 PersistentManager 并接入 Redis 存储。具体做法是在context.xml里配置一个支持 Redis 的 Store常见的有tomcat-redis-session-manager这类第三方组件。这个方案的问题在于它和 Tomcat 容器强耦合所有后端实例必须清一色使用同一版本的 Tomcat 或者同一系列的容器一旦迁移到 Jetty、Undertow 等别的容器这套方案直接作废。另外第三方 Manager 开源组件的维护活跃度参差不齐遇到容器版本升级经常出现兼容性故障实际上我见过不少项目在这个上面栽过跟头。所以除非项目本身就是非常传统的 Tomcat 部署方式、短期内完全没有迁移可能否则我不会轻易推荐这一条路。2.3 Cookie-Based Session把数据放在客户端还有一种思路是把 Session 数据本身加密后写入 Cookie服务端通过解密 Cookie 来恢复会话状态典型的代表就是 Spring Session 提供的CookieHttpSessionStrategy新版叫CookieRequestedCache等实现。这种方案的优势是服务端完全不需要维护 Session 状态有利于横向扩展但也带来了几个硬伤Cookie 体积有限一般浏览器单条限制 4KB 左右复杂的业务 Session 数据塞不进去数据放在客户端安全性敏感必须做加密和签名无法实现服务端主动注销、踢人下线等操作。这方案适合非常轻量的会话场景主流业务系统一般不建议使用。2.4 方案选型的最终对比对比项Spring Session Data RedisTomcat Session ManagerCookie-Based Session与代码侵入性无侵入API 不变侵入小但要改容器配置无侵入但要改配置容器耦合度不绑定容器强绑定 Tomcat不绑定容器Session 大小限制受 Redis 存储限制几乎无上限受 Redis 存储限制受 Cookie 4KB 限制服务端主动失效支持支持不支持横纵向扩展能力极好一般极好安全级别高高需要额外加密签名维护成本低Spring 官方长期维护较低但第三方组件被动中对比下来常规企业级项目选用 Spring Session Data Redis 是最稳的。接下来的实操也是基于这条路线。3. 实操落地基于 Spring Session Data Redis 的 Session 共享纸上谈兵没什么意思直接用代码走一遍完整落地过程。我这边用的是 Spring Boot 2.7.x 环境Java 8Redis 6.x。这个过程对 Spring Boot 3.x 同样适用主要变化在javax.servlet变成jakarta.servlet依赖坐标上会有细微差别但整体思路一模一样。3.1 第一步引入核心依赖在pom.xml里加两样东西dependency groupIdorg.springframework.session/groupId artifactIdspring-session-data-redis/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency细心的同学会发现 Redis 连接那块如果沿用spring-boot-starter-data-redis的自动配置一般不需要额外配 RedisTemplate 的 Bean因为 Spring Session 内部会自己构建一个连接工厂。不过有一点容易踩坑Spring Boot 2.x 默认用 Lettuce 作为 Redis 客户端Lettuce 多线程共享连接的特性在 Spring Session 场景下性能不错但遇到大流量时要留意连接池配置。所以最好额外加一段连接池参数保证高并发下不出现连接等待spring: redis: host: redis-server port: 6379 password: your-redis-password timeout: 3000ms lettuce: pool: max-active: 64 max-idle: 16 min-idle: 8 max-wait: 1000msmax-active64、max-wait1000ms这两个参数是我压测后觉得比较稳妥的起步值流量再大就根据监控继续往上调但注意连接过多也可能打满 Redis 的服务端连接数要两边一起估算。3.2 第二步配置 Session 存储类型和过期时间Spring Boot 下通过配置项即可完成大部分设置。核心的是下面这一段spring: session: store-type: redis timeout: 30m redis: namespace: demo:sessionstore-type: redis让 Spring Boot 自动装配RedisIndexedSessionRepository这一步是开关。timeout定义默认 Session 过期时间注意这里不是简单数字秒数可以带单位30m表示 30 分钟不配置默认是 30 分钟。namespace是会话 Key 的前缀这个很有用同一个 Redis 集群里多个应用、多套环境并存时通过不同的 namespace 自然隔离不会互相冲刷数据。3.3 第三步序列化策略的设计与会话对象规范Spring Session 默认会把 Session 的属性值做 JDK 原生序列化也就是 Java 的Serializable机制。存进 Redis 里的内容是一长串以\xac\xed\x00开头的二进制对象。功能上没问题但有几个坏处Redis 里直接看数据可读性极差没法排查问题跨语言调用时其他系统根本没法解析这些数据序列化后的体积偏大浪费内存。我在项目里用GenericJackson2JsonRedisSerializer替换默认序列化器实现方式是通过自定义 RedisSerializer 的 BeanConfiguration public class SessionRedisConfig { Bean public RedisSerializerObject springSessionDefaultRedisSerializer() { return new GenericJackson2JsonRedisSerializer(); } }注意这个 Bean 的名字是方法名springSessionDefaultRedisSerializerSpring Session 装配时会优先使用这个名称的 Bean。这个配置配置好之后Redis 里看到的 Session 属性就是 JSON 字符串可读性和扩展性都好了很多。此时就对 Session 里存放的对象提出了要求必须可以被 Jackson 正常序列化。最简单的做法就是让实体类实现Serializable接口并且字段都是普通的 POJO。如果 Session 里塞了自定义对象又没给默认构造函数Jackson 在反序列化时就会报错这是替换默认序列化器之后出现频次最高的问题。3.4 第四步动态控制 Session 失效时间业务上有个非常常见的需求用户登录后如果 30 分钟不操作就自动失效但如果用户一直在操作则需要顺延过期时间。这个逻辑对不应通过重启应用来解决Spring 提供的方案是基于 Redis 的 TTL 精准控制。默认情况下Redis 中的 Session key 的 TTL 等于配置的过期时间。为了触发 TTL 续期Spring Session 提供了一种机制每次请求访问 Session 时服务端会发一个ExpirationPolicy相关的心跳告诉 Redis 可以刷新该 Session 的 TTL。更直接的做法是在代码里主动设置 Session 的最大空闲时间RestController RequestMapping(/user) public class UserController { PostMapping(/login) public Result login(RequestBody LoginDTO dto, HttpSession session) { // 登录校验通过后 session.setAttribute(userId, userId); session.setAttribute(userInfo, userInfo); // 可选手动兜底设置过期时间 session.setMaxInactiveInterval(30 * 60); return Result.success(); } }setMaxInactiveInterval的意思是“最大空闲存活时间”注意它表示“无操作”多久后失效不是“绝对过期时间”。每次请求访问到该 Session容器都会检查闲置时间并决定是否需要续期。如果业务上需要绝对过期时间比如强制每天凌晨失效单纯靠这个配置做不到需要另外加一层定时任务或者用业务字段辅助判断。3.5 第五步双实例启动验证是否真的“共享”成功配置全部完成后验证环节必不可少。我每次改造完都会用两个不同端口启动同一个应用模拟双机部署# 终端 1模拟实例 A java -jar demo-app.jar --server.port8081 # 终端 2模拟实例 B java -jar demo-app.jar --server.port8082然后通过 Nginx 配置上游指向这两个端口负载均衡设置为轮询upstream demo_cluster { server 127.0.0.1:8081; server 127.0.0.1:8082; } server { listen 8080; location / { proxy_pass http://demo_cluster; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }用 curl 带 Cookie 的方式逐步验证# 第一步登录获取会话 Cookie curl -i -X POST http://127.0.0.1:8080/user/login \ -H Content-Type: application/json \ -d {username:demo,password:123456} \ -c cookies.txt # 第二步带着同一个 Cookie 连续访问多个实例的接口 curl -i -b cookies.txt http://127.0.0.1:8080/user/profile curl -i -b cookies.txt http://127.0.0.1:8080/user/profile curl -i -b cookies.txt http://127.0.0.1:8080/user/profile跑多轮并留意响应内容如果三次请求返回的都是同一个登录用户信息就说明即使请求被 Nginx 分发到了不同的后端实例Session 数据也能从公共的 Redis 中正确恢复。此时再看 Redis 里的 Key你会发现类似demo:session:expires:xxxxx和demo:session:sessions:xxxxx这样的数据前一个是过期时间索引后一个是真正的会话数据。4. 把 Session 放进 Redis 之后的隐藏代价服务端要补哪些功课Session 共享不是一配了之数据从本地内存搬到 Redis 里等于把一部分关键运行时数据托付给了外部存储。这里面有几个容易被忽略的代价提前做好功课能避免上线后的睡不着觉。4.1 Redis 宕机的合理降级设计Session 数据只存在 Redis 中意味着 Redis 一旦不可用所有用户都会被迫登出业务影响面是全局性的。这个问题靠架构上必须提前想清楚。有几个值得考虑的处理方式。第一Redis 本身要做到高可用哨兵或者集群模式至少要有一主多从不能是单节点裸奔。主从加上自动故障转移能够把宕机时间压缩到几十秒级别。第二业务层面做一个容错降级当 Redis 访问失败时捕获异常并回退到本地 JVM 的 Session 存储虽然这种降级会重新引入单机 Session 问题但至少业务不中断用户在那一刻不会掉线。等 Redis 恢复后再把后续会话切换回去。这种降级逻辑代码量不大但很体现工程完备度。还有一个细节Redis 主从切换时如果 Session 写入采用主从同步在极端情况下存在主库数据尚未同步完成、新主库立即接管请求的场景会有极小概率造成部分用户 Session 丢失。这是分布式系统的固有取舍如果想要更可靠的方案就得在业务自身层面做 Session 数据的持久化或冗余成本和复杂度会显著上升。大多数业务系统做到 Redis 高可用已经是足够好的折中。4.2 持久化策略决定重启后是否丢 SessionRedis 默认配置下是有 RDB 快照的但快照频率相对粗糙。我的建议是开 AOF 且设置appendfsync everysec这样每秒最多丢一秒的写入数据在 Session 场景里基本可以接受。如果业务要求极致可靠可以用always模式但磁盘压力会明显增大读写性能折损明显。具体配置修改 Redis 的redis.confappendonly yes appendfilename appendonly.aof appendfsync everysec auto-aof-rewrite-percentage 100 auto-aof-rewrite-min-size 64mb另外特别提醒不要为了省事把 Redis 的maxmemory设置得特别小然后期望淘汰策略自动清理 Session。Session 数据是有业务价值的一旦触发了allkeys-lru这类淘汰策略内存紧张时最先被干掉的往往是那些低频访问的大 Session。建议设成volatile-ttl或者noeviction配合maxmemory-policy volatile-lru也凑合但一定要搞清楚策略是精准作用于设置了过期时间的 Key 上。4.3 多应用共用 Redis 时的 Key 隔离规划实际情况中一个 Redis 集群常常同时服务多个应用、多套环境日常环境、预发环境、生产环境。如果不做 Key 隔离A 应用的 Session 数据可能会被 B 应用误读取或者同一应用不同环境之间互相干扰。这里有两个层面的隔离手段namespace 隔离这一点在配置中已经提过每个应用一套独立的spring.session.redis.namespace。Redis 集群实例隔离极重要的系统建议使用独立的 Redis 实例或集群session 噪声和数据损坏风险才最小。表上写的默认demo:session我建议按“应用名-环境名”的形式改比如order-service-prod:session、pay-service-test:session。顺带提一下 Redis 的数据结构Spring Session 的存储主要用到 Hash、String 以及带有 TTL 的 Key排查数据时一定记得用hgetall、ttl、type这些命令去确认一下类型再操作防止误把 Hash 当 String 处理直接清空掉。5. 实际生产环境里的踩坑记录与排查实录这个方案我刚落地时踩了不少坑这里挑几个最有代表性的记录下来很多问题不是你配置错了而是序列化、网络、或者容器规范带来的隐藏陷阱。5.1 换掉默认序列化器之后数据读不出来了我之前在某个项目里为了追求体积和速度用FastJson2JsonRedisSerializer替换默认序列化器结果线上出现部分用户会话丢失排查发现是序列化器重写时引入了类型信息丢失问题。快照里的 Session 属性反序列化回来时变成了JSONObject而不是原来的业务对象代码里一强转就报ClassCastException。最后解决办法是统一用GenericJackson2JsonRedisSerializer因为它会在 JSON 里自动写入class类型标记反序列化时能正确还原原始类型。手动实现序列化器时一定要确认这个类型标记逻辑不能省。5.2 验证码模块在 Session 共享之后反而失效有次改造完登录页的图形验证码总是提示错误排查了半天发现原因很有意思验证码是存在 Session 里的但验证码的生成请求和校验请求恰好被负载均衡分发到了不同实例旧架构靠 ip_hash 掩盖了问题Session 一共享理论上就通了但项目里有个老代码在存储验证码时自己用request.getSession()生成了新 Session而校验时却根据请求头里缺失的旧 JSESSIONID 去查自然取不到。这个问题的本质是代码里对 Session 的获取时机不一致有的地方应该复用现有会话而不是强制新建。修复方法很简单所有写 Session 的入口统一判断是否已有会话避免反复getSession(true)覆盖 Cookie 里的持卡标识。同样的现象还可能出现在图片验证码、短信验证码、扫码登录标志位上排查方向都是一样的找到代码里所有getSession的调用点确认它们的获取模式是一致的。5.3 Cookie 路径和跨域问题导致 Session 串号或取不到Session 共享成功后用户浏览器里的 JSESSIONID 在所有后端实例中确实统一了但如果整个系统不止这一个应用还涉及 Admin 后台、网关、用户门户等多个应用如果域名或路径边界设计不合理Cookie 之间会发生串号问题。有一次我发现 A 系统的登录状态能被 B 系统读取到原因就是两个应用部署在同一个父域下而 Cookie 的 Path 都设置成了根路径JSESSIONID 互相覆盖。解决办法是每个应用在 Cookie 中设置独立的 Path配合不同的 Redis namespace 双保险。多域名架构下要严谨设置 Cookie 的 Domain 和 Path比如用户系统的 Cookie Path 限定在/user/管理后台限定在/admin/等。5.4 Redis 连接超时、网络抖动对接口耗时的影响Session 共享之后每次 HTTP 请求几乎都会访问一次 RedisRedis 的响应速度直接决定了接口的响应延迟。有一次排查接口偶发变慢发现是 Redis 客户端连接池过小高峰期大量请求排队等待获取连接单次请求多等了 800ms 以上。后来调整了max-active和max-wait参数并把失败重试改成快速失败接口耗时才归位。另外一个习惯是所有 Redis 相关的配置都要设置超时时间不要用默认的无超时或者过大超时。读取 Session 这种请求如果 Redis 卡住整个请求都会被拖死所以timeout: 3000ms不是随便写的它意味着“Session 读取最多等你 3 秒拿不到就直接报错”这个阈值根据业务容忍度调。5.5 关于负载均衡算法还有一个常被忽略的注意点虽然 Session 已经共享但负载均衡的策略最好还是优先使用最小连接数或者加权轮询原因不是为了 Session 保活而是为了均衡后端实例负载。如果还用ip_hash一些集中出口 IP 的用户会把压力全打到同一台机器上白白浪费了多实例扩展的能力。所以 Session 共享做完之后顺手把 Nginx 的负载均衡策略从 ip_hash 改成 least_conn 是必要的。6. 上线前的链路验证与压测建议改造完不能直接上线我做完整套验证流程才敢切换流量。这里列一个可复用的验证清单帮大家少走弯路。6.1 功能链路验证清单验证项验证方式预期结果登录后跨实例访问登录后反复刷新多个接口观察后端日志确认请求落在不同实例用户依然处于已登录状态Session 空闲过期登录后等待超过过期时间再访问跳转登录页Redis 中对应 Session Key 已消失验证码过期机制生成验证码后等待超时再提交校验失败提示验证码过期主动注销退出登录后再访问受保护接口返回未登录Redis 重启后恢复模拟 Redis 故障节点切换后继续访问依赖高可用方案尽量保证用户无感多应用隔离切换 namespace 后访问两个独立应用登录状态互不影响6.2 压测时的三个重点关注指标压测工具首选ab或者wrk重点观察三组数据一是接口的 P99 耗时在 Session 共享前后是否有明显劣化二是 Redis 的 QPS 和连接数是否在合理区间三是后端实例之间的 Session 命中率是否均衡。这里强调一下压测的建模方式直接用登录接口压测没太大意义因为登录本身就涉及密码校验等重逻辑。真正应该模拟的是“带合法 Session 的连续业务请求”场景先用脚本登录并保存 Cookie然后用多个并发用户循环访问业务接口观察 Session 读取对整体耗时的影响。压测用的 Redis 要和线上配置一致否则内存占用和网络消耗都没有参考价值。我一次压测时发现 TPS 上不去定位到瓶颈竟然在 Redis 的单线程网络模型上大量 Session 的读写和业务缓存读写都在同一个 Redis 实例里挤着。后来把 Session 专用的 Redis 和业务缓存 Redis 物理拆开TPS 才稳定回升。这里也建议在架构上做物理隔离Session 数据量不大但请求频率高和缓存混在一起容易互相拖累。6.3 平稳切流先灰度后全量上线时别一次性切所有流量先把一个较小比例比如 5%–10%的请求路由到新架构观察错误率和用户反馈再逐步提高比例。回滚方案也要提前准备好只要把负载均衡指回旧节点同时保证旧节点原本的本地 Session 数据没有过期用户基本无感。我个人的习惯是Session 共享这种底子类改造最好选在低峰期操作预留充足的观察窗口并且提前写好回滚脚本因为一旦出问题影响的是全量用户。最后说两句实在的这套方案前前后后在好几个项目里落地过总结下来效果很明显多实例部署终于敢放心把请求随意分发到任意节点扩容缩容也不需要再关心用户会话绑定问题发布、重启也不再是用户集体掉线的导火索。但每次我都提醒团队Session 放进 Redis 只是分布式改造的中间态不是终点。真正核心的业务状态应该想办法往更可靠的存储或客户端去迁移Redis 承担 Session 这种瞬态数据是合适的但别让它变成系统的单点隐患。从一个开发者的角度来说理解了 Session 的存取链路看懂了序列化、过期机制、连接池这些细节比单纯会配置几个依赖要有用得多排查线上问题的时候你才能一眼定位到问题到底出在哪一环。

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

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

免费获取报价 →
↑