如果你最近在用 Spring Session 把登录会话丢到 Redis,并且发现used_memory涨得拦都拦不住,那我强烈建议你停下手里的事,先看一眼 Redis 里到底躺着多少spring:session:前缀的 Key。说个实际的案例:某天线上告警内存使用率 95%,DBSIZE 从几百万一路涨到几千万,redis-cli --bigkeys跑出来两个 Type 是 Set 和 String 的庞然大物,TTL 全是-1,查了下前缀,全部指向spring:session:。
那一次排查最终锁定在两个永远不会过期的 Key 上:spring:session:sessions:expires:<sessionId>和spring:session:index:<principalName>。这两个 Key 从设计上就是不带 TTL 的,按理说应该由后续的事件链路主动删掉,但一旦某个环节断了,它们就会像没人认领的孤儿一样,永远留在 Redis 里,直到把内存彻底吃光。这篇文章我把当时的排查过程、底层原理、清理脚本和预防方案全部写出来,给后来人少踩几个坑。
1. 现场排查:内存飙升背后的两个 Key
1.1 第一个信号:Redis 内存告警
事情开始得很平淡,就是某个下午监控平台发来消息,说生产 Redis 的used_memory已经超过阈值,两个小时内从告警变成严重告警。我上去先看INFO memory,used_memory_human已经到 15GB,而整个实例的maxmemory只有 16GB。再看INFO keyspace,db0 里躺着 3800 多万个 Key,这个数字对于一个中小型 Web 应用来说明显不正常。
我当时的第一个念头是「是不是有人把缓存 Key 的过期时间都设成 -1 了」,但扫了一圈发现业务缓存并没有异常,反而spring:session:sessions:expires:*这个前缀占比高得吓人。用扫描命令粗看了一下,这种 Key 有上千万个,而且大多数TTL返回-1。看到这个结果的时候直觉告诉我,问题大概率出在 Spring Session 的会话管理上,而不是业务代码里乱写缓存。
后来我让同事翻了一下项目配置,确认了项目引入了spring-session-data-redis,并且通过@EnableRedisHttpSession启用了 Redis 会话存储。这个组件本身非常常用,但它的某些默认行为很容易被忽略:Session 的实体 Key 确实会过期,但配套生成的辅助 Key 不一定有过期时间。等到出问题的时候,Redis 里已经是尸横遍野。
1.2 锁定元凶:TTL 为 -1 的 Key
排查命令其实很简单,Redis 自带的--bigkeys能直接把大 Key 揪出来,但那些不是最大的 Key,单个看都不算大,真正的问题是海量的「蚂蚁搬家」。
我当时用--scan把spring:session:前缀的 Key 数量统计出来:
redis-cli --scan --pattern 'spring:session:*' | wc -l输出结果是 2600 多万。再抽样看 TTL:
redis-cli --scan --pattern 'spring:session:sessions:expires:*' | head -20 | xargs -i redis-cli TTL {}结果齐刷刷的-1。这就很说明问题了,TTL 为 -1 意味着这个 Key 在 Redis 里永远不过期,除非有人主动删除它。正常来说,spring:session:sessions:expires:*不应该长期残留,因为它们只是 Spring Session 内部用来辅助过期处理的占位 Key,不是用户数据的本体。
我又顺手查了spring:session:index:*,同样是一堆 TTL=-1。这种 Key 只有在使用RedisIndexedSessionRepository时才出现,但一旦出现,就是另一个无底洞。到这里基本可以确定,内存泄漏的元凶就是这两类「本应被清理、但永远不会被 Redis 自动清理」的 Key。
2. 元凶之一:spring:session:sessions:expires 哨兵 Key
2.1 这个 Key 是为了解决什么问题而存在的
要理解spring:session:sessions:expires:<sessionId>为什么会存在,得先理解 Spring Session 在 Redis 里如何实现 Session 过期。按照正常思路,最简单粗暴的做法就是给 Session 数据 Key 设置一个 TTL,时间一到 Redis 自动删除。但实际上 Spring Session 并没有只依赖这一条路,它额外维护了一套「过期哨兵」机制。
我拆过部分源码后给你一句话解释:sessions:<sessionId>是真正的 Session 数据,默认 30 分钟过期,但它一旦过期被 Redis 删掉,Spring 这边就再也拿不到 Session 里的内容了,连「哪个用户过期了」都不知道。为了解决这个问题,Spring Session 在写入会话数据的同时,还会创建一个sessions:expires:<sessionId>的空壳 Key,用来在会话过期后充当事件回调的载体。
这个空壳 Key 有一个非常反直觉的设计:它不设置 TTL。原因很好理解,如果它也设置过期时间,那它可能会比 Session 数据 Key 早过期,或者也在某个时刻被 Redis 主动删除,事件回调就无法保证稳定执行。Spring Session 的本意是「会话过期后,由业务逻辑或异步任务主动删除这个空壳」,而不是让 Redis 自动淘汰它。
2.2 为什么它永远不会过期
TTL返回 -1 不代表 Redis 有 Bug,而是这个 Key 从创建起就根本没带过期时间。
spring:session:sessions:expires:<sessionId>的创建时机是在每次 Session 保存时。只要用户登录一次,Spring Session 就会写一个这样的辅助 Key,哪怕这个用户后续再也没访问过,这个 Key 也会安静地躺在 Redis 里。它要等到对应的sessions:<sessionId>真正过期之后,由 Spring Session 的过期监听器统一清理。
问题在于,全自动清理依赖一条完整链路,链路里任何一环断了,清理就停了。常见的断点包括:Redis 没开启过期事件通知、Spring 容器里没有正确注册过期事件监听器、应用在事件触发前重启、集群模式配置不对称等。链路断掉之后,sessions:expires:*就成了无人认领的孤儿 Key,新增的会话还在不断产生,旧的又不删除,内存自然只涨不跌。
2.3 残留条件与触发链路的脆弱点
这个 Key 的清理依赖的是另一个 Key:spring:session:expirations:<时间戳>。Spring Session 会把同一时间段内过期的 SessionId 放进一个 Set 中,然后给这个 Set 设置一个稍晚的 TTL。当这个 Set 过期时,Redis 会触发expired事件,Spring Session 收到事件后,会取出 Set 里的所有 SessionId,然后逐个删除对应的sessions:<sessionId>和sessions:expires:<sessionId>。
我用生活化的方式比喻一下:sessions是房间,expires是房间门口挂的牌子,expirations是物业手里的「今晚退房名单」。晚上到了,物业需要拿着名单去敲门、拆牌子、收房。但如果物业电话打不通(事件通知没开)、或者物业公司换人了没交接(应用重启),那敲门这步永远不会发生,牌子也永远不会摘。
所以,sessions:expires:*虽然本身根本没设 TTL,但它不是生来就打算永生的,而是被设计成「靠外力删除」的。外力不来,它就赖着不走。
3. 元凶之二:spring:session:index 索引 Key
3.1 只有在索引模式下才会出现的 Key
第二个无 TTL 大户是spring:session:index:<principalName>。它不是默认配置下一定会出现的 Key,只有当你启用了RedisIndexedSessionRepository,也就是使用@EnableRedisIndexedHttpSession这类注解时才会产生。这个 Repository 比普通的RedisSessionRepository多了按用户名或者其他属性查找 Session 的能力,常用于实现「把同一用户的其他设备踢下线」这类功能。
举个例子,用户登录时,业务代码往 Session 里放了一个LOGIN_USER_ID属性。Spring Session 发现这个属性是配置好的索引字段,就会额外创建或者更新一个spring:session:index:userId123的 Set,里面保存的是所有属于这个用户的 SessionId。这样后续你想把这个人所有的会话都找出来,直接读这个 Set 就行,非常方便。
这个设计的问题同样出在 TTL 上。索引 Set 的 TTL 是-1,永久不过期。原因是索引本身不应该随着某个 Session 过期而整体消失,它里面可能还有其他 SessionId。Spring Session 的预期是:当某个 Session 过期或者被销毁时,通过事件回调把对应的 SessionId 从 Set 里移除;当 Set 里没有任何 SessionId 时,再删除整个索引 Key。
3.2 索引 Key 的增长逻辑与清理依赖
现实往往是残酷的,索引 Key 的清理依赖同一套过期事件链路,链路断了之后,用户每次登录都会往 Set 里追加一个 SessionId,但没有任何逻辑把它移除。一个用户登录一百次,索引 Set 里就堆一百个 SessionId,其中有九十九个对应的 Session 早已过期。
从内存角度看,一个 Set 里的元素本身只有几十字节,但如果用户基数大、登录频繁,这个 Set 会膨胀到几十万个元素,成为一个典型的 Redis 大 Key。更麻烦的是,它还在持续增长。更隐蔽的问题是,用户如果重新登录并且LOGIN_USER_ID相同,Set 不会重复,因为 Redis Set 自带去重;但如果用户每次登录生成了不同的标识,或者干脆不是同一个用户,那每个用户的索引 Set 都会堆积大量死 SessionId。
所以第二个「永远不会过期的 Key」其实不是单个 Key,而是一整套前缀为spring:session:index:的索引 Key 家族。只要清理逻辑不跑,它们就会一直增长,并且因为是 Set 类型,每次SMEMBERS读取都可能引发阻塞风险,严重时还会拖垮 Redis 主线程。
4. Spring Session 的 Redis 数据结构和工作链路
4.1 一个表格理清所有 Key 的职责
到这里,有必要把 Spring Session 在 Redis 里常见的 Key 结构完整列出来。用一张表看会清晰很多:
| Key 前缀 | 类型 | TTL | 作用 | 谁来删 |
|---|---|---|---|---|
spring:session:sessions:<id> | Hash | 有,默认 30 分钟 | 真正的 Session 数据,包括属性、最后访问时间等 | Redis 自动过期,过期事件触发后也会被主动删 |
spring:session:sessions:expires:<id> | String | 无 TTL | 过期的占位哨兵,用于事件回调 | 过期事件链路主动删除 |
spring:session:expirations:<时间戳> | Set | 有,通常是过期时间 + 5 分钟 | 存放同一个过期时间点上的 SessionId 集合 | Redis 自动过期,触发后续清理 |
spring:session:index:<principal> | Set | 无 TTL | 按用户名/属性索引,保存会话 ID | 会话销毁事件主动移除,无事件时不会自动删 |
从表里能直接看出一个规律:真正承载数据的sessions:<id>其实不会造成长期堆积,因为 Redis 会自动清除它;造成长期堆积的全是那些「没有 TTL、只能靠事件手动删」的辅助 Key。这也是为什么很多团队只给 Session 本身设置了较短的过期时间,内存却依然被撑爆。
4.2 一次完整 Session 的诞生与消亡
我以一个用户登录的完整流程来走一遍。
用户第一次登录,Spring Session 创建 Session 对象,并在 Redis 里做三件事:写 Session 数据到spring:session:sessions:<id>,设置 30 分钟过期;创建spring:session:sessions:expires:<id>哨兵 Key,不设置 TTL;把该 SessionId 加入到当前时间窗口对应的spring:session:expirations:<时间戳>Set 中,并给这个 Set 设置一个相对较晚的过期时间。
用户如果在 30 分钟内持续访问,sessions:<id>的 TTL 会被刷新,expires:<id>会重新创建,SessionId 也会被挪到新的expirations:<时间戳>桶中。也就是说,一个活跃用户的 Session 可以被无限续期,一切正常。
用户停止访问 30 分钟后,sessions:<id>到达过期时间,Redis 惰性删除或者定期删除把它清掉。与此同时,expirations:<时间戳>这个桶也过期了,Redis 发布expired事件,Spring Session 收到后遍历桶里的 SessionId,再做两件事:删除对应的sessions:expires:<id>,并发布一个SessionExpiredEvent给业务监听器。
如果开启索引模式,还有第四件事:把该 SessionId 从spring:session:index:<principal>的 Set 中移除。整条链路听起来很顺畅,但现实里只要 Redis 的键空间通知没开,或者事件监听器没生效,从「桶过期」这一步开始之后就全部停摆,后面的删除动作一个也发生不了。
4.3 Key 空间通知:决定命运的那个开关
这里必须强调一个关键配置,那就是 Redis 的notify-keyspace-events。很多人 Spring Session 跑了好几年都没注意过这个参数,因为它「看起来」跟业务无关,但实际上它决定了过期事件能不能从 Redis 推送到 Spring。
在 Redis 里,过期事件属于键空间通知的一种,默认是关闭的。Spring Session 的详细过期清理需要监听expired事件,所以 Redis 至少要开启Ex或者Egx。你可以临时用命令打开:
redis-cli config set notify-keyspace-events Egx这个设置只对当前运行时生效,要想重启后依然保留,还要同步修改redis.conf里的对应配置。如果你用的是集群或者哨兵,要确保每个节点都做了同样配置,否则某个节点上的 Key 过期后不会发出事件,照样漏。
开启之后,你会看到 Redis 的日志或者订阅信息明显变多,这是正常的。因为每次有过期 Key 时 Redis 都会向订阅者推送一条通知,虽然每条消息很小,但数量很大,对网络和 Spring 侧事件处理的压力需要提前评估。
5. 现场处置:安全清理与长期预防
5.1 用脚本只删“孤儿” Key
搞清楚原理之后,第一件事是把已经堆积的垃圾清掉。但生产环境不能直接FLUSHDB,也不能无脑把所有sessions:expires:*删掉,因为活跃用户的 Session 还依赖它触发过期事件。我只删「孤儿」:也就是对应的sessions:<id>已经不存在的那些expiresKey。
用一个 Bash 脚本循环可以实现:
redis-cli --scan --pattern 'spring:session:sessions:expires:*' | while read key do session_id="${key##*expires:}" session_key="spring:session:sessions:${session_id}" exists=$(redis-cli EXISTS "$session_key") if [ "$exists" -eq 0 ]; then redis-cli DEL "$key" fi done这个脚本思路清晰,但逐条EXISTS再DEL性能太差,生产环境几千万 Key 会跑很久。更好的方式是分批用SCAN+--pipe批量处理,再配合 Lua 脚本在服务端完成判断。下面是一个简化版 Lua 脚本示例,注意在低峰期运行,COUNT不要太大,避免阻塞 Redis 主线程:
local cursor = '0' repeat local result = redis.call('SCAN', cursor, 'MATCH', 'spring:session:sessions:expires:*', 'COUNT', 500) cursor = result[1] local keys = result[2] for _, key in ipairs(keys) do local pos = string.find(key, 'expires:', 1, true) if pos then local session_id = string.sub(key, pos + 8) local session_key = 'spring:session:sessions:' .. session_id if redis.call('EXISTS', session_key) == 0 then redis.call('DEL', key) end end end until cursor == '0'清理索引 Key 的思路类似,遍历spring:session:index:*下的每一个 Set,对里面每个 SessionId 检查sessions:<id>是否存在,不存在就从 Set 中SREM。如果整个 Set 空了,再把索引 Key 删除。这一步同样建议用脚本批量执行,不要直接SMEMBERS一个大 Set 一次性拉出来,可能直接 OOM 客户端。
5.2 把过期事件真正打开
只清理现场是不够的,如果不修复链路,第二天新的垃圾又会长出来。第一个要做的就是配置 Redis 键空间通知,在redis.conf里确保有这一行:
notify-keyspace-events Egx如果要临时生效,用redis-cli config set notify-keyspace-events Egx,但记得把config rewrite也执行一下,或者同步改掉配置文件。
接着检查 Spring 侧是否注册了对应的监听器。通常 Spring Boot 引入spring-session-data-redis后,会自动配置SpringSessionBackedRedisConnectionFactory和相关的过期事件监听器。但如果你在项目里手动定义了RedisMessageListenerContainer,或者自定义了RedisConnectionFactory,就可能导致自动配置失效。
一个常见的坑是:Key 的事件监听器能收到消息,但消息里带的 Key 反序列化失败,最终事件被异常打断,后续清理逻辑不执行。所以,如果你用了 JSON 序列化或者自定的RedisSerializer,先确认 Session 相关 Key 的序列化方式与监听器一致,或者给监听器单独设置 ByteArray 序列化,保证至少能拿到原始 Key 名。
5.3 定时任务兜底清理
即便开了事件通知,也不能保证百分之百不泄漏,毕竟还存在发布重启、Redis 主从切换、网络抖动等意外情况。稳妥的做法是再加一道定时任务兜底,每天凌晨低峰期扫一遍孤儿 Key,把事件链路漏掉的那部分补删掉。
任务逻辑很简单,和上面的清理脚本一致,只是放到 Spring 里变成定时任务:
@Component public class SessionOrphanKeyCleanTask { @Scheduled(cron = "0 0 4 * * *") public void cleanOrphanExpiresKeys() { // 1. 扫描 spring:session:sessions:expires:* // 2. 对于每个 key,判断 spring:session:sessions:<id> 是否存在 // 3. 不存在则删除,或者加入待删除队列 } }我不建议在定时任务里用KEYS命令,因为数据量大时会阻塞 Redis。正确做法是用SCAN游标遍历,每次拉几万个 Key,分批处理,删除的时候用 Pipeline 批量发送命令。低峰期执行确实能兜住大部分问题。
清理完几千万个 Key 后,Redis 的内存会肉眼可见地降下来。我当时清完以后,used_memory 从 15GB 降到 2GB 左右,DBSIZE 也回到了一个相对理性的范围。
5.4 长期建议:会话存储的架构选型
处理完紧急情况,还得想想为什么当初会走到这一步。最根本的原因是该服务把所有用户会话都无脑塞进了 Spring Session Redis,而且用了索引模式。如果你的业务里根本没有「按用户枚举所有会话」的需求,完全没必要启用RedisIndexedSessionRepository,用默认的RedisSessionRepository就好,这样至少少了一大类无 TTL 的索引 Key。
如果业务对会话数据的实时性和可控性要求很高,可以评估把 Spring Session 替换成更轻量的 Token 方案。比如自己生成一个sessionId作为 Key,Value 是简单的 JSON,TTL 显式设置 30 分钟,过期后由 Redis 自动删除,不需要依赖任何过期事件。这样 Key 结构简单,可观测性强,也不存在哨兵和索引的隐形成本。
当然,如果团队测试过 Spring Session 的机制,觉得自己能维护好事件监听和兜底任务,继续使用也没有问题。但一定要把「会话 Key 数增长」纳入日常监控,设定阈值,比如 DBSIZE 中spring:session:*占比超过 10% 就告警,不要等内存爆炸了才去补窟窿。
6. 踩坑速查与经验补充
6.1 高频问题速查表
我复盘了一下,下面这些问题基本涵盖了这次故障最容易被反复追问的点,做成一张表方便你保存。
| 问题 | 原因 | 对策 |
|---|---|---|
spring:session:sessions:expires:*为什么 TTL 是 -1 | 该 Key 设计上就不带 TTL | 依赖过期事件链路主动删,或定时任务兜底 |
开了notify-keyspace-events还是堆积 | Spring 监听器没注册,或反序列化失败 | 检查消息监听容器,确保能正确收到 expired 事件 |
项目没有启用索引注解,却有spring:session:index:* | 可能有老版本的配置或误用 | 全局搜索RedisIndexedSessionRepository相关配置 |
spring:session:expirations:*很大但 TTL 不为 -1 | 它是 Set,存了很多 SessionId | 等它过期即可,不必强行删除 |
直接删掉活跃 Session 的expiresKey 会怎样 | 该 Session 过期后无法触发清理事件 | 只删孤儿 Key,不要动仍然存活的sessions:<id>对应的辅助 Key |
| Redis 被清空后,用户的登录态会在下一分钟失效 | Session 实体 Key 没了 | 属于正常现象,但需要确认客户端有重新登录的兜底 |
6.2 几个容易被忽略的细节
第一,redis-cli --bigkeys只能帮你发现「已经很大」的 Key,对于这种海量小 Key 堆积的内存泄漏,用--bigkeys大概率看不出异常,因为这些 Key 单个都不算大。真正需要看的是 DBSIZE 和scan出来的某个前缀命中的 Key 数量,所以监控一定不能只看内存,还要看 Key 总量。
第二,spring:session:sessions:expires:<id>这个无 TTL 的 Key 不属于用户数据,删错了不会影响用户当前登录,但它会导致 Session 过期清理事件丢失。所以清理脚本一定要判断sessions:<id>是否还存在,只清理「对应的会话已经不存在」的孤儿 Key。这个判断既是安全线,也是最容易写错的地方。
第三,如果 Redis 用的是集群模式,SCAN和DEL在跨 Slot 时会遇到限制。清理前先确认spring:session:前缀的 Key 是否集中在一个 Slot,或者在代码里按节点逐个处理。否则脚本跑到一半报CROSSSLOT,又会浪费一天时间。
第四,不要把所有希望都寄托在 Spring Session 的监听器上,因为应用重启和发版必然会导致丢失一段时间的事件。把这套清理做成一个可持续的定时任务,比什么都可靠。
我在这次排查里最大的体会是:凡是「没有 TTL,但又没有删除逻辑」的 Key,都应该当成定时炸弹处理。Redis 本身没有义务帮你清理一个没设置过期的 Key,设计再巧妙的架构,只要把清理工作寄托在一条不直观的链路上,就一定会有断掉的一天。该加的监控加上,该写的兜底任务写上,内存才不会在半夜突然报警。