StackTraces
Spring Boot已验证

Spring Boot 受检异常不回滚与 rollbackFor

@debug_master27 天前更新阅读 6 min00

@Transactional 默认的回滚规则仅对未受检异常(RuntimeException)和系统错误(Error)生效。当业务方法抛出受检异常(Checked Exception,如 SQLException、IOException 或直接继承自 Exception 的自定义业务异常)时,Spring 事务拦截器会将其视为调用方预期内的异常,判定不满足回滚条件,导致事务照常提交。

Issue & Env(案发现场与环境)

运行环境

  • Spring Boot 2.x / 3.x
  • Spring Framework 5.x / 6.x
  • 持久层框架:MyBatis / Spring Data JPA

业务场景

在文件批量数据导入或外部接口数据同步业务中,业务方法在执行完主表插入后,遇到 IO 异常、校验异常或三方接口通讯异常并向上抛出 IOException 或继承自 Exception 的受检异常。开发者直觉认为“既然方法抛出了异常,数据理应自动全部回滚”,但排查数据库时却发现前面的数据已经持久化入库,造成脏数据或数据不一致。

核心特征表

Spring 事务针对不同异常类型的默认回滚行为如下:

异常类型 默认行为 代表异常
RuntimeException及其子类 自动触发事务回滚(Rollback) NullPointerException, IllegalArgumentException, IllegalStateException
Error及其子类 自动触发事务回滚(Rollback) OutOfMemoryError, StackOverflowError
受检异常(Checked Exception) 照常提交事务(Commit) IOException, SQLException, 自定义 extends Exception

StackTrace(堆栈日志)

开启 Spring 事务 DEBUG 日志时,能清晰观察到“抛出异常后却触发提交”的核心矛盾:

logging.level.org.springframework.transaction.interceptor=DEBUG
logging.level.org.springframework.jdbc.datasource.DataSourceTransactionManager=DEBUG

运行日志记录如下:

2026-09-14 10:15:21.102 DEBUG 12450 --- [nio-8080-exec-1] o.s.j.d.DataSourceTransactionManager     : Creating new transaction with name [com.example.service.ImportService.importData]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
2026-09-14 10:15:21.103 DEBUG 12450 --- [nio-8080-exec-1] o.s.j.d.DataSourceTransactionManager     : Acquired Connection [HikariProxyConnection@12345678] for JDBC transaction
2026-09-14 10:15:21.120 DEBUG 12450 --- [nio-8080-exec-1] o.s.t.i.TransactionInterceptor           : Completing transaction for [com.example.service.ImportService.importData] after throwing: java.io.IOException: file corrupted or truncated
2026-09-14 10:15:21.121 DEBUG 12450 --- [nio-8080-exec-1] o.s.j.d.DataSourceTransactionManager     : Initiating transaction commit
2026-09-14 10:15:21.122 DEBUG 12450 --- [nio-8080-exec-1] o.s.j.d.DataSourceTransactionManager     : Committing JDBC transaction on Connection [HikariProxyConnection@12345678]

日志排查铁证:

  • 日志明确打印:after throwing: java.io.IOException;
  • 紧接其后的不是 Initiating transaction rollback,而是 Initiating transaction commit。说明框架捕获到了异常,但经由匹配规则判定不应回滚。

Reproduce(复现代码)

以下是典型的因受检异常导致事务非预期提交的代码:

@Service
public class ImportService {

    @Autowired
    private BatchMapper batchMapper;

    @Autowired
    private SummaryMapper summaryMapper;

    // 缺陷代码:未配置 rollbackFor,默认对受检异常 IOException 不回滚
    @Transactional
    public void importData(File file) throws IOException {
        // 1. 批量插入基础数据
        batchMapper.insertAll(parseData(file));

        // 2. 检查文件大小并模拟抛出受检异常
        if (file.length() > 10 * 1024 * 1024) {
            throw new IOException("文件大小超出限制,导入终止");
        }

        // 3. 统计汇总
        summaryMapper.insertSummary(buildSummary(file));
    }

    private List<BatchData> parseData(File file) {
        return Collections.emptyList();
    }

    private Summary buildSummary(File file) {
        return new Summary();
    }
}

Root Cause(根因分析)

1. DefaultTransactionAttribute.rollbackOn 的判定逻辑

Spring 的声明式事务回滚逻辑在 TransactionAspectSupport.completeTransactionAfterThrowing() 中触发,最终由 DefaultTransactionAttribute 决定是否回滚:

// org.springframework.transaction.interceptor.DefaultTransactionAttribute
@Override
public boolean rollbackOn(Throwable ex) {
    return (ex instanceof RuntimeException || ex instanceof Error);
}
  • 该规则沿袭了 EJB 时代规范的设计约定:受检异常是编译器强制要求捕获或声明的异常,被视作预期内“可通过程序自愈”的业务分支,因此默认不触发回滚;未受检异常(如空指针、数组越界)被视作非预期的程序崩溃,才默认触发回滚。
  • 然而在现代 Java 及 Spring Boot 架构下,大多数团队倾向于直接将异常抛出给统一全局异常处理器处理。一旦自定义业务异常继承了 Exception 而非 RuntimeException,或者调用了抛出标准 JDK 受检异常(如 IOException、SQLException)的库,就会陷入该默认陷阱。

2. 定位手段与排查命令

检索项目中所有使用了受检异常的自定义类以及裸注 @Transactional 的代码:

# 1. 检索项目中继承自 Exception 而非 RuntimeException 的类
grep -rn "class .* extends Exception" src/main/java --include="*Exception*.java"

# 2. 统计项目中没有显式指定 rollbackFor 的 @Transactional 注解
grep -rn "@Transactional$" src/main/java
grep -rn "@Transactional(" src/main/java | grep -v "rollbackFor"

The Fix(解决方案)

方案 A(推荐):显式声明 rollbackFor = Exception.class

这是最通用的防漏写法,确保只要有任何 Exception 抛出均触发回滚:

@Service
public class ImportService {

    @Transactional(rollbackFor = Exception.class)
    public void importData(File file) throws IOException {
        batchMapper.insertAll(parseData(file));

        if (file.length() > 10 * 1024 * 1024) {
            throw new IOException("文件大小超出限制,导入终止");
        }

        summaryMapper.insertSummary(buildSummary(file));
    }
}

方案 B:精确指定回滚的受检异常类型

如果团队希望精准控制回滚范围,可以显式声明具体的受检异常类列表:

@Transactional(rollbackFor = {IOException.class, SQLException.class})
public void importData(File file) throws IOException, SQLException {
    // 仅针对指定类型的受检异常回滚
}

方案 C:规范团队业务异常基类,继承 RuntimeException

从架构设计层面,团队的业务异常应统一继承 RuntimeException,从源头避免受检异常引发的回滚规则盲区:

// 推荐业务基类设计
public class BaseBusinessException extends RuntimeException {
    private final String errorCode;

    public BaseBusinessException(String errorCode, String message) {
        super(message);
        this.errorCode = errorCode;
    }
}

总结与避坑要点

  1. 默认规则牢记于心:裸 @Transactional 仅在抛出 RuntimeException 或 Error 时回滚,受检异常一律照常提交。
  2. 日志快速辨析:DEBUG 观察 after throwing 之后跟随的是 Committing 还是 Rolling back。
  3. 架构规范落地:在架构规范与静态代码检查(如 SonarQube)中强制推行两项规则:自定义异常继承 RuntimeException;所有 @Transactional 注解显式标注 rollbackFor = Exception.class。

References(参考来源)

最后更新于 2026-09-14

这篇帮到你了吗?

刚解决了一个棘手的报错?花两分钟记录下来,帮助下一个遇到同样问题的开发者。

贡献一条解法