前言
上一篇我们完整介绍了 Raft 算法:通过任期(Term)充当逻辑时钟,随机超时打破选票瓜分,强 Leader 单向复制日志,再用选举限制和提交限制双重保险守住安全性。
Raft 发表于 2014 年,但在这之前,ZooKeeper 已经在 Hadoop 生态中默默运行了好几年。ZooKeeper 用的是它自己的共识协议------ZAB(ZooKeeper Atomic Broadcast,ZooKeeper 原子广播协议),在生产环境中经历了大规模验证。
ZAB 和 Raft 同宗同源------都是强 Leader 模型,都用多数派机制保证一致性------但在选举策略 和数据恢复上走了截然不同的路。本篇将深入 ZAB 的核心机制,最后做一次 ZAB vs Raft 的正面对比。
读完本篇,你将理解:
- ZAB 的 ZXID 是如何设计的,以及它解决了什么问题。
- ZooKeeper 崩溃恢复的两个阶段:Leader 选举 和 数据同步。
- ZAB 和 Raft 在哪些关键点上做了不同的取舍。
一、ZAB 是什么?为什么需要它?
ZooKeeper 的角色
ZooKeeper 是一个分布式协调服务,用于存储少量关键的协调数据------集群配置、服务注册信息、分布式锁的持有者等。它不是一个通用数据库,数据量小,但可靠性要求极高:ZooKeeper 挂了,依赖它的所有上层系统(Kafka、HBase、Hadoop...)都会受到波及。
ZAB 的核心职责
ZAB 协议的目标只有一个:保证 ZooKeeper 集群中,所有节点以相同的顺序看到相同的状态变更。这正是共识问题的本质。
ZAB 将系统的运行模式划分为两个阶段:
(Leader 选举 + 数据同步)"]:::box B["✅ 消息广播模式
(正常服务)"]:::box %% 核心流转逻辑 R ==>|"恢复完成"| B B -.->|"Leader 宕机 / 失联"| R end %% 美化外观:橙色代表恢复中,绿色代表正常运行 style ZK fill:#f8f9fa,stroke:#adb5bd,stroke-width:2px,stroke-dasharray: 5 5,rx:10px style R stroke:#ff922b,stroke-width:3px style B stroke:#51cf66,stroke-width:3px
- 消息广播模式(Broadcast):集群有稳定的 Leader,正常处理客户端请求,类似 Raft 的日志复制阶段。
- 崩溃恢复模式(Recovery):Leader 宕机或集群刚启动时,触发选举和数据同步,直到新 Leader 产生并完成数据对齐,再进入广播模式。
理解 ZAB 的关键,在于理解它的崩溃恢复模式------这是 ZAB 和 Raft 差异最大的地方。
二、ZXID:ZAB 的"基因编码"
在深入选举之前,必须先理解 ZXID(ZooKeeper Transaction ID),因为 ZAB 的选举逻辑完全围绕 ZXID 展开。
ZXID 的结构
ZXID 是一个 64 位整数,被巧妙地拆成两部分:
ini
ZXID(64位):
┌──────────────────────────────┬──────────────────────────────┐
│ 高 32 位:Epoch (纪元) │ 低 32 位:Counter (计数) │
│ 当前 Leader 的任期号 │ 当前任期内的事务序号 │
└──────────────────────────────┴──────────────────────────────┘
举例:
ZXID = 0x0000000300000042
│ │
Epoch = 3 Counter = 66 (第 3 任 Leader 的第 66 个事务)
- Epoch(纪元):每次选出新 Leader,Epoch +1。等价于 Raft 中的 Term,用来区分不同 Leader 的时代。
- Counter(计数器):每一条事务(写操作)在当前 Epoch 内递增。每次 Epoch 变更,Counter 清零。
为什么这样设计?
这个设计回答了一个关键问题:如何在不同任期之间比较两条日志的新旧?
Raft 的做法是分开存储 Term 和 Index,比较时先比 Term,再比 Index。ZAB 把这两个信息合并成一个 64 位整数,比较时直接做整数大小比较,更简洁:
css
比较两个 ZXID 谁更新:
节点A 的最大 ZXID:0x0000000300000042(Epoch=3, Counter=66)
节点B 的最大 ZXID:0x0000000200000099(Epoch=2, Counter=153)
整数比较:0x0000000300000042 > 0x0000000200000099
→ 节点A 更新(尽管 Counter 比 B 小,但 Epoch 更大)✅
这个设计让"谁的数据最新"的判断变得极其简单,也正是 ZAB 选举逻辑的基础。
💡 类比理解:ZXID 就像图书馆的图书编号,格式是"版本号-序号"。"第3版第66页"一定比"第2版第153页"更新,因为版本号更高,不需要再比页码。
三、崩溃恢复(Recovery):ZAB 的核心差异
当 Leader 宕机,ZAB 进入崩溃恢复模式,分为两个串行阶段:
- Leader 选举(Fast Leader Election):选出一个候选 Leader。
- 数据同步(Data Synchronization):准 Leader 在正式上任前,先把数据对齐到所有 Follower。
阶段一:Leader 选举(Fast Leader Election)
选举的核心原则
ZAB 的选举目标和 Raft 一样:选出拥有最新数据的节点当 Leader。但实现方式不同。
每个节点在选举时,向外广播一张"选票",包含三个字段:
scss
选票结构:
(myid, ZXID, electionEpoch)
│ │ │
│ │ └── 当前这是第几轮"完整选举"
│ │ 只有在本轮选举迟迟无法收敛、节点决定放弃重来时,
│ │ electionEpoch 才会 +1。
│ └───────────── 本节点持有的最大 ZXID(可能是未提交的,为了选出最新节点)
└───────────────────── 本节点的唯一标识(配置中指定的整数 ID)
⚠️ 两个 Epoch 不要混淆
- ZXID 中的 Epoch:属于"日志版本"的一部分,用来表示这条事务属于哪一任 Leader。
- 选票中的 electionEpoch:属于"选举过程"的一部分,用来表示当前正在进行第几轮完整选举。 它的作用是:丢弃上一轮选举残留的旧选票,避免过期消息干扰当前选举。
每个节点收到别人的选票后,将其与自己当前的选票做 PK,按以下优先级判断:
markdown
1. 先比 ZXID:ZXID 大的胜出(数据最新,优先当 Leader)
2. ZXID 相同,再比 myid:myid 大的胜出(打破平局)
如果对方选票胜出 → 更新自己的选票,改投对方,并重新广播
如果自己选票胜出 → 保持不变,继续等待其他选票
规则确定后,选举的收敛过程就很自然了:所有节点不断 PK、不断向"更优"靠拢,直到多数派聚焦到同一个节点。
选举流程
当前 electionEpoch = 1 rect rgb(240, 248, 255) Note over A,C: 第一次广播:各自先投自己(注意:这仍然属于同一轮 electionEpoch=1) A->>B: 选票 (myid=1, ZXID=0x300000042, electionEpoch=1) A->>C: 选票 (myid=1, ZXID=0x300000042, electionEpoch=1) B->>A: 选票 (myid=2, ZXID=0x300000050, electionEpoch=1) B->>C: 选票 (myid=2, ZXID=0x300000050, electionEpoch=1) C->>A: 选票 (myid=3, ZXID=0x300000050, electionEpoch=1) C->>B: 选票 (myid=3, ZXID=0x300000050, electionEpoch=1) end Note over A: 收到 B、C 的选票
两者 ZXID 都比自己大
再比较 myid,C(3) > B(2)
A 更新选票,改投 C Note over B: 收到 C 的选票
两者 ZXID 相同,比较 myid
C(3) > B(2)
B 更新选票,改投 C rect rgb(255, 250, 230) Note over A,C: 第二次广播:A、B 广播自己更新后的选票(仍属于同一轮 electionEpoch=1) A->>B: 选票 (myid=3, ZXID=0x300000050, electionEpoch=1) A->>C: 选票 (myid=3, ZXID=0x300000050, electionEpoch=1) B->>A: 选票 (myid=3, ZXID=0x300000050, electionEpoch=1) B->>C: 选票 (myid=3, ZXID=0x300000050, electionEpoch=1) end Note over C: 自己 + A + B = 3 票(多数派)
C 当选准 Leader 🎉 Note over A,B: 确认 C 当选,转为 FOLLOWING 状态
💡 与 Raft 的第一个关键差异
Raft 的选举限制要求:Candidate 必须日志"不比投票者旧"才能获得投票,由投票者主动判断并拒绝落后的 Candidate。
ZAB 的策略是:节点一旦发现别人的选票比自己更优,就主动更新选票改投更优的节点,不断收敛,直到多数派聚焦在同一个节点上。
两种方式殊途同归------最终当选的一定是数据最新的节点------但 ZAB 的收敛是"主动靠拢",Raft 的收敛是"被动筛选"。
阶段二:数据同步(Data Synchronization)
选出 Leader 只是第一步。此时准 Leader 不会立刻开始服务,而是先做数据同步,这是 ZAB 最有特色的设计。
为什么要先同步数据?
来看一个场景:
Plaintext
css
5 节点集群,Leader 宕机前后的状态:
节点A(原 Leader): [Epoch=2, 1~50 条事务已提交,第51条写入本地后宕机]
节点B: [Epoch=2, 1~50 已提交]
节点C: [Epoch=2, 1~50 已提交]
节点D: [Epoch=2, 1~48 已提交,落后了]
节点E: [Epoch=2, 1~48 已提交,落后了]
此时,B、C 拥有当前存活节点中的最大 ZXID(到第50条),假设 B 当选新 Leader(Epoch=3)。
问题来了:
- D 和 E 落后了 2 条已提交事务。
- 假设后续 A 恢复并重新加入集群,A 本地有一条从未被多数派确认过的第 51 条事务,而新 Leader B 没有。
- 这些不一致必须在对外服务前解决。
ZAB 的解决方案:新 Leader 在进入广播模式前,强制把所有 Follower 的数据与自己(新 Leader 的最新状态)对齐。
同步的三种场景
准 Leader 收集所有 Follower 的最大 ZXID,与自己的数据对比,针对不同情况执行不同操作:
| Follower 情况 | 例子 | 同步操作 | 说明 |
|---|---|---|---|
| 落后少量 | D、E 只到第 48 条 | DIFF | Leader 补发第 49、50 条事务 |
| 有多余未提交事务 | A 恢复后携带了脏日志 51 | TRUNC | 截断新 Leader 所没有的多余日志(强制向新 Leader 看齐) |
| 落后太多 | 节点数据过旧,增量补不动 | SNAP | 直接发送快照,全量同步 |
一句话概括:DIFF 是补缺,TRUNC 是截断,SNAP 是重建。
💡 关键细节说明(ZAB 对未提交事务的态度):
如果新 Leader 本身就包含上一任期未提交的事务(假设上面的例子中 B 当选前也收到了第 51 条),它绝对不会 丢弃这条事务!相反,它会认为自己是最新的权威,通过
DIFF把 51 补发给多数派,顺势在新的 Epoch 中将其正式提交。TRUNC仅仅发生在 Follower 拥有新 Leader 没有 的日志时。
💡 与 Raft 的第二个关键差异Raft 的数据同步是持续进行的:Leader 上任后直接开始工作,通过 AppendEntries 逐渐把 Follower 拉齐,慢的节点慢慢修复,不阻塞客户端服务。
ZAB 的数据同步是上任前的前置步骤 :Leader 必须等到多数派 Follower 完成数据对齐,才切换到广播模式开始服务。
两种策略的取舍很明显:
- Raft:故障恢复快,Leader 很快能服务,但部分 Follower 的数据可能短暂不一致。
- ZAB:故障恢复慢,但 Leader 开始服务时,多数派数据已经是对齐的,状态更"干净"。
完整的崩溃恢复流程
投票信息:(myid, ZXID, electionEpoch)"] D --> E["按 ZXID > myid 规则持续 PK"] E --> F["多数派收敛到同一节点
产生准 Leader"] end subgraph R2["阶段二:数据同步"] G["准 Leader 发送 NEWLEADER
声明新的 Epoch"] G --> H["Follower 上报本地最大 ZXID"] H --> I{"与准 Leader 历史比较"} I -->|"落后少量"| J["DIFF
补发缺失事务"] I -->|"尾部有脏日志"| K["TRUNC
截断未提交尾部"] I -->|"落后太多"| L["SNAP
发送快照全量同步"] J --> M["Follower 完成同步并 ACK"] K --> M L --> M M --> N{"多数派已完成同步?"} N -->|"否"| O["继续等待其余 Follower 完成"] O --> N end R1 --> G N -->|"是"| P["准 Leader 正式激活为 Leader"] P --> Q["进入广播模式(Broadcast)"] Q --> R["开始处理客户端请求"] classDef danger fill:#ff6b6b,stroke:#d9485f,color:#fff,stroke-width:2px; classDef process fill:#4dabf7,stroke:#1c7ed6,color:#fff,stroke-width:2px; classDef voting fill:#ffd43b,stroke:#fab005,color:#333,stroke-width:2px; classDef sync fill:#69db7c,stroke:#37b24d,color:#fff,stroke-width:2px; classDef branch fill:#b197fc,stroke:#7950f2,color:#fff,stroke-width:2px; classDef decision fill:#ffa94d,stroke:#f76707,color:#fff,stroke-width:2px; classDef success fill:#20c997,stroke:#0ca678,color:#fff,stroke-width:2px; classDef wait fill:#adb5bd,stroke:#868e96,color:#fff,stroke-width:2px; class A danger; class B,G,H,M,Q process; class C,D,E,F voting; class J,K,L sync; class I,N decision; class O wait; class P,R success; style R1 fill:#fdfdfd,stroke:#ddd,stroke-dasharray: 5 5 style R2 fill:#fdfdfd,stroke:#ddd,stroke-dasharray: 5 5
四、消息广播(Broadcast):正常服务阶段
Leader 完成数据同步后,进入广播模式。这个阶段的逻辑和 Raft 的日志复制高度相似,但有几点区别值得关注。
广播流程
写入本地事务日志 rect rgb(240, 248, 255) Note over L,F2: 并行发送 PROPOSAL L->>F1: PROPOSAL(ZXID=0x300000043, cmd=set x=42) L->>F2: PROPOSAL(ZXID=0x300000043, cmd=set x=42) F1-->>L: ACK ✅ F2-->>L: ACK ✅ end Note over L: 多数派确认(含自己 3/3)
提交事务 L-->>C: 返回结果: OK rect rgb(240, 255, 240) Note over L,F2: 广播 COMMIT 消息 L->>F1: COMMIT(ZXID=0x300000043) L->>F2: COMMIT(ZXID=0x300000043) Note over F1,F2: 执行 set x=42,更新状态机 end
流程与 Raft 几乎一致:PROPOSAL 对应 AppendEntries,COMMIT 对应 commitIndex 更新。
一个重要的顺序保证
ZAB 要求 Follower 必须按 ZXID 顺序处理 PROPOSAL 。这在底层实现上意味着 ZooKeeper 的 Leader 和 Follower 之间使用 TCP 长连接,利用 TCP 的有序传输来天然保证消息顺序,无需额外的重排序机制。
这也意味着 ZAB 的广播模型是一个严格的 FIFO 管道------不同于 Raft 那种可以并行处理、乱序确认的设计,ZAB 的事务必须严格按序处理。
五、两个关键保证:ZAB 必须守住的底线
ZAB 在协议层面,明确要求新 Leader 必须保证两件事:
保证一:已提交的事务不能丢
只要一条事务被 Leader 提交(多数派 ACK),即使 Leader 立刻宕机,这条事务必须被新 Leader 继承。
这一点通过选举机制保证:选票中包含 ZXID,拥有最大 ZXID 的节点优先当选。已提交事务一定存在于多数派节点,而当选需要多数派投票支持,两个多数派必有交集,所以新 Leader 一定持有所有已提交事务。
保证二:丢弃新 Leader 所不包含的未提交事务
如果一条事务只被原 Leader 写入本地就宕机了,这条事务从未被提交。当原 Leader 恢复并作为 Follower 重新加入集群时,只要新 Leader 的日志里没有这条事务,它必须在恢复过程中被丢弃。
这一点通过数据同步阶段的 TRUNC 操作 保证:新 Leader 会以自己当前的最高 ZXID 为权威边界,发现 Follower 拥有自己没有的、Epoch 更老的脏事务时,会发送 TRUNC 指令要求其截断删除。
Plaintext
makefile
反面教材:如果不丢弃这些孤儿事务会怎样?
T1: 原 Leader 将事务 X 只发给了自己,就宕机了
T2: 新 Leader 产生,由于它没有收到事务 X,以没有 X 的状态继续处理新事务
T3: 原 Leader 恢复,以 Follower 身份重新加入集群
如果不清除原 Leader 本地的事务 X:
- 原 Leader(现在是 Follower)的状态包含 X
- 其他所有节点的状态不包含 X
- 集群状态永久不一致 💥
这两条保证合在一起,定义了 ZAB 崩溃恢复的核心语义:只要被多数派确认的一定保留;新 Leader 看不到的未提交脏数据一定丢弃。
六、ZAB vs Raft:殊途同归的两条路
两者的整体设计目标相同,但在几个关键点上做出了不同选择。
对比总览
| 维度 | Raft | ZAB |
|---|---|---|
| 模型 | 强 Leader | 强 Leader |
| 逻辑时钟 | Term | ZXID(Epoch + Counter 合一) |
| 选举策略 | 投票者主动拒绝落后 Candidate | 节点主动改投更优选票 |
| 故障恢复 | 先服务,边服务边同步 | 先同步,同步完再服务 |
| 脏数据处理语义 | 以新 Leader 为准抹除 Follower 脏数据 | 以新 Leader 为准抹除 Follower 脏数据 |
| 脏数据处理实现 | 递减 prevLogIndex 匹配并覆盖 | 发送 TRUNC 命令显式截断 |
| 日志传输保序 | prevLogIndex/Term 检查 | TCP FIFO 天然保序 |
| 快照机制 | InstallSnapshot RPC | SNAP 同步 |
| 典型应用 | Nacos, etcd, Consul | ZooKeeper |
选举策略:被动筛选 vs 主动靠拢
(发起选举)"]:::candidate R_V["🗳️ Voter
(投票者)"]:::voter R_C -->|"1.请投给我 (Term=3, Index=5)"| R_V R_V -.->|"2.拒绝投票 ❌
(我的Index=7更新)"| R_C end subgraph ZAB ["🤝 ZAB:主动靠拢 (节点向更优者看齐)"] direction LR Z_A["🖥️ 节点 A
(首轮投自己)"]:::nodeA Z_B["🖥️ 节点 B
(广播选票)"]:::nodeB Z_A -->|"1.广播选票(ZXID=...42)"| Z_B Z_B -->|"2.选票 PK
(我的 ZXID=...50 更大)"| Z_A Z_A -.->|"3.更新选票,改投 B ✅"| Z_B end %% 美化外层子图边框 (虚线、浅灰色背景) style Raft fill:#fafafa,stroke:#90caf9,stroke-width:2px,stroke-dasharray: 5 5,rx:10 style ZAB fill:#fafafa,stroke:#ce93d8,stroke-width:2px,stroke-dasharray: 5 5,rx:10
结果相同,路径不同:两种方式都能保证最终当选的是数据最新的节点,只是收敛路径不一样。Raft 靠投票者的拒绝机制,ZAB 靠节点自身的主动修正。
故障恢复速度:快上线 vs 先对齐
这是 ZAB 和 Raft 最显著的工程取舍:
(新 Leader 上任)"]:::elect --> R3["✅ 新 Leader 立即开始处理请求
【服务快速恢复】"]:::serve --> R4["🔄 后台异步修复落后的 Follower
(AppendEntries)"]:::sync end subgraph ZAB ["🤝 ZAB 的故障恢复:先同步,同步完再服务"] direction TB Z1["💥 Leader 宕机"]:::crash --> Z2["🗳️ 选举完成
(准 Leader 产生)"]:::elect --> Z3["⏳ 等待多数派数据同步完成
【这里有额外延迟】"]:::sync --> Z4["✅ 切换到广播模式
开始处理客户端请求"]:::serve end %% 美化外层子图边框 style Raft fill:#fafafa,stroke:#90caf9,stroke-width:2px,stroke-dasharray: 5 5,rx:10 style ZAB fill:#fafafa,stroke:#ce93d8,stroke-width:2px,stroke-dasharray: 5 5,rx:10
ZAB 的恢复时间更长,但换来的是:Leader 正式服务时,多数派数据已经整齐一致,系统状态更干净,减少了服务期间的修复负担。
对 ZooKeeper 这种存储少量关键协调数据的场景来说,这个取舍是合理的------宁可恢复慢一点,也要保证状态干净。
七、ZooKeeper 的架构设计
了解了 ZAB 协议后,我们来看 ZooKeeper 在工程层面是如何实现的。
读写分离
ZooKeeper 的读写策略和 Raft 系统(如 Nacos)有一个显著不同:
markdown
ZooKeeper 的读写路由:
客户端写请求 ──────→ Leader(或转发给 Leader)
│
▼
ZAB 广播提交
客户端读请求 ──────→ 任意节点(包括 Follower)直接返回
← 本地读,不走共识,极快
写:必须经过 Leader 走 ZAB 广播,保证线性一致性。
读 :可以在任意节点本地读取,不保证线性一致性------读到的可能是略微落后的数据。
这是一个大胆的取舍:ZooKeeper 的读操作不保证强一致,但极快 (本地 O(1) 内存操作,没有网络开销)。如果业务确实需要强一致性读,ZooKeeper 提供了 sync() API,调用后会强制同步到最新数据,再执行读取。
💡 这和 CAP 定理的关系 ZooKeeper 的读操作在网络分区时依然可以本地响应(保 A),但可能返回旧数据(牺牲 C)。写操作需要多数派(保 C),但少数派节点上的写会挂起(牺牲 A)。ZooKeeper 并不是简单的"CP 系统",而是针对读写分别做了不同的一致性/可用性取舍。
Observer:扩展读能力
ZooKeeper 引入了一种特殊角色:Observer。
(处理写请求/广播事务)"]:::leader F1["🛡️ Follower 1
(处理读/参与多数派投票)"]:::follower F2["🛡️ Follower 2
(处理读/参与多数派投票)"]:::follower O["👀 Observer
(处理读/不参与投票,仅扩展性能)"]:::observer end %% 客户端连接 C1 -->|"读/写请求"| L C2 -->|"读请求"| F1 C3 -->|"读请求"| O %% 集群内部通信 L ==>|"ZAB 广播事务"| F1 L ==>|"ZAB 广播事务"| F2 L -.->|"后台同步日志"| O %% 美化外层边框 style ZK fill:#fafafa,stroke:#bdbdbd,stroke-width:2px,stroke-dasharray: 5 5,rx:10
Observer 和 Follower 的区别:
- Observer 不参与投票,不计入多数派,不影响写入延迟。
- Observer 同步所有事务,可以响应本地读请求。
这意味着你可以无限增加 Observer 来扩展读吞吐量,而不会因为节点增多导致多数派门槛升高、写入变慢。Follower 通常维持在 3~5 个(保证容错即可),Observer 按读负载水平扩展。
总结
本篇完整介绍了 ZAB 协议和 ZooKeeper 的架构设计:
ZAB 的核心机制:
- ZXID:64 位整数,高 32 位是 Epoch(任期),低 32 位是 Counter(事务序号)。合二为一的设计让新旧比较变成简单的整数大小比较。
- 崩溃恢复(Recovery):分两阶段。先选举(按 ZXID > myid 规则主动收敛),再数据同步(DIFF 补数据、TRUNC 删多余、SNAP 全量同步),两阶段都完成才开始服务。
- 消息广播(Broadcast):正常服务阶段。类似两阶段提交(PROPOSAL -> 多数派 ACK -> COMMIT),并强依赖底层 TCP 协议的 FIFO 特性,保证事务严格按 ZXID 顺序处理。
- 两条底线:已提交的事务一定保留;新 Leader 看不到的未提交脏数据一定丢弃。
ZAB vs Raft 的核心差异:
- 选举策略:ZAB 主动靠拢(节点改投更优选票),Raft 被动筛选(投票者拒绝落后候选人)。
- 恢复策略:ZAB 先同步再服务(状态更干净),Raft 先服务边同步(恢复更快)。
ZooKeeper 的工程设计:
- 读本地、写共识,读不保证强一致(可用
sync()强制对齐)。 - Observer 角色扩展读吞吐量,不影响写入性能。
Raft 和 ZAB 都是优秀的工程级共识协议,只是出发点和侧重不同------Raft 追求可理解性,ZAB 为 ZooKeeper 的协调场景深度定制。它们殊途同归,最终都在各自的领域成为了事实标准。
下篇预告:
我们介绍的 Raft 和 ZAB 都属于强一致性方案:写入必须经过多数派,代价是延迟高、可用性有损。但现实世界中有大量场景可以接受"最终一致"------牺牲强一致性来换取极高的可用性和写入性能。
下一篇,我们将进入最终一致性的世界:Quorum NWR 的灵活配置、Gossip 协议的概率性传播,以及如何用向量时钟(Vector Clock)解决数据冲突。
思考题
- ZAB 的 ZXID 把 Epoch 和 Counter 合并成一个 64 位整数,而 Raft 分开存储 Term 和 Index。除了"比较方便"之外,这种合并设计还有什么潜在的问题?
参考答案
主要问题:Counter 空间有限
ZXID 低 32 位 Counter 最多表示约 42 亿个事务。理论上,如果一个 ZooKeeper 集群在同一个 Epoch 内处理了超过 42 亿次写操作,Counter 会溢出,与 Epoch 位发生冲突。
ZooKeeper 的应对 :ZooKeeper 在 Counter 即将溢出时,会主动触发 Leader 切换(重新选举),将 Epoch+1、Counter 清零,规避溢出风险。但这个设计依赖运维层面的预判,相比 Raft 的 Index 无上限更脆弱。
另一个问题 :Epoch 的快速消耗。如果集群不稳定,Leader 频繁切换,Epoch 部分(也是 32 位)也可能溢出,不过在实际场景中这几乎不可能发生。
总结:合并设计带来了比较上的简洁,但牺牲了扩展性,依赖业务量不会把任一维度撑爆的前提假设。Raft 分开存储的设计在这点上更鲁棒(Index 是无界递增的,Term 理论上也是)。
- ZAB 的数据同步阶段要求多数派完成同步后才能开始服务。如果一个节点同步特别慢,会一直阻塞等它吗?
参考答案
不会永远等------只要多数派完成同步就够了。
ZAB 的要求和选举一样:多数派(超过半数)Follower 完成数据同步即可 ,不需要等所有节点都同步完成。
具体流程 :
- 准 Leader 并行向所有 Follower 发送同步数据。
- 哪个 Follower 先完成,就先回复 ACK。
- 一旦收到多数派 ACK,准 Leader 立刻切换到广播模式,不再等剩余节点。
- 慢节点继续在后台接收数据,完成后自动加入广播流程。
但有一个超时兜底 :如果某个 Follower 连接超时(如网络故障),Leader 会放弃等待它,继续推进------只要不影响多数派即可。
总结:ZAB 的"先同步再服务"延迟的只是等多数派对齐的时间,而不是全体节点对齐的时间。这和多数派选举的逻辑是一致的。
- ZooKeeper 的读操作不保证强一致性(可能读到旧数据)。请举一个实际场景,说明这在什么情况下会出问题,以及如何解决?
参考答案
典型场景:分布式锁的释放
问题复现 :
- 进程 A 持有分布式锁(在 ZK 上写了一个节点 /lock)。
- 进程 A 释放锁(删除 /lock),写操作提交到 Leader 并广播。
- 进程 B 立刻尝试获取锁,读取 /lock 是否存在------但 B 的请求被路由到一个还没同步最新数据的 Follower。
- Follower 返回 /lock 仍然存在,进程 B 误认为锁未释放,不敢加锁。
解决方案 :在读取关键数据之前,先调用 sync() API。
sync() 的底层并非简单地去 Leader 查询一个同步点,而是向 Leader 提交一个异步的空事务(Dummy Transaction) 。由于 ZAB 协议的广播管道基于 TCP,严格保证 FIFO 顺序,当这个空事务到达该 Follower 并被处理时,Follower 就绝对确信:在这个空事务之前产生的所有写操作,都已经被自己应用了。此时再执行本地读取,就能保证读到最新的数据。
代价 :sync() 虽然不需要经过多数派共识,但依然需要经历一次"客户端 -> Follower -> Leader -> Follower"的空事务管道传输延迟。
工程实践 :ZooKeeper 的客户端库(如 Curator)在实现分布式锁等关键协调原语时,都会在必要时自动插入 sync(),开发者无需手动处理。
- 如果 ZooKeeper 集群有 5 个节点(1 Leader + 4 Follower),Leader 宕机了。在选举期间,整个集群对外不可用(读写均不响应)。这段不可用时间大约是多长?能缩短吗?
参考答案
典型不可用时长:200ms ~ 数秒
时间拆解 :
- 心跳超时检测 :Follower 发现 Leader 失联需要等待
tickTime × initLimit,默认配置下约 2~10 秒(可调)。这是最大的一块。 - 选举本身(Fast Leader Election):通常 200ms 以内,节点少时甚至更快。
- 数据同步:取决于落后量,毫秒到秒级不等。
缩短的方向 :
- 调小
tickTime和超时倍数,可以让心跳超时检测更快,但网络抖动下容易误判。 - 保持 Follower 数据不要落后太多,减少同步耗时。
- 使用 Observer 扩展读服务------Observer 在选举期间虽然也无法接受新写,但可以继续服务旧数据的读请求(取决于客户端配置)。
根本限制:完全消除不可用窗口是不可能的------这是 CAP 定理的必然结果。Leader 宕机触发选举,选举期间必须停止写入以避免脑裂,这段停顿时间只能优化,无法消除。