面试||分布式事务

什么是分布式事务?

分布式事务就是指事务的参与者、支持事务的服务器、资源服务器以及事务管理器分别位于不同的分布式系统的不同节点之上。简单的说,就是一次大的操作由不同的小操作组成,这些小的操作分布在不同的服务器上,且属于不同的应用,分布式事务需要保证这些小操作要么全部成功,要么全部失败。本质上来说,分布式事务就是为了保证不同数据库的数据一致性

或者,换一句话说,分布式事务 = n 个本地事务。通过事务管理器,达到 n 个本地事务要么全部成功,要么全部失败。

为什么会有分布式事务?

从本地事务来看,我们可以看为两块,一个是 service 产生多个节点,另一个是 resource 产生多个节点。

我们回过头来想想用户下单完成,我们需要给用户发短信。如果发送短信失败,可能是网络抖动的原因,我们是不应该去回滚本地事务,那么此时也可以认为是一个分布式事务。

1)service 多个节点
随着互联网快速发展,微服务,SOA等服务架构模式正在被大规模的使用,举个简单的例子,一个公司之内,用户的资产可能分为好多个部分,比如余额,积分,优惠券等等。在公司内部有可能积分功能由一个微服务团队维护,优惠券又是另外的团队维护。这样的话就无法保证积分扣减了之后,优惠券能否扣减成功。
2)resource 多个节点
同样的,互联网发展得太快了,我们的Mysql一般来说装千万级的数据就得进行分库分表,对于一个支付宝的转账业务来说,你给的朋友转钱,有可能你的数据库是在北京,而你的朋友的钱是存在上海,所以我们依然无法保证他们能同时成功。

最常见的场景,系统里引入了 Redis 做缓存,那么 DB 和 Redis 的一致性问题,就是一种分布式事务的场景。

是否真的要分布式事务?

在说分布式事务的方案之前,首先你一定要明确你是否真的需要分布式事务?

上面说过出现分布式事务的两个原因,其中有个原因是因为微服务过多。我见过太多团队一个人维护几个微服务,太多团队过度设计,搞得所有人疲劳不堪,而微服务过多就会引出分布式事务,这个时候我不会建议你去采用分布式事务的方案,而是请把需要事务的微服务聚合成一个单机服务,使用数据库的本地事务。因为不论任何一种方案都会增加你系统的复杂度,这样的成本实在是太高了,千万不要因为追求某些设计,而引入不必要的成本和复杂度。

当然,如果你是个人的练习 Demo 项目,请使劲的造,拼命的玩。甚至说,我建议你能读完所使用的分布式事务的方案的原理和源码。因为,一旦上了生产,出了问题,你很有可能无从下手~

所以,想清楚你是否需要分布式事务,你是否能够 hold 住分布式事务的解决方案。

分布式事务的基础?

《分布式系统理论(一):CAP 定理》
数据库的 ACID 满足了数据库本地事务的基础,但是它无法满足分布式事务,这个时候衍生了 CAPBASE 两个经典理论。

CAP 理论
CAP 定理,又被叫作布鲁尔定理。对于设计分布式系统来说(不仅仅是分布式事务)的架构师来说,CAP 就是你的入门理论。

  • C (一致性):在分布式系统中的所有数据备份,在同一时刻是否同样的值。(等同于所有节点访问同一份最新的数据副本)
  • A (可用性):在集群中一部分节点故障后,集群整体是否还能响应客户端的读写请求。(对数据更新具备高可用性)
  • P (分区容错性):以实际效果而言,分区相当于对通信的时限要求。系统如果不能在时限内达成数据一致性,就意味着发生了分区的情况,必须就当前操作在 C 和 A 之间做出选择。

高可用、数据一致性是很多系统设计的目标,但是分区又是不可避免的事情。我们来看一看分别拥有 CA、CP 和 AP 的情况。

  • CA without P:如果不要求 P(不允许分区),则 C(强一致性)和A(可用性)是可以保证的。但其实分区不是你想不想的问题,而是始终会存在,因此 CA 的系统更多的是允许分区后各子系统依然保持 CA 。
  • CP without A:如果不要求 A(可用),相当于每个请求都需要在 Server 之间强一致,而 P(分区)会导致同步时间无限延长,如此 CP 也是可以保证的。很多传统的数据库分布式事务都属于这种模式。
  • AP wihtout C:要高可用并允许分区,则需放弃一致性。一旦分区发生,节点之间可能会失去联系,为了高可用,每个节点只能用本地数据提供服务,而这样会导致全局数据的不一致性。现在众多的NoSQL都属于此类。

BASE 理论
BASE 是 Basically Available(基本可用)、Soft state(软状态)和 Eventually consistent (最终一致性) 三个短语的缩写。是对 CAP 中AP 的一个扩展

  • BA 基本可用:分布式系统在出现故障时,允许损失部分可用功能,保证核心功能可用。
  • S 软状态:允许系统中存在中间状态,这个状态不影响系统可用性,这里指的是 CAP 中的不一致。
  • E 最终一致:最终一致是指经过一段时间后,所有节点数据都将会达到一致。

BASE 解决了 CAP 中理论没有网络延迟,在 BASE 中用软状态和最终一致,保证了延迟后的一致性。

BASE 和 ACID 是相反的,它完全不同于 ACID 的强一致性模型,而是通过牺牲强一致性来获得可用性,并允许数据在一段时间内是不一致的,但最终达到一致状态。

对于大部分的分布式应用而言,只要数据在规定的时间内达到最终一致性即可。我们可以把符合传统的 ACID 叫做刚性事务,把满足 BASE 理论的最终一致性事务叫做柔性事务。
一味的追求强一致性,并非最佳方案。对于分布式应用来说,刚柔并济是更加合理的设计方案,即在本地服务中采用强一致事务,在跨系统调用中采用最终一致性。如何权衡系统的性能与一致性,是十分考验架构师与开发者的设计功力的。

具体到分布式事务的实现上,业界主要采用了 XA 协议的强一致规范以及柔性事务的最终一致规范。
市面上的分布式事务的解决方案,除了 XA 协议是强一直的,其他都是最终一致的。

分布式事务的实现主要有哪些方案?

分布式事务的实现主要有以下 6 种方案:

  • XA 方案
  • TCC 方案
  • 本地消息表
  • 可靠消息最终一致性方案
  • 最大努力通知方案
  • SAGA

聊聊 XA 方案?

XA 是 X/Open CAE Specification (Distributed Transaction Processing)模型,它定义的 TM(Transaction Manager)与 RM(Resource Manager)之间进行通信的接口。

Java中 的 javax.transaction.xa.XAResource 定义了 XA 接口,它依赖数据库厂商对 jdbc-driver 的具体实现。

mysql-connector-java-5.1.30 的实现可参 com.mysql.jdbc.jdbc2.optional.MysqlXAConnection 类。
在 XA 规范中,数据库充当 RM 角色,应用需要充当 TM 的角色,即生成全局的 txId ,调用 XAResource 接口,把多个本地事务协调为全局统一的分布式事务。

目前 XA 有两种实现:

  • 基于一阶段提交( 1PC ) 的弱 XA 。
  • 基于二阶段提交( 2PC ) 的强 XA 。

弱 XA

弱 XA 通过去掉 XA 的 Prepare 阶段,以达到减少资源锁定范围而提升并发性能的效果。典型的实现为在一个业务线程中,遍历所有的数据库连接,依次做 commit 或者 rollback 。
弱 XA 同本地事务相比,性能损耗低,但在事务提交的执行过程中,若出现网络故障、数据库宕机等预期之外的异常,将会造成数据不一致,且无法进行回滚。
面试||分布式事务

强 XA

面试||分布式事务
二阶段提交是 XA 的标准实现。它将分布式事务的提交拆分为 2 个阶段:preparecommit/rollback

  • 第一阶段:事务管理器要求每个涉及到事务的数据库预提交(precommit)此操作,并反映是否可以提交。
  • 第二阶段:事务协调器要求每个数据库提交数据,或者回滚数据。

开启 XA 全局事务后,所有子事务会按照本地默认的隔离级别锁定资源,并记录 undo 和 redo 日志。然后由 TM 发起 prepare 投票,询问所有的子事务是否可以进行提交:

  • 当所有子事务反馈的结果为 “yes” 时,TM 再发起 commit 。
  • 若其中任何一个子事务反馈的结果为“no”,TM 则发起 rollback 。
  • 如果在 prepare 阶段的反馈结果为 “yes” ,而 commit 的过程中出现宕机等异常时,则在节点服务重启后,可根据 XA recover 再次进行 commit 补偿,以保证数据的一致性。

优点?
尽量保证了数据的强一致,实现成本较低,在各大主流数据库都有自己实现,对于 MySQL 是从 5.5 开始支持。

缺点?
单点问题:事务管理器在整个流程中扮演的角色很关键,如果其宕机,比如在第一阶段已经完成,在第二阶段正准备提交的时候事务管理器宕机,资源管理器就会一直阻塞,导致数据库无法使用。

同步阻塞:在准备就绪之后,资源管理器中的资源一直处于阻塞,直到提交完成,释放资源。

数据不一致:两阶段提交协议虽然为分布式数据强一致性所设计,但仍然存在数据不一致性的可能,比如在第二阶段中,假设协调者发出了事务commit 的通知,但是因为网络问题该通知仅被一部分参与者所收到并执行了 commit 操作,其余的参与者则因为没有收到通知一直处于阻塞状态,这时候就产生了数据的不一致性。

应用场景

这种分布式事务方案,比较适合单块应用里,跨多个库的分布式事务,而且因为严重依赖于数据库层面来搞定复杂的事务,效率很低,绝对不适合高并发的场景。

这个方案,我们很少用,一般来说某个系统内部如果出现跨多个库的这么一个操作,是不合规的。我可以给大家介绍一下, 现在微服务,一个大的系统分成几百个服务,几十个服务。一般来说,我们的规定和规范,是要求每个服务只能操作自己对应的一个数据库。

如果你要操作别的服务对应的库,不允许直连别的服务的库,违反微服务架构的规范,你随便交叉胡乱访问,几百个服务的话,全体乱套,这样的一套服务是没法管理的,没法治理的,可能会出现数据被别人改错,自己的库被别人写挂等情况。

如果你要操作别人的服务的库,你必须是通过调用别的服务的接口来实现,绝对不允许交叉访问别人的数据库。
面试||分布式事务

聊聊 TCC 方案?

TCC 模型是把锁的粒度完全交给业务处理,它需要每个子事务业务都实现Try-Confirm / Cancel 接口。
TCC 模式本质也是 2PC ,只是 TCC 在应用层控制。

Try:

  • 尝试执行业务
  • 完成所有业务检查(一致性)
  • 预留必须业务资源(准隔离性)

Confirm:

  • 确认执行业务;
  • 真正执行业务,不作任何业务检查
  • 只使用Try阶段预留的业务资源
  • Confirm 操作满足幂等性

Cancel:

  • 取消执行业务
  • 释放Try阶段预留的业务资源
  • Cancel操作满足幂等性

这三个阶段,都会按本地事务的方式执行。不同于 XA的prepare ,TCC 无需将 XA 的投票期间的所有资源挂起,因此极大的提高了吞吐量。

应用场景

下面对TCC模式下,A账户往B账户汇款100元为例子,对业务的改造进行详细的分析:
面试||分布式事务
汇款服务和收款服务分别需要实现,Try-Confirm-Cancel 接口,并在业务初始化阶段将其注入到 TCC 事务管理器中。
汇款服务

  • Try:
    • 检查A账户有效性,即查看A账户的状态是否为“转帐中”或者“冻结”
    • 检查A账户余额是否充足
    • 从A账户中扣减 100 元,并将状态置为“转账中”
    • 预留扣减资源,将从 A 往 B 账户转账 100 元这个事件存入消息或者日志中
  • Confirm:
    • 不做任何操作
  • Cancel:
    • A 账户增加 100 元
    • 从日志或者消息中,释放扣减资源

收款服务

  • Try:
    • 检查 B 账户账户是否有效;
  • Confirm:
    • 读取日志或者消息,B 账户增加 100 元
    • 从日志或者消息中,释放扣减资源;
  • Cancel:
    • 不做任何操作
    • 由此可以看出,TCC 模型对业务的侵入强,改造的难度大。

但是,在需要前置资源锁定的场景,不得不使用 XA 或 TCC 的方式。再例如说,下单场景,在订单创建之前,需要扣除如下几个资源:优惠劵、钱包余额、积分….
那么,不得不进行前置多资源锁定,无非是使用 XA 的强锁,还是 TCC 的弱锁。在 oceans 的 tag 0.0.1 中,在未使用 TCC 的情况下,模拟 TCC 的效果的苦闷。

当然,如果能不用 TCC 的情况下,尽量不要用 TCC 。因为,编写回滚逻辑的代码,可能会比较恶心。

聊聊本地消息表?

本地消息表,其实是 国外的 Ebay 搞出来的这么一套思想 。

这个大概意思是这样的:
面试||分布式事务

  1. A 系统在自己本地一个事务里操作同时,插入一条数据到消息表;
  2. 接着 A 系统将这个消息发送到 MQ 中去;
  3. B 系统接收到消息之后,在一个事务里,往自己本地消息表里插入一条数据,同时执行其他的业务操作,如果这个消息已经被处理过了,那么此时这个事务会回滚,这样保证不会重复处理消息;
  4. B 系统执行成功之后,就会更新自己本地消息表的状态以及 A 系统消息表的状态;
  5. 如果 B 系统处理失败了,那么就不会更新消息表状态,那么此时 A 系统会定时扫描自己的消息表,如果有未处理的消息,会再次发送到 MQ 中去,让 B 再次处理;
  6. 这个方案保证了最终一致性,哪怕 B 事务失败了,但是 A 会不断重发消息,直到 B 那边成功为止。

这个方案说实话最大的问题就在于严重依赖于数据库的消息表来管理事务啥的,会导致如果是高并发场景咋办呢?咋扩展呢?所以一般确实很少用。
本地消息队列是 BASE 理论,是最终一致模型,适用于对一致性要求不高的。实现这个模型时需要注意重试的幂等。

聊聊可靠消息最终一致性方案?

这个的意思,就是干脆不要用本地的消息表了,直接基于 MQ 来实现事务。比如阿里的 RocketMQ 就支持消息事务。

大概的意思就是:
面试||分布式事务

  1. A 系统先发送一个 prepared 消息到 mq,如果这个 prepared 消息发送失败那么就直接取消操作别执行了;
  2. 如果这个消息发送成功过了,那么接着执行本地事务,如果成功就告诉 mq 发送确认消息,如果失败就告诉 mq 回滚消息;
  3. 如果发送了确认消息,那么此时 B 系统会接收到确认消息,然后执行本地的事务;
  4. mq 会自动定时轮询所有 prepared 消息回调你的接口,问你,这个消息是不是本地事务处理失败了,所有没发送确认的消息,是继续重试还是回滚?一般来说这里你就可以查下数据库看之前本地事务是否执行,如果回滚了,那么这里也回滚吧。这个就是避免可能本地事务执行成功了,而确认消息却发送失败了。
  5. 这个方案里,要是系统 B 的事务失败了咋办?重试咯,自动不断重试直到成功,如果实在是不行,要么就是针对重要的资金类业务进行回滚,比如 B 系统本地回滚后,想办法通知系统 A 也回滚;或者是发送报警由人工来手工回滚和补偿。

聊聊最大努力通知方案?

解释一

最大努力送达,是针对于弱 XA 的一种补偿策略。它采用事务表记录所有的事务操作 SQL 。
面试||分布式事务
如果子事务提交成功,将会删除事务日志。
如果执行失败,则会按照配置的重试次数,尝试再次提交,即最大努力的进行提交,尽量保证数据的一致性,这里可以根据不同的业务场景,平衡 C 和 A ,采用同步重试或异步重试。
优点
无锁定资源时间,性能损耗小。
缺点
尝试多次提交失败后,无法回滚,它仅适用于事务最终一定能够成功的业务场景。
总结
因此 BED 是通过事务回滚功能上的妥协,来换取性能的提升。
貌似,暂时也想象不到具体的使用场景。

解释二

这个方案的大致意思就是:

  1. 系统 A 本地事务执行完之后,发送个消息到 MQ;
  2. 这里会有个专门消费 MQ 的最大努力通知服务,这个服务会消费 MQ 然后写入数据库中记录下来,或者是放入个内存队列也可以,接着调用系统 B 的接口;
  3. 要是系统 B 执行成功就 ok 了;要是系统 B 执行失败了,那么最大努力通知服务就定时尝试重新调用系统 B,反复 N 次,最后还是不行就放弃。