问题
举个转账的例子,小韩给小马转账100元,如何保证两者账户金额正确?
将转账的过程分成两步:
1.小韩的账户扣减100元
2.小马的账户增加100元
要保证账户金额正确,无论先进行哪一步,我们都必须要保证两步动作要么同时进行,要么同时不进行。
如果小韩和小马的账户在同一系统、同一数据库中管理,我们可以将两个操作放到同一个事务中(不考虑账户金额是否充足),利用事务原子性我们可以保证账户的金额正确。
begin transaction;
// 小韩的账户扣减100
update Account set balance = balance - 100 where user = '小韩';
// 小马的账户增加100
update Account set balance = balance + 100 where user = '小马';
commit;
如果小韩和小马的账户不在同一系统管理,需要使用http请求来操作订单与库存的话呢?
可能会碰到的几种问题:
1.小韩账户扣减成功,调用B系统接口失败
2.小韩账户扣减成功,系统崩溃
3.小韩账户扣减成功,调用B系统接口超时
什么是分布式事务?
而随着微服务架构的普及,一个大型业务系统往往由若干个子系统构成,这些子系统又拥有各自独立的数据库。往往一个业务流程需要由多个子系统共同完成,而且这些操作可能需要在一个事务中完成。在微服务系统中,这些业务场景是普遍存在的。此时,我们就需要在数据库之上通过某种手段,实现支持跨数据库的事务支持,这也就是大家常说的“分布式事务”。
分布式事务的常见解决办法
1. 两阶段提交(2PC)与三阶段提交(3PC)
在分布式系统中,每个节点虽然可以知道自己操作是否成功,但是却无法得知其他节点上操作是否成功,因此当一个事务跨越了多个节点的时候,就需要一个协调者,能够掌控到所有节点的执行情况。
第一阶段(提交请求阶段):
协调者节点向所有参与者节点询问是否可以执行提交操作(vote),并开始等待各参与者节点的响应。
参与者节点执行询问发起为止的所有事务操作,并将Undo信息和Redo信息写入日志。(注意:若成功这里其实每个参与者已经执行了事务操作)
各参与者节点响应协调者节点发起的询问。如果参与者节点的事务操作实际执行成功,则它返回一个"同意"消息;如果参与者节点的事务操作实际执行失败,则它返回一个"中止"消息。
第二阶段(提交执行阶段):
当协调者节点从所有参与者节点获得的相应消息都为"同意"时:
协调者节点向所有参与者节点发出"正式提交(commit)"的请求。
参与者节点正式完成操作,并释放在整个事务期间内占用的资源。
参与者节点向协调者节点发送"完成"消息。
协调者节点受到所有参与者节点反馈的"完成"消息后,完成事务。
如果任一参与者节点在第一阶段返回的响应消息为"中止",或者 协调者节点在第一阶段的询问超时之前无法获取所有参与者节点的响应消息时:
协调者节点向所有参与者节点发出"回滚操作(rollback)"的请求。
参与者节点利用之前写入的Undo信息执行回滚,并释放在整个事务期间内占用的资源。
参与者节点向协调者节点发送"回滚完成"消息。
协调者节点受到所有参与者节点反馈的"回滚完成"消息后,取消事务。
不管最后结果如何,第二阶段都会结束当前事务。
2PC实现的思路倒是很简单,不过这个思路中存在着几个的问题:
- 执行过程中,所有参与节点都是事务阻塞型的。当参与者占有公共资源时,其他第三方节点访问公共资源不得不处于阻塞状态。
- 协调者发生故障。参与者会一直阻塞下去,需要额外的备机进行容错。
三阶段提交协议是两阶段提交协议的改进版本。它通过超时机制解决阻塞的问题,并且把两个阶段增加为三个阶段:
询问阶段:协调者询问参与者是否可以完成指令,协调者只需要回答是还是不是,而不需要做真正的操作,这个阶段超时导致中止
准备阶段:如果在询问阶段所有的参与者都返回可以执行操作,协调者向参与者发送预执行请求,然后参与者写redo和undo日志,执行操作,但是不提交操作;如果在询问阶段任何参与者返回不能执行操作的结果,则协调者向参与者发送中止请求,这里的逻辑与两阶段提交协议的的准备阶段是相似的,这个阶段超时导致成功
提交阶段:如果每个参与者在准备阶段返回准备成功,也就是预留资源和执行操作成功,协调者向参与者发起提交指令,参与者提交资源变更的事务,释放锁定的资源;如果任何一个参与者返回准备失败,也就是预留资源或者执行操作失败,协调者向参与者发起中止指令,参与者取消已经变更的事务,执行undo日志,释放锁定的资源,这里的逻辑与两阶段提交协议的提交阶段一致
3PC的出现就是通过增加复杂度(性能也因此降低)来解决或优化2PC中的一部分问题。
2.TCC
这个概念最初是由Pat Helland于2007年提出的,在2008年的软件开发2.0技术大会上支付宝将其在国内推广开来。
TCC编程模式本质上也是一种二阶段协议,不同在于TCC编程模式需要与具体业务耦合,下面首先看下TCC编程模式步骤:
- 所有事务参与方都需要实现try,confirm,cancel接口。
- 事务发起方向事务协调器发起事务请求,事务协调器调用所有事务参与者的try方法完成资源的预留,这时候并没有真正执行业务,而是为后面具体要执行的业务预留资源,这里完成了一阶段。
- 如果事务协调器发现有参与者的try方法预留资源时候发现资源不够,则调用参与方的cancel方法回滚预留的资源,需要注意cancel方法需要实现业务幂等,因为有可能调用失败(比如网络原因参与者接受到了请求,但是由于网络原因事务协调器没有接受到回执)会重试。
- 如果事务协调器发现所有参与者的try方法返回都OK,则事务协调器调用所有参与者的confirm方法,不做资源检查,直接进行具体的业务操作。
- 如果协调器发现所有参与者的confirm方法都OK了,则分布式事务结束。
- 如果协调器发现有些参与者的confirm方法失败了,或者由于网络原因没有收到回执,则协调器会进行重试。这里如果重试一定次数后还是失败,会怎么样那?常见的是做事务补偿。
继续使用上面的转账例子,我们将账户系统简化成只有账户和余额 2 个字段,并且为了适应 DTS 的两阶段设计要求,业务上又增加了一个冻结金额(冻结金额是指在一笔转账期间,在一阶段的时候使用该字段临时存储转账金额,该转账额度不能被使用,只有等这笔分布式事务全部提交成功时,才会真正的计入可用余额)。按这样的设计,用户的可用余额等于账户余额减去冻结金额。
在try阶段并没有对A系统和B系统中的余额字段做操作,而是对冻结金额做的操作,对应A系统中预留资源操作是对冻结金额加上100元,这时候A系统账号上可用钱为余额字段-冻结金额;对应B系统的操作是对冻结金额上减去100,这时候B系统账号上可用的钱为余额字段-冻结金额。
如果事务协调器调用A系统和B系统的try方法有一个失败了(比如A系统的账户余额不够了),则调用cancel进行回滚操作(具体是对冻结金额做反向操作)。如果调用try方法都OK了,则进入confirm阶段,confirm阶段则不做资源检查,直接做业务操作,对应A系统要在账户余额减去100,然后冻金额减去100;对应B系统要对账户余额字段加上100,然后冻结金额加上100。
优点:TCC是对二阶段的一个改进,try阶段通过预留资源的方式避免了同步阻塞资源的情况。
缺点: 缺点还是比较明显的,业务侵入性太强,需要大量开发工作进行业务改造,给业务升级、运维都带来困难;在一些场景中,一些业务流程可能用TCC不太好定义及处理。
3.本地消息表(非事务消息)
该方案关键是要有个消息表。另外,一般会有个队列,而且我们一般都会假设这个MQ不丢消息。
本地消息表的基本思路就是:
上游系统在完成任务后,向消息中间件同步地发送一条消息,确保消息中间件成功持久化这条消息,然后上游系统可以去做别的事情了;
消息中间件收到消息后负责将该消息同步投递给相应的下游系统,并触发下游系统的任务执行;
当下游系统处理成功后,向消息中间件反馈确认应答,消息中间件便可以将该条消息删除,从而该事务完成。
// 1. 执行本地事务
begin transaction;
update Account set balance = balance - 100 where user = '小韩';
insert into send_message_log ...; //消息状态为未发送
commit;
// 2. 发送消息
// 3. 修改消息状态未已发送
上面是一个理想化的过程,但在实际场景中,往往会出现如下几种意外情况:
- 上游系统向消息中间件发送消息失败
- 消息中间件向下游系统投递消息失败
对于第一种情况,需要在上游系统中建立消息重发机制。可以在上游系统建立一张本地消息表,并将 任务处理过程 和 向本地消息表中插入消息 这两个步骤放在一个本地事务中完成。如果向本地消息表插入消息失败,那么就会触发回滚,之前的任务处理结果就会被取消。如果这量步都执行成功,那么该本地事务就完成了。接下来会有一个专门的消息发送者不断地发送本地消息表中的消息,如果发送失败它会返回重试。当然,也要给消息发送者设置重试的上限,一般而言,达到重试上限仍然发送失败,那就意味着消息中间件出现严重的问题,此时也只有人工干预才能解决问题。
对于第二种情况,消息中间件具有重试机制,我们可以在消息中间件中设置消息的重试次数和重试时间间隔,对于网络不稳定导致的消息投递失败的情况,往往重试几次后消息便可以成功投递,如果超过了重试的上限仍然投递失败,那么消息中间件不再投递该消息,而是记录在失败消息表中,消息中间件需要提供失败消息的查询接口,下游系统会定期查询失败消息,并将其消费,这就是所谓的“定期校对”。
如果重复投递和定期校对都不能解决问题,往往是因为下游系统出现了严重的错误,此时就需要人工干预。
对于不支持事务型消息的消息中间件,如果要实现分布式事务的话,就可以采用这种方式。它能够通过重试机制+定期校对实现分布式事务,但相比于第二种方案,它达到数据一致性的周期较长,而且还需要在上游系统中实现消息重试发布机制,以确保消息成功发布给消息中间件。
因此,尽量选择支持事务型消息的消息中间件来实现分布式事务,如RocketMQ。
4.事务消息
这种实现分布式事务的方式需要通过消息中间件来实现。假设有A和B两个系统,分别可以处理任务A和任务B。此时系统A中存在一个业务流程,需要将任务A和任务B在同一个事务中处理。下面来介绍基于消息中间件来实现这种分布式事务。
- 在系统A处理任务A前,首先向消息中间件发送一条消息
- 消息中间件收到后将该条消息持久化,但并不投递。此时下游系统B仍然不知道该条消息的存在。
- 消息中间件持久化成功后,便向系统A返回一个确认应答;
- 系统A收到确认应答后,则可以开始处理任务A;
- 任务A处理完成后,向消息中间件发送Commit请求。该请求发送完成后,对系统A而言,该事务的处理过程就结束了,此时它可以处理别的任务了。
- 消息中间件收到Commit指令后,便向系统B投递该消息,从而触发任务B的执行;
- 当任务B执行完成后,系统B向消息中间件返回一个确认应答,告诉消息中间件该消息已经成功消费,此时,这个分布式事务完成。
上述过程中,如果任务A处理失败,那么需要进入回滚流程,如下图所示:
- 若系统A在处理任务A时失败,那么就会向消息中间件发送Rollback请求。和发送Commit请求一样,系统A发完之后便可以认为回滚已经完成,它便可以去做其他的事情。
- 消息中间件收到回滚请求后,直接将该消息丢弃,而不投递给系统B,从而不会触发系统B的任务B。
上面所介绍的Commit和Rollback都属于理想情况,但在实际系统中,Commit和Rollback指令都有可能在传输途中丢失。那么当出现这种情况的时候,消息中间件是如何保证数据一致性呢?——答案就是超时询问机制。
系统A除了实现正常的业务流程外,还需提供一个事务询问的接口,供消息中间件调用。当消息中间件收到一条事务型消息后便开始计时,如果到了超时时间也没收到系统A发来的Commit或Rollback指令的话,就会主动调用系统A提供的事务询问接口询问该系统目前的状态。该接口会返回三种结果:
- 提交 若获得的状态是“提交”,则将该消息投递给系统B。
- 回滚 若获得的状态是“回滚”,则直接将条消息丢弃。
- 处理中 若获得的状态是“处理中”,则继续等待。
整体交互流程如下图所示:
幂等与消息顺序
即使没有MQ,重试也是无处不在的。所以幂等问题不是因为用到MQ后引入的,而是老问题。
要实现严格的顺序消息条件非常苛刻:从发送方到服务方到接受者都是单点单线程。
虽然很难做到消息全局有序,但是还是可以通过一些方法做到消息局部有序,说三种通用的解决方案:
- 唯一标示
- 版本号
- 状态机
唯一标识
对于本身不具有幂等性的操作,主要思想是为每条事件存储执行结果,当收到一条事件时我们需要根据事件的id查询该事件是否已经执行过,如果执行过直接返回上一次的执行结果,否则调度执行事件。
版本号
举个简单的例子,一个产品的状态有上线/下线状态。如果消息1是下线,消息2是上线。
消息顺序如果和想象的顺序不一致。比如应该的顺序是12,到来的顺序是21。则最后会发生状态错误。
如果到来的顺序是21,则先把2存起来,待1到来后,先处理1,再处理2,这样顺序性要求就都达到了。
状态机
业务流程本身可以表示成一个状态机。那么在生产数据的时候可以把目标实体的当前状态和目标状态写入消息中,消费的过程中通过消息的当前状态和实体实际的当前状态进行对比,如果一致再进行处理。
举个例子说明,一个工单的状态有:未接单,已接单,上门中,已完成。工单的开始状态为未接单,切换到已接单时发送 消息1,切换到上门中时发送 消息2,切换到已完成时发送 消息3。正常情况下,下游接受到的消息顺序是123,但是实际情况下收到的消息顺序变成了312。
那么下游接收到消息3时,判断状态机与当前状态不符,拒绝接受,要求重发。然后收到消息1,状态是未接单-->已接单。然后是消息2,状态是已接单-->上门中。最后处理重发的消息3,状态上门中-->已完成。
参考
https://docs.oracle.com/cd/E11882_01/server.112/e40540/transact.htm#CNCPT117
https://dev.mysql.com/doc/refman/5.6/en/mysql-acid.html
https://zh.wikipedia.org/wiki/ACID
https://www.ibm.com/support/knowledgecenter/en/SSGMCP_5.4.0/product-overview/acid.html
https://www.jianshu.com/p/453c6e7ff81c
https://tech.antfin.com/docs/2/46886