最近帮一家电商客户做架构审计,发现了一个挺典型的问题:他们的订单状态在管理后台显示“已支付”,但在库存服务里却显示“待扣减”。技术人员第一反应是去查代码Bug,翻了半小时日志没找到,最后排查才发现是MySQL主从同步延迟了整整3秒,加上应用层没有做适当的读写分离策略,导致用户刚付完钱,查询接口却读到了从库的旧数据。
这种“数据对不上”的焦虑,几乎每个做后端开发的人都会遇到。今天咱们就坐下来,不聊那些干巴巴的理论,把MySQL数据一致性这个“坑”彻底趟清楚。咱们从最让人头疼的主从延迟说起,再到分布式环境下的两阶段提交(2PC)和最终一致性方案,最后手把手教你怎么排查故障和搭建高可用架构。放心,我会用大白话配合真实的代码示例,保证你看完能直接上手解决问题。
主从同步延迟:那个让你怀疑人生的“3秒”
首先,咱们得搞清楚,为什么主从同步会有延迟?这其实是个很物理的问题。
MySQL的主从复制(Master-Slave Replication)本质上是一个异步过程。主库(Master)把数据变更记录到二进制日志(binlog)里,然后从库(Slave)通过I/O线程拉取binlog,写到本地的relay log,再由SQL线程重放这些操作。你看,这里有好几个环节:网络传输、磁盘写入、SQL重放。任何一个环节慢了,延迟就产生了。
延迟产生的常见场景
- 大事务:如果主库上有一个更新10万条记录的
UPDATE语句,从库必须逐个重放,这时候延迟会瞬间飙升。 - 高并发写入:主库写入压力极大,binlog生成速度快,但从库硬件配置不如主库,或者SQL线程只有一个(MySQL 5.6之前),处理不过来。
- 复杂的查询占用资源:有时候从库不仅用于同步,还用来跑报表查询,这些慢查询会占用CPU和IO,导致SQL线程重放变慢。
如何监控和排查延迟?
别靠猜,要看数据。MySQL提供了SHOW SLAVE STATUS命令,重点关注两个字段:
Seconds_Behind_Master:表示从库落后主库多少秒。注意,这个值在某些情况下可能为NULL,表示无法计算,这时候延迟可能很大。Relay_Log_Space:中继日志的大小,如果一直增长,说明从库跟不上主库。
举个例子,如果你在业务高峰期发现Seconds_Behind_Master经常跳到5以上,那就要警惕了。
实战建议:开启GTID(全局事务标识符)。GTID让每个事务都有唯一ID,主从切换时更容易追踪事务进度,也能更准确地计算延迟。在my.cnf里配置:
[mysqld]
gtid_mode = ON
enforce_gtid_consistency = ON
重启MySQL生效。之后,你可以用SHOW MASTER STATUS和SHOW SLAVE STATUS直接对比GTID集合,判断哪些事务还没同步。
读写分离下的数据一致性陷阱
很多公司为了扛住流量,采用了读写分离架构。但这里有个巨大的坑:你读到的数据,可能不是最新的。
常见的错误用法
假设你的应用代码是这样的:
// 伪代码
order = saveOrder(order); // 写入主库
orderDetail = queryOrderDetail(order.getId()); // 直接读从库
如果主从同步延迟超过业务允许的时间窗口,queryOrderDetail可能读不到刚插入的订单详情。用户看到“查询失败”或数据缺失,体验极差。
解决方案:强制读主库
对于关键业务,比如支付后的订单状态查询,必须强制读主库。怎么实现?
方法一:连接标签法
在MySQL 8.0+中,可以使用SET TRANSACTION READ WRITE或连接标签。但更通用的做法是在应用层做标记。比如,使用MyBatis的分页插件或Spring的@DataSource注解,对特定方法强制指定主库数据源。
@Primary
@Bean("masterDataSource")
public DataSource masterDataSource() {
// 主库数据源配置
}
@Bean("slaveDataSource")
public DataSource slaveDataSource() {
// 从库数据源配置
}
// 在Service层
@Transactional(readOnly = true, dataSource = "masterDataSource")
public OrderDetail queryOrderDetail(Long orderId) {
return orderMapper.selectById(orderId);
}
方法二:会话绑定
对于微服务架构,可以在RPC调用链中传递“主库强制”标记。比如,在网关层或拦截器中,如果请求来自写操作后的同一会话,自动切换到主库连接。
方法三:引入缓存层
用Redis缓存热点数据,设置短TTL(比如5秒)。写操作后,先写数据库,再删除Redis缓存(Cache Aside Pattern)。这样,后续的读请求要么命中缓存,要么回源到数据库。虽然不能保证强一致,但能把延迟控制在毫秒级,满足大多数业务需求。
记住,没有银弹。读写分离的代价就是放弃强一致性。你需要根据业务场景权衡:是允许偶尔读到旧数据(如商品列表),还是必须实时准确(如库存扣减)?
分布式事务:当数据分布在多个节点时
随着微服务拆分,单一MySQL实例已经不够用了。订单服务、库存服务、支付服务可能各自有自己的数据库。这时候,如何保证“扣库存”和“创建订单”要么同时成功,要么同时失败?这就是分布式事务要解决的问题。
方案一:2PC(两阶段提交)—— 强一致,但性能差
2PC是经典方案,分为准备阶段和提交阶段。
准备阶段:协调者(通常是事务发起方)向所有参与者(各数据库)发送准备请求。参与者执行事务,但不提交,只写redo log,然后回复“准备就绪”或“中止”。
提交阶段:如果所有参与者都回复“准备就绪”,协调者发送提交请求,参与者正式提交事务。如果有任何一个参与者回复“中止”,协调者发送回滚请求。
缺点:同步阻塞、单点故障、数据不一致风险(如果协调者在提交阶段崩溃)。
在实际生产中,纯粹的2PC很少用,因为性能太差。大家更倾向于使用封装好的框架,比如Seata。
方案二:Seata —— 阿里开源的分布式事务解决方案
Seata支持多种事务模式,其中最常用的是AT模式(自动补偿)。
AT模式原理:
- 一阶段:Seata代理数据源,在执行SQL前,解析SQL语义,生成“ before image”(变更前快照)和“ after image”(变更后快照),并保存到undo_log表中。然后执行SQL,但不提交。
- 二阶段提交:如果事务成功,Seata异步删除undo_log记录。
- 二阶段回滚:如果事务失败,Seata根据undo_log中的before image,生成反向SQL,执行回滚。
代码示例(使用Spring Boot + Seata):
首先,在pom.xml中引入依赖:
<dependency>
<groupId>io.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
<version>1.6.1</version>
</dependency>
然后在application.yml中配置Seata:
seata:
enabled: true
tx-service-group: my_test_tx_group
service:
vgroup-mapping:
my_test_tx_group: default
grouplist:
default: 127.0.0.1:8091
config:
type: file
registry:
type: file
接着,在需要分布式事务的方法上添加@GlobalTransactional注解:
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private StockMapper stockMapper;
@GlobalTransactional(timeoutMills = 30000, name = "ds-tx")
public void createOrder(Order order) {
// 1. 插入订单(本地事务)
orderMapper.insert(order);
// 2. 扣减库存(远程调用,也是本地事务)
stockMapper.decreaseStock(order.getProductId(), order.getQuantity());
}
}
注意,每个参与的微服务都需要配置Seata客户端,并且数据库中要有undo_log表(Seata官方提供建表脚本)。
AT模式的优点:对业务代码侵入小,只需加一个注解。 缺点:需要维护undo_log,有一定存储开销;一阶段本地事务未提交,会占用数据库连接资源。
方案三:TCC模式 —— 高性能,但开发复杂
TCC(Try-Confirm-Cancel)要求开发者手动编写三个阶段:
- Try:预留资源(如冻结库存)。
- Confirm:确认提交,使用Try预留的资源。
- Cancel:取消事务,释放Try预留的资源。
这种方式性能更好,因为一阶段就提交了本地事务,但不释放连接。缺点是代码量大,需要为每个业务操作编写Try/Confirm/Cancel逻辑,且要处理幂等性和空回滚问题。
何时选择哪种方案?
- 如果对一致性要求极高,且事务范围广,选2PC/Seata AT。
- 如果性能敏感,且业务逻辑清晰(如库存扣减),选TCC。
- 如果对一致性要求不高,允许短暂不一致,选最终一致性(消息队列)。
最终一致性:消息队列的妙用
并不是所有场景都需要强一致性。比如,用户下单后,通知服务、积分服务、物流服务等可以异步处理。这时候,基于消息队列的最终一致性方案是更好的选择。
原理
- 订单服务创建订单,同时发送一条消息到MQ(如RocketMQ、Kafka)。
- 订单服务本地事务提交。
- 通知服务、积分服务等订阅消息,异步处理。
- 如果消费失败,MQ会重试,直到成功或达到最大重试次数。
如何保证“不丢消息”?
发送端:使用本地消息表。在订单服务中,创建订单和插入消息记录在同一个本地事务中。然后有一个定时任务扫描未发送的消息,发送到MQ。发送成功后,更新消息状态为“已发送”。
CREATE TABLE local_message (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
business_id VARCHAR(64),
topic VARCHAR(64),
content TEXT,
status TINYINT DEFAULT 0, -- 0:待发送, 1:已发送
create_time DATETIME
);
接收端:实现幂等性。因为MQ可能重复投递,所以消费逻辑必须幂等。可以用数据库唯一键或Redis分布式锁来实现。
@RocketMQMessageListener(topic = "ORDER_CREATED", consumerGroup = "notification_group")
public class NotificationConsumer implements RocketMQListener<OrderEvent> {
@Autowired
private NotificationService notificationService;
@Override
public void onMessage(OrderEvent event) {
// 幂等检查
if (notificationService.isProcessed(event.getOrderId())) {
return;
}
notificationService.sendNotification(event.getOrderId());
notificationService.markAsProcessed(event.getOrderId());
}
}
这种方案虽然最终一致,但延迟通常在秒级,足以满足大多数业务场景,而且系统解耦、可扩展性强。
常见故障排查实战
即使有了好架构,故障也会发生。下面分享几个真实的排查案例。
案例1:主从同步中断,延迟不断累积
现象:监控报警,Seconds_Behind_Master持续上升。
排查步骤:
- 查看从库错误日志:
tail -f /var/log/mysql/salve.err - 执行
SHOW SLAVE STATUS\G,查看Last_Error字段。 - 常见错误:
- 语句级复制报错:比如从库上某条SQL执行失败(如外键约束、唯一索引冲突)。解决方法:跳过错误(不推荐生产环境长期使用)或修复数据。
- GTID模式不匹配:主库和从库的
gtid_executed集合不一致。可以使用pt-gtid-duplicate-transactions工具修复。 - 网络中断:检查主从之间的网络连接,确保3306端口畅通。
修复示例(跳过错误):
STOP SLAVE;
SET GLOBAL SQL_SLAVE_SKIP_COUNTER = 1;
START SLAVE;
注意:跳过错误可能导致数据不一致,慎用!
案例2:Seata分布式事务卡住
现象:调用链超时,数据库连接池耗尽。
排查步骤:
- 检查Seata Server状态:
curl http://127.0.0.1:8091/health - 查看数据库中的
global_table和branch_table,看是否有大量未提交的事务。 - 如果Seata Server崩溃,可能导致全局事务一直处于“等待”状态。
解决方案:
- 配置Seata的超时时间:
seata.tx-service-group对应的global.transaction.timeout。 - 使用Seata的运维工具清理悬空事务。
- 确保每个微服务的Seata客户端版本一致,避免兼容性问题。
案例3:MQ消息堆积
现象:消费延迟高,业务数据不及时。
排查步骤:
- 检查消费者状态:是否所有消费者都在线?有没有消费者崩溃?
- 检查消费逻辑:是否因为某个异常导致消费者不断重试,占用了线程资源?
- 检查MQ broker:是否有磁盘空间不足或网络延迟?
解决方案:
- 扩容消费者实例。
- 优化消费逻辑,将耗时操作异步化。
- 设置合理的重试策略,失败消息进入死信队列,人工处理。
高可用架构实战:MySQL Group Replication与MGR
传统的主从复制在故障切换时存在数据丢失风险。MySQL Group Replication(MGR)提供了一个更强大的解决方案:多主复制、自动故障切换、一致性保证。
MGR核心特性
- 冲突检测与解决:基于乐观并发控制,多个节点可以并行写,如果发生冲突,后提交的事务会失败并回滚。
- 自动故障切换:集群中大部分节点存活时,可以自动选主并继续提供服务。
- 强一致性:通过Paxos协议保证数据一致性,所有事务在多数派节点提交后才返回成功。
搭建MGR集群步骤
环境准备:3个MySQL实例,端口分别为3306、3307、3308。
1. 配置每个实例的my.cnf:
[mysqld]
server_id=1
gtid_mode=ON
enforce_gtid_consistency=ON
binlog_checksum=NONE
master_info_repository=TABLE
relay_log_info_repository=TABLE
binlog_group_commit_sync_delay=1
binlog_group_commit_sync_no_delay_count=10
loose-group_replication_group_name="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
loose-group_replication_start_on_boot=OFF
loose-group_replication_local_address= "127.0.0.1:33061"
loose-group_replication_group_seeds= "127.0.0.1:33061,127.0.0.1:33071,127.0.0.1:33081"
loose-group_replication_bootstrap_group=OFF
loose-group_replication_single_primary_mode=TRUE
loose-group_replication_enforce_update_everywhere_checks=FALSE
注意:group_replication_group_name必须是唯一的UUID,每个实例相同;group_replication_local_address是每个实例接收组内成员连接的地址。
2. 启动MySQL实例,创建复制用户:
CREATE USER 'rpl_user'@'%' IDENTIFIED BY 'password';
GRANT REPLICATION SLAVE ON *.* TO 'rpl_user'@'%';
FLUSH PRIVILEGES;
3. 安装Group Replication插件:
INSTALL PLUGIN group_replication SONAME 'group_replication.so';
4. 配置复制通道:
CHANGE MASTER TO MASTER_USER='rpl_user', MASTER_PASSWORD='password' FOR CHANNEL 'group_replication_recovery';
5. 引导第一个节点:
SET GLOBAL group_replication_bootstrap_group=ON;
START GROUP_REPLICATION;
SET GLOBAL group_replication_bootstrap_group=OFF;
6. 其他节点加入集群:
START GROUP_REPLICATION;
7. 验证集群状态:
SELECT * FROM performance_schema.replication_group_members;
如果看到所有成员状态为ONLINE,说明集群搭建成功。
MGR的注意事项
- 写性能:由于需要冲突检测,MGR的写性能比普通主从复制低。适合读多写少或写入冲突少的场景。
- 网络要求:组内成员之间需要低延迟、高带宽的网络连接。
- 备份恢复:MGR集群的备份需要特殊处理,建议使用
mysqldump或Percona XtraBackup,并在恢复后重新加入集群。
结语:没有最好,只有最合适
聊了这么多,你可能会问:到底哪种方案最好?
我的答案是:没有银弹。
- 如果你的业务对一致性要求极高,且数据集中在单一MySQL实例,那么主从复制+读写分离就够用,记得强制关键查询读主库。
- 如果数据分散在多个微服务,且事务范围小,Seata AT模式是快速上手的最佳选择。
- 如果对性能要求极高,且业务逻辑允许编写TCC接口,那就选TCC。
- 如果能接受最终一致性,消息队列是最灵活、最解耦的方案。
- 如果需要高可用且强一致,MGR是一个值得尝试的选项,但要做好性能损耗的心理准备。
