土法炼钢兴趣小组的算法知识备份

【Redis / 缓存内核】TTL 与过期:expires dict · 惰性/主动删除 · lazyfree

文章导航

分类入口
databasestorage
标签入口
#redis#ttl#expire#lazyfree#expires-dict#active-expire#cache#7.4

目录

第 6 篇 讲完 ZSet 与 Stream 的内存布局;从本篇起进入「键何时消失」这一组问题。应用侧给 key 设了 EXPIRE,但 Redis 不会为每个 TTL 维护一个精确到毫秒的定时器——那会引入与键数量线性增长的堆结构,与 O(1) 命令路径的设计目标冲突。工程上的折中是:辅助 expires 表 + 访问时惰性删除 + 后台主动采样,必要时把大块释放挪到 bio 线程。

本文锚定 Redis OSS 7.4.x 源码:expire.c(主动过期)、db.c(惰性过期)、lazyfree.c(异步释放)。不展开 Hash 字段级过期(HFE,ebuckets)的全部细节,只点出它与 key 级过期并行的存在。

本文是「Redis / 缓存内核」系列第 7 篇(共 16 篇)。→ 系列目录

版本锚定:Redis OSS 7.4 / 8.x;源码路径以 redis-7.4 tag 为准。


一、问题:TTL 不是定时器,而是「到期时间戳」

对字符串 key 执行 SET k v EX 60,Redis 在 db->keys 里照常存 (k → redisObject),同时在 db->expires 里再记一条 (k → expire_at_ms)expireskeys平行结构(7.4 起均为 kvstore),value 侧只存绝对毫秒时间戳,不存剩余秒数。

这种「双表」设计与经典缓存文献里的 lazy expiration 一脉相承:到期判断是 \(O(1)\) 比较 now > expire_at,真正删除可以推迟到读路径后台清扫。与之对照的是 proactive expiration(为每个对象维护定时事件)——精确但内存与调度成本高;Redis 选择后者为主、前者为辅的混合,与 Memcached 的 lazy + crawler 思路同类,但实现落在自己的 dict 采样框架里。

常见运维困惑:设了 TTL 的 key 到期后仍占内存,往往是因为从未被访问、主动过期周期还没扫到,或 value 很大正走 lazyfree 排队——下文分别钉路径。


二、expires dict 与设置路径

redisDbserver.h 中同时持有 keysexpires

typedef struct redisDb {
    kvstore *keys;
    kvstore *expires;      /* 仅含带 TTL 的 key → 绝对毫秒时间戳 */
    ebuckets hexpires;     /* Hash 字段级过期(7.4+) */
    /* ... */
    unsigned long expires_cursor;
} redisDb;

PEXPIREAT / SET ... EX 最终调用 setExpire():在 expires 插入或更新条目;取消 TTL 则从 expires 删除。主数据始终在 keysexpires 是纯索引,主动过期循环只扫这张表(或对 HFE 扫 hexpires),避免遍历全库。

flowchart LR
  cmd["SET / EXPIRE / PEXPIREAT"]
  keys["db->keys<br/>key → robj"]
  exp["db->expires<br/>key → expire_at_ms"]
  cmd --> keys
  cmd --> exp

三、惰性删除:读路径上的 expireIfNeeded

任何 lookupKeyRead / lookupKeyWrite 在返回对象前调用 expireIfNeeded()db.c)。逻辑极简:

  1. keyIsExpired()expires 里有条目且 mstime() > expire_at
  2. 主节点上调用 deleteExpiredKeyAndPropagate():从 keys/expires 摘除,发 DEL 到 AOF/复制流,递增 stat_expiredkeys
  3. 从节点默认不本地删(等主节点合成 DEL),避免复制序错乱;可写副本例外。
keyStatus expireIfNeeded(redisDb *db, robj *key, int flags) {
    if (server.lazy_expire_disabled) return KEY_VALID;
    if (!keyIsExpired(db, key)) return KEY_VALID;
    /* 从节点、PAUSE ACTION 等边界略 */
    deleteExpiredKeyAndPropagate(db, key);
    return KEY_DELETED;
}

语义后果:冷 key 过期后仍占内存,直到被 GET 触发删除或主动过期扫到。这与「TTL = 精确消失时刻」的直觉不同——TTL 定义的是逻辑失效时间,物理回收允许滞后。缓存穿透场景下,过期 key 被读一次会多付一次删除成本,但之后 GET 得到 miss。


四、主动过期:activeExpireCycle 自适应采样

仅靠惰性删除,永不访问的过期 key 会长期占内存。expire.cactiveExpireCycle() 在两条路径上运行:

类型 触发 时间预算
ACTIVE_EXPIRE_CYCLE_SLOW serverCron,频率 hz(默认 10) hz 周期的 25% CPU(可被 active-expire-effort 放大)
ACTIVE_EXPIRE_CYCLE_FAST 每次 beforeSleep 默认 1000 μs;仅当 stale 比例高或 slow 周期因超时退出时加码

核心循环对 db->expires部分扫描(非全表):每轮最多 ACTIVE_EXPIRE_CYCLE_KEYS_PER_LOOP(20,随 effort 增加)个 key,通过 expireScanCallback 调用 activeExpireCycleTryExpire()——与惰性路径共用 deleteExpiredKeyAndPropagate()

int activeExpireCycleTryExpire(redisDb *db, dictEntry *de, long long now) {
    long long t = dictGetSignedIntegerVal(de);
    if (now > t) {
        /* deleteExpiredKeyAndPropagate ... */
        return 1;
    }
    return 0;
}

自适应体现在 stat_expired_stale_perc:若过期 key 占采样比例超过阈值(默认 10%,随 effort 降低),则加大扫描力度;否则 slow 周期提前结束以省 CPU。active-expire-effort(1–10)同时放大 fast/slow 的 key 数与时间上限——这是 Redis 7.x 暴露给运维的「过期清扫力度」旋钮,不是魔法开关。

flowchart TD
  cron["serverCron / beforeSleep"]
  slow["slow cycle<br/>~25% of hz tick"]
  fast["fast cycle<br/>≤1000 μs"]
  scan["sample expires dict"]
  del["deleteExpiredKeyAndPropagate"]
  cron --> slow
  cron --> fast
  slow --> scan
  fast --> scan
  scan --> del

4.1 与 maxmemory 的边界

主动过期删的是已逻辑过期的 key;maxmemory 淘汰删的是仍有效的 key(见 第 8 篇)。两者都减内存,但触发条件与策略矩阵完全不同。运维上「内存满了但大量 key 已过期」通常是主动过期跟不上写入速度,而非淘汰策略选错。


五、lazyfree:大对象删除不堵主线程

同步删除一个百万域的 Hash 或长 Stream,会在主线程上递归 decrRefCount,造成毫秒级停顿。lazyfree.cbio 后台线程承接释放:

与过期相关的配置:

配置项 作用
lazyfree-lazy-expire 主动/惰性过期删除大 value 时走 UNLINK 语义
lazyfree-lazy-eviction maxmemory 淘汰时异步释放(第 8 篇)
lazyfree-lazy-server-del UNLINK 命令

INFO 中的 lazyfree_pending_objects 表示排队中的对象数——key 已对客户端不可见,但内存尚未归还 allocator,碎片整理或 jemalloc purge 前 RSS 可能仍偏高。

删除路径在 dbGenericDelete() 选择 unlink(异步)或 del(同步);过期与淘汰通过 server.lazyfree_lazy_expire / lazyfree_lazy_eviction 切换。


六、学术谱系与争论

思路 代表 Redis 落点
Lazy expiration 经典缓存/数据库教材 expireIfNeeded 读路径
Periodic sweep 定时清扫 activeExpireCycle slow
Work-bounded sampling CLOCK 类近似 fast/slow 双周期 + stale 比例
Async reclamation 日志结构存储延迟回收 lazyfree + bio

争论点(工程上仍活跃)

  1. 采样过期 vs 精确过期:精确堆结构在键空间极大时 CPU/内存不可接受;采样带来「过期 key 占内存多久」的上界与 workload 相关,官方用 active-expire-effort 调节,但没有闭式 SLA。
  2. 惰性 vs 主动谁先:读多写少 workload 下惰性足够;大量写 TTL 的冷 key(会话、验证码)依赖主动周期,7.4 的 fast cycle 就是为高 stale 比例加码。
  3. lazyfree 与复制/AOF:主线程先传播 DEL,对象异步释放——副本与持久化看到的是逻辑删除时刻,与 InnoDB 异步 purge 类似,存在「逻辑已删、物理仍占内存」窗口。

开放问题:HFE(Hash 字段 TTL)与 key TTL 两套结构并行后,主动过期的 CPU 预算如何在 key 级与 field 级之间分配(activeExpireHashFieldCycle),大规模 HFE 场景的最佳 active-expire-effort 仍缺公开基准。


七、常见误解

7.1 「TTL 到期 = 立刻不占内存」

不对。到期只保证下次访问视为不存在;物理内存回收取决于惰性命中、主动采样是否扫到、以及 lazyfree 队列深度。监控应用 INFO memorylazyfree_pending_objects,不要只看 TTL 返回值。

7.2 「Redis 为每个 key 建了定时器」

不对。expires 是哈希表,不是 timer wheel。没有 per-key 回调;只有全局 cron 驱动的采样循环。

7.3 「从节点会自己删掉过期 key」

默认不会。从节点在 expireIfNeeded 中对过期 key 返回 KEY_EXPIRED 但不删除,等主节点 DEL 流下来,保证复制序一致。可写副本 + EXPIRE_FORCE_DELETE_EXPIRED 是例外路径。

对调用方都是 key 消失;差别在服务端是否阻塞主线程释放大对象。过期路径在开启 lazyfree-lazy-expire 时内部用 UNLINK 语义。


八、小结

三句话小结

  1. TTL 存的是绝对时间戳,挂在独立 expires 表;判断过期 O(1),物理删除可滞后。
  2. 惰性删除挂在每次 lookupKey主动过期用 fast/slow 双周期采样 expires,力度由 active-expire-effort 调节。
  3. lazyfree 把超过阈值的大对象释放挪到 bio 线程,避免过期/删除拖垮单线程命令延迟。

上一篇ZSet 与 Stream

下一篇maxmemory 淘汰策略


参考资料

  1. Redis OSS 7.4 源码:src/expire.cactiveExpireCycleactiveExpireCycleTryExpire)、src/db.cexpireIfNeededdeleteExpiredKeyAndPropagate)、src/lazyfree.cfreeObjAsyncLAZYFREE_THRESHOLD)、src/server.hredisDb.expires)。
  2. Redis Documentation v7.x, Key expirationLazy freeingACTIVE-EXPIRE-EFFORT
  3. 经典缓存替换语境下的 lazy expiration,对照 页面置换算法 第三节「缓存抽象」。
  4. 系列 index第 8 篇 maxmemory

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。


By .