专栏文章
专栏文章
缓存算法系列
1. 缓存算法 #01:LRU(最近最少使用) 2. 缓存算法 #02:LFU(最少使用频率) 3. 缓存算法 #03:Clock 与 CLOCK-Pro 4. 缓存算法 #04:ARC(自适应替换缓存) 5. 缓存算法 #05:W-TinyLFU 与 Caffeine 6. 缓存算法 #06:MySQL Buffer Pool 与 midpoint LRU 7. 缓存算法 #07:Redis LRU 近似算法

缓存算法 #01:LRU(最近最少使用)

发布于 2026-06-02 02:21 👁 8 次阅读
#算法#data-structure#缓存#lru#engineering

LRU(Least Recently Used)是最经典的缓存淘汰算法:当缓存满时,驱逐最长时间未被访问的条目。本文从数据结构设计出发,推导出 O(1) 的实现,并深入分析 LRU 在工程中的边界情况与局限性。

相关文章LFU(最少使用频率) · ARC(自适应替换缓存) · W-TinyLFU 与 Caffeine · MySQL Buffer Pool 与 midpoint LRU


目录

章节 说明
问题定义 缓存的本质约束
数据结构设计 为什么需要 HashMap + 双向链表
完整伪代码 get / put 的每一步操作
执行追踪 逐步演示状态变化
复杂度分析 时间与空间
异常场景 缓存污染 / 并发 / 容量为 1
LRU 的局限 何时 LRU 会失效
工程实现参考 Java LinkedHashMap / Redis

问题定义

设计一个固定容量 capacity 的缓存,支持:

get(key)        → 返回 value(若存在),否则返回 -1
put(key, value) → 插入或更新 key-value;
                  若容量已满,先驱逐最近最少使用的条目

两个核心约束

  1. getput 都必须是 O(1)
  2. 每次 getput 访问一个 key,该 key 变为"最近使用"

数据结构设计

为什么单用数组或链表不够

方案1:数组(按访问时间排序)
  get(key):遍历找 key,O(N)  ← 不满足 O(1)

方案2:HashMap
  get(key):O(1) ✅
  但无法高效找到"最久未用"的 key(需要额外排序)

方案3:HashMap + 双向链表 ← 正确答案
  HashMap:key → 链表节点(O(1) 定位)
  双向链表:维护访问顺序(最近用的在头部,最久未用的在尾部)
  节点删除:O(1)(有前驱和后继指针,无需遍历)

数据结构

lru structure

// 双向链表节点
Node {
    key:  int
    val:  int
    prev: Node   // 指向前一个节点
    next: Node   // 指向后一个节点
}

// LRU 缓存
LRUCache {
    capacity: int
    size:     int                  // 当前存储的条目数
    map:      HashMap<int, Node>   // key → 节点指针(O(1) 查找)
    head:     Node                 // 哨兵头节点(最近使用端)
    tail:     Node                 // 哨兵尾节点(最久未用端)
}

// 初始化:head <-> tail(空链表,head 和 tail 互指)
init(capacity):
    self.capacity = capacity
    self.size     = 0
    self.map      = {}
    head.next = tail
    tail.prev = head

哨兵节点的作用:避免在头尾操作时判断空链表,简化代码。


完整伪代码

辅助操作

// 将节点插入到链表头部(最近使用端)
addToHead(node):
    node.prev = head
    node.next = head.next
    head.next.prev = node   // 原第一个节点的 prev 指向 node
    head.next = node        // head 的 next 指向 node

// 从链表中删除节点(O(1),利用双向指针)
removeNode(node):
    node.prev.next = node.next
    node.next.prev = node.prev
    // node.prev 和 node.next 保留,但已从链表断开

// 移动节点到头部(= 先删除,再插入头部)
moveToHead(node):
    removeNode(node)
    addToHead(node)

// 删除尾部节点(最久未用的节点)并返回
removeTail():
    node = tail.prev        // 真正的最后一个节点(tail 是哨兵)
    removeNode(node)
    return node

get(key)

get(key):
    if key not in map:
        return -1

    node = map[key]
    moveToHead(node)        // 标记为"最近使用"
    return node.val

put(key, value)

put(key, value):
    if key in map:
        // key 已存在:更新值,移到头部
        node = map[key]
        node.val = value
        moveToHead(node)
    else:
        // key 不存在:新建节点
        node = Node(key, value)
        map[key] = node
        addToHead(node)
        size += 1

        if size > capacity:
            // 驱逐最久未用的节点(链表尾部)
            evicted = removeTail()
            del map[evicted.key]
            size -= 1

执行追踪

capacity = 3,依次执行:put(1,A) put(2,B) put(3,C) get(1) put(4,D) get(2)

初始:head ↔ tail   map={}

put(1,A):
  map = {1:N1}
  链表:head ↔ [1,A] ↔ tail    (N1 在头部)

put(2,B):
  map = {1:N1, 2:N2}
  链表:head ↔ [2,B] ↔ [1,A] ↔ tail

put(3,C):
  map = {1:N1, 2:N2, 3:N3}
  链表:head ↔ [3,C] ↔ [2,B] ↔ [1,A] ↔ tail
  (缓存已满)

get(1):
  找到 N1,moveToHead(N1)
  链表:head ↔ [1,A] ↔ [3,C] ↔ [2,B] ↔ tail
  返回 A ✅

put(4,D):
  新建 N4,addToHead(N4),size=4 > capacity=3
  → removeTail() 驱逐 [2,B](最久未用)
  del map[2]
  链表:head ↔ [4,D] ↔ [1,A] ↔ [3,C] ↔ tail
  map = {1:N1, 3:N3, 4:N4}

get(2):
  key=2 不在 map → 返回 -1  (已被驱逐)

复杂度分析

操作 时间复杂度 原因
get O(1) HashMap 定位节点 + 链表节点移动(有双向指针)
put O(1) 同上,驱逐也是 O(1)
空间 O(capacity) HashMap + 链表各存 capacity 个节点

异常场景

并发访问(多线程)

问题:get 和 put 都修改链表,并发时产生竞争

Java 的解法:
  方案1:synchronized 整个方法(简单,高并发时锁竞争严重)
  方案2:分段锁(将缓存按 key hash 分桶,各桶独立加锁)
  方案3:Caffeine 使用 CAS + 写缓冲区(无锁或细粒度锁),性能最优

Go 的解法:
  用 sync.RWMutex,get 加读锁,put 加写锁
  注意:get 虽然是"读",但会修改链表(moveToHead),必须加写锁!

缓存穿透(key 永远不存在)

问题:get("不存在的key") 每次都穿透到数据库,缓存没有防护作用

解法:
  缓存空值:put(key, NULL_SENTINEL),TTL 设短(如 30s)
  Bloom Filter 前置拦截:先查 Bloom Filter,不存在则直接返回

容量为 1 的特殊情况

capacity = 1,put(1,A) put(2,B) get(1):

put(1,A):链表 [1,A],map={1}
put(2,B):链表 [2,B],驱逐 [1,A],map={2}
get(1):  key=1 不在 map → 返回 -1

容量为 1 时,每次 put 不同 key 都会驱逐前一个,缓存命中率为 0
说明 LRU 在容量极小时退化严重

put 相同 key(更新操作)

put(1,A),put(1,B)(相同 key,不同 value):

第一次:创建节点 [1,A],size=1
第二次:key 已在 map,走更新分支
         node.val = B,moveToHead(node)
         size 不变(没有新增节点)

注意:更新已有 key 时不能走"新建 + 驱逐"分支,否则 size 计数错误

LRU 的局限

问题 1:缓存污染(Cache Pollution)

场景:热点数据是 key=1~10,缓存容量=10
  → 长期稳定命中 key=1~10

突然执行一次全表扫描,顺序访问 key=1~1000:
  → key=1~1000 全部被 put 进缓存
  → key=1~10 全被驱逐(被后来的 key 挤走)

扫描结束后,再访问热点 key=1~10:全部 miss!
缓存需要重新预热 → 短暂的缓存雪崩

根本原因:LRU 只看"最近一次"访问时间,不看访问频率。一次扫描就能驱逐高频热点。

问题 2:时间局部性假设失效

LRU 隐含假设:最近用过的数据,将来大概率还会用

反例:视频流缓存
  用户顺序播放视频片段,每个片段只播一次
  最近访问的片段是"刚播完就再也不需要的"
  → LRU 缓存命中率极低(每次都是最新的,永远不复用)

更适合的算法:LIRS、ARC、W-TinyLFU

工程实现参考

Java LinkedHashMap(内置 LRU)

// accessOrder=true 表示按访问顺序排序(LRU 模式)
// removeEldestEntry 决定是否驱逐
class LRUCache<K, V> extends LinkedHashMap<K, V> {
    private final int capacity;

    LRUCache(int capacity) {
        super(capacity, 0.75f, true);  // accessOrder=true
        this.capacity = capacity;
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        return size() > capacity;  // 超过容量时驱逐最老的
    }
}
// get/put 直接继承 LinkedHashMap,O(1)

Redis 的 LRU

Redis 使用近似 LRU(非精确),每个对象记录一个 24 bit 的 lru_clock(秒级时间戳)。驱逐时随机采样若干 key,选其中 lru_clock 最小的驱逐。详见 Redis LRU 近似算法


参考资料

  • LeetCode 146. LRU Cache
  • Redis 对象结构:redisObject.lru
  • Java LinkedHashMap 源码
← 返回列表

评论 (0)

暂无评论,来留下第一条吧。

发表评论