迷你KV存储引擎器
# 第六章:C++ 迷你KV存储引擎器
本章是综合案例的最终关·毕业设计,也是整本《C++ 入门到精通》卷一的"集大成之作"。前 5 个案例每篇只覆盖 3-5 章,本案例一口气串起卷一 17 章——从基础语法到
std::variant,从智能指针到std::jthread,从异常体系到预处理器宏,全部在同一个项目里落地。完成它之后,你会得到一个能跑、能落盘、能并发、能过期的"迷你 Redis",更重要的是:你会真正理解"C++ 工程"是怎样把这些散点搭成一座可维护的房子的。学习方式:本案例篇幅较长(2200+ 行 / 16-20 小时),建议分 5 次读完,每次 1.5 - 2 小时;绝不要试图一口气吃完。每一节都自带"为什么这样写"的思考小结,慢慢消化更有效果。如果说前 5 关是"过单元考试",本关就是"毕业答辩"——它检验你能不能把之前学过的所有特性协同起来,而不只是会用单点。
# 📚 渐进学习节奏
💡 先读这段,再开始敲代码!本案例 14 个章节,严格按"5 次会话"切分,每次会话独立可验收。
\u3010\u7b2c 1 \u6b21\u4f1a\u8bdd\u3011 MVP \u9aa8\u67b6 + \u73b0\u4ee3 C++ \u8868\u8fbe\uff08\u7ae0 02-04\uff09 \u00b7 3 h
\u9636\u6bb5 \u2460 REPL \u9aa8\u67b6\uff1amain \u5faa\u73af + enum class + string_view \u5207\u5206\n \u9636\u6bb5 \u2461 Value \u8868\u8fbe\uff1avariant \u88c5 5 \u79cd\u7c7b\u578b\n \u9636\u6bb5 \u2462 Entry \u751f\u547d\u5468\u671f\uff1aEntry = Value + TTL\uff0cshared_ptr \u62e5\u6709\u5173\u7cfb\n \u2705 \u9a8c\u6536\uff1aREPL \u8df1\u901a\uff0c\u80fd\u5c06 SET k 123 \u5b58\u8fdb map\uff0cGET k \u8bfb\u51fa\n\n\u3010\u7b2c 2 \u6b21\u4f1a\u8bdd\u3011 \u547d\u4ee4\u6a21\u5f0f + Store \u6838\u5fc3\uff08\u7ae0 05-06\uff09 \u00b7 3 h\n \u9636\u6bb5 \u2463 Command \u62bd\u8c61\u57fa\u7c7b + 5 \u4e2a\u6d3e\u751f\u547d\u4ee4\n \u9636\u6bb5 \u2464 Store \u4e3b\u63a5\u53e3 apply\uff0c\u628a if-else \u91cd\u6784\u4e3a\u7c7b\u4f53\u7cfb\n \u9636\u6bb5 \u2465\uff08\u9009\u8bb2\uff09\u6a21\u677f\u5316 registerCmd<T>\n \u2705 \u9a8c\u6536\uff1a\u52a0\u4e00\u4e2a\u65b0\u547d\u4ee4 INCR \u53ea\u9700\u52a8 1 \u4e2a\u6587\u4ef6\n\n\u3010\u7b2c 3 \u6b21\u4f1a\u8bdd\u3011 \u5f02\u5e38\u4f53\u7cfb + \u65e5\u5fd7\u5b8f\uff08\u7ae0 07\u300109\uff09 \u00b7 3 h\n \u9636\u6bb5 \u2466 KvError \u81ea\u5b9a\u4e49\u5f02\u5e38\u4f53\u7cfb + \u4e3b\u5faa\u73af\u7edf\u4e00\u6355\u83b7\n \u9636\u6bb5 \u2467 KV_LOG \u5b8f + __FILE__/__LINE__ + \u6761\u4ef6\u7f16\u8bd1\u5173\u95ed\n \u2705 \u9a8c\u6536\uff1a\u8f93\u9519\u547d\u4ee4\u4e0d\u5d29\u6e83 + Debug/Release \u7f16\u8bd1\u90fd\u80fd\u8dd1\n\n\u3010\u7b2c 4 \u6b21\u4f1a\u8bdd\u3011 AOF \u6301\u4e45\u5316\uff08\u7ae0 08\uff09 \u00b7 3 h \u3010\u9ad8\u5cf0\u00b7\u8d26\u672c\u80fd\u91cd\u542f\u3011\n \u9636\u6bb5 \u2468 AofWriter \u8ffd\u52a0\u5199 + filesystem \u5efa\u76ee\u5f55\n \u9636\u6bb5 \u2469 \u542f\u52a8\u91cd\u653e\u8fd8\u539f\u72b6\u6001\n \u2705 \u9a8c\u6536\uff1ass-9 \u6740\u8fdb\u7a0b\u91cd\u542f\u540e\u6240\u6709\u6570\u636e\u8fd8\u5728\n\n\u3010\u7b2c 5 \u6b21\u4f1a\u8bdd\u3011 \u5e76\u53d1 + TTL\uff08\u7ae0 10-12\uff09 \u00b7 3 h \u3010\u9ad8\u5cf0\u00b7\u751f\u4ea7\u7ea7\u95ed\u73af\u3011\n \u9636\u6bb5 \u246a mutex + lock_guard \u4fdd\u62a4 store\n \u9636\u6bb5 \u246b shared_mutex \u4f18\u5316\u8bfb\u591a\u573a\u666f\n \u9636\u6bb5 \u246c jthread + stop_token \u540e\u53f0\u8fc7\u671f\u6e05\u7406\n \u9636\u6bb5 \u246d \u7aef\u5230\u7aef\u538b\u6d4b + \u6740\u8fdb\u7a0b\u9a8c\u8bc1\n \u2705 \u9a8c\u6536\uff1a\u591a\u5ba2\u6237\u7aef\u6df7\u5199\u4e0d\u5d29\u6e83 + 100 \u4e2a TTL key 5 \u79d2\u540e\u81ea\u52a8\u6d88\u5931\n```
> 🎯 **每次会话开始前必读**:
> 1. **打开上次会话的代码 commit**,先看一遍验收点是否还在
> 2. **每个 Step 写完一小段就 build**(CMake 增量编译只要几秒)
> 3. **看到该节末尾的 ✅ 验收命令出现预期输出,再进入下一节**
>
> ⚠️ **本案例独有的"先快速搭骨架,再回头加肉"策略**:第 1 次会话搭好的 MVP 骨架(用 `unordered_map<string, string>` 直接存)在第 2 次会话会**被推翻重构**为命令模式。这是真实工程的常态——**先让程序跑起来,再让它好起来**。如果你看到第 5 节"为什么 if-else 越长越烂"觉得"早干嘛去了?",恭喜你,你已经长出工程师的直觉了。
>
> 📌 **强烈建议**:每次会话结束做一次 `git commit`,这样第 4 次会话的 AOF 重放出问题,你能轻松回退到第 3 次会话的稳态。
> 🎯 **本案例的四处"灵魂三问"**(动手前先想清楚):
> - **§02 MVP 骨架前**:为什么先做 REPL 不是 map?为什么用 enum class?为什么用 string_view?
> - **§05 命令模式前**:为什么不一直 if-else?为什么用 unique_ptr?为什么 toAofLine 要独立虚函数?
> - **§08 AOF 持久化前**:为什么用 AOF 不用 dump?为什么追加而不是改写?write 后为什么还要 fsync?
> - **§10 并发安全前**:为什么不用 atomic?shared_mutex 是否万能?Redis 单线程是不是更聪明?
> ⚠️ **本案例的五处"造 BUG → 修复"高峰**(亲眼看到才能记住):
> - **§04 五法则现场验证**:忘写虚析构 → 派生类析构没调
> - **§05 if-else 大爆炸(Step 4.1)**:8 命令 200 行 switch 现场
> - **§06 迭代器失效(Step 5.3)**:`erase` 后还自增 → UB 崩溃
> - **§08 AOF 半行损坏(Step 8.5)**:手改 aof.log 制造截断 → 测试容错
> - **§10 计数器变 1837(Step 10.1)**:两线程 SET 1000 次 → 总和不是 2000
---
## MiniKV 技术全景
```mermaid
graph TB
subgraph "C++基础语法层(卷一第2-7章)"
A1[main函数入口] --> A2[cin/cout IO操作]
A2 --> A3[enum class命令枚举]
A3 --> A4[string_view字符串切分]
A4 --> A5[switch/while流程控制]
end
subgraph "面向对象编程层(卷一第8-10章)"
B1[Command抽象基类] --> B2[SetCmd/GetCmd派生类]
B2 --> B3[虚函数多态分发]
B3 --> B4[Store类封装]
B4 --> B5[五法则资源管理]
end
subgraph "现代C++特性层(卷一第11-18章)"
C1[variant多类型Value] --> C2[智能指针所有权]
C2 --> C3[optional可选类型]
C3 --> C4[filesystem文件管理]
C4 --> C5[jthread后台线程]
end
subgraph "并发与工程化层(卷一第13-17章)"
D1[mutex/lock_guard锁] --> D2[shared_mutex读写锁]
D2 --> D3[AOF持久化机制]
D3 --> D4[异常处理体系]
D4 --> D5[KV_LOG宏系统]
end
A5 --> B1
B5 --> C1
C5 --> D1
subgraph "最终产物:MiniKV存储引擎"
E[完整的KV存储系统<br/>支持8个命令+持久化+并发+TTL]
end
D5 --> E
style A1 fill:#e1f5fe
style B1 fill:#f3e5f5
style C1 fill:#e8f5e8
style D1 fill:#fff3e0
style E fill:#ffcdd2
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
# 技术栈分层详解
| 技术层级 | 对应C++知识点 | 在MiniKV中的具体实现 |
|---|---|---|
| 基础语法层 | 变量、流程控制、函数 | REPL主循环、命令解析、字符串处理 |
| OOP层 | 类、继承、多态、封装 | Command命令模式、Store数据管理 |
| 现代特性层 | 智能指针、variant、optional | 类型安全Value、RAII资源管理 |
| 工程化层 | 并发、异常、文件IO、宏 | 多线程安全、AOF持久化、日志系统 |
# 为什么是毕业设计
- 完整性:覆盖C++从基础语法到高级特性的全栈知识
- 实用性:实现了一个真实可用的KV存储系统
- 递进性:技术栈层层递进,符合学习认知规律
- 可扩展:命令模式架构便于后续添加新功能
- 工程化:包含异常处理、日志系统、持久化等工程必备要素
通过完成这个1500行的项目,你将真正理解"C++工程"是如何把散落的知识点搭建成可维护的系统的。
# 00.案例元信息
| 项目 | 说明 |
|---|---|
| 难度 | ★★★★★ |
| 预估时长 | 16 - 20 小时(建议 5 次完成) |
| 前置章节 | 卷一第 2 - 17 章全部 + 第 18 章特性图谱 |
| 覆盖知识点 | REPL 主循环、std::variant 多类型 Value、enum class 命令枚举、<=> 三路比较、抽象基类 + 命令模式(Command Pattern)、std::unique_ptr/std::shared_ptr 取舍、自定义异常体系、AOF 追加日志、std::filesystem 目录管理、std::mutex + std::lock_guard 并发保护、std::jthread 后台过期清理线程、KV_LOG 宏与 __FILE__/__LINE__、模板化命令注册 |
| 设计亮点 | 命令模式让"加新命令零修改主流程";AOF 让进程重启数据不丢;后台 TTL 线程展示生产者-消费者外的"清理线程"模式 |
| 最终产物 | 单文件 mini_kv.cpp + REPL 可执行 + 数据目录 ./data/aof.log |
| 代码规模 | 约 1500 行(不含空行注释 ~1100 行) |
# 📋 目录快速导航
点击以下条目即可跳转到对应节。【🔑 重点节】推荐优先阅读。
- 📚 渐进学习节奏 【🔑 必读】
- MiniKV 技术全景 【🔑】
- 00.案例元信息
- 01.需求说明
- 02.MVP 骨架启动 【第1次会话】
- 03.Value 类型表达
- 04.Entry 与生命周期
- 05.Command 命令模式 【第2次会话·OOP高峰⭐】
- 06.Store 串起命令
- 07.异常体系工程化 【第3次会话】
- 08.AOF 持久化实现 【第4次会话·重启高峰⭐】
- 09.日志系统实战
- 10.并发安全读写 【第5次会话·并发高峰⭐】
- 11.TTL 后台清理
- 12.端到端运行
- 13.项目总结
- 14.技术思考
- 15.衔接与延伸
- 16.尾声寄语
# 01.需求说明
# 1.1 为什么做 KV 存储
KV(Key-Value)存储是后端世界最常见的数据结构——Redis、Memcached、etcd、RocksDB 本质上都是 KV。它的接口极简(GET/SET/DEL),但要做"对",需要解决一连串工程问题:
| 工程问题 | 在本案例中的体现 | 用到的卷一章节 |
|---|---|---|
| 一个 key 能存多种类型的值 | Value 用 std::variant 表达 | 第 3 / 18 章 |
| 命令越加越多怎么办 | 命令模式:每加一个命令只新增一个类 | 第 9 / 10 章 |
| 进程一关数据就没了 | AOF 追加日志 + 启动重放 | 第 13 章 |
| 多客户端同时读写崩了 | mutex + lock_guard | 第 15 章 |
| key 设了 TTL 谁来删 | 后台 jthread + 惰性删除 | 第 11 / 15 章 |
| 写错命令程序就崩 | 自定义异常 KvError 体系 | 第 14 章 |
| 调试时想看哪行打的日志 | KV_LOG 宏 + __FILE__/__LINE__ | 第 17 章 |
这恰好就是 16 章的卷一。 我们不是为了"凑覆盖"才做 KV——而是 KV 存储这个题目本身天然需要这些技术,做一遍刚好把它们用对位置。
# 1.2 目标命令集
实现一个支持以下 8 个命令的 REPL(Read-Eval-Print-Loop):
$ ./mini_kv
> SET name zhangsan
OK
> SET age 30
OK
> GET name
"zhangsan"
> GET age
(integer) 30
> EXPIRE name 60 # 60 秒后过期
OK
> TTL name # 查询剩余秒数
(integer) 58
> KEYS * # 列出所有 key
1) "name"
2) "age"
> DEL age
(integer) 1
> SAVE # 强制刷盘
OK
> EXIT
bye.
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
支持的 Value 类型:
- 字符串
"hello" - 整数
42 - 浮点数
3.14 - 布尔
true/false - 列表
LPUSH mylist a b c(选讲)
# 1.3 与卷一对应关系
下表是本案例最关键的索引,遇到知识点不熟的章节,直接翻到对应的"小节"复习:
| 卷一章节 | 在本项目中的落地节 | 形式 |
|---|---|---|
| 02 基础语法 | 2.1 main 入口 | int main() / cout / cin |
| 03 数据类型 | 3.1 Value 类型 | int64_t / double / bool / string |
| 04 运算符 | 4.1 Entry 比较 | <=> 三路比较 |
| 05 复合类型 | 2.2 命令枚举 | enum class CmdType |
| 06 流程语句 | 2.1 命令循环 | while + switch + 结构化绑定 |
| 07 函数 | 6.3 命令注册 | Lambda + std::function |
| 08 指针引用 | 4.2 智能指针 | shared_ptr<Entry> + string_view |
| 09 类与对象 | 5.2 / 6.1 | 抽象基类 + Store 五法则 |
| 10 继承多态 | 5.2 - 5.3 | Command 体系 + 虚函数 |
| 11 内存模型 | 4.3 | 栈 / 堆 / 静态区分析 |
| 12 动态内存 | 4.2 / 5.4 | make_shared / make_unique |
| 13 IO 与文件 | 8.2 - 8.4 | ofstream / filesystem |
| 14 异常处理 | 07 整章 | KvError + noexcept |
| 15 线程和锁 | 10 / 11 | mutex / lock_guard / jthread |
| 16 STL 模板 | 6.1 / 6.3 | unordered_map + 模板注册 |
| 17 预处理器 | 09 整章 | #define KV_LOG + 条件编译 |
| 18 特性图谱 | 3.1 / 4.1 | variant + optional + string_view |
第 1 章(C++ 简史)和第 18 章(特性图谱本身是索引)不强行覆盖。
# 1.4 项目目录结构
为了便于你跟着写,本案例采用单文件实现(不拆 .h/.cpp),避免初学者陷入构建系统的坑:
mini_kv/
├── mini_kv.cpp # 全部源码(约 1500 行)
├── CMakeLists.txt # 构建脚本(10 行)
└── data/ # 运行时自动创建
└── aof.log # AOF 持久化文件
2
3
4
5
CMakeLists.txt 内容(一次性给出,后面不再提):
cmake_minimum_required(VERSION 3.16)
project(mini_kv CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_executable(mini_kv mini_kv.cpp)
if(MSVC)
target_compile_options(mini_kv PRIVATE /W4)
else()
target_compile_options(mini_kv PRIVATE -Wall -Wextra -Wpedantic)
endif()
2
3
4
5
6
7
8
9
10
工程级项目当然要拆头文件 + 源文件 + 测试,本案例为了让读者把注意力放在 C++ 知识本身,刻意单文件。卷四会专门讲多文件工程组织。
# 02.MVP 骨架启动
工程实践有个金科玉律:先让"最小可用版本"跑起来,再迭代添加功能。本节先不管类型、不管持久化、不管并发,只做一件事:读一行 → 切词 → 打印。这一步覆盖卷一第 2 / 5 / 6 章。
🎯 阶段① · MVP 骨架(约 1 小时)
本阶段你将完成:
- Step 1.1 写 25 行最小 REPL 主循环 → ✅ 编译运行,能读一行打一行
- Step 1.2 引入
enum class CmdType,把字符串收敛为强类型枚举 → ✅switch能正确分发 8 种命令- Step 1.3 用
std::string_view写零拷贝tokenize→ ✅ 替换istringstream切词,性能提升阶段验收:跑
./build/mini_kv,敲SET k 1看到[TODO] SET、敲WRONG看到(error) unknown command、敲EXIT干净退出。建议节奏:每个 Step 写完一段就编译一次,看到预期输出再进入下一个 Step;卡住超过 15 分钟就回退到上一个稳定版本。
# 灵魂三问:动手前先想清楚
❓ 问题一:明明是个 KV 存储,为什么第一步是写 REPL(命令行交互),不是 unordered_map?
来看反例——99% 初学者的第一反应:
// ❌ 反例:上来就写"看起来很核心"的 map
int main() {
std::unordered_map<std::string, std::string> kv;
kv["name"] = "zhangsan";
std::cout << kv["name"]; // 能跑!但这不是 KV "存储引擎",是 STL 演示
}
2
3
4
5
6
问题:
- 没有交互入口——你敲不进新命令,只能重新编译才能改数据
- 没有命令解析——KV 存储引擎的核心难点之一就是"如何把字符串变成结构化命令"
- 后续无法持久化、无法并发——因为整个程序是"无状态的脚本",没有"长连接 / 长生命周期 server" 这个概念
✅ 正确做法:先做 REPL 壳子——它确立了"server 进程持续运行 + 反复处理用户命令"的架构。容器(map)反而是最容易补的部分,第三阶段才出现。这就是 §02 的小标题"MVP 骨架启动"——骨架(控制流)比器官(容器)优先。
❓ 问题二:为什么用 enum class,不用字符串直接判断?
来看反例:
// ❌ 反例:直接字符串判断散落各处
if (tokens[0] == "SET") { /* main 里 */ }
if (tokens[0] == "set") { /* logger 里 */ }
if (tokens[0] == "Set") { /* AOF 里 */ } // 一处大小写处理不一致就出 bug
2
3
4
问题:
- 散落各处——同一个"命令名"判断逻辑在 main / 日志 / AOF / 测试中都有,改名字要全局搜索
- 拼写错误编译器查不出——你写
"SEt"编译器照样通过,运行时静默失效 - 大小写规则不一致——有的地方 toupper,有的地方原样比,bug 就在这种缝隙里钻出来
✅ 正确做法:输入边界一次性 parse 成强类型 enum——后续所有代码看到的都是 CmdType::Set,编译期穷举 + 0 拼写风险。这是"边界归一化"原则——让脏数据只能存活在 1 行代码里。
❓ 问题三:为什么用 std::string_view,普通 std::string 不香吗?
答:在切词这种"短生命周期、纯只读" 的场景,string_view 是性能与安全的双赢:
| 维度 | std::string | std::string_view |
|---|---|---|
| 内存分配 | 每个 token 都堆分配 | 0 分配(只是指针+长度) |
| sizeof | 24-32 字节 | 16 字节 |
| 是否拷贝 | 是 | 否 |
| 危险点 | 无 | 不延长源字符串生命周期 |
| 适用场景 | 长期存储 / 需要修改 | 临时只读切片 |
但 string_view 有个杀手坑——悬垂引用:如果原 std::string line 已经销毁,view 还指向那块内存就 UB。本案例里 tokenize 的返回值仅在当前命令处理周期内使用,源 line 还活着,安全。
🔑 教学要点:MVP 的意义不是"代码少",而是"先让最薄的一层端到端跑通,再分层加肉"。第 1 阶段你可能觉得"啥都没干"——别急,整个项目的"骨架决策"都是这 80 行决定的:REPL 控制流、enum 边界、view 性能基线,缺一不可。
# Step 1.1 最小 REPL 循环
🎯 本步目标:让程序能反复读一行、切词、打印,并响应
EXIT。这是整个项目唯一一次"程序还没有真实功能"的版本——但它确立了交互入口,后续所有功能都在这个壳里长出来。
// mini_kv.cpp —— 第一个版本
#include <iostream>
#include <string>
#include <sstream>
#include <vector>
int main() {
std::cout << "MiniKV v0.1 — type EXIT to quit.\n";
std::string line;
while (true) {
std::cout << "> ";
if (!std::getline(std::cin, line)) break; // Ctrl-D 退出
if (line.empty()) continue;
// 切词
std::istringstream iss(line);
std::vector<std::string> tokens;
for (std::string tok; iss >> tok; ) tokens.push_back(tok);
if (tokens.empty()) continue;
if (tokens[0] == "EXIT") { std::cout << "bye.\n"; break; }
std::cout << "(unknown) you said: ";
for (auto& t : tokens) std::cout << "[" << t << "] ";
std::cout << "\n";
}
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
✅ Step 1.1 编译运行验证:
$ cmake -B build && cmake --build build
$ ./build/mini_kv
MiniKV v0.1 — type EXIT to quit.
> set name zhangsan
(unknown) you said: [set] [name] [zhangsan]
> hello world
(unknown) you said: [hello] [world]
> EXIT
bye.
2
3
4
5
6
7
8
9
看到这三行输出说明 Step 1.1 成功——程序能读一行、切词、回显,并干净退出。如果 cmake -B build 报错,先检查是否有 CMakeLists.txt(最简版只需 3 行:cmake_minimum_required(VERSION 3.16); project(mini_kv); add_executable(mini_kv mini_kv.cpp))。
为什么这样写:
while(true)+if(!getline) break是 C++ 处理"读到 EOF 退出"的标准写法,比while(getline(...))更直观地表达"我要主动判断"。std::istringstream切词比手写指针扫描简单 10 倍——这是 STL 流的典型用法,源自卷一第 13 章。- 这一版故意不用 namespace、不用
auto、不用类——MVP 的灵魂是"看到这 30 行能完整理解每一行做什么",等下面真正需要时再升级。
💡 小白避坑:
std::getline返回的是std::istream&,转 bool 时空行返回true、EOF 返回false。所以if (!std::getline(...))的真正语义是"读到 EOF 才退出",空行不会误退。
# Step 1.2 enum class 命令
🎯 本步目标:把散在各处的字符串比较
tokens[0] == "SET"收敛成一次解析、一处枚举。这是卷一第 5 章的强类型枚举在工程里的第一次正经亮相。
字符串比较散落在各处迟早会乱(容易拼错、容易漏判大小写、加新命令要改 N 处),工程上的做法是字符串解析一次后变成强类型枚举,后续全部用枚举:
enum class CmdType {
Set, Get, Del, Expire, Ttl, Keys, Save, Exit, Unknown
};
CmdType parseCmdType(const std::string& s) {
// 支持大小写不敏感
std::string up;
up.reserve(s.size());
for (char c : s) up.push_back(static_cast<char>(std::toupper(c)));
if (up == "SET") return CmdType::Set;
if (up == "GET") return CmdType::Get;
if (up == "DEL") return CmdType::Del;
if (up == "EXPIRE") return CmdType::Expire;
if (up == "TTL") return CmdType::Ttl;
if (up == "KEYS") return CmdType::Keys;
if (up == "SAVE") return CmdType::Save;
if (up == "EXIT") return CmdType::Exit;
return CmdType::Unknown;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
把 main 里的字符串判断改成 switch:
switch (parseCmdType(tokens[0])) {
case CmdType::Set: std::cout << "[TODO] SET\n"; break;
case CmdType::Get: std::cout << "[TODO] GET\n"; break;
case CmdType::Del: std::cout << "[TODO] DEL\n"; break;
case CmdType::Expire: std::cout << "[TODO] EXPIRE\n"; break;
case CmdType::Ttl: std::cout << "[TODO] TTL\n"; break;
case CmdType::Keys: std::cout << "[TODO] KEYS\n"; break;
case CmdType::Save: std::cout << "[TODO] SAVE\n"; break;
case CmdType::Exit: std::cout << "bye.\n"; return 0;
case CmdType::Unknown: std::cout << "(error) unknown command\n"; break;
}
2
3
4
5
6
7
8
9
10
11
✅ Step 1.2 编译运行验证:
$ cmake --build build
$ ./build/mini_kv
MiniKV v0.1 — type EXIT to quit.
> SET name zhangsan
[TODO] SET
> get name
[TODO] GET # 大小写不敏感生效
> wrong
(error) unknown command # 未知命令不再回显,而是统一错误
> EXIT
bye.
2
3
4
5
6
7
8
9
10
11
看到 SET / get / wrong 都被正确分发说明 Step 1.2 成功。关键里程碑:从此 main 里再也不会出现 tokens[0] == "XXX"——所有命令决策都基于 CmdType。
💡 小白避坑:
switch里如果漏写一个case,多数编译器会发警告(-Wswitch),这正是enum class比裸字符串更安全的原因。强烈建议 CMake 里加add_compile_options(-Wall -Wextra),让编译器替你查漏补缺。
为什么用 enum class 而不是 enum 或字符串:
| 方案 | 缺点 |
|---|---|
裸字符串 if (cmd == "SET") | 散落各处、易拼错("SEt" 编译器不报错)、无法编译期穷举检查 |
C 风格 enum { SET, GET } | 名字污染全局(SET 撞别处的宏)、可隐式转 int(if (cmd) 居然能编译) |
enum class CmdType | 强类型、必须 CmdType::Set、switch 漏 case 编译器警告 ✅ |
enum class 是 C++11 起的"必选项",卷一第 5 章讲过。本项目从这里开始,所有枚举一律用它。
# Step 1.3 string_view 切词
🎯 本步目标:把
istringstream切词替换成std::string_view版本,避免每条命令都拷贝 5-10 个std::string。这是 C++17 的明星特性,整个项目所有"只读字符串参数"的入口。
std::istringstream 切词在 MVP 里够用,但它会拷贝字符串。一个真实的 KV 服务器一秒处理几千条命令,每次都拷贝 5-10 个 std::string 是巨大的浪费。
工程级做法是用 std::string_view(C++17)做"零拷贝切片":
#include <string_view>
// 把一行命令切成多个 string_view,全部指向同一个底层 buffer
std::vector<std::string_view> tokenize(std::string_view line) {
std::vector<std::string_view> out;
size_t i = 0;
while (i < line.size()) {
while (i < line.size() && std::isspace(static_cast<unsigned char>(line[i]))) ++i;
size_t start = i;
while (i < line.size() && !std::isspace(static_cast<unsigned char>(line[i]))) ++i;
if (start < i) out.emplace_back(line.substr(start, i - start));
}
return out;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
string_view 的杀手锏:
string_view内部就是指针 + 长度两个字段,sizeof 16 字节,不持有内存。- 它把
const char*/std::string/ 字符串字面量统一成同一种参数类型——以后所有"我只读不改"的字符串参数,都改成std::string_view。 - 危险点:
string_view不延长底层字符串生命周期。如果原std::string销毁了,view 就成了悬垂引用。本项目里我们保证tokenize的返回值不跨越line的生命周期,安全。
卷一第 8 章和第 18 章都讲过
string_view,但都是孤立例子。这里你能看到一个真实场景:命令分发器对性能敏感,所以从入口就用 view。
✅ Step 1.3 编译运行验证:
$ cmake --build build
$ ./build/mini_kv
> SET name zhangsan # 中间多空格
[TODO] SET
> \t\tGET\tname # tab 分隔
[TODO] GET
> EXIT
bye.
2
3
4
5
6
7
8
看到多空格、tab 都被正确切分说明 tokenize 工作正常——std::isspace 默认认所有空白字符。
💡 小白避坑:
std::isspace(c)在c为负数(汉字字节符号扩展为负)时是 未定义行为!必须先static_cast<unsigned char>(c)再传入。这是 C 标准库被诟病已久的坑,本项目所有 ctype 函数调用都遵守这条铁律。
📌 阶段① 小结:MVP 骨架已就位
收获 对应卷一章节 REPL 主循环(getline + while) 第 6 章流程控制 enum class CmdType替代字符串第 5 章强类型枚举 std::string_view零拷贝切词第 8 / 18 章 switch穷举命令分发第 6 章 + 编译器警告 当前代码量:~80 行;当前能力:识别 8 种命令并打印
[TODO],所有命令尚未实现。下一阶段预告:进入阶段② Value 表达,让
SET k 123真的能把123解析成int64、SET name "hi"解析成字符串,并能用GET读出来。
到此 MVP 完成 80 行代码,能正确识别命令、切词,但所有命令都返回 [TODO]。下一节我们把 Value 类型和 Entry 实体补上,让 SET/GET 真的能存能取。
# 03.Value 类型表达
KV 存储的 V(Value)天生需要"一个变量装多种类型"——同一个 GET 命令可能返回字符串、整数、浮点。Python/JS 这种动态语言天然支持,但 C++ 是静态语言,需要技巧。本节专门解决"如何在 C++ 里表达和类型(sum type)"。
🎯 阶段② · Value 表达(约 1 小时)
本阶段你将完成:
- Step 2.1 用
std::variant定义支持 5 种类型的Value→ ✅ 编译通过、能在 main 里建一个变量赋值- Step 2.2 写
formatValue,用std::visit输出不同类型 → ✅ 看到(integer) 42 / "hi" / true / (nil)各种格式- Step 2.3 写
valueFromToken,从字符串自动推断真实类型 → ✅42自动认成 int64,3.14认成 double,"hello"认成 string阶段验收:在 main 里临时加一段测试代码
Value v = valueFromToken("42"); std::cout << formatValue(v);,看到(integer) 42。重点知识点:
std::variant+std::visit+if constexpr这三件套是现代 C++ 表达"和类型"的标准姿势。
# Step 2.1 variant 定义 Value
🎯 本步目标:先把类型确定下来——一个
Value能装 5 种东西之一,且类型在编译期就被穷举。
我们决定支持以下 5 种 Value:
Value ::= Null ((nil))
| Bool (true / false)
| Int (int64_t)
| Double (double)
| String (std::string)
2
3
4
5
真正的 Redis 还支持 List / Hash / Set / ZSet 等"复合类型"。本案例先做简单值,15.3 节会留一个"加 List 类型"的延伸挑战。
# 为什么不是继承
读过卷一第 10 章的同学第一反应可能是"那就抽象基类 + 5 个派生类":
// 反面教材
class Value { public: virtual ~Value() = default; };
class IntValue : public Value { int64_t v; };
class StringValue : public Value { std::string v; };
// ...
std::shared_ptr<Value> entry; // 怎么取出来?dynamic_cast 强转一遍
2
3
4
5
6
继承方案的三个问题:
- 必须用堆:
Value*没法存基类对象本身,只能new派生类,每个 KV 都多一次堆分配。 - 取值要
dynamic_cast:用户写dynamic_cast<IntValue*>(v.get())极易写错,且有运行时开销。 - 类型穷举不安全:加了
FloatValue但忘了改某个if-else分支,编译器一声不吭。
更好的方案是 std::variant(C++17,第 18 章主角)——它在栈上保存"5 种类型之一",并用 std::visit 在编译期强制穷举。
# Value 的类型定义
#include <variant>
#include <string>
#include <cstdint>
namespace mkv {
struct Null {}; // 占位类型,表示"没有值"
inline bool operator==(Null, Null) noexcept { return true; }
using Value = std::variant<Null, bool, std::int64_t, double, std::string>;
// 类型查询(封装一下让调用方好看)
inline bool isNull (const Value& v) noexcept { return std::holds_alternative<Null>(v); }
inline bool isBool (const Value& v) noexcept { return std::holds_alternative<bool>(v); }
inline bool isInt (const Value& v) noexcept { return std::holds_alternative<std::int64_t>(v); }
inline bool isDouble(const Value& v) noexcept { return std::holds_alternative<double>(v); }
inline bool isString(const Value& v) noexcept { return std::holds_alternative<std::string>(v); }
} // namespace mkv
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
✅ Step 2.1 编译运行验证:在 main 里临时插入 4 行:
mkv::Value v1 = std::int64_t{42};
mkv::Value v2 = std::string("hello");
std::cout << "v1 isInt? " << mkv::isInt(v1) << "\n";
std::cout << "v2 isString? " << mkv::isString(v2) << "\n";
2
3
4
编译运行应输出:
v1 isInt? 1
v2 isString? 1
2
1 = true 说明类型识别正确,Step 2.1 通过。验证完记得删掉这 4 行测试代码——这就是"先验证再前进"的工程节奏。
💡 小白避坑:写
Value v = 42;在某些编译器上会报"二义性"——因为 42 既能当int64_t也能当bool。显式写std::int64_t{42}或int64_t(42)能避免歧义;项目里我们会专门用valueFromToken工厂函数,避免直接用字面量构造 Value。
# Step 2.2 visit 输出 Value
🎯 本步目标:实现"把 Value 变成人类可读字符串"的核心函数——
std::visit+ 泛型 lambda +if constexpr三件套首次登场。
回到 namespace 内,加上:
namespace mkv {
// 把 Value 转成可读字符串(用于 GET 命令返回)
std::string formatValue(const Value& v) {
return std::visit([](const auto& x) -> std::string {
using T = std::decay_t<decltype(x)>;
if constexpr (std::is_same_v<T, Null>) return "(nil)";
else if constexpr (std::is_same_v<T, bool>) return x ? "true" : "false";
else if constexpr (std::is_same_v<T, std::int64_t>) return "(integer) " + std::to_string(x);
else if constexpr (std::is_same_v<T, double>) return "(double) " + std::to_string(x);
else if constexpr (std::is_same_v<T, std::string>) return "\"" + x + "\"";
}, v);
}
} // namespace mkv
2
3
4
5
6
7
8
9
10
11
12
13
14
15
✅ Step 2.2 编译运行验证:临时测试代码:
std::cout << mkv::formatValue(std::int64_t{42}) << "\n";
std::cout << mkv::formatValue(std::string("hello")) << "\n";
std::cout << mkv::formatValue(true) << "\n";
std::cout << mkv::formatValue(mkv::Null{}) << "\n";
std::cout << mkv::formatValue(3.14) << "\n";
2
3
4
5
应输出:
(integer) 42
"hello"
true
(nil)
(double) 3.140000
2
3
4
5
5 种类型都能正确输出说明 Step 2.2 通过。
两个新知识点解释:
std::visit+ 泛型 Lambda +if constexpr是variant的标准搭档。visit会根据当前实际类型选择匹配分支;if constexpr是 C++17 的"编译期 if",会在编译时只保留对应分支,不符合的分支连编译都不做(所以Null分支里写+ x也不会出错)。noexcept标记:类型查询函数显然不会抛异常,标noexcept让编译器更激进地优化,也让调用方写noexcept(isInt(v))可以拿到true。这是卷一第 14 章讲过的"工程级习惯"。
💡 小白避坑:泛型 lambda 里漏掉
if constexpr直接写if会导致 5 个分支全部编译,("true" : "false")在Null分支里就会报错。if constexpr才是把 lambda 当成"模板"来用的关键。
# Step 2.3 类型推断函数
🎯 本步目标:SET 命令拿到的是字符串 token,要能自动判断
"42"是 int、"3.14"是 double、"hello"是 string。这是"动态类型"在静态语言里的真实落地姿势。
从字符串构造 Value——SET 命令拿到的是 std::string_view,要根据"长得像什么"决定它的真实类型:
// "true"/"false" -> bool
// 全数字 -> int64
// 带小数点且能 parse -> double
// 其他 -> string
mkv::Value valueFromToken(std::string_view s) {
if (s == "true") return true;
if (s == "false") return false;
if (s == "nil") return mkv::Null{};
// 试探 int64
if (!s.empty()) {
bool allDigit = true;
size_t start = (s[0] == '-') ? 1 : 0;
for (size_t i = start; i < s.size(); ++i) {
if (!std::isdigit(static_cast<unsigned char>(s[i]))) { allDigit = false; break; }
}
if (allDigit && start < s.size()) {
try { return static_cast<std::int64_t>(std::stoll(std::string(s))); }
catch (...) { /* 落到下面 */ }
}
}
// 试探 double
if (s.find('.') != std::string_view::npos) {
try { return std::stod(std::string(s)); }
catch (...) { /* 落到下面 */ }
}
// 兜底当字符串
return std::string(s);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
这个"类型推断"故意写得朴素(没有
std::from_chars那么严谨),目的是让你看到"动态类型"在静态语言里其实就是个 if-else。等你做卷三时会重新认识from_chars的快和准。
✅ Step 2.3 编译运行验证:把 Step 2.1/2.2 的临时代码替换成:
for (auto s : {"42", "-7", "3.14", "true", "false", "nil", "hello", "42abc"}) {
std::cout << s << " -> " << mkv::formatValue(mkv::valueFromToken(s)) << "\n";
}
2
3
应输出:
42 -> (integer) 42
-7 -> (integer) -7
3.14 -> (double) 3.140000
true -> true
false -> false
nil -> (nil)
hello -> "hello"
42abc -> "42abc" # 不是纯数字,落到字符串
2
3
4
5
6
7
8
8 种输入都被正确推断说明 Step 2.3 通过——测试结束记得删掉临时代码。
💡 小白避坑:
std::stoll/std::stod必须接std::string,不能直接接string_view。这就是为什么源码里有std::string(s)一次拷贝——在严谨的现代 C++ 项目里,类型推断这种偶尔调用的路径上,一次小拷贝完全可以接受。
# 3.4 卷一 18 章练习
合上书想一下:第 18 章讲过 variant 的什么特性?把它们一一对应到上面代码:
| 第 18 章特性 | 本节用到的位置 |
|---|---|
std::variant 是和类型 | Value 的定义 |
std::holds_alternative<T> | 5 个 isXxx 函数 |
std::visit + lambda | formatValue |
std::get<T> 取值(会抛 bad_variant_access) | 后面 GetCmd 会用 |
std::monostate 表"空" | 我们用自定义 Null{} 替代(语义更清晰) |
为什么不直接用
std::monostate?因为它的名字对业务无意义。自定义struct Null {}一行代码,换来代码可读性大涨——这就是"工程级 C++"和"教材 C++"的差别之一。
📌 阶段② 小结:Value 已经是真类型
收获 对应卷一章节 std::variant<Null, bool, int64, double, string>第 18 章和类型 std::visit+ 泛型 lambda +if constexpr第 18 章 + 第 17 章模板 holds_alternative<T>类型查询第 18 章 自定义 Null{}替代monostate提升可读性工程经验 当前代码量:~150 行;当前能力:能解析任意字符串 → 5 种类型 Value → 格式化输出。
下一阶段预告:进入阶段③,给 Value 加上 TTL 元数据成为
Entry,并把它装进IndexMap—— SET/GET 终于不再返回[TODO]。
# 04.Entry 与生命周期
光有 Value 还不够。一个 KV 条目除了"值"还要带元数据:什么时候创建的、什么时候过期、被改了几次。我们把这些打包成 Entry,并讨论一个深刻问题:Entry 应该住在哪里?
🎯 阶段③ · Entry 与索引(约 1 小时)
本阶段你将完成:
- Step 3.1 定义
Entry = Value + TTL + 元数据,并实现isExpired()→ ✅ 单测:手动设expireAt为 1 秒后,sleep 2 秒看 isExpired 转 true- Step 3.2 用
unordered_map<string, shared_ptr<Entry>>装索引,把 SET / GET 的[TODO]替换成真实存取 → ✅ 终于能SET k 123 / GET k看到(integer) 123阶段验收(也是第 1 次会话毕业验收):跑
./build/mini_kv,连续输入SET name zhangsan / SET age 25 / GET name / GET age / GET nokey,分别看到"zhangsan" / (integer) 25 / (nil)。重点知识点:智能指针所有权抉择、
std::optional、steady_clockvssystem_clock、五法则的"零定义即正确"。
# Step 3.1 Entry 与 TTL
#include <chrono>
#include <optional>
namespace mkv {
struct Entry {
Value value;
// 绝对过期时间点;nullopt 表示永不过期
std::optional<std::chrono::steady_clock::time_point> expireAt;
std::chrono::steady_clock::time_point createdAt = std::chrono::steady_clock::now();
std::uint64_t version = 0; // 每次被 SET 覆写就 +1,用于将来做 CAS
[[nodiscard]] bool isExpired() const noexcept {
return expireAt.has_value() && std::chrono::steady_clock::now() >= *expireAt;
}
};
} // namespace mkv
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
几个细节都不是随便写的:
std::optional<time_point>表"可选过期":替代 C 风格的time_t expireAt = -1(用魔法值表示"无"),optional让"无值"在类型上就显式存在。这是卷一第 18 章的核心理念。std::chrono::steady_clock不是system_clock:system_clock会被用户改系统时间影响(你 SET 完用户调慢钟,TTL 全乱套),steady_clock单调递增不会倒退,TTL 必须用它。[[nodiscard]]属性:调用entry.isExpired()但不用返回值的代码 99% 是写错了,加上这个标记编译器会警告。卷一第 18 章讲过的小特性,工程上用得极频繁。
✅ Step 3.1 编译运行验证:临时测试 TTL 行为:
#include <thread>
using namespace std::chrono_literals;
mkv::Entry e;
e.value = std::int64_t{42};
e.expireAt = std::chrono::steady_clock::now() + 1s;
std::cout << "now isExpired: " << e.isExpired() << "\n"; // 0
std::this_thread::sleep_for(1500ms);
std::cout << "1.5s later isExpired: " << e.isExpired() << "\n"; // 1
2
3
4
5
6
7
8
9
应输出:
now isExpired: 0
1.5s later isExpired: 1
2
看到 "现在没过期、1.5 秒后过期" 说明 Step 3.1 通过。
💡 小白避坑:用
system_clock(墙上时钟)代替steady_clock,看似也能工作,但只要用户在终端里执行sudo date -s ...调系统时间,所有 TTL 立刻乱套——单调时钟才是 TTL 的唯一正确选择。
# Step 3.2 IndexMap 接 main
🎯 本步目标:把
unordered_map<string, shared_ptr<Entry>>引入 main 主循环,第一次让 SET/GET 不返回[TODO]——这是阶段③的最终交付,也是第 1 次会话的毕业演出。
Store 内部要用一个 unordered_map<string, ???> 装所有 Entry。??? 的选择体现 C++ 工程师的功力:
| 方案 | 优点 | 缺点 |
|---|---|---|
unordered_map<string, Entry> | 零间接、缓存友好 | rehash 时 Entry 全部移动;多线程下迭代器易失效 |
unordered_map<string, std::unique_ptr<Entry>> | 唯一所有权清晰 | 想"返回一个 Entry 让外面看一眼"时只能借出引用,不能给所有权 |
unordered_map<string, std::shared_ptr<Entry>> | 可以安全地把 Entry 借给后台线程、借给日志 | 多一个原子引用计数 |
本案例选 std::shared_ptr<Entry>,理由如下:
- 后台 TTL 清理线程会扫描所有 key 检查过期,扫描期间可能有客户端正好读这个 key——
shared_ptr的引用计数让"正在被使用的 Entry"不会被清理线程析构。 - AOF 写盘时也想"持有这条 Entry 的快照再慢慢序列化",
shared_ptr一份引用 + 锁外操作完美适配。 - 引用计数的开销在 KV 这个量级(每秒几千次 set)完全可忽略。
#include <unordered_map>
#include <memory>
namespace mkv {
using EntryPtr = std::shared_ptr<Entry>;
using IndexMap = std::unordered_map<std::string, EntryPtr>;
} // namespace mkv
2
3
4
5
6
7
8
9
给 main 加上真正的 SET/GET 实现——把原来打印 [TODO] SET / [TODO] GET 的两个 case 替换成下面:
mkv::IndexMap idx; // main 函数内、while 循环外
// ... 在 switch 里 ...
case mkv::CmdType::Set: {
if (tokens.size() < 3) { std::cout << "(error) usage: SET key value\n"; break; }
auto entry = std::make_shared<mkv::Entry>();
entry->value = mkv::valueFromToken(tokens[2]);
idx[std::string(tokens[1])] = std::move(entry);
std::cout << "OK\n";
break;
}
case mkv::CmdType::Get: {
if (tokens.size() < 2) { std::cout << "(error) usage: GET key\n"; break; }
auto it = idx.find(std::string(tokens[1]));
if (it == idx.end() || it->second->isExpired()) std::cout << "(nil)\n";
else std::cout << mkv::formatValue(it->second->value) << "\n";
break;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
✅ Step 3.2 编译运行验证(也是第 1 次会话毕业验收):
$ cmake --build build && ./build/mini_kv
MiniKV v0.1 — type EXIT to quit.
> SET name zhangsan
OK
> SET age 25
OK
> GET name
"zhangsan"
> GET age
(integer) 25
> GET nokey
(nil)
> EXIT
bye.
2
3
4
5
6
7
8
9
10
11
12
13
14
5 个 case 全部正确说明你毕业了——你已经独立实现了一个虽然简陋但完整闭环的 KV 内存数据库。这一刻请 git commit -m "session-1: MVP with SET/GET",给自己一个稳定的回退点。
💡 小白避坑:
tokens[1]是string_view,作 map key 必须先std::string(tokens[1])转回 string。unordered_map的 key 是值语义,view 转 string 是必要的拷贝;后续阶段⑥会演示 C++20 的transparent hash怎么省掉这次拷贝。
# 4.3 内存模型回顾
写到这里可以停下来盘点一下:上面这几行代码,变量都住在哪里?
mkv::IndexMap idx; // ① idx 对象本身在栈
auto entry = std::make_shared<mkv::Entry>(); // ② 控制块 + Entry 在堆
entry->value = std::string("hello"); // ③ "hello" 短字符串可能 SSO 在 Entry 内, 长字符串在另一处堆
idx["foo"] = entry; // ④ map 内部桶在堆,键 "foo" 也是 std::string 在堆
2
3
4
| 区域 | 住户 |
|---|---|
| 栈 | idx、entry 这两个变量本身(指针/对象头部) |
| 堆(动态区) | Entry 本体、shared_ptr 控制块、unordered_map 的桶数组、长字符串的字符数据 |
| 静态/全局 | 字符串字面量 "hello" 在常量区(赋值给 std::string 时会拷贝到堆) |
| 代码区 | 函数 make_shared、operator[] 的指令本身 |
卷一第 11 章这张分区图你大概率背过;但只有在真实项目里指着自己的代码说出"这个变量在栈上、那个在堆上",你才算"懂"了。
# 4.4 五法则的现场验证
Entry 没有手写析构、没有手写拷贝构造、没有手写赋值——为什么没问题?
因为 Entry 的所有成员(Value / optional / time_point / uint64_t)都已经正确实现了五大特殊成员函数。卷一第 9 章讲过的"五法则"其实是说:"如果你必须手写其中一个,你大概率要写全五个。" 反过来——如果你的类成员都是 RAII 的、所有权清晰的,一个都不用写就是最好的写法。
Entry 就是范例:32 行结构体,零自定义成员函数,能拷贝、能移动、能销毁,全部正确。这就是现代 C++ 的味道——让类型系统替你工作。
📌 阶段③ 小结 + 🎉 第 1 次会话毕业
收获 对应卷一章节 Entry = Value + optional<time_point> + 元数据第 18 章 optional + 第 14 章 chrono shared_ptr<Entry>+unordered_map索引第 12 章智能指针 + STL [[nodiscard]]/noexcept工程级标注第 18 章 + 第 14 章 五法则的零定义胜利(编译器全自动) 第 9 章 栈/堆/常量区/代码区分布盘点 第 11 章 当前代码量:~200 行;当前能力:8 个命令解析 OK、SET/GET 真实生效、TTL 字段就绪(但 EXPIRE 命令尚未实现)。
第 1 次会话验收清单(建议截图保存进度):
- [x]
./build/mini_kv能跑 REPL 不崩溃- [x]
SET name zhangsan→OK,GET name→"zhangsan"- [x]
SET age 25→OK,GET age→(integer) 25- [x]
GET nokey→(nil)- [x]
WRONG→(error) unknown command- [x]
EXIT干净退出- [x]
git commit -m "session-1: MVP with SET/GET"完成下一会话预告:第 2 次会话将启动命令模式重构——你现在 main 里那个 200 行 switch 会被抽成 5 个 Command 派生类,加新命令零修改主流程。
到此 Value 和 Entry 都齐活,下一节我们用命令模式把 8 个命令串成一棵继承树,让 main 函数从此告别长长的 if-else。
# 05.Command 命令模式
到此为止主循环里还是 switch + case + 调函数 的写法。这种写法在命令少时没问题,但有两个隐患:新增命令要改 switch、命令的"参数解析+执行+持久化"会散落在三处。本节用**命令模式(Command Pattern)**重构,把每条命令收敛成一个类,正面对接卷一第 9 / 10 / 12 章。
🎯 阶段④ · 命令模式重构(约 1.5 小时)
本阶段你将完成:
- Step 4.1 ⚠️ 造 BUG 演示:先把 8 个命令全堆进 main 里,亲眼看 200+ 行 switch 长什么样 → ✅ 体会到"再加一个命令,main 就要破 300 行"的窒息感
- Step 4.2 设计抽象基类
Command,定义 4 个虚接口(name/isWrite/execute/toAofLine) → ✅ 编译通过- Step 4.3 实现
SetCmd / GetCmd / DelCmd / ExpireCmd4 个派生类 → ✅ 单测:手动 new 一个 SetCmd 调 execute 看返回值- Step 4.4 写工厂函数
makeCommand,把 tokens 一键变成unique_ptr<Command>→ ✅ 测试 4 种命令都能正确分发- Step 4.5 🔧 修 BUG:把 main 里 200 行 switch 删光,换成 8 行多态调用 → ✅ 全部命令仍正常工作,但 main 干净了 90%
阶段验收:跑同样的命令序列得到同样结果,但 main 函数从 200 行缩到 80 行,加新命令只需"写派生类 + 在 makeCommand 加一行"。
重点知识点:抽象基类 / 纯虚函数 / vtable 内存布局 /
[[nodiscard]]/make_unique异常安全。
# 灵魂三问:动手前先想清楚
❓ 问题一:if-else 真的不能继续用吗?8 个命令凑合一下不就行了?
答案是"短期能凑合,长期会窒息"。来看真实 Redis 的对比:
| 维度 | if-else 路线 | 命令模式路线 |
|---|---|---|
| 8 命令时 | main ~150 行 | main ~30 行 + 8 个独立类 |
| 80 命令时 | main ~1500 行(地狱) | main 不变,多 72 个类 |
| 加一个新命令 | 改 3 处(switch / parseCmdType / 业务) | 加 1 个文件,改 1 处 |
| 单测 SetCmd 逻辑 | 必须启动整个 REPL | 直接 SetCmd c; c.execute(s); |
| 命令复用(如 MSET 复用 SET) | copy-paste | 内部直接 new SetCmd 即可 |
| 持久化逻辑 | 散落 8 处 case,漏一处丢数据 | 集中在 cmd->toAofLine() 一处 |
✅ 结论:8 命令是"教学规模",真实工程的命令规模一定会增长。抽象的成本是先付的,收益是延期的——但延期收益永远 >> 先付成本。
❓ 问题二:智能指针为什么选 unique_ptr<Command> 而不是 shared_ptr<Command>?
来看反例:
// ❌ 反例:到处用 shared_ptr
std::shared_ptr<Command> cmd = makeCommand(tokens);
cmd->execute(store);
2
3
问题:
- 语义不对——一条命令从被解析到被执行,全程只有一个所有者(main 局部变量)。"共享"是个伪需求。
- 性能浪费——
shared_ptr的引用计数是原子操作,每次拷贝/赋值要 atomic 加减。unique_ptr是零开销的纯指针包装。 - 隐藏延迟析构——shared_ptr 在最后一个引用消失时才析构,可能跨线程;unique_ptr 在作用域结束确定析构。
✅ 正确做法:默认用 unique_ptr,需要共享时再降级 / 升级。这条规则放之四海而皆准——本案例 §11 的 Server 持有 Store 也用 unique_ptr。只有 §04 的 EntryPtr = shared_ptr<Entry> 是真正"多个 key 可能指向同一 Entry"的共享场景,才该用 shared。
❓ 问题三:为什么 toAofLine() 要独立成一个虚函数,而不是让 main 自己拼字符串?
来看反例:
// ❌ 反例:main 里拼 AOF 字符串
auto cmd = makeCommand(tokens);
cmd->execute(store);
if (cmd->isWrite()) {
if (cmd->name() == "SET") aof << "SET " << /* ??? 怎么拿到 cmd 内部的 key 和 value */;
if (cmd->name() == "DEL") aof << "DEL " << /* ??? */;
if (cmd->name() == "EXPIRE") aof << "EXPIRE " << /* ??? */;
// ... 又来 8 个 if
}
2
3
4
5
6
7
8
9
问题:
- 暴露内部字段——main 要拼 AOF 就必须 down-cast 回
SetCmd*拿 key_/value_,封装彻底破产 - switch 又长出来了——本来命令模式就是为了消灭 main 里的 switch,结果在持久化里又重新写一遍
- 格式分散——AOF 序列化逻辑散落在 main,未来支持 RESP 协议要全改一遍
✅ 正确做法:让每个命令自己负责"我该怎么写进 AOF"——这是单一职责原则的最直接体现。一个命令对象 = "怎么校验 + 怎么执行 + 怎么持久化"三位一体。
🔑 教学要点:抽象基类的虚函数列表,本质上是"对外契约"。每多一个虚函数 = 多一个对外承诺。设计基类时反复问自己:"这个能力是不是所有派生类都该有?"——能就提到基类,不能就别污染基类。
# Step 4.1 if-else 大爆炸
🎯 本步目标:故意先走错路——把 SET / GET / DEL / EXPIRE / TTL / KEYS / SAVE 全部塞进 main 里,让你亲眼感受"不抽象的代价"。不抽象时代码会以多快的速度腐烂,没有比自己写一遍更深刻的体会。
5.1 if-else 越长越烂
设想你已经实现了 8 个命令,main 大致长这样:
// 反面教材:所有逻辑全在 main
case CmdType::Set: {
if (tokens.size() < 3) { std::cout << "(error) SET need 2 args\n"; break; }
std::string key(tokens[1]);
Value val = valueFromToken(tokens[2]);
auto entry = std::make_shared<Entry>();
entry->value = std::move(val);
store[key] = entry;
aof << "SET " << key << " " << tokens[2] << "\n"; aof.flush();
std::cout << "OK\n";
break;
}
case CmdType::Get: { /* 又来一坨 */ break; }
// ... × 8
2
3
4
5
6
7
8
9
10
11
12
13
14
问题清单:
- 职责堆叠:参数校验、业务逻辑、AOF 写盘、输出格式四件事挤在一个 case 里,没法单测。
- 新增命令必须修改 main:违反"开闭原则"——对扩展开放、对修改关闭。
- 命令之间无法复用:比如
MSET key1 v1 key2 v2想复用 SET,只能复制粘贴。 - 持久化逻辑散落:每个 case 都要记得调
aof <<,少写一处就丢数据。
🔍 数一数:现在如果让你加一个 INCR 命令(key 自增 1),你需要改几个地方?
答:① main 的 switch 里加一个 case;② parseCmdType 加一个映射;③ 在新 case 里写参数校验+取值+加1+写回+AOF+输出,6 件事一字排开。改 3 处文件、写 30 行代码,只为了一个新功能——这就是"未抽象的代价"。
💡 小白领悟:你也许会想"再忍忍呗,反正能跑"。但 8 个命令的项目还能忍,等到 80 个命令的真实 Redis 你就会写到崩溃。抽象不是为了炫技,是为了能在 3 个月后还看得懂自己写的代码。
✅ Step 4.1 验收:合上书凭印象写一遍这种 200 行 switch(不用真编译,写在草稿纸或注释里都行),写到 100 行的时候你会感受到"这玩意儿不能这么下去了"——有这种厌恶感,说明你已经准备好接受抽象了。
# Step 4.2 抽象基类 Command
🎯 本步目标:把"一条命令"抽象成一个对象,让对象自己知道怎么校验、怎么执行、怎么序列化成 AOF。这一步只写接口,连一个派生类都不写——先把蓝图画清楚。
5.2 抽象基类 Command
我们把"一条命令"抽象成一个对象,对象自己知道:怎么校验参数、怎么执行、怎么序列化成 AOF 字符串。
namespace mkv {
class Store; // 前置声明
class Command {
public:
virtual ~Command() = default;
// 用户可见的命令名,例如 "SET"
[[nodiscard]] virtual std::string_view name() const noexcept = 0;
// 是否需要写入 AOF(GET / KEYS / TTL 这种只读命令返回 false)
[[nodiscard]] virtual bool isWrite() const noexcept = 0;
// 执行命令,返回给客户端看的字符串
[[nodiscard]] virtual std::string execute(Store& store) = 0;
// 序列化回 AOF 行(仅写命令需实现,读命令直接返回空字符串)
[[nodiscard]] virtual std::string toAofLine() const { return {}; }
protected:
Command() = default;
Command(const Command&) = delete; // 禁止拷贝
Command& operator=(const Command&) = delete;
};
} // namespace mkv
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
几个关键设计:
| 设计 | 为什么 |
|---|---|
virtual ~Command() = default | 第 10 章铁律:基类析构必须 virtual,否则 delete (Command*)setCmd 不会调派生类析构 |
= 0 纯虚函数 | Command 不允许被实例化,强制派生 |
[[nodiscard]] 标在 execute 上 | 命令的输出绝不能被丢弃,编译器帮你查 |
拷贝构造和拷贝赋值 = delete | 命令对象一次性使用,没有复制语义;只允许 move(默认移动也被 delete 了,需要时再放开) |
noexcept 标在 name/isWrite 上 | 这两个返回常量,绝不抛 |
✅ Step 4.2 编译验证:把上面的 Command 类粘到 command.h,加个空的 command.cpp 包含它,编译应该直接通过——纯接口类不需要任何实现。
$ cmake --build build
[ OK ] command.h compiled (no warnings)
2
如果你故意写一行 mkv::Command c; 编译,会得到错误信息:
error: variable type 'mkv::Command' is an abstract class
note: unimplemented pure virtual method 'name' / 'isWrite' / 'execute'
2
这正是 = 0 的力量:编译器帮你把关,禁止任何人直接构造 Command。看到这个报错说明 Step 4.2 通过——记得删掉那行测试代码。
💡 小白避坑:忘记把析构写成
virtual是 C++ 经典坑。你写delete (Command*)setCmd;时,只有基类析构是 virtual,编译器才会通过 vtable 调到派生类析构——否则 SetCmd 里的string key_就泄漏了。这条铁律 100% 严格遵守,任何抽象基类都要virtual ~X() = default。
# Step 4.3 实现派生类
🎯 本步目标:把蓝图变成实物——写 SetCmd / GetCmd / DelCmd / ExpireCmd 四个具体类。每个类负责"一种命令的全部逻辑",参数解析、执行、AOF 序列化都内聚在自己内部。
5.3 五个具体命令
下面给出 4 个核心命令的实现,剩下的(TTL/KEYS/SAVE)模式相同,留作练习。
// SET key value
class SetCmd : public Command {
public:
SetCmd(std::string key, Value value, std::string rawValueToken)
: key_(std::move(key)), value_(std::move(value)), rawToken_(std::move(rawValueToken)) {}
std::string_view name() const noexcept override { return "SET"; }
bool isWrite() const noexcept override { return true; }
std::string execute(Store& store) override; // 见下文 Store
std::string toAofLine() const override {
return "SET " + key_ + " " + rawToken_ + "\n";
}
private:
std::string key_;
Value value_;
std::string rawToken_; // 原始 token,写入 AOF 时不再二次 format
};
// GET key
class GetCmd : public Command {
public:
explicit GetCmd(std::string key) : key_(std::move(key)) {}
std::string_view name() const noexcept override { return "GET"; }
bool isWrite() const noexcept override { return false; }
std::string execute(Store& store) override;
private:
std::string key_;
};
// DEL key
class DelCmd : public Command {
public:
explicit DelCmd(std::string key) : key_(std::move(key)) {}
std::string_view name() const noexcept override { return "DEL"; }
bool isWrite() const noexcept override { return true; }
std::string execute(Store& store) override;
std::string toAofLine() const override { return "DEL " + key_ + "\n"; }
private:
std::string key_;
};
// EXPIRE key seconds
class ExpireCmd : public Command {
public:
ExpireCmd(std::string key, int seconds) : key_(std::move(key)), seconds_(seconds) {}
std::string_view name() const noexcept override { return "EXPIRE"; }
bool isWrite() const noexcept override { return true; }
std::string execute(Store& store) override;
std::string toAofLine() const override {
return "EXPIRE " + key_ + " " + std::to_string(seconds_) + "\n";
}
private:
std::string key_;
int seconds_;
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
多态的甜头:上面 4 个类长得几乎一样,但任何调用方都只需要持有 std::unique_ptr<Command>,然后调 cmd->execute(store) —— 调到哪个派生类的 execute 由对象的实际类型决定,这就是**虚函数表(vtable)**的工作。
卷一第 10 章讲过 vtable 的内存布局:每个
Command派生类对象在堆上多 8 字节存一个指向 vtable 的指针;vtable 里 4 个 slot 分别指向 name/isWrite/execute/toAofLine 的实际地址。命令模式之所以"零修改主流程能加新命令",本质就是把 if-else 的"开关"挪到了 vtable 里。
✅ Step 4.3 单元测试:先别接 Store,单独测一下派生类能不能 new 出来:
int main() {
auto setCmd = std::make_unique<mkv::SetCmd>(
"name", std::string("zhangsan"), "zhangsan");
std::cout << "name: " << setCmd->name() << "\n";
std::cout << "isWrite: " << setCmd->isWrite() << "\n";
std::cout << "toAofLine: " << setCmd->toAofLine();
mkv::Command* base = setCmd.get(); // 多态测试
std::cout << "poly name: " << base->name() << "\n";
}
2
3
4
5
6
7
8
9
10
应输出:
name: SET
isWrite: 1
toAofLine: SET name zhangsan
poly name: SET
2
3
4
看到"通过基类指针调用,输出仍然是 SET"——这就是多态真实工作了。Step 4.3 通过。
💡 小白避坑:如果你看到
name()返回乱码或崩溃,99% 是std::string_view指向了已销毁的临时字符串。return "SET";这种字符串字面量是程序生命周期的,安全;但如果你写return std::string("SET");就会返回一个临时 string 的 view,函数结束 view 就悬垂了。string_view必须返回生命周期更长的源——这条规则刻进 DNA。
# Step 4.4 makeCommand 工厂
🎯 本步目标:把"字符串 tokens →
unique_ptr<Command>"的逻辑收敛到唯一一处——以后整个 main 里再也不直接new SetCmd,全部走工厂。这就是"对象创建集中化"。
5.4 工厂函数 makeCommand
把"字符串 tokens → unique_ptr<Command>" 的逻辑收敛到一个工厂函数:
#include <stdexcept>
namespace mkv {
// 预先声明异常(07 节会详细讲)
class CmdSyntaxError : public std::runtime_error {
public:
using std::runtime_error::runtime_error;
};
[[nodiscard]]
std::unique_ptr<Command> makeCommand(const std::vector<std::string_view>& tokens) {
if (tokens.empty()) throw CmdSyntaxError("empty command");
auto needArgs = [&](size_t n, std::string_view name) {
if (tokens.size() != n + 1)
throw CmdSyntaxError(std::string(name) + " expects " + std::to_string(n) + " arg(s)");
};
switch (parseCmdType(std::string(tokens[0]))) {
case CmdType::Set: {
needArgs(2, "SET");
return std::make_unique<SetCmd>(
std::string(tokens[1]),
valueFromToken(tokens[2]),
std::string(tokens[2]));
}
case CmdType::Get: {
needArgs(1, "GET");
return std::make_unique<GetCmd>(std::string(tokens[1]));
}
case CmdType::Del: {
needArgs(1, "DEL");
return std::make_unique<DelCmd>(std::string(tokens[1]));
}
case CmdType::Expire: {
needArgs(2, "EXPIRE");
int sec = std::stoi(std::string(tokens[2]));
return std::make_unique<ExpireCmd>(std::string(tokens[1]), sec);
}
default:
throw CmdSyntaxError("unknown command: " + std::string(tokens[0]));
}
}
} // namespace mkv
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
短短 8 行,加新命令完全不用改这里——只需要:① 写一个新派生类,② 在 makeCommand 加一个 case。这就是命令模式的力量。
✅ Step 4.5 编译运行验证:跑跟阶段③一样的命令序列:
$ cmake --build build && ./build/mini_kv
> SET name zhangsan
OK
> SET age 25
OK
> GET name
"zhangsan"
> GET age
(integer) 25
> DEL age
(integer) 1
> GET age
(nil)
> SET # 故意少参
(error) SET expects 2 arg(s)
> EXIT
bye.
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
看到 SET / GET / DEL 全部正常 + 错误命令被工厂拦截 → Step 4.5 通过。对比 Step 4.1 的痛苦感——main 的核心逻辑只剩 8 行,所有命令各自隔离在自己的派生类里。
💡 小白领悟:你刚刚完成了 C++ 工程师生涯的第一次重构——功能不变、代码大幅精简、扩展性大幅提升。写程序的真正乐趣有一半就在这种"重构后回头看"的瞬间。
✅ Step 4.4 编译运行验证:写一段集成测试:
int main() {
std::vector<std::string_view> t1{"SET", "name", "zhangsan"};
std::vector<std::string_view> t2{"GET", "name"};
std::vector<std::string_view> t3{"DEL"}; // 故意少参
try {
auto c1 = mkv::makeCommand(t1); std::cout << c1->name() << "\n";
auto c2 = mkv::makeCommand(t2); std::cout << c2->name() << "\n";
auto c3 = mkv::makeCommand(t3); std::cout << c3->name() << "\n";
} catch (const mkv::CmdSyntaxError& e) {
std::cout << "caught: " << e.what() << "\n";
}
}
2
3
4
5
6
7
8
9
10
11
12
13
应输出:
SET
GET
caught: DEL expects 1 arg(s)
2
3
看到前两个正常分发、第三个被异常拦下来——工厂的护栏起作用了。Step 4.4 通过。
💡 小白避坑:
makeCommand里的parseCmdType接收的是std::string,所以要std::string(tokens[0])一次拷贝;这是 view → string 的常见接口边界,老老实实拷一次更安全。等你做并发版本时再考虑用from_chars之类的优化。
# Step 4.5 main 大瘦身
🎯 本步目标:兑现承诺——把 Step 4.1 那个 200 行 switch 干掉,换成 8 行多态调用。代码体积砍 90%,但功能完全不变。这就是"重构的味道"。
现在 main 的 switch 可以彻底瘦身:
auto tokens = tokenize(line);
try {
auto cmd = mkv::makeCommand(tokens);
std::string out = cmd->execute(store);
if (cmd->isWrite()) aofWrite(cmd->toAofLine());
std::cout << out << "\n";
} catch (const mkv::CmdSyntaxError& e) {
std::cout << "(error) " << e.what() << "\n";
}
2
3
4
5
6
7
8
9
短短 8 行,加新命令完全不用改这里——只需要:① 写一个新派生类,② 在 makeCommand 加一个 case。这就是命令模式的力量。
✅ Step 4.5 编译运行验证:跑跟阶段③一样的命令序列:
$ cmake --build build && ./build/mini_kv
> SET name zhangsan
OK
> SET age 25
OK
> GET name
"zhangsan"
> SET # 故意少参
(error) SET expects 2 arg(s)
> EXIT
bye.
2
3
4
5
6
7
8
9
10
11
看到正常命令走多态 + 错误命令被异常拦截 → Step 4.5 通过。对比 Step 4.1 的痛苦感——main 的核心逻辑只剩 8 行,所有命令各自隔离在自己的派生类里。
💡 小白领悟:你刚刚完成了 C++ 工程师生涯的第一次重构——功能不变、代码大幅精简、扩展性大幅提升。
📌 阶段④ 小结:命令模式已落地
收获 对应卷一章节 抽象基类 Command+ 纯虚函数第 10 章继承多态 virtual ~Cmd() = default铁律第 10 章虚析构 [[nodiscard]]+noexcept工程标注第 14 + 18 章 std::unique_ptr<Command>+make_unique第 12 章智能指针 工厂函数 + 异常拦截非法命令 第 9 章构造 + 工程模式 vtable 让"加新命令零修改主流程" 第 10 章 vtable 内存布局 当前代码量:~400 行;当前能力:4 种命令通过多态正确执行、错误命令被异常拦截。
下一阶段预告:进入阶段⑤,把现在散落在 main 里的
unordered_map<string, EntryPtr>抽成Store类——并见识 STL 容器最经典的迭代器删除坑。
# 06.Store 串起命令
Store 是 KV 存储的"数据中心"——所有 Entry 都住在它的 IndexMap 里,所有命令的 execute 都通过它操作数据。本节展示类设计的工程级思路和模板化命令注册。
🎯 阶段⑤+⑥ · Store 核心 + 集成(约 1 小时)
本阶段你将完成:
- Step 5.1 写
Store的字段与方法签名(先不写实现),把所有= delete标记完整 → ✅ 编译通过- Step 5.2 实现 7 个核心方法(set / get / del / expire / ttl / keys / size) → ✅ 单测:直接构造 Store 调方法
- Step 5.3 ⚠️ 造 BUG → 修复:先写错误的迭代器删除让程序崩溃,再用
it = erase(it)修复 → ✅ 看到 STL 经典坑- Step 5.4 把 4 个派生类的
execute接到 Store 上 → ✅ 集成测试:完整 SET/GET/DEL/EXPIRE 走通- Step 6.1(选讲)模板化命令注册表,让插件式扩展成为可能 → ✅ 通过则进阶,跳过也不影响主线
阶段验收(也是第 2 次会话毕业):可以连续 SET 几个 key、EXPIRE、TTL、sleep 后 GET 看到
(nil)—— 且 main 函数从 200+ 行减到 80 行以内。重点知识点:const 正确性 /
= delete五法则 / STL 迭代器失效 /std::function+ 模板工厂。
# Step 5.1 Store 字段签名
🎯 本步目标:先把"骨架"立起来——只写字段和方法签名,不写实现。这一步是"对外契约"的设计,比写实现重要 10 倍。
6.1 Store 的字段设计
namespace mkv {
class Store {
public:
Store() = default;
// 五法则:Store 是有所有权的资源管理者,禁止拷贝
Store(const Store&) = delete;
Store& operator=(const Store&) = delete;
Store(Store&&) = delete;
Store& operator=(Store&&) = delete;
// ====== 命令对应的底层操作 ======
void set (const std::string& key, Value v);
EntryPtr get (const std::string& key) const; // 不存在返回 nullptr
std::size_t del (const std::string& key); // 删了返回 1,否则 0
bool expire (const std::string& key, int seconds); // 设过期;key 不存在返回 false
std::int64_t ttl (const std::string& key) const; // 剩余秒;-2 不存在 -1 永久
std::vector<std::string> keys() const;
std::size_t size() const noexcept;
// ====== 维护操作 ======
void purgeExpired(); // 后台线程调用:扫描并删除过期 key
private:
IndexMap idx_;
};
} // namespace mkv
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
几个工程级习惯:
- 五大特殊成员一次性
= delete:Store是单例式的"中心组件",复制它毫无意义且代价高昂;显式 delete 比"留着默认实现等出 bug"安全得多。 - const 正确性:
get/ttl/keys/size都标const——告诉调用方"我不改你",也让 const Store 引用能调它们。 - 返回类型表达失败:
get用EntryPtr(可为 nullptr)表"找不到";ttl用约定-2/-1表多种"找不到的原因"——这两种风格都比抛异常合适,因为"key 不存在"是高频常态而非异常。
✅ Step 5.1 编译验证:把上面贴到 store.h,再写一个空的 store.cpp 包含它,应该直接编译通过——纯声明不需要实现。如果你忘记前置声明 class Store; 而 Command::execute 又用了 Store&,会报 error: 'Store' has not been declared ——这就是头文件循环依赖的初阶预警。
💡 小白避坑:四个
= delete别图省事只写两个。Store(const Store&) = delete会自动禁拷贝但不会自动禁移动,一些编译器认为"你只删了拷贝,那移动还要保留"。显式四个全 delete 才是真禁止,零模糊。
# Step 5.2 七个核心方法
🎯 本步目标:把签名变成可工作的代码。每实现一个方法立刻测一下,不要一口气写完 7 个再编译——那样调试起来定位困难。
6.2 实现:成员函数
namespace mkv {
void Store::set(const std::string& key, Value v) {
auto it = idx_.find(key);
if (it == idx_.end()) {
auto entry = std::make_shared<Entry>();
entry->value = std::move(v);
idx_.emplace(key, std::move(entry));
} else {
it->second->value = std::move(v);
it->second->version += 1;
it->second->expireAt.reset(); // SET 会清掉旧的 TTL(与 Redis 行为一致)
}
}
EntryPtr Store::get(const std::string& key) const {
auto it = idx_.find(key);
if (it == idx_.end()) return nullptr;
if (it->second->isExpired()) return nullptr; // 惰性删除:读到过期当不存在
return it->second;
}
std::size_t Store::del(const std::string& key) {
return idx_.erase(key); // unordered_map::erase 返回删除的元素数
}
bool Store::expire(const std::string& key, int seconds) {
auto it = idx_.find(key);
if (it == idx_.end()) return false;
it->second->expireAt = std::chrono::steady_clock::now() + std::chrono::seconds(seconds);
return true;
}
std::int64_t Store::ttl(const std::string& key) const {
auto it = idx_.find(key);
if (it == idx_.end()) return -2;
if (!it->second->expireAt) return -1;
auto remaining = std::chrono::duration_cast<std::chrono::seconds>(
*it->second->expireAt - std::chrono::steady_clock::now()).count();
return remaining < 0 ? 0 : remaining;
}
std::vector<std::string> Store::keys() const {
std::vector<std::string> out;
out.reserve(idx_.size());
for (const auto& [k, v] : idx_) {
if (!v->isExpired()) out.push_back(k);
}
return out;
}
std::size_t Store::size() const noexcept { return idx_.size(); }
void Store::purgeExpired() {
for (auto it = idx_.begin(); it != idx_.end(); ) {
if (it->second->isExpired()) it = idx_.erase(it);
else ++it;
}
}
} // namespace mkv
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
✅ Step 5.2 单元测试:还没接 Command,先用 main 直接测 Store:
int main() {
mkv::Store s;
s.set("name", std::string("zhangsan"));
s.set("age", std::int64_t{25});
std::cout << "size = " << s.size() << "\n"; // 2
std::cout << "get name = " << mkv::formatValue(s.get("name")->value) << "\n"; // "zhangsan"
std::cout << "get nokey = " << (s.get("nokey") ? "hit" : "miss") << "\n"; // miss
std::cout << "del age = " << s.del("age") << "\n"; // 1
std::cout << "del age = " << s.del("age") << "\n"; // 0 幂等
s.expire("name", 100);
std::cout << "ttl name = " << s.ttl("name") << "\n"; // 接近 100
std::cout << "ttl nokey = " << s.ttl("nokey") << "\n"; // -2
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
应输出:
size = 2
get name = "zhangsan"
get nokey = miss
del age = 1
del age = 0
ttl name = 99
ttl nokey = -2
2
3
4
5
6
7
7 个方法逐一过关 → Step 5.2 通过。注意 del 第二次返回 0——这就是"幂等"的体现,删一个不存在的 key 不报错。
# Step 5.3 迭代器失效
🎯 本步目标:亲眼看一次 STL 最经典的坑——
erase后还自增迭代器导致 UB——再用正确写法修复。这是面试官最爱问的问题之一,自己踩一次永远忘不了。
purgeExpired 里的迭代器删除——卷一第 16 章的 STL 易错点:
// 错误写法(it 失效后还自增)
for (auto it = idx_.begin(); it != idx_.end(); ++it) {
if (...) idx_.erase(it); // erase 后 it 失效,++it 是 UB
}
// 正确写法(erase 返回下一个有效迭代器)
for (auto it = idx_.begin(); it != idx_.end(); ) {
if (...) it = idx_.erase(it);
else ++it;
}
2
3
4
5
6
7
8
9
10
这是 C++ 容器最经典的坑之一。
erase返回下一个迭代器是 C++11 起补的"善意 API",记住这个返回值能救你很多次。
✅ Step 5.3 造 BUG 复现(强烈推荐做一次):把 purgeExpired 故意改成错误版运行一次,然后再修回正确版。在 main 里加:
mkv::Store s;
for (int i = 0; i < 10; ++i) {
s.set("k" + std::to_string(i), std::int64_t{i});
s.expire("k" + std::to_string(i), 1);
}
std::this_thread::sleep_for(1500ms);
s.purgeExpired(); // 💥 错误版本下大概率崩溃 / 死循环 / 莫名残留
std::cout << "size after purge = " << s.size() << "\n";
2
3
4
5
6
7
8
错误版运行后可能看到(每次表现可能不同,这就是 UB 最坑的地方):
- 程序崩溃
Segmentation fault - 程序 hang 住死循环
- 输出
size after purge = 3(部分残留,看似正常实则随机)
🔧 修复:换回原版 it = idx_.erase(it),再跑一次测试输出 size after purge = 0 —— bug 消失。
💡 小白领悟:UB(未定义行为)的可怕之处在于"它不一定崩"——也许 dev 机器跑过了,到生产环境某天突然崩,且崩的瞬间和 bug 起源毫无关联。在 STL 上写迭代器循环,永远要查一遍"erase / insert 是否使迭代器失效"——
unordered_map/vector/list各有不同规则。
# Step 5.4 派生类接 Store
🎯 本步目标:把 5.3 节留的"
execute见下文"补上——4 个派生类终于不再是空壳,能真正操作数据了。
6.3 execute 接 Store
回到 5.3 节留的"execute 见下文",现在补上:
namespace mkv {
std::string SetCmd::execute(Store& store) {
store.set(key_, value_);
return "OK";
}
std::string GetCmd::execute(Store& store) {
auto entry = store.get(key_);
if (!entry) return "(nil)";
return formatValue(entry->value);
}
std::string DelCmd::execute(Store& store) {
auto n = store.del(key_);
return "(integer) " + std::to_string(n);
}
std::string ExpireCmd::execute(Store& store) {
return store.expire(key_, seconds_) ? "OK" : "(integer) 0";
}
} // namespace mkv
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
短短 20 行就把 4 个命令串起来——因为前面的抽象做对了。
✅ Step 5.4 集成测试(第 2 次会话核心验收):把 main 改回完整的 REPL,传一个 Store store; 进循环,跑:
$ ./build/mini_kv
> SET name zhangsan
OK
> SET age 25
OK
> GET name
"zhangsan"
> EXPIRE name 1
OK
> DEL age
(integer) 1
> GET age
(nil)
> EXIT
bye.
2
3
4
5
6
7
8
9
10
11
12
13
14
15
SET / GET / DEL / EXPIRE 全部走通 → Step 5.4 通过。这一刻你已经完成从"main 里堆代码"到"Command + Store 双层架构"的跃迁。
# Step 6.1 模板化注册表
🎯 本步目标(选讲):再进一步——把
makeCommand的 switch 也干掉,换成"注册表 + Lambda"。读完不实现也不影响主线,但你能看到"插件式架构"在 C++ 里的样子。
6.4 registerCmd 选讲
如果你不想每加一个命令就改 makeCommand 的 switch,可以把命令类注册到一张表里,用模板自动推导:
#include <functional>
#include <unordered_map>
namespace mkv {
class CommandFactory {
public:
using Builder = std::function<std::unique_ptr<Command>(const std::vector<std::string_view>&)>;
template <typename CmdT>
void registerCmd(std::string_view name, Builder b) {
builders_.emplace(std::string(name), std::move(b));
(void)sizeof(CmdT); // 只是借模板参数做个静态断言占位
static_assert(std::is_base_of_v<Command, CmdT>, "CmdT must derive from Command");
}
[[nodiscard]]
std::unique_ptr<Command> build(const std::vector<std::string_view>& tokens) const {
if (tokens.empty()) throw CmdSyntaxError("empty command");
std::string up;
for (char c : tokens[0]) up.push_back(static_cast<char>(std::toupper(c)));
auto it = builders_.find(up);
if (it == builders_.end()) throw CmdSyntaxError("unknown command: " + up);
return it->second(tokens);
}
private:
std::unordered_map<std::string, Builder> builders_;
};
inline CommandFactory& globalFactory() {
static CommandFactory f;
return f;
}
} // namespace mkv
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
启动时一次性注册:
auto& f = mkv::globalFactory();
f.registerCmd<mkv::SetCmd>("SET", [](const auto& t) {
if (t.size() != 3) throw mkv::CmdSyntaxError("SET expects 2 args");
return std::make_unique<mkv::SetCmd>(
std::string(t[1]), mkv::valueFromToken(t[2]), std::string(t[2]));
});
f.registerCmd<mkv::GetCmd>("GET", [](const auto& t) { /* ... */ });
// ...
2
3
4
5
6
7
8
模板 + Lambda + 工厂 是 C++ 工程里"插件化"的经典套路:第三方写一个 MyCmd : public Command,调一行 registerCmd<MyCmd>(...) 就接入了,内核代码一行不动。
这套机制和卷一第 16 章
std::function+ 第 10 章虚函数 + 第 7 章 lambda 是一脉相承的。看到这里如果你还能跟上,恭喜——你已经在用"组件化思维"写 C++ 了。
📌 阶段⑤+⑥ 小结 + 🎉 第 2 次会话毕业
收获 对应卷一章节 类设计:字段、方法、五法则 = delete第 9 章 const 正确性 + [[nodiscard]]第 14 + 18 章 失败用返回值表达( -2/-1约定)工程经验 STL 迭代器失效与正确删除 第 16 章 UB 的可怕:不崩不代表对 工程教训 std::function+ 模板工厂 + Meyers 单例第 16 + 17 章 当前代码量:~600 行;当前能力:完整 SET/GET/DEL/EXPIRE 经过命令模式 → Store → 内存这条完整链路。
第 2 次会话验收清单(建议截图保存进度):
- [x] main 函数代码量从 200+ 行减到 80 行以内
- [x] 加新命令只需"写派生类 + 工厂加一行",不动 main
- [x]
SET / GET / DEL / EXPIRE全部正常工作- [x] 错误命令(如
SET缺参)被异常优雅拦截- [x] 迭代器失效坑亲身踩过且修复
- [x]
git commit -m "session-2: command-pattern + Store"完成下一会话预告:第 3 次会话进入异常体系和 AOF 持久化——你将看到"程序重启后数据还在"的魔法,并学会怎么把异常按层次组织成三套铠甲。
下一节我们把异常体系正式建起来,让所有错误都走同一条管道。
# 07.异常体系工程化
🎯 阶段⑦ · 异常体系建立
子步骤 内容 卷一对应章节 验证点 Step 7.1 选择题:异常 vs 错误码 第 14 章 ✅ 决策矩阵记心里 Step 7.2 设计 5 个自定义异常类( KvError树)第 14 章 + 第 9 章继承 ✅ 编译通过 Step 7.3 ⚠️ 造 BUG → 修复:不抓异常 → 程序崩溃 → 加分层 catch 第 14 章 ✅ 看到 terminate calledStep 7.4 应用守则:fail-fast + noexcept 标注 第 14 章 + 第 18 章 ✅ 错误命令优雅提示 本阶段后能力跃迁:从"哪里出错就在哪里
cout一句"升级到"按层抛出 → 顶层统一捕获 → 按错误类型分流处理"——这是工程级 C++ 项目的标准做法。⏱ 预估:阅读 30 min + 动手 60 min = 1.5 小时(重点是 Step 7.3 的造 BUG 实验,强烈推荐亲手做一次)。
代码到目前为止已经零零散散用过几次 throw,但还没有"体系"。一个工程级项目的异常必须分类、必须有上下文、必须能被外层精确捕获。本节专门解决这件事,是卷一第 14 章在真实项目里的完整呈现。
# Step 7.1 异常 vs 错误码
🎯 本步目标:在写代码之前,先想清楚什么时候用异常、什么时候用返回值。这一步不写代码,只是建立判断标准——但比写代码还重要,因为选错了后面所有代码都会很别扭。
7.1 本项目的取舍
C++ 错误处理一直是宗教战争,本项目采用混合策略——根据"错误是常态还是异常"做选择:
| 情况 | 例子 | 用什么 |
|---|---|---|
| 高频常态结果 | GET 一个不存在的 key | 返回值(nullptr / optional / 哨兵值) |
| 调用方写错了 | SET 不带 value、命令拼错 | 异常 CmdSyntaxError |
| 系统资源问题 | AOF 文件无法打开、磁盘满 | 异常 IoError |
| 类型搞错了 | 对 string Value 调 get<int64> | 异常 TypeError |
核心原则:异常用于"调用方写错了或环境出了我无法处理的事",不是用来表达"业务上的 false"。
✅ Step 7.1 自检:合上书闭眼想 5 秒——下面 4 个场景该用哪种?
| 场景 | 你的选择 | 答案 |
|---|---|---|
| 文件打开失败 | ? | 异常(IoError) |
| GET 不存在的 key | ? | 返回值(nullptr) |
EXPIRE foo abc 中 abc 不是数字 | ? | 异常(CmdSyntaxError) |
用户输入 EXIT 退出 | ? | 返回值/break(这是正常流程!) |
4 个全对 → Step 7.1 通过。至少错 1 个 → 回头再读一遍"高频常态 vs 调用方写错"那段——这一关闭眼能答才是真懂。
💡 小白避坑:刚学异常的人很容易"为了显得专业一切都用异常"。实际工程相反:能用返回值表达的,绝不用异常——因为构造异常对象 + 栈展开比返回 nullptr 慢几百倍。Google C++ Style Guide 甚至直接禁用异常(出于二进制兼容考虑)。本项目采用的是中庸之道:分场景挑选。
# Step 7.2 五个自定义异常
🎯 本步目标:建立一个以
KvError为根的异常类体系。这里同时复习了卷一第 9 章的公有继承和第 14 章的异常多态——该你看到"为什么异常类也要用继承"了。
# 7.2 异常基类 KvError
#include <stdexcept>
namespace mkv {
// 所有 KV 内部异常的根
class KvError : public std::runtime_error {
public:
using std::runtime_error::runtime_error;
};
// 命令语法错误(用户输入问题)
class CmdSyntaxError : public KvError {
public:
using KvError::KvError;
};
// 类型不匹配(如 GET 返回 string 但调用方按 int 用)
class TypeError : public KvError {
public:
using KvError::KvError;
};
// IO 错误(AOF 写盘失败、目录创建失败)
class IoError : public KvError {
public:
using KvError::KvError;
};
// AOF 重放时遇到坏行
class AofCorrupted : public KvError {
public:
AofCorrupted(std::size_t lineNo, const std::string& detail)
: KvError("AOF corrupted at line " + std::to_string(lineNo) + ": " + detail) {}
};
} // namespace mkv
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
几个工程要点:
- 用
using BaseClass::BaseClass一行继承全部构造函数(C++11 起)—— 比写一堆转发构造函数省 10 行。 - 统一根类
KvError:上层调用方只需要catch (const mkv::KvError&)就能兜住所有"自家异常",再用dynamic_cast或多个catch区分细类型;不会和std::bad_alloc/ 第三方库异常混在一起。 AofCorrupted带行号:异常类不是只能装一个字符串,可以带结构化上下文让排查问题秒级定位。
✅ Step 7.2 编译验证:把上面 5 个类贴到 errors.h,建一个测试 main:
#include "errors.h"
#include <iostream>
int main() {
try {
throw mkv::CmdSyntaxError("EXPIRE seconds must be integer");
} catch (const mkv::KvError& e) { // ✅ 用基类捕获能抓住派生类
std::cout << "caught KvError: " << e.what() << "\n";
}
try {
throw mkv::AofCorrupted(42, "unknown command 'XYZ'");
} catch (const std::exception& e) { // ✅ std 基类也能抓住
std::cout << "caught std::exception: " << e.what() << "\n";
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
应输出:
caught KvError: EXPIRE seconds must be integer
caught std::exception: AOF corrupted at line 42: unknown command 'XYZ'
2
两条 catch 都能命中 → Step 7.2 通过。这就是"继承体系"在异常上的威力——外层可以用一个 catch (const KvError&) 兜住所有自家异常,是不是和卷一第 9 章"基类指针指向派生类"完全是同一个套路?
💡 小白避坑:写
class CmdSyntaxError : public KvError时必须是public继承,否则外层catch (const KvError&)抓不到——因为非 public 继承下编译器认为 "CmdSyntaxError不是KvError"。这是新手最爱踩的坑之一。
# Step 7.3 不抓异常会咂样
🎯 本步目标:亲眼看一次"报了异常但没人接住"的后果——程序直接 abort。然后一步步加上分层 catch。这一步不亲身踩一遍,永远学不会为什么需要统一捕获。
# 💥 阶段 1:不抓,看看多惨
现在你的 main 大概是这样的(第 2 次会话结束后的状态):
int main() {
mkv::Store store;
std::string line;
while (true) {
std::cout << "> ";
if (!std::getline(std::cin, line)) break;
if (line.empty()) continue;
// ⚠️ 没有 try-catch!
auto tokens = tokenize(line);
auto cmd = mkv::makeCommand(tokens);
std::cout << cmd->execute(store) << "\n";
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
现在跑一个错误命令:
$ ./build/mini_kv
> SET
terminate called after throwing an instance of 'mkv::CmdSyntaxError'
what(): SET requires 2 arguments
zsh: abort ./build/mini_kv
2
3
4
5
**💥 看到了吗?**一个用户输错一个命令,整个服务挂了。之前你辛辛苦苦 SET 进去的 100 个 key 全部还回内存里,贵价有多高不言而喻。
这就是为什么需要统一捕获——任何一个接受用户输入的系统,都必须在输入的最外层接住所有异常,否则一个坏请求就能反复打挂服务。
# 🔧 阶段 2:加上分层 catch
把 main 循环体裹进 try-catch——这个完整代码会在 7.4 节里全量展开,这里先看修复后重跑错误命令的效果:
$ ./build/mini_kv
> SET
(syntax) SET requires 2 arguments
> SET name zhang
OK
> EXPIRE name abc
(syntax) EXPIRE seconds must be integer, got: abc
> GET name
"zhang"
> EXIT
bye.
2
3
4
5
6
7
8
9
10
11
对比阶段 1:
- 错误命令 → 优雅提示
(syntax) ...,服务不挂 - 之前 SET 进去的 key 还在,GET 能查到
- 用户可以继续使用
这才是生产级服务该有的样子。
✅ Step 7.3 造 BUG 复现:严格体验上面两个阶段——先别加 try-catch跑一次看 abort,再加上 try-catch 跑一次看优雅提示。看过这两个现象的对比,你一辈子忘不了为什么需要统一捕获。
💡 小白领悟:这个"先造 BUG 看犱狂、再修复"的学习路径是高效学习的黄金法。你以后面试被问到"C++ 异常为什么要在 main 里统一接",能脱口说出"不接就 abort 了、服务会被坏请求反复打挂"这种有画面感的回答,远比背书本上的定义让面试官记住你。
# Step 7.4 异常应用守则
🎯 本步目标:这一步是以“思想点”为主。面试考点、Code Review 最跳不过去的三个异常守则都在这里。看完后动手把
noexcept加到指定函数,代码就正式完成了工程化升级。
# 7.3 异常守则 noexcept
守则一:异常的抛出位置要尽可能浅(fail fast)。比如解析 EXPIRE foo abc,应该在 makeCommand 里 std::stoi 直接抛 std::invalid_argument,包成 CmdSyntaxError,而不是把 "abc" 一路传到 Store 层才发现。
// makeCommand 里翻译异常
case CmdType::Expire: {
needArgs(2, "EXPIRE");
int sec = 0;
try {
sec = std::stoi(std::string(tokens[2]));
} catch (const std::exception& e) {
// 翻译成自家异常 + 添加上下文
throw CmdSyntaxError("EXPIRE seconds must be integer, got: " + std::string(tokens[2]));
}
if (sec < 0) throw CmdSyntaxError("EXPIRE seconds must be non-negative");
return std::make_unique<ExpireCmd>(std::string(tokens[1]), sec);
}
2
3
4
5
6
7
8
9
10
11
12
13
守则二:异常用于跨层传递,本层能处理就别抛。下面是反例:
// 反例:自己能处理却抛了
EntryPtr Store::get(const std::string& key) const {
auto it = idx_.find(key);
if (it == idx_.end()) throw std::out_of_range("key not found"); // ❌
return it->second;
}
2
3
4
5
6
GET 一个不存在的 key 是正常情况(缓存查询常态),用异常会导致每次 miss 都要构造 std::out_of_range 对象 + 栈展开,性能差几个数量级。返回 nullptr 才是对的。
守则三:能 noexcept 就标 noexcept——这不只是优化,更是给读者的契约。本项目有 noexcept 标记的关键位置:
| 函数 | 为什么标 noexcept |
|---|---|
Entry::isExpired() const noexcept | 纯计算,绝不抛 |
Store::size() const noexcept | 仅读 size_t |
各种 is*(Value&) noexcept | variant 类型查询本身不抛 |
| 析构函数(默认就是 noexcept) | C++ 隐式规则,析构里抛异常是死罪 |
唯一一个不该标 noexcept 的地方:
Store::set—— 内部要make_shared、要哈希插入,可能抛bad_alloc。强行标 noexcept 等于告诉 C++"如果抛了请直接std::terminate",会让进程直接挂掉。
✅ Step 7.4 验证:去 entry.h / value.h / store.h 中加 noexcept 标记,重新编译:
$ cmake --build build
[100%] Built target mini_kv
2
应该一发通过。如果你误把 set 标了 noexcept,看起来能编译但运行时 OOM 会令进程被 terminate——这是最难查的一类 bug,因为开发机安全、生产机才出问题。
💡 小白避坑:C++ 的
noexcept不是万能药。该标才标、不该标别乱标。准则只有一条:你能用五秒说清“这个函数不可能抛异常”的原因才标。能说清的那些其实都很简单:纯计算、仅读原生类型、返回bool/size_t之类。
# 7.4 主循环里的统一捕获
main 里捕获异常的部分要分层捕获,对应不同的退出码:
while (true) {
std::string line;
std::cout << "> ";
if (!std::getline(std::cin, line)) break;
if (line.empty()) continue;
try {
auto tokens = tokenize(line);
auto cmd = mkv::makeCommand(tokens);
std::string out = cmd->execute(store);
if (cmd->isWrite()) aofWrite(cmd->toAofLine());
std::cout << out << "\n";
}
catch (const mkv::CmdSyntaxError& e) {
std::cout << "(syntax) " << e.what() << "\n";
}
catch (const mkv::TypeError& e) {
std::cout << "(type) " << e.what() << "\n";
}
catch (const mkv::IoError& e) {
// IO 错误较严重,记录到错误流但不退出
std::cerr << "(io) " << e.what() << "\n";
}
catch (const mkv::KvError& e) {
std::cerr << "(internal) " << e.what() << "\n";
}
catch (const std::exception& e) {
// 兜底:第三方异常或意外的 std 异常
std::cerr << "(unexpected) " << e.what() << "\n";
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
为什么 IoError 要 std::cerr 不要 break:网络 / 磁盘临时故障可能下一秒就好,让用户继续操作;而不能让一次写盘失败把用户辛辛苦苦积累的内存数据全丢掉。
一个真正成熟的服务还会把这些异常结构化输出到日志系统(带 traceId、命令、客户端 IP),便于事后分析。本案例为简化只打到 stderr,下一节的
KV_LOG宏会让这个升级变得无痛。
📌 阶段⑦ 小结 + 🎉 第 3 次会话毕业
收获 对应卷一章节 异常 vs 错误码的工程决策矩阵 第 14 章 自定义异常类 + using BaseClass::BaseClass技巧第 14 + 11 章 异常的公有继承 / 多态捕获 第 9 章 fail-fast、跳层传递、不该为常态报错 第 14 章 + 工程经验 noexcept的按需标注第 14 + 18 章 主循环分层 catch + 顶层兜底 第 14 章 + 服务端常识 当前代码量:~750 行;当前能力:任何一条错误命令 / 错误参数 / 意外异常都会被优雅提示而不会击垮服务。
第 3 次会话验收清单(建议截图保存进度):
- [x] 能说出为什么该场景选异常 / 为什么该场景选返回值
- [x]
errors.h中 5 个异常类都是 public 继承KvError,能被基类 catch 抓住- [x] 亲手造过一次 BUG:不加 try-catch 看 abort 了,加上之后优雅提示了
- [x] main 里的分层 catch 覆盖:CmdSyntaxError / TypeError / IoError / KvError / std::exception
- [x] 需要的函数都加了
noexcept(isExpired/size/is*系列)- [x] 错误输入不会击垮服务,之前的 key 依然在
- [x]
git commit -m "session-3: error-system"完成下一会话预告:第 4 次会话进入 AOF 持久化——你将亲手看到“进程重启后数据还在”的魔法。重点造 BUG:AOF 半行损坏怎么办——这是实际生产事故中十拿九稳出现的场景。
下一节我们开始干这件大事:让数据“活过”重启。
# 08.AOF 持久化实现
🎯 阶段⑧ · 持久化底层打通
子步骤 内容 卷一对应章节 验证点 Step 8.1 理解 AOF 原理(append-only file) 第 13 章 + 系统课 ✅ 能画出“启动中间关闭”三个阶段图 Step 8.2 实现 RAII 类 AofWriter第 12+13 章 ✅ 能手动 cat aof.log 看到追加的一行 Step 8.3 实现 replayAof启动重放第 13 章 ✅ 重启后 GET 能拿到上次的 key Step 8.4 主循环串起 + kill -9验证第 13 章 + 工程实践 ✅ 强杀进程后重启,数据不丢 Step 8.5 ⚠️ 造 BUG → 修复:AOF 半行损坏怎么办 第 13+14 章 ✅ 体验严格式 vs 宽松式的取舍 本阶段后能力跃迁:从"进程关了就拜拜"变为"数据跨重启存活"——这是内存数据库变“真数据库”的分水岭。更重要的是 Step 8.5 让你亲手踩一次"生产环境十拿九稳遇到的 AOF 损坏"。
⏱ 预估:阅读 30 min + 动手 90 min = 2 小时(重点是 Step 8.4 的 kill -9 实验 和 Step 8.5 的手改损坏 AOF 实验,必须亲手跑一遍)。
到目前为止,进程一关数据就全没了——这是"内存数据库"的天然问题。Redis 用两个机制对付它:RDB(定时把整个内存快照 dump 到文件)和 AOF(每条写命令追加到日志)。本案例采用更简单的 AOF。
# 灵魂三问:动手前先想清楚
❓ 问题一:为什么用 AOF(追加日志),不用 dump(快照)?
来看反例 —— "看似最直觉"的方案:
// ❌ 反例:每次有变化就把整个 map 序列化到文件
void onWrite() {
std::ofstream f("data.dump", std::ios::trunc);
for (auto& [k, e] : store.idx_) f << k << "=" << format(e->value) << "\n";
}
2
3
4
5
问题:
- 写放大致命——只改 1 个 key,要把 10 万 key 全部重写。100 KB 的写命令变成 10 MB 的磁盘 IO
- 崩溃中段就毁了文件——
trunc之后还没写完就崩溃,原数据和新数据都没了 - 跟"操作日志"丢失——只看到"最终态",不知道"是怎么变成这样的",调试困难
✅ 正确做法:追加日志 = 只写"刚发生的事情"。无论 store 多大,每条写命令只产生一行 IO。这就是日志结构化(log-structured)存储——MySQL binlog、PostgreSQL WAL、Kafka、HDFS 全部基于这个思路。
| 维度 | RDB 快照 | AOF 追加日志 |
|---|---|---|
| 单次写 IO 量 | 全量(万级) | 1 行 |
| 崩溃中点恢复 | 文件损坏率高 | 最坏丢最后 1 行 |
| 文件大小 | 紧凑 | 持续增长(要靠 rewrite 压缩) |
| 重放速度 | 直接 load | 一条条 replay 慢 |
| 本案例选哪个? | ❌ | ✅(教学价值高,逻辑直观) |
❓ 问题二:为什么用追加 (std::ios::app),不用改写已有内容?
来看反例:
// ❌ 反例:用普通 ofstream(默认 trunc)
std::ofstream aof("aof.log"); // ⚠️ 一打开,aof.log 立即被清空
aof << "SET name zhangsan\n"; // 历史数据全丢
2
3
问题:
- trunc 模式 = 程序启动就清空文件——上次重启留下的 100 万行操作日志瞬间归零
- 没法多进程协作——两个进程同时写,谁后写谁覆盖
- 没法并发安全——
<<不是原子的,需要 OS 级"追加写"保证
✅ 正确做法:用 std::ios::app 模式打开,OS 保证每次 write 都是原子追加到文件末尾(POSIX 标准 O_APPEND)。这是文件系统给我们的"免费保障"——不用应用层加锁。
❓ 问题三:write 完就万事大吉了吗?为什么 §14.2 还要专门讲 fsync?
答:write ≠ 数据落盘!
你的代码 → std::ofstream::flush()
↓
C 库 stdio buffer ← flush 之后到这里
↓
内核 page cache ← 这才是 OS 缓冲区!write 一返回就到这
↓
磁盘扇区 ← 必须 fsync() 才能强制刷到这里
2
3
4
5
6
7
flush() 只把数据从用户态推到内核态——内核什么时候真把数据写到磁盘,看心情(默认 30 秒)。断电时 page cache 里的所有数据全丢。
| 策略 | 性能 | 安全性 | 用在什么场景 |
|---|---|---|---|
| 不 flush | 极快 | 进程崩就丢 | 缓存 / 不重要日志 |
| 每次 flush(本案例默认) | 中 | 进程崩不丢,断电丢 | 教学 / 可容忍数据丢失 |
| 每次 fsync | 慢 10x | 断电也不丢 | 银行 / 订单 / 关键数据 |
| 每秒 fsync(Redis 默认) | 较快 | 最坏丢 1 秒数据 | 工业级折中(推荐) |
🔑 教学要点:持久化的本质是"和 OS 谈一场关于性能与安全的谈判"。本案例选"每次 flush 不 fsync"是教学权衡——你必须知道生产环境要根据数据价值升级到 fsync 策略。这就是 §14.2 章节专门展开的原因。
# Step 8.1 理解 AOF 原理
🎯 本步目标:不写代码,先把"AOF 为什么能让数据活过重启"这件事看明白。这是全节底调理解不上去,后面写代码只是背记。
# 8.1 AOF 原理
启动: 打开 aof.log → 一行行读 → 重放到 Store
运行中: 每个写命令 execute 之后 → 追加一行到 aof.log → fsync(可选)
关闭: 什么都不做(已经全在文件里)
2
3
AOF 的妙处:写入的格式就是命令本身("SET name zhangsan\n"),重放时直接走和正常运行一样的命令解析路径。这意味着:新增命令完全不用改持久化代码——5.3 节的 toAofLine() 和 5.4 节的 makeCommand() 自动覆盖。
✅ Step 8.1 自检:合上书闭眼回答 3 个问题:
- AOF 重放时,走的是与正常运行同一套命令解析代码吗?✅ 是(复用了
tokenize+makeCommand+execute) - 重放期间是否需要再次 append 到 AOF?❌ 不需要(会造成文件每启动一次翻倍)
- 新增一个命令,需要动持久化逻辑吗?❌ 不需要(在
toAofLine继承体系里加一个覆盖就行)
3 个都能说明白 → Step 8.1 通过。这三个点是 AOF 设计哲学的精髓。
💡 小白领悟:AOF 能“偺平事件”是因为它记录的是动作(SET / DEL / EXPIRE)而不是快照状态。这个思想在数据库领域叫 "event sourcing",应用到 Web 就是 Redux 的 reducer 思想。你看一个东西能不能看出“同一个思想的多个面孔”,是高级工程师和初级工程师的分水岭。
# Step 8.2 RAII 类 AofWriter
🎯 本步目标:先只写写端。先能 append 到文件里,能手动
cat aof.log看到追加的记录——你才会信“数据进文件了”。此时先不要管重放。
# 8.2 AofWriter:负责追加写
把 AOF 写盘封装成一个类,遵守 RAII:
#include <fstream>
#include <filesystem>
namespace mkv {
namespace fs = std::filesystem;
class AofWriter {
public:
explicit AofWriter(const fs::path& path) : path_(path) {
// 自动创建父目录
if (path.has_parent_path()) {
std::error_code ec;
fs::create_directories(path.parent_path(), ec);
if (ec) throw IoError("create dir failed: " + ec.message());
}
out_.open(path, std::ios::out | std::ios::app | std::ios::binary);
if (!out_) throw IoError("open AOF failed: " + path.string());
}
void append(std::string_view line) {
if (line.empty()) return;
out_.write(line.data(), static_cast<std::streamsize>(line.size()));
if (!out_) throw IoError("write AOF failed");
}
void flush() { out_.flush(); }
// 析构时关闭文件 = RAII
~AofWriter() = default;
private:
fs::path path_;
std::ofstream out_;
};
} // namespace mkv
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
几个细节都不能省:
std::ios::app追加模式:每次write自动定位到末尾,多个进程都能安全追加(虽然本项目只有单进程)。std::ios::binary:避免 Windows 把\n偷偷换成\r\n导致 AOF 在跨平台时格式错乱。- 构造函数失败抛异常:与卷一第 12 章 RAII 原则一致——构造函数失败的对象不能存在于"半初始化"状态。
flush()暴露给上层:调用方决定 fsync 时机(每次写都 flush 太慢,永远不 flush 又不安全),见 14.2 节"fsync 策略"。
✅ Step 8.2 验证:先不动 main,单独写个小验证:
// test_aof_writer.cpp
#include "aof_writer.h"
#include <iostream>
int main() {
mkv::AofWriter aof("data/aof.log");
aof.append("SET hello world\n");
aof.append("SET name minikv\n");
aof.flush();
std::cout << "写入完成\n";
}
2
3
4
5
6
7
8
9
10
11
$ cmake --build build && ./build/test_aof_writer
写入完成
$ cat data/aof.log
SET hello world
SET name minikv
$ ./build/test_aof_writer # 再跑一次
$ cat data/aof.log
SET hello world
SET name minikv
SET hello world
SET name minikv # ⚠️ 重复了两行
2
3
4
5
6
7
8
9
10
11
12
13
cat 看到追加的两行 且二次跑后变 4 行 → Step 8.2 通过。你亲眼验证了 std::ios::app 是“追加不覆盖”的语义。
💡 小白避坑:如果你发现二次跑后文件变成了“SET hello world\nSET name minikv”(只保留后一次),那予二你忘写
std::ios::app,三你写成了std::ios::trunc——那个是打开时清空文件的语义,生产环境谁误用谁丢饰。
# Step 8.3 replayAof 重放
🎯 本步目标:有了写端,现在加读端。启动时读起全部行子,一行一行调
makeCommand+execute——重放逻辑同正常运行逻辑完全一致。这里临时使用严格模式(坏一行就报错),Step 8.5 会该进。
# 8.3 启动重放还原状态
namespace mkv {
void replayAof(const fs::path& path, Store& store) {
if (!fs::exists(path)) return; // 全新启动,无 AOF
std::ifstream in(path);
if (!in) throw IoError("open AOF for replay failed");
std::string line;
std::size_t lineNo = 0;
while (std::getline(in, line)) {
++lineNo;
if (line.empty()) continue;
try {
auto tokens = tokenize(line);
auto cmd = makeCommand(tokens);
if (!cmd->isWrite()) {
// 不应该出现在 AOF 里,跳过但记录
continue;
}
(void)cmd->execute(store); // 重放时丢弃返回值
} catch (const KvError& e) {
// 一行损坏不要终止整个重放,但要明确告警
throw AofCorrupted(lineNo, e.what());
}
}
}
} // namespace mkv
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
两个工程级思考:
- 重放过程中是否要写 AOF?答:不要。否则启动一次 AOF 文件就翻倍。我们用一个标志位控制,或简单地——重放期间不传 AofWriter 给 Store。本案例采用"主循环里手动调 aofWrite,重放时不调"的方式,由代码组织保证。
- 遇到坏行怎么办?两种策略:
- 严格:抛
AofCorrupted,要求人工处理(本案例采用,更安全)。 - 宽松:跳过坏行继续,只记日志(生产 Redis 默认行为,损失小部分写)。
- 严格:抛
✅ Step 8.3 验证:上一步已经在 data/aof.log 里写进了两行 SET hello world / SET name minikv,现在写个小测试:
// test_replay.cpp
#include "store.h"
#include "aof.h"
#include <iostream>
int main() {
mkv::Store store;
mkv::replayAof("data/aof.log", store);
std::cout << "replayed " << store.size() << " keys\n";
auto v = store.get("hello");
if (v) std::cout << "hello = " << mkv::formatValue(v->value) << "\n";
}
2
3
4
5
6
7
8
9
10
11
12
$ ./build/test_replay
replayed 2 keys
hello = "world"
2
3
能拿到上次写进去的 key → Step 8.3 通过。这意味着 AOF 闭环已经接通:文件 → getline → tokenize → makeCommand → execute → Store。
# Step 8.4 主循环串 AOF
🎯 本步目标:先删掉
data/aof.log(为了干净起步),然后仅仅用 main 这个入口设、进、kill -9 、重启走一遇。这一步你会看到“让数据跨重启存活”的魔法时刻。
# 8.4 main 串 AOF
int main() {
try {
mkv::fs::path aofPath = "data/aof.log";
mkv::Store store;
mkv::replayAof(aofPath, store);
std::cout << "MiniKV v0.5 — replayed " << store.size() << " keys\n";
mkv::AofWriter aof(aofPath);
std::string line;
while (true) {
std::cout << "> ";
if (!std::getline(std::cin, line)) break;
if (line.empty()) continue;
try {
auto tokens = mkv::tokenize(line);
if (tokens.empty()) continue;
// EXIT 单独处理,不走 makeCommand
if (mkv::parseCmdType(std::string(tokens[0])) == mkv::CmdType::Exit) {
std::cout << "bye.\n";
break;
}
auto cmd = mkv::makeCommand(tokens);
std::string out = cmd->execute(store);
// 写命令同步落盘,确保 ACK 时数据已在文件里
if (cmd->isWrite()) {
aof.append(cmd->toAofLine());
aof.flush();
}
std::cout << out << "\n";
}
catch (const mkv::KvError& e) {
std::cout << "(error) " << e.what() << "\n";
}
}
}
catch (const mkv::AofCorrupted& e) {
std::cerr << "FATAL: " << e.what() << "\n";
return 2;
}
catch (const mkv::IoError& e) {
std::cerr << "FATAL IO: " << e.what() << "\n";
return 3;
}
catch (const std::exception& e) {
std::cerr << "FATAL: " << e.what() << "\n";
return 1;
}
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
验证持久化的正确性——保存数据 → kill -9 强杀 → 重启:
$ ./mini_kv
MiniKV v0.5 — replayed 0 keys
> SET name zhang
OK
> SET age 30
OK
> EXPIRE name 3600
OK
> ^C # 强制中断
$ ./mini_kv
MiniKV v0.5 — replayed 2 keys # 数据回来了
> GET name
"zhang"
> TTL name
(integer) 3597
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
为什么 kill -9 数据也不丢?因为我们在每次写命令后都 aof.flush()。flush() 会让 C++ runtime 把缓冲区数据交给 OS(write 系统调用);OS 在被 kill -9 杀进程时会保留已写入文件系统缓存的数据。如果想抗住整机断电,还需要进一步 fsync(fd)——14.2 节会展开。
✅ Step 8.4 验证:必须亲手跑一遇上面 4 个动作(写 → kill → 重启 → GET 拿到数据)。看到“replayed 2 keys”那一瞬,你才会真正明白为什么 Redis 能扣什么名助“持久化”。
💡 小白领悟:上面这句话看似句开玩笑,其实是市场上一大类偏高薪后端面试题的低起点:“说说你理解的服务重启后数据为什么还能拿到”。你身上跑一遇 = 你拿个面试 OK。
# Step 8.5 AOF 半行损坏
🎯 本步目标:这是本节最重要的 BUG 场景。生产环境中“AOF 半行损坏”是高频事故——上一个写命令刚写到一半,机器断电了 / OOM 了。你会身上体验严格式 vs 宽松式两种策略的取舍。
# 💥 阶段 1:手动制造一个损坏的 AOF
先让上面的 main 在正常进程中跑一会,为 aof.log 凑几行正常数据,然后人为追加一行“损坏的半行”模拟断电场景:
$ ./build/mini_kv
> SET name zhang
OK
> SET age 30
OK
> EXIT
bye.
# 模拟断电:追加一个不完整的命令 「SET」 后面缺少参数
$ printf 'SET\n' >> data/aof.log
$ cat data/aof.log
SET name zhang
SET age 30
SET # ⚠️ 损坏的半行:缺少参数
2
3
4
5
6
7
8
9
10
11
12
13
14
15
现在重启服务:
$ ./build/mini_kv
FATAL: AOF corrupted at line 3: SET requires 2 arguments
2
💥 这是上一节设定的“严格式”表现——replayAof 里 catch 后重抠为 AofCorrupted。理论上安全,但现实中却让服务起不来。线上出现这种状况后务以为间是看代码、决定是否手动截掉最后一行、 git revert…一路下去可以走动帮 30 分钟,整个服务这段时间是不可用的。
Redis 生产环境默认是宽松式:遇到坏行跳过、记日志、继续启动,损失一小部分进不来的写,但服务可用。这才是生产该有的默认值。
# 🔧 阶段 2:修复,增加宽松模式
重构 replayAof,加一个 tolerant 参数控制严格 / 宽松,默认宽松:
struct ReplayReport {
std::size_t replayed = 0; // 成功重放的行数
std::size_t corrupted = 0; // 跳过的坏行数
std::size_t totalLines = 0;
};
ReplayReport replayAof(const fs::path& path, Store& store, bool tolerant = true) {
ReplayReport r{};
if (!fs::exists(path)) return r;
std::ifstream in(path);
if (!in) throw IoError("open AOF for replay failed");
std::string line;
std::size_t lineNo = 0;
while (std::getline(in, line)) {
++lineNo;
if (line.empty()) continue;
++r.totalLines;
try {
auto tokens = tokenize(line);
auto cmd = makeCommand(tokens);
if (cmd->isWrite()) {
(void)cmd->execute(store);
++r.replayed;
}
} catch (const KvError& e) {
if (tolerant) {
++r.corrupted;
// 错误输出坏行详情,便于事后人工修补
std::cerr << "[AOF] skip corrupted line " << lineNo
<< ": " << e.what() << "\n";
} else {
throw AofCorrupted(lineNo, e.what());
}
}
}
return r;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
main 里也接进这个变化:
auto report = mkv::replayAof(aofPath, store);
std::cout << "MiniKV v0.6 — replayed " << report.replayed
<< " keys, skipped " << report.corrupted << " corrupted lines\n";
2
3
重新运行:
$ ./build/mini_kv
[AOF] skip corrupted line 3: SET requires 2 arguments
MiniKV v0.6 — replayed 2 keys, skipped 1 corrupted lines
> GET name
"zhang"
> GET age
(integer) 30
> SET key3 value3
OK
2
3
4
5
6
7
8
9
对比阶段 1:
- 坏行 → 跳过且记录(不会静默丢)
- 服务能正常启动、能接新写入
- 用户能马上看到“skipped 1 corrupted lines”提示,事后去翻日志查看详情
✅ Step 8.5 造 BUG 复现:必须亲手动二连弹——先看严格式 abort,再看宽松式优雅启动。看过这个对比,你以后设计任何带持久化的服务都会本能反应:“坏行怎么办?默认宽松 + 详细告警”。
💡 小白避坑:不要为了"准确"而盲目地选严格式。对于缓存、会话、计数器这类场景,丢一两行数据远远不及服务不可用严重。仅在财务、订单这种强一致场景才选严格式——但那种场景根本不会依赖 AOF,而是上 WAL → fsync → 主从同步。
# 8.5 filesystem 亮点
第 13 章学到的 <filesystem>(C++17)在本项目里默默承担了很多事,盘点一下:
fs::path aofPath = "data/aof.log"; // 跨平台路径表达
fs::create_directories(aofPath.parent_path()); // 递归创建目录
fs::exists(aofPath); // 文件存在性
fs::file_size(aofPath); // 文件大小(用于决定要不要 rewrite)
fs::rename(tmp, final); // 原子替换(写新文件 + rename,AOF rewrite 用)
2
3
4
5
对比 C 时代——同样的事在 POSIX 里要 mkdir(2) + errno + stat(2) + S_ISDIR,在 Windows 还要换一套 CreateDirectory。std::filesystem 一套 API 跨所有平台跑,这就是现代 C++ 的力量。
📌 阶段⑧ 小结
收获 对应卷一章节 AOF event-sourcing 思想 第 13 章 + 架构学 RAII 类负责文件句柄 第 12 章 std::ofstream的app/binary/trunc语义第 13 章 std::filesystem跨平台路径/目录 API第 13 章 “重放 = 调用重同一套命令解析管道”的复用思想 第 16 章多态 flush 与 fsync 区分、 kill -9数据不丢原因第 13 章 + 系统课 AOF 损坏的严格 vs 宽松处理策略 工程经验 当前代码量:~870 行;当前能力:服务有了持久化与损坏容错,是“真数据库”的准入门槛。下一阶段 阶段⑨ 会把“服务调试类”的 KV_LOG 宏加上,为阶段⑩“多线程”准备。
# 09.日志系统实战
🎯 阶段⑨ · 工程化日志双升级
子步骤 内容 卷一对应章节 验证点 Step 9.1 ⚠️ 造 BUG → 修复: cerr多原始 → 升级为KV_LOG宏第 17 章 ✅ 输出带时间戳 + 文件/行号 + 级别 Step 9.2 宏里的几个“必须” + 造 BUG 看 do-while(0)第 17 章 ✅ 亲眼看到 else错位的翻车现场Step 9.3 条件编译关闭日志( -DKV_LOG_LEVEL=99)第 17 章 ✅ g++ -E看到日志调用被优化掉Step 9.4 多文件头保护 + include 守序 第 17 章 ✅ 能说出 #pragma oncevs#ifndef优劣本阶段后能力跃迁:从“调试代码里散葬
cerr”变为“带时间戳/级别/文件位置、可裁剪、类型安全的项目级日志”。这是阶段⑩(多线程)前必要打底——不然多线程出问题你主动看不出来。⏱ 预估:阅读 25 min + 动手 35 min = 1 小时。Step 9.2 的“不加 do-while(0)”造 BUG 是宏调试的经典现场,必须亲手踩。
到目前为止 std::cerr 直接打印太"原始"——没有时间戳、没有级别、没有文件位置、上线后不能动态关闭。本节用宏 + 条件编译做一个工程级日志,对应卷一第 17 章。
# Step 9.1 cerr 升级 KV_LOG
🎯 本步目标:亲眼看一次裸
cerr不够用的窘境,然后设计一个KV_LOG宏上线。有了裸cerr的“项”你会体会到KV_LOG为什么这么设计。
# 💥 阶段 1:裸 cerr 多难用
在 阶段⑧(8节)的 main 里,你大概还是这么打印的:
std::cerr << "AOF replay skip line " << lineNo << ": " << e.what() << "\n";
std::cerr << "open AOF failed: " << path << "\n";
std::cerr << "FATAL: " << e.what() << "\n";
2
3
运行后输出如下:
AOF replay skip line 3: SET requires 2 arguments
open AOF failed: data/aof.log
FATAL: Out of memory
2
3
💥 这里底底下不够用:
- 没时间戳 → 出问题后不知道故障发生在哪秒,跟不上发起者上报的时间点
- 没级别 → ERROR 和 INFO 混在一起,不能
grep ERROR筛严重问题 - 没文件位置 → 看到“FATAL: Out of memory”不知道代码哪一行报的,要反重三 grep
- 上线后不能关闭 → 生产环境 DEBUG 日志量大会压垄磁盘 IO,但
cerr没开关
这就是为什么需要 KV_LOG 宏。
# 🔧 阶段 2:设计 KV_LOG 宏
# 9.1 KV_LOG 宏的设计
#include <iostream>
#include <chrono>
#include <iomanip>
#include <sstream>
namespace mkv {
enum class LogLevel { Debug, Info, Warn, Error };
inline const char* levelName(LogLevel l) noexcept {
switch (l) {
case LogLevel::Debug: return "DEBUG";
case LogLevel::Info: return "INFO ";
case LogLevel::Warn: return "WARN ";
case LogLevel::Error: return "ERROR";
}
return "?????";
}
inline std::string nowStr() {
auto t = std::chrono::system_clock::now();
auto tt = std::chrono::system_clock::to_time_t(t);
std::tm tm{};
#if defined(_WIN32)
localtime_s(&tm, &tt);
#else
localtime_r(&tt, &tm);
#endif
std::ostringstream oss;
oss << std::put_time(&tm, "%H:%M:%S");
return oss.str();
}
} // namespace mkv
// 关键:宏要写在 namespace 外
#ifndef KV_LOG_LEVEL
#define KV_LOG_LEVEL 1 // 默认 Info 起步
#endif
#define KV_LOG_IMPL(level, lvname, ...) \
do { \
if (static_cast<int>(level) >= KV_LOG_LEVEL) { \
std::cerr << "[" << ::mkv::nowStr() << "] " \
<< "[" << lvname << "] " \
<< "[" << __FILE__ << ":" << __LINE__ << " " \
<< __func__ << "] " \
<< __VA_ARGS__ << std::endl; \
} \
} while (0)
#define KV_LOG_DEBUG(...) KV_LOG_IMPL(0, "DEBUG", __VA_ARGS__)
#define KV_LOG_INFO(...) KV_LOG_IMPL(1, "INFO ", __VA_ARGS__)
#define KV_LOG_WARN(...) KV_LOG_IMPL(2, "WARN ", __VA_ARGS__)
#define KV_LOG_ERROR(...) KV_LOG_IMPL(3, "ERROR", __VA_ARGS__)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
使用方式:
KV_LOG_INFO("server start, replayed " << store.size() << " keys");
KV_LOG_WARN("AOF replay corrupted at line " << lineNo);
KV_LOG_DEBUG("set key=" << key << " value=" << formatValue(v));
2
3
输出:
[14:23:01] [INFO ] [mini_kv.cpp:632 main] server start, replayed 2 keys
对比阶段 1:时间戳✅ 级别✅ 文件位置✅ 函数名✅——一行信息量多了 4 倍。且语义类型安全(连拼按调用重载表怎么干都能编译报错)。
✅ Step 9.1 验证:把上面的 enum class LogLevel + nowStr + KV_LOG_* 五个宏贴到一个 kv_log.h。下面一个小验证:
#include "kv_log.h"
int main() {
KV_LOG_INFO("server start version=" << "v0.6");
KV_LOG_WARN("AOF replay skip line=" << 3);
KV_LOG_ERROR("OOM at " << "set key=foo");
}
2
3
4
5
6
$ g++ -std=c++17 test_log.cpp -o test_log && ./test_log
[15:01:23] [INFO ] [test_log.cpp:3 main] server start version=v0.6
[15:01:23] [WARN ] [test_log.cpp:4 main] AOF replay skip line=3
[15:01:23] [ERROR] [test_log.cpp:5 main] OOM at set key=foo
2
3
4
三行都出,时间戳/级别/位置都在 → Step 9.1 通过。
💡 小白领悟:这个宏的精髓是
__FILE__/__LINE__/__func__——这三个是预处理器反身提供的 不可以被函数获得(函数里出的 LINE 返回是函数被调用那行,不是调用根使用那行)。这是为什么日志必须是宏、不能是函数的根本原因。
# Step 9.2 do-while(0) 必须
🎯 本步目标:看明白为什么宏体要用
do { ... } while(0)裹起来。不用会怎么样?亲手造一次。
# 💥 造 BUG:去掉 do-while(0) 试试
不装表付到快递,先去掉 do { ... } while(0)只留多句语句:
// 错误示范:没有 do-while(0) 包裹
#define KV_LOG_INFO_BAD(msg) \
if (1 >= KV_LOG_LEVEL) \
std::cerr << "[INFO ] " << msg << std::endl
2
3
4
现在调用方写:
if (store.size() > 0)
KV_LOG_INFO_BAD("store has data");
else
KV_LOG_INFO_BAD("store empty"); // ⚠️ 这行会起反作用
2
3
4
预处理后这代码变成:
if (store.size() > 0)
if (1 >= KV_LOG_LEVEL)
std::cerr << "[INFO ] " << "store has data" << std::endl;
else // ⚠️ else 错配了里层 if!
if (1 >= KV_LOG_LEVEL)
std::cerr << "[INFO ] " << "store empty" << std::endl;
2
3
4
5
6
**💥 怎样?**调用者写的 else 本意是配外层 if (store.size() > 0),结果被预处理重写后错配给了宏里层的 if (1 >= KV_LOG_LEVEL)。这是生产事故里最难调的 BUG——代码看起来很正常,运行起来逻辑却额外。
# 🔧 修复:用 do-while(0) 裹住
加上 do { ... } while(0),宏体永远是“一句话”,如何使用都不会与周围控制流冲突。其他几个必须项也列出来:
# 9.2 宏里的几个"必须"
| 必须做 | 为什么 |
|---|---|
整个体用 do { ... } while(0) 包起来 | 否则 if (x) KV_LOG_INFO("y"); else ...; 会因为宏展开后的多语句导致 if-else 错位 |
宏体里的标识符前缀 ::mkv:: | 调用方可能在任何 namespace 里,不加全限定会撞名 |
用 << 拼接而不是 printf 风格 | 类型安全(编译期检查 << 重载),不会有 %d 传 string 的崩溃 |
__VA_ARGS__ 接收变长参数 | 让用户写 KV_LOG_INFO("x=" << x << " y=" << y) 一行搞定 |
✅ Step 9.2 验证:必须亲手踩一次上面的造 BUG 示范,看到“else 错配”的表现后还原为 do-while(0) 版本,验证恢复正常。这一步踩不踩决定你以后看别人宏代码能不能一眼发现隐含问题。
💡 小白领悟:你看 Linux 内核代码里同样
BUG_ON(...)、pr_err(...)这类宏都是do { ... } while (0)包裹。不包裹的“多语句宏”是代码 review 里馈 需被reject的代码。
# Step 9.3 条件编译关闭
🎯 本步目标:让 release 版本完全不进入日志打印逻辑。这意味着
KV_LOG_DEBUG不是“调用了但判断不打”,而是连调用本身都被优化器抖掉。
# 9.3 条件编译关闭日志
发布 release 版本时希望连日志的开销都没有。利用 KV_LOG_LEVEL 宏 + 编译期常量分支:
# Debug 构建:所有日志打开
g++ -O0 -DKV_LOG_LEVEL=0 mini_kv.cpp -o mini_kv
# Release 构建:只保留 Warn 和 Error
g++ -O3 -DKV_LOG_LEVEL=2 mini_kv.cpp -o mini_kv
# 极端裁剪:彻底关掉
g++ -O3 -DKV_LOG_LEVEL=99 mini_kv.cpp -o mini_kv
2
3
4
5
6
7
8
由于 if (static_cast<int>(level) >= KV_LOG_LEVEL) 里 KV_LOG_LEVEL 是宏常量,优化器会在编译期把整段 if 消掉,运行时连分支判断都没有。这就是为什么 C++ 工程里日志几乎是零成本的。
✅ Step 9.3 验证:亲眼看优化器是怎么把日志整段干掉的:
# 加 -E -P 让编译器只跑预处理,不打跟调位置指示
$ g++ -E -P -DKV_LOG_LEVEL=99 test_log.cpp | grep -A1 "KV_LOG_INFO\|cerr"
# 输出仅为 do { if (1 >= 99) { ... } } while (0);
# 优化器会看出 1 >= 99 恒假 → 整个 if 体删除
$ g++ -O2 -DKV_LOG_LEVEL=99 test_log.cpp -o test_log_silent
$ ./test_log_silent # 什么输出都没有
2
3
4
5
6
7
运行时一句话都不打 → Step 9.3 通过。如果你能 objdump -d 看反汇编,会发现二进制里根本没有“调用 nowStr”这步。
💡 小白避坑:别在宏体里写“不能被优化掉的副作用”。比如错误写
KV_LOG_DEBUG("x=" << ++x)—— 一旦被优化掉,++x不会被执行,逻辑业务就丢了。这是插代码 review 中要重点胆检查的问题。
# Step 9.4 头保护与 include
🎯 本步目标:本项目代码量越来越大,是时候考虑拆头文件了。这一步介绍拆分后怎样守 include 顺序。
# 9.4 头保护 include 守序
虽然本案例是单文件,但顺手讲一下第 17 章的"工程级"用法。如果未来你想拆 entry.hpp / store.hpp 等多个头文件:
// store.hpp
#pragma once // 现代写法,等价于 #ifndef ... #define ... #endif
#include "entry.hpp" // 项目内头文件用引号
#include <string> // 标准库用尖括号
#include <vector>
namespace mkv {
class Store { /* ... */ };
}
2
3
4
5
6
7
8
9
10
#pragma once vs 经典 #ifndef 守卫:
| 维度 | #pragma once | #ifndef GUARD |
|---|---|---|
| 语法 | 一行 | 三行 |
| 跨平台 | GCC/Clang/MSVC 全支持,事实标准 | 100% 标准 |
| 同名文件分散在不同目录 | 视为不同文件,各保护一份(合理) | 宏名相同会冲突 |
本项目统一用 #pragma once。
✅ Step 9.4 验证:能说出下面 2 个问题的答案你就可以宣告 Step 9.4 过了:
- 第三方库
<vector>和本项目"store.h"为什么一个用尖括号一个用引号? → 编译器搜索路径优先级不同:尖括号从-I/ 系统路径起找,引号先从当前文件目录起找。项目内部头文件用引号能避免同名冲突。 #pragma once什么时候不如#ifndef? → 项目里同名文件冲突甚少,且 100% 主流编译器都支持#pragma once。但如果是跨多个古老平台的库(例如你要发布一个 header-only 库给不明环境用户),选#ifndef更保险。
💡 小白避坑:本项目暂时是单文件不需要
#pragma once,但卷二的服务架构进入第 18 章后立刻会拆头文件。这一步算预先打底,不要跳。
📌 阶段⑨ 小结 + 🎉 第 4 次会话毕业
收获 对应卷一章节 AOF event-sourcing 思想 + RAII 封装 + 重放机制 第 12+13 章 kill -9不丢数据 与 flush/fsync 区分第 13 章 + 系统课 AOF 损坏严格 vs 宽松处理的工程取舍 工程经验 KV_LOG宏与__FILE__/__LINE__/__func__第 17 章 宏的 do-while(0)与 else 错配 BUG第 17 章 KV_LOG_LEVEL条件编译裁剪 + 编译期优化第 17 章 #pragma once与 include 守序的平台取舍第 17 章 当前代码量:~950 行;当前能力:服务有了持久化、损坏容错、工程级日志三件套。现在才叫"服务"。
第 4 次会话验收清单(建议截图保存进度):
- [x] AOF 三阶段原理能画出(启动/运行/关闭)
- [x]
AofWriterRAII 实现,std::ios::app+std::ios::binary都在- [x]
replayAof返回ReplayReport,默认宽松模式- [x] 亲手造过 BUG:手改损坏 AOF 看严格式 abort,改为宽松后优雅启动
- [x]
kill -9后重启数据还在,能说清与 fsync 的区别- [x]
KV_LOG_*4 个宏实现,带时间戳/级别/位置- [x] 亲手造过 BUG:不加 do-while(0) 看 else 错配,修复为裹裹后恢复正常
- [x]
g++ -E -P -DKV_LOG_LEVEL=99看到日志被优化掉- [x]
git commit -m "session-4: persistence + logging"完成下一会话预告:第 5 次会话进入终点:阶段⑩ 多线程 + 阶段⑪ 模板提炼 + 阶段⑫ 智能指针反思。重点造 BUG:两个线程同时 SET 同一个 key——这是多线程世界最经典的“数据竞争”现场,你会亲眼看到计数器从 2000 变成 1837 这种“说不出理由但就是丢了数据”的诡异现象。
下一节进入"并发"——这是把"玩具"升级为"服务"的关键一跃。
# 10.并发安全读写
🎯 阶段⑩ · 多线程与数据竞争
子步骤 内容 卷一对应章节 验证点 Step 10.1 ⚠️ 造 BUG(重头戏):两线程 SET计数器从 2000 变 1837 第 15 章 ✅ 多跑几次看到不同的错值 Step 10.2 std::mutex+std::lock_guard修复第 12+15 章 ✅ 加锁后看到 2000 出现 100 次都不变 Step 10.3 std::shared_mutex读写锁优化第 15 章 ✅ 出现 GET 并发度提升 Step 10.4 锁粒度取舍与 Redis 单线程哲学 第 15 章 + 架构课 ✅ 能说出为什么 Redis 不用锁 本阶段后能力跃迁:从“单人玩”变为“多客户端同时读写不出事”。这是服务能上线的最后一道门——不过这关的代码一上线必犬。且本节 Step 10.1 的造 BUG 场景是全书最重要的一个 BUG 示范——你将用眼睛看到“计数器从 2000 变 1837”,从此你对“数据竞争”这个词不再纸上谈兵。
⏱ 预估:阅读 30 min + 动手 60 min = 1.5 小时。Step 10.1 必须亲手跑,不能跳。
到现在 KV 服务只能一个用户在 REPL 里玩。真实场景需要多个客户端(多线程模拟)同时操作 Store——这一秒就会撞车。本节用 std::mutex 解决基础并发问题,再用 std::shared_mutex 优化读多写少。覆盖卷一第 15 章。
# 灵魂三问:动手前先想清楚
❓ 问题一:05 案例的库存计数器用 atomic 修复了,为什么这里要用 mutex?
来看反例 —— "把 05 案例的经验直接搬过来":
// ❌ 反例:把 unordered_map 包成 atomic
std::atomic<std::unordered_map<std::string, EntryPtr>> idx_; // ⚠️ 直接编译失败
2
问题:
atomic<T>只能装 trivially copyable 的类型——map 内部有指针、堆内存、自定义析构,不能 atomic- 真实业务的"原子操作"远不止读/写一个值——
SET要"查 key→插 entry→更新 TTL"三步,任何一步被另一个线程插足都会乱 - CAS 循环对复合操作不适用——05 案例
stock是一个 int,CAS 可以;这里是"找 + 改 + 写",没法塞进一次 CAS
✅ 正确做法:临界区由若干语句组成时,用 mutex 把它们包起来——atomic 只解决"单变量读改写",复合操作只能靠锁。这是为什么所有现实数据库都重度依赖锁。
❓ 问题二:既然有 shared_mutex 让读并发,为什么不所有锁都升级成它?
来看反例:
// ❌ 反例:所有 mutex 一律升级为 shared_mutex
class Store {
mutable std::shared_mutex mtx_; // 听起来更高级,用着用着发现性能反而下降
// ...
};
2
3
4
5
问题:
- shared_mutex 内部更复杂——要维护"读者计数 + 写者标志 + 等待队列",单次加锁开销是 mutex 的 2-3 倍
- 写少读多才划算——如果实际是 50% 读 50% 写,shared_mutex 会比 mutex 慢
- 写者饥饿(writer starvation)——读者源源不断时,写者可能永远拿不到锁
- 不是所有平台 shared_mutex 都"真公平"——MSVC、libstdc++、libc++ 实现各异
✅ 正确做法:默认用 mutex,只有"读 ≥ 90%、临界区较长"时再升级成 shared_mutex。本案例 §10.3 演示了"如何度量读写比,再决定要不要升级"——性能优化必须先量再改。
❓ 问题三:Redis 是单线程的也跑得飞起,那我们费这劲加锁干嘛?
这是工程上的"灵魂拷问"。来看真实对比:
| 维度 | 单线程 Redis | 多线程加锁 |
|---|---|---|
| 实现复杂度 | 极简(无锁) | 复杂(锁选型、粒度、饥饿) |
| 单核利用 | 100%(所有命令排队执行) | 100% |
| 多核利用 | 几乎 0(命令处理只用 1 核) | 接近线性 |
| 命令延迟方差 | 大(命令必须排队) | 小(并发执行) |
| 长命令阻塞影响 | 致命(KEYS * 卡所有人) | 局部 |
| 适合场景 | 命令短、QPS 高、单机够用 | 命令长、需要多核 |
Redis 6+ 引入了多线程 IO——网络读写并发,但命令执行依然单线程。原因:单线程对 Redis 的核心数据结构修改"零锁",一致性保证最简单。
✅ 本案例的选择:教学上必须演示"多线程 + 锁"——因为这是 C++ 工程师必备技能。生产架构选哪个是另一个问题(会在 §14.4 讨论)。
🔑 教学要点:并发不是越多越好。一个变量该不该跨线程共享、共享时该用什么粒度的锁、是否该退一步改成单线程——这些决策远比"会写 lock_guard"重要。
# Step 10.1 计数器变 1837
🎯 本步目标:这是全书最重要的一个造 BUG。设计一个“两线程各干 1000 次递增,期望总计数为 2000”的场景,亲眼看到“总计数变成 1837 / 1742 / 1923…”。看过一次你一辈子忘不了数据竞争是什么。
# 💥 阶段 1:最小复现代码
先不动 Store,只用最裸的 int 变量看现象。新建 test_race.cpp:
// test_race.cpp——两线程各加 1000 次,期望 2000
#include <iostream>
#include <thread>
int counter = 0; // 裸变量,未保护
int main() {
std::thread t1([] { for (int i = 0; i < 1000; ++i) ++counter; });
std::thread t2([] { for (int i = 0; i < 1000; ++i) ++counter; });
t1.join();
t2.join();
std::cout << "counter = " << counter << "\n";
}
2
3
4
5
6
7
8
9
10
11
12
13
$ g++ -std=c++17 -O2 test_race.cpp -o test_race -lpthread
$ ./test_race
counter = 1837
$ ./test_race
counter = 1742
$ ./test_race
counter = 1923
$ ./test_race
counter = 2000 # 偊尔也能踩到“正确”
2
3
4
5
6
7
8
9
💥 你看到了什么?
- 期望 2000,实际在 1700~2000 之间摆动
- 每次跑结果不一样(不可重现性是这类 BUG 最讨厌的点)
- 代码看起来总个完全正确,但就是错
原因在哪?++counter 看似一句,实际是三步:
1. load counter → 寄存器 (读到当前值,假设是 100)
2. add 1 (变 101)
3. store 寄存器 → counter (写回去)
2
3
三步之间都可能被其他线程插足:t1 读到 100,同一时间 t2 也读到 100,t1 写回 101,t2 也写回 101——本来该加 2 次但只加了 1 次。这就是丢据。
# 💥 阶段 2:同样的问题出现在 Store上
现在把场景携到本项目的 Store 上:
// test_store_race.cpp
mkv::Store store;
std::vector<std::thread> ths;
for (int i = 0; i < 8; ++i) {
ths.emplace_back([&store, i] {
for (int j = 0; j < 10000; ++j) {
store.set("counter", static_cast<std::int64_t>(i * 10000 + j));
}
});
}
for (auto& t : ths) t.join();
std::cout << mkv::formatValue(store.get("counter")->value) << "\n";
2
3
4
5
6
7
8
9
10
11
12
这段代码大概率:
- 打印一个无意义的值(脏写);
- 直接段错误(
unordered_map::emplace在 rehash 时被另一个线程插入打断); - 死循环或挂起(链表/树指针被并发修改)。
$ ./test_store_race
Segmentation fault (core dumped) # ⚠️ 运气不好
$ ./test_store_race
79234 # ⚠️ 不是 79999 也不是任何该出现的值
2
3
4
原因是 Store::set 没有同步保护,多个线程同时调用 idx_.emplace(...) 时容器内部状态被破坏。
✅ Step 10.1 验证:
- 阶段 1 代码亲手跑 5 次,能看到至少 3 个不同的错值
- 阶段 2 代码亲手跑 3 次,能看到“段错误”或“不该出现的值”至少 1 次
- 能说出“++counter 为什么不是原子的”(读-加-写三步可被插足)
这 3 点都能勾后 → Step 10.1 通过。你已经体会了全世界最难改的一类 BUG 是什么感觉。
💡 小白领悟:数据竞争的可怕不在于“会出错”而在于“不一定出错”。你本地跑 100 次都过的代码上线后偊尔状况出 BUG——这是跨运营商类、不亜于咪零拼酒多酱冹圣运营赴责。这就是为什么你必须靠锁、
std::atomic、或者 lock-free 数据结构主动防御,而不是“上线看看再说”。
# Step 10.2 mutex 修复竞争
🎯 本步目标:加一把锁让上面的造 BUG 代码变成“100 次都返回正确值”。且要看明白
mutable与 RAII 锁为什么是原子双掌。
# 10.2 mutex 守护数据
最朴素的修复是给 Store 加一把互斥锁,每个公开方法在最开始上锁:
#include <mutex>
namespace mkv {
class Store {
public:
void set(const std::string& key, Value v) {
std::lock_guard<std::mutex> lk(mu_);
// ... 原来的 set 逻辑 ...
}
EntryPtr get(const std::string& key) const {
std::lock_guard<std::mutex> lk(mu_);
// ... 原来的 get 逻辑 ...
}
// 所有公开方法都加锁
private:
mutable std::mutex mu_; // mutable: const 方法里也能上锁
IndexMap idx_;
};
} // namespace mkv
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
两个新点:
mutable关键字:std::mutex::lock()不是 const 函数,但我们想在 const 的get()里也能上锁;mutable就是给"逻辑上 const、物理上要修改"的成员开的后门。这是卷一第 9 章 const 的延伸用法。std::lock_guard是 RAII 锁:构造时 lock,析构时 unlock;中间无论怎么return/throw,锁一定会释放。这是卷一第 12 章 RAII 思想在多线程世界的体现——永远不要手动 lock/unlock。
✅ Step 10.2 验证:把 Step 10.1 阶段 2 的 Store::set / Store::get 加上 std::lock_guard<std::mutex> lk(mu_),重新跑 test_store_race 一百次:
$ for i in $(seq 1 100); do ./test_store_race; done | sort -u | wc -l
1 # 所有 100 次输出都一样,看到一个确定的值
2
100 次都一样 且 没有 segfault → Step 10.2 通过。同时 Step 10.1 造出的 BUG 被以“能说出为什么加锁能修”的身份收尾。
💡 小白避坑:别为了“看起来不需要锁”而不加锁。多线程下代码能不能不加锁不是表面能看出来的,要么靠
std::atomic,要么靠 lock。中间不存在“我觉得应该不必锁”这一选项。
# Step 10.3 shared_mutex 优化
🎯 本步目标:KV 负载是读多写少(GET:SET 通常 10:1)。
std::mutex让读互斥也太亏——std::shared_mutex让 GET 可以并发。
# 10.3 shared_mutex 读优化
KV 存储的真实负载是读多写少(GET:SET 通常 10:1 以上)。std::mutex 让所有读也互斥就太亏——其实多个 GET 同时跑没有任何冲突。std::shared_mutex(C++17)允许"多读 / 单写":
#include <shared_mutex>
namespace mkv {
class Store {
public:
void set(const std::string& key, Value v) {
std::unique_lock lk(mu_); // 写锁:独占
// ... 写逻辑 ...
}
EntryPtr get(const std::string& key) const {
std::shared_lock lk(mu_); // 读锁:可共享
// ... 读逻辑 ...
}
std::vector<std::string> keys() const {
std::shared_lock lk(mu_);
// ... 读逻辑 ...
}
private:
mutable std::shared_mutex mu_;
IndexMap idx_;
};
} // namespace mkv
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
unique_lock vs shared_lock:
| 类型 | 行为 | 用在哪 |
|---|---|---|
std::lock_guard<M> | 构造 lock、析构 unlock,最简单 | 简单互斥场景 |
std::unique_lock<M> | 同上但可暂时 unlock / 转移所有权 | 配合 condition_variable 必选 |
std::shared_lock<SM> | 共享读锁(多个可并存) | 配合 shared_mutex 用于读 |
上面写法用了 C++17 的类模板参数推导(CTAD)——
std::unique_lock lk(mu_)不用写<std::shared_mutex>,编译器自己推。
✅ Step 10.3 验证:在 main 里启 10 个线程只读 GET(1 个线程偊尔 SET),分别用 std::mutex 与 std::shared_mutex 跑 5 秒,看总 QPS:
$ ./bench_mutex # mutex
QPS = 1.2M ops/s
$ ./bench_shared_mutex # shared_mutex
QPS = 4.8M ops/s # ⚡ 读并发上去了
2
3
4
读在并发上优势明显 → Step 10.3 通过。你亲眼验证了“为什么要区分读锁写锁”不是书上纸谈兵,而是 4 倍吞吐量。
💡 小白避坑:不要代码里遇到任何 const 方法都提 shared,“读锁”与“不可变”不是一件事。比如 cache 装个
mutable起动化意思的计数器不能用读锁加(多个读者同时改计数器是错的),这个召要用写锁。看出这点是 Senior C++ 工程师反震。
# Step 10.4 锁粒度取舍
🎯 本步目标:跳到架构层面看问题。读完这一步你能看明白“为什么 Redis 完全不用锁”这个看似奇谈的实际选择。
# 10.4 锁粒度的取舍
shared_mutex 已经够用,但更激进的优化是分片锁(sharded lock)——把 16 个 bucket 各配一把锁,访问 key 时按 hash 选一把锁。本案例不实现,因为:
- 教学目标是理解锁的本质,不是榨性能;
- 分片锁会让"原子地修改 N 个 key"变得复杂(要按固定顺序拿 N 把锁防死锁);
- 真实 Redis 是单线程模型——它压根不用锁,靠 epoll 串行执行所有命令,反而更快更简单。
这里有个深刻教训:锁不是越多越好。你能 lock-free,就别 fine-grained;你能粗粒度,就别细粒度。先正确,再性能。
✅ Step 10.4 验证:能用一句话回答下面 3 个面试额外额外高频问题你就可以宣告 Step 10.4 过了:
- 为什么 Redis 不用锁? → 单主线程串行执行命令,用 epoll 取代多线程,压根不用锁。
- 什么场景下应该选分片锁? → 写吞吐量拼上限、且代码能保证不跨多个分片修改时。
- 为什么“原子地修改 N 个 key”拿分片锁会难? → 需要按固定顺序拿 N 把锁防止死锁,代码复杂度限制拼。
📌 阶段⑩ 小结
收获 对应卷一章节 数据竞争原理与“2000 变 1837”亲眼现场 第 15 章 std::mutex+std::lock_guardRAII 锁第 12+15 章 std::shared_mutex+unique_lock/shared_lock第 15 章 mutable为 const 方法里加锁开后门第 9 章(const 的深化用法) C++17 类模板参数推导 CTAD 第 16 章 锁粒度 vs Redis 单线程架构哲学 架构课 当前代码量:~1050 行;当前能力:服务可以多客户端同时读写不出事。下一阶段 阶段⑪ 要动后台线程扫过期 key——同时看
std::jthread怎么不忘 unjoinable 动静。
# 11.TTL 后台清理
🎯 阶段⑪ · 后台线程与优雅停止
子步骤 内容 卷一对应章节 验证点 Step 11.1 惰性删除 + 主动清理双机制 第 13 章 ✅ 能说出为什么需要后台扫描 Step 11.2 ⚠️ 造 BUG:裸 std::thread忘 join →terminate第 15 章 ✅ 亲眼看到进程 abort,换 jthread后优雅退出Step 11.3 main 里启动 Server,析构顺序伏笔 第 12+15 章 ✅ 能说出 server/aof/store 为什么要这个顺序 Step 11.4 内存模型全景回顾(栈/堆/静态区) 第 11+13 章 ✅ 能画出三大区域各住什么 本阶段后能力跃迁:有了干净启停的后台线程。
jthread+stop_token是 C++20 最玩的多线程原语之一,用了就不会再发生”进程关了但后台线程还在跑“这种圣运赔钕门。⏱ 预估:阅读 25 min + 动手 35 min = 1 小时。Step 11.2 造 BUG 必须亲手踩。
加了 EXPIRE 之后还有个隐藏问题:没人主动删过期 key。Store::get 的"惰性删除"只在被读到时才生效;如果一个过期 key 永远没人读,它就永远占着内存。本节加一个后台线程定期扫描清理,用上 C++20 的 std::jthread。
# Step 11.1 惰性与主动清理
🎯 本步目标:看明白 Redis 为什么要同时用两套机制,不会只要一套。
# 11.1 惰性删除 + 主动清理
Redis 用两套机制:
| 策略 | 触发时机 | 优点 | 缺点 |
|---|---|---|---|
| 惰性删除 | 用户读到过期 key 时 | 零额外 CPU | 没人读的过期 key 永远不删 |
| 定期采样 | 后台每 100ms 抽一批检查 | 兜底覆盖 | 抽样可能漏到长尾 |
本案例两个都做,是 KV 存储的工程标准实践。
✅ Step 11.1 验证:能用一句话回答”如果只要惰性删除会怎样?只要后台扫描会怎样?“两个问题你就可以过 Step 11.1:只要惰性会造成过期不访问的 key 占内存;只要扫描会让后台线程占额外 CPU 且底不会遇到“上一秒刚过期下一秒就被访问”这种场景。两者结合才完备。
# Step 11.2 裸 thread 忘 join
🎯 本步目标:亲手造一个“裸
std::thread出作用域未 join”的进程崩溃,亲眼看到为什么std::jthread是 C++20 需要推出这东西。
# 💥 阶段 1:裸 std::thread 忘 join 看 terminate
// test_thread_forget_join.cpp
#include <iostream>
#include <thread>
#include <chrono>
void runOnce() {
std::thread t([]{
std::this_thread::sleep_for(std::chrono::seconds(1));
std::cout << "bg work done\n";
});
// ⚠️ 忘了 t.join() 也忘了 t.detach()
} // t 出作用域 → std::thread 析构检查到 joinable() 为 true → std::terminate
int main() {
runOnce();
std::cout << "main continue\n"; // ⚠️ 这句话永远不会输出
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ g++ -std=c++17 test_thread_forget_join.cpp -o tt -lpthread && ./tt
terminate called without an active exception
Aborted (core dumped)
2
3
**💥 怎样?**进程直接 abort。C++ 标准说:std::thread 析构时如果仍 joinable(),必须调 std::terminate()。这种设计是为了“防止静默丢线程”,但代价是“可能不小心就让服务整个 abort”。
# 🔧 阶段 2:换 std::jthread 立马优雅
上面代码只需一个字母的改动:std::thread → std::jthread:
void runOnce() {
std::jthread t([]{ // ⚡ 就这一个 j
std::this_thread::sleep_for(std::chrono::seconds(1));
std::cout << "bg work done\n";
});
} // jthread 析构会自动 request_stop() + join()
2
3
4
5
6
$ ./tt
bg work done
main continue # ✅ 优雅退出
2
3
一个字母从 abort 变优雅——这就是 C++20 为什么要推 std::jthread。本案例 Server 里用 std::jthread purger_; 同一个原理。
✅ Step 11.2 验证:阶段 1 看到 abort、阶段 2 看到 “bg work done + main continue” 都出。两个都踩过后你亲身领悟了 “为什么现代 C++ 代码 review 看到裸 std::thread 会被 reject”。
💡 小白避坑:有的人遇上面问题后会加
t.detach()“修复”。detach 该用场合很紧——一旦 detach 后你就完全丢了这个线程的控制,进程退出时后台线程可能还在访问已销毁的资源(UB)。默认选 jthread,遇到 review 里的 detach 要问一句“为什么”。
# Step 11.3 main 启动 Server
🎯 本步目标:把上面的
Server中 main 里拼起来,重点看对象析构顺序怎么一路伏下来。
# 11.2 jthread 与 stop_token
C++20 引入的 std::jthread 是 std::thread 的"豪华升级版":
| 特性 | std::thread (C++11) | std::jthread (C++20) |
|---|---|---|
| 析构时未 join | terminate 进程 ❌ | 自动 request_stop + join ✅ |
| 协作式停止 | 自己加标志位 | 内置 std::stop_token |
| 推荐用法 | 老代码兼容 | 新项目首选 |
定义一个守护线程:
#include <thread>
#include <stop_token>
#include <chrono>
namespace mkv {
class Server {
public:
Server(Store& store) : store_(store) {
// 启动 TTL 清理线程
purger_ = std::jthread([this](std::stop_token st) {
KV_LOG_INFO("ttl purger thread started");
while (!st.stop_requested()) {
// 睡 100ms,但能被 stop_requested 提前唤醒
std::this_thread::sleep_for(std::chrono::milliseconds(100));
try {
store_.purgeExpired();
} catch (const std::exception& e) {
KV_LOG_ERROR("purger exception: " << e.what());
}
}
KV_LOG_INFO("ttl purger thread stopped");
});
}
// 析构时 jthread 自动 request_stop + join
~Server() = default;
Server(const Server&) = delete;
Server& operator=(const Server&) = delete;
private:
Store& store_;
std::jthread purger_;
};
} // namespace mkv
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
std::jthread 的"自动析构 join"为什么重要:用 std::thread 时一不小心就会写出这种代码:
{
std::thread t([]{ /* ... */ });
} // ❌ 离开作用域,t 既没 join 也没 detach → terminate
2
3
std::jthread 析构时会自动调 request_stop() + join(),从根上消除这个雷。这就是为什么新代码都该用 jthread。
# 11.3 main 里启动 Server
int main() {
try {
mkv::fs::path aofPath = "data/aof.log";
mkv::Store store;
mkv::replayAof(aofPath, store);
KV_LOG_INFO("replayed " << store.size() << " keys");
mkv::AofWriter aof(aofPath);
mkv::Server server(store); // 启动后台清理
std::string line;
while (true) {
std::cout << "> ";
if (!std::getline(std::cin, line)) break;
// ... 命令循环不变 ...
}
// server 析构 → 后台线程自动停 → 然后 aof 析构 → store 析构
}
catch (const std::exception& e) {
KV_LOG_ERROR("fatal: " << e.what());
return 1;
}
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
对象析构顺序值得专门记一笔:C++ 规则是栈上对象按构造逆序析构。这里:
- 先析构
server→ 停后台线程,确保不再有人调store.purgeExpired(); - 再析构
aof→ 关闭文件; - 最后析构
store→ 释放所有 Entry。
这个顺序不能颠倒——如果先析构 store 而 server 的后台线程还在跑,就会访问已销毁的 store,立刻 UB。这就是为什么"声明顺序"在 RAII 工程里值得当成设计来做,第 12 章末尾埋的伏笔到这里彻底兑现。
✅ Step 11.3 验证:能用一句话说出下面 3 个问题你就过了 Step 11.3:
mkv::Store store; mkv::AofWriter aof; mkv::Server server;这三行顺序能怎么调顺序吗? → 不能,须是 store → aof → server。- 为什么不能
Server server; Store store;? → server 需要拿 store 的引用,store 还没构造完。 - 为什么不能
Store store; Server server; AofWriter aof;? → 析构顺序为 aof → server → store,aof 先销会丢最后一批 flush 机会。
# Step 11.4 内存模型回顾
🎯 本步目标:走到终点了,朗一眼全月能看明白“这个项目运行起来后各个对象住在哪”是说明你会了 C++ 内存模型。
# 11.4 卷一 11 章再演
到此各对象的"住址"全景如下:
栈帧 (main 内)
├── store ─┐
├── aof ─┤───── 三个对象本体在栈上
├── server ─┘ 析构按声明逆序:server → aof → store
堆区
├── server.purger_ 拥有的 jthread 内核句柄、栈
├── store.idx_ 桶数组
├── 各 Entry(shared_ptr 控制块 + Entry 本体)
└── 各 std::string 的字符 buffer(>15 字节的部分)
代码区 / 静态区
├── main 等函数指令
├── KV_LOG_LEVEL 宏展开后形成的字面量
└── 字符串字面量 "OK", "(nil)" 等
2
3
4
5
6
7
8
9
10
11
12
13
14
15
写到这里你应该能回答最开始的问题:"C++ 工程师"和"会写 C++ 的人"差在哪?答案就是:前者随时能在脑里画出这张图。
✅ Step 11.4 验证:合上书在白纸上手画一遍上面那张内存布局图。能在 5 分钟内补出“3 大区域×每区域什么住在里面” → Step 11.4 通过。在纸上画不出来 = 头脑里还不清楚。
📌 阶段⑪ 小结
收获 对应卷一章节 惰性删除 + 主动扫描 的工程双机制 第 13 章 std::jthread+stop_token的优雅停止第 15 章 裸 std::thread忘 join 会terminate的造 BUG 领悟第 15 章 对象声明顺序 ≡ 析构顺序 的设计选择 第 12 章(RAII 的设计表达力) 栈/堆/静态区 全景内存模型 第 11 章 当前代码量:~1200 行;当前能力:服务有了后台优雅启停、内存不漏泄。下一阶段 阶段⑫ 是终点:端到端跑起来 + 全书毕业。
# 12.端到端运行
🎯 阶段⑫ · 端到端运行与压测
子步骤 内容 卷一对应章节 验证点 Step 12.1 CMake 编译与 C++20 的踩坑 工具链 ✅ 能跑出 ./build/mini_kvStep 12.2 一轮 REPL 交互演示 本节 ✅ 能依次跑完 8 个命令 Step 12.3 杀进程 + 重启验证持久化 本节 ✅ kill -9后重启能拿到原数据Step 12.4 多线程 stress 压测验证锁有效 第 15 章 ✅ 8 线程各 1 万次访问后数据不坏 本阶段后能力跃迁:你不只写完了一个 1200 行的 KV 存储,还亲手验证了它“能跑、跨重启、多线程不出事”。现在你可以在简历上写“独立使用 C++17/20 实现了一个带持久化与并发安全的 KV 存储”了。
⏱ 预估:动手 30~60 min。这一节全是亲手践踏,不要看,看只是你个人越过了阀门,不是代码越过了阀门。
终于到了见证奇迹的时刻——把所有东西编译跑起来。
# Step 12.1 编译
# 12.1 编译
$ cmake -B build -DCMAKE_BUILD_TYPE=Release
$ cmake --build build -j
$ ls build/mini_kv
build/mini_kv
2
3
4
第一次编译可能踩的坑:
| 错误信息 | 原因 | 解决 |
|---|---|---|
'jthread' is not a member of 'std' | 编译器不支持 C++20 | 升级 GCC ≥ 10 / Clang ≥ 12 / MSVC ≥ 19.30 |
undefined reference to pthread_create | Linux 下没链接 pthread | CMakeLists 加 target_link_libraries(mini_kv PRIVATE Threads::Threads) |
'shared_mutex' file not found | C++ 标准低于 17 | 检查 set(CMAKE_CXX_STANDARD 20) |
补全后的 CMakeLists(最终版):
cmake_minimum_required(VERSION 3.16)
project(mini_kv CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
find_package(Threads REQUIRED)
add_executable(mini_kv mini_kv.cpp)
target_link_libraries(mini_kv PRIVATE Threads::Threads)
if(MSVC)
target_compile_options(mini_kv PRIVATE /W4)
else()
target_compile_options(mini_kv PRIVATE -Wall -Wextra -Wpedantic)
endif()
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Step 12.2 一次完整会话
# 12.2 一次完整的会话演示
$ ./build/mini_kv
[14:30:01] [INFO ] [mini_kv.cpp:712 main] replayed 0 keys
[14:30:01] [INFO ] [mini_kv.cpp:680 operator()] ttl purger thread started
MiniKV v1.0 — type EXIT to quit.
> SET name zhangsan
OK
> SET age 30
OK
> SET pi 3.14
OK
> SET active true
OK
> GET name
"zhangsan"
> GET age
(integer) 30
> GET pi
(double) 3.140000
> GET active
true
> KEYS *
1) "name"
2) "age"
3) "pi"
4) "active"
> EXPIRE name 5
OK
> TTL name
(integer) 4
# 等 6 秒
> GET name
(nil) # 已被惰性删除(或后台线程已清理)
> KEYS *
1) "age"
2) "pi"
3) "active" # name 不见了
> DEL pi
(integer) 1
> EXIT
bye.
[14:31:15] [INFO ] [mini_kv.cpp:683 operator()] ttl purger thread stopped
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# Step 12.3 验证 AOF 重启
# 12.3 杀进程再启动
$ ./build/mini_kv
> SET project minikv
OK
> SET stars 1024
OK
> ^C # Ctrl+C 强制退出
$ cat data/aof.log
SET project minikv
SET stars 1024
$ ./build/mini_kv
[14:32:08] [INFO ] [mini_kv.cpp:712 main] replayed 2 keys # 数据回来了
> GET project
"minikv"
> GET stars
(integer) 1024
> EXIT
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# Step 12.4 stress 并发验证
# 12.4 stress 并发验证
为了确认锁没写错,写一个极简的 stress harness(可以放在另一个 cpp 里临时跑):
int main() {
mkv::Store store;
std::vector<std::jthread> ths;
constexpr int N = 8;
constexpr int M = 100000;
auto t0 = std::chrono::steady_clock::now();
for (int i = 0; i < N; ++i) {
ths.emplace_back([&store, i] {
for (int j = 0; j < M; ++j) {
store.set("k" + std::to_string(j % 100), static_cast<std::int64_t>(i));
(void)store.get("k" + std::to_string(j % 100));
}
});
}
ths.clear(); // join all
auto t1 = std::chrono::steady_clock::now();
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(t1 - t0).count();
std::cout << N * M * 2 << " ops in " << ms << " ms = "
<< (N * M * 2.0 / ms * 1000) << " ops/s\n";
std::cout << "final size = " << store.size() << "\n";
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Mac M2 + Release 编译典型输出:
1600000 ops in 950 ms = 1684210.5 ops/s
final size = 100
2
168 万 ops/s——一个手写一晚上的 KV 存储,扛住 8 线程并发,零崩溃零数据错乱。这就是 C++ 给你的力量。
✅ Step 12.4 验证:
- 跑完 stress 测试没有 segfault → 阶段⑩的
shared_mutex真扛住了多线程 final size = 100→ 100 个 key 都正确创建,没有数据竞争丢失- ops/s 数字稳定(多跑 3 次,方差 < 5%)→ 说明锁开销稳定,没有死锁
3 项都过 → Step 12.4 通过 → 整个 06 案例全部 12 个 Step 全部毕业。 🎓
📌 阶段⑫ 小结 + 🎓 全书 12 阶段毕业卡片
# 🏆 12 阶段全清单回顾
阶段 主题 核心造 BUG / 验证亮点 ① 极简 v0.1(map + REPL) ✅ 第一次回车看到 OK ② Value variant 类型 ✅ 三种类型都能存取 ③ Entry + 过期 + shared_ptr ✅ EXPIRE 后 GET 返回 nil ④ 命令模式抽象 ✅ 多态分发与 AOF 复用 ⑤ Store 升级 + 索引 ✅ unordered_map 性能验证 ⑥ tokenize + 命令工厂 ✅ 解析鲁棒性 ⑦ 异常体系 + 分层 catch ⚠️ 不抓异常 → abort 复现 ⑧ AOF 持久化 + 损坏容错 ⚠️⚠️ AOF 半行损坏严格 vs 宽松 ⑨ KV_LOG 宏 + 条件编译 ⚠️ do-while(0) else 错配 ⑩ 多线程并发安全 ⚠️⚠️⚠️ 2000 变 1837 数据竞争(全书最经典) ⑪ jthread 后台清理 ⚠️ 裸 thread 忘 join → terminate ⑫ 端到端运行 + stress ✅ 168 万 ops/s 实测 # 📊 最终数据
- 代码总量:~1500 行(按章节"逻辑展开"),实际工程拆 9 个 .hpp + 9 个 .cpp
- C++ 特性覆盖:variant / shared_ptr / unique_ptr / 多态 / 模板 / 异常 / mutex / shared_mutex / jthread / stop_token / lock_guard / unique_lock / shared_lock / filesystem / 宏 / 类模板参数推导(CTAD) / 移动语义 / RAII / mutable / final / override / pimpl / event sourcing
- 造过 BUG 总数:8 处(07 节 1 + 08 节 1 + 09 节 2 + 10 节 1 + 11 节 1 + 12 节验证 1 + 跨节呼应若干)
- 编译验证点:每一个 Step 末尾都有,全文累计 40+ 处 ✅
# 🎓 你现在可以在简历/面试上直说
- "用现代 C++(17/20)独立实现了一个支持 String/Int/List 类型、AOF 持久化、TTL 过期、多线程安全的 KV 存储,约 1500 行代码"
- "工程实践中亲手处理了:AOF 半行损坏的严格 vs 宽松取舍、多线程数据竞争(用 shared_mutex 解决读多写少)、
std::jthread替代std::thread防止析构 terminate、do-while(0)防御宏 else 错配"- "通过 stress 测试在 M2 上跑出 168 万 ops/s(8 线程 100 key 16 万次混合读写)"
# 🏁 下一站
这是卷二 6 个综合案例的最后一个。你已经完成了从"01 学生通讯录的 cin >> name"到"06 多线程 KV 存储 168 万 ops/s"的跨越——一个月前你不敢相信自己能写。
接下来推荐路线:
- 立刻:把 15.3 三个延伸挑战的至少挑战一做完(List 类型 + LPUSH/LRANGE)
- 本周:进入卷三第 6~10 章——内存模型、协程、网络编程、高性能日志、Profile,把 MiniKV 升级为真协议级 Redis 兼容服务
- 本月:把整个 06 案例的源码包整理上 GitHub,README 写清"对应卷一各章节",作为简历项目
C++ 的学习曲线就此走完最陡的一段,剩下都是平原。Welcome to the C++ engineer club. 🍺
下一节做最后总结:知识矩阵回顾、与生产 Redis 的差距、技术思考、衔接卷三。
# 13.项目总结
# 13.1 整体架构图
┌─────────────────┐
│ main loop │
│ (REPL 主循环) │
└────────┬────────┘
│ 一行命令
▼
┌─────────────────┐
│ tokenize │ ← string_view 零拷贝
└────────┬────────┘
│ vector<string_view>
▼
┌─────────────────┐
│ makeCommand │ ← 工厂 + 异常翻译
└────────┬────────┘
│ unique_ptr<Command>
▼
┌─────────────────┐
│ cmd->execute │ ← 多态分发
└────────┬────────┘
│ 调用
▼
┌────────────────────────────────────────────────────┐
│ Store │
│ ┌──────────────────────────────────────────────┐ │
│ │ shared_mutex mu_ │ │
│ │ unordered_map<string, shared_ptr<Entry>> │ │
│ └──────────────────────────────────────────────┘ │
└─────────────┬──────────────────────────┬───────────┘
│ │
▼ ▼
┌────────────────┐ ┌────────────────────┐
│ AofWriter │ │ jthread purger │
│ (追加写日志) │ │ (后台清理过期) │
└────────────────┘ └────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# 13.2 16 章覆盖回顾
请在每一项前打 ✅ 或 ❓。所有都打 ✅ 才算"卷一通关":
| 卷一章节 | 你能在本项目中指出落地代码吗 |
|---|---|
| 02 基础语法 | □ main 主循环 / cin/getline |
| 03 数据类型 | □ Value 包含 int64_t/double/bool/string |
| 04 运算符 | □ Entry 默认生成的 <=> 比较 |
| 05 复合类型 | □ enum class CmdType |
| 06 流程语句 | □ switch 命令分发 / 范围 for / 结构化绑定 |
| 07 函数 | □ Lambda 注册命令 / 默认参数 |
| 08 指针引用 | □ string_view 零拷贝 / 智能指针 |
| 09 类与对象 | □ Store 的五法则 / Command 抽象 |
| 10 继承多态 | □ Command 基类 + 4 个派生 + vtable |
| 11 内存模型 | □ 4.3 节内存分区图 / 11.4 节对象生命周期 |
| 12 动态内存 | □ make_shared / make_unique |
| 13 IO 与文件 | □ ofstream 追加 / filesystem 创建目录 |
| 14 异常处理 | □ KvError 体系 / noexcept 标记 |
| 15 线程和锁 | □ shared_mutex / lock_guard / jthread |
| 16 STL 模板 | □ unordered_map / 模板化 registerCmd<T> |
| 17 预处理器 | □ KV_LOG 宏 / __FILE__/__LINE__ / 条件编译 |
如果有 ❓,回到对应小节再读一遍——这才是"综合案例"的真正用法。
# 13.3 距生产 Redis 差距
老老实实地说,本案例和 Redis 的差距是:
| 维度 | MiniKV | Redis |
|---|---|---|
| 网络协议 | 本地 REPL(无网络) | RESP 协议 + epoll |
| 并发模型 | 多线程 + shared_mutex | 单线程 + epoll(更快更简单) |
| 数据结构 | string/int/float/bool | + List/Hash/Set/ZSet/Stream |
| 持久化 | 仅 AOF(无 fsync 策略调优) | RDB + AOF 双轨 + everysec/always 三级 fsync |
| 复制 | 无 | 主从复制 + Sentinel |
| 集群 | 无 | Redis Cluster 槽位 + Gossip |
| 过期算法 | 简单遍历 | 抽样 + 自适应频率 |
但这不是缺陷——正是这些"做减法"让你能在两个晚上写出来并完整理解每一行。后面想扩任何一个能力,都有现成的算法和论文可以学。
# 13.4 代码统计(参考)
$ wc -l mini_kv.cpp
1487 mini_kv.cpp
$ cloc mini_kv.cpp
-------------------------------------------------------------
Language files blank comment code
-------------------------------------------------------------
C++ 1 198 213 1076
-------------------------------------------------------------
2
3
4
5
6
7
8
9
千余行代码 + 近两百行注释,一个能用的小型 KV 存储——值得作为简历项目。
# 14.技术思考
# 14.1 命令模式优于 if-else
if-else 不是"丑"——而是它违反了开闭原则。每加一个命令都要修改主循环,每改一次主循环就要重新测全部命令的回归。
命令模式把"加新命令"和"修改主流程"正交化:新命令是新文件、新类、新单测,老代码一行不动。这就是为什么 GoF《设计模式》把它列为基础模式之一。
更深的好处:命令对象可以被存起来——你能轻易实现 MULTI/EXEC 事务(攒 N 个 Command 后一起执行)、命令日志(执行前序列化保存,做审计)、命令回放(如 AOF)。这些 if-else 风格根本做不到。
# 14.2 AOF 的 fsync 策略
本案例每次写命令后调 flush(),但 flush() 只是把数据从 C++ stdio 缓冲推到 OS,OS 本身还有页缓存(page cache),整机断电仍可能丢最后几秒。真正落盘要调 fsync(fd):
| 策略 | 含义 | 性能 | 数据安全 |
|---|---|---|---|
always | 每次写都 fsync | 极慢(磁盘随机写) | 最安全 |
everysec | 每秒 fsync 一次 | 中等(Redis 默认) | 最多丢 1 秒 |
no | 完全交给 OS 决定 | 最快 | 整机断电可能丢几十秒 |
std::ofstream 没有跨平台的 fsync 接口,需要用 POSIX 的 fsync(fileno(...)) 或 Windows 的 FlushFileBuffers。这是本案例的简化点——卷三会专门讲存储引擎的 fsync 调度。
# 14.3 jthread 优于 thread
总结一张表:
| 维度 | std::thread | std::jthread |
|---|---|---|
| 析构未 join | terminate(程序挂了) | 自动 request_stop + join |
| 协作停止 | 自己写 atomic<bool> 标志位 | 内置 std::stop_token |
| 与 condition_variable 配合 | 自己实现"被唤醒检查 stop" | 有 condition_variable_any::wait 直接接 stop_token |
| 何时该用旧的 thread | 与老代码兼容 / 必须用 detach() | 几乎不再有理由 |
结论:C++20 之后,新代码默认用 jthread,让"忘了 join"这种 bug 不可能写出来。这就是现代 C++ 的进化方向——"让正确写法成为默认写法"。
# 14.4 KV 单线程多线程
可能让你意外的是——Redis 是单线程(指命令执行)。原因:
- KV 的瓶颈是网络和内存,不是 CPU;
- 单线程没有锁开销,单核能跑出 10 万 QPS,多核加锁反而互相挤;
- 单线程没有竞态,bug 少一个数量级;
- 多机扩展靠水平分片而不是单机多线程。
我们做多线程是为了练 C++ 的 mutex/jthread,这是教学目标,不是性能最优。如果你以后真要做高性能 KV,可以学习单线程 + epoll + 协程的组合(卷三第 8-10 章会讲)。
# 15.衔接与延伸
# 15.1 与上一案例衔接
05.多线程订单 教会你"用 mutex/atomic/cv 保证多线程安全",06.MiniKV 教会你"在这个安全调用之上再加上 AOF 持久化 + 后台定时任务 + 命令模式"。两个案例的并发原语一脉相承——你甚至可以直接把 05 章的 ThreadPool 组件拿来复用,让 MiniKV 的 REPL 可以同时服务多个客户端。这就是良好抽象的复用价值。
同时本案例也隶属收了原 11.JSON 案例里的 std::variant 抽象——mkv::Value 和 04.JSON 案例 中的 JsonNode 是一脉相承的设计,你甚至可以直接把 04 章的抽象拿来替换 MiniKV 的 mkv::Value,立刻获得"存任意 JSON 到 KV"的能力。
留给你做的延伸:在 MiniKV 里加一条命令 JSET key json_string,把 JSON 字符串 parse 成 JsonNode 存进去;JGET key path 按 JSON Path(如 user.name)取值。
# 15.2 与卷三的递进
本案例做完,你已经在卷一与卷三之间架好桥梁。卷三关于本案例的进阶话题:
| 卷三章节 | 与本案例的递进 |
|---|---|
| 第 6 章 内存模型与原子操作 | 把 shared_mutex 换成无锁哈希表(folly F14) |
| 第 7 章 协程 | 把 jthread 后台清理换成协程 |
| 第 8 章 网络编程 | 把 REPL 升级为 TCP Server,加 RESP 协议解析 |
| 第 9 章 高性能日志 | 把 KV_LOG 升级为异步日志(写入双缓冲,后台线程刷盘) |
| 第 10 章 Profile | 用 perf / VTune 测出 MiniKV 的热点是哪行 |
# 15.3 三个延伸挑战
按难度递增,做完任何一个都能让你的 C++ 上一个台阶:
挑战一(★★★):加 List 类型 + LPUSH/RPUSH/LRANGE
- 在
Value的variant里加std::vector<std::string>; - 新增
LPushCmd/LRangeCmd派生类; - AOF 重放路径完全不用改(这就是命令模式的好处)。
挑战二(★★★★):实现 AOF Rewrite
- 现状:AOF 文件只增不减,跑久了会膨胀;
- 实现:定时把当前内存状态 dump 成"等价但更短的命令序列",原子替换旧 AOF;
- 关键:rewrite 期间新到达的写命令要同步追加到新文件 → 用
std::filesystem::rename做原子替换。
挑战三(★★★★★):网络化 + RESP 协议
- 用 POSIX socket 或 Boost.Asio 让 MiniKV 监听 6379 端口;
- 实现 RESP 协议(Redis Serialization Protocol)的 parser;
- 用
redis-cli直接连你的 MiniKV,看到redis-cli ping返回PONG那一刻——你已经做出"协议级兼容 Redis 的服务器"了。
# 15.4 多文件源码包
正文为聚焦讲解,把 ~1500 行代码"逻辑上"按章节展开。真正落到磁盘上时,工程级 C++ 不会一文件包打天下——配套源码已按职责拆好放在:
code/06.MiniKVStore/
├── CMakeLists.txt
├── README.md ← 编译运行指南 + 命令清单 + 章节对照
├── include/mkv/
│ ├── errors.hpp ← §07 异常体系
│ ├── value.hpp ← §03 Value
│ ├── entry.hpp ← §04 Entry / EntryPtr / IndexMap
│ ├── log.hpp ← §09 KV_LOG 宏
│ ├── store.hpp ← §06 / §10 Store + shared_mutex
│ ├── command.hpp ← §05 Command 体系 + makeCommand
│ ├── aof.hpp ← §08 AofWriter / replayAof
│ └── server.hpp ← §11 jthread 后台清理
├── src/ ← 与头文件一一对应的 .cpp
│ └── main.cpp ← REPL 主入口
└── tests/
└── stress.cpp ← §12.4 多线程压测
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
一行编译运行:
cd code/06.MiniKVStore
cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j
./build/mini_kv # REPL
./build/mini_kv_stress # 压测
2
3
4
5
6
拆分原则:每个
.hpp都对应一个章节里的概念组,依赖关系单向流动(main → command → store → entry/value/errors/log)。看代码时建议按本文章节顺序对照阅读,你会明白"为什么这么拆"远比"怎么拆"更重要——这是从"会写 C++"到"能维护一个 C++ 工程"的关键一步。
# 16.尾声寄语
恭喜你读完了本卷最长的一章。如果你跟着把 1500 行代码敲完了,回头看看自己一个月前写的"01 学生通讯录"——那时你可能还在纠结 cin >> name 为什么读不全空格。
C++ 的学习曲线确实陡,但每一段陡坡之后都是开阔地。你现在已经具备了用 C++ 工程化解决问题的能力——剩下的只是不断接触真实世界的复杂度,慢慢长出"工程师的直觉"。
下一卷见,我们去看 C++ 的底层世界。
- ⬅ 上一案例:05.多线程订单与线程池
- 🎓 恭喜你完成本卷 6 个综合案例的最后一个,进入卷三《底层卷》继续学习。