分布式事务处理方案大 PK!

[TOC]

说好了写 TienChin 项目的,最近这个分布式事务算是一个支线任务吧,今天是最后一篇,松哥再来一个短篇和小伙伴们总结一下分布式事务。

首先先说一个大原则:分布式事务能不用就不要用,毕竟这个用起来还是有一些麻烦的。当然,不用和不会用可是两码事。

1. 分布式事务基础理论

学习分布式事务,有一些基础理论需要我们先来了解下。

1.1 本地事务

本地事务是指将多条语句作为一个整体进行操作的功能,通过数据库事务可以确保该事务范围内的所有操作都可以全部成功或者全部失败,如果事务失败,那么效果就和没有执行这些SQL一样,不会对数据库数据有任何改动。也就是事务具有原子性,一个事务中的一系列操作要么全部成功,要么全部失败。一般来说,事务具有 4 个属性:

  • Atomic:原子性,将一个事务中的所有 SQL 作为原子工作单元执行,要么全部执行,要么全部不执行;
  • Consistent:一致性,事务完成后,所有数据的状态都是一致的,以银行转帐为例,如果 A 账户减去了 100,则 B 账户则必定加上了 100;
  • Isolation:隔离性,如果有多个事务并发执行,每个事务作出的修改必须与其他事务隔离;
  • Duration:持久性,即事务完成后,对数据库数据的修改被持久化存储。

这四个属性通常称为 ACID 特性。

这块松哥之前专门录过相关的视频,这里就不再赘述了。

1.2 分布式事务

当我们的项目上了微服务之后,分布式事务就是一个比较常见的问题了,我们也会遇到很多相关的场景。

就拿我们前两天讲的商品下单的分布式事务的案例来说,像下面这样,一共有五个服务,架构如下图:

  • eureka:这是服务注册中心。
  • account:这是账户服务,可以查询/修改用户的账户信息(主要是账户余额)。
  • order:这是订单服务,可以下订单。
  • storage:这是一个仓储服务,可以查询/修改商品的库存数量。
  • bussiness:这是业务,用户下单操作将在这里完成。

当用户想要下单的时候,调用了 bussiness 中的接口,bussiness 中的接口又调用了它自己的 service,在 service 中,通过 feign 调用 storage 中的接口去扣库存,然后再通过 feign 调用 order 中的接口去创建订单(order 在创建订单的时候,不仅会创建订单,还会扣除用户账户的余额)。

这三个操作,我们希望他们能够同时成功或者同时失败。然而如上图所示,三个微服务都有自己的 DB,这是三个完全不同的 DB,相当于三个不同的本地事务,按照传统的本地事务规则,我们显然是无法实现三个操作同时成功或者同时失败的。

想要实现 storage、order 以及 account 中的操作同时成功或者同时失败,就得考虑分布式事务了。

最后,我们再来看看分布式事务的概念:分布式事务是指事务的参与者、支持事务的服务器、资源服务器以及事务管理器分别位于的不同节点之上,数据库的操作执行成功与否,不仅取决于本地 DB 的执行结果,也取决于第三方系统的执行结果。而分布式事务就保证这些操作要么全部成功,要么全部失败。本质上,分布式事务就是为了保证不同数据库的数据一致性。

1.3 CAP

CAP 定理(CAP theorem),有时候又被称作布鲁尔定理(Brewer’s theorem),它指出对于一个分布式计算系统来说,不可能同时满足以下三点:

  1. 一致性(Consistency):在分布式系统中的所有数据备份,在同一时刻是否具备同样的值。(等同于所有节点访问同一份最新的数据副本)。
  2. 可用性(Availability):在集群中一部分节点故障后,集群整体是否还能响应客户端的读写请求。(对数据更新具备高可用性)。
  3. 分区容错性(Partition tolerance):这个我觉得可能对有的小伙伴来说有点难以理解,我就简单说一下,先来说分区:因为我们是分布式系统,分布式系统中不同的微服务位于不同的网络节点上,当发生网络故障或者节点故障的时候,不同的服务之间就无法通信了,也就是说发生了分区;再来看分区容错性:这是说,当我们的系统中出现分区的时候,系统还要能运行,不能罢工!一般来说,在一个分布式系统中,分区发生的概率还是比较大的,不会发生分区的系统,那就不是分布式系统了,而是单体应用了。

CAP 原则的精髓就是要么 AP,要么 CP,要么 AC,但是不存在 CAP。因为在分布式系统内,P 是必然的发生的,不选 P,一旦发生分区,整个分布式系统就完全无法使用了,这样的系统就太脆弱了。所以对于分布式系统,我们只能能考虑当发生分区错误时,如何选择一致性和可用性(选择一致性,意味着服务在某段时间内不可用,选择了可用性,意味着服务虽然一直可用但是返回的数据却不一致)。

而根据一致性和可用性的选择不同,开源的分布式系统往往又被分为 CP 系统和 AP 系统。

当一套系统在发生分区故障后,客户端的任何请求都被卡死或者超时,但是系统的每个节点总是会返回一致的数据,则这套系统就是 CP 系统,经典的比如 Zookeeper。

如果一套系统发生分区故障后,客户端依然可以访问系统,但是获取的数据有的是新的数据,有的还是老数据,那么这套系统就是 AP 系统,经典的比如 Eureka。

1.4 BASE

因为无法同时满足 CAP,所以又有了 BASE 理论,BASE 理论指的是:

  1. 基本可用 Basically Available:分布式系统在出现故障的时候,允许损失部分可用性,即保证核心可用。
  2. 软状态 Soft State:允许系统存在中间状态,而该中间状态不会影响系统整体可用性。
  3. 终一致性 Eventual Consistency:系统中的所有数据副本经过一定时间后,最终能够达到一致的状态。

BASE 理论的核心思想是即便无法做到强一致性,但应该采用适合的方式保证最终一致性。

BASE 理论本质上是对 CAP 理论的延伸,是对 CAP 中 AP 方案的一个补充。

1.5 刚柔并济

事务有刚性事务和柔性事务之分。

刚性事务(如单数据库中的本地事务)完全遵循 ACID 规范,即数据库事务正确执行的四个基本要素:

  • 原子性(Atomicity)
  • 一致性(Consistency)
  • 隔离性(Isolation)
  • 持久性(Durability)

柔性事务,主要就是只分布式事务了,柔性事务为了满足可用性、性能与降级服务的需要,降低一致性(Consistency)与隔离性(Isolation)的要求,遵守 BASE 理论:

  • 基本业务可用性(Basic Availability)
  • 柔性状态(Soft state)
  • 最终一致性(Eventual consistency)

当然,柔性事务也部分遵循 ACID 规范:

  • 原子性:严格遵循
  • 一致性:事务完成后的一致性严格遵循;事务中的一致性可适当放宽
  • 隔离性:并行事务间不可影响;事务中间结果可见性允许安全放宽
  • 持久性:严格遵循

柔性事务有不同的分类,不过基本上都可以看作是分布式事务的解决方案:

  • 两阶段型:分布式事务二阶段提交,对应技术上的 XA、JTA/JTS,这是分布式环境下事务处理的典型模式。
  • 补偿型:我们之前文章介绍的 TCC,就算是一种补偿型事务,在 Try 成功的情况下,如果事务要回滚,Cancel 将作为一个补偿机制,回滚 Try 操作;TCC 各操作事务本地化,且尽早提交(没有两阶段约束);当全局事务要求回滚时,通过另一个本地事务实现“补偿”行为。 TCC 是将资源层的二阶段提交协议转换到业务层,成为业务模型中的一部分。
  • 异步确保型:将一些有同步冲突的事务操作变为异步操作,避免对数据库事务的争用,如消息事务机制。
  • 最大努力通知型:通过通知服务器(消息通知)进行,允许失败,有补充机制。

2. 分布式事务实践

2.1 XA

先来说说 XA。

XA 是一种典型的两阶段提交(2PC,Two-phase commit protocol),而两阶段提交是一种强一致性设计,在两阶段提交中,一般会引入一个事务协调者的角色来协调管理各个事务参与者,例如我们之前文章中使用的 seata-server 其实是就是一个事务协调者。所谓的两阶段分别指的是准备和提交两个阶段。

XA 规范 是 X/Open 组织定义的分布式事务处理(DTP,Distributed Transaction Processing)标准。

XA 规范描述了全局的事务管理器与局部的资源管理器之间的接口。 XA规范的目的是允许多个资源(如数据库,应用服务器,消息队列等)在同一事务中访问,这样可以使 ACID 属性跨越应用程序而保持有效。

XA 规范使用两阶段提交来保证所有资源同时提交或回滚任何特定的事务。

XA 规范在上世纪 90 年代初就被提出。目前,几乎所有主流的数据库如 MySQL、Oracle、MSSQL 等都对 XA 规范提供了支持。

XA 事务的基础是两阶段提交协议。需要有一个事务协调者来保证所有的事务参与者都完成了准备工作(第一阶段)。如果协调者收到所有参与者都准备好的消息,就会通知所有的事务都可以提交了(第二阶段)。MySQL 在这个 XA 事务中扮演的是参与者的角色,而不是协调者(事务管理器)。

MySQL 的 XA 事务分为内部 XA 和外部 XA。外部 XA 可以参与到外部的分布式事务中,需要应用层介入作为协调者;内部 XA 事务用于同一实例下跨多引擎事务,由 Binlog 作为协调者,比如在一个存储引擎提交时,需要将提交信息写入二进制日志,这就是一个分布式内部 XA 事务,只不过二进制日志的参与者是 MySQL 本身。 MySQL 在 XA 事务中扮演的是一个参与者的角色,而不是协调者。

XA 事务的特点是:

  • 简单易理解,开发较容易。
  • 对资源进行了长时间的锁定,并发度低。

2.2 3PC

3PC 主要是为了弥补 2PC 的不足而产生的,2PC 有哪些不足呢?

  1. 同步阻塞:2PC 在执行过程中,所有参与节点(也就是一个分支事务)都是事务阻塞型的,当参与者占有公共资源时,其他第三方节点访问公共资源不得不处于阻塞状态,也就是在 2PC 执行的过程中,资源是被锁住的。
  2. 单点故障:在 2PC 中,事务协调者扮演了举足轻重的作用,由于事务协调者的重要性,一旦事务协调者发生故障,事务的参与者就会一直阻塞下去。尤其是在第二阶段,如果协调者发生故障,那么所有的参与者还都处于锁定事务资源的状态中,而无法继续完成事务操作。还有一个问题,就是当事务协调者发出 commit 指令之前,如果宕机了,此时虽然可以重新选举一个新的协调者出来,但是还是无法解决因为事务协调者宕机导致的事务参与者处于阻塞状态的问题。

3PC 则尝试解决 2PC 的这些问题。3PC 主要是把 2PC 中的第一阶段再次一分为二,这样 3PC 就有 CanCommit、PreCommit 以及 DoCommit 三个不同的阶段。不过 3PC 并不能解决 2PC 的所有问题,3PC 主要解决了单点故障问题,并且减少了阻塞。一旦事务参与者(分支事务)无法及时收到来自事务协调者的信息,那么分支事务会默认执行 commit,而不会一直持有事务资源并处于阻塞状态,不过这种机制也带来了新的问题,假设事务协调者发送了 abort 指令给各个分支事务,然而由于网络问题导致分支事务没有及时接收到该指令,那么分支事务在等待超时之后执行了 commit 操作,这样就和其他接到 abort 命令并执行回滚的分支事务之间存在数据不一致的情况。

我们来看看 3PC 的流程:

  1. CanCommit 阶段:这个阶段所做的事很简单,就是事务协调者询问各个分支事务,你是否有能力完成此次事务?如果都返回 yes,则进入第二阶段;有一个返回 no 或等待响应超时,则中断事务,并向所有分支事务发送 abort 请求。
  2. PreCommit 阶段:此时事务协调者会向所有的分支事务发送 PreCommit 请求,分支事务收到后开始执行事务操作,并将 Undo 和 Redo 信息记录到事务日志中。分支执行完事务操作后(此时属于未提交事务的状态),就会向事务协调者反馈“Ack”表示我已经准备好提交了,并等待协调者的下一步指令。
  3. DoCommit 阶段:在阶段二中如果所有的分支事务节点都可以进行 PreCommit 提交,那么事务协调者就会从“预提交状态”转变为“提交状态”,然后向所有的分支事务节点发送”doCommit”请求,分支事务节点在收到提交请求后就会各自执行事务提交操作,并向协调者节点反馈“Ack”消息,协调者收到所有参与者的 Ack 消息后完成事务。

相反,如果有一个分支事务节点未完成 PreCommit 的反馈或者反馈超时,那么协调者都会向所有的参与者节点发送 abort 请求,从而中断事务。

2.3 TCC

关于 TCC(Try-Confirm-Cancel)的概念,最早是由 Pat Helland 于 2007 年发表的一篇名为《Life beyond Distributed Transactions:an Apostate’s Opinion》的论文提出。

TCC 模式主要有如下一些优缺点:

优点:

  1. 性能提升:通过具体业务来实现控制资源锁的粒度变小,不会锁定整个资源。
  2. 数据最终一致性:基于 Confirm 和 Cancel 的幂等性,保证事务最终完成确认或者取消,保证数据的一致性。
  3. 可靠性:解决了 XA 协议的协调者单点故障问题,由主业务方发起并控制整个业务活动,业务活动管理器也变成多点,引入集群。

缺点:

  1. 对微服务的侵入性强,微服务的每个事务都必须实现 try,confirm,cancel 等 3 个方法,开发成本高,今后维护改造的成本也高。
  2. 为了达到事务的一致性要求,try,confirm、cancel 接口必须实现等幂性操作,这在一定程度上增加了开发工作量。

TCC 主要是两个阶段,步骤如下:

  1. Try 阶段(一阶段):尝试执行,完成所有业务检查(一致性), 预留必须业务资源(准隔离性)。
  2. Confirm 阶段(二阶段):确认执行真正执行业务,不作任何业务检查,只使用 Try 阶段预留的业务资源,Confirm 操作满足需要满足幂等性,Confirm 执行失败后需要进行重试。
  3. Cancel 阶段:取消执行,释放 Try 阶段预留的业务资源,Cancel 操作也需要满足幂等性。Cancel 阶段的异常和 Confirm 阶段异常处理方案基本上一致。

在我们之前的文章中,松哥也给大家举了 TCC 的例子了,这里就不再赘述了。

2.4 SAGA

SAGA 最初出现在 1987 年 Hector Garcaa-Molrna & Kenneth Salem 发表的论文 SAGAS 里。这篇论文的核心思想是将长事务拆分为多个短事务,由 Saga 事务协调器协调,如果每个短事务都成功提交完成,那么全局事务就正常完成,如果某个步骤失败,则根据相反顺序一次调用补偿操作。

Saga 事务的特点是:

  1. 并发度高,不用像 XA 事务那样长期锁定资源。
  2. 需要定义正常操作以及补偿操作(回滚),开发量工作量比 XA 大。
  3. 一致性较弱,对于转账,可能发生 A 用户已扣款,最后转账又失败的情况

SAGA 适用的场景较多,适用于长事务或者对中间结果不敏感的业务场景。

2.5 本地消息表

本地消息表这个方案最初是 ebay 架构师 Dan Pritchett 在 2008 年发表给 ACM 的文章中提出。

顾名思义,本地消息表就是会有一张存放本地消息的表,一般都是放在数据库中,然后在执行业务的时候将业务的执行和将消息放入消息表中的操作放在同一个事务中,这样就能保证消息放入本地表以及业务肯定是一起执行成功的。

当一个操作执行成功之后,再去执行下一个操作,如果下一个操作调用成功了好说,消息表的消息状态可以直接改为已成功;如果下一个任务调用失败也没关系,会有后台任务定时去读取本地消息表,筛选出还未成功的消息再调用对应的服务(重试),服务更新成功了再变更消息的状态。

重试就得保证对应服务的方法是幂等的,而且一般重试会有最大次数,超过最大次数可以记录下报警让人工处理。

根据上面的描述,小伙伴们其实可以看到,本地消息表其实实现的是最终一致性,容忍了数据暂时不一致的情况。

本地消息表的特点:

  • 长事务仅需要分拆成多个任务,使用简单。
  • 生产者需要额外的创建消息表。
  • 每个本地消息表都需要进行轮询(如果有失败的要重试)。
  • 消费者的逻辑如果无法通过重试成功,那么还需要更多的机制,来回滚操作。

根据本地消息表的特点我们可以发现,本地消息表适用于可异步执行且后续操作无需回滚的业务。

2.6 消息事务

这种方案的核心思路,其实就是通过消息中间件来将全局事务转为本地事务,通过消息中间件来确保各个分支事务最终都能调用成功。

松哥之前写过一篇文章是利用 RabbitMQ 实现的:

不过后来发现利用 Alibaba 的 RocketMQ(4.3之后)可以更好的实现分布式事务。

RocketMQ 是一种最终一致性的分布式事务,就是说它保证的是消息最终一致性,而不是像 2PC、3PC、TCC 那样强一致分布式事务,在 RocketMQ 中有一种消息叫做 Half Message,Half Message 是指暂不能被 Consumer 消费的消息,虽然 Producer 已经把消息成功发送到了 Broker 端,但此消息被标记为暂不能投递状态,处于该种状态下的消息称为半消息,此时需要 Producer 对消息进行二次确认后,Consumer 才能去消费它。

RocketMQ 就是基于 Half Message 来实现的分布式事务,举一个转账的例子:

  1. A 服务先发送个 Half Message 给 Brock 端,消息中携带 B 服务即将要 +100 元的信息。
  2. 当 A 服务知道 Half Message 发送成功后,那么开始本地事务。
  3. 执行本地事务(会有三种情况1、执行成功;2、执行失败;3、网络等原因导致没有响应)
    3.1 如果本地事务成功,那么 A 向 Broker 服务器发送 Commit,这样 B 服务就可以消费该 message。
    3.2 如果本地事务失败,那么 A 向 Broker 服务器发送 Rollback,那么就会直接删除上面这条半消息。
    3.3 如果由于网络或者生产者应用重启等原因。导致 A 一直没有对 Half Message 进行二次确认,此时 Broker 服务器会定时扫描长期处于半消息的消息,会主动询问 A 端该消息的最终状态(Commit 或者 Rollback),这个操作也就是所谓的消息回查。

可能有小伙伴会说,那要是 B 最终执行失败怎么办?对于这种情况,我们几乎可以断定就是代码有问题所以才引起异常,因为消费端 RocketMQ 有重试机制,如果不是代码问题一般重试几次就能成功。

如果是代码的原因引起多次重试失败后,也没有关系,将该异常记录下来,由人工处理,人工兜底处理后,就可以让事务达到最终的一致性。

2.7 最大努力通知

发起通知方通过一定的机制最大努力将业务处理结果通知到接收方。具体包括:

  1. 有一定的消息重试机制。因为接收通知方可能没有接收到通知,此时要有一定的机制对消息进行重试。
  2. 消息校对机制。如果尽最大努力也没有通知到接收方,或者接收方消费消息后要再次消费,此时可由接收方主动向通知方查询消息信息来满足需求。

在前面两个小节介绍的的本地消息表和事务消息都属于可靠消息,这与我们这里介绍的最大努力通知有什么不同?

  • 可靠消息一致性:消息发起方需要保证将消息发出去,并且将消息发到接收方,消息的可靠性关键由发起方来保证。
  • 最大努力通知:消息发起方尽最大努力将业务处理结果通知给接收方,但是可能消息接收不到,此时需要接收方主动调用发起方的接口查询业务处理结果,此时消息的可靠性关键在接收方。

仅此而已。

在具体的解决方案上,最大努力通知需要消息发起方提供接口,让被通知方能够通过接口查询业务处理结果。

最大努力通知适用于业务通知类型,最常见的场景就是支付回调,支付服务收到第三方服务支付成功通知后,先更新自己库中订单支付状态,然后同步通知订单服务支付成功。如果此次同步通知失败,会通过异步脚步不断重试地调用订单服务的接口。

最大努力通知更多是业务上的设计,在基础设施层,可以直接使用二阶段消息,或者事务消息、本地消息表等来实现。

3. 小结

好啦,学习分布式事务解决方案,最大的感受就是:没有银弹!

前面的文章松哥也和大家聊了很多实际的解决方案,也录制了相应的分布式事务视频在 TienChin 项目中,欢迎一起探讨。

参考资料:

  1. https://help.aliyun.com/document_detail/132895.html
  2. https://cloud.tencent.com/developer/article/1860632
  3. https://zh.m.wikipedia.org/zh-hans/CAP%E5%AE%9A%E7%90%86
  4. https://zhuanlan.zhihu.com/p/35616811

喜欢这篇文章吗?扫码关注公众号【江南一点雨】【江南一点雨】专注于 SPRING BOOT+微服务以及前后端分离技术,每天推送原创技术干货,关注后回复 JAVA,领取松哥为你精心准备的 JAVA 干货!

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×