使用示例
- 安装并运行事务管理器
bash
go install github.com/dtm-labs/[email protected]
nohup dtm
- 创建全局事务控制表和业务表
sql
CREATE DATABASE IF NOT EXISTS `dtm_barrier`;
USE `dtm_barrier`;
DROP TABLE IF EXISTS `barrier`;
CREATE TABLE `barrier` (
`id` bigint NOT NULL AUTO_INCREMENT,
`trans_type` varchar(45) DEFAULT '',
`gid` varchar(128) DEFAULT '',
`branch_id` varchar(128) DEFAULT '',
`op` varchar(45) DEFAULT '',
`barrier_id` varchar(45) DEFAULT '',
`reason` varchar(45) DEFAULT '' COMMENT 'the branch type who insert this record',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `gid` (`gid`,`branch_id`,`op`,`barrier_id`),
KEY `create_time` (`create_time`),
KEY `update_time` (`update_time`)
) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
sql
CREATE DATABASE IF NOT EXISTS `dtm_busi`;
USE `dtm_busi`;
DROP TABLE IF EXISTS `user_account`;
CREATE TABLE `user_account` (
`id` int NOT NULL AUTO_INCREMENT,
`user_id` int NOT NULL,
`balance` decimal(10,2) NOT NULL DEFAULT '0.00',
`trading_balance` decimal(10,2) NOT NULL DEFAULT '0.00',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `user_id` (`user_id`)
) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
INSERT INTO `user_account` VALUES (1,1,6.00,0.00,'2023-09-18 16:13:36','2023-09-18 16:13:36');
INSERT INTO `user_account` VALUES (2,2,6.00,0.00,'2023-09-18 16:14:51','2023-09-18 16:14:51');
XA(两阶段提交)
- 第一阶段:每个服务模块开启本地事务并执行sql
- 第二阶段:如果所有服务本地sql执行成功,则全提交事务,否则全回滚
go
func main() {
app := gin.Default()
const TransInUID = 2
const TransOutUID = 1
// 注册转入逻辑
app.POST("/TransInXa", dtmutil.WrapHandler2(func(c *gin.Context) interface{} {
return dtmcli.XaLocalTransaction(c.Request.URL.Query(), basic.BusiConf, func(db *sql.DB, xa *dtmcli.Xa) error {
return basic.SagaAdjustBalance(db, TransInUID, basic.ReqFrom(c).Amount, basic.ReqFrom(c).TransInResult)
})
}))
// 注册转出逻辑
app.POST("/TransOutXa", dtmutil.WrapHandler2(func(c *gin.Context) interface{} {
return dtmcli.XaLocalTransaction(c.Request.URL.Query(), basic.BusiConf, func(db *sql.DB, xa *dtmcli.Xa) error {
return basic.SagaAdjustBalance(db, TransOutUID, -basic.ReqFrom(c).Amount, basic.ReqFrom(c).TransOutResult)
})
}))
app.Run(":8080")
}
func SagaAdjustBalance(db dtmcli.DB, uid int, amount int, result string) error {
if strings.Contains(result, dtmcli.ResultFailure) {
return dtmcli.ErrFailure
}
_, err := dtmimp.DBExec(BusiConf.Driver, db, "update dtm_busi.user_account set balance = balance + ? where user_id = ?", amount, uid)
return err
}
go
func main() {
gid := dtmcli.MustGenGid(dtmutil.DefaultHTTPServer)
req := ReqHTTP{Amount: 1}
host := "http://localhost:8080"
// 注册全局事务
err := dtmcli.XaGlobalTransaction(dtmutil.DefaultHTTPServer, gid, func(xa *dtmcli.Xa) (*resty.Response, error) {
resp, err := xa.CallBranch(req, host+"/TransOutXa")
if err != nil {
return resp, err
}
return xa.CallBranch(req, host+"/TransInXa")
})
if err != nil {
log.Fatalln(err)
}
}
优点:所有分布式事务的解决方案中,一致性是最好的
缺点:由于需要长时间开启本地事务,性能低
两阶段消息
假如A和B两个操作需要保证在同一个事务中,且B操作一定会执行成功
我们可以先执行A操作,再根据A操作的执行结果决定是否执行b操作
go
func main() {
r := gin.Default()
const TransInUID = 2
r.POST("/SagaBTransIn", dtmutil.WrapHandler(func(c *gin.Context) interface{} {
barrier := basic.MustBarrierFromGin(c)
return barrier.CallWithDB(basic.PdbGet(), func(tx *sql.Tx) error {
return basic.SagaAdjustBalance(tx, TransInUID, basic.ReqFrom(c).Amount, basic.ReqFrom(c).TransInResult)
})
}))
r.GET("/QueryPreparedB", dtmutil.WrapHandler(func(c *gin.Context) interface{} {
logger.Debugf("%s QueryPreparedB", c.Query("gid"))
bb := basic.MustBarrierFromGin(c)
db := dtmutil.DbGet(dtmimp.DBConf(basic.BusiConf)).ToSQLDB()
return bb.QueryPrepared(db)
}))
r.Run(":8080")
}
go
func main() {
gid := shortuuid.New()
req := busi.GenReqHTTP(1, false, false)
msg := dtmcli.NewMsg(DtmServer, gid).
Add("http://localhost:8080/SagaBTransIn", req)
err := msg.DoAndSubmitDB(busi.Busi+"/QueryPreparedB", dtmutil.DbGet(BusiConf).ToSQLDB(), func(tx *sql.Tx) error {
return SagaAdjustBalance(tx, busi.TransOutUID, -req.Amount, "SUCCESS")
})
logger.FatalIfError(err)
}
func SagaAdjustBalance(db dtmcli.DB, uid int, amount int, result string) error {
if strings.Contains(result, dtmcli.ResultFailure) {
return dtmcli.ErrFailure
}
_, err := dtmimp.DBExec(BusiConf.Driver, db, "update dtm_busi.user_account set balance = balance + ? where user_id = ?", amount, uid)
return err
}
优点:和XA相反,不需要长时间的本地事务,性能较高
缺点:使用场景有局限,各子事务中只允许有一个子事务可以失败,其他的必须保证成功
TCC
在业务表中预留一个尝试字段,tcc分为3步执行。
- Try 阶段:尝试执行,完成所有业务检查(一致性), 预留必须业务资源(准隔离性)
- Confirm 阶段:如果所有分支的Try都成功了,则走到Confirm阶段。Confirm真正执行业务,不作任何业务检查,只使用 Try 阶段预留的业务资源。(此阶段必须成功)
- Cancel 阶段:如果所有分支的Try有一个失败了,则走到Cancel阶段。Cancel释放 Try 阶段预留的业务资源。(此阶段必须成功)
go
func main() {
app := gin.Default()
const TransInUID = 2
const TransOutUID = 1
app.POST("/TccBTransOutTry", dtmutil.WrapHandler2(func(c *gin.Context) interface{} {
bb := basic.MustBarrierFromGin(c)
return bb.Call(basic.TxGet(), func(tx *sql.Tx) error {
return tccAdjustTrading(tx, TransOutUID, -basic.ReqFrom(c).Amount)
})
}))
app.POST("/TccBTransOutConfirm", dtmutil.WrapHandler2(func(c *gin.Context) interface{} {
bb := basic.MustBarrierFromGin(c)
return bb.Call(basic.TxGet(), func(tx *sql.Tx) error {
return tccAdjustBalance(tx, TransOutUID, -basic.ReqFrom(c).Amount)
})
}))
app.POST("/TccBTransOutCancel", dtmutil.WrapHandler2(func(c *gin.Context) interface{} {
bb := basic.MustBarrierFromGin(c)
return bb.Call(basic.TxGet(), func(tx *sql.Tx) error {
return tccAdjustTrading(tx, TransOutUID, basic.ReqFrom(c).Amount)
})
}))
app.POST("/TccBTransInTry", dtmutil.WrapHandler2(func(c *gin.Context) interface{} {
bb := basic.MustBarrierFromGin(c)
return bb.Call(basic.TxGet(), func(tx *sql.Tx) error {
return tccAdjustTrading(tx, TransInUID, basic.ReqFrom(c).Amount)
})
}))
app.POST("/TccBTransInConfirm", dtmutil.WrapHandler2(func(c *gin.Context) interface{} {
bb := basic.MustBarrierFromGin(c)
return bb.Call(basic.TxGet(), func(tx *sql.Tx) error {
return tccAdjustBalance(tx, TransInUID, basic.ReqFrom(c).Amount)
})
}))
app.POST("/TccBTransInCancel", dtmutil.WrapHandler2(func(c *gin.Context) interface{} {
bb := basic.MustBarrierFromGin(c)
return bb.Call(basic.TxGet(), func(tx *sql.Tx) error {
return tccAdjustTrading(tx, TransInUID, -basic.ReqFrom(c).Amount)
})
}))
app.Run(":8080")
}
func tccAdjustTrading(db dtmcli.DB, uid int, amount int) error {
affected, err := dtmimp.DBExec("mysql", db, `update dtm_busi.user_account
set trading_balance=trading_balance+?
where user_id=? and trading_balance + ? + balance >= 0`, amount, uid, amount)
if err == nil && affected == 0 {
return fmt.Errorf("update error, maybe balance not enough")
}
return err
}
func tccAdjustBalance(db dtmcli.DB, uid int, amount int) error {
affected, err := dtmimp.DBExec("mysql", db, `update dtm_busi.user_account
set trading_balance=trading_balance-?,
balance=balance+? where user_id=?`, amount, amount, uid)
if err == nil && affected == 0 {
return fmt.Errorf("update user_account 0 rows")
}
return err
}
go
func main() {
host := "http://localhost:8080"
gid := shortuuid.New()
// TccGlobalTransaction 会开启一个全局事务
err := dtmcli.TccGlobalTransaction(DtmServer, gid, func(tcc *dtmcli.Tcc) (*resty.Response, error) {
// CallBranch 会将事务分支的Confirm/Cancel注册到全局事务上,然后直接调用Try
req := ReqHTTP{Amount: 1}
res1, rerr := tcc.CallBranch(req, host+"/TccBTransOutTry", host+"/TccBTransOutConfirm", host+"/TccBTransOutCancel")
if rerr != nil {
return res1, rerr
}
return tcc.CallBranch(req, host+"/TccBTransInTry", host+"/TccBTransInConfirm", host+"/TccBTransInCancel")
})
if err != nil {
log.Fatalln(err)
}
}
优点:try过程使用预留字段修改,不破坏原数据
局限:必须保证confirm和cancel操作可以被正确执行
SAGA
定义本地操作时同时定义相反的补偿操作在事务管理器
若存在执行失败的操作,则对执行成功的操作进行补偿
go
func main() {
app := gin.Default()
const TransInUID = 2
const TransOutUID = 1
app.POST("/SagaBTransIn", dtmutil.WrapHandler2(func(c *gin.Context) interface{} {
barrier := basic.MustBarrierFromGin(c)
return barrier.Call(basic.TxGet(), func(tx *sql.Tx) error {
err := basic.SagaAdjustBalance(tx, TransInUID, basic.ReqFrom(c).Amount, "")
if err != nil {
return dtmcli.ErrFailure
}
return nil
})
}))
app.POST("/SagaBTransInCom", dtmutil.WrapHandler2(func(c *gin.Context) interface{} {
barrier := basic.MustBarrierFromGin(c)
return barrier.Call(basic.TxGet(), func(tx *sql.Tx) error {
return basic.SagaAdjustBalance(tx, TransInUID, -basic.ReqFrom(c).Amount, "")
})
}))
app.POST("/SagaBTransOut", dtmutil.WrapHandler2(func(c *gin.Context) interface{} {
barrier := basic.MustBarrierFromGin(c)
return barrier.Call(basic.TxGet(), func(tx *sql.Tx) error {
return basic.SagaAdjustBalance(tx, TransOutUID, -basic.ReqFrom(c).Amount, "")
})
}))
app.POST("/SagaBTransOutCom", dtmutil.WrapHandler2(func(c *gin.Context) interface{} {
barrier := basic.MustBarrierFromGin(c)
return barrier.Call(basic.TxGet(), func(tx *sql.Tx) error {
return basic.SagaAdjustBalance(tx, TransOutUID, basic.ReqFrom(c).Amount, "")
})
}))
app.Run(":8080")
}
func SagaAdjustBalance(db dtmcli.DB, uid int, amount int, result string) error {
if strings.Contains(result, dtmcli.ResultFailure) {
return dtmcli.ErrFailure
}
_, err := dtmimp.DBExec(BusiConf.Driver, db, "update dtm_busi.user_account set balance = balance + ? where user_id = ?", amount, uid)
return err
}
go
func main() {
req := &gin.H{"amount": 1}
// DtmServer为DTM服务的地址
saga := dtmcli.NewSaga(DtmServer, shortuuid.New()).
// 添加一个TransOut的子事务,正向操作为: "/TransOut", 逆向操作为: "/TransOutCom"
Add("http://localhost:8080/SagaBTransOut", "http://localhost:8080/SagaBTransOutCom", req).
// 添加一个TransIn的子事务,正向操作为: "/TransOut", 逆向操作为: "/TransInCom"
Add("http://localhost:8080/SagaBTransIn", "http://localhost:8080/SagaBTransInCom", req)
// 提交saga事务,dtm会完成所有的子事务/回滚所有的子事务
err := saga.Submit()
if err != nil {
log.Fatalln(err)
}
}
和tcc相似,saga模式必须保证补偿操作可以被正确执行
异常与子事务屏障
分布式事务之所以难,主要是因为分布式系统中的各个节点都可能发生各种非预期的情况。本文先介绍分布式系统中的异常问题,然后介绍这些问题带给分布式事务的挑战,接下来指出现有各种常见用法的问题,最后给出正确的方案。
NPC的挑战
分布式系统最大的敌人可能就是NPC了,在这里它是Network Delay, Process Pause, Clock Drift的首字母缩写。我们先看看具体的NPC问题是什么:
- Network Delay,网络延迟。虽然网络在多数情况下工作的还可以,虽然TCP保证传输顺序和不会丢失,但它无法消除网络延迟问题。
- Process Pause,进程暂停。有很多种原因可以导致进程暂停:比如编程语言中的GC(垃圾回收机制)会暂停所有正在运行的线程;再比如,我们有时会暂停云服务器,从而可以在不重启的情况下将云服务器从一台主机迁移到另一台主机。我们无法确定性预测进程暂停的时长,你以为持续几百毫秒已经很长了,但实际上持续数分钟之久进程暂停并不罕见。
- Clock Drift,时钟漂移。现实生活中我们通常认为时间是平稳流逝,单调递增的,但在计算机中不是。计算机使用时钟硬件计时,通常是石英钟,计时精度有限,同时受机器温度影响。为了在一定程度上同步网络上多个机器之间的时间,通常使用NTP协议将本地设备的时间与专门的时间服务器对齐,这样做的一个直接结果是设备的本地时间可能会突然向前或向后跳跃。
分布式事务既然是分布式的系统,自然也有NPC问题。因为没有涉及时间戳,带来的困扰主要是NP。
异常分类
我们以分布式事务中的TCC作为例子,看看NP带来的影响。
一般情况下,一个TCC回滚时的执行顺序是,先执行完Try,再执行Cancel,但是由于N,则有可能Try的网络延迟大,导致先执行Cancel,再执行Try。
这种情况就引入了分布式事务中的两个难题:
- 空补偿: Cancel执行时,Try未执行,事务分支的Cancel操作需要判断出Try未执行,这时需要忽略Cancel中的业务数据更新,直接返回
- 悬挂: Try执行时,Cancel已执行完成,事务分支的Try操作需要判断出Cancel已执行,这时需要忽略Try中的业务数据更新,直接返回
分布式事务还有一类需要处理的常见问题,就是重复请求 - 幂等: 由于任何一个请求都可能出现网络异常,出现重复请求,所有的分布式事务分支操作,都需要保证幂等性
因为空补偿、悬挂、重复请求都跟NP有关,我们把他们统称为子事务乱序问题。在业务处理中,需要小心处理好这三种问题,否则会出现错误数据。
异常原因
下面看一个网络异常的时序图,更好的理解上述几种问题
- 业务处理请求4的时候,Cancel在Try之前执行,需要处理空回滚
- 业务处理请求6的时候,Cancel重复执行,需要幂等
- 业务处理请求8的时候,Try在Cancel后执行,需要处理悬挂
现有方案的问题
我们看到开源项目dtm之外,包括各云厂商,各开源项目,他们给出的业务实现建议大多类似如下(这也是大多数用户最容易想到的方案):
- 空补偿: "针对该问题,在服务设计时,需要允许空补偿,即在没有找到要补偿的业务主键时,返回补偿成功,并将原业务主键记录下来,标记该业务流水已补偿成功。"
- 防悬挂: "需要检查当前业务主键是否已经在空补偿记录下来的业务主键中存在,如果存在则要拒绝执行该笔服务,以免造成数据不一致。"
上述的这种实现,能够在大部分情况下正常运行,但是上述做法中的"先查后改"在并发情况下是容易掉坑里的,我们分析以下如下场景:
- 正常执行顺序下,Try执行时,在查完没有空补偿记录的业务主键之后,事务提交之前,如果发生了进程暂停P,或者事务内部进行网络请求出现了拥塞,导致本地事务等待较久
- 全局事务超时后,Cancel执行,因为没有查到要补偿的业务主键,因此判断是空补偿,返回
- Try的进程暂停结束,最后提交本地事务
- 全局事务回滚完成后,Try分支的业务操作没有被回滚,产生了悬挂
事实上,NPC里的P和C,以及P和C的组合,有很多种的场景,都可以导致上述竞态情况,就不一一赘述了。
虽然这种情况发生的概率不高,但是在金融领域,一旦涉及金钱账目,那么带来的影响可能是巨大的。
PS:幂等控制如果也采用"先查再改",也是一样很容易出现类似的问题。解决这一类问题的关键点是要利用唯一索引,"以改代查"来避免竞态条件。
子事务屏障
在DTM中,首创了子事务屏障技术,使用该技术,能够非常便捷的解决异常问题,极大的降低了分布式事务的使用门槛。
子事务屏障能够达到下面这个效果,看示意图:
所有这些请求,到了子事务屏障后:不正常的请求,会被过滤;正常请求,通过屏障。开发者使用子事务屏障之后,前面所说的各种异常全部被妥善处理,业务开发人员只需要关注实际的业务逻辑,负担大大降低。 子事务屏障提供了方法BranchBarrier.CallWithDB ,方法的原型为:
业务开发人员,在busiCall里面编写自己的相关逻辑,调用 BranchBarrier.CallWithDB 。 BranchBarrier.CallWithDB 保证,在空回滚、悬挂等场景下,busiCall不会被调用;在业务被重复调用时,有幂等控制,保证只被提交一次。
子事务屏障会管理TCC、SAGA、事务消息等,也可以扩展到其他领域
原理
子事务屏障技术的原理是,在本地数据库,建立分支操作状态表dtm_barrier,唯一键为全局事务id-分支id-分支操作(try|confirm|cancel)
- 开启本地事务
- 对于当前操作op(try|confirm|cancel),insert ignore一条数据gid-branchid-op,如果插入不成功,提交事务返回成功(常见的幂等控制方法)
- 如果当前操作是cancel,那么在insert ignore一条数据gid-branchid-try,如果插入成功(注意是成功),则提交事务返回成功
- 调用屏障内的业务逻辑,如果业务返回成功,则提交事务返回成功;如果业务返回失败,则回滚事务返回失败
在此机制下,解决了乱序相关的问题
- 空补偿控制--如果Try没有执行,直接执行了Cancel,那么3中Cancel插入gid-branchid-try会成功,不走屏障内的逻辑,保证了空补偿控制
- 幂等控制--2中任何一个操作都无法重复插入唯一键,保证了不会重复执行
- 防悬挂控制--Try在Cancel之后执行,那么Cancel会在3中插入gid-branchid-try,导致Try在2中不成功,就不执行屏障内的逻辑,保证了防悬挂控制
对于SAGA、二阶段消息,也是类似的机制。
原理图解(可选读)
下面我们以图的方式来详解子事务屏障,因为Confirm操作不涉及空补偿和悬挂,所以重点看Try与Cancel,Try对应图中的A,Cancel对应图中的C:
子事务屏障中对应的幂等处理部分:
这部分就是常规的幂等处理部分,往数据库中插入一个唯一键,如果是重复请求,那么插入失败,直接失败返回。
子事务屏障技术就是在上述的幂等处理部分,添加一个步骤--补偿服务再插入一条A记录,正常流程下,会因为唯一键冲突导致插入失败,往下执行业务。
当发生乱序,假设C在A前面执行,那么会发生下面的时序图:
- 对于C操作,他先于A执行,是一个空补偿;此时C操作插入A记录时,发现插入成功,直接返回
- 对于A操作,他在C之后执行,是一个悬挂;此时A操作插入A记录时,发现插入失败,直接返回
这两种情况都会被子事务屏障拦截返回,而不执行内部的业务操作。可以看到子事务屏障非常巧妙的解决了幂等、空补偿和悬挂三个问题。
竞态分析
上面分析了Try和Cancel的执行时间没有重叠的情况下,能够解决空补偿和悬挂问题。如果出现了Try和Cancel执行时间重叠的情况,我们看看会发生什么。
假设Try和Cancel并发执行,Cancel和Try都会插入同一条记录gid-branchid-try,由于唯一索引冲突,那么两个操作中只有一个能够成功,而另一个则会等持有锁的事务完成后返回。
- 情况1,Try插入gid-branchid-try失败,Cancel操作插入gid-branchid-try成功,此时就是典型的空补偿和悬挂场景,按照子事务屏障算法,Try和Cancel都会直接返回
- 情况2,Try插入gid-branchid-try成功,Cancel操作插入gid-branchid-try失败,按照上述子事务屏障算法,会正常执行业务,而且业务执行的顺序是Try在Cancel前
- 情况3,Try和Cancel的操作在重叠期间又遇见宕机等情况,那么至少Cancel会被dtm重试,那么最终会走到情况1或2。
综上各种情况的详细论述,子事务屏障能够在各种NP情况下,保证最终结果的正确性。
优点
事实上,子事务屏障有大量优点,包括:
- 两个insert判断解决空补偿、防悬挂、幂等这三个问题,比其他方案的三种情况分别判断,逻辑复杂度大幅降低
- dtm的子事务屏障是SDK层解决这三个问题,业务完全不需要关心
- 性能高,对于正常完成的事务(一般失败的事务不超过1%),子事务屏障的额外开销是每个分支操作一个SQL,比其他方案代价更小。
支持的存储
目前子事务屏障已经支持了
- 数据库:包括 Mysql, Postgres, 以及与Mysql,Postgres兼容的数据库
- 缓存 Redis:采用 Lua 脚本事务支持
- Mongo:采用 Mongo 的事务支持
在子事务屏障的支持下,您可以将Redis、Mongo和数据库的事务组合在一起,形成一个全局事务。相关用法,可以在dtm-examples里面找到
总结
- 二阶段消息模式: 适合不需要回滚的场景
- saga模式: 适合需要回滚的场景
- tcc事务模式: 适合一致性要求较高的场景
- xa事务模式: 适合并发要求不高,没有数据库行锁争抢的场景