某個週一早上,客服丟來一張截圖:一筆訂單,扣款成功,庫存卻沒扣。你點開資料庫,付款紀錄乖乖躺在那,庫存數量原封不動。這兩件事應該綁在同一個交易裡,要嘛一起成功,要嘛一起失敗,怎麼會變成一半一半。

你翻開那段 service,@Transactional 就大剌剌地掛在方法上。加了。你甚至還記得當初 review 的時候特別確認過。

這就是 @Transactional 最陰險的地方。NullPointerException 至少會當場炸給你看,逼你面對。這個註解失效卻是無聲的,等到資料對不起來、對帳表兜不攏,你才發現它從頭到尾根本沒作用。

反直覺的地方在這:@Transactional 真正的身份,是一個站在方法門口的代理人。你以為把註解貼上去,交易就自動包在方法外面了。實際上能不能包得住,全看這個代理人有沒有機會出手。多數失效的案例,不是代理人根本沒被叫到,就是被叫到了卻誤判情況。

想通這一層,接下來四個最常踩的坑,就會從「玄學」變成「意料之中」。

先搞懂那個代理人到底是誰

Spring 在啟動時做了一件你八成沒留意的事。它拿到你寫的 service 之後,會偷偷在外面包一層,用 CGLIB 或 JDK 動態代理,生出一個長得一模一樣的替身。所有從外部打進來的呼叫,都會先撞到這個替身。

替身做的事很單純:進方法前開一個交易,方法正常結束就 commit,中途噴例外就 rollback,然後才把控制權交回給你真正的程式碼。

拿生活裡的東西類比,這個替身就像公司大門口的管家。客人一進門,管家先幫你登記、開門,事後幫你結帳。至於你本人在辦公室裡走來走去做了什麼,管家一概管不著。後面四個坑,全都繞著這件事打轉。

坑一:同類方法自己叫自己

第一個坑,也是最多人栽的。你的 service 長這樣:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Service
public class OrderService {

public void placeOrder(Order order) {
// 這裡呼叫的是「自己」的方法,走的是 this
this.saveWithTx(order);
}

@Transactional
public void saveWithTx(Order order) {
paymentDao.charge(order); // 扣款成功
stockDao.deduct(order); // 扣庫存,這行炸了
}
}

saveWithTx 明明掛了 @Transactional,扣庫存那行也真的丟出例外,結果扣款卻沒 rollback。錢扣了,庫存沒動,就是開頭那筆對不起來的訂單。

為什麼?想想門口那位管家。placeOrder 是你本人已經在辦公室裡,直接走到隔壁房間喊了一聲 saveWithTx。這一步走的是 this.saveWithTx(),完全在辦公室內部繞,根本沒有經過大門口。管家不知道你開了一個需要交易的房間,自然不會幫你開交易,出事了也不會幫你 rollback。那個 @Transactional 就這樣被整個繞過去。

怎麼修?讓呼叫重新走一次大門。最乾淨的做法是把 saveWithTx 搬到另一個 bean,placeOrder 透過注入去呼叫它,這樣呼叫就會打在那個 bean 的代理替身上:

1
2
3
4
5
6
7
8
9
@Service
public class OrderService {
@Autowired
private OrderTxService orderTxService; // 注入另一個 bean

public void placeOrder(Order order) {
orderTxService.saveWithTx(order); // 這次走的是代理,管家攔得到
}
}

也有人用 AopContext.currentProxy() 拿到自己的代理再呼叫,或是乾脆自己注入自己。能動,但讀起來像在跟框架角力。多切一個 bean 通常比較誠實,也比較好懂。

坑二:方法不是 public

你把交易方法收成 private,想說反正只有自己人會用到:

1
2
3
4
5
6
7
8
9
10
11
12
@Service
public class ReportService {

public void generate() {
doWrite();
}

@Transactional
private void doWrite() { // private,@Transactional 直接被無視
// ...寫入資料庫
}
}

跑起來一樣沒交易。而且這次連例外都不必丟,Spring 壓根沒把交易包上去。

原因藏在代理的實作方式裡。Spring 預設用的代理,不管是 JDK 動態代理還是 CGLIB 子類,都只攔得到 public 方法。CGLIB 靠繼承你的 class、再覆寫方法來插入交易邏輯,而 private 方法子類覆寫不到;就算是 protected 或 package-private,Spring 預設也直接跳過不處理。換句話說,管家只招呼從正門走進來的客人,你從側門溜進去,他當作沒看到。

修法很無聊但有效:把它改成 public。如果你真的很想在非 public 方法上掛交易,得切到 AspectJ 的 load-time weaving 模式,但那個複雜度通常划不來。動手之前先問自己一句:這個方法為什麼非藏起來不可。

坑三:例外被 try-catch 吃掉

這個坑最要命,因為它連 log 都給你記得漂漂亮亮,看起來完全沒事。

1
2
3
4
5
6
7
8
9
@Transactional
public void transfer(Long from, Long to, BigDecimal amount) {
accountDao.debit(from, amount); // 轉出,扣款成功
try {
accountDao.credit(to, amount); // 轉入,這行炸了
} catch (Exception e) {
log.error("入帳失敗", e); // 只記 log,沒有再拋出去
}
}

轉出扣了,轉入失敗,錢就這樣憑空蒸發。而你的 log 還好端端記了一筆「入帳失敗」,看起來像是你有好好處理過。

管家判斷要不要 rollback,靠的是「方法有沒有把例外拋到他面前」。你在方法裡 catch 掉、只寫 log、不 rethrow,例外就死在方法內部,永遠傳不到管家那一層。方法從頭到尾看起來「正常結束」,管家理所當然幫你 commit。等於你在房間裡砸了東西,出門卻笑臉迎人,他哪知道要善後。

這裡藏著一個更容易搞混的點:就算你想 catch 起來做點事、又想保住交易,也有兩條直接的路。要嘛把例外原封不動往外拋,讓管家看到;要嘛在 catch 裡手動幫這筆交易蓋個章,標記它「註定要 rollback」:

1
2
3
4
} catch (Exception e) {
log.error("入帳失敗", e);
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
}

setRollbackOnly() 等於直接在管家的帳本上蓋一個「這筆別 commit」的戳。就算方法表面上正常結束,交易最後還是會被 rollback。

坑四:checked exception 預設不會 rollback

這個坑更狡猾:例外確實拋出去了,管家也看到了,卻還是幫你 commit。

1
2
3
4
5
@Transactional
public void importData(File file) throws IOException {
userDao.insert(record); // 已經寫進去了
parse(file); // 丟出 IOException(checked exception)
}

原因藏在預設值裡:@Transactional 預設只對 RuntimeExceptionError rollback。IOExceptionSQLException,還有你自己 extends Exception 的那些 checked exception,預設一律不 rollback。管家確實看到了例外,但他手上那本規則沒把 checked exception 列進 rollback 名單,於是照常把你剛剛寫進去的資料 commit 了。

這其實是 Spring 刻意的設計,沿襲早年 EJB 的慣例:checked exception 被當成「業務上可以預期、可以恢復」的狀況,所以預設放行。合不合理見仁見智,重點是你得知道它就長這樣。

修法一行搞定,把 rollback 的觸發條件講清楚:

1
2
3
4
@Transactional(rollbackFor = Exception.class)
public void importData(File file) throws IOException {
// ...現在任何 Exception 都會觸發 rollback
}

很多團隊乾脆在自己的基底註解或程式碼規範裡直接寫死 rollbackFor = Exception.class,省得每個人各踩一次。

回到那筆對不起來的訂單

現在你再面對開頭那筆「扣款成功、庫存沒扣」的訂單,還會瞪著 @Transactional 發呆嗎?不會了。你會照順序問四個問題:這個方法是被外部呼叫、還是同類自己叫自己?它是 public 嗎?裡面有沒有 try-catch 把例外默默吞掉?丟出來的是 RuntimeException 還是 checked exception?

這四個問題背後其實是同一件事。@Transactional 從來不是你貼上去就會自動生效的魔法,它是一個站在方法門口的代理人。它會不會出手、出手時看到什麼,全看那個呼叫有沒有真的經過它、那個例外有沒有真的送到它面前。把這條線抓住,這些看起來隨機到像玄學的失效,其實都能在你動手 debug 之前,就先被你料到。