UKVEngine 开发流程纪实
欢迎回到 Aloe。在这篇博客中,笔者将向各位读者介绍最近开发的 UKVEngine —— 高性能 KV 存储引擎。这篇文章记录了笔者开发 UKVEngine 时的流程,包括立项动机、架构设计、性能迭代、踩坑记录、不同环境下的性能差异、AOF 设计等内容。希望读者能从中获得启发。
前言:为什么要做这个项目?
笔者在最初立项的时候的想法还是蛮简单的:
想深入了解 Linux 系统上的开发。
想理解网络和多线程并发编程的底层原理与设计思想。
想有一个真正意义上能压测、能讲清楚,能系统地串联起自己所学的一个项目。
综合上述几个想法,笔者决定从零开始手写一个 KV 存储引擎(GitHub 仓库)。
架构设计与选型
UKVEngine 分为 ukv 和 ukvd
两个部分,在这篇文章里会着重介绍 ukvd。 笔者会以组成
ukvd 的类的视角来为读者拆解这个项目:
LruCacheShardedLruCacheRespParserThreadPoolUkvServer
LruCache 类
一个标准的 LRU Cache 实现,担任整个 KV 存储引擎的底层数据结构。其本质是基于“最近最少使用”原则的缓存淘汰算法。
核心思想是维护一个按照存取时间排序的哈希表 +
双向链表。
对于
GET操作:若key存在,则将该数据移到链表头部,表示最近使用对于
PUT操作:若key存在,则将该数据更新并移到链表头部( 特别地,若容量满,则删除链表尾部的元素);若不存在,则在链表头部构造新数据。
1 | class LruCache { |
ShardedLruCache 类
在性能调优阶段加入,为了解决在多线程下频繁修改链表而造成的高锁竞争的问题而设计。
其本质是一个维护了若干个 LruCache 对象指针的数组。
核心思路是将原来的单一 LRU Cache 进行分片:
上层
UkvServer并不直接调用LruCache类的方法,而是交给这个分片类的同名方法预处理。成员函数只做这三件事情:
- 根据传进的
key计算出 hash 值, - 根据 hash 值,使用取模(或更快速的按位与)计算出这个键值对所在的分片(数组下标),
- 调用这个分片对象的同名方法。
- 根据传进的
在开发初期,笔者以为朴素地使用一把互斥锁直接锁住
LruCache 是足够的, 但到了 benchmark
环节,压测结果告诉笔者显然并非如此。无论是
GET, PUT 还是 DEL,
都会对链表进行修改(上锁)。原本朴素的方案在高并发下吞吐退化明显,
于是分片成为了必须使用的策略。
1 | class ShardedLruCache { |
RespParser 类
针对压测结果持续优化迭代的流式 RESP 解析状态机,能够有效地应对半包、粘包等问题。
笔者曾写出了一个严重依赖
find_first_of的版本,代码充斥着各种 Magic Number、 边界条件以及特殊判断,非常难以理解和维护。于是笔者重构成了这种基于状态机的实现。
依据 RESP 协议的标准格式,若协议内容无错误,NextCommand
方法会严格遵循如下的解析步骤:
读入 Bulk Array 最开头的字符
*,并准备读入 Array 元素个数;逐字符读入数字并存入临时字符串,直到读入
\r,将字符串转为数组元素个数,并准备读入\n;读入
\n,并准备读入 Bulk String 最开头的字符$;读入 Bulk String 最开头的字符
$,并准备读入 String 长度;逐字符读入数字并存入临时字符串,直到读入
\r,将字符串转为字符串长度,并准备读入\n;读入
\n,并准备读入字符串的内容;根据步骤 5. 得到的字符串长度,直接得到对应长度的子串,并准备读入
\r与\n;读入
\r与\n。判断已读 Bulk String 个数是否等于步骤 2. 得到的数组元素个数:等于:返回解析后的字符串数组。
不等于:准备读入
$,并转到步骤 4.。
若在某个环节的解析过程中出现意外的字符,则视为非法请求。清空缓冲区,状态机恢复为初始状态, 返回
std::nullopt。
1 | class RespParser { |
ThreadPool 与
UkvServer 类
这两个类我们放在一起说,他们共同实现了 ukvd
的网络层。
UkvServer 目前采用单 Reactor 多线程的模式: Reactor 负责监听,Handler 仅负责读取与响应,实际的数据业务逻辑交给了线程池来处理。
线程池的核心实现
核心思想是减少频繁创建和销毁线程带来的性能开销,重用预先创建的线程,使得任务到达时可立即执行。
ThreadPool 类持有一个
std::vector<std::thread> 的线程数组。
数组内的每个线程都被初始化成一个死循环函数:
工作线程在没有任务的时候会调用
cv_.wait挂起自己,等待notify后醒来。但
wait存在虚假唤醒的问题——线程在没有任何notify的情况下自行被唤醒。 这是操作系统与 CPU 层面的事情,POSIX 明确允许这种情况发生。 此时如果直接取队列头部(头部可能为空)会导致未定义行为。- 解决方法是使用带有 predicate 的
wait重载,相当于把函数包装进一个while循环。 虚假唤醒后,条件仍会被检查,只有真正满足了条件才能继续。
- 解决方法是使用带有 predicate 的
被唤醒后,判断接下来要做的事情:
running_ == false:在析构函数中唤醒了所有线程,此时立刻结束线程。running_ == true:在EnQueue方法中唤醒了一个线程,此时可以保证队列头部必有元素, 拿走队列头的任务并开始执行。
执行完毕任务后,重新开始循环。
1 | class ThreadPool { |
服务端的工作流程
- 初始化网络
- 初始化 Socket,配置为接受 IPv4 + IPv6 双栈,使用 epoll 监听 listen 事件。
- 开始事件循环
新连线(配置在
InitNetwork中的监听):接受这个新连线,并将文件描述符配置为非阻塞 I/O (
O_NONBLOCK)。- 这是必要配置。防止网络波动带来的
read未读全所有数据造成的工作线程阻塞的问题。 在资源不可用时可以立即返回,否则会造成工作线程的无故浪费。
- 这是必要配置。防止网络波动带来的
为这个连线分配一个
RespParser对象。使用 epoll 监听这个连线发送数据事件,并将标志配置为
EPOLLONESHOT。- 这是必要配置。这确保了一个 socket 连线在任一时刻只会被一个线程处理。 否则若数据被拆成多次事件,则多个线程会同时操作一个 socket,引发严重的竞态条件。
发送数据(配置在
HandleNewConnection中的初次监听):向线程池任务队列构造一个任务:
建立读取缓冲区,非阻塞读发送来的数据,得到实际读到数据大小。
1
2
3
4
5
6
7
8
9parser->AppendData(buffer, bytes_read);
std::optional<std::vector<std::string>> opt_command;
// thread_local 避免每次请求分配新 string,也避免多线程竞争
thread_local std::string out_buffer;
// 需要预留缓冲区大小和显式清空缓冲区
out_buffer.reserve(8192);
out_buffer.clear();建立线程唯一输出缓冲区,将读到的数据追加到该连线对应的
RespParser对象中。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24// 若为 std::nullopt,可能是遇到了半包,等下次数据来了再继续处理
while ((opt_command = parser->NextCommand()) != std::nullopt) {
auto command = std::move(opt_command.value());
if (command.empty()) {
continue;
}
std::string& action = command[0];
std::transform(action.begin(), action.end(), action.begin(), ::toupper);
auto iter = command_handlers_.find(action);
if (iter != command_handlers_.end()) {
// 只攒响应,不立即发送
iter->second(std::move(command), out_buffer);
}
else {
// 只攒响应,不立即发送
RespBuilder::Error("unknown action", out_buffer);
}
}
// 命令处理完毕后,循环外统一发送响应
if (!out_buffer.empty()) {
write(active_fd, out_buffer.data(), out_buffer.size());
}循环从解析器中取出命令,一条一条处理,直到读到
std::nullopt, 每条命令的响应都追加到输出缓冲区,不立即发送。 所有命令处理完之后,把本次响应一次性全部发出去,这是 pipeline 正确性的关键—— 循环内只攒响应,循环外统一发送。
1
2
3
4
5// 重新注册
epoll_event re_event{};
re_event.events = EPOLLIN | EPOLLONESHOT;
re_event.data.fd = active_fd;
epoll_ctl(epoll_fd_, EPOLL_CTL_MOD, active_fd, &re_event);因为用了
EPOLLONESHOT,fd 触发一次后会自动从 epoll 监听中摘除。 处理完数据之后必须手动用EPOLL_CTL_MOD重新注册, 否则这个连线后续发来的数据就没人处理了。连线关闭之后,从哈希表中清掉对应的解析器,释放内存。
性能迭代过程
在开发过程中,通过 redis-benchmark
对系统进行持续压测与分析:
第一轮:Pipeline 异常
先给出当前主线分支与第一轮优化前的压测结果对比:
| 测试场景 | 当前分支结果 | 修复前结果 |
|---|---|---|
| c=1 | ~40,000 QPS | ~27,000 QPS |
| c=100 | ~119,000 QPS | ~83,000 QPS |
| c=1000 | ~100,000 QPS | ~65,000 QPS |
| c=100, P=16 | ~1,570,000 QPS | ~38,000 QPS |
事实上,Pipeline 的语义是「客户端批量发送多条命令,不等响应」, 正确实现应该极大提升吞吐。我们这里反而跌了一半。 经过分析,几乎可以断定是解析层每次只读一条命令, 没有处理「一次 recv 可能收到多条命令」的情况,导致:
多条命令粘在一起时,解析出错或被丢弃
或者反复做了无效的系统调用
排查代码后,发现每条命令解析并执行后,都会调用一次
write,并且过程中居然有输入输出流操作。
反复执行系统调用的性能开销是很昂贵的,于是做了如下的修改:
建立线程唯一输出缓冲区
thread_local std::string out_buffer, 将原来的每次调用拼接到输出缓冲区。仅在循环外(全部命令处理完毕后)执行一次
write调用。删除所有流操作。
此举带来了相当可观的性能进步:
| 测试场景 | 第一轮修复前 | 第一轮修复后 | 提升 |
|---|---|---|---|
| c=1 | ~27,000 QPS | ~41,000 QPS | +52% |
| c=100 | ~83,000 QPS | ~120,000 QPS | +44% |
| c=1000 | ~65,000 QPS | ~95,000 QPS | +46% |
| c=100, P=16 | ~38,000 QPS | ~1,330,000 QPS | +3400% |
第二轮:高并发退化
笔者注意到第一轮优化后,高并发退化问题仍然存在:
1 | c=100 → 120k qps |
并且注意单次请求等待时间:
1 | p50@c=100 | 0.42ms |
吞吐跌得少是因为连接数暂且撑住了,但是单次等待时间增长问题无法忽视,锁竞争仍然是主要问题。
out_buffer 的引入理论上让写操作更集中,但 LRU 每次
GET 修改链表的写锁问题还在。
相比于其他的数据结构(比如跳表),LRU 的问题不是”慢”,是”锁”。 LRU 的实现是 HashMap + 双向链表,单次操作是 O(1),比跳表的 O(log n) 还快。 所以查找速度不是问题。
问题在于:每次 GET
都会修改链表(把节点移到表头),这意味着每次 GET
都会有:
1 | GET 操作 → 读数据(需要锁)+ 更新LRU顺序(也需要写锁) |
这是一个隐蔽的写争用。在 c=1000 并发时,每个 GET 都在抢写锁, 这会显著加剧我们观察到的并发退化。
提示: Redis 用单线程 event loop 就是完全绕开了这个问题。
在这里笔者选择对原有的单一 LRU 进行分片,按照 key hash 分 64 片,每片一把锁, 竞争概率直接降到 1/64。
| 测试场景 | 分片前 | 分片后 | 提升 |
|---|---|---|---|
| c=1000 | ~95,000 QPS | ~109,000 QPS | +13% |
| p50@c=1000 | 5.3ms | 4.6ms | +15% |
提升是真实的,说明锁竞争的确是部分原因,分片有效果,但是提升不算太大。
在以上展示的数据中,笔者设定的 LRU 容量均为 16384。但笔者也尝试过扩大容量这种做法, 比如把容量从 16384 大幅提升至 131072,也同样跑出了与分片类似的结果。 深入探究,笔者发现这不是真正的优化,而是针对特定 benchmark 数据量的巧合适配。 简单来说,分片和扩大容量都在做一件事情:减少写锁的压力,只不过路径不同。
为什么扩容能起作用
benchmark 跑 100k 次操作,容量只有 16384,意味着大量 key 被淘汰:
1 | 每次 SET → 可能触发淘汰 → 链表删除节点 → 写锁 |
扩容到 131072 > 100k 之后,几乎没有淘汰发生,写锁的持有频率和时长都大幅下降,竞争自然减少。
对比两种方式
| 扩容 | 分片 | |
|---|---|---|
| 减少竞争的方式 | 减少写操作的发生次数 | 把竞争分散到 N 把锁 |
| 依赖条件 | 数据量必须小于容量 | 无条件有效 |
| 真实业务环境下 | 不可靠,数据量一旦超过容量立刻失效 | 始终有效 |
分片才是结构性的解决方案。换句话说,benchmark 数据碰巧 fit 进了扩大后的 cache, 实际上规避了 LRU 的写操作。这是个测试条件的巧合, 不是真正的优化 。
第三轮:线程模型的结构性开销(TODO)
再次分析第二轮优化后的结果,说明瓶颈不仅是 LRU 锁,或者说 LRU 锁已经不是主要瓶颈了。
更可疑的信号是 p50 在 c=1000 时仍然是 4.6ms,是 c=100 的 11 倍。 锁竞争不能完全解释这个延迟,线程调度开销更符合这个特征。
回忆我们的架构: 1
epoll_wait → 把任务丢给线程池 → 工作线程处理 → 归还线程
这个模式本身没有问题,Netty 也是类似思路。但对于 KV 存储这种场景,它有一个结构性的代价:
问题所在
KV 操作本身极快,可能只需要 1-5 微秒。但每次分发给线程池需要:
任务入队(锁)
唤醒工作线程(条件变量)
上下文切换
这些开销加起来轻松达到 10-50 微秒,比任务本身还慢。 这就是为什么 c=1000 时 p50 达到 4.6ms 的深层原因——大量时间花在线程调度上, 而不是真正的 KV 操作。1000 个连接意味着事件风暴,线程池频繁入队出队,竞争非常激烈。
对比几种模型
| 模型 | 特点 | 适合场景 |
|---|---|---|
| 纯单线程 Reactor(Redis) | 无切换开销,但不能用多核 | CPU 轻任务,极低延迟 |
| 单 Reactor + 线程池(目前思路) | 有切换开销,能用多核 | CPU 重任务 |
| 多线程 Reactor(多个 epoll) | 每个线程独立 event loop | 高并发 + 低延迟 |
KV 操作是典型的 CPU 轻任务,模型的多核优势发挥不出来,切换开销反而成了主要成本。 转为多 Reactor,这是笔者计划中的下一轮迭代方向。
改进方向
多 Reactor:启动 N 个线程,每个线程跑自己独立的 epoll loop, 连接按
fd % N
分配到某个线程,该线程全程负责这个连接的读/解析/执行/写,
完全不需要跨线程传递任务。
1 | Thread 0: epoll → read → parse → execute → write(负责 fd 0,4,8...) |
这样既利用了多核,又避免了线程池的任务分发开销,是目前主流高性能网络库(如 muduo)的标准做法。
踩过的坑
开发过程中,难免会写出运行结果让人皱眉的代码,这里分享一些笔者踩过的坑:
在工作线程中加入输出流操作
笔者在开发时在工作线程中加入了一些 debug 文本,每次监听到请求都会向标准错误输出命令文本, 但是在压测是时候居然忘记删除掉这些流操作! 低效的流操作导致 QPS 骤降,这在第一轮性能优化过程中被发现并解决。
shard_index
操作数硬编码
笔者起初在 ShardedLruCache 的成员函数里这样计算某个 key
所在的分片:
1 | size_t hash_val = std::hash<std::string>{}(key); |
不难发现,笔者硬编码了这个按位与的操作数 15。背后的原因是这样的:
笔者了解到对一个「2 的幂」取余数,等价于对这个数 - 1 做按位与, 用数学语言可以表示为:
hash (mod num) = x&(num − 1)
并且笔者也确实了解到在计算机底层,取余操作比按位与操作慢得多。
当时笔者并未完全理解这个等式,加上当时的分片数确实定为 16。于是笔者直接硬编码为 15。 后续调整分片数为 64 后,发现对没有任何性能优化效果。仔细排查后发现居然在这里写死了操作数, 这导致我们只能用到前 16 个分片!于是笔者做了以下调整:
修改为正确的写法:对
num_shards_ - 1按位与。在构造函数加入断言:
1
assert((num_shards & (num_shards - 1)) == 0 && "num_shards must be power of 2");
确保分片数的确为 2 的幂。
这个修正,解决了当分片数达到更高(比如 32、64 )时,错误路由到前 16 片的问题; 同时通过加入断言,提升了代码的健壮性。
在工作线程中加入文件流操作
与第一点:在工作线程中加入输出流类似。在初版 AOF 的实现中, 每轮
DoSet 和 DoDelete 都会调用
AppendAof 方法,而这个方法最初的实现效果欠佳:
将本次接收到的 RESP 字符串,直接调用
aof_file_.write()加入文件流缓冲区;一个预先启动的后台线程,每 1 秒调用
aof_file_.flush()写盘;期间两个(或更多)线程争抢唯一一把文件锁
aof_mutex_,在高并发下造成了严重的锁竞争, 相当于让我们分片 LRU 带来的优势荡然无存。
性能调优的过程事实上也很简单,就是不断减小锁粒度。为此笔者做出了这样的改变:
优化:双缓冲机制
核心思路是一致的:坚决不能让工作线程直接碰文件本身,文件锁竞争 + 磁盘
I/O 会直接拖垮吞吐。 那么工作线程要去操作什么呢?笔者在
UkvServer 类内维护了两个字符串缓冲区
active_aof_buffer_ 与
backend_aof_buffer_。
1 | void UkvServer::AppendAof(const std::vector<std::string>& args) { |
在 AofAppend 方法中,工作线程只需要获取锁,进行
append 操作并立刻释放锁。
因为这只涉及内存拷贝,速度是纳秒级别,所以锁冲突时间极其微小。
后台线程依旧 1 秒醒来一次,期间获取锁,只做两件事情:
1 | aof_flusher_ = std::thread([this] { |
获取锁,瞬间交换两个缓冲区。
backend拿到了要写盘的字符串,active变空。 这也是纳秒级的速度。无锁写盘,即使速度较慢,也影响不到工作线程。
提示: 这里无锁写盘是安全的。因为我们提到过:只有后台线程真正进行写盘操作。 并且工作线程只存取
active,写盘过程只存取backend,真正慢的磁盘 I/O 完全在锁外面。清空
backend,为下一次交换做准备。
压测结果对比:
| 测试场景 | 无 AOF | AOF v1(直接写) | AOF v2(双缓冲) | v2 vs v1 |
|---|---|---|---|---|
| c=1 | ~43k QPS | ~40k QPS | ~40k QPS | 持平 |
| c=100 | ~123k QPS | ~114k QPS | ~119k QPS | +4% |
| c=1000 | ~109k QPS | ~91k QPS | ~100k QPS | +10% |
| c=100, P=16 | ~1.33M QPS | ~1.34M QPS | ~1.57M QPS | +17% |
本地与服务器环境的性能差异
笔者在 PC 和服务器中分别进行了压测,得到了这样的结果:
| 测试场景 | PC | 服务器 | 保留比例 |
|---|---|---|---|
| c=1 | ~43,000 QPS | ~30,000 QPS | 70% |
| c=100 | ~123,000 QPS | ~83,000 QPS | 67% |
| c=1000 | ~109,000 QPS | ~78,000 QPS | 72% |
| c=100, P=16 | ~1,330,000 QPS | ~880,000 QPS | 68% |
整体均匀地跌了约 30%,这个”均匀”本身是关键信息。
其中笔者的 PC 与服务器的环境分别为:
Core i5-10200H (4 CPU) @ 4.100 GHz, Arch Linux (Linux 6.18.9-arch1-2)
Xeon E5-2678v3 (8 vCPU) @ 3.300 GHz, Debian 12 LXC (Linux 6.8.12-15-pve)
均匀下跌说明什么
如果是某个特定场景跌得多,说明是结构性问题(锁、线程模型等)。 但所有场景一致地跌 30%,说明是底层计算资源的差距,不是代码与结构的问题。 E5-2678 v3 是 2014 年的 Haswell-EP,单核性能和本地的现代 CPU 相比差距就在这个量级。 再加上 LXC 虚拟化的网络栈和调度开销,30% 的损耗是完全合理的,属于正常现象。
反而更好的信号…
服务器上 c=1000 的退化幅度比本地更小:
1 | PC: c=100 123k → c=1000 109k,跌 11% |
8 vCPU 在高并发下能更充分地并行处理,说明多线程模型在真正的多核环境下反而体现出了一点优势。
AOF 持久化的设计思路
为了做到数据不易失,AOF 持久化是必要的。之前在踩坑小节简单介绍过 AOF v1 造成的严重性能问题, 在这里我们启发式地引出目前主线分支使用的 AOF v2(双缓冲机制)策略,让读者与笔者可以一起思考。
我们从一个 RESP 命令的起点开始说起:
命令路由
假设有一条命令 SET name ukvd,这条命令会在
HandleClientData 方法中被识别, 并且路由到
DoSet 方法中。
缓冲区追加
在 DoSet 里,我们会拿到解析完毕的字符串数组
["SET", "name", "ukvd"], 我们直接将这个字符串数组
args 交给 AppendAof 方法。
AppendAof 不能直接进行慢速的磁盘
I/O,因此自然考虑到写入缓冲区。将数组还原为 RESP,
然后交由后台线程慢速写盘。考虑到这个方法会被多个工作线程调用,因此需要对缓冲区追加操作上锁。
思考: 只设计单个缓冲区是否足够?
工作线程只负责进行纳秒级的内存拷贝(append 操作),
锁持有时间极短,因此不会对 QPS 造成毁灭性的打击。
命令写盘
我们建立一个后台线程,每秒唤醒一次,专门用于取出缓冲区的 RESP 并写入文件。 由于工作线程会写入缓冲区,为了多线程安全,后台线程读缓冲区的操作需要获取锁?
思考: 这种实现正确吗?对于步骤 2. 中提出的思考是否有了答案?
事实上这种操作是错误的,这等同于将慢速的磁盘 I/O 推迟到了后台线程而已。后台线程持续持有锁, 无疑是再次向工作线程引入激烈的锁竞争。给出明确的回答:只设计单个缓冲区是不够的。
为了解决这个问题,笔者引入了双缓冲机制。在
UkvServer 类内维护两个字符串与一把锁: active
与 backend 缓冲区与
aof_mutex_。工作线程只负责向 active
追加字符串, 工作线程只负责从 backend
字符串中读取并写盘,锁只用于锁住 active 的变动。
思考: 如何让 active 与
backend 既互不干扰,又都能同时存取呢?
第一直觉
在工作线程同时写入两个字符串?显然是错的,因为这样做会面临以下的问题:
后台线程写盘的时候,如果需要追加数据,该不该写,该写给谁?
后台写盘完毕后,用过的数据已经失去价值,该清理哪些?如何保证线程安全?
如果第一个问题选择只写给
active,那么后台写盘完毕后,如何同步写盘期间差额数据? 什么时候同步?如何保证线程安全?
如果不严格考虑线程安全与锁竞争,其实这几个问题还是很好解答的:
继续追加数据,不过只写给
active,backend等待后续同步。清理
backend与active重叠部分,可以对active上锁,backend可以等写完再清理。可以选一个固定时间,比如清理无用数据的时候,把差额数据同步给
backend, 可以对active上锁。
看起来甚至是多线程安全的,只不过性能差一点,我们思考一下哪些动作是重复的。
思考: 如果写盘时间过长,导致 backend
迟迟没能同步到/共同写到最新数据,这是个大问题吗?
刚才提到的哪个步骤可以很好地弥补这一点?
如果无限延长 backend
的等待时间,直到清理无用数据事件发生前。此时会发生这样的事情:
active积累了一个定时周期的数据,在即将清理的时刻终于同步给了backend。此时
active可以选择清除所有数据,继续等待哪怕很慢的backend写盘。backend终于写盘完毕,清空数据,随之而来的又是第一步的循环。
不难发现,即使 backend 没能与
active 同时写哪怕一字节,同样也能完成任务,
这给了我们启发:
- 取消
active与backend同时写,而是定期自动清理 + 同步,这样active可以直接同步字符串内所有数据给backend,自己瞬间清空;backend也刚刚清理 完毕上一轮写盘数据,刚刚接收到active的全量数据。
思考: 这个动作可以简化成一次标准库内存在的什么动作?
交换!清理 + 同步的过程完全可以简化为成:满
active 与空 backend 的一次交换, 这一切只需要
backend
在写盘完毕后清空自己即可。同样,交换的过程也只涉及内存,
对这一个动作加锁是完全可以接受的。我们找到了兼顾多线程安全与高性能的方案。
正确实现
1 | aof_flusher_ = std::thread([this] { |
下面正式介绍 AOF v2 的写盘流程:
后台写盘线程每秒醒来一次,获取锁并把
active与backend交换,之后瞬间释放锁。无锁读取
backend,并写入文件。写盘完毕后,清空
backend,再次睡眠。当服务端停机后,把最后不足一秒而未能唤醒写盘线程的剩余数据,额外最后写一次。
至此,我们成功地完成了 AOF 的设计!
总结与 Roadmap
项目收获
这个项目让笔者真正理解了一件事:性能问题不是靠猜的,靠的是数字。 每一轮迭代都始于一个让人皱眉的 benchmark 结果,终于一张对比表格。 从 pipeline 的 38k 到 1.57M,从单锁 LRU 到分片,从直接写盘到双缓冲, 每一步优化背后都有一个可以被数据验证的假设。 这种”提出假设 → 实现 → 压测 → 验证”的工程思维,是笔者认为这个项目给自己最大的收获。
项目现状与不足
目前 UKVEngine 与真正的生产级 KV 存储相比,仍有明显差距:
- 命令支持有限,仅实现了 SET / GET / DEL
- 线程模型仍是单 Reactor + 线程池,高并发延迟存在结构性瓶颈
- 尚不支持 TTL 与键过期
Roadmap
写在最后
这篇文章既是开发流程的记录,也是笔者对”如何系统地思考一个工程问题”的一次梳理。 如果你也在做类似的项目,希望这些踩坑经历和迭代思路能对你有所帮助。我们下一篇文章见!



