rollbackFor 不设置,Checked 异常不回滚:Spring 默认规则与两行修复的取舍

本文摘要:Spring不回滚受检异常,提交后数据已变,对账才暴露。最小示例还原根因,rollbackFor扩展回滚,拆类避自调用陷阱。异常可控时才加rollbackFor,自调用先拆类,吞异常须重抛。

一、问题与结论

场景 :Spring Boot 3.2 转账服务,transfer 方法标注 @Transactional。业务逻辑是先查两个账户余额,修改后 save 保存,最后校验余额------若转出方余额不足,抛出 InsufficientBalanceException(继承 Exception)。接口正常返回"余额不足",调用方也捕获到了异常,但数据库 balance 已被扣成负数。日志无异常堆栈,事务管理器无警告,直到财务对账才发现金额不对。

Java 中 Checked 异常要求调用方显式处理,通常用于可预期的业务失败。但 Spring 事务语境下这类异常不触发回滚,与"抛出异常就应回滚"的直觉相悖。Spring 自 5.x 到 6.x、Spring Boot 2.x 到 3.x 均保持此默认行为,升级框架版本不解决问题。

结论 :Spring 默认只对 RuntimeException 和 Error 回滚,Checked 异常不回滚。添加 rollbackFor = {Exception.class} 即可修复。但自调用绕代理和异常被 catch 吞掉是另外两类问题,仅加 rollbackFor 无法覆盖。

二、排查与选择依据

排查事务不回滚,按三步逐条确认:

第一步:确认异常类型。 查看类声明:extends Exception 是 Checked,extends RuntimeException 是 Unchecked。IDE 中被 throw 的 Checked 异常须在方法签名声明 throws,这也是快速识别信号。IOException、SQLException 及自定义 extends Exception 的类型均需设置 rollbackFor。

第二步:确认代理是否生效。 在方法入口打印 AopUtils.isAopProxy(this)。返回 false 说明当前对象不是代理。最常见原因是同一 Bean 内 this.method() 调用------绕过 AOP 代理,@Transactional 完全不生效。Spring AOP 只拦截 public 方法外部调用,private 方法上的注解也不会被识别。此时须先把方法拆到另一个 Bean。

第三步:确认异常是否被吞。 若 catch 块只打日志没有重新抛出,异常不传播到事务代理层。回滚判定在代理层执行,代理层收不到异常不触发回滚。修复方式是重新抛出异常,或在 catch 中调用 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。

替代方案与取舍

方案 改动量 代价 适用边界
rollbackFor = {Exception.class} 每注解 1 行 极低 异常类型可控,中小型项目
精确指定 rollbackFor = {XxxException.class} 每注解确认 低 回滚范围需精细控制
业务异常继承 RuntimeException 异常体系重构 中 新项目或愿意做异常治理
TransactionTemplate 编程式事务 业务代码改写 高 需精细控制回滚时机

不应使用 rollbackFor 的场景有三类:只读查询抛 Checked 异常不需要回滚;异常代表可恢复业务分支应在调用层 catch 走降级逻辑;项目已统一用 RuntimeException 作为业务异常基类,Spring 默认规则已覆盖。

三、关键原理

3.1 回滚判定逻辑

Spring 事务代理捕获异常后调用 TransactionAttribute.rollbackOn(ex) 决定是否回滚。默认实现:

java 复制代码
// DefaultTransactionAttribute 源码(Spring 6.x 简化)
@Override
public boolean rollbackOn(Throwable ex) {
    return (ex instanceof RuntimeException || ex instanceof Error);
}

设置 rollbackFor 后判定逻辑变为 RuleBasedTransactionAttribute:先查 noRollbackFor(命中则不回滚,优先级最高),再查 rollbackFor(命中则回滚),均不命中退回默认规则。Error 虽非 Exception 子类,但已默认触发回滚,故 rollbackFor = {Exception.class} 配合默认规则实际覆盖所有 Throwable 子类。

3.2 代理机制与自调用陷阱

@Transactional 基于 Spring AOP 代理实现。外部调用 transferService.transfer(...) 时请求经代理对象(JDK 动态代理或 CGLIB)拦截。但同一 Bean 内 this.transfer(...) 走目标对象直接调用,不经过代理。此时 @Transactional 完全不生效。修复 rollbackFor 无法解决,必须拆方法到独立 Bean,或通过 AopContext.currentProxy() 获取代理调用。

四、可运行示例

4.1 环境

JDK 17 + Spring Boot 3.2.x + H2。pom.xml 需 spring-boot-starter-data-jpa、h2、spring-boot-starter-test 三个依赖,无需额外配置。Account 实体包含 accountName 和 balance 两个字段,AccountRepository 继承 JpaRepository 提供 CRUD。

4.2 核心代码

自定义 Checked 异常:

java 复制代码
public class InsufficientBalanceException extends Exception {
    public InsufficientBalanceException(String message) { super(message); }
}

Service(问题版本,未设置 rollbackFor):

java 复制代码
@Service
public class TransferService {
    private final AccountRepository accountRepository;

    public TransferService(AccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }

    @Transactional
    public void transfer(Long fromId, Long toId, BigDecimal amount)
            throws InsufficientBalanceException {
        Account from = accountRepository.findById(fromId).orElseThrow();
        Account to = accountRepository.findById(toId).orElseThrow();
        from.setBalance(from.getBalance().subtract(amount));
        to.setBalance(to.getBalance().add(amount));
        accountRepository.save(from);
        accountRepository.save(to);
        if (from.getBalance().signum() < 0) {
            throw new InsufficientBalanceException("余额不足");
        }
    }
}

测试用例:

java 复制代码
import static org.junit.jupiter.api.Assertions.fail;

@SpringBootTest
class TransferServiceTest {
    @Autowired TransferService transferService;
    @Autowired AccountRepository accountRepository;

    @Test
    void checkedExceptionNotRollback() {
        Account a = new Account();
        a.setAccountName("A");
        a.setBalance(new BigDecimal("100"));
        a = accountRepository.save(a);
        Account b = new Account();
        b.setAccountName("B");
        b.setBalance(new BigDecimal("50"));
        b = accountRepository.save(b);
        try {
            transferService.transfer(a.getId(), b.getId(), new BigDecimal("200"));
        } catch (InsufficientBalanceException e) {
            // 预期异常
        }
        Account after = accountRepository.findById(a.getId()).orElseThrow();
        if (after.getBalance().compareTo(new BigDecimal("100")) != 0) {
            fail("余额未回滚,实际为: " + after.getBalance());
        }
    }
}

4.3 运行步骤

bash 复制代码
mvn test -Dtest=TransferServiceTest#checkedExceptionNotRollback

预期输出(修复前,余额未回滚,断言失败):

text 复制代码
余额未回滚,实际为: -100

实际输出 (添加 rollbackFor 后重跑):

text 复制代码
Tests run: 1, Failures: 0, Errors: 0

4.4 修复

java 复制代码
@Transactional(rollbackFor = {Exception.class})

或精确到指定异常:

java 复制代码
@Transactional(rollbackFor = {InsufficientBalanceException.class})

五、验证与边界

修复后余额回滚,断言通过。验证方法是查库断言余额值,而非仅验证异常是否抛出------异常抛出和事务回滚是两件独立的事。

边界一:自调用绕代理。 batchTransfer 内部 this.transfer(...) 调用时,即使加 rollbackFor 也不生效。修复方式是拆到独立 @Service 类注入调用,或启动类加 @EnableAspectJAutoProxy(exposeProxy = true) 后通过 AopContext.currentProxy() 获取代理。拆类更直观,维护成本更低。

边界二:异常被吞。 catch 只打日志没有重新抛出,代理层收不到异常不触发回滚。修复方式是在 catch 末尾抛出异常,或调用 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。

边界三:只读与可恢复场景。 查询方法抛 Checked 异常不需要回滚,盲目加 rollbackFor 会掩盖业务语义。异常代表可恢复业务分支时,应在调用层 catch 走降级逻辑。

三种边界常叠加出现,逐层确认代理链和异常传播链是定位关键。

思考

  1. 业务异常统一继承 RuntimeException 与逐个注解写 rollbackFor,哪种更适合异构团队?
  2. 批量操作中途部分失败时,REQUIRES_NEW 独立事务与拆分多次调用的适用条件如何界定?

参考资料

相关推荐
孙启超2 小时前
【AI开发之Rust】第 17 课:项目总览与核心架构 —— AI 助手 Rust 核心从 0 到 1
开发语言·后端·rust
❀͜͡傀儡师2 小时前
Spring AI 集成 TypeSafe:用判断模型处理工单分流与链路决策
java·人工智能·spring
bro_Java6662 小时前
《栈与队列:数据结构的“双生花”》
java·数据结构·编辑器
花开路口2 小时前
线程安全完全指南:从 Java 到 Kotlin,一文吃透并发编程
java·kotlin
她说..2 小时前
IntelliJ IDEA 快捷键速查表(Mac 版)
java·macos·intellij-idea
EatFan3 小时前
JunoYi 框架实践:Spring Boot 项目为什么拆成 framework、module、server 三层?
java·spring boot·后端·framework·module·模块化·junoyi
汉堡大王95273 小时前
一张图三句需求,我用 Trae Work 做了一块能看日出日落和月相的天文机械表
前端·后端·github
mudtools3 小时前
在.NET现有系统中快速集成飞书任务分配能力
后端·c#·.net
Wang's Blog3 小时前
Java 项目实战: 外卖平台优化-从库Slave配置与主从复制验证
java·开发语言