主题
ZAB 协议详解:ZooKeeper 为什么不用 Raft?ZXID、崩溃恢复与数据同步
本文是分布式系统学习系列的 B5 共识算法篇。前置:03. Raft vs Paxos 选举、28. 从 Paxos 到 Raft 的推导。学完可以对比阅读:29. ZK 与 etcd 协调服务。
问题
ZooKeeper 的一致性协议叫 ZAB(ZooKeeper Atomic Broadcast),不是 Paxos 也不是 Raft。面试常问:ZAB 和 Raft 到底差在哪?ZXID 是什么?ZK 崩溃恢复时怎么保证数据不丢?
ZAB 的两阶段:原子广播和崩溃恢复
ZAB 和 Raft 同属 leader-based 共识家族,但解决问题的路径不同。Raft 把共识拆成选举、复制、安全三个独立子问题,每个子问题有清晰的状态机。ZAB 把协议分为两个阶段:
- 崩溃恢复(Recovery Phase):选主 + 数据同步,保证所有节点回到一致的状态
- 消息广播(Broadcast Phase):leader 驱动事务,类似 2PC 的 Propose/Ack/Commit
两个阶段交替运行:正常时跑广播,leader 挂了就切回恢复,恢复完再切回广播。ZAB 保证一个全局性质:"在同一个 epoch 里,所有事务的提交顺序在全集群一致"。
消息广播:类 2PC 但去掉了回滚
ZK 的写请求全部走 leader。leader 收到写请求后,执行 ZAB 广播流程:
Client -> Leader: 写请求
Leader: 生成提案 (zxid)
Leader -> Follower: PROPOSAL(zxid, data)
Follower: 写入本地事务日志,返回 ACK
Leader: 收到多数派 ACK -> COMMIT
Leader -> Follower: COMMIT(zxid)
Follower: 提交到内存数据树
Leader -> Client: 返回成功和经典 2PC 的区别在于:ZAB 没有回滚阶段。如果 leader 在广播过程中挂了,由崩溃恢复阶段决定哪些提案要丢弃、哪些要提交,而不是主动回滚。因为 ZAB 的提案都是"先写日志再回复 ACK"的模式,崩溃恢复阶段可以靠 ZXID 辨别哪些提案已经"多数派确认"。
为什么去掉了回滚?因为 ZK 是协调服务,所有写操作都是幂等的事务(创建节点、删除节点、设置数据),不存在"操作一半不能回滚"的问题——要么全提交,要么全丢弃,不存在中间态。
ZXID:全局有序的唯一凭证
ZXID(ZooKeeper Transaction ID)是 ZAB 实现全局有序的关键。它是一个 64 位整数,分为两部分:
ZXID = epoch (高 32 位) + counter (低 32 位)- epoch:每换一次 leader 就加 1,标识"这是第几代 leader 的任期"
- counter:同一个 leader 内单调递增,从 0 开始,每次提案加 1
ZXID 的有序性决定了 ZK 的全局事务顺序。两个 ZXID 比较时,先比 epoch,再比 counter。所以 0x200000001(epoch=2, counter=1)一定在 0x100000005(epoch=1, counter=5)之后——即使 counter 更小,但 epoch 更大。
这个设计比 Raft 的 term + index 多了一层含义:ZXID 可以同时标识"哪个 leader 发的"和"在这个 leader 下的第几个事务"。Raft 的 term 只用于选举,日志顺序靠 index 保证;ZAB 的 epoch 既控制选举优先级,也参与事务排序。
崩溃恢复:选主 + 同步两阶段
ZAB 的崩溃恢复是整个协议最精巧的部分。它解决的是:leader 挂了,新 leader 上来后,怎么保证所有 follower 和 leader 的数据一致?
选主算法
ZAB 的选主基于 ZXID 和 myid(服务器编号,手动配置的 1-255 整数):
- 每个节点投给自己,广播自己的
(myid, zxid) - 收到其他节点的投票后,比较规则:先比 zxid,zxid 大的优先;zxid 相同再比 myid,myid 大的优先
- 当某个节点收到多数派(quorum,通常 N/2+1)的相同投票,它成为 leader
这个规则保证了:拥有最新事务日志的节点最可能当选。因为 zxid 最大的节点意味着它的事务最新,这样新 leader 的数据最接近崩溃前的状态,后续同步的数据量最小。
数据同步:DIFF、TRUNC 和 SNAP
新 leader 当选后,需要让所有 follower 的数据和它一致。ZAB 设计了三种同步策略,取决于 follower 和 leader 的日志差距:
Leader 视角的日志对比:
leader 的日志尾
|
leader: ... [txn_N] [txn_N+1] [txn_N+2] [txn_N+3]
follower A: ... [txn_N] [txn_N+1] <- DIFF:缺尾部,补发
follower B: ... [txn_N] [txn_N+1] [txn_N+2] [txn_N+3] [txn_N+4] <- TRUNC:多了一截,截断
follower C: ... (完全对不上,或差距太大) <- SNAP:全量同步DIFF(增量同步):follower 的日志和 leader 共享前缀,但缺尾部。leader 把缺少的提案直接发给 follower,follower 按顺序执行。
TRUNC(截断):follower 的日志比 leader 还长。这种情况发生在:旧 leader 广播了提案但还没 COMMIT 就挂了,而某个 follower 已经收到了这个提案(PROPOSAL 已收到但没收到 COMMIT)。新 leader 没有这个提案,所以要求 follower 截掉多出来的部分。
SNAP(全量同步):follower 的日志和 leader 差距太大(比如刚重启的节点),或者 epoch 完全对不上。leader 直接把自己的完整内存数据快照发给 follower,follower 清空后加载。
三种策略的优先级:DIFF > TRUNC > SNAP,因为 DIFF 传输量最小,恢复最快。实际生产里,正常运行中的节点重连通常走 DIFF,新加入的节点走 SNAP。
崩溃恢复的关键保证
ZAB 的崩溃恢复保证两个性质:
- 已提交的提案不丢:leader 在 COMMIT 之前先写了自己的日志,多数派也 ACK 了。新 leader 从多数派里选,必然包含至少一个拥有最新已提交日志的节点。
- 未提交的提案不出现:如果旧 leader 广播了 PROPOSAL 但没等到多数派 ACK 就挂了,这个提案只存在于少数节点上。新 leader 的日志里没有它,follower 上多出来的会被 TRUNC 掉。
这两个性质合起来等于:ZAB 是 CP 的——分区发生时,如果无法形成多数派,ZK 停止服务,保证数据不出现分歧。
ZAB 与 Raft 的逐项对比
理解了 ZAB 的细节,就能和 Raft 做精确对比了:
| 维度 | ZAB (ZooKeeper) | Raft (etcd) |
|---|---|---|
| 任期标识 | epoch(64 位 ZXID 高 32 位,事务有序) | term(独立整数,只用于选举) |
| 日志顺序 | ZXID 全局有序,同时包含 epoch 和 counter | term + index 二元组,log index 在该 term 内单调 |
| 选举优先级 | zxid 优先,zxid 相同再比 myid | 日志最新的优先(term 最大,或 term 相同 index 最大) |
| 日志同步 | DIFF / TRUNC / SNAP 三种策略 | 强制 follower 逐条复制 leader 日志,从冲突点覆盖 |
| 写流程 | 类 2PC:Propose → Ack → Commit | AppendEntries RPC:leader 先写本机,再广播等待多数派确认 |
| 读一致性 | 不保证线性一致(follower 可能旧),需 sync 命令 | 支持 ReadIndex 和 Lease Read 读 |
| 响应判断 | 多数派 ACK 即提交,不等待 COMMIT 被确认 | 多数派确认即提交,提交后 apply 到状态机 |
| 角色 | Leader / Follower / Observer(观察者不投票) | Leader / Follower / Candidate |
| 成员变更 | 不支持动态变更(需重启或滚动重启) | 支持 Joint Consensus 分批变更 |
| 网络分区 | 少数分区停止服务(CP),leader 在多数侧 | 少数分区选不出 leader 只读不可写,多数侧继续 |
核心差异的本质
ZAB 的 epoch 携带了事务顺序信息,所以日志天然有序,不需要像 Raft 那样用 term + index 两个维度定位日志位置。代价是 ZXID 是 64 位整数,事务总量受限于 2^32 个 counter——虽然实际够用,但设计上不如 Raft 的 index 无限增长灵活。
Raft 的日志复制严格按"follower 必须接受 leader 的日志,冲突的条目用 leader 的覆盖"。ZAB 的 DIFF/TRUNC/SNAP 更灵活,但也更复杂——三种策略的切换逻辑本身就是 bug 高发区。ZK 历史上多个 bug 出在崩溃恢复阶段(比如 ZOOKEEPER-2013 的 TRUNC 竞争条件)。
ZK 为什么不用 Raft
这个问题在社区里反复被问。历史原因很直接:ZK 诞生于 2007 年,Raft 论文发表于 2013 年。ZK 设计时 Raft 还没出现,ZAB 是由 ZK 团队自己设计的协议。
但更深层的原因是:ZAB 和 Raft 虽然目标一致(原子广播),但设计哲学不同。ZAB 的"两阶段交替"更贴近 ZK 的使用场景——ZK 是协调服务,写操作频率低但数据一致性要求高,ZAB 的 epoch 体系天然适合这种"leader 偶尔切换、日常广播"的模式。Raft 的"单一 Leader + 强日志覆盖"更适合日志复制场景(如 etcd 的 KV 存储)。
换个角度:如果 ZK 团队在 2013 年后重新设计,会不会用 Raft?很可能不会。因为 ZAB 的 DIFF/TRUNC/SNAP 在恢复速度上比 Raft 的"全量日志覆盖"更适合 ZK 的场景——ZK 的内存数据量小(默认 1GB 上限),SNAP 全量同步很快;而 Raft 的日志复制在 follower 掉队太多时需要 snapshot + install snapshot,代价类似。
常见面试追问
- ZXID 溢出了怎么办:32 位 counter 够 40 多亿次事务,实际一个 ZK 集群一天几十万次写是上限,够用几百年。溢出了 ZXID 变为负数,选举时按无符号比较,不影响。
- Observer 在 ZAB 中怎么工作:Observer 不参与投票,只接收事务流。崩溃恢复时 Observer 从 leader 同步数据,但不影响选举结果。
- ZAB 能保证线性一致性吗:不能。ZK 的写操作是线性一致的(leader 序列化执行),但读操作可能从 follower 读到旧数据。要线性一致读必须调 sync 命令。
- ZK 的 watch 和 ZAB 的关系:watch 是 ZK 上层的通知机制,不依赖 ZAB。事务提交后,ZAB 保证数据在节点间一致,watch 是在这个基础上触发的。两者独立。
小结
ZAB 是 ZK 的协议层灵魂,理解它等于理解了 ZK 为什么是 CP 的、为什么选主看 ZXID、为什么崩溃恢复选 DIFF 而不是全量同步。和 Raft 对比时,别只背表格,要理解两种设计选择的动机:ZAB 的 epoch+counter 设计让日志天然有序但恢复逻辑更复杂,Raft 的 term+index 设计更简洁但恢复时需要逐条覆盖。两种共识算法解决问题的方式不同,但都保证了"在多数派存活时,系统对外表现为一个一致的状态机"。
参考
- Zab: High-performance broadcast for primary-backup systems(J. R. 等,OSDI 2008)
- In Search of an Understandable Consensus Algorithm(Raft 论文,Diego Ongaro,2013)
- ZooKeeper 源码分析:ZAB 协议实现(QuorumPeer、Leader/Follower 状态机)
- ZooKeeper 协调服务
- Raft 与 Paxos 对比