Try-Catch包裹的代碼異常後,竟然導致了產線事務回滾!

導讀:​一段被try-catch包裹後的代碼在產線穩定運行了200天後忽然發生了異常,而這個異常竟然導致了產線事務回滾。這期間究竟發生了什麼?日常在項目過程中該如何避免事務異常?就在這個時候,老闆拿着《XX公司關於三十歲員工優化通知》走了過來……

在這裡插入圖片描述# 01

產線部分數據丟失了,因為一個蹊蹺的事務回滾。而造成事務回滾的,竟然是一段被try-cath包裹後的代碼,一段已經在產線穩定運行了200天的代碼,穩定到我們已經把它遺忘了。誰也沒想到的是,它竟然以這樣一種方式重新回到了我們的視野,宣告着它的存在!

小九九是一個永遠19歲的程序員,和所有程序員一樣地陽光、帥氣(這句話不管你信不信,反正我自己也不信。為了能夠開始今天的文章,就這麼瞎編吧,總比以「一個沒有頭髮的程序員」開頭的好)。當他告訴我一段try-catch的代碼造成產線事務回滾後,我溫柔、耐心地對他說:「滾一邊去,沒看我正忙着嗎?」,然後他給我甩出了一段代碼,用猥瑣又真誠的眼睛告訴我,他說的是真的。

02

我們來看一下這段導致了產線事務回滾的代碼,類似於下面這樣的:

@Transactional
public void main() {
    // 假設有多個user的操作,需要事務控制
    methodA();

    try {
        orderService.methodB();
    } catch (Exception e) {
        // order失敗了不能影響該方法,不回滾。
        // 異常處理,略
    }
    userOtherProcess();
}

methodA方法需要事務控制,methodB方法不管遇到什麼異常都不能影響A事務,所以加了try-catch。可能有的人和我的第一反應一樣,是不是最後的userOtherProcess方法執行異常造成了methodA的事務回滾?小九九告訴我真的是因為methodB,這段代碼當初經過嚴格的測試,而且已經200天沒人碰過了。也可能已經有人猜出了問題的原因了,這裡先賣個關子,因為這件事情里,最重要的是這個坑是如何一步步產生的。

為了更形象地描述這個事情我畫一個圖,紅色背景表示該方法是有事務控制的,白色背景表示該方法沒有事務

一開始的時候,正如大家所看到的代碼,methodA方法有事務,methodB無事務且被try-catch包裹了,運行得很完美。過了一段時間後來到了階段二,因為一些需求變更新增了methodC,該業務也依賴了methodB,依然很完美地上線了。

過了一段時間來到了階段3,依賴methodC相關業務再次發生了變更,需要在methodB里增加一些邏輯且需要事務控制,經過評估確實對methodA沒有影響,於是經過充分測試後再次完美地上線了,然而隱藏的炸彈就在這個時候埋下了。小夥伴們這個時候應該已經猜到原因了,是的,你猜的沒錯。某一天methodA調用methodBmethodB發生了異常,由於是繼承性事務,雖然methodB發生了異常被try-catch了,依然造成了methodA事務回滾。還沒有理解的小夥伴,可以看下面這張圖:

我們可以把事務控制機制理解為上圖這樣一個紅色的長長的房間,這個房間是有人看守的,他負責事務的開始、提交,還有一項重要的任務就是監控異常,一旦發現RuntimeException異常直接回滾整個事務,我們給他一個title,稱之為「監事」吧。再來看階段三和一開始的代碼,方法的開頭有一個@Transactional註解,於是他打開了這個紅色房間的門,把methodA放了進去,接着methodB過來了,也開啟了事務–繼承性事務,於是監事把methodB也安排到了這個房間,methodB雖然發生了異常且被try-catch包裹,但逃不過監事的火眼金睛,於是他按下了事務回滾的按鈕。

這樣理解了之後,我們再來簡單看一下源碼:

org.springframework.transaction.UnexpectedRollbackException: Transaction rolled back because it has been marked as rollback-only
    at org.springframework.transaction.support.AbstractPlatformTransactionManager.processRollback(AbstractPlatformTransactionManager.java:873)
    at org.springframework.transaction.support.AbstractPlatformTransactionManager.commit(AbstractPlatformTransactionManager.java:710)
    at org.springframework.transaction.interceptor.TransactionAspectSupport.commitTransactionAfterReturning(TransactionAspectSupport.java:534)

根據異常提示,可以看到錯誤發生在AbstractPlatformTransactionManager的873行processRollback方法,通過Find Usages找到調用方commit方法,顯然這是一段事務提交的邏輯。

@Override
public final void commit(TransactionStatus status) throws TransactionException {
    // 為便於閱讀,刪除部分代碼
    ......
	if (!shouldCommitOnGlobalRollbackOnly() && defStatus.isGlobalRollbackOnly()) {
		// 為便於閱讀,刪除部分代碼
		processRollback(defStatus, true);
		return;
	}
	processCommit(defStatus);
}
  • shouldCommitOnGlobalRollbackOnly:默認實現是false,意思是如果發現事務被標記全局回滾並且該標記不需要提交事務的話,那麼則進行回滾。
  • defStatus.isGlobalRollbackOnly():判斷是否是讀取DefaultTransactionStatus中transaction對象的ConnectionHolder的rollbackOnly標誌位

繼續往上追溯,來到TransactionAspectSupport.invokeWithinTransaction方法:

@Nullable
protected Object invokeWithinTransaction(Method method, @Nullable Class<?> targetClass,
		final InvocationCallback invocation) throws Throwable {
	// 為便於閱讀,刪除部分代碼
    ......
    // 如果是聲明式事務
	if (txAttr == null || !(tm instanceof CallbackPreferringPlatformTransactionManager)) {
		// Standard transaction demarcation with getTransaction and commit/rollback calls.
		TransactionInfo txInfo = createTransactionIfNecessary(tm, txAttr, joinpointIdentification);

		Object retVal;
		try {
			// This is an around advice: Invoke the next interceptor in the chain.
			// This will normally result in a target object being invoked.
			// 執行事務方法
			retVal = invocation.proceedWithInvocation();
		}
		catch (Throwable ex) {
			// 捕獲異常,並將會把事務設置為Rollback回滾狀態。
			completeTransactionAfterThrowing(txInfo, ex);
			throw ex;
		}
		finally {
			cleanupTransactionInfo(txInfo);
		}
		// 提交事務
		commitTransactionAfterReturning(txInfo);
		return retVal;
	}

	else {
		// 聲明式事務,略
	}
}

整個執行過程參見注釋說明,其它源碼就不羅列了。Spring捕獲異常後,正如我們所猜測的,事務將會被設置全局rollback,而最外層的事務方法執行commit操作,這時由於事務狀態為rollback,Spring認為不應該commit提交事務,而應該回滾事務,所以拋出rollback-only異常。

03

還有一個比較典型的事務問題就是:在同一個類中,mehtodA沒有事務,mehtodB開啟了(聲明式)事務,此時mehtodA調用mehtodB時事務是不生效

如上面這張圖所示,我們還是把AOP想像成一個長方形的房間,由於mehtodA沒有事務,這個房間已經被標誌為沒有事務無人值守了,mehtodB雖然標記了事務,但很顯然是不生效的。

接下來我們重新回顧一下事務的幾種配置:

  • REQUIRED:支持當前事務,如果當前沒有事務,就新建一個事務。這是最常見的選擇。
  • REQUIRES_NEW:新建事務,如果當前存在事務,把當前事務掛起。
  • SUPPORTS:支持當前事務,如果當前沒有事務,就以非事務方式執行。
  • MANDATORY:支持當前事務,如果當前沒有事務,就拋出異常。
  • NEVER:以非事務方式執行,如果當前存在事務,則拋出異常。
  • NOT_SUPPORTED:以非事務方式執行操作,如果當前存在事務,就把當前事務掛起。
  • NESTED:支持當前事務,如果當前事務存在,則執行一個嵌套事務,如果當前沒有事務,就新建一個事務。

這方面的文章很多,這裡就不做描述了。

04

事務問題本身是比較難通過測試發現的,我們再來聊一聊項目過程中如何防止事務問題的發生。比如筆者之前曾負責過支付及資金處理相關係統,產品的單筆交易額比較大,每筆至少1萬+,正常10萬+,很多時候一筆支付就是300萬,所以容不得出現一筆資金差錯。好在我們資金交易從0做到了3000億,依然資金0差錯。針對可能的事務問題,我們採取的措施有:

  1. 通過開發規範、產線坑集等文檔、培訓等讓開發人員對事務有足夠的了解、敏感度。
  2. 系統設計時,對於關鍵的業務場景需要寫明是否啟用了事務,哪些方法包裹在一個事務中,並進行評審。
  3. 代碼Review環節有很多專項Review,比如資金review、多線程Review等等,也有一項專門的事務Review:需不需要加事務?事務配置是否正確?異常是否處理等。
  4. 開發人員構造事務異常場景進行自測、交叉驗證。
  5. 測試團隊參與系統設計評審,並進行事務相關測試。比如通過防火牆阻斷請求、手動鎖表等方式來模擬可能的事務異常。

筆者在之前一家公司還有一種做法就是通過開發規範約束:所有事務的方法全部以tx開頭。比如methodB方法需要開啟事務,則新增一個txMethodB方法,在該方法中調用methodB。通過這種方式完全可以避免上面問題的發生,但很顯然這種方式相當地「醜陋」。

05

正和小九九聊着事務問題,老闆手裡拿着幾張A4紙走了過來。

作為公司唯一的30歲程序員,我提高了聲音對小九九說:你有沒有發現@Transactional中還有一個配置項readOnly,如果需要使用這個參數,必須啟動一個事務。但如果是讀取數據,根本就不需要事務啊?為什麼會有這麼一個自相矛盾的配置項呢?小九九一臉茫然地搖了搖頭。

老闆沖我點了點頭,轉身回到了辦公室,坐下思考了一會,然後把手裡的A4紙《XX公司​關於三十歲員工優化通知》放到了抽屜一疊資料的最下面,接着又抽出來放到了資料的中間。

看來我的程序生涯,又可以持續一段時間了!

推薦閱讀

Redis 6.0 新特性-多線程連環13問!
報告老闆,微服務高可用神器已祭出,您花巨資營銷的高流量來了沒?
我成功攻擊了Tomcat服務器,大佬們的反應亮了

公眾號:碼大叔
資深程序員、架構師技術社區。
微服務 | 大數據 | 架構設計 | 技術管理。