从底层硬件到高并发架构:全景解析 Cache 替换算法与 Redis 近似 LRU 策略

在计算机科学中,无论是底层的 CPU 硬件,还是上层的分布式软件,都遵循着一个无法打破的物理规律:访问速度越快的存储介质,其单位成本就越高,容量也就越小。

为了填补 CPU 极速运算与主存(内存)慢速读取之间的鸿沟,硬件工程师引入了 CPU Cache(高速缓冲存储器);同样,为了填补应用服务与关系型数据库(如 MySQL)之间的鸿沟,软件工程师引入了 Redis。

既然 Cache 和 Redis 的容量都是极其有限的,当缓冲层被打满时,"到底该把哪块旧数据踢出去,给新数据腾位置?" 就成了一个核心命题。本文将从硬件 Cache 的经典替换算法出发,一步步带你探究软件层面的 Redis 是如何实现海量数据淘汰的。

第一部分:理论基石 ------ 计算机底层 Cache 的四大替换算法

在 CPU Cache 的硬件设计中,工程师们面临的场景是:主存中的一个块(Block)需要调入 Cache,但 Cache 已经没有空闲行(Line)了。为此,学术界和工程界总结出了四种经典的替换策略。

1. 随机淘汰算法 (Random)

  • 运行逻辑: 顾名思义,当 Cache 满了需要替换时,硬件底层的随机数发生器会随便挑一个 Cache 块踢出去。

  • 特性分析: 这种算法实现极其简单,完全不需要记录额外的状态信息,硬件开销几乎为零。但它的缺点也很致命:它完全无视了程序的运行规律,很有可能把 CPU 下一秒就要用的高频数据给替换掉,导致 Cache 命中率极不稳定。

2. 先进先出算法 (FIFO - First In First Out)

  • 运行逻辑: 按照数据进入 Cache 的先后顺序来决定去留。谁在 Cache 里待的时间最长,谁就被优先淘汰。

  • 特性分析: 硬件上只需要维护一个简单的循环队列指针即可。但它同样不符合实际程序的运行规律。有些基础变量(如全局配置)虽然很早就被加载进了 Cache,但在整个程序运行期间会被高频访问。FIFO 算法会无情地将它们踢出,导致后续频繁的 Cache Miss。

3. 最不经常使用算法 (LFU - Least Frequently Used)

  • 运行逻辑: 关注数据的访问频次。系统为每个 Cache 块维护一个访问计数器,每次被访问时计数器加 1。淘汰时,寻找计数器数值最小的块。

  • 特性分析: LFU 看起来非常合理,但在真实的程序运行中容易产生"缓存污染"。比如某段初始化代码在程序刚启动时被极其疯狂地循环访问了上万次,但之后再也不会用到了。由于它的历史计数极高,LFU 会让它长时间霸占宝贵的 Cache 空间,而真正需要的新数据反而被排挤。

4. 近期最少使用算法 (LRU - Least Recently Used) 【最优解】

  • 运行逻辑: 关注数据的访问时效。淘汰那个在最近一段时间内,最久没有被访问过的块。

  • 理论支撑:局部性原理 (Principle of Locality)。 程序的运行通常具有"时间局部性",即如果一个数据刚刚被访问过,那么它在未来极短的时间内被再次访问的概率非常高。 LRU 完美契合了这一自然规律,因此它是大多数情况下的效果最好的算法。

  • 硬件级实现原理: 为了在硬件层面实现 LRU,通常会给 Cache 中的每个块配备一个计数器

    • 当主存中的一个新块被载入 Cache 时,其计数器置为 0

    • 当发生 Cache 命中 时,被命中块的计数器清零,同时将 Cache 中其他所有块的计数器加 1(确保能维持一个相对的老化顺序)。

    • 当发生 Cache 未命中且空间已满时,硬件直接扫描所有的计数器,找出数值最大的那个块(也就是最久未被访问的块),将其淘汰。

第二部分:工程演进 ------ Redis 的淘汰策略与近似 LRU 的妥协

理解了底层的理论,我们将视角拉升到软件架构。当 Redis 的内存使用量达到了 maxmemory 设定的上限时,它也面临着完全相同的抉择。

Redis 的内部实现了多种淘汰策略,这些策略实际上是对底层 Cache 算法的致敬与延伸:

  • 对应 Random 算法:allkeys-randomvolatile-random

  • 对应 FIFO 思想(时间维度):volatile-ttl(淘汰剩余存活时间最短的)。

  • 对应 LFU 算法:allkeys-lfuvolatile-lfu(Redis 4.0 引入)。

  • 对应 LRU 算法:allkeys-lruvolatile-lru。这是生产环境中最普遍、最核心的选型。

严峻的现实:为什么 Redis 放弃了严格的 LRU?

在数据结构理论中,实现严格的 LRU 通常需要借助 Hash 表 + 双向链表。每次访问一个数据,都需要将该节点从链表中摘下,并移动到链表头部;淘汰时,直接删除链表尾部的节点。

但在 Redis 这种支撑千万级 QPS 的海量内存数据库中,严格 LRU 撞上了两道无法逾越的物理高墙:

  1. 庞大的内存开销: 维护双向链表意味着每个 Key 都要额外保存 prevnext 两个指针。在 64 位系统中,仅仅这两个指针就会额外消耗 16 字节。面对数千万个 Key,这会造成极其恐怖的内存浪费。

  2. 严重的并发性能拖累: Redis 核心逻辑是单线程的。如果有每秒十万次的并发读请求,严格 LRU 要求这十万次读操作 都要伴随链表节点的写操作(移动位置)。这会产生极高的计算开销,直接拖垮 Redis 的整体吞吐量。

工程学的智慧:Redis 的近似 LRU (Approximated LRU)

面对理论与现实的冲突,Redis 的作者 Antirez 做出了一个极其精妙的妥协:通过随机抽样,实现"近似 LRU"。

既然不能维护全局的链表,Redis 选择在每个 Key 的对象头(redisObject)中,硬挤出 24 bit 的空间,专门记录这个 Key 最后一次被访问的时间戳(秒级)。每次读取 Key 时,极低成本地更新一下这个时间戳即可。

当触发内存淘汰时,Redis 的具体运作逻辑如下:

  1. 随机采样: Redis 根据配置文件中的 maxmemory-samples(默认值为 5),从庞大的数据库中随机抽出 5 个 Key。

  2. 局部比较: 读取这 5 个 Key 头部的 lru 时间戳,找出这 5 个样本中最旧的那个。

  3. 执行淘汰: 将样本中最旧的这个 Key 踢出内存。如果内存依然不够,就再抽 5 个,再踢 1 个,循环往复。

妥协的价值

你可能会问:"随机抽 5 个里面的最旧值,并不代表它是整个数据库里最旧的啊!"

没错,这正是工程学的魅力所在。根据 Redis 官方的测试曲线:当采样数设置为 5 时,其淘汰数据的准确率已经非常接近严格 LRU;当设置为 10 时,几乎与严格 LRU 完美重合。 Redis 用微乎其微的局部误差,换取了 100% 的链表内存节省,并且彻底去除了高频移动链表节点带来的 CPU 损耗。

结语

从 CPU 内部为了几十 KB 空间精打细算的硬件计数器,到 Redis 为了千万级吞吐量挥刀斩链表的近似抽样算法。 跨越软硬件的边界,我们看到的是统一的计算机科学哲学:在资源匮乏的缓冲层,LRU 和局部性原理始终是数据的最高法则;而在真实的工业落地中,基于概率与采样的"工程妥协",往往比死板的严谨更具智慧。