mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
2451 字
7 分钟
LRU 缓存(LRU Cache)
2026-06-13

一、为什么需要 LRU 缓存#

假设你在写一个数据库查询层,每次查询耗时 200ms。为了加速重复查询,你加了一层内存缓存——用哈希表存结果,命中就直接返回。问题来了:内存不是无限的。当缓存条目越来越多,迟早要撑爆内存。你需要一个淘汰策略:满了之后,删谁?

最简单的做法是随机删一个,但可能刚好删掉马上还要用的热点数据。也可以删最早插入的(FIFO),但一个频繁访问的老条目会被新条目挤走,哪怕它下一秒还要用。你需要一种策略,让「最近被访问过」的条目留下来,把「最久没被访问」的条目淘汰掉——这就是 LRU(Least Recently Used,最近最少使用)。

LRU 的核心假设是局部性原理:刚被访问的数据,短期内再次被访问的概率更高。这个假设在大多数场景下成立——数据库的热点查询、浏览器的页面缓存、操作系统的页面置换,都遵循这个规律。

二、现实类比#

想象你有一张不大的办公桌,只能同时放五六本书。桌上已经摆满了,你又要查一本新书。怎么办?你会把桌上「最久没翻过」的那本放回书架,腾出位置放新书。每次你翻阅桌上的某本书,它就自动变成「最近用过」——不会被优先放回书架。

这个日常行为就是 LRU:

  • 办公桌 = 固定容量的缓存
  • 书架 = 后端存储
  • 翻阅一本书 = get 操作,这本书变成「最近使用」
  • 放一本新书 = put 操作,如果满了就淘汰「最久没翻」的那本
  • 淘汰 = 把最久没翻的书放回书架

关键在于:你不需要记住每本书被翻了多少次,只需要知道「最后一次翻阅的先后顺序」。这比 LFU(按频率淘汰)简单得多,也正因如此,LRU 可以做到 O(1) 的查询和插入。

三、核心思想#

LRU 缓存需要两个操作都达到 O(1):

  1. 按 key 查找 → 哈希表
  2. 维护访问顺序 → 双向链表

哈希表负责 O(1) 的键值查找,双向链表负责 O(1) 的顺序调整。两者组合,就是经典的 LRU 实现。

flowchart LR subgraph 哈希表 H1["key:A → node1"] H2["key:B → node2"] H3["key:C → node3"] end subgraph 双向链表 HEAD["head"] --> N1["A(最近)"] N1 --> N2["B"] N2 --> N3["C(最久)"] N3 --> TAIL["tail"] end H1 -.-> N1 H2 -.-> N2 H3 -.-> N3

操作流程#

get(key):哈希表找到节点 → 把节点移到链表头部 → 返回值。如果没找到,返回 -1。

put(key, value)

  • key 已存在:更新值 → 移到链表头部
  • key 不存在:创建新节点插到链表头部 → 哈希表记录映射 → 如果超容量,删除链表尾部节点(最久未用)→ 同时删除哈希表中对应条目
flowchart TD A["get/put 访问 key"] --> B{哈希表中存在?} B -- 是 --> C["获取链表节点"] C --> D["移到链表头部"] B -- 否 --> E["put: 创建新节点"] E --> F["插入链表头部"] F --> G{超过容量?} G -- 是 --> H["删除链表尾部节点"] H --> I["删除哈希表对应条目"] G -- 否 --> J["完成"] I --> J D --> J

复杂度#

操作时间复杂度说明
getO(1)哈希表查找 + 链表移动
putO(1)哈希表插入 + 链表插入,可能 O(1) 淘汰
空间O(n)n 为缓存容量,每个条目存一份哈希表条目 + 链表节点

双向链表之所以比单向链表关键,是因为删除节点需要知道前驱节点。单向链表找前驱要 O(n),双向链表直接 O(1)。

四、变体与对比#

LRU 不是唯一的淘汰策略。不同场景需要不同的取舍:

策略淘汰依据优点缺点典型场景
LRU最久未访问实现简单,局部性好扫描破坏缓存,每条目多 2 个指针通用缓存、页面置换
LFU访问频率最低保留真正热点频率计数开销大,历史热点难淘汰稳定热点数据
ARCLRU + 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

  1. 每个 key 头部有一个 24 位的 lru_clock 字段,记录最后一次访问时间(精度约 1.6 秒)
  2. 需要淘汰时,随机采样 maxmemory-samples 个 key(默认 5),选 idle 时间最长的淘汰
  3. 采样 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/listMoveToFrontPushFrontBackRemove 都是 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);
}
}

Mapkeys() 迭代器按插入顺序遍历,第一个就是最久没更新的条目。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 缓存(LRU Cache)
https://blog.souloss.com/posts/programming/data-structures/data-structures-lru-cache/
作者
Tsukimi
发布于
2026-06-13
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时