大家好,我是小富。
《十万个why》系列持续更新中
线上一台 Redis 实例,所有业务 key 都设了 TTL,按理说到期就删,内存应该很稳定。但 Grafana 监控上 used_memory 一直在缓慢爬升,过了两周直接触发了 maxmemory 的上限告警。
所有 key 都有过期时间,不应该越存越多啊,内存去哪了?
Redis 删除过期 key 的策略:并不是"到期就删"
Redis 用两种策略配合删除过期 key:
1. 惰性删除(Lazy Expiration)
只有当你 GET 一个 key 的时候,Redis 才检查它是否过期,如果过期了就删掉并返回 nil。
也就是说:如果一个 key 过期了但没人来访问它,它就一直待在内存里。
2. 定期删除(Active Expiration)
Redis 每 100ms 执行一次定期清理任务(activeExpireCycle),流程是:
1. 从设置了过期时间的 key 中随机抽样 20 个
2. 删除其中已过期的
3. 如果过期比例 > 25%,则重复步骤 1
4. 如果过期比例 <= 25%,或者执行时间超过 25ms,停止
注意两个关键细节:
- 只随机抽样 20 个,不是全量扫描
- 有时间上限(25ms),为了不阻塞主线程
这意味着:如果过期 key 产生的速度快于定期删除的清理速度,过期 key 会在内存中堆积。
场景还原:为什么会堆积
假设你有这样一个业务场景:
// 用户访问记录,30 分钟过期
redis.setex("user:visit:" + userId + ":" + timestamp, 1800, data);
每个用户每次访问都写一个 key,30 分钟过期。高峰期每秒产生 5000 个这样的 key。
30 分钟后这些 key 开始过期。但定期删除每次只抽样 20 个,每 100ms 执行一次,最多一秒清理几百个。而过期 key 的总量已经积累到了 5000 × 1800 = 900 万个。
定期删除跑着跑着发现过期比例已经低于 25% 了(因为 900 万个 key 里被抽中的只有 20 个,其中过期的可能只有几个),就停了。大量过期 key 还在内存里。
惰性删除呢?这些 key 的命名格式是 user:visit:userId:timestamp,timestamp 每次不同,过期后再也不会有人来访问这个精确的 key。没有 GET 就不会触发惰性删除。
两种删除策略都失效了。
BigKey 过期:另一种内存炸弹
还有一种情况更隐蔽。假设你有一个 Hash 类型的 key,里面有 50 万个 field,大小 200MB:
SET big_hash expire 3600 # 1 小时后过期
1 小时后这个 key 过期了。当某个请求恰好触发了惰性删除(或者定期删除抽中了它),Redis 需要同步删除这 200MB 的数据。
在 Redis 4.0 之前,删除是在主线程里执行的。删除一个 200MB 的 key 可能要几百毫秒甚至几秒。在这段时间内,Redis 的所有其他请求全部被阻塞。
这就是著名的"大 key 过期导致 Redis 卡顿"问题。你在监控上看到的表现是:Redis 的 latency 突然出现一个巨大的毛刺,持续几百毫秒到几秒。
Redis 4.0 引入了 lazyfree-lazy-expire 配置(默认关闭),开启后过期 key 的删除会放到后台线程异步执行,不阻塞主线程。但即使异步删了,这段时间内内存占用还是高的。
第三种情况:内存碎片
即使 key 被正常删除了,内存也不一定会释放回操作系统。
Redis 使用 jemalloc 内存分配器,它以固定大小的内存块来分配。比如你的 key 需要 50 字节,jemalloc 会分配 64 字节的块。key 被删除后,这 64 字节的块被标记为空闲,但不会还给操作系统。
当大量不同大小的 key 反复创建和删除后,内存里会出现大量"碎片"——有空闲空间但不连续,无法被新的 key 使用:
Redis info memory 输出:
used_memory: 2.1 GB # Redis 认为自己用了 2.1GB
used_memory_rss: 3.5 GB # 操作系统实际分配了 3.5GB
mem_fragmentation_ratio: 1.67 # 碎片率 1.67(理想值是 1.0 ~ 1.05)
mem_fragmentation_ratio 远大于 1 就说明有严重的碎片问题。3.5GB 的 RSS 里只有 2.1GB 在被有效使用,1.4GB 都是碎片。
解决方案
1. 开启 maxmemory + 淘汰策略兜底
maxmemory 4gb
maxmemory-policy volatile-lru
即使过期 key 没被及时清理,当内存到达上限时,淘汰策略会强制删除一些 key。这是最后一道防线。
2. 定期用 SCAN 主动清理
# 用脚本定时 SCAN 所有 key,触发惰性删除
redis-cli --scan --pattern "user:visit:*" | head -10000 | xargs redis-cli unlink
或者对过期 key 密集的前缀执行一次 SCAN 遍历,光是访问一下就会触发惰性删除。
3. 避免"只写不读"的 key 设计
如果一个 key 写完之后再也不会被读取,惰性删除就永远不会触发。要么换个设计(比如用 Sorted Set 按时间存,定期 ZREMRANGEBYSCORE 清理),要么靠定期 SCAN 兜底。
4. 开启内存碎片整理(Redis 4.0+)
activedefrag yes
active-defrag-ignore-bytes 100mb
active-defrag-threshold-lower 10
Redis 会在后台自动整理内存碎片,把分散的小块合并成大块。但这会占用一定的 CPU,需要根据实际情况调整阈值。
5. BigKey 治理
# 扫描大 key
redis-cli --bigkeys
# 开启异步删除
lazyfree-lazy-expire yes
lazyfree-lazy-server-del yes
对于大 key,用 UNLINK 替代 DEL,走后台异步删除。
总结
| 原因 | 表现 | 解决方案 |
|---|---|---|
| 惰性删除 + 定期删除覆盖不全 | 过期 key 堆积,used_memory 缓慢增长 | SCAN 主动清理,避免"只写不读"的 key |
| BigKey 过期时同步删除阻塞 | Redis latency 突然飙高 | 开启 lazyfree,用 UNLINK 替代 DEL |
| 内存碎片 | used_memory_rss 远大于 used_memory | 开启 activedefrag,或重启实例 |
| 没设 maxmemory 兜底 | 内存无限增长直到 OOM | 必须设置 maxmemory + 淘汰策略 |
Redis 的过期删除不是精确的定时器,而是"尽力而为"的概率清理。 当过期 key 的产生速度超过清理速度,且没有读请求触发惰性删除时,内存就会被"已过期但未清理"的 key 慢慢撑爆。
我是小富,下期见。
