在多线程并发环境中,死锁是事务相互等待对方释放锁资源而形成的僵局。MySQL InnoDB 引擎具备自动检测死锁的能力,但死锁仍然可能导致事务回滚,影响业务正常运行。理解死锁的产生条件、检测机制和处理策略,可以帮助你从根源上减少死锁,并在发生时快速定位问题。
6.5.1 死锁的四个必要条件
从操作系统的经典理论看,死锁必须同时满足以下四个条件:
- 互斥条件:资源每次只能被一个事务占用,例如某行数据的排他锁不能被共享。
- 请求与保持:事务已经持有一个资源(如行 A 的锁),又去申请另一个资源(如行 B 的锁),而该资源被其他事务持有,此时事务进入等待,但不会释放已持有的资源。
- 不可剥夺:事务已获得的锁在未使用完前,不能被其他事务强行夺走,只能由持有者自己释放。
- 循环等待:存在一个事务等待链,形成闭环。如事务 T1 等待 T2 持有的资源,T2 等待 T3 持有的资源,T3 又在等待 T1 持有的资源。
在 MySQL 的场景下,资源主要指行级锁(记录锁、间隙锁、临键锁)。只要四个条件同时成立,死锁就会发生。在实际开发中,破坏任一条件就能防止死锁,但前面三个条件是锁机制本身固有的,因此可行的方法往往是破坏循环等待条件,即让事务按一致的顺序申请资源。
6.5.2 InnoDB 的死锁检测机制
InnoDB 并不是被动地等待事务超时,而是主动进行死锁检测。检测算法基于等待图(Wait-for Graph),系统会维护一张有向图,节点代表活跃事务,有向边表示一个事务在等待另一个事务持有的锁。
当有事务请求锁但无法立即获得时,会触发一次死锁检测。检测器从该事务出发遍历等待图,如果发现有回路存在,就判定出现了死锁。此时 InnoDB 会自动选择一个“代价最小”的事务进行回滚(释放其持有的锁),从而打破死锁。通常选择回滚代价最小的事务,例如插入、更新或删除的行数最少的事务,或者根据 innodb_rollback_on_timeout 配置来决定行为。
相关的核心参数:
innodb_deadlock_detect(默认 ON):控制是否开启死锁检测。如果关闭,InnoDB 会依赖innodb_lock_wait_timeout参数设定的超时时间来让事务等待超时而回滚,这在高并发且死锁频繁的场景下可能有助于降低检测开销,但会增加响应延迟。建议在极高并发且明显观察到死锁检测成为瓶颈时才考虑关闭。innodb_lock_wait_timeout:事务等待行锁的超时时间,默认 50 秒。这个参数在死锁检测关闭后尤其重要,等待超时的事务也会被回滚。
当死锁发生时,MySQL 会记录两条关键信息:
- 错误日志:包含时间、事务 ID、涉及的 SQL 语句和锁信息。
SHOW ENGINE INNODB STATUS输出的LATEST DETECTED DEADLOCK部分,详细展示了发生死锁时的两个事务各自持有哪些锁、在请求什么锁,以及回滚了哪个事务。
例如,典型的 SHOW ENGINE INNODB STATUS 死锁信息片段:
------------------------
LATEST DETECTED DEADLOCK
------------------------
2024-01-10 14:30:15 0x7f8b3c001700
*** (1) TRANSACTION:
TRANSACTION 4215, 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 140228727367424, query id 125 localhost root updating
UPDATE orders SET status='paid' WHERE id=102
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 46 page no 3 n bits 80 index PRIMARY of table `db`.`orders` trx id 4215 lock_mode X locks rec but not gap waiting
Record lock, heap no 5 PHYSICAL RECORD: ...
*** (2) TRANSACTION:
TRANSACTION 4216, ACTIVE 8 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 140228726761216, query id 126 localhost root updating
UPDATE orders SET amount=99 WHERE id=101
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 46 page no 3 n bits 80 index PRIMARY of table `db`.`orders` trx id 4216 lock_mode X locks rec but not gap
Record lock, heap no 5 PHYSICAL RECORD: ...
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 46 page no 3 n bits 80 index PRIMARY of table `db`.`orders` trx id 4216 lock_mode X locks rec but not gap waiting
Record lock, heap no 4 PHYSICAL RECORD: ...
*** WE ROLL BACK TRANSACTION (2)
从上面可以看出,两个事务互相持有对方需要的锁,InnoDB 回滚了事务(2)。
6.5.3 死锁的常见场景与成因
死锁并不是数据库 bug,而是并发访问下逻辑设计不合理造成的。常见场景包括:
- 不同事务以相反的顺序锁定资源:这是最常见的死锁原因。例如,事务 A 先更新表
accounts中 id=1 的记录,再更新 id=2;事务 B 却先更新 id=2,再更新 id=1。当两个事务同时交错执行时,很容易形成循环等待。
- 主键/唯一索引冲突导致共享锁升级:事务 A 插入一条记录,在插入前会获取该行的插入意向锁,如果发现有重复键冲突,事务 A 会向已有记录施加共享锁(S 锁)来等待重复键检查,若此时另一个事务 B 也插入相同键值并持有共享锁,双方就可能互相等待对方释放共享锁,形成死锁。
- 间隙锁并发引起的死锁:在可重复读隔离级别下,事务使用了间隙锁来防止幻读。多个事务在不同位置插入记录时,如果间隙锁交织,也容易产生死锁。例如,事务 A 锁定 id>10 and id<20 的间隙,事务 B 也锁定了同样的间隙,然后双方都试图插入一条记录,就会导致互相等待。
- 长事务持有锁过久:事务越长,持有的锁时间越长,与其他事务产生冲突的概率就越大。尤其在批量更新或混合读写的事务中,可能锁定大量行,增加了死锁风险。
6.5.4 死锁的排查步骤
当业务报出死锁错误(Error 1213: Deadlock found when trying to get lock; try restarting transaction)时,可以按照以下步骤定位问题:
- 查看死锁信息:使用
SHOW ENGINE INNODB STATUS命令,找到LATEST DETECTED DEADLOCK段,分析死锁双方执行了哪些 SQL、持有哪些锁、等待什么锁。注意该信息只保留最近一次死锁的详情。
- 开启死锁日志记录:如果死锁发生频率较高,可以开启
innodb_print_all_deadlocks参数(默认 OFF),这样每次死锁都会记录到错误日志中,方便事后回溯。
- 分析应用日志:结合业务请求的日志,找到同时期执行的事务序列,看是否存在不一致的加锁顺序。
- 检查事务代码:找出发生死锁的事务对应的业务代码,观察它们对表的访问顺序、是否在循环中执行操作、是否包含不必要的
SELECT ... FOR UPDATE等显式锁定。
6.5.5 处理与预防策略
死锁无法完全避免,但可以被减少到对业务无影响的程度。处理策略包括:
- 重试机制:这是最直接的应对方法。当捕获到死锁异常(如 Java 中
SQLTransactionRollbackException对应 MySQL 的 1213 错误码)时,应用程序应简单地重新执行事务。因为死锁只是瞬时的锁冲突,重试后大概率会成功。对于幂等性操作,可以封装一层重试逻辑;非幂等操作可能需要结合业务逻辑回退状态。大部分框架(如 Spring)可以配置死锁重试。
- 统一加锁顺序:在设计事务时,确保所有事务以相同的顺序访问和锁定资源。例如,规定必须先更新账户 A 再更新账户 B,所有相关事务都遵循此顺序,即可破坏循环等待条件。
- 缩短事务长度:将不影响一致性的查询、计算逻辑移到事务外,减少事务持有锁的时间。尽量不要在事务中执行远程调用或等待用户输入。
- 使用更细粒度的锁或更低隔离级别:如果业务允许,可以将隔离级别降低为读已提交(
READ COMMITTED),此时不会使用间隙锁,能显著减少因间隙锁引发的死锁。但要评估幻读风险是否可接受。
- 合理使用索引:确保更新或删除时能够通过索引快速定位行,避免因为全表扫描而锁住大量不需要的行,减少锁冲突范围。
- 分解大型事务:如果一个事务需要更新大量行(如批量结算),可以分段提交,将一个大事务拆分成多个小事务,每个事务只锁定一部分行,降低冲突概率。
- 使用悲观锁或乐观锁替代部分场景:在并发冲突概率高的热点行上,可以考虑用
SELECT ... FOR UPDATE先锁定所需资源,但要注意这可能会增加等待。另一种方式是使用乐观锁(版本号或时间戳),将冲突检测放在应用层,避免数据库锁等待。
6.5.6 死锁与业务设计的关系
需要明确的是,InnoDB 自动检测并回滚死锁事务,本质上是保护数据库不会永久卡死。但被动回滚会带来事务失败,业务上可能表现为用户操作失败。因此,死锁不仅仅是 DBA 关心的问题,更是开发者在设计业务流程时必须考虑的因素。
在设计高并发系统时,应预先评估热点资源是否会导致锁争抢。比如秒杀场景下对同一库存记录的扣减,如果使用数据库行锁保证不超卖,大量的请求会排队等锁,虽然不是死锁,但性能极差;而用 Redis 缓存或预扣库存可以避免数据库层面的锁冲突。如果数据库不可避免,可以考虑用排队机制将并发转为串行,或者用分布式锁来协调。
死锁排查和预防需要结合业务逻辑,单纯调参数无法根治。每次发生死锁,都应该弄清楚“为什么这两个事务会互相等待”,然后从架构和代码层面消除这个原因,这才是解决死锁的正确思路。