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,只能回滚
  -> 抛出 UnexpectedRollbackException

2. 场景总表

以下结论基于 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
  -> 最外层结束 -> ROLLBACK

rollback-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 时,不能只问“有没有异常”,还要问:

  1. 异常类型是什么?
  2. 对应事务拦截器是否真的执行?
  3. rollbackFor 和 noRollbackFor 如何配置?
  4. 异常在哪一层被捕获?

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_NEWNESTED
物理事务新事务通常仍是外层物理事务
实现机制暂停外层事务保存点
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
PersistenceException

12.5 开启事务调试日志

排查环境可以临时开启:

logging:
  level:
    org.springframework.transaction: DEBUG
    org.springframework.jdbc.datasource.DataSourceTransactionManager: DEBUG
    org.springframework.orm.jpa.JpaTransactionManager: DEBUG

不同项目只需开启实际使用的事务管理器,避免无关日志过多。

重点关注以下事件:

  • 创建新事务。
  • 加入已有事务。
  • 暂停和恢复事务。
  • 参与事务失败后设置 rollback-only。
  • 最外层提交时执行回滚。