RocksDB Write-Ahead Log(WAL)完全解析:格式、读写路径、恢复与完整性校验
2026/9/19 11:52:47 网站建设 项目流程

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_walstrack_and_verify_wals_in_manifestrecycle_log_file_numasync_wal_precreate等选项构建不同强度的崩溃恢复与防损坏能力,并理解WriteOptions::syncmanual_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大小字段说明
04CRCcrc32c(type 字节 + payload),经Mask()掩码后存储
42Sizepayload 长度(小端,两字节上限 65535)
61Type记录类型

可回收头(11 字节)

Offset大小字段说明
04CRCcrc32c(type 字节 + log_number + payload),掩码后存储
42Sizepayload 长度
61Type记录类型(可回收变体)
74Log NumberWAL 文件代次号(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

类型说明
kZeroType0预分配填充,永不作为真实记录写入
kFullType1完整逻辑记录,单条物理记录承载
kFirstType2跨块逻辑记录的首个分片
kMiddleType3中间分片
kLastType4末个分片
kRecyclableFullType..kRecyclableLastType5-8上述类型的可回收变体
kSetCompressionType9元记录:声明 WAL 压缩算法
kUserDefinedTimestampSizeType10元记录:各 CF 的用户自定义时间戳大小
kPredecessorWALInfoType130WAL 链校验信息
kRecyclePredecessorWALInfoType131前驱 WAL 信息的可回收变体

格式上有两条重要约定:

  • 类型值 ≥ 10 时,bit 0 用于区分可回收(奇数)与非可回收(偶数)变体,如 10/11、130/131 成对出现;
  • bit 7 被置位(kRecordTypeSafeIgnoreMask = 0x80)的类型可以被不理解它的旧版本读取器安全跳过,这为格式向前兼容提供了机制;
  • kMaxRecordTypekRecyclePredecessorWALInfoType(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 - 消费元记录kSetCompressionTypekUserDefinedTimestampSizeTypekPredecessorWALInfoType(及其可回收变体)均在读取器内部处理,不会返回给上层调用者。其中kUserDefinedTimestampSizeType记录各列族的用户自定义时间戳大小,同一日志文件内不允许对同一列族重复记录或记录零值。

格式规则:一条逻辑记录必然是kFullkFirst [kMiddle...] kLast的形态;分片乱序即视为损坏。

读取端的错误处理与WALRecoveryMode(定义于 include/rocksdb/options.h,DBOptions::wal_recovery_mode,默认kPointInTimeRecovery)深度耦合:

恢复模式语义
kTolerateCorruptedTailRecords0容忍日志尾部损坏(含预分配的零字节);只要Append()持久化即保证已应用更新不回滚
kAbsoluteConsistency1干净关闭下不应有任何损坏,适合单测与强一致场景
kPointInTimeRecovery2遇到不一致即停止回放,恢复到一致的时间点(默认)
kSkipAnyCorruptedRecords3灾后恢复,忽略损坏尽量抢救数据

在 db/log_reader.cc 中可以看到,kBadHeader、EOF 处的半截记录、kBadRecordLen等在kAbsoluteConsistency/kPointInTimeRecovery模式下会报告损坏,而在其他模式下可被忽略;kOldRecord在非kSkipAnyCorruptedRecords模式下按 EOF 处理。相关行为在 db/corruption_test.cc 中有大量测试覆盖,例如kTolerateCorruptedTailRecordskPointInTimeRecovery的差异测试。

WAL 文件生命周期

一个 WAL 文件经历以下阶段(相关代码位于 db/db_impl/db_impl.h 的SwitchMemtable()等路径):

  1. 创建(Created):当前 WAL 非空时,SwitchMemtable()触发新 WAL 文件的创建,文件号通过VersionSet::NewFileNumber()分配。
  2. 写入(Written)AddRecord()以分片记录形式持续追加WriteBatch数据。
  3. 同步(Synced):当WriteOptions::sync为 true,或显式调用FlushWAL(true)时,调用fsync()落盘。
  4. 废弃(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_保存该大小,未同步或空文件的大小记为kUnknownWalSizeuint64_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::syncmanual_wal_flush_行为
自动同步(Auto-sync)truefalse每个写组之后执行 fsync
自动冲刷(Auto-flush)falsefalse冲刷到 OS 页缓存,不做 fsync
手动冲刷(Manual flush)falsetrue由应用显式调用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_precreaterecycle_log_file_num > 0时会被禁用。

小结与配置建议

WAL 是 RocksDB 写入路径上承上启下的关键组件:块化存储与分片重组解决大记录与定长块之间的矛盾;双层校验(传统头 CRC / 可回收头 log number + CRC)保障记录级完整性;track_and_verify_wals_in_manifesttrack_and_verify_wals分别从 MANIFEST 元数据和文件内嵌前驱信息两个维度对抗不同类别的文件级损坏;async_wal_precreaterecycle_log_file_num则分别优化切换延迟与文件分配开销。

实际部署时的典型组合:

  • 默认配置wal_recovery_mode = kPointInTimeRecoveryWriteOptions::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),仅供参考

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

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

立即咨询