上一篇结尾留了一个黑盒: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();
}
}
注意两点。
- 解析不依赖任何外部信息,长度前缀让每条 record 自描述,一个 tag 出现问题就地返回
Corruption,不会牵连后面的 record 边界。 - 末尾的校验:数出来的条数必须等于头部声明的 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;
}