Skip to content

过期删除与内存淘汰 ​

看在前面 ​

一个 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全部 keyLFU 频率淘汰周期性热点、抗偶发扫描
volatile-lfu带 TTLLFU 频率淘汰同上,保护无 TTL key
allkeys-random全部 key随机淘汰几乎不用
volatile-random带 TTL随机淘汰几乎不用
volatile-ttl带 TTL剩余寿命最短先淘汰逼近「先到期的先走」

陷阱先行:volatile-* 系列在没有一个 key 带 TTL 时,行为退化成 noeviction(写命令返回错误)。上线后要复核「所有可淘汰的 key 是否都有 TTL」。

近似 LRU:随机采样 + 候选池,不是全局链表 ​

面试官:Redis 的 LRU 是真 LRU 吗?为什么不做真的?

不是真的。真 LRU 需要全局双向链表维护访问次序:每 key 两个额外指针、每次访问都要摘节点挂链头。内存和吞吐都太贵。Redis 的近似实现:

  1. 每个对象的 lru 字段(24bit)记录最近访问时间戳(秒级精度,约 194 天回绕);
  2. 淘汰时随机采样 maxmemory-samples 个 key(默认 5,调 10 更准),淘汰其中空闲最久的;
  3. 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。

面试官视角小结 ​

  1. 过期删除:expires 过期字典。
    • 惰性(访问时删,省 CPU)+ 定期(每 100 ms 抽 20 个、过期超 25% 再来一轮、有时间上限)组合。
    • 灵魂是按密度自适应。
  2. 从库语义:过期判断权威在主库。从库等 DEL 传播,窗口内读从库可得旧值。敏感读走主或业务带时间戳自判。
  3. 八种策略:2×4 矩阵(allkeys/volatile × lru/lfu/random/ttl)+ noeviction。volatile-* 无 TTL key 时退化为写命令返回错误,这是最大坑。
  4. 近似 LRU:lru 字段记时间戳 + 随机采样 + eviction pool 候选池。平摊 O(1) 零额外指针。主动给出「准确性换成本」的定性。
  5. LFU:16 bit 衰减时间 + 8 bit 莫里斯对数计数器。新 key 初始 5、百万次访问饱和 255、按分钟衰减。有扫描/周期热点用 LFU,否则 LRU 够用。

2026-10 新增(P0 补全)。