资讯详情

Spring Session Redis内存泄漏排查:无TTL Key清理与预防

📅 2026/10/14 4:37:41 | 华诺云谱 👁 阅读
Spring Session Redis内存泄漏排查:无TTL Key清理与预防
如果你最近在用 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. 现场排查内存飙升背后的两个 Key1.1 第一个信号Redis 内存告警事情开始得很平淡就是某个下午监控平台发来消息说生产 Redis 的used_memory已经超过阈值两个小时内从告警变成严重告警。我上去先看INFO memoryused_memory_human已经到 15GB而整个实例的maxmemory只有 16GB。再看INFO keyspacedb0 里躺着 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 多万。再抽样看 TTLredis-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 哨兵 Key2.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 的清理依赖的是另一个 Keyspring: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 索引 Key3.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:idHash有默认 30 分钟真正的 Session 数据包括属性、最后访问时间等Redis 自动过期过期事件触发后也会被主动删spring:session:sessions:expires:idString无 TTL过期的占位哨兵用于事件回调过期事件链路主动删除spring:session:expirations:时间戳Set有通常是过期时间 5 分钟存放同一个过期时间点上的 SessionId 集合Redis 自动过期触发后续清理spring:session:index:principalSet无 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_keyspring: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作为 KeyValue 是简单的 JSONTTL 显式设置 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对应的辅助 KeyRedis 被清空后用户的登录态会在下一分钟失效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设计再巧妙的架构只要把清理工作寄托在一条不直观的链路上就一定会有断掉的一天。该加的监控加上该写的兜底任务写上内存才不会在半夜突然报警。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑