上一篇 分布式共识机制 · Paxos 与 Raft 图解 讲清了「一组节点如何对操作顺序达成不可推翻的一致」。生产里真正天天打交道的,往往不是论文里的 Paxos,而是把共识封装成可用产品的两套协调服务:ZooKeeper 与 etcd。选主、配置下发、服务发现、分布式锁——这些控制面能力,几乎都站在它们之上。
本篇剥开这两套服务的实现:ZooKeeper 的 znode / Session / 一次性 Watch / ZAB,以及 etcd 的 Raft / MVCC · revision / Lease / 可持续 Watch,把模型、一致性语义和线上容易踩的坑对齐讲清楚。下一篇 分布式锁深挖 会直接用到这里的临时节点、租约与 CAS。
本文是 分布式一致性 系列的第 2 篇(协调服务)。 全系列 5 篇:
- 分布式共识机制 · Paxos 与 Raft 图解
- 基于共识引擎的协调服务 · ZooKeeper 与 etcd 底层图解(本篇)
- 分布式锁深挖 · Redis Redlock 与 ZooKeeper/etcd
- 分布式事务深挖 · 2PC、TCC、Saga 与 Outbox
- 分布式 ID 深挖 · 雪花算法及其变体
一句话定位:协调服务不是通用数据库,而是「轻量元数据 + 强一致写入 + 状态变更回调」的专用内核——只托管必须全网一致的那一小撮控制面状态,支撑选主、发现与并发控制。
TL;DR
- 五大落地场景:领导者选举、配置下发、服务发现、分布式锁、集群元数据。共性是数据极小、读多写少、一致性容忍度为零——这正是共识引擎产品化的驱动力。
- ZooKeeper 的 znode 树:类文件系统路径寻址;每个 znode 只适合挂 KB 级载荷;类型含持久 / 临时(ephemeral) / 顺序(sequential),可组合。
- 临时节点绑定 Session:会话超时(Session Expired)后服务端删除该客户端全部临时节点——服务摘除与选主交接的自动回收路径。
- 一次性 Watch:触发后必须重新注册才能继续监听;只保证「知道发生过变更」,不保证捕获每一次中间变更。
- ZAB:以 zxid(epoch + 计数)定全局序;写路径走
Propose → Ack → Commit,多数派落盘后才能提交。 - etcd 的 Raft 底座:内嵌 Raft 做日志复制;对外是扁平 KV + 全局 revision。
- MVCC 与 revision:每次写入推进全局
revision并保留历史版本,支撑 Watch 从指定 revision 回放(弥补 ZK 易漏事件的短板);过期版本靠 Compaction 回收。 - Lease:对标 ZK Session 的显式租约;Key 挂到 lease 上,靠
KeepAlive续期,TTL 归零则关联 Key 删除——粒度比整会话更细。 - ZAB ≈ Raft 的工程同源:都靠 Quorum、强 Leader、单调序号;ZAB 偏「主备原子广播」,Raft 偏「复制状态机」,后者心智更干净、成员变更更成熟。
- 读一致性要显式选型:线性一致读需打到 Leader(etcd ReadIndex / ZK
sync);可接受短暂旧值则读本地副本换吞吐。 - 选型:JVM 存量依赖(Kafka / HBase / Dubbo)→ ZooKeeper;云原生 / K8s / gRPC → etcd;要开箱服务发现与健康检查 → Consul / Nacos。
Table of contents
Open Table of contents
1. 分布式协调服务的核心痛点与定位
分布式系统里总有一类状态:全集群必须看到同一份视图,否则就会双写、脑裂或控制面死锁。
- 领导者选举(Leader Election):对等节点里谁写、谁调度?两人同时自称 Leader,就会并发双写。Kafka 早期靠 ZooKeeper 选 Controller 并管 Broker / ISR;新架构用内置 KRaft 把元数据闭环掉。
- 配置动态下发:功能开关、路由表、限流阈值——改一次就要对全集群一致、尽快可见。
- 服务发现:实例上下线频繁时,调用方要在较短时间内拿到可用端点列表。
- 分布式锁:跨进程互斥临界区(下一篇 分布式锁深挖 · Redis Redlock 与 ZooKeeper/etcd 专讲)。
- 集群元数据:分片归属、成员名单、任务指派等控制面权威源。
这些场景的共同画像是:载荷很小(常见 KB 级)、读远多于写、一致性几乎零容忍。不适合塞进重型关系库;需要的是「Quorum 约束 + 强一致日志 + 变更回调」的薄内核。协调服务做的事,就是把 Paxos / Raft 收成开发者能直接用的 API 与状态模型——上一篇的协议,在这里变成产品。
红线:不要把协调服务当普通数据库用。 塞大 Value、高频强写、甚至当消息队列中转,是线上事故的常见源头。它适合管「必须一致的小元数据」,不适合扛业务流量。
2. ZooKeeper 架构:znode 模型与 ZAB 广播协议
ZooKeeper 是该领域的早期事实标准(约 2008,随 Hadoop 生态),Kafka、HBase、Dubbo 等都曾重度依赖。内核可拆成四块:数据模型、会话、Watch、ZAB。
临时节点把「客户端失联」变成服务端可观测的删除事件;一次性 Watch 提供变更回调,但要防惊群,也要接受可能漏掉中间变更。
2.1 存储拓扑:层级 znode 树
ZooKeeper 对外是一棵类文件系统的路径树,节点叫 znode(如 /services/payment/inst-01)。与 OS 目录不同:znode 既可以有子节点,也可以自带数据——「目录」和「文件」并不互斥。
业务上常用几类(可组合):
- 持久节点(Persistent):显式 Delete 才消失;适合配置、路径骨架。
- 临时节点(Ephemeral):生命周期绑定创建者的 Session。客户端断开或 Session Expired 后,服务端删除之。临时节点不能有子节点。 服务注册与选主交接常靠这条自动回收。
- 顺序节点(Sequential):创建时父路径下追加单调递增序号(如
n_0000000001)。适合队列、公平锁、按序选举。 - 临时顺序节点(Ephemeral Sequential):自回收 + 有序,分布式锁与选举的常用形态(下一篇会展开)。
每个 znode 还带着版本元数据(version / cversion / aversion)与相关 zxid。写可用期望版本做 CAS(如 setData(path, data, expectedVersion)),避免盲目覆盖。
2.2 心跳会话与临时节点的生命周期联动
客户端连上集群后获得一个 Session,并协商 sessionTimeout。客户端按周期发 Ping 续期;心跳在,会话就在。
工程上真正有用的是 Session 与临时节点的绑定:
- 实例上线时,在约定路径下创建临时节点,写入自身地址;
- 会话存活 → 节点在 → 调用方可发现;
- 会话超时(进程挂、网络断、或 JVM Full GC 卡住心跳线程)→ 服务端判定会话失效 → 删除该会话下全部临时节点;
- 「实例挂了」因此变成服务端发出的删除事件,观察方用 Watch 就能感知,而不必靠慢轮询。
这套机制有代价:宕机感知与状态删除绑在一起很干净;但**「会话超时」≠「进程一定已死」**。网络抖动或长时间 STW,会让健康实例被误摘;假死触发的重选与误摘,根因经常在这里。
2.3 Watch:一次性触发的变更回调
相对死轮询,ZooKeeper 提供 Watch:在 getData / exists / getChildren 等读请求上可挂标志;目标数据、子节点或存在性变化时,服务端推一条轻量通知。
三条约束必须记牢:
- 一次性(One-time Trigger):触发后 Watch 失效,要持续监听必须重新注册。在「收到通知」到「重新挂上」的窗口里若再发生变更,中间事件会丢。Watch 只告诉你「变了」,新值还得自己再读。
- 事件顺序:同一客户端上,Watch 通知顺序与状态机演进顺序一致;且保证先收到变更通知,再通过后续读看到新数据。
- 惊群(Herd Effect):通知本身很小,但若成千上万客户端 Watch 同一节点,一次删除/改写会扇出海量唤醒,打满带宽与 CPU。选举排队时,标准做法是每个候选者只 Watch 自己前面的那个邻居,把
O(N)唤醒压到O(1)——下一篇锁实现会复用这一招。
2.4 ZAB 广播协议:崩溃恢复与原子全序
ZooKeeper 的共识层不是直接用 Paxos/Raft 论文实现,而是自研的 ZAB(ZooKeeper Atomic Broadcast)。目标与 Raft 同类:让副本组在事务日志顺序上达成一致。生命周期分两段:
- 崩溃恢复(Recovery):冷启动或 Leader 失联时进入选主。Fast Leader Election(FLE) 让节点交换各自最新
zxid,日志最新者优先当选,避免已提交事务丢失。新 Leader 先把 Follower 追齐,多数派对齐后再对外服务。 - 原子广播(Broadcast):稳态下,写请求必须到 Leader。Leader 为每次写分配单调 zxid,再走
Propose→ Follower 落盘并Ack→ 收齐 Quorum 后Commit。可看成精简版两阶段提交。
zxid 是 64 位:高 32 位 epoch(每任合法 Leader 一轮,选举时递增),低 32 位为本任期内事务序号。换主则 epoch 升高、计数清零——用来丢掉旧任期 Leader 事后再抛出的过期提案,并保持全局序单调。
靠 Primary-Order 与恢复期的历史对齐,ZooKeeper 对外承诺线性一致写 + 顺序一致读(本地读默认不保证最新,见 §6)。
3. etcd 架构:MVCC 引擎与 Lease 租约机制
etcd(CoreOS,Go)是 Kubernetes 控制面元数据存储(各类 API 对象落盘于此)。相对 ZooKeeper,模型更现代:扁平 KV、MVCC 多版本、底层直接跑 Raft。
Raft 保证写序;对外是带 revision 的扁平 KV。Lease 用 TTL + KeepAlive 保活,到期则删关联 Key。
3.1 核心数据结构:扁平 KV 空间与 revision 版本序列
etcd 对外是扁平、可按字典序扫描的键值空间(习惯上仍用 / 前缀模拟层级)。核心是 MVCC:
- 集群维护一条全局单调递增的
revision。任意 Key 的任意一次写,都会让 revision +1。 - 每次覆写带
create_revision/mod_revision;旧值不立刻抹掉,而是保留历史版本。 - 于是可以读历史 revision,也方便
Txn做 CAS。
MVCC 直接支撑 可从历史 revision 回放的 Watch(§3.3)。代价是历史会膨胀,必须靠周期性 Compaction 砍掉过旧 revision——运维上 Compaction 与磁盘空间常是排障重点。
3.2 Lease 租约:用显式 TTL 替代隐式会话
保活上,etcd 用 Lease 对标 ZK Session,但控制更显式:
lease grant:申请带 TTL 的leaseID(如 10 秒)。put --lease:一个或多个 Key 挂到该 lease。KeepAlive:经 gRPC 长连接续期,把 TTL 重置。- 到期清理:心跳停、TTL 归零 → lease 上关联 Key 被删,并产生 Watch DELETE 事件。
相对「一整条 Session 绑一批临时节点」,lease 更细:一条租约可同时管地址、标签、元数据多个 Key;不同链路也可设不同 TTL。
3.3 可持续 Watch:按 revision 回放,避免漏事件
相对 ZK 的一次性 Watch,etcd Watch 是按 revision 输出的连续事件流:
watch时可指定从哪个 revision 开始;- 服务端按序推送该点之后的增量,不丢中间事件(在历史未被 Compaction 的前提下);
- 断线重连时带上「上次成功消费的 revision + 1」,即可补回空窗内的变更——前提是那段历史还在。
因此 etcd 更适合持续增量同步(K8s 的 list-watch / Informer 就建在这套语义上)。硬约束:起始 revision 不能早于已 Compaction 的下界,否则返回 ErrCompacted。
3.4 写入路径与 Raft 绑定
写路径就是标准 Raft:请求到 Leader → 追加 raft log(通常要求 fsync)→ AppendEntries 复制 → 多数派确认 → 提交到状态机(MVCC)并推进 revision。所以 etcd 对磁盘 fsync 延迟极敏感:盘慢会拖提交,甚至拖死心跳,触发不必要的选主——线上很常见的一类故障。
4. ZAB 与 Raft 协议:底层架构的异同与取舍
ZAB 与 Raft 常被拿来对比。结论先放前头:要解决的问题高度重合,宏观机制相似度远大于差异。 上一篇讲过的 Quorum、强 Leader、任期与日志提交,在这里几乎一一对应。
选主、追日志、复制与多数派提交——两条链路几乎镜像。差别主要在抽象与工程细节,不是「谁更强一致」。
共性(先对齐这些):
- 都用 Quorum(⌊N/2⌋+1) 做决策,少数派分区写不进去。
- 都是强 Leader:写(及线性一致读)走 Leader,Follower 跟日志。
- 都用单调序号定全序——ZAB 的
zxid(epoch + counter),Raft 的(term, index)。 - 崩溃恢复共识一致:已 Committed 不丢;Uncommitted 可被新 Leader 覆盖。
- 选主都倾向「日志更新」的候选人。
差异(心智与工程):
| 对比视角 | ZAB(ZooKeeper) | Raft(etcd 等) |
|---|---|---|
| 模型 | 主备 + 原子广播(Atomic Broadcast) | 复制状态机(Replicated State Machine) |
| 序号 | zxid = epoch ‖ counter | (term, index) |
| 侧重点 | Primary-order 广播规则 | 日志 Append-only,论文更易读 |
| 提交管线 | Propose → Ack → Commit | AppendEntries 达多数 → 推进 commitIndex |
| 成员变更 | 早期较弱,常需停机或专用流程 | Joint Consensus 等动态变更更成熟 |
| 来源 | 随 ZooKeeper 工程提炼 | 为可理解性专门设计,etcd 等跟进 |
结论:ZAB 是为 ZooKeeper 量身定做的原子广播;Raft 是面向通用复制状态机的共识模板。 一致性承诺同级。Raft 因论文清晰、成员变更与生态更全,成了后续协调件(etcd、Consul、TiKV 等)的默认底座。
5. 经典工程落地:领导者选举与配置流转
协议落到写代码,常见就这几类模式。
5.1 领导者选举(Leader Election)
ZooKeeper:标准做法是「临时顺序节点 + 只 Watch 前驱」。候选者在 /election 下建顺序节点,序号最小者当 Leader;其他人只 Watch 紧邻前一个。前驱删除(通常意味着前任掉线)后再看自己是否已是最小。生产不要手写整条链路,优先用 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(); // 阻塞直到获得领导权
if (latch.hasLeadership()) {
// 仅当前 Leader 进入:执行写主逻辑
}
// 进程崩溃或会话断开 → 临时节点删除 → 后继触发重选
etcd:Lease + Txn(CAS)抢占固定 Key,持有者靠 KeepAlive 续租;CLI 已封装为 election:
# 节点 A:竞选并阻塞持有
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 挂、续约停 → Key 随 lease 删除 → 其他候选再抢。
5.2 动态配置的全网流转
ZooKeeper:配置放持久 znode;读时挂 Watch,收到变更后再读一次:
zkCli.sh
> create /config/rate-limit "1000" # 初始配置
> get -w /config/rate-limit # 读并挂 watch,变更触发 NodeDataChanged
> set /config/rate-limit "2000" # 变更 → 通知已挂 watch 的客户端
etcd:按 Key 或前缀 Watch,事件流可持续,并可从指定 revision 接着看:
etcdctl put /config/rate-limit 1000
etcdctl watch /config/rate-limit # 持续输出后续 PUT/DELETE
etcdctl watch --prefix /config/ # 监听整棵配置前缀
注意 ZK 一次性 Watch 的重挂窗口可能漏变;etcd 更适合「配置要跟每一次变更」。
5.3 实例服务自动发现与探活
两边都是「生命周期绑定的临时实体 + 对父路径/前缀的监听」:进程启动写入与自身同生共死的地址,调用方监听父级拿列表。
# etcd:注册时挂短 TTL lease,存活则 KeepAlive,挂了则服务端删 Key
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 写入:线性一致
两边的写都按线性一致走:必须经 Leader,多数派持久化后才 ACK,全网有唯一提交序。代价直接:写吞吐受限于半数节点落盘速度——etcd 卡在 raft log 的 fsync,ZK 卡在 Transaction Log。生产上写 QPS 常见落在数千到约万级量级,绝不能当业务主库扛洪峰。
6.2 读取:默认语义不同,短暂落后要心里有数
容易踩的点在读:
- ZooKeeper 默认读本地连接的 Follower,不经过 Leader。Follower 可能略落后(顺序仍对,但不保证最新)。要最新,先调
sync(),让本地与 Leader 对齐再读。 - etcd 默认线性一致读:走 ReadIndex——确认 Leader 身份与最新
commitIndex,状态机应用到该点后再返回。保证不漏已提交写,但多一轮确认。要吞吐可降为 serializable 本地读,可能读到略旧快照。
戒律:下一拍依赖上一拍结果的操作(验锁、新 Leader 拉权威配置等)→ 必须线性一致读(etcd 默认,或 ZK 加
sync)。可容忍几十毫秒旧值的只读配置 → 可用本地读换吞吐。搞反就会出现「名义强一致、实际读到旧视图」。
6.3 违背直觉的规模陷阱
多数派推导出一条反直觉结论:协调集群节点加得越多,写往往越慢。 5 节点要等 3 份确认,7 节点要等 4 份;容错只从「挂 1 台」升到「挂 2 台」。所以生产 ZooKeeper / etcd 多数只跑 3 或 5 节点。要扩读,加 Learner / Observer(无表决权、只跟日志)即可,不要把有票权节点堆上去。这与 高可用防线设计 里「冗余要卡在 Quorum 成本甜点」是同一逻辑。
7. 工业级协调组件的架构选型
没有万能答案,按生态与能力切:
| 选型切片 | ZooKeeper | etcd | Consul / Nacos |
|---|---|---|---|
| 底层引擎 | ZAB | Raft | 多为 Raft 系 |
| 数据模型 | 层级 znode | 扁平 KV + MVCC | KV + 服务目录 |
| Watch | 一次性触发 | revision 可回放 | 长轮询 / 推送等 |
| 生态 | JVM(Kafka / HBase / 经典 Dubbo) | 云原生(K8s / gRPC / Go) | 微服务、多机房 |
| 健康检查 | Session + 临时节点 | Lease | 内建 HTTP/TCP 等探针 |
| 服务发现 | 需自建 znode 约定 | 需自建 KV 约定 | 开箱 + DNS/HTTP |
| 典型用户 | Kafka(迁出中)、HBase、传统 Dubbo | K8s、CoreDNS、M3 等 | Consul 栈、Nacos / Spring Cloud Alibaba |
选型可压成三条:
- 已绑 JVM 且存量依赖 ZK(Kafka、HBase 等)→ 继续用 ZooKeeper,别为「换新」拆出第二套控制面。
- 云原生 / Kubernetes / Go·gRPC → 优先 etcd(K8s 已内嵌);revision Watch 对增量同步友好。
- 只要开箱服务发现 + 健康检查,且多机房 → Consul 或 Nacos,把路由、探活、配置做成产品能力,少手写注册约定。
趋势:Kafka 在推进 去 ZooKeeper,用内置 KRaft(内部 Raft Controller Quorum)管元数据,去掉外部协调件的运维与扩展瓶颈。这也侧面说明:Raft 心智与工程封装,正在成为控制面默认选择。细节见 Kafka 架构核心链路。
协调服务把「共识」收成了 API;下一篇把同一套临时节点、Lease 与 CAS,接到分布式锁的正确性边界上——Redis Redlock 与 ZK/etcd 锁各自能守住什么、又会在何处失手。
参考
- 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 与线性读。
- 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 上层配方;生产优先用现成封装,避免从零手写选举与锁。