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 位置,校验失败则认为整块盘数据有问题,直接走恢复流程。