线上订单接口偶发报错 Deadlock found when trying to get lock,但重试后又恢复。究竟是数据库性能不足,还是两个事务互相等待?本文用 MySQL 8.4 的双会话实验,讲清楚死锁证据、索引与锁范围、Spring 事务重试边界,并给出排查和验证流程。
技术栈:Java 17、Spring Boot 3.x、Spring JDBC、MySQL 8.4 / InnoDB
一、先分清死锁和锁等待超时
事务 A 锁住订单 101,准备更新 102;事务 B 锁住 102,准备更新 101。A 等 B,B 等 A,形成循环等待。
普通锁等待可能在持锁方提交后结束;死锁必须打破循环。InnoDB 默认进行死锁检测,选择一个事务作为牺牲者回滚。
常见错误码:
| 情况 | MySQL 错误码 | SQLSTATE | 处理思路 |
|---|---|---|---|
| 死锁 | 1213 | 40001 | 回滚后重试完整事务 |
| 锁等待超时 | 1205 | HY000 | 排查长事务和阻塞链,谨慎重试 |
锁等待超时不意味着整个事务一定自动回滚;默认情况下通常回滚当前语句,具体行为受配置影响。因此应用不能假设两类错误完全等价。
二、两个会话复现死锁
以下 SQL 仅用于隔离的测试数据库,切勿直接对生产表操作。
1. 初始化数据
sql
CREATE TABLE demo_account (
id BIGINT PRIMARY KEY,
balance DECIMAL(12, 2) NOT NULL
) ENGINE=InnoDB;
INSERT INTO demo_account(id, balance)
VALUES (101, 100.00), (102, 100.00);
2. 会话 A 先锁住 101
sql
START TRANSACTION;
UPDATE demo_account
SET balance = balance - 10
WHERE id = 101;
-- 等待 B 执行第一条 UPDATE
UPDATE demo_account
SET balance = balance + 10
WHERE id = 102;
3. 会话 B 先锁住 102
sql
START TRANSACTION;
UPDATE demo_account
SET balance = balance - 20
WHERE id = 102;
-- 等待 A 执行第一条 UPDATE
UPDATE demo_account
SET balance = balance + 20
WHERE id = 101;
操作顺序:A 第一条、B 第一条、A 第二条、B 第二条。预期一个事务出现死锁错误,另一个事务得以继续。
被回滚的是哪一个由 InnoDB 决定;不要写死"B 必定失败"。未失败的会话应显式 COMMIT,失败方结束当前事务后重新开始。
三、如何获取死锁证据?
1. 读取最近一次死锁
sql
SHOW ENGINE INNODB STATUS\G
重点查看 LATEST DETECTED DEADLOCK,提取事务 ID、正在执行的 SQL、持有锁、等待锁、涉及索引和被回滚事务。
这个区域通常只保留最近一次死锁;生产高并发下可能被后续事件覆盖,最好在异常发生时及时采集。
2. 需要历史记录时打开日志
sql
SET GLOBAL innodb_print_all_deadlocks = ON;
此开关会把死锁信息写入 MySQL 错误日志。需要相应权限,且会增加日志量;应由运维评估是否开启、保留多久。
3. 查看当前锁等待关系
sql
SELECT
REQUESTING_ENGINE_TRANSACTION_ID AS waiting_trx,
BLOCKING_ENGINE_TRANSACTION_ID AS blocking_trx,
REQUESTING_ENGINE_LOCK_ID AS waiting_lock,
BLOCKING_ENGINE_LOCK_ID AS blocking_lock
FROM performance_schema.data_lock_waits;
sql
SELECT ENGINE_TRANSACTION_ID,
OBJECT_SCHEMA, OBJECT_NAME, INDEX_NAME,
LOCK_TYPE, LOCK_MODE, LOCK_STATUS, LOCK_DATA
FROM performance_schema.data_locks;
这些表展示当前锁信息,不是已经结束的死锁历史库。不同锁类型下 LOCK_DATA 的含义不同,不能一律当成业务主键。
四、为什么更新少量行也可能锁很多记录?
InnoDB 的锁范围受索引访问路径、隔离级别和执行计划影响,而不是仅取决于最终修改了多少行。
例如:
sql
UPDATE orders
SET status = 'EXPIRED'
WHERE status = 'UNPAID'
AND expire_at < NOW();
如果缺少合适索引,可能扫描较多记录并扩大锁竞争范围。可以在测试环境评估:
sql
CREATE INDEX idx_orders_expire
ON orders(status, expire_at, id);
索引并非越多越好:增加写入维护成本、占用磁盘,且真实锁行为需结合执行计划和事务隔离级别验证。
在默认 REPEATABLE READ 下,范围查询可能涉及 next-key lock;切换 READ COMMITTED 在部分场景能减少间隙锁,但不是通用死锁修复方案。
涉及二级索引更新、外键和唯一约束时,锁路径还可能超出最直观的业务主键记录。
五、统一访问顺序,避免最典型的交叉死锁
如果一个业务事务需要更新多个账户,约定所有代码路径都先访问较小 ID,再访问较大 ID:
java
long first = Math.min(fromId, toId);
long second = Math.max(fromId, toId);
分别执行:
sql
SELECT balance
FROM demo_account
WHERE id = ?
FOR UPDATE;
先对 first 加锁,再对 second 加锁。不要依赖 WHERE id IN (...) ORDER BY id 必然按照你设想的顺序获取所有锁。
统一顺序能减少这类交叉死锁,但不能消灭索引、外键或其他事务路径产生的全部死锁。
六、Spring Boot 应该怎样重试?
重试完整业务事务,而不是在失败的事务内部继续执行同一条 SQL。
可以用 TransactionTemplate 显式建立每次尝试的事务边界:
java
@Service
public class TransferService {
private final TransactionTemplate tx;
private final JdbcTemplate jdbc;
public TransferService(
PlatformTransactionManager manager,
JdbcTemplate jdbc) {
this.tx = new TransactionTemplate(manager);
this.jdbc = jdbc;
}
public void transfer(long from, long to, BigDecimal amount) {
if (from == to || amount.signum() <= 0) {
throw new IllegalArgumentException("invalid transfer");
}
for (int attempt = 1; attempt <= 3; attempt++) {
try {
tx.executeWithoutResult(s ->
transferOnce(from, to, amount));
return;
} catch (DeadlockLoserDataAccessException ex) {
if (attempt == 3) throw ex;
backoff(attempt);
}
}
}
private void transferOnce(
long from, long to, BigDecimal amount) {
long first = Math.min(from, to);
long second = Math.max(from, to);
jdbc.queryForObject(
"SELECT balance FROM demo_account WHERE id=? FOR UPDATE",
BigDecimal.class, first);
jdbc.queryForObject(
"SELECT balance FROM demo_account WHERE id=? FOR UPDATE",
BigDecimal.class, second);
int debited = jdbc.update("""
UPDATE demo_account SET balance=balance-?
WHERE id=? AND balance>=?
""", amount, from, amount);
if (debited != 1) {
throw new IllegalStateException("insufficient funds");
}
int credited = jdbc.update("""
UPDATE demo_account SET balance=balance+?
WHERE id=?
""", amount, to);
if (credited != 1) {
throw new IllegalStateException("target missing");
}
}
private void backoff(int attempt) {
try {
Thread.sleep(30L * attempt +
ThreadLocalRandom.current().nextLong(30));
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw new IllegalStateException("interrupted", ex);
}
}
}
这只是演示数据库事务和重试边界的核心代码,不是生产资金系统:未实现请求幂等、流水审计、权限校验和跨服务清算,也未在本次环境编译或运行。
这里有两个必须注意的前提。
第一,DeadlockLoserDataAccessException 的实际映射取决于驱动和 Spring 异常转换链。应记录 SQLSTATE 与数据库错误码,在集成测试中确认真实异常类型。
第二,TransactionTemplate 默认使用 REQUIRED 传播。如果调用方已经开启外层事务,内部调用可能加入外层事务,重试就不是独立的新事务。应保证入口没有外层事务,或明确设计传播边界。
为什么不能在 @Transactional 内写无限重试?
java
@Transactional
public void wrong() {
for (;;) {
try {
updateRows();
return;
} catch (Exception ignored) {
// 失败事务内继续执行:错误示范
}
}
}
这种写法可能在已经被回滚或标记回滚的事务上下文中继续执行,还会把余额不足、参数错误等非瞬时异常一起重试。
重试必须有限次数、有限总时长,最好加入小幅随机退避,避免大量请求同时重试。
七、死锁重试不等于业务幂等
客户端可能因为 HTTP 超时再次提交同一笔请求;这和数据库死锁是两种不同的问题。
即使每次事务都成功,重复请求也可能造成重复扣款。生产业务必须引入稳定请求 ID、唯一约束和事务内幂等状态判断。
如果事务还要发送短信、MQ 消息或调用第三方支付,不要把这些不可随意重放的副作用直接放入重试回调。可以评估 Transactional Outbox、下游幂等键或结果查询机制。
八、如何验证修复效果?
建议依次执行以下测试:
- 两会话交叉更新:预期能够复现 1213,并抓到完整死锁记录。
- 统一锁顺序:预期同类交叉等待明显减少,但不能宣称全部死锁消失。
- 余额不足:应抛业务异常并回滚,不应进入死锁重试。
- 并发重试:最终余额满足守恒,不能出现部分扣款。
- 重复业务请求:结合单独的幂等实现,确认同一请求只处理一次。
- 故障监控:观察死锁次数、重试次数、事务耗时、连接池等待和最终失败率。
本文没有执行压测,不提供虚构的 TPS、毫秒数或性能提升百分比。
排查清单
| 排查点 | 重点证据 |
|---|---|
| SQLSTATE / 错误码 | 区分 1213 与 1205 |
| InnoDB 死锁日志 | 两个事务、锁对象、索引 |
| 执行计划 | 是否扫描过多索引记录 |
| 事务时长 | 是否包含远程调用或慢 SQL |
| 访问顺序 | 多个事务是否交叉更新 |
| Spring 传播行为 | 重试是否真的开启新事务 |
| 业务幂等 | 客户端重发是否重复执行 |
常见问题
调大 innodb_lock_wait_timeout 能解决死锁吗? 不能解决循环等待的根本原因。
所有查询都加 FOR UPDATE 会更安全吗? 不一定,可能增加锁竞争;只在需要明确排他读的场景使用。
出现死锁意味着数据库丢数据了吗? InnoDB 会回滚被选中的事务;应用必须正确处理失败并重试,不能忽略异常。
能否只重试失败的 UPDATE? 对包含多条语句的业务事务,应该重试整个事务,才能恢复一致状态。
总结
治理 MySQL 死锁,最有效的顺序是:保留证据 → 还原锁等待图 → 检查索引与事务顺序 → 缩短事务 → 有限重试完整事务 → 验证业务幂等。
先证明是哪两个事务互相等待,再改 SQL 和代码,通常比盲目调整超时参数更可靠。