1. 先搞清楚选举在网络层和算法层分别干什么
写这个系列前几篇的时候,有朋友问我,zookeeper启动流程里最绕的是不是就是leader选举那段,投票消息发来发去、然后又收回来,最后怎么又选出一个leader了。说实话,如果只盯着某一个类的代码看,确实容易绕进去,因为选举这个过程本身被拆成了两层在跑:一层是网络通信层,负责把投票消息从一台机器送到另一台机器;另一层是算法决策层,负责生成投票、比较投票、判定结果。两层通过队列和线程解耦,各干各的活,代码读起来反而不像一条直线,更像两条并行推进的流水线。
这篇继续上一讲的启动流程,专门把“投票发送”和“投票响应”这条链路完整拆开。核心涉及两大块:FastLeaderElection这个算法类,以及QuorumCnxManager这个连接管理器。前者看得懂谁当leader、为什么你推荐他而我推荐我;后者看得懂投票消息到底怎么从本机进程内存,一步步变成字节流、塞进socket、最后出现在另一台机器的队列里。把这两块拼起来,整个选举的闭环就清楚了。
适合什么人看:你已经知道zookeeper选票的三要素(epoch、zxid、myid),也知道LOOKING、LEADING这些状态大概是什么,但读源码的时候见到ToSend、Notification这些类就懵,不知道它们之间怎么流转的。这期的目标就是帮你把这些“中间零件”全部对接上。
2. 三个核心类不弄清楚,后面全白读
FastLeaderElection内部有几套数据结构,是理解选举绕不过去的。我用一次完整选举的视角把它们串起来。
2.1 Vote:真正参与比较的选票对象
先看Vote。这个类代表“一台机器投给谁”的完整结果,你从代码里能看到它主要封装了这些字段:
id:被投节点的server id。zxid:被投节点最近一次的事务id(含epoch部分)。electionEpoch:当前选举轮次。peerEpoch:法定人数确认过的epoch。state:投出这张票的节点当前状态。
读源码的时候注意一点:Vote虽然叫选票,但它在算法里更多的是一种“意见表达”。每个节点启动后,会根据自己掌握的最新数据产生一张Vote,然后试图让所有人接受自己的意见。谁的zxid最大,谁就有资格当leader,这个“大”是总体的比较规则,后面会展开。
有相当一部分资料会把Notification和Vote混在一起说,实际上它们分工完全不同:Vote是算法层内部用的,Notification是节点之间传消息时用的,中间还夹着一个ToSend。
2.2 ToSend:发送侧的“待收件消息”
ToSend是发送时临时封装的一个结构,代码里能看到它的构造方法很长,参数包括sid(发给谁)、state、leader(推荐谁)、zxid、electionEpoch等。它的作用和信封差不多:把要发的内容打包,然后交给QuorumCnxManager去传。
为什么要单独搞一个ToSend,不直接用Notification?我的理解是Notification是给接收方反序列化用的,里面带了接收方关心的各种状态字段;ToSend则在发送侧做了一次完整的组装,后续直接序列化成ByteBuffer就可以写socket了。职责单一,方便各自演进。
2.3 Notification:接收侧的消息还原
Notification是接收方收到消息后还原出的对象。它在原有消息基础上多了一些读取侧关心的字段,比如leader、proposedLeader,以及判断轮次用的electionEpoch、peerEpoch。WorkerReceiver从网络队列里取到消息后,调messageToNotification把它转成Notification,之后算法层才拿它和本地Vote做比较。
光记住“Vote用于比较、Notification用于传递、ToSend用于发送”这层关系,再读后面代码就不太会出现“看半天不知道在取谁的值”的困惑了。
3. 投票发送链路:一票从产生到落地的全过程
接下来走一条完整链路:本机决定投谁,到对方机器能在队列里拿到这票,中间到底发生了什么。
3.1 lookForLeader里的初始广播
每一次选举都是从lookForLeader开始的。这个方法先把自己置为LOOKING状态,更新logicalclock,然后调用sendNotifications把当前自己推荐的Vote广播给集群所有节点。
这里有一个容易忽略的点:logicalclock在每次“我发起新一轮选举”时都会自增。它是用来区分“你说的是这次选举的事,还是上次选举的事”的关键。你在源码里看到logicalclock++,本质就是在说“本次选举开始”。不同节点的logicalclock不一定一样,因为可能别人比你更早进入新一轮选举,后面处理Notification时会拿它做轮次判断。
sendNotifications的实现其实很简单,就是遍历所有可能的对端sid(通过getVotingView拿到的法定成员表),逐个构建ToSend,然后交给底层发送。注意它发给的对象是“所有能投票的节点”,不是只看数据中心内的某几个,因为这和quorum定义有关。
3.2 QuorumCnxManager的队列体系
QuorumCnxManager才是真正动手发消息的类。它内部维护了几层队列,读的时候要特别留心:
queueSendMap:按目标sid分组的发送队列,每个目标一个ConcurrentLinkedQueue<ByteBuffer>。recvQueue:统一的接收队列,所有从socket读到的消息都先进这里,等待分发。senderWorkerMap:记录目标和SendWorker的对应关系。
这里的核心思路是异步化。你调用send方法并不是真的立刻把数据写到网络流里,而是先按sid放进对应的queueSendMap队列,然后由后台SendWorker线程不断从这个队列里取数据往socket里写。好处是上层算法不用卡在网络耗时上,发完票就能继续处理其他事情。
3.3 sendNotification到底往哪发
FastLeaderElection.sendNotification大概长这个样子(简化后):
private void sendNotification(ToSend notification) { QuorumCnxManager cnxManager = qp.quorumCnxnManager; cnxManager.send(notification.data, notification.sid); }这里的notification.data其实是一个ByteBuffer,notification.sid是目标server id。cnxManager.send的干活逻辑大致是:
- 先从
queueSendMap里拿到对应sid的发送队列,没有就创建一个。 - 检查是否已建立连接,如果没有就触发连接建立流程。
- 把
ByteBuffer塞进队列。
你可能会问,连接什么时候建?zookeeper采用的是启动时统一尝试建连,以及在有消息要发但还没连接时再触发建连的机制。两个节点之间只允许存在一条长连接,这是通过“大头连小头”的规则保证的(if (sid < selfId) { 主动连 } else { 等待对端连我 })。这个规则得仔细看一下,它直接决定了pair连接不会重复建立,也能避免集群节点同时互相发连接请求导致连接风暴。
3.4 SendWorker怎么把票写进Socket
SendWorker是真正把ByteBuffer发出去的地方。它在循环里做两件事:
- 从发送队列里取出一条ByteBuffer。
- 通过包装了socket的
DataOutputStream写二进制数据。
写的时候用的是长度前缀方式:先写一个int表示消息长度,再写具体内容。接收侧对应的是RecvWorker,先读长度,再读对应字节,还原成Message。
我最初读这段代码时觉得奇怪,队列里明明有消息,SendWorker为什么还要加一个定时轮询的兜底逻辑?后来在调试一个断连场景时明白了:如果对端断开,队列里堆积的消息会越积越多,必须有机制把发不出去的旧消息清掉,否则会影响后续重连后的正常投票。所以这块代码不只是“发消息”这么简单,里面还包含连接状态异常的清理逻辑。
3.5 底层连接的鉴权和超时心跳
不能忽略的是,QuorumCnxManager里建立连接后,还会做超时检查,定时发送ping消息或接收对端的心跳。这其实就是zookeeper集群内部保证“对方还活着”的手段。选举投票的时效性非常依赖这条链路是健康的,如果因为网络抖动导致消息发不出去,SendWorker卡住或者队列堆积,你会在日志里看到大量超时或者重连记录。
这里给个排查经验:如果你发现某个节点一直没有收到别人的选票,先别急着去看算法层,用jstack看一眼这个节点的SendWorker和RecvWorker线程是什么状态。如果线程阻塞在socket读写上,大概率是网络层面的问题;如果线程在正常跑但队列空,才是算法层没发出去的锅。分清楚这两类,能少浪费很多排查时间。
4. 投票响应链路:收到选票之后,算法层做了什么
发送只是半条路,另一半是接收方怎么处理收到的投票。响应链路的起点在RecvWorker,终点回到FastLeaderElection.lookForLeader的while循环里。
4.1 RecvWorker怎么读消息
RecvWorker是每个连接上常驻的读线程。它做的事情很简单:循环调用readPacket从DataInputStream读取消息,读到后封装成Message,丢进recvQueue。
这里有个很重要的设计:recvQueue是全局共享的,不是按目标sid分队列。所有连接收到的消息都进了同一个队列,由消费者WorkerReceiver统一处理。这和发送侧的queueSendMap完全不同,发送是分流的,接收是汇聚的。读源码时不要把这两个队列搞混。
4.2 WorkerReceiver:从网络消息到Notification
WorkerReceiver是连接网络层和算法层的桥梁。它从recvQueue里取Message,然后调用messageToNotification转换。
转换过程中,它会对消息类型做区分:
- 选举相关的
MsgType:解析出各字段,生成Notification。 - 非选举消息(如同步、事务等):转给其他处理逻辑。
这里还有一个细节容易被忽略:WorkerReceiver在处理消息时会检查发送方的sid。如果这个sid不在当前集群的投票列表里,消息会被丢弃。这是个安全校验,防止一些不属于本集群的节点混进来干扰选举。
4.3 messageToNotification是怎么还原一个Vote的
messageToNotification看起来像是机械地复制字段,其实里面暗藏了一个关键点:它需要从ByteBuffer里连续读取electionEpoch、state、leader、zxid、peerEpoch等数据,任何一次顺序错乱都会导致解析出荒谬的结果。所以发送和接收的序列化格式必须完全一致,中间哪怕多一个int都会导致消息错位。
源码里你还会看到from这个词,它是从Message.sid取出来的,代表“这张票是谁发给我的”。很多人会把这个from和Vote.id搞混。from是消息来源节点,Vote.id是消息内容推荐的那个leader节点,两者可能相同也可能不同。比如A发给B一张票,内容是“我推荐A当leader”,那对B来说,from是A的id,Vote.id也是A的id;但如果是A转发了一张别人的意见,那Vote.id就和from不同了。
4.4 网络消息进算法层的完整链条
整个接收链路用一句话串起来就是:
socket数据流 -> RecvWorker -> recvQueue -> WorkerReceiver -> messageToNotification -> Notification -> lookForLeader主循环
注意lookForLeader里不是被动等着,它本身是个while循环,不断从队列里拉取投票消息来处理。每次循环处理一张Notification,然后重新判断是否有必要更新自己推荐的Vote,是否需要重新广播,以及是否已经选出leader。
有读者会问,为什么lookForLeader不是一次性把队列里所有消息处理完,而是一张一张处理?因为处理一张选票可能会改变当前节点的推荐结果,而这种改变可能又需要立刻广播出去,如果一次性处理完所有堆积的选票,可能会错过“中间状态”的传播时机,导致选举收敛变慢。一张一张处理,虽然看起来慢,但保证了每一次意见变化都能及时同步给其他节点。
5. 一轮投票怎么变成选举结果:比较逻辑和完成条件
通过链路把消息收回来只是第一步,真正的决策在这之后。
5.1 投票三要素怎么比大小
totalOrderPredicate是选举里最核心的判断函数,它的比较规则我整理成了表格:
| 比较顺序 | 条件 | 结论 |
|---|---|---|
| 1 | 两个投票的electionEpoch不同 | electionEpoch大的一方胜出 |
| 2 | electionEpoch相同,zxid不同 | zxid大的一方胜出 |
| 3 | zxid也相同,myid不同 | myid大的一方胜出 |
| 4 | 以上全相同 | 视为相同选票 |
这个顺序就是选票的“字典序”。为什么要这么排?electionEpoch保证先处理新一轮的选票,避免旧轮次的残余消息干扰;zxid体现数据的新旧,数据越新越有资格当leader;myid则作为最终打破平局的依据。
注意这里的zxid是带epoch的,也就是高32位是leader纪元,低64位是事务计数。两个节点持有的事务数相同但纪元不同,那么纪元大的那个胜出,这个设计保证“更高时代的经验”优于低时代的经验。
5.2 什么时候认定选举完成
termPredicate是判断是否结束选举的函数。它统计收到的所有投票,看是否有某个候选者获得了超过半数的投票。
这里的“投票”不是所有消息,而是针对“同一个leader id”的投票。通常是遍历votes集合,统计每个id出现的次数,然后判断是否大于quorum(半数节点数)。一旦有候选者满足条件,lookForLeader就会确认选举结果,把最终选票保存下来,并将本机状态切换到LEADING或FOLLOWING。
有一个细节容易忽略:termPredicate里统计时,有些版本是统计Notification.leader,有些版本是统计Vote.id,需要看清楚你读的版本。本质上统计的是“最终认可的leader”,如果一张选票的Vote.id和Notification.leader不同,说明这张票是转发过来的,统计时要小心。
5.3 收到旧轮次消息怎么办
选举过程中经常出现“滞后消息”。比如节点A已经进入第3轮选举,这时收到了第2轮的投票消息,如果不做处理,这个旧消息可能会干扰当前轮的统计。
源码里的处理方式是:如果收到的Notification.electionEpoch小于当前logicalclock,直接丢弃;如果大于当前logicalclock,说明自己落后了,需要更新本地的logicalclock,并清空已有的投票记录,重新开始本轮投票。这个机制能保证集群最终收敛到同一轮选举上。
我在阅读时觉得这段设计非常关键,它也是zookeeper选举稳定性的一个重要保障。
5.4 一轮票数不足时怎么办
如果一轮结束后没有任何候选者获得半数票,lookForLeader会继续循环等待新消息。因为之前发出去的选票可能因为网络延迟丢失,选手们会不断收到新的投票、更新自己的推荐,并重新广播。只要集群没有脑裂,最终总会在某一轮收敛。
源码里还有一个提升收敛速度的优化点:当某个节点收到一张比自己推荐的候选者更优的选票时,会立刻转而推荐这个更优候选者,再广播出去。这样“意见传播”就像滚雪球一样快速收敛,而不是死等超时重发。
6. 常见问题与排查技巧实录
源码读得再多,最终还是要落到“出问题时怎么定位”上。下面几条是我实际调试zookeeper选举过程中总结的经验。
6.1 某个节点迟迟选不出leader,先查连接
选举选不出来,最常见的原因不是算法有问题,而是网络连接根本没建起来。你可以在本机用netstat看集群内部端口之间有没有建立连接,也可以直接在zookeeper配置里把日志级别调到DEBUG,观察QuorumCnxManager的连接日志。
经验之谈:如果发现某个节点已经发起了initiateConnectionAsync,但对方没有调用receiveConnection,多半是对方的AcceptThread没有收到连接请求,或者被防火墙拦了。把这条链路先看通,再去看算法层的日志。
6.2 逻辑时钟不一致导致投票反复横跳
有几次我在测试环境看到两个节点反复互相覆盖选票,日志里logicalclock不断增大,但就是收敛不了。后来定位到是某个节点在启动后第一次选举时,因为磁盘上有旧数据,加载完成后又重新进入了选举流程,把logicalclock又推了一轮。其他节点收到这个更高轮次的选票,不得不清空已有记录重来。
遇到这种情况,建议优先对比所有节点的logicalclock变化。如果某个节点总是高出一截,先看它是不是因为加载了特殊的快照或者事务日志而多触发了一次选举。
6.3 用jstack快速判断卡在发送还是接收
选不出leader时,我一般会抓三份线程栈:
- 抓
FastLeaderElection线程,看它是卡在poll等待,还是在疯狂循环处理。 - 抓
SendWorker线程,看它有没有正常消费发送队列。 - 抓
RecvWorker线程,看它有没有阻塞在读数据上。
实际上,如果SendWorker和RecvWorker都是正常的RUNNABLE状态,但选举还是没法完成,那问题大概率在算法层:要么是quorum配置和实际节点数对不上,要么是某些节点一直没有参与投票。这时候把logicalclock、zxid、当前投票候选打出来对比,一眼就能看出谁的意见被卡住了。
6.4 投票数据不一致的排查小技巧
有时候两台机器日志里都显示自己投了A,但结果就是出不来。这种情况我建议先用zookeeper的stat命令看每个节点的角色状态,再用jstack看当前线程是否还在lookForLeader内。如果一台已经变成FOLLOWING了,另一台还在LOOKING,重点看两者之间是不是有消息丢在了recvQueue里没被消费。
再解释一个很多人问的点:为什么有时候收到的选票里Vote.id不是发送方自己?因为节点可以转发其他节点的意见。比如A发了一张推荐B的票给C,C收到后就会觉得“A也认为B适合当leader”。这种转发机制能显著加快选举收敛。排查日志时如果看到这类现象不要惊讶,把它理解成朴素的信息扩散即可。
7. 从投票链路看整个启动流程的分层设计
借这篇把投票发送和响应链路拉通之后,再回头看zookeeper启动的整体架构,会有一种“豁然开朗”的感觉。
底层QuorumCnxManager根本不知道选举是什么,它只知道把字节流从一个节点送到另一个节点。中间层的WorkerSender、WorkerReceiver负责把算法层的对象转换成网络消息或反向转换。最上层的FastLeaderElection专心处理选票比较和结果判定。三层职责分开,每一层都可以独立做单元测试和故障排查。
个人让我印象很深的一点是:这套分层虽然是很多年前的设计,但放到今天的视角看依然非常清晰。尤其是消息收发完全异步化这条思路,对现在做分布式系统的人仍有很强的参考意义。如果你正在写一个需要多节点协商的模块,完全可以模仿它的队列加线程模型:发送按目标分流、接收统一汇聚、上层逻辑只关心消息的内容而不关心传输过程。
建议你读完这篇之后,自己去源码里走一遍这样一个断点:在lookForLeader里打上断点,然后启动一个三节点伪集群,观察每一张Notification是怎么从集群另一端的SendWorker一步步走进recvQueue的,再观察totalOrderPredicate比较时用的三个值分别是什么。走完这一步,你才算真正“撸”通了zookeeper的leader选举投票机制。