分佈式事務,阿里爲什麼鍾愛 TCC

分佈式事務的實現方式中,TCC 是比較知名的模式。但是我一直不喜歡這種模式,原因是這種模式有很多問題要考慮。

之前寫過一篇文章說了 TCC 的很多缺點,後來我把文章刪了,原因是一位阿里大佬加我好友並指正了我的觀點。

太感謝了!

1 TCC 概要

簡單來講,TCC 模式就是將整個事務分成兩個階段來提交,try 階段進行預留資源,如果所有分支都預留成功,則進入 commit 階段提交所有分支事務,否則執行 cancel 取消所有分支事務。

以電商系統爲例,假如有訂單、庫存和賬戶 3 個服務,客戶購買一件商品,訂單服務增加訂單,庫存服務扣減庫存,賬戶服務扣減金額,這三個操作必須是原子性的,要麼全部成功,要麼全部失敗。

try 階段

如下圖:

訂單服務增加一個訂單,庫存服務凍結訂單上的庫存,賬戶服務凍結訂單上的金額。

訂單、庫存和賬戶這三個服務作爲整個分佈式事務的分支事務,在 try 階段都是要提交本地事務的。上面庫存和賬戶說的凍結,就是說這個訂單對應的庫存和金額已經不能再被其他事務使用了,所以必須提交本地事務。

但這個提交併不是真正的提交全局事務,而是把資源轉到中間態,這個中間態需要在 try 方法的業務代碼中實現,比如賬戶扣除的金額可以先存放到一箇中間賬戶。

如果 try 階段不提交本地事務會有什麼問題呢?有可能其他事務在 try 階段發現用戶賬戶裏面的金額還夠,但是 commit 的時候發現金額不夠了,commit 階段扣款只能失敗,這時其他兩個分支事務提交成功而賬戶服務的分支事務提交失敗,最終數據就不一致了。

commit 階段

如下圖:

commit 階段,數據從中間態轉入終態,比如訂單金額從中間賬戶轉到最終賬戶。

cancel 階段跟 commit 階段類似,比如訂單金額從中間賬戶退回到客戶賬戶。

2 問題代碼

下面這段代碼也可以理解爲 TCC,是在 try 階段 hold 住了 connection,不提交分支事務,到 commit 階段再提交分支事務。代碼如下:我們以扣減賬戶爲例,首先定義 2 個變量來 hold 住 connection:

private Map<String, Statement> statementMap = new ConcurrentHashMap<>(100);
private Map<String, Connection> connectionMap = new ConcurrentHashMap<>(100);

try 方法代碼如下:

public boolean try(String xid, Long userId, BigDecimal payAmount) {
    LOGGER.info("decrease, xid:{}", xid);
    LOGGER.info("------->嘗試扣減賬戶開始account");

    try {
        //嘗試扣減賬戶金額,事務不提交
        Connection connection = hikariDataSource.getConnection();
        connection.setAutoCommit(false);
        String sql = "UPDATE account SET balance = balance - ?,used = used + ? where user_id = ?";
        PreparedStatement stmt = connection.prepareStatement(sql);
        stmt.setBigDecimal(1, payAmount);
        stmt.setBigDecimal(2, payAmount);
        stmt.setLong(3, userId);
        stmt.executeUpdate();
        statementMap.put(xid, stmt);
        connectionMap.put(xid, connection);
    } catch (Exception e) {
        LOGGER.error("decrease parepare failure:", e);
        return false;
    }

    LOGGER.info("------->嘗試扣減賬戶結束account");

    return true;
}

commit 方法代碼如下:

public boolean commit(BusinessActionContext actionContext){
    String xid = actionContext.getXid();
    PreparedStatement statement = (PreparedStatement) statementMap.get(xid);
    Connection connection = connectionMap.get(xid);
    try {
        if (null != connection){
            connection.commit();
        }
    } catch (SQLException e) {
        LOGGER.error("扣減賬戶失敗:", e);
        return false;
    }finally {
        try {
            statementMap.remove(xid);
            connectionMap.remove(xid);
            if (null != statement){
                statement.close();
            }
            if (null != connection){
                connection.close();
            }
        } catch (SQLException e) {
            LOGGER.error("扣減賬戶提交事務後關閉連接池失敗:", e);
        }
    }
    return true;
}

cancel 方法代碼如下:

public boolean rollback(BusinessActionContext actionContext){
    String xid = actionContext.getXid();
    PreparedStatement statement = (PreparedStatement) statementMap.get(xid);
    Connection connection = connectionMap.get(xid);
    try {
        connection.rollback();
    } catch (SQLException e) {
        return false;
    }finally {
        try {
            if (null != statement){
                statement.close();
            }
            if (null != connection){
                connection.close();
            }
            statementMap.remove(xid);
            connectionMap.remove(xid);
        } catch (SQLException e) {
            LOGGER.error("扣減賬戶回滾事務後關閉連接池失敗:", e);
        }
    }
    return true;
}

這段代碼是問題代碼,不能用,不能用,不能用

這個代碼存在兩個問題:

2.1 阻塞等待

如果當前事務不提交,比如賬戶服務,那就相當於是鎖定了資源,後面的事務只能等待資源釋放。

2.2 服務集羣

以訂單服務爲例,假如訂單服務是一個 3 個機器的集羣,如下圖:

協調節點使用註冊中心客戶端來調用訂單服務,如果 try 請求發送到了訂單服務 1,而 commit 請求發送到了訂單服務 2,那訂單服務 2 上的 connectionMap 裏不會有 xid=123 這個 connection,只能提交失敗。

3 TCC 存在的問題

上面的問題代碼就是給大家一個思路,如果真要 hold 住 connection,也算是實現了 TCC 的思想,但是在系統中,我們是不可能這樣做的,所以把它叫做問題代碼。

3.1 空回滾

如下圖,訂單服務 1 節點故障,如果不考慮重試,try 方法失敗:

try 雖然失敗了,但是全局事務已經開啓,框架必須要把這個全局事務推向結束狀態,這就不得不調用訂單服務 cancel 方法進行回滾,結果訂單服務空跑了一次 cancel 方法。

解決這個問題,可以記錄一張事務控制表,保存全局事務 xid 和分支事務 branchId,try 階段會插入一條記錄,表示 try 階段執行了。cancel 方法讀取該記錄,如果記錄存在,正常回滾;如果該記錄不存在,那就是空回滾。

3.2 冪等

冪等是指在 commit/cancel 階段,因爲 TC 沒有收到分支事務的響應,需要進行重試,這就要分支事務支持冪等。以訂單服務爲例。如下圖:

要支持冪等,可以記錄一張事務控制表,保存全局事務 xid 和分支事務 branchId,以及分支事務狀態,在第二階段 commit/cancel 之前先檢查分支事務狀態是否已經是終態,如果不是,再執行第二階段的邏輯。

3.3 懸掛

懸掛是指事務的 cancel 方法比 try 方法先執行。上面講了 seata 的使用過程中會發生空回滾,如果發生了空回滾,執行了 cancel 方法後全局事務結束了,但是因爲網絡問題,訂單服務又收到了 try 請求,執行 try 方法後預留資源成功,這些資源最終不能釋放了。

解決這個問題的方法就是在 cancel 方法中記錄 xid 對應的分支事務回滾記錄,try 階段執行的時候先判斷分支事務是否已經回滾,如果存在回滾記錄,則直接退出。

3.4 業務代碼侵入

TCC 的 try/commit/cancel,對業務代碼都有侵入,而且每個方法都是一個本地事務。再加上需要考慮冪等、空回滾、懸掛等,代碼侵入會更高。

4.TCC 優勢

這裏以 seata 實現的四種模式來比較,包括 XA、SAGA、TCC、AT。

效率

使用 TCC 模式時,在 try 階段就提交了本地事務,並不會鎖定資源,所以沒有其他額外的性能開銷。相比之下,來看其他幾種模式:

saga 模式

更適合長流程的業務場景。

  1. 性能優化

參考 [1]

5.1 異步提交

優化思路是 try 階段成功後,不立即執行 confirm/cancel 階段,而是等系統空閒的時候異步執行。如下圖:

這樣在 try 階段結束後,就認爲全局事務結束了,可以定時 (比如 10 分鐘) 來異步執行第二階段,性能大幅提升。

當然,帶來的一點問題就是如果全局事務回滾,會有短暫的數據不一致。比如扣款的場景,定時 10 分鐘執行一次異步任務,如果第二階段是 cancel,那客戶會在這 10 分鐘內不能使用這筆金額。

這個異步執行的時間也可以根據業務來決定,比如不需要及時從中間賬戶轉移到最終賬戶的場景可以設置更長。

5.2 同庫模式

首先回顧一下 TCC 中各個角色:

先看一下優化之前的通信模型,如下圖:

在優化之前,TM 開啓全局事務時,RM 需要向 TC 發送 RPC 消息進行註冊,TC 保存分支事務的狀態。TM 請求提交或回滾時,TC 需要向 RM 發送 RPC 消息進行提交或回滾。這樣包含兩個個分支事務的分佈式事務中,TC 和 RM 之間有四次 RPC。

優化之後的模型如下圖:

TM 開啓全局事務時,不再需要向 TC 註冊分支事務,而是把分支事務狀態保存在了本地。TM 向 TC 發送提交或回滾消息時,TC 保存全局事務的狀態。而 RM 則啓動異步線程檢測本地記錄的未提交分支事務,向 TC 發送 RPC 消息獲取整體事務狀態,以決定是提交還是回滾本地事務。可見,優化後的模型,RPC 次數減少了 50%,性能大幅提升。

  1. 總結

TCC 的問題確實不少,但是除了侵入業務代碼這一個問題,其他問題都有對應的解決方案。

阿里針對 TCC 做了一些優化,包括第二階段異步提交和同庫模式,性能提升很明顯。

參考資料

[1]

參考: https://tech.antfin.com/community/live/462/data/736

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/BlOxKx-QA6mFZyN27fWk8g