分布式事务的理解和常见解决方案汇总

时间:2022-09-13 00:55:04


分布式事务的理解和常见解决方案汇总

分布式事务的理解

分布式事务简介

在互联网系统里面,一般都是牺牲强一致性换取系统的高性能和高可用,只需要保证数据的最终一致性,只不过达到最终一致性所需要的时间需要在用户和产品层面是可以接受的。而金融系统则不同,金融系统一般都需要保持强一致性。

那么,在我们互联网场景中,怎么保证我们的数据最终一致性呢?一般来说,我们会采用分布式事务、分布式锁等,分布式事务与数据库事务一样,同样需要具有事务最基本的 ACID(原子性、一致性、隔离性、持久性)四个属性,事务的 ACID 特性,实现的是系统状态的一致性。一般在支付,或其他需要原子操作的场景下比较常用。

我们怎么理解分布式事务呢?我们知道,现在的系统,一般都是微服务架构,即便不是微服务架构,那么一个系统也会有多个服务来组成。那么业务上的一个完整流程,很有可能就需要分别调用散落在各个节点的各个不同服务上的接口,这个时候,如果我们想要保证这个完整流程的数据一致性,那么就需要保证请求在各个节点的各个服务中的请求,要么都成功,要么都失败。如果采用强一致性方案,基本上就靠“两阶段提交”这样的方式;如果采用弱一致性方案(最终一致性方案),那么可选择方案就会比较多,本文会详细梳理各个方案的优缺点。

分布式事务 vs 一致性协议(分布式一致性协议)

我们讲分布式事务的时候,总是会提及到分布式一致性协议,那么他们是否一回事呢?答案是否定的。我们细细分析下。

分布式一致性协议针对的是多个节点重复做相同的一件事情,主要处理的是多副本之间的一致性,更像是共识算法,比如 Paxos 算法、ZAB 协议、Raft 算法。分布式一致性协议要处理的逻辑是:

节点1完成任务1
节点2完成任务1
节点3完成任务1
每个节点都要完成同一个任务并且保证节点之间的处理状态完全一致

而分布式事务则针对的是有几个不同的任务或者流程,他们要捆绑一起成功或失败,要么都成功、要么都失败,要保证多个流程的一致性,针对的整体且完整流程的原子性。而 Raft 等共识算法、 TCC(Try-Confirm-Cancel)、Gossip 协议等则可以实现分布式事务的一致性。分布式事务要处理的逻辑是:

节点1完成任务1
节点2完成任务2
节点3完成任务3
每个节点完成不同任务并且保证同时成功或者同时失败

分布式事务 vs 数据库事务

数据库事务相对来说,会非常容易实现,数据库系统会将跨表事务的问题收拢到系统内部来处理,然后系统内部基于 XA 或其他协议,结合 MVCC 事务锁之类的机制,就可以解决好这个问题。

但是对在业务层面的分布式事务而言,我们前面说到,它涉及到的是散落在各个节点的各个服务之间的一致性,每个服务节点的数据存储机制和方案都是不固定的,因此就无法采用数据库事务的解决方案。比如如下一个分布式事务的场景:

分布式事务开启
  任务流程1:修改数据 A // 可能是 DB 存储
  任务流程2:修改数据 B // 可能是 nosql 存储
  任务流程3:调用系统内服务接口 C // -- 被调服务可能是类似情况
  任务流程4:调用外部门服务接口 D// -- 被调服务可能是类似情况
分布式事务结束

从上面这个流程可以看到,我们无法统一收归下游其他服务已经内部不同存储系统之间的事务。因此,针对业务层面的分布式事务的解决方案,一般的做法就是加一层或多层抽象:

  • • 增加外在的事务存储(主 key 为事务 ID,记录事务的参与方、进度、子事务等信息)。

  • • 要求事务参与方遵循某些约束(如本地事务 / 对账能力),调用特定的一些 API 将事务信息进行上报关联。

  • • 引入事务协调者,由它来根据参与方上报的信息做一些事务的驱动逻辑。

分布式事务的关键点

参考现有的各种框架级别的解决方案,分布式事务的关键点主要包括:

  • • 创建事务一定需要一个唯一主 key ,一般就是事务 ID ,然后基于这个唯一事务 ID ,关联一些事务信息的存储和各个子任务。

  • • 需要有一个协调者来负责跟踪推进完整个事务,然后各参与者需要遵从一定的规范约束,基于事务 ID ,以幂等、对账能力为基础,实现相应的 API。

  • • 一致性要求高的场景,会有对资源做锁定或预留的做法,最终一致性要求的场景,则只要最终符合预期即可。基于对资源要求的不同,会有一些常见的解决方案,例如多阶段协商提交、TCC、事务消息等。

分布式事务的常见解决方案

分布式事务的常见解决方案主要分为强一致性的解决方案 和 最终一致性的解决方案

强一致性的解决方案

XA 分布式事务协议

XA 分布式事务协议,是由 Oracle Tuxedo 系统提出的,XA中大致分为两部分:事务管理器和本地资源管理器。其中本地资源管理器往往由数据库实现,比如Oracle、DB2这些商业数据库都实现了XA接口,而事务管理器作为全局的调度者,负责各个本地资源的提交和回滚。XA 协议通常包含两阶段提交(2PC)和三阶段提交(3PC)两种实现。XA 并发性能不太好,无法满足高并发场景,在互联网中使用较少。

2PC 两阶段提交

两阶段提交(2PC,Two-phase Commit Protocol)是非常经典的强一致性、中心化的原子提交协议,在各种事务和一致性的解决方案中,都能看到两阶段提交的应用。

3PC 三阶段提交

三阶段提交协议(3PC,Three-phase_commit_protocol)是在 2PC 之上扩展的提交协议,主要是为了解决两阶段提交协议的阻塞问题,从原来的两个阶段扩展为三个阶段,增加了超时机制。

DTS 方案

阿里有一个分布式事务服务 (Distributed Transaction Service, DTS), 是一个分布式事务框架,用来保障在大规模分布式环境下事务的最终一致性。DTS 从架构上分为 xts-client 和 xts-server 两部分,前者是一个嵌入客户端应用的 JAR 包,主要负责事务数据的写入和处理;后者是一个独立的系统,主要负责异常事务的恢复。

最终一致性的解决方案

TCC 分段提交【较常用】

实现分布式事务,最常用的方法是二阶段提交协议和 TCC,这两个算法的适用场景是不同的,二阶段提交协议实现的是数据层面的事务,比如 XA 规范采用的就是二阶段提交协议;TCC 实现的是业务层面的事务,比如当操作不仅仅是数据库操作,还涉及其他业务系统的访问操作时,这时就应该考虑 TCC 了。

TCC 是一个分布式事务的处理模型,将事务过程拆分为 Try、Confirm、Cancel 三个步骤,在保证强一致性的同时,最大限度提高系统的可伸缩性与可用性,又被称补偿事务。它的核心思想是针对每个操作都要注册一个与其对应的确认操作和补偿操作(也就是撤销操作)

TCC 实现的是业务层面的事务,TCC 可以理解为是一个业务层面的协议,可以当做为一个编程模型来看待,因此这个的应用还是非常广泛的。,TCC 的 3 个操作是需要在业务代码中编码实现的,为了实现一致性,确认操作和补偿操作必须是等幂的,因为这 2 个操作可能会失败重试。

TCC 不依赖于数据库的事务,而是在业务中实现了分布式事务,这样能减轻数据库的压力,但对业务代码的入侵性也更强,实现的复杂度也更高。

MQ(非事务消息)【较常用】

采用非事务消息的这种方式比较常见,一个是由于市面上很多这种成熟的非事务消息的解决方案,一个是由于这些 MQ 的性能和吞吐量都比较好,可以满足大部分的业务场景。

一个典型流程基本上就是,生产者先执行本地事务并将消息落库,状态标记为待发送,然后发送消息。如果发送成功,则将消息改为发送成功;如果发送失败则不修改标记。然后会起一个定时任务,定时从数据库捞取在一定时间内待发送的消息并将消息发送。为确保消息一定能消费,消费者一般采用手动 ACK 机制,并且最好需要支持幂等。

在消费者端,我们可能面临的问题和解决方案是:

  1. 1. 消费者消费到消息后,消费者要保证对应的业务操作要执行成功之后再才能主动 ACK。如果业务执行失败,消息不能失效或者丢失。目前主流的 MQ 产品都具有持久化消息的功能,如果消费者宕机或者消费失败,都可以执行重试机制的,因此这个比较好解决。

  2. 2. 消费者消费消息要能够在业务层面保持幂等,因为消费可能会失败,因此只有具有幂等性,才能不影响业务。具体的方案,在业务层可以采用唯一主键来解决,也可以采用其他的日志或库表(去重表)来保证。

在生产者端,面临的问题是执行本地事务和发送消息是两个异步的操作,他们并不能保证强一致性,因此有一定的概率会出现一些 bug。

MQ(事务消息)【没有开源方案】

在分布式事务实践中事务性消息也是比较常使用的,所谓的消息事务就是基于消息队列的两阶段提交,本质上是对消息队列的一种特殊利用,它是将本地事务和发消息放在了一个分布式事务里,本地事务和发送消息这两个步骤是保持了强一致性的,保证要么本地操作成功成功并且对外发消息成功,要么两者都失败,这种消息就是事务性消息。和不支持事务的消息中间相比,只是消息发送的时候,保证了和本地事务的一致。消费者实现还是不变。

通过 事务消息来实现的话,整体的可靠性会比较高,阿里的 RocketMQ 就是属于事务消息。RocketMQ 的事务消息的设计思路是:RocketMQ 第一阶段发送 Prepared 消息时,会拿到消息的地址,第二阶段执行本地事物,第三阶段通过第一阶段拿到的地址去访问消息,并修改状态。然后需要定期扫描消息集群中的事物消息,这时候发现了 Prepared 消息,它会向消息发送者确认,RocketMQ 会根据发送端设置的策略来决定是回滚还是继续发送确认消息。这样就保证了消息发送与本地事务同时成功或同时失败。RocketMQ 中的事务,它解决的问题是,确保执行本地事务和发消息这两个操作,要么都成功,要么都失败。并且 RocketMQ 增加了一个事务反查的机制,来尽量提高事务执行的成功率和数据一致性。

目前一些主流的开源消息队列比如 ActiveMQ、RabbitMQ、Kafka 等都没有实现对事务消息的支持,但是可以有类似的实现方式。比如,Kafka 中的事务,它解决的问题是,确保在一个事务中发送的多条消息,要么都成功,要么都失败。

SAGA 长流程分布式事务【较常用】

SAGA 用于处理有序的一长串的长流程的事务,相对来说,性能更好,无资源锁定,无流程阻塞,但是不保证事务间的隔离性与原子性,需要业务侧根据需要处理可能的问题。

SAGA 的每个子事务都有一个补偿接口,如果执行到某个阶段失败后,则对已经成功的子事务按栈顺序依次进行补偿操作(补偿不允许失败,失败必须重试直到成功),SAGA 的一阶段为 Do,二阶段是 Undo,每个 Do 都是一个完整的事务,但整个流程并不能保证隔离性与原子性。

业务补偿方式:重试(or 回滚)+告警+人工修复【较常用】

另外一个实现弱一致性的比较简单粗暴的方式,就是采用业务补偿方式,通过重试 + 回滚 (自动或手动)的方式,当出现不一致的时候,进行重试,重试还是失败后就进行回滚,或者告警后人工修复。

补偿事务的缺点在于不同的业务要写不同的补偿事务,不具备通用性;并且如果业务流程很复杂,if/else会嵌套非常多层;同时也没有考虑补偿事务失败的后续流程。总之是一个比较糙的解决方案。在对一致性要求不高的情况下,如果又想要比较简单的实现,可以采用这种业务补偿方式。业务补偿设计的主要核心点在于:

  • • 将服务做成幂等性的,如果一个事务失败了或是超时了,我们需要不断地重试,努力地达到最终我们想要的状态。

  • • 如果执行不下去,需要启动补偿机制,回滚业务流程。

一个业务补偿的伪代码示例如下:

// 执行第一个事务
int flag = Do_AccountT();
if(flag=YES){
    //第一个事务成功,则执行第二个事务
    flag= Do_OrderT();
    if(flag=YES){
        // 第二个事务成功,则成功
        return YES;
    }
    else{
        // 第二个事务失败,执行第一个事务的补偿事务
        Compensate_AccountT();
    }
}