MySQL 死锁:产生原因、分析与解决
在高并发系统中,数据库死锁是比较常见的问题。
很多开发者第一次遇到死锁时,会认为:
数据库是不是出问题了?
实际上,死锁本身并不意味着 MySQL 出现了故障。对于 InnoDB 来说,死锁是并发事务之间形成循环等待后,由存储引擎主动检测并处理的一种正常机制。
真正需要解决的问题不是简单地“关闭死锁”,而是:
为什么会产生死锁?
哪些 SQL 导致了循环等待?
为什么某个事务被回滚?
如何从根本上减少死锁?
应用程序收到死锁异常后应该怎么处理?
理解这些问题,才能正确处理生产环境中的死锁。
1. 什么是死锁
死锁(Deadlock)是指两个或多个事务互相持有对方需要的锁,同时等待对方释放锁,最终形成循环等待。
例如有两个账户:
账户 1
账户 2
事务 A:
锁住账户 1
↓
等待账户 2
事务 B:
锁住账户 2
↓
等待账户 1
最终形成:
事务 A → 等待事务 B 持有的锁
事务 B → 等待事务 A 持有的锁
这就是典型的死锁。
需要注意,死锁和锁等待不是一回事。
普通锁等待可能是:
事务 A 持有锁
↓
事务 B 等待事务 A
只要事务 A 最终提交或回滚,事务 B 就可以继续执行。
而死锁是:
事务 A 等待 B
↑ ↓
└────┘
形成了循环等待,没有任何一个事务可以自然地继续。
InnoDB 会检测这种循环依赖,并主动选择其中一个事务进行回滚,从而打破死锁。
因此,应用程序通常会收到类似:
Deadlock found when trying to get lock;
try restarting transaction
这并不代表整个数据库不可用了,而是说明当前事务成为了死锁处理的牺牲者(victim)。
2. MySQL 中死锁是怎么产生的
死锁产生的基本条件可以概括为:
事务 A 持有锁 1;
事务 B 持有锁 2;
A 需要锁 2;
B 需要锁 1;
最终形成循环等待。
例如:
事务 A:
锁定记录 1
↓
请求记录 2
↓
等待 B
事务 B:
锁定记录 2
↓
请求记录 1
↓
等待 A
这里真正的问题通常不是某一条 SQL 本身错误,而是多个事务获取锁的顺序不一致。
在实际系统中,还可能出现更复杂的情况,例如:
UPDATE与UPDATE之间产生死锁;SELECT ... FOR UPDATE与UPDATE产生死锁;多行更新时加锁顺序不同;
Gap Lock / Next-Key Lock 引发死锁;
不同索引访问路径导致锁范围不同;
多张表之间交叉更新;
事务中执行了过多业务逻辑,导致持锁时间过长。
因此,分析死锁时不能只看报错的那一条 SQL,而应该分析整个事务的加锁过程。
3. 经典死锁案例:两个事务更新相反顺序
最经典的例子是转账。
假设:
accounts
id balance
1 1000
2 1000
事务 A:
START TRANSACTION;
UPDATE accounts
SET balance = balance - 100
WHERE id = 1;
UPDATE accounts
SET balance = balance + 100
WHERE id = 2;
COMMIT;
事务 A 的加锁过程:
锁住 id = 1
↓
请求 id = 2
与此同时,事务 B:
START TRANSACTION;
UPDATE accounts
SET balance = balance - 50
WHERE id = 2;
UPDATE accounts
SET balance = balance + 50
WHERE id = 1;
COMMIT;
事务 B:
锁住 id = 2
↓
请求 id = 1
于是:
事务 A 持有 id=1
↓
等待 id=2
↑
事务 B 持有 id=2
↓
等待 id=1
形成循环等待。
如果两个事务恰好按照这个顺序并发执行,就可能产生死锁。
最简单的解决方案
统一资源加锁顺序。
例如规定:
涉及多个账户时,永远按照账户 ID 从小到大处理。
无论转账方向是什么,都先处理:
id = 1
再处理:
id = 2
这样两个事务的加锁顺序始终一致:
事务 A:
1 → 2
事务 B:
1 → 2
即使事务 B 需要等待事务 A,也只是普通锁等待,而不是循环等待。
这也是实际系统中非常重要的一个原则:
多个事务需要访问相同资源时,应尽可能保证一致的加锁顺序。
4. 范围锁与 Gap Lock 导致的死锁
死锁不一定发生在两个明确的记录之间。
InnoDB 的 Gap Lock、Next-Key Lock 也可能参与死锁。
例如:
SELECT *
FROM orders
WHERE user_id = 100
FOR UPDATE;
在特定隔离级别、索引结构和执行计划下,锁定读可能涉及索引记录以及记录之间的间隙。
因此,两个事务即使操作的最终数据看起来并不完全相同,也可能因为锁范围发生冲突。
这也是为什么分析死锁时不能简单地认为:
“我更新的是不同 ID,怎么可能死锁?”
真正应该关注的是:
使用了哪个索引?
↓
访问了哪些索引记录?
↓
锁住了哪些范围?
↓
其他事务又请求了什么锁?
尤其是在默认的 REPEATABLE READ 隔离级别下,Gap Lock / Next-Key Lock 是分析范围锁问题时需要重点关注的内容。
例如一个典型场景:
事务 A
↓
锁住索引范围 A
↓
请求范围 B
事务 B
↓
锁住索引范围 B
↓
请求范围 A
同样可能形成循环等待。
因此,索引设计不仅影响查询性能,也会影响事务访问数据时的锁范围和并发行为。
5. 如何查看和分析死锁
MySQL 最经典的死锁分析命令是:
SHOW ENGINE INNODB STATUS\G
重点查看:
LATEST DETECTED DEADLOCK
其中通常可以看到:
TRANSACTION
WAITING FOR THIS LOCK TO BE GRANTED
HOLDS THE LOCK(S)
以及另外一个事务的信息。
分析时重点关注:
事务 1
HOLDS THE LOCK(S)
说明当前事务已经持有什么锁。
然后查看:
WAITING FOR THIS LOCK TO BE GRANTED
说明它正在等待什么锁。
事务 2
同样查看:
HOLDS THE LOCK(S)
WAITING FOR THIS LOCK TO BE GRANTED
如果发现:
Transaction A
持有 Lock A
等待 Lock B
Transaction B
持有 Lock B
等待 Lock A
就可以确认存在循环等待。
分析死锁时不要只看 SQL 文本,还需要关注:
事务 ID
锁类型
索引名称
表
索引记录
等待的锁
持有的锁
事务执行时间
这些信息才能帮助定位真正的问题。
6. 使用 performance_schema 排查锁等待
MySQL 8 中,还可以通过 performance_schema 查看当前锁相关信息。
例如:
SELECT *
FROM performance_schema.data_locks;
查看当前事务之间的锁等待:
SELECT *
FROM performance_schema.data_lock_waits;
还可以结合事务信息:
SELECT *
FROM information_schema.innodb_trx;
实际排查时可以建立这样的思路:
innodb_trx
↓
当前有哪些事务
↓
data_locks
↓
这些事务持有哪些锁
↓
data_lock_waits
↓
谁在等待谁
例如:
事务 101
持有 orders.id=10
等待 orders.id=20
事务 102
持有 orders.id=20
等待 orders.id=10
就能够比较直观地发现循环等待关系。
生产环境排查时,建议同时结合:
SHOW ENGINE INNODB STATUS\G
和:
performance_schema.data_locks
performance_schema.data_lock_waits
information_schema.innodb_trx
不要只依赖一条 SQL。
7. 为什么死锁无法完全避免
很多系统都会尝试:
“把死锁彻底消灭。”
实际上,对于高并发事务系统来说,很难保证死锁永远不会发生。
原因是业务操作复杂、并发关系复杂,而且事务之间的执行顺序并不是完全可控的。
例如:
订单系统
库存系统
支付系统
优惠券系统
账户系统
一个复杂业务可能同时修改多个资源。
即使已经统一了大部分加锁顺序,随着业务发展,也可能出现新的事务路径。
因此更合理的设计目标是:
降低死锁发生概率 + 正确处理死锁异常。
数据库负责:
检测死锁
↓
选择事务回滚
↓
释放锁
应用程序负责:
捕获异常
↓
判断是否属于可重试错误
↓
重新执行完整事务
两者配合才能形成完整的容错机制。
8. 如何减少死锁
8.1 统一加锁顺序
这是最重要的方法之一。
例如:
永远按照 ID 升序处理资源
不要出现:
事务 A:1 → 2
事务 B:2 → 1
应该统一为:
事务 A:1 → 2
事务 B:1 → 2
8.2 缩短事务时间
事务持锁时间越长,发生锁竞争的机会通常越大。
例如不建议:
BEGIN
↓
UPDATE
↓
调用 HTTP API
↓
处理文件
↓
复杂业务计算
↓
UPDATE
↓
COMMIT
更合理的是:
业务准备
↓
必要的外部操作
↓
BEGIN
↓
数据库操作
↓
COMMIT
事务中尽量只保留真正需要原子性的数据库操作。
8.3 合理设计索引
索引不仅影响查询性能,也影响数据库访问数据的方式。
例如:
UPDATE orders
SET status = 2
WHERE user_id = 100
AND status = 1;
如果缺少合理索引,可能需要访问大量记录。
而合适的索引:
CREATE INDEX idx_user_status
ON orders(user_id, status);
可以缩小需要访问的数据范围。
但索引设计不能简单理解成:
“索引越多越好。”
应该结合:
WHERE
JOIN
ORDER BY
数据分布
执行计划
事务并发
综合判断。
8.4 减少事务范围
事务只包裹必要操作。
例如:
DB::beginTransaction();
try {
// 必须保持原子性的数据库操作
DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
throw $e;
}
不要把大量非数据库逻辑全部放进事务。
8.5 避免交叉更新
如果业务经常出现:
A → B
B → A
就应该重新设计资源访问顺序。
例如批量更新多个 ID:
[5, 2, 8, 1]
可以先排序:
[1, 2, 5, 8]
再按照统一顺序处理。
这比依赖数据库不断处理死锁更加可靠。
9. 应用层如何处理死锁
即使已经进行了上述优化,应用程序仍然应该考虑死锁重试。
核心原则是:
死锁重试的是整个事务,而不是失败的那一条 SQL。
例如:
for ($i = 0; $i < 3; $i++) {
try {
DB::transaction(function () {
// 整个事务
});
break;
} catch (\Throwable $e) {
if (! isDeadlockException($e) || $i === 2) {
throw $e;
}
usleep(random_int(10_000, 50_000));
}
}
这里有几个关键点。
第一:重试次数必须有限
不要:
while (true) {
retry();
}
否则数据库出现持续性问题时,应用可能不断制造新的数据库压力。
通常可以设置:
2~3 次
具体次数根据业务重要性和系统负载确定。
第二:使用随机退避
两个事务同时发生死锁后,如果立即同时重试:
A 重试
B 重试
仍然可能再次发生竞争。
因此可以增加一个随机延迟:
usleep(random_int(10_000, 50_000));
即:
死锁
↓
回滚
↓
随机等待
↓
重新执行事务
可以降低多个请求同时重试造成再次碰撞的概率。
第三:整个事务一起重试
错误的方式:
UPDATE A 成功
UPDATE B 死锁
↓
只重试 UPDATE B
正确方式:
BEGIN
↓
UPDATE A
↓
UPDATE B
↓
COMMIT
整个事务失败后:
ROLLBACK
↓
重新 BEGIN
↓
完整执行事务
否则可能破坏事务原本的业务语义。
另外,需要注意:
死锁重试必须建立在事务操作本身具备可重复执行条件的基础上。
如果事务中包含发送消息、调用支付接口、扣库存等外部副作用,就不能简单地把整个代码块无限重复执行,而应该结合幂等、Outbox、状态机等机制设计。
10. 总结
MySQL InnoDB 死锁的本质是:
多个事务
↓
互相持有锁
↓
继续请求对方持有的锁
↓
形成循环等待
↓
InnoDB 检测死锁
↓
回滚其中一个事务
处理死锁不能只依赖:
innodb_lock_wait_timeout
因为锁等待超时和死锁是两个不同的问题。InnoDB 通常会主动检测死锁,而不是一直等待到 innodb_lock_wait_timeout。
实际项目中可以按照下面的思路处理:
发现死锁
↓
SHOW ENGINE INNODB STATUS
↓
分析 HOLDS / WAITING
↓
确认事务之间的循环依赖
↓
检查 SQL、索引和事务范围
↓
统一加锁顺序
↓
缩短事务
↓
优化索引
↓
减少交叉更新
↓
应用层有限重试 + 随机退避
需要特别记住几个原则:
锁等待不等于死锁。
死锁是并发事务之间的循环等待。
InnoDB 会主动检测死锁并回滚其中一个事务。
统一加锁顺序是降低死锁概率的重要手段。
事务越长,锁竞争窗口通常越大。
索引不仅影响查询性能,也会影响锁的访问范围。
应用层应该对可重试的死锁进行有限重试。
重试应该针对完整事务,而不是单条失败 SQL。
死锁不能只靠调大
innodb_lock_wait_timeout解决。
对于高并发系统来说,合理的目标并不是宣称“系统绝不会死锁”,而是让死锁可分析、可控制、可恢复。