MySQL死锁排查与预防实战
作者:花宝宝hua
死锁是MySQL高并发场景下的常见问题,偶尔一两次可以通过业务重试解决,本文就来详细的介绍一下MySQL死锁排查与预防实战,感兴趣的可以了解一下
前言
线上日志里突然出现大量这个错误:
Deadlock found when trying to get lock; try restarting transaction
死锁是MySQL高并发场景下的常见问题。偶尔一两次可以通过业务重试解决,但如果频繁出现,就需要从根本上排查和优化。
这篇整理MySQL死锁的排查方法和预防策略。
一、查看死锁信息
MySQL有个命令能看到最近一次死锁的详情:
SHOW ENGINE INNODB STATUS\G
输出很长,找LATEST DETECTED DEADLOCK这部分:
*** (1) TRANSACTION: UPDATE orders SET status = 'paid' WHERE id = 1001 *** (1) HOLDS THE LOCK(S): -- 持有orders表的锁 *** (1) WAITING FOR THIS LOCK: -- 等inventory表的锁 *** (2) TRANSACTION: UPDATE inventory SET quantity = quantity - 1 WHERE product_id = 2001 *** (2) HOLDS THE LOCK(S): -- 持有inventory表的锁 *** (2) WAITING FOR THIS LOCK: -- 等orders表的锁 *** WE ROLL BACK TRANSACTION (2)
经典的死锁场景:事务A锁了orders等inventory,事务B锁了inventory等orders,互相等。
二、分析死锁原因
知道是哪两个SQL了,回去翻代码。
原来下单逻辑里有两种调用顺序:
// 路径A:先改订单再扣库存 updateOrderStatus(orderId, "paid"); decreaseInventory(productId, 1); // 路径B:先扣库存再改订单(另一个接口) decreaseInventory(productId, 1); updateOrderStatus(orderId, "paid");
两个接口都在事务里,刚好并发了就死锁。
三、解决方案
最直接的办法:统一加锁顺序。
不管哪个接口,都先操作orders再操作inventory(或者反过来,总之要一致)。
// 统一顺序:先orders后inventory
@Transactional
public void processOrder(long orderId, long productId) {
updateOrderStatus(orderId, "paid"); // 永远先锁orders
decreaseInventory(productId, 1); // 再锁inventory
}
如果涉及多条记录,按ID排序:
List<Long> ids = Arrays.asList(id1, id2, id3);
Collections.sort(ids);
for (Long id : ids) {
lockAndProcess(id);
}
四、间隙锁导致的死锁
还有一种更诡异的死锁,两个事务操作的都不是同一行数据。
这通常是间隙锁的问题。RR隔离级别下,SELECT ... FOR UPDATE如果没命中数据,会锁一个"间隙"。
比如user_id有1、5、10三条记录:
-- 事务A SELECT * FROM orders WHERE user_id = 3 FOR UPDATE; -- 没有user_id=3的数据,但会锁住(1,5)这个间隙 -- 事务B SELECT * FROM orders WHERE user_id = 7 FOR UPDATE; -- 锁住(5,10)这个间隙 -- 然后两边各自INSERT -- 事务A想插入user_id=6,要等(5,10)的间隙锁 -- 事务B想插入user_id=4,要等(1,5)的间隙锁 -- 死锁
解决办法:
- 改用RC隔离级别(间隙锁少很多,但要注意幻读)
- 用唯一索引精确查询,避免范围锁
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
五、缩小事务范围
还有个常见问题是事务太长。事务越长,持有锁的时间越久,死锁概率越高。
// 这种写法不好
@Transactional
public void process() {
queryData(); // 查数据
callExternalApi(); // 调外部接口,可能很慢
updateDatabase(); // 更新数据库
}
// 改成这样
public void process() {
queryData();
callExternalApi(); // 外部调用放事务外面
updateInTransaction();
}
@Transactional
public void updateInTransaction() {
updateDatabase(); // 只有真正需要事务的操作
}
六、监控与告警
建议加上监控:
# 简单脚本,每分钟检查死锁次数
DEADLOCKS=$(mysql -e "SHOW GLOBAL STATUS LIKE 'Innodb_deadlocks'" | awk 'NR==2{print $2}')
echo "$(date) deadlocks: $DEADLOCKS" >> /var/log/deadlock.log
配合Prometheus的话:
- alert: MySQLDeadlock expr: increase(mysql_global_status_innodb_deadlocks[5m]) > 0 for: 1m
死锁次数涨了就告警,别等业务反馈才知道。
七、业务层重试
有些场景死锁确实很难完全避免,那就在业务层做重试:
int retry = 3;
while (retry-- > 0) {
try {
doTransaction();
break;
} catch (DeadlockException e) {
if (retry == 0) throw e;
Thread.sleep(100); // 等一下再试
}
}
MySQL检测到死锁会立即回滚一个事务,不会一直卡着,所以重试通常能成功。
总结
死锁本质是资源竞争问题,预防比解决更重要:
| 方法 | 效果 |
|---|---|
| 统一加锁顺序 | 最有效,从根本上避免死锁 |
| 缩小事务范围 | 减少锁持有时间 |
| 合理使用索引 | 减少锁的范围 |
| 降低隔离级别 | 减少间隙锁(RC级别) |
| 业务层重试 | 兜底方案 |
记住两点:统一加锁顺序、缩小事务范围,能解决大部分死锁问题。
到此这篇关于MySQL死锁排查与预防实战的文章就介绍到这了,更多相关MySQL死锁排查内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!
