上一篇分布式共识:Paxos 与 Raft 图解把「一堆机器怎么就一件事达成一致」的理论讲透了。但工程师真正天天打交道的,不是 Paxos 论文,而是两个把共识封装成产品的协调服务:ZooKeeper 和 etcd。选主、配置下发、服务注册、分布式锁——这些”分布式系统的公共设施”,十有八九跑在它们身上。
这一篇就钻进这两个系统的内部:ZooKeeper 的 znode / 会话 / Watch / ZAB,etcd 的 Raft / MVCC / lease / 可回放 Watch,把它们的机制、命令、取舍与坑一次讲清。
本文是分布式协调系列的第 2 篇。 开篇讲共识理论,本篇讲承载共识的两个引擎;后续会分别下钻分布式锁、分布式事务与分布式 ID。全系列顺序:
- 分布式共识:Paxos 与 Raft 图解
- ZooKeeper 与 etcd 深挖 · ZAB、Raft、Watch 与租约(本篇)
- 分布式锁深挖 · Redis Redlock vs ZooKeeper/etcd
- 分布式事务深挖 · 2PC、TCC、Saga 与 Outbox
- 分布式 ID 深挖 · 雪花算法及其变体
一句话定位:协调服务不是数据库,而是”少量元数据 + 强一致 + 变更通知”的分布式内核——你把系统里那点”必须所有人看法一致”的关键状态托管给它,换来选主、发现、加锁这些能力。
TL;DR
- 协调服务解决五类问题:选主(leader election)、配置下发、服务发现、分布式锁、集群元数据。共同点是数据量小、读多写少、但一致性要求极高——这正是把共识引擎产品化的动机。
- ZooKeeper 的数据模型是 znode 树:类似文件系统的层级路径,每个 znode 存少量数据(默认上限 1MB,实际应 ≤ 几 KB);分持久 / 临时(ephemeral) / 顺序(sequential) 及其组合。
- 临时节点 = 会话的影子:临时节点与客户端 session 绑定,会话超时(session expired)服务端就自动删掉它——这是服务自动摘除、选主自动让位的物理基础。
- ZooKeeper Watch 是一次性的:注册一次、触发一次,收到通知后要重新注册才能继续监听;因此它保证的是”有变化”,不保证”你能看到每一次变化”。
- ZAB = 崩溃恢复 + 原子广播:ZooKeeper 自研的一致性协议,用 zxid(epoch + 计数)保证全序;写请求由 Leader 以
Propose→Ack→Commit广播,多数派确认才提交。 - etcd 建在 Raft 之上:直接采用 Raft 做日志复制,语义比 ZAB 更简洁;对外是一个扁平的、带版本(revision)的 key-value。
- etcd 的 MVCC/revision 是杀手锏:每次写让全局
revision单调递增、保留历史版本,所以它的 Watch 可以从任意 revision 回放——彻底解决了 ZK Watch “一次性、会漏事件”的痛点;旧版本靠 compaction 回收。 - etcd 的 lease 对标 ZK 会话:
lease是带 TTL 的租约,key 可挂到 lease 上,靠KeepAlive心跳续租;停止续租 → TTL 到期 → key 自动删除。比 ZK 会话更显式、更灵活(一个 lease 可绑多个 key)。 - ZAB vs Raft 同大于异:都靠多数派、都有强 Leader、都用单调序号保证全序;差别在 ZAB 面向”主备原子广播”、Raft 面向”复制状态机”,Raft 规则更少、更易实现与理解。
- 读一致性要拎清:默认线性一致读要走 Leader 确认(etcd 的 ReadIndex / ZK 的
sync),牺牲一点延迟换”读到最新已提交”;能容忍轻微陈旧就读本地副本(etcd serializable / ZK 从任意节点读)换吞吐。 - 选型一句话:JVM 生态 + 已有 ZK(Kafka/HBase/Dubbo) → ZooKeeper;云原生 / Kubernetes / gRPC → etcd;要开箱即用的服务发现 + 健康检查 + 多数据中心 → Consul / Nacos。
- 最惨的坑是会话超时:Full GC 或网络抖动让 ZK 会话过期 → 临时节点被删 → 大量 Watch 惊群 + 服务被误判下线,甚至假选主抖动——会话超时是最惨的一类坑。
Table of contents
Open Table of contents
1. 协调服务到底解决什么问题
分布式系统里有一类状态,必须让所有节点看法完全一致,否则系统就会精神分裂:
- 选主(leader election):一堆对等节点里,谁是主?如果两台机器都以为自己是主(脑裂),就会双写、重复处理。Kafka 早期用 ZooKeeper 选 Controller、管 broker 与 ISR,新版则内置 KRaft(自带 Raft)取代 ZK。
- 配置下发:一个开关、一份路由表、一个限流阈值,改一次要让全集群及时且一致地生效。
- 服务发现:服务实例上下线,调用方要能近实时拿到最新的可用实例列表。
- 分布式锁:跨进程互斥,保证某段临界区同一时刻只有一个持有者(下一篇分布式锁深挖专门讲)。
- 集群元数据:分片归属、成员列表、任务分配……这些”控制面”状态的权威副本。
这些问题的共同画像是:数据量小(KB 级)、读远多于写、但对一致性零容忍。它不需要数据库的海量存储与复杂查询,需要的是”一份所有人都认的、强一致的、能订阅变更的小状态”。于是就有了协调服务——本质上是把 Paxos/Raft 这样的共识算法,封装成好用的 API 和数据模型。
划重点:别把协调服务当数据库用。 往 ZK/etcd 里塞大 value、高频写、当消息队列,几乎是所有翻车案例的起点。它是”分布式系统的内核态”,要惜字如金。
2. ZooKeeper:znode、会话、Watch 与 ZAB
ZooKeeper 是这条赛道的”老大哥”(2008 年从 Hadoop 生态孵化),Kafka、HBase、Dubbo、Solr 都曾/仍重度依赖它。它的设计可以拆成四块:数据模型、会话、Watch、底层协议。
ZooKeeper 的两大机制:临时节点让”下线”变成服务端自动删除的物理事件;一次性 Watch 让客户端能订阅变更——但要小心惊群。
2.1 数据模型:znode 树
ZooKeeper 对外是一棵类文件系统的层级树,每个节点叫 znode,用路径寻址(如 /services/payment/inst-01)。和文件系统不同的是:znode 既是”目录”又是”文件”——它既能有子节点,自己也能存一小段数据。
znode 有几种类型(可组合):
- 持久节点(persistent):显式删除前一直存在。适合存配置、存”目录”结构。
- 临时节点(ephemeral):与创建它的会话绑定,会话一断(正常关闭或超时),节点自动消失。不能有子节点。 这是服务注册、选主的关键。
- 顺序节点(sequential):创建时父节点会给它追加一个单调递增的 10 位后缀(如
n_0000000001)。这个全局有序的编号,是排队、公平锁、选主的天然依据。 - 组合出最常用的 临时顺序节点(ephemeral sequential):既会随会话消失,又带一个有序编号——分布式锁与选主几乎都用它。
每个 znode 还带一组版本号(version/cversion/aversion)与事务 id zxid,写操作可以带 version 做 CAS(setData(path, data, expectedVersion)),天然支持乐观并发控制。
2.2 会话(session)与临时节点
客户端连上 ZooKeeper 集群会建立一个 session,有一个协商出来的 sessionTimeout。客户端靠周期性心跳(ping) 维持会话;只要在超时时间内服务端收到过心跳,会话就活着。
会话的精妙之处在于它和临时节点的联动:
- 客户端注册服务时,创建一个临时节点写上自己的地址;
- 只要会话活着,节点就在,别人就能发现它;
- 一旦会话超时(进程崩了、网络断了、或 Full GC 卡太久没发心跳),服务端判定会话过期,自动删除它名下所有临时节点;
- 于是”这个实例下线了”就变成了一个服务端主动产生的、可被 Watch 到的事件——不需要额外的健康检查。
这套机制既优雅又危险:优雅在于”下线”是自动的、强一致的;危险在于**“会话超时”不等于”进程真的挂了”**。一次长 Full GC、一次网络抖动,都可能让一个活得好好的实例”被下线”——§8 的事故正来源于此。
2.3 Watch:一次性的变更通知
ZooKeeper 不是让你轮询,而是提供 Watch:客户端在读(getData/exists/getChildren)时可以带一个 watch,当对应的数据/子节点/存在性发生变化时,服务端推一个通知给它。
Watch 有三个必须记住的性质:
- 一次性(one-time):触发一次就失效,想继续监听必须重新注册。所以在”通知到达”和”你重新注册”之间的变更,你是看不到的——Watch 只告诉你”变了”,具体变成啥要自己再读。
- 有序:客户端看到的 watch 通知顺序,与 ZooKeeper 状态变更的顺序一致;且你一定先收到通知,再能读到变更后的数据。
- 轻量但会惊群:通知本身很轻(只说”哪个节点、什么类型的变化”)。但如果成千上万个客户端都 watch 同一个节点,该节点一变,服务端要给所有人发通知——这就是惊群(herd effect)。选主场景的最佳实践是让每个候选只 watch 排在自己前面的那个节点,而不是都盯着 Leader,把 O(N) 的唤醒降为 O(1)。
2.4 ZAB 协议:崩溃恢复 + 原子广播
ZooKeeper 底层跑的不是 Paxos 也不是 Raft,而是自研的 ZAB(ZooKeeper Atomic Broadcast)。它的目标和 Raft 很像——让一组副本对”操作序列”达成一致——分两个阶段:
- 崩溃恢复(recovery):集群启动或 Leader 挂掉时,进入选主。ZooKeeper 用 Fast Leader Election:节点互相交换自己见过的最大
zxid,拥有最新数据(最大 zxid)的节点当选 Leader(保证已提交的提案不丢)。选出后,Leader 把自己的历史同步给各 Follower,达到一致后进入广播阶段。 - 原子广播(broadcast):正常服务时,所有写请求都转发给 Leader。Leader 给每个写分配一个单调递增的 zxid,然后
Propose(广播提案)→ 各 Follower 持久化后回Ack→ Leader 收到多数派 Ack 后广播Commit。这本质是一个简化的两阶段提交。
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 直接暴露成一个带版本的 KV:写走多数派提交、revision 单调递增;lease 用续约心跳维系”存活”,续约一停 key 就自动过期。
3.1 数据模型:扁平 KV + revision
etcd 对外不是树,而是一个有序的、扁平的 key-value 空间(key 是字节串,可按前缀范围扫描,用 / 分隔来”模拟”层级)。真正的关键设计是 MVCC(多版本并发控制):
- etcd 维护一个全局单调递增的
revision,每一次写(不管改哪个 key)都让 revision +1; - 每个 key 的每次修改都记为一个版本(
create_revision/mod_revision/version),旧版本不立即删除,而是保留; - 所以你能查”某个 revision 时刻的历史值”,也能做 CAS(基于
mod_revision或 value 的事务Txn,即compare-and-swap)。
这套 MVCC 直接催生了 etcd 最值钱的能力——可回放的 Watch(见 3.3)。代价是历史版本会累积,靠 compaction 定期压缩掉某个 revision 之前的旧版本来回收空间(否则 db 会一直涨,这也是 etcd 运维的一个重点)。
3.2 lease 租约:比会话更显式的”存活”
etcd 用 lease(租约) 表达”存活性”,对标 ZK 的会话,但更灵活:
lease grant:申请一个带 TTL(如 10s)的租约,拿到一个leaseID;put --lease:把一个或多个 key 挂到这个 lease 上;KeepAlive:客户端通过一条 gRPC 流周期性续约,每次续约把 TTL 重置;- 一旦客户端停止续约(崩溃、网络断),TTL 倒计时归零,lease 过期,挂在它上面的所有 key 被自动删除,并产生对应的 Watch DELETE 事件。
和 ZK 会话相比,lease 的优势是显式且可复用:一个 lease 可以绑多个 key(比如一个服务实例的地址、元数据、健康标记一起过期),TTL 也能按 key 独立设定,语义比”一个会话管所有临时节点”更清晰可控。
3.3 Watch:基于 revision,可回放
这是 etcd 相对 ZooKeeper 的代际优势。etcd 的 Watch 不是一次性的,而是基于 revision 的持续流:
- 你可以
watch一个 key 或一段前缀范围,并指定从哪个 revision 开始; - etcd 会把从该 revision 起的所有变更事件按序推给你,不会漏;
- 客户端断线重连时,只要带上”我上次处理到的 revision + 1”,就能从断点继续、把断线期间的事件补齐——因为历史版本还在(未被 compaction)。
对比 ZK Watch 的”一次性、通知后要重读、期间可能漏变更”,etcd 的 Watch 天然适合做增量同步(Kubernetes 的 list-watch、informer 机制就建在它上面)。唯一约束:你要 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 阶段几乎一一对应:选主 → 对齐历史 → 复制 → 多数派提交。差别在设计取向而非能力。
共性(这才是重点):
- 都靠多数派(quorum,N/2+1) 达成一致,都能容忍少数节点故障;
- 都是强 Leader 模型:写只走 Leader,Follower 复制;
- 都用单调递增的序号保证操作全序——ZAB 是
zxid(epoch + 计数),Raft 是(term, index); - 崩溃恢复的正确性目标一致:已提交的绝不丢失,未提交的可被新 Leader 覆盖;
- 选主时都倾向让”数据最新”的节点当选(ZAB 比最大 zxid,Raft 比 log 的 term/index 新旧)。
差异(多是取向而非能力):
| 维度 | ZAB(ZooKeeper) | Raft(etcd) |
|---|---|---|
| 心智模型 | 主备复制 + 原子广播 | 复制状态机 |
| 序号 | zxid = epoch << 32 | counter | (term, index) |
| 强调 | primary-order(主序广播) | 日志 append-only、易理解 |
| 提交流程 | Propose → Ack → Commit | AppendEntries → 多数派 → 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 Curator 的 LeaderLatch / 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 被通知
etcd:watch 一个 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 读:默认可能读到”旧一点”的值
这是最容易踩的认知坑:
- ZooKeeper 的读默认由连接的那台节点直接返回,不走 Leader。Follower 的数据可能略微滞后于 Leader(它保证顺序一致、单调,但不保证你读到的是”此刻最新已提交”)。要强制读到最新,客户端要先调
sync()把该节点与 Leader 对齐再读。 - etcd 默认是线性一致读:通过 ReadIndex 机制——Leader 先确认自己仍是合法 Leader(收到多数派心跳响应)且拿到当前 commit index,等状态机应用到该 index 再返回,保证读到最新已提交,但多一轮确认、延迟略高。想要低延迟可显式用 serializable 读,直接读本节点本地状态(可能稍旧、吞吐高)。
取舍规则:需要”读到刚写进去的值”(如选主后立刻读 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
没有绝对的赢家,按生态和需求选:
| 维度 | ZooKeeper | etcd | Consul / Nacos |
|---|---|---|---|
| 协议 | ZAB | Raft | Raft |
| 数据模型 | znode 树 | 扁平 KV + MVCC | KV + 原生服务目录 |
| Watch | 一次性 | 基于 revision、可回放 | 长轮询 / 流 |
| 语言/生态 | JVM 系(Kafka/HBase/Dubbo/Solr) | 云原生(Kubernetes/gRPC/Go) | 微服务、多数据中心 |
| 健康检查 | 靠会话/临时节点 | 靠 lease | 内置多种健康检查 |
| 服务发现 | 需自己在 znode 上搭 | 需自己在 KV 上搭 | 开箱即用 + DNS/HTTP 接口 |
| 典型使用者 | Kafka(旧)、HBase、Dubbo | Kubernetes、CoreDNS、M3 | Consul-based 微服务、Nacos(Spring Cloud Alibaba) |
一句话选型:
- 已经在 JVM 生态、且已经有 ZooKeeper(比如用 Kafka/HBase/Dubbo) → 继续用 ZooKeeper,别为了赶时髦多引一个组件。
- 云原生、Kubernetes、Go/gRPC 技术栈 → etcd 是天然选择(K8s 本来就带一套),Watch 可回放对增量同步太友好。
- 要开箱即用的服务发现 + 健康检查 + 多机房 → Consul(HashiCorp 系)或 Nacos(Spring Cloud Alibaba 系)更省心,它们把服务目录、健康检查、配置中心打包好了。
补充趋势:Kafka 正在去 ZooKeeper 化——新版用内置的 KRaft(自带 Raft 的 Controller quorum)替代 ZK,少一个外部依赖、元数据传播更快。这也印证了行业方向:Raft 因其可理解性,正逐步成为协调层的事实标准。相关背景见 Kafka 核心原理。
参考
- Apache ZooKeeper. ZooKeeper Programmer’s Guide(数据模型、会话、Watch):znode 类型、临时节点、Watch 语义的权威说明。
- Apache ZooKeeper. ZooKeeper Internals(ZAB 与 zxid):广播与崩溃恢复、zxid 结构的一手资料。
- etcd. etcd Documentation — Learning / API / MVCC / Lease:Raft 集成、revision/MVCC、lease、Watch、线性一致读的官方文档。
- Diego Ongaro, John Ousterhout. In Search of an Understandable Consensus Algorithm (Raft):Raft 论文,理解 etcd 复制层的必读。
- Flavio Junqueira, Benjamin Reed, Marco Serafini. ZAB: High-performance broadcast for primary-backup systems:ZAB 协议原始论文。
- Apache Curator. Curator Recipes(LeaderLatch / 分布式锁等):ZooKeeper 上层封装的生产级实现,别自己造轮子。