深入剖析Redis内存淘汰策略:从LRU、LFU到工程实践

本文为资深工程师与架构师设计,旨在深入探讨 Redis 内存淘汰(Eviction)策略的核心机制、性能影响与选型权衡。我们将超越“配置一个参数”的层面,从计算机科学的基本原理(如局部性原理)出发,剖析 Redis 为何采用近似 LRU/LFU 算法,并深入其 C 源码层面的实现细节。最终,我们将结合典型业务场景(如电商大促、社交Feed流),提供一套从简单到复杂的架构演进路径,帮助你在真实工程环境中做出最优决策,避免因缓存策略不当引发的性能雪崩。

现象与问题背景

在绝大多数高并发系统中,Redis 都扮演着性能“生命线”的角色。然而,它基于内存的特性也带来了最直接的约束:物理内存是有限的。当 Redis 的内存使用达到 `maxmemory` 限制时,若不进行处理,任何新的写入操作都将导致服务错误(`OOM command not allowed`),这对于核心业务是灾难性的。内存淘汰策略(Eviction Policy)正是为了应对这一挑战而生。它定义了当内存不足时,Redis 应该“牺牲”哪些数据来为新数据腾出空间。

一个常见的反面教材是:某大型电商平台在大促期间,Redis 缓存命中率突然断崖式下跌,数据库压力剧增,导致前端页面响应时间从 50ms 飙升至 2s 以上。事后复盘发现,其商品详情缓存实例采用了默认的 `noeviction` 策略,当缓存写满后,所有新的热门商品信息都无法写入,而旧的、已不再热门的商品却占据着宝贵的内存。更糟糕的是,另一个团队为了“临时解决”问题,粗暴地将策略改为 `allkeys-random`,虽然写入不再报错,但由于随机淘汰了大量本应保留的热点数据,缓存命中率进一步恶化,最终导致了局部系统的服务不可用。

这个案例暴露了一个核心问题:选择错误的淘汰策略,其破坏力不亚于一次数据库宕机。它不仅影响性能,更直接关系到系统的稳定性和可用性。因此,理解每种策略的底层原理、适用场景和潜在陷阱,是架构师和资深开发者的必备技能。

关键原理拆解

在深入 Redis 的具体实现之前,我们必须回归到计算机科学的基础,理解缓存淘汰算法的理论基石。这有助于我们明白 Redis 为何做出某些“妥协”的设计决策。

  • 局部性原理(Principle of Locality): 这是所有缓存系统设计的理论基础。它包含两个维度:
    • 时间局部性(Temporal Locality): 如果一个数据项被访问,那么在不久的将来它很可能被再次访问。这正是 LRU(Least Recently Used)算法的理论依据。我们淘汰最久未被访问的数据,因为它们“未来被访问”的概率最低。
    • 空间局部性(Spatial Locality): 如果一个数据项被访问,那么与它地址相邻的数据项也很可能在不久的将来被访问。这个原理在 CPU Cache Line 和磁盘预读中应用更广,在 Redis 的 Key-Value 模型中不直接体现,但理解它有助于形成完整的缓存知识体系。
  • 理想的 LRU/LFU 算法实现:
    • LRU (Least Recently Used): 理论上,最完美的 LRU 实现需要一个哈希表(HashMap)和一个双向链表(Doubly Linked List)。哈希表用于 O(1) 时间复杂度的快速查找,双向链表用于 O(1) 时间复杂度的节点移动。当一个元素被访问时,我们将其从链表中的当前位置移动到表头。当需要淘汰时,我们直接移除链表尾部的元素。这个组合拳确保了所有操作都是高效的。
    • LFU (Least Frequently Used): 理论上,LFU 旨在淘汰访问频率最低的数据。一个经典的实现是为每个频率维护一个双向链表,并使用一个哈希表来存储 Key 到节点及其频率的映射。当一个 Key 被访问,其频率增加,并从旧频率的链表移动到新频率的链表头部。这种实现的复杂度较高,尤其是在频率动态变化时,维护成本很大。

那么问题来了,既然理论上有完美的实现,为什么 Redis 不直接使用?这是典型的学术理论与工程现实的碰撞。对于 Redis 这样的高性能系统,每一个操作的微小开销都至关重要。如果为每个 Key 的每次访问都附加链表操作,即使是 O(1),在高并发下也会累积成显著的 CPU 开销。更致命的是,在每个 Redis Object 中增加两个指针(用于双向链表)会带来巨大的内存开销。假设有 1 亿个 Key,每个指针 8 字节,仅指针就会额外消耗 1.6 GB 内存。这是 Redis 无法接受的,因此它走上了一条“近似”实现的道路。

系统架构总览

在 Redis 内部,内存淘汰并非一个孤立的模块,它与核心数据结构、事件循环和配置管理紧密相连。我们可以将 Redis 的内存管理和淘汰机制理解为一个闭环系统:

  1. 客户端命令入口:当一个写入命令(如 `SET`, `HSET`)到达时,Redis 首先会检查内存使用情况。
  2. 内存上限检查 (`maxmemory`):在执行命令前,Redis 会调用 `freeMemoryIfNeeded()` 函数。该函数检查当前已用内存是否超过 `maxmemory` 阈值。
  3. 触发淘汰流程:如果超过阈值,淘汰流程启动。此时,Redis 会根据配置的 `maxmemory-policy` 选择一种策略。
  4. 策略执行(以近似 LRU 为例)
    • 采样:Redis 不会遍历所有 Key(这会阻塞服务器)。而是随机抽取 `maxmemory-samples` 个 Key(默认为 5)放入一个候选池。
    • 评估与选择:遍历候选池,根据策略(LRU 或 LFU)找到“最差”的那个 Key。对于 LRU,就是空闲时间最长的 Key;对于 LFU,就是访问频率最低的 Key。
    • 执行淘汰:删除被选中的 Key,释放其占用的内存。
    • 循环检查:再次检查内存是否降到阈值以下。如果仍然超限,重复上述采样、评估、淘汰的循环,直到内存达标或候选池为空。
  5. 执行原始命令:内存空间被成功释放后,原始的写入命令得以执行。如果经过多轮淘汰仍无法释放足够空间,则命令执行失败,返回 OOM 错误。

这个架构的核心思想是 “带采样的懒惰淘汰”。它不在全局维护一个精确的、有序的数据结构,而是通过小规模的、随机的采样来逼近全局最优解。这是一种典型的用精度换取性能和内存效率的工程权衡。

核心模块设计与实现

现在,让我们像个极客一样,深入 Redis 源码,看看这些策略是如何被巧妙实现的。

1. `redisObject` 的复用设计

Redis 的所有数据类型的底层都离不开 `redisObject` 这个结构体。它的一个关键成员 `lru` 是实现 LRU 和 LFU 的基石。这是一个 24 bit (3 字节) 的字段,其含义根据当前服务器的淘汰策略动态变化。


typedef struct redisObject {
    unsigned type:4;
    unsigned encoding:4;
    unsigned lru:24; // 24 bits for LRU/LFU time or frequency
    int refcount;
    void *ptr;
} robj;

极客视角:看到这个 `lru:24` 就应该感到兴奋。它不是一个标准的 32 位或 64 位整数,而是一个比特域(bit-field)。这是 C 语言中一种极致的内存优化技巧,确保结构体不浪费任何一个字节。24 位的设计,本身就是一种权衡:

  • 当作为 LRU 时:它存储的是服务器 `lru_clock` 的低 24 位。`lru_clock` 是一个以秒为单位(Redis 4.0 以后是毫秒)递增的时钟。通过当前 `lru_clock` 减去对象的 `lru` 值,就可以得到该对象的空闲时间。24 位足够表示约 194 天(秒精度)的空闲时间,对于大多数缓存场景来说绰绰有余。
  • 当作为 LFU 时:这 24 位被进一步拆分。高 16 位用于存储 `ldt` (Last Decrement Time),即上次计数器衰减的时间(以分钟为单位)。低 8 位用于存储 `logc` (Logarithmic Counter),即对数增长的访问频率计数器。

2. 近似 LRU (Approximate LRU) 的实现

当策略为 `allkeys-lru` 或 `volatile-lru` 时,淘汰逻辑的核心是比较对象的空闲时间。伪代码如下:


// Inside freeMemoryIfNeeded() loop
best_key = NULL;
max_idle_time = 0;
candidate_pool = sample_keys(maxmemory_samples);

for key in candidate_pool:
    obj = lookup_key(key);
    idle_time = estimate_object_idle_time(obj->lru);
    if idle_time > max_idle_time:
        max_idle_time = idle_time;
        best_key = key;

if best_key is not NULL:
    db_delete(best_key);

工程坑点:`maxmemory-samples` 的值直接影响 LRU 的近似程度和 CPU 开销。默认值 5 是一个保守的平衡点。如果你的业务场景中,Key 的访问模式非常符合“幂律分布”(即少数 Key 占据绝大多数访问),那么较小的采样值也能获得不错的效果。但如果访问模式更均匀,或者你需要更精确的 LRU 行为,可以适当调大这个值(例如到 10),但这会增加每次淘汰时的 CPU 消耗。你需要通过 `INFO stats` 中的 `evicted_keys` 和 `keyspace_hits`/`keyspace_misses` 指标来监控调整后的效果。

3. LFU (Least Frequently Used) 的实现

LFU 的实现比 LRU 精巧得多,它需要解决两个核心问题:如何有效记录频率,以及如何让“旧热点”数据有机会被淘汰。

对数计数器 (`logc`):如果每次访问都简单地将计数器加 1,那么一个早期的高频访问对象将“永远”无法被淘汰。Redis 采用了一种基于概率的对数增长策略。每次访问时,代码并不直接加 1,而是:


// Inside LFU_Increment()
double r = (double)rand() / RAND_MAX;
double baseval = counter - LFU_INIT_VAL; // LFU_INIT_VAL is 5
if (baseval < 0) baseval = 0;
double p = 1.0 / (baseval * server.lfu_log_factor + 1);
if (r < p) {
    counter++;
}

极客视角:这段代码是 LFU 的灵魂。`lfu_log_factor`(默认为 10)控制着计数器增长的难度。一个 Key 的访问次数越多(`baseval` 越大),概率 `p` 就越小,想让计数器再加 1 就越难。这完美地模拟了对数增长曲线,使得计数器不会无限膨胀,并且能够区分“比较热门”和“超级热门”的 Key。

频率衰减 (`ldt`):为了让旧的热点数据有机会被淘汰,Redis 引入了衰减机制。对象的 `ldt` 记录了上次衰减的时间。当一个对象被访问时,会计算当前时间与 `ldt` 的差值,根据配置的 `lfu-decay-time`(默认为 1 分钟)来决定衰减多少。如果距离上次访问超过 `N * lfu-decay-time` 分钟,计数器就会被减半 `N` 次。这确保了曾经的热点,如果长时间不被访问,其频率会逐渐“冷却”下来。

性能优化与高可用设计

理解了原理与实现,我们才能真正讨论策略选择的权衡,这直接关系到系统的生死。

策略选择的 Trade-off

  • `noeviction`: 默认但危险的策略。适用于 Redis 用作主数据库或存储关键元数据的场景,绝不允许数据丢失。应用程序必须自己处理写失败的异常。如果你的 Redis 只是个缓存,用这个策略等于埋下一颗定时炸弹。
  • `allkeys-lru` vs. `volatile-lru`:
    • `allkeys-lru`: 适用于 Redis 中所有数据都是缓存的场景。这是最纯粹的缓存用法。例如,缓存商品详情、用户信息等。
    • `volatile-lru`: 适用于混合存储的场景。实例中既有需要持久化的数据(如用户购物车、会话信息,没有设置 TTL),也有可以淘汰的缓存数据(设置了 TTL)。此策略只会在设置了 TTL 的 Key 中进行 LRU 淘汰,保护了核心数据。这是非常重要的隔离思想
  • `allkeys-lfu` vs. `volatile-lfu` (Redis 4.0+):
    • LFU 解决了 LRU 的一个经典痛点:缓存污染。当一次批量操作(如全表扫描、数据预热)加载了大量冷数据时,这些数据会因为“最近被访问”而挤出真正的热点数据。LFU 因为关注的是长期频率,能更好地抵抗这种短期访问波动。
    • LFU 的弱点:对于新加入的热点数据不友好。一个新商品上线,即使立刻成为爆款,其初始频率也很低,容易被旧的高频数据淘汰。Redis 的对数增长和衰减机制部分缓解了这个问题,但风险依然存在。
  • `random` 策略 (`allkeys-random`, `volatile-random`): 几乎永不使用。它的 CPU 开销最低,因为不需要元数据维护和排序。但在大多数遵循二八定律的业务场景中,它的缓存命中率会惨不忍睹。仅在数据访问模式完全随机(极罕见)时才有一丝理论价值。
  • `volatile-ttl`: 在设置了过期时间的 Key 中,选择剩余 TTL 最短的进行淘汰。适用于那些你关心数据“新鲜度”超过关心其“热度”的场景。

高可用与监控

淘汰策略的选择不是一劳永逸的。你需要建立配套的监控和运维体系。

  • 核心监控指标:
    • `evicted_keys`: 淘汰的 Key 数量。持续高速增长意味着内存压力巨大,可能需要扩容或优化数据结构。
    • `keyspace_hits` & `keyspace_misses`: 命中的关键指标。命中率(`hits / (hits + misses)`)的突然下降,往往是淘汰策略不匹配的直接信号。
    • `used_memory` 和 `maxmemory`: 监控内存使用率,确保其在一个健康的范围内波动,而不是持续顶满。
    • `latest_fork_usec`: 如果你开启了 RDB 或 AOF rewrite,fork 操作会消耗大量内存。淘汰策略需要为 fork 预留足够的内存空间,否则可能导致 fork 失败或系统 OOM。
  • 主从架构下的影响: 在主从复制中,淘汰行为发生在主库。主库淘汰一个 Key 后,会向所有从库发送一个 `DEL` 命令。这意味着主库的内存压力会同步到整个集群。如果主库因为错误的策略频繁淘汰,会产生大量的 `DEL` 命令,增加复制的延迟和网络开销。

架构演进与落地路径

一个成熟的系统,其缓存策略往往是伴随业务发展不断演进的。

第一阶段:启动与探索 (单一业务,简单缓存)

系统初期,流量不大,Redis 主要用于缓存数据库的热点查询。此时,最简单有效的策略是 `allkeys-lru`。它符合大多数场景的直觉,配置简单,效果立竿见影。团队需要做的,是合理评估 `maxmemory`,并开始建立基础的命中率和内存监控。

第二阶段:业务扩展与隔离 (混合存储)

随着业务复杂化,同一个 Redis 实例可能开始存储多种类型的数据,例如,既有商品信息(可淘汰),又有进行中的订单摘要(不可淘汰)。此时,必须从 `allkeys-*` 切换到 `volatile-*` 策略,例如 `volatile-lru`。同时,所有可缓存数据在写入时必须设置合理的 TTL。这是架构上的一次重要升级,实现了数据重要性的分层,避免了核心业务数据被当作普通缓存淘汰掉。

第三阶段:性能精细化调优 (应对复杂访问模式)

系统进入高并发阶段,可能会遇到 LRU 的瓶颈。例如,风控系统需要对用户行为特征进行缓存,某些特征(如“是否是羊毛党”)访问频率极高,而某些临时活动带来的大量用户画像数据则可能污染缓存。此时,是引入 LFU (`volatile-lfu`) 的最佳时机。它能更好地保护那些长期、稳定高频访问的核心数据。这个阶段需要进行 A/B 测试,通过对比命中率、P99 延迟等指标,验证 LFU 是否带来了实际收益。

第四阶段:分布式与专业化 (多实例/集群)

当单一实例的职责过于臃肿,或者不同类型数据的淘汰需求差异巨大时,最终的演进方向是按业务或数据特性拆分 Redis 实例/集群。

  • 缓存集群:专门用于热点数据缓存,可以采用 `allkeys-lfu` 策略,追求极致的命中率。
  • 会话/状态集群:存储用户登录态、分布式锁等,必须采用 `noeviction` 或 `volatile-ttl`,保证数据的绝对可靠。
  • 计数器/排行榜集群:这类数据更新频繁,但可能不需要长期存储,`volatile-lru` 或 `volatile-ttl` 可能是合适的选择。

通过物理隔离,每种业务场景都可以选择最适合自己的淘汰策略,互不干扰。这虽然增加了运维成本,但换来的是整个系统更高的性能、稳定性和可扩展性。这标志着团队对 Redis 的使用从“一个工具”上升到了“构建分布式系统的一个组件”的层次。

延伸阅读与相关资源

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