分布式事务解决方案


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 失败 不执行 失败

无论是哪种做法,失败只可能导致库存的多扣,避免了超卖问题。对于多扣的库存,可以通过补偿措施进行库存释放,这个补偿措施就是上面说的对账。


其实上面的妥协方案还可以再妥协一点,那整个过程就是:先扣减库存,再创建订单,不做补偿措施。并且库存服务提供回滚接口,若创建订单失败,则重试创建订单,重试几次都失败了,则回滚库存的扣减,如果回滚不成功,则记录错误,发出告警,进行人工干预修复。通常来说,只要各个服务的业务逻辑没有漏洞,并且可用性很高,重试、回滚之后失败的概率很小,所以这种原始的办法通过牺牲一致性来保证系统的并发度,还是可以接受的。


你可能感兴趣的:(分布式事务解决方案)