MySQL双主同步失败导致订单数据冲突怎么办主从延迟对账不一致排查案例缓存与数据库双写不同步的解决方案
那天深夜,我收到了运维团队的报警电话——生产环境的订单系统出问题了,用户下单后状态异常,有的订单显示”待支付”却已经发货,有的重复扣款。我赶紧登上去排查,才发现是MySQL双主同步链路断了,数据冲突像多米诺骨牌一样崩开了。
这种问题在咱们做高可用架构时并不少见,但真碰到时手忙脚乱的大有人在。今天我就把整个排查过程、根因分析和解决方案,掰开揉碎了讲给你听。
双主同步失效的”无声危机”
双主模式(Master-Master)听起来很美好——两台MySQL互为主从,读写都能扛,故障切换快。但它的致命弱点在于:同步失败时,默认情况下两台机器都会继续接收写请求,只是复制线程停止了。如果你没有完善的监控,这种”半死”状态可以静默运行很久。
常见失效场景
| 场景 | 表现 | 危害程度 |
|---|---|---|
| 网络闪断后未自动恢复 | 复制线程持续Running: No,但Binlog位置已漂移 | 高 |
| 主键冲突导致复制中断 | Last_SQL_Error 报 Duplicate entry 错误 | 极高 |
| 大事务跨节点执行 | 事务在A主写入,B从还没来得及同步就发生切换 | 极高 |
| 表结构不一致 | 某次DDL只在其中一台执行 | 高 |
我的排查思路
发现问题后,我第一反应是看复制状态:
-- 在两台机器上都执行
SHOW MASTER STATUS;
SHOW SLAVE STATUS\G
重点关注这几个字段:
Slave_IO_Running和Slave_SQL_Running是否为 YesSeconds_Behind_Master是否为 NULL 或较大值Last_SQL_Error是否有关键错误信息Relay_Log_Space和Exec_Master_Log_Pos是否持续增长
我当时看到的现象是:两台机器都显示 Running: Yes,但 Seconds_Behind_Master 逐渐变大,直到变成 NULL。这说明IO线程还在工作,但SQL线程在某个位置卡住了。
Slave_IO_Running: Yes
Slave_SQL_Running: No
Last_SQL_Error: Error 'Duplicate entry '10086' for key 'PRIMARY'' on query.
Default database: 'order_db'. Query: 'INSERT INTO orders VALUES (10086, ...)'
问题清楚了:双写时主键冲突了。两台机器同时接受了同一个订单ID的插入请求。
订单数据冲突的根因分析
双主架构下出现冲突,本质上是因为写请求没有全局唯一性约束。我画了个时间线帮助团队理解:
时间线:双主冲突产生过程
Master A Master B
| |
|---订单ID:10086写入------------>|
| |
|<---binlog复制(延迟)------------|
| |
|---另一路订单ID:10086写入-------| ← 冲突!
| |
|<---SQL线程报错停止-------------|
| |
|---订单10086状态异常-----------| ← 用户看到的问题
为什么会这样?
核心问题出在订单ID生成策略上。很多团队在双主环境下用了自增ID,但忘记配置 auto_increment_increment 和 auto_increment_offset。
-- Master A 配置:奇数ID
SET GLOBAL auto_increment_increment = 2;
SET GLOBAL auto_increment_offset = 1;
-- Master B 配置:偶数ID
SET GLOBAL auto_increment_increment = 2;
SET GLOBAL auto_increment_offset = 2;
这样两台机器生成的ID就不会重叠:A生成 1,3,5,7… B生成 2,4,6,8…
但如果没配这个,或者配错了,冲突就是必然的。
主从延迟对账不一致:一个真实案例
双写冲突只是表象,更麻烦的是延迟对账。即使同步恢复了,已经产生的脏数据也不会自动修复。
业务场景
我们的订单系统有一个对账job,每小时执行一次,核对Redis缓存和MySQL的数据一致性。正常情况下,对账能兜住大部分问题。但那次,对账发现了大量异常:
- 订单状态:MySQL显示”已支付”,Redis缓存还是”待支付”
- 订单金额:两台MySQL数据不一致
- 用户余额:扣款后没有及时更新
对账SQL示例
-- 对比MySQL和缓存的订单状态
SELECT
o.order_id,
o.status AS mysql_status,
o.gmt_modified AS mysql_time,
r.status AS redis_status,
r.gmt_modified AS redis_time
FROM orders o
LEFT JOIN order_cache r ON o.order_id = r.order_id
WHERE o.gmt_modified > DATE_SUB(NOW(), INTERVAL 1 HOUR)
AND (o.status != r.status
OR ABS(TIMESTAMPDIFF(SECOND, o.gmt_modified, r.gmt_modified)) > 5);
这个查询能找出近一小时内状态不一致的订单。
延迟产生的根源
我们排查后发现,延迟主要来自三个方面:
1. 大事务阻塞
某个批量退款接口一次性处理了500笔订单,在Master A上执行了45秒。这45秒内,所有后续的binlog事件都要排队等待。
-- 监控正在执行的大事务
SELECT
PROCESSLIST_ID AS id,
PROCESSLIST_USER AS user,
PROCESSLIST_HOST AS host,
PROCESSLIST_TIME AS duration_sec,
PROCESSLIST_STATE AS state,
LEFT(PROCESSLIST_INFO, 200) AS sql
FROM information_schema.PROCESSLIST
WHERE COMMAND != 'Sleep'
ORDER BY PROCESSLIST_TIME DESC;
2. 网络抖动
两台机器之间是跨机房部署的,网络偶尔抖动会导致复制线程短暂断开重连。每次重连都要重新同步大量binlog,加剧延迟。
3. 单线程复制
MySQL 5.7 默认单线程复制,即使 binlog 已经就绪,SQL线程也只能一条一条回放。
-- MySQL 5.7 多线程复制配置(需要升级或配置)
SET GLOBAL slave_parallel_type = 'LOGICAL_CLOCK';
SET GLOBAL slave_parallel_workers = 4;
缓存与数据库双写不同步的解决方案
这是最经典的问题之一。我见过太多团队在这里翻车。
问题的本质
缓存和数据库是两套存储,写操作时有先后,读操作时有超时,天然不可能做到强一致。我们能做的是最终一致。
策略一:先删缓存,后更新数据库
写入流程:
1. 更新数据库
2. 删除缓存(不是更新缓存!)
读取流程:
1. 读缓存,命中则返回
2. 未命中则读数据库,写入缓存
为什么是”删”而不是”更新”?因为并发场景下,如果两个请求同时写,A先写DB再更新缓存,B后写DB再更新缓存,最终缓存是B的值,但B可能写的是旧数据。删缓存可以让下次读取时重新从DB加载最新数据。
// Java伪代码示例
@Transactional
public void updateOrder(Order order) {
// 1. 先更新数据库
orderMapper.updateById(order);
// 2. 再删除缓存
String cacheKey = "order:" + order.getId();
redisService.del(cacheKey);
}
策略二:延时双删
对于高并发场景,可以在更新数据库前后各删一次缓存:
public void updateOrderWithDoubleDelete(Order order) {
// 第一次删除:防止旧数据被读取
redisService.del("order:" + order.getId());
// 更新数据库
orderMapper.updateById(order);
// 延时再次删除:确保延迟的旧缓存也被清除
try {
Thread.sleep(500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
redisService.del("order:" + order.getId());
}
策略三:使用Canal监听binlog异步更新缓存
这是最可靠的方案,把缓存更新从业务代码中解耦出来:
// 使用Canal订阅MySQL binlog
@Component
public class OrderBinlogListener implements CanalEventListener {
@Override
public void onEvent(CanalEvent event) {
if (event.getType() == EventType.INSERT || event.getType() == EventType.UPDATE) {
Order order = parseOrder(event);
// 异步更新缓存
redisService.setex(
"order:" + order.getId(),
3600,
JSON.toJSONString(order)
);
}
}
}
这样,只要MySQL写入成功,缓存就会自动更新,不依赖业务代码的缓存逻辑。
策略四:设置合理的缓存过期时间
不管用什么策略,都给缓存设置一个短TTL(比如30秒-5分钟),作为最后的兜底:
// 写入缓存时设置过期时间
redisService.setex(
"order:" + orderId,
300, // 5分钟过期
JSON.toJSONString(order)
);
这样即使双写出了问题,最多5分钟后数据就会自愈。
完整的故障恢复流程
回到最开始的问题。发现双主同步失效后,我的处理步骤是:
第一步:止血
-- 在出问题的Master B上停止复制
STOP SLAVE;
-- 查看当前错误位置
SHOW SLAVE STATUS\G
-- 记录当前的Master_Log_File和Exec_Master_Log_Pos
第二步:评估数据差异
-- 对比两台机器的数据量
SELECT
TABLE_NAME,
TABLE_ROWS
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'order_db'
ORDER BY TABLE_ROWS DESC;
-- 对比关键表的checksum
CHECKSUM TABLE orders;
CHECKSUM TABLE order_detail;
如果数据量差异不大,可以接受直接跳过错误恢复;如果差异巨大,需要手动修复。
第三步:跳过冲突事件(临时方案)
-- 跳过当前错误的SQL事件
SET GLOBAL sql_slave_skip_counter = 1;
START SLAVE;
-- 观察是否还有其他错误
SHOW SLAVE STATUS\G
这个操作要小心,跳过的是整个事务。如果事务里有多个操作,跳过可能导致部分数据缺失。
第四步:数据修复
对于已经产生冲突的订单数据,我们写了一个修复脚本:
-- 找出冲突订单:同一订单ID在两台上都有记录
SELECT
a.order_id,
a.status AS status_a,
a.gmt_modified AS time_a,
b.status AS status_b,
b.gmt_modified AS time_b
FROM order_db.orders a
JOIN order_db_replica.orders b ON a.order_id = b.order_id
WHERE a.gmt_modified != b.gmt_modified;
然后选择时间较新的那条记录作为权威数据,手动同步到另一台。
第五步:恢复同步并验证
-- 确认同步正常后
START SLAVE;
-- 等待同步追上
-- 每秒执行一次检查
SHOW SLAVE STATUS\G
-- 关注 Seconds_Behind_Master 是否为0或很小的值
预防措施:让问题不再发生
排查解决问题只是救火,真正重要的是防火。以下是我们团队 implemented 的预防措施:
1. 完善的监控告警
# Prometheus监控配置示例
- alert: MySQLReplicationDelay
expr: mysql_slave_status_seconds_behind_master > 10
for: 2m
labels:
severity: warning
annotations:
summary: "MySQL复制延迟超过10秒"
description: "主机 {{ $labels.instance }} 的复制延迟为 {{ $value }} 秒"
- alert: MySQLReplicationStopped
expr: mysql_slave_status_slave_io_running == 0 or mysql_slave_status_slave_sql_running == 0
for: 1m
labels:
severity: critical
annotations:
summary: "MySQL复制线程已停止"
2. 定期健康检查
-- 每天自动执行的检查脚本
SELECT
'Master Status' AS check_type,
@@hostname AS instance,
@@read_only AS read_only,
@@server_id AS server_id
UNION ALL
SELECT
'Slave Status',
@@hostname,
CASE WHEN Slave_SQL_Running = 'Yes' THEN 0 ELSE 1 END,
CASE WHEN Slave_IO_Running = 'Yes' THEN 0 ELSE 1 END
FROM information_schema.PROCESSLIST
WHERE NAME = 'Slave_SQL_Running';
3. 主从切换演练
每半年做一次主从切换演练,确保故障时能快速响应。不要等真出问题了再测试。
4. 订单ID生成改用UUID或雪花算法
从根本上避免双主冲突:
// 雪花算法生成订单ID
public class SnowflakeIdGenerator {
private long workerId;
private long datacenterId;
private long sequence = 0L;
private long lastTimestamp = -1L;
public synchronized long nextId() {
long timestamp = System.currentTimeMillis();
if (timestamp < lastTimestamp) {
throw new RuntimeException("时钟回拨");
}
if (timestamp == lastTimestamp) {
sequence = (sequence + 1) & 0xFFF;
if (sequence == 0) {
timestamp = waitNextMillis(timestamp);
}
} else {
sequence = 0L;
}
lastTimestamp = timestamp;
return ((timestamp - EPOCH) << 22)
| (datacenterId << 17)
| (workerId << 12)
| sequence;
}
}
雪花算法生成的ID全局唯一,彻底解决双主冲突问题。
总结
双主同步失败不是小概率事件,在高可用架构中几乎是必会遇到的问题。关键在于:
- 监控要到位 —— 复制延迟、线程状态必须实时监控
- ID生成要统一 —— 避免自增ID冲突,改用分布式ID
- 缓存策略要合理 —— 先删缓存、延时双删、Canal监听多管齐下
- 演练不能少 —— 故障处理能力是在演练中练出来的
那次故障之后,我们团队做了全面的改进,至今再没出现过类似问题。希望这些经验能帮到你。如果有具体的场景需要进一步探讨,随时问我。
