博客 / 詳情

返回

當 Redis 集羣説"分手":Redis 集羣腦裂問題及解決方案

深夜,生產環境告警瘋狂轟炸,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 要求選擇架構,確保網絡冗餘設計
恢復流程 - 標準化應急預案、灰度恢復策略、數據一致性工具 定期混沌工程測試,優化恢復時間,明確數據恢復策略
user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.