1. 从缓存淘汰说起:LRU 到底解决的是什么问题
聊 LRU 之前,我先讲个特别接地气的场景。你家里有个鞋柜,只能放十双鞋,但你有一百双鞋。每次出门穿哪双,脱下来就往柜子里塞。塞满了怎么办?要么把最久没穿过的那双扔了,要么把最近刚穿过的扔掉。正常人都会做第一个选择——把"最久没碰过的"清出去,因为那双鞋大概率你近期也不需要了。LRU 算法(Least Recently Used,最近最少使用)干的就是这么回事,只不过它管的不是鞋,是内存里的数据。
在计算机系统里,内存和缓存永远不够用。CPU 有寄存器、L1/L2/L3 缓存,容量一级比一级小、速度一级比一级快。操作系统管理物理内存的时候,不可能把所有进程的数据都同时塞进内存条。数据库查询热数据的时候,也不可能把整张表都常驻内存。这就必然引出一个问题:当缓存空间满了,新的数据要进来,得请谁出去?这就是所谓的"淘汰策略",而 LRU 是这里面应用最广、也最经得起实战考验的一种。
1.1 为什么是"最近最少使用"而不是别的
淘汰策略其实有好几种,比较常见的还有 FIFO(先进先出)和 LFU(最不经常使用)。FIFO 顾名思义,谁先进来的谁先滚蛋,简单粗暴,但它有个致命缺陷:它不考虑数据的使用频率。想象一个场景,你在做一个网页服务,首页的配置数据是访问最频繁的,但它在系统启动时是最早加载的那批。如果按 FIFO 淘汰,这个最热的配置数据反而会最先被踢出去,然后下次访问又要重新加载,然后又最早进来,又被最早踢出去——循环往复,简直灾难。
LFU 是按访问次数来淘汰,听起来更科学,但它的问题在于"历史包袱"。一个数据可能曾经被访问过一万次,但现在早就不用了,LFU 还是会把它当宝贝供着,因为它次数高。而且 LFU 需要给每个数据维护一个计数器,开销也不小。
LRU 的哲学就很有意思了:它赌的是"局部性原理"。什么意思?就是如果一个数据刚刚被访问过,那么它在不久的将来被再次访问的概率非常高。反过来,如果一个数据很久没被碰过了,那它未来被访问的概率就很低。这个假设在很多真实场景下都成立——你看视频,刚看过的片段可能还要回看;你查数据库,同一批热点商品数据会被反复查询。所以 LRU 淘汰"最久没用过的",实际上是在用最小代价保住最可能被用到的数据。
注意:LRU 的核心假设是访问的"时间局部性",它不保证绝对最优。如果你的访问模式是随机的、没有局部性,LRU 的命中率可能还不如一些更简单的策略。所以在选型前,先想清楚你的业务访问模式。
1.2 LRU 在真实系统里的位置
你平时可能没直接写过 LRU,但你几乎每天都在用它。操作系统的页面置换,Linux 内核里那一套 active/inactive 链表,本质就是 LRU 的变体;MySQL 的 InnoDB Buffer Pool 用的近似 LRU,通过分代优化来避免全表扫描污染缓存;Redis 的maxmemory-policy里就有allkeys-lru和volatile-lru两种模式;各种 HTTP 客户端、CDN、浏览器缓存,背后也都有 LRU 的影子。
所以当面试官问你"手写一个 LRU"的时候,他不是在考你背书,他是在看你对"数据结构 + 缓存思想"的理解深度。这个题之所以经典,是因为它逼着你在时间复杂度和空间复杂度之间做权衡,而做权衡恰恰是工程的核心。
2. 核心设计拆解:为什么必须是哈希表 + 双向链表
很多人第一次面对"设计一个 O(1) 的 LRU"时会懵。直觉上,我需要两件事:第一,我要能快速找到某个 key 对应的数据在哪里(查找快);第二,我要能快速知道谁是最久没用的,并且能快速把它删掉、把新来的加进去(更新快)。
单靠一个数组不行,删除中间元素要移动后面所有元素,O(n)。单靠一个单向链表也不行,你找到某个节点之后,想把它移到链表头,但单向链表拿不到它的前驱节点,还是要从头遍历,O(n)。单靠一个哈希表更不行,哈希表能让你 O(1) 找到数据,但它没法告诉你"谁最久没用"。
所以答案就是组合拳:哈希表负责"找得到",双向链表负责"排得序"。
2.1 哈希表在这里扮演什么角色
哈希表(在 Java 里是 HashMap,Python 里是 dict,C++ 里是 unordered_map)存的是 key 到链表节点的映射。这里有个细节新手容易踩坑:哈希表里存的 value 不是数据本身,而是指向双向链表节点的引用(指针)。
为什么要这么设计?因为当你要访问某个 key 的时候,你希望 O(1) 就能定位到它在链表里的那个节点,然后直接把这个节点从当前位置"摘"下来,移到链表头部,标记为"最近使用"。如果你存的是数据值而不是节点引用,你就得拿着 key 再去链表里遍历找节点,那又退化成 O(n) 了。
实操心得:很多教程图省事,哈希表直接存 value,然后在淘汰时遍历链表找"最旧"的那个——这在小数据量下看着没问题,一旦数据量上来,性能直接崩盘。LRU 的精髓就在这个"节点引用",别省这一步。
2.2 双向链表为什么不是单向链表
双向链表在这里的职责是维护"使用顺序"。我们约定:靠近头部的节点是最近使用的,靠近尾部的节点是最久未使用的。
- 访问一个数据:把对应节点移到头部。
- 淘汰数据:把尾部节点删掉。
- 插入新数据:放到头部。
这里面有个关键动作叫"把任意节点移到头部"。如果链表是单向的,你要删除一个节点,必须知道它的前驱节点,而单向链表只能从头往后找,这就是 O(n) 的根源。双向链表每个节点都有prev和next两个指针,不管这个节点在哪个位置,你都能直接拿到它的前驱和后继,摘除和插入都是常数时间。
为了代码写起来不恶心,工业实现里通常会引入虚拟头节点(dummy head)和虚拟尾节点(dummy tail)。这两个节点不存真实数据,只是哨兵。好处是:你永远不用判断"是不是空链表""是不是在头部插入""是不是在尾部删除"这些边界情况,所有操作都能用统一的逻辑处理,代码会干净很多,bug 也少很多。
2.3 各操作的时间复杂度账
我们把账算清楚:
| 操作 | 做法 | 时间复杂度 |
|---|---|---|
| 查找 key | 哈希表定位 | O(1) |
| 访问数据 | 哈希表定位 + 链表节点移到头部 | O(1) |
| 插入新数据 | 存入哈希表 + 插入链表头部 | O(1) |
| 淘汰数据 | 删除链表尾部节点 + 删哈希表项 | O(1) |
| 空间复杂度 | 哈希表 O(n) + 链表 O(n) | O(n) |
这就是 LRU 的精髓所在:用额外的 O(n) 空间,换取所有核心操作 O(1) 的时间。在缓存这个场景里,空间本来就是要用来存数据的,所以这份额外开销(哈希表存指针、链表节点存指针)完全可以接受。
2.4 为什么不用现成的有序结构
有人会问,Java 里不是有LinkedHashMap吗,它可以设置accessOrder=true,天生就是 LRU。没错,LinkedHashMap底层就是"哈希表 + 双向链表",和我们手写的结构一模一样。那手写还有什么意义?
第一,你得理解它,否则遇到需要定制版本(比如加过期时间、加权重、加分段)的时候你就抓瞎。第二,LinkedHashMap的 LRU 是全局锁的,高并发下性能很差,真到生产环境,你往往需要自己实现一套带分片加锁或者无锁的结构。第三,面试和笔试它是刚需,躲不掉。所以我一直建议:先手写一遍理解原理,再在合适的场景用现成实现,需要性能时自己优化。
3. 手写实现:从零到可运行的完整代码
光说不练假把式。我用 Python 写一版最清晰的,再给一版 C++ 的,最后给一版 Java 用LinkedHashMap的极简版,方便不同语言的读者直接抄作业。
3.1 双向链表节点的定义
节点是基础。我们把它设计得简单点:
class Node: def __init__(self, key=0, value=0): self.key = key # 存 key 是为了淘汰时能反查哈希表 self.value = value self.prev = None self.next = None这里有个容易被忽略的细节:节点里为什么要存 key?因为淘汰尾部节点的时候,我们不仅要把这个节点从链表删掉,还要把哈希表里对应的映射删掉,否则哈希表就会泄漏。要删哈希表,你就得有 key,而尾部节点只有 value 没有 key,你就找不到哈希表里那项。所以节点必须存 key。这是新手最常踩的坑之一,代码跑起来发现数据对不上,八成就是这里出了问题。
3.2 完整 LRU 实现(Python 版)
class LRUCache: def __init__(self, capacity: int): self.capacity = capacity self.cache = {} # key -> Node # 虚拟头尾节点,简化边界处理 self.head = Node() self.tail = Node() self.head.next = self.tail self.tail.prev = self.head def _remove(self, node): """把节点从链表中摘除""" node.prev.next = node.next node.next.prev = node.prev def _add_to_head(self, node): """把节点插入到头部(最近使用的位置)""" node.next = self.head.next node.prev = self.head self.head.next.prev = node self.head.next = node def _move_to_head(self, node): self._remove(node) self._add_to_head(node) def get(self, key: int) -> int: if key not in self.cache: return -1 node = self.cache[key] self._move_to_head(node) return node.value def put(self, key: int, value: int) -> None: if key in self.cache: node = self.cache[key] node.value = value self._move_to_head(node) else: node = Node(key, value) self.cache[key] = node self._add_to_head(node) if len(self.cache) > self.capacity: # 淘汰尾部节点(最久未使用) lru = self.tail.prev self._remove(lru) del self.cache[lru.key]这段代码我建议你对着敲一遍,尤其是_remove和_add_to_head这两个操作里指针的顺序。指针操作是 LRU 实现里最容易出 bug 的地方,顺序错了就会形成环或者断链。
操作禁忌:写指针操作的时候,一定要遵循"先接后断"的原则——先把新指针接好,再断开旧指针。上面
_add_to_head里,先设置node.next和node.prev,再修改self.head.next.prev和self.head.next,这个顺序不能乱,否则会丢失节点引用。
3.3 关键步骤的现场推演
我拿一组具体数据带你把流程走一遍,容量设为 2。
初始状态:链表空(head <-> tail)。
put(1, 1):新建节点1,挂到头部。链表变成head <-> 1 <-> tail,哈希表{1: node1}。
put(2, 2):新建节点2,挂到头部。链表变成head <-> 2 <-> 1 <-> tail,哈希表{1: node1, 2: node2}。
get(1):命中,把节点1移到头部。链表变成head <-> 1 <-> 2 <-> tail。返回 1。注意现在节点2变成了尾部,也就是"最旧"的。
put(3, 3):新建节点3,此时 size 会变成 3,超过容量 2。先挂节点3到头部,链表变成head <-> 3 <-> 1 <-> 2 <-> tail,然后淘汰尾部节点2,删链表和哈希表。最终head <-> 3 <-> 1 <-> tail,哈希表{1: node1, 3: node3}。
get(2):不在哈希表里,返回 -1。正确,因为2已经被淘汰了。
你把这几步在纸上画一画,整个 LRU 的动态就活了。很多人看代码觉得懂了,一画图发现理解是错的,这就是为什么要动手。
3.4 C++ 版本的核心骨架
C++ 里用std::list和std::unordered_map组合最省事,因为std::list天然支持 O(1) 的任意位置删除和转移。
class LRUCache { private: int cap; std::list<std::pair<int, int>> lst; // 头部最新,尾部最旧 std::unordered_map<int, std::list<std::pair<int, int>>::iterator> mp; public: LRUCache(int capacity) : cap(capacity) {} int get(int key) { auto it = mp.find(key); if (it == mp.end()) return -1; // 把命中的节点 splice 到头部 lst.splice(lst.begin(), lst, it->second); return it->second->second; } void put(int key, int value) { auto it = mp.find(key); if (it != mp.end()) { it->second->second = value; lst.splice(lst.begin(), lst, it->second); return; } if ((int)lst.size() == cap) { int oldKey = lst.back().first; lst.pop_back(); mp.erase(oldKey); } lst.emplace_front(key, value); mp[key] = lst.begin(); } };这里splice是std::list的杀手锏,它能把一个节点从链表的一个位置"剪切"到另一个位置,而且不涉及内存分配和拷贝,纯指针操作,效率极高。很多人不知道这个函数,用erase + push_front,虽然也对,但多了一次节点构造和析构,性能上有差距。
实操心得:C++ 的
std::list迭代器在splice之后依然有效(只要节点没被销毁),所以哈希表里存的迭代器不用更新。这个特性是很多面试官想考察的点,答对了加分。
3.5 Java 一行搞定的方式
如果你只是想快速用,Java 的LinkedHashMap是标准答案,重写removeEldestEntry即可:
class LRUCache extends LinkedHashMap<Integer, Integer> { private int capacity; public LRUCache(int capacity) { super(capacity, 0.75f, true); // accessOrder = true 开启 LRU 排序 this.capacity = capacity; } public int get(int key) { return super.getOrDefault(key, -1); } public void put(int key, int value) { super.put(key, value); } @Override protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) { return size() > capacity; } }注意构造函数第三个参数accessOrder必须传true,默认是false(插入顺序)。这一点坑过无数人,跑出来的行为是 FIFO 而不是 LRU,还找不到原因。
4. 进阶优化:真实生产环境里的 LRU 长什么样
标准 LRU 在教科书里很美好,但到了生产环境,它有几个硬伤。这一章我讲讲怎么把它改造成能扛住真实业务的版本,这些内容在普通教程里基本看不到。
4.1 并发环境下的加锁问题
单线程 LRU 没问题,但 Redis 那种高并发场景,多个线程同时读写链表,不加锁必崩。最粗暴的方案是给整个 LRU 加一把大锁,但这样所有读操作都串行化,吞吐量上不去。
工业界的做法通常是分段锁(sharding):把一个大的 LRU 拆成 N 个小 LRU,每个小 LRU 一把锁,访问时用 key 的哈希值决定去哪个分片。
import threading class ShardedLRUCache: def __init__(self, capacity, shard_count=16): self.shards = [ (LRUCache(capacity // shard_count), threading.Lock()) for _ in range(shard_count) ] self.shard_count = shard_count def get(self, key): idx = hash(key) % self.shard_count cache, lock = self.shards[idx] with lock: return cache.get(key) def put(self, key, value): idx = hash(key) % self.shard_count cache, lock = self.shards[idx] with lock: cache.put(key, value)这样做的好处是,只要 key 分布均匀,并发冲突概率大大降低。代价是每个分片独立淘汰,整体上不是严格的 LRU,但在这个量级下误差可以忽略,性能收益是值得的。
注意:分片数不是越多越好。分片太多,锁竞争是小了,但内存碎片、缓存局部性、管理开销都会上升。实践中一般取 2 的幂次,16 或 32 是比较稳的起点。
4.2 近似 LRU:Redis 为什么不用严格 LRU
Redis 官方文档里说得很清楚,它用的是近似 LRU(Approximate LRU)。为什么?因为维护一个严格的全局 LRU 链表,每次访问都要修改指针,在高频读写场景下,这个链表操作本身就是巨大的开销,而且严重限制并发。
Redis 的做法是:每个对象存一个lru_clock时间戳(精度是秒级或毫秒级),淘汰的时候随机采样若干个 key,从中挑出最久未使用的那个淘汰。这样不需要维护链表,淘汰时也不影响读写主流程。采样数量默认是 5,可以通过maxmemory-samples配置。
采样 5 个听起来很粗糙,但 Redis 官方做过压测:采样 10 个的时候,近似 LRU 的效果已经和严格 LRU 非常接近了。这就是工程上的经典取舍——用一点点精确性,换来了巨大的性能提升和并发能力。
4.3 LRU-K 与 2Q:解决缓存污染
标准 LRU 有个著名的问题叫缓存污染(cache pollution)。举个例子,你做数据库缓存,突然来了一次全表扫描,大量数据一次性涌入,瞬间把原本热点的数据全挤出去了。等全表扫描结束,热点数据全没了,缓存命中率断崖式下跌。
解决方案之一是LRU-K。它的思路是:记录每个数据最近的第 K 次访问时间,只有当访问次数达到 K 次时,才认为它是"热数据",才允许进入主缓存。这样偶尔访问一次的数据(比如全表扫描的数据)根本进不来,污染不了。
2Q(Two Queues)是另一个思路,维护两个队列:一个 FIFO 队列(用于过滤冷数据)和一个 LRU 队列(存热数据)。数据先进 FIFO,被第二次访问才移到 LRU。原理和 LRU-K 类似,实现更简单。
| 算法 | 核心思想 | 适用场景 | 代价 |
|---|---|---|---|
| 标准 LRU | 淘汰最久未使用 | 访问局部性强 | 易被偶发大量访问污染 |
| LRU-K | 访问达 K 次才入主缓存 | 有明显热点、抗污染 | 需维护访问计数,K 值难调 |
| 2Q | FIFO 过滤 + LRU 留存 | 抗扫描污染 | 结构复杂,内存开销大 |
| 近似 LRU | 采样淘汰 | 高并发、海量 key | 精度略降 |
实操心得:K 值怎么选?没有万能公式。太小(比如 2)过滤能力弱,太大(比如 5)会导致新热点迟迟进不来。我一般从 2 开始试,看命中率曲线,找到拐点。这东西必须靠业务数据调,别迷信理论值。
4.4 给缓存加过期时间和权重
真实业务里,光有 LRU 不够,经常还要叠加 TTL(生存时间)和权重。
TTL 好理解,每个节点加个过期时间戳,get的时候先检查是否过期,过期就当作未命中。但要注意,TTL 的清理策略也有讲究:惰性删除(访问时才检查)省 CPU 但浪费内存,定期删除(后台线程扫描)占 CPU 但内存干净。Redis 用的是两者结合。
权重是另一个维度。假设你的缓存里既有小对象(几 KB)又有大对象(几 MB),单纯按个数淘汰,一个大对象可能占着很多空间却只算一个名额,这不合理。所以有些系统会引入基于大小的淘汰(GDSF 等),让大对象在淘汰时"权重大",更容易被清出去。
这些改造在校招面试里基本不会问,但一旦你工作两三年,负责一个真实的缓存层,这些就是你必须考虑的东西。我见过太多项目,一开始用最简单的 LRU,跑着跑着内存爆了、命中率崩了,回过头来才发现是没考虑这些。
5. 常见问题排查与踩坑实录
这一章是我自己这些年踩过的坑,还有帮别人看代码时遇到的典型问题。LRU 本身不难,但细节特别多,一不留神就翻车。
5.1 高频 Bug 速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 淘汰后数据还能查到 | 淘汰时忘了删哈希表 / 节点没存 key | 检查put里的 cleanup 逻辑 |
| 命中率异常低 | accessOrder 没开,退化成 FIFO | 检查构造参数 |
| 链表出现环,死循环 | 指针操作顺序错误 | 检查_remove和_add顺序 |
get之后淘汰错了对象 | 没有把命中节点移到头部 | 检查get是否调用_move_to_head |
| 容量为 0 时报错 | 没处理边界情况 | 特判 capacity <= 0 |
| 更新已存在的 key 没生效 | 只更新了哈希表没更新节点 | 检查put的更新分支 |
5.2 一个我真实踩过的坑:节点没存 key
刚工作那会儿,我写了个 LRU 用在接口缓存上,测试环境跑得好好的,一上预发就出问题:缓存里已经有 100 条了,还在往里加,看起来容量限制完全没生效。查了半天才发现,我淘汰尾部节点的时候只删了链表节点,没删哈希表里的映射。而判断是否超容量是看哈希表大小,所以哈希表一直在涨,链表缩了但计数没减,两边对不上。
这个坑的教训就是:哈希表和链表是两份必须同步的数据结构,任何一方的增删都必须同步到另一方。后来我养成一个习惯,把"删链表 + 删哈希表"封装成一个原子方法,永远成对调用,不再分开写。这个小重构之后再没犯过类似的错。
5.3 命中率上不去的排查思路
如果你发现 LRU 命中率远低于预期,别急着换算法,先按这个顺序排查:
第一,确认访问模式有没有局部性。如果业务本身就是随机访问海量 key,那 LRU 天生就不适合,换什么算法都白搭,得考虑用别的方案或者加大容量。
第二,看有没有缓存污染。抓一段时间的访问日志,看看是不是有周期性的批量扫描把热点冲掉了。如果是,上 LRU-K 或 2Q。
第三,检查容量设置是否合理。容量太小,怎么淘汰都不够用。一般有个经验值,热点数据的总大小乘以 1.5 到 2 倍是比较舒服的容量。
第四,确认 key 的粒度。有时候一个大 key 里塞了几百个小字段,访问其中一个字段也要整个换入换出,命中率自然低。这时候应该考虑把 key 拆细。
5.4 面试答题的加分点
如果你是在准备面试,除了能写出 O(1) 的代码,我建议你主动聊这几点,面试官会觉得你真的理解而不是背题:
- 主动说明为什么要用双向链表而不是单向链表,点出前驱指针的必要性。
- 提一句虚拟头尾节点简化边界处理。
- 说出 LRU 基于"时间局部性原理",并说明它的局限。
- 如果能顺带提到 Redis 用近似 LRU、MySQL 用分代 LRU,那就是明显加分。
- 被问"如果并发怎么办",答分段锁,并说明取舍。
这些点我在面试别人的时候特别看重,能把标准答案背出来的人很多,能讲清楚"为什么这么设计""什么场景不适用"的人很少。
最后再分享一个我自己调 LRU 的小技巧:加个命中率监控埋点,定期打日志。很多问题不是代码错了,是业务访问模式变了而你还不知道。有了命中率曲线,你就能第一时间发现异常,早发现早处理,比事后救火强太多。