21.9 ZAB
一句话
ZAB 是给 ZooKeeper 定制的"全序广播 + 崩溃恢复"协议:它用 zxid =(epoch, counter) 把"谁的日志更新"变成一次单调比较,用 discovery / sync 两个显式阶段强迫新 leader 先成为"最完整的那个",然后才允许广播——这是它与 Raft"靠日志匹配隐式压制旧日志"最本质的区别。
为什么 ZooKeeper 不用 Paxos
| 需求 | Paxos(Multi-Paxos)能给吗 | ZAB 的做法 |
|---|---|---|
| primary order:所有事务按 leader 提出的顺序被处理,且同一客户端的请求保持 FIFO | 能,但要自己补"客户端 FIFO"与"顺序提交"的约束 | 广播阶段天然按 zxid 全序,client 的请求由 leader 串行化 |
| 崩溃恢复后不能让"未提交事务"留在日志里 | Paxos 允许 slot 有洞、后续再填,恢复语义要自行设计 | 恢复阶段显式 TRUNC(截断未提交后缀)或 DIFF(补齐) |
| 读多写少,需要扩展读能力 | 未定义 | observer 不参与投票,只同步日志、服务本地读 |
| 实现可验证 | 难(状态机分散在多个角色) | 三个阶段边界清晰,容易做状态机测试 |
🔑 所以 ZAB 的定位不是「Paxos 的升级版」,而是"为 ZooKeeper 的语义(全序 + FIFO + 会话 + watch)定制的一份协议"。它牺牲了通用性,换来实现清晰与语义贴合。
三个角色与一个编号
| 角色 | 投票 | 处理写 | 处理读 | 说明 |
|---|---|---|---|---|
| leader | 是 | 是(唯一入口) | 是(本地读) | 同一时刻最多一个 |
| follower | 是 | 转发给 leader | 是(本地读,可能陈旧) | 参与 quorum |
| observer | 否 | 转发给 leader | 是 | 只同步日志,用来扩展读与跨机房 |
zxid(ZooKeeper Transaction ID) 是 64 位整数:高 32 位是 epoch(第几个 leader 任期),低 32 位是 counter(该任期内的第几个事务)。
zxid = 0x00000005_00000003
└─ epoch=5 ─┘└ counter=3 ┘
比较规则:先比 epoch,epoch 相同再比 counter(无符号比较,正好等价于整体比较)
单调性: 同一 epoch 内 counter 严格递增;换 epoch 时 epoch 递增、counter 从 0 重新开始
zxid 一个字段干了三件事,这也是 ZAB 设计的精巧处:
- 事务 id:所有写操作的全局顺序;
- 选举比较键:候选人比"谁的 epoch 大、zxid 大";
- 恢复依据:follower 上报
lastZxid,leader 据此判断该补(DIFF)、该截(TRUNC)还是该发快照(SNAP)。
三个阶段
ZAB 把"一次 leader 更替 + 持续广播"拆成三段,前两段只在选主后执行一次:
┌──────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ 1 Discovery │──▶│ 2 Synchronization│──▶│ 3 Broadcast │
│ 定 epoch、 │ │ 补齐/截断 follower│ │ PROPOSE→ACK→COMMIT│
│ 收集 lastZxid │ │ 日志,宣布新 leader│ │ 持续进行 │
└──────────────┘ └──────────────────┘ └──────────────────┘
| 阶段 | 关键动作 | 消息 | 失败会怎样 |
|---|---|---|---|
| Discovery | 准 leader 收集多数派的 acceptedEpoch/lastZxid,提出新 epoch,选出"日志最新的 follower"作为同步源 | FOLLOWERINFO → NEWEPOCH → ACKEPOCH → INFORM | 拿不到多数派 → 继续选主,不进入下一阶段 |
| Synchronization | leader 把差异发给每个 follower:DIFF(补缺失事务)/TRUNC(截掉多余未提交事务)/SNAP(差距太大发快照);多数派确认后宣布 NEWLEADER 生效 | DIFF、TRUNC、SNAP、NEWLEADER、UPTODATE | 多数派没确认 → 新 epoch 不生效,回退重试 |
| Broadcast | leader 收到写 → 生成 zxid → 发 PROPOSE → 多数派落盘 ACK → 发 COMMIT → follower 按 zxid 顺序交付给应用 | PROPOSE、ACK、COMMIT | 未达多数派 → 该事务不提交;leader 挂了则被 TRUNC |
三段的顺序不可交换:没有 Discovery 就没有合法 epoch(可能出现两个 leader 用同一 epoch);没有 Synchronization 就不能保证"leader 的日志是最新的"(未提交事务会留在 follower 上,之后被当成已提交)。
广播:两阶段提交,但没有回滚
client ──写──▶ leader: 生成 zxid=(e, c),写本地 txnlog,发 PROPOSE(zxid, txn)
follower: 写本地 txnlog → ACK(zxid)
leader: 收到多数派 ACK → 发 COMMIT(zxid)(并本地提交)
follower: 收到 COMMIT → 按 zxid 顺序交付给应用(apply)
leader ──成功──▶ client
| 与经典 2PC 的差异 | 说明 |
|---|---|
| 没有 abort 消息 | 未达多数派的提案不会被"回滚",而是留在 leader/follower 日志里,等下次恢复被 TRUNC 掉 |
| 提交点是 leader 发出 COMMIT | 一旦多数派 ACK 且 leader 发出 COMMIT,事务不能再丢(新 leader 必含它) |
| 顺序由 zxid 保证 | follower 必须按 zxid 顺序 apply,不能乱序执行 |
⚠️ 一个常被忽略的细节:leader 在发出 COMMIT 之前就崩溃,某些 follower 可能已经 apply 了该事务(如果它们提前收到 COMMIT),而另一些没有。ZAB 的处理不是"回滚已 apply 的",而是保证新 leader 一定包含所有已 COMMIT 的事务,未 COMMIT 的被截断——所以"已 apply 的事务不会消失"依赖的是"COMMIT 只在多数派持久化之后发出",而不是两阶段提交的回滚能力。
四条保证
| 保证 | 含义 | 工程含义 |
|---|---|---|
| 可靠投递(Reliable Delivery) | 一个事务被某个 follower 交付后,最终会被所有 follower 交付 | 不会出现"只有部分节点看到这条写" |
| 全序(Total Order) | 所有事务有一个全局顺序(zxid) | 所有副本 apply 的顺序一致 |
| 因果顺序(Causal Order) | 同一客户端的前一个请求先于后一个被处理 | 客户端的写后读(同一连接)不会倒序 |
| 前缀性质(Prefix Property) | follower 的日志永远是 leader 日志的前缀(收敛后) | 截断只能截后缀,不能改中间 |
读的一致性要单独说:ZooKeeper 的读是本地读,一个 follower 可能还没 apply 到最新 zxid,所以 get 可能返回旧值。它保证的是"写线性一致 + 每客户端 FIFO",不是"读线性一致"。要读最新,客户端先 sync()(让该 server 追到 leader 当前的 zxid)再读。这一点在产品层面也常被误解(见 48.6 产品 (etcd / Consul / ZooKeeper / Nacos))。
恢复时的三条分支
新 leader 在 Synchronization 阶段对每个 follower 做一次判断:
| follower 相对 leader | 动作 | 语义 |
|---|---|---|
| 落后但历史一致 | DIFF:补发缺失的事务 | 最常见 |
| 多出未提交的后缀(旧 leader 提案但未 COMMIT) | TRUNC:截断到与 leader 一致的最后一个 zxid | 丢弃"从未提交"的提案 |
| 差距过大(日志已被清理/快照) | SNAP:发完整快照 | 唯一能追上的方式 |
这三条分支与 Raft 的 nextIndex 回退 + InstallSnapshot 是同一个问题的两种表达:Raft 用"追加时发现冲突再回退"隐式完成,ZAB 用"同步阶段显式分类"完成。效果等价,实现复杂度不同。
与 Raft 的机制差异
| 维度 | ZAB | Raft |
|---|---|---|
| 事务编号 | zxid = (epoch, counter),全局单调 | (term, index),index 在 term 内连续 |
| 新 leader 的"合法性" | Discovery 阶段选"日志最新者",并显式同步 | 选举限制(投票时比较日志新旧),同步靠 AppendEntries 回退 |
| 未提交后缀 | 恢复时显式 TRUNC | 追加冲突时隐式删除 |
| 成员 | follower / observer(observer 不投票) | voter / learner(learner 不投票) |
| 读 | 本地读,可能陈旧;sync() 保证追平 | ReadIndex / 租约 / follower 读(见 21.12 共识算法对比) |
一句话记法:ZAB 把"恢复"做成了一个显式阶段,Raft 把"恢复"摊进了每一次 AppendEntries。
运维上真正会咬人的点
| 点 | 现象 | 处理 |
|---|---|---|
| txnlog 的 fsync | 写吞吐被磁盘 fsync 限制(单机几千 TPS 量级) | 用 SSD、独立磁盘、批量提交;别把 data 与 txnlog 放同一块慢盘 |
| 快照是"模糊快照" | 拍快照时仍在处理写,快照内容对应某个 zxid 区间 | 恢复时用"快照 + 之后的 txnlog 回放",不要假设快照是某一瞬间 |
| observer 不扩容 quorum | 加 observer 只提升读能力,不提升写可用性 | 写可用性仍由 voter 数量决定 |
| session 超时与临时节点 | 网络抖动导致 session 过期 → 临时节点被删 → 依赖它的服务惊群 | 合理设置 sessionTimeout,客户端做重连与重建 |
| watch 是一次性的 | 触发一次就失效,必须重新注册 | 客户端封装"重注册",避免漏事件(见 48.5 Watch 与通知机制) |
| 磁盘满 | ZooKeeper 进入只读/不可用,且可能损坏事务日志 | 监控磁盘 + 定期清理旧快照(保留策略见 39.3 存储分级) |
反模式
| 反模式 | 后果 |
|---|---|
| 把 ZooKeeper 当强一致读的数据库 | 读到旧值,业务出现"写完立刻读不到" |
| 用 ZooKeeper 存大量数据(超过几 MB/节点) | 写放大、快照变大、恢复变慢;它设计给元数据与小配置 |
| 指望加 observer 提升写可用性 | observer 不投票,写 quorum 不变 |
| 频繁创建/删除临时节点(如每次请求建一个) | zxid 暴涨、watch 风暴、日志膨胀 |
忽略 sync() 就读关键配置 | 滚动重启期间读到旧配置 |
| 把 session 超时设得很短 | 网络抖动即失联,临时节点闪断引发雪崩 |
自检
Q1 zxid 为什么把 epoch 放在高位?如果只用 counter 会怎样?
▸ 答案:比较时"先比 epoch、再比 counter"必须与"整体数值比较"一致,所以高位放 epoch。如果只用 counter,leader 更替后新 leader 的计数器从 0 开始,会出现"新事务的编号比旧事务小"——全序被破坏,恢复时无法判断谁更新,已提交事务可能被当成旧的截断掉。
Q2 ZAB 的 Discovery 阶段解决什么问题?没有它会怎样?
▸ 答案:解决"新 leader 凭什么说自己的历史是最新的"以及"新 epoch 是否被多数派接受"。它收集多数派的 lastZxid、确定新 epoch、并选出日志最新的 follower 作为同步源。没有它:可能出现两个 leader 同时用同一 epoch 广播(epoch 冲突),或者新 leader 少一条最晚提交的事务却不知道,导致已提交事务被 TRUNC 掉。
Q3 follower 上有一段"leader 提过但没 COMMIT"的事务,恢复时怎么处理?为什么不能保留?
▸ 答案:用 TRUNC 截断掉。它从未被多数派确认,也没有向客户端承诺成功;保留它会导致两个问题:① 新 leader 的日志不是"最新且唯一"的,可能出现两条不同的历史都自称合法;② 如果它恰好与新 leader 在同一位置的事务冲突,保留就意味着"两个已提交的不同值",违反全序。截断的合法性来自"COMMIT 只在多数派持久化之后发出"。
Q4 ZooKeeper 说"写是线性一致的",为什么读还需要 sync()?
▸ 答案:写线性一致指的是写操作的全局顺序与可见性(通过 leader + zxid 全序保证);而读是每个 server 本地执行的,follower 可能还没 apply 到最新 zxid。sync() 会强制该 server 与 leader 同步到当时的最新状态后再返回,之后的读才能看到此前已完成的写。若业务需要"读己之写"或"读最新配置",要么走 sync(),要么直接读 leader(不推荐,会打爆 leader)。
Q5 同样解决"未提交后缀",ZAB 的 TRUNC 与 Raft 的冲突删除有什么本质不同?
▸ 答案:机制不同、保证相同。ZAB 在恢复阶段显式分类(DIFF/TRUNC/SNAP),一次性把每个 follower 的日志调整到与 leader 一致;Raft 在每次 AppendEntries 时用 (prevLogIndex, prevLogTerm) 探测,发现冲突就删掉冲突位置之后的所有条目,逐步回退到一致点。ZAB 的做法让"恢复"成为一个可测试的独立阶段,Raft 的做法让恢复分摊到正常路径、无需额外阶段;两者都只在"未提交后缀"上动手,都不会删除已提交条目。