3、TTL实现机制:被动过期、主动过期、惰性删除、定时扫描
TTL 过期策略,说白了就是「怎么把过期的数据清理掉」。
我刚开始接触 Redis 时,以为它有个后台线程一直在扫,发现过期就删。后来翻了源码才发现,事情没那么简单。实际上,TTL 的清理机制是四种策略的组合拳:被动过期、主动过期、惰性删除、定时扫描。
嗯,这里要注意——很多资料把「被动过期」和「惰性删除」混为一谈,其实它们是两回事。咱们一个一个拆开讲。
3.1 被动过期(Passive Expiry)
被动过期,也叫「懒过期」。它的逻辑很简单:数据过期了?先不管,等有人访问它再说。
你想想看,如果一个 key 设置了 TTL=60 秒,60 秒后它确实该过期了。但如果没有任何客户端来读它,系统何必急着删它?删了也是白删,还浪费 CPU。
所以被动过期的做法是:
- 每次读取 key 时,检查它的过期时间
- 如果已过期,立即删除,返回 nil
- 如果未过期,正常返回数据
核心逻辑:过期检查发生在「读」路径上,不是后台线程。
我在项目中遇到过一个问题:某个缓存 key 设置了 5 分钟过期,但业务高峰期有大量并发读。结果每次读都要检查过期时间,虽然检查本身很快(O(1) 复杂度),但架不住请求量太大。后来我们做了本地缓存兜底,才把 Redis 的读压力降下来。
3.2 主动过期(Active Expiry)
被动过期有个明显的坑:如果过期 key 一直没人访问,它就永远占着内存。
举个例子,你存了 100 万个 key,TTL 都是 1 小时。1 小时后它们全过期了,但如果没有客户端来读它们,这些 key 就赖在内存里不走。内存泄漏?不完全是,但确实浪费。
主动过期就是来解决这个问题的。Redis 的做法是:
- 每 100ms 执行一次「过期扫描」
- 从设置了过期时间的 key 集合中,随机抽一批检查
- 如果过期了就删掉
具体来说,Redis 的主动过期策略是这样的:
# 伪代码逻辑
def active_expire_cycle():
while True:
# 从过期字典中随机取 20 个 key
keys = random_sample(expire_dict, 20)
expired_count = 0
for key in keys:
if key.is_expired():
delete(key)
expired_count += 1
# 如果过期比例超过 25%,继续扫
if expired_count > 20 * 0.25:
continue
else:
break
# 每次循环最多执行 25ms
if time_elapsed > 25ms:
break
为什么要随机抽样?因为全量扫描太慢了。100 万个 key 全扫一遍,CPU 扛不住。随机抽样 + 比例控制,能在「清理效率」和「CPU 开销」之间找到平衡。
避坑指南:我曾经遇到过一个案例,业务方给大量 key 设置了相同的 TTL,导致同一时刻大量 key 集中过期。Redis 的主动过期策略在清理时,发现过期比例超过 25%,就会一直循环扫描,导致 CPU 飙升。解决方案是给 TTL 加一个随机偏移量,比如 TTL = 3600 + random(0, 600)。
3.3 惰性删除(Lazy Deletion)
惰性删除,这个名字容易让人误解。它不是「懒着不删」,而是「删的时候不真删」。
什么意思呢?
在 Redis 中,当你执行 DEL 命令删除一个大的 hash 或 list 时,如果这个 key 里有上百万个元素,Redis 会卡住——因为它要遍历所有元素,逐个释放内存。
惰性删除的做法是:
- 把删除操作放到后台线程去执行
- 主线程只做「标记删除」,立刻返回
- 后台线程慢慢释放内存
Redis 4.0 引入了 UNLINK 命令,就是惰性删除的典型实现:
# 主线程:标记删除,O(1) 复杂度
UNLINK my_big_hash
# 后台线程:异步释放内存
# 不会阻塞主线程
我个人的习惯是:只要删除的 key 可能包含大量元素,一律用 UNLINK 代替 DEL。尤其是在生产环境,一个 DEL 卡住几秒钟,后果可能是雪崩级的。
| 特性 | DEL(同步删除) | UNLINK(惰性删除) |
|---|---|---|
| 时间复杂度 | O(N),N 为元素个数 | O(1) + 后台 O(N) |
| 是否阻塞主线程 | 是,大 key 会卡住 | 否,立即返回 |
| 适用场景 | 小 key、确定元素很少 | 大 key、不确定元素数量 |
| 内存释放时机 | 立即释放 | 后台异步释放 |
3.4 定时扫描(Timer Scan)
定时扫描,是主动过期的一种「增强版」。它不像 Redis 那样随机抽样,而是按时间槽(time slot)分批扫描。
这种策略在 Tair、Memcached 等系统中比较常见。它的核心思路是:
- 把时间分成固定大小的槽位(比如 1 秒一个槽)
- 每个 key 根据过期时间,落入对应的槽位
- 定时器每秒触发一次,扫描当前槽位里的所有 key
- 过期的直接删除
这样做的好处是:过期时间越近的 key,越早被扫描到。不会出现「明明快过期了,却一直没被扫到」的情况。
注意:定时扫描的精度取决于时间槽的大小。槽位越小,精度越高,但维护成本也越大。如果槽位是 1 秒,那 100 万个 key 分布在 86400 个槽里,每个槽平均只有 11 个 key,扫描压力很小。但如果所有 key 都落在同一个槽里,那这个槽就是性能瓶颈。
我曾经在自研的缓存中间件中实现过这种策略。当时遇到的问题是:业务方喜欢在整点(比如 00:00:00)设置大量 key 过期,导致 0 号槽的 key 数量是其他槽的 100 倍。解决方案是:在写入时对过期时间做「散列化」,比如 TTL 加上一个随机秒数,让 key 均匀分布到各个槽位。
3.5 四种策略的对比与协作
这四种策略不是互斥的,而是互补的。实际系统中,通常是多策略组合使用:
实际系统中,这四种策略的协作关系是这样的:
- 被动过期是第一道防线,保证读一致性
- 主动过期是第二道防线,防止内存泄漏
- 惰性删除是性能保障,避免大 key 阻塞
- 定时扫描是精度补充,适合对过期时间敏感的场景
总结:没有一种策略是万能的。被动过期省 CPU 但可能漏删,主动过期保内存但耗 CPU,惰性删除保性能但延迟释放,定时扫描精度高但实现复杂。好的系统设计,是根据业务场景选择合适的策略组合。
嗯,这一章的内容就到这里。四种策略的细节比较多,但核心就一句话:过期清理不是「要不要做」的问题,而是「怎么做更划算」的问题。