Skip to content
Charles Shao
Go back

ZooKeeper 与 etcd 深挖 · ZAB、Raft、Watch 与租约

views

上一篇分布式共识:Paxos 与 Raft 图解把「一堆机器怎么就一件事达成一致」的理论讲透了。但工程师真正天天打交道的,不是 Paxos 论文,而是两个把共识封装成产品的协调服务:ZooKeeperetcd。选主、配置下发、服务注册、分布式锁——这些”分布式系统的公共设施”,十有八九跑在它们身上。

这一篇就钻进这两个系统的内部:ZooKeeper 的 znode / 会话 / Watch / ZAB,etcd 的 Raft / MVCC / lease / 可回放 Watch,把它们的机制、命令、取舍与坑一次讲清。

本文是分布式协调系列的第 2 篇。 开篇讲共识理论,本篇讲承载共识的两个引擎;后续会分别下钻分布式锁、分布式事务与分布式 ID。全系列顺序:

  1. 分布式共识:Paxos 与 Raft 图解
  2. ZooKeeper 与 etcd 深挖 · ZAB、Raft、Watch 与租约(本篇)
  3. 分布式锁深挖 · Redis Redlock vs ZooKeeper/etcd
  4. 分布式事务深挖 · 2PC、TCC、Saga 与 Outbox
  5. 分布式 ID 深挖 · 雪花算法及其变体

一句话定位:协调服务不是数据库,而是”少量元数据 + 强一致 + 变更通知”的分布式内核——你把系统里那点”必须所有人看法一致”的关键状态托管给它,换来选主、发现、加锁这些能力。

TL;DR

Table of contents

Open Table of contents

1. 协调服务到底解决什么问题

分布式系统里有一类状态,必须让所有节点看法完全一致,否则系统就会精神分裂:

这些问题的共同画像是:数据量小(KB 级)、读远多于写、但对一致性零容忍。它不需要数据库的海量存储与复杂查询,需要的是”一份所有人都认的、强一致的、能订阅变更的小状态”。于是就有了协调服务——本质上是把 Paxos/Raft 这样的共识算法,封装成好用的 API 和数据模型

划重点:别把协调服务当数据库用。 往 ZK/etcd 里塞大 value、高频写、当消息队列,几乎是所有翻车案例的起点。它是”分布式系统的内核态”,要惜字如金。

2. ZooKeeper:znode、会话、Watch 与 ZAB

ZooKeeper 是这条赛道的”老大哥”(2008 年从 Hadoop 生态孵化),Kafka、HBase、Dubbo、Solr 都曾/仍重度依赖它。它的设计可以拆成四块:数据模型、会话、Watch、底层协议。

ZooKeeper 的 znode 树与 Watch 通知机制示意图:左侧是一棵类文件系统的层级树,根节点 / 下挂三个持久节点——/config(其下 /config/db.url 存配置值)、/services/payment(其下挂两个临时顺序节点 inst-0000000001 与 inst-0000000002,分别绑定会话 A 与会话 B)、/election(其下 n_01 为 Leader、n_02 为候选,均为临时顺序节点);图中标注会话 B 超时后其临时节点 inst-0000000002 被服务端自动删除。右侧是 Watch 三步流程:① 客户端对某 znode 注册 Watch,② 该节点数据或子节点发生变更或被删除(NodeDeleted),③ 服务端推送一次性通知、客户端需重新注册。底部两条说明:临时节点与会话绑定,会话超时后节点自动删除,是服务注册摘除与选主让位的基础;Watch 一次性触发,大量客户端 watch 同一节点时删除会引发惊群,选主应只 watch 前驱节点来规避。

ZooKeeper 的两大机制:临时节点让”下线”变成服务端自动删除的物理事件;一次性 Watch 让客户端能订阅变更——但要小心惊群。

2.1 数据模型:znode 树

ZooKeeper 对外是一棵类文件系统的层级树,每个节点叫 znode,用路径寻址(如 /services/payment/inst-01)。和文件系统不同的是:znode 既是”目录”又是”文件”——它既能有子节点,自己也能存一小段数据。

znode 有几种类型(可组合):

每个 znode 还带一组版本号(version/cversion/aversion)与事务 id zxid,写操作可以带 versionCAS(setData(path, data, expectedVersion)),天然支持乐观并发控制。

2.2 会话(session)与临时节点

客户端连上 ZooKeeper 集群会建立一个 session,有一个协商出来的 sessionTimeout。客户端靠周期性心跳(ping) 维持会话;只要在超时时间内服务端收到过心跳,会话就活着。

会话的精妙之处在于它和临时节点的联动:

这套机制既优雅又危险:优雅在于”下线”是自动的、强一致的;危险在于**“会话超时”不等于”进程真的挂了”**。一次长 Full GC、一次网络抖动,都可能让一个活得好好的实例”被下线”——§8 的事故正来源于此。

2.3 Watch:一次性的变更通知

ZooKeeper 不是让你轮询,而是提供 Watch:客户端在读(getData/exists/getChildren)时可以带一个 watch,当对应的数据/子节点/存在性发生变化时,服务端推一个通知给它。

Watch 有三个必须记住的性质:

  1. 一次性(one-time):触发一次就失效,想继续监听必须重新注册。所以在”通知到达”和”你重新注册”之间的变更,你是看不到的——Watch 只告诉你”变了”,具体变成啥要自己再读。
  2. 有序:客户端看到的 watch 通知顺序,与 ZooKeeper 状态变更的顺序一致;且你一定先收到通知,再能读到变更后的数据
  3. 轻量但会惊群:通知本身很轻(只说”哪个节点、什么类型的变化”)。但如果成千上万个客户端都 watch 同一个节点,该节点一变,服务端要给所有人发通知——这就是惊群(herd effect)。选主场景的最佳实践是让每个候选只 watch 排在自己前面的那个节点,而不是都盯着 Leader,把 O(N) 的唤醒降为 O(1)。

2.4 ZAB 协议:崩溃恢复 + 原子广播

ZooKeeper 底层跑的不是 Paxos 也不是 Raft,而是自研的 ZAB(ZooKeeper Atomic Broadcast)。它的目标和 Raft 很像——让一组副本对”操作序列”达成一致——分两个阶段:

zxid 是 ZAB 的灵魂,它是一个 64 位数:高 32 位是 epoch(第几任 Leader),低 32 位是该任期内的递增计数。换 Leader 就换 epoch、计数清零——这保证了跨任期的全局有序,也让”旧 Leader 复活后发来的过期提案”能被一眼识别并丢弃。

ZAB 保证的核心是 primary-order:同一个 Leader 广播的消息,所有节点按同样顺序处理;且崩溃恢复后,已经被提交的提案绝不丢失。这些性质,正是 ZooKeeper 能对外承诺”线性一致写 + 顺序一致读”的底座。

3. etcd:Raft、MVCC、lease 与可回放 Watch

etcd 是 CoreOS 出的协调服务,用 Go 写成,是 Kubernetes 的元数据大脑(所有对象都存 etcd)。它的设计取向和 ZK 很不一样:扁平 KV + 多版本 + 直接用 Raft

etcd 的 Raft 日志复制与 Lease 租约续约示意图:上半部分是一个 Raft 集群,客户端把写请求 Put(key, leaseID) 转发给 Leader,Leader 写入 raft log/WAL 并 fsync,通过 AppendEntries 复制给 Follower 1 与 Follower 2,两个 Follower 回 ack,Leader 收到多数派持久化确认后提交并让全局 revision 递增。下半部分是 Lease 租约生命周期六步横向流程:Grant(lease TTL=10s) → Put 并把 key 挂载到 leaseID → KeepAlive 周期续约心跳 → 客户端失联导致停止续约(中断) → TTL 到期后 key 被自动删除 → 触发 Watch 的 DELETE 事件(带 revision)。底部说明 MVCC/revision 每次写让全局 revision 单调递增、保留历史版本、Watch 可从任一 revision 回放、compaction 压缩旧版本,KeepAlive 走 gRPC 流续租、一个 lease 可绑多个 key,读一致性默认走 Leader 的线性一致读(ReadIndex)、也可 serializable 读本地副本换低延迟。

etcd 把 Raft 直接暴露成一个带版本的 KV:写走多数派提交、revision 单调递增;lease 用续约心跳维系”存活”,续约一停 key 就自动过期。

3.1 数据模型:扁平 KV + revision

etcd 对外不是树,而是一个有序的、扁平的 key-value 空间(key 是字节串,可按前缀范围扫描,用 / 分隔来”模拟”层级)。真正的关键设计是 MVCC(多版本并发控制)

这套 MVCC 直接催生了 etcd 最值钱的能力——可回放的 Watch(见 3.3)。代价是历史版本会累积,靠 compaction 定期压缩掉某个 revision 之前的旧版本来回收空间(否则 db 会一直涨,这也是 etcd 运维的一个重点)。

3.2 lease 租约:比会话更显式的”存活”

etcd 用 lease(租约) 表达”存活性”,对标 ZK 的会话,但更灵活:

  1. lease grant:申请一个带 TTL(如 10s)的租约,拿到一个 leaseID
  2. put --lease:把一个或多个 key 挂到这个 lease 上
  3. KeepAlive:客户端通过一条 gRPC 流周期性续约,每次续约把 TTL 重置;
  4. 一旦客户端停止续约(崩溃、网络断),TTL 倒计时归零,lease 过期,挂在它上面的所有 key 被自动删除,并产生对应的 Watch DELETE 事件。

和 ZK 会话相比,lease 的优势是显式且可复用:一个 lease 可以绑多个 key(比如一个服务实例的地址、元数据、健康标记一起过期),TTL 也能按 key 独立设定,语义比”一个会话管所有临时节点”更清晰可控。

3.3 Watch:基于 revision,可回放

这是 etcd 相对 ZooKeeper 的代际优势。etcd 的 Watch 不是一次性的,而是基于 revision 的持续流

对比 ZK Watch 的”一次性、通知后要重读、期间可能漏变更”,etcd 的 Watch 天然适合做增量同步(Kubernetes 的 list-watchinformer 机制就建在它上面)。唯一约束:你要 watch 的起始 revision 不能早于已经 compaction 掉的点,否则会收到 ErrCompacted

3.4 读写路径与 Raft 的关系

etcd 的写路径就是标准 Raft:请求路由到 Leader → Leader 追加到本地 raft log(WAL,fsync 落盘)→ AppendEntries 复制给 Follower → 多数派持久化确认 → 提交、应用到状态机(MVCC 存储)、revision 递增。所以 etcd 对磁盘 fsync 延迟极其敏感——盘一慢,Raft 提交就慢,心跳也可能超时触发无谓选主(这也是一个经典事故源)。

4. ZAB vs Raft:同大于异

ZooKeeper 的 ZAB 和 etcd 的 Raft 经常被拿来比。结论先行:它们要解决的是同一个问题,机制上同大于异。

ZAB 与 Raft 协议阶段对比图:左栏为 ZAB(ZooKeeper Atomic Broadcast)四阶段——崩溃恢复·快速选主(比较 zxid,最大者当选 Leader)、发现 Discovery(确认新 epoch 与最新历史)、同步 Synchronization(Leader 补齐各 Follower 事务日志)、广播 Broadcast(Propose→Ack→Commit,类 2PC);右栏为 Raft(etcd)四阶段——Leader 选举 Election(term+投票,比较 log 新旧)、日志复制 AppendEntries(Leader 单向 append,含心跳)、提交 Commit(多数派持久化后推进 commitIndex)、快照与成员变更(snapshot 压缩 / joint consensus)。底部说明共性:都靠多数派 quorum(N/2+1)、都有强 Leader、都用单调序号保证全序——ZAB 用 zxid(高 32 位 epoch + 低 32 位计数)、Raft 用 (term, index),崩溃后都保证已提交不丢、未提交可覆盖;差异:ZAB 面向主备复制+原子广播、强调 primary-order,Raft 面向复制状态机、日志 append-only、规则更少更易实现,etcd 在其上做线性一致读 ReadIndex。

ZAB 与 Raft 阶段几乎一一对应:选主 → 对齐历史 → 复制 → 多数派提交。差别在设计取向而非能力。

共性(这才是重点):

差异(多是取向而非能力):

维度ZAB(ZooKeeper)Raft(etcd)
心智模型主备复制 + 原子广播复制状态机
序号zxid = epoch << 32 | counter(term, index)
强调primary-order(主序广播)日志 append-only、易理解
提交流程Propose → Ack → CommitAppendEntries → 多数派 → commitIndex
成员变更早期较弱,需重启/重配joint consensus,运行时可增删节点
诞生动机先有 ZooKeeper 再抽象出 ZAB先有 Raft(为”可理解性”而生)再有 etcd

一句话总结:ZAB 是”为 ZooKeeper 量身定做的原子广播”,Raft 是”为可理解性重新设计的共识”。 用户视角看,两者提供的一致性保证是同一档次的;Raft 因规则少、成员变更完善、论文清晰,成了后来者(etcd、Consul、TiKV)的默认选择。

5. 典型用法:选主、配置、服务发现

抛开原理,看看这两样东西在工程里到底怎么用。三大经典场景,各给一段代码/命令。

5.1 选主(leader election)

ZooKeeper——经典做法是”临时顺序节点 + 只 watch 前驱”。所有候选在 /election 下创建临时顺序节点,编号最小的当选 Leader;其余节点各自 watch 排在自己前一位的节点,前驱消失(通常是 Leader 挂了逐级递补)才去检查自己是否成了最小。生产里别自己手写,用 Apache CuratorLeaderLatch / LeaderSelector

CuratorFramework client = CuratorFrameworkFactory.newClient(
    "zk1:2181,zk2:2181,zk3:2181", new ExponentialBackoffRetry(1000, 3));
client.start();

LeaderLatch latch = new LeaderLatch(client, "/election/my-service");
latch.start();
latch.await();                       // 阻塞直到成为 Leader
if (latch.hasLeadership()) {
    // 只有 Leader 会走到这里:执行主逻辑
}
// 进程崩溃/会话超时 → 临时节点消失 → 自动触发重新选主

etcd——用 lease + Txn(CAS) 抢一个 key,抢到的续租保活,就是 Leader;etcdctl 内置了 election

# 客户端 A:竞选,成为 Leader 后一直持有(阻塞)
etcdctl elect my-service nodeA

# 另一个终端观察谁是 Leader
etcdctl elect --observe my-service

底层等价于:lease grant 拿租约 → 用事务 if create_revision(key)==0 then put(key, self, lease) 抢占 → 抢到就 KeepAlive,租约在 Leader 就在;Leader 崩溃续租停 → key 过期 → 其他候选竞选。

5.2 配置下发

ZooKeeper:配置存持久节点,客户端读时挂 Watch,变更后收到通知重读:

zkCli.sh
> create /config/rate-limit "1000"      # 写入配置
> get -w /config/rate-limit             # 读并注册 watch,变更时收到 NodeDataChanged
> set /config/rate-limit "2000"         # 运营改配置 → 所有 watcher 被通知

etcdwatch 一个 key 或前缀,天然是持续流、还能带 revision 回放:

etcdctl put /config/rate-limit 1000
etcdctl watch /config/rate-limit        # 持续输出后续每一次 PUT/DELETE
etcdctl watch --prefix /config/         # 监听整棵"配置树"

5.3 服务注册与发现

两者都用”临时/租约 + 监听子节点”:实例上线时注册一个会随自己消失的节点,调用方 watch 父路径拿到实时实例列表。

# etcd:实例注册(挂 10s 租约),进程活着就续租,挂了自动摘除
LEASE=$(etcdctl lease grant 10 | awk '{print $2}')
etcdctl put /services/payment/10.0.0.7:8080 '{"weight":10}' --lease=$LEASE
etcdctl lease keep-alive $LEASE &        # 后台持续续约
# 调用方:一次拿全量 + 持续监听增量
etcdctl get --prefix /services/payment/
etcdctl watch --prefix /services/payment/

ZooKeeper 侧完全同构:create -e /services/payment/inst- ... 建临时节点注册,调用方 getChildren -w /services/payment 监听子节点变化。这也是为什么 Dubbo 早期默认用 ZK 做注册中心。

6. 一致性与性能取舍

协调服务号称”强一致”,但这条路上其实有多个档位,直接关系性能,必须拎清。

6.1 写:都是线性一致

两者的都是线性一致(linearizable)的:走 Leader、多数派提交后才返回,全局有唯一的顺序。代价是写吞吐受限于多数派持久化——etcd 每次写要 fsync raft log,ZooKeeper 要落 transaction log,都不是无成本的。所以协调服务写 QPS 通常只有几千到上万量级,绝不能拿它扛业务写流量。

6.2 读:默认可能读到”旧一点”的值

这是最容易踩的认知坑:

取舍规则:需要”读到刚写进去的值”(如选主后立刻读 Leader 信息、锁状态) → 用线性一致读(etcd 默认 / ZK 加 sync);只是拉个配置、能容忍几十毫秒陈旧 → 用本地读换吞吐与延迟。把这条搞反,是”明明强一致却读到旧值”这类灵异 bug 的常见根因。

6.3 集群规模的反直觉

多数派机制决定了一个反直觉结论:节点越多,写越慢、可用性不一定更高。 5 节点要 3 个确认、7 节点要 4 个,写延迟随规模上升;而能容忍的故障数只从”挂 1”变成”挂 2”。所以 ZK/etcd 集群几乎都是 3 或 5 节点,再多是给自己找麻烦。想扩读吞吐,用 learner / observer(不参与投票、只同步数据的只读副本),而不是加投票节点。这一点在高可用设计里也反复出现:冗余不是越多越好,而是要卡在”多数派 + 成本”的甜点上。

7. 选型:ZooKeeper vs etcd vs Consul/Nacos

没有绝对的赢家,按生态和需求选:

维度ZooKeeperetcdConsul / Nacos
协议ZABRaftRaft
数据模型znode 树扁平 KV + MVCCKV + 原生服务目录
Watch一次性基于 revision、可回放长轮询 / 流
语言/生态JVM 系(Kafka/HBase/Dubbo/Solr)云原生(Kubernetes/gRPC/Go)微服务、多数据中心
健康检查靠会话/临时节点靠 lease内置多种健康检查
服务发现需自己在 znode 上搭需自己在 KV 上搭开箱即用 + DNS/HTTP 接口
典型使用者Kafka(旧)、HBase、DubboKubernetes、CoreDNS、M3Consul-based 微服务、Nacos(Spring Cloud Alibaba)

一句话选型:

补充趋势:Kafka 正在去 ZooKeeper 化——新版用内置的 KRaft(自带 Raft 的 Controller quorum)替代 ZK,少一个外部依赖、元数据传播更快。这也印证了行业方向:Raft 因其可理解性,正逐步成为协调层的事实标准。相关背景见 Kafka 核心原理

参考


views
Share this post on:

Previous Post
分布式锁深挖 · Redis Redlock vs ZooKeeper/etcd
Next Post
Criteo CTR 特征工程实战:把理论串成一条能跑的广告管线