Skip to content

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 --> Storage

Raft 核心子问题:

子问题机制
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 三者的经典应用场景,但实现方式和可靠性差异显著:

维度etcdZooKeeperRedis (Redlock)
获取机制事务 CAS:If key不存在 Then PUT创建临时顺序节点,比较序号最小SET NX PX + 唯一值
锁释放DELETE key断开连接 / 手动删除Lua 脚本:if val=='mine' then DEL
可靠性✅✅ Raft 共识,过半写入✅ ZAB 共识❌ 异步复制,主从切换可能丢锁
性能🟡 中等(需过半写入)🟡 中等✅✅ 极高(纯内存)
TTL 续约Lease KeepAliveSession 心跳需自行实现 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 --> ZAB

ZAB 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
维度PaxosRaftZAB
设计理念理论完备性优先可理解性优先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 ​

维度EtcdZooKeeper
一致性协议RaftZAB
数据模型扁平 KV + 前缀树形 ZNode
Watch持久监听 (长连接流)一次性,需反复注册
并发原语Lease, Transaction (CAS)临时节点 + 序号
API 风格gRPC (HTTP/2)自定义 TCP 协议
语言GoJava
运维简单(二进制 + 静态配置)较重(依赖 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-interval100msLeader 发心跳间隔WAN 环境调到 500ms
election-timeout1000msFollower 等多久发起选举大小是 heartbeat 的 10×
max-request-bytes1.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=1h1 小时后自动清理旧版本自动
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 和 util

5. 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=<...> defrag

Raft 选举超时参数调优 ​

etcd 的 Raft 实现中,以下参数直接影响集群稳定性和故障恢复速度:

bash
# etcd 关键 Raft 参数
--election-timeout=1000       # 选举超时 (ms),默认 1000
--heartbeat-interval=100      # 心跳间隔 (ms),默认 100
text
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"]
    end

Snapshot(快照)触发条件与机制 ​

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-count100000写密集 → 减小 (50000);读为主 → 可增大
heartbeat-interval100ms跨可用区 → 增大到 300-500ms
election-timeout1000ms跨可用区 → 增大到 3000-5000ms

参考 ​

批注模式

💬 文章评论

暂无评论,来说点什么吧 👇

编程学习笔记