📝 术语别名
  • etcd: 一个分布式、强一致、高可用的 KV 存储, 底层用 Raft 做共识, Go 写的, 现属 CNCF, 是 k8s 唯一的事实数据源 (source of truth);
  • revision: etcd 集群级、全局单调递增的版本号, 每次成功的写/事务 +1, 是整个 keyspace 的"全局时钟";
  • MVCC: Multi-Version Concurrency Control, 多版本并发控制, 写不覆盖旧值而是生成新版本, 让读写互不阻塞;
  • Watch: 订阅某个 key/区间从指定 revision 起的变更, 按序、不丢地推给客户端;
  • Lease: 带 TTL 的 key, 客户端要不断续约 (keepalive), 断了自动删, 拿来做心跳/选主;
  • Txn: etcd 的迷你事务, If/Then/Else, 本质是基于 revision 的 CAS (compare-and-swap);
  • compaction: 压缩, 丢弃某 revision 之前的历史版本来回收空间, 压缩点同时也是 Watch 能回放的最早边界;
  • reconcile: 调谐, 比较"期望状态"和"实际状态"、采取动作把差距抹平的幂等操作;
  • List-Watch: 先全量拉一次基线 (List) 再持续追增量 (Watch) 的模式, k8s informer 的核心范式;

从 Raft 到 etcd: 中间多了什么

上回书我们学完了 Raft, 知道 Raft 解决的是「一组节点如何就一条有序日志达成一致」; 它是个 replicated state machine 的框架, 但它不规定状态机到底是什么, 只保证所有副本以相同顺序看到相同的 log entry;

etcd 的回答是:

那条 Raft 日志里的每一条 entry, 就是一次 KV 写操作; 状态机就是一个 MVCC 的键值存储 (底层 bbolt);

1
2
client 写 → Leader 把这次写当成一条 log entry → Raft 复制到多数派 →
commit → 各节点按相同顺序 apply 到本地 KV → 返回 client

所以分工很清楚: Raft 管"所有副本以相同顺序看到相同的写", etcd 管"写的是什么、能查到什么、上层暴露什么 API"; etcd 真正值钱的地方不是它内嵌了 Raft (那是地基), 而是它在 Raft 之上搭了一组让分布式协调变得好写的能力, 这也是 k8s 选它、而不是自己撸一套 Paxos 的根本原因:

能力 是什么 k8s 为什么离不开
MVCC + revision 全局单调递增版本号, 旧版本保留到 compaction 才删 Watch / 一致读 / CAS 的共同地基
Watch 从指定 revision 起订阅变更, 不丢不乱序 informer 的命根子, 驱动 reconcile
Lease 带 TTL 的 key, 断约自动删 节点心跳、组件 leader election
Txn 基于 revision 的 CAS 迷你事务 乐观并发控制

版本提醒: 现在都是 v3 API (gRPC + protobuf + 扁平 MVCC keyspace); v2 是老的 REST + 层级结构, 已经弃用了, 看资料别看串了;

MVCC: 给存储装上一条时间轴

它要解决的矛盾: 读写并发别互相堵

没有 MVCC 的老办法, 数据就一个"当前值", 一个事务在写、另一个想读同一份数据就撞上了, 只能上锁: 读者等写者、写者等读者, 并发死活上不去;

MVCC 换了个思路: 每次写不是"把旧值改掉", 而是"生成一个新版本", 旧版本留着; 每个读看到的是某个时间点的一致快照, 各读各的版本, 于是读者不挡写者、写者也不挡读者, 因为它俩碰的根本就不是同一份东西; PostgreSQL、MySQL InnoDB、Oracle 全是这么干的;

etcd 里的 MVCC 长什么样

  1. 一个集群级、全局单调递增的 revision, 每次写/事务成功就 +1;
  2. 底层 bbolt 存的不是 key → value, 而是 (key, revision) → value, 所以历史版本天然就共存了;
  3. 每个 key 还挂三个数:
    • create_revision: 这 key 被创建时的 revision;
    • mod_revision: 最后一次改它的 revision, CAS/Txn 就靠比这个;
    • version: 它被改过几次;
  4. 读可以 etcdctl get x --rev=6 直接读历史快照;

拿 key x 走一遍:

1
2
3
rev 1:  x = "a"          ← 版本1
rev 5: x = "b" ← 写新版本, "a" 还留着
rev 8: delete x ← 写一个 tombstone(墓碑), 不是物理删

三个读者各看各的: "读最新"看到已删除; "读 as of rev 6"看到 x="b"; "读 as of rev 3"看到 x="a"; 同一份数据同一时刻, 带着各自的版本视角, 看到的不一样还都自洽;

代价就是旧版本越堆越多, 所以得定期 compaction 把老版本丢掉回收空间; 但压缩之后那些老快照就读不到了, Watch 也没法从更早的点续传 (这个坑后面还会再撞一次);

说到底, MVCC 把 etcd 从"一个会用 Raft 同步的当前值存储", 升级成了"一个带时间轴的存储"; 而 k8s 要的恰恰就是这条时间轴: 谁在哪个 revision 改了什么;

Watch: 时间轴上的游标 + 重放引擎

在做什么

订阅一个 key 或一段区间 (前缀), 从某个 revision 开始, 把之后所有变更按序、不丢地推给你;

“从某个 revision 开始"就是 MVCC 直接喂出来的能力: 因为旧版本还在, 才能让你"回到过去某个点开始订阅”, 而不是只能盯着未来;

API 形态 (v3, gRPC streaming)

Watch 是一条双向 gRPC 流, 不是一次性请求:

1
2
3
4
5
6
7
8
9
10
11
client → WatchCreateRequest {
key, range_end, // 单 key, 或区间/前缀
start_revision, // 从哪开始; 省略 = 只看当前之后的未来
prev_kv, // 要不要带上变更前的旧值
progress_notify, // 要不要定期推进度
filters // 比如只看 PUT 不看 DELETE
}
server → WatchResponse 流 {
watch_id, header.revision,
events: [ Event{type:PUT|DELETE, kv:{...}, prev_kv:{...}}, ... ]
}

一条 gRPC 流能复用好多个逻辑 watcher (各有一个 watch_id); DELETE 事件的 kv.value 是空的 (对应那个 tombstone);

三条核心保证 (一定要记)

保证 含义
Ordered 有序 事件严格按 revision 递增, 绝不乱序
Reliable 可靠 只要没被 compaction 干掉, watched 区间里的事件一个都不会被悄悄丢
Atomic 原子 同一个 revision 产生的多个事件, 一定在同一个 WatchResponse 里整批给你

第三条特别关键: 一次 Txn 可能同时改好几个 key, 它们共享同一个 mod_revision, Watch 保证把它们打包一起给你, 你不会读到"半个事务";

内部实现: synced / unsynced 双队列

服务端拿 watchableStore 把 MVCC store 一裹, 然后把所有 watcher 分成两拨:

  • synced (已经追平的): start_revision 已经赶上当前 revision 了; 走热路径, 每次写一提交就在 notify 流程里直接推过去, 延迟最低;
  • unsynced (落后的): start_revision 还停在过去 (比如你 watch from rev 100, 现在都 rev 5000 了); 有个后台 goroutine syncWatchers 周期性地去 bbolt 按 revision 区间扫历史事件, 把落在你 watch 区间里的补发给你, 追平了再把它从 unsynced 挪进 synced;
1
2
3
4
你 watch from rev=100


[unsynced] ──syncWatchers 扫 bbolt 历史(100→当前)──▶ 追平 ──▶ [synced] ──新写直接推──▶ 你
  • victim / 慢消费者: 万一你的接收 channel 满了 (你处理太慢), etcd 把这个 watcher 丢进 victim 列表先缓着、之后再重试, 免得一个慢家伙把整条写路径拖死;
  • 底层撑这一切的是内存里一个 treeIndex (B-tree), 把"用户 key → keyIndex(这 key 历史上所有 revision)"映射起来; 补历史的时候拿 revision 区间去 bbolt 扫, 再用 treeIndex 过滤;

和 compaction 的致命交互

如果你的 start_revision <= compact_revision (你想从的起点已经被压缩掉了), 服务端没法重放那段历史了, 就返回 ErrCompacted、把这个 watch 取消掉 (canceled=true);

客户端这时候唯一的出路: 重新拉一次全量当前状态, 拿到新 revision, 再从新 revision 重新 watch; 记住这个套路, k8s 的 relist 就是它;

断线续传

客户端得一直记着自己最后收到的那条事件的 revision = R, 重连时用 start_revision = R+1 重建 watch (+1 是因为 R 那条已经收到过了); 对于很久不变的 key, progress_notify 会周期性推一个空 WatchResponse, 只带当前 revision, 告诉你"到这儿为止你没漏", 让 R 能跟着往前挪, 免得回退太多一不小心撞上 compaction;

Watch 到底是为了啥: 拿事件流换掉轮询

机制讲完, 退一步看它为啥存在; 一句话, 就是让分布式组件从"自己主动去轮询共享状态"变成"被动收变更", 把状态同步变成事件驱动;

没有 Watch 你只能轮询, 而轮询有个死结:

1
2
3
4
while true:
state = etcd.get("/config") // 拉全量
if state != last: react()
sleep(?) // 这个 ? 怎么填都不对
  • 间隔短, 实时是实时, 但 N 个组件高频拉, 直接把 etcd 打爆 (惊群);
  • 间隔长, 负载是下来了, 但变更得等下一轮才发现, 延迟高;
  • 更阴间的是漏更新: 两次轮询之间一个 key 被改了又改回去, 你压根察觉不到中间发生过啥;

轮询的根子病在于它只能看到"现在的快照", 看不到"发生过的事件序列"; Watch 拿"推送 + 有序不丢 + 能从历史续传"一次性把这仨补齐, 于是应用就能安心搭这么个东西: 本地存一份共享状态的实时镜像, 每来一个变更就触发一段反应逻辑, 也就是 List-Watch 模式;

落到具体应用:

应用 Watch 在这干啥
动态配置 watch 配置 key, 一改全实例热加载, 不重启也不轮询
服务发现 watch /services/ 前缀, 实时维护一份活的 endpoint 列表
Leader election watch leader key, leader 一挂立刻感知然后接管
分布式锁/队列 watch “排我前面那个 key”, 它一释放就精确把我叫醒
k8s 控制器调谐 watch 期望状态, 一变就触发 reconcile, 把实际拽向期望

reconcile: 控制器调谐到底是个啥

最后一行是 k8s 选 etcd 的根本理由, 单拎出来讲;

reconcile 这词的本义

它在会计里就是"对账、把两边弄一致": 你 APP 里记的余额和银行对账单本该一样, 不一样就逐项核对、调整, 直到对上; k8s 把这词原封不动搬了过来, 两份"账"换成了:

  • 期望状态 (desired state): 你声明的、存在 etcd 里的 (“我要 3 个 Pod”);
  • 实际状态 (actual state): 集群里真在跑的 (“现在只有 2 个 Pod”);

reconcile 就是一个循环跑的函数

1
2
3
4
5
reconcile():
desired = 读期望状态 // 从 informer 本地缓存
actual = 观察实际状态
diff = desired - actual
采取动作抹平 diff // 多了删, 少了建

拿 Deployment 声明 replicas: 3 走一遍:

1
2
3
4
第1轮: desired=3, actual=2 → 差1 → 创建1个 Pod
第2轮: desired=3, actual=3 → 差0 → 啥都不干
(某个 Pod 崩了)
第3轮: desired=3, actual=2 → 差1 → 再补1个

所谓控制器, 就是一个不停跑这个 reconcile 的死循环 (control loop);

关键的一点: 它是 level-triggered, 不是 edge-triggered

风格 怎么反应 毛病
edge-triggered 边沿触发 盯"发生了什么变化" (“刚有人删了个 Pod, 我补一个”) 漏一个事件, 状态就永久错了
level-triggered 电平触发 盯"当前是什么状态" (“不管刚才发生过啥, 我现在看一眼差几个, 补几个”) 漏事件无所谓, 下一轮重算就修正

k8s 的 reconcile 是 level-triggered 的; 这就解释了为啥 Watch 偶尔漏个事件、或者 relist 一下不致命:

Watch 事件在这儿其实只是个"醒一醒, 去重新对一次账"的闹钟; 真正干活的 reconcile 每次都重新读全量期望、重新看一眼实际、重算差距; 所以你漏了某个事件, 下次随便哪个事件 (或者定时 resync) 触发 reconcile, 照样能算出差距修回来;

也正因为这样, reconcile 必须是幂等的: 算出"差 0"就什么都不做, 重复跑也没副作用;

你在别处见过的 reconcile (Terraform 的 plan/apply、React 协调虚拟 DOM、Git merge) 本质都是同一个模式: 比较"想要的"和"现有的", 生成并执行抹平差异的动作; k8s 只是把它做成了集群级、永不停歇的基础范式;

谁在 watch 谁: Pod 要自己维持 Watch 吗

这是个特别常见的误解, 先甩结论:

普通应用 Pod 里的程序完全不需要、也根本不参与 Watch; Watch 是 k8s 控制面在"外面"自动维持的, 你的 Pod 对此一无所知;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
        etcd
▲ 只有 kube-apiserver 直连 etcd(读/写/watch)

kube-apiserver ← 唯一的 etcd 大门

│ 这些组件 watch 的是 apiserver, 不是 etcd
┌──────┼───────────────┬──────────────┐
controller-manager scheduler kubelet


┌─────────────┐
│ 你的 Pod │ ← 它是“被管理的对象”, 不是 watcher
│ (web/ML…) │ 里面程序啥都不用做, 正常跑业务
└─────────────┘

两个关键事实:

  1. 只有 kube-apiserver 直连 etcd; 你的 Pod 永远不碰 etcd, 连它地址都不知道;
  2. 真正干 Watch 这活的是控制面组件 (controller-manager 里各种控制器、scheduler、kubelet), 它们 watch 的还是 apiserver (apiserver 再去 etcd 拿, 中间还垫了个 watch cache, 不然几千个 informer 能把 etcd 打穿);

心智模型纠一下: 你的 Pod 是"账本里被对账的那一行 (actual state)", 不是"对账的人"; Pod 崩了, 是控制器 watch 到"少了一个"然后补一个, 不是 Pod 自己 watch 自己、自己救自己; 所以应用层啥 watch 都不用写、不用心跳上报、不用维持连接, 这正是声明式最爽的地方: 应用无感;

唯一的例外是你自己写控制器/Operator (ingress-controller、各种 Operator、service mesh 控制面) 的时候, 这个 Pod 本身就是个 watcher 了; 但就算这样, 一来它 watch 的仍然是 apiserver, 绝不直连 etcd; 二来你也不用手撸 gRPC watch 流、重连、relist 那一套, 用官方的 client-go informer 框架就行: 注册几个事件回调 + 一个 reconcile 函数, watch 的生命周期 (连接、重连、relist、本地缓存) 全由库替你维持, 你只管写"差距出现时干啥";

k8s 怎么把这套吃下去 (List-Watch 全景)

informer/reflector 基本就是 Watch 语义的教科书式用法:

  1. List: 先全量拉一次某个资源 (比如所有 Pod), 拿到 resourceVersion = RV0; 这个 resourceVersion 本质就是 etcd 的 revision (对上层是个不透明字符串);
  2. Watch: 从 RV0 + 1 开始 watch, 增量事件灌进本地缓存 (Indexer/Store), 控制器从本地缓存做 reconcile, 这就是调谐数据的来路;
  3. Relist (兜底): watch 报 410 Gone / too old resourceVersion (底层就是 etcd ErrCompacted), 说明你落后到被压缩了; reflector 就把缓存一扔, 重新 List、重新 Watch; 跟前面 Watch 那节的续传套路一模一样;

两个 k8s 特有的优化:

  • Watch Cache: apiserver 在内存里维护一圈最近事件的环形缓冲, 几千个 informer 都从这个 cache 拿, 而不是各自打穿到 etcd;
  • Bookmark 事件: apiserver 周期性发一个只带 resourceVersion、没有实际变化的 bookmark, 让闲着的 watcher 也能把自己的 RV 往前挪 (跟 etcd 的 progress_notify 一个思路), 减少 relist;

小结: 一条链路

1
2
3
4
5
6
Raft(有序日志共识)
└→ MVCC(给存储装时间轴: revision)
└→ Watch(时间轴上的游标+重放: 有序/不丢/可续传)
└→ List-Watch(本地实时镜像)
└→ reconcile(level-triggered 对账, 容忍丢事件)
└→ 声明式 + 控制循环(k8s 的根范式)

所以 etcd 的角色, 与其说是"存数据的数据库", 不如说是"整个集群协调逻辑的事件骨架": MVCC 提供时间轴, Watch 把时间轴变成可靠事件流, reconcile 在事件流上做幂等对账; 这三层叠起来, k8s "你声明期望、系统自己收敛"这件事才在工程上真正成立;

待填 / 下一步

  • [ ] Lease + leader election 实操: 上表"成员管理/选主"那一格的具体实现;
  • [ ] 线性一致读 (ReadIndex): 读侧怎么保证不读到旧 Leader 的脏数据 (serializable vs linearizable read);
  • [ ] Txn / CAS 乐观并发的实际写法;
  • [ ] 存储与持久化: bbolt 后端 + WAL + snapshot, Raft 日志怎么落盘、怎么截断;
  • [ ] 回到 k8s: 抓一眼 apiserver 访问 etcd 的实际 key 结构 (/registry/...);