MySQL 事务死锁排查:从日志分析到解决实战

发布于:2025-07-22 ⋅ 阅读:(17) ⋅ 点赞:(0)

在 MySQL 事务操作中,死锁是最让人头疼的问题之一 —— 两个或多个事务互相持有对方需要的锁,陷入无限等待状态,最终被数据库强行终止。比如电商平台的并发下单场景,若两个订单同时操作同两个商品的库存,就可能出现 “事务 A 锁商品 1 等商品 2,事务 B 锁商品 2 等商品 1” 的死锁。本文基于之前讲解的事务与锁机制,详细拆解死锁的排查方法、分析思路及解决策略。​

一、死锁的本质:什么是死锁?​

死锁是两个或多个事务在并发执行时,因循环等待对方持有的锁而无法继续执行的状态。例如:​

时间点​

事务 A​

事务 B​

T1​

启动事务,更新商品 1 库存(持有商品 1 的行锁)​

启动事务,更新商品 2 库存(持有商品 2 的行锁)​

T2​

尝试更新商品 2 库存(等待事务 B 释放商品 2 的锁)​

尝试更新商品 1 库存(等待事务 A 释放商品 1 的锁)​

T3​

等待事务 B 释放锁​

等待事务 A 释放锁​

此时两个事务进入无限等待,MySQL 会检测到死锁并终止其中一个事务(“牺牲者”),释放其持有的锁,让另一个事务继续执行。被终止的事务会收到错误提示:Deadlock found when trying to get lock; try restarting transaction。​

二、死锁产生的 4 个必要条件​

死锁的产生必须同时满足以下 4 个条件,只要破坏其中一个,就能避免死锁:​

  1. 互斥条件:锁是排他的,一个事务持有锁时,其他事务无法同时持有(如行锁的排他性)。​
  1. 持有并等待条件:事务已持有至少一个锁,且在等待其他事务持有的锁。​
  1. 不可剥夺条件:事务持有的锁不能被强行剥夺,只能由事务主动释放(如未提交的事务不会释放锁)。​
  1. 循环等待条件:多个事务形成循环等待链(如事务 A 等事务 B 的锁,事务 B 等事务 A 的锁)。​

三、死锁排查核心工具:从日志中找线索​

排查死锁的关键是获取死锁发生时的详细信息,MySQL 提供了专门的日志和系统表用于分析。​

1. 最核心:InnoDB 死锁日志(SHOW ENGINE INNODB STATUS)​

当死锁发生时,InnoDB 会自动记录死锁相关信息到引擎状态日志中,通过SHOW ENGINE INNODB STATUS可直接查看。​

操作步骤:​

  1. 死锁发生后,立即在 MySQL 客户端执行:​

TypeScript取消自动换行复制

  1. 在输出结果中找到LATEST DETECTED DEADLOCK部分,这就是最近一次死锁的详细日志。​

日志关键信息解读(附实战案例)​

假设两个事务因更新商品库存产生死锁,日志片段如下(已简化关键信息):​

TypeScript取消自动换行复制

关键信息提取:​

  • 事务 1(TRANSACTION 12345):已持有商品 1 的锁,正在等待商品 2 的锁(UPDATE id=2)。​
  • 事务 2(TRANSACTION 12346):已持有商品 2 的锁,正在等待商品 1 的锁(UPDATE id=1)。​
  • 死锁原因:两个事务形成 “商品 1→商品 2” 和 “商品 2→商品 1” 的循环等待。​
  • 处理结果:数据库回滚事务 1,释放其持有的锁,让事务 2 继续执行。​

2. 长期监控:performance_schema 死锁记录表​

SHOW ENGINE INNODB STATUS只能查看最近一次死锁,若需监控历史死锁,可通过performance_schema中的表记录(MySQL 5.7 + 支持)。​

开启监控:​

TypeScript取消自动换行复制

四、死锁解决步骤:从分析到修复​

排查死锁的核心是 “找到循环等待的锁和对应的 SQL”,再针对性优化。标准步骤如下:​

步骤 1:复现死锁场景​

根据死锁日志中的 SQL 和事务操作,在测试环境复现死锁。例如:​

  • 确定涉及的表(如stock)和 SQL(如UPDATE stock WHERE id = ?)。​
  • 模拟并发:用两个客户端同时执行事务,按日志中的操作顺序执行 SQL。​

步骤 2:分析锁竞争关系​

通过日志明确:​

  • 每个事务持有哪些锁(如事务 1 持有 id=1 的 X 锁)。​
  • 等待哪些锁(如事务 1 等待 id=2 的 X 锁)。​
  • 操作顺序是否导致循环等待(如 “先更 id=1 再更 id=2” vs “先更 id=2 再更 id=1”)。​

步骤 3:针对性优化(破坏死锁条件)​

根据分析结果,采用以下方法解决:​

1. 固定事务操作顺序(破坏 “循环等待”)​

最有效且简单的方法:让所有事务按相同的顺序操作资源。​

以上述商品库存为例,无论业务顺序如何,强制两个事务都先更新 id 小的商品,再更新 id 大的商品:​

  • 事务 1:先更 id=1,再更 id=2(符合顺序)。​
  • 事务 2:先更 id=1(需等待事务 1 释放 id=1 的锁),再更 id=2(此时事务 1 已释放 id=2 的锁)。​

此时不会形成循环等待,死锁消除。​

2. 减小事务粒度(破坏 “持有并等待”)​

长事务会持有锁更久,增加死锁概率。将事务拆分为更小的单元,减少 “持有锁并等待其他锁” 的时间。​

例如:原事务包含 “扣库存→生成订单→扣优惠券”3 个操作,可拆分为:​

  • 事务 1:仅扣库存(快速提交,释放锁)。​
  • 事务 2:生成订单 + 扣优惠券(不涉及库存锁)。​

3. 降低锁持有时间(减少冲突窗口)​

  • 避免在事务中执行非数据库操作(如调用外部接口、sleep),减少锁持有时间。​
  • 优化 SQL 执行效率:给 WHERE 条件的字段加索引(如stock.id加主键索引),避免全表扫描导致的表锁。​

4. 使用较低的隔离级别(减少锁竞争)​

在业务允许的情况下,将隔离级别从 “可重复读” 降为 “读已提交(RC)”。RC 级别下,InnoDB 的行锁释放更早(非索引条件的锁会提前释放),可减少锁竞争。​

5. 主动检测与重试(最后手段)​

若无法完全避免死锁(如高并发随机场景),可在应用层处理:​

  • 捕获死锁错误(错误码 1213)。​
  • 自动重试事务(建议重试 1-3 次,避免无限重试)。​

Java 代码示例(MyBatis):​

TypeScript取消自动换行复制

public void updateStockWithRetry(int goodsId1, int goodsId2) {​

int retryCount = 0;​

while (retryCount < 3) {​

try (SqlSession session = sqlSessionFactory.openSession()) {​

try {​

// 执行更新操作​

stockMapper.updateStock(goodsId1);​

stockMapper.updateStock(goodsId2);​

session.commit();​

break; // 成功提交,退出循环​

} catch (SQLException e) {​

// 捕获死锁错误(1213)​

if (e.getErrorCode() == 1213) {​

retryCount++;​

session.rollback();​

Thread.sleep(100); // 等待100ms再重试​

} else {​

throw e;​

}​

}​

}​

}​

}​

五、常见死锁场景及解决方案汇总​

死锁场景​

典型案例​

解决方法​

交叉更新​

事务 A:更新 A→更新 B;事务 B:更新 B→更新 A​

固定更新顺序(如按 ID 升序)​

长事务持有锁​

事务中包含 sleep、外部接口调用​

拆分事务,移除非数据库操作​

索引失效导致表锁​

WHERE 条件无索引,触发全表扫描​

给 WHERE 字段加索引,优化 SQL​

间隙锁冲突(RR 级别)​

按范围更新(如 UPDATE stock SET num=1 WHERE id>10)​

降低隔离级别为 RC,或避免范围更新​

六、总结​

死锁排查的核心是 “通过日志找到锁竞争关系”,解决的关键是 “破坏死锁的必要条件”。记住三个原则:​

  1. 优先固定操作顺序(最简单有效);​
  1. 尽量缩小事务范围(减少锁持有时间);​
  1. 结合应用层重试(应对极端场景)。​

如果你的业务中遇到过特殊死锁场景,欢迎在评论区分享,我们一起分析解决~后续会更新 “MySQL 间隙锁与幻读” 的深度解析,关注不错过!


网站公告

今日签到

点亮在社区的每一天
去签到