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 中死锁是怎么产生的

死锁产生的基本条件可以概括为:

  1. 事务 A 持有锁 1;

  2. 事务 B 持有锁 2;

  3. A 需要锁 2;

  4. B 需要锁 1;

  5. 最终形成循环等待。

例如:

事务 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、索引和事务范围
  ↓
统一加锁顺序
  ↓
缩短事务
  ↓
优化索引
  ↓
减少交叉更新
  ↓
应用层有限重试 + 随机退避

需要特别记住几个原则:

  1. 锁等待不等于死锁。

  2. 死锁是并发事务之间的循环等待。

  3. InnoDB 会主动检测死锁并回滚其中一个事务。

  4. 统一加锁顺序是降低死锁概率的重要手段。

  5. 事务越长,锁竞争窗口通常越大。

  6. 索引不仅影响查询性能,也会影响锁的访问范围。

  7. 应用层应该对可重试的死锁进行有限重试。

  8. 重试应该针对完整事务,而不是单条失败 SQL。

  9. 死锁不能只靠调大 innodb_lock_wait_timeout 解决。

对于高并发系统来说,合理的目标并不是宣称“系统绝不会死锁”,而是让死锁可分析、可控制、可恢复。