缓存淘汰策略全解:从原理到手写实现(Java / Go / Python)

面试被问到 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) 的 getput,需要同时满足:

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:有两种情况:

  1. 删除操作(淘汰/覆盖写) :不需要更新 minFreq,因为总有其他 key。
  2. 原有桶变空且该桶就是 minFreqminFreq++。注意只能 +1,因为当前频率桶刚被移空,下一个最小频率一定是 minFreq+1(不可能跳跃)。
  3. 插入新 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)调整策略。


如果这篇文章对你有帮助,欢迎点赞收藏~ 有疑问或想深挖某个点的,评论区见!

相关推荐
AI人工智能+电脑小能手2 小时前
【大白话说Java面试题】【Java基础篇】第29题:静态代理和动态代理的区别是什么
java·开发语言·后端·面试·代理模式
dovens3 小时前
SpringBoot集成MQTT客户端
java·spring boot·后端
❀͜͡傀儡师3 小时前
Spring Boot 集成 RocksDB 实战:打造高性能 KV 存储加速层
java·spring boot·后端·rocksdb
TeamDev3 小时前
如何在 DotNetBrowser 中使用本地 AI 模型
前端·后端·.net
Rust语言中文社区3 小时前
【Rust日报】2026-05-02 Temper - 用 Rust 编写的 Minecraft 服务器项目发布 0.1.0 版
运维·服务器·开发语言·后端·rust
陈随易4 小时前
2年没用Nodejs了,Bun很香
前端·后端·程序员
knight_9___4 小时前
LLM工具调用面试篇5
人工智能·python·深度学习·面试·职场和发展·llm·agent
用户9416146933654 小时前
Python 实时监控 A 股行情并自动筛选强势股(REST + WebSocket 两种方案)
后端·数据分析