分布式事务,倘若跨数据库或者夸服务存储时,这个问题就变得有趣了
背景
在分布式架构 一文中,已经大致讲解了分布式事务业界的实现方案,比如2PC/3PC/TCC等等,今天我们专门探讨下分布式事务的实现。
事务的概念,本文不阐述,感兴趣的可以移步INNODB引擎详解 一文中,详细的说明了事务的特征以及实现原理。
分布式事务实现的难度是很高的,但业界早已经提出一套被广泛认可应用的解决方案。比如本地事务+消息投递,TCC,AT等等模式。
我们知道CPA理论,无法拥有三种同时状态,所以目前指导分布式事务的理论,一般是BASE,也就是即使无法做到强一致性,但每个应用都可以根据自身的业务特点,采用适当的方式来使系统达到最终一致性。
因为没有分布式事务能够保证数据状态具备百分百的一致性,根本原因就是分布式下,网络环境和第三方系统的不稳定性,但是我们最终一致性即可。
解决方案
一般来说,被认可的解决分布式事务的方案,业界用的多的,就是事务消息和TCC方案,我们今天都来探讨下吧。
事务消息
这是一个偏狭义的分布式事务解决方案,有一些可以解决,但是有一些他是无能为力的。不管怎么样,我们也需要了解下,花开两朵,各表一枝,我们具体看看。
事务消息方案,是基于消息队列 MQ 实现的事务消息,目前很多MQ都支持事务消息,比如rbMQ、rocketMQ等等,本文只是设计理论,不涉及具体的MQ主键,但是需要有一定MQ的知识,比如需要知道消费方、生产法、MQ等等概念即可。
我们知道,在MQ组件中,有个很重要的能力,就是当我们想把一个同步交互的行为,转为异步交互的行为时,我们抛开MQ给我们带来解耦、削峰等等的优势外,其实还有一点就是当我们作为生产方,只要成功把消息的投递到MQ组件之后,这个消息至少会被下游的消费者消费到一次,这就是 at least once规则,可能出现重复,但是肯定不会丢失。这个是任何的MQ组件都提供的最最基础的能力。
刚刚也说了,可能出现重复,如果需要追求精确消费一次的目标,则下游的消费者还需要基于消息的唯一键执行幂等的去重操作,在消费一次的能力基础上再次过滤掉重复消息,最终达到 精准消费一次,如下:
案例
假如在微服务架构下,我们需要执行一个分布式事务,需要有两步操作的步骤,第一步,在服务A中执行变更的数据记录,称为动作一,第二步操作,需要在服务B中执行缓存redis的数据变更,称为动作二。这两步的两个动作,分别在两个独立的服务中执行,由于业务流程关联,这两个动作需要再事务中执行,具备数据的一致性,我们来剖析下:
- 1:我们把服务A作为MQ的生产方 producer,服务B作为MQ的消费方 consumer,服务A和B事先在MQ中已经沟通好协议内容了,比如kafka的topic、rbmq的queue。
- 2:当事务流程开启之后,服务A首先执行动作1,执行成功之后往MQ中投递消息。
- 3:服务B消费到消息,执行动作2。
这个就是最最基础的事务消息流程,当很是简单,但是也具备以下优势:
- 1:服务A和服务B通过MQ服务实现异步解耦,因为服务A处理完成后,通过消息给了服务B,不需要关心服务B是否完成。从而提高了系统处理事务流程的吞吐量。
- 2:当服务A动作1失败后,可以选择不投递消息到MQ中直接熔断,就可以规避一个动作1成功,动作2失败的流程,出现不一致问题。
- 3:基于MQ的至少投递一次原则,服务A只要成功消息的投递,就可以相信服务B一定可以消费到该消息,当然什么时间我们不确定,只是确定肯定会消费到,且执行动作2,至于成不成功我可不管。
明显,优势中有着非常严重的问题:
- 1:服务A执行动作1,数据库已经修改了,且发送消息到了MQ中,但此时服务B执行动作2的时候,由于某些原因执行失败了,并非是网络原因,有可能就是业务流程,比如库存已经为0了,不能再扣除了。这就缺乏服务A回滚动作一的机制,所以动作1执行成功,动作2失败,就造成数据不一致的问题。
- 动作一和动作二本质上无法保证原子性,因为动作1是和自己数据库交互,动作2是和MQ交互,有可能服务1投递到MQ失败,这也会造成数据不一致的问题。
所以,分布式事务出现问题,这也是事务消息的最大的一个问题,在本身提供的基础上是无能力为的一个问题。
针对于问题1,如果要解决这一个问题,那就需要不断的重重回调,这已经违背了事务消息原本的主流实现了,已经造成额外的问题了。
而问题2的思路,也就是本地事务和消息投递为原子性,一气呵成,可以接着往下看。
本地事务与消息投递顺序
基于刚刚的问题2,涉及到两个问题,一个是本地事务,一个是消息投递,这两个步骤有两种顺序,一个是先执行本地事务再消息投递,一个是先消息投递在执行本地事务。我们两个分别看下:
先事务再消息
这种方式,就是执行本地事务成功,然后发送MQ消息失败。
这种的好处就是,在出现本地事务失败了,我们可以控制不去投递消息到MQ中,这就保证了一定是在本地事务成功之后,才进行投递,否则熔断,初步解决了数据不一致。
但是也是相反的,如果本地事务成功了,但是MQ消息投递失败,消息无法发出,但是本地事务已经提交了,要执行回滚操作会存在很高的成本。
先消息再事务
这种方式,就是先发生消息出去,然后再执行本地事务,这样有个问题就是,假设我发送消息时成功了,服务B也处理了,但是执行本地事务的是失败了,且重试也是失败的,还是会造成数据不一致的情况。因为消息一旦发出去,就覆水难收了。
由此可见,这两种方式都是不可行的,都有一定的弊端,那么,是否有一种方式可以两全其美呢?我们再来看看一个流程。
本地事务包裹消息投递
这是一种比较容易想到的实现思路,具体如下:
- 首先在本地事务中,使用begin transaction,开启事务。
- 在事务中,执行本地状态数据的更新,只是但是并未提交,在MVCC中还是一个不稳定的版本,只是创建了版本。
- 执行消息的投递操作
- 假设消息投递成功,则提交事务,commit transaction
- 如果投递失败,则回滚,rollback transaction
这样一看,好像貌似大概应该是没有问题,但是经不起推敲。。。
- 在和数据库交互的本地事务中,夹杂了第三方组件MQ的操作,这是一个比较避讳的操作,因为可能会引发长事务的风险。因为事务我们希望是在尽可能短的时间内提交,不然会锁住一些数据。
- 在和消息队列 MQ 进行交互的时候,有一些bad case产生,比如因为超时或者其他意外原因,导致出现消息在事实上已投递成功,但生产方获得的投递响应发生异常的问题,返回失败的ack,这样会导致本地事务被误回滚的问题。
- 在执行事务提交操作的时候,可能发生失败,此时事务内的数据库修改,DB会有自动 回滚机制回滚数据,但是MQ消息一经发出,就无法收回了。你总不能再发一条数据让他回滚把。
看上去这种方案,也并非两全其美,那还有没有呢?继续看
正解-事务消息
我们先上图
- 1:首先在消息队列组件中进行交互,创建一个半事务消息的概念,处于一个中间态的概念,当这个半事务开启的时候,就表明我们的分布式事务已经开启了,且明确的知道这个事务提交后,有一条消息是要被发出来的,但是此时是一个半事务消息。
- 2:生产方执行本地事务的操作,在和本地DB交互的时候,事务提交后会有一个回调结果,要么成功要么失败。
- 3.1:如果成功了,则发出一条确认消息,和之前的半事务消息进行关联。
- 3.2:如果本地事务执行失败了,则往MQ发一条取消消息,也和之前的半事务消息进行关联的。
- 4:这个MQ呢,会根据发送的是确认消息还是取消消息,去做一个对应的消息给下游。如果是确认则发送消息,如果是取消则终止流程。
这看上去是一个二阶段提交的一个事务。有一个半事务消息,这是一个MQ组件提供的能力,好像只有RocketMQ才有,kafka和rbmq是没有发送一个半事务消息的概念。
总结
但是,事务消息是有一定局限性的,我们之前也聊到过。就是不能强保证数据的一致性,服务A执行动作1,数据库已经修改了,且发送消息到了MQ中,但此时服务B执行动作2的时候,由于某些原因执行失败了,并非是网络原因,有可能就是业务流程,比如库存已经为0了,不能再扣除了。这就缺乏服务A回滚动作一的机制,所以动作1执行成功,动作2失败,就造成数据不一致的问题。
如果要实现事务的逆向回滚,就必然需要构建打通一条由下游逆流而上回调上游的通道,这一点并不属于事务消息的范畴,实现起来也不是那么的好。
当然,我们还有其他的方法,往下看。
TCC实现方案
TCC 全称是 try-confirm-cancel,也就是将一笔状态数据的修改操作也是分为两个阶段:
- 第一阶段是 try,并不是最终的提交,而是先对资源进行锁定,通过冻结进行标识,并不是提交,还有回旋的余地。
- 第二阶段,要么是comfirm要么是cancel,如果是comfirm,那么就提交数据最终修改,如果是cancel,那么就解冻,恢复原状。
所以看出 TCC 本质上是一个2PC的提交的一个落地的方案。
架构流程
涉及到三个角色
- 第一个最简单,和TCC架构没有太强的关系,是一个业务应用方,理解为甲方,开启分布式事务的应用方,使用能力的甲方。
- 第二个是事务协调器 TX manager,它是非常重要的角色,承上启下的中枢,包括两阶段的流程,也是由事务协调器来做的,和业务应用方的交互也是它来做的
- 第三个就是TCC的组件,理解为分布式下面的系统,或者是微服务的服务,比如服务1,服务2等等。我们在接入的时候,有一定的成本的,需要把这些组件改造成Try、confirm、cancel三个对应的方法格式化,并且向外暴露接口。try就是一个冻结态、confirm是一个提交,修改置为成功,cancel就把数据回滚。
好了,整体流程如下:
- 1:应用方先和事务协调器交互,调用开始事务的一个方法,开启的是需要告诉事务协调这次分布式事务涉及到和哪些组件进行交互,而且把所需要的参数都需要传给事务协调器。
- 2:事务协调器会在它的存储介质里面维护好对应的事务日志,后续可能会有一些记录,比如哪些TCC组件成功,哪些还没响应,哪些失败了等等状态标识,存储下来。
- 3:事务协调器批量的调用所涉及到的组件的try接口,相当于把变更的请求,以一个中间态的形式尝试进行一个冻结。
- 4:事务协调器会把每一个事务的反馈响应记录下来,假设这些所有的反馈都成功,对于事务协调器而言,它就认为已经成功了。
- 5:全部反馈成功后,它会给业务应用方一个事务成功的响应。
- 6.1:批量的调用所有组件的confirm接口进行落地存储,事务提交
- 6.2:如果第一个阶段try,但凡有一个try失败,则认为整个分布式事务失败,此时事务协调器会进行标识,同时给到应用方一个失败响应,且对所有组件调用cancel接口,回滚第一阶段的操作。
这些大概就是整个的流程,看完后是不是有一个疑问,就是事务协调器为什么是在调用try接口之后,然后给到业务应用方一个成功或者失败响应,不应该是等到批量调用组件的第二阶段接口后再给出业务应用方的反馈吗?
答案是不需要的,因为在TCC两阶段当中,其实只有第一阶段是有余地的,根据第一阶段的结果来做第二阶段的选择权,一旦第一阶段落定之后,这个实物的最终结果已经落地,要么成功要么失败。最终会通过一个合理的、尽可能接近百分百的机制来保障第二阶段的confirm或者cancel的批量成功,也就是第二阶段是不会对我们的事务产生影响的,第二阶段也没有商量的余地了,哪怕第二阶段失败了,也是尘埃落定了。
案例分析
以上为TCC整体架构,但是都是难懂的文字,比较干聊,下面引入具体的分布式事务场景。
以下是一个电商的支付请求,采用微服务架构,需要前后状态保持一致:
- 首选需要在订单模块中创建这笔订单的流水记录
- 然后在账户模块中,,对用户的账户进行相对应金额的扣减
- 最后在库存模块中,对商品的库存数量进行扣减
这需要三个微服务模块的状态数据始终保持高度一致性。要么都是成功,要么都是失败。
明显可以看出,这里面有三个TCC组件,分别是订单模块、账户模块、库存模块。这三个组件都需要暴露出Try、CConfirm、Cancel三个Api对应于冻结资源、确认更新资源和回滚解冻资源的三个行为。
然后基于APP注册的方法,把这三个方法注册到事务协调器中,且在组件表中给出一个唯一的id,这样后续通过id就可以找到对应的组件。
这个事务协调器,理解为一个关系型数据库,需要把每一笔事务都记录到事务日志中去。这个日志中会有一个状态的细分,核心就是除了成功或者失败外,还需要有一个status状态去表示try的冻结状态,也就是各个模块的记录表中,需要有一个status的状态。分别对应成功、冻结、失败的细分。
流程
- 三个TCC的组件,已经注册到事务协调器的组件表当中了
- 用户,application提交事务,调用事务协调器的入口方法
- 事务协调器就知道了,本次分布式事务具体需要用到哪些TCC组件,然后在事务日志表中把新开启的事务记录下来。
- 批量调用所有组件,然后再记录TCC组件的各个状态的回调情况,比如组件的try方法是否给了callback回来,是否成功或者失败等等。
- 根据所在第一轮当中收集到的try信息,判断所有的try接口全部成功。
- 如果全部成功,则调用涉及到组件的confirm方法。
- 有一个不成功,则调用全部涉及到的组件的cancel方法。
我们来做个总结吧:
首先try的容错率是比较高的,因为有第二阶段做一个兜底,try只是一个尝试的操作,是一个中间态,作为一个状态的标识,后续还有进一步的操作,所以无论是成功还是失败,都会进行修正。
这就说明了,我们在第二阶段的confirm和cancel操作是没有容错的,因为第二阶段没有兜底了,我们要尽可能的保证成功。那么,我们怎么样保证呢?
第二阶段兜底
以上,我们也有一个解决方案,就是在第二阶段中,事务协调器有一个轮询重试机制,外加TCC component幂等去重动作,去保证confirm、cancel第二阶段至少会被TCC component执行一次。
如下图流程:
- 启动一个轮询任务 tick。
- 对于事务日志表中,所有还没有更新为成功/失败的对应终态的事务,需要摘出进行检查。 比如在第二阶段中的cancel全部都执行成功的情况下,才会把最终态置为失败,或者是在第二阶段全部confirm成功的情况下,会把最终态置为成功。这样才会在失败或者成功的情况下,通过轮询的任务来去做一个兜底,只要是第二阶段没有进入终态,我们就认为第二阶段任务还没执行好,就需要基于轮询进行重试。
- 当然,轮询的时候也会查看当前事务是否执行的时间太久,如果太久后这个TCC组件还没答复,则也会认为这个TCC组件是失败的。
- 如果所有的事务的状态都是成功或者失败,但是最终态没有更新,说明在之前批量操作的时候可能发生了错误,这时候会补偿性的批量调用对应的接口操作,如cancel接口再次调用一次,等到所有的回调都反馈成功,就会将事务置为对应的状态。 当然,接口需要支持幂等。
- 如果事务处于进行中的状态,则会按照事务失败的方式进行处理。
所以,第二阶段的confirm或者cancel可能会出现多次执行的情况,所以在我们下游TCC组件中,需要执行幂等去重的操作。幂等去重我们知道,需要有一个唯一键作为去重的标识,这个标识键就是事务管理器在开启事务时为他分配的全局唯一的事务id,它既要作为这个事务在事务日志表中的唯一键,也是事务协调器每次想TCC 组件发起请求时,都要携带上这个事务id,来表明目前是向谁发起的。
还会有一个悬挂的问题,比如由于网络原因,先收到了的是cancel请求,后面才收到try请求,此时会将cancel请求记录下来,因为后面可能会过来try请求,如果过来后立马执行cancel请求,不然后续try过来后,cancel则永远过不来了。我们的架构中,需要保证支持空回滚的操作。
总结
以上,我们可以针对TCC做总结:
优势:
- TCC可以称为真正意义上的分布式事务,因为第一阶段有兜底,无论组件操作发生什么问题,都能支持事务的整体回滚。
- TCC的最终一致性接近于100%,因为第二阶段的成功率在重试和幂等的保证下,出问题较少。
- 事务协调器通过try操作,让tcc组件提前锁定的对应的资源,确保资源是充分的,且由于执行的状态锁定,出现的并发问题概率也是比较小的,因为在极少的时间下,一般情况是少出现并发问题。
但是,也有一定的缺点:
- TCC分布式事务中,不单单只是TCC咯,所有的分布式事务都只能保证最终趋于一致性,无法做到即时的一致性。
- 事务的原子性也只能做到趋近于100%,无法真正意义上的100%,原因就是第二阶段也会有极小的概率发生失败,即时通过重试机制也无法挽救,但是这部分小概率事件,可以通过人为介入,或者告警机制来进度兜底处理。
- TCC架构的实现成本比较高,需要所有的子模块都需要改造成TCC组件的格式,且整个事务的处理流程是相对繁重复杂的,所以在针对数据一致性要求不是那么高的场景中,不会用到这套架构,一般是电商用到比较多。