分布式事务实战:用Go轻松完成一个TCC

TCC 分布式事务来源于 2007 年 Pat Helland 发表的一篇名为《Life beyond Distributed Transactions:an Apostate’s Opinion》的论文,TCC 分别是 Try、Confirm、Cancel 的手写字母。

组成

TCC 有三个分支:

  • Try 分支:预留锁定业务相关资源,如果资源不够,则返回失败
  • Confirm 分支:如果前面的 Try 全部成功,则进入 Confirm,进行数据变更,这个阶段不会返回失败
  • Cancel 分支:如果前面的 Try 没有全部成功,有返回失败的,则进入 Cancel。Cancel 解冻 Try 锁定的资源,也类似 Confirm 是不会返回失败的。

假设有一个银行跨行转账的业务,因为不同银行,数据不在同一个数据库,而更可能在不同微服务下的数据库里。这是一个典型的分布式事务场景,我们看看一个成功的 TCC 时序图:

实践

A 转账给 B 的跨行转账操作,如果转账不成功,我们不想让用户看到自己账上的余额变动过,因此我们在 Try 阶段冻结相关的余额,Confirm 阶段进行转账,Cancel 阶段进行余额解冻。这样可以避免 A 看到自己的存款减少了,但是最后转账又失败的情况。

下面是具体的开发详情:

我们采用Go语言,使用 https://github.com/yedf/dtm 这个功能强大又简单易用的分布式事务框架。

创建两张表,一个用户余额表,另一个是冻结资金表,语句如下:

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 记录的是交易中的金额。

最重要的业务代码包括冻结/解冻资金和调整余额,代码如下:

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)

}

其他情况检查及处理

} 

业务有个重要约束 balance+trading_balance >= 0,表示用户最终的余额不能为负。如果约束不成立,返回失败。

然后是 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 事务,进行分支调用:

err := dtmcli.TccGlobalTransaction(DtmServer, gid, func(tcc *dtmcli.Tcc) (*resty.Response, error) {

resp, err := tcc.CallBranch(&TransReq{Amount: 30}, Busi+"/TccBTransOutTry", Busi+"/TccBTransOutConfirm", Busi+"/TccBTransOutCancel")

if err != nil {

  return resp, err

}

return tcc.CallBranch(&TransReq{Amount: 30}, Busi+"/TccBTransInTry", Busi+"/TccBTransInConfirm", Busi+"/TccBTransInCancel")

}) 

至此,一个 TCC 分布式事务全部完成。

yedf/dtm 项目中有完整的示例,你可以访问该项目,通过下面命令运行上述的示例。

go run app/main.go tcc_barrier

回滚

跨行转账有可能出现失败,例如 A 转账给 B,但是 B 的账户由于各类原因异常,返回无法转入,这种情况会怎么样?我们可以修改代码,让我们的示例处理这种情况:

RegisterPost(app, "/api/TransInTry", func (c *gin.Context) (interface{}, error) {  

return gin.H{"dtm_result":"FAILURE"}, nil  

})

因为 B 账户的异常,会导致整个全局事务的回滚,时序图如下:

这个时序图与成功的时序图非常相近,主要差别在于 TransIn 返回了失败,后续的操作由 Confirm 变成了 Cancel。

小结

这篇文章完整的介绍了 TCC 事务的全过程,包括 TCC 事务的业务设计要点、一个成功完成的例子、一个成功回滚的例子。相信读者到这里,已经对 TCC 有了很清晰的理解。

原文链接:,转发请注明来源!