第 4 章 · etcd 与一致性:状态存哪、怎么保证「众人看到同一份」

一句话结论:K8s 把整个集群的期望状态 + 实际状态,全部存进一个分布式 KV 存储 etcd,让它成为「唯一事实源(single source of truth)」。所有控制器、调度器、API 服务器,都通过**「读 etcd + watch 变化」**来对同一份状态达成一致。这个「唯一事实源 + 事件通知」的选择,是 K8s 协调一切的地基。


1. 先问:状态到底该存哪?

第 2、3 章告诉我们:声明式期望状态要被持久化,控制器要能读到它、并持续对比实际状态。于是冒出一个基础问题——这份「全集群共享的状态」存在哪?

方向 A:存关系数据库(MySQL/Postgres)。

  • 优点:成熟、事务、SQL 查询方便。
  • 问题:K8s 需要的不是「复杂查询」,而是① 高吞吐的小对象读写,② 强一致,③ 最重要的——「变化通知」(watch)。关系数据库做「订阅某张表的变化并实时推送」很别扭。

方向 B:存分布式 KV(etcd / ZooKeeper / Consul)。

  • 优点:天生为「一致性的小对象存储 + 变更通知」设计。
  • 这正是 K8s 需要的那三样。

为什么 K8s 选 etcd 而不是 ZooKeeper(这也是一个真实的选型思考):

  • etcd 用 Raft 共识算法保证强一致(多副本之间状态严格一致),而 ZooKeeper 用的是 ZAB 协议、且它的一些 API(比如 watch 是一次性的、需要反复注册)用起来更别扭。
  • etcd 的 watch 是「长期流式」的——订阅一次,之后的所有变更都会持续推送过来,天然契合控制器的「持续关注」需求。
  • etcd 提供租约(lease)和乐观并发(compare-and-swap),这些对 K8s 的选主、并发控制至关重要。

记住选型的本质:不是「哪个数据库牛」,而是「哪个存储的抽象,恰好匹配你要解决的问题的形状」。 K8s 的问题形状是「强一致 + 小对象 + 高吞吐读写 + 变更通知」,etcd 的形状恰好就是为这个刻出来的。


2. 一致性的真正难点:并发修改怎么办?

多个控制器、多个用户,可能同时改同一个对象。比如用户 A 把 replicas 改成 3,用户 B 同时把它改成 5——谁生效?怎么避免「后写覆盖先写、且两者都不自知」?

K8s 用的是一套乐观并发控制(optimistic concurrency),核心是一个字段 resourceVersion:

type Deployment struct {
    ResourceVersion string // 每次修改都会自增的「版本号」
    Spec            DeploymentSpec
}
 
// 乐观并发更新:带上「我基于哪个版本改的」
func (c *Client) Update(obj *Deployment) error {
    // 提交时声明:只有当对象当前版本 == 我读到的版本,才允许更新
    resp := etcd.CompareAndSwap(
        key, obj,
        ifVersion: obj.ResourceVersion,   // 前提:版本没被别人动过
    )
    if resp.Conflict {
        return ErrConflict                // 别人先改了,你要重读、重试
    }
    return nil
}

这套机制解决的是分布式系统里的经典问题——丢失更新(lost update):

  • A 读到版本 1,B 也读到版本 1。
  • A 先提交「改成 3」,成功,版本变成 2。
  • B 再提交「改成 5」,但 B 声明「基于版本 1」→ 被拒绝(Conflict)。
  • B 必须重读(看到版本 2、replicas=3),重新决策,再提交。

为什么用「乐观」而不是「悲观」(加锁):

  • 悲观锁在分布式下代价极高(锁要跨机器、要处理死锁、要处理持锁者崩溃)。
  • 乐观并发假设「冲突是小概率」,只在提交时检查,冲突了再重试——在 K8s 这种「读多写少、写冲突罕见」的场景下,乐观并发用极低的代价就换来了正确性。
  • 而且 resourceVersion 还顺手解决了一个问题:它让 watch 能「从某个版本开始接着看」,断线重连后不会漏掉中间的变化。

3. watch 机制:让控制器「订阅」变化,而不是「轮询」

第 1 章结尾埋了钩子:编排系统要用「事件驱动 + 定期对账兜底」。这里的「事件驱动」,就是 etcd 的 watch:

// 控制器的「眼睛」:不是轮询,而是订阅
func (c *DeploymentController) watch() {
    stream := etcd.Watch(prefix("/deployments/"))   // 订阅这个前缀下的所有变化
    for event := range stream {
        c.queue.Add(event.Key)                       // 有变化 → 塞进工作队列
    }
}

再配合第 3 章的 reconcile 循环,就形成了 K8s 经典的 informer 机制(client-go 的核心):

etcd 变化 → watch 事件 → informer 缓存 → 工作队列 → 控制器 reconcile

这套机制有几个精妙点,值得你(Go 程序员)特别留意:

  1. informer 的本地缓存:控制器不每次直接查 etcd,而是维护一份本地内存缓存(通过 watch 保持同步),reconcile 时从缓存读。这避免了「每个控制器都直接打 etcd」,把 etcd 从「高频读」里解放出来。
  2. 工作队列 + 去重:同一个对象短时间内多次变化,队列会合并成「只处理一次」。因为 level-driven 的 reconcile 不需要「每次变化都处理」,只需要「最终处理到最新状态」。
  3. watch 断线重连 + 版本续传:靠 resourceVersion 从断点续看,不漏事件。

这整套「watch + 缓存 + 队列 + reconcile」的组合,是 Go 生态里最经典的「事件驱动 + 异步对账」范本。如果你要自己造一个「持续协调多个子系统状态」的系统,这套骨架几乎可以原样搬过去。


4. 为什么「唯一事实源」这么重要

把状态集中到 etcd 一个地方,带来的好处是根本性的:

  • 所有组件看到的是同一份真相:调度器、控制器、用户看到的 Deployment 是同一个对象,不存在「各组件各自维护一份状态、越维护越不一致」的问题。
  • 可恢复:etcd 有快照 + 日志,整个集群状态可以备份、恢复。
  • 可观测:集群「现在应该是什么样」有唯一出处,排查问题有据可依。

代价:etcd 成了单点依赖 + 性能瓶颈。所以 K8s 花了大量精力在「怎么让 etcd 少扛读压力」(informer 缓存)、「怎么扛住大规模」(分片、按对象类型分存储)上。


思考题

Q:既然 informer 用「本地缓存 + watch 同步」来减轻 etcd 读压力,那缓存会不会「读到过期数据」?控制器基于一个「可能已经过时」的缓存做决策,会不会做出错误动作?

参考答案:

会读到「略微过期」的数据,但这不影响正确性,原因正是第 3 章讲的 level-driven:

  • 缓存可能慢半拍(watch 事件还在路上),但只要最终会同步到最新状态,控制器在下一次 reconcile 时就会看到最新状态并纠正。
  • 换句话说:「短暂的不一致」是可以容忍的,只要「最终收敛」被保证。 这正是分布式系统里著名的「最终一致性(eventual consistency)」思想——K8s 不追求「每一瞬间都严格一致」,它追求「最终一定收敛到期望状态」。
  • 但要注意一个边界:对「必须强一致」的关键操作(比如并发写、选主),K8s 会绕开缓存、直接走 etcd 的乐观并发控制,而不是靠可能过期的缓存。所以答案是分层的——读可以容忍最终一致(用缓存),写必须强一致(用 etcd 的 CAS)。

这引出一个很有价值的分布式系统认知:一致性不是「要么全强、要么全弱」的二选一,而是「按操作的关键程度分层」——强一致用在该用的地方(写、选主、抢锁),最终一致用在能容忍的地方(读、观测、对账)。 把一致性当「可调的资源」而不是「绝对的属性」,是设计分布式系统的重要心智。