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 四种策略的对比与协作

这四种策略不是互斥的,而是互补的。实际系统中,通常是多策略组合使用:

TTL 过期策略协作关系图 数据存储层 被动过期 读路径检查 主动过期 定时随机抽样 惰性删除 异步释放内存 定时扫描 时间槽分批扫描 策略协作说明 1. 被动过期:保证「读」时数据一致性,过期 key 不会被读到 2. 主动过期:防止过期 key 长期占用内存,定期随机清理 3. 惰性删除:大 key 删除不阻塞主线程,异步释放内存 4. 定时扫描:按时间槽精确清理,适合对过期精度要求高的场景

实际系统中,这四种策略的协作关系是这样的:

  • 被动过期是第一道防线,保证读一致性
  • 主动过期是第二道防线,防止内存泄漏
  • 惰性删除是性能保障,避免大 key 阻塞
  • 定时扫描是精度补充,适合对过期时间敏感的场景

总结:没有一种策略是万能的。被动过期省 CPU 但可能漏删,主动过期保内存但耗 CPU,惰性删除保性能但延迟释放,定时扫描精度高但实现复杂。好的系统设计,是根据业务场景选择合适的策略组合。

嗯,这一章的内容就到这里。四种策略的细节比较多,但核心就一句话:过期清理不是「要不要做」的问题,而是「怎么做更划算」的问题。