今日主题#
- 主主题:
Write Path / WriteBatch / Sequence Number - 副主题:
WriteThread 如何把多个写请求折叠成一个 group
学习目标#
- 讲清用户一次
Put/Write最终是如何走到DBImpl::WriteImpl()的。 - 讲清
WriteBatch在写路径里到底扮演什么角色,它为什么要在 header 里保存sequence + count。 - 讲清 sequence number 是什么时候分配的、分配给谁、又是怎么传进 memtable 的。
- 讲清
WriteThread为什么要分 leader / follower,以及 batch group 的边界是怎么定的。 - 给后面学习
WAL、MemTable、Snapshot / 可见性留出清晰的源码入口。
前置回顾#
- Day 002 结束在
DB::Open -> Recover -> CreateWAL -> LogAndApplyForRecovery -> InstallSuperVersion。 - 那一章解决的是“数据库如何从磁盘和 WAL 恢复成可服务状态”。
- Day 003 要接着回答另一个问题:数据库已经 open 起来以后,新的写请求是如何被接进去的。
源码入口#
D:\program\rocksdb\include\rocksdb\db.hD:\program\rocksdb\include\rocksdb\write_batch.hD:\program\rocksdb\db\db_impl\db_impl_write.ccD:\program\rocksdb\db\write_thread.hD:\program\rocksdb\db\write_thread.ccD:\program\rocksdb\db\write_batch_internal.hD:\program\rocksdb\db\write_batch.ccD:\program\rocksdb\db\dbformat.h
它解决什么问题#
写路径真正要同时满足 4 件事:
- 给每次写入分配全局有序的
sequence number,后续读路径和 snapshot 都要靠它判断可见性。 - 把这次更新可靠地落到
WAL,保证崩溃恢复时不会丢掉已经承诺成功的写。 - 把更新写进
memtable,让前台读尽快看见它。 - 在高并发写入下避免每个线程都单独抢锁、单独写 WAL、单独更新共享状态,于是需要
WriteThread做 batching。
一句话概括:
WriteImpl() 要把“用户提交的一批逻辑更新”变成“带有有序 sequence 的 WAL 记录和 memtable 状态变化”。
它是怎么工作的#
先看主流程图:
sequenceDiagram
participant U as User Thread
participant DB as DBImpl::WriteImpl
participant WT as WriteThread
participant WAL as WAL Writer
participant MEM as MemTable
participant VS as VersionSet
U->>DB: Write(options, WriteBatch)
DB->>WT: JoinBatchGroup(writer)
WT-->>DB: 当前线程成为 leader 或 follower
alt 当前线程是 leader
DB->>WT: EnterAsBatchGroupLeader()
DB->>DB: 统计 group / 计算 seq_inc
DB->>WAL: WriteGroupToWAL(...)
WAL-->>DB: sequence 起点已写入 WAL
DB->>MEM: InsertInto(...)
DB->>VS: SetLastSequence(last_sequence)
DB->>WT: ExitAsBatchGroupLeader(...)
else 当前线程是 follower
WT-->>U: 等 leader 代写或并行写 memtable
end再换成更短的骨架:
- 用户线程进入
DBImpl::WriteImpl() - 注册成一个
WriteThread::Writer - 进入
JoinBatchGroup()排队 - leader 决定这次 group 里要包含哪些 writer
- leader 计算这组写一共要消耗多少 sequence
- leader 把合并后的 batch 写进 WAL
- 再把对应 batch 插进 memtable
- 最后发布
LastSequence
这里最重要的顺序是:
先拿到 sequence 范围 -> 写 WAL -> 写 memtable -> 发布 last_sequence
关键数据结构与实现点#
WriteBatch#
- 是一次逻辑写入的容器,不只是一个“操作列表”。
- 它的 header 直接带着:
sequencecount
- 写 WAL 时,这个 batch 会带着起始 sequence 一起落盘。
- 写 memtable 时,
Iterate(&inserter)会按顺序把每条记录展开,并随着inserter.sequence()递增。
WriteThread::Writer#
- 表示一个正在等待或执行的写请求。
- 它保存:
batchstatesequencewal_used- 各类 callback
sequence不是在用户线程创建 batch 时写好的,而是在真正执行写路径时才填进去。
WriteThread::WriteGroup#
- 一次由 leader 领衔的批量写分组。
- 关注三个字段:
leaderlast_writerlast_sequence
- 它是“这一组请求共享一次 WAL 写入和一次顺序推进”的边界。
SequenceNumber#
- 在
dbformat.h中是 RocksDB 内部 key 语义的核心组成部分。 - 它最终会和
ValueType一起编码进 internal key。 - Day 003 先只抓住一点:
- 写路径负责分配 sequence
- 读路径和 snapshot 后面会消费它
源码细读#
这次抓 8 个片段,分别回答 8 个问题。
1. 用户写请求最后收敛到哪里#
// db/db_impl/db_impl_write.cc, DBImpl::Write(...)
Status DBImpl::Write(const WriteOptions& write_options, WriteBatch* my_batch) {
Status s;
...
if (s.ok()) {
s = WriteImpl(write_options, my_batch, /*callback=*/nullptr,
/*user_write_cb=*/nullptr,
/*wal_used=*/nullptr);
}
return s;
}这一段的意义很简单,但值得明确记住:
- 对前台正常写入来说,真正的核心入口不是
Put(),而是WriteImpl()。 - 后面看写路径时,应该优先顺着
DBImpl::WriteImpl()读,而不是在各种 API overload 里绕。
2. WriteBatch 为什么能承载 sequence#
write_batch.cc 开头已经把格式说得很直白了:
// db/write_batch.cc, WriteBatch::rep_ 格式说明
// WriteBatch::rep_ :=
// sequence: fixed64
// count: fixed32
// data: record[count]
// ...
再看真正读写 header 的代码:
// db/write_batch.cc, WriteBatchInternal::{Count, Sequence, SetSequence}
uint32_t 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_.data(), seq);
}这里要抓住两个点:
WriteBatch不是纯逻辑对象,它的二进制表示天然适合直接写 WAL。SetSequence()说明 sequence 并不是每条记录单独先写好,而是先写 batch header,再在遍历 batch 时递增使用。
3. 写线程为什么先变成 Writer#
先看 Writer 里最关键的几个字段:
// db/write_thread.h, class WriteThread::Writer
struct Writer {
WriteBatch* batch;
...
std::atomic<uint8_t> state;
WriteGroup* write_group;
SequenceNumber sequence; // 当前 writer 第一条 key 使用的 sequence
Status status;
...
};这段说明了一个容易忽略的事实:
- RocksDB 不是直接把“线程”排队,而是把“写请求描述符”排队。
- 这让 leader 可以代别的线程完成一部分工作,比如:
- 合并 WAL 写入
- 直接帮 follower 完成顺序 memtable 写
- 或通知 follower 并行写 memtable
4. JoinBatchGroup() 到底做了什么#
// db/write_thread.cc, WriteThread::JoinBatchGroup(...)
void WriteThread::JoinBatchGroup(Writer* w) {
...
bool linked_as_leader = LinkOne(w, &newest_writer_);
w->CheckWriteEnqueuedCallback();
if (linked_as_leader) {
SetState(w, STATE_GROUP_LEADER);
}
...
if (!linked_as_leader) {
...
AwaitState(w,
STATE_GROUP_LEADER | STATE_MEMTABLE_WRITER_LEADER |
STATE_PARALLEL_MEMTABLE_CALLER |
STATE_PARALLEL_MEMTABLE_WRITER | STATE_COMPLETED,
&jbg_ctx);
...
}
}这一段说明:
- 每个写请求先挂到
newest_writer_链上。 - 如果自己是第一个,就直接变 leader。
- 如果不是,就等待 leader 分配角色。
也就是说,JoinBatchGroup() 不是“马上写”,而是“先进入一个写执行编排系统”。
5. leader 怎么决定哪些 writer 能并进来#
// db/write_thread.cc, WriteThread::EnterAsBatchGroupLeader(...)
size_t WriteThread::EnterAsBatchGroupLeader(Writer* leader,
WriteGroup* write_group) {
...
size_t size = WriteBatchInternal::ByteSize(leader->batch);
size_t max_size = max_write_batch_group_size_bytes;
const uint64_t min_batch_size_bytes = max_write_batch_group_size_bytes / 8;
if (size <= min_batch_size_bytes) {
max_size = size + min_batch_size_bytes;
}
...
while (w != newest_writer) {
...
if ((w->sync && !leader->sync) ||
(w->no_slowdown != leader->no_slowdown) ||
(w->disable_wal != leader->disable_wal) ||
(w->protection_bytes_per_key != leader->protection_bytes_per_key) ||
(w->rate_limiter_priority != leader->rate_limiter_priority) ||
(w->batch == nullptr) ||
(w->callback != nullptr && !w->callback->AllowWriteBatching()) ||
(size + WriteBatchInternal::ByteSize(w->batch) > max_size) ||
(leader->ingest_wbwi || w->ingest_wbwi)) {
...
} else {
w->write_group = write_group;
size += WriteBatchInternal::ByteSize(w->batch);
write_group->last_writer = w;
write_group->size++;
}
}
...
}这一段是 Day 003 很关键的“去神秘化”片段。
它说明 batch group 并不是“谁排在后面就都并进来”,而是要满足兼容条件:
- sync 语义兼容
- slowdown 语义兼容
disableWAL兼容- callback 愿意被 batching
- 总大小不能太大
所以 WriteThread 的 batching 不是粗暴拼单,而是“在语义兼容前提下做折叠”。
6. WriteImpl() 主流程里 sequence 是怎么被算出来的#
先看主链里最关键的一段:
// db/db_impl/db_impl_write.cc, DBImpl::WriteImpl(...)
write_thread_.JoinBatchGroup(&w);
...
last_batch_group_size_ =
write_thread_.EnterAsBatchGroupLeader(&w, &write_group);
...
size_t total_count = 0;
size_t valid_batches = 0;
...
for (auto* writer : write_group) {
...
if (writer->ShouldWriteToMemtable()) {
total_count += WriteBatchInternal::Count(writer->batch);
...
}
}
...
seq_inc = seq_per_batch_ ? valid_batches : total_count;
...
if (!two_write_queues_) {
...
io_s = WriteGroupToWAL(write_group, wal_context.writer, wal_used,
wal_context.need_wal_sync,
wal_context.need_wal_dir_sync, last_sequence + 1,
*wal_context.wal_file_number_size);
} else {
...
io_s = ConcurrentWriteGroupToWAL(write_group, wal_used, &last_sequence,
seq_inc);
}
...
const SequenceNumber current_sequence = last_sequence + 1;
last_sequence += seq_inc;
// 当前 group 分配到的 sequence 区间是 [current_sequence, last_sequence]
这段代码回答了 Day 003 的核心问题:
- 不是一进
WriteImpl()就立刻拿 sequence。 - leader 先知道这一组 group 总共要消耗多少 sequence。
- 然后把整个区间一次性划出来。
如果你以后读源码时只记一个结论,就记这个:
sequence 是按 write group 统一预留的,不是每条 key 临时抢一个。
7. sequence 为什么会先进 WAL#
// db/db_impl/db_impl_write.cc, DBImpl::WriteGroupToWAL(...)
io_s = status_to_io_status(MergeBatch(write_group, &tmp_batch_, &merged_batch,
&write_with_wal, &to_be_cached_state));
...
WriteBatchInternal::SetSequence(merged_batch, sequence);
...
io_s = WriteToWAL(*merged_batch, write_options, log_writer, wal_used,
&log_size, wal_file_number_size, sequence);再看 WriteToWAL():
// db/db_impl/db_impl_write.cc, DBImpl::WriteToWAL(...)
Slice log_entry = WriteBatchInternal::Contents(&merged_batch);
...
io_s = log_writer->AddRecord(write_options, log_entry, sequence);
...
if (wal_used != nullptr) {
*wal_used = cur_wal_number_;
...
}这里要看清两层含义:
- leader 可能会先把多个 writer 的 batch merge 成一个大 batch。
- 然后给这个 merged batch 写入起始 sequence,再整体落 WAL。
这就是为什么恢复时可以从 WAL replay 出相同顺序:
- WAL 里已经带着这批写的 sequence 起点
- replay 时再按相同 batch 顺序展开,就能复原出相同的 sequence 递增过程
8. WAL 写完后,sequence 又是怎么传给 memtable 的#
先看 group 版本的 InsertInto():
// db/write_batch.cc, WriteBatchInternal::InsertInto(WriteGroup&, ...)
Status WriteBatchInternal::InsertInto(
WriteThread::WriteGroup& write_group, SequenceNumber sequence,
ColumnFamilyMemTables* memtables, FlushScheduler* flush_scheduler,
TrimHistoryScheduler* trim_history_scheduler,
bool ignore_missing_column_families, uint64_t recovery_log_number, DB* db,
bool seq_per_batch, bool batch_per_txn) {
MemTableInserter inserter(
sequence, memtables, flush_scheduler, trim_history_scheduler,
ignore_missing_column_families, recovery_log_number, db,
/* 并发 memtable 写关闭 */ false, nullptr, nullptr, seq_per_batch,
batch_per_txn);
for (auto w : write_group) {
...
w->sequence = inserter.sequence();
...
SetSequence(w->batch, inserter.sequence());
...
w->status = w->batch->Iterate(&inserter);
...
}
return Status::OK();
}再对照 WriteImpl() 里真正调用它的地方:
// db/db_impl/db_impl_write.cc, DBImpl::WriteImpl(...)
if (!parallel) {
w.status = WriteBatchInternal::InsertInto(
write_group, current_sequence, column_family_memtables_.get(),
&flush_scheduler_, &trim_history_scheduler_,
write_options.ignore_missing_column_families,
0 /* recovery_log_number */, this, seq_per_batch_, batch_per_txn_);
} else {
write_group.last_sequence = last_sequence;
write_thread_.LaunchParallelMemTableWriters(&write_group);
...
w.status = WriteBatchInternal::InsertInto(
&w, w.sequence, &column_family_memtables, &flush_scheduler_,
&trim_history_scheduler_,
write_options.ignore_missing_column_families, 0 /* recovery_log_number */,
this, true /* 并发 memtable 写 */, seq_per_batch_,
w.batch_cnt, batch_per_txn_,
write_options.memtable_insert_hint_per_batch);
}这两段合起来说明:
- WAL 成功后,memtable 插入并不是重新分配 sequence。
- 它直接沿用刚才那段已经预留好的 sequence 区间。
- 顺序模式下,leader 串行推进
MemTableInserter.sequence()。 - 并行模式下,每个 writer 先拿到自己的起始
w.sequence,再各自并发插入。
9. 最后什么时候才正式“发布”这次写入#
// db/db_impl/db_impl_write.cc, DBImpl::WriteImpl(...)
if (should_exit_batch_group) {
if (status.ok()) {
...
if (w.status.ok()) { // 不发布部分成功的 batch 写入
versions_->SetLastSequence(last_sequence);
}
}
...
write_thread_.ExitAsBatchGroupLeader(write_group, status);
}这里和 Day 002 能连起来:
- Day 002 学的是 open 阶段如何恢复
LastSequence - Day 003 学的是运行时如何推进
LastSequence
也就是说,LastSequence 既是恢复后的起点,也是实时写入持续向前推进的全局游标。
常见误区或易混点#
误区 1:WriteBatch 一创建就自带 sequence#
不对。WriteBatch 创建时只是“有一个预留好的 header 位置”,真正的 sequence 是在写路径执行时通过 SetSequence() 填进去的。
误区 2:每条 key 都单独去全局抢 sequence#
不对。
常见路径下是 leader 先为整个 write group 预留一个连续区间,再由 batch / memtable 插入逻辑顺序消耗。
误区 3:JoinBatchGroup() 之后 follower 就什么都不做#
不一定。
如果启用并发 memtable 写,follower 可能会被唤醒去做自己的 memtable 插入。
误区 4:SetLastSequence() 就等于已经写 WAL#
不对。SetLastSequence() 是更靠后的“发布”动作,前面已经经历了 WAL 写入和 memtable 插入。
设计动机#
这一章值得补一个 设计动机,因为这里有很明显的取舍。
RocksDB 不让每个写线程各自独立完成“分配 sequence + 写 WAL + 写 memtable”,主要是为了压缩共享状态上的竞争:
- sequence 分配希望保持连续有序
- WAL 追加希望尽量合并、减少小写
- 共享队列和统计更新希望集中处理
代价是:
- 写路径实现明显更复杂
- 需要维护 leader / follower / 并发 memtable writer 这些状态机
- 读源码时如果没有先抓骨架,很容易迷失在分支里
工程启发#
- 把“请求排队”和“真正执行”拆开,是一种很强的并发控制套路。
- 先为一组请求统一预留逻辑顺序,再局部展开执行,能显著降低全局共享元数据的竞争。
- 如果系统既要吞吐又要顺序语义,
group + publish往往比“每条请求独立提交”更稳。 - RocksDB 的日志聚合是机会主义的,而不是时间窗口驱动的:
- 它不主动等待更多请求来凑批,因此不会为了 batching 人为拉长单次写延迟。
- 但一旦并发写自然形成排队,leader 又能立刻把兼容请求折叠进同一个
write group,把一次 WAL append 扩展成多请求共享。 - 这种设计说明 RocksDB 更偏向“优先覆盖更宽的 workload 范围,再从自然形成的并发中提取聚合收益”,而不是“为了追求更大批次而主动攒批”。
今日问题与讨论#
我的问题#
问题 1:为什么 sequence 不是在用户构造 WriteBatch 时就确定#
- 简答:
- 因为 sequence 必须反映“真正进入数据库的全局顺序”,而不是用户在线程本地创建 batch 的时间点。
- 源码依据:
DBImpl::WriteImpl()中 leader 先统计 group,再在WriteGroupToWAL()/ConcurrentWriteGroupToWAL()中推进 sequence。WriteBatchInternal::SetSequence()在真正写路径执行时才被调用。
- 当前结论:
- sequence 属于数据库运行时分配的提交顺序,不属于 API 层的提前编号。
- 是否需要后续回看:
yes,后面学 snapshot / 可见性时还要回来验证这件事。
问题 2:为什么 WAL 写完后还要再把 batch 的 sequence 填给 memtable#
- 简答:
- 因为 memtable 和恢复路径都需要以相同顺序解释这批记录;WAL 和 memtable 不是两套独立编号。
- 源码依据:
WriteGroupToWAL()先SetSequence(merged_batch, sequence)再写 WAL。WriteBatchInternal::InsertInto()再SetSequence(w->batch, inserter.sequence())后迭代插入 memtable。
- 当前结论:
- 同一套 sequence 同时服务于持久化顺序和内存可见性顺序。
- 是否需要后续回看:
yes,读路径和 WAL 章节还要继续细化。
问题 3:RocksDB 的一次前台写请求,是不是写入 memtable 后就算结束;memtable -> imm -> SST -> L0 是否都是后台进行#
- 简答:
- 对默认写路径来说,前台写请求通常在“
WAL已写入 +memtable已更新 +LastSequence已发布”时就算完成。 - 从
imm memtableflush 成SST / L0通常是后台线程做的。 - 但有一个细节不能说得过满:
mem -> imm的切换本身,有时会在前台写线程里触发;后台做的主要是 flush job 和后续 compaction。
- 对默认写路径来说,前台写请求通常在“
- 源码依据:
db/db_impl/db_impl_write.cc中DBImpl::WriteImpl(...)先WriteGroupToWAL(...),再WriteBatchInternal::InsertInto(...),最后versions_->SetLastSequence(last_sequence)。db/db_impl/db_impl_write.cc中ScheduleFlushes(...) / HandleWriteBufferManagerFlush(...)会在前台写路径里调用SwitchMemtable(...),把当前 mutable memtable 挂到imm()上,并新建一个 memtable。db/db_impl/db_impl_write.cc中SwitchMemtable(...)的关键动作是:cfd->imm()->Add(cfd->mem(), ...)cfd->SetMemtable(new_mem)InstallSuperVersionAndScheduleWork(...)
db/db_impl/db_impl_compaction_flush.cc中MaybeScheduleFlushOrCompaction()通过env_->Schedule(&DBImpl::BGWorkFlush, ...)把 flush 任务调度到后台线程。db/db_impl/db_impl_compaction_flush.cc中BackgroundCallFlush(...)/BackgroundFlush(...)/FlushMemTablesToOutputFiles(...)才真正把 immutable memtable 刷成 SST。
- 当前结论:
- 可以把“用户写请求成功”理解为前台先完成了可恢复性和可见性:
- 默认情况下:
WAL + memtable - 然后发布
LastSequence
- 默认情况下:
- 而
imm -> SST -> L0以及更后面的 compaction,通常属于后台整理 LSM 的过程。 - 但不要把“写请求结束”误解成“这次写一定还停留在 mutable memtable 里”;在写压、WAL 切换、write buffer manager 触发 flush 等情况下,前台线程可能顺手把旧 memtable 切成 immutable。
- 可以把“用户写请求成功”理解为前台先完成了可恢复性和可见性:
- 是否需要后续回看:
yes,等 Day 004WAL、Day 005MemTable / SkipList / Arena、Day 006Flush时继续压实这条边界。
问题 4:RocksDB 里看到 pipelined write,它的大概逻辑是什么#
- 简答:
pipelined write不是改变“先 WAL、后 memtable”的基本语义,而是把这两个阶段拆成两段流水线。- 默认写路径里,一个 leader 往往要把“WAL 写入 + memtable 写入”整组做完才交棒。
pipelined write里,前一组写请求在做 memtable 插入时,后一组请求已经可以开始做 WAL 阶段了,因此两个阶段能部分重叠。
- 源码依据:
db/db_impl/db_impl_write.cc中DBImpl::WriteImpl(...)如果immutable_db_options_.enable_pipelined_write为真,会走PipelinedWriteImpl(...)。db/db_impl/db_impl_write.cc中PipelinedWriteImpl(...)先组织wal_write_group,完成:EnterAsBatchGroupLeader(...)- sequence 计算
WriteGroupToWAL(...)ExitAsBatchGroupLeader(...)
- 然后再进入第二阶段的
memtable_write_group,由:STATE_MEMTABLE_WRITER_LEADEREnterAsMemTableWriter(...)WriteBatchInternal::InsertInto(...)ExitAsMemTableWriter(...)完成 memtable 插入。
db/write_thread.cc中ExitAsBatchGroupLeader(...)在 pipelined 模式下会把还需要写 memtable 的 writer 链接到newest_memtable_writer_,并唤醒STATE_MEMTABLE_WRITER_LEADER,这正是“WAL 阶段”和“memtable 阶段”拆开的关键。
- 当前结论:
pipelined write的目标主要是提高吞吐,让 WAL 阶段和 memtable 阶段形成重叠,而不是让一条写跳过 WAL 或颠倒顺序。- 对单条写的逻辑顺序来说,仍然是“先 WAL,后 memtable”。
- 变化的是系统层面的执行方式:不同批次之间可以像流水线一样并行推进不同阶段。
- 是否需要后续回看:
yes,这条线和WAL、MemTable章节直接相关,但 Day 003 先只保留骨架理解,不展开状态机细节。
问题 5:sequence 不是 logger 返回的,而是在 WriteImpl() 里先算好,这是否意味着写日志顺序是由 WriteImpl / WriteThread 保证的#
- 简答:
- 你的理解方向是对的。
- 在 RocksDB 里,sequence 的分配权不在 logger 手里,而在 DB 写路径自己手里。
- logger 更像“按给定顺序追加记录的执行器”,而不是“谁先写进去就给谁编号”的顺序裁判。
- 但更精确地说,保证顺序的不是单独一个
JoinBatchGroup(),而是整套WriteThread leader 交接协议 + 某些模式下的 wal_write_mutex_。
- 源码依据:
db/db_impl/db_impl_write.cc中默认路径先在WriteImpl(...)里取last_sequence = versions_->LastSequence(),再把last_sequence + 1传给WriteGroupToWAL(...)。- 同文件里有明确注释:
... &w is currently responsible for logging and protects against concurrent loggers ...
db/write_thread.h里也明确写了:- 正确性依赖于“同一时刻只有一个 leader”。
db/write_thread.cc中JoinBatchGroup(...)会让第一个 writer 成为STATE_GROUP_LEADER,ExitAsBatchGroupLeader(...)再把领导权显式移交给下一个 leader。- 但在
two_write_queues_分支里,db/db_impl/db_impl_write.cc又明确写了:LastAllocatedSequence is increased inside WriteToWAL under wal_write_mutex_ to ensure ordered events in WAL- 也就是该模式下不仅靠 leader,还额外靠
wal_write_mutex_保证 WAL 顺序。
- 在
pipelined write分支里,sequence 也是先由写线程阶段算出,再传给WriteGroupToWAL(...),不是由 logger 回填。
- 当前结论:
- 是的,RocksDB 的设计前提之一就是:
- sequence 顺序由 DB 写路径先决定
- WAL 追加顺序必须服从这个顺序
- 不能反过来靠 logger 的“谁先 append 成功”来决定 sequence
- 但如果要把这件事说完整,建议表述成:
- 默认写路径下,
WriteThread的单 leader 机制基本保证了同一时刻只有一个 group 在推进 WAL - 在并发写队列等特殊模式下,还要结合
wal_write_mutex_一起看
- 默认写路径下,
- 所以你的原句可以稍微修正为:
- “这段代码蕴含的不是 logger 决定顺序,而是 RocksDB 写路径先决定顺序;默认模式下主要靠
WriteThread的 leader 串行化,某些并发模式下还要加上wal_write_mutex_。”
- “这段代码蕴含的不是 logger 决定顺序,而是 RocksDB 写路径先决定顺序;默认模式下主要靠
- 是的,RocksDB 的设计前提之一就是:
- 是否需要后续回看:
yes,等 Day 004WAL时,可以专门把“顺序由谁决定”作为一个小节再压实一次。
问题 6:RocksDB 是怎么做日志聚合的,终止条件是什么;如果长时间没有新写入,会不会靠时钟超时来结束聚合#
- 简答:
- RocksDB 的日志聚合不是“攒一会儿再发”的时间窗口模型,而是“当前 leader 看到队列里已有多少兼容 writer,就一次性把这批拿走”的队列模型。
- 终止条件主要不是时钟超时,而是:
- 已经走到当前队尾
- 遇到不兼容 writer
- 或者 batch 大小达到上限
- 如果长时间没有新写入,那么当前 group 就按已经拿到的内容直接继续执行;源码里这里没有看到“等待额外若干微秒以便继续聚合”的时钟逻辑。
- 源码依据:
db/write_thread.cc中EnterAsBatchGroupLeader(...)先取newest_writer = newest_writer_.load(...),然后只遍历从当前 leader 到“那一刻的 newest_writer”为止的 writer。- 同函数里的终止/切分条件主要是:
sync / no_slowdown / disable_wal / protection_bytes_per_key / rate_limiter_priority不兼容- callback 不允许 batching
size + ByteSize(w->batch) > max_sizeingest_wbwi需要独占 group
- 整个函数里没有“等待更多 writer 到来再凑一批”的 timeout 分支。
- 当前结论:
- RocksDB 的日志聚合更像“队列截断 + 兼容性过滤”,而不是“按时间窗口收集”。
- 所以如果当前只有一个写请求,leader 通常就直接带着这个单元素 group 往下走,不会为了多聚合几个请求而额外等一个时钟。
- 是否需要后续回看:
yes,后面看性能调优时可以再回来看 batching 大小和吞吐关系。
问题 7:当前 write group 还在经历 WAL、memtable 等阶段时,新来的前台写会怎么处理#
- 简答:
- 新来的前台写不会插进当前已经确定好的 write group 里。
- 它会继续挂到
newest_writer_队列尾部,等待当前 leader 完成这一轮并显式交棒给下一任 leader。 - 在 pipelined write 模式下,情况更进一步:新来的写有机会在前一批做 memtable 时,先进入下一批的 WAL 阶段。
- 源码依据:
db/write_thread.h明确写了正确性依赖于“同一时刻只有一个 leader”。db/write_thread.cc中EnterAsBatchGroupLeader(...)一开始就把本轮观察范围固定在当时的newest_writer_;后面新到的 writer 不属于这轮 group。- 同文件
ExitAsBatchGroupLeader(...)中,默认模式下会在当前 group 完成后把last_writer->link_newer设成下一任 leader,并SetState(..., STATE_GROUP_LEADER)。 db/db_impl/db_impl_write.cc里也有注释:- 当前
&w正在负责 logging,并保护当前阶段不被并发 logger 干扰。
- 当前
- 在
PipelinedWriteImpl(...)中,WAL group 完成后会调用ExitAsBatchGroupLeader(wal_write_group, ...),把后续还需写 memtable 的请求转移到 memtable writer 队列,同时新的 WAL leader 已经可以继续往前推进。
- 当前结论:
- 默认写路径下:
- 当前 group 做
WAL + memtable时,新来的写在外面继续排队 - 等当前 leader 完成后,再由下一任 leader 处理下一批
- 当前 group 做
- pipelined write 下:
- 新来的写不一定要等前一批连 memtable 都做完
- 前一批做 memtable 时,后一批已经可以启动 WAL 阶段
- 默认写路径下:
- 是否需要后续回看:
yes,这条线和pipelined write、WAL、MemTable三章都直接相关。
问题 8:如何评价 RocksDB 这套日志聚合策略#
- 简答:
- 这套策略很有工程味道。
- 它承认 WAL 天然更适合顺序写,因此先把顺序控制收回到
WriteThread。 - 同时它又避免走“主动等待凑批”这条更激进的路线,而是只在并发自然形成时顺手拿 batching 收益。
- 源码依据:
db/write_thread.cc中EnterAsBatchGroupLeader(...)只截取“当前已经在队列里的兼容 writer”,没有看到专门等待更多 writer 的超时窗口。db/write_thread.h与db/db_impl/db_impl_write.cc明确把“同一时刻单 leader 推进 WAL / memtable”作为基本执行模型。
- 当前结论:
- RocksDB 的日志聚合是“保守但不浪费机会”的:
- 低并发时,不为了聚合额外牺牲延迟
- 高并发时,又能从自然排队里提取可观的 WAL batching 收益
- 这也是它为什么能同时覆盖:
- 单线程 / 低并发写入
- 多线程 / 高并发写入 这两类差异很大的 workload。
- RocksDB 的日志聚合是“保守但不浪费机会”的:
- 是否需要后续回看:
yes,后面学 WAL 和调优时,这条观察仍然有价值。
外部高价值问题#
今天先不额外引入外部问题。
这一章本地源码主线已经足够密集,先把 WriteImpl / WriteThread / WriteBatchInternal 这条线吃透更重要。
复习问答回合(2026-04-12)#
- 结论:
- 本轮复习结果判定为
pass。 - 当前已经没有关键误解,可以进入下一章。
- 本轮复习结果判定为
WriteThread::JoinBatchGroup()和EnterAsBatchGroupLeader()分别负责什么?- 用户回答摘要:
JoinBatchGroup()负责把前台写线程组织成 leader / follower,形成日志聚合组。EnterAsBatchGroupLeader()负责在 leader 视角下筛选兼容 writer,形成真正执行的WriteGroup。
- 评价:
- 回答正确。
- 这一题已经抓住了“先排队,再由 leader 组批”的结构。
- 用户回答摘要:
为什么说 sequence 通常是“按 write group 预留”,而不是“每条 key 单独抢号”?
- 用户回答摘要:
- 因为写线程机制会把日志推进串起来,因此可以先按 group 计算 sequence,不需要每个 key 单独抢号。
- 评价:
- 大方向正确。
- 后续还可以再补一句更精确的话:
- sequence 不只是为了“谁先写日志”,也是为了让这组写在
WAL和memtable中共享同一套顺序语义。
- sequence 不只是为了“谁先写日志”,也是为了让这组写在
- 用户回答摘要:
WriteBatch的 header 为什么要保存sequence + count?- 用户回答摘要:
count便于序列化与解析,sequence用于可见性与并发安全。
- 评价:
- 回答可接受,但还不够完整。
- 这一题更完整的版本应强调:
count让 batch 知道自己包含多少逻辑记录;sequence让同一个 batch 在写入WAL、恢复 replay、插入memtable时都能用同一套起始顺序展开。
- 用户回答摘要:
为什么写路径通常是“先 WAL,后 memtable”,而不是反过来?
- 用户回答摘要:
- 先写 memtable 再写 WAL 会在崩溃时出现状态对不上的风险。
- 评价:
- 回答正确。
- 这一题已经抓住“先保证可恢复性,再保证前台可见性”的顺序。
- 用户回答摘要:
versions_->SetLastSequence(last_sequence)为什么要放在更靠后的发布位置?- 用户回答摘要:
- 因为如果在 batch 还没完全写入 memtable 时就发布,读线程可能看到中间状态。
- 评价:
- 回答正确。
- 这一题已经抓住“不要发布部分成功写入”的核心。
- 用户回答摘要:
复习问答后的补充澄清(2026-04-12)#
- 这轮问答里最值得继续压实的只有两点:
WriteBatch header中的sequence不只是“发布可见性”用,它还直接服务于:WAL落盘- recovery replay
memtable插入时的顺序展开
- “按
write group预留 sequence” 的原因,也不只是减少抢号,而是 RocksDB 先在 DB 写路径层面决定这一整组写的顺序,再要求 WAL 和 memtable 共同服从这个顺序。
复习问答结果更新(2026-04-12)#
- 当前判定:
review_status: answeredreview_result: passreview_block_next: no
- 本章掌握度更新:
- 保持
understanding_status: yellow mastery_score从3/5上调到4/5ready_for_next: yes
- 保持
- 保留的回看点:
WriteBatchheader 在 WAL / replay / memtable 三处的统一作用pipelined write与默认写路径的边界
今日小结#
WriteImpl()是普通写路径的真正核心入口。WriteThread先把写请求组织成 group,再由 leader 统一推进。- sequence 通常是按 group 预留一段连续区间,不是每条 key 临时抢一个。
- WAL 写入和 memtable 插入共享同一套 sequence 语义。
versions_->SetLastSequence(last_sequence)是这次写入真正完成发布的末尾动作。
明日衔接#
Day 003 结束后,最自然的下一章是 WAL。
因为今天已经明确:
- sequence 会先写进 WAL
- recovery 依赖 WAL 重新展开 batch
所以下一章正好可以继续追:
- WAL record 物理格式
log::Writer::AddRecord()怎么分块写- recovery 时怎么把 WAL record 重新还原成
WriteBatch
复习题#
WriteThread::JoinBatchGroup()和EnterAsBatchGroupLeader()分别负责什么?- 为什么说 sequence 通常是“按 write group 预留”,而不是“每条 key 单独抢号”?
WriteBatch的 header 为什么要保存sequence + count?- 为什么写路径通常是“先 WAL,后 memtable”,而不是反过来?
versions_->SetLastSequence(last_sequence)为什么要放在更靠后的发布位置?


