欢迎回到 Aloe。在这篇博客中,笔者将向各位读者介绍最近开发的 UKVEngine —— 高性能 KV 存储引擎。这篇文章记录了笔者开发 UKVEngine 时的流程,包括立项动机、架构设计、性能迭代、踩坑记录、不同环境下的性能差异、AOF 设计等内容。希望读者能从中获得启发。

前言:为什么要做这个项目?

笔者在最初立项的时候的想法还是蛮简单的:

  • 想深入了解 Linux 系统上的开发。

  • 想理解网络和多线程并发编程的底层原理设计思想

  • 想有一个真正意义上能压测能讲清楚能系统地串联起自己所学的一个项目。

综合上述几个想法,笔者决定从零开始手写一个 KV 存储引擎(GitHub 仓库)。

架构设计与选型

用图片展示存储引擎请求处理流水线

UKVEngine 分为 ukvukvd 两个部分,在这篇文章里会着重介绍 ukvd。 笔者会以组成 ukvd 的类的视角来为读者拆解这个项目:

  • LruCache

  • ShardedLruCache

  • RespParser

  • ThreadPool

  • UkvServer

LruCache

一个标准的 LRU Cache 实现,担任整个 KV 存储引擎的底层数据结构。其本质是基于“最近最少使用”原则的缓存淘汰算法。

核心思想是维护一个按照存取时间排序的哈希表 + 双向链表

  • 对于 GET 操作:若 key 存在,则将该数据移到链表头部,表示最近使用

  • 对于 PUT 操作:若 key 存在,则将该数据更新并移到链表头部( 特别地,若容量满,则删除链表尾部的元素);若不存在,则在链表头部构造新数据。

1
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
class LruCache {
public:
explicit LruCache(size_t capacity);

LruCache(const LruCache&) = delete;
LruCache& operator=(const LruCache&) = delete;

~LruCache();

bool Get(const std::string& key, std::string* out_value);
void Put(std::string key, std::string value);
bool Delete(const std::string& key);

private:
size_t capacity_;
size_t size_;
Node* head_;
Node* tail_;
std::unordered_map<std::string, Node*> table_;

std::mutex mutex_;

void DetachNode(Node* node);
void AttachToHead(Node* node);
void MoveToHead(Node* node);
};

ShardedLruCache

在性能调优阶段加入,为了解决在多线程下频繁修改链表而造成的高锁竞争的问题而设计。 其本质是一个维护了若干个 LruCache 对象指针的数组。

核心思路是将原来的单一 LRU Cache 进行分片

  • 上层 UkvServer 并不直接调用 LruCache 类的方法,而是交给这个分片类的同名方法预处理。

  • 成员函数只做这三件事情:

    • 根据传进的 key 计算出 hash 值,
    • 根据 hash 值,使用取模(或更快速的按位与)计算出这个键值对所在的分片(数组下标),
    • 调用这个分片对象的同名方法。

在开发初期,笔者以为朴素地使用一把互斥锁直接锁住 LruCache 是足够的, 但到了 benchmark 环节,压测结果告诉笔者显然并非如此。无论是 GET, PUT 还是 DEL, 都会对链表进行修改(上锁)。原本朴素的方案在高并发下吞吐退化明显, 于是分片成为了必须使用的策略。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class ShardedLruCache {
public:
explicit ShardedLruCache(size_t capacity, size_t num_shards = 16);

ShardedLruCache(const ShardedLruCache&) = delete;
ShardedLruCache& operator=(const ShardedLruCache&) = delete;

bool Get(const std::string& key, std::string* value);
void Put(std::string key, std::string value);
bool Delete(const std::string& key);

private:
std::vector<std::unique_ptr<LruCache>> shards_;
size_t num_shards_;
};

RespParser

针对压测结果持续优化迭代的流式 RESP 解析状态机,能够有效地应对半包、粘包等问题。

笔者曾写出了一个严重依赖 find_first_of 的版本,代码充斥着各种 Magic Number边界条件以及特殊判断,非常难以理解和维护。于是笔者重构成了这种基于状态机的实现。

依据 RESP 协议的标准格式,若协议内容无错误,NextCommand 方法会严格遵循如下的解析步骤:

  1. 读入 Bulk Array 最开头的字符 *,并准备读入 Array 元素个数;

  2. 逐字符读入数字并存入临时字符串,直到读入 \r,将字符串转为数组元素个数,并准备读入 \n

  3. 读入 \n,并准备读入 Bulk String 最开头的字符 $

  4. 读入 Bulk String 最开头的字符 $,并准备读入 String 长度;

  5. 逐字符读入数字并存入临时字符串,直到读入 \r,将字符串转为字符串长度,并准备读入 \n

  6. 读入\n,并准备读入字符串的内容;

  7. 根据步骤 5. 得到的字符串长度,直接得到对应长度的子串,并准备读入 \r\n

  8. 读入 \r\n。判断已读 Bulk String 个数是否等于步骤 2. 得到的数组元素个数:

    • 等于:返回解析后的字符串数组。

    • 不等于:准备读入 $,并转到步骤 4.。

若在某个环节的解析过程中出现意外的字符,则视为非法请求。清空缓冲区,状态机恢复为初始状态, 返回 std::nullopt

1
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
class RespParser {
public:
RespParser() = default;

void AppendData(const char* data, size_t len);
std::optional<std::vector<std::string>> NextCommand();

private:
enum class ParseState {
READ_ARRAY_PREFIX,
READ_ARRAY_LEN,
EXPECT_ARRAY_LF,

READ_STR_PREFIX,
READ_STR_LEN,
EXPECT_STR_LF,

READ_STR_DATA,
EXPECT_DATA_CR,
EXPECT_DATA_LF
};

std::string internal_buffer_;
size_t parse_index_ = 0;
ParseState parse_state_ = ParseState::READ_ARRAY_PREFIX;

std::vector<std::string> command_;
size_t array_len_ = 0;
size_t expected_len_ = 0;
std::string t_;

std::nullopt_t ErrorDataHandler();
};

ThreadPoolUkvServer

这两个类我们放在一起说,他们共同实现了 ukvd 的网络层。

UkvServer 目前采用单 Reactor 多线程的模式: Reactor 负责监听,Handler 仅负责读取与响应,实际的数据业务逻辑交给了线程池来处理。

线程池的核心实现

核心思想是减少频繁创建和销毁线程带来的性能开销,重用预先创建的线程,使得任务到达时可立即执行。

ThreadPool 类持有一个 std::vector<std::thread> 的线程数组。 数组内的每个线程都被初始化成一个死循环函数:

  • 工作线程在没有任务的时候会调用 cv_.wait 挂起自己,等待 notify 后醒来。

    • wait 存在虚假唤醒的问题——线程在没有任何 notify 的情况下自行被唤醒。 这是操作系统与 CPU 层面的事情,POSIX 明确允许这种情况发生。 此时如果直接取队列头部(头部可能为空)会导致未定义行为。

      • 解决方法是使用带有 predicate 的 wait 重载,相当于把函数包装进一个 while 循环。 虚假唤醒后,条件仍会被检查,只有真正满足了条件才能继续。
  • 被唤醒后,判断接下来要做的事情:

    • running_ == false:在析构函数中唤醒了所有线程,此时立刻结束线程。

    • running_ == true:在 EnQueue 方法中唤醒了一个线程,此时可以保证队列头部必有元素, 拿走队列头的任务并开始执行。

  • 执行完毕任务后,重新开始循环。

1
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
class ThreadPool {
public:
explicit ThreadPool(size_t thread_num) : running_(true) {
for (size_t i = 0; i < thread_num; i++) {
workers_.emplace_back([this] {
for (;;) {
std::function<void()> task;

{
std::unique_lock<std::mutex> lock(mutex_);
cv_.wait(lock,
[this] { return !running_ || !tasks_.empty(); });

if (!running_ && tasks_.empty()) {
return;
}

task = std::move(this->tasks_.front());
tasks_.pop();
}

task();
}
});
}
}

ThreadPool(const ThreadPool&) = delete;
ThreadPool& operator=(const ThreadPool&) = delete;

template<typename T>
void EnQueue(T&& f) {
{
std::unique_lock<std::mutex> lock(mutex_);
tasks_.emplace(std::forward<T>(f));
}

cv_.notify_one();
}

~ThreadPool() {
{
std::unique_lock<std::mutex> lock(mutex_);
running_ = false;
}

cv_.notify_all();
for (auto& worker : workers_) {
worker.join();
}
}

private:
std::vector<std::thread> workers_;
std::queue<std::function<void()> > tasks_;

std::mutex mutex_;
std::condition_variable cv_;

bool running_;
};

服务端的工作流程

  1. 初始化网络
  • 初始化 Socket,配置为接受 IPv4 + IPv6 双栈,使用 epoll 监听 listen 事件。
  1. 开始事件循环
  • 新连线(配置在 InitNetwork 中的监听):

    • 接受这个新连线,并将文件描述符配置为非阻塞 I/O (O_NONBLOCK)。

      • 这是必要配置。防止网络波动带来的 read 未读全所有数据造成的工作线程阻塞的问题。 在资源不可用时可以立即返回,否则会造成工作线程的无故浪费。
    • 为这个连线分配一个 RespParser 对象。

    • 使用 epoll 监听这个连线发送数据事件,并将标志配置为 EPOLLONESHOT

      • 这是必要配置。这确保了一个 socket 连线在任一时刻只会被一个线程处理。 否则若数据被拆成多次事件,则多个线程会同时操作一个 socket,引发严重的竞态条件
  • 发送数据(配置在 HandleNewConnection 中的初次监听):

    向线程池任务队列构造一个任务:

    建立读取缓冲区,非阻塞读发送来的数据,得到实际读到数据大小。


    1
    2
    3
    4
    5
    6
    7
    8
    9
    parser->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
2
c=100  → 120k qps
c=1000 → 95k qps ← 并发涨10x,吞吐反而跌

并且注意单次请求等待时间:

1
2
p50@c=100  | 0.42ms
p50@c=1000 | 5.28ms ← 并发涨10x,等待时间涨约13x

吞吐跌得少是因为连接数暂且撑住了,但是单次等待时间增长问题无法忽视,锁竞争仍然是主要问题。 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
2
每次 SET → 可能触发淘汰 → 链表删除节点 → 写锁
每次 GET → 移动节点到头部 → 写锁

扩容到 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
2
3
Thread 0: epoll → read → parse → execute → write(负责 fd 0,4,8...)
Thread 1: epoll → read → parse → execute(负责 fd 1,5,9...)
...

这样既利用了多核,又避免了线程池的任务分发开销,是目前主流高性能网络库(如 muduo)的标准做法。

踩过的坑

开发过程中,难免会写出运行结果让人皱眉的代码,这里分享一些笔者踩过的坑:

在工作线程中加入输出流操作

笔者在开发时在工作线程中加入了一些 debug 文本,每次监听到请求都会向标准错误输出命令文本, 但是在压测是时候居然忘记删除掉这些流操作! 低效的流操作导致 QPS 骤降,这在第一轮性能优化过程中被发现并解决。

shard_index 操作数硬编码

笔者起初在 ShardedLruCache 的成员函数里这样计算某个 key 所在的分片:

1
2
size_t hash_val = std::hash<std::string>{}(key);
size_t shard_index = hash_val & 15;

不难发现,笔者硬编码了这个按位与的操作数 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 的实现中, 每轮 DoSetDoDelete 都会调用 AppendAof 方法,而这个方法最初的实现效果欠佳:

  • 将本次接收到的 RESP 字符串,直接调用 aof_file_.write() 加入文件流缓冲区;

  • 一个预先启动的后台线程,每 1 秒调用 aof_file_.flush() 写盘;

  • 期间两个(或更多)线程争抢唯一一把文件锁 aof_mutex_,在高并发下造成了严重的锁竞争, 相当于让我们分片 LRU 带来的优势荡然无存。

性能调优的过程事实上也很简单,就是不断减小锁粒度。为此笔者做出了这样的改变:

优化:双缓冲机制

展示双缓冲机制时序/状态图

核心思路是一致的:坚决不能让工作线程直接碰文件本身,文件锁竞争 + 磁盘 I/O 会直接拖垮吞吐。 那么工作线程要去操作什么呢?笔者在 UkvServer 类内维护了两个字符串缓冲区 active_aof_buffer_backend_aof_buffer_

1
2
3
4
5
6
7
8
9
10
11
12
void UkvServer::AppendAof(const std::vector<std::string>& args) {
if (!aof_file_.is_open()) {
return;
}

std::string line;
RespBuilder::BulkStringArray(args, line);

// 获取锁,只追加字符串,绝对不进行文件 I/O。追加后立刻释放锁
std::lock_guard<std::mutex> lock(aof_buffer_mutex_);
active_aof_buffer_.append(line);
}

AofAppend 方法中,工作线程只需要获取锁,进行 append 操作并立刻释放锁。 因为这只涉及内存拷贝,速度是纳秒级别,所以锁冲突时间极其微小

后台线程依旧 1 秒醒来一次,期间获取锁,只做两件事情:

1
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
aof_flusher_ = std::thread([this] {
while (g_running) {
std::this_thread::sleep_for(std::chrono::seconds(1));

{
// 获取锁,交换两个缓冲区,然后立刻释放锁
std::lock_guard<std::mutex> lock(aof_buffer_mutex_);
if (active_aof_buffer_.empty()) { continue; }
std::swap(active_aof_buffer_, backend_aof_buffer_);
}

if (aof_file_.is_open()) {
// 由于只有后台线程去存取文件,因此不需要加锁,不会向工作线程引入锁竞争
aof_file_.write(backend_aof_buffer_.data(), backend_aof_buffer_.size());
aof_file_.flush();
}

backend_aof_buffer_.clear();
}

// 停机时,最后几个命令大概率不满 1 秒等到后台线程写盘,因此手动写最后一次文件
std::lock_guard<std::mutex> lock(aof_buffer_mutex_);
if (aof_file_.is_open() && !active_aof_buffer_.empty()) {
aof_file_.write(active_aof_buffer_.data(), active_aof_buffer_.size());
aof_file_.flush();
active_aof_buffer_.clear();
}
});
  1. 获取锁,瞬间交换两个缓冲区。backend 拿到了要写盘的字符串,active 变空。 这也是纳秒级的速度。

  2. 无锁写盘,即使速度较慢,也影响不到工作线程。

    提示: 这里无锁写盘是安全的。因为我们提到过:只有后台线程真正进行写盘操作。 并且工作线程只存取 active,写盘过程只存取 backend,真正慢的磁盘 I/O 完全在锁外面。

  3. 清空 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
2
PC:   c=100 123k → c=1000 109k,跌 11%
服务器:c=100 83k → c=1000 78k,跌 6%

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 类内维护两个字符串与一把锁: activebackend 缓冲区与 aof_mutex_。工作线程只负责向 active 追加字符串, 工作线程只负责从 backend 字符串中读取并写盘,锁只用于锁住 active 的变动。

思考: 如何让 activebackend 既互不干扰,又都能同时存取呢?

第一直觉

在工作线程同时写入两个字符串?显然是错的,因为这样做会面临以下的问题:

  • 后台线程写盘的时候,如果需要追加数据,该不该写,该写给谁?

  • 后台写盘完毕后,用过的数据已经失去价值,该清理哪些?如何保证线程安全?

  • 如果第一个问题选择只写给 active,那么后台写盘完毕后,如何同步写盘期间差额数据? 什么时候同步?如何保证线程安全?

如果不严格考虑线程安全与锁竞争,其实这几个问题还是很好解答的:

  • 继续追加数据,不过只写给 activebackend 等待后续同步。

  • 清理 backendactive 重叠部分,可以对 active 上锁, backend 可以等写完再清理。

  • 可以选一个固定时间,比如清理无用数据的时候,把差额数据同步给 backend, 可以对 active 上锁。

看起来甚至是多线程安全的,只不过性能差一点,我们思考一下哪些动作是重复的。

思考: 如果写盘时间过长,导致 backend 迟迟没能同步到/共同写到最新数据,这是个大问题吗? 刚才提到的哪个步骤可以很好地弥补这一点?

如果无限延长 backend 的等待时间,直到清理无用数据事件发生前。此时会发生这样的事情:

  • active 积累了一个定时周期的数据,在即将清理的时刻终于同步给了 backend

  • 此时 active 可以选择清除所有数据,继续等待哪怕很慢backend 写盘。

  • backend 终于写盘完毕,清空数据,随之而来的又是第一步的循环。

不难发现,即使 backend 没能与 active 同时写哪怕一字节,同样也能完成任务, 这给了我们启发:

  • 取消 activebackend 同时写,而是定期自动清理 + 同步,这样 active 可以直接同步字符串内所有数据给 backend,自己瞬间清空;backend 也刚刚清理 完毕上一轮写盘数据,刚刚接收到 active 的全量数据。

思考: 这个动作可以简化成一次标准库内存在的什么动作?

交换!清理 + 同步的过程完全可以简化为成:满 active 与空 backend 的一次交换, 这一切只需要 backend 在写盘完毕后清空自己即可。同样,交换的过程也只涉及内存, 对这一个动作加锁是完全可以接受的。我们找到了兼顾多线程安全高性能的方案。

正确实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
aof_flusher_ = std::thread([this] {
while (g_running) {
std::this_thread::sleep_for(std::chrono::seconds(1));

{
std::lock_guard<std::mutex> lock(aof_buffer_mutex_);
if (active_aof_buffer_.empty()) { continue; }
std::swap(active_aof_buffer_, backend_aof_buffer_);
}

if (aof_file_.is_open()) {
aof_file_.write(backend_aof_buffer_.data(), backend_aof_buffer_.size());
aof_file_.flush();
}

backend_aof_buffer_.clear();
}

std::lock_guard<std::mutex> lock(aof_buffer_mutex_);
if (aof_file_.is_open() && !active_aof_buffer_.empty()) {
aof_file_.write(active_aof_buffer_.data(), active_aof_buffer_.size());
aof_file_.flush();
active_aof_buffer_.clear();
}
});

下面正式介绍 AOF v2 的写盘流程:

  • 后台写盘线程每秒醒来一次,获取锁并把 activebackend 交换,之后瞬间释放锁。

  • 无锁读取 backend,并写入文件。

  • 写盘完毕后,清空 backend,再次睡眠。

  • 当服务端停机后,把最后不足一秒而未能唤醒写盘线程的剩余数据,额外最后写一次。

至此,我们成功地完成了 AOF 的设计

再次展示双缓冲机制时序/状态图

总结与 Roadmap

项目收获

这个项目让笔者真正理解了一件事:性能问题不是靠猜的,靠的是数字。 每一轮迭代都始于一个让人皱眉的 benchmark 结果,终于一张对比表格。 从 pipeline 的 38k 到 1.57M,从单锁 LRU 到分片,从直接写盘到双缓冲, 每一步优化背后都有一个可以被数据验证的假设。 这种”提出假设 → 实现 → 压测 → 验证”的工程思维,是笔者认为这个项目给自己最大的收获。

项目现状与不足

目前 UKVEngine 与真正的生产级 KV 存储相比,仍有明显差距:

  • 命令支持有限,仅实现了 SET / GET / DEL
  • 线程模型仍是单 Reactor + 线程池,高并发延迟存在结构性瓶颈
  • 尚不支持 TTL 与键过期

Roadmap

写在最后

这篇文章既是开发流程的记录,也是笔者对”如何系统地思考一个工程问题”的一次梳理。 如果你也在做类似的项目,希望这些踩坑经历和迭代思路能对你有所帮助。我们下一篇文章见!