Spring中事務巢狀這麼用一定得注意了!!

JAVA旭陽發表於2023-04-22

前言

最近專案上有一個使用事務相對複雜的業務場景報錯了。在絕大多數情況下,都是風平浪靜,沒有問題。其實內在暗流湧動,在有些異常情況下就會報錯,這種偶然性的問題很有可能就會在暴露到生產上造成事故,那究竟是怎麼回事呢?

問題描述

我們用一個簡單的例子模擬下,大家也可以看看下面這段程式碼輸出的結果是什麼。

  1. 在類SecondTransactionService定義一個簡單介面transaction2,插入一個使用者,同時必然會丟擲錯誤
@Override
@Transactional(rollbackFor = Exception.class)
public void transaction2() {
    System.out.println("do transaction2.....");
    User user = new User("tx2", "111", 18);
    // 插入一個使用者
    userService.insertUser(user);
    // 跑錯了
    throw new RuntimeException();
}
  1. 在另外一個類FirstTransactionService定義一個介面transaction1,它呼叫transaction2方法,同時做了try catch處理
@Override
@Transactional(rollbackFor = Exception.class)
public void transaction1() {
    System.out.println("do transaction1 .......");
    try {
        // 呼叫另外一個事務,try catch住
        secondTransactionService.transaction2();
    } catch (Exception e) {
        e.printStackTrace();
    }

    // 插入當前使用者tx1
    User user = new User("tx1", "111", 18);
    userService.insertUser(user);
}
  1. 定義一個controller,呼叫transaction1方法
@GetMapping("/testNestedTx")
public String testNestedTx() {
    firstTransactionService.transaction1();
    return "success";
}

大家覺得呼叫這個http介面,最終資料庫插入的是幾條資料呢?

問題結果

正確答案是資料庫插入了0條資料。

同時控制檯也報錯了,報錯原因是:org.springframework.transaction.UnexpectedRollbackException: Transaction rolled back because it has been marked as rollback-only

是否和你預想的一樣呢?你知道是為什麼嗎?

原因追溯

其實原因很簡單,我們都知道,一個事務要麼全成功提交事務,要麼失敗全部回滾。如果出現在一個事務中部分SQL要回滾,部分SQL要提交,這不就主打的一個”前後矛盾,精神分裂“嗎?

controller.testNestedTx() 
  || 
  / 
FirstTransactionService.transaction1()   REQUIRED隔離級別
       || 
       || 
       || 捕獲異常,提交事務,出錯啦
       / || 
FirstTransactionService.transaction2()   REQUIRED隔離級別
       || || 
       || 丟擲異常,標記事務為rollback only
       =======================
  1. 事務的隔離級別為REQUIRED,那麼發現沒有事務開啟一個事務操作,有的話,就合併到這個事務中,所以transaction1()transaction2()是在同一個事務中。
  2. transaction2()丟擲異常,那麼事務會被標記為rollback only, 原始碼如下所示:

  1. transaction1()由於try catch 異常,正常執行,想必就要可以提交事務了,在提交事務的時候,會檢查rollback標記,如果是true, 這時候就會丟擲上面的異常了。原始碼如下圖所示:

這下,是不是很清楚知道報錯的原因了,那想想該怎麼處理呢?

解決之道

知道了根本原因之後,是不是解決的方案就很明朗了,我們可以透過調整事務的傳播方式分拆多個事務管理,或者讓一個事務"前後一致",做一個誠信的好事務。

  • try catch放到內層事務中,也就是transaction2()方法中,這樣內層事務會跟著外部事務進行提交或者回滾。
@Override
    @Transactional(rollbackFor = Exception.class)
    public void transaction2() {
        try {
            System.out.println("do transaction2.....");
            User user = new User("tx2", "111", 18);
            userService.insertUser2(user);
            throw new RuntimeException();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
  • 如果希望內層事務丟擲異常時中斷程式執行,直接在外層事務的catch程式碼塊中丟擲e,這樣同一個事務就都會回滾。
  • 如果希望內層事務回滾,但不影響外層事務提交,需要將內層事務的傳播方式指定為PROPAGATION_NESTEDPROPAGATION_NESTED基於資料庫savepoint實現的巢狀事務,外層事務的提交和回滾能夠控制嵌內層事務,而內層事務報錯時,可以返回原始savepoint,外層事務可以繼續提交。

事務的傳播機制

前面提到了事務的傳播機制,我們再看都有哪幾種。

  • PROPAGATION_REQUIRED:加入到當前事務中,如果當前沒有事務,就新建一個事務。這是最常見的選擇,也是Spring中預設採用的方式。
  • PROPAGATION_SUPPORTS:支援當前事務,如果當前沒有事務,就以非事務方式執行。
  • PROPAGATION_MANDATORY :支援當前事務,如果當前沒有事務,就丟擲異常。
  • PROPAGATION_REQUIRES_NEW:新建一個事務,如果當前存在事務,把當前事務掛起。
  • PROPAGATION_NOT_SUPPORTED :以非事務方式執行操作,如果當前存在事務,就把當前事務掛起。
  • PROPAGATION_NEVER: 以非事務方式執行,如果當前存在事務,則丟擲異常。
  • PROPAGATION_NESTED :如果當前存在事務,則在巢狀事務內執行。如果當前沒有事務,則進行與PROPAGATION_REQUIRED類似的操作。

如何理解PROPAGATION_NESTED的傳播機制呢,和PROPAGATION_REQUIRES_NEW又有什麼區別呢?我們用一個例子說明白。

  • 定義serviceA.methodA()PROPAGATION_REQUIRED修飾;
  • 定義serviceB.methodB()以表格中三種方式修飾;
  • methodA中呼叫methodB;

總結

在我的專案中之所以會報“rollback-only”異常的根本原因是程式碼風格不一致的原因。外層事務對錯誤的處理方式是返回true或false來告訴上游執行結果,而內層事務是透過丟擲異常來告訴上游(這裡指外層事務)執行結果,這種差異就導致了“rollback-only”異常。大家也可以去review自己專案中的程式碼,是不是也偷偷犯下同樣的錯誤了。

歡迎關注個人公眾號【JAVA旭陽】交流學習

相關文章