Skip to content
Charles Shao
Go back

基于共识引擎的协调服务 · ZooKeeper 与 etcd 底层图解

–views

上一篇 分布式共识机制 · Paxos 与 Raft 图解 讲清了「一组节点如何对操作顺序达成不可推翻的一致」。生产里真正天天打交道的,往往不是论文里的 Paxos,而是把共识封装成可用产品的两套协调服务:ZooKeeper 与 etcd。选主、配置下发、服务发现、分布式锁——这些控制面能力,几乎都站在它们之上。

本篇剥开这两套服务的实现:ZooKeeper 的 znode / Session / 一次性 Watch / ZAB,以及 etcd 的 Raft / MVCC · revision / Lease / 可持续 Watch,把模型、一致性语义和线上容易踩的坑对齐讲清楚。下一篇 分布式锁深挖 会直接用到这里的临时节点、租约与 CAS。

本文是 分布式一致性 系列的第 2 篇(协调服务)。 全系列 5 篇:

  1. 分布式共识机制 · Paxos 与 Raft 图解
  2. 基于共识引擎的协调服务 · ZooKeeper 与 etcd 底层图解(本篇)
  3. 分布式锁深挖 · Redis Redlock 与 ZooKeeper/etcd
  4. 分布式事务深挖 · 2PC、TCC、Saga 与 Outbox
  5. 分布式 ID 深挖 · 雪花算法及其变体

一句话定位:协调服务不是通用数据库,而是「轻量元数据 + 强一致写入 + 状态变更回调」的专用内核——只托管必须全网一致的那一小撮控制面状态,支撑选主、发现与并发控制。

TL;DR

Table of contents

Open Table of contents

1. 分布式协调服务的核心痛点与定位

分布式系统里总有一类状态:全集群必须看到同一份视图,否则就会双写、脑裂或控制面死锁。

这些场景的共同画像是:载荷很小(常见 KB 级)、读远多于写、一致性几乎零容忍。不适合塞进重型关系库;需要的是「Quorum 约束 + 强一致日志 + 变更回调」的薄内核。协调服务做的事,就是把 Paxos / Raft 收成开发者能直接用的 API 与状态模型——上一篇的协议,在这里变成产品。

红线:不要把协调服务当普通数据库用。 塞大 Value、高频强写、甚至当消息队列中转,是线上事故的常见源头。它适合管「必须一致的小元数据」,不适合扛业务流量。

2. ZooKeeper 架构:znode 模型与 ZAB 广播协议

ZooKeeper 是该领域的早期事实标准(约 2008,随 Hadoop 生态),Kafka、HBase、Dubbo 等都曾重度依赖。内核可拆成四块:数据模型、会话、Watch、ZAB。

ZooKeeper 的 znode 树与 Watch 通知机制示意图

临时节点把「客户端失联」变成服务端可观测的删除事件;一次性 Watch 提供变更回调,但要防惊群,也要接受可能漏掉中间变更。

2.1 存储拓扑:层级 znode 树

ZooKeeper 对外是一棵类文件系统的路径树,节点叫 znode(如 /services/payment/inst-01)。与 OS 目录不同:znode 既可以有子节点,也可以自带数据——「目录」和「文件」并不互斥。

业务上常用几类(可组合):

每个 znode 还带着版本元数据(version / cversion / aversion)与相关 zxid。写可用期望版本做 CAS(如 setData(path, data, expectedVersion)),避免盲目覆盖。

2.2 心跳会话与临时节点的生命周期联动

客户端连上集群后获得一个 Session,并协商 sessionTimeout。客户端按周期发 Ping 续期;心跳在,会话就在。

工程上真正有用的是 Session 与临时节点的绑定:

这套机制有代价:宕机感知与状态删除绑在一起很干净;但**「会话超时」≠「进程一定已死」**。网络抖动或长时间 STW,会让健康实例被误摘;假死触发的重选与误摘,根因经常在这里。

2.3 Watch:一次性触发的变更回调

相对死轮询,ZooKeeper 提供 Watch:在 getData / exists / getChildren 等读请求上可挂标志;目标数据、子节点或存在性变化时,服务端推一条轻量通知。

三条约束必须记牢:

  1. 一次性(One-time Trigger):触发后 Watch 失效,要持续监听必须重新注册。在「收到通知」到「重新挂上」的窗口里若再发生变更,中间事件会丢。Watch 只告诉你「变了」,新值还得自己再读。
  2. 事件顺序:同一客户端上,Watch 通知顺序与状态机演进顺序一致;且保证先收到变更通知,再通过后续读看到新数据。
  3. 惊群(Herd Effect):通知本身很小,但若成千上万客户端 Watch 同一节点,一次删除/改写会扇出海量唤醒,打满带宽与 CPU。选举排队时,标准做法是每个候选者只 Watch 自己前面的那个邻居,把 O(N) 唤醒压到 O(1)——下一篇锁实现会复用这一招。

2.4 ZAB 广播协议:崩溃恢复与原子全序

ZooKeeper 的共识层不是直接用 Paxos/Raft 论文实现,而是自研的 ZAB(ZooKeeper Atomic Broadcast)。目标与 Raft 同类:让副本组在事务日志顺序上达成一致。生命周期分两段:

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。

etcd 的 Raft 日志复制与 Lease 租约续约示意图

Raft 保证写序;对外是带 revision 的扁平 KV。Lease 用 TTL + KeepAlive 保活,到期则删关联 Key。

3.1 核心数据结构:扁平 KV 空间与 revision 版本序列

etcd 对外是扁平、可按字典序扫描的键值空间(习惯上仍用 / 前缀模拟层级)。核心是 MVCC:

MVCC 直接支撑 可从历史 revision 回放的 Watch(§3.3)。代价是历史会膨胀,必须靠周期性 Compaction 砍掉过旧 revision——运维上 Compaction 与磁盘空间常是排障重点。

3.2 Lease 租约:用显式 TTL 替代隐式会话

保活上,etcd 用 Lease 对标 ZK Session,但控制更显式:

  1. lease grant:申请带 TTL 的 leaseID(如 10 秒)。
  2. put --lease:一个或多个 Key 挂到该 lease。
  3. KeepAlive:经 gRPC 长连接续期,把 TTL 重置。
  4. 到期清理:心跳停、TTL 归零 → lease 上关联 Key 被删,并产生 Watch DELETE 事件。

相对「一整条 Session 绑一批临时节点」,lease 更细:一条租约可同时管地址、标签、元数据多个 Key;不同链路也可设不同 TTL。

3.3 可持续 Watch:按 revision 回放,避免漏事件

相对 ZK 的一次性 Watch,etcd Watch 是按 revision 输出的连续事件流:

因此 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、任期与日志提交,在这里几乎一一对应。

ZAB 与 Raft 阶段几乎一一对应

选主、追日志、复制与多数派提交——两条链路几乎镜像。差别主要在抽象与工程细节,不是「谁更强一致」。

共性(先对齐这些):

差异(心智与工程):

对比视角ZAB(ZooKeeper)Raft(etcd 等)
模型主备 + 原子广播(Atomic Broadcast)复制状态机(Replicated State Machine)
序号zxid = epoch ‖ counter(term, index)
侧重点Primary-order 广播规则日志 Append-only,论文更易读
提交管线Propose → Ack → CommitAppendEntries 达多数 → 推进 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 读取:默认语义不同,短暂落后要心里有数

容易踩的点在读:

戒律:下一拍依赖上一拍结果的操作(验锁、新 Leader 拉权威配置等)→ 必须线性一致读(etcd 默认,或 ZK 加 sync)。可容忍几十毫秒旧值的只读配置 → 可用本地读换吞吐。搞反就会出现「名义强一致、实际读到旧视图」。

6.3 违背直觉的规模陷阱

多数派推导出一条反直觉结论:协调集群节点加得越多,写往往越慢。 5 节点要等 3 份确认,7 节点要等 4 份;容错只从「挂 1 台」升到「挂 2 台」。所以生产 ZooKeeper / etcd 多数只跑 3 或 5 节点。要扩读,加 Learner / Observer(无表决权、只跟日志)即可,不要把有票权节点堆上去。这与 高可用防线设计 里「冗余要卡在 Quorum 成本甜点」是同一逻辑。

7. 工业级协调组件的架构选型

没有万能答案,按生态与能力切:

选型切片ZooKeeperetcdConsul / Nacos
底层引擎ZABRaft多为 Raft 系
数据模型层级 znode扁平 KV + MVCCKV + 服务目录
Watch一次性触发revision 可回放长轮询 / 推送等
生态JVM(Kafka / HBase / 经典 Dubbo)云原生(K8s / gRPC / Go)微服务、多机房
健康检查Session + 临时节点Lease内建 HTTP/TCP 等探针
服务发现需自建 znode 约定需自建 KV 约定开箱 + DNS/HTTP
典型用户Kafka(迁出中)、HBase、传统 DubboK8s、CoreDNS、M3 等Consul 栈、Nacos / Spring Cloud Alibaba

选型可压成三条:

趋势:Kafka 在推进 去 ZooKeeper,用内置 KRaft(内部 Raft Controller Quorum)管元数据,去掉外部协调件的运维与扩展瓶颈。这也侧面说明:Raft 心智与工程封装,正在成为控制面默认选择。细节见 Kafka 架构核心链路。

协调服务把「共识」收成了 API;下一篇把同一套临时节点、Lease 与 CAS,接到分布式锁的正确性边界上——Redis Redlock 与 ZK/etcd 锁各自能守住什么、又会在何处失手。

参考


–views
Share this post on:

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