ZooKeeper 内核:从 ZAB 协议到分布式协调实践
一个在线广告系统有 200 台服务器,每台跑着同一份竞价引擎。某天运维需要把竞价超时从 80ms 改成 120ms。方案一:SSH 逐台改配置,重启服务——两小时搞完,中间一半机器跑旧参数,另一半跑新参数,流量分配直接乱套。方案二:推 Ansible Playbook——快一些,但依然存在中间状态。方案三:把这个参数写到某个所有节点都在监听的地方,改一次,200 台机器在几百毫秒内全部拿到新值。
方案三需要一个服务来充当这个”某个地方”。这个服务的核心要求不多但全部很硬:数据强一致——不能一台读到旧值另一台读到新值;高可用——配置中心挂了等于全系统挂了;低延迟——不能让竞价引擎等配置等太久。ZooKeeper 就是这类需求最早的工业级回答之一。
ZooKeeper 诞生于 Yahoo! Research,2008 年进入 Apache 孵化器,2010 年成为顶级项目。Hunt 等人在 2010 年的 USENIX ATC 论文中给了它一个精准的定位:Wait-free coordination for Internet-scale systems。注意用词——不是数据库,不是消息队列,不是通用存储,而是协调服务(Coordination Service)。它解决的问题域是:分布式锁、Leader 选举(Leader Election)、配置管理(Configuration Management)、服务发现(Service Discovery)、屏障(Barrier)、队列(Queue)。这些问题的共同特征是数据量极小但一致性要求极高。
ZooKeeper 的内核设计可以拆成四个层面来理解:底层的共识协议 ZAB(ZooKeeper Atomic Broadcast),数据模型 ZNode,通知机制 Watch,以及会话管理(Session)。这四个层面叠在一起,构成了上层各种分布式协调模式的基础。本文将依次拆解每一层,然后讨论典型用法和已知问题,最后梳理 ZooKeeper 在 Kafka、HBase、Hadoop 等系统中扮演的角色。
一、ZAB 协议(与 Raft/Paxos 异同)
1.1 为什么不直接用 Paxos
ZooKeeper 最早的原型确实考虑过 Paxos。但 Paxos 解决的是对单个值达成共识的问题(Single-Decree Paxos),要管理一系列有序的状态变更需要 Multi-Paxos,而 Multi-Paxos 从来没有一份完整的、公认的协议规范。更关键的问题是,ZooKeeper 需要的不仅仅是共识,而是全序广播(Total Order Broadcast):所有写操作必须按照同一个全局顺序被所有节点应用。Paxos 本身不保证不同提案之间的顺序关系,需要在上面额外搭建一层日志管理逻辑。
Junqueira、Reed 和 Serafini 在 2011 年的 DSN 论文中正式描述了 ZAB 协议,明确了它的设计目标:为主备复制系统(Primary-Backup System)提供高性能的全序广播。ZAB 不是通用共识协议,而是专门为”一个 Leader 处理所有写请求,然后把状态变更广播给 Follower”这种架构模式设计的。
1.2 ZAB 的三阶段流程
ZAB 协议包含两个核心模式:崩溃恢复(Crash Recovery)和消息广播(Message Broadcast)。崩溃恢复又可以细分为 Leader 选举、发现(Discovery)和同步(Synchronization)三个阶段。正常运行时处于消息广播模式。整个流程如下:
Phase 0:Leader 选举
集群启动或 Leader 崩溃时,所有节点进入选举状态。ZooKeeper 3.x 默认使用快速 Leader 选举算法(Fast Leader Election,FLE)。每个节点广播自己的投票,投票内容是 (myZxid, mySid)——自己最后处理的事务 ID 和自己的服务器 ID。选举规则很简单:先比较 zxid(事务 ID),谁的 zxid 更大谁胜出——因为 zxid 更大意味着数据更新;zxid 相同则比较 sid(服务器 ID),sid 更大的胜出。当某个节点收到超过半数节点的认可时,它成为准 Leader(Prospective Leader)。
Phase 1:发现(Discovery)
准 Leader 向所有 Follower 发送 CEPOCH 消息,携带自己的轮次号(epoch)。每个 Follower 回复 ACKEPOCH,携带自己接受过的最后一个轮次号和自己的事务历史。准 Leader 收集到多数派的 ACKEPOCH 后,确定新的轮次号 e’ = max(所有收到的 epoch) + 1。这个新 epoch 保证了旧 Leader 的任何残留提案不会被误认为当前 Leader 的提案。
Phase 2:同步(Synchronization)
准 Leader 根据收集到的事务历史,确定一个权威的事务序列(通常是拥有最大 zxid 的那个 Follower 的历史)。然后准 Leader 向所有 Follower 发送 NEWLEADER 消息,携带新 epoch 和需要同步的事务序列。每个 Follower 接收到后,应用自己缺失的事务,截断自己多余的事务(如果有的话),然后发送 ACK。当准 Leader 收到多数派的 ACK 后,发送 COMMIT 消息,正式成为 Leader。集群从崩溃恢复模式进入消息广播模式。
Phase 3:广播(Broadcast)
进入稳态后,所有写请求都由 Leader 处理。具体流程是一个经典的两阶段提交变体:
- Leader 为每个写请求分配一个新的 zxid,生成一个事务提案(Proposal)。
- Leader 将提案发送给所有 Follower。
- 每个 Follower 将提案写入本地事务日志(Transaction Log),然后回复 ACK。
- Leader 收到多数派的 ACK 后,发送 COMMIT 消息。
- Follower 收到 COMMIT 后,将事务应用到内存数据树(DataTree)。
这里有一个关键设计:Leader 保证按 zxid 顺序发送提案,Follower 也按接收顺序处理提案。这就保证了全序性——所有节点看到的事务序列完全一致。
下图展示了 ZAB 协议的完整流程(参见
zab-protocol.svg):
1.2a Leader 崩溃恢复全流程演练
理解 ZAB 的四个阶段最好的方式是走一遍完整的崩溃恢复时间线。假设一个三节点集群(S1、S2、S3),其中 S1 是当前 Leader,配置为 tickTime=2s、initLimit=5、syncLimit=2。
第一步:Leader 崩溃检测(约 4 秒)。 S1 进程崩溃,S2 和 S3 不再收到来自 S1 的心跳 ping。Follower 的超时阈值为 syncLimit * tickTime = 2 * 2s = 4s。在 4 秒左右,S2 和 S3 判定 Leader 不可达,将自身状态从 FOLLOWING 切换为 LOOKING,集群进入选举阶段。
第二步:快速选举(约 2-6 秒)。 S2 和 S3 各自广播投票 (myZxid, mySid)。假设 S3 的 zxid 更大,S2 收到 S3 的投票后发现 S3 的 zxid 优于自己,立即更新投票为 S3 并重新广播。S3 收到多数派确认(自己 + S2 = 2/3),成为准 Leader。在网络正常的情况下,FLE 通常在 2-3 轮消息交换内收敛,耗时约 200ms 到数秒不等。
第三步:发现(Discovery,约 1-2 秒)。 准 Leader S3 向 S2 发送 CEPOCH,S2 回复 ACKEPOCH,携带自己的事务历史。S3 确定新 epoch = 旧 epoch + 1。
第四步:同步(Synchronization,约 2-4 秒)。 S3 将权威事务序列发送给 S2。S2 应用缺失的事务、截断多余的事务,然后回复 ACK。S3 收到多数派 ACK 后发送 COMMIT,正式成为 Leader。同步阶段的超时上限为 initLimit * tickTime = 5 * 2s = 10s。
第五步:恢复服务。 S3 进入 Broadcast 模式,开始处理客户端的读写请求。整个崩溃恢复过程在典型配置下约 8-16 秒,期间集群不可用。
下面的状态图概括了上述流程中节点状态的流转:
stateDiagram-v2
[*] --> Looking : 启动或Leader崩溃
Looking --> Election : 发起投票
Election --> Discovery : 选出准Leader
Discovery --> Synchronization : 确定新epoch
Synchronization --> Broadcast : 多数派同步完成
Broadcast --> Looking : Leader崩溃或网络分区
Broadcast --> Broadcast : 正常处理读写请求
该状态图描述了 ZAB 协议中节点在崩溃恢复与正常广播之间的完整状态迁移路径。节点初始进入 Looking 状态后,依次经历 Election、Discovery、Synchronization 三个阶段才能最终进入 Broadcast 稳态。值得注意的是,Broadcast 状态下如果再次发生 Leader 崩溃或网络分区,节点会重新回到 Looking 状态,再次走完整个恢复流程。
1.3 ZXID 的结构
ZXID(ZooKeeper Transaction ID)是一个 64 位整数,分为两部分:
| 位段 | 名称 | 含义 |
|---|---|---|
| 高 32 位 | epoch | Leader 的轮次号,每次新 Leader 上任加 1 |
| 低 32 位 | counter | 当前 epoch 内的事务计数器,从 0 开始递增 |
这个设计的精妙之处在于:通过简单的 64 位整数比较就能同时判断两件事——哪个 Leader 产生的事务(epoch 部分),以及同一个 Leader 内的事务先后关系(counter 部分)。新 Leader 上任后 counter 归零、epoch 加一,保证新 Leader 的第一个事务 ID 一定大于旧 Leader 的最后一个事务 ID。
在代码中,ZXID 的构造和解析如下:
// ZooKeeper 源码中 ZXID 的操作
public class ZxidUtils {
// 从 epoch 和 counter 构造 ZXID
static public long makeZxid(long epoch, long counter) {
return (epoch << 32L) | (counter & 0xffffffffL);
}
// 从 ZXID 提取 epoch
static public long getEpochFromZxid(long zxid) {
return zxid >> 32L;
}
// 从 ZXID 提取 counter
static public long getCounterFromZxid(long zxid) {
return zxid & 0xffffffffL;
}
}1.3a ZXID 时间线演示
下面的时序图展示了 ZXID 在 Leader 切换前后的变化过程。重点观察 epoch 部分在新 Leader 上任时加一、counter 部分归零的行为:
sequenceDiagram
participant L1 as Leader-1 (epoch=1)
participant F as Follower
participant L2 as Leader-2 (epoch=2)
L1->>F: Proposal zxid=0x0001_0001
F-->>L1: ACK
L1->>F: Proposal zxid=0x0001_0002
F-->>L1: ACK
Note over L1: Leader-1 崩溃
Note over F: 检测到超时,进入选举
Note over L2: 当选新Leader,epoch+1
L2->>F: Proposal zxid=0x0002_0001
F-->>L2: ACK
L2->>F: Proposal zxid=0x0002_0002
F-->>L2: ACK
该时序图清晰地展示了 ZXID 的两段式结构在实际运行中的表现。Leader-1 在 epoch=1 期间发出的事务 counter 从 1 递增到 2;当 Leader-1 崩溃、Leader-2 当选后,epoch 增加为 2,counter 重新从 1 开始。这种设计保证了新 Leader 发出的任何 ZXID 都严格大于旧 Leader 的所有 ZXID,从而在日志比较和冲突截断时提供了简洁可靠的判定依据。
1.4 ZAB vs Raft:核心差异
ZAB 和 Raft 都是 Leader-based 的共识协议,核心思路高度相似。下表列出关键差异:
| 维度 | ZAB | Raft |
|---|---|---|
| 设计目标 | 主备系统的全序广播 | 通用的可理解共识 |
| 事务标识 | zxid = epoch (32 bit) + counter (32 bit) | term + index(都是 64 bit) |
| Leader 选举依据 | zxid 最大者优先 | 日志最新者优先(term 最大,index 最大) |
| 日志冲突解决 | 同步阶段截断多余事务 | Leader 强制覆盖 Follower 的冲突日志 |
| 读请求处理 | 默认可读 Follower(非线性一致) | 严格走 Leader(线性一致),或 ReadIndex |
| 成员变更 | 不在协议规范内 | Joint Consensus 或 Single-Server Change |
两者最关键的差异有两点。第一,读一致性语义不同:Raft
明确定义了线性一致性读的实现路径(ReadIndex /
LeaseRead),而 ZooKeeper 默认允许 Follower
直接响应读请求,可能返回过时数据,需要客户端显式调用
sync
才能获得线性一致读。第二,选举收敛机制不同:Raft
使用随机化超时避免分票,ZAB 的 FLE 算法依赖”投票
PK”逐轮收敛,在节点数较多时可能需要更多轮消息交换。
1.5 ZAB vs Paxos:本质区别
ZAB 与 Basic Paxos 的根本差异在于架构假设:Paxos 是去中心化的,任何节点都可以发起提案;ZAB 从设计之初就假定单一 Leader 处理所有写操作。这一假设带来以下具体差异:
- 通信轮次:Paxos 每次提案需要 Prepare-Accept 两轮通信,ZAB 广播阶段只需 Propose-ACK 一轮,因为 Leader 权威已在发现阶段确立。
- 全序性:Paxos 不内建全序保证,需上层维护日志序号;ZAB 的全序性是协议固有属性。
- 活锁风险:Paxos 允许多个 Proposer 并发提案,可能互相抢占导致活锁;ZAB 由单一 Leader 发起提案,不存在此问题。
- 崩溃恢复:Paxos 的恢复逻辑由实现者自行设计,ZAB 将崩溃恢复作为协议的内建部分。
需要澄清一个常见误解:“ZAB 是 Paxos 的变种”。更准确的表述是:ZAB 是专门为主备复制场景设计的独立协议,其正确性证明不依赖 Paxos。Junqueira 等人在 DSN 论文中独立证明了 ZAB 的安全性和活性属性。
1.6 ZAB 的一致性保证
ZAB 协议提供两个核心保证:
- 一致性保证(Agreement):如果一个事务被提交(Committed),那么最终所有正确的节点都会提交该事务。
- 全序保证(Total Order):如果事务 A 在事务 B 之前被提交,那么在所有节点上 A 都在 B 之前被应用。
此外,ZAB 还保证了因果序(Causal Order):如果事务 A 因果地先于事务 B(例如 A 的结果被 B 读到),那么 A 一定在 B 之前被提交。这比 Raft 的保证更强一些——Raft 论文只明确保证了全序,因果序需要上层应用自己维护。
二、数据模型(ZNode/临时节点/顺序节点)
2.1 层次命名空间
ZooKeeper 的数据模型是一棵层次化的树,类似文件系统。树中的每个节点叫做 ZNode。与文件系统的区别在于:每个 ZNode 既可以存储数据,也可以有子节点——相当于每个”目录”同时也是一个”文件”。
路径使用 / 分隔,根节点是
/。典型的路径结构如下:
/
├── /app
│ ├── /app/config
│ │ ├── /app/config/db_url
│ │ └── /app/config/timeout
│ ├── /app/leader
│ └── /app/workers
│ ├── /app/workers/worker-0000000001
│ └── /app/workers/worker-0000000002
└── /zookeeper
└── /zookeeper/quota
2.2 ZNode 类型
ZNode 有多种类型,每种类型的生命周期和行为不同:
| 类型 | 英文 | 版本 | 生命周期 | 特性 |
|---|---|---|---|---|
| 持久节点 | Persistent | 1.0+ | 显式删除才消失 | 默认类型 |
| 临时节点(Ephemeral Node) | Ephemeral | 1.0+ | 创建它的会话结束时自动删除 | 不能有子节点 |
| 持久顺序节点 | Persistent Sequential | 1.0+ | 显式删除 | 节点名后自动追加递增序号 |
| 临时顺序节点(Ephemeral Sequential Node) | Ephemeral Sequential | 1.0+ | 会话结束时自动删除 | 临时 + 自动追加序号 |
| 容器节点(Container Node) | Container | 3.6+ | 子节点全部删除后自动清理 | 用于 Leader 选举、分布式锁等场景 |
| TTL 节点 | TTL | 3.6+ | 超过 TTL 且无子节点时自动删除 | 需要显式启用 |
临时节点是 ZooKeeper 最有价值的特性之一。当一个服务实例注册自己到 ZooKeeper 时,它创建一个临时节点。如果这个服务实例崩溃或网络不可达,它和 ZooKeeper 之间的会话最终会超时,临时节点被自动删除。其他服务通过 Watch 这个节点,可以感知到该服务实例的离线。这就是 ZooKeeper 实现服务发现和故障检测的基础机制。
顺序节点(Sequential Node)的行为是:创建时,ZooKeeper
在你指定的路径名后面追加一个 10
位的递增序号。例如,你请求创建
/app/lock-,ZooKeeper 实际创建的可能是
/app/lock-0000000001。多个客户端同时创建顺序节点时,ZooKeeper
保证序号不重复且递增。这个机制是实现分布式锁和分布式队列的关键。
2.3 数据大小限制
每个 ZNode 默认最大存储 1MB 数据。这不是一个可以轻易调大的参数——ZooKeeper 的设计假设是存储小量的协调数据(配置项、锁标记、服务地址),而不是业务数据。整棵数据树都在内存中,每个 ZNode 的数据越大,内存消耗越高,快照写盘越慢。在实践中,大多数 ZNode 存储的数据在几百字节到几 KB 之间。
2.4 Stat 结构
每个 ZNode 都关联一个 Stat 结构,记录了详细的元数据:
czxid = 0x100000003 // 创建该节点的事务 ZXID
ctime = 1702000000000 // 创建时间(毫秒时间戳)
mzxid = 0x100000005 // 最后修改数据的事务 ZXID
mtime = 1702000100000 // 最后修改时间
pzxid = 0x100000007 // 最后修改子节点列表的事务 ZXID
cversion = 2 // 子节点列表变更次数
dataVersion = 1 // 数据变更次数
aclVersion = 0 // ACL 变更次数
ephemeralOwner = 0x0 // 临时节点的会话 ID(持久节点为 0)
dataLength = 128 // 数据长度(字节)
numChildren = 3 // 子节点数量
Stat 中的 dataVersion
字段特别重要——ZooKeeper 支持
CAS(Compare-And-Swap)操作:setData(path, data, version)
只有在当前版本号等于指定 version
时才会成功。这是实现乐观锁的基础。
2.5 ACL 模型
ZooKeeper 的访问控制基于 ACL(Access Control List)。每个 ZNode 可以独立设置 ACL,但 ACL 不继承——子节点的 ACL 和父节点无关。
ACL 由三部分组成:scheme:id:permissions。
| Scheme | 说明 |
|---|---|
| world | 所有人,id 固定为 anyone |
| auth | 已认证用户 |
| digest | 用户名:密码(SHA-1 加密) |
| ip | 客户端 IP 地址 |
| sasl | Kerberos 认证 |
权限是五个标志的组合:
| 权限 | 缩写 | 含义 |
|---|---|---|
| CREATE | c | 创建子节点 |
| READ | r | 读取数据和子节点列表 |
| WRITE | w | 修改数据 |
| DELETE | d | 删除子节点 |
| ADMIN | a | 修改 ACL |
示例——为 /app/config 设置 ACL,只允许 digest
认证的 admin 用户完全控制:
# 使用 zkCli 设置 ACL
addauth digest admin:password123
setAcl /app/config digest:admin:password123:crwda在生产环境中,ACL 配置往往和 Kerberos(sasl scheme)结合使用,特别是在 Hadoop 生态中。
三、Watch 机制(一次性通知)
3.1 核心语义
Watch 是 ZooKeeper 最具特色的机制之一。客户端可以在
getData、getChildren、exists
等读操作上注册一个 Watch。当被监视的 ZNode
发生变化时,ZooKeeper
服务器会向客户端推送一个通知事件(WatchEvent)。
Watch 的核心语义是一次性触发(One-Time Trigger):一个 Watch 被触发一次之后就失效了。客户端如果想继续监视,必须在处理完事件后重新注册 Watch。这个设计选择有明确的工程原因——如果 Watch 是持久的,服务器需要维护大量的 Watch 状态,并且在每次数据变更时遍历所有注册的 Watch。一次性 Watch 把状态管理的复杂度推给了客户端。
3.2 Watch 类型
Watch 分为两类:
- 数据 Watch(Data Watch):通过
getData或exists注册。当节点的数据被修改或节点被删除时触发。 - 子节点 Watch(Child Watch):通过
getChildren注册。当节点的子节点列表发生变化时触发。
一个容易混淆的细节:exists 注册的 Watch
可以捕获节点的创建事件(NodeCreated),因为
exists
可以在节点不存在时调用。getData
只能在节点存在时调用,所以它注册的 Watch 不会收到
NodeCreated 事件。
3.3 Watch 事件类型
| 事件类型 | 英文 | 触发条件 | 触发的 Watch 类型 |
|---|---|---|---|
| 节点创建 | NodeCreated | 节点从不存在变为存在 | exists Watch |
| 节点删除 | NodeDeleted | 节点被删除 | getData Watch / exists Watch |
| 数据变更 | NodeDataChanged | 节点数据被修改 | getData Watch / exists Watch |
| 子节点变更 | NodeChildrenChanged | 子节点列表变化(增删) | getChildren Watch |
3.4 投递保证
ZooKeeper 对 Watch 事件有两个重要保证:
- 有序性:客户端在看到 Watch
事件之前,一定已经看到了触发该事件的数据变更。换句话说,如果一个
setData操作触发了 Watch,那么客户端在收到 Watch 事件后去getData,一定能读到至少是这次 setData 写入的值(或更新的值)。 - 一次性:每个 Watch 只触发一次。
但 ZooKeeper 不保证你能观察到每一次变更。假设在 Watch 被触发和客户端重新注册 Watch 之间,又发生了三次数据变更,客户端只会看到重新注册 Watch 时的最新值,中间的两次变更丢失了。这是 Watch 机制最根本的局限性。
3.4a Watch 边界条件深度分析
Watch 机制的表面语义——“注册一次、触发一次、需要重新注册”——看似简单,但在生产环境中有若干边界条件需要深入理解。
一次性触发的内部机制。 当客户端调用
getData(path, watch=true) 时,服务器端将 (path,
clientConnection) 对插入 dataWatches
哈希表。当该路径上的写事务被提交并应用到 DataTree
时,processTxn 方法会从
dataWatches
中移除该路径对应的所有 Watcher,生成
WatchEvent,然后通过对应的 ClientConnection
发送给客户端。移除操作是原子的——Watch
触发后立即失效,不会重复触发。
投递顺序保证。 ZooKeeper
保证客户端在收到 WatchEvent
之前,不会看到触发该事件的新数据值。具体实现方式是:服务器先通过
ClientConnection 发送 WatchEvent
通知,客户端的事件线程先处理该通知,然后才会处理后续的读响应。这意味着如果客户端在收到
NodeDataChanged 事件后立即调用
getData,返回的值一定是触发事件的那次变更或更新的值。
重连间隙问题。 如果客户端在 Watch 触发后、重新注册 Watch 之前发生了网络断连并重连,客户端会丢失断连期间的所有变更通知。更微妙的情况是:Watch 在服务器端已经触发并发出通知,但通知在网络传输中丢失(TCP 连接断开),客户端重连后并不知道 Watch 已经触发过。ZooKeeper 3.6 之前,客户端需要在重连后主动重新读取数据并重新注册 Watch 来弥补这个间隙。3.6 引入的持久 Watch 缓解了重新注册的负担,但仍不保证捕获每一次中间状态变更。
惊群效应缓解。 当 N 个客户端同时 Watch 同一个路径时,该路径的一次变更会触发 N 个 WatchEvent 的发送,产生 O(N) 的通知开销。在分布式锁场景中,经典的缓解策略是让每个客户端只 Watch 自己的前驱节点(predecessor),而不是锁的根路径。这样每次锁释放只触发一个 WatchEvent(通知下一个等待者),将通知开销从 O(N) 降低到 O(1)。
下面的时序图展示了一次完整的 Watch 注册、触发、重新注册的生命周期:
sequenceDiagram
participant C1 as 客户端A
participant ZK as ZooKeeper
participant C2 as 客户端B
C1->>ZK: getData(/config, watch=true)
ZK-->>C1: 返回 value="v1"
C2->>ZK: setData(/config, "v2")
ZK->>C1: WatchEvent(NodeDataChanged)
Note over C1: 处理事件,准备重新注册
C1->>ZK: getData(/config, watch=true)
ZK-->>C1: 返回 value="v2"(重新注册Watch)
该时序图展示了 Watch 的典型使用模式:客户端A 注册 Watch
后收到当前值 v1,当客户端B 修改数据为 v2 时,ZooKeeper
向客户端A 发送变更事件。客户端A 处理事件后需要主动重新调用
getData 并设置 watch=true 来完成
Watch
的重新注册。注意在事件处理和重新注册之间存在一个时间窗口,此窗口内发生的变更将不会被通知。
3.5 一次性 Watch 的间隙问题
下面的时序图说明了这个间隙:
时间 客户端 ZooKeeper 服务器
─────────────────────────────────────────────────────
t1 getData(/config, watch=true)
t2 /config 数据 = "v1"
t3 /config 被修改为 "v2"
t4 收到 Watch 事件: NodeDataChanged
t5 (客户端处理事件中……)
t6 /config 被修改为 "v3"
t7 /config 被修改为 "v4"
t8 getData(/config, watch=true) <-- 重新注册
t9 /config 数据 = "v4"
(客户端错过了 v3 的变更)
对于配置管理这类场景,错过中间值通常不是问题——客户端只关心最新值。但如果你试图用 Watch 来实现事件队列或变更日志,这个间隙就是致命缺陷。
3.6 持久 Watch 和递归 Watch(3.6+)
ZooKeeper 3.6 引入了两种新的 Watch 类型来缓解一次性 Watch 的局限:
- 持久 Watch(Persistent Watch):触发后不失效,直到客户端显式移除。
- 递归持久 Watch(Persistent Recursive Watch):不仅持久,而且自动监视指定路径下所有子树的变化。
// 使用 ZooKeeper 3.6+ 的持久递归 Watch
zk.addWatch("/app/config",
event -> {
System.out.println("变更事件: " + event.getType()
+ " 路径: " + event.getPath());
},
AddWatchMode.PERSISTENT_RECURSIVE);持久 Watch 解决了”间隙问题”中”需要重新注册”的部分,但仍然不保证你能看到每一次中间状态变更——如果两次变更发生在同一个服务器端的通知批次内,客户端可能只收到一个事件。
3.7 Watch 的服务器端实现
服务器端,Watch
存储在两个哈希表中:dataWatches(路径 ->
客户端连接集合)和 childWatches(路径 ->
客户端连接集合)。当一个写事务被提交并应用到 DataTree
时,DataTree 的 processTxn
方法会检查被修改的路径是否有注册的 Watch,如果有就生成
WatchEvent 并发送给对应的客户端连接。
Watch 的内存开销不大——每个 Watch 只存储了 (path, connection) 的映射,没有回调逻辑。但当大量客户端 Watch 同一个路径时,触发 Watch 的开销会变得显著:服务器需要遍历该路径的所有 Watcher 并逐一发送事件。这就是惊群效应(Herd Effect)的根源,后文会详细讨论。
四、会话管理
4.1 会话生命周期
ZooKeeper 客户端和服务器之间通过 TCP 长连接(Session)进行通信。会话有四个状态:
CONNECTING ──(连接成功)──> CONNECTED
↑ |
| (连接断开)
| ↓
└───(重连成功)───── RECONNECTING ──(超时)──> EXPIRED
- CONNECTING:客户端正在尝试连接到 ZooKeeper 集群中的某个服务器。
- CONNECTED:连接建立成功,会话激活。
- RECONNECTING(也称 NOT_CONNECTED):连接断开,客户端正在尝试重连到集群中其他服务器。
- EXPIRED:会话超时,所有临时节点被删除,所有 Watch 失效。客户端必须创建新的会话。
一个关键点:连接断开不等于会话过期。只要客户端在会话超时时间(Session Timeout)内重新连接到集群中的任意一台服务器,会话就能延续。临时节点不会被删除,Watch 继续有效。这是因为会话状态是在集群级别(而不是单个服务器级别)维护的——任何一台服务器都可以接管已存在的会话。
4.2 会话超时协商
客户端在创建会话时指定一个期望的超时时间(Session
Timeout)。服务器不一定原样接受这个值——它会把客户端请求的超时时间限制在
[minSessionTimeout, maxSessionTimeout]
范围内。默认配置中,minSessionTimeout 是 2 倍 tickTime(通常
4 秒),maxSessionTimeout 是 20 倍 tickTime(通常 40
秒)。
// 创建会话时指定超时时间为 30 秒
ZooKeeper zk = new ZooKeeper(
"host1:2181,host2:2181,host3:2181",
30000, // 期望的会话超时时间(毫秒)
watchedEvent -> {
// 处理会话事件
if (watchedEvent.getState() == Event.KeeperState.SyncConnected) {
System.out.println("会话已建立");
} else if (watchedEvent.getState() == Event.KeeperState.Expired) {
System.out.println("会话已过期");
}
}
);
// 获取协商后的实际超时时间
int negotiatedTimeout = zk.getSessionTimeout();4.3 会话 ID 和密码
每个会话有一个全局唯一的 64 位会话 ID(Session ID)和一个 16 字节的密码(Session Password)。会话 ID 由 Leader 分配,保证在整个集群生命周期内唯一。密码用于会话重连时的身份验证——防止恶意客户端冒充已有会话。
会话 ID 的高位编码了分配该会话的 Leader 的 sid,低位是一个递增计数器。这使得从会话 ID 就能判断它是哪个 Leader 分配的。
4.4 会话迁移
当客户端连接的服务器宕机时,客户端会自动尝试连接集群中的其他服务器。连接建立后,客户端发送自己的会话 ID 和密码。新服务器将这个会话迁移到本地——验证密码,恢复该会话的 Watch 注册(Watch 在重连后需要客户端重新注册,但 ZooKeeper 客户端库自动完成了这个操作),更新会话的超时计时器。
这个过程对上层应用是透明的——如果重连在超时时间内完成,应用代码看到的就是短暂的延迟,所有操作可以正常继续。
4.5 心跳机制
客户端通过定期发送 PING 请求来保持会话活跃。PING 的频率约为会话超时时间的 1/3——例如会话超时是 30 秒,客户端大约每 10 秒发一次 PING。如果有其他请求在这段时间内发送过,PING 可以跳过,因为任何请求都会重置服务器端的会话超时计时器。
服务器端的会话过期检测使用了分桶(Bucketing)机制:将会话按过期时间分到不同的桶中,每个 tickTime 间隔(默认 2 秒)检查一次最近到期的桶。这意味着会话过期的检测粒度是 tickTime,实际过期时间可能比配置的超时时间多出一个 tickTime。
4.6 会话过期检测延迟问题
会话过期检测存在一个不可避免的延迟:从服务实例真正崩溃到 ZooKeeper 检测到会话过期、删除临时节点、触发 Watch,中间可能经过一整个会话超时周期(通常 10-40 秒)。这意味着在这段时间内,系统中存在”幽灵节点”——临时节点还在,但对应的服务实例已经不存在了。
这个延迟在某些场景下会导致严重问题。例如在分布式锁的场景中:持有锁的进程已经崩溃,但锁对应的临时节点还没过期,其他进程无法获取锁。在需要更快检测故障的场景中,通常需要在 ZooKeeper 之上叠加应用层的心跳机制。
另一个更微妙的问题是会话过期后的脑裂窗口。考虑这样一个场景:客户端 A 持有一个临时节点作为分布式锁。由于 GC 停顿,A 和 ZooKeeper 的会话超时,ZooKeeper 删除了临时节点,客户端 B 随后获得了锁。但 A 从 GC 停顿中恢复后,它并不立即知道自己的会话已经过期——它可能继续执行被锁保护的临界区代码。此时 A 和 B 同时在临界区中,分布式锁失效。
这个问题的解决方案是使用防护令牌(Fencing Token):客户端获取锁时同时获取一个单调递增的令牌(可以用 ZNode 的 czxid 或 version),在访问共享资源时携带这个令牌。共享资源(例如数据库)拒绝来自旧令牌的请求。Martin Kleppmann 在《Designing Data-Intensive Applications》中详细讨论了这个问题。
4.7 会话语义深度剖析
会话(Session)是 ZooKeeper 客户端与服务器之间所有交互的上下文容器。理解会话的深层语义,对于正确使用临时节点、分布式锁等高级功能至关重要。
临时节点与会话的绑定关系。 临时节点(Ephemeral Node)的生命周期绑定到创建它的会话,而非底层的 TCP 连接。当客户端与服务器之间的 TCP 连接断开时(例如网络抖动),只要客户端在会话超时时间内重新连接到集群中的任意一台服务器,会话仍然有效,所有临时节点继续存在。这是因为会话状态维护在 Leader 上并通过 ZAB 协议复制到所有节点——会话是集群级别的概念,不是单机级别的。只有当会话真正过期(服务器端在超时时间内未收到任何来自该会话的消息)时,该会话创建的所有临时节点才会被删除。
会话超时与心跳间隔的关系。
客户端创建会话时可以指定一个期望的超时时间(sessionTimeout),但服务器会将其约束在
tickTime * 2 到 tickTime * 20
的范围内。以默认的 tickTime=2s 为例,sessionTimeout
的有效范围是 4s 到 40s。客户端的心跳(PING)发送间隔约为
sessionTimeout / 3——如果 sessionTimeout=30s,则大约每 10s
发送一次 PING。这个 1/3
的比例是一个经验值:给网络抖动和重试留出足够的缓冲,同时不至于在正常情况下浪费过多的会话过期检测时间。
网络分区下的会话行为。 当发生网络分区时,处于少数派分区的客户端无法与任何拥有多数派的服务器通信。此时客户端会不断尝试重连,但所有连接都会失败。与此同时,多数派分区中的服务器在 sessionTimeout 到期后会标记该会话为已过期,并删除该会话创建的所有临时节点、触发相应的 Watch 事件。然而,少数派分区中的客户端在网络恢复之前并不知道自己的会话已经过期——它的本地状态仍然是 CONNECTING,而非 EXPIRED。只有当网络恢复、客户端成功连接到服务器时,服务器才会通知客户端其会话已过期。在这个时间窗口内,客户端可能错误地认为自己仍然持有临时节点(例如分布式锁)。
“僵尸会话”问题。 上述网络分区场景的极端形式被称为”僵尸会话”(Zombie Session):服务器端已经判定会话过期并清理了所有关联资源(临时节点、Watch),但客户端尚未收到过期通知,仍然在执行业务逻辑。在分布式锁的场景中,这意味着旧的锁持有者可能在锁已经被其他客户端获取之后仍然执行被锁保护的临界区代码。这个问题无法仅靠 ZooKeeper 本身解决,必须结合防护令牌(Fencing Token)等应用层机制来保证安全性。核心思路是:任何受分布式锁保护的共享资源操作,都应该携带一个从 ZooKeeper 获取的单调递增令牌,共享资源端拒绝来自过期令牌的请求。
五、典型用法
5.1 分布式锁:使用临时顺序节点避免惊群效应
分布式锁是 ZooKeeper 最常见的使用场景。最简单的实现是让所有客户端竞争创建同一个临时节点——创建成功的获得锁,创建失败的等待。但这个方案有一个严重问题:当持有锁的客户端释放锁(删除节点),所有等待的客户端同时收到 Watch 事件,同时发起创建请求,只有一个成功,其余全部失败。这就是惊群效应(Herd Effect)——N 个客户端竞争,只有 1 个成功,但产生了 N 次请求。
正确的做法是使用临时顺序节点实现”公平锁”:
- 每个客户端在锁路径下创建一个临时顺序节点,例如
/locks/resource-1/lock-。 - 客户端获取
/locks/resource-1下所有子节点并排序。 - 如果自己创建的节点序号最小,获得锁。
- 否则,Watch 比自己序号小一位的那个节点(不是 Watch 所有节点!)。
- 当那个节点被删除时,重新检查自己是否是最小的。
这个方案把 O(N) 的惊群降低到了 O(1)——每次只有一个客户端被唤醒。
public class DistributedLock {
private final ZooKeeper zk;
private final String lockPath;
private String myNode;
public DistributedLock(ZooKeeper zk, String lockPath) {
this.zk = zk;
this.lockPath = lockPath;
}
public void lock() throws Exception {
// 创建临时顺序节点
myNode = zk.create(
lockPath + "/lock-",
new byte[0],
ZooDefs.Ids.OPEN_ACL_UNSAFE,
CreateMode.EPHEMERAL_SEQUENTIAL
);
while (true) {
// 获取所有子节点并排序
List<String> children = zk.getChildren(lockPath, false);
Collections.sort(children);
String myNodeName = myNode.substring(
myNode.lastIndexOf('/') + 1);
// 如果自己是最小的,获得锁
int myIndex = children.indexOf(myNodeName);
if (myIndex == 0) {
return; // 获得锁
}
// 否则 Watch 前一个节点
String prevNode = children.get(myIndex - 1);
CountDownLatch latch = new CountDownLatch(1);
Stat stat = zk.exists(
lockPath + "/" + prevNode,
event -> {
if (event.getType()
== Watcher.Event.EventType.NodeDeleted) {
latch.countDown();
}
}
);
if (stat == null) {
// 前一个节点已经被删除,重新检查
continue;
}
// 等待前一个节点被删除
latch.await();
}
}
public void unlock() throws Exception {
zk.delete(myNode, -1);
}
}Apache Curator
框架对这个模式做了更完善的封装(InterProcessMutex),处理了会话过期、重入、超时等边界情况。生产环境中建议使用
Curator 而不是手写锁逻辑。
5.2 Leader 选举
Leader 选举的模式和分布式锁几乎一样——本质上就是”谁先抢到谁当 Leader”。使用临时顺序节点的选举流程:
- 每个候选节点在选举路径下创建临时顺序节点。
- 序号最小的节点成为 Leader。
- 非 Leader 节点 Watch 比自己序号小一位的节点。
- 当 Leader 崩溃(临时节点被删除),序号次小的节点收到通知,检查自己是否成为最小的,如果是则接任 Leader。
// Go 语言的 Leader 选举示例(基于 go-zookeeper 库)
package election
import (
"fmt"
"path"
"sort"
"strings"
"github.com/go-zookeeper/zk"
)
type LeaderElection struct {
conn *zk.Conn
basePath string
myNode string
isLeader bool
}
func New(conn *zk.Conn, basePath string) *LeaderElection {
return &LeaderElection{conn: conn, basePath: basePath}
}
func (le *LeaderElection) Run() error {
// 确保选举目录存在
le.conn.Create(le.basePath, nil, 0, zk.WorldACL(zk.PermAll))
// 创建临时顺序节点
nodePath, err := le.conn.CreateProtectedEphemeralSequential(
le.basePath+"/candidate-", nil, zk.WorldACL(zk.PermAll))
if err != nil {
return fmt.Errorf("创建候选节点失败: %w", err)
}
le.myNode = path.Base(nodePath)
return le.checkLeadership()
}
func (le *LeaderElection) checkLeadership() error {
for {
children, _, err := le.conn.Children(le.basePath)
if err != nil {
return err
}
sort.Strings(children)
myIndex := -1
for i, child := range children {
if child == le.myNode {
myIndex = i
break
}
}
if myIndex < 0 {
return fmt.Errorf("自己的节点消失了")
}
if myIndex == 0 {
le.isLeader = true
fmt.Println("成为 Leader")
return nil
}
// Watch 前一个节点
prevNode := children[myIndex-1]
prevPath := le.basePath + "/" + prevNode
exists, _, ch, err := le.conn.ExistsW(prevPath)
if err != nil {
return err
}
if !exists {
continue // 前驱已删除,重新检查
}
fmt.Printf("Watch 前驱节点: %s\n", prevNode)
evt := <-ch
if evt.Type == zk.EventNodeDeleted {
continue // 前驱被删除,重新检查自己是否最小
}
}
}
func (le *LeaderElection) IsLeader() bool {
return le.isLeader
}注意 CreateProtectedEphemeralSequential
的使用——这个方法创建的节点名带有一个 GUID
前缀,即使在创建过程中客户端断开并重连,也能识别出自己之前创建的节点,避免重复创建。
5.3 配置管理
配置管理是最直观的场景:把配置数据存储在 ZNode 中,所有需要这份配置的服务实例 Watch 这个节点。配置变更时,管理员更新 ZNode 的数据,所有服务实例收到 Watch 事件后重新读取配置。
// 配置管理示例
public class ConfigManager {
private final ZooKeeper zk;
private final String configPath;
private volatile Properties currentConfig;
public ConfigManager(ZooKeeper zk, String configPath)
throws Exception {
this.zk = zk;
this.configPath = configPath;
loadConfig();
}
private void loadConfig() throws Exception {
byte[] data = zk.getData(configPath, event -> {
if (event.getType()
== Watcher.Event.EventType.NodeDataChanged) {
try {
loadConfig(); // 重新加载并注册 Watch
} catch (Exception e) {
e.printStackTrace();
}
}
}, null);
Properties props = new Properties();
props.load(new ByteArrayInputStream(data));
this.currentConfig = props;
System.out.println("配置已更新: " + props);
}
public String getConfig(String key) {
return currentConfig.getProperty(key);
}
}这里有一个典型的递归调用模式——loadConfig
方法在读取数据的同时注册一个 Watch,Watch 被触发后调用
loadConfig 自身。这保证了 Watch
的持续性。但要注意异常处理:如果 loadConfig 在
Watch 回调中抛出异常,Watch
链断裂,后续的配置变更将不再被感知。生产代码中需要用重试机制来保护这个调用链。
5.4 服务发现
服务发现结合了临时节点和 Watch 两个机制:
- 服务注册:服务实例启动时,在服务路径下创建一个临时节点,节点数据中存储自己的地址和端口。
- 服务发现:消费者用
getChildren获取服务路径下所有子节点,同时注册子节点 Watch。 - 故障检测:服务实例崩溃后会话过期,临时节点自动删除,消费者收到子节点变更通知。
// 服务注册
public class ServiceRegistry {
private final ZooKeeper zk;
private final String servicePath;
public ServiceRegistry(ZooKeeper zk, String serviceName) {
this.servicePath = "/services/" + serviceName;
this.zk = zk;
}
public void register(String host, int port) throws Exception {
// 确保父路径存在
ensurePath(servicePath);
// 创建临时顺序节点
String nodeData = host + ":" + port;
zk.create(
servicePath + "/instance-",
nodeData.getBytes(),
ZooDefs.Ids.OPEN_ACL_UNSAFE,
CreateMode.EPHEMERAL_SEQUENTIAL
);
}
private void ensurePath(String path) throws Exception {
if (zk.exists(path, false) == null) {
try {
zk.create(path, new byte[0],
ZooDefs.Ids.OPEN_ACL_UNSAFE,
CreateMode.PERSISTENT);
} catch (KeeperException.NodeExistsException e) {
// 其他客户端已经创建,忽略
}
}
}
}
// 服务发现
public class ServiceDiscovery {
private final ZooKeeper zk;
private final String servicePath;
private volatile List<String> instances = new ArrayList<>();
public ServiceDiscovery(ZooKeeper zk, String serviceName)
throws Exception {
this.zk = zk;
this.servicePath = "/services/" + serviceName;
refreshInstances();
}
private void refreshInstances() throws Exception {
List<String> children = zk.getChildren(servicePath,
event -> {
if (event.getType()
== Watcher.Event.EventType.NodeChildrenChanged) {
try {
refreshInstances();
} catch (Exception e) {
e.printStackTrace();
}
}
});
List<String> addrs = new ArrayList<>();
for (String child : children) {
byte[] data = zk.getData(
servicePath + "/" + child, false, null);
addrs.add(new String(data));
}
this.instances = addrs;
System.out.println("可用实例: " + addrs);
}
public List<String> getInstances() {
return new ArrayList<>(instances);
}
}六、已知问题
6.1 惊群效应(Herd Effect)
惊群效应是 ZooKeeper 最广为人知的性能陷阱。当大量客户端
Watch 同一个节点时,该节点的任何变更都会触发对所有 Watcher
的通知。如果有 1000 个客户端在 Watch
/locks/my-lock,当锁被释放时,服务器需要向 1000
个客户端发送 Watch
事件。这些客户端同时收到通知,同时发起请求,但只有一个能成功获取锁,其余
999 个的请求是浪费的。
这不仅浪费了网络带宽和服务器 CPU,更严重的是可能导致 ZooKeeper 服务器的请求队列被打满,影响集群中其他正常操作。
解决方案就是前面分布式锁一节中描述的”只
Watch
前一个节点”模式。这个模式确保每次只有一个客户端被唤醒,把惊群彻底消除。Apache
Curator 的 InterProcessMutex
默认使用这种实现。
6.2 会话过期的边界情况
会话过期最棘手的边界情况是”假死”:客户端经历了一次长时间 GC 停顿或网络分区,期间会话在服务器端已经过期,临时节点已被删除。但客户端从停顿中恢复后,它仍然持有旧的 ZooKeeper 连接对象,可能在收到 Expired 事件之前执行了几个操作。
ZooKeeper
客户端库在这种情况下的行为是:重连时发现会话已过期,抛出
SessionExpiredException,并向应用发送
KeeperState.Expired
事件。但从客户端进程恢复到收到 Expired
事件之间有一个时间窗口,应用代码可能在这个窗口内做出了错误的判断(例如认为自己仍然持有分布式锁)。
处理这种情况的最佳实践:
- 使用防护令牌(Fencing Token)——任何受分布式锁保护的操作都应该携带一个版本号或序列号。
- 监听
KeeperState.Expired和KeeperState.Disconnected事件,在这两种状态下暂停所有需要锁保护的操作。 - 使用 Curator 框架的
ConnectionStateListener,它提供了更完善的连接状态管理。
6.3 性能瓶颈
ZooKeeper 的性能瓶颈主要体现在以下几个方面:
写吞吐受限于 Leader 单点。所有写操作都必须经过 Leader,Leader 的磁盘 IO(事务日志持久化)和网络带宽成为集群写性能的上限。在一个典型的 3 节点集群中,ZooKeeper 的写吞吐约为 10,000-20,000 TPS(取决于硬件和数据大小)。增加节点数量不会提升写性能——相反,更多的 Follower 意味着每次写都需要等待更多 ACK,写延迟反而可能增加。
读吞吐随 Follower
数量线性扩展,但代价是一致性降级——Follower
上的读可能返回过时数据。如果需要线性一致读,必须使用
sync + getData,这会退化为走
Leader 的路径。
Watch 通知是同步阻塞的。在 ZooKeeper 3.x 中,Watch 通知在事务处理的关键路径上执行。如果一个节点有大量 Watch,触发这些 Watch 的开销会拖慢整个事务处理管线。
大量会话管理的开销。每个会话都需要周期性的心跳处理和超时检测。当客户端数量达到数万级别时,会话管理本身就成了显著的 CPU 开销。
6.4 网络分区与脑裂
ZooKeeper 使用多数派(Quorum)机制来容忍故障。一个 2N+1 节点的集群可以容忍 N 个节点故障。但在网络分区的场景下,少数派分区内的节点无法形成多数派,无法处理写请求,也无法选举新 Leader。
一个需要特别注意的场景是:Leader 和少数 Follower 在一个分区,多数 Follower 在另一个分区。多数派分区会选举出新 Leader 并继续服务。旧 Leader 所在的少数派分区中,旧 Leader 在一段时间内可能仍然认为自己是 Leader(因为它还没有意识到自己被隔离了)。在这段时间内:
- 旧 Leader 可以接收写请求,但无法提交(因为得不到多数派 ACK)。客户端会等待直到超时。
- 旧 Leader 可以响应读请求(如果配置允许 Follower 读的话)。这些读可能返回过时数据。
最终旧 Leader 会因为无法与多数派通信而退位。但在退位之前的窗口内,如果客户端连的是旧 Leader 所在分区的服务器,可能会经历写超时和读过时数据。
七、在 Kafka/HBase/Hadoop 中的角色
7.1 Kafka 中的 ZooKeeper
在 Kafka 2.x 及更早版本中,ZooKeeper 是 Kafka 的外部依赖(External Dependency),承担了多项关键协调任务:
Broker 注册与发现:每个 Kafka Broker
启动时,在 /brokers/ids/<broker_id>
下创建一个临时节点,存储 Broker 的主机名、端口、JMX
端口等信息。Broker 崩溃后临时节点自动消失,Controller 通过
Watch 感知到 Broker 的上下线。
Controller 选举:Kafka 集群中有一个
Controller 负责分区(Partition)的 Leader
选举和副本管理。Controller 是通过在 ZooKeeper 的
/controller
路径创建临时节点来选举的——第一个成功创建节点的 Broker 成为
Controller。
Topic 和分区元数据:Topic
的配置信息存储在
/config/topics/<topic_name>,分区的状态信息(Leader
是谁、ISR 列表是什么)存储在
/brokers/topics/<topic_name>/partitions/<partition_id>/state。
消费者组协调(旧版本):在 Kafka 0.8
时代,消费者的偏移量(Offset)存储在 ZooKeeper 中。但由于
ZooKeeper
不适合高频写入(每个消费者每消费一条消息就更新一次偏移量),Kafka
0.9 引入了内部 Topic __consumer_offsets 来取代
ZooKeeper 存储偏移量。
KRaft 模式——去 ZooKeeper 化:从 Kafka
2.8 开始,Kafka 引入了 KRaft(Kafka Raft)模式,使用内置的
Raft 协议来替代 ZooKeeper 进行元数据管理。KRaft
模式将元数据存储在 Kafka 自身的内部 Topic
@metadata 中,由一组 Controller 节点通过 Raft
协议管理。到 Kafka 3.3,KRaft 被标记为生产就绪(Production
Ready)。Kafka 4.0 计划完全移除 ZooKeeper 依赖。
去 ZooKeeper 化的主要动机:
- 运维复杂度——维护两套分布式系统(Kafka + ZooKeeper)比维护一套更复杂。
- 元数据性能——ZooKeeper 的写吞吐限制了 Kafka 集群可以支持的分区数量上限(大约在 20 万分区级别)。KRaft 模式下这个限制大幅提升。
- 部署一致性——KRaft 模式下所有节点都是 Kafka 进程,监控、升级、故障排查更统一。
Kafka + ZooKeeper 架构:
┌──────────────┐
Producer ──────> │ Kafka Broker │ <──────> ZooKeeper Ensemble
│ (Controller) │ (元数据、选举)
Consumer <────── │ │
└──────────────┘
Kafka KRaft 架构:
┌──────────────┐
Producer ──────> │ Kafka Broker │
│ │
Consumer <────── │ │
└──────────────┘
┌──────────────┐
│ KRaft │ (内置 Raft 共识)
│ Controller │
└──────────────┘
7.2 HBase 中的 ZooKeeper
HBase 对 ZooKeeper 的依赖比 Kafka 更深,目前没有去 ZooKeeper 化的计划。
RegionServer 存活检测:每个 RegionServer(RS)在 ZooKeeper 上维护一个临时节点。HBase Master 通过 Watch 这些节点来感知 RS 的存活状态。当 RS 崩溃后,ZooKeeper 会话超时导致临时节点被删除,Master 检测到后触发 Region 重新分配。
HBase Master 选举:HBase 支持多个 Master
实例以实现高可用。Active Master 在 ZooKeeper
上持有一个临时节点 /hbase/master,Standby
Master Watch 这个节点。Active Master 崩溃后,Standby Master
通过竞争创建这个节点来接任。
META 表定位:HBase 的 META
表记录了所有用户表的 Region 到 RegionServer 的映射关系。META
表本身所在的 RegionServer 地址存储在 ZooKeeper 的
/hbase/meta-region-server
节点中。客户端首先访问 ZooKeeper 获取 META 表位置,然后从
META 表查找目标 Region 的位置。
集群配置和状态:集群的 ClusterID、RS 的负载信息、Region 迁移状态等都通过 ZooKeeper 进行协调。
7.3 Hadoop 中的 ZooKeeper
在 Hadoop 2.x 及以上版本中,ZooKeeper 用于多个组件的高可用方案:
HDFS NameNode HA:HDFS 支持双 NameNode 的 Active/Standby 架构。ZooKeeper 用于两个目的:
- Leader 选举——ZKFC(ZooKeeper Failover Controller)在 ZooKeeper 上创建临时节点来竞选 Active NameNode。
- 防护(Fencing)——当需要切换 Active NameNode 时,ZKFC 通过 ZooKeeper 来协调防护操作(例如通过 SSH 杀死旧的 Active NameNode 进程),避免脑裂。
NameNode HA 架构:
┌─────────┐ ┌─────────┐
│ Active │ │ Standby │
│ NameNode │ │ NameNode│
└────┬─────┘ └────┬────┘
│ │
┌────┴─────┐ ┌────┴────┐
│ ZKFC │ │ ZKFC │
└────┬─────┘ └────┬────┘
│ │
└──────┬───────┘
│
┌──────┴──────┐
│ ZooKeeper │
│ Ensemble │
└─────────────┘
YARN ResourceManager HA:类似 HDFS NameNode HA,YARN 也支持双 ResourceManager 的 Active/Standby 架构。ZooKeeper 用于 ResourceManager 的选举和状态存储。ResourceManager 将自己的状态(正在运行的应用、调度信息等)存储在 ZooKeeper 中,Standby RM 从 ZooKeeper 恢复状态后可以无缝接管。
跨组件协调:在完整的 Hadoop 生态中,ZooKeeper 是 HDFS HA、YARN HA、HBase、Kafka、Hive(锁管理、Leader 选举)等组件共同依赖的协调基础设施。一个 Hadoop 集群通常部署一个共享的 ZooKeeper 集群,所有组件共用。这降低了运维成本,但也意味着 ZooKeeper 集群的故障会影响整个大数据平台的所有组件——它成了系统中最关键的单点依赖。
7.4 ZooKeeper 的定位总结
一个简要的对比表格:
| 系统 | 使用 ZooKeeper 做什么 | 是否正在去 ZK 化 |
|---|---|---|
| Kafka | Broker 注册、Controller 选举、Topic 元数据 | 是(KRaft,3.3+ 生产就绪) |
| HBase | RS 存活检测、Master 选举、META 定位 | 否 |
| HDFS | NameNode HA 选举与防护 | 否 |
| YARN | ResourceManager HA | 否 |
| Hive | 锁管理、Leader 选举 | 部分(Hive 3.x 减少了对 ZK 的依赖) |
ZooKeeper 的核心价值在于提供了一套经过十多年生产验证的分布式协调原语。但它也有明确的局限性:不适合大数据量存储(1MB 限制)、写吞吐有限(Leader 单点)、Watch 语义有间隙、会话过期检测有延迟。在选择是否使用 ZooKeeper 时,需要根据具体场景权衡这些特性。对于新项目,etcd 和基于 Raft 的嵌入式方案(如 Kafka KRaft)正在成为越来越流行的替代选择。
参考文献
- Hunt, P., Konar, M., Junqueira, F. P., & Reed, B. (2010). ZooKeeper: Wait-free Coordination for Internet-scale Systems. USENIX ATC. https://www.usenix.org/legacy/events/atc10/tech/full_papers/Hunt.pdf
- Junqueira, F. P., Reed, B. C., & Serafini, M. (2011). Zab: High-performance broadcast for primary-backup systems. DSN. https://ieeexplore.ieee.org/document/5958223
- Apache ZooKeeper Documentation. https://zookeeper.apache.org/doc/current/
- Ongaro, D., & Ousterhout, J. (2014). In Search of an Understandable Consensus Algorithm. USENIX ATC. https://raft.github.io/raft.pdf
- Kleppmann, M. (2016). Designing Data-Intensive Applications. O’Reilly Media. Chapter 8-9.
- Apache Curator Documentation. https://curator.apache.org/
- Kafka KIP-500: Replace ZooKeeper with a Self-Managed Metadata Quorum. https://cwiki.apache.org/confluence/display/KAFKA/KIP-500
- Medeiros, A. (2012). ZooKeeper’s Atomic Broadcast Protocol: Theory and Practice. https://www.cs.cornell.edu/courses/cs6452/2012sp/papers/zab-ieee.pdf
Prev: Dataflow 模型与流批一体 | Next: etcd 深度解剖
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【分布式系统百科】分布式锁的真相:从 Redlock 争论到 Fencing Token
完整还原 Kleppmann 与 Antirez 关于 Redlock 的技术争论,拆解 Fencing Token 方案的原理与实现,对比基于 etcd 和 ZooKeeper 的分布式锁正确实现,讨论锁粒度、Advisory Lock 与 Mandatory Lock 的区别,以及用版本号代替锁的替代思路。
【分布式系统百科】etcd 深度解剖:从 Watch 机制到 MVCC 存储引擎
深入剖析 etcd 的核心机制:持久化 Watch 与 Revision 追溯、Lease 租约机制、基于 BoltDB 的 MVCC 存储引擎、与 Raft 共识的联动方式,以及在 Kubernetes 中的关键角色。涵盖性能调优策略、容量限制与规模化方案。
【分布式系统百科】Raft 深度重写:从论文的 18 页到 etcd 的 15000 行
Raft 论文 18 页就能读完,但 etcd/raft 用了 15000 行 Go 才把它变成能在生产环境跑的代码。这篇文章从论文的每一个核心机制出发,逐一拆解工程实现中论文没说的东西:PreVote、ReadIndex、LeaderTransfer、ConfChange V2、流水线复制、Async Apply,以及 TiKV 的 Multi-Raft 实践。最后做一次精确的 Paxos 对比,并坦诚讨论 Raft 的已知缺陷。
【分布式系统百科】05 · 分布式系统的复杂性度量:消息复杂度、轮次复杂度与空间下界
顺序算法用时间复杂度和空间复杂度就能衡量好坏。分布式算法多了消息复杂度、轮次复杂度和容错数量三个维度,三者之间存在不可调和的 trade-off。本文从选主、共识、广播三个典型问题出发,梳理这些度量指标的定义、下界和工程影响。