Intro
在 Spring 專案開發中,@Transactional 註解因其高易用性,成為處理資料庫事務(Transaction)的首選方式。
然而,在處理批次資料寫入(Bulk Insert/Update)、大批次資料對帳或長流程業務邏輯時,若習慣性地在方法(Method)層級套用 @Transactional,容易引發「長事務(Long Transaction)」,對資料庫與系統穩定性帶來嚴重隱患:
- 資料庫鎖定與超時 (Lock Wait Timeout):長事務會長時間持有資料列鎖或間隙鎖(Gap Lock),導致其他併發的線上請求因無法取得鎖而排隊超時。
- Undo Log 膨脹與效能下滑:未 Commit 的事務會阻止 MySQL (InnoDB) 進行 Undo Log 的清理(Purge),導致膨脹的 Undo Log 鏈大幅拖慢全系統的查詢速度。
- 失敗代價過高:若在處理上萬筆資料的最後一刻發生異常,整個長事務會強制全數回滾(Rollback),導致先前耗費的 CPU 與 I/O 資源完全浪費。
本文旨在釐清 聲明式事務(@Transactional) 與 編程式事務(TransactionTemplate) 的適用場景,並針對批次處理(Chunk / Batch Process)建立標準的事務控制規範,協助團隊在開發便利性與系統效能間取得最佳平衡。
@Transactional vs TransactionTemplate
| 評估維度 | @Transactional (聲明式) |
TransactionTemplate (編程式) |
|---|---|---|
| 控制粒度 | 方法(Method)層級 | 區塊(Block)層級 |
| 侵入性 | 無侵入,僅加 Annotation | 需注入元件並寫 Lambda |
| 維護性 | 邏輯乾淨,適合 90% 的日常開發 | 稍為繁瑣,但對底層掌控度最高 |
| 常見地雷 | 迴圈中使用容易造成 Long Transaction | 忘記處理 Lambda 內部的變數作用域 |
核心觀念與比較 (Core Concepts & Comparison)
- 優先使用 @Transactional 的場景:
- 日常 Web REST API 請求:如建立訂單、修改會員資料、進行單次扣款等短暫操作。
- 原子性要求極高:整個 Method 必須符合 All-or-Nothing,不允許任何中間狀態存留。
- 優先使用 TransactionTemplate 的場景:
- 批次資料處理(Bulk/Chunk Insert):需要每 N 筆(如 500、1000 筆)獨立 Commit 的情境。
- 包含第三方 API 呼叫:避免外部網路延遲導致資料庫 Transaction 掛載過久。
- 部分成功/可重試邏輯:即使中途出錯,也希望前面已成功的批次能保留在資料庫中。
實踐範例 (Best Practices & Code Examples)
錯誤範例:長事務(Long Transaction)
以下為典型的不當使用案例。在 Method 頭部標註 @Transactional,會導致整個迴圈都在同一個 Transaction 內,直到 Method 結束時才發出 COMMIT:
@Transactional
public void processInBulk(List<Data> dataList) {
// 舊資料清理(鎖定範圍將持續掛載到 Method 結束)
repository.deleteByDate(now);
for (int offset = 0; offset < dataList.size(); offset += 1000) {
List<Data> chunk = getChunk(dataList, offset, 1000);
// 雖然呼叫 saveAll,但資料庫並未 COMMIT,記憶體與 Undo Log 持續膨脹
repository.saveAll(chunk);
}
// 離開 Method 前才會發出 1 次 COMMIT
}
正確範例:分批獨立 Commit(Chunk Transaction)
移除方法層級的 @Transactional,改以 TransactionTemplate 精確劃分每一個區塊的事務邊界:
@Service
@RequiredArgsConstructor
public class BatchService {
private final TransactionTemplate transactionTemplate;
private final DataRepository repository;
public void processInBulk(List<Data> dataList) {
// 1. Delete 操作包入獨立事務,執行完立刻 Commit 並釋放鎖定
transactionTemplate.executeWithoutResult(status -> {
repository.deleteByDate(now);
});
// 2. 每批 Chunk 獨立事務,執行完立即 Commit
for (int offset = 0; offset < dataList.size(); offset += 1000) {
List<Data> chunk = getChunk(dataList, offset, 1000);
if (!chunk.isEmpty()) {
transactionTemplate.executeWithoutResult(status -> {
repository.saveAll(chunk);
}); // 離開 Lambda 時即完成 COMMIT
}
}
}
}