说实话,写这篇东西的起因很直接:我接手过一个访问量正在往上走的项目,系统之前一直是单机部署,内网压测、外网访问都挺正常,可一旦把应用从1个实例扩到3个实例,线上立刻出问题。用户前一个请求还在正常操作,下一个请求回来就被引导去重新登录,客服那边一天能收到几十条“为什么老让我重新登录”的反馈。我排查了整整两天,中间试过调负载均衡策略、改Tomcat配置,效果都不彻底,最后拍板换成了标题里这套组合:Redis + Spring Session。
这不是网上常说的“临时绕过”,而是把Session的存储位置从根本上搬到了应用之外,让每一个实例都变成无状态节点。如果你正在做微服务拆分、多实例部署,或者只是单纯想把单机应用水平扩容,这套思路应该能给你一个比较彻底、能直接落地的解决路径。下面我把整个问题的来龙去脉、原理、实操步骤和上线后踩过的坑都梳理一遍,内容会偏“实战记录”风格,你可以边看边在本地复现。
1. 单机Session的局限性:多实例部署后为什么用户被疯狂踢下线
1.1 一个真实故障:轮询策略导致用户批量掉线
先说当时的现象。系统从单机扩到3个实例,前面挂了负载均衡,策略是最常用的“轮询”。逻辑上没什么问题:3台机器平均分担流量,哪台都能处理请求。结果用户一登录,请求被分到实例A,登录成功,JSESSIONID写进浏览器Cookie;刷新页面后下一个请求被轮询到实例B,实例B在自己的本地内存里根本找不到这个Session,于是判定用户未登录,直接重定向到登录页。
最迷惑人的是这个问题不是100%必现。同一个用户点几次,有时能保持登录,有时被踢出来,完全取决于当前请求被分发到哪台机器。我在本地复现的时候,开着开发者工具看Network面板,清一色的302跳转,Cookie明明没丢,服务器就是不认。后来往日志里加了节点信息才发现,所有“登录无效”的请求都落在非登录节点上。到这一步,问题的定位已经很明确:HttpSession默认只存在于单个JVM进程内,实例之间互相不知道对方手里的Session。
1.2 理解HttpSession的“本地宿命”:状态为什么只在单个实例里
Java Web开发里,HttpSession的默认实现类是存在应用服务器内存里的,也就是Tomcat、Jetty这类容器的JVM堆。当你在代码里执行request.getSession().setAttribute("user", user),底层就是在当前实例的堆内存里维护一个Map结构,key是Session ID,value是属性集合。这个Map的生命周期被死死绑定在单个JVM上。
单机部署时没有问题,因为所有请求都打到同一个JVM,Session数据天然可见。但一旦拆成多个实例,每个实例都有自己独立的内存,A实例保存的Session对B实例来说就是“不存在的数据”。负载均衡把请求撒到不同实例上时,用户会话自然就“丢”了。本质是:会话状态(Session State)和计算节点(Application Instance)被绑死在一起,横向扩容时状态没有跟着扩容。
1.3 ip_hash和Session复制为什么只是“缓解”而不是“修复”
我第一反应是改负载均衡策略,把轮询改成ip_hash,让同一IP的请求固定打到同一台实例。这个方案在内部办公网络环境下验证是有效的,但放到公网环境就露馅了。
移动网络下用户经常切换基站,出口IP会变化;公司、家庭、4G/5G网络来回切换时IP也不固定;还有一些代理和NAT场景,同一个用户每次请求的源IP根本不是同一个。IP一变化,ip_hash就把请求分到另一台实例,用户一样被踢。更麻烦的是,即使IP一直稳定,如果某台实例要做滚动发布或重启,固定在这台实例上的所有在线用户会在重启期间集体掉线。ip_hash本质上是在“请求入口”层面想办法把用户留在同一个节点上,它没有解决“Session状态不共享”的根因,只是降低了状态串台的概率。
我也看过Session复制方案。Tomcat支持把Session变更广播到集群中的其他节点,这样每个实例都复制一份全量Session。但复制带来的问题同样明显:随着在线用户增多,集群内需要同步的数据量成倍增长,广播风暴、网络开销、延迟抖动很快会出现。而且这个方案强依赖具体应用服务器厂商的实现,换容器、换云平台时兼容成本很高。Session复制把状态从一个节点复制到所有节点,本质上还是在“单机Session”的框架里打转,治标不治本。
2. Redis + Spring Session的组合原理:这为什么算“最终方案”
2.1 Spring Session接管了Session的生命周期,而不是存个key那么简单
要理解为什么这套组合能解决问题,先搞清楚Spring Session干了什么。Spring Session不是一个简单的“Session工具类”,它的核心是通过一个Servlet过滤器——SessionRepositoryFilter——把应用原本使用的HttpSession实现整个替换掉。
具体流程是这样的:请求进入Servlet容器后,SessionRepositoryFilter会先把原始的HttpServletRequest包装成一个SessionRepositoryRequestWrapper。当你调用request.getSession()时,它不再从容器本地的SessionManager里拿对象,而是从Spring Session配置好的SessionRepository里去取。这个SessionRepository可以是基于Redis、JDBC、MongoDB等任意持久化存储实现,我们这里用的是RedisSessionRepository。
整套机制对外暴露的接口仍然是标准的HttpSession,所以业务代码基本不用改动:以前怎么用session.setAttribute(),现在还是怎么用。Spring Session在背后帮你完成了Session的创建、读取、写入、过期删除,并且把数据序列化后存到Redis。这里有一个关键点:Session的状态不再存放在应用实例的本地内存,而是被抽离到一个独立的、所有实例都能访问的存储层。
2.2 Redis里到底存了哪些key:一个登录用户的数据结构长什么样
光说“把Session存到Redis”有点抽象,看完Redis里的实际数据结构你会有更直观的理解。Spring Session在Redis中主要维护以下几类key。
第一类是主数据key,默认前缀是spring:session:sessions:<sessionId>,类型是Hash。这个Hash里存了Session的创建时间、最后访问时间、最大不活跃间隔、以及你通过setAttribute写入的每一个业务属性。比如你用session.setAttribute("loginUser", userInfo),Redis里就会多出一个field,field名是sessionAttr:loginUser,value是序列化之后的用户信息。
第二类是过期索引key,格式是spring:session:expirations:<timestamp>,类型是Set。这里面保存了一批即将在指定时间点过期的Session ID,供后台任务定点扫描使用。Spring Session并不是完全依赖Redis的key过期机制来清理Session,因为Redis key过期事件并不可靠,可能丢失、可能延迟。它的策略是:每个被访问的Session都会刷新自己的最后访问时间,同时由一个专门的维护任务在到达过期时间点时,把还在集合里的Session ID找出来做显式删除。
第三类是查询索引key,格式是spring:session:findByIndexName:<索引名>:<索引值>,比如按用户名建立索引之后,可以快速查出某个用户当前对应的Session列表。做强制下线、在线用户列表这类功能时这个索引很有用。
通过这几种key的组合,Spring Session把Session的读写、过期、查询都安排得明明白白。Redis本身就是KV存储,Hash结构天然适合存储“属性集合”这种数据形态,TTL机制又和Session的过期时间能对应上。选Redis的根本原因,不是因为它能缓存,而是因为它提供了一整套分布式环境都能访问的、带超时能力的、结构足够灵活的数据存储能力。
2.3 这套组合带来的三个直接收益
收益是很直观的。第一是实例彻底无状态化。应用节点不再持有用户状态,新增机器、缩减机器、滚动重启,对在线用户没有任何影响。用户在某台机器上登录,下一次请求被负载均衡分到另一台机器,依旧能从Redis里拿到同一个Session。扩容从“有状态扩容”变成了“无状态扩容”,技术上轻松了一个量级。
第二是故障影响面大幅缩小。以前如果一台实例宕机,那台机器上的所有在线用户集体掉线。现在实例宕机,只要Redis集群还稳着,用户根本感知不到后端少了一台机器,请求被自动分发到其他健康实例,Session照常可用。
第三是统一了会话管理能力。你可以通过Redis里的Session数据做全局的用户登录态清理,比如后台强制踢人下线、限制账号同时在多端登录、构建实时在线用户列表。这在单机Session时代几乎不可想象,但在多个微服务共享同一个Redis时,这些功能实现起来非常顺手。很多团队最后选这套方案,不只是为了“解决掉线”,而是为了把会话能力沉淀成一套基础设施。
3. 实操落地:从依赖引入到双实例验证
3.1 依赖和版本选型:Spring Boot 2.x/3.x别搞混
直接给可用的依赖清单,我用的是Maven。假设你的项目是标准的Spring Boot Web应用,需要引入三个starter:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <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的依赖管理统一控制。这里要特别提醒:spring-session-data-redis的版本必须和Spring Boot版本匹配,最省心的做法就是让Spring Boot BOM来管理它。自己单独指定版本很容易踩到API不兼容的坑,尤其是Spring Boot 3.x和2.x之间,包名、配置项差异很大。
我这里本地验证用的是Spring Boot 2.7环境,但下面的配置我会同时给出Spring Boot 3.x需要调整的地方。Spring Boot 3.x把Redis配置的前缀从spring.redis.*改成了spring.data.redis.*,Session相关的配置不变。碰到3.x项目别惯性照着旧文档抄,否则配置项根本不会生效。
3.2 关键配置:Redis连接、Session存储与命名空间
配置文件是重头戏。先说一个最容易出错的点:Spring Boot 2.x和3.x的Redis连接配置前缀不一样。
Spring Boot 2.x的配置:
spring: redis: host: 127.0.0.1 port: 6379 password: abc123 database: 0 timeout: 3s lettuce: pool: max-active: 16 max-idle: 8 min-idle: 2 max-wait: 3sSpring Boot 3.x的配置:
spring: data: redis: host: 127.0.0.1 port: 6379 password: abc123 database: 0 timeout: 3s lettuce: pool: max-active: 16 max-idle: 8 min-idle: 2 max-wait: 3s两段配置的内容几乎一样,唯独嵌套层级从spring.redis变成了spring.data.redis。我用的是Lettuce客户端,“Lettuce连接池”需要单独启用spring.redis.lettuce.pool.*(或3.x的spring.data.redis.lettuce.pool.*)才会生效。如果不配连接池,高并发下Lettuce很容易出现连接等待超时,生产环境一定要配。
Session本身的配置如下:
spring: session: store-type: redis timeout: 30m redis: namespace: demo-session flush-mode: on_save save-mode: on_set_attribute这里解释几个参数背后的含义。store-type: redis是明确告诉Spring Boot用Redis存储Session,不带这个参数时Spring Boot可能会根据classpath自动推断,但显式写出来更可控。timeout: 30m是Session最大不活跃间隔,单位是m代表分钟,这个值最终会写入Redis的TTL。生产环境要根据业务形态设置,纯管理后台可以设短一点,电商网站购物车场景可能需要更长。redis.namespace是给当前应用设置Redis key前缀,如果多个应用共用同一个Redis实例,一定要设置不同的namespace,否则不同应用的Session数据会互相干扰。
flush-mode: on_save表示Session每次请求处理结束时才把数据写回Redis,这是默认模式。还有一个模式是immediate,每次调用setAttribute时立刻写Redis,实时性高但性能差,一般不建议生产环境使用。save-mode: on_set_attribute表示只要Session属性发生变化就标记为需要保存,这是默认行为,通常不需要改动。
如果要通过Java配置类来开启Redis Session存储,可以加上一个@EnableRedisHttpSession注解:
@Configuration @EnableRedisHttpSession(maxInactiveIntervalInSeconds = 1800) public class SessionConfig { }这个注解里可以配置最大不活跃间隔秒数,和spring.session.timeout是等价的。我个人更推荐用配置文件来管理,改参数时不用重新编译打包。
3.3 自定义Cookie序列化器:Cookie名字、HttpOnly与SameSite
Spring Session默认使用名为SESSION的Cookie来传递Session ID,但很多老项目前端约定的是JSESSIONID。为了兼容业务系统,通常要自定义一个CookieSerializer:
@Bean public CookieSerializer cookieSerializer() { DefaultCookieSerializer serializer = new DefaultCookieSerializer(); serializer.setCookieName("JSESSIONID"); serializer.setCookiePath("/"); serializer.setUseHttpOnlyCookie(true); serializer.setSameSite("Lax"); return serializer; }setCookiePath("/")要特别注意。如果项目部署在/api这类子路径下,Cookie path设置不对,浏览器会在跨路径请求时不再携带Cookie,导致Session丢失。setUseHttpOnlyCookie(true)能防止JavaScript读取Cookie,降低XSS攻击后的信息泄露风险。setSameSite("Lax")用于控制Cookie在跨站请求时的发送策略,对前后端分离的部署方式影响很大。如果你的系统还有HTTPS环境,可以开启serializer.setUseSecureCookie(true),确保Cookie只通过HTTPS传输。
3.4 双实例联调脚本:证明Session真的共享了
配置全部搞定后,验证逻辑很简单:本地起两个应用实例,端口分别设为8081和8082,然后在8081上登录获取Cookie,再用这个Cookie去访问8082的接口。如果8082能返回当前登录用户的信息,说明Session已经成功共享。
第一步,先在一个终端启动第一个实例:
mvn spring-boot:run -Dspring-boot.run.arguments=--server.port=8081第二个终端启动第二个实例:
mvn spring-boot:run -Dspring-boot.run.arguments=--server.port=8082假设项目里有一个登录接口/api/login和一个获取当前用户的接口/api/user/info。用curl先登录:
curl -i -X POST http://localhost:8081/api/login \ -H "Content-Type: application/json" \ -d '{"username":"admin","password":"123456"}'响应头里能看到Set-Cookie: JSESSIONID=xxxxx; Path=/; HttpOnly。把这个Cookie值记下来,再去请求8082的接口:
curl -i http://localhost:8082/api/user/info \ -H "Cookie: JSESSIONID=xxxxx"正常返回用户信息就是验证通过。这时再去Redis里检查一下:
redis-cli > keys 'demo-session:*' > hgetall demo-session:sessions:xxxxx能看到Session属性已经落在Redis里,而且8082实例读取的底层数据源是同一个Redis。这套验证在本地大概10分钟就能跑完,比纯看理论踏实得多。
4. 生产环境下踩过的坑:乱码、覆盖、网关和内存
4.1 序列化乱码与JSON反序列化失败:看懂根因比替换配置更重要
上线运行不到一周,我就发现一个隐蔽问题:Redis里存的Session属性值有一大堆看不懂的字节。直接用hgetall查看某个Session时,field的值长这样:
\xac\xed\x00\x05t\x00\x03com...这种以\xac\xed开头的乱码是Java标准JDK序列化机制的产物。Spring Session默认使用JDK自带的序列化器,把对象转成二进制字节后再塞进Redis。它的问题有两个:一是不可读,排查问题时瞪大了眼也看不出里面存的是什么;二是兼容性差,如果对象类的结构发生变化,比如新增字段、删除字段,旧数据反序列化时直接抛InvalidClassException。
解决办法是替换Redis序列化器。Spring Session默认从springSessionDefaultRedisSerializer这个Bean获取序列化器,所以我只需要覆盖这个Bean即可:
@Bean public RedisSerializer<Object> springSessionDefaultRedisSerializer() { return new GenericJackson2JsonRedisSerializer(); }注意这里必须叫springSessionDefaultRedisSerializer,不是随便定义个RedisTemplate的序列化器。很多人在这里踩坑:配置了RedisTemplate<String,Object>的序列化方式,结果Session数据还是JDK序列化,因为Spring Session内部根本不走RedisTemplate的默认序列化逻辑。
换JSON序列化之后还有一个更深的坑要提醒:JSON串里默认会带上类信息(@class字段),用来反序列化时确定目标类型。一旦你的用户类从User改名为Account,或者在包里移动了位置,Redis里旧数据的类路径已经不存在了,反序列化直接失败。我后来定的规矩是:涉及Session里保存对象的字段变更时,要么提前清空线上Redis的Session前缀,要么在代码里写一个兼容的转换处理器。老数据有时候舍不得清,但Session本来就是“丢了就让用户重新登录”的临时数据,优先保证系统可用比死守数据更实际。
4.2 用户的并发请求导致Session覆盖:一个被低估的丢失更新问题
这个坑不是马上暴露的,而是上线后功能越加越多才慢慢显现。现象是:用户同时打开多个浏览器标签页,在一个标签页里更新了Session中的用户资料,随后在另一个标签页里操作时,第一次更新“消失”了。
根因要从Redis的读写模式说起。Spring Session在请求结束后把整个Session对象写回Redis,这个操作不是原子更新局部字段,而是读出Session、反序列化、修改属性、整体写回的过程。当两个请求分别落在两个不同的实例上,两边几乎同时从Redis读出同一个Session的同一个字段,各自在本地做修改,然后先后写回Redis。后写的那个会把先写的覆盖掉,其中一个请求的修改就丢了。
这种问题场景和并发写数据库记录时的“丢失更新”完全一致,只是很多人没意识到Session也会遇到。我当时的排查思路是:确认哪些数据会被高频并发写。比如用户每次访问时更新“最近访问时间”,这个字段就在并发时频繁触发覆盖。解决办法分两路走:一是把并发写频率最高的数据从Session里挪出去,比如“最近访问时间”这种数据完全可以通过Redis单独记录一个user:online:<userId>的key来管理,不必塞进Session;二是尽量减少Session里的可变属性总量,Session只保留登录凭证、用户主键这类低频变化的标识性数据,其余高频业务数据走专门设计的Redis key。
如果确实需要保留并发写场景,可以考虑把Session的存储粒度做细,或者引入分布式锁来控制同一Session的写并发。但我的真实感受是,最佳策略不是“控制并发”,而是“减少写入”——Session的属性越少、越稳定,这套方案就越稳。它本质上是一个定位标识,不是内容仓库。
4.3 网关转发与前后端分离下的Cookie透传
项目改造到一定阶段,前端从服务端渲染换成了前后端分离,请求统一走API网关。网关部署完之后又冒出一个新问题:用户明明登录成功了,紧接着请求业务接口却返回401。浏览器里Cookie明明还在,后端Session也存在Redis里,但请求就是带不到后端。
排查下来发现是网关这一层把Cookie信息丢掉了。网关在转发HTTP请求时,如果配置里没有显式转发Cookie头,后端拿到的请求里就不会包含JSESSIONID。后端的SessionRepository完全不知道该查找哪个Session,自然判定未登录。解决方式是在网关的转发配置里把Cookie头原样透传。以常见的网关配置为例,在路由规则里加一行转发Header:
filters: - PreserveHostHeader - RewritePath=/api/(?<segment>.*), /$\{segment} default-filters: - DedupeResponseHeader=Access-Control-Allow-Credentials Access-Control-Allow-Origin核心是确保转发请求时保留浏览器提交的Cookie头,同时响应时不要重复添加互相冲突的CORS头。
跨域配置也是个关键点。前后端域名不一致时,后端必须配置CORS允许携带凭证:
@Bean public CorsWebFilter corsWebFilter() { CorsConfiguration config = new CorsConfiguration(); config.setAllowCredentials(true); config.addAllowedOrigin("https://frontend.example.com"); config.addAllowedHeader("*"); config.addAllowedMethod("*"); return new CorsWebFilter(source -> config); }这里有一个安全限制必须要知道:allowCredentials(true)时,allowedOrigins不能写成"*",必须指定具体域名。浏览器规范明确禁止携带凭证的跨域请求使用通配符来源。前端调用时也要设置withCredentials: true,否则即使后端允许,浏览器也不会主动携带Cookie。这个跨域问题排查起来比较费时,我每次都会先看浏览器Network面板里请求是否带上了Cookie,再看响应头里Access-Control-Allow-Credentials是否为true,两步能定位90%的问题。
4.4 Redis内存持续增长与session过期回收失效
还有一个经典问题:系统跑了一段时间后,Redis内存占用越来越高,明明设置了Session过期时间,数据却没有按预期释放。这通常不是Spring Session没做清理,而是清理机制依赖某些前提条件。
Spring Session的过期清理依赖两个关键机制:一个是通过spring:session:expirations:<timestamp>集合定时扫描,另一个是Redis的过期key事件通知。如果Redis实例关闭了notify-keyspace-events配置,Spring Session就无法收到key过期事件来触发后续的删除操作。虽然扫描任务也能兜底,但事件通知关闭时,清理效率和实时性都会下降,内存里会残留更多等待淘汰的Session数据。建议在Redis配置里打开必要的事件通知:
notify-keyspace-events Exgx这个配置项开启了过期key事件和通用命令事件。生产环境调整Redis配置后要记得验证生效,用config get notify-keyspace-events确认。
更需要注意的另一个坑是Redis内存淘汰策略。如果Redis配置的maxmemory-policy是allkeys-lru,那么当内存达到上限时,Redis会从所有key里挑选最久未用的key来淘汰,Session数据也会被当成普通缓存清掉,哪怕它还没到过期时间。结果就是:用户明明还在活跃,Session却被Redis强制删除,用户被莫名其妙踢下线。这种情况特别难排查,因为日志里看不到任何报错,就是偶发掉线。
后来我把Redis的内存淘汰策略改成了volatile-lru,这个策略只淘汰设置了TTL的key。Session主key自带TTL,业务缓存如果不需要被淘汰可以不带TTL。这样既能保证Redis内存可控,又不会误伤活跃Session。同时我还写了一个定时巡检脚本,每天检查一次Redis内存增长曲线和spring:session:*的key数量,如果发现Session数量异常增多,基本都是某个接口在疯狂创建新Session,这时会去检查业务代码里是不是有“一登录就访问request.getSession(false)并触发创建新Session”的逻辑。
结尾:改动之外,我留下的一点心得
整套方案落地后再回看,技术层面的难度其实不算高,真正的难点在于两处:一是意识到“Session状态必须和实例解耦”这个根本前提,二是上线后通过实际问题把方案打磨到符合业务特性。如果你还在单机架构里,提前把Session迁到Redis并不是过度设计,而是一笔很划算的“架构保险”,因为等到扩容当天再处理,紧迫感和故障压力会让你没有充足时间考虑细节。
我个人的体会是,迁到Redis + Spring Session之后,最需要改变的其实是“往Session里乱塞东西”的习惯。单机时代往Session塞什么都能跑,但分布式环境下Session变成了分布式共享存储,每一次写入都对应一次网络请求加一次序列化,滥用会直接把Redis拖成瓶颈。我现在定的原则很简单:Session里只放稳定且低频使用的核心标识数据,比如用户ID、登录令牌、少量偏好设置;所有需要业务计算和频繁变化的数据,单独设计Redis缓存key或落库。这套原则比任何技术选型都更影响长期稳定。
最后分享一个实用小技巧:本地开发环境可以保留内存存储,只把生产环境切到Redis,避免每个开发同学都依赖一个共享的Redis实例。具体做法是在application.yml里用多环境配置区分dev和prod,dev环境不配spring.session.store-type,让它走Spring Boot默认的内存Session;prod环境显式配置成redis。这样开发调试不受Redis状态影响,线上又能享受无状态扩容的红利。两套环境隔离好,开发效率和系统稳定性都可以兼得。