Skip to content

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 把协议分为两个阶段:

  1. 崩溃恢复(Recovery Phase):选主 + 数据同步,保证所有节点回到一致的状态
  2. 消息广播(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 整数):

  1. 每个节点投给自己,广播自己的 (myid, zxid)
  2. 收到其他节点的投票后,比较规则:先比 zxid,zxid 大的优先;zxid 相同再比 myid,myid 大的优先
  3. 当某个节点收到多数派(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 的崩溃恢复保证两个性质:

  1. 已提交的提案不丢:leader 在 COMMIT 之前先写了自己的日志,多数派也 ACK 了。新 leader 从多数派里选,必然包含至少一个拥有最新已提交日志的节点。
  2. 未提交的提案不出现:如果旧 leader 广播了 PROPOSAL 但没等到多数派 ACK 就挂了,这个提案只存在于少数节点上。新 leader 的日志里没有它,follower 上多出来的会被 TRUNC 掉。

这两个性质合起来等于:ZAB 是 CP 的——分区发生时,如果无法形成多数派,ZK 停止服务,保证数据不出现分歧。

ZAB 与 Raft 的逐项对比

理解了 ZAB 的细节,就能和 Raft 做精确对比了:

维度ZAB (ZooKeeper)Raft (etcd)
任期标识epoch(64 位 ZXID 高 32 位,事务有序)term(独立整数,只用于选举)
日志顺序ZXID 全局有序,同时包含 epoch 和 counterterm + index 二元组,log index 在该 term 内单调
选举优先级zxid 优先,zxid 相同再比 myid日志最新的优先(term 最大,或 term 相同 index 最大)
日志同步DIFF / TRUNC / SNAP 三种策略强制 follower 逐条复制 leader 日志,从冲突点覆盖
写流程类 2PC:Propose → Ack → CommitAppendEntries 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 对比

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。
粤ICP备2026104257号-1