跳到主要内容

过期与内存淘汰

两个容易混淆的概念:过期删除处理「设了 TTL 且已到期」的键;内存淘汰处理「内存用满了,不管有没有 TTL 都得腾空间」。

过期键怎么删​

Redis 把带 TTL 的键单独放在一个过期字典里(key → 过期时间戳)。删除策略是两种的组合:

1. 惰性删除(被动) —— 访问某个 key 时才检查是否过期,过期了就删并返回 nil。

  • 优点:对 CPU 友好,不做无用检查
  • 缺点:过期了但再也没人访问的 key 会一直占内存

2. 定期删除(主动) —— 默认每秒 10 次(hz 10),每次从过期字典里随机抽取 20 个 key 检查,删掉过期的;如果这批里过期比例超过 25%,立刻再抽一轮,否则结束。单次循环有 25ms 的时间上限,防止阻塞。

  • 这是抽样而非全量扫描,所以不保证及时,只保证过期 key 的比例收敛在 25% 以下

为什么不用「定时删除」(每个 key 设个定时器到点就删)?因为需要大量定时器,CPU 开销与内存开销都不划算。

:::info 主从环境下的过期 从库不会自己删过期 key。从库只在主库发来 DEL 命令时才删,这是为了保证主从一致。但从库读取时会判断逻辑过期,对客户端返回 nil —— 所以不会读到脏数据,只是内存里还留着。 :::

八种内存淘汰策略​

当内存达到 maxmemory 时触发。按「作用范围 × 淘汰依据」组合:

策略范围依据
noeviction(默认)—不淘汰,写入直接报错(读正常)
volatile-lru仅设了 TTL 的 key最久未使用
volatile-lfu仅设了 TTL 的 key使用频率最低(4.0+)
volatile-random仅设了 TTL 的 key随机
volatile-ttl仅设了 TTL 的 key剩余 TTL 最短
allkeys-lru全部 key最久未使用
allkeys-lfu全部 key使用频率最低(4.0+)
allkeys-random全部 key随机

怎么选:

  • 纯缓存场景 → allkeys-lru(最常用)
  • 有明显热点、访问频率差异大 → allkeys-lfu
  • 缓存与持久数据混存(部分 key 不能丢)→ volatile-lru,只淘汰带 TTL 的
  • noeviction 适合「Redis 当存储用、宁可写失败也不丢数据」的场景,但必须配监控告警

LRU 和 LFU 的近似实现​

Redis 的 LRU 不是标准 LRU。标准 LRU 需要维护一个全局链表,每次访问都要移动节点,内存和 CPU 开销都大。Redis 的做法:

  • 每个对象头里存一个 24 位的 lru 时钟(秒级精度)
  • 淘汰时随机采样 maxmemory-samples 个 key(默认 5),淘汰其中最旧的
  • 3.0 起引入候选池:维护一个 16 个 key 的池子,每次采样的结果和池子里的比较,保留更旧的,提升了近似精度

采样数调大更接近真实 LRU,但 CPU 开销上升。默认 5 已经足够接近。

LFU 的实现更精巧,解决 LRU 的一个缺陷:「某个 key 很久以前被疯狂访问过,之后再没人用」在 LRU 下可能长期不被淘汰(如果刚好被扫到过),而「刚被访问一次的冷 key」反而排在前面。

LFU 把 24 位拆成两部分:

  • 16 位 ldt(last decrement time)—— 上次衰减的分钟级时间戳
  • 8 位 logc(logistic counter)—— 访问频率计数,用对数方式递增

对数递增意味着计数越大、再增加的概率越低,8 位就能表示很大的访问量级(由 lfu-log-factor 控制)。同时计数会随时间衰减(由 lfu-decay-time 控制,默认每分钟减 1),这样历史热点会逐渐冷却。

提示

这个「对数递增 + 时间衰减」的设计是 LFU 题的得分点。只答「LFU 按频率淘汰」是不够的,面试官想听的是怎么用 8 位存下频率和怎么避免历史热点永不淘汰。

相关的坑​

不要给所有 key 设相同的 TTL —— 会导致同一时刻大批 key 集中过期,引发缓存雪崩。TTL 上加随机抖动。

EXPIRE 会被某些命令清除 —— 对 key 执行 SET(不带 KEEPTTL)会清掉 TTL 变成永久 key。INCR、LPUSH、HSET 等修改值的命令不影响 TTL。这个差异是线上内存泄漏的常见原因。

PERSIST 可以主动移除 TTL;TTL key 返回 -1 表示无过期时间,-2 表示 key 不存在。