我们在生产环境遇到过一次特别诡异的发布:SolrCloud的节点没有重启,也没有人动网络,但所有副本突然变成了红色,查询全部超时。后来排查发现,是ZooKeeper(下面简称ZK)的会话超时了。一旦某个节点的ZK会话过期,它就会被从集群成员里踢出去,然后触发一连串的选举和恢复动作,而这个过程如果再赶上网络抖动,就可能让两个shard都以为自己是“老大”,这就是咱们今天要聊的脑裂。

一、脑裂的本质是什么

要理解脑裂,先想象一个班级。班里有个班长,班长负责收作业和发布命令。某天班长和同学们之间的联系突然断断续续,几个同学联系不上班长,就决定自己建一个群,重新选了个临时班长。等原来的班长恢复联系时,班级里就有了两套指挥系统,作业收重、命令冲突,这就是脑裂。在SolrCloud里,一个集合的shard通常有多个副本,副本之间靠ZK来协调谁是leader。如果因为网络分区或IO卡顿,ZK认为某个节点死了,而另一个副本感知不到这个变化,就会各自为政,同一个shard出现多个leader,数据写入就开始错乱。

1.1 为什么SolrCloud特别怕脑裂

SolrCloud不是简单的主从复制,它依赖ZK上的元数据来确认每个副本的状态。一旦出现脑裂,两个leader都可能接收写请求,但它们各自维护的version和索引数据是不一致的。等网络恢复,ZK再一做协调,要么强制丢弃一部分数据,要么把冲突的索引合并,结果就是丢数据或者索引损坏。更重要的是,搜索业务一般要求实时性,脑裂期间的查询会打到不一致的副本上,出现“明明刚写入却搜不到”的怪问题。很多团队一开始觉得脑裂只是理论上的问题,直到某天大促前节点抖动,才发现索引里已经有了重复文档,这才着急。

二、根子出在ZK会话超时上

2.1 ZK会话超时是怎么发生的

ZK客户端和服务端之间通过心跳维持“我还活着”的信号。如果客户端在会话超时时间内没发心跳,服务端就把这个会话标记为过期。注意,这里的心跳不是一次普通的网络请求,它需要经过网络的往返。如果节点上发生一次长时间Full GC,或者磁盘IO卡了10秒,心跳就发不出去。ZK不会管你是因为什么,它只看时间。

用一段Java代码来说明会话设置和常见的过期场景:

// 技术栈:Java (ZooKeeper客户端 3.6.x)
import org.apache.zookeeper.*;
import java.util.concurrent.CountDownLatch;

public class ZkSessionDemo {
    public static void main(String[] args) throws Exception {
        // 设置会话超时时间为10秒,这个值会和服务端配置做协商
        int sessionTimeoutMs = 10000;
        CountDownLatch latch = new CountDownLatch(1);

        // 创建ZK客户端,连接的是三台ZK组成的集群
        ZooKeeper zk = new ZooKeeper("zk1:2181,zk2:2181,zk3:2181",
                sessionTimeoutMs, event -> {
                    // 把连接成功的事件打个标记
                    if (event.getState() == Watcher.Event.KeeperState.SyncConnected) {
                        latch.countDown();
                    }
                });

        latch.await();
        System.out.println("连接成功,sessionId = " + zk.getSessionId());

        // 模拟一次线程停顿5秒(比如Full GC),此时心跳发不出去
        Thread.sleep(5000);

        // 注意:这里并没有真正触发超时,只是演示影响心跳的一个场景
        System.out.println("当前状态: " + zk.getState());
        zk.close();
    }
}

在这个例子里,我们把超时设为10秒,理论上5秒的停顿不会过期。但如果停顿时间超过10秒,或者网络丢包导致心跳连续几次没到达,会话就会过期。ZK服务端的默认tickTime是2000ms,实际生效的超时时间不是客户端随便写的,它会被服务端的minSessionTimeout和maxSessionTimeout限制。通常生产环境会设置成10到20秒,太小的话一个GC就可能让节点掉线,太大又会影响故障转移的速度。很多同学在测试环境把超时设成3000毫秒,一压测就掉线,就是这个原因。

2.2 会话过期后SolrCloud节点做了什么

SolrCloud节点在启动时会注册一批临时节点,比如核心节点的状态、leader的锁。临时节点有个特点:会话一结束,节点自动消失。这本来是个保护机制,但也很粗暴。服务端感觉到会话过期后,会把这个节点对应的临时节点全部删掉。SolrCloud看到自己注册的状态没了,就知道自己已经被“请出群聊”,于是它开始尝试重新连接,并且做状态恢复。问题就出在这个恢复过程,如果同一时间有多个节点掉线,它们可能会一起去抢leader,或者互相观望,导致集群长时间处于不可用状态。我们在一次故障演练中故意把ZK的会话超时调到5秒,然后对Solr节点执行kill -STOP(暂停进程)10秒,结果重启后整个集群花了20分钟左右才稳定下来,期间写请求全部拒绝。这还只是一个节点,如果是多个节点同时抖动,恢复时间会更长。

三、leader选举机制如何成为帮凶

3.1 一次选举的完整过程

SolrCloud底层用ZK的临时顺序节点做leader选举。每个副本都会在某个固定路径下创建一个临时顺序节点,比如“candidate-0000000001”。序号最小的节点成为leader,其他的作为follower并监听前一个节点的删除事件。当leader的会话过期,它的临时节点被删除,排在后面的节点收到通知,立刻去检查自己是不是最小序号,如果是就宣布成为新leader。

看起来逻辑很清晰,但脑裂就藏在“谁的会话过期”这个判断里。如果A和B两个副本,A是leader,B是follower。由于网络分区,A能和客户端通信但连不上ZK,B却连得上ZK。ZK因为A超时把它删了,B马上当选新leader。然后A的网络恢复了,A还认为自己是leader,于是两个leader同时存在。这个场景在SolrCloud中并不罕见,尤其是在跨机房部署时,机房之间的专线抖动很容易引发。

3.2 用代码看看选举中的时间窗口

这里我用Java模拟一下“判断自己是不是leader”的逻辑,注意代码中加了一些注释来标识容易出问题的位置:

// 技术栈:Java (ZooKeeper客户端)
import org.apache.zookeeper.*;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public class LeaderElectionV1 {
    private static final String ELECTION_PATH = "/solr/collections/demo/leaders";

    public static void main(String[] args) throws Exception {
        int sessionTimeoutMs = 10000;
        ZooKeeper zk = new ZooKeeper("zk1:2181,zk2:2181", sessionTimeoutMs, event -> {
            // 只处理会话过期事件,这是脑裂的关键入口
            if (event.getState() == Watcher.Event.KeeperState.Expired) {
                System.err.println("会话过期!当前节点必须停止服务,不能再参与选举");
            }
        });

        // 创建临时顺序节点,返回完整路径,如 /.../candidate-0000000002
        String myPath = zk.create(ELECTION_PATH + "/candidate-", new byte[0],
                ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL);
        System.out.println("我创建的节点路径: " + myPath);

        // 获取当前所有参与选举的节点
        List<String> children = zk.getChildren(ELECTION_PATH, false);
        List<String> sorted = new ArrayList<>(children);
        Collections.sort(sorted);

        // 判断自己是不是序号最小的
        String myName = myPath.substring(myPath.lastIndexOf('/') + 1);
        if (myName.equals(sorted.get(0))) {
            System.out.println("我是本轮选举的leader");
            // 在这里开始接收写请求
        } else {
            System.out.println("我是follower,等待leader出现故障");
            // 如果leader故障,follower要监听前一个节点事件
        }
        // 注意:如果这里代码执行过程中会话刚好过期,这个逻辑就已经过时了
        zk.close();
    }
}

这段代码只是最简版本,真实实现还要考虑监听前一个节点的删除通知、处理会话重连后的重新注册等。但已经能看出问题:节点在创建完临时节点后,如果ZK连接断掉导致临时节点消失,而本地代码还没来得及重新选举,它可能仍然在对外提供服务。这就是脑裂的一个微观基础。更可怕的是,在Java的ZooKeeper客户端里,连接断开不一定会立刻触发状态变更,有时候要等到会话过期才会收到事件,这中间的时间窗口就是脑裂的高发期。

四、修复脑裂的正确姿势

4.1 先从根上降低超时率

既然脑裂的源头是ZK会话超时,那第一件事就是让节点不易超时。不要把sessionTimeout设置成3秒这种极限值,除非你的网络和GC都极其稳定。建议设置为10秒到20秒,同时监控一下Full GC的耗时。如果发现经常超过1秒,那就要优化堆内存或者调整垃圾回收器。另外,ZooKeeper的服务器配置里有两个参数:minSessionTimeout和maxSessionTimeout。如果客户端传入的超时时间不在这个范围内,ZK会自动把它修正到边界值。建议把maxSessionTimeout适当调大一点,比如20秒,避免某些客户端因为网络延迟被误杀。

在SolrCloud的启动脚本中,可以通过JVM参数来控制ZK客户端的行为?实际上Solr的ZK超时参数在solr.xml中有配置。这里用Java代码来展示一个健康检查的思路:定期检查ZK连接状态,一旦发现状态不是Connected,就主动拒绝外部搜索请求,而不是继续为已过期的会话提供服务。

// 技术栈:Java (Solr插件或自研组件)
import org.apache.zookeeper.ZooKeeper;
import org.apache.zookeeper.Watcher;

public class HealthyStatusHolder {
    private volatile boolean healthy = true;
    private final ZooKeeper zk;

    public HealthyStatusHolder(ZooKeeper zk) {
        this.zk = zk;
        // 监听所有状态变化
        zk.register(event -> {
            Watcher.Event.KeeperState state = event.getState();
            if (state == Watcher.Event.KeeperState.Disconnected) {
                healthy = false;
                System.out.println("连接断开,标记为不健康");
            } else if (state == Watcher.Event.KeeperState.SyncConnected) {
                healthy = true;
                System.out.println("重新连接成功,恢复健康");
            } else if (state == Watcher.Event.KeeperState.Expired) {
                healthy = false;
                System.err.println("会话过期,必须停止服务");
            }
        });
    }

    public boolean isHealthy() {
        return healthy;
    }
}

这样一来,即使副本进程还活着,只要ZK会话状态不对,它就不能参与读写,也就不会去抢leader,脑裂的一个主要触发路径就被堵住了。实际落地时,可以在Solr的RequestHandler入口检查这个isHealthy()方法,返回false时直接抛异常或者拒绝写请求。

4.2 用“观察者”减少选举压力

SolrCloud支持ZooKeeper的observer角色。observer不参与选举投票,只负责同步最新状态。如果你的集群有大量只读节点,把它们配置成observer,可以减少ZK集群内部的选举压力,防止因为ZK集群自身抖动导致会话大面积超时。在ZK的配置文件中加入一行:

// 技术栈:Java (以字符串形式展示ZK配置)
String zooCfg = """
    server.1=zk1:2888:3888
    server.2=zk2:2888:3888
    server.3=zk3:2888:3888
    server.4=zk4:2888:3888:observer
    """;

注意第4个节点带了observer标记,这样它只同步数据,不参与选举。好处是扩大集群读能力的同时,不会增加选举的复杂度和超时窗口。在SolrCloud中,我们可以把一些非核心的集合副本也打上类似标记,让它们作为只读副本存在,这样即使它们发生会话超时,也不会引发leader选举,减少了脑裂的可能性。

4.3 网络和部署层面的“土办法”

不要小看机房网络,脑裂很多是因为网络分区引起的。如果我们能避免单点故障,也能减少脑裂概率。比如把SolrCloud节点和ZK集群放在同一个可靠机房,而不是跨地域部署。如果一定要跨机房,那就需要保证机房之间的网络质量稳定,或者采用更复杂的双活方案,而不是简单把ZK节点散落在多个机房。

另外,ZK集群本身要独立部署,不要和Solr节点混在一起。混部时,Solr的IO压力会拖慢ZK的响应,导致心跳延迟。把ZK挪到专用机器上,虽然增加成本,但换来的是稳定。我们曾经把ZK从混部机器上迁移到SSD机器后,会话超时次数降低了80%。

4.4 监控和告警要盯住哪些指标

监控是防御脑裂的最后一道防线。你需要关注四个关键指标:ZK会话建立数、会话过期数、临时节点瞬时变化数、leader选举次数。一旦发现“会话过期数”从0变成1,或者“leader选举次数”在短时间内猛增,就说明可能发生了一次小规模脑裂或者节点抖动。用Java写一个简单的告警伪代码:

// 技术栈:Java (监控框架示例)
import java.util.concurrent.atomic.AtomicLong;

public class ZkMetricsMonitor {
    private AtomicLong expireCount = new AtomicLong(0);

    public void onSessionExpired() {
        long count = expireCount.incrementAndGet();
        if (count > 5) {
            // 这里可以调用钉钉、企微机器人发送告警
            sendAlert("ZK会话过期次数超过阈值,当前=" + count);
        }
    }

    private void sendAlert(String message) {
        System.out.println("【告警】" + message);
        // 实际项目中会写HTTP调用
    }
}

除了这些,我们还应该监控ZK的延迟百分位数,比如p99。如果p99超过500毫秒,说明ZK已经有压力了,需要提前扩容。另外,ZK的jvm full gc次数也很关键,full gc一次可能让ZK集群暂停几百毫秒,对客户端来说就是心跳超时。

五、应用场景与优缺点分析

5.1 哪些场景特别容易遇到脑裂

第一种是重IO场景,比如全量索引重建或者大批量导入数据时,磁盘和CPU被打满,ZK心跳发不出去。第二种是云环境中的虚拟机,宿主机负载过高会导致虚拟机卡顿。第三种是频繁发布或扩容,在滚动重启过程中,节点先离线再上线,如果ZK没来得及同步好,就可能出现短暂的多个leader。第四种是跨机房容灾场景,两个机房间的专线不稳定,容易出现“部分节点连不上ZK”的情况。如果你所在的业务正好在这些场景里,那脑裂就不是偶然事件,而是一个必须预防的常规风险。

5.2 现有方案的优缺点

ZK自带的会话超时和临时节点机制,优点是简单可靠,能快速剔除不健康节点;缺点是“一刀切”,不会区分逻辑卡顿和物理故障。SolrCloud的leader选举机制成熟,但依赖网络基础设施,在极端网络分区下仍然会脑裂。我们上面说的健康检查器,优点是能在业务入口挡住不健康节点,缺点是引入了额外的判断逻辑,可能增加请求延迟。观察者模式能减轻ZK压力,但配置复杂,而且不能完全根治脑裂。最稳妥的做法是组合拳:调整超时 + 部署隔离 + 健康检查 + 监控告警,而不是指望某一种技术能解决所有问题。

5.3 注意事项

不要随便调整ZK的tickTime和sessionTimeout,它们要一起调。更重要的是一致性优先:当你不确定自己是不是leader时,宁可拒绝服务,也不要乱收数据。另外,临时节点和持久节点的混合使用要小心,如果某些元数据用了持久节点,故障恢复时不会自动消失,容易留下脏数据。在代码层面,所有对ZK的写操作都要考虑会话重连时的幂等性,比如创建节点时要捕获NodeExistsException,删除时要捕获NoNodeException,避免因为重试导致的数据不一致。

六、总结

脑裂是一个分布式系统里永远绕不开的话题。SolrCloud中的脑裂,本质上是ZK会话超时与leader选举机制在异常情况下发生了“竞争”,最终导致多个副本同时认为自己是leader。要修复它,不能只靠调参数,需要从减少超时、增加监控、规范部署和确保业务入口的自我保护这几个方向一起用力。最核心的一条经验是:在分布式系统里,任何节点在无法确认自己状态时,都应该选择“先停下,再确认”,而不是带着不确定的状态继续工作。这样虽然会牺牲一点可用性,但能保护数据的一致性,而数据一致性才是搜索系统的生命线。

以上就是本次分享的全部内容,希望对你排查和预防这类问题有帮助。