导读 :线上报
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),发现环路后马上挑一个事务回滚,另一个继续执行。被回滚的那个收到:
text
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
所以死锁本身不会把库卡死,代价是:
- 被选中的事务整个回滚,接口报错,用户看到失败
- 业务代码如果吞掉异常、接着往下执行,会写进半截数据(第五节有实测)
- 死锁频繁时,往往说明加锁顺序或索引有问题,高峰期失败率会跟着并发一起涨
什么时候该动它:偶尔一两次、有重试兜底的,记下来就行;同一个 SQL 反复出现、或者每次发版后变多的,要按下面的步骤找到加锁顺序问题。
二、测试环境
| 项 | 值 |
|---|---|
| MySQL | 8.2.0(macOS arm64,临时数据目录,默认参数) |
| 隔离级别 | 默认 REPEATABLE READ,第四节对比 READ COMMITTED |
| 方法 | 两个 mysql 客户端会话,用 sleep() 控制先后顺序 |
| Spring | spring-jdbc / spring-tx 7.1.0-RC1,只读 jar 里的错误码映射并编译了示例代码 |
开始前先看一眼这几个参数,后面会反复用到:
sql
-- 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 转钱。
sql
-- 建表(两个会话共用)
CREATE TABLE account (id BIGINT PRIMARY KEY, balance INT);
INSERT INTO account VALUES (1, 100), (2, 100);
两个会话按下面的时间线执行:
| 时间 | 会话 A | 会话 B |
|---|---|---|
| 0s | BEGIN; UPDATE account SET balance=balance-10 WHERE id=1; |
|
| 0.3s | BEGIN; UPDATE account SET balance=balance-10 WHERE id=2; |
|
| 1s | UPDATE ... WHERE id=2;(等 B) |
|
| 2.3s | UPDATE ... WHERE id=1; → ERROR 1213 |
|
| 之后 | 拿到锁,COMMIT 成功 |
整个事务回滚 |
最后表里是 1 → 90, 2 → 110:A 的转账生效了,B 第一条 UPDATE 也一起撤掉了,并没有留下"只扣了钱没加钱"的半截状态。
修法很简单:所有事务按同一个顺序加锁,比如按 id 从小到大。A→B 和 B→A 两个方向都先锁 1 号、再锁 2 号,后来的只会排队,不会成环。
四、场景二:先查后插,REPEATABLE READ 下两个并发请求必死锁
这个场景在业务代码里更常见:防重复下单、幂等表、"没有就插入"。写法通常是:
sql
-- 订单号上有唯一索引
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 |
|---|---|---|
| 0s | SELECT ... WHERE order_no=15 FOR UPDATE(空) |
|
| 0.2s | 同一句(空,不阻塞) | |
| 1s | INSERT 15(等 B) |
|
| 1.7s | INSERT 15 → ERROR 1213 |
我在 REPEATABLE READ 下连续跑了 5 次(每次换一个不存在的订单号),5 次全是死锁,不是偶发。
原因在于 FOR UPDATE 查一个不存在的值时,锁的不是行,是间隙 :10 和 20 之间这段空档。间隙锁之间互不冲突,所以 A、B 都能拿到;但 INSERT 要申请"插入意向锁",它和别人的间隙锁冲突。A 等 B 的间隙锁,B 等 A 的间隙锁,成环。
在 A 的 INSERT 等待时查 performance_schema.data_locks,看得很清楚:
text
+------+------------+-----------+------------------------+-------------+-----------+
| 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 插入成功
没有死锁,但要处理唯一键冲突。
更直接的修法是不要"先查后插",直接用唯一索引兜底:
sql
-- 两个会话同时执行,实测: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
出了死锁,第一步执行:
sql
SHOW ENGINE INNODB STATUS\G
找 LATEST DETECTED DEADLOCK 这一段。下面是场景二的原文(删掉了十六进制行):
text
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)
按这个顺序读:
- 两条 SQL :每个 TRANSACTION 下面那行是它最后执行 的语句,不是整个事务。前面执行过什么(比如那句
FOR UPDATE)日志里没有,要回代码里找 - HOLDS / WAITING :看锁在哪个索引上(
index uk_no)、什么模式 - 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 都在死锁时,后面的会把前面的覆盖。要留全量记录,打开:
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 再触发一次,就完整了:
sql
SET GLOBAL log_error_verbosity = 3;
改完之后的错误日志(节选),每段都有时间戳和 [MY-012469] 标记,最后一行说明回滚了哪个事务:
text
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
死锁是瞬间发生的,等你去查已经结束了。但"锁等待"(还没成环、只是在排队)可以现场抓:
sql
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,别被它误导。
六、最容易被忽略的坑:死锁回滚整个事务,超时只回滚一句
这两个错误码经常被放在一起处理,但回滚范围完全不同。我在同一张表上分别测了:
sql
-- 测试表:三行,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,模拟"出错后代码继续往下走"):
sql
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 继续执行下一句:
sql
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 异常 | 父类 |
|---|---|---|
| 1213 | DeadlockLoserDataAccessException |
PessimisticLockingFailureException |
| 1205、3572 | CannotAcquireLockException |
PessimisticLockingFailureException |
| 1062 | DuplicateKeyException |
DataIntegrityViolationException |
重试要包在事务外面 ,让整个事务重新执行。下面这段用 TransactionTemplate,已在 Spring 7.1.0-RC1 上编译通过:
java
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 官方文档的加锁规则。
参考链接
- MySQL 8.0 Reference Manual: Deadlocks in InnoDB
- MySQL 8.0 Reference Manual: How to Minimize and Handle Deadlocks
- MySQL 8.0 Reference Manual: InnoDB Locking
- MySQL 8.0 Reference Manual: Locks Set by Different SQL Statements in InnoDB
- Spring Framework 文档:Data Access Exception Hierarchy
你们线上碰到的死锁,日志里是
locks rec but not gap(交叉更新那种)多,还是locks gap before rec+insert intention(先查后插那种)多?评论区说说是什么业务场景,我挑典型的补进 FAQ。觉得有用的话点个赞、收个藏,下次碰到 1213 时翻得到。