Redis 为什么把 LRU 和 LFU 结合在一起做近似淘汰

如果你在面试中被问到 Redis 的缓存淘汰策略,大概率会听到这样的回答:“Redis 支持 LRU 和 LFU,可以通过 maxmemory-policy 配置。”但真正拉开差距的追问往往是:为什么 Redis 不直接用标准的 LRU 或 LFU,而是搞了一套“近似 LRU”和“近似 LFU”,甚至把两者的思想揉在一起?

这个问题看似在问淘汰算法,实际上考的是你对 Redis 内存模型、性能取舍和工程实现的理解。本文从标准 LRU/LFU 的局限讲起,逐步拆解 Redis 的设计动机。

一、先厘清:Redis 到底提供了哪些淘汰策略

maxmemory 被设置后,Redis 提供了 8 种淘汰策略,其中和 LRU/LFU 直接相关的有:

  • allkeys-lru:在所有 key 中淘汰最近最少使用的
  • volatile-lru:在设置了过期时间的 key 中淘汰最近最少使用的
  • allkeys-lfu:在所有 key 中淘汰最不经常使用的
  • volatile-lfu:在设置了过期时间的 key 中淘汰最不经常使用的

注意措辞:Redis 官方文档里用的是 approximated LRUapproximated LFU,也就是“近似”。这不是谦虚,而是事实——Redis 根本没有维护一个完整的 LRU 链表或 LFU 计数器表。

二、标准 LRU 的问题:内存与指针开销

标准 LRU 的经典实现是“哈希表 + 双向链表”:哈希表保证 O(1) 查找,双向链表保证 O(1) 移动和删除。每次访问一个 key,就把它移到链表头部;淘汰时从尾部删除。

听起来很完美,但放到 Redis 里就有两个致命问题:

  1. 每个 key 需要额外的两个指针。Redis 的每个对象本身已经带有 redisObject 结构,如果再挂一个双向链表指针,在几千万甚至上亿 key 的场景下,内存开销非常可观。Redis 是内存数据库,内存就是成本。
  2. 每次访问都要修改链表。这看似是 O(1),但在高并发下,对共享链表的修改需要加锁或使用无锁结构,会带来竞争和 CPU 缓存不友好的问题。Redis 单线程模型下,频繁的指针操作也会挤占本已紧张的执行时间。

所以 Redis 选择了“采样近似”:不维护全局链表,而是给每个对象记录一个最后一次访问的时间戳lru 字段,24 bit),淘汰时随机采样若干 key,从中挑出最久未使用的那个。

这就是“近似 LRU”的本质——用随机采样 + 时间戳比较替代精确链表。

三、近似 LRU 的缺陷:只看“最近”,不看“频率”

近似 LRU 解决了内存和性能问题,但它继承甚至放大了 LRU 本身的缺陷:LRU 对“偶发批量访问”非常脆弱

举个例子:一个 key 平时被访问得很频繁,是典型的热点数据。某天来了一次全量扫描或批量任务,大量冷 key 被顺序访问了一遍。在 LRU 视角下,这些冷 key 的“最近访问时间”变得很新,而真正的热点 key 反而显得“很久没被访问”。如果此时触发淘汰,热点 key 可能被误杀。

这就是经典的 LRU 缓存污染问题。而 LFU(Least Frequently Used)按访问频率淘汰,天然对这种场景更鲁棒——偶尔被访问一次的冷 key,频率计数很低,不会因为“刚被访问过”就获得高优先级。

但标准 LFU 也有自己的问题:

  • 需要为每个 key 维护一个计数器,内存开销更大;
  • 频率是只增不减的,一个曾经的热点 key 即使后来不再被访问,也会因为历史高频率而长期占坑,这就是“频率僵化”;
  • 新 key 天然处于劣势,容易饿死。

四、Redis 的解法:LFU 是对 LRU 字段的“复用与改造”

Redis 4.0 引入 LFU 时,做了一个非常巧妙的设计:它没有新增字段,而是复用了原来 LRU 的 24 bit lru 字段

这 24 bit 被拆成两部分:

  • 高 16 bit:访问时间(ldt,last decrement time),记录上一次频率衰减的时间;
  • 低 8 bit:访问频率计数器(logc,logistic counter)。

频率计数器不是简单加一,而是采用概率递增:计数器越大,继续增加的概率越低,上限为 255。这样既能区分冷热,又不会让热点 key 的计数无限膨胀。

更关键的是衰减机制:LFU 会按时间对计数器做衰减。如果一个 key 长时间不被访问,它的频率计数会逐渐降低。这直接解决了标准 LFU 的“频率僵化”问题——曾经的热点如果不再被访问,会慢慢让出位置。

淘汰时,Redis 同样采用随机采样:从待淘汰集合中采样若干 key,比较它们的频率计数(必要时再比较访问时间),淘汰频率最低的。

五、为什么说“结合在一起”:LRU 与 LFU 在 Redis 中的统一

到这里,答案已经比较清晰了。Redis 并不是简单地在 LRU 和 LFU 之间二选一,而是:

  1. 共用同一套近似淘汰框架:随机采样 + 24 bit 元数据字段 + 候选池比较。
  2. LRU 模式:24 bit 全部用作时间戳,比较谁最久未访问。
  3. LFU 模式:24 bit 拆成时间 + 频率,比较谁频率最低,频率还会随时间衰减。
  4. LFU 内部仍然保留时间维度:当频率相同时,用时间戳做次级比较,避免平局时无法决策。

换句话说,Redis 的 LFU 并不是纯粹的“频率优先”,而是频率为主、时间为辅,并且在频率计数中引入了时间衰减。这本质上是一种 LRU 与 LFU 思想的融合:用 LFU 抵抗缓存污染,用时间衰减和次级时间比较避免 LFU 的僵化,用近似采样控制内存和 CPU 开销。

六、面试怎么答:三层递进

如果面试官问“Redis 为什么把 LRU 和 LFU 结合”,可以按三层来回答:

第一层,标准算法不可用:标准 LRU 需要双向链表,每个 key 多两个指针,内存开销大;每次访问改链表,并发和性能成本高。所以 Redis 用随机采样 + 时间戳做近似 LRU。

第二层,近似 LRU 不够用:LRU 只看最近访问时间,批量扫描会污染缓存,误杀热点 key。LFU 按频率淘汰更抗污染,但标准 LFU 有计数器内存开销和频率僵化问题。

第三层,Redis 的融合设计:复用 24 bit 字段,LFU 模式下拆成 16 bit 时间 + 8 bit 对数频率;频率概率递增、随时间衰减;淘汰时随机采样,先比频率、再比时间。既保留了近似框架的低开销,又结合了 LRU 的时间敏感和 LFU 的频率敏感。

七、小结

Redis 选择“近似 + 融合”的淘汰策略,根本原因在于它是一个内存敏感、单线程、高吞吐的系统。精确 LRU 的内存和指针开销不可接受,纯 LRU 又抵抗不了缓存污染,纯 LFU 则有僵化和饿死问题。把两者结合,用同一套 24 bit 元数据和采样框架承载两种语义,是在工程约束下做出的最优折中。

理解这一点,比背下 8 种淘汰策略的名字更有价值——它体现的是 Redis 一贯的设计哲学:在理论最优和工程可行之间,永远选择后者,但把后者的效果做到足够接近前者。

未经允许不得转载:任鹏个人博客 » Redis 为什么把 LRU 和 LFU 结合在一起做近似淘汰

赞 (0) 打赏

评论 0

取消
  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

微信扫一扫打赏