【LevelDB 源码阅读】05 | WriteBatch:原子写的载体

上一篇结尾留了一个黑盒:BuildBatchGroup 把八个线程的写请求拼成一个大 batch,一次落盘------这个"batch"究竟长什么样?每个成员怎么分到自己的序列号?为什么说它就是一次原子写?

这一篇先从一个更实际的问题开始。短链服务创建一条短链,在 01 篇设计的 key 布局下其实是三个动作:

plain 复制代码
c:Ab3xK            → 短码主体(目标 URL 等元数据)
u:robin:c:Ab3xK    → 用户 → 短码索引
c:Ab3xK:click      → 点击计数,初始化为 0

三条记录,三次写。如果第二条写完、第三条失败,库里就出现一条有索引、计数却失踪的短链;反过来则是有主体、用户的列表里查不到。都是脏数据,而且后续每一次读都要小心翼翼地兜底。能不能让这三步要么全部生效、要么全部不生效?

答案不用去很远的地方找,它就是标题里的 WriteBatch------而且读完源码会发现,这个结构早就渗透在写路径的每个角落:单次 Put 是它,并发合并的载体是它,WAL 里的还是它。

:::tips 怎么保证"创建短链 + 计数 + 索引"三步要么都生效要么都不生效?------LevelDB 的答案是:把这三步编码成同一份字节,让这份字节成为崩溃一致性的最小单位。

:::


到处都是 WriteBatch

先感受一下 WriteBatch 的出场频率。04 篇读 DBImpl::Write 时其实已经见过它三次了:

  • DB::Put 的实现就是包一个单条目的 batch 转手交给 Write(db_impl.cc:1469-1473 ),Delete 同理(db_impl.cc:1475-1479 )------对 Write 来说,世界上没有"单条写"这个概念,只有 batch;
  • BuildBatchGroup 把多个 writer 的 batch 用 Append 拼成一个大 batch(04 篇读过的 tmp_batch_);
  • 恢复时从 WAL 读出的 record,被 SetContents 直接当成 batch 用,再走一遍 InsertInto(db_impl.cc:438、444)。
cpp 复制代码
WriteBatchInternal::SetContents(&batch, record);

if (mem == nullptr) {
  mem = new MemTable(internal_comparator_);
  mem->Ref();
}
status = WriteBatchInternal::InsertInto(&batch, mem);

一个结构同时扮演"用户 API 的参数"、"并发合并的载体"和"日志的物理格式",这在整个代码库里大概是独一份。头文件(include/leveldb/write_batch.h)的公共接口很精炼:

cpp 复制代码
class LEVELDB_EXPORT WriteBatch {
 public:
  class LEVELDB_EXPORT Handler {
   public:
    virtual ~Handler();
    virtual void Put(const Slice& key, const Slice& value) = 0;
    virtual void Delete(const Slice& key) = 0;
  };

  // (构造、拷贝控制与各方法的说明注释略)
  void Put(const Slice& key, const Slice& value);
  void Delete(const Slice& key);
  void Clear();
  size_t ApproximateSize() const;
  void Append(const WriteBatch& source);
  Status Iterate(Handler* handler) const;

 private:
  friend class WriteBatchInternal;
  std::string rep_;  // See comment in write_batch.cc for the format of rep_
};

注意最后那行注释:全部家当就是一个 std::string rep_,格式说明"见 write_batch.cc "。那我们就去 write_batch.cc 文件里瞅瞅。

探索 rep_

db/write_batch.cc 开头的格式注释是全篇最重要的一段信息(write_batch.cc:5-14):

cpp 复制代码
// WriteBatch::rep_ :=
//    sequence: fixed64
//    count: fixed32
//    data: record[count]
// record :=
//    kTypeValue varstring varstring         |
//    kTypeDeletion varstring
// varstring :=
//    len: varint32
//    data: uint8[len]

翻译一下:头部 12 字节------8 字节序列号(小端 fixed64)加 4 字节条目数(小端 fixed32);头部后面是 count 条变长 record,每条以 1 字节 tag 开头,kTypeValue(值是 0x1)后面跟两段"长度前缀字符串"(key、value),kTypeDeletion(值是 0x0)后面只跟 key。这两个 tag 值定义在 dbformat.h:54:

cpp 复制代码
enum ValueType { kTypeDeletion = 0x0, kTypeValue = 0x1 };

顺带提一嘴:这不是 WriteBatch 专用的枚举,MemTable 里的每条记录、SSTable 里的每条 KV,用的都是同一对 type 值------一个编码从 batch 一路贯穿到最终的磁盘文件,后面讲 InternalKey 时会再遇到它。

构造函数只是调用 Clear()(write_batch.cc:29 ),而 Clear 是把 rep_ 清空后 resize 出 12 个字节(write_batch.cc:35-38):一个新生的 batch 就是 12 个零字节,seq 是 0,count 是 0。

cpp 复制代码
void WriteBatch::Clear() {
  rep_.clear();
  rep_.resize(kHeader);
}

往 WriteBatch 里写入数据的代码同样十分简单。Put(write_batch.cc:98-103):

cpp 复制代码
void WriteBatch::Put(const Slice& key, const Slice& value) {
  WriteBatchInternal::SetCount(this, WriteBatchInternal::Count(this) + 1);
  rep_.push_back(static_cast<char>(kTypeValue));
  PutLengthPrefixedSlice(&rep_, key);
  PutLengthPrefixedSlice(&rep_, value);
}

Delete(write_batch.cc:105-109)方法就少了一段操作 value 的代码:

cpp 复制代码
void WriteBatch::Delete(const Slice& key) {
  WriteBatchInternal::SetCount(this, WriteBatchInternal::Count(this) + 1);
  rep_.push_back(static_cast<char>(kTypeDeletion));
  PutLengthPrefixedSlice(&rep_, key);
}

count 加一,tag 一字节,key 和 value 各自带上变长长度前缀,追加,结束。没有其它操作------往 WriteBatch 里 Put 同一个 key 一百次,它就老老实实存一百条 record。头文件的注释特意强调了顺序语义(write_batch.h:7-14):

cpp 复制代码
// The updates are applied in the order in which they are added
// to the WriteBatch.  For example, the value of "key" will be "v3"
// after the following batch is written:
//
//    batch.Put("key", "v1");
//    batch.Delete("key");
//    batch.Put("key", "v2");
//    batch.Put("key", "v3");

为什么"按加入顺序生效"能成立?答案在序列号的分配方式上,下一节展开。

把短链"三合一"batch 的字节画出来(假设它分到的序列号是 1):

plain 复制代码
偏移    字节                         含义
------  ---------------------------  -------------------------------
0       01 00 00 00 00 00 00 00      seq = 1        (fixed64,8B)
8       03 00 00 00                  count = 3      (fixed32,4B)
12      01                           kTypeValue
13      07                           key_len = 7    (varint32)
14      "c:Ab3xK"                    key
21      23                           val_len = 35   (varint32,0x23)
22      "https://example.com/..."    value(35B)
57      01                           kTypeValue
58      0f                           key_len = 15
59      "u:robin:c:Ab3xK"            key
74      01                           val_len = 1
75      "1"                          value
76      01                           kTypeValue
77      0d                           key_len = 13
78      "c:Ab3xK:click"              key
91      01                           val_len = 1
92      "0"                          value
------  ---------------------------  -------------------------------
93                                   总计 93 字节

三个逻辑写入,93 字节,不需要标记事务"开始/结束",原子性靠后续流程保证这一批数据要么同时成功,要么同时失败。

还有个细节值得注意:seq 在这份布局里占了头 8 个字节,但用户 API 里对它完全无感------构造出来是 0,Put/Delete 也不更新它。它是留给 Write 路径的,调用方塞完数据把 batch 交出去之后,库会回头来填这个字段。

Iterate:一份字节,一个 Handler

写侧是纯追加,读侧是 Iterate(write_batch.cc:42-80 ):跳过 12 字节头,循环读 tag、按 tag 解出 key/value、回调 Handler 的对应方法:

cpp 复制代码
Status WriteBatch::Iterate(Handler* handler) const {
  Slice input(rep_);
  if (input.size() < kHeader) {
    return Status::Corruption("malformed WriteBatch (too small)");
  }

  input.remove_prefix(kHeader);
  Slice key, value;
  int found = 0;
  while (!input.empty()) {
    found++;
    char tag = input[0];
    input.remove_prefix(1);
    switch (tag) {
      case kTypeValue:
        if (GetLengthPrefixedSlice(&input, &key) &&
            GetLengthPrefixedSlice(&input, &value)) {
          handler->Put(key, value);
        } else {
          return Status::Corruption("bad WriteBatch Put");
        }
        break;
      case kTypeDeletion:
        if (GetLengthPrefixedSlice(&input, &key)) {
          handler->Delete(key);
        } else {
          return Status::Corruption("bad WriteBatch Delete");
        }
        break;
      default:
        return Status::Corruption("unknown WriteBatch tag");
    }
  }
  if (found != WriteBatchInternal::Count(this)) {
    return Status::Corruption("WriteBatch has wrong count");
  } else {
    return Status::OK();
  }
}

注意两点。

  1. 解析不依赖任何外部信息,长度前缀让每条 record 自描述,一个 tag 出现问题就地返回 Corruption,不会牵连后面的 record 边界。
  2. 末尾的校验:数出来的条数必须等于头部声明的 count------格式自检查。

Handler 是给消费方的空位:拿到 (key, value) 序列你打算干什么,WriteBatch 不关心。它最重要的消费者马上登场。

seq 传播:一批占一段连续序列号

Iterate 里用到了 Count,这个函数不在公共头文件里,而在 db/write_batch_internal.h 的 WriteBatchInternal 中。这个类的注释说明了它的身份(write_batch_internal.h:15-16):

cpp 复制代码
// WriteBatchInternal provides static methods for manipulating a
// WriteBatch that we don't want in the public WriteBatch interface.

一批不想暴露给用户的内部操作:Count/SetCount、Sequence/SetSequence、Contents、ByteSize、SetContents、InsertInto、Append。前几个的实现就是对着 rep_ 的固定位置做定点读写(write_batch.cc:82-96):

cpp 复制代码
int WriteBatchInternal::Count(const WriteBatch* b) {
  return DecodeFixed32(b->rep_.data() + 8);
}

SequenceNumber WriteBatchInternal::Sequence(const WriteBatch* b) {
  return SequenceNumber(DecodeFixed64(b->rep_.data()));
}

void WriteBatchInternal::SetSequence(WriteBatch* b, SequenceNumber seq) {
  EncodeFixed64(&b->rep_[0], seq);
}

现在可以回答上一节遗留的的问题了:batch 的 seq 头是什么时候填的?回到 04 篇读过的 DBImpl::Write,leader 合并完之后有这三行(db_impl.cc:1220-1222):

cpp 复制代码
WriteBatch* write_batch = BuildBatchGroup(&last_writer);
WriteBatchInternal::SetSequence(write_batch, last_sequence + 1);
last_sequence += WriteBatchInternal::Count(write_batch);

假设当前全局序列号是 s,这个 batch 有 n 条 record:头部 seq 被填成 s+1,然后全局序列号一口气推进 n,变成 s+n。也就是说,这个 batch 占用了 [s+1, s+n] 这一段连续序列号。

序列号具体怎么落到每条 record 上?看 InsertInto(write_batch.cc:132-137 )和它用的 Handler(write_batch.cc:115-130):

cpp 复制代码
// write_batch.cc:132-137
Status WriteBatchInternal::InsertInto(const WriteBatch* b, MemTable* memtable) {
  MemTableInserter inserter;
  inserter.sequence_ = WriteBatchInternal::Sequence(b);
  inserter.mem_ = memtable;
  return b->Iterate(&inserter);
}
cpp 复制代码
// write_batch.cc:115-130
namespace {
class MemTableInserter : public WriteBatch::Handler {
 public:
  SequenceNumber sequence_;
  MemTable* mem_;

  void Put(const Slice& key, const Slice& value) override {
    mem_->Add(sequence_, kTypeValue, key, value);
    sequence_++;
  }
  void Delete(const Slice& key) override {
    mem_->Add(sequence_, kTypeDeletion, key, Slice());
    sequence_++;
  }
};
}  // namespace

sequence_ 从批头那个 seq 出发,每回调一次自增一。批内第 1 条 record 拿 s+1,第 2 条拿 s+2......第 n 条拿 s+n,严格按加入顺序。这些序列号跟着 key 一起进 MemTable:MemTable::Add(memtable.cc:76 )把每个 user key 拼上一个 8 字节尾巴,尾巴由 EncodeFixed64(p, (s << 8) | type) 编码(memtable.cc:93 )------56 位序列号压在高位,8 位 ValueType 留在低位,这也是 kMaxSequenceNumber = (1 << 56) - 1(dbformat.h:67)的来历。同一个 user key 的多条版本靠这个尾巴分出新旧,具体的排序规则后面会详细讲解。

到这里,头文件那个"v3 例子"的机制就理顺了:Put v1、Delete、Put v2、Put v3 在批内依次拿到递增的序列号,而读取时同 key 的新序列号永远盖过旧序列号------所以最后留下的是 v3。顺序语义不是靠"执行顺序"保证的,是靠序列号的大小关系保证的,即使这四条操作被写进 SSTable、经过 Compaction 反复搬运,这个大小关系都不变。

序列号的连续分配还说明:一个 batch 的所有 record 共享同一个可见点 。DBImpl::Get 在持锁状态下取 versions_->LastSequence() 作为本次读取的可见上界(db_impl.cc:1124 ),而 Write 是在整批 InsertInto 完成之后才调 SetLastSequence 发布新序列号(db_impl.cc:1251 )。于是并发的读线程要么看到整批,要么一条都看不到,不存在"看见了第 1 条、看不见第 2 条"的中间态------哪怕 MemTableInserter 是一条一条往跳表里插的。(跳表本身支持无锁读,细节过两篇展开。)

还有一个小发现:Iterate 是 const 方法,InsertInto 拿到的却是非 const 的 sequence_------每条 record 的序列号不存在 record 里,而是遍历时现场数出来的。每条 record 里如果存 seq 要多 8 字节 × n 条,而"批头一个 seq + 顺序计数"把这份冗余全省了。代价是 Iterate 不能随机跳到第 i 条,必须从头数------对这个一次顺序遍历到底的场景,不构成问题。

一鱼两吃(还送一口):同一份字节的三份工作

现在把 Write 方法中间的那段再拿出来(04 篇读过锁的部分,这次只看 batch 相关的三行,db_impl.cc:1229-1241):

cpp 复制代码
mutex_.Unlock();
status = log_->AddRecord(WriteBatchInternal::Contents(write_batch));
bool sync_error = false;
if (status.ok() && options.sync) {
  status = logfile_->Sync();
  if (!status.ok()) {
    sync_error = true;
  }
}
if (status.ok()) {
  status = WriteBatchInternal::InsertInto(write_batch, mem_);
}
mutex_.Lock();

Contents 返回的就是 Slice(batch->rep_)(write_batch_internal.h:32)。也就是说------

  • 落入 WAL:rep_ 的 93 个字节原样交给 log_->AddRecord,成为一条日志 record。没有"每条 KV 一条日志"的转译,没有再序列化,写路径对 batch 的磁盘表达就是它的内存表达本身;
  • 进 MemTable:同一份字节掉头走一遍 Iterate,被 MemTableInserter 解析回一条条 KV 插进跳表。

同一份字节,两种用途,一份当日志写,一份当数据解。这是"一鱼两吃"。第三口在恢复路径:03 篇看过 RecoverLogFile 读 WAL 的现象,这次可以认出它的本质了(db_impl.cc:432-444):

cpp 复制代码
while (reader.ReadRecord(&record, &scratch) && status.ok()) {
  if (record.size() < 12) {
    reporter.Corruption(record.size(),
                        Status::Corruption("log record too small"));
    continue;
  }
  WriteBatchInternal::SetContents(&batch, record);

  if (mem == nullptr) {
    mem = new MemTable(internal_comparator_);
    mem->Ref();
  }
  status = WriteBatchInternal::InsertInto(&batch, mem);

从 WAL 读出的 record 字节被 SetContents 直接灌进一个 batch(还会顺手挡掉不足 12 字节头的残片),然后走与写路径完全相同的 InsertInto 进 MemTable。所谓恢复,就是把这批字节最后再吃一遍。

原子性的答案也藏在这份字节流的待遇里。WAL 的每条 record 自带 CRC 和长度(物理格式下一篇展开):写一半断电,残缺 record 在恢复时整个被丢弃,不会出现半截;record 完整,整批重放。提交的最小单位是"WAL 的一条 record",而一条 record 恰好就是一个完整的 batch------三合一的原子性就是这么来的,不需要任何 undo 日志。

要说边界,也有一个容易误会的地方:WriteBatch 有原子性,但没有回滚能力。InsertInto 只在字节流损坏时返回 Corruption,正常的内存插入不会半途失败;真正的危险点是 fsync 失败,而那种情况下库的处理是 04 篇读过的 RecordBackgroundError------直接关闭写通道,因为那条 record 在不在已经不可知了,宁可停机等人工介入,也不能假装无事发生。换句话说,LevelDB 的原子性是"提交点原子":一批要么整体跨过崩溃边界,要么整体没跨过去;它不像关系数据库那样支持"执行到一半撤销已做的部分"。这个差别我们在对比小节详细展开。

AddRecord 之后数据还只是在页缓存里,sync=false 时进程崩溃不丢、操作系统崩溃可能丢------这是下一篇的入场券,篇幅有限,这一篇我们先不讲。

回到 04 篇的合并:Append 的视角

有了格式知识,04 篇的合并拼图可以补上最后一块。BuildBatchGroup 用 WriteBatchInternal::Append 把追随者的 batch 拼进 tmp_batch_(db_impl.cc:1310-1316 ),而 Append 的全部逻辑是(write_batch.cc:144-148):

cpp 复制代码
void WriteBatchInternal::Append(WriteBatch* dst, const WriteBatch* src) {
  SetCount(dst, Count(dst) + Count(src));
  assert(src->rep_.size() >= kHeader);
  dst->rep_.append(src->rep_.data() + kHeader, src->rep_.size() - kHeader);
}

count 相加,body 直接拼接。不重排、不去重、不解析,追随者的 record 原样跟在队首后面。合并出的新批 seq 头是什么值?无所谓------反正 Write 随后会用 SetSequence 覆盖它(db_impl.cc:1221)。

于是 04 篇结尾的问题有了完整答案:八个线程各写一条,被 leader 合并成一个 count=8 的 batch,分到 [s+1, s+8] 八个连续序列号,分配顺序就是 writers_ 队列里的排队顺序。每个成员最终落在 MemTable 里的序列号,在它入队、被合并的那一刻就定下来了,而它自己对这一切毫不知情------醒来时只看到 done == true。

动手实践:三合一,与收益曲线的两端

机制读完,回到短链服务。创建短链的三合一代码(完整版见文末附录 create_link.cc):

cpp 复制代码
leveldb::WriteBatch batch;
batch.Put("c:" + code, target);               // 短码主体
batch.Put("u:" + user + ":c:" + code, "1");   // 用户 → 短码索引
batch.Put("c:" + code + ":click", "0");       // 点击计数初始化
s = db->Write(leveldb::WriteOptions(), &batch);

跑一下,三个 key 都在;再往同一个 batch 里对同一个 key 做 Put("1")、Delete、Put("2"),读回来是 2------批内顺序语义实测成立:

plain 复制代码
after batch:
  c:Ab3xK            -> https://example.com/a-very-long-url
  u:robin:c:Ab3xK    -> 1
  c:Ab3xK:click      -> 0
after put/delete/put on same key:
  c:Ab3xK:click      -> 2

原子性之外,batch 还有一层很现实的收益:摊薄固定成本 。一次 db->Write 的固定成本------抢锁、排队、一条 WAL record 的封装,尤其是 sync=true 时的一次 fsync------与批大小基本无关。用 batch_bench(代码见附录)固定总写入量、扫描批大小,本机(macOS,fsync 很慢,见 04 篇附注)实测,先看 sync=false,总量 100000 条:

批大小 吞吐(ops/s) 相对逐条写
1 145,511 1.00x
10 542,076 3.73x
100 930,657 6.39x
1000 958,911 6.59x
10000 1,078,935 7.42x
100000 1,236,354 8.50x

收益从批大小 10 就开始显现,100 之后趋于平缓------锁与队列的固定成本被摊得差不多了,剩下的提升来自更少的系统调用。

真正悬殊的是 sync=true,总量降到 2000 条(fsync 单价约 6~9ms):

批大小 耗时 吞吐(ops/s) 相对逐条写
1 12.883s 155 1.00x
10 1.388s 1,441 9.30x
100 0.166s 12,043 77.70x
1000 0.018s 111,539 719.00x
2000 0.008s 257,931 1,663.00x

逐条写 2000 条要 12.9 秒------每条一次 fsync;攒成 2000 条一个 batch,0.008 秒,快 1600 多倍。一个更有意思的对照:sync=true + 批 1000(111,539 ops/s)已经追平 sync=false + 逐条写(145,511 ops/s)。在这台 fsync 极慢的机器上,每 1000 条攒一次盘,就把持久化的钱基本赚了回来。数字与机器强相关,但"批越大、fsync 越摊越薄"的相对关系是通用的。

对比参照:三种强度的"原子"

"原子写"这个词,在三个熟悉的系统里指的东西并不一样。

Redis 的 MULTI/EXEC 。命令入队,EXEC 时由单线程一口气执行完,中途不会被别的客户端插进去------原子性由"单线程 + 执行期独占"保证。但它没有回滚:队列里某条命令执行出错,前面的效果保留,后面的继续执行,只把错误逐条报回来。Redis 官方文档对此的态度很坦率(Transactions 一页的 "Why Redis does not support roll backs" 小节):因为命令只在语法错误(入队阶段就拒绝)或类型错误时失败,而这些本可以在编程时避免,回滚机制不值得它的复杂度。LevelDB 的 batch 与此神似:追加即入队、按序应用、无回滚。差别在提交点的性质------Redis 的原子区间止于内存应用,持久化交给 AOF 策略另说;LevelDB 的原子区间一直延伸到 WAL record 的落盘边界。

InnoDB 的事务 。真正的"执行原子":事务中途任何一步失败,undo log 把已做的部分物理地翻回去,回到事务开始前的样子;配合 redo log 和隔离级别,还能在并发下维持各种一致性视图。代价是整套 undo/redo 双日志、回滚段、purge 机制。为什么 LevelDB 不需要?约束不同:InnoDB 面对的是任意大小、任意复杂度的事务,UPDATE 可能命中千万行,执行到一半失败是常态,必须有撤销手段;LevelDB 的 batch 在进 Write 之前就已经完整编码在内存里,"执行"只剩顺序应用,不存在应用一半失败再回头的场景------它把 InnoDB 需要 undo 兜底的那些中途失败,直接排除在了提交点之前。回滚机制服务于"可失败的复杂执行",简单执行的原子性用提交点就够。

RocksDB 。WriteBatch 的序列化格式几乎原样继承自 LevelDB,在此基础上长出了 WriteBatchWithIndex------给批内数据建索引,支持在提交前读到批里未生效的修改(read-your-own-writes)。单机批的"看不到自己未提交的写"这个不便,要在原格式上补一层索引才解决,也算从侧面说明了原设计把 batch 保持得多么克制。

工程启示

用好 LevelDB

  • 关联更新必须放进同一个 batch 。三合一创建短链是标准姿势:c:{code}、u:{user}:c:{code}、c:{code}:click 一次 Write。凡是"这几条记录应该同时出现/消失"的写法,都值得检查是否共批------跨 batch 的原子性,库帮不了你。
  • 持久化写入请配合批大小 。sync=true 逐条写是全额付费的 fsync;本机实测攒 1000 条一批,吞吐追平不落盘的逐条写。对"掉电不能丢"的写入,考虑积攒几百到几千条、或按几十毫秒的周期攒批 flush 一次。
  • 批大小有个实用上限:别超过 write_buffer_size。超限后每次写都触发 MemTable 切换并与 flush 串行化,实测吞吐回落。1MB 上下(与库自己的合并上限同量级)是安全区。
  • batch 有顺序语义,但没有隔离和回滚 。批内同 key 按 Put 的先后生效,可以放心写"先初始化再覆盖"的逻辑;但别把它当事务用------没有隔离级别,也没有 ROLLBACK,Write 返回错误时这批数据处于"有没有写进去不一定"的状态(fsync 失败路径),业务侧要么重试整个批,要么做好幂等。

设计借鉴

  • type + 长度前缀 的变长 record 加 Handler 回调,是一套可以搬走的编码模式。生产端纯追加、消费端按 tag 分发、格式自校验(条数对账),序列化与业务解耦------同一份字节流今天喂了三个消费者(WAL、MemTable、恢复重放),明天加一个消费场景只需要新写一个 Handler。需要持久化的队列、流水、binlog 类结构可以直接套。
  • 把"一组逻辑变更"编码成一个物理单元,崩溃一致性的边界就自动清楚了。不用两阶段提交、不用 undo,只要保证这个物理单元在存储层是"全或无"的(整条 record 加 CRC),一组操作的原子性就有了着落。反过来设计日志格式时,让"日志的最小单位"对齐"业务的最小原子单位",能省掉一整层恢复协议。
  • 冗余字段的取舍:批头一个 seq,胜过每条 record 一个 seq。序列号只存一次、遍历时按位置推出,省下的是 n × 8 字节的存储与传输。这个手法的前提是消费方永远顺序遍历------编码格式从来不是孤立的,它和消费模式是一起设计的。

到这里,05 篇的问题都有了着落:三合一靠"一个 batch = 一条 WAL record = 一段连续序列号"达成原子;合并后的序列号按队列顺序分配;批内顺序语义由序列号大小关系保证。但还给 06 篇留了一个小尾巴:log_->AddRecord 把 93 字节交出去之后,字节去了哪?一条 record 被切成 32KB 的 block 分片时发生了什么?写一半断电,恢复时的 CRC 检查和尾部截断,是怎么把"半个 batch"安全地丢掉的?

下一篇,进 WAL 的物理格式。


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

create_link.cc:三合一创建短链,顺带验证批内顺序语义。

cpp 复制代码
// create_link.cc:短链创建"三合一"------c:{code} 主体 + u:{user}:c:{code} 索引 + 计数初始化。
// 先用三次独立 Put 展示"写一半"的风险,再用一个 WriteBatch 原子完成,最后读回验证。
// 用法:create_link [dbpath]
//   例:./.output/create_link .output/batch-demo
// 编译(仓库根目录,macOS 用 clang++,Linux 用 g++):
//   clang++ -std=c++17 -I leveldb-1.23/include examples/shorturl/create_link.cc \
//           .output/build/libleveldb.a -o .output/create_link
#include <cstdio>
#include <string>

#include "leveldb/db.h"
#include "leveldb/write_batch.h"

static void Show(leveldb::DB* db, const char* key) {
  std::string value;
  leveldb::Status s = db->Get(leveldb::ReadOptions(), key, &value);
  if (s.ok()) {
    std::printf("  %-18s -> %s\n", key, value.c_str());
  } else {
    std::printf("  %-18s -> (%s)\n", key, s.ToString().c_str());
  }
}

int main(int argc, char* argv[]) {
  const std::string dbpath = argc > 1 ? argv[1] : ".output/batch-demo";
  const std::string user = "robin";
  const std::string code = "Ab3xK";
  const std::string target = "https://example.com/a-very-long-url";

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

  // ---- 方式一:三次独立 Put。第二条成功、第三条失败时,库里有索引却没主体,
  //      或者有主体却查不到索引------业务上都是脏数据。(这里只演示全部成功的情况)
  db->Put(leveldb::WriteOptions(), "c:" + code, target);
  db->Put(leveldb::WriteOptions(), "u:" + user + ":c:" + code, "1");
  db->Put(leveldb::WriteOptions(), "c:" + code + ":click", "0");

  // ---- 方式二:一个 WriteBatch 三合一,要么全部生效,要么全部不生效。
  leveldb::WriteBatch batch;
  batch.Put("c:" + code, target);                 // 短码主体
  batch.Put("u:" + user + ":c:" + code, "1");     // 用户 -> 短码索引
  batch.Put("c:" + code + ":click", "0");         // 点击计数初始化
  s = db->Write(leveldb::WriteOptions(), &batch);
  if (!s.ok()) {
    std::fprintf(stderr, "write batch error: %s\n", s.ToString().c_str());
    return 1;
  }

  std::printf("after batch:\n");
  Show(db, ("c:" + code).c_str());
  Show(db, ("u:" + user + ":c:" + code).c_str());
  Show(db, ("c:" + code + ":click").c_str());

  // ---- 同一个 batch 里对同一个 key 的多次操作,按加入顺序生效:
  //      点击一次 = 读改写计数,这里演示"先 Put 再 Put"的顺序语义。
  leveldb::WriteBatch click;
  click.Put("c:" + code + ":click", "1");
  click.Delete("c:" + code + ":click");
  click.Put("c:" + code + ":click", "2");
  db->Write(leveldb::WriteOptions(), &click);
  std::printf("after put/delete/put on same key:\n");
  Show(db, ("c:" + code + ":click").c_str());

  delete db;
  return 0;
}

batch_bench.cc:固定总写入量,扫描批大小,观察吞吐曲线与拐点。

cpp 复制代码
// batch_bench.cc:单线程批量写压测------固定总条数,扫描 batch 大小,观察收益曲线。
// 1000 次单 Put vs 1 个 1000 条 batch,就是 batch_size=1 与 batch_size=1000 的对比。
// 用法:batch_bench <dbpath> <total> <batch_size> [sync(0/1)]
//   例:./.output/batch_bench .output/bench-b100 100000 100
// 编译(仓库根目录,macOS 用 clang++,Linux 用 g++):
//   clang++ -std=c++17 -I leveldb-1.23/include examples/shorturl/batch_bench.cc \
//           .output/build/libleveldb.a -o .output/batch_bench
#include <chrono>
#include <cstdio>
#include <cstring>
#include <string>

#include "leveldb/db.h"
#include "leveldb/write_batch.h"

int main(int argc, char* argv[]) {
  if (argc < 4) {
    std::fprintf(stderr, "usage: %s <dbpath> <total> <batch_size> [sync(0/1)]\n",
                 argv[0]);
    return 1;
  }
  const std::string dbpath = argv[1];
  const long total = std::atol(argv[2]);
  const long batch_size = std::atol(argv[3]);
  const bool sync = argc > 4 && std::strcmp(argv[4], "1") == 0;

  leveldb::Options options;
  options.create_if_missing = 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::WriteOptions wo;
  wo.sync = sync;

  char key[32], val[64];
  long done = 0;
  const auto t0 = std::chrono::steady_clock::now();
  while (done < total) {
    // 每个batch独立构造、写完即弃,避免 Clear 复用带来的干扰
    leveldb::WriteBatch batch;
    for (long i = 0; i < batch_size && done < total; i++, done++) {
      std::snprintf(key, sizeof(key), "c:B%06ld", done);
      std::snprintf(val, sizeof(val), "https://example.com/%06ld", done);
      batch.Put(key, val);
    }
    s = db->Write(wo, &batch);
    if (!s.ok()) {
      std::fprintf(stderr, "write error: %s\n", s.ToString().c_str());
      return 1;
    }
  }
  const auto t1 = std::chrono::steady_clock::now();

  const double secs =
      std::chrono::duration_cast<std::chrono::microseconds>(t1 - t0).count() /
      1e6;
  std::printf("total=%ld batch_size=%ld sync=%d elapsed=%.3fs throughput=%.0f ops/s\n",
              total, batch_size, sync ? 1 : 0, secs, total / secs);

  delete db;
  return 0;
}
相关推荐
云浪4 天前
Go源码分析:搞懂 Go 是如何实现堆的
后端·go·源码阅读
Android打工仔5 天前
CoroutineScheduler 设计解析(上)—— 为什么 Dispatchers.IO 会创建更多线程?
android·kotlin·源码阅读
嘎嘎风6 天前
MiniMind 学习笔记之 04 注意力之外:位置、记忆、省显存、省时间、深加工
架构·源码阅读
嘎嘎风6 天前
MiniMind 学习笔记之 07 模型怎么学会猜词(旋钮是怎么被拧动的)
算法·源码阅读
嘎嘎风6 天前
MiniMind 学习笔记之 05 从 768 维到下一个 token
github·源码阅读
RobinXYuan9 天前
【LevelDB 源码阅读】03 | 打开一个数据库:DBImpl::Open 与恢复
源码阅读·leveldb
程序猿阿越11 天前
containerd如何拉取镜像
后端·kubernetes·源码阅读
RobinXYuan13 天前
02 | 当数据放不进内存:从一次 Put 说起
源码阅读
Amos_Web21 天前
Rspack 源码解析(十一):资产 Hook 与增量构建
前端·rust·源码阅读