第 8
篇 分列线性一致读与 Serializable 读;distributed/50
百科 讲过 Watch 与 Revision 直觉。生产排障里,apiserver
或 controller 常见误判是「Put
成功但下游没反应」——要先问:Watch 落在 Watch 轴的
synced、unsynced 还是 victims;startRev 是否已被 compaction
裁掉;客户端消费是否背压到
chanBufLen。
本篇钉 watchableStore
内核:synced/unsynced
分组、syncWatchersLoop/syncVictimsLoop
双 goroutine、notify 与 channel 满时的
victims,以及 CompactRevision →
ErrCompacted 的重同步策略。不写未测
Watch 延迟数字。
本篇在系列中的位置
篇目 核心内容 第 8 篇 · 读路径与一致性 ReadIndex、Serializable、Lease read 第 9 篇 · Watch 机制 watchableStore、synced/unsynced、ErrCompacted 第 10 篇 · Lease 与 KeepAlive grant/KeepAlive/checkpoint、Leader 切换 系列目录 五轴、阅读路径
版本锚定:etcd v3.5.33(源码 tag
v3.5.33)。机制对齐server/mvcc/watchable_store.go、watcher.go、watcher_group.go@ etcd-io/etcd v3.5.33。无真实集群则不粘贴伪造 Watch 事件流或etcdctl watch输出。
一、watchableStore 在 Apply 链上的位置
MVCC store 负责 Revision 与 bbolt
读写;watchableStore 在其外包一层 Watch
状态机(watchable_store.go):
type watchableStore struct {
*store
victims []watcherBatch
victimc chan struct{}
unsynced watcherGroup // 追赶历史 revision
synced watcherGroup // 已与 store 进度对齐
}Apply 写路径在 storeTxnWrite.End()
提交后调用 notify(rev, evs),只遍历
synced 组里匹配 key/前缀的 watcher。Lease
过期触发的批量 Delete 同样走 SetRangeDeleter
回调,保证 Lease 轴删除也会推送 DELETE
事件(第 10 篇)。
与 第 6
篇 的分工:Raft commit → apply → MVCC 写 →
notify 是 Watch
轴唯一热路径;unsynced 追赶不阻塞 Apply,由后台
loop 分批完成。
flowchart TD
apply["Raft Apply → MVCC Put/Delete"] --> notify["watchableStore.notify"]
notify --> synced["synced watchers → ch"]
apply --> rev["currentRev++"]
loop["syncWatchersLoop 100ms"] --> unsynced["unsynced → 读 bbolt 历史"]
unsynced --> synced
chfull["ch 满"] --> victims["victims + syncVictimsLoop"]
victims --> unsynced
二、建立 Watch:synced 与 unsynced 的判据
watch() 根据 startRev 与
currentRev
决定分组(watchable_store.go):
startRev == 0或startRev > currentRev:synced。minRev设为max(currentRev+1, startRev),只接收后续新事件。- 否则:unsynced(slow
watcher)。
slowWatcherGauge递增,由syncWatchersLoop从 bboltbuckets.Key按 revision 范围扫描历史 KV,转成mvccpb.Event推送。
WatchStream 多路复用:一个 gRPC
双向流可挂多个
watch_id(watcher.go)。chanBufLen
默认为 128(测试可改);多个 watcher
可共享同一 ch,背压会连带影响同流上所有
Watch。
Progress
通知:RequestProgress /
RequestProgressAll 仅在 watcher 处于 synced 且
rev >= startRev
时发送空事件、Revision 为当前 store
revision——用于客户端确认「已追到 head」,apiserver watch
cache 重连时常用(第 13 篇指针)。
三、syncWatchers 与 victims:背压落格
3.1 历史追赶
syncWatchersLoop 默认每
100ms 唤醒(有剩余 unsynced
且上一轮有进展时,等待时间可缩至上一轮
syncDuration,避免长期霸占 store
锁)。每轮最多处理
maxWatchersPerSync = 512 个
unsynced watcher:
choose()取批次,计算minRev;- 若
minRev < compactMainRev,该 watcher 需要的 revision 已被 compaction 裁掉(见第四节); - 否则
UnsafeRange读 bbolt,经kvsToEvents过滤 key 后send; - 成功则移入 synced;若
eb.moreRev != 0表示单批未读完,留在 unsynced。
3.2 victims
notify 或 syncWatchers 调用
watcher.send() 时,若目标 ch
非阻塞发送失败:
- watcher 标记
victim = true,移出 synced; - 事件批次进入
victims; syncVictimsLoop每 10ms 或victimc信号重试。
排障口令:etcd_debugging_mvcc_slow_watcher_total(Prometheus
slowWatcherGauge)上升 →
客户端消费慢或同流 Watch 过多,不是 Raft
lag。与 MVCC 轴 database space exceeded
分列(第 12 篇)。
| 症状 | 更可能落格 | 不像 |
|---|---|---|
| Put 成功、synced Watch 无事件 | victims 或 filter 掉事件 | Raft 未 commit |
| 重连后大量历史事件、内存涨 | unsynced 从旧 startRev 追赶 | compaction 本身 |
Watch 收到 CompactRevision |
startRev < compactRev | 网络瞬断 alone |
| 同 Pod 多 Watch 全断 | 共享 ch 背压 |
单 key 未写入 |
四、ErrCompacted 与 CompactRevision
MVCC compaction 更新 compactMainRev
后,早于该 revision 的 Range/Watch 历史不可再读。store
层错误为:
mvcc: required revision has been compacted
Watch 路径不走 Range 错误,而在
watcher_group.chooseAll:当
w.minRev < compactRev 时,向 ch
发送:
WatchResponse{WatchID: w.id, CompactRevision: compactRev}并设 w.compacted = true、从 watcher
组删除。客户端(client/v3)将其映射为
rpctypes.ErrCompacted。若此时
ch 已满,发送失败则 下轮 syncWatchers
重试(注释明确:避免 compaction 通知丢失在 channel
背压下被静默跳过)。
客户端策略(机制事实,非 SLA):
- 记录最后成功事件的
mod_revision/ header revision; - 收到
ErrCompacted后 List 全量或按 prefix 重建本地缓存,再以Rev=current重新 Watch; - 不要把「compaction 间隔内断线重连」假设为一定能从旧 rev 续传——auto-compaction 与 manual compact 都会裁历史(第 12 篇)。
Restore 后所有 synced watcher 会被标记
restore=true 并移入 unsynced,需重新追赶;这与
Leader 切换后 apiserver 大规模 resync 的成本相关(第 13
篇)。
五、与读写一致性的交界
- 事件顺序:同一 Watch 流内事件按 revision 严格递增,跨 key 顺序与 Raft log 全序一致(distributed/50 已述;本篇钉代码路径)。
- Put 响应 vs Watch 事件:两者不同 gRPC
流;Watcher 可能 略早于 Put RPC
返回看到事件(
distributed/50时序图仍成立)。 - Serializable 读 + Watch:读路径若走 Serializable(第 8 篇),可能 stale;Watch 本身始终反映 已 Apply 的 MVCC 状态,不替代线性一致读。
- Follower 上的 Watch:v3 gRPC Watch 由 处理该 RPC 的成员 本地推送;follower 上 Watch 进度可能落后 Leader,K8s apiserver 通常对 Leader 或负载均衡后需处理 member 切换(第 15 篇五轴)。
六、谱系与开放问题
ZK 一次性 Watch(Hunt et al. 2010)→ 重注册间隙丢事件
→ etcd v3:持久 Watch + Revision 回溯
→ watchableStore:synced 热路径 + unsynced 扫盘 + victims 背压
→ compaction 与 ErrCompacted:历史窗口 vs 磁盘 SLO 的硬 trade-off
开放问题(工程判断):K8s 控制面在
apiserver watch cache + etcd Watch 双层的 端到端
resync SLO 尚无与 compaction
间隔统一的公开建模;chanBufLen=128
对超大规模单流 Watch 是否足够,官方 issue #11906
讨论过背压语义——本篇无实测,不写成调参结论。
本篇不写什么:未跑的 Watch
延迟/QPS;apiserver storage 实现全书(第 13 篇);伪造
etcdctl watch 输出。
参考资料
规范 / 官方文档 / 源码(A)
- etcd-io/etcd
v3.5.33:server/mvcc/watchable_store.go;watcher.go;watcher_group.go;kvstore.go(ErrCompacted) - etcd v3.5 · Watch API
- etcd v3.5 · Compaction
论文 / 对照(A/B)
- Hunt, P., et al., ZooKeeper: Wait-free coordination for Internet-scale systems, ATC 2010(一次性 Watch 对照)
- Ongaro & Ousterhout, In Search of an Understandable Consensus Algorithm, ATC 2014(Raft 全序 → revision 顺序)
站内对照
上一篇:读路径与一致性
读完这篇,下一步读什么
优先读同系列或同问题的下一篇,把单篇消费变成主题集群。
【etcd】生产全景:缺口、五轴坐标系与 16 篇路线
相对 distributed/50、39、13 钉清 etcd 生产内核缺口;定义 Raft/WAL/MVCC/Watch/Lease 五轴排障坐标系,给出 16 篇阅读路线与 K8s 控制面耦合指针。版本锚定 v3.5.33。
【etcd】Kubernetes 控制面耦合:apiserver、resourceVersion 与 Node Lease
钉 kube-apiserver 与 etcd 的分层边界;resourceVersion 如何映射 Revision MVCC;Node Lease 如何落在 Lease 轴;以及 apiserver 超时应如何分列到 Raft/Watch/Quota 五轴。
【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 的语义基础。