RocksDB Sync Write on HDD 性能优化

2025.11.11
SF-Zhou

1. 背景

业务需求,需要在 HDD 上使用 RocksDB 并且每一笔写入都需要确保落盘,即写入返回成功后立即断电,重新加载也能完整恢复数据。每次写入实际上只有 100B 左右,需要尽可能地降低延迟。发现在 HDD 上 RocksDB 的 sync write 性能非常差,平均在 30ms 以上。而业务上又很难将多笔写入合并为大的 write batch 一次落盘,故有了本文中阐述的方案。

2. 分析

RocksDB 的 sync write 性能差,核心原因是使用 buffer IO 模式写完 WAL 文件后,fdatasync 的性能太差了。如果替换成 direct IO,应该可以规避 fdatasync。跟 AI 简单聊了两句,发现 RocksDB 上 SST 文件已经支持了 direct IO 读写了,讲道理让 WAL 支持 direct IO 应该不会太困难。

此时我又在忙别的活,就让 Copilot 帮我写一版,PR 在这里。Copilot 改的非常简单,第一眼我看着以为是瞎改。跟它掰扯了几下,又看了下代码,发现它做的是对的。一来 RocksDB 本身支持使用 direct IO 写文件,会在内存里维护最后一部分非对齐的内容,每次追加时会覆盖写最后的 512B,padding 部分会填充 0;二来 WAL 是支持跳过全 0 的记录的,意外断电恢复也就不是事了。

简单过了下单元测试,就开始将这个修改集成当我们的系统中,意外地发现性能非常好。启用 direct IO + sync,单次写入平均延迟降低到 4ms,直接变可用了!

本来以为这样就足够了,后面仔细阅读代码发现启用 direct IO 时,Sync 会直接跳过 fdatasync 操作。尝试改代码强制执行 fdatasync,性能直接回退到之前的状态。不得已还是得考虑完全去掉 fdatasync 的方案。

假定我们使用的企业级硬盘包含 PLDP 能力,即在 direct IO 写完成后,数据部分不会丢失,那么剩下的只需要保证文件元数据不丢失,即文件长度信息和 extent 状态信息。考虑到业务场景,一个巧妙的方法在 WAL 文件末尾预分配一块空间,写入全 0,并且使用 fdatasync 将元数据集中落盘;后续所有在这个区间的 write batch 写入都直接通过 direct IO 完成,因为文件长度和 extent 状态信息都不会发生变化,只要写下去了就可以认为它不会丢失了。预分配的过程可以在后台异步完成,不阻塞正常 write batch 写入。并且在 direct IO 模式下实现对应的 Sync 函数逻辑:即等待预分配的 fdatasync 确认落盘的长度大于 WAL 逻辑数据的长度即可。

使用该思路实现一版代码。中间还发现 RocksDB 中的一些 bug,给官方提了 PR 一并提交了:https://github.com/facebook/rocksdb/pull/14116

我也跑了一下 RocksDB 自带的 db_bench,能看到新的 direct IO + sync 远超 buffer IO + sync:

# disable use_direct_io_for_wal.
./db_bench --benchmarks=fillseq --db=/storage/data1/rocksdb --num=10000 --value_size=128 --sync=true --use_direct_io_for_wal=false
#> fillseq      :   25555.892 micros/op 39 ops/sec 255.559 seconds 10000 operations;    0.0 MB/s

# enable use_direct_io_for_wal.
./db_bench --benchmarks=fillseq --db=/storage/data1/rocksdb --num=10000 --value_size=128 --sync=true --use_direct_io_for_wal=true
#> fillseq      :     147.164 micros/op 6795 ops/sec 1.472 seconds 10000 operations;    0.9 MB/s

3. 后续

如评论区里提到的,我们在机器上做了测试,只有在预分配的情况下 direct IO 写才不会丢数据。direct IO 分配的 extent 信息以及 extent 是否 written 的元数据都依赖 fdatasync 才能确保落盘。因为提前补 0 + fdatasync,才保证了后续的 direct IO 写操作不会带来新的元数据更新。

那么类似于 3FS 中 chunk engine 的场景,可以直接使用这个优化吗?答案是否定的。chunk engine 中使用 fallocate 预先分配一批 chunk 的空间,而后使用 direct IO 写 chunk data 数据,最后写 RocksDB 时默认开启 sync,在 XFS 系统上后者可以顺带将前者产生的元信息修改一起落盘。如果使用文中的优化策略,那么写 chunk data 产生的 extent 状态信息就没有 fdatasync 来确保它落盘了。简单的策略是在分配一批空间时填充全 0 + fdatasync,但那样会导致写吞吐的显著下降。

还有一种策略是,放弃使用文件系统,至少是局部地放弃文件系统。RocksDB 仍然可以放在文件系统上,chunk data 数据可以直接使用裸的 block device,使用纯粹的 direct IO 读写,就不需要在意文件 extent 带来的额外开销了。但如果不希望这么激进,那么还剩一条邪道可以走:

  1. 分配一批 chunk 空间时使用 fallocate,并且使用 fdatasync 确保分配的 extent 元数据落盘,该操作非常低频并且是后台任务;
  2. 使用 direct IO 写 chunk data,并且不使用 fdatasync 落盘 extent 元数据 written 状态的修改;
  3. 写 RocksDB 时顺便记录一个额外的 prefix,表明 chunk data 被修改,并且 extent 元数据可能没有落盘的情况;
  4. 定期调用 syncfs(fd)(对 XFS 来说,fsync(fd) 就足够了),调用成功后就可以清理掉一批上述 prefix 的 chunk,因为此时可以确保 extent 元数据已经落盘了;
  5. 加载 chunk engine 时检查是否存在上述 prefix 的 chunk。如果有,那么就获取 chunk data 对应位置的 extent 信息,检查 chunk 长度内是否有 unwritten 状态的 extent,如果有则直接读取 block device 对应位置中的数据,并且与 chunk meta 中的 crc32c 对比,校验成功则写回原 chunk 位置,校验失败则认为整块盘数据有问题,直接走恢复流程。

4. 尾声

为了在 3FS 完整的应用上述 Direct IO 写优化,又要维持存量数据的兼容性,最终我决定走一下邪道。

  1. 首先为了保证完整的数据安全性,chunk engine 内部引入了分段 checksum 机制,按照 64KiB 大小计算 CRC32C。后续所有的读操作也按照 64KiB 对齐,这样所有的读操作都可以校验 CRC32C 是否与写入时一致了,起码保证了读操作能返回靠谱数据,不用担心这个优化把烂数据返回给用户了;
  2. 然后我又用 AI 帮我写了几个 crate 来实现后续的功能,包括:
    1. blkpath,用来查询某个文件所在的 block device 路径;
    2. blkmap,用来查询某个文件某个 range 对应的 extent 物理地址信息;
    3. blkreader,综合上述两个 crate,直接在 block device 上读取某个文件某个 range 的数据,包括 unwritten 状态的 extent。
  3. 最后综合一下上面的结果,chunk engine 本身所做的改动并不大。写入的时候顺便记录 dirty chunk id,定期 syncfs(fd) 清理 dirty chunk id,启动的时候检查 dirty chunk id。等完整上线了我再更新一下。