图 1:本文封面,顺序来自全局唯一且递增的事务编号。
先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。「Zookeeper 如何保证事务的顺序一致性」是分布式协调方向的高频题。集群里好几台服务器,客户端随便连哪台都能写,凭什么所有节点最后按同一个顺序记账?答案藏在三个机制的咬合里:ZXID 编号、两阶段广播、过半写。
第一步,顺序从哪来:ZXID 与单一发号人。
顺序一致的前提是存在一个全局公认的顺序。Zookeeper 的做法是:每个写事务在成为提案时被盖上一个 ZXID(Zookeeper Transaction Id),一个 64 位数字——高 32 位是 epoch(朝代号,每次换 Leader 加一),低 32 位是本朝代内的计数器,Leader 每发一个提案就递增一次。关键设计是发号权独占:任意节点收到写请求都会转给 Leader,只有 Leader 能生成 ZXID。一个发号人加一个单调递增的编号器,顺序天然唯一,不存在「两个提案争同一个位置」的问题。再加上服务器之间的通信走 FIFO 通道(TCP 的有序交付),提案按编号顺序发出、按顺序落盘、按顺序确认,全链路的顺序就保住了。
第二步,一次写怎么走:两阶段广播。
正常运行期,一个写请求的路径是四步。第一步,客户端把写请求发给任意节点,若该节点不是 Leader,转发给 Leader。第二步,Leader 为这个写生成提案,盖上递增的 ZXID,按编号顺序广播给所有 Follower。第三步,Follower 收到提案先写日志落盘,再回 ACK——注意落盘在确认之前,确认意味着「这条提案我持久化了」。第四步,Leader 收到的 ACK 凑够过半(quorum 大小为 n/2+1,Leader 自己算一票)就广播 COMMIT,所有节点按同一 ZXID 顺序把事务应用到内存状态机。
这套流程类似两阶段提交,但没有中止分支:过半确认即提交,不足过半就一直等。为什么过半就够?因为任意两个过半集合必然至少相交于一台服务器,已提交的提案一定在下一次选举产生的多数派视野里——这是「已提交不丢」的数学依据(选举与崩溃恢复的细节属于另一个话题,本文不展开)。
第三步,用代码把流程跑一遍。
下面这份代码模拟了迷你版 ZAB 广播:Leader 发号、Follower 落盘回 ACK、过半提交、按序应用,再实测「一台 Follower 假死仍能提交」的过半特性。用 Node.js 22.15.0 直接运行(node zab.js)即可:
'use strict';
// 迷你 ZAB 广播模拟:Leader 发号(ZXID = epoch:counter)、两阶段提交、按序应用
function makeZxid(epoch, counter) {
return { epoch, counter, toString: () => `${epoch}:${counter}` };
}
class Follower {
constructor(name) {
this.name = name;
this.log = []; // 已落盘的提案
this.applied = []; // 已提交应用的事务
}
onProposal(zxid, data) {
this.log.push({ zxid: zxid.toString(), data }); // 落盘后回 ACK
return 'ACK';
}
onCommit(zxid) {
const entry = this.log.find((e) => e.zxid === zxid);
if (entry) this.applied.push(`${zxid}->${entry.data}`);
}
}
class Leader {
constructor(followers, quorum) {
this.followers = followers;
this.quorum = quorum; // 过半 = n/2+1
this.epoch = 3;
this.counter = 0;
}
propose(data) {
const zxid = makeZxid(this.epoch, ++this.counter); // 全局递增编号
const acks = new Set();
// 第一阶段:按编号顺序把提案广播给所有 Follower,各自落盘后回 ACK
for (const f of this.followers) {
if (f.onProposal(zxid, data) === 'ACK') acks.add(f.name);
}
// 第二阶段:ACK(含 Leader 自己一票)凑够过半就广播 COMMIT
if (acks.size + 1 >= this.quorum) {
for (const g of this.followers) g.onCommit(zxid.toString());
return { zxid: zxid.toString(), committed: true, acks: acks.size + 1 };
}
return { zxid: zxid.toString(), committed: false, acks: acks.size + 1 };
}
}
// 场景 1:三台节点、quorum=2,三个写提案按序提交
const f1 = new Follower('F1');
const f2 = new Follower('F2');
const leader = new Leader([f1, f2], 2);
for (const data of ['注册模型A', '注册模型B', '下线模型A']) {
const r = leader.propose(data);
console.log(`提案 ${r.zxid}(${data}):ACK=${r.acks}/${3},${r.committed ? '已提交' : '未提交'}`);
}
console.log(`F1 应用顺序:${f1.applied.join(' | ')}`);
console.log(`F2 应用顺序:${f2.applied.join(' | ')}`);
// 场景 2:一个 Follower 假死不回 ACK,仍凑够 quorum 提交;过半线决定了可用性
const f3 = new Follower('F3');
f3.onProposal = () => 'TIMEOUT'; // 模拟假死
const leader2 = new Leader([new Follower('F4'), f3], 2);
const r = leader2.propose('切换配置');
console.log(`一个 Follower 假死时:提案 ${r.zxid},ACK=${r.acks}/3,${r.committed ? '仍凑够过半、已提交' : '无法提交'}`);
实测输出:
提案 3:1(注册模型A):ACK=3/3,已提交
提案 3:2(注册模型B):ACK=3/3,已提交
提案 3:3(下线模型A):ACK=3/3,已提交
F1 应用顺序:3:1->注册模型A | 3:2->注册模型B | 3:3->下线模型A
F2 应用顺序:3:1->注册模型A | 3:2->注册模型B | 3:3->下线模型A
一个 Follower 假死时:提案 3:1,ACK=2/3,仍凑够过半、已提交
输出说明了顺序一致性的两个侧面:所有正常节点的事务应用顺序完全一致(3:1 先于 3:2 先于 3:3);集群容忍少数派故障,三选二的过半线意味着挂一台不影响写。
第四步,过半写的代价与读的边界。
过半不是免费的:一次写要等多数派确认,延迟取决于多数派里最慢的那台,节点越多写越慢——这是元数据写路径延迟的来源。读的边界更要记牢:Zookeeper 的写是线性一致的,但默认读走本地节点、不过半数派,可能读到旧值,官方给的一致性承诺是「顺序一致」而非线性一致。要强一致的读,常见做法是先调 sync 让本地跟上 Leader 再读,或者直接对 Leader 读。所以「先注册的模型先生效」成立(同一客户端的写严格按发送顺序应用),但「注册完立刻从任意节点读到」不保证。
图 2:写路径四步与过半写、读边界的完整图景。
可能的追问与易错点。
「为什么不用所有节点都确认?」全员确认意味着任何一台慢或挂都阻塞写,可用性塌方;过半是可用性与一致性的折中点。易错点有两个:把顺序一致性说成强一致读,忽略默认读可能返回旧值;以为 ZXID 只是计数器,忽略高 32 位 epoch 在换 Leader 时保证新旧朝代提案不混淆的作用。回到面试现场,答题节奏建议:先给 ZXID 结构,再走写路径四步,然后讲过半与交集论证,最后主动交代读边界与 sync 模式。你在用协调服务时踩过「读到旧值」的坑吗?欢迎分享。