跳至主要內容

程序员小富大约 5 分钟

大家好,我是小富。

《十万个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 慢慢撑爆。


我是小富,下期见。

上次编辑于: