导读: 缓存系统有一个绕不开的灵魂问题:容量满了,淘汰谁?LRU(Least Recently Used,最近最少使用)的答案是------淘汰最久没被访问的那个。听起来简单,但要在 O(1) 时间内完成"访问即更新""淘汰最旧"两个操作,需要精心设计数据结构。MiniKV 的 Store 类给出了教科书式的答案:
std::list记录访问顺序、std::unordered_map存键值、Entry 内嵌 list 迭代器,三个结构协作实现 O(1) 的查询、插入与淘汰。本文逐行拆解这个经典组合,并回答一个关键问题:为什么必须是 list + map,vector 不行吗?
一、LRU 要解决什么问题
假设我们有一个容量为 2 的缓存,依次执行:
SET cold 1 → 缓存: [cold]
SET hot 2 → 缓存: [hot, cold] (hot 最近,cold 次近)
GET cold → 缓存: [cold, hot] (访问 cold,它变成最近!)
SET new 3 → 容量满了!淘汰谁? → 淘汰 hot(最久未用)
关键洞察 :GET cold 让 cold 从"次近"变成了"最近"------每一次访问都必须更新访问顺序。如果这个"更新"是 O(n) 的,那缓存越大越慢,性能无从谈起。所以 LRU 的实现核心是:
两个 O(1) 操作:访问时 O(1) 移到最前,淘汰时 O(1) 找到并删掉最旧。
二、数据结构:为什么是 list + map 组合
MiniKV 的 Store 类用三个结构协作:
cpp
// store.h
class Store {
const std::size_t capacity_;
std::list<std::string> lru_; // ① 访问顺序链表
std::unordered_map<std::string, Entry> entries_; // ② 键值哈希表
struct Entry {
std::string value;
std::optional<Clock::time_point> expires_at; // TTL(第 3 篇展开)
std::list<std::string>::iterator lru_position; // ③ ⭐ 链表迭代器
};
};
三个结构的职责分工:
| 结构 | 存什么 | 解决什么问题 |
|---|---|---|
lru_(std::list) |
所有 key,按访问顺序排列(头部=最近) | "谁最久没被访问"的答案 |
entries_(unordered_map) |
key → Entry(含 value) | O(1) 按 key 查值 |
lru_position(list 迭代器) |
该 key 在链表中的位置 | ⭐ 通过 map 找到 Entry 后 O(1) 定位链表节点 |
lru_position 是整个设计的点睛之笔。它让"通过 key 找到 Entry"和"通过 Entry 找到链表位置"两个方向都变成 O(1)------不需要为了找链表位置而 O(n) 遍历链表。

三、O(1) 访问更新:splice 的妙用
每次 get/set 命中,都要把该 key 移到链表头部。MiniKV 的实现:
cpp
void Store::touch_locked(Entry& entry) {
lru_.splice(lru_.begin(), lru_, entry.lru_position);
entry.lru_position = lru_.begin();
}
std::list::splice 的语义是:把节点从当前链表剪切到目标位置------不复制、不移动数据,纯指针操作,O(1)。
为什么选 splice 而不是"删掉再插入"?因为 splice 有两个关键性质:
- O(1):直接操作节点指针,不遍历
- 迭代器不失效 :splice 保持元素的"身份",
entry.lru_position在剪切后仍然有效(指向同一个节点,只是位置变了)
这两点恰好是 LRU 频繁移动节点的刚需。

四、O(1) 淘汰:evict_if_needed_locked
容量超限时的淘汰逻辑:
cpp
void Store::evict_if_needed_locked() {
while (entries_.size() > capacity_) {
const std::string victim = lru_.back(); // 最久未用的 key(链表尾部)
lru_.pop_back();
entries_.erase(victim);
++stats_.evictions;
}
}
lru_.back():链表尾部就是最久未用的 key------O(1) 拿到pop_back()+entries_.erase(victim):链表和哈希表同步删除------都是 O(1)- 注意是
while而不是if:正常情况下每次 set 只多 1 个键,if就够;但极端情况(如构造后容量被调小)可能一次需要淘汰多个,while保证彻底清到容量内
五、set 与 get 的完整流程
set:三种情况
cpp
void Store::set(std::string key, std::string value) {
std::lock_guard<std::mutex> lock(mutex_); // 线程安全(第4篇展开)
remove_if_expired_locked(key); // TTL 惰性清理(第3篇)
const auto found = entries_.find(key);
if (found != entries_.end()) { // ① 已存在:更新
found->second.value = std::move(value);
found->second.expires_at.reset(); // 重置 TTL
touch_locked(found->second); // 移到链表头
return;
}
lru_.push_front(key); // ② 新 key:链表头插入
entries_.emplace(std::move(key), Entry{std::move(value), std::nullopt, lru_.begin()});
evict_if_needed_locked(); // ③ 超容量:淘汰最旧
}
注意 entries_.emplace(..., lru_.begin())------新 Entry 的 lru_position 初始化为链表头迭代器(因为 push_front 刚把它放到了头部)。
get:命中即 touch
cpp
std::optional<std::string> Store::get(const std::string& key) {
std::lock_guard<std::mutex> lock(mutex_);
if (remove_if_expired_locked(key)) { ++stats_.misses; return std::nullopt; }
const auto found = entries_.find(key);
if (found == entries_.end()) { ++stats_.misses; return std::nullopt; }
++stats_.hits;
touch_locked(found->second); // ⭐ 访问即更新 LRU 位置
return found->second.value;
}
touch_locked 在 get 里是关键------读取本身就会改变访问顺序,这正是 LRU 与 FIFO 的本质区别。
六、为什么是 list,不是 vector?
这是面试必考题,也是理解 LRU 实现的关键:
| 特性 | std::list | std::vector |
|---|---|---|
| 任意位置插入/删除 | O(1)(指针操作) | O(n)(搬移元素) |
| 迭代器稳定性 | 插入/删除后其他迭代器仍有效 | 插入/删除可能使所有迭代器失效 |
| splice 剪切节点 | ✅ 支持,O(1) | ❌ 无此操作 |
LRU 的核心操作是"把链表中间的节点移到头部"------如果 lru_position 存的是 vector 的迭代器(下标),每次移动后 vector 里其他元素的迭代器可能全部失效,lru_position 就全错了。list 的迭代器稳定性 + splice 的 O(1) 剪切,是 LRU 选择 list 的根本原因。
七、测试验证:StoreEvictsLeastRecentlyUsed
MiniKV 自带的单元测试验证了完整逻辑(tests/store_test.cpp):
cpp
TEST(StoreEvictsLeastRecentlyUsed) {
minikv::Store store(2); // 容量 2
store.set("cold", "1"); // lru: [cold]
store.set("hot", "2"); // lru: [hot, cold]
store.get("cold"); // touch → lru: [cold, hot](cold 变最近!)
store.set("new", "3"); // 超容量 → 淘汰 hot(最久未用)
EXPECT_TRUE(store.exists("cold")); // cold 存活(刚被访问过)✓
EXPECT_FALSE(store.exists("hot")); // hot 被淘汰 ✓
EXPECT_TRUE(store.exists("new")); // new 已加入 ✓
EXPECT_EQ(store.stats().evictions, std::uint64_t{1});
}
这个测试完美演示了 LRU 的核心语义:get("cold") 改变了淘汰对象------如果淘汰规则是 FIFO,淘汰的会是 cold;但因为 LRU 记住了 cold 刚被访问,淘汰的是 hot。

小结
- LRU 的核心需求:访问时 O(1) 更新顺序,淘汰时 O(1) 找到最旧
- list + map 组合:list 记访问顺序(头=最近,尾=最旧),map 按 key O(1) 查 Entry
lru_position点睛:Entry 内嵌 list 迭代器,让"key→链表位置"双向 O(1)- splice 妙用:O(1) 剪切节点且迭代器不失效,是"移到头部"的正确工具
- while 而非 if:淘汰逻辑要考虑一次淘汰多个的极端情况
- list vs vector:迭代器稳定性 + splice,是 LRU 选 list 的根本原因
下一篇预告
《TTL 过期机制:惰性删除的取舍》 ------Entry 里那个 expires_at 时间戳,是 TTL(过期时间)的核心。MiniKV 采用"惰性删除"策略:不主动扫描过期键,而是在每次操作时顺手检查。这篇讲清楚惰性删除的原理、六条命令如何统一调用 remove_if_expired_locked、以及它和 LRU 淘汰如何协作。
参考文献与引用
- cppreference - std::list::splice :en.cppreference.com/w/cpp/container/list/splice------splice 的 O(1) 剪切与迭代器不失效的权威说明
- cppreference - std::list :en.cppreference.com/w/cpp/container/list------list 迭代器稳定性保证
📥 下载完整源码 :如需整个工程的源码,请在下面的链接下载:
觉得有用?点个关注,持续获取 C++ 与系统编程技术干货。