Spring 事务什么时候会被标记为 rollback-only
先从一个订单创建场景开始。
A 方法负责创建订单,带有 @Transactional。它调用 B 方法扣减库存,并且已经用 try-catch 捕获了 B 的异常:
@Service
public class OrderService {
private final InventoryService inventoryService;
private final OrderRepository orderRepository;
@Transactional
public void createOrder() { // A 方法
orderRepository.insertOrder();
try {
inventoryService.deduct(); // 调用 B 方法
} catch (RuntimeException ex) {
log.warn("库存扣减失败,异常已捕获", ex);
}
orderRepository.updateStatus("CREATED");
}
}B 位于另一个 Spring Bean 中,也带有默认的 @Transactional:
@Service
public class InventoryService {
private final InventoryRepository inventoryRepository;
@Transactional
public void deduct() { // B 方法
inventoryRepository.deduct();
throw new IllegalStateException("库存扣减发生异常");
}
}从普通 Java 异常处理的角度看,B 的异常已经被 A 捕获,A 还执行了后续状态更新并正常返回。很多人因此认为订单事务应该正常提交。
实际结果通常是订单、库存和状态更新全部回滚,并在 A 方法结束时出现:
org.springframework.transaction.UnexpectedRollbackException:
Transaction rolled back because it has been marked as rollback-only问题不在 catch 是否执行,而在 B 抛出异常经过事务代理时,A 和 B 正在共享同一个事务。B 的事务拦截器已经把这个共享事务标记为 rollback-only。A 可以捕获 Java 异常,却不能通过 catch 清除事务状态。
调用过程可以直接画成:
A 开启事务 T1
-> B 以 REQUIRED 加入 T1
-> B 抛出运行时异常
-> B 的事务拦截器把 T1 标记为 rollback-only
-> A 捕获异常并继续执行
-> A 正常返回,准备提交 T1
-> T1 已不允许提交,只能回滚
-> 抛出 UnexpectedRollbackException但是,只改动这个例子中的一个条件,结果就可能完全不同。
情况一:去掉 B 的 @Transactional
如果 B 仍在另一个 Bean 中,但没有 @Transactional:
public void deduct() {
inventoryRepository.deduct();
throw new IllegalStateException("库存扣减发生异常");
}B 的 SQL 仍然运行在 A 已经开启的 T1 中。不过 B 方法入口没有事务拦截器,因此普通运行时异常被 A 捕获后,通常没有内层事务拦截器把 T1 标记为 rollback-only。
在没有 JPA、数据库或手动回滚等额外因素时,A 最终可能正常提交,连 B 抛异常前执行的库存 SQL 也会一起提交。
情况二:A、B 都有注解,但 B 是同类方法
如果把 B 移到 OrderService 中:
@Transactional
public void createOrder() {
try {
deduct(); // this.deduct(),同类自调用
} catch (RuntimeException ex) {
log.warn("库存扣减失败", ex);
}
}
@Transactional
public void deduct() {
inventoryRepository.deduct();
throw new IllegalStateException("库存扣减发生异常");
}默认代理模式下,this.deduct() 不会再次经过 Spring 事务代理。B 上虽然写了 @Transactional,这次调用却不会触发 B 的事务拦截器。
因此 A 捕获异常后,结果通常与“B 没有事务注解”相似:事务可能正常提交。即使把 B 改成 REQUIRES_NEW,同类自调用下也不会真的创建新事务。
情况三:跨 Bean 的 B 使用 REQUIRES_NEW
如果 B 仍通过另一个 Bean 的代理调用,但改成:
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void deduct() {
inventoryRepository.deduct();
throw new IllegalStateException("库存扣减发生异常");
}Spring 会暂停 A 的 T1,为 B 创建独立事务 T2。B 失败时只回滚 T2;A 捕获异常后,T1 可以继续提交,通常不会因为 B 的回滚而出现 UnexpectedRollbackException。
情况四:A 不捕获 B 的异常
如果删除 A 的 try-catch,异常会一直传播出最外层事务方法。A 的事务拦截器直接回滚事务,调用方通常看到原始的 IllegalStateException,而不是提交阶段的 UnexpectedRollbackException。
同样是“A 调 B,B 抛异常”,结果却可能是:
| 条件 | 典型结果 |
|---|---|
跨 Bean,B 为 REQUIRED,A 捕获异常 | 共享事务已被标记,A 提交时抛 UnexpectedRollbackException |
| B 无事务注解,A 捕获异常 | 通常没有内层切面设置回滚标记,A 可能提交 |
| 同类自调用 B,A 捕获异常 | B 的事务注解被绕过,A 可能提交 |
跨 Bean,B 为 REQUIRES_NEW,A 捕获异常 | B 的新事务回滚,A 的事务可以提交 |
| A 不捕获 B 的运行时异常 | 异常传播出 A,外层事务回滚,通常返回原始异常 |
所以,判断 Spring 事务是否回滚,不能只看“哪个方法写了 @Transactional”或者“异常有没有被捕获”。还必须同时确认:
- B 的调用是否经过 Spring 代理。
- B 使用哪种事务传播行为。
- A 和 B 是否共享同一个物理事务。
- 异常是否满足事务的回滚规则。
- 在异常被捕获前,事务是否已经被标记为
rollback-only。
下面再从这些变量出发,逐步拆解每种场景。
1. 先记住四个核心结论
1.1 没有注解的方法也可以运行在事务中
@Transactional 决定的是“是否在方法入口处通过事务拦截器创建、加入或切换事务”,并不表示只有带注解的方法才能执行在事务中。
如果 A 已经开启事务,A 在当前线程中直接调用没有注解的 B,那么 B 执行的数据库操作仍然使用 A 的事务:
A 开启事务
-> A 调用 B
-> B 沿用当前线程绑定的连接和事务资源
-> A 结束时统一提交或回滚1.2 同类自调用会绕过 Spring 事务代理
在默认代理模式下,外部对象调用 Spring Bean 时会先经过代理;类内部使用 this.b() 调用另一个方法时,不会再次经过代理。
因此,同一个类中 B 上的 @Transactional 可能完全不生效:
@Service
public class OrderService {
@Transactional
public void a() {
b(); // 等价于 this.b(),没有再次经过事务代理
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void b() {
// REQUIRES_NEW 不会生效,仍然运行在 A 的事务中
}
}这里不是“B 的事务加入了 A”,而是 B 的事务注解根本没有被事务拦截器读取。
1.3 加入同一事务的内层方法可以把共享事务标记为 rollback-only
当 A、B 位于不同的 Spring Bean,B 使用默认的 Propagation.REQUIRED 时,B 会加入 A 已经存在的事务。
如果 B 的事务拦截器判断本次异常需要回滚,它无法只回滚共享事务中的“B 那一段”,通常会把整个共享事务标记为 rollback-only。
A 捕获异常只能阻止 Java 异常继续向上传播,不能撤销这个事务标记。
1.4 UnexpectedRollbackException 通常出现在最外层提交阶段
内层 B 抛出异常后,如果异常一直传播出 A,调用方通常先看到原始业务异常。此时回滚符合调用方预期,并不“unexpected”。
最典型的 UnexpectedRollbackException 场景是:
B 加入 A 的事务
-> B 抛出运行时异常
-> B 的事务拦截器把共享事务标记为 rollback-only
-> A 捕获异常并正常返回
-> A 的事务拦截器尝试提交
-> 发现 rollback-only,只能回滚
-> 抛出 UnexpectedRollbackException2. 场景总表
以下结论基于 Spring 默认代理模式、A 通过 Spring 代理从外部进入、B 抛出默认会触发回滚的运行时异常。
| A 与 B 的关系 | B 的事务声明 | A 是否捕获 B 异常 | 典型结果 |
|---|---|---|---|
| 同类自调用 | B 无注解 | 否 | 异常传播出 A,A 事务回滚 |
| 同类自调用 | B 无注解 | 是 | 通常提交,因为没有 B 的事务拦截器标记 rollback-only |
| 同类自调用 | B 有 REQUIRED | 否 | B 注解被绕过;异常传播出 A,A 事务回滚 |
| 同类自调用 | B 有 REQUIRED | 是 | B 注解被绕过;通常提交 |
| 同类自调用 | B 有 REQUIRES_NEW | 是或否 | B 注解被绕过,不会创建新事务,实际仍在 A 事务中 |
| 跨 Bean 调用 | B 无注解 | 否 | 异常传播出 A,A 事务回滚 |
| 跨 Bean 调用 | B 无注解 | 是 | 通常提交,没有 B 的事务切面主动标记 rollback-only |
| 跨 Bean 调用 | B 为 REQUIRED | 否 | B 标记共享事务回滚,异常继续传播,整个事务回滚 |
| 跨 Bean 调用 | B 为 REQUIRED | 是 | 共享事务已是 rollback-only;A 提交时通常抛 UnexpectedRollbackException |
| 跨 Bean 调用 | B 为 REQUIRES_NEW | 是 | B 的新事务回滚;A 的外层事务可以继续提交 |
| 跨 Bean 调用 | B 为 REQUIRES_NEW | 否 | B 的新事务回滚;异常传播出 A 后,A 通常也回滚 |
| 跨 Bean 调用 | B 为 NESTED | 是 | B 通常回滚到保存点;A 可继续并提交外层事务 |
表中的“通常提交”有前提:数据库驱动、JPA Provider 或业务代码没有以其他方式把底层事务标记为 rollback-only。后文会说明这些例外。
3. A 有事务,B 没有事务注解
先看最简单的代码:
@Service
public class OrderService {
@Transactional
public void a() {
orderRepository.insertOrder();
b();
}
public void b() {
inventoryRepository.deduct();
throw new IllegalStateException("扣减库存后发生异常");
}
}B 虽然没有 @Transactional,但它是在 A 的事务上下文中执行的。订单写入和库存扣减仍然属于同一个物理事务。
3.1 A 不捕获异常
@Transactional
public void a() {
orderRepository.insertOrder();
b();
}B 抛出的 IllegalStateException 一直传播出 A。A 的事务拦截器看到运行时异常后执行回滚:
B 抛出 RuntimeException
-> 异常传播出 A
-> A 的事务拦截器执行 rollback
-> A、B 的数据库操作全部回滚此时不需要 B 自己带事务注解。
3.2 A 捕获异常
@Transactional
public void a() {
orderRepository.insertOrder();
try {
b();
} catch (IllegalStateException ex) {
log.warn("库存扣减失败", ex);
}
}如果没有其他因素介入,异常不会离开 A,A 的事务拦截器看到的是“方法正常返回”,因此会尝试提交。
这里没有 B 的事务拦截器,所以仅仅执行下面这条语句并不会自动产生一个独立的 rollback-only 标记:
throw new IllegalStateException("扣减库存后发生异常");结果可能是订单和 B 抛异常前已经执行的 SQL 一起提交。这也是“在事务方法内部吞异常”容易制造数据不一致的原因。
4. A、B 都有事务,但位于同一个类
@Service
public class OrderService {
@Transactional
public void a() {
try {
b();
} catch (RuntimeException ex) {
log.warn("B 执行失败", ex);
}
}
@Transactional
public void b() {
inventoryRepository.deduct();
throw new IllegalStateException("B 执行失败");
}
}调用链是:
外部调用
-> OrderService 代理
-> 开启 A 的事务
-> 目标对象 a()
-> this.b()
-> 没有经过代理因此 B 上的以下配置都会被绕过:
propagation。rollbackFor和noRollbackFor。isolation。timeout。readOnly。
如果 A 捕获 B 的普通运行时异常,Spring 通常不会因为 B 的注解而把事务标记为 rollback-only,因为 B 的事务拦截器根本没有执行。
如果异常继续传播出 A,则 A 的事务拦截器仍会根据 A 的规则回滚。
4.1 把 B 改成 public 也不能解决自调用
很多代码已经把 B 声明为 public,但下面的调用仍是自调用:
public void a() {
this.b();
}public 只是代理能够拦截的必要条件之一,不会让 this.b() 自动绕回代理。
更清晰的做法通常是把 B 拆到另一个职责明确的 Bean:
@Service
public class OrderService {
private final InventoryService inventoryService;
public OrderService(InventoryService inventoryService) {
this.inventoryService = inventoryService;
}
@Transactional
public void a() {
inventoryService.b();
}
}5. A、B 都有事务,并且位于不同 Bean
@Service
public class OrderService {
private final InventoryService inventoryService;
@Transactional
public void a() {
orderRepository.insertOrder();
try {
inventoryService.b();
} catch (RuntimeException ex) {
log.warn("忽略库存异常,继续执行", ex);
}
auditRepository.insertAudit();
}
}
@Service
public class InventoryService {
@Transactional
public void b() {
inventoryRepository.deduct();
throw new IllegalStateException("库存服务失败");
}
}默认传播行为是:
@Transactional(propagation = Propagation.REQUIRED)B 通过 InventoryService 的代理进入。代理发现当前已经存在 A 的事务,于是 B 加入该事务。
5.1 A 不捕获异常
调用过程如下:
A 开启事务 T1
-> B 加入 T1
-> B 抛出 RuntimeException
-> B 的事务拦截器要求回滚 T1
-> 异常继续传播出 A
-> A 的事务拦截器回滚 T1订单、库存和审计操作都会回滚。调用方一般看到原始的 IllegalStateException。
5.2 A 捕获异常
调用过程变成:
A 开启事务 T1
-> B 加入 T1
-> B 抛出 RuntimeException
-> B 的事务拦截器把 T1 标记为 rollback-only
-> A 捕获异常
-> A 继续执行 SQL 并正常返回
-> A 尝试提交 T1
-> 事务管理器发现 rollback-only
-> 回滚 T1
-> 抛出 UnexpectedRollbackException需要特别注意:A 在 catch 后执行的代码,不代表最终能够提交。
try {
inventoryService.b();
} catch (RuntimeException ex) {
log.warn("已经捕获异常");
}
auditRepository.insertAudit(); // SQL 可能执行成功,但最终仍会随 T1 回滚catch 处理的是 Java 控制流,rollback-only 属于事务状态,两者不是同一个层面。
6. rollback-only 到底是什么
可以把一个事务简化成以下状态:
ACTIVE
-> 正常结束 -> COMMIT
-> 出现回滚条件 -> ROLLBACK_ONLY
-> 最外层结束 -> ROLLBACKrollback-only 不是立即关闭事务,也不一定立即抛异常。它表达的是:
当前事务还可以继续运行,但最终不允许提交。对于 REQUIRED 加入的共享事务,内层逻辑没有独立物理事务可以单独回滚。内层事务拦截器执行“回滚”时,常见结果就是给共享事务设置全局回滚标记。
最终是否抛出 UnexpectedRollbackException,由事务管理器、传播行为、异常是否继续传播等因素共同决定。不能把所有回滚都等同于该异常。
7. 哪些异常默认触发回滚
Spring 默认规则是:
RuntimeException及其子类触发回滚。Error及其子类触发回滚。- 受检异常
Exception默认不触发回滚。
例如:
@Transactional
public void save() throws IOException {
repository.insert();
throw new IOException("文件写入失败");
}IOException 默认不会让 Spring 回滚事务。需要显式配置:
@Transactional(rollbackFor = Exception.class)
public void save() throws IOException {
repository.insert();
throw new IOException("文件写入失败");
}也可以排除某类异常:
@Transactional(noRollbackFor = BizNoticeException.class)
public void save() {
// BizNoticeException 不触发回滚
}判断 rollback-only 时,不能只问“有没有异常”,还要问:
- 异常类型是什么?
- 对应事务拦截器是否真的执行?
rollbackFor和noRollbackFor如何配置?- 异常在哪一层被捕获?
8. REQUIRES_NEW:让 B 使用独立事务
如果 B 的失败不应该污染 A,可以让 B 开启新事务:
@Service
public class InventoryService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void b() {
inventoryRepository.deduct();
throw new IllegalStateException("B 执行失败");
}
}跨 Bean 调用时,事务链路为:
A 开启 T1
-> 调用 B
-> 暂停 T1
-> B 开启 T2
-> B 异常,回滚 T2
-> 恢复 T1
-> A 捕获异常
-> A 提交 T1此时 B 的回滚不会自动把 T1 标记为 rollback-only。
但必须注意两个方向的独立性:
- B 回滚后,A 捕获异常,A 可以提交。
- B 已经提交后,即使 A 后续回滚,也不会撤销 B 的提交。
因此 REQUIRES_NEW 适合确实需要独立提交边界的业务,例如某些审计、补偿记录或独立状态更新。不能为了消除 UnexpectedRollbackException 就机械使用,否则可能破坏原本需要的原子性。
此外,如果 A、B 在同一个类中自调用,B 的 REQUIRES_NEW 仍然不会生效。
9. NESTED:使用保存点回滚局部操作
NESTED 通常在同一个物理事务中创建保存点:
@Transactional(propagation = Propagation.NESTED)
public void b() {
inventoryRepository.deduct();
throw new IllegalStateException("B 执行失败");
}典型过程是:
A 开启事务 T1
-> B 创建保存点 S1
-> B 执行 SQL
-> B 异常
-> 回滚到 S1
-> A 捕获异常并继续
-> A 提交 T1 中保存点之前和之后的有效操作它与 REQUIRES_NEW 的区别是:
| 对比项 | REQUIRES_NEW | NESTED |
|---|---|---|
| 物理事务 | 新事务 | 通常仍是外层物理事务 |
| 实现机制 | 暂停外层事务 | 保存点 |
| B 成功后 A 回滚 | B 已提交,不受影响 | B 随 A 一起回滚 |
| 资源要求 | 需要额外事务资源 | 需要事务管理器和数据库支持保存点 |
NESTED 并非在所有事务管理器中都可用。DataSourceTransactionManager 对 JDBC 保存点支持较直接;JPA 场景受 JpaTransactionManager、JDBC 访问方式和具体 Provider 限制,使用前必须通过集成测试确认。
10. 即使 A 捕获异常,事务仍可能被其他机制标记回滚
前文说“B 没有事务注解且 A 捕获异常时通常提交”,这里的“通常”非常重要。
以下情况仍可能让事务失去提交资格。
10.1 手动设置 rollback-only
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();设置后,即使方法正常返回,最终也只能回滚。
10.2 JPA Provider 标记底层事务失败
某些持久化异常发生后,JPA Provider 会把当前事务标记为 rollback-only。业务代码捕获 Java 异常,也不代表底层事务状态能够恢复。
例如唯一键冲突、约束异常或 flush 失败,可能在不同数据库和 Provider 下产生不同表现。不能依赖“catch 以后继续提交”来恢复一个已经失败的持久化上下文。
10.3 数据库事务已经进入失败状态
部分数据库在某条 SQL 失败后,会要求整个事务回滚。此时继续执行 SQL 也可能失败。Spring 是否设置本地标记并不是唯一决定因素,底层资源本身也有事务状态。
10.4 更深层的事务方法已经标记共享事务
即使当前看到的 B 没有注解,B 里面还可能调用另一个带 REQUIRED 的 C。C 的事务拦截器可以把共享事务标记为 rollback-only。
排查时必须沿完整调用链检查,不能只看 A 和 B 两个方法。
11. 常见误区
11.1 “B 没有 @Transactional,所以 B 不在事务里”
错误。只要 A 已开启事务,并且 B 没有使用异步线程或显式挂起事务,B 的数据库操作通常仍在 A 的事务中。
11.2 “A 捕获了异常,所以事务一定能提交”
错误。异常可以被捕获,但 rollback-only 标记不会因此自动清除。
11.3 “B 写了 REQUIRES_NEW,所以一定是新事务”
错误。同类自调用、对象不是 Spring Bean、方法不满足代理条件等情况,都可能导致事务注解没有被拦截。
11.4 “出现 UnexpectedRollbackException 的地方就是根因”
错误。这个异常通常出现在最外层提交处,真正设置 rollback-only 的位置可能在更早的内层调用中。
11.5 “把所有异常都 catch 住就不会回滚”
错误。这既无法清除已经存在的事务标记,也可能让调用方误以为操作成功。
12. 如何快速定位是谁设置了 rollback-only
12.1 先画出代理调用链
至少标记以下信息:
Controller
-> AService.a() @Transactional(REQUIRED)
-> BService.b() @Transactional(REQUIRED)
-> CService.c() 无事务注解同时确认每次调用是跨 Bean,还是 this.xxx() 自调用。
12.2 检查异常捕获位置
重点寻找:
try {
bService.b();
} catch (Exception ex) {
log.warn("忽略异常", ex);
}如果 B 是跨 Bean 的 REQUIRED,这段代码就是 UnexpectedRollbackException 的高发结构。
12.3 检查回滚规则
查看:
- 异常是运行时异常还是受检异常。
- 是否配置
rollbackFor。 - 是否配置
noRollbackFor。 - 是否存在统一异常包装,把受检异常转换成运行时异常。
12.4 检查手动回滚和底层异常
搜索:
setRollbackOnly
TransactionAspectSupport
TransactionStatus
UnexpectedRollbackException
DataIntegrityViolationException
ConstraintViolationException
PersistenceException12.5 开启事务调试日志
排查环境可以临时开启:
logging:
level:
org.springframework.transaction: DEBUG
org.springframework.jdbc.datasource.DataSourceTransactionManager: DEBUG
org.springframework.orm.jpa.JpaTransactionManager: DEBUG不同项目只需开启实际使用的事务管理器,避免无关日志过多。
重点关注以下事件:
- 创建新事务。
- 加入已有事务。
- 暂停和恢复事务。
- 参与事务失败后设置 rollback-only。
- 最外层提交时执行回滚。