分布式事务

最后更新:2020-04-15

1. 本地事务

ACID

  • Atomicity 原子性,构成事务的一组SQL,要么全部生效,要么全不生效,不会出现部分生效的情况
  • Consistency 一致性,数据库经过事务操作后从一种状态转变为另一个状态。可以说原子性是从行为上描述,而一致性是从结果上描述
  • Isolation 隔离性,事务操作的数据对象 相对于 其他事务操作的数据对象相互隔离,互不影响
  • Durability 持久性,事务提交后,其结果就是永久性的,即使发生宕机(非磁盘损坏)

事务实现

对于MySQL数据库(InnoDB存储引擎)而言,隔离性是通过不同粒度的锁机制来实现事务间的隔离;原子性、一致性和持久性通过redo log重做日志和undo log回滚日志来保证的。

  • redo log,当数据库对数据做修改的时候,需要把数据页从磁盘读到buffer pool中,然后在buffer pool中进行修改,那么这个时候buffer pool中的数据页就与磁盘上的数据页内容不一致,称buffer pool的数据页为dirty page脏数据,如果这个时候发生非正常的DB服务重启,那么这些数据还没在内存,并没有同步到磁盘文件中(注意,同步到磁盘文件是个随机IO),也就是会发生数据丢失,如果这个时候,能够在有一个文件,当buffer pool中的data page变更结束后,把相应修改记录记录到这个文件(注意,记录日志是顺序IO),那么当DB服务发生crash的情况,恢复DB的时候,也可以根据这个文件的记录内容,重新应用到磁盘文件,数据保持一致。
  • undo log,undo日志用于存放数据被修改前的值,如果修改出现异常,可以使用undo日志来实现回滚操作,保证事务的一致性。另外InnoDB MVCC事务特性也是基于undo日志实现的。undo日志分为insert undo log(insert语句产生的日志,事务提交后直接删除)和update undo log(delete和update语句产生的日志,由于该undo log可能提供MVVC机制使用,所以不能再事务提交时删除)。

2. 分布式事务

对于用户来说的一个创建订单的过程,背后很可能跨越了多个应用服务。涉及诸如:订单、库存、积分、优惠券等多个微服务模块,而每个模块的数据库可能存在不同节点上,但是其中的任何一个环节都有可能程序运行错误,导致数据的不一致。

单一数据库可以简单的使用事务来保证一致性,但是分布式的问题则需要分布式的事务来控制数据的一致性。

分布式事务的产生的原因

  • 数据库分库分表

由于单表的数据量巨大导致的分库分表,在业务中如果需要进行跨库或者跨表更新,同时要保证数据的一致性,就产生了分布式事务问题。

  • 应用服务化

多个业务中心有各自的数据库,也会涉及多个数据库的一致性问题。

比如电商网站系统,业务初期可能是一个单体工程支撑整套服务,但随着系统规模进一步变大,参考康威定律,大多数公司都会将核心业务抽取出来,以作为独立的服务。商品、订单、库存、账号信息都提供了各自领域的服务,业务逻辑的执行散落在不同的服务器上。

用户如果在某网站上进行一个下单操作,那么会同时依赖订单服务、库存服务、支付扣款服务,这几个操作如果有一个失败,那下单操作也就完不成,这就需要分布式事务来保证了。

分布式事务的解决方案

分布式事务的解决方案,典型的有XA方案、 TCC 分段提交,和基于消息队列的最终一致性设计。

  • XA 方案:即“全票通过方案”,要求所有的事务系统必须全部准备好,才可以进行事务处理,这种方案其实是将事务问题抛给了各个数据库本身,好处是数据一致性很高,缺点是耗费性能,所以这种方案一般用的不多。XA方案又分为2PC和3PC
    • 2PC 两阶段提交:两阶段提交(2PC,Two-phase Commit Protocol)是非常经典的强一致性、中心化的原子提交协议,在各种事务和一致性的解决方案中,都能看到两阶段提交的应用。
    • 3PC 三阶段提交:三阶段提交协议(3PC,Three-phase_commit_protocol)是在 2PC 之上扩展的提交协议,主要是为了解决两阶段提交协议的阻塞问题,从原来的两个阶段扩展为三个阶段,增加了超时机制。
  • TCC 方案:即“局部通过方案”,要求部分事务系统准备好处理事务即可,相对比XA方案灵活了许多,同时它的处理方式是将事务问题交给系统本身处理,需要用大量的代码控制,优点是数据一致性也很高,缺点是控制事务的逻辑代码复杂冗余,性能也很差。所以这种方案也不常用。
  • 基于消息补偿的最终一致性 异步化在分布式系统设计中随处可见,基于消息队列的最终一致性就是一种异步事务机制,在业务中广泛应用。
    • 本地消息表:这是一种基于数据库:这种方案是基于数据库表的一种方案,由各个系统分建自己的消息表,记录数据的发起及接收,并给数据做状态标记,借助MQ,观察消息的状态来决定事务是否需要回滚。有点是代码量少,数据可以保持最终一致性,缺点是表需要维护,且对高并发的支持不怎么好。
    • MQ事务消息:这是一种目前市面上比较常用的方案,其原理与上述方法类似,也需要借助MQ,只是不再借助数据库的消息表,而是由系统发起一条预发送消息,当系统本身的事务执行完毕后再将MQ中的消息变为确认消息,同样其他系统接收到MQ的消息后开始处理本地事务,根据处理情况决定事务是否需要回滚。相对来说优点是事务控制较为灵活,缺点是不稳定因素较多。
  • 最大努力通知方案:这是不要求最终一致性的柔性事务。这种方案目前还不太成熟,用的也不多,其原理与上述方案类似,只是预发送消息也没有了,有系统处理完本地事务后直接发起MQ,而接受方是本地的一套专门处理事务的服务,此服务调用待接收系统的接口,以此处理事务。优点是事务节点较少,缺点是事务处理服务的维护成本较高,同时需要多个系统的接口才行。

3. XA方案

XA规范是X/Open关于分布式事务处理(DTP)的规范。规范描述了全局的事务管理器与局部的资源管理器之间的接口。XA规范的目的是允许多个资源(如数据库,应用服务器,消息队列,等等)在同一事务中访问,这样可以使ACID属性跨越应用程序而保持有效。XA使用两阶段提交来保证所有资源同时提交或回滚任何特定的事务。

事务管理器负责协调多个数据库(资源管理器)的事务,事务管理器先问问各个数据库你准备好了吗?如果每个数据库都回复OK,那么就正式提交事务,在各个数据库上执行操作;如果任何其中一个数据库回答不OK,那么就回滚事务。

这种分布式事务方案,比较适合单块应用里,跨多个库的分布式事务,而且因为严重依赖于数据库层面来搞定复杂的事务,效率很低,绝对不适合高并发的场景。

基本用的很少,不过多描述

4. TCC方案

TCC 的全称是:Try、Confirm、Cancel。

  • Try 阶段:对各个服务的资源做检测以及对资源进行锁定或者预留。

  • Confirm 阶段:这个阶段主要对业务系统做确认提交,Try阶段执行成功并开始执行 Confirm阶段时,默认Confirm阶段是不会出错的。即:只要Try成功,Confirm一定成功。要求具备幂等设计,Confirm失败后需要进行重试。

  • Cancel 阶段:如果任何一个服务的业务方法执行出错,那么这里就需要进行补偿,就是执行已经执行成功的业务逻辑的回滚操作。(把那些执行成功的回滚)Cancel操作满足幂等性

TCC又可以被称为两阶段补偿事务,第一阶段try只是预留资源,第二阶段要明确的告诉服务提供者,这个资源你到底要不要,对应第二阶段的confirm/cancel,用来清除第一阶段的影响,所以叫补偿型事务。

举个例子,假设,库存数量本来是50,那么可销售库存也是50。账户余额为50,可用余额也为50。用户下单,买了1个单价为1元的商品。流程如下:

Try阶段

  • 订单服务:修改订单的状态为支付中
  • 账户服务:账户余额不变,可用余额减1,然后将1这个数字冻结在一个单独的字段里
  • 库存服务:库存数量不变,可销售库存减1,然后将1这个数字冻结在一个单独的字段里

confirm阶段

  • 订单服务:修改订单的状态为支付完成
  • 账户服务:账户余额变为(当前值减冻结字段的值),可用余额不变(Try阶段减过了),冻结字段清0。
  • 库存服务:库存变为(当前值减冻结字段的值),可销售库存不变(Try阶段减过了),冻结字段清0。

cancel阶段

  • 订单服务:修改订单的状态为未支付
  • 账户服务:账户余额不变,可用余额变为(当前值加冻结字段的值),冻结字段清0。
  • 库存服务:库存不变,可销售库存变为(当前值加冻结字段的值),冻结字段清0。

Try 阶段失败可以 Cancel,如果 Confirm 和 Cancel 阶段失败了怎么办?

TCC 中会添加事务日志,如果 Confirm 或者 Cancel 阶段出错,则会进行重试,所以这两个阶段需要支持幂等;如果重试失败,则需要人工介入进行恢复和处理等。

优点

实际开发中,TCC 的本质是把数据库的二阶段提交上升到微服务来实现,从而避免数据库2PC中长事务引起的低性能风险。

所以说,TCC 解决了跨服务的业务操作原子性问题,比如下订单减库存,多渠道组合支付等场景,通过 TCC 对业务进行拆解,可以让应用自己定义数据库操作的粒度,可以降低锁冲突,提高系统的业务吞吐量。

缺点

缺点还是比较明显的,在2,3步中都有可能失败。TCC属于应用层的一种补偿方式,所以需要程序员在实现的时候多写很多补偿的代码,在一些场景中,一些业务流程可能用TCC不太好定义及处理。并且 Confirm、Cancel 必须保证幂等。

使用范围

强隔离性,严格一致性要求的业务活动。适用于执行时间较短的业务,比如处理账户或者收费等等。

由于TCC的侵入较大,我们可以使用TCC框架

5. 本地消息表

本地消息表这种实现方式应该是业界使用最多的,其核心思想是将分布式事务拆分成本地事务进行处理,通过消息日志的方式来异步执行,这种思路是来源于ebay。

本地消息表是一种业务耦合的设计,消息生产方需要额外建一个事务消息表,并记录消息发送状态,消息消费方需要处理这个消息,并完成自己的业务逻辑,另外会有一个异步机制来定期扫描未完成的消息,确保最终一致性。我们可以从下面的流程图中看出其中的一些细节:

消息生产方,需要额外建一个消息表,并记录消息发送状态。消息表和业务数据要在一个事务里提交,也就是说他们要在一个数据库里面。然后消息会经过MQ发送到消息的消费方。如果消息发送失败,会进行重试发送。

消息消费方,需要处理这个消息,并完成自己的业务逻辑。此时如果本地事务处理成功,表明已经处理成功了,如果处理失败,那么就会重试执行。如果是业务上面的失败,可以给生产方发送一个业务补偿消息,通知生产方进行回滚等操作。

生产方和消费方定时扫描本地消息表,把还没处理完成的消息或者失败的消息再发送一遍。如果有靠谱的自动对账补账逻辑,这种方案还是非常实用的。

示例:

1.当你支付的时候,你需要在你支付服务上新增加一个本地消息表,你需要把减去余额和减去商品库存写入到本地消息表放入同一个事务(依靠数据库本地事务保证一致性)。

2.这个时候有个定时任务去轮询这个本地事务表,把没有发送的消息,扔给商品库存服务,叫他减去商品的库存,到达商品服务之后这个时候得先写入这个服务的事务表,然后进行扣减,扣减成功后,更新事务表中的状态。

3.商品服务通过定时任务扫描消息表或者直接通知账户服务,账户服务本地消息表进行状态更新。

4.针对一些异常情况,定时扫描未成功处理的消息,进行重新发送,在商品服务接到消息之后,首先判断是否是重复的,如果已经接收,在判断是否执行,如果执行在马上又进行通知事务,如果未执行,需要重新执行需要由业务保证幂等,也就是不会多扣一次库存。

这种方案遵循BASE理论,采用的是最终一致性.

优点:一种非常经典的实现,避免了分布式事务,实现了最终一致性。

缺点:消息表会耦合到业务系统中,如果没有封装好的解决方案,会有很多杂活需要处理。

通过异步消息服务可以确保事件的正确发送,然而事件是有可能重复发送的,那么就需要消费端保证同一条事件不会重复被消费,简而言之就是保证事件消费的幂等性

如果事件本身是具备幂等性的状态型事件,如订单状态的通知(已下单、已支付、已发货等),则需要判断事件的顺序。一般通过时间戳来判断,既消费过了新的消息后,当接受到老的消息直接丢弃不予消费。如果无法提供全局时间戳,则应考虑使用全局统一的序列号。

对于不具备幂等性的事件,一般是动作行为事件,如扣款100,存款200,则应该将事件ID及事件结果持久化,在消费事件前查询事件ID,若已经消费则直接返回执行结果;若是新消息,则执行,并存储执行结果。

6. MQ事务消息

有一些第三方的MQ是支持事务消息的,比如RocketMQ,他们支持事务消息的方式也是类似于采用的二阶段提交,但是市面上一些主流的MQ都是不支持事务消息的,比如RabbitMQ和Kafka都不支持。

RocketMQ中间件的其思路大致为:

  • 第一阶段Prepared消息,会拿到消息的地址。
  • 第二阶段执行本地事务
  • 第三阶段通过第一阶段拿到的地址去访问消息,并修改状态。

也就是说在业务方法内要向消息队列提交两次请求,一次发送消息和一次确认消息。如果确认消息发送失败了RocketMQ会定期扫描消息集群中的事务消息,这时候发现了Prepared消息,它会向消息发送者确认,所以生产方需要实现一个check接口,RocketMQ会根据发送端设置的策略来决定是回滚还是继续发送确认消息。这样就保证了消息发送与本地事务同时成功或同时失败。

优点: 实现了最终一致性,不需要依赖本地数据库事务。 缺点: 实现难度大,主流MQ不支持。

7. 最大努力通知方案

最大努力通知型的特点是,业务服务在提交事务后,进行有限次数(设置最大次数限制)的消息发送,比如发送三次消息,若三次消息发送都失败,则不予继续发送。所以有可能导致消息的丢失。同时,主业务方需要提供查询接口给从业务服务,用来恢复丢失消息。最大努力通知型对于时效性保证比较差(既可能会出现较长时间的软状态),所以对于数据一致性的时效性要求比较高的系统无法使用。这种模式通常使用在不同业务平台服务或者对于第三方业务服务的通知,如银行通知、商户通知等。

最大努力通知方案涉及三个模块:

  • 上游应用,发消息到 MQ 队列。
  • 下游应用(例如短信服务、邮件服务),接受请求,并返回通知结果。
  • 最大努力通知服务,监听消息队列,将消息存储到数据库中,并按照通知规则调用下游应用的发送通知接口。

具体流程如下:

  1. 上游应用发送 MQ 消息到 MQ 组件内,消息内包含通知规则和通知地址
  2. 最大努力通知服务监听到 MQ 内的消息,解析通知规则并放入延时队列等待触发通知
  3. 最大努力通知服务调用下游的通知地址,如果调用成功,则该消息标记为通知成功,如果失败则在满足通知规则(例如 5 分钟发一次,共发送 10 次)的情况下重新放入延时队列等待下次触发。

最大努力通知服务表示在 不影响主业务 的情况下,尽可能地确保数据的一致性。它需要开发人员根据业务来指定通知规则,在满足通知规则的前提下,尽可能的确保数据的一致,以尽到最大努力的目的。

根据不同的业务可以定制不同的通知规则,比如通知支付结果等相对严谨的业务,可以将通知频率设置高一些,通知时间长一些,比如隔 5 分钟通知一次,持续时间 1 小时。

如果不重要的业务,比如通知用户积分增加,则可以将通知频率设置低一些,时间短一些,比如 10 分钟通知一次,持续 30 分钟。

代入上面提到的支付成功短信通知用户的案例,通过最大努力通知方案,当支付成功后,将消息发送到 MQ 中间件,在消息中,定义发送规则为 5 分钟一次,最大发送数为 10 次。

最大努力通知服务监听 MQ 消息并根据规则调用消息通知服务(短信服务)的消息发送接口,并记录每次调用的日志信息。在通知成功或者已通知 10 次时,停止通知。

主要思路

  • 系统 A 本地事务执行完之后,发送个消息到 MQ;
  • 这里会有个专门消费 MQ 的最大努力通知服务,这个服务会消费 MQ 然后写入数据库中记录下来,或者是放入个内存队列也可以,接着调用系统 B 的接口;
  • 要是系统 B 执行成功就 ok 了;要是系统 B 执行失败了,那么最大努力通知服务就定时尝试重新调用系统 B,反复 N 次,最后还是不行就放弃。

8. 补偿方案

补偿方案主要是HTTP同步调用的补偿和异步消息消费失败的补偿。

8.1. HTTP同步调用补偿

一般情况下,HTTP同步调用会得到下游系统的同步结果,对结果的处理存在下面几种常见的情况:

  • 同步结果返回正常,得到了和下游约定的最终状态,交互结束,一般认为成功就是最终状态,不需要补偿;
  • 同步结果返回正常,得到了和下游约定的非最终状态,需要定时补偿到最终状态或到达重试上限自行标记为最终状态;
  • 同步结果返回异常,最常见的是下游服务不可用返回HTTP状态码为5XX。

首先要有一个简单的认知:短时间内的HTTP重试通常情况下都是无效的。如果是瞬时的网络抖动,短时间内HTTP同步重试是可行的,大部分情况下是下游服务无法响应、下游服务重启中或者复杂的网络情况导致短时间内无法恢复,这个时候做HTTP同步重试调用往往是无效的。

如果面对的场景是内部低并发量的系统之间的进行HTTP交互,可以考虑使用基于指数退避的算法进行重试。但是,如果面对的是并发比较高、用户体验优先级比较高的场景,这样做显然是不合理的。为了稳妥起见,可以采取相对传统而有效的方案:HTTP调用的调用瞬时内容保存到一张本地重试表中,这个保存操作绑定在业务处理的事务中,通过定时调度对未调用成功的记录进行重试。

[下单接口请求下游钱包服务扣钱的过程]

process(){
    [事务代码块-start]
    1、处理业务逻辑,保存订单信息,订单状态为扣钱处理中
    2、组装将要向下游钱包服务发起的HTTP调用信息,保存在本地表中
    [事务代码块-end]
    
    3、事务外进行HTTP调用(OkHttp客户端或者Apache的Http客户端),调用成功更新订单状态为扣钱成功
}

定时调度(){
    4、定时查询订单状态为扣钱处理中的订单进行HTTP调用,调用成功更新订单状态为扣钱成功
}

8.2. 异步消息消费失败补偿

异步消息消费失败的场景发生只能在消息消费方,也就是下游服务。从降低成本的目的上看,消息消费失败的补偿应该由消息处理的一方(消费者)自行承担。

如果由上游服务进行补偿,存在两个明显的问题:

  • 消息补偿模块需要在所有的上游服务中编写,这是不合理的;
  • 一旦下游消费出现生产问题需要上游补偿,需要先定位出对应的消息是哪个上游服务推送,然后通过该上游服务进行补偿,处理生产问题的复杂度提高。

在最近的一些项目实践中,确定在使用异步消息交互的时候,补偿统一由消息消费方实现。最简单的方式也是使用类似本地消息表的方式,把消费失败的消息入库,并且进行重试,到达重试上限依然失败则进行预警和人工介入即可。简单的流程图如下:

9. 基于消息补偿的最终一致性

基于消息补偿的最终一致性的方案不仅可以解决由于业务流程的同步执行而造成的阻塞问题,还可以实现业务解耦合流量削峰,但并不是引入了消息队列中间件就万事大吉了,我们还需要思考以下问题。

MQ 自动应答机制导致的消息丢失

订阅消息事件的库存服务在接收订单服务投递的消息后,消息中间件(如 RabbitMQ)默认是开启消息自动应答机制,当库存服务消费了消息,消息中间件就会删除这个持久化的消息。

但在库存服务执行的过程中,很可能因为执行异常导致流程中断,那这时候消息中间件中就没有这个数据了,进而会导致消息丢失。因此你要采取编程的方式手动发送应答,也就是当库存服务执行业务成功之后,消息中间件才能删除这条持久化消息。

Kafka并不会删除持久化消息,但是需要类似的机制保证能重新读取未消费成功的消息

高并发场景下的消息积压导致消息丢失

分布式部署环境基于网络进行通信,而在网络通信的过程中,上下游可能因为各种原因而导致消息丢失。比如库存服务由于流量过大而触发限流,不能保证事件消息能够被及时地消费,这个消息就会被消息队列不断地重试,最后可能由于超过了最大重试次数而被丢弃到死信队列中。

但实际上,你需要人工干预处理移入死信队列的消息,于是在这种场景下,事件消息大概率会被丢弃。而这个问题源于订单服务作为事件的生产者进行消息投递后,无法感知它下游的所有操作,那么库存服务作为事件的消费者,是消费成功还是消费失败,库存系统并不知道。

顺着这个思路,如果让订单知道消费执行结果的响应,即使出现了消息丢失的情况,订单系统也还是可以通过定时任务扫描的方式,将未完成的消息重新投递来进行消息补偿。这是基于消息队列实现分布式事务的关键,是一种双向消息确认的机制

你可以先让订单服务把要发送的消息持久化到本地数据库里,然后将这条消息记录的状态设置为代发送,紧接着订单服务再投递消息到消息队列,库存系统消费成功后,也会向消息队列发送一个通知消息。当订单服务接收到这条通知消息后,再把本地持久化的这条消息的状态设置为完成。

这样做后,即使最终 MQ 出现了消息丢失,也可以通过定时任务从订单服务的本地数据库中扫描出一段时间内未完成的消息,进行重新投递,最终保证订单系统和库存服务的最终事务一致性。

10. 参考资料

https://mp.weixin.qq.com/s/7HKRkzTDu3AVP0UbeHb-zw

https://mp.weixin.qq.com/s/LmKqrK9jyi_MFIvFoHsg2Q

《架构设计面试精讲》

Edgar

Edgar
一个略懂Java的小菜比