@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。
