回滾由
TransactionInterceptor捕獲「穿過代理邊界的例外」觸發。事務方法內部 try-catch 把例外吞掉後正常返回,代理感知不到任何例外,事務照常提交——這是事務失效裡最常見也最反直覺的一種,因為方法確實“報錯了”,事務卻“沒壞”。
Issue & Env(案發現場與環境)
- 技術棧:Spring Boot 2.x / 3.x、Spring Framework 聲明式事務(
@Transactional) - 業務場景:下單主流程在插入訂單記錄後,調用下游扣庫存或發積分等子操作。開發者用
try-catch包裹了子調用以打印錯誤日誌或做局部防禦,但未重新拋出例外。 - 故障特徵:業務日誌中明確打印了
ERROR: 库存扣减失败,然而下游庫存雖然未扣除成功,前置插入的order訂單記錄與積分卻依然被持久化提交到了數據庫,未發生預期內的全量事務回滾。
StackTrace(堆疊日誌)
本故障表面上沒有未捕獲例外拋出,控制台通常輸出業務自定義的錯誤日誌,緊隨其後的是事務正常提交的跟蹤記錄:
2026-09-14 10:02:11 ERROR c.e.OrderService : 库存扣减失败: insufficient stock
2026-09-14 10:02:11 DEBUG o.s.t.interceptor.TransactionInterceptor : Completing transaction for [OrderService.createOrder] after return: null
// ^^^^^^^^^^^^^^ after return,不是 after throwing
開啟 Spring 事務詳細調試日誌可清楚觀察到提交決策:
logging.level.org.springframework.transaction.interceptor=DEBUG
關鍵證據對比:
Completing transaction ... after return:代理攔截器收到的是方法正常返回,走成功提交流程。Completing transaction ... after throwing: xxxException:只有例外冒泡到代理層時,才會進入回滾判定鏈。
Reproduce(復現程式碼)
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private InventoryService inventoryService;
@Autowired
private PointService pointService;
@Transactional
public void createOrder(Order order) {
// 1. 插入订单
orderMapper.insert(order);
// 2. 扣库存,被 catch 住且未重新抛出
try {
inventoryService.deduct(order.getSkuId(), order.getCount());
} catch (BusinessException e) {
log.error("库存扣减失败: {}", e.getMessage()); // 仅记日志,吞掉异常
}
// 3. 正常执行后续逻辑并提交
pointService.add(order.getUserId(), order.getPoint());
}
}
觸發調用後,inventoryService.deduct 拋出 BusinessException,但被內部消化,外部觀測發現 order 成功落庫。
Root Cause(根因分析)
Spring 聲明式事務的回滾判定鏈路高度依賴 AOP 代理層的攔截:
业务方法抛出异常
→ 代理层 TransactionInterceptor.invokeWithinTransaction() 捕获
→ TransactionAspectSupport.completeTransactionAfterThrowing()
→ 按 rollbackFor 规则匹配(默认针对 RuntimeException 和 Error)
→ rollback()
整條鏈路的前提只有一個:例外必須穿透方法體並拋至代理層(Proxy Boundary)。
@Transactional是環繞通知包裝器,無法窺探方法內部執行流程。- 當方法體內自行執行了
try-catch且沒有throw,Java 方法調用視為“正常返回(Return Normally)”。 - 代理層攔截器只能觸發
completeTransactionAfterReturning(),從而執行數據庫連接提交。
快速定位排查: 使用 grep 快速排查業務層中所有隻打印日誌而不拋出例外的 catch 塊:
grep -rn -A2 "catch.*Exception" src/main/java --include="*Service*" | grep -B1 "log.error"
The Fix(解決方案)
方案 A(推薦):catch 後重新拋出例外
如果需要記錄業務上下文日誌,可以保留 catch 塊,但在記錄後必須將例外繼續向外拋出(或轉換為受支持的運行時例外):
try {
inventoryService.deduct(order.getSkuId(), order.getCount());
} catch (BusinessException e) {
log.error("库存扣减失败: {}", e.getMessage());
throw new RuntimeException("库存扣减失败,触发回滚", e); // 重新抛出,让代理感知
}
方案 B:catch 內手動標記回滾狀態(setRollbackOnly)
對於確實需要執行降級返回、不能直接對外拋出例外中斷響應的場景,可以在 catch 塊內顯式將當前事務標記為回滾狀態:
try {
inventoryService.deduct(order.getSkuId(), order.getCount());
} catch (BusinessException e) {
log.error("库存扣减失败,标记事务回滚: {}", e.getMessage(), e);
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
}
注意:setRollbackOnly() 會使當前事務在方法執行完畢後強制回滾,即便後續方法正常 return。
方案 C:將非關鍵子操作拆分為獨立事務(REQUIRES_NEW)
如果子操作是邊緣日誌或非核心通知,即便失敗也不應當影響主訂單事務,應將該方法拆分至獨立 Bean 並聲明為獨立事務:
@Service
public class AuditLogService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void recordLog(LogEntity log) {
// 独立事务,失败自生自灭,不污染主事务
}
}
總結與避坑要點
- 事務回滾的硬性前置條件是“例外穿過代理邊界”,內部 catch 是對代理的“報喜不報憂”。
- catch 塊內要麼重新拋出例外,要麼調用
setRollbackOnly(),絕不可靜默吃掉。 - Code Review 時重點關注所有
@Transactional方法內的 catch 塊,確認其例外傳遞路徑是否符合事務原子性預期。
