分布式事务

什么是分布式事务

分布式事务就是指事务的参与者、支持事务的服务器、资源服务器以及事务管理器分别位于不同的分布式系统的不同节点之上。

简单的说,就是一次大的操作由不同的小操作组成,这些小的操作分布在不同的服务器上,且属于不同的应用,分布式事务需要保证这些小操作要么全部成功,要么全部 失败。

本质上来说,分布式事务就是为了保证不同数据库的数据一致性。

分布式事务产生的原因

  • 数据库分库分表
  • 应用的服务化

分布式事务的场景

支付

一笔支付,是对买家账户进行扣款,同时对卖家账户进行加钱,这些操作必须在一个事务里执行,要么全部成功,要么全部失败。

而对于买家账户属于买家中心,对应的是买家数据库,而卖家账户属于卖家中心,对应的是卖家数据库,对不同数据库的操作必然需要引入分布式事务。

在线下单

买家在电商平台下单,往往会涉及到两个动作,一个是扣库存,第二个是更新订单状态,库存和订单一般属于不同的数据库,需要使用分布式事务保证数据一致性。

同步超时

服务化的系统间调用常常因为网络问题导致系统间调用超时,即使是网络很好的机房,在亿次流量的基数下,同步调用超时也是家常便饭。系统A同步调用系统B超时,系统A可以明确得到超时反馈,但是无法确定系统B是否已经完成了预定的功能或者没有完成预定的功能。于是,系统A就迷茫了,不知道应该继续做什么,如何反馈给使用方。

异步回调超时

这个场景使用了异步回调,系统A同步调用系统B发起指令,系统B采用受理模式,受理后则返回受理成功,然后系统B异步通知系统A。在这个过程中,如果系统A由于某种原因迟迟没有收到回调结果,那么两个系统间的状态就不一致,互相认知不同会导致系统间发生错误,严重情况下会影响核心事务,甚至会导致资金损失。

分布式事务解决方案

分段提交

基于XA协议的两阶段提交

【准备阶段】

协调者向参与者发起指令,参与者评估自己的状态,如果参与者评估指令可以完成,参与者会写redo或者undo日志(这也是前面提起的Write-Ahead Log的一种),然后锁定资源,执行操作,但是并不提交。

【提交阶段】 如果每个参与者明确返回准备成功,也就是预留资源和执行操作成功,协调者向参与者发起提交指令,参与者提交资源变更的事务,释放锁定的资源;如果任何一个参与者明确返回准备失败,也就是预留资源或者执行操作失败,协调者向参与者发起中止指令,参与者取消已经变更的事务,执行undo日志,释放锁定的资源。

三阶段提交

由于二阶段提交存在着诸如同步阻塞、单点问题、脑裂等缺陷,所以,研究者们在二阶段提交的基础上做了改进,提出了三阶段提交。

消息事务+最终一致性

现在大型互联网平台普遍采用的方案,就是利用消息中间件。

单系统分布式事务

jpeg
jpeg
  1. A系统向消息中间件发送一条预备消息
  2. 消息中间件保存预备消息并返回成功
  3. A执行本地事务
  4. A发送提交消息给消息中间件

通过以上4步完成了一个消息事务。对于以上的4个步骤,每个步骤都可能产生错误,下面一一分析:

  1. 步骤一出错,则整个事务失败,不会执行A的本地操作
  2. 步骤二出错,则整个事务失败,不会执行A的本地操作
  3. 步骤三出错,这时候需要回滚预备消息,怎么回滚?答案是A系统实现一个消息中间件的回调接口,消息中间件会去不断执行回调接口,检查A事务执行是否执行成功,如果失败则回滚预备消息
  4. 步骤四出错,这时候A的本地事务是成功的,那么消息中间件要回滚A吗?答案是不需要,其实通过回调接口,消息中间件能够检查到A执行成功了,这时候其实不需要A发提交消息了,消息中间件可以自己对消息进行提交,从而完成整个消息事务

多系统分布式事务

jpeg
jpeg

虽然上面的方案能够完成A和B的操作,但是A和B并不是严格一致的,而是最终一致的,我们在这里牺牲了一致性,换来了性能的大幅度提升。当然,这种玩法也是有风险的,如果B一直执行不成功,那么一致性会被破坏,具体要不要玩,还是得看业务能够承担多少风险。

消息中间件也可称作消息系统(MQ),它本质上是一个暂存转发消息的一个中间件。在分布式应用当中,我们可以把一个业务操作转换成一个消息,比如支付宝转账余额宝操作,支付宝系统执行减掉账户金额操作之后向消息系统发一个消息,余额宝系统订阅这条消息然后进行增加账户金额操作。

尽管存在各种各样的消息系统,每个消息系统都有各自的消息路由方式,但总体上有两种类型的消息系统:queue和topic,它们也各自关联着一种特定的消息处理模型:点对点(point-to-point/queue)和发布/订阅(publish/subscribe/topic)。

在点对点模式中,每个消息只有一个发送者和一个接收者。如下图所示: 在点对点模型中, 消息broker会把消息放入一个queue。当一个接收者请求下一个消息时,消息会被从queue中取出并传递给接收者。因为消息从queue中取出便会被移除,所以这保证了一个消息只能有一个接收者。

在发布/订阅模式中,消息是被发送到topic中的。就像queue一样,很多接收者可以监听同一个topic,但是与queue每个消息只传递给一个接收者不同,订阅了同一个topic的所有接收者都会收到消息的拷贝。从发布/订阅的名字中我们也可看出,发布者发布一条消息,所有订阅者都能收到,这就是发布订阅模式最大的特性。

在分布式业务之间引入消息中间件存在一个问题,就是如何保证业务系统与消息系统之间消息传递的可靠性。在分布式业务场景中,可靠性永远是最重要的。如果采用消息中间件,保证业务之间消息的发送与接收的可靠性是非常重要的问题。

当采用消息中间件时,消息的可靠性体现在两个方面:

  1. 消息的发送者端 (生产者):发送者端完成操作后一定能将消息成功发送到消息系统
  2. 消息的接收者端(消费者):消费者端仅且能够从消息系统成功消费一次消息。

适用场景

  • 执行周期较长
  • 实时性要求不高

例如:

  • 跨行转账/汇款业务(两个服务分别在不同的银行中)
  • 退货/退款业务
  • 财务,账单统计业务(先发送到消息中间件, 然后进行批量记账)

TCC编程模式

阿里巴巴提出了新的TCC协议,TCC协议将一个任务拆分成Try、Confirm、Cancel,正常的流程会先执行Try,如果执行没有问题,再执行Confirm,如果执行过程中出了问题,则执行操作的逆操Cancel,从正常的流程上讲,这仍然是一个两阶段的提交协议,但是,在执行出现问题的时候,有一定的自我修复能力,如果任何一个参与者出现了问题,协调者通过执行操作的逆操作来取消之前的操作,达到最终的一致状态。

从TCC的逻辑上看,可以说TCC是简化版的三阶段提交协议,解决了两阶段提交协议的阻塞问题,但是没有解决极端情况下会出现不一致和脑裂的问题。然而,TCC通过自动化补偿手段,会把需要人工处理的不一致情况降到到最少,也是一种非常有用的解决方案,根据线人,阿里在内部的一些中间件上实现了TCC模式。

优势:

  • TCC能够对分布式事务中的各个资源进行分别锁定,分别提交与释放,例如,假设有AB两个操作,假设A操作耗时短,那么A就能较快的完成自身的try-confirm-cancel流程,释放资源,无需等待B操作。如果事后出现问题, 追加执行补偿性事务即可。
  • TCC是绑定在各个子业务上的(除了cancle中的全局回滚操作),也就是各服务之间可以在一定程度上”异步并行”执行。

劣势

可以看出,从时序上,如果遇到极端情况下TCC会有很多问题的,例如,如果在Cancel的时候一些参与者收到指令,而一些参与者没有收到指令,整个系统仍然是不一致的,这种复杂的情况,系统首先会通过补偿的方式,尝试自动修复的,如果系统无法修复,必须由人工参与解决。

适用场景

  • 严格一致性
  • 执行时间短
  • 实时性要求高

举例:红包、收付款业务、秒杀。 在秒杀的场景,用户发起下单请求,应用层先查询库存,确认商品库存还有余量,则锁定库存,此时订单状态为待支付,然后指引用户去支付,由于某种原因用户支付失败,或者支付超时,系统会自动将锁定的库存解锁供其他用户秒杀。

消息事务发送者端保证消息的可靠性

利用本地事务

在数据库中建一张消息表,将消息数据与业务数据保存在同一数据库实例里,这样就可以利用本地数据库的事务机制,保证业务操作和保存消息完全一致:

1
2
3
4
5
6
7
8
Begin transaction

update A set amount=amount-10000 where userId=1;
insert into message(userId, amount,status) values(1, 10000, 1);

End transaction

commit;

通过本地事务一定能保证扣完款后消息能保存下来。当上述事务提交成功后,我们再通过消息中间件实时扫描这张消息表,把消息表中的数据转移到消息中间件,若转移消息成功则删除消息表中的数据,若转移失败继续重试。

非事务性的消息中间件

通常情况下,在使用非事务消息支持的MQ产品时,我们很难将业务操作与对MQ的操作放在一个本地事务域中管理。通俗点描述,以“支付宝转账”为例,我们很难保证在支付宝扣款完成之后对MQ投递消息的操作就一定能成功。 伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
public void trans(){
try{
//1.操作数据库
bool result = dao.update(model);//操作数据库失败,会抛出异常
//2.如果第一步成功,则操作消息队列(投递消息)
if(result){
mq.append(model); //如果mq.append方法执行失败(投递消息失败),方法内部会抛出异常
}
}
catch(Exception ex){
rollback(); //如果发生异常,则回滚
}
}

根据上述代码及注释,我们来分析下可能的情况:

  1. 操作数据库成功,向MQ中投递消息也成功,皆大欢喜
  2. 操作数据库失败,不会向MQ中投递消息了
  3. 操作数据库成功,但是向MQ中投递消息时失败,向外抛出了异常,刚刚执行的更新数据库的操作将被回滚

所以这种方式基本上能保证发送者发送消息的可靠性。

支持事务的消息中间件

阿里巴巴的RocketMQ中间件就支持一种事务消息机制,能够确保本地操作和发送消息达到本地事务一样的效果。

  • 第一阶段,RocketMQ在执行本地事务之前,会先发送一个Prepared消息,并且会持有这个消息的地址
  • 第二阶段,执行本地事物操作
  • 第三阶段,确认消息发送,通过第一阶段拿到的地址去访问消息,并修改状态,如果本地事务成功,则修改状态为已提交,否则修改状态为已回滚

但是如果第三阶段的确认消息发送失败了怎么办?RocketMQ会定期扫描消息集群中的事物消息,如果发现了prepare状态的消息(既不是提交也不是回滚的中间状态),它会向消息发送者确认本地事务是否已执行成功,如果成功是回滚还是继续发送确认消息呢。RocketMQ会根据发送端设置的策略来决定是回滚还是继续发送确认消息。这样就保证了消息发送与本地事务同时成功或同时失败。

消息事务接收者端保证消息的可靠性

保证消费者不重复消费消息

什么情况下会产生重复消费的情况呢?比如消费者接收到消息并完成了本地事务(如减库存操作),此时还要返回消息系统一个通知,告诉消息系统把这条消息删除掉。然后不巧恰恰在此时网络出现了问题,返回给消息系统删除消息的通知丢失,则消费者端会再次消费这条消息,导致了重复消费。

那么该怎么处理这种情况呢?

  1. 消费端处理消息的业务逻辑保持幂等性
  2. 保存消费者消费的状态

即保证每条消息都有唯一编号,并且保证消息处理成功后一定能写入到一张去重日志表

关于第2条,原理就是利用一张日志表来记录已经处理成功的消息的ID,如果新到的消息ID已经在日志表中,那么就不再处理这条消息。当然这个可以消息系统实现,也可以业务端实现。正常情况下出现重复消息的概率不一定大,且由消息系统实现的话,肯定会对消息系统的吞吐量和高可用有影响,所以,一般消费状态的保存都是在消费者端进行保存。

RocketMQ、Kafka都不保证消息不重复,如果你的业务需要保证严格的不重复消息,那么就需要在我们的业务端保存消费状态,进行去重。

解决消费者消费超时

再回到转账的例子,如果Bob的账户的余额已经减少,且消息已经发送成功,Smith端开始消费这条消息,这个时候就会出现消费失败和消费超时两个问题?解决超时问题的思路就是一直重试,直到消费端消费消息成功,整个过程中有可能会出现消息重复的问题,按照前面的思路解决即可。

解决消费失败:报警系统+人工处理

上面基本上可以解决超时问题, 但是如果消费失败怎么办?比如系统自身有bug或者程序逻辑有问题,那么重试1W次那也是无济于事的。 大家可以考虑一下,如果按照事务的流程,如果事务中的某个步骤操作失败了的话,就要回滚之前的所有操作。如果消息系统要实现这个回滚流程的话,系统复杂度将大大提升,且很容易出现Bug,估计出现Bug的概率会比消费失败的概率大很多。而且一般通过消息系统的处理流程都是一个异步操作,也就是说,但当用户下单时我们不会等到整个流程完成之后才返回给用户结果,而是直接返回给用户下单成功的结果,后端再慢慢处理。如果我们进行回滚操作的话,那么就会出现用户明明下单成功了过段时间一看又失败了这种情况,这是不允许的。

所以针对消费失败这种情况,最好的办法就是通过报警系统及时发现失败情况然后再人工处理。其实为了交易系统更可靠,我们一般会在类似交易这种高级别的服务代码中,加入详细日志记录的,一旦系统内部引发类似致命异常要及时通过短信、邮件通知给业务方。同时,应该设计一个报警系统在后台实时扫描和分析此类日志,检查出这种特殊的情况,通过短信、邮件及时通知相关人员。

开源分布式事务框架

GTS

全局事务服务(Global Transaction Service,简称 GTS)是一款高性能、高可靠、接入简单的分布式事务中间件,用于解决分布式环境下的事务一致性问题。 在单机数据库下很容易维持事务的 ACID(Atomicity、Consistency、Isolation、Durability)特性,但在分布式系统中并不容易,GTS 可以保证分布式系统中的分布式事务的 ACID 特性。 GTS 支持 DRDS、RDS、MySQL 等多种数据源,可以配合 EDAS 和 Dubbo 等微服务框架使用, 兼容 MQ 实现事务消息。通过各种组合,可以轻松实现分布式数据库事务、多库事务、消息事务、服务链路级事务等多种业务需求。

总结

分段提交的缺点

  • 复杂
  • 成本高
    但是遇到极端情况,系统会发生阻塞或者不一致的问题,需要运营或者技术人工解决。
  • 不够灵活
    需要人工干预处理,没有自动化的解决方案,因此两阶段提交协议在正常情况下能保证系统的强一致性,但是在出现异常情况下,当前处理的操作处于错误状态,需要管理员人工干预解决,因此可用性不够好。
  • 性能较差
    执行过程中,所有参与节点都是事务阻塞型的。当参与者占有公共资源时,其他第三方节点访问公共资源不得不处于阻塞状态。 两阶段提交涉及多次节点间的网络通信,通信时间太长! 事务时间相对于变长了,锁定的资源的时间也变长了,造成资源等待时间也增加好多!
  • 单点故障
    由于协调者的重要性,一旦协调者发生故障。参与者会一直阻塞下去。尤其在第二阶段,协调者发生故障,那么所有的参与者还都处于锁定事务资源的状态中,而无法继续完成事务操作。(如果是协调者挂掉,可以重新选举一个协调者,但是无法解决因为协调者宕机导致的参与者处于阻塞状态的问题)
  • 数据不一致
    在二阶段提交的阶段二中,当协调者向参与者发送commit请求之后,发生了局部网络异常或者在发送commit请求过程中协调者发生了故障,这回导致只有一部分参与者接受到了commit请求。而在这部分参与者接到commit请求之后就会执行commit操作。但是其他部分未接到commit请求的机器则无法执行事务提交。于是整个分布式系统便出现了数据部一致性的现象。
  • 并发低
    XA无法满足高并发场景。
  • 对数据库支持不理想
    XA目前在商业数据库支持的比较理想,在mysql数据库中支持的不太理想,mysql的 XA实现,没有记录prepare阶段日志,主备切换回导致主库与备库数据不一致。 许多nosql也没有支持XA,这让XA的应用场景变得非常狭隘。

大部分高并发系统都在避免使用分段提交的方式,往往通过其它方式来解决分布式一致性问题。

二阶段提交与三阶段提交的区别

相对于2PC,3PC主要解决的单点故障问题,并减少阻塞,因为一旦参与者无法及时收到来自协调者的信息之后,他会默认执行commit。而不会一直持有事务资源并处于阻塞状态。

但是这种机制也会导致数据一致性问题,因为,由于网络原因,协调者发送的abort响应没有及时被参与者接收到,那么参与者在等待超时之后执行了commit操作。这样就和其他接到abort命令并执行回滚的参与者之间存在数据不一致的情况。

了解了2PC和3PC之后,我们可以发现,无论是二阶段提交还是三阶段提交都无法彻底解决分布式的一致性问题。 Google Chubby的作者Mike Burrows说过, there is only one consensus protocol, and that’s Paxos” – all other approaches are just broken versions of Paxos. 意即世上只有一种一致性算法,那就是Paxos,所有其他一致性算法都是Paxos算法的不完整版。

坚持原创技术分享,您的支持将鼓励我继续创作!
0%