用 Go 轻松完成一个 TCC 分布式事务,保姆级教程 - V2EX
V2EX = way to explore
V2EX 是一个关于分享和探索的地方
Sign Up Now
For Existing Member  Sign In
如果想在 V2EX 获得更好的推广效果,欢迎了解 PRO 会员机制:
pro/about
dongfuye1

用 Go 轻松完成一个 TCC 分布式事务,保姆级教程

  dongfuye1 Aug 9, 2021 9247 views
This topic created in 1721 days ago, the information mentioned may be changed or developed.

什么是 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 的异常情况,和 SAGA 、可靠消息等事务模式有相近的地方,因此我们把所有异常的解决方案统统放在这篇文章《还被分布式事务的网络异常困扰吗?一个函数调用帮你搞定它》进行讲解

TCC 实践

对于前面的跨行转账操作,最简单的做法是,在 Try 阶段调整余额,在 Cancel 阶段反向调整余额,Confirm 阶段则空操作。这么做带来的问题是,如果 A 扣款成功,金额转入 B 失败,最后回滚,把 A 的余额调整为初始值。在这个过程中如果 A 发现自己的余额被扣减了,但是收款方 B 迟迟没有收到余额,那么会对 A 造成困扰。

更好的做法是,Try 阶段冻结 A 转账的金额,Confirm 进行实际的扣款,Cancel 进行资金解冻,这样用户在任何一个阶段,看到的数据都是清晰明了的。

下面我们进行一个 TCC 事务的具体开发

目前可用于 TCC 的开源框架,主要为 Java 语言,其中以 seata 为代表。我们的例子采用 go 语言,使用的分布式事务框架为https://github.com/yedf/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, "/ai/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 的账户异常,返回失败,会怎么样?我们修改代码,模拟这种情况:

RegisterPost(app, "/api/TransInTry", func (c *gin.Context) (interface{}, error) { return gin.H{"dtm_result":"FAILURE"}, nil }) 

这是事务失败交互的时序图 image.png

这个跟成功的 TCC 差别就在于,当某个子事务返回失败后,后续就回滚全局事务,调用各个子事务的 Cancel 操作,保证全局事务全部回滚。

小结

在这篇文章里,我们介绍了 TCC 的理论知识,也通过一个例子,完整给出了编写一个 TCC 事务的过程,涵盖了正常成功完成,以及成功回滚的情况。相信读者通过这边文章,对 TCC 已经有了深入的理解。

关于分布式事务中需要处理的幂等、悬挂、空补偿,请参考另一篇文章:分布式事务你不能不知的坑,一个函数调用帮你搞定它

关于分布式事务更多更全面的知识,请参考分布式事务最经典的七种解决方案

文中使用的例子节选自yedf/dtm,支持多种事务模式:TCC 、SAGA 、XA 、事务消息 跨语言支持,已支持 golang 、python 、PHP 、nodejs 等语言的客户端。提供子事务屏障功能,优雅解决幂等、悬挂、空补偿等问题。 阅读完此篇干货,欢迎大家访问https://github.com/yedf/dtm项目,给颗星星支持!

21 replies    2022-11-20 21:39:45 +08:00
wuqingdzx
    1
wuqingdzx  
   Aug 9, 2021
干货了
we8105
    2
we8105  
   Aug 9, 2021
make
yRebelHero
    3
yRebelHero  
   Aug 9, 2021
干货了,谢谢楼主,收藏一波。
zhangfeiwudi
    4
zhangfeiwudi  
   Aug 9, 2021
make
halweg
    5
halweg  
   Aug 9, 2021 via Android
谢谢
Rwing
    6
Rwing  
   Aug 9, 2021
不错,不过这跟哪个语言关系不大吧
waibunleung
    7
waibunleung  
   Aug 9, 2021
@zhangfeiwudi
@we8105
make = mark ?
LoNeFong
    8
LoNeFong  
   Aug 9, 2021
@waibunleung 应该是, 看得我一愣一愣的
zhangfeiwudi
    9
zhangfeiwudi  
   Aug 9, 2021
@waibunleung 哈哈 是 mark 我要按回车的时候才反应过来,不过无所谓了 大家都能看懂
Euthpic
    10
Euthpic  
   Aug 10, 2021 via Android   1
点赞收藏退出一气呵成
sunmoon1983
    11
sunmoon1983  
   Aug 10, 2021
@halweg 卧槽,头像好评
dongfuye1
    12
dongfuye1  
OP
   Aug 11, 2021
@Rwing 跟语言的关系不太大,但也需要各个语言提供简单的 SDK 。目前 dtm 已经支持 python 、php 、csharp 、nodejs 等多个语言
Rwing
    13
Rwing  
   Aug 11, 2021   1
@dongfuye1 感动,竟然支持 csharp
bthulu
    14
bthulu  
   Aug 19, 2021
Cancel 失败了怎么办呢, 要不要无限重试 cancel?
无限重试过程中容器挂了重启后, 怎么保留挂之前的重试 cancel 呢, 是不是还要找个地方持久化 cancel 呢? 万一这个持久化 cancel 也失败了呢, 怎么办呢?
dongfuye1
    15
dongfuye1  
OP
   Aug 19, 2021   1
@bthulu confirm 和 cancel 如果没有返回成功,都是无限重试的。
时序图里面注册分支时,就已经把 confirm|cancel 持久化到数据库了,如果当时持久化失败,当时这个 tcc 事务就返回失败了
heww
    16
heww  
   Aug 23, 2021
@dongfuye1 “如果当时持久化失败,当时这个 tcc 事务就返回失败了” tcc 事务你可以失败,但 confim 阶段的数据你没有 rollback 回去啊?
dongfuye1
    17
dongfuye1  
OP
   Aug 23, 2021
@heww TCC 的第一个阶段是 Try 阶段,如果注册某一个分支失败,那么会请求 dtm,告知失败,并返回失败。后续 dtm 会对已经注册过的子事务,调用 Cancel 分支,进行回滚。
Try 阶段要全部执行成功,才会到 confirm 阶段,此时的协议是 confirm 不允许失败,临时的网络故障可以通过重试成功。
heww
    18
heww  
   Aug 23, 2021
看错了,我以为是在 cancel 阶段才做 cancel 的持久化的。
lanlanye
    19
lanlanye  
   Nov 3, 2021
想请教一下,纠结 Cancel 阶段失败怎么办是不是有些钻牛角尖?
可是实际应用中这种情况应该会存在的吧?
lanlanye
    20
lanlanye  
   Nov 3, 2021
@lanlanye 看到链接的文章中有讲,当我没问吧
shore123
    21
shore123  
   Nov 20, 2022
想问一下,微服务 1 和 2 都 try 成功了, 协调者是如何得知的? 进而协调者再分别调用两个服务的 confirm.
About     Help     Advertise     Blog     API     FAQ     Solana     3684 Online   Highest 6679       Select Language
创意工作者们的社区
World is powered by solitude
VERSION: 3.9.8.5 81ms UTC 00:48 PVG 08:48 LAX 17:48 JFK 20:48
Do have faith in what you're doing.
ubao msn snddm index pchome yahoo rakuten mypaper meadowduck bidyahoo youbao zxmzxm asda bnvcg cvbfg dfscv mmhjk xxddc yybgb zznbn ccubao uaitu acv GXCV ET GDG YH FG BCVB FJFH CBRE CBC GDG ET54 WRWR RWER WREW WRWER RWER SDG EW SF DSFSF fbbs ubao fhd dfg ewr dg df ewwr ewwr et ruyut utut dfg fgd gdfgt etg dfgt dfgd ert4 gd fgg wr 235 wer3 we vsdf sdf gdf ert xcv sdf rwer hfd dfg cvb rwf afb dfh jgh bmn lgh rty gfds cxv xcv xcs vdas fdf fgd cv sdf tert sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf shasha9178 shasha9178 shasha9178 shasha9178 shasha9178 liflif2 liflif2 liflif2 liflif2 liflif2 liblib3 liblib3 liblib3 liblib3 liblib3 zhazha444 zhazha444 zhazha444 zhazha444 zhazha444 dende5 dende denden denden2 denden21 fenfen9 fenf619 fen619 fenfe9 fe619 sdf sdf sdf sdf sdf zhazh90 zhazh0 zhaa50 zha90 zh590 zho zhoz zhozh zhozho zhozho2 lislis lls95 lili95 lils5 liss9 sdf0ty987 sdft876 sdft9876 sdf09876 sd0t9876 sdf0ty98 sdf0976 sdf0ty986 sdf0ty96 sdf0t76 sdf0876 df0ty98 sf0t876 sd0ty76 sdy76 sdf76 sdf0t76 sdf0ty9 sdf0ty98 sdf0ty987 sdf0ty98 sdf6676 sdf876 sd876 sd876 sdf6 sdf6 sdf9876 sdf0t sdf06 sdf0ty9776 sdf0ty9776 sdf0ty76 sdf8876 sdf0t sd6 sdf06 s688876 sd688 sdf86