分布式事务解决方案汇总

分布式事务解决方案汇总

如何保证转账这个操作在两个系统中同时成功呢?

2PC

每个参与者要实现三个接口:Prepare、Commit、Rollback 三个接口,这就是 XA 协议

XA 则规范了 TM 与 RM 之间的通信接口,在 TM 与多个 RM 之间形成一个双向通信桥梁,从而在多个数据库资源下保证 ACID 四个特性。

主要的缺点就是:

  • 性能问题
  • 事务执行到中间,事务协调者宕机,整个事务处于悬而不决的状态。
  • 一个参与者超时了,那么其它参与者应该提交还是回滚呢?
  • 2PC 主要用在两个数据库之间,而非两个系统之间。

3PC

3PC 把 2PC 的准备阶段分为了准备阶段和预处理阶段,在第一阶段只是询问各个资源节点是否可以执行事务,而在第二阶段,所有的节点反馈可以执行事务,才开始执行事务操作,最后在第三阶段执行提交或回滚操作。并且在事务管理器和资源管理器中都引入了超时机制,如果在第三阶段,资源节点一直无法收到来自资源管理器的提交或回滚请求,它就会在超时之后,继续提交事务。

所以 3PC 可以通过超时机制,避免管理器挂掉所造成的长时间阻塞问题,但其实这样还是无法解决在最后提交全局事务时,由于网络故障无法通知到一些节点的问题,特别是回滚通知,这样会导致事务等待超时从而默认提交。

消息中间件-最终一致性

消息中间件本身如 Kafka 不提供事务消息功能,那么解决步骤如下:

  • 系统 A 增加一张消息表,消息写入到消息表中和 DB1 的扣钱操作放到一个数据库的事务里,保证原子性。
  • 系统 A 后台程序源源不断地将消息表中的消息传送给消息中间件,失败了也尝试重传
  • 系统 B 通过消息中间件的 ACK 机制,明确自己是否消费成功。
  • 系统 B 增加判重表,记录处理成功的消息 ID 和消息中间件对应的 offset,实现业务幂等性,应对重复消费问题;如果业务本身有业务数据,可以判断是否重复,那么就无需这个判重表。

消息中间件如 RocketMQ 本身提供事务消息

RocketMQ 会定期 (默认 1min) 扫描所有的预发送但是还没有确认的消息,回调给发送方,询问这条消息是要发送出去,还是取消。发送方根据自己的业务数,知道这条消息是应该发送出去 (DB 更新成功),还是应该取消 (DB 更新失败)。

RocketMQ 最大的改变就是把 “扫描消息表” 这件事不让业务方做,而是让消息中间件完成。

无论什么 MQ,如果消费端一直消费失败,那么是要需要自动回滚整个流程?答案是需要人工介入,更加可靠,更加简单,发生概率极低

TCC

支付宝提出了 TCC,是为了解决 SOA 系统中的分布式事务问题,这是一个应用层面的 2PC 协议。

TCC 区别于 2PC 的地方在于,Confirm 或者 Cancel 失败了,TCC 框架会不断重试,这就要求这两个操作都必须是幂等性操作。

在转账操作中,Try 操作主要是为了锁定,是为了保证业务操作的前置条件都得到满足。然后 Confirm 阶段,才可以通过不断重试来保证成功。

TCC 补偿性事务也有比较明显的缺点,那就是对业务的侵入性非常大。

首先,我们需要在业务设计的时候考虑预留资源;然后,我们需要编写大量业务性代码,例如 Try、Confirm、Cancel 方法;最后,我们还需要为每个方法考虑幂等性。这种事务的实现和维护成本非常高,但综合来看,这种实现是目前大家最常用的分布式事务解决方案。

XA 和 TCC

TCC XA
主要限制 业务有侵入性,需要提供了三个接口 必须要拿到所有数据源,而且数据源还要支持XA协议。性能比较差,要把所有涉及到的数据都要锁定,是强一致性的,会产生长事务
使用范围 分布式架构。TCC是分布式事务,是最终一致性的,不会长期持有所有数据库资源的锁,原理上还是提交本地事务,所以不会存在长事务,这样就和本地事事务没有区别,性能很好 适用于单体架构

参考

事务状态表 + 调用方重试 + 接受方幂等

这是一种类似 TCC 的方法,只是这个方法是业务方自己实现的。

调用方维护一张事务状态表(或事务日志表、日志流水表),每次调用之前,落盘一条事务流水,生成一个全局的事务 ID。如下表所示,状态的设置也可以只设置 BEGIN 和 END,不保存中间状态:

有一个后台任务,扫描状态表,过了某段时间后,发现状态没有变为最终的状态 4,那么说明这条事务没有执行成功,于是需要重新调用 A、B、C,保证最终状态是状态 4。

  • 后台任务一直重试失败,则置为 Error 状态,人工介入干预
  • 调用方同步调用,部分成功,则客户端返回该笔钱转账超时,请稍候再来确认
  • 调用方调用 A、B 失败的时候,可以重试三次,三次还不成功,则放弃操作,交由后台任务后续处理。

对账

所有的过程必然产生结果,一个过程部分成功,则结果是不完整的,需要对数据进行修补,这就是对账的思路。

  • 系统中某个数据对象过了一个限定的时间生命周期,仍然没有走完,仍然处于中间状态,则系统不一致了,要进行补偿操作。
  • 微博的关注关系,一次向两个数据库写入两条数据,如何保证原子性?
  • 电商的订单系统分库分表,卖家和买家冗余一份,方便查询,这又如何保证原子性?

上述,可以保证一个库的数据是准确的,用这个库去对账另外一个库的数据。

对账分为全量对账增量对账

  • 全量对账:每天晚上运行一个定时任务,对比两个数据库
  • 增量对账:基于数据库的更新时间,也可以基于消息中间件,每一次业务操作都写一个消息到消息中间件,然后消费者消费消息,将两个数据库的数据进行对比(当然,消息可能会丢失,所以无法百分百保证,还是需要全量来对账

对账就是找到背后的数学规律,然后发现问题,然后补偿

妥协方案:弱一致性+基于状态的补偿

最终一致性方案是异步操作,数据有延迟;TCC 性能损耗大;事务状态表也有性能损耗;对账也是事后过程。

下单操作至少包含:创建订单 + 扣库存。

  • 最终一致性:异步操作,库存扣减不及时,会导致超卖
  • TCC:一个用户请求调用两次 Try、Confirm 订单和两次 Try、Confirm 库存服务,性能达不到要求
  • 事务状态表:存在性能问题

满足高并发,达到一致性,二者不可兼得。这个需求,关键特性:允许少卖,不能超卖。

(1) 先扣库存,后创建订单

有三种情况:

  • 扣库存成功,提交订单失败,返回成功
  • 扣库存成功,提交订单失败,返回失败,调用方重试(可能会多扣库存)
  • 扣库存成功, 不再提交订单,返回失败,调用方重试(可能会多扣库存)

(2) 先创建订单,后扣库存

三种情况:

  • 提交订单成功,扣库存成功,返回成功
  • 提交订单成功,扣库存失败,返回失败,调用方失败 (此处可能会多扣库存)
  • 提交订单失败,不再扣库存,调用方重试

无论哪个方案,只要最终保证库存可以多扣,不能少扣即可。

但是库存扣多了,数据不一致,怎么补偿?

库存每扣一次,生成一条流水记录,这条记录最终状态是 占用,订单支付成功,状态改为 释放

对于那些过了很长时间一直占用,而不释放的库存,可能是多扣、或者下单没有支付导致的。通过对比,就可以回收这些库存,取消相应订单。

妥协方案:重试+回滚+报警+人工修复

根据业务逻辑,通过三次重试或者回滚的方法,最大限度保证一致。实在不一致,就触发报警,让人工干预。只要日志流水记录得完整,人工肯定可以修复!

业务无侵入方案-Seata(Fescar)

Seata 是阿里去年开源的一套分布式事务解决方案,开源一年多已经有一万多 star 了,可见受欢迎程度非常之高。

Seata 的基础建模和 DTP 模型类似,只不过前者是将事务管理器分得更细了,抽出一个事务协调器(Transaction Coordinator 简称 TC),主要维护全局事务的运行状态,负责协调并驱动全局事务的提交或回滚。而 TM 则负责开启一个全局事务,并最终发起全局提交或全局回滚的决议。如下图所示:

整个事务流程为:

  • TM 向 TC 申请开启一个全局事务,全局事务创建成功并生成一个全局唯一的 XID;
  • XID 在微服务调用链路的上下文中传播;
  • RM 向 TC 注册分支事务,将其纳入 XID 对应全局事务的管辖;
  • TM 向 TC 发起针对 XID 的全局提交或回滚决议;
  • TC 调度 XID 下管辖的全部分支事务完成提交或回滚请求。

Seata 与其它分布式最大的区别在于,它在第一提交阶段就已经将各个事务操作 commit 了。Seata 认为在一个正常的业务下,各个服务提交事务的大概率是成功的,这种事务提交操作可以节约两个阶段持有锁的时间,从而提高整体的执行效率。

Seata 将 RM 提升到了服务层,通过 JDBC 数据源代理解析 SQL,把业务数据在更新前后的数据镜像组织成回滚日志,利用本地事务的 ACID 特性,将业务数据的更新和回滚日志的写入在同一个本地事务中提交。

如果 TC 决议要全局回滚,会通知 RM 进行回滚操作,通过 XID 找到对应的回滚日志记录,通过回滚记录生成反向更新 SQL,进行更新回滚操作。

以上我们可以保证一个事务的原子性和一致性,但隔离性如何保证呢?

Seata 设计通过事务协调器维护的全局写排它锁,来保证事务间的写隔离,而读写隔离级别则默认为未提交读的隔离级别。

参考