图 1:本文封面——Leader 倒下之后,集群靠什么规矩选出新的话事人。
先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。「Zookeeper 如何选举 Leader」是协调服务方向的招牌题,它真正考察的是两件事:选票怎么比,以及新 Leader 凭什么保证不丢已提交的数据。
想象面试现场:「Zookeeper 集群的 Leader 选举流程讲一下?Leader 崩溃了怎么办?」
第一步,先立两个核心概念:ZXID 与 epoch。
Zookeeper 给每个事务提案盖一个全局唯一的编号 ZXID,64 位整数:高 32 位是 epoch(朝代号,每换一届 Leader 加一),低 32 位是该朝代内单调递增的计数器。ZXID 越大,说明这个节点见过的提案越新。epoch 的作用是划朝代——老 Leader 复活后带着旧 epoch 的编号回来,一眼就能被认出是「前朝遗老」,它的提案不会被接受。
第二步,选举流程:LOOKING、投票、比较、过半。
节点启动或发现 Leader 失联后进入 LOOKING 状态,开始投票。规则很朴素:
第一,每个节点先投自己一票,选票内容是自己的三元组(epoch,ZXID,myid 节点编号)。第二,收到别人的票之后按固定顺序比较:先比 epoch,朝代新的赢;epoch 相同再比 ZXID,数据新的赢;ZXID 也相同才比 myid,编号大的赢。发现自己的票不如对方,就改投对方。第三,某个节点得票超过集群半数,即当选 Leader,其他节点进入 FOLLOWING,集群恢复服务。
为什么是这个比较顺序?因为选举的真正目标不是「选编号最大的」,而是「选数据最全的」——ZXID 最大意味着它见过最新的提案,由它当 Leader,恢复时丢数据的风险最小。
第三步,崩溃恢复:先同步,再服务。
新 Leader 选出后不能立刻接客,要走一段恢复期:它与过半节点交换各自的历史提案,把已提交但部分节点没同步到的提案补发下去,落后太多的直接发全量快照。这里有一条硬规矩:新 Leader 会发起一个 NEW_LEADER 提案,等过半节点确认并提交之后才开始接受新的写请求。同步完成才对外服务——这是「已提交提案一条不丢」的最后一道闸门。
为什么这样就不丢?两个事实叠加:提案提交要过半节点确认,选举获胜也要过半——两个「过半」必然有交集,交集节点身上一定有最新已提交的提案;新 Leader 又是其中 ZXID 最大的,天然掌握全部已提交历史。至于某些节点多出来的、没来得及提交的提案,恢复时直接丢弃——它们从未获得过半确认,本来就不算数。
第四步,用代码把选举规则跑一遍。
下面这份代码模拟了选票比较与过半判定,跑三个场景:全员存活、挂一台、挂两台。用 Node.js 22.15.0 直接运行(node zk-election.js)即可:
'use strict';
// 模拟 Zookeeper FastLeaderElection 的选票比较与过半判定
// 选票三元组:(epoch, zxid, myid)。比较顺序:先 epoch,再 zxid,最后 myid。
// 注意:过半线按集群配置的总台数算,不是按当前存活台数算。
function compareVote(a, b) {
if (a.epoch !== b.epoch) return a.epoch - b.epoch;
if (a.zxid !== b.zxid) return a.zxid - b.zxid;
return a.myid - b.myid;
}
function elect(ensembleSize, alive) {
const quorum = Math.floor(ensembleSize / 2) + 1;
const votes = alive.map((s) => ({ ...s })); // 每个节点手里的当前最优票,初始是自己
let changed = true;
while (changed) { // 模拟投票消息互达收敛的过程
changed = false;
for (let i = 0; i < votes.length; i++) {
for (let j = 0; j < votes.length; j++) {
if (compareVote(votes[j], votes[i]) > 0) {
votes[i] = { ...votes[j] }; // 发现别人的票更优,改投
changed = true;
}
}
}
}
const tally = new Map();
for (const v of votes) {
const key = `myid=${v.myid}`;
tally.set(key, (tally.get(key) ?? 0) + 1);
}
const [leader, count] = [...tally.entries()].sort((a, b) => b[1] - a[1])[0];
return { leader, count, quorum, elected: count >= quorum };
}
// 场景一:三台全员存活,myid 最大的数据反而最旧(epoch 落后一代),数据最新的当选
const cluster = [
{ myid: 1, epoch: 5, zxid: 0x5000000a },
{ myid: 2, epoch: 5, zxid: 0x5000002b }, // 数据最新
{ myid: 3, epoch: 4, zxid: 0x4fffffff }, // epoch 落后一整代
];
let r = elect(3, cluster);
console.log(`三台全活:${r.leader} 得 ${r.count} 票(过半线 ${r.quorum}),${r.elected ? '当选' : '流选'}`);
// 场景二:三台挂一台,剩两台仍达到过半线 2,正常选出
r = elect(3, cluster.slice(0, 2));
console.log(`三台挂一剩二:${r.leader} 得 ${r.count} 票(过半线 ${r.quorum}),${r.elected ? '当选' : '流选'}`);
// 场景三:三台挂两台,剩一台达不到过半线,集群暂停写服务
r = elect(3, cluster.slice(0, 1));
console.log(`三台挂二剩一:${r.leader} 得 ${r.count} 票(过半线 ${r.quorum}),${r.elected ? '当选' : '无法当选,集群暂停写服务'}`);
实测输出:
三台全活:myid=2 得 3 票(过半线 2),当选
三台挂一剩二:myid=2 得 2 票(过半线 2),当选
三台挂二剩一:myid=1 得 1 票(过半线 2),无法当选,集群暂停写服务
场景一值得多看一眼:myid=3 编号最大,但 epoch 落后一整代,连入围资格都没有——选举比的是数据新旧,不是编号大小。场景三则展示了过半机制的代价:三台集群最多容忍一台故障,挂两台就连选举都凑不齐票数,宁可停写也不冒险分裂。
图 2:选举四步与崩溃恢复三步,右侧是比较规则速查卡。
可能的追问与易错点。
「为什么集群一般部署奇数台?」三台和四台一样只能容忍一台故障,四台多花钱不提升容错。「和 Raft 的选举什么关系?」目标相同、规则不同——Raft 按任期号加日志完整性投票,Zookeeper 按 epoch 加 ZXID 投票,细节不展开,记住「过半加数据最新者优先」这个共性就够。易错点主要有二:把过半线按存活台数算(实际按配置总台数算);以为选完 Leader 立刻能服务,漏掉 NEW_LEADER 提交前的同步恢复期。
这套机制在 AI 基础设施里天天在用:模型注册中心、特征存储、任务调度器背后的协调服务,都要回答「Leader 挂了怎么办」。回到面试现场,建议的答题节奏:先立 ZXID 与 epoch 两个概念,再走选举四步,然后用「两个过半必相交」解释为什么已提交提案不丢,最后补恢复期的同步闸门。你被问过「为什么选举要比 ZXID 而不是只比 myid」吗?欢迎聊聊你的答法。