本文首发公众号 猩猩程序员 欢迎关注
本文来自 blog.canoozie.net/async-i-o-o…
最近,我在做一个复杂的多模型数据库项目。几周前,我抽时间做了个简化版的实验,测试了一下在简单的键值数据库中使用的一个想法。我从最基本的开始:一个内存中的哈希表,一个简单的只追加日志用于持久化和耐久性,每次写入日志时都调用经典的 fsync() 来保证数据不丢失。
它是能工作的,但速度没那么快。
在Kevo中,我采用的就是这个方法。但在Klay(还没公开,不过准备好了会开源)中,我走的是另一条路。如果把硬盘上的每个扇区当作不可靠的,数据库会是什么样?怎么才能做到又快又稳定?
这时候,我开始看关于 Linux 上的 io_uring,在这里(PDF)和这里也有一些很有用的资料。
io_uring... 什么鬼?
你完全可以去维基百科查这些东西,所以我就不废话了。
它的承诺听起来好像太完美了:真正的异步I/O,适用所有操作类型,不止是网络套接字。告别线程池,不再为了绕过阻塞的磁盘I/O而搞复杂的状态机,epoll也能省了……那它的坑在哪呢?
我看了些资料后,io_uring背后的核心思路几乎瞬间让我明白了。传统的I/O接口让你必须同步思考——你发个系统调用,内核做点事情,然后你拿到结果。但是现代存储硬件是并行的。像 NVMe SSD 那种硬盘,可以同时处理成千上万的操作,每个操作都有自己的队列。瓶颈不在硬件,而在软件的抽象上。
io_uring 通过两个环形缓冲区把这种并行处理暴露给你:一个是提交队列(SQ),另一个是完成队列(CQ)。你可以提交多个操作,只用一次 io_uring_submit 调用,就能提交几十个操作,而不需要每次都调用一次系统调用。
我第一次试 io_uring 的实验非常简单:把我的同步写前日志(WAL)改成异步的。每次写日志时,我不再等它完成,而是提交写操作后继续处理其他任务。效果非常明显——吞吐量直接提高了一个数量级。
但接下来我遇到了问题……
耐久性问题
在数据库上下文里,使用异步 I/O 的问题是:你可能失去了数据库最重要的耐久性保证。当客户端收到一个成功响应时,他们的期待是数据会在系统崩溃后依然存在。但使用异步 I/O 时,当你发送响应时,数据可能仍然还在内核缓冲区里,甚至还没写到硬盘。
我最初的解决方法是追踪所有待处理的 I/O 操作,只有在 io_uring 给出对应的完成信号后,才返回成功响应。这个方法有效,但我又回到了原点——还是得等磁盘 I/O 完成,才能完成事务。
显然,我需要一种更好的方法。
重新思考我的 WAL
传统的写前日志(WAL)协议很简单:记录变更,然后强制写入磁盘,再应用它。但如果我们能把“打算变更”与“确认变更”分开呢?如果我们能快速且异步地记录变更意图,然后再单独确认完成呢?
这时,我看到了 Joran Dirk Greef 在讲 TigerBeetle 的演讲。结果发现,TigerBeetle 也用了一种类似的方式。随着我对 TigerBeetle 的了解加深,我对这种方法更有信心了。(注意:TigerBeetle 其实并没有异步地进行外部提交,具体可以参考这篇 X 帖子的澄清。)
于是,我决定尝试在一个简单的内存键值数据库中实现双 WAL 设计,方法是:
- 意图 WAL:记录我计划执行的操作
- 完成 WAL:记录这些操作是否成功完成
最终,协议变成了这样:
- 写入意图记录(异步)
- 在内存中执行操作
- 写入完成记录(异步)
- 等待完成记录写入 WAL
- 返回成功给客户端
在恢复时,我只会应用那些同时有意图和完成记录的操作。这保证了数据一致性,并且能达到更高的吞吐量。
更新:虽然两个 WAL 写入操作都是异步的,但必须等到完成记录被持久写入后,才能给客户端响应。这是通过 io_uring 的完成队列跟踪的——我们只有在收到完成记录已成功写入的确认后才发送成功响应。如果没有这个保证,系统崩溃后可能会丢失数据,违背了耐久性预期。
构建双 WAL 系统
好吧,我还用了 Zig 来实现,因为 Klay 是用 Zig 编写的,Poro(GitHub)也用了 Zig,这样能减少我需要记住的东西。如果没看出来,Poro 就是我实现了双 WAL 系统的实验性键值数据库。
实现这个方法需要注意一些细节。首先,我需要为每种 WAL 类型创建单独的 io_uring 实例。这样可以避免意图写入被阻塞,从而影响完成写入。
pub const WAL = struct {
intent_ring: io_uring, // 专用于意图操作的环形缓冲区
completion_ring: io_uring, // 专用于完成操作的环形缓冲区
intent_file_fd: std.posix.fd_t,
completion_file_fd: std.posix.fd_t,
intent_buffer: []u8, // 循环缓冲区
completion_buffer: []u8, // 循环缓冲区
};
循环缓冲区对性能很重要。我不会把每一条记录都写入磁盘,而是将它们批量处理,直到缓冲区满 75% 后再刷新,这样可以最大化 io_uring 批处理的优势。
每个完成条目都包括一个校验和,并且引用回相应的意图条目:
pub const CompletionEntry = struct {
intent_offset: u32, // 链接到意图条目的偏移
timestamp: i64, // 完成记录的时间戳
status: Status, // 成功、I/O 错误或校验和错误的枚举
checksum: u32, // 键+值的 CRC32 校验
};
恢复过程
恢复过程更复杂一些,但也更稳健:
- 读取整个意图日志,查看哪些操作已经被尝试过
- 读取整个完成日志,查看哪些操作成功完成
- 构建哈希映射,将意图条目和完成条目关联起来
- 只重放那些有成功完成记录的操作
- 验证校验和以确保数据完整性
这种方法处理部分失败时更优雅。如果系统在写入意图记录和对应的完成记录之间崩溃,恢复时会忽略这个操作,就好像它根本没发生过一样。在有复制的数据库中,你还可以通过向集群询问是否有副本包含这个数据来修复数据。
处理延迟与批量性能的平衡
双 WAL 设计确实带来了单个操作的延迟成本——我们现在需要两次写入,才能完成操作响应。对于单一操作来说,理论上这会使延迟翻倍。
但真正的性能提升来自于批处理。当多个客户端并发写入时,我们可以:
- 在一个
io_uring批处理中提交数十条意图记录 - 在写入操作飞行过程中处理所有操作
- 再批量提交所有完成记录
- 一起等待所有完成
这样就把原本需要 2N 次同步写入(针对 N 个操作)的任务,压缩成了 2 次 io_uring 提交,加上等待完成。随着批量大小的增加,每个操作的摊销成本大幅降低。实际测试中,在高负载下,系统的吞吐量提升显著,因为它能够并行处理多个操作,而不是一个个串行处理。
性能突破
结果超出了预期。基准测试显示,事务吞吐量比最初的同步实现提高了 10 倍。更重要的是,系统现在随着 CPU 核心数的增加而扩展,而不再被磁盘 I/O 序列化所瓶颈。
io_uring 的设计与双 WAL 方法完全契合。每个 WAL 都有自己的环形缓冲区,避免了 I/O 争用。操作可以批量提交,减少系统调用的开销。最终,完成队列提供了精准的信息,告诉你哪些
操作完成了,结果如何,这使得恢复逻辑更复杂、也更健壮。
本文首发公众号 猩猩程序员 欢迎关注