接手过一个某平台旧系统的服务拆分改造,当时遇到的最头疼问题不是接口性能,而是用户登录态在多个服务节点之间互相不认识。用户在首页登录成功,跳到订单页马上又变成未登录,排查下来才知道是Session存储在了各自节点的内存里,负载均衡一转发,下一个节点根本不认识这个用户。那段时间我把市面上主流的Session共享方案都过了一遍,最后还是选了Spring Session配合Redis这条路。这篇文章就把从原理到落地、从配置到踩坑的完整过程记录下来,给正在做多节点部署或者微服务拆分的朋友做个参考。
1. 从“单机够用”到“多节点掉线”:Session一致性问题的真实场景
1.1 传统Session的宿命:容器内存
在没有引入Spring Session之前,我们的Session生命周期完全由Web容器管理。以Tomcat为例,用户第一次请求过来,容器生成一个sessionId,往Set-Cookie里写JSESSIONID,同时在后端内存里挂一个HttpSession对象。后续请求只要带上这个Cookie,容器就能在本地内存里找到对应的Session。
单机部署时这套机制运行得非常好,因为所有请求都落在同一个进程里。一旦节点数变成两个、三个,问题立刻暴露:假设负载均衡器把登录请求转发到了节点A,节点A的内存里存了一份Session;下一次普通请求被转发到了节点B,节点B翻遍自己的内存也找不到对应的Session,于是把它当作新访客,重新生成一个新的Session返回给浏览器。用户感知就是:页面里状态丢失,登录态时有时无。
这里有一个容易被忽略的细节:当节点B生成新Session时,浏览器里的JSESSIONID也会被覆盖。所以即使用户刷新页面,请求再次回到节点A,由于Cookie已经变成节点B生成的新的sessionId,节点A依旧找不到旧Session。最终表现就是无论怎么刷新,用户都处于“永远登录不进去”的状态。
1.2 常见解决思路的优劣对照
当时团队内部讨论过几个方案,我整理了一张对比表,贴出来供参考:
| 方案 | 核心思路 | 优点 | 缺陷 |
|---|---|---|---|
| 负载均衡粘性会话 | 通过IP Hash等方式固定后端节点 | 改造量小 | 节点故障后Session丢失,扩容缩容要重新Hash |
| 数据库存储Session | 把Session数据持久化到数据库 | 实现简单 | 每次请求读写数据库,性能差,数据库压力大 |
| Redis共享Session | Session统一存到Redis | 性能较好,节点无状态 | 引入额外中间件,需要处理序列化与缓存一致性 |
粘性会话看起来最省事,但实际上问题很多。如果某个节点宕机,这个节点上挂着的所有在线用户的Session会全部失效;而且当容器动态扩缩容时,Hash结果会剧烈变化,Session大量失效。数据库方案在低并发场景下可以跑,但并发一上来,每个页面请求都要查一次Session表,这个开销很难让人接受。
1.3 为什么最终选择Spring Session落地
最终决定用Spring Session,主要是看重它“应用无感”的特性。Spring Session并没有要求我们手写Session存储逻辑,而是把HttpSession的操作统一接管,业务代码里写的还是request.getSession()、session.setAttribute()这些老API,底层存储却被替换成了Redis。这意味着改造期间不需要改动大量业务代码,只需引入依赖、调整配置,再对Redis数据结构做验证,就能逐步替换。
另一个考虑是Spring Session具有良好的隔离性。它不会影响Spring容器中其他基于HttpSession的组件,适配自己的安全性管理和超时策略也相对独立。对于需要长期维护的项目来说,这种低侵入的改造方式比重新造一套Session体系要稳妥得多。
2. Spring Session的“无感替换”原理
2.1 过滤器如何拦截Session读写
Spring Session的核心是一个过滤器,这个过滤器在整个请求链的最前端生效。当请求到达应用时,过滤器会基于当前的HttpServletRequest和HttpServletResponse创建出两个“包装对象”,这两个包装对象把原生的请求、响应对象包在里面,之后往下传给Spring MVC或Servlet的时候,调用的其实就是被包装过的方法。
关键点在于request.getSession()方法被做了手脚。业务代码里调用getSession()时,包装对象不再去访问容器内存,而是把请求转给SessionRepository,让它根据sessionId从Redis中加载数据。如果Redis里没有对应的Session,包装对象会根据配置决定是返回null还是新建一个Session。整个读写过程对业务代码完全透明,业务方甚至感觉不到Session已经从内存换到了Redis。
2.2 装饰模式下ServletRequest行为变化
装饰器模式是理解Spring Session的关键。我举一个具体的例子:原生ServletRequest的getSession(boolean create)在被包装后,会先判断当前请求SessionRepository中是否已存在有效记录。如果用户带上了一个Cookie,但Cookie里的sessionId在Redis中不存在,那getSession(true)会创建新Session并写入Redis,同时通过响应里的Cookie回写给浏览器。
这里有个细节经常让人困惑:Spring Session自己的Cookie默认名称是SESSION而不是JSESSIONID。如果不做额外配置,浏览器里会同时存在两个不同的会话Cookie,这会导致某些场景下会话ID混乱。我们在配置时可以把Cookie名称统一调整为熟悉的JSESSIONID,也可以结合自己系统的前端策略来决定,这个后面章节会详细讲。
另一个行为变化是Session失效处理。业务代码调用session.invalidate()时,包装对象会调用SessionRepository的deleteById方法,把Redis中对应的Hash结构删除,同时清理Cookie。这个操作是即时生效的,不像之前容器内存中还要等待GC回收。
2.3 Redis数据结构和TTL机制
Spring Session在Redis中的存储结构本质上是一个Hash。默认的key格式是spring:session:sessions: + sessionId,里面存的字段包括creationTime、lastAccessedTime、maxInactiveInterval以及业务方自定义的attribute等。除此之外,Spring Session还会维护一个名为spring:session:expirations的Sorted Set结构,用来记录会话的过期时刻,从而实现后台定时清理。
TTL机制是Spring Session实现会话超时的核心。每次请求访问Session时,系统会更新lastAccessedTime,同时根据maxInactiveInterval重新计算过期时间。Redis的expire和过期Sorted Set两种策略共同协作,既保证了Redis从存储层自动淘汰过期数据,又保证了Spring Session自身能主动清理过期Session。这套机制比单靠Redis过期时间要严谨,因为它还考虑到了清理被过期Session引用的相关属性。
3. 环境搭建与最小可运行配置
3.1 依赖引入与版本对齐
搭建环境的第一步是引入依赖。如果项目使用的是Spring Boot,直接添加spring-boot-starter-data-redis和spring-session-data-redis两个依赖即可。需要注意版本对齐问题,尤其是Spring Boot 3.x之后底层的Redis客户端已经从Jedis切换为Lettuce,某些老项目里如果同时存在Jedis的旧依赖,很容易出现连接池冲突。
我当时使用的是Spring Boot 2.7.x版本,依赖配置大致如下:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.session</groupId> <artifactId>spring-session-data-redis</artifactId> </dependency>如果用的是Spring Boot 3.x,基本依赖不变,但要注意Spring Session版本已经升级到3.x,部分配置项的名称和默认值有调整。最好把依赖的版本交给Spring Boot的依赖管理BOM统一控制,不要手动指定,免得出现版本不匹配导致的方法签名错误。
3.2 连接配置与@EnableRedisHttpSession细节
依赖引入之后,配置Redis连接信息:
spring: redis: host: 127.0.0.1 port: 6379 password: timeout: 3000ms在启动类或者配置类上加上@EnableRedisHttpSession注解:
@SpringBootApplication @EnableRedisHttpSession(maximumInactiveIntervalInSeconds = 1800) public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }maximumInactiveIntervalInSeconds是会话最大空闲时间,这里设置成30分钟,业务方可以根据自己的安全要求调整。这个注解默认使用RedisIndexedSessionRepository作为SessionRepository的实现,它把会话数据以Hash结构存储,同时维护过期索引。
3.3 验证Session真的进了Redis
配置完成之后,可以做一个非常简单的验证:启动一个接口,往Session里放一个用户对象,然后打开Redis客户端看key是否存在。
我用Redis CLI来查看:
127.0.0.1:6379> keys spring:session*正常情况下会看到两类key:
- spring:session:sessions:xxxx-xxxx-xxxx
- spring:session:expirations:yyyyyyyy
再用HGETALL查看Hash内容:
127.0.0.1:6379> HGETALL spring:session:sessions:xxxx-xxxx-xxxx能看到creationTime、lastAccessedTime、maxInactiveInterval这些元数据,如果业务里设置了attribute,Redis里也会出现对应的字段。这个验证很关键,它确认了业务代码使用的Session确实已经落到了Redis。
4. Session生命周期与Cookie行为:容易忽视的隐性配置雷区
4.1 超时时间有多个入口
关于Session超时,很多开发者会以为只配置一个地方就够了,实际上这里存在多个入口,优先级还不太一样。首先是@EnableRedisHttpSession里的maximumInactiveIntervalInSeconds,这是Spring Session层面默认的最大空闲时间;其次是Spring Boot里的spring.session.timeout,这个配置在某些版本中会影响Session的有效期;再往下还有Web容器的session-timeout设置,虽然被Spring Session接管后这个值不一定生效,但在老项目迁移时依然容易造成迷惑。
从实际操作来看,最稳妥的方式是统一通过Spring Session的配置来控制超时时间,同时把application配置里其他容易产生歧义的会话超时配置注释掉。如果项目中有部分接口需要更长的会话时间,可以通过自定义CookieSerializer或重写SessionRepository的方法来单独控制,不要试图用同一套配置去覆盖所有场景。
4.2 Cookie默认名与JSESSIONID的差异
前面提到过,Spring Session默认的Cookie名是SESSION。这本身没什么问题,但对于迁移项目就需要注意了:旧的系统使用JSESSIONID,前端在登录成功后往往存的是旧的Cookie,如果后端切换成Spring Session而前端没有适配新Cookie名,那么所有用户都会被当成新访客。
解决方案是在配置类中自定义一个CookieSerializer:
@Bean public CookieSerializer cookieSerializer() { DefaultCookieSerializer serializer = new DefaultCookieSerializer(); serializer.setCookieName("JSESSIONID"); serializer.setCookiePath("/"); serializer.setCookieMaxAge(30 * 60); return serializer; }cookieMaxAge我这里设置成和Session超时一致,可以避免浏览器端的Cookie在Redis里的Session还活着时就被提前清掉。需要留意setCookiePath,如果应用跑在某个子路径下,不设置路径就会导致Cookie在各个路径之间不可共享。
4.3 Session创建时机与节省序列化的开销
还有一个容易忽略的点是Session的创建时机。如果业务代码在登录接口里调用了request.getSession(true),Session会被立即创建并写入Redis;但很多登录流程实际上会先通过业务逻辑校验,再在成功后把用户信息塞进Session。比较合理的做法是:在确认为合法请求后,才通过request.getSession()获取或创建Session,避免无效请求预先占用Redis空间。
另外,不是每一个请求都需要访问Session。有些页面是完全公开的,比如静态资源、开放API等。如果过滤器对每个请求都去Redis检查一遍Session,会额外增加一次网络IO和序列化开销。Spring Session本身有策略可以关闭部分URL的Session追踪,也可以通过自定义过滤器对公开路径进行跳过。量大的时候这个优化能省下不少Redis请求量。
5. 序列化策略的选择:从“能跑”到“能在Redis里看懂数据”
5.1 JDK序列化的乱码问题
刚接入Spring Session时,项目直接用了默认的序列化方式,Session里的业务对象确实是存进Redis了,但打开Redis里一看,全都是\xAC\xED\x00\x05t...这种二进制乱码。原因很简单,Spring Session默认使用JDK自带的序列化机制,对象变成了一串Java特有的字节流,不仅肉眼无法阅读,而且只有Java应用才能反序列化。
JDK序列化还有一个潜在问题:被序列化的对象必须实现java.io.Serializable接口,而且一旦业务对象的属性结构发生变化,旧数据可能无法被反序列化。这意味着上线之后,如果改了一个字段名或类型,可能导致之前存的Session读取失败,用户会被强制踢下线。
5.2 用Jackson/自定义序列化器的注意点
为了解决乱码问题,业内通用的做法是改成JSON序列化器。Spring Data Redis针对RedisTemplate和Session存储都提供了对应序列化器配置入口。我们可以注册一个RedisSerializer
注册Bean的方式如下:
@Bean public RedisSerializer<Object> springSessionDefaultRedisSerializer() { return new GenericJackson2JsonRedisSerializer(); }注意这里Bean的名称必须是springSessionDefaultRedisSerializer,Spring Session在创建Repository时才会优先注入这个序列化器。用了JSON序列化之后,Redis里的数据变成明文JSON,排查问题时直接用HGETALL就能读懂用户Session内容,方便很多。
5.3 一个实际踩坑:某字段反序列化后丢失
JSON序列化也不是没有坑。有一次我们把一个用户对象放在Session里,这个对象有一个LocalDateTime类型的字段。上线后第二天有用户反馈登录状态异常,通过Redis查看Session数据,发现JSON里LocalDateTime字段变成了一段带数组格式时间的表示。数据本身还在,但反序列化回对象之后,字段值变成null。
原因是GenericJackson2JsonRedisSerializer默认对Java 8时间类型的支持不完整。解决方案是在注册序列化器之前,引入JSR310模块,或者改用一个自定义的ObjectMapper,为LocalDateTime配置全局的序列化与反序列化格式。
ObjectMapper mapper = new ObjectMapper(); mapper.registerModule(new JavaTimeModule()); mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer(mapper);这种字段丢失问题在测试环境不容易发现,因为测试数据往往比较固定,不会覆盖所有时间字段场景。建议上线前把Session里的典型对象全部造一遍数据,写入后立即读出,对比字段值是否完整。
6. 多节点部署验证与故障排查
6.1 本地双实例验证步骤
验证Session共享最直接的方式是本地启动两个不同端口的服务实例,比如8080和8081,两个实例连同一个Redis,然后通过浏览器分别访问两个端口的页面。正常情况下,用户从8080端口登录,带着Cookie去请求8081端口,依然能读到登录态。
如果是在本地模拟,需要注意Cookie不能跨端口混淆。最简单的方法是用curl手动指定Cookie:
curl -v -c cookies.txt http://127.0.0.1:8080/user/login curl -v -b cookies.txt http://127.0.0.1:8081/user/info只要第二个请求能正确返回用户信息,说明Session已经在两个节点间共享。如果拿不到数据,先用HGETALL检查Redis中是否存在对应的Session记录。若Redis里有记录但接口读取不到,大概率是Cookie未带上,或者Cookie的path配置不一致。
6.2 登录后立刻掉线的排查路线
上线后我遇到频率比较高的故障是“登录成功,跳转两次后掉线”。排查这类问题有一定的固定路线:先确认浏览器Cookie里的name和value是否存在,再看这个sessionId在Redis里是否存在,最后看返回给浏览器的Set-Cookie是否覆盖了原有Cookie。
第二条链路里有一个常见陷阱:多个服务集群如果各自制定了不同的CookieName或CookiePath,那么一次请求可能写入两个不同类型的Cookie。用户带上旧Cookie到新节点,新旧Cookie互相干扰,导致Session读取出现随机性。解决方法是全局统一Cookie配置,并且用环境变量或配置中心管理,避免各节点配置漂移。
6.3 上线前要做的Redis安全与监控检查
Session数据敏感性比较高,上线前Redis的安全检查不能省。至少需要确认Redis没有暴露在公网,设置了访问密码,并且关闭了危险命令的自动执行。如果应用和Redis部署在同一内网,建议参考基线安全配置,限制来源IP连接Redis端口。
监控方面,Spring Boot Actuator的health端点中已经包含Redis健康检查。除了基础健康状态,我还会额外关注Redis的平均操作耗时和连接数,因为Session读取会出现在用户每一次请求的链路里,一旦Redis出现阻塞,用户会有明显的感知。我们之前遇到过慢查询导致个别请求的Session读取超过1秒,整体接口耗时被拉高。排查之后发现是某个大字段的JSON反序列化过于复杂,后来对这个字段做了精简存储,问题才解决。
另外建议对Session过期数量、Session占比内存大小做个简单的定期巡检。Spring Session在Redis里产生的key数量级会跟着在线用户数线性增长,如果用户基数很大,要注意Redis内存容量规划,设置合适的淘汰策略和持久化策略。Session虽然允许一定程度的丢失,但尽量不要因为Redis内存满了触发key淘汰导致全体用户被下线。
写在最后的经验小结
从最初被Session一致性搞得焦头烂额,到最终把Spring Session和Redis稳定跑起来,整个过程最深的体会是:技术选型其实不难,关键是理解数据到底存在哪里、什么时候被创建、什么时候被清理。Spring Session把会话数据从容器内存里抽离出来,本质上是把Session当成了一种可扩展的存储数据来看待,这让多节点部署和微服务拆分都变得轻盈很多。如果你当前也正在被多实例登录态问题困扰,建议先按本文的验证步骤做一次最小化复现,再根据自己业务的用户对象和响应时间要求调整超时、序列化和Cookie设置。这样落地的成功率会高很多,也不会被一些莫名其妙的配置问题挡住脚步。