排查 ZooKeeper 集群问题时,十次有八次最后都会绕到 Leader 选举上。三台机器明明都活着,为什么客户端突然报 ConnectionLoss?一台节点重启之后,为什么整个集群的角色全部翻了一遍?这些现象背后的原因,往往不是某段代码写错了,而是选举过程中的某个细节没有按预期走。
这篇文章只围绕一件事展开:Apache ZooKeeper 的 Leader 选举机制。我会从为什么必须选主、选举比较时到底在看哪些字段、Fast Leader Election 的完整流程、网络层如何交换选票,一直讲到日常运维里最常见的故障排查。没有分布式系统基础也能看,读过之后再去处理生产环境的选主问题,思路会清晰很多。
1. 为什么 ZooKeeper 非得选一个 Leader 出来
1.1 写请求的“总编辑”不能缺席
ZooKeeper 对外提供的是类似文件系统的树形结构,但它真正的价值不在于存储,而在于给分布式系统提供一个全局一致的协调视图。分布式锁、服务注册发现、配置中心、分布式队列,这些场景都依赖同一个前提:所有客户端对某个数据节点的变更顺序,必须是全局唯一的。
这个全局顺序怎么保证?靠的就是 Leader。
在 ZooKeeper 集群里,任意一台 Follower 收到写请求后,并不会自己处理,而是先把请求转发给 Leader。Leader 收到请求后会分配一个全局唯一的事务编号 zxid,然后通过 ZAB 协议把提议广播给所有 Follower。只有超过半数的节点确认写入成功后,这次写操作才算成功。
如果去掉 Leader,让每台节点都可以直接接受写请求,两台节点同时拿到相同的数据目录做修改,它们各自产生的版本号和写入顺序就无法统一。客户端在节点 A 上读到的是 v1,在节点 B 上读到的却可能是 v2,协调服务的顺序一致性就名存实亡了。
可以这么理解:一个群里要发通知,需要有一个群主统一排号。没有群主,每个人都能发,每个人看到的消息顺序就会乱。Leader 干的活,就是给每一条事务排号。
1.2 不是所有分布式系统都需要“选主”
有人会问,Kafka、Redis Cluster 都选主,MySQL 主从也选主,这好像没什么特别的。但选主不是一件理所当然的事。很多追求 AP 的系统,允许各节点暂时不一致,最终通过补偿和同步达到一致,这种情况下不一定需要全局唯一的写入节点。
ZooKeeper 是典型的 CP 型系统,它宁可短暂不可用,也不能让不同节点对外呈现不一致的数据。选出一个唯一的 Leader,是它实现顺序一致性和线性写的基本前提。所以“Leader 选举机制”不是 ZooKeeper 的一个辅助功能,而是整个一致性协议的基石。
ZooKeeper 选主不是很多人以为的“选个老大出来发号施令”这么简单,它真正的难点在于:怎么保证选出来的新 Leader 拥有最完整的数据?怎么防止两个节点都认为自己是 Leader(也就是脑裂)?怎么在节点反复启动、网络分区、假死恢复的情况下依然能收敛到一个正确的 Leader?
这些问题,下一页开始逐个拆。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 选举里的三个硬通货:epoch、zxid、myid
2.1 zxid:判断谁的数据“最新”
在 ZooKeeper 里,每个事务都会被打上一个 64 位的 zxid。这个 zxid 并不是一个简单的自增数字,它由两部分组成:高 32 位是 epoch,低 32 位是计数。
每个 Leader 任期开始时,epoch 会加 1,计数从 0 重新累计。所以每换一个 Leader,zxid 的高位都会变化,相当于开启了一个新的“朝代”。这样做的好处是,即使旧的 Leader 恢复过来,它带着旧 epoch 的事务也无法混进新任期。
投票时比较 zxid 的大小,本质上是在判断哪个节点的数据更新。每个 Follower 成功同步一条事务后,它本地记录的最新 zxid 就会前进。zxid 越大,说明这个节点看到的事务越多,数据越接近 Leader 的最新状态。
选主时必须选 zxid 最大的节点,因为只有它最有可能拥有所有已经提交的事务。如果随便选一个数据落后的节点当 Leader,它可能连以前提交的事务都没有,更别提带着整个集群往前走。
2.2 两个 epoch 千万别搞混
看 ZooKeeper 源码时你会发现有两个地方都会出现 epoch,一个是选举轮次(electionEpoch),一个是事务 epoch(也就是 zxid 高 32 位)。
electionEpoch 是逻辑时钟,每一轮选举都会递增。它的作用是标识“这轮选举的新旧”。如果一台节点收到了一个 electionEpoch 更大的投票,说明对方已经进入了一轮新的选举,自己之前收集的选票全部作废。
zxid 高 32 位的 epoch 则是 Leader 任期的代际。它随着新 Leader 的产生而递增,用来区分不同 Leader 任期下的事务。
这两个概念容易混淆,但本质上都在做同一件事:区分版本新旧。前者区分的是“选举论战”第几轮,后者区分的是“事务历史”第几代。面试和源码阅读时,只要把这两层关系理清楚,后面看 FastLeaderElection 的代码就顺了。
2.3 myid:只在平局时登场
myid 是每台 ZooKeeper 节点的身份标识,通常配置在数据目录下的 myid 文件里。它是一个整数,在 zoo.cfg 的 server 列表里要能找到对应的配置。
投票时如果两个人的 epoch 相同、zxid 也相同,数据进度完全一致,这时候怎么分高下?靠 myid。myid 大的节点会被优先选择。
这个设计是为了避免选票永远势均力敌,选不出结果。实际运行中,myid 并不常起到决定作用,真正比较的优先级永远是 epoch 优先、其次 zxid、最后才是 myid。很多刚接触 ZooKeeper 的人以为“谁的 myid 大谁当 Leader”,这是不对的,只有在数据进度完全相同的时候,myid 才会成为最后那个破局的砝码。
三个字段的比较优先级可以用这张表概括:
| 比较目标 | 比较规则 | 谁优先 |
|---|---|---|
| epoch | 越大代表进入更新选举轮次 | 大者优先 |
| zxid | 越大代表数据越新、事务越全 | 大者优先 |
| myid | 仅在前两者相同的情况下打破僵局 | 大者优先 |
3. Fast Leader Election 从发起投票到尘埃落定
3.1 哪些情况会触发选举
选举不是常态运行的机制,它只在节点处于 LOOKING 状态时才会发生。触发场景主要有三种。
第一种是集群首次启动。所有节点都是 LOOKING,互相不知道谁是 Leader,需要通过一轮完整的投票选出一个初始 Leader。
第二种是运行中的 Leader 失联。Follower 在 syncLimit 定义的心跳时间内收不到 Leader 的消息,会认为 Leader 已经挂了,于是把自己的状态切到 LOOKING,发起新一轮选举。
第三种是 Leader 自身失去多数派支持。Leader 需要持续收到超过半数 Follower 的确认,如果由于网络分区,Leader 身边只剩下一两个 Follower,它无法再获得法定人数的支持,会主动退位重新进入 LOOKING。这种情况最容易引起运维困惑,因为 Leader 进程可能还“活着”,CPU 和内存都正常,但它已经无法履行 Leader 的职责。
3.2 一张选票里装了什么
每次参与选举,节点广播的投票消息包含几个核心信息:自己的 myid、自己本地最近一次提交的 zxid、当前选举轮次 epoch、当前状态。在源码中会封装为 Vote 对象。
投票本质上就是一台节点大声喊出来:“我认为目前最合适当 Leader 的是节点 X,它所拥有的数据到 zxid Y,这轮选举是我们的第 Z 轮。”
注意一个细节:节点初始投票通常是投给自己。但这只是一个起点,不是终点。选举过程中节点会根据收到的其他投票不断修正自己的选择,最后选出大多数人公认的那个节点。
3.3 一轮选举的完整时序
以全新启动的三个节点为例,完整的选举过程可以拆成六步。
第一步,节点进入 LOOKING 状态,把自己的选举轮次加一,生成初始投票,投票对象是自己。
第二步,节点把这张投票通过选举端口发送给集群中的所有其他节点。
第三步,节点不断从网络队列中接收其他节点发来的投票。每收到一张票,都会做一次比较。如果这张票携带的 epoch 比自己的大,说明自己已经落后于选举轮次,需要更新逻辑时钟。如果 epoch 相同,就继续比较 zxid。对方的 zxid 更大,说明对方数据更新,自己应该放弃当前的投票对象,改投对方。如果 zxid 也相同,再比较 myid,myid 大的胜出。
第四步,节点把自己的选票修正为比较后的胜者,并把这张修正后的选票再次广播出去。
第五步,节点统计所有收到的投票。如果某个候选者获得了超过半数的选票,当前节点就认为它当选了。
第六步,节点把最终投票结果通知给其他节点,同时进入 LEADING 或 FOLLOWING 状态。如果自己是当选者,进入 LEADING,开始等待 Follower 连接;如果不是,进入 FOLLOWING,准备同步新 Leader 的数据。
这里有个容易忽略的点:一个节点“认为自己选出了 Leader”不代表选举立刻结束。当选者需要确保其他节点也知道这个结果,才能正式对外提供服务。如果只是部分节点知道了结果,剩余节点还在 LOOKING 状态到处找 Leader,集群依然是不完整的。
3.4 为什么“超过半数”这么重要
多数派是整个 ZooKeeper 选举机制最核心的防线。
假设一个集群有五台节点,提交一个事务时 Leader 需要至少三台节点确认,客户端才会收到成功响应。如果这时候 Leader 宕机了,剩下的节点需要选出新 Leader。因为这条事务已经被三台节点保存,而新 Leader 只需要联系上三台存活节点,就至少有一台节点保留着这条事务。所以新 Leader 可以从那个节点上把事务同步回来,不会丢失任何已经确认成功的数据。
反过来,如果一个事务只在 Leader 本地写成功了,还没来得及同步给任何 Follower,Leader 就宕机了,新 Leader 当选后不会包含这个事务。从客户端的角度看,这次写操作本来就没有收到成功响应,所以它不算“已提交数据”,丢了也不会破坏一致性。
这种“两个多数派必有交集”的数学性质,是多数派协议能够成立的基础。它保证了两件事:选举只会产生一个 Leader,并且新 Leader 不会丢已经确认的事务。
3.5 新 Leader 产生之后,还要做什么
很多人以为选举流程结束,新 Leader 就可以直接对外服务了。实际上 FLE 选的只是一个“候选人”,它还需要通过数据同步阶段来确认所有 Follower 的数据一致性。
新 Leader 确定后,原来的 Follower 会连接到新 Leader,发送自己当前的 zxid。新 Leader 对比双方的 zxid 后,会给出差异处理方案:如果 Follower 少了事务,就把差量事务推给它;如果 Follower 因为旧 Leader 的原因多了一些没有被确认的事务,就执行截断,把多余的部分回滚掉。
这个阶段保证了所有节点的数据状态收敛到新 Leader 的视角。只有大部分 Follower 完成同步之后,新 Leader 才会宣布自己可以接受写请求。所以一个节点从选举开始到真正可写,中间还有一段不可忽略的窗口期,生产环境评估可用性时要把它算进去。
4. 网络层和源码里的几个关键设计
4.1 QuorumCnxManager:选票的高速公路
投票消息不是凭空传输的,它依赖 ZooKeeper 内部一个专门的网络层组件 QuorumCnxManager。每个节点在启动时会监听两个端口,一个是同步端口,用于 Follower 连接 Leader 同步数据,另一个是选举端口,专门用来传输投票消息。
QuorumCnxManager 内部维护了多个队列。简单来说,每个节点都有对应的发送队列和接收队列。这样设计的好处是,单个节点的网络抖动不会阻塞其他节点的消息传递。某个节点响应慢了,消息会堆积在它自己的发送队列里,不影响整个选举过程的推进。
还有一个很值得讲的细节:ZooKeeper 为了减少连接数量,做了一个优化——每个节点只主动连接比自己 myid 大的节点。这样原本全互联需要 N 平方条连接,现在只需要 N(N-1)/2 条,直接砍半。同时避免了两个节点互相建连造成的资源浪费。
4.2 lookForLeader 主循环的核心逻辑
FastLeaderElection 里最核心的方法是 lookForLeader,它体现了上面说的完整比较逻辑。这里贴一段伪代码,不是源码,方便理解。
java复制while (running) {
// 1. 更新逻辑时钟,投自己一票
updateProposal(myid, zxid, currentEpoch);
sendNotifications();
// 2. 从队列取其他节点的投票
while ((message = recvQueue.poll()) != null) {
if (message.electionEpoch > currentEpoch.get()) {
// 有人进入了新一轮选举,清空旧票,以新轮次为准
}
Vote result = compareVote(message.vote, myVote);
if (result != myVote) {
// 发现更合适的节点,改票并广播
updateProposal(result);
sendNotifications();
}
// 3. 统计票数,超过半数就结束
if (hasAllQuorum()) {
return result;
}
}
}
这段逻辑的核心思想就是“从善如流”:我先投自己,如果发现有比我数据更新、更适合当 Leader 的节点,我立刻改投它,同时把改票结果告诉别人。整个集群的选票会像潮水一样迅速向最合适的节点收敛。
看源码时不需要记住所有类名,抓住几个关键变量就行:收到的投票是否比我新、选票是否过半数、最终选出的结果是什么。这三个问题贯穿了 lookForLeader 的整个生命周期。
5. 实例推演:不同集群规模下,谁的话语权最大
5.1 三节点集群首次启动,Leader 如何出现
假设三台节点 myid 分别是 1、2、3,zoo.cfg 里配置了三个 server。先启动节点 1,它进入 LOOKING,投自己一票。一票显然达不到两票的法定要求,节点 1 会一直等待其他节点加入。
接着启动节点 2。节点 2 加入后也投自己一票,然后与节点 1 交换投票。如果两台节点的 zxid 都是 0,epoch 也相同,比较 myid,2 比 1 大,所以节点 1 会改成投 2。此时节点 2 获得了自己的一票和节点 1 的一票,共两票,刚好达到三节点集群的半数要求,节点 2 会成为 Leader。
此时节点 3 还没有启动。集群其实已经可以对外提供服务了,但只有两台节点在线,容错能力为零。节点 3 稍后启动,它会发现某个节点已经处于 LEADING 状态,并且获了法定的两票,于是节点 3 不会发起新一轮竞争,而是以 Follower 的身份加入集群。
这个例子揭示了一个重要特性:先启动的节点未必吃亏,后启动的节点也未必有机会当 Leader。谁能拿到过半选票,谁才是最终赢家。
5.2 五节点运行中 Leader 宕机,新 Leader 怎么定
五节点集群中假设 myid 为 3 的节点是 Leader,epoch 是 5,当前最新的 zxid 是 0x50000020。Leader 突然宕机,剩下四台 Follower 在心跳超时后进入 LOOKING。
四台节点本地的事务进度不一定完全相同。有的 Follower 可能还没来得及同步最后一个事务,zxid 停留在 0x5000000f;有的运气好,已经同步到 0x50000020。投票开始后,zxid 为 0x50000020 的节点拥有天然优势。如果两台节点的 zxid 都是 0x50000020,比如 myid 4 和 5,那么 myid 更大的 5 会胜出。
最终节点 5 会获得超过三票吗?从数学上说,四张票中不可能有两个节点各自获得三票。所以在没有网络分区的情况下,选举必然收敛到一个 Leader。最终谁当选,取决于多数节点最终把票投给了谁。这里不存在“必须由 myid 最大者当 Leader”的规定,数据进度和投票时序都会影响结果。
生产环境里经常有人问:为什么重启了一台低 myid 的 Follower,Leader 反而变了?答案往往是那台 Follower 虽然 myid 低,但它在宕机前拥有其他节点更完整的 zxid,或者它恢复后以新 epoch 发起了新一轮选举。这不是故障,是选举机制在按数据完整度重新评估领导权。
5.3 Leader 假死、网络分区:过半机制如何护住底线
Leader 假死是最考验选举机制的场景,也就是所谓“脑裂”的源头。
五节点集群,Leader 是 myid 3。因为一次长时间的 Full GC,节点 3 暂时停止了心跳,Follower 等待超时后判定它失联,进入 LOOKING,并很快选出了新的 Leader。
此时节点 3 恢复了,它醒来后依然认为自己是 Leader,试图继续给其他节点发送心跳和事务。但其他节点已经在新 Leader 的领导下工作,不会承认旧的 Leader。节点 3 发现自己无法从多数节点获得确认,只能退出 LEADING,重新变为 Follower,并同步新 Leader 的数据。
这个机制说明了过半设计的意义:老的 Leader 即使恢复过来,也因为无法凑齐多数派而自动退位。集群里绝对不会出现“两个人都拿到多数票”的情况,因为两个不同的多数派集合必然会有重叠,而重叠的节点不可能同时支持两个不同的 Leader。
6. 高频问题排查与配置加固
6.1 Leader 频繁切换,先看日志和网络
Leader 频繁切换在运维场景里非常常见,尤其是集群规模较大或者上云之后。现象是日志里隔一段时间就会出现一次 LOOKING,随后又变成 LEADING,客户端大量断连。
遇到这种问题,第一步不是改参数,而是看监控。重点关注三个方面:网络延迟和丢包、磁盘响应时间、JVM GC 暂停时间。
ZooKeeper 的心跳依赖网络和磁盘,Follower 必须在 syncLimit 规定的时间内收到 Leader 的消息,否则就认为 Leader 失联。如果网络本身有抖动,或者事务日志落盘太慢,Leader 的响应就会超时,引发一次误判选举。
我遇到过几次“Leader 频繁切换”的案例,最后定位到都不是 ZooKeeper 的问题,而是宿主机网卡软中断过高、磁盘 IO 被其他业务抢占、或者 JVM 在持久代 GC 时暂停过久。调大 syncLimit 只能缓解症状,根本解法是把 ZooKeeper 部署在稳定的环境里,并且单独使用一块 IO 能力足够的磁盘放 dataLogDir。
6.2 节点一直 LOOKING,始终选不出主
如果集群里有节点长时间处于 LOOKING 状态,先不要怀疑算法出了问题。大概率是选票不够半数。
选举时每个节点都在统计“有多少人投票给我”,这是通过选举端口完成的。如果某些节点的 3888 端口被防火墙挡了,或者配置的 server 列表 IP 不被其他节点访问,投票就送不出去。几个节点各投各的,各自认为自己是最合适的 Leader,但票数永远达不到多数派,选举就会一直卡住。
排查方法很直接:在节点上执行查看监听端口,确认 2888 和 3888 端口处于监听状态。然后用 telnet 或 nc 从一台节点访问另一台节点的 3888 端口,看能不能通。最怕出现的情况是节点之间配的是 localhost 或内网 DNS 别名,其他机器根本连不上。
另一种隐蔽的原因是多个节点之间 zoo.cfg 不一致。比如节点 A 配的集群成员是 1、2、3,节点 B 配的是 1、2、4,它们之间互相不认账,选票自然无法形成统一的多数据派。
6.3 别把 myid 和数据目录配错
myid 文件的位置在 dataDir 下,文件名就叫 myid,内容是一个数字。多实例部署时最容易踩的坑,是把同一个数据目录复制给多个实例,导致所有节点的 myid 都一样。表面上集群起来了,实际投票时完全错乱,甚至出现两个节点认为自己拥有同一个身份。
正常部署时,每台节点的 dataDir 必须独立,myid 文件必须与 server 列表一一对应。容器化部署时尤其要注意,别把 myid 写死在镜像里,否则就是跨环境事故。
还有一个操作层面的提醒:dataDir 下有 version-2 目录,里面保存着 currentEpoch 和 acceptedEpoch 文件。这两个文件记录了当前任期的 epoch 信息。如果误删或手工修改,节点启动时会看到错误的历史状态,可能跳过应有的数据同步,造成日志和快照不一致。清理数据目录是一个非常危险的操作,必须完整备份后再处理。
6.4 Observer 节点会影响选举吗
Observer 是 ZooKeeper 里一种特殊的角色,它同步数据、提供读服务,但不参与 Leader 投票。
引入 Observer 的目的是为了扩展读能力而不增加写路径的负担。一个 Observer 挂掉或者加入,都不会影响 Leader 选举的法定票数。这对部署架构来说很有用,比如跨机房场景下,可以把 Observer 部署在远端机房,让客户端就近读取数据,又不用增大写确认的等待集合。
配置 Observer 时有三点容易出错:peerType 要设置为 observer;server 行里要把对应节点的端口后追加 :observer;另外要把法定投票节点数计算清楚。有人把投票节点数误算成包括 Observer 的总节点数,导致集群对容错能力的预期完全错误。
6.5 几个调优参数和我的建议
| 参数 | 默认值 | 作用 | 排障建议 |
|---|---|---|---|
| tickTime | 2000ms | 基础时间单元,影响心跳与会话超时 | 保持 2000,不随意改 |
| initLimit | 10 | Follower 初始连接和同步允许的 tick 数 | 如果节点启动时数据量很大,可适当调大到 20-30 |
| syncLimit | 5 | Follower 与 Leader 心跳允许的 tick 数 | 网络不稳时可调到 10 左右,但故障发现会变慢 |
| autopurge.purgeInterval | 0(关闭) | 事务日志和快照自动清理周期 | 建议设为 24,防止磁盘被旧日志占满 |
调参不能只看一个参数。syncLimit 乘以 tickTime,就是 Follower 等待 Leader 心跳的最长时间。如果这个值小于环境中可能出现的最大 GC 暂停时间,误判就会发生。一般建议这个值至少是 JVM 最大暂停时间和网络最大抖动的两倍以上。
7. 回顾几个值得记住的经验
文章写到这,核心技术点基本都覆盖了。最后从我自己的维护经验里挑几条觉得特别值得提醒的。
第一,不要试图用 “让某台机器的 myid 配得很大” 来指定 Leader。myid 只能在数据进度相同时起作用。真正决定谁是 Leader 的,是它手里有没有最新的数据和能不能在投票窗口内凑齐多数票。人为干预 Leader 归属的最好做法,是保证该节点的数据及时同步,在大多数场景下 zxid 领先的节点会自然而然胜出。
第二,要接受选举期间的一小段不可用窗口。Leader 切换往往在几百毫秒到几秒之间,这期间集群无法提供写服务,客户端可能会遇到 ConnectionLoss。生产环境的客户端必须配置合理的会话超时和重连策略,不能把 ZooKeeper 客户端当成 T 型数据库连接那样用完就丢。
第三,了解日志比乱试参数重要。ZooKeeper 日志中如果出现 “LOOKING”“LEADING”“FOLLOWING” 的频繁变化,代表集群在经历角色翻转。先结合监控看网络、磁盘、GC 这三个外部指标,再考虑调参。数据目录的 currentEpoch 和 acceptedEpoch 也可以提供很多信息,判断当前任期是从哪个 epoch 开始的。
第四,如果是准备面试,不要只背“投票时比较 zxid 和 myid” 这一句话。完整地说清楚“先比 epoch、再比 zxid、最后比 myid,并且得票数必须超过半数才能当选”就已经超过了大多数人。如果再能补一句“选举完成之后还有数据同步阶段,Follower 通过 DIFF 或 TRUNC 流程把自己对齐到新 Leader”,基本可以说明对选举机制有系统理解。
ZooKeeper 的 Leader 选举机制本质上是一套围绕多数派的胜者通吃协议。它把数据完整性、故障转移和脑裂防制都收敛到一个简单的比较规则里,简单却极其精巧。理解这套机制之后,再去观察生产环境中的各类角色切换,就不会觉得它像一个黑盒了。
