第25章 Redis集群脑裂实录:一次真实故障复盘

文章来源声明: 原文作者:一个爱跑步的猿; 来源站点:掘金; 原文链接:https://juejin.cn/post/7684845209646514176; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

真实时间线加原理拆解,适合后端与运维做脑裂风险排查。它提醒高可用不等于不丢数据,min-replicas与WAIT只能缩小窗口,关键业务需架构与业务双重兜底。

> 所属模块:模块四:缓存架构深水区

Redis集群脑裂实录:一次真实故障复盘

一、时间线复盘

T+0s 机房网络发生短暂分区,Redis主节点与多数Sentinel节点之间的网络通信中断,但主节点自身依然正常运行,并且还能接受来自部分客户端(那些恰好网络没有受影响、依然能连接到原主节点的客户端)的写入请求。

T+3s 失去与主节点联系的多数Sentinel,依次判定主节点"主观下线",累计确认数达到法定数量后,判定为"客观下线"。

T+5~8s Sentinel集群发起选举,选出一个从节点提升为新的主节点,并通知其他从节点和客户端更新连接信息,大部分客户端开始将写请求路由到新主节点。

T+8~20s(问题窗口期) 此时出现了脑裂——原主节点由于网络分区依然认为自己是主节点,继续接受那部分未能感知到主从切换的客户端的写入;而新主节点同时也在接受另一部分客户端的写入。两个"主节点"并存,各自独立地接受写操作,数据出现分裂

T+25s 网络分区恢复,原主节点重新与集群建立通信,Sentinel检测到集群里出现了两个主节点的冲突状态,将原主节点强制降级为新主节点的从节点,原主节点在这个降级过程中会清空自己在分区期间产生的数据,重新从新主节点全量同步数据。

故障后果:原主节点在T+8~25s期间接受的所有写入(约17秒的数据),在降级重新同步时被丢弃,造成了数据丢失。

时间线速览

阶段时间关键动作数据风险
网络分区发生T+0s主节点与多数Sentinel失联,但仍可服务部分客户端尚无风险,服务表面正常
主观下线→客观下线T+3s多数Sentinel达成一致,判定主节点下线
选举新主T+5~8s新主节点上线,多数客户端切换过去无(数据尚未分裂)
脑裂窗口期T+8~20s双主并存,各自独立接受写入**数据开始分裂,风险持续累积**
分区恢复、原主降级T+25s原主全量同步新主数据,期间写入被丢弃**约17秒的数据永久丢失**

二、原理拆解

2.1 脑裂的本质:CAP 定理下的必然取舍

脑裂产生的根本原因是网络分区场景下,原主节点自身无法感知到"自己已经被集群判定下线并选出了新主"这个事实,只要它还能接受写请求(哪怕这些请求来自部分网络隔离的客户端),它就会继续正常工作,而不会主动停止服务。

从分布式系统 CAP 理论的角度看,网络分区(Partition)发生时,系统只能在一致性(Consistency)和可用性(Availability)之间二选一:Sentinel 的设计选择偏向可用性优先——只要还有部分节点能对外提供服务,就不会让整个集群停止写入,这也是它能在故障发生后几秒内就完成自动切换、快速恢复服务能力的原因;但这个设计取向的代价,就是在网络分区尚未愈合之前,两侧的节点都"以为自己是对的",从而产生脑裂。理解这一点很重要:脑裂不是 Sentinel 的 bug,而是"可用性优先"这个架构选择在网络分区场景下的必然副作用

2.2 Sentinel 故障转移流程细节

Sentinel 判定主节点下线并完成切换,依赖几个关键配置:

  • down-after-milliseconds:单个 Sentinel 认为主节点"主观下线"所需要的连续失联时长;
  • quorum:需要多少个 Sentinel 都认为主节点主观下线,才会被判定为"客观下线",这是一个法定人数(quorum)机制,避免单个 Sentinel 因自身网络问题误判;
  • failover-timeout:一次故障转移流程允许的最长耗时,超时会重新发起;
  • parallel-syncs:故障转移完成后,允许多少个从节点同时向新主节点发起同步,数值越大切换后集群恢复正常服务的速度越快,但对新主节点的压力也越大。

down-after-millisecondsquorum 的取值直接决定了脑裂问题的检测速度:设置得太短,正常的网络抖动也可能被误判为节点下线,引发不必要的切换;设置得太长,脑裂窗口期会相应拉长,本例中的 T+3s 到 T+8s 这几秒延迟,正是这几个参数共同作用的结果。

2.3 降低脑裂数据丢失风险:min-replicas 参数

Redis提供了min-replicas-to-writemin-replicas-max-lag两个参数用于降低脑裂期间的数据丢失风险(而不是完全避免脑裂发生)——设置这两个参数后,主节点会要求至少有N个从节点在M秒内成功同步,才允许接受写请求;如果因为网络分区导致主节点感知到自己已经无法和足够数量的从节点保持同步,会主动拒绝写入,从而缩小脑裂期间"孤立无援"的原主节点能够继续接受写入并造成数据丢失的窗口。

容易被忽视的细节min-replicas-to-write 判断的是"当前是否有足够数量的从节点处于正常同步状态",这是一个持续性的健康检查,而不是针对"这一次具体的写入是否已经同步给了从节点"的确认。也就是说,即使配置了这两个参数,主节点在判定自己"从节点同步正常"的那一刻到真正失去与从节点联系之间,仍然存在一个小的时间差,这段时间差内的写入依然有丢失风险——这两个参数只是缩小脑裂窗口,不是逐条写入的强确认机制。

2.4 WAIT 命令:针对单次写入的同步确认

如果需要针对每一次具体的写操作做更强的同步确认,可以配合 Redis 的 WAIT 命令——客户端发起写入后,调用 WAIT numreplicas timeout,等待指定数量的从节点确认已经收到这次写入的复制数据,才认为写入成功。这比 min-replicas-to-write 的"持续性健康检查"更进一步,做到了对单次写入的同步确认,但代价是会显著增加写入延迟(需要等待网络往返和从节点处理时间),因此通常只在对数据丢失极度敏感的关键写操作上使用,而不是对所有写请求全局启用。

2.5 Redis Cluster 模式下的脑裂:与哨兵模式的差异

如果使用的是 Redis Cluster(去中心化集群模式)而不是哨兵模式,脑裂的检测和处理机制有所不同:Cluster 内部通过 Gossip 协议让各节点互相感知彼此的存活状态,节点是否下线由集群内多数派节点共同投票判定(而不是依赖独立于数据节点之外的 Sentinel),关键参数是 cluster-node-timeout(节点无响应多久后被认为疑似下线)。Cluster 模式下同样存在"网络分区后少数派节点侧的主节点依然对外服务"的风险,Redis 官方建议为每个负责写入的主节点配置足够数量的从节点,并结合 cluster-require-full-coverage 等参数,在集群明显处于分裂状态时让少数派一侧直接拒绝服务,而不是继续对外提供可能不一致的写入能力。

2.6 更彻底的方案:如果数据零丢失是硬性要求

对数据一致性要求极高、无法接受任何丢失的场景(如资金类计数),min-replicas-to-write + WAIT 命令的组合也只能把风险降到较低水平,而不是理论上的零风险。更彻底的取舍包括:

  • 采用多可用区部署,尽量降低机房内网络分区的发生概率,并确保 Sentinel 和 Redis 节点分布在不同可用区,避免"整个决策链路"因单一机房故障而集体失效;
  • 将 AOF 持久化策略设置为 always(每次写入都刷盘),以进一步降低单机数据丢失风险(但会显著增加写入延迟,通常只用于对丢失零容忍的少数关键数据);
  • 对于真正要求强一致性(不允许脑裂)的场景,考虑改用基于 Raft/Paxos 等共识协议的存储(如 etcd、TiKV),Redis 的架构设计本质上是为高性能和高可用优化的,并不以强一致性为首要目标;
  • 在业务层建立幂等和补偿机制——例如关键写操作携带版本号或唯一请求 ID,即使发生脑裂导致的数据回滚,也能通过重放请求日志或对账机制找出并补偿丢失的数据,把"防止脑裂"和"脑裂后能否恢复"分开设计。

三、排查工具 / 关键命令

<span># 查看主从复制状态,判断是否存在脑裂风险(比如从节点数量异常减少)</span>
redis-cli -h <host> INFO replication

<span># 故障复盘时,结合Sentinel的日志(记录主观下线/客观下线/选举过程)</span>
<span># 和应用层的写入日志(哪些写请求路由到了哪个节点)做时间线比对</span>
<span>tail</span> -f /var/log/redis/sentinel.log

<span># 查看 Sentinel 当前对主节点的判断状态和已知的从节点列表</span>
redis-cli -p 26379 SENTINEL master mymaster
redis-cli -p 26379 SENTINEL replicas mymaster

<span># 查看 Sentinel 配置的 quorum、down-after-milliseconds 等关键参数当前生效值</span>
redis-cli -p 26379 SENTINEL CKQUORUM mymaster

<span># Redis Cluster 模式下查看各节点的存活判定和槽位分布,确认是否存在网络分区导致的视图分裂</span>
redis-cli --cluster check <host>:<port>
redis-cli -c CLUSTER NODES

<span># 故障演练:通过网络工具(如 tc/iptables)人为制造网络分区,复现脑裂场景以验证配置有效性</span>
<span># 例如临时阻断主节点与多数Sentinel之间的通信,观察 min-replicas-to-write 是否按预期拒绝写入</span>
iptables -A INPUT -s <sentinel_ip> -j DROP

四、配置与代码示例

4.1 降低脑裂数据丢失风险的核心配置

<span># redis.conf,主节点配置</span>
min-replicas-to-write <span>1</span>
min-replicas-max-lag <span>10</span>

这两个参数的含义是:如果主节点发现处于"正常同步状态"(数据延迟不超过10秒)的从节点数量少于1个,就拒绝写入请求——这样即使发生网络分区,原主节点也会因为无法确认从节点的同步状态而较快地停止接受写入,而不是无限期地继续"孤立无援"地写入数据,大幅缩短脑裂窗口期能造成的数据损失范围。

4.2 Sentinel 关键参数配置示例

<span># sentinel.conf</span>
<span>sentinel</span> <span>monitor</span> <span>mymaster</span> <span>192.168</span><span>.1</span><span>.10</span> <span>6379 </span><span>2</span>
<span>sentinel</span> <span>down-after-milliseconds</span> <span>mymaster</span> <span>3000</span>
<span>sentinel</span> <span>failover-timeout</span> <span>mymaster</span> <span>10000</span>
<span>sentinel</span> <span>parallel-syncs</span> <span>mymaster</span> <span>1</span>

其中 2 是 quorum 值,表示需要至少 2 个 Sentinel 都认定主节点下线,才会触发客观下线判定;down-after-milliseconds 设置为 3000 表示单个 Sentinel 连续 3 秒无法联系主节点即判定主观下线,这个数值需要结合网络环境的正常抖动范围来设置,过短容易误判,过长会拉长脑裂窗口。

4.3 关键写操作使用 WAIT 命令做同步确认

<span>// 对资金类等关键写操作,写入后显式等待至少1个从节点确认同步,</span>
<span>// 超时时间设置为500ms,超时则认为本次写入的持久性无法保证,需要业务层介入处理</span>
<span>public</span> <span>boolean</span> <span>criticalWrite</span><span>(String key, String value)</span> {
    redisTemplate.opsForValue().set(key, value);
    <span>Long</span> <span>ackedReplicas</span> <span>=</span> redisTemplate.execute((RedisCallback<Long>) connection ->
        connection.execute(<span>"WAIT"</span>, <span>"1"</span>.getBytes(), <span>"500"</span>.getBytes()) <span>instanceof</span> Long
            ? (Long) connection.execute(<span>"WAIT"</span>, <span>"1"</span>.getBytes(), <span>"500"</span>.getBytes())
            : <span>0L</span>
    );
    <span>if</span> (ackedReplicas == <span>null</span> || ackedReplicas < <span>1</span>) {
        log.warn(<span>"WAIT confirm failed for key={}, replication may not be guaranteed"</span>, key);
        <span>return</span> <span>false</span>; <span>// 业务层可选择重试、降级或告警,而不是默认认为写入已可靠持久化</span>
    }
    <span>return</span> <span>true</span>;
}

4.4 脑裂后数据丢失核对脚本(复盘用)

<span>// 结合应用层写入审计日志(记录每次写请求的key、value、写入时间、路由到的节点)</span>
<span>// 与故障恢复后 Redis 中的实际数据做比对,定位脑裂窗口期内哪些写入被丢弃</span>
<span>public</span> List<String> <span>findLostWritesDuringSplitBrain</span><span>(Instant windowStart, Instant windowEnd)</span> {
    List<WriteAuditLog> logs = writeAuditRepository.findByWriteTimeBetween(windowStart, windowEnd);
    List<String> lostKeys = <span>new</span> <span>ArrayList</span><>();
    <span>for</span> (WriteAuditLog logEntry : logs) {
        <span>String</span> <span>currentValue</span> <span>=</span> redisTemplate.opsForValue().get(logEntry.getKey());
        <span>if</span> (!Objects.equals(currentValue, logEntry.getExpectedValue())) {
            lostKeys.add(logEntry.getKey());
        }
    }
    <span>return</span> lostKeys; <span>// 结合业务补偿机制,对这些 key 做人工核对或重放修复</span>
}

五、常见误区

  • 认为配置了Sentinel实现高可用,就意味着数据绝对不会有问题——高可用(服务不中断)和数据一致性是两个不同维度的目标,Sentinel保证的是故障时能自动完成主从切换、让服务尽快恢复可用,但并不能完全避免脑裂期间双主并存导致的数据丢失和冲突风险,这需要额外通过min-replicas-to-write这类参数配合,并且要清楚认识到——即使做了这些配置,脑裂造成的数据丢失风险也只是被降低而非完全消除,对数据一致性要求极高的场景,还需要考虑更强一致性保证的方案。
  • 认为 min-replicas-to-write 能保证"每一次写入都已同步给从节点"——如前所述,这个参数是持续性的健康检查,而不是逐条写入的同步确认,对真正需要单次写入强确认的关键操作,需要额外配合 WAIT 命令。
  • 把 Sentinel 节点和 Redis 主节点部署在同一个机房、同一个网络域——一旦发生机房级别的网络故障,Sentinel 和主节点可能同时受影响,自动切换机制本身也会失效,多可用区部署是更稳妥的做法。
  • down-after-milliseconds 等参数设置不合理——设置过短会导致正常的网络抖动也触发不必要的主从切换,反而降低系统稳定性;设置过长则会拉长脑裂窗口期,需要结合实际网络环境的抖动特征谨慎调优,而不是直接套用默认值或抄网上的经验值。
  • 只做故障后复盘,不做故障演练——脑裂这类问题在正常运行时很难被观察到,建议通过人为制造网络分区(如使用 iptables/tc 或专门的混沌工程工具)定期演练,验证 min-replicas-to-write、Sentinel 参数等配置在真实故障场景下是否达到了预期效果。

正确的核心认知:Redis 的高可用方案(Sentinel / Cluster)在设计取向上优先保证可用性,脑裂是网络分区场景下这一取向必然带来的副作用,min-replicas-to-writeWAIT 等手段只能降低数据丢失的概率和范围,无法从根本上消除。对数据一致性要求极高的场景,需要在架构层面(多可用区、共识协议存储)和业务层面(幂等、补偿机制)做双重兜底,而不是仅仅依赖 Redis 自身的配置参数。