分布式事务

1、事务的概念

1.1 本地事务

  • 在计算机系统中,更多的是通过关系型数据库来控制事务,这是利用数据库本身的事务特性来实现的,因此叫数据库事务,由于应用主要靠关系数据库来控制事务,而数据库通常和应用在同一个服务器,所以基于关系型数据库的事务又被称为本地事务。
  • 数据库事务的四大特性ACID:
    • A(Atomic):原子性,构成事务的所有操作,要么都执行完成,要么全部不执行,不可能出现部分成功部分失败的情况。
    • C(Consistency):一致性,在事务执行前后,数据库的一致性约束没有被破坏。比如:张三向李四转100元,转账前和转账后的数据是正确状态这叫一致性,如果出现张三转出100元,李四账户没有增加100元这就出现了数据错误,就没有达到一致性。
    • I(Isolation):隔离性,数据库中的事务一般都是并发的,隔离性是指并发的两个事务的执行互不干扰,一个事务不能看到其他事务运行过程的中间状态。通过配置事务隔离级别可以避脏读、重复读等问题。
    • D(Durability):持久性,事务完成之后,该事务对数据的更改会被持久化到数据库,且不会被回滚。
  • 数据库事务在实现时会将一次事务涉及的所有操作全部纳入到一个不可分割的执行单元,该执行单元中的所有操作要么都成功,要么都失败,只要其中任一操作执行失败,都将导致整个事务的回滚。

1.2 分布式事务

  • 分布式事务,就是指不是在单个服务或单个数据库架构下产生的事务,分布式系统会把一个应用系统拆分为可独立部署的多个服务,因此需要服务与服务之间远程协作才能完成事务操作,这种分布式系统环境下由不同的服务之间通过网络远程协作完成事务称之为分布式事务,分为:
    • 跨数据源的分布式事务。
    • 跨服务的分布式事务。
    • 综合情况。

1.2.1 跨数据源

  • 随着业务数据规模的快速发展,数据量越来越大,单库单表逐渐成为瓶颈。所以我们对数据库进行了水平拆分,将原单库单表拆分成数据库分片,于是就产生了跨数据库事务问题。

    1643510188184

1.2.2 跨服务

  • 在业务发展初期,”一块大饼”的单业务系统架构,能满足基本的业务需求。但是随着业务的快速发展,系统的访问量和业务复杂程度都在快速增长,单系统架构逐渐成为业务发展瓶颈,解决业务系统的高耦合、可伸缩问题的需求越来越强烈。

  • 如下图所示,按照面向服务(SOA)的架构的设计原则,将单业务系统拆分成多个业务系统,降低了各系统之间的耦合度,使不同的业务系统专注于自身业务,更有利于业务的发展和系统容量的伸缩。

1.2.3 分布式系统的数据一致性问题

  • 在数据库水平拆分、服务垂直拆分之后,一个业务操作通常要跨多个数据库、服务才能完成。在分布式网络环境下,我们无法保障所有服务、数据库都百分百可用,一定会出现部分服务、数据库执行成功,另一部分执行失败的问题。当出现部分业务操作成功、部分业务操作失败时,业务数据就会出现不一致。例如电商行业中比较常见的下单付款案例,包括下面几个行为:

    • 创建新订单。
    • 扣减商品库存。
    • 从用户账户余额扣除金额。
  • 完成上面的操作需要访问三个不同的微服务和三个不同的数据库。

    • 在分布式环境下,肯定会出现部分操作成功、部分操作失败的问题,比如:订单生成了,库存也扣减了,但是 用户账户的余额不足,这就造成数据不一致。
    • 订单的创建、库存的扣减、账户扣款在每一个服务和数据库内是一个本地事务,可以保证ACID原则。
    • 但是当我们把三件事情看做一个事情事,要满足保证“业务”的原子性,要么所有操作全部成功,要么全部失败,不允许出现部分成功部分失败的现象,这就是分布式系统下的事务了。此时ACID难以满足,这是分布式事务要解决的问题。

2、分布式事务基础理论

  • 通过前面的内容可以了解到分布式事务的基础概念。与本地事务不同的是,分布式系统之所以叫分布式,是因为提供服务的各个节点分布在不同机器上,相互之间通过网络交互。不能因为有一点网络问题就导致整个系统无法提供服务,网络因素成为了分布式事务的考量标准之一。因此,分布式事务需要更进一步的理论支持,接下来需要认识分布式事务的CAP理论。

2.1 CAP理论

  • CAP是ConsistencyAvailabilityPartition tolerance三个词语的缩写,分别表示一致性、可用性、分区容忍性。

    • 一致性(consistency):在分布式系统中的所有数据备份,在同一时刻是否同样的值。(等同于所有节点访问同一份最新的数据副本)。
    • 可用性(Availability):在集群中一部分节点故障后,集群整体是否还能响应客户端的读写请求。(对数据更新具备高可用性)
    • 分区容惜性(Partitiontolerance):大多数分布式系统都分布在多个子网络。每个子网络就叫做一个区(partition)。分区容错的意思是,区间通信可能失败。比如,一台服务器放在中国,另一台服务器放在美国,这就是两个区,它们之间可能无法通信。
  • CAP原则指的是,这三个要素最多只能同时实现两点,不可能三者兼顾。

    • CP要求一致性(有一个没同步好就不可用)。
    • AP要求高可用。
  • 为了方便对CAP理论的理解,我们结合电商系统中的一些业务场景来理解CAP。如下图,是商品信息管理的执行流程:

    1643513095455
    • 整体执行流程如下:
      • ①商品服务请求主数据库写入商品信息(添加商品、修改商品、删除商品)。
      • ②主数据库向商品服务响应写入成功。
      • ③商品服务请求从数据库读取商品信息。

2.1.1 C-Consistency

  • 一致性是指写操作后的读操作可以读取到最新的数据状态,当数据分布在多个节点上,从任意结点读取到的数据都是最新的状态。上图中,商品信息的读写要满足一致性就是要实现如下目标:
    • 商品服务写入主数据库成功,则向从数据库查询新数据也成功。
    • 商品服务写入主数据库失败,则向从数据库查询新数据也失败。
  • 实现一致性:
    • 写入主数据库后要将数据同步到从数据库。
    • 写入主数据库后,在向从数据库同步期间要将从数据库锁定,待同步完成后再释放锁,以免在新数据写入成功后,向从数据库查询到旧的数据。
  • 分布式系统一致性的特点:
    • 由于存在数据同步的过程,写操作的响应会有一定的延迟。
    • 为了保证数据一致性会对资源暂时锁定,待数据同步完成释放锁定资源。
    • 如果请求数据同步失败的结点则会返回错误信息,一定不会返回旧数据。

2.1.2 A-Availability

  • 可用性是指任何事务操作都可以得到响应结果,且不会出现响应超时或响应错误。上图中,商品信息读取满足可用性就是要实现如下目标:
    • 从数据库接收到数据查询的请求则立即能够响应数据查询结果。
    • 从数据库不允许出现响应超时或响应错误。
  • 实现可用性:
    • 写入主数据库后要将数据同步到从数据库。
    • 由于要保证从数据库的可用性,不可将从数据库中的资源进行锁定。
    • 即使数据还没有同步过来,从数据库也要返回要查询的数据,哪怕是旧数据,如果连旧数据也没有则可以按照约定返回一个默认信息,但不能返回错误或响应超时。
  • 分布式系统可用性的特点:
    • 所有请求都有响应,且不会出现响应超时或响应错误。

2.1.3 P-Partition tolerance

  • 通常分布式系统的各各结点部署在不同的子网,这就是网络分区,不可避免的会出现由于网络问题而导致结点之间通信失败,此时仍可对外提供服务,这叫分区容忍性。上图中,商品信息读写满足分区容忍性就是要实现如下目标:
    • 主数据库向从数据库同步数据失败不影响读写操作。
    • 其中一个结点挂掉不影响另一个结点对外提供服务。
  • 实现分区容忍性:
    • 尽量使用异步取代同步操作,例如使用异步方式将数据从主数据库同步到从数据,这样结点之间能有效的实现松耦合。
    • 添加从数据库结点,其中一个从结点挂掉其它从结点提供服务。
  • 分布式分区容忍性的特点:
    • 分区容忍性分是布式系统具备的基本能力。

2.1.4 CAP组合方式

  • 在所有分布式事务场景中不会同时具备CAP三个特性,因为在具备了P的前提下C和A是不能共存的。比如下图满足了P即表示实现分区容忍:

    1643515360052
    • 本图分区容忍的含义是:
      • 主数据库通过网络向从数据同步数据,可以认为主从数据库部署在不同的分区,通过网络进行交互。
      • 当主数据库和从数据库之间的网络出现问题不影响主数据库和从数据库对外提供服务。
      • 其一个结点挂掉不影响另一个结点对外提供服务。
    • 如果要实现C则必须保证数据一致性,在数据同步的时候为防止向从数据库查询不一致的数据则需要将从数据库数据锁定,待同步完成后解锁,如果同步失败从数据库要返回错误信息或超时信息。
    • 如果要实现A则必须保证数据可用性,不管任何时候都可以向从数据查询数据,则不会响应超时或返回错误信息。通过分析发现在满足P的前提下C和A存在矛盾性。
  • 在生产中对分布式事务处理时要根据需求来确定满足CAP的哪两个方面。

    • AP:放弃一致性,追求分区容忍性和可用性。这是很多分布式系统设计时的选择。例如:
      • 上边的商品管理,完全可以实现AP,前提是只要用户可以接受所查询的到数据在一定时间内不是最新的即可。
      • 通常实现AP都会保证最终一致性,后面讲的BASE理论就是根据AP来扩展的,一些业务场景比如:订单退款,今日退款成功,明日账户到账,只要用户可以接受在一定时间内到账即可。
    • CP:放弃可用性,追求一致性和分区容错性,例如zookeeper其实就是追求的强一致,又比如跨行转账,一次转账请求要等待双方银行系统都完成整个事务才算完成。
    • CA: 放弃分区容忍性,即不进行分区,不考虑由于网络不通或结点挂掉的问题,则可以实现一致性和可用性。那么系统将不是一个标准的分布式系统,我们最常用的关系型数据就满足了CA。主数据库和从数据库中间不再进行数据同步,数据库可以响应每次的查询请求,通过事务隔离级别实现每个查询请求都可以返回最新的数据。
  • CAP是一个已经被证实的理论:一个分布式系统最多只能同时满足一致性(Consistency)、可用性(Availability)和分区容忍性(Partition tolerance)这三项中的两项。它可以作为我们进行架构设计、技术选型的考量标准。对于多数大型互联网应用的场景,结点众多、部署分散,而且现在的集群规模越来越大,所以节点故障、网络故障是常态,而且要保证服务可用性达到N个9(99.99…%),并要达到良好的响应性能来提高用户体验,因此一般都会做出如下选择:保证P和A,舍弃C强一致,保证最终一致性。

2.2 BASE理论

  • 理解强一致性和最终一致性:CAP理论告诉我们一个分布式系统最多只能同时满足一致性(Consistency)、可用性(Availability)和分区容忍性(Partition tolerance)这三项中的两项,其中AP在实际应用中较多,AP即舍弃一致性,保证可用性和分区容忍性,但是在实际生产中很多场景都要实现一致性,比如前边我们举的例子主数据库向从数据库同步数据,即使不要一致性,但是最终也要将数据同步成功来保证数据一致,这种一致性和CAP中的一致性不同,CAP中的一致性要求在任何时间查询每个结点数据都必须一致,它强调的是强一致性,但是最终一致性是允许可以在一段时间内每个结点的数据不一致,但是经过一段时间每个结点的数据必须一致,它强调的是最终数据的一致性。

  • Base理论:是对CAP中AP的一个扩展,通过牺牲强一致性来获得可用性,当出现故障允许部分不可用但要保证核心功能可用,允许数据在一段时间内是不一致的,但最终一致性。满足BASE理论的事务,我们称之为”柔性事务“。

    • 基本可用(Basically Available):是指分布式系统在出现故障的时候,允许损失部分可用性(例如响应时间、功能上的可用性),允许损失部分可用性。需要注意的是,基本可用绝不等价于系统不可用。
      • 响应时间上的损失:正常情况下搜索引擎需要在0.5秒之内返回给用户相应的查询结果,但由于出现故障(比如系统部分机房发生断电或断网故障),查询结果的响应时间增加到了1~2秒。
      • 功能上的损失:购物网站在购物高峰(如双十一)时,为了保护系统的稳定性,部分消费者可能会被引导到一个降级页面。
    • 软状态(Soft state):是指允许系统存在中间状态,而该中间状态不会影响系统整体可用性。这个状态不影响系统可用性,如订单的”支付中”、”数据同步中”等状态,待数据最终一致后状态改为”成功”状态。
    • 最终一致性(Eventual Consistency):是指系统中的所有数据副本经过一定时间后,最终能够达到一致的状态。如订单的“支付中“状态,最终会变为”支付成功”或者”支付失败”,使订单状态与实际交易结果达成一致,但需要一定时间的延迟、等待。弱一致性和强一致性相反,最终一致性是弱一致性的一种特殊情况。
  • 从客户端角度,多进程并发访同时,更新过的数据在不同进程如何获得的不同策略,决定了不同的一致性。

  • 对于关系型要求更新过的数据在后续的访问中都能看到,这是强一致性。

  • 如果能容忍更新过的数据访问不到,则是弱一致性。

  • 如果经过一段时间后要求能访问到更新后的数据,则是最终一致性。

3、分布式事务解决方案之2PC

3.1 2PC介绍

  • 2PC即两阶段提交协议,是将整个事务流程分为两个阶段,准备阶段(prepare phase)、提交阶段(commit phase),2是指两个阶段,P是指准备阶段,C是指提交阶段。 注意这只是协议或者说是理论指导,只阐述了大方向,具体落地还是有会有差异的,数据库支持的2PC,又叫做XA Transactions

  • 在计算机中部分关系数据库如Oracle、MySQL支持两阶段提交协议:

    • 准备阶段(Prepare phase):事务管理器给每个参与者发送Prepare消息,每个数据库参与者在本地执行事务,并写本地的Undo/Redo日志,此时事务没有提交。(Undo日志是记录修改前的数据,用于数据库回滚,Redo日志是记录修改后的数据,用于提交事务后写入数据文件)
    • 提交阶段(commit phase):如果事务管理器收到了参与者的执行失败或者超时消息时,直接给每个参与者发送回滚(Rollback)消息;否则,发送提交(Commit)消息;参与者根据事务管理器的指令执行提交或者回滚操作,并释放事务处理过程中使用的锁资源。注意必须在最后阶段释放锁资源。
  • 下图展示了2PC的两个阶段,分成功和失败两个情况说明:

    • 成功情况:

    • 失败情况:

  • 优点:

    • 2PC对业务侵入很小,它最大的优势就是对使用方透明,用户可以像使用本地事务一样使用基于XA协议的分布式事务,能够严格保障事务ACID特性。
  • 缺点:

    • 同步阻塞问题。执行过程中,所有参与节点都是事务阻塞型的。当参与者占有公共资源时,其他第三方节点访问公共资源不得不处于阻塞状态。(1pc准备阶段,只执行sql,而不提交,并且占用数据库连接资源)
    • 单点故障。由于协调者的重要性,一旦协调者发生故障。参与者会一直阻塞下去。尤其在第二阶段,协调者发生故障,那么所有的参与者还都处于锁定事务资源的状态中,而无法继续完成事务操作。(如果是协调者挂掉,可以重新选举一个协调者,但是无法解决因为协调者宕机导致的参与者处于阻塞状态的问题)
    • 数据不一致。在2PC的阶段二中,当协调者向参与者发送commit请求之后,发生了局部网络异常或者在发送commit请求过程中协调者发生了故障,这回导致只有一部分参与者接受到了commit请求。而在这部分参与者接到commit请求之后就会执行commit操作。但是其他部分未接到commit请求的机器则无法执行事务提交。于是整个分布式系统便出现了数据部一致性的现象。
    • 2PC无法解决的问题:协调者在发出commit消息之后宕机,而唯一接收到这条消息的参与者同时也宕机了。那么即使协调者通过选举协议产生了新的协调者,这条事务的状态也是不确定的,没人知道事务是否被已经提交。

3.2 2PC解决方案

3.2.1 XA方案

  • 2PC的传统方案是在数据库层面实现的,如Oracle、MySQL都支持2PC协议,为了统一标准减少行业内不必要的对接成本,需要制定标准化的处理模型及接口标准,国际开放标准组织Open Group定义了分布式事务处理模型DTP(Distributed Transaction Processing Reference Model)。下面新用户注册送积分为例来说明:

    1643527073515
    • ①应用程序(AP)持有用户库和积分库两个数据源。
    • ②应用程序(AP)通过TM通知用户库RM新增用户,同时通知积分库RM为该用户新增积分,RM此时并未提交事务,此时用户和积分资源锁定
    • ③TM收到执行回复,只要有一方失败则分别向其他RM发起回滚事务,回滚完毕,资源锁释放。
    • ④TM收到执行回复,全部成功,此时向所有RM发起提交事务,提交完毕,资源锁释放。
  • DTP模型定义如下角色:

    • AP(Application Program):应用程序,可以理解为使用DTP分布式事务的程序。
    • RM(Resource Manager):资源管理器,可以理解为事务的参与者,一般情况下是指一个数据库实例,通过资源管理器对该数据库进行控制,资源管理器控制着分支事务。
    • TM(Transaction Manager):即事务管理器,负责协调和管理事务,事务管理器控制着全局事务,管理事务生命周期,并协调各个RM。全局事务是指分布式事务处理环境中,需要操作多个数据库共同完成一个工作,这个工作即是一个全局事务。
  • 以上三个角色之间的交互方式如下:

    • TM向AP提供应用程序编程接口,AP通过TM提交及回滚事务。
    • TM交易中间件通过XA接口来通知RM数据库事务的开始、结束以及提交、回滚等。
  • DTP模型定义TM和RM之间通讯的接口规范叫XA,简单理解为数据库提供的2PC接口协议,基于数据库的XA协议来实现2PC又称为XA方案。

  • 总结:

    • 整个2PC的事务流程涉及到三个角色AP、RM、TM。AP指的是使用2PC分布式事务的应用程序;RM指的是资源管理器,它控制着分支事务;TM指的是事务管理器,它控制着整个全局事务。
      • 准备阶段RM执行实际的业务操作,但不提交事务,资源锁定。
      • 提交阶段TM会接受RM在准备阶段的执行回复,只要有任一个RM执行失败,TM会通知所有RM执行回滚操作,否则,TM将会通知所有RM提交该事务。提交阶段结束资源锁释放。
    • XA方案的问题:
      • 需要本地数据库支持XA协议。
      • 资源锁需要等到两个阶段结束才释放,性能较差。

3.2.2 Seata-AT方案

  • Seata是由阿里中间件团队发起的开源项目Fescar,后更名为Seata,它是一个是开源的分布式事务框架。

  • 传统2PC的问题在Seata中得到了解决,它通过对本地关系数据库的分支事务的协调来驱动完成全局事务,是工作在应用层的中间件。主要优点是性能较好,且不长时间占用连接资源,它以高效并且对业务0侵入的方式解决微服务场景下面临的分布式事务问题,它目前提供AT模式(即2PC)及TCC模式的分布式事务解决方案。

  • Seata的设计思想如下:

    • Seata的设计目标其一是对业务无侵入,因此从业务无侵入的2PC方案着手,在**传统2PC(XA方案)**的基础上演进,并解决2PC方案面临的问题。

    • Seata把一个分布式事务理解成一个包含了若干分支事务全局事务。全局事务的职责是协调其下管辖的分支事务达成一致,要么一起成功提交,要么一起失败回滚。此外,通常分支事务本身就是一个关系数据库的本地事务,下图是它们的关系图:

      1643528239830
    • 与传统2PC的模型类似,Seata定义了3个组件来协议分布式事务的处理过程:

      • Transaction Coordinator (TC): 事务协调器,它是独立的中间件,需要独立部署运行,它维护全局事务的运行状态,接收TM指令发起全局事务的提交与回滚,负责与RM通信协调各各分支事务的提交或回滚。事务协调者有几个相关DB表:
        • global_table:全局事务。每当有一个全局事务发起后,就会在该表中记录全局事务ID。
        • branch_table:分支事务。记录每一个分支事务的ID,分支事务操作的数据库等信息。
        • lock_table:全局锁。
      • Transaction Manager(TM): 事务管理器,TM需要嵌入应用程序中工作,它负责开启一个全局事务,并最终向TC发起全局提交或全局回滚的指令。(jar包)
      • Resource Manager (RM): 资源管理器,控制分支事务,负责分支注册、状态汇报,并接收事务协调器TC的指令,驱动分支(本地)事务的提交和回滚。
  • 新用户注册送积分举例Seata的分布式事务过程(先注册再送积分,所以开启全局事务的一方是用户服务,所以TM在用户服务。谁发起,谁是TM,一旦开始执行后,就生成一个全局事务和2个branch数据):

    • ①用户服务的TM向TC申请开启一个全局事务,全局事务创建成功并生成一个全局唯一的XID并返回给TM。
    • ②用户服务的RM向TC注册分支事务,该分支事务在用户服务执行新增用户逻辑并提交,并将其纳入XID对应全局事务的管辖。
      • 查询before_image
      • 执行本地事务。
      • 查询after_image
      • 生成undo_log并写入数据库。
      • 向TC注册分支事务,告知事务执行结果。
      • 获取全局锁(阻止其它全局事务并发修改当前数据)。
      • 释放本地锁(不影响其它业务对数据的操作)。
    • ③用户服务执行分支事务,向用户表插入一条记录。
    • ④逻辑执行到远程调用积分服务时(XID在微服务调用链路的上下文中传播)。积分服务的RM向TC注册分支事务,该分支事务执行增加积分的逻辑,并将其纳入XID对应全局事务的管辖。
    • ⑤积分服务执行分支事务,向积分记录表插入一条记录,执行完毕后,返回用户服务。
    • ⑥用户服务分支事务执行完毕。
    • ⑦TM向TC发起针对XID的全局提交或回滚决议。
    • ⑧TC调度XID下管辖的全部分支事务完成提交或回滚请求。
  • 写隔离:

    • 一阶段本地事务提交前,需要确保先拿到全局锁

    • 拿不到全局锁 ,不能提交本地事务。

    • 全局锁的尝试被限制在一定范围内,超出范围将放弃,并回滚本地事务,释放本地锁。

    • 示例:两个全局事务tx1和tx2,分别对a表的m字段进行更新操作,m的初始值1000。

      • ①tx1先开始,开启本地事务,拿到本地锁,更新操作m = 1000 - 100 = 900。本地事务提交前,先拿到该记录的全局锁 ,本地提交释放本地锁。tx2后开始,开启本地事务,拿到本地锁,更新操作m = 900 - 100 = 800。本地事务提交前,尝试拿该记录的全局锁,tx1全局提交前,该记录的全局锁被tx1持有,tx2需要重试等待全局锁。

      • ②tx1二阶段全局提交,释放全局锁。tx2拿到全局锁提交本地事务。

      • ③如果tx1的二阶段全局回滚,则tx1需要重新获取该数据的本地锁,进行反向补偿的更新操作,实现分支的回滚。此时,如果tx2仍在等待该数据的全局锁,同时持有本地锁,则tx1的分支回滚会失败。分支的回滚会一直重试,直到tx2的全局锁等锁超时,放弃全局锁并回滚本地事务释放本地锁,tx1的分支回滚最终成功。 因为整个过程全局锁在tx1结束前一直是被tx1持有的,所以不会发生脏写的问题。

  • 读隔离:

    • 在数据库本地事务隔离级别读已提交(Read Committed)或以上的基础上,Seata(AT 模式)的默认全局隔离级别是读未提交(Read Uncommitted)

    • 如果应用在特定场景下,必需要求全局的读已提交 ,目前Seata的方式是通过SELECT FOR UPDATE语句的代理。

    • SELECT FOR UPDATE语句的执行会申请全局锁 ,如果全局锁被其他事务持有,则释放本地锁(回滚 SELECT FOR UPDATE 语句的本地执行)并重试。这个过程中,查询是被block住的,直到全局锁拿到,即读取的相关数据是已提交的才返回。出于总体性能上的考虑,Seata目前的方案并没有对所有SELECT语句都进行代理,仅针对FOR UPDATE的SELECT语句。

  • Seata实现2PC与传统2PC的差别:

    • 架构层次方面,传统2PC方案的RM实际上是在数据库层,RM本质上就是数据库自身,通过XA协议实现,而Seata的RM是以jar包的形式作为中间件层部署在应用程序这一侧的。
    • 两阶段提交方面,传统2PC无论第二阶段的决议是commit还是rollback,事务性资源的锁都要保持到阶段2完成才释放。而Seata的做法是在阶段1就将本地事务提交,这样就可以省去Phase2持锁的时间,整体提高效率。

Seata执行流程分析

  • 通过Seata中间件实现分布式事务,模拟两个账户的转账交易过程为例。两个账户在两个不同的银行(张三在bank1、李四在bank2),bank1和bank2是两个微服务。交易过程是,张三给李四转账指定金额。上述交易步骤,要么一起成功,要么一起失败,必须是一个整体性的事务。

    1643549272045
    • 正常提交流程:

      • ①TM向TC请求开启全局事务,然后得到了全局事务ID并返回。
      • ②bank1携带全局事务ID向TC注册分支事务,并返回分支事务ID。
      • ③bank1做扣减金额的动作,即执行修改业务数据的相关sql语句,并写入undo_log日志(记录张三账户扣减之前和之后的金额),最后提交分支事务并上报处理结果给TC。
      • ④bank1通过feign远程调用bank2时会携带全局事务ID。
      • ⑤bank2携带全局事务ID向TC注册分支事务,并返回分支事务ID。
      • ⑥bank2做增加金额的动作,即执行修改业务数据的相关sql语句,并写入undo_log日志(记录李四账户增加之前和之后的金额),最后提交分支事务并上报处理结果给TC。
      • ⑦TM向TC发起提交全局事务请求,紧接着TC会提交分支事务,即删除bank1和bank2的相关undo_log日志。
    • 回滚流程图:

  • 注意:

    • 每个RM使用DataSourceProxy连接数据库,其目的是使用ConnectionProxy,使用数据源和数据连接代理的目的就是在第一阶段将undo_log和业务数据放在一个本地事务提交,这样就保存了只要有业务操作就一定有undo_log
    • 在第一阶段undo_log中存放了数据修改前和修改后的值,为事务回滚作好准备,所以第一阶段完成就已经将分支事务提交,也就释放了锁资源。
    • TM开启全局事务开始,将全局事务ID放在事务上下文中,通过feign调用也将XID传入下游分支事务,每个分支事务将自己的分支事务ID与XID关联。
    • 第二阶段全局事务提交,TC会通知各分支参与者提交分支事务,在第一阶段就已经提交了分支事务,这里各各参与者只需要删除undo_log即可,并且可以异步执行,第二阶段很快可以完成。
    • 第二阶段全局事务回滚,TC会通知各分支参与者回滚分支事务,通过XIDBranch ID找到相应的回滚日志,通过回滚日志生成反向的SQL并执行,以完成分支事务回滚到之前的状态,如果回滚失败则会重试回滚操作。

3.3 seata实现2PC事务

  • ①下载并安装seata的TC,并在项目引入TM和RM的相关jar包,具体步骤可参考https://www.jianshu.com/p/fda8f616ba22。

  • ②场景如下:两个账户在两个不同的银行(张三在bank1、李四在bank2),bank1和bank2是两个微服务。交易过程是,张三给李四转账指定金额。将@GlobalTransactional注解标注在全局事务发起的Service实现方法上,开启全局事务:GlobalTransactionalInterceptor会拦截@GlobalTransactional注解的方法,生成全局事务ID(XID),XID会在整个分布式事务中传递。在远程调用时,spring-cloud-alibaba-seata会拦截Feign调用将XID传递到下游服务。

    • bank1模块:开启全局事务,远程调用ban2模块(省略具体的业务操作)。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      @Service
      @Slf4j
      public class AccountInfoServiceImpl implements AccountInfoService {
      @Autowired
      AccountInfoDao accountInfoDao;

      @Autowired
      Bank2Client bank2Client;

      @GlobalTransactional//开启全局事务
      @Override
      public void updateAccountBalance(String accountNo, Double amount) {
      log.info("bank1 service begin,XID:{}", RootContext.getXID());
      //扣减张三的金额
      accountInfoDao.updateAccountBalance(accountNo,amount *-1);
      //调用李四微服务,转账
      String transfer = bank2Client.transfer(amount);
      }
      }
    • bank2模块:李四账户增加金额,本账号事务中作为分支事务不使用@GlobalTransactional。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      @Service
      @Slf4j
      public class AccountInfoServiceImpl implements AccountInfoService {

      @Autowired
      AccountInfoDao accountInfoDao;

      @Override
      public void updateAccountBalance(String accountNo, Double amount) {
      log.info("bank2 service begin,XID:{}",RootContext.getXID());
      //李四增加金额
      accountInfoDao.updateAccountBalance(accountNo,amount);
      }
      }

4、分布式事务解决方案之TCC

4.1 TCC介绍

  • TCC是Try、Confirm、Cancel三个词语的缩写,TCC要求每个分支事务实现三个操作:预处理Try、确认Confirm、撤销Cancel。Try操作做业务检查及资源预留,Confirm做业务确认操作,Cancel实现一个与Try相反的操作即回滚操作。TM首先发起所有的分支事务的try操作,任何一个分支事务的try操作执行失败,TM将会发起所有分支事务的Cancel操作,若try操作全部成功,TM将会发起所有分支事务的Confirm操作,其中Confirm/Cancel操作若执行失败,TM会进行重试。

    • 简单点概括,SEATA的TCC模式就是手工的AT模式,它允许你自定义两阶段的处理逻辑而不依赖AT模式的undo_log。
  • TCC分为三个阶段:

    • Try阶段是做业务检查一致性及资源预留,此阶段仅是一个初步操作,它和后续的Confirm一起才能真正的构成一个完整的业务逻辑;
    • Confirm阶段是确认提交,Try阶段所有的分支事务执行成功后开始执行Confirm。通常情况下,采用TCC则认为Confirm阶段是不会出错的。即:只要Try成功,Confirm一定成功。若Confirm阶段真的出错了,需要引入重试机制或进行人工处理。
    • Cancel阶段是在业务执行错误需要回滚的状态下,执行分支事务的业务取消了,预留资源释放。通常情况下,采用TCC则认为Cancel阶段也是一定成功的。如果Cancel阶段真的出错了,需要引入重试机制或进行人工处理。
  • TM在发起全局事务时生成全局事务记录,全局事务ID贯穿整个分布式事务调用链条,用来记录事务上下文,追踪和记录状态,由于Confirm和cancel失败需进行重试,因此需要实现为幂等,幂等性是指同一个操作无论请求多少次,其结果都相同。

  • TCC不存在资源阻塞的问题,因为每个方法都直接进行事务的提交,一旦出现异常通过则Cancel来进行回滚补偿,这也就是常说的补偿性事务。原本一个方法,现在却需要三个方法来支持,可以看到TCC对业务的侵入性很强,而且这种模式并不能很好地被复用,会导致开发量激增。还要考虑到网络波动等原因,为保证请求一定送达都会有重试机制,所以考虑到接口的幂等性。

  • TCC 对业务的侵入较大和业务紧耦合,需要根据特定的场景和业务逻辑来设计相应的操作。还有一点要注意,撤销和确认操作的执行可能需要重试,因此还需要保证操作的幂等。相对于2PC、3PC ,TCC适用的范围更大,但是开发量也更大,毕竟都在业务上实现,而且有时候你会发现这三个方法还真不好写。不过也因为是在业务上实现的,所以TCC可以跨数据库、跨不同的业务系统来实现事务。

4.2 TCC解决方案

  • 以下几个框架均支持TCC全局事务,目前阿里seata拥有较大的用户基数,github的star数也是遥遥领先。( (以下数据采集日为2022年02月01日)

    框架名称 Github地址 star数量
    Alibaba Seata https://github.com/seata/seata 21.6k
    tcc-transaction https://github.com/changmingxie/tcc-transaction 5.3k
    LCN https://github.com/codingapi/tx-lcn 4.1k
    Hmily https://github.com/dromara/hmily 3.8k
    ByteTCC https://github.com/liuyangming/ByteTCC 2.8k
  • TCC需要注意三种异常处理分别是空回滚、幂等、悬挂:

    • ①空回滚:在没有调用TCC资源Try方法的情况下,调用了二阶段的Cancel方法,Cancel方法需要识别出这是一个空回滚,然后直接返回成功。
      • 出现原因:当一个分支事务所在服务宕机或网络异常,分支事务调用记录为失败,这个时候其实是没有执行Try阶段,当故障恢复后,分布式事务进行回滚则会调用二阶段的Cancel方法,从而形成空回滚。
      • 解决思路:关键就是要识别出这个空回滚。思路很简单就是需要知道一阶段是否执行,如果执行了,那就是正常回滚;如果没执行,那就是空回滚。前面已经说过TM在发起全局事务时生成全局事务记录,全局事务ID贯穿整个分布式事务调用链条。再额外增加一张分支事务记录表,其中有全局事务ID和分支事务ID,第一阶段Try方法里会插入一条记录,表示一阶段执行了。Cancel接口里读取该记录,如果该记录存在,则正常回滚;如果该记录不存在,则是空回滚。
    • ②幂等:通过前面介绍已经了解到,为了保证TCC二阶段提交重试机制不会引发数据不一致,要求TCC的二阶段Try、Confirm 和 Cancel 接口保证幂等,这样不会重复使用或者释放资源。如果幂等控制没有做好,很有可能导致数据不一致等严重问题。
      • 解决思路:在上述”分支事务记录”中增加执行状态,每次执行前都查询该状态。更加严谨需要增加分布式锁。
    • ③悬挂:是指对于一个分布式事务,其二阶段Cancel接口比Try接口先执行。官网解释为TCC防悬挂的目的是为了防止空回滚,即二阶段回滚方法比一阶段try方法先执行。
      • 出现原因:在RPC调用分支事务try时,先注册分支事务,再执行RPC调用,如果此时RPC调用的网络发生拥堵,通常RPC调用是有超时时间的,RPC超时以后,TM就会通知RM回滚该分布式事务,可能回滚完成后,RPC请求才到达参与者真正执行,而一个Try方法预留的业务资源,只有该分布式事务才能使用,该分布式事务第一阶段预留的业务资源就再也没有人能够处理了,对于这种情况,我们就称为悬挂,即业务资源预留后没法继续处理。
      • 解决思路:如果二阶段执行完成,那一阶段就不能再继续执行。在执行一阶段事务时判断在该全局事务下,分支事务记录表中是否已经有二阶段事务记录,如果有则不执行Try。
  • 举例场景:A转账30元给B,A和B账户在不同的服务。

    • 方案1:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      # 账户A
      try:
      检查余额是否够30元
      扣减30元
      confirm:

      cancel:
      增加30元

      # 账户B
      try:
      增加30元
      confirm:

      cancel:
      减少30元
      • 说明:
        • 账户A,这里的余额就是所谓的业务资源,按照前面提到的原则,在第一阶段需要检查并预留业务资源,因此,我们在扣钱TCC资源的Try接口里先检查A账户余额是否足够,如果足够则扣除30元。Confirm接口表示正式提交,由于业务资源已经在Try接口里扣除掉了,那么在第二阶段的Confirm接口里可以什么都不用做。Cancel接口的执行表示整个事务回滚,账户A回滚则需要把Try接口里扣除掉的30元还给账户。
        • 账号B,在第一阶段Try接口里实现给账户B加钱,Cancel接口的执行表示整个事务回滚,账户B回滚则需要把Try接口里加的30元再减去。
      • 问题分析:
        • 如果账户A的try没有执行,直接cancel则就多加了30元。
        • 由于try,cancel、confirm都是由单独的线程去调用,且会出现重复调用,所以都需要实现幂等。
        • 账号B在try中增加30元,当try执行完成后可能会其它线程给消费了。
        • 如果账户B的try没有执行在cancel则就多减了30元。
      • 问题解决:
        • 账户A的cancel方法需要判断try方法是否执行,正常执行try后方可执行cancel。
        • try,cancel、confirm方法实现幂等。
        • 账号B在try方法中不允许更新账户金额,在confirm中更新账户金额。
        • 账户B的cancel方法需要判断try方法是否执行,正常执行try后方可执行cancel。
    • 优化方案:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      # 账户A
      try:
      try幂等校验
      try悬挂处理
      检查余额是否够30元
      扣减30元
      confirm:

      cancel:
      cancel幂等校验
      cancel空回滚处理
      增加可用余额30元

      # 账户B
      try:

      confirm:
      confirm幂等校验
      正式增加30元
      cancel:

4.3 seata实现TCC事务

  • ①定义TCC接口。

    • 由于我们使用的是SpringCloud + Feign,Feign的调用基于http,因此此处使用@LocalTCC便可。值得注意的是,@LocalTCC一定需要注解在接口上,此接口可以是寻常的业务接口,只要实现了TCC的两阶段提交对应方法便可,TCC相关注解如下:

      • @LocalTCC 适用于SpringCloud+Feign模式下的TCC。
      • @TwoPhaseBusinessAction 注解try方法,其中name为当前tcc方法的bean名称,写方法名便可(全局唯一),commitMethod指向提交方法,rollbackMethod指向事务回滚方法。指定好三个方法之后,seata会根据全局事务的成功或失败,去帮我们自动调用提交方法或者回滚方法。
      • @BusinessActionContextParameter 注解可以将参数传递到二阶段(commitMethod/rollbackMethod)的方法。
      • BusinessActionContext 便是指TCC事务上下文。
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      /**
      * 这里定义tcc的接口
      * 一定要定义在接口上
      * 我们使用springCloud的远程调用
      * 那么这里使用LocalTCC便可
      */
      @LocalTCC
      public interface TccService {

      /**
      * 定义两阶段提交
      * name = 该tcc的bean名称,全局唯一
      * commitMethod = commit 为二阶段确认方法
      * rollbackMethod = rollback 为二阶段取消方法
      * BusinessActionContextParameter注解 传递参数到二阶段中
      *
      * @param params -入参
      * @return String
      */
      @TwoPhaseBusinessAction(name = "insert", commitMethod = "commitTcc", rollbackMethod = "cancel")
      String insert(
      @BusinessActionContextParameter(paramName = "params") Map<String, String> params
      );

      /**
      * 确认方法、可以另命名,但要保证与commitMethod一致
      * context可以传递try方法的参数
      */
      boolean commitTcc(BusinessActionContext context);

      /**
      * 二阶段回滚方法
      */
      boolean cancel(BusinessActionContext context);
      }
  • ②TCC接口的业务实现。

    • 为了保证代码的简洁,此处将路由层与业务层结合讲解,实际项目则不然。

      • 在try方法中使用@Transational可以直接通过spring事务回滚关系型数据库中的操作,而非关系型数据库等中间件的回滚操作可以交给rollbackMethod方法处理。
      • 使用context.getActionContext(“params”)便可以得到一阶段try中定义的参数,在二阶段对此参数进行业务回滚操作。
      • 注意:此处亦不可以捕获异常(同理切面处理异常),否则TCC将识别该操作为成功,二阶段直接执行commitMethod;TCC模式要开发者自行保证幂等和事务防悬挂。
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      @Slf4j
      @Service
      public class TccServiceImpl implements TccService {

      @Autowired
      TccDAO tccDAO;

      /**
      * tcc服务t(try)方法
      * 根据实际业务场景选择实际业务执行逻辑或者资源预留逻辑
      *
      * @param params - name
      * @return String
      */
      @Override
      @Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRED)
      public String insert(Map<String, String> params) {
      log.info("xid = " + RootContext.getXID());
      params.put("123","456");
      //todo 实际的操作,或操作MQ、redis等
      // tccDAO.insert(params);
      //放开以下注解抛出异常
      throw new RuntimeException("服务tcc测试回滚");
      // return "success";
      }

      /**
      * tcc服务 confirm方法
      * 若一阶段采用资源预留,在二阶段确认时要提交预留的资源
      *
      * @param context 上下文
      * @return boolean
      */
      @Override
      public boolean commitTcc(BusinessActionContext context) {
      log.info("xid = " + context.getXid() + "提交成功");
      //todo 若一阶段资源预留,这里则要提交资源
      return true;
      }

      /**
      * tcc 服务 cancel方法
      *
      * @param context 上下文
      * @return boolean
      */
      @Override
      public boolean cancel(BusinessActionContext context) {
      //todo 这里写中间件、非关系型数据库的回滚操作
      System.out.println("please manually rollback this data:" + context.getActionContext("params"));
      return true;
      }
      }
  • ③在TM中开启全局事务,调用RM-TCC接口。调用方是通过Feign进行服务的调用,调用方的配置,在需要实现全局事务的方法上加上”@GlobalTransactional”注解即可,与AT模式下的配置一致。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    @Slf4j
    @Service
    public class TmServiceImpl implements TmService {

    @Autowired
    private TmDAO tmDAO;
    @Autowired
    private ServiceTCCFeign tccFeign;

    /**
    * 往本地插入记录
    * 再请求tcc服务插入一条记录
    *
    * @param params - name
    * @return String
    */
    @Override
    @GlobalTransactional(timeoutMills = 60000 * 2)
    public String insertTcc(Map<String, String> params) {
    log.info("------------------> xid = " + RootContext.getXID());
    tmDAO.insert(params);
    tccFeign.insertTCC(params);
    //throw new RuntimeException("TCC服务测试回滚");
    return "success";
    }
    }
  • 小结:

    • 如果拿TCC事务的处理流程与2PC两阶段提交做比较,2PC通常都是在跨库的DB层面,而TCC则在应用层面的处理,需要通过业务逻辑来实现。这种分布式事务的实现方式的优势在于,可以让应用自己定义数据操作的粒度,使得降低锁冲突、提高吞吐量成为可能。
    • 而不足之处则在于对应用的侵入性非常强,业务逻辑的每个分支都需要实现try、confirm、cancel三个操作。此外,其实现难度也比较大,需要按照网络状态、系统故障等不同的失败原因实现不同的回滚策略。

5、分布式事务解决方案之可靠消息最终一致性

5.1 可靠消息最终一致性介绍

  • 可靠消息最终一致性方案是指当事务发起方执行完成本地事务后并发出一条消息,事务参与方(消息消费者)一定能够接收消息并处理事务成功,此方案强调的是只要消息发给事务参与方最终事务要达到一致。

  • 此方案是利用消息中间件完成,如下图:

    • 事务发起方(消息生产方)将消息发给消息中间件,事务参与方从消息中间件接收消息,事务发起方和消息中间件之间,事务参与方(消息消费方)和消息中间件之间都是通过网络通信,由于网络通信的不确定性会导致分布式事务问题。
  • 因此可靠消息最终一致性方案要解决以下几个问题:

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

      • 先来尝试下这种操作,先发送消息,再操作数据库:

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

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

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

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

        这种情况下貌似没有问题,如果发送MQ消息失败,就会抛出异常,导致数据库事务回滚。但如果是超时异常,数据库回滚,但MQ其实已经正常发送了,同样会导致不一致。

    • ②事务参与方接收消息的可靠性。即事务参与方必须能够从消息队列接收到消息,如果接收消息失败可以重复接收消息。

    • ③消息重复消费的问题。由于网络的存在,若某一个消费节点超时但是消费成功,此时消息中间件会重复投递此消息,就导致了消息的重复消费。要解决消息重复消费的问题就要实现事务参与方的方法幂等性

5.2 可靠消息解决方案

5.2.1 本地消息表方案

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

  • 本地消息表顾名思义就是会有一张存放本地消息的表,一般都是放在数据库中,然后在执行业务的时候将业务的执行和将消息放入消息表中的操作放在同一个事务中,这样就能保证消息放入本地表中业务肯定是执行成功的。然后再去调用下一个操作,如果下一个操作调用成功了好说,消息表的消息状态可以直接改成已成功。 如果调用失败也没事,会有后台任务定时去读取本地消息表,筛选出还未成功的消息再调用对应的服务,服务更新成功了再变更消息的状态。这时候有可能消息对应的操作不成功,因此也需要重试,重试就得保证对应服务的方法是幂等的,而且一般重试会有最大次数,超过最大次数可以记录下报警让人工处理。可以看到本地消息表其实实现的是最终一致性,容忍了数据暂时不一致的情况。

  • 以注册送积分为例来说明,共有两个微服务交互,用户服务和积分服务,用户服务负责添加用户,积分服务负责增加积分。

    • ①用户注册。用户服务在本地事务新增用户和增加”积分消息日志”,即用户表和消息表通过本地事务保证一致,这种情况下,本地数据库操作与存储积分消息日志处于同一个事务中,本地数据库操作与记录消息日志操作具备原子性

      1
      2
      3
      4
      begin transaction;
      //1.新增用户
      //2.存储积分消息日志
      commit transation;
    • ②定时任务扫描日志。经过第一步消息已经写到消息日志表中,可以启动独立的线程,定时对消息日志表中的消息进行扫描并发送至消息中间件,在消息中间件反馈发送成功后删除该消息日志,否则等待定时任务下一周期重试。这保证了消息能成功发送给消息队列。

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

      • 积分服务接收到”增加积分”消息,开始增加积分,积分增加成功后向消息中间件回应ack,否则消息中间件将重复投递此消息。由于消息可能会重复投递,积分服务的”增加积分”功能需要实现幂等性。

5.2.2 RocketMQ事务消息方案

  • RocketMQ是一个来自阿里巴巴的分布式消息中间件,于2012年开源,并在2017年正式成为Apache顶级项目。据了解,包括阿里云上的消息产品以及收购的子公司在内,阿里集团的消息产品全线都运行在RocketMQ之上,并且最近几年的双十一大促中,RocketMQ 都有抢眼表现。Apache RocketMQ4.3之后的版本正式支持事务消息,为分布式事务实现提供了便利性支持。

  • RocketMQ事务消息设计则主要是为了解决Producer端的消息发送与本地事务执行的原子性问题,RocketMQ的设计中broker与producer端的双向通信能力,使得broker天生可以作为一个事务协调者存在;而RocketMQ本身提供的存储机制为事务消息提供了持久化能力;RocketMQ的高可用机制以及可靠消息设计则为事务消息在系统发生异常时依然能够保证达成事务的最终一致性。

  • 在RocketMQ4.3后实现了完整的事务消息,实际上其实是对本地消息表的一个封装,将本地消息表移动到了MQ内部,解决Producer端的消息发送与本地事务执行的原子性问题。

    • ①Producer发送事务消息。Producer (MQ发送方)发送事务消息至MQ Server,MQ Server将消息状态标记为Prepared(预备状态),注意此时这条消息消费者(MQ订阅方)是无法消费到的。本例中,Producer发送”增加积分消息”到MQ Server。

    • ②MQ Server回应消息发送成功。MQ Server接收到Producer发送给的消息则回应发送成功表示MQ已接收到消息。

    • ③Producer执行本地事务。Producer端执行业务代码逻辑,通过本地数据库事务控制。本例中,Producer执行添加用户操作。

    • ④消息投递。若Producer本地事务执行成功则自动向MQServer发送commit消息,MQ Server接收到commit消息后将”增加积分消息”状态标记为可消费,此时MQ订阅方(积分服务)即正常消费消息;若Producer本地事务执行失败则自动向MQServer发送rollback消息,MQ Server接收到rollback消息后将删除”增加积分消息”。MQ订阅方(积分服务)消费消息,消费成功则向MQ回应ack,否则将重复接收消息。这里ack默认自动回应,即程序执行正常则自动回应ack。

    • ⑤事务回查。如果执行Producer端本地事务过程中,执行端挂掉,或者超时,MQ Server将会不停的询问同组的其他Producer来获取事务执行状态,这个过程叫事务回查。MQ Server会根据事务回查结果来决定是否投递消息。以上主干流程已由RocketMQ实现,对用户侧来说,用户需要分别实现本地事务执行以及本地事务回查方法,因此只需关注本地事务的执行状态即可。

      • RocketMQ提供RocketMQLocalTransactionListener接口:

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        public interface RocketMQLocalTransactionListener {
        /**
        ‐ 发送prepare消息成功此方法被回调,该方法用于执行本地事务
        @param msg 回传的消息,利用transactionId即可获取到该消息的唯一Id
        @param arg 调用send方法时传递的参数,当send时候若有额外的参数可以传递到send方法中,这里能获取到
        @return 返回事务状态,COMMIT:提交 ROLLBACK:回滚 UNKNOW:回调
        */
        RocketMQLocalTransactionState executeLocalTransaction(Message msg, Object arg);
        /**
        @param msg 通过获取transactionId来判断这条消息的本地事务执行状态
        @return 返回事务状态,COMMIT:提交 ROLLBACK:回滚 UNKNOW:回调
        */
        RocketMQLocalTransactionState checkLocalTransaction(Message msg);
        }
  • 可靠消息最终一致性就是保证消息从生产方经过消息中间件传递到消费方的一致性,本案例使用了RocketMQ作为消息中间件,RocketMQ主要解决了两个功能:

    • 本地事务与消息发送的原子性问题。
    • 事务参与方接收消息的可靠性。
  • 可靠消息最终一致性事务适合执行周期长且实时性要求不高的场景。引入消息机制后,同步的事务操作变为基于消息执行的异步操作, 避免了分布式事务中的同步阻塞操作的影响,并实现了两个服务的解耦。

6、分布式事务解决方案之最大努力通知

6.1 最大努力通知介绍

  • 最大努力通知也是一种解决分布式事务的方案,下边是一个是充值的例子:

    1643773312377
    • 账户系统调用充值系统接口。
    • 充值系统完成支付处理向账户系统发起充值结果通知(这个是重点)。若通知失败,则充值系统按策略进行重复通知。
    • 账户系统接收到充值结果通知修改充值状态。
    • 账户系统未接收到通知会主动调用充值系统的接口查询充值结果。
  • 通过上边的例子我们总结最大努力通知方案的目标:发起通知方通过一定的机制最大努力将业务处理结果通知到接收方。具体包括:

    • 有一定的消息重复通知机制。因为接收通知方可能没有接收到通知,此时要有一定的机制对消息重复通知。
    • 消息校对机制。如果尽最大努力也没有通知到接收方,或者接收方消费消息后要再次消费,此时可由接收方主动向通知方查询消息信息来满足需求。
  • 最大努力通知与可靠消息一致性有什么不同?

    • 解决方案思想不同。
      • 可靠消息一致性,发起通知方需要保证将消息发出去,并且将消息发到接收通知方,消息的可靠性关键由发起通知方来保证。
      • 最大努力通知,发起通知方尽最大的努力将业务处理结果通知为接收通知方,但是可能消息接收不到,此时需要接收通知方主动调用发起通知方的接口查询业务处理结果,通知的可靠性关键在接收通知方。
    • 两者的业务应用场景不同。
      • 可靠消息一致性关注的是交易过程的事务一致,以异步的方式完成交易。最大努力通知关注的是交易后的通知事务,即将交易结果可靠的通知出去。
    • 技术解决方向不同。
      • 可靠消息一致性要解决消息从发出到接收的一致性,即消息发出并且被接收到。
      • 最大努力通知无法保证消息从发出到接收的一致性,只提供消息接收的可靠性机制。可靠机制是,最大努力的将消息通知给接收方,当消息无法被接收方接收时,由接收方主动查询消息(业务处理结果)。

6.2 最大努力通知解决方案

方案1

  • 通过对最大努力通知的理解,采用MQ的ack机制就可以实现最大努力通知。

    • 发起通知方将通知发给MQ,如果消息没有发出去可由接收通知方主动请求发起通知方查询业务执行结果。
    • 接收通知方监听MQ。
    • 接收通知方接收消息,业务处理完成回应ack。
    • 接收通知方若没有回应ack则MQ会重复通知。MQ会按照间隔1min、5min、10min、30min、1h、2h、5h、10h的方式,逐步拉大通知间隔 (如果MQ采用rocketMQ,在broker中可进行配置),直到达到通知要求的时间窗口上限。
    • 接收通知方可通过消息校对接口来校对消息的一致性。

方案2

  • 本方案也是利用MQ的ack机制,与方案1不同的是应用程序向接收通知方发送通知,如下图:

    • 发起通知方将通知发给MQ。使用可靠消息一致方案中的事务消息保证本地事务与消息的原子性,最终将通知先发给MQ。
    • 通知程序监听MQ,接收MQ的消息。方案1中接收通知方直接监听MQ,方案2中由通知程序监听MQ。通知程序若没有回应ack则MQ会重复通知。
    • 通知程序通过互联网接口协议(如http、webservice)调用接收通知方案接口,完成通知。通知程序调用接收通知方案接口成功就表示通知成功,即消费MQ消息成功,MQ将不再向通知程序投递通知消息。
    • 接收通知方可通过消息校对接口来校对消息的一致性。

方案1和方案2的不同点

  • 方案1中接收通知方与MQ接口,即接收通知方案监听MQ,此方案主要应用与内部应用之间的通知。
  • 方案2中由通知程序与MQ接口,通知程序监听MQ,收到MQ的消息后由通知程序通过互联网接口协议调用接收通知方。此方案主要应用于外部应用之间的通知,例如支付宝、微信的支付结果通知。
  • 最大努力通知方案是分布式事务中对一致性要求最低的一种,适用于一些最终一致性时间敏感度低的业务;最大努力通知方案需要实现如下功能:
    • 消息重复通知机制。
    • 消息校对机制。

7、总结

  • 2PC最大的诟病是一个阻塞协议。RM在执行分支事务后需要等待TM的决定,此时服务会阻塞并锁定资源。由于其阻塞机制和最差时间复杂度高, 因此,这种设计不能适应随着事务涉及的服务数量增加而扩展的需要,很难用于并发较高以及子事务生命周期较长 (long-running transactions) 的分布式服务中。

  • 如果拿TCC事务的处理流程与2PC两阶段提交做比较,2PC通常都是在跨库的DB层面,而TCC则在应用层面的处理,需要通过业务逻辑来实现。这种分布式事务的实现方式的优势在于,可以让应用自己定义数据操作的粒度,使得降低锁冲突、提高吞吐量成为可能。而不足之处则在于对应用的侵入性非常强,业务逻辑的每个分支都需要实现try、confirm、cancel三个操作。此外,其实现难度也比较大,需要按照网络状态、系统故障等不同的失败原因实现不同的回滚策略。典型的使用场景:满,登录送优惠券等。

  • 可靠消息最终一致性事务适合执行周期长且实时性要求不高的场景。引入消息机制后,同步的事务操作变为基于消息执行的异步操作, 避免了分布式事务中的同步阻塞操作的影响,并实现了两个服务的解耦。典型的使用场景:注册送积分,登录送优惠券等。

  • 最大努力通知是分布式事务中要求最低的一种,适用于一些最终一致性时间敏感度低的业务;允许发起通知方处理业务失败,在接收通知方收到通知后积极进行失败处理,无论发起通知方如何处理结果都会不影响到接收通知方的后续处理;发起通知方需提供查询执行情况接口,用于接收通知方校对结果。典型的使用场景:银行通知、支付结果通知等。

  • 在条件允许的情况下,我们尽可能选择本地事务单数据源,因为它减少了网络交互带来的性能损耗,且避免了数据弱一致性带来的种种问题。若某系统频繁且不合理的使用分布式事务,应首先从整体设计角度观察服务的拆分是否合理,是否高内聚低耦合?是否粒度太小?分布式事务一直是业界难题,因为网络的不确定性,而且我们习惯于拿分布式事务与单机事务ACID做对比。

  • 无论是数据库层的XA、还是应用层TCC、可靠消息、最大努力通知等方案,都没有完美解决分布式事务问题,它们不过是各自在性能、一致性、可用性等方面做取舍,寻求某些场景偏好下的权衡。