etcd 存储引擎深度实战:从 bbolt 单文件 B+ 树、revision 索引树到 watch 事件流的工程全解
在云原生体系里,etcd 是整个集群的"真相之源"。但绝大多数人对 etcd 的认知止步于"一个用 Raft 实现的分布式 KV",把注意力全放在共识层的一致性证明上,而对真正决定它性能上限与稳定性边界的东西——存储引擎——几乎一无所知。
现实是:Raft 只负责把日志在多副本之间复制一致,一旦日志被 apply,剩下的活儿全部交给单机存储引擎。生产环境中 etcd 出故障,十次里有八次不是脑裂,而是 etcdserver: mvcc: database space exceeded、fsync 延迟飙升、defrag 期间选主失败。要真正驯服 etcd,必须把 bbolt、revision 索引树和 watchableStore 这三层的机制拆开看。
一、存储分层全景:日志、索引与后端
etcd 的一次写入要经过三层结构,各层的职责边界非常清晰:
Put(key, val)
│
├─ 1. Raft 层:封装为 pb.Request,复制到多数派后 commit
│
├─ 2. Apply 层:分配 revision = {main: <raft term/index>, sub: <同批序号>}
│ 写入 WAL(顺序追加,fsync)
│
├─ 3. treeIndex(内存 B 树):keyIndex 追加 generation,记录 (revision → 版本)
│
└─ 4. backend(bbolt):把 revision 作为 key、value 连同 key 名一起落盘
关键点在于:持久化 KV 里存的 key 不是用户 key,而是 revision。用户 key 只存在于内存索引 treeIndex 中。这个设计直接决定了 etcd 的读路径、压缩语义和 watch 实现,也是它和 RocksDB/LevelDB 那类 LSM 引擎最本质的分野。
二、bbolt:一个把 COW 做到极致的单文件 B+ 树
etcd 的 backend 是 bbolt(前身是 LMDB 的 Go 移植 Bolt)。它有几个反直觉但极其关键的特性。
2.1 mmap 读 + 串行写事务
bbolt 把整个 db 文件 mmap 到进程地址空间,因此读事务完全不经过系统调用,也不做任何内存拷贝——B+ 树的节点指针就是文件内偏移。而写事务全局一把 rwlock,任何时刻只有一个 writer。
db, err := bolt.Open("/var/lib/etcd/member/snap/db", 0600, nil)
if err != nil { log.Fatal(err) }
defer db.Close()
// 写事务:全局串行,内部是 COW,提交时最多 2 次 fsync
err = db.Update(func(tx *bolt.Tx) error {
b, err := tx.CreateBucketIfNotExists([]byte("key"))
if err != nil { return err }
// 注意:这里的 key 是 8+8 字节的 revision 编码,不是用户 key
return b.Put(revBytes(12, 0), encodeKV([]byte("/foo"), []byte("bar")))
})
// 读事务:无锁,纯 mmap 指针遍历
_ = db.View(func(tx *bolt.Tx) error {
c := tx.Bucket([]byte("key")).Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() {
_ = v
}
return nil
})
2.2 COW 带来的写放大与文件膨胀
bbolt 不做原地更新。修改一个叶子页,需要把从根到该叶子的整条路径全部复制出新页,新页从 freelist 或文件尾部分配,最后原子切换 meta page(meta page 有双备份,交替写,保证崩溃后至少有一份可用)。
这带来两个生产级后果:
- 写放大极高。改 100 字节的 value,可能要重写 4~6 个 4KB 页。所以 etcd 对"大 value"极其敏感——官方建议单对象不超过 1.5MB,实际生产中超过几十 KB 就会明显拖慢 fsync。
- db 文件只增不减。被 COW 替换掉的旧页进入 freelist 复用,文件大小不会收缩。这就是为什么 etcd 的 db 文件在你删掉 90% 的 key 之后依然纹丝不动。
etcd 启动时会把 freelist 全量加载进内存做扫描,这一步是 O(页数) 的,db 越大启动越慢;3.4+ 默认启用 no_freelist_sync 把 freelist 改为按需重建,但代价是首次写放大。
三、revision 索引树:treeIndex 才是 MVCC 的本体
etcd 的多版本管理不靠 bbolt,而是靠内存中的一棵 Google BTree(treeIndex)。它的核心数据结构长这样:
type keyIndex struct {
key []byte
modified Revision // 最后一次修改的 revision
generations []generation
}
type generation struct {
ver int64
created Revision // 本代创建时的 revision(首次 put)
revs []Revision // 该代内所有历史版本,tombstone 为末元素
}
一个 key 被创建→更新→删除→再创建,会产生多个 generation。删除不移除 keyIndex,而是往 revs 尾部追加一个 tombstone(sub=0 的 revision),并开启新 generation。
3.1 一次 Range 查询的真实路径
func (ti *treeIndex) Range(key, end []byte, atRev int64) ([][]byte, []Revision) {
// 1. 在内存 B 树中做 O(log N) 范围扫描,得到候选 keyIndex
// 2. 对每个 keyIndex,二分查找 <= atRev 的最大 revision
// 3. 若命中的是 tombstone,则该 key 在 atRev 时刻不存在,跳过
// 4. 返回 revision 集合
}
注意第 4 步:内存索引只给你 revision,真正的 value 还要回 bbolt 按 revision 取。这意味着一次 etcdctl get --prefix /registry/pods 是"一次内存 B 树扫描 + N 次 bolt 点查"。N 很大时(比如几十万个 Pod),瓶颈根本不在网络,而在 bbolt 的随机点查和页缓存命中率。这也是为什么 K8s 大规模集群必须做 list 分页与 watch 缓存。
3.2 压缩(compact)不是删除数据
# 压缩到 revision 1000000:所有 <= 该 revision 的历史版本不再可读
etcdctl compact 1000000
# 压缩后必须 defrag 才会真正归还磁盘
etcdctl defrag --cluster
compact 做两件事:在 treeIndex 里裁剪掉已被新版本覆盖的 revision,并往 bbolt 的 meta bucket 写入 scheduled compaction 记录;随后后台任务分批删除 bolt 中过期的 revision key。它只是把空间还给 freelist,db 文件大小不变。 想要文件变小,只能 defrag——而 defrag 会新建一个临时 db 文件做全量 COW 拷贝,期间该 member 长时间无法响应,极易触发选主。生产上的正确姿势是:滚动逐个 member 执行,并且先确认该 member 是 follower。
四、watch 流:从内存索引长出来的事件总线
etcd 的 watch 之所以能做到"不丢事件且严格有序",是因为它复用了 revision 这条单调递增的时间轴。
watchableStore 内嵌 store,并把所有 watcher 分成两组:
- synced:已经跟上最新 revision 的 watcher,事件可以直接推送到它的 channel。
- unsynced:创建时带了历史 revision(断点续播),需要走后台
syncWatchersLoop从 bolt 里回放历史事件;它维护一个VictimByKey的最小堆,按 revision 从小到大批量补齐,补齐后转入 synced。
// 客户端:从指定 revision 续播,而非从 now 开始
watchChan := cli.Watch(ctx, "/registry/pods/",
clientv3.WithPrefix(),
clientv3.WithRev(lastSeenRev+1), // 关键:+1 避免重复收到最后一条
clientv3.WithProgressNotify(), // 定期收到无事件的 bookmark
)
for resp := range watchChan {
if err := resp.Err(); err != nil {
switch err {
case v3rpc.ErrCompacted:
// 要续播的 revision 已被压缩,历史事件永久丢失
// 唯一正确做法:全量 list 重建本地缓存,并从新的 revision 开始 watch
rebuildFromSnapshot(ctx, cli)
return
case mvcc.ErrNoSpace:
log.Fatal("etcd 配额耗尽,集群进入只读")
}
continue
}
for _, ev := range resp.Events {
applyLocal(ev) // 幂等!重连可能重放
lastSeenRev = ev.Kv.ModRevision
}
}
三个实战要点:
- 事件处理必须幂等。网络闪断后客户端用旧 revision 重连,服务端会重放这段区间的全部事件。
WithProgressNotify()是低成本的活性探针。它让服务端周期性发一个只带 revision、不带事件的响应,客户端据此判断"没事件"与"连接已死"。- ErrCompacted 是最容易踩的坑。K8s 的 informer 在这条路径上的处理就是丢弃本地 cache、重新 list。任何自研的 watch 消费者都必须实现同样的兜底,否则会静默丢数据。
五、生产调优清单
| 问题 | 根因 | 处置 |
|---|---|---|
database space exceeded | db 达到 --quota-backend-bytes(默认 2GB)触发 alarm,集群转只读 | 先 compact 再 defrag,必要时临时 alarm disarm 争取窗口 |
| fsync P99 抖动 | bbolt 写事务 fsync + COW 写放大,与 WAL fsync 叠加 | 独立 NVMe、关闭与 etcd 共盘的日志采集;监控 etcd_disk_wal_fsync_duration_seconds 的 P99 应 < 10ms |
| db 无故膨胀 | 高频更新 key(如 Event、Lease 续期)持续制造新 revision | 缩短 Event TTL、调小 --auto-compaction-retention(如 1h)让 revision 增长与回收平衡 |
| defrag 期间选主 | 全量重写 db 阻塞 apply 循环 | 只在 follower 上滚动执行,且避开业务高峰;大集群建议拆分为多次小步 |
| 读放大 | 读路径是内存 B 树 + N 次 bolt 点查 | 大 list 必须分页;客户端侧做本地缓存,靠 watch 增量维护 |
另外一个常被忽略的点:线性一致性读 vs 串行读。默认 Range 是线性一致读,需要向 leader 取 readIndex 并确认自身仍是 leader,代价是一次 RTT;用 clientv3.WithSerializable() 可以跳过这步直接读本地 follower,吞吐提升明显,但可能读到落后数据。对于"配置下发"这类场景,串行读是完全可接受的性能取舍。
六、结语
etcd 的工程取舍非常清晰:用内存索引换掉 LSM 的读放大,用单文件 COW B+ 树换掉原地更新的崩溃一致性难题,用revision 单调递增这条时间轴同时支撑多版本读、压缩、watch 三件事。理解了这三层,你就能解释几乎所有 etcd 的"诡异"现象——为什么删了数据磁盘不降、为什么 defrag 会引发选主、为什么 watch 会突然报 ErrCompacted、为什么 list 十万 key 会拖垮集群。
存储引擎从来不是黑盒。当你把它拆开,那些"玄学故障"都会变成可推导、可预防的工程结论。

发表评论 取消回复