1. 导致死锁的原因#

在 MySQL(尤其是 InnoDB 存储引擎)里,常见的死锁往往与行锁(Row Lock)、间隙锁(Gap Lock)、Next-Key Lock 等锁机制的细节紧密相关。要理解为什么会出现死锁,需要先明白 InnoDB 是如何在事务中为数据上锁的,以及不同类型的锁是如何导致冲突的。

  • 行锁(Row Lock):对索引记录本身加的锁(Record Lock)

  • 间隙锁(Gap Lock):对索引记录之间的间隙加的锁,用于防止幻读(Phantom Read)

  • Next-Key Lock:它实际上是“记录锁 + 间隙锁”的组合锁,会锁住“当前索引记录”以及紧邻的一段间隙

MySQL InnoDB 的默认隔离级别是 REPEATABLE READ。在该隔离级别下,对索引进行范围查询时,为了避免幻读,InnoDB 通常会通过 Next-Key Lock 把记录本身和相邻间隙一起锁定。正是因为这种锁定方式,当多事务并发执行时,如果它们的锁定范围出现交叠或顺序不一致,就比较容易引发死锁。

1.1. 更新操作顺序不一致 普通行x锁#

这是最直观的死锁场景之一, 也是很多人最先想到的场景, 本质原因是两个事务以不同的顺序加锁, 从而彼此等待。先来举一个简单的例子(无 Gap Lock 干扰), 只用普通行x锁。

场景: 事务 A 先更新 id=1 的记录,再更新 id=2 的记录;事务 B 先更新 id=2 的记录,再更新 id=1 的记录。

如果两个事务并行执行,就可能出现这样的顺序:

  1. 事务 A: UPDATE t_user SET balance = balance - 10 WHERE id = 1; 成功锁住 id=1
  2. 事务 B: UPDATE t_user SET balance = balance - 20 WHERE id = 2; 成功锁住 id=2
  3. 事务 A: UPDATE t_user SET balance = balance + 10 WHERE id = 2; 要锁 id=2,但是这把锁被事务 B 占用,需要等待
  4. 事务 B: UPDATE t_user SET balance = balance + 20 WHERE id = 1; 要锁 id=1,但是这把锁被事务 A 占用,需要等待

此时 A 等 B 释放锁,B 等 A 释放锁,形成死锁, 由于 InnoDB 检测到死锁,会自动回滚其中一个事务, 这种多发生在批量更新的情况, 可以标准化更新顺序,例如按 id 升序更新:

-- 事务 A 和 B 都按 order_id 升序更新
UPDATE orders SET status = 'xxx' WHERE order_id IN (1, 2) ORDER BY order_id;

1.2. 间隙锁导致的死锁#

下面这个场景更具代表性, 也更体现出 MySQL Next-Key Lock 如何导致死锁, 很多开发者在进行范围查询或插入时, 会因为不理解 Gap Lock 的触发条件而踩坑:

CREATE TABLE products (
    id INT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(50),
    price DECIMAL(10, 2),
    -- 间隙锁只会加在索引上, 如果不是索引则退化为全表扫描, 索引也分为唯一和不唯一索引
    UNIQUE KEY (price)
);

假设现在我们的表里有四条记录, price 分别是 1.00, 2.50, 3.00, 4.00,

假设有两个事务, 它们都使用了范围查询, 由于 MySQL 默认隔离级别是 REPEATABLE READ, 所以范围查询 当前读会触发 Next-Key Lock, 导致锁住记录本身以及相邻的“间隙”:

  • T1 执行 SELECT * FROM products WHERE price BETWEEN 2.00 AND 3.00 FOR UPDATE; 这是一个当前读 FOR UPDATE, 虽然 price 唯一, 但这是范围查询, 所以会加 Next-Key Lock

  • 锁定范围:

    • price = 2.50:Record Lock
    • price = 3.00:Record Lock
    • 间隙:(1.00, 2.50) 和 (2.50, 3.00) 和 (3.00, 4.00)
  • T2 执行 SELECT * FROM products WHERE price BETWEEN 3.00 AND 4.00 FOR UPDATE; 尝试锁住 price=3.00price=4.00 以及与其相邻的间隙

  • 由于 T1 已经锁住了 price=3.00, T2 在尝试加锁 price=3.00 时会被阻塞,等待 T1 释放

  • 如果这时候 T1 在同一个事务里又做了其他操作(例如插入一条 price=3.50 的记录), 因为插入需要先获得插入意向锁即 (3.00, 4.00) 这个间隙锁,而这个间隙锁已经被 T2 拿到了一部分,也会等待

这样两边都在等对方释放锁, 就会出现死锁,

1.3. 外键引起#

假设我们有两个表:Parent 和 Child, Child 表有一个外键引用 Parent 表的主键,

  1. 事务 A

    • 启动事务后,执行

      DELETE FROM Parent WHERE id = 1;
      
    • InnoDB 对 Parent 表中 id = 1 这条记录加上 排它锁(X 锁)

    • 因为有外键约束,删除时必须确认 Child 表中是否还存在引用 Parent(id=1) 的行;如果存在并且外键约束是不允许删除(例如 ON DELETE RESTRICT),那就会报错阻止删除;如果是 ON DELETE CASCADE,则要继续删除 Child 表里相应的记录

    • 无论哪种外键策略,InnoDB 都会去扫描 Child 表是否有 parentId = 1 的记录,并尝试对这些子记录(或相应索引)加锁

  2. 事务 B

    • 事务 A 还没结束时,事务 B 执行插入:

      INSERT INTO Child (id, parentId, ...) VALUES (101, 1, ...);
      
    • InnoDB 在往 Child 表里插入新行之前,会先在 Child 表对应的索引上加 插入意向锁(或间隙锁)

    • 接着,为了保证外键引用的有效性,需要去 Parent 表里确认 id=1 存在,这就要去给 Parent(id=1) 这条记录请求 锁(通常是 S 锁或者意向锁)

    • 但是由于事务 A 已经对 Parent(id=1) 持有了 X 锁,事务 B 在此时会被阻塞

  3. 进入相互等待

需要注意的点是, 在现实里要形成上述场景, 往往是因为:

  • 事务 A 删除 Parent 的时候,ON DELETE RESTRICT 或者 ON DELETE NO ACTION 外键策略没有立即报错, 而是先尝试获取 Child 那边的锁去检查/删除,这时刚好被另一个事务 B 插入或更新冲突;或者
  • 事务 A 是 “先删父,再删子” 但中间不小心被打断,事务 B 恰好要插入/更新同样引用的父记录从而引发冲突。

2. 如何排查死锁#

在 MySQL(InnoDB 引擎)中,当 InnoDB 检测到死锁时,会主动回滚其中一个事务,并将死锁相关信息记录到 SHOW ENGINE INNODB STATUS 命令输出中。

2.1. 查看异常日志#

  • Spring Boot 日志:在 Spring Boot 的运行日志中,通常会看到类似 Deadlock found when trying to get lock; try restarting transaction 的异常信息,这意味着某条 SQL 在执行时被 MySQL 判定发生死锁,从而回滚
  • MySQL 错误日志:有时还可以在 MySQL 服务器的错误日志中找到死锁相关信息(如果有开启记录)

2.2. 使用 SHOW ENGINE INNODB STATUS 命令#

在 MySQL 客户端或者其他数据库管理工具中,执行:

SHOW ENGINE INNODB STATUS \G

------------------------
LATEST DETECTED DEADLOCK
------------------------
2025-03-06 14:37:07 0x7f9bd9513700
*** (1) TRANSACTION:
TRANSACTION 598373, ACTIVE 7 sec
3 lock struct(s), heap size 1136, 2 row lock(s), undo log entries 1
MySQL thread id 100, OS thread handle 140309172537088, query id 225 server 127.0.0.1 user update
INSERT INTO log_record (account_id, operation) VALUES (1, 'minus 100')
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 63 page no 4 n bits 72 index `PRIMARY` of table `test`.`log_record`
trx id 598373 lock_mode X locks rec but not gap waiting
Record lock, heap no 3 PHYSICAL RECORD: n_fields 5; compact format; info bits 0
...

*** (2) TRANSACTION:
TRANSACTION 598374, ACTIVE 5 sec
2 lock struct(s), heap size 1136, 2 row lock(s), undo log entries 1
MySQL thread id 101, OS thread handle 140309172538112, query id 226 server 127.0.0.1 user update
UPDATE account SET balance = balance - 50 WHERE id = 1
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 62 page no 5 n bits 80 index `PRIMARY` of table `test`.`account`
trx id 598374 lock_mode X
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 63 page no 4 n bits 72 index `PRIMARY` of table `test`.`log_record`
trx id 598374 lock_mode X locks rec but not gap waiting
...

*** WE ROLL BACK TRANSACTION (2)

这条命令可以查看最近一次死锁的详细信息,包括:

  • 具体冲突的表、行、SQL 语句
  • 形成死锁的事务持有哪些锁、在等待哪些锁(Record lock、Gap lock、Next-key lock)

根据这些信息, 我们可以判断代码中的 SQL 执行顺序, 因为多个事务以不同顺序申请锁而导致的死锁, 可以在业务层确保所有事务对资源的加锁顺序一致(例如,按主键 ID 升序加锁)

也可以使用使用 EXPLAIN 分析 SQL 的执行计划, 检查 SQL 是否命中索引, 因为死锁也有可能是由于缺少合适的索引可能导致锁范围扩大(整张表)进而提高死锁概率,

根据上面输出:

*** (1) TRANSACTION:
TRANSACTION 598373, ACTIVE 7 sec
3 lock struct(s), heap size 1136, 2 row lock(s), undo log entries 1
MySQL thread id 100, OS thread handle 140309172537088, query id 225 server 127.0.0.1 user update
INSERT INTO log_record (account_id, operation) VALUES (1, 'minus 100')
  • 该事务(1) 持有 3 个锁结构, 其中包括 2 个行锁: 3 lock struct(s), 2 row lock(s)
  • 正在对表 ⁠log_record 进行 INSERT 操作
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 63 page no 4 n bits 72 index `PRIMARY` of table `test`.`log_record`
trx id 598373 lock_mode X locks rec but not gap waiting
Record lock, heap no 3 PHYSICAL RECORD: n_fields 5; compact format; info bits 0
  • 当前事务正在等待获取一个锁,但锁还未被授予, 换句话说,有另一个事务持有锁,导致当前事务被阻塞
  • index PRIMARY of table test.log_record
    • index PRIMARY: 锁的目标是表的主键索引(primary key index)
    • table test.log_record: 锁发生在数据库 test 中的表 log_record 上
  • lock_mode X locks rec but not gap waiting
    • lock_mode X: 表示这是一个排他锁
    • locks rec but not gap: 表示这是一个记录锁 record lock, 而不是 间隙锁 gap lock, 记录锁锁定特定的行, 间隙锁锁定行之间的范围(常用于防止幻读)
    • waiting: 当前事务正在等待, 因为另一个事务已经持有了锁

3. 避免死锁#

统一加锁顺序

  • 例如:如果业务逻辑需要在一笔交易中更新 A 表B 表,就应统一先更新 A 表 再更新 B 表(或者反之),避免在不同事务中出现“先 A 后 B”与“先 B 后 A”的交叉
  • 这种统一顺序减少了在数据库级别发生环形等待的可能

尽量缩短事务执行时间

  • 在事务中只放真正需要保证原子性和一致性的 SQL 操作,把其他耗时操作(如外部 HTTP 调用、文件操作)放在事务外执行
  • 事务持有锁的时间越短,发生死锁的窗口就越小

查看执行计划并监控慢查询

  • 除了使用 EXPLAIN 分析单条 SQL 的执行计划外,可配合开启 MySQL 慢查询日志,关注是否存在长时间占用锁的慢 SQL
  • 如果出现大量慢查询,也意味着事务执行时间变长,更容易导致死锁和高锁等待