2PC是一种实现分布式事务的简单模型,在2PC中有两个角色:事务协调者和事务参与者。具体到一个服务访问多个数据库的场景中,数据库就是事务参与者,服务就是事务协调者。这两个阶段是:
1)准备阶段:协调者向各个参与者发起询问请求:“我要执行分布式事务了,这个事务涉及到的资源是……,你们准备好各自的资源(即各自执行本地事务到待提交阶段)”。各个参与者恢复yes(表示已准备好,允许执行事务)或no或超时。
2)提交阶段:如果各个参与者回复的都是yes,则协调者向所有参与者发起事务提交操作,然后所有参与者收到后各自执行本地提交操作并向协调者发送ACK;如果任何一个参与者回复no或者超时,则所有参与者各自回滚事务并向协调者发送ACK。
要实现2PC,所有的参与者都要实现三个接口:Prepare、Commit、Rollback,这就是XA协议。
2PC存在如下的问题:
1)性能差,在准备阶段,要等待所有的参与者返回,才能进入阶段二。在这期间,各个参与者上面的相关资源被排他地锁住,影响了各个参与者的本地事务并发度;
2)准备阶段完成后,如果协调者宕机,所有的参与者都收不到提交或回滚指令,导致所有参与者“不知所措”;
3)在提交阶段,协调者向所有的参与者发送了提交指令,如果一个参与者未返回ACK,那么协调者不知道这个参与者内部发生了什么,也就无法决定下一步是否进行全体参与者的回滚。
2PC之后又出现了3PC进一步加强了整个事务过程的可靠性,但是3PC同样无法应对类似的宕机问题。
2PC除了性能和可靠性上存在问题,它的适用场景也很局限,它要求参与者实现了XA协议,例如实现了XA协议的数据库作为参与者可以使用2PC。但是在多个系统服务利用api接口相互调用的时候,就不遵守XA协议了,这时候2PC就不适用了。所以2PC在分布式应用场景中很少使用。
在分布式应用场景中,实现分布式事务的一种思路是:利用消息中间件达到最终一致性。这里先给出一种错误的实现方式。
+--------+ (*1) +--------+ (*2) +--------+
|SystemA |-------->|MQ |-------->|SystemB |
+--------+ +--------+ +--------+
| |
扣钱 加钱
| |
v v
+--------+ +--------+
|DB1 | |DB2 |
+--------+ +--------+
注释
(*1):发送加钱消息
(*2):消费加钱消息
这种实现方式将系统A的“发送加钱消息”和“从DB扣钱”两个步骤放在一个事务中进行,若消息发送失败则回滚事务,撤销从DB扣钱。这样做的缺陷为:
1)存在网络的2将军问题,发送消息失败,对于发送方来说,可能是消息中间件没有收到消息,也可能是中间件收到了消息,但向发送方响应的ACK由于网络问题没有被发送方收到。因此系统A贸然进行事务回滚,撤销从DB扣钱,是不对的。
2)把网络调用放在数据库事务里,可能会因为网络延迟产生数据库长事务,影响数据库的并发度。
接着给出正确的实现方式。
+--------+
|SystemA |
+--------+
|
|在账号余额表扣钱 && 写消息表
v
+--------+ (*1) +--------+ (*2) +--------+
|DB1 |-------->|MQ |-------->|SystemB |
| | | |<--------| |
+--------+ +--------+ (*3) +--------+
DB1包含消息表和账号余额表 |
|在账号余额表加钱 && 写判重表
v
+--------+
|DB2 |
+--------+
DB2包含判重表和账号余额表
注释
(*1):后台任务读取消息表发送到MQ,若失败则不断尝试
(*2):消费加钱消息
(*3):ACK消费
如上图所示,利用消息中间件如Kafka来实现分布式转账过程的最终一致性。对这幅图做以下说明:
1)系统A中,“在账号余额表扣钱 && 写消息表”这个过程要在一个事务中完成,保证过程的原子性。同样,系统B中,“在账号余额表加钱 && 写判重表”这个过程也要在一个事务中完成。
2)系统A中有一个后台程序,源源不断地把消息表中的消息传送给消息中间件。如果失败了,也会不断尝试重传。由于存在网络2将军问题,即当系统A发送给消息中间件的消息网络超时时,这时候消息中间件可能收到了消息但响应ACK失败,也可能没收到,系统A会再次发送该消息,直至消息中间件响应ACK成功,这样可能发生消息的重复发送,不过没关系,只要保证消息不丢失,不乱序就行,后面系统B会做去重处理。
3)消息中间件向系统B推送某消息,系统B成功处理完成后会向中间件响应ACK,系统B收到这个ACK才认为系统B成功处理了这条消息,否则会重复推送该消息。但是有这样的情形:系统B成功处理了消息,向中间件发送的ACK在网络传输中由于网络故障丢失了,导致中间件没有收到ACK重新推送了该消息。这也要靠系统B的消息去重特性来避免消息重复消费。
4)在2)和3)中提到了两种导致系统B重复收到消息的原因,一是生产者重复生产,二是中间件重传。为了实现业务的幂等性,系统B中维护了一张判重表,这张表中记录了被成功处理的消息的id。系统B每次接收到新的消息都先判断消息是否被成功处理过,若是的话不再重复处理。
通过这种设计,实现了消息在发送方不丢失,消息在接收方不被重复消费,联合起来就是消息不漏不重,严格实现了系统A和系统B的最终一致性。
在上一个解决方案中,系统A要维护消息表和后台任务,不断扫表消息表,导致消息的处理和业务逻辑耦合。RocketMQ的“事务消息”特性可以解决这一问题。
+----------+ (*1) +--------+ (*3) +--------+
|SystemA |-------->|RocketMQ|-------->|SystemB |
| |-------->| |<--------| |
+----------+ (*2) +--------+ (*4) +--------+
| ^ | |
| | (*5) | |
| +---------------+ |
扣钱 加钱 && 写判重表
| |
v v
+--------+ +--------+
|DB1 | |DB2 |
+--------+ +--------+
DB1包含账号余额表 DB2包含判重表和账号余额表
注释
(*1):prepare消息
(*2):confirm消息
(*3):消费加钱消息
(*4):ACK消费
(*5):异常消息,定期回调
Rocket不是提供一个简单的发送接口,而是把消息的发送拆成了两个阶段,prepare和confirm阶段。具体使用方法如下:
1)步骤一:系统A调用prepare接口,预发送消息。此时消息保存在中间件里,但消息中间件还不会把消息推给消费方,消息只是暂存在中间件。
2)步骤二:系统A更新数据库,完成扣钱。
3)步骤三:系统A调用confirm接口,确认消息发送,此时中间件才会把消息推给消费方进行消费。
显然这里有两种异常场景:
1)场景一:步骤一成功,步骤二成功,步骤三超时或者失败。
2)场景二:步骤一成功,步骤二超时或者失败,步骤三不会执行。
这两种异常场景,利用的就是Rocket的“事务消息”特性,具体来说就是:RocketMQ会定期扫描所有的预发送但还没有confirm的消息,回调给发送方,询问这条消息是要发出去,还是要取消。发送方根据自己的业务数据,判断这条消息是应该发出去(DB扣款成功了),还是要取消(DB扣款失败了)。
对比上部分给出的基于MQ + 本地消息表的方案,会发现RocketMQ其实是把“扫描本地消息表”这件事从系统A中剥离出来,不让业务系统去做,而是交由消息中间件完成。
至于消费端的判重,和上部分的策略一样。
2PC通常用来解决多个数据库之间的事务问题,比较局限。现代企业多采用分布式的SOA服务,因此更多的是要解决多个服务之间的分布式事务问题。
TCC是一种解决多个服务之间的分布式事务问题的方案。TCC是Try、Confirm、Cancel三个词的缩写,其本质是一个应用层面上的2PC,同样分成两个阶段:
1)阶段一:准备阶段,协调者调用所有的服务提供的try接口,将整个事务涉及到的资源锁定住,锁定成功向协调者返回yes。
2)阶段二:提交阶段,若所有的服务都返回yes,则进行提交阶段,协调者调用所有服务的confirm接口,各个服务进行事务提交。如果有任何一个服务在阶段一返回no或者超时,则协调者调用所有服务的cancel接口。
这里有个关键问题,既然TCC是一种服务层面上的2PC,它是如何解决2PC无法应对宕机问题的缺陷的呢?答案是不断重试。由于try操作锁住了事务涉及的所有资源,保证了业务操作的所有前置条件得到满足,因此无论是confirm阶段失败还是cancel阶段失败都能通过不断重试直至confirm或cancel成功(所谓成功就是所有的服务都对confirm或者cancel返回了ACK)。
这里还有个关键问题,在不断重试confirm和cancel的过程中(考虑到网络二将军问题的存在)有可能重复进行了confirm或cancel,因此还要再保证confirm和cancel操作具有幂等性。
另外有一种类似TCC的事务解决方案,借助事务状态表来实现。假设要在一个分布式事务中实现调用订单服务生成订单、调用库存服务扣减库存、调用物流服务启动发货三个过程。在这种方案中,协调者维护一张如下的事务状态表:
分布式事务ID | 事务内容 | 事务状态 |
---|---|---|
global_dis_trx_id_1 | 操作1:调用订单服务生成订单 操作2:调用库存服务扣减库存 操作3:调用物流服务启动发货 |
状态1:初始 状态2:操作1成功 状态3:操作1、2成功 状态4:操作1、2、3成功 |
初始状态为1,每成功调用一个服务则更新一次状态,最后所有的服务调用成功,状态更新到4。
有了这张表,就可以启动一个后台任务,扫描这张表中事务的状态,如果一个分布式事务一直(设置一个事务周期阈值)未到状态4,说明这条事务没有成功执行,于是可以重新调用订单服务生成订单、调用库存服务扣减库存、调用物流服务启动发货。直至所有的调用成功,事务状态到4。
如果多次重试仍未使得状态到4,可以将事务状态置为error,通过人工介入进行干预。
由于存在服务的调用重试,因此每个服务的接口要根据全局的分布式事务ID做幂等。
下面给出利用对账来补偿数据的思路。
以上给出的分布式事务的解决方案,无论是基于MQ + 消息表的最终一致性方案,还是TCC方案,还是事务状态表方案,关注的都是实现事务的过程,即都是在保证整个分布式事务的“过程的原子性”。
所有的过程都会产生结果,从结果也会反推出过程。对账就是根据结果反推过程中出现的问题,从而对数据进行修补。
例如,新员工入职,HR使用HRMS系统录入新员工信息,新员工信息同时也要同步到研发管理系统、资产管理系统等一系列公司系统中。使用分布式事务的解决方案可以这么做:调用HRMS接口插入员工信息时,同时调用研发管理系统接口、资产管理系统接口插入员工信息,利用分布式事务保证整个过程的原子性和一致性。而利用对账法可以这么做:调用HRMS系统接口插入员工信息,返回。后面,研发管理系统、资产管理系统等会轮询HRMS的数据库(基准库)中有哪些员工的信息不存在于自身数据库(校准库)中,对于不存在的员工信息,读取并插入到自身数据库中。
对账分为增量对账和全量对账。
研究以上方案,可以发现:
1)“最终一致性”是一种异步方法,数据的一致有一定延迟
2)TCC是一种同步方法,但TCC需要两个阶段,性能损耗较大
3)事务状态表也是一种同步方法,但每次都要记录事务流水,要更新事务状态,很繁琐,新能损耗也很大
4)对账是一种事后干预过程
在高并发场景中,要求一个系统能承受大量的分布式事务并发进行。所以需要一个同步方案,既能让系统之间保持一致性,又能具备很高的性能,支持高并发。下面给出一种对分布式事务的妥协方案:若一致性 + 基于状态的补偿。
这里以电商系统的下单场景来解释这种方案,下单场景如下:协调者调用订单服务产生订单,并调用库存服务扣减库存。即一个分布式事务内容如下:
begin
调用订单服务产生订单
调用库存服务扣减库存
end
如果使用基于MQ + 消息表的最终一致性分布式事务方案,由于通知库存服务扣减库存是异步的,因此扣减不及时可能会出现超卖的问题;如果使用基于TCC的分布式事务方案,意味着一次下单要调用两次订单服务(try、confirm / cancel)和两次库存服务(try、confirm / cancel),性能较差;如果使用基于事务状态表的分布式事务方案,需要不断刷新事务状态表,性能也很差。
既要满足高并发,又要达到一致性,鱼与熊掌不可兼得,可以使用一种弱一致性方案。可以有两种做法:
1)先扣库存,再创建订单,这种做法产生三种结果:
扣库存 | 创建订单 | 返回结果 | |
---|---|---|---|
结果1 | 成功 | 成功 | 成功 |
结果2 | 成功 | 失败 | 失败 |
结果3 | 失败 | 不执行 | 失败 |
2)先创建订单,再扣库存,这种做法也产生三种结果:
创建订单 | 扣库存 | 返回结果 | |
---|---|---|---|
结果1 | 成功 | 成功 | 成功 |
结果2 | 成功 | 失败 | 失败 |
结果3 | 失败 | 不执行 | 失败 |
无论是哪种做法,失败只可能导致库存的多扣,避免了超卖问题。对于多扣的库存,可以通过补偿措施进行库存释放,这个补偿措施就是上面说的对账。
其实上面的妥协方案还可以再妥协一点,那整个过程就是:先扣减库存,再创建订单,不做补偿措施。并且库存服务提供回滚接口,若创建订单失败,则重试创建订单,重试几次都失败了,则回滚库存的扣减,如果回滚不成功,则记录错误,发出告警,进行人工干预修复。通常来说,只要各个服务的业务逻辑没有漏洞,并且可用性很高,重试、回滚之后失败的概率很小,所以这种原始的办法通过牺牲一致性来保证系统的并发度,还是可以接受的。