关于脑裂(Split Brain) - tenji/ks GitHub Wiki

关于脑裂(Split-Brain)

一、基本概念

脑裂是指采用主从(Master-Slave)架构的分布式系统中,出现了多个活动的主节点的情况。但正常情况下,集群中应该只有一个活动主节点。

造成脑裂的原因主要是网络分区(也就是 CAP 中的 P)。由于网络故障或者集群节点之间的通信链路有问题,导致原本的一个集群被物理分割成为两个甚至多个小的、独立运作的集群,这些小集群各自会选举出自己的主节点,并同时对外提供服务。网络分区恢复后,这些小集群再度合并为一个集群,就出现了多个活动的主节点。

另外,主节点假死也有可能造成脑裂。由于当前主节点暂时无响应(如负载过高、频繁 GC 等)导致其向其他节点发送心跳信号不及时,其他节点认为它已经宕机,就触发主节点的重新选举。新的主节点选举出来后,假死的主节点又复活,就出现了两个主节点。

脑裂的危害非常大,会破坏集群数据和对外服务的一致性,所以在各分布式系统的设计中,都会千方百计地避免产生脑裂。

二、脑裂的解决方法

一般有以下三种思路来避免脑裂:

  • 法定人数/多数机制(Quorum)
  • 隔离机制(Fencing)
  • 冗余通信机制(Redundant communication)

2.1 ZooKeeper & Quorum

2.1.1 Quorum(多数机制、过半原则)

防止脑裂的措施有多种,Zookeeper 默认采用的是“过半原则”。所谓的过半原则就是:在 Leader 选举的过程中,如果某台 zkServer 获得了超过半数的选票,则此 zkServer 就可以成为 Leader 了。

底层源码实现如下:

public class QuorumMaj implements QuorumVerifier {
 
    int half;
    
    // QuorumMaj 构造方法。
    // 其中,参数 n 表示集群中 zkServer 的个数,不包括观察者节点
    public QuorumMaj(int n){
        this.half = n/2;
    }

    // 验证是否符合过半机制
    public boolean containsQuorum(Set<Long> set){
        // half是在构造方法里赋值的
        // set.size()表示某台zkServer获得的票数
        return (set.size() > half);
    }
}

上述代码在构建 QuorumMaj 对象时,传入了集群中有效节点的个数;containsQuorum 方法提供了判断某台 zkServer 获得的票数是否超过半数,其中 set.size 表示某台 zkServer 获得的票数。

上述代码核心点两个:第一,如何计算半数;第二,投票属于半数的比较。

以上图6台服务器为例来进行说明:half = 6 / 2 = 3,也就是说选举的时候,要成为 Leader 至少要有4台机器投票才能够选举成功。那么,针对上面2个机房断网的情况,由于机房1和机房2都只有3台服务器,根本无法选举出 Leader。这种情况下整个集群将没有 Leader。

在没有 Leader 的情况下,会导致 Zookeeper 无法对外提供服务,所以在设计的时候,我们在集群搭建的时候,要避免这种情况的出现。

如果两个机房的部署请求部署 3:3 这种状况,而是 3:2,也就是机房1中三台服务器,机房2中两台服务器:

在上述情况下,先计算 half = 5 / 2 = 2,也就是需要大于2台机器才能选举出 Leader。那么此时,对于机房1可以正常选举出 Leader。对于机房2来说,由于只有2台服务器,则无法选出 Leader。此时整个集群只有一个 Leader。

对于上图,颠倒过来也一样,比如机房1只有2台服务器,机房2有三台服务器,当网络断开时,选举情况如下:

Zookeeper 集群通过过半机制,达到了要么没有 Leader,要没只有1个 Leader,这样就避免了脑裂问题。

对于过半机制除了能够防止脑裂,还可以实现快速的选举。因为过半机制不需要等待所有 zkServer 都投了同一个 zkServer 就可以选举出一个 Leader,所以也叫快速领导者选举算法

2.1.2 epoch

通过过半原则可以防止机房分区时导致脑裂现象,但还有一种情况避免不了,那就是 Leader 假死。

假设某个 Leader 假死,其余的 followers 选举出了一个新的 Leader。这时,旧的 Leader 复活并且仍然认为自己是 Leader,向其他 followers 发出写请求也是会被拒绝的。

因为 ZooKeeper 维护了一个叫 epoch 的变量,每当新 Leader 产生时,会生成一个 epoch 标号(标识当前属于那个 Leader 的统治时期),epoch 是递增的,followers 如果确认了新的 Leader 存在,知道其 epoch,就会拒绝 epoch 小于现任 leader epoch 的所有请求。

那有没有 follower 不知道新的 Leader 存在呢,有可能,但肯定不是大多数,否则新 Leader 无法产生。ZooKeeper 的写也遵循 quorum 机制,因此,得不到大多数支持的写是无效的,旧 leader 即使各种认为自己是 Leader,依然没有什么作用。

2.2. HDFS NameNode HA & Fencing

HDFS NameNode 高可用需要两个 NN 节点,一个处于活动状态,另一个处于热备状态,由 ZKFailoverController 组件借助外部ZK集群提供主备切换支持。

当活动 NN 假死时,ZK集群长时间收不到心跳信号,就会触发热备 NN 提升为活动 NN,之前的 NN 复活就造成脑裂。如何解决呢?答案就是隔离,即将原来那个假死又复活的 NN 限制起来(就像用篱笆围起来一样),使其无法对外提供服务。具体来讲涉及到三方面。

  • 两个 NN 中同时只有一个能向共享存储(QJM 方案下就是 JournalNode 集群)写入 edit log;
  • 两个 NN 中同时只有一个能向 DataNode 发出数据增删的指令;
  • 两个 NN 中同时只有一个能响应客户端的请求。

为了实现 Fencing,成为活动 NN 的节点会在 ZK 中创建一个路径为 /hadoop-ha/${dfs.nameservices}/ActiveBreadCrumb 的持久 znode。当正常发生主备切换时,ZK Session 正常关闭的同时会一起删除上述 znode。但是,如果 NN 假死,ZK Session 异常关闭,/hadoop-ha/${dfs.nameservices}/ActiveBreadCrumb 这个 znode 就会残留下来。由热备升格为活动的 NN 会检测到这个节点,并执行 Fencing 逻辑:

  1. 尝试调用旧活动 NN 的 RPC 接口中的相关方法,强制将其转换成热备状态;
  2. 如果转换失败,那么就根据 dfs.ha.fencing.methods 执行 sshfence、shellfence 两种隔离措施。sshfence 就是通过 SSH 登录到该节点上,执行 fuser 命令通过定位端口号杀掉 NameNode 进程;shellfence 就是执行用户定义的 Shell 脚本来隔离 NameNode 进程。

只有 Fencing 执行完毕之后,新的NN才会真正转换成活动状态并提供服务,所以能够避免脑裂。

最后废话一句,JournalNode 集群区分新旧 NN 同样是靠纪元值,而它的可用性也是靠 Quorum 机制——即如果 JournalNode 集群有 2N + 1 个节点的话,最多可以容忍 N 个节点失败。

参考链接

⚠️ **GitHub.com Fallback** ⚠️