一、为什么需要 LRU 缓存
假设你在写一个数据库查询层,每次查询耗时 200ms。为了加速重复查询,你加了一层内存缓存——用哈希表存结果,命中就直接返回。问题来了:内存不是无限的。当缓存条目越来越多,迟早要撑爆内存。你需要一个淘汰策略:满了之后,删谁?
最简单的做法是随机删一个,但可能刚好删掉马上还要用的热点数据。也可以删最早插入的(FIFO),但一个频繁访问的老条目会被新条目挤走,哪怕它下一秒还要用。你需要一种策略,让「最近被访问过」的条目留下来,把「最久没被访问」的条目淘汰掉——这就是 LRU(Least Recently Used,最近最少使用)。
LRU 的核心假设是局部性原理:刚被访问的数据,短期内再次被访问的概率更高。这个假设在大多数场景下成立——数据库的热点查询、浏览器的页面缓存、操作系统的页面置换,都遵循这个规律。
二、现实类比
想象你有一张不大的办公桌,只能同时放五六本书。桌上已经摆满了,你又要查一本新书。怎么办?你会把桌上「最久没翻过」的那本放回书架,腾出位置放新书。每次你翻阅桌上的某本书,它就自动变成「最近用过」——不会被优先放回书架。
这个日常行为就是 LRU:
- 办公桌 = 固定容量的缓存
- 书架 = 后端存储
- 翻阅一本书 =
get操作,这本书变成「最近使用」 - 放一本新书 =
put操作,如果满了就淘汰「最久没翻」的那本 - 淘汰 = 把最久没翻的书放回书架
关键在于:你不需要记住每本书被翻了多少次,只需要知道「最后一次翻阅的先后顺序」。这比 LFU(按频率淘汰)简单得多,也正因如此,LRU 可以做到 O(1) 的查询和插入。
三、核心思想
LRU 缓存需要两个操作都达到 O(1):
- 按 key 查找 → 哈希表
- 维护访问顺序 → 双向链表
哈希表负责 O(1) 的键值查找,双向链表负责 O(1) 的顺序调整。两者组合,就是经典的 LRU 实现。
操作流程
get(key):哈希表找到节点 → 把节点移到链表头部 → 返回值。如果没找到,返回 -1。
put(key, value):
- key 已存在:更新值 → 移到链表头部
- key 不存在:创建新节点插到链表头部 → 哈希表记录映射 → 如果超容量,删除链表尾部节点(最久未用)→ 同时删除哈希表中对应条目
复杂度
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
get | O(1) | 哈希表查找 + 链表移动 |
put | O(1) | 哈希表插入 + 链表插入,可能 O(1) 淘汰 |
| 空间 | O(n) | n 为缓存容量,每个条目存一份哈希表条目 + 链表节点 |
双向链表之所以比单向链表关键,是因为删除节点需要知道前驱节点。单向链表找前驱要 O(n),双向链表直接 O(1)。
四、变体与对比
LRU 不是唯一的淘汰策略。不同场景需要不同的取舍:
| 策略 | 淘汰依据 | 优点 | 缺点 | 典型场景 |
|---|---|---|---|---|
| LRU | 最久未访问 | 实现简单,局部性好 | 扫描破坏缓存,每条目多 2 个指针 | 通用缓存、页面置换 |
| LFU | 访问频率最低 | 保留真正热点 | 频率计数开销大,历史热点难淘汰 | 稳定热点数据 |
| ARC | LRU + LFU 自适应 | 扫描抗性好,自适应 | 实现复杂,需维护两个链表 | 数据库缓冲池 |
| FIFO | 最早进入 | 最简单,零额外开销 | 不考虑访问模式,命中率低 | 简单队列场景 |
扫描抗性问题
LRU 有一个经典弱点:扫描破坏。假设缓存容量 100,已经装满热点数据。一个爬虫依次访问了 200 个冷页面,每个页面只访问一次。LRU 会把所有热点条目逐个挤走,缓存里最后只剩下 100 个再也不会被访问的冷页面。这就是「一次扫描毁掉全部热点」。
解决方案:
- LRU-K:要求被访问 K 次才进入 LRU 链表,过滤掉一次性扫描
- ARC(Adaptive Replacement Cache):同时维护最近条目和频繁条目两个链表,自动调整比例
- Redis 近似 LRU:随机采样 N 个 key,淘汰其中最久未用的,避免维护全局链表
Redis 的近似 LRU
Redis 没有给每个 key 维护双向链表指针——那要多花 16 字节(两个 64 位指针)per key。它用的是近似 LRU:
- 每个 key 头部有一个 24 位的
lru_clock字段,记录最后一次访问时间(精度约 1.6 秒) - 需要淘汰时,随机采样
maxmemory-samples个 key(默认 5),选 idle 时间最长的淘汰 - 采样 10 个 key 的效果已经接近精确 LRU,但省掉了全局链表的开销
这种「用随机采样换精确排序」的思路,在分布式系统中很常见——用少量随机性换取可观的内存节省。
五、多语言实现
Go(使用 container/list)
Go 标准库的 container/list 就是双向链表,配合 map 即可实现 LRU:
package lru
import "container/list"
type entry struct { key string value any}
type Cache struct { capacity int items map[string]*list.Element order *list.List}
func New(capacity int) *Cache { return &Cache{ capacity: capacity, items: make(map[string]*list.Element), order: list.New(), }}
func (c *Cache) Get(key string) (any, bool) { if elem, ok := c.items[key]; ok { c.order.MoveToFront(elem) // 访问后移到头部 return elem.Value.(*entry).value, true } return nil, false}
func (c *Cache) Put(key string, value any) { if elem, ok := c.items[key]; ok { c.order.MoveToFront(elem) elem.Value.(*entry).value = value return } // 新条目插入头部 elem := c.order.PushFront(&entry{key: key, value: value}) c.items[key] = elem // 超容量则淘汰尾部 if c.order.Len() > c.capacity { oldest := c.order.Back() c.order.Remove(oldest) delete(c.items, oldest.Value.(*entry).key) }}container/list 的 MoveToFront、PushFront、Back、Remove 都是 O(1) 操作。链表节点里存了 key,是为了淘汰时能反向删除哈希表条目——这是 LRU 实现中容易遗漏的细节。
TypeScript(利用 Map 的插入顺序)
JavaScript 的 Map 会按插入顺序迭代。利用这个特性,可以用 delete + 重新 set 来模拟「移到头部」:
class LRUCache<K, V> { private capacity: number; private cache: Map<K, V>;
constructor(capacity: number) { this.capacity = capacity; this.cache = new Map(); }
get(key: K): V | undefined { if (!this.cache.has(key)) return undefined; // 删除再插入,移到迭代顺序末尾(即"最近"端) const value = this.cache.get(key)!; this.cache.delete(key); this.cache.set(key, value); return value; }
put(key: K, value: V): void { if (this.cache.has(key)) { this.cache.delete(key); // 已存在则先删除 } else if (this.cache.size >= this.capacity) { // 淘汰最久未用:迭代器的第一个元素即最早插入的 const oldest = this.cache.keys().next().value; this.cache.delete(oldest); } this.cache.set(key, value); }}Map 的 keys() 迭代器按插入顺序遍历,第一个就是最久没更新的条目。delete + set 把条目移到末尾,末尾就是「最近」端。这个实现不需要手写链表,但 delete + set 涉及两次哈希表操作,比原生双向链表的指针操作稍慢。在大多数业务场景下,这点差异可以忽略。
六、生产验证
Go groupcache LRU
Brad Fitzpatrick(memcached 创始人)在 groupcache 中实现了一个精简的 LRU 缓存,代码不到 100 行。它用 container/list + map 的经典组合,OnEvicted 回调支持淘汰时的资源清理。
- 项目:golang/groupcache
- 用途:groupcache 是一个分布式缓存库,LRU 用于单个节点的本地缓存淘汰
Redis 近似 LRU 淘汰
Redis 没有使用精确 LRU,而是用随机采样实现近似 LRU。evict.c 中的 evictionPoolPopulate 函数随机采样 N 个 key,按 idle 时间排序后选出最佳淘汰候选。默认采样 5 个 key,调到 10 就能接近精确 LRU 的效果。
- 项目:redis/redis
- 用途:Redis 内存达到
maxmemory限制时的 key 淘汰策略
Linux 内核页面置换
Linux 内核的页面置换也用了 LRU 的变体。它维护 active 和 inactive 两个链表(类似 ARC 的思路),页面在 inactive 链表被再次访问时晋升到 active 链表,active 链表末尾的页面降级回 inactive。这种双链表设计天然抗扫描——一次性扫描的页面只会进入 inactive 链表,不会挤走 active 链表的热点。
- 项目:torvalds/linux
- 用途:物理内存页面的换入换出决策
七、小结
何时使用 LRU 缓存
- 数据库查询缓存:热点 SQL 结果集缓存,避免重复查询
- DNS 解析缓存:域名到 IP 的映射,近期访问的域名大概率再访问
- 浏览器资源缓存:HTTP 响应、图片、JS/CSS 文件,最近浏览的页面资源优先保留
- API 响应缓存:高频接口的返回值缓存,降低后端压力
- OS 页面/目录项/索引节点缓存:内核的 dentry cache 和 inode cache 都基于 LRU 变体
何时不要使用 LRU 缓存
- 扫描密集型负载:爬虫、全表扫描等一次性遍历会冲掉所有热点,改用 LRU-K 或 ARC
- 需要按时间过期:LRU 只管访问顺序,不管 TTL。如果需要「超过 10 分钟自动失效」,要在 LRU 之上加 TTL 层
- 访问频率比访问时序更重要:少数条目被高频访问、大量条目偶尔访问时,LFU 保留热点更稳定
- 不需要淘汰策略:数据量可控、内存充裕时,简单哈希表就够了,不必引入 LRU 的复杂度
八、参考资料
- LRU Cache - LeetCode 146 - 经典面试题,哈希表 + 双向链表的标准实现
- groupcache/lru.go - Brad Fitzpatrick 的 Go LRU 实现,简洁清晰
- Redis evict.c - Redis 近似 LRU 淘汰策略源码,随机采样替代全局链表
- The LRU-K Page Replacement Algorithm - O’Neil et al., 1993, LRU-K 算法原始论文
- ARC: A Self-Tuning, Low Overhead Replacement Cache - Megiddo & Modha, 2003, IBM 的自适应替换缓存论文
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






