概述跨服务更新数据是应用程序开发中的常见任务。如果一些关键数据对一致性要求高,业务不需要支持回滚,那么通常使用本地消息表来保证最终一致性。很多公司在处理跨服务更新数据一致性问题时,会先引入本地消息表,然后随着业务场景越来越复杂,引入更多的事务模式。本文提出的二阶消息是一种新模式、新架构,优雅的解决了消息最终一致性的问题。解决同样的问题,本地消息表或交易消息中的数百行代码可以简化为五六行左右,大大简化了架构,提高了开发效率,具有很大的优势。下面以跨行转账为例,详细解释一下这种新结构。业务场景如下:我们需要从A跨行向B转账30元。我们先进行可能会失败的TransOut操作,即扣A的30元,如果A因为余额不足扣不成功,那么转账直接失败,返回错误;如果扣款成功,则进行下一次转入操作,因为转入操作不存在余额不足的问题,可以认为转入操作会成功。使用新架构开发新架构基于分布式事务管理器dtm-labs/dtm,完成上述任务的核心代码如下:msg:=dtmcli.NewMsg(DtmServer,gid).Add(busi.Busi+"/TransIn",&TransReq{Amount:30})err:=msg.DoAndSubmitDB(busi.Busi+"/QueryPreparedB",db,func(tx*sql.Tx)error{returnbusi.SagaAdjustBalance(tx,busi.TransOutUID,-req.Amount,"SUCCESS")})以上代码为HTTP访问。gRPC的访问与HTTP基本相同,这里不再赘述。需要的读者可以参考dtm-labs/dtm-examples中的例子。这部分代码中,一个DTM的msg全局事务,通过dtm服务器地址和全局事务id给msg添加一个分支业务逻辑,这里的业务逻辑是余额转账操作TransIn,然后把这个服务的数据带上需要转账,金额30元,然后调用msgDoAndSubmitDB,该函数保证业务成功执行和msg全局事务的提交,要么同时成功,要么同时失败。第一个参数是核对URL。详细含义将在后面描述。第二个参数是sql.DB,是业务访问的数据库对象的第三个参数是业务函数。我们例子中的业务是从A的余额中扣除30元,由于当前TransOut业务操作和TransIn不再是同一个服务,可能会出现一个操作执行完后发生进程崩溃,导致另一个操作发生没有被执行。此时dtm会通过回溯URL查询TransOut的业务操作是否成功完成。dtm中的审核只需要粘贴如下代码,框架会自动完成审核逻辑:app.GET(BusiAPI+"/QueryPreparedB",dtmutil.WrapHandler2(func(c*gin.Context)interface{}{returnMustBarrierFromGin(c).QueryPrepared(dbGet())}))至此完成了一个完整的二阶段消息业务,访问复杂度和代码量相对于本地消息表等现有方案具有巨大优势,已经成为这样问题首选。您可以使用以下命令运行完整示例:rundtmgitclonehttps://github.com/dtm-labs/dtm&&cddtmgorunmain.godtm-examples&&cddtm-examplesgorunmain.gohttp_msg_doAndCommit成功流程PrepareAndSubmit如何保证业务执行成功和msg提交之间的原子性?请看下面的时序图:一般情况下,时序图中的五个步骤会正常完成,整个业务会按预期进行,完成全局事务。这里有一个新的内容需要说明一下,就是分两个阶段发起msg的提交。第一阶段调用Prepare,第二阶段调用Commit。DTM收到Prepare调用后,不会调用分支事务,而是等待后续的Submit。只有收到Submit,才开始分支调用,最终完成全局事务。提交后的宕机过程在分布式系统中,需要考虑各种宕机和网络异常。我们看一下可能出现的问题:首先,我们最需要达到的目标是业务成功执行,而msg事务是一个原子操作,所以首先,如果业务执行完后出现宕机怎么办已提交但在提交消息发送之前?新架构如何保证原子性?我们来看一下这个案例的时序图:本地事务提交之后,Submit发送之前,进程崩溃或者机器崩溃了怎么办?此时DTM会取出已经准备好但在一定超时后还没有提交的msg事务,调用msg事务指定的checkback服务。你的checkback服务逻辑不需要手动写,你只需要按照之前给的代码调用它,它会在表中查询,本地交易是否已经提交:已提交:返回成功,dtm进行下一步sub-transaction调用已回滚:返回失败,dtm终止全局事务,不再子事务调用进行中:本次review会等待最终结果,然后按照之前commit前处理downtime流程提交/回滚的情况我们来看看本地事务回滚的时序图:如果AP在dtm收到Prepare调用后事务提交前遇到故障宕机,那么数据库会检测到连接AP断开连接,自动回滚本地事务。后续dtmpolling取出全局的超时事务,只Prepare不Submit,回头检查。checkback服务发现本地事务已经回滚,将结果返回给dtm。dtm收到回滚结果后,将全局事务标记为失败,结束全局事务。易用性新架构用于处理一致性问题。只需要定义本地业务逻辑,指定下一步要处理的服务,然后定义QueryPrepared处理服务,将示例代码复制粘贴即可。那么让我们看看其他的解决方案。二阶消息与本地消息表。以上问题也可以使用本地消息表方案(详见分布式事务最经典的七大方案)来保证数据的最终一致性。如果使用本地消息表,需要做的工作包括:在本地事务中执行本地业务逻辑,将消息插入消息表最后提交轮询任务,将本地消息表的消息发送到消息队列进行消费消息,并将消息发送给相应的处理服务与两者相比,二阶消息有以下优点:无需学习或维护任何消息队列无需处理轮询任务无需消费消息二阶-ordermessagevstransactionmessage以上问题也可以使用RocketMQ的事务消息方案(方案详见分布式事务最经典的七大方案)来保证数据的最终一致性。如果使用本地消息表,需要做的工作包括:如果使用事务消息,需要做的工作包括:启动本地事务,发送半消息,提交事务,发送提交消息消费超时的半消息,并查询收到本地数据库的超时半消息,然后Commit/Rollback消费提交的消息,并将消息发送给处理服务两者相比,二阶消息有以下优点:无需学习或维护本地事务和消息队列中发送消息之间的任何复杂操作手动处理,如果不小心,可能会出现错误。二阶消息是全自动处理,不需要消费消息。二阶消息在双阶段提交方面类似于RocketMQ的事务消息。它是受RocketMQ事务消息启发的一种新架构。二阶消息的名称不再复用RocketMQ的事务消息,主要是因为二阶消息在架构上有很大的变化,另一方面在分布式事务的上下文中,名称“事务消息”",容易造成理解上的混乱。更多优势相比于上面描述的队列方案,二阶消息还有很多额外的优势:二阶消息整个暴露的接口与队列完全无关,只与实际业务和服务相关调用,对开发者更友好二阶消息不需要考虑消息队列消息堆积等故障,因为二阶消息只依赖dtm,开发者可以认为dtm与系统中其他普通的无状态服务,只依赖于其背后的存储Mysql/Redis。消息队列是异步的,二阶消息支持异步和同步。默认是异步的。只需开启msg.WaitResult=true,即可同步等待下游服务完成二阶消息。它还支持同时指定多个下游服务。二阶消息的未来期待二阶消息能够大大降低消息最终一致性解决方案的难度,得到广泛应用。未来dtm会考虑增加后台,允许动态指定下游服务,提供更大的灵活性。如果说你原本是为了服务解耦而使用消息队列,那么这个dtm后台可以让你直接为一个消息指定多个接收函数,而不需要编写消息消费者,带来更简单、更直观、更易用的开发体验。backcheck原理分析backcheck服务出现在前面的时序图和界面中。在二阶消息中,代码是自动复制粘贴的,而RocketMQ的事务消息是手动处理的。那么自动加工的原理是什么?回头查看,先在业务数据库实例中创建一个独立的表,里面存放的是全局事务id。在处理业务交易时,gid会被写入到这张表中。当我们用gid回查的时候,如果在表中能查到gid,就说明本地事务已经提交了,这样我们就可以返回dtm通知本地事务已经提交了。当我们回头用gid查看时,如果表中没有找到gid,说明本地事务还没有提交。这时候有两种可能的结果,一种是事务还在进行中,另一种是事务已经回滚了。查了很多关于RocketMQ的资料,都没有找到有效的解决办法。找到所有结果的解决方案是,如果没有找到结果,什么也不做,等待下一次检查。如果2分钟或更长时间都没有找到check-back,则认为本地事务已经回滚。上面的方案有个很大的问题:两分钟找不到gid,不能认为本地事务回滚了。极端情况下,可能会出现数据库故障(比如进程或磁盘卡住),并持续2分钟以上,最后又重新提交数据,那么这个时候数据并不是最终一致的,人工干预是必需的。如果本地事务已经回滚,但是checkback操作还是会在两分钟以内,按照10s左右的时间间隔连续轮询,会对服务器造成不必要的压力,而dtm的二阶消息解决方案彻底解决了这部分的问题。dtm的二阶消息工作过程如下:在处理本地事务时,会将gid插入到dtm_barrier.barrier表中,并提交插入原因。该表有一个唯一索引,主字段是gid。回查时,二阶消息的操作不是直接检查gid是否存在,而是insertignore一条gid相同的数据,同时把插入原因带回滚。这时,如果表中有gid记录,新的插入操作将被忽略,否则将数据插入。然后使用gid查询表中的记录。如果找到的记录的原因是committed,说明本地事务已经commit;如果查到记录的原因是回滚,说明本地事务已经回滚。那么,相比于RocketMQcheckback常见的解决方案,二阶消息如何区分进行中和回滚呢?诀窍在于检查期间插入的数据。如果进行核查时数据库事务仍在进行中,则插入操作将被正在进行的事务阻塞,因为插入操作将等待事务中持有的锁。如果插入操作正常返回,那么数据库中的本地事务一定已经结束,一定已经提交或者回滚。这里给大家提个问题:二阶消息的操作3可以省略吗,还是可以根据第2步插入是否成功来判断是否已经回滚?欢迎大家留言讨论普通留言。二阶消息不仅可以替代本地消息表方案,还可以替代普通消息方案。如果直接调用Submit,则类似于普通的消息方案,但提供了更加灵活和简单的接口。假设这样一个应用场景,界面上有一个参与事件的按钮。如果您参加该活动,您将获得两本电子书的永久许可。在这种情况下,它可以在这个按钮的服务器中像这样处理:msg:=dtmcli.NewMsg(DtmServer,gid).Add(busi.Busi+"/AuthBook",&Req{UID:1,BookID:5}).Add(busi.Busi+"/AuthBook",&Req{UID:1,BookID:6})err:=msg.Submit()这个方法也提供了一个不依赖消息队列的异步接口。在微服务的很多场景下,原来的异步消息架构是可以被替代的。总结本文提出的二阶消息,界面简洁优雅,带来了比本地消息表和Rocket事务消息更简单的架构,可以帮助大家更好的解决数据一致性无回滚问题。项目地址更多分布式事务的理论知识和实践,可以访问以下项目和公众号:https://github.com/dtm-labs/dtm,欢迎访问,star支持。关注【分布式事务】公众号,获取更多分布式事务知识,加入我们的社区
