Appearance
过期删除与内存淘汰
看在前面
一个 key 的 TTL 到了,它不会立刻消失。过期是击穿的导火索(热 key 过期瞬间的并发重建,见缓存穿透-击穿-雪崩)。内存淘汰是雪崩的最后防线之一(Redis 不被打爆,DB 才有活路)。这篇把「过期怎么删」和「内存满了淘汰谁」一次讲透。姊妹篇:数据类型与底层编码、持久化-RDB与AOF、高可用与部署选型-主从哨兵与集群。
过期时间存在哪
每个 db 除了主字典,还有一张过期字典 expires:key → 绝对过期时间戳。SET k v EX 300、EXPIRE、PEXPIREAT 都是操作这张表;TTL 返回剩余秒数;PERSIST 摘除条目。带 TTL 的 key 同时存在于两张表里,「过期」的语义就是这两张表的协作。这决定了删除策略必须在 CPU(及时删)与内存(攒着删)之间找平衡。
顺带两个高频细节:
SET k v EX 300与先SET再EXPIRE语义等价。但不是原子的两条命令在并发下可能留下「永不过期」的 key,能用一条命令就别拆开。EXPIRE的 NX/XX/GT/LT 选项(7.0+)可做「只延长不缩短」(GT)、「原本无 TTL 才设置」(NX)这类精细控制。比如对热 key 做滑动续期时,GT 能避免误把长 TTL 改短。
三种经典策略,Redis 选了「惰性 + 定期」
| 策略 | 做法 | 缺点 |
|---|---|---|
| 定时删除 | 每个 key 挂一个 timer,到点即删 | key 一多 CPU 被删 key 吃光,Redis 不用 |
| 惰性删除 | 访问时才检查,过期才删 | 没人访问的 key 永远占内存 |
| 定期删除 | 周期性随机抽样删除 | 频率/时长要调,是前两者的折中 |
惰性删除:所有读写命令入口(lookupKeyRead/Write)先做 expireIfNeeded——过期就地删除并按 miss 处理。CPU 几乎零成本,但防不住内存泄漏。
定期删除(active expire cycle,在主线程 serverCron 里执行,每秒 hz 次、默认 10 次):
每 100ms 一轮:
对每个 db:
① 从 expires 随机抽 20 个带 TTL 的 key
② 删掉其中已过期的
③ 若「过期数 / 抽样数 > 25%」→ 说明过期 key 还很多,回到 ① 继续
④ 达到本轮时间上限 → 强制收工(保证不阻塞主线程),下轮再来两个可调项:hz(默认 10,可到 100,过期 key 突增或内存紧张的实例调大,代价是 CPU)。dynamic-hz yes 会按客户端数自适应。抽样数与 25% 阈值写死——这套设计的灵魂是「按过期 key 的密度自适应」:密度高多删几轮,密度低快速让出 CPU。
从库的过期策略:过期判断的权威在主库
面试官:从库会自己删过期 key 吗?读写分离会不会读到已过期的数据?
会读到。设计是:从库不做过期判断(惰性/定期都只在主库生效)。主库删除过期 key 时会向从库传播一条 DEL/UNLINK,从库照做。于是存在窗口:key 在从库视角已逻辑过期,但主库的 DEL 还没到(或主库自己还没抽到它)。此时读从库仍返回旧值。
这是刻意的:过期判定权收归主库,避免主从各自判断产生不一致。业务侧兜底:对过期敏感的 key 读主(读写分离的路由选项见高可用与部署选型-主从哨兵与集群客户端一节)。或 value 里带时间戳由业务自判。
内存淘汰:八种 maxmemory-policy
先给一张三层防线全景图,把「过期删除」和「内存淘汰」串起来:
写入 key(带 TTL)→ 进主字典 + expires 过期字典
│
├─ ① 定期删除:每 100ms 抽样清理大头(按过期密度自适应)
├─ ② 惰性删除:访问路径上顺手清理
│
└─ ①② 都没删干净的漏网 key
│
▼
maxmemory 打满 → ③ 淘汰策略挤出(noeviction 则写命令报错)淘汰在 maxmemory 打满时触发。八种策略是一张 2×4 矩阵 + 1:候选范围(allkeys 全部 / volatile 仅带 TTL 的)× 算法(lru / lfu / random / ttl),外加 noeviction:
| 策略 | 范围 | 语义 | 备注 |
|---|---|---|---|
| noeviction | —— | 内存满后写命令报 OOM 错误 | 默认值;当存储用才选它 |
| allkeys-lru | 全部 key | 近似 LRU 淘汰 | 纯缓存最常用 |
| volatile-lru | 带 TTL | 近似 LRU 淘汰 | 同实例混存可丢/不可丢数据 |
| allkeys-lfu | 全部 key | LFU 频率淘汰 | 周期性热点、抗偶发扫描 |
| volatile-lfu | 带 TTL | LFU 频率淘汰 | 同上,保护无 TTL key |
| allkeys-random | 全部 key | 随机淘汰 | 几乎不用 |
| volatile-random | 带 TTL | 随机淘汰 | 几乎不用 |
| volatile-ttl | 带 TTL | 剩余寿命最短先淘汰 | 逼近「先到期的先走」 |
陷阱先行:volatile-* 系列在没有一个 key 带 TTL 时,行为退化成 noeviction(写命令返回错误)。上线后要复核「所有可淘汰的 key 是否都有 TTL」。
近似 LRU:随机采样 + 候选池,不是全局链表
面试官:Redis 的 LRU 是真 LRU 吗?为什么不做真的?
不是真的。真 LRU 需要全局双向链表维护访问次序:每 key 两个额外指针、每次访问都要摘节点挂链头。内存和吞吐都太贵。Redis 的近似实现:
- 每个对象的
lru字段(24bit)记录最近访问时间戳(秒级精度,约 194 天回绕); - 淘汰时随机采样
maxmemory-samples个 key(默认 5,调 10 更准),淘汰其中空闲最久的; - 3.0 起加了 eviction pool:容量 16 的候选池按空闲时间有序。
- 采样的 key 若比池里最冷的还冷就入池,淘汰永远从池尾(最冷)拿。
- 多轮采样之间留下记忆,效果大幅逼近真 LRU。
复杂度:每次淘汰约 O(采样数),平摊 O(1),零额外指针。定性一句话:用一点点准确度损失,换掉了整条全局链表的内存和每次访问的维护成本。面试时主动给出这个结论。
LFU:4.0 的对数计数器 + 衰减
LRU 的死穴是偶发批量扫描:一次全表遍历能把冷数据全变「最近访问」,真热 key 反被挤出。LFU(4.0+)改记访问频率,全塞在同一个 24 bit lru 字段里:
┌──────────────────┬───────────────┐
│ 16bit ldt │ 8bit logc │
│ 上次衰减时间(分钟) │ 对数频率计数器 │
└──────────────────┴───────────────┘- 计数器是莫里斯对数计数器(0~255):访问时按概率
1 - 1/(counter*lfu_log_factor + 1)递增——counter 越大越难加。默认lfu-log-factor 10下,counter 从初始值涨到 255 大约需要百万次访问,8 bit 就够表达「极热」; - 新 key 的 counter 初始为 5(不是 0):新数据先给一点热度,避免刚回填就被淘汰;
- 衰减:每过
lfu-decay-time分钟(默认 1)counter 减 1——昨天的热 key 今天不再热,反映「近期频率」而非「历史频率」。
一句话对比:LRU 记「多久没碰」,LFU 记「最近多常碰」。有周期性热点、常有批量扫描(报表、爬虫、批量预热)的实例,LFU 命中率显著更高。redis-cli --hotkeys 正是按 LFU counter 排序的(热 key 探测见缓存穿透-击穿-雪崩)。
生产选型
| 场景 | 推荐 | 理由 |
|---|---|---|
| 纯缓存(可全丢) | allkeys-lru;有扫描/周期热点换 allkeys-lfu | 丢谁都只是回源重建,命中率优先 |
| 缓存 + 少量不可丢数据同实例 | volatile-lru / volatile-lfu | 用 TTL 圈定可丢范围(警惕无 TTL 退化) |
| Redis 当存储(队列、去重表、状态) | noeviction + 容量规划 + 告警 | 宁可写失败,不可静默丢数据 |
| 想提高淘汰精度 | maxmemory-samples 10 | 采样翻倍,CPU 代价可忽略 |
conf
maxmemory 4gb
maxmemory-policy allkeys-lfu
maxmemory-samples 10
lfu-log-factor 10
lfu-decay-time 1
lazyfree-lazy-expire yes # 过期释放走异步线程,大 value 过期不卡主线程
lazyfree-lazy-eviction yes # 淘汰释放同样异步化再配两个必看指标:
INFO的expired_keys(累计过期删除次数)看①②的工作量。evicted_keys(累计淘汰次数)持续上涨说明容量已经不够。淘汰是兜底不是常态,靠淘汰活着就该扩容或减量了。TTL 打散的教训见缓存穿透-击穿-雪崩的雪崩篇。
追问链
面试官:惰性 + 定期还是会漏掉一些过期 key,内存怎么办?
这正是三层防线的分工:定期删大头、惰性删访问路径、内存淘汰兜底。就算过期 key 一个没删干净,maxmemory 一满照样按策略挤出去。三件事共用同一套对象释放底层,设计上构成一个完整循环。
面试官:DEL 和 UNLINK 的区别?过期删除会卡吗?
DEL 在主线程同步释放,百万元素的集合能卡秒级(BigKey 判定见数据类型与底层编码)。UNLINK 先把 value 引用摘掉、释放交给 bio 后台线程。lazyfree-lazy-expire yes 让定期删除的释放也异步化——BigKey 高发实例必开。
面试官:内存淘汰触发时主线程在干什么?会抖吗?
会。淘汰是边写入边逐条挤(一次可能连删多条)。大量淘汰时(重启回温、批量导入),释放与 allocator 归还都有成本。表现为主线程延迟毛刺、evicted_keys 飙升。先确认是不是容量规划问题,再谈调参,必要时 lazyfree-lazy-eviction yes 异步化。
面试官:过期 key 会写进 RDB / AOF 吗?
RDB 生成时跳过生成时刻已逻辑过期的 key(不管惰性/定期删没删到)。AOF 重写同样跳过。运行期 key 被过期删除时会补一条 DEL 进 AOF,保证重放后状态一致。即「过期最终表现为一次删除」,持久化细节见持久化-RDB与AOF。
面试官视角小结
- 过期删除:expires 过期字典。
- 惰性(访问时删,省 CPU)+ 定期(每 100 ms 抽 20 个、过期超 25% 再来一轮、有时间上限)组合。
- 灵魂是按密度自适应。
- 从库语义:过期判断权威在主库。从库等 DEL 传播,窗口内读从库可得旧值。敏感读走主或业务带时间戳自判。
- 八种策略:2×4 矩阵(allkeys/volatile × lru/lfu/random/ttl)+ noeviction。volatile-* 无 TTL key 时退化为写命令返回错误,这是最大坑。
- 近似 LRU:lru 字段记时间戳 + 随机采样 + eviction pool 候选池。平摊 O(1) 零额外指针。主动给出「准确性换成本」的定性。
- LFU:16 bit 衰减时间 + 8 bit 莫里斯对数计数器。新 key 初始 5、百万次访问饱和 255、按分钟衰减。有扫描/周期热点用 LFU,否则 LRU 够用。
2026-10 新增(P0 补全)。