【Seata原始碼領讀】揭秘 @GlobalTransactional 背後 RM 的黑盒操作之一階段

架構師修行手冊發表於2023-05-19

來源:架構染色

一、前奏

Seata 從設計層面將事務參與者的角色分為 TC(事務協調器)、RM(資源管理器)、TM(事務管理器) ,傳統的 XA 方案中, RM 是在資料庫層,即依賴了 DB 的 XA 驅動能力,也會有典型的資料鎖定和連線鎖定的問題,為了規避 XA 帶來的制約,Seata 將 RM 從 DB 層遷移出來,以中介軟體的形式放在應用層,完全剝離了分散式事務方案對資料庫在協議支援上的要求。在 Seata 的 AT 模式中, RM 的能力是在資料來源做了一層代理,Seata 在這層代理中干預業務 SQL 執行過程,加入分散式事務所需的邏輯,透過這種方式,Seata 真正做到了對業務程式碼無侵入,只需要透過簡單的配置和宣告,業務方就可享受 Seata 所帶來的分散式事務能力;而且跟 XA 模式相比,當本地事務執行完可以立即釋放本地事務鎖定的資源,效能更好。

二、Seata AT 模式的頂層設計

Seata AT 模式下,一個典型的分散式事務過程如下:

【Seata原始碼領讀】揭秘 @GlobalTransactional 背後 RM 的黑盒操作之一階段
  • TM 向 TC 申請開啟一個全域性事務,全域性事務建立成功並生成一個全域性唯一的 XID。
  • XID 在微服務呼叫鏈路的上下文中傳播,供 RM 使用。
  • RM 向 TC 註冊分支事務,將其納入 XID 對應全域性事務的管轄,執行後上報分支事務狀態。
  • TM 向 TC 發起針對 XID 的全域性提交或回滾決議。
  • TC 驅動 RM 完成 XID 下管轄的全部分支事務的提交或回滾操作。

前文《【Seata 原始碼領讀】揭秘 @GlobalTransactional 背後 TM 的黑盒操作》中描述了 TM 的能力,本篇繼續介紹 RM,RM(Resource Manager)叫做資源管理器,控制分支事務,負責 1 階段的分支註冊、狀態彙報,並接收事務協調器 TC 的指令,在 2 階段執行分支(本地)事務的提交和回滾

在傳統的 XA 方案中 RM 是放在資料庫層的,它依賴了資料庫的 XA 驅動程式,如下圖所示【Seata原始碼領讀】揭秘 @GlobalTransactional 背後 RM 的黑盒操作之一階段

理論上分散式事務能力下沉,由 DB 提供是好事,但此種 XA 模式有兩個典型的問題:

  1. 一是資料鎖定問題,XA 事務過程中,資料是被鎖定的。XA 的資料鎖定是資料庫的內部機制維護的,所以依賴 DBA 干預資料庫去解除資料鎖定。

  2. 另一個是連線鎖定問題,XA 事務過程中,連線也是被鎖定的。至少在兩階段提交的 prepare 之前,連線是不能釋放的(因為連線斷開,這個連線上的 XA 分支就會回滾,整個事務也會被迫回滾)。較之於資料的鎖定(資料的鎖定對於事務的隔離性是必要的機制),連線的鎖定帶給整個業務系統的直接影響,限制了併發度。

正式為了規避 XA 方案所帶來的制約,Seata 將 RM 從 DB 層遷移出去,以中介軟體的形式放在應用層,完全剝離了分散式事務方案對資料庫在協議支援上的要求。

【Seata原始碼領讀】揭秘 @GlobalTransactional 背後 RM 的黑盒操作之一階段

Seata AT 模式下 RM 的能力概括來說是在資料來源做了一層代理,當程式執行到 DAO 層,透過 JdbcTemplate 或 Mybatis 操作 DB 時所使用的資料來源實際上用的是 Seata 提供的資料來源代理 DataSourceProxy,Seata 在這層代理中干預業務 SQL 執行過程,加入分散式事務所需的邏輯,主要是解析 SQL,把業務資料在更新前後的資料映象組織成回滾日誌,並將 undoLog 日誌插入 undo_log 表中,保證每條更新資料的業務 sql 都有對應的回滾日誌存在。透過這種方式,Seata 真正做到了對業務程式碼無侵入,只需要透過簡單的配置,業務方就可以輕鬆享受 Seata 所帶來的功能。

【Seata原始碼領讀】揭秘 @GlobalTransactional 背後 RM 的黑盒操作之一階段

另外這樣做還有效能好處,當本地事務執行完時立即釋放了本地事務鎖定的資源,然後向 TC 上報分支狀態。當 TM 決議全域性事務提交時,就不需要同步呼叫 RM 做什麼處理,而是給 TC 傳送提交指令,委託 TC 以非同步方式排程各個 RM 分支事務刪除對應的 undoLog 日誌即可,這個步驟完成的非常快速;但當 TM 決議全域性回滾時(發生回滾的機率還是很小的),委託 TC 同步向所有 相關 RM 傳送回滾請求,RM 透過 XID 找到對應的 undoLog 回滾日誌,然後構建回滾 sql 並執行,以完成回滾操作。

三、Seata AT 模式 RM 的底層實現

3.1 關鍵類能力簡述

1) DataSourceProxy

  1. 構建並向 TC 註冊 Resource 資訊
  2. 初始化業務表的後設資料資訊,用於為前後映象的構建和二階段回滾提供基礎能力。

2)ConnectionProxy

提供增強版的 commit,增加的邏輯分兩類:

  1. 若上下文中繫結當前全域性事務的 xid,處理分支事務提交
  • 向 TC 註冊分支事務、使用本地事務提交業務 SQL 和 undoLog、向 TC 上報本地 commit 結果;
  1. 若上下文中繫結是否需要檢測全域性鎖,處理帶@GlobalLock 的本地事務提交
  • 檢測全域性鎖不存在則提交本地事務

若業務層還顯式的開啟了 JDBC 的事務(AutoCommit 被設定為 false),則提交中還伴有鎖衝突後的重試。

3) StatmentProxy

  1. 解析 SQL,根據不同的 SQL 型別委託不同的執行器,構建前後映象生成 undoLog 放置在上下文中。
  2. 若業務層未顯式的開啟 JDBC 的事務,則開啟重試機制,並在執行完第一步之後,呼叫 ConnectionProxy 的增強版提交;
  3. 若業務層顯式的開啟 JDBC 的事務,則沒有第 2 步中的自動提交

3.2 鳥瞰分支事務的 1 階段處理

Seata AT 模式下,正如下圖的原始碼檢索結果所示,分支事務的執行是在 StatementProxyPreparedStatementProxyexecuteexecuteQueryexecuteUpdate 等方法中,而這些方法最終都會執行到 ExecuteTemplate#execute 方法

【Seata原始碼領讀】揭秘 @GlobalTransactional 背後 RM 的黑盒操作之一階段

所以StatementProxyPreparedStatementProxy 中是委託ExecuteTemplate完成分支事務的一階段流程

【Seata原始碼領讀】揭秘 @GlobalTransactional 背後 RM 的黑盒操作之一階段

下邊使用虛擬碼,對照官方原理圖,從宏觀視角來描述以下分支事務的一階段邏輯:獲取連結,構建Statement,之後執行 SQL 解析、根據 SQL 型別構建執行器,由執行器在業務 SQL 執行前後查詢資料快照並組織成 UndoLog;在提交環節有向 TC 註冊分支事務、UndoLog 的刷盤隨業務 SQL 在本地事務一併 Commit、向 TC 上報分支事務狀態等;若遇到異常會執行本地回滾,上拋異常讓業務邏輯感知;最後釋放資源。

【Seata原始碼領讀】揭秘 @GlobalTransactional 背後 RM 的黑盒操作之一階段
conProxy = mybatis#getConnection()
pareparedStatement = conProxy.PareparedStatement();
pareparedStatementProxy.execute();
    ExecuteTemplate.execute
        解析SQL構建 xxxExecutor,如 update 對應為 UpdateExecutor
        如果autoCommit為true,設定autoCommit為false
        LockRetryPolicy.execute//重試策略
            AbstractDMLBaseExecutor#executeAutoCommitFalse()
                beforImage()//構建前映象
                execute()//執行業務sql
                afterImage()//構建後映象
            connectionProxy.commit // 增強版的提交事務
                try
                    doCommit
                        register()//向TC註冊分支事務,TC會檢測全域性鎖
                        flushUndoLogs//undoLog刷盤
                        try
                            targetConnection.commit();//使用原始con提交本地事務
                        catch
                            report : PhaseOne_Failed //本地commit失敗,向TC上報1階段失敗,丟擲異常
                        report PhaseOne_Done //向TC 上報 本地commit成功

                catch //捕獲到異常就進行回滾
                    doRollback
                        targetConnection.rollback();// 執行本地回滾
                        report : PhaseOne_Failed //跟TC上報本地commit失敗,這裡似乎會重複report


pareparedStatement.close()
con.close()

3.3 詳解分支事務的 1 階段處理

1)基於執行器的設計

如果瞭解過 mybatis 原始碼,會有印象其中關鍵類的命名和執行流程是 xxxTemplate -呼叫-> yyyExecutor;Seata 中的實現很相似,是 ExecuteTemplate -呼叫-> xxxExecutor

  1. ExecuteTemplate 分析上下文,構建正確的 Executor
  2. Executor 的職責
  • 首先判斷若當前上下文與 Seata 無關(當前即不是 AT 模式的分支事務,又不用檢測全域性鎖),直接使用原始的 Statment 執行業務 SQL,避免因引入 Seata 導致非全域性事務中的 SQL 執行效能下降。
  • 解析 SQL 識別 SQL 屬於增刪改查哪種型別,解析結果有快取,因為有些 SQL 解析會比較耗時,可能會導致在應用啟動後剛開始的那段時間裡處理全域性事務中的 SQL 執行效率降低。
  • 對於 INSERT、UPDATE、DELETE、SELECT..FOR UPDATE 等幾類(具體看原始碼)的 sql 使用對應的Executor進行處理,其它 SQL (設計初衷這裡是指普通的 select)直接使用原始的 Statment 執行。
  • 返回執行結果,如有異常則直接拋給上層業務程式碼進行處理。
【Seata原始碼領讀】揭秘 @GlobalTransactional 背後 RM 的黑盒操作之一階段

2)解析 sql,構建 sql 執行器

目前 Seata 1.6.1 版本中 根據 sql 的的型別封裝瞭如INSERTUPDATEDELETESELECT_FOR_UPDATEINSERT_ON_DUPLICATE_UPDATEUPDATE_JOIN 這六大類Executor(執行器)。但從事務處理的能力上有分為 3 大類

  1. PlainExecutor

其中 PlainExecutor 是 原生的 JDBC 介面實現,未做任何處理,提供給全域性事務中的普通的 select 查詢使用

  1. SelectForUpdateExecutor:

Seata 的 AT 模式在本地事務之上預設支援讀未提交的隔離級別,但是透過SelectForUpdateExecutor 執行器,可以支援讀已提交的隔離級別。

前面的文章我們說過用 select...for update 語句來保證隔離級別為讀已提交。SelectForUpdateExecutor 就是用來執行 select...for update 語句的。

先透過 select 檢索記錄,構建出 lockKeys 發給 TC,請 TC 核實這些記錄是否已經被其他事務加鎖了,如果被加鎖了,則根據重試策略不斷重試,如果沒被加鎖,則正常返回查詢的結果。

【Seata原始碼領讀】揭秘 @GlobalTransactional 背後 RM 的黑盒操作之一階段
  1. DML 類的 Executor

DML 增刪改型別的執行器主要在 sql 執行的前後對 sql 語句進行解析,並實現瞭如下兩個抽象介面:

protected abstract TableRecords beforeImage() throws SQLException;

protected abstract TableRecords afterImage(TableRecords beforeImage) throws SQLException;

這兩個介面便是 AT 模式下 RM 的核心能力:構建 beforeImage,執行 sql,之後再構建 afterImage,透過beforeImageafterImage 生成了提供回滾操作的 undoLog 日誌,不同的執行器這兩個介面的實現不同。

型別構建前映象構建後映象
insert
update
delete

其中構建 updatedelete 這兩類前映象的 sql 語句的是select ... for update,其中for update 是一種非常必要的基於本地事務排它機制的隔離保障。

3)執行器的核心方法execute

上下文中設定關鍵的標識資訊:在ConnectionProxy中設定全域性事務的 XID,則標識後續執行分支事務;如果RootContext.requireGlobalLock()true,則標識後續是處理@GlobalLock的全域性鎖檢測+本地事務提交。

public T execute(Object... args) throws Throwable {
    // 從事務上下文中獲取xid
    String xid = RootContext.getXID();
    if (xid != null) {
        // 將xid繫結到連線的 ConnectionContext 中,由此分支事務感知其所歸屬的全域性事務的xid
        statementProxy.getConnectionProxy().bind(xid);
    }
    // 從上下文中獲取是否需要全域性鎖的標記,傳遞給ConnectionProxy
    statementProxy.getConnectionProxy().setGlobalLockRequire(RootContext.requireGlobalLock());
    // 處理sql
    return doExecute(args);
}

DML 類執行器的核心邏輯在 AbstractDMLBaseExecutor#doExecute 中,這裡根據是否有開啟 Spring 事務而處理邏輯不通。executeAutoCommitTrue中會自動提交。而executeAutoCommitFalse中不執行提交,而由 Spring 事務發起commit(呼叫的是ConnectionProxy增強版的commit)

public T doExecute(Object... args) throws Throwable {
    AbstractConnectionProxy connectionProxy = statementProxy.getConnectionProxy();
    if (connectionProxy.getAutoCommit()) {
        // 如果AutoCommit是true,沒有開啟spring事務(即沒有con.setAutoCommit(false)的呼叫)
        return executeAutoCommitTrue(args);
    } else {
        // 如果AutoCommit是false
        // 目前已知的情況是由於顯示開啟了事務,保障多條SQL語句的執行只在最後顯式的commit提交後,才生效,
        // 如宣告式Spring事務@Transactional,其處理過程會由con.setAutoCommit(false);
        // 如果是程式設計式Spring事務,需要顯示呼叫con.setAutoCommit(false);
        return executeAutoCommitFalse(args);
    }
}

4) executeAutoCommitTrue執行業務 sql,構建undoLog並執行增強版的提交。如果有 Spring 事務開啟(AutoCommit設定為false),則不執行這個方法,其中有 3 個關鍵邏輯

  1. 執行此方法時, Seata 框架將 AutoCommit設定為false,在 2.2 中主動 commit
  • 目的是 2.1 和 2.2 兩個步驟中的所有本地 sql 同時提交,簡單理解就是 業務 sql 和 Seata 框架的 undoLog 一起提交。
  • 提交過程可能遇到鎖衝突,在遇到鎖衝突時,會有重試策略,重試邏輯中有 2 個邏輯主體:
    • 2.1. 業務 sql 的執行(構造前後映象)
    • 2.2. 增強版commit(此時,其內部的重試策略無效),下述邏輯根據上下文是三選一
      • 直接提交本地事務
      • 申請到全域性鎖後執行本地提交,這種情況下還需要構造前後映象嘛?
      • 執行分支事務的提交,向 TC 申請行鎖,鎖衝突則進入重試邏輯
      • 不衝突執行註冊分支事務,提交本地事務,向 TC 上報結果
      • 2.2.1 processGlobalTransactionCommit();
      • 2.2.2 processLocalCommitWithGlobalLocks();
      • 2.2.3 targetConnection.commit();
    1. 無論第 2 步成功還是失敗,重置上下文,恢復自動提交

    第 2 步遇衝突則重試的機制在介紹完 2.1 和 2.2 的主體邏輯後,再補充

    protected T executeAutoCommitTrue(Object[] args) throws Throwable {
        ConnectionProxy connectionProxy = statementProxy.getConnectionProxy();
        try {
            // AutoCommit設定的false,
            // 目的是 2.1 和 2.2 兩個步驟中的所有本地sql同時提交,簡單理解就是 業務sql 和 Seata 框架的undoLog一起提交。
            connectionProxy.changeAutoCommit();
            // 2. 提交過程可能遇到鎖衝突,在遇到鎖衝突時,會有重試策略,重試邏輯中有2個邏輯主體:
            return new LockRetryPolicy(connectionProxy).execute(() -> {
                // 2.1 業務sql的執行(構造前後映象)
                T result = executeAutoCommitFalse(args);
                // 2.2 commit(此時,其內部的重試策略無效),下述邏輯根據上下文是三選一
                //            2.2.1 processGlobalTransactionCommit();
                //                執行分支事務的提交,向TC申請行鎖,鎖衝突則進入重試邏輯
                //                不衝突執行註冊分支事務,提交本地事務,向TC上報結果
                //            2.2.2 processLocalCommitWithGlobalLocks();
                //                申請到全域性鎖後執行本地提交,這種情況下還需要構造前後映象嘛?
                //            2.2.3 targetConnection.commit();
                //                直接提交本地事務
                connectionProxy.commit();
                return result;
            });
        } catch (Exception e) {
            // when exception occur in finally,this exception will lost, so just print it here
            LOGGER.error("execute executeAutoCommitTrue error:{}", e.getMessage(), e);
            // isLockRetryPolicyBranchRollbackOnConflict() 預設是true,衝突時會重試,則不在這裡回滾
            if (!LockRetryPolicy.isLockRetryPolicyBranchRollbackOnConflict()) {
                connectionProxy.getTargetConnection().rollback();
            }
            throw e;
        } finally {
            // 重置上下文
            connectionProxy.getContext().reset();
            // 設定為自動提交
            connectionProxy.setAutoCommit(true);
        }
    }

    5)executeAutoCommitFalse 執行業務 sql,生成前後映象融合成 undoLog,注意此時不提交。

    protected T executeAutoCommitFalse(Object[] args) throws Exception {
        // 構造beforeImage
        TableRecords beforeImage = beforeImage();
        // 使用原始Statement 執行sql
        T result = statementCallback.execute(statementProxy.getTargetStatement(), args);
        // 構建afterImage
        TableRecords afterImage = afterImage(beforeImage);
        // 整合 beforeImage 和 afterImage 構建undoLog
        prepareUndoLog(beforeImage, afterImage);
        // 返回業務sql的執行結果,並未commit
        return result;
    }

    5.1)整合 beforeImageafterImage 構建 undoLog

    1. 根據前後映象構建 lockKeysundoLog,暫存到connectionProxy的上下文中,在下文commit`方法中才刷盤
    2. 鎖 key 的構建有其規則,形如 t_user:1_a,2_b 。其中 t_user 是表名,第 1 條記錄的主鍵是 1a,第 2 條記錄的逐漸是 2b;即一條記錄的多個主鍵值之間用*串聯 ;記錄和記錄之間的 key 資訊用,串聯;表名和主鍵部分用:串聯
    3. 如果是 DELETE 語句,則使用前映象構建 lockKeys
    protected void prepareUndoLog(TableRecords beforeImage, TableRecords afterImage) throws SQLException {
        if (beforeImage.getRows().isEmpty() && afterImage.getRows().isEmpty()) {
            return;
        }
        if (SQLType.UPDATE == sqlRecognizer.getSQLType()) {
            if (beforeImage.getRows().size() != afterImage.getRows().size()) {
                throw new ShouldNeverHappenException("Before image size is not equaled to after image size, probably because you updated the primary keys.");
            }
        }
        ConnectionProxy connectionProxy = statementProxy.getConnectionProxy();

        // 如果是DELETE 語句,則使用前映象構建鎖key,
        TableRecords lockKeyRecords = sqlRecognizer.getSQLType() == SQLType.DELETE ? beforeImage : afterImage;
        // 一條記錄的多個主鍵值之間用_串聯;記錄之間的key資訊用,串聯;
        // 形如 t_user:1_a,2_b,第1條記錄的主鍵是1和a,第2條記錄的逐漸是2和b
        String lockKeys = buildLockKey(lockKeyRecords);
        if (null != lockKeys) {
            // lockKeys 暫存到connectionProxy的上下文中,在commit環節,向註冊分支事務環節,這些鎖key被用於檢測全域性行鎖儲存和衝突檢測
            connectionProxy.appendLockKey(lockKeys);
            // 整合 beforeImage 和 afterImage 構建undoLog
            SQLUndoLog sqlUndoLog = buildUndoItem(beforeImage, afterImage);
            // undoLog暫存到connectionProxy的上下文中,在commit環節,才執行undoLog刷盤,伴隨業務SQL在本地事務一起提交
            connectionProxy.appendUndoLog(sqlUndoLog);
        }
    }

    6)connectionProxy.commit()增強版的提交

    6.1)connectionProxy.commit()中的重試

    此處的重試很容易讓人迷糊,因為透過對上文的原始碼梳理可知,重試邏輯在資料來源的代理中有兩處。需清楚這兩個重試是互補的,即同一流程中只會有其中一個重試邏輯生效,這兩個重試之間微妙的關係如下:

    • 如果業務 SQL 的執行上下文中,沒有 Spring 的事務,那麼程式會執行AbstractDMLBaseExecutor.executeAutoCommitTrue ,則其方法中的重試邏輯生效,則此處connectionProxy.commit()的重試邏輯不啟用

    • 如果業務 SQL 的執行上下文中,有 Spring 的事務,那麼程式會執行AbstractDMLBaseExecutor#executeAutoCommitFalse ,而不會被執行 AbstractDMLBaseExecutor.executeAutoCommitTrue ,則此處connectionProxy.commit()的重試邏輯生效

    • 另外connectionProxy.commit()AbstractDMLBaseExecutor.executeAutoCommitTrue的重試主體對比的話,此處的connectionProxy.commit()主體只有 doCommit,沒有業務 SQL 的執行以及前後映象的構建,這是重點。為何如此設計筆者理解的尚不夠透徹,望讀者老師能加 V 加群給與解惑。

    connectionProxy.commit()原始碼如下:

    public void commit() throws SQLException {
        try {
            // 這裡的重試 只有 doCommit,沒有業務SQL的執行以及前後映象的構建
            // 重試策略在資料來源的代理中從程式碼上看是有兩處,這兩個重試是互補的,也即同一流程中只會有其中一個重試生效。
            // 首先如果業務SQL的執行上下文中,沒有Spring的事務,那麼AbstractDMLBaseExecutor.executeAutoCommitTrue 中的重試策略生效,則此處的重試策略不啟用
            // 首先如果業務SQL的執行上下文中,有Spring的事務,那麼此處的重試策略生效,而 AbstractDMLBaseExecutor.executeAutoCommitTrue 不會被執行
            lockRetryPolicy.execute(() -> {
                doCommit();
                return null;
            });
        } catch (SQLException e) {
            // 沒有自動提交,也沒有被Seata調整為非自動提交(沒有執行AbstractDMLBaseExecutor.executeAutoCommitTrue)
            // 那麼遇到Seata 增強邏輯中丟擲的 SQLException 異常時,在此處執行回滾。並且丟擲異常
            // 否則,是由上層發起回滾。
            if (targetConnection != null && !getAutoCommit() && !getContext().isAutoCommitChanged()) {
                rollback();
            }
            throw e;
        } catch (Exception e) {
            throw new SQLException(e);
        }
    }

    LockRetryController#sleep方法中控制 重試次數(--lockRetryTimes) 和 重試間隔(Thread.sleep(lockRetryInterval)),超過次數上拋異常,退出迴圈。

    public void sleep(Exception e) throws LockWaitTimeoutException {
        // prioritize the rollback of other transactions
        // 重試次數控制
        if (--lockRetryTimes < 0 || (e instanceof LockConflictException
            && ((LockConflictException)e).getCode() == TransactionExceptionCode.LockKeyConflictFailFast)) {
            throw new LockWaitTimeoutException("Global lock wait timeout", e);
        }

        try {
            // 透過sleep控制重試間隔
            Thread.sleep(lockRetryInterval);
        } catch (InterruptedException ignore) {
        }
    }

    是否重試是有開關的,在啟動時讀取配置,從 1.6.1 的程式碼來看,未支援執行期變更,預設值是 true

    // 在衝突時是否重試的開關
    client.rm.lock.retry-policy-branch-rollback-on-conflict=true

    重試間隔和次數可在透過配置中心做執行時變更,預設值如下:

    // 重試間隔 lockRetryInterval 預設值 10 毫秒
    client.rm.lock.retry-interval=10
    // 重試次數 lockRetryTimes 預設值 30次
    client.rm.lock.retry-times=30

    6.2)doCommit()中的 3 種選擇

    增強版的提交程式碼中有下述三種提交邏輯,根據上下文只選其一

    1. processGlobalTransactionCommit();
    • 執行分支事務的提交,向 TC 申請行鎖,鎖衝突則向上反饋後進入上層的重試邏輯
    • 不衝突執行註冊分支事務,提交本地事務,向 TC 上報結果
    1. processLocalCommitWithGlobalLocks();
    • 申請到全域性鎖後執行本地提交,這種情況下還需要構造前後映象嘛?
    1. targetConnection.commit();
    • 直接提交本地事務

    ConnectionProxy#doCommit原始碼如下:

    private void doCommit() throws SQLException {
        // xid不為空
        // 如果 BaseTransactionalExecutor.execute 中 透過 statementProxy.getConnectionProxy().bind(xid) 在context繫結了xid
        // 其內部是 context.bind(xid); 那麼此處context.inGlobalTransaction() = true
        // 則執行增強版的分支事務提交
        if (context.inGlobalTransaction()) {
            processGlobalTransactionCommit();
        }
        // 如果開發者使用@GlobalLock,則 BaseTransactionalExecutor.execute 中
        // 透過statementProxy.getConnectionProxy().setGlobalLockRequire(RootContext.requireGlobalLock())
        // 在context繫結了全域性鎖標識,那麼此處context.isGlobalLockRequire() = true
        // 則執行增強版的檢測不到全域性鎖才做本地事務提交
        else if (context.isGlobalLockRequire()) {
            //申請到全域性鎖後執行本地提交
            processLocalCommitWithGlobalLocks();
        } else {
            // 既不是分支事務,又不是@Globallock,那使用原生的本地事務提交
            targetConnection.commit();
        }
    }

    6.4)processGlobalTransactionCommit處理分支事務的提交

    前文BaseTransactionalExecutor#execute中如果識別出有全域性事務的 xid,則給ConnectionProxyConnectionContext上繫結 xid,表明資料來源代理層是要做分支事務的處理。

    【Seata原始碼領讀】揭秘 @GlobalTransactional 背後 RM 的黑盒操作之一階段

    所以如果那麼此處context.inGlobalTransaction()就等於 true,則透過processGlobalTransactionCommit處理分支事務的提交,在這個方法中是分支事務處理核心中的核心:

    1. 註冊分支事務,申請全域性行鎖,如果鎖衝突則丟擲異常,重試機制識別到衝突的異常後做重試處理
    2. undoLog 刷盤
    3. 執行本地事務提交,會將本地業務 sql 和 undoLog 一起提交
    4. 將本地事務提交的結果(1 階段的處理結果)上報給 TC,TC 若在二階段回滾,而分支事務上報的是 1 階段失敗了,則無需通知此分支事務做 2 階段回滾;否則通知分支事務做 2 階段回滾
    5. 重置上下文
    private void processGlobalTransactionCommit() throws SQLException {
        try {
            // 1. 註冊分支事務,申請全域性行鎖,如果鎖衝突則丟擲異常
            // 有沒有重複註冊的情況呢?
            register();
        } catch (TransactionException e) {
            // 如果異常code是 LockKeyConflict 和 LockKeyConflictFailFast 才重新組織丟擲異常 LockConflictException
            // 外部的重試管控,識別出LockConflictException後實施重試。
            // 其他異常此處不處理
            recognizeLockKeyConflictException(e, context.buildLockKeys());
        }
        try {
            // 2. 寫undoLog
            UndoLogManagerFactory.getUndoLogManager(this.getDbType()).flushUndoLogs(this);
            // 3. 執行本地事務提交,將本地業務sql和undoLog一起提交
            targetConnection.commit();
        } catch (Throwable ex) {
            LOGGER.error("process connectionProxy commit error: {}", ex.getMessage(), ex);
            // 4. 向TC上報異常,並丟擲SQLException,告訴TC二階段若回滾則此分支事務無需回滾,因為1階段失敗了。
            report(false);
            throw new SQLException(ex);
        }
        if (IS_REPORT_SUCCESS_ENABLE) {
            // 4. 上報事務處理結果,告訴TC二階段若回滾則此分支事務必須回滾,因為1階段成功了。
            report(true);
        }
        // 5. 重試上下文
        context.reset();
    }

    6.5)report(false)預設只上報 commit 異常

    1. 預設是隻上報 commit 失敗的情況,開關是 client.rm.reportSuccessEnable預設值是 false
    2. 上報異常會重試,預設 5 次機會 閾值 REPORT_RETRY_COUNT 其配置為client.rm.reportRetryCount預設值是 5
    3. 提交失敗一定要上報 BranchStatus.PhaseOne_Failed 告訴 TC 二階段若回滾則此分支事務無需回滾,因為 1 階段失敗了。
    // client.rm.reportRetryCount = 5
    // 成員變數,構建新物件時就會讀取新到配置中心的新值,也算支援執行期配置變更
    private static final int REPORT_RETRY_COUNT = ConfigurationFactory.getInstance().getInt(
        ConfigurationKeys.CLIENT_REPORT_RETRY_COUNT, DEFAULT_CLIENT_REPORT_RETRY_COUNT);

    // client.rm.reportSuccessEnable = false
    public static final boolean IS_REPORT_SUCCESS_ENABLE = ConfigurationFactory.getInstance().getBoolean(
        ConfigurationKeys.CLIENT_REPORT_SUCCESS_ENABLE, DEFAULT_CLIENT_REPORT_SUCCESS_ENABLE);


    private void report(boolean commitDone) throws SQLException {
        if (context.getBranchId() == null) {
            return;
        }
        int retry = REPORT_RETRY_COUNT;//client.rm.reportRetryCount 預設值是 5,不支援執行期變更
        while (retry > 0) {
            try {
                DefaultResourceManager.get().branchReport(BranchType.AT, context.getXid(), context.getBranchId(),
                    commitDone ? BranchStatus.PhaseOne_Done : BranchStatus.PhaseOne_Failed, null);
                return;
            } catch (Throwable ex) {
                LOGGER.error("Failed to report [" + context.getBranchId() + "/" + context.getXid() + "] commit done ["
                    + commitDone + "] Retry Countdown: " + retry);
                retry--;

                if (retry == 0) {
                    throw new SQLException("Failed to report branch status " + commitDone, ex);
                }
            }
        }
    }

    6.6)向 TC 註冊分支事務,並申請全域性行鎖,如果全域性行鎖申請成功才意味著註冊成功,返回分支事務branchId,儲存在上下文中。

    private void register() throws TransactionException {
        // 不需要回滾,或不需要全域性鎖,就不註冊
        if (!context.hasUndoLog() || !context.hasLockKey()) {
            return;
        }
        // 向TC傳送 BranchRegisterRequest 請求
        Long branchId = DefaultResourceManager.get().branchRegister(BranchType.AT, getDataSourceProxy().getResourceId(),
            null, context.getXid(), context.getApplicationData(),
            context.buildLockKeys());
        // 將branchId繫結到上下文中,同一時刻,一個con上只有一個分支事務
        context.setBranchId(branchId);
    }

    6.7)若向 TC 註冊分支事務時,因行鎖衝突導致註冊失敗,則會丟擲鎖衝突的異常LockConflictException,前邊提到過重試邏輯中會識別此異常後執行重試,這個重試邏輯就在LockRetryPolicy#doRetryOnLockConflict中。

    protected <T> T doRetryOnLockConflict(Callable<T> callable) throws Exception {
        LockRetryController lockRetryController = new LockRetryController();
        // 迴圈
        while (true) {
            try {
                return callable.call();
            } catch (LockConflictException lockConflict) {
                // 衝突的情況下,清空context,執行本地rollback();
                onException(lockConflict);
                // AbstractDMLBaseExecutor#executeAutoCommitTrue the local lock is released
                if (connection.getContext().isAutoCommitChanged()
                    && lockConflict.getCode() == TransactionExceptionCode.LockKeyConflictFailFast) {
                    // 這個轉換,目前還未搞清楚用意
                    lockConflict.setCode(TransactionExceptionCode.LockKeyConflict);
                }
                // sleep方法裡 重試 和 間隔控制;
                // 超過次數丟擲異常,退出迴圈
                lockRetryController.sleep(lockConflict);
            } catch (Exception e) {
                // 其他異常情況下,清空context,執行本地rollback();
                onException(e);
                // 上拋異常
                throw e;
            }
        }
    }

    對於分支事務來說,這其中return callable.call();對應的就是下圖中紅框圈注的內容

    【Seata原始碼領讀】揭秘 @GlobalTransactional 背後 RM 的黑盒操作之一階段

    但需特別注意LockRetryPolicy#onException這個方法是空的,但AbstractDMLBaseExecutor.LockRetryPolicy重寫了onException方法,在這個方法中會清除上邊重試主體執行過程暫存在上下文中的的鎖 key 和 undoLog,並透過原始Connection執行回滾。

    protected void onException(Exception e) throws Exception {
        ConnectionContext context = connection.getContext();
        //UndoItems can't use the Set collection class to prevent ABA
        //清除構建undoLog時,暫存在上下文中的的鎖key 和  undoLog
        context.removeSavepoint(null);
        // 透過原始con 執行回滾
        connection.getTargetConnection().rollback();
    }

    從重試的管控邏輯分析可知,AbstractDMLBaseExecutor.LockRetryPolicy的邏輯是這樣,當遇到鎖衝突異常後,會在onException中清除上下文,執行回滾之後重試,當重試次數達到上限後,還是會上拋異常。另外若遇到的並非鎖衝突類的異常,會在onException中清除上下文,執行回滾,之後上跑異常。 異常上拋後,業務上層會接收到該異常,至於是給 TM 模組返回成功還是失敗,由業務上層實現決定,如果返回失敗,則 TM 識別到異常後,會裁決對全域性事務進行回滾。

    四、如果跟 GlobalLock 相關

    簡單來說 RM 在資料來源代理層的邏輯為

    • 向 TC 查詢鎖是否存在,全域性事務的鎖還存在就透過拋異常繼續重試
    • 如果向 TC 查詢鎖不存在,則提交本地事務。

    參考:

    • https://mp.weixin.qq.com/s/EzmZ-DAi-hxJhRkFvFhlJQ
    • https://blog.csdn.net/zzti_erlie/article/details/120939588

      來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70027824/viewspace-2953339/,如需轉載,請註明出處,否則將追究法律責任。

      相關文章