
1. 为什么选个老大这件事这么重要先说个我早年踩过的坑。第一次搭 ZooKeeper 三节点集群的时候我在三台机器上把 zoo.cfg 配好myid 写好然后启动了第一台。日志刷了一会儿集群没有任何反应页面连不上客户端连接一直报错。我又把第二台、第三台都拉起来突然整个集群就醒来了三台机器几乎同时在日志里打出 LEADING、FOLLOWING 这样的字眼。那时候我才意识到ZooKeeper 里藏着一个完整的选举机制而且这个机制在集群刚启动、一个 Follower 都没有的时候就已经开始运作了。现在回看ZooKeeper 之所以能成为 Kafka、HBase、HDFS 这些重量级组件共同依赖的协调服务靠的并不是能存数据这么简单而是它在任何时刻都保证整个集群只有一个 Leader并且这个 Leader 有最高的话语权。所有写请求都由 Leader 接收和转发Follower 只负责同步数据和响应读请求。如果 Leader 挂了整个集群必须快速选出新 Leader否则 ZooKeeper 直接拒绝服务。这个快速选出新 Leader的能力就是靠一套选举协议来支撑的。这篇文章想和你聊的并不是复杂的 Paxos 或者 Raft 理论推导而是 ZooKeeper 在初始化阶段——也就是集群从零开始、一台机器都还没选定 Leader 的时候——到底是怎么通过内部投票把 Leader 选出来的。我会尽可能从一个从业者亲手搭集群、看日志、踩坑的角度来拆解整个过程包括配置、日志、协议、代码层面的逻辑以及一些常规文档里很少提到的细节。这篇文章适合刚接触 ZooKeeper、准备做集群部署的人也适合已经在用 ZooKeeper 但一直对选举原理一知半解的开发者和运维。读完之后你至少能回答几个问题初始化选举需要哪些前置条件投票信息里到底装了什么东西为什么重启的时候经常出现选出了之前的 Leader以及集群一直选不出 Leader 的时候到底该怎么排查。2. 选举的前提条件myid、zxid、逻辑时钟2.1 每个节点必须有一个唯一的身份标识ZooKeeper 做选举首先要解决一个问题每台机器怎么知道自己是谁怎么知道别人是谁。这个身份标识就是 myid。myid 的配置非常朴素在 dataDir 目录下创建一个名为 myid 的文件里面写一个整数这个整数必须在 1 到 255 之间并且集群内必须唯一。比如三节点集群节点A的 myid 是1节点B是2节点C是3。启动的时候ZooKeeper 会读取这个文件把它作为当前节点在集群里的身份证。注意myid 文件一定不要只改 zoo.cfg 里的 server.id 而忘记写 myid 文件否则启动日志会直接报错告诉你找不到 myid 文件。这个错误在初学阶段几乎人人都遇到。myid 不仅用于选举也用于节点之间的通信识别。在 ZooKeeper 的配置里每个 server 行都有这样的格式server.1192.168.1.101:2888:3888 server.2192.168.1.102:2888:3888 server.3192.168.1.103:2888:3888其中 2888 是 Leader 和 Follower 之间同步数据的端口3888 是选举通信端口。注意这两个端口不能冲突也不能和 clientPort默认2181冲突。2.2 数据新旧怎么看zxid第二个关键概念是 zxid全称 ZooKeeper Transaction ID。你可以把它理解为 ZooKeeper 里的事务版本号每次写操作都会生成一个新的 zxid它由两部分组成高32位是当前 Leader 的 epoch纪元低32位是当前纪元内的递增序号。举一个不太严谨但很好理解的生活化类比如果把 ZooKeeper 集群比作一家公司epoch 就像是第几届领导班子序号就像是这一届领导下发的第几号文件。新 Leader 上任epoch 一定会加1前任 Leader 审批过的所有写请求全部作废新 Leader 从自己的历史快照和日志里恢复数据继续往下发号。在选举过程中zxid 是判断数据新旧的核心标准。数据越新的节点也就是 zxid 越大越有资格成为 Leader。原因很简单如果一个节点数据落后太多它当上 Leader 之后整个集群的数据就会回滚这绝对不行。所以选举协议里有一个铁律先比 epoch再比事务序号zxid 大的节点优先。2.3 逻辑时钟防止拿着旧地图选新队长第三个概念叫 logicalclock中文常翻译成逻辑时钟也有人叫投票轮次。它解决的是一类非常隐蔽的问题一个节点可能因为网络分区、重启、旧投票延迟到达等原因收到过往轮次的选票如果它不加以区分就可能拿旧票去干扰新一轮选举。举个具体场景节点A已经进入第二轮选举这时候收到一张第一轮选举的投票通知如果不加判断A 可能把这张旧票当成有效票来统计结果把票数搞乱。引入 logicalclock 之后每个节点在发起新一轮选举时都会把本地逻辑时钟加1收到投票时先比较逻辑时钟只处理当前轮次的投票数据旧票直接丢弃或者忽略。这三个概念——myid、zxid、logicalclock——是理解初始化选举的底层地基。下面讲到的所有投票行为、比较规则、状态切换都建立在这三个数值之上。3. 初始化选举完整流程从第一张票到Leader产生3.1 第一步每个节点先投自己一票初始化选举之所以叫初始化是因为此刻集群里还没有任何 Leader。每个节点启动后发现自己处于 LOOKING 状态就会立刻发起一轮投票。这轮投票的内容是(myid, zxid, logicalclock)。假设我有三台机器节点1myid1zxid0空数据节点2myid2zxid0空数据节点3myid3zxid0空数据三台机器刚启动数据都是空的zxid 都是0logicalclock 也都是1。于是三张初始选票分别是节点1 投给 (1, 0, 1)节点2 投给 (2, 0, 1)节点3 投给 (3, 0, 1)注意这里投给自己并不意味着最终结果一定是自己。它更像是在宣告自己的存在并且用 (myid, zxid) 告诉其他人我在这轮选举里身份是几号数据是新是旧。3.2 第二步收到别人的选票后怎么PK当节点收到另一张选票时并不会盲目相信而是先做一轮比较。比较的规则并不复杂但顺序非常关键先看逻辑时钟logicalclock。如果收到的选票逻辑时钟比我当前的小说明这是过期选票直接忽略。如果比我当前的大说明我已经落后于新一轮选举需要更新自己的逻辑时钟并清空之前统计的所有选票。逻辑时钟相同的情况下对比 zxid。如果收到的选票 zxid 比当前我投票的节点 zxid 大说明那个节点数据更新我改投它。如果 zxid 也相同再比较 myid。myid 大的节点优先。这一套规则用一句话总结选票只看数据新不新、身份大不大不看面子。数据越新越有资格数据一样新就比身份编号这样既保证了 Leader 一定带有最新数据又能在数据相同时快速收敛到一个确定结果不会出现选票在几个节点之间来回横跳的情况。3.3 第三步过半机制定胜负投票的根本目的是让某个节点在集群内获得超过半数的选票。ZooKeeper 采用的正是过半机制Quorum而不是全员同意。假设集群有3个节点那么半数以上就是2票。假设有5个节点半数以上就是3票。只要一个节点获得的票数达到 (n/2 1)向下取整以上它就可以认定为 Leader。为什么必须过半这个设计有很深的哲学过半意味着在任何网络分区场景下不可能出现两个节点同时获得过半票数。反过来只要有过半节点能互相通信集群就能选出 Leader就能继续对外提供服务。这就是 ZooKeeper 高可用的核心逻辑牺牲一点极端一致性换取可用性和容错性。我见过很多新手在这里卡住说为什么我三台机器在一起投票结果却是平手答案是平手只发生在投票统计还没完成的时候。当三个节点都参与投票后必定有一个节点获得至少2票因为它自己会投给自己然后再收到另外一个节点的改投票。这里的关键是——所有节点必须能够互相通信并且票数统计必须有一个收敛过程。3.4 第四步Leader 的过三关当某个节点发现自己的票数过半它并不会立刻宣布自己成为 Leader。整个选举结果还需要经历三个阶段才算真正稳定。第一阶段是LEADING 宣告。节点确认自己获得过半票数后状态从 LOOKING 切换为 LEADING然后广播自己的当选消息给所有 Follower。第二阶段是FOLLOWING 确认。其他节点收到 Leader 广播后如果确认这个 Leader 的数据确实最新也就是当初投票比较规则的结果会把状态从 LOOKING 切换为 FOLLOWING并向 Leader 发送确认消息。第三阶段是数据同步。Leader 在收到足够多 Follower 的确认后开始主导数据同步过程。Follower 会向 Leader 发送自己本地最新的 zxidLeader 根据这个 zxid 决定同步策略如果 Follower 落后太多就做一个全量快照同步如果只落后一部分日志就做增量同步。等数据对齐之后集群才算真正对外提供服务。这里有一个容易误解的地方初始化选举中所有节点启动时的数据可能都是空的zxid 都是0所以选举结果通常就是 myid 最大的那个节点。但如果是集群滚动重启老节点的 zxid 可能更大这时候选举结果就会偏向数据最新的节点而不是固定的某个编号。这也是为什么生产环境里经常出现重启之后 Leader 换了台机器的现象。4. 结合代码看选举FastLeaderElection 的实现细节4.1 三种选举算法以及默认选哪个ZooKeeper 历史上出现过三种选举算法LeaderElection、AuthFastLeaderElection、FastLeaderElection。其中 LeaderElection 和 AuthFastLeaderElection 已经废弃默认实现是 FastLeaderElection从 ZooKeeper 3.4.0 开始就是标配。在源码层面FastLeaderElection 有以下几个核心组成部分QuorumCnxManager负责管理节点之间的网络连接包括建立连接、发送消息、接收消息。它内部有 SendWorker 和 RecvWorker 两个后台线程。FastLeaderElection负责选举逻辑本身包括发起投票、接收投票、统计票数、判断结果。Notification这是节点之间传递的投票消息结构体。4.2 Notification 里到底装了什么如果你看源码Notification 类里有这样几个字段proposalLeader被投节点的 myidproposalZxid被投节点的 zxidlogicalclock投票轮次state投票节点的当前状态LOOKING 或 FOLLOWING 或 LEADINGsid发送这张投票的节点自己的 myid这里要特别提一下state 字段。在 FastLeaderElection 中节点不仅会在 LOOKING 状态下发选票在选举完成后已经变成 FOLLOWING 或 LEADING 的节点也会继续广播自己的状态。这样做的好处是避免其他节点因为错过早期选票迟迟无法从 LOOKING 状态恢复。比如一个节点因为网络原因晚启动它可以收到来自 Leader 的我已经是 LEADING消息从而快速加入集群而不是傻傻地等完整一轮投票。4.3 LOOKING 状态下的状态机FastLeaderElection 的核心逻辑可以抽象成一个状态机状态为 LOOKING 时发起新一轮投票把逻辑时钟加1然后把选票投给自己。把选票发送给集群内所有节点。持续从消息队列中读取 Notification。每收到一张 Notification做逻辑时钟比较、zxid 比较、myid 比较决定是否更新自己的投票。如果自己的投票没有变化继续等待下一张 Notification。如果自己的投票发生了变化广播新投票给所有节点。统计收到的票数如果某个节点票数过半结束 LOOKING 状态切换为 LEADING 或 FOLLOWING。这个状态机的关键点在于投票可以随时改变。一个节点今天投节点3收到更高 zxid 的选票后可以改投节点1。这种设计保证了最终能收敛到一个大家都认可的 Leader。提示源码里有一个很隐蔽的细节——节点在处理 Notification 时会维护一个 recvset 集合用来记录本轮选举中收到的所有投票。这个集合是选举结束后统计过半票数的依据。很多时候集群卡在 LOOKING 状态选不出 Leader不是因为算法有问题而是因为 recvset 里统计的票数死活不够也就是网络通信有问题。5. 初始化选举和运行时选举差的不是一星半点5.1 数据起点不同初始化选举发生在集群没有任何 Leader 的场景下比如全新部署后的首次启动、集群所有节点同时重启、或者数据目录被清空后的重启。这种场景下所有节点的 zxid 可能都非常小甚至全部是0。选举的重心在于快速收敛而不是数据恢复因为大家都没什么数据。运行时选举则完全不同。它发生在 Leader 突然崩溃、或者 Leader 与过半数节点失联的情况下。这时候存活的节点里可能有一个节点拥有集群最全的数据它的 zxid 最大按规则它最有可能当选。选举的重心在于不丢数据必须选出数据最完整的节点。5.2 选票风暴初始化选举更容易出现震荡还有一个特别值得注意的现象初始化选举容易出现 FOLLOWER→LOOKING→FOLLOWER 的震荡。举一个真实例子三节点集群节点3 先启动因为只有它一个节点它一直处于 LOOKING 状态投自己一票然后等待其他节点。过一会儿节点1 和节点2也启动了三者开始正常投票按理应该很快选出 Leader。但如果网络有抖动节点3暂时联系不上节点1而节点2已经把票投给了节点1节点1获得两票自己节点2成为 Leader。节点3 恢复通信后发现 Leader 已经产生于是切换为 FOLLOWING。正常情况下这个流程没有问题但如果在初始化阶段有节点重启、磁盘IO卡顿、或者投票消息延迟就有可能出现先选出一个 Leader过一会儿因为数据不同步又触发重新选举的现象。解决办法很简单初始化启动时尽量控制节点启动顺序让所有节点都起来之后再统一观察状态。虽然 ZooKeeper 不要求按顺序启动但按顺序启动能有效减少选举震荡。6. 从零搭建一个能观察到选举过程的集群6.1 三节点最小配置如果你想亲眼看到初始化选举的完整过程我建议你准备三台虚拟机或者用 Docker 起三个容器用最小配置搭一个集群。下面是 zoo.cfg 的参考配置tickTime2000 initLimit10 syncLimit5 dataDir/data/zookeeper clientPort2181 server.1node1:2888:3888 server.2node2:2888:3888 server.3node3:2888:3888然后在每个节点的 dataDir 目录下创建 myid 文件分别写上 1、2、3。启动顺序没有硬性要求但为了观察初始化选举建议一台一台启动观察每一台启动后日志的变化启动节点1日志显示进入 LOOKING 状态因为没有其他节点它一直投自己。启动节点2两台节点开始互相通信投票比较之后节点2 因为 myid 更大更有可能成为 Leader。启动节点3节点3 加入后投票收敛到节点3如果三台节点数据都是空的myid 最大者胜出节点3 变成 LEADING其他节点变成 FOLLOWING。6.2 从日志里读选举过程ZooKeeper 的日志默认输出到控制台或者 log4j 配置的日志文件。启动后留意这几类关键日志LOOKING说明节点还在选举状态。LEADING说明该节点当选为 Leader。FOLLOWING说明该节点成为 Follower。Notification 相关日志能看到节点之间交换的选票内容。我自己观察过一次三节点从零启动的日志节点3 当选 Leader 时日志大概长这样INFO [QuorumPeer:/192.168.1.103:3888:QuorumPeer] ... INFO [QuorumPeer] LEADING INFO [Learner] LEARNER - LEADER而节点1 和节点2 会输出类似这样的内容INFO [QuorumPeer] FOLLOWING INFO [Learner] LEARNER - FOLLOWER看到这三行集群才算真正初始化完成。提示生产环境里千万不要在生产集群上随意清空 dataDir 来做初始化选举测试。dataDir 里不仅包含 myid还包含 ZooKeeper 的数据快照和事务日志一清空就等于丢数据而且集群恢复后会出现不可预测的数据不一致。6.3 常用的集群状态检查命令初始化完成之后可以用 ZooKeeper 自带的四字命令4 Letter Words来检查集群状态。Linux 下可以这样执行echo stat | nc 127.0.0.1 2181它会返回当前节点的角色、zfID即 zxid、连接数、模式等信息。如果输出里有 Mode: leader说明当前节点是 Leader如果是 Mode: follower说明是 Follower。另外ZooKeeper 3.5.0 之后引入了zookeeper-admin工具也可以用zkServer.sh status来查看节点状态。这个命令会自动连接到指定端口输出节点角色和集群信息。7. 常见问题与排查技巧实录7.1 集群一直选不出 Leader这个问题排在 ZooKeeper 排查问题榜单第一位。表现是所有节点日志都停在 LOOKING没有任何节点切换成 LEADING。排查思路按下面几步走第一检查 myid 是否唯一。如果两台机器 myid 相同投票时会互相干扰票数永远无法过半。第二检查 3888 端口是否开放。很多云服务器默认安全组只放行了 2181放行了 2888忘了放行 3888。选举消息就是通过 3888 传输的端口不通选票根本发不出去。第三检查防火墙。Linux 自带的 firewalld 或 iptables 有可能拦掉了 3888即使云控制台放行了本机防火墙也会拦。第四检查 zoo.cfg 里的 server 列表确保三行配置的 IP 是节点之间互相可达的地址。不要用 127.0.0.1也不要用 hostname 但没配 /etc/hosts。7.2 选举完成后有一个节点一直连不上集群这种问题多见于集群重启场景。节点A已经重启完成节点B却迟迟不切换状态。排查时先看节点A的日志里是否有 Connection refused 或 Timeout 相关字样。如果有大概率是节点A到节点B的网络不通或者节点B的 2888 端口没监听。另外注意ZooKeeper 对连接超时非常敏感如果你的 tickTime 设置得太小而节点之间网络延迟又比较大就会频繁出现连接超时节点之间互相认为对方失联触发新一轮选举。网络不稳定的环境里可以适当调大 initLimit 和 syncLimit。7.3 重启时Leader变了是不是出问题了很多人在集群滚动重启后发现 Leader 从原来的节点3变成了节点1第一反应是是不是选错了。其实这不是问题是正常现象。重启时节点会重新发起选举如果原 Leader 节点刚好在重启窗口内落后于其他节点比如它还没来得及恢复完数据别的节点已经先完成了日志同步它可能就不是 zxid 最大的节点于是选票会偏向其他数据更完整的节点。还有一种情况是大家都重启完了所有节点数据都同步到同一状态zxid 相同这时候就按 myid 取最大者所以经常出现 Leader 漂移。只要集群最终进入稳定的 LEADING/FOLLOWING 状态并且客户端读写正常就不需要干预。如果你希望某个节点长期固定当 Leader可以在代码层面控制数据同步顺序或者在运维层面手动调整但一般没有必要。提示哪个节点当 Leader对业务来说其实是无感的。客户端连接的是整个 ZooKeeper 集群只要集群可用写请求自然会路由到 Leader。我在实际运维中见过不少人纠结为什么 Leader 不是我想的那台其实这种纠结完全没必要ZooKeeper 的选举算法已经替我们选出了最合适的节点。7.4 快速自查表症状大概率原因解决方法所有节点一直 LOOKING3888端口不通 / myid冲突检查安全组、防火墙、myid部分节点 LOOKING其他正常网络分区 / 防火墙只放行了部分IP检查节点间连通性选举过程无限循环tickTime太小频繁超时调大 initLimit、syncLimitLeader反复漂移数据同步滞后 / 网络抖动检查磁盘IO、网络延迟无法写入数据过半节点宕机恢复宕机节点或扩容集群8. 最后分享一点体会我从第一次搭建 ZooKeeper 集群到现在遇到过太多稀奇古怪的问题。最有价值的经验只有一个不要凭感觉猜测选举结果一定要看日志。ZooKeeper 的日志非常诚实它会把每个节点每个时刻的状态转变、选票来源、选票比较结果都记录下来。与其反复重启集群试探不如停下来认认真真读一遍日志。还有一个容易被忽略的点ZooKeeper 集群里数据目录dataDir的存储介质对选举性能有很大影响。如果你用机械硬盘跑 ZooKeeper那么在节点崩溃恢复或者滚动重启时日志恢复和数据同步的速度会明显变慢这会导致节点长时间停留在 LOOKING 状态误触发超时重选。生产环境如果条件允许尽量把 dataDir 放到 SSD 或者高性能云盘上这个收益在节点频繁重启或者大流量写入时特别明显。最后再分享一个小技巧初始化集群时如果你用的是 Docker Compose记得把 2888 和 3888 两个端口都映射出来。不少人只映射了 2181结果容器起来了日志里全是连接错误。三个端口缺一不可——2181 给客户端2888 给数据同步3888 给选举。记住了踩坑率能降低一半。