@Transactional 大概是 Spring 裡最容易「加了但沒有生效」的註解——而最可怕的地方是它不會報錯。
@Service
public class OrderService {
public void createOrder(Order order) {
this.saveWithTransaction(order); // 內部呼叫
}
@Transactional
public void saveWithTransaction(Order order) {
orderRepo.save(order);
throw new RuntimeException("測試回滾"); // ← 資料不會回滾
}
}
程式正常執行、log 沒有任何異常、@Transactional 明明就在那裡——但資料沒有回滾。這種 bug 通常要等到某次真正的線上異常,才會發現資料庫裡躺著一堆本該被回滾的髒資料。
上一章講到 AOP 代理在 Bean 初始化之後生成,容器裡放的是代理物件而不是原始物件。這一章要把那件事的後果講完——因為 Spring AOP 的所有陷阱,全部來自同一個事實:
代理是「包在外面」的另一個物件,只有「從外面進來」的呼叫才會經過它。
把這句話理解透,下面這些問題都會變成同一個問題的不同表現:
this.method() 為什麼繞過代理,以及三種修法各自的代價。private 方法加 @Transactional 會怎樣」。REQUIRES_NEW 跟 NESTED 差在哪,什麼時候會意外形成兩個交易。前置:ch06 的「AOP 代理在
postProcessAfterInitialization生成」是這章的起點;ch02 的交易隔離等級與長事務會在後半段被反覆用到。
這一張卡是整章的地基。Spring AOP 的所有陷阱都是它的推論。
很多人以為 @Transactional 是把交易邏輯「織入」你的方法裡。Spring AOP 不是這樣做的(那是 AspectJ 的編譯期織入)。Spring 做的是:
你的類別 Spring 生成的代理
┌──────────────┐ ┌────────────────────────┐
│ OrderService │ │ OrderService$$Proxy │
│ │ ←────── │ ┌──────────────────┐ │
│ save() │ 持有 │ │ save() { │ │
│ │ │ │ 開啟交易 │ │
└──────────────┘ │ │ target.save() │ │
│ │ 提交/回滾 │ │
│ └──────────────────┘ │
└────────────────────────┘
容器裡放的是「代理」,你的原始物件被代理「包在裡面」OrderService 時,拿到的是代理,所以它的呼叫會經過交易邏輯。this.save(),this 指向的是原始物件——它根本不知道外面有一層代理。代理的本質是「產生一個新類別,覆寫方法後在前後加東西」。所以:
private 方法 → 不能被覆寫,代理不到final 方法/類別 → 不能被覆寫,代理不到static 方法 → 不屬於實例,代理不到而且這些情況都不會報錯,註解就是靜靜地沒有作用。
@Autowired OrderService orderService;
orderService.getClass()
// → class com.example.OrderService$$SpringCGLIB$$0
// 不是 class com.example.OrderService所以 getClass() == OrderService.class 是 false,用 getClass().getAnnotation(...) 也可能拿不到你以為會有的註解。需要拿到原始物件時用 AopProxyUtils.ultimateTargetObject()。
@Service
public class OrderService {
public void createOrder(Order order) {
validate(order);
this.saveWithTransaction(order); // ← 內部呼叫
}
@Transactional
public void saveWithTransaction(Order order) {
orderRepo.save(order);
throw new RuntimeException("boom"); // 不會回滾!
}
}外部呼叫 createOrder()
→ 經過代理 → 代理發現 createOrder 沒有 @Transactional → 直接轉給原始物件
→ 原始物件執行 this.saveWithTransaction()
→ this = 原始物件,不是代理
→ 完全沒有經過那層交易邏輯 ★@Service
public class OrderService {
private final OrderTxService txService; // 注入的是「代理」
public void createOrder(Order order) {
validate(order);
txService.saveWithTransaction(order); // ✅ 跨 Bean → 經過代理
}
}優點:交易邊界變成明確可見的類別邊界。而且通常會發現這個拆分本來就該做——交易方法和非交易的協調邏輯本來就是兩件事。
@Service
public class OrderService {
@Autowired @Lazy
private OrderService self; // 注入自己的代理
public void createOrder(Order order) {
self.saveWithTransaction(order); // ✅ 經過代理
}
}@Lazy 是必要的——否則會變成 ch06 講的循環依賴(自己依賴自己)。缺點:能跑,但讀程式碼的人會困惑「為什麼要注入自己」,而且它掩蓋了設計問題。
// 需要 @EnableAspectJAutoProxy(exposeProxy = true)
((OrderService) AopContext.currentProxy()).saveWithTransaction(order);要額外開設定、程式碼醜、而且把 Spring 的實作細節寫進業務邏輯裡。
@Cacheable、@Async、@Retryable——所有基於代理的註解都一樣。@Async 的自我呼叫特別隱蔽:方法會變成同步執行,但你完全不會發現。
利用反射,動態生成一個「實作了目標介面」的類別。
interface OrderService { void save(); }
class OrderServiceImpl implements OrderService { ... }
代理長這樣:
class $Proxy123 implements OrderService {
void save() { 前置邏輯; target.save(); 後置邏輯; }
}(OrderServiceImpl) proxy 會 ClassCastException)利用 ASM 產生「目標類別的子類別」,覆寫方法。
class OrderService$$SpringCGLIB$$0 extends OrderService {
@Override
void save() { 前置邏輯; super.save(); 後置邏輯; }
}final 類別或 final 方法(不能繼承/覆寫)private 方法也代理不到(子類別看不到)spring.aop.proxy-target-class=true),就算有實作介面也一樣。final。@Transactional 加在 private 方法上——不會報錯,就是不生效@Transactional 的方法或類別宣告成 final——同樣靜默失效(Kotlin 尤其要注意,Kotlin 的類別和方法預設就是 final,所以需要 kotlin-spring 外掛自動加 open)System.out.println(orderService.getClass().getName());
// JDK 代理 → com.sun.proxy.$Proxy123
// CGLIB → com.example.OrderService$$SpringCGLIB$$0
// 沒有代理 → com.example.OrderService ← 註解沒生效的訊號!傳播行為回答的是:「呼叫這個方法的時候,如果外面已經有一個交易了,該怎麼辦?」
REQUIRED(預設)——有就加入,沒有就新建。加入的意思是「用同一個交易」:外層回滾,內層做的事也一起回滾。REQUIRES_NEW——永遠開一個全新的、獨立的交易。把外層的交易掛起(suspend),自己跑完提交,再恢復外層。兩者的成敗互不影響。NESTED——在目前交易裡建立一個 savepoint。內層失敗只回滾到 savepoint,外層可以繼續;但外層回滾時,內層一定也跟著回滾。 內層失敗 外層失敗
REQUIRES_NEW 外層不受影響 內層「已提交」不受影響 ★
NESTED 外層可繼續 內層一起回滾 ★
★ 這是選擇的關鍵:內層的結果,該不該跟著外層一起消失?REQUIRES_NEW(獨立交易,主流程回滾也不影響)。REQUIRED 或 NESTED。REQUIRES_NEW,實際上一個請求會佔兩條連線——並行上限直接砍半。jstack 看起來只是大家都在等連線。SUPPORTS——有交易就加入,沒有就以非交易方式執行NOT_SUPPORTED——掛起現有交易,以非交易方式執行(適合長時間的唯讀查詢,避免拉長交易)MANDATORY / NEVER——強制要求「必須有」/「必須沒有」交易,否則丟例外。適合用來保護那些「絕對不能單獨呼叫」的方法。REQUIRED 就對了。會需要改的通常只有兩種:日誌/稽核用 REQUIRES_NEW,長時間唯讀報表用 NOT_SUPPORTED。看到程式碼裡大量出現 REQUIRES_NEW,通常代表交易邊界劃錯了。交易明明生效了(有經過代理),例外也拋出來了,但資料就是沒回滾。
@Transactional
public void process() throws IOException {
orderRepo.save(order);
throw new IOException("檔案讀取失敗"); // ❌ 不會回滾!
}RuntimeException(unchecked)和 Error 才觸發回滾。IOException、SQLException、以及你自己繼承 Exception 的業務例外)預設都會正常提交。// 修法一:明確指定
@Transactional(rollbackFor = Exception.class)
// 修法二(更好):業務例外一律繼承 RuntimeException
public class BusinessException extends RuntimeException { ... }@Transactional
public void process() {
try {
orderRepo.save(order);
riskyOperation();
} catch (Exception e) {
log.error("失敗了", e); // ❌ 吞掉了,代理看不到例外 → 正常提交
}
}代理是靠「有沒有例外往外拋」來決定回滾的。你在方法內部把例外吃掉,代理就以為一切正常。
// 修法:處理完要重新拋出
catch (Exception e) {
log.error("失敗了", e);
throw new BusinessException("處理失敗", e);
}
// 或:明確標記回滾(不想拋例外時)
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();外層 @Transactional → 呼叫內層 @Transactional(REQUIRED,同一個交易)
內層拋例外 → 標記整個交易為 rollback-only
← 外層 catch 住例外,想正常結束
→ 提交時發現交易被標記了
→ UnexpectedRollbackException:
Transaction rolled back because it has been
marked as rollback-onlyREQUIRED 是同一個交易——內層的失敗污染了共用的那個交易狀態。REQUIRES_NEW 開獨立交易;如果該影響,外層就不該 catch。把視角從 @Transactional 拉回 AOP 本身——它其實只是 Spring 內建的一個切面。
@Around 前半部
@Before
┌─────────────────┐
│ 目標方法執行 │
└─────────────────┘
@AfterReturning(正常回傳時)/ @AfterThrowing(拋例外時)
@After(finally,兩種情況都會執行)
@Around 後半部@Around 最強大也最危險——它必須自己呼叫 joinPoint.proceed(),忘了寫的話目標方法根本不會執行,而且完全不報錯。
@Aspect @Order(1) class LoggingAspect { } // 最外層
@Aspect @Order(2) class SecurityAspect { }
// @Transactional 的優先序預設是 Ordered.LOWEST_PRECEDENCE(最內層)REQUIRES_NEW。// 攔截某個套件下所有 public 方法
@Pointcut("execution(public * com.example.service..*.*(..))")
// 攔截標了特定註解的方法(最常用,也最不容易誤傷)
@Pointcut("@annotation(com.example.RequirePermission)")
// 攔截某個註解標記的類別裡的所有方法
@Pointcut("@within(org.springframework.stereotype.Service)")@annotation)。基於套件路徑的表達式很容易在重構時失效,或不小心攔截到不該攔的方法——而且失效時同樣不會報錯。這張卡把三章串起來。「交易」在應用程式看是一個註解,在資料庫看是一段時間,在連線池看是一條被佔住的連線。
進入 @Transactional 方法
→ 跟連線池借一條連線(ch03) ★ 連線開始被佔用
→ 送出 BEGIN
→ 建立 Read View(ch02,RR 下第一次查詢時)★ 舊版本開始無法被清理
→ 執行你的業務邏輯 ……
→ COMMIT / ROLLBACK
→ 歸還連線 ★ 連線釋放@Transactional 方法裡呼叫第三方 API,對方慢 30 秒 → 連線被佔 30 秒 + 交易開 30 秒。十個並行請求就吃掉 10 條連線,池子很快見底。// ❌ 交易包住所有東西
@Transactional
public void createOrder(OrderRequest req) {
Order order = orderRepo.save(toEntity(req));
paymentClient.charge(order); // 外部 API
mailService.send(order); // 寄信
fileService.generatePdf(order); // 檔案 I/O
}
// ✅ 交易只包住 DB 操作
public void createOrder(OrderRequest req) {
Order order = txService.saveOrder(req); // @Transactional 只在這裡
paymentClient.charge(order);
// 要在交易成功後才做的事,用事件
}
@TransactionalEventListener(phase = AFTER_COMMIT)
public void onOrderCreated(OrderCreatedEvent e) {
mailService.send(e.getOrder());
}AOP 解決的是「橫切關注點」——散落在很多地方、跟業務邏輯無關的重複程式碼。但它的代價是「隱形」:程式碼看不出來有東西在執行。
共同特徵:邏輯統一、跟業務無關、散落在大量方法上。
if 判斷方法名稱,比不用還糟。@annotation)而不是套件路徑——至少在方法上看得到標記,讀程式碼的人有線索AopUtils.isAopProxy())this.)?private/final/static)?getClass() 看看)?| 面向 | JDK 動態代理 | CGLIB |
|---|---|---|
| 實作原理 | 反射,生成實作目標介面的類別 | ASM 位元組碼,生成目標類別的子類別 |
| 前提 | 目標類別必須實作介面 | 不需要介面 |
| 限制 | 只能攔截介面上宣告的方法 | 無法代理 final 類別/方法、private 方法 |
| 代理物件型別 | $Proxy123 implements 介面 | OrderService$$SpringCGLIB$$0 extends 目標類別 |
| Spring Boot 2.x+ | 非預設 | ✅ 預設一律使用(行為較一致) |
| Propagation | 外面已有交易時 | 內層失敗 | 外層失敗 | 典型用途 |
|---|---|---|---|---|
| REQUIRED(預設) | 加入同一個交易 | 外層一起回滾 | 內層一起回滾 | 90% 的情況 |
| REQUIRES_NEW | 掛起外層,開全新獨立交易 | 外層不受影響 | 內層已提交,不受影響 | 操作日誌/稽核:主流程回滾也要留紀錄 |
| NESTED | 在現有交易建立 savepoint | 只回滾到 savepoint,外層可繼續 | 內層一起回滾 | 可局部失敗但整體要一致的批次處理 |
| SUPPORTS | 加入;沒有就非交易執行 | — | — | 唯讀查詢,兩種情境都能用 |
| NOT_SUPPORTED | 掛起外層,非交易執行 | — | — | 長時間唯讀報表,避免拉長交易 |
| MANDATORY / NEVER | 必須有/必須沒有,否則丟例外 | — | — | 保護「絕對不能單獨呼叫」的方法 |
| 現象 | 原因 | 怎麼確認 | 修法 |
|---|---|---|---|
| 完全沒有交易行為 | this.method() 自我呼叫,繞過代理 | 檢查呼叫是否來自同一個類別內部 | 拆成兩個 Service(推薦)/自我注入 @Lazy |
| 完全沒有交易行為 | 方法是 private / final / static,代理不到 | 印 orderService.getClass(),若不是 $$SpringCGLIB$$ 就沒被代理 | 改成 public 非 final;Kotlin 要加 open 或用 kotlin-spring |
| 拋了例外但沒回滾 | checked exception 預設不回滾 | 看例外是否繼承 RuntimeException | @Transactional(rollbackFor = Exception.class),或業務例外繼承 RuntimeException |
| 拋了例外但沒回滾 | 例外被方法內部 catch 吃掉,代理看不到 | 檢查 try-catch 有沒有重新拋出 | 重新拋出,或 setRollbackOnly() |
| UnexpectedRollbackException | 內層(REQUIRED,同一交易)標記了 rollback-only,外層想提交 | 看錯誤訊息 marked as rollback-only | 內層改用 REQUIRES_NEW,或外層不要 catch |
| 連線池耗盡/死鎖 | REQUIRES_NEW 導致一個請求佔兩條連線 | 看 hikaricp_connections_pending 與傳播設定 | 減少 REQUIRES_NEW;重新檢視交易邊界 |
點擊卡片翻面查看答案,共 17 張。