图解分布式事务实现原理(一)

参考

本文参考https://zhuanlan.zhihu.com/p/648556608,在小徐的基础上做了个人的笔记。


分布式事务场景

事务核心特性

在聊分布式事务之前,我们先理清楚有关于 “事务” 的定义.

事务 Transaction,是一段特殊的执行程序,其需要具备如下四项核心性质:


当涉及到事务处理时,有四个核心要素,它们被称为事务的ACID四大特性,即原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability)。这些特性在关系型数据库范围内通常较容易实现,因为数据和操作都在同一个数据库内。然而,当一个事务涉及到跨越不同的数据库、服务或存储组件时,这个问题就变得更加复杂和有趣,这正是我们今天要重点讨论的“分布式事务”领域所涉及的问题。

分布式事务概念

由于数据库的拆分或分布式架构(微服务)不可避免的带来了分布式事务的问题。如下为当前针对分布式事务的工程实践和处理方式。

  • 基于业务逻辑和应用场景最小化分布式事务边界
    言外之意就是说应该在设计阶段尽可能规避没必要的分布式事务场景。
  • 基于 XA 的强一致性事务
    XA模式是传统的强一致性分布式事务解决方案,性能较低且锁资源竞争突出。XA的实现方式存在长事务风险且锁资源严重。在实际业务中使用较少,本文不做更多讨论。
  • 追求最终一致性的柔性事务
    柔性事务通过放宽对强一致性要求,而是通过反向补偿来达到最终一致性,同时换取系统吞吐量的提升和缓解锁资源竞争。目前,Seata 框架提供了多种事务管理模式来支持柔性事务的落地实现。

虚拟业务场景设计

下面我们通过一个常见的场景问题引出有关于分布式事务的话题.

假设我们在维护一个电商后台系统,每当在处理一笔来自用户创建订单的请求时,需要执行两步操作:

  • 从账户系统中,扣减用户的账户余额
  • 从库存系统中,扣减商品的剩余库存

从业务流程上来说,这个流程需要保证具备事务的原子性,即两个操作需要能够一气呵成地完成执行,要么同时成功,要么同时失败,不能够出现数据状态不一致的问题,比如发生从用户账户扣除了金额但商品库存却扣减失败的问题。

然而从技术流程上来讲,两个步骤是相对独立的两个操作,底层涉及到的存储介质也是相互独立的,因此无法基于本地事务的实现方式。

分布式事务的实现确实面临着很高的难度,但在业界已经提出了一套被广泛认可并应用的解决方案。这些解决方案将在后续的章节中介绍。在此之前,我们需要明确在分布式事务的实现中,所谓的数据状态一致性需要做出妥协:

  • 数据状态一致性:在分布式事务中,我们所谈论的数据状态一致性指的是数据的最终一致性,而不是即时一致性。即时一致性通常在分布式系统中难以实现,因为网络延迟和不同组件之间的通信可能导致即时一致性变得不切实际。因此,在分布式环境中,我们更关注确保数据最终达到一致的状态,即经过一段时间后,系统的各个节点都会收敛到相同的数据状态。

  • 百分之百的一致性无法保证:分布式事务中的一个根本挑战是无法百分之百地保证数据状态的一致性。这是因为分布式系统的稳定性和一致性受到网络环境的影响,以及与第三方系统的交互等多种因素的影响。即使采用了复杂的分布式事务协议和机制,也难以消除所有可能的故障和不一致性。

因此,分布式事务的实现需要在数据一致性和系统性能之间寻找平衡。通常情况下,分布式系统会采用某种程度的最终一致性,同时尽力减小数据不一致性的发生概率。这可能涉及到使用分布式事务协议、分布式锁、版本控制等技术手段,以确保在大多数情况下数据状态是一致的。但在特殊情况下,仍然需要处理可能的不一致性问题,并设计恢复机制来纠正这些问题。因此,分布式事务的实现需要权衡各种因素,以满足系统的要求和可用性目标。


事务消息方案

首先,一类偏狭义的分布式事务解决方案是基于消息队列 MessageQueue(后续简称 MQ)实现的事务消息 Transaction Message.

RocketMQ 简介

RocketMQ 是阿里基于 java 实现并托管于 apache 基金会的顶级开源消息队列组件,其中事务消息 TX Msg 也是 RocketMQ 现有的一项能力. 本章将主要基于 RocketMQ 针对事务消息的实现思路展开介绍.

RocketMQ github 地址:https://github.com/apache/rocketmq

图解分布式事务实现原理(一)_第1张图片

kafka

图解分布式事务实现原理(一)_第2张图片
Kafka(Apache Kafka)是一种高吞吐量、分布式、持久性的消息传递系统,最初由LinkedIn开发,并且后来成为了Apache软件基金会的一个顶级项目。Kafka旨在处理大量数据流,并支持实时数据流处理应用程序。
Kafka的典型用例包括日志聚合、事件溯源、监控和度量、实时数据分析、日志流式处理、电子商务订单处理等。它在大规模数据处理、实时数据流和事件驱动架构中广泛使用。

基于 MQ 实现分布式事务

我们知道在 MQ 组件中,通常能够为我们保证的一项能力是:投递到 MQ 中的消息能至少被下游消费者 consumer 消费到一次,即所谓的 at least once 语义.

基于此,MQ 组件能够保证消息不会在消费环节丢失,但是无法解决消息的重复性问题. 因此,倘若我们需要追求精确消费一次的目标,则下游的 consumer 还需要基于消息的唯一键执行幂等去重操作,在 at least once 的基础上过滤掉重复消息,最终达到 exactly once 的语义.


依赖于 MQ 中 at least once 的性质,我们简单认为,只要把一条消息成功投递到 MQ 组件中,它就一定被下游 consumer 端消费端,至少不会发生消息丢失的问题.

倘若我们需要执行一个分布式事务,事务流程中包含需要在服务 A 中执行的动作 I 以及需要在服务 B 中执行的动作 II,此时我们可以基于如下思路串联流程:

  • 以服务 A 作为 MQ 生产方 producer,服务 B 作为 MQ 消费方 consumer
  • 服务 A 首先在执行动作 I,执行成功后往 MQ 中投递消息,驱动服务 B 执行动作 II
  • 服务 B 消费到消息后,完成动作 II 的执行

对上述流程进行总结,其具备如下优势:

  • 服务 A 和服务 B 通过 MQ 组件实现异步解耦,从而提高系统处理整个事务流程的吞吐量
  • 当服务 A 执行 动作 I 失败后,可以选择不投递消息从而熔断流程,保证不会出现动作 II 执行成功,而动作 I 执行失败的不一致的问题
  • 基于 MQ at least once 的语义,服务 A 只要成功消息的投递,就可以相信服务 B 一定能消费到该消息,至少服务 B 能感知到动作 II 需要执行的这一项情报
  • 依赖于 MQ 消费侧的 ack 机制,可以实现服务 B 有限轮次的重试能力. 即当服务 B 执行动作 II 失败后,可以给予 MQ bad ack,从而通过消息重发的机制实现动作 II 的重试,提高动作 II 的执行的成功率

与之相对的,上述流程也具备如下几项局限性:

  • 问题 1:服务 B 消费到消息执行动作 II 可能发生失败,即便依赖于 MQ 重试也无法保证动作一定能执行成功,此时缺乏令服务 A 回滚动作 I 的机制. 因此很可能出现动作 I 执行成功,而动作 II 执行失败的不一致问题
  • 问题 2:在这个流程中,服务 A 需要执行的操作有两步:(1)执行动作 I;(2)投递消息. 这两个步骤本质上也无法保证原子性,即可能出现服务 A 执行动作 I 成功,而投递消息失败的问题.

图解分布式事务实现原理(一)_第3张图片

本地事务+消息投递

上面的小节中,聊到的服务 A 所要执行的操作分为两步:本地事务+消息投递. 这里我们需要如何保证这两个步骤的执行能够步调统一呢,下面不妨一起来推演一下我们的流程设计思路:

首先,这两个步骤在流程中一定会存在一个执行的先后顺序,我们首先来思考看看不同的组织顺序可能会分别衍生出怎样的问题:

组合 I:先执行本地事务,后执行消息投递

图解分布式事务实现原理(一)_第4张图片
组合 I 的优势:

  • 消息投递成功与本地事务一致:当使用组合 I 策略时,可以确保消息的投递与本地事务的执行是一致的。这意味着只有在本地事务执行成功时,消息才会被投递。这可以防止消息投递成功但本地事务失败的情况
  • 熔断机制:如果本地事务执行失败,您可以主动停止或熔断消息的投递动作。这可以防止错误的消息被发送,降低了系统可能面临的问题。

组合 I 的劣势:

  • 消息投递失败可能导致消息丢失:虽然组合 I 确保了消息投递与本地事务的一致性,但在某些情况下,消息投递可能会失败。例如,即使本地事务成功,但消息投递由于网络或其他问题而失败,导致消息丢失。此时,由于本地事务已经提交,要执行回滚操作会非常复杂和昂贵。

组合 II:先执行消息投递,后执行本地事务

图解分布式事务实现原理(一)_第5张图片
组合 II 的优势:

  • 避免不必要的本地事务:如果消息投递失败,您可以避免执行不必要的本地事务。这可以提高系统的效率,因为不会浪费资源在本地事务上,除非消息可以被成功投递。

组合 II 的劣势:

  • 消息投递成功可能导致问题:尽管组合 II 确保了本地事务与消息投递的一致性,但在某些情况下,消息投递成功可能导致问题。例如,如果消息成功发送后,本地事务一直无法成功执行,那么可能会出现数据不一致或其他问题。

对上面对流程进行梳理总结实现思路是:基于本地事务包裹消息投递操作的实现方式,对应执行步骤如下:

  • 首先 begin transaction,开启本地事务
  • 在事务中,执行本地状态数据的更新
  • 完成数据更新后,不立即 commit transaction
  • 执行消息投递操作
  • 倘若消息投递成功,则 commit transaction
  • 倘若消息投递失败,则 rollback transaction


这个流程乍一看没啥毛病,重复利用了本地事务回滚的能力,解决了本地修改操作成功、消息投递失败后本地数据修正成本高的问题.

然而,这仅仅是表现. 上述流程实际上是经不住推敲的,其中存在三个致命问题:

  • 本地事务中夹杂第三方组件的IO操作:在本地事务中执行与第三方组件的IO操作可能引发长事务的风险。长事务可能会导致数据库锁定、性能问题和资源浪费。为了缓解这个问题,可以考虑将IO操作与数据库事务解耦,将其移到事务之外,或者采用异步处理方法。
  • 消息投递可能因超时或其他问题导致异常:当消息在实际上已成功投递但生产者未能获得投递响应时,可能会导致本地事务被误回滚的问题。为了避免这种情况,可以实现幂等性操作,确保消息处理具有幂等性,以便在重试消息时不会引发问题。
  • 事务提交失败可能导致无法回滚消息:如果在执行事务提交操作时发生失败,数据库修改操作会回滚,但已经发送的MQ消息无法回收。这可能导致数据不一致性。为了处理这个问题,可以采用两阶段提交(2PC)或者分布式事务管理器来确保事务操作的一致性,包括数据库和消息的一致性。

事务消息原理TX Msg

我们以 RocketMQ 中 TX Msg 的实现方案为例展开介绍。首先抛出结论,TX Msg 能保证我们做到在本地事务执行成功的情况下,后置的投递消息操作能以接近百分之百的概率被发出. 其实现的核心流程为:

  • 生产方 producer 首先向 RocketMQ 生产一条半事务消息,此消息处于中间态,会暂存于 RocketMQ 不会被立即发出
  • producer 执行本地事务
  • 如果本地事务执行成功,producer 直接提交本地事务,并且向 RocketMQ 发出一条确认消息
  • 如果本地事务执行失败,producer 向 RocketMQ 发出一条回滚指令
  • 倘若 RocketMQ 接收到确认消息,则会执行消息的发送操作,供下游消费者 consumer 消费
  • 倘若 RocketMQ 接收到回滚指令,则会删除对应的半事务消息,不会执行实际的消息发送操作
  • 此外,在 RocketMQ 侧,针对半事务消息会有一个轮询任务,倘若半事务消息一直未收到来自 producer 侧的二次确认,则 RocketMQ 会持续主动询问 producer 侧本地事务的执行状态,从而引导半事务消息走向终态。


在 TX Msg 的实现流程中,能够保证 前面小节中谈及的各种 bad case 都能被很好地消化:

  • 倘若本地事务执行失败,则 producer 会向 RocketMQ 发出删除半事务消息的回滚指令,因此保证消息不会被发出
  • 倘若本地事务执行成功, 则 producer 会向 RocketMQ 发出事务成功的确认指令,因此消息能够被正常发出
  • 倘若 producer 端在发出第二轮的确认或回滚指令前发生意外状况,导致第二轮结果指令确实. 则 RocketMQ 会基于自身的轮询机制主动询问本地事务的执行状况,最终帮助半事务消息推进进度.

总结一下:

  • 保证本地事务成功后消息投递接近百分之百的概率:RocketMQ的TX Msg机制确保了在本地事务执行成功的情况下,消息会以接近百分之百的概率被成功发出。这是因为只有在本地事务成功后,才会向RocketMQ发送确认消息,从而触发消息的真正发送。

  • 事务性保障:RocketMQ的TX Msg允许生产者执行本地事务,确保了消息发送与事务的一致性。如果本地事务失败,消息不会被发送,从而维护了数据的一致性。

  • 回滚机制:如果本地事务执行失败,RocketMQ会接收到回滚指令,然后删除对应的半事务消息,而不会执行实际的消息发送操作。这意味着即使本地事务失败,不会导致消息被误发送,保持了数据的一致性。

  • 轮询任务:RocketMQ会定期轮询半事务消息的状态,如果长时间未收到本地事务的二次确认,RocketMQ会主动询问生产者本地事务的执行状态,确保半事务消息能够最终达到终态。

RocketMQ 中半事务消息轮询流程示意如下:

图解分布式事务实现原理(一)_第6张图片

最后,我们再回过头把 RocketMQ TX Msg 的使用交互流程总结梳理如下:

事务消息局限性

现在我们就来总结梳理一下,TX Msg 中存在的几项局限性:

  • 流程高度抽象:TX Msg 把流程抽象成本地事务+投递消息两个步骤. 然而在实际业务场景中,分布式事务内包含的步骤数量可能很多,因此就需要把更多的内容更重的内容糅合在所谓的“本地事务”环节中,上游 producer 侧可能会存在比较大的压力
  • 不具备逆向回滚能力:倘若接收消息的下游 consumer 侧执行操作失败,此时至多只能依赖于 MQ 的重发机制通过重试动作的方式提高执行成功率,但是无法从根本上解决下游 consumer 操作失败后回滚上游 producer 的问题. 这一点正是 TX Msg 中存在的最大的局限性.

关于上面第二点,我们再展开谈几句. 我们知道,并非所有动作都能通过简单的重试机制加以解决.

打个比方,倘若下游是一个库存管理系统,而对应商品的库存在事实上已经被扣减为 0,此时无论重试多少次请求都是徒然之举,这就是一个客观意义上的失败动作.

而遵循正常的事务流程,后置操作失败时,我们应该连带前置操作一起执行回滚,然而这部分能力在 TX Msg 的主流程中并没有予以体现.

要实现这种事务的逆向回滚能力,就必然需要构筑打通一条由下游逆流而上回调上游的通道,这一点并不属于 TX Msg 探讨的范畴.

你可能感兴趣的:(go语言,分布式,分布式,golang,后端)