MySQL死锁问题
# MySQL死锁问题
# 前言
在日常开发中,我们经常会遇到数据库死锁问题。就像交通堵塞一样,当多个事务互相等待对方释放资源时,就形成了死锁。本文将围绕InnoDB存储引擎中的死锁问题,详细介绍相关概念、产生原因、典型场景以及解决方案。
版本说明
本文写于 2022-03。
InnoDB 死锁检测机制与排查思路未变,经 2026-07 复核仍适用。
# 相关概念
# 并发控制
并发控制(Concurrency Control)是数据库管理系统用于保证数据一致性的重要机制。
# 锁的类型
MySQL实现并发控制主要通过两种类型的锁:
- 共享锁(Shared Lock,S锁):也叫读锁,允许多个事务同时读取同一资源
- 排他锁(Exclusive Lock,X锁):也叫写锁,一个事务获取了写锁后,其他事务无法获取该资源的读锁或写锁
# 锁粒度
InnoDB存储引擎支持多种粒度的锁:
- 表锁:锁定整张表,开销小但并发度低
- 行锁:只锁定涉及的数据行,开销较大但并发度高
- 间隙锁(Gap Lock):锁定索引记录之间的间隙,防止幻读
- Next-Key Lock:行锁和间隙锁的组合
# 事务的特性
事务必须满足ACID特性:
- 原子性(Atomicity):事务是不可分割的工作单位,要么全部执行,要么全部回滚
- 一致性(Consistency):事务执行前后数据库必须保持一致状态
- 隔离性(Isolation):事务执行过程中的中间状态对其他事务不可见
- 持久性(Durability):事务一旦提交,其修改将永久保存在数据库中
# 事务隔离级别
MySQL支持四种事务隔离级别:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 |
| READ COMMITTED | 不可能 | 可能 | 可能 |
| REPEATABLE READ(默认) | 不可能 | 不可能 | 可能 |
| SERIALIZABLE | 不可能 | 不可能 | 不可能 |
(这张表是 SQL 标准的定义。InnoDB 在 RR 下已用 MVCC + next-key lock 堵住了幻读,细节见 MySQL的事务隔离级别。本文关心的是它的副作用:间隙锁正是死锁的主要来源。)
# 死锁的定义与危害
# 什么是死锁
死锁是指两个或多个事务在执行过程中,因争夺资源而造成的一种互相等待的现象。比如:
- 事务A持有资源1,等待资源2
- 事务B持有资源2,等待资源1 这样两个事务就陷入了互相等待的死锁状态。
# 死锁的危害
- 事务无法完成,系统资源被长期占用
- 降低系统性能和吞吐量
- 可能引发连锁反应,导致更多事务被阻塞
# 死锁的典型场景
以下示例基于InnoDB存储引擎,隔离级别为REPEATABLE READ。
# 场景一:互相请求对方持有的锁
-- 准备数据
CREATE TABLE `test` (
`id` int NOT NULL,
`value` int DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB;
INSERT INTO test VALUES (1, 10), (2, 20);
-- Session A
START TRANSACTION;
UPDATE test SET value = 11 WHERE id = 1;
-- Session B
START TRANSACTION;
UPDATE test SET value = 21 WHERE id = 2;
-- Session A
UPDATE test SET value = 12 WHERE id = 2; -- 等待Session B释放锁
-- Session B
UPDATE test SET value = 22 WHERE id = 1; -- 死锁发生
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 场景二:间隙锁导致的死锁
先说一个常见的错误示例——很多文章会这样写:
-- Session A
START TRANSACTION;
SELECT * FROM orders WHERE status = 1 FOR UPDATE;
-- Session B
START TRANSACTION;
INSERT INTO orders VALUES (5, 1); -- 被 A 的间隙锁阻塞
-- Session A
INSERT INTO orders VALUES (6, 1); -- 号称"死锁发生"
2
3
4
5
6
7
8
这个例子复现不出来。A 自己插入不会被自己持有的间隙锁挡住(锁是按事务归属的),所以 A 的 INSERT 直接成功,B 继续等,不构成环路——只是单向阻塞。死锁必须是两个事务互相等对方。
真正能稳定复现间隙锁死锁的是下面这个:两个事务先各自拿到同一段间隙的共享间隙锁(S-gap),再各自申请插入意向锁(II-gap)。间隙锁之间是互相兼容的,所以两边都能拿到;但插入意向锁与别人的间隙锁冲突,于是两边都在等对方放手。
CREATE TABLE `orders` (
`id` int NOT NULL,
`status` int DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB;
INSERT INTO orders VALUES (1, 1), (10, 1);
-- 隔离级别必须是 RR(RC 没有间隙锁,复现不了)
2
3
4
5
6
7
8
9
| 时序 | Session A | Session B |
|---|---|---|
| ① | BEGIN;SELECT * FROM orders WHERE id = 5 FOR SHARE;→ 空结果,但在 (1,10) 上拿到共享间隙锁 | |
| ② | BEGIN;SELECT * FROM orders WHERE id = 6 FOR SHARE;→ 同一间隙,间隙锁互相兼容,也拿到了 | |
| ③ | INSERT INTO orders VALUES (5, 1);→ 申请插入意向锁,被 B 的间隙锁挡住,开始等 | |
| ④ | INSERT INTO orders VALUES (6, 1);→ 同样被 A 的间隙锁挡住 → 环路成立,死锁 |
第 ④ 步会立刻报错:
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
这个模式在业务里对应的是最经典的一类写法:「先查有没有,没有就插入」(check-then-insert)。两个并发请求查同一个不存在的键,各自拿到同一段间隙锁,然后各自去插——线上大量「幂等写入」的代码都长这样。
正解不是加重试,而是别用 check-then-insert:给业务键建唯一索引,直接 INSERT ... ON DUPLICATE KEY UPDATE(见 insert on duplicate key update 的理解),让唯一索引去做冲突判定,不要自己先 SELECT 一次。
# 死锁的预防和处理
# 预防措施
按固定顺序访问表和行
- 对多个表进行操作时,应该按照相同的顺序
- 批量更新数据时,按主键或索引顺序进行更新
合理设计事务
- 保持事务尽量短小
- 一次性锁定所需要的所有资源
- 避免事务中的用户交互
优化表结构和索引
- 合理设计索引,避免全表扫描
- 避免使用过多的锁定范围
应用层优化
- 使用乐观锁替代悲观锁
- 适当的重试机制
- 合理设置事务隔离级别
# 死锁检测和处理
InnoDB有两种处理死锁的方式:
等待超时
- 通过
innodb_lock_wait_timeout参数设置 - 默认值为50秒
- 通过
死锁检测
- 通过
innodb_deadlock_detect参数控制 - 发现死锁后,会回滚代价较小的事务
- 通过
-- 查看和设置等待超时时间
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';
SET GLOBAL innodb_lock_wait_timeout = 50;
-- 查看死锁检测状态
SHOW VARIABLES LIKE 'innodb_deadlock_detect';
2
3
4
5
6
# 怎么读死锁日志
前面所有分析都建立在「你知道是哪两条 SQL 撞上了」的前提上。线上死锁是随机发生的,抓现场只有一个办法——读 InnoDB 的死锁记录。
默认只能看到最近一次死锁:
SHOW ENGINE INNODB STATUS\G
在输出里找 LATEST DETECTED DEADLOCK 段落:
------------------------
LATEST DETECTED DEADLOCK
------------------------
2026-08-24 15:02:11 0x7f1c
*** (1) TRANSACTION:
TRANSACTION 4213, ACTIVE 6 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1136, 2 row lock(s)
MySQL thread id 88, query id 1024 192.0.2.50 app update
INSERT INTO orders VALUES (5, 1)
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 42 page no 4 n bits 72 index PRIMARY of table `demo`.`orders`
trx id 4213 lock_mode X locks gap before rec
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 42 page no 4 n bits 72 index PRIMARY of table `demo`.`orders`
trx id 4213 lock_mode X locks gap before rec insert intention waiting
*** (2) TRANSACTION:
...
*** WE ROLL BACK TRANSACTION (2)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
按这个顺序读,四步就能定位:
- 两个事务各自在执行哪条 SQL(每段 TRANSACTION 下面那条语句)——注意日志只显示当前正在等的那一条,不是整个事务,往往还得结合业务代码看这个事务前面还做了什么;
HOLDS THE LOCK(S)vsWAITING FOR——把两边一对照,环路就出来了;index XXX of table——撞在哪个索引上。撞在二级索引和撞在主键,改法完全不同;lock_mode的措辞决定了是哪一类死锁:lock_mode X locks rec but not gap→ 纯行锁冲突,多半是加锁顺序不一致(场景一),按固定顺序访问即可解决locks gap before rec/insert intention waiting→ 间隙锁 + 插入意向锁(场景二),是 check-then-insert 模式,改写法或降到 RClock_mode S→ 有共享锁参与,检查是不是有多余的FOR SHARE或外键检查(外键约束会隐式加共享锁,是很容易漏掉的一个死锁来源)
WE ROLL BACK TRANSACTION (n)——被回滚的是哪个,对应应用侧哪个请求收到了 1213。
生产环境必须打开全量死锁日志,否则只留最近一次,等你去看的时候现场早被冲掉了:
[mysqld]
innodb_print_all_deadlocks = ON
2
打开后每次死锁都会完整写进错误日志(不是慢日志),可以直接接告警。它是动态参数,也可以在线开:
SET GLOBAL innodb_print_all_deadlocks = ON;
验证:
SHOW VARIABLES LIKE 'innodb_print_all_deadlocks';
预期输出:
+----------------------------+-------+
| Variable_name | Value |
+----------------------------+-------+
| innodb_print_all_deadlocks | ON |
+----------------------------+-------+
2
3
4
5
然后按上面场景二的时序手工造一次死锁,tail -f 错误日志应该立刻看到新记录。
配套看一眼死锁发生的频率(累计计数):
SHOW GLOBAL STATUS LIKE 'Innodb_deadlocks';
# 坑与边界
- 死锁不是故障,超时才是。InnoDB 检测到死锁会立刻回滚一个事务(毫秒级),应用重试即可;真正拖垮系统的是
innodb_lock_wait_timeout(默认 50 秒)的锁等待——它不构成环路,检测不到,只能干等。看到大量连接卡住但没有 1213 报错,要查的是锁等待而不是死锁。 innodb_deadlock_detect=OFF不要在线上关。有些高并发压测建议关掉它(检测本身在高并发热点行上有 O(n²) 开销),但关掉之后死锁不再被发现,会一路等到innodb_lock_wait_timeout才报错,故障时间从毫秒变成 50 秒。要关,必须同时把innodb_lock_wait_timeout调到 1~2 秒。- 回滚的是「代价最小」的事务,不是「后来的」那个。判据是事务在 undo 里累积的修改行数,所以一个刚开始的小事务可能反复被大事务撞掉——业务上表现为「某个接口偶发失败,且总是同一个」。
- 重试必须重试整个事务,不能只重试报错的那条 SQL。死锁发生时该事务已经被完整回滚,前面执行成功的语句也没了。
- 没走索引的 UPDATE 会锁住扫到的每一行,把本来毫不相干的两个事务变成互撞。排查死锁时先看两条 SQL 的执行计划有没有走索引(见 MySQL执行计划分析),这一步能解释掉相当一部分「看起来毫无关系却死锁了」的案例。
- 外键会隐式加锁。父表有外键引用时,子表的写入会去父表加共享锁,死锁日志里会出现业务 SQL 里根本没提到的表。
# 总结
死锁是并发数据库系统中难以完全避免的问题。通过:
- 理解死锁产生的原因
- 采用合理的预防措施
- 正确配置数据库参数
- 优化应用程序设计
我们可以最大限度地减少死锁的发生,提高系统的可用性和性能。
# 参考资料
- 《高性能MySQL》第三版
- MySQL官方文档:https://dev.mysql.com/doc/refman/8.0/en/innodb-deadlocks.html