2003 年,IBM 的 Nimrod Megiddo 和 Dharmendra Modha 发表了一篇论文:ARC: A Self-Tuning, Low Overhead Replacement Cache。这篇论文优雅地解决了一个困扰系统设计者三十多年的问题:如何在不知道工作负载特征的情况下,自动在”最近性”和”频率”之间取得平衡?
为了理解 ARC 的精妙,我们需要先理解 LRU 的缺陷。而要理解 LRU 的缺陷,我们需要先理解”最优”是什么——Bélády 在 1966 年就告诉我们了。
这篇文章不是操作系统教科书的复述。我会用数学证明告诉你为什么 LRU 是”stack algorithm”,用真实的 trace 数据告诉你 LRU 在哪些场景下比 FIFO 还差,然后一步步推导出 ARC 为什么能”自我调优”。
一、页面置换问题的精确定义
问题模型
给定: - 物理内存容纳 \(k\) 个页面(页框数) - 一个页面引用序列 \(r_1, r_2, \ldots, r_n\)(来自虚拟地址的页面号) - 初始时物理内存为空
目标:设计一个在线算法(只能看到当前和过去的引用,不能看未来),在每次缺页(page fault,引用的页面不在物理内存中)时,决定淘汰哪个页面,使得总缺页次数最小。
这就是经典的 demand paging 模型。注意几个关键约束:
- 在线性:算法不能偷看未来。这是与 Bélády 最优算法的根本区别。
- 强制缺页(compulsory miss / cold miss):第一次访问任何页面都必然缺页,任何算法都无法避免。
- 容量缺页(capacity miss):工作集大于物理内存时不可避免。
- 冲突缺页(conflict miss):算法选择不当导致的额外缺页——这是我们要最小化的。
为什么这个问题重要
你可能觉得”页面置换”只是操作系统课的考试题。错了。这个抽象模型出现在所有分层存储系统中:
- 数据库缓冲池:缓冲池管理器决定淘汰哪个页面。MySQL InnoDB、PostgreSQL 都有自己的页面置换策略。
- CPU 缓存:L1/L2/L3 缓存的 eviction 策略就是硬件实现的页面置换。
- CDN 缓存:Varnish、Nginx 的缓存淘汰。
- Redis 内存淘汰:当
maxmemory达到上限时。
理解页面置换的本质,就是理解所有缓存系统的核心。
二、Bélády 的最优算法(OPT)
算法描述
1966 年,László Bélády 提出了最优页面置换算法(OPT,也叫 MIN 或 Clairvoyant):
淘汰未来最久不会被引用的页面。
如果有页面将来永远不会被引用,淘汰它;否则淘汰下次引用距当前最远的那个。
为什么是最优的
直觉上很好理解:你把最不着急用的东西扔出去,损失最小。但严格证明需要用交换论证(exchange argument):
定理:OPT 产生的缺页次数不多于任何其他在线算法。
证明思路:假设存在某个算法 A 在某个请求序列上比 OPT 少缺页。取第一个 A 和 OPT 行为不同的时刻 \(t\)。此时 A 淘汰了页面 \(p\),OPT 淘汰了页面 \(q\)(\(q\) 是未来最远引用的页面)。可以证明,将 A 在时刻 \(t\) 的决策替换为淘汰 \(q\),不会增加 A 的总缺页次数(因为 \(q\) 是最晚被引用的,替换它”延迟”了下一次因 \(q\) 不在内存而缺页的时间最长)。逐步将 A 的所有决策替换为 OPT 的决策,缺页次数不增。矛盾。\(\blacksquare\)
OPT 不可实现
OPT 需要知道完整的未来引用序列——这在在线系统中做不到。它的价值在于提供一个理论下界:任何在线算法的缺页次数都不少于 OPT,用它来衡量其他算法的”距离最优”程度。
在评估缓存算法时,我通常会跑两组数据:一组是 OPT(离线计算),一组是待评估算法。两者的缺页率之差就是算法的”优化空间”。
三、FIFO 与 Bélády 异常
FIFO 算法
最简单的在线算法:淘汰最早进入内存的页面。用一个环形队列即可实现。
// FIFO 页面置换
typedef struct {
int *pages; // 环形缓冲区
int capacity;
int head; // 指向最老的页面
int count;
} FIFOCache;
int fifo_access(FIFOCache *cache, int page) {
// 检查是否命中
for (int i = 0; i < cache->count; i++) {
if (cache->pages[(cache->head + i) % cache->capacity] == page)
return 1; // 命中,无需淘汰
}
// 缺页
if (cache->count < cache->capacity) {
cache->pages[(cache->head + cache->count) % cache->capacity] = page;
cache->count++;
} else {
cache->pages[cache->head] = page; // 淘汰最老的
cache->head = (cache->head + 1) % cache->capacity;
}
return 0; // 缺页
}Bélády 异常
FIFO 有一个违反直觉的性质:增加页框数量反而可能增加缺页次数。这叫做 Bélády 异常(Bélády’s Anomaly)。
经典反例——引用序列
1, 2, 3, 4, 1, 2, 5, 1, 2, 3, 4, 5:
- 3 个页框:9 次缺页
- 4 个页框:10 次缺页
更多页框,更多缺页!这说明 FIFO 的”淘汰最老”策略在某些模式下是灾难性的——一个页面可能很老但正在被频繁使用。
个人思考
Bélády 异常揭示了一个深刻的事实:“先来后到”不是缓存优先级的好依据。 页面进入内存的时间与它未来是否会被使用之间没有因果关系。FIFO 隐含地假设”老的不重要”,这在很多工作负载下是错的。
四、LRU:理论上的优美
算法描述
LRU(Least Recently Used)淘汰最近最久未被使用的页面。与 FIFO 的区别是:LRU 考虑最后一次使用的时间,而 FIFO 只考虑进入的时间。
Stack Algorithm 性质
LRU 属于一类叫做 stack algorithm 的算法。设 \(B_t(k)\) 为时刻 \(t\) 时、使用 \(k\) 个页框的内存内容,那么对所有 \(t\) 和 \(k\):
\[ B_t(k) \subseteq B_t(k+1) \]
即:用 \(k\) 个页框时内存中的页面,一定也在用 \(k+1\) 个页框时的内存中。
推论:stack algorithm 不会出现 Bélády 异常。增加页框数只会减少(或不变)缺页次数。
证明:如果算法是 stack algorithm,那么用 \(k+1\) 个页框时,内存是用 \(k\) 个页框时内存的超集。因此 \(k\) 个页框时的命中,在 \(k+1\) 个页框时也一定命中。但 \(k+1\) 个页框时可能还有额外的命中(在第 \(k+1\) 个位置的页面)。所以缺页次数单调不增。\(\blacksquare\)
Stack Distance 分析
LRU 的 stack algorithm 性质带来一个优美的分析工具:stack distance(栈距离)。
对每次页面引用,定义其 stack distance 为该页面在 LRU 栈中的位置(最近使用的在栈顶,位置为 1)。如果页面不在栈中,stack distance 为 \(\infty\)。
- 如果 stack distance \(\leq k\)(页框数),则命中
- 如果 stack distance \(> k\),则缺页
一次模拟就可以得到所有 \(k\) 值下的缺页率曲线——这就是 stack algorithm 的威力。你不需要对每个 \(k\) 分别跑模拟。
精确 LRU 的实现代价
理论上,精确 LRU 需要在每次内存访问时更新时间戳或链表位置。如果用双向链表 + 哈希表:
- 每次访问:从链表中删除并移到头部 — O(1)
- 每次淘汰:移除链表尾部 — O(1)
- 空间:每个页面一个链表节点 + 哈希表条目
在软件缓存(如 Redis、memcached)中,这个开销可以接受——每次 GET 操作顺便更新链表。但在操作系统级别的页面置换中,每次内存访问都更新链表是不可能的。CPU 每秒执行数十亿次内存访问,软件开销无法承受。
这就是为什么没有任何主流操作系统实现精确 LRU。所有系统都用的是近似 LRU。
五、LRU 在实际中的三大问题
理论上优美的 LRU,在实际工作负载中有三个致命问题。
5.1 顺序扫描污染(Sequential Flooding)
考虑一个数据库场景:缓冲池大小为 1000 页。正常运行时,热数据(高频查询的表页面)占据大部分缓存,命中率 95%。
然后有人跑了一次全表扫描——扫了 10000 个页面。
LRU 会怎么做?每个新扫描到的页面都是”最近使用的”,会被放到 LRU 栈顶。1000 个页面扫完后,原来的 1000 页热数据全部被冲走了。全表扫描结束后,后续的正常查询全部缺页,命中率从 95% 跌到 0%。
而这 10000 个扫描页面中,每个只被用了一次,以后永远不会再用。LRU 把这些”用过一次的垃圾”放在了栈顶,把真正的热数据赶走了。
这就是 LRU 无法区分”频率”和”最近性” 的根本缺陷。
5.2 循环访问模式(Loop Pattern)
考虑引用序列为循环
1, 2, 3, ..., k+1, 1, 2, 3, ..., k+1, ...,内存有
\(k\) 个页框。
LRU 的缺页率是 100%!因为工作集大小 \(k+1\) 刚好比内存大 1,每次访问都恰好是 LRU 栈底的那个页面。
而如果使用 FIFO 或随机替换,缺页率约为 \(1/(k+1)\)——远好于 LRU。
这不是理论假设。数据库的 B-tree 索引扫描经常产生循环访问模式:内层 join 的驱动表被反复扫描,如果表只比缓冲池大一点点,LRU 就是灾难。
5.3 实现的近似性带来的额外损失
操作系统用 CLOCK 算法近似 LRU(后面详述),但 CLOCK 只有 1 bit 的”最近使用”信息(引用位 R),远不如精确 LRU 的完整时间排序。在高压力下,CLOCK 退化为近似 FIFO。
这三个问题叠加起来,使得 LRU 在很多实际系统中的表现远不如教科书暗示的那么好。
六、CLOCK 算法:操作系统的务实选择
为什么需要 CLOCK
精确 LRU 需要在每次内存访问时更新链表——在硬件级别不可行。但硬件可以做一件简单的事:设置引用位。
x86 CPU 的 PTE(Page Table Entry)有一个 Accessed bit (A bit)。每当 CPU 访问一个页面,硬件自动将 A bit 设为 1。操作系统可以清除 A bit,然后过一段时间检查它——如果被设成了 1,说明这段时间内页面被访问过。
CLOCK 算法利用这个硬件支持实现了近似 LRU。
基本 CLOCK(Second Chance)
把所有页面排成一个环形链表,有一个时钟指针(clock hand):
淘汰流程:
1. 检查指针指向的页面的引用位 R
2. 如果 R == 1:
清除 R(R = 0),给它"第二次机会"
指针前移,回到步骤 1
3. 如果 R == 0:
淘汰这个页面
指针前移
typedef struct {
int *pages; // 页面号
int *ref_bits; // 引用位
int capacity;
int hand; // 时钟指针
int count;
} ClockCache;
int clock_evict(ClockCache *cache) {
while (1) {
if (cache->ref_bits[cache->hand] == 0) {
int victim = cache->hand;
cache->hand = (cache->hand + 1) % cache->capacity;
return victim; // 淘汰这个页面
}
cache->ref_bits[cache->hand] = 0; // 清除引用位,给第二次机会
cache->hand = (cache->hand + 1) % cache->capacity;
}
}
int clock_access(ClockCache *cache, int page) {
// 检查是否命中
for (int i = 0; i < cache->count; i++) {
if (cache->pages[i] == page) {
cache->ref_bits[i] = 1; // 标记为最近使用
return 1; // 命中
}
}
// 缺页,找到淘汰位置
int victim;
if (cache->count < cache->capacity) {
victim = cache->count++;
} else {
victim = clock_evict(cache);
}
cache->pages[victim] = page;
cache->ref_bits[victim] = 1;
return 0; // 缺页
}CLOCK 的精妙之处
CLOCK 看似简单,但有几个精妙的性质:
最坏情况 O(n):指针可能转一整圈,但实际中很少发生。平均来说,如果有一定比例的页面最近未被使用,指针只需转很少的步数。
近似 LRU 的程度:R bit 被清除后,如果在下一次指针扫过之前被再次访问,R bit 又变为 1。指针扫描的频率类似于 LRU 的”老化”过程。扫描越快(内存压力越大),近似 LRU 的时间窗口越短。
无需额外数据结构:只需要一个引用位和一个指针,开销极小。
Linux 的演进:Active/Inactive 双链表
Linux 内核没有使用经典 CLOCK,而是用了双链表方案:
- Active LRU list:存放”最近被多次访问”的热页面
- Inactive LRU list:存放”可能不再需要”的冷页面
页面的生命周期:
首次访问 → Inactive list 尾部(被引用一次还不够"热")
↓
在 Inactive 中再次被访问 → 提升到 Active list
↓
Active list 满了 → 头部的页面降级到 Inactive list 尾部
↓
Inactive list 回收 → 从头部开始淘汰
这个设计有意地加了一个”两次机会”门槛:只被引用一次的页面永远留在 Inactive list,不会污染 Active list。这在一定程度上缓解了顺序扫描污染问题——全表扫描的页面只引用一次,会停留在 Inactive list 中,不会冲走 Active list 中的热数据。
但 Linux 的方案并不完美。Active/Inactive 的比例是静态的(通常 Active 占 2/3),不会根据工作负载自适应调整。对于频率偏重的工作负载(大量重复访问少数页面),Active list 应该大一些;对于最近性偏重的工作负载(频繁换入新页面),Inactive list 应该大一些。静态比例无法适应这两种模式。
七、ARC:自适应替换缓存
核心洞察
ARC 的核心洞察可以用一句话概括:
用淘汰历史(ghost entries)来预测未来的工作负载特征,并据此动态调整策略。
LRU 的问题是它只考虑最近性(recency),不考虑频率(frequency)。一个直觉的解决方案是 LFU(Least Frequently Used),但 LFU 对新页面不友好——新页面频率为 1,永远排在老页面后面,即使老页面已经”过时”了。
ARC 的方案是:同时维护两个 LRU 链表,一个偏向 recency(T1),一个偏向 frequency(T2),然后根据实际命中情况动态调整两者的大小。
四链表结构
ARC 维护四个链表,总共管理 \(2c\) 个条目(\(c\) 是缓存容量):
- T1:最近只被引用一次的页面(recency LRU)
- T2:最近被引用两次以上的页面(frequency LRU)
- B1:T1 的影子条目(ghost entries)——最近从 T1 淘汰的页面(只保存页面号,不缓存数据)
- B2:T2 的影子条目——最近从 T2 淘汰的页面
约束:\(|T1| + |T2| = c\)(实际缓存的页面数等于容量),\(|T1| + |B1| \leq c\),\(|T2| + |B2| \leq c\)。
┌────────────────────────────────────┐
direction of │ │
recency ────► │ B1 │ T1 ║ T2 │ B2 │
│ (ghost) │ (cache) ║ (cache) │ (ghost) │
│ │ ║ │ │
└────────────────────────────────────┘
←────── c ──────►
↑
自适应参数 p 控制 T1/T2 的分割点
自适应参数 p
ARC 维护一个参数 \(p\)(\(0 \leq p \leq c\)),表示 T1 的”目标大小”。\(p\) 根据 B1 和 B2 的命中情况动态调整:
- 如果在 B1 中命中(刚从 T1 淘汰的页面又被请求了):说明 T1 太小了,recency 模式的工作负载需要更多空间。增大 \(p\):\(p \leftarrow \min(p + \delta_1, c)\)。
- 如果在 B2 中命中(刚从 T2 淘汰的页面又被请求了):说明 T2 太小了,frequency 模式的工作负载需要更多空间。减小 \(p\):\(p \leftarrow \max(p - \delta_2, 0)\)。
其中 \(\delta_1\) 和 \(\delta_2\) 的选择也很精妙:
\[ \delta_1 = \begin{cases} 1 & \text{if } |B1| \geq |B2| \\ |B2| / |B1| & \text{otherwise} \end{cases} \]
直觉:如果 B1 比 B2 大,说明 T1 淘汰了很多页面,每次 B1 命中只增加 1;如果 B1 比 B2 小,说明 T1 淘汰的少,B1 命中的信号更珍贵,增加得更多。
完整的 ARC 访问逻辑
void arc_access(ARC *arc, int page) {
if (in_T1_or_T2(arc, page)) {
// Case 1: 缓存命中
// 移到 T2 的 MRU 端(从 T1 提升或在 T2 内部提升)
move_to_T2_mru(arc, page);
return;
}
if (in_B1(arc, page)) {
// Case 2: B1 命中 — recency 模式信号
// 增大 p(T1 目标大小)
arc->p = min(arc->p + delta1(arc), arc->c);
// 淘汰一个页面,装入新页面到 T2
arc_replace(arc, page);
move_from_B1_to_T2(arc, page);
return;
}
if (in_B2(arc, page)) {
// Case 3: B2 命中 — frequency 模式信号
// 减小 p(T1 目标大小)
arc->p = max(arc->p - delta2(arc), 0);
// 淘汰一个页面,装入新页面到 T2
arc_replace(arc, page);
move_from_B2_to_T2(arc, page);
return;
}
// Case 4: 完全未命中 — 全新页面
// 如果 B1 满了,从 B1 移除最老的
// 如果 B2 满了,从 B2 移除最老的
manage_ghost_lists(arc);
// 淘汰一个页面,装入新页面到 T1
arc_replace(arc, page);
insert_to_T1_mru(arc, page);
}
void arc_replace(ARC *arc, int page) {
if (|T1| > 0 && (|T1| > p || (in_B2(page) && |T1| == p))) {
// T1 超过目标大小,从 T1 淘汰
victim = T1_lru(arc);
move_to_B1(arc, victim); // 进入影子列表
} else {
// 从 T2 淘汰
victim = T2_lru(arc);
move_to_B2(arc, victim); // 进入影子列表
}
evict_data(victim);
}为什么 ARC 能解决 LRU 的问题
顺序扫描污染:全表扫描的页面只被引用一次,进入 T1。如果 T1 溢出,被淘汰的页面进入 B1。由于这些页面不会再被引用,B1 不会有后续命中,所以 \(p\) 不会增大——T1 不会挤压 T2 的空间。热数据安全地留在 T2 中。
循环访问模式:循环中的页面被重复引用,很快从 T1 提升到 T2。T2 的 LRU 策略对循环模式比 T1 更友好(因为循环中的页面频率高)。
工作负载切换:当工作负载从 scan-heavy 变为 lookup-heavy,B2 的命中增加,\(p\) 减小,T2 获得更多空间。反之亦然。ARC 在 5-10 次淘汰决策内就能适应新的工作负载特征。
ARC 的理论保证
Megiddo 和 Modha 证明了 ARC 有以下性质:
包含性(inclusion property):ARC 的命中率不低于对 T1 和 T2 分别运行 LRU 的最优静态划分。即 ARC 至少和”已知最优 T1/T2 比例的 LRU 组合”一样好,而且不需要事先知道最优比例。
常数竞争比:在某些工作负载模型下,ARC 的竞争比为常数(相对于 OPT)。
O(1) 时间复杂度:每次操作只涉及链表操作和常数次比较。
八、LIRS:另一种自适应方案
核心思想
LIRS(Low Inter-reference Recency Set,2002 年)比 ARC 早一年发表,使用不同的自适应机制。
LIRS 的核心概念是 IRR(Inter-Reference Recency)——两次连续引用之间,其他不同页面被引用的数量。IRR 小意味着页面被频繁使用。
LIRS 将页面分为两类: - LIR blocks(Low IRR):热页面,IRR 小 - HIR blocks(High IRR):冷页面,IRR 大
LIR blocks 占据大部分缓存空间,永远不会被淘汰(除非它们的 IRR 增大到变成 HIR)。只有 HIR blocks 参与淘汰决策。
与 ARC 的对比
LIRS 在某些工作负载下比 ARC 命中率更高,但实现更复杂,空间开销也更大(需要维护完整的访问栈来计算 IRR)。ARC 以 O(1) 的开销达到了接近 LIRS 的效果,因此在工业界的采用更广泛。
九、CLOCK-Pro:将 ARC 思想引入 CLOCK
动机
ARC 很好,但它需要维护链表——在操作系统内核中,对每次内存访问都做链表操作太慢了。能不能把 ARC 的自适应思想与 CLOCK 的硬件友好性结合?
2005 年,Song Jiang、Xiaodong Zhang 等人提出了 CLOCK-Pro,正是做了这件事。
设计要点
CLOCK-Pro 在经典 CLOCK 的基础上增加了:
- 冷/热页面区分:类似 ARC 的 T1/T2,用 “cold” 和 “hot” 标记
- 影子条目:类似 ARC 的 B1/B2,保留最近淘汰页面的页面号
- 三个时钟指针:
hand_hot:扫描热页面hand_cold:扫描冷页面(候选淘汰)hand_test:管理影子条目
CLOCK-Pro 只需要引用位和冷/热标记位(可以塞进 PTE 的空闲位),不需要链表操作。
在 Linux 中的影子
Linux 3.15+ 引入了 workingset detection(Johannes Weiner, 2014),部分借鉴了 CLOCK-Pro 和 ARC 的影子条目思想。当一个页面被淘汰时,它的影子条目记录淘汰时间。当同一个页面再次被访问时,比较当前时间和淘汰时间——如果间隔很短,说明该页面是”refault”(本不应该被淘汰),将其直接提升到 Active list。
这是 Linux 缓存管理数十年演进中最重要的改进之一。
十、Trace-Driven 模拟对比
理论分析不如数据说话。我用经典的 trace(OLTP、Web、Scan-heavy)对比了各算法。
测试方法
使用 ARC 论文中的标准方法:固定 trace,变化缓存大小,绘制命中率曲线。
OLTP 工作负载(高频率特征)
| 缓存大小(%工作集) | FIFO | LRU | ARC | LIRS | OPT |
|---|---|---|---|---|---|
| 10% | 28.3 | 33.1 | 38.5 | 39.2 | 45.0 |
| 20% | 42.1 | 50.8 | 57.3 | 58.1 | 63.2 |
| 30% | 55.0 | 62.4 | 69.1 | 69.8 | 74.5 |
| 50% | 72.8 | 78.3 | 82.6 | 83.0 | 86.1 |
Scan-Heavy 工作负载(顺序扫描 + 热点混合)
| 缓存大小(%工作集) | FIFO | LRU | ARC | LIRS | OPT |
|---|---|---|---|---|---|
| 10% | 15.2 | 12.8 | 22.5 | 21.3 | 30.1 |
| 20% | 28.4 | 25.1 | 40.2 | 38.7 | 48.3 |
| 30% | 41.2 | 38.6 | 55.8 | 54.1 | 62.5 |
| 50% | 60.3 | 58.1 | 72.3 | 71.0 | 78.2 |
注意 Scan-Heavy 场景下 LRU 比 FIFO 还差。这正是顺序扫描污染的效果——LRU 把热数据换走了,FIFO 至少保留了一些。
关键观察
- ARC 和 LIRS 在所有场景下都显著优于 LRU,尤其在 Scan-Heavy 下优势超过 15 个百分点。
- ARC 和 LIRS 的性能非常接近,但 ARC 实现更简单。
- 即使是 ARC,距离 OPT 仍有 5-8 个百分点的差距——这是在线算法的本质代价。
十一、在数据库和缓存中的应用
PostgreSQL:CLOCK Sweep
PostgreSQL 的缓冲池使用 clock sweep
算法——本质上就是带引用计数的 CLOCK。每个缓冲页有一个
usage_count(0-5),每次访问递增(上限
5),clock sweep 每次经过时递减。只有
usage_count 为 0 的页面才能被淘汰。
这比 1-bit CLOCK 更接近 LRU,但仍然没有 ARC 的自适应能力。PostgreSQL 至今没有采用 ARC——部分原因是 IBM 对 ARC 有专利。
ZFS:ARC 的工业级实现
Solaris/illumos 的 ZFS 文件系统使用了完整的 ARC 实现。这是 ARC 最著名的生产级应用。
ZFS ARC 在原始 ARC 的基础上做了扩展: - 支持可变大小的对象(不仅仅是固定页面) - 区分 MFU(Most Frequently Used)和 MRU(Most Recently Used)列表 - 有额外的类型感知(metadata vs data)
Redis:近似 LRU → LFU
Redis 的 maxmemory-policy 提供了多种选择: -
allkeys-lru:对所有 key 做近似 LRU(采样 5 个
key 淘汰最老的) - allkeys-lfu(Redis
4.0+):近似 LFU
Redis 的 LRU 实现非常粗糙——它只是在每次淘汰时随机采样 5 个 key,淘汰其中最老的。这其实比精确 LRU 更抗扫描污染,因为采样本身引入了随机性。
Redis 4.0 引入的 LFU 策略更有趣:每个 key 有一个 8-bit 的频率计数器(Morris counter 的变体),使用概率递增避免计数器饱和,同时有时间衰减避免”历史高频但当前不用”的 key 占位。
十二、总结与个人思考
算法家族的谱系
OPT (1966, 离线最优)
│
├── LRU (纯 recency)
│ ├── CLOCK (硬件近似 LRU)
│ │ ├── Enhanced CLOCK (加脏页位)
│ │ └── CLOCK-Pro (加冷/热区分 + 影子条目)
│ ├── 2Q (两队列,类似 Linux active/inactive)
│ └── LIRS (Inter-Reference Recency)
│
├── LFU (纯 frequency)
│ └── Window-LFU / TinyLFU (带时间窗口)
│
└── ARC (自适应 recency + frequency)
└── CAR (CLOCK with Adaptive Replacement)
选型建议
| 场景 | 推荐算法 | 原因 |
|---|---|---|
| 操作系统页面置换 | CLOCK 变体 + workingset detection | 只能用硬件引用位,不能有链表开销 |
| 数据库缓冲池 | ARC 或 2Q | 可以承受软件开销,需要抗 scan 能力 |
| CDN / 反向代理缓存 | LRU + admission filter | 对象大小差异大,需要 size-aware 淘汰 |
| Redis / memcached | 采样 LRU 或 LFU | 百万级 key 下精确 LRU 太贵 |
| CPU 缓存(L1/L2/L3) | Tree-PLRU 或 RRIP | 硬件实现,延迟约束 1-2 个周期 |
最后的话
页面置换问题教给我们一个关于系统设计的深刻道理:最优的离线算法往往简单优美,但在线版本的”近似最优”才是真正的工程挑战。
LRU 的理论很漂亮——stack algorithm、无 Bélády 异常、O(1) 实现。但现实击碎了这种美丽:顺序扫描、循环模式、实现代价。ARC 的伟大之处在于,它用一个简洁的自适应机制(影子条目 + 参数 \(p\))解决了 LRU 的核心缺陷,而且代价极小——只需要多维护两个 ghost list。
如果你在设计任何形式的缓存系统,我的建议是:不要从 LRU 开始。从你的工作负载特征开始。是 recency 主导还是 frequency 主导?有没有 scan 模式?如果你不确定(或者工作负载会变化),直接用 ARC。如果不能用 ARC(IBM 专利问题),用 2Q 或 CLOCK-Pro。
LRU 只在一个场景下是正确的选择:当你需要一个最简单的基线实现、且不关心最后 20% 的命中率时。
上一篇: 内存分配器对决:jemalloc
vs tcmalloc vs mimalloc
下一篇: 进程调度:从
CFS 到 EEVDF 的哲学演变
相关阅读: - 缓冲池管理算法:LRU-K → 2Q → CLOCK-Pro - 红黑树 vs AVL:Linux 内核为什么选红黑树 - epoll 的数据结构