ZooKeeper 的 Leader 选举,基本是每个做分布式系统的人都会碰到的坎。很多人对它的理解停留在“挂了一台机器,集群能自动选个新主出来”这个层面,可真要追问一句:选票是怎么比较的?为什么有时候数据最全的节点反而选不上?新 Leader 已经选出来了,为什么客户端还是连不上?能接住的人就少很多了。这篇文章把我这些年看源码、做集群压测、排查线上选主问题的笔记重新整理了一遍,从设计逻辑讲到 FastLeaderElection 的代码主流程,再给你一套可以照着操作的真实环境验证方法,最后是高频故障的排查经验。内容以生产环境最常碰到的 3.4.x 到 3.6.x 分支为主,3.4.0 之后选举算法基本统一为 FastLeaderElection,所以你可以把这里的结论放心用在自己的集群上。
1. Leader 选举在 ZooKeeper 整体架构里到底干嘛的
1.1 没有动态选举,分布式协调服务根本没法做
很多人第一次接触 ZooKeeper 时,容易把它和普通的主从数据库混为一谈。主从数据库通常有一个固定 Master,写死了谁是主,主挂了就人工切换或者依赖外部 HA 软件。ZooKeeper 不能这么干,因为它的定位是给其他分布式系统提供强一致的协调原语——分布式锁、配置发布、服务注册,全都建立在“集群里必须有一个合法 Leader 来仲裁写请求”的基础上。
如果 ZooKeeper 本身的主节点还需要人工干预,那它就不是一个高可用的协调组件,而是一个随时可能变成单点的普通服务。固定 Master 方案在故障发生时有几个致命问题:一是故障检测往往依赖第三方脚本,容易误判;二是脚本切换时两个节点可能都认为自己应该接管写入,形成“双主”脑裂;三是切换逻辑没有一套所有节点都认同的纪律,数据一致性无从谈起。
ZooKeeper 的解决方案是让“谁当 Leader”这件事本身变成一个集群内所有节点共同参与的计算问题。每个节点都保存自己的身份标识、最新事务进度、当前所处的选举轮次,然后通过一套统一的投票协议去收敛出一个“大多数节点都认可的节点”。这套过程不依赖任何外部组件,不依赖人工配置,只要集群里还能凑出可用的大多数,它就能自动完成交接。这正是动态选举和固定主从最大的区别。
1.2 选举只解决“谁当 Leader”,完整恢复还要看 ZAB 协议
这里必须澄清一个常见误读:Leader 选举不等于 ZooKeeper 服务的完整恢复。很多人看到集群已经选出了新 Leader,但客户端写入还是报错,就开始怀疑选举逻辑出了问题。实际上,选举在 ZAB 协议的全流程里只占了一部分。
ZAB(ZooKeeper Atomic Broadcast)大致分成三个阶段:发现、同步、广播。选举解决的是“发现”阶段最核心的问题——让集群节点对谁是新的 Leader 达成一致。但这个新 Leader 产生之后,它还需要和集群里的其他节点做数据同步,确保自己没有丢任何已提交事务,也确保其他 follower 能把和 Leader 的差距补齐。只有同步完成,整个集群才进入正常广播阶段,可以对外提供读写服务。
理解这个分层非常重要。我见过不少调优的人,把“选主慢”的所有原因都归到选举算法上,最后发现其实是新 Leader 启动后的同步阶段迟迟没能完成。所以在看日志时,要分清当前到底卡在哪个环节:是节点一直处于 LOOKING 状态选不出主,还是已经进入了 LEADING/FOLLOWING 状态但数据同步一直不结束。
1.3 哪些情况会触发 Leader 选举
触发选举的入口,在 ZooKeeper 源码里最终都会落到节点状态的改变上,最常见的有以下几类:
- 集群刚启动,还没有任何节点处于 LEADING 状态,所有节点都要通过选举产生第一个 Leader。
- 运行过程中 Leader 节点宕机,或者 Leader 和集群内大多数节点之间的网络中断,导致 Leader 失去法定人数支持。
- Follower 长时间没有收到 Leader 的心跳或数据同步消息,认为 Leader 已不可用,主动发起新一轮选举。
- 人工维护操作,比如滚动重启某个节点,或者通过 jstack 等工具定位问题后不得不 kill 掉 Leader 进程。
- Leader 节点自身在运行中发现已经无法满足过半机制,也会主动从 LEADING 状态退到 LOOKING 状态,而不是继续硬撑着接受写入。
有一个很有意思的细节:运行中的 Leader 如果发现自己能联系到的节点已经不足半数,它会主动让出 Leader 位置,不会傻傻地继续服务。这一点和很多人的直觉相反——“不是别人把我投下去,我才下台;而是我自己知道自己已经不代表大多数了,先退位再说”。这套自降级逻辑是防脑裂的第一道闸门。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 三个决定胜负的值:myid、zxid、epoch,以及选票怎么比大小
2.1 myid:节点的身份牌
在每个 ZooKeeper 节点的数据目录 dataDir 下,有一个名为 myid 的文件,里面就一个整数,比如 1、2、3。QuorumPeer 启动时读到这个值,把它作为当前节点在整个集群中的唯一标识。配置文件中 server.1=host:port:port、server.2=host:port:port 的编号,就是和这个值对应的。
myid 在选举里扮演的角色有点像身份证号里的尾号:它在比较选票时是最后一级的“破平局”手段。两个节点的事务进度完全相同,票数也相同,那么 myid 较大的节点会被认为更优。但请记住,myid 永远不是第一优先级,数据最新、任期最新才是。
实操中有个很常见的低级事故:myid 文件里不小心写了空格,或者两台机器的 myid 重复了。前三台可能不明显,一旦你扩容一台恰好复制了旧节点的 dataDir,又忘记改 myid,那两台节点会以同一个身份出现在集群中,会造成投票混乱。检查的时候不要只 content 文件,还要注意文件里是否有隐藏字符。
2.2 zxid:数据的进度条
zxid 全称 ZooKeeper Transaction Id,它是一个 64 位的整数,用于标识每个事务请求。高 32 位叫做 epoch,代表当前 Leader 的任期编号;低 32 位是事务计数,代表在这个任期内处理到第几个事务。
举几个直观的例子:
0x0000000000000001:第一个任期的第一个事务。0x0000000100000001:第二个任期的第一个事务。高位加了 1,低位重新从 1 开始计数。0x100000001换算成十进制是 4294967297,所以你在 mntr 输出里看到的zk_last_processed_zxid = 4294967297,其实对应的是0x100000001。
这个“任期加事务计数”的结构设计是刻意的。如果只用全局递增计数,一旦老 Leader 恢复网络,它可能又拿旧的计数继续产生事务,新老 Leader 之间就会产生冲突。现在每个新 Leader 上任,都会从上一个任期的最大 zxid 继续,但高 32 位的 epoch 会立刻加一,这样所有节点可以一眼看出某个事务是哪个 Leader 任期内产生的。
选举时比较 zxid,本质上是在问:谁的日志最全,谁最近处理过的事务最多?在“保证不丢已提交数据”这个目标下,让日志最新的节点来当新 Leader 是成本最低、最安全的选择。它不是随便抓一个活着的节点顶上。
2.3 epoch:每次选主后递增的“任期号”
epoch 这个词在源码里会出现多个地方,容易看晕。选举场景里,你至少要分清两个概念:
- peerEpoch:Leader 的任期编号。每个新 Leader 产生时都会在前一个任期基础上加一,对应 zxid 的高 32 位。
- electionEpoch:选举轮次编号,也叫 logicalclock。每开始一轮新的选举,这个值会加一,用来区分“这是哪一轮的投票”。如果收到了上一轮遗留的投票消息,节点可以立刻判断应该丢弃。
可以类比一下:公司换新 CEO,叫 peerEpoch 递增;公司要重新选举 CEO 这件事本身开过几次会,叫 electionEpoch 递增。一个是“谁来领导”的结果标记,一个是“当前决策流程进行到哪一轮”的过程标记。
在 FastLeaderElection 里,节点进入选举后,会把自己当前投票中的 peerEpoch 加一,然后广播出去。收到别人投票时,如果对方消息里的 electionEpoch 小于自己当前的 logicalclock,说明这条消息是老一轮的残党,直接忽略,避免旧的投票结果干扰新一轮决策。
2.4 totalOrderPredicate 源码比较逻辑
选举选票的胜负规则集中在一个方法里:totalOrderPredicate。下面是 ZooKeeper 3.6.x 里的核心逻辑,我做了一些注释。
java复制protected boolean totalOrderPredicate(long newId, long newZxid, long newEpoch,
long curId, long curZxid, long curEpoch) {
// 如果自己当前选票的 peerEpoch 大于对方,说明对方数据已经是旧任期的,
// 自己当前的选票依然更优,不需要改票。
if (self.getCurrentVote().getEpoch() > newEpoch) {
return true;
}
// 对方选票的 epoch 更新,无条件改投给对方。
// 这里不比较 zxid,原因是任期编号越新,意味着对方经历过更新的 Leader 周期,
// 更有可能包含完整的事务历史。
if (self.getCurrentVote().getEpoch() < newEpoch) {
return false;
}
// epoch 相同,则比较 zxid,事务进度更新的节点胜出。
if (newZxid > curZxid) {
return false;
}
if (newZxid == curZxid) {
// zxid 也相同,用 myid 打破平衡,sid 大的节点胜出。
if (newId > curId) {
return false;
}
}
return true;
}
这个方法初看容易把人绕晕,因为你必须理解返回值的含义:返回 true 表示“当前持有的选票更好,继续保持”,返回 false 表示“对方选票更好,我需要更新自己的投票”。
结论用一句话概括就是:先比 epoch,epoch 大者胜;epoch 相同,比 zxid,zxid 大者胜;epoch 和 zxid 都相同,比 myid,myid 大者胜。
注意,网上很多文章会说“zxid 优先于 myid”,其实漏了 epoch 这一层。如果 epoch 不同,zxid 根本不会进入比较环节。这背后的道理是:高 32 位的 epoch 本身就反映了 Leader 任期的新旧,一个更新的任期往往意味着它的日志更完整,直接采用“任期优先”可以更快收敛,也天然防止了旧 Leader 用旧的 zxid 抢回位置。
3. FastLeaderElection 工作流程源码级剖析
3.1 算法演进:为什么最终只剩 FastLeaderElection
ZooKeeper 的历史里其实出现过多种选举实现。早期的 LeaderElection 基于 UDP 设计,实现简单但 UDP 不可靠,需要大量重传和超时补偿。后来出现的 FastLeaderElection 改用 TCP,在选票投递的可靠性上有了本质提升。
在 3.4.0 之前,配置项 electionAlg 可以选不同的算法,默认值也曾经变动过。到 3.4.0 之后,FastLeaderElection 成为默认推荐算法,旧算法逐步废弃。现在绝大多数生产环境跑的就是 FastLeaderElection。
我把它们放在一起对比一下:
| 实现 | 通信方式 | 特点 | 状态 |
|---|---|---|---|
| LeaderElection | UDP | 实现简单,但丢包会影响收敛速度 | 早期版本,已废弃 |
| AuthFastLeaderElection | TCP | 带认证的选举,实际生产中很少使用 | 兼容阶段,不建议选 |
| FastLeaderElection | TCP | 收敛快、实现健壮,是当前事实标准 | 3.4.0 之后默认算法 |
如果你在较老的配置里看到 electionAlg=0 或 electionAlg=1,建议尽快升级配置或换版本。在新版本里,这类值基本已经走不通,老老实实用默认值最安心。
3.2 一次完整选举的五个阶段细看
FastLeaderElection 的核心逻辑收敛在 lookForLeader 方法里。一次完整的选举我可以拆成五个阶段来看。
第一阶段是状态切换和初始化。节点发现自己和 Leader 失联,或者启动时发现集群里没有 Leader,就会把自己的状态切到 LOOKING,然后调用 lookForLeader。这个过程中,节点会先更新自己的 logicalclock,也就是选举轮次加一,然后把当前投票内容初始化为投给自己,携带自己最新的 zxid 和递增后的 epoch。
第二阶段是广播投票。节点把自己的投票封装成 Notification,通过 QuorumCnxManager 发送给集群里的其他投票节点。发送目标不是所有节点而是投票视图里的成员,Observer 不参与这个环节。
第三阶段是接收和处理别人的投票。节点从 recvqueue 队列里取出收到的 Notification,先校验是否来自合法的投票成员,再检查对方的 electionEpoch 是否落后。如果对方投票比当前节点的投票更优,调用 totalOrderPredicate 判断后,就更新自己的投票,并再次广播出去。这一轮消息会持续在网络中传播,直到所有节点收敛到同一个候选人。
第四阶段是统计选票。FastLeaderElection 并不是一收到投票就立刻统计,而是要判断当前已经收到了多少个“跟自己选票一致”的投票。判断条件是:收到的票投给同一个 candidate,并且这些投票的来源数量已经达到了当前 quorum 的要求。
第五阶段是确认 Leader。选主一旦在过半节点上达成一致,节点就会结束 lookForLeader 循环,根据自己是不是候选 Leader 决定进入 LEADING 还是 FOLLOWING 状态。注意,这还没完,新 Leader 要继续执行 lead() 里的数据同步流程;Follower 要连接到 Leader 并开始同步数据。等 ZAB 的同步阶段结束,集群才真正可用。
3.3 关键线程与队列:Messenger 的职责
看源码时,FastLeaderElection 内部有两个很重要的线程角色:WorkerSender 和 WorkerReceiver,它们合起来被称为 Messenger。
WorkerSender 的工作是不断从 sendqueue 队列里取出要发送的 Notification,再通过底层连接发送到对应节点。因为网络发送是异步的,选票更新后只要往 sendqueue 里塞一条消息就行,不用阻塞当前选主逻辑。
WorkerReceiver 的工作是接收底层网络传来的消息,进行初步解析后放入 recvqueue 队列。它还会对消息来源做检查,如果不是当前集群投票成员,会滤掉。
逻辑上,选举主循环只关心两个队列:发消息丢进 sendqueue,等消息从 recvqueue 里拿。这种生产者-消费者模型有个很大的好处:把网络 I/O 和选主逻辑解耦。选主的核心比较计算不会被一个慢速的网络请求拖住,吞吐量能维持稳定。
网上讨论这个模型时会提到一个词叫“消息风暴”。当多个节点同时发现 Leader 宕机,所有节点都会在短时间内互相广播投票。如果没有队列缓冲,可能会导致连接堆积和线程阻塞。ZooKeeper 里每个节点之间都有独立的长连接,消息会经过底层队列按顺序投递,这能有效避免同一条连接上的乱序问题。
3.4 连接管理在 3.5 之后的改进
3.5.0 是一个重要版本节点。之前版本的选举连接管理在某些网络抖动场景下会出现连接无法及时回收的问题,比如旧 Leader 虽然已经下台,但它与其他节点之间的选举连接可能还占着资源,新选出的 Leader 又要建立新连接,严重时可能造成选举一直无法收敛。
3.5.0 之后,QuorumCnxManager 做了很大重构。每对服务器之间尽量只保留一条选举连接,如果发现已经存在了一条到对端的连接,新来的重复连接会被主动关闭。这个策略减少了双方同时互建连接导致的资源浪费,也避免了我在实际故障中遇到过的“两边都在等对方断开,结果谁也不先动”的僵局。
除此之外,选举阶段如果用 TCP 建立连接迟迟不成功,ZooKeeper 会按一定规则重新发起。3.5 之后的实现里,在节点试图连接远端失败后,会先判断是否存在同样方向的有效连接,如果存在就不再反复新建,而是复用已有通道。这个细节直接影响了领导者切换后的自愈速度。
3.5 Observer 不投票,那它做什么
Observer 是 ZooKeeper 里一种特殊的节点。它不参与投票,所以在选举统计 quorum 时不计入法定人数。它也不会成为 Leader,永远以 Observer 身份跟随当前 Leader 同步数据。
那它有什么用?主要用来扩展读能力,或者把“只读副本”部署到离客户端更近的位置。因为 Observer 不参与写请求的仲裁,增加再多的 Observer 也不会改变集群的容错上限,但它能有效分担读流量。
因为 Observer 不参与投票,所以一个集群里 Observer 的宕机、重启、网络隔离,都不会触发 Leader 选举。这点在故障排查时非常关键:有些运维同学看到 Observer 节点断了,就担心整个集群要重新选主,其实不会。真正会触发选主的,是集群里参与投票的那部分节点失联,导致当前 Leader 得不到多数派的支持。
4. 动手观察一次真实选主
4.1 环境准备与配置要点
要理解选举,光看代码不够。最好自己搭三个节点,在生产机房或者你自己的 VMware 里跑一下真实故障。
先准备三台虚拟机,假设 IP 分别是 10.0.0.11、10.0.0.12、10.0.0.13。在每台机器上解压 ZooKeeper,然后编辑 conf/zoo.cfg。配置如下:
properties复制tickTime=2000
initLimit=10
syncLimit=5
dataDir=/data/zk
clientPort=2181
server.1=10.0.0.11:2888:3888
server.2=10.0.0.12:2888:3888
server.3=10.0.0.13:2888:3888
这里两个端口要搞清楚。2888 端口是 Follower 连接 Leader 时用来同步数据、建立 Zab 通道的;3888 端口是选举期间各节点互相通信用的。生产上安全组或防火墙规则如果只放开了 2181 和 2888,一旦真的需要重新选主,选举消息会在 3888 端口上被拦掉,集群就会卡在 LOOKING 状态出不来。现实里很多人排查了半天选主问题,最后发现是防火墙端口没放。
接着创建数据目录,并写 myid 文件:
bash复制mkdir -p /data/zk
echo -n "1" > /data/zk/myid
注意我这个命令用了 -n,目的就是避免字符串带换行。虽然 ZooKeeper 读文件时会做 trim,但建议从一开始就不要制造这种模糊地带。三台机器分别写 1、2、3。
4.2 从 stat/mntr 看当前谁是 Leader
启动三台节点后,用 ZooKeeper 自带的四字命令来看状态。Linux 下可以先用 yum install nc 装好 netcat,然后执行:
bash复制echo mntr | nc 127.0.0.1 2181
输出的关键字段如下:
text复制zk_version 3.6.4
zk_server_state leader
zk_last_processed_zxid 0x100000001
zk_server_state 会是 leader 或 follower。我自己的习惯是写一个小脚本,遍历三个 IP 都执行一次,哪个返回 leader 就一目了然。
如果你想看得更细,也可以执行 echo stat | nc 127.0.0.1 2181,里面有一行 Mode: leader。
4.3 杀掉 Leader,观察自动恢复过程
找到 Leader 后,我们来人工模拟宕机。这里我推荐先执行 kill,不要直接 kill -9,因为程序退出前会做一些状态清理;如果你关心的是崩溃场景,kill -9 更能模拟进程突然消失。实际操作中我会两种都测,重点看日志和恢复时间差异。
假设 Leader 是 10.0.0.11,执行:
bash复制kill -9 <leader_pid>
然后立刻观察 10.0.0.12 和 10.0.0.13 的日志。正常情况下,十几秒内会出现类似下面的变化:
text复制INFO ... PeerState changed to [ LOOKING ]
INFO ... Looking for new leader, myid=2
INFO ... LEADING - LEADING ELECTION TOOK ...
如果用的是 3.5 以上版本,日志文件位置可能在 logs/zookeeper-<user>-server-<hostname>.out,老版本则是启动脚本指定的 zookeeper.out。
故障恢复后重新执行 mntr,你会发现原来的 Follower 之一已经变成 leader,并且 zk_server_state 发生了切换。
为了验证新 Leader 是否产生新任期,再执行一下这个命令:
bash复制printf '0x%x\n' $(echo mntr | nc 127.0.0.1 2181 | awk '/zk_last_processed_zxid/{print $2}')
输出会看到高 32 位已经比原来的 epoch 大。这就直观地验证了前面说的 zxid 结构:新 Leader 上任,任期编号递增。
4.4 打开 DEBUG 看 Notification 的关键日志
如果你想把选票的往来过程看得更清楚,可以把 ZooKeeper 的日志级别临时调整到 DEBUG。3.6.x 默认日志框架是 logback,配置文件通常在 conf/logback.xml。把下面这行的级别改成 DEBUG:
xml复制<logger name="org.apache.zookeeper.server.quorum" level="INFO"/>
改成:
xml复制<logger name="org.apache.zookeeper.server.quorum" level="DEBUG"/>
改完重启节点后,再做一次 Leader 故障演练,你会在日志里看到很多 Notification 相关的输出,包含我的 sid、对方的 sid、选举轮次等关键信息。生产环境不建议这么做,DEBUG 日志量极大,会把磁盘瞬间写满。如果你想在生产观察,可以临时只对某个 IP 开一个独立的 appender,操作复杂度较高,我的建议是搭一套测试环境来抓日志。
4.5 从 zxid 变化验证新 Leader 的“任期递增”
我在生产环境定位问题时,习惯先看两个值:当前 Leader 的 zxid,以及每个 Follower 的 zxid。如果一台 Follower 的 zxid 和 Leader 差距很大,说明这个 Follower 可能长期处于离线状态,或者网络质量不好导致同步阻塞。
选主完成后,新 Leader 的 zxid 高位会比你 kill 之前看到的旧值至少大 1。例如旧 Leader 在 0x100000010,新 Leader 接管后很快会走到 0x200000001 或更高。这个变化标志着一个新任期正式开始。以后你再看到这个十六进制数,就不会只是把它当一个大整数,而是能一眼拆出任期和事务计数。
5. 常见选举故障与排错经验
5.1 少数派分区为什么永远进入不了“已选主”状态
5 个节点组成的集群,假设发生了网络分区:3 个节点能互相通信,2 个节点只能彼此通信。3 个节点那半边,因为能凑出 quorum,它们会继续工作甚至选出新 Leader。而那 2 个节点那半边,即使它们内部能够互相通信,也会发现无论如何都达不到 3 票的多数要求,于是会一直处于 LOOKING 状态,无限发起选举。
这不是 Bug,而是刻意设计的“宁可不可用,不可脑裂”原则。如果少数派也能自选主,那同一份数据在两边同时被改,等网络恢复后冲突将不可调和。ZooKeeper 宁可让少数派节点停机保数据,也不能让它们对外继续服务。
排障时的判断方法很简单:用 mntr 看少数派节点,会发现它们一直处于 looking 状态。此时先不要纠结选举算法,优先查网络连通性和交换机状态;网络恢复后,这些会节点自动回到集群中,并同步补齐数据。
5.2 集群各节点看到的成员列表不一致会怎么样
ZooKeeper 的配置文件里,每个节点都维护了一份服务器列表。理想情况下所有节点看到的 server.1、server.2 必须完全一致。某个节点因为升级或误操作,配置文件里多了一台机器或少了一台机器,会导致两个危害。
一是 quorum 的计算基准不一致。A 节点认为集群是 5 个节点,必须要 3 票;B 节点认为集群是 3 个节点,只要 2 票就能选出 Leader。两边对“谁合法”的判断完全不同,选主结果会反复横跳。
二是投票集合不一致。有些节点会把选票发给某个在别人看来根本不存在的节点,接收到未知投票后,FastLeaderElection 会过滤掉,但这个过程会消耗额外时间,造成选举收敛变慢。
遇到这种情况,建议把各节点 zoo.cfg 里的 server 列表逐行 diff,并检查 dataDir 下的 myid 是否唯一。这是最基础也最容易忽略的问题。
5.3 加了 Observer 之后配置错误的坑
Observer 在 zoo.cfg 里有一个很典型的写法,它和普通节点的配置有些区别。先看这段:
properties复制server.1=10.0.0.11:2888:3888
server.2=10.0.0.12:2888:3888
server.3=10.0.0.13:2888:3888
server.4=10.0.0.14:2888:3888:observer
如果要把节点 4 配置成 Observer,必须指定 :observer 后缀。如果忘了加,ZooKeeper 默认会把它当成参与投票的 Follower 节点,这会改变集群的法定人数。举例来说,原来三节点集群加一台 Observer 后仍然只需要 2 票达成 quorum;但如果把它误配成 Follower,集群就变成了 4 个投票节点,需要 3 票才能选出 Leader,容错上限反而下降了。
5.4 日志中反复出现 LOOKING 的排查思路
处理过多次选主问题后,我总结了一个实战排查顺序。每次看到节点反复在 LOOKING 状态切换,不会直接改选举超时参数,而是先按顺序查这些点:
| 检查项 | 具体操作 | 预期结果 |
|---|---|---|
| 统计 quorum 的节点数量 | 所有参与投票的节点是否互相能连通 3888 端口 | 各节点互通 |
| 网络分区 | telnet 各节点 3888 端口,看是否有丢包 | 稳定连接 |
| 防火墙策略 | 是否有安全组只放通了 2181 但没放通 2888/3888 | 全部放通 |
| myid 冲突 | 检查所有参与投票节点的 dataDir/myid | 不存在重复 |
| server 列表一致性 | 逐行 diff 每台节点的 zoo.cfg | 完全一致 |
| Observer 配置错误 | 搜索配置中是否漏掉 :observer 后缀 | observer 节点不计入 quorum |
按这个表格排查,绝大多数“反复选主”都能找到直接原因。先不要动算法参数,绝大多数问题不是算法参数造成的,而是节点连通性或配置不一致造成的。
5.5 关于“旧 Leader 假死恢复”的一点实战体会
最后我想聊一个线上最容易引起混淆、也最考验功底的场景。假设旧 Leader 因为长时间的 Full GC 或者瞬时网络抖动,在一段时间内无法响应其他节点。新 Leader 被选出来了并开始处理请求。这时候旧 Leader 恢复了,它发现自己还能收到其他节点的消息,而自己的 zxid 并没有落后太多。它会不会尝试抢回 Leader 位置?如果抢,会不会造成双写?
答案是:不会。因为新一轮选主里,所有节点广播的 epoch 已经比旧 Leader 的任期新。旧 Leader 即使从 GC 中恢复,它收到的选票或 Leader 消息携带的 peerEpoch 也更大,按照 totalOrderPredicate 的比较逻辑,它会主动认可新 Leader 并转为 Follower,去新 Leader 那边同步自己缺失的事务。
这就是 epoch 机制最大的价值。它不靠物理时钟、不靠人工标记,而是靠逻辑上的“任期编号递增”来天然阻止旧 Leader 篡位。我在做故障演练时,最喜欢人为制造这种“旧 Leader 延迟恢复”,然后观察它是否乖乖降级。只要 epoch 递增逻辑没有被破坏,旧 Leader 永远不会在新任期内重新成为 Leader。
这个知识点听起来抽象,但真正理解了它,你才能解释为什么 ZooKeeper 在极端网络分区下依然能保证不出现双主,也才会明白为什么配置里意外重复的 myid 那么危险——因为一旦两个节点使用相同 sid 但又都认为自己代表了不同的角色,epoch 再大也救不回来。
