@Transactional 明明加了,資料為什麼還是沒 rollback
某個週一早上,客服丟來一張截圖:一筆訂單,扣款成功,庫存卻沒扣。你點開資料庫,付款紀錄乖乖躺在那,庫存數量原封不動。這兩件事應該綁在同一個交易裡,要嘛一起成功,要嘛一起失敗,怎麼會變成一半一半。
你翻開那段 service,@Transactional 就大剌剌地掛在方法上。加了。你甚至還記得當初 review 的時候特別確認過。
這就是 @Transactional 最陰險的地方。NullPointerException 至少會當場炸給你看,逼你面對。這個註解失效卻是無聲的,等到資料對不起來、對帳表兜不攏,你才發現它從頭到尾根本沒作用。
反直覺的地方在這:@Transactional 真正的身份,是一個站在方法門口的代理人。你以為把註解貼上去,交易就自動包在方法外面了。實際上能不能包得住,全看這個代理人有沒有機會出手。多數失效的案例,不是代理人根本沒被叫到,就是被叫到了卻誤判情況。
想通這一層,接下來四個最常踩的坑,就會從「玄學」變成「意料之中」。
先搞懂那個代理人到底是誰
Spring 在啟動時做了一件你八成沒留意的事。它拿到你寫的 service 之後,會偷偷在外面包一層,用 CGLIB 或 JDK 動態代理,生出一個長得一模一樣的替身。所有從外部打進來的呼叫,都會先撞到這個替身。
替身做的事很單純:進方法前開一個交易,方法正常結束就 commit,中途噴例外就 rollback,然後才把控制權交回給你真正的程式碼。
拿生活裡的東西類比,這個替身就像公司大門口的管家。客人一進門,管家先幫你登記、開門,事後幫你結帳。至於你本人在辦公室裡走來走去做了什麼,管家一概管不著。後面四個坑,全都繞著這件事打轉。
坑一:同類方法自己叫自己
第一個坑,也是最多人栽的。你的 service 長這樣:
1 |
|
saveWithTx 明明掛了 @Transactional,扣庫存那行也真的丟出例外,結果扣款卻沒 rollback。錢扣了,庫存沒動,就是開頭那筆對不起來的訂單。
為什麼?想想門口那位管家。placeOrder 是你本人已經在辦公室裡,直接走到隔壁房間喊了一聲 saveWithTx。這一步走的是 this.saveWithTx(),完全在辦公室內部繞,根本沒有經過大門口。管家不知道你開了一個需要交易的房間,自然不會幫你開交易,出事了也不會幫你 rollback。那個 @Transactional 就這樣被整個繞過去。
怎麼修?讓呼叫重新走一次大門。最乾淨的做法是把 saveWithTx 搬到另一個 bean,placeOrder 透過注入去呼叫它,這樣呼叫就會打在那個 bean 的代理替身上:
1 |
|
也有人用 AopContext.currentProxy() 拿到自己的代理再呼叫,或是乾脆自己注入自己。能動,但讀起來像在跟框架角力。多切一個 bean 通常比較誠實,也比較好懂。
坑二:方法不是 public
你把交易方法收成 private,想說反正只有自己人會用到:
1 |
|
跑起來一樣沒交易。而且這次連例外都不必丟,Spring 壓根沒把交易包上去。
原因藏在代理的實作方式裡。Spring 預設用的代理,不管是 JDK 動態代理還是 CGLIB 子類,都只攔得到 public 方法。CGLIB 靠繼承你的 class、再覆寫方法來插入交易邏輯,而 private 方法子類覆寫不到;就算是 protected 或 package-private,Spring 預設也直接跳過不處理。換句話說,管家只招呼從正門走進來的客人,你從側門溜進去,他當作沒看到。
修法很無聊但有效:把它改成 public。如果你真的很想在非 public 方法上掛交易,得切到 AspectJ 的 load-time weaving 模式,但那個複雜度通常划不來。動手之前先問自己一句:這個方法為什麼非藏起來不可。
坑三:例外被 try-catch 吃掉
這個坑最要命,因為它連 log 都給你記得漂漂亮亮,看起來完全沒事。
1 |
|
轉出扣了,轉入失敗,錢就這樣憑空蒸發。而你的 log 還好端端記了一筆「入帳失敗」,看起來像是你有好好處理過。
管家判斷要不要 rollback,靠的是「方法有沒有把例外拋到他面前」。你在方法裡 catch 掉、只寫 log、不 rethrow,例外就死在方法內部,永遠傳不到管家那一層。方法從頭到尾看起來「正常結束」,管家理所當然幫你 commit。等於你在房間裡砸了東西,出門卻笑臉迎人,他哪知道要善後。
這裡藏著一個更容易搞混的點:就算你想 catch 起來做點事、又想保住交易,也有兩條直接的路。要嘛把例外原封不動往外拋,讓管家看到;要嘛在 catch 裡手動幫這筆交易蓋個章,標記它「註定要 rollback」:
1 | } catch (Exception e) { |
setRollbackOnly() 等於直接在管家的帳本上蓋一個「這筆別 commit」的戳。就算方法表面上正常結束,交易最後還是會被 rollback。
坑四:checked exception 預設不會 rollback
這個坑更狡猾:例外確實拋出去了,管家也看到了,卻還是幫你 commit。
1 |
|
原因藏在預設值裡:@Transactional 預設只對 RuntimeException 和 Error rollback。IOException、SQLException,還有你自己 extends Exception 的那些 checked exception,預設一律不 rollback。管家確實看到了例外,但他手上那本規則沒把 checked exception 列進 rollback 名單,於是照常把你剛剛寫進去的資料 commit 了。
這其實是 Spring 刻意的設計,沿襲早年 EJB 的慣例:checked exception 被當成「業務上可以預期、可以恢復」的狀況,所以預設放行。合不合理見仁見智,重點是你得知道它就長這樣。
修法一行搞定,把 rollback 的觸發條件講清楚:
1 |
|
很多團隊乾脆在自己的基底註解或程式碼規範裡直接寫死 rollbackFor = Exception.class,省得每個人各踩一次。
回到那筆對不起來的訂單
現在你再面對開頭那筆「扣款成功、庫存沒扣」的訂單,還會瞪著 @Transactional 發呆嗎?不會了。你會照順序問四個問題:這個方法是被外部呼叫、還是同類自己叫自己?它是 public 嗎?裡面有沒有 try-catch 把例外默默吞掉?丟出來的是 RuntimeException 還是 checked exception?
這四個問題背後其實是同一件事。@Transactional 從來不是你貼上去就會自動生效的魔法,它是一個站在方法門口的代理人。它會不會出手、出手時看到什麼,全看那個呼叫有沒有真的經過它、那個例外有沒有真的送到它面前。把這條線抓住,這些看起來隨機到像玄學的失效,其實都能在你動手 debug 之前,就先被你料到。










