MySQL 死锁怎么排查:看懂 INNODB STATUS 死锁日志,先查后插为什么必死锁(MySQL 8.2 实测)

0 阅读14分钟

导读:线上报 Deadlock found when trying to get lock,大多数人第一反应是加重试。重试没错,但先得知道两个事务在抢哪把锁。这篇在 MySQL 8.2 上复现了两种最常见的死锁:交叉更新、先 SELECT ... FOR UPDATE 再 INSERT,逐段解释死锁日志怎么读,再补上两个容易漏的坑:错误日志默认只记一半,死锁回滚的是整个事务。


一、先说清楚:死锁是什么,不管会怎样

死锁就是两个事务互相等对方手里的锁。A 拿着 1 号行等 2 号行,B 拿着 2 号行等 1 号行,谁也不会先放手。

InnoDB 默认开着死锁检测(innodb_deadlock_detect=ON),发现环路后马上挑一个事务回滚,另一个继续执行。被回滚的那个收到:

ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction

所以死锁本身不会把库卡死,代价是:

  • 被选中的事务整个回滚,接口报错,用户看到失败
  • 业务代码如果吞掉异常、接着往下执行,会写进半截数据(第五节有实测)
  • 死锁频繁时,往往说明加锁顺序或索引有问题,高峰期失败率会跟着并发一起涨

什么时候该动它:偶尔一两次、有重试兜底的,记下来就行;同一个 SQL 反复出现、或者每次发版后变多的,要按下面的步骤找到加锁顺序问题。


二、测试环境

项值
MySQL8.2.0(macOS arm64,临时数据目录,默认参数)
隔离级别默认 REPEATABLE READ,第四节对比 READ COMMITTED
方法两个 mysql 客户端会话,用 sleep() 控制先后顺序
Springspring-jdbc / spring-tx 7.1.0-RC1,只读 jar 里的错误码映射并编译了示例代码

开始前先看一眼这几个参数,后面会反复用到:

-- MySQL 8.2 默认值(本机实测)
SHOW VARIABLES WHERE Variable_name IN (
  'innodb_deadlock_detect',      -- ON,开着死锁检测
  'innodb_lock_wait_timeout',    -- 50,锁等待超时秒数
  'innodb_rollback_on_timeout',  -- OFF,超时只回滚当前语句
  'innodb_print_all_deadlocks',  -- 默认 OFF,打开后每次死锁都写错误日志
  'log_error_verbosity',         -- 2,影响死锁日志写得全不全
  'transaction_isolation'        -- REPEATABLE-READ
);

三、场景一:交叉更新(最好懂的一种)

转账是经典例子:A 给 B 转钱、B 同时给 A 转钱。

-- 建表(两个会话共用)
CREATE TABLE account (id BIGINT PRIMARY KEY, balance INT);
INSERT INTO account VALUES (1, 100), (2, 100);

两个会话按下面的时间线执行:

时间会话 A会话 B
0sBEGIN; UPDATE account SET balance=balance-10 WHERE id=1;
0.3sBEGIN; UPDATE account SET balance=balance-10 WHERE id=2;
1sUPDATE ... WHERE id=2;(等 B)
2.3sUPDATE ... WHERE id=1; → ERROR 1213
之后拿到锁,COMMIT 成功整个事务回滚

最后表里是 1 → 90, 2 → 110:A 的转账生效了,B 第一条 UPDATE 也一起撤掉了,并没有留下"只扣了钱没加钱"的半截状态。

修法很简单:所有事务按同一个顺序加锁,比如按 id 从小到大。A→B 和 B→A 两个方向都先锁 1 号、再锁 2 号,后来的只会排队,不会成环。


四、场景二:先查后插,REPEATABLE READ 下两个并发请求必死锁

这个场景在业务代码里更常见:防重复下单、幂等表、"没有就插入"。写法通常是:

-- 订单号上有唯一索引
CREATE TABLE t_order (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  order_no INT NOT NULL,
  amount INT,
  UNIQUE KEY uk_no (order_no)
);
INSERT INTO t_order(order_no, amount) VALUES (10, 1), (20, 1);

-- 业务逻辑:先锁住查一下,没有再插
BEGIN;
SELECT * FROM t_order WHERE order_no = 15 FOR UPDATE;   -- 查不到
INSERT INTO t_order(order_no, amount) VALUES (15, 1);
COMMIT;

两个请求同时带着 order_no=15 进来,时间线:

时间会话 A会话 B
0sSELECT ... WHERE order_no=15 FOR UPDATE(空)
0.2s同一句(空,不阻塞)
1sINSERT 15(等 B)
1.7sINSERT 15 → ERROR 1213

我在 REPEATABLE READ 下连续跑了 5 次(每次换一个不存在的订单号),5 次全是死锁,不是偶发。

原因在于 FOR UPDATE 查一个不存在的值时,锁的不是行,是间隙:10 和 20 之间这段空档。间隙锁之间互不冲突,所以 A、B 都能拿到;但 INSERT 要申请"插入意向锁",它和别人的间隙锁冲突。A 等 B 的间隙锁,B 等 A 的间隙锁,成环。

在 A 的 INSERT 等待时查 performance_schema.data_locks,看得很清楚:

+------+------------+-----------+------------------------+-------------+-----------+
| trx  | INDEX_NAME | LOCK_TYPE | LOCK_MODE              | LOCK_STATUS | LOCK_DATA |
+------+------------+-----------+------------------------+-------------+-----------+
| 1337 | uk_no      | RECORD    | X,GAP                  | GRANTED     | 20, 2     |
| 1336 | uk_no      | RECORD    | X,GAP                  | GRANTED     | 20, 2     |
| 1336 | uk_no      | RECORD    | X,GAP,INSERT_INTENTION | WAITING     | 20, 2     |
+------+------------+-----------+------------------------+-------------+-----------+

两个事务都拿着 X,GAP(锁的是 20 这条记录前面的间隙),1336 的 INSERT_INTENTION 在等。LOCK_DATA 的 20, 2 是唯一索引值 20 加上主键 2。

换成 READ COMMITTED 会怎样? 同样的时间线:

  • 两个 FOR UPDATE 都不加间隙锁
  • B 的 INSERT 会等 A 那条还没提交的 15
  • A 提交后,B 拿到 ERROR 1062 Duplicate entry '15' for key 't_order.uk_no';A 回滚的话,B 插入成功

没有死锁,但要处理唯一键冲突。

更直接的修法是不要"先查后插",直接用唯一索引兜底:

-- 两个会话同时执行,实测:B 等 A 提交约 0.8 秒后成功,没有死锁,最终 amount=2
INSERT INTO t_order(order_no, amount) VALUES (18, 1)
ON DUPLICATE KEY UPDATE amount = amount + 1;

不需要更新的场景,用 INSERT 后捕获 1062(Spring 里是 DuplicateKeyException)当作"已存在"处理。

⚠️ 上面只测了两个会话并发 upsert。ON DUPLICATE KEY UPDATE 在三个以上并发、或表上有多个唯一索引时也可能死锁,我没测这些组合,高并发场景仍然要配合重试。


五、死锁日志怎么读

5.1 SHOW ENGINE INNODB STATUS

出了死锁,第一步执行:

SHOW ENGINE INNODB STATUS\G

找 LATEST DETECTED DEADLOCK 这一段。下面是场景二的原文(删掉了十六进制行):

LATEST DETECTED DEADLOCK
------------------------
2026-11-12 08:42:55 0x17619f000
*** (1) TRANSACTION:
TRANSACTION 1322, ACTIVE 2 sec inserting
LOCK WAIT 3 lock struct(s), heap size 1128, 2 row lock(s), undo log entries 1
insert into t_order(order_no,amount) values(15,1)

*** (1) HOLDS THE LOCK(S):
RECORD LOCKS ... index uk_no of table `d`.`t_order` trx id 1322 lock_mode X locks gap before rec

*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS ... index uk_no of table `d`.`t_order` trx id 1322 lock_mode X locks gap before rec insert intention waiting

*** (2) TRANSACTION:
TRANSACTION 1323, ACTIVE 2 sec inserting
insert into t_order(order_no,amount) values(15,2)

*** (2) HOLDS THE LOCK(S):
RECORD LOCKS ... index uk_no ... trx id 1323 lock_mode X locks gap before rec

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS ... index uk_no ... trx id 1323 lock_mode X locks gap before rec insert intention waiting

*** WE ROLL BACK TRANSACTION (2)

按这个顺序读:

  1. 两条 SQL:每个 TRANSACTION 下面那行是它最后执行的语句,不是整个事务。前面执行过什么(比如那句 FOR UPDATE)日志里没有,要回代码里找
  2. HOLDS / WAITING:看锁在哪个索引上(index uk_no)、什么模式
  3. WE ROLL BACK TRANSACTION (2):被回滚的是哪个

lock_mode 几种写法对照:

日志里的写法含义常见来源
X locks rec but not gap只锁这一行主键或唯一索引等值命中
X locks gap before rec只锁这条记录前面的间隙RR 下 FOR UPDATE 查不存在的值
X(后面什么都没有)行 + 前面的间隙(next-key lock)RR 下范围查询、普通索引
insert intention waiting插入前申请的意向锁在等有人持有这段间隙锁

场景一的日志里是 index PRIMARY ... lock_mode X locks rec but not gap,一眼就知道是主键行锁交叉,和场景二的间隙锁是两回事。

5.2 只显示最近一次:打开 innodb_print_all_deadlocks

SHOW ENGINE INNODB STATUS 只保留最近一次死锁,线上多条 SQL 都在死锁时,后面的会把前面的覆盖。要留全量记录,打开:

SET GLOBAL innodb_print_all_deadlocks = ON;

这里有个坑:MySQL 8.x 默认 log_error_verbosity=2,死锁信息只写一半。我在默认设置下触发了 3 次死锁,错误日志里只有裸的 TRANSACTION 1318 ...、RECORD LOCKS ... 这些行,没有时间戳,没有 *** (1) TRANSACTION: 这类分段标题,也没有 WE ROLL BACK TRANSACTION 那一行,看不出哪个事务被回滚、两个事务的边界在哪。

原因是那些标题行在 8.x 里是 Note 级别的日志([MY-012469]),默认只记 Error 和 Warning。改成 3 再触发一次,就完整了:

SET GLOBAL log_error_verbosity = 3;

改完之后的错误日志(节选),每段都有时间戳和 [MY-012469] 标记,最后一行说明回滚了哪个事务:

2026-11-12T00:44:17.724236Z 0 [Note] [MY-012469] [InnoDB]  *** (2) TRANSACTION:
TRANSACTION 1365, ACTIVE 1 sec starting index read
update account set balance=2 where id=1
2026-11-12T00:44:17.725512Z 0 [Note] [MY-012469] [InnoDB] *** WE ROLL BACK TRANSACTION (2)

SET GLOBAL 重启后失效,要长期生效写进 my.cnf。verbosity=3 会把其他 Note 也一起写进来,日志量会变大,按需权衡;云数据库一般在控制台的参数设置里改,有的会直接提供死锁日志页面。

5.3 锁还在等的时候:sys.innodb_lock_waits

死锁是瞬间发生的,等你去查已经结束了。但"锁等待"(还没成环、只是在排队)可以现场抓:

SELECT waiting_pid, waiting_query, waiting_lock_mode,
       blocking_pid, blocking_query, blocking_lock_mode,
       sql_kill_blocking_query
FROM sys.innodb_lock_waits\G

场景二里查到的关键字段:waiting_lock_mode: X,GAP,INSERT_INTENTION,blocking_lock_mode: X,GAP,还有现成的 KILL QUERY 19。注意 blocking_query 显示的是对方当前在跑的语句(这次是 select sleep(4)),真正持锁的是它之前那句 FOR UPDATE,别被它误导。


六、最容易被忽略的坑:死锁回滚整个事务,超时只回滚一句

这两个错误码经常被放在一起处理,但回滚范围完全不同。我在同一张表上分别测了:

-- 测试表:三行,v 都是 0
CREATE TABLE k (id INT PRIMARY KEY, v INT);
INSERT INTO k VALUES (1,0),(2,0),(3,0);

锁等待超时(1205):另一个会话锁住 id=3,本会话把超时设成 1 秒,然后依次更新 1、3、2 再提交(客户端加 --force,模拟"出错后代码继续往下走"):

SET innodb_lock_wait_timeout = 1;
BEGIN;
UPDATE k SET v=v+1 WHERE id=1;   -- 成功
UPDATE k SET v=v+1 WHERE id=3;   -- ERROR 1205 Lock wait timeout exceeded
UPDATE k SET v=v+1 WHERE id=2;   -- 成功
COMMIT;                          -- 结果:1→1, 2→1, 3→0

超时只撤掉了出错的那一句,事务还在,COMMIT 把另外两句提交了。这是 innodb_rollback_on_timeout=OFF(默认)的行为。

死锁(1213):本会话更新 id=2,然后在更新 id=1 时被选为死锁牺牲者,--force 继续执行下一句:

BEGIN;
UPDATE k SET v=20 WHERE id=2;   -- 成功
UPDATE k SET v=20 WHERE id=1;   -- ERROR 1213 Deadlock,整个事务已回滚
UPDATE k SET v=20 WHERE id=3;   -- 成功,但这时已经不在事务里了
COMMIT;                         -- 结果:1→10, 2→10(对方的值),3→20

死锁之后事务已经没了,后面那句 UPDATE 是自动提交执行的,id=3 被单独改成了 20,这就是半截数据。

1213 死锁1205 锁等待超时
回滚范围整个事务只有出错的那一句(默认)
出错后接着执行不在事务里,每句自动提交还在原事务里
正确处理整个事务从头重来回滚整个事务再重来

在 Spring 的 @Transactional 里,异常往外抛、事务整体回滚,一般不会踩到这个坑。危险的是这几种写法:

  • 在事务方法里 try { ... } catch (Exception e) { log.warn(...) } 吞掉异常,继续执行后面的 SQL
  • 手写 JDBC,setAutoCommit(false) 之后出错没 rollback() 就接着用连接
  • 在事务内部"只重试出错的那条 SQL"

七、Java 里怎么重试

查了 spring-jdbc 7.1.0-RC1 jar 里的 sql-error-codes.xml,MySQL 的映射是:

MySQL 错误码Spring 异常父类
1213DeadlockLoserDataAccessExceptionPessimisticLockingFailureException
1205、3572CannotAcquireLockExceptionPessimisticLockingFailureException
1062DuplicateKeyExceptionDataIntegrityViolationException

重试要包在事务外面,让整个事务重新执行。下面这段用 TransactionTemplate,已在 Spring 7.1.0-RC1 上编译通过:

import org.springframework.dao.PessimisticLockingFailureException;
import org.springframework.transaction.support.TransactionTemplate;

public class OrderService {

    private final TransactionTemplate tx;

    public OrderService(TransactionTemplate tx) {
        this.tx = tx;
    }

    // 整个事务重来,而不是只重试出错的那条 SQL
    public void transfer(long from, long to, int amount) {
        int maxAttempts = 3;
        for (int attempt = 1; ; attempt++) {
            try {
                tx.executeWithoutResult(status -> doTransfer(from, to, amount));
                return;
            } catch (PessimisticLockingFailureException e) {
                // 1213 死锁和 1205 锁等待超时都是它的子类
                if (attempt >= maxAttempts) {
                    throw e;
                }
                try {
                    Thread.sleep(50L * attempt);
                } catch (InterruptedException ie) {
                    Thread.currentThread().interrupt();
                    throw e;
                }
            }
        }
    }

    private void doTransfer(long from, long to, int amount) {
        // 按 id 从小到大加锁,两个方向的转账就不会交叉等待
        long first = Math.min(from, to);
        long second = Math.max(from, to);
        lockAccount(first);
        lockAccount(second);
        // 扣款、加款
    }

    private void lockAccount(long id) {
        // select id from account where id = ? for update
    }
}

几个注意点:

  • 如果用 @Transactional + 重试注解,重试的那层必须在事务代理外面,否则重试的还是同一个已经回滚的事务
  • 重试只适合幂等的操作;事务里发了 MQ、调了外部接口的,要先确认重来一次不会重复发
  • 3 次左右就够,死锁频繁说明要改加锁顺序或 SQL,重试只是兜底

💡 上面的重试代码只做了编译,没有在真实 Spring Boot 应用里连 MySQL 跑。死锁和回滚行为是用 mysql 客户端实测的。


八、排查步骤速查

步骤做什么命令
1看最近一次死锁SHOW ENGINE INNODB STATUS\G,找 LATEST DETECTED DEADLOCK
2确认两条 SQL、锁在哪个索引、什么模式读 HOLDS / WAITING 和 lock_mode
3回代码补全事务里前面执行过的语句日志里只有最后一句
4要全量记录innodb_print_all_deadlocks=ON + log_error_verbosity=3
5现场抓锁等待sys.innodb_lock_waits、performance_schema.data_locks
6改统一加锁顺序;先查后插改成直接插 + 唯一键兜底;给 UPDATE 的条件列加索引减少锁范围
7兜底事务外重试 PessimisticLockingFailureException,2~3 次

九、常见问题

Q:改成 READ COMMITTED 能根治死锁吗?

能消掉间隙锁导致的那一类(场景二),交叉更新那类(场景一)照样死锁。改隔离级别还会影响可重复读的语义,要评估业务里有没有依赖它的地方。

Q:能不能关掉死锁检测?

innodb_deadlock_detect=OFF 之后,死锁要等 innodb_lock_wait_timeout(默认 50 秒)才会超时报错,而且报的是 1205,只回滚一句。MySQL 文档提到只有在超高并发、检测本身成为瓶颈时才考虑关,普通业务别动。

Q:日志里的 SQL 和我代码里对不上?

日志只记每个事务最后一条语句。前面加锁的那句(往往是 FOR UPDATE 或者范围 UPDATE)要回代码里按事务边界找。

Q:UPDATE 带的条件没索引会怎样?

InnoDB 会按扫描到的记录加锁,没索引就是全表扫描、锁住扫过的所有行,和别的事务冲突的概率大很多。死锁日志里如果看到 index PRIMARY 上锁了一大片、而 SQL 条件是普通字段,先查这个字段有没有索引。这一条我没在本文环境复现,结论来自 MySQL 官方文档的加锁规则。


参考链接


你们线上碰到的死锁,日志里是 locks rec but not gap(交叉更新那种)多,还是 locks gap before rec + insert intention(先查后插那种)多?评论区说说是什么业务场景,我挑典型的补进 FAQ。

觉得有用的话点个赞、收个藏,下次碰到 1213 时翻得到。