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