用 Go 輕鬆完成一個 TCC 分佈式事務,保姆級教程

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

TCC 組成

TCC 分爲 3 個階段

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

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

TCC 網絡異常

TCC 在整個全局事務的過程中,可能發生各類網絡異常情況,典型的是空回滾、冪等、懸掛,由於 TCC 的異常情況,和 SAGA、可靠消息等事務模式有相近的地方,因此我們把所有異常的解決方案統統放在這篇文章《還被分佈式事務的網絡異常困擾嗎?一個函數調用幫你搞定它》進行講解

TCC 實踐

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

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

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

目前可用於 TCC 的開源框架,主要爲 Java 語言,其中以 seata 爲代表。我們的例子採用 go 語言,使用的分佈式事務框架爲 dtm,它對分佈式事務的支持非常優雅。下面來詳細講解 TCC 的組成

我們首先創建兩張表,一張是用戶餘額表,一張是凍結資金錶,建表語句如下:

CREATE TABLE dtm_busi.`user_account` (
  `id` int(11) AUTO_INCREMENT PRIMARY KEY,
  `user_id` int(11) not NULL UNIQUE ,
  `balance` decimal(10,2) NOT NULL DEFAULT '0.00',
  `create_time` datetime DEFAULT now(),
  `update_time` datetime DEFAULT now()
);

CREATE TABLE dtm_busi.`user_account_trading` (
  `id` int(11) AUTO_INCREMENT PRIMARY KEY,
  `user_id` int(11) not NULL UNIQUE ,
  `trading_balance` decimal(10,2) NOT NULL DEFAULT '0.00',
  `create_time` datetime DEFAULT now(),
  `update_time` datetime DEFAULT now()
);

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

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

func adjustTrading(uid int, amount int) (interface{}, error) {
  冪等、懸掛處理
  dbr := sdb.Exec("update dtm_busi.user_account_trading t join dtm_busi.user_account a on t.user_id=a.user_id and t.user_id=? set t.trading_balance=t.trading_balance + ? where a.balance + t.trading_balance + ? >= 0", uid, amount, amount)
  if dbr.Error == nil && dbr.RowsAffected == 0 { // 如果餘額不足,返回錯誤
    return nil, fmt.Errorf("update error, balance not enough")
  }
  其他情況檢查及處理
}

然後是調整餘額

func adjustBalance(uid int, amount int) (ret interface{}, rerr error) {
  冪等、懸掛處理
  這裏略去進行相關的事務處理,包括開啓事務,以及在defer中處理提交或回滾
  // 將原先凍結的資金記錄解凍
  dbr := db.Exec("update dtm_busi.user_account_trading t join dtm_busi.user_account a on t.user_id=a.user_id and t.user_id=? set t.trading_balance=t.trading_balance + ?", uid, -amount)
  if dbr.Error == nil && dbr.RowsAffected == 1 { // 解凍成功
    // 調整金額
    dbr = db.Exec("update dtm_busi.user_account set balance=balance+? where user_id=?", amount, uid)
  }
  其他情況檢查及處理
}

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

RegisterPost(app, "/api/TransInTry", func (c *gin.Context) (interface{}, error) {
  return adjustTrading(1, reqFrom(c).Amount)
})
RegisterPost(app, "/api/TransInConfirm", func TransInConfirm(c *gin.Context) (interface{}, error) {
  return adjustBalance(1, reqFrom(c).Amount)
})
RegisterPost(app, "/api/TransInCancel", func TransInCancel(c *gin.Context) (interface{}, error) {
  return adjustTrading(1, -reqFrom(c).Amount)
})

RegisterPost(app, "/api/TransOutTry", func TransOutTry(c *gin.Context) (interface{}, error) {
  return adjustTrading(2, -reqFrom(c).Amount)
})
RegisterPost(app, "/api/TransOutConfirm", func TransInConfirm(c *gin.Context) (interface{}, error) {
  return adjustBalance(2, -reqFrom(c).Amount)
})
RegisterPost(app, "/api/TransOutCancel", func TransInCancel(c *gin.Context) (interface{}, error) {
  return adjustTrading(2, reqFrom(c).Amount)
})

到此各個子事務的處理函數已經 OK 了,然後是開啓 TCC 事務,進行分支調用

// TccGlobalTransaction 會開啓一個全局事務
_, err := dtmcli.TccGlobalTransaction(DtmServer, func(tcc *dtmcli.Tcc) (rerr error) {
  // CallBranch 會將事務分支的Confirm/Cancel註冊到全局事務上,然後直接調用Try
  res1, rerr := tcc.CallBranch(&TransReq{Amount: 30}, host+"/api/TransOutTry", host+"/api/TransOutConfirm", host+"/api/TransOutRevert"
  進行錯誤檢查,以及其他邏輯
  res2, rerr := tcc.CallBranch(&TransReq{Amount: 30}, host+"/api/TransInTry", host+"/api/TransInConfirm", host+"/api/TransInRevert")
  進行錯誤檢查,有任何錯誤,返回錯誤,回滾交易
  // 如果沒有錯誤,函數正常返回後,全局事務會提交,TM會調用各個事務分支的Confirm,完成整個事務
})

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

如果您想要完整運行一個成功的示例,那麼按照 dtm 項目的說明搭建好環境之後,運行下面命令運行 tcc 的例子即可

go run app/main.go tcc_barrier

TCC 的回滾

假如銀行將金額準備轉入用戶 2 時,發現用戶 2 的賬戶異常,返回失敗,會怎麼樣?我們給出事務失敗交互的時序圖

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

**小結
**

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

關於分佈式事務中需要處理的冪等、懸掛、空補償,請參考另一篇文章:

分佈式事務你不能不知的坑,一個函數調用幫你搞定它

文中使用的例子節選自 dtm,它由 go 實現,支持多種事務模式:TCC、SAGA、XA、事務消息 跨語言支持,已支持 golang、python、PHP、nodejs 等語言的客戶端。提供子事務屏障功能,優雅的解決了冪等、懸掛、空補償等問題。

閱讀完此篇乾貨,歡迎大家訪問 dtm 項目,給顆星星支持!

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