在不少业务系统里ZooKeeper 都是一个看起来没什么存在感、但谁都离不开的组件。我曾经在半夜遇到过集群因为一次网卡抖动触发 Leader 重选分布式锁瞬间全部释放原本应该在整点执行的定时任务被重复触发注册中心里的服务列表也出现短暂的读写异常。事后一群人围着日志排查发现大家对 ZK 集群的理解基本停在“能搭、能用”的层面连过半提交和 zxid 的比较规则都说不清楚。这篇文章不打算逐行解析源码而是把 ZK 集群的五条主线完整捋一遍集群里有哪几种角色、Leader 到底怎么选出来、ZAB 原子广播如何保证事务有序、集群崩溃后靠什么恢复、恢复完成后各节点又怎么同步数据。无论你是刚接手 ZK 集群的运维还是想补分布式一致性理论的开发把这五个问题想透排障时心里就会有一张完整的图。1. ZooKeeper 集群的设计目标与角色分工1.1 单机模式的局限高可用从多副本开始大多数人对 ZooKeeper 的第一次认知是“一个能存配置的小数据库”。单机 ZooKeeper 完全可以用也能提供 ZNode 树、临时节点、Watch 这些接口但它有一个致命问题如果这台机器挂了所有依赖它的服务就失去了配置中心、注册中心和分布式锁整个系统瞬间不可用。所以集群的意义首先是消除单点。但消除单点不等于简单地把一份数据复制到多台机器上——多副本带来的新问题是如何让多台机器上的数据保持同步并且保证客户端无论连到哪一台看到的都是同一个有序的历史。ZooKeeper 对这个问题给出的答案是它本质上是一个复制状态机每一台节点都保存同一棵 ZNode 树所有事务按同一个顺序应用到各自的内存状态中。1.2 Leader、Follower、Observer 三种角色各管什么一个 ZK 集群里的节点分三种角色。Leader 是整个集群的写入枢纽所有写请求最终都由它分发事务 zxid 也由它统一分配。Follower 是参与投票的从节点既处理读请求也把写请求转发给 Leader并且参与每一次事务的 ACK 和 Leader 选举。Observer 是“只看不投”的从节点它同步数据、处理读请求但不参与 ZAB 的 ACK也不参与选票统计。Observer 存在的意义在于水平扩展读能力和数据副本数但不会拖慢写路径。因为每增加一个 Follower会让一次写事务需要过半 ACK 的门槛更高也会增加选举时间和投票通信量。而 Observer 不投票所以理论上可以多加几个专门扛读。在我实际维护过的集群里读多写少场景下优先扩 Observer比盲目增加 Follower 划算得多。1.3 过半提交机制一切可用性和一致性的起点ZooKeeper 的提交协议要求一个写事务真正生效必须获得超过集群总节点数一半的 ACK。半数的公式是 N/21比如 3 节点集群要 2 个 ACK5 节点集群要 3 个 ACK。这个数字背后的数学逻辑很关键任何两个过半集合必然存在交集所以一条事务如果被一个过半集合确认那么另一个过半集合里至少有一个节点见过它不会出现两边各自独立提交、各说各话的情况。这个机制也顺带解释了为什么生产环境要用奇数节点。3 节点和 4 节点在容错能力上一样都最多容忍 1 台故障但 4 节点写事务需要 3 个 ACK比 3 节点需要的 2 个更多容错没提升成本反而更高。5 节点容忍 2 台故障6 节点也是 2 台同样多一台机器却没有换来更高的容错。所以最佳实践是取奇数3、5、7 都可以。还要强调一点过半机制不仅保护提交还用于防止脑裂。当网络分区发生时只有包含多数节点的那个分区能选出 Leader 并继续提交事务少数派分区因为凑不够过半票数会主动拒绝外部读写。ZooKeeper 在这里明确选择了 CP 而不是 AP——宁可暂时不可用也不让两个分区各自为政。2. Leader 选举从“数据最新”到“多数派公认”的收敛过程2.1 哪些时刻会触发选举很多人以为 Leader 选举只在集群启动时发生其实生产环境中大部分选举是因为 Leader 失联或心跳超时。具体触发条件有四类集群里还没有任何 Leader比如首次启动或全部重启Leader 进程崩溃或节点宕机Leader 进程虽然还活着但它在 syncLimit 时间内没能和过半 Follower 完成心跳Follower 就会认为 Leader 失效运维手动重启某个节点时如果该节点的状态异常也可能触发新一轮投票。这里要看懂 syncLimit 的判定。ZooKeeper 默认 tickTime2000mssyncLimit5意味着 Leader 如果在 10 秒内没有和某台 Follower 完成心跳这台 Follower 就会转入 LOOKING 状态并发起选举。10 秒听起来很长但在一次 Full GC 或者磁盘 IO 卡顿面前可能一晃就过去了。所以生产环境里看到的“莫名选主”很多时候不是代码问题而是资源抖动被心跳机制放大成了故障。2.2 投票比较规则epoch、zxid、myid选举不是靠“谁能力强选谁”而是按一组严格排序规则自动收敛。每个节点发出去的投票核心三元组是(currentEpoch, lastZxid, myid)。比较优先级从高到低先比 currentEpoch大的优先它代表当前选举轮次和时代再比 lastZxid大的优先最后处理的 zxid 越大说明数据越全选它不容易丢事务最后比 myid大的当 Leader用来在平票时让结果也具备确定性。这里需要真正理解 zxid。它是一个 64 位整数高 32 位是 leader epoch低 32 位是事务计数器。每选出一任新 Leader高 32 位就加 1低 32 位从 0 开始重新计数。所以 zxid 不只是简单的事务序号它还隐含“这是哪一任 Leader 任期内的哪条事务”的信息。到了第 4 章讲崩溃恢复时你会发现这个设计非常关键。2.3 Fast Leader Election 的收敛过程ZooKeeper 实现的是 Fast Leader Election核心可以概括为一句话每个节点先投自己一票然后不断用“别人比我更适合我就改票”的方式向更优候选收敛。大致流程分四步节点进入 LOOKING 状态把投票初始化为(myEpoch, lastZxid, myid)也就是先投自己向所有对等节点广播自己的投票同时接收别人发来的票收到一张票时按上面说的规则与本地投票比较如果对方的候选更优就把本地投票改成对方候选并重新广播如果本地更优则忽略这张票每轮收集完成后统计结果如果某个候选拿到了超过半数的票选举就完成自己成为 Leader 则进入 LEADING否则进入 FOLLOWING。FLE 里还有个逻辑时钟的概念。如果选举过程中发现其他节点广播的投票 epoch 比自己大说明这是新一轮选举自己必须放弃旧时代的投票跟着新的 epoch 重新参与。这个机制保证了在乱序网络里任何过期的投票都不会干扰新一届选举。2.4 为什么最终只会有一个 Leader多数派与少数派拒绝服务因为每个节点都在向“更适合”的方向收敛最终所有健康节点会选到同一张票而法定人数保证了这个结果唯一。举一个常见场景3 节点集群 A、B、CA 是旧 Leader网络把 A 和 B、C 隔开。B、C 互相投票2 票已经达到 3 节点集群的过半票数于是 B 或 C 胜出形成新 Leader。此时 A 还孤零零地活着它无论怎样尝试提交写事务都凑不到 2 个 ACK无法提交任何新事务实际上已经不再是可用 Leader。客户端如果连到 A会在选举结束后被引导到新 Leader。反过来如果网络把 5 节点集群切成 3 和 23 节点那边能凑齐 3 张票选出新 Leader2 节点那边凑不够 3就无法产生 Leader。这就是过半机制防脑裂的本质不是让所有人同意而是让超过一半的人同意剩下的少数派自动被排除在可用集合之外。3. ZAB 原子广播保证写事务有序提交的底层引擎3.1 ZAB 不是 Paxos也不完全是 RaftZooKeeper 没有直接复用现成的一致性算法。Paxos 适合用来达成一次共识但 ZooKeeper 需要的是一个主备系统任意时刻只有一个 Leader 负责写入所有历史事务按顺序应用到整个集群。ZAB 协议为这个需求而生它有两个核心能力原子广播和崩溃恢复。原子广播保证同一个 Leader 任期内所有写事务按 zxid 严格有序发送和提交崩溃恢复保证 Leader 切换后集群既不丢失已提交事务也不让未提交的“半截事务”污染新状态。拿 Raft 对比ZAB 和 Raft 在“单一 Leader 多数派确认”上思路一致但 ZAB 的历史更早更侧重广播机制恢复流程也有自己的细节。理解 ZAB 时可以把它看成两阶段提交的优化变体不是所有节点都 ACK 才能提交而是过半 ACK 就能提交这样少数派故障不会阻塞整个集群。3.2 一次写请求从客户端到返回成功的完整链路把一次写请求从头到尾串一遍才能理解 ZAB 到底做了什么。以客户端向 Follower 发起create /app/node为例Follower 收到请求后判断这不是自己能处理的写操作把它转发给当前 LeaderLeader 校验权限、路径合法性、会话有效性然后为事务生成一个全局唯一的 zxid这个 zxid 必须比当前已知的所有 zxid 都大Leader 用这个 zxid 构造 PROPOSAL 消息广播给所有 FollowerFollower 收到 PROPOSAL 后先将事务写入本地事务日志并执行 fsync保证日志真正落盘再返回 ACKLeader 自己也会写入本地日志然后统计 ACK 数量一旦达到过半就确定该事务可以被提交Leader 广播 COMMIT 消息通知所有 Follower 把事务应用到内存 ZNode 树Leader 自己也应用客户端最终从 Leader 或转发的 Follower 那里收到成功响应。为什么 Follower 必须先落盘再 ACK如果它只把事务放在内存里就回 ACKLeader 一旦宕机这条事务可能没有任何节点持久化恢复时就会丢失。把 ACK 和磁盘写入绑定是 ZooKeeper 不丢已确认写入的根基。这也是为什么事务日志磁盘的 fsync 性能会直接影响整个集群的写延迟。3.3 读请求为什么可能读到旧数据ZAB 管的是写路径读路径并不过协议。一台 Follower 可以直接用本地内存中的 ZNode 树回答客户端读请求当它还没有应用某些刚提交的事务时返回的自然是旧值。这在大多数场景下可以接受但如果你用它实现分布式锁或全局唯一 ID就得小心读到旧状态导致的错误判断。要强制读到相对新的数据常用的办法有三种把读请求也转发给 Leader 处理在客户端读之前调用sync()让 Follower 与 Leader 的日志对齐或者使用内部自动做 sync 的客户端封装。核心原则是ZooKeeper 默认提供的是顺序一致性而不是线性一致性。很多一致性测试里暴露出来的“读旧数据”问题本质都是绕过了写协议直接读本地副本。3.4 事务顺序与 zxid 的连续性给事务分配 zxid 时Leader 会保证它严格递增并按升序广播。Follower 不能跳过某个 zxid 去应用后面的事务必须一条一条按顺序来。正是这种严格顺序让依赖方可以通过 zxid 判断事件的新旧。比如 Watch 回调事件里你可以比较 zxid 知道一次变更发生在前还是后。失败的事务一般不会占用 zxid。因为写请求会先经过 leader 的预处理检查如果会话无效、路径非法或者权限不足在分配事务号之前就被拒绝了。能拿到 zxid 的事务通常已经通过校验接下来就是走广播和提交流程。4. 崩溃恢复旧 Leader 遗留的未提交事务怎么裁决4.1 崩溃恢复要解决的核心矛盾Leader 崩溃时最怕两件事一是已经告诉客户端“写入成功”的事务在集群其他节点上可能还没提交如果新 Leader 直接把这条事务丢掉客户端看到的事实就突然消失了二是旧 Leader 残留了一些“半截”事务挂在日志里如果新 Leader 不分青红皂白全部采纳集群状态可能被污染。ZAB 的恢复阶段就是在这两个风险之间找平衡。ZAB 的处理原则是一条事务是否被保留取决于它是否已经在一个法定多数节点上持久化。如果已经被多数节点保存即使旧 Leader 没来得及发出最终的 COMMIT新 Leader 也会在恢复阶段把它补全为已提交事务如果它只存在于极少数节点甚至只存在于旧 Leader 本地就当作未提交事务在同步时被丢弃。4.2 选举完成只是第一步恢复阶段还没完很多人以为 ZK 节点一进入 LEADING 状态集群就能恢复写服务了。实际操作中你会发现选举完成只是第一步新 Leader 还必须完成日志恢复才能正式对外提供写服务。整个崩溃恢复大体分两段先选出新 Leader也就是上一章的 FLE然后是 Recovery Phase。在 Recovery Phase新 Leader 和每个 Follower 建立连接交换彼此的最后 zxid。每个 Follower 上报自己的 lastZxid也就是它处理过的最后一条事务编号。Leader 根据这些信息决定每个 Follower 是补数据、截断还是重新加载快照。这个对齐过程会持续到所有 Follower 处理完Leader 才宣布自己可以接收新的写请求。4.3 保留还是丢弃用一个例子说透假设 3 节点集群 A旧 Leader、B、C。正常运行中A 把事务 T1 同步给了 B、C收到所有 ACK 并提交。接着 A 处理事务 T2把 T2 的 PROPOSAL 发给 BB 已经落盘并回 ACK但 C 还没来得及收到 T2A 就宕机了。此时如果新选举选出的 Leader 是 CC 没有 T2而健康节点中只有 B 有 T2不满足多数节点持久化T2 就不能算提交同步时会被截断。但如果 C 后来也收到了 T2 并落盘那么 B 和 C 都持有 T2构成法定多数即使 A 没来得及发 COMMIT新 Leader 也必须保留 T2 并补发 COMMIT。这个例子说明崩溃恢复不是简单保留最大的 zxid。有的节点 zxid 可能很大但后继无人必须被截掉有的 zxid 不是最大但因为存在于多数节点就会被保留。这也是为什么 ZK 恢复协议里特别强调“法定人数持久化”而不是“单个节点的日志长度”。4.4 恢复期间的客户端与会话当集群进入恢复流程旧 Leader 上的客户端连接会全部断开。客户端会收到 ConnectionLoss 或 SessionExpired 异常具体看会话能否在新 Leader 附近被重建。ZooKeeper 规定只要会话在 sessionTimeout 时间内重新连接成功会话仍然有效超过这个时间会话过期临时节点就会被删除。由此衍生出一个实际经验如果生产环境经常发生 Leader 切换而且你发现大量临时节点消失先不要只盯着业务代码看看恢复时间是不是已经超过了客户端设置的 sessionTimeout。恢复时间很长通常是磁盘 fsync 慢或网络分区导致的。调整超时参数只是缓解不是根治。5. 恢复后的数据同步DIFF、TRUNC、SNAP 三种补齐方式5.1 新 Leader 如何决定每个 Follower 该走哪条路Recovery Phase 里新 Leader 会维护一个已提交事务队列简称 committedLog。收到某个 Follower 的 lastZxid 后Leader 拿它和队列比对并根据结果选择三种同步方式之一DIFF、TRUNC、SNAP。可以先用一张表看清三者的关系同步方式判定条件处理方式DIFF 增量Follower 的 lastZxid 命中 committedLog 中的某个位置但之后仍有日志没同步Leader 把该位置之后的事务作为补丁发给 FollowerFollower 顺序追赶TRUNC 截断Follower 的 lastZxid 大于 Leader committedLog 的最大 zxidFollower 回退到 Leader 的最大已提交位置删除多余事务SNAP 全量Follower 的 lastZxid 小于 committedLog 最早一条或落后太多Leader 发送完整快照Follower 加载后再用 DIFF 追赶这个判定是为了最小化传输成本同时保证状态一致。DIFF 最常见短暂网络分区后回来的 Follower 基本都在这个范围里TRUNC 说明这位 Follower 曾经接触过一条未在多数节点确认的事务需要把多走的路倒回去SNAP 适合新加入的节点或离线过久的节点。5.2 DIFF最常见的增量追赶假设新 Leader 自己最后提交到 zxid 0x300000017而某个 Follower 回来时上报 lastZxid 0x300000010并且 committedLog 里 0x...011 到 0x...017 这七条事务都还在Leader 就把这七条事务作为同步内容发给 Follower。Follower 从 0x...010 之后逐个应用最终也对齐到 0x...017。有一个细节值得注意Follower 应用这些增量时不是简单更新内存而是先写事务日志再应用到内存保证同步过程中再次崩溃也能从日志里恢复。所以 DIFF 本质上是把 Leader 写事务的过程重放了一遍而不是只把最终结果复制过去。5.3 TRUNC把多走的路退回来TRUNC 是很多人最陌生的场景。为什么 Follower 的 zxid 会领先 Leader接着上一章的例子旧 Leader A 发送事务 T2 给 BB 已经应用了 T2但 A 没有把 T2 提交给多数节点就崩了。新的 Leader 是 CC 没有 T2。B 上报自己的 lastZxid 后发现它比 C 的 committedLog 最大值还要大。这时 C 会告诉 B把你本地多余的事务日志全部截掉回到我认定的已提交位置。截断之后B 内存里来自 T2 的变更会被撤销ZNode 树回到 T2 发生之前的状态。这看起来像业务数据回滚但它是正确行为T2 没有成为法定提交事务它本来就不应该存在。如果业务方真的需要 T2 的效力只能重新发起请求。5.4 SNAP全量快照和增量补丁的组合当 Follower 的 lastZxid 比 committedLog 里最早的记录还旧说明中间的事务日志可能已经被清理掉了无法靠增量补全。Leader 会把整个内存 ZNode 树序列化成快照发过去。Follower 清空本地数据并加载新快照后再继续接收快照之后的增量日志。这里常有人误解 SNAP 是同步终点其实它通常只是追赶的起点。ZooKeeper 自己会周期性地生成快照并清理旧事务日志。所以生产环境里dataDir 下的 snapshot 文件和 dataLogDir 下的日志文件不在同一个时间点这是正常现象。如果 Follower 落后超过一个清理周期Leader 找不到足够早的 committedLog就只能走 SNAP。这也是为什么新节点加入集群时经常能看到一次很大的网络传输。5.5 同步完成才恢复对外服务新 Leader 必须等所有需要同步的 Follower 都到达一致状态才能把自己的状态切换为接受新事务。如果 Leader 还没和 Follower 对齐就接受新命令新事务就会基于一个残缺的历史继续增长后面的恢复只会越来越乱。理解了这一步就能解释为什么大集群或慢磁盘环境下一次 Leader 切换后的“恢复窗口”可能比预期长。Observer 节点也会参与同步但它们不向 Leader 回 ACK也不影响写提交。它们落后时要么从 Leader 拿快照要么拿增量过程基本一样只是不会阻塞其他节点。6. 生产环境里真正决定 ZK 集群生死的运维细节6.1 节点数量决策不要给偶数节点交学费第一点还是节点数量。很多搭建文档只说建议奇数但不解释原因。半个通读下来你应该已经明白N 节点集群的容错上限是向下取整的 (N-1)/2而写请求需要过半 ACK。3 节点容忍 1 故障5 节点容忍 2 故障4 节点最多容忍 1 故障却要多付出一次 ACK 的成本。想省钱用 3 台想提高容错用 5 台不要用 4 台或 6 台。6.2 zoo.cfg 里最值得调校的几个参数一个最小可用的 3 节点配置可以长这样tickTime2000 initLimit10 syncLimit5 dataDir/data/zookeeper/snapshot dataLogDir/data/zookeeper/journal clientPort2181 server.1zk-node-01:2888:3888 server.2zk-node-02:2888:3888 server.3zk-node-03:2888:38882888 是 Leader 与 Follower 之间数据同步端口3888 是选举通信端口。dataLogDir 建议单独指定到独立磁盘因为事务日志的 fsync 是写路径上最大的瓶颈。dataDir 放快照文件dataLogDir 放事务日志两者不要放在同一个分区否则磁盘满了 ZooKeeper 会直接拒绝新事务。tickTime 是基础时间单位initLimit 和 syncLimit 以 tickTime 为单位。默认 tickTime2000、initLimit10、syncLimit5意味着启动同步窗口是 20 秒运行中心跳超时是 10 秒。如果集群跨机房或网络 RTT 偏大建议把 syncLimit 调大到 10 甚至 15避免一次网络抖动就触发主从切换。但也不要无限调大否则故障发现太慢。6.3 磁盘 IO 和 Full GC两个容易忽略的隐形杀手ZooKeeper 每次写提交都要经过 fsync。在机械盘、云盘或共享存储上fsync 延迟很不稳定可能出现几百毫秒甚至一秒以上的抖动。抖动发生在 Leader 上Follower 会以为它失联发生在 Follower 上Leader 会因为等不到 ACK 卡住写请求。把 dataLogDir 放到本地 SSD 或 NVMe 上是我维护过的集群里最有效的优化手段。另一个隐形杀手是 Java Full GC。ZK 节点在 Full GC 时无法处理任何请求如果停顿时间超过 syncLimit 的判定窗口其他节点就会认为它已经死亡并触发选举。比较好的预防办法是设置合理堆大小并开启 GC 日志观察有没有连续的长停顿。我通常建议堆大小控制在 2-4GB不要盲目追求大堆堆过大反而会拉长 GC 时间增大被误判的风险。6.4 故障时的排查顺序当集群出现 Leader 消失或频繁切换我的排查顺序是先确认是哪个节点被判定失联看它的磁盘 IO 和 GC 日志再看网络层有没有丢包抖动可以抓选举端口的包然后用四字命令查看状态比如echo srvr | nc zk-node 2181返回的模式和节点数echo mntr | nc zk-node 2181能看到更多监控指标。如果发现某个节点长期处于 LOOKING 状态或者日志里出现多节点竞争的迹象第一反应不是重启而是先用命令看每个节点各自认为的集群视图。绝大多数这类问题都是网络分区或防火墙隔开了选举端口。重启只能制造新的冲击甚至让故障窗口更长。6.5 客户端侧的几个坑最后说客户端。连接串一定要配多个节点地址不要只填一个sessionTimeout 建议设成服务端默认超时的 2 到 3 倍不要太贴近恢复窗口使用 Curator 这类封装时重试策略要限流避免所有客户端同时把重试请求砸向新 Leader。另一个常见坑是 maxClientCnxns默认只有 60如果某个业务 IP 上有大量并发连接可能触发连接被拒影响客户端重连。我的习惯是给 ZK 集群单独留一台监控机每隔几秒跑一次mntr记录 Leader 是否存在、未同步 Follower 数、znode 总量和事务写入延迟。这些指标平时波澜不惊只有故障前才会出现异常趋势。看懂它们比故障发生后翻日志省力得多。为什么我把 ZooKeeper 集群单独拎出来讲这么多因为在真实事故里ZK 很少是代码 bug 挂掉的绝大多数都出在一致性协议的最底层日志没有及时落盘、心跳超时设得太短、恢复阶段没有完成同步就匆匆重启。把这些底层机制捋顺之后你会发现很多线上问题都有非常直接的因果链。最后再分享一个亲测有效的小技巧每次做 ZK 节点升级或迁移前先把目标节点从集群里摘掉确认剩余节点仍满足过半条件再逐台操作。不要在大晚上把三台里的两台同时重启那个瞬间无论选举算法写得再好可用性都会因为法定人数不足直接归零。