2026年7月12日 · 6 分钟阅读

Redis 内存管理:过期删除策略与内存淘汰算法

深入剖析 Redis 的 TTL 存储方式、惰性删除与定期删除的协作机制,以及 8 种内存淘汰策略的原理,包括 LRU 和 LFU 在 Redis 中的工程实现细节。

Redis 是内存数据库,数据存在内存里。但内存是有成本的,所以需要两套机制来控制内存的使用:

  • 过期删除:数据到了 TTL,就该被清除。但”到了”不等于”立即删”,什么时候删是个设计问题。
  • 内存淘汰:内存满了,新数据写不进来怎么办?必须选一些 key 淘汰掉。

TTL 的存储方式

Redis 是一个 key-value 数据库,但并不是所有 key 都有过期时间。因此在 RedisDB 中维护了两个 Dict

  • dict:所有 key-value 的映射。
  • expires:记录了有 TTL 的 key 及其过期时间(毫秒级时间戳)。
dict:     {"name" → "极客时间", "session:1" → {...}, "cache:key" → "value"}
expires:  {"session:1" → 1700000000000, "cache:key" → 1700000100000}

当访问 session:1 时,Redis 先去 expires 中查一下它的过期时间。如果当前时间超过了那个时间戳,就认为 key 过期了。


过期删除策略

惰性删除

每次访问 key 时检查是否过期,过期则删除,再返回 nil。

def lookupKey(db, key):
    if key in db.expires and current_time > db.expires[key]:
        deleteKey(db, key)  # 过期了,删掉
        return None
    return db.dict[key]

优点:消耗 CPU 最少——只删除被访问到的过期 key。 缺点:如果某个过期 key 再也不被访问了,它就一直占着内存,这就是内存泄漏

定期删除(周期删除)

为了解决惰性删除的漏删问题,Redis 还有一个定期删除任务,以周期性抽样的方式主动清理过期 key。

实现方式有两种模式运行:

SLOW 模式

serverCron() 定时任务中执行,频率由 server.hz 控制(默认 10,即每秒 10 次):

  1. 逐步遍历所有 RedisDB。
  2. 每个 DB 遍历 bucket,随机抽取 20 个 key 判断是否过期。
  3. 删掉过期的。
  4. 如果超过 25% 的 key 过期(即抽样中过期比例 > 10%),则重复步骤 2-3。
  5. 总耗时不超过执行周期的 25%(默认 100ms 的周期,即 ≤ 25ms)。

FAST 模式

beforesleep() 事件循环前执行:

  1. 两次 FAST 模式间隔不低于 2ms。
  2. 同样是随机抽样 20 个 key。
  3. 总耗时不超过 1ms。
  4. 如果过期 key 比例小于 10% 则不执行。

两种模式协作:SLOW 保证有兜底,FAST 尽量在请求到来前清理。

总结

策略时机优点缺点
惰性删除访问 key 时CPU 开销小过期 key 可能一直残留
定期删除(SLOW)每 100ms主动清理,兜底占用固定 CPU 预算
定期删除(FAST)事件循环前请求前清理清理不完的留到下次

内存淘汰策略

过期删除解决的是”数据到期了怎么删”,内存淘汰解决的是”内存满了怎么办”。

Redis 在每次执行命令前检查内存使用,如果超过 maxmemory 限制,就触发内存淘汰。

8 种淘汰策略

策略范围算法说明
noeviction不淘汰,内存满时拒绝写入(默认策略)
allkeys-lru所有 keyLRU淘汰最近最少使用的 key
allkeys-lfu所有 keyLFU淘汰访问频率最低的 key
allkeys-random所有 key随机随机淘汰 key
volatile-lru有 TTL 的 keyLRU淘汰设置了过期时间中最久未使用的
volatile-lfu有 TTL 的 keyLFU淘汰设置了过期时间中频率最低的
volatile-random有 TTL 的 key随机从 expires dict 中随机淘汰
volatile-ttl有 TTL 的 keyTTL淘汰剩余 TTL 最小的 key(最快过期)

使用建议

  • 缓存场景推荐 allkeys-lruallkeys-lfu
  • 需要保证某些 key 永不被淘汰时,用 volatile-*,并对不想淘汰的 key 不设 TTL。

LRU 和 LFU 的实现细节

面试常问的就是这两个,但很多人不清楚它们在 Redis 中的具体实现。

LRU(Least Recently Used)

LRU 核心思想:淘汰最长时间未被访问的 key。

Redis 的 LRU 实现:近似 LRU,不是标准的全链表 LRU。

RedisObject 中有一个 24 位的 lru 字段:

struct redisObject {
    unsigned type:4;
    unsigned encoding:4;
    unsigned lru:24;  // LRU: 最后一次访问的时间戳(秒级)
    int refcount;
    void *ptr;
};

淘汰时:

  1. 随机从 maxmemory-samples(默认 5)个 key 中采样。
  2. 淘汰采样中 当前时间 - lru 最大的那个(即最久未访问的)。
  3. 采样数量越大,淘汰结果越接近标准 LRU,但性能消耗也越大。

标准 LRU 需要用链表维护所有 key 的访问顺序,对内存不友好。近似 LRU 用很小的代价就达到了接近 LRU 的效果,在实际 production 中足够用了。

LFU(Least Frequently Used)

LFU 核心思想:淘汰访问频率最低的 key。

LFU 复用同一个 lru 字段(24 位),但拆分成两部分:

  • 高 16 位:上次递减时间(分钟级时间戳)。
  • 低 8 位:逻辑访问次数(counter,最大值 255)。

访问次数计数

LFU 的”访问次数”不是一个简单的计数器,它用了概率递增

每次访问 key 时:
  1. 生成一个 0~1 之间的随机数 R
  2. 计算 P = 1 / (旧次数 × lfu_log_factor + 1)
  3. 如果 R < P,counter + 1(最多 255)

lfu_log_factor 默认是 10。这意味着:

counter 值需要的大致访问次数
0 → 11 次
1 → 210 次
10 → 11100 次
100 → 1011000 次

高频 key 的 counter 会增长得越来越慢,不会被短期爆发式访问挤占长期热 key 的位置。

访问次数衰减

如果 counter 只增不减,冷 key 只要在一段时间内被频繁访问过就无法被淘汰了。所以 LFU 还有衰减机制

  • 每过 lfu-decay-time 分钟(默认 1 分钟),counter 减 1。
  • 若分布式系统内对应的分钟数大于 255,减至 0。

这样,一个曾经很热的 key 经过足够长的冷却时间后,访问频率最终会归零。


最佳实践

  1. maxmemory 不要设到物理内存上限,留出给操作系统、fork 子进程、AOF 缓冲区等。建议 maxmemory 设到机器内存的 50%~70%。

  2. 缓存场景用 allkeys-lru——覆盖最完善,最接近 LRU 语义,且对所有 key 一视同仁。

  3. 有明确的冷热数据区分时用 volatile-lru,给每个 key 设一个合理的 TTL,淘汰只发生在过期 key 内部,不影响持久化 key。

  4. LFU 适合访问模式有明显长尾效应的场景,比如某些 key 长期高频访问(热帖),某些 key 偶尔访问(老帖子)。LRU 倾向于淘汰这些”长期高频但最近没有被访问”的热 key,而 LFU 能更好地保留它们。

  5. 永远不要让 maxmemory-policy 保持默认的 noeviction——生产环境内存满了会直接拒绝写入,导致大面积报错。


总结

  • Redis 通过 dict + expires 双 Dict 分离常规数据和过期时间。
  • 过期删除是惰性 + 定期双策略:惰性保证访问时必删,定期保证没人访问也会删。
  • 内存淘汰有 8 种策略,理解 LRU 和 LFU 的工程实现(近似采样、概率递增、衰减机制)才是面试真正的分水岭。
  • 两者协作的根本目的:让 Redis 在有限内存下保持高性能,不让内存耗尽或异常数据占据过多空间