分布式事务

2023/2/13
  • 分布式理论的 CP -> 刚性事务(遵循 ACID,对数据要求强一致性)
  • 分布式理论的 AP + BASE -> 柔性事务(遵循 BASE,允许一定时间内不同节点的数据不一致,但要求最终一致)

# 分布式事务体系

image.png

刚性事务:分布式理论的 CP,遵循 ACID,对数据要求强一致性。

  • XA协议 是一个基于数据库层面的分布式事务协议,其分为两部分:事务管理器(Transaction Manager)和本地资源管理器(Resource Manager)。事务管理器作为一个全局的调度者,负责对各个本地资源管理器统一号令提交或者回滚。主流的诸如 Oracle、MySQL 等数据库均已实现了 XA 接口。
    • 二阶提交协议(2PC): 根据 XA 协议衍生出来而来; 引入一个作为协调者的组件来统一掌控所有参与者的操作结果并最终指示这些节点是否要把操作结果进行真正的提交; 参与者将操作成败通知协调者,再由协调者根据所有参与者的反馈情报决定各参与者是否要提交操作还是中止操作。所谓的两个阶段是指:第一阶段:准备阶段 (投票阶段) 和第二阶段:提交阶段(执行阶段)
    • 三阶提交协议(3PC): 是对两段提交(2PC)的一种升级优化,3PC 在 2PC 的第一阶段和第二阶段中插入一个准备阶段。保证了在最后提交阶段之前,各参与者节点的状态都一致。同时在协调者和参与者中都引入超时机制,当参与者各种原因未收到协调者的 commit 请求后,会对本地事务进行 commit,不会一直阻塞等待,解决了 2PC 的单点故障问题,但 3PC 还是没能从根本上解决数据一致性的问题。
  • Java事务规范
    • JTA:Java 事务 API(Java Transaction API)是一个 Java 企业版的应用程序接口,在 Java 环境中,允许完成跨越多个 XA 资源的分布式事务。
    • JTS:Java 事务服务(Java Transaction Service)是 J2EE 平台提供了分布式事务服务的具体实现规范,j2ee 服务器提供商根据 JTS 规范实现事务并提供 JTA 接口。

柔性事务:分布式理论的 AP,遵循 BASE,允许一定时间内不同节点的数据不一致,但要求最终一致。

  • 基于业务层
    • TCC: TCC(Try-Confirm-Cancel)又被称补偿事务,TCC 与 2PC 的思想很相似,事务处理流程也很相似,但 2PC 是应用于在 DB 层面,TCC 则可以理解为在应用层面的 2PC,是需要我们编写业务逻辑来实现。
    • SAGA:Saga 是由一系列的本地事务构成。每一个本地事务在更新完数据库之后,会发布一条消息或者一个事件来触发 Saga 中的下一个本地事务的执行。如果一个本地事务因为某些业务规则无法满足而失败,Saga 会执行在这个失败的事务之前成功提交的所有事务的补偿操作。Saga 的实现有很多种方式,其中最流行的两种方式是:基于事件的方式和基于命令的方式。
  • 最终一致性
    • 消息表:本地消息表的方案最初是由 eBay 提出,核心思路是将分布式事务拆分成本地事务进行处理。
    • 消息队列:基于 MQ 的分布式事务方案其实是对本地消息表的封装,将本地消息表基于 MQ 内部,其他方面的协议基本与本地消息表一致。
    • 最大努力通知:最大努力通知也称为定期校对,是对MQ事务方案的进一步优化。它在事务主动方增加了消息校对的接口,如果事务被动方没有接收到消息,此时可以调用事务主动方提供的消息校对的接口主动获取。

# 2PC 3PC

请看这里

# TCC

TCC(Try-Confirm-Cancel)又被称补偿事务,TCC 与 2PC 的思想很相似,事务处理流程也很相似,但2PC 是应用于在 DB 层面,TCC 则可以理解为在应用层面的 2PC,是需要我们编写业务逻辑来实现

TCC 它的核心思想是:针对每个操作都要注册一个与其对应的确认(Try)和补偿(Cancel)。

还拿下单扣库存解释下它的三个操作:

  • Try 阶段主要是对业务系统做检测及资源预留(例如像扣库存,资源预留可以使用 select...for update的方式锁住那条库存;或者在库存表中增加一个占用库存的字段)
  • Confirm 阶段主要是对业务系统做确认提交
  • Cancel 阶段主要是在业务执行错误,需要回滚的状态下执行的业务取消,预留资源释放。

举个例子,假入 Bob 要向 Smith 转账,思路大概是:我们有一个本地方法,里面依次调用 1、首先在 Try 阶段,要先调用远程接口把 Smith 和 Bob 的钱给冻结起来。 2、在 Confirm 阶段,执行远程调用的转账的操作,转账成功进行解冻。 3、如果第2步执行成功,那么转账成功,如果第二步执行失败,则调用远程冻结接口对应的解冻方法 (Cancel)。 image.png

# TCC 三大问题

  • 空回滚

当一个分支事务所在的服务发生宕机或者网络异常导致调用失败,并未执行 try 方法,当恢复后事务执行回滚操作就会调用此分支事务的 cancel 方法,如果 cancel 方法不能处理此种情况就会出现空回滚。

是否出现空回滚,我们需要需要判断是否执行了 try 方法,如果执行了就没有空回滚。解决方法就是当主业务发起事务时,生成一个全局事务记录,并生成一个全局唯一 ID,贯穿整个事务,再创建一张分支事务记录表,用于记录分支事务,try 执行时将全局事务 ID 和分支事务 ID 存入分支事务表中,表示执行了 try 阶段,当 cancel 执行时,先判断表中是否有该全局事务 ID 的数据,如果有则回滚,否则不做任何操作。比如 seata 的 AT模式中就有分支事务表。

  • 幂等问题

由于服务宕机或者网络问题,方法的调用可能出现超时,为了保证事务正常执行我们往往会加入重试的机制,因此就需要保证 confirm 和 cancel 阶段操作的幂等性。

我们可以在分支事务记录表中增加事务执行状态,每次执行 confirm 和 cancel 方法时都查询该事务的执行状态,以此判断事务的幂等性。

  • 悬挂问题

TCC 中,在调用 try 之前会先注册分支事务,注册分支事务之后,调用出现超时,此时 try 请求还未到达对应的服务,因为调用超时了,所以会执行 cancel 调用,此时 cancel 已经执行完了,然而这个时候 try 请求到达了,这个时候执行了 try 之后就没有后续的操作了,就会导致资源挂起,无法释放。

执行 try 方法时我们可以判断 confirm 或者 cancel 方法是否执行,如果执行了那么就不执行 try 阶段。同样借助分支事务表中事务的执行状态。如果已经执行了 confirm 或者 cance l那么 try 就执行。

# Saga 事务(长事务解决方案)

Saga 是由一系列的本地事务构成。每一个本地事务在更新完数据库之后,会发布一条消息或者一个事件来触发Saga 中的下一个本地事务的执行。如果一个本地事务因为某些业务规则无法满足而失败,Saga 会执行在这个失败的事务之前成功提交的所有事务的补偿操作。 Saga 的实现有很多种方式,其中最流行的两种方式是:

  • 基于事件的方式。这种方式没有协调中心,整个模式的工作方式就像舞蹈一样,各个舞蹈演员按照预先编排的动作和走位各自表演,最终形成一只舞蹈。处于当前 Saga 下的各个服务,会产生某类事件,或者监听其它服务产生的事件并决定是否需要针对监听到的事件做出响应。
  • 基于命令的方式。这种方式的工作形式就像一只乐队,由一个指挥家(协调中心)来协调大家的工作。协调中心来告诉 Saga 的参与方应该执行哪一个本地事务。

假设一个完整的订单流程包含了如下几个服务:

  1. Order Service:订单服务
  2. Payment Service:支付服务
  3. Stock Service:库存服务
  4. Delivery Service:物流服务

image.png

# 基于事件的方式

在基于事件的方式中,第一个服务执行完本地事务之后,会产生一个事件。其它服务会监听这个事件,触发该服务本地事务的执行,并产生新的事件。

采用基于事件的saga模式的订单处理流程如下: image.png

  • 订单服务创建一笔新订单,将订单状态设置为"待处理",产生事件ORDER_CREATED_EVENT。
  • 支付服务监听ORDER_CREATED_EVENT,完成扣款并产生事件BILLED_ORDER_EVENT。
  • 库存服务监听BILLED_ORDER_EVENT,完成库存扣减和备货,产生事件ORDER_PREPARED_EVENT。
  • 物流服务监听ORDER_PREPARED_EVENT,完成商品配送,产生事件ORDER_DELIVERED_EVENT。
  • 订单服务监听ORDER_DELIVERED_EVENT,将订单状态更新为"完成"。

在这个流程中,订单服务很可能还会监听BILLED_ORDER_EVENT,ORDER_PREPARED_EVENT来完成订单状态的实时更新。将订单状态分别更新为"已经支付"和"已经出库"等状态来及时反映订单的最新状态。

# 该模式下分布式事务的回滚

为了在异常情况下回滚整个分布式事务,我们需要为相关服务提供补偿操作接口。

假设库存服务由于库存不足没能正确完成备货,我们可以按照下面的流程来回滚整个 Saga 事务: image.png

  1. 库存服务产生事件PRODUCT_OUT_OF_STOCK_EVENT。
  2. 订单服务和支付服务都会监听该事件并做出响应:
  3. 支付服务完成退款。
  4. 订单服务将订单状态设置为"失败"。

# 基于事件方式的优缺点

优点:简单且容易理解。各参与方相互之间无直接沟通,完全解耦。这种方式比较适合整个分布式事务只有 2-4个步骤的情形。

缺点:这种方式如果涉及比较多的业务参与方,则比较容易失控。各业务参与方可随意监听对方的消息,以至于最后没人知道到底有哪些系统在监听哪些消息。更悲催的是,这个模式还可能产生环形监听,也就是两个业务方相互监听对方所产生的事件。

接下来,我们将介绍如何使用命令的方式来克服上面提到的缺点

# 基于命令的方式

在基于命令的方式中,我们会定义一个新的服务,这个服务扮演的角色就和一支交响乐乐队的指挥一样,告诉各个业务参与方,在什么时候做什么事情。我们管这个新服务叫做协调中心。协调中心通过命令/回复的方式来和Saga中其它服务进行交互。

我们继续以之前的订单流程来举例。下图中的 Order Saga Orchestrator 就是新引入的协调中心。 image.png

  • 订单服务创建一笔新订单,将订单状态设置为"待处理",然后让 Order Saga Orchestrator(OSO)开启创建订单事务。
  • OSO 发送一个"支付命令"给支付服务,支付服务完成扣款并回复"支付完成"消息。
  • OSO 发送一个"备货命令"给库存服务,库存服务完成库存扣减和备货,并回复"出库"消息。
  • OSO 发送一个"配送命令"给物流服务,物流服务完成配送,并回复"配送完成"消息。
  • OSO 向订单服务发送"订单结束命令"给订单服务,订单服务将订单状态设置为"完成"。
  • OSO 清楚一个订单处理 Saga 的具体流程,并在出现异常时向相关服务发送补偿命令来回滚整个分布式事务。

# 该模式下分布式事务的回滚

image.png

  • 库存服务回复 OSO 一个"库存不足"消息。
  • OSO 意识到该分布式事务失败了,触发回滚流程:
  • OSO 发送"退款命令"给支付服务,支付服务完成退款并回复"退款成功"消息。
  • OSO 向订单服务发送"将订单状态改为失败命令",订单服务将订单状态更新为"失败"。

# 基于命令方式的优缺点

优点:

  1. 避免了业务方之间的环形依赖。
  2. 将分布式事务的管理交由协调中心管理,协调中心对整个逻辑非常清楚。
  3. 减少了业务参与方的复杂度。这些业务参与方不再需要监听不同的消息,只是需要响应命令并回复消息。
  4. 测试更容易(分布式事务逻辑存在于协调中心,而不是分散在各业务方)。
  5. 回滚也更容易。

缺点:

  1. 一个可能的缺点就是需要维护协调中心,而这个协调中心并不属于任何业务方。

# 本地消息表

通过在事务主动发起方额外新建事务消息表,事务发起方处理业务和记录事务消息在本地事务中完成,轮询事务消息表的数据发送事务消息,事务被动方基于消息中间件消费事务消息表中的事务。

整体的流程如下图: image.png 上图中整体的处理步骤如下:

  1. 事务主动方在同一个本地事务中处理业务和写消息表操作
  2. 事务主动方通过消息中间件,通知事务被动方处理事务通知事务待消息。消息中间件可以基于 Kafka、RocketMQ 消息队列,事务主动方主动写消息到消息队列,事务消费方消费并处理消息队列中的消息。
  3. 事务被动方通过消息中间件,通知事务主动方事务已处理的消息。
  4. 事务主动方接收中间件的消息,更新消息表的状态为已处理。

一些必要的容错处理如下:

  • 当 1,2 处理出错,由于还在事务主动方的本地事务中,直接回滚即可
  • 当3 处 理出错,由于事务主动方本地保存了消息,只需要轮询消息重新通过消息中间件发送,事务被动方重新读取消息处理业务即可。
  • 如果是业务上处理失败,事务被动方可以发消息给事务主动方回滚事务
  • 如果事务被动方已经消费了消息,事务主动方需要回滚事务的话,需要发消息通知事务主动方进行回滚事务。

优点

  • 从应用设计开发的角度实现了消息数据的可靠性,消息数据的可靠性不依赖于消息中间件,弱化了对 MQ 中间件特性的依赖。
  • 方案轻量,容易实现。

缺点

  • 与具体的业务场景绑定,耦合性强,不可公用。
  • 消息数据与业务数据同库,占用业务系统资源。
  • 业务系统在使用关系型数据库的情况下,消息服务性能会受到关系型数据库并发性能的局限。

# 事务消息(可靠消息事务)

基于 MQ 的分布式事务方案其实是对本地消息表的封装,将本地消息表基于 MQ 内部,其他方面的协议基本与本地消息表一致。

事务消息方案整体流程和本地消息表的流程很相似,如下图: image.png 从上图可以看出和本地消息表方案唯一不同就是将本地消息表存在了MQ内部,而不是业务数据库中。 那么MQ内部的处理尤为重要,下面主要基于 RocketMQ 4.3 之后的版本介绍 MQ 的分布式事务方案。

在本地消息表方案中,保证事务主动方发写业务表数据和写消息表数据的一致性是基于数据库事务,RocketMQ 的事务消息相对于普通 MQ提供了 2PC 的提交接口,方案如下:

# 正常情况:事务主动方发消息

image.png 这种情况下,事务主动方服务正常,没有发生故障,发消息流程如下:

  • 发送方向 MQ 服务端(MQ Server)发送 half 消息。
  • MQ Server 将消息持久化成功之后,向发送方 ack 确认消息已经发送成功。
  • 发送方开始执行本地事务逻辑。
  • 发送方根据本地事务执行结果向 MQ Server 提交二次确认(commit 或是 rollback)。
  • MQ Server 收到 commit 状态则将半消息标记为可投递,订阅方最终将收到该消息;MQ Server 收到 rollback 状态则删除半消息,订阅方将不会接受该消息。

# 异常情况:事务主动方消息恢复

image.png 在断网或者应用重启等异常情况下,图中 4 提交的二次确认超时未到达 MQ Server,此时处理逻辑如下:

  • MQ Server 对该消息发起消息回查。
  • 发送方收到消息回查后,需要检查对应消息的本地事务执行的最终结果。
  • 发送方根据检查得到的本地事务的最终状态再次提交二次确认。
  • MQ Server 基于 commit/rollback 对消息进行投递或者删除。

优点 相比本地消息表方案,MQ 事务方案优点是:

  • 消息数据独立存储 ,降低业务系统与消息系统之间的耦合。
  • 吞吐量大于使用本地消息表方案。

缺点

  • 一次消息发送需要两次网络请求(half 消息 + commit/rollback 消息) 。
  • 业务处理服务需要实现消息状态回查接口。

# 最大努力通知

最大努力通知也称为定期校对,是对事务消息方案的进一步优化。它在事务主动方增加了消息校对的接口,如果事务被动方没有接收到消息,此时可以调用事务主动方提供的消息校对的接口主动获取。

最大努力通知的整体流程如下图: image.png 在可靠消息事务中,事务主动方需要将消息发送出去,并且消息接收方成功接收,这种可靠性发送是由事务主动方保证的;

但是最大努力通知,事务主动方尽最大努力(重试,轮询....)将事务发送给事务接收方,但是仍然存在消息接收不到,此时需要事务被动方主动调用事务主动方的消息校对接口查询业务消息并消费,这种通知的可靠性是由事务被动方保证的。

最大努力通知适用于业务通知类型,例如微信交易的结果,就是通过最大努力通知方式通知各个商户,既有回调通知,也有交易查询接口。

# 参考

  1. https://www.pdai.tech/md/arch/arch-z-transection.html (opens new window)
  2. https://seata.io/zh-cn/blog/tcc-mode-design-principle.html (opens new window)
  3. https://github.com/wuhuachuan712/my_data_rebuild/issues/23 (opens new window)
  4. https://benym.cn/archives/327/ (opens new window)
  5. https://cloud.tencent.com/developer/article/1551891 (opens new window)
  6. https://segmentfault.com/a/1190000040934074 (opens new window)