什麼是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事務典型的時序圖如下:
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專案,給顆星星支援!
本作品採用《CC 協議》,轉載必須註明作者和本文連結