用Java輕鬆完成一個分散式事務TCC,自動處理空補償、懸掛、冪等

葉東富發表於2021-11-29

什麼是TCC,TCC是Try、Confirm、Cancel三個詞語的縮寫,最早是由 Pat Helland 於 2007 年發表的一篇名為《Life beyond Distributed Transactions:an Apostate’s Opinion》的論文提出。

TCC組成

TCC分為3個階段

  • Try 階段:嘗試執行,完成所有業務檢查(一致性), 預留必須業務資源(準隔離性)
  • Confirm 階段:如果所有分支的Try都成功了,則走到Confirm階段。Confirm真正執行業務,不作任何業務檢查,只使用 Try 階段預留的業務資源
  • Cancel 階段:如果所有分支的Try有一個失敗了,則走到Cancel階段。Cancel釋放 Try 階段預留的業務資源。

TCC分散式事務裡,有3個角色,與經典的XA分散式事務一樣:

  • AP/應用程式,發起全域性事務,定義全域性事務包含哪些事務分支
  • RM/資源管理器,負責分支事務各項資源的管理
  • TM/事務管理器,負責協調全域性事務的正確執行,包括Confirm,Cancel的執行,並處理網路異常

如果我們要進行一個類似於銀行跨行轉賬的業務,轉出(TransOut)和轉入(TransIn)分別在不同的微服務裡,一個成功完成的TCC事務典型的時序圖如下:
image.png

TCC網路異常

TCC在整個全域性事務的過程中,可能發生各類網路異常情況,典型的是空回滾、冪等、懸掛。這裡有一篇文章分散式事務的這些常見用法都有坑,來看看正確姿勢,進行了詳細的講解

TCC實踐

對於前面的跨行轉賬操作,最簡單的做法是,在Try階段調整餘額,在Cancel階段反向調整餘額,Confirm階段則空操作。這麼做帶來的問題是,如果A扣款成功,金額轉入B失敗,最後回滾,把A的餘額調整為初始值。在這個過程中如果A發現自己的餘額被扣減了,但是收款方B遲遲沒有收到餘額,那麼會對A造成困擾。

更好的做法是,Try階段凍結A轉賬的金額,Confirm進行實際的扣款,Cancel進行資金解凍,這樣使用者在任何一個階段,看到的資料都是清晰明瞭的。

下面我們進行一個TCC事務的具體開發

我們的例子採用Java語言,使用的分散式事務框架為https://github.com/yedf/dtm,它對分散式事務的支援非常優雅。下面來詳細講解TCC的組成

我們首先建立使用者餘額表,建表語句如下:

create table if not exists dtm_busi.user_account(
  id int(11) PRIMARY KEY AUTO_INCREMENT,
  user_id int(11) UNIQUE,
  balance DECIMAL(10, 2) not null default '0',
  trading_balance DECIMAL(10, 2) not null default '0',
  create_time datetime DEFAULT now(),
  update_time datetime DEFAULT now(),
  key(create_time),
  key(update_time)
);

表中,trading_balance記錄正在交易的金額。

我們先編寫核心程式碼,凍結/解凍資金操作,會檢查約束balance+trading_balance >= 0,如果約束不成立,執行失敗

public void adjustTrading(Connection connection, TransReq transReq) throws Exception {
    String sql = "update dtm_busi.user_account set trading_balance=trading_balance+?"
            + " where user_id=? and trading_balance + ? + balance >= 0";
    PreparedStatement preparedStatement = null;
    try {
        preparedStatement = connection.prepareStatement(sql);
        preparedStatement.setInt(1, transReq.getAmount());
        preparedStatement.setInt(2, transReq.getUserId());
        preparedStatement.setInt(3, transReq.getAmount());
        if (preparedStatement.executeUpdate() > 0) {
            System.out.println("交易金額更新成功");
        } else {
            throw new FailureException("交易失敗");
        }
    } finally {
        if (null != preparedStatement) {
            preparedStatement.close();
        }
    }
    
}

然後是調整餘額

public void adjustBalance(Connection connection, TransReq transReq) throws SQLException {
    PreparedStatement preparedStatement = null;
    try {
        String sql = "update dtm_busi.user_account set trading_balance=trading_balance-?,balance=balance+? where user_id=?";
        preparedStatement = connection.prepareStatement(sql);
        preparedStatement.setInt(1, transReq.getAmount());
        preparedStatement.setInt(2, transReq.getAmount());
        preparedStatement.setInt(3, transReq.getUserId());
        if (preparedStatement.executeUpdate() > 0) {
            System.out.println("餘額更新成功");
        }
    } finally {
        if (null != preparedStatement) {
            preparedStatement.close();
        }
    }
}

下面我們來編寫具體的Try/Confirm/Cancel的處理函式

@RequestMapping("barrierTransOutTry")
public Object TransOutTry(HttpServletRequest request) throws Exception {

    BranchBarrier branchBarrier = new BranchBarrier(request.getParameterMap());
    logger.info("barrierTransOutTry branchBarrier:{}", branchBarrier);

    TransReq transReq = extracted(request);
    Connection connection = dataSourceUtil.getConnecion();
    branchBarrier.call(connection, (barrier) -> {
        System.out.println("使用者: +" + transReq.getUserId() + ",轉出" + Math.abs(transReq.getAmount()) + "元準備");
        this.adjustTrading(connection, transReq);
    });
    connection.close();
    return TransResponse.buildTransResponse(Constant.SUCCESS_RESULT);
}

@RequestMapping("barrierTransOutConfirm")
public Object TransOutConfirm(HttpServletRequest request) throws Exception {
    BranchBarrier branchBarrier = new BranchBarrier(request.getParameterMap());
    logger.info("barrierTransOutConfirm branchBarrier:{}", branchBarrier);
    Connection connection = dataSourceUtil.getConnecion();
    TransReq transReq = extracted(request);
    branchBarrier.call(connection, (barrier) -> {
        System.out.println("使用者: +" + transReq.getUserId() + ",轉出" + Math.abs(transReq.getAmount()) + "元提交");
        adjustBalance(connection, transReq);
    });
    connection.close();
    return TransResponse.buildTransResponse(Constant.SUCCESS_RESULT);
}

@RequestMapping("barrierTransOutCancel")
public Object TransOutCancel(HttpServletRequest request) throws Exception {
    BranchBarrier branchBarrier = new BranchBarrier(request.getParameterMap());
    logger.info("barrierTransOutCancel branchBarrier:{}", branchBarrier);
    TransReq transReq = extracted(request);
    Connection connection = dataSourceUtil.getConnecion();
    branchBarrier.call(connection, (barrier) -> {
        System.out.println("使用者: +" + transReq.getUserId() + ",轉出" + Math.abs(transReq.getAmount()) + "元回滾");
        this.adjustTrading(connection, transReq);
    });
    connection.close();
    return TransResponse.buildTransResponse(Constant.SUCCESS_RESULT);
}

// TransIn相關函式與TransOut類似,這裡省略

到此各個子事務的處理函式已經OK了,然後是開啟TCC事務,進行分支呼叫

@RequestMapping("tccBarrier")
public String tccBarrier() {
    // 建立dmt client
    DtmClient dtmClient = new DtmClient(ipPort);
    //建立tcc事務
    try {
        dtmClient.tccGlobalTransaction(dtmClient.genGid(), TccTestController::tccBarrierTrans);
    } catch (Exception e) {
        log.error("tccGlobalTransaction error", e);
        return "fail";
    }
    return "success";
}

public static void tccBarrierTrans(Tcc tcc) throws Exception {
    // 使用者1 轉出30元
    Response outResponse = tcc
            .callBranch(new TransReq(1, -30), svc + "/barrierTransOutTry", svc + "/barrierTransOutConfirm",
                    svc + "/barrierTransOutCancel");
    log.info("outResponse:{}", outResponse);

    // 使用者2 轉入30元
    Response inResponse = tcc
            .callBranch(new TransReq(2, 30), svc + "/barrierTransInTry", svc + "/barrierTransInConfirm",
                    svc + "/barrierTransInCancel");
    log.info("inResponse:{}", inResponse);
}

至此,一個完整的TCC分散式事務編寫完成。

如果您想要完整執行一個成功的示例,那麼按照dtmcli-java-sample專案的說明搭建好環境啟動之後,執行下面命令執行tcc的例子即可

curl http://localhost:8081/tccBarrier

TCC的回滾

假如銀行將金額準備轉出使用者2時,發現使用者2的賬戶異常,返回失敗,會怎麼樣?我們的例子中,可使用者餘額為10000,發起一筆100000的轉賬會觸發異常而失敗:
curl http://localhost:8081/tccBarrier
這是事務失敗互動的時序圖
image.png

這個跟成功的TCC差別就在於,當某個子事務返回失敗後,後續就回滾全域性事務,呼叫各個子事務的Cancel操作,保證全域性事務全部回滾。

小結

在這篇文章裡,我們介紹了TCC的理論知識,也通過一個例子,完整給出了編寫一個TCC事務的過程,涵蓋了正常成功完成,以及成功回滾的情況。相信讀者通過這邊文章,對TCC已經有了深入的理解。

關於分散式事務中需要處理的冪等、懸掛、空補償,請參考另一篇文章:分散式事務的這些常見用法都有坑,來看看正確姿勢

關於分散式事務更多更全面的知識,請參考分散式事務最經典的七種解決方案

文中使用的例子選自yedf/dtmcli-java-sample。使用的分散式事務管理器為https://github.com/yedf/dtm,支援多種事務模式:TCC、SAGA、XA、事務訊息 跨語言支援,已支援 golang、python、PHP、nodejs等語言的客戶端。提供子事務屏障功能,優雅解決冪等、懸掛、空補償等問題。

閱讀完此篇乾貨,歡迎大家訪問https://github.com/yedf/dtm專案,給顆星星支援!

相關文章