【LevelDB 源码阅读】06 | WAL:日志的物理格式

上一篇结尾留下了几个问题:log_->AddRecord 把 93 字节交出去之后,字节去了哪?一条 record 被切成 32KB 的 block 分片时发生了什么?写一半断电,恢复时的 CRC 检查和尾部截断,是怎么把"半个 batch"安全地丢掉的?以及 03 篇遗留的一个更早的问题:sync=false 时"进程被杀不丢数据、机器掉电可能丢数据",这条边界的物理细节如何。

这一篇的任务就是把这一批债还清。我们仍旧考虑短链服务:写入洪峰里机器掉电,磁盘上的 log 文件停在一个任意的字节位置------可能是一条 record 的正中间,可能是 7 字节头刚写了 3 个字节,也可能什么都没少。重启之后,LevelDB 靠什么决定"恢复到哪一条"?

掉电前写到一半的日志,重启后怎么处理?------LevelDB 的答案是:让每条 record 自带 checksum、长度和类型,恢复时完整的重放、残缺的丢弃,把"恢复到哪"从一个难题变成一次线性扫描。


一条 record 长什么样

格式的定义集中在两个地方。db/log_format.h ------这是一个很短的文件,拢共 35 行,包含了 writer 和 reader 共享的全部常量(log_format.h:14-30):

cpp 复制代码
enum RecordType {
  // Zero is reserved for preallocated files
  kZeroType = 0,

  kFullType = 1,

  // For fragments
  kFirstType = 2,
  kMiddleType = 3,
  kLastType = 4
};
static const int kMaxRecordType = kLastType;

static const int kBlockSize = 32768;

// Header is checksum (4 bytes), length (2 bytes), type (1 byte).
static const int kHeaderSize = 4 + 2 + 1;

配套的 doc/log_format.md 中还有一段描述,清楚地讲解了整个文件的格式:

plain 复制代码
block := record* trailer?
record :=
  checksum: uint32     // crc32c of type and data[] ; little-endian
  length: uint16       // little-endian
  type: uint8          // One of FULL, FIRST, MIDDLE, LAST
  data: uint8[length]

log 文件是一串 32KB 的 block(文件尾部允许最后一个 block 不满),每个 block 里是一条条连续的 record,每条 record 是 7 字节头 + 载荷:

  • checksum 4 字节(uint32)
  • length 2 字节(uint16)
  • type 1 字节(uint8)

checksum 和 length 两者均为小端。type 说的是这条物理 record 与它所属的逻辑 record 的关系------一个逻辑 record 如果塞得进剩余空间就是 FULL;塞不下就被切成若干片,第一片 FIRST、中间各片 MIDDLE、最后一片 LAST。

把 record 的字节布局画出来(数字为字节偏移):

看完了规则,我们拿一条真实的 record 来实际观察一下。用 05 篇的三合一 batch 原样重做一遍实验:新建库,写入那个 93 字节的 batch(sync=false),然后 kill -9。log 文件恰好 100 字节------7 字节头加 93 字节载荷,hexdump 出来(附录 crash_batch.cc 的 three 子命令):

plain 复制代码
00000000  26 73 05 f1 5d 00 01 01  00 00 00 00 00 00 00 03  |&s..]...........|
00000010  00 00 00 01 07 63 3a 41  62 33 78 4b 23 68 74 74  |.....c:Ab3xK#htt|
00000020  70 73 3a 2f 2f 65 78 61  6d 70 6c 65 2e 63 6f 6d  |ps://example.com|
00000030  2f 61 2d 76 65 72 79 2d  6c 6f 6e 67 2d 75 72 6c  |/a-very-long-url|
00000040  01 0f 75 3a 72 6f 62 69  6e 3a 63 3a 41 62 33 78  |..u:robin:c:Ab3x|
00000050  4b 01 31 01 0d 63 3a 41  62 33 78 4b 3a 63 6c 69  |K.1..c:Ab3xK:cli|
00000060  63 6b 01 30                                       |ck.0|
00000064

我们逐段查看:

plain 复制代码
偏移    字节                含义
------  ------------------  --------------------------------
0       26 73 05 f1         checksum(小端 uint32,Mask 后的 CRC32C)
4       5d 00               length = 0x005d = 93(小端 uint16)
6       01                  type = 1 = FULL
7       01 00 ... 00 03     batch 载荷:seq=1(8B)+ count=3(4B)...
------  ------------------  --------------------------------
100                         文件恰好在 record 末尾结束

93 字节的载荷与 05 篇手工推演的 batch 布局可以对上:seq=1、count=3,三条 kTypeValue record 依次是 c:Ab3xK、u:robin:c:Ab3xK、c:Ab3xK:click。write 路径没有对 batch 做任何再加工------AddRecord(Contents(write_batch)) 收到的就是 rep_ 原文,前面加 7 字节头,一条 FULL record,就这么多。

这 100 字节也验证了一件事:一个三合一 batch 在 WAL 里就是一条 record------05 篇那条结论,提交的最小单位是 WAL 的一条 record,而一条 record 恰好就是一个完整的 batch,在字节层面长这个样子。

顺手写了个小工具 wal_inspect(附录),不经过 log::Reader 的重组逻辑,直接按 32KB block 逐条走 record 头,把每个字段的值解出来、CRC 现场重算对照:

plain 复制代码
$ .output/wal_inspect .output/wal-three/000003.log -b
[    0] block  0 #0 type=FULL len=   93 crc=OK seq=1 count=3
file size: 100 bytes

后面所有实验都会用它来给文件"拍 X 光"。

为什么切 32KB 的 block

既然追加写,一条 record 接一条 record 直接排下去不行吗?为什么要引入 block 这一层?主要有三重理由。

  1. 给"重新同步"一个确定的锚点。 日志读到坏数据时,恢复手段只有"跳过一段再试"。如果没有 block,跳过之后从哪里开始试?只能碰运气地逐字节滑动(传统 recordio 格式的 resync 启发式)。有了固定的 32KB 边界,答案变成确定的:跳到下一个 block 边界,从头扫。doc/log_format.md 末尾把这条列为相对 recordio 的第一条好处,而且------把一个 log 文件的内容当 record 嵌进另一个 log 文件里,reader 也不会出错。对顺序扫描友好这个性质后来还成了 MapReduce 类任务切分文件的依据(找到 block 边界,跳到下一个 FULL/FIRST 为止)。

  2. 把损坏的爆炸半径圈起来。 一个字节翻转,最多污染它所在的 block,下一个 block 边界就是防火墙。这个性质我们后面会设计一个位翻转实验验证一下。

  3. 控制校验的粒度。 CRC 按 record 计算、按 block 隔离,一条损坏、只需要将它所在 block 的剩余部分丢弃掉,损失可控,而不需要报废整个文件。

block 边界上还有两条规则。

  • trailer:一条 record 绝不在 block 的最后 6 字节内起头(7 字节头都放不下,起了也读不完),剩下不足 7 字节的空间必须填零,reader 跳过。
  • 零长度 FIRST :如果当前 block 恰好剩 7 字节,而下一条 record 非空,writer 必须在这 7 字节里发一条零载荷的 FIRST record 占位,把真正的数据全部挪到后面的 block。

分开看这两条规则也许不好理解,但把它们放到一起,我们可以发现它们共同维护着同一个不变量------任何 record 的头永远不会横跨 block 边界,reader 永远可以从 block 开头无歧义地顺序解析。

文件、block、record 的三层关系如下图:

读 log_reader.cc 的时候发现一个有意思的注脚:ReadRecord 的 kFullType/kFirstType 分支里各有一段"Handle bug in earlier versions of log::Writer"的兼容代码(log_reader.cc:93-117),说的是旧版 writer 曾在不该发的场合发过"块尾空 FIRST",reader 至今带着补丁。也就是说"恰好剩 7 字节"这个边角的正确处理,历史上翻过车------格式文档里那句不起眼的 Aside 是有故事的。(具体是哪个版本引入的 bug,我没有考证,这里只转述注释。)

写侧走读:AddRecord 怎么操作的

格式了解清楚了,接下来我们看看 writer 怎么生产它。Writer::AddRecord(log_writer.cc:34-80 )是一个 do-while 循环,每一轮"发射"一个物理 fragment(EmitPhysicalRecord,lol):

cpp 复制代码
Status Writer::AddRecord(const Slice& slice) {
  const char* ptr = slice.data();
  size_t left = slice.size();

  // Fragment the record if necessary and emit it.  Note that if slice
  // is empty, we still want to iterate once to emit a single
  // zero-length record
  Status s;
  bool begin = true;
  do {
    const int leftover = kBlockSize - block_offset_;
    assert(leftover >= 0);
    if (leftover < kHeaderSize) {
      // Switch to a new block
      if (leftover > 0) {
        // Fill the trailer (literal below relies on kHeaderSize being 7)
        static_assert(kHeaderSize == 7, "");
        dest_->Append(Slice("\x00\x00\x00\x00\x00\x00", leftover));
      }
      block_offset_ = 0;
    }

    // Invariant: we never leave < kHeaderSize bytes in a block.
    assert(kBlockSize - block_offset_ - kHeaderSize >= 0);

    const size_t avail = kBlockSize - block_offset_ - kHeaderSize;
    const size_t fragment_length = (left < avail) ? left : avail;

    RecordType type;
    const bool end = (left == fragment_length);
    if (begin && end) {
      type = kFullType;
    } else if (begin) {
      type = kFirstType;
    } else if (end) {
      type = kLastType;
    } else {
      type = kMiddleType;
    }

    s = EmitPhysicalRecord(type, ptr, fragment_length);
    ptr += fragment_length;
    left -= fragment_length;
    begin = false;
  } while (s.ok() && left > 0);
  return s;
}

循环体按照顺序做四件事:

  1. 当前 block 装不下一个头(leftover < 7)就填零 trailer 换新 block;
  2. 算出这个 block 还装得下多少载荷(avail,注意头本身要占 7 字节);
  3. 按"是不是第一片、是不是最后一片"的二维表格选出 type;
  4. 发射这一片,指针前移,直到载荷发完。

注意 static_assert(kHeaderSize == 7) 这一行:填 trailer 用的字符串字面量写死了 6 个零,格式头长度一变这行就得跟着改。

真正的"发射"逻辑在 EmitPhysicalRecord(log_writer.cc:82-108)中:

cpp 复制代码
Status Writer::EmitPhysicalRecord(RecordType t, const char* ptr,
                                  size_t length) {
  assert(length <= 0xffff);  // Must fit in two bytes
  assert(block_offset_ + kHeaderSize + length <= kBlockSize);

  // Format the header
  char buf[kHeaderSize];
  buf[4] = static_cast<char>(length & 0xff);
  buf[5] = static_cast<char>(length >> 8);
  buf[6] = static_cast<char>(t);

  // Compute the crc of the record type and the payload.
  uint32_t crc = crc32c::Extend(type_crc_[t], ptr, length);
  crc = crc32c::Mask(crc);  // Adjust for storage
  EncodeFixed32(buf, crc);

  // Write the header and the payload
  Status s = dest_->Append(Slice(buf, kHeaderSize));
  if (s.ok()) {
    s = dest_->Append(Slice(ptr, length));
    if (s.ok()) {
      s = dest_->Flush();
    }
  }
  block_offset_ += kHeaderSize + length;
  return s;
}

有三个细节值得我们关注。

  • 头和载荷是两次独立的 Append。 我第一反应是:为什么不拼进一个 buffer 一次写?想了想这其实是个合理的选择------头是 7 字节的栈上小 buffer,载荷直接引用调用方的内存(在 Write 路径上就是 batch 的 rep_),两次 Append 省掉一次拼装大 buffer 的拷贝。而且这两次"写"多数时候只停留在用户态:PosixWritableFile 内部有一个 64KB 缓冲(kWritableFileBufferSize,env_posix.cc:58 ),record 装得进缓冲剩余空间时,头和载荷都只是 memcpy 进去,由 EmitPhysicalRecord 末尾的 Flush() 合成一次 write() 落进页缓存(env_posix.cc:265-292 )------进程崩溃劈不开这种 record,内核还在,write() 不会写一半。真正能被劈开的是两种情况:载荷超过 64KB、装不下缓冲的大 record,头先随缓冲刷出去、载荷随后走 WriteUnbuffered 直写(env_posix.cc:285-291 );以及 record 恰好骑在缓冲边界上,Append 的"先填满、刷出、再装剩余"逻辑把同一条 record 的字节拆成两批送进页缓存。后一种概率很小,但却真实存在------kill -9 实验里我们再来看它。

  • type_crc_ 是预计算的。 构造 Writer 时 InitTypeCrc 把每种 type 那 1 个字节的 CRC 先算好存起来(log_writer.cc:16-30),每条 record 只需在此基础上增量计算载荷部分。type 只有 5 个取值,每个都高频出现,这笔预计算是纯赚。

CRC 算的是 type 字节加 payload,唯独不算 length。 为什么?稍想一下就会发现 length 没法自我保护:校验范围(type+payload 有多少字节)本身要由 length 说了算,把被测对象请进裁判席是绕不出去的死循环;退一步说,就算把 length 塞进 CRC,失败时也分不清是 length 坏了还是载荷坏了,不带来新信息。真正要操心的是读取侧------一个不受保护的 length,读它的人该怎么对待?这个悬念留给 reader 一节。

Writer 还有第二个构造函数 Writer(dest, dest_length),把 block_offset_ 初始化为 dest_length % kBlockSize(log_writer.cc:27-30 )。什么时候用?03 篇 RecoverLogFile 里"复用旧 log 接着写"的场景(db_impl.cc:480):接着一个已有内容的 log 追加,必须先对齐到它当前的 block 偏移,否则新旧 record 会在同一个 block 里踩到对方。

看一个真实的大 batch 分片。用 05 篇的 batch_bench 一次写 2000 条(单批约 76KB,必然跨 block),sync=false,进程正常退出------close 并不消费 log,消费发生在下次 open(03 篇见过),所以文件原样留在磁盘上,wal_inspect 看 log:

plain 复制代码
$ .output/wal_inspect .output/wal-big/000003.log -b
[    0] block  0 #0 type=FIRST len=32761 crc=OK seq=1 count=2000
[32768] block  1 #1 type=MIDDLE len=32761 crc=OK
[65536] block  2 #2 type=LAST len=10490 crc=OK
file size: 76033 bytes

一个逻辑 record 切成三片:block 0 装满 32761 字节(FIRST),block 1 又装满 32761 字节(MIDDLE),剩下的 10490 字节收尾(LAST)。32761 × 2 + 10490 = 76012 字节载荷,加三个 7 字节头,76033 字节,与文件大小严丝合缝。seq/count 只在第一条物理片里出现------它本来就是 batch 头的一部分,跟着载荷的第一片走。

把这次分片画成图:

四种 type 至此各就各位:装得下的是一片 FULL------前面那条 93B 的三合一 batch 就是这个形态;装不下就 FIRST 起头、MIDDLE 接力、LAST 收尾。

CRC

CRC32C 在这个格式里的职责被严格限定:只告诉你这条 record 是否损坏。坏了的处置方式只有一种------丢弃。这是与"纠删码/ECC"一类技术的分界线,日志场景选择检测 + 丢弃,因为发送端(写路径)随时可以重试或重放,接收端没有必要背负修复的复杂度。

实现上一句话带过:util/crc32c.cc 首次使用时用已知向量探测 CPU 是否支持 SSE4.2 的 CRC32C 硬件指令(port::AcceleratedCRC32C),不支持就退回查表的软件实现(crc32c.cc:265-280)。

还有一个细节:存进文件的 CRC 不是裸值,而是经过 Mask 变换的(EmitPhysicalRecord 里那句 crc = crc32c::Mask(crc))。util/crc32c.h 的注释解释了动机(crc32c.h:22-38 ):如果数据里嵌着它自己的 CRC 裸值,对这段数据再做 CRC 会产生病态的相关性("计算包含内嵌 CRC 的字符串的 CRC 是有问题的");Mask 把 CRC 循环右移 15 位再加上一个固定常数,破坏这种规律,Unmask 可逆。读侧的校验就是 Unmask(DecodeFixed32(header)) 对比重算值(log_reader.cc:245-246)。

读侧走读:重组,与三种"静默"

Reader::ReadRecord(log_reader.cc:56-174 )是一个状态机,外层循环不断调 ReadPhysicalRecord 拿物理片,按 type 归位:

cpp 复制代码
Slice fragment;
while (true) {
  const unsigned int record_type = ReadPhysicalRecord(&fragment);
  // ...
}
  • FULL 直接返回;
cpp 复制代码
case kFullType:
  if (in_fragmented_record) {
    // Handle bug in earlier versions of log::Writer where
    // it could emit an empty kFirstType record at the tail end
    // of a block followed by a kFullType or kFirstType record
    // at the beginning of the next block.
    if (!scratch->empty()) {
      ReportCorruption(scratch->size(), "partial record without end(1)");
    }
  }
  prospective_record_offset = physical_record_offset;
  scratch->clear();
  *record = fragment;
  last_record_offset_ = prospective_record_offset;
  return true;
  • FIRST 存进 scratch、标记 in_fragmented_record;
cpp 复制代码
case kFirstType:
  if (in_fragmented_record) {
    // Handle bug in earlier versions of log::Writer where
    // it could emit an empty kFirstType record at the tail end
    // of a block followed by a kFullType or kFirstType record
    // at the beginning of the next block.
    if (!scratch->empty()) {
      ReportCorruption(scratch->size(), "partial record without end(2)");
    }
  }
  prospective_record_offset = physical_record_offset;
  scratch->assign(fragment.data(), fragment.size());
  in_fragmented_record = true;
  break;
  • MIDDLE 追加;
cpp 复制代码
case kMiddleType:
  if (!in_fragmented_record) {
    ReportCorruption(fragment.size(),
                     "missing start of fragmented record(1)");
  } else {
    scratch->append(fragment.data(), fragment.size());
  }
  break;
  • LAST 追加后把 scratch 整体交出去------分片的重组就是这么朴素,一个字符串拼接到尾。
cpp 复制代码
case kLastType:
  if (!in_fragmented_record) {
    ReportCorruption(fragment.size(),
                     "missing start of fragmented record(2)");
  } else {
    scratch->append(fragment.data(), fragment.size());
    *record = Slice(*scratch);
    last_record_offset_ = prospective_record_offset;
    return true;
  }
  break;

还有两个分支是对"读不下去"的处理。仔细区分,reader 面对两种性质完全不同的异常,态度也截然不同。

第一种:文件到头了(EOF)。 "写一半"的 log 是正常产物------任何一次崩溃都会留下一个停在任意字节的文件,恢复机制必须把它当常态消化。所以截断类的 EOF 一律静默,不报告、不报错,只是安静地丢弃,忽略这一条。具体有三条代码路径,分别对应"断在哪里"的三种可能:

  • 头都没写完 。ReadPhysicalRecord 发现缓冲区不足 7 字节且已到文件尾(log_reader.cc:206-213),注释写得很清楚:这是 writer 写头写到一半崩了,当作 EOF 处理。后面 truncate 矩阵里截断在 110 字节那一行(r1 只剩 3 个字节的残头)走的就是这个分支。
cpp 复制代码
// Reader::ReadPhysicalRecord
else {
  // Note that if buffer_ is non-empty, we have a truncated header at the
  // end of the file, which can be caused by the writer crashing in the
  // middle of writing the header. Instead of considering this an error,
  // just report EOF.
  buffer_.clear();
  return kEof;
}
  • 头完整、载荷不完整 。头里声明的 length 超出文件剩余内容(log_reader.cc:222-233 ):如果不是文件尾,报 bad record length 损坏;是文件尾,静默 EOF------writer 写载荷写到一半崩了。
cpp 复制代码
// Reader::ReadPhysicalRecord
if (kHeaderSize + length > buffer_.size()) {
  size_t drop_size = buffer_.size();
  buffer_.clear();
  if (!eof_) {
    ReportCorruption(drop_size, "bad record length");
    return kBadRecord;
  }
  // If the end of the file has been reached without reading |length| bytes
  // of payload, assume the writer died in the middle of writing the record.
  // Don't report a corruption.
  return kEof;
}
  • 逻辑 record 拼了一半到头 。FIRST 甚至 MIDDLE 都读到了,文件结束(log_reader.cc:144-151),注释同样直白:writer 写完一个物理片、还没写完下一个就死了,不算损坏,整条逻辑 record 静默丢弃。
cpp 复制代码
// Reader::ReadRecord
case kEof:
  if (in_fragmented_record) {
    // This can be caused by the writer dying immediately after
    // writing a physical record but before completing the next; don't
    // treat it as a corruption, just ignore the entire logical record.
    scratch->clear();
  }
  return false;

第二种:数据坏了(corruption)。 CRC 对不上,或者中段冒出读不通的 length。这不是正常崩溃的产物,而是磁盘、传输层面的真问题,reader 会老老实实报告------但报告之后继续往下读,恢复流程不停。

其中最要紧的是 checksum mismatch 的处置(log_reader.cc:243-257):

cpp 复制代码
// Check crc
if (checksum_) {
  uint32_t expected_crc = crc32c::Unmask(DecodeFixed32(header));
  uint32_t actual_crc = crc32c::Value(header + 6, 1 + length);
  if (actual_crc != expected_crc) {
    // Drop the rest of the buffer since "length" itself may have
    // been corrupted and if we trust it, we could find some
    // fragment of a real log record that just happens to look
    // like a valid log record.
    size_t drop_size = buffer_.size();
    buffer_.clear();
    ReportCorruption(drop_size, "checksum mismatch");
    return kBadRecord;
  }
}

buffer_ 里是整个 32KB block。CRC 失败,丢的不是这一条 record,而是整个 block 的剩余部分。为什么这么做?回想上一节的悬念:length 字段不在 CRC 的覆盖范围内。CRC 失败时你无从知道是载荷坏了还是头坏了------如果 length 本身就是坏数据,顺着它走下去,可能在一堆噪声里"恰好"解出一条 CRC 也对得上的假 record(32 位 CRC 挡不住精心或碰巧的碰撞)。唯一的安全做法是退到下一个可信边界:block 开头。"坏长度不可信,那就一个字节都别信"------这是一条看似粗暴却很实用的防御性读取原则。

还有个分支:kZeroType 且长度为 0 的 record 直接跳过、不报损坏(log_reader.cc:235-241 ),注释说这是 env_posix.cc 里 mmap 预分配文件区域的产物------kZeroType 这个枚举值就是为它保留的,正常写路径永远不会产生。

cpp 复制代码
if (type == kZeroType && length == 0) {
  // Skip zero length record without reporting any drops since
  // such records are produced by the mmap based writing code in
  // env_posix.cc that preallocates file regions.
  buffer_.clear();
  return kBadRecord;
}

损伤实验:数据缺失与损坏

读完代码,我们脑子里也对整个流程有了一个初步的认识,下一步就是动动手,确认下我们的认识是否正确。

我们不可能在程序运行时去拔掉电源,看看掉电后怎么处理,只能在代码上"动动手脚",主动制造损伤------这里我的方法是:直接对 log 文件做字节级操作(截断、翻转)。写了个 wal_torn 工具(附录),先构造一个"全形态"文件:4 条逻辑 record,100, 32650, 70000, 100 字节,一个文件集齐 FULL、4 字节 trailer、FIRST/MIDDLE/LAST 跨 block 序列、再来一条 FULL:

plain 复制代码
$ .output/wal_torn make .output/torn.log
wrote 4 records: [100, 32650, 70000, 100] -> .output/torn.log
$ .output/wal_inspect .output/torn.log
[    0] block  0 #0 type=FULL len=  100 crc=OK
[  107] block  0 #1 type=FULL len=32650 crc=OK
[32764] block  0 trailer  4 bytes (all zero)
[32768] block  1 #2 type=FIRST len=32761 crc=OK
[65536] block  2 #3 type=MIDDLE len=32761 crc=OK
[98304] block  3 #4 type=LAST len= 4478 crc=OK
[102789] block  3 #5 type=FULL len=  100 crc=OK
file size: 102896 bytes

r0(100B 的 FULL)和 r1(32650B 的 FULL)都落在 block 0:107 + 7 + 32650 = 32764,块尾恰好剩 4 字节,正好展示一条真实的全零 trailer。r2(70000B)跨三个 block 被切成 FIRST/MIDDLE/LAST。r3 收尾。

给 torn.log 画张全景图(方括号内是各段字节数:record 记 头+载荷,trailer 记填零宽度;右侧是该 block 的偏移区间):

plain 复制代码
block 0   [r0 FULL 7+100B    ]   [r1 FULL 7+32650B  ]   [trailer 4B zeros]  [0, 32768)
block 1   [r2 FIRST 7+32761B ]                                              [32768, 65536)
block 2   [r2 MIDDLE 7+32761B]                                              [65536, 98304)
block 3   [r2 LAST 7+4478B   ]   [r3 FULL 7+100B    ]   (free)              [98304, 102896)

后面 truncate 和 flip 两类实验都是在这张图的不同位置动刀------r0~r3 的位置记住了,下面的矩阵就好读了。

数据缺失:truncate 矩阵

把文件截断到不同偏移,模拟"写到一半断电",每个点都先重新 make 再截断再读:

截断到 截断位置 恢复 reader 的反应
不截断 --- 4/4 无
55 r0 载荷中段 0/4 静默(载荷不完整 → EOF)
107 恰在 r0 末尾 1/4 静默(缓冲区空 → EOF)
110 r1 头部中段(剩 3 字节残头) 1/4 静默(头不完整 → EOF)
40000 r2 的 FIRST 载荷中段 2/4 静默(载荷不完整 → r2 整条丢弃)
102846 r3 载荷中段 3/4 静默(载荷不完整 → EOF)

五次截断,零报告。这正是上一节"三种静默"的实测版:无论断在头的中间、载荷的中间还是恰好齐整的 record 边界,reader 都把它当作"writer 死在半路"的正常现场,安静地恢复到此为止。

要把"reader 的反应"一列对回代码,得看 ReadPhysicalRecord 的入口(log_reader.cc:190-214,后半省略):

cpp 复制代码
// Reader::ReadPhysicalRecord 入口(log_reader.cc:190-214)
while (true) {
    if (buffer_.size() < kHeaderSize) {
      if (!eof_) {
        // Last read was a full read, so this is a trailer to skip
        buffer_.clear();
        Status status = file_->Read(kBlockSize, &buffer_, backing_store_);
        end_of_buffer_offset_ += buffer_.size();
        if (!status.ok()) {
          buffer_.clear();
          ReportDrop(kBlockSize, status);
          eof_ = true;
          return kEof;
        } else if (buffer_.size() < kBlockSize) {
          eof_ = true;
        }
        continue;
      } else {
        // Note that if buffer_ is non-empty, we have a truncated header at the
        // end of the file, which can be caused by the writer crashing in the
        // middle of writing the header. Instead of considering this an error,
        // just report EOF.
        buffer_.clear();
        return kEof;
      }
    }
    // ...

我们一行行看上面列出的表格。55、40000、102846 三行的头都解析得出来,死在后面的载荷长度检查上------头里声明的 length 超过了文件实有字节,且 eof_ 已置位,于是走上一节"头完整、载荷不完整"的静默路径(log_reader.cc:222-233 )。40000 那行还有个细节:截断的是 r2 FIRST 片的载荷,片本身就没读出来,in_fragmented_record 从未置位------上一节第三条路径里"清空 scratch"的分支(log_reader.cc:145-150 )在这张表里一次都没触发。要请它出场,得断在两片之间的缝隙里,比如恰好 65536:FIRST 片完整读到了、MIDDLE 还没影,实测同样是静默的 2/4。

再看 107 和 110 这两行------断点齐整与否,恢复条数一模一样。我起初以为这是两条分支,对着代码看了一遍才发现,它们最终都走到了同一个逻辑 return kEof:截断在 107 时,r0 刚好消费完,下一轮进来 buffer_ 已经空了,buffer_.size() < kHeaderSize 以 0 < 7 成立;截断在 110 时,buffer_ 里还攥着 r1 的 3 字节残头,同一个判断以 3 < 7 成立。差别只在离开时 buffer_ 里有没有东西可清------注释那句"if buffer_ is non-empty, we have a truncated header"说的正是后一种现场。一条分支消化两种情况,这在读代码时容易漏掉。

数据损坏:flip 实验

数据缺失部分我们模拟了"文件少了一截",位翻转模拟的是"字节坏了"------后者才会触发 CRC。翻转 r0 载荷里的一个字节(偏移 60,block 0 内部):

plain 复制代码
$ .output/wal_torn flip .output/torn2.log 60
flipped byte at 60 (0x61 -> 0x9e)
$ .output/wal_torn read .output/torn2.log
  [reporter] dropping 32768 bytes: Corruption: checksum mismatch
  recovered record #1: 70000 bytes (head: Z x 1)
  recovered record #2: 100 bytes (head: x x 1)
read done: 2/4 records recovered from 102896 bytes

r0 坏了,意料之中;但注意 r1 也阵亡了------明明 r1 自己完好无损。原因就是上一节那条"丢整个 block 剩余部分"的规则:CRC 失败后 length 不可信,r1 和 r0 同在 block 0,被连带丢弃。这是"不信 length"策略的代价,用一条无辜的 record 换取"绝不采信假 record"的保证。而 r2 虽然起点在 block 1、身体横跨三个 block,它的 FIRST 片完好,重组不受影响------爆炸半径被 block 0 的边界死死圈住。

再翻转 r2 的 MIDDLE 片里的一个字节(偏移 70000,block 2 内部):

plain 复制代码
$ .output/wal_torn flip .output/torn3.log 70000
flipped byte at 70000 (0x5a -> 0xa5)
$ .output/wal_torn read .output/torn3.log
  recovered record #1: 100 bytes (head: a x 1)
  recovered record #2: 32650 bytes (head: b x 1)
  [reporter] dropping 32768 bytes: Corruption: checksum mismatch
  [reporter] dropping 32761 bytes: Corruption: error in middle of record
  [reporter] dropping 4478 bytes: Corruption: missing start of fragmented record(2)
  recovered record #3: 100 bytes (head: x x 1)
read done: 3/4 records recovered from 102896 bytes

r2 一条逻辑 record 的死亡,被 reporter 讲成了一个三幕悲剧:checksum mismatch 丢弃整个 block 2(32768 字节)------MIDDLE 片所在的 block;此时 scratch 里还攒着它的 FIRST 片,进入 kBadRecord 分支触发 error in middle of record(32761 字节,正是 FIRST 片的长度);

cpp 复制代码
case kBadRecord:
  if (in_fragmented_record) {
    ReportCorruption(scratch->size(), "error in middle of record");
    in_fragmented_record = false;
    scratch->clear();
  }
  break;

等扫描推进到 block 3,迎面撞上孤零零的 LAST 片,missing start of fragmented record(4478 字节)。

cpp 复制代码
case kLastType:
  if (!in_fragmented_record) {
    ReportCorruption(fragment.size(),
                     "missing start of fragmented record(2)");
  } else {
      // ...
  }
  break;

而 block 0 的 r0、r1 和 block 3 的 r3 全部幸存------1 个字节的损伤,半径精确等于 1 个 block。

两个实验对照出一条干净的分界:尾部缺损静默,中段损坏报告。前者是每次崩溃的必然后果,后者才值得写进日志让人排查。

kill -9 之后:恢复恰好停在 batch 边界上

物理层的最后一块拼图:真实进程的 kill -9,恢复到底停在哪里。crash_batch(附录)先往一个新库连续写 batch(每批 100 条短链,每批一次 Write,sync=false),写的过程中从外部 kill -9。重启数数之前,先用 wal_inspect 给 log 拍张 X 光------reopen 会消费掉 log 文件(03 篇见过的现象),取证必须跑在前面:

plain 复制代码
$ .output/crash_batch write .output/wal-bench 1000 100 &
$ sleep 0.08 && kill -9 %1        # 退出码 137
$ .output/wal_inspect .output/wal-bench/000003.log -b | head -1
[    0] block  0 #0 type=FULL len= 4212 crc=OK seq=1 count=100
$ .output/wal_inspect .output/wal-bench/000003.log -b | tail -2
[856639] block 26 #229 type=FULL len= 4212 crc=OK seq=20301 count=100
file size: 860858 bytes
$ .output/wal_inspect .output/wal-bench/000003.log -b | grep -c "seq="
204
$ .output/wal_inspect .output/wal-bench/000003.log | grep -c "type=FULL"
178
$ .output/crash_batch count .output/wal-bench 100
recovered 20400 links (last: c:K00020399)
aligned to batch boundary (per=100): YES (204 batches)

文件一侧,log 里不多不少 204 条 record:seq= 的取值从 1 递增到 20301、步长恰好 100,每条 count=100;最后一条 record 起点在 856639,7 字节头加 4212 字节载荷走完,正好是文件大小 860858------文件在 record 边界上干净收尾,第 205 个 batch 连一个字节都没落盘。恢复一侧,重启数出来 20400 条 = 204 × 100,最后一条 key c:K00020399 正是 seq=20301 那条 record 的第 100 条。文件里躺着几条 record,恢复就重放出几批数据,两侧严丝合缝------"完整的重放、残缺的丢弃"在真实崩溃现场对上了账。

顺手再验证一件事:grep -c "type=FULL" 数出来只有 178------204 条里有 26 条撞上了 32KB block 边界,被切成 FIRST/LAST 两片(4212 字节的载荷不足 32KB,至多两片),物理 record 总数 178 + 26 × 2 = 230,最后一条编号 #229,对上。4219 字节一条的 record 与 32768 的 block 并不对齐,分片在真实写入里是常态而非例外,reader 的重组逻辑一直在默默干活。

为什么能这么齐?两层保证共同作用。

  1. 第一层:sync=false 时字节已经通过 write() 进了操作系统页缓存,进程死亡不带走页缓存,所以 204 个完整 batch 的 record 一个不缺、也不多------内核还在,写过的字节都在。

  2. 第二层:就算击杀的瞬间不巧落在第 205 个 batch 的 EmitPhysicalRecord 里,结局也只有三种------字节还全部或部分留在 64KB 用户态缓冲里没刷出去,随进程一起消失,磁盘上什么都不多;已经完整落进页缓存,第 205 批照样算数;或者恰好骑在缓冲边界上只落了一半,成为残缺 record,恢复时被上一节的静默 EOF 路径原样丢弃。无论落在哪种,完整的重放,残缺的丢弃,恢复出来的集合永远是"完整 batch 的前缀" ,count % 100 == 0 不是运气,是格式的性质。

为什么"直接丢弃"是安全的

现在可以正面回答标题问题了:掉电前写到一半的日志,重启后怎么处理?------完整 record 重放,残缺 record 丢弃。但"丢弃"凭什么安全?丢掉的会不会是已经提交给用户的数据?

不会,判据是 05 篇建立的那个提交点:DBImpl::Write 里 AddRecord(以及可选的 Sync)成功、InsertInto 完成、唤醒等待的 writer,这之后 Write 才返回 OK(db_impl.cc:1228-1263 )。一条 record 只有完整地跨过了崩溃边界,它对应的 Write 才可能返回过成功 ;反过来,残缺 record 对应的那次 Write 必然还没返回------调用方拿到的是错误或压根没拿到结果,这批数据本来就不算数,丢了正是语义要求的。恢复丢弃的,从来只是"用户不知道成没成"的写,而不是"用户以为成了"的写。

这里还有一层容易被忽略的细节:恢复时的 reader 是强制 开校验的。RecoverLogFile 构造 reader 的地方带着一段注释(db_impl.cc:418-422):

cpp 复制代码
  // We intentionally make log::Reader do checksumming even if
  // paranoid_checks==false so that corruptions cause entire commits
  // to be skipped instead of propagating bad information (like overly
  // large sequence numbers).
  log::Reader reader(file, &reporter, true /*checksum*/, 0 /*initial_offset*/);

即使 paranoid_checks=false(reporter 的 status 为空,只往 LOG 文件打一行 (ignoring error) dropping N bytes),校验也照做------宁可整批跳过,也不能让坏数据(比如一个坏掉的序列号)污染 MemTable。校验可以低调,但从不缺席。

而提交点的另一半------Sync 失败怎么办------库的态度同样干脆:RecordBackgroundError,之后所有写失败(db_impl.cc:1242-1247)。注释说得很直白:此时 log 文件状态不确定,宁可把库逼进"未来所有写都失败"的模式,等人工介入,也不假装无事发生。

回到 03 篇遗留的问题:Flush、Sync 与数据丢失窗口

03 篇说 sync=false"进程崩溃不丢、机器掉电可能丢",物理细节在这一节中我们都看到了。EmitPhysicalRecord 末尾那句 dest_->Flush() 不是 fsync------WritableFile 的接口语义里,Flush 只是把库内部缓冲的字节交给操作系统(env_posix.cc 里 Flush() 转发 FlushBuffer(),env_posix.cc:304 ),数据落在页缓存里;真正把页缓存推向磁盘的是 Sync()(SyncFd,最终 fdatasync/fsync,env_posix.cc:306、369)。所以:

  • sync=false:AddRecord + Flush 就返回。页缓存里的字节进程死了也在(kill -9 实验的 20400 条就是证据),但整机断电页缓存蒸发,最后若干条 record 消失------丢失的单位是 record,也就是 batch,所以丢也是"整批整批地丢",不会丢出半个 batch;
  • sync=true:Write 路径在 AddRecord 后追加一次 logfile_->Sync()(db_impl.cc:1232-1237 ),语义上从 write(2) 升级成 write(2)+fsync(2)。include/leveldb/options.h 对 WriteOptions::sync 的文档注释(options.h:168-182)把这两档语义写得非常清楚,包括"sync=true 会显著变慢"。有多慢、以及攒批怎么把这笔钱赚回来,05 篇的表格已经算过这笔账,这里不再赘述。

顺带一个 macOS 特供的冷知识:env_posix.cc 的注释提到,macOS 的 fsync() 不保证掉电后数据仍在,所以 LevelDB 在这个平台走 F_FULLFSYNC(env_posix.cc:371-374)------04 篇实测这台机器 fsync 慢得离谱,此刻有了合理的解释,同时备注中我也大概描述了原因。

最后补上 log 文件生命周期的边界:这个文件什么时候产生、什么时候删掉,属于 MemTable 切换与 flush 的地盘。根据 03 篇 reopen 实验里见过现象,我们不妨猜测:写满 4MB 换新文件,旧 log 的内容落成 L0 SSTable 之后整文件删除,但我们的猜测对不对,留待后续文章展开。

MANIFEST:同一套格式的第二位用户

读到这里你可能以为这套 record 格式是 WAL 专用的------不是。MANIFEST 文件(元数据日志)用的是同一套 log::Writer/log::Reader:建库时 NewDB 用 log::Writer 写下第一条 VersionEdit(db_impl.cc:195-198 );运行期 VersionSet 每次 LogAndApply 都把一条 VersionEdit 编码后 AddRecord(version_set.cc:812 、825);恢复时用 log::Reader(checksum 同样强制打开)重放(version_set.cc:906)。一份"checksum + length + type + 块分片"的格式同时服务数据日志和元数据日志,一个 111 行的 writer 和一个 274 行的 reader 被两种场景复用------这也从侧面说明了这套格式的通用性,工程启示里我们再来探讨。

对比参照:三种日志,分别记录得是什么

WAL 不是世界上唯一的日志,把三个熟悉的系统摆在一起看"日志里记什么、恢复时干什么",约束与设计的对应关系会清楚很多。

Redis AOF 记"逻辑命令"。 文本的 SET key value 命令流,恢复 = 把命令从头重新执行一遍,在内存里重建全部状态------这是纯内存数据库的必然选择:状态本来就只活在内存里,日志是唯一的持久层影子,重建成本就是全量重放。持久化策略是显式的旋钮:appendfsync always/everysec/no 对应"每条刷 / 每秒刷 / 交给 OS",也就是把"丢多少、慢多少"的取舍直接交给用户------LevelDB 的 sync 布尔值是同一个旋钮的简化版。日志膨胀问题 AOF 靠重写(bgrewriteaof)解决:把历史命令折叠成等价的最小命令集;LevelDB 的答案不是重写而是轮转------旧 log 的内容变成 SSTable 后整文件删除,历史自动清零。AOF 尾部残缺的处置是 redis-check-aof --fix 截到最近一个合法结尾,与 LevelDB reader 的"尾部静默截断"如出一辙,只是一个靠人工工具、一个内建在读取路径里。

InnoDB RedoLog 记"物理变更"。 512 字节固定 block(对齐磁盘扇区),每条记录说的是"某页某偏移改成了什么",外加全局单调的 LSN 把所有变更放在一条时间线上,恢复 = 从 checkpoint 起重放。为什么必须记物理变更?因为 B+ 树是就地更新:数据页被直接改写,崩溃时同一个页可能只写了一半------而半新半旧的页连"重放的起点"都不是,redo 再精确也无从下手。这就是 doublewrite 存在的理由:先把整页写到一块连续的影子区,再落位于真正的数据页,原地页坏了就从影子区拿一份完整的旧页来重放。代价链完整:就地更新 → 撕裂页风险 → doublewrite → 额外一倍顺序写。

LevelDB WAL 记"整批字节"。 既不是命令文本也不是页级物理变更,而是把提交的原子单位------batch 的 rep_------原样快照下来。恢复不需要理解内容语义:SetContents 灌回一个 batch、InsertInto 重放,写路径怎么写、恢复路径就怎么吃(05 篇的"一鱼两吃")。没有 LSN(序列号长在 batch 头里),没有 doublewrite,也没有重写------因为 LSM 的数据文件是追加式的:SSTable 先完整落盘、校验、再原子安装,坏文件整体作废重来,页永远不存在"改了一半"的中间态,doublewrite 要防的那类事故在这个架构里根本没有发生的机会。32KB 的 block 也只是逻辑边界,不追求与扇区对齐------它不指望单条 record 原子落盘,撕裂交给 CRC 识别。

三者的分野可以总结成一句话:存储结构决定日志记录。纯内存重建 → 记命令;就地更新的页 → 记物理变更并防撕裂;追加式的不可变文件 → 记提交单位的字节快照就足够。恢复方式、防御机制、甚至日志清理策略(重写 / 循环 / 删除),都是这一步的下游。

工程启示

用好 LevelDB

  • 按崩溃场景选 sync,而不是无脑 true。 sync=false 的数据丢失窗口很明确:只有整机断电才会丢,丢失单位是完整 batch;进程崩溃(含被管理器 kill、panic 重启)一分不丢。短链服务里,点击计数这类容忍少量丢失的写完全可以用 false,创建短链这类关键写要么 true,要么参考 05 篇的攒批方案把 fsync 的钱赚回来。
  • fsync 失败不是普通错误。 一旦发生,库进入"所有后续写失败"的模式,这是刻意设计。监控上要把写错误与 LOG 里的背景错误一起告警,别当成偶发抖动重试了事------此时 log 状态不确定,业务侧正确动作是用最近的完好备份恢复,而不是继续写。
  • 对 .log 文件做取证,务必在 reopen 之前。 恢复成功的那一刻旧 log 就被消费删除了(03 篇的现象),hexdump、wal_inspect 都要先做。这一篇的实验脚本全部遵循"先取证、后重启"的顺序。
  • paranoid_checks=false 不代表恢复不校验。 reader 永远开着 CRC,只是损坏时降级为 LOG 里一行 dropping N bytes 而已。定期 grep 一下 LOG 文件里的 dropping,是发现磁盘静默损坏的廉价手段。

设计借鉴

  • checksum + length + type 的自描述 record 加固定 block,是一套可以直接借鉴的 append-only 文件格式。 LevelDB 自己就用它同时支撑了 WAL 和 MANIFEST 两种语义迥异的日志。自己的场景里要做持久化队列、操作流水、binlog,照抄这 7 字节头加 32KB block 就有及格线以上的健壮性:尾部写一半天然可截断,中段损坏有界可恢复。
  • 不被 checksum 保护的字段,出错后就不要再信。 length 不在 CRC 覆盖内,于是 CRC 失败时干脆丢弃整个 block 剩余部分,退到下一个可信边界。任何自描述格式都要回答"元数据坏了怎么办",这个"宁可多丢、不可错信"的答案值得记住------反例是顺着可疑的 length 继续解析,然后用 32 位 CRC 去过滤噪声,撞上假 record 只是时间问题。
  • 把"写一半"和"数据损坏"当成两类事件。 EOF 类(尾部残缺)静默处理,因为它是每次崩溃的必然副产品;corruption 类(中段损坏)如实报告,因为它指向真实的硬件或软件问题。日志系统里这种分类让监控信号干净------静默的不是问题,报告的才值得查。

到这里,之前文章中欠下的两笔债都还清了。93 字节出了 AddRecord 的门,先被套上 7 字节的头(checksum + length + type),变成一条 100 字节的 FULL record;大 batch 撞上 32KB block 边界,被切成 FIRST/MIDDLE/LAST 的接力;掉电留下的残缺尾巴,恢复时被三种静默路径截得整整齐齐;翻转的字节则被 CRC 揪住、连坐整个 block,但火势绝不越过块边界。sync=false 的数据丢失窗口,也终于有了字节级的账目:正是页缓存里那些"进程死了还在、机器死了才没"的 record。

可是故事只讲了一半。恢复时 InsertInto 把 20400 条短链一条条插回了 MemTable,写路径上每一个 batch 落 WAL 之后也立刻进了同一个地方------这个所有写入的必经之地、库内存里唯一可变的数据结构,内部长什么样?为什么它选了跳表,而不是红黑树或者哈希表?

下一篇,我们进入 MemTable 的世界。


附录:本篇示例与实验代码

wal_inspect.cc :WAL 的物理层检查器,按 32KB block 逐条走 record 头,解出偏移、type、length,CRC 现场重算对照;识别 trailer 与截断的头/载荷,-b 顺带解码 batch 头的 seq/count。

cpp 复制代码
// wal_inspect.cc:WAL/MANIFEST 的物理层检查器------不经过 log::Reader 的重组,
// 直接按 32KB block 逐条走 record 头,打印每条物理 record 的偏移、type、长度,
// 并用 crc32c 现场重算校验和对照。用于把 log 文件的字节逐个认出来。
// 用法:wal_inspect <logfile> [-b](-b:对 FULL/FIRST 解码 batch 头的 seq/count,
//                          仅对真实 WriteBatch record 有意义)
//   例:./.output/wal_inspect .output/wal-demo/000003.log -b
// 编译(仓库根目录,需要额外 -I leveldb-1.23 以使用内部头文件):
//   clang++ -std=c++17 -I leveldb-1.23 -I leveldb-1.23/include \
//           examples/shorturl/wal_inspect.cc .output/build/libleveldb.a \
//           -o .output/wal_inspect
#include <cstdint>
#include <cstdio>
#include <string>
#include <vector>

#include "db/log_format.h"
#include "util/coding.h"
#include "util/crc32c.h"

static const char* TypeName(int t) {
  switch (t) {
    case leveldb::log::kZeroType: return "ZERO";
    case leveldb::log::kFullType: return "FULL";
    case leveldb::log::kFirstType: return "FIRST";
    case leveldb::log::kMiddleType: return "MIDDLE";
    case leveldb::log::kLastType: return "LAST";
    default: return "UNKNOWN";
  }
}

int main(int argc, char* argv[]) {
  if (argc < 2) {
    std::fprintf(stderr, "usage: %s <logfile> [-b]\n", argv[0]);
    return 1;
  }
  bool show_batch = false;
  for (int i = 2; i < argc; i++) {
    if (std::string(argv[i]) == "-b") show_batch = true;
  }
  FILE* f = std::fopen(argv[1], "rb");
  if (f == nullptr) {
    std::perror("open");
    return 1;
  }
  std::vector<char> data;
  char buf[4096];
  size_t n;
  while ((n = std::fread(buf, 1, sizeof(buf), f)) > 0) {
    data.insert(data.end(), buf, buf + n);
  }
  std::fclose(f);

  const size_t kBlockSize = leveldb::log::kBlockSize;
  const size_t kHeaderSize = leveldb::log::kHeaderSize;
  size_t pos = 0;
  int index = 0;
  while (pos < data.size()) {
    const size_t block = pos / kBlockSize;
    const size_t left_in_block = kBlockSize - (pos % kBlockSize);

    if (left_in_block < kHeaderSize) {
      // 尾部不足 7 字节:trailer,按格式约定应全零
      bool zero = true;
      for (size_t i = pos; i < (block + 1) * kBlockSize && i < data.size(); i++) {
        if (data[i] != 0) zero = false;
      }
      std::printf("[%5zu] block %2zu trailer %2zu bytes (%s)\n", pos, block,
                  left_in_block, zero ? "all zero" : "NON-ZERO!");
      pos = (block + 1) * kBlockSize;
      continue;
    }

    if (pos + kHeaderSize > data.size()) {
      std::printf(
          "[%5zu] truncated header: only %zu bytes left, header needs %d\n",
          pos, data.size() - pos, leveldb::log::kHeaderSize);
      break;
    }
    const uint32_t crc_stored = leveldb::DecodeFixed32(&data[pos]);
    const uint32_t len =
        (static_cast<uint8_t>(data[pos + 4])) |
        (static_cast<uint8_t>(data[pos + 5]) << 8);
    const int type = static_cast<uint8_t>(data[pos + 6]);
    if (pos + kHeaderSize + len > data.size()) {
      std::printf(
          "[%5zu] truncated payload: header says %u bytes, only %zu in "
          "file\n",
          pos, len, data.size() - pos - kHeaderSize);
      break;
    }

    // 重算 CRC(与 log_writer.cc 一致:type 字节 + payload,存储时做了 Mask)
    const uint32_t crc_expected = leveldb::crc32c::Unmask(crc_stored);
    const uint32_t crc_actual =
        leveldb::crc32c::Value(&data[pos + 6], 1 + len);

    std::printf("[%5zu] block %2zu #%d type=%s len=%5u crc=%s", pos, block,
                index, TypeName(type), len,
                crc_expected == crc_actual ? "OK" : "MISMATCH");
    if (show_batch && len >= 12 && (type == leveldb::log::kFullType ||
                      type == leveldb::log::kFirstType)) {
      // batch 的开头:8 字节 seq + 4 字节 count(小端)
      const uint64_t seq = leveldb::DecodeFixed64(&data[pos + kHeaderSize]);
      const uint32_t count = leveldb::DecodeFixed32(&data[pos + kHeaderSize + 8]);
      std::printf(" seq=%llu count=%u", (unsigned long long)seq, count);
    }
    std::printf("\n");

    pos += kHeaderSize + len;
    index++;
  }
  std::printf("file size: %zu bytes\n", data.size());
  return 0;
}

wal_torn.cc :在裸 log 文件上模拟"写一半断电"(truncate)与"磁盘位翻转"(flip),再用 log::Reader 读回统计。继承 log::Reader::Reporter 的示例必须加 -fno-rtti 编译------libleveldb 本体以 -fno-rtti 构建,两边不一致会链接失败。

cpp 复制代码
// wal_torn.cc:在裸 log 文件上模拟"掉电写一半"------
//   make    <file>   用 log::Writer 写入 4 条精心安排的 record:100B 与
//                    32650B 两条 FULL(后者正好在块尾留出 4 字节 trailer)、
//                    70000B 一条(跨 block,切成 FIRST/MIDDLE/LAST)、再一条
//                    100B 的 FULL------一个文件集齐全部 record 形态。
//   truncate <file> <offset>  把文件截断到 offset 字节(模拟写到一半断电);
//   flip     <file> <offset>  翻转 offset 处的 1 个字节(模拟磁盘位翻转);
//   read     <file>  用 log::Reader 读回,统计恢复了哪几条、丢弃了什么。
// 用法示例:
//   ./.output/wal_torn make .output/torn.log
//   ./.output/wal_torn truncate .output/torn.log 240
//   ./.output/wal_torn read .output/torn.log
// 编译(仓库根目录,需要额外 -I leveldb-1.23 以使用内部头文件;
// libleveldb 以 -fno-rtti 构建,继承了内部类的示例必须保持一致才能链接):
//   clang++ -std=c++17 -fno-rtti -I leveldb-1.23 -I leveldb-1.23/include \
//           examples/shorturl/wal_torn.cc .output/build/libleveldb.a \
//           -o .output/wal_torn
#include <cstdio>
#include <string>
#include <unistd.h>

#include "db/log_reader.h"
#include "db/log_writer.h"
#include "leveldb/env.h"
#include "leveldb/slice.h"
#include "leveldb/status.h"

namespace {

class StdoutReporter : public leveldb::log::Reader::Reporter {
 public:
  void Corruption(size_t bytes, const leveldb::Status& s) override {
    std::printf("  [reporter] dropping %zu bytes: %s\n", bytes,
                s.ToString().c_str());
  }
};

std::string Payload(char fill, size_t n) {
  return std::string(n, fill);
}

int DoMake(const std::string& fname) {
  leveldb::Env* env = leveldb::Env::Default();
  leveldb::WritableFile* file;
  leveldb::Status s = env->NewWritableFile(fname, &file);
  if (!s.ok()) {
    std::fprintf(stderr, "create: %s\n", s.ToString().c_str());
    return 1;
  }
  leveldb::log::Writer writer(file);
  // r0: 100 字节(量级上像 05 篇那个 93 字节的三合一 batch)
  s = writer.AddRecord(leveldb::Slice(Payload('a', 100)));
  // r1: 32650 字节------107 + 7 + 32650 = 32764,块尾恰好剩 4 字节 trailer
  if (s.ok()) s = writer.AddRecord(leveldb::Slice(Payload('b', 32650)));
  // r2: 70000 字节:必然跨 32KB block,被切成 FIRST/MIDDLE/LAST
  if (s.ok()) s = writer.AddRecord(leveldb::Slice(Payload('Z', 70000)));
  // r3: 再 100 字节:落在后面的 block 里
  if (s.ok()) s = writer.AddRecord(leveldb::Slice(Payload('x', 100)));
  delete file;
  if (!s.ok()) {
    std::fprintf(stderr, "write: %s\n", s.ToString().c_str());
    return 1;
  }
  std::printf("wrote 4 records: [100, 32650, 70000, 100] -> %s\n",
              fname.c_str());
  return 0;
}

int DoTruncate(const std::string& fname, long offset) {
  if (::truncate(fname.c_str(), offset) != 0) {
    std::perror("truncate");
    return 1;
  }
  std::printf("truncated %s to %ld bytes\n", fname.c_str(), offset);
  return 0;
}

int DoFlip(const std::string& fname, long offset) {
  FILE* f = std::fopen(fname.c_str(), "r+b");
  if (f == nullptr) {
    std::perror("open");
    return 1;
  }
  if (std::fseek(f, offset, SEEK_SET) != 0) {
    std::perror("fseek");
    std::fclose(f);
    return 1;
  }
  int c = std::fgetc(f);
  if (c == EOF) {
    std::fprintf(stderr, "offset beyond EOF\n");
    std::fclose(f);
    return 1;
  }
  std::fseek(f, offset, SEEK_SET);
  std::fputc(c ^ 0xff, f);
  std::fclose(f);
  std::printf("flipped byte at %ld (0x%02x -> 0x%02x)\n", offset, c, c ^ 0xff);
  return 0;
}

int DoRead(const std::string& fname) {
  leveldb::Env* env = leveldb::Env::Default();
  leveldb::SequentialFile* file;
  leveldb::Status s = env->NewSequentialFile(fname, &file);
  if (!s.ok()) {
    std::fprintf(stderr, "open: %s\n", s.ToString().c_str());
    return 1;
  }
  StdoutReporter reporter;
  // 与 DBImpl::RecoverLogFile 相同:校验和强制打开
  leveldb::log::Reader reader(file, &reporter, true /*checksum*/,
                              0 /*initial_offset*/);
  leveldb::Slice record;
  std::string scratch;
  int recovered = 0;
  while (reader.ReadRecord(&record, &scratch)) {
    recovered++;
    std::printf("  recovered record #%d: %zu bytes (head: %c x %d)\n",
                recovered, record.size(), record.empty() ? '?' : record[0],
                record.empty() ? 0 : 1);
  }
  uint64_t size = 0;
  env->GetFileSize(fname, &size);
  std::printf("read done: %d/%d records recovered from %llu bytes\n", recovered,
              4, (unsigned long long)size);
  delete file;
  return 0;
}

}  // namespace

int main(int argc, char* argv[]) {
  if (argc < 3) {
    std::fprintf(stderr,
                 "usage: %s make <file>\n"
                 "       %s truncate <file> <offset>\n"
                 "       %s flip <file> <offset>\n"
                 "       %s read <file>\n",
                 argv[0], argv[0], argv[0], argv[0]);
    return 1;
  }
  const std::string cmd = argv[1];
  const std::string fname = argv[2];
  if (cmd == "make" && argc == 3) return DoMake(fname);
  if (cmd == "read" && argc == 3) return DoRead(fname);
  if ((cmd == "truncate" || cmd == "flip") && argc == 4)
    return cmd == "truncate" ? DoTruncate(fname, std::atol(argv[3]))
                             : DoFlip(fname, std::atol(argv[3]));
  std::fprintf(stderr, "bad arguments\n");
  return 1;
}

crash_batch.cc:batch 边界的 kill -9 实验------写入若干个 batch 后从外部击杀,重启统计恢复条数是否与 batch 边界对齐。

cpp 复制代码
// crash_batch.cc:batch 边界的 kill -9 实验------
//   three <dbpath>   新建库,写入 05 篇那个"三合一"batch(c:Ab3xK 主体 +
//                    用户索引 + 计数初始化,共 93 字节),然后 SIGKILL 自己;
//   write <dbpath> <batches> <per>  新建库,写 batches 个 batch、每个 per 条
//                    短链(每个 batch 一次 Write),写完 SIGKILL 自己;
//   count <dbpath> [per]  重启库,数一数 c: 开头的 key 有多少个,检查是否
//                    与 batch 边界对齐(count % per == 0)。
// 用法示例:
//   ./.output/crash_batch three .output/wal-three
//   ./.output/reopen .output/wal-three c:Ab3xK
//   ./.output/crash_batch write .output/wal-crash 1000 100 &
//   sleep 0.08 && kill -9 %1        # 从外部击杀,落在任意时刻
//   ./.output/crash_batch count .output/wal-crash 100
// 编译(仓库根目录):
//   clang++ -std=c++17 -I leveldb-1.23/include examples/shorturl/crash_batch.cc \
//           .output/build/libleveldb.a -o .output/crash_batch
#include <csignal>
#include <cstdio>
#include <string>
#include <unistd.h>

#include "leveldb/db.h"
#include "leveldb/iterator.h"
#include "leveldb/options.h"
#include "leveldb/status.h"
#include "leveldb/write_batch.h"

static void KillSelf() {
  std::printf("killing myself with SIGKILL\n");
  std::fflush(stdout);
  ::kill(::getpid(), SIGKILL);
}

static int DoThree(const std::string& dbpath) {
  leveldb::Options options;
  options.create_if_missing = true;
  options.error_if_exists = true;
  leveldb::DB* db = nullptr;
  leveldb::Status s = leveldb::DB::Open(options, dbpath, &db);
  if (!s.ok()) {
    std::fprintf(stderr, "open error: %s\n", s.ToString().c_str());
    return 1;
  }
  leveldb::WriteBatch batch;  // 与 05 篇的三合一完全一致:93 字节
  batch.Put("c:Ab3xK", "https://example.com/a-very-long-url");
  batch.Put("u:robin:c:Ab3xK", "1");
  batch.Put("c:Ab3xK:click", "0");
  s = db->Write(leveldb::WriteOptions(), &batch);  // sync=false
  if (!s.ok()) {
    std::fprintf(stderr, "write error: %s\n", s.ToString().c_str());
    return 1;
  }
  std::printf("three-in-one batch written (sync=false), killing self\n");
  KillSelf();
  return 0;  // 不会执行到这里
}

static int DoWrite(const std::string& dbpath, long batches, long per) {
  leveldb::Options options;
  options.create_if_missing = true;
  options.error_if_exists = true;
  leveldb::DB* db = nullptr;
  leveldb::Status s = leveldb::DB::Open(options, dbpath, &db);
  if (!s.ok()) {
    std::fprintf(stderr, "open error: %s\n", s.ToString().c_str());
    return 1;
  }
  char key[32], val[64];
  for (long b = 0; b < batches; b++) {
    leveldb::WriteBatch batch;
    for (long i = 0; i < per; i++) {
      const long id = b * per + i;
      std::snprintf(key, sizeof(key), "c:K%08ld", id);
      std::snprintf(val, sizeof(val), "https://example.com/%08ld", id);
      batch.Put(key, val);
    }
    s = db->Write(leveldb::WriteOptions(), &batch);  // sync=false
    if (!s.ok()) {
      std::fprintf(stderr, "write error: %s\n", s.ToString().c_str());
      return 1;
    }
    std::printf("batch %ld done (%ld links)\n", b + 1, per);
    std::fflush(stdout);
  }
  KillSelf();
  return 0;  // 不会执行到这里
}

static int DoCount(const std::string& dbpath, long per) {
  leveldb::Options options;
  options.create_if_missing = false;
  leveldb::DB* db = nullptr;
  leveldb::Status s = leveldb::DB::Open(options, dbpath, &db);
  if (!s.ok()) {
    std::fprintf(stderr, "open error: %s\n", s.ToString().c_str());
    return 1;
  }
  long count = 0;
  std::string last_key;
  leveldb::Iterator* it = db->NewIterator(leveldb::ReadOptions());
  for (it->Seek("c:"); it->Valid() && it->key().starts_with("c:"); it->Next()) {
    last_key = it->key().ToString();
    count++;
  }
  delete it;
  delete db;
  std::printf("recovered %ld links (last: %s)\n", count,
              last_key.c_str());
  if (per > 0) {
    std::printf("aligned to batch boundary (per=%ld): %s (%ld batches)\n",
                per, count % per == 0 ? "YES" : "NO", count / per);
  }
  return 0;
}

int main(int argc, char* argv[]) {
  if (argc < 3) {
    std::fprintf(stderr,
                 "usage: %s three <dbpath>\n"
                 "       %s write <dbpath> <batches> <per>\n"
                 "       %s count <dbpath> [per]\n",
                 argv[0], argv[0], argv[0]);
    return 1;
  }
  const std::string cmd = argv[1];
  const std::string dbpath = argv[2];
  if (cmd == "three" && argc == 3) return DoThree(dbpath);
  if (cmd == "write" && argc == 5)
    return DoWrite(dbpath, std::atol(argv[3]), std::atol(argv[4]));
  if (cmd == "count" && (argc == 3 || argc == 4))
    return DoCount(dbpath, argc == 4 ? std::atol(argv[3]) : 0);
  std::fprintf(stderr, "bad arguments\n");
  return 1;
}
相关推荐
嘎嘎风1 天前
MiniMind 学习笔记之 21 训练细节:数据、分词器、断点续训
gpt·源码阅读
RobinXYuan1 天前
【Kafka 源码手记】01 从消息队列到 Event Streaming
kafka·源码阅读
RobinXYuan3 天前
【LevelDB 源码阅读】05 | WriteBatch:原子写的载体
源码阅读·leveldb
云浪6 天前
Go源码分析:搞懂 Go 是如何实现堆的
后端·go·源码阅读
Android打工仔7 天前
CoroutineScheduler 设计解析(上)—— 为什么 Dispatchers.IO 会创建更多线程?
android·kotlin·源码阅读
嘎嘎风8 天前
MiniMind 学习笔记之 04 注意力之外:位置、记忆、省显存、省时间、深加工
架构·源码阅读
嘎嘎风8 天前
MiniMind 学习笔记之 07 模型怎么学会猜词(旋钮是怎么被拧动的)
算法·源码阅读
嘎嘎风8 天前
MiniMind 学习笔记之 05 从 768 维到下一个 token
github·源码阅读
RobinXYuan11 天前
【LevelDB 源码阅读】03 | 打开一个数据库:DBImpl::Open 与恢复
源码阅读·leveldb