第七章 事务

到目前为止,我们已经介绍了复制和分区技术,复制技术(包介后面绍的共识算法)提升了系统的容错性,而分区技术提升了系统的扩展性,这两项技术解决的是数据的*“物理问题”。除此以外,分布式系统中的数据访问还经常面临着“逻辑问题”,此时就需要本章将要介绍的事务*技术来解决:

  • 复制 (Replication):主要目标是高可用性和数据冗余。通过在不同的节点上存储相同的数据副本,当某个节点发生故障时,系统可以从其他副本继续提供服务。它回答的是:“我的数据会不会因为一台机器挂掉而丢失或无法访问?"。
  • 分区 (Partitioning / Sharding):主要目标是可扩展性。当单台机器的存储或计算能力无法承载全部数据和请求时,我们将数据水平切分到多个节点上。它回答的是:“我的系统如何处理不断增长的数据量和访问压力?”
  • 事务 (Transaction):主要目标是数据操作的正确性。它将一系列操作打包成一个不可分割的逻辑单元,保证这些操作要么全部成功,要么全部失败,并且在并发执行时互不干扰。它回答的是:“我如何确保一系列相关的操作在任何情况下(并发、故障)都能保持数据的正确状态?”

为什么只有复制和分区是不够的?我们来看以下几个典型场景:

场景一:转账操作(原子性缺失的灾难)

用户A要向用户B转账100元。这个操作至少包含两个步骤:

  1. 从用户A的账户中扣除100元。
  2. 给用户B的账户增加100元。

在一个缺少事务技术的分布式系统中,可能会出现以下的问题:

  • 发生故障:系统在执行完第1步后突然崩溃(比如数据库节点宕机)。此时,A的钱被扣了,但B没收到钱。钱"凭空消失"了。即使数据有多个副本(复制技术),但所有副本记录的都是这个"中间状态"的错误数据。
  • 跨分区操作:假设用户A的数据在分区1,用户B的数据在分区2。这个转账操作需要一个协调者来分别通知两个分区执行操作。如果分区1成功扣款,但分区2因为网络问题或自身故障未能成功收款,同样会导致数据不一致。

但是如果使用事务技术,事务的原子性 (Atomicity) 保证了这一系列操作是一个"全有或全无"的原子单元。系统会确保"扣款"和"收款"这两个步骤要么都完成,要么如果中间有任何差错,所有已经完成的步骤都会被回滚(Rollback),系统状态恢复到操作开始之前。这样就绝不会出现钱平白消失或多出来的情况。

场景二:并发抢购(隔离性缺失的混乱)

一个电商网站上,某件商品只剩下最后1件库存。此时,两个用户(用户C和用户D)同时点击了"购买"按钮。

在一个缺少事务技术的分布式系统中,可能会出现以下的问题:

  1. 用户C的请求到达,系统读取库存为1。
  2. 在用户C的请求完成"减库存"操作之前,用户D的请求也到达了,系统读取库存仍然为1。
  3. 用户C的请求执行"减库存”,库存变为0。
  4. 用户D的请求也执行"减库存",库存变为-1(超卖)。
  5. 最终结果是:两个用户都以为自己买到了商品,而系统库存出现了负数。这造成了严重的业务逻辑错误。

但是如果使用事务技术,事务的隔离性 (Isolation) 保证了并发执行的多个事务之间互不干扰,就像它们是串行执行的一样。当用户C的事务开始处理库存时,它会锁定该数据。用户D的事务在用户C的事务完成(提交或回滚)之前,无法修改库存数据,它要么等待,要么读取到一个旧的值然后操作失败。这样就保证了最终只有一个人能成功买到商品。

场景三:系统崩溃后的数据完整性(持久性缺失的风险)

一个订单系统刚刚完成了一笔重要订单的创建,所有数据已经写入。在数据从内存刷到磁盘的瞬间,服务器断电了。

在一个缺少事务技术的分布式系统中,如果系统依赖于操作系统的缓存写入,那么这笔订单数据可能就永久丢失了。尽管你可能收到了"操作成功"的响应,但数据并未真正持久化。

事务的持久性 (Durability) 保证了一旦事务被提交,其结果就是永久性的。即使系统崩溃,数据也能够被恢复。这通常通过预写日志(Write-Ahead Logging, WAL)等机制来实现:在修改数据本身之前,先将操作记录到持久化的日志中。

我们可以把分布式系统想象成一个大型的、跨部门的公司项目:

  • 复制技术保证了每个部门都有核心成员的备份,或者有完整的项目文档副本。如果一个核心成员请假或离职,备份人员可以顶上,保证部门工作不中断(高可用性)。
  • 分区技术把项目拆分给不同的部门(前端部、后端部、数据库部),这提高了整个公司的处理能力(可扩展性)。
  • 事务技术是项目管理中的一个"工作流"或"流程规定"。比如,“产品上线"这个工作流必须包含:1. 代码部署成功, 2. 数据库迁移成功, 3. CDN缓存刷新成功。这个流程规定了:这三件事必须全部搞定,这个"产品上线"才算真正成功。 如果任何一步失败,整个上线流程就要回滚到初始状态(比如代码回滚,数据库恢复),绝不能停在一个"部署了一半"的尴尬状态。

除此以外,事务还为应用开发者提供简化的编程模型。试想一下,如果没有事务的各种保证,应用开发者需要:

  • 手动处理各种失败场景下的回滚逻辑。
  • 手动加锁来避免并发冲突。
  • 编写复杂的补偿代码来修复部分失败导致的数据不一致。

这会使业务代码变得异常复杂、容易出错且难以维护。事务将所有这些复杂性封装起来,向开发者提供了一个简洁的抽象:“你可以把一系列操作当作一个不可分割的单元来执行,系统保证其ACID属性。” 这极大地提升了开发效率和应用可靠性。

在本章中,我们先从单机数据库事务开始展开对事务ACID特性的讨论,进而延展到涉及多个服务的分布式事务。在分布式系统中,由于可能跨多个服务(例如一次电商的购买行为中中涉及订单服务、库存服务、支付服务),面临着更大的挑战:

  • 网络不可靠,可能出现延迟、分区、丢包等情况。
  • 节点会失败,服务器可能会宕机、进程可能会崩溃。
  • 数据不一致的风险,一部分操作成功,一部分失败(例如,订单创建成功,但扣库存失败)。

7.1 深入理解ACID #

在单机数据库中,可能出现各种故障:

  • 数据库正在写入数据时系统崩溃,在重启数据库之后,如何从故障数据中恢复。
  • 多个客户端同时写入多个数据,例如本章开头的电商购买例子中,

为了保障应用开发者不被这些故障困扰,事务技术一直据库系统的首选机制。事务技术向应用开发者提供了ACID的安全保证,我们首先来了解这些特性。ACID最早在论文中被提出,做为精确描述数据库的容错机制而定义,是以下四个单词的缩写:

  • A(Atomicity):原子性,事务保证了对多个数据的修改,要么同时成功,要么同时失败。
  • C(Consistency):一致性:一个事务在执行前后,数据库都必须处于正确的状态,满足完整性约束。
  • I(Isolation):隔离性,多个事务并发执行时,一个事务的执行不应影响其他事务的执行。
  • D(Durability):持久性,事务处理完成后,对数据的修改就是永久的,即便系统故障也不会丢失。

总体而言,ACID属性提供了一种机制,使每个事务都作为一个单元,完成一组操作,产生一致结果,事务之间彼此隔离,更新永久生效”,从而来确保数据库的正确性和一致性。

以下采用银行转账的例子来说明事务的属性,事务通常以"BEGIN"和"END"来标识开始和结束。假设用户x和y的账户余额都是10,并发执行以下两个事务T1和T2:

T1:             T2:
  BEGIN           BEGIN
    add(x, 1)       x1 = get(x)
    add(y, -1)      y1 = get(y)
  END             print x1, y1
                  END	

代码7.1 银行转账例子,并发执行事务T1和T2


7.1.1 原子性和持久性 #

ACID中的原子性(Atomicity)和持久性(Durability)密切相关,它们共同构成了事务可靠性的基石,它们共同作用于事务的"失败恢复"与"成功固化"两个互补场景,从而使数据库在无论成功或失败的情况下都能维持可预期的状态。两者共同作用,确保数据库在事务处理中既不会出现不完整操作,也不会丢失已确认的操作结果。这两个特性共同构成了事务可靠性的核心:

  • 原子性关注的是事务执行过程中的逻辑正确性:要么全做,要么全不做,绝不留下中间状态。事务执行过程中若发生任何异常(语句错误、死锁、宕机等),原子性要求已做的修改必须全部撤销,数据库回到"这笔事务从未发生过"的状态。
  • 持久性关注的是事务执行结果的物理可靠性:一旦成功,结果就永不丢失,即使立即遭遇断电、崩溃,修改也必须被保留。

简言之,原子性给"失败"兜底,持久性给"成功"兜底,两者配合才实现了事务在任意故障场景下的一致性与可恢复性。例如,在银行转账事务中:

  • 原子性确保"从x账户扣款"和"向y账户存款"要么都成功,要么都失败。
  • 持久性确保如果这两个操作都成功了,即使系统立即崩溃,转账结果也能被恢复

没有原子性,就无法确定哪些操作已经成功执行,也就无法正确应用持久性;没有持久性,即使事务满足原子性,其结果也可能在系统故障后丢失。接下来详细解析它们的原理。

原子性

原子性规定,一个事务内包含的所有操作,必须被视为一个不可分割的整体。这个整体中的所有操作最终只有两种状态:全部成功执行全部不执行,系统绝不允许停留在"做了一半"的中间状态,因此也被称为"All or Nothing"。

  • 全部成功执行,我们称之为提交 (Commit)
  • 全部不执行(即撤销所有已做的操作,恢复到事务开始前的状态),我们称之为回滚 (Rollback)

以前面银行转账的例子为例,如果原子性被破坏(例如,在执行完第1步后系统崩溃),A的账户少了钱,而B的账户却没增加。这不仅是数据不一致,更是严重的业务错误,导致银行资产负债表不平。

原子性确保了,无论发生任何故障(应用崩溃、数据库宕机、网络中断),这个转账事务的结果只可能是:

  • 成功提交(Commit): x的余额减少1,y的余额增加1。
  • 事务中断(Rollback): x和y的余额都维持不变,就像转账从未发生过。

原子性的实现主要依赖于数据库的日志机制,特别是回滚日志(Undo Log)。其工作原理如下:

  1. 事务开始: 系统开启一个新事务。
  2. 数据修改前记录"逆操作":这就是所谓的回滚日志,用于在事务撤销时回滚对应的操作。在事务中,当数据库要修改某条数据(比如x的余额从10变为9)时,它不会直接在原始数据上修改。而是先在回滚日志中记录一条"逆向操作"的日志,比如:“将A的余额从9改回10”。
  3. 执行数据修改: 记录完回滚日志后,再在内存中的数据页上执行修改,将x的余额更新为9。
  4. 如果一切正常,事务将提交 (Commit): 如果事务中的所有操作都成功完成,事务管理器会提交事务。此时,回滚日志可能会被标记为可丢弃。
  5. 如果事务执行的过程中有任何的错误,事务都将回滚(Rollback),回滚时会查找那些尚未提交的事务,并利用对应的回滚日志来执行逆向操作,从而将所有被修改过的数据恢复到事务开始前的状态。例如,它会读取到"将x的余额从9改回10"这条日志,并执行它恢复账户A的数据。

在分布式系统中,实现原子性更复杂,通常需要两阶段提交 (Two-Phase Commit, 2PC) 或其变种(如3PC、Paxos、Raft)来协调所有参与节点,确保它们要么一起提交,要么一起回滚。

持久性

持久性保证,只要事务成功提交,那么它对数据库所做的更改就是永久性的。即使在此之后系统发生任何故障(如数据库服务器断电、操作系统崩溃),这些已提交的数据也绝不会丢失。持久性是系统对数据可靠性的终极承诺。想象一下:

  • 你刚在电商网站下了一个订单,系统提示"下单成功"。如果这个订单数据没有被持久化,服务器一重启,你的订单就消失了。
  • 你刚把重要的文件存入云盘,系统显示"上传完成"。如果缺乏持久性,机房断电可能导致你的文件永久丢失。

持久性确保了,系统给出的"成功"回执是真实可信的。

持久性的实现同样依赖于日志,但和原子性不同的是,持久性依赖的是重做日志(Redo Log),而实现这一机制的核心思想就是预写日志(Write-Ahead Logging,简称WAL)。预写日志的核心原理是:修改数据时,先写日志,再进行数据的修改。工作原理如下:

  1. 数据修改生成预写日志:当事务要修改数据时,它会首先在内存中生成一条预写日志,记录了"要做什么修改",例如:“在数据页X的偏移量Y处,将值从A改为B”。
  2. 日志先行写入磁盘: 在修改内存中的数据页之前,这条预写必须被写入到磁盘上的日志文件中,并确保刷盘成功(fsync)。这是一个顺序写(Append)操作,速度会非常快。
  3. 修改内存数据页: 日志落盘后,系统才会在内存中的数据页上进行实际的修改。
  4. 返回成功: 一旦预写日志被成功写入磁盘,系统就可以向客户端返回"事务提交成功"的响应了。

有了预写日志的支持之后,内存中被修改过的数据页(称为*“脏页”*)不必立即写入磁盘。数据库可以根据自己的策略(比如批量、在系统空闲时)将这些脏页异步地刷入磁盘。这是一个随机写操作,比较慢。

有预写日志后,如何应对崩溃进行数据恢复呢?

假设在第4步之后、第5步之前,数据库服务器突然断电。此时,内存中的修改全部丢失,但记录在磁盘上的 Redo Log 是完好无损的。当服务器重启时,数据库会执行恢复流程:

  1. 读取预写日志。
  2. 它发现某个事务已经提交(因为日志里有 commit 记录),但对应的数据页可能还是旧的(因为没来得及刷盘)。数据库会根据预写日志中的内容,重新执行一遍修改操作(这就是"Redo"的含义),将数据恢复到崩溃前的正确状态。

因为日志是预先写入的,所以任何已提交的事务修改都能通过重放日志来恢复,从而保证了持久性。

表7.1中总结了银行转账例子中两个操作对应的undo和redo日志内容。使用这两类日志,如图7.1所示,给出了分别用来解决事务中断和崩溃恢复的例子,图中绿色的日志表示重做时需要执行的日志操作:

  • 事务中断:图中的情况1,事务执行add(x,1)成功,但是在执行add(y,-1)时失败,做为事务原子性的保证,事务的所有操作必须全部成功或者失败,因此需要回滚前面已经执行成功的操作。此时两个变量的值分别为x=11和y=10,由于add(y,-1)并未成功执行,此时中断事务操作需要使用add(x,1)的undo操作(即x=10)即可恢复到事务执行前的状态x=10和y=10。
  • 崩溃恢复:图中的情况2,事务的两个操作都执行成功,事务也成功提交了,但是此时系统崩溃。做为持久化的保证,一旦事务成功提交,即使系统崩溃也必须保存成功执行的事务状态。当系统恢复后,需要根据事务的redo日志重做一遍事务的所有操作,最终得到事务成功执行后的状态x=11和y=9。

表7.1 银行转账例子里事务中两个修改操作所对应的undo和redo日志内容

修改操作 undo日志 redo日志
add(x,1) x=10 x=11
add(y,-1) y=10 y=9

图7.1 运用undo和redo日志来解决事务中断和崩溃恢复的例子

注意

以上是对原子性和持久性实现原理的简单介绍,在附录中,详细解释了用于数据库持久化和崩溃恢复的ARIES算法原理。


7.1.2 隔离性 #

事务的隔离性是指,并发执行的各个事务之间不能互相干扰,即一个事务内部的操作及使用的数据,对并发的其他事务是隔离的。有不同的事务隔离性级别,其中最经典的隔离性定义为可串行化(Serializable),它保证并发执行的多个事务,其最终结果与它们以某种顺序一个接一个地串行执行的结果完全相同。它为每个事务提供了一种"幻觉":仿佛在它执行的整个过程中,系统中只有它自己这一个事务在运行,看不到其他事务的中间状态。

下面以例子来说明可串行化的概念。假设用户x和y的账户余额都是10,并发执行以下两个事务T1和T2:

如图7.2所示,可能的串行化执行顺序为:

  • T1 T2:T2输出"11,9",最终结果为x=11,y=9;
  • T2 T1:T2输出"10,10",最终结果为x=11,y=9。

图7.2 满足可串行化隔离性的两种可能的执行顺序

如果两个事务中的操作交叉执行,就不满足可串行化隔离性,例如T1 T2事务中的操作以下面的顺序来交叉执行,如图7.3所示:

add(x, 1)
x1 = get(x)
y1 = get(y)
add(y, -1)
print x1, y1

代码7.2 事务T1和T2的操作交叉执行

图7.3 并发执行的两个事务交叉执行其中的事务,不满足可串行化隔离性要求

在这种情况下,输出的结果为"11,10",尽管所有操作执行完毕后最终结果也是x=11,y=9,这是不符合可串行化规则的。

从上面也可以看到,可串行性要求同时并发的事务一个接一个地执行,影响了系统的性能。幸运的是,数据库事务有不同的隔离级别1,上面介绍的串行化方式是最强的隔离级别,除此以外,数据库还提供了其它不同强度的隔离级别供应用开发者根据不同的场景来选择最适合的级别。

为了更好地使用数据库,开发者有必要了解并发事务执行过程中可能遇到的各种异常现象,以及为了避免发生这些异常而使用的隔离级别。我们先从各种异常现象谈起。

脏读

*脏读(Dirty read)*指的是在多个事务同时操作相同的数据时,一个事务A读取到了另一个事务B还未提交的数据。如果A最终因为某种原因回滚了,那么B读取到的就是一份从未真实存在过的"脏"数据,并可能基于这个脏数据做出错误的后续操作,例如:

  • 当事务有多个更新操作时,其它事务读到了部分更新的数据;
  • 当事务出现中断回滚时,其它事务读到了被回滚的值,并以此值做为后续操作的基础。

如图7.4所示,事务A修改x的值为10,该事务还未提交时事务B读到了这个值,并且以该值为基础来执行更新操作,而事务A最终并未成功提交,导致事务B的这个修改操作是非法的。

图7.4 脏读的例子,并发执行的事务读到了另一个事务中未提交的数据

不可重复读

不可重复读,指的是同一个事务执行过程中,对同一行数据前后两次读取的结果不一致。这是因为在事务的前后两次读取之间,另一个事务提交了对该数据的修改 (UPDATE) 或 删除 (DELETE)操作,这破坏了事务内部逻辑的一致性。

如图7.5所示,x的初始值为1,事务A修改x的值为10,在该事务提交前后,事务B两次读到的值不相同。这种同一个事务中前后多次读取同一个数据读到了不同的结果的现象被称为不可重复读(non-repeatable read),也被称为模糊读(fuzzy read)

图7.5 不可重复读

不可重复读导致的问题,是在同一个事务里读到了前后不一致的数据。在某些场景里,这不是一个严重的问题,例如:

  • 读取某些非关键的数据:例如某些社交类应用在同一次事务中,多次读取同一个用户的个人偏好、浏览历史等非关键信息,这类场景一般能够容忍同一个事务中前后的数据不一致;
  • 缓存更新:例如电商平台会将商品描述信息放入缓存中,这类缓存更新有时间窗口,商品的描述信息在前后发生变更,是可以接受的。

但是也有很多场景,必须避免出现不可重复读问题,这类问题的特点是:要求在同一个事务里保证前后数据的完整性,例如:

  • 金融类场景:例如两个用户之间的转账,在这个场景里,对数据完整性的要求是转账前后两个账户的账户余额总和要保持一致,如图7.6所示,账户A和B的初始余额分别是10和20,但是用户B在前后两次分别查看A和B的账户余额得到的值是10和25,和初始的余额之和30不一致;
  • 库存管理:电商平台必须保证库存数据的前后一致性,在这个场景里,对数据完整性的要求是在售和售出的物品总和前后保持一致;

图7.6 不可重复读导致银行账户余额总和出现前后不一致

幻读

幻读(Phantom read)指的是在同一个事务执行过程中,按照同样的查询条件前后两次执行查询,返回的记录行数不一样。

幻读和不可重复读看起来很像,都是同一个事务前后读到了不同的数据。两者的区别在于:

  • 不可重复读:单行数据的值被修改。
  • 幻读:一批数据的行数(或集合本身)发生了增减。

可见,不可重复读关注的同一数据被修改的情况,而幻读关注的是一个数据集发生变化的情况。

以图7.7为例。事务A首先查询id=5的数据,返回了空数据集合,事务A认为id=5没有被占用,但是在它写入id=5的数据之前,事务B写入了id=5的数据,导致事务A写入失败。

图7.7 同一个事务前后两次查询数据量不一致

以上了解了并发事务时常见的问题类型,下面了解一下数据库中设计的不同隔离级别,按照由弱到强的顺序排列如下:

读未提交

*读未提交(read uncommitted)*是最弱的隔离级别,不提供任何保证,脏读、不可重复读、幻读等异常都有可能出现在这个隔离级别中,因此极少被使用,通常只用于对数据准确性要求极低的场景。

读已提交

*读已提交(read committed)*是大部分数据库默认的隔离性级别,该级别规定一个事务只能读取到其他事务已经提交的数据,因此在这个级别下杜绝了出现脏读的情况,但是仍有可能出现不可重复读和幻读。

可重复读

*可重复读(Repeatable Read)*是比读已提交更强的隔离性级别,该级别保证在同一个事务中,多次读取同一行数据,结果是一致的。在这个级别下杜绝了出现脏读和不可重复读情况的情况,但是仍有可能出现幻读。

所有隔离级别中,最强的就是可串行化,该级别杜绝了脏读、不可重复读和幻读,但相对应的,可串行化的并行度也是最低的。

表7.2 隔离级别和可能出现的异常现象

隔离级别 脏读 不可重复读 幻读
读未提交 ❌ 可能 ❌ 可能 ❌ 可能
读已提交 ✅ 避免 ❌ 可能 ❌ 可能
可重复读 ✅ 避免 ✅ 避免 ❌ 可能
可串行化 ✅ 避免 ✅ 避免 ✅ 避免

7.1.3 一致性 #

以上我们已经解读了ACID中的三个特性,现在来解析ACID中最特殊、也最容易被误解的成员:C - 一致性 (Consistency)。在ACID四个特性中,A (原子性)、I (隔离性)、D (持久性) 主要是数据库系统提供的技术保障。而C (一致性) 则更像是一个业务目标,是由A、I、D共同保障,并结合应用层逻辑共同实现的最终效果。

ACID中一致性的核心思想是:一个事务的执行,不能破坏数据库数据的完整性和业务规则。换句话说,无论事务是否能成功提交,在事务执行前后,数据库必须处于一个合法的状态。

  • “一致的状态"是什么意思? 这意味着数据必须满足所有预设的规则和约束。这些规则可以是数据库层面的,也可以是应用层面的。这些约束包括:数据库层面的主键约束、外键约束、唯一性约束、检查约束,以及业务层面业务规则的约束等。
  • 事务中间状态: 在事务执行的过程中,数据可能会暂时处于一个不一致的(无效的)中间状态。但由于事务的原子性和隔离性,这个中间状态对其他事务是不可见的。当事务最终提交时,必须保证数据已经回到了一个全新的、一致的状态。

在前面的章节里,我们已经在很多地方看到"一致性"这个词:

  • 在一致性模型章节中,详细讨论了各种一致性模型。
  • 在CAP定理介绍时,其中的"C"也是一致性,指的是线性一致性。

但是ACID中的一致性与前面谈到一致性模型中的一致性却有很大的不同:

  • ACID中的一致性指的是单个数据库节点内部的数据,必须符合预定义的业务规则和约束(如银行账户总额不能为负)。它关注的是数据的逻辑正确性。
  • CAP理论中的一致性指的是分布式系统中多个节点之间的数据副本,在任何时刻读取到的值都必须是相同的。它关注的是数据在不同副本间的同步性和可见性。

简单来说,ACID中的一致性关注的是"业务逻辑对不对”,CAP的一致性关注的是"各个副本数据一不一样"。

ACID中对一致性的保障是一个分层的、共同协作的过程:

  • 数据库层面:数据库通过内置的功能来强制执行一部分数据规则,这是最底层、最强硬的保障。包括:
    • 数据库内置的约束,例如如果声明了主键,数据库将保证注解唯一且非空;如果声明了外键,数据库将保证外键必须指向一个真实存在的主键,确保两者的关联关系是有效的。
    • 除此以外,数据库提供的A、I、D特性是实现一致性的基石。
  • 应用层面:很多复杂的业务规则是数据库无法理解的,必须由应用程序来保障。
    • 业务逻辑:比如在一个银行系统中,“所有活期账户的总存款,必须等于银行总账上记录的活期存款总额”。这个规则数据库本身无法校验,必须由应用程序在每次存取款操作中通过计算来保证。
    • 事务编排: 应用程序负责开启 (BEGIN TRANSACTION)、提交 (COMMIT) 或回滚 (ROLLBACK) 事务,将一系列操作打包,确保它们共同满足业务上的一致性要求。

仍然以前面的银行转账为例来说明系统如何保证这个业务的一致性。银行转账业务的一致性规则是:任何时候,所有账户的总资产不变。使用事务进行转账的流程如下:

  1. 初始状态:用户x和y的账户余额都是10,总资产加起来是20,无论最终是否能够转账成功,都要满足总资产是20的业务一致性规则。
  2. 执行事务
    1. 扣除用户x账户的1元钱,此时数据库的内部状态为:x账户为9,y账户为10。可以看到在这个时候,两者账户综合为19,这是一个短暂的、不一致的中间状态,并不满足前面的业务一致性要求。但是由于ACID中的隔离性,保证了其它并发事务看不到这个状态,他们看到的仍然还是事务开始前的状态:用户x和y的账户余额都是10。
    2. 给用户y的账号增加1元钱。执行这个操作之后,满足了账户总资产是20的一致性要求,数据来到一个新的、满足业务规则的状态。
    3. 事务提交。
  3. 最终一致状态:最终的状态为x账户为9,y账户为11,满足总资产是20的一致性规则。

如果中间发生故障(比如操作1后崩溃),原子性会介入,回滚所有操作,数据库恢复到初始的x=10, y=10的一致状态。持久性则确保一旦提交成功,x=9, y=11这个新的状态就永久保存下来了。

总结下来,ACID中的一致性是系统的最终目标,它要求数据在事务前后都必须满足所有预设的业务规则和完整性约束,同时,它是一种业务层面的正确性,需要由数据库和应用程序共同保障。


7.2 并发控制 #

并发访问机制大体可以分为两类:悲观(Pessimistic)乐观(Optimistic)。悲观方式认为,事务之间的冲突经常发生,要在访问资源之前加锁以避免冲突;而乐观方式则认为,事务之间的冲突并不经常发生,事务可以不加锁的访问资源,最后再判断是否有冲突需要解决。

按照这两种划分方式,本节介绍几种常见的并发控制机制,它们是:

  • 悲观方式:两阶段锁;
  • 乐观方式:乐观并发控制、多版本控制。

注意

虽然乐观方式可以不加锁访问资源,但不能就此认为乐观方式一定比悲观方式性能更佳,我们将看到,每种乐观并发控制都要对应搭配相应的冲突解决方案,当资源竞争激烈的时候,采用乐观方式反而更慢,大量的时间都会消耗在冲突解决上。


7.2.1 两阶段锁 #

两阶段锁(Two-Phase Locking, 简称2PL)2将事务的加锁操作分为两个阶段,来管理事务对共享资源的访问。具体而言,锁分为两个类型,这两类锁的兼容性如表7.3所示。

  • 共享锁(Shared Lock):允许多个事务同时数据;
  • 排它锁(Exclusive Lock):只允许一个事务修改数据。

表7.3 锁的兼容性矩阵

共享锁 排它锁
共享锁
排它锁

两阶段锁协议,将事务对共享资源的加锁和释放操作,分为两个明确的阶段:

  • 扩展阶段(Growing Phase):事务可以获取锁,但不能释放任何锁;
  • 收缩阶段(Shrinking Phase):事务可以释放锁,但不能获取任何新的锁。

一旦事务进入收缩阶段,就不能再申请新的锁,直到事务结束。

根据这个规则,事务在进入收缩阶段之后,锁的数量只能逐渐减少,如图7.8所示。

图7.8 正确的两阶段锁,事务在进入收缩阶段之后,锁的数量只能逐渐减少

如图7.9,当事务的锁开始减少进入收缩阶段以后,还增加了锁数量,就违反了两阶段锁的规则了。

图7.9 事务在进入收缩阶段之后,锁的数量还出现增加的情况,违反两阶段锁规则

当一个事务需要对共享资源进行加锁操作时,如果该资源被另外一个事务加锁,且这两个锁不能兼容时,就必须等待另一个事务将锁释放才能加锁成功。如图7.10所示,事务A对变量x加了写锁,此时事务B必须等待这个锁释放,才能同样对变量x加锁。

图7.10 2阶段锁例子

两阶段锁在大部分情况下都能工作地很好,但是在一些情况下会将未提交的数据外泄出去,导致"脏读"问题。如图7.11所示,事务B在事务A释放变量x的锁之后,读到了事务A修改的值,但是事务A在这之后中断回滚了过程中的变更,这样事务B读到了脏数据。

图7.11 两阶段锁导致脏读问题

为了解决两阶段锁导致的脏读问题,引入了2PL的变体严格两阶段锁(Strong strict two-phase locking,简称SS2PL)。区别于2PL的是,SS2PL仅在事务结束(中断、提交)之后才开始释放锁。如图7.12所示,直到事务结束才开始释放锁,读者可以和图7.8进行对比。如图7.13是严格两阶段锁的例子,读者可以和图7.10进行对比。

图7.12 严格两阶段锁

图7.13 严格两阶段锁例子,事务A直到事务提交时才释放锁,避免出现脏读问题

两阶段锁可能导致并发事务之间出现*死锁(Deadlock)*现象。如图7.14所示,事务T1要对对象B加共享锁,但是依赖于事务T2释放对象B的排它锁;而事务T2对对象C加上的排它锁,依赖于事务T3释放对象C的共享锁;最后,事务T3对对象A加的排它锁,依赖于事务T1释放对象A的共享锁。这个场景里,三个事务之间的依赖关系,形成了一个有向环,于是导致了死锁问题。

图7.14 事务之间出现死锁


7.2.2 乐观并发控制 #

两阶段锁对于事务并发中的可能出现冲突的处理是悲观的,它假设冲突经常会发生,因此避免冲突的方式是提前给需要被访问的资源加锁;而乐观的做法则是,假设事务之间的冲突并不常发生,在事务执行期间不会给资源加锁,等到事务提交的时候再进行冲突检测,解决出现的冲突。

这里介绍的乐观并发控制(Optimistic Concurrency Control,简称OCC)3就是这种思想的体现,1981年在论文《On Optimistic Methods for Concurrency Control》中被提出。

如图7.15所示,乐观并发控制的执行流程分为三个阶段:

读阶段

在这个阶段,事务可以读取数据并进行计算,但所有的修改都只发生在事务的本地私有工作区(例如,内存中的副本),而不会直接修改数据库。

  • 读取数据:当事务需要读取一个数据项时,它会将数据的最新已提交版本读入自己的私有工作区。
  • 进行修改:事务对私有工作区中的数据进行修改。这些修改对其他事务是完全不可见的。

这个阶段没有锁,因此不会有任何等待,读写效率非常高。

验证阶段

当事务准备提交时,进入验证阶段,这是乐观并发控制的核心和精髓。系统会检查在该事务的执行过程中,是否有其他事务的修改与它发生了冲突。

验证的本质是检查事务的可串行化顺序。一个常见的规则是:事务T的所有读操作所涉及的数据,在T开始读取之后、直到T完成验证之前,没有被其他任何已提交的事务修改过。换句话说,事务T的"读集"必须是最新的。如果它的读集被污染了,那么它之前基于这个读集所做的计算就失去了正确的前提。

具体的实现上,每个事务开始时,系统通常为每个事务分配一个唯一的单调递增时间戳。在进行验证时,会将被验证事务T与所有在其之前开始、但在其之后完成验证的其他事务T’进行冲突检查,如果发现事务T所涉及的数据,在数据库已经有更新的数据,则意味着产生了冲突。

写阶段

如果验证阶段成功通过,事务进入写阶段。系统将事务在私有工作区中的所有修改原子地写回到全局数据库中,包括采用事务的时间戳来更新数据最新的时间戳,这将为其它事务进行冲突验证提供依据。在写入完成后,事务的修改才对其他事务可见。

如果验证阶段失败,则意味着发生了冲突。事务会被完全回滚(直接丢弃其私有工作区)。系统通常可以选择中止并报错,或者自动重启这个事务。重启后,它会基于新的数据状态重新执行。

图7.15 乐观并发控制的三阶段流程,事务从数据库读取数据后,将修改的数据写入事务的私有工作空间,直到验证无冲突后才写入数据库

乐观并发控制的关键点在于如何验证两个事务读写的数据不发生冲突,关键在于判断在事务开始读取之后、直到完成验证之前,事务所读到的数据是否被修改。以下是两种出现冲突的场景:

写后写冲突

*写后写(Write After Write)*冲突是指,如果两个事务都对变量x有修改操作,一个事务的修改覆盖另一个事务修改的情况。

如图7.16所示,事务T1和T2修改的是同一个数据x,在事务T2开始和验证的过程中,事务T1完成了提交,因此验证失败,需要重新执行事务。

图7.16 写后写冲突,事务T1的修改覆盖了T2的修改

读后写冲突

如图7.17所示,*读后写(Write After Read)*冲突是指,一个事务读出的数据,在事务过程中发生了修改,此时也会验证失败。

图7.17 读后写冲突,事务T1读出变量x的值,被事务T2的修改

乐观并发控制在执行时不会加锁,在冲突率低的场景下,性能远高于基于锁的协议。由于不申请锁,从根本上避免了死锁问题。但是,如果事务间频繁冲突,大量的回滚和重试会带来巨大开销,性能甚至不如简单的加锁。一个长事务可能被短事务不断抢先提交而导致多次验证失败和回滚。

乐观并发控制采用时间戳的方式验证数据版本的思想,也可以用于应用层面。例如可以在数据表中增加一个字段"version"表示数据的版本,在提交修改时,仅当数据版本是当初读出来的版本时才进行更新,否则再次读取当前的最新数据进行重试:

UPDATE table SET data = new_value, version = 2 WHERE id = 123 AND version = 1;

如果这条UPDATE语句返回的"受影响行数"为0,就意味着在它读取之后、更新之前,已经有其他事务修改了数据(version 不再是 1),即验证失败。应用层可以捕获此信号并进行回滚或重试。这本质上是乐观并发控制思想在SQL层面的实现。


7.2.3 多版本并发控制 #

多版本并发控制(Multi Version Concurrency Control,简称MVCC)4通过在数据库中创建多个版本的数据副本来实现并发控制,它最早在1978年的博士论文《Naming and Synchronization in a Decentralized Computer System》中被提出,在1981年的论文《Concurrency Control in Distributed Database Systems》中详细描述并且引入了数据库并发控制领域。

MVCC的基本原理是,数据库管理系统在其中维护单个逻辑对象的多个物理副本:

  • 当一个事务向某个对象写入数据时,数据库管理系统会创建该对象的新版本。
  • 当一个事务读取一个对象时,它读取的是该事务开始时存在的最新版本。

并发执行的事务有彼此隔离的物理数据,不再相互干扰。用一句话来概括就是:写操作不阻塞读操作,读操作不阻塞写操作(Writers don’t block readers, Readers don’t block writers)。

不同的数据库系统,对于MVCC的实现不尽相同,下面简单阐述基本的原理,不局限于某个特定的数据库系统实现中。

对于每条记录,除了记录的值以外,数据库还维护两个与事务相关的数据:Begin和End,表示该版本数据生效的事务周期范围,用于控制这个版本的数据对哪些事务可见。当End为无穷大时,表示这个版本的数据是目前最新的;当End为空值时,表示该版本的数据还未被事务提交。

记录加上了版本之后,事务的执行逻辑修改为:

  • 读操作:事务要读取一条记录时,只能读取到满足以下条件的最新数据:

    1. Begin小于等于当前事务ID;
    2. End大于当前事务ID(如果End为无穷大时,也满足这一条件)。

    这两个条件,保证了事务读取到在事务开始之前的最新数据。

  • 写操作

    1. 当事务修改记录时,不会直接覆盖原数据,而是首先复制一份最新的记录;
    2. 在复制的记录上进行数据修改,同时这份新记录的Begin为当前事务ID;
  • 提交事务

    1. 如果当前存在事务ID小于本事务的其它事务在执行,则需要等待这些事务先完成提交;
    2. 对照修改的数据事务ID,如果和最开始读取的事务ID不一致,选择中断事务或者重启事务;
    3. 将本事务修改的记录End改成无穷大,表示是目前最新的版本,同时把前面最后一个版本的End修改成本事务的ID。

以图7.18和图7.19为例子说明MVCC的工作原理,两个事务T1和T2并发执行,修改同一条记录:

  • (a):事务T1开始执行,修改记录A,会新增一条新的版本A1,该记录的Begin时间为事务T1的时间戳1,同时由于事务T1开始执行,在事务状态表中也会新增一条事务T1的状态;
  • (b):事务T2开始执行,修改记录A,会新增一条新的版本A2,该记录的Begin时间为事务T2的时间戳2,同时由于事务T2开始执行,在事务状态表中也会新增一条事务T2的状态;
  • (c):事务T1提交,将当前最新的记录A0的End时间戳修改为事务T1的时间戳1,同时将记录A1的End时间修改为$\infty$,表示这条记录是当前的最新记录,最后将事务状态表中事务T1的状态修改为"已提交";
  • (d):值得注意的是,事务T2如果在提交时,在事务状态表中发现当前有时间早于自己的事务T1还未提交,需要一直等到事务T1提交之后才能提交。提交时发现数据A的最新版本是1,而不是事务T2开始时的版本0,事务T2选择重启事务,读到最新的数据A1,再进行修改。重启事务之后,再没有发现冲突,此时可以完成提交。和事务T1一样,需要将上一个最新记录的End时间修改为本事务的时间2,将A2记录的End时间修改为$\infty$,表示是目前最新的数据,这些都完成之后,修改事务的状态为"已提交"。

注意

以上的流程中,步骤(d)假设两个事务修改的数据完全没有重合,这样事务T2只需要等待事务T1提交后就能提交。如果两个事务修改的数据有重合部分,那么事务T2需要重新执行。

图7.18 两个事务并发执行,修改同一条记录

图7.19 (续)两个事务并发执行,修改同一条记录

从以上流程可以看到,多版本并发控制中,同一份数据的不同版本之间,按照时间序构成了一个链表关系,时间最早的数据位于链表头部,这也给实现*时间旅行(time travel)*类的查询提供了便利。如图7.20中,数据A的三个版本按照时间序构成链表,每个链表有一个指针指向前一个版本的数据,每个数据有自己的[开始时间戳,结束时间戳]。假如需要查询在某一时间的数据,流程如下:

  • 首先拿到当前最新的数据,如果查询的时间大于最新数据的开始时间,则返回最新数据。
  • 否则,往前得到上一份数据,如果所需查询的时间落在该数据的时间范围内,则返回数据,否则就继续往前查找。
  • 如果一直到链表头部都没有找到符合要求的数据,即待查找的时间小于数据的最早版本开始时间戳,就返回链表头的数据。

图7.20 同一数据的多个版本,按照时间序构成链表关系

但是,也正因为同一份数据有多个版本,造成了大量的数据冗余。除了事务成功时有多个数据版本以外,在事务中断时(例如在提交时发现与另一个事务有数据冲突)也可能产生冗余数据,这些没有成功提交的数据被称为孤儿数据(Orphan Data)。以上两类数据都需要定期清理(这个流程在数据库中通常被称为vacuum5),查找待清理数据的逻辑和时间旅行查询的逻辑类似:根据一个时间遍历数据链表,找到在此时间之前的数据版本进行清理。

MVCC已经成为现代很多主流数据库(如Postgresql6、MySQL Innodb7)的并发控制方案选择8

写倾斜

采用多版本并发控制技术,能够防止出现前面提到的大部分异常,但是却无法解决*写倾斜(Write Skew)*问题。写偏斜是一种并发异常,发生在多个事务并发地读取同一数据集的不同部分,基于各自读到的数据更新该数据集的不同部分,最终导致数据库状态违反某种一致性约束。它的核心特征是:

  • 读集合不重叠:每个事务读取的数据项(行)是不同的。
  • 写集合不重叠:每个事务更新的数据项(行)也是不同的。
  • 存在全局约束:虽然读和写的行都不同,但这些行之间存在一个全局的、跨行的业务规则约束。单独执行任何一个事务都能保持约束,但并发执行后,约束被破坏。

简单来说,写偏斜是 “瞎子摸象” 的数据库版本:每个事务只看到了数据的一部分(并且这部分本身是有效的),并根据这部分信息做出了局部正确的决策,但它们的决策合在一起却破坏了全局的整体规则。

以医院值班系统为例来说明写倾斜问题。医院值班系统的规则是"任何时刻必须至少有一名医生在值班",表7.4是医生的值班表,表中用字段"oncall"来表示该医生是否值班,可以看到当前有两位医生值班。如图7.21所示,两名医生同时分别以"oncall=true"的条件来查询当前值班医生的人数,得到的结果都是2,满足超过1名医生值班的条件,于是设置自己的状态为下班(oncall=false)。由于两位医生分别修改的是自己的数据,即使在多版本并发控制中也检测不出来数据冲突,最终导致了违反值班规则的情况发生。

表7.4 医院的值班表

字段 name id oncall
A 1 true
B 2 true

图7.21 写倾斜问题示例,两名医生分别更新自己的值班数据,最终违反了必须有至少一名医生值班的规定

与"丢失更新"不同,丢失更新是两个事务修改同一个数据项,而在写倾斜中,事务通常修改的是不同的数据项。这种异常通常发生在快照隔离或可重复读等中等隔离级别下,因为这些级别无法检测到这种逻辑冲突。

值得注意的是,写倾斜并非 MVCC 机制下的偶然异常,而是快照隔离(Snapshot Isolation, SI)9模型中特有的典型问题。大多数基于 MVCC 实现的数据库,其默认隔离级别本质上就是快照隔离。在此级别下,每个事务都仿佛拥有数据库在特定时刻的"冻结"快照,事务基于该快照中的状态(例如:当前只有 1 名医生值班)做出业务决策。写倾斜的根本原因在于:事务做决策所依赖的前提(Premise),在事务提交时已经因为其他并发事务的修改而不再成立,但传统的快照隔离机制无法感知这种跨记录的逻辑依赖。

在较低的隔离级别(如读已提交或可重复读)下,可以通过手动加锁来防止写倾斜。最常用的方法是SELECT ... FOR UPDATE,这个命令会在事务读取数据时,对所读取的行加上排他锁(或升级锁)。

-- 事务 T1 和 T2 都会执行类似下面的代码
BEGIN;
-- 读取并锁定所有在班的医生记录
SELECT * FROM on_call_doctors WHERE oncall = true FOR UPDATE;
-- 假设查询结果返回了 A 和 B 两行,且 count > 1
-- 应用程序代码检查 count
-- 如果可以下班,则更新
UPDATE on_call_doctors SET oncall = false WHERE name = 'A'; -- 或 'B'
COMMIT;

代码7.3 改进后的医生值班事务

它的执行原理是:当事务T1执行SELECT … FOR UPDATE时,它锁定了A和B的记录。此时事务T2再尝试执行同样的查询时,它必须等待,直到T1提交或回滚释放了锁。这样一来,并发执行变成了串行执行,写倾斜就不会发生。

除此以外,现代数据库理论还提出了更为高效的*可串行化快照隔离(Serializable Snapshot Isolation, SSI)*算法。SSI 是一种乐观并发控制机制,它保留了 MVCC “读不阻塞写"的高性能优势,通过在后台追踪事务之间的读写依赖关系,自动检测并中止那些可能导致写倾斜的事务。目前,PostgreSQL 等主流数据库的 Serializable 级别正是采用了这种技术,在保证严格一致性的同时,最大程度地减少了锁竞争带来的性能损耗。


7.3 分布式事务 #

到目前为止,我们深入讨论了单机数据库事务的相关技术,但是从单机数据库演进到分布式事务时,系统的复杂度是指数级增长的。下面以电商网站用户下单为例,说明两种架构的区别。

一个典型的用户下单流程中,包含三个核心步骤:创建订单、扣减库存,以及最后的支付。

如果采用的是单机数据库架构,可以将这几个步骤要更新的数据表操作,放在同一个事务中,在这种情况下,单机数据库事务的ACID特性就能很好满足这个业务的需求,所有复杂的并发控制、故障恢复等技术难点都由数据库这个"黑盒"完美处理了。这是最简单、最可靠的模式。

现在,业务发展了,需要进行微服务拆分。上述三个操作被拆分到三个不同的服务:订单服务、库存服务,以及支付服务,每个服务都有自己独立的数据库。此时,单机数据库事务已经不能满足这个架构了。以下简单列举分布式架构下引入的复杂度和难点。

丧失原子性

由于涉及到多个服务,可能出现*部分失败(partial failure)*问题,这将破坏系统的原子性。例如,订单创建成功了,库存也扣减了,但在调用支付服务时,网络超时了或者支付服务不可用。

丧失隔离性

除此以外,系统还会暴露调用过程中的中间状态。例如,在调用订单服务和库存服务成功后,但支付服务还没完成的"中间状态"时,其他业务或用户可能会看到这个不一致的状态。假设用户A的下单流程正在进行中,库存已经扣减。此时用户B来查询该商品库存,看到了一个减少后的(但尚未最终确定的)库存量。如果用户A的订单最终失败并回滚了库存,那么用户 B 看到的就是"脏读"数据。

从单机到分布式,我们实际上是用巨大的系统复杂性(代码、架构、运维)去交换系统的可扩展性、可用性和团队的独立性。这就是为什么架构设计中常说:“如果单体能解决问题,就不要轻易上微服务。” 因为一旦跨入分布式领域,原本由数据库帮你透明解决的问题,现在都需要你自己来处理了。

我们刚才通过一个"用户下单"的例子,清晰地看到了从单机数据库迁移到分布式微服务架构时,所面临的一系列棘手问题。这个痛苦的现实迫使我们思考一个根本问题:在分布式世界里,我们是否还能像过去一样追求那种"完美"的、教科书式的 ACID事务?

答案是:通常不能,也不应该。为了解决这些难题,业界发展出了一整套全新的理论和实践,这就是分布式事务解决方案,而其背后的核心指导思想,正是 BASE理论。

分布式事务的目标是在由多个独立服务、多个独立数据库组成的系统中,保证一个完整业务流程的数据一致性,主要分为两大流派:

  • 刚性事务:追求数据的强一致性,试图在分布式环境下,最大程度地模拟 ACID 的特性,尤其是在隔离性和原子性上,代表有两阶段提交 (2PC)和三阶段提交 (3PC),这些方案通常会通过长时间锁定资源来实现一致性,代价是性能低下、同步阻塞、可用性差。在互联网高并发场景下,这几乎是不可接受的。
  • 柔性事务:放弃了强一致性,接受数据在一定时间内可能不一致。它不再追求"所有操作要么同时成功,要么同时失败”,而是承诺"如果出错了,我有办法把它纠正回来,让数据最终达到一致"。它的优点是资源锁定时间极短(甚至没有),异步化程度高,系统吞吐量和可用性远超刚性事务,代表有SAGA、TCC等。

如今,柔性事务已经逐渐成为分布式事务的主流选择,而它的理论基石,是BASE理论。在CAP定理中,三个条件限制得过于僵硬,做为对CAP定理的补充,2008年由eBay的系统架构师Dan Pritchett在论文《BASE: An ACID Alternative》中提出了BASE理论,它是基本可用性(Basically Available)、*柔性状态(Soft state)最终一致性(Eventually consistent)*的缩写。

在CAP定理中,C采用的是线性一致性,要求在任何时刻,整个系统表现得像一个副本一样,写入的数据要立即反映到系统中的所有节点上。BASE理论则选择了更弱的最终一致性来换取整个系统的可用性:

  • 基本可用性:在强一致性方案(如 2PC)中,一个服务故障或网络阻塞,会导致整个事务流程卡死,系统不可用。BASE理论认为,应该保证系统核心功能随时可用,在系统的部分节点出现故障时,允许系统有性能上的损失,或者部分非核心功能的损失,例如延迟时间增大、登录游戏时需要排队等,都是基本可用性降级时常见的策略。
  • 软状态:软状态是相对于CAP定理中要求的线性一致性这个"硬状态"而言的,线性一致性要求数据修改立即同步到所有节点上。因为相同的数据存储在多个节点上,且数据可能需要时间传播到每个节点,这意味着数据库无法强有力地保证数据的完整性。BASE理论认为,要承认并接受这种"中间状态"的存在,软状态允许有中间状态进行过渡。
  • 最终一致性:BASE理论认为,节点之间同步数据需要时间,修改并不能马上同步到所有节点上,允许系统出现临时的不一致(软状态),但必须设计一套机制(例如 Saga 模式的补偿操作),确保在流程成功或失败后,所有相关数据最终都能达到一个逻辑上自洽的、一致的状态。

BASE理论是对CAP定理中的一致性和可用性进行权衡的结果,其核心思想是:放弃强一致性,根据各服务的特点,采用适当的方式使系统达到最终一致性。BASE理论描述了一种尽最大努力保证所有查询都能成功返回结果的系统,但代价是返回的结果有时可能反映的是一个略微过时的数据版本。按照CAP定理中三选二的划分方式,BASE理论中描述的系统更像是一个AP系统。

在论文中,Dan Pritchett演示了如何通过牺牲系统的强一致性,以保证系统柔性可用。以转账为例,用户A向用户B转账,两个账户分别位于不同的银行:

  1. 新建一张消息表,用于存储需要向银行中的账户执行加钱的操作;
  2. 在用户A所在的银行系统开启事务,对A的账户执行扣钱操作,同时向消息表中新增一条数据,表示需要向用户B的账户执行加钱操作;
  3. 在用户A所在的银行系统提交事务,如果事务提交失败,将从消息表中删除新增的数据;
  4. 另外的进程定期轮询消息表,执行其中的操作,直到执行成功才将数据从消息表中删除。

在这个例子中,系统在一段时间内并不处于数据一致的状态(用户A和用户B的账户余额总和不一致),但是系统终会达成一致,而换来的是系统整体的可用性提升。但是这种实现方案,保证不了数据的隔离性。例如,用户B的账户如果在还没有加上钱之前,其它用户看到的仍然是旧的账户余额,对于隔离性要求高的场景而言,要选择其它方案,本章后续会介绍这些方案。

了解了CAP定理和BASE理论之后,我们知道在分布式事务中,有强一致和最终一致两种不同的方案,分别是:

  • 强一致方案:两阶段提交、三阶段提交,Google Spanner。
  • 最终一致方案:TCC事务、SAGA事务。

下面来展开了解这些不同的方案。


7.3.1 两阶段提交 #

经典的两阶段提交流程

第一种用于实现强一致分布式事务的方案是两阶段提交(Two Phase Commit,简称2PC)10,在这个方案中,有两种类型的子系统:

  • 事务管理者(Transaction Manager,简称TM):负责本机事务的并发控制和异常恢复等功能。
  • 事务协调者(Transaction Coordinator,简称TC):负责开启和协调分布式事务,将分布式事务划分成两阶段提交给TM执行。

在每次分布式事务的执行过程中,只能有一个事务协调者,但是事务协调者可以同时也是一个事务的管理者。

一个典型的两阶段提交流程如图7.22所示,分为以下两个步骤:

  1. 准备阶段(Prepare Phase):在这一阶段,协调者向事务的所有参与者发出Prepare消息,询问是否准备好提交事务。

    • 参与者如果准备好提交事务,就在本地持久化记录事务提交所要做的内容,但是并不真正提交。这意味着在准备阶段,参与者在持久化数据之后,参与者还需要保持数据的隔离性,不能释放锁让数据对外可见。如果参与者是数据库,准备阶段要做的事情就是在数据库事务中写入对应的修改,但是并不提交。
    • 如果参与者在准备阶段执行出错,则向协调者应答Abort消息,表示要中断事务。
  2. 提交阶段(Commit Phase)

    • 当协调者收到所有参与者的Ready应答后,就认为这个分布式事务可以提交,此时将向所有参与者发送Commit消息,所有参与者执行事务提交操作。
    • 如果有一个参与者应答了Abort,或者在超时时间只能没有收到所有参与者的应答,协调者认为需要中断事务,向所有参与者发送Abort消息中断事务。

图7.22 典型的2PC流程

分布式事务两阶段提交的划分,让协调者在准备阶段和所有参与者协调是否可以提交事务。在这一阶段,参与者可以决定是否提交事务,也有权利一票否决中断事务。但是当一个参与者同意可以提交事务之后,决定权就不在参与者掌握中。因此第一阶段是参与者向协调者应答的不可逆的承诺。因此当协调者收集了所有参与者的第一阶段应答,持久化事务的Commit或者Abort信息之后,事务就不会再发生改变了。

两阶段提交的原理并不复杂,但是实现起来有几个缺陷:

  • 单点问题:两阶段提交中的协调者会成为两阶段提交中的单点。如果是参与者宕机,根据前面描述的两阶段提交流程,如果协调者在等待超时之后依然没有收到所有参与者的应答,会中断事务。但如果协调者宕机,参与者将一直等待协调者恢复。
  • 同步阻塞问题:两阶段提交过程中,所有的参与者和协调者成为一个统一的整体协同工作,需要三次数据持久化和两次远程服务器调用才能完成,整个过程的最后完成时间取决于这个整体中最后完成的操作时间。
  • 数据一致性问题:在提交阶段,如果某些参与者由于网络故障等原因,没有收到来自协调者的Commit消息,将导致只有部分参与者执行了提交事务操作,造成整个系统数据不一致。

7.3.2 三阶段提交 #

为了缓解两阶段提交中的单点和准备阶段的性能问题,提出了三阶段提交(Three Phase Commit,简称3PC)11协议,在2PC的基础上,将准备阶段再划分为两个阶段,分别称为CanCommit阶段和PreCommit阶段,把提交阶段改称为DoCommit阶段,三阶段提交的工作步骤如下,如图7.23:

  1. CanCommit阶段:也称询问阶段,在这一阶段,协调者向所有参与者发送CanCommit请求,询问参与者是否准备好提交事务。参与者根据自身情况评估是否可以完成事务,应答协调者。
  2. PreCommit阶段:也称准备阶段,如果所有参与者都应答了可以提交事务,协调者向所有参与者发送PreCommit请求,参与者将操作持久化但并不提交,以保证数据隔离性,并且应答协调者准备好提交事务;
  3. DoCommit阶段:也称提交阶段,如果所有参与者应答了准备好提交事务,协调者向所有参与者发送DoCommit请求,通知参与者提交事务。在这一阶段中,如果参与者在超时时间到来之前,并没有收到协调者的DoCommit消息,默认的策略是提交事务。

图7.23 典型的3PC流程

对比2PC协议,3PC协议做了如下的修改:

  • 将2PC协议的Prepare阶段,分为了CanCommit和PreCommit阶段。这么划分的原因是,在准备阶段中,参与者持久化数据是一个很重的操作,而一旦另外的参与者应答不能提交事务,则其它参与者的前期持久化工作就要被回滚。新增CanCommit阶段询问各参与者是否可以提交事务,再在所有参与者都同意之后再执行准备阶段的持久化操作,成功的概率就会大很多。
  • 在2PC协议中,一旦协调者宕机,参与者将一直等待协调者恢复才能继续工作。而在3PC协议中,前面两阶段通过之后,事务的回滚概率很小,因此如果在PreCommit阶段之后,参与者在超时到来之前没有收到协调者的DoCommit消息,默认将提交事务。

虽然3PC协议改善了2PC协议中存在的单点和准备阶段的性能问题,但是在事务能够正常提交的情况下,3PC比2PC还多一次询问;此外,3PC的数据一致性问题并没有得到解决。例如,如果按照PreCommit阶段之后,协调者发出的指令是中断事务,但是只有部分参与者收到了这个指令,其它没有收到该指令的参与者,将在超时之后执行默认的事务提交操作,依然会存在数据不一致问题。

如图7.24所示,事务协调者和两个事务管理者已经完成了询问阶段和准备阶段,来到提交阶段时,事务管理者A与协调者发生网络隔离,导致一直没有收到协调者的消息,于是采用了超时的默认行为:提交事务;而协调者由于某些原因,判定事务失败,向管理者B发送了中断事务的消息,并且管理者B回滚了事务。最终系统的状态变成了:管理者A提交事务,而管理者B回滚了事务,两者的数据不一致。

图7.24 3PC流程由于网络隔离导致的数据不一致问题

3PC试图用"超时"这种依赖于时间假设的机制来解决分布式问题,我们在时间模型章节已经解释过,在异步网络中,超时并不一定代表失败,也可能只是慢,基于超时做自动提交的决策是极其危险的。


7.3.3 Google Spanner #

通过前面的分析,可以看到2PC最大的问题是性能差和单点故障,后续的3PC也并没有解决这些问题。在本节,我们将介绍Google Spanner系统的实现,这是一个工业级的强一致分布式事务的实现。

Spanner的主要特性有:

  • 提供外部一致性(External Consistency): Spanner提供了分布式系统中最高的一致性级别。得益于 TrueTime 机制,Spanner 能够将事务的提交顺序与物理时间的先后顺序严格锚定。这意味着,如果一个事务 $T_1$ 在物理时间上先于 $T_2$ 提交,系统能保证所有客户端都能观测到 $T_1$ 发生在 $T_2$ 之前,从而呈现出线性一致性(Linearizability)的全局视图。
  • 支持严格的串行化隔离(Strict Serializability): Spanner 的分布式读写事务完全遵循 ACID 特性,并默认提供串行化(Serializable)隔离级别。系统通过两阶段锁定(2PL)机制,有效消除了脏读、不可重复读和幻读等并发异常,且无需应用层介入处理复杂的并发冲突。
  • 全球级的水平扩展与跨分片原子性: Spanner 将数据自动切分为多个分片(Shards/Tablets)分布于全球数据中心。通过结合两阶段提交(2PC)与 Paxos 协议,Spanner 保证了跨越多个分片的分布式事务具有严格的原子性,即所有变更要么全部持久化,要么全部回滚,从而屏蔽了底层分片的复杂性。

在具体实现上,Spanner 采用分层架构:底层利用 Paxos 共识算法确保数据在跨数据中心甚至跨洲际级别的副本复制与高可用;上层则通过 两阶段提交(2PC)与 两阶段锁定(2PL) 协议,结合独创的 Commit Wait 机制,实现了跨分片的分布式事务。这种设计使得 Spanner 成为世界上第一个真正意义上的"全球数据库"(NewSQL 的鼻祖),它不仅让开发者从繁琐的数据分片和一致性处理中解脱出来,更为后来涌现的 CockroachDB、TiDB 等开源分布式数据库提供了理论基石与工程范本。

本节将系统性地剖析Spanner的实现原理,重点探讨其如何利用物理时钟界定全局事务顺序,以及 TrueTime、Paxos 与分布式锁机制如何在不同层级上协同工作。通过对这一架构的解构,旨在阐明在大规模分布式环境中实现线性一致性(Linearizability)和可串行化隔离级别的工程路径与设计权衡。

7.3.3.1 整体架构 #

Spanner的架构设计旨在满足全球范围内的可扩展性、高可用性以及多数据中心部署的需求。如图7.25所示,从宏观视角来看,Spanner采用了一种分层的层次结构,自顶向下依次为Universe、Zone以及具体的SpannerServer节点。

图7.25 Spanner系统架构

  • Universe:Spanner 的顶级部署单元被称为Universe。一个Universe通常涵盖整个企业的全球数据部署。虽然理论上可以部署多个Universe,但在实际生产环境中,Spanner被设计为一个全球唯一的单例系统,以此提供统一的命名空间和资源管理。
  • Zone:Universe内部被划分为多个Zone。Zone是Spanner的物理部署单元,也是基本的物理隔离域。一个Zone包含了一组物理服务器集群,通常部署在特定的数据中心内。Zone是数据复制和故障隔离的基本维度:数据会被复制到不同数据中心的多个Zone中,以防止单点故障或单一数据中心瘫痪导致的数据丢失。

在一个Zone内部,包含以下核心组件:

  • Zone Master:负责管理Zone内的所有Spanner Server,分配数据分片(Tablet)给具体的SpannerServer,并处理负载均衡。
  • Location Proxy:作为客户端与Spanner Server之间的路由层,根据数据的位置信息将客户端请求转发至正确的 Spanner Server。
  • Spanner Server:负责存储和处理数据的核心工作单元。它直接响应客户端的读写请求。

除此以外,还有两个核心服务:

  • Universe Master:这是一个全局的单例控制台,主要用于监控整个 Universe 的状态,提供关于 Zone 的状态信息的交互式调试界面。
  • Placement Driver:负责跨 Zone 的数据迁移与调度。该组件会定期与各 Zone 的 Spanner Server 通信,监控负载状态,并在必要时触发数据的自动迁移,以满足复制策略或进行负载均衡。

Spanner Server是系统运作的核心,每个Spanner Server负责管理成百上千个数据分片。如图7.26所示,Spanner Server 自下而上主要包含以下模块:

  • Tablet:Spanner Server管理的基本数据单元称为 Tablet。Tablet 类似于 Bigtable 中的同名概念,是一组键值对的集合,形式为 (Key: string, Timestamp: int64) -> String。这种结构天然支持多版本并发控制(MVCC),而不是一个简单的KV存储,这使得 Spanner 能够高效支持快照读。
  • Paxos:为了保证数据的一致性,每个 Tablet 的元数据和日志都通过 Paxos 协议在不同 Zone 的副本间进行同步。每个 Tablet 对应一个 Paxos Group。在 Spanner Server 中,Paxos 状态机负责将写操作日志持久化,并协调副本间的状态。
  • Lock Table:在 Paxos 组的 Leader 副本上,维护着一个锁表。该组件用于实现两阶段锁定(2PL)机制,负责管理读写事务中的行级锁或范围锁,以保证事务的隔离性。注意,采用乐观并发控制的只读事务无需访问锁表。
  • Transaction Manager:每个 Spanner Server 上运行着事务管理器,负责协调分布式事务。对于仅涉及单个 Paxos Group 的事务,该管理器可独立作为协调者;对于跨多个 Paxos Group 的事务,涉及的多个 Transaction Manager 会协同工作,其中一个被选为协调者,其余作为参与者,共同执行两阶段提交(2PC)协议。

图7.26 Spanner Server技术栈

注意

到目前为止,我们还没有介绍过Paxos这类共识算法,可以将它理解为让分布式系统中的多个副本就一项提案(例如,写入一个数据、选举一个节点做为系统的Leader节点等)达成一致的算法,我们将在共识算法章节详细介绍共识算法。

7.3.3.2 True Time #

我们已经在物理时钟章节中解释了物理时钟做为分布式系统时间的局限性,在传统分布式系统中,由于物理时钟漂移(Clock Drift)的存在,节点间的时钟难以保持精确同步。因此,系统设计通常依赖逻辑时钟来确立事件的因果顺序。但是,Spanner并未采用逻辑时钟方案,原因如下;

  • Spanner想要实现线性一致性,而逻辑时钟的本质是仅捕捉系统内部的因果关系,它实现的是因果一致性,无法感知系统外部发生的事件顺序。
  • Spanner的一个核心特性是支持在任意时间点进行高效的、无锁的快照读取(我们将在本节中详细介绍Spanner如何实现分布式事务)。如果采用逻辑时钟,实现这一特性将面临巨大的性能惩罚:当用户请求"读取当前最新数据"时,系统无法立即确定什么是"当前"。为了构建一个全局一致的快照,系统必须咨询所有相关的分片(甚至全网),询问它们当前的逻辑时钟进度,以协商出一个全局的"安全快照点"。这在跨洲际的分布式系统中会导致不可接受的通信延迟。
  • 虽然向量时钟能够比Lamport时钟更精确地描述因果关系,但它在 Spanner 的规模下不可行。向量时钟的大小与系统中的参与节点数量成正比($O(N)$)。Spanner 拥有成千上万个 Spanner server 和数以百万计的 Tablet。如果在每个事务的元数据中都携带如此庞大的向量信息,网络带宽和存储空间将被元数据消耗殆尽。

逻辑时钟虽然在理论上优雅且无需特殊硬件,但它将"定序"的责任推给了应用层(要求应用传递 Token)或导致读取性能低下。Spanner 通过引入 TrueTime,通过软硬件结合的手段,将时间的不确定性压缩到一个极小的物理窗口内,从而在软件层面实现了一个看似拥有全局同步时钟的理想环境。这使得 Spanner 既拥有了关系型数据库的强一致性语义,又具备了 NoSQL 级别的全球扩展能力。本小节我们详细解释TrueTime的实现机制。

Spanner 并未试图消除物理时钟的误差,而是通过 TrueTime 机制,将这种误差进行量化并显式地暴露给上层应用,从而在分布式环境中构建了全局唯一的物理时间基准。本小节介绍TrueTime API从硬件基础设施到软件算法的完整实现机制。

TrueTime 的高可用性和精确性首先建立在庞大且冗余的硬件基础之上。Google 在其全球各个数据中心部署了专门的时间主控节点(Time Master)。为了消除单一故障点并降低误差,Time Master采用了两种物理时间源混合部署的策略:

  • GPS接收器:大部分 Master 节点配备了 GPS 接收器,能够直接接收全球定位系统的卫星原子钟信号。GPS 的优势在于长期精度高,误差极小。
  • 原子钟: 为了应对GPS信号干扰或天线故障,Google在部分 Master 节点上部署了原子钟。原子钟虽然存在微小的漂移,但短期稳定性极高,能够在其通过 GPS 校准期间提供可靠的备份时间源。

Spanner通过运行在每个 Spanserver 上的守护进程Time Daemon与时间主控节点通信。它定期从多个 Time Master(包括本数据中心和远程数据中心)轮询时间信息。Daemon 使用 Marzullo算法的一个变种,以此过滤掉异常的时钟源,并计算出本地机器时钟相对于基准时间的偏差范围。

TrueTime API 与传统的gettimeofday()System.currentTimeMillis()有着本质的区别。它并不返回一个单一的、可能存在偏差的时间点,而是返回一个包含误差范围的时间区间。TrueTime的核心接口TT.now()返回的是一个时间区间对象TTinterval

$$TT.now() = [earliest, latest]$$

该区间表示调用该方法时,真实的绝对时间 $t_{abs}$​ 必定落在该区间内,即$earliest \leq t_{abs}​ \leq latest$。这个区间的宽度由误差项ε决定,即 [t−ε,t+ε]。该区间的时间跨度被记为 $2\epsilon$,其中 $\epsilon$ 代表时间的不确定性半宽(uncertainty bound)。在 Google 的生产网络环境中,$\epsilon$ 的平均值通常极低,但在网络分区或系统负载极高时,$\epsilon$ 可能会增大。

TrueTime 提供了以下三个核心方法:

  • TT.now():返回 TTinterval: [earliest, latest]
  • TT.after(t):若当前时间确定已晚于时间戳$t$,则返回 true(即 $TT.now().earliest > t$)。
  • TT.before(t):若当前时间确定早于时间戳 $t$,则返回 true(即 $TT.now().latest < t$)。

TrueTime API 的最终目的是为了实现 Spanner 的外部一致性(External Consistency),即线性一致性(Linearizability)。为此,Spanner 引入了提交等待(Commit Wait)策略。

当一个事务$T_i$​尝试提交时,Spanner会为该事务分配一个提交时间戳$s_i$​。为了保证如果事务$T_1​$在事务$T_2​$开始之前就已经提交完成,要求$s_1​

  • 时间戳锚定:事务$T_i$​的提交时间戳$s_i$​必须大于等于调用TT.now().latest的值(即$si​\geq TT.now().latest$)。
  • 提交等待:在将数据写入持久化存储并对外宣告事务成功之前,协调者必须进行等待,直到由于时间的自然流逝,使得该时间戳$s_i$​确定已经成为"过去时"。

具体来说,系统必须等待直到当前的TT.now().earliest大于事务选定的时间戳$s_i$​,即:

$$TT.after(s_i​) \to true$$

如图7.27所示是Spanner事务提交等待策略的一个示例。在图中,事务$T_1$在$t_1$时刻发出提交请求,这个时间需要等待到$t_2$才认为提交完成,这两个时间区间的跨度为$2\epsilon$;在$T_1$之后启动的事务$T_2$,它的开始时间$t_3$必须满足$t_3.after(t_2) \to true$。

图7.27 Spanner事务提交等待策略

这个等待时长通常为2ε。通过这种强制等待,Spanner确保了在事务提交的那一刻,物理时间确实已经越过了$s_i$​。

由于 Commit Wait 机制的存在,写事务的提交延迟下限被严格锁定为 $2\epsilon$。因此,Spanner 的工程目标之一是在保证正确性的前提下,尽可能压缩这一不确定性窗口。TrueTime 的误差主要由两部分构成:本地时钟漂移和网络通信延迟。

在两次同步之间,本地机器时钟的不确定性会随着时间线性增长,呈现出一种"锯齿状"形态。为了控制这种增长幅度,Spanner server 上的 Time Daemon 会以极高的频率(通常为每 30 秒一次,甚至更高频)轮询 Time Master。轮询频率越高,本地时钟在两次校准之间累积的漂移误差(Drift)就越小,从而维持较低的$\epsilon$ 基线。

为了最大限度降低网络通信延迟,Spanner 在物理部署上进行了深度优化:

  • Time Master 的邻近部署:虽然 Time Master 是全球分布的,但 Spanserver 在轮询时间时,会优先与位于同一数据中心或邻近区域且通信链路低延迟的 Time Master 进行同步。
  • 长尾延迟剔除:网络通信中偶尔会出现抖动(Jitter)。TrueTime 的客户端守护进程在向多个 Master 发起轮询时,会采用类似于 Marzullo 算法的变种逻辑,自动识别并剔除响应时间异常长的 Master 数据,仅利用那些响应最快且一致性最高的时间源来计算当前的参考时间。

Spanner 通过高性能的网络基础设施、高频的时间同步协议以及严格的离群点剔除算法,将 $\epsilon$ 通常维持在毫秒级(论文发表时约为 1ms 到 7ms,平均约为4ms,近年来随着硬件升级已进一步降低)。这种工程层面的极限压缩,使得 Commit Wait 带来的延迟损耗在实际业务场景中变得可以接受,从而在强一致性与高性能之间取得了平衡。

7.3.3.3 分布式事务实现 #

有了TrueTime机制来保证事务之间的提交顺序后,我们来看看Spanner如何结合TrueTime、Paxos算法、2PL、2PC等几项技术来实现分布式事务。Spanner支持以下几种类型的事务:读写事务(Read-Write Transaction)只读事务(Read-Only Transaction)快照读(Snapshot Read),这三种模式均基于TrueTime API和多版本并发控制(MVCC)构建,但在锁机制、时间戳分配及副本交互上采取了不同的策略。

读写事务

读写事务是Spanner中唯一支持写入操作的事务类型。为了保证ACID特性,Spanner将以下几种关键技术进行组合:

  • 两阶段提交(2PC)用于保证跨分片的原子性。
  • Paxos用于保证单分片的高可用与持久化。
  • TrueTime确保了全局的时序一致性。

在深入流程之前,必须明确 Spanner 的基本数据单元。如图7.28所示,Spanner 将数据切分为 Tablet,每个 Tablet 的数据由一个 Paxos Group 负责维护。这个 Group 包含多个地理分布的副本,它们通过 Paxos 协议选出一个 Leader。

  • 所有的写操作都必须由 Paxos Leader 处理。
  • 所有的锁管理(Lock Table)都维护在 Leader 的内存中。
  • 事务的持久化依赖于将日志条目写入 Paxos Group 的多数派。

图7.28 Spanner 将数据切分为 Tablet,每个 Tablet 的数据由一个 Paxos Group 负责维护

当一个事务涉及多个 Tablet 时,就涉及到了多个 Paxos Group,这时就需要两阶段提交(2PC)介入。

假设一个事务$T$ 需要修改位于Paxos Group A 和 Group B 上的数据,Spanner会自动选择其中一个 Group(例如 Group A)充当协调者(Coordinator),而其他参与的 Group(如 Group B)则称为参与者(Participant)。

整个过程遵循标准的 2PC 协议,但在关键步骤上利用TrueTime进行了强化。核心流程如下:

  1. 读阶段与悲观锁: 当事务执行读取操作时,必须首先获得相关数据的读锁(Read Locks)。如果数据分布在多个 Paxos 组(Paxos Groups)中,事务会分别向各个组的 Leader 申请锁。这种悲观锁机制确保了事务执行期间数据的隔离性。重要的是,所有的写入操作在事务提交前都会先缓存在客户端,不会立即与服务器交互。
  2. 提交阶段:当客户端发起提交请求时,涉及多个 Paxos 组的事务将启动两阶段提交(2PC)协议。
    • 协调者选举:其中一个Paxos组被选为协调者(Coordinator)。
    • 时间戳锚定:协调者负责为整个事务分配一个全局唯一的提交时间戳$S_{commit}$​。根据 TrueTime的特性,协调者必须确保$S_{commit}$满足两个条件:
      • 单调性:$S_{commit}​$大于所有参与者在 Prepare 阶段记录的时间戳。
      • 现时性:$S_{commit}​$必须大于协调者收到提交请求时的TT.now().latest
  3. 提交等待:这是Spanner保证线性一致性的关键。在将$S_{commit}​$真正应用并对外宣告成功之前,协调者必须执行"提交等待"。它会持续检查 TrueTime,直到满足以下条件: $$TT.now().earliest> S_{commit}​$$ 这一等待确保了在物理时间轴上,事务的生效时刻$S_{commit}​$已经成为了确定的过去。因此,任何在事务提交后(即物理时间晚于提交完成时刻)开始的新事务,其获取的时间戳一定晚于$S_{commit}​$。

下面以经典的银行转账为例,说明Spanner的读写事务流程:

Write(A, A - 50)
Write(B, B + 50)

流程的时序图如图7.29和图7.30所示。图中有以下几个角色:

  • 客户端:发起事务的业务端。
  • Paxos组1 Leader(参与者 Leader):持有数据A的分片组 Leader,只负责处理自己这部分数据的锁和写入。
  • Paxos组2 Leader(协调者 Leader):持有数据B的分片组 Leader,除了处理数据 B,还负责指挥整个事务的提交过程(决定时间戳、执行等待)。

需要说明的是,图中省略了两个Paxos组采用Paxos算法复制数据到组内其它副本的流程,同时假设在这次事务中,Paxos组1做为参与者,Paxos组1做为协调者。

图中的主要步骤如下:

  1. 阶段一(步骤1-6):客户端向两个Paxos组Leader分别发起读数据A和B的读请求,获得共享锁。获取A和B的数据之后,客户端并不马上发起请求,而是现在本地计算,缓冲写操作。
  2. 阶段二(步骤7-13):两阶段提交之中的Prepare阶段,当客户端决定提交时,系统进入2PC的第一阶段。客户端会将缓冲的写操作分发给所有相关的参与者 Leader和预选的协调者 Leader。
  3. 阶段三(步骤14-16):时间戳仲裁与提交等待阶段,协调者会选定一个全局唯一的提交时间戳$S_{commit}$​。为了保证外部一致性,这个时间戳必须严格大于所有参与者建议的准备时间,且必须大于当前时刻的可能上限(TT.now().latest)。这一步将逻辑事务与物理时间进行了绑定。
  4. 阶段四(步骤17-23):一旦等待期结束,协调者会在 Paxos 组内写入 Commit 记录,并立即告知客户端事务成功。为了提高响应速度,协调者通常会异步地通知其他参与者进行提交。由于参与者此前已在持久化日志中记录了 Prepare 状态,它们只需在收到指令后,以确定的$S_{commit}$​为版本号应用更新并释放锁。

图7.29 银行转账的Spanner读写事务执行流程:阶段一和阶段二

图7.30 (续)银行转账的Spanner读写事务执行流程:阶段三和阶段四

快照读和只读事务

在传统的强一致性数据库中,读取操作往往需要获取锁(S-Lock)以防止与写操作发生冲突,这在高并发场景下会显著降低系统的吞吐量。Google Spanner 的一大技术突破在于,它利用多版本并发控制(MVCC)和 TrueTime API,实现了无锁(Lock-Free) 的只读事务。

Spanner 的只读事务具备以下关键特性:

  • 无锁机制: 读操作绝不会阻塞写操作,反之亦然。
  • 外部一致性: 默认情况下,只读事务保证能读到所有在事务开始前已提交的数据(即强一致性)。
  • 全球一致性快照: 即使数据分布在全球不同的数据中心,事务也能看到整个数据库在某一个特定时间点 T 的一致性视图。

只读事务的基础是快照读。由于 Spanner 的每次写操作都会打上一个基于TrueTime 的时间戳,数据库实际上存储了数据的多个版本。执行快照读时,Spanner 首先会确定一个读取时间戳$T_{read​}$。系统保证:对于任意在$T_{read​}$之前($\leq T_{read​}$​)提交的事务,其结果对本次读取可见;对于在$T_{read​}$之后($> T_{read​}$​)提交的事务,其对本次读取完全不可见。

只读事务的执行可以分为两个主要阶段:时间戳选定安全读取

时间戳选定是决定一致性级别的关键步骤。为了保证线性一致性,即"看到所有刚刚发生的写入",Spanner 必须选择一个足够新的时间戳。

  • 策略:Spanner 将$T_{read​}$设置为当前时刻的上限,即$T_{read}$​=TT.now().latest
  • 原理: 结合写事务中的 Commit Wait 机制,任何已经收到"提交成功"响应的写事务,其提交时间戳 Scommit​ 一定小于当前时刻。因此,使用TT.now().latest作为读取时间点,必然能覆盖所有已提交的历史。

确定了$T_{read​}$后,客户端可以将读请求发送给包含目标数据的任意副本,而不仅限于 Leader,这使得 Spanner 的读性能可以随着副本数线性扩展。

然而,如何保证被选中的副本(可能是一个从库)已经拥有了$T_{read​}$时刻的数据呢?这里引入了 安全时间(Safe Time, $t_{safe}$​)的概念。

每个副本都会维护一个$t_{safe}$​值。它表示该副本已经通过 Paxos 协议同步了所有时间戳小于等于$t_{safe}$​的日志。换句话说,该副本确信:在$t_{safe}$​之前的所有事务都已经同步到了本地。

当一个副本收到时间戳为 Tread​ 的读请求时,它会执行以下判断:

  • 如果$T_{read​} \leq ​t_{safe}$​: 说明该副本的数据足够新,可以立即执行读取并返回结果。这是最理想的情况,完全无阻塞。
  • 如果$T_{read​} > ​t_{safe}$: 说明该副本的数据落后了,尚未同步到$T_{read​}$时刻的状态。此时,该副本必须等待,直到其通过 Paxos 同步了新的日志,使得$t_{safe}$​推进并超过$T_{read​}$。

如图7.31是Spanner只读事务执行流程的时序示意图。

图7.31 Spanner只读事务执行流程

如果一个只读事务需要读取位于不同 Paxos Group(不同分片)的数据 A 和 B,流程如下:

  1. 确定全局时间戳:客户端或协调节点在事务开始时,确定一个统一的$T_{read​}$(例如 TT.now().latest)。
  2. 并发分发:客户端携带这同一个$T_{read​}$,并发地向持有 A 的副本和持有 B 的副本发送读请求。
  3. 独立执行:A副本和B副本分别根据自己的$t_{safe}$独立处理请求。
  4. 结果聚合:客户端收到所有结果。

7.3.3.4 总结 #

传统的2PC算法中,存在的两大问题是单点故障和性能差。Spanner在2PC的基础上,引入TrueTime和Paxos,来解决了这两个问题:

  • 单点故障:传统2PC中,如果节点宕机,事务就卡住了。Spanner的架构中,2PC的每一个参与者和协调者(都不是单机,而是一个Paxos组里,即使其中的某个节点宕机,也可以继续运行完成事务,避免了单点故障问题。2PC解决的是跨分片时的原子性问题。即:如何让不同的数据要么一起成功,要么一起失败。Paxos解决的是高可用问题。即:如何让同一份数据的多个副本保持一致。
  • 读写性能:传统2PC另一痛点是性能,特别是读操作也被锁住。在强一致性要求下,读操作必须去等待写操作完成(锁冲突),或者读操作必须去问Leader(网络延迟)。针对互联网应用"读多写少"的特性,Spanner 利用 MVCC(多版本并发控制) 实现了近乎完美的只读事务。通过 Safe Time(安全时间) 机制,Spanner 允许客户端在任意副本(包括非 Leader 副本)上进行强一致性读取。这种"无锁读"不仅极大提升了系统的并行处理能力,还通过将流量分散至全球各地的边缘节点,显著降低了用户的访问延迟。

7.3.4 TCC事务 #

在前面讲解BASE时举的转账例子中,系统并没有满足隔离性的要求,在达成最终一致性之前,系统中两个账户的余额之和可能是不一致的。在某些对隔离性有要求的业务场景里,这是不可接受的。例如在电商购物场景中,如果扣款应答客户购买成功之前,没有扣除商品库存,可能会导致商品超卖。

本小节要介绍的TCC是另一种常见的实现分布式事务的机制,最初由Pat Helland在2007年提出,TCC是Try-Confirm-Cancel三个单词的首字母。TCC是补偿型分布式事务方案,通过业务逻辑拆分和补偿机制保证最终一致性。

TCC事务的核心思想是"资源预留(Resource Reservation)"。它是一种两阶段提交的变种和优化。与传统的两阶段提交协议不同,TCC事务不会长时间锁定数据库资源,而是将锁的粒度从"数据库"层面提升到了"业务"层面,由应用(代码)自己来控制。

TCC事务将完整的业务操作分解为两个阶段:

阶段一:Try(尝试/预留)

在第一阶段,对业务所需的所有资源进行检查和预留。这不是真正的执行业务,而是做一个预备操作。例如,不是直接扣减库存,而是"冻结"库存;不是直接扣款,而是"冻结"用户账户的相应金额。第一阶段的所有操作必须是可逆的,即有对应的撤销操作。

阶段二:根据 Try 阶段的结果执行Confirm或Cancel

如果所有参与方的Try操作全部成功,将执行Confirm操作,这会真正地执行业务,完成资源的最终变更。Confirm操作使用Try 阶段预留的资源来完成操作。例如,将"冻结"的库存变为"已扣减";将"冻结"的金额真正地从用户账户划转。要求Confirm操作必须是幂等的。因为网络原因可能导致重试。

如果有任何一个参与方的Try操作失败,将执行Cancel操作,这会取消Try阶段的所有预留操作,释放资源,使数据恢复到事务开始前的状态。例如,解冻之前冻结的库存;解冻用户账户的金额。Cancel 操作也必须是幂等的。

由于三阶段接口由业务实现,需要注意到以下事项:

  • TCC的Confirm阶段和Cancel阶段可能会由于网络重试等原因,被多次调用,因此要实现为幂等接口,支持重复调用。
  • 需要处理不同阶段消息可能乱序达到的情况,例如同一个事务中,Cancel阶段先于Try阶段到来等场景。
  • TCC事务由于由服务提供三阶段接口,因此隔离性也需要业务来提供,例如Try阶段锁定的资源,不能被其它事务所见和修改。

以跨行转账为例,假设用户 A(银行1)要转账 100 元给用户 B(银行2),需要设计的三阶段流程如表7.5所示。

表7.5 跨行转账TCC事务操作

服务方 Try Confirm Cancel
银行1 (扣款方) 1、检查 A 的余额是否大于100元。
2、冻结 A 账户中的100元。
将 A 账户中"冻结"的100元真正扣除。 将A账户中"冻结"的100元解冻,恢复余额。
银行2 (收款方) 检查B账户是否有效、状态正常。 增加B账户100元。 不做任何操作

TCC事务通常需要一个协调器负责与事务中涉及的各个服务交互,如图7.32和图7.33演示了跨行转账的成功和失败流程。

图7.32 跨行转账事务的TCC事务流程:Try-Confirm流程

图7.33 跨行转账事务的TCC事务流程:Try-Cancel流程

可以看到,TCC事务和两阶段提交的过程很相似,但是有以下的区别:

  • 2PC作用在资源层,而TCC事务的三个接口都是由业务提供的,这也意味着TCC事务对业务的侵入性更强,业务的改造成本高;
  • 2PC在Prepare阶段,所有参与者进行了操作表决;而TCC事务的Try阶段并没有表决准备,而是冻结资源;
  • 业务需要根据不同的失败原因实现不同的回滚策略,还需要保证Confirm和Cancel接口的幂等性。

7.3.5 SAGA事务 #

TCC事务对业务的侵入性很强,要求业务实现TCC事务的三阶段接口。对于某些业务场景,流程长、流程多,且还需要调用第三方公司的服务,对这些外部服务很难要求配合TCC事务提供三阶段接口。例如,在某些电商场景中,需要调用外部银行系统的扣费服务,这时很难要求这些第三方服务遵循TCC事务的接口规范。

针对这类业务场景,提出了SAGA分布式事务模式。它最早在1987年被提出,文中提出了一种提升*长时间事务(Long Live Transaction)效率的方法,它更适用于业务流程长、流程多,且难以实现TCC事务三阶段接口的业务场景。在这个序列中,每个本地事务都会完成自己服务内的业务操作并立即提交。如果整个SAGA流程中的某一步失败了,系统会调用一系列补偿事务 (Compensating Transaction)*来撤销之前所有已经成功提交的本地事务,从而使得整个系统最终回到一个一致的状态。简单来说,SAGA的哲学是:允许向前走,但必须保证有路可以退回来。

具体而言,SAGA分布式事务由以下两部分组成:

  • 本地事务:SAGA将一个大的分布式事务拆分成n个小的子事务,命名为$T_1,T_2\cdots T_n$,这些子事务将被依次执行,每个子事务都应该能被视为是原子操作。例如在一次购买行为里,可以划分为扣款、减库存、发货这几个子事务。如果每个子事务能够正确按照执行顺序提交,其效果等同于这个大的分布式事务成功提交。
  • 补偿事务:对每个子事务,设计对应的补偿操作,例如"取消订单"、“归还库存"等,命名为$C_1,C_2\cdots C_{n-1}$,用于在某一项子事务执行失败时,执行前面已成功子事务的逆向补偿操作。

以银行系统扣款操作为例,如果在TCC事务中,需要银行系统实现TCC事务中的Try接口,在这个接口中冻结账户对应的款项,并且要保证在事务提交或者回滚之前,冻结的款项不被其它事务可见;除了需要实现冻结账户款项操作,银行系统还需要实现撤销冻结操作,以便在回滚时调用。而如果使用SAGA事务,则不需要银行系统实现多余的接口,因为扣款操作对应的补偿操作只需要给账户加上对应的款项即可,这个接口银行系统已经实现,是由分布式事务的发起方来控制执行的。

可见,SAGA分布式事务对比TCC事务,对业务的侵入性和改造幅度更小,尤其适合需要调用第三方服务,或者调用遗留服务的业务。

当SAGA事务执行失败时,有两种恢复模式,如图7.34所示:

  • 向前恢复(Forward Recovery):如果某个子事务$T_i$执行失败,在这种模式下会一直尝试,直到成功为止。例如在电商购物场景中,如果扣款成功,就一定要保证发货成功,否则将一直尝试。
  • 向后恢复(backward recovery):如果某个子事务$T_i$执行失败,将依次调用在它之前已经执行成功的子事务的补偿操作,完成事务的回滚。在子事务$T_{i}$失败之后,将从操作$C_{i-1}$开始执行补偿操作。

图7.34 SAGA事务的两种恢复模式

以飞机票购票系统为例,解释SAGA中的正向和反向流程:

  • 正向流程

    1. 创建机票订单,预留15分钟。
    2. 锁定飞机对应航班的座位,在预留时间内不再允许其他人预定。
    3. 向用户发起支付订单请求。
    4. 如果用户支付成功,确认订单购买成功,向用户发送通知。
  • 反向流程:如果用户在预留的15分钟内都没有完成支付(无论什么原因),对应的补偿操作是:解锁飞机对应航班座位,取消订单。

由于SAGA事务将一个分布式事务划分成了多个子事务执行,每个子事务是一个原子操作,子事务之间无法保证数据的隔离性。这意味着在SAGA事务尚未完成时,其他事务可以看到中间状态。例如在购买商品扣款成功但还没有扣除库存之前,用户的银行账户数据已经被扣除了款项,但是在商品服务中还没有库存数量还没有发生变化。可见SAGA事务的数据隔离性较弱,因此SAGA事务的业务场景,要求子事务之间相对独立且对隔离性要求不高。因此采用SAGA事务的服务,设计时必须能处理这种"脏读"场景。例如,订单状态可以设计为Pending,而不是直接Completed。

SAGA事务保证的是最终一致性,而不是强一致性。在子事务失败后,系统会有一小段时间处于不一致状态(例如库存扣了,但订单最终是取消的),直到补偿完成才能恢复业务状态。

除此以外,SAGA事务中的补偿操作,必须满足幂等性(Idempotent),因为补偿操作可能会被重试(例如网络失败),必须保证执行一次和执行多次的效果是一样的。在补偿操作失败的情况下,情况可能会非常复杂,例如电商服务中最终支付失败,需要补偿库存操作,如果这一步也失败了该怎么办?如果持续失败,这个场景下可能需要根据日志人工进行干预。

最后,不是所有类型的操作都可以补偿,比如,如果一个操作是"发送一封邮件"或者是"发送一件商品”,这类型的操作无法真正被"撤销",所以并不是所有业务类型都适合SAGA事务。

除了对业务侵入性和适用场景的区别外,TCC 与 SAGA 在并发控制与隔离性的处理机制上也存在本质差异。这种差异直接决定了在高并发场景下,两者对于"超卖"、“脏读"等问题的抵抗能力。

  • TCC的资源预留机制(强隔离性):TCC 的核心优势在于 Try 阶段的资源锁定。通过在业务层面显式地冻结资源(例如将库存从"可用"状态划转为"冻结"状态),TCC 实际上实现了一种应用层的"悲观锁”。这种机制确保了在 Confirm 阶段执行时,资源是绝对可用的。因此,TCC 在应对高并发下的资源竞争(如秒杀抢购)时表现优异,它能够有效防止超卖,并且在逻辑上保证了较好的隔离性,避免了其他事务读取到不可用的资源。
  • SAGA 的缺乏隔离性(弱隔离性):相比之下,SAGA 牺牲了隔离性以换取长流程的执行效率。在 SAGA 模式中,每一个子事务(Local Transaction)都会直接提交到数据库,这意味着数据的变更会立即对其他并发事务可见。SAGA 缺乏类似于 TCC 的资源预留阶段,这导致它天然面临着缺乏隔离性(Lack of Isolation)的挑战。

这种特性在并发场景下会引发两类严重问题:

  • 脏读与级联回滚风险: 如果事务 A 的某个子事务修改了数据,事务 B 随即读取了该数据并基于此进行了后续操作;一旦事务 A 后续失败触发补偿(回滚),事务 B 所持有的数据就变成了无效的"脏数据",甚至可能导致事务 B 也必须进行复杂的级联回滚。
  • 不可对易性(Non-commutativity): 如果并发的 SAGA 事务涉及对同一资源的修改,且该操作不满足交换律(例如计算利息或非累加性的数值更新),由于缺乏锁机制,执行顺序的随机性可能导致最终结果错误。

因此,SAGA 更适用于并发冲突较低、或业务操作满足对易性(Commutative,如单纯的加减操作)的长流程场景。而在必须严防资源超卖或对中间状态可见性敏感的高并发金融/电商核心链路中,TCC往往是更安全的选择。


7.4 本章小结 #

本章我们从数据操作的逻辑正确性出发,系统地探讨了分布式系统事务技术。作为构建可靠系统的基石,事务技术屏蔽了底层硬件故障、并发冲突与网络不确定性带来的复杂性,为上层应用提供了"全有或全无"的原子性承诺。

首先,我们立足于单机数据库,深入剖析了 ACID 特性的实现原理。我们了解到,原子性(A)与持久性(D)并非凭空而来,而是依赖于 Undo Log 与 Redo Log(WAL)的日志机制,分别实现了故障回滚与崩溃恢复。在并发控制领域,我们探讨了从悲观的 两阶段锁(2PL) 到乐观的 MVCC(多版本并发控制) 的演进。特别是 MVCC,通过维护数据的多版本历史,实现了"读写互不阻塞"的高效并发模式,已成为现代主流数据库的标准实现。然而,我们也指出,即便在 MVCC 下,依然存在写倾斜(Write Skew)等异常,需配合隔离级别的合理选型加以规避。

随后,我们将视野扩展至分布式系统。在跨越网络与节点的场景下,面临着部分失败与通信延迟的严峻挑战,传统的 ACID 特性受到了 CAP 定理的制约。对此,本章展示了两条截然不同的技术演进路线:

  • 追求强一致性的刚性事务:以 2PC(两阶段提交) 为代表,虽然它提供了严格的原子性保障,但也引入了同步阻塞与单点故障的风险。作为这一领域的巅峰之作,我们重点解构了 Google Spanner 的架构设计。Spanner 创造性地结合了 TrueTime 原子钟技术、Paxos 共识算法与 2PL,在全球跨洲际的尺度上实现了外部一致性(External Consistency),打破了传统观念中"强一致性无法横向扩展"的桎梏,为 NewSQL 数据库指明了方向。
  • 追求高可用的柔性事务:基于 BASE 理论,我们放弃了即时的强一致性,转而在业务层面追求最终一致性。我们详细介绍了 TCC(Try-Confirm-Cancel) 模式,通过业务资源的预留机制实现更细粒度的控制;以及 SAGA 模式,通过正向操作与补偿操作的编排,解决长运行事务(Long Lived Transaction)的效率问题。这两类方案虽然对业务代码有较强的侵入性,但在互联网高并发场景下提供了极佳的系统可用性。

综上所述,分布式事务并没有"银弹"。从单机的 ACID 到分布式的 2PC/3PC,再到 Spanner 的 TrueTime 创新,以及 TCC/SAGA 的业务妥协,本质上都是在数据一致性(Consistency)、系统可用性(Availability) 与 性能(Performance) 之间寻找最佳的平衡点。作为系统设计者,理解这些技术背后的原理与代价,根据具体的业务场景做出恰当的权衡,才是掌握分布式事务的精髓所在。