Redis内存淘汰策略的原理、陷阱与选型哲学

当 Redis 的内存使用触及 `maxmemory` 上限时,系统并不会简单地崩溃,而是启动了一套精密的“自保”机制——内存淘汰(Eviction)。选择何种淘汰策略,远非修改一个配置项那么简单。它是一项深刻的架构决策,直接影响系统吞吐、响应延迟,甚至在极端情况下决定业务的成败。本文将深入剖乙析 Redis 内存淘汰的底层原理,剖析 LRU 与 LFU 在计算机科学中的理论基础,直面其在 Redis 中的工程实现与妥协,并最终给出一套从监控、选型到架构演进的实战方法论。

现象与问题背景

任何一个将 Redis 作为核心缓存的系统,都必然会遇到这个日志:OOM command not allowed when used memory > 'maxmemory'。这是 Redis 在 `noeviction` 策略下发出的明确拒绝服务的信号。初级工程师的反应通常是“加内存”,但作为架构师,我们必须认识到,内存资源永远是有限且昂贵的。缓存的本质,就是用有限的高速存储(内存)来缓存无限的低速存储(磁盘)中的热点数据。因此,当缓存空间被占满时,我们必须回答一个核心问题:应该丢弃哪些数据,来为即将到来的新数据腾出空间?

这个问题在不同业务场景下有截然不同的答案。在一个高并发的电商秒杀场景中,缓存里充满了商品详情、库存信息和用户信息。此时,一个后台批处理任务,如对账或用户画像分析,突然对大量冷数据进行了一次性读取。如果我们的淘汰策略不当,可能会导致真正的热点商品数据被这些“一次性”的冷数据“污染”并淘汰出缓存,造成缓存穿透,瞬间将压力传导至后端数据库,引发系统雪崩。这正是经典的“缓存污染”问题。选择正确的淘汰策略,就是构建缓存系统第一道,也是最重要的一道防线。

关键原理拆解

在深入 Redis 的实现之前,我们必须回归到计算机科学的本源,理解缓存淘汰算法的两个基石:LRU 和 LFU。它们的设计哲学,源于对程序“局部性原理”(Principle of Locality)的深刻洞察。

  • 时间局部性 (Temporal Locality): 如果一个数据项被访问,那么在不久的将来,它很可能被再次访问。这是 LRU 算法的理论基础。
  • 空间局部性 (Spatial Locality): 如果一个数据项被访问,那么与它相邻的数据项也很可能被访问。这在 CPU Cache line 设计中体现得更明显,但在应用层缓存中,时间局部性是更核心的考量。

LRU (Least Recently Used – 最近最少使用)

LRU 的思想如其名:当需要淘汰数据时,优先淘汰最久没有被访问过的数据。它完美地诠释了时间局部性原理——既然一个数据很久没被访问了,那么我们有理由相信它在未来被访问的概率也很低。

在学术上,一个完美的 LRU 实现通常需要一个哈希表和一个双向链表。

  • 哈希表: 用于实现 O(1) 复杂度的快速查找。Key 指向链表中的节点。
  • 双向链表: 用于维护所有数据的访问顺序。当一个数据被访问时,将其对应的链表节点移动到链表头部。当需要淘汰数据时,直接从链表尾部移除节点即可。

这个组合使得数据访问(命中)和淘汰(未命中需插入新数据)的操作时间复杂度都为 O(1)。操作系统中的虚拟内存页面替换算法,很多都是 LRU 的变种。但它的致命弱点在于,对“偶发性”的批量访问毫无抵抗力,正如前文提到的“缓存污染”场景。一次全表扫描,就能将缓存中所有宝贵的热点数据全部置换出去。

LFU (Least Frequently Used – 最不经常使用)

LFU 则从另一个维度来衡量数据的重要性:访问频率。它的核心思想是,如果一个数据在过去一段时间被访问了很多次,那么它就是“热点”,未来被访问的概率也更高。因此,当需要淘汰数据时,LFU 会选择淘汰被访问次数最少的数据。

一个朴素的 LFU 实现可能需要一个哈希表来存储数据,并用一个最小堆(Min-Heap)来维护所有数据的访问频率。每次访问,数据频率加一,并调整其在堆中的位置(sift-up/sift-down),时间复杂度为 O(log N)。淘汰数据时,直接从堆顶取出频率最低的元素,时间复杂度为 O(log N)。

LFU 能很好地抵抗偶发的批量访问。对于一次性的全表扫描,每个数据只被访问一次,它们的频率都很低,不会轻易淘汰掉那些被持续高频访问的真正热点。但 LFU 也有其固有的问题:

  • 频率累积问题: 一个曾经的热点数据,即使后来不再被访问,它的高频率计数也会使其在缓存中“赖着不走”,导致“老赖数据”问题。
  • 新数据不友好: 一个新加入的数据,其初始频率非常低,即使它即将成为热点,也可能在频率累积起来之前就被淘汰掉。

为了解决这些问题,现代 LFU 实现通常会引入“频率衰减”机制,定期为所有数据的频率打个折扣,以此来降低历史访问权重,让算法更能反应近期的热度。

Redis 的实现之道

理解了理论模型后,我们再来看 Redis 的实现,就会发现它充满了极客式的工程智慧与妥协。Redis 是一个对内存和性能极致压榨的系统,它绝不会为了一个完美的 LRU/LFU 算法而引入过高的内存开销(如每个 Key 增加两个指针用于双向链表)或复杂的计算(如堆操作)。

近似 LRU (Approximate LRU)

Redis 并没有实现一个真正的 LRU。它采用了一种巧妙的近似算法。在 Redis 的核心数据结构 `redisObject` 中,有一个 24-bit 的字段 `lru`。这个字段记录了该对象最后一次被访问时,Redis 服务器的全局时钟 `server.lruclock` 的值。

当需要进行淘汰时,Redis 的具体做法是:

  1. 随机挑选 `maxmemory-samples` 个 key(默认是 5)。
  2. 遍历这批样本 key,找到其中 `lru` 字段值最小的那个(也就是最久未被访问的)。
  3. 将这个最“老”的 key 淘汰掉。

这是一种基于采样的概率性算法。`maxmemory-samples` 的值越大,淘汰结果就越接近真实的 LRU,但 CPU 消耗也越高。实践证明,即使 `samples` 值为 5 或 10,其效果已经非常接近理论上的 LRU。这种设计,用极小的 CPU 开销和零额外的内存指针开销,换取了一个足够好的 LRU 近似效果,是典型的工程权衡。

LFU 的精巧实现

Redis 4.0 引入的 LFU 实现更是将“物尽其用”发挥到了极致。它复用了 `redisObject` 的 24-bit `lru` 字段,将其一分为二:

  • 高 16 位 (ldt – Last Decrement Time): 存储一个分钟级别的时间戳,用于实现频率的衰减。
  • 低 8 位 (counter): 存储访问频率的计数值。

对数增长的 Counter: 仅仅 8 位,最大只能表示 255,如何记录成千上万次的访问?Redis 采用了一个对数增长策略。每次访问时,并不是简单地 `counter++`。它会根据配置的 `lfu-log-factor` 参数,计算一个概率 P,只有当一个随机数小于 P 时,counter 才会增加。`counter` 的值越大,P 就越小,增加就越困难。这使得有限的 8 位空间能够表达一个非常宽泛的访问热度范围。

基于时间的衰减: 为了解决“老赖数据”问题,Redis LFU 引入了衰减机制。当一个 key 被访问时,它会检查距离上次衰减(记录在 ldt 字段中)过去了多久。根据配置的 `lfu-decay-time`(分钟),计算出应该衰减多少计数值,然后对 `counter` 进行减法操作。这样,长时间不被访问的数据,即使曾经频率很高,其 `counter` 也会逐渐“冷却”下来,最终被淘汰。

下面是一段伪代码,展示了 LFU 频率更新的核心逻辑,帮助理解其精妙之处:


// redis/src/evict.c LFUUpdateTimestamp() and LFULogIncr()

// 1. 计算衰减值
// now 是当前分钟级时间戳
// ldt 是对象中存储的上一次衰减时间戳
// lfu_decay_time 是配置的衰减周期(分钟)
unsigned long T = now;
unsigned long ldt = o->lru >> 8;
long decay = (lfu_decay_time > 0) ? (T - ldt) / lfu_decay_time : 0;
if (decay > 0) {
    // 对 counter 进行衰减
    o->lru_counter = (o->lru_counter > decay * 2) ? o->lru_counter - decay : o->lru_counter / 2;
}

// 2. 对数增长 counter
// r 是一个 0-1 之间的随机数
// baseval 是 counter 的当前值
// p 是增长概率,计算公式大致为 1 / (baseval * lfu_log_factor + 1)
if (r < p) {
    if (o->lru_counter < 255) {
        o->lru_counter++;
    }
}

// 3. 更新 ldt 时间戳
o->lru = (T << 8) | o->lru_counter;

通过这种设计,Redis LFU 在不增加任何内存开销的前提下,实现了一个带衰减机制的、能够有效对抗缓存污染的、高性能的淘汰策略。这是教科书级别的工程设计。

策略对抗:选型中的 Trade-off

理解了原理和实现后,我们才能真正地、有依据地进行策略选择。每种策略都是一把双刃剑,适用于不同的场景。

  • noeviction:
    • 优点: 保证数据不丢失。对于将 Redis 用作数据库或关键任务队列的场景,这是最安全的选择。
    • 缺点: 内存写满后,所有写操作都会失败,可能导致服务不可用。
    • 适用场景: 数据绝对不能丢的场景。需要配合严格的容量规划和实时监控告警。
  • allkeys-lru / volatile-lru:
    • 优点: 算法简单,CPU 开销小,能够很好地处理符合时间局部性的访问模式。
    • 缺点: 致命弱点是缓存污染。一次非预期的批量读取(如后台任务、爬虫、手动全量刷新)可以瞬间“清洗”掉所有热点数据,导致缓存命中率骤降。
    • 适用场景: 大多数通用缓存场景。如果你的业务访问模式比较稳定,没有明显的、周期性的全量数据扫描,LRU 是一个不错的起点。`volatile-lru` 适用于 Redis 同时作为持久化存储和缓存的混合场景。
  • allkeys-lfu / volatile-lfu:
    • 优点: 能有效抵抗缓存污染。它关注的是访问频率,短期内大量的低频访问不会影响到长期稳定高频访问的热点数据。
    • 缺点: 对于新加入的、即将成为热点的数据不够友好(有被提前淘汰的风险)。对于访问模式剧烈变化的场景(例如新闻feed流,热点每分钟都在变),LFU 的表现可能不如 LRU。需要额外配置 `lfu-log-factor` 和 `lfu-decay-time`,调优有一定成本。
    • 适用场景: 存在明显热点数据,且热点相对稳定,同时又需要防范后台任务等造成的缓存污染。例如,电商系统的商品信息、社交平台的用户资料等。
  • allkeys-random / volatile-random:
    • 优点: 实现最简单,无额外CPU开销。
    • 缺点: 效果最差,完全随机,可能淘汰掉刚刚被访问的热点数据。
    • 适用场景: 对缓存命中率要求不高,或者数据访问模式完全没有规律可循的场景。
  • volatile-ttl:
    • 优点: 优先清理即将过期的数据,最大化利用内存。
    • 缺点: 忽略了数据的访问热度。
    • 适用场景: 缓存对象的 TTL 设置得非常精细,且希望尽快回收过期空间的场景,如验证码、Session 存储。

总结一下 LRU vs LFU 的核心对抗点:

LRU 的命门是“最近”:它只看最后一次访问时间,一次批量访问就能欺骗它。
LFU 的命门是“频率”:它需要时间来累积频率,对新热点反应慢,且依赖衰减机制来遗忘历史。

架构演进与落地路径

作为架构师,我们不能简单地“选一个最好的”,而应制定一个可观测、可演进的落地策略。

第一阶段:基线与监控

对于一个新系统,如果没有明确的数据表明存在缓存污染问题,可以从 `volatile-lru` 或 `allkeys-lru` 开始。这是最通用、最稳妥的起点。但关键在于建立监控。你必须关注以下核心指标:

  • evicted_keys: 每秒被淘汰的 key 的数量。如果这个值持续很高,说明内存压力很大。
  • keyspace_hits / keyspace_misses: 命中和未命中次数。命中率(hits / (hits + misses))是衡量缓存有效性的黄金指标。
  • used_memory: 内存使用情况,结合 `maxmemory` 监控内存水位。
  • 业务指标: 缓存命中率的波动,最终会体现在应用层的响应延迟和后端数据库的负载上。必须将缓存指标与业务指标关联分析。

此外,定期使用 `redis-cli –hotkeys` 来抽样分析当前的热点 key,可以帮助你理解实际的访问模式。

第二阶段:数据驱动的策略切换

当你通过监控发现,缓存命中率存在周期性的、无规律的下跌,并且下跌时间点与某些后台任务、数据同步脚本的执行时间高度吻合时,这大概率就是 LRU 缓存污染。此时,切换到 LFU 的时机就成熟了。

切换到 `allkeys-lfu` 并非一劳永逸,你需要开始调优 `lfu-log-factor` 和 `lfu-decay-time`。一个经验法则是:

  • 如果你的数据访问频率分布非常集中(幂律分布),少数 key 占据了绝大部分访问,可以适当增大 `lfu-log-factor`,让频率增长更慢,更好地区分顶级热点。
  • 如果你的业务热点变化较快,希望系统能更快地“忘记”旧热点,可以适当减小 `lfu-decay-time`。

这需要一个观察、调优、再观察的迭代过程。

第三阶段:多级缓存与冷热分离

当单个 Redis 集群的内存压力极大,无论使用何种策略都无法维持理想的命中率时,就需要从架构层面进行升维打击了。

  • 引入本地缓存 (L1 Cache): 在应用进程内使用 Caffeine、Guava Cache 或自定义的 Map 作为一级缓存,缓存最热的数据。这可以挡住大部分请求,大幅降低对 Redis (L2 Cache) 的访问压力。
  • 冷热数据分离: 不要将所有类型的数据都塞在一个 Redis 集群里。可以根据业务特性,将数据垂直拆分到不同的 Redis 集群。例如:
    • 热点商品集群: 内存配置高,使用 `allkeys-lfu` 策略,确保核心商品信息常驻内存。
    • 用户会话集群: 内存配置适中,使用 `volatile-ttl` 策略,有效管理 Session 生命周期。
    • 通用数据集群: 用于其他各类缓存,使用 `volatile-lru` 作为通用策略。

通过这种方式,我们将笼统的内存管理问题,分解为针对不同数据访问模式的、更精细的治理问题。这才是从根本上解决大规模缓存系统性能与成本问题的最终路径。选择淘汰策略,不仅仅是在 LRU 和 LFU 之间做选择题,更是对整个系统数据访问模式的一次深度审视和架构重构。

延伸阅读与相关资源

  • 想系统性规划股票、期货、外汇或数字币等多资产的交易系统建设,可以参考我们的
    交易系统整体解决方案
  • 如果你正在评估撮合引擎、风控系统、清结算、账户体系等模块的落地方式,可以浏览
    产品与服务
    中关于交易系统搭建与定制开发的介绍。
  • 需要针对现有架构做评估、重构或从零规划,可以通过
    联系我们
    和架构顾问沟通细节,获取定制化的技术方案建议。
滚动至顶部