复习Redis到集群这一块时,多数人会把精力放在哨兵怎么选主、集群槽位怎么分配这些"常规操作"上,但真要问线上出过事的人,大概率都会给你同一个提醒:先去看脑裂问题。我对脑裂的理解一开始也很浅,总觉得不就是"网络分区导致出现两个主节点"嘛,后来自己在测试环境复现过一次,又翻过事故报告,才意识到这个问题的杀伤力远不止"多了一个主节点"这么简单。它真正危险的地方在于:故障转移机制本身很正常,甚至可以说执行得很完美,但恰恰是这份"完美",让系统进入了一个表面上没问题、实际上数据已经开始出错的状态。
这篇内容适合正在系统复习Redis的人,尤其是准备面试或者即将接手生产环境维护的同学。文章中会把脑裂从现象、原理、危害到防御手段都过一遍,会涉及哨兵模式和集群模式两种场景,也会专门讲分布式锁和脑裂叠加时最容易被忽视的坑。读完之后,你应该能回答这几个问题:脑裂是怎么产生的?它到底会造成什么后果?配置层面能做什么?业务层面又该怎么兜底?
1. 脑裂到底是什么:一次"双主同时可写"的故障现场
1.1 一个让人意外的场景
先看一个最常见的脑裂现场假设。
有一组Redis主从架构,1个主节点,2个从节点,再配3个哨兵节点。主节点放在机房A,两个从节点和哨兵放在机房B。某天机房A和机房B之间的专线出现了抖动,但抖动只影响了Redis节点之间的心跳通信,没有影响机房A内部的客户端到主节点的连接。
此时会发生什么?机房B这边的哨兵发现机房A的主节点失联了,按照规则判定主节点下线,然后从两个从节点中选出一个,提升为新的主节点。新主节点诞生后,机房B的客户端开始往新主节点写入数据。
但请注意,机房A里的主节点并没有宕机,它还在正常接收机房A内客户端的写入,并且由于网络分区,它根本不知道自己已经被哨兵"判了死刑"。这时候系统里其实有两个主节点,一个在机房A,一个在机房B,两边都在接收写入。
这就是脑裂。而且这里有个很容易被忽略的点:脑裂不是从"两个主节点同时出现"那一刻才开始的,而是从网络分区发生的那一刻就已经埋下了,危险窗口会一直持续到网络恢复、老主节点被降级为从节点位置为止。
1.2 为什么故障转移成功的瞬间,反而是事故的开始
很多人会困惑一个问题:Redis有哨兵盯着,主节点挂了会自动切换,为什么切换成功了反而出问题?
关键在于"切换成功"是针对哨兵视角和客户端视角而言的,但老主节点自己的视角里,"切换"这件事根本不存在。它只是发现自己和从节点、哨兵暂时联系不上了,而本地服务还在正常运行。老主节点每秒都在处理写入,它还会继续往自己的AOF文件里追加数据,甚至可能因为重连不上从节点而产生复制积压缓存。
等到网络恢复,老主节点重新连接上哨兵和新主节点时,它才发现自己已经变成"旧主"了。这时候Redis的处理逻辑是:让老主节点降级为从节点,然后向新主节点发起全量重同步。全量同步意味着什么?意味着老主节点本地的数据会被清空,然后完整复制新主节点的数据。
也就是说,从网络分区发生,到老主节点降级同步这段窗口内,所有写入老主节点的数据,全部丢失。
你可能会问,难道不能让老主节点把多出来的数据同步给新主节点吗?问题在于Redis主从复制方向是单向的,从节点只能向主节点同步数据,而老主已经变成从节点,它的数据只会被覆盖,不会上传。再加上老主和从之间可能出现数据冲突,Redis没有解决冲突的机制,只能选择"以新主为准,全量覆盖"。
2. 从网络分区到双主形成:完整时间线拆解
脑裂不是一个瞬间事件,而是多个机制先后触发后叠加出来的结果。要真正理解它,得把故障转移的完整时间线拉出来看。
2.1 哨兵模式下的客观下线与主从切换
哨兵模式下,三个概念必须分清:主观下线、客观下线、故障转移。
主观下线(sdown):每个哨兵节点会定期向主从节点发送PING,如果超过down-after-milliseconds配置的时间没收到回复,这个哨兵会自己标记该节点为主观下线。注意,这只是单个哨兵的判断,不代表节点真的挂了。
客观下线(odnow):当多个哨兵都认为主节点主观下线,并且数量达到了配置的quorum值时,主节点才会被标记为客观下线。这一步很关键,它是从"某个哨兵觉得不对劲"升级到"哨兵集群集体认定主节点不可用"。
客观下线确认后,哨兵集群内部会通过Raft协议选举出一个leader哨兵,由它来执行故障转移。故障转移的具体动作包括:从从节点中选出一个作为新主节点,执行slaveof no one让它升级,然后通知其他从节点去复制新主节点,最后通过发布订阅机制通知客户端主节点地址已经变更。
整个流程设计得相当成熟,但问题出在:这套流程的判定依据是"哨兵和主节点之间的心跳",而不是"主节点本身是否健康"。只要心跳断了,哪怕主节点服务正常,也会被判定下线并触发切换。
这就是哨兵模式下脑裂产生的根源:哨兵认为主节点死了,但主节点活得好好的,只是暂时联系不上而已。
2.2 集群模式下Fail状态与从节点接管
Redis Cluster集群模式下的脑裂机制和哨兵类似,但细节略有不同。
集群模式中,每个主节点都会和其他节点通过Gossip协议交换状态信息,互相用PING/PONG保持心跳。当某个主节点A持续无法和主节点B通信,并且超过cluster-node-timeout(默认15秒)后,A会标记B为疑似故障状态。如果集群中超过半数的主节点都标记B为疑似故障,B就会被标记为FAIL状态。
B被标记为FAIL之后,B的从节点会发起选举。选举通过后,从节点会执行cluster failover相关逻辑成为新的主节点,接管B负责的槽位。
这里有个和哨兵模式不太一样的地方:集群模式中,节点被标记为FAIL之后,可能需要经过一段时间才会被集群彻底遗忘,而老主节点恢复后,即使发现自己已经被新主替换,也会尝试重新建立主从关系。但由于槽位已经被新主接管,老主节点的数据会同样面临全量覆盖的问题。
无论哨兵还是集群,本质逻辑都一样:节点之间靠心跳通信,网络分区切断了心跳,却切断不了老主节点的工作状态。双主的诞生,从机制上来说是"无法避免"的,只能靠防御手段缩小它的影响。
2.3 分区恢复后老主的处境:降级与全量同步
把时间线再往后推,看看网络分区恢复之后发生了什么。
我整理了一张时间线表,方便对照理解:
| 时间点 | 事件 | 系统状态 |
|---|---|---|
| T0 | 主节点M与哨兵、从节点断连 | 分区发生,M仍在接收客户端写入 |
| T1 | 单个哨兵标记M主观下线 | 客户端无感知 |
| T2 | 达到quorum,M被判定客观下线 | 客户端无感知 |
| T3 | 哨兵提升S1为新主,客户端切流 | 系统出现双主:M和S1同时可写 |
| T4 | 网络恢复正常,M与哨兵重连 | 双主窗口仍然存在 |
| T5 | M被降级为从节点,向S1发起全量同步 | M本地数据被清空重写,窗口期写入丢失 |
T3到T5之间这段窗口,就是我前面反复强调的"危险窗口"。窗口越长,丢失的数据越多。有些方案会在检测到老主节点重新连接时,立刻执行降级并尽量缩短窗口,但由于主从复制是全量覆盖,窗口内写入的数据仍然救不回来。
3. 脑裂的真正代价:数据丢失只是最轻的一层
3.1 直接杀伤:切换窗口内的写数据被覆盖
先看最直观的危害,数据丢失。
假设有一个订单系统,订单数据先写数据库,再写Redis缓存。脑裂窗口内,机房A的客户端写入了一批订单数据到老主节点,这些数据还没有来得及同步给从节点,网络就断了。分区恢复后,老主节点被降级,向新主节点做全量同步,本地数据直接清空覆盖。
后果是什么?这批订单数据从Redis里消失了。如果业务只是缓存,那问题还相对小一点,缓存重建后可以从数据库恢复;但如果业务把Redis当作了准实时存储,或者在半同步场景下依赖Redis做计数、限流、秒杀扣减,那丢失的数据可能就是直接的经济损失。
更麻烦的是,脑裂导致的不只是"少了一部分数据",还可能是"数据逻辑错乱"。假设老主节点在窗口期内写入了一个key,新主节点在同名key上写入的是另一个值,两个节点之间无法自动合并。全量同步完成后,老主节点上的值被覆盖,但如果这个值对应的业务操作已经发生了(比如已经发货了),它不会因为Redis数据被覆盖而回滚。
3.2 隐蔽杀伤:分布式锁的双主写入
如果说数据丢失是"明伤",那分布式锁失效就是"暗伤",而且往往是更致命的那一个。
我们知道Redis分布式锁的标准实现是SET key value NX EX seconds,利用单主节点的特性保证互斥:同一时间只有一个客户端能成功写入这个key,其他客户端拿不到锁。这套逻辑的前提是:整个Redis系统只有一个主节点,所有客户端都往这个主节点上写锁。
但脑裂把这个前提直接掀翻了。
一个典型的分布式锁脑裂事故流程:
- 客户端A在Redis主节点M上执行
SET lock order:1001 tokenA NX EX 30,成功拿到锁,开始处理订单支付逻辑。 - 网络分区发生,M被哨兵判定下线,从节点S1被提升为新主节点S1'。
- 由于主从复制是异步的,M上的锁数据还没来得及同步到S1,锁在S1'上根本不存在。
- 客户端B连接到新的S1',执行同样的
SET lock order:1001 tokenB NX EX 30,成功拿到锁。 - 客户端A和客户端B同时处理同一个订单,分布式锁完全失效。
你是不是觉得这个场景很眼熟?很多公司把Redis分布式锁当作并发安全的第一道防线,但它恰恰是最容易被脑裂击穿的防线。面试里如果你能主动说出这个场景,并且讲明白为什么SET NX EX在脑裂下会失效,面试官对你的评价会明显不一样。
3.3 底层原因:Redis主从复制的异步本质
要理解为什么分布式锁会失效、数据为什么会丢,必须回到主从复制的本质上。
Redis默认采用异步复制,也就是当主节点收到一条写命令,它会先执行并返回客户端成功,然后再把这条命令异步地传播给从节点。换句话说,主节点给客户端的"写入成功"确认,不等于数据已经落到了从节点上。
这种设计的优点是性能好、延迟低,缺点是主节点一旦故障,未同步到从节点的数据就丢了。同步复制可以避免这个问题(主节点等从节点ACK后才返回),但代价是每次写入都要多一次网络往返,性能和可用性都会下降。
Redis提供了WAIT命令,可以在写入后主动等待指定数量的从节点确认,但这并不能从根本上解决脑裂。原因很简单:脑裂发生时,老主节点和从节点之间根本联系不上,WAIT会一直等不到ACK,而客户端通常不会无限期等下去,最终还是会超时返回。更关键的是,老主节点在分区期间能不能写入,本质上取决于它自己是否知道"我已经不是主了"——而它恰恰不知道。
4. 官方标准的防御手段:min-slaves参数防脑裂
既然脑裂无法从机制上消灭,那就想办法缩小它的破坏范围。Redis官方给出的方案很直接:让"被孤立的主节点"写不了数据。
4.1 min-slaves-to-write与min-slaves-max-lag的配合逻辑
Redis提供了两个配置参数组合使用,专门用来应对脑裂场景:
min-slaves-to-write:主节点能执行写操作的最少从节点数量。如果当前连接到主节点的从节点数量小于这个值,主节点会拒绝写入。
min-slaves-max-lag:从节点的最大同步延迟秒数。如果主节点发现任何一个从节点的ACK延迟超过了这个值,同样拒绝写入。
这两个参数必须同时理解才有效。脑裂发生时,老主节点被孤立,它和所有从节点的连接都断了。此时从节点数量变成0(低于min-slaves-to-write),同步延迟无限大(超过min-slaves-max-lag),老主节点就会直接拒绝客户端的写请求。
你可能会想:这不就相当于"网络分区时牺牲可用性,保证一致性"吗?没错,这正是在CAP里选了AP还是CP的问题。Redis默认是AP,优先保证可用性;打开min-slaves参数后,在极端情况下会主动放弃写入,换取数据安全。
4.2 配置案例与动态调整
具体配置可以参考下面这个例子:
# redis.conf min-slaves-to-write 1 min-slaves-max-lag 10含义是:至少要有1个从节点在线,且从节点的同步延迟不能超过10秒,否则主节点拒绝写入。
如果主节点被孤立,它连着的从节点数为0,写入就会被拒绝,客户端会收到类似-MISCONF Redis is configured to save RDB snapshots, but it's currently unable to persist on disk这种风格的错误——准确说是-MASTERDOWN或错误提示,实际Redis返回的是-NOMASTERLINK之类的和主从链路相关的错误。生产环境部署时,建议先在测试环境复现一次,确认客户端能正确处理这些写入报错。
这两个参数支持动态调整,不用重启Redis:
redis-cli> CONFIG SET min-slaves-to-write 1 redis-cli> CONFIG SET min-slaves-max-lag 10动态调整的意义在于,你可以把切换脚本设计得更灵活。比如在计划内维护时,可以临时把min-slaves-to-write改成0,允许短暂无从节点状态下写入,避免维护窗口影响业务;维护结束后再改回来。
不过我得提醒一句:动态调整有风险,曾经就有团队在做机房迁移时把min-slaves-to-write改成0,结果迁移过程中恰好发生网络波动,脑裂窗口期内老主节点照常写入,直接丢了一批数据。所以我个人的习惯是:这个参数保持常开,除非有极强的理由,否则不要为了"方便"把它关掉。
4.3 这套方案能防到什么程度
说个公道话:min-slaves参数是防脑裂的有效手段,但不是万能手段,它解决的是"大部分脑裂写入"问题。
它能防住的场景:老主节点被网络分区彻底孤立,和所有从节点失联。这种情况下从节点数为0,写入被拒,脑裂的破坏力被压到最低。
它防不住的场景:网络分区比较"奇怪",比如老主节点能和一部分从节点保持通信,但和另一部分从节点和哨兵失联。假设你有3个从节点,其中1个和老主仍在一个分区内,此时min-slaves-to-write 1仍然满足,老主节点的写入不会被拒绝,但哨兵那边却可能因为另外两个从节点和所有哨兵失联而判定主节点下线,触发切换——双主还是会短暂出现。
所以,min-slaves参数能极大降低脑裂发生的概率和危害,但如果你面对的是高一致性要求的场景,光靠它还不够,还得在应用层做兜底。
5. 分布式锁场景的加固方案:RedLock与兜底思维
5.1 RedLock的多数派加锁思路
分布式锁在脑裂面前这么脆弱,Redis作者Antirez也考虑过这个问题,于是提出了RedLock算法。
简单说,RedLock的思路是把锁从"放在一个Redis节点上"改成"放在多个互相独立的Redis节点上"。官方推荐部署5个独立的Redis节点(不互相复制,彼此完全独立)。客户端加锁时,依次向这5个节点执行SET key value NX EX,只有当成功写入的节点数超过总数的一半(比如5个中有3个成功),才认为加锁成功。
这样设计的好处很明显:就算某个主节点发生脑裂,锁数据丢失,只要有超过半数的节点上锁还在,其他客户端就无法获得多数派锁,互斥性在一定程度上得到了保证。脑裂导致单点锁失效的问题,被"多数派"思想稀释掉了。
RedLock的加锁流程大致如下:
# 伪代码,展示RedLock的核心逻辑 import redis import time nodes = [ redis.Redis(host="node1", port=6379), redis.Redis(host="node2", port=6379), redis.Redis(host="node3", port=6379), redis.Redis(host="node4", port=6379), redis.Redis(host="node5", port=6379), ] def acquire_lock(lock_key, lock_value, ttl): start = time.time() success_count = 0 for node in nodes: try: if node.set(lock_key, lock_value, nx=True, ex=ttl): success_count += 1 except Exception as e: # 单个节点异常不阻塞整体流程 pass elapsed = time.time() - start # 要求成功节点数过半,且耗时小于锁的过期时间 if success_count >= 3 and elapsed < ttl: return True # 加锁失败,释放已取得的锁 for node in nodes: node.delete(lock_key) return False这个逻辑本身不复杂,但要注意几个实现细节:必须给所有节点设置相同的key和value;必须记录加锁耗时,防止因为某个节点网络超时导致整个加锁过程超过了锁的TTL;加锁失败后必须清理已经成功写入的锁,避免产生孤儿锁。
5.2 RedLock的争议与适用边界
RedLock并不是银弹,围绕它的争议在业界一直没停过。
最有名的论战来自Martin Kleppmann(《Designing Data-Intensive Applications》的作者)和Antirez两人。Martin的核心观点是:分布式锁的正确性不能只依赖"多数派节点加锁成功",因为锁的持有者可能因为GC暂停、网络延迟等原因,实际执行时间超过了锁的TTL,锁已经过期被其他客户端拿到,而原持有者还在继续操作。这种场景下,不管是不是RedLock,都拦不住"两个客户端同时干活"。
Martin提出的替代方案是fencing token(隔离令牌),也就是在锁里带一个单调递增的版本号,每次操作前校验版本号,旧版本的请求直接拒绝。这个思路其实比单纯讨论锁的互斥性更接近问题的本质。
Antirez的反驳文章也很精彩,他强调RedLock的目标定位是"在Redis这种没有fencing机制的系统中,尽量提供更可靠的互斥",并且认为正确使用RedLock的场景下,出问题的概率可以压到极低。
作为复习我们需要记住的结论是:RedLock比单节点锁更可靠,但它增加的不只是部署复杂度(你需要5个独立实例),还有性能开销(每次加锁要5次网络往返)。如果你的业务是电商秒杀这种极度追求性能的场景,RedLock可能并不合适,真正合适的方案反而可能是数据库乐观锁 + 业务幂等,让Redis锁只承担"削峰"职责,不承担"最终一致性"职责。
5.3 业务层兜底:幂等、版本号与唯一约束
不管用不用RedLock,我都建议在业务层做兜底设计,这是最稳妥的做法。
第一个兜底是幂等。比如订单支付回调这个场景,如果Redis锁失效导致两个客户端同时执行支付逻辑,只要支付接口本身是幂等的(同一个order_id重复支付,只处理一次),那锁失效的后果就被控制住了。实现方式可以是数据库订单状态字段加乐观锁(UPDATE orders SET status='paid' WHERE order_id=? AND status='unpaid'),或者支付流水表加唯一约束。
第二个兜底是版本号/fencing token。拿锁时给锁设置一个递增的版本号,比如用Redis的INCR生成一个全局递增的数字,或者直接用当前时间戳加随机数。执行业务操作时把这个版本号带上,真正操作数据库时校验版本号,如果版本号已经过期,说明锁已经被别人拿走了,主动放弃操作。
第三个兜底是缩短锁的TTL,并引入续期机制。TTL时间越短,双主窗口内锁失效的概率越低。你可以写一个后台守护线程,在锁快过期时自动续期,这样即使发生脑裂,锁过期的速度也更快,两个客户端同时持锁的窗口被压缩到最小。
我在实际项目里常用的是"Redis锁 + 数据库乐观锁"组合,Redis锁负责拦住大多数并发请求,数据库乐观锁负责兜底极端情况,效果比单纯依赖某一种方案稳定很多。
6. 一次脑裂事故的排查复盘:从监控告警到根因确认
最后分享一次我实际参与排查的脑裂事故复盘过程。事故发生在凌晨,业务方早上发现订单缓存数据异常,随后运维群里炸了锅。
6.1 第一步:确认双主窗口的存在
拿到问题的第一时间,先别急着翻日志,先确认当前Redis节点的角色状态。
# 检查主从状态 redis-cli -p 6379 info replication输出的关键字段是role和master_link_status。正常情况下,一个Redis集群里只有一个role:master。如果执行后发现有两个节点同时显示role:master,或者有一个节点的master_link_status:down,那脑裂基本可以实锤了。
我们当时在主节点所在的机器上执行这条命令,发现它的role虽然显示slave(说明已经降级),但master_link_status:down,而且master_host指向的地址是另一台机器——很明显,这个节点经历过一次"从主到从"的角色变更,而且变更后连接还没有完全恢复。
6.2 第二步:用日志重建切换时间线
确认了角色异常后,第二步是收集日志,把所有和主从切换相关的线索对齐到同一时间轴上。
Redis的日志会记录关键事件,比如:
Connection with master lost:从节点和主节点断开连接MASTER MODE enabled:节点被提升为主节点SLAVE OF 192.168.x.x:6379 enabled:节点开始复制新主Full resync:触发了全量重同步
哨兵的日志也很重要,/var/log/redis/sentinel.log里能查到主节点被判下线的时间点、执行故障转移的时间点、以及新主选举的结果。
把Redis日志、哨兵日志和应用日志放在一张时间轴上对齐,我们很快还原出了完整的事件线:凌晨2点03分网络发生抖动,2点04分哨兵判定主节点客观下线,2点05秒完成切换,新主节点开始对外服务,2点07分网络恢复,老主节点降级并触发全量同步。
6.3 第三步:根据证据链定位根因并定预防方案
时间线还原后,根因已经很清晰了:网络分区导致哨兵误判主节点下线,切换过程中老主节点继续接收写入,分区恢复后数据被覆盖。
接着我们检查了当时的redis.conf,发现min-slaves-to-write和min-slaves-max-lag根本没有配置。也就是说,系统在脑裂窗口期内没有任何防御手段,老主节点照单全收所有写入请求。
这个环节我特意说一句:很多团队会花大力气做Redis监控,但监控告警本身只能告诉你有问题,不能帮你挡住问题。真正起作用的,还是配置层面的防御策略。
事故处理完毕后,我们做了几件事:
- 给所有Redis主从架构加上
min-slaves-to-write 1和min-slaves-max-lag 10。 - 客户端增加写入失败的重试和降级逻辑,遇到
MISCONF相关错误时切换备用数据源。 - 针对分布式锁场景,额外增加了数据库乐观锁兜底。
- 网络层面优化了跨机房的专线冗余,尽量避免同类问题再次发生。
6.4 复习提示:常见误区与自测清单
最后把复习时容易踩的误区汇总一下,你可以拿来自测:
第一个误区:以为Redis集群永远不会有两个主节点。事实是网络分区下,新老主可以同时存在,而且这恰恰是脑裂的核心。
第二个误区:以为主从复制是同步的,主节点返回成功就代表数据安全。事实是Redis默认异步复制,从节点ACK确认前数据都可能丢。
第三个误区:以为SET NX EX分布式锁可以完全保证互斥。事实是锁的底层存储一旦出现脑裂,互斥性就会被突破,必须有业务层兜底。
第四个误区:以为min-slaves参数能100%防止脑裂。事实是它能大幅缩小脑裂窗口期的写入量,但无法根治脑裂本身,部署和业务设计仍需做冗余。
我个人在实际排查中的体会是:脑裂问题最难的地方不在于"怎么解决",而在于"你得先意识到它的存在"。很多故障排查久了才发现,根本不是代码逻辑的锅,而是底层的复制架构在某一次网络抖动中悄悄埋了一颗雷。复习Redis集群时,把脑裂放到和主从复制、哨兵原理同等重要的位置,遇到相关问题时你会感谢现在的自己。