巢狀事務總結
最近線上發生了一起故障,是關於巢狀事務未回滾的問題,這裡記錄一下。
發生故障的場景是:
主方法parent()裡調child()方法,當child()丟擲異常時,parent()和child()均未回滾。背景先介紹到這裡,你可以先想想為什麼沒回滾,下面由淺入深講解。
------------------------------------華麗的分割線---------------------------------------------------
一、場景分析
場景A:
這裡是分別執行了兩個事務,執行的結果是兩個方法都可以插入資料!
場景B:
修改上述程式碼如下:
注:這裡的Propagation是事務的傳播行為,預設是REQUIRED,意思是如果當前沒有事務,就開啟一個事務,如果已經存在一個事務,就加入到這個事務中;REQUIRES_NEW是說,新建事務,如果當前存在事務,把當前事務掛起;意思是這裡執行到child()方法時,parent所在的事務就會掛起,方法child就會起一個新的事務,等待方法child
的事務完成以後,方法parent
才繼續執行。
執行的結果是兩個方法都可以插入資料!
場景A
和場景B
都是正常的執行,期間沒有發生任何的回滾,假如child()
方法中出現了異常!
場景C:
修改child()
的程式碼如下所示,其他程式碼和場景B
一樣:
會出現異常,並且資料全都沒有插入進去:
疑問1:場景C
中child()
丟擲了異常,但是parent()
沒有丟擲異常,按道理是不是應該parent()
提交成功而child()
回滾?
可能有的小夥伴要說了,child()
丟擲了異常在parent()
沒有進行捕獲,造成了parent()
也是丟擲了異常了的!所以他們兩個都會回滾?
場景D:
按照上述小夥伴的疑問這個時候,如果對parent()
方法修改,捕獲child()
中丟擲的異常,其他程式碼和場景C
一樣:
然後再次執行,結果是兩個都插入了資料庫:
看到這裡很多小夥伴都可能會問,按照我們的邏輯來想的話child()
中丟擲了異常,parent()
沒有丟擲並且捕獲了child()
丟擲了異常!執行的結果應該是child()
回滾,parent()
提交成功的啊!
疑問2:場景D
為什麼不是child()
回滾和parent()
提交成功哪?
二、問題本質所在
我們知道Spring事務管理是通過JDK動態代理的方式進行實現的(另一種是使用CGLib動態代理實現的),也正是因為動態代理的特性造成了上述parent()方法呼叫child()方法的時候造成了child()方法中的事務失效!簡單的來說,在場景D中parent()方法呼叫child()方法的時候,child()方法的事務是不起作用的,此時的child()方法像一個沒有加事務的普通方法,其本質上就相當於下邊的程式碼:
場景C的本質:
場景D的本質:
正如上述的程式碼,我們可以很輕鬆的解釋疑問1和疑問2,因為動態代理的特性造成了場景C和場景D的本質如上述程式碼。在場景C中,child()丟擲異常沒有捕獲,相當於parent事務中丟擲了異常,造成parent()一起回滾,因為他們本質是同一個方法;在場景D中,child()丟擲異常並進行了捕獲,parent事務中沒有丟擲異常,parent()和child()同時在一個事務裡邊,所以他們都成功了;
看到這裡,那麼動態代理的這個特性到底是什麼才會造成Spring事務失效吶?
三、動態代理的這個特性到底是什麼?
首先我們看一下一個簡單的動態代理實現方式:
此時我們執行以下測試方法,注意了此時是同時呼叫了say()
和say2()
的,執行結果如下:
可以看出,在HelloImpl 類中由於say()沒有呼叫say2(),他們方法的執行都是使用了代理的,也就是說say和say2都是通過代理物件呼叫的invoke()方法,這和我們場景A和場景B類似。
假如我們模擬一下場景C和場景D在say()中呼叫say2(),那麼程式碼修改為如下:
執行結果如下:
這裡可以很清楚的看出來say()走的是代理,而say2()走的是普通的方法,沒有經過代理!看到這裡你是否已經恍然大明白了呢?
這個應該可以很好的理解為什麼是這樣子!這是因為在Java中say()中呼叫say2()中的方法,本質上就相當於把say2()的方法體放入到say()中,也就是內部方法,同樣的不管你嵌套了多少層,只有代理物件proxy直接呼叫的那一個方法才是真正的走代理的,如下:
測試方法和上邊的測試方法一樣,執行結果如下:
記住:只有代理物件proxy直接呼叫的那個方法才是真正的走代理的!
四 、如何解決這個問題?
上文的分析中我們已經瞭解了為什麼在該特定場景下使用Spring事務的時候造成事務無法回滾的問題,下邊我們談一下幾種解決的方法:
1、我們可以選擇逃避這個問題!我們可以不使用以上這種事務巢狀的方式來解決問題,最簡單的方法就是把問題提到Service或者是更靠前的邏輯中去解決,使用service.xxxtransaction是不會出現這種問題的。
2、通過AopProxy上下文獲取代理物件:
(1)SpringBoot配置方式:註解開啟 exposeProxy = true,暴露代理物件 (否則AopContext.currentProxy()) 會丟擲異常。
新增依賴:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
<version>2.0.3.RELEASE</version>
</dependency>
添加註解
修改原有程式碼的執行方式為:
可見,child
方法由於異常已經回滾了,而parent
可以正確的提交,這才是我們想要的結果!注意的是在parent
呼叫child
的時候是通過try/catch
捕獲了異常的!
如果我們把child()事務傳播型別改為REQUIRED的話
這個時候parent()和child()兩個方法在同一個事務裡,child()拋異常的話,兩個方法都會回滾的。
如果在parent方法內把try..catch..去掉的話
兩個方法都會回滾的,因為child()方法是起了一個新的事務,他會回滾,然後異常往上拋,parent()也會回滾。
(2)傳統Spring XML配置檔案只需要新增依賴個設定如下配置即可,使用方式一樣:
<aop:aspectj-autoproxy expose-proxy="true"/>
五、總結
這裡回到文章首頁線上故障場景拿過來
主方法parent()裡調child()方法,當child()丟擲異常時,parent()和child()均未回滾。這是因為parent調child方法,就是在調一個普通方法,即使child()上寫了@Transactional,其本質就是:
內層方法拋異常,但是被catch到,自始至終都沒有觸發異常來回滾。