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 标准的事务回滚流程
按照正常的理解,当数据库检测到死锁时,应该:
- 选择一个事务作为"牺牲品"
- 彻底回滚这个事务
- 将该事务标记为终止状态
- 向客户端返回死锁错误
- 后续任何操作都会失败,直到客户端执行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 核心原则
-
永不吞没数据库异常
// ❌ 错误 catch (Exception e) { log.error("error", e); } // ✅ 正确 catch (Exception e) { log.error("error", e); throw e; } -
危险操作必须独立事务
@Transactional(propagation = Propagation.REQUIRES_NEW) public void riskyOperation() { // 可能死锁的操作 } -
重要操作不能放在危险操作后面
// ❌ 错误 saveImportantData(); // 如果后面死锁,这些数据会丢 riskyOperation(); // ✅ 正确 riskyOperation(); // 独立事务,失败不影响 saveImportantData(); // 新事务 -
状态分离是最安全的模式
- 预存数据(状态=待确认)
- 执行危险操作
- 成功则确认数据
- 失败则清理数据
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中,死锁异常不是事务的终点,而是新事务的起点。保护好你的前置数据,重试才有意义。
你的应用中是否也遇到过类似的诡异问题?欢迎在评论区分享你的踩坑经历!
扩展阅读:


453

被折叠的 条评论
为什么被折叠?



