Spring 聲明式事務應該怎么學?
本文轉載自微信公眾號「Java課代表」,作者Java課代表。轉載本文請聯系Java課代表公眾號。
1、引言
Spring 的聲明式事務極大地方便了日常的事務相關代碼編寫,它的設計如此巧妙,以至于在使用中幾乎感覺不到它的存在,只需要優雅地加一個 @Transactional 注解,一切就都順理成章地完成了!
毫不夸張地講,Spring 的聲明式事務實在是太好用了,以至于大多數人都忘記了編程式事務應該怎么寫。
不過,越是你認為理所應當的事情,如果出了問題,就越難排查。不知道你和身邊的小伙伴有沒有遇到過 @Transactional 失效的場景,這不但是日常開發中常踩的坑,也是面試中的高頻問題。
其實這些失效場景不用死記硬背,如果搞明白了它的工作原理,再結合源碼,需要用到的時候 Debug 一下就能自己分析出來。畢竟,源碼才是最好的說明書。
還是那句話,授人以魚不如授人以漁,課代表就算總結 100 種失效場景,也不一定能覆蓋到你可能踩到的坑。所以本文中,課代表將結合幾個常見失效情況,從源碼層面解釋其失效原因。認真讀完本文,相信你會對聲明式事務有更深刻的認識。
文中所有代碼已上傳至課代表的 github,為了方便快速部署并運行,示例代碼采用了內存數據庫H2,不需要額外部署數據庫環境。
2、回顧手寫事務
數據庫層面的事務,有 ACID 四個特性,他們共同保證了數據庫中數據的準確性。事務的原理并不是本文的重點,我們只需要知道樣例中用的 H2 數據庫完全實現了對事務的支持(read committed)。
編寫 Java 代碼時,我們使用 JDBC 接口與數據庫交互,完成事務的相關指令,偽代碼如下:
- //獲取用于和數據庫交互的連接
- Connection conn = DriverManager.getConnection();
- try {
- // 關閉自動提交:
- conn.setAutoCommit(false);
- // 執行多條SQL語句:
- insert();
- update();
- delete();
- // 提交事務:
- conn.commit();
- } catch (SQLException e) {
- // 如果出現異常,回滾事務:
- conn.rollback();
- } finally {
- //釋放資源
- conn.close();
- }
這是典型的編程式事務代碼流程:開始前先關閉自動提交,因為默認情況下,自動提交是開啟的,每條語句都會開啟新事務,執行完畢后自動提交。
關閉事務的自動提交,是為了讓多個 SQL 語句在同一個事務中。代碼正常運行,就提交事務,出現異常,就整體回滾,以此保證多條 SQL 語句的整體性。
除了事務提交,數據庫還支持保存點的概念,在一個物理事務中,可以設置多個保存點,方便回滾到指定保存點(其類似玩單機游戲時的存檔,你可以在角色掛掉后隨時回到上次的存檔)設置和回滾到保存點的代碼如下:
- //設置保存點
- Savepoint savepoint = connection.setSavepoint();
- //回滾到指定的保存點
- connection.rollback(savepoint);
- //回滾到保存點后按需提交/回滾前面的事務
- conn.commit();//conn.rollback();
Spring 聲明式事務所做的工作,就是圍繞著 提交/回滾 事務,設置/回滾到保存點 這兩對命令進行的。為了讓我們盡可能地少寫代碼,Spring 定義了幾種傳播屬性將事務做了進一步的抽象。注意哦,Spring 的事務傳播(Propagation) 只是 Spring 定義的一層抽象而已,和數據庫沒啥關系,不要和數據庫的事務隔離級別混淆。
3、Spring 的事務傳播(Transaction Propagation)
觀察傳統事務代碼:
- conn.setAutoCommit(false);
- // 執行多條SQL語句:
- insert();
- update();
- delete();
- // 提交事務:
- conn.commit();
這段代碼表達的是三個 SQL 語句在同一個事務里。
他們可能是同一個類中的不同方法,也可能是不同類中的不同方法。如何來表達諸如事務方法加入別的事務、新建自己的事務、嵌套事務等等概念呢?這就要靠 Spring 的事務傳播機制了。
事務傳播(Transaction Propagation)就是字面意思:事務的傳播/傳遞 方式。
在 Spring 源碼的TransactionDefinition接口中,定義了 7 種傳播屬性,官網對其中的 3 個做了說明,我們只要搞懂了這 3 個,剩下的 4 個就是舉一反三的事了。
1)PROPAGATION_REQUIRED
字面意思:傳播-必須
PROPAGATION_REQUIRED是其默認傳播屬性,強制開啟事務,如果之前的方法已經開啟了事務,則加入前一個事務,二者在物理上屬于同一個事務。
一圖勝千言,下圖表示它倆物理上是在同一個事務內:
上圖翻譯成偽代碼是這樣的:
- try {
- conn.setAutoCommit(false);
- transactionalMethod1();
- transactionalMethod2();
- conn.commit();
- } catch (SQLException e) {
- conn.rollback();
- } finally {
- conn.close();
- }
既然在同一個物理事務中,那如果transactionalMethod2()發生了異常,導致需要回滾,那么請問transactionalMethod1()是否也要回滾呢?
得益于上面的圖解和偽代碼,我們可以很容易地得出答案,transactionalMethod1()肯定回滾了。
這里拋一個問題:
事務方法里面的異常被 try catch 吃了,事務還能回滾嗎?
先別著急出結論, 看下面兩段代碼示例。
示例一:不會回滾的情況(事務失效)
觀察下面的代碼,methodThrowsException()什么也沒干,就拋了個異常,調用方將其拋出的異常try catch 住了,該場景下是不會觸發回滾的
- @Transactional(rollbackFor = Exception.class)
- public void tryCatchRollBackFail(String name) {
- jdbcTemplate.execute("INSERT INTO USER (NAME) VALUES ('" + name + "')");
- try {
- methodThrowsException();
- } catch (RollBackException e) {
- //do nothing
- }
- }
- public void methodThrowsException() throws RollBackException {
- throw new RollBackException(ROLL_BACK_MESSAGE);
- }
示例二:會回滾的情況(事務生效)
再看這個例子,同樣是 try catch 了異常,結果卻截然相反
- @Transactional(rollbackFor = Throwable.class)
- public void tryCatchRollBackSuccess(String name, String anotherName) {
- jdbcTemplate.execute("INSERT INTO USER (NAME) VALUES ('" + name + "')");
- try {
- // 帶事務,拋異常回滾
- userService.insertWithTxThrowException(anotherName);
- } catch (RollBackException e) {
- // do nothing
- }
- }
- @Transactional(rollbackFor = Throwable.class)
- public void insertWithTxThrowException(String name) throws RollBackException {
- jdbcTemplate.execute("INSERT INTO USER (NAME) VALUES ('" + name + "')");
- throw new RollBackException(ROLL_BACK_MESSAGE);
- }
本例中,兩個方法的事務都沒有設置propagation屬性,默認都是PROPAGATION_REQUIRED。即前者開啟事務,后者加入前面開啟的事務,二者同屬于一個物理事務。insertWithTxThrowException()方法拋出異常,將事務標記為回滾。既然大家是在一條船上,那么后者打翻了船,前者肯定也不能幸免。
所以tryCatchRollBackSuccess()所執行的SQL也必將回滾,執行此用例可以查看結果
訪問 http://localhost:8080/h2-console/ ,連接信息如下:
點擊Connect進入控制臺即可查看表中數據:
USER 表確實沒有插入數據,證明了我們的結論,并且可以看到日志報錯:
Transaction rolled back because it has been marked as rollback-only事務已經回滾,因為它被標記為必須回滾。
也就是后面方法觸發的事務回滾,讓前面方法的插入也回滾了。
看到這里,你應該能把默認的傳播類型PROPAGATION_REQUIRED理解透徹了,本例中是因兩個方法在同一個物理事務下,相互影響從而回滾。
你可能會問,那我如果想讓前后兩個開啟了事務的方法互不影響該怎么辦呢?
這就要用到下面要說的傳播類型了。
2)、PROPAGATION_REQUIRES_NEW
字面意思:傳播- 必須-新的
PROPAGATION_REQUIRES_NEW與PROPAGATION_REQUIRED不同的是,其總是開啟獨立的事務,不會參與到已存在的事務中,這就保證了兩個事務的狀態相互獨立,互不影響,不會因為一方的回滾而干擾到另一方。
一圖勝千言,下圖表示他倆物理上不在同一個事務內:
上圖翻譯成偽代碼是這樣的:
- //Transaction1
- try {
- conn.setAutoCommit(false);
- transactionalMethod1();
- conn.commit();
- } catch (SQLException e) {
- conn.rollback();
- } finally {
- conn.close();
- }
- //Transaction2
- try {
- conn.setAutoCommit(false);
- transactionalMethod2();
- conn.commit();
- } catch (SQLException e) {
- conn.rollback();
- } finally {
- conn.close();
- }
TransactionalMethod1 開啟新事務,當他調用同樣需要事務的TransactionalMethod2時,由于后者的傳播屬性設置了PROPAGATION_REQUIRES_NEW,所以掛起前面的事務(至于如何掛起,后面我們會從源碼中窺見),并開啟一個物理上獨立于前者的新事務,這樣二者的事務回滾就不會相互干擾了。
還是前面的例子,只需要把insertWithTxThrowException()方法的事務傳播屬性設置為Propagation.REQUIRES_NEW就可以互不影響了:
- @Transactional(rollbackFor = Throwable.class)
- public void tryCatchRollBackSuccess(String name, String anotherName) {
- jdbcTemplate.execute("INSERT INTO USER (NAME) VALUES ('" + name + "')");
- try {
- // 帶事務,拋異常回滾
- userService.insertWithTxThrowException(anotherName);
- } catch (RollBackException e) {
- // do nothing
- }
- }
- @Transactional(rollbackFor = Throwable.class, propagation = Propagation.REQUIRES_NEW)
- public void insertWithTxThrowException(String name) throws RollBackException {
- jdbcTemplate.execute("INSERT INTO USER (NAME) VALUES ('" + name + "')");
- throw new RollBackException(ROLL_BACK_MESSAGE);
- }
PROPAGATION_REQUIRED和Propagation.REQUIRES_NEW已經足以應對大部分應用場景了,這也是開發中常用的事務傳播類型。前者要求基于同一個物理事務,要回滾一起回滾,后者是大家使用獨立事務互不干涉。還有一個場景就是:外部方法和內部方法共享一個事務,但是內部事務的回滾不影響外部事務,外部事務的回滾可以影響內部事務。這就是嵌套這種傳播類型的使用場景。
3)、PROPAGATION_NESTED
字面意思:傳播-嵌套
PROPAGATION_NESTED可以在一個已存在的物理事務上設置多個供回滾使用的保存點。這種部分回滾可以讓內部事務在其自己的作用域內回滾,與此同時,外部事務可以在某些操作回滾后繼續執行。其底層實現就是數據庫的savepoint。
這種傳播機制比前面兩種都要靈活,看下面的代碼:
- @Transactional(rollbackFor = Throwable.class)
- public void invokeNestedTx(String name,String otherName) {
- jdbcTemplate.execute("INSERT INTO USER (NAME) VALUES ('" + name + "')");
- try {
- userService.insertWithTxNested(otherName);
- } catch (RollBackException e) {
- // do nothing
- }
- // 如果這里拋出異常,將導致兩個方法都回滾
- // throw new RollBackException(ROLL_BACK_MESSAGE);
- }
- @Transactional(rollbackFor = Throwable.class,propagation = Propagation.NESTED)
- public void insertWithTxNested(String name) throws RollBackException {
- jdbcTemplate.execute("INSERT INTO USER (NAME) VALUES ('" + name + "')");
- throw new RollBackException(ROLL_BACK_MESSAGE);
- }
外部事務方法invokeNestedTx()開啟事務,內部事務方法insertWithTxNested標記為嵌套事務,內部事務的回滾通過保存點完成,不會影響外部事務。而外部方法的回滾,則會連帶內部方法一塊回滾。
小結:本小節介紹了 3 種常見的Spring 聲明式事務傳播屬性,結合樣例代碼,相信你也對其有所了解了,接下來我們從源碼層面看一看,Spring 是如何幫我們簡化事務樣板代碼,解放生產力的。
4、源碼窺探
在閱讀源碼前,先分析一個問題:我要給一個方法添加事務,需要做哪些工作呢?
就算我們自己手寫,也至少得需要這么四步:
- 開啟事務
- 執行方法
- 遇到異常就回滾事務
- 正常執行后提交事務
這不就是典型的AOP嘛~
沒錯,Spring 就是通過 AOP,將我們的事務方法增強,從而完成了事務的相關操作。下面給出幾個關鍵類及其關鍵方法的源碼走讀。
既然是 AOP 那肯定要給事務寫一個切面來做這個事,這個類就是 TransactionAspectSupport,從命名可以看出,這就是“事務切面支持類”,他的主要工作就是實現事務的執行流程,其主要實現方法為invokeWithinTransaction:
- protected Object invokeWithinTransaction(Method method, @Nullable Class<?> targetClass,
- final InvocationCallback invocation) throws Throwable {
- // 省略代碼...
- // Standard transaction demarcation with getTransaction and commit/rollback calls.
- // 1、開啟事務
- TransactionInfo txInfo = createTransactionIfNecessary(ptm, txAttr, joinpointIdentification);
- try {
- // This is an around advice: Invoke the next interceptor in the chain.
- // This will normally result in a target object being invoked.
- //2、執行方法
- retVal = invocation.proceedWithInvocation();
- }
- catch (Throwable ex) {
- // target invocation exception
- // 3、捕獲異常時的處理
- completeTransactionAfterThrowing(txInfo, ex);
- throw ex;
- }
- finally {
- cleanupTransactionInfo(txInfo);
- }
- if (retVal != null && vavrPresent && VavrDelegate.isVavrTry(retVal)) {
- // Set rollback-only in case of Vavr failure matching our rollback rules...
- TransactionStatus status = txInfo.getTransactionStatus();
- if (status != null && txAttr != null) {
- retVal = VavrDelegate.evaluateTryFailure(retVal, txAttr, status);
- }
- }
- //4、執行成功,提交事務
- commitTransactionAfterReturning(txInfo);
- return retVal;
- // 省略代碼...
結合課代表增加的這四步注釋,相信你很容易就能看明白。
搞懂了事務的主要流程,它的傳播機制又是怎么實現的呢?這就要看AbstractPlatformTransactionManager這個類了,從命名就能看出, 它負責事務管理,其中的handleExistingTransaction方法實現了事務傳播邏輯,這里挑PROPAGATION_REQUIRES_NEW的實現跟一下代碼:
- protected Object doSuspend(Object transaction) {
- DataSourceTransactionObject txObject = (DataSourceTransactionObject) transaction;
- txObject.setConnectionHolder(null);
- return TransactionSynchronizationManager.unbindResource(obtainDataSource());
- }
前文我們知道PROPAGATION_REQUIRES_NEW會將前一個事務掛起,并開啟獨立的新事務,而數據庫是不支持事務的掛起的,Spring 是如何實現這一特性的呢?
通過源碼可以看到,這里調用了返回值為SuspendedResourcesHolder的suspend(transaction)方法,它的實際邏輯由其內部的doSuspend(transaction)抽象方法實現。這里我們使用的是JDBC連接數據庫,自然要選擇DataSourceTransactionManager這個子類去查看其實現,代碼如下:
- protected Object doSuspend(Object transaction) {
- DataSourceTransactionObject txObject = (DataSourceTransactionObject) transaction;
- txObject.setConnectionHolder(null);
- return TransactionSynchronizationManager.unbindResource(obtainDataSource());
- }
這里是把已有事務的connection解除,并返回被掛起的資源。在接下來開啟事務時,會將該掛起資源一并傳入,這樣當內層事務執行完成后,可以繼續執行外層被掛起的事務。
那么,什么時候來繼續執行被掛起的事務呢?
事務的流程,雖然是由TransactionAspectSupport實現的,但是真正的提交,回滾,是由AbstractPlatformTransactionManager來完成,在其processCommit(DefaultTransactionStatus status)方法最后的finally塊中,執行了cleanupAfterCompletion(status):
- private void cleanupAfterCompletion(DefaultTransactionStatus status) {
- status.setCompleted();
- if (status.isNewSynchronization()) {
- TransactionSynchronizationManager.clear();
- }
- if (status.isNewTransaction()) {
- doCleanupAfterCompletion(status.getTransaction());
- }
- // 有掛起事務則獲取掛起的資源,繼續執行
- if (status.getSuspendedResources() != null) {
- if (status.isDebug()) {
- logger.debug("Resuming suspended transaction after completion of inner transaction");
- }
- Object transaction = (status.hasTransaction() ? status.getTransaction() : null);
- resume(transaction, (SuspendedResourcesHolder) status.getSuspendedResources());
- }
- }
這里判斷有掛起的資源將會恢復執行,至此完成掛起和恢復事務的邏輯。
對于其他事務傳播屬性的實現,感興趣的同學使用課代表的樣例工程,打斷點自己去跟一下源碼。限于篇幅,這里只給出了大概處理流程,源碼里有大量細節,需要同學們自己去體驗,有了上文介紹的主邏輯框架基礎,跟蹤源碼查看其他實現應該不怎么費勁了。
5、常見失效場景
很多人(包括課代表本人)一開始使用聲明式事務,都會覺得這玩意兒真坑,使用起來那么多條條框框,一不小心就不生效了。為什么會有這種感覺呢?
爬了多次坑之后,課代表總結了兩條經驗:
- 沒看官方文檔
- 不會讀源碼
下面簡單列舉幾個失效場景:
1)非 public 方法不生效
官網有說明:
Method visibility and @Transactional
When you use transactional proxies with Spring’s standard configuration, you should apply the @Transactional annotation only to methods with public visibility.
2)Spring 不支持 redis 集群中的事務
redis事務開啟命令是multi,但是 Spring Data Redis 不支持 redis 集群中的 multi 命令,如果使用了聲明式事務,將會報錯:MULTI is currently not supported in cluster mode.
3)多數據源情況下需要為每個數據源配置TransactionManager,并指定transactionManager參數
第四部分源碼窺探中已經看到實際執行事務操作的是AbstractPlatformTransactionManager,其為TransactionManager的實現類,每個事務的connection連接都受其管理,如果沒有配置,無法完成事務操作。單數據源的情況下正常運行,是因為 SpringBoot 的DataSourceTransactionManagerAutoConfiguration為我們自動配置了。
4)rollbackFor 設置錯誤
默認情況下只回滾非受檢異常(也就是,java.lang.RuntimeException的子類)和java.lang.Error,如果明確知道拋異常就要回滾,建議設置為@Transactional(rollbackFor = Throwable.class)
5)AOP不生效問題
其他諸如 MyISAM 不支持,es 不支持等等就不一一列舉了。
如果感興趣,以上這些在源碼中都能找到解答。
6、結束語
關于 Spring 的聲明式事務,如果想用好,還真得多 Debug 幾遍源碼,由于 Spring 的源碼細節過于豐富,實在不適合全部貼到文章里,建議自己去跟一下源碼。熟悉之后就不怕再遇到失效情況了。
以下資料證明我不是在胡扯
1、文中測試用例代碼:https://github.com/zhengxl5566/springboot-demo/tree/master/transactional
2、Spring 官網事務文檔:https://docs.spring.io/spring-framework/docs/current/reference/html/data-access.html#tx-propagation
3、Oracle官網JDBC文檔:https://docs.oracle.com/javase/tutorial/jdbc/basics/index.html
4、Spring Data Redis 源碼:https://github.com/spring-projects/spring-data-redis