系列目录
- MySQL 索引与慢查询:B+ 树如何减少扫描
- 声明式事务之下:InnoDB 的 MVCC 与锁
- 接入层:Nginx 反向代理与 OpenResty 的边界
- Web 容器与 Netty:线程模型之下的 IO 模型
- Redis(上):缓存用法与单线程模型的限制
- Redis(下):超出缓存用途的用法:锁、队列与排行榜
- 数据库访问层:连接池与 MyBatis 的显式 SQL
- Kafka(上):吞吐的来源是顺序 IO
- Kafka(下):生产端、broker 与消费端的可靠性配置
- RPC 框架:像本地调用一样调远程的代价
- 消息语义:按 at-least-once 设计业务代码
- 熔断与限流中间件:把失败当作正常状态管理
- 唯一 ID 中间件 Leaf:号段模式与雪花模式
- RocketMQ 的事务消息与延迟消息
- 分布式任务调度:同一时刻只跑一份
- ZooKeeper/etcd:小数据强一致的协调服务(本篇)
切入:Kafka 为什么依赖 ZK
之前写 Kafka 时,我把 broker、副本和 ISR 作为 Kafka 内部机制处理,没有展开 ZooKeeper。查阅 Kafka 1.x 的设计与文档后,可以看到它对 ZK 的依赖集中在 broker 注册、controller 选举和分区状态变更等环节。controller 是集群中的管理角色,负责管理分区 leader 选举和副本状态机;哪个 broker 担任 controller,由谁先在 ZK 上创建 /controller 临时节点决定。
可以用一个典型的会话过期过程理解 controller 为什么会切换:原 controller 与 ZK 的心跳中断时间超过 session timeout 后,ZK 会判定会话过期并删除 /controller 临时节点。其他 broker 随后可以创建该节点并接管 controller 职责,接管过程中会处理分区 leader 和副本状态。原 broker 进程即使在停顿结束后继续运行,原来的 ZK 会话和 controller 身份也已经失效,必须重新建立会话并重建相关状态。这个过程用于说明机制,并非本文记录的实际故障。
Kafka 自己有一套副本同步和 leader 选举协议,但「谁是 controller」由 ZK 的会话和临时节点机制决定。唯一 ID 中间件 Leaf:号段模式与雪花模式的雪花模式使用 ZK 持久顺序节点分配 workerId,分布式任务调度:同一时刻只跑一份中 ElasticJob 使用 ZK 临时节点选主和分片,也采用了同类分工。这些系统将「谁是主、哪些实例在线」交给独立的协调服务,自己读取状态并据此行动。协调服务的会话和状态变化会直接影响这些系统的故障行为。
原理拆解:共识协议要解决什么
ZooKeeper/etcd 通过共识协议保证集群内写一致。共识要解决的问题是:多个节点对同一个值达成一致,且已达成的一致不再改变。节点可能崩溃,网络可能延迟或丢消息。ZK/etcd 只考虑 crash 故障,不考虑拜占庭故障(节点撒谎),因此不需要引入签名等机制。
ZooKeeper 用 ZAB(ZooKeeper Atomic Broadcast),etcd 用 Raft。两者实现不同,共同包含以下三个过程。
选主。 集群里选出一个 leader 节点,所有写请求由它处理。ZAB 的选主阶段节点互相交换自己见过的最大事务 id(zxid),zxid 最大者当选;Raft 用 term 加日志索引,候选人向其他节点拉票,多数派同意则当选。选主本身也靠多数派:一个节点要成为 leader,必须拿到超过半数节点的认可。多数派即 N/2+1,3 节点集群容忍 1 个故障,5 节点容忍 2 个。协调服务通常部署 3 或 5 个节点,偶数节点不增加容错能力只增加写开销。
日志复制。 leader 把每个写请求当作一条日志条目,按顺序复制到 follower。ZAB 里叫 proposal-ack-commit:leader 发 proposal,等多数派 follower ack 后发 commit;Raft 里 leader 把日志 append 到本地,并行发给 follower,多数派复制后 leader commit 并通知 follower apply。两者都要求一条日志在被认为已提交前得到多数派确认。
多数派确认保证已提交日志不丢失。 任何两个多数派集合必然有交集,已经提交的日志必然落在交集中,新 leader 不会丢掉已提交的日志。这个性质叫 safety。代价是可用性受多数派约束:少于多数派节点存活时,集群拒绝写入。3 节点挂 2 个,集群不可写。该限制来自共识协议的多数派机制,不能通过配置消除。
共识协议不保证读强一致。ZK 默认读可以走任意 follower 的本地数据,follower 可能落后于 leader,读到旧值。要强一致读得用 sync 命令先追平。etcd 默认读走 raft read index 或 lease read,保证线性一致,但这是 etcd 的选择,不是共识协议本身自带的。
原理拆解:ZK 的数据模型与会话
共识协议保证写一致,中间件通过数据模型保存协调状态。ZK 把数据组织成一棵 znode 树,路径采用文件系统形式(/app1/leader、/app1/members/node-1)。znode 有四种类型:持久节点、临时节点、持久顺序节点、临时顺序节点。
临时节点绑定客户端会话,会话过期后自动删除。选主和成员管理依赖这一语义。选主:N 个实例启动时各自尝试创建同一个临时节点 /leader,只有一个成功,它就是主;主宕机会话过期,临时节点消失,其余实例感知到后重新抢。成员管理:每个实例在 /members 下创建自己的临时节点,列表里有的就是在线成员,实例宕机节点自动消失。唯一 ID 中间件 Leaf:号段模式与雪花模式里雪花模式用的持久顺序节点是另一种用法:节点序号单调递增,实例拿序号当 workerId,进程退出后节点保留,序号不回收。这避免了回收后重发,代价是 workerId 空间只增不减。
watcher 是一次性触发:客户端在某个 znode 上注册 watcher,该 znode 发生变更时 ZK 推送一次通知,之后 watcher 失效,客户端要重新注册才能收到下一次通知。一次性触发是 3.5 的行为,持久 watcher 是 3.6 引入的,本篇不涉及。这个限制影响使用方式:收到通知后重新注册的窗口期内可能漏掉新的变更,客户端通常在收到通知后先重新注册再处理,并对「可能漏变更」做兜底,比如拿到通知后重新拉一次完整状态而不是依赖增量。
会话由 ZK 客户端与服务器之间的心跳维持。session timeout 是核心参数:心跳间隔内没收到响应,ZK 判定会话过期。会话过期后,该会话创建的所有临时节点消失、所有 watcher 失效,客户端收到 Expired 事件,连接上的状态全部作废。协调服务据此判定实例是否在线,controller 抖动那次就是会话过期触发的。会话过期由 ZK 判定,客户端进程可能仍在运行(比如刚结束一次长 GC),但临时节点已经消失,新主已经选出。客户端恢复后必须重建状态:重新创建临时节点、重新注册 watcher、重新确认自己的角色。
etcd 没有临时节点,用 lease 实现相同语义。客户端申请一个 lease(带 TTL),把 key 绑定到 lease 上,定时续约;lease 过期后绑定的 key 自动删除。ZK 的临时节点和 etcd 的 lease+key 都将「客户端存活」与「数据存在」绑定,客户端失联后数据自动清理。
原理拆解:中间件如何使用协调服务
Kafka 的 controller、ElasticJob 的主节点、Leaf 的 workerId 分配、Dubbo 早期的注册中心,都使用 ZK 保存协调状态。选主、成员关系和元数据是分布式中间件中常见的需求。自行实现时,要处理节点崩溃和网络分区下主节点唯一、成员列表与实际存活状态一致、元数据写入不冲突等问题。将状态存入 ZK/etcd 后,ZAB/Raft 负责保证写一致,中间件不必再实现一套选主和日志复制逻辑。
中间件需要从协调服务读取状态,并据此行动。Kafka broker 启动时读 /controller 判断自己是否为 controller,是则接管管理职责;ElasticJob 实例读分片分配 znode 决定自己运行哪些分片;Leaf 雪花实例读取自己的 workerId znode 获取机器位。中间件依赖协调服务返回的一致状态,可以将代码集中在分区分配算法、分片策略和发号等逻辑上;协调服务承担分布式状态的一致性。
协调服务也会成为共同依赖。ZK 集群不可用时,所有依赖它的中间件都无法完成选主和成员变更:Kafka 无法选新 controller、ElasticJob 无法重分片、Leaf 雪花新实例无法启动。一次 ZK 抖动会同时影响多个中间件,故障相关性较强。有些系统随后移除了对 ZK 的依赖:Kafka 后来用 KRaft 把元数据管理移回 Kafka 自己(本篇截止 2020-03,KRaft 尚未出现,不展开);Dubbo 默认注册中心也转向 Nacos 等 AP 方案。协调服务作为强一致 CP 组件,在可用性敏感的注册场景中成本较高。
常见实现:ZK/etcd 如何实现分布式锁与选主
Redis(下):超出缓存用途的用法:锁、队列与排行榜讨论 Redis 锁时提到:需要更强保证时,可使用基于共识协议的协调服务。本节根据 ZooKeeper、etcd 与 Curator 的公开文档整理常见实现,不构成生产接入经验或参数建议。
ZK 做分布式锁的标准方案是临时顺序节点。客户端在锁路径下创建临时顺序节点 /lock/node-00001,ZK 保证序号单调递增;客户端拿到自己创建的节点序号后,查询所有子节点,如果自己序号最小则获得锁,否则监听前一个节点的删除事件。持锁客户端释放锁(删除自己的节点)或会话过期后,下一个序号的客户端被唤醒。与 Redis 锁相比,这套方案有两个差异。第一,持锁客户端会话过期后临时节点自动消失,不依赖 TTL 续期,不存在由 TTL 到期产生的「锁过期但持有人仍在运行」窗口。第二,每个等待者仅监听前一个节点,前一个节点删除时只唤醒一个后继,避免惊群。Redis 锁释放时,所有等待者需要重新竞争。
ZK 的顺序节点序号单调递增,可以作为 fencing token:持锁客户端把序号随请求带给下游,下游拒绝旧序号的请求,防止持锁客户端停顿后锁被他人接管却仍写入。这对应 Redis(下):超出缓存用途的用法:锁、队列与排行榜中 Kleppmann 强调的 fencing token;ZK 可以直接提供递增序号,RedLock 不提供这一机制。
etcd 做锁的思路类似:用 lease 保证客户端失联后 key 自动释放,用 revision(etcd 的全局单调递增版本号)做顺序判断和 fencing。etcd 3.x 提供了官方 concurrency 包封装这套逻辑,客户端不用自己拼。
选主可视为长期持有的互斥锁:锁用于临界区互斥,选主持续到持有者失联。ZK 选主时创建同一个临时节点 /leader,创建成功的实例为主;etcd 选主使用 lease 加一个 leader key,持有 lease 的实例为主。ZK 将临时节点作为协议内置的节点类型,etcd 则组合 lease 和普通 key 实现这一机制。
本文没有直接维护 ZooKeeper 或 etcd 集群,也没有在生产中实现过基于其客户端的选主或锁逻辑。本系列第 14、16 篇中涉及的 ZK 用法由 Leaf、ElasticJob 等框架封装。下面的 ZK 片段依据 Curator 客户端 API 和官方文档整理,etcd 片段依据 go 客户端 v3 API 整理,用于说明临时节点、lease 与续约的关系;接入生产前仍需按所用版本、故障模型和业务写入路径完成验证:
// ZK 临时顺序节点做锁(Curator InterProcessMutex,最小示意)
InterProcessMutex lock = new InterProcessMutex(client, "/orders/lock");
// 内部即临时顺序节点方案:创建 /orders/lock/_c_00000001,
// 序号最小者获锁,否则监听前一个节点的删除
if (lock.acquire(5, TimeUnit.SECONDS)) {
try {
// 临界区
} finally {
lock.release(); // 删除自己的顺序节点,唤醒后继
}
}
// 会话过期时,临时顺序节点由 ZK 自动删除,无需客户端清理// etcd lease 绑定 key(go 客户端 v3,最小示意)
resp, _ := client.Grant(ctx, 30) // 申请 30s lease
client.Put(ctx, "/orders/leader", "node-1", clientv3.WithLease(resp.ID)) // key 绑定 lease
ch, _ := client.KeepAlive(ctx, resp.ID) // 定期续约;失联后 lease 过期,key 自动删除边界清单
-
写入受多数派确认限制。 共识协议每条写要多数派确认,3 节点集群每次写至少 2 个节点落盘。写入吞吐受 RTT 和落盘延迟限制,远低于 Redis 或内存数据库。将其作为高频写入存储会超出协调服务的适用负载范围。
-
不适合存大对象。 ZK 单个 znode 默认 1MB 上限,etcd 默认 1.5MB(3.3)。协调服务存储的是元数据:谁是主、谁在线、配置版本号。业务数据、消息内容和大配置文件不应写入其中,应只保存指针类信息。
-
不适合高频读写。 每次写要经过共识,每次读如果走 leader 或
sync也会增加开销。ZK 默认读 follower 的本地数据,可能读到旧值;强一致读需要sync,性能会下降。高频读取且要求强一致的场景通常需要在客户端做本地缓存。 -
客户端必须处理会话过期后的状态重建。 网络抖动、长 GC、ZK 侧维护都可能导致会话过期。客户端收到
Expired后,之前创建的临时节点全部消失、watcher 全部失效,必须重新创建、重新注册、重新确认角色。否则,进程可能仍在执行临界区,却已不持有锁。 -
集群通常使用 3 或 5 个节点。 增加更多节点不会增加容错能力,只会增加写延迟。这是共识协议多数派机制的限制。扩缩容需要动态重配置,ZK 3.5 支持动态成员变更但操作复杂,多数集群长期保持固定规模。
-
一致性与可用性的要求取决于场景。 协调服务是 CP(强一致、牺牲可用性):少数派分区不可写。注册中心这类场景对可用性更敏感,ZK 不是唯一选择。选主、元数据这类对一致性敏感的场景适合使用 ZK/etcd;服务发现这类对可用性敏感的场景,AP 方案成本更低。
中间件与 ZK/etcd 的协调关系见下图。
参考资料
- ZooKeeper 官方文档(znode 数据模型、临时节点与顺序节点、watcher 一次性触发、会话与 session timeout,3.5 版本)
- Junqueira, Flavio P., and Benjamin Reed. ZooKeeper: Distributed Process Coordination. O’Reilly, 2013(ZAB 协议:proposal-ack-commit、crash recovery、leader 选举)
- Ongaro, Diego, and John Ousterhout. “In Search of an Understandable Consensus Algorithm.” USENIX ATC 2014(Raft:leader election、log replication、safety;2013 年技术报告草案不引为正式发表)
- etcd 官方文档(Raft、lease、watch、revision,3.3 版本;v2 API 废弃)
- Redis(下):超出缓存用途的用法:锁、队列与排行榜(Redis 锁与 RedLock 争论、fencing token,本篇对照 ZK/etcd 锁方案)
- 唯一 ID 中间件 Leaf:号段模式与雪花模式(ZK 持久顺序节点分配 workerId,本篇展开 ZK 侧机制)
- 分布式任务调度:同一时刻只跑一份(ElasticJob 用 ZK 选主与分片,本篇展开 ZK 协调层)
