MySQL 死锁:从 “业务卡顿” 到 “根因定位” 的实战指南
后端开发必看:MySQL死锁排查与预防全攻略
线上系统突然报出Deadlock found when trying to get lock; try restarting transaction,用户操作卡顿甚至超时,排查时却对着一堆日志无从下手?这篇文章帮你彻底搞定MySQL死锁问题!
一、先搞懂:死锁的"本质"和"必要条件"
很多人把"锁等待"当成死锁,其实二者完全不同:
- 锁等待是"一个事务等另一个事务释放锁"(比如事务A没提交,事务B等着改同一行数据),等事务A提交后,事务B就能继续
- 死锁是"多个事务互相等对方释放锁",陷入无限循环,最后MySQL只能"牺牲"一个事务来打破僵局
死锁的"4个必要条件"
MySQL死锁的产生,必须同时满足4个条件(少一个都不行),搞懂这4点,就能明白死锁的"软肋"在哪里:
- 互斥条件:锁资源是"独占的",一个事务拿了锁,其他事务只能等(比如行锁,事务A锁住一行后,事务B不能再拿同一行的写锁)
- 持有并等待条件:事务已经拿了至少一个锁,又在等其他事务持有的锁(比如事务A拿了行1的锁,又等着拿行2的锁,同时事务B拿了行2的锁,等着拿行1的锁)
- 不可剥夺条件:事务拿到的锁不能被"抢"走,只能自己释放(比如事务A拿了锁,除非它主动提交/回滚,否则其他事务不能强制让它释放)
- 循环等待条件:多个事务形成"环形等待链"(比如A等B的锁,B等C的锁,C等A的锁)
既然死锁需要4个条件同时成立,那预防死锁的核心思路就很明确:打破其中一个条件(比如避免循环等待、减少"持有并等待"的时间)。
死锁和"锁等待超时"的区别
很多同学会把Lock wait timeout exceeded
(锁等待超时)和死锁搞混,其实二者的触发机制完全不同:
- 锁等待超时:事务A持有锁,事务B一直等A释放,等了超过
innodb_lock_wait_timeout
(默认50秒)还没等到,就会报这个错(是"单方面等待超时") - 死锁:多个事务互相等待,MySQL的"死锁检测器"(每隔一段时间运行)发现了循环等待,就会主动选择一个"代价小"的事务回滚,报
Deadlock found...
(是"双向循环等待,MySQL主动干预")
简单说:锁等待是"一个等一个,等不及了";死锁是"互相等,谁也走不了,MySQL强行拆局"。
二、3个真实场景:死锁是怎么"造"出来的?
死锁不是凭空产生的,几乎都是"业务逻辑 + SQL执行顺序"共同导致的。下面3个场景你可能或多或少遇见过,我们逐一拆解死锁的产生过程。
场景1:"交叉更新"引发的死锁(最常见)
业务背景:电商订单系统,用户支付后需要"扣减库存"和"更新订单状态",两个事务分别操作两行数据,但更新顺序相反。
事务1(用户A支付):
-- 步骤1:扣减商品1库存
UPDATE product_stock SET stock = stock - 1 WHERE product_id = 1;
-- 步骤2:更新订单1001状态为"已支付"
UPDATE orders SET status = 'PAID' WHERE order_id = 1001;
事务2(用户B支付):
-- 步骤1:更新订单1001状态为"已支付"
UPDATE orders SET status = 'PAID' WHERE order_id = 1001;
-- 步骤2:扣减商品1库存
UPDATE product_stock SET stock = stock - 1 WHERE product_id = 1;
死锁产生过程:
- 事务1执行"扣减库存",拿到
product_stock
表中product_id=1
的行锁 - 同时,事务2执行"更新订单",拿到
orders
表中order_id=1001
的行锁 - 事务1继续执行"更新订单",需要
orders
表中order_id=1001
的行锁,但这把锁被事务2拿着,于是事务1开始等 - 事务2继续执行"扣减库存",需要
product_stock
表中product_id=1
的行锁,但这把锁被事务1拿着,于是事务2开始等 - 此时,事务1等事务2的锁,事务2等事务1的锁 —— 形成循环等待,死锁产生
为什么会这样:两个事务操作的是"同一批资源"(库存行 + 订单行),但更新的顺序相反,满足了"持有并等待"和"循环等待"两个条件。
场景2:"间隙锁"引发的死锁(容易被忽略)
业务背景:用户表user
有age
字段(无索引),两个事务同时按age
范围更新数据,InnoDB的"间隙锁"可能导致死锁。
事务1:
-- 更新age在10-20之间的用户状态
UPDATE user SET status = 1 WHERE age BETWEEN 10 AND 20;
事务2:
-- 更新age在15-25之间的用户状态
UPDATE user SET status = 1 WHERE age BETWEEN 15 AND 25;
死锁产生过程:
- InnoDB中,若查询条件是"范围"且字段无索引,会触发"间隙锁"(锁定一个范围的"间隙",防止幻读)
- 事务1执行时,
age BETWEEN 10 AND 20
会锁定(10,20)
之间的间隙(包括边界) - 事务2执行时,
age BETWEEN 15 AND 25
会锁定(15,25)
之间的间隙,这两个范围有重叠(15-20) - 此时,事务1可能需要访问事务2锁定的间隙(比如15-20),事务2也需要访问事务1锁定的间隙,双方互相等待,触发死锁
为什么会这样:间隙锁的"范围重叠"导致了循环等待,而且很多人容易忽略"无索引时范围查询会加间隙锁"这个特性,排查时往往想不到是锁的范围出了问题。
场景3:"事务长 + 锁竞争"引发的死锁(高并发下常见)
业务背景:秒杀系统,高并发下多个事务同时"创建订单→扣减库存→记录日志",事务执行时间长,持有锁的时间也长。
事务流程(每个秒杀请求):
-- 步骤1:创建订单(写orders表)
INSERT INTO orders (order_id, user_id, goods_id) VALUES (...);
-- 步骤2:扣减库存(更新goods表)
UPDATE goods SET stock = stock - 1 WHERE goods_id = 100;
-- 步骤3:记录秒杀日志(写seckill_log表)
INSERT INTO seckill_log (log_id, order_id) VALUES (...);
-- 步骤4:(业务逻辑处理,比如调用其他服务,耗时1-2秒)
-- 步骤5:提交事务
COMMIT;
死锁产生过程:
- 高并发下,事务A执行到步骤2(扣减库存,持有goods表行锁),但因为步骤4有业务逻辑,没及时提交
- 事务B也执行到步骤2,需要goods表的行锁,开始等事务A释放
- 事务C执行步骤1(创建订单),持有orders表的行锁,继续执行步骤2时,也开始等事务A释放goods表的锁
- 此时,事务A可能因为某种原因(比如需要查询订单状态),尝试访问事务C持有的orders表行锁,而事务C在等事务A的goods表行锁 —— 形成循环等待,死锁产生
为什么会这样:事务太长(步骤4耗时)导致锁持有时间久,高并发下锁竞争激烈,原本"单一方向"的等待可能因为"事务回查资源"变成循环等待。
三、死锁排查:3个工具 + 1个核心日志
遇到死锁别慌,MySQL自带了排查工具,关键是要找到"死锁时各事务持有了什么锁、在等什么锁"。掌握下面3个工具和1个核心日志,就能快速定位死锁根因。
1. 实时查看死锁:show engine innodb status
这是排查死锁的"第一手资料",执行后能看到最近一次死锁的详细信息(包括事务ID、SQL语句、锁信息等)。
操作步骤:在MySQL客户端执行:
show engine innodb status\G;
关键信息解读(以场景1的交叉更新为例):
------------------------
LATEST DETECTED DEADLOCK
------------------------
2024-05-20 10:00:00
*** (1) TRANSACTION:
TRANSACTION 12345, ACTIVE 2 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s)
MySQL thread id 10, OS thread handle 1234, query id 5678 localhost root updating
UPDATE orders SET status = 'PAID' WHERE order_id = 1001*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 10 page no 3 n bits 72 index PRIMARY of table `test`.`orders` trx id 12345 lock_mode X locks rec but not gap waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 5; compact format; info bits 0*** (2) TRANSACTION:
TRANSACTION 12346, ACTIVE 2 sec starting index read
mysql tables in use 1, locked 1
2 lock struct(s), heap size 1136, 1 row lock(s)
MySQL thread id 11, OS thread handle 5678, query id 5679 localhost root updating
UPDATE product_stock SET stock = stock - 1 WHERE product_id = 1*** (2) HOLDING THE LOCK(S):
RECORD LOCKS space id 10 page no 3 n bits 72 index PRIMARY of table `test`.`orders` trx id 12346 lock_mode X locks rec but not gap
Record lock, heap no 2 PHYSICAL RECORD: n_fields 5; compact format; info bits 0*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 11 page no 4 n bits 72 index PRIMARY of table `test`.`product_stock` trx id 12346 lock_mode X locks rec but not gap waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 3; compact format; info bits 0*** WE ROLL BACK TRANSACTION (1)
重点看这几点:
TRANSACTION 12345
和TRANSACTION 12346
:两个死锁的事务ID- 每个事务下的
UPDATE
语句:能看到事务在执行什么操作 WAITING FOR THIS LOCK TO BE GRANTED
:事务在等什么锁(比如事务12345在等orders表的X锁)HOLDING THE LOCK(S)
:事务已经持有什么锁(比如事务12346已经持有orders表的X锁)- 最后一行:MySQL选择回滚哪个事务(这里回滚了事务12345)
通过这些信息,能快速还原"谁持有什么锁、在等什么锁",进而定位到业务代码中的SQL顺序问题。
2. 监控锁等待:information_schema.innodb_locks和innodb_lock_waits
如果死锁不是"实时发生",而是偶尔出现,可以通过这两个系统表查看当前的"锁持有情况"和"锁等待情况"。
常用查询语句:
-- 查看当前持有锁的情况
SELECT * FROM information_schema.innodb_locks;-- 查看当前锁等待的情况(谁在等谁的锁)
SELECT r.trx_id waiting_trx_id, -- 等待锁的事务IDr.trx_mysql_thread_id waiting_thread, -- 等待锁的线程IDr.trx_query waiting_query, -- 等待锁的SQLb.trx_id blocking_trx_id, -- 持有锁的事务IDb.trx_mysql_thread_id blocking_thread, -- 持有锁的线程IDb.trx_query blocking_query -- 持有锁的SQL
FROM information_schema.innodb_lock_waits w
JOIN information_schema.innodb_trx b ON w.blocking_trx_id = b.trx_id
JOIN information_schema.innodb_trx r ON w.requesting_trx_id = r.trx_id;
结果解读:innodb_lock_waits
的结果会清晰显示"等待事务ID、等待的SQL、持有锁的事务ID、持有锁的SQL",比如场景1中,会看到"事务12345在等事务12346的锁,事务12346在等事务12345的锁",直接印证循环等待。
3. 开启死锁日志:innodb_print_all_deadlocks(推荐线上开启)
show engine innodb status
只能看到"最近一次死锁",如果死锁频繁发生,可能会覆盖之前的记录。开启innodb_print_all_deadlocks
后,MySQL会把所有死锁信息写入错误日志(error log),方便后续分析。
开启方式:
-
临时开启(重启MySQL后失效):
set global innodb_print_all_deadlocks = 1;
-
永久开启(修改my.cnf):
[mysqld] innodb_print_all_deadlocks = 1
日志位置:错误日志的位置可以通过show variables like 'log_error';
查看,通常在/var/log/mysql/error.log
(Linux)或C:\ProgramData\MySQL\MySQL Server 8.0\Data\XXX.err
(Windows)。
日志中会记录每次死锁的详细信息(和show engine innodb status
的死锁部分一致),方便追溯历史死锁。
四、死锁预防:5个实战方案(从代码到配置)
排查死锁是"事后补救",预防死锁才是"治本之策"。结合前面的死锁产生原因和条件,这5个方案能有效减少死锁发生的概率。
1. 统一资源访问顺序(打破循环等待)
这是预防"交叉更新"死锁最有效的方法 —— 让所有事务操作同一批资源时,按固定的顺序访问。
比如场景1中,两个事务都需要操作"库存行"和"订单行",可以约定"先操作库存行,再操作订单行",无论业务逻辑如何,都严格遵守这个顺序:
事务1(用户A支付):
-- 步骤1:先扣减库存(固定顺序第1步)
UPDATE product_stock SET stock = stock - 1 WHERE product_id = 1;
-- 步骤2:再更新订单(固定顺序第2步)
UPDATE orders SET status = 'PAID' WHERE order_id = 1001;
事务2(用户B支付):
-- 步骤1:先扣减库存(和事务1顺序一致)
UPDATE product_stock SET stock = stock - 1 WHERE product_id = 1;
-- 步骤2:再更新订单(和事务1顺序一致)
UPDATE orders SET status = 'PAID' WHERE order_id = 1001;
这样一来,事务2执行步骤1时,如果事务1已经持有库存行的锁,事务2会等待;但事务1执行步骤2时,不会再等事务2的锁(因为事务2还在等步骤1的锁),不会形成循环等待,死锁就不会产生。
实操技巧:可以给资源(比如订单ID、商品ID)排序,按"从小到大"或"从大到小"的顺序访问,比如"先操作ID小的行,再操作ID大的行"。
2. 减少锁持有时间(打破"持有并等待")
事务持有锁的时间越短,锁竞争的概率就越低,死锁也越难发生。核心是"让事务尽快提交",避免在事务中做"非必要操作"。
优化方案:
- 把"耗时的业务逻辑"放到事务外执行(比如调用第三方接口、复杂计算)
- 事务中只做"必要的数据库操作"(增删改查),执行完立即提交
- 避免在事务中使用
SELECT ... FOR UPDATE
(行级锁)做"预查询",除非确实需要
反例→正例:
-- 反例:事务中包含耗时业务逻辑,锁持有时间长
START TRANSACTION;
-- 步骤1:数据库操作(拿锁)
UPDATE goods SET stock = stock - 1 WHERE goods_id = 100;
-- 步骤2:耗时操作(调用第三方支付接口,耗时1秒)
-- 步骤3:数据库操作
INSERT INTO orders (...) VALUES (...);
COMMIT;-- 正例:事务只包含数据库操作,耗时操作放外面
-- 步骤1:先执行耗时操作(无锁)
call third_party_pay(...);
-- 步骤2:事务中只做数据库操作,快速提交
START TRANSACTION;
UPDATE goods SET stock = stock - 1 WHERE goods_id = 100;
INSERT INTO orders (...) VALUES (...);
COMMIT; -- 立即提交,释放锁
3. 避免间隙锁(针对范围查询)
间隙锁是InnoDB为了防止"幻读"引入的,但在某些场景下(比如字段无索引、范围查询)会导致锁范围扩大,引发死锁。可以通过以下方式减少间隙锁:
- 给查询字段加索引:InnoDB对有索引的字段做范围查询时,只会锁定"存在的行",不会锁定间隙(除非使用
FOR UPDATE
且是RR隔离级别) - 使用RC隔离级别:InnoDB在
READ COMMITTED
(RC)隔离级别下,会关闭间隙锁(除了外键约束和唯一性检查),减少锁范围 - 避免用"范围条件"更新数据:如果能明确"主键/唯一索引条件",就别用
BETWEEN
、IN
等范围条件
示例:
-- 反例:age无索引,范围更新触发间隙锁
UPDATE user SET status = 1 WHERE age BETWEEN 10 AND 20;-- 正例1:给age加索引(减少间隙锁范围)
ALTER TABLE user ADD INDEX idx_age (age);-- 正例2:用RC隔离级别(关闭间隙锁)
SET tx_isolation = 'READ-COMMITTED';-- 正例3:用具体条件代替范围(如果业务允许)
UPDATE user SET status = 1 WHERE age = 10 OR age = 11 OR ...; -- 明确值
4. 合理设置事务隔离级别
InnoDB的默认隔离级别是REPEATABLE READ
(RR),会启用间隙锁;而READ COMMITTED
(RC)隔离级别下会关闭间隙锁,虽然可能出现"不可重复读",但在大部分业务场景下是可接受的,且能减少死锁。
修改隔离级别:
-
临时修改(当前会话):
set session transaction isolation level read committed;
-
永久修改(my.cnf):
[mysqld] transaction-isolation = READ-COMMITTED
注意:RC隔离级别下,SELECT ... FOR UPDATE
仍会锁行,但不会锁间隙,适合对"幻读"要求不高的业务(如电商订单、用户管理)。
5. 主动重试死锁事务(最后一道防线)
即使做了前面的预防措施,高并发下仍可能出现死锁。此时可以在业务代码中"捕获死锁异常",主动重试事务,减少对用户的影响。
Java代码示例:
// 重试次数
private static final int MAX_RETRY = 3;public void processOrder() {int retryCount = 0;while (retryCount < MAX_RETRY) {try {// 执行事务操作transactionTemplate.execute(status -> {updateStock(); // 扣减库存updateOrderStatus(); // 更新订单return null;});break; // 成功则跳出循环} catch (Exception e) {// 判断是否是死锁异常if (e.getMessage().contains("Deadlock found when trying to get lock")) {retryCount++;log.warn("发生死锁,第{}次重试", retryCount);// 可选:短暂休眠,避免立即重试再次冲突Thread.sleep(100);} else {// 其他异常,直接抛出throw e;}}}
}
注意:重试次数不宜过多(建议3-5次),且重试间隔可以加个"随机值"(比如50-200ms),避免多个事务同时重试再次冲突。
五、总结:死锁处理的"黄金流程"
遇到死锁不用慌,记住这个"黄金流程",从排查到解决一步到位:
- 查日志:用
show engine innodb status
或错误日志(开启innodb_print_all_deadlocks
)获取死锁详情,明确"各事务持有什么锁、在等什么锁" - 找原因:根据锁信息和SQL语句,判断是"交叉更新"(顺序问题)、“间隙锁”(范围查询问题)还是"长事务"(锁持有时间问题)
- 做优化:按场景选方案 —— 交叉更新就统一访问顺序,间隙锁就加索引或降隔离级别,长事务就减少锁持有时间
- 留后手:代码中捕获死锁异常,主动重试,降低用户感知
死锁的本质是"资源竞争下的循环等待",只要能打破死锁的4个必要条件中的一个,就能有效减少死锁。实际开发中,最优先做的是"统一资源访问顺序"和"减少锁持有时间"—— 这两个方案成本低、效果好,大部分死锁都能通过这两点解决。