ARTICLE · 1150350
【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 时其实已经见过它三次了:
DB::Put 的实现就是包一个单条目的 batch 转手交给 Write(db_impl.cc:1469-1473),Delete 同理(db_impl.cc:1475-1479)——对 Write 来说,世界上没有“单条写”这个概念,只有 batch;
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();}}
注意两点。
解析不依赖任何外部信息,长度前缀让每条 record 自描述,一个 tag 出现问题就地返回 Corruption,不会牵连后面的 record 边界。
末尾的校验:数出来的条数必须等于头部声明的 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)。也就是说——

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

进 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。
机制读完,回到短链服务。创建短链的三合一代码(完整版见文末附录 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-urlu:robin:c:Ab3xK -> 1c: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 条:

收益从批大小 10 就开始显现,100 之后趋于平缓——锁与队列的固定成本被摊得差不多了,剩下的提升来自更少的系统调用。
真正悬殊的是 sync=true,总量降到 2000 条(fsync 单价约 6~9ms):

| 9.30x | |||
| 77.70x | |||
| 719.00x | |||
| 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 越摊越薄”的相对关系是通用的。
“攒一批、刷一次”这个思路在数据库圈有个通用名字:group commit。对照几个熟悉的实现,能看清 LevelDB 的取舍在哪。
命令入队,EXEC 时由单线程一口气执行完,中途不会被别的客户端插进去——原子性由“单线程 + 执行期独占”保证。但它没有回滚:队列里某条命令执行出错,前面的效果保留,后面的继续执行,只把错误逐条报回来。
Redis 官方文档对此的态度很坦率(Transactions 一页的 “Why Redis does not support roll backs” 小节):因为命令只在语法错误(入队阶段就拒绝)或类型错误时失败,而这些本可以在编程时避免,回滚机制不值得它的复杂度。LevelDB 的 batch 与此神似:追加即入队、按序应用、无回滚。差别在提交点的性质——Redis 的原子区间止于内存应用,持久化交给 AOF 策略另说;LevelDB 的原子区间一直延伸到 WAL record 的落盘边界。
真正的“执行原子”:事务中途任何一步失败,undo log 把已做的部分物理地翻回去,回到事务开始前的样子;配合 redo log 和隔离级别,还能在并发下维持各种一致性视图。代价是整套 undo/redo 双日志、回滚段、purge 机制。
为什么 LevelDB 不需要?约束不同:InnoDB 面对的是任意大小、任意复杂度的事务,UPDATE 可能命中千万行,执行到一半失败是常态,必须有撤销手段;LevelDB 的 batch 在进 Write 之前就已经完整编码在内存里,“执行”只剩顺序应用,不存在应用一半失败再回头的场景——它把 InnoDB 需要 undo 兜底的那些中途失败,直接排除在了提交点之前。回滚机制服务于“可失败的复杂执行”,简单执行的原子性用提交点就够。
WriteBatch 的序列化格式几乎原样继承自 LevelDB,在此基础上长出了 WriteBatchWithIndex——给批内数据建索引,支持在提交前读到批里未生效的修改(read-your-own-writes)。单机批的“看不到自己未提交的写”这个不便,要在原格式上补一层索引才解决,也算从侧面说明了原设计把 batch 保持得多么克制。
三合一创建短链是标准姿势:c:{code}、u:{user}:c:{code}、c:{code}:click 一次 Write。凡是“这几条记录应该同时出现/消失”的写法,都值得检查是否共批——跨 batch 的原子性,库帮不了你。
sync=true 逐条写是全额付费的 fsync;本机实测攒 1000 条一批,吞吐追平不落盘的逐条写。对“掉电不能丢”的写入,考虑积攒几百到几千条、或按几十毫秒的周期攒批 flush 一次。
超限后每次写都触发 MemTable 切换并与 flush 串行化,实测吞吐回落。1MB 上下(与库自己的合并上限同量级)是安全区。
批内同 key 按 Put 的先后生效,可以放心写“先初始化再覆盖”的逻辑;但别把它当事务用——没有隔离级别,也没有 ROLLBACK,Write 返回错误时这批数据处于“有没有写进去不一定”的状态(fsync 失败路径),业务侧要么重试整个批,要么做好幂等。
生产端纯追加、消费端按 tag 分发、格式自校验(条数对账),序列化与业务解耦——同一份字节流今天喂了三个消费者(WAL、MemTable、恢复重放),明天加一个消费场景只需要新写一个 Handler。需要持久化的队列、流水、binlog 类结构可以直接套。
不用两阶段提交、不用 undo,只要保证这个物理单元在存储层是“全或无”的(整条 record 加 CRC),一组操作的原子性就有了着落。反过来设计日志格式时,让“日志的最小单位”对齐“业务的最小原子单位”,能省掉一整层恢复协议。
序列号只存一次、遍历时按位置推出,省下的是 n × 8 字节的存储与传输。这个手法的前提是消费方永远顺序遍历——编码格式从来不是孤立的,它和消费模式是一起设计的。
到这里,05 篇的问题都有了着落:三合一靠“一个 batch = 一条 WAL record = 一段连续序列号”达成原子;合并后的序列号按队列顺序分配;批内顺序语义由序列号大小关系保证。但还给 06 篇留了一个小尾巴:`log_->AddRecord` 把 93 字节交出去之后,字节去了哪?一条 record 被切成 32KB 的 block 分片时发生了什么?写一半断电,恢复时的 CRC 检查和尾部截断,是怎么把“半个 batch”安全地丢掉的?
下一篇,进 WAL 的物理格式。
// 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 大小,观察收益曲线。// 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;}
