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 的内部架构全貌,后续各节将逐一深入每个组件:
一、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:
- synced watchers:这些 Watcher 已经追上了当前最新的 Revision。当新的写入操作发生时,apply 流程会直接通知这些 Watcher。
- unsynced watchers:这些 Watcher 请求从一个历史 Revision 开始 Watch,还没有追上当前最新状态。etcd 有一个后台 goroutine 负责从 BoltDB 中读取历史事件,逐步将 unsynced watcher 追赶到最新状态,然后移入 synced 组。
// watchableStore 的核心结构(简化)
type watchableStore struct {
*store
synced watcherGroup // 已追上最新 revision 的 watcher
unsynced watcherGroup // 还在追赶历史 revision 的 watcher
victims []watcherBatch // 因 channel 满而暂时挂起的事件
}当一个写操作被 Raft 提交并应用(apply)到 MVCC 存储后,watchableStore 会做以下事情:
- 在 treeIndex 和 BoltDB 中写入新的 key-value 和 revision 映射。
- 遍历 synced watchers,找到所有匹配该 key(或 key 前缀/范围)的 Watcher。
- 为每个匹配的 Watcher 生成一个 WatchEvent,包含事件类型(PUT 或 DELETE)、key、value 和 revision。
- 将事件推入 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}
一次读操作的流程:
- 客户端请求读取 key
/services/api。 - treeIndex 中查找该 key 的 keyIndex,找到最新 Revision
{15, 0}。 - 用这个 Revision 作为 key 去 BoltDB 中查找,得到完整的 KeyValue 结构。
- 返回给客户端。
如果是指定 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 操作:
- 从 Raft 提交的日志条目中取出 PUT 请求。
- 递增全局 Revision(main++)。
- 在 treeIndex 中查找 key。如果不存在,创建新的 keyIndex 和 generation;如果已存在,向当前 generation 追加新的 revision。
- 将
revision -> KeyValue写入 BoltDB 的当前批次事务(batch transaction)中。 - 通知 watchableStore 中匹配的 synced watchers。
DELETE 操作:
- 从 Raft 提交的日志条目中取出 DELETE 请求。
- 递增全局 Revision。
- 在 treeIndex 中查找 key。向当前 generation 追加新的 revision 作为墓碑。关闭当前 generation。
- 将
revision -> KeyValue{tombstone}写入 BoltDB。 - 通知匹配的 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)-----| |
详细步骤:
客户端通过 gRPC 发送 PUT 请求到 etcd 集群的某个节点。如果该节点不是 Leader,它会将请求转发给 Leader(或返回 Leader 地址让客户端重定向)。
Leader 的 KV 服务层将 PUT 请求序列化为 Raft 日志条目,调用
raft.Node.Propose()提交给 Raft 模块。Raft 模块将该条目追加到本地日志,然后通过 MsgApp(AppendEntries)消息发送给所有 Follower。
在发送 MsgApp 之前(或同时),Leader 将日志条目写入 WAL(Write-Ahead Log)并 fsync 到磁盘。这确保即使 Leader 崩溃,已接受的日志条目也不会丢失。
Follower 收到 MsgApp 后,将日志条目写入自己的 WAL 并 fsync,然后回复 MsgAppResp 确认接收。
当 Leader 收到多数派(quorum)的确认后,推进 commit index。日志条目状态从 proposed 变为 committed。
committed 的日志条目进入 apply 流程。apply 是一个串行流程:按 commit 顺序逐条将操作应用到 MVCC 存储引擎。
apply 完成后,Leader 向客户端返回成功响应,包含分配的 Revision。
4.2 读取路径:Serializable vs Linearizable
etcd 支持两种读取模式:
Serializable Read(可串行化读取):直接从本地 MVCC 存储读取,不经过 Raft。这意味着可能读到过期数据(stale read)——如果你读的是一个 Follower,它的 apply 进度可能落后于 Leader。但性能高,不涉及网络往返。
# 可串行化读取(可能读到过期数据,但快)
etcdctl get /foo --consistency=sLinearizable Read(线性一致性读取,默认):保证读到最新的已提交数据。etcd 使用 ReadIndex 协议实现线性一致性读取:
- 收到读请求后,Leader 记录当前的 commit index 作为 ReadIndex。
- Leader 向所有 Follower 发送心跳,确认自己仍然是合法的 Leader(防止网络分区导致的脑裂)。
- 收到多数派心跳回复后,Leader 等待本地 apply index 追上 ReadIndex。
- 从本地 MVCC 存储读取数据并返回。
# 线性一致性读取(默认,保证最新)
etcdctl get /fooReadIndex 协议的巧妙之处在于:它不需要将读请求写入 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 包含两部分:
- MVCC 数据的完整快照:整个 BoltDB 数据库文件。
- 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=500004.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.conf5.3 网络延迟与 Raft 超时
Raft 的正确运行依赖于合理的超时配置。etcd 的两个关键超时参数:
- heartbeat-interval:Leader 发送心跳的间隔,默认 100ms。
- election-timeout:Follower 等待心跳的超时时间,默认 1000ms(10 倍 heartbeat-interval)。
这两个参数必须根据节点间的网络 RTT 来调整。官方推荐的经验规则是:
- heartbeat-interval 应该是节点间平均 RTT 的 0.5 到 1.5 倍。
- election-timeout 应该是 heartbeat-interval 的 10 倍左右。
# 跨数据中心部署(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=500005.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 decodekube-apiserver 使用 protobuf 序列化 Kubernetes 对象后存入 etcd。etcd 本身不理解 Kubernetes 的对象模型——对 etcd 来说,这些都是不透明的 byte 数组。
6.2 Watch 驱动 Kubernetes 控制循环
Kubernetes 的声明式 API 和控制循环(control loop)模式完全依赖 etcd 的 Watch 机制。控制循环的核心逻辑是:
- 通过 Watch(实际上是 kube-apiserver 的 List-Watch 机制,底层基于 etcd Watch)持续监听资源对象的变更。
- 比较对象的当前状态(status)与期望状态(spec)。
- 如果不一致,执行调谐(reconcile)操作,使当前状态趋向期望状态。
例如,Deployment Controller 的控制循环:
- Watch 所有 Deployment 和 ReplicaSet 对象。
- 当 Deployment 的 replicas 从 3 变为 5 时,收到 Watch 事件。
- 检查当前的 ReplicaSet 副本数是否为 5,如果不是,更新 ReplicaSet 的 replicas 字段。
- ReplicaSet Controller 收到 ReplicaSet 变更事件,创建新的 Pod 对象。
- Scheduler Watch 到未调度的 Pod,执行调度并写回 nodeName。
- kubelet Watch 到本节点上新绑定的 Pod,启动容器。
如果 etcd 的 Watch 不可靠——比如丢事件——整个控制循环链路就会断裂。这就是为什么 etcd 的 Revision 追溯和持久 Watch 对 Kubernetes 至关重要。
6.3 List-Watch 与 Informer 机制
直接对 etcd 发起大量 Watch 会造成严重的性能问题。Kubernetes 通过 Informer(SharedInformer)机制在 kube-apiserver 侧做了优化:
- kube-apiserver 维护一个 Watch 缓存(watch cache),将 etcd 的 Watch 事件缓存在内存中。
- 多个客户端(controller、scheduler 等)对同一资源类型的 Watch 请求由 kube-apiserver 统一服务,不需要每个客户端都直接 Watch etcd。
- Informer 的 ListAndWatch 机制:启动时先 List 获取全量数据建立本地缓存,然后通过 Watch 增量更新缓存。后续的读操作直接从本地缓存读取,不经过 kube-apiserver 和 etcd。
这种分层缓存设计极大降低了 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 数据库越大,以下问题越严重:
- Snapshot 时间:生成 Raft Snapshot 需要读取整个 BoltDB 数据库。8GB 的数据库在 SSD 上可能需要数十秒。
- 碎片整理时间:碎片整理需要重写整个数据库文件,期间节点暂停服务。
- 启动时间:etcd 启动时需要加载 BoltDB 数据库和重放 WAL,数据库越大启动越慢。
- 内存占用:treeIndex 存储在内存中。key 越多,treeIndex 越大。每个 key 的 keyIndex 结构大约占用 150-200 字节,100 万个 key 的 treeIndex 约占 150-200MB 内存。
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)策略选择:
- 周期性压缩(periodic):适合写入频率稳定的场景。保留固定时间窗口内的历史版本。推荐保留时间为 1-5 小时。
- 基于 Revision 的压缩(revision):适合写入频率波动大的场景。保留固定数量的最近 Revision。
# 推荐配置:每小时自动压缩
etcd --auto-compaction-mode=periodic --auto-compaction-retention=1h碎片整理(Defragmentation)策略:
- 定期执行,建议每天一次或当碎片率超过 50% 时执行。
- 逐节点执行,避免集群不可用。先对 Follower 执行,最后对 Leader 执行。
- 在低峰期执行,因为碎片整理会暂停该节点的读写服务。
# 检查数据库大小和使用量
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:2379Events 是 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 选举、集群成员管理。
参考文献
- etcd Documentation. https://etcd.io/docs/
- CoreOS. etcd: A distributed, reliable key-value store. Design documentation. https://github.com/etcd-io/etcd
- etcd Technical Overview. https://etcd.io/docs/v3.5/learning/
- etcd Data Model. https://etcd.io/docs/v3.5/learning/data_model/
- Xiang Li, et al. etcd: A Distributed Key-Value Store for Inspiring Reliable Distributed Systems. USENIX ;login:, 2017.
- BoltDB (bbolt) Documentation. https://github.com/etcd-io/bbolt
- Kubernetes etcd Administration Guide. https://kubernetes.io/docs/tasks/administer-cluster/configure-upgrade-etcd/
- Diego Ongaro, John Ousterhout. In Search of an Understandable Consensus Algorithm (Extended Version). USENIX ATC, 2014.
- etcd Performance Benchmarking. https://etcd.io/docs/v3.5/op-guide/performance/
- Patrick Hunt, et al. ZooKeeper: Wait-free Coordination for Internet-scale Systems. USENIX ATC, 2010.
Prev: ZooKeeper 内核 | Next: 分布式锁的真相
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【分布式系统百科】ZooKeeper 内核:从 ZAB 协议到分布式协调实践
深入拆解 ZooKeeper 的核心机制:ZAB 协议的三阶段流程、ZNode 数据模型、Watch 一次性通知、会话管理,以及分布式锁、Leader 选举、配置管理等典型用法。分析惊群效应等已知问题,并梳理 ZooKeeper 在 Kafka、HBase、Hadoop 生态中的角色。
【分布式系统百科】Raft 深度重写:从论文的 18 页到 etcd 的 15000 行
Raft 论文 18 页就能读完,但 etcd/raft 用了 15000 行 Go 才把它变成能在生产环境跑的代码。这篇文章从论文的每一个核心机制出发,逐一拆解工程实现中论文没说的东西:PreVote、ReadIndex、LeaderTransfer、ConfChange V2、流水线复制、Async Apply,以及 TiKV 的 Multi-Raft 实践。最后做一次精确的 Paxos 对比,并坦诚讨论 Raft 的已知缺陷。
【分布式系统实战】Raft 实现拆解:etcd 的共识算法到底长什么样
Raft 论文 18 页,etcd raft 库 ~15000 行 Go。中间的差距不是代码量,是论文没提的工程 edge case:PreVote、流水线复制、ReadIndex、joint consensus。
【分布式系统百科】分布式锁的真相:从 Redlock 争论到 Fencing Token
完整还原 Kleppmann 与 Antirez 关于 Redlock 的技术争论,拆解 Fencing Token 方案的原理与实现,对比基于 etcd 和 ZooKeeper 的分布式锁正确实现,讨论锁粒度、Advisory Lock 与 Mandatory Lock 的区别,以及用版本号代替锁的替代思路。