分布式事物

分布式事务解决方案

在单个数据库的情况下,数据事务操作具有 ACID 四个特性,但如果在一个事务中操作多个数据库,则无法使用数据库事务来保证一致性。

也就是说,当两个数据库操作数据时,可能存在一个数据库操作成功,而另一个数据库操作失败的情况,我们无法通过单个数据库事务来回滚两个数据操作。

而分布式事务就是为了解决在同一个事务下,不同节点的数据库操作数据不一致的问题。在一个事务操作请求多个服务或多个数据库节点时,要么所有请求成功,要么所有请求都失败回滚回去。通常,分布式事务的实现有多种方式,例如 XA 协议实现的二阶提交(2PC)、三阶提交 (3PC),以及 TCC 补偿性事务。

在了解 2PC 和 3PC 之前,我们有必要先来了解下 XA 协议。XA 协议是由 X/Open 组织提出的一个分布式事务处理规范,目前 MySQL 中只有 InnoDB 存储引擎支持 XA 协议。

XA 规范

在 XA 规范之前,存在着一个 DTP 模型,该模型规范了分布式事务的模型设计。

DTP 规范中主要包含了 AP、RM、TM 三个部分,其中 AP 是应用程序,是事务发起和结束的地方;RM 是资源管理器,主要负责管理每个数据库的连接数据源;TM 是事务管理器,负责事务的全局管理,包括事务的生命周期管理和资源的分配协调等。

XA 则规范了 TM 与 RM 之间的通信接口,在 TM 与多个 RM 之间形成一个双向通信桥梁,从而在多个数据库资源下保证 ACID 四个特性。

二阶提交(2PC)

XA 规范实现的分布式事务属于二阶提交事务,顾名思义就是通过两个阶段来实现事务的提交。

第一阶段,应用程序向事务管理器(TM)发起事务请求,而事务管理器则会分别向参与的各个资源管理器(RM)发送事务预处理请求(Prepare),此时这些资源管理器会打开本地数据库事务,然后开始执行数据库事务,但执行完成后并不会立刻提交事务,而是向事务管理器返回已就绪(Ready)或未就绪(Not Ready)状态。如果各个参与节点都返回状态了,就会进入第二阶段。

第二阶段,如果资源管理器返回的都是就绪状态,事务管理器则会向各个资源管理器发送提交(Commit)通知,资源管理器则会完成本地数据库的事务提交,最终返回提交结果给事务管理器。

在第二阶段中,如果任意资源管理器返回了未就绪状态,此时事务管理器会向所有资源管理器发送事务回滚(Rollback)通知,此时各个资源管理器就会回滚本地数据库事务,释放资源,并返回结果通知。

二阶段提交的算法思路可以概括为:协调者向参与者下发请求事务操作,参与者接收到请求后,进行相关操作并将操作结果通知协调者,协调者根据所有参与者的反馈结果决定各参与者是要提交操作还是撤销操作。

二阶事务提交也存在一些缺陷。

第一,在整个流程中,我们会发现各个资源管理器节点存在阻塞,只有当所有的节点都准备完成之后,事务管理器才会发出进行全局事务提交的通知,这个过程如果很长,则会有很多资源管理器节点长时间占用资源,从而影响整个节点的性能。只有协调者有超时机制,在第二阶段中,如果资源管理器(RM)一直没有收到事务管理器(TM)的“回滚”或者“提交”操作,那么资源管理器会一直阻塞。

第二,仍然存在数据不一致的可能性。例如,在最后通知提交全局事务时,由于网络故障,部分节点有可能收不到通知,由于这部分节点没有提交事务,就会导致数据不一致的情况出现。

第三,单点故障。一旦事务管理器发生故障,整个系统都处于停滞状态。尤其是在提交阶段,一旦事务管理器发生故障,资源管理器会由于等待事物管理器的消息,而一直锁定事务资源,导致整个系统被阻塞。

三阶提交(3PC)

三阶段提交协议就有 CanCommit、PreCommit、DoCommit 三个阶段,下面我们来看一下这个三个阶段。

第一,CanCommit 阶段。

协调者向参与者发送请求操作(CanCommit 请求),询问参与者是否可以执行事务提交操作,然后等待参与者的响应;参与者收到 CanCommit 请求之后,回复 Yes,表示可以顺利执行事务;否则回复 No。

第二,PreCommit 阶段。

协调者根据参与者第一阶段(CanCommit)的回复情况,来决定是否可以进行 PreCommit 操作(预提交阶段)。

  1. 如果所有参与者回复的都是“Yes”,协调者向参与者发送 PreCommit 请求,进入预提交阶段。

  2. 参与者接收到 PreCommit 请求后执行事务操作,并将 Undo 和 Redo 信息记录到事务日志中。如果参与者成功执行了事务操作,则返回 ACK 响应,同时开始等待最终指令。

  3. 假如任何一个参与者向协调者发送了“No”消息,或者协调者等待超时之后都没有收到参与者的响应,就执行中断事务的操作:协调者向所有参与者发送“Abort”消息,参与者收到“Abort”消息之后,则执行事务的中断操作;

    如果参与者超时后仍未收到协调者的PreCommit消息,也会执行事务的中断操作。

预提交阶段保证了在最后提交阶段(DoCmmit 阶段)之前所有参与者的状态是一致的。

第三,DoCommit 阶段。

DoCmmit 阶段进行真正的事务提交,根据 PreCommit 阶段协调者发送的消息,进入执行提交阶段或事务中断阶段。

执行提交阶段:若协调者接收到所有参与者发送的 Ack 响应,则向所有参与者发送 DoCommit 消息,开始执行阶段。参与者接收到 DoCommit 消息之后,正式提交事务。完成事务提交之后,释放所有锁住的资源,并向协调者发送 Ack 响应。协调者接收到所有参与者的 Ack 响应之后,完成事务。

事务中断阶段:协调者向所有参与者发送 Abort 请求。参与者接收到 Abort 消息之后,利用其在 PreCommit 阶段记录的 Undo 信息执行事务的回滚操作,释放所有锁住的资源,并向协调者发送 Ack 消息。协调者接收到参与者反馈的 Ack 消息之后,执行事务的中断,并结束事务。

3PC 把 2PC 的准备阶段分为了准备阶段和预处理阶段,在第一阶段只是询问各个资源节点是否可以执行事务,而在第二阶段,所有的节点反馈可以执行事务,才开始执行事务操作,最后在第三阶段执行提交或回滚操作。并且在事务管理器和资源管理器中都引入了超时机制,如果在第三阶段,资源管理器一直无法收到来自事物管理器的提交或回滚请求,它就会在超时之后,继续提交事务。

所以 3PC 可以通过超时机制,避免事物管理器挂掉所造成的长时间阻塞问题,但其实这样还是无法解决在最后提交全局事务时,由于网络故障无法通知到一些节点的问题,特别是回滚通知,这样会导致事务等待超时从而默认提交。

事务补偿机制(TCC)

以上这种基于 XA 规范实现的事务提交,由于阻塞等性能问题,有着比较明显的低性能、低吞吐的特性。所以很难满足系统的并发性能。

除了性能问题,JTA 只能解决同一服务下操作多数据源的分布式事务问题,换到微服务架构下,可能存在同一个事务操作,分别在不同服务上连接数据源,提交数据库操作。

而 TCC 正是为了解决以上问题而出现的一种分布式事务解决方案。TCC 采用最终一致性的方式实现了一种柔性分布式事务,与 XA 规范实现的二阶事务不同的是,TCC 的实现是基于服务层实现的一种二阶事务提交。

TCC 分为三个阶段,即 Try、Confirm、Cancel 三个阶段。

  • Try 阶段:主要尝试执行业务,执行各个服务中的 Try 方法,主要包括预留操作;
  • Confirm 阶段:确认 Try 中的各个方法执行成功,然后通过 TM 调用各个服务的 Confirm 方法,这个阶段是提交阶段;
  • Cancel 阶段:当在 Try 阶段发现其中一个 Try 方法失败,例如预留资源失败、代码异常等,则会触发 TM 调用各个服务的 Cancel 方法,对全局事务进行回滚,取消执行业务。

以上执行只是保证 Try 阶段执行成功或失败的提交和回滚操作,你肯定会想到,如果在 Confirm 和 Cancel 阶段出现异常情况,那 TCC 该如何处理呢?此时 TCC 会不停地重试调用失败的 Confirm 或 Cancel 方法,直到成功为止。

但 TCC 补偿性事务也有比较明显的缺点,那就是对业务的侵入性非常大。

首先,我们需要在业务设计的时候考虑预留资源;然后,我们需要编写大量业务性代码,例如 Try、Confirm、Cancel 方法;最后,我们还需要为每个方法考虑幂等性。这种事务的实现和维护成本非常高,但综合来看,这种实现是目前大家最常用的分布式事务解决方案。

可靠消息最终一致性

2PC 和 3PC 核心思想均是以集中式的方式实现分布式事务,这两种方法都存在两个共同的缺点,一是,同步执行,性能差;二是,数据不一致问题。为了解决这两个问题,通过分布式消息来确保事务最终一致性的方案便出现了。

在 eBay 的分布式系统架构中,架构师解决一致性问题的核心思想就是:将需要分布式处理的事务通过消息或者日志的方式异步执行,消息或日志可以存到本地文件、数据库或消息队列中,再通过业务规则进行失败重试。

基于分布式消息的最终一致性方案的事务处理,引入了一个消息中间件(在本案例中,我们采用 Message Queue,MQ,消息队列),用于在多个应用之间进行消息传递。实际使用中,阿里就是采用 RocketMQ 机制来支持消息事务。

本地事务与消息发送的原子性问题

本地事务与消息发送的原子性问题:事务发起方在本地事务执行成功后消息必须发出去,否则就丢弃消息。即实
现本地事务和消息发送的原子性,要么都成功,要么都失败。本地事务与消息发送的原子性问题是实现可靠消息最
终一致性方案的关键问题。
第一种方案:先发送消息,再操作数据库:

begin transaction;
// 1. 发送MQ
// 2. 数据库操作
commit transation;

这种情况无法保证数据库操作与发送消息的一致性,因为可能会存在消息发送成功但是数据库操作失败

第二种方案:先操作数据库,再发送消息

begin transaction;
// 1. 数据库操作
// 2. 发送MQ
commit transation;

这种情况看起来好像没有问题,如果消息发送失败并抛出异常,数据库事物就可以回滚。

如果是发送消息超时异常,此时抛出异常就会回滚数据库事物,但其实消息是已经发送成功了的,这也会导致数据不一致的情况

解决方案

本地消息表方案

本地消息表这个方案最初是eBay提出的,此方案的核心是通过本地事务保证数据业务操作和消息的一致性,然后
通过定时任务将消息发送至消息中间件,待确认消息发送给消费方成功再将消息删除。

我们“发消息”这个过程,目的往往是通知另外一个系统或者模块去更新数据,消息队列中的“事务”,主要解决的是消息生产者和消息消费者的数据一致性问题。

拿我们熟悉的电商来举个例子。一般来说,用户在电商 APP 上购物时,先把商品加到购物车里,然后几件商品一起下单,最后支付,完成购物流程,就可以愉快地等待收货了。

这个过程中有一个需要用到消息队列的步骤,订单系统创建订单后,发消息给购物车系统,将已下单的商品从购物车中删除。因为从购物车删除已下单商品这个步骤,并不是用户下单支付这个主要流程中必需的步骤,使用消息队列来异步清理购物车是更加合理的设计。

以上图为例:共有两个微服务交互,订单服务和购物车服务,订单服务负责添创建订单,购物车服务负责清理购物车。

交互流程如下:

  1. 创建订单

    订单服务在本地事务创建订单和增加 ”清理购物车消息日志“。(订单表和消息表通过本地事务保证一致)
    下边是伪代码

    begin transaction;
    // 1. 存储订单
    // 2. 存储清理购物车消息日志
    commit transation;

    这种情况下,本地存储订单操作与存储清理购物车消息日志处于同一个事务中,这两个操作具备原子性。

  2. 定时任务扫描日志

    如何保证将消息发送给消息队列呢?经过第一步消息已经写到消息日志表中,可以启动独立的线程,定时对消息日志表中的消息进行扫描并发送至消息中间件,在消息中间件反馈发送成功后删除该消息日志,否则等待定时任务下一周期重试。

  3. 消费消息

    如何保证消费者一定能消费到消息呢?这里可以使用MQ的ack(即消息确认)机制,消费者监听MQ,如果消费者接收到消息并且业务处理完成后向MQ发送ack(即消息确认),此时说明消费者正常消费消息完成,MQ将不再向消费者推送消息,否则消费者会不断重试向消费者来发送消息。

    购物车服务接收到”清理购物车“消息,开始清理购物车,购物车清理成功后向消息中间件回应ack,否则消息中间件将重复投递此消息。由于消息会重复投递,购物车服务的”清理购物车“功能需要实现幂等性。

消息队列实现分布式事务

事务消息需要消息队列提供相应的功能才能实现,Kafka 和 RocketMQ 都提供了事务相关功能。

回到订单和购物车这个例子,我们一起来看下如何用消息队列来实现分布式事务。

首先,订单系统在消息队列上开启一个事务。然后订单系统给消息服务器发送一个“半消息”,这个半消息不是说消息内容不完整,它包含的内容就是完整的消息内容,半消息和普通消息的唯一区别是,在事务提交之前,对于消费者来说,这个消息是不可见的。

半消息发送成功后,订单系统就可以执行本地事务了,在订单库中创建一条订单记录,并提交订单库的数据库事务。然后根据本地事务的执行结果决定提交或者回滚事务消息。如果订单创建成功,那就提交事务消息,购物车系统就可以消费到这条消息继续后续的流程。如果订单创建失败,那就回滚事务消息,购物车系统就不会收到这条消息。这样就基本实现了“要么都成功,要么都失败”的一致性要求。

如果你足够细心,可能已经发现了,这个实现过程中,有一个问题是没有解决的。如果在第四步提交事务消息时失败了怎么办?对于这个问题,Kafka 和 RocketMQ 给出了 2 种不同的解决方案。

Kafka 的解决方案比较简单粗暴,直接抛出异常,让用户自行处理。我们可以在业务代码中反复重试提交,直到提交成功,或者删除之前创建的订单进行补偿。RocketMQ 则给出了另外一种解决方案。

在 RocketMQ 中的事务实现中,增加了事务反查的机制来解决事务消息提交失败的问题。如果 Producer 也就是订单系统,在提交或者回滚事务消息时发生网络异常,RocketMQ 的 Broker 没有收到提交或者回滚的请求,Broker 会定期去 Producer 上反查这个事务对应的本地事务的状态,然后根据反查结果决定提交或者回滚这个事务。

为了支撑这个事务反查机制,我们的业务代码需要实现一个反查本地事务状态的接口,告知 RocketMQ 本地事务是成功还是失败。

在我们这个例子中,反查本地事务的逻辑也很简单,我们只要根据消息中的订单 ID,在订单库中查询这个订单是否存在即可,如果订单存在则返回成功,否则返回失败。RocketMQ 会自动根据事务反查的结果提交或者回滚事务消息。

这个反查本地事务的实现,并不依赖消息的发送方,也就是订单服务的某个实例节点上的任何数据。这种情况下,即使是发送事务消息的那个订单服务节点宕机了,RocketMQ 依然可以通过其他订单服务的节点来执行反查,确保事务的完整性。

综合上面讲的通用事务消息的实现和 RocketMQ 的事务反查机制,使用 RocketMQ 事务消息功能实现分布式事务的流程如下图:

刚性事务与柔性事务

刚性事务,遵循 ACID 原则,具有强一致性。比如,数据库事务。

柔性事务,其实就是根据不同的业务场景使用不同的方法实现最终一致性,也就是说我们可以根据业务的特性做部分取舍,容忍一定时间内的数据不一致。

总结来讲,与刚性事务不同,柔性事务允许一定时间内,数据不一致,但要求最终一致。而柔性事务的最终一致性,遵循的是 BASE 理论。

eBay 公司的工程师 Dan Pritchett 曾提出了一种分布式存储系统的设计模式——BASE 理论。 BASE 理论包括基本可用(Basically Available)、柔性状态(Soft State)和最终一致性(Eventual Consistency)。

  • 基本可用:分布式系统出现故障的时候,允许损失一部分功能的可用性,保证核心功能可用。比如,某些电商 618 大促的时候,会对一些非核心链路的功能进行降级处理。
  • 在柔性事务中,允许系统存在中间状态,且这个中间状态不会影响系统整体可用性。比如,数据库读写分离,写库同步到读库(主库同步到从库)会有一个延时,其实就是一种柔性状态。
  • 最终一致性:事务在操作过程中可能会由于同步延迟等问题导致不一致,但最终状态下,所有数据都是一致的。

BASE 理论为了支持大型分布式系统,通过牺牲强一致性,保证最终一致性,来获得高可用性,是对 ACID 原则的弱化。ACID 与 BASE 是对一致性和可用性的权衡所产生的不同结果,但二者都保证了数据的持久性。ACID 选择了强一致性而放弃了系统的可用性。与 ACID 原则不同的是,BASE 理论保证了系统的可用性,允许数据在一段时间内可以不一致,最终达到一致状态即可,也即牺牲了部分的数据一致性,选择了最终一致性。

二阶段提交、三阶段提交方法,遵循的是 ACID 原则,而消息最终一致性方案遵循的就是 BASE 理论。

编码

本作品采用《CC 协议》,转载必须注明作者和本文链接
讨论数量: 1

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!