01 etcd 全局架构——Raft 共识与 MVCC 存储
摘要
etcd 是 CoreOS 于 2013 年开源的分布式键值存储,以 Go 语言编写,基于 Raft 共识协议保证强一致性。它是 Kubernetes 的”大脑”——集群中所有资源对象(Pod、Service、ConfigMap、Secret、RBAC 规则等)的状态全部持久化于 etcd。理解 etcd 的架构,是理解 Kubernetes 高可用、故障恢复和性能瓶颈的基础。本文以一次写请求的完整链路为主线,串联 etcd 的各个层次:gRPC API 层、Raft 共识层、WAL 持久化层、BoltDB 存储层和 MVCC 多版本语义,建立整体认知框架。
第 1 章 etcd 在 Kubernetes 生态中的地位
1.1 Kubernetes 为什么选择 etcd
Kubernetes 在 2014 年设计时,需要一个集中存储集群状态的组件,要求:
- 强一致性:所有 API Server 实例读取的集群状态必须一致(etcd 的线性一致性保证了这一点);
- Watch 支持:Controller Manager 需要监听资源变化并响应(etcd 的 Watch 机制提供了事件流推送);
- 高可用:etcd 集群应能容忍部分节点故障(Raft 的多数派机制保证);
- 轻量级:不需要关系型数据库的复杂查询能力,键值存储足够;
- Go 语言:与 Kubernetes 的技术栈一致,维护成本低。
etcd 恰好满足上述所有要求。Zookeeper 是当时唯一可比的竞争对手,但 ZooKeeper 的 Java 实现(JVM GC 问题)、复杂的 ZAB 协议和较差的跨语言客户端,让 Kubernetes 团队最终选择了 etcd。
1.2 etcd 存储了哪些 Kubernetes 数据
以一个典型的 Kubernetes 集群为例,etcd 中存储的 key 格式为 /registry/{resource-type}/{namespace}/{name}:
/registry/pods/default/nginx-deployment-6d4b9c9c4-abc12
/registry/services/default/my-service
/registry/configmaps/kube-system/kube-dns
/registry/secrets/default/db-password
/registry/deployments/default/nginx-deployment
/registry/nodes/node-1
/registry/namespaces/default
...
每个 key 对应的 value 是对应 Kubernetes 对象的 Protobuf 序列化(Kubernetes 1.6+ 默认使用 Protobuf 序列化存储到 etcd,比 JSON 体积小约 30%)。
对于一个中型 Kubernetes 集群(100 个节点,1000 个 Pod),etcd 中大约有 10,000100,000 个 key,总数据量通常在 100MB2GB 范围内。
1.3 etcd 的集群规模
etcd 生产集群通常部署 3 节点或 5 节点:
- 3 节点:quorum = 2,可容忍 1 节点故障;
- 5 节点:quorum = 3,可容忍 2 节点故障;
- 7 节点:理论上可容忍 3 节点故障,但写操作需要 4 个节点确认,延迟增加,实际生产中很少使用。
生产避坑
etcd 集群不应该超过 7 个节点。每次写入需要超过半数节点确认,节点越多写延迟越大(需要更多网络往返)。如果需要扩展读吞吐量,应通过 API Server 的缓存层(informer 机制)来减少对 etcd 的直接读取,而不是增加 etcd 节点数。
第 2 章 etcd 的分层架构
2.1 五层架构概览
etcd 的内部架构分为五层(从上到下):
graph TD A["客户端(kubectl / API Server)"] B["gRPC API 层</br>(KV / Watch / Lease / Cluster / Auth)"] C["Raft 共识层</br>(Leader 选举 / 日志复制 / 成员变更)"] D["WAL 持久化层</br>(Write-Ahead Log,顺序写磁盘)"] E["存储引擎层</br>(BoltDB B+树 + MVCC 多版本索引)"] F["快照层</br>(定期全量快照,加速重启恢复)"] A --> B B --> C C --> D C --> E E --> F classDef client fill:#f1fa8c,stroke:#282a36,color:#282a36 classDef api fill:#bd93f9,stroke:#282a36,color:#282a36 classDef raft fill:#50fa7b,stroke:#282a36,color:#282a36 classDef storage fill:#6272a4,stroke:#bd93f9,color:#f8f8f2 class A client class B api class C raft class D,F storage class E storage
gRPC API 层:etcd 对外暴露基于 gRPC(HTTP/2)的 API,主要服务:
KV:Put/Get/Delete/Txn(事务);Watch:基于 Revision 的事件流订阅;Lease:TTL 租约管理(GrantLease/KeepAlive/Revoke);Cluster:成员管理(MemberAdd/MemberRemove);Maintenance:运维操作(Snapshot/Defragment/Alarm)。
同时,etcd 也通过 HTTP/1.1 提供了 v3 API 的 JSON 网关(grpc-gateway),方便调试。
Raft 共识层:实现 Raft 协议,保证集群内所有节点的数据一致性。所有写请求必须经过 Raft Leader,被 quorum 确认后才能提交。
WAL 持久化层:Write-Ahead Log,在 Raft Entry(日志条目)被应用到状态机之前,先追加写入磁盘文件。WAL 保证了即使节点崩溃,重启后可以通过重放日志恢复状态。
存储引擎层:以 BoltDB(嵌入式 B+ Tree 数据库)作为持久化存储引擎,在 BoltDB 之上构建了 MVCC(多版本并发控制)索引,实现历史版本查询和 Watch 的事件溯源。
快照层:定期将 BoltDB 的完整状态序列化为快照文件,用于新节点加入时的全量同步,以及重启时的快速数据恢复(避免重放所有 WAL)。
第 3 章 MVCC:etcd 多版本存储的核心设计
3.1 什么是 MVCC,为什么需要它
MVCC(Multi-Version Concurrency Control,多版本并发控制)是 etcd 存储层最重要的设计。在普通的键值存储中,每次 Put 会覆盖旧值,只保留最新版本。etcd 的 MVCC 则保留每个 key 的所有历史版本。
为什么保留历史版本?
etcd 的 Watch 机制需要历史版本:客户端可以订阅”从 Revision 100 开始的所有变更事件”。如果没有历史版本,当客户端因网络断开重连时,就无法获取断线期间的变更,会遗漏事件。
Kubernetes 的 Controller 机制依赖这个特性——每个 Controller(Deployment Controller、Service Controller 等)通过 resourceVersion 断点续传,确保每一次资源变更都被处理到,不会因为 Controller 重启或 List-Watch 重连而遗漏事件。
3.2 Revision 与 ModRevision
etcd 有两个核心版本号概念:
Revision(全局递增版本号):
etcd 维护一个全局单调递增的 Revision 计数器,每次成功的写操作(Put/Delete/Txn)都会使 Revision 加 1。Revision 是整个 etcd 集群的”逻辑时钟”——它反映了从 etcd 创建以来一共发生了多少次写操作。
ModRevision(Key 的最后修改版本):
每个 key 有自己的 ModRevision(最近一次修改时的全局 Revision)、CreateRevision(创建时的 Revision)、Version(该 key 自创建以来被修改的次数):
Key: /registry/pods/default/nginx
CreateRevision = 100 ← 在 Revision=100 时创建
ModRevision = 250 ← 最后一次修改是在 Revision=250
Version = 5 ← 被修改了 5 次(从创建到现在)
Value = <Protobuf>
3.3 MVCC 的内部存储结构
etcd 的 MVCC 由两部分组成:
B-Tree 内存索引(treeIndex):
用一个内存中的 B-Tree(Go 标准库 btree)维护 key → keyIndex 的映射,keyIndex 记录该 key 的所有历史版本:
type keyIndex struct {
key []byte // key 的字节表示
modified revision // 最后一次修改的 Revision
generations []generation // 每个 generation 是 key 从创建到删除的一段生命周期
}
type generation struct {
ver int64 // 该 generation 的版本计数
created revision // 该 generation 开始的 Revision(创建或上次删除后重建)
revs []revision // 该 generation 中所有修改的 Revision 列表
}BoltDB 持久化存储:
以 {main_revision, sub_revision} 为 key,存储对应版本的 key-value 数据:
BoltDB key: {Revision=250, Sub=0} → value: {key: /registry/pods/..., value: <Protobuf>}
BoltDB key: {Revision=249, Sub=0} → value: {key: /registry/services/..., value: <Protobuf>}
查询某个 key 的最新值的流程:
- 在 treeIndex(内存 B-Tree)中查找
/registry/pods/default/nginx,找到其最新的 ModRevision = 250; - 在 BoltDB 中查找 key =
{250, 0},取出对应的 value。
查询某个 key 在历史 Revision 100 的值:
- 在 treeIndex 中找到
/registry/pods/default/nginx的所有历史 Revision; - 找到 ≤ 100 的最大 Revision(如 CreateRevision=100);
- 在 BoltDB 中查找 key =
{100, 0},取出历史值。
第 4 章 一次写请求的完整链路
4.1 从 gRPC 到 Raft
当客户端(如 kubectl)执行 etcdctl put /config/database.url "jdbc:mysql://... 时:
sequenceDiagram participant C as "客户端" participant L as "etcd Leader" participant F1 as "etcd Follower 1" participant F2 as "etcd Follower 2" C->>L: "gRPC Put(/config/database.url, value)" Note over L: "生成 Raft Log Entry</br>包含操作类型+key+value" Note over L: "将 Entry 追加到本地 WAL" L->>F1: "AppendEntries(entry)" L->>F2: "AppendEntries(entry)" F1->>L: "ACK(写入本地 WAL 后返回)" Note over L: "收到多数 ACK(F1 + 自身 = 2/3)</br>更新 commitIndex" L->>F1: "AppendEntries(更新 commitIndex)" L->>F2: "AppendEntries(更新 commitIndex)" Note over L: "应用已提交 Entry 到状态机</br>(BoltDB + MVCC 索引更新)" L-->>C: "返回 Put 成功,Revision=251" Note over F1: "收到 commitIndex 更新</br>应用 Entry 到本地 BoltDB" Note over F2: "稍后同步并应用"
关键步骤详解:
步骤 1:客户端连接 Leader
etcd 客户端(go.etcd.io/etcd/client/v3)会自动发现 Leader——如果连接到 Follower,Follower 会返回 NOT_LEADER 错误并附上 Leader 的地址,客户端自动重连到 Leader。
步骤 2:Leader 生成 Raft Log Entry
Leader 将写请求封装为 Raft Log Entry(包含操作类型、key、value、客户端 ID 等),分配一个递增的 Index(日志索引,不同于 MVCC 的 Revision)。
步骤 3:WAL 持久化
在发送 AppendEntries 给 Follower 之前,Leader 先将 Entry 追加写入本地 WAL 文件(fsync,确保落盘)。
步骤 4:Follower 确认
Follower 收到 AppendEntries 后,将 Entry 写入本地 WAL(fsync),返回 ACK。
步骤 5:提交(Commit)
Leader 收到超过半数(quorum)的 ACK 后,将 commitIndex 更新为该 Entry 的 Index,通过下一次 AppendEntries 将 commitIndex 通知给 Follower。
步骤 6:应用(Apply)
Leader 的 apply 协程将 commitIndex 以下所有已提交但未应用的 Entry,按顺序应用到状态机(BoltDB + MVCC treeIndex 更新)。MVCC 的全局 Revision 在此时递增,新的 key-value 存入 BoltDB。
步骤 7:响应客户端
Entry 应用完成后,Leader 向客户端返回成功响应,携带新的 Revision(如 251)。
4.2 读请求的一致性保证
etcd 支持两种读模式,在一致性和性能之间权衡:
线性化读(Linearizable Read,默认):
保证读到最新的已提交数据。实现方式是 ReadIndex:
- Leader 收到读请求时,记录当前的
commitIndex; - Leader 向所有 Follower 发送心跳(确认自己仍然是合法的 Leader,防止网络分区后脑裂 Leader 读到旧数据);
- 等待状态机的
appliedIndex追上commitIndex; - 此时从状态机读取数据返回给客户端。
代价:每次读需要一次心跳往返(约等于半个网络 RTT),相比不保证线性化的读,延迟略高。
串行化读(Serializable Read):
直接从本地状态机读取,不经过 Raft,延迟极低,但可能读到稍旧的数据(Follower 的 apply 进度可能落后 Leader)。适合对数据新鲜度要求不严格的场景(如监控仪表盘的背景刷新)。
客户端控制:
// 线性化读(默认)
resp, err := client.Get(ctx, "/config/key")
// 串行化读(指定选项)
resp, err := client.Get(ctx, "/config/key", clientv3.WithSerializable())第 5 章 etcd 的数据模型与 API
5.1 Key-Value 的完整结构
etcd 的每个键值对不只有 key 和 value,还包含丰富的元数据:
message KeyValue {
bytes key = 1; // 键
int64 create_revision = 2; // 创建时的全局 Revision
int64 mod_revision = 3; // 最后修改时的全局 Revision
int64 version = 4; // 该 key 被修改的次数(从 1 开始,删除后重建归零重新计数)
bytes value = 5; // 值
int64 lease = 6; // 关联的 Lease ID(0 = 无 Lease)
}5.2 Txn:Mini 事务
etcd 支持一种有限但非常实用的原子事务(Txn),格式为 if-then-else:
txnResp, err := client.Txn(ctx).
If(
// 条件:/locks/my-lock 的 createRevision == 0(即不存在)
clientv3.Compare(clientv3.CreateRevision("/locks/my-lock"), "=", 0),
).
Then(
// 如果条件成立:创建临时节点(关联 Lease)
clientv3.OpPut("/locks/my-lock", leaseID, clientv3.WithLease(leaseID)),
).
Else(
// 如果条件不成立:获取当前值
clientv3.OpGet("/locks/my-lock"),
).
Commit()
if txnResp.Succeeded {
// 成功获得锁
} else {
// 锁已被其他节点持有
currentHolder := txnResp.Responses[0].GetResponseRange().Kvs[0]
}这个 if (CreateRevision == 0) then Put 的原子操作,是 etcd 实现分布式锁的基础——只有 CreateRevision == 0(key 不存在)时才能成功 Put,且整个过程原子,无并发竞争问题。
5.3 Prefix Scan:前缀查询
etcd 的 key 是字节序列,可以利用词典序做前缀范围查询:
// 获取所有 /registry/pods/ 下的 key(Kubernetes 查询所有 Pod)
resp, err := client.Get(ctx, "/registry/pods/", clientv3.WithPrefix())
// 等价于范围查询:key >= "/registry/pods/" && key < "/registry/pods0"
// ('/' 的 ASCII 码是 47,'0' 是 48,所以 "/registry/pods0" 是 "/registry/pods/" 的后继边界)Kubernetes API Server 通过前缀查询高效地获取某类资源的所有对象列表,而无需全表扫描。
小结
本文建立了对 etcd 架构的整体认知:
- etcd 在 Kubernetes 中的角色:所有集群状态(Pod/Service/ConfigMap 等)的持久化存储,是 Kubernetes 控制面的”真相单一来源”;
- 五层架构:gRPC API → Raft 共识 → WAL 持久化 → BoltDB 存储 → 快照;每层职责清晰,组合保证了强一致性和持久性;
- MVCC 多版本存储:全局递增
Revision是 etcd 的”逻辑时钟”,BoltDB 以Revision为 key 存储所有历史版本,内存 B-Tree 索引提供 key → RevisionList 的快速查找; - 一次写请求链路:Client → Leader gRPC → Raft AppendEntries(WAL fsync)→ Majority ACK → commitIndex 更新 → apply 到 BoltDB → 响应 Client;
- 读一致性:线性化读(默认)通过 ReadIndex 保证读到最新数据;串行化读直接从本地状态机读,延迟更低但可能稍旧。
下一篇文章将深入 Raft 协议的核心机制——Leader 选举的随机超时、日志复制的 AppendEntries 协议,以及 Raft 的安全性保证(已提交的日志不会被覆盖)。
思考题
- etcd 使用 Raft 协议实现一致性,所有写操作必须经过 Leader 节点。在一个 3 节点的 etcd 集群中,写操作需要 2 个节点确认(quorum)才算成功。如果 Leader 和一个 Follower 之间的网络延迟为 100ms,写操作的最低延迟是多少?在跨数据中心部署 etcd 时(节点分布在不同城市),Raft 的延迟如何影响 Kubernetes 的 API 响应时间?
- etcd 的数据存储在 BoltDB(一个嵌入式 B+ 树 KV 存储)中。BoltDB 使用 mmap 将文件映射到内存,读操作直接从内存中读取。但 BoltDB 的写操作需要获取全局写锁——这意味着写操作是串行的。在高写入负载场景中,etcd 的写吞吐量瓶颈在哪里?bbolt(BoltDB 的 fork)做了哪些改进?
- etcd 被 Kubernetes 用作唯一的存储后端——所有集群状态(Pod、Service、ConfigMap 等)都存储在 etcd 中。如果 etcd 不可用,Kubernetes 控制平面完全无法工作。在生产环境中,你如何保证 etcd 的高可用?etcd 节点应该部署在专用机器上还是与 K8s Master 共享?