脑裂:分布式系统里最危险的故障
分布式系统里最危险的故障不是节点宕机——宕机了你处理就行。最危险的是你以为它宕机了,但它还活着。两台机器同时认为自己是主节点,各自接受写入,数据分叉——这就是脑裂(Split-Brain)。等你发现的时候,两边已经各自处理了一堆请求,合并回去要么丢数据,要么重复执行。
什么是脑裂
集群被网络分成两个子集,每个子集内部通信正常,但子集之间不通。每个子集都认为对方挂了,各自选出一个主节点,同时对外提供服务。
两个主节点并行写入,操作同一份数据但互不感知。等网络恢复,两边的写操作冲突——谁的数据是对的?没有答案,因为两个主都是"合法"选出来的。
脑裂和普通故障的区别:普通故障是"不能用了",用户立刻感知到。脑裂是"你以为能用了,但数据正在被毁"——可能静默运行很久才暴露。
为什么会发生
网络分区。 交换机故障、网线松动、防火墙规则变更——机房里的网络随时可能断。A 机房和 B 机房互相看不到对方,各自认为"对方挂了,我得顶上"。
心跳超时。 主从架构靠心跳维持关系。从节点连续 N 秒没收到主节点响应,就认为主挂了,开始选举。但主节点可能只是 GC 停顿了几秒、CPU 被占满、或者网络抖动了一下。它没死,只是"暂时说不出话"。从节点等不及了,选了新主。等旧主恢复,两个主同时存在。
VM 暂停。 虚拟化的暂停/恢复(vMotion、快照、内存压缩)会导致进程暂停几秒甚至几十秒。暂停期间其他节点收不到心跳,以为这个节点挂了,触发选主。暂停结束后,这个节点以为自己还是主,但别人已经选了新的——脑裂。
三种场景的共同点:不是真挂了,是"看起来挂了"。 分布式系统靠心跳判断存活,但心跳消失不等于节点死亡——可能只是慢了。这个不确定性是脑裂的根源。
脑裂的后果
数据丢失。 两个主各自接受写入。旧主在不知道自己被废黜的情况下,继续处理了一批写请求。新主也在写。网络恢复后,要么丢掉旧主的写入,要么丢掉新主的——总要丢一批。
重复执行。 两个主各自处理了同一批队列消息、定时任务。转账做了两次,订单创建了两个,发券发了双份——这些副作用在网络恢复后无法自动合并。
状态分歧不可逆。 两个主各自基于不同的状态继续演化。旧主的状态是"10 点之前",新主的状态是"10 点之后"。不是简单的"最后写入获胜"能解决的——两个分支的后续操作各自依赖各自的前置状态,逻辑上已经分叉,合并需要人工介入。

怎么防
核心思路:确保同一时间只有一个主。
法定人数(Quorum)。 选主需要多数派同意——N 个节点,需要 ⌈N/2⌉+1 个投票。网络分区后,只有拥有多数派的那一边才能选出主,少数派那边选不出来,自动退位。5 节点集群分成 3+2,3 的那边能凑够多数派选主,2 的那边凑不够,只能等着。永远只有一边有主。
但 quorum 有前提:节点数必须是奇数。偶数节点(4 个)分成 2+2,两边都凑不够多数派,整个集群不可用。所以生产环境部署 3 节点或 5 节点,不用 4 节点。
Fencing Token。 Quorum 解决了"选主"的问题,但旧主可能还活着,还在写。Fencing 就是把旧主的写入物理拦住。新主当选时获得一个单调递增的 token(epoch/term),每次写入都带上。存储层检查 token:旧主的 token 比新主的小,直接拒绝。旧主发现自己的写被拒了,才知道自己已经不是主了,主动退位。
关键在于 存储层必须支持 fencing 检查。如果存储层不检查 token(比如普通 MySQL),旧主的写入照样成功,fencing 形同虚设。这也是为什么 ZooKeeper、etcd 内置了 fencing——写请求带 zxid/revision,旧 leader 的写会被自动拒绝。
租约(Lease)。 主节点持有一个有时限的租约,到期必须续租。租约有效期内,只有持有租约的节点才能当主。租约过期后,其他节点可以选新主。旧主如果因为 GC 暂停错过了续租,租约到期后它就不再是主了——即使它恢复后不知道这件事,它的写入也不会被接受(前提是存储层检查租约有效性)。
租约的坑在于时钟:不同节点的时钟不同步,租约的过期时间判断可能出错。节点 A 认为租约还有 2 秒,节点 B 认为已经过期了——又脑裂。所以租约方案依赖单调时钟或 NTP 精度,不能假设时钟完美同步。

实现上的坑
Quorum 不等于不会脑裂。 Quorum 防的是"选主脑裂",不是所有脑裂。节点磁盘故障导致数据回滚,恢复后拿着旧状态重新加入集群——这不是选主问题,是数据一致性问题,Quorum 管不到。
STONITH 不是万能的。 STONITH(Shoot The Other Node In The Head)在选新主之前先强制关掉旧主的电源。比 fencing 更暴力——直接让旧主物理不可用。但它依赖管理网络和业务网络的隔离:如果两个网络走同一条线,网络分区时 STONITH 也发不出去。而且强制关机本身有风险——磁盘正在写的时候断电,可能造成文件系统损坏。
客户端缓存了旧主地址。 新主选出来了,但客户端还连着旧主。客户端的写入到了旧主,旧主接受了一部分后才被 fencing 拦住。这段时间窗口内的写入要么丢失,要么需要重试。客户端必须能感知主节点切换——靠服务端返回错误强制刷新,或靠定期重新查询主节点地址。

常见误区
- "我们用了 Raft/Paxos,不会脑裂。" Raft 和 Paxos 用 quorum 防选主脑裂,但 fencing 依然需要。Raft 的 leader 被废黜后如果还活着,它的写入在 quorum 层面会被拒绝(得不到多数派 ack),但如果它直连了一个 follower 并写入,数据仍然会分叉。协议防住了大部分情况,但实现细节(leader 是否在写入前检查自己的 term)决定了是否真正安全。
- "双活就是两个主同时写。" 双活不是两个主并行写同一份数据。真正的双活要么基于 quorum(两个数据中心凑不够多数派,需要引入第三个站点的仲裁节点),要么基于分区(A 数据中心写 A 分区,B 数据中心写 B 分区,互不冲突)。两个主写同一行数据不叫双活,叫脑裂。
- "心跳超时设短一点就安全了。" 超时设短,故障发现快,但误判也多——正常的 GC 暂停、网络抖动都会触发不必要的选主。频繁选主本身是问题:每次选主期间集群不可用,旧主的在途请求全部失败。超时设长,误判少,但真实故障的发现也慢。没有完美值,只有 tradeoff。
小结
脑裂的根因不是节点真挂了,是"看起来挂了"——网络分区、心跳超时、VM 暂停都会制造这个假象。防护靠三层:quorum 确保只有多数派那边能选主,fencing token 让旧主的写入被存储层物理拒绝,租约给主节点加一个时效约束。Raft/Paxos 防住了选主层面的脑裂,但 fencing 仍需存储层配合——协议不能替代实现。心跳超时的长短没有标准答案,是故障发现速度和误判率之间的 tradeoff。
寒蝉 Hancic


