
你有没有想过一个问题:你给 Redis 的一个 key 设置了 5 分钟过期,5 分钟到了,这个 key 真的被删掉了吗?
直觉告诉你:当然删了,不然设置过期干嘛。
但 Redis 的实际情况是:不一定。如果你在这 5 分钟内再也没有访问过这个 key,它可能还在内存里躺着,等下一次被访问才被清理。
或者更极端的情况:内存满了,它还没被访问,结果它和其他 key 一起被"淘汰"了。“淘汰"和"过期"是两回事,后面会详细讲。

我遇到过类似的问题。线上有个服务用 Redis 缓存用户 session,所有 key 统一设置 8 小时过期。结果某天下午 3 点,接口 P99 从 20ms 飙升到 2 秒,CPU 跳到 95%。查了半天才发现:20 万个 session key 在同一时间过期,后台的"扫地机器人"忙不过来,大量过期 key 占了内存,淘汰器一触发,反而把还在活跃的 key 给淘汰了。这个案例我做了简化,但问题的链条是真实的。
你看,“过期"和"淘汰"两套机制在这里都起作用了。但不是你以为的那个方式。
很多人把 Redis 的过期策略和内存淘汰机制混为一谈,面试的时候能说出一堆策略名字,但问"它们什么关系"就卡住了。这篇文章的目的很简单:帮你把这两套机制拆开看,再看它们怎么配合。
一、过期策略:两把尺子量到底
过期策略解决一个问题:设了 TTL 的 key,时间到了怎么删。
注意这个限定——“设了 TTL 的 key”。如果你没设过期时间,过期策略跟你没关系。这个后面还会提到。
过期策略(Expiry Policy)——管理设置了 TTL 的 key 在过期后的删除方式。类比:冰箱里的牛奶标了保质期,过期了不一定立刻扔,但要喝的时候会检查。
Redis 用了两把尺子:一把叫"惰性删除”,一把叫"定期删除”。一懒一勤,配合干活。
1.1 惰性删除——你不碰我,我就不删
下面所有实验都在本地 Docker Redis 7.0(macOS)上运行,使用默认配置,除非特别说明。实验数据可能因环境差异略有不同。
我跑了一组实验来验证惰性删除的实际行为。
redis-cli SET mykey "hello"
redis-cli EXPIRE mykey 5
然后等了 6 秒,肯定过期了对吧?但这个时候,Redis 并没有主动删掉这个 key。如果你立刻查 EXISTS mykey,结果取决于你有没有"碰"它。
我做了两组对比:
场景一:过期后立刻访问(GET)
SET mykey "hello" + EXPIRE 5
→ 等待 6 秒
GET mykey → (nil) ← 被删了
EXISTS mykey → 0 ← 确实没了
场景二:过期后不访问,直接用 EXISTS 检查
SET lazykey "i-will-expire" + EXPIRE 3
→ 等待 4 秒
EXISTS lazykey → 1 ← key 还在内存里!
GET lazykey → (nil) ← GET 触发了惰性删除
EXISTS lazykey → 0 ← 这次真的没了
发现了吗?上面的实验揭示��一个细节:EXISTS 返回了 1,说明它不一定触发惰性删除——至少在这个 Redis 版本中是这样。而 GET 确实触发了删除。
准确地说,任何读操作(GET、MGET、TTL 等)都会检查被访问的 key 是否过期,过期就删掉再返回。Redis 源码里负责这个检查的函数叫 expireIfNeeded,你不需要记住这个名字。EXISTS 的行为在不同版本中有差异,旧版本中它可能只检查 key 是否在字典里,不触发过期删除——所以实验中才会出现"EXISTS 返回 1,但随后 GET 返回 nil"的现象。

但关键问题是:如果你再也不碰这个 key,它就永远占用内存。
为了验证这个结论,我写了一个 10KB 的大 key,设 2 秒过期:
SET bigdata "xxxxx...(10KB)" + EXPIRE 2
→ 等待 3 秒
INFO memory → used_memory: 1.16M ← 内存还在
GET bigdata → (nil) ← 触发惰性删除
INFO memory → used_memory: 1.16M ← Redis 释放内存需要时间
惰性删除释放内存不是即时的,但 key 确实在 GET 时被删了。只是 Redis 的内存分配器不会立刻把内存还给 OS。
一句话总结惰性删除:它只在有人访问时才干活。没人访问的过期 key,它就是视而不见。
这听起来很"懒",但 Redis 这么做有它的道理:如果一个 key 过期后再也没人访问,删除它也是白费 CPU。惰性删除就是"按需清理":需要读才删,不需要就不删,CPU 成本几乎为零。每次读取操作多一次过期检查而已。
1.2 定期删除——后台的扫地机器人
但问题来了:如果一个 key 过期后再也没人访问,它就一直占着内存?那缓存雪崩不就是迟早的事?
这就是第二把尺子的用武之地。Redis 在后台跑了一个定时任务 activeExpireCycle(源码里叫这个名字),默认配置(hz=10)下每 100 毫秒执行一次。hz 是可配置的,调大频率就更高。它的工作方式很有意思:
- 从设置了过期时间的 key 中随机采样 20 个
- 检查哪些已过期,删除它们
- 如果过期比例超过 25%,就继续扫下一批
- 每次执行有严格时间上限(约 1ms),不会成为主线程瓶颈
这个"25% 比例触发继续扫"的自适应机制是关键设计。如果过期 key 比例低,Redis 就少扫几次,省 CPU;如果突然有大量 key 过期,它会持续扫直到比例降下来。

我跑了三组实验来验证定期删除的能力:
第一组:1000 个 key 同时过期
写入 1000 个 key,全部 TTL=5 秒
→ 等待 8 秒
→ 过期 key 全部被清理(expired_keys: 1006,含前序实验残留的 6 个)
DBSIZE: 0
第二组:10000 个 key 同时过期
写入 10000 个 key,全部 TTL=5 秒
→ 等待 10 秒
→ 全部清理完毕
DBSIZE: 0
第三组:50000 个 key 同时过期(极端情况)
写入 50000 个 key,全部 TTL=5 秒
→ 等待 15 秒
→ 全部清理完毕
DBSIZE: 0
50000 个 key 同时过期,Redis 在 15 秒内全部清理完毕。这个效率相当高,Redis 是单线程的,5 万个 key 在 15 秒内清理完,相当于每秒处理 3000+ 个 key。activeExpireCycle 每 100ms 只扫 20 个 key,但它有自适应机制:当过期比例高时,它会持续扫描,不会扫完 20 个就休息。
不过,定期删除也有它的限制。每次执行的时间被限制在约 1ms 以内,而且每次只采样 20 个 key。如果你的业务场景是"几百万个 key 在极短时间内同时过期",定期删除可能还是忙不过来。
这里有个关键的设计细节:activeExpireCycle 默认每 100ms 执行一次(hz=10 时),每次最多运行约 1ms。如果 1ms 内扫完了 20 个 key 且过期比例低于 25%,它就提前结束,等下一个 100ms 周期再跑。这个设计保证了定期删除不会成为 Redis 主线程的瓶颈。
惰性删除作为兜底就显得很重要了:如果定期删除没扫到的过期 key,只要有客户端访问它,惰性删除就会出手。两把尺子互为补充。
定期删除扫描的是设置了过期时间的 key 空间,不是所有 key。也就是说,如果某个 key 没有设 TTL,定期删除根本不会检查它。这个设计很合理,没设 TTL 的 key 本来就不需要过期检查,何必浪费 CPU。
还有一个细节:定期删除每次扫描的 20 个 key 是从设置了过期时间的 key 字典中随机选择的,不是从整个数据库。这意味着如果你的数据库有 100 万个 key,但只有 100 个设置了 TTL,定期删除只会在那 100 个里采样。不会因为你总 key 多就增加扫描范围。
所以如果你的业务特征是"大量 key 设了 TTL 但很快就不用了",定期删除是主要清理手段。而如果你的业务特征是"少量 key 设了 TTL,大部分是无 TTL 的持久数据",那过期 key 主要靠惰性删除来清理——谁读到了谁删。
过期策略到这里就讲完了。核心记住一点:过期策略只管"设了 TTL 的 key",它的目标是"在过期后尽快释放内存",但不是"立即释放"。
讲完过期策略,该讲淘汰了。那些没设 TTL 的 key,如果内存满了怎么办?
二、内存淘汰:八把刀选一把
注意,淘汰机制和过期机制是完全独立的。它的触发条件是"内存满了",不是"时间到了"。
2.1 八种策略
内存淘汰(Eviction Policy):当 Redis 内存达到 maxmemory 上限时,选择哪些 key 删除来腾出空间。冰箱装不下了,不管有没有过期标签,总要扔点东西才能放进新的。
Redis 提供了 8 种淘汰策略,默认是 noeviction——内存满了直接报错,需要你主动配置为其他策略。4.0 之前有 6 种(没有 LFU 系列),4.0 开始补全到 8 种。可以分三组来看:
第一组:拒绝组(1 种)
- noeviction——内存满了就不让写,直接报错。简单粗暴,适合数据绝对不能丢的场景。
我实测了一下:设 maxmemory=2MB,然后用 SET 逐个写入 10KB 的 key。前 78 个 key 都成功了,第 79 个时报了 OOM command not allowed when used memory > 'maxmemory'。noeviction 说到做到——满了就真不让写。
第二组:全体组(3 种)——从所有 key 里选
- allkeys-lru——淘汰最近最少使用的 key
- allkeys-lfu——淘汰访问频率最低的 key(4.0 引入)
- allkeys-random——随机淘汰
第三组:过期组(4 种)——只从设了 TTL 的 key 里选
- volatile-lru——从过期 key 里淘汰最近最少使用的
- volatile-lfu——从过期 key 里淘汰访问频率最低的
- volatile-random——从过期 key 里随机淘汰
- volatile-ttl——淘汰剩余 TTL 最短的

我跑了一组实验来展示 volatile-lru 和 allkeys-lru 的关键区别:
实验:volatile-lru 策略下,有 TTL 的 key vs 无 TTL 的 key
创建 10 个有 TTL 的 key(withttl:1-10)+ 10 个无 TTL 的 key(nottl:1-10)
设置 maxmemory=128KB 触发淘汰
结果:
有 TTL 的 key 剩余:0/10 ← 全部被淘汰
无 TTL 的 key 剩余:10/10 ← 全部保留
这个实验完美展示了 volatile-* 策略的行为:它只在设了 TTL 的 key 里做选择,无 TTL 的 key 永远不会被淘汰。
反过来,allkeys-* 策略则一视同仁——不管有没有 TTL,全都可以被淘汰。
这就是为什么说"过期"和"淘汰"是两套独立机制:
- 过期策略只关心"设了 TTL 的 key 过期了没有"
- 淘汰策略关心"内存满了要删谁"
- 它们的交集是"设了 TTL 的 key 也可能被淘汰器选中"——但这是偶然,不是必然
2.2 近似 LRU——精确的代价太高
LRU(Least Recently Used)是最常用的淘汰策略。但你猜 Redis 用的是不是精确 LRU?
不是。精确 LRU 需要维护一个全局的访问时间链表,每个 key 被访问时都要更新链表——内存消耗大,更新成本高。Redis 用的是近似 LRU。
怎么近似呢?Redis 的做法是:
- 从所有 key 中随机采样 N 个(N = maxmemory-samples,默认 5)
- 从这 N 个里选一个最久没被访问的淘汰
- 重复直到内存降到 maxmemory 以下
采样数 N 决定了淘汰的精度。我跑了一组实验来验证这个参数的影响:
| maxmemory-samples | 热 key(前 200 个访问 10 次)保留 | 冷 key(后 200 个不访问)保留 |
|---|---|---|
| 5(默认) | 200/200 | 172/200 |
| 20(高采样) | 200/200 | 194/200 |
| 1(极低采样) | 96/200 | 92/200 |
实验结果很有意思:
- samples=5(默认):热 key 全部保留,冷 key 大部分被淘汰——精度已经不错
- samples=20:热 key 全部保留,冷 key 保留更多(194/200)——淘汰选择更精确
- samples=1:热 key 和冷 key 保留率接近(~50%)——基本退化为随机淘汰

samples=20 和 samples=5 的差异不大,说明 Redis 默认的 5 已经是一个很好的平衡点。但在内存非常紧张的场景下,把 samples 提高到 10 可以换来更好的精度,代价是每次淘汰时多采样 5 个 key 的 CPU 开销。
Redis 3.0 之后还加了 eviction pool 优化,维护一个淘汰候选池让近似 LRU 更接近精确 LRU。这个优化我没单独做实验验证,但 Redis 官方文档和源码注释里有说明,日常使用中你不需要关心这个细节。
2.3 LFU——治"偶发热点"的良药
LRU 有个经典问题:一个 key 被大量访问一次后就再也没人用了,但它在 LRU 链上排在"最近使用"的位置,淘汰时会绕过它。
这就是"偶发热点"问题。举个例子:一个定时任务每分钟批量读一批 key,这批 key 每分钟被"刷"一次最近访问时间,但它们其实不热——只是运气好,在 LRU 检查时刚好被访问了。
LFU(Least Frequently Used)就是来解决这个问题的。它不看"最近一次访问时间",而是看访问频率。
Redis 的 LFU 有两个关键参数:
- lfu-log-factor:频率增长速率。值越大,频率增长越慢,越难变"热"。默认 10,如果你想让热点数据更"黏",可以调低到 5
- lfu-decay-time:频率衰减周期。以分钟为单位,N 分钟内没被访问,频率减半。默认 1,即 1 分钟不访问频率就衰减
实现上,Redis 用了 Morris counter(概率计数器)来追踪访问频率,而不是精确计数。好处是只需要 8 位就能表示约百万次访问(在默认 lfu-log-factor=10 下),非常适合嵌入 key 的 metadata。这个设计很紧凑,代价是访问频率是近似值——需要精确访问次数统计的业务场景,LFU 不合适。具体位分配等实现细节见源码,这里不展开。

一个小细节:LFU 和 LRU 共享同一个 maxmemory-policy 配置空间,不能同时启用。你在配置里写 allkeys-lfu,那就是 LFU 模式;写 allkeys-lru,那就是 LRU 模式。它们各自有自己的算法实现,不冲突。
三、过期 vs 淘汰:5 个维度说清楚

讲到这里,两套机制的核心都拆开了。最后用一个对比表来收束:
| 维度 | 过期策略 | 内存淘汰 |
|---|---|---|
| 触发条件 | 时间到了 | 内存满了 |
| 目标 | 释放过期 key 的内存 | 腾出空间给新写入 |
| 作用对象 | 仅限设了 TTL 的 key | 取决于策略(allkeys: 所有 key / volatile: 仅设 TTL 的 key) |
| 执行者 | expireIfNeeded(惰性)+ activeExpireCycle(定期) | eviction pool + 策略算法 |
| 可控性 | 不可控(TTL 到了就过期) | 可控(8 种策略可选) |
最关键的区分在这张表里。很多人把"过期"和"淘汰"混为一谈,其实是没分清这两者的触发条件和作用对象。
过期是时间问题,淘汰是空间问题。 一个 key 可以同时经历这两件事:先过期(时间到了),然后因为没人访问它而继续占内存,最后被淘汰器选中(空间满了)。
但它们也可以是独立发生的:一个没设 TTL 的 key,永远不会触发过期策略;一个设了 TTL 的 key,如果内存永远不满,淘汰器永远不会被触发。
3.1 它们怎么配合
两套机制在实际运行中是配合的:
- 定期删除释放了内存 → 淘汰器可能不必触发。如果你的过期 key 被定期删除及时清理了,内存就有空间给新 key,淘汰器不需要出手。
- 设了 TTL 的 key 被淘汰器选中。在 volatile-* 策略下,淘汰器只从设了 TTL 的 key 里选——至于选哪个,按 LRU/LFU/Random/TTL 算法决定,不是因为"快过期所以先删"。被淘汰的 key 不一定已过期,只是设了 TTL。
- 过期 key 太多 → 定期删除 CPU 上升。如果大量 key 在同一时间过期,定期删除会持续扫描,CPU 消耗增加。
回到文章开头的那个案例:session key 集中过期 → 定期删除忙不过来 → 过期 key 占内存 → 淘汰器触发 → 活跃 key 被误淘汰。缓存雪崩就发生了。

这个链条里,两套机制都参与了,但不是 Redis 有 bug——它们都在按设计工作,只是我们的业务设计(统一 TTL)触发了两套机制最不利的协作模式。
解决方案其实很简单:给 session key 的 TTL 加入随机偏移(±30%),让过期时间分散开。这套组合拳下来,类似的问题就很少再出现了。
3.2 实战建议
根据你的业务场景,可以这样选:
什么时候关注过期策略:
- 你的 key 都有 TTL
- 大量 key 同时过期
- 对过期 key 的即时性有要求
什么时候关注淘汰策略:
- 缓存是"写满删"的模式(没有 TTL)
- 内存是瓶颈资源
- 数据有冷热分布
选什么淘汰策略:
- 大部分场景 → allkeys-lru(通用选择)。根据上面 E4 实验,samples=5 的默认值已经是不错的平衡,日常使用不需要额外调参
- 有偶发热点 → allkeys-lfu(4.0+ 推荐)
- 业务数据可丢,但不能丢没 TTL 的 → volatile-lru
- 数据绝对不能丢 → noeviction + 监控告警

另外还有一个常见的实用技巧:把 Redis 分成多个实例,不同的业务用不同的淘汰策略。比如 session 缓存用 volatile-lru(有 TTL 的保护无 TTL 的业务数据),数据缓存用 allkeys-lru(一视同仁),各管各的,互不影响。
回到开头的那个问题:5 分钟到了,key 真的被删了吗?
不一定是"被删除"。如果没人访问它,且定期删除还没扫到它,它还在内存里。但如果你设的 TTL 是 5 分钟,而你的内存限制是 1GB——那它在被访问前可能先被淘汰器盯上了。
Redis 的过期和淘汰,是两套独立但又会互相影响的内存管理机制。过期是时间问题,淘汰是空间问题。记住这句话,下次遇到 Redis 内存问题,你就知道该从哪套机制查起了——过期查 TTL,淘汰查 maxmemory 和策略配置。
原文发布于 止语Lab