当两个事务分别持有部分资源锁,又同时等待对方释放另一部分锁时,MySQL就会抛出死锁错误,并回滚其中一个事务以保证系统继续运行。死锁并不是MySQL独有的问题,但在InnoDB的行级锁和间隙锁组合下,很多看似简单的并发更新也可能触发循环等待。

InnoDB锁机制与死锁的四个条件
死锁产生的理论基础是四个必要条件同时成立:互斥、持有并等待、不可剥夺、循环等待。InnoDB默认提供行级锁,事务对数据行加排他锁(X锁)后,其他事务必须等待;事务在等待新锁时不会释放已持有的锁;锁只能由持有者主动释放;当两个或多个事务的等待关系形成闭环时,死锁就发生了。
InnoDB的锁体系比单纯的记录锁更复杂。除了共享锁(S锁)和排他锁(X锁),还有意向锁(IS、IX)、记录锁、间隙锁和临键锁。在可重复读隔离级别下,为了防止幻读,InnoDB会对索引范围加间隙锁。间隙锁锁住的是索引记录之间的区间,这会使锁的粒度从一行扩大到一段范围。例如一个范围查询SELECT * FROM t WHERE id > 5 FOR UPDATE不仅锁住现有的id=6、7等记录,还会锁住5之后的所有间隙,此时另一个事务插入id=6就会被阻塞,即使那条记录在物理上并不存在。
索引选择对锁范围有决定性影响。如果SQL语句没有走索引,InnoDB无法精确定位要锁定的记录,就会退化为锁住全部记录甚至全表,这会急剧增大死锁概率。因此在分析死锁问题时,必须同时查看相关SQL的执行计划,确认索引是否被正确使用。
典型死锁场景复现与分析
最经典的死锁场景是两个事务按相反顺序更新相同的两行记录。假设有一张账户表accounts,包含id和balance字段。事务A先更新id=1,再更新id=2;事务B先更新id=2,再更新id=1。执行到这里时,事务A持有id=1的排他锁等待id=2的锁,事务B持有id=2的排他锁等待id=1的锁,等待图形成环,InnoDB检测到死锁并回滚其中一个事务。
下面用三个SQL片段模拟整个过程。先执行会话1:
-- 会话1 BEGIN; UPDATE accounts SET balance = balance - 100 WHERE id = 1; -- 持有id=1的X锁
然后切换到会话2执行:
-- 会话2 BEGIN; UPDATE accounts SET balance = balance - 200 WHERE id = 2; -- 持有id=2的X锁 UPDATE accounts SET balance = balance - 100 WHERE id = 1; -- 等待id=1的X锁,被会话1阻塞
最后回到会话1继续执行第二条更新:
-- 回到会话1继续执行 UPDATE accounts SET balance = balance - 200 WHERE id = 2; -- 等待id=2的X锁,形成循环等待,触发死锁
此时InnoDB会立刻检测到死锁,并自动回滚其中一个事务(通常是持有undo日志较少或事务权重较低的那个)。被回滚的事务会收到错误提示:Deadlock found when trying to get lock; try restarting transaction。另一个事务会正常提交。这种机制保证了系统不会无限等待,但业务代码必须处理这个错误并做重试。
间隙锁导致的死锁同样常见。例如事务A执行SELECT * FROM t WHERE id > 5 FOR UPDATE,锁住了5之后的间隙;事务B执行INSERT INTO t(id) VALUES(6),试图获取id=6所在间隙的插入意向锁,被事务A阻塞。接着事务B再执行INSERT INTO t(id) VALUES(7),又等待同一个间隙锁,而事务A此时如果也需要插入id=6,就会与事务B的插入意向锁冲突,形成更复杂的死锁环。这说明即使更新的行不同,间隙锁也可能让本不冲突的事务互相等待。
死锁日志分析与定位方法
当死锁发生时,MySQL会在SHOW ENGINE INNODB STATUS输出的LATEST DETECTED DEADLOCK部分记录详细信息。这份日志会列出涉及的事务ID、各自持有的锁、正在等待的锁、等待的SQL语句,以及最终回滚了哪个事务。学会阅读死锁日志是定位问题的关键一步。
下面是一段简化后的死锁日志:
------------------------ LATEST DETECTED DEADLOCK ------------------------ 2024-06-15 10:23:45 0x7f8a1c0a5700 *** (1) TRANSACTION: TRANSACTION 421234, ACTIVE 12 sec starting index read mysql tables in use 1, locked 1 LOCK WAIT 3 lock struct(s), heap size 1136, 2 row lock(s) MySQL thread id 8, OS thread handle 140234567890, query id 1234 localhost root updating UPDATE accounts SET balance = balance - 200 WHERE id = 2 *** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 12 page no 3 n bits 72 index PRIMARY of table `test`.`accounts` trx id 421234 lock_mode X locks rec but not gap waiting Record lock, heap no 2 PHYSICAL RECORD: n_fields 4; compact format; info bits 0 0: len 4; hex 80000002; asc ;; 1: len 6; hex 000000066d2e; asc m.; 2: len 7; hex b90000012d0110; asc - ;; 3: len 8; hex 8000000000000064; asc d;; *** (2) TRANSACTION: TRANSACTION 421235, ACTIVE 10 sec starting index read mysql tables in use 1, locked 1 3 lock struct(s), heap size 1136, 2 row lock(s) MySQL thread id 9, OS thread handle 140234512340, query id 1235 localhost root updating UPDATE accounts SET balance = balance - 100 WHERE id = 1 *** (2) HOLDS THE LOCK(S): RECORD LOCKS space id 12 page no 3 n bits 72 index PRIMARY of table `test`.`accounts` trx id 421235 lock_mode X locks rec but not gap Record lock, heap no 2 PHYSICAL RECORD: n_fields 4; compact format; info bits 0 ... *** (2) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 12 page no 3 n bits 72 index PRIMARY of table `test`.`accounts` trx id 421235 lock_mode X locks rec but not gap waiting Record lock, heap no 1 PHYSICAL RECORD: n_fields 4; compact format; info bits 0 ... *** WE ROLL BACK TRANSACTION (2)
阅读日志时重点关注每个事务的WAITING FOR THIS LOCK TO BE GRANTED和HOLDS THE LOCK(S)部分。事务1持有某个锁并等待另一个锁,事务2持有事务1等待的锁并等待事务1持有的锁,这种交叉关系就是死锁的直接证据。日志最后一行WE ROLL BACK TRANSACTION (2)表明MySQL选择回滚了事务2。
为了方便事后追溯,可以在配置文件中开启innodb_print_all_deadlocks = ON,让所有死锁信息都输出到MySQL错误日志。此外,performance_schema.data_locks和data_lock_waits视图可以实时查看锁持有和等待关系,适合在出现性能问题时手动分析。
预防与减少死锁的实践策略
死锁无法被完全消灭,但可以通过合理的设计把发生概率降到很低。首要原则是保持事务中的加锁顺序一致。如果多个事务都需要更新多行记录,约定按主键升序或统一的业务顺序访问,就能有效避免循环等待。例如所有事务都先更新id小的记录再更新id大的记录,就不会出现互相等待对方已持有锁的情况。
索引优化同样重要。更新和删除语句必须走索引,否则InnoDB会锁住大量记录甚至全表。对于范围查询,尽量缩小扫描范围,必要时拆分成多个较小批次。例如批量更新几万行数据时,可以按主键分段执行,而不是一次性UPDATE ... WHERE create_time > ?锁住大量间隙。
缩短事务时间是最直接的优化手段。将不必要的读操作、远程调用或用户交互移出事务,只保留关键写操作。一个常见错误是在事务中执行了耗时较长的查询或计算,导致锁持有时间被拉长,给其他事务制造了更多等待机会。
根据业务需要选择隔离级别。可重复读是MySQL默认级别,它使用间隙锁防止幻读,但也会增加死锁概率。如果业务不需要严格防止幻读,可以尝试将隔离级别降低为读已提交,这样间隙锁的使用会大幅减少,死锁风险也随之下降。不过降低隔离级别前必须评估数据一致性的影响。
最后,应用层一定要实现死锁重试机制。当捕获到MySQL错误码1213时,休眠一个短暂随机时间后重试整个事务,通常就能成功。下面是一段Java风格的重试逻辑示例:
int retryCount = 0;
while (retryCount < 3) {
try {
beginTransaction();
// 执行数据库操作
commit();
break;
} catch (DeadlockLoserDataAccessException e) {
rollback();
retryCount++;
Thread.sleep(50 * retryCount);
}
}
死锁是多事务并发下难以避免的现象,但通过理解InnoDB的锁行为、规范事务设计、优化索引和建立完善的监控排查机制,完全可以把死锁对业务的影响控制在可接受范围内。定位问题时抓住锁等待图这条主线,结合死锁日志和实时锁视图,大部分死锁都能快速找到根源。