深蓝黑背景封面:左侧大标题「所有节点,按同一个顺序记账」,副标题「ZXID、两阶段提交与过半写」;右侧原创示意——一条竖直的流水编号 ZXID 41、42、43 自上而下,三台服务器节点在右侧依次对齐同一编号序列;左上标注「面试经典 · 系统设计」,右上标注「Day 03 · 10-21」 图 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 读。所以「先注册的模型先生效」成立(同一客户端的写严格按发送顺序应用),但「注册完立刻从任意节点读到」不保证。

协议流程图:上方写路径四步——客户端写请求到任意节点后被转给 Leader;Leader 生成全局递增的 ZXID(高 32 位 epoch 朝代号、低 32 位本朝代计数),把提案按编号顺序广播给所有 Follower;Follower 落盘写日志后回 ACK;收到过半 ACK 后 Leader 发 COMMIT,所有节点按同一 ZXID 顺序应用事务。下方左侧说明顺序从哪来:ZXID 全局递增且只有 Leader 能发号,顺序天然唯一;下方右侧说明过半写的含义与读边界:写要等多数派确认所以延迟受最慢多数派影响,默认读走本地节点、可能读到旧值,要强一致读需要先 sync 或直连 Leader 图 2:写路径四步与过半写、读边界的完整图景。

可能的追问与易错点。

「为什么不用所有节点都确认?」全员确认意味着任何一台慢或挂都阻塞写,可用性塌方;过半是可用性与一致性的折中点。易错点有两个:把顺序一致性说成强一致读,忽略默认读可能返回旧值;以为 ZXID 只是计数器,忽略高 32 位 epoch 在换 Leader 时保证新旧朝代提案不混淆的作用。回到面试现场,答题节奏建议:先给 ZXID 结构,再走写路径四步,然后讲过半与交集论证,最后主动交代读边界与 sync 模式。你在用协调服务时踩过「读到旧值」的坑吗?欢迎分享。