从本地存储引擎中移除 fsync

Hacker News Top 工具

摘要

FractalBits 推出了一种专为单节点设计的 KV 存储引擎,通过在硬件层级直接管理数据持久性来消除 fsync 调用,从而在 NVMe SSD 上实现显著提升的写入吞吐量。

暂无内容
查看原文 导出为 Word 导出为 PDF
查看缓存全文

缓存时间: 2026/05/09 15:36

# 从我们的本地存储引擎中移除 fsync | FractalBits 博客 来源: https://fractalbits.com/blog/remove-fsync/ 大多数存储引擎在持久化写入路径的某个位置都会调用 `fsync`。我们构建了一个范围 narrowly scoped 的单节点 KV 存储引擎,它在执行 PUT 或 DELETE 操作时并不调用 `fsync`。该设计依赖于固定大小的预分配文件、预先清零的扩展区(extents)、`O_DIRECT` 写入,以及一个提交操作与 SSD 原子写入单元对齐的日志(journal)。 这并不是对 `fsync` 的一般性否定。之所以有效,是因为我们的持久性契约(durability contract)比 POSIX 文件语义更窄,我们的部署环境仅限于 SSD,且引擎完全掌控分配、日志记录和恢复过程。在 AWS i8g.2xlarge 本地 NVMe 上进行的 4KB 随机写入基准测试中,该引擎达到了 190,985 obj/s,而 ext4 + `O_DIRECT` + `fsync` 的方案为 116,041 obj/s。 ## fsync 的代价 让我们先从对象存储和数据库当前如何处理 `fsync` 说起。MinIO 无论是单节点还是分布式部署,最终都会写入本地文件系统。每次 PUT 操作都会对数据部分和 `xl.meta` 发出 `fdatasync` 或 `fsync`,将文件刷新到设备上。RocksDB 的预写日志(WAL)默认不同步。需要崩溃一致性语义的应用程序必须显式选择启用。etcd 更为严格:每个 Raft 条目在写入磁盘时都会进行 `fsync`,etcd 论文指出这对 Raft 安全性至关重要。Postgres 在提交时对 WAL 进行 `fsync`,并使用组提交(group commit)来分摊每次提交的延迟。Kafka 是个例外。默认情况下,它在写入路径上根本不会进行 `fsync`,完全依靠复制来实现持久性。其权衡之处在于单节点数据安全性被削弱。电源丢失窗口内的数据可能会丢失,集群的复制因子成为唯一的防线。 正确使用 `fsync` 很难。Jepsen (https://jepsen.io/) 多次在分布式系统中发现了与 `fsync` 相关的数据丢失漏洞。最近的一个例子是 NATS 2.12.1 在崩溃恢复路径上丢失数据(Jepsen NATS 2.12.1 分析 (https://jepsen.io/analyses/nats-2.12.1))。 撇开正确性问题不谈,调用 `fsync` 本身也很昂贵。在 SSD 上执行一次 `fsync` 通常需要几百微秒到几毫秒。将数据从页面缓存(page cache)刷新到设备只是其中一部分。不可预测的部分在于元数据。`fsync` 不仅同步文件的数据,还同步文件依赖的所有元数据:inode、目录项、扩展区映射,一直到底层的文件系统日志提交。一个看似只触及几 KB 数据的 `fsync` 调用,可能在底层触发数量级更多的 I/O。 尾部延迟(tail latency)更难控制。除了文件系统日志刷新外,任何给定 `fsync` 调用的实际延迟还取决于同一设备上的并发 I/O、日志当前的提交进度以及 SSD 的垃圾回收(GC)活动。其中任何一项都可能使延迟高于中位数的数倍。 ## 为什么我们要构建自己的引擎 上述 `fsync` 的成本之所以令人痛苦,是因为基于文件系统的对象路径将每次持久化写入变成了文件系统事务:文件数据、inode 状态、目录项、扩展区映射和日志提交都成为了关键路径的一部分。如果我们希望在不为每次 PUT 支付该成本的情况下实现崩溃一致性的写入,就必须将预写边界从文件系统移到由我们控制的存储引擎中。 这之所以可行,是因为我们的本地引擎并非通用解决方案。我们的背景极大地缩小了设计空间: - 介质仅限于 SSD。我们不针对 HDD。 - 值的大小从几百字节到几百 KB 不等。 - 写入语义是操作原子的。写入要么完全可见,要么完全不可见。 - 接口是简单的 KV API。 放宽其中任何一项(添加 HDD 支持、POSIX 兼容性或更丰富的 KV 语义),设计就需要从头重新思考。 在这些约束条件下,现成的选项仍让我们为错误的抽象买单。直接使用带有 `fsync` 的文件系统保留了上述的元数据瓶颈。LevelDB 和 RocksDB 等 LSM 引擎在应用程序需要崩溃一致性提交时,仍然依赖 `fsync` 进行 WAL 同步,并且其压缩层增加了显著的写入放大,这对于几百 KB 的值来说并不合适。我们选择从头构建一个单节点引擎:空间分配、索引、日志记录和恢复,全部自主完成。 ## 架构概述 引擎由三个组件组成:索引、日志和数据区域。 **索引**将键映射到值的位置,并主要驻留在内存中。**日志**记录对索引和数据区域分配状态的更改。它是引擎的崩溃一致性边界。**数据区域**存储值,在引擎管理的布局策略下写入。日志和数据区域都存在于文件系统中。运行时写入通过固定预分配和预先清零来避免文件系统元数据更改,使用 `O_DIRECT` 绕过页面缓存,并依靠以下日志规则来保证崩溃一致性。 本地存储引擎架构 ## 索引:单节点 Fractal ART 索引将键映射到它们在磁盘上的值所在位置。我们并没有从零开始构建它。我们复用了现有的 Fractal ART 元数据引擎 (https://fractalbits.com/blog/metadata-engine-for-our-object-storage-from-lsm-tree-to-fractal-art/) 并针对单节点情况进行了适配,主要通过添加日志记录、检查点(checkpointing)和空间管理来实现。索引旨在保持其数据驻留内存,但不需要一次性全量加载。PUT 和 DELETE 仅修改内存中的指针,这使得操作在内存层面是原子的。持久性完全委托给日志处理。 日志区域和数据区域共享两个底层设计选择:基于 `fallocate` 的预分配和 `O_DIRECT` 写入。 **固定大小预分配。** 这两个区域在启动时通过 `fallocate` 进行预分配,大小在引擎生命周期内保持固定。这很重要,因为如果文件在写入时大小增长,inode 的大小字段必须更新,而大小变化是需要 `fsync` 才能持久化的元数据。固定大小消除了这条路径。写入永远不会分配新的扩展区,也永远不会修改文件大小。inode 保持不变。写入路径不会触发文件系统级别的元数据更改。这里有一个关于 `fallocate`(预先清零)的细节,我们稍后会讨论。 **`O_DIRECT` 写入。** 这两个区域都通过 `O_DIRECT` 使用对齐的缓冲区和偏移量进行写入,绕过页面缓存。这消除了“内核内存中的脏页”中间状态,因此引擎不需要仅仅为了将缓存页面推送到块层而调用 `fsync`。 仅靠 `O_DIRECT` 并不是通用的持久性保证。无 `fsync` 路径依赖于一个存储契约:已完成的直接写入不得停留在未受保护的易失性缓存中,且 4KB 对齐的日志写入不得在断电时发生撕裂。这就是为什么我们的部署目标是云提供商的本地 NVMe 或具有保护或非易失性缓存行为的企业级 NVMe。如果该契约不成立,正确的回退方案是拒绝无 `fsync` 模式或为日志使用基于同步的路径。 在满足该契约的 NVMe SSD 上,`O_DIRECT` 也比缓冲 I/O 具有明显更好的尾部延迟稳定性,因为它从前景写入路径中移除了页面缓存写回。 ## 日志 日志是设计中最敏感的部分。大多数存储引擎依赖 `fsync` 来使 WAL 或日志持久化,并在崩溃后重放它以恢复一致状态。我们的目标是在不调用 `fsync` 的情况下保留“写入完成即持久化”的保证。 日志仅记录对索引和空间映射(space map)的更改。它不携带值数据。值的持久化由数据区域独立处理,日志仅记录“此键当前指向哪里”。每条记录通常为几十到几百字节,但日志处理引擎吞吐量最高的写入,并具有最严格的延迟和持久性要求。 预分配和 `O_DIRECT` 已经消除了文件系统层面的不确定性。为了保证单条记录的完整性,日志还依赖一项设备能力: **4KB 原子写入。** 为了使该设计正确无误,设备/平台契约必须保证 4KB 对齐的日志提交块不会被撕裂,包括断电情况。在 NVMe 术语中,相关的限制是设备报告的原子写入单元值。提交块要么完全生效,要么根本不发生。日志提交以 4KB 对齐的方式写入,因此断电只能发生在两个提交块之间,而不能发生在块内部。 预分配、预先清零、`O_DIRECT` 和 4KB 原子日志提交共同赋予日志与部署环境中 post-`fsync` WAL 写入相同的实际崩溃一致性角色,而无需支付 `fsync` 的运行时成本。在该设备契约范围内,已确认的日志提交被视为在断电后持久存在。在该契约之外,无 `fsync` 模式是不安全的。 该设计还产生了一个有用的属性: **批量提交。** 多个并发的 puts 可以合并为对日志的单个 I/O 写入,提高 I/O 效率并降低每次 put 的平均延迟。 引擎定期获取内存中元数据索引的检查点,这使得较旧的日志块可以被回收。检查点在前景写入的同时并发运行。重启时,引擎加载最近的检查点并从那里重放日志。 恢复依赖于日志是自描述且可验证的。每个提交块携带一个检查点纪元(epoch)、单调递增的序列号、长度受限的记录负载以及校验和。重启时,引擎加载最新的有效检查点,按顺序扫描对齐的日志提交块,在校验和序列检查有效的情况下应用记录。由于每个提交块在设备契约下是原子的,恢复过程看到的要么是前一个状态,要么是下一个完整的提交。在日志提交之前写入数据区域但在重放中未引用的值,将通过空间映射扫描被回收。 ## 数据区域 数据区域存储值。在共享基础之上,它解决了另外两个问题:空间分配和值放置。 空间分配完全由引擎控制。我们维护一个单独的空间映射,记录哪些区域空闲、哪些正在使用以及每个值的位置。它独立于文件系统的扩展区树。其好处如下: - **可预测的分配成本。** 所有空间映射查询和更新都在内存中进行,从不进入文件系统元数据路径。每次 put 的分配成本基本可以忽略不计。 - **廉价的删除和复用。** 释放空间只需翻转空间映射中的位。无需文件系统级别的元数据回收,释放的区域立即可供复用。 - **灵活的放置策略。** 可以根据访问模式自由调整冷热分区和大小分层放置策略,不依赖文件系统分配器。 ## 一次完整的 PUT 将这三个组件连接起来,单次 PUT 操作如下所示: 1. 调用者调用 `put(key, value)`。 2. 引擎从空间映射中分配空间。 3. 通过 `O_DIRECT` 将值写入数据区域。 4. 一条变更记录被批量放入 4KB 对齐的日志提交块中,捕获新位置以及空间映射更新。 5. 内存中的索引(Fractal ART)更新以指向新位置。 6. PUT 返回成功。 整个流程不调用 `fsync`。原子性完全依赖于第 4 步。磁盘上已有的日志提交在重启后是可见的。未写入或验证失败的提交在恢复过程中被忽略。第 3 步在日志提交之前写入值,因此其有效性取决于第 4 步。如果断电发生在第 3 步和第 4 步之间,重启时的空间映射扫描将该区域分类为未提交,数据对外部读取者不可见。 相比之下,文件系统中的严格 PUT 需要多次 `fsync`。仅对文件的文件描述符(fd)调用 `fsync` 并不能保证文件存在。目录项是父目录的元数据,需要单独对父目录的 fd 进行 `fsync` 才能使其持久化。如果没有这一步,新创建的文件在断电后可能会消失。POSIX 的这一要求在实践中很少被遵循。我们的引擎通过预分配和固定大小规避了所有元数据类问题。PUT 路径不对任何文件或任何目录进行 `fsync`。 DELETE 在此设计中便宜得多。删除记录进入日志,索引丢弃该键,空间映射将该区域标记为可回收。数据区域从未被触及。旧值占据的空间自然会在下一次覆盖时被复用。在文件系统中,delete 则相反。`unlink` 会触及父目录项、inode 和扩展区记录,所有更改都需经过文件系统日志。即使是像 MinIO 这样在 `unlink` 后不对父目录进行 `fsync` 的实现,仍需支付元数据提交的成本。这正是该设计节省大量文件系统元数据成本的地方。 在文件系统方面有一个不太明显的二阶效应:删除会干扰并发的 puts。这两个操作竞争同一个文件系统日志,因此当删除并发进行时,put 的尾部延迟会受到影响。删除还会使空闲扩展区结构碎片化,迫使后续的 put 在空洞中搜索可用区域。在我们的引擎中,删除仅触及内存中的空间映射,put 也从同一映射中分配。它们之间没有共享的元数据 I/O 路径。 ## 关于预先清零的注意事项 `fallocate` 之后,日志和数据区域还需要一个额外的设置步骤:预先清零整个区域。 `fallocate` 仅在文件系统层将扩展区标记为“已分配”。它实际上并未写入数据。在 ext4 和 XFS 上,这被称为未写入扩展区(unwritten extent)。第一次向这些区域之一写入数据时,会触发从“未写入”到“已写入”的扩展区转换,这会更新 inode 元数据并提交到文件系统日志。换句话说,预分配的好处在第一次写入时就被消耗掉了。 我们通过在 `fallocate` 后立即在整个区域写入零来规避这一点,强制将每个扩展区转换为“已写入”状态。这是一次性的启动成本,以换取在运行时写入路径上永远不触发文件系统级别的元数据更新。如果扩展区转换泄露到运行时路径中,之前所有避免元数据 I/O 的努力都将白费,尾部延迟也将卷土重来。 ## 性能数据 我们在 4KB 随机写入工作负载下将引擎与基于文件系统的方法进行了基准测试。测试环境:AWS EC2 i8g.2xlarge 本地 NVMe SSD,4KB 文件和值,ext4 用于文件系统基线,单线程 `io_uring`,队列深度 QD=100。该基准测试特意针对本地写入路径,而非完整的分布式产品栈。 **4KB 随机写入** | 配置 | 吞吐量 (obj/s) | avg | P50 | P99 | | :--- | :--- | :--- | :--- | :--- | | ext4 + buffered + fsync | 79,123 | 1262 μs | 1229 μs | 1852 μs | | ext4 + O_DIRECT + fsync | 116,041 | 859 μs | 828 μs | 1425 μs | | Our engine | 190,985 | 413 μs | 363 μs | 1013 μs | 引擎的吞吐量是 buffered+fsync 情况的 2.4 倍,是 O_DIRECT+fsync 情况的 1.6 倍。平均延迟分别低了 3.1 倍和 2.1 倍。差距来自于完全从写入路径中移除 `fsync`,以及预分配消除了文件系统日志的元数据提交,从而收紧了尾部延迟。 关于公平性的说明:两个 ext4+fsync 配置仅对数据文件进行 `fsync`,不对父目录进行 `fsync`。POSIX 严格要求对父目录进行 `fsync` 才能使新创建文件的持久性得以保证。否则文件在断电后可能会消失...

相似文章

Bitfield

Product Hunt

Bitfield 是一款号称全球最快的数据库,读取速度 0.69 纳秒,写入速度 0.58 纳秒。

Hugging Face Hub 推出 Storage Buckets

Hugging Face Blog

Hugging Face 推出 Storage Buckets,这是 Hub 上全新的可变性类 S3 对象存储功能,通过其 Xet 后端实现高效去重,专为生产级 ML 工作流优化。

Rust 零拷贝页面:我是如何停止焦虑并爱上生命周期的

Hacker News Top

# Rust 零拷贝页面:我是如何停止焦虑并爱上生命周期的 来源:[https://redixhumayun.github.io/databases/2026/04/14/zero-copy-pages-in-rust.html](https://redixhumayun.github.io/databases/2026/04/14/zero-copy-pages-in-rust.html) *你可以在[这里](https://github.com/redixhumayun/simpledb/)找到该项目的源代码* 零拷贝是一种旨在消除内核与用户空间缓冲区之间 CPU 数据复制的技术,尤其在数据处理等高吞吐量应用中极具价值。