一文教你搞懂分布式事务

975 阅读31分钟

前言: 从单体架构发展到微服务架构,分布式事务是不可避免的一个问题,本文主要来谈一谈分布式事务的由来,以及如今分布式事务的一些成熟的解决方案。

阅读本文之前,你先思考一下如下几个问题,看看自己是否能够回答的出来:

  1. 数据库事务如何实现原子性和持久性?
  2. 数据库事务如何实现隔离性?
  3. 分布式事务有哪些解决方案?

目的:本文将围绕上述三个问题展开,希望能给读者朋友们带来一些不一样的收获,同时也加深一下自己对事务的理解。

本地事务如何实现原子性和持久性?

在微服务出现之前,大多数应用都是单体架构,一般都是一个应用连接一个数据源,这种情况下我们可以不用考虑事务问题,因为大部分关系型数据库已经帮我们实现了强一致性的事务控制,也就是关系型数据库中事务的ACID特性,我们先来聊一聊,到底什么是ACID?

什么是ACID?

  • 原子性(Atomic):在一个业务操作下,事务保证对数据的多个修改,要么都成功,要么都失败。
  • 一致性(Consistency):简单地说,就是我给你的银行卡转账一百元,转账成功后,必须是你的账户多了一百元,我的账户少了一百元,不能出现我的账户钱少了一百元,你的账户钱还没到的情况,这个就是事务要给我们保证的,记住,在ACID中,C是目的,AID都是手段,通过AID这三个手段,实现了C这个目的。
  • 隔离性(Isolation):隔离性是指不同的事务之间对数据库的读、写操作相互隔离,互不影响。就比如说我给你转账一百元,同时有人给我转账一百万,你不能说我的账户余额被这个并发事务给操作一下,给搞错了吧,让我损失一百万,那我真要暴走了。
  • 持久性(Durability):事务应该保证所有提交了的事务数据不丢,就算系统奔溃,也不能给我丢了。

了解了ACID的概念,我们来想想,如何实现事务的原子性和一致性?

原子性和持久性在事务里是密切相关的两个属性,原子性保证了事务的多个操作要么都生效要么都不生效,不会存在中间状态;持久性保证了一旦事务生效,就不会再因为任何原因而导致其修改的内容被撤销或丢失。

举个具体的场景吧,我给你转账一百块,假设对应数据库的具体步骤如下:

  1. 从磁盘中把我的账户记录读到内存中,将余额扣减一百元。
  2. 从磁盘中把你的账户记录读到内存中,余额加上一百块。
  3. 将修改的结果一起写入到磁盘中。

显而易见,数据必须要成功写入磁盘、磁带等持久化存储器后才能拥有持久性,只存储在内存中的数据,一旦遇到程序忽然崩溃、数据库崩溃、操作系统崩溃,机器突然断电宕机(后面我们都统称为崩溃,Crash)等情况就会丢失。实现原子性和持久性所面临的困难是,“写入磁盘”这个操作不会是原子的,不仅有“写入”与“未写入”,还客观地存在着“正在写”的中间状态。

按照上面我们列出的示例场景,转账过程需要修改两个数据:在我的账户中减去金额、在你的账户中增加金额,由于写入存在中间状态,可能发生以下情形:

未提交事务:程序还没修改完两个数据,数据库已经将其中一个数据的变动写入了磁盘,此时出现崩溃,一旦重启之后,数据库必须要有办法得知崩溃前发生过一次不完整的转账操作,将已经修改过的数据从磁盘中恢复成没有改过的样子,以保证原子性。

已提交事务:程序已经修改完两个数据,数据库还未将全部两个数据的变动都写入到磁盘,此时出现崩溃,一旦重启之后,数据库必须要有办法得知崩溃前发生过一次完整的转账操作,将还没来得及写入磁盘的那部分数据重新写入,以保证持久性。

这种数据恢复操作被称为崩溃恢复(Crash Recovery,也有称作 Failure Recovery 或 Transaction Recovery)。为了能够顺利地完成崩溃恢复,在磁盘中写数据就不能像程序修改内存中变量值那样,直接改变某表某行某列的某个值,必须将修改数据这个操作所需的全部信息(比如修改什么数据、数据物理上位于哪个内存页和磁盘块中、从什么值改成什么值等等),以日志的形式(日志特指仅进行顺序追加的文件写入方式,这是最高效的写入方式)先记录到磁盘中。

只有在日志记录全部都安全落盘,见到代表事务成功提交的“Commit Record”后,数据库才会根据日志上的信息对真正的数据进行修改,修改完成后,在日志中加入一条“End Record”表示事务已完成持久化,这种事务实现方法被称为“Commit Logging”。

额外知识:Shadow Paging

虽然日志是实现事务原子性和持久性的主流实现方式,但是还是有另一种方式可以实现,那就是 Shadow Paging ,SQLite Version 3 采用的就是 Shadow Paging。

Shadow Paging 的大体思路是对数据的变动会写到硬盘的数据中,但并不是直接就地修改原先的数据,而是先将数据复制一份副本,保留原数据,修改副本数据。在事务过程中,被修改的数据会同时存在两份,一份修改前的数据,一份是修改后的数据,这也是“影子”(Shadow)这个名字的由来。

当事务成功提交,所有数据的修改都成功持久化之后,最后一步要修改数据的引用指针,将引用从原数据改为新复制出来修改后的副本,最后的“修改指针”这个操作将被认为是原子操作,所以 Shadow Paging 也可以保证原子性和持久性。 Shadow Paging 相对简单,但涉及到隔离性与锁时,Shadow Paging 实现的事务并发能力相对有限,因此在高性能的数据库中应用不多。

Commit Logging 保障数据持久性、原子性的原理并不难想明白。

首先,日志一旦成功写入 Commit Record,那整个事务就是成功的,即使修改数据时崩溃了,重启后根据已经写入磁盘的日志信息恢复现场、继续修改数据即可,这保证了持久性。

其次,如果日志没有写入成功就发生崩溃,系统重启后会看到一部分没有 Commit Record 的日志,那将这部分日志标记为回滚状态即可,整个事务就像完全没有发生过一样,这保证了原子性。

Commit Logging 实现事务简单清晰,也有一些数据库就是采用 Commit Logging 机制来实现事务的(较具代表性的是阿里的 OceanBase)。但是,Commit Logging 存在一个巨大的缺陷:所有对数据的真实修改都必须发生在事务提交、日志写入了 Commit Record 之后,即使事务提交前磁盘 I/O 有足够空闲、即使某个事务修改的数据量非常庞大,占用大量的内存缓冲,无论何种理由,都决不允许在事务提交之前就开始修改磁盘上的数据,这一点对提升数据库的性能是很不利的。

为了解决这个缺陷,“Write-Ahead Logging”的日志改进方案登场了,其名字里所谓的“提前写入”(Write-Ahead),就是允许在事务提交之前,提前写入变动数据的意思。

Write-Ahead Logging 先将何时写入变动数据,按照事务提交时点为界,分为了 FORCE 和 STEAL 两类:

FORCE:当事务提交后,要求变动数据必须同时完成写入则称为 FORCE,如果不强制变动数据必须同时完成写入则称为 NO-FORCE。现实中绝大多数数据库采用的都是 NO-FORCE 策略,只要有了日志,变动数据随时可以持久化,从优化磁盘 I/O 性能考虑,没有必要强制数据写入立即进行。

STEAL:在事务提交前,允许变动数据提前写入则称为 STEAL,不允许则称为 NO-STEAL。从优化磁盘 I/O 性能考虑,允许数据提前写入,有利于利用空闲 I/O 资源,也有利于节省数据库缓存区的内存。

Commit Logging 允许 NO-FORCE,但不允许 STEAL。因为假如事务提交前就有部分变动数据写入磁盘,那一旦事务要回滚,或者发生了崩溃,这些提前写入的变动数据就都成了错误。Write-Ahead Logging 允许 NO-FORCE,也允许 STEAL,它给出的解决办法是增加了另一种称为 Undo Log 的日志。当变动数据写入磁盘前,必须先记录 Undo Log,写明修改哪个位置的数据、从什么值改成什么值,以便在事务回滚或者崩溃恢复时,根据 Undo Log 对提前写入的数据变动进行擦除。

Undo Log 现在一般被翻译为“回滚日志”,此前记录的用于崩溃恢复时重演数据变动的日志,就相应被命名为 Redo Log,一般翻译为“重做日志”。

由于 Undo Log 的加入,Write-Ahead Logging 在崩溃恢复时,会以此经历以下三个阶段:

  • 分析阶段(Analysis):该阶段从最后一次检查点(Checkpoint,可理解为在这个点之前所有应该持久化的变动都已安全落盘)开始扫描日志,找出所有没有 End Record 的事务,组成待恢复的事务集合(一般包括 Transaction Table 和 Dirty Page Table)。
  • 重做阶段(Redo):该阶段依据分析阶段中,产生的待恢复的事务集合来重演历史(Repeat History),找出所有包含 Commit Record 的日志,将它们写入磁盘,写入完成后增加一条 End Record,然后移除出待恢复事务集合。
  • 回滚阶段(Undo):该阶段处理经过分析、重做阶段后剩余的恢复事务集合,此时剩下的都是需要回滚的事务(被称为 Loser),根据 Undo Log 中的信息回滚这些事务。

重做阶段和回滚阶段的操作都应该设计为幂等的。而为了追求高性能,以上三个阶段都无可避免地会涉及到非常繁琐的概念和细节(如 Redo Log、Undo Log 的具体数据结构等),这里我们就不展开讲了,如果想要继续学习,可以去网上搜一下 ARIES 理论

数据库按照“是否允许 FORCE 和 STEAL”可以产生四种组合,从优化磁盘 I/O 的角度看,NO-FORCE 加 STEAL 组合的性能无疑是最高的;从算法实现与日志的角度看,NO-FORCE 加 STEAL 组合的复杂度无疑是最高的。

数据库事务如何实现隔离性?

本地事务的隔离级别

RED UNCOMMITTED(读未提交)

在RED UNCOMMITTED级别,事务中的修改,即使没提交,对其他事务也是可见的。事务可以读取未提交的数据,这被称为“脏读”(Dirty Read),因为读取的很可能是中间过程的脏数据,而不是最终数据。

RED COMMITTED(读提交)

大多数数据库系统默认的隔离级别都是RED COMMITTED,但是MYSQL不是。RED COMMITTED说的是,一个事务只能读到其他事务已经提交的数据,所以叫提交读。这个事务级别也叫做不可重复读(nonrepeatableread),因为两次同样的查询,可能会得到不同的结果。

REPEATABLE READ(可重复读)

REPEATABLE READ解决了脏读的问题。该级别保证了在同一事务中多次读取同样的记录结果是一致的。但是无法解决幻读的问题,所谓幻读,指的是当某个事务再读取某个范围内的记录时,另外一个事务又在该范围内插入了新的记录,当之前的事务再次读取该范围内的记录时,发现多了一行,会产生幻行。

SERIALIZABLE(可串行化)

SERIALIZABLE是最高级别的隔离。它通过强制事务串行执行,避免了前面说的幻读的问题。简单来说,SERIALIZABLE会在读取的每一行数据上都加锁,所以可能导致大量的超时和锁争用的问题。

隔离性保证了每个事务各自读、写的数据互相独立,不会彼此影响。只从定义上,我们就能感觉到隔离性肯定与并发密切相关。如果没有并发,所有事务全都是串行的,那就不需要任何隔离,或者说这样的访问具备了天然的隔离性。

但是并发是不可避免的,一般来说,数据库提供了以下锁来处理并发问题:

  • 写锁 : 只有拿到了写锁才能写数据。
  • 读锁 : 只有拿到了读锁才能读数据。一个数据可以申请多个读锁,读锁和写锁是互斥的,数据被加上读锁后就不能再被加上写锁,所以其他事务不能对该数据进行写入。
  • 范围锁 : 对于两个排好序之间的数据范围加锁,加了范围锁后,不能插入这个范围内的数据,在这个范围内的数据不能被读取,也不能被写入。

有了这三种锁,就可以解决事务的隔离性问题。在不同隔离级别下,加的锁也不一样。

  • 可串行化 可串行化比较符合普通程序员对数据竞争加锁的理解,如果不考虑性能优化的话,对事务所有读、写的数据全都加上读锁、写锁和范围锁即可
  • 可重复度 可重复读的意思就是对事务所涉及到的数据加读锁和写锁,并且一直持续到事务结束,但不再加范围锁。
  • 读已提交 读已提交对事务涉及到的数据加的写锁,会一直持续到事务结束,但加的读锁在查询操作完成后就马上会释放。
  • 读未提交 读未提交对事务涉及到的数据只加写锁,这会一直持续到事务结束,但完全不加读锁。

除了锁之外,以上对四种隔离级别的介绍还有一个共同特点,就是一个事务在读数据过程中,受另外一个写数据的事务影响而破坏了隔离性。针对这种“一个事务读 + 另一个事务写”的隔离问题,有一种名为“多版本并发控制”(Multi-Version Concurrency Control,MVCC)的无锁优化方案被主流的商业数据库广泛采用。

MVCC 的基础原理

MVCC 是一种读取优化策略,它的“无锁”是特指读取时不需要加锁。MVCC 的基本思路是对数据库的任何修改都不会直接覆盖之前的数据,而是产生一个新版副本与老版本共存,以此达到读取时可以完全不加锁的目的。

这句话里的“版本”是个关键词,你不妨将其理解为数据库中每一行记录都存在两个看不见的字段:CREATE_VERSION 和 DELETE_VERSION,这两个字段记录的值都是事务 ID(事务 ID 是一个全局严格递增的数值),然后:

  • 数据被插入时:CREATE_VERSION 记录插入数据的事务 ID,DELETE_VERSION 为空。
  • 数据被删除时:DELETE_VERSION 记录删除数据的事务 ID,CREATE_VERSION 为空。
  • 数据被修改时:将修改视为“删除旧数据,插入新数据”,即先将原有数据复制一份,原有数据的 DELETE_VERSION 记录修改数据的事务 ID,CREATE_VERSION 为空。复制出来的新数据的 CREATE_VERSION 记录修改数据的事务 ID,DELETE_VERSION 为空。

此时,当有另外一个事务要读取这些发生了变化的数据时,会根据隔离级别来决定到底应该读取哪个版本的数据:

  • 隔离级别是可重复读:总是读取 CREATE_VERSION 小于或等于当前事务 ID 的记录,在这个前提下,如果数据仍有多个版本,则取最新(事务 ID 最大)的。
  • 隔离级别是读已提交:总是取最新的版本即可,即最近被 Commit 的那个版本的数据记录。

另外,两个隔离级别都没有必要用到 MVCC,读未提交直接修改原始数据即可,其他事务查看数据的时候立刻可以查看到,根本无需版本字段。可串行化本来的语义就是要阻塞其他事务的读取操作,而 MVCC 是做读取时无锁优化的,自然就不会放到一起用。

分布式事务有哪些解决方案

什么是分布式事务?

分布式系统会把一个应用系统拆分为可独立部署的多个服务,因此需要服务与服务之间远程协作才能完成事务操作,这种分布式系统环境下由不同的服务之间通过网络远程协作完成事务称之为分布式事务

CAP定理

CAP定理是指在分布式系统中,当涉及到节点间数据同步问题时,一下特性最多只能被满足两个。

  • C:一致性:这里的一致性是指集群副本间的一致性,区别于ACID中的一致性。ACID中的C是指数据库状态的一致性,在分布式事务中,ACID 的 C 要以满足 CAP 中的 C 为前提。
  • A: 可用性:系统正常服务的时间,一般用多少个9来衡量,例如:99-百分之九十九的时间可用
  • P:分区容错性:服务集群因为网络问题,无法正常和另一部分节点同步数据,无论哪一部分节点对外提供的服务,都可能是不正确的,我们需要考虑能否接受由于部分节点之间的连接中断,而影响整个集群的正确性的情况。此为分区容忍性问题。

BASE理论

BASE理论内容

  • 基本可用(Basically Available): 假设系统,出现了不可预知的故障,但还是能用,相比较正常的系统而言: 响应时间上的损失:正常情况下的搜索引擎0.5秒即返回给用户结果,而基本可用的搜索引擎可以在2秒作用返回结果。 功能上的损失:在一个电商网站上,正常情况下,用户可以顺利完成每一笔订单。但是到了大促期间,为了保护购物系统的稳定性,部分消费者可能会被引导到一个降级页面。
  • 软状态(Soft State) 什么是软状态呢?相对于原子性而言,要求多个节点的数据副本都是一致的,这是一种“硬状态”。 软状态指的是:允许系统中的数据存在中间状态,并认为该状态不影响系统的整体可用性,即允许系统在多个不同节点的数据副本存在数据延时。
  • 最终一致性(Eventually Consistent) 上面说软状态,不可能一直是软状态,必须有个时间期限。在期限过后,应当保证所有副本保持数据一致性,从而达到数据的最终一致性。这个时间期限取决于网络延时、系统负载、数据复制方案设计等等因素。

在CAP定理中,P是不可避免的,除非你能保住你的网络完全不会出故障,这是不可能的,那么可用性和一致性该如何取舍呢,除了金融方面会舍弃A,宁愿系统不可用也不愿意面临一致性问题外,其余大多数服务系统应该都会把可用性作为第一目标。

牺牲了 C 的 AP 系统,又要尽可能获得正确的结果的行为,称为追求“弱一致性”。那么,在“分布式事务”中,我们的设计目标同样也不得不从获得强一致性,降低为获得“最终一致性”,在这个意义上,其实“事务”一词的含义也已经被拓宽了。

除了本地事务、全局事务和分布式事务以外,还有一种对于不同事务的叫法,那就是针对追求 ACID 的事务,我们称之为“刚性事务”。而在接下来和下一讲中,我将要介绍的几种分布式事务的常见做法,会统称为“柔性事务”。这一讲我们先来讨论下,可靠消息队列这种分布式事务的实现方式。

分布式事务解决方案?

XA协议

1991 年X/Open组织提出了一套叫做X/Open XA(eXtended Architecture)的事务处理框架。这个框架的核心内容是,定义了全局的事务管理器(Transaction Manager,用于协调全局事务)和局部的资源管理器(Resource Manager,用于驱动本地事务)之间的通讯接口。

XA 并不是 Java 规范(因为当时还没有 Java),而是一套通用的技术规范。Java 后来专门定义了一套全局事务处理标准,也就是我们熟知的 JTA(JSR 907 Java Transaction API)接口。它有两个最主要的接口:

  • 事务管理器的接口:javax.transaction.TransactionManager,这套接口是给 Java EE 服务器提供容器事务(由容器自动负责事务管理)使用的。另外它还提供了另外一套 javax.transaction.UserTransaction 接口,用于给程序员通过程序代码手动开启、提交和回滚事务。

  • 满足 XA 规范的资源定义接口:javax.transaction.xa.XAResource。任何资源(JDBC、JMS 等)如果需要支持 JTA,只要实现 XAResource 接口中的方法就可以了。

两阶段提交
  • 准备阶段:又叫做投票阶段。在这一阶段,协调者询问事务的所有参与者是否准备好提交,如果已经准备好提交回复 Prepared,否则回复 Non-Prepared

这里的“准备”操作,其实和我们通常理解的“准备”不太一样:对于数据库来说,准备操作是在重做日志中记录全部事务提交操作所要做的内容,它与本地事务中真正提交的区别只是暂不写入最后一条 Commit Record。这意味着在做完数据持久化后并不会立即释放隔离性,也就是仍继续持有锁,维持数据对其他非事务内观察者的隔离状态。

  • 提交阶段:又叫做执行阶段,协调者如果在准备阶段收到所有事务参与者回复的 Prepared 消息,就会首先在本地持久化事务状态为 Commit,然后向所有参与者发送 Commit 指令,所有参与者立即执行提交操作;否则,任意一个参与者回复了 Non-Prepared 消息,或任意一个参与者超时未回复,协调者都会将自己的事务状态持久化为“Abort”之后,向所有参与者发送 Abort 指令,参与者立即执行回滚操作。

对于数据库来说,提交阶段的提交操作是相对轻量的,仅仅是持久化一条 Commit Record 而已,通常能够快速完成。回滚阶段则相对耗时,收到 Abort 指令时,需要根据回滚日志清理已提交的数据,这可能是相对重负载操作。

两阶段提交缺点:
  • 协调者单点问题:协调者一旦发生宕机,参与者会一直等待。
  • 性能问题:两次rpc操作,协调者向参与者发送准备提交和提交/回滚操作。 三次数据持久化,参与者写重做日志,协调者状态持久化,参与者写commit日志。
  • 一致性问题:协调者向参与者发送提交或者回滚指令是,部分参与者收到,部分未收到,会导致数据不一致。
三阶段提交

三阶段提交是为了解决两阶段提交协调者单点的问题,我们看看是如何解决的,三阶段提交 把准备阶段拆分为两步,canCommit和preCommit,但是拆分三个阶段之后,多了一次rpc调用,性能更差了,性能问题没有解决,提交阶段甚至增加了一致性的风险

  • canCommit:协调者问参与者,你有没有执行这次事务的资源啊,有的话给我预定好,我要开启事务了。如果参与者都说我有资源可以干,那这次事务成功的概率就高了。

  • preCommit:和两阶段提交的准备提交阶段差不多,不过加了一个超时机制,解决协调者单点问题,当协调者发出了preCommit后挂了,参与者超过一定时间未收到协调者的指令,会自动提交。

  • doCommit:和两阶段提交协议的提交阶段一致。还是存在一致性的问题,当协调者发出回滚指令,部分参与者没收到,超时后这部分参与者会自动提交事务,增加了一致性的风险。

可靠事件队列

前面提到的最终一致性的概念,是由 eBay 的系统架构师丹 · 普利切特(Dan Pritchett)在 2008 年发表于 ACM 的论文“Base: An Acid Alternative”中提出的。可靠事件队列是一种独立于 ACID 获得的强一致性之外的途径,即通过 BASE 来达成一致性目的,最终一致性就是其中的“E”。

那么,可靠事件队列到底是怎么实现的呢?我们举个典型的电商购物的例子来说明一下:

一般电商网站都包含:用户服务,商品服务,商家服务,假如你去网站购买一件衣服,向电商网站发起购物请求,购买一件衣服,网站内部的调用流程应该是:先调用户服务,扣减用户的金额,再调用商品服务,扣减相应的库存,最后调用商家服务,商家收款入账,。从请求到达用户服务开始,我们看看具体步骤:

  1. 用户服务收到请求,进行扣款业务,扣款成功,在用户服务连接的数据库里建立一张消息表,里面存一条消息记录:"事务ID,用户服务扣款业务执行状态(已成功),商品服务扣库存业务执行状态(进行中),商家服务收款业务状态(进行中)",用户账户扣款和写入消息是同一个本地事务中写入用户服务的数据库的。
  2. 用户服务整个定时任务,去扫描这个消息表,把进行中的消息发送到相应的服务中去处理。

这时候会有以下几种情况:

  • 消息服务将商品服务和商家服务完成了扣库存和收款的操作,向用户服务返回执行结果,用户服务将进行中的消息更新为已完成。
  • 商品服务或者商家服务由于网络原因没有收到用户服务的消息,则用户服务的定时任务再次轮询的时候会重复发送消息,直到商品服务和商家服务响应,将消息改为已完成。这时候商家服务和商品服务的接口就得保证幂等性了。
  • 商品服务和商家服务一直无法响应怎么办,这时候就得人工接入了,用户服务的定是任何还是会不停的发送消息。
  • 如果商品服务和商家服务都成功执行了,响应的消息丢失了,用户服务会重复发送消息,由于商品和商家服务是幂等的,所以不会有问题,等到网络恢复就行。

优点:实现比较简单,不用引入中间件。

缺点:没有隔离性,例如两个用户同时下单,两人下单时的库存都未超过总库存,但是加起来却超过了总库存,那么可靠时间队列不能回滚的特性,就会出现超卖的情况了。

TCC

如果业务需要隔离性,那么可靠事件队列就不合适了,那么就应该重点考虑TCC方案。

  • try:尝试执行阶段,完成所有业务可执行性的检查(保障一致性),并且预留好事务需要用到的所有业务资源(保障隔离性)。

  • confirm:确认执行阶段,不进行任何业务检查,直接使用 Try 阶段准备的资源来完成业务处理。注意,Confirm 阶段可能会重复执行,因此需要满足幂等性。

  • commit:取消执行阶段,释放 Try 阶段预留的业务资源。注意,Cancel 阶段也可能会重复执行,因此也需要满足幂等性。

TCC 和 2PC 的准备阶段和提交阶段差不多,但 TCC 是位于用户代码层面,而不是在基础设施层面,这就为它的实现带来了较高的灵活性,我们可以根据需要设计资源锁定的粒度。另外,TCC 在业务执行的时候,只操作预留资源,几乎不会涉及到锁和资源的争用,所以它具有很高的性能潜力。

但是,由于 TCC 的业务侵入性比较高,需要开发编码配合,在一定程度上增加了不少工作量,也就给我们带来了一些使用上的弊端,那就是我们需要投入更高的开发成本和更换事务实现方案的替换成本。

通常我们并不会完全靠裸编码来实现 TCC,而是会基于某些分布式事务中间件(如阿里开源的Seata)来完成,以尽量减轻一些编码工作量。

但是TCC还是有一个缺点,就是在预留资源这一步,如果你对接一些银行这些第三方系统,人家肯定不可能给你提供预留资源的接口啊,例如先给你冻结100块这种,一般都是先支付,如果事务失败,再由系统账户把金额退回用户呗,下面SAGA方式就是为了解决这种问题。

SAGA

SAGA是将一个大事务分为若干个小事务,每个小事务对应一个补偿动作,例如:用户充值100元欢乐豆,这个事务可以分为两个小事务:1.用户给系统转账100元;2.系统给用户充值100元欢乐豆;那么用户给系统转账100,那么对应的补偿动作就是:系统给用户补回100。

如果拆分出来的事务都执行成功,那么万事大吉,事务完成。

如果有一个事务失败了咋办呢?这时候有两种恢复措施:

  • 正向恢复: 不断重试执行这个失败的事务,直至成功。例如:你给系统付款了一百元,下一步是系统给你充值一百元的欢乐豆,那么充值过程失败了,会一直重复给你充值欢乐豆这个事务,直到成功。

  • 反向恢复: 不断重试执行这个失败事务的补偿动作,直至成功。例如:你给系统付款了一百元,下一步是系统给你充值一百元的欢乐豆,补偿动作是系统给你返还一百元,如果充值欢乐豆过程失败了,就会不断重试系统给你返还一百元的补偿动作,直至成功。

SAGA 必须保证所有子事务都能够提交或者补偿,但 SAGA 系统本身也有可能会崩溃,所以它必须设计成与数据库类似的日志机制(被称为 SAGA Log),以保证系统恢复后可以追踪到子事务的执行情况,比如执行都到哪一步或者补偿到哪一步了。

SAGA 事务通常也不会直接靠裸编码来实现,一般也是在事务中间件的基础上完成。我前面提到的 Seata 就同样支持 SAGA 事务模式。

AT事务

AT 事务是参照了 XA 两段提交协议来实现的,但针对 XA 2PC 的缺陷,即在准备阶段,必须等待所有数据源都返回成功后,协调者才能统一发出 Commit 命令而导致的木桶效应(所有涉及到的锁和资源,都需要等到最慢的事务完成后才能统一释放),AT 事务也设计了针对性的解决方案。

它大致的做法是在业务数据提交时,自动拦截所有 SQL,分别保存 SQL 对数据修改前后结果的快照,生成行锁,通过本地事务一起提交到操作的数据源中,这就相当于自动记录了重做和回滚日志。

如果分布式事务成功提交了,那么我们后续只需清理每个数据源中对应的日志数据即可;而如果分布式事务需要回滚,就要根据日志数据自动产生用于补偿的“逆向 SQL”。

所以,基于这种补偿方式,分布式事务中所涉及的每一个数据源都可以单独提交,然后立刻释放锁和资源。AT 事务这种异步提交的模式,相比 2PC 极大地提升了系统的吞吐量水平。而使用的代价就是大幅度地牺牲了隔离性,甚至直接影响到了原子性。因为在缺乏隔离性的前提下,以补偿代替回滚不一定总能成功。

比如,当在本地事务提交之后、分布式事务完成之前,该数据被补偿之前又被其他操作修改过,即出现了脏写(Dirty Wirte),而这个时候一旦出现分布式事务需要回滚,就不可能再通过自动的逆向 SQL 来实现补偿,只能由人工介入处理了。

所以,阿里开源的Seata框架增加了一个“全局锁”(Global Lock)的机制来实现写隔离,要求本地事务提交之前,一定要先拿到针对修改记录的全局锁后才允许提交,而在没有获得全局锁之前就必须一直等待。

这种设计以牺牲一定性能为代价,避免了在两个分布式事务中,数据被同一个本地事务改写的情况,从而避免了脏写。

分布式事务主流框架

Seata

Seata 是一款开源的分布式事务解决方案,致力于在微服务架构下提供高性能和简单易用的分布式事务服务,支持TCC,SAGA,AT

Hmily

Hmily是一个金融级柔性分布式事务解决方案;

本文主要是阅读周志明老师《凤凰架构》一书的学习笔记,如有侵权,联系删除。