第 5 篇 钉了 bbolt 的 batch 与单写者;Apply 管道回答:Raft committed 的 entry 如何变成 MVCC revision、何时触发 Watch、以及 applied index 落后 commit index 时客户端为何仍可能读到旧数据(线性读会等待)。
本篇是五轴中 「Raft 轴 vs MVCC/存储轴」 的分界篇:proposal 卡在复制还是 apply,证据包不同。
本篇在系列中的位置
篇目 核心内容 第 5 篇 · bbolt 后端 mmap、batch、单写者 第 6 篇 · Apply 管道 propose → commit → apply、Watch 触发 第 7 篇 · 写入路径 Txn、mod revision、quota 系列目录 关键问题、阅读路径
版本锚定:etcd v3.5.33(tag
v3.5.33)。机制对齐server/etcdserver/server.go(applyAll/applyEntries/apply)、server/mvcc/index.go、server/mvcc/watchable_store_txn.go。无真实集群则不粘贴伪造endpoint status的 index 字段。
一、三段索引:propose、commit、apply
客户端 Put/Txn 经 gRPC 进入 Leader 的
raftRequest → Propose 写入
WAL 并复制 → 多数派 ack 后 commit
index 前进 → 本地 apply 例程消费
committed entry,调用 applyV3.Apply。
sequenceDiagram
participant client as gRPC client
participant leader as EtcdServer Leader
participant raft as Raft plus WAL
participant apply as apply goroutine
participant mvcc as MVCC plus treeIndex
client->>leader: Put or Txn
leader->>raft: Propose InternalRaftRequest
raft->>raft: replicate and commit
raft->>apply: committed entries
apply->>mvcc: applyV3.Apply
mvcc->>mvcc: treeIndex plus bbolt
mvcc-->>client: response after apply
图:写请求在 apply 完成
后才对客户端可见(raftRequestOnce 等待
applyResult)。commit ≠
apply:follower 可能已 commit 但 apply
滞后;Serializable 读不等待 apply 对齐(第 8 篇)。
| 索引 | 含义 | 典型排障 |
|---|---|---|
committed index |
Raft 已提交日志位置 | 复制 lag、磁盘慢 |
applied index |
已映射到 MVCC 的位置 | apply 慢、snapshot 恢复 |
currentRev |
MVCC 逻辑 revision | compaction、Watch start rev |
二、applyAll 与 applyEntries
EtcdServer.applyAll 是每个 tick
的核心循环(server.go):
applySnapshot— 若有新快照,替换 backend、重建 store;applyEntries— 消费apply.entries中尚未 apply 的 committed entry;applyWait.Trigger(appliedi)— 唤醒等待线性读的 goroutine;- 等待 Raft 侧磁盘写入通知后
triggerSnapshot。
applyEntries 计算
ents = apply.entries[appliedi+1-firsti:],调用
apply(ents, confState)。若
firsti > appliedi+1 直接 panic——说明
WAL/快照与内存状态不一致,属于数据损坏级故障。
apply() 逐条处理 EntryNormal 与
EntryConfChange:
- Normal:反序列化
InternalRaftRequest,经applyV3(auth + quota 包装)执行 Put/Txn/Compaction/LeaseGrant 等; - ConfChange:成员变更,与 v2 store /
backend 的
ShouldApplyV3门控配合; - 空 entry:Leader 确认 noop;触发
lessor.Promote(Lease 主从切换相关,第 10 篇)。
三、treeIndex:内存中的 key → revision
treeIndex(server/mvcc/index.go)是
google/btree
实现的内存索引,与 bbolt 分工明确:
| 操作 | treeIndex | bbolt key bucket |
|---|---|---|
| Put | Put(key, idxRev) 记录 revision 链 |
UnsafeSeqPut(revBytes, marshaled KV) |
| Range | Revisions(key, end, atRev, limit) |
按 revision 取 value |
| Compact | Compact(rev) 裁剪旧 revision 索引 |
异步物理删除(compaction 调度) |
每个 user key 对应 keyIndex 结构,维护
(main, sub) revision 序列与 tombstone。Range
路径:先在 treeIndex 过滤
revision,再批量读 bbolt——避免全表扫描。
Revision 分配在
storeTxnWrite.End():currentRev++
作为本次 txn 的 main;txn 内第 \(n\) 个 op 的
sub = len(changes)(见
kvstore_txn.go put)。因此单个 Txn
内多条写共享同一 ModRevision(main),以
sub 区分顺序——这是 第 7 篇 mod
revision 语义的基础。
四、applyV3 分层与 consistent index
applyEntryNormal 在
e.Index > consistIndex.ConsistentIndex()
时设置 ShouldApplyV3 = ApplyBoth,并通过
txPostLockInsideApplyHook 在 bbolt 事务锁内更新
consistent index(与 Raft applied index
对齐)。重复启动时,已在 backend 持久化的 entry 可跳过 MVCC
重放,加速 recovery。
applier 栈(apply.go):
quotaApplierV3 → authApplierV3 → applierV3 → mvcc/store
- quotaApplierV3:Put/Txn/LeaseGrant 前检查 backend 配额(第 7 篇);
- authApplierV3:RBAC;
- applierV3:调用
mvcc.KV的 Put/Range/Txn/Compaction。
写 apply 在 LockInsideApply
内完成 bbolt 写;consistent index 更新确保 crash 后不会重复
apply 已持久化的 v3 状态。
五、Watch 触发点:watchableStore
MVCC store 之上包装
watchableStore(watchable_store.go)。写事务结束于
watchableStoreTxnWrite.End():
// server/mvcc/watchable_store_txn.go @ v3.5.33(节选)
func (tw *watchableStoreTxnWrite) End() {
changes := tw.Changes()
// ... 构造 mvccpb.Event 列表 ...
tw.s.mu.Lock()
tw.s.notify(rev, evs)
tw.TxnWrite.End()
tw.s.mu.Unlock()
}要点:
- notify 在 MVCC txn End 时同步调用,在
watchableStore.mu保护下; - 事件 revision 为
tw.Rev()+1(apply 后的新 main rev); synced/unsyncedwatcher 队列、victims 处理在syncWatchersLoop异步进行(第 9 篇展开)。
因此 Watch 延迟 = apply 延迟 + 通知队列调度,不是 Raft commit alone。apiserver Watch 卡住时,应先查 applied index 与 MVCC rev,再查 watcher 背压。
六、committed vs applied:排障分列
Raft lag(commit 慢)与 apply lag(MVCC/bbolt 慢)症状可能相似(写超时),证据包不同:
| 观察 | 倾向轴 |
|---|---|
committed index 落后
leader index |
Raft 复制 / WAL |
applied index 明显小于
committed index |
apply / backend batch / 大 txn |
| 线性读慢、写正常 | ReadIndex + applyWait(第 8 篇) |
| Watch 事件断流但写成功 | watchableStore / 客户端 rev |
processInternalRaftRequestOnce 在
committed - applied > maxGapBetweenApplyAndCommitIndex
时返回
ErrTooManyRequests,背压客户端
proposal——这是 apply 滞后直接反馈到写路径的门。
Snapshot apply
会阻塞整条管道:applySnapshotInProgress 指标为
1 期间,normal entry 不推进。恢复窗口运维见第 14 篇。
参考资料
规范 / 源码(A)
etcd-io/etcdtagv3.5.33:server/etcdserver/server.go(applyAll、apply、applyEntryNormal)server/mvcc/index.go、server/mvcc/watchable_store_txn.go、server/etcdserver/apply.go
站内对照
- distributed/13 · Raft 深度(commit 语义)
- 第 5 篇 · bbolt 后端
争论 / 开放问题
- apply 串行 vs 分 shard MVCC(etcd 未采用;TiKV Region 对照见 tikv-htap)
- Watch notify 同步 vs 异步对 p99 的影响(机制已知,跨 workload 数字未在本站实测)
上一篇:bbolt 后端
下一篇:写入路径深读
读完这篇,下一步读什么
优先读同系列或同问题的下一篇,把单篇消费变成主题集群。
etcd / 生产内核:Raft、WAL、MVCC 与 Kubernetes 控制面底座
补齐 distributed 百科单篇之上的 etcd 生产内核:单 Raft 组、WAL/快照、Revision MVCC、bbolt 单写者、Watch/Lease、K8s apiserver 耦合,并以排障与相对 TiKV/FoundationDB 的选型收束。
【etcd】生产全景:缺口、五轴坐标系与 16 篇路线
相对 distributed/50、39、13 钉清 etcd 生产内核缺口;定义 Raft/WAL/MVCC/Watch/Lease 五轴排障坐标系,给出 16 篇阅读路线与 K8s 控制面耦合指针。版本锚定 v3.5.33。
【etcd】排障五轴:Raft/WAL/MVCC/Watch/Lease 口令表
按 Raft、WAL、MVCC、Watch、Lease 五轴做症状否证;解释 etcdctl endpoint status/health 字段语义;并对照 K8s 控制面 apiserver 超时与 Node Lease 漂移。
【etcd】MVCC 数据模型:Revision、keyIndex 与 generation
拆解 etcd v3.5.33 的 Revision (main, sub) 全序、keyIndex/generation 生命周期与 treeIndex 分工;简要对照 v2 平面键空间,交代 MVCC 与 Watch/compaction 的语义基础。