上篇赘述了好多,无非就是想把tcc分布式事务的流程给讲清楚,并介绍了它与另外两种常用的分布式事务“可靠消息队列”,“saga”的区别和适用场景。
那接下来就引出我们的主角“dtm”吧,它类似于阿里的分布式事务框架seata,可惜由于seata的语言局限性,让我们.neter们“望而却步”,但更可怜的是我们.net自己的生态圈中竟然找不到能使用的类似的框架或中间件。眼看着自己设计好的新的下单流程因为没有能够支撑业务的中间件而流产的时候,突然收到群友的消息,扔给我一个dtm项目的链接,说可以看看。在这里我很感谢群友发给我的这个信息,让我找到了能把订单改造继续做下去的信心。下面的篇幅针对我在dtm试用过程中遇到的问题,和一些细节做个记录。
对于dtm这个跨语言的tcc分布式事务中间件的介绍,官网很多篇文章,循序渐进的看下来已经很清楚了,官网地址:https://dtm.pub/。
dtm官方推荐的c# sdk客户端,是由张善友大佬提供的,地址是:https://github.com/yedf/dtmcli-csharp
,对应的demo地址:https://github.com/yedf/dtmcli-csharp-sample。张队长“人狠话不多”,sdk和例子都提供了,但就是没有使用的细节文档,潜台词就是结合着例子自己看源码吧。sdk源码还是比较简单的,运行示例调试,并结合dtm服务端的数据库,最终掌握dtm的使用还是不难的。只不过初次使用还是会遇到各种各样的问题的。
我的部署环境是centos7.6和docker20.10,原始的docker-compose.yml内容如下:
其中,红线框起来的部分原版是没有的,因为使用中遇到了问题,通过跟作者的沟通请教,找到问题后加上去的。
1、 红框一的作用:让容器内的时间和时区与宿主机使用的一致,如果不加,会导致mysql容器使用了北京时间,而dtm服务程序使用的确是utc时间,相差8个小时,这样会造成dtm服务端在管理事务时,因为时间混乱而判断和查询失误,无法完成事务的正确调度。
2、红框二的作用:很明显是为了dtm服务端和mysql之间互联的需要。
备注:作者说他在ubuntu下不需要加红框部分也可以的,但centos7.6是需要的。
实际上,使用上述改过后的docker-compose.yml启动容器后,还是有问题,dtm服务程序会闪退,报错如下:
看报错应该是dtm没有连上数据库,在这里说一下作者本人真的是很负责,帮我看了下原因说应该是mysql还未启动就绪,dtm就开始创建库导致的,让我等mysql容器就绪后再启动dtm容器试试。经过作者的指点,我把退出的dtm容器,用docker start重新手动启动后,终于mysql上相应的库被dtm服务程序创建,而dtm再也不会报错退出了,环境至此搭建完毕。
dtm服务端环境搭建完毕后,接下来我们就可以运行c#示例看效果啦。
dtm服务的注册:
public void ConfigureServices(IServiceCollection services) { services.AddDtmcli(dtm => dtm.DtmUrl = "http://192.168.1.244:8080"); services.AddControllers(); services.AddSwaggerGen(c => { c.SwaggerDoc("v1", new OpenApiInfo { Title = "DtmTccSample", Version = "v1" }); }); }
很简单,只需要一句话:services.AddDtmcli(dtm => dtm.DtmUrl = "http://192.168.1.244:8080");,其中DtmUrl就是dtm服务端的地址。
sdk的介绍文档和示例中只有如下一段代码:
首先要说的是,示例代码能够运行没有问题,但只是try阶段执行成功的情况下,子事务顺利提交的正常场景。
如果要测试try阶段不成功或异常,子事务应该回滚,示例代码要这样写:
1 [HttpPost] 2 public async Task<RestResult> Demo() 3 { 4 var svc = "http://172.16.2.14:5000/api"; 5 TransRequest request = new TransRequest() { Amount = 30 }; 6 var cts = new CancellationTokenSource(); 7 try 8 { 9 var res1 = string.Empty; 10 var res2 = string.Empty; 11 var isTryOk = true; 12 await globalTransaction.Excecute(async (tcc) => 13 { 14 res1 = await tcc.CallBranch(request, svc + "/TransOut/Try", svc + "/TransOut/Confirm", svc + "/TransOut/Cancel", cts.Token); 15 res2 = await tcc.CallBranch(request, svc + "/TransIn/Try", svc + "/TransIn/Confirm", svc + "/TransIn/Cancel",cts.Token); 16 17 if (!res1.Contains("SUCCESS") || !res2.Contains("SUCCESS")) 18 { 19 //异常日志 20 logger.LogError($"{res1}{res2}"); 21 //抛出异常:目的是让sdk客户端捕获异常,通知dtm服务端,try阶段遇到了异常,所有子事务回滚 22 throw new AccessViolationException($"{res1}{res2}"); 23 } 24 logger.LogInformation($"tcc returns: {res1}-{res2}"); 25 }, cts.Token); 26 if (res1.Contains("SUCCESS") || res2.Contains("SUCCESS")) 27 { 28 logger.LogError($"{res1}{res2}"); 29 return new RestResult() { Result = "FAILURE" }; 30 } 31 } 32 catch(Exception ex) 33 { 34 35 } 36 return new RestResult() { Result = "SUCCESS" }; 37 }
实际上,核心代码是17-23行,要想将try阶段的失败告知dtm,我们要做的就是捕获所有子事务try阶段的返回值,并判断是否有任何一个返回值不是我们想要的(例子中子事务的返回值是res1和res2),如果返回值不正确,就在 globalTransaction.Excecute方法内抛出一个异常。抛出去的异常会被sdk客户端捕获,并告知dtm应该对子事务进行回滚了,具体源码如下:
1 public async Task<string> Excecute(Func<Tcc,Task> tcc_cb, CancellationToken cancellationToken =default) 2 { 3 var tcc = new Tcc(this.dtmClient, await this.GenGid()); 4 5 var tbody = new TccBody 6 { 7 Gid = tcc.Gid, 8 Trans_Type ="tcc" 9 }; 10 11 12 try 13 { 14 await dtmClient.TccPrepare(tbody, cancellationToken); 15 16 await tcc_cb(tcc); 17 18 await dtmClient.TccSubmit(tbody, cancellationToken); 19 } 20 catch(Exception ex) 21 { 22 logger.LogError(ex,"submitting or abort global transaction error"); 23 await this.dtmClient.TccAbort(tbody, cancellationToken); 24 return string.Empty; 25 } 26 return tcc.Gid; 27 }
catch块中,this.dtmClient.TccAbort就是去告知dtm,应该回滚子事务。
try阶段执行成功,所有子事务会执行Confirm的接口,如果try阶段失败,则所有子事务会执行Cancel阶段的接口。如果Confirm或Cancel阶段有任何子事务执行失败,dtm则会不断地去重试这个子事务,直至成功为止,所以Confirm或Cancel阶段,进行“最大努力交付”,没有回头路可走。
这里要说明一点的是,Confirm或Cancel子事务的执行是按顺序的,如果有哪个子事务因为执行失败而不断的重试,那么其余未执行的子事务也会被它阻塞而无法执行。
重试第一次会间隔10s,后续的重试时间都会翻倍,20 40 80 160这样子的间隔。
如果子事务在try阶段是按照ab的顺序执行,那么cancel阶段则是按照ba的顺序执行。
dtm的日志还是很详细的,包括对事务接口的调用还有数据库的执行以及错误异常,容器内查看dtm日志的命令:
docker container logs dtm-api-1 --follow
tcc事务设计的表如下图:
其中trans_global是全局事务表,trans_branch是分支子事务表。注释也都一目了然,sql如下:
1 CREATE TABLE `trans_global` ( 2 `id` int(11) NOT NULL AUTO_INCREMENT, 3 `gid` varchar(128) NOT NULL COMMENT '事务全局id', 4 `trans_type` varchar(45) NOT NULL COMMENT '事务类型: saga | xa | tcc | msg', 5 `status` varchar(12) NOT NULL COMMENT '全局事务的状态 prepared | submitted | aborting | finished | rollbacked', 6 `query_prepared` varchar(128) NOT NULL COMMENT 'prepared状态事务的查询api', 7 `protocol` varchar(45) NOT NULL COMMENT '通信协议 http | grpc', 8 `create_time` datetime DEFAULT NULL, 9 `update_time` datetime DEFAULT NULL, 10 `commit_time` datetime DEFAULT NULL, 11 `finish_time` datetime DEFAULT NULL, 12 `rollback_time` datetime DEFAULT NULL, 13 `next_cron_interval` int(11) DEFAULT NULL COMMENT '下次定时处理的间隔', 14 `next_cron_time` datetime DEFAULT NULL COMMENT '下次定时处理的时间', 15 `owner` varchar(128) NOT NULL DEFAULT '' COMMENT '正在处理全局事务的锁定者', 16 PRIMARY KEY (`id`), 17 UNIQUE KEY `gid` (`gid`), 18 KEY `owner` (`owner`), 19 KEY `create_time` (`create_time`), 20 KEY `update_time` (`update_time`), 21 KEY `status_next_cron_time` (`status`,`next_cron_time`) COMMENT '这个索引用于查询超时的全局事务,能够合理的走索引' 22 ) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4; 23 24 CREATE TABLE `trans_branch` ( 25 `id` int(11) NOT NULL AUTO_INCREMENT, 26 `gid` varchar(128) NOT NULL COMMENT '事务全局id', 27 `url` varchar(128) NOT NULL COMMENT '动作关联的url', 28 `data` text COMMENT '请求所携带的数据', 29 `branch_id` varchar(128) NOT NULL COMMENT '事务分支名称', 30 `branch_type` varchar(45) NOT NULL COMMENT '事务分支类型 saga_action | saga_compensate | xa', 31 `status` varchar(45) NOT NULL COMMENT '步骤的状态 submitted | finished | rollbacked', 32 `finish_time` datetime DEFAULT NULL, 33 `rollback_time` datetime DEFAULT NULL, 34 `create_time` datetime DEFAULT NULL, 35 `update_time` datetime DEFAULT NULL, 36 PRIMARY KEY (`id`), 37 UNIQUE KEY `gid_uniq` (`gid`,`branch_id`,`branch_type`), 38 KEY `create_time` (`create_time`), 39 KEY `update_time` (`update_time`) 40 ) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4;
最后附上dtm c#沟通微信群,有问题可以一起来讨论: