關於spring巢狀事務,我發現網上好多熱門文章持續性地以訛傳訛

是奉壹呀發表於2023-04-24

事情起因是,摸魚的時候在某平臺刷到一篇spring事務相關的博文,文章最後貼了一張圖。裡面關於巢狀事務的表述明顯是錯誤的。

更奇怪的是,這張圖有點印象。在必應搜尋關鍵詞PROPAGATION_NESTED出來的第一篇文章,裡面就有這這部份內容,也是結尾部份完全一模一樣。

更關鍵的是,人家原文是表格,這位倒好,估計是怕麻煩,直接給截成圖片了。

而且這篇文章其實在評論區已經被人指出來這方面的問題了,但是這位作者依然不加驗證的直接拿走了。

這位作者可不是個小號,是某年度的人氣作者。
可能是有自己的公眾號,得保持一定的更新頻率?



好傢伙,沒經過驗證,一部份錯誤的內容就這樣被持續擴大傳播了。

在必應搜尋關鍵詞PROPAGATION_NESTED出來文章,前兩篇都是CSDN,都是一樣的文章一樣的錯誤。另外幾篇文章也或多或少有些表述不清的地方。因此嘗試來寫一寫這方面的東西。

順便吐槽一下CSDN,我好多篇文章都被這上面的某些作者給扒過去,然後搜尋一模一樣的標題,權重比我還高,出來排第一位的反而是CSDN的盜版文章。

1.當我們在談論巢狀事務的時候,巢狀的是什麼?


當看到`巢狀事務`第一反應想到是這樣式的:

但這更像PROPAGATION_REQUIRES_NEW啊,感興趣可以去打斷點執行一下。PROPAGATION_REQUIRES_NEW事務傳播下,方法A呼叫方法B就是這樣,

//        事務A doBegin()
//            事務B doBegin()
//            事務B doCommit()
//        事務A doCommit()
 

而在PROPAGATION_NESTED事務傳播下,打了個斷點,會發現只會執行一次doBegin和doCommit:

事務A doBegin()
事務A doCommit()

我們用程式碼輸出更加直觀。
定義兩個方法serviceA和serviceB,使用前者呼叫後者。前者事務傳播使用REQUIRED,後者使用PROPAGATION_NESTED

@Transactional(propagation = Propagation.REQUIRED)
    public void serviceA(){
            Tcity tcity2 = new Tcity();
            tcity2.setId(0);
            tcity2.setStateCode("5");
            tcity2.setCnCity("測試城市2");
            tcity2.setCountryCode("ALB");
            tcityMapper.insertSelective(tcity2);
            transactionInfo();
            test2.serviceB();
    }
 @Transactional(rollbackFor = Exception.class, propagation = Propagation.NESTED)
    public void serviceB() {
        Tcity tcity = new Tcity();
        tcity.setId(0);
        tcity.setStateCode("5");
        tcity.setCnCity("測試城市");
        tcity.setCountryCode("ALB");
        tcityMapper.insertSelective(tcity);
        tcityMapper.selectAll2();
        transactionInfo();

這裡的transactionInfo()使用事務同步器管理器TransactionSynchronizationManager註冊一個事務同步器TransactionSynchronization
這樣在事務完成之後afterCompletion會輸出當前事務是commit還是rollback,這樣也便於測試,比起去重新整理資料庫看有沒有寫入,更加方便快捷直觀。

同時使用TransactionSynchronizationManager.getCurrentTransactionName()可以得到當前事務的名稱,這樣可以直觀的看到當前方法使用的是同一個事務還是不同的事務。

protected void transactionInfo() {

        String transactionName = TransactionSynchronizationManager.getCurrentTransactionName();
        boolean active = TransactionSynchronizationManager.isActualTransactionActive();
        log.info("transactionName:{}, active:{}", transactionName, active);

        if (!active) {
            log.info("transaction :{} not active", transactionName);
            return;
        }
        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCompletion(int status) {
                if (status == STATUS_COMMITTED) {
                    log.info("transaction :{} commit", transactionName);
                } else if (status == STATUS_ROLLED_BACK) {
                    log.info("transaction :{} rollback", transactionName);
                } else {
                    log.info("transaction :{}  unknown", transactionName);
                }
            }
        });
    }

執行測試程式碼:

@RunWith(SpringRunner.class)
@SpringBootTest
public class Test { 
    @Autowired
    private Test1 test1;

    @org.junit.Test
    public void test(){
        test1.serviceA();
    }
}

輸出:

可以非常直觀地觀察到3點情況:
1.透過上圖示記為1的地方,可以看到兩個方法使用了一個事務com.nyp.test.service.propagation.Test1.serviceA
2.透過上圖示記為2的地方,以及箭頭順序,可以看到事務執行順序類似於(事實上不是,只是事務同步器的問題,下文有說明):

//        事務A doBegin()
//            事務B doBegin()
//        事務A doCommit()
//            事務B doCommit()

3.透過事務同步器列印日誌發現commit執行了兩次。

以上2,3兩點與前面打斷點的結論貌似是有點衝突。


1.1巢狀事務究竟有幾個事務


原始碼版本:spring-tx 5.3.25

透過原始碼,可以很直觀地觀察到,useSavepointForNestedTransaction()預設返回true,這樣就不會開啟一個新的事務(startTransaction), 而是建立一個新的savepoint

相當於在方法A的時候會開啟一個新的事務,在呼叫方法B的時候,會在方法A之後方法B之前建立一個檢查點。

類似於在原來的A方法上手動新增檢查點。

    @Transactional(propagation = Propagation.REQUIRED)
    public void serviceA(){
        Object savePoint = null;
        try {
            Tcity tcity2 = new Tcity();
            tcity2.setId(0);
            tcity2.setStateCode("5");
            tcity2.setCnCity("測試城市2");
            tcity2.setCountryCode("ALB");
            tcityMapper.insertSelective(tcity2);
            transactionInfo();
            savePoint = TransactionAspectSupport.currentTransactionStatus().createSavepoint();
            test2.serviceB();
        } catch (Exception exception) {
            exception.printStackTrace();
            TransactionAspectSupport.currentTransactionStatus().rollbackToSavepoint(savePoint);
        }
    }


然後透過檢查點,將一個邏輯事務分為多個物理事務
我這可不是在亂講啊,我是有備而來。


https://github.com/spring-projects/spring-framework/issues/8135
上面是spring 在github官方社群07年的一個貼子,Juergen Hoeller有一段回覆。

Juergen Hoeller是誰?他是spring的聯合創始人,事務這一塊的主要開發者。


PROPAGATION_NESTED的不同之處在於,它使用具有多個儲存點的單個物理事務,可以回滾到這些儲存點。這種部分回滾允許內部事務範圍觸發其範圍的回滾,而外部事務可以繼續進行物理事務,儘管已經回滾了一些操作。這通常對映到JDBC儲存點上,因此只適用於JDBC資源事務(Spring的DataSourceTransactionManager)。

在巢狀事務中,整體是一個邏輯事務,透過savepoint在jdbc物理層面把呼叫方法分割成一個個的物理事務。
因為spring層面只有一個邏輯事務,所以透過斷點只執行了一次doBegin()和doCommit(),但實際上執行了兩次preCommit(),如果有savepoint那就不執行commit(),
這也能回答上面2,3兩點問題的疑問。

所以上面方法A呼叫方法B進行巢狀事務,右(下)圖比左(上)圖更形象準確:

1.2 savepoint

savepoint是JDBC的一種機制,spring運用savepoint來實現了巢狀事務。
在資料庫操作中,預設autocommit為true,意味著一條SQL一個事務。也可以將autocommit設定為false,將多條SQL組成一個事務,一起commit或者rollback。
以上都是常規操作,在一個事務中所以資料庫操作全部捆綁在一起。在某些特定情況下,在一個事務中,使用者只希望rollback其中某部份,這時候可以用到savepoint。


記我們忘掉@Transactional,以程式設計式事務的方式來手動設定一個savepoint。

方法A,寫入一條使用者記錄,並設定一個檢查點。

    @Autowired
    private PlatformTransactionManager platformTransactionManager;

    public void serviceA(){
        TransactionStatus status = platformTransactionManager.getTransaction(new DefaultTransactionDefinition());
        Object savePoint = null;
        try {
            Person person = new Person();
            person.setName("張三");
            personDao.insertSelective(person);
            transactionInfo();
            // 設定一個savepoint
            savePoint = status.createSavepoint();
            test2.serviceB();
        } catch (Exception exception) {
            exception.printStackTrace();
            // 這裡輸出兩次commit,到rollback到51行,會插入一條資料
            status.rollbackToSavepoint(savePoint);
            // 這裡會兩次rollback
//            platformTransactionManager.rollback(status);

        }
        platformTransactionManager.commit(status);
    }

方法B寫入一條日誌記錄。並在此模擬一個異常。

    public void serviceB() {
        TLog tLog = new TLog();
        tLog.setOprate("user");
        transactionInfo();
        tLogDao.insertSelective(tLog);     
        int a = 1 / 0;
    }

測試希望達到的效果是,日誌寫入失敗,但使用者記錄寫入成功。很明顯,如果不使用savepoint是達不到的。因為兩個方法是一個事務,在方法B中報錯了,丟擲異常,使用者和日誌的資料庫操作都將回滾。

測試輸出日誌:

[2023-04-24 14:40:18.740] INFO 88384 [main] [com.nyp.test.service.propagation.Test1] : transactionName:null, active:true
[2023-04-24 14:40:18.742] INFO 88384 [main] [com.nyp.test.service.propagation.Test2] : transactionName:null, active:true
java.lang.ArithmeticException: / by zero
	......省略
[2023-04-24 14:40:18.747] INFO 88384 [main] [com.nyp.test.service.propagation.Test1] : transaction :null commit
[2023-04-24 14:40:18.747] INFO 88384 [main] [com.nyp.test.service.propagation.Test2] : transaction :null commit

資料庫也表明使用者寫入成功,日誌寫入失敗。


2.一開始的問題,B先回滾A再正常提交?

本文開始的問題是方法A事務傳播為PROPAGATION_REQUIRED,方法B事務傳播為PROPAGATION_NESTED。方法A呼叫B,methodA正常,methodB拋異常。
這種情況下會發生什麼?

B先回滾,A再正常提交這種說法為什麼會有問題,有什麼問題?

2.1 先B後A的順序有問題嗎?

透過前面事務同步器列印的日誌我們得知,事務以test1.serviceA()執行doBegin(),test2.serviceB()執行doBegin(),test1.serviceA()執行doCommit(),test2.serviceB()執行doCommit()這樣的順序執行。

但是果真如此嗎?

透過原始碼我們首先得知,preCommit()在commit()方法之前,在preCommit()會做savepoint的判斷,如果有檢查點就不執行commit()。

  1. 同時方法B只是一個savepoint不是一個真正的事務,並不會執行事務同步器。
  2. 方法A是一個真正的事務,所以會執行commit(),同時也會執行上面的事務同步器。


這裡的事務同步器是一個Arraylist,它的執行順序即是arraylist的遍歷順序,僅僅只代表加入的先後,並不代表事務真正commit/rollback的順序。


從1,2兩點可以得出結論,先B後A的順序並沒有問題。

同時,根據1,在巢狀事務中使用事務同步器要特別小心,在檢查點的時候並不會執行同步器,同時會掩蓋真正的操作。

比如方法B回滾了,但因為方法B只是個savepoint,所以事務同步器不會執行。等到方法A執行完操作事務同步器的時候,也只會反應外層事務即方法A的事務結果。


2.2 真正的問題

如果B回滾,A是commit還是rollback取決於方法A是否繼續把異常往上拋。


讓我們先暫時忘掉巢狀事務,測試一個REQUIRES_NEW的案例。
同樣的方法A事務傳播為REQUIRES,方法B為REQUIRES_NEW
此時方法A和方法B為兩個彼此獨立的事務。
方法A呼叫方法B,方法B丟擲異常。
此時,方法B肯定會回滾,但方法A呢?按理說彼此獨立,那肯定是commit了。


但真的如此嗎?


(1). 方法A不做異常處理。

測試結果:

可以看到確實是兩個事務,但兩個事務都rollback了。因為方法A雖然沒有報異常,但它接到了方法B的異常且往上拋了,spring只會認為方法A同樣也丟擲了異常。因此兩個事務都需要回滾。

(2).方法A處理了異常。

將方法A程式碼try-catch住,再執行。

日誌有點多不做截圖,

[2023-04-24 16:10:30.669] INFO 96664 [main] [com.nyp.test.service.propagation.Test1] : transactionName:com.nyp.test.service.propagation.Test1.serviceA, active:true
[2023-04-24 16:10:30.672] INFO 96664 [main] [com.nyp.test.service.propagation.Test2] : transactionName:com.nyp.test.service.propagation.Test2.serviceB, active:true
[2023-04-24 16:10:30.687] INFO 96664 [main] [com.nyp.test.service.propagation.Test2] : transaction :com.nyp.test.service.propagation.Test2.serviceB rollback
java.lang.ArithmeticException: / by zero
	 省略
[2023-04-24 16:10:30.689] INFO 96664 [main] [com.nyp.test.service.propagation.Test1] : transaction :com.nyp.test.service.propagation.Test1.serviceA commit

可以看到兩個單獨的事務,事務B回滾了,事務A提交了。

雖然我們這小節說的是REQUIRES_NEW,但巢狀事務是一樣的道理。

如果B回滾,當方法A繼續往上拋異常,則A回滾;當方法A處理了異常不往上拋,則A提交。

3. 場景

在2.2小節中,我們舉了REQUIRES_NEW的例子來說明,有的同學可能就會有點疑問了。既然事務B回滾了,事務A都要根據情況來判斷是否回滾,那這樣巢狀事務跟REQUIRES_NEW有啥區別?

還是拿註冊的場景來說。往資料庫寫1條使用者記錄,再寫1條註冊成功操作日誌。

  1. 如果日誌寫入失敗,使用者寫入不受影響。這種情況下, REQUIRES_NEW和巢狀事務都能實現。而且很明顯REQUIRES_NEW還沒那麼彎彎繞繞。
    2.考慮另外一種情況,如果使用者寫入失敗了,那這時候我想要日誌寫入也失敗。因為使用者都沒了,就不存在註冊操作成功的操作日誌了。

這種場景,在方法B為REQUIRES_NEW模式下,列印輸出

可以看到方法B提交了,也就是說使用者註冊失敗了,但使用者註冊成功的操作日誌卻寫入成功了。


我們再來看看巢狀事務的情況下:
方法A傳播級別為REQUIRED,並模擬一個異常。

    @Transactional(propagation = Propagation.REQUIRED)
    public void serviceA(){
        Person person = new Person();
        person.setName("李四");
        personDao.insertSelective(person);
        transactionInfo();
        test2.serviceB();
        int a = 1 / 0;
    }

方法B事務傳播級別為NESTED。

    @Transactional(propagation = Propagation.NESTED)
    public void serviceB() {
        TLog tLog = new TLog();
        tLog.setOprate("user");
        transactionInfo();
        tLogDao.insertSelective(tLog);
    }

執行日誌

可以看到同一個邏輯事務下的兩段物理事務都回滾了,達到了我們預期的效果。

4.小結

1.方法A事務傳播為REQUIRED,方法B事務傳播為NESTED。方法A呼叫方法B,當B丟擲異常時,
如果A處理了異常,此時事務A提交。否則,事務A回滾。

2.REQUIRED_NEW和NESTED在有些場景下可以實現相同的功能,但在某些特定場景下只能NESTED實現。
3.NESTED底層邏輯是JDBC的savepoint。父事務類似於一個邏輯事務,savepoint將各方法分割了若干物理事務。
4.在巢狀事務中使用事務同步器時需要特別小心。


看到這裡點個讚唄`

相關文章