Spring 事务提交顺序深度解析:从踩坑到理解原理
本文基于真实开发场景,结合 Spring Boot + MySQL + MyBatis-Plus 技术栈,深入讲解
@Transactional的事务提交顺序、子服务事务传播、以及一个让很多人困惑的"日志打了但数据没提交"的经典问题。
一、没有 @Transactional 时,提交是什么顺序?
先看最基础的情况,假设 Service 方法上没有任何事务注解:
java
public boolean addRewardOrder(AddOrderDTO addOrderDTO) {
// ...
paymentOnAutomService.save(paymentOnAutom); // 第1步
save(aOrder); // 第2步
paymentQuzrtzService.save(paymentQuartz); // 第3步
}
每一句 save 就是一次独立提交,顺序如下:
① paymentOnAutomService.save() ──→ 立即提交到数据库(循环几次提交几次)
② save(aOrder) ──→ 立即提交到数据库
③ paymentQuzrtzService.save() ──→ 立即提交到数据库(循环几次提交几次)
代码从上往下执行,遇到一个 save 就立刻提交一次,不存在"等到最后一起提交"的概念。
这种方式的风险极大:
save(paymentOnAutom)存了几条记录,然后save(aOrder)失败 → 付款记录存在,但订单不存在,数据孤儿save(aOrder)成功,但save(paymentQuartz)中途失败 → 支付队列不完整
二、加上 @Transactional 之后,提交顺序是什么?
写在类上和写在方法上效果一样
java
@Slf4j
@Service
@Transactional // 写在类上,等价于所有 public 方法都加了 @Transactional
public class OrderServiceImpl extends ServiceImpl<...> implements OrderService {
}
加上事务之后,只有一次提交,不再有顺序的概念:
方法开始 ──→ BEGIN(开启事务)
① paymentOnAutomService.save() × N ← SQL已发送,未提交
② save(aOrder) ← SQL已发送,未提交
③ paymentQuzrtzService.save() × N ← SQL已发送,未提交
方法正常返回 ──→ COMMIT(全部一次性写入数据库)
任意步骤异常 ──→ ROLLBACK(全部回滚,数据库无任何变化)
要么全部成功,要么全部回滚,不存在"谁先提交谁后提交"的说法。
注意:默认不回滚 Checked Exception
类上的 @Transactional 默认等价于:
java
@Transactional(rollbackFor = {RuntimeException.class, Error.class})
如果抛出的是 IOException、自定义 Exception 等 checked exception,不会触发回滚。建议统一改成:
java
@Transactional(rollbackFor = Exception.class)
三、经典疑问:日志打印出来了,但数据没提交?
这是很多开发者遇到过的困惑,来看这段代码:
java
if (save) {
for (APaymentOnAutom paymentOnAutom : paymentOnAutomList) {
log.info("奖励支付订单添加到支付队列!{}", paymentOnAutom); // 日志立即打印
paymentQuzrtzService.save(PaymentQuartz.builder()
.paymentId(paymentOnAutom.getId())
.build());
}
}
log.info 和数据库提交是两回事。
log.info直接输出到控制台,不受事务控制,立即执行save的 SQL 已经发送给数据库连接,但处于未提交状态
所以你看到日志,不代表数据已经写入数据库。数据要等到整个方法正常返回后才会 COMMIT。
如果你看到了日志,但数据库里没有数据,通常是方法后续抛出了异常触发了回滚:
log 打印了 ──→ save 发送了 SQL ──→ 后续某处抛异常 ──→ ROLLBACK ──→ 数据消失
四、子服务没有 @Transactional,save() 为什么不能加入当前事务?
这是一个非常底层但重要的知识点。
问题现象
java
// 调用子服务的 save,数据没有加入当前事务
paymentQuzrtzService.save(paymentQuartz); // ❌ 独立提交,不受当前事务控制
// 改用 getBaseMapper().insert(),反而能加入当前事务
paymentQuzrtzService.getBaseMapper().insert(paymentQuartz); // ✅ 加入当前事务
根本原因
Spring 事务是通过 AOP 代理 实现的。MyBatis-Plus 的 IService.save() 内部实现是这样的:
java
default boolean save(T entity) {
return SqlHelper.retBool(getBaseMapper().insert(entity));
}
当 PaymentQuzrtzServiceImpl 没有加 @Transactional 时,这个 save() 方法不经过 Spring 的事务代理,它会新开一个数据库连接,用自己的连接独立提交,和你当前事务的连接不是同一个。
而 getBaseMapper().insert() 走的是 MyBatis 的 Mapper 层,MyBatis 在执行 SQL 前会检查当前线程是否绑定了事务连接,有的话直接复用,所以它自动加入了你的事务。
三种情况对比
| 调用方式 | 子服务有无 @Transactional |
是否加入当前事务 |
|---|---|---|
service.save() |
✅ 有 | ✅ 加入,一起提交 |
service.save() |
❌ 无 | ❌ 独立提交,无法回滚 |
getBaseMapper().insert() |
无所谓 | ✅ 自动加入当前事务 |
正确解决方案
不推荐用 getBaseMapper().insert() 绕过去,正确做法是给子服务加上 @Transactional:
java
@Service
@Transactional(rollbackFor = Exception.class) // ✅ 加上这个
public class PaymentQuzrtzServiceImpl extends ServiceImpl<PaymentQuartzMapper, PaymentQuartz>
implements PaymentQuzrtzService {
}
加上之后,两个 service 的事务传播级别都是默认的 PROPAGATION_REQUIRED,含义是:
当前已有事务 → 加入这个事务(同一个连接,同一次提交)
这样所有 save 调用都在同一个事务里,方法正常返回时统一提交。
五、总结
| 场景 | 提交方式 | 风险 |
|---|---|---|
无 @Transactional |
每个 save 立即独立提交 |
数据不一致,无法回滚 |
有 @Transactional,子服务也有 |
方法返回时统一一次提交 | ✅ 安全 |
有 @Transactional,子服务没有 |
子服务独立提交,主服务回滚时子服务数据无法还原 | ⚠️ 部分数据不一致 |
有 @Transactional,抛 checked exception |
默认不回滚 | ⚠️ 需配置 rollbackFor |
一句话记住核心:Spring 事务的提交发生在方法正常返回的那一刻,日志打印和数据库提交无关,子服务必须也受事务管理才能保证原子性。
技术栈 :Spring Boot · MySQL · MyBatis-Plus
关键注解 :@Transactional(rollbackFor = Exception.class)
传播级别 :PROPAGATION_REQUIRED(默认)