ZooKeeper主节点崩溃恢复:ZAB选主、epoch与数据同步 凌晨两点被电话叫起来打开监控面板一个五节点的协调服务集群里主节点那条曲线是一条笔直的平线——进程没了剩下四个兄弟还在互相喊话业务侧的告警在同一个时间窗口里集中爆发连接超时、临时节点批量消失、分布式锁全部被释放。第一次遇到这个场面的人第一反应往往是“再选一个主不就完了”。但真把日志摊开看压在这个问题背后的其实是三件事叠在一起老主到底死没死透、新主手里有没有最新的数据、故障窗口里已经应答给客户端的东西还算不算数。这三个问题合起来就是 ZAB 协议ZooKeeper Atomic Broadcast原子广播中崩溃恢复这一半内容存在的全部理由。这篇东西适合两类人看一类是正在啃分布式一致性协议、被 Paxos/Raft 的论文绕得头晕想找个工程落点的人另一类是线上跑着协调服务集群只知道“三节点比单机稳”但主节点真挂了心里没底的人。我会把主节点崩溃之后的每一秒钟拆开讲清楚包括 zxid 怎么比、epoch 怎么涨、数据同步的四种策略怎么选以及那些只有真在生产环境里熬过夜才发现的小坑。1. 主节点倒下的那三十秒现场到底发生了什么1.1 一次典型故障的时间线还原先还原现场。假设一个五节点集群一主四从客户端通过会话连在上面做临时节点和分布式锁。主节点被 OOM Killer 干掉或者所在机器的磁盘 IO 卡死了进程还在但完全没响应。这个过程里发生的事情按时间顺序大概是这样从节点每隔一段时间给主节点发心跳连续几个心跳周期收不到回应从节点就判定“主节点失联”把自己的状态从跟随FOLLOWING切成寻找LOOKING然后开始向其他节点广播自己的投票信息。注意这个时候从节点并不知道主节点是真死了还是网络不通它只知道“我不能继续跟着一个没回应的主干活了”。与此同时客户端侧的感受是写请求全部卡住因为所有写都要由主节点串行处理读请求取决于你连的是哪个节点如果连的是从节点且读的是本地数据短时间内还能读到旧值看起来“服务正常”这正是最危险的地方——业务以为自己还活着实际已经在一个即将失效的视图上做决策。等新主选出来、数据同步完、开始对外服务通常是十几秒到几十秒的量级。如果你的tickTime调得比较大或者节点间网络抖动这个时间会被拉长到一分钟以上那时候客户端会话大概率已经超时了。提示会话超时和主节点故障恢复时间是两个独立的量。很多人以为“集群恢复快客户端就没事”实际上客户端的sessionTimeout必须大于“故障检测 选主 数据同步”三段时间之和否则临时节点一定会被清掉锁一定会丢。这个时间线里最容易被忽略的是“心跳判定”这一段。从节点判断主节点失联不是靠一次超时而是靠syncLimit * tickTime这个窗口。默认tickTime2000、syncLimit5也就是 10 秒才开始怀疑。这 10 秒设计上是为了容忍短暂的网络抖动和长 GC 停顿但如果你的主节点真的挂了这 10 秒就是纯损失。反过来如果你为了追求快速故障切换把syncLimit调到 2那么一次稍长的 Full GC 就可能触发一次不必要的选主主备来回切换业务感受更差。这个参数没有“最优值”只有“匹配你的 GC 和网络状况的值”。1.2 为什么“选个新主”只是整个问题的三分之一很多人把主节点崩溃想成“换个人当班长”但分布式系统里换主远不是交接钥匙那么简单。至少有三个硬问题必须在协议层面回答否则集群就是不可信的。第一个问题新主一定包含所有已提交的数据吗主节点在崩溃前可能刚把一条提案发给了部分从节点自己已经做了提交决定但还没通知所有人。如果新选出来的主恰好是那个“没收到这条提案”的节点那这条已经被应答给客户端的写就凭空消失了。这是绝对不允许的。第二个问题那些“半吊子”提案怎么办主节点发出的提案里有些被过半节点确认了可以提交有些只发到了一两个节点不能提交。恢复过程必须能把这批悬而未决的提案干净地处理掉——该提交的提交该丢弃的丢弃不能留一个“有的节点有、有的节点没有”的中间态。第三个问题恢复期间能不能接受新写入答案是不能或者说不能以客户端无感的方式接受。因为此时集群还没有形成一个唯一的、被所有节点认可的写入序列起点。硬要接受写入就会出现两个节点对同一个 zxid 有不同内容的灾难。这三个问题对应了 ZAB 恢复阶段要做的三件事选出一个数据最全的节点当主、让所有节点和新主对齐数据、用一个明确的标记划分新旧轮次。第三件事的答案就是后面要重点讲的 epoch 递增。1.3 ZAB 的定位它和通用共识算法差在哪这里必须解释一个设计取舍否则后面的很多细节看起来会像“多此一举”。ZAB 是一个主备模型的原子广播协议不是对称的共识算法。在它里面只有主节点能提出提案所有写请求都必须汇聚到主节点由主节点分配一个全局递增的编号后广播出去。为什么不做成任何节点都能提案的对称模型因为协调服务的使用场景对“顺序”的要求远高于对“吞吐”的要求。分布式锁的加锁顺序、临时节点的创建顺序、配置变更的生效顺序这些语义都依赖一个全局的、所有客户端都能达成一致的全序。主备模型天然给你这个全序——所有提案都从一个口子出来编号天然递增实现起来简单得多也容易推理。代价也很明确主节点是写入瓶颈恢复期间服务不可用主节点所在的机器配置要求更高。所以 ZAB 的名字里虽然有“广播”但它真正的价值在于“原子”——要么所有节点都认这条写要么所有节点都不认不存在中间状态。理解了这一点你再看它为什么要把运行过程硬生生切成“广播模式”和“恢复模式”两段就顺理成章了。2. 两种模式的切换广播态与恢复态2.1 广播态一条写请求的完整旅程先看健康状态下的路径因为恢复阶段的很多动作是为了“回到这个状态”服务的。一条写请求进入集群后经历的过程大致是这样的客户端连到集群中任意一个节点如果连的是从节点从节点会把请求转发给主节点自己不做任何写入决定。主节点收到请求把它包装成一个提案proposal并分配一个 zxid。主节点先把这条提案写进自己的事务日志此时是“预提交”还没有真正生效然后通过队列广播给所有从节点。从节点收到提案同样写入本地事务日志然后回一个确认ACK。主节点收到包含自己在内的过半节点的 ACK做出提交决定写一条 COMMIT 到本地日志然后广播 COMMIT 给所有从节点。从节点收到 COMMIT把提案应用到内存数据结构上回一个 ACK。主节点收到足够多的 COMMIT 确认后给客户端返回成功。这里有三个细节值得停下来看。第一主节点的提交决定不依赖所有节点只依赖过半。五节点集群里主节点只要收到自己加两个从节点共三个 ACK 就可以提交剩下两个慢节点不影响可用性。第二广播是走队列的主节点对每个从节点维护一条 FIFO 队列配合 TCP 本身的保序特性保证同一个从节点收到的提案顺序和主节点发出的顺序完全一致。第三提交和应用是两回事写日志是持久化应用到内存才是对外可见这个分离是为了崩溃后能从日志恢复。注意主节点是“先写日志再广播”不是“先广播再写日志”。顺序反了的话主节点崩溃重启后有可能出现“我记得我发过但日志里没有”的情况恢复逻辑会直接错乱。2.2 崩溃态一次失联触发的连锁反应主节点失联之后从节点这边的状态机开始转动。整个链条可以概括成四步但每一步都有讲究。第一步是状态切换。从节点发现主节点失联把自身状态从 FOLLOWING 改成 LOOKING意思是“我现在不跟任何人了我在找新主”。这个切换是原子的切换之后它不再接受主节点的任何提案哪怕老主节点的消息还在路上也会被丢弃。第二步是投票。每个 LOOKING 状态的节点向集群广播自己的投票信息里面包含自己的标识和本地最新的事务编号。收到别人的投票后按规则比较谁的“资历”更老就把票投给谁。相持不下就进入下一轮直到某个节点拿到过半票数。第三步是数据同步。新主产生后其他节点向新主注册新主根据每个节点和自己的数据差异决定用哪种方式把对方拉到和自己一致的状态。这一步是整个恢复过程里最耗时、也最容易出问题的环节。第四步是收尾。新主和一个过半的节点集合完成同步后会发起一个特殊的提案来正式宣告“我这个轮次开始了”这个提案被提交之后集群才重新回到广播态开始接受客户端写入。整个链条里第二步和第三步的先后顺序不能换。必须是先选出主再同步数据不能反过来。因为数据同步需要一个明确的“以谁为准”的基准这个基准只能由选主结果提供。2.3 为什么恢复期必须“停下来”不能边恢复边接客这是新手最容易质疑的地方既然只是同步数据为什么不能让集群一边对外提供读服务、一边慢慢同步答案藏在一个具体的场景里。假设主节点崩溃时提案 P 只发到了节点 A 和主节点自己节点 A 收到了但没收到 COMMIT节点 B 和 C 完全没收到。新主选出来是节点 B。如果此时允许客户端读客户端从节点 A 读可能看到一个“P 已经存在”的视图从节点 B 读看到的是“P 不存在”。同一时刻两个客户端看到两个不同的世界这在协调服务里是致命的——比如一个客户端认为锁被占用了另一个客户端认为锁是空闲的直接导致两个客户端同时持锁。所以恢复期必须是一个“黑盒”外部看到的要么是故障前的状态要么是恢复后的状态中间态不可见。这就是为什么协议要严格区分两种模式而不是模糊地“渐进式恢复”。有些系统会在这段时间内降级为只读但那是权衡之后的选择不是协议本身的能力。3. zxid 与 epoch崩溃恢复的两把尺子3.1 64 位 zxid 的切分逻辑与计算验证ZAB 里所有事务都有唯一编号叫 zxid是一个 64 位整数。它不是简单的自增计数器而是被切成了两段高 32 位是 epoch也叫轮次号低 32 位是事务计数器。这个切分是整个恢复机制的地基。为什么要切因为如果只有一个单调递增的计数器主节点崩溃重启后新主根本不知道自己该从哪个数字接着数——接小了会和老提案冲突接大了会留下空洞。而有了 epoch新主只要把 epoch 加一、计数器清零就能得到一个明确大于所有历史事务的新编号空间天然解决了“我是新一轮”的表达问题。具体算一下会更清楚。假设当前 epoch 是 7本轮的计数器走到 300那么 zxid 就是epoch 7 counter 300 zxid (epoch 32) | counter print(hex(zxid)) # 0x70000012c print(zxid 32) # 7 print(zxid 0xFFFFFFFF) # 300反解也一样直接右移 32 位拿到 epoch与 0xFFFFFFFF 做与运算拿到计数器。这种设计在比较大小的时候特别好用——直接按无符号整数比较 zxid谁的数值大谁的事务更新不需要先比 epoch 再比计数器一次比较就够。这一点在选主投票里非常关键后面会用到。字段位宽含义何时变化epoch高 32 位主节点轮次号每次新主上任递增counter低 32 位该轮次内的事务序号每提交一个提案递增zxid64 位全局事务编号由前两者组合而成3.2 epoch 递增如何挡住“幽灵主”epoch 的真正威力在脑裂场景下才显现。构造一个场景老主节点因为交换机故障被隔离在一个小分区里它自己并不知道外面已经变天了还认为自己活着继续处理客户端请求、继续生成提案、继续用旧的 epoch 编号广播给“从节点”。如果此时集群的其余节点已经选出了新主新主的 epoch 比老主大 1。当老主发出的提案到达新分区里的节点时节点只需要做一个判断这条提案的 epoch 是否等于我当前认可的 epoch。不等直接丢弃连日志都不写。老主因为永远拿不到过半节点的 ACK也就永远无法提交任何提案最终会发现自己“孤立无援”而主动退出。这就是 epoch 的价值它把一个“谁是主”的时序问题转化成了一个“编号谁更大”的纯数值比较问题不需要节点之间反复协商也不需要额外的租约机制。判断规则简单到可以写进一行代码但效果是绝对的。实操心得排查“疑似脑裂”问题时第一件事不是看节点存活而是把各节点日志里的 epoch 拉出来对比。如果同一个时间点出现两个不同的 epoch 在处理写请求那基本可以确认是旧主没有及时退出通常和 GC 停顿、磁盘阻塞或者虚拟机被挂起有关。3.3 过半确认的边界多数派到底保证了什么过半这个设计经常被误解成“性能优化”其实它是正确性保证。核心逻辑是任意两个过半集合必然有交集。五节点集群里提交一个提案需要 3 个节点确认选举一个新主也需要 3 个节点投票这两个 3 节点集合至少有一个节点是重叠的。这个重叠节点既知道“哪些提案已经提交了”又参与“谁当新主”的投票所以它绝不会把票投给一个缺少已提交提案的节点。节点数和容错能力的关系可以直接算出来。设节点数为 n过半数为 q n/2 1向下取整后加一能容忍的故障节点数为 f n - q。节点数 n过半数 q可容忍故障 f说明110单机无容错321最常见的起步配置431比三节点多花一台机器容错不变532生产环境主流选择642同样不划算743写延迟开始明显上升看表就能明白为什么大家都说“用奇数”。四节点和六节点多出来的机器不增加任何容错能力反而因为要等更多的 ACK 而增加了写延迟。当然如果是跨机架部署多一台机器可以作为“备胎”加速恢复但这属于运维层面的考虑不是协议层面的收益。还有一个容易被忽略的点Observer 不参与投票。它的设计目的是扩展读能力不进入过半计算。一个三节点一主两从加两个 Observer 的集群写操作依然只需要 2 个节点确认Observer 挂了对写入没有任何影响。但如果把 Observer 当成“提升容错”的手段那就是理解错了——容错看的是参与投票的节点数。3.4 数据同步的四种策略DIFF、SNAP、TRUNC 怎么选选主完成后新主面对的第一个工程问题就是每个从节点的数据进度都不一样怎么把它们拉到一致ZAB 准备了三种处理方式选哪种完全由“从节点的最新 zxid 和新主的差距”决定。DIFF差异同步从节点只是稍微落后新主内存里还保留着它缺少的那部分提案直接把缺的那几条发过去就行。这是最快的路径通常几十毫秒完成。SNAP快照同步从节点落后太多新主内存里已经没有对应的提案记录了只能把当前完整的数据快照整个传过去从节点加载快照后再增量补齐。这是最慢的路径数据量大的时候可能要几百兆甚至几个 G 的传输。TRUNC截断同步这种情况比较特殊——从节点的事务编号反而比新主还大。听起来像是新主落后了但实际上是因为这个从节点曾经跟着旧主接收了一些从未被提交的提案。新主的处理方式是让从节点把这些“多余”的提案砍掉回退到和新主一致的位置再走差异同步。策略触发条件数据流向耗时量级典型场景DIFF从节点 zxid 在新主的提案队列范围内主到从增量毫秒级短暂网络抖动SNAP从节点 zxid 早于新主内存队列的起点主到从全量秒到分钟级节点长时间离线TRUNC从节点 zxid 大于新主的最新 zxid从节点回退毫秒级旧主残留提案判断逻辑说白了就是一次范围比较新主拿到从节点的最新 zxid先在内存的提案队列里找有没有这个位置。找到了就走 DIFF找不到但 zxid 更小就走 SNAPzxid 更大就走 TRUNC。三段逻辑写完不超过二十行代码但它决定了集群从故障中恢复的速度也决定了你有没有必要为了“快速恢复”去调大内存里保留的提案数量。4. 选主过程拆解从投票到新主就绪4.1 谁有资格当主一条只比两个字段的规则选主的比较规则简单到有点反直觉每张票包含两个关键信息——节点标识sid和该节点的最新事务编号zxid。比较时先比 zxidzxid 大的胜出zxid 相同再比 sidsid 大的胜出。为什么先比 zxid因为 zxid 代表数据的新旧。一个节点的事务编号越大说明它掌握的历史越完整。让数据最新的节点当主可以最大程度减少后续同步的工作量更重要的是能保证新主一定包含所有已经提交的提案——这是安全性要求不是性能优化。为什么 zxid 相同要比 sid因为必须有一个确定性的规则来打破平局否则两个节点可能永远互相觉得“我比你更合适”投票无法收敛。用 sid 做兜底保证了任意两个节点之间的比较结果一定是全序的、无歧义的不会出现 A 觉得 B 好、B 觉得 A 好的死循环。注意这条规则意味着你不能人为指定某个节点当主。有些团队希望把主节点固定在配置最好的机器上于是通过调整启动顺序之类的办法“引导”选举结果这在正常情况下可能有效但一旦发生故障切换规则还是会回到“数据最全者优先”。与其做这种引导不如把所有节点的硬件配置拉齐。4.2 投票收敛为什么几轮之内一定能出结果投票过程是一个不断“听到更好的就改票”的迭代。每轮里节点把自己的投票广播出去收到别人的投票后如果对方的候选人比自己当前的候选人更优就改投对方并把轮次推进重新广播。如果收到的票比自己差就不动但在下一轮继续广播自己的立场。举个三节点的例子。节点 A 的 zxid 是 100节点 B 是 105节点 C 是 102。第一轮A 投自己100B 投自己105C 投自己102。消息交换后A 发现 B 的 105 更大改投 BC 也发现 B 更大改投 B。第二轮A 和 C 都投 BB 拿到 3 票超过半数选举结束。整个过程两轮几十毫秒。这个收敛性是有保证的因为比较关系是全序的全局最优的那个节点一定会被所有人逐步识别出来。最坏情况下的轮次和节点数的量级相当实际生产中很少超过三四轮。真正拖慢选举的通常不是轮次而是网络——如果节点之间本来就存在网络分区消息反复丢失那投票会一直无法过半集群就卡在“没有主”的状态这时候要做的是排查网络而不是调协议参数。4.3 新主上任的三把火宣告、对齐、开工选出来只是开始新主必须完成一系列动作才能真正对外服务。这个过程可以理解为“就职典礼”。第一把火是宣告轮次。新主把 epoch 加一然后向所有节点发送一个特殊的提案表明“从现在起我这一轮开始了”。这个提案不走普通的提交流程而是有一个专门的类型标记。其他节点收到后更新自己记录的 epoch并开始接受这位新主的领导。第二把火是对齐数据。新主逐个处理每个节点的注册请求按照前面讲的 DIFF/SNAP/TRUNC 策略把每个节点拉到和自己一致。这一步做完集群内所有节点的数据视图统一了。第三把火是敲定悬案。新主会提交一个空事务有些实现里是随宣告提案一起完成的。这个空事务本身不改变任何业务数据它的作用是给上一个轮次所有悬而未决的提案画一条线在这条线之前的全部作废或者全部提交不能再有中间态。这条线画完之后集群才真正回到广播模式。这三步的顺序不能乱也不能省。省掉第一步节点无法判断新旧轮次省掉第二步各节点数据不一致就开始接客省掉第三步旧轮次的残留提案可能在后续某个时刻“复活”造成数据错乱。4.4 一段可运行的模拟代码把投票收敛跑出来光看文字容易虚写一段能跑的模拟代码会更直观。下面这段用 Python 模拟了选主的核心逻辑不涉及网络只演示比较规则和收敛过程。import random class Node: def __init__(self, sid, zxid): self.sid sid self.zxid zxid self.vote_for sid # 当前投给谁 self.round 1 def better(self, other): # 先比 zxid再比 sid返回更优的那个节点标识 if other.zxid ! self.zxid: return other.sid if other.zxid self.zxid else self.sid return other.sid if other.sid self.sid else self.sid def election(nodes): history [] for rnd in range(1, len(nodes) 2): # 收集本轮各节点的投票立场 ballots {n.sid: n.vote_for for n in nodes} history.append((rnd, dict(ballots))) changed False for n in nodes: for m in nodes: if n.sid m.sid: continue # 用 m 的 (sid, zxid) 和 n 当前支持的候选比较 cand next(x for x in nodes if x.sid n.vote_for) winner n.better(m) if cand.sid n.sid else cand.sid # 上面这行只是简化示意真实实现按 (zxid, sid) 全序比较 target max([cand, m], keylambda x: (x.zxid, x.sid)) if target.sid ! cand.sid: n.vote_for target.sid changed True # 统计票数看是否有人过半 tally {} for n in nodes: tally[n.vote_for] tally.get(n.vote_for, 0) 1 for sid, cnt in tally.items(): if cnt len(nodes) // 2: return sid, history if not changed: break return None, history if __name__ __main__: cluster [Node(1, 100), Node(2, 105), Node(3, 102)] leader, hist election(cluster) for rnd, ballots in hist: print(f第 {rnd} 轮投票: {ballots}) print(f最终主节点: {leader})跑出来的结果会显示第一轮各投各的第二轮收敛到 zxid 最大的节点 2。把节点数改成 5 个、zxid 改成随机值会发现绝大多数情况下两三轮就收敛偶尔出现平局也只需要多一轮。这段代码的意义不在于复现生产逻辑而在于帮你建立一个直觉选主不是一场复杂的谈判而是一次全序比较的收敛过程。理解了这一点再看真实实现里那些字段版本号、状态、选举轮次就知道它们是为了处理网络乱序和消息重放而不是改变比较规则本身。5. 常见故障场景与排查手册5.1 “写成功了却没了”数据回滚的真相这个现象听起来像数据丢失但绝大多数情况下是理解偏差。分布式系统里“写成功”有三种不同的含义客户端发出了请求、服务端做出了提交决定、客户端收到了成功应答。这三者之间任何一处断掉都会让客户端产生误判。真实会发生的情况是这样的主节点收到请求广播提案拿到过半 ACK做出提交决定然后开始给客户端回包——就在这个瞬间主节点崩溃了。客户端没收到成功应答超时了以为失败了但新主上任后这条提案因为已经被过半节点确认会被保留下来并最终提交。反过来也有主节点广播了提案但还没拿到过半 ACK 就崩了客户端也没收到应答这条提案会被丢弃。客户端在这种情况下无法判断到底成没成功这就是分布式系统里的“不确定态”。提示解决不确定态的唯一办法是让业务操作具备幂等性。比如“创建节点”这种操作用带版本号的乐观锁代替裸创建比如“扣减库存”这种操作用唯一业务流水号做去重。指望协议层给你一个“绝对明确的成功/失败”是不现实的任何声称能做到的说法都值得怀疑。5.2 选主反复拉锯不是协议的锅是环境的锅比“选不出主”更烦人的是“选出来了又切走切走了又选”。集群日志里能看到 epoch 在短时间内连续递增业务侧表现为间歇性的写入超时。这类问题基本可以按下面的顺序排查。先看 GC。从节点判断主节点失联靠的是心跳超时一次长时间的 Full GC 就能让主节点“假死”超过阈值。如果是堆内存大、对象多的场景GC 停顿三五百毫秒很常见但如果是几个 G 的堆加上不合适的垃圾回收器停顿两三秒也不稀奇这时候syncLimit设得太小就会误判。再看磁盘。事务日志的写入是同步刷盘的如果磁盘 IO 出现抖动比如和别的服务共用一块盘或者用了网络存储主节点的写入延迟会飙升从节点等不到提案也会误认为主节点出问题。这也是为什么生产环境强调事务日志要放在独立的本地盘上。最后看网络。跨机架、跨可用区部署时网络抖动的概率远高于同机柜。如果节点分布在不同区域建议把tickTime适当放大用一点点故障检测延迟换取稳定性。5.3 集群起不来过半不足与 Observer 的误解还有一种情况是主节点挂了之后集群干脆起不来一直卡在选主。这时候先数一数活着的、参与投票的节点有几个。假设五节点集群挂了两台剩三台刚好够过半数能选主。但如果挂了三台剩两台永远选不出主集群进入不可用状态。这是设计使然不是 bug——宁可不服务也不能让两个节点在没有共识的情况下自作主张。另一个常见误解是把 Observer 算进过半数。一个三投票节点加两个 Observer 的部署如果三个投票节点只剩两个集群仍然可用但如果三个投票节点只剩一个哪怕两个 Observer 都活着集群照样不可用。Observer 的作用是分担读请求、降低跨区域同步的带宽消耗它不提供任何容错能力。注意做机房级容灾规划时投票节点必须分散在至少三个故障域里。如果五个投票节点里三个在同一个机柜那个机柜断电集群直接不可用另外两个机柜的节点再多也没用。5.4 排查速查表从现象倒推原因把上面这些经验整理成一张表出事的时候可以直接对着看。现象高概率原因优先排查动作写入全部超时读正常主节点失联集群在选主看各节点状态是否为 LOOKING看日志有无 epoch 递增会话频繁过期临时节点消失恢复耗时超过 sessionTimeout对比恢复日志时间戳与客户端超时配置epoch 短时间连续递增GC 停顿或磁盘抖动导致误判拉 GC 日志、看磁盘 IO 延迟曲线集群长时间无法选主存活投票节点不足过半数存活节点数确认 Observer 未计入某节点反复被截断同步该节点曾跟随旧主接收未提交提案检查该节点日志中的 TRUNC 记录确认旧主是否残留同步耗时极长落后太多触发全量快照看同步日志里的策略类型评估是否要扩容内存队列6. 工程落地参数、监控与演练6.1 关键配置项的取值逻辑参数不能照抄得知道每个数字在控制什么。下面这几个是最常调的。tickTime是时间基准其他很多参数都是它的倍数。它不是“心跳间隔”那么简单而是整个时间体系的刻度。默认 2000 毫秒大多数场景够用跨区域部署可以放宽到 3000 到 4000。initLimit控制从节点启动时与主节点完成初次同步允许的 tick 数。集群数据量大、快照大的时候这个值要放大否则新节点还没同步完就被判定超时陷入“启动—超时—重启”的循环。syncLimit控制运行期心跳超时前面讲过它直接决定故障检测的灵敏度。默认 5意味着 10 秒才判定失联。如果你的业务对故障切换时间敏感可以降到 3如果 GC 停顿大就得往上加。snapCount控制多少次事务后打一次快照。太小会导致频繁的磁盘写入放大太大会导致重启时回放日志的时间过长。经验值是几万到十几万之间具体要看单条事务的大小和磁盘写入能力。maxSessionTimeout是会话超时的上限。客户端申请的会话时间不能超过这个值。设得太小客户端在网络抖动时容易掉线设得太大主节点故障后临时节点迟迟不清锁释放不及时。这个值要结合业务的容错预期来定。# 一个典型的生产配置片段示意 tickTime2000 initLimit10 syncLimit5 snapCount100000 maxSessionTimeout60000 dataLogDir/data/zk/txnlog dataDir/data/zk/snapshot注意dataLogDir和dataDir分开配置这一条是很多团队踩过坑才加上的。事务日志是顺序写、同步刷盘快照是随机写、数据量大两者放同一块盘上会互相干扰主节点的写延迟一抖动选举就跟着抖。6.2 监控什么四个必须盯住的指标第一个是主节点是否存在。这个指标看起来废话但很多团队只监控进程存活不监控“集群里有没有主”。进程都活着但选不出主的情况是真实存在的必须有一个独立的探测。第二个是写入延迟的分位数尤其是 P99。平均值好看不代表没问题一次提交卡住会让 P99 直接爆表而这往往是大故障的前兆。第三个是各从节点落后主节点的提案数。这个数字持续增长说明某个从节点跟不上一旦它被选为主恢复时间会非常长。落后太多的时候应该主动干预比如重启该节点触发重新同步。第四个是选举次数。正常运行的集群可能几周都不选举一次如果一天内出现多次说明环境有问题回去看 5.2 节。6.3 演练怎么做把kill -9变成常规动作最后说一个我认为最有价值的事定期做故障演练。不是演练“优雅重启”是直接kill -9主节点进程模拟最恶劣的情况。演练时要观察三件事业务侧的报错窗口有多长、集群恢复后数据是否一致、客户端的会话有没有异常丢失。第一次演练大概率会发现一些问题比如恢复时间比预期长、某些客户端没有正确重连、临时节点清理导致业务逻辑出错。这些问题在演练里暴露比在凌晨两点的告警里暴露要好得多。演练的节奏建议是每季度一次并且在任何一次配置变更、版本升级、机器迁移之后补做一次。分布式系统的行为对环境和参数极度敏感纸面上的“应该没问题”从来不作数。我个人在实际运维中的体会是ZAB 这套机制的正确性其实很少出问题出问题的永远是环境——磁盘慢了一点、GC 长了一点、网络抖了一下然后被放大成一次集群级别的故障。所以与其反复研究协议细节不如把精力花在两件事上把参数调到匹配自己的环境以及把故障演练做成肌肉记忆。协议保证了“最坏情况下不丢数据”但“最坏情况下业务能多快恢复”那是运维的活儿。