📘 教案 31:缓存淘汰策略(LRU / LFU · 结构与复杂度)
一、问题模型(精确定义)
容量受限缓存 ©,最大容纳 (K) 个键值对。操作:
get(k):若存在返回值并更新访问状态put(k, v):写入;若满则淘汰一个键
目标(经典假设:未来未知):
在在线(online)条件下,最小化未命中(miss)次数。
这在理论上等价于近似最优的离线算法(Belady,淘汰未来最晚使用项)。
二、LRU(Least Recently Used)
1. 语义
淘汰"最近最久未被访问"的键。
2. 数据结构设计(O(1) 实现)
需要同时支持:
- 按 key 查找:(O(1))
- 更新"最近使用"顺序:(O(1))
- 删除最旧元素:(O(1))
结构:HashMap + 双向链表
map: key → node*list: 头=最近使用(MRU),尾=最久未用(LRU)
3. 不变量(必须维持)
- 链表顺序严格表示访问新旧
- map 中的指针始终指向链表中的有效节点
4. 操作流程
get(k)
- 若不存在:返回 miss
- 若存在:将节点移动到表头(MRU),返回值
put(k, v)
-
若已存在:更新值,移动到表头
-
若不存在:
- 若未满:头插新节点
- 若已满:删除尾节点(LRU),再头插
5. 复杂度
get / put:(O(1))(均摊)- 空间:(O(K))
6. 代码骨架(Python)
python
class Node:
__slots__ = ("k","v","prev","next")
def __init__(self,k,v):
self.k,self.v=k,v
self.prev=self.next=None
class LRUCache:
def __init__(self, capacity):
self.cap=capacity
self.map={}
# 哨兵节点
self.head=Node(0,0)
self.tail=Node(0,0)
self.head.next=self.tail; self.tail.prev=self.head
def _add_front(self, x):
x.next=self.head.next; x.prev=self.head
self.head.next.prev=x; self.head.next=x
def _remove(self, x):
x.prev.next=x.next; x.next.prev=x.prev
def _move_to_front(self, x):
self._remove(x); self._add_front(x)
def get(self, k):
if k not in self.map: return -1
x=self.map[k]
self._move_to_front(x)
return x.v
def put(self, k, v):
if k in self.map:
x=self.map[k]; x.v=v
self._move_to_front(x); return
if len(self.map)==self.cap:
lru=self.tail.prev
self._remove(lru)
del self.map[lru.k]
x=Node(k,v)
self._add_front(x)
self.map[k]=x
7. LRU 的问题(必须知道)
- 对"扫描型访问"(scan)不友好:一次线性扫描会把热点挤出
- 不区分"频率":短时间内多次访问与一次访问效果相同(都会被刷新到 MRU)
三、LFU(Least Frequently Used)
1. 语义
淘汰"访问频率最低"的键;若频率相同,通常再用 LRU 作为次序。
2. O(1) 设计(难点)
需要:
get/put:(O(1))- 按频率分桶,支持频率递增与最小频率定位
结构:
key_map: key → (value, freq, node*)freq_map: freq → 双向链表(该频率的键,按最近使用排序)min_freq: 当前最小频率
3. 不变量
- 每个 key 只在一个 freq 桶中
min_freq指向非空的最小频率桶
4. 操作流程
get(k)
-
不存在:miss
-
存在:
- 从
freq = f的链表中删除该节点 - 若该链表为空且
f == min_freq,则min_freq += 1 - 将节点插入
freq = f+1的链表头(MRU) - 返回值
- 从
put(k, v)
-
若容量为 0:直接返回
-
若已存在:更新值,执行
get(k)的"频率提升"流程 -
若不存在:
- 若已满:在
freq = min_freq的链表中删除尾节点(LRU) - 插入新节点到
freq = 1的链表头,设min_freq = 1
- 若已满:在
5. 复杂度
get / put:(O(1))(均摊)- 空间:(O(K))
6. 关键代码骨架(精简)
python
from collections import defaultdict
class Node:
__slots__=("k","v","f","prev","next")
def __init__(self,k,v):
self.k,self.v,self.f=k,v,1
self.prev=self.next=None
class DL:
def __init__(self):
self.h=Node(0,0); self.t=Node(0,0)
self.h.next=self.t; self.t.prev=self.h
self.sz=0
def add_front(self,x):
x.next=self.h.next; x.prev=self.h
self.h.next.prev=x; self.h.next=x; self.sz+=1
def remove(self,x):
x.prev.next=x.next; x.next.prev=x.prev; self.sz-=1
def pop_tail(self):
if self.sz==0: return None
x=self.t.prev; self.remove(x); return x
class LFUCache:
def __init__(self, cap):
self.cap=cap; self.size=0
self.key_map={}
self.freq_map=defaultdict(DL)
self.minf=0
def _inc(self, x):
f=x.f
self.freq_map[f].remove(x)
if f==self.minf and self.freq_map[f].sz==0:
self.minf+=1
x.f+=1
self.freq_map[x.f].add_front(x)
def get(self,k):
if k not in self.key_map: return -1
x=self.key_map[k]; self._inc(x); return x.v
def put(self,k,v):
if self.cap==0: return
if k in self.key_map:
x=self.key_map[k]; x.v=v; self._inc(x); return
if self.size==self.cap:
x=self.freq_map[self.minf].pop_tail()
del self.key_map[x.k]; self.size-=1
x=Node(k,v)
self.key_map[k]=x
self.freq_map[1].add_front(x)
self.minf=1; self.size+=1
7. LFU 的问题
- 维护成本高(常数大)
- 对"短期突发热点"反应慢(历史频率拖累)
四、工程级改进(真实系统使用)
1. LRU-K
记录最近 K 次访问时间 ,淘汰"第 K 次访问最早"的项。
近似"频率 + 新近性"的折中,复杂度高于 LRU。
2. 2Q
将缓存分为:
- A1(短期队列)
- Am(长期队列)
新进入先到 A1,命中后晋升到 Am。
目的:抵抗扫描污染。
3. ARC(Adaptive Replacement Cache)
同时维护:
- T1(近期)
- T2(频繁)
- B1/B2(历史幽灵队列)
动态调整 T1/T2 比例,自适应负载 。
(专利曾限制使用,但思想重要)
4. TinyLFU(现代主流,Caffeine/Redis 变体)
核心组件:
- Count-Min Sketch:近似统计频率(极低内存)
- Admission 策略:新元素只有在"频率超过被淘汰候选"时才进入
- Window LRU + Main Segmented LRU
优点:
- 低内存频率统计
- 对扫描与突发都鲁棒
五、分布式与系统细节
1. 分片(Sharding)
- 一致性哈希将 key 分配到不同节点
- 每个节点内部独立执行 LRU/LFU
2. 过期与淘汰的关系
- TTL(时间过期) 与 容量淘汰是两条路径
- 实现上常采用:惰性删除 + 定期采样
3. 并发控制
- 分段锁(segmented lock)或无锁结构(CAS)
- 读多写少场景下避免全局锁
4. 命中率(Hit Ratio)
核心指标:
\\text{Hit Ratio} = \\frac{\\text{hits}}{\\text{requests}}
评估不同策略(LRU/LFU/TinyLFU)的实际效果。
六、选择建议(工程结论)
| 场景 | 建议 |
|---|---|
| 通用缓存 | LRU(简单、稳定) |
| 强热点、稳定访问 | LFU / TinyLFU |
| 扫描/突发混合 | 2Q / TinyLFU |
| 高并发大规模 | TinyLFU(如 Caffeine 思路) |
七、结论性表述
缓存淘汰策略通过对"访问新近性与访问频率"的建模,在有限容量约束下选择被驱逐对象,从而最大化命中率。其工程实现依赖于常数时间的数据结构与对实际访问分布的鲁棒性设计(如对扫描与突发的抵抗能力)。