RocksDB 崩溃恢复正确性验证:用 db_stress 检测“丢失的缓冲写入“造成的恢复空洞
2026/9/19 22:25:25 网站建设 项目流程

RocksDB 崩溃恢复正确性验证:用 db_stress 检测"丢失的缓冲写入"造成的恢复空洞

【免费下载链接】rocksdbA library that provides an embeddable, persistent key-value store for fast storage.项目地址: https://gitcode.com/gh_mirrors/ro/rocksdb

本文围绕 RocksDB 官方在 2022 年 10 月发布的博客《Verifying crash-recovery with lost buffered writes》,系统讲解 RocksDB 如何为"崩溃后恢复数据出现空洞(hole)"这一正确性隐患设计端到端测试方案:包括四种典型崩溃场景的界定、基于"轨迹(trace)重放"的测试预言机(test oracle)扩展,以及用TestFSWritableFile在进程内模拟系统崩溃的巧妙手法。读完本文,你将理解 RocksDB 崩溃恢复的缓冲语义、为什么"恢复结果是写入历史的前缀"是必须保证的性质,以及如何从源码层面验证db_stressdb_crashtest.py这套测试框架的具体实现。

写入路径上的多层缓冲与两种崩溃类型

RocksDB 的每一次写入在真正持久化之前,会经过多个可能引入缓冲的层级。这些缓冲层会延迟数据的持久化时机,而根据缓冲所在位置的不同,崩溃时会丢失的数据范围也截然不同:

  • 进程崩溃(process crash):只会丢失保存在进程内存中的缓冲写入;
  • 系统崩溃(system crash):除了进程内存,还会丢失保存在**操作系统内存(OS page cache)**中的写入,因为系统掉电后 page cache 内容同样无法保留。

一个写入需要经过的典型持久化路径包括:进程内存中的 memtable、WAL 写入缓冲、WAL 文件(写入 OS page cache)、以及最终落盘。是否调用Sync/Fsync决定了数据是否到达可抗系统崩溃的持久化点。

新引入的测试覆盖要验证的核心不变量是:在两类崩溃下,恢复出来的数据都不能存在"空洞"。所谓空洞,指的是"某一条恢复出来的写入,比某一条丢失的写入更新",即恢复数据在时间线上出现断层:

有效(无空洞)的恢复:所有恢复的写入(1 和 2)都早于所有丢失的写入(3 和 4)

无效(有空洞)的恢复:一条恢复出的写入(4)比一条丢失的写入(3)更新

这个保证对很多应用至关重要,例如以恢复出的最新写入作为复制起点的场景:如果复制起点处存在空洞,从节点可能永远错过某些写操作,或者以错误的顺序应用数据。

需要说明的是,这套测试覆盖对使用场景做了明确假设:

  • 所有写入使用相同的缓冲/持久化相关选项(例如不覆盖"WAL 交替开启/关闭"的场景,即不覆盖WriteOptions::disableWAL交替取值的情况);
  • 崩溃本身不会带来意外后果(例如不会损坏已持久化的数据)。

为什么验证"无空洞"很困难:多种合法恢复结果

测试"恢复无空洞"的难点在于:合法的恢复结果不止一种。由于缓冲层可能丢失任意一部分写入,恢复后 DB 中既可能出现最新的值,也可能出现较早的值,只要满足"没有空洞"即可——即恢复出的写操作必须是写入历史上连续的一段前缀。

传统方案是"逐 key 校验最新值",但它在丢失缓冲写入的场景下会失效:恢复出旧值是被容忍甚至预期的行为。因此需要一种能校验"恢复结果 = 写入历史前缀"的机制。

RocksDB 的解法是:追踪(trace)所有写入操作,然后验证恢复结果恰好匹配写入轨迹的一个前缀。只要恢复出的写操作数量为 M(由 DB 自身的 sequence number 决定),并且这 M 个写操作与轨迹中的前 M 个操作完全一致,就能证明恢复无空洞。

覆盖的四种崩溃场景

测试覆盖从以下四种新场景开始,它们已被纳入 RocksDB 内部 CI,周期性对 main 分支最新提交运行。四种场景分别组合了不同的缓冲/持久化选项与崩溃类型:

#场景涉及选项会丢失的写入范围
1WAL 关闭 + 进程崩溃WriteOptions::disableWAL=1自上次 memtable flush 以来的写入
2WAL 开启 + 系统崩溃WriteOptions::disableWAL=0,配合WriteOptions::sync=1SyncWAL()FlushWAL(true /* sync */)自上次 memtable flush 或 WAL sync 以来的写入
3手动 WAL flush + 进程崩溃DBOptions::manual_wal_flush=1自上次 memtable flush 或手动FlushWAL()以来的写入
4手动 WAL flush + 系统崩溃DBOptions::manual_wal_flush=1自上次 memtable flush 或已 sync 的手动 flush(FlushWAL(true),或FlushWAL(false)之后再做 WAL sync)以来的写入

这些选项在 db_stress 的 gflags 定义 中均有对应入口:

  • DEFINE_bool(disable_wal, false, "If true, do not write WAL for write.")(见 db_stress_gflags.cc 第 799 行):对应场景 1/2;
  • manual_wal_flush_one_in(见 db_stress_gflags.cc 第 97 行):以概率触发手动 WAL flush 路径,且"设为大于 0 即隐含Options::manual_wal_flush = true",对应场景 3/4;
  • expected_values_dir(见 db_stress_gflags.cc 第 740 行):指定保存历史期望值(.state/.trace 文件)的目录,是场景 3/4 等"历史校验"能力的基础。

新覆盖发现并修复的真实问题

这套覆盖上线后立刻发挥了价值,暴露了三个此前未被发现的缺陷(均为 RocksDB 官方 Pull Request,编号与标题如下):

  1. PR #10185track_and_verify_wals_in_manifest与 WAL sync 之间存在竞态,导致系统崩溃后误报数据损坏(false detection of corruption);
  2. PR #10560:WAL sync 过程中的竞态导致系统崩溃后出现未被检测到的恢复空洞(undetected hole in recovery);
  3. PR #10573:关键元数据文件缺少目录 sync(directory sync),导致系统崩溃后恢复失败(recovery failure)。

其中第二个问题尤其值得警惕——它说明"空洞"缺陷是真实存在的,而不仅仅是理论风险,正因如此,"恢复必须是轨迹前缀"这类结构性验证才必不可少。

方案总览:db_stress 压力程序 + db_crashtest.py 崩溃注入脚本

正确性测试框架由两部分组成:

  • db_stress压力测试程序:见 db_stress_tool/db_stress_tool.cc,负责操作 DB 并维护测试预言机(oracle);
  • db_crashtest.py包装脚本:见 tools/db_crashtest.py,负责管理多个db_stress实例——启动它们、注入崩溃。

工作流程如下:

  1. 启动时,db_stress依据测试预言机校验 DB,跳过上次崩溃时仍有 pending 写入的 key;
  2. 随后db_stress对 DB 施加随机读写压力,并持续更新测试预言机。

这里的预言机就是"Latest values file"(最新值文件)——正如其名,它只记录每个 key 的最新值。正因如此,这套基础设置无法验证涉及丢失缓冲写入的恢复场景:当允许恢复出旧值时,逐 key 的最新值比对就不再适用。

关键扩展一:verifiedSeqno 快照与轨迹重放

为了让预言机支持"恢复结果必须是无空洞前缀"的校验,RocksDB 将预言机扩展为新增两个文件:

  • <verifiedSeqno>.state:在 sequence number 为verifiedSeqno时的期望值文件快照。verifiedSeqno是上一次成功校验时的 DB sequence number;
  • <verifiedSeqno>.trace:该 sequence number 之后所有操作的轨迹文件。

在 expected_state.cc 中可以看到这些文件约定的直接实现(见 第 231–237 行):

const std::string FileExpectedStateManager::kStateFilenameSuffix = ".state"; const std::string FileExpectedStateManager::kTraceFilenameSuffix = ".trace"; const std::string FileExpectedStateManager::kPersistedSeqnoBasename = "PERSIST";

恢复路径:由 DB 学习 M,由文件系统学习 N,重放 M−N 个操作

当上一个db_stress实例可能丢失了缓冲写入时,当前实例必须在启动校验前重建最新值文件。这里定义两个关键序列号:

  • M:当前db_stress实例的恢复 sequence number,从 DB 自身学习(即db->GetLatestSequenceNumber());
  • N:上一个db_stress实例的恢复 sequence number,从文件系统学习——通过解析"*.{trace,state}"文件名获得。

之后,"LATEST.state"(最新值文件)即可通过在 N.state 之上重放 N.trace 中前 M−N 个追踪到的操作来重建。由于 M 是 DB 实际恢复出的写入数,重放出的期望值与 DB 逐 key 比对一致,即可证明恢复是轨迹的前缀、无空洞。

这一逻辑在源码中对应FileExpectedStateManager::Restore()(见 expected_state.cc 第 777 行):它用db->GetLatestSequenceNumber()计算replay_write_ops = seqno - saved_seqno_,通过ExpectedStateTraceRecordHandler处理轨迹记录中的写操作(Put/Delete/DeleteRange/Merge/PutEntity 等,见 expected_state.cc 第 474 行 起的 Handler 实现),把轨迹中的写操作按序应用到期望状态上;同时它还妥善处理了若干边界情况,例如轨迹末尾因db_stress崩溃写入而损坏的记录——只要已重放出所需数量的写操作,尾部损坏可以被容忍(见 expected_state.cc 第 842–870 行)。

写入路径:保存 M.state 并从 M.trace 开始记录

反过来,当当前db_stress实例可能丢失缓冲写入时(即开启历史校验的实例运行时),它会:

  1. 把当前期望值保存为 "M.state";
  2. 开始在 "M.trace" 中记录更新的操作。

对应实现是FileExpectedStateManager::SaveAtAndAfter()(见 expected_state.cc 第 387 行)。值得注意的是其中两个工程细节:

  • 原子性:"<seqno>.state" 先写临时文件再RenameFile()原子改名,避免进程在初始化中途被杀导致文件不完整;若崩溃恰好发生在 state 创建之后、trace 创建之前,则按"trace 存在但为空"处理(见 expected_state.cc 第 400–420 行);
  • 轨迹的可靠性:轨迹写入被封装为FatalExpectedStateTraceWriter——一旦轨迹写失败,进程直接std::_Exit(1)终止,绝不继续运行导致历史分叉(见 expected_state.cc 第 353–383 行)。同时轨迹过滤掉 Get/MultiGet/IteratorSeek 等读操作,只保留写操作(见 expected_state.cc 第 435–444 行)。

这套"历史恢复"机制在db_stress的共享状态中也有体现:db_stress_shared_state.h提供SetPersistedSeqno()记录已持久化的序列号(见 db_stress_shared_state.h 第 448 行),并由 db_stress_listener.h 中的监听器在每次写提交时同步(见 db_stress_listener.h 第 75 行)。

关键扩展二:TestFSWritableFile 在进程内模拟系统崩溃

直接触发真实系统崩溃在运维上极其困难。RocksDB 的解法是在进程内模拟:让本应写入 OS page cache 的未同步数据滞留在进程内存中,从而"进程崩溃"即可等价于"系统崩溃"。

实现方式是引入TestFSWritableFile(见 utilities/fault_injection_fs.h 第 245 行),其核心行为与文档描述完全一致:

  • Append():把写入缓冲在本地std::string(即 fault_injection_fs.h 第 228 行 的buffer_)中,而不是调用write()落盘;
  • Sync():才把本地std::string的内容转交给PosixWritableFile::Append(),后者真正write()到 OS page cache。

这样一来,现有的db_stress进程崩溃机制(直接 kill 进程)天然会丢失所有未 sync 的写入,恰好模拟出系统崩溃中"page cache 数据丢失"的效果。该文件在注释中明确说明了设计意图:"数据先缓存在 buffer 中,只有调用 Sync 时才持久化,可模拟未被 Sync 保护的文件数据(或整个文件)的丢失"(见 fault_injection_fs.h 第 11–13 行)。

尚未覆盖的保证与后续工作

作者在"Next steps"中坦诚指出了该方案目前的边界:

  1. 用户显式 flush 的写入必须全部恢复:一个尚未被测试覆盖的保证是——RocksDB 会恢复所有用户显式从崩溃丢失的缓冲中 flush 出去的写入。由于内部也可能触发缓冲 flush,实际恢复出的写入可能多于这个下界,但绝不应少于。为此,预言机需要进一步扩展,追踪"预期能在崩溃中存活的 sequence number 下界";
  2. 更真实的系统崩溃模拟:当前模拟只丢弃未同步的普通文件数据,尚未覆盖未同步的目录项(directory entries)。而目录项丢失恰恰是上面 PR #10573(关键元数据文件缺目录 sync 导致恢复失败)所涉及的问题类型,将其纳入模拟是后续的重要方向。

致谢与参考实现

该功能的落地离不开多位贡献者:Hui Xiao 增加了手动 WAL flush 覆盖,并让方案兼容TransactionDB;Zhichao Cao 实现了系统崩溃模拟;多位 RocksDB 团队成员贡献了该特性所依赖的基础设施。

感兴趣的读者可以继续在仓库中探索以下实现与测试文件:

  • 测试预言机核心:db_stress_tool/expected_state.cc(.state/.trace 文件的创建、保存与重放逻辑)与 db_stress_tool/expected_state.h;
  • 压力测试入口与选项:db_stress_tool/db_stress_tool.cc、db_stress_tool/db_stress_gflags.cc、db_stress_tool/db_stress_test_base.cc;
  • 崩溃注入脚本:tools/db_crashtest.py;
  • 系统崩溃模拟:utilities/fault_injection_fs.h、utilities/fault_injection_fs.cc。

【免费下载链接】rocksdbA library that provides an embeddable, persistent key-value store for fast storage.项目地址: https://gitcode.com/gh_mirrors/ro/rocksdb

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询