kv数据库-leveldb (7) 预写日志 (Log / WAL)

118 阅读8分钟

在上一章 迭代器 (Iterator) 中,我们学习了如何像翻书一样优雅地遍历数据库中的数据。至此,我们已经掌握了 LevelDB 的主要用户接口。我们知道了如何使用 LevelDB。

从本章开始,我们将深入 LevelDB 的内部,探索那些支撑起这些强大功能的底层组件,去理解它为何能如此工作。我们将从一个最根本的问题开始:当我调用 db->Put() 并得到一个成功的返回时,LevelDB 到底做了什么来确保我的数据即使在下一秒就断电的情况下也不会丢失?

答案就是本章的主角——预写日志(Write-Ahead Log),通常简称为 Log 或 WAL。

什么是预写日志?为什么需要它?

想象一下你在写一篇非常重要的论文。你使用的写作软件非常高级,它会在你写作时自动排版、生成目录、管理引用,这些都是很复杂的操作。如果在软件执行这些复杂操作的过程中,电脑突然蓝屏了,你可能会发现整个文档都损坏了,无法打开。

一个聪明的做法是,在你每写完一个段落时,都先把它快速地复制粘贴到一个最简单的文本文件(比如记事本)里。这个“记事本”文件只做一件事:按顺序记录你写下的所有内容。它的写入速度极快,而且格式简单,不容易损坏。这样一来,即使高级写作软件崩溃了,你也可以通过这个简单的记事本文件,恢复出绝大部分的工作成果。

LevelDB 的预写日志(WAL)扮演的正是这个“记事本”的角色。它是确保数据持久性 (Durability) 的关键机制。它的核心原则非常简单:

在对数据进行任何复杂的内存操作(比如写入 内存表 (MemTable))之前,必须先将这个操作的内容顺序地、完整地记录到一个日志文件中。

这个日志文件就像一本只能在末尾追加内容的流水账。因为磁盘的顺序写入速度非常快,所以记录日志这个步骤并不会带来很大的性能开销。但它却带来了巨大的安全保障:

  • 写入成功 = 数据安全:一旦 db->Put()db->Write() 操作返回成功,就意味着你的数据已经被记录在这本“流水账”里了。此时,即使系统立即崩溃,数据也不会丢失。
  • 崩溃恢复:当数据库下次重启时,它会首先检查这本流水账。如果发现账本里记录了一些操作,但这些操作可能还没来得及完全应用到最终的数据结构中,LevelDB 就会“重放”(Replay)这本账本,把丢失的操作重新执行一遍,从而将数据库恢复到崩溃前的一致状态。

你可以把 WAL 想象成飞机的“黑匣子”,它忠实地记录了每一次写入指令,为数据恢复提供了可能。

日志文件的内部结构

虽然 WAL 的概念很简单,但它的文件格式经过了精心设计,以确保高效和健壮。一个日志文件(例如 000003.log)在物理上被划分为一系列固定大小的块 (Block),每个块的大小通常是 32KB。

一个或多个用户的写操作(比如一个 WriteBatch 的内容)构成一个记录 (Record)。如果一个记录很大,超过了一个块的剩余空间,它就会被分片 (Fragment) 存储在多个物理记录中。

graph LR

    subgraph "块 2 的内容"
        direction LR
        Rec2_Part2["记录 B 的中间部分<br/>(MiddleType)"] --> Rec2_Part3["记录 B 的最后部分<br/>(LastType)"] --> Rec3["记录 C<br/>(FullType)"]
    end
    subgraph "块 1 的内容"
        direction LR
        Rec1["记录 A<br/>(FullType)"] --> Rec2_Part1["记录 B 的第一部分<br/>(FirstType)"]
    end
    subgraph "日志文件 (例如 000003.log)"
        direction LR
        Block1["块 1 (32KB)"] --> Block2["块 2 (32KB)"] --> Block3["块 3 (32KB)"] --> More["..."]
    end    

    style Block1 fill:#cde
    style Block2 fill:#cde
    style Block3 fill:#cde

如图所示:

  • 记录 A 比较小,被完整地存放在块 1 中。这种完整的记录类型是 kFullType
  • 记录 B 比较大,它的开头部分存放在块 1 的末尾(类型为 kFirstType),中间部分占据了块 2 的一部分(kMiddleType),最后一部分结束在块 2 中(kLastType)。
  • 记录 C 又是一个完整的记录。

每个物理记录(无论是完整的还是分片的)都有一个 7 字节的头部,包含了校验和(CRC)、数据长度和记录类型,以确保数据的完整性。

这种分块和分片的设计有两个主要好处:

  1. 错误恢复:如果文件中的某个块损坏了,LevelDB 可以直接跳过这个损坏的块,从下一个块开始继续解析,从而尽可能多地恢复数据。
  2. 空间利用:可以充分利用每个块的存储空间,避免因为一个大记录放不下而浪费块尾的大量空间。

WAL 是如何工作的?

WAL 是 LevelDB 内部的机制,我们无法直接操作它,但理解它的工作流程有助于我们理解 LevelDB 的写入过程。当我们调用 db->Write(batch) 时,内部会发生以下事情:

sequenceDiagram
    participant App as 你的应用程序
    participant DB as DB 实例
    participant LogWriter as 日志写入器
    participant LogFile as 日志文件 (磁盘)
    participant MemTable as 内存表 (内存)

    App->>DB: Write(batch)
    Note over DB: 收到写入请求
    DB->>LogWriter: 1. AddRecord(batch 内容)
    LogWriter->>LogFile: 2. 将 batch 内容<br/>(可能分片后)追加写入文件
    LogFile-->>LogWriter: 写入成功
    LogWriter-->>DB: 日志记录完毕
    DB->>MemTable: 3. 将 batch 内容写入内存表
    MemTable-->>DB: 内存更新完毕
    DB-->>App: 返回成功
  1. DB 实例首先会将整个 WriteBatch 的内容作为一个“记录”交给 log::Writer
  2. log::Writer 负责将这个记录序列化,并按照我们上面描述的格式(如果需要则进行分片),构造一个或多个带有头部的物理记录,然后将它们追加到当前日志文件的末尾。这个操作会确保数据被真正写入到磁盘上。
  3. 只有在日志文件写入成功之后DB 实例才会继续将 WriteBatch 中的操作应用到内存中的 内存表 (MemTable)里。
  4. 最后,DB 实例向应用程序返回成功。

这个先写日志,再写内存的顺序是 WAL 机制的核心,也是它名字(Write-Ahead Log,预写日志)的由来。

深入代码实现

让我们通过源码来具体看看这个过程。相关的实现主要在 db/log_writer.ccdb/log_reader.cc 中,而格式定义在 db/log_format.h

1. 日志格式定义 (db/log_format.h)

这个头文件定义了日志结构中的所有魔法数字。

// 来自 db/log_format.h

enum RecordType {
  // ...
  kFullType = 1,

  // For fragments
  kFirstType = 2,
  kMiddleType = 3,
  kLastType = 4
};

static const int kBlockSize = 32768; // 32KB

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

这段代码清晰地定义了我们之前讨论过的记录类型(RecordType)、块大小(kBlockSize)和头部大小(kHeaderSize)。

2. 日志的写入 (db/log_writer.cc)

log::Writer 类负责将用户数据写入日志文件。核心方法是 AddRecord

// 来自 db/log_writer.cc (简化逻辑)

Status Writer::AddRecord(const Slice& slice) {
  const char* ptr = slice.data();
  size_t left = slice.size(); // 剩下待写入的数据长度

  Status s;
  bool begin = true; // 是否是记录的第一个分片
  do {
    const int leftover = kBlockSize - block_offset_; // 当前块的剩余空间
    // ... 如果剩余空间不足以容纳头部,就填充并切换到新块 ...

    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;
}

这个 do-while 循环完美地展示了分片逻辑。它不断地从用户数据(slice)中切下一块(fragment_length),确定它的类型(type),然后调用 EmitPhysicalRecord 将它写入文件,直到所有数据都写完为止。

EmitPhysicalRecord 函数则负责构建 7 字节的头部并和数据一起写入文件。

// 来自 db/log_writer.cc (简化逻辑)

Status Writer::EmitPhysicalRecord(RecordType t, const char* ptr,
                                  size_t length) {
  // ...
  // 1. 格式化 7 字节的头部
  char buf[kHeaderSize];
  buf[4] = static_cast<char>(length & 0xff);      // 长度低 8 位
  buf[5] = static_cast<char>(length >> 8);       // 长度高 8 位
  buf[6] = static_cast<char>(t);                 // 类型
  
  // 2. 计算校验和并写入头部
  uint32_t crc = crc32c::Extend(type_crc_[t], ptr, length);
  EncodeFixed32(buf, crc32c::Mask(crc));

  // 3. 将头部和数据追加到文件
  Status s = dest_->Append(Slice(buf, kHeaderSize));
  if (s.ok()) {
    s = dest_->Append(Slice(ptr, length));
    if (s.ok()) {
      s = dest_->Flush(); // 确保写入磁盘
    }
  }
  // ...
  return s;
}

这个函数是 WAL 机制的最后一步,它将一个物理记录(分片)真正地写入到磁盘,并通过 Flush() 保证其持久化。

3. 日志的读取 (db/log_reader.cc)

log::ReaderWriter 的搭档,负责在数据库启动时读取日志文件进行恢复。它的 ReadRecord 方法会读取一个一个的物理记录,并将分片重新组合成完整的用户记录,然后交给上层进行重放。

总结

在本章中,我们揭开了 LevelDB 数据安全的第一道防线——预写日志(WAL)的神秘面纱。

  • WAL 是一种保证数据持久性的机制,它的核心思想是先写日志,再改内存
  • 它将所有写操作顺序地追加到一个日志文件中,这种方式在保证安全的同时,性能开销很小。
  • 日志文件由固定大小的组成,一个大的用户记录可能会被分片存储在多个物理记录中,每个物理记录都有独立的类型(Full, First, Middle, Last)和校验和。
  • 在数据库因意外崩溃而重启时,LevelDB 会通过重放日志文件来恢复数据,确保任何已提交的写操作都不会丢失。

我们现在知道了,当调用 db->Write() 时,数据首先被安全地记录在了 WAL 中。那么,日志写入成功后,数据又被放到了内存的哪个地方呢?这个地方必须支持极快的写入和查找,并且是有序的,以便将来能高效地写入磁盘。

在下一章,我们将探索这个位于内存中的核心数据结构:内存表 (MemTable)。