kv数据库-leveldb (5) 批量写 (WriteBatch)

93 阅读7分钟

在上一章 数据库实例 (DB) 中,我们学习了如何使用 DB 实例来进行单次的 PutGetDelete 操作。这对于许多简单的场景已经足够了。但是,如果我们遇到一个更复杂的情况呢?

为什么需要批量写?

想象一个经典的银行转账场景:用户 A 要转 100 元给用户 B。这个操作需要至少两个步骤:

  1. 从用户 A 的账户余额中减去 100 元。
  2. 给用户 B 的账户余额增加 100 元。

如果我们用上一章学到的知识,可能会写出这样的代码:

// 伪代码
db->Put(options, "balance_A", "900"); // A 原来有 1000
// 糟糕!如果程序在这里崩溃了怎么办?
db->Put(options, "balance_B", "600"); // B 原来有 500

这里存在一个巨大的风险:如果在执行完第一个 Put 操作后,服务器突然断电或程序崩溃,那么用户 A 的钱被扣了,但用户 B 却没有收到。数据库的状态就不一致了,这在金融系统中是绝对不能接受的。

我们需要一种方法,能告诉数据库:“听着,接下来这两个操作是一个整体,你要么全部成功,要么就一个也别做,回到操作之前的状态。”

这就是 WriteBatch 发挥作用的地方。它就像一张购物清单。你把所有要买的东西(PutDelete 操作)都写在清单上,然后一次性交给管理员。管理员会保证,要么清单上的所有东西都买回来,要么就一样东西都不买。他绝不会只买回一半的东西。这种“要么全做,要么全不做”的特性,我们称之为原子性 (Atomicity)

如何使用 WriteBatch

使用 WriteBatch 非常简单,主要分为三个步骤:

  1. 创建一个 WriteBatch 对象。
  2. 向这个对象里添加 PutDelete 操作。
  3. 调用 DB::Write 方法,将整个批次一次性写入数据库。

让我们用 WriteBatch 来安全地实现上面的转账操作。

#include "leveldb/db.h"
#include "leveldb/write_batch.h"
#include <iostream>

// (此处省略了打开数据库的代码, 假设 db 已经成功打开)

int main() {
  leveldb::DB* db;
  // ... 打开数据库 ...

  // 假设 A 有 1000 元, B 有 500 元
  db->Put(leveldb::WriteOptions(), "balance_A", "1000");
  db->Put(leveldb::WriteOptions(), "balance_B", "500");
  
  // --- 开始转账操作 ---
  
  // 1. 创建一个 WriteBatch 对象
  leveldb::WriteBatch batch;

  // 2. 将两个修改操作添加到 batch 中
  //    注意: 这时数据还没有真正写入数据库
  batch.Put("balance_A", "900");  // A 减 100
  batch.Put("balance_B", "600");  // B 加 100

  // 3. 原子地将整个批次写入数据库
  leveldb::Status status = db->Write(leveldb::WriteOptions(), &batch);

  if (status.ok()) {
    std::cout << "转账成功!" << std::endl;
  } else {
    std::cerr << "转账失败: " << status.ToString() << std::endl;
  }

  // ... 清理和关闭数据库 ...
  delete db;
  return 0;
}

解释:

  • 我们创建了一个 WriteBatch 对象 batch
  • 我们调用 batch.Put 而不是 db->Put。这些操作只是被记录在了 batch 对象内部,并没有对数据库产生任何影响。它们就像是写在购物清单上的项目。
  • 最后,db->Write 方法接收整个 batch。LevelDB 会保证 batch 中的所有操作被当作一个原子单元来执行。现在,即使在 db->Write 执行过程中发生崩溃,LevelDB 也能通过恢复机制确保数据库状态要么是转账前的状态,要么是转账成功后的状态,绝不会出现中间状态。

WriteBatch 不仅可以添加 Put 操作,也可以添加 Delete 操作。例如,删除一个用户时,我们可能需要同时删除他的基本信息和头像信息。

leveldb::WriteBatch user_deletion_batch;

user_deletion_batch.Delete("user:123");
user_deletion_batch.Delete("user_avatar:123");

db->Write(leveldb::WriteOptions(), &user_deletion_batch);

这确保了不会出现用户基本信息被删了,但头像还在的“数据孤儿”问题。

WriteBatch 内部是如何工作的?

WriteBatch 的强大功能背后,是其简洁而高效的设计。

WriteBatch 对象内部,本质上只有一个 std::string rep_ 成员变量。rep_ 是 "representation"(表示)的缩写。我们向 batch 添加的每一个 PutDelete 操作,都会被序列化成特定的字节格式,然后追加到这个 rep_ 字符串的末尾。

WriteBatch 的序列化格式

这个内部字符串 rep_ 的格式大致如下:

graph LR
    subgraph "WriteBatch 内部的 rep_ 字符串"
        A["序列号<br/>8 字节"] --> B["操作计数<br/>4 字节"] --> C["记录 1"] --> D["记录 2"] --> E["..."]
    end

    subgraph "记录 (Record)"
        direction LR
        F["类型<br/>1 字节"] --> G[键] --> H["值 (仅 Put 有)"]
    end
    
    style F fill:#f9f
  • 序列号 (Sequence Number): 一个8字节的数字,用于内部版本控制。在提交到数据库前,LevelDB会填充这个值。
  • 操作计数 (Count): 一个4字节的数字,记录了这个批次里包含多少个操作。
  • 数据 (Data): 紧跟着的是一系列的操作记录。
    • 每条记录都以一个1字节的类型标记开头(例如,kTypeValue 代表 PutkTypeDeletion 代表 Delete)。
    • 后面跟着的是键和值(如果是 Put 操作)。为了能正确解析,键和值本身都带有长度信息。

所以,当我们执行 batch.Put("name", "level")batch.Delete("key") 时,rep_ 字符串的内容(简化后)看起来就像这样: [序列号][计数值=2][类型=Put][长度=4]name[长度=5]level[类型=Delete][长度=3]key

DB::Write 的原子性保证

WriteBatch 的原子性保证主要依赖于 LevelDB 的 预写日志 (Log / WAL)。当我们调用 db->Write(options, &batch) 时,会发生以下关键步骤:

sequenceDiagram
    participant App as 你的应用程序
    participant DB as DB 实例
    participant WAL as 预写日志 (文件)
    participant Mem as MemTable (内存)

    App->>DB: Write(batch)
    Note right of DB: 管理员收到一张“购物清单”
    DB->>WAL: 1. 将整个 batch 的内容<br/>(rep_ 字符串) 作为一条记录<br/>写入日志文件
    Note right of WAL: 原子写入, 保证崩溃可恢复
    WAL-->>DB: 日志写入成功
    DB->>Mem: 2. 遍历 batch 中的操作,<br/>逐一应用到 MemTable
    Note right of Mem: 快速的内存更新
    Mem-->>DB: 所有操作应用完毕
    DB-->>App: 返回成功
  1. 写入日志: LevelDB 会将 WriteBatch 内部的整个 rep_ 字符串,作为一个单条记录,原封不动地追加到 预写日志 (Log / WAL) 文件中。这是实现原子性的关键。因为对文件的追加写入通常是原子操作,所以这条日志记录要么被完整地写入,要么就根本没写进去。
  2. 写入内存表: 只有在日志成功写入磁盘后,LevelDB 才会开始解析 WriteBatch 里的内容,然后将这些 PutDelete 操作一个个地应用到 内存表 (MemTable) 中。

如果系统在第 2 步执行到一半时崩溃了,会发生什么?没关系!当数据库下次重启时,它会读取 WAL。它会看到那条完整的 WriteBatch 日志记录,然后重新将这个批次里的所有操作应用到 MemTable 中,从而将数据恢复到一致的状态。

深入代码实现

让我们看看相关的源码来印证我们的理解。

WriteBatch 的定义在 include/leveldb/write_batch.h 中,非常简单:

// 来自 include/leveldb/write_batch.h

class LEVELDB_EXPORT WriteBatch {
 public:
  // ...
  void Put(const Slice& key, const Slice& value);
  void Delete(const Slice& key);
  // ...
 private:
  friend class WriteBatchInternal;
  std::string rep_;
};

可以看到,核心就是我们提到的 rep_ 字符串。

Put 方法的实现位于 db/write_batch.cc,它清晰地展示了如何将操作序列化并追加到 rep_ 中:

// 来自 db/write_batch.cc (简化后)

void WriteBatch::Put(const Slice& key, const Slice& value) {
  // 增加计数
  WriteBatchInternal::SetCount(this, WriteBatchInternal::Count(this) + 1);
  // 追加类型标记
  rep_.push_back(static_cast<char>(kTypeValue));
  // 追加带长度前缀的 key 和 value
  PutLengthPrefixedSlice(&rep_, key);
  PutLengthPrefixedSlice(&rep_, value);
}

这个实现和我们之前描述的序列化过程完全一致:更新计数,然后将 [类型][键][值] 的字节序列追加到 rep_ 字符串的末尾。Delete 方法的实现与此类似。

总结

在本章中,我们学习了一个非常重要的功能:WriteBatch

  • WriteBatch 允许我们将多个 PutDelete 操作组合成一个原子单元
  • 它解决了需要同时修改多条数据时的一致性问题,是实现事务等高级功能的基础。
  • 它的工作原理是:将所有操作序列化到一个内部字符串中,然后将这个字符串作为单条记录写入 预写日志 (Log / WAL) 中,从而保证了操作的原子性。
  • 使用 WriteBatch 还能提升性能,因为它将多次小的磁盘写入合并成了一次大的写入,减少了系统调用的开销。

我们现在已经掌握了如何向 LevelDB 中安全地写入单条和多条数据。但是,数据存进去之后,我们该如何高效地遍历和查看它们呢?仅仅使用 Get 方法一次只能读取一条记录,效率太低。

在下一章,我们将学习另一个强大的工具——迭代器 (Iterator),它能让我们像遍历数组一样轻松地扫描数据库中的数据。