RocksDB db_stress 崩溃恢复验证:Expected-State Trace 前缀恢复机制深度解析
2026/9/20 2:50:06 网站建设 项目流程

RocksDB db_stress 崩溃恢复验证:Expected-State Trace 前缀恢复机制深度解析

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

导读

本文基于 docs/components/stress_test/expected_state_trace.md 展开,深入剖析 RocksDBdb_stress在模拟未同步数据丢失(unsynced data loss)场景下,如何通过"期望状态(expected-state)快照 + 写轨迹(trace)回放"的侧车(sidecar)oracle 机制,验证崩溃恢复满足"无洞(no hole)"前缀语义。读完本文,你将掌握--expected_values_dir--sync_fault_injection--disable_wal--manual_wal_flush_one_in等参数背后的完整生命周期、LATEST.state/<N>.state/<N>.trace各文件的作用与不变量、SaveAtAndAfter()/Restore()的精确算法,以及如何用trace_analyzer离线检查崩溃轨迹。核心实现位于 db_stress_tool/expected_state.cc、db_stress_tool/expected_state.h 与 db_stress_tool/db_stress_driver.cc。

背景:LATEST.state作为 oracle 的局限

LATEST.statedb_stress常规验证所依赖的"预言机"(oracle):它为每个逻辑 key 保存最新期望值,验证阶段将数据库中的实际值与其比对。这在"恢复必须精确保留最新状态"的前提下是足够的。

但在以下测试模式下,允许丢失部分缓冲写入,恢复后数据库可以回到最近写入的某个较早前缀:

  • --sync_fault_injection:注入未同步数据丢失故障;
  • --disable_wal:完全禁用 WAL;
  • --manual_wal_flush_one_in > 0:随机显式调用FlushWAL(),缓冲的 WAL 可能在崩溃时丢失。

上述模式下,恢复结果只要求满足"无洞"属性:

  • 恢复出的写入必须是崩溃前所有写入的前缀
  • 不允许恢复出较新写入的同时丢失较旧的写入。

单一的LATEST.state快照无法刻画这种"任意前缀"语义,因此需要一套"基线快照 + 写轨迹回放"的机制。FileExpectedStateManagerSaveAtAndAfter()在已知 DB 序列号N处对 oracle 拍照,并开始追踪后续写入;恢复时根据恢复后的 DB 序列号M,回放轨迹中前M - N个写操作来重建 oracle。源码中的类注释对这一契约有精确描述(见 db_stress_tool/expected_state.h 中SaveAtAndAfter()Restore()的 API 文档)。

该路径何时激活

历史状态追踪仅在db_stress使用文件型期望状态管理器时存在,即--expected_values_dir非空

轨迹追踪(tracing)启动需同时满足三个条件:

  1. 压测模式跟踪期望状态(IsStateTracked()返回 true);
  2. --expected_values_dir非空;
  3. MightHaveUnsyncedDataLoss()返回 true。

从源码看,MightHaveUnsyncedDataLoss()(定义于 db_stress_tool/db_stress_test_base.h)当前的判定为:

  • FLAGS_sync_fault_injection为 true,
  • FLAGS_disable_wal为 true,
  • FLAGS_manual_wal_flush_one_in > 0

这比--expected_values_dir的 flag 帮助文本描述的范围更广——帮助文本仍写着"历史值仅在设置--sync_fault_injection时被追踪"(见 db_stress_tool/db_stress_gflags.cc),实际代码已扩展到三类场景。

另外需要注意IsStateTracked()的变体差异:默认的NoBatchedOpsStressTest返回 true,而BatchedOpsStressTestCfConsistencyStressTestMultiOpsTxnsStressTest均返回 false(见 db_stress_tool/no_batched_ops_stress.cc、db_stress_tool/batched_ops_stress.cc、db_stress_tool/cf_consistency_stress.cc 与 db_stress_tool/multi_ops_txns_stress.h),因此这些专用变体不会启用历史轨迹。

高层生命周期

单个db_stress进程的完整流程如下:

  1. 打开 DB(InitDb);
  2. 若存在历史快照/轨迹,在启动验证之前先把LATEST.state恢复到与 DB 恢复后的序列号一致(FinishInitDb内调用Restore);
  3. 基于重建后的LATEST.state运行验证;
  4. 在 DB 当前序列号处保存新的历史基线,并开始追踪新的写入(TrackExpectedStateSaveAtAndAfter);
  5. 运行压测操作;
  6. 崩溃或直接重开(不显式关闭 trace)。

db_stress_driver.cc中几个关键的顺序约定(见 db_stress_tool/db_stress_driver.cc):

  • FinishInitDb()在新一轮的 tracing 启动之前执行;
  • TrackExpectedState()在启动验证之后执行,避免验证期间的大量Get()/MultiGet()与 DB 全局 trace 互斥锁竞争(源码注释明确说明这一点);
  • 模拟数据丢失的故障注入设置在TrackExpectedState()之后才启用(fault_fs->SetInjectUnsyncedDataLoss(...))。

该顺序保证:在压测开始产生"可能丢失"的 DB 写入之前,侧车 oracle 文件已经就绪。

目录内的文件与不变量

文件型管理器FileExpectedStateManager--expected_values_dir目录内使用以下文件(文件名常量定义见 db_stress_tool/expected_state.cc):

文件含义
LATEST.state当前期望值 oracle,用于常规验证(mmap 映射的std::atomic<uint32_t>数组)
PERSIST.seqno独立的持久化序列号 oracle 元数据
<N>.state在 DB 序列号N处的期望值历史快照
<N>.trace序列号N之后发生写入的轨迹
.<name>.tmp用于原子替换的临时文件

同一时刻只关心一代历史

  • saved_seqno_*.state文件中(LATEST.state除外)的最大序列号;
  • 更旧的*.state*.trace文件被视为过期并清理。

Open()还会修复一种特定的"部分保存"场景(见 db_stress_tool/expected_state.cc 中FileExpectedStateManager::Open()):

  • <N>.state存在而<N>.trace不存在,则创建一个空的<N>.trace

这模拟的语义是:崩溃发生在基线快照已创建、但 tracing 尚未真正开始之后。注释也说明,LATEST.statePERSIST.seqno的初始化也采用"临时文件 + rename"的方式,避免初始化中途被杀留下残缺文件。

为什么 oracle 文件位于故障注入路径之外

期望状态快照与轨迹均通过Env::Default()写入,不走 DB 的故障注入文件系统包装器。这是有意设计:这些文件属于测试 oracle 的一部分,而不是被验证的数据库状态。如果它们与 DB 文件一样遭受模拟数据丢失,那么在"最需要 oracle 可靠"的时刻 oracle 反而不可靠。

此外,SaveAtAndAfter()对 trace 文件禁用了WritableFileWriter缓冲(soptions.writable_file_max_buffer_size = 0),从而去除用户态缓冲,避免进程被杀时轨迹数据滞留于应用缓冲区。代码中还用一个FatalExpectedStateTraceWriter包装器包裹 trace writer:一旦写轨迹失败,立即向 stderr 打印错误并std::_Exit(1)——因为期望状态轨迹是崩溃恢复验证的一部分,不是"尽力而为"的可观测性,绝不允许历史发生偏离。

保存 / 启动轨迹路径(SaveAtAndAfter)

StressTest::TrackExpectedState()调用SharedState::SaveAtAndAfter(),后者分发到FileExpectedStateManager::SaveAtAndAfter(DB*)(见 db_stress_tool/expected_state.cc)。保存路径依次执行:

  1. 读取 DB 序列号N = db->GetLatestSequenceNumber()
  2. LATEST.state复制到临时文件;
  3. 将临时文件重命名为<N>.state
  4. 创建空的<N>.trace
  5. 在 DB 上启动 RocksDB tracing,写入<N>.trace
  6. 若存在旧的<old>.state<old>.trace,将其删除。

状态快照通过"临时文件 + rename"原子创建;trace 文件直接创建,因为空 trace 本身就具有期望的含义(崩溃发生在快照之后、tracing 之前等价于空 trace)。

TraceOptions 是关键

  • 过滤读操作:设置kTraceFilterGet | kTraceFilterMultiGet | kTraceFilterIteratorSeek | kTraceFilterIteratorSeekForPrev
  • 写操作仍然被追踪;
  • preserve_write_order = true

这里的 "filter" 位是排除位:设置这些位意味着"不追踪这些读操作"。preserve_write_order = true是必需的,因为恢复依赖前缀语义——回放前M - N个被追踪的写操作,要求轨迹顺序必须与 DB/WAL 的应用顺序一致;否则轨迹中可能包含正确写入但顺序错乱,前缀回放就会出错。

轨迹覆盖契约(Trace Coverage Contract)

对期望状态恢复而言,轨迹必须满足如下性质:

  • 任何可能出现在恢复后 DB 序列/WAL 状态中的写入,必须已存在于轨迹中且保持相同的前缀顺序;
  • 额外的写记录只要出现在恢复前缀之外,就是可接受的。

等价表述:

  • 缺失轨迹记录是致命的(fatal);
  • 多余的后缀轨迹记录可容忍(tolerated)。

这直接源于Restore()消费轨迹的方式:回放长度取自db->GetLatestSequenceNumber(),而非轨迹元数据或显式提交确认;随后从轨迹中回放恰好这么多逻辑写操作。

由此推出一个重要结论:更晚的轨迹点可能严格劣于更早的轨迹点。若崩溃发生在 WAL/序列状态可恢复之后、但侧车轨迹文件尚未写入该记录之前,则Restore()会"欠回放"(under-replay),验证失败。相反,更早的轨迹点可能留下对"未能幸存于恢复"的写入的尾部记录——只要这些记录保持在恢复序列号所隐含前缀之后即可接受。

一句话总结:db_stress需要的是保持前缀的超集(prefix-preserving superset)的可恢复写入,而非"在轨迹点已知完整完成的写入的精确集合"。

生产者与消费者的关系

该路径的语义由轨迹生产者与期望状态消费者共同定义:

  1. 通用生产者 API:生产者使用通用的StartTrace()/Tracer/ReplayerAPI,但本路径中活跃的消费者是FileExpectedStateManager::Restore(),而非通用查询回放。
  2. 回放进度来自 DB 序列空间Restore()并非"回放到轨迹声称提交为止",而是回放db->GetLatestSequenceNumber() - saved_seqno_个逻辑写操作。
  3. 侧车轨迹文件<N>.trace通过Env::Default()写入,故意位于故障注入 DB 路径之外;WAL 持久性与轨迹持久性之间不存在原子耦合。
  4. 有序前缀语义:对本路径而言,preserve_write_order意味着恢复的轨迹前缀必须与 DB/WAL 应用顺序一致。它本身并不决定轨迹包含的是"已完成写入的精确集合"还是"可恢复写入的超集"——这个要求来自Restore()如何解释轨迹。

<N>.trace里到底有什么

<N>.trace是 RocksDB 通用二进制查询轨迹文件,由Tracer产生。在本db_stress路径中它包含:

  • 一条kTraceBegin头部记录(含轨迹 magic 与版本元数据);
  • 零条或多条kTraceWrite记录;
  • 可选的一条kTraceEnd尾部记录。

由于读轨迹类型已被过滤,实际载荷是"头 + 写批次"。每条kTraceWrite记录存储:时间戳、轨迹类型、载荷 map,以及原始WriteBatch::Data()字节。时间戳由通用 tracing 库记录,但期望状态恢复路径完全不用时间——它只把Replayer::Prepare()Replayer::Next()当作轨迹流的解析器使用。

为什么截断或无 footer 的轨迹是常态

db_stress在正常的崩溃/重开循环中不会显式调用DB::EndTrace()。这意味着:

  • 轨迹常常没有kTraceEndfooter;
  • 若进程在写轨迹中途死亡,最后一条记录可能只写入了一部分。

这不是偶然,而是恢复逻辑有意容忍的场景。通用TraceReader在 EOF 处返回Status::Incomplete(),通用回放栈已将其识别为"未调用EndTrace()就杀掉进程"引发的状况。FileExpectedStateManager::Restore()增加了期望状态特有的规则:只有在已恢复足够多的写入之后,EOF 或尾部损坏才可接受

  • 若在回放足量写入之前遇到 EOF,则恢复失败;
  • 若在回放足量写入之后遇到 EOF,则恢复成功;
  • 若在回放足量写入之后遇到尾部记录损坏,恢复同样成功。

源码中对应逻辑为:tolerated_tail_corruption = s.IsCorruption() && handler_done,且s.IsIncomplete()一律视为正常终止(见 db_stress_tool/expected_state.cc 的Restore()回放循环)。这是"轨迹只需好到恢复 DB 序列号为止"这一核心结论的落地。

恢复路径(Restore)

下一轮运行时,FinishInitDb()检查shared->HasHistory();若存在历史,则在常规验证之前、以及共享状态被挂载到 compaction filter factory 之前,调用shared->Restore(db_)(见 db_stress_tool/db_stress_test_base.cc)。

Restore(DB*)的步骤(见 db_stress_tool/expected_state.cc):

  1. 读取恢复后的 DB 序列号M = db->GetLatestSequenceNumber()
  2. 要求M >= saved_seqno_,否则 DB 回滚到比最旧可恢复基线更早的位置,恢复失败(返回Status::Corruption("DB is older than any restorable expected state"));
  3. 计算replay_write_ops = M - saved_seqno_
  4. <saved_seqno_>.state复制为临时LATEST.state
  5. 打开<saved_seqno_>.trace
  6. 构建默认Replayer,调用Prepare()并反复调用Next()解码轨迹记录;
  7. 将每条解码出的TraceRecord交给自定义 handler,更新临时期望状态文件;
  8. 一旦恰好应用了replay_write_ops个逻辑写操作,恢复即拥有足够信息,对 EOF 或尾部损坏转为容忍;
  9. 将临时LATEST.state原子 rename 到位;
  10. 删除<saved_seqno_>.state
  11. 删除早于<saved_seqno_>.trace的旧轨迹,但保留刚回放过的轨迹本身以便调试;
  12. 清除saved_seqno_

一个重要的细节:默认Replayer并不用于对 DB 执行被追踪的操作,只用于解析头部与记录格式。Restore()通过Next()取出TraceRecord,然后自行调用record->Accept(custom_handler, &result)。注意若replay_write_ops == 0(DB 恰好恢复到saved_seqno_),则跳过打开与回放轨迹,直接走状态提升与清理流程。

回放如何更新 oracle

ExpectedStateTraceRecordHandler同时实现两个接口(见 db_stress_tool/expected_state.cc):

  • TraceRecord::Handler
  • WriteBatch::Handler

通用轨迹层向它提供解码后的TraceRecord;对写记录,它从追踪字节构造WriteBatch并迭代批次,让 handler 逐个处理批次条目。读轨迹类型被忽略——实践中它们不应出现(因为 trace options 已过滤),但 handler 仍对其容忍。

Key 解码

handler 不在期望状态 oracle 中存储原始 RocksDB key,而是把追踪的用户 key 映射回db_stress的逻辑整数 key:

  1. 去掉追踪 key 上的用户时间戳后缀(StripTimestampFromUserKey,配合FLAGS_user_timestamp_size);
  2. GetIntVal()解析剩余用户 key;
  3. 用得到的逻辑 key ID 修改期望状态数组。

这也是调试日志关注两类问题的原因:解析失败、原始 key 到逻辑 key 的往返不匹配(roundtrip 检查会比较追踪原始 key 与Key(parsed_id))。

每操作语义

handler 只回放 oracle 所需的逻辑效果:

  • PutCF/TimedPutCF:解析逻辑 key,从追踪值字节读取value_base,调用ExpectedState::SyncPut()
  • PutEntityCF:反序列化宽列实体(WideColumnSerialization::DeserializeSimple),校验列一致性,取默认宽列值得到value_base,调用SyncPut()
  • DeleteCF:解析逻辑 key,调用SyncDelete()
  • SingleDeleteCF:在非预备事务(prepared transaction)外按DeleteCF回放;在预备事务内则缓冲原始 single-delete 形式直到提交;
  • DeleteRangeCF:解析 begin/end 逻辑 key,调用SyncDeleteRange(begin, end);即使它影响多个逻辑 key,也只计为一次回放的写操作;
  • MergeCF:按PutCF回放——这与db_stress的 merge 算子一致:其合并值由最新操作数推导,而非更复杂的累积规则;
  • PutBlobIndexCF:blob 直写追踪记录的是转换后的BlobIndex而非原始用户值字节,因此 handler 将其视为"对该逻辑 key 的又一次 put",基于现有期望值推导下一个value_basestate_->Get(...).NextValueBase())。

预备事务(Prepared Transactions)

预备事务需要额外处理,因为轨迹中可能包含 prepare 与 commit 标记,而非立即应用的写入。handler 按事务 ID 在内存中缓冲预备写入:

  • MarkBeginPrepare()开始向临时WriteBatch缓冲;
  • 缓冲期间遇到的写条目追加到该批次;
  • MarkEndPrepare(xid)将缓冲批次存入 map;
  • MarkCommit(xid)将存储的批次重新喂给同一 handler 回放;
  • MarkRollback(xid)丢弃存储批次而不应用。

这样期望状态 oracle 反映的是提交语义,而非 prepare 时的可见性。

为什么回放按"写操作数"计数而非轨迹记录数

轨迹流由kTraceWrite记录构成,但每条记录包含一个完整的WriteBatch,而一个批次可包含多条独立写入条目。因此Restore()使用 handler 已应用的写操作数(而非读取的轨迹记录数)来计量回放进度,目标计数为:

db->GetLatestSequenceNumber() - saved_seqno_

在某个被追踪的WriteBatch内部,handler 的Continue()方法在应用足量写操作后停止批次迭代;外层恢复循环仍持续读取轨迹记录,直到Next()返回 EOF、footer 或损坏,届时恢复逻辑判定已消费的轨迹前缀是否足够。

离线轨迹检查(trace_analyzer)

<N>.trace使用 RocksDB 通用二进制查询轨迹格式,因此可直接用现成的离线打印工具trace_analyzer(源码位于 tools/trace_analyzer.cc)转储为可读文本。在添加任何期望状态专属调试日志之前,先用它把轨迹导出,这是人类与 Agent 检查回放输入的最快途径。

构建:

make -j128 trace_analyzer

先创建输出目录再运行:

mkdir -p /tmp/trace_dump ./trace_analyzer \ -trace_path=/path/to/<N>.trace \ -output_dir=/tmp/trace_dump \ -output_prefix=<N> \ -convert_to_human_readable_trace \ -try_process_corrupted_trace \ -no_print

这会写出/tmp/trace_dump/<N>-human_readable_trace.txt。行格式为:

  • 普通记录:<hex_key> type_id cf_id value_size timestamp_us
  • 范围删除:<begin_hex> <end_hex> type_id cf_id 0 timestamp_us

实用 flag:

  • -no_key:省略十六进制 key 列以减小输出体积;
  • -try_process_corrupted_trace:对db_stress崩溃轨迹强烈推荐,因为其尾部记录可能合法地截断或损坏。

两个重要注意事项:

  • trace_analyzer要求-output_dir必须已存在;
  • 期望状态回放只需要轨迹的写前缀,但文件格式本身是通用 RocksDB 轨迹格式,而非期望状态专属格式。

编码在文件删除顺序中的崩溃安全规则

代码中有多处刻意安排的删除顺序:

  • 成功保存新基线后,删除旧历史文件的顺序无关紧要——新的一对文件已经建立,旧文件不会再被使用(即使崩溃);
  • 恢复成功后,先删除旧的<N>.state再删除旧轨迹:若先删轨迹再崩溃,将没有任何办法回放到序列号N(见Restore()中"must delete the state file first"的注释)。

Clean()(见 db_stress_tool/expected_state.cc)还会清理:

  • Open()SaveAtAndAfter()中断遗留的过期临时文件;
  • 早于saved_seqno_的过期历史 state 文件;
  • 早于saved_seqno_的过期轨迹文件。

最小工作示例

假设上一轮在序列号100处保存了基线:

  • 100.state包含序列号 100 处的 oracle 快照;
  • 100.trace包含序列号 100 之后的写入。

随后进程又发出 10 个写操作后崩溃。恢复后的 DB 最新序列号为107

下一轮启动时:

  1. Restore()100.state复制为临时LATEST.state
  2. 读取100.trace
  3. 将前107 - 100 = 7个回放的写操作应用到临时 oracle;
  4. 忽略这 7 个操作之后的任何尾部——即使轨迹无 footer 结束,或下一条记录被截断;
  5. 将重建后的临时文件 rename 为LATEST.state

重建后的 oracle 与恢复后的 DB 一致,启动验证即可检查逻辑"洞"是否存在(即验证不存在"较新写入幸存而较旧写入丢失"的情况)。

总结

期望状态轨迹逻辑是一个前缀恢复 oracle

  • SaveAtAndAfter()在序列号N处对 oracle 拍照,并启动"仅写、保序"的轨迹(过滤读、preserve_write_order = true、禁用轨迹文件用户态缓冲);
  • Restore()获知恢复后的序列号M,将前M - N个被追踪写操作回放到快照上,重建LATEST.state
  • 只要M所需前缀完好,截断或无 footer 的轨迹均可接受;
  • 因此轨迹必须是"可能幸存于恢复的写入的有序超集"——"精确过滤已成功写入"并非正确的目标不变量。

正是这套机制让db_stress能够在允许丢失未同步写入(--sync_fault_injection--disable_wal--manual_wal_flush_one_in)的前提下,验证恢复结果满足"无洞"性质,而不是要求精确保留最新的未同步写入。所有关键实现均可在 db_stress_tool/expected_state.cc 与 db_stress_tool/db_stress_driver.cc 中逐行核对。

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

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

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

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

立即咨询