3、架构-事务处理

目录

概述

场景事例

本地事务

实现原子性和持久性

实现隔离性 

全局事务

共享事务

分布式事务

CAP与ACID

可靠事件队列

TCC事务

SAGA事务

SAGA事务的核心概念

SAGA事务的执行流程

SAGA事务的优缺点

总结


概述

    事务处理几乎在每一个信息系统中都会涉及,它存在的意义是为 了保证系统中所有的数据都是符合期望的,且相互关联的数据之间不 会产生矛盾,即数据状态的一致性(Consistency)。按照数据库的经 典理论,要达成这个目标,需要三方面共同努力来保障。

  1. 原子性(Atomic):在同一项业务处理过程中,事务保证了对 多个数据的修改,要么同时成功,要么同时被撤销。
  2. 隔离性(Isolation):在不同的业务处理过程中,事务保证了各 业务正在读、写的数据相互独立,不会彼此影响。
  3. 持久性(Durability):事务应当保证所有成功被提交的数据修 改都能够正确地被持久化,不丢失数据。

以上四种属性即事务的“ACID”特性,但笔者对这种说法其实不 太认同,因为这四种特性并不正交,A、I、D是手段,C是目的,前者 是因,后者是果,弄到一块去完全是为了拼凑个单词缩写。

事务的概念虽然最初起源于数据库系统,但今天已经有所延伸, 不再局限于数据库本身了。所有需要保证数据一致性的应用场景,包 括但不限于数据库、事务内存、缓存、消息队列、分布式存储,等 等,都有可能用到事务。后文里笔者会使用“数据源”来泛指所有这 些场景中提供与存储数据的逻辑设备,但是上述场景所说的事务和一 致性含义可能并不完全一致,说明如下。

  • 当一个服务只使用一个数据源时,通过A、I、D来获得一致性 是最经典的做法,也是相对容易的。此时,多个并发事务所读写的数 据能够被数据源感知是否存在冲突,并发事务的读写在时间线上的最 终顺序是由数据源来确定的,这种事务间一致性被称为“内部一致 性”。
  • 当一个服务使用到多个不同的数据源,甚至多个不同服务同时 涉及多个不同的数据源时,问题就变得困难了许多。此时,并发执行 甚至是先后执行的多个事务,在时间线上的顺序并不由任何一个数据 源来决定,这种涉及多个数据源的事务间一致性被称为“外部一致 性”。

        外部一致性问题通常很难使用A、I、D来解决,因为这样需要付出 很大甚至不切实际的代价;但是外部一致性又是分布式系统中必然会 遇到且必须要解决的问题,为此我们要转变观念,将一致性从“是或 否”的二元属性转变为可以按不同强度分开讨论的多元属性,在确保 代价可承受的前提下获得强度尽可能高的一致性保障,也正因如此, 事务处理才从一个具体操作上的“编程问题”上升成一个需要全局权 衡的“架构问题”。 人们在探索这些解决方案的过程中,产生了许多新的思路和概 念,有一些概念看上去并不那么直观,在本章,笔者会通过同一个场 景事例讲解如何在不同的事务方案中贯穿、理顺这些概念。

场景事例

        Fenix’s Bookstore是一个在线书店。当一本书被成功售出时,需 要确保以下三件事情被正确地处理:

  1. 用户的账号扣减相应的商品款项;
  2. 商品仓库中扣减库存,将商品标识为待配送状态;
  3. 商家的账号增加相应的商品款项。

         接下来,笔者将逐一介绍在“单个服务使用单个数据源”“单个 服务使用多个数据源”“多个服务使用单个数据源”以及“多个服务 使用多个数据源”下,可以采用哪些手段来保证数据在以上场景中被 正确地读写。

本地事务

        本地事务(Local Transaction)其实应该翻译成“局部事务”才 好与稍后的“全局事务”相对应,不过现在“本地事务”的译法似乎 已经成为主流,这里也就不去纠结名称了。本地事务是指仅操作单一 事务资源的、不需要全局事务管理器进行协调的事务。在没有介绍什 么是“全局事务管理器”前,很难从概念入手去讲解“本地事务”, 所以这里先暂且将概念放下,等后面再来对比理解。

        本地事务是一种最基础的事务解决方案,只适用于单个服务使用 单个数据源的场景。从应用角度看,它是直接依赖于数据源本身提供 的事务能力来工作的,在程序代码层面,最多只能对事务接口做一层 标准化的包装(如JDBC接口),并不能深入参与到事务的运作过程 中,事务的开启、终止、提交、回滚、嵌套、设置隔离级别,乃至与 应用代码贴近的事务传播方式,全部都要依赖底层数据源的支持才能 工作,这一点与后续介绍的XA、TCC、SAGA等主要靠应用程序代码来实 现的事务有着十分明显的区别。举个例子,假设你的代码调用了JDBC 中的Transaction::rollback()方法,方法的成功执行也并不一定代表 事务就已经被成功回滚,如果数据表采用的引擎是MyISAM,那 rollback()方法便是一项没有意义的空操作。因此,我们要想深入讨 论本地事务,便不得不越过应用代码的层次,去了解一些数据库本身 的事务实现原理,弄明白传统数据库管理系统是如何通过ACID来实现 事务的。

实现原子性和持久性

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

        众所周知,数据必须要成功写入磁盘、磁带等持久化存储器后才 能拥有持久性,只存储在内存中的数据,一旦遇到应用程序忽然崩 溃,或者数据库、操作系统一侧崩溃,甚至是机器突然断电宕机等情 况就会丢失,后文我们将这些意外情况都统称为“崩溃”(Crash)。 实现原子性和持久性的最大困难是“写入磁盘”这个操作并不是原子 的,不仅有“写入”与“未写入”状态,还客观存在着“正在写”的 中间状态。由于写入中间状态与崩溃都不可能消除,所以如果不做额 外保障措施的话,将内存中的数据写入磁盘,并不能保证原子性与持 久性。下面通过具体事例来说明。

        按照前面预设的场景事例,从Fenix’s Bookstore购买一本书需 要修改三个数据:在用户账户中减去货款、在商家账户中增加货款、 在商品仓库中标记一本书为配送状态。由于写入存在中间状态,所以 可能出现以下情形。

  1. 未提交事务,写入后崩溃:程序还没修改完三个数据,但数据 库已经将其中一个或两个数据的变动写入磁盘,若此时出现崩溃,一 旦重启之后,数据库必须要有办法得知崩溃前发生过一次不完整的购 物操作,将已经修改过的数据从磁盘中恢复成没有改过的样子,以保 证原子性。
  2. 已提交事务,写入前崩溃:程序已经修改完三个数据,但数据 库还未将全部三个数据的变动都写入磁盘,若此时出现崩溃,一旦重 启之后,数据库必须要有办法得知崩溃前发生过一次完整的购物操 作,将还没来得及写入磁盘的那部分数据重新写入,以保证持久性。

        由于写入中间状态与崩溃都是无法避免的,为了保证原子性和持 久性,就只能在崩溃后采取恢复的补救措施,这种数据恢复操作被称 为“崩溃恢复”(Crash Recovery,也有资料称作Failure Recovery 或Transaction Recovery)。

        为了能够顺利地完成崩溃恢复,在磁盘中写入数据就不能像程序 修改内存中的变量值那样,直接改变某表某行某列的某个值,而是必 须将修改数据这个操作所需的全部信息,包括修改什么数据、数据物 理上位于哪个内存页和磁盘块中、从什么值改成什么值,等等,以日 志的形式——即以仅进行顺序追加的文件写入的形式(这是最高效的 写入方式)先记录到磁盘中。只有在日志记录全部安全落盘,数据库 在日志中看到代表事务成功提交的“提交记录”(Commit Record) 后,才会根据日志上的信息对真正的数据进行修改,修改完成后,再 在日志中加入一条“结束记录”(End Record)表示事务已完成持久 化,这种事务实现方法被称为“提交日志”(Commit Logging)。

        Commit Logging保障数据持久性、原子性的原理并不难理解:首 先,日志一旦成功写入Commit Record,那整个事务就是成功的,即使 真正修改数据时崩溃了,重启后根据已经写入磁盘的日志信息恢复现 场、继续修改数据即可,这保证了持久性;其次,如果日志没有成功 写入Commit Record就发生崩溃,那整个事务就是失败的,系统重启后 会看到一部分没有Commit Record的日志,将这部分日志标记为回滚状 态即可,整个事务就像完全没有发生过一样,这保证了原子性。

        Commit Logging的原理很清晰,也确实有一些数据库就是直接采 用Commit Logging机制来实现事务的,譬如较具代表性的是阿里的 OceanBase。但是,Commit Logging存在一个巨大的先天缺陷:所有对 数据的真实修改都必须发生在事务提交以后,即日志写入了Commit Record之后。在此之前,即使磁盘I/O有足够空闲,即使某个事务修改 的数据量非常庞大,占用了大量的内存缓冲区,无论何种理由,都决 不允许在事务提交之前就修改磁盘上的数据,这一点是Commit Logging成立的前提,却对提升数据库的性能十分不利。为此,ARIES 提出了“提前写入日志”(Write-Ahead Logging)的日志改进方案, 所谓“提前写入”(Write-Ahead),就是允许在事务提交之前写入变 动数据的意思。

        Write-Ahead Logging按照事务提交时点,将何时写入变动数据划 分为FORCE和STEAL两类情况。

  1. FORCE:当事务提交后,要求变动数据必须同时完成写入则称为 FORCE,如果不强制变动数据必须同时完成写入则称为NO-FORCE。 现实中绝大多数数据库采用的都是NO-FORCE策略,因为只要有了日 志,变动数据随时可以持久化,从优化磁盘I/O性能考虑,没有必要强 制数据写入时立即进行。
  2. 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在崩溃恢复时会经历以下三个阶段。

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

        重做阶段和回滚阶段的操作都应该设计为幂等的。为了追求高I/O 性能,以上三个阶段无可避免地会涉及非常烦琐的概念和细节(如 Redo Log、Undo Log的具体数据结构等)。数据库按照是否允许FORCE和STEAL可以产生四种组合,从优化磁 盘I/O的角度看,NO-FORCE加STEAL的组合的性能无疑是最高的;从算 法实现与日志的角度看,NO-FORCE加STEAL的组合的复杂度无疑也是最 高的。

实现隔离性 

        隔离性保证了每个 事务各自读、写的数据互相独立,不会彼此影响。只从定义上就能嗅 出隔离性肯定与并发密切相关,因为如果没有并发,所有事务全都是 串行的,那就不需要任何隔离,或者说这样的访问具备了天然的隔离 性。但现实情况是不可能没有并发,那么,要如何在并发下实现串行 的数据访问呢?几乎所有程序员都会回答:加锁同步呀!正确,现代 数据库均提供了以下三种锁。

  1. 写锁(Write Lock,也叫作排他锁,eXclusive Lock,简写为XLock):如果数据有加写锁,就只有持有写锁的事务才能对数据进行 写入操作,数据加持着写锁时,其他事务不能写入数据,也不能施加 读锁。
  2. 读锁(Read Lock,也叫作共享锁,Shared Lock,简写为SLock):多个事务可以对同一个数据添加多个读锁,数据被加上读锁 后就不能再被加上写锁,所以其他事务不能对该数据进行写入,但仍 然可以读取。对于持有读锁的事务,如果该数据只有它自己一个事务 加了读锁,则允许直接将其升级为写锁,然后写入数据。
  3. 范围锁(Range Lock):对于某个范围直接加排他锁,在这个 范围内的数据不能被写入。如下语句是典型的加范围锁的例子:

SELECT * FROM books WHERE price < 100 FOR UPDATE;

        请注意“范围不能被写入”与“一批数据不能被写入”的差别, 即不要把范围锁理解成一组排他锁的集合。加了范围锁后,不仅不能 修改该范围内已有的数据,也不能在该范围内新增或删除任何数据, 后者是一组排他锁的集合无法做到的。 

        串行化访问提供了最高强度的隔离性,ANSI/ISO SQL-92[1]中定 义的最高等级的隔离级别便是可串行化(Serializable)。可串行化 完全符合普通程序员对数据竞争加锁的理解,如果不考虑性能优化的 话,对事务所有读、写的数据全都加上读锁、写锁和范围锁即可做到 可串行化,“即可”是简化理解,实际还是很复杂的,要分成加锁 (Expanding)和解锁(Shrinking)两阶段去处理读锁、写锁与数据 间的关系,称为两阶段锁(Two-Phase Lock,2PL)。但数据库不考虑 性能肯定是不行的,并发控制(Concurrency Control)理论[2]决定 了隔离程度与并发能力是相互抵触的,隔离程度越高,并发访问时的 吞吐量就越低。现代数据库一定会提供除可串行化以外的其他隔离级 别供用户使用,让用户自主调节隔离级别,根本目的是让用户可以调 节数据库的加锁方式,取得隔离性与吞吐量之间的平衡。

        可串行化的下一个隔离级别是可重复读(Repeatable Read),可 重复读对事务所涉及的数据加读锁和写锁,且一直持有至事务结束, 但不再加范围锁。可重复读比可串行化弱化的地方在于幻读问题 (Phantom Read),它是指在事务执行过程中,两个完全相同的范围 查询得到了不同的结果集。

        譬如现在要准备统计一下Fenix’s Bookstore中售价小于100元的书的本数,可以执行以下第一条SQL语 句:

SELECT count(1) FROM books WHERE price < 100 /* 时间顺序:1,事务: T1 */ INSERT INTO books(name,price) VALUES ('深入理解Java虚拟机',90)/* 时间顺序: 2,事务: T2 */ SELECT count(1) FROM books WHERE price < 100 /* 时间顺序:3,事务: T1 */

        根据前面对范围锁、读锁和写锁的定义可知,假如这条SQL语句在 同一个事务中重复执行了两次,且这两次执行之间恰好有另外一个事 务在数据库插入了一本小于100元的书,这是会被允许的,那这两次相 同的SQL查询就会得到不一样的结果,原因是可重复读没有范围锁来禁 止在该范围内插入新的数据,这是一个事务受到其他事务影响,隔离 性被破坏的表现。

        可重复读的下一个隔离级别是读已提交(Read Committed),读 已提交对事务涉及的数据加的写锁会一直持续到事务结束,但加的读 锁在查询操作完成后会马上释放。读已提交比可重复读弱化的地方在 于不可重复读问题(Non-Repeatable Read),它是指在事务执行过程 中,对同一行数据的两次查询得到了不同的结果。譬如笔者要获取 Fenix’s Bookstore中《深入理解Java虚拟机》这本书的售价,同样 执行了两条SQL语句,在此两条语句执行之间,恰好有另外一个事务修 改了这本书的价格,将书的价格从90元调整到了110元,如下SQL所 示:

SELECT * FROM books WHERE id = 1; /* 时间顺序:1,事务: T1 */ UPDATE books SET price = 110 WHERE id = 1; COMMIT; /* 时间顺序:2,事务: T2 */ SELECT * FROM books WHERE id = 1; COMMIT; /* 时间顺序:3,事务: T1 */

        如果隔离级别是读已提交,这两次重复执行的查询结果就会不一 样,原因是读已提交的隔离级别缺乏贯穿整个事务周期的读锁,无法 禁止读取过的数据发生变化,此时事务T2中的更新语句可以马上提交 成功,这也是一个事务受到其他事务影响,隔离性被破坏的表现。假 如隔离级别是可重复读,由于数据已被事务T1施加了读锁且读取后不 会马上释放,所以事务T2无法获取到写锁,更新就会被阻塞,直至事 务T1被提交或回滚后才能提交。 读已提交的下一个级别是读未提交(Read Uncommitted),它只 会对事务涉及的数据加写锁,且一直持续到事务结束,但完全不加读 锁。读未提交比读已提交弱化的地方在于脏读问题(Dirty Read), 它是指在事务执行过程中,一个事务读取到了另一个事务未提交的数 据。譬如笔者觉得《深入理解Java虚拟机》从90元涨价到110元是损害 消费者利益的行为,又执行了一条更新语句把价格改回了90元,在提 交事务之前,同事说这并不是随便涨价,而是印刷成本上升导致的, 按90元卖要亏本,于是笔者随即回滚了事务,如下SQL所示:

         SELECT * FROM books WHERE id = 1; /* 时间顺序:1,事务: T1 */ /* 注意没有COMMIT */ UPDATE books SET price = 90 WHERE id = 1; /* 时间顺序:2,事务: T2 */ /* 这条SELECT模拟购书的操作的逻辑 */ SELECT * FROM books WHERE id = 1; /* 时间顺序:3,事务: T1 */ ROLLBACK;/* 时间顺序:4,事务: T2 */

         不过,在之前修改价格后,事务T1已经按90元的价格卖出了几 本。原因是读未提交在数据上完全不加读锁,这反而令它能读到其他 事务加了写锁的数据,即上述事务T1中两条查询语句得到的结果并不 相同。如果你不能理解这句话中的“反而”二字,请再读一次写锁的 定义:写锁禁止其他事务施加读锁,而不是禁止事务读取数据,如果 事务T1读取数据前并不需要加读锁的话,就会导致事务T2未提交的数 据也马上能被事务T1所读到。这同样是一个事务受到其他事务影响, 隔离性被破坏的表现。假如隔离级别是读已提交的话,由于事务T2持 有数据的写锁,所以事务T1的第二次查询就无法获得读锁,而读已提 交级别是要求先加读锁后读数据的,因此T1中的查询就会被阻塞,直 至事务T2被提交或者回滚后才能得到结果。

        理论上还存在更低的隔离级别,就是“完全不隔离”,即读、写 锁都不加。读未提交会有脏读问题,但不会有脏写问题(Dirty Write),即一个事务没提交之前的修改可以被另外一个事务的修改覆 盖掉。脏写已经不单纯是隔离性上的问题了,它将导致事务的原子性 都无法实现,所以一般谈论隔离级别时不会将完全不隔离纳入讨论范 围内,而是将读未提交视为最低级的隔离级别。

        以上四种隔离级别属于数据库理论的基础知识,多数大学的计算 机课程应该都会讲到,可惜的是不少教材、资料将它们当作数据库的 某种固有属性或设定来讲解,导致很多同学只能对这些现象死记硬 背。其实不同隔离级别以及幻读、不可重复读、脏读等问题都只是表 面现象,是各种锁在不同加锁时间上组合应用所产生的结果,以锁为 手段来实现隔离性才是数据库表现出不同隔离级别的根本原因。

        除了都以锁来实现外,以上四种隔离级别还有另外一个共同特 点,就是幻读、不可重复读、脏读等问题都是由于一个事务在读数据 的过程中,受另外一个写数据的事务影响而破坏了隔离性。针对这种 “一个事务读+另一个事务写”的隔离问题,近年来有一种名为“多版 本并发控制[3]”(Multi-Version Concurrency Control,MVCC)的 无锁优化方案被主流的商业数据库广泛采用。MVCC是一种读取优化策 略,它的“无锁”特指读取时不需要加锁。MVCC的基本思路是对数据 库的任何修改都不会直接覆盖之前的数据,而是产生一个新版本与老 版本共存,以此达到读取时可以完全不加锁的目的。在这句话中, “版本”是个关键词,你不妨将版本理解为数据库中每一行记录都存 在两个看不见的字段:CREATE_VERSION和DELETE_VERSION,这两个字 段记录的值都是事务ID,事务ID是一个全局严格递增的数值,然后根 据以下规则写入数据。

  1. 插入数据时:CREATE_VERSION记录插入数据的事务ID, DELETE_VERSION为空。
  2. 删除数据时:DELETE_VERSION记录删除数据的事务ID, CREATE_VERSION为空。
  3. 修改数据时:将修改数据视为“删除旧数据,插入新数据”的 组合,即先将原有数据复制一份,原有数据的DELETE_VERSION记 录修改数据的事务ID,CREATE_VERSION为空。复制后的新数据的 CREATE_VERSION记录修改数据的事务ID,DELETE_VERSION为 空。

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

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

        另外两个隔离级别都没有必要用到MVCC,因为读未提交直接修改 原始数据即可,其他事务查看数据的时候立刻可以看到,根本无须版 本字段。可串行化本来的语义就是要阻塞其他事务的读取操作,而 MVCC是做读取时的无锁优化的,自然不会放到一起用。 MVCC是只针对“读+写”场景的优化,如果是两个事务同时修改数 据,即“写+写”的情况,那就没有多少优化的空间了,此时加锁几乎 是唯一可行的解决方案,稍微有点讨论余地的是加锁策略是选“乐观 加锁”(Optimistic Locking)还是选“悲观加锁”(Pessimistic Locking)。前面笔者介绍的加锁都属于悲观加锁策略,即认为如果不 先加锁再访问数据,就肯定会出现问题。相对地,乐观加锁策略认为 事务之间数据存在竞争是偶然情况,没有竞争才是普遍情况,这样就 不应该在一开始就加锁,而是应当在出现竞争时再找补救措施。这种 思路也被称为“乐观并发控制”(Optimistic Concurrency Control,OCC),囿于篇幅与主题,这里就不再展开了,不过笔者提 醒一句,没有必要迷信什么乐观锁要比悲观锁更快的说法,这纯粹看 竞争的激烈程度,如果竞争激烈的话,乐观锁反而更慢。

全局事务

        与本地事务相对的是全局事务(Global Transaction),在一些 资料中也将其称为外部事务(External Transaction),在本节里, 全局事务被限定为一种适用于单个服务使用多个数据源场景的事务解 决方案。请注意,理论上真正的全局事务并没有“单个服务”的约 束,它本来就是DTP(Distributed Transaction Processing,分布式 事务处理)模型中的概念,但本节讨论的是一种在分布式环境中仍 追求强一致性的事务处理方案,对于多节点而且互相调用彼此服务的 场合(典型的就是现在的微服务系统)是极不合适的,当前它几乎只 实际应用于单服务多数据源的场合中,为了避免与后续介绍的放弃了 ACID的弱一致性事务处理方式混淆,所以这里的全局事务的范围有所 缩减,后续涉及多服务多数据源的事务,笔者将称其为“分布式事 务”。

        1991年,为了解决分布式事务的一致性问题,X/Open组织(后来 并入了The Open Group)提出了一套名为X/Open XA(XA是eXtended Architecture的缩写)的处理事务架构,其核心内容是定义了全局的 事务管理器(Transaction Manager,用于协调全局事务)和局部的资 源管理器(Resource Manager,用于驱动本地事务)之间的通信接 口。XA接口是双向的,能在一个事务管理器和多个资源管理器 (Resource Manager)之间形成通信桥梁,通过协调多个数据源的一 致动作,实现全局事务的统一提交或者统一回滚,现在我们在Java代 码中还偶尔能看见的XADataSource、XAResource都源于此。

        不过,XA并不是Java的技术规范(XA提出那时还没有Java),而 是一套语言无关的通用规范,所以Java中专门定义了JSR 907 Java Transaction API,基于XA模式在Java语言中实现了全局事务处理的标 准,这也是我们现在所熟知的JTA。JTA最主要的两个接口如下。

  1. 事务管理器的接口:javax.transaction.TransactionManager。这套接 口用于为Java EE服务器提供容器事务(由容器自动负责事务管理)。 JTA还提供了另外一套javax.transaction.UserTransaction接口,用于通过程 序代码手动开启、提交和回滚事务
  2. 满足XA规范的资源定义接口:javax.transaction.xa.XAResource。 任何资源(JDBC、JMS等)如果想要支持JTA,只要实现XAResource接 口中的方法即可。

        现在,我们对本章的场景事例做另外一种假设:如果书店的用 户、商家、仓库分别处于不同的数据库中,其他条件仍与之前相同, 那情况会发生什么变化呢?假如你平时以声明式事务来编码,那它与 本地事务看起来可能没什么区别,都是标一个@Transactional注解而 已,但如果以编程式事务来实现的话,就能在写法上看出差异,伪代 码如下所示:

  }
    public void buyBook(PaymentBill bill) {
        userTransaction.begin();
        warehouseTransaction.begin();
        businessTransaction.begin();
        try {
            userAccountService.pay(bill.getMoney());
            warehouseService.deliver(bill.getItems());
            businessAccountService.receipt(bill.getMoney());
            userTransaction.commit();
            warehouseTransaction.commit();
            businessTransaction.commit();
        } catch(Exception e) {
            userTransaction.rollback();
            warehouseTransaction.rollback();
            businessTransaction.rollback();
        }
    }

        从代码可以看出,程序的目的是做三次事务提交,但实际上代码 并不能这样写,试想一下,如果在businessTransaction.commit()中 出现错误,代码转到catch块中执行,此时userTransaction和 warehouseTransaction已经完成提交,再去调用rollback()方法已经 无济于事,这将导致一部分数据被提交,另一部分被回滚,整个事务 的一致性也就无法保证了。为了解决这个问题,XA将事务提交拆分成 两阶段。

  • 准备阶段:又叫作投票阶段,在这一阶段,协调者询问事务的 所有参与者是否准备好提交,参与者如果已经准备好提交则回复 Prepared,否则回复Non-Prepared。这里所说的准备操作跟人类语言中 通常理解的准备不同,对于数据库来说,准备操作是在重做日志中记 录全部事务提交操作所要做的内容,它与本地事务中真正提交的区别 只是暂不写入最后一条Commit Record而已,这意味着在做完数据持久 化后并不立即释放隔离性,即仍继续持有锁,维持数据对其他非事务 内观察者的隔离状态。
  • 提交阶段:又叫作执行阶段,协调者如果在上一阶段收到所有 事务参与者回复的Prepared消息,则先自己在本地持久化事务状态为 Commit,然后向所有参与者发送Commit指令,让所有参与者立即执行 提交操作;否则,任意一个参与者回复了Non-Prepared消息,或任意一 个参与者超时未回复时,协调者将在自己完成事务状态为Abort持久化 后,向所有参与者发送Abort指令,让参与者立即执行回滚操作。对于 数据库来说,这个阶段的提交操作应是很轻量的,仅仅是持久化一条 Commit Record而已,通常能够快速完成,只有收到Abort指令时,才需 要根据回滚日志清理已提交的数据,这可能是相对重负载操作。

以上这两个过程被称为“两段式提交”(2 Phase Commit,2PC) 协议,而它能够成功保证一致性还需要一些其他前提条件。

  • 必须假设网络在提交阶段的短时间内是可靠的,即提交阶段不 会丢失消息。同时也假设网络通信在全过程都不会出现误差,即可以 丢失消息,但不会传递错误的消息,XA的设计目标并不是解决诸如拜 占庭将军一类的问题。在两段式提交中,投票阶段失败了可以补救 (回滚),提交阶段失败了则无法补救(不再改变提交或回滚的结 果,只能等崩溃的节点重新恢复),因而此阶段耗时应尽可能短,这 也是为了尽量控制网络风险。
  • 必须假设因为网络分区、机器崩溃或者其他原因而导致失联的 节点最终能够恢复,不会永久性地处于失联状态。由于在准备阶段已 经写入了完整的重做日志,所以当失联机器一旦恢复,就能够从日志 中找出已准备妥当但并未提交的事务数据,进而向协调者查询该事务 的状态,确定下一步应该进行提交还是回滚操作。

请注意,上面所说的协调者、参与者通常都是由数据库自己来扮 演的,不需要应用程序介入。协调者一般是在参与者之间选举产生, 而应用程序对于数据库来说只扮演客户端的角色。两段式提交的交互 时序示意图如图3-2所示

两段式提交原理简单,并不难实现,但有几个非常显著的缺点:

  • 单点问题:协调者在两段式提交中具有举足轻重的作用,协调 者等待参与者回复时可以有超时机制,允许参与者宕机,但参与者等 待协调者指令时无法做超时处理。一旦宕机的不是其中某个参与者, 而是协调者的话,所有参与者都会受到影响。如果协调者一直没有恢 复,没有正常发送Commit或者Rollback的指令,那所有参与者都必须一 直等待。
  • 性能问题:在两段式提交过程中,所有参与者相当于被绑定为 一个统一调度的整体,期间要经过两次远程服务调用,三次数据持久 化(准备阶段写重做日志,协调者做状态持久化,提交阶段在日志写 入提交记录),整个过程将持续到参与者集群中最慢的那一个处理操 作结束为止,这决定了两段式提交的性能通常都较差。
  • 一致性风险:前面已经提到,两段式提交的成立是有前提条件 的,当网络稳定性和宕机恢复能力的假设不成立时,仍可能出现一致 性问题。宕机恢复能力这一点不必多谈,1985年Fischer、Lynch、 Paterson提出了“FLP不可能原理”,证明了如果宕机最后不能恢复, 那就不存在任何一种分布式协议可以正确地达成一致性结果。该原理 在分布式中是与CAP不可兼得原理齐名的理论。而网络稳定性带来的一 致性风险是指:尽管提交阶段时间很短,但这仍是一段明确存在的危 险期,如果协调者在发出准备指令后,根据收到各个参与者发回的信 息确定事务状态是可以提交的,协调者会先持久化事务状态,并提交 自己的事务,如果这时候网络忽然断开,无法再通过网络向所有参与 者发出Commit指令的话,就会导致部分数据(协调者的)已提交,但 部分数据(参与者的)未提交,且没有办法回滚,产生数据不一致的 问题。

        为了缓解两段式提交协议的一部分缺陷,具体地说是协调者的单 点问题和准备阶段的性能问题,后续又发展出了“三段式提交”(3 Phase Commit,3PC)协议。三段式提交把原本的两段式提交的准备阶 段再细分为两个阶段,分别称为CanCommit、PreCommit,把提交阶段 改称为DoCommit阶段。其中,新增的CanCommit是一个询问阶段,即协 调者让每个参与的数据库根据自身状态,评估该事务是否有可能顺利 完成。将准备阶段一分为二的理由是这个阶段是重负载的操作,一旦 协调者发出开始准备的消息,每个参与者都将马上开始写重做日志, 它们所涉及的数据资源即被锁住,如果此时某一个参与者宣告无法完 成提交,相当于大家都做了一轮无用功。所以,增加一轮询问阶段, 如果都得到了正面的响应,那事务能够成功提交的把握就比较大了, 这也意味着因某个参与者提交时发生崩溃而导致大家全部回滚的风险 相对变小。因此,在事务需要回滚的场景中,三段式提交的性能通常 要比两段式提交好很多,但在事务能够正常提交的场景中,两者的性 能都很差,甚至三段式因为多了一次询问,还要稍微更差一些。

        同样也是由于事务失败回滚概率变小,在三段式提交中,如果在 PreCommit阶段之后发生了协调者宕机,即参与者没有等到DoCommit的 消息的话,默认的操作策略将是提交事务而不是回滚事务或者持续等 待,这就相当于避免了协调者单点问题的风险。

        从以上过程可以看出,三段式提交对单点问题和回滚时的性能问 题有所改善,但是对一致性风险问题并未有任何改进,甚至是略有增 加的。譬如,进入PreCommit阶段之后,协调者发出的指令不是Ack而 是Abort,而此时因网络问题,有部分参与者直至超时都未能收到协调 者的Abort指令的话,这些参与者将会错误地提交事务,这就产生了不 同参与者之间数据不一致的问题

共享事务

        与全局事务里讨论的单个服务使用多个数据源正好相反,共享事 务(Share Transaction)是指多个服务共用同一个数据源。这里有必 要再强调一次“数据源”与“数据库”的区别:数据源是指提供数据 的逻辑设备,不必与物理设备一一对应。在部署应用集群时最常采用 的模式是将同一套程序部署到多个中间件服务器上,构成多个副本实 例来分担流量压力。它们虽然连接了同一个数据库,但每个节点配有 自己的专属数据源,通常是中间件以JNDI的形式开放给程序代码使 用。在这种情况下,所有副本实例的数据访问都是完全独立的,并没 有任何交集,每个节点使用的仍是最简单的本地事务。而本节讨论的 是多个服务之间会产生业务交集的场景,举个具体例子,在Fenix’s Bookstore的场景事例中,假设用户账户、商家账户和商品仓库都存储 于同一个数据库之中,但用户、商家和仓库都部署了独立的微服务, 此时一次购书的业务操作将贯穿三个微服务,且它们都要在数据库中 修改数据。如果我们直接将不同数据源视为不同数据库,那上一节所 讲的全局事务和下一节要讲的分布式事务都是可行的,不过,针对这 种每个数据源连接的都是同一个物理数据库的特例,共享事务很有可 能成为另一条提高性能、降低复杂度的途径,当然,也很有可能是一 个伪需求。 一种理论可行的方案是直接让各个服务共享数据库连接,在同一 个应用进程中的不同持久化工具(JDBC、ORM、JMS等)中共享数据库 连接并不困难,某些中间件服务器,譬如WebSphere会内置“可共享连 接”功能来专门给予这方面的支持。但这种共享的前提是数据源的使 用者都在同一个进程内,由于数据库连接的基础是网络连接,它是与 IP地址和端口号绑定的,字面意义上的“不同服务节点共享数据库连 接”很难做到,所以为了实现共享事务,就必须新增一个“交易服务 器”的中间角色,无论是用户服务、商家服务还是仓库服务,都通过 同一台交易服务器来与数据库打交道。如果按照JDBC规范来实现交易 服务器的对外接口的话,那它完全可以作为一个独立于各个服务的远 程数据库连接池,或者直接作为数据库代理来看待。此时三个服务所 发出的交易请求就有可能做到由交易服务器上的同一个数据库连接, 通过本地事务的方式完成。譬如,交易服务器根据不同服务节点传来 的同一个事务ID,使用同一个数据库连接来处理跨越多个服务的交易 事务。

        之所以强调理论可行,是因为该方案是与实际生产系统中的压力 方向相悖的,一个服务集群里数据库才是压力最大而又最不容易伸缩 拓展的“重灾区”,所以现实中只有类似ProxySQL、MaxScale这样用 于对多个数据库实例做负载均衡的数据库代理(其实用ProxySQL代理 单个数据库,再启用Connection Multiplexing,已经接近于前面所提 及的交易服务器方案了),而几乎没有反过来代理一个数据库为多个 应用提供事务协调的交易服务代理。这也是说它更有可能是个伪需求 的原因,如果你有充足理由让多个微服务去共享数据库,就必须找到 更加站得住脚的理由来向团队解释拆分微服务的目的是什么才行。 在日常开发中,上述方案还存在一类更为常见的变种形式:使用 消息队列服务器来代替交易服务器,当用户、商家、仓库的服务操作 业务时,通过消息将所有对数据库的改动传送到消息队列服务器,通 过消息的消费者来统一完成由本地事务来保障的持久化操作。这被称 作“单个数据库的消息驱动更新”(Message-Driven Update of a Single Database)。

分布式事务

        本节所说的分布式事务(Distributed Transaction)特指多个服 务同时访问多个数据源的事务处理机制,请注意它与DTP模型中“分布 式事务”的差异。DTP模型中的“分布式”是相对于数据源而言的,并 不涉及服务,这部分内容已经在3.2节里讨论过。本节所指的“分布 式”是相对于服务而言的,如果严谨地说,它更应该被称为“在分布 式服务环境下的事务处理机制”。 在2000年以前,人们曾经希望XA的事务机制在本节所说的分布式 环境中也能良好应用,但这个美好的愿望今天已经被CAP理论彻底击碎 了,接下来就先从CAP与ACID的矛盾说起。

CAP与ACID

        CAP定理(Consistency、Availability、Partition Tolerance Theorem),也称为Brewer定理,起源于2000年7月,是加州大学伯克 利分校的Eric Brewer教授于“ACM分布式计算原理研讨会(PODC)” 上提出的一个猜想:

        两年后,麻省理工学院的Seth Gilbert和Nancy Lynch以严谨的数 学推理证明了CAP猜想。自此,CAP正式从猜想变为分布式计算领域所 公认的著名定理。这个定理描述了在一个分布式系统中,涉及共享数 据问题时,以下三个特性最多只能同时满足其中两个。 

  • 一致性(Consistency):代表数据在任何时刻、任何分布式节点 中所看到的都是符合预期的。一致性在分布式研究中是有严肃定义、 有多种细分类型的概念,以后讨论分布式共识算法时,我们还会提到 一致性,但那种面向副本复制的一致性与这里面向数据库状态的一致 性从严格意义来说并不完全等同。
  • 可用性(Availability):代表系统不间断地提供服务的能力。理 解可用性要先理解与其密切相关的两个指标:可靠性(Reliability)和可 维护性(Serviceability)。可靠性使用平均无故障时间(Mean Time Between Failure,MTBF)来度量;可维护性使用平均可修复时间 (Mean Time To Repair,MTTR)来度量。可用性衡量系统可以正常使 用的时间与总时间之比,其表征为:A=MTBF/(MTBF+MTTR),即 可用性是由可靠性和可维护性计算得出的比例值,譬如99.9999%可用, 即代表平均年故障修复时间为32秒。
  • 分区容忍性(Partition Tolerance):代表分布式环境中部分节点 因网络原因而彼此失联后,即与其他节点形成“网络分区”时,系统 仍能正确地提供服务的能力。

由于CAP定理已有严格的证明,本节不去探讨为何CAP不可兼得, 而是直接分析舍弃C、A、P时所带来的不同影响。

  1. 如果放弃分区容忍性(CA without P),意味着我们将假设节点 之间的通信永远是可靠的。永远可靠的通信在分布式系统中必定是不 成立的,这不是你想不想的问题,而是只要用到网络来共享数据,分 区现象就始终存在。在现实中,最容易找到放弃分区容忍性的例子便 是传统的关系数据库集群,这样的集群虽然依然采用由网络连接的多 个节点来协同工作,但数据却不是通过网络来实现共享的。以Oracle的 RAC集群为例,它的每一个节点均有自己独立的SGA、重做日志、回滚 日志等部件,但各个节点是通过共享存储中的同一份数据文件和控制 文件来获取数据,通过共享磁盘的方式来避免出现网络分区。因而 Oracle RAC虽然也是由多个实例组成的数据库,但它并不能称作分布式 数据库。
  2. 如果放弃可用性(CP without A),意味着我们将假设一旦网络 发生分区,节点之间的信息同步时间可以无限制地延长,此时,问题 相当于退化到前面3.2节讨论的一个系统使用多个数据源的场景之中, 我们可以通过2PC/3PC等手段,同时获得分区容忍性和一致性。在现实 中,选择放弃可用性的情况一般出现在对数据质量要求很高的场合 中
  3. 如果放弃一致性(AP without C),意味着我们将假设一旦发生 分区,节点之间所提供的数据可能不一致。选择放弃一致性的AP系统 是目前设计分布式系统的主流选择,因为P是分布式网络的天然属性, 你再不想要也无法丢弃;而A通常是建设分布式的目的,如果可用性随 着节点数量增加反而降低的话,很多分布式系统可能就失去了存在的 价值,除非银行、证券这些涉及金钱交易的服务,宁可中断也不能出 错,否则多数系统是不能容忍节点越多可用性反而越低的。目前大多 数NoSQL库和支持分布式的缓存框架都是AP系统,以Redis集群为例, 如果某个Redis节点出现网络分区,那仍不妨碍各个节点以自己本地存 储的数据对外提供缓存服务,但这时有可能出现请求分配到不同节点 时返回客户端的是不一致的数据的情况。

        读到这里,不知道你是否对“选择放弃一致性的AP系统是目前设 计分布式系统的主流选择”这个结论感到一丝无奈,本章讨论的话题 “事务”原本的目的就是获得“一致性”,而在分布式环境中,“一 致性”却不得不成为通常被牺牲、被放弃的那一项属性。但无论如 何,我们建设信息系统,终究还是要确保操作结果至少在最终交付的 时候是正确的,这句话的意思是允许数据在中间过程出错(不一 致),但应该在输出时被修正过来。为此,人们又重新给一致性下了 定义,将前面我们在CAP、ACID中讨论的一致性称为“强一致性” (Strong Consistency),有时也称为“线性一致性” (Linearizability,通常是在讨论共识算法的场景中),而把牺牲了 C的AP系统又要尽可能获得正确结果的行为称为追求“弱一致性”。不 过,如果单纯只说“弱一致性”那其实就是“不保证一致性”的意 思。在弱一致性里,人们又总结出了一种稍微强一点的特例,被称为 “最终一致性”(Eventual Consistency),它是指如果数据在一段 时间之内没有被另外的操作更改,那它最终会达到与强一致性过程相 同的结果,有时候面向最终一致性的算法也被称为“乐观复制算 法”。

        在本节讨论的主题“分布式事务”中,目标同样也不得不从之前 三种事务模式追求的强一致性,降低为追求获得“最终一致性”。由 于一致性的定义变动,“事务”一词的含义其实也同样被拓展了,人 们把使用ACID的事务称为“刚性事务”,而把笔者下面将要介绍的几 种分布式事务的常见做法统称为“柔性事务”。

可靠事件队列

        最终一致性的概念是由eBay的系统架构师Dan Pritchett在2008年 在ACM发表的论文“Base:An Acid Alternative”中提出的,该论文 总结了一种独立于ACID获得的强一致性之外的、使用BASE来达成一致 性目的的途径。BASE分别是基本可用性(Basically Available)、柔 性事务(Soft State)和最终一致性(Eventually Consistent)的缩 写。BASE这种提法简直是把数据库科学家酷爱凑缩写的恶趣味发挥到 淋漓尽致,不过有ACID vs BASE(酸vs碱)这个朗朗上口的梗,该论 文的影响力的确传播得足够快。在这里笔者就不多谈BASE中的概念问 题了,虽然调侃它是恶趣味,但这篇论文本身作为最终一致性的概念 起源,并系统性地总结了一种针对分布式事务的技术手段,是非常有 价值的。

        我们继续以本章的场景事例来解释Dan Pritchett提出的“可靠事 件队列”的具体做法,目标仍然是交易过程中正确修改账号、仓库和 商家服务中的数据

  1. 最终用户向Fenix’s Bookstore发送交易请求:购买一本价值 100元的《深入理解Java虚拟机》。
  2. Fenix’s Bookstore首先应对用户账号扣款、商家账号收款、 库存商品出库这三个操作有一个出错概率的先验评估,根据出错概率 的大小来安排它们的操作顺序,这种评估一般直接体现在程序代码 中,一些大型系统也可能会实现动态排序。譬如,根据统计,最有可 能出现的交易异常是用户购买了商品,但是不同意扣款,或者账号余 额不足;其次是仓库发现商品库存不够,无法发货;风险最低的是收 款,如果到了商家收款环节,一般就不会出什么意外了。那最容易出 错的就应该最先进行,即:账号扣款→仓库出库→商家收款。
  3. 账号服务进行扣款业务,如扣款成功,则在自己的数据库建立 一张消息表,里面存入一条消息:“事务ID:某UUID,扣款:100元 (状态:已完成),仓库出库《深入理解Java虚拟机》:1本(状态: 进行中),某商家收款:100元(状态:进行中)”。注意,这个步骤 中“扣款业务”和“写入消息”是使用同一个本地事务写入账号服务 自己的数据库的。
  4. 在系统中建立一个消息服务,定时轮询消息表,将状态是“进 行中”的消息同时发送到库存和商家服务节点中去(也可以串行地 发,即一个成功后再发送另一个,但在我们讨论的场景中没必要)。 这时候可能产生以下几种情况。
  • 商家和仓库服务都成功完成了收款和出库工作,向用户账号服 务器返回执行结果,用户账号服务把消息状态从“进行中”更新为 “已完成”。整个事务顺利结束,达到最终一致性的状态。
  • 商家或仓库服务中至少有一个因网络原因,未能收到来自用户 账号服务的消息。此时,由于用户账号服务器中存储的消息状态一直 处于“进行中”,所以消息服务器将在每次轮询的时候持续地向未响 应的服务重复发送消息。这个步骤的可重复性决定了所有被消息服务 器发送的消息都必须具备幂等性,通常的设计是让消息带上一个唯一 的事务ID,以保证一个事务中的出库、收款动作会且只会被处理一 次。
  • 商家或仓库服务有某个或全部无法完成工作,譬如仓库发现 《深入理解Java虚拟机》没有库存了,此时,仍然是持续自动重发消 息,直至操作成功(譬如补充了新库存),或者被人工介入为止。由 此可见,可靠事件队列只要第一步业务完成了,后续就没有失败回滚 的概念,只许成功,不许失败。
  • 商家和仓库服务成功完成了收款和出库工作,但回复的应答消 息因网络原因丢失,此时,用户账号服务仍会重新发出下一条消息, 但因操作具备幂等性,所以不会导致重复出库和收款,只会导致商 家、仓库服务器重新发送一条应答消息,此过程持续自动重复直至双 方网络通信恢复正常。
  • 也有一些支持分布式事务的消息框架,如RocketMQ,原生就支 持分布式事务操作,这时候上述第二、四种情况也可以交由消息框架 来保障。

        以上这种依靠持续重试来保证可靠性的解决方案谈不上是Dan Pritchett的首创或者独创,它在计算机的其他领域中已被频繁使用, 也有了专门的名字——“最大努力交付”(Best-Effort Delivery),譬如TCP协议中未收到ACK应答自动重新发包的可靠性保 障就属于最大努力交付。而可靠事件队列还有一种更普通的形式,被 称为“最大努力一次提交”(Best-Effort 1PC),指的是将最有可能 出错的业务以本地事务的方式完成后,采用不断重试的方式(不限于 消息系统)来促使同一个分布式事务中的其他关联业务全部完成。 

TCC事务

        TCC是另一种常见的分布式事务机制,它是“Try-Confirm-Cancel”三个单词的缩写,是由数据库专家Pat Helland在2007年撰写 的论文“Life beyond Distributed Transactions:An Apostate’s Opinion”中提出。

        前面介绍的可靠消息队列虽然能保证最终结果的相对可靠性,过 程也足够简单(相对于TCC来说),但整个过程完全没有任何隔离性可 言,虽然在一些业务中隔离性是无关紧要的,但在有些业务中缺乏隔 离性就会带来许多麻烦。譬如在本章的场景事例中,缺乏隔离性会带 来的一个明显问题便是“超售”:如两个客户在短时间内都成功购买 了同一件商品,而且他们各自购买的数量都不超过目前的库存,但他 们购买的数量之和却超过了库存。如果这件事情属于刚性事务,且隔 离级别足够时是可以完全避免的,譬如,以上场景就需要“可重复 读”(Repeatable Read)的隔离级别,以保证后面提交的事务会因为 无法获得锁而导致失败,但用可靠消息队列就无法保证这一点,这部 分属于数据库本地事务方面的知识,可以参考前面的讲解。如果业务 需要隔离,那架构师通常就应该重点考虑TCC方案,该方案天生适用于 需要强隔离性的分布式事务中。

        TCC(Try-Confirm-Cancel)是一个分布式事务管理模式,特别适用于需要高一致性和隔离性的场景。它由三个阶段组成:尝试(Try)、确认(Confirm)和取消(Cancel)。以下是详细的解释和实现示例。

TCC的三个阶段

  1. Try阶段

    • 目的:预留必要的资源并检查所有业务操作是否能够成功。
    • 操作:执行预操作,例如检查库存、预留资金等,但不进行实际的业务提交。
    • 结果:记录预操作状态,准备进入下一阶段。
  2. Confirm阶段

    • 目的:在所有预操作都成功的情况下,正式提交业务操作。
    • 操作:执行实际的业务操作,例如扣减库存、扣款等。
    • 结果:提交所有业务操作,事务完成。
  3. Cancel阶段

    • 目的:在Try阶段的任何操作失败时,回滚所有已预留的资源。
    • 操作:撤销预操作,例如释放预留的库存、返还资金等。
    • 结果:回滚所有操作,事务取消。

        虽然TCC事务(Try-Confirm-Cancel)模式在保证分布式事务的一致性和隔离性方面有许多优点,但它也有一些缺陷和挑战。以下是TCC事务的一些主要缺陷:

1. 实现复杂度高

TCC事务需要在每个服务中实现Try、Confirm、Cancel三个阶段的操作。每个服务需要分别处理资源预留、确认和取消,这增加了开发和维护的复杂性。

示例:

  • 对于每个业务操作,需要编写额外的逻辑来处理Try、Confirm、Cancel三个步骤。
  • 错误处理和异常恢复逻辑变得更加复杂。

2. 网络开销大

TCC事务需要多个阶段的通信,每个阶段都涉及网络调用,这增加了网络开销和延迟。

示例:

  • 每个事务需要至少三次网络调用(Try、Confirm、Cancel),在高并发情况下,这会增加系统的负载和网络延迟。

3. 资源锁定时间长

在Try阶段预留资源后,直到Confirm阶段或Cancel阶段才能释放资源。如果Confirm阶段或Cancel阶段发生延迟,资源将被长时间锁定,可能导致资源浪费或系统性能下降。

示例:

  • 在电商平台上,库存预留后,如果Confirm或Cancel阶段处理延迟,库存将被长时间锁定,影响其他客户的购买。

4. 补偿操作复杂

Cancel阶段需要实现补偿操作,以回滚Try阶段的预留资源。这些补偿操作在某些场景下可能难以实现,特别是涉及外部系统或复杂业务逻辑时。

示例:

  • 在金融交易系统中,取消已预留的资金可能涉及多个外部银行系统,补偿操作复杂且难以协调。

5. 并发控制复杂

在高并发环境下,TCC事务需要处理多个并发请求,确保资源预留和确认的正确性。这需要复杂的并发控制机制,增加了系统的复杂性。

示例:

  • 需要确保多个并发事务在Try阶段正确预留资源,避免资源冲突或重复预留。

6. 恢复机制复杂

在系统崩溃或网络故障后,恢复TCC事务状态需要复杂的机制,确保系统能够正确恢复并继续处理未完成的事务。

示例:

  • 需要在崩溃后恢复未完成的事务状态,确保系统能够正确地进行Confirm或Cancel操作。

7. 数据一致性难题

在某些情况下,数据的一致性要求非常高,TCC模式可能无法完全满足。例如,某些金融交易需要原子性操作,TCC的分阶段操作可能无法确保这种原子性。

示例:

  • 高频交易系统需要确保交易的原子性和一致性,TCC模式的分阶段操作可能无法满足这些严格的要求。

8. 冲突处理

当两个事务在Try阶段预留相同的资源时,如何处理这种冲突是一个复杂的问题。需要有效的冲突检测和处理机制。

示例:

  • 两个客户几乎同时预留相同的库存,系统需要有效检测并处理这种冲突,确保只有一个客户能够成功预留。

总结

尽管TCC事务模式在分布式事务管理中具有许多优点,但其实现和维护复杂性、网络开销、资源锁定、补偿操作、并发控制和恢复机制等方面的挑战和缺陷不容忽视。在选择TCC模式时,系统架构师需要权衡这些缺陷,并根据具体业务需求和系统环境选择合适的事务管理方案。

SAGA事务

SAGA事务是一种分布式事务管理模式,用于保证在分布式系统中的多个服务之间执行长时间运行的事务。与传统的分布式事务不同,SAGA事务通过将大事务分解为一系列子事务,并为每个子事务定义补偿操作(即回滚操作)来保证最终一致性。

SAGA事务的核心概念

  1. 子事务(Sub-transaction):每个子事务是一个独立的操作,应该尽可能快地完成。每个子事务都有一个相应的补偿事务,用于在需要回滚时撤销操作。
  2. 补偿事务(Compensation Transaction):当某个子事务失败时,SAGA会执行之前所有成功子事务的补偿事务,以撤销其效果,确保数据的一致性。
  3. 最终一致性(Eventual Consistency):SAGA事务保证在所有子事务和其补偿事务执行完毕后,系统达到最终一致性状态。

SAGA事务的执行流程

  1. 顺序执行子事务:依次执行各个子事务。
  2. 失败处理:如果某个子事务失败,开始执行已成功子事务的补偿事务,按相反顺序撤销操作。
  3. 成功处理:如果所有子事务都成功,事务完成,达到一致性状态。

SAGA事务的优缺点

优点

  1. 无锁:SAGA事务不需要在整个事务期间持有锁,避免了资源长时间锁定,提高了系统的并发性能。
  2. 高可用性:由于每个子事务独立执行,SAGA事务在某些子事务失败时可以通过补偿操作恢复,增强了系统的容错能力。
  3. 最终一致性:适用于对一致性要求较低但需要高可用性的场景,保证了系统的最终一致性。

缺点

  1. 复杂性:需要为每个子事务设计补偿事务,增加了系统实现和维护的复杂性。
  2. 一致性问题:在某些极端情况下(如网络分区、系统崩溃等),补偿事务可能无法完全恢复系统状态,导致一致性问题。
  3. 延迟:补偿事务的执行会增加事务的完成时间,导致更高的延迟。

总结

SAGA事务模式通过将大事务分解为多个子事务,并为每个子事务定义补偿操作,确保在分布式系统中实现最终一致性。它适用于对一致性要求较低但需要高可用性的场景,虽然实现复杂度较高,但在无锁的情况下提高了系统的并发性能和容错能力。架构师应根据具体业务需求权衡选择TCC或SAGA事务模式。

  • 41
    点赞
  • 30
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值