Spring Session + Redis:微服务分布式会话的实践与踩坑指南

📅 发布时间:2026/10/6 3:20:35
Spring Session + Redis:微服务分布式会话的实践与踩坑指南
1. 从单体到微服务Session为什么会成为第一个爆炸的组件先还原一个我印象特别深的现场。某个电商项目做服务化拆分把用户、订单、商品拆成了三个微服务网关用的Nginx负载均衡到三个用户服务实例自以为万事大吉。结果上线第一周就收到一堆投诉用户登录成功后刷新一下页面又跳回登录页有时候点着点着突然被踢下线。那次我们花了整整一个下午定位问题最后发现根子就在Session——登录状态写进了A实例的内存里下一次请求被负载均衡转发到B实例B的HttpSession里根本查不到这个用户。单体时代没人会考虑Session在哪儿存的Tomcat启动就把Session放在自己的堆内存里所有请求都在同一个进程内处理getSession()自然拿得到同一个对象。可一旦应用拆成多个实例、多个服务部署负载均衡把请求轮流分发到不同节点Session就“漂”走了。这不是代码逻辑的问题这是架构从“单机有状态”变成“分布式无状态”之后必然撞上的墙。当时团队里有人提出用Nginx的ip_hash或者sticky session让同一用户的请求总是落到同一台机器问题不就解决了吗确实能解决一部分但代价很沉重某台实例宕机时这台机器上挂着的所有用户Session瞬间全丢想要重启发布、滚动升级也等于强制所有在线用户重新登录一次更别说按IP哈希在NAT网络下会让大量用户挤到同一节点负载根本均衡不了。它只是把“Session失效”从高频变成低频并没有真正解决问题。所以Spring Session这套方案才会流行起来。思路其实很简单Session数据不再放在应用的内存里而是抽出来放到一个所有服务都能访问的独立存储中——最常用的就是Redis。应用本身变成无状态节点随便扩容、随便重启用户登录态都稳稳地待在Redis里请求无论打到哪个实例都能读到同一份会话数据。这篇文章我会先拆解Spring Session在底层到底是怎么运作的再用一个真实项目里的微服务场景带你把依赖、配置、跨域、安全问题一步步落地最后把我在生产环境踩过的几个典型的坑完整过一遍。适合哪些人看正在做微服务拆分、发现登录态东丢西丢的人以及已经引入了Spring Session但遇到序列化乱码、Session不生效、Redis连接风暴等诡异问题的开发者。如果你是第一次听说Spring Session也没关系我会把原理部分讲得足够细保证你能照着做、也知道自己改的每一行配置在干什么。2. Spring Session的运作机制SessionRepository、过滤器链与Redis的三角关系说实话第一次看Spring Session源码的人很容易懵因为它的核心思路不是提供一个“替代Session的工具类”而是直接“伪装”成一个标准的Servlet实现。你在代码里照旧调用request.getSession()它返回给你的其实是一个被包装过的Session对象背后的一切都由Spring Session和Redis代劳。要理解这套机制抓住三个关键角色就够了。2.1 SessionRepository所有存储操作的统一入口SessionRepository是Spring Session里最核心的接口它的职责非常像DAO层对数据库的操作接口——负责Session的创建、查询、保存、删除。接口本身不关心数据存在哪里不同的实现类决定存储后端RedisIndexedSessionRepository基于Redis的默认实现也是本文的主角。它不只是简单地把Session对象塞进Redis而是维护了一套索引结构支持按principalName查找Session方便实现“踢人下线”“统计在线用户”这类功能。MapSessionRepository基于内存Map的实现适合本地测试或单机场景。JdbcIndexedSessionRepository把Session存进数据库适合强一致性和审计要求极高的场景。最重要的是SessionRepository的创建时机是每个请求进来时通过SessionRepositoryFilter完成的这就引出了第二个关键角色。2.2 SessionRepositoryFilter在Servlet过滤器链里偷梁换柱Spring Session能无缝侵入原有代码全靠springSessionRepositoryFilter这个过滤器。它被注册在Servlet过滤器链的最前端早于应用的Servlet和Spring MVC的DispatcherServlet执行。它做了什么逻辑其实很巧妙包装原始的HttpServletRequest生成一个SessionRepositoryRequestWrapper。包装原始的HttpServletResponse生成一个SessionRepositoryResponseWrapper。当你调用request.getSession()时真正执行的是SessionRepositoryRequestWrapper.getSession()它内部会通过SessionRepository去Redis里查Session没有就创建一个新的RedisSession对象返回。当请求结束时SessionRepositoryFilter会根据Session是否有更新决定是否调用sessionRepository.save(session)把数据同步回Redis。一整套流程走完你的业务代码根本感觉不到自己在操作Redis。这也是Spring Session这套设计的精妙之处它不逼你改写业务逻辑而是从Servlet容器层面直接把Session的存取替换掉因此对老的Servlet应用、Spring MVC应用、Spring Boot应用统统适用。2.3 序列化策略为什么默认的JDK序列化容易“翻车”Redis里存Session免不了要做序列化。Spring Session的RedisIndexedSessionRepository默认使用JdkSerializationRedisSerializer这相当于Java原生的ObjectOutputStream序列化结果是一长串二进制用redis-cli看基本是人眼无法辨认的内容而且要求被序列化的类必须实现java.io.Serializable接口。这在实际项目中非常容易出问题。最常见的一种情况你往Session里放了一个自定义对象UserInfo本地开发一切正常部署到生产环境后突然报ClassNotFoundException或者反序列化失败——因为Session里已经存了旧版本类结构序列化出来的二进制数据代码更新后类的serialVersionUID或者字段变了就反序列化不出来了。另一种情况是微服务之间共享SessionA服务存进去的Session数据类型B服务没有对应的类定义那B服务一读就直接抛异常。所以我个人的习惯是如果Session里只放基本类型、String、Map这种简单的数据结构直接把默认序列化器换成GenericJackson2JsonRedisSerializer让Redis里存的是JSON文本跨服务、跨语言都好排查redis-cli也能直接看到内容。后面排坑部分我会单独讲怎么配置才最稳妥。2.4 Redis里的数据长什么样索引、过期与键空间通知用RedisIndexedSessionRepository时Redis里不只有一个Key而是一组Key。以默认的namespacespring:session为例几个关键的Key结构是spring:session:sessions:sessionIdSession本体里头的hash字段包括creationTime、lastAccessedTime、maxInactiveInterval和每个Session attribute。spring:session:expirations:timestamp用来辅助过期清理的时间分片索引结构是一个ZSet里面存放的是即将过期但还没触发删除的Session ID。spring:session:index:org.springframework.session.FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME:username按用户名建立的索引Set结构指向该用户的所有Session ID。这套多Key设计的直接好处是Session查询、按用户索引、过期清理都高效。你可能会问Redis不是自带了EXPIRE机制吗为什么Spring Session还要搞expirations索引原因在于Session的“过期”和Redis的“Key过期”是两个层面的东西Redis的过期是物理删除但Spring Session需要知道某个Session是因为超时被删除还是因为用户手动注销这关系到要不要触发SessionDestroyedEvent事件进而让监听器做清理工作。所以Spring Session在超时时间临近时会先把Session ID放进expirations分片通过Redis的键空间通知keyspace notifications监听expired事件再回过头来删除对应的Session索引。提示如果你没在Redis侧启用键空间通知Session过期事件就不会被Spring Session感知索引可能残留。生产环境一定要在Redis配置里加上notify-keyspace-events Ex这一项后面我会给完整的排查过程。3. 从零搭建实战三步让Spring Boot应用将Session托管到Redis原理部分讲清楚了接下来进入实操。我会以一个标准的Spring Boot单体服务为例带你把Session迁移到Redis里然后再扩展到多实例和微服务场景。依赖版本以Spring Boot 2.7.x / 3.x为例有些配置项在不同大版本上略有差异我会标注清楚。3.1 引入依赖别看只多了一个坐标里面暗藏玄机要启用Spring Session RedisMaven里需要加两个核心依赖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 3.x第一个依赖对应的是spring-boot-starter-data-redis没有变spring-session-data-redis的版本则会由Spring Boot的依赖管理自动锁定不需要手写版本号。有一部分人在这里就踩坑了只加了spring-session-data-redis没加spring-boot-starter-data-redis结果启动报RedisConnectionFactory找不到。这是因为Spring Session本身不负责Redis连接它只负责“Session存储逻辑”真正连Redis还得靠Spring Data Redis提供RedisConnectionFactory、RedisTemplate这些基建。同样的道理如果你项目中已经因为缓存需求加了spring-boot-starter-data-redis那正常情况下只需要再补一个spring-session-data-redis就行。生产环境建议再加一个连接池依赖dependency groupIdorg.apache.commons/groupId artifactIdcommons-pool2/artifactId /dependency这个不是必选项但如果你的服务并发量稍微上来一点没有连接池的话LettuceSpring Boot默认的Redis客户端每创建一个连接都可能成为瓶颈具体表现到后文“线上踩坑”部分再说。3.2 配置文件三个关键项的取舍依赖加好之后在application.yml里做最小化配置spring: redis: host: 127.0.0.1 port: 6379 password: yourpassword # 没有密码就删掉这行 timeout: 3000ms lettuce: pool: max-active: 16 max-idle: 8 min-idle: 2 session: store-type: redis timeout: 30m逐个解释一下这几项spring.redis.*这是Redis连接的基础配置跟Spring Session没有直接绑定关系但Session要存到Redis必须保证连接没问题。spring.session.store-type: redis显式告诉Spring Boot“会话存储使用Redis”。如果你漏掉这一项Spring Boot会通过自动配置去推断实际项目中我建议永远显式声明避免因为classpath里多了一个Session依赖导致行为漂移。spring.session.timeout: 30m控制Session的闲置超时时间单位可以是30s、30m、24h。这里设置的超时会覆盖server.servlet.session.timeout并且会同步写到Redis Session的maxInactiveInterval字段里。spring.redis.lettuce.pool.*连接池参数。max-active不建议写太大对Redis这种内存数据库来说真正的瓶颈往往在应用侧和网络侧16到32个连接是多数场景下的合理区间。3.3 启用注解EnableRedisHttpSession的底层参数Spring Boot 2.x时代你还需要在启动类或者配置类上加EnableRedisHttpSession注解Spring Boot 3.x依然支持这种写法但也可以依赖自动配置。我的建议是显式加上不是为了“仪式感”而是为了能清楚看到自己能控制哪些参数。Configuration EnableRedisHttpSession( maxInactiveIntervalInSeconds 1800, redisNamespace demo:session, flushMode FlushMode.ON_SAVE, saveMode SaveMode.ON_SET_ATTRIBUTE ) public class SessionConfig { }几个参数值得展开说maxInactiveIntervalInSecondsSession闲置过期秒数1800就是30分钟。注意这里和spring.session.timeout是同一件事的两种写法如果都配置了注解上的优先级更高。线上我一般只保留一种写法避免维护的人看懵。redisNamespaceRedis Key的前缀相当于给当前应用的Session数据做了逻辑隔离。多服务共享Session时为了读同一份数据必须保证namespace一致。flushModeON_SAVE表示在请求结束、SessionRepositoryFilter触发save时统一写入RedisIMMEDIATE表示每次修改Session属性立即写入。默认是ON_SAVE性能更好。但如果你的业务里有的请求在结束前会长时间占住线程或者有异步线程在请求结束后还要读Session才需要考虑IMMEDIATE。saveMode控制什么时候需要保存Session。ON_SET_ATTRIBUTE表示只要有属性值变化就保存ALWAYS表示只要getSession()被调用过就在请求结束时保存。默认是ON_SET_ATTRIBUTE这是最符合直觉、也是开销最小的策略。3.4 用两个接口验证Session真的“跑”进Redis了配置做完写两个最简单的接口来验证效果RestController public class SessionController { PostMapping(/login) public String login(HttpServletRequest request, String username) { HttpSession session request.getSession(true); session.setAttribute(username, username); return login success, sessionId session.getId(); } GetMapping(/current) public String current(HttpServletRequest request) { HttpSession session request.getSession(false); if (session null || session.getAttribute(username) null) { return not login; } return current user: session.getAttribute(username); } }启动应用后先调用/login?usernamezhangsan再用redis-cli看Redis里的数据正常情况下你会看到类似这样的Keydemo:session:sessions:4b6b2088-xxxx-xxxx-xxxx-xxxxxxxxxxxx demo:session:expirations:1761900000 demo:session:index:org.springframework.session.FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME:zhangsanSession ID出现在了三类Key里说明Session确实被托管到Redis了。为了进一步验证“重启不丢登录态”直接把Spring Boot进程kill掉再启动重新调用/current接口并用之前拿到的同一个Cookie也就是Session ID访问你会发现登录态还在。这个实验在单体内存Session时代是根本做不出来的——进程一死Session跟着没了。3.5 别急着上线Cookie的默认行为和安全参数Spring Session默认通过Cookie把Session ID传给浏览器Cookie名叫SESSION。有几个默认行为容易在生产环境咬你一口Cookie的Path默认是/意味着同一域名下的所有路径都能拿到这个Cookie。单体服务没问题但微服务场景下如果你的不同服务前缀不同要确认Path覆盖了所有需要会话的路径。Cookie默认不设置Secure走HTTPS的站点建议配置server.servlet.session.cookie.secure: true防止Session ID在HTTP明文链路里被截获。Cookie默认不设置HttpOnly虽然Spring Session默认启用了HttpOnly但不同版本表现不一建议显式配置server.servlet.session.cookie.http-only: true避免脚本通过document.cookie窃取Session ID。SameSite策略Spring Boot 2.6之后Spring Session默认把Cookie的SameSite设置为Lax。这种策略下跨站POST请求不会带上Cookie同站跨端口、跨子域场景也可能引起“登录态莫名丢失”的错觉。如果你是用localhost:8080访问前端页面localhost:8081调用后端API这个时候的跨域情况是“同站跨端口”。好在Cookie的SameSite规则通常按站点而不是端口判断浏览器一般会放行但一旦部署到多级域名环境下问题就会集中爆发。第4部分我会专门讲微服务场景下Cookie到底该怎么配。4. 微服务场景下的配置进阶跨域、Cookie策略与多实例共享单体服务把Session放进Redis只是第一步。真正上了微服务架构问题就变成了“多个服务怎么共享一份登录态”“前端跨域请求怎么把Cookie带过去”“网关怎么做统一认证”。4.1 多个服务共享同一个Sessionnamespace统一是前提微服务场景下用户登录可能发生在auth-service认证服务而真正的业务请求打到了order-service订单服务。这两个服务必须能读到同一个Session。要做到这一点核心条件是“读同一个Redis、用同一个namespace”。假设auth-service的respository namespace配置成了auth:sessionorder-service配置成了order:session那就等于两个服务各存各的钥匙永远不可能共享登录态。正确的做法是在两个服务里都配置同一个namespace比如统一叫platform:session。接着是依赖和自动配置的差异。比如订单服务里没有用户的登录逻辑它只是在每次请求进来时通过SessionRepositoryFilter去Redis里读Session所以也要加spring-session-data-redis依赖也要配置同一个namespace。从代码层面看业务侧只需要这么一段配置spring: session: store-type: redis redis: namespace: platform:session这里有个容易忽略的坑多个服务共享同一个Redis实例时如果A服务Session里存的对象B服务没有对应的类定义使用JDK序列化时会出现类加载失败。强烈建议多服务共享Session时Session里只放简单的、每个服务都能理解的数据结构——比如只放userId、username、roles这些字段不要放某个服务私有的复杂对象。要是实在要放对象就定义成共享模块里共用的DTO保证所有服务都能加载到这个类。4.2 Cookie跨域SameSite、Domain、前端withCredentials三件套微服务前后端分离以后最常见的形态是前端站点在www.example.com后端API在api.example.com浏览器发起跨域AJAX请求。这个场景下Cookie要能正常携带三个条件缺一不可第一后端要允许前端域名跨域并且明确允许携带凭证。用Spring Boot的CORS配置举例Configuration public class CorsConfig { Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config new CorsConfiguration(); config.setAllowCredentials(true); config.addAllowedOrigin(https://www.example.com); config.addAllowedHeader(*); config.addAllowedMethod(*); return config; } }这里最关键的是setAllowCredentials(true)。如果你用addAllowedOrigin(*)通配所有来源那么浏览器会拒绝携带Cookie的跨域请求——因为凭证模式下不允许使用通配符来源。生产环境务必把addAllowedOrigin改成具体的前端域名。第二Cookie的SameSite属性要放宽。前面说过Spring Session默认是Lax。对于跨站场景比如www.example.com的前端调用api.example.com的接口对浏览器来说这就是跨站请求Lax策略下一般只在顶层导航的GET请求里才带Cookie。要放开限制可以自定义Cookie序列化器Bean public CookieSerializer cookieSerializer() { DefaultCookieSerializer serializer new DefaultCookieSerializer(); serializer.setCookieName(SESSION); serializer.setCookiePath(/); serializer.setDomainName(example.com); serializer.setSameSite(Lax); serializer.setUseHttpOnlyCookie(true); return serializer; }看到setDomainName(example.com)这里得注意如果你的服务部署在api.example.comCookie的Domain设置成example.com可以让www.example.com和api.example.com共享这个Cookie这是同级域名共享Session的常见做法。但如果你是用localhost:8080访问前端、localhost:8081访问后端Domain要留空或设置成localhost否则浏览器会直接丢弃这个Domain无效的Cookie。第三前端发请求必须显式带上凭证。前端axios得这样配置axios.defaults.withCredentials true;不加这一行就算后端配置全对浏览器的跨域请求也不会携带Cookie。这三个是“三件套”少了哪个表现为可能略有不同但结果都一样无法维持登录态。4.3 网关统一认证路由转发时不把Session校验散落在每个服务有了共享Session之后是不是每个服务都要写一遍“读Session、解析用户、没登录就拒绝”当然不应该这样。网关层做统一认证是微服务架构里更优雅的做法。以Spring Cloud Gateway为例核心是一个全局过滤器Component public class SessionAuthGlobalFilter implements GlobalFilter, Ordered { Override public MonoVoid filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request exchange.getRequest(); // 白名单路径直接放行 if (isWhitelist(request.getPath().value())) { return chain.filter(exchange); } // 获取前端传过来的Session Cookie String sessionId request.getCookies().getFirst(SESSION) null ? null : request.getCookies().getFirst(SESSION).getValue(); if (sessionId null) { exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); return exchange.getResponse().setComplete(); } // 用ReactiveRedisTemplate或WebFlux的SessionRepository校验Session return sessionRepository.findById(sessionId) .flatMap(session - { if (session.isExpired()) { exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); return exchange.getResponse().setComplete(); } // 把用户信息放进请求头下游服务直接使用 ServerHttpRequest mutated request.mutate() .header(X-UserId, String.valueOf(session.getAttribute(userId))) .build(); return chain.filter(exchange.mutate().request(mutated).build()); }) .switchIfEmpty(Mono.defer(() - { exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); return exchange.getResponse().setComplete(); })); } }这套方案的好处是下游服务不再需要关心Session细节网关验证通过后把用户身份塞进请求头X-UserId各业务服务取这个请求头即可。Session数据的读取只发生在网关一层下游的Redis压力也随之下降。注意网关里要引入spring-session-data-redis的响应式版本依赖也就是spring-boot-starter-data-redis-reactive。4.4 多实例部署时的Session失效与Redis高可用多实例一旦遇到负载均衡必须确保所有实例连的是同一个Redis。很多人把spring.redis.host配置成redis-1.internal这种单点地址Redis挂了整个系统全挂。生产环境推荐的做法是配置Redis Sentinel或Redis Cluster以Sentinel为例spring: redis: sentinel: master: mymaster nodes: - sentinel-1:26379 - sentinel-2:26379 - sentinel-3:26379这样应用会自动通过Sentinel感知当前的主节点主节点故障时自动切换到从节点。Session缓存的RDB或AOF持久化也要做否则Redis一重启所有用户又被踢下线。5. 线上踩坑清单与排查思路这部分我整理了过去半年在几个项目里实际遇到过的典型问题每个问题都按“现象 - 根因 - 解决过程”来写希望能帮你在遇到类似情况时少走弯路。5.1 Redis Key变成一串二进制乱码序列化器没换现象项目启动后用redis-cli执行keys *看到大量类似\xAC\xED\x00\x05t\x00\x1cspring:session:sessions:...的Key开头。代码里用redisTemplate.opsForHash().get(session::user, name)读不到值而用Redis Desktop Manager却能看到数据。根因Spring Session默认的JdkSerializationRedisSerializer把Key和Value全部用JDK序列化串序列化Key在Redis里显示为一堆转义字符。更麻烦的是如果同一份数据被不同的客户端操作比如在代码里用默认的StringRedisTemplate去读序列化方式不一致自然读不到。解决过程我给RedisIndexedSessionRepository替换了自定义的RedisSerializer关键是Key用StringRedisSerializerValue用GenericJackson2JsonRedisSerializerBean public RedisSerializerObject springSessionDefaultRedisSerializer() { return new GenericJackson2JsonRedisSerializer(); } Bean public RedisIndexedSessionRepository sessionRepository(RedisConnectionFactory factory) { RedisIndexedSessionRepository repository new RedisIndexedSessionRepository(factory); repository.setDefaultSerializer(new GenericJackson2JsonRedisSerializer()); repository.setSessionIdGenerator(new UuidSessionIdGenerator()); return repository; }如果你的项目里RedisIndexedSessionRepository是自动配置的直接提供RedisSerializerObject这个Bean就会被自动采用。改完之后Redis里的Session数据变成可读的JSON排查问题的效率直线上升。注意GenericJackson2JsonRedisSerializer会把类型信息写进JSONclass字段如果Session里放了某个自定义类反序列化时依然需要类存在。想彻底去掉类型信息可以用GenericToStringSerializer但普通的Map和String存取没问题复杂的嵌套结构容易失去类型。5.2 每次请求都新建SessionFilter顺序和路径匹配的坑现象接口调用方发现每次请求返回的Set-Cookie都是一个新的SESSION用户永远处于未登录状态但Redis里查spring:session:sessions:却能看到一条条Session记录在不断增加。根因排除掉Cookie本身没保存的情况后最典型的两个原因一个是springSessionRepositoryFilter没有在业务Filter之前执行另一个是Cookie的Path配置不对。如果项目里还有其他自定义Filter它们在springSessionRepositoryFilter之前执行时调用了request.getSession()此时Spring Session的包装还没生效就会创建出原生的、不存在于Redis里的Session还有一种情况自定义Filter的order在Spring Session前面导致Session读取时机不对。解决过程显式声明springSessionRepositoryFilter的Ordered优先级或者在配置类里对自定义FilterRegistrationBean设置setOrder(Ordered.HIGHEST_PRECEDENCE 1)确保Spring Session的过滤器链先执行。排查Cookie Path时重点看setCookiePath(/)是否覆盖了所有业务路径如果服务部署在/api子路径下Cookie Path就要设为/api。5.3 Session数据删不掉、过期也不消失Redis键空间通知没开启现象Session的maxInactiveIntervalInSeconds设为30分钟但30分钟后用redis-cli执行type spring:session:sessions:id仍然能看到KeyT Ever显示的值也没有被设置。根因Spring Session要监听Session过期事件依赖Redis的keyspace notifications。如果Redis服务器配置里notify-keyspace-events为空或具体事件类型不包含Expired那么Spring Session只会依赖自己缓存的expirations分片ZSet去主动清理某些版本的实现会出现“过期时间到了但数据还在”的假象。解决过程排查时先在Redis里执行CONFIG GET notify-keyspace-events看到空字符串就确定问题在这里。处理办法是在Redis配置文件redis.conf中加一行notify-keyspace-events Ex然后重启Redis或者执行CONFIG SET notify-keyspace-events Ex在线生效。注意E表示Key事件x表示过期事件。配好之后再观察spring:session:expirations:*里的Session ID是否会在过期时间点被消费掉以及对应spring:session:sessions:*是否被删除。5.4 类型转换异常多个微服务反序列化同一个Session时“各执一词”现象用户登录后在A服务正常请求跳到B服务时抛出类似ClassCastException: java.util.LinkedHashMap cannot be cast to com.demo.UserInfo的异常。根因A服务往Session里放了一个UserInfo对象B服务也引用了同名的UserInfo类但两个类可能来自不同的jar包或代码版本序列化时写入的类描述信息和B服务的类不匹配。如果用的是GenericJackson2JsonRedisSerializerJSON里的class标记的是A服务的全限定类名B服务加载这个类时如果类路径上根本没有这个类或者类版本不一致就会反序列化失败或者类型转换失败。解决过程规范做法是Session里只保存稳定且简单的数据类型比如userIdLong、usernameString、rolesList 。所有服务需要用户信息时都通过网关放入请求头的X-UserId去用户服务查询而不是把完整对象塞进Session。这种做法不仅规避了序列化问题还让Session的体积变小Redis内存和带宽压力也跟着减小。如果确实想把对象放Session里就把这个类放到所有服务共享的基础库中保证类名、字段、包路径完全一致。5.5 Redis连接风暴高并发下Lettuce连接池耗尽现象某个大促活动开始时后台日志大量出现io.lettuce.core.RedisCommandTimeoutException: Command timed out after 3 second(s)诡异的是Redis本身的CPU、内存负载都正常甚至INFO显示连接数拉到很高。根因Lettuce默认使用共享连接本不该这么容易耗尽但Spring Session的RedisConnectionFactory如果没有配置连接池或者连接池的max-active设置太小高并发的Session写入会阻塞在获取连接上。更隐蔽的是有些团队在服务里同时用了RedisTemplate缓存和Spring Session两套Redis访问通道两者共用一个连接池缓存流量一旦波动Session读写也会受影响。解决过程给Redis连接池配置合理的参数并且针对Session和业务缓存分别设置连接池上限spring: redis: lettuce: pool: max-active: 32 max-idle: 16 min-idle: 4 max-wait: 1000ms同时把spring.redis.timeout调高到3000ms甚至5000ms给网络抖动的场景留出余量。排查顺序上先从redis-cli --stat看连接数再用redis-cli monitor观察命令执行耗时确认瓶颈是不是出在应用侧连接排队。5.6 排查套路总结三把快刀如果你遇到Session问题但一头雾水我建议按照以下顺序快速缩小范围看Redis里有没有Session数据执行KEYS spring:session:*如果什么都没有说明Session压根没写进去问题大概率在SessionRepositoryFilter或序列化配置。看浏览器有没有正确携带Cookie使用浏览器开发者工具检查响应里的Set-Cookie是否符合预期请求头里Cookie: SESSIONxxx是否存在。没有Cookie后端再多努力也无济于事。看服务端Session ID是否变化在接口里打印request.getSession().getId()对比多次请求的ID是否一致。一致但登录状态丢失说明Session内容读写有问题不一致说明Cookie丢失、被覆盖或每次创建了新的Session。这三板斧能筛掉80%的初级问题剩下的再深入Redis的键空间通知、过滤器链顺序、序列化器实现逐一排查。6. 会话方案之外Spring Session与JWT的选型思考写到这里可能会有读者问现在大家都在用JWT为什么还要用Spring Session这套基于服务端存储的方案这是个好问题我在不同项目里两类方案都深入实践过说一些个人体会。Spring Session Redis的本质是“服务端保存状态客户端只保存一个不透明的Session ID”。它的核心优势在于服务端可控性强主动注销调用session.invalidate()就能立即让Session失效JWT则要等它自然过期或者依赖黑名单。踢人下线有了按用户索引的Session管理可以轻松遍历某个用户的所有Session并删除实现“强制下线”功能。权限实时变更用户的角色、封禁状态一变通过Session存储的用户信息立即更新JWT里的权限声明只能等着重新签发。登录态生命周期管理可以随时看到在线用户数、活跃Session数也能做超过固定时长自动失效这类安全策略。JWT的优势体现在另一个方向无状态、可扩展性好、多端解码容易适合移动端、开放API、第三方授权这些前后端完全分离、且对服务端存储有强要求的场景。但JWT的短板也很明显——无法主动失效一旦签发出去就算服务端把用户删了拿到Token的人只要没过期就能继续访问只能通过维护黑名单去弥补而这个黑名单本身又离不开Redis。所以我个人的选型建议是场景推荐方案内部微服务架构、有用户登录态、需要踢人/注销/在线管理Spring Session Redis对外纯API、无浏览器Cookie、无状态诉求强JWT多端Web/APP统一登录网关层用Spring SessionAPP端可以另签短期JWT既有Spring Session又想兼容Token调用加一层JWT解析过滤器把解析出的userId写入Session上下文如果你现在正处在“要不要把Session方案换成JWT”的犹豫期我的建议是不要只看某个技术博主说“JWT更先进”就冲动重构。看你的业务有没有服务端失效诉求没有这个诉求、各服务完全可以无状态化JWT是清爽的有那就老老实实用Spring Session Redis。我见过太多团队把登录态从Session强行改成JWT结果为了支持踢人功能被迫在Redis里维护了一个庞大的Token黑名单绕了一圈又回到了服务端存储的老路上。写在最后的实战体会这篇文章从Spring Session的底层原理一路写到了微服务架构下的配置、排坑和选型信息量不小。老实说Session这套东西单独拎出来任何一个环节都不难最容易出问题的永远是各个环节之间的衔接Cookie Domain写错、序列化器不一致、Redis过期事件没开、连接池被缓存流量拖垮——每一个我都真实踩过每一次排查都让我对Spring Session的理解更深一层。如果只能给出一条建议那就是在引入Spring Session之前先把你的Session里到底该放什么想清楚。只放userId和username这种稳定字段后面会少掉一大半的序列化和跨服务共享问题。另外别等到上线了才想起来看Redis里的Session数据长什么样本地开发阶段就把序列化器、namespace、Cookie策略这些基础配置对齐比任何“上线后补救”都省事。这套方案后续还可以往这些方向扩展为Session增加统一审计日志、把Redis换成Redis Cluster做更大规模的Session存储、在网关层实现基于Session的灰度发布等等。你在落地过程中遇到什么奇怪的现象欢迎带着你的排查日志来聊很多问题的根源其实都是一些看起来很不起眼的配置。