第 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.4tag 为准。
一、问题:TTL 不是定时器,而是「到期时间戳」
对字符串 key 执行 SET k v EX 60,Redis 在
db->keys 里照常存
(k → redisObject),同时在
db->expires 里再记一条
(k → expire_at_ms)。expires 与
keys 是平行结构(7.4 起均为
kvstore),value
侧只存绝对毫秒时间戳,不存剩余秒数。
这种「双表」设计与经典缓存文献里的 lazy
expiration 一脉相承:到期判断是 \(O(1)\) 比较
now > expire_at,真正删除可以推迟到读路径或后台清扫。与之对照的是
proactive
expiration(为每个对象维护定时事件)——精确但内存与调度成本高;Redis
选择后者为主、前者为辅的混合,与 Memcached 的 lazy + crawler
思路同类,但实现落在自己的 dict 采样框架里。
常见运维困惑:设了 TTL 的 key 到期后仍占内存,往往是因为从未被访问、主动过期周期还没扫到,或 value 很大正走 lazyfree 排队——下文分别钉路径。
二、expires dict 与设置路径
redisDb 在 server.h 中同时持有
keys 与 expires:
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
删除。主数据始终在 keys;expires
是纯索引,主动过期循环只扫这张表(或对 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)。逻辑极简:
keyIsExpired():expires里有条目且mstime() > expire_at。- 主节点上调用
deleteExpiredKeyAndPropagate():从keys/expires摘除,发DEL到 AOF/复制流,递增stat_expiredkeys。 - 从节点默认不本地删(等主节点合成
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.c 的
activeExpireCycle() 在两条路径上运行:
| 类型 | 触发 | 时间预算 |
|---|---|---|
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.c
用 bio 后台线程承接释放:
lazyfreeGetFreeEffort()估算对象「逻辑子项」数量(如 quicklist 长度、dict 大小)。- 超过
LAZYFREE_THRESHOLD(64)且refcount == 1时,freeObjAsync()投递bioCreateLazyFreeJob(),主线程立即从 dict 摘除 key,对象稍后由lazyfreeFreeObject释放。
与过期相关的配置:
| 配置项 | 作用 |
|---|---|
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 |
争论点(工程上仍活跃):
- 采样过期 vs
精确过期:精确堆结构在键空间极大时
CPU/内存不可接受;采样带来「过期 key 占内存多久」的上界与
workload 相关,官方用
active-expire-effort调节,但没有闭式 SLA。 - 惰性 vs 主动谁先:读多写少 workload 下惰性足够;大量写 TTL 的冷 key(会话、验证码)依赖主动周期,7.4 的 fast cycle 就是为高 stale 比例加码。
- lazyfree 与复制/AOF:主线程先传播
DEL,对象异步释放——副本与持久化看到的是逻辑删除时刻,与 InnoDB 异步 purge 类似,存在「逻辑已删、物理仍占内存」窗口。
开放问题:HFE(Hash 字段 TTL)与 key TTL
两套结构并行后,主动过期的 CPU 预算如何在 key 级与 field
级之间分配(activeExpireHashFieldCycle),大规模
HFE 场景的最佳 active-expire-effort
仍缺公开基准。
七、常见误解
7.1 「TTL 到期 = 立刻不占内存」
不对。到期只保证下次访问视为不存在;物理内存回收取决于惰性命中、主动采样是否扫到、以及
lazyfree 队列深度。监控应用 INFO memory 与
lazyfree_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 是例外路径。
7.4 「UNLINK 和 DEL 对客户端语义不同」
对调用方都是 key
消失;差别在服务端是否阻塞主线程释放大对象。过期路径在开启
lazyfree-lazy-expire 时内部用
UNLINK 语义。
八、小结
三句话小结
- TTL 存的是绝对时间戳,挂在独立
expires表;判断过期 O(1),物理删除可滞后。 - 惰性删除挂在每次
lookupKey;主动过期用 fast/slow 双周期采样expires,力度由active-expire-effort调节。 - lazyfree 把超过阈值的大对象释放挪到 bio 线程,避免过期/删除拖垮单线程命令延迟。
上一篇:ZSet 与 Stream
下一篇:maxmemory 淘汰策略
参考资料
- Redis OSS 7.4
源码:
src/expire.c(activeExpireCycle、activeExpireCycleTryExpire)、src/db.c(expireIfNeeded、deleteExpiredKeyAndPropagate)、src/lazyfree.c(freeObjAsync、LAZYFREE_THRESHOLD)、src/server.h(redisDb.expires)。 - Redis Documentation v7.x, Key
expiration、Lazy
freeing、
ACTIVE-EXPIRE-EFFORT。 - 经典缓存替换语境下的 lazy expiration,对照 页面置换算法 第三节「缓存抽象」。
- 系列 index、第 8 篇 maxmemory。
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【Redis / 缓存内核】生产排障:大 key · fork · 慢日志与可复现清单
按 Redis Latency Monitoring 与 INFO/SLOWLOG 信号,建立大 key、fork 延迟、阻塞命令、内存碎片四类故障的排查决策树;给出可在本机 redis-server 上执行的复现命令清单,不粘贴伪造输出。
【Redis / 缓存内核】缓存服务器全景:HashMap · Memcached · Redis · 嵌入 KV
定位进程内 HashMap、Memcached、Redis 与 RocksDB 嵌入 KV 在内存缓存栈中的生态位;划分与 architecture/17、storage/66、linux/redis-source 的分工,并给出本系列 16 篇阅读地图。
【Redis / 缓存内核】maxmemory 策略:近似 LRU/LFU 与 MEMORY DOCTOR
从 evict.c 拆解 volatile/allkeys 与 LRU/LFU/random/ttl 策略矩阵、24 位时钟近似 LRU、淘汰池采样,并对照算法篇真 LRU 与 MEMORY DOCTOR 信号。
【Redis / 缓存内核】RDB:fork COW · BGSAVE · 快照格式
从 rdb.c 拆解 SAVE/BGSAVE 的 fork 写时复制语义、子进程快照路径,以及 REDIS 魔数与 opcode 锚定的 RDB 格式边界。