一個@Transaction哪裡來這麼多坑?

程式設計師DMZ發表於2020-08-20

前言

在之前的文章中已經對Spring中的事務做了詳細的分析了,這篇文章我們來聊一聊平常工作時使用事務可能出現的一些問題(本文主要針對使用@Transactional進行事務管理的方式進行討論)以及對應的解決方案

  1. 事務失效
  2. 事務回滾相關問題
  3. 讀寫分離跟事務結合使用時的問題

事務失效

事務失效我們一般要從兩個方面排查問題

資料庫層面

資料庫層面,資料庫使用的儲存引擎是否支援事務?預設情況下MySQL資料庫使用的是Innodb儲存引擎(5.5版本之後),它是支援事務的,但是如果你的表特地修改了儲存引擎,例如,你通過下面的語句修改了表使用的儲存引擎為MyISAM,而MyISAM又是不支援事務的

alter table table_name engine=myisam;

這樣就會出現“事務失效”的問題了

解決方案:修改儲存引擎為Innodb

業務程式碼層面

業務層面的程式碼是否有問題,這就有很多種可能了

  1. 我們要使用Spring的申明式事務,那麼需要執行事務的Bean是否已經交由了Spring管理?在程式碼中的體現就是類上是否有@ServiceComponent等一系列註解

解決方案:將Bean交由Spring進行管理(新增@Service註解)

  1. @Transactional註解是否被放在了合適的位置。在上篇文章中我們對Spring中事務失效的原理做了詳細的分析,其中也分析了Spring內部是如何解析@Transactional註解的,我們稍微回顧下程式碼:

image-20200818152357704

程式碼位於:AbstractFallbackTransactionAttributeSource#computeTransactionAttribute

也就是說,預設情況下你無法使用@Transactional對一個非public的方法進行事務管理

解決方案:修改需要事務管理的方法為public

  1. 出現了自呼叫。什麼是自呼叫呢?我們看個例子
@Service
public class DmzService {
	
	public void saveAB(A a, B b) {
		saveA(a);
		saveB(b);
	}

	@Transactional
	public void saveA(A a) {
		dao.saveA(a);
	}
	
	@Transactional
	public void saveB(B b){
		dao.saveB(a);
	}
}

上面三個方法都在同一個類DmzService中,其中saveAB方法中呼叫了本類中的saveAsaveB方法,這就是自呼叫。在上面的例子中saveAsaveB上的事務會失效

那麼自呼叫為什麼會導致事務失效呢?我們知道Spring中事務的實現是依賴於AOP的,當容器在建立dmzService這個Bean時,發現這個類中存在了被@Transactional標註的方法(修飾符為public)那麼就需要為這個類建立一個代理物件並放入到容器中,建立的代理物件等價於下面這個類

public class DmzServiceProxy {

    private DmzService dmzService;

    public DmzServiceProxy(DmzService dmzService) {
        this.dmzService = dmzService;
    }

    public void saveAB(A a, B b) {
        dmzService.saveAB(a, b);
    }

    public void saveA(A a) {
        try {
            // 開啟事務
            startTransaction();
            dmzService.saveA(a);
        } catch (Exception e) {
            // 出現異常回滾事務
            rollbackTransaction();
        }
        // 提交事務
        commitTransaction();
    }

    public void saveB(B b) {
        try {
            // 開啟事務
            startTransaction();
            dmzService.saveB(b);
        } catch (Exception e) {
            // 出現異常回滾事務
            rollbackTransaction();
        }
        // 提交事務
        commitTransaction();
    }
}

上面是一段虛擬碼,通過startTransactionrollbackTransactioncommitTransaction這三個方法模擬代理類實現的邏輯。因為目標類DmzService中的saveAsaveB方法上存在@Transactional註解,所以會對這兩個方法進行攔截並嵌入事務管理的邏輯,同時saveAB方法上沒有@Transactional,相當於代理類直接呼叫了目標類中的方法。

我們會發現當通過代理類呼叫saveAB時整個方法的呼叫鏈如下:

事務失效

實際上我們在呼叫saveAsaveB時呼叫的是目標類中的方法,這種清空下,事務當然會失效。

常見的自呼叫導致的事務失效還有一個例子,如下:

@Service
public class DmzService {
	@Transactional
	public void save(A a, B b) {
		saveB(b);
	}
	
	@Transactional(propagation = Propagation.REQUIRES_NEW)
	public void saveB(B b){
		dao.saveB(a);
	}
}

當我們呼叫save方法時,我們預期的執行流程是這樣的

事務失效(自呼叫requires_new)

也就是說兩個事務之間互不干擾,每個事務都有自己的開啟、回滾、提交操作。

但根據之前的分析我們知道,實際上在呼叫saveB方法時,是直接呼叫的目標類中的saveB方法,在saveB方法前後並不會有事務的開啟或者提交、回滾等操作,實際的流程是下面這樣的

事務失效(自呼叫requires_new)執行流程

由於saveB方法實際上是由dmzService也就是目標類自己呼叫的,所以在saveB方法的前後並不會執行事務的相關操作。這也是自呼叫帶來問題的根本原因:自呼叫時,呼叫的是目標類中的方法而不是代理類中的方法

解決方案

  1. 自己注入自己,然後顯示的呼叫,例如:

    @Service
    public class DmzService {
    	// 自己注入自己
    	@Autowired
    	DmzService dmzService;
    	
    	@Transactional
    	public void save(A a, B b) {
    		dmzService.saveB(b);
    	}
    
    	@Transactional(propagation = Propagation.REQUIRES_NEW)
    	public void saveB(B b){
    		dao.saveB(a);
    	}
    }
    

    這種方案看起來不是很優雅

  2. 利用AopContext,如下:

    @Service
    public class DmzService {
    
    	@Transactional
    	public void save(A a, B b) {
    		((DmzService) AopContext.currentProxy()).saveB(b);
    	}
    
    	@Transactional(propagation = Propagation.REQUIRES_NEW)
    	public void saveB(B b){
    		dao.saveB(a);
    	}
    }
    

    使用上面這種解決方案需要注意的是,需要在配置類上新增一個配置

    // exposeProxy=true代表將代理類放入到執行緒上下文中,預設是false
    @EnableAspectJAutoProxy(exposeProxy = true)
    

    個人比較喜歡的是第二種方式

這裡我們做個來做個小總結

總結

一圖勝千言

事務失效的原因

事務回滾相關問題

回滾相關的問題可以被總結為兩句話

  1. 想回滾的時候事務確提交了
  2. 想提交的時候被標記成只能回滾了(rollback only)

先看第一種情況:想回滾的時候事務確提交了。這種情況往往是程式設計師對Spring中事務的rollbackFor屬性不夠了解導致的。

Spring預設丟擲了未檢查unchecked異常(繼承自 RuntimeException 的異常)或者 Error才回滾事務;其他異常不會觸發回滾事務,已經執行的SQL會提交掉。如果在事務中丟擲其他型別的異常,但卻期望 Spring 能夠回滾事務,就需要指定 rollbackFor屬性。

對應程式碼其實我們上篇文章也分析過了,如下:

image-20200818195112983

以上程式碼位於:TransactionAspectSupport#completeTransactionAfterThrowing方法中

預設情況下,只有出現RuntimeException或者Error才會回滾

public boolean rollbackOn(Throwable ex) {
    return (ex instanceof RuntimeException || ex instanceof Error);
}

所以,如果你想在出現了非RuntimeException或者Error時也回滾,請指定回滾時的異常,例如:

@Transactional(rollbackFor = Exception.class)

第二種情況:想提交的時候被標記成只能回滾了(rollback only)

對應的異常資訊如下:

Transaction rolled back because it has been marked as rollback-only

我們先來看個例子吧

@Service
public class DmzService {

	@Autowired
	IndexService indexService;

	@Transactional
	public void testRollbackOnly() {
		try {
			indexService.a();
		} catch (ClassNotFoundException e) {
			System.out.println("catch");
		}
	}
}

@Service
public class IndexService {
	@Transactional(rollbackFor = Exception.class)
	public void a() throws ClassNotFoundException{
		// ......
		throw new ClassNotFoundException();
	}
}

在上面這個例子中,DmzServicetestRollbackOnly方法跟IndexServicea方法都開啟了事務,並且事務的傳播級別為required,所以當我們在testRollbackOnly中呼叫IndexServicea方法時這兩個方法應當是共用的一個事務。按照這種思路,雖然IndexServicea方法丟擲了異常,但是我們在testRollbackOnly將異常捕獲了,那麼這個事務應該是可以正常提交的,為什麼會丟擲異常呢?

如果你看過我之前的原始碼分析的文章應該知道,在處理回滾時有這麼一段程式碼

rollBackOnly設定

在提交時又做了下面這個判斷(這個方法我刪掉了一些不重要的程式碼

commit_rollbackOnly

可以看到當提交時發現事務已經被標記為rollbackOnly後會進入回滾處理中,並且unexpected傳入的為true。在處理回滾時又有下面這段程式碼

丟擲異常

最後在這裡丟擲了這個異常。

以上程式碼均位於AbstractPlatformTransactionManager

總結起來,主要的原因就是因為內部事務回滾時將整個大事務做了一個rollbackOnly的標記,所以即使我們在外部事務中catch了丟擲的異常,整個事務仍然無法正常提交,並且如果你希望正常提交,Spring還會丟擲一個異常。

解決方案:

這個解決方案要依賴業務而定,你要明確你想要的結果是什麼

  1. 內部事務發生異常,外部事務catch異常後,內部事務自行回滾,不影響外部事務

將內部事務的傳播級別設定為nested/requires_new均可。在我們的例子中就是做如下修改:

// @Transactional(rollbackFor = Exception.class,propagation = Propagation.REQUIRES_NEW)
@Transactional(rollbackFor = Exception.class,propagation = Propagation.NESTED)
public void a() throws ClassNotFoundException{
// ......
throw new ClassNotFoundException();
}

雖然這兩者都能得到上面的結果,但是它們之間還是有不同的。當傳播級別為requires_new時,兩個事務完全沒有聯絡,各自都有自己的事務管理機制(開啟事務、關閉事務、回滾事務)。但是傳播級別為nested時,實際上只存在一個事務,只是在呼叫a方法時設定了一個儲存點,當a方法回滾時,實際上是回滾到儲存點上,並且當外部事務提交時,內部事務才會提交,外部事務如果回滾,內部事務會跟著回滾。

  1. 內部事務發生異常時,外部事務catch異常後,內外兩個事務都回滾,但是方法不丟擲異常
@Transactional
public void testRollbackOnly() {
try {
   indexService.a();
} catch (ClassNotFoundException e) {
   // 加上這句程式碼
   TransactionInterceptor.currentTransactionStatus().setRollbackOnly();
}
}

通過顯示的設定事務的狀態為RollbackOnly。這樣當提交事務時會進入下面這段程式碼

顯示回滾

最大的區別在於處理回滾時第二個引數傳入的是false,這意味著回滾是回滾是預期之中的,所以在處理完回滾後並不會丟擲異常。

讀寫分離跟事務結合使用時的問題

讀寫分離一般有兩種實現方式

  1. 配置多資料來源
  2. 依賴中介軟體,如MyCat

如果是配置了多資料來源的方式實現了讀寫分離,那麼需要注意的是:如果開啟了一個讀寫事務,那麼必須使用寫節點如果是一個只讀事務,那麼可以使用讀節點

如果是依賴於MyCat等中介軟體那麼需要注意:只要開啟了事務,事務內的SQL都會使用寫節點(依賴於具體中介軟體的實現,也有可能會允許使用讀節點,具體策略需要自行跟DB團隊確認)

基於上面的結論,我們在使用事務時應該更加謹慎,在沒有必要開啟事務時儘量不要開啟。

一般我們會在配置檔案配置某些約定的方法名字字首開啟不同的事務(或者不開啟),但現在隨著註解事務的流行,好多開發人員(或者架構師)搭建框架的時候在service類上加上了@Transactional註解,導致整個類都是開啟事務的,這樣嚴重影響資料庫執行的效率,更重要的是開發人員不重視、或者不知道在查詢類的方法上面自己加上@Transactional(propagation=Propagation.NOT_SUPPORTED)就會導致,所有的查詢方法實際並沒有走從庫,導致主庫壓力過大。

其次,關於如果沒有對只讀事務做優化的話(優化意味著將只讀事務路由到讀節點),那麼@Transactional註解中的readOnly屬性就應該要慎用。我們使用readOnly的原本目的是為了將事務標記為只讀,這樣當MySQL服務端檢測到是一個只讀事務後就可以做優化,少分配一些資源(例如:只讀事務不需要回滾,所以不需要分配undo log段)。但是當配置了讀寫分離後,可能會可能會導致只讀事務內所有的SQL都被路由到了主庫,讀寫分離也就失去了意義。

總結

本文為事務專欄最後一篇啦!這篇文章主要是總結了工作中事務相關的常見問題,想讓大家少走點彎路!希望大家可以認真讀完哦,有什麼問題可以直接在後臺私信我或者加我微信!

這篇文章也是整個Spring系列的最後一篇文章,之後可能會出一篇原始碼閱讀心得,跟大家聊聊如何學習原始碼。

另外今年也給自己定了個小目標,就是完成SSM框架原始碼的閱讀。目前來說Spring是完成,接下來就是SpringMVC跟MyBatis。

在分析MyBatis前,會從JDBC原始碼出發,然後就是MyBatis對配置的解析、MyBatis執行流程、MyBatis的快取、MyBatis的事務管理已及MyBatis的外掛機制。

在學習SpringMVC前,會從TomCat出發,先講清楚TomCat的原理,我們再來看SpringMVC。整個來說相比於Spring原始碼,我覺得應該不算特別難。

希望在這個過程中可以跟大家一起進步!!!
如果本文對你由幫助的話,記得點個贊吧!也歡迎關注我的公眾號,微信搜尋:程式設計師DMZ,或者掃描下方二維碼,跟著我一起認認真真學Java,踏踏實實做一個coder。

公眾號

我叫DMZ,一個在學習路上匍匐前行的小菜鳥!

相關文章