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

這篇幫到你了嗎?

剛解決了一個棘手的報錯?花兩分鐘記錄下來,幫助下一個遇到同樣問題的開發者。

貢獻一條解法