土法炼钢兴趣小组的算法知识备份

【分布式系统百科】etcd 深度解剖:从 Watch 机制到 MVCC 存储引擎

文章导航

分类入口
distributed
标签入口
#etcd#mvcc#boltdb#watch#lease#raft#kubernetes#distributed-coordination

目录

etcd 深度解剖:从 Watch 机制到 MVCC 存储引擎

你在 Kubernetes 集群上部署了一个 Deployment,把副本数从 3 改成 5。kubectl 命令返回成功。几秒之内,两个新 Pod 被调度到不同节点上启动。整个过程没有任何轮询,没有定时扫描——kube-controller-manager 是怎么知道副本数变了的?

答案是 Watch。kube-controller-manager 对 etcd 中所有 Deployment 对象建立了持久的 Watch 连接。你修改副本数的那一刻,etcd 通过 gRPC 双向流(bidirectional streaming)把变更事件推送给 kube-apiserver,kube-apiserver 再推送给 controller-manager。controller-manager 计算出当前只有 3 个 Pod,期望 5 个,于是创建两个新 Pod 对象写回 etcd。scheduler 同样在 Watch 新创建的 Pod,发现有未调度的 Pod,执行调度逻辑并写回绑定信息。kubelet Watch 到自己节点上被绑定的 Pod,拉起容器。

这条链路上,每一步都由 Watch 事件驱动。如果 Watch 丢了一个事件,整个链路就断了——Pod 永远不会被创建,或者永远不会被调度。etcd 的 Watch 机制凭什么敢承诺”不丢事件”?

这篇文章从 Watch 开始,逐层拆解 etcd 的核心机制。Watch 的持久性和 Revision 追溯能力来自 MVCC 存储引擎,MVCC 的数据一致性来自 Raft 共识,Raft 的持久性来自 WAL(预写日志)。每一层都不能单独理解——它们是一个紧密耦合的整体。

前置知识:建议先阅读 Raft 共识算法ZooKeeper 内核,了解分布式共识和协调服务的基本概念。

下图展示了 etcd 的内部架构全貌,后续各节将逐一深入每个组件:

etcd 内部架构

一、Watch 机制:持久 Watch 与 Revision 追溯

1.1 从 ZooKeeper 的痛点说起

要理解 etcd Watch 的设计意图,最好先看看它试图解决的问题。ZooKeeper 的 Watch 是一次性的(one-time trigger):客户端对一个 znode 注册 Watch 后,下一次该 znode 变更时触发通知,然后 Watch 自动注销。如果客户端想继续监听,必须在处理完通知后重新注册。

这个设计有一个根本缺陷:在通知到达和重新注册之间,如果 znode 又发生了变更,客户端会错过这些事件。ZooKeeper 文档自己也承认这一点,建议客户端在收到通知后重新读取数据来弥补。但”读取当前值”和”获取所有变更历史”是两件不同的事。如果一个 key 在 100ms 内被写了三次:A -> B -> C,客户端只能看到最终值 C,丢失了中间状态 B。对于很多场景——比如审计日志、事件溯源——中间状态至关重要。

etcd v3 的 Watch 彻底重新设计了这个机制。

1.2 持久 Watch 与 Revision

etcd 的 Watch 是持久的:一旦建立,除非客户端主动取消或连接断开,Watch 会持续推送所有后续变更事件。更关键的是,etcd 的每一次写入操作都会分配一个全局递增的修订号(Revision),Watch 可以指定从任意历史 Revision 开始监听。

Revision 是一个二元组 (main, sub)。main 是全局递增的事务编号,每个事务(包括单 key 写入,因为单 key 写入本质上是一个只包含一个操作的事务)递增一次。sub 是事务内的操作序号,一个事务如果包含多个操作,sub 从 0 开始递增。

// etcd 源码中的 revision 定义(server/storage/mvcc/revision.go)
type revision struct {
    main int64
    sub  int64
}

这意味着 etcd 可以精确区分同一个事务内的不同操作,也可以在不同事务之间建立严格的全序关系。当客户端建立 Watch 时,可以指定 start_revision,etcd 会从该 Revision 开始推送所有后续事件,保证不丢不重。

# 从 revision 5 开始 watch /foo 前缀下所有 key 的变更
etcdctl watch /foo --prefix --rev=5

这个能力解决了 ZooKeeper 的根本问题:客户端即使断开重连,只要记住上次收到的最后一个 Revision,就可以从该 Revision + 1 继续 Watch,不会错过任何事件。唯一的前提是:你要 Watch 的历史 Revision 还没有被压缩(compaction)。如果已经被压缩,etcd 会返回 ErrCompacted 错误,客户端需要执行全量重新同步。

1.3 Watch 的内部实现:watchableStore

etcd 的 Watch 实现围绕 watchableStore 这个核心结构展开。watchableStore 内嵌了 MVCC 存储(store),并在其基础上维护了两组 Watcher:

// watchableStore 的核心结构(简化)
type watchableStore struct {
    *store
    synced   watcherGroup  // 已追上最新 revision 的 watcher
    unsynced watcherGroup  // 还在追赶历史 revision 的 watcher
    victims  []watcherBatch // 因 channel 满而暂时挂起的事件
}

当一个写操作被 Raft 提交并应用(apply)到 MVCC 存储后,watchableStore 会做以下事情:

  1. 在 treeIndex 和 BoltDB 中写入新的 key-value 和 revision 映射。
  2. 遍历 synced watchers,找到所有匹配该 key(或 key 前缀/范围)的 Watcher。
  3. 为每个匹配的 Watcher 生成一个 WatchEvent,包含事件类型(PUT 或 DELETE)、key、value 和 revision。
  4. 将事件推入 Watcher 的输出 channel。如果 channel 已满(说明客户端消费慢),将 Watcher 移入 victims 列表,后续重试。

这个设计的关键优势是:对于已经同步的 Watcher,事件推送的延迟几乎等于 Raft 提交的延迟——不需要额外的轮询或扫描。

1.3a Put 请求的完整 Watch 触发链路

从客户端发起一次 Put 请求到 Watcher 收到事件通知,数据流经的完整链路如下:

sequenceDiagram
    participant C as 客户端
    participant API as gRPC API层
    participant R as Raft模块
    participant F as Follower
    participant MVCC as MVCC存储引擎
    participant W as watchableStore
    C->>API: Put(/foo, "bar")
    API->>R: Propose日志条目
    R->>F: MsgApp复制
    F-->>R: 多数派ACK
    R->>MVCC: Apply已提交条目
    MVCC->>MVCC: treeIndex插入revision
    MVCC->>MVCC: BoltDB写入KV
    MVCC->>W: 通知synced watchers
    W->>C: 推送WatchEvent(PUT)
    API-->>C: 返回OK(revision=N)

该链路清晰地展示了 etcd 写入操作的两条并行路径:一条是 Raft 共识后 Apply 到 MVCC 存储引擎的持久化路径,另一条是通过 watchableStore 向已订阅 Watcher 推送事件的通知路径。需要注意的是,Watcher 收到事件的时刻可能略早于客户端收到 Put 响应,因为 Watch 推送与 gRPC 响应分属不同的流。这种设计保证了 Watch 事件的实时性,使下游消费者能够以极低延迟感知数据变更。

1.4 WatchStream 多路复用

一个客户端可能需要同时 Watch 多个 key 或 key 范围。为每个 Watch 建立一个独立的 gRPC 连接显然不经济。etcd 使用 WatchStream 多路复用(multiplexing)机制:一个 gRPC 双向流可以承载多个 Watch,每个 Watch 有独立的 watch_id。

// 客户端创建多个 Watch 共享同一个 gRPC Stream
cli, _ := clientv3.New(clientv3.Config{Endpoints: endpoints})
watcher := cli.Watch(ctx, "/services/", clientv3.WithPrefix())
// 同一连接上再 Watch 另一个前缀
watcher2 := cli.Watch(ctx, "/config/", clientv3.WithPrefix())

在服务端,所有 Watch 共享同一个 gRPC 双向流连接。每个 WatchResponse 中包含 watch_id 字段,客户端据此分发事件到对应的回调处理逻辑。

1.5 Watch 事件类型与顺序保证

etcd Watch 只有两种事件类型:

事件类型 触发条件 说明
PUT 创建或更新 key 包含新的 key-value
DELETE 删除 key 或 Lease 过期 value 为空,key 保留

事件的顺序保证是严格的:同一个 Watch 收到的事件按 Revision 严格递增。跨 key 的事件也满足这个保证——如果 key A 在 Revision 10 被写入,key B 在 Revision 11 被写入,Watch 会先推送 A 的事件再推送 B 的事件。这个保证来自 Raft 的全序提交特性:所有写入操作在 Raft 日志中有唯一的顺序,Revision 就是这个顺序的编号。

1.6 与 ZooKeeper Watch 的本质区别

下表总结了两种 Watch 机制的核心差异:

特性 etcd v3 Watch ZooKeeper Watch
持久性 持久(长连接) 一次性(触发后注销)
历史追溯 指定 Revision 回溯 不支持
事件丢失 保证不丢(Revision 连续) 重注册间隙可能丢事件
传输协议 gRPC 双向流 TCP 自定义协议
多路复用 单连接多 Watch 每个 Watch 独立注册
事件内容 完整 key-value + revision 仅通知变更发生

最后一点值得强调:ZooKeeper 的 Watch 通知不包含变更的值,只告诉你”这个 znode 变了”,客户端需要再发一次 getData 请求获取新值。etcd 的 Watch 事件直接包含完整的 key-value 对和新旧 Revision,减少了一次网络往返。


二、Lease 机制:分布式场景下的 TTL 管理

2.1 Lease 的基本概念

Lease(租约)是 etcd 提供的一种带有生存时间(TTL,Time To Live)的资源。你可以创建一个 Lease 并指定 TTL(比如 10 秒),然后将任意数量的 key 附加(attach)到这个 Lease 上。当 Lease 到期且没有被续约时,所有附加到该 Lease 的 key 会被自动删除。

这个机制是服务注册与发现(service registration and discovery)的基础设施。一个服务实例启动时,在 etcd 中注册一个 key(比如 /services/api/instance-1),并附加到一个 TTL 为 10 秒的 Lease 上。服务实例每隔 3 秒发送一次 KeepAlive 请求续约。如果实例崩溃,KeepAlive 停止,10 秒后 Lease 过期,key 被自动删除,其他服务就知道这个实例不可用了。

2.2 Lease 的完整生命周期

Lease 的生命周期包含以下阶段:

创建(Grant):客户端向 etcd 请求创建一个 Lease,指定 TTL。etcd 返回一个全局唯一的 Lease ID。

# 创建一个 TTL 为 30 秒的 Lease
etcdctl lease grant 30
# 输出: lease 694d81417aec400b granted with TTL(30s)

附加 key(Attach):将 key 附加到已有的 Lease。一个 Lease 可以附加多个 key,但一个 key 只能属于一个 Lease。

# 将 key 附加到 Lease
etcdctl put /services/api/instance-1 "192.168.1.10:8080" --lease=694d81417aec400b

续约(KeepAlive):客户端定期发送 KeepAlive 请求,重置 Lease 的 TTL 计时器。

# 持续续约
etcdctl lease keep-alive 694d81417aec400b

撤销(Revoke):主动撤销 Lease,立即删除所有附加的 key。

# 主动撤销
etcdctl lease revoke 694d81417aec400b

过期(Expire):如果 TTL 内没有收到 KeepAlive,Lease 自动过期,效果等同于 Revoke。

2.2a Lease 生命周期状态机

Lease 在整个生命周期中经历的状态转换可以用以下状态机来描述:

stateDiagram-v2
    [*] --> Granted : Grant(TTL)
    Granted --> Active : 附加key
    Active --> Active : KeepAlive续约(重置TTL)
    Active --> Expired : TTL耗尽,无续约
    Active --> Revoked : 主动撤销
    Expired --> KeysDeleted : 自动删除所有关联key
    Revoked --> KeysDeleted : 立即删除所有关联key
    KeysDeleted --> [*]

从状态机可以看出,Lease 的终态只有一个——关联 key 被删除并释放资源。Active 状态是 Lease 的核心工作状态,客户端通过周期性 KeepAlive 维持该状态;一旦续约中断,Lease 将沿 Expired 路径自动清理。主动 Revoke 与被动 Expire 在最终效果上等价,但 Revoke 立即生效,适用于服务主动下线等需要确定性清理的场景。

2.3 Go 客户端中的 Lease 使用

以下是一个完整的服务注册示例:

package main

import (
    "context"
    "fmt"
    "log"
    "time"

    clientv3 "go.etcd.io/etcd/client/v3"
)

func main() {
    cli, err := clientv3.New(clientv3.Config{
        Endpoints:   []string{"localhost:2379"},
        DialTimeout: 5 * time.Second,
    })
    if err != nil {
        log.Fatal(err)
    }
    defer cli.Close()

    // 创建一个 TTL 为 10 秒的 Lease
    leaseResp, err := cli.Grant(context.TODO(), 10)
    if err != nil {
        log.Fatal(err)
    }
    leaseID := leaseResp.ID

    // 将 key 附加到 Lease
    _, err = cli.Put(context.TODO(), "/services/api/instance-1",
        "192.168.1.10:8080", clientv3.WithLease(leaseID))
    if err != nil {
        log.Fatal(err)
    }

    // 启动自动续约(返回一个 channel,持续接收续约响应)
    keepAliveCh, err := cli.KeepAlive(context.TODO(), leaseID)
    if err != nil {
        log.Fatal(err)
    }

    // 消费续约响应(必须消费,否则 channel 阻塞会导致续约停止)
    for ka := range keepAliveCh {
        if ka == nil {
            fmt.Println("Lease expired or revoked")
            break
        }
        fmt.Printf("Lease renewed, TTL: %d\n", ka.TTL)
    }
}

2.4 Lease Checkpoint 机制

etcd 3.4 引入了 Lease Checkpoint(租约检查点)机制(--experimental-enable-lease-checkpoint,3.6 中正式稳定),解决了一个微妙但重要的问题:Leader 切换时 Lease 的 TTL 会被重置。

问题场景是这样的:一个 TTL 为 60 秒的 Lease 已经存活了 55 秒(还剩 5 秒就要过期)。此时 Leader 发生了切换。新 Leader 上任后,从 Raft 日志中恢复 Lease 信息,但它只知道 Lease 的原始 TTL 是 60 秒,不知道已经过了 55 秒。于是 Lease 的 TTL 被重置为 60 秒,相当于被”续命”了 55 秒。在极端情况下,如果 Leader 频繁切换,Lease 可能永远不会过期。

Lease Checkpoint 的解决方案是:Leader 定期将所有 Lease 的剩余 TTL 持久化到 Raft 日志中。当新 Leader 上任时,它可以恢复每个 Lease 的真实剩余 TTL,而不是使用原始 TTL。

# 启用 Lease Checkpoint(etcd 3.4+)
etcd --experimental-enable-lease-checkpoint=true \
     --experimental-enable-lease-checkpoint-persist=true

2.5 Lease 的典型使用场景

服务注册与发现:如 2.3 节所示,将服务实例信息作为 key 附加到 Lease。实例存活时持续续约,崩溃后 key 自动消失。

分布式锁:获取锁时创建一个带 Lease 的 key。如果持有锁的客户端崩溃,Lease 过期,锁自动释放。etcd 官方提供了基于 Lease 的分布式锁实现 concurrency.NewMutex

session, _ := concurrency.NewSession(cli, concurrency.WithTTL(15))
mutex := concurrency.NewMutex(session, "/locks/my-resource")
if err := mutex.Lock(context.TODO()); err != nil {
    log.Fatal(err)
}
// 临界区操作
mutex.Unlock(context.TODO())

Leader 选举:多个候选者竞争创建同一个 key。创建成功的成为 Leader,并通过 Lease 续约保持 Leader 身份。如果 Leader 崩溃,Lease 过期,其他候选者可以竞选。

临时配置:将临时性的配置(如特性开关、灰度比例)附加到 Lease。设置者主动管理 Lease 的生命周期,当不再需要这些配置时,撤销 Lease 即可批量删除。

2.6 KeepAlive 的内部机制

客户端调用 KeepAlive 后,etcd Go 客户端库会启动一个后台 goroutine,以 Lease TTL / 3 的间隔自动发送 KeepAlive 请求。例如 TTL 为 15 秒的 Lease,客户端每 5 秒发送一次 KeepAlive。

KeepAlive 请求通过 gRPC 双向流传输,多个 Lease 的 KeepAlive 可以复用同一个流。服务端收到 KeepAlive 后,只在本地重置 Lease 的过期时间——这是一个纯本地操作,不经过 Raft。这意味着 KeepAlive 的性能非常高,不会给 Raft 日志带来额外压力。

但这也带来了一个一致性问题:如果 Leader 收到 KeepAlive 后立即崩溃,新 Leader 不知道这次续约,Lease 可能提前过期。在实践中,这个问题的影响很小——客户端会在 Lease 过期前多次发送 KeepAlive,下一次 KeepAlive 到达新 Leader 就会续上。但如果你的系统对 Lease 的精确过期时间有严格要求,需要了解这个行为。


三、MVCC 存储引擎:treeIndex 与 BoltDB 的双层架构

3.1 为什么需要 MVCC

etcd 的存储引擎使用多版本并发控制(MVCC,Multi-Version Concurrency Control)。MVCC 的核心思想是:每次写操作不覆盖旧值,而是创建一个新版本。旧版本保留在存储中,直到被显式压缩(compaction)。

这个设计服务于两个核心需求:

第一,Watch 需要历史回溯。如前文所述,Watch 可以从任意历史 Revision 开始监听。如果旧版本被覆盖了,Watch 就无法从历史 Revision 推送事件。MVCC 保证所有未被压缩的历史版本都可以被访问。

第二,事务需要快照隔离。etcd 支持事务(mini-transaction),事务中的读操作需要看到一致的快照。MVCC 天然支持这一点——每个事务读取特定 Revision 的快照,不受并发写入的影响。

3.2 双层架构:treeIndex + BoltDB

etcd 的 MVCC 存储引擎由两层组成:

treeIndex:内存中的 B 树索引,基于 Google 的 btree 库实现。treeIndex 将用户 key 映射到 keyIndex 结构,keyIndex 记录了该 key 的所有历史 Revision。

BoltDB(bbolt):磁盘上的 B+ 树存储引擎。BoltDB 以 Revision 为 key,以序列化的 KeyValue 结构为 value。

treeIndex (内存 B-tree):
  "/services/api" -> keyIndex {
      key: "/services/api",
      modified: revision{main: 15, sub: 0},
      generations: [
          {created: rev{10,0}, revs: [rev{10,0}, rev{12,0}, rev{15,0}]},
      ]
  }

BoltDB (磁盘 B+ tree):
  rev{10,0} -> KeyValue{Key:"/services/api", Value:"v1", CreateRevision:10, ModRevision:10, Version:1}
  rev{12,0} -> KeyValue{Key:"/services/api", Value:"v2", CreateRevision:10, ModRevision:12, Version:2}
  rev{15,0} -> KeyValue{Key:"/services/api", Value:"v3", CreateRevision:10, ModRevision:15, Version:3}

一次读操作的流程:

  1. 客户端请求读取 key /services/api
  2. treeIndex 中查找该 key 的 keyIndex,找到最新 Revision {15, 0}
  3. 用这个 Revision 作为 key 去 BoltDB 中查找,得到完整的 KeyValue 结构。
  4. 返回给客户端。

如果是指定 Revision 的读取(比如读取 Revision 12 时的值),步骤 2 中会在 keyIndex 的 Revision 列表中二分查找不超过 12 的最大 Revision,得到 {12, 0},然后从 BoltDB 读取。

3.2a 读取路径可视化

下面用流程图直观展示一次 GET 请求在 MVCC 层的完整数据路径:

flowchart LR
    A[客户端请求: GET /foo] --> B[treeIndex查找]
    B --> C{key存在?}
    C -->|是| D[获取最新revision]
    C -->|否| E[返回key不存在]
    D --> F[BoltDB按revision查询]
    F --> G[反序列化KeyValue]
    G --> H[返回value给客户端]

该流程图揭示了 etcd 读取操作的两级索引本质:treeIndex 作为内存中的 B-Tree 提供 O(log N) 的 key 定位能力,再以查到的 revision 作为二级索引去 BoltDB 中获取实际数据。这种分层设计使得读取操作无需扫描磁盘上的全量数据,同时也解释了为什么 treeIndex 的内存占用会随 key 数量线性增长——每个 key 的所有历史 revision 都保留在内存中,直到被 Compaction 清理。

3.3 keyIndex 结构详解

keyIndex 是理解 etcd MVCC 的关键数据结构:

// server/storage/mvcc/key_index.go(简化)
type keyIndex struct {
    key         []byte
    modified    revision       // 最后一次修改的 revision
    generations []generation   // 一个 key 可能被多次创建/删除
}

type generation struct {
    ver     int64      // 当前 generation 内的版本号
    created revision   // 本 generation 的创建 revision
    revs    []revision // 本 generation 的所有 revision
}

generation 这个概念比较特殊。一个 key 从创建到删除算一个 generation。如果 key 被删除后又被重新创建,就进入下一个 generation。每个 generation 内部的 ver(version)从 1 开始递增。

举例:key /foo 在 Revision 3 创建,Revision 5 更新,Revision 7 删除,Revision 10 重新创建。它的 keyIndex 中有两个 generation:

generation[0]: created=rev{3,0}, revs=[rev{3,0}, rev{5,0}, rev{7,0}(tombstone)]
generation[1]: created=rev{10,0}, revs=[rev{10,0}]

删除操作在 BoltDB 中会写入一个墓碑(tombstone)记录,value 为空。treeIndex 中将 DELETE 的 revision 追加到当前 generation 的 revs 列表末尾。

3.4 PUT 和 DELETE 的存储流程

PUT 操作

  1. 从 Raft 提交的日志条目中取出 PUT 请求。
  2. 递增全局 Revision(main++)。
  3. 在 treeIndex 中查找 key。如果不存在,创建新的 keyIndex 和 generation;如果已存在,向当前 generation 追加新的 revision。
  4. revision -> KeyValue 写入 BoltDB 的当前批次事务(batch transaction)中。
  5. 通知 watchableStore 中匹配的 synced watchers。

DELETE 操作

  1. 从 Raft 提交的日志条目中取出 DELETE 请求。
  2. 递增全局 Revision。
  3. 在 treeIndex 中查找 key。向当前 generation 追加新的 revision 作为墓碑。关闭当前 generation。
  4. revision -> KeyValue{tombstone} 写入 BoltDB。
  5. 通知匹配的 synced watchers(事件类型为 DELETE)。

注意步骤 4 中的”批次事务”:etcd 不是每个操作单独提交到 BoltDB,而是将一批操作聚合成一个 BoltDB 写事务。这是一个重要的性能优化,后文”与 Raft 共识联动”一节会详细讨论。

3.5 压缩(Compaction)

MVCC 保留所有历史版本,如果不清理,磁盘占用会持续增长。etcd 通过压缩机制清理旧版本。

etcd 支持两种压缩方式:

基于 Revision 的压缩:指定一个 Revision 号,删除所有小于该 Revision 的历史版本。

# 压缩 Revision 1000 之前的所有历史版本
etcdctl compact 1000

周期性自动压缩:配置 etcd 自动每隔一段时间(或每隔一定数量的 Revision)执行压缩。

# 每小时自动压缩,保留最近 1 小时的历史
etcd --auto-compaction-mode=periodic --auto-compaction-retention=1h

# 基于 Revision 的自动压缩,保留最近 10000 个 revision
etcd --auto-compaction-mode=revision --auto-compaction-retention=10000

压缩操作在 treeIndex 和 BoltDB 上都需要执行:treeIndex 中删除过期的 revision 和已关闭的空 generation,BoltDB 中删除对应的 key-value 记录。

需要注意:BoltDB 的压缩只是标记页面为空闲,不会将磁盘空间归还给操作系统。如果要回收磁盘空间,需要执行碎片整理(defragmentation):

# 对指定节点执行碎片整理
etcdctl defrag --endpoints=http://localhost:2379

碎片整理会暂停该节点的读写服务,因此在生产环境中应该逐节点执行,避免集群不可用。

3.6 BoltDB 的特性与局限

BoltDB(etcd 使用的是其维护分支 bbolt)是一个纯 Go 实现的嵌入式 KV 数据库,有以下关键特性:

单写入者(single writer):同一时间只能有一个写事务。这极大简化了并发控制,但也限制了写入吞吐量。etcd 通过批量提交(batch commit)来缓解这个限制。

mmap 读取:BoltDB 使用内存映射文件(mmap)进行读取。读操作直接从内存映射的页面读取数据,不需要系统调用,性能极高。多个读事务可以并发执行。

B+ 树组织:数据按 B+ 树组织,叶子节点存储实际的 key-value 对,内部节点只存储索引。这种结构对顺序扫描友好,适合 etcd 按 Revision 范围扫描的场景。

页面分配:BoltDB 按页面(page)分配存储空间,默认页面大小为 4KB。删除数据后,空闲页面不会立即归还操作系统,而是放入内部的空闲列表(freelist)供后续使用。这就是为什么压缩后磁盘空间不会减少,需要碎片整理。

etcd 选择 BoltDB 的原因是:它足够简单、可靠,作为嵌入式库不需要额外的进程管理,而且纯 Go 实现意味着交叉编译和部署都很方便。但 BoltDB 的局限也很明显——单写入者设计限制了写入吞吐量,这是 etcd 不适合高写入场景的根本原因之一。


四、与 Raft 共识联动

4.1 写入路径全链路

一次 etcd 写入操作从客户端发起到返回响应,经历以下完整路径:

Client                 Leader                  Follower(s)
  |                       |                       |
  |---PUT /foo=bar------->|                       |
  |                       |--Propose to Raft----->|
  |                       |                       |
  |                       |---MsgApp(entry)------>|
  |                       |<--MsgAppResp----------|
  |                       |                       |
  |                       | (quorum reached)      |
  |                       |--commit index++------>|
  |                       |                       |
  |                       |--Apply to MVCC        |
  |                       |  treeIndex + BoltDB   |
  |                       |  notify Watchers      |
  |                       |                       |
  |<--OK (revision=N)-----|                       |

详细步骤:

  1. 客户端通过 gRPC 发送 PUT 请求到 etcd 集群的某个节点。如果该节点不是 Leader,它会将请求转发给 Leader(或返回 Leader 地址让客户端重定向)。

  2. Leader 的 KV 服务层将 PUT 请求序列化为 Raft 日志条目,调用 raft.Node.Propose() 提交给 Raft 模块。

  3. Raft 模块将该条目追加到本地日志,然后通过 MsgApp(AppendEntries)消息发送给所有 Follower。

  4. 在发送 MsgApp 之前(或同时),Leader 将日志条目写入 WAL(Write-Ahead Log)并 fsync 到磁盘。这确保即使 Leader 崩溃,已接受的日志条目也不会丢失。

  5. Follower 收到 MsgApp 后,将日志条目写入自己的 WAL 并 fsync,然后回复 MsgAppResp 确认接收。

  6. 当 Leader 收到多数派(quorum)的确认后,推进 commit index。日志条目状态从 proposed 变为 committed。

  7. committed 的日志条目进入 apply 流程。apply 是一个串行流程:按 commit 顺序逐条将操作应用到 MVCC 存储引擎。

  8. apply 完成后,Leader 向客户端返回成功响应,包含分配的 Revision。

4.2 读取路径:Serializable vs Linearizable

etcd 支持两种读取模式:

Serializable Read(可串行化读取):直接从本地 MVCC 存储读取,不经过 Raft。这意味着可能读到过期数据(stale read)——如果你读的是一个 Follower,它的 apply 进度可能落后于 Leader。但性能高,不涉及网络往返。

# 可串行化读取(可能读到过期数据,但快)
etcdctl get /foo --consistency=s

Linearizable Read(线性一致性读取,默认):保证读到最新的已提交数据。etcd 使用 ReadIndex 协议实现线性一致性读取:

  1. 收到读请求后,Leader 记录当前的 commit index 作为 ReadIndex。
  2. Leader 向所有 Follower 发送心跳,确认自己仍然是合法的 Leader(防止网络分区导致的脑裂)。
  3. 收到多数派心跳回复后,Leader 等待本地 apply index 追上 ReadIndex。
  4. 从本地 MVCC 存储读取数据并返回。
# 线性一致性读取(默认,保证最新)
etcdctl get /foo

ReadIndex 协议的巧妙之处在于:它不需要将读请求写入 Raft 日志(那样会让读操作和写操作一样慢),只需要一轮心跳确认 Leader 身份。在网络状况良好的情况下,线性一致性读取的延迟约为一个 RTT(Round Trip Time)加上可能的 apply 等待时间。

etcd 还实现了一种更激进的优化——LeaseRead。Leader 在租约期内(leader lease,通过心跳维护)不需要每次读请求都发心跳确认,而是直接读取本地数据。这进一步降低了读延迟,但依赖时钟的正确性(如果时钟漂移导致 Leader lease 过期而 Leader 不知道,可能读到过期数据)。

4.3 Raft 日志与 MVCC 操作的映射

每个 Raft 日志条目包含一个序列化的 InternalRaftRequest,其中封装了实际的 KV 操作。apply 流程负责将日志条目反序列化并执行到 MVCC 存储上。

// 日志条目中的请求结构(简化)
type InternalRaftRequest struct {
    Put           *PutRequest
    DeleteRange   *DeleteRangeRequest
    Txn           *TxnRequest
    Compaction    *CompactionRequest
    LeaseGrant    *LeaseGrantRequest
    LeaseRevoke   *LeaseRevokeRequest
    // ...
}

apply 流程是严格串行的——这保证了 MVCC 的 Revision 分配是全局有序的,不需要额外的并发控制。每个 apply 操作递增一次 main revision。如果是一个 Txn(事务),事务内的多个操作共享同一个 main revision,通过 sub revision 区分。

4.4 Raft Snapshot

当一个新节点加入集群或一个落后太多的 Follower 需要追赶时,Leader 不可能发送整个 Raft 日志(日志可能已经被截断)。这时 Leader 会发送 Raft Snapshot。

etcd 的 Raft Snapshot 包含两部分:

  1. MVCC 数据的完整快照:整个 BoltDB 数据库文件。
  2. Snapshot metadata:对应的 Raft term 和 index。

接收方收到 Snapshot 后,用其中的 BoltDB 文件替换自己的数据库,然后从 Snapshot 对应的 index 开始接收后续的增量日志条目。

Snapshot 的生成频率由 --snapshot-count 参数控制。默认值是 100000,即每 100000 个 apply 操作触发一次 Snapshot。生成 Snapshot 时,etcd 会对 BoltDB 做一次一致性读取(利用 BoltDB 的 MVCC 事务),将整个数据库序列化到磁盘上的 snapshot 文件。

# 调整 snapshot 触发频率
etcd --snapshot-count=50000

4.5 后端批量提交优化

etcd 的一个关键性能优化是后端批量提交(backend batch commit)。BoltDB 的单写入者特性意味着频繁提交写事务的代价很高(每次提交需要 fsync)。etcd 将多个 Raft apply 操作聚合到一个 BoltDB 写事务中,定期(默认每 100ms)或当累积的操作数达到阈值时批量提交。

// backend 的批量提交配置
type BackendConfig struct {
    BatchInterval time.Duration // 批量提交间隔,默认 100ms
    BatchLimit    int           // 最大累积操作数,默认 10000
}

这个优化将 BoltDB 的 fsync 频率从”每个操作一次”降低到”每 100ms 一次”,显著提升了写入吞吐量。但也意味着:如果 etcd 在两次批量提交之间崩溃,已经 apply 但尚未 commit 到 BoltDB 的操作会丢失。这不是数据丢失——这些操作已经在 WAL 中持久化,etcd 重启后会重放 WAL 恢复到一致状态。


五、性能调优:磁盘、网络与快照

5.1 磁盘 IOPS 是瓶颈

etcd 的写入性能主要受磁盘 IOPS 限制。每次 Raft 日志持久化和 BoltDB 批量提交都需要 fsync 调用,而 fsync 的延迟直接取决于底层存储设备的 IOPS。

SSD 是硬性要求。etcd 官方文档明确推荐使用 SSD。一块典型的 SATA SSD 可以提供 10000-50000 IOPS,NVMe SSD 可以达到 100000+ IOPS。而传统的 7200RPM HDD 只有 75-150 IOPS。在 HDD 上运行 etcd 会导致 Raft 选举超时、请求延迟飙升等一系列问题。

关键指标:通过 Prometheus 监控以下指标来判断磁盘是否成为瓶颈:

指标 说明 健康阈值
etcd_disk_wal_fsync_duration_seconds WAL fsync 延迟 p99 < 10ms
etcd_disk_backend_commit_duration_seconds BoltDB 批量提交延迟 p99 < 25ms
etcd_server_proposals_failed_total 失败的 Raft 提案数 0

如果 WAL fsync 延迟持续超过 10ms,说明磁盘 IOPS 不足。如果 backend commit 延迟持续超过 25ms,说明 BoltDB 数据库可能太大或磁盘性能不够。

5.2 WAL 调优

WAL(Write-Ahead Log,预写日志)是 etcd 持久性的保障。每个 Raft 日志条目在被发送给 Follower 之前,必须先写入本地 WAL 并 fsync。

将 WAL 放在独立的磁盘上是一个常见的优化手段。WAL 的写入模式是顺序追加(sequential append),而 BoltDB 的写入模式是随机写入(random write)。将两者放在不同的磁盘上可以避免 IO 争用。

# 将 WAL 放在独立的 SSD 上
etcd --wal-dir=/ssd1/etcd-wal --data-dir=/ssd2/etcd-data

使用 ionice 提高 etcd 进程的 IO 优先级也有帮助,特别是在与其他 IO 密集型应用共享磁盘时:

ionice -c2 -n0 etcd --config-file=/etc/etcd/etcd.conf

5.3 网络延迟与 Raft 超时

Raft 的正确运行依赖于合理的超时配置。etcd 的两个关键超时参数:

这两个参数必须根据节点间的网络 RTT 来调整。官方推荐的经验规则是:

# 跨数据中心部署(RTT 约 50ms),适当放大超时
etcd --heartbeat-interval=200 --election-timeout=2000

如果 heartbeat-interval 设置得太小(远小于 RTT),Leader 的心跳会频繁超时,导致 Follower 误判 Leader 宕机并发起不必要的选举。反之,如果设置得太大,Leader 真正宕机后,Follower 需要等待很长时间才能发现。

5.4 快照策略

--snapshot-count 控制 Raft Snapshot 的生成频率。默认值 100000 意味着每 100000 个 apply 操作生成一次 Snapshot。

调小 snapshot-count 的好处是:新加入的节点可以更快地通过 Snapshot 追赶状态,减少从旧日志回放的时间。坏处是:Snapshot 生成本身有 IO 开销,频率太高会影响正常的读写性能。

# 对于写入频繁的集群,可以适当调小
etcd --snapshot-count=50000

5.5 客户端优化

连接池:etcd Go 客户端默认为每个 endpoint 维护一个 gRPC 连接。在高并发场景下,单连接可能成为瓶颈。可以通过创建多个 client 实例来增加并发连接数。

Watch 合并:如果需要 Watch 大量具有公共前缀的 key,使用前缀 Watch(WithPrefix())代替逐个 Watch。一个前缀 Watch 只需要一个 Watcher,比 N 个单 key Watch 高效得多。

批量操作:使用事务(Txn)将多个读写操作合并为一次网络往返。

// 使用事务批量写入
_, err := cli.Txn(ctx).
    Then(
        clientv3.OpPut("/key1", "val1"),
        clientv3.OpPut("/key2", "val2"),
        clientv3.OpPut("/key3", "val3"),
    ).Commit()

5.6 基准测试

etcd 自带基准测试工具 benchmark,可以测试集群的读写性能:

# 测试写入性能:100 个并发客户端,共写入 100000 个 key
benchmark --endpoints=localhost:2379 \
    --conns=100 --clients=100 \
    put --total=100000 --key-size=8 --val-size=256

# 测试读取性能:100 个并发客户端,线性一致性读
benchmark --endpoints=localhost:2379 \
    --conns=100 --clients=100 \
    range /foo --total=100000 --consistency=l

典型的参考数字(三节点集群,NVMe SSD,同数据中心):

操作 吞吐量 p99 延迟
写入(单 key) 15000-25000 ops/s 5-15ms
线性一致性读 50000-80000 ops/s 2-5ms
可串行化读 100000+ ops/s < 1ms

这些数字高度依赖硬件配置和网络环境。跨数据中心部署的延迟会显著增加。


六、Kubernetes 中的角色

6.1 etcd 是 Kubernetes 的唯一持久化后端

Kubernetes 的所有集群状态都存储在 etcd 中,没有例外。Pod 定义、Service 配置、ConfigMap、Secret、Deployment、StatefulSet、Namespace、RBAC 规则——所有这些对象的当前状态和期望状态都持久化在 etcd 的 KV 空间中。

kube-apiserver 是唯一直接与 etcd 通信的组件。所有其他组件(scheduler、controller-manager、kubelet、kubectl)都通过 kube-apiserver 的 REST API 间接访问 etcd。这种架构保证了所有对 etcd 的访问都经过统一的认证、授权和准入控制。

etcd 中的 key 按层级组织。以一个 default namespace 下名为 nginx 的 Pod 为例,它在 etcd 中的存储路径是:

# 查看 etcd 中存储的 Kubernetes 对象
etcdctl get /registry/pods/default/nginx --print-value-only | auger decode

kube-apiserver 使用 protobuf 序列化 Kubernetes 对象后存入 etcd。etcd 本身不理解 Kubernetes 的对象模型——对 etcd 来说,这些都是不透明的 byte 数组。

6.2 Watch 驱动 Kubernetes 控制循环

Kubernetes 的声明式 API 和控制循环(control loop)模式完全依赖 etcd 的 Watch 机制。控制循环的核心逻辑是:

  1. 通过 Watch(实际上是 kube-apiserver 的 List-Watch 机制,底层基于 etcd Watch)持续监听资源对象的变更。
  2. 比较对象的当前状态(status)与期望状态(spec)。
  3. 如果不一致,执行调谐(reconcile)操作,使当前状态趋向期望状态。

例如,Deployment Controller 的控制循环:

如果 etcd 的 Watch 不可靠——比如丢事件——整个控制循环链路就会断裂。这就是为什么 etcd 的 Revision 追溯和持久 Watch 对 Kubernetes 至关重要。

6.3 List-Watch 与 Informer 机制

直接对 etcd 发起大量 Watch 会造成严重的性能问题。Kubernetes 通过 Informer(SharedInformer)机制在 kube-apiserver 侧做了优化:

这种分层缓存设计极大降低了 etcd 的负载。在一个大型集群中,可能有数百个 controller 和 operator 在运行,如果它们都直接 Watch etcd,etcd 会不堪重负。

6.4 etcd 在大型 Kubernetes 集群中的挑战

随着集群规模增长,etcd 面临多方面的压力:

对象数量增长:一个 5000 节点的 Kubernetes 集群可能有数十万个 Pod、数万个 Service、数千个 ConfigMap 和 Secret。每个对象在 etcd 中占用几 KB 到几十 KB 的存储空间。

事件风暴:某些操作会触发大量连锁 Watch 事件。例如,滚动更新一个 1000 副本的 Deployment,会依次创建和删除 1000 个 Pod,每个 Pod 的生命周期涉及多次状态变更(Pending -> Running -> Terminated),总计可能产生数千个 Watch 事件。

Lease 压力:每个 kubelet 通过 Node Lease 维持心跳(Kubernetes 1.14+ 使用 Lease API 代替 NodeStatus 更新)。5000 个节点意味着 5000 个 Lease 在持续续约。

存储容量:etcd 默认的 2GB 存储限制在大型集群中可能不够用。将限制提高到 8GB 可以缓解问题,但 BoltDB 数据库越大,碎片整理和 Snapshot 生成的时间越长,对性能的影响也越大。

6.5 Kubernetes 特有的 etcd 运维实践

备份:etcd 数据是 Kubernetes 集群最重要的数据。定期做 Snapshot 备份是基本要求。

# 创建 etcd 快照备份
etcdctl snapshot save /backup/etcd-$(date +%Y%m%d%H%M%S).db \
    --endpoints=https://127.0.0.1:2379 \
    --cacert=/etc/etcd/ca.crt \
    --cert=/etc/etcd/server.crt \
    --key=/etc/etcd/server.key

独立部署:etcd 不应与 Kubernetes 控制面组件(kube-apiserver、controller-manager、scheduler)运行在同一台机器上。独立部署可以避免 CPU 和磁盘 IO 争用。

监控告警:除了 5.1 节提到的磁盘指标,还需要关注:

# 数据库大小(接近限制时需要扩容或清理)
etcd_mvcc_db_total_size_in_bytes

# 发送给 peer 的消息队列长度(过长说明网络瓶颈或 peer 响应慢)
etcd_network_peer_sent_failures_total

# 慢请求数量
etcd_server_slow_apply_total
etcd_server_slow_read_indexes_total

七、容量限制与规模化

7.1 存储限制:2GB 的默认天花板

etcd 的默认存储限制是 2GB,可以通过 --quota-backend-bytes 配置为最大 8GB。这个限制指的是 BoltDB 数据库文件的大小上限。当数据库大小达到限制时,etcd 会拒绝所有写入请求,返回 mvcc: database space exceeded 错误。集群进入只读模式——读取和删除仍然可用,但无法写入新数据。

# 设置 8GB 存储限制
etcd --quota-backend-bytes=$((8*1024*1024*1024))

# 如果遇到空间超限,需要先压缩再碎片整理
etcdctl compact $(etcdctl endpoint status --write-out=json | \
    python3 -c "import sys,json; print(json.load(sys.stdin)[0]['Status']['header']['revision'])")
etcdctl defrag --endpoints=http://localhost:2379
# 解除告警
etcdctl alarm disarm

为什么不把限制设得更大?因为 BoltDB 数据库越大,以下问题越严重:

7.2 key 数量与性能退化

etcd 的性能随 key 数量增长而退化,原因来自多个层面:

treeIndex 查找:treeIndex 是 B 树,查找复杂度为 O(log N)。key 数量从 10 万增长到 1000 万时,查找深度大约增加 2-3 层。每层涉及一次内存比较,影响相对较小。

BoltDB 查找:BoltDB 是 B+ 树,查找复杂度同样是 O(log N)。但 BoltDB 的节点是磁盘页面,虽然有 mmap 缓存,但数据库越大,工作集超过系统内存的概率越高,导致 mmap 产生页面错误(page fault),性能急剧下降。

前缀扫描:Watch 前缀和 Range 查询需要扫描连续的 key 范围。如果匹配的 key 很多,扫描本身的开销也很大。

实践经验表明:当 key 数量超过百万级别时,etcd 的各项延迟指标开始显著上升。超过千万级别时,etcd 基本不再适用——此时应该考虑其他方案。

7.3 Key 前缀设计

合理的 key 前缀设计可以在一定程度上缓解容量和性能问题:

扁平化 vs 层级化:etcd 的 key 空间是扁平的(不像 ZooKeeper 有显式的树形结构),但通常用 / 分隔来模拟层级。前缀设计应该考虑 Watch 和 Range 查询的访问模式。

# 推荐的 key 设计:按资源类型和 namespace 分层
/registry/pods/default/nginx
/registry/pods/kube-system/coredns
/registry/services/default/my-svc

# 不推荐:过深的层级或过长的 key
/registry/v1/core/namespaces/default/pods/deployment-abc123/container-xyz

减少冗余数据:避免在多个 key 中存储重复信息。如果多个 key 需要共享配置,考虑使用引用而不是复制。

定期清理过期数据:对于有生命周期的数据(如临时任务状态),及时删除不再需要的 key。

7.4 压缩与碎片整理策略

操作 触发条件 影响 建议频率
压缩(Compaction) 历史版本占用空间增长 删除旧revision,释放treeIndex内存 自动:每1-5小时
碎片整理(Defrag) DB SIZE远大于DB SIZE IN USE 重写BoltDB文件,归还磁盘空间 手动:每天一次或碎片率>50%
快照(Snapshot) 每snapshot-count次apply 截断Raft日志,加速新节点追赶 自动:默认每100000次

三者的关系是递进式的:Compaction 清理逻辑层面的历史版本,释放 treeIndex 占用的内存,但不会缩小 BoltDB 文件体积;Defrag 在 Compaction 的基础上重写物理文件,将已释放的页归还给操作系统;Snapshot 则从 Raft 日志层面进行截断,防止日志无限增长。生产环境中三者通常配合使用:Compaction 自动周期执行,Defrag 在低峰期手动触发,Snapshot 由 etcd 自动管理。

生产环境中,压缩和碎片整理是持续性的运维任务:

压缩(Compaction)策略选择:

# 推荐配置:每小时自动压缩
etcd --auto-compaction-mode=periodic --auto-compaction-retention=1h

碎片整理(Defragmentation)策略:

# 检查数据库大小和使用量
etcdctl endpoint status --write-out=table
# 输出中的 DB SIZE 和 DB SIZE IN USE 的差值就是碎片大小

7.5 超越单集群:分片与替代方案

当单个 etcd 集群无法满足规模需求时,有以下策略:

etcd 分片:将不同类型的数据存储在不同的 etcd 集群中。Kubernetes 1.22+ 支持通过 kube-apiserver 的 --etcd-servers-overrides 参数将特定资源类型路由到不同的 etcd 集群。

# 将 events 存储到独立的 etcd 集群
kube-apiserver --etcd-servers=https://etcd-main:2379 \
    --etcd-servers-overrides=/events#https://etcd-events:2379

Events 是 Kubernetes 中写入量最大的资源类型之一,将其分离到独立的 etcd 集群可以显著降低主 etcd 集群的负载。

etcd-druid:由 Gardener 项目开发的 etcd 运维工具,支持自动化的备份、恢复、扩缩容和碎片整理。适合管理大规模 Kubernetes 集群中的多个 etcd 实例。

Kine:Rancher 开发的 etcd API 兼容层,将 etcd API 翻译为对 MySQL、PostgreSQL 或 SQLite 等关系型数据库的操作。适合小型集群或不想运维 etcd 的场景,但在功能完整性和性能上与原生 etcd 有差距。

7.6 什么时候不应该用 etcd

etcd 是分布式协调服务,不是通用数据库。以下场景不适合使用 etcd:

大量数据存储:如果数据量超过 GB 级别,etcd 的存储限制和性能退化会成为严重问题。使用专门的分布式数据库(TiKV、CockroachDB、Cassandra 等)。

高写入吞吐:etcd 的单 Leader 写入架构和 BoltDB 的单写入者限制了写入吞吐量。如果需要每秒数万到数十万次写入,etcd 不是合适的选择。

大 value 存储:etcd 建议单个 value 不超过 1.5MB(硬限制)。存储大文件、二进制数据、日志等应该使用对象存储或文件系统。

消息队列:虽然 Watch 机制看起来像发布/订阅,但 etcd 不是消息队列。它没有消费者组、消息确认、重试等消息队列特性。使用 Kafka、RabbitMQ、NATS 等专门的消息系统。

频繁的全量扫描:etcd 的 Range 查询在 key 数量很多时性能下降明显。如果业务需要频繁做全量扫描或复杂查询,应该使用关系型数据库或搜索引擎。

etcd 最适合的场景是:少量(千到万级别)的小型 key-value 数据,需要强一致性保证,需要 Watch 变更通知,对可用性要求极高。典型的使用场景包括:配置管理、服务发现、分布式锁、Leader 选举、集群成员管理。


参考文献

  1. etcd Documentation. https://etcd.io/docs/
  2. CoreOS. etcd: A distributed, reliable key-value store. Design documentation. https://github.com/etcd-io/etcd
  3. etcd Technical Overview. https://etcd.io/docs/v3.5/learning/
  4. etcd Data Model. https://etcd.io/docs/v3.5/learning/data_model/
  5. Xiang Li, et al. etcd: A Distributed Key-Value Store for Inspiring Reliable Distributed Systems. USENIX ;login:, 2017.
  6. BoltDB (bbolt) Documentation. https://github.com/etcd-io/bbolt
  7. Kubernetes etcd Administration Guide. https://kubernetes.io/docs/tasks/administer-cluster/configure-upgrade-etcd/
  8. Diego Ongaro, John Ousterhout. In Search of an Understandable Consensus Algorithm (Extended Version). USENIX ATC, 2014.
  9. etcd Performance Benchmarking. https://etcd.io/docs/v3.5/op-guide/performance/
  10. Patrick Hunt, et al. ZooKeeper: Wait-free Coordination for Internet-scale Systems. USENIX ATC, 2010.

Prev: ZooKeeper 内核 | Next: 分布式锁的真相

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-13 · distributed

【分布式系统百科】Raft 深度重写:从论文的 18 页到 etcd 的 15000 行

Raft 论文 18 页就能读完,但 etcd/raft 用了 15000 行 Go 才把它变成能在生产环境跑的代码。这篇文章从论文的每一个核心机制出发,逐一拆解工程实现中论文没说的东西:PreVote、ReadIndex、LeaderTransfer、ConfChange V2、流水线复制、Async Apply,以及 TiKV 的 Multi-Raft 实践。最后做一次精确的 Paxos 对比,并坦诚讨论 Raft 的已知缺陷。


By .