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 设计的精巧处:

  1. 事务 id:所有写操作的全局顺序;
  2. 选举比较键:候选人比"谁的 epoch 大、zxid 大";
  3. 恢复依据: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 的做法让恢复分摊到正常路径、无需额外阶段;两者都只在"未提交后缀"上动手,都不会删除已提交条目。

关联