【系列:MiniKV 原理剖析 · 第 2 篇】

导读: 缓存系统有一个绕不开的灵魂问题:容量满了,淘汰谁?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 有两个关键性质:

  1. O(1):直接操作节点指针,不遍历
  2. 迭代器不失效 :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 淘汰如何协作。

参考文献与引用

📥 下载完整源码 :如需整个工程的源码,请在下面的链接下载:

https://download.csdn.net/download/ganxin7932508/93241722


觉得有用?点个关注,持续获取 C++ 与系统编程技术干货。

相关推荐
王琦03181 小时前
Linux的管理程序
linux·运维·服务器
charlie1145141912 小时前
IMX6ULL WM8960 的移植——前置介绍
开发语言·c++·开源项目·嵌入式linux
Crazy________2 小时前
Redis02:库切换、监控与安全配置
linux·redis·云原生·mybatis
charlie1145141912 小时前
Cinux是如何管理进程的 —— 上下文与调度
开发语言·c++·操作系统·开源项目
Eloudy2 小时前
给 Linux 加新硬盘的挂载命令
linux·运维·服务器
hold?fish:palm2 小时前
redis中AOF 重写机制解析
数据库·c++·redis
三言老师2 小时前
ss高效查看网络连接端口实操
linux·运维·服务器·网络
三言老师2 小时前
traceroute路由追踪实操
linux·运维·服务器·网络
阿米亚波2 小时前
【C++ 异常处理】try-catch
开发语言·c++·笔记·try-catch