分布式事务简介

事务

数据库事务(简称:事务,Transaction)是指数据库执行过程中的一个逻辑单位,由一个有限的数据库操作序列构成。

事务拥有以下四个特性,习惯上被称为 ACID 特性:

  1. 原子性(Atomicity):事务作为一个整体被执行,包含在其中的对数据库的操作要么全部被执行,要么都不执行。

  2. 一致性(Consistency):事务应确保数据库的状态从一个一致状态转变为另一个一致状态。一致状态是指数据库中的数据应满足完整性约束。除此之外,一致性还有另外一层语义,就是事务的中间状态不能被观察到(这层语义也有说应该属于原子性)。

  3. 隔离性(Isolation):多个事务并发执行时,一个事务的执行不应影响其他事务的执行,如同只有这一个操作在被数据库所执行一样。

  4. 持久性(Durability):已被提交的事务对数据库的修改应该永久保存在数据库中。在事务结束时,此操作将不可逆转。

本地事务:基于单个服务单一数据库资源访问的事务,被称为本地事务(Local Transaction);

分布式事务: 当一个服务操作访问不同的数据库资源,或者是多个服务操作多个数据库资源需要保证一致性时,就需要使用分布式事务;

CAP理论

CAP理论说的是:在一个分布式系统中,最多只能满足C、A、P中的两个需求。

CAP的含义:

  • C:Consistency 一致性
    同一数据的多个副本是否实时相同。

  • A:Availability 可用性
    可用性:一定时间内 & 系统返回一个明确的结果 则称为该系统可用。

  • P:Partition tolerance 分区容错性
    将同一服务分布在多个系统中,从而保证某一个系统宕机,仍然有其他系统提供相同的服务。

CAP理论告诉我们,在分布式系统中,C、A、P三个条件中我们最多只能选择两个

BASE理论

CAP理论告诉我们一个悲惨但不得不接受的事实——我们只能在C、A、P中选择两个条件。而对于业务系统而言,我们往往选择牺牲一致性来换取系统的可用性和分区容错性。不过这里要指出的是,所谓的“牺牲一致性”并不是完全放弃数据一致性,而是牺牲强一致性换取弱一致性。下面来介绍下BASE理论。

  • BA:Basic Available 基本可用
    分布式系统在出现故障时,允许损失部分可用功能,保证核心功能可用。只不过“基本可用”和“高可用”的区别是:

    • “一定时间”可以适当延长, 当举行大促时,响应时间可以适当延长
    • 给部分用户直接返回一个降级页面,从而缓解服务器压力。但要注意,返回降级页面仍然是返回明确结果。
  • S:Soft State:柔性状态
    允许系统中存在中间状态,这个状态不影响系统可用性,这里指的是CAP中的不一致。

  • E:Eventual Consisstency:最终一致性
    同一数据的不同副本的状态,可以不需要实时一致,但一定要保证经过一定时间后最终是一致的。

分布式事务协议

两阶段提交协议 2PC

分布式系统的一个难点是如何保证多个节点在进行事务性操作的时候一致性。为实现这个目的,二阶段提交算法的成立基于以下假设:

  • 该分布式系统中,存在一个全局事务管理器(TM,Transaction Manager)和多个资源管理器(RM,Resource Manager)。且节点之间可以进行网络通信。

  • 所有节点都采用预写式日志,且日志被写入后即被保持在可靠的存储设备上,即使节点损坏不会导致日志数据的消失。

  • 所有节点不会永久性损坏,即使损坏后仍然可以恢复。

1. 准备阶段(投票阶段):

  • TM 向每个 RM 发送准备消息;
  • 如果 RM 的本地事务操作执行成功(并将Undo信息和Redo信息写入日志),则返回成功;
  • 如果 RM 的本地事务操作执行失败,则返回失败;

注意:若成功这里其实每个 RM 已经执行了事务操作。

2. 提交阶段(执行阶段):

  • 如果 TM 收到了所有 RM 回复的成功消息,则向每个 RM 发送提交消息;

  • 否则发送回滚消息;RM 根据 TM 的指令执行提交或者回滚本地事务操作,释放所有事务处理过程中使用的锁资源。

不管最后结果如何,第二阶段都会结束当前事务。

二阶段提交看起来确实能够提供原子性的操作,但是不幸的事,二阶段提交还是有几个缺点的:

  • 执行过程中,所有参与节点都是事务阻塞型的。当参与者占有公共资源时,其他第三方节点访问公共资源不得不处于阻塞状态。

  • 参与者发生故障。协调者需要给每个参与者额外指定超时机制,超时后整个事务失败。(没有多少容错机制)

  • 协调者发生故障。参与者会一直阻塞下去。需要额外的备机进行容错。(这个可以依赖后面要讲的Paxos协议实现HA)

  • 二阶段无法解决的问题:协调者在发出commit消息之后宕机,而唯一接收到这条消息的参与者同时也宕机了。那么即使协调者通过选举协议产生了新的协调者,这条事务的状态也是不确定的,没人知道事务是否被已经提交。

为此,Dale Skeen和Michael Stonebraker在“A Formal Model of Crash Recovery in a Distributed System”中提出了三阶段提交协议(3PC)。

三阶段提交协议 3PC

与两阶段提交不同的是,三阶段提交有两个改动点。

  • 引入超时机制。同时在协调者和参与者中都引入超时机制。
  • 在第一阶段和第二阶段中插入一个准备阶段。保证了在最后提交阶段之前各参与节点的状态是一致的。

也就是说,除了引入超时机制之外,3PC把2PC的准备阶段再次一分为二,这样三阶段提交就有CanCommitPreCommitDoCommit三个阶段。

CanCommit阶段

3PC的CanCommit阶段其实和2PC的准备阶段很像。

  • 事务询问
    协调者向参与者发送CanCommit请求。询问是否可以执行事务提交操作。然后开始等待参与者的响应;

  • 响应反馈
    参与者接到CanCommit请求之后,正常情况下,如果其自身认为可以顺利执行事务,则返回Yes响应,并进入预备状态。否则反馈No;

PreCommit阶段

协调者根据参与者的反应情况来决定是否可以进行事务的PreCommit操作。根据响应情况,有以下两种可能:

  • 所有反馈都是Yes响应,那么就会执行事务的预执行:

    • 发送预提交请求
      协调者向参与者发送PreCommit请求,并进入Prepared阶段;

    • 事务预提交
      参与者接收到PreCommit请求后,会执行事务操作,并将undo和redo信息记录到事务日志中;

    • 响应反馈
      如果参与者成功的执行了事务操作,则返回ACK响应,同时开始等待最终指令;

  • 任何一个参与者反馈了No响应,或者等待超时之后,协调者都没有接到参与者的响应,那么就执行事务的中断:

    • 发送中断请求
      协调者向所有参与者发送abort请求;

    • 中断事务
      参与者收到来自协调者的abort请求之后(或超时之后,仍未收到协调者的请求),执行事务的中断;

DoCommit阶段

该阶段进行真正的事务提交,也可以分为以下两种情况:

  • 执行提交

    • 发送提交请求
      协调接收到参与者发送的ACK响应,那么他将从预提交状态进入到提交状态。并向所有参与者发送doCommit请求;

    • 事务提交
      参与者接收到doCommit请求之后,执行正式的事务提交。并在完成事务提交之后释放所有事务资源;

    • 响应反馈
      事务提交完之后,向协调者发送Ack响应;

    • 完成事务
      协调者接收到所有参与者的ack响应之后,完成事务;

  • 中断事务
    协调者没有接收到参与者发送的ACK响应(可能是接受者发送的不是ACK响应,也可能响应超时),那么就会执行中断事务:

    • 发送中断请求
      协调者向所有参与者发送abort请求;

    • 事务回滚
      参与者接收到abort请求之后,利用其在阶段二记录的undo信息来执行事务的回滚操作,并在完成回滚之后释放所有的事务资源;

    • 反馈结果
      参与者完成事务回滚之后,向协调者发送ACK消息;

    • 中断事务
      协调者接收到参与者反馈的ACK消息之后,执行事务的中断;

分布式事务模型

XA协议

最早的分布式事务模型是 X/Open 国际联盟提出的 X/Open Distributed Transaction Processing(DTP)模型,也就是大家常说的 X/Open XA 协议,简称 XA 协议。

DTP 模型中包含一个全局事务管理器(TM,Transaction Manager)和多个资源管理器(RM,Resource Manager)。

  • 全局事务管理器负责管理全局事务状态与参与的资源,协同资源一起提交或回滚;
  • 资源管理器则负责具体的资源操作;

XA 协议描述了 TM 与 RM 之间的接口,允许多个资源在同一分布式事务中访问。

基于 DTP 模型的分布式事务流程

  1. 应用程序(AP,Application)向 TM 申请开始一个全局事务;

  2. 针对要操作的 RM,AP 会先向 TM 注册(TM 负责记录 AP 操作过哪些 RM,即分支事务),TM 通过 XA 接口函数通知相应 RM 开启分布式事务的子事务,接着 AP 就可以对该 RM 管理的资源进行操作;

  3. 当 AP 对所有 RM 操作完毕后,AP 根据执行情况通知 TM 提交或回滚该全局事务,TM 通过 XA 接口函数通知各 RM 完成操作。TM 会先要求各个 RM 做预提交,所有 RM 返回成功后,再要求各 RM 做正式提交,XA 协议要求,一旦 RM 预提交成功,则后续的正式提交也必须能成功;如果任意一个 RM 预提交失败,则 TM 通知各 RM 回滚;

  4. 所有 RM 提交或回滚完成后,全局事务结束。

XA模型ACID

  • 原子性
    XA 协议使用 2PC(Two Phase Commit,两阶段提交)原子提交协议来保证分布式事务原子性。

  • 隔离性
    XA 协议中没有描述如何实现分布式事务的隔离性,但是 XA 协议要求 DTP 模型中的每个 RM 都要实现本地事务,也就是说,基于 XA 协议实现的分布式事务的隔离性是由每个 RM 本地事务的隔离性来保证的,当一个分布式事务的所有子事务都是隔离的,那么这个分布式事务天然的就实现了隔离性。

  • 一致性
    一致性有两层语义,一层是确保事务执行结束后,数据库从一个一致状态转变为另一个一致状态。另一层语义是事务执行过程中的中间状态不能被观察到。

    前一层语义的实现很简单,通过原子性、隔离性以及 RM 自身一致性的实现就可以保证。至于后一层语义,我们先来看看单个 RM 上的本地事务是怎么实现的。还是以 MySQL 举例,MySQL 通过 MVCC(Multi Version Concurrency Control,多版本并发控制)机制,为每个一致性状态生成快照(Snapshot),每个事务看到的都是各 Snapshot 对应的一致性状态,从而也就保证了本地事务的中间状态不会被观察到。

    很多分布式数据库都自己实现了分布式 MVCC 机制来提供全局的一致性读。一个基本思路是用一个集中式或者逻辑上单调递增的东西来控制生成全局 Snapshot,每个事务或者每条 SQL 执行时都去获取一次,从而实现不同隔离级别下的一致性。比如 Google 的 Spanner 就是用 TrueTime 来控制访问全局 Snapshot。

小结

XA 协议通常实现在数据库资源层,直接作用于资源管理器上。因此,基于 XA 协议实现的分布式事务产品,无论是分布式数据库,还是分布式事务框架,对业务几乎都没有侵入,就像使用普通数据库一样。

XA 协议严格保障事务 ACID 特性,能够满足所有业务领域的功能需求,但是,这同样是一把双刃剑。

由于隔离性的互斥要求,在事务执行过程中,所有的资源都被锁定,只适用于执行时间确定的短事务。同时,整个事务期间都是独占数据,对于热点数据的并发性能可能会很低,实现了分布式 MVCC 或乐观锁(optimistic locking)以后,性能可能会有所提升。

同时,为了保障一致性,要求所有 RM 同等可信、可靠,要求故障恢复机制可靠、快速,在网络故障隔离的情况下,服务基本不可用。

TCC模型

TCC(Try-Confirm-Cancel)分布式事务模型相对于 XA 等传统模型,其特征在于它不依赖资源管理器(RM)对分布式事务的支持,而是通过对业务逻辑的分解来实现分布式事务

TCC 模型认为对于业务系统中一个特定的业务逻辑,其对外提供服务时,必须接受一些不确定性,即对业务逻辑初步操作的调用仅是一个临时性操作,调用它的主业务服务保留了后续的取消权。如果主业务服务认为全局事务应该回滚,它会要求取消之前的临时性操作,这就对应从业务服务的取消操作。而当主业务服务认为全局事务应该提交时,它会放弃之前临时性操作的取消权,这对应从业务服务的确认操作。每一个初步操作,最终都会被确认或取消。

三段业务逻辑

针对一个具体的业务服务,TCC 分布式事务模型需要业务系统提供三段业务逻辑:

  • (1)初步操作Try:完成所有业务检查,预留必须的业务资源;

  • (2)确认操作Confirm:真正执行的业务逻辑,不作任何业务检查,只使用 Try 阶段预留的业务资源。因此,只要 Try 操作成功,Confirm 必须能成功。另外,Confirm 操作需满足幂等性,保证一笔分布式事务有且只能成功一次;

  • (3)取消操作Cancel:释放 Try 阶段预留的业务资源。同样的,Cancel 操作也需要满足幂等性;

TCC 分布式事务模型包括三部分:

1. 主业务服务: 主业务服务为整个业务活动的发起方,服务的编排者,负责发起并完成整个业务活动;

2. 从业务服务: 从业务服务是整个业务活动的参与方,负责提供 TCC 业务操作,实现初步操作(Try)、确认操作(Confirm)、取消操作(Cancel)三个接口,供主业务服务调用;

3. 业务活动管理器: 业务活动管理器管理控制整个业务活动,包括记录维护 TCC 全局事务的事务状态和每个从业务服务的子事务状态,并在业务活动提交时调用所有从业务服务的 Confirm 操作,在业务活动取消时调用所有从业务服务的 Cancel 操作。

完整的TCC分布式事务流程

  1. 主业务服务首先开启本地事务;

  2. 主业务服务向业务活动管理器申请启动分布式事务主业务活动;

  3. 然后针对要调用的从业务服务,主业务活动先向业务活动管理器注册从业务活动,然后调用从业务服务的 Try 接口;

  4. 当所有从业务服务的 Try 接口调用成功,主业务服务提交本地事务;若调用失败,主业务服务回滚本地事务;

  5. 若主业务服务提交本地事务,则 TCC 模型分别调用所有从业务服务的 Confirm 接口;若主业务服务回滚本地事务,则分别调用 Cancel 接口;

  6. 所有从业务服务的 Confirm 或 Cancel 操作完成后,全局事务结束。

TCC模型ACID

  • 原子性
    TCC 模型也使用 2PC 原子提交协议来保证事务原子性。Try 操作对应 2PC 的一阶段准备(Prepare);Confirm 对应 2PC 的二阶段提交(Commit),Cancel 对应 2PC 的二阶段回滚(Rollback),可以说 TCC 就是应用层的 2PC。

  • 隔离性
    TCC 分布式事务模型仅提供两阶段原子提交协议,保证分布式事务原子性。事务的隔离交给业务逻辑来实现。

  • 一致性
    再来看看 TCC 分布式事务模型下的一致性实现。与 XA 协议实现一致性第一层语义类似,通过原子性保证事务的原子提交、业务隔离性控制事务的并发访问,实现分布式事务的一致性状态转变。

小结

TCC 分布式事务模型的业务实现特性决定了其可以跨 DB、跨服务实现资源管理,将对不同的 DB 访问、不同的业务操作通过 TCC 模型协调为一个原子操作,解决了分布式应用架构场景下的事务问题。

TCC 模型通过 2PC 原子提交协议保证分布式事务的的原子性,把资源层的隔离性上升到业务层,交给业务逻辑来实现。TCC 的每个操作对于资源层来说,就是单个本地事务的使用,操作结束则本地事务结束,规避了资源层在 2PC 和 2PL 下对资源占用导致的性能低下问题

同时,TCC 模型也可以根据业务需要,做一些定制化的功能,比如交易异步化实现削峰填谷等。

但是,业务接入 TCC 模型需要拆分业务逻辑成两个阶段,并实现 Try、Confirm、Cancel 三个接口,定制化程度高,开发成本高。

常用解决方案

基于MQ的分布式事务

通过消息中间件来实现。假设有A和B两个系统,分别可以处理任务A和任务B。此时系统A中存在一个业务流程,需要将任务A和任务B在同一个事务中处理。下面来介绍基于消息中间件来实现这种分布式事务。

尽量选择支持事务型消息的消息中间件来实现分布式事务,如RocketMQ

具体流程:

  • 在系统A处理任务A前,首先向消息中间件发送一条消息;

  • 消息中间件收到后将该条消息持久化,但并不投递。此时下游系统B仍然不知道该条消息的存在;

  • 消息中间件持久化成功后,便向系统A返回一个确认应答;

  • 系统A收到确认应答后,则可以开始处理任务A;

  • 任务A处理完成后,向消息中间件发送Commit请求。该请求发送完成后,对系统A而言,该事务的处理过程就结束了,此时它可以处理别的任务了;但commit消息可能会在传输途中丢失,从而消息中间件并不会向系统B投递这条消息,从而系统就会出现不一致性。这个问题由消息中间件的事务回查机制完成;

  • 消息中间件收到Commit指令后,便向系统B投递该消息,从而触发任务B的执行;

  • 当任务B执行完成后,系统B向消息中间件返回一个确认应答,告诉消息中间件该消息已经成功消费,此时,这个分布式事务完成。

在上述过程中:

  • 消息中间件扮演者分布式事务协调者的角色;
  • 系统A完成任务A后,到任务B执行完成之间,会存在一定的时间差。在这个时间差内,整个系统处于数据不一致的状态,但这短暂的不一致性是可以接受的,因为经过短暂的时间后,系统又可以保持数据一致性,满足BASE理论;

任务A处理失败-回滚

  • 若系统A在处理任务A时失败,那么就会向消息中间件发送Rollback请求。和发送Commit请求一样,系统A发完之后便可以认为回滚已经完成,它便可以去做其他的事情。

  • 消息中间件收到回滚请求后,直接将该消息丢弃,而不投递给系统B,从而不会触发系统B的任务B。

此时系统又处于一致性状态,因为任务A和任务B都没有执行。

Commit和Rollback指令丢失

在实际系统中,Commit和Rollback指令都有可能在传输途中丢失。那么当出现这种情况的时候,消息中间件是如何保证数据一致性呢?——答案就是超时询问机制

系统A除了实现正常的业务流程外,还需提供一个事务询问的接口,供消息中间件调用。

当消息中间件收到一条事务型消息后便开始计时,如果到了超时时间也没收到系统A发来的Commit或Rollback指令的话,就会主动调用系统A提供的事务询问接口询问该系统目前的状态。该接口会返回三种结果:

  • 提交 若获得的状态是“提交”,则将该消息投递给系统B;
  • 回滚 若获得的状态是“回滚”,则直接将条消息丢弃;
  • 处理中 若获得的状态是“处理中”,则继续等待。

消息中间件的超时询问机制能够防止上游系统因在传输过程中丢失Commit/Rollback指令而导致的系统不一致情况,而且能降低上游系统的阻塞时间,上游系统只要发出Commit/Rollback指令后便可以处理其他任务,无需等待确认应答。而Commit/Rollback指令丢失的情况通过超时询问机制来弥补,这样大大降低上游系统的阻塞时间,提升系统的并发度。

保证可靠消费

当上游系统执行完任务并向消息中间件提交了Commit指令后,便可以处理其他任务了,此时它可以认为事务已经完成,接下来消息中间件一定会保证消息被下游系统成功消费掉! 那么这是怎么做到的呢?这由消息中间件的投递流程来保证。

等待下游ACK
消息中间件向下游系统投递完消息后便进入阻塞等待状态,下游系统便立即进行任务的处理,任务处理完成后便向消息中间件返回应答。消息中间件收到确认应答后便认为该事务处理完毕!

如果消息在投递过程中丢失,或消息的确认应答在返回途中丢失,那么消息中间件在等待确认应答超时之后就会重新投递,直到下游消费者返回消费成功响应为止。

当然,一般消息中间件可以设置消息重试的次数和时间间隔,比如:当第一次投递失败后,每隔五分钟重试一次,一共重试3次。如果重试3次之后仍然投递失败,那么这条消息就需要人工干预。

消息投递失败后为什么不回滚消息

消息投递失败后为什么不回滚消息,而是不断尝试重新投递?

这就涉及到整套分布式事务系统的实现成本问题。
我们知道,当系统A将向消息中间件发送Commit指令后,它便去做别的事情了。如果此时消息投递失败,需要回滚的话,就需要让系统A事先提供回滚接口,这无疑增加了额外的开发成本,业务系统的复杂度也将提高。对于一个业务系统的设计目标是,在保证性能的前提下,最大限度地降低系统复杂度,从而能够降低系统的运维成本。

另外一方面,常见的购物网站,一旦付款成功,没有理由回滚到下单失败,即使出了问题,也会想办法保证订单成功。

问题思考

上游系统A向消息中间件提交Commit/Rollback消息采用的是异步方式,也就是当上游系统提交完消息后便可以去做别的事情,接下来提交、回滚就完全交给消息中间件来完成,并且完全信任消息中间件,认为它一定能正确地完成事务的提交或回滚。

然而,消息中间件向下游系统投递消息的过程是同步的。也就是消息中间件将消息投递给下游系统后,它会阻塞等待,等下游系统成功处理完任务返回确认应答后才取消阻塞等待。为什么这两者在设计上是不一致的呢?

  • 上游系统和消息中间件之间采用异步通信

    上游系统和消息中间件之间采用异步通信是为了提高系统并发度。

    业务系统直接和用户打交道,用户体验尤为重要,因此这种异步通信方式能够极大程度地降低用户等待时间。

    此外,异步通信相对于同步通信而言,没有了长时间的阻塞等待,因此系统的并发性也大大增加。

    但异步通信可能会引起Commit/Rollback指令丢失的问题,这就由消息中间件的超时询问机制来弥补。

  • 消息中间件和下游系统之间采用同步通信

    异步能提升系统性能,但随之会增加系统复杂度;

    而同步虽然降低系统并发度,但实现成本较低。

    因此,在对并发度要求不是很高的情况下,或者服务器资源较为充裕的情况下,我们可以选择同步来降低系统的复杂度。

    消息中间件是一个独立于业务系统的第三方中间件,它不和任何业务系统产生直接的耦合,它也不和用户产生直接的关联,它一般部署在独立的服务器集群上,具有良好的可扩展性,所以不必太过于担心它的性能,如果处理速度无法满足我们的要求,可以增加机器来解决。

    而且,即使消息中间件处理速度有一定的延迟那也是可以接受的,因为前面所介绍的BASE理论就告诉我们了,我们追求的是最终一致性,而非实时一致性,因此消息中间件产生的时延导致事务短暂的不一致是可以接受的。

本地消息表

本地消息表这个方案最初是ebay提出的ebay的完整方案

此方案的核心是将需要分布式处理的任务通过消息日志的方式来异步执行
消息日志可以存储到本地文本、数据库或消息队列,再通过业务规则自动或人工发起重试。人工重试更多的是应用于支付场景,通过对账系统对事后问题的处理。

对于不支持事务型消息的消息中间件,如果要实现分布式事务的话,就可以采用这种方式。它能够通过重试机制+定期校对实现分布式事务最终一致性。

Saga事务

其核心思想是将长事务拆分为多个本地短事务,由Saga事务协调器协调,如果正常结束那就正常完成,如果某个步骤失败,则根据相反顺序一次调用补偿操作。

Saga的组成:

  • 每个Saga由一系列sub-transaction Ti 组成;

  • 每个Ti 都有对应的补偿动作Ci,补偿动作用于撤销Ti造成的结果, 这里的每个T,都是一个本地事务;

可以看到,和TCC相比,Saga没有“预留 try”动作,它的Ti就是直接提交到库。

Saga的执行顺序有两种:

1
2
3
T1, T2, T3, ..., Tn

T1, T2, ..., Tj, Cj,..., C2, C1,其中0 < j < n

Saga定义了两种恢复策略:

  • 向后恢复
    即上面提到的第二种执行顺序,其中j是发生错误的sub-transaction,这种做法的效果是撤销掉之前所有成功的sub-transation,使得整个Saga的执行结果撤销;

  • 向前恢复
    适用于必须要成功的场景,执行顺序是类似于这样的:T1, T2, ..., Tj(失败), Tj(重试),..., Tn,其中j是发生错误的sub-transaction。该情况下不需要Ci。

注意
在saga模式中不能保证隔离性,因为没有锁住资源,其他事务依然可以覆盖或者影响当前事务。

参考文章

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

一篇文章带你学习分布式事务

再有人问你分布式事务,把这篇扔给他