ZooKeeper 集群脑裂问题与过半机制
2015 年,某个知名的互联网公司发生了一次故障。
故障的原因很简单:他们的 ZooKeeper 集群有 5 台机器,其中 2 台因为网络问题和其他 3 台断开了连接。
按照预期,这 2 台机器应该停止服务,让另外 3 台继续工作。
但实际上,5 台机器都在各自运行,都认为自己是「主节点」。
这就是分布式系统中最著名的问题之一:脑裂(Split-Brain)。
什么是脑裂?
在分布式系统中,「脑裂」指的是集群被网络分区拆分成多个部分,每个部分都认为自己是唯一的集群,各自独立运行。
类比一下:如果把 ZooKeeper 集群比作一个公司的董事会,脑裂就像是电话会议断线后,两个会议室里的人各自以为自己在主持会议,做出了互相矛盾的决策。
脑裂会导致什么问题?
- 数据不一致:两个「主节点」各自处理请求,写入不同的数据
- 重复执行:同一个任务被两个节点执行两次
- 服务不可用:客户端连接了错误的节点,获取到错误的数据
过半机制:ZooKeeper 的解决方案
ZooKeeper 用一个简单而优雅的方案解决了脑裂问题:过半机制(Majority Quorum)。
只有当集群中超过半数的节点正常运行并能相互通信时,集群才能对外提供服务。
为什么是过半?
这要从 N = 2F + 1 说起。
假设集群有 N 台机器,最多能容忍 F 台机器故障而仍然正常工作。那么:
- N = 1,最多容忍 0 台故障(1 = 2×0 + 1)✓
- N = 3,最多容忍 1 台故障(3 = 2×1 + 1)✓
- N = 5,最多容忍 2 台故障(5 = 2×2 + 1)✓
- N = 4,最多容忍 1 台故障(4 ≠ 2×1 + 1)
为什么 N 要是奇数?
因为 4 台机器的集群,最多容忍 1 台故障。但 1 < 4/2,所以当 2 台机器故障时,剩下的 2 台都无法组成「过半」。
所以 4 台和 5 台机器的容灾能力是一样的,但 5 台多了一次「容错机会」。
过半机制如何避免脑裂?
回到开头的场景:
5 台机器的 ZooKeeper 集群,网络分区后变成 2 + 3。
分区一:2 台机器
分区二:3 台机器过半机制判断:
- 分区一(2 台):2 < 5/2,不是「过半」,不能提供服务
- 分区二(3 台):3 > 5/2,是「过半」,可以继续服务
只有 3 台的那个分区能继续工作,2 台的那个分区会停止服务。
这就是 ZooKeeper 的「要么全有,要么全无」策略。
ZooKeeper 的读写机制
理解过半机制后,再来看 ZooKeeper 的读写流程:
写操作:需要过半节点确认
客户端发送写请求到 Leader
↓
Leader 提案(propose)写入数据
↓
Follower 接收提案,响应「收到」
↓
当有过半节点(包括 Leader 自己)都确认后
↓
Leader 提交(commit)事务
↓
返回成功给客户端为什么需要过半确认?
因为只有过半节点确认了,即使 Leader 挂了,新的 Leader 也一定包含这条数据。
数学证明: 如果有 N=2F+1 台机器,过半就是 F+1 台。当 Leader 挂了,新 Leader 一定是从 F+1 台中产生,而旧数据在这 F+1 台中至少有一份。
读操作:可以读任意节点
ZooKeeper 的读操作可以在任意节点上执行:
// 读请求可以直接发给 Follower
byte[] data = zk.getData("/config/app", false, stat);这意味着读操作可能读到过期数据。
但这通常是可接受的——配置服务、选举服务对一致性要求高,读多台可以接受短暂的不一致。
如果需要强一致读,可以强制读 Leader:
// 强制读取 Leader
zk.sync("/config/app");
byte[] data = zk.getData("/config/app", false, stat);Leader 选举:重新组建集群
当 Leader 挂了,或者集群启动时,需要选举新的 Leader。
选举算法:ZAB 协议
ZooKeeper 使用 ZAB(ZooKeeper Atomic Broadcast) 协议进行 Leader 选举。
选举过程可以简化为「比大小」:
每个服务器投票时,携带两个信息:
- myid:服务器 ID(配置文件中指定,越大越优先)
- zxid:最后一条事务的 ID(越大说明数据越新)
投票规则:
- 先比较 zxid,大的优先(数据越完整越优先)
- zxid 相同则比较 myid,大的优先
选举过程
假设 5 台机器的集群,其中 Leader 挂了:
时刻 1:每个服务器投票给自己
Server1: (zxid=100, myid=1) → 票数:1
Server2: (zxid=100, myid=2) → 票数:1
Server3: (zxid=100, myid=3) → 票数:1
Server4: (zxid=200, myid=4) → 票数:1 (zxid 最大)
Server5: (zxid=200, myid=5) → 票数:1
时刻 2:Server1、2、3 收到 Server4、5 的票
Server1: 收到 (zxid=200) → 改投 Server4 → 票数:2
Server2: 收到 (zxid=200) → 改投 Server4 → 票数:3
Server3: 收到 (zxid=200) → 改投 Server4 → 票数:4
时刻 3:Server4 获得过半票数,成为新 Leaderzxid 大的优先,保证了新 Leader 拥有最完整的数据。
网络分区与恢复
网络分区发生时
正常状态:
[Client] → [LoadBalancer] → [Server1, Server2, Server3, Server4, Server5]
↓
[ZooKeeper Cluster]
(5 台,Leader 正常运行)
网络分区:
[Client] → [LoadBalancer] → [Server1, Server2, Server3] ← 还能工作
[Server4, Server5] ← 停止服务
分区后的 ZooKeeper:
[Server1, Server2, Server3] → 3 台,过半,继续服务
[Server4, Server5] → 2 台,不过半,停止服务网络恢复后
当网络恢复后,被隔离的服务器会重新加入集群:
- 发现 Leader 还在,主动同步 Leader 的数据
- 丢弃自己在隔离期间收到的写请求(因为没有过半确认)
- 恢复服务
ZooKeeper 的设计哲学:不要相信在网络分区期间产生的数据。 因为那些数据只有少数节点知道,没有过半确认,不是「合法」的数据。
过半机制的设计权衡
优点
- 简单高效:不需要复杂的分布式事务协议
- 强一致性:写入需要过半确认,保证数据不丢失
- 容错能力强:N=2F+1 的设计,最多容忍 F 台故障
缺点
- 不能容忍「半死不活」:比如 5 台机器,3 台正常、2 台响应很慢,整个集群性能会被拖慢
- 读取不是强一致:可能读到旧数据
- 必须奇数部署:偶数台没有意义
实际配置建议
| 集群规模 | 容灾能力 | 适用场景 |
|---|---|---|
| 3 台 | 容忍 1 台故障 | 开发测试环境 |
| 5 台 | 容忍 2 台故障 | 生产环境推荐 |
| 7 台 | 容忍 3 台故障 | 对可用性要求极高的场景 |
一般建议用 5 台,既保证了一定的容灾能力,又不会像 7 台那样增加运维复杂度。
总结
ZooKeeper 通过过半机制巧妙地解决了脑裂问题:
- 写操作需要过半节点确认,保证数据不会丢失
- 只有过半节点才能组成合法集群,避免多个分区同时工作
- 选举时比较 zxid 和 myid,保证新 Leader 数据最完整
这个设计的核心思想是:宁可停止服务,也不要产生错误的数据。
在分布式系统中,「正确性」永远比「可用性」更重要。ZooKeeper 选择了 CP(一致性 + 分区容忍),而不是 AP(可用性 + 分区容忍)。
留给你的问题:
假设你有一个 5 台机器的 ZooKeeper 集群,突然有 3 台机器同时宕机。剩下的 2 台还能服务吗?
如果此时有客户端连接这 2 台服务器,它读取到的数据和真正的数据可能一致吗?
这个问题涉及到 CAP 定理的权衡,你有兴趣深入研究吗?
