夜雨聆风学习资料网

ARTICLE · 1150350

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

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

05 原子写的载体

WriteBatch

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

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

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

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

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

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

01

到处都是 WriteBatch

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

01

DB::Put 的实现就是包一个单条目的 batch 转手交给 Write(db_impl.cc:1469-1473),Delete 同理(db_impl.cc:1475-1479)——对 Write 来说,世界上没有“单条写”这个概念,只有 batch;

02
BuildBatchGroup 把多个 writer 的 batch 用 Append 拼成一个大 batch(04 篇读过的 tmp_batch_);
03
恢复时从 WAL 读出的 record,被 SetContents 直接当成 batch 用,再走一遍 InsertInto(db_impl.cc:438、444)。
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)的公共接口很精炼:

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 文件里瞅瞅。

02

探索 rep_

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

// 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:

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。

void WriteBatch::Clear() {  rep_.clear();  rep_.resize(kHeader);}

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

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 的代码:

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):

// 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):

偏移    字节                         含义------  ---------------------------  -------------------------------0       01 00 00 00 00 00 00 00      seq = 1        (fixed64,8B)8       03 00 00 00                  count = 3      (fixed32,4B)12      01                           kTypeValue13      07                           key_len = 7    (varint32)14      "c:Ab3xK"                    key21      23                           val_len = 35   (varint32,0x23)22      "https://example.com/..."    value(35B)57      01                           kTypeValue58      0f                           key_len = 1559      "u:robin:c:Ab3xK"            key74      01                           val_len = 175      "1"                          value76      01                           kTypeValue77      0d                           key_len = 1378      "c:Ab3xK:click"              key91      01                           val_len = 192      "0"                          value------  ---------------------------  -------------------------------93                                   总计 93 字节

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

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

03

Iterate:一份字节,一个 Handler

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

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

注意两点。

01

解析不依赖任何外部信息,长度前缀让每条 record 自描述,一个 tag 出现问题就地返回 Corruption,不会牵连后面的 record 边界。

02

末尾的校验:数出来的条数必须等于头部声明的 count——格式自检查。

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

04

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

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

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

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

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):

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):

// write_batch.cc:132-137Status WriteBatchInternal::InsertInto(const WriteBatch* b, MemTable* memtable) {  MemTableInserter inserter;  inserter.sequence_ = WriteBatchInternal::Sequence(b);  inserter.mem_ = memtable;  return b->Iterate(&inserter);}
// write_batch.cc:115-130namespace {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 条,必须从头数——对这个一次顺序遍历到底的场景,不构成问题。

05

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

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

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)。也就是说——

01

落入 WAL:rep_ 的 93 个字节原样交给 log_->AddRecord,成为一条日志 record。没有“每条 KV 一条日志”的转译,没有再序列化,写路径对 batch 的磁盘表达就是它的内存表达本身;

02

进 MemTable:同一份字节掉头走一遍 Iterate,被 MemTableInserter 解析回一条条 KV 插进跳表。

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

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 时进程崩溃不丢、操作系统崩溃可能丢——这是下一篇的入场券,篇幅有限,这一篇我们先不讲。

06

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

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

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。

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

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

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——批内顺序语义实测成立:

after batch:  c:Ab3xK            -> https://example.com/a-very-long-url  u:robin:c:Ab3xK    -> 1  c:Ab3xK:click      -> 0after 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 条:

sync=false
批大小
吞吐(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):

sync=true
批大小
耗时
吞吐(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 越摊越薄”的相对关系是通用的。

08
对比参照:三种强度的“原子”

“攒一批、刷一次”这个思路在数据库圈有个通用名字:group commit。对照几个熟悉的实现,能看清 LevelDB 的取舍在哪。

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 保持得多么克制。

09
工程启示
用好 LevelDB
关联更新必须放进同一个 batch
01

三合一创建短链是标准姿势:c:{code}、u:{user}:c:{code}、c:{code}:click 一次 Write。凡是“这几条记录应该同时出现/消失”的写法,都值得检查是否共批——跨 batch 的原子性,库帮不了你。

持久化写入请配合批大小
02

sync=true 逐条写是全额付费的 fsync;本机实测攒 1000 条一批,吞吐追平不落盘的逐条写。对“掉电不能丢”的写入,考虑积攒几百到几千条、或按几十毫秒的周期攒批 flush 一次。

批大小有个实用上限:别超过 `write_buffer_size`
03

超限后每次写都触发 MemTable 切换并与 flush 串行化,实测吞吐回落。1MB 上下(与库自己的合并上限同量级)是安全区。

batch 有顺序语义,但没有隔离和回滚
04

批内同 key 按 Put 的先后生效,可以放心写“先初始化再覆盖”的逻辑;但别把它当事务用——没有隔离级别,也没有 ROLLBACK,Write 返回错误时这批数据处于“有没有写进去不一定”的状态(fsync 失败路径),业务侧要么重试整个批,要么做好幂等。

设计借鉴
type + 长度前缀 的变长 record 加 Handler 回调,是一套可以搬走的编码模式
01

生产端纯追加、消费端按 tag 分发、格式自校验(条数对账),序列化与业务解耦——同一份字节流今天喂了三个消费者(WAL、MemTable、恢复重放),明天加一个消费场景只需要新写一个 Handler。需要持久化的队列、流水、binlog 类结构可以直接套。

把“一组逻辑变更”编码成一个物理单元,崩溃一致性的边界就自动清楚了
02

不用两阶段提交、不用 undo,只要保证这个物理单元在存储层是“全或无”的(整条 record 加 CRC),一组操作的原子性就有了着落。反过来设计日志格式时,让“日志的最小单位”对齐“业务的最小原子单位”,能省掉一整层恢复协议。

冗余字段的取舍:批头一个 seq,胜过每条 record 一个 seq
03

序列号只存一次、遍历时按位置推出,省下的是 n × 8 字节的存储与传输。这个手法的前提是消费方永远顺序遍历——编码格式从来不是孤立的,它和消费模式是一起设计的。

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

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

A
附录:本篇实验代码
create_link.cc:三合一创建短链,顺带验证批内顺序语义
// 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:固定总写入量,扫描批大小,观察吞吐曲线与拐点
// 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;}
LevelDB 源码阅读
微信号:rxynotes

相关学习资料