面试被问到 LRU 只会背 LinkedHashMap?LFU 的 O(1) 实现说不清楚?Redis 的淘汰策略只知道配置项名字?这篇文章帮你彻底搞定。
一、为什么需要缓存淘汰?
1.1 一个直觉类比
想象你的书桌只能放 5 本书,但你有一整书柜的书。每次要看某本书时:
- 如果书桌上有 → 直接拿(缓存命中,极快)
- 如果书桌上没有 → 去书柜找,放到书桌(缓存未命中,较慢)
- 如果书桌放满了 → 得先腾出一本再放新书(触发淘汰)
腾走哪本书,就是淘汰策略要解决的问题。 腾走的那本你马上又要用怎么办?只能再回书柜取,命中率就下去了。
1.2 缓存未命中的代价有多大?
┌─────────────────────────────────────────────────────────────────┐
│ 一次请求的成本链路 │
├──────────────┬──────────────────┬──────────────────────────────┤
│ 命中 L1 缓存│ ~0.5 ns │ ██ 1x(基准) │
│ 命中 L2 缓存│ ~7 ns │ ████████████████ 14x │
│ 命中内存缓存│ ~100 ns │ 延迟可接受 │
│ 未命中→磁盘 │ ~10 ms │ ██████████ 100,000x!! │
│ 未命中→远程DB│ ~50-200 ms │ 毁灭性,影响用户体验 │
└──────────────┴──────────────────┴──────────────────────────────┘
结论: 缓存命中率提升 1%,在高并发系统里可能意味着每秒节省数千次数据库查询。淘汰策略直接决定命中率。
1.3 数据的访问规律 --- 80/20 法则
现实世界的数据访问几乎都符合 幂律分布(Power-Law) :
markdown
访问频率
▲
│ █
│ █
│ ██
│ ████
│ ████████
│ ███████████████████████████████████████
└───────────────────────────────────────▶ key 编号
← 20% 的热数据 →│← 80% 的冷数据 →
20% 的数据承载 80% 的访问量。好的淘汰策略,就是把这 20% 的热数据尽可能留在缓存里。
二、六大策略速览
| 策略 | 淘汰谁 | 时间复杂度 | 命中率 | 实现难度 | 适用场景 |
|---|---|---|---|---|---|
| FIFO | 最早进入的 | O(1) | ★★☆ | ★☆☆ | 简单流水缓存 |
| LRU | 最久没被访问的 | O(1) | ★★★ | ★★☆ | 通用,绝大多数场景 |
| LFU | 访问次数最少的 | O(1) | ★★★ | ★★★ | 热点数据明显的场景 |
| LRU-K | 访问次数<K的最久未用 | O(log n) | ★★★ | ★★★ | 防缓存污染 |
| ARC | 自适应 LRU+LFU | O(1) | ★★★ | ★★★ | 文件系统(ZFS) |
| Random | 随机一个 | O(1) | ★★☆ | ★☆☆ | 分布式系统兜底 |
面试重点:LRU 必须手写,LFU 必须讲清楚 O(1) 实现,Redis 策略必须知道背后的近似机制。
三、FIFO --- 最朴素的淘汰
原理
F irst I n F irst O ut,先进先出。缓存满了就把最先放进来的赶出去,不考虑它有没有被访问过。
less
队列示意(容量=3):
← 淘汰方向(队头)
时刻1: 放入 A [A]
时刻2: 放入 B [A, B]
时刻3: 放入 C [A, B, C]
时刻4: 放入 D [B, C, D] ← A 被淘汰(最先进的)
时刻5: 访问 B [B, C, D] ← B 虽刚被访问,依然排在"最老"位置
时刻6: 放入 E [C, D, E] ← B 被淘汰!(明明刚用过)
→ 插入方向(队尾)
致命缺陷:不考虑最近是否访问,刚用过的数据也可能被淘汰。
⚠️ Belady 异常(面试必答)
正常来说,缓存越大命中率越高。但 FIFO 有一个反常现象------增大缓存容量,命中率反而下降:
访问序列:1 2 3 4 1 2 5 1 2 3 4 5
缓存大小=3: 命中次数=4
缓存大小=4: 命中次数=2 ← 更大的缓存,更低的命中率!
这就是著名的 Belady 异常,是 FIFO 的理论硬伤。LRU 不存在这个问题(LRU 属于 Stack Algorithm)。
四、LRU --- 面试最高频考点 ★★★
4.1 原理
L east R ecently U sed,淘汰最久没被访问的那个。
核心假设:刚被访问过的数据,近期大概率还会再次被访问(时间局部性原理)。
4.2 数据结构设计 --- 为什么需要双向链表 + HashMap?
要实现 O(1) 的 get 和 put,需要同时满足:
css
需求1: get(key) → O(1) 查找 → 需要 HashMap
需求2: put(key) → O(1) 淘汰最老的 → 需要有序结构
需求3: 访问后移到"最新"位置 → O(1) 的节点移动 → 需要双向链表(单向链表删除节点需 O(n))
双向链表 + HashMap 的组合,正是专门解决这个问题的。
ini
HashMap: key → Node 指针(O(1) 定位)
双向链表(从左到右:最久未用 → 最近使用):
Dummy_Head ⟷ [Node_B] ⟷ [Node_D] ⟷ [Node_A] ⟷ Dummy_Tail
↑ ↑
下次淘汰这里 最近刚用过
两个虚拟哨兵节点(Dummy Head/Tail)的好处:避免处理头尾为 null 的边界情况,代码更整洁。
4.3 操作动画演示(容量=3)
ini
初始状态(空): Head ⟷ Tail
操作: put(1,"a")
├─ 插入链表尾部(最新)
└─ Head ⟷ [1] ⟷ Tail
操作: put(2,"b")
└─ Head ⟷ [1] ⟷ [2] ⟷ Tail
操作: put(3,"c")
└─ Head ⟷ [1] ⟷ [2] ⟷ [3] ⟷ Tail
↑ 最新
操作: get(1) ← 访问 key=1
├─ 在 HashMap 中 O(1) 找到节点 [1]
├─ 从当前位置摘下,移到链表尾部
└─ Head ⟷ [2] ⟷ [3] ⟷ [1] ⟷ Tail
↑ 最久未用 ↑ 刚访问
操作: put(4,"d") ← 容量已满,触发淘汰
├─ 淘汰链表头部第一个节点 [2]
├─ 从 HashMap 删除 key=2
└─ Head ⟷ [3] ⟷ [1] ⟷ [4] ⟷ Tail
4.4 手写实现
Java 版
java
import java.util.HashMap;
import java.util.Map;
public class LRUCache<K, V> {
// 双向链表节点
private static class Node<K, V> {
K key;
V value;
Node<K, V> prev, next;
Node(K key, V value) {
this.key = key;
this.value = value;
}
}
private final int capacity;
private final Map<K, Node<K, V>> map;
private final Node<K, V> head; // 虚拟头节点(最久未用方向)
private final Node<K, V> tail; // 虚拟尾节点(最近使用方向)
public LRUCache(int capacity) {
this.capacity = capacity;
this.map = new HashMap<>();
head = new Node<>(null, null);
tail = new Node<>(null, null);
head.next = tail;
tail.prev = head;
}
public V get(K key) {
Node<K, V> node = map.get(key);
if (node == null) return null;
moveToTail(node); // 访问后移到最近使用位置
return node.value;
}
public void put(K key, V value) {
if (map.containsKey(key)) {
Node<K, V> node = map.get(key);
node.value = value;
moveToTail(node);
} else {
if (map.size() >= capacity) {
// 淘汰链表头部(最久未用)
Node<K, V> lru = head.next;
removeNode(lru);
map.remove(lru.key);
}
Node<K, V> newNode = new Node<>(key, value);
addToTail(newNode);
map.put(key, newNode);
}
}
// 从链表中摘除节点
private void removeNode(Node<K, V> node) {
node.prev.next = node.next;
node.next.prev = node.prev;
}
// 插入到链表尾部(最近使用位置)
private void addToTail(Node<K, V> node) {
node.prev = tail.prev;
node.next = tail;
tail.prev.next = node;
tail.prev = node;
}
// 先摘出,再插尾:等价于"移到最近使用"
private void moveToTail(Node<K, V> node) {
removeNode(node);
addToTail(node);
}
}
面试加分 :如果只是功能实现,Java 可以直接继承
LinkedHashMap并重写removeEldestEntry,但面试通常要求手写双向链表,考察对数据结构的理解深度。
Go 版
go
package lru
import "container/list"
type LRUCache struct {
capacity int
list *list.List
items map[int]*list.Element
}
type entry struct {
key int
value int
}
func NewLRUCache(capacity int) *LRUCache {
return &LRUCache{
capacity: capacity,
list: list.New(),
items: make(map[int]*list.Element),
}
}
func (c *LRUCache) Get(key int) int {
elem, ok := c.items[key]
if !ok {
return -1
}
c.list.MoveToBack(elem) // 移到尾部 = 最近使用
return elem.Value.(*entry).value
}
func (c *LRUCache) Put(key, value int) {
if elem, ok := c.items[key]; ok {
elem.Value.(*entry).value = value
c.list.MoveToBack(elem)
return
}
if c.list.Len() >= c.capacity {
// 淘汰链表头部(最久未用)
oldest := c.list.Front()
c.list.Remove(oldest)
delete(c.items, oldest.Value.(*entry).key)
}
e := &entry{key, value}
elem := c.list.PushBack(e)
c.items[key] = elem
}
Python 版
python
from collections import OrderedDict
class LRUCache:
"""
OrderedDict 天然维护插入顺序,
move_to_end() 对应"标记为最近使用"。
"""
def __init__(self, capacity: int):
self.capacity = capacity
self.cache = OrderedDict()
def get(self, key: int) -> int:
if key not in self.cache:
return -1
self.cache.move_to_end(key) # 标记为最近使用
return self.cache[key]
def put(self, key: int, value: int) -> None:
if key in self.cache:
self.cache.move_to_end(key)
else:
if len(self.cache) >= self.capacity:
self.cache.popitem(last=False) # 淘汰最久未用(头部)
self.cache[key] = value
4.5 LRU 的局限性 --- 缓存污染
css
场景:缓存容量=3,热点数据是 A、B、C
正常状态:[A, B, C] 命中率接近 100%
突然来了一次批量扫描:访问 D, E, F, G, H ...(全是不会再访问的数据)
结果:[F, G, H] ← A、B、C 全被挤出!
下一次正常请求 A、B、C 全部缓存未命中!
这就是缓存污染(Cache Pollution) ,LRU 对一次性大量访问非常脆弱。LRU-K 和 2Q 正是为了解决这个问题而生。
五、LFU --- 进阶必考题 ★★★
5.1 原理
L east F requently U sed,淘汰访问次数最少的那个。每次访问都会给该 key 的计数器 +1,缓存满时淘汰计数最低的。
相比 LRU 的优势:不容易被一次性扫描污染,真正的热点数据会被长期保留。
相比 LRU 的劣势:新插入的 key 计数初始为 1,容易被淘汰(新数据不友好);历史累积高频数据即使不再访问也难以被淘汰("频率惰性")。
5.2 O(1) 实现 --- 频率桶结构
朴素的 LFU 用最小堆,复杂度 O(log n)。面试要求的 O(1) 实现,使用以下结构:
ini
两张 HashMap + 一个 minFreq 指针:
freqMap: {频率 → 该频率下所有 key 的有序集合}
keyMap: {key → (value, 当前频率)}
minFreq: 当前最小频率(淘汰时直接找 freqMap[minFreq])
示意图(容量=3):
keyMap: freqMap:
┌─────┬──────┬──────┐ freq=1: [C] ← 最新插入的在后面
│ key │ val │ freq │ freq=2: [A, B](A 比 B 更久没访问)
├─────┼──────┼──────┤ freq=3: (空)
│ A │ va │ 2 │
│ B │ vb │ 2 │ minFreq = 1
│ C │ vc │ 1 │
└─────┴──────┴──────┘
关键操作:访问 key A(freq: 2 → 3)
ini
1. 从 freqMap[2] 中移除 A
2. 如果 freqMap[2] 变空 且 minFreq==2,则 minFreq++ 变为 3
3. 将 A 加入 freqMap[3]
4. 更新 keyMap[A].freq = 3
关键操作:插入新 key D(缓存已满,需要淘汰)
ini
1. minFreq=1,取 freqMap[1] 的头部(最久未用的那个)= C
2. 删除 C(从 freqMap[1] 和 keyMap 都删掉)
3. 插入 D,freq=1,加入 freqMap[1]
4. minFreq 重置为 1(新插入的频率总是 1)
为什么 freqMap 里每个桶要用有序集合(LinkedHashSet)? 因为同频率下可能有多个 key,需要按插入时间决定谁先被淘汰(相当于同频时退化为 LRU)。
5.3 手写实现
Java 版
java
import java.util.*;
public class LFUCache {
private final int capacity;
private int minFreq;
// key → [value, freq]
private final Map<Integer, int[]> keyMap;
// freq → 该频率下的 key 集合(LinkedHashSet 维护插入顺序)
private final Map<Integer, LinkedHashSet<Integer>> freqMap;
public LFUCache(int capacity) {
this.capacity = capacity;
this.minFreq = 0;
this.keyMap = new HashMap<>();
this.freqMap = new HashMap<>();
}
public int get(int key) {
if (!keyMap.containsKey(key)) return -1;
increaseFreq(key);
return keyMap.get(key)[0];
}
public void put(int key, int value) {
if (capacity <= 0) return;
if (keyMap.containsKey(key)) {
keyMap.get(key)[0] = value;
increaseFreq(key);
return;
}
if (keyMap.size() >= capacity) {
evict();
}
keyMap.put(key, new int[]{value, 1});
freqMap.computeIfAbsent(1, k -> new LinkedHashSet<>()).add(key);
minFreq = 1; // 新插入的频率永远是 1
}
// 将 key 的频率 +1,并维护 minFreq
private void increaseFreq(int key) {
int freq = keyMap.get(key)[1];
keyMap.get(key)[1]++;
// 从旧频率桶中移除
freqMap.get(freq).remove(key);
if (freqMap.get(freq).isEmpty()) {
freqMap.remove(freq);
if (minFreq == freq) minFreq++; // 只有当前桶变空且是最小频率才更新
}
// 加入新频率桶
freqMap.computeIfAbsent(freq + 1, k -> new LinkedHashSet<>()).add(key);
}
// 淘汰 minFreq 桶中最久未用的 key
private void evict() {
LinkedHashSet<Integer> bucket = freqMap.get(minFreq);
int evictKey = bucket.iterator().next(); // 头部 = 最久未访问
bucket.remove(evictKey);
if (bucket.isEmpty()) freqMap.remove(minFreq);
keyMap.remove(evictKey);
}
}
Go 版
go
package lfu
import "container/list"
type LFUCache struct {
capacity int
minFreq int
keyMap map[int]*list.Element
freqMap map[int]*list.List
freqOf map[int]int // key → freq
valOf map[int]int // key → value
}
func NewLFUCache(capacity int) *LFUCache {
return &LFUCache{
capacity: capacity,
keyMap: make(map[int]*list.Element),
freqMap: make(map[int]*list.List),
freqOf: make(map[int]int),
valOf: make(map[int]int),
}
}
func (c *LFUCache) Get(key int) int {
if _, ok := c.keyMap[key]; !ok {
return -1
}
c.increaseFreq(key)
return c.valOf[key]
}
func (c *LFUCache) Put(key, value int) {
if c.capacity == 0 {
return
}
if _, ok := c.keyMap[key]; ok {
c.valOf[key] = value
c.increaseFreq(key)
return
}
if len(c.keyMap) >= c.capacity {
c.evict()
}
c.valOf[key] = value
c.freqOf[key] = 1
if c.freqMap[1] == nil {
c.freqMap[1] = list.New()
}
elem := c.freqMap[1].PushBack(key)
c.keyMap[key] = elem
c.minFreq = 1
}
func (c *LFUCache) increaseFreq(key int) {
freq := c.freqOf[key]
// 从旧桶移除
c.freqMap[freq].Remove(c.keyMap[key])
if c.freqMap[freq].Len() == 0 {
delete(c.freqMap, freq)
if c.minFreq == freq {
c.minFreq++
}
}
// 加入新桶
freq++
c.freqOf[key] = freq
if c.freqMap[freq] == nil {
c.freqMap[freq] = list.New()
}
elem := c.freqMap[freq].PushBack(key)
c.keyMap[key] = elem
}
func (c *LFUCache) evict() {
l := c.freqMap[c.minFreq]
front := l.Front()
key := front.Value.(int)
l.Remove(front)
if l.Len() == 0 {
delete(c.freqMap, c.minFreq)
}
delete(c.keyMap, key)
delete(c.freqOf, key)
delete(c.valOf, key)
}
Python 版
python
from collections import defaultdict, OrderedDict
class LFUCache:
def __init__(self, capacity: int):
self.capacity = capacity
self.min_freq = 0
self.key_map = {} # key → [value, freq]
self.freq_map = defaultdict(OrderedDict) # freq → {key: None}(有序,可 O(1) 取头部)
def get(self, key: int) -> int:
if key not in self.key_map:
return -1
self._increase_freq(key)
return self.key_map[key][0]
def put(self, key: int, value: int) -> None:
if self.capacity <= 0:
return
if key in self.key_map:
self.key_map[key][0] = value
self._increase_freq(key)
return
if len(self.key_map) >= self.capacity:
self._evict()
self.key_map[key] = [value, 1]
self.freq_map[1][key] = None
self.min_freq = 1
def _increase_freq(self, key: int):
freq = self.key_map[key][1]
del self.freq_map[freq][key]
if not self.freq_map[freq]:
del self.freq_map[freq]
if self.min_freq == freq:
self.min_freq += 1
freq += 1
self.key_map[key][1] = freq
self.freq_map[freq][key] = None
def _evict(self):
# popitem(last=False) 弹出最早插入的(最久未用)
key, _ = self.freq_map[self.min_freq].popitem(last=False)
if not self.freq_map[self.min_freq]:
del self.freq_map[self.min_freq]
del self.key_map[key]
六、LRU-K / 2Q --- 工程改良方案
6.1 LRU-K:访问 K 次才"晋升"热区
普通 LRU 只要访问 1 次就进入缓存,批量扫描能轻易污染缓存。LRU-K 的思路:
yaml
冷区(访问记录队列) 热区(真正的 LRU 缓存)
┌──────────────────┐ ┌───────────────────┐
│ key 的历史访问次数 │ │ 访问次数 ≥ K 的 key │
│ │ 达到K次 │ │
│ A: 1次 │ ─────────▶│ B: 5次 │
│ C: 2次 │ │ D: 8次 │
└──────────────────┘ └───────────────────┘
一次性扫描的数据
全部停在冷区,
不会污染热区!
K=2 是最常用的选择(LRU-2),能有效过滤一次性访问。
6.2 2Q:冷热双队列
2Q 是 LRU-K=2 的一种简化工程实现:
scss
┌──────────────────────────────────────┐
新数据 ──────────▶ FIFO 队列 │ LRU 队列(热区) │
首次进入 (冷区) │ │
┌──────────┐ │ 第二次被访问时,晋升到热区 │
│ A(新) │ ──▶ │ │
│ B(新) │ │ ┌────────────────────────────────┐ │
│ C(新) │ │ │ E(热) → F(热) → G(热) → H(热) │ │
└──────────┘ │ └────────────────────────────────┘ │
FIFO 满了就 │ │
直接淘汰,不 └──────────────────────────────────────┘
影响热区 热区满了按 LRU 淘汰
七、ARC --- 自适应的艺术
ARC(A daptive R eplacement Cache)是 IBM 在 2003 年发明的算法,被 ZFS、Oracle DB 等采用。
四条链表结构
less
┌────────────────────────────────────────────────────────────────────┐
│ ARC 内部结构 │
│ │
│ T1: 最近访问过 1 次的 key(新鲜的) B1: T1 的淘汰记录(幽灵) │
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ [a] [b] [c] │ │ [x] [y] [z] (元数据)│ │
│ └──────────────────────┘ └──────────────────────┘ │
│ │
│ T2: 访问过 2 次以上的 key(稳定热点) B2: T2 的淘汰记录(幽灵) │
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ [d] [e] [f] [g] │ │ [m] [n] (元数据) │ │
│ └──────────────────────┘ └──────────────────────┘ │
│ │
│ p ← 动态参数,决定 T1 和 T2 各占多大比例 │
└────────────────────────────────────────────────────────────────────┘
自适应精髓:
- 如果最近经常在 B1(T1 的幽灵列表)中命中 → 说明 T1 太小了 → p 增大(给 T1 更多空间)
- 如果最近经常在 B2(T2 的幽灵列表)中命中 → 说明 T2 太小了 → p 减小(给 T2 更多空间)
ARC 能在"时间局部性"和"频率局部性"之间自动寻找平衡点,不需要手动调参。
八、Redis 缓存淘汰策略深度解析
8.1 整体触发流程
scss
客户端发来写命令(SET / LPUSH / ...)
│
▼
Redis 检查内存使用量
│
超过 maxmemory?
┌────┴────┐
否 是
│ │
│ ▼
│ 执行 freeMemoryIfNeeded()
│ │
│ 根据 maxmemory-policy
│ 选出候选 key 并淘汰
│ │
└────┬────┘
▼
执行原始写命令
配置方式(redis.conf 或运行时):
bash
# 设置内存上限
maxmemory 1gb
# 设置淘汰策略
maxmemory-policy allkeys-lru
# 采样数量(影响近似精度,默认5,推荐10)
maxmemory-samples 10
8.2 八种淘汰策略一览
arduino
┌─────────────────┬────────────────────────────────────────────────────┐
│ 策略名称 │ 说明 │
├─────────────────┼────────────────────────────────────────────────────┤
│ noeviction │ 不淘汰,内存满后拒绝写入,返回错误 │
├─────────────────┼────────────────────────────────────────────────────┤
│ allkeys-lru │ 所有 key 中淘汰最久未使用的(★ 最常用) │
│ volatile-lru │ 只在有 TTL 的 key 中做 LRU 淘汰 │
├─────────────────┼────────────────────────────────────────────────────┤
│ allkeys-lfu │ 所有 key 中淘汰访问频率最低的 │
│ volatile-lfu │ 只在有 TTL 的 key 中做 LFU 淘汰 │
├─────────────────┼────────────────────────────────────────────────────┤
│ allkeys-random │ 随机淘汰任意 key │
│ volatile-random │ 随机淘汰有 TTL 的 key │
├─────────────────┼────────────────────────────────────────────────────┤
│ volatile-ttl │ 优先淘汰剩余 TTL 最短的 key │
└─────────────────┴────────────────────────────────────────────────────┘
allkeys-* 作用范围:全部 key
volatile-* 作用范围:仅设置了过期时间的 key(如果没有这样的 key,则行为同 noeviction)
8.3 Redis 为什么不用"真正的 LRU"?
精确 LRU 需要维护一个全局有序链表,对于 Redis 来说:
- 每次访问任何一个 key 都要移动链表节点
- 高并发下链表操作需要全局锁 → 性能瓶颈
- 额外存储每个节点的 prev/next 指针 → 内存开销巨大
所以 Redis 用了一个近似 LRU 方案,巧妙地解决了这个问题:
vbnet
近似 LRU 的实现方式:
每个 Redis Object 都有一个 24-bit 的 lru_clock 字段,
记录最后一次被访问的时间戳(精度:秒)。
淘汰时,Redis 不遍历所有 key,而是:
1. 随机采样 N 个 key(默认 N=5,maxmemory-samples 控制)
2. 把这些 key 按 lru_clock 排序
3. 淘汰其中 lru_clock 最小(最久未访问)的那个
┌────────────────────────────────────────────────────────┐
│ 候选池(eviction pool)优化(Redis 3.0+) │
│ │
│ 不再每次只看 N 个随机 key,而是: │
│ 维护一个大小为 16 的候选池,每次采样后和池里的 key 合并, │
│ 取整体最差的进行淘汰,精度大幅提升。 │
└────────────────────────────────────────────────────────┘
lru_clock 的局限 :24-bit 秒级时钟,约 194 天轮回一次。超过这个周期的 key,时钟值会产生绕回,但实际影响极小(很少有 key 超过 194 天完全不被访问)。
8.4 Redis LFU --- Morris Counter(概率计数器)
LFU 需要记录每个 key 的访问次数,如果用普通整数,频繁访问的 key 计数会非常大,而且无法体现"最近是否还在被访问"。
Redis 用了一个非常精妙的 8-bit Morris 概率计数器:
matlab
计数器规则(lfu-log-factor=10 时):
┌────────────────────────────────────────────────────────────────┐
│ counter 值 │ 对应的近似访问次数 │
│──────────────┼────────────────────────────────────────────────│
│ 0 │ 几乎从未被访问 │
│ 1-10 │ 很少访问 │
│ 10-50 │ 中等访问 │
│ 50-100 │ 高频访问 │
│ 200+ │ 极热点(counter 最大值 255,对应约 100 万次访问) │
└────────────────────────────────────────────────────────────────┘
关键:counter 不是线性累加,而是"越高越难涨":
- counter 很低时,访问一次几乎必然 +1
- counter 很高时,访问一次大概率不增加(概率递减)
衰减机制 :lfu-decay-time 配置每过多少分钟 counter 减半。这样"以前热、现在冷"的 key,counter 会随时间下降,最终被淘汰。
css
访问后 counter 变化示意:
时间 ─────────────────────────────────────────────▶
lfu-decay-time 到期
key A (曾经热点): 255 ──────────────────▶ 127 ──▶ 64 ──▶ 淘汰候选
key B (新热点): 1 ──▶▶▶▶▶▶▶▶▶▶▶▶▶▶ 200 (持续被访问,counter 增长)
8.5 策略选型决策树
arduino
我的数据有没有设置 TTL(过期时间)?
│
├── 大部分没有设置 TTL ──────────────────────────────────▶ 选 allkeys-*
│ │
│ 有明显热点数据(少数key被大量访问)?
│ ├── 是 ──▶ allkeys-lfu ★推荐
│ └── 否 ──▶ allkeys-lru ★通用默认
│
├── 大部分都设置了 TTL ──────────────────────────────────▶ 选 volatile-*
│ │
│ 需要精细控制?
│ ├── 按访问频率 ──▶ volatile-lfu
│ ├── 按最近访问 ──▶ volatile-lru
│ └── 按过期时间 ──▶ volatile-ttl
│
└── 数据绝对不能丢(如分布式锁、强一致配置)────────────────▶ noeviction
(配合业务侧限流/告警)
生产配置推荐(通用缓存场景) :
perl
maxmemory 4gb
maxmemory-policy allkeys-lru
maxmemory-samples 10 # 提升近似精度,CPU 开销可接受
lfu-log-factor 10 # LFU 场景使用
lfu-decay-time 1 # LFU 场景使用,1分钟衰减一次
九、面试高频 Q&A
Q1:LRU 为什么用双向链表,而不是单向链表?
A :删除一个节点时需要找到它的前驱节点,单向链表找前驱需要 O(n) 遍历,双向链表通过 node.prev 直接获取,O(1)。所有操作(摘除、插入尾部)都需要知道前驱,所以双向链表是必须的。
Q2:LFU 的 minFreq 什么时候需要更新?
A:有两种情况:
- 删除操作(淘汰/覆盖写) :不需要更新 minFreq,因为总有其他 key。
- 原有桶变空且该桶就是 minFreq :
minFreq++。注意只能 +1,因为当前频率桶刚被移空,下一个最小频率一定是minFreq+1(不可能跳跃)。 - 插入新 key:minFreq 必须重置为 1,因为新 key 的初始频率是 1。
这是 LFU O(1) 实现的最容易写错的地方。
Q3:Redis 近似 LRU 和精确 LRU 差距大吗?
A :差距随采样数量而变化。默认 maxmemory-samples=5 时已经相当接近精确 LRU;设为 10 时几乎无法区分(Redis 官方测试数据)。代价仅是轻微的 CPU 开销,通常值得。
ini
精确LRU(理想): 必然淘汰最久未使用的那个
近似LRU (samples=5): 准确率约 85-90%
近似LRU (samples=10): 准确率约 97-98%
Q4:什么场景用 LFU 比 LRU 更合适?
A :当数据访问呈现明显的冷热分层时,LFU 更优。典型场景:
- 电商商品缓存:少数爆款商品占绝大多数访问
- 新闻 / 内容缓存:热点文章访问量远高于其他文章
- 配置缓存:少数核心配置被高频读取
LRU 更合适的场景:数据访问随时间线性推进(如时序数据、日志流),时间局部性强。
Q5:如何设计一个线程安全的带 TTL 的 LRU?
A:核心思路:
markdown
1. 在 Node 中增加 expireAt 字段(System.currentTimeMillis() + ttl)
2. get() 时检查:
if (System.currentTimeMillis() > node.expireAt) {
// 惰性删除(lazy deletion)
removeNode(node);
map.remove(key);
return null;
}
3. 线程安全:
- 读写锁(ReentrantReadWriteLock):get 用读锁,put 用写锁
- 或直接 synchronized(简单但并发度低)
- 高并发场景:参考 Caffeine 的 Striped Lock 或 ConcurrentHashMap 分段锁
4. 主动过期(可选):
- 后台定时线程扫描过期 key
- 注意:扫描时不要全表扫描,参考 Redis 的随机采样思路
彩蛋:Belady 最优算法
Belady(1966)证明了一个理论最优 的淘汰策略:每次淘汰下次最久才会被访问的那个 key。
css
访问序列:A B C A B D A B
缓存容量:2
最优策略(Belady):已知未来访问顺序,每次淘汰离下次被访问最远的
LRU:根据过去决策,近似最优
遗憾:Belady 需要"未来知识",只能作为评价其他算法的理论上界,
无法在实际系统中使用。
总结对比
scss
┌──────────┬──────────┬──────────┬──────────┬─────────────────────────┐
│ 算法 │ 时间复杂度│ 空间 │ 抗污染 │ 推荐使用场景 │
├──────────┼──────────┼──────────┼──────────┼─────────────────────────┤
│ FIFO │ O(1) │ 低 │ ✗ │ 简单队列场景 │
│ LRU │ O(1) │ 中 │ 一般 │ 通用,绝大多数场景 ★ │
│ LFU │ O(1) │ 中高 │ 好 │ 热点明显的业务 ★ │
│ LRU-K │ O(log n) │ 较高 │ 很好 │ 防扫描污染 │
│ ARC │ O(1) │ 高 │ 很好 │ 文件系统(ZFS) │
│ Random │ O(1) │ 低 │ N/A │ 分布式兜底策略 │
└──────────┴──────────┴──────────┴──────────┴─────────────────────────┘
工程实践中:大多数缓存场景首选 LRU(或 Redis 的 allkeys-lru),热点访问模式明显时升级为 LFU。过早优化淘汰算法往往不如先把缓存容量配置好,再根据监控指标(命中率、evicted_keys)调整策略。
如果这篇文章对你有帮助,欢迎点赞收藏~ 有疑问或想深挖某个点的,评论区见!