哎,咱们今天聊点“扎心”的。做后端开发的,尤其是扛过高并发场景的,谁没被MySQL这三位“老朋友”恶心过:主从延迟让你不敢读从库、锁表死锁让接口卡死、分布式事务让数据对不上账。这些问题看着吓死人,但其实都有迹可循。别慌,我把这些年踩坑总结出来的排查思路、实战代码、甚至怎么跟DBA“battle”的经验,全揉碎了讲给你听。
一、主从延迟:那只“看不见”的数据黑洞
1.1 为什么主从会延迟?别只怪网络
首先你得明白,MySQL主从复制的本质是:主库写binlog,从库IO线程拉取,SQL线程回放。延迟往往卡在这两个环节:
- IO延迟:网络抖动、带宽瓶颈,导致从库拉取binlog慢。
- SQL延迟:这是大头!主库上的复杂查询、大事务、
ALTER TABLE,在从库单线程回放时会被放大。比如主库一个UPDATE语句更新了100万行,从库SQL线程得乖乖一行行执行,主库早就写完了,从库还在加班。
真实案例:我曾经见过一个电商系统,大促期间主库QPS 2万+,从库延迟飙到30秒。业务层为了读实时数据,硬着头皮读主库,结果主库扛不住直接宕机。这才是最痛的——延迟导致读写不分,最终一起挂。
1.2 怎么查?别光看Seconds_Behind_Master
很多新手一看show slave status里的Seconds_Behind_Master,嗯,0,没问题。等等,下一秒就报数据不一致了。为什么?因为这个值是瞬时的,它只反映“此刻”的延迟,一旦从库SQL线程抢占了CPU,瞬间追上,它就变0了。但业务高峰期那一秒的延迟,你可能完全没感知到。
真正有用的排查姿势:
第一步:看长期趋势,不是瞬间值
-- 在从库执行,观察长期延迟
SHOW SLAVE STATUS\G
重点看这几个字段:
Seconds_Behind_Master:瞬时值,仅作参考。Relay_Log_Space:中继日志空间,如果持续增长,说明从库回放跟不上。Slave_IO_Running/Slave_SQL_Running:必须是Yes,否则复制已中断。
第二步:用pt-heartbeat监控真实延迟(强推!)
这是Percona Toolkit的神器,它能让你在主库写时间戳,从库读时间戳对比,算出真实的业务延迟,而不是单纯的SQL线程落后多少秒。
# 主库初始化表(只需要一次)
pt-heartbeat --update --daemonize --database mysql --table heartbeat \
--user admin --password your_password --host localhost
# 从库监控延迟(另一台机器或定时任务)
pt-heartbeat --monitor --database mysql --table heartbeat \
--user admin --password your_password --host slave_host
第三步:定位瓶颈是IO还是SQL
-- 在从库执行
SHOW PROCESSLIST;
如果看到State是Waiting for master to send event,说明卡在IO线程,可能是网络或带宽问题。
如果看到State是Waiting for dependent transaction to commit或executing SQL,说明卡在SQL线程,得看是不是有复杂查询。
第四步:看binlog的“流量”
# 在主库查看binlog生成速度
mysqlbinlog --base64-output=DECODE-ROWS -v /path/to/binlog.000001 | wc -l
如果binlog量突然激增,而从库回放跟不上,那大概率是主库有“大事务”或“批量更新”在捣鬼。
1.3 解决方案:别硬扛,该妥协就妥协
- 架构层面:读写分离+缓存。对于非实时性要求的数据(比如用户资料、商品详情),用Redis缓存,主从延迟根本感知不到。
- 从库优化:给从库加SSD,开启
slave_parallel_workers(MySQL 5.7+支持多线程回放)。 - 业务层面:读主库的场景要极其谨慎,必须明确业务语义是否允许秒级延迟。如果不能,干脆别用从库,直接读主库,但要做好主库压力预案。
给小朋友的话:主从延迟就像你写作业,哥哥(主库)写得快,弟弟(从库)写得慢。如果哥哥每秒钟写一页,弟弟得一页一页抄,哥哥都写完十页了,弟弟才抄到第五页。这时候如果你问弟弟“第十页写了啥”,他肯定不知道。所以,别急着问弟弟,先让哥哥缓存下来,或者等弟弟追上再说。
二、锁表死锁:MySQL里的“交通拥堵”
2.1 死锁和锁表的本质区别
很多工程师把这两个概念混为一谈。锁表通常指LOCK TABLES语句,或者因为大事务、长查询导致连接数占满,新请求排队等待。死锁则是两个事务互相等待对方释放锁,谁都不让谁,MySQL检测到后会主动kill其中一个。
真实场景:一个转账接口,A事务先锁账户1,再锁账户2;B事务先锁账户2,再锁账户1。两个人同时出发,在路口撞上了,谁也不倒车,最后交警(MySQL)来了,判定B是违章,把B拖走,A继续走。这就是死锁。
2.2 怎么查?别只看SHOW ENGINE INNODB STATUS
-- 查看当前死锁信息(MySQL 5.7+)
SHOW ENGINE INNODB STATUS\G
在LATEST DETECTED DEADLOCK部分,你会看到两个事务的完整SQL、持有的锁、等待的锁。但这只是“事后诸葛亮”,你得学会事前预防。
第一步:开启死锁日志监控
-- 查看死锁统计
SHOW STATUS LIKE 'Innodb_deadlocks';
SHOW STATUS LIKE 'Innodb_row_lock_time';
SHOW STATUS LIKE 'Innodb_row_lock_waits';
如果Innodb_deadlocks频繁上涨,说明你有死锁问题。
第二步:用performance_schema抓实时锁等待
-- 查看当前正在等待锁的事务
SELECT * FROM performance_schema.data_locks;
SELECT * FROM performance_schema.data_lock_waits;
这两张表能告诉你:谁在锁谁。比如事务A锁住了行X,事务B正在等行X,你就能精准定位。
第三步:查长查询,往往是锁的源头
-- 查看运行时间超过5秒的查询
SELECT * FROM information_schema.processlist
WHERE TIME > 5 AND COMMAND != 'Sleep'
ORDER BY TIME DESC;
很多死锁是因为一个长查询(比如未索引的UPDATE)一直持有锁,其他事务排队等,最终形成死锁。
2.3 死锁的“经典套路”与解法
套路1:锁顺序不一致
-- 事务1
BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 1;
UPDATE account SET balance = balance + 100 WHERE id = 2;
COMMIT;
-- 事务2
BEGIN;
UPDATE account SET balance = balance - 50 WHERE id = 2;
UPDATE account SET balance = balance + 50 WHERE id = 1;
COMMIT;
解法:所有事务按相同的顺序访问资源。比如都先锁id小的,再锁id大的。
-- 修正后
BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 1;
UPDATE account SET balance = balance + 100 WHERE id = 2;
COMMIT;
套路2:大事务持有锁太久
BEGIN;
-- 做了100次查询和更新
UPDATE order SET status = 'paid' WHERE id = 1001;
UPDATE user SET points = points + 100 WHERE id = 101;
-- ... 一堆业务逻辑
COMMIT;
解法:拆分事务,只把真正需要一致性的操作放在事务里。或者用SELECT FOR UPDATE缩小锁范围。
套路3:间隙锁(Gap Lock)误伤
这是InnoDB的“特性”。当你对一个范围进行UPDATE或DELETE,且索引不是唯一的,InnoDB会锁住整个范围,包括不存在的记录。
-- 假设id字段有索引,但不是主键
UPDATE order SET status = 'cancelled' WHERE create_time > '2023-01-01';
这条SQL可能锁住从'2023-01-01'到'+infinity'的所有记录,包括不存在的。其他事务如果插入create_time在这个范围内的记录,就会阻塞。
解法:尽量用主键或唯一索引锁定,避免范围查询。如果必须用范围,考虑用NOWAIT或SKIP LOCKED(MySQL 8.0+支持)。
2.4 给小朋友的话:锁就像玩滑梯
想象幼儿园有10个滑梯(行锁),小朋友(事务)要玩滑梯。如果小朋友A先占了滑梯1和2,小朋友B也要玩滑梯1和2,但顺序反了,A在玩2,B在玩1,两个人都等着对方放开,那就谁也没法玩。这时候老师(MySQL)来了,把B喊走,让他下次再来,A才能继续玩。所以,大家都按同一个顺序玩滑梯,就不会堵车了。
三、分布式事务最终一致性:别迷信强一致性
3.1 为什么不用2PC?性能太差
两阶段提交(2PC)是分布式事务的经典方案,但它有个致命问题:阻塞性。在第一阶段,所有参与方都要锁定资源,如果某个参与方挂了,整个事务就卡住,其他参与者白等。在高并发场景下,这简直是自杀。
真实案例:一个支付系统,用2PC协调订单服务和库存服务。支付高峰期,库存服务的一个节点响应慢,导致整个订单创建请求阻塞5秒+,用户体验极差,最终只能放弃2PC,改用最终一致性。
3.2 最终一致性的核心:本地事务+消息队列
经典模式:
- 业务操作和发送消息放在同一个本地事务里(或者用事务日志+补偿)。
- 消息队列保证消息至少投递一次。
- 消费者重试机制保证最终成功。
实战代码(Spring Boot + RocketMQ):
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private RocketMQTemplate rocketMQTemplate;
/**
* 创建订单:本地事务 + 发送消息
*/
@Transactional
public void createOrder(Order order) {
// 1. 本地数据库操作
orderMapper.insert(order);
// 2. 发送消息(同一事务内,要么都成功,要么都失败)
// 注意:这里用的是RocketMQ的事务消息,不是普通消息
Message msg = new Message("OrderTopic", "Tag", order.getId().toString(), order);
rocketMQTemplate.sendMessageInTransaction("orderProducerGroup", msg, order);
}
}
/**
* 消息生产者:实现事务消息回调
*/
@Component
public class OrderProducer implements TransactionListener {
@Autowired
private OrderMapper orderMapper;
/**
* 本地事务执行后,MQ Broker询问是否提交消息
*/
@Override
public LocalTransactionState checkLocalTransaction(MessageExt msg) {
Long orderId = Long.parseLong(msg.getKeys());
Order order = orderMapper.selectById(orderId);
if (order != null && "CREATED".equals(order.getStatus())) {
return LocalTransactionState.COMMIT_MESSAGE; // 本地事务成功,提交消息
}
return LocalTransactionState.ROLLBACK_MESSAGE; // 本地事务失败,回滚消息
}
}
/**
* 消息消费者:最终一致性保证
*/
@Service
public class InventoryService {
@MQListener(topic = "OrderTopic", tag = "Tag")
public void deductInventory(MessageExt msg) {
Long orderId = Long.parseLong(msg.getKeys());
// 幂等性检查:防止重复扣库存
if (inventoryAlreadyDeducted(orderId)) {
return;
}
// 扣减库存
inventoryMapper.deduct(orderId);
// 记录扣减日志,用于对账
deductLogMapper.insert(new DeductLog(orderId));
}
private boolean inventoryAlreadyDeducted(Long orderId) {
// 查数据库或Redis,判断是否已经处理过
return deductLogMapper.existsById(orderId);
}
}
关键点解析:
- 事务消息:RocketMQ的事务消息机制,确保了“本地事务”和“消息发送”的原子性。如果本地事务失败,消息不会发送;如果本地事务成功,消息才会被消费者看到。
- 幂等性:消费者必须幂等,因为消息可能重复投递。通过
deductLogMapper.existsById检查,避免重复扣库存。 - 补偿机制:如果消费者失败,消息会重试。如果多次重试还是失败,可以入死信队列,人工介入。
3.3 对账:最终一致性的“兜底网”
消息队列不能100%保证不丢消息,网络抖动、机器宕机都可能导致数据不一致。所以,定期对账是最终一致性架构的必备组件。
@Component
public class ReconciliationJob {
@Scheduled(cron = "0 0 2 * * ?") // 每天凌晨2点执行
public void dailyReconciliation() {
// 1. 拉取订单表数据
List<Order> orders = orderMapper.selectAll();
// 2. 拉取库存扣减日志
List<DeductLog> logs = deductLogMapper.selectAll();
// 3. 对比差异
for (Order order : orders) {
DeductLog log = logs.stream()
.filter(l -> l.getOrderId().equals(order.getId()))
.findFirst()
.orElse(null);
if (log == null) {
// 补偿:重新发送消息或手动扣减库存
compensationService.reDeductInventory(order.getId());
} else if (!log.getStatus().equals(order.getStatus())) {
// 数据不一致,记录异常,人工介入
exceptionService.logException(order.getId(), log.getStatus(), order.getStatus());
}
}
}
}
对账原则:
- 定时:每天凌晨业务低峰期执行。
- 全量:不要只核对差异,要全量比对,避免漏检。
- 自动补偿+人工介入:小差异自动修复,大差异报警给运营。
3.4 给小朋友的话:最终一致性就像“记账”
想象你和好朋友一起购物,你付钱,朋友拿货。但你的朋友有时候会忘记告诉你他拿了什么。于是你约定:每天晚上10点,你们对账。如果朋友没告诉你,你就自己查账单,发现少了东西,就再问他一次。这样,虽然过程中可能有点小误差,但最终账目是对的。这就是“最终一致性”——不强求每一步都对,但保证最后一定对。
四、总结:排查问题的“三板斧”
- 主从延迟:别信瞬时值,用
pt-heartbeat看真实延迟,定位是IO还是SQL瓶颈,该用缓存用缓存,该优化从库优化从库。 - 锁表死锁:用
performance_schema抓实时锁等待,分析死锁日志,规范锁顺序,缩小事务范围,避免间隙锁误伤。 - 分布式事务:放弃2PC,用本地事务+消息队列实现最终一致性,必须幂等,必须对账。
这些问题没有银弹,只有针对场景的权衡。希望这些经验能帮你少走弯路。如果还有具体场景拿不准,随时来聊,咱们一起拆。
