上周二凌晨,我们的支付回调服务突发严重的数据不一致,用户明明扣款成功但订单状态在 Redis 里依然是未支付。排查了半天日志,最后定位到了 Redis 哨兵(Sentinel)架构下典型的“脑裂”(Split-brain)数据丢失问题。这块大坑坑得我们大半夜被叫起来给用户退款,排查过程极其折腾,心智负担极重。

故障现场的部署拓扑是这样的:我们部署了 3 个 Sentinel 节点和一主二从的 Redis 实例。由于机房网络波动,突然发生了网络分区。主库 Master 和 Sentinel A 被隔离在分区一;而 Sentinel B、C 以及两个从库 Slave1、Slave2 被划分在分区二。

由于 Sentinel 判定主库下线的 quorum 配置的是 2,分区二中的 Sentinel B 和 C 在几秒内就协商一致,认为 Master 已经挂了,于是选出了 Slave1 提升为新的 Master。但对于客户端而言,由于旧 Master 依旧存活,且网络分区中部分微服务还能连接到旧 Master,客户端继续往旧 Master 里写入了大量支付订单状态。

当网络分区恢复、两部分网络重新联通后,哨兵强制将旧 Master 降级为新 Master 的从库。此时,旧 Master 执行 replicaof 重新同步,进行全量复制。在这个动作中,旧 Master 会清空自己的本地数据以加载新 Master 的 RDB 报文。结果,在网络脑裂期间客户端写入旧 Master 的这批订单状态全部人间蒸发,造成了严重的写丢失。

为了解决这个黑盒问题,彻底屏蔽脑裂隐患,我们需要在 Redis 服务端修改 redis.conf 增加强制一致性约束:

# /etc/redis/redis.conf
# 至少有 1 个从库在进行心跳同步,否则主库拒绝写入
min-replicas-to-write 1
# 从库心跳检测延迟不得超过 10 秒
min-replicas-max-lag 10

配置该参数后,一旦发生网络分区,旧 Master 发现只剩自己一人,找不到任何健康的 Replica,就会直接拒绝客户端的写入请求,抛出 NOREPLICAS Not enough good replicas to write. 的异常。

在客户端侧,我们需要优雅地捕获这个异常并退化为重试或者切换哨兵感知新 Master。下面是我们在生产环境落地并验证过的 Go 语言 go-redis 客户端哨兵防脑裂写丢失的代码实现:

package main

import (
	"context"
	"errors"
	"fmt"
	"log"
	"time"

	"github.com/redis/go-redis/v9"
)

// RedisSentinelClient 封装了哨兵连接和写保护检测
type RedisSentinelClient struct {
	client *redis.Client
	ctx    context.Context
}

// NewSentinelClient 初始化哨兵模式连接池
func NewSentinelClient() *RedisSentinelClient {
	rdb := redis.NewFailoverClient(&redis.FailoverOptions{
		MasterName:    "mymaster",
		SentinelAddrs: []string{"192.168.1.101:26379", "192.168.1.102:26379", "192.168.1.103:26379"},
		DB:            0,
		DialTimeout:   2 * time.Second,
		ReadTimeout:   1 * time.Second,
		WriteTimeout:  1 * time.Second,
		PoolSize:      50,
	})

	return &RedisSentinelClient{
		client: rdb,
		ctx:    context.Background(),
	}
}

// SafeSet 尝试写入 Redis Master,捕获因为 min-replicas-to-write 导致的写拒绝异常
func (r *RedisSentinelClient) SafeSet(key string, value string, ttl time.Duration) error {
	err := r.client.Set(r.ctx, key, value, ttl).Err()
	if err == nil {
		return nil
	}

	// 捕获 NOREPLICAS 错误,进行重试或降级到 DB/本地缓存
	if err.Error() == "NOREPLICAS Not enough good replicas to write." {
		log.Printf("[RECOVERY_WARN] Redis 脑裂防护生效!从节点数量不足,拒绝写操作以保护一致性. Key: %s", key)
		return fmt.Errorf("redis_split_brain_protection: %w", err)
	}

	return err
}

func main() {
	client := NewSentinelClient()
	defer client.client.Close()

	// 模拟写入订单状态
	err := client.SafeSet("order:txn:202605070001", "PAID", 10*time.Minute)
	if err != nil {
		log.Printf("[ERROR] 写入失败,将降级至本地持久化队列,等待哨兵重连: %v", err)
		// TODO: 执行备用持久化逻辑
		return
	}
	fmt.Println("订单状态写入成功,并已同步到足够数量的副本中。")
}

改造完毕后,我们在测试环境模拟断开 Slave 节点的网线。此时向 Master 写入数据会立刻收到 NOREPLICAS 报错,完美保住了数据的一致性。

遇到类似情况时,你们在生产中是直接开启 min-replicas-to-write 保护并接受短暂的写入拒绝,还是宁可忍受极小概率的数据丢失也要保障服务的高可用写入?欢迎在评论区聊聊你们的设计考量。