深夜,生产环境告警疯狂轰炸,Redis 集群数据不一致,交易系统瘫痪。这样的噩梦,相信不少开发者都曾经历过。查日志、排问题,结果发现是 Redis 集群脑裂作祟。这个看似神秘的"脑裂"问题,究竟是怎么回事?今天就带大家深入了解这个 Redis 集群中的棘手问题。

什么是 Redis 集群脑裂?

脑裂(Split-Brain),简单来说就是集群中的节点因为网络问题等原因,分裂成了多个小集群,各自"独立"工作,导致数据不一致。

graph TB
    subgraph 正常集群
        M[主节点] --- S1[从节点1]
        M --- S2[从节点2]
    end

    subgraph 脑裂后
        subgraph 网络分区A
            M1[主节点]
        end

        subgraph 网络分区B
            S11[从节点1晋升为新主节点] --- S21[从节点2]
        end
    end

脑裂产生的原因

Redis 集群脑裂主要由以下几个原因引起:

  1. 网络分区:机房之间的网络故障导致节点间通信中断
  2. 节点负载过高:主节点 CPU 或内存压力大,响应变慢
  3. 心跳超时配置不合理:心跳检测间隔太短或超时时间设置不当
  4. 意外重启:主节点服务器突然重启

实际案例分析

某金融支付平台在月底结算高峰期遇到了典型的脑裂问题。系统架构如下:

graph LR
    subgraph 机房A
        MA[主节点]
    end

    subgraph 机房B
        SA1[从节点1]
        SA2[从节点2]
    end

    MA -- 同步数据 --> SA1
    MA -- 同步数据 --> SA2

当机房间网络出现短暂抖动时,从节点们无法接收到主节点的心跳包。此时,哨兵(Sentinel)机制判断主节点已经下线,从从节点中选举了一个新的主节点。但实际上,主节点还在运行!

脑裂后的核心矛盾:主节点并不知道自己已被"废黜",仍然认为自己是主节点并继续接收写请求。同时,哨兵已选出的新主节点也开始接收写请求。这就导致了两个不同的"主节点"同时存在,各自维护不同的数据版本。

sequenceDiagram
    participant M as 主节点
    participant S as 从节点
    participant ST as 哨兵
    participant A as 应用

    Note over M,S: 正常复制
    M->>S: 数据同步

    Note over M,S: 网络分区发生
    M--xS: 心跳超时
    ST->>ST: 判断主节点下线
    ST->>S: 选举新主节点

    A->>M: 写入请求A (红色路径)
    A->>S: 写入请求B (蓝色路径)

    Note over M,S: 数据分裂
    Note over M: 保存请求A的数据
    Note over S: 保存请求B的数据

    Note over M,S: 网络恢复
    S->>M: 覆盖数据(请求A数据丢失)

实际影响

  • 约 8%的交易记录被丢弃(主节点接收的交易未同步到新主节点)
  • 数据不一致导致对账失败,账务系统出现差异
  • 故障恢复耗时 45 分钟,期间部分支付渠道完全不可用
  • 交易对账差异处理耗费了运维团队整整一周时间

如何检测 Redis 集群是否发生脑裂?

我们可以通过以下几种方式检测脑裂:

  1. 监控 info replication 输出:检查主从状态是否异常
public boolean checkSplitBrain(Jedis jedis) {
    try {
        String info = jedis.info("replication");

        // 一次性解析所有需要的信息,提高效率
        Map<String, String> infoMap = new HashMap<>();
        for (String line : info.split("\n")) {
            String[] parts = line.split(":", 2);
            if (parts.length == 2) {
                infoMap.put(parts[0].trim(), parts[1].trim());
            }
        }

        // 获取角色和从节点数量
        String role = infoMap.get("role");
        int connectedSlaves = 0;

        try {
            if (infoMap.containsKey("connected_slaves")) {
                connectedSlaves = Integer.parseInt(infoMap.get("connected_slaves"));
            }
        } catch (NumberFormatException e) {
            // 格式解析异常时记录日志并使用默认值
            logger.warn("Failed to parse connected_slaves value", e);
        }

        // 如果是主节点但没有从节点连接,可能是脑裂
        return "master".equals(role) && connectedSlaves == 0;
    } catch (Exception e) {
        logger.error("Failed to check split brain status", e);
        // 检测失败时保守返回,认为可能存在脑裂
        return true;
    }
}
  1. Redis 哨兵日志分析:检查是否有频繁的主从切换记录
  2. 监控 master_run_id 变化:每个 Redis 实例都有唯一标识符,比较各节点认知的主节点 ID
public boolean detectMasterIdInconsistency(List<JedisPool> redisPools) {
    String masterRunId = null;

    try {
        for (JedisPool pool : redisPools) {
            try (Jedis jedis = pool.getResource()) {
                String info = jedis.info("replication");

                // 一次性解析信息
                Map<String, String> infoMap = new HashMap<>();
                for (String line : info.split("\n")) {
                    String[] parts = line.split(":", 2);
                    if (parts.length == 2) {
                        infoMap.put(parts[0].trim(), parts[1].trim());
                    }
                }

                // 获取角色和主节点ID
                String role = infoMap.get("role");
                String currentId = null;

                // 根据角色获取相应的ID
                if ("master".equals(role)) {
                    currentId = infoMap.get("run_id");
                } else if ("slave".equals(role)) {
                    currentId = infoMap.get("master_run_id");
                }

                if (currentId != null) {
                    if (masterRunId == null) {
                        masterRunId = currentId;
                    } else if (!masterRunId.equals(currentId)) {
                        // 发现不同的master_run_id,表示存在多个主节点
                        logger.warn("Detected inconsistent master IDs: {} vs {}", masterRunId, currentId);
                        return true;
                    }
                }
            }
        }
        return false;
    } catch (Exception e) {
        logger.error("Failed to detect master ID inconsistency", e);
        return true; // 检测失败时保守返回
    }
}
  1. 监控 master_link_status:从节点中的此值为"down"可能表示脑裂开始
public boolean checkMasterLinkStatus(Jedis slave) {
    try {
        String info = slave.info("replication");

        // 一次性解析
        Map<String, String> infoMap = new HashMap<>();
        for (String line : info.split("\n")) {
            String[] parts = line.split(":", 2);
            if (parts.length == 2) {
                infoMap.put(parts[0].trim(), parts[1].trim());
            }
        }

        String status = infoMap.get("master_link_status");
        return "up".equals(status); // 返回链接是否正常
    } catch (Exception e) {
        logger.error("Failed to check master link status", e);
        return false;
    }
}

脑裂问题解决方案

配置层面的预防

  1. 优化 Redis 配置

Redis 提供了三个重要参数来防止脑裂:

min-replicas-to-write 1      # 主节点必须至少有1个从节点连接
min-replicas-max-lag 10      # 数据复制和同步的最大延迟秒数
cluster-node-timeout 15000   # 集群节点超时毫秒数

这些配置的作用是:当主节点发现从节点数量不足或者数据同步延迟过高时,拒绝写入请求,防止数据不一致。

重要说明min-replicas-max-lag的单位是秒,与 Redis INFO 命令返回的lag字段单位一致。这确保了配置与监控的一致性。

  1. 网络质量保障

确保 Redis 集群节点间的网络稳定:

  • 使用内网专线连接
  • 避免跨公网部署
  • 配置合理的 TCP keepalive 参数
  • 多机房部署时:确保跨机房专线有冗余通道,避免单点故障

代码层面的解决方案

使用 Java 实现脑裂监控和自动恢复:

public class RedisSplitBrainMonitor {

    private static final Logger logger = LoggerFactory.getLogger(RedisSplitBrainMonitor.class);
    private final JedisPool jedisPool;
    private final int checkIntervalMillis;
    private final int minSlaves;
    private final int maxLag; // 单位:秒
    private final ExecutorService executor;
    private volatile boolean running = false;

    public RedisSplitBrainMonitor(JedisPool jedisPool, int checkIntervalMillis,
                                  int minSlaves, int maxLag) {
        this.jedisPool = jedisPool;
        this.checkIntervalMillis = checkIntervalMillis;
        this.minSlaves = minSlaves;
        this.maxLag = maxLag;
        this.executor = Executors.newSingleThreadExecutor(
            new ThreadFactory() {
                @Override
                public Thread newThread(Runnable r) {
                    Thread t = new Thread(r, "redis-split-brain-monitor");
                    t.setDaemon(true); // 设置为守护线程
                    return t;
                }
            }
        );
    }

    public void start() {
        if (running) {
            return;
        }

        running = true;
        executor.submit(() -> {
            while (running) {
                try {
                    checkAndRecover();
                    Thread.sleep(checkIntervalMillis);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    logger.warn("Split brain monitor interrupted", e);
                    break;
                } catch (Exception e) {
                    // 日志记录异常
                    logger.error("Split brain check failed", e);
                }
            }
        });
    }

    private void checkAndRecover() {
        try (Jedis jedis = jedisPool.getResource()) {
            String info = jedis.info("replication");

            // 一次性解析所有需要的信息
            Map<String, String> infoMap = new HashMap<>();
            for (String line : info.split("\n")) {
                String[] parts = line.split(":", 2);
                if (parts.length == 2) {
                    infoMap.put(parts[0].trim(), parts[1].trim());
                }
            }

            // 解析复制信息
            String role = infoMap.get("role");
            int connectedSlaves = 0;

            try {
                String slavesStr = infoMap.get("connected_slaves");
                if (slavesStr != null) {
                    connectedSlaves = Integer.parseInt(slavesStr);
                }
            } catch (NumberFormatException e) {
                logger.warn("Failed to parse connected_slaves value", e);
            }

            int replicationLag = calculateMaxReplicationLag(info);

            logger.debug("Redis status - role: {}, connected slaves: {}, max lag: {}s",
                      role, connectedSlaves, replicationLag);

            if ("master".equals(role) && (connectedSlaves < minSlaves || replicationLag > maxLag)) {
                // 可能的脑裂情况,执行恢复策略
                handlePotentialSplitBrain(jedis);
            }
        } catch (Exception e) {
            logger.error("Error during split brain check", e);
        }
    }

    private int calculateMaxReplicationLag(String info) {
        // 更健壮的复制延迟计算方法
        int maxLag = 0;
        try {
            // 通过正则匹配出所有slave开头的条目
            Pattern slavePattern = Pattern.compile("slave\\d+:(.+)");
            Matcher slaveMatcher = slavePattern.matcher(info);

            while (slaveMatcher.find()) {
                String slaveInfo = slaveMatcher.group(1);
                Map<String, String> slaveProps = new HashMap<>();

                // 将slave信息解析为key-value对
                for (String prop : slaveInfo.split(",")) {
                    String[] kv = prop.split("=", 2);
                    if (kv.length == 2) {
                        slaveProps.put(kv[0].trim(), kv[1].trim());
                    }
                }

                // 强化异常处理:先检查关键字段是否存在
                String state = slaveProps.get("state");
                String lagStr = slaveProps.get("lag");

                // 只有当state和lag字段都存在且state为online时才处理
                if (state != null && "online".equals(state) && lagStr != null && !lagStr.isEmpty()) {
                    try {
                        int lag = Integer.parseInt(lagStr);
                        maxLag = Math.max(maxLag, lag);
                    } catch (NumberFormatException e) {
                        logger.warn("Invalid lag value: {}", lagStr, e);
                    }
                }
            }
        } catch (Exception e) {
            logger.error("Error calculating max replication lag", e);
        }

        return maxLag;
    }

    private void handlePotentialSplitBrain(Jedis jedis) {
        // 脑裂处理策略
        logger.warn("Potential Redis split brain detected!");

        try {
            // 1. 立即暂停接收写请求(最高优先级)
            jedis.configSet("min-replicas-to-write", String.valueOf(minSlaves));
            jedis.configSet("min-replicas-max-lag", String.valueOf(maxLag));

            logger.info("Applied protective configuration: min-replicas-to-write={}, min-replicas-max-lag={}",
                      minSlaves, maxLag);

            // 2. 通知管理员
            notifyAdministrator("Potential Redis split brain detected!");

            // 3. 可选:强制进行主从切换(谨慎使用)
            // 此处可以调用哨兵API进行主动切换,但需谨慎操作
        } catch (Exception e) {
            logger.error("Failed to handle potential split brain", e);
        }
    }

    private void notifyAdministrator(String message) {
        // 实现通知逻辑:发送邮件、短信、钉钉等
        logger.warn("ADMIN ALERT: {}", message);
        // alertService.sendAlert("Redis集群警告", message);
    }

    public void stop() {
        running = false;
        try {
            // 尝试优雅关闭
            executor.shutdown();
            if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {
                executor.shutdownNow();
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            executor.shutdownNow();
        }
        logger.info("Redis split brain monitor stopped");
    }
}

使用哨兵+主从架构预防脑裂

flowchart TB
 subgraph s1["主节点区域"]
        M["主节点"]
  end
 subgraph s2["从节点区域"]
        S1["从节点1"]
        S2["从节点2"]
  end
 subgraph s3["哨兵集群"]
        ST1["哨兵1"]
        ST2["哨兵2"]
        ST3["哨兵3"]
  end
    M --- S1 & S2
    ST1 --- M & S1 & S2
    ST2 --- M & S1 & S2
    ST3 --- M & S1 & S2

    linkStyle 0 stroke:#AA00FF,fill:none
    linkStyle 1 stroke:#2962FF
    linkStyle 2 stroke:#FF6D00,fill:none
    linkStyle 3 stroke:#FF6D00,fill:none
    linkStyle 4 stroke:#FF6D00,fill:none
    linkStyle 5 stroke:#00C853,fill:none
    linkStyle 6 stroke:#00C853,fill:none
    linkStyle 7 stroke:#00C853,fill:none
    linkStyle 8 stroke:#FFD600,fill:none
    linkStyle 9 stroke:#FFD600,fill:none
    linkStyle 10 stroke:#FFD600,fill:none

哨兵配置示例:

sentinel monitor mymaster 192.168.1.100 6379 2
sentinel down-after-milliseconds mymaster 5000
sentinel failover-timeout mymaster 60000
sentinel parallel-syncs mymaster 1
sentinel auth-pass mymaster your_redis_password

这个配置表示:

  • 监控 IP 为 192.168.1.100,端口为 6379 的主节点
  • quorum=2表示至少 2 个哨兵认为主节点宕机才触发故障转移,这是避免单个哨兵误判导致脑裂的关键参数
  • down-after-milliseconds设置为 5000ms(5 秒),这比默认值 30 秒小很多,能更快检测到故障,但也增加了误判风险,需根据实际网络情况调整
  • 故障转移的超时时间为 60 秒
  • parallel-syncs=1表示每次只允许一个从节点进行同步,这可以降低故障转移过程中新主节点的负载压力,间接减少脑裂的风险。限制同步节点数量避免新主节点因带宽/CPU 瓶颈导致响应超时,防止刚完成选举的新主节点再次被误判下线,形成"二次脑裂"
  • auth-pass指定 Redis 密码,确保哨兵能在启用认证的情况下正常监控和管理 Redis 节点

哨兵集群规模设计

  • 哨兵节点数应为奇数(3/5/7),便于投票决策
  • 哨兵应分布在不同物理机、机架甚至机房,避免因单点故障导致哨兵集群失效
  • quorum值设置为(n/2)+1(n 为哨兵数量),确保多数派共识

哨兵与脑裂的关系

哨兵通过gossip协议交换节点状态,当quorum数量的哨兵达成共识后才触发故障转移,这能有效避免因网络分区导致的"部分哨兵误判",从而减少脑裂概率。只有当绝大多数哨兵都同意主节点已下线,才会执行主从切换操作,这是防止脑裂的关键机制。

哨兵模式 vs Redis Cluster 对比

哨兵模式主要解决高可用问题,通过主从复制和哨兵选举机制降低脑裂风险,适合中小规模部署;而 Redis Cluster(分片集群)主要解决数据分片问题,但因节点间通信更复杂,脑裂风险反而更高。在 Cluster 模式下,需额外注意cluster-require-full-coverage参数设置,避免部分分区不可用导致整个集群拒绝服务。简单来说,如果你主要担心脑裂问题,哨兵模式比 Cluster 模式更容易管理。

Redis 集群脑裂的恢复流程

当检测到脑裂发生后,恢复流程如下:

sequenceDiagram
    participant A as 监控系统
    participant B as 原Redis主节点
    participant C as 新Redis主节点
    participant D as 应用系统

    A->>A: 1. 检测到脑裂

    A->>B: 2. 立即阻止主节点写入(最高优先级)
    Note over B: 设置min-replicas-to-write

    A->>D: 3. 通知应用暂停写入

    A->>C: 4. 确认新主节点状态

    A->>A: 5. 数据一致性分析
    Note over A: 比对两个主节点数据
    Note over A: 根据业务决定保留哪个版本

    A->>B: 6. 降级主节点为从节点
    Note over B: 执行SLAVEOF命令
    Note over B: 清除临时数据

    A->>C: 7. 验证数据同步状态
    Note over C: 确认offset一致

    A->>D: 8. 灰度切换应用连接
    Note over D: 10%流量->50%->100%

    A->>D: 9. 恢复应用读写

数据一致性恢复策略

  1. 以新主节点为准:最简单的策略,但会丢失原主节点未同步的写入,适合允许少量数据丢失的场景(如缓存、日志类数据)
  2. 数据合并:复杂但保留双方数据,需业务层支持冲突解决,适合金融、订单等强一致性要求的业务场景
  3. 时间戳对比:根据数据的时间戳决定保留哪个版本,要求业务数据包含可靠的时间戳字段

数据一致性验证工具

  • Redis 官方工具redis-cli --rdb导出 RDB 文件进行对比
  • redis-dump工具可以将数据导出为 JSON 格式,便于差异比对
  • redis-compare-tool可用于快速比对两个 Redis 实例的键差异

恢复过程中的风险控制

  • 数据一致性验证前先做 RDB 备份,确保有回滚能力
  • 应用连接切换采用灰度方式,避免新主节点承受突发流量
  • 设置明确的回滚触发条件:如连续 3 次写入错误率超过 0.5%,或单一批次错误率超过 2%时立即回滚
  • 灰度阶段每批次保持 5 分钟观察期,确保系统稳定后再增加流量比例

实战场景:金融支付系统的 Redis 防脑裂方案

在金融支付系统中,交易记录、账户余额等都是关键信息,不容有失。针对这种场景,推荐以下 Redis 集群防脑裂策略:

1. 业务层防护

金融系统的业务层防护至关重要:

@Service
public class TransactionServiceImpl implements TransactionService {

    private final StringRedisTemplate redisTemplate;
    private final TransactionRepository repository;

    @Override
    @Transactional
    public void processPayment(PaymentRequest request) {
        // 1. 生成带版本号的交易ID
        String transactionId = generateTransactionId();

        // 2. 使用分布式锁确保同一账户操作串行化
        RLock lock = redisson.getLock("account:" + request.getAccountId());
        try {
            // 设置获取锁超时和锁过期时间
            if (lock.tryLock(5, 10, TimeUnit.SECONDS)) {
                try {
                    // 3. 写入数据库主表
                    Transaction tx = new Transaction();
                    tx.setTransactionId(transactionId);
                    tx.setAmount(request.getAmount());
                    tx.setTimestamp(System.currentTimeMillis());
                    tx.setVersion(1L);  // 初始版本号
                    repository.save(tx);

                    // 4. 写入Redis缓存,使用乐观锁模式
                    // 若发生脑裂,不同主节点可能同时写入,但版本号确保后续恢复时能识别最新数据
                    redisTemplate.opsForHash().putIfAbsent(
                        "transaction:" + transactionId,
                        "data",
                        JSON.toJSONString(tx)
                    );

                    // 5. 发送MQ消息,确保异步系统也能收到通知
                    kafkaTemplate.send("transaction-topic", transactionId, JSON.toJSONString(tx));

                } finally {
                    lock.unlock();
                }
            } else {
                throw new ConcurrentOperationException("Account is locked by another operation");
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new ServiceException("Lock acquisition interrupted", e);
        }
    }
}

2. 实际监控代码

@Component
@Slf4j
public class RedisSplitBrainPreventor {

    private final StringRedisTemplate redisTemplate;
    private final AlertService alertService;

    @Value("${redis.min-slaves:1}")
    private int minSlaves;

    @Value("${redis.max-lag:10}")
    private int maxLag;

    // 健康检查指标
    private final AtomicBoolean lastHealthStatus = new AtomicBoolean(true);
    private final AtomicLong unhealthyStartTime = new AtomicLong(0);

    // 为监控系统提供的指标
    @Getter
    private final AtomicInteger connectedSlaves = new AtomicInteger(0);
    @Getter
    private final AtomicInteger maxReplicationLag = new AtomicInteger(0);
    @Getter
    private final AtomicReference<String> masterLinkStatus = new AtomicReference<>("unknown");

    public RedisSplitBrainPreventor(StringRedisTemplate redisTemplate,
                                   AlertService alertService) {
        this.redisTemplate = redisTemplate;
        this.alertService = alertService;
    }

    @Scheduled(fixedRate = 10000) // 每10秒检查一次
    public void checkRedisHealth() {
        try {
            RedisConnection connection = redisTemplate.getConnectionFactory().getConnection();
            Properties info = connection.info("replication");

            // 一次性解析所有需要的信息
            Map<String, String> infoMap = new HashMap<>();
            for (Object key : info.keySet()) {
                infoMap.put(key.toString(), info.getProperty(key.toString()));
            }

            String role = infoMap.getOrDefault("role", "");
            String connectedSlavesStr = infoMap.getOrDefault("connected_slaves", "0");
            String masterLinkStatusStr = infoMap.getOrDefault("master_link_status", "unknown");

            // 更新主从连接状态
            masterLinkStatus.set(masterLinkStatusStr);

            // 解析从节点数量
            int currentConnectedSlaves = 0;
            try {
                currentConnectedSlaves = Integer.parseInt(connectedSlavesStr);
                // 更新监控指标
                connectedSlaves.set(currentConnectedSlaves);
            } catch (NumberFormatException e) {
                log.warn("Failed to parse connected_slaves: {}", connectedSlavesStr, e);
            }

            log.debug("Redis role: {}, connected slaves: {}, master_link_status: {}",
                     role, currentConnectedSlaves, masterLinkStatusStr);

            // 计算最大复制延迟
            int currentMaxLag = calculateMaxLag(info);
            maxReplicationLag.set(currentMaxLag);

            if ("master".equals(role) &&
                (currentConnectedSlaves < minSlaves || currentMaxLag > maxLag)) {

                // 首次检测到不健康状态
                if (lastHealthStatus.compareAndSet(true, false)) {
                    unhealthyStartTime.set(System.currentTimeMillis());
                    log.warn("Redis unhealthy state detected! Connected slaves: {}, max lag: {}s",
                              currentConnectedSlaves, currentMaxLag);
                }

                // 如果不健康状态持续超过30秒,认为可能发生脑裂
                long unhealthyDuration = System.currentTimeMillis() - unhealthyStartTime.get();
                if (unhealthyDuration > 30000) {
                    handlePotentialSplitBrain(connection, currentConnectedSlaves, currentMaxLag);
                }
            } else if ("slave".equals(role) && "down".equals(masterLinkStatusStr)) {
                // 从节点失去与主节点的连接,可能是网络分区开始
                log.warn("Slave node lost connection to master. Potential network partition starting.");
                alertService.sendAlert("Redis主从连接异常",
                                    "从节点与主节点连接中断,可能出现网络分区",
                                    AlertLevel.WARNING);
            } else if (lastHealthStatus.compareAndSet(false, true)) {
                // 恢复健康
                log.info("Redis returned to healthy state. Connected slaves: {}, max lag: {}s",
                         currentConnectedSlaves, currentMaxLag);
            }
        } catch (Exception e) {
            log.error("Failed to check Redis health", e);
            alertService.sendAlert("Redis监控异常", "无法检查Redis健康状态: " + e.getMessage());
        }
    }

    private int calculateMaxLag(Properties info) {
        int maxLag = 0;
        try {
            // 获取并解析所有从节点信息
            for (int i = 0; ; i++) {
                String slaveKey = "slave" + i;
                String slaveInfo = info.getProperty(slaveKey);
                if (slaveInfo == null) {
                    break;
                }

                // 将从节点信息解析成键值对
                Map<String, String> slaveProps = new HashMap<>();
                for (String prop : slaveInfo.split(",")) {
                    String[] kv = prop.split("=", 2);
                    if (kv.length == 2) {
                        slaveProps.put(kv[0].trim(), kv[1].trim());
                    }
                }

                // 强化字段存在性检查
                String state = slaveProps.get("state");
                String lagStr = slaveProps.get("lag");

                // 只有当state和lag字段都存在且state为online时才处理
                if (state != null && "online".equals(state) && lagStr != null && !lagStr.isEmpty()) {
                    try {
                        int lag = Integer.parseInt(lagStr);
                        maxLag = Math.max(maxLag, lag);
                    } catch (NumberFormatException e) {
                        log.warn("Invalid lag value: {}", lagStr, e);
                    }
                }
            }
        } catch (Exception e) {
            log.warn("Error calculating max lag", e);
        }
        return maxLag;
    }

    private void handlePotentialSplitBrain(RedisConnection connection,
                                          int currentConnectedSlaves,
                                          int currentMaxLag) {
        log.warn("Potential split brain detected! Connected slaves: {}, max lag: {}s",
                 currentConnectedSlaves, currentMaxLag);

        try {
            // 1. 立即阻止写入(最高优先级)
            connection.setConfig("min-replicas-to-write", String.valueOf(minSlaves));
            connection.setConfig("min-replicas-max-lag", String.valueOf(maxLag));

            log.info("Applied protective configuration: min-replicas-to-write={}, min-replicas-max-lag={}",
                    minSlaves, maxLag);

            // 2. 发送高优先级告警
            alertService.sendUrgentAlert("Redis潜在脑裂风险",
                                      "主节点状态异常,连接从节点:" + currentConnectedSlaves +
                                      ",最大延迟:" + currentMaxLag + "秒",
                                      AlertLevel.CRITICAL);

            // 3. 触发应用降级策略
            triggerApplicationFallback();
        } catch (Exception e) {
            log.error("Failed to handle potential split brain", e);
        }
    }

    private void triggerApplicationFallback() {
        // 实现应用降级逻辑
        log.info("Triggering application fallback strategy");

        try {
            // 通过Spring Cloud Gateway动态调整路由策略
            RouteDefinition writeRoute = new RouteDefinition();
            writeRoute.setId("redis-write-route");

            // 设置路由断言和过滤器
            PredicateDefinition predicate = new PredicateDefinition();
            predicate.setName("Path");
            predicate.addArg("pattern", "/api/write/**");
            writeRoute.setPredicates(Collections.singletonList(predicate));

            // 添加熔断过滤器,拒绝写请求
            FilterDefinition filter = new FilterDefinition();
            filter.setName("CircuitBreaker");
            filter.addArg("name", "redisWriteBreaker");
            filter.addArg("fallbackUri", "forward:/api/readonly-fallback");
            writeRoute.setFilters(Collections.singletonList(filter));

            // 更新路由配置
            gatewayClient.update(writeRoute);

            log.info("Application write operations disabled via API gateway");
        } catch (Exception e) {
            log.error("Failed to update gateway routes", e);
        }
    }
}

3. 压测复现脑裂问题

在测试环境中可以使用 Linux 的tc(Traffic Control)工具模拟网络分区,复现脑裂:

# 在主节点服务器上模拟网络分区
tc qdisc add dev eth0 root netem loss 100%

# 同时监控各节点的角色和主节点ID
while true; do
    echo "Main node:";
    redis-cli -h main-redis info replication | grep -E "role|master_run_id|connected_slaves";
    echo "Replica 1:";
    redis-cli -h replica1 info replication | grep -E "role|master_run_id|master_link_status";
    echo "Replica 2:";
    redis-cli -h replica2 info replication | grep -E "role|master_run_id|master_link_status";
    echo "-----------------";
    sleep 1;
done

# 一段时间后恢复网络
tc qdisc del dev eth0 root

测试前的准备工作

  1. 备份所有关键数据(主节点和从节点的 RDB 文件)
  2. 暂停依赖 Redis 的非核心业务
  3. 准备好回滚方案,设置好监控告警阈值
  4. 通知相关团队,确保测试时段内有人员待命处理可能的问题

测试成功的判断标准

  • 哨兵在 10 秒内检测到主节点"下线"
  • 30 秒内成功选举新主节点
  • 应用系统在 45 秒内自动进入只读模式
  • 网络恢复后 60 秒内自动完成数据同步
  • 灰度切换过程中错误率不超过 0.1%

4. 云原生环境中的 Redis 部署

在 Kubernetes 环境中部署 Redis 集群时,需要特别注意以下配置以避免脑裂:

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: redis
spec:
  serviceName: redis
  replicas: 3
  selector:
    matchLabels:
      app: redis
  template:
    metadata:
      labels:
        app: redis
    spec:
      # 设置节点反亲和性,避免Redis实例部署在同一节点
      affinity:
        podAntiAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            - labelSelector:
                matchExpressions:
                  - key: app
                    operator: In
                    values:
                      - redis
              topologyKey: "kubernetes.io/hostname"
      containers:
        - name: redis
          image: redis:6.2
          command:
            - redis-server
            - "/etc/redis/redis.conf"
          ports:
            - containerPort: 6379
          volumeMounts:
            - name: redis-config
              mountPath: /etc/redis
            - name: redis-data
              mountPath: /data
          resources:
            requests:
              cpu: 500m
              memory: 1Gi
            limits:
              cpu: 1000m
              memory: 2Gi
      volumes:
        - name: redis-config
          configMap:
            name: redis-config
  # 使用持久卷确保数据持久化
  volumeClaimTemplates:
    - metadata:
        name: redis-data
      spec:
        accessModes: ["ReadWriteOnce"]
        storageClassName: "ssd"
        resources:
          requests:
            storage: 10Gi
---
# Redis配置中添加防脑裂参数
apiVersion: v1
kind: ConfigMap
metadata:
  name: redis-config
data:
  redis.conf: |
    # 常规配置
    port 6379
    dir /data

    # 防脑裂配置
    min-replicas-to-write 1
    min-replicas-max-lag 10

    # 集群配置
    cluster-enabled yes
    cluster-config-file /data/nodes.conf
    cluster-node-timeout 15000

K8s 环境中的实战经验

  • 使用 StatefulSet 而非 Deployment,确保稳定的网络标识
  • 配置 Pod 反亲和性,将 Redis 节点分散到不同物理机
  • 使用高性能 StorageClass,避免磁盘 IO 成为瓶颈
  • 设置资源限制,防止节点过载
  • 使用网络策略(NetworkPolicy)限制 Redis 集群内部通信,提高安全性

混沌工程实战:主动注入脑裂故障

为了验证系统在脑裂场景下的稳定性,可以采用混沌工程原则,定期进行故障注入测试:

  1. 周期性脑裂模拟:每月进行一次主从网络隔离测试
  2. 故障自愈验证:验证自动检测和恢复机制是否正常工作
  3. 数据一致性检验:测试后对比主从数据,验证恢复效果

使用 Chaos Monkey 实现自动化故障注入:

apiVersion: chaos-mesh.org/v1alpha1
kind: NetworkChaos
metadata:
  name: redis-network-partition
spec:
  action: partition
  mode: all
  selector:
    namespaces:
      - redis
    labelSelectors:
      app: redis
      role: master
  direction: to
  target:
    selector:
      namespaces:
        - redis
      labelSelectors:
        app: redis
        role: slave
  duration: "5m"

测试成功标准

  • 监控系统在 30 秒内识别出脑裂风险
  • 防护措施在 60 秒内自动激活
  • 应用系统在 75 秒内进入降级模式
  • 网络恢复后,数据同步时间不超过 2 分钟
  • 全部恢复后,数据一致性校验通过率 100%

这些明确的指标可以帮助评估你的系统在脑裂场景下的表现,持续改进防护措施的有效性。

标准化应急预案

针对 Redis 脑裂,建立标准化应急预案非常重要:

1. 故障确认阶段

  • 触发条件:至少 3 个监控指标异常(如 connected_slaves=0、master_link_status=down、master_run_id 不一致)
  • 确认步骤

    1. 检查各节点INFO replication输出
    2. 验证哨兵日志中的主节点感知状态
    3. 确认应用侧是否有写入错误增加

2. 应急处理阶段

  • 应急小组:DBA 负责人、应用开发负责人、网络工程师
  • 处理流程

    1. 立即阻止继续写入:设置min-replicas-to-write(最高优先级)
    2. 触发告警:通知应急小组
    3. 判断主节点:根据哨兵多数意见确认"真"主节点
    4. 手动强制切换:必要时使用SENTINEL FAILOVER命令

3. 数据恢复阶段

  • 数据一致性检查

    1. 对比主从节点数据(可使用 Redis 官方工具redis-cli --rdb导出比对)
    2. 根据业务时间戳确认最新数据
  • 数据恢复策略

    1. 普通缓存数据:以新主节点为准
    2. 关键业务数据:执行差异合并或回放丢失事务

4. 恢复与回顾

  • 灰度恢复

    1. 10%应用流量接入
    2. 监控 1 分钟无异常后扩大到 50%
    3. 再观察 5 分钟无异常后全量恢复
  • 后续优化

    1. 记录恢复时间(RTO)和数据丢失量(RPO)
    2. 分析根因并更新预防措施

总结

问题原因解决方案实战经验
脑裂定义集群分裂成多个独立工作的部分-理解原理是解决问题的基础
产生原因网络分区、节点负载过高、心跳超时配置不合理、意外重启网络优化、硬件升级、参数调优合理规划网络拓扑,避免跨公网部署
危害数据不一致、服务中断、性能下降快速检测和自动恢复建立多指标监控体系(包括 master_run_id、master_link_status 等)
预防策略-min-replicas-to-write、min-replicas-max-lag 参数配置奇数个哨兵(至少 3 个),quorum≥(n/2)+1,跨机架部署
代码实现-异常处理完善的监控代码、健壮的字段解析、自动恢复逻辑结合业务特性实现数据一致性保障机制(如版本号、分布式锁)
架构设计-哨兵模式、多机房冗余部署、云原生适配根据 RTO/RPO 要求选择架构,确保网络冗余设计
恢复流程-标准化应急预案、灰度恢复策略、数据一致性工具定期混沌工程测试,优化恢复时间,明确数据恢复策略

异常君
7 声望8 粉丝

在 Java 的世界里,永远有下一座技术高峰等着你。我愿做你登山路上的同频伙伴,陪你从看懂代码到写出让自己骄傲的代码。咱们,代码里见!