凌晨一点半,监控大屏突然炸了。业务群里有人喊“分布式锁失效了”,后台日志里出现了两个节点同时认为自己才是集群主节点的记录。等网络抖动恢复,数据一比对,才发现同一把锁被两边各写了一次,关键业务字段互相覆盖,损失已经无法挽回。这种事故,圈里人叫它集群脑裂(split brain),而绝大多数脑裂事故的化解方案,绕不开一个核心机制:多数派 Quorum 选举。
作为一个常年跟分布式系统打交道的工程师,我可以直说:脑裂不是一个“万一遇到”的小概率问题,它是任何多节点系统在设计时都必须正面回答的问题。这篇内容围绕“集群脑裂:多数派 Quorum 选举”展开,重点讲清楚脑裂的本质、多数派为什么能救场、选举机制如何落地,以及我踩过的坑和现场排障经验。适合正在做分布式存储、消息队列、配置中心、注册中心这类基础组件的开发同学,也适合负责运维分布式集群的 SRE 和架构师阅读。
1. 先搞懂脑裂:一场令人头疼的分布式灾难
1.1 什么是脑裂?用生活场景理解它
互联网公司里,任何分布式系统都由多个节点组成。节点之间通过网络传递心跳、同步数据、协商主从关系。脑裂的本质,就是“网络分区”导致集群内部出现两派节点,各自都认为自己是合法的主控方,彼此无法协商,最终各干各的。
举个例子。你家有两个管家,平时一个管账、一个管物,配合得很好。但有一天,他们之间用来沟通的对讲机坏了。A 管家联系不上 B 管家,B 管家也联系不上 A 管家。两边都担心对方“不在了”,于是各自开始全权处理家里所有事务。等对讲机修好,两个人一对账,发现钱和东西都对不上了——这就是脑裂。
在分布式系统里,“对讲机”就是节点之间的网络连接,“管家”就是集群中的节点角色。网络抖动、交换机故障、网卡异常、长停顿(比如垃圾回收暂停)、磁盘 IO 卡顿,都可能导致节点之间的心跳丢失。一旦心跳丢失超过阈值,节点就会认为同伴“死”了,于是尝试重新选举,试图让自己成为新的主节点。但与此同时,旧主节点如果还活着,它并不知道自己被“下线”了,继续接受写请求。这时,两个“主节点”同时存在,脑裂发生了。
1.2 脑裂为什么可怕:不只是多了一个主节点
很多人第一反应是:脑裂无非就是多选出一个主节点,把其中一个降级不就行了?但实际上问题要严重得多。
最典型的伤害是多个主节点同时写入同一个数据项。比如配置中心里有条配置项switch=on,旧主节点把它改成switch=off,新主节点把它改成switch=on。两边都成功写入本地存储。等到网络恢复、两边尝试同步数据时,发现同一个 key 有两个不同的值,到底以谁为准?任何自动合并规则都可能产生错误。
再比如分布式锁。基于 Redis 的分布式锁、基于 ZooKeeper 的临时节点锁,在脑裂时都可能出现两个客户端同时拿到锁的情况。对资金操作、库存扣减这类场景,双写一次可能就会造成严重的事故。我见到过不止一次因为脑裂导致的资损事故,往往事后要花大量时间人工对账、修复脏数据,痛苦至极。
更麻烦的是,脑裂发生时不一定会立刻暴露问题。如果两个分区各自正常运行,没有发生对同一 key 的写入,表面上看集群是健康的。直到网络恢复后开始做数据对齐,才发现两个分区的数据出现了不可调和的矛盾。这种滞后性使得排障更加困难,因为你很难确定事故是从哪一刻开始的。
1.3 哪些场景最容易触发脑裂
根据我的经验,下面这些场景是最常见的高危区:
- 网络设备异常:交换机单点故障、光纤不稳定、防火墙误拦心跳端口。
- 虚拟化环境抖动:宿主机负载过高导致虚拟机网络中断,或者宿主机迁移期间网络不可用。
- 长 GC 暂停:JVM 应用 Full GC 长达十几秒,节点间心跳超时。
- 磁盘 IO 卡顿:本地磁盘写入速度骤降,节点“假死”。
- 网络分区维护:机房断电、链路割接、跨地域专线质量不稳定。
只要心跳机制存在,上述任何一种情况都可能触发集群的重新选举逻辑。如果没有 Quorum 机制兜底,后果不堪设想。反过来,有了 Quorum 机制,即使某些节点失联,也无法轻易形成两个“合法主节点”,这是分布式系统设计的第一道防线。
2. 多数派 Quorum:解决脑裂的数学底线
2.1 Quorum 是什么?一句话版本
Quorum,中文常译为“法定人数”或“仲裁”。在一个 N 个节点的集群里,任何一项需要达成共识的操作,都必须获得超过半数节点(即N/2 + 1个节点)的认可,才能被认为是合法的。
这句话听着很简单,但它背后的作用是革命性的:在任何网络分区场景下,永远不可能同时存在两个“超过半数”的派别。因为两个派别若要各自达到多数派,节点总数加起来至少需要N + 2个,这在只有 N 个节点的集群里不可能发生。数学上保证了“多数派”的唯一性,也就避免了脑裂后出现两个合法的决策源。
我举个具体例子。集群有 5 个节点,网络抖动导致两个节点和另外三个节点失联。此时前两个节点即使想选主,最多只有 2 票,不够法定人数;后三个节点有 3 票,可以顺利选主。于是只有后者能合法选举产生主节点。两个节点的分区因为达不到多数派,会一直停留在 Candidate 状态或退化为只读。等到网络恢复,两个节点再重新加入集群并同步数据。这就是 Quorum 的威力。
2.2 为什么集群设计成奇数节点
做分布式系统选型时,很多人会问:为什么大多数中间件推荐 3 个、5 个节点,而不是 2 个、4 个?答案就在多数派容忍度和成本权衡之间。
用 2 个节点举例。两个节点的多数派是 2(因为2/2 + 1 = 2),只要一个节点挂了,剩下的一个节点就无法达到多数派,整个集群就停了,毫无高可用性。3 个节点时,多数派是 2,允许 1 个节点宕机而不影响集群运行。5 个节点时,多数派是 3,允许 2 个节点宕机。可以总结为:2N+1 个节点,最多容忍 N 个节点故障。
相比之下,4 个节点的集群多数派是 3,也只允许 1 个节点故障,和 3 个节点的容忍度一样,却多买了一台机器。所以从成本和容错能力的角度看,奇数节点总是更经济的方案。这也是 Raft、Zab、etcd、Consul 等系统普遍建议奇数成员的原因。
2.3 不只是写操作:读操作同样需要 Quorum
很多人忽略了一个点:Quorum 不只在选举主节点时生效,在读写数据时也有对应的约束。分布式共识算法里,写操作需要获得多数派节点确认才能提交,而读操作如果不想读到过期数据,也需要与写操作形成某种交集。
具体来说,假设集群有 5 个节点,写 Quorum 设为 W(比如 3),读 Quorum 设为 R(比如 3)。只要R + W > 5,就能保证一次读操作至少能读到一次写操作已经落盘的数据。极端情况下,读操作读取的那几个副本中可能没有最新数据,但由于与写 Quorum 有交集,至少有一个节点返回了新值,配合版本号比较就能拿到最新结果。
这一条在选择存储系统副本策略时很关键。有些系统允许配置R=1(只读主副本),此时读性能很高,但读到过期的风险也高。有些系统配置R=W=N(所有人都要参与),安全性最高但可用性大打折扣。真实业务里需要根据一致性需求去权衡 R 和 W 的大小。
2.4 Quorum 背后的假设:先谈投票权再谈多数
严格来说,多数派并不总是按“节点个数”计算,也可以按“权重”计算。比如一个节点磁盘更大、网络更好、承担更多数据分片,可以给它更高的投票权重。此时 Quorum 的计算就要基于权重的总和过半,而不是节点数量过半。
但这种做法在实践中相对少见,因为它增加了管理复杂度:一旦权重配置不合理,很容易出现某个节点权重过高,但它宕机后整个集群满足不了多数派的情况。大多数开源系统选择“一节点一票”的简单模型。如果你在设计自研系统,建议也遵循简单原则,先把一节点一票跑通,再考虑权重这类增强特性。
3. 选举机制:Quorum 如何从理论走向落地
3.1 旧主节点的心跳租约如何失效
选举的第一步,是让旧主节点腾出位置。如果旧主节点一直没有感知到自己失联,它可能继续以主节点身份对外服务。Quorum 机制中通常引入“租约”概念:主节点只有在租约有效期内才能执行写操作,而租约需要节点们定期续约。
比如一个基于 Raft 的系统,Leader 会周期性发送心跳给其他节点,Follower 收到心跳就刷新本地计时器。假设选举超时时间设置为 5 秒,如果 Follower 超过 5 秒没有收到 Leader 的心跳,它就会认为 Leader 可能已经失效,开始发起新一轮选举。这里的“5 秒”就是一种租约。哪怕旧 Leader 实际还活着,但由于它无法在超时时间内联系到 Follower,它也不能继续合法地写入。
等网络恢复后,旧 Leader 会发现自己已不是当前任期的 Leader,必须转化为 Follower 角色,接受新 Leader 的日志同步。这个过程中有一个非常关键的机制叫fencing token(隔离令牌):每次选举成功,都会生成一个单调递增的令牌,旧 Leader 持有的令牌已经过期。如果旧 Leader 在恢复后还想写入,它必须用新令牌,因此无法提交旧数据,防止了脑裂后旧主节点继续写脏数据。
3.2 任期与投票:完整选举流程
以 Raft 为例,一次典型的选举过程可以拆成下面几步:
- Follower 在选举超时时间内没有收到 Leader 心跳,进入 Candidate 状态。
- Candidate 将当前任期号 Term +1,并给其他节点发送 RequestVote RPC,请求对方投票给自己。
- 每个节点同一个任期只能投一票,通常先来先得,并且只投票给日志足够新的候选者。
- 如果 Candidate 收到超过半数的投票,它成为新 Leader。
- 新 Leader 立即开始发送心跳,巩固自己的地位,同时重置其他节点的选举定时器。
这里有个细节必须注意:节点并不是盲目投票的。Raft 要求候选者的日志至少不能落后于自己,否则它可能丢失已提交数据。日志新的判定规则是:比较最后一个日志条目的任期号和索引号,任期号大的更新,任期号相同则索引号大的更新。如果不检查这一步,一个日志落后的节点被选为 Leader,就可能把已提交的数据覆盖掉,造成严重的数据丢失。
ZooKeeper 的 Zab 协议在选主时也有类似机制。ZooKeeper 里每个事务都有一个 ZXID(ZooKeeper Transaction ID),包含逻辑时钟和事务序号,选主时会选择 ZXID 最大的节点优先成为主节点,目的同样是避免数据丢失。
3.3 投票统计:为什么必须严格过半
回到多数派的本质,投票统计必须严格超过N/2而非等于N/2。比如 4 节点集群,多数派是 3,如果误以为 2 就是多数派,那就可能出现两个 2 节点的分区各自选主,脑裂依旧会发生。“严格过半”确保了任何两个多数派集合必有交集,这是共识算法正确性的基石。
那么,如果恰好两个候选者各获得一半投票怎么办?比如 4 节点集群中 A、B 各得 2 票,都不足 3 票,选举失败,两个候选者在超时后重新发起新选举。为了避免两个节点长期僵持,系统通常加入随机超时时间。每个候选者等待一个随机的重新选举超时时间(比如 150ms 到 300ms 之间),谁先超时就先发起新选举,大概率能打破僵局。这也是 Raft 随机 election timeout 的设计初衷。
3.4 不只选一次:日志复制时也需要 Quorum
选主只是 Quorum 的一种应用。一个集群在正常运行期间,Leader 需要把客户端请求写入日志并复制到各个节点。每一条日志的提交,同样需要多数派节点确认写入成功。
这体现了一个更底层的规律:Quorum 是一个贯穿共识算法、数据复制、状态机同步的统一原则。Leader 可以没有到多数派就选不出来,日志不能到多数派就不能提交,只有提交后的日志才能应用到状态机。理解了这一点,你就明白为什么很多系统会发生“写延迟增加”这种情况——因为每笔写都要等多数派节点返回 ACK,而不是只等主节点写本地磁盘。
我见过很多人在调优时问:能不能降低写 Quorum,只要一个节点返回就提交?答案是可以,但会牺牲一致性。如果那个唯一的节点又宕机了,数据可能就永久丢失。除非业务能接受这样的数据风险,否则最好让 Quorum 保持多数派。
4. 实际系统里的 Quorum 选主:从 etcd 到 ZooKeeper
4.1 etcd 选主参数与心跳调整
etcd 是云原生世界里最常见的分布式键值存储,底层基于 Raft 算法。和选主相关的两个核心参数是heartbeat-interval和election-timeout。
heartbeat-interval:Leader 给 Follower 发送心跳的间隔,默认 100ms。election-timeout:Follower 等待 Leader 心跳的超时时间,默认 1000ms(实际取值范围是 heartbeat-interval 到两倍之间)。
这两个参数需要配合调整。如果心跳间隔太短,系统会频繁发送心跳,消耗网络和 CPU;如果太长,故障感知变慢,集群恢复时间变长。如果集群跨地域部署,网络 RTT 较高,可能需要把心跳间隔调到 200ms 甚至更高,不然心跳频繁超时,会导致 Leader 频繁切换。
调整参数时要注意:election-timeout一般要大于心跳间隔的 5 倍以上,避免网络抖动引起选举风暴。以 etcd 的默认值来说,100ms 的心跳配合 1000ms 的选举超时,已经留出了较大余量。但如果你的网络质量不好,建议用官方文档推荐的--heartbeat-interval=500 --election-timeout=5000这类组合。
4.2 ZooKeeper 的 Quorum 机制和角色演进
传统 ZooKeeper 集群使用 Zab 协议,角色分为 Leader、Follower 和 Observer。选举时,只有 Follower 和 Leader 有投票权,Observer 只负责处理读请求,不参与投票。这一点常常让新人困惑:为什么加了 Observer 不能提升选主性能?因为 Observer 的目的在于扩展读能力,而非提升共识能力。
ZooKeeper 里的核心超时参数是tickTime,以及initLimit和syncLimit。其中initLimit是 Follower 初始连接 Leader 并完成同步的最大时间,syncLimit是 Follower 与 Leader 之间心跳的最大延迟时间。如果集群节点分布在不同机房,需要相应调大这些参数。ZooKeeper 3.8 之后还引入了QuorumVerifier可以灵活配置投票权重,但默认还是等权。
4.3 主流中间件的 Quorum 参数速查
为了方便平时查阅,我整理一个常用系统 Quorum 选主相关的速查表,覆盖同类开源系统在选举与多数派方面的关键配置:
| 系统 | 共识协议 | 默认多数派计算 | 关键参数 | 特别说明 |
|---|---|---|---|---|
| etcd | Raft | N/2 + 1 | heartbeat-interval, election-timeout | 节点数建议奇数,最少 3 节点 |
| ZooKeeper | Zab | N/2 + 1 | tickTime, initLimit, syncLimit | Observer 不参与投票,可扩展读 |
| Consul | Raft | N/2 + 1 | HeartbeatTimeout, ElectionTimeout | 多数据中心场景要小心跨地域选举 |
| Kafka(KRaft) | Raft | N/2 + 1 | controller.quorum.election.timeout.ms | 新版本不再依赖 ZooKeeper,控制器选主关乎分区元数据 |
| Redis Sentinel | 自研选主逻辑 | N/2 + 1(按 Sentinel 数量) | down-after-milliseconds, failover-timeout | Sentinel 数量建议奇数,防止两个数据中心独立选主 |
| ClickHouse | 内置多主 + 选主(如 keeper) | N/2 + 1 | 依赖 Keeper 的 election_timeout | 老版本 zoo 专家调度,新版本推荐 ClickHouse Keeper |
这里要补充一点:上面这些系统的多数派计算都是基于“节点数量”,如果你部署成偶数节点,同样可能发生两个候选者各拿一半票导致选举僵持。因此部署时不要把节点数搞成偶数,特别是在跨机房双活场景下,更要注意避免两个机房的节点数对半开。
5. 现场排障:脑裂问题识别与恢复实录
5.1 判断脑裂的信号有哪些
脑裂发生时,系统通常会出现以下异常表现:
- 同一时刻有两个节点声称自己是 Leader。在 etcd 中可能看到
etcdserver: leader changed日志反复出现,或者raft.node提示不同节点的 Leader ID 不一致。 - 分布式锁偶尔加锁成功,但业务侧同时出现两个执行者持有锁。如果锁基于 ZooKeeper,可能出现两个客户端都创建了临时节点,且都认为自己获取锁成功。
- 监控图中出现 Write/Read 失败率突增,Leader 频繁切换,或者某些节点一直在进行选举循环。
- 数据不一致:不同节点查询同一个 key 返回不同值,或客户端连接的节点各不相同,导致看到的数据视图不一致。
值得注意的是,脑裂期间集群不一定会完全不可用,可能只是部分请求失败。如果某个分区不足多数派但包含客户端连接的节点,客户端写请求就会失败,因为系统在等待多数派确认时永远等不到响应。这类“无响应”也是脑裂的常见信号。
5.2 排障步骤:日志、网络、状态三连
当你怀疑集群发生脑裂,我建议按以下步骤做检查,顺序很重要:
第一步看日志:在 etcd 中搜索election、leader、request vote关键字;在 ZooKeeper 中查看LEADER、FOLLOWER、LOOKING状态切换记录。日志往往能直接告诉你节点当时在做什么。
第二步看网络:检查节点之间的 TCP 连接是否正常,用ping、telnet或nc测试目标端口。重点观察是否有丢包、延迟突增。跨机房部署时还要检查专线质量。很多“脑裂”其实就是网络抖动触发的误判。
第三步看状态:用系统自带的健康接口查看集群成员状态。etcd 可以用etcdctl endpoint status --cluster查看每个节点的 Leader ID;ZooKeeper 用mntr命令或 4 字命令stat查看当前角色的归属。如果发现两个节点分属不同 Leader,基本可以确认脑裂已经发生。
这里我特别强调一个排查技巧:不要只看单个节点的日志,要把集群中所有节点同一时间段的日志拉出来对齐。脑裂本身就是“多个节点视角不一致”,只盯一台机器容易误判。
5.3 恢复与修复:合并数据的正确姿势
脑裂恢复后,首要任务是停止脏写入,然后进行数据合并或回滚。具体做法取决于你的业务模型:
如果是配置数据,通常希望保留携带最新版本号的数据。如果系统支持 MVCC 或多版本控制,你可以对比两个分区的数据版本,以最新者为准,回滚掉旧数据。如果系统不支持版本号,那就需要人工根据业务语义决定保留哪个分区的值。
如果是分布式锁数据,重点是检查是否有业务动作在此期间执行。锁数据本身可以删除,但执行业务产生的外部影响需要单独排查。比如库存扣减记录、资金变动流水,这些需要结合业务日志和数据库审计进行对账。
如果是有状态的数据存储,恢复过程会更痛苦。一个可接受的方案是:选择包含最多已提交日志的分区作为最终数据源,另一个分区的增量数据按业务规则进行重放或丢弃。Raft 类和 Zab 类系统本身会通过日志复制自动合并,如果旧 Leader 的数据落后,会以新 Leader 为准回放。
手动恢复时,务必备份所有分区的数据再操作,且恢复过程要保证新 Leader 只接受完整多数派仲裁后的请求。
5.4 预防脑裂的三道防线
第一道防线是 Quorum 选主,按多数派原则确保不会出现两个合法主节点。这是标配,所有生产集群都必须有。
第二道防线是隔离机制,也就是 fence 能力。选主成功后,新 Leader 必须能够阻止旧 Leader 继续写入。很多系统通过令牌机制实现,比如生成单调递增的 epoch 或 term,写入请求中携带该标识,若标识低于当前合法值则拒绝。如果你的自研系统没有实现这一层,网络恢复后的“旧主回写”是极大的隐患。
第三道防线是架构设计。尽量避免把偶数节点集群部署成两个机房对半分布,因为这在大分区场景下很容易形成“两边一边一半”的局面。更合理的方式是奇数节点跨机房分布,或者容忍单机房整体不可用。
此外,自动化监控也必不可少。盯住 Leader 任期变化频率、心跳超时次数、节点状态切换记录,一旦指标偏离基线,立刻告警,把脑裂扼杀在早期。
6. 避坑指南与实用建议
6.1 三个最容易踩的坑
第一个坑是随意调整选举超时参数。很多人为了降低故障切换时间,把心跳间隔调得非常短,结果正常网络波动也会引起选举,集群频繁抖动,可用性反而下降。记住一个原则:超时参数要能容忍正常情况下的网络抖动,而且要经过压测验证再上生产。
第二个坑是使用偶数节点部署。有些团队总觉得 4 节点比 3 节点更“安全”,实际上 4 节点只允许 1 个节点故障,跟 3 节点一样,却增加了一台机器的成本;而且 4 节点在节点两两分组时容易出现两个 2 票阵营,机器越多反而越容易出现选主僵局。
第三个坑是脑裂恢复后直接人工干预,没有先做数据备份。有些同学一看脑裂恢复了,就马上把旧 Leader 的库删掉或者直接回滚日志,结果新 Leader 的数据本身也有缺失,导致二次事故。任何恢复动作前先备份,这是保命操作。
6.2 推荐落地的配置检查清单
结合我的经验,下面一份清单可以帮你快速核对集群配置是否健康:
- 集群节点数是否为奇数(3、5、7 等),最少不要低于 3。
- 部署节点是否分散在至少两个故障域(机架/机房),避免单点故障拖垮整个集群。
- 心跳和选举超时时间是否对当前网络状况留有余量。
- 是否验证过“某个节点宕机,集群仍然可写”的场景。
- 是否测试过“网络分区后,少数派节点不会对外提供写服务”。
- 是否配置了 Leader 任期变化的监控告警。
- 是否设计了 fence 机制,保证旧 Leader 在租约过期后无法继续写入。
- 是否定期演练脑裂恢复流程,确保团队实际操作熟练。
我建议把上面这些项目作为上线前的硬性检查,而不是等出事了再回头查。
6.3 一个可以立刻上手的验证方法
如果你有测试环境,想验证一个系统是否具备脑裂防护能力,有个简单粗暴的方法:用 Linux 的iptables或tc命令人为制造网络分区。模拟步骤如下:
- 在某个 Follower 节点上执行
iptables -A INPUT -s <leader_ip> -j DROP,模拟它与 Leader 的单向失联。 - 观察该节点是否会主动开始选主,它的选主是否会成功。
- 如果选主失败(因为无法获得多数派),说明 Quorum 机制正常。
- 恢复网络后观察该节点是否自动重新加入集群并同步数据。
这个实验能快速暴露配置错误,也能帮助团队理解 Quorum 的运作边界。更重要的是,通过这样的演练,整个团队对脑裂的应对会更有底气。真正的稳定性,不是祈祷不故障,而是故障来了也能安全兜底。