0%

分布式事务概述

什么是事务

事务通常被看作是一系列数据交换(例如修改数据库)操作的集合,其目的在于保证数据的一致性。在mysql中,可以通过

1
2
begin
commit/rollback

来限定若干条SQL语句的执行结果:所有语句要么全部成功,要么全部失败。早起事务仅用来表达对单一数据库资源的管理,所以通常也被称为本地事务。但随着分布式系统的普及,分布式事务也频繁的出现在人们的视野里。

在介绍分布式事务前,首先需要回顾的是:如何保证本地事务的可靠性?这就不得不提到ACID规范

  • Atomicity(原子性):一个事务(transaction)中的所有操作,或者全部完成,或者全部不完成,不会结束在中间某个环节。事务在执行过程中发生错误,会被回滚(Rollback)到事务开始前的状态,就像这个事务从来没有执行过一样。即,事务不可分割、不可约简。
  • Consistency(一致性):在事务开始之前和事务结束以后,数据库的完整性没有被破坏。这表示写入的资料必须完全符合所有的预设约束、触发器、级联回滚等。
  • Isolation(隔离性):数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。事务隔离分为不同级别,包括未提交读(Read uncommitted)、提交读(read committed)、可重复读(repeatable read)和串行化(Serializable)。
  • Durability(持久性):事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。

通过严格遵守ACID规范,我们能够保证在操作单一数据库时的数据完整性。但是如果数据的流转涉及多数据节点,多节点之间通过网络请求交换数据,如果在复杂的网络条件下,保障不同数据节点之间数据的正确交换呢?分布式事务因此应运而生。

分布式事务

理论基础

CAP理论

分布式事务的作用是保证数据在分布式系统中的正确交换。而CAP理论可以说是分布式系统中的基石。

  • Consistency (一致性):所有节点在同一时间的数据完全一致。
  • Availability (可用性):服务在正常响应时间内一直可用。
  • Partition Tolerance(分区容忍性):分布式系统在遇到某节点或网络分区故障的时候,仍然能够对外提供满足一致性或可用性的服务。

CAP三种特性已经被证明不可以同时满足,所以需要更具不同的场景,选择抛弃其中一项。

  • CA:在不允许分区的情况下,自然就不存在节点之间数据不一致的情况。并且只要服务不宕机,就能正常响应。
  • CP:在抛弃可用性的情况下,各节点之间的数据保持强一致性。节点之间数据同步效率受诸多外界干扰,如丢包,阻塞等,最终会导致可用性降低。
  • AP:在不考虑一致性的情况下,可用性能够得到保证,但是用户访问不同节点返回的数据不同,这在大多数应用场景下是不能容忍的。

BASE理论

所谓均衡存于万物之间,既然可用性和一致性不能同时满足,那么能不能退而共存呢?这就不得不提到BASE理论。

  • Basically Available (基本可用性):出现故障时,不要求所有节点都必须可以,可以牺牲部分可用性,如非关键路径停止服务、延迟提高。
  • Soft State(软状态):不要求所有节点间数据时刻保持完全一致状态,允许过渡态存在。
  • Eventually Consistency(最终一致性):所有节点数据最终会收敛一直。

BASE理论通过对各节点间数据一致性的妥协,换取系统可用性的提升。

小结

CAP理论和BASE理论从不同角度定义了如何保证分布式系统的可用性,我们可以根据不同业务场景的特性,选择不同的模型。例如,在金融场景下,不同节点间的数据一致性要求是极为高的,那么可以选择CP或者CA来保证一个强一致性;而在积分兑换场景,为了保证用户的体验,可以选择BASE理论,来保证顾客的使用体验,只要所有节点数据能够最终收敛达到一致性。

强一致性

单机场景下的强一致性实现较为容易,那么如何在分布式系统下实现强一致性呢?

两阶段提交协议(2 phase commit)

2PC的目的在于:保证在分布式系统当中,多节点数据间的一致性。为了达到这个目的,需要引入一个协调者(coordinator)来统一控制所有节点(participant)的行为。

正是引入了一个协调者的角色,所有参与者才能共进退,达到一致性的目的。

第一阶段:voting phase 投票阶段

事务协调者给每个参与者发送Prepare消息,每个参与者要么直接返回失败(如权限验证失败),要么在本地执行事务,写本地的redo和undo日志,但不提交,并且锁住需要的资源,到达一种“万事俱备,只欠东风”的状态。

参与者写入本地redo日志是十分必要的操作。若协调者发出commit指令后,若参与者宕机,redo日志可以供参与者恢复后,反查协调者是commit或者abort。

第二阶段:commit phase 提交阶段

如果协调者收到了参与者的失败消息或者超时,直接给每个参与者发送回滚(Rollback)消息;否则,发送提交(Commit)消息;参与者根据协调者的指令执行提交或者回滚操作,释放所有事务处理过程中使用的锁资源。(注意:必须在最后阶段释放锁资源)

看起来2PC协议非常理想,但现实却十分骨感:

1. 单点故障问题

协调者在两段提交中具有举足轻重的作用,协调者等待参与者回复时可以有超时机制,允许参与者宕机,但参与者等待协调者指令时无法做超时处理。一旦协调者宕机,所有参与者都会受到影响。如果协调者一直没有恢复,没有正常发送 Commit 或者 Rollback 的指令,那所有参与者都必须一直等待。注意,此时参与这占有的资源是锁住的

2. 同步阻塞问题

执行过程中,所有参与节点都是事务阻塞型的。当参与者占有资源时,其他第三方节点访问公共资源不得不处于阻塞状态。也就是说从投票阶段到提交阶段完成这段时间,资源是被锁住的。这回极大的影响系统的可用性。

3. 数据一致性问题

当因为网络问题,提交指令没有到达所有的参与者,那么由于协调者在指令发出前(后)已经标记了事务已完成,但是参与者们并没有收到指令而最终提交操作,这将造成严重的数据不一致问题。
同时,假设某个协调者宕机未恢复,这也会使参与者间的数据出现不一致的情况。

三阶段提交协议(3 phase commit)

计算机科学里所有的问题,都可以通过加一层中间层解决,如果解决不了,就加两层

为了解决2PC中存在的问题,3PC协议被创造了出来,主要的改动有两点:

  • 引入超时机制:在2PC中,参与者的Commit/Abort操作仅由协调者发出的指令决定,这导致如果没有收到指令,参与者将一直持有资源,阻塞等待。超时机制的引入,意味着参与者可以在不同的阶段,自行决定是Commit/Abort,极大的提升了可用性。

  • 拆分第一阶段:将2PC的投票阶段,拆分为问询、预提交两个阶段。目的在于改善2PC持有资源时间过长的阻塞问题。3PC的问询极端仅仅是让参与者判断是否能够正常进行事务,并不涉及资源的持有。

尽管3PC协议在一定程度上,改善了2PC协议的同步阻塞和超时问题。但是,对于因Abort请求丢失而造成的数据不一致问题,同样无能为力。同时因为其实现上的复杂性,在选择分布式事务解决方案时,大家更倾向于选择2PC

XA

PC 两阶段提交协议本身只是一个通用协议,不提供具体的工程实现的规范和标准,在工程实践中为了统一标准,减少行业内不必要的对接成本,需要制定标准化的处理模型及接口标准,国际开放标准组织 Open Group 定义了分布式事务处理模型 DTP(Distributed Transaction Processing)Model,现在 XA 已经成为 2PC 分布式事务提交的事实标准,很多主流数据库如 Oracle、MySQL 等都已经实现 XA。

它定义了三大组件:

  • AP(Application Program):应用程序,一般指事务的发起者(比如数据库客户端或者访问数据库的程序),定义事务对应的操作(比如更新操作 UPDATE table SET xxx WHERE xxx)
  • RMs(Resource Managers):资源管理器,是分布式事务的参与者,管理共享资源,并提供访问接口,供外部程序来访问共享资源,比如数据库、打印服务等,另外 RM 还应该具有事务提交或回滚的能力。
  • TM(Transaction Manager):事务管理器,是分布式事务的协调者,管理全局事务,与每个RM进行通信,协调事务的提交和回滚,并协助进行故障恢复。

一般来讲,执行一次分布式事务的流程:

  1. 配置TM,将RM注册到TM
  2. AP从TM获取资源管理器的代理,获取TM所管理的RM的连接(Conn)
  3. AP向TM发起全局事务
  4. TM将XID通知到各RM
  5. AP通过Conn直接对RM进行操作
  6. AP结束全局事务
  7. TM会通知RM全局事务结束
  8. 开始二阶段提交

在mysql,开启XA事务的语法如下,需要注意的是分布式事务和本地事务是互斥的,即你无法在一个分布式事务里,继续开始本地事务:

1
2
3
4
5
6
7
8
9
10
11
12
// 开启xa事务
XA start <xid>
// DML语句,即SQL增删改查语句
select * from t_student where id = 1;
// 终止XA事务
XA end <xid>
// 预提交事务, 这一步是有返回值的
XA prepare <xid>
// 提交
XA commit <xid>
// 回滚
XA rollback <xid>

最终一致性

上面介绍了如何在分布式事务中,实现系统各节点之间数据的强一致性。但是在如今的大多数业务场景下,为了保证服务能够承担更大的TPS,我们接受各节点数据的短暂不一致,只要求数据的最终一致性

TCC

TCC(Try-Confirm-Cancel)方案的设计思路与2PC极为相似,主要区别体现在两点:

  • 锁定资源:TCC基本上不涉及资源的锁定,这极大的提升了服务的性能。TCC采取的尝试策略,每次执行前,尝试扣除资源,这既保障了不同事务间的隔离性,也提升了性能。
  • 应用层实现:TCC是业务自己通过一定的规则,实现的事务(可以类比用户态线程和内核态线程)。这在一定程度上保障的事务实现的灵活性,业务可以根据不同的场景,定制化的实现不同程度的数据一致性。

TCC最大的缺点,就是它对业务的入侵性极强,上一节提到,2PC的事实标准是XA协议,其中定义了TM,RM。TM通过Commit/Abort接口管理RM的提交和回滚。而采取TCC方案的业务,也需要提供

  • Try接口:尝试进行资源扣除
  • Commit接口:资源事实扣除
  • Cancel:清理残留事务信息

这使得很多业务,尤其是老业务,改造难度很大。

SAGA

a long story about Scandinavian history, written in the Old Norse language in the Middle Ages, mainly in Iceland
–Cambridge Dictionary

SAGA通常被用来解决一系列子事务组成的事务,基本协议如下:

  • 每个 Saga 事务由一系列幂等的有序子事务(sub-transaction) T1,T2,…,Ti,…,Tn组成。
  • 每个 Ti 都有对应的幂等补偿动作C1,C2,…,Ci,…,Cn,补偿动作用于撤销 T1,T2,…,Ti,…,Tn造成的结果。

SAGA内部又可以细分为两个实现方式

  • 向前恢复(Forward Recovery):假设Ti失败,它会重复尝试Ti直到成功,并重复执行该策略,直至Tn完成。
  • 向后恢复(Backward Recovery):假设Ti失败,他会对Ti-1 … T1调用Ci-1 … C1进行恢复。

SAGA弥补了TCC的缺点(需要提供三个协议接口),但也失去了事务间的隔离性。

本地状态事务表

本地事务状态表方案的大概处理流程是:

  1. 在调用方请求外部系统前将待执行的事务流程及其状态信息存储到数据库中,依赖数据库本地事务的原子特性保证本地事务和调用外部系统事务的一致性,这个存储事务执行状态信息的表称为本地事务状态表。
  2. 在将事务状态信息存储到DB后,调用方才会开始继续后面流程,同步调用外部系统,并且每次调用成功后会更新相应的子事务状态,某一步失败时则中止执行。
  3. 同时在后台运行一个定时任务,定期扫描事务状态表中未完成的子事务,并重新发起调用,或者执行回滚,或者在失败重试指定次数后触发告警让人工介入进行修复。

本地状态事务表最核心的理念就是将事务状态的写入操作与业务数据的修改操作合为同一个事务,要不全部成功(分布式事务开始),要不全部失败(分布式事务结束)。

由于状态表的引入,各个子事务的完成情况可以直接查表获得,但是这也要求各个子系统要保证接口的幂等性,防止重入问题。

可靠消息队列

可靠消息队列方案是指当事务发起方执行完成本地事务后并发出一条消息,事务参与方(消息消费者)一定能够接收到消息并处理事务成功,此方案强调的是只要消息发给事务参与方,则最终事务要达到一致。

由于引入的中间间,那么可靠消息队列方案必须要考虑以下问题:

  • 本地事务与消息发送的原子性问题:要求事务发起方在本地事务执行成功后消息必须发出去,否则就丢弃消息
  • 事务参与方接收消息的可靠性:要求事务参与方必须能够从消息队列接收到消息,如果接收消息失败可以重复接收消息
  • 消息重复消费的问题:要解决消息重复消费的问题就要实现事务参与方的方法幂等性。

为此消息队列组件必须提供以下能力:

  • 记录消息消费状态
  • 提供翻查消息生产方,本地事务的完成情况。
  • 两阶段提交能力
  • 保证消息投递、消费的顺序性

小结

TCC很好理解,就是业务层面实现的XA协议,灵活性高,但是对业务入侵性很大。对于老业务和第三方业务,改造难度大,可能不太适用TCC。SAGA对一些有多个子事务组成的场景更加友好,可以通过编排或OSS等方式管理子事务的提交与回滚。
个人认为,本地事务表和可靠消息队列的差别不大,本质上都需要通过一张事务表来管理所有事务的执行。唯一的差异性体现在,后者引入了消息队列,使其具备了消息队列的一些优点,例如削峰,解耦和异步。当然也不可避免的带来服务成本的提升。