Java 後端底層 ch07 Spring AOP 與 @Transactional 為什麼靜默失效
下一章→
CH 07 Spring

Spring AOP 與 @Transactional 為什麼靜默失效

代理是一個包在外面的物件JDK 動態代理 vs CGLIB自我呼叫陷阱六種傳播行為例外沒回滾交易與連線的關係

@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() 為什麼繞過代理,以及三種修法各自的代價。
  • JDK 動態代理與 CGLIB 的差別,以及它如何決定「private 方法加 @Transactional 會怎樣」。
  • 六種傳播行為:REQUIRES_NEW 跟 NESTED 差在哪,什麼時候會意外形成兩個交易。
  • 為什麼 catch 住例外之後就不回滾了,以及 checked exception 預設不回滾這個地雷。
  • 交易與連線的關係:回到 ch03,一個交易就佔住一條連線。

前置:ch06 的「AOP 代理在 postProcessAfterInitialization 生成」是這章的起點;ch02 的交易隔離等級與長事務會在後半段被反覆用到。

這一張卡是整章的地基。Spring AOP 的所有陷阱都是它的推論。

代理不是「改寫你的類別」

很多人以為 @Transactional 是把交易邏輯「織入」你的方法裡。Spring AOP 不是這樣做的(那是 AspectJ 的編譯期織入)。Spring 做的是:

你的類別                     Spring 生成的代理
┌──────────────┐          ┌────────────────────────┐
│ OrderService │          │ OrderService$$Proxy    │
│              │  ←────── │   ┌──────────────────┐ │
│ save()       │  持有    │   │ save() {         │ │
│              │          │   │   開啟交易        │ │
└──────────────┘          │   │   target.save()  │ │
                          │   │   提交/回滾      │ │
                          │   └──────────────────┘ │
                          └────────────────────────┘

容器裡放的是「代理」,你的原始物件被代理「包在裡面」

推論一:只有「從外面進來」的呼叫才會經過代理

別的 Bean 注入 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 = 原始物件,不是代理
      → 完全沒有經過那層交易邏輯 ★
最危險的地方在於:程式完全正常執行,沒有例外、沒有警告、log 裡什麼都沒有。你只會在某次真正的線上異常之後,發現資料庫裡躺著一堆本該被回滾的髒資料。

三種修法,各有代價

① 拆成兩個 Service(最推薦)

@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 講的循環依賴(自己依賴自己)。缺點:能跑,但讀程式碼的人會困惑「為什麼要注入自己」,而且它掩蓋了設計問題。

③ AopContext(不推薦)

// 需要 @EnableAspectJAutoProxy(exposeProxy = true)
((OrderService) AopContext.currentProxy()).saveWithTransaction(order);

要額外開設定、程式碼醜、而且把 Spring 的實作細節寫進業務邏輯裡。

✅ 判斷準則:先問「這兩段邏輯真的該在同一個類別嗎」。大多數自我呼叫問題,拆開之後反而更清楚——這跟 ch06 說「循環依賴是設計訊號」是同一個道理。

同樣的陷阱也會發生在

@Cacheable、@Async、@Retryable——所有基於代理的註解都一樣。@Async 的自我呼叫特別隱蔽:方法會變成同步執行,但你完全不會發現。

JDK 動態代理:基於介面

利用反射,動態生成一個「實作了目標介面」的類別。

interface OrderService { void save(); }
class OrderServiceImpl implements OrderService { ... }

代理長這樣:
class $Proxy123 implements OrderService {
    void save() { 前置邏輯; target.save(); 後置邏輯; }
}
  • 限制:目標類別必須實作介面
  • 只能攔截介面上宣告的方法——類別上多出來的 public 方法攔截不到
  • 代理物件不能轉型成實作類別((OrderServiceImpl) proxy 會 ClassCastException)

CGLIB:基於繼承

利用 ASM 產生「目標類別的子類別」,覆寫方法。

class OrderService$$SpringCGLIB$$0 extends OrderService {
    @Override
    void save() { 前置邏輯; super.save(); 後置邏輯; }
}
  • 不需要介面
  • 限制:無法代理 final 類別或 final 方法(不能繼承/覆寫)
  • private 方法也代理不到(子類別看不到)

Spring Boot 的選擇

Spring Boot 2.x 起預設一律使用 CGLIB(spring.aop.proxy-target-class=true),就算有實作介面也一樣。

為什麼改:JDK 代理只實作介面,導致「注入時只能用介面型別」、「介面外的方法攔截不到」等問題經常讓人困惑。CGLIB 行為比較一致,代價是不能有 final。

由此推導出的實務規則

  • 不要把 @Transactional 加在 private 方法上——不會報錯,就是不生效
  • 不要把有 @Transactional 的方法或類別宣告成 final——同樣靜默失效(Kotlin 尤其要注意,Kotlin 的類別和方法預設就是 final,所以需要 kotlin-spring 外掛自動加 open)
  • CGLIB 需要一個可用的建構子——早期版本需要無參建構子,現代 Spring 已用 Objenesis 繞過這個限制

怎麼確認實際用了哪一種

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    外層不受影響     內層「已提交」不受影響 ★
NESTED          外層可繼續       內層一起回滾 ★

★ 這是選擇的關鍵:內層的結果,該不該跟著外層一起消失?
典型場景:記錄操作日誌。
「不管主流程成功失敗,日誌都要留下」→ REQUIRES_NEW(獨立交易,主流程回滾也不影響)。
「主流程失敗的話日誌也不該存在」→ REQUIRED 或 NESTED。

選錯的後果是資料不一致,而且非常難查——因為兩種都「能跑」。

REQUIRES_NEW 的隱藏代價:它要第二條連線

外層交易被掛起時,它手上的資料庫連線不會被釋放(交易還沒結束)。內層要開新交易,就得再跟連線池借一條。

回到 ch03:如果連線池只有 10 條,而每個請求都用到 REQUIRES_NEW,實際上一個請求會佔兩條連線——並行上限直接砍半。

更糟的情況:連線池耗盡時的死鎖。所有連線都被外層交易佔住,內層永遠借不到新連線,而外層又在等內層完成——整個系統卡死,而且 jstack 看起來只是大家都在等連線。

另外三種

  • SUPPORTS——有交易就加入,沒有就以非交易方式執行
  • NOT_SUPPORTED——掛起現有交易,以非交易方式執行(適合長時間的唯讀查詢,避免拉長交易)
  • MANDATORY / NEVER——強制要求「必須有」/「必須沒有」交易,否則丟例外。適合用來保護那些「絕對不能單獨呼叫」的方法。
💡 90% 的情況用預設的 REQUIRED 就對了。會需要改的通常只有兩種:日誌/稽核用 REQUIRES_NEW,長時間唯讀報表用 NOT_SUPPORTED。看到程式碼裡大量出現 REQUIRES_NEW,通常代表交易邊界劃錯了。

交易明明生效了(有經過代理),例外也拋出來了,但資料就是沒回滾。

原因一:checked exception 預設不回滾

@Transactional
public void process() throws IOException {
    orderRepo.save(order);
    throw new IOException("檔案讀取失敗");   // ❌ 不會回滾!
}
Spring 的預設規則:只有 RuntimeException(unchecked)和 Error 才觸發回滾。
所有 checked exception(IOException、SQLException、以及你自己繼承 Exception 的業務例外)預設都會正常提交。

這個設計來自 EJB 的傳統慣例——checked exception 被視為「可預期、可處理的業務情況」。但它幾乎違反所有人的直覺。
// 修法一:明確指定
@Transactional(rollbackFor = Exception.class)

// 修法二(更好):業務例外一律繼承 RuntimeException
public class BusinessException extends RuntimeException { ... }

原因二:例外被 catch 住了

@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();

原因三:內層標記了 rollback-only,外層卻想提交

外層 @Transactional  →  呼叫內層 @Transactional(REQUIRED,同一個交易)
                          內層拋例外 → 標記整個交易為 rollback-only
                    ←  外層 catch 住例外,想正常結束
                    →  提交時發現交易被標記了
                    →  UnexpectedRollbackException:
                       Transaction rolled back because it has been
                       marked as rollback-only
這個錯誤訊息常讓人困惑:「我明明 catch 住了,為什麼還是回滾?」
因為 REQUIRED 是同一個交易——內層的失敗污染了共用的那個交易狀態。

解法:如果內層的失敗真的不該影響外層,那它就該用 REQUIRES_NEW 開獨立交易;如果該影響,外層就不該 catch。

把視角從 @Transactional 拉回 AOP 本身——它其實只是 Spring 內建的一個切面。

核心術語(用一句話對應)

  • 切面 Aspect——「要織入的橫切邏輯」,例如交易、日誌、權限
  • 連接點 Join Point——「可以被攔截的地方」,Spring AOP 裡只有方法呼叫
  • 切入點 Pointcut——「要攔截哪些連接點」的表達式
  • 通知 Advice——「攔截到之後做什麼」

五種通知與執行順序

@Around      前半部
  @Before
    ┌─────────────────┐
    │   目標方法執行   │
    └─────────────────┘
  @AfterReturning(正常回傳時)/ @AfterThrowing(拋例外時)
  @After(finally,兩種情況都會執行)
@Around      後半部

@Around 最強大也最危險——它必須自己呼叫 joinPoint.proceed(),忘了寫的話目標方法根本不會執行,而且完全不報錯。

多個切面的順序:@Order

@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 方法實際佔用了什麼

進入 @Transactional 方法
  → 跟連線池借一條連線(ch03)      ★ 連線開始被佔用
  → 送出 BEGIN
  → 建立 Read View(ch02,RR 下第一次查詢時)★ 舊版本開始無法被清理
  → 執行你的業務邏輯 ……
  → COMMIT / ROLLBACK
  → 歸還連線                         ★ 連線釋放
所以 ch02 的「長事務」和 ch03 的「連線不夠用」是同一件事的兩面:
方法執行多久,交易就開多久,連線就被佔多久,undo log 就多久不能清理。

一個 @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());
}

@Transactional(readOnly = true) 值得養成習慣

  • Hibernate 會關閉 dirty checking(不做快照比對,省記憶體也省 CPU)
  • 某些設定下可以自動路由到唯讀副本
  • 對資料庫明確表達意圖,某些驅動會做額外優化
✅ 判斷準則(三章共用):如果一段程式碼的執行時間不是由你的資料庫決定的(網路、檔案、第三方 API),它就不該在交易裡面。這條規則同時解決長事務(ch02)、連線耗盡(ch03)與交易邊界(本章)三個問題。

AOP 解決的是「橫切關注點」——散落在很多地方、跟業務邏輯無關的重複程式碼。但它的代價是「隱形」:程式碼看不出來有東西在執行。

AOP 適合什麼

  • 交易、快取、重試、限流
  • 日誌與稽核紀錄
  • 權限檢查
  • 效能監控

共同特徵:邏輯統一、跟業務無關、散落在大量方法上。

不適合什麼

  1. 核心業務邏輯——「訂單金額超過一萬要打九折」寫進切面,等於把業務規則藏進看不見的地方。三個月後沒有人找得到它。
  2. 只有一兩個地方要用——直接寫在方法裡更清楚,用 AOP 是殺雞用牛刀。
  3. 需要依賴呼叫端上下文的邏輯——切面拿不到完整的呼叫語境,硬要拿就會寫出一堆 if 判斷方法名稱,比不用還糟。
AOP 最大的成本是除錯困難。當一個方法的行為跟你預期不符時,你必須知道「這個方法被哪些切面攔截了」——而這件事在程式碼裡是完全看不見的。切面越多,這個成本越高。

三個實用的自保習慣

  • 切入點優先用註解(@annotation)而不是套件路徑——至少在方法上看得到標記,讀程式碼的人有線索
  • 切面要記 log,至少在 debug 等級,出事時能確認它到底有沒有執行
  • 啟動時驗證關鍵註解有生效——例如寫一個測試,斷言關鍵 Service 拿到的是代理物件(AopUtils.isAopProxy())

回到整章的那句話

🚨 Spring AOP 的所有陷阱,都是「代理是包在外面的另一個物件」這一件事的推論。
下次遇到「註解沒生效」,照這個順序問:
① 這個呼叫是從外面進來的嗎(還是 this.)?
② 這個方法能被覆寫嗎(private/final/static)?
③ 這個 Bean 真的被代理了嗎(印 getClass() 看看)?

三個問題就能涵蓋絕大多數情況。
JDK 動態代理 vs CGLIB
面向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必須有/必須沒有,否則丟例外——保護「絕對不能單獨呼叫」的方法
Transactional 失效診斷表
現象原因怎麼確認修法
完全沒有交易行為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;重新檢視交易邊界

練習題 點選選項查看解析

0 / 10
01 / 10
同一個類別裡 this.saveWithTransaction() 呼叫帶 @Transactional 的方法,為什麼交易失效?
A 因為 Spring 不支援同類別內的交易
B 因為代理是包在外面的另一個物件,this 指向的是原始物件,呼叫不會經過代理那層交易邏輯
C 因為 @Transactional 只能用在 Controller
D 因為缺少 @EnableTransactionManagement
解析
Spring AOP 不是把邏輯織入你的方法,而是產生一個包在外面的代理物件。只有「從外面進來」的呼叫才會經過代理;原始物件內部的 this 完全不知道外面有那一層。最危險的是它不會報錯。
02 / 10
為什麼 private 方法加 @Transactional 不會生效?
A 因為 private 方法不能被其他類別呼叫
B 因為代理的本質是「產生新類別覆寫方法」,private 方法無法被覆寫,所以攔截不到
C 因為 Spring 掃描不到 private 方法
D 其實會生效,這是誤解
解析
同理 final 方法/類別、static 方法也都攔截不到。這三種情況都不會報錯,註解就是靜靜地沒有作用。Kotlin 特別要注意,因為它的類別和方法預設就是 final,需要 kotlin-spring 外掛自動加 open。
03 / 10
Spring Boot 2.x 之後預設用哪種代理?為什麼改?
A JDK 動態代理,因為效能較好
B CGLIB,因為 JDK 代理只實作介面會造成「注入只能用介面型別」「介面外的方法攔截不到」等困惑,CGLIB 行為較一致
C 兩種混用,由 Spring 自動判斷
D AspectJ 編譯期織入
解析
spring.aop.proxy-target-class 預設為 true,就算有實作介面也用 CGLIB。代價是不能有 final 類別/方法。要確認實際用了哪種,印 getClass():$Proxy123 是 JDK、$$SpringCGLIB$$ 是 CGLIB,如果印出原始類別名稱代表根本沒被代理。
04 / 10
@Transactional 方法拋出 IOException(checked exception),資料會回滾嗎?
A 會,任何例外都會觸發回滾
B 不會。Spring 預設只有 RuntimeException 和 Error 才回滾,checked exception 會正常提交
C 會,但需要等到方法結束
D 取決於資料庫的隔離等級
解析
這個預設來自 EJB 的傳統慣例(checked exception 被視為可預期的業務情況),但幾乎違反所有人的直覺。修法:@Transactional(rollbackFor = Exception.class),或更好的做法是讓業務例外一律繼承 RuntimeException。
05 / 10
在 @Transactional 方法裡 try-catch 住例外並只寫 log,會發生什麼?
A 交易正常回滾,因為 Spring 能偵測到例外
B 交易正常提交——代理是靠「有沒有例外往外拋」判斷回滾的,例外被吃掉代理就以為一切正常
C 拋出 UnexpectedRollbackException
D 交易會被掛起
解析
修法有兩種:處理完重新拋出(建議包成 BusinessException),或在不想拋例外時明確呼叫 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。
06 / 10
REQUIRES_NEW 和 NESTED 最關鍵的差別是什麼?
A REQUIRES_NEW 比較快
B 外層失敗時:REQUIRES_NEW 的內層已提交不受影響,NESTED 的內層會一起回滾
C NESTED 不支援 MySQL
D REQUIRES_NEW 不需要額外連線
解析
選擇的關鍵是「內層的結果該不該跟著外層一起消失」。操作日誌要「不管主流程成敗都留下」就用 REQUIRES_NEW;「主流程失敗日誌也不該存在」就用 REQUIRED 或 NESTED。選錯的後果是資料不一致,而且兩種都「能跑」所以很難查。
07 / 10
大量使用 REQUIRES_NEW 對連線池有什麼影響?
A 沒有影響,交易和連線無關
B 外層交易掛起時不會釋放連線,內層要再借一條——一個請求佔兩條連線,並行上限砍半,極端情況會死鎖
C 會自動擴大連線池
D 只影響記憶體不影響連線
解析
外層交易還沒結束,連線不會歸還。極端情況是所有連線都被外層佔住、內層永遠借不到、而外層又在等內層——整個系統卡死,且 jstack 看起來只是大家都在等連線。看到程式碼裡大量 REQUIRES_NEW 通常代表交易邊界劃錯了。
08 / 10
外層 catch 住了內層(REQUIRED)拋出的例外,為什麼還是收到 UnexpectedRollbackException?
A 因為 catch 寫錯了型別
B 因為 REQUIRED 是同一個交易,內層失敗時已把整個交易標記為 rollback-only,外層提交時就會失敗
C 因為資料庫連線中斷了
D 因為外層也需要加 rollbackFor
解析
內層的失敗污染了共用的交易狀態。解法看語意:如果內層失敗真的不該影響外層,它就該用 REQUIRES_NEW 開獨立交易;如果該影響,外層就不該 catch。
09 / 10
多個切面的執行順序由什麼決定?@Order 數字大小代表什麼?
A 由類別名稱字母順序決定
B 由 @Order 決定,數字越小越外層
C 由 @Order 決定,數字越大越外層
D 隨機,無法控制
解析
數字越小越外層。實務上很重要:記錄操作日誌的切面如果比交易更內層,交易回滾時日誌也會跟著回滾(若日誌寫在同一個資料庫)。想「不管成敗都留下日誌」,切面要在交易外層,或日誌用 REQUIRES_NEW。
10 / 10
遇到「某個 Spring 註解沒生效」時,最有效的三個排查問題是什麼?
A 版本對不對、有沒有加 @EnableXxx、依賴有沒有引入
B ① 呼叫是從外面進來的還是 this. ② 方法能被覆寫嗎(private/final/static)③ 這個 Bean 真的被代理了嗎(印 getClass())
C 資料庫連線正常嗎、交易管理器設定對嗎、隔離等級對嗎
D 重啟服務、清快取、重新編譯
解析
所有基於代理的註解(@Transactional、@Cacheable、@Async、@Retryable)都適用這三個問題,因為它們的陷阱全部來自同一件事:代理是包在外面的另一個物件,只有從外面進來、且能被覆寫的方法呼叫才會經過它。

點擊卡片翻面查看答案,共 17 張。

QUESTION
★ Spring AOP 所有陷阱的根源是哪一句話?
點擊翻面
ANSWER
代理是「包在外面」的另一個物件,只有「從外面進來」的呼叫才會經過它。Spring 不是把邏輯織入你的方法(那是 AspectJ),而是產生一個包住原始物件的代理。
點擊翻回
QUESTION
代理機制的三個推論?
點擊翻面
ANSWER
① 只有從外面進來的呼叫會經過代理(this. 不會)② 只能攔截可被覆寫的方法(private/final/static 都不行)③ 代理物件的型別不是你的類別(getClass() 會看到 $$SpringCGLIB$$)。
點擊翻回
QUESTION
自我呼叫陷阱的三種修法與代價?
點擊翻面
ANSWER
① 拆成兩個 Service(推薦,交易邊界變成明確的類別邊界)② @Autowired @Lazy 自我注入(能跑但掩蓋設計問題,@Lazy 是為了避免循環依賴)③ AopContext.currentProxy()(要額外開設定、把實作細節寫進業務碼)。
點擊翻回
QUESTION
自我呼叫陷阱只影響 @Transactional 嗎?
點擊翻面
ANSWER
不是。所有基於代理的註解都一樣:@Cacheable、@Async、@Retryable。@Async 特別隱蔽——方法會變成同步執行,但完全不會發現。
點擊翻回
QUESTION
JDK 動態代理與 CGLIB 的前提與限制?
點擊翻面
ANSWER
JDK:反射生成實作介面的類別,必須有介面,只能攔截介面上的方法。CGLIB:ASM 生成子類別,不需要介面,但無法代理 final 類別/方法與 private 方法。
點擊翻回
QUESTION
怎麼確認一個 Bean 有沒有被代理?
點擊翻面
ANSWER
印 getClass().getName()。$Proxy123 = JDK 代理;OrderService$$SpringCGLIB$$0 = CGLIB;如果印出原始類別名稱,代表根本沒被代理——註解沒生效的訊號。
點擊翻回
QUESTION
Kotlin 用 Spring 為什麼要特別注意?
點擊翻面
ANSWER
Kotlin 的類別和方法預設就是 final,CGLIB 無法繼承/覆寫,所有基於代理的註解都會靜默失效。需要 kotlin-spring 外掛自動加 open。
點擊翻回
QUESTION
REQUIRED / REQUIRES_NEW / NESTED 的差別?
點擊翻面
ANSWER
REQUIRED(預設):加入同一交易,共存亡。REQUIRES_NEW:掛起外層開全新交易,互不影響。NESTED:建 savepoint,內層失敗外層可繼續,但外層回滾內層一起回滾。
點擊翻回
QUESTION
REQUIRES_NEW 的隱藏代價?
點擊翻面
ANSWER
外層交易掛起時不會釋放連線,內層要再借一條——一個請求佔兩條連線,並行上限砍半。極端情況:所有連線被外層佔住、內層借不到、外層等內層 → 死鎖,且 jstack 只看到大家在等連線。
點擊翻回
QUESTION
checked exception 為什麼預設不回滾?
點擊翻面
ANSWER
Spring 預設只有 RuntimeException 和 Error 才回滾,這來自 EJB 的傳統慣例(checked exception 視為可預期的業務情況)。修法:rollbackFor = Exception.class,或讓業務例外繼承 RuntimeException。
點擊翻回
QUESTION
為什麼 catch 住例外就不回滾了?
點擊翻面
ANSWER
代理是靠「有沒有例外往外拋」判斷是否回滾的。例外被方法內部吃掉,代理就以為一切正常。修法:重新拋出,或呼叫 setRollbackOnly() 明確標記。
點擊翻回
QUESTION
UnexpectedRollbackException 是怎麼來的?
點擊翻面
ANSWER
內層(REQUIRED,同一交易)失敗時已把交易標記為 rollback-only,外層 catch 住例外想提交,提交時發現被標記了。解法:內層改 REQUIRES_NEW,或外層不要 catch。
點擊翻回
QUESTION
五種通知的執行順序?
點擊翻面
ANSWER
@Around 前半 → @Before → 目標方法 → @AfterReturning/@AfterThrowing → @After(finally)→ @Around 後半。@Around 忘了呼叫 proceed() 的話目標方法根本不會執行,且不報錯。
點擊翻回
QUESTION
多個切面的順序怎麼控制?為什麼重要?
點擊翻面
ANSWER
@Order 數字越小越外層。重要在於:記錄日誌的切面若比交易內層,交易回滾時日誌也會回滾(同一個資料庫的話)。要「不管成敗都留紀錄」就得放外層或用 REQUIRES_NEW。
點擊翻回
QUESTION
切入點該用註解還是套件路徑?
點擊翻面
ANSWER
優先用 @annotation。套件路徑表達式重構時容易失效、也容易誤傷,而且失效不會報錯。用註解至少在方法上看得到標記,讀程式碼的人有線索。
點擊翻回
QUESTION
★ 三章共用的交易邊界判斷準則?
點擊翻面
ANSWER
如果一段程式碼的執行時間不是由你的資料庫決定的(網路、檔案、第三方 API),它就不該在交易裡。這條同時解決長事務(ch02)、連線耗盡(ch03)與交易邊界(ch07)。
點擊翻回
QUESTION
註解沒生效時的三個排查問題?
點擊翻面
ANSWER
① 呼叫是從外面進來的還是 this. ② 方法能被覆寫嗎(private/final/static)③ 這個 Bean 真的被代理了嗎(印 getClass())。三個問題涵蓋絕大多數情況。
點擊翻回