Etcd / ZooKeeper 深度解析
#组件 · #Etcd · #ZooKeeper · #分布式协调 · #服务发现 · #选主
分布式协调服务的核心——一致性协议、数据模型、Watch 机制与典型应用场景。
一、Etcd
1.1 架构与 Raft 共识
mermaid
flowchart TB
subgraph Cluster["Etcd Cluster (3/5 节点)"]
subgraph Leader["Node 1 (Leader)"]
L1["接收所有写请求"]
L2["日志复制到 Follower"]
end
subgraph F1["Node 2 (Follower)"]
F1A["复制日志"]
F1B["响应投票"]
end
subgraph F2["Node 3 (Follower)"]
F2A["复制日志"]
F2B["响应投票"]
end
end
Leader -->|"Raft Log Replication"| F1
Leader -->|"Raft Log Replication"| F2
subgraph Storage["持久化存储"]
BoltDB["BoltDB<br/>· 快照 (Snapshot)<br/>· WAL (预写日志)"]
end
Leader --> Storage
F1 --> Storage
F2 --> StorageRaft 核心子问题:
| 子问题 | 机制 |
|---|---|
| Leader 选举 | 随机超时 (150-300ms) + Term 递增,过半投票当选 |
| 日志复制 | Leader 接收写请求 → 复制到 Follower → 过半确认 → 提交 → 应用到状态机 |
| 安全性 | 选举限制(Candidate 日志不能旧于多数派)+ 只追加不覆盖 |
1.2 数据模型
etcd 是扁平的 KV 存储,Key 按字节序排列:
/
├── /registry/
│ ├── /pods/default/nginx-abc123
│ ├── /pods/default/redis-xyz789
│ └── /services/endpoints/
└── /config/
└── /app/db-url
前缀查询: GET /registry/pods/ --prefix| 特性 | 说明 |
|---|---|
| MVCC | 每个 Key 多个版本,按 Revision 递增 |
| Lease (租约) | Key 绑定 TTL,心跳保活,到期自动删除 |
| Watch | 监听 Key/前缀变更事件(长连接 gRPC 流) |
| Transaction | 原子 CAS(Compare-And-Swap),if...then...else |
| Compact | 压缩历史版本回收空间 |
1.3 Watch 机制
Client ──── Watch("/config/db", revision=100) ────→ Etcd
// 后续任何对该 Key 的修改, 都推送事件:
Event 1: {Type: PUT, Key: "/config/db", Value: "mysql://...", Revision: 105}
Event 2: {Type: PUT, Key: "/config/db", Value: "pg://...", Revision: 110}
Event 3: {Type: DELETE, Key: "/config/db", Revision: 115}Watch 设计要点:
- gRPC 双向流,服务端推送,无需客户端轮询
- 从指定 Revision 开始回放(不丢事件)
- 断线重连时从上次 Revision 继续
1.4 线性一致性读
这是 etcd 最容易被误解的特性——很多人以为"从 Follower 读就是最终一致性"。实际上 etcd v3 提供多种读保证:
| 读模式 | 一致性 | 延迟 | 原理 |
|---|---|---|---|
| Serializable | ❌ 可能读到旧数据 | 最低 | 直接从本地状态机读,不经过 Raft |
| Linearizable | ✅ 保证读最新 | 稍高 | 读请求也走 Raft(ReadIndex 机制) |
ReadIndex 机制(etcd 默认线性一致性读的核心):
mermaid
sequenceDiagram
participant Leader
participant Follower
participant Client
Note over Leader: 收到线性一致性读请求
Leader->>Leader: 1. 记录当前 commitIndex
Leader->>Follower: 2. 广播心跳确认自己还是 Leader
Follower-->>Leader: ACK(过半确认)
Leader->>Leader: 3. 等待 appliedIndex ≥ commitIndex
Leader->>Client: 4. 返回数据
Note over Leader,Client: 关键:读请求不写日志!<br/>只需要确认"我确实是 Leader"<br/>然后等到自己追上 commitIndex为什么不直接返回?因为旧 Leader 可能因网络分区而认为自己仍是 Leader——如果此时它返回本地数据,就可能返回已经被新 Leader 覆盖的旧值。ReadIndex 通过"向 Follower 确认自己还是 Leader"来解决这个"stale read"问题。
Lease Read:如果 Leader 的 Lease(通常几秒的租约)还未过期,可以直接从本地读,跳过 ReadIndex 的确认步骤——这是 ReadIndex 的优化版,减少一次广播开销。
1.5 分布式锁对比 — etcd vs ZooKeeper vs Redis
分布式锁是 etcd/ZK/Redis 三者的经典应用场景,但实现方式和可靠性差异显著:
| 维度 | etcd | ZooKeeper | Redis (Redlock) |
|---|---|---|---|
| 获取机制 | 事务 CAS:If key不存在 Then PUT | 创建临时顺序节点,比较序号最小 | SET NX PX + 唯一值 |
| 锁释放 | DELETE key | 断开连接 / 手动删除 | Lua 脚本:if val=='mine' then DEL |
| 可靠性 | ✅✅ Raft 共识,过半写入 | ✅ ZAB 共识 | ❌ 异步复制,主从切换可能丢锁 |
| 性能 | 🟡 中等(需过半写入) | 🟡 中等 | ✅✅ 极高(纯内存) |
| TTL 续约 | Lease KeepAlive | Session 心跳 | 需自行实现 WatchDog |
| 公平性 | ❌ 需自行排序 | ✅ 临时顺序节点天然 FIFO | ❌ 需自行实现排队 |
| 惊群效应 | 有(所有等待者同时竞争) | ✅ 无(Watch 前一个节点) | 有 |
go
// etcd 分布式锁(使用官方 concurrency 包)
client, _ := clientv3.New(clientv3.Config{Endpoints: []string{"localhost:2379"}})
session, _ := concurrency.NewSession(client)
locker := concurrency.NewLocker(session, "/lock/resource-1")
locker.Lock() // 阻塞直到获得锁
// ... 临界区 ...
locker.Unlock()选型建议:性能优先且能接受极低概率的锁丢失 → Redis;可靠性优先 → etcd;需要公平排队(FIFO)→ ZooKeeper(临时顺序节点天然支持)。
1.4 典型应用
服务发现:
注册: PUT /services/payment/192.168.1.10:8080 + Lease (TTL 10s)
续约: KeepAlive 每 3s 心跳
发现: GET /services/payment/ --prefix
下线: 进程退出 → Lease 过期 → Key 自动删除 → Watch 通知消费者分布式锁:
go
// 1. 创建 Lease (TTL 30s)
// 2. 事务: 若 Key 不存在则 PUT (value=唯一ID), 否则失败
// 3. 获得锁后 KeepAlive 续约
// 4. 释放: DELETE Key配置中心:
PUT /config/app/timeout = "30s"
所有节点 Watch("/config/app/", prefix=true)
更新配置 → 所有节点收到变更事件 → 热加载选主:
多个候选人: Campaign("/election/leader", "node-A")
只有一个胜出 (Session 绑定 Lease)
Leader 宕机 → Lease 过期 → 其他候选人自动竞选二、ZooKeeper
2.1 架构与 ZAB 协议
mermaid
flowchart TB
subgraph ZK["ZooKeeper Ensemble (奇数节点)"]
L["Leader<br/>写+协调"]
F1["Follower<br/>读+投票"]
F2["Follower<br/>读+投票"]
L -->|"提议(Proposal)"| F1
L -->|"提议(Proposal)"| F2
F1 -->|"确认(ACK)"| L
F2 -->|"确认(ACK)"| L
L -->|"提交(Commit)"| F1
L -->|"提交(Commit)"| F2
end
ZAB["ZAB 原子广播协议<br/>两阶段提交变体<br/>保证顺序+原子性"]
ZK --> ZABZAB vs Raft vs Paxos — 三大共识协议横向对比:
mermaid
flowchart TB
subgraph Paxos["Basic / Multi-Paxos"]
P1["无固定 Leader<br/>两阶段: Prepare+Accept"]
P2["极度难理解/实现<br/>论文很薄但工程很厚"]
P3["应用: Google Chubby, Spanner"]
end
subgraph Raft["Raft"]
R1["强 Leader 模型<br/>Leader 选举 + 日志复制 + 安全"]
R2["以可理解性为核心设计目标<br/>模块化拆分"]
R3["应用: etcd, TiKV, Consul, NATS"]
end
subgraph ZAB["ZAB (ZooKeeper Atomic Broadcast)"]
Z1["强 Leader 模型<br/>Propose + Ack + Commit"]
Z2["为 ZooKeeper 定制<br/>强调 FIFO 顺序"]
Z3["应用: ZooKeeper"]
end| 维度 | Paxos | Raft | ZAB |
|---|---|---|---|
| 设计理念 | 理论完备性优先 | 可理解性优先 | ZooKeeper 定制 |
| Leader 模型 | 弱 Leader(Multi-Paxos 才有) | 强 Leader(唯一) | 强 Leader(唯一) |
| 日志提交 | 两阶段(Propose→Accept) | 过半复制即提交 | Propose→Ack→Commit |
| Follower 日志追赶 | 无标准方案 | Leader 递减 nextIndex 回溯 | Leader 发 TRUNC/DIFF/SNAP |
| 成员变更 | 无标准方案 | Joint Consensus(两阶段) | 原子广播中处理 |
| 实现难度 | ⭐⭐⭐⭐⭐ 极难 | ⭐⭐ 较易 | ⭐⭐⭐ 中等 |
为什么选 Raft 而非 Paxos:Diego Ongaro 在设计 Raft 时发现,Paxos 的理论简洁掩盖了工程复杂——日志复制、成员变更、快照压缩都需要在 Paxos 基础上重新设计。Raft 通过模块化拆解(选举→日志→安全→成员变更),让每个子问题可以独立理解和实现,这也是它被工业界广泛采用的根本原因。
2.2 数据模型与 ZNode
/
├── /services
│ ├── /payment ← 持久节点
│ └── /order ← 持久节点
├── /locks
│ └── /resource-1 ← 临时顺序节点
└── /config
└── /timeout ← 持久节点, 带 Watch| 节点类型 | 特性 |
|---|---|
| 持久节点 (PERSISTENT) | 显式删除才消失 |
| 临时节点 (EPHEMERAL) | 客户端断开自动删除(session 机制) |
| 持久顺序 (PERSISTENT_SEQUENTIAL) | 持久 + 自动递增序号 |
| 临时顺序 (EPHEMERAL_SEQUENTIAL) | 临时 + 自动递增序号 |
2.3 Session 与 Watch
Session:
连接建立 → 分配 SessionID + 超时时间
客户端定期发送心跳 (Ping)
超时未收到心跳 → Session 过期 → 临时节点删除 + Watch 通知失效
Watch:
exists / getData / getChildren 注册一次性触发器
事件发生 → 服务端推送通知 → Watch 失效
需重新注册 (与 Etcd 持久 Watch 不同)2.4 典型应用
分布式锁:
1. 所有竞争者创建临时顺序节点 /lock/request_0000000001
2. getChildren("/lock"),检查自己是否是最小序号
3. 是最小 → 获得锁;不是 → Watch 前一个节点
4. 释放: 断开连接或删除节点 → Watch 通知下一个竞争者服务注册/发现:
注册: create /services/payment/node-1 (EPHEMERAL)
发现: getChildren /services/payment, Watch
下线: session 断开 → 节点自动删除 → Watch 通知消费者三、Etcd vs ZooKeeper
| 维度 | Etcd | ZooKeeper |
|---|---|---|
| 一致性协议 | Raft | ZAB |
| 数据模型 | 扁平 KV + 前缀 | 树形 ZNode |
| Watch | 持久监听 (长连接流) | 一次性,需反复注册 |
| 并发原语 | Lease, Transaction (CAS) | 临时节点 + 序号 |
| API 风格 | gRPC (HTTP/2) | 自定义 TCP 协议 |
| 语言 | Go | Java |
| 运维 | 简单(二进制 + 静态配置) | 较重(依赖 Java/JVM 调优) |
| 社区 | Kubernetes 标配 | Hadoop 生态核心 |
| v3 Watch | ✅ 解决 v2 全量推送问题 | ❌ 始终需自行管理 Watch |
选型建议:
- 新项目、K8s 生态 → Etcd
- 老项目、Hadoop/大数据生态 → ZooKeeper
- 两者都不适合大量数据存储(DB 的事交给 DB)
四、并发控制原语实现
使用 Etcd 实现常见模式
分布式读写锁:
读锁 (共享): N 个客户端都可持有, 获得相同 Lease
写锁 (排他): Key 不存在才能 PUT, 单持有者
实现: PUT /lock/resource with Lease (CAS)屏障 (Barrier):
N 个 worker:
1. PUT /barrier/worker-{id}
2. Watch /barrier/ (所有 worker 就位)
3. 协调者 PUT /barrier/ready
4. 所有 worker 收到事件 → 同时开始计数器:
INCR: PUT /counter/key → 事务 if not exists =0 else version++
原子增减: Etcd v3 不支持原生 incr, 需 CAS 重试循环工程实践:Raft Leader Election、Watch 机制与磁盘故障
1. Raft Leader Election 流程
mermaid
sequenceDiagram
participant L as Leader (Node 1)
participant F1 as Follower (Node 2)
participant F2 as Follower (Node 3)
Note over L: Leader 每 heartbeat-interval 发心跳
L--xF1: heartbeat (超时!)
Note over F1: 未收到心跳<br/>election timeout
F1->>F1: 自己成为 Candidate<br/>term++, vote for self
F1->>F2: RequestVote (term=T+1)
Note over F2: 收到 Leader 心跳<br/>→ reject!
L->>F1: heartbeat (term=T, 已过期)
Note over F1: leader term 落后<br/>→ reject!
F1->>F1: 重新开始选举<br/>term++, 随机超时
Note over L: Leader 网络恢复
L->>F1: heartbeat (term=T, 更落后了...)
F1->>L: reject! (term>T)
Note over L: 发现自己 term 落后<br/>→ 降级为 Follower关键参数:
| 参数 | 默认 | 含义 | 调优建议 |
|---|---|---|---|
heartbeat-interval | 100ms | Leader 发心跳间隔 | WAN 环境调到 500ms |
election-timeout | 1000ms | Follower 等多久发起选举 | 大小是 heartbeat 的 10× |
max-request-bytes | 1.5MB | 单次请求最大大小 | 不要调太大 |
2. Watch 机制
etcd v3 的 Watch 基于 MVCC revision,不是传统的长轮询:
text
etcd Watch 工作原理:
1. Client 发起 Watch(key="/config", start_revision=100)
2. etcd 在内存中维护 WatcherGroup (按 key 前缀分组)
3. 任何 PUT key="/config/db" → 通知该 WatcherGroup 中所有 watcher
4. 每个 watcher 根据自己的 start_revision 过滤事件
5. Client 收到 WatchResponse{Events: [{PUT, key: "/config/db", revision: 105}]}
内存开销:
每个 watcher ~1KB 内存
10000 个 watcher = 10MB (可接受)
100000 个 watcher = 100MB (需要注意)mermaid
sequenceDiagram
participant C1 as Client 1
participant C2 as Client 2
participant E as etcd
C1->>E: Watch("/app/config", rev=100)
C2->>E: PUT "/app/config/db" = "mysql" → revision=105
E-->>C1: WatchResponse{revision=105, Events: [{PUT}]}
C2->>E: PUT "/app/config/cache" = "redis" → revision=106
E-->>C1: WatchResponse{revision=106, Events: [{PUT}]}
Note over C1: 自动收到所有 /app/config/ 下的变更3. MVCC Revision、Compaction 与 Defrag
text
etcd 的 MVCC:
- 每次修改产生一个新 revision (全局递增)
- 旧 revision 保留在 BoltDB 中
- Compaction: 删除老 revision (释放逻辑空间)
- Defrag: 回收 BoltDB 文件中的物理空洞 (必须离线或影响性能)
问题链:
频繁更新 → 大量 revision → DB 文件膨胀 → 超过 quota → 拒绝写入| 操作 | 含义 | 频率 |
|---|---|---|
auto-compaction-retention=1h | 1 小时后自动清理旧版本 | 自动 |
etcdctl compaction <rev> | 手动清理 | 按需 |
etcdctl defrag | 物理回收磁盘空间 | 定期(低峰期) |
4. 磁盘 fsync 慢导致 K8s API 慢案例
text
场景: K8s API 响应突然变慢,kubectl get pods 卡 5s+
根因链:
etcd 每次写入必须 fsync 到磁盘 (WAL)
→ 磁盘 fsync 延迟从 1ms 涨到 200ms
→ etcd 写入吞吐从 10000/s 降到 50/s
→ K8s API Server 等待 etcd → 请求堆积 → kubectl 超时
常见磁盘问题:
- 云盘性能抖动 (突发的 fsync 延迟尖峰)
- 和其他进程共用磁盘 (如 Prometheus 写 TSDB)
- 磁盘接近写满 (BoltDB 碎片化)
排查:
# etcd 指标
etcd_disk_wal_fsync_duration_seconds (P99 应该 < 10ms)
etcd_disk_backend_commit_duration_seconds
etcd_server_leader_changes_seen_total (频繁切主 → 磁盘慢)
# 系统层
iostat -x 1 # 看 await 和 util5. etcd 排障命令速查
bash
# 查看集群状态
etcdctl --endpoints=<...> endpoint status --write-out=table
# 关注: DB SIZE, LEADER, IS LEADER
# 查看集群健康
etcdctl --endpoints=<...> endpoint health
# 查看成员
etcdctl member list
# 检查 alarm (磁盘满会触发 NOSPACE)
etcdctl alarm list
etcdctl alarm disarm # 解除
# 获取当前 revision
etcdctl --endpoints=<...> get "" --prefix --keys-only --limit=1 \
| head -1 | cut -d' ' -f1 # 显示 revision
# 压缩 + 碎片整理
rev=$(etcdctl --endpoints=<...> endpoint status --write-out=json | jq '.[0].Status.header.revision')
etcdctl --endpoints=<...> compact $rev
etcdctl --endpoints=<...> defragRaft 选举超时参数调优
etcd 的 Raft 实现中,以下参数直接影响集群稳定性和故障恢复速度:
bash
# etcd 关键 Raft 参数
--election-timeout=1000 # 选举超时 (ms),默认 1000
--heartbeat-interval=100 # 心跳间隔 (ms),默认 100text
Raft 选举超时公式:
election_timeout = rand(election_timeout_min, election_timeout_min × 2)
其中 election_timeout_min = election-timeout (默认 1000ms)
心跳和选举的制约关系:
heartbeat_interval (100ms) × N 号心跳丢失
→ Follower 的 election timer 先触达 election_timeout
→ 发起选举
election_timeout 的选择:
太小 (200ms):
- 网络波动 → 频繁触发无意义的选举 → 系统不稳定
- 适合: 局域网高速网络,低延迟要求
默认 (1000ms):
- 10 次心跳丢失 (1s) 后触发选举
- 适合: 大多数场景 (跨可用区、云环境)
太大 (5000ms):
- Leader 故障后 5s 才开始选举 → 5s 不可用窗口
- 适合: 高延迟广域网,需要容忍网络抖动mermaid
flowchart TB
subgraph Timeout["选举超时 vs 心跳的关系"]
direction LR
H1["心跳 100ms"]
H2["心跳 100ms"]
H3["心跳丢失!"]
H4["心跳丢失!"]
end
H1 --> H2 --> H3 --> H4
H4 --> E["Follower: election timer 到 1000ms<br/>→ 自己的 term + 1<br/>→ 投票给自己<br/>→ 发送 RequestVote RPC"]
subgraph Tuning["参数调优建议"]
A["生产环境: heartbeat=100ms, election=1000ms"]
B["跨地域: heartbeat=500ms, election=3000ms"]
C["严格低延迟: heartbeat=50ms, election=500ms"]
endSnapshot(快照)触发条件与机制
Raft 日志无限增长会消耗磁盘。etcd 通过 Snapshot 压缩历史日志:
text
Snapshot 触发条件:
- --snapshot-count=100000 → 每 10 万次 apply 触发一次 snapshot
- 手动: etcdctl snapshot save snapshot.db
Snapshot 流程:
1. 当 applied index - snapshot index >= snapshot-count → 触发 snapshot
2. 将当前状态机的全部 KV 数据序列化为 snapshot 文件
3. 删除 [1, snapshot_index] 的 Raft 日志条目
4. 新节点 join → Leader 发送 snapshot + 之后的增量日志mermaid
sequenceDiagram
participant L as Leader
participant F as Follower
Note over L,F: Follower 落后太多 → 需要 Snapshot
F->>L: AppendEntries RPC<br/>(Follower 的 nextIndex 已超出 Leader 日志范围)
Note over L: Leader 发现 Follower 落后过多<br/>→ 不发增量日志<br/>→ 发 InstallSnapshot RPC
L->>F: InstallSnapshot RPC<br/>(包含: lastIncludedIndex, lastIncludedTerm, snapshot数据)
Note over F: 接收 snapshot → 覆盖状态机<br/>→ 删除旧 Raft 日志<br/>→ 从 lastIncludedIndex+1 开始接收增量日志
F-->>L: ACK
L->>F: AppendEntries (增量日志)bash
# 手动触发 snapshot
etcdctl --endpoints=$ENDPOINTS snapshot save /backup/etcd-$(date +%Y%m%d).db
# 查看 snapshot 状态
etcdctl --endpoints=$ENDPOINTS snapshot status /backup/etcd.db --write-out=table
# 恢复 snapshot
etcdctl snapshot restore /backup/etcd.db \
--name=etcd-restore \
--initial-cluster=etcd-restore=http://localhost:2380 \
--initial-advertise-peer-urls=http://localhost:2380| 参数 | 默认值 | 调优建议 |
|---|---|---|
snapshot-count | 100000 | 写密集 → 减小 (50000);读为主 → 可增大 |
heartbeat-interval | 100ms | 跨可用区 → 增大到 300-500ms |
election-timeout | 1000ms | 跨可用区 → 增大到 3000-5000ms |
登录后即可发表评论 👇