第 4 篇
把 Revision、keyIndex、generation 钉在 MVCC
逻辑层;bbolt 才是这些 revision
在磁盘上的落点。读者若把 etcd 当成「带 Raft
的通用数据库」,会在 单写者 + batch commit
处撞墙:读可以并发,写路径全局串行,且 bbolt 事务未必随每条
Raft entry 立即 fsync。
本篇回答:v3.5.33 如何把 MVCC 映射到 bbolt bucket、mmap 与 batch 如何 amortize fsync、单写者约束对 apply 与线性一致读意味着什么。 Apply 管道见第 6 篇;quota 触顶见第 7 篇。
本篇在系列中的位置
篇目 核心内容 第 4 篇 · MVCC 数据模型 Revision、keyIndex、generation 第 5 篇 · bbolt 后端 mmap、batch、单写者、bucket 布局 第 6 篇 · Apply 管道 propose → commit → apply 系列目录 关键问题、阅读路径
版本锚定:etcd v3.5.33(源码 tag
v3.5.33;go.etcd.io/bbolt v1.3.12)。机制对齐server/mvcc/backend/、server/mvcc/buckets/@ 该 tag。无真实集群则不粘贴伪造etcdctl输出或磁盘延迟数字。
一、bbolt 在 etcd 栈中的位置
etcd v3 存储栈可以粗分为三层:
| 层 | 组件 | 职责 |
|---|---|---|
| 共识 | Raft + WAL | 日志复制、commit index |
| MVCC | store + treeIndex |
Revision 分配、内存索引、可见性 |
| 持久化 | bbolt backend | mmap B+ 树、批量写事务、快照 |
bbolt(go.etcd.io/bbolt,BoltDB 的 etcd
维护分支)是嵌入式 KV:无独立 server
进程,由 etcd 进程内 bolt.Open 打开单个
member/snap/db
文件。其设计约束——同一时刻最多一个写事务、多个只读事务可并发——直接塑造了
etcd 的 apply 串行与 backend batch 策略。
谱系:BoltDB 来自 CoreOS 时代的嵌入式 Go 存储;etcd 在 v3 全面转向 MVCC 后仍选 bbolt 而非 LSM,换取实现简单、范围读友好、单文件运维,代价是写扩展性受单写者封顶(与 TiKV Multi-Raft 的分叉见本系列第 16 篇,不在此展开)。
flowchart TB
raft["Raft commit"] --> apply["apply → MVCC store"]
apply --> batch["backend BatchTx"]
batch --> bbolt["bbolt mmap file"]
read["Range / Watch 读"] --> concurrent["ConcurrentReadTx"]
concurrent --> bbolt
concurrent --> buf["txReadBuffer 写缓冲"]
图:写经 BatchTx 聚合;读走 ConcurrentReadTx,合并 bbolt 与内存缓冲。
二、bucket 布局与 key 编码
server/mvcc/buckets/bucket.go 定义 bbolt
内部分区。与 KV 直接相关的主要 bucket:
| Bucket | 用途 |
|---|---|
key |
MVCC 主数据:以 revision 字节序 为
key,序列化 mvccpb.KeyValue 为 value |
meta |
consistent_index、Raft term 等元数据 |
lease |
租约元数据 |
alarm |
NOSPACE / CORRUPT 告警状态 |
cluster / members |
成员与集群配置 |
逻辑 key(用户可见的字节串)不作为 bbolt
主键;第 4
篇 的 treeIndex(内存 B-tree)负责
(userKey → revision) 映射,Range 时先用
treeIndex 查 revision 列表,再按
revToBytes(revision) 到 key bucket
取 value(kvstore_txn.go 的
rangeKeys)。
key bucket 标记为
safeRangeBucket:同一 revision
不会被覆盖,Range 扫描安全。这与 LSM「同 key
多版本」的物理布局不同——etcd 的多版本是多条 revision
记录,靠 compaction 回收旧 revision。
三、mmap 与 InitialMmapSize
backend.go 打开数据库时设置
bolt.Options.InitialMmapSize,Linux 默认
InitialMmapSize = 10 GiB。注释说明意图:预映射区域大于潜在最大库文件时,写者扩容
mmap 时不至于长时间阻塞读者。
// server/mvcc/backend/backend.go @ v3.5.33(节选)
var InitialMmapSize = uint64(10 * 1024 * 1024 * 1024)
bopts.InitialMmapSize = bcfg.mmapSize()
db, err := bolt.Open(bcfg.Path, 0600, bopts)工程含义(非 benchmark):
- 读路径通过 mmap 直接访问页缓存映射,避免每次 Range 走 read syscall 风暴;
- DB 文件增长时可能触发
remap;
InitialMmapSize是 Linux 上的预分配门,与--quota-backend-bytes配合规划容量; - 可选
Mlock防止 backend 页被 swap(BackendConfig.Mlock),K8s 控制面文档常建议配合足够 RAM。
开放问题:云盘延迟与 mmap
扩容的交互因环境而异;本站无统一实测表,排障时看
etcd_disk_backend_commit_duration_seconds(官方运维文档口径,B
级)而非臆测阈值。
四、batch commit:100ms 与 10000 条
bbolt 每次 Commit() 可能触发 fsync;若每条
Raft apply 单独 commit,写放大不可接受。etcd backend 用
batchTxBuffered 聚合:
// server/mvcc/backend/backend.go @ v3.5.33
var (
defaultBatchLimit = 10000
defaultBatchInterval = 100 * time.Millisecond
)后台 goroutine run() 每
batchInterval 检查 pending
操作,达到阈值或定时器触发时
Commit()。Unlock()
路径上还有关键分支:delete 操作会立即
commit——因为 delete 没有 put
那样的内存写缓冲,若延迟 commit 可能导致后续读从 bbolt
读到陈旧数据,破坏线性一致(注释引用 PR #17119)。
| 触发条件 | 行为 |
|---|---|
定时器(默认 100ms)且
safePending() > 0 |
批量 commit |
pending >= batchLimit(默认 10000) |
强制 commit |
| 任意 pending delete | 立即 commit |
指标
etcd_disk_backend_commit_duration_seconds
反映的是这一层的 fsync 延迟,与 WAL fsync(第 3
篇)分列——排障时勿混为一谈。
五、单写者约束与读写事务
bbolt 保证:一个写事务与多个读事务并发;写事务提交前,新读事务看到旧快照。etcd 在此基础上封装:
BatchTx():apply 路径持有,LockInsideApply()仅在 apply 内获取写锁;ReadTx():传统读事务,已被主路径ConcurrentReadTx取代(#10523);ConcurrentReadTx():复制txReadBuffer,引用当前 batch 的 bbolt 读 Tx,非阻塞创建;读可见 bbolt 已提交数据 + 未 commit 的写缓冲。
写缓冲(batchTxBuffered.buf)是理解「apply
已完成但 bbolt 尚未 fsync」的关键:Put
可在缓冲中立刻被读路径看见;Delete 必须尽快落盘。第 6 篇
Apply 管道在此之上串行调度 Raft entry;第 8 篇 ReadIndex
保证线性读在 applied index 对齐后再读
MVCC。
Defrag(backend.Defrag())会
LockOutsideApply 独占 backend,与在线 batch
互斥——运维窗口见第 12 篇。
六、与 MVCC store 的事务边界
一次 MVCC
写事务(storeTxnWrite)典型步骤:
BatchTx().LockInsideApply()(由 apply 钩子保证在 apply 栈内);UnsafeSeqPut写入keybucket,treeIndex.Put更新内存索引;End()递增currentRev,释放 store 锁;- backend 层按 batch 规则异步或同步 commit。
watchableStoreTxnWrite.End() 在 MVCC
提交后调用 notify() 推送 Watch——Watch 触发点在
MVCC 层,不等待 bbolt fsync,但受 delete
立即 commit 规则保护一致读。
单写者意味着:不存在跨 key 的并行 apply 写 bbolt;Raft 已串行化日志,apply 再串行映射到 MVCC,与 bbolt 模型一致。试图用「多线程 apply」绕过此约束会与 quota、consistent index、Watch 顺序全面冲突。
参考资料
规范 / 源码(A)
etcd-io/etcdtagv3.5.33:server/mvcc/backend/backend.go、batch_tx.go、server/mvcc/buckets/bucket.gogo.etcd.io/bbolt v1.3.12:B+ 树、mmap、单写者多读事务模型- etcd v3.5 · Hardware guidelines(backend commit 告警口径,B)
站内对照
- 第 4 篇 · MVCC 数据模型
- distributed/50 · etcd 深度解剖(BoltDB 概览)
争论 / 开放问题
- bbolt 单写者 vs LSM(TiKV)写扩展性;quota 触顶迁移条件(本系列 07、16)
- batch 窗口与 delete 立即 commit 对 p99 写延迟的权衡(机制见 #17119 讨论,无本站压测)
上一篇:MVCC 数据模型
读完这篇,下一步读什么
优先读同系列或同问题的下一篇,把单篇消费变成主题集群。
etcd / 生产内核:Raft、WAL、MVCC 与 Kubernetes 控制面底座
补齐 distributed 百科单篇之上的 etcd 生产内核:单 Raft 组、WAL/快照、Revision MVCC、bbolt 单写者、Watch/Lease、K8s apiserver 耦合,并以排障与相对 TiKV/FoundationDB 的选型收束。
【etcd】treeIndex 与 Apply 管道:propose → commit → apply
走读 etcd v3.5.33 从 Raft commit 到 MVCC apply 的串行管道:treeIndex 与 bbolt 分工、consistent index、watchableStore 通知触发点,以及 committed/applied 分列排障。
【etcd】Compaction、Defrag 与容量:quota、alarm 与 SLO
钉 etcd v3.5.33 MVCC compaction 与 bbolt defrag 的分工、auto-compaction 保留窗口、quota-backend-bytes 与 NOSPACE alarm 写入门,以及 ErrCompacted 如何写进 Watch SLO。
【etcd】生产全景:缺口、五轴坐标系与 16 篇路线
相对 distributed/50、39、13 钉清 etcd 生产内核缺口;定义 Raft/WAL/MVCC/Watch/Lease 五轴排障坐标系,给出 16 篇阅读路线与 K8s 控制面耦合指针。版本锚定 v3.5.33。