死锁是指两个或多个事务在执行过程中,因争夺锁资源而造成的一种互相等待的现象。具体到MySQL的InnoDB存储引擎,当事务A持有某行记录的排他锁并请求事务B持有的锁,同时事务B也在等待事务A已持有的锁,就会形成循环等待链,数据库无法自行解开这种僵局。MySQL的死锁检测机制会在发现循环等待后,选择一个代价较小的事务进行回滚,从而让其他事务继续执行。

死锁的底层原理与InnoDB锁机制
InnoDB实现了行级锁,主要包括共享锁(S锁)和排他锁(X锁)。共享锁允许事务读取一行,多个事务可同时持有;排他锁则用于修改,同一时刻只能有一个事务持有。锁的兼容性矩阵决定了不同锁之间能否共存:S锁与S锁兼容,S锁与X锁互斥,X锁与任何锁都互斥。当事务执行UPDATE、DELETE或显式SELECT ... FOR UPDATE时,会对涉及行加X锁。
除了记录锁,InnoDB还有间隙锁(Gap Lock)和临键锁(Next-Key Lock)。在可重复读隔离级别下,为了防止幻读,InnoDB默认使用临键锁,它锁定记录本身及前面的间隙。这意味着即使某行不存在,事务也能锁住一个范围,从而更容易在并发插入或更新时引发死锁。理解这些锁的类型,是分析死锁成因的前提。
当多个事务以相反顺序访问资源时,死锁极易发生。例如事务一先更新用户表再更新订单表,事务二先更新订单表再更新用户表,在并发场景下二者可能分别持有对方所需的锁。InnoDB内部维护了一个等待图(wait-for graph),当检测到图中存在环时即判定为死锁,随后抛出错误码1213并回滚其中一个事务。
MySQL死锁产生的常见原因与示例
最常见的死锁场景是不同的加锁顺序。下面通过两段伪代码展示问题所在。第一段事务先锁用户后锁订单,第二段则相反,高并发时就会冲突。
-- 事务A START TRANSACTION; UPDATE user SET balance=balance-100 WHERE id=1; UPDATE order SET status=1 WHERE id=10; COMMIT; -- 事务B START TRANSACTION; UPDATE order SET status=1 WHERE id=10; UPDATE user SET balance=balance-100 WHERE id=1; COMMIT;
另一个原因是间隙锁与插入意向锁的冲突。在可重复读级别下,事务A对某个范围加间隙锁,事务B尝试插入该范围的新记录时需要获取插入意向锁,二者互斥;若此时A又反过来请求B持有的记录锁,便形成死锁。此外,二级索引上的加锁也会回溯到主键,增加锁的复杂度。
还有一类死锁来自外键约束。当子表插入数据时会去父表加S锁校验存在性,若父表事务同时更新子表相关记录,也可能交叉等待。通过SHOW ENGINE INNODB STATUS可以查看最近一次死锁的详细信息,包括每个事务持有的锁和等待的锁,对排查非常有帮助。
如何避免与解决MySQL死锁
最根本的避免方式是统一资源的访问顺序。所有业务代码对多张表或多行记录的更新,都应约定相同的顺序,比如永远先改user再改order。这样就不会出现循环等待。同时应尽量缩短事务长度,把非数据库操作(如远程调用、文件处理)移到事务之外,减少锁的占用时间。
在隔离级别上,如果业务允许,可考虑将隔离级别降为读已提交(READ COMMITTED),此时InnoDB会禁用间隙锁,仅用记录锁,能显著降低死锁概率,但需自行处理幻读风险。另外,为频繁更新的表建立合适索引也很关键,因为缺乏索引会导致锁升级为表级锁或扫描更多行,扩大锁冲突面。
应用层必须做好死锁重试。当捕获到MySQL返回的1213错误时,不应视作严重故障,而应使用指数退避策略重新执行该事务。以下Java片段演示了基础的重试逻辑:
int retries = 3;
while (retries-- > 0) {
try {
executeTransaction();
break;
} catch (SQLException e) {
if (e.getErrorCode() == 1213) {
Thread.sleep(50);
continue;
}
throw e;
}
}
最后,定期通过监控工具收集死锁日志,分析高频死锁的SQL模式,从表结构或业务流程上做优化。死锁在并发系统中难以彻底消失,但通过上述手段可以将其频率降到可接受范围,保障系统稳定。