RocksDB Write-Ahead Log(WAL)完全解析:格式、读写路径、恢复与完整性校验
【免费下载链接】rocksdbA library that provides an embeddable, persistent key-value store for fast storage.项目地址: https://gitcode.com/gh_mirrors/ro/rocksdb
导读
本文以 RocksDB 的 Write-Ahead Log(预写日志,WAL)为核心,系统讲解其从磁盘块格式、物理记录头、分片与重组、压缩元记录,到文件生命周期、异步预创建、MANIFEST 追踪、前驱链校验与回收机制的完整实现。文章既包含可落地的选项配置与同步模式选型,也结合当前仓库源码(db/log_format.h、db/log_writer.cc、db/log_reader.cc、db/wal_edit.h 等)给出底层原理佐证。读完本文,你将掌握 WAL 的磁盘布局、如何通过track_and_verify_wals、track_and_verify_wals_in_manifest、recycle_log_file_num、async_wal_precreate等选项构建不同强度的崩溃恢复与防损坏能力,并理解WriteOptions::sync、manual_wal_flush与持久性、延迟之间的权衡。
WAL 的设计目的与核心不变式
WAL 是 RocksDB 实现崩溃恢复的基石。其核心职责是:在数据写入 memtable 之前,先把对应的WriteBatch持久化到磁盘上的 WAL 文件中。这样,当进程崩溃或机器掉电后,任何尚未 flush 到 SST 文件的写入,都可以在恢复阶段通过重放 WAL 记录重建 memtable 状态。
对应的源码注释在 db/log_format.h 中说明了读写双方共享的日志格式约定。整个机制遵循一条关键不变式:
WAL 必须先于 memtable 写入。
这条不变式保证了:如果崩溃发生在 memtable 写入之后、下一次 flush 之前,WAL 中必然包含所有尚未 flush 的写入,可完整重放。一旦 memtable 被成功 flush 为 SST 文件,对应 WAL 中的数据才允许被删除或回收。
块结构:固定 32KB 的逻辑块
WAL 文件在逻辑上被划分为固定大小的块(block),每个块大小为32 KB。该常量定义于 db/log_format.h:
constexpr unsigned int kBlockSize = 32768;每个块由一个或多个物理记录(physical record)组成。一条逻辑记录(即一条WriteBatch数据)如果无法放入单个块中剩余的空间,就会被**分片(fragment)**跨多个块存放。
写入端在发现块剩余空间不足以容纳下一条记录的头部时,会用零字节填充当前块的尾部(trailer),然后推进到下一个块边界继续写入。这一逻辑体现在 db/log_writer.cc 的Writer::AddRecord()中:
const int64_t leftover = kBlockSize - block_offset_; if (leftover < header_size_) { // Switch to a new block if (leftover > 0) { // Fill the trailer (literal below relies on kHeaderSize and // kRecyclableHeaderSize being <= 11) s = dest_->Append(opts, Slice("\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", static_cast<size_t>(leftover)), 0 /* crc32c_checksum */); ... } block_offset_ = 0; }读取端也以 32KB 为粒度通过backing_store_缓冲读取,log::Reader的构造器在 db/log_reader.cc 中分配了new char[kBlockSize]的读缓冲。
记录头格式:7 字节传统头与 11 字节可回收头
每条物理记录都以一个定长头部开始,RocksDB 支持两种头部格式,常量定义同样位于 db/log_format.h:
// Header is checksum (4 bytes), length (2 bytes), type (1 byte) constexpr int kHeaderSize = 4 + 2 + 1; // Recyclable header is checksum (4 bytes), length (2 bytes), type (1 byte), // log number (4 bytes). constexpr int kRecyclableHeaderSize = 4 + 2 + 1 + 4;传统头(7 字节)
| Offset | 大小 | 字段 | 说明 |
|---|---|---|---|
| 0 | 4 | CRC | crc32c(type 字节 + payload),经Mask()掩码后存储 |
| 4 | 2 | Size | payload 长度(小端,两字节上限 65535) |
| 6 | 1 | Type | 记录类型 |
可回收头(11 字节)
| Offset | 大小 | 字段 | 说明 |
|---|---|---|---|
| 0 | 4 | CRC | crc32c(type 字节 + log_number + payload),掩码后存储 |
| 4 | 2 | Size | payload 长度 |
| 6 | 1 | Type | 记录类型(可回收变体) |
| 7 | 4 | Log Number | WAL 文件代次号(file incarnation number) |
可回收头中的 log number 用于在recycle_log_file_num > 0(见 include/rocksdb/options.h 的DBOptions)时检测来自上一代文件的陈旧数据。由于 log number 只编码了 64 位值中的低 32 位,代码注释明确说明:只有在回收了约 40 亿个日志文件后才会漏检旧记录,这在实践中几乎不可能发生,且即使发生,也远比 32 位 CRC 误报的概率低。
记录类型表
记录类型在 db/log_format.h 中定义为enum RecordType : uint8_t:
| 类型 | 值 | 说明 |
|---|---|---|
kZeroType | 0 | 预分配填充,永不作为真实记录写入 |
kFullType | 1 | 完整逻辑记录,单条物理记录承载 |
kFirstType | 2 | 跨块逻辑记录的首个分片 |
kMiddleType | 3 | 中间分片 |
kLastType | 4 | 末个分片 |
kRecyclableFullType..kRecyclableLastType | 5-8 | 上述类型的可回收变体 |
kSetCompressionType | 9 | 元记录:声明 WAL 压缩算法 |
kUserDefinedTimestampSizeType | 10 | 元记录:各 CF 的用户自定义时间戳大小 |
kPredecessorWALInfoType | 130 | WAL 链校验信息 |
kRecyclePredecessorWALInfoType | 131 | 前驱 WAL 信息的可回收变体 |
格式上有两条重要约定:
- 类型值 ≥ 10 时,bit 0 用于区分可回收(奇数)与非可回收(偶数)变体,如 10/11、130/131 成对出现;
- bit 7 被置位(
kRecordTypeSafeIgnoreMask = 0x80)的类型可以被不理解它的旧版本读取器安全跳过,这为格式向前兼容提供了机制; kMaxRecordType为kRecyclePredecessorWALInfoType(131),Writer构造时据此预计算type_crc_[kMaxRecordType+1]数组。
读取端对未知类型的处理印证了这一约定:在 db/log_reader.cc 的Reader::ReadRecord()中,只有(record_type & kRecordTypeSafeIgnoreMask) == 0的未知类型才会报告损坏,否则直接忽略并继续。
写路径:Writer::AddRecord()的分片与 CRC 优化
log::Writer::AddRecord()(实现于 db/log_writer.cc)负责把一条逻辑记录写入 WAL。结合源码,写路径可归纳为以下步骤:
Step 1 - 压缩(可选):若启用了 WAL 压缩(DBOptions::wal_compression,当前仅支持 ZSTD),先通过流式压缩器StreamingCompress压缩 payload。压缩元记录kSetCompressionType必须是 WAL 文件的首条记录(AddCompressionTypeRecord()中断言block_offset_ == 0),写入成功后才会初始化压缩器;若写入失败则自动回退为kNoCompression。
Step 2 - 分片:将 payload 按kBlockSize - header_size的可用空间切分成片段。注意实现细节:即使 slice 为空,也会迭代一次以发出一条零长度记录。
Step 3 - 逐片发出:对每个分片确定类型(kFull/kFirst/kMiddle/kLast,回收模式下使用可回收变体),调用EmitPhysicalRecord()写出头部 + payload。
Step 4 - 补零与换块:若下一个头部无法放入当前块剩余空间,则零填充 trailer 并推进到下一块。
CRC 优化:Writer构造时预计算type_crc_[i] = crc32c::Value(&t, 1)(即每个类型字节的 CRC),避免每次写入都重新计算类型字节的 CRC。EmitPhysicalRecord()中实际计算方式为:
uint32_t crc = type_crc_[t]; ... uint32_t payload_crc = crc32c::Value(ptr, n); crc = crc32c::Crc32cCombine(crc, payload_crc, n); crc = crc32c::Mask(crc); // Adjust for storage EncodeFixed32(buf, crc);对于可回收记录,还会把 log number 的 4 个字节折叠进 CRC(crc = crc32c::Extend(crc, buf + 7, 4))。Mask()的作用是避免 CRC 值中出现与头部其他字段混淆的字节模式,同时保证其不依赖平台字节序。
读路径:Reader::ReadRecord()的校验、重组与元记录消费
log::Reader::ReadRecord()(实现于 db/log_reader.cc)是恢复时的核心入口,其流程如下:
Step 1 - 读取物理记录:调用ReadPhysicalRecord()从 32KB 的backing_store_缓冲区读取;缓冲区不足时通过ReadMore()续读,块内剩余不足头部大小时返回kBadHeader。
Step 2 - 校验:若checksum_开启(恢复时默认开启),先Unmask头部中的期望 CRC,再对header + 6起的length + header_size - 6字节重新计算crc32c并比对,不匹配返回kBadRecordChecksum。对于可回收记录,额外校验头部中的 log number 是否与当前期望的log_number_一致,不一致则返回kOldRecord(视为上一代文件残留,默认按 EOF 处理)。此外,若一个非回收文件的首条记录就是可回收类型,会返回kBadRecord("A recycled log should have started with a recycled record")。
Step 3 - 分片重组:在ReadRecord()的状态机中:
kFull:立即返回完整记录;kFirst:开始向scratch累积;kMiddle:追加到scratch;kLast:追加并返回完整记录;- 出现乱序分片(如
kMiddle/kLast前没有kFirst,或碎片未以kLast结束)则报告损坏。
Step 4 - 解压(可选):若读取到kSetCompressionType元记录,通过InitCompression()初始化流式解压器;此后普通记录在ReadPhysicalRecord()中解压,并用 XXH3 校验解压前后的内容一致性。
Step 5 - 消费元记录:kSetCompressionType、kUserDefinedTimestampSizeType、kPredecessorWALInfoType(及其可回收变体)均在读取器内部处理,不会返回给上层调用者。其中kUserDefinedTimestampSizeType记录各列族的用户自定义时间戳大小,同一日志文件内不允许对同一列族重复记录或记录零值。
格式规则:一条逻辑记录必然是kFull或kFirst [kMiddle...] kLast的形态;分片乱序即视为损坏。
读取端的错误处理与WALRecoveryMode(定义于 include/rocksdb/options.h,DBOptions::wal_recovery_mode,默认kPointInTimeRecovery)深度耦合:
| 恢复模式 | 值 | 语义 |
|---|---|---|
kTolerateCorruptedTailRecords | 0 | 容忍日志尾部损坏(含预分配的零字节);只要Append()持久化即保证已应用更新不回滚 |
kAbsoluteConsistency | 1 | 干净关闭下不应有任何损坏,适合单测与强一致场景 |
kPointInTimeRecovery | 2 | 遇到不一致即停止回放,恢复到一致的时间点(默认) |
kSkipAnyCorruptedRecords | 3 | 灾后恢复,忽略损坏尽量抢救数据 |
在 db/log_reader.cc 中可以看到,kBadHeader、EOF 处的半截记录、kBadRecordLen等在kAbsoluteConsistency/kPointInTimeRecovery模式下会报告损坏,而在其他模式下可被忽略;kOldRecord在非kSkipAnyCorruptedRecords模式下按 EOF 处理。相关行为在 db/corruption_test.cc 中有大量测试覆盖,例如kTolerateCorruptedTailRecords与kPointInTimeRecovery的差异测试。
WAL 文件生命周期
一个 WAL 文件经历以下阶段(相关代码位于 db/db_impl/db_impl.h 的SwitchMemtable()等路径):
- 创建(Created):当前 WAL 非空时,
SwitchMemtable()触发新 WAL 文件的创建,文件号通过VersionSet::NewFileNumber()分配。 - 写入(Written):
AddRecord()以分片记录形式持续追加WriteBatch数据。 - 同步(Synced):当
WriteOptions::sync为 true,或显式调用FlushWAL(true)时,调用fsync()落盘。 - 废弃(Obsolete):当引用该 WAL 的所有 memtable 均完成 flush 后,WAL 变为废弃,随后被回收(
recycle_log_file_num > 0)或删除。
异步 WAL 预创建(async_wal_precreate)
当DBOptions::async_wal_precreate开启时,RocksDB 会在后台预创建至多一个未来的 WAL 文件(EXPERIMENTAL 选项,默认 false,定义于 include/rocksdb/options.h)。机制要点:
- 前台先保留一个文件号,再调度后台任务;后台任务打开文件与 writer,但刻意只保留为空存储:
- 不写入压缩元数据;
- 不写入前驱 WAL 信息;
- 不把该文件加入
logs_、alive_wal_files_或 MANIFEST 的 WAL 追踪。
- 该文件只有在前台
SwitchMemtable()消费它时才成为逻辑 WAL:此时写入正常 WAL 元数据、冲刷上一个 WAL writer 的缓冲,并以与同步创建完全相同的顺序安装新 WAL。 - 若前台切换发生时后台任务仍在运行,writer 会等待保留的文件号,而不是分配一个更大的文件号,从而避免"更新的 WAL 已上线后,较晚完成的低文件号 WAL 才出现"的乱序问题。
- 若后台预创建失败,后台任务仅记录失败日志,前台切换回退到正常的同步创建流程。
安全性与限制:未被消费的预创建 WAL 是零记录的"未来文件",因此跨崩溃与干净关闭都是安全的——恢复按 log number 处理 WAL,把观察到的每个 WAL 号都标记为已用,遇到空的未来 WAL 视为 EOF;干净关闭时释放未发布的 writer 而无需删除空文件。该选项在recycle_log_file_num > 0时会被sanitize 为 false(二者互斥)。
MANIFEST 中的 WAL 追踪(track_and_verify_wals_in_manifest)
WalSet(见 db/wal_edit.h,由VersionSet持有)负责在 MANIFEST 中管理 WAL 元数据:
WalAddition记录:当某个已关闭/非活跃 WAL 的同步大小(synced size)确定时写入,WalMetadata通过synced_size_bytes_保存该大小,未同步或空文件的大小记为kUnknownWalSize(uint64_t最大值);WalDeletion记录:WAL 在 flush 后变废弃时写入,语义为"删除 log number 小于指定值的所有 WAL";- 活跃 WAL 的同步(经
DB::SyncWAL()或WriteOptions::sync)有意不写入 MANIFEST——这是出于性能/效率的取舍,见 include/rocksdb/options.h 中该选项的注释。
该追踪由DBOptions::track_and_verify_wals_in_manifest(默认 false)开启,恢复时通过校验"已同步的已关闭 WAL 存在且大小符合预期"来发现丢失或截断的 WAL。WalSet::CheckWals()(见 db/wal_edit.h)负责此校验,logs_on_disk参数中可能包含已废弃但尚未从磁盘删除的日志。注意:系统级属性允许最多一个 WAL 的同步大小未知(即当前打开的那个 WAL),不过WalSet本身并不强制该约束。该选项不与 secondary 实例兼容。
WAL 链校验(track_and_verify_wals)
另一个独立选项DBOptions::track_and_verify_wals(默认 false,标记为 EXPERIMENTAL,注释称其旨在成为track_and_verify_wals_in_manifest的更好替代品)采用完全不同的完整性机制:每个新 WAL 文件通过内嵌的kPredecessorWALInfoType记录(回收文件用kRecyclePredecessorWALInfoType)保存其前驱 WAL 的信息:
- 前驱的 log number、文件大小、最后记录的 sequence number;
- 恢复时逐条验证这些记录,检测缺失或截断的 WAL 文件;
- 提供针对"文件系统级损坏删除/重命名 WAL 文件"的防御能力。
验证实现在 db/log_reader.cc 的Reader::MaybeVerifyPredecessorWALInfo():当发现前驱 log number 缺失(>= min_wal_number_to_keep_却未观察到)、或记录的 log number / 最后 seqno / 文件大小与观察值不一致时,报告损坏。写入侧由 db/log_writer.cc 的Writer::MaybeAddPredecessorWALInfo()完成。
两种机制相互独立、可分别开启:track_and_verify_wals_in_manifest在 MANIFEST 中追踪 WAL 元数据,track_and_verify_wals把前驱信息内嵌进 WAL 文件本身,捕获的故障类别不同。
同步模式与持久性权衡
DBOptions::manual_wal_flush(默认 false)与WriteOptions::sync(每次写操作时设置)组合出三种同步模式:
| 模式 | WriteOptions::sync | manual_wal_flush_ | 行为 |
|---|---|---|---|
| 自动同步(Auto-sync) | true | false | 每个写组之后执行 fsync |
| 自动冲刷(Auto-flush) | false | false | 冲刷到 OS 页缓存,不做 fsync |
| 手动冲刷(Manual flush) | false | true | 由应用显式调用FlushWAL() |
自动同步提供最强的持久性保证(写组提交后数据已落盘),但每次写组的 fsync 带来最高延迟;自动冲刷只保证数据进入操作系统缓冲,性能最好但掉电时可能丢失;手动冲刷把 fsync 的时机完全交给应用(例如攒批后统一FlushWAL(true)),兼顾吞吐与可控性,适合对延迟敏感的批量场景。
从源码看,manual_wal_flush_为 true 时Writer::AddRecord()写完后不自动Flush(),而是由上层显式触发。DB::FlushWAL(bool sync)的声明位于 db/db_impl/db_impl.h。WAL 压缩选项DBOptions::wal_compression(默认kNoCompression,仅 ZSTD)同样位于 include/rocksdb/options.h。
WAL 回收(recycle_log_file_num)
当DBOptions::recycle_log_file_num > 0(默认 0)时,废弃的 WAL 文件不再删除,而是保留在回收池(wal_recycle_files_)中;新建 WAL 时通过重命名复用回收文件,从而避免文件系统分配的开销——代码注释指出复用文件的块已分配好,且 fdatasync 无需在每次写入后更新 inode。可回收记录头中携带的 log number 正是用来区分当前数据与上一代残留的陈旧数据。
限制:WAL 回收通常与disableWAL不兼容,因为回收文件中的损坏检测依赖连续的 sequence number。但一个例外是two_write_queues && disable_memtable下使用的仅 WAL 内部路径(如 2PC prepare),该场景不受此限制。另外如前所述,async_wal_precreate在recycle_log_file_num > 0时会被禁用。
小结与配置建议
WAL 是 RocksDB 写入路径上承上启下的关键组件:块化存储与分片重组解决大记录与定长块之间的矛盾;双层校验(传统头 CRC / 可回收头 log number + CRC)保障记录级完整性;track_and_verify_wals_in_manifest与track_and_verify_wals分别从 MANIFEST 元数据和文件内嵌前驱信息两个维度对抗不同类别的文件级损坏;async_wal_precreate与recycle_log_file_num则分别优化切换延迟与文件分配开销。
实际部署时的典型组合:
- 默认配置:
wal_recovery_mode = kPointInTimeRecovery,WriteOptions::sync = false,依赖 OS 页缓存与 flush 机制,兼顾性能与常规崩溃恢复; - 强持久性:
WriteOptions::sync = true或应用侧周期性FlushWAL(true);关键场景可开启track_and_verify_wals(或track_and_verify_wals_in_manifest)在恢复时主动发现缺失/截断的 WAL; - 高吞吐写入:考虑
manual_wal_flush = true攒批冲刷、recycle_log_file_num > 0复用文件、wal_compression = kZSTD压缩日志体积; - 降低切换延迟:在
recycle_log_file_num == 0的前提下开启async_wal_precreate,让新 WAL 的创建绕开前台路径。
所有选项的默认值、语义与限制均可在 include/rocksdb/options.h 的DBOptions中查阅,上述机制在 db/corruption_test.cc 等测试中均有对应行为验证,可作为进一步研究 WAL 容错行为的起点。
【免费下载链接】rocksdbA library that provides an embeddable, persistent key-value store for fast storage.项目地址: https://gitcode.com/gh_mirrors/ro/rocksdb
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考