@Transactional千萬不要這樣用!!踩坑了你都可能發現不了!!!

2023-02-25 18:02:11

前陣子接手了一段同事之前的程式碼,裡面用到了@Transactional註解,瞭解Spring的小夥伴肯定知道,@Transactional是Spring提供的一種控制事務管理的快捷手段。但是我這段程式在執行的時候,經常出現莫名其妙的問題,連夜研究了好久才搞清楚,在這裡記錄一下, 避免大家入坑。

1. 大家來找茬

在介紹具體問題之前,我把問題程式碼簡化了一下,看大家能找到其中的問題嗎?

問題程式碼1

下面的這段程式碼主要是想利用MySQL裡面的行鎖select for update,來實現簡單的分散式鎖。但是在實踐過程中,發現這個鎖好像並沒有生效,而且在資料庫的裡面也沒有查詢對應transaction連線的資訊。

@Component
@EnableScheduling
public class someService {
  
  @Scheduled(...)
  public doSomeWork() {
    // find some id by logic
    
    // process the related info
    doOtherWork(id);
  }
  
  @Transactional(isolation = Isolation.READ_COMMITTED)
  public void doOtherWork(id) {
    Info info = requestMapper.selectByPrimaryKeyForUpdate(id);
    doSomeFollowingProcess(info);
    ...
  }
}

問題程式碼2

下面程式碼分兩個步驟,第一步會檢查相關資訊,第二步呼叫了一個transactional修飾的方法,完成一些基本工作;但在實踐中,發現一個非常詭異的問題,在MainWork中,doSomeCheck執行時會丟擲nullPointException,debug發現所有autowired進來的service均為空,註釋掉doSomeCheck裡面的內容後,繼續往下執行,卻發現doWork能夠正常執行,所有的注入均沒有問題。

@Component
public class MainWork {
  @AutoWired
  DetailWork detailWork
    
  public void workflow() {
    detailWork.doSomeCheck();
    detailWork.doWork();
  }
}

@Component
public class DetailWork {
  
  	@AutoWired
  	UsefulService usefulService;
  
  	@AutoWired
  	InfoService infoService;
  
    @Transactional(isolation = Isolation.READ_COMMITTED)
  	public void doWork() {
      usefulService.doSomeWork();
    }
  
  	void doSomeCheck() {
      infoService.getInfo();
    }
}

大夥看看能發現什麼問題嗎?

2. 關於@Transactional註解

Spring支援程式設計式事務管理宣告式事務管理兩種方式。

  • 程式設計式事務管理使用TransactionTemplate或者直接使用底層的PlatformTransactionManager。

  • 宣告式事務管理建立在AOP之上的。其本質是對方法前後進行攔截,然後在目標方法開始之前建立或者加入一個事務,在執行完目標方法之後,根據執行情況提交或者回滾事務。宣告式事務最大的優點就是不需要通過程式設計的方式管理事務,這樣就不需要在業務邏輯程式碼中摻雜事務管理的程式碼,只需基於@Transactional註解的方式,便可以將事務規則應用到業務邏輯中

下圖是呼叫@Transactional註解的方法時,Spring內部的時序圖。簡單來講就是IOC容器初始化時,會生成@Transactional註解所在類的代理物件,然後實際執行中會通過AOP執行代理物件的方法,TransactionAdvisor會在方法呼叫前判斷是否開啟事務,在呼叫結束後,會判斷是否提交或回滾事務。

深入研究程式碼,我們會發現TransactionInterceptor (事務攔截器)在目標方法執行前後進行攔截,DynamicAdvisedInterceptor(CglibAopProxy 的內部類)的 intercept 方法或 JdkDynamicAopProxy 的 invoke 方法會間接呼叫 AbstractFallbackTransactionAttributeSource的 computeTransactionAttribute 方法,獲取Transactional 註解的事務設定資訊。

protected TransactionAttribute computeTransactionAttribute(Method method,
    Class<?> targetClass) {
        // Don't allow no-public methods as required.
        if (allowPublicMethodsOnly() && !Modifier.isPublic(method.getModifiers())) {
        return null;
}

此方法會檢查目標方法的修飾符是否為 public,不是 public則不會獲取@Transactional 的屬性設定資訊。也就是說protected、private 修飾的方法上使用 @Transactional 註解會導致事務無效。

瞭解了@Transactional的原理之後,我們在回頭看看之前的問題,會不會是使用方法不對導致的呢?

3. 撥雲見日

問題程式碼1解析

下面的程式碼中,我們在同一個類裡面呼叫了@Transactional修飾的方法,其實這樣呼叫的話並沒有用到Spring AOP生成的代理物件。從上面的時序圖也可以看到,只有當事務方法被當前類以外的程式碼呼叫時,才會由Spring生成的代理物件來管理。

@Component
@EnableScheduling
public class someService {
  
  @Scheduled(...)
  public doSomeWork() {
    // find some id by logic
    
    // process the related info
    doOtherWork(id);
  }
  
  @Transactional(isolation = Isolation.READ_COMMITTED)
  public void doOtherWork(id) {
    Info info = requestMapper.selectByPrimaryKeyForUpdate(id);
    doSomeFollowingProcess(info);
    ...
  }
}

那如何解決這種類內呼叫的問題呢? 很簡單,可以使用applicationContext直接從IOC容器中將someService類取出來,然後再呼叫doOtherWork方法即可,這樣就能用上Spring AOP生成的代理物件了

下面是更改之後的程式碼,更改之後發現事務生效了,問題解決!

@Component
@EnableScheduling
public class someService {
  
  @Autowired
  private ApplicationContext applicationContext;
  
  @Scheduled(...)
  public doSomeWork() {
    // find some id by logic
    
    // process the related info
    SomeService someService = applicationContext.getBean(someService.class);
    someService.doOtherWork(id);
  }
  
  @Transactional(isolation = Isolation.READ_COMMITTED)
  public void doOtherWork(id) {
    Info info = requestMapper.selectByPrimaryKeyForUpdate(id);
    doSomeFollowingProcess(info);
    ...
  }
}

問題程式碼2解析

下面的程式碼中,MainWork呼叫doSomeCheck的時候,會出現null的情況,原因是由於該方法不是public方法,會導致@Transactional呼叫失敗。你可能會說這就是普通方法,跟@Transactional有什麼關係?

需要注意的是,無論transactional註解在類上還是在方法上,IOC容器都會生成對應類的代理物件,然後使用代理物件去存取對應的方法。在這個例子裡面, 呼叫doWork時一切正常,事務也會生效;但是呼叫doSomeCheck時,從之前的分析可以看到,由於方法不是public,此時事務管理器不會起作用,直接導致所有的autowired未完成注入。修改的方法也很簡單,把doSomeCheck改成public就行了。

這個問題隱藏比較深一些,不清楚原理很難發現這個問題。

@Component
public class MainWork {
  @AutoWired
  DetailWork detailWork
    
  public void workflow() {
    detailWork.doSomeCheck();
    detailWork.doWork();
  }
}

@Component
public class DetailWork {
  
  	@AutoWired
  	UsefulService usefulService;
  
  	@AutoWired
  	InfoService infoService;
  
    @Transactional(isolation = Isolation.READ_COMMITTED)
  	public void doWork() {
      usefulService.doSomeWork();
    }
  
  	public void doSomeCheck() {
      infoService.getInfo();
    }
}

4. 相關拓展

幾種事務失效的場景

上面說到的兩個問題,其實就是@Transactional註解使用不當,導致失效的兩種情形;除此之外,以下幾種情況也會導致事務失效:

  • 業務程式碼中存在異常時,使用try…catch…語句塊捕獲,而catch語句塊沒有throw new RuntimeExecption異常;(最難被排查到問題且容易忽略)

  • 註解@TransactionalPropagation屬性值設定錯誤即Propagation.NOT_SUPPORTED(一般不會設定此種傳播機制)

  • mysql關係型資料庫,且儲存引擎是MyISAM而非InnoDB,則事務會不起作用(比較少見);

  • 業務程式碼丟擲異常型別非RuntimeException,事務失效;Spring預設丟擲未檢查unchecked異常(繼承自 RuntimeException 的異常)或者 Error才回滾事務;其他異常不會觸發回滾事務。如果在事務中丟擲其他型別的異常,但卻期望 Spring 能夠回滾事務,就需要指定 rollbackFor屬性。

事務的傳播行為

事務的傳播行為也會影響到事務與事務之間的關係,一定要搞清楚,否則經常會出現很奇怪的問題。

具體來講有以下幾種屬性:

  • propagation 代表事務的傳播行為,預設值為 Propagation.REQUIRED,其他的屬性資訊如下:

  • Propagation.REQUIRED:如果當前存在事務,則加入該事務,如果當前不存在事務,則建立一個新的事務。( 也就是說如果A方法和B方法都新增了註解,在預設傳播模式下,A方法內部呼叫B方法,會把兩個方法的事務合併為一個事務 )

  • Propagation.SUPPORTS:如果當前存在事務,則加入該事務;如果當前不存在事務,則以非事務的方式繼續執行。

  • Propagation.MANDATORY:如果當前存在事務,則加入該事務;如果當前不存在事務,則丟擲異常。

  • Propagation.REQUIRES_NEW:重新建立一個新的事務,如果當前存在事務,暫停當前的事務。( 當類A中的 a 方法用默Propagation.REQUIRED模式,類B中的 b方法加上採用 Propagation.REQUIRES_NEW模式,然後在 a 方法中呼叫 b方法運算元據庫,然而 a方法丟擲異常後,b方法並沒有進行回滾,因為Propagation.REQUIRES_NEW會暫停 a方法的事務 )

  • Propagation.NOT_SUPPORTED:以非事務的方式執行,如果當前存在事務,暫停當前的事務。

  • Propagation.NEVER:以非事務的方式執行,如果當前存在事務,則丟擲異常。

  • Propagation.NESTED :和 Propagation.REQUIRED 效果一樣。

事務的隔離級別

SQL標準定義了4種事務隔離級別來避免3種資料不一致的問題。事務等級從高到低,分別為:

1.Serializable(序列化)

系統中所有的事務以序列地方式逐個執行,所以能避免所有資料不一致情況。

但是這種以排他方式來控制並行事務,序列化執行方式會導致事務排隊,系統的並行量大幅下降,使用的時候要絕對慎重。

2.Repeatable read(可重複讀)

一個事務一旦開始,事務過程中所讀取的所有資料不允許被其他事務修改。

一個隔離級別沒有辦法解決「幻影讀」的問題。

因為它只「保護」了它讀取的資料不被修改,但是其他資料會被修改。如果其他資料被修改後恰好滿足了當前事務的過濾條件(where語句),那麼就會發生「幻影讀」的情況。

其他兩種事務隔離等級為:

3.Read Committed(已提交讀)

一個事務能讀取到其他事務提交過(Committed)的資料。

一個事務在處理過程中如果重複讀取某一個資料,而且這個資料恰好被其他事務修改並提交了,那麼當前重複讀取資料的事務就會出現同一個資料前後不同的情況。

在這個隔離級別會發生「不可重複讀」的場景。

4.Read Uncommitted(未提交讀)

一個事務能讀取到其他事務修改過,但是還沒有提交的(Uncommitted)的資料。

資料被其他事務修改過,但還沒有提交,就存在著回滾的可能性,這時候讀取這些「未提交」資料的情況就是「髒讀」。

在這個隔離級別會發生「髒讀」場景。


參考: