数据库死锁以及排查
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
的记录。
如果两个事务并行执行,就可能出现这样的顺序:
- 事务 A:
UPDATE t_user SET balance = balance - 10 WHERE id = 1;
成功锁住id=1
行 - 事务 B:
UPDATE t_user SET balance = balance - 20 WHERE id = 2;
成功锁住id=2
行 - 事务 A:
UPDATE t_user SET balance = balance + 10 WHERE id = 2;
要锁id=2
,但是这把锁被事务 B 占用,需要等待 - 事务 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.00
、price=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 表的主键,
-
事务 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
的记录,并尝试对这些子记录(或相应索引)加锁
-
-
事务 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 在此时会被阻塞
-
-
进入相互等待
需要注意的点是, 在现实里要形成上述场景, 往往是因为:
- 事务 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 - 如果出现大量慢查询,也意味着事务执行时间变长,更容易导致死锁和高锁等待