
做微服务改造这几年有个问题几乎每个团队都会碰到用户明明登录了请求被负载均衡转发到另一个服务实例登录态就丢了用户被一遍一遍地要求重新登录。这个问题的根源是会话Session数据一直存放在应用服务器的本地内存里。微服务拆分、多实例部署之后Session所在的那个实例不一定处理到了这一次的请求。解决这个问题的标准答案之一就是这篇要聊的Spring Session Redis分布式会话方案。这篇文章我会从单体时代的Session机制讲起拆解Spring Session的核心运作原理再给出Redis落地实操、微服务场景下的会话共享要点最后整理我踩过的坑和排查思路。大纲如下单体应用Session机制回顾与微服务痛点Spring Session的核心组件与工作原理Spring Boot Redis接入Spring Session的完整配置微服务场景下多服务共享会话的实践方案常见问题与避坑实录1. 分布式会话微服务改造绕不开的一道坎1.1 单体应用时代的Session到底存在哪先回忆一下单体应用里的Session机制。最经典的实现是Tomcat提供的HttpSession用户第一次请求时Tomcat生成一个Session对象把数据存在JVM堆内存里同时通过Set-Cookie把JSESSIONID发给浏览器浏览器后续请求带上这个CookieTomcat根据JSESSIONID去内存里找到对应的Session对象。这个过程在单体应用下非常好使只有一个Tomcat所有请求都进同一个JVMSession数据天然共享。但如果做过集群部署问题就来了——多个Tomcat实例各自维护各自的SessionA实例上创建的SessionB实例根本感知不到。早期的解决办法是配置Tomcat的Cluster会话复制DeltaManager让实例之间同步Session数据这在实例少、用户少的时候能用但实例一多广播同步的内存开销和网络开销都非常吓人。单体应用还有一个业务层面的问题Session数据通常越存越多。很多团队习惯把用户对象、购物车、临时数据全塞进Session一旦Session对象不能被序列化还会引发更隐蔽的错误。这些都是后来微服务化的隐患。1.2 微服务拆分之后Session遇到了什么麻烦微服务架构下单体Tomcat变成了若干个独立部署的服务每个服务可能有多个实例前面还有网关和负载均衡。此时Session面临的问题从集群内同步升级为跨服务、跨实例共享。最典型的场景是用户登录请求打到用户服务的实例A登录态写入了实例A的本地内存用户下一次请求查询订单网关把请求转发到订单服务的实例B订单服务需要校验登录态但它既没有实例A的内存数据也没有任何会话存储可以查询。结果就是用户莫名其妙被踢回登录页。为了解决这个问题业界有过几个折中方案。一是粘滞会话Sticky Session让负载均衡器按用户维度固定转发到同一实例比如按Cookie或IP哈希。这个方案能解燃眉之急但实例重启、扩容缩容时用户会话依然会丢负载均衡器的配置也变复杂了。二是前端把用户状态全部塞到Cookie里后端无状态化这个方向没错但涉及安全签名、Cookie大小限制、敏感信息泄露风险工程改造量并不小。真正相对通用、对现有代码侵入最小的方案就是把Session数据从应用内存里抽出来放到一个独立的、所有服务都能访问的存储中这就是外置会话存储的思路。Spring Session正是这个思路的标准化实现。1.3 为什么大家都选Redis来存会话分布式会话的存储选型顺序一般是Redis、数据库、Memcached实际生产里Redis的使用率最高。原因很简单Session是典型的读多写少、短生命周期数据网络IO型操作Redis的内存存储加高效IO正好匹配Redis的过期机制可以直接映射会话的过期时间省去了定时清理的麻烦。Redis还提供了持久化和高可用方案。RDB快照加AOF日志可以保证重启不丢太多数据哨兵模式或集群模式可以扛住单点故障。相比数据库存储Redis的并发吞吐能力高得多响应时间也稳定——毕竟每个请求都要查一次会话这个操作不能成为性能瓶颈。另外要说一下为什么不用JWT做无状态的问题。JWT是很多团队喊得响的方案但无状态不等于万能注销和踢人需要通过黑名单实现token过期时间设置成了两难密钥管理也要格外小心。Spring Session Redis方案可以让后端继续沿用HttpSession的编程模型对老代码的改造量最小。我个人观点是新项目可以认真考虑JWT或OAuth2老项目迁微服务用Spring Session过渡是最平滑的。2. Spring Session的运作机制2.1 Spring Session到底做了什么Spring Session从本质上说是一个会话存储适配层。它把原本由Servlet容器Tomcat、Jetty管理的HttpSession替换成由Spring Session自己管理的Session对象并使用可插拔的存储后端Redis、JDBC、Hazelcast等来存放数据。对业务代码来说你操作的对象依然是HttpSession——request.getSession()然后setAttribute()熟悉的API完全不变变的只是底层存储位置和实现方式。这一点非常关键因为它决定了一个团队能不能在不改动业务逻辑的前提下完成会话方案升级。我见过不少项目就是通过引入Spring Session把用户登录态从Tomcat内存平滑迁移到了Redis代码改动量只集中在依赖和配置上。Spring Session对存储的选择是开放的。默认支持Redis、JDBC、MongoDB等其中Redis的实现最成熟。通过EnableRedisHttpSession注解或者Spring Boot自动配置Spring Session会自动替换HttpSession的实现。2.2 三个核心组件必须搞清楚要理解Spring Session的工作过程需要掌握三个核心组件。第一个是SessionRepository它决定了Session数据存到哪里、怎么存取。这是整个方案的存储接口Redis场景下对应的是RedisIndexedSessionRepository它把每个Session保存为Redis里的一个哈希结构并额外维护了一些用于按属性查找会话的索引。第二个是SessionRepositoryFilter它像一个拦截交换器。这个Filter实现了Servlet的Filter接口职责是拦截所有请求用Spring Session包装后的HttpServletRequest和HttpServletResponse替换原始的请求响应对象。当业务代码调用request.getSession()时返回的不再是Tomcat的HttpSession而是Spring Session的MapSession。当业务代码写入、提交Session时数据会被同步到Redis。第三个是SessionIdResolver它负责把会话ID在请求Cookie和会话对象之间牵线搭桥。默认实现是CookieHttpSessionIdResolver逻辑和JSESSIONID类似解析请求Cookie里指定名称的值把客户端发来的会话ID匹配到服务端的Session。三个组件的协作关系大致是请求到达 → Filter包装请求 → 业务代码通过SessionIdResolver拿到ID → SessionRepository按ID从Redis加载Session → 业务操作Session属性 → 请求结束时保存回Redis。2.3 序列化策略JDK序列化还是JSON序列化Session数据要存到Redis就一定涉及序列化问题。Spring Session默认使用的是JDK序列化也就是Java对象通过ObjectOutputStream转成字节流。JDK序列化的优点是零配置、开箱即用但坑非常多。第一个坑是性能。JDK序列化后的字节流体积大对象结构越复杂越明显Redis存储空间和网络传输成本都被撑高。第二个坑是兼容性。被序列化的类一旦改了包名、类名或字段结构旧数据很可能反序列化失败。我遇到过线上事故用户类从com.old.User改成com.new.User之后Redis里一堆旧Session直接变成烫手山芋一读就报ClassNotFoundException最后只能清缓存让用户重新登录。更稳妥的做法是改用JSON序列化。Spring Session Redis提供了RedisSerializer.json()本质上是使用GenericJackson2JsonRedisSerializer序列化结果可读、体积小、跨语言通用。配置方式很简单新建RedisConfig设置RedisSerializationContext的键值和哈希序列化方式。需要注意JSON序列化有两个小细节一是默认会写入class类型信息用于反序列化还原所以被序列化的对象必须有无参构造方法二是不建议把含有LocalDateTime等复杂Java时间类型的对象直接塞进去需要配置JavaTimeModule否则会报找不到序列化器。我给出的建议是业务对象尽量简单能不存对象就不存对象只存用户ID、昵称等基础字段。复杂对象需要存时优先用JSON序列化并提前做好兼容性测试。3. Redis Spring Session落地实操3.1 依赖与配置一步都不能省以Spring Boot项目为例引入两个关键依赖就够了。如果你的项目用的是Spring Boot 2.x或3.x以下配置同样适用只有配置项前缀有细微差异。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.springframework.session/groupId artifactIdspring-session-data-redis/artifactId /dependency注意spring-boot-starter-data-redis已经包含了spring-data-redis和lettuce-core也就是Redis客户端Lettuce。如果你的项目里还引用了jedis需要排除掉或将Lettuce排除两者同时存在会有冲突。然后是配置文件spring: data: redis: host: 127.0.0.1 port: 6379 password: yourpassword # 生产环境必须有 database: 0 timeout: 3s lettuce: pool: max-active: 16 max-idle: 8 min-idle: 2 session: store-type: redis timeout: 1800s关于spring.session.store-type设置成redis后Spring Boot会自动装配RedisIndexedSessionRepository和相关Filter不用额外写EnableRedisHttpSession注解。如果你的项目是传统Spring应用没有Spring Boot支持就必须手动加注解。3.2 写一个最基础的登录会话配置完成之后业务代码的使用方式和原来的HttpSession几乎一样。我举个例子一个非常典型的登录接口RestController RequestMapping(/auth) public class AuthController { PostMapping(/login) public String login(RequestBody UserLoginDTO dto, HttpServletRequest request) { // 校验用户名密码省略具体逻辑 User user userService.login(dto.getUsername(), dto.getPassword()); if (user null) { throw new BusinessException(用户名或密码错误); } HttpSession session request.getSession(true); session.setAttribute(USER_INFO, user); session.setMaxInactiveInterval(1800); return 登录成功; } GetMapping(/current) public User current(HttpServletRequest request) { HttpSession session request.getSession(false); if (session null || session.getAttribute(USER_INFO) null) { throw new BusinessException(未登录或会话已过期); } return (User) session.getAttribute(USER_INFO); } PostMapping(/logout) public String logout(HttpServletRequest request) { HttpSession session request.getSession(false); if (session ! null) { session.invalidate(); } return 已退出; } }这段代码在单体Tomcat里怎么写在这里就怎么写。你可以注意到我在登录成功时创建会话、在查询接口读取会话、在退出时销毁会话没有一行代码是在直接操作Redis但后台数据确实流向了Redis。Spring Session的Filter把这一切都接管了。有一点要提醒request.getSession(true)和request.getSession(false)的语义要区分清楚。业务上查询用户是否登录一定用getSession(false)避免强迫症似的给所有请求都创建无意义的会话。3.3 打开Redis看看数据长什么样把上面这个登录接口跑起来登录成功后到Redis命令行敲keys *你会看到类似下面这样的键spring:session:expirations:1800000000000 spring:session:sessions:5f2d1c4e-7f3e-4a6b-a1a9-2d8b3f4f7c11 spring:session:index:org.springframework.session.FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME:user001其中spring:session:sessions:{sessionId}是核心它存放了一个会话的完整数据类型是Hash。字段包括creationTime、lastAccessedTime、maxInactiveInterval、sessionAttr:USER_INFO等。spring:session:expirations:{毫秒时间戳}是过期扫描用的时间槽Redis通过它实现批量清理过期会话。spring:session:index:{索引名}:{值}是用于按用户名等索引查询会话的辅助键。如果你在Redis客户端里用hgetall spring:session:sessions:{sessionId}查看会看到类似的结构1) creationTime 2) 1700000000000 3) lastAccessedTime 4) 1700003600000 5) maxInactiveInterval 6) 1800 7) sessionAttr:USER_INFO 8) {\class\:\com.example.entity.User\,\id\:1,\username\:\user001\}这里如果你看到的还是\xAC\xED\x00\x05开头的二进制乱码就说明你还是用了JDK序列化建议按照2.3节的方法切换成JSON序列化。我一般会用Another Redis Desktop Manager这类可视化工具直接观察数据排查会话丢失问题时非常直观。3.4 配置JSON序列化为了避免JDK序列化的一堆坑推荐在项目里显式配置Redis序列化。新建一个配置类Configuration public class RedisSessionConfig { Bean public RedisSerializerObject springSessionDefaultRedisSerializer() { return new GenericJackson2JsonRedisSerializer(); } }简单到只有一个Bean。Spring Session在自动配置阶段会优先使用这个RedisSerializerObject作为会话属性的序列化器。如果你还需要配置RedisTemplate来操作其他业务数据建议会话的RedisConnectionFactory保持独立或者至少命名空间要区分开避免业务键和会话键混在一起影响排查。泛型Jackson序列化器在反序列化时会读取class字段还原具体类型。这意味着被序列化的User对象必须有无参构造方法否则反序列化阶段会报错。我见过不少团队在这个地方卡住明明存的时候一切正常一重启应用就反序列化失败。4. 微服务场景下的会话共享与加固4.1 多服务共享同一个会话微服务架构下使用Spring Session Redis带来的核心价值就是会话不再是某个服务的私有状态。只要两个服务连的是同一个Redis、同一个spring.session.store-type配置并且会话ID能被正确传递到每个服务那么用户服务写入的Session订单服务、支付服务就能直接读取。要做到这点架构上需要满足几个条件。第一所有服务使用同一个Redis实例或同一个Redis集群且建议使用相同的db index。第二服务的ContextPath或网关路由规则不能改变Cookie的作用域确保JSESSIONID能随请求一起转发。第三一个服务写入的Session属性其他服务要能反序列化也就是说涉及的业务对象要放在公共模块里或者至少类名、包结构保持一致。实际项目里我更推荐会话只存用户基础身份不存业务细节的做法。两个服务共享会话时会话里放一个LoginUser公共对象包含userId、username、角色列表就足够了。其他业务数据各服务用用户ID去查自己的库谁也不依赖谁的内部结构。这样即便未来某个服务改了会话结构其他服务也不会受到影响。4.2 用户身份如何跨服务传递会话共享之后身份校验仍然不能掉以轻心。我见过很多团队在每个服务里写一遍从Session取用户ID的逻辑代码重复度高而且容易漏校验。更好的做法是抽一个公共的会话工具或者专门的鉴权组件。最简单的方案是写一个公共的SecurityUtils类public class SecurityUtils { public static LoginUser getCurrentUser() { HttpSession session getSession(false); if (session null) { throw new UnauthorizedException(登录已过期); } Object obj session.getAttribute(SessionKeys.LOGIN_USER); if (obj null) { throw new UnauthorizedException(未登录); } return (LoginUser) obj; } }各服务的Controller和Service直接调用SecurityUtils.getCurrentUser()获取用户信息。如果引入了Spring Security则可以实现SecurityContext从Session中同步的策略在认证过滤器里读取会话填充SecurityContextHolder。这个方案更规范也让安全框架统一管理认证状态。网关层的统一鉴权是另一个话题。如果所有请求都经过网关可以在网关层解析Cookie里的会话ID调用会话服务校验用户是否有效。但由于网关往往不依赖Spring Session这个方案一般需要单独开发或者直接用Spring Cloud Gateway Spring Session的源码方式接入。小团队我更推荐各服务用Session自校验减少链路依赖等业务规模上来再考虑网关统一鉴权。4.3 会话安全加固会话存储方案落地后绝对不能忽略安全配置。这里有几个必须检查的项。第一是Cookie属性。默认情况下Spring Session写入的Cookie名称是SESSION而不是JSESSIONID。建议显式配置Cookie的安全性spring: session: timeout: 1800s servlet: context-path: / # 会话Cookie的作用路径同时通过DefaultCookieSerializer配置HttpOnly和SecureBean public DefaultCookieSerializer cookieSerializer() { DefaultCookieSerializer serializer new DefaultCookieSerializer(); serializer.setCookieName(SESSION); serializer.setUseHttpOnlyCookie(true); serializer.setUseSecureCookie(true); // HTTPS环境下必须开启 serializer.setSameSite(Lax); // 防止CSRF攻击的合理配置 return serializer; }HttpOnly是为了防止恶意脚本通过document.cookie读取会话IDSecure保证Cookie只通过HTTPS传输SameSite则能降低跨站请求伪造CSRF风险。这三个属性缺一不可尤其是涉及支付、个人信息等敏感业务的服务。第二是会话固定攻击防护。攻击者可以先自建会话然后诱导用户使用这个会话ID登录如果服务端不在登录成功后更换会话ID攻击者就能继承用户登录态。Spring Session提供了SessionManagement变更会话ID的能力最简单的方式是在登录成功后调用request.changeSessionId()让会话ID重新生成旧的会话数据作废。第三是Redis本身的访问控制。生产环境Redis必须设置requirepass密码开启rename-command FLUSHALL等危险命令的重命名或禁用限制仅内网访问。很多会话数据泄露事件根因不是应用层漏洞而是Redis端口暴露在公网并且没有密码。5. 实战中的问题排查与避坑实录我把自己在实际项目中遇到的高频问题整理成了一张速查表基本覆盖了大部分团队上分布式会话方案时的疑难杂症。现象可能原因排查手段与解决方案服务一写入会话服务二读不到两个服务连了不同的Redis库检查各服务的spring.data.redis.database配置必须一致登录后偶尔跳到登录页会话序列化反序列化失败查看Redis里session属性数据确认是否JDK二进制切换JSON序列化应用重启后大量用户重新登录Redis持久化未开启或RDB/AOF策略不合理配置AOF日志并设置合理的appendfsync策略Redis出现大量expirations开头的key正常现象Spring Session自带的过期扫描机制无需处理获取LocalDateTime对象时报序列化错误Jackson未配置JavaTimeModule自定义ObjectMapper注册JavaTimeModule并发登录时会话互相覆盖多端登录同一账号评估业务形态使用Session固定ID策略或改存多个会话访问被限流或出现连接耗尽Lettuce连接池过小根据QPS调整max-active和max-idle监控连接使用率本地开发一切正常线上偶发登录失败Redis Cluster模式操作了跨slot的keySpring Session在集群模式下使用Hash Tag检查是否被关闭5.1 序列化反序列化问题上面表格里列的第一类问题实际出现概率最高。特别是团队在开发阶段没有统一配置序列化方式某个服务用JDK序列化另一个服务用JSON序列化两边读同一个Session时就会互相不认识对方的数据。这里的不认识轻则反序列化异常重则getAttribute返回null登录态静默丢失非常隐蔽。排查这类问题的顺序是先看Redis里会话数据是不是可读文本再用会话ID在Redis里hgetall查看字段内容最后看两个服务的springSessionDefaultRedisSerializer是否一致。我强烈建议把序列化配置写进公共依赖包里每个服务默认都继承同一份Redis配置。5.2 粘滞会话的误解有些团队引入Spring Session之后仍然担心负载均衡策略会不会影响Session。这里要澄清Spring Session Redis方案本身就是无粘滞的请求随便打哪个实例都能正确读到会话数据负载均衡器的粘滞策略反而会掩盖实例间状态不一致的问题。如果你用了粘滞会话应尽快取消否则一旦负载均衡器调整权重或重启节点用户会话仍然可能大面积失效。5.3 Redis持久化与高可用Session数据如果全部依赖Redis那么Redis的可用性和持久化就直接决定了用户登录体验。Redis的save参数如果只配置了RDB快照宕机时最多丢几百MB数据会话全部失效。所以要开启AOF持久化或者至少配置RDB加AOF双开。高可用层面建议生产环境使用哨兵模式或集群模式避免Redis单点故障引发全站登录失效。有一件我踩过的具体事上线初期Redis没有配置密码结果被扫描器盯上FLUSHALL命令被执行线上Session一家伙全被清空。从此以后我对Redis安全配置的检查格外严格密码、端口访问控制、危险命令禁用三项缺一不可。5.4 会话过期时间怎么设置会话超时时间不能拍脑袋。设置太短用户频繁重新登录设置太长存在安全风险。我建议按业务敏感度分级普通后台管理系统1800秒30分钟电商购物流程可以到7200秒金融或后台管理类服务控制在600到900秒。同时一定要配置spring.session.timeout并注意这个时间单位是秒还是Duration——Spring Boot 2.x以后配置项默认是Duration类型写600s不会有问题写单纯的600可能会被当作纳秒处理一个小陷阱。还有一个容易被忽略的技术细节是session.setMaxInactiveInterval()的优先级高于配置文件里的全局spring.session.timeout。如果代码里写了1800秒配置文件里写了900秒生效的是代码里的值。多服务场景下要保证所有服务对这个会话超时时间的设置是一致的否则请求打到不同服务时会话过期时间就会被来回覆盖。5.5 会话数据过度膨胀最后提醒一个容易在业务迭代中慢慢恶化的地方Session里不要什么都放。有的团队把用户权限列表、菜单树、甚至临时查询结果全塞进去Session体积越滚越大Redis内存和序列化开销都在涨。一个明显信号是网络报表里Redis响应时间变长检查后发现单个Session的Hash结构里有几十个字段每个字段都是一个几百KB的大对象。我的做法是约定一套规范Session只放用户ID、用户名、登录时间、角色ID列表其他一切数据一律通过用户ID去业务库里拿。这既减少了Redis压力也避免了会话数据和其他服务的缓存数据互相污染。Spring Session Redis这套方案的可扩展方向很多。比如用Spring Session的FindByIndexNameSessionRepository实现按用户名查找会话从而支持强制下线指定用户这类管理操作再比如把会话从Redis迁移到Redis Cluster时提前考虑Hash Tag问题。我个人的体会是这套方案最大的价值不是省了多少登录页面跳转而是让服务从记住用户这件事里解放出来了服务变成了真正的无状态扩容缩容、发布重启都对用户透明。做完之后你再回头看会发现微服务化的很多痛点是共通的——状态外置是解决这类问题最核心的思路。