MySQL死锁时的事务自动重启机制:一个让无数Java开发者失眠的“特性“

MySQL死锁时的事务自动重启机制:一个让无数Java开发者失眠的"特性"

当你在Spring事务中捕获到死锁异常,以为一切尽在掌控时,MySQL已经悄悄替你开启了新事务

引言:一个令人困惑的生产事故

“怎么可能?我明明捕获了死锁异常,事务应该回滚才对,为什么后面的数据还是提交了?”

这是我上周收到的一条紧急求助。在WMS系统的整件复核功能中,同事遇到了一个令人费解的现象:

@Transactional(rollbackFor = Exception.class)
public void checkConfirm() {
    // 1. 更新操作 ✅ 执行成功
    batchUpdateOperations();
    
    // 2. 调用存储过程 ❌ 发生死锁
    callProcedureThatMayDeadlock();
    
    // 3. 异常被捕获并记录(未抛出)
    
    // 4. 差异库存上架 ✅ 执行成功
    updateDifferenceInventory();
}

运行结果令人震惊:

  • 存储过程前的次更新:全部失效(被回滚)
  • 存储过程后的差异更新:成功提交了!
  • 异常明明被捕获了,事务却被部分提交

更诡异的是,在差异更新方法中打印事务状态:

public void updateDifferenceInventory() {
    boolean hasTx = TransactionSynchronizationManager.isActualTransactionActive();
    System.out.println("是否有事务: " + hasTx);  // 输出: true
}

明明有事务,为什么前面的操作回滚了,后面的操作却提交了?

这个bug让整个团队陷入困惑,直到我们挖出了MySQL死锁处理机制中最容易忽视的特性。

第一部分:死锁检测与处理的标准流程

1.1 什么是死锁?

死锁是指两个或多个事务相互持有对方需要的锁,导致彼此都无法继续执行的情况:

事务A:持有锁1,等待锁2
事务B:持有锁2,等待锁1

这两个事务会无限期地等待下去,形成死锁。

1.2 标准的事务回滚流程

按照正常的理解,当数据库检测到死锁时,应该:

  1. 选择一个事务作为"牺牲品"
  2. 彻底回滚这个事务
  3. 将该事务标记为终止状态
  4. 向客户端返回死锁错误
  5. 后续任何操作都会失败,直到客户端执行ROLLBACK

大多数数据库(Oracle、PostgreSQL、SQL Server)都是这样做的:

// Oracle/PostgreSQL中的表现
try {
    // 执行SQL
} catch (DeadlockException e) {
    // 事务已被终止
    // 必须显式回滚
    connection.rollback();
    // 重新开启新事务
    connection.setAutoCommit(false);
}

第二部分:MySQL的特殊之处

2.1 隐式的事务重启

MySQL的InnoDB引擎在死锁处理上走了一条与众不同的路。当它检测到死锁并选择当前事务作为牺牲品时,会执行以下操作:

-- MySQL内部自动执行:
ROLLBACK;                    -- 1. 回滚当前事务
START TRANSACTION;           -- 2. ⚡️隐式开启新事务⚡️
-- 返回错误:Deadlock found when trying to get lock; try restarting transaction

这个隐式开启新事务的行为,就是所有困惑的根源。

2.2 验证实验

让我们用一个简单的实验来验证这个行为:

会话1(事务A):

BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 1;
-- 持有id=1的行锁

会话2(事务B):

BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 2;
UPDATE account SET balance = balance + 100 WHERE id = 1;
-- 等待会话1的锁

会话1(事务A):

UPDATE account SET balance = balance + 100 WHERE id = 2;
-- 等待会话2的锁,死锁发生!
-- 会话2会收到死锁错误

验证事务状态:

SELECT trx_id, trx_state, trx_mysql_thread_id 
FROM information_schema.innodb_trx;

-- 发现:被牺牲的事务连接上竟然还有一个活跃事务!
-- 说明MySQL自动开启了新事务

第三部分:Java程序员的噩梦

3.1 你以为的执行流程

@Transactional
public void businessMethod() {
    // 1. 执行一批重要操作
    batchUpdateImportantData();  // 假设这里插入了100条记录
    
    try {
        // 2. 调用可能死锁的存储过程
        callProcedureThatMayDeadlock();
    } catch (Exception e) {
        // 3. 捕获异常,只记录日志
        log.error("存储过程执行失败,但没关系", e);
        // ❌ 没有重新抛出
    }
    
    // 4. 继续执行其他操作
    updateOtherData();
}

你以为的执行流程:

1. 事务A开启
2. 100条插入(在事务A中)
3. 存储过程死锁 ❌
4. 捕获异常,事务A被标记为rollbackOnly
5. updateOtherData(仍在事务A中)
6. 提交时发现rollbackOnly,回滚事务A
7. 所有数据都回滚

3.2 MySQL实际执行的流程

1. 事务A开启
2. 100条插入(在事务A中)
3. 存储过程死锁 ❌
   ├── MySQL回滚事务A(100条插入丢失)
   └── MySQL隐式开启新事务B ⚡️
4. 捕获异常(Spring仍认为事务A活跃)
5. updateOtherData执行 → 实际在事务B中
6. Spring提交事务 → 实际提交事务B

最终状态:
- 100条插入:在事务A中,被回滚 ❌
- updateOtherData:在事务B中,被提交 ✅

3.3 事务状态对比

时间点Spring感知数据库实际有无回滚标记
死锁前事务A激活事务A运行
死锁发生时收到异常事务A回滚事务A结束
死锁后仍认为事务A激活事务B已开启事务B无回滚标记
后续操作在事务A中在事务B中
提交时提交事务A提交事务B成功

3.4 日志中的证据

15:58:22.669 - 第一次 insert(存储过程前)✅
15:58:22.672 - 第一次 update(存储过程前)✅
...
15:58:24.922 - 调用存储过程 {call p_wms_sorder_update}
15:58:25.011 - 存储过程失败:Deadlock found when trying to get lock
15:58:25.020 - 释放分布式锁,异常被捕获并记录(未抛出)
...
15:58:36.708 - update wms_goods_inventory(差异更新)✅
15:58:36.771 - insert into wms_goods_inventory_operate ✅
15:58:36.863 - 事务提交 ✅

第四部分:为什么其他数据库不会这样?

4.1 各数据库死锁处理对比

数据库死锁时事务状态能否继续执行是否需要显式ROLLBACK
MySQL InnoDB隐式开启新事务不需要
Oracle事务终止不能必须ROLLBACK
PostgreSQL事务中断不能必须ROLLBACK
SQL Server事务终止不能必须ROLLBACK

4.2 Oracle的死锁处理

-- Oracle死锁后
SQL Error: ORA-00060: deadlock detected while waiting for resource

-- 后续SQL会失败
SELECT * FROM dual;
-- ORA-01591: lock held by in-doubt distributed transaction

-- 必须显式回滚
ROLLBACK;  -- 才能继续

4.3 PostgreSQL的死锁处理

-- PostgreSQL死锁后
ERROR: deadlock detected

-- 后续SQL会失败
SELECT * FROM users;
-- ERROR: current transaction is aborted, commands ignored

-- 必须回滚
ROLLBACK;

第五部分:重试的真正意义

5.1 一个常见的误区

很多开发者会这样处理死锁:

@Transactional
public void businessMethod() {
    // 重要操作
    saveImportantData();
    
    // 死锁重试
    int retryCount = 0;
    while (retryCount < 3) {
        try {
            riskyOperation();
            break;
        } catch (DeadlockException e) {
            retryCount++;
        }
    }
}

问题: 第一次死锁时,整个事务已经被回滚,saveImportantData() 的数据已经丢失了!即使后面的重试成功,重要数据也回不来了。

5.2 重试的真正意义

重试的核心不是"重试同一个操作",而是"用新的事务重试,并确保前置数据安全"。

// ❌ 错误的重试:在同一事务中
@Transactional
public void wrongRetry() {
    saveImportantData();  // 死锁时,这些数据会被回滚
    retryRiskyOperation(); // 重试成功也无用
}

// ✅ 正确的重试:独立事务 + 前置数据保护
public void correctRetry() {
    // 方案A:先执行危险操作
    executeRiskyWithRetry();  // 独立事务
    
    // 再执行重要操作
    saveImportantData();      // 新事务
    
    // 方案B:状态分离
    String id = saveDataWithPending();  // 预存数据
    if (executeRiskyWithRetry()) {      // 危险操作成功
        confirmData(id);                 // 确认数据
    } else {
        rollbackData(id);                // 清理数据
    }
}

5.3 事务分割的两种模式

模式1:危险操作前置
public void businessMethod() {
    // 1. 先执行可能死锁的操作(独立事务)
    executeRiskyOperationWithRetry();
    
    // 2. 再执行重要操作(新事务)
    @Transactional
    public void saveImportantData() {
        // 重要数据保存
    }
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void executeRiskyOperationWithRetry() {
    int retryCount = 0;
    while (retryCount < 3) {
        try {
            procedureMapper.execProcedure();
            return;
        } catch (DeadlockLoserDataAccessException e) {
            retryCount++;
            if (retryCount == 3) throw e;
            Thread.sleep(100 * retryCount);
        }
    }
}
模式2:状态分离(最安全)
public void businessMethod() {
    // 1. 预存数据(状态为"待确认")
    String batchId = saveDataWithPendingStatus();
    
    // 2. 执行可能死锁的操作(独立事务)
    boolean success = executeRiskyOperationWithRetry();
    
    if (success) {
        // 3. 成功:确认数据
        confirmData(batchId);
    } else {
        // 4. 失败:清理预存数据
        rollbackPendingData(batchId);
        throw new RuntimeException("操作失败");
    }
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
public String saveDataWithPendingStatus() {
    // 保存数据,状态为'PENDING'
    insertDataWithStatus("PENDING");
    return generateBatchId();
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
public boolean executeRiskyOperationWithRetry() {
    // 重试逻辑
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void confirmData(String batchId) {
    // 修改状态为'CONFIRMED'
    updateStatus(batchId, "CONFIRMED");
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void rollbackPendingData(String batchId) {
    // 删除或标记无效
    deletePendingData(batchId);
}

第六部分:针对业务场景的解决方案

6.1 问题分析

你的业务场景:

@Transactional
public void checkConfirm() {
    // 🔴 重要操作:330次更新(绝对不能丢)
    batchUpdateImportantData();
    
    // 🟡 危险操作:存储过程(可能死锁)
    callProcedure();
    
    // 🟢 后续操作:差异更新
    updateDifference();
}

核心矛盾: 危险操作可能导致重要数据丢失。

6.2 解决方案对比

方案优点缺点适用场景
操作顺序调换简单,事务单一可能不符合业务顺序业务允许调整顺序时
状态分离数据安全,可追溯需要状态字段,两阶段推荐,适用你的场景
消息队列完全解耦,高可用复杂,需要消息中间件大型系统,允许异步
保存点最直接,代码改动小依赖数据库支持小范围临时方案

6.3 推荐方案:状态分离实现

@Service
public class WmsRecheckGoodsWholeService {
    
    @Autowired
    private WmsSorderInfoService sorderInfoService;
    
    public void checkConfirm(String sealDetailId) {
        // 1. 预存复核数据(状态为"待确认")
        String batchNo = saveRecheckDataWithPending(sealDetailId);
        
        // 2. 在独立事务中执行存储过程(带重试)
        boolean procedureSuccess = false;
        try {
            procedureSuccess = executeSorderUpdateWithRetry(sealDetailId);
        } catch (Exception e) {
            log.error("存储过程最终失败", e);
        }
        
        if (procedureSuccess) {
            // 3. 成功:确认数据
            confirmRecheckData(batchNo);
            // 4. 执行差异更新
            updateWholeRecheckCompleteDiffcount(sealDetailId);
        } else {
            // 5. 失败:清理待确认数据
            rollbackPendingRecheckData(batchNo);
            throw new RuntimeException("复核失败,数据已清理");
        }
    }
    
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public String saveRecheckDataWithPending(String sealDetailId) {
        // 330次更新,但状态为"待确认"
        sealDetailIds.forEach(id -> {
            WmsRecheckGoodsWhole entity = new WmsRecheckGoodsWhole();
            // ... 设置数据
            entity.setStatus("PENDING");  // 关键:待确认状态
            wmsRecheckGoodsWholeMapper.insert(entity);
            
            // 其他更新也标记为待确认
            updateWmsRecheckStripSealDetailWithPending(id);
            updateWmsPickingDetailWithPending(id);
            insertWmsSorderDetailOperateWithPending(id);
        });
        return generateBatchNo(sealDetailId);
    }
    
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public boolean executeSorderUpdateWithRetry(String sealDetailId) {
        int maxRetries = 3;
        int retryCount = 0;
        
        while (retryCount < maxRetries) {
            try {
                sorderInfoService.execSorderUpdateProcedure(sorderId, status);
                return true;
            } catch (DeadlockLoserDataAccessException e) {
                retryCount++;
                if (retryCount == maxRetries) {
                    log.error("死锁重试{}次后失败", maxRetries, e);
                    return false;
                }
                // 指数退避
                Thread.sleep(100 * (long) Math.pow(2, retryCount));
            } catch (Exception e) {
                log.error("其他异常", e);
                return false;
            }
        }
        return false;
    }
    
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void confirmRecheckData(String batchNo) {
        // 将状态从'PENDING'改为'CONFIRMED'
        wmsRecheckGoodsWholeMapper.confirmByBatchNo(batchNo);
        wmsRecheckStripSealDetailMapper.confirmByBatchNo(batchNo);
        wmsPickingDetailMapper.confirmByBatchNo(batchNo);
        wmsSorderDetailOperateMapper.confirmByBatchNo(batchNo);
    }
    
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void rollbackPendingRecheckData(String batchNo) {
        // 删除或标记无效
        wmsRecheckGoodsWholeMapper.deleteByBatchNo(batchNo);
        wmsRecheckStripSealDetailMapper.deleteByBatchNo(batchNo);
        wmsPickingDetailMapper.updateStatusByBatchNo(batchNo, "FAILED");
        wmsSorderDetailOperateMapper.deleteByBatchNo(batchNo);
    }
}

6.4 数据库表设计

-- 添加状态字段
ALTER TABLE wms_recheck_goods_whole ADD COLUMN data_status VARCHAR(20) DEFAULT 'PENDING';
ALTER TABLE wms_recheck_strip_seal_detail ADD COLUMN data_status VARCHAR(20) DEFAULT 'PENDING';
ALTER TABLE wms_picking_detail ADD COLUMN data_status VARCHAR(20) DEFAULT 'PENDING';
ALTER TABLE wms_sorder_detail_operate ADD COLUMN data_status VARCHAR(20) DEFAULT 'PENDING';

-- 添加批次号
ALTER TABLE wms_recheck_goods_whole ADD COLUMN batch_no VARCHAR(64);
ALTER TABLE wms_recheck_strip_seal_detail ADD COLUMN batch_no VARCHAR(64);
ALTER TABLE wms_picking_detail ADD COLUMN batch_no VARCHAR(64);
ALTER TABLE wms_sorder_detail_operate ADD COLUMN batch_no VARCHAR(64);

-- 创建索引
CREATE INDEX idx_batch_no ON wms_recheck_goods_whole(batch_no);
CREATE INDEX idx_data_status ON wms_recheck_goods_whole(data_status);

第七部分:最佳实践总结

7.1 核心原则

  1. 永不吞没数据库异常

    // ❌ 错误
    catch (Exception e) {
        log.error("error", e);
    }
    
    // ✅ 正确
    catch (Exception e) {
        log.error("error", e);
        throw e;
    }
    
  2. 危险操作必须独立事务

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void riskyOperation() {
        // 可能死锁的操作
    }
    
  3. 重要操作不能放在危险操作后面

    // ❌ 错误
    saveImportantData();  // 如果后面死锁,这些数据会丢
    riskyOperation();
    
    // ✅ 正确
    riskyOperation();     // 独立事务,失败不影响
    saveImportantData();  // 新事务
    
  4. 状态分离是最安全的模式

    • 预存数据(状态=待确认)
    • 执行危险操作
    • 成功则确认数据
    • 失败则清理数据

7.2 监控告警

-- 开启死锁日志
SET GLOBAL innodb_print_all_deadlocks = ON;

-- 查看最近死锁
SHOW ENGINE INNODB STATUS\G

-- 监控待确认数据
SELECT COUNT(*) FROM wms_recheck_goods_whole WHERE data_status = 'PENDING' AND create_time < NOW() - INTERVAL 1 HOUR;

结语

MySQL的死锁自动重启机制,本意是为了提高易用性,避免开发者手动处理事务回滚。但在Spring事务的上下文中,这个特性却成了一个"陷阱",导致了许多难以排查的数据不一致问题。

更关键的是,重试的真正意义不在于重试本身,而在于重试时重要的前置数据必须还在。如果前置数据已经因为死锁丢失了,重试成功也毫无意义。

理解这个机制,遵循"永不吞没异常"和"危险操作独立事务"的原则,是避免此类问题的关键。下次当你遇到"捕获了异常但事务还是提交了"的怪现象时,不妨想想:是不是MySQL悄悄替你重启了事务?你的前置数据还在吗?

记住:在MySQL中,死锁异常不是事务的终点,而是新事务的起点。保护好你的前置数据,重试才有意义。


你的应用中是否也遇到过类似的诡异问题?欢迎在评论区分享你的踩坑经历!

扩展阅读:

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

wolf犭良

谢谢您的阅读与鼓励!

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值