多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Redis 脑裂故障深度解析,双 Master 写入后的数据一致性修复指南

Redis 脑裂故障深度解析,双 Master 写入后的数据一致性修复指南 凌晨三点的红色警报当 Redis Cluster 遭遇“双 Master危机对于运维工程师和 DBA 来说凌晨三点的告警电话往往意味着一场硬仗的开始。在那次事故中监控大屏上的订单服务 P99 延迟曲线从平稳的 50ms 瞬间拉升至 5s紧接着是密集的 Redis 连接超时报警。更令人费解的是应用日志中开始大量涌现READONLY You cant write against a read only replica的错误但与此同时业务端却报告部分写入成功部分写入失败甚至出现了重复扣款的风险提示。经过初步排查一个极端的分布式系统故障浮出水面Redis Cluster 脑裂Split-Brain。在跨机房的网络链路因 STP生成树协议重计算而短暂中断的几十秒内哨兵集群误判主节点下线触发了故障转移导致原主节点和新选举出的主节点同时认为自己是集群的唯一领导者。于是两个 Master 节点在隔离的网络分区内并行接收写入请求数据一致性瞬间崩塌。这次事故不仅造成了约 48 分钟的服务抖动更留下了棘手的数据分歧难题。本文将复盘此次故障的全流程重点剖析如何利用工具确认分裂状态深入对比三种数据修复方案的优劣并详细拆解基于 MySQL Binlog 回放的一致性修复实战最后总结针对此类网络分区场景的预防策略。网络分区下的拓扑迷雾如何确认脑裂状态当怀疑发生脑裂时首要任务不是急于重启或切换而是准确掌握集群当前的“分裂视图”。在网络分区期间不同分区内的节点对集群拓扑的认知是完全割裂的。我们需要像法医一样收集各节点的现场证据还原故障发生时的真实状态。最直接的诊断工具是redis-cli。我们需要登录到集群中的每一个节点包括疑似的旧 Master、新 Master 以及各个 Slave 节点执行CLUSTER NODES命令。在正常状态下所有节点输出的拓扑信息应当是一致的每个节点都能正确识别其他节点的角色和状态。但在脑裂场景下你会看到截然不同的结果分区 A原主机房旧 Master 节点仍然标记自己为master且认为其他分区的节点处于fail状态。它继续接受客户端的写入请求因为它认为自己仍是合法的领导者。分区 B备机房哨兵集群由于检测不到旧 Master 的心跳超过down-after-milliseconds阈值判定其主观下线sdown进而客观下线odown并选举出了一个新的 Master。在这个分区的节点视角里新 Master 是合法的而旧 Master 已经下线。为了系统化地收集这些信息我们可以编写一个简单的 Shell 脚本遍历所有节点并保存关键状态快照。以下是一个用于事故现场信息收集的脚本示例它能帮助我们在混乱中理清头绪#!/bin/bash # Redis Cluster 脑裂诊断脚本 # 用于事故后现场信息收集避免手动逐个登录的遗漏 REDIS_CLIredis-cli OUTPUT_DIR/tmp/redis-splitbrain-$(date %Y%m%d_%H%M%S) mkdir -p $OUTPUT_DIR # 节点列表 (按实际环境 IP 填写) NODES( 10.0.1.1:6379 10.0.1.2:6379 10.0.1.3:6379 10.0.2.1:6379 10.0.2.2:6379 10.0.2.3:6379 ) echo 开始诊断信息收集 $(date) for node in ${NODES[]}; do host${node%:*} port${node#*:} node_id${host}_${port} echo --- 收集节点$host:$port --- # 1. 集群拓扑视角核心证据查看各节点眼中的集群状态 $REDIS_CLI -h $host -p $port \ --cluster-timeout 5000 \ cluster nodes $OUTPUT_DIR/${node_id}_nodes.txt 21 # 2. 集群基本信息查看 cluster_state 是否为 ok $REDIS_CLI -h $host -p $port \ cluster info $OUTPUT_DIR/${node_id}_info.txt 21 # 3. 角色信息确认当前节点自认为的角色 $REDIS_CLI -h $host -p $port \ role $OUTPUT_DIR/${node_id}_role.txt 21 # 4. 复制偏移量判断数据分歧程度的关键指标 $REDIS_CLI -h $host -p $port \ info replication | grep -E master_repl_offset|slave_repl_offset \ $OUTPUT_DIR/${node_id}_replication.txt 21 # 5. 键数量差异快速评估数据丢失量 $REDIS_CLI -h $host -p $port \ dbsize $OUTPUT_DIR/${node_id}_dbsize.txt 21 echo 收集完成 done # 6. 哨兵日志收集定位故障转移的确切时间点 echo --- 收集哨兵日志 --- SENTINELS(10.0.1.5:26379 10.0.2.5:26379) for sentinel in ${SENTINELS[]}; do host${sentinel%:*} $REDIS_CLI -h $host -p 26379 \ sentinel masters $OUTPUT_DIR/sentinel_${host}_masters.txt 21 done echo 诊断信息已保存到$OUTPUT_DIR 通过对比上述脚本生成的nodes.txt文件我们可以清晰地看到两个分区各自维护了一套独立的 Master 列表。此外检查info replication中的master_repl_offset至关重要。在脑裂期间两个 Master 的复制偏移量会各自独立增长不再同步。这两个偏移量的差值直接反映了两个分区在隔离期间产生的数据分歧量。如果旧 Master 的偏移量比新 Master 高出数万说明在 network partition 期间大量写请求被旧 Master 接收了而这些数据在新集群中是完全缺失的。数据修复的三重抉择回滚、合并还是回放确认了脑裂状态和数据分歧后最艰难的决策随之而来如何修复数据此时集群中存在着两份“真相”每一份都包含了一部分正确的业务数据同时也可能包含冲突的写入。处理不当可能导致资金损失、订单丢失或用户数据错乱。通常我们有三种主要的修复路径每种都有其适用场景和代价。方案一全量回滚Full Rollback这是最保守也是最“安全”的方案。直接放弃脑裂期间产生的所有数据变更将集群恢复到脑裂发生前的快照状态RDB 或 AOF 快照。优点操作简单逻辑清晰绝对保证数据的一致性虽然是一致地回到过去。缺点数据丢失量巨大。在本案中脑裂持续了约 48 分钟期间产生了数千笔订单和支付记录。如果选择回滚意味着这 48 分钟内所有用户的操作全部作废这将引发严重的客诉甚至法律风险。适用场景仅适用于测试环境或者业务对数据实时性要求极低、且能容忍大规模数据重放的场景。对于核心的交易链路此方案通常是不可接受的。方案二基于时间戳的合并Timestamp-based Merge尝试保留两边的数据对于相同的 Key比较其修改时间戳保留最新版本的值。优点理论上能保留最多的数据减少人工介入。缺点实现极其复杂且风险高。Redis 原生并不支持这种复杂的合并逻辑。更致命的是“最新”不代表“正确”。在分布式系统中业务逻辑往往涉及多个 Key 的联动例如扣减库存 Key、增加订单 Key、更新用户余额 Key。如果只简单保留某个 Key 的最新值可能会破坏业务逻辑的原子性。例如订单生成了但库存没扣减或者余额扣了但订单没生成。这种逻辑上的不一致比数据丢失更可怕。适用场景适用于非核心业务、配置类数据或对最终一致性要求不高、且业务逻辑简单的场景。方案三基于业务日志的回放Binlog Replay这是本次事故中我们最终选择的方案。既然 Redis 中的数据已经混乱我们就绕过 Redis直接从业务的“单一事实来源”——MySQL 数据库中寻找真相。通过解析 MySQL 的 Binlog二进制日志提取脑裂期间所有成功的业务写入操作然后在修复后的 Redis 集群上重新执行这些操作。优点精度最高完全符合业务逻辑。MySQL 作为持久化存储通常在事务层面保证了数据的原子性和一致性即使 Redis 挂了MySQL 的事务提交也是可靠的。通过回放 Binlog我们可以确保 Redis 中的数据状态与 MySQL 严格一致。缺点实施难度大需要开发专门的解析和回放工具且耗时较长。必须确保 MySQL 在脑裂期间没有受到同样的影响通常 MySQL 的主从切换机制比 Redis Sentinel 更稳健或者有更强的一致性保障。适用场景核心交易链路、金融支付场景、对数据准确性要求极高的系统。在订单和支付场景下方案三是唯一可行的选择。因为每一笔订单的背后都对应着资金的流转任何简单的覆盖或丢弃都可能造成资损。我们必须以 MySQL 中已提交的事务为准绳重建缓存层的状态。实战演练基于 MySQL Binlog 的数据一致性修复确定了采用 Binlog 回放方案后修复工作进入了精细化的操作阶段。这一过程并非简单的“拷贝粘贴”而是一场需要严密逻辑控制的“数据手术”。1. 确定时间窗口与提取 Binlog首先我们需要精确界定脑裂的时间窗口。通过前文收集的哨兵日志我们定位到故障转移开始于03:17:42网络恢复且集群稳定于04:05:10。因此我们需要提取 MySQL 主库在该时间段内的所有 Binlog。使用mysqlbinlog工具我们可以将二进制日志转换为可读的 SQL 语句mysqlbinlog \ --start-datetime2023-10-27 03:17:42 \ --stop-datetime2023-10-27 04:05:10 \ /var/lib/mysql/mysql-bin.000123 \ /var/lib/mysql/mysql-bin.000124 \ /tmp/brain_split_events.sql这一步生成的 SQL 文件包含了所有INSERT、UPDATE和DELETE操作。但直接使用它是危险的因为其中可能包含非业务相关的系统操作或者在脑裂期间因超时而实际上并未在应用层成功的“脏数据”。2. 过滤与清洗只保留“成功”的业务我们需要编写脚本对 SQL 进行过滤。核心逻辑是只重放那些在应用层确认成功的业务操作。 在实际操作中我们通常结合应用日志Access Log 或 Business Log来辅助判断。如果一条数据库变更记录在应用日志中没有对应的“成功响应”那么它可能是脑裂期间被超时重试机制重复执行的或者是孤立的事务。更稳妥的做法是不直接回放 SQL而是解析 Binlog 中的行事件Row Events提取出受影响的主键 ID如order_id然后调用业务系统的内部接口或直接查询 MySQL 获取该订单的最新完整状态再将其序列化后写入 Redis。这种方式虽然性能较低但逻辑最安全因为它利用了业务代码中已有的组装逻辑避免了直接操作底层数据结构可能带来的格式错误。假设我们采用直接构建 Redis 命令的方式适用于数据结构简单的场景修复脚本的逻辑大致如下# 伪代码基于 Binlog 解析的修复逻辑 import redis import mysql.connector # 连接修复后的 Redis 集群此时已只有一个合法的 Master r_cluster redis.RedisCluster(host10.0.2.1, port6379) # 连接 MySQL 获取权威数据 db_conn mysql.connector.connect(hostmysql-master, userrepl_user, password***) def repair_order(order_id): # 1. 从 MySQL 读取权威数据 cursor db_conn.cursor(dictionaryTrue) cursor.execute(SELECT * FROM orders WHERE id %s, (order_id,)) order_data cursor.fetchone() if not order_data: return # 订单已被删除无需修复 # 2. 构建 Redis Key 和 Value key forder:detail:{order_id} value json.dumps(order_data) # 3. 写入 Redis覆盖脑裂期间的脏数据 # 注意这里可能需要根据业务设置合理的 TTL r_cluster.setex(key, 3600, value) print(fFixed order: {order_id}) # 遍历从 Binlog 解析出的受影响 Order ID 列表 affected_order_ids parse_binlog_for_order_ids(/tmp/brain_split_events.sql) for oid in affected_order_ids: try: repair_order(oid) except Exception as e: logging.error(fFailed to fix {oid}: {e})3. 验证与观察回放完成后不能立即宣布胜利。必须进行严格的验证抽样比对随机抽取修复后的 Redis 数据与 MySQL 数据进行字段级比对确保完全一致。业务验证让测试人员在生产环境灰度尝试查询修复期间的订单确认页面展示正常状态流转无误。监控观察密切关注后续几小时的错误率和延迟指标确保修复操作本身没有引入新的性能瓶颈。在本次事故中通过这种“以 MySQL 为准重构 Redis的方式我们成功恢复了约 400 个冲突键和数千个单边写入键的数据一致性确保了无一例资金差错。亡羊补牢Sentinel 调优与多可用区部署策略事故的修复只是第一步更重要的是如何防止同类问题再次发生。Redis Cluster 脑裂的本质是网络分区与故障检测机制之间的博弈。在跨机房部署的场景下网络波动是不可避免的因此我们的防御策略必须从“避免网络分区”转向“适应网络分区”和“快速收敛”。1. 优化 Sentinel 超时参数默认的 Sentinel 配置往往过于敏感。在跨机房场景中网络抖动可能导致短暂的心跳丢失。如果down-after-milliseconds设置过短如默认的 30 秒一次轻微的网络波动就可能触发不必要的故障转移进而引发脑裂。调优建议适当增大down-after-milliseconds。例如将其从 30000ms 调整为 60000ms 甚至更高给网络恢复留出缓冲时间。同时调整quorum法定人数确保只有在多数哨兵节点都确认故障时才触发切换避免单点误判。注意增大超时时间会延长真实故障的恢复时间RTO这是一个权衡。需要根据业务对可用性和一致性的具体诉求来设定。对于强一致性要求的场景宁可容忍短暂的不可用也要避免脑裂带来的数据污染。2. 多可用区Multi-AZ部署与拓扑感知将 Redis 节点均匀分布在不同的可用区Availability Zone或机房是基础但还不够。哨兵部署策略哨兵节点本身也必须跨可用区部署且数量应为奇数如 3 或 5并尽量分散在不同机房。这样可以避免某个机房网络隔离时该机房内的哨兵集体“失联”导致误判。客户端拓扑感知现代 Redis 客户端应支持拓扑感知。当检测到某个分区不可达时客户端应能智能地将请求路由到存活的分区而不是盲目重试导致请求堆积在错误的节点上。3. 引入网络层面的监控与熔断不要完全依赖应用层的 Redis 心跳来检测网络状态。应在基础设施层面建立网络质量监控如专线延迟、丢包率。一旦检测到机房间网络质量下降可主动触发应用层的熔断机制暂停非核心业务的写入或者强制将流量切换到单一机房人为地“制造”单活状态从而从源头上杜绝双 Master 写入的可能性。分布式系统的稳定性从来不是靠运气而是靠对故障模式的深刻理解和层层防御。那次凌晨的脑裂事故虽然惊心动魄但也让我们彻底重塑了对 Redis 高可用架构的认知。在网络分区不可避免的现实世界里唯有通过精细化的参数调优、严谨的数据修复预案以及多可用区的冗余设计才能在风暴来临时守住数据一致性的最后一道防线。
返回列表