MySQL 死锁怎么排查?InnoDB 锁等待与 Spring Boot 整事务重试实战

线上订单接口偶发报错 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、下游幂等键或结果查询机制。

八、如何验证修复效果?

建议依次执行以下测试:

  1. 两会话交叉更新:预期能够复现 1213,并抓到完整死锁记录。
  2. 统一锁顺序:预期同类交叉等待明显减少,但不能宣称全部死锁消失。
  3. 余额不足:应抛业务异常并回滚,不应进入死锁重试。
  4. 并发重试:最终余额满足守恒,不能出现部分扣款。
  5. 重复业务请求:结合单独的幂等实现,确认同一请求只处理一次。
  6. 故障监控:观察死锁次数、重试次数、事务耗时、连接池等待和最终失败率。

本文没有执行压测,不提供虚构的 TPS、毫秒数或性能提升百分比。

排查清单

排查点 重点证据
SQLSTATE / 错误码 区分 1213 与 1205
InnoDB 死锁日志 两个事务、锁对象、索引
执行计划 是否扫描过多索引记录
事务时长 是否包含远程调用或慢 SQL
访问顺序 多个事务是否交叉更新
Spring 传播行为 重试是否真的开启新事务
业务幂等 客户端重发是否重复执行

常见问题

调大 innodb_lock_wait_timeout 能解决死锁吗? 不能解决循环等待的根本原因。

所有查询都加 FOR UPDATE 会更安全吗? 不一定,可能增加锁竞争;只在需要明确排他读的场景使用。

出现死锁意味着数据库丢数据了吗? InnoDB 会回滚被选中的事务;应用必须正确处理失败并重试,不能忽略异常。

能否只重试失败的 UPDATE? 对包含多条语句的业务事务,应该重试整个事务,才能恢复一致状态。

总结

治理 MySQL 死锁,最有效的顺序是:保留证据 → 还原锁等待图 → 检查索引与事务顺序 → 缩短事务 → 有限重试完整事务 → 验证业务幂等。

先证明是哪两个事务互相等待,再改 SQL 和代码,通常比盲目调整超时参数更可靠。

官方参考资料

相关推荐
Aloudata12 分钟前
Claude 做数据分析的这两年:一份复盘与发展线索
大数据·数据库·人工智能·数据挖掘·数据分析·dataagent
Decay__22 分钟前
01_SQLServer 体系架构全解 —— 实例进程、关系引擎、SQLOS 与事务日志深入
运维·数据库·sql·mysql·架构
zhanghaha131443 分钟前
C# 系列教程_12 字符串 string 保姆级系统教程(面向新手,大量示例 + 原理讲解)
开发语言·数据库·c#
韩曙亮1 小时前
【错误记录】安卓 adb 调试 5037 端口占用排查 ( adb 的三层结构 | 找出本机占用 5037 的进程 )
android·数据库·adb·安卓·错误记录
瑞雪兆丰年兮1 小时前
JavaWeb-day07-MySQL(DQL&多表设计)
java·开发语言·数据库·sql·mysql·javaweb·web
lsc_blog6 小时前
年终自评别贴提交记录,把技术产出讲成变化
数据库·面试·职场和发展·跳槽·求职招聘
FungLeo9 小时前
成为全栈·基础补充·数据库迁移与事务:数据结构怎样安全演进
mysql·postgresql·sqlite·数据一致性·数据库事务·数据库迁移·成为全栈
RuoyiOffice12 小时前
SpringBoot+Vue3 凭证生命周期设计:审核、过账、冲销与反过账怎样保持账目一致
java·spring boot·事务·springboot3·财务系统·会计凭证·ruoyi office
RuoyiOffice13 小时前
KubeSphere 容器云实践:从管理 K8s 到建设企业 PaaS,还需要哪些能力
spring boot·kubernetes·kubesphere·jenkins·paas·微服务部署·ruoyi office