ch8-事务

第八章 事务

一些作者声称,通用的两阶段提交代价太高,不应采用,因为它会带来性能或可用性问题。我们认为,当瓶颈出现时,最好让应用程序员处理因过度使用事务而导致的性能问题,而不是始终在缺乏事务的情况下编写代码。

—— James Corbett 等,《Spanner:Google 的全球分布式数据库》(2012)

早期版本读者须知

早期版本电子书让您能够在作者撰写过程中获取原始且未经编辑的内容,从而在正式出版前提前了解这些技术。

本章将是最终书籍的第 8 章。本书的 GitHub 仓库位于 https://github.com/ept/ddia2-feedback

如果您希望积极参与本草案的审阅和评论,请在 GitHub 上联系我们。


在数据系统的严酷现实中,许多问题都可能发生:

  • 数据库软件或硬件可能随时故障(包括在写入操作过程中)。
  • 应用程序可能随时崩溃(包括在一系列操作执行到一半时)。
  • 网络中断可能意外切断应用程序与数据库的连接,或切断数据库节点之间的连接。
  • 多个客户端可能同时向数据库写入,相互覆盖彼此的更改。
  • 客户端可能读取到因部分更新而导致不合理的数据。
  • 客户端之间的竞争条件可能引发令人意外的缺陷。

为了实现可靠性,系统必须处理这些故障,并确保它们不会导致整个系统的灾难性失败。然而,实现容错机制需要大量工作。这需要仔细考虑所有可能出错的情况,并进行大量测试以确保解决方案真正有效。

几十年来,事务一直是简化这些问题的首选机制。事务是将应用程序中的多次读取和写入组合成一个逻辑单元的一种方式。从概念上讲,事务中的所有读取和写入都作为一个操作执行:要么整个事务成功(提交),要么失败(中止回滚)。如果失败,应用程序可以安全地重试。有了事务,应用程序的错误处理变得更加简单,因为它不必担心部分失败——即某些操作成功而某些操作失败的情况(无论出于何种原因)。

如果您已经使用事务多年,它们可能看起来理所当然,但我们不应将其视为理所当然。事务并非自然法则;它们是为特定目的而创建的,即简化访问数据库的应用程序的编程模型。通过使用事务,应用程序可以忽略某些潜在的错误场景和并发问题,因为数据库会代为处理(我们称之为安全保证)。

并非每个应用程序都需要事务,有时削弱事务保证或完全放弃事务也有优势(例如,为了实现更高的性能或更高的可用性)。某些安全属性可以在没有事务的情况下实现。另一方面,事务可以防止很多麻烦:例如,邮局地平线丑闻(参见”可靠性有多重要?“)的技术原因可能是底层会计系统缺乏 ACID 事务 [1]。

如何判断是否需要事务?为了回答这个问题,我们首先需要准确理解事务可以提供哪些安全保证,以及与之相关的成本。虽然事务乍一看似乎很简单,但实际上有许多微妙但重要的细节需要考虑。

在本章中,我们将研究许多可能出错的例子,并探索数据库用于防范这些问题的算法。我们将深入探讨并发控制领域,讨论可能出现的各种竞争条件,以及数据库如何实现读已提交快照隔离可串行化等隔离级别。

并发控制与单节点数据库和分布式数据库都相关。在本章后面的”分布式事务”中,我们将研究两阶段提交协议以及在分布式事务中实现原子性的挑战。


事务究竟是什么?

如今几乎所有关系型数据库,以及某些非关系型数据库,都支持事务。它们大多遵循 IBM System R 于 1975 年引入的风格,这是第一个 SQL 数据库 [2, 3, 4]。虽然某些实现细节已经改变,但总体理念在 50 年中几乎保持不变:MySQL、PostgreSQL、Oracle、SQL Server 等数据库的事务支持与 System R 惊人地相似。

2000 年代末,非关系型(NoSQL)数据库开始流行。它们旨在通过提供新的数据模型选择(参见第 3 章),以及默认包含复制(第 6 章)和分片(第 7 章)来改进关系型数据库的现状。事务是这场运动的主要牺牲品:这一代许多数据库完全放弃了事务,或者重新定义了这个词,以描述比以前理解的弱得多的保证。

围绕 NoSQL 分布式数据库的炒作导致了一种普遍观点,即事务从根本上说是不可扩展的,任何大规模系统都必须放弃事务以保持良好的性能和高可用性。最近,这种观点被证明是错误的。所谓的”NewSQL”数据库,如 CockroachDB [5]、TiDB [6]、Spanner [7]、FoundationDB [8] 和 Yugabyte,已经表明事务系统可以扩展到大数据量和高吞吐量。这些系统将分片与共识协议(第 10 章)相结合,以大规模提供强 ACID 保证。

然而,这并不意味着每个系统都必须是事务性的:与其他技术设计选择一样,事务有其优势和局限性。为了理解这些权衡,让我们深入了解事务可以提供的保证的细节——无论是在正常操作期间,还是在各种极端(但现实的)情况下。


ACID 的含义

事务提供的安全保证通常用众所周知的缩写词 ACID 来描述,代表原子性(Atomicity)一致性(Consistency)隔离性(Isolation)持久性(Durability)。它由 Theo Härder 和 Andreas Reuter 于 1983 年创造,旨在为数据库中的容错机制建立精确的术语 [9]。

然而,在实践中,一个数据库的 ACID 实现并不等于另一个数据库的实现。例如,正如我们将看到的,隔离性的含义存在很多歧义 [10]。高层理念是合理的,但细节决定成败。今天,当一个系统声称”符合 ACID”时,不清楚您实际上可以期待哪些保证。不幸的是,ACID 已经主要成为一个营销术语。

(不符合 ACID 标准的系统有时被称为 BASE,代表基本可用(Basically Available)软状态(Soft state)最终一致性(Eventual consistency) [11]。这比 ACID 的定义更加模糊。似乎 BASE 唯一合理的定义是”非 ACID”;即,它几乎可以表示任何您想要的含义。)

让我们深入研究原子性、一致性、隔离性和持久性的定义,因为这将使我们完善对事务的理解。

原子性

一般来说,原子指的是无法分解为更小部分的东西。这个词在计算的不同分支中含义相似但略有不同。例如,在多线程编程中,如果一个线程执行原子操作,这意味着另一个线程无法看到该操作的半成品结果。系统只能处于操作之前或之后的状态,而不能处于中间状态。

相比之下,在 ACID 的上下文中,原子性与并发无关。它并不描述如果多个进程同时尝试访问相同数据会发生什么,因为这由字母 I(隔离性)涵盖(参见”隔离性”)。

相反,ACID 原子性描述的是如果客户端想要进行多次写入,但在部分写入处理后发生故障时会发生什么——例如,进程崩溃、网络连接中断、磁盘已满或某些完整性约束被违反。如果写入被分组到原子事务中,并且由于故障无法完成(提交)事务,则事务被中止,数据库必须丢弃或撤销该事务迄今为止所做的任何写入。

如果没有原子性,如果在进行多次更改的过程中发生错误,很难知道哪些更改已经生效,哪些没有。应用程序可以重试,但这有重复进行相同更改的风险,导致重复或不正确的数据。原子性简化了这个问题:如果事务被中止,应用程序可以确定它没有改变任何东西,因此可以安全地重试。

在错误时中止事务并丢弃该事务的所有写入的能力是 ACID 原子性的定义特征。也许可中止性是比原子性更好的术语,但我们将坚持使用原子性,因为这是通常使用的词。

一致性

一致性这个词被严重超载:

  • 在第 6 章中,我们讨论了副本一致性和异步复制系统中出现的最终一致性问题(参见”复制延迟的问题”)。
  • 数据库的一致性快照,例如用于备份,是整个数据库在某一时刻存在的快照。更准确地说,它与happens-before 关系一致(参见""happens-before”关系与并发”):也就是说,如果快照包含在特定时间写入的值,那么它也反映该值写入之前发生的所有写入。
  • 一致性哈希是某些系统用于重新平衡的分片方法(参见”一致性哈希”)。
  • 在 CAP 定理中(参见第 10 章),一致性一词用于表示线性一致性(参见”线性一致性”)。
  • 在 ACID 的上下文中,一致性指的是数据库处于”良好状态”的特定于应用程序的概念。

不幸的是,同一个词至少有五种不同的含义。

ACID 一致性的理念是,您对数据有某些陈述(不变式),这些陈述必须始终为真——例如,在会计系统中,所有账户的借方和贷方必须始终平衡。如果事务以根据这些不变式有效的数据库开始,并且事务期间的任何写入都保持有效性,那么您可以确信不变式始终得到满足。(不变式可能在事务执行期间暂时被违反,但应在事务提交时再次得到满足。)

如果您希望数据库强制执行您的不变式,您需要将它们作为模式的一部分声明为约束。例如,外键约束唯一性约束检查约束(限制单个行中可以出现的值)通常用于建模特定类型的不变式。更复杂的一致性要求有时可以使用触发器或物化视图来建模 [12]。

然而,复杂的不变式可能难以或无法使用数据库通常提供的约束来建模。在这种情况下,应用程序有责任正确定义其事务以保持一致性。如果您写入违反不变式的坏数据,但您没有声明这些不变式,数据库无法阻止您。因此,ACID 中的 C 通常取决于应用程序如何使用数据库,而不仅仅是数据库本身的属性。

隔离性

大多数数据库由多个客户端同时访问。如果它们正在读取和写入数据库的不同部分,这不是问题,但如果它们正在访问相同的数据库记录,您可能会遇到并发问题(竞争条件)。

图 8-1 是这类问题的一个简单例子。假设您有两个客户端同时递增存储在数据库中的计数器。每个客户端需要读取当前值,加 1,然后将新值写回(假设数据库中没有内置递增操作)。在图 8-1 中,计数器应该从 42 增加到 44,因为发生了两次递增,但由于竞争条件,它实际上只增加到 43。

图 8-1:两个客户端同时递增计数器的竞争条件

ACID 意义上的隔离性意味着并发执行的事务彼此隔离:它们不能互相干扰。经典数据库教科书将隔离性形式化为可串行化,这意味着每个事务都可以假装它是整个数据库上运行的唯一事务。数据库确保当事务提交时,结果与它们串行运行(一个接一个)相同,即使实际上它们可能是并发运行的 [13]。

然而,可串行化有性能成本。在实践中,许多数据库使用比可串行化弱的隔离形式:也就是说,它们允许并发事务以有限的方式相互干扰。一些流行的数据库,如 Oracle,甚至不实现它(Oracle 有一个称为”可串行化”的隔离级别,但它实际上实现的是快照隔离,这是比可串行化弱的保证 [10, 14])。这意味着某些类型的竞争条件仍然可能发生。我们将在”弱隔离级别”中探索快照隔离和其他形式的隔离。

持久性

数据库系统的目的是提供一个安全的地方来存储数据,而不必担心丢失它。持久性是承诺一旦事务成功提交,其写入的任何数据都不会被遗忘,即使发生硬件故障或数据库崩溃。

在单节点数据库中,持久性通常意味着数据已写入非易失性存储,如硬盘或 SSD。常规文件写入通常在发送到磁盘之前在内存中缓冲,这意味着如果突然断电,它们会丢失;因此,许多数据库使用 fsync() 系统调用来确保数据确实已写入磁盘。数据库通常还有预写日志或类似机制(参见”使 B 树可靠”),允许它们在崩溃发生在写入过程中时恢复。

在复制数据库中,持久性可能意味着数据已成功复制到某些数量的节点。为了提供持久性保证,数据库必须等待这些写入或复制完成,然后才报告事务已成功提交。然而,正如”可靠性与容错性”中讨论的,完美的持久性不存在:如果您的所有硬盘和所有备份同时被销毁,显然您的数据库无法挽救您。

复制与持久性

历史上,持久性意味着写入归档磁带。然后它被理解为写入磁盘或 SSD。最近,它已被调整为意味着复制。哪种实现更好?

事实是,没有什么是完美的:

  • 如果您写入磁盘而机器死亡,即使您的数据没有丢失,在您修复机器或将磁盘转移到另一台机器之前,它也无法访问。复制系统可以保持可用。
  • 相关故障——停电或导致特定输入上每个节点崩溃的错误——可能一次性击倒所有副本(参见”可靠性与容错性”),丢失仅在内存中的任何数据。因此,写入磁盘对于复制数据库仍然相关。
  • 在异步复制系统中,当领导者不可用时,最近的写入可能会丢失(参见”处理节点故障”)。
  • 当电源突然切断时,SSD 尤其被证明有时会违反它们应该提供的保证:即使 fsync 也不能保证正确工作 [15]。磁盘固件可能有错误,就像任何其他类型的软件一样 [16, 17],例如在恰好 32,768 小时运行后导致驱动器故障 [18]。而且 fsync 很难使用;即使 PostgreSQL 也错误地使用了它超过 20 年 [19, 20, 21]。
  • 存储引擎和文件系统实现之间的微妙交互可能导致难以追踪的错误,并可能在崩溃后导致磁盘上的文件损坏 [22, 23]。一个副本上的文件系统错误有时会传播到其他副本 [24]。
  • 磁盘上的数据可能在未被检测到的情况下逐渐损坏 [25, 26]。如果数据已损坏一段时间,副本和最近的备份也可能已损坏。在这种情况下,您需要尝试从历史备份中恢复数据。
  • 一项对 SSD 的研究发现,30% 到 80% 的驱动器在运行的前四年内至少产生一个坏块,其中只有部分可以通过固件纠正 [27]。磁性硬盘的坏扇区率较低,但完全故障率高于 SSD。
  • 当磨损的 SSD(经历了多次写入/擦除循环)断电时,它可能在几周到几个月的时间尺度内开始丢失数据,具体取决于温度 [28]。对于磨损水平较低的驱动器,这不是问题 [29]。

在实践中,没有一种技术可以提供绝对保证。只有各种风险降低技术,包括写入磁盘、复制到远程机器和备份——它们可以而且应该一起使用。一如既往,对任何理论”保证”持健康的怀疑态度是明智的。


单对象与多对象操作

回顾一下,在 ACID 中,原子性隔离性描述了如果客户端在同一事务中进行多次写入,数据库应该做什么:

原子性 如果在一系列写入过程中发生错误,事务应该被中止,并且到目前为止所做的写入应该被丢弃。换句话说,数据库通过提供全有或全无的保证,使您不必担心部分失败。

隔离性 并发运行的事务不应相互干扰。例如,如果一个事务进行多次写入,那么另一个事务应该看到这些写入的全部或没有一个,而不是某些子集。

这些定义假设您想要同时修改多个对象(行、文档、记录)。如果多个数据片段需要保持同步,通常需要这种多对象事务。图 8-2 显示了一个来自电子邮件应用程序的例子。要显示用户的未读邮件数量,您可以查询:

SELECT COUNT(*) FROM emails
WHERE recipient_id = 2 AND unread_flag = true

图 8-2:违反隔离性:一个事务读取另一个事务的未提交写入(“脏读”)

然而,如果有很多邮件,您可能会发现这个查询太慢,并决定将未读邮件数量存储在单独的字段中(一种反规范化,我们在”规范化、反规范化和连接”中讨论)。现在,每当有新邮件到达时,您必须同时递增未读计数器,每当邮件被标记为已读时,您也必须递减未读计数器。

在图 8-2 中,用户 2 遇到了异常:邮箱列表显示有一封未读邮件,但计数器显示零封未读邮件,因为计数器递增尚未发生。(如果电子邮件应用程序中的不正确计数器看起来太微不足道,请考虑客户账户余额而不是未读计数器,以及付款交易而不是电子邮件。)隔离性会通过确保用户 2 要么看到插入的电子邮件和更新的计数器,要么两者都看不到,而不是不一致的中间状态,来防止这个问题。

图 8-3 说明了原子性的需求:如果在事务过程中某处发生错误,邮箱内容和未读计数器可能会变得不同步。在原子事务中,如果计数器更新失败,事务被中止,插入的电子邮件被回滚。

图 8-3:原子性确保如果发生错误,该事务的任何先前写入都会被撤销,以避免不一致状态

多对象事务需要某种方法来确定哪些读取和写入操作属于同一事务。在关系型数据库中,这通常基于客户端与数据库服务器的 TCP 连接:在任何特定连接上,BEGIN TRANSACTIONCOMMIT 语句之间的所有内容都被视为同一事务的一部分。如果 TCP 连接中断,事务必须被中止。

另一方面,许多非关系型数据库没有这样的方法来组合操作。即使有多对象 API(例如,键值存储可能有一个 multi-put 操作,可以在一个操作中更新多个键),这也不一定意味着它具有事务语义:命令可能对某些键成功,对其他键失败,使数据库处于部分更新状态。

单对象写入

当单个对象被更改时,原子性和隔离性也适用。例如,想象您正在向数据库写入 20 KB 的 JSON 文档:

  • 如果在发送前 10 KB 后网络连接中断,数据库会存储那个无法解析的 10 KB JSON 片段吗?
  • 如果数据库在磁盘上覆盖先前值的过程中断电,您最终会得到新旧值拼接在一起的结果吗?
  • 如果另一个客户端在写入过程中读取该文档,它会看到部分更新的值吗?

这些问题会令人非常困惑,因此存储引擎几乎普遍旨在在一个节点上的单个对象(如键值对)级别提供原子性和隔离性。原子性可以使用日志实现崩溃恢复(参见”使 B 树可靠”),隔离性可以使用每个对象上的锁实现(一次只允许一个线程访问对象)。

一些数据库还提供更复杂的原子操作,例如递增操作,这消除了在图 8-1 中进行读取-修改-写入循环的需要。同样流行的是条件写入操作,它允许写入仅在值未被其他人并发更改时发生(参见”条件写入(比较并设置)”),类似于共享内存并发中的比较并设置或比较并交换(CAS)操作。

注意 严格来说,术语原子递增使用的是多线程编程意义上的原子一词。在 ACID 的上下文中,它实际上应该被称为隔离可串行化递增,但这不是通常使用的术语。

这些单对象操作很有用,因为它们可以防止多个客户端同时尝试写入同一对象时的丢失更新(参见”防止丢失更新”)。然而,它们不是通常意义上的事务。例如,Cassandra 和 ScyllaDB 的”轻量级事务”功能,以及 Aerospike 的”强一致性”模式,提供单个对象上的线性化(参见”线性一致性”)读取和条件写入,但不提供跨多个对象的保证。

多对象事务的必要性

我们究竟需要多对象事务吗?是否可能仅使用键值数据模型和单对象操作来实现任何应用程序?

在某些用例中,单对象插入、更新和删除就足够了。然而,在许多其他情况下,需要协调对多个不同对象的写入:

  • 在关系数据模型中,一个表中的行通常具有对另一个表中行的外键引用。类似地,在图状数据模型中,顶点具有指向其他顶点的边。多对象事务允许您确保这些引用保持有效:当插入相互引用的多个记录时,外键必须正确且最新,否则数据变得毫无意义。
  • 在文档数据模型中,需要一起更新的字段通常在同一文档中,该文档被视为单个对象——更新单个文档时不需要多对象事务。然而,缺乏连接功能的文档数据库也鼓励反规范化(参见”何时使用哪种模型”)。当需要更新反规范化信息时,如图 8-2 的例子,您需要一次性更新多个文档。在这种情况下,事务非常有用,可以防止反规范化数据失去同步。
  • 在具有二级索引的数据库中(几乎所有纯键值存储之外的数据库),每次更改值时也需要更新索引。从事务的角度来看,这些索引是不同的数据库对象:例如,如果没有事务隔离性,记录可能出现在一个索引中但不在另一个索引中,因为第二个索引的更新尚未发生(参见”分片与二级索引”)。

这样的应用程序仍然可以在没有事务的情况下实现。然而,没有原子性,错误处理变得更加复杂,缺乏隔离性可能导致并发问题。我们将在”弱隔离级别”中讨论这些问题,并在[链接待补充]中探索替代方法。

处理错误和中止

事务的一个关键特性是,如果发生错误,它可以被中止并安全重试。ACID 数据库基于这种理念:如果数据库有违反其原子性、隔离性或持久性保证的危险,它宁愿完全放弃事务,也不愿让其保持半完成状态。

然而,并非所有系统都遵循这种理念。特别是,具有无主复制的数据存储(参见”无主复制”)更多地以”尽力而为”的方式工作,这可以概括为”数据库会尽其所能,如果遇到错误,它不会撤销已经做过的事情”——因此应用程序有责任从错误中恢复。

错误不可避免地会发生,但许多软件开发人员更喜欢只考虑快乐路径,而不是错误处理的复杂性。例如,流行的对象关系映射(ORM)框架,如 Rails 的 ActiveRecord 和 Django,不会重试中止的事务——错误通常会导致异常冒泡到堆栈,因此任何用户输入都会被丢弃,用户会收到错误消息。这很遗憾,因为中止的全部意义在于实现安全重试。

虽然重试中止的事务是一种简单有效的错误处理机制,但它并不完美:

  • 如果事务实际上成功了,但在服务器尝试向客户端确认成功提交时网络中断(因此从客户端的角度看超时),那么重试事务会导致它执行两次——除非您有额外的应用程序级去重机制。
  • 如果错误是由于过载或事务之间的高争用造成的,重试事务会使问题变得更糟,而不是更好。为了避免这种反馈循环,您可以限制重试次数,使用指数退避,并以不同方式处理与过载相关的错误与其他错误(参见”当过载系统无法恢复时”)。
  • 仅在瞬态错误(例如由于死锁、隔离性违反、临时网络中断和故障转移)之后才值得重试;在永久错误(例如约束违反)之后,重试是没有意义的。
  • 如果事务在数据库之外也有副作用,即使事务被中止,这些副作用也可能发生。例如,如果您正在发送电子邮件,您不会希望每次重试事务时都再次发送电子邮件。如果您想确保几个不同的系统要么一起提交要么一起中止,两阶段提交可以提供帮助(我们将在”两阶段提交(2PC)“中讨论)。
  • 如果客户端进程在重试时崩溃,它试图写入数据库的任何数据都会丢失。

弱隔离级别

如果两个事务不访问相同的数据,或者两者都是只读的,它们可以安全地并行运行,因为彼此不依赖。只有当以下情况出现时,并发问题(竞争条件)才会发挥作用:一个事务读取另一个事务并发修改的数据,或者两个事务尝试修改相同的数据。

并发缺陷很难通过测试发现,因为这种缺陷只有在时机不巧时才会触发。这种时机问题可能非常罕见,通常难以重现。并发性也很难推理,特别是在大型应用程序中,您不一定知道哪些其他代码正在访问数据库。如果一次只有一个用户,应用程序开发已经足够困难;有许多并发用户会使情况变得更加困难,因为任何数据都可能在任何时候意外更改。

因此,数据库长期以来一直试图通过提供事务隔离向应用程序开发人员隐藏并发问题。理论上,隔离应该让您的生活更轻松,让您假装没有并发发生:可串行化隔离意味着数据库保证事务具有与它们串行运行(即一个接一个,没有任何并发)相同的效果。

在实践中,隔离性 unfortunately 并不那么简单。可串行化隔离有性能成本,许多数据库不想为此付出代价 [10]。因此,系统通常使用弱隔离级别,这在某些方面保护免受并发问题,但不是全部。这些隔离级别更难理解,它们可能导致微妙的错误,但它们 nevertheless 在实践中被使用 [30]。

弱事务隔离引起的并发错误不仅仅是理论问题。它们已造成大量金钱损失 [31, 32, 33],导致财务审计员调查 [34],并导致客户数据损坏 [35]。对此类问题 revelations 的一个流行评论是”如果您正在处理财务数据,请使用 ACID 数据库!“——但这 misses the point。即使许多流行的关系型数据库系统(通常被认为是”ACID”)也使用弱隔离,因此它们不一定能阻止这些错误的发生。

注意 顺便说一句,大部分银行系统依赖于通过安全 FTP 交换的文本文件 [36]。在这种情况下,拥有审计跟踪和某些人工级别的欺诈预防措施实际上比 ACID 属性更重要。

这些例子还突出了一个重要观点:即使在正常操作中并发问题很少见,您也必须考虑攻击者故意向您的 API 发送大量高度并发请求以试图故意利用并发错误的可能性 [31]。因此,为了构建可靠且安全的应用程序,您必须确保系统地防止此类错误。

在本节中,我们将研究实践中使用的几种弱(非可串行化)隔离级别,并详细讨论哪些类型的竞争条件可以发生,哪些不能发生,以便您可以决定哪种级别适合您的应用程序。完成此操作后,我们将详细讨论可串行化(参见”可串行化”)。我们对隔离级别的讨论将是非正式的,使用示例。如果您想要严格的定义和属性分析,可以在学术文献中找到 [37, 38, 39, 40]。

读已提交

最基本的事务隔离级别是读已提交。它提供两个保证:

  1. 从数据库读取时,您只会看到已提交的数据(无脏读)。
  2. 写入数据库时,您只会覆盖已提交的数据(无脏写)。

某些数据库支持更弱的隔离级别,称为读未提交。它防止脏写,但不防止脏读。让我们更详细地讨论这两个保证。

无脏读

想象一个事务已向数据库写入一些数据,但该事务尚未提交或中止。另一个事务能看到该未提交的数据吗?如果可以,这称为脏读 [3]。

在读已提交隔离级别运行的事务必须防止脏读。这意味着事务的任何写入仅在事务提交时才对其他人可见(然后其所有写入同时变得可见)。这在图 8-4 中说明,其中用户 1 已将 x 设置为 3,但用户 2 的 get x 仍返回旧值 2,而用户 1 尚未提交。

图 8-4:无脏读:用户 2 仅在用户 1 的事务提交后才看到 x 的新值

防止脏读有几个原因:

  • 如果事务需要更新多行,脏读意味着另一个事务可能看到部分更新但不是全部。例如,在图 8-2 中,用户看到新的未读邮件,但看不到更新的计数器。这是对邮件的脏读。看到数据库处于部分更新状态对用户来说很困惑,可能导致其他事务做出错误决策。
  • 如果事务中止,其任何写入都需要回滚(如图 8-3)。如果数据库允许脏读,这意味着事务可能看到稍后回滚的数据——即实际上从未提交到数据库的数据。任何读取未提交数据的事务也需要被中止,导致称为级联中止的问题。

无脏写

如果两个事务同时尝试更新数据库中的同一行会发生什么?我们不知道写入将以何种顺序发生,但我们通常假设后面的写入覆盖前面的写入。

然而,如果前面的写入是尚未提交的事务的一部分,因此后面的写入覆盖了未提交的值,会发生什么?这称为脏写 [37]。在读已提交隔离级别运行的事务必须防止脏写,通常通过延迟第二个写入直到第一个写入的事务提交或中止。

通过防止脏写,这种隔离级别避免了一些并发问题:

  • 如果事务更新多行,脏写可能导致不良结果。例如,考虑图 8-5,它说明了一个二手车销售网站,Aaliyah 和 Bryce 同时尝试购买同一辆车。购买汽车需要两次数据库写入:网站上的列表需要更新以反映买家,销售发票需要发送给买家。在图 8-5 的情况下,销售授予 Bryce(因为他对列表表执行了获胜更新),但发票发送给 Aaliyah(因为她对发票表执行了获胜更新)。读已提交可以防止此类事故。

  • 然而,读已提交不能防止图 8-1 中两个计数器递增之间的竞争条件。在这种情况下,第二次写入发生在第一次事务提交之后,因此它不是脏写。它仍然不正确,但出于不同的原因——我们将在”防止丢失更新”中讨论如何使此类计数器递增安全。

图 8-5:通过脏写,来自不同事务的冲突写入可能混在一起

实现读已提交

读已提交是一个非常流行的隔离级别。它是 Oracle 数据库、PostgreSQL、SQL Server 和许多其他数据库的默认设置 [10]。

最常见的是,数据库通过使用行级锁来防止脏写:当事务想要修改特定行(或文档或其他对象)时,它必须首先获取该行上的锁。然后它必须持有该锁直到事务提交或中止。任何给定行只能有一个事务持有锁;如果另一个事务想要写入同一行,它必须等待第一个事务提交或中止后才能获取锁并继续。这种锁定由数据库在读已提交模式(或更强的隔离级别)中自动完成。

我们如何防止脏读?一个选项是使用相同的锁,并要求任何想要读取行的事务短暂获取锁,然后在读取后立即释放。这将确保读取不会在行具有脏的、未提交的值时发生(因为在那段时间,锁将由已进行写入的事务持有)。

然而,在实践中,要求读锁的方法效果不佳,因为一个长时间运行的写入事务可能迫使许多其他事务等待,直到长时间运行的事务完成,即使其他事务只读取而不向数据库写入任何内容。这会损害只读事务的响应时间,并且对可操作性不利:应用程序某一部分的减速可能对应用程序完全不同的另一部分产生连锁反应,因为等待锁。

然而,锁在某些数据库中用于防止脏读,例如 IBM Db2 和 read_committed_snapshot=off 设置的 Microsoft SQL Server [30]。

防止脏读的更常用方法是图 8-4 中说明的方法:对于写入的每一行,数据库记住旧提交值和当前持有写入锁的事务设置的新值。当事务正在进行时,任何其他读取该行的事务只是被给予旧值。只有当新值提交后,事务才切换到读取新值(有关更多详细信息,参见”多版本并发控制(MVCC)”)。

快照隔离与可重复读

如果您肤浅地看读已提交隔离,您可能会原谅地认为它做了事务需要做的一切:它允许中止(原子性所需),它防止读取事务的不完整结果,它防止并发写入混杂。确实,这些是有用的功能,比您从无事务系统中获得的保证强得多。

然而,使用这种隔离级别时,仍然有很多方法可能出现并发错误。例如,图 8-6 说明了读已提交可能出现的问题。

图 8-6:读偏斜:Aaliyah 观察到数据库处于不一致状态

假设 Aaliyah 在银行有 1,000 美元存款,分布在两个账户中,每个账户 500 美元。现在一个事务将 100 美元从她的一个账户转移到另一个账户。如果她很不幸,在事务处理的那一刻查看她的账户余额列表,她可能会看到一个账户余额在 incoming 付款到达之前(余额为 500 美元),另一个账户在 outgoing 转账之后(新余额为 400 美元)。对 Aaliyah 来说,现在看起来她总共只有 900 美元——似乎 100 美元凭空消失了。

这种异常称为读偏斜,它是不可重复读的一个例子:如果 Aaliyah 在事务结束时再次读取账户 1 的余额,她会看到与她之前查询不同的值(600 美元)。读偏斜在读已提交隔离下被认为是可接受的:Aaliyah 看到的账户余额确实在她读取时已提交。

注意 不幸的是,术语偏斜被超载:我们之前在偏斜工作负载和缓解热点的意义上使用了它,而这里它意味着时序异常

在 Aaliyah 的情况下,这不是一个持久的问题,因为如果她几秒钟后重新加载网上银行网站,她很可能会看到一致的账户余额。然而,某些情况不能容忍这种临时不一致:

备份 进行备份需要复制整个数据库,这在大型数据库上可能需要数小时。在备份过程运行期间,写入将继续对数据库进行。因此,您可能最终得到备份的某些部分包含数据的旧版本,其他部分包含新版本。如果需要从此类备份恢复,不一致(如消失的钱)将成为永久性的。

分析查询和完整性检查 有时,您可能想要运行扫描数据库大部分的查询。此类查询在分析中很常见(参见”分析型与操作型系统”),或者可能是定期检查一切是否正常的完整性检查的一部分(监控数据损坏)。如果它们观察到数据库在不同时间点的部分,这些查询可能返回无意义的结果。

快照隔离 [37] 是此问题的最常见解决方案。其理念是每个事务从数据库的一致快照中读取——即事务看到数据库中事务开始时已提交的所有数据。即使数据随后被另一个事务更改,每个事务也只看到该特定时间点的旧数据。

快照隔离对于长时间运行的只读查询(如备份和分析)是一大福音。如果数据在查询执行的同时发生变化,很难推理查询的含义。当事务可以看到数据库在特定时间点冻结的一致快照时,理解起来容易得多。

快照隔离是一个流行的功能:PostgreSQL、使用 InnoDB 存储引擎的 MySQL、Oracle、SQL Server 等的变体都支持它,尽管详细行为因系统而异 [30, 41, 42]。某些数据库,如 Oracle、TiDB 和 Aurora DSQL,甚至选择快照隔离作为其最高隔离级别。

多版本并发控制(MVCC)

与读已提交隔离一样,快照隔离的实现通常使用写锁来防止脏写(参见”实现读已提交”),这意味着进行写入的事务可以阻止另一个写入同一行的事务的进度。然而,读取不需要任何锁。从性能角度来看,快照隔离的一个关键原则是读者从不阻塞写者,写者从不阻塞读者。这允许数据库在处理写入的同时,在一致快照上处理长时间运行的读取查询,两者之间没有任何锁争用。

为了实现快照隔离,数据库使用我们在图 8-4 中看到的防止脏读机制的推广。数据库必须潜在地保留一行的多个不同提交版本,因为各种正在进行的事务可能需要看到数据库在不同时间点的状态。因为它并排维护一行的多个版本,这种技术称为多版本并发控制(MVCC)

图 8-7 说明了基于 MVCC 的快照隔离在 PostgreSQL 中的实现方式 [41, 43, 44](其他实现类似)。当事务开始时,它被赋予一个唯一的、始终递增的事务 ID(txid)。当事务向数据库写入任何内容时,它写入的数据都标有写入者的事务 ID。(准确地说,PostgreSQL 中的事务 ID 是 32 位整数,因此在大约 40 亿事务后溢出。vacuum 进程执行清理以确保溢出不影响数据。)

图 8-7:使用多版本并发控制实现快照隔离

表中的每一行都有一个 inserted_by 字段,包含将此行插入表的事务的 ID。此外,每行都有一个 deleted_by 字段,最初为空。如果事务删除一行,该行实际上不会从数据库中删除,而是通过将 deleted_by 字段设置为请求删除的事务的 ID 来标记为删除。稍后,当确定没有事务可以再访问已删除数据时,数据库中的垃圾收集进程会删除标记为删除的任何行并释放其空间。

更新在内部被转换为删除插入 [45]。例如,在图 8-7 中,事务 13 从账户 2 中扣除 100 美元,将余额从 500 美元更改为 400 美元。accounts 表现在实际上包含账户 2 的两行:一行余额为 500 美元,被事务 13 标记为删除,以及一行余额为 400 美元,由事务 13 插入。

一行的所有版本都存储在同一数据库堆中(参见”在索引中存储值”),无论写入它们的事务是否已提交。同一行的版本形成链表,从新版本到旧版本或相反方向,以便查询可以在内部迭代一行的所有版本 [46, 47]。

观察一致快照的可见性规则

当事务从数据库读取时,使用事务 ID 来决定它可以看到哪些行版本,哪些是不可见的。通过仔细定义可见性规则,数据库可以向应用程序呈现数据库的一致快照。这大致如下工作 [44]:

  1. 在每个事务开始时,数据库列出当时正在进行的(尚未提交或中止的)所有其他事务。这些事务所做的任何写入都被忽略,即使这些事务随后提交。这确保我们看到不受另一个事务提交影响的一致快照。
  2. 忽略由具有较晚事务 ID 的事务所做的任何写入(即,在当前事务开始之后开始的事务,因此不包含在正在进行的事务列表中),无论这些事务是否已提交。
  3. 忽略由中止事务所做的任何写入,无论中止何时发生。这有优势,当事务中止时,我们不需要立即从存储中删除它写入的行,因为可见性规则会过滤它们。垃圾收集进程可以稍后删除它们。
  4. 所有其他写入对应用程序的查询可见。

这些规则适用于插入和删除行。在图 8-7 中,当事务 12 从账户 2 读取时,它看到 500 美元的余额,因为 500 美元余额的删除是由事务 13 进行的(根据规则 2,事务 12 看不到事务 13 进行的删除),而 400 美元余额的插入尚不可见(根据相同规则)。

换句话说,如果满足以下两个条件,则行可见:

  • 在读者事务开始时,插入行的事务已经提交。
  • 行未被标记为删除,或者如果是,请求删除的事务在读者事务开始时尚未提交。

长时间运行的事务可能会继续使用快照很长时间,继续读取(从其他事务的角度看)早已被覆盖或删除的值。通过从不就地更新值,而是每次更改值时插入新版本,数据库可以在仅产生少量开销的情况下提供一致快照。

索引与快照隔离

在多版本数据库中,索引如何工作?最常见的方法是每个索引条目指向匹配该条目的行的一个版本(最旧或最新版本)。每个行版本可能包含对下一个最旧或下一个最新版本的引用。使用索引的查询必须在行上迭代以找到可见的,并且值匹配查询正在寻找的内容。当垃圾收集删除对任何事务不再可见的旧行版本时,相应的索引条目也可以被删除。

许多实现细节影响多版本并发控制的性能 [46, 47]。例如,PostgreSQL 有优化,如果同一行的不同版本可以放在同一页上,则避免索引更新 [41]。某些其他数据库避免存储修改行的完整副本,只存储版本之间的差异以节省空间。

CouchDB、Datomic 和 LMDB 使用另一种方法。虽然它们也使用 B 树(参见”B 树”),但它们使用不可变(写时复制)变体,在更新时不覆盖树的页面,而是创建每个修改页面的新副本。父页面,一直到树的根,都被复制并更新以指向其子页面的新版本。不受写入影响的任何页面都不需要复制,可以与新树共享 [48]。

使用不可变 B 树,每个写入事务(或事务批次)创建一个新的 B 树根,特定根是创建时数据库的一致快照。不需要基于事务 ID 过滤行,因为后续写入不能修改现有 B 树;它们只能创建新的树根。这种方法还需要用于压缩和垃圾收集的后台进程。

快照隔离、可重复读和命名混乱

MVCC 是数据库中常用的实现技术,通常用于实现快照隔离。然而,不同数据库有时使用不同术语来指代相同的东西:例如,快照隔离在 PostgreSQL 中称为”可重复读”,在 Oracle 中称为”可串行化” [30]。有时不同系统使用相同术语表示不同含义:例如,虽然在 PostgreSQL 中”可重复读”意味着快照隔离,但在 MySQL 中它意味着比快照隔离一致性弱的 MVCC 实现 [42]。

这种命名混乱的原因是 SQL 标准没有快照隔离的概念,因为该标准基于 System R 1975 年的隔离级别定义 [3],而快照隔离当时尚未发明。相反,它定义了可重复读,表面上与快照隔离相似。PostgreSQL 称其快照隔离级别为”可重复读”,因为它符合标准的要求,因此他们可以声称符合标准。

不幸的是,SQL 标准对隔离级别的定义是有缺陷的——它是模糊的、不精确的,不像标准应该的那样独立于实现 [37]。尽管多个数据库实现了可重复读,但它们实际提供的保证存在很大差异,尽管表面上已标准化 [30]。研究文献中有可重复读的正式定义 [38, 39],但大多数实现不满足该正式定义。更糟糕的是,IBM Db2 使用”可重复读”来指代可串行化 [10]。

因此,没有人真正知道可重复读意味着什么。

防止丢失更新

迄今为止我们讨论的读已提交和快照隔离级别主要关注只读事务在并发写入存在时的保证。我们大多忽略了两个事务同时写入的问题——我们只讨论了脏写(参见”无脏写”),一种可能发生的特定类型的写写冲突。

并发写入事务之间可能发生几种其他有趣的冲突。其中最著名的是丢失更新问题,如图 8-1 中两个并发计数器递增的例子所示。

如果应用程序从数据库读取某个值,修改它,然后将修改后的值写回(读取-修改-写入循环),则可能发生丢失更新问题。如果两个事务同时执行此操作,其中一个修改可能会丢失,因为第二次写入不包含第一次修改。(我们有时说后面的写入覆盖了前面的写入。)这种模式出现在各种不同的场景中:

  • 递增计数器或更新账户余额(需要读取当前值,计算新值,然后写回更新的值)
  • 对复杂值进行本地更改,例如向 JSON 文档中的列表添加元素(需要解析文档,进行更改,然后写回修改后的文档)
  • 两个用户同时编辑维基页面,每个用户通过将整页内容发送到服务器来保存其更改,覆盖数据库中当前的内容

因为这是如此常见的问题,已经开发了各种解决方案 [49]。

原子写操作

许多数据库提供原子更新操作,消除了在应用程序代码中实现读取-修改-写入循环的需要。如果您的代码可以用这些操作表示,它们通常是最佳解决方案。例如,以下指令在大多数关系型数据库中是并发安全的:

UPDATE counters SET value = value + 1 WHERE key = 'foo';

类似地,MongoDB 等文档数据库提供对 JSON 文档部分进行本地修改的原子操作,Redis 提供修改数据结构(如优先队列)的原子操作。并非所有写入都可以轻松用原子操作表示——例如,维基页面的更新涉及任意文本编辑,可以使用”CRDT 与操作转换”中讨论的算法处理——但在可以使用原子操作的情况下,它们通常是最佳选择。

原子操作通常通过在读取对象时获取其上的排他锁来实现,以便在更新应用之前没有其他事务可以读取它。另一个选项是简单地强制所有原子操作在单个线程上执行。

不幸的是,对象关系映射(ORM)框架使意外编写执行不安全读取-修改-写入循环的代码而不是使用数据库提供的原子操作变得容易 [50, 51, 52]。这可能是难以通过测试发现的微妙错误的来源。

显式锁定

如果数据库的内置原子操作不提供所需功能,防止丢失更新的另一个选项是应用程序显式锁定将要更新的对象。然后应用程序可以执行读取-修改-写入循环,如果任何其他事务尝试并发更新或锁定同一对象,它被迫等待直到第一个读取-修改-写入循环完成。

例如,考虑一个多人在线游戏,多个玩家可以同时移动同一棋子。在这种情况下,原子操作可能不够,因为应用程序还需要确保玩家的移动遵守游戏规则,这涉及一些无法合理实现为数据库查询的逻辑。相反,您可以使用锁来防止两个玩家同时移动同一棋子,如示例 8-1 所示。

示例 8-1:显式锁定行以防止丢失更新

BEGIN TRANSACTION;

SELECT * FROM figures
  WHERE name = 'robot' AND game_id = 222
  FOR UPDATE;  -- 1

-- 检查移动是否有效,然后更新
-- 由前一个 SELECT 返回的棋子的位置。
UPDATE figures SET position = 'c4' WHERE id = 1234;

COMMIT;

¹ FOR UPDATE 子句指示数据库应该对此查询返回的所有行获取锁。

这有效,但要正确实现,您需要仔细考虑应用程序逻辑。很容易忘记在代码的某个地方添加必要的锁,从而引入竞争条件。

此外,如果您锁定多个对象,则有死锁的风险,其中两个或更多事务相互等待对方释放其锁。许多数据库自动检测死锁,并中止其中一个涉及的事务,以便系统可以取得进展。您可以通过重试中止的事务在应用程序级别处理这种情况。

自动检测丢失更新

原子操作和锁是通过强制读取-修改-写入循环顺序发生来防止丢失更新的方法。自动检测丢失更新是一种替代方法,允许它们并行执行,如果事务管理器检测到丢失更新,则中止事务并强制其重试其读取-修改-写入循环。

这种方法的优势在于,数据库可以在与快照隔离结合的情况下高效地执行此检查。实际上,PostgreSQL 的可重复读、Oracle 的可串行化和 SQL Server 的快照隔离级别自动检测何时发生丢失更新并中止违规事务。然而,MySQL/InnoDB 的可重复读不检测丢失更新 [30, 42]。一些作者 [37, 39] 认为数据库必须防止丢失更新才能被视为提供快照隔离,因此根据此定义,MySQL 不提供快照隔离。

丢失更新检测是一个很棒的功能,因为它不需要应用程序代码使用任何特殊的数据库功能——您可能忘记使用锁或原子操作,从而引入错误,但丢失更新检测自动发生,因此不太容易出错。然而,您还需要在应用程序级别重试中止的事务。

条件写入(比较并设置)

在不提供事务的数据库中,您有时会找到条件写入操作,它允许更新仅在值自上次读取以来未更改时才发生(以前在”单对象写入”中提到过)。如果当前值与您之前读取的不匹配,更新无效,必须重试读取-修改-写入循环。它是许多 CPU 支持的原子比较并设置或比较并交换(CAS)指令的数据库等效物。

例如,为了防止两个用户同时更新同一维基页面,您可以尝试以下操作,期望更新仅在页面内容自用户开始编辑以来未更改时才发生:

-- 这可能安全也可能不安全,取决于数据库实现
UPDATE wiki_pages SET content = 'new content'
  WHERE id = 1234 AND content = 'old content';

如果内容已更改且不再匹配 'old content',此更新将无效,因此您需要检查更新是否生效,并在必要时重试。您也可以使用版本号列,每次更新时递增,并仅在当前版本号未更改时才应用更新。这种方法有时称为乐观锁定 [53]。

请注意,如果另一个事务已并发修改内容,根据 MVCC 可见性规则,新内容可能不可见(参见”观察一致快照的可见性规则”)。许多 MVCC 实现对此场景有可见性规则的例外,其中其他事务所做的值对 UPDATEDELETE 查询的 WHERE 子句的评估可见,即使这些写入在快照中否则不可见。

冲突解决与复制

在复制数据库中(参见第 6 章),防止丢失更新具有另一个维度:由于它们在多个节点上有数据副本,并且数据可能潜在地在不同节点上并发修改,因此需要采取一些额外步骤来防止丢失更新。

锁和条件写入操作假设数据的单个最新副本存在。然而,具有多领导者无主复制的数据库通常允许几个写入同时发生并异步复制,因此它们不能保证数据的单个最新副本存在。因此,基于锁或条件写入的技术在此上下文中不适用。(我们将在”线性一致性”中更详细地重新审视这个问题。)

相反,如”处理冲突写入”中讨论的,此类复制数据库中的常见方法是允许并发写入创建值的多个冲突版本(也称为兄弟),并使用应用程序代码或特殊数据结构来事后解决和合并这些版本。

如果更新是可交换的(即,您可以在不同副本上以不同顺序应用它们,仍然获得相同结果),则合并冲突值可以防止丢失更新。例如,递增计数器或向集合添加元素是可交换操作。这就是我们在”CRDT 与操作转换”中遇到的 CRDT 背后的理念。然而,某些操作(如条件写入)不能制成可交换的。

另一方面,**最后写入获胜(LWW)**冲突解决方法容易出现丢失更新,如”最后写入获胜(丢弃并发写入)“中所讨论的。不幸的是,LWW 是许多复制数据库中的默认设置。

写偏斜与幻读

在前面的章节中,我们看到了脏写丢失更新,当不同事务并发尝试写入相同对象时可能发生的两种竞争条件。为了避免数据损坏,需要防止这些竞争条件——要么由数据库自动防止,要么通过使用锁或原子写操作等手动保护措施。

然而,这不是并发写入之间可能发生的潜在竞争条件的完整列表。在本节中,我们将看到一些更微妙的冲突例子。

首先,想象这个例子:您正在为医生编写一个应用程序,用于管理他们在医院的值班班次。医院通常试图在任何时间都有几位医生值班,但它绝对必须在任何班次至少有一位医生值班。医生可以放弃他们的班次(例如,如果他们自己生病了),前提是至少有一位同事在该班次值班 [54, 55]。

现在想象 Aaliyah 和 Bryce 是特定班次的两位值班医生。两人都感觉不适,因此他们都决定请求休假。不幸的是,他们碰巧在大约同一时间点击了下班按钮。接下来发生的事情如图 8-8 所示。

图 8-8:写偏斜导致应用程序错误的例子

在每个事务中,您的应用程序首先检查当前是否有两位或更多医生值班;如果是,它假设一位医生下班是安全的。由于数据库使用快照隔离,两次检查都返回 2,因此两个事务都进入下一阶段。Aaliyah 更新她自己的记录以使自己下班,Bryce 同样更新他自己的记录。两个事务都提交,现在没有医生值班。您至少有一位医生值班的要求被违反了。

写偏斜的特征

这种异常称为写偏斜 [37]。它既不是脏写也不是丢失更新,因为两个事务正在更新两个不同的对象(分别是 Aaliyah 和 Bryce 的值班记录)。冲突发生在这里不太明显,但这肯定是一个竞争条件:如果两个事务一个接一个地运行,第二位医生将被阻止下班。异常行为只有在事务并发运行时才可能。

您可以将写偏斜视为丢失更新问题的推广。如果两个事务读取相同的对象,然后更新其中一些对象(不同事务可能更新不同对象),则可能发生写偏斜。在特殊情况下,不同事务更新相同对象时,根据时机,您会得到脏写或丢失更新异常。

我们看到有各种不同的方法可以防止丢失更新。对于写偏斜,我们的选择更受限制:

  • 原子单对象操作没有帮助,因为涉及多个对象。
  • 您在快照隔离的某些实现中发现的自动丢失更新检测 unfortunately 也没有帮助:写偏斜在 PostgreSQL 的可重复读、MySQL/InnoDB 的可重复读、Oracle 的可串行化或 SQL Server 的快照隔离级别中都不会自动检测 [30]。自动防止写偏斜需要真正的可串行化隔离(参见”可串行化”)。
  • 某些数据库允许您配置约束,然后由数据库强制执行(例如,唯一性、外键约束或对特定值的限制)。然而,为了指定至少一位医生必须值班,您需要涉及多个对象的约束。大多数数据库没有对此类约束的内置支持,但您可能可以使用触发器或物化视图来实现它们,如”一致性”中所讨论的 [12]。
  • 如果您不能使用可串行化隔离级别,在这种情况下,第二好的选择可能是显式锁定事务依赖的行。在医生示例中,您可以编写如下内容:
BEGIN TRANSACTION;

SELECT * FROM doctors
  WHERE on_call = true
  AND shift_id = 1234 FOR UPDATE;  -- 1

UPDATE doctors
  SET on_call = false
  WHERE name = 'Aaliyah'
  AND shift_id = 1234;

COMMIT;

¹ 与之前一样,FOR UPDATE 告诉数据库锁定此查询返回的所有行。

更多写偏斜的例子

写偏斜起初可能看起来是一个深奥的问题,但一旦您意识到它,您可能会注意到更多可能发生的情况。以下是更多例子:

会议室预订系统 假设您要强制执行同一会议室在同一时间不能有两个预订 [56]。当有人想要进行预订时,您首先检查任何冲突的预订(即,同一会议室具有重叠时间范围的预订),如果没有找到,则创建会议(参见示例 8-2)。

示例 8-2:会议室预订系统尝试避免双重预订(在快照隔离下不安全)

BEGIN TRANSACTION;

-- 检查是否有任何现有预订与中午-下午 1 点的时间段重叠
SELECT COUNT(*) FROM bookings
  WHERE room_id = 123 AND
    end_time > '2025-01-01 12:00' AND start_time < '2025-01-01 13:00';

-- 如果前一个查询返回零:
INSERT INTO bookings
  (room_id, start_time, end_time, user_id)
  VALUES (123, '2025-01-01 12:00', '2025-01-01 13:00', 666);

COMMIT;

不幸的是,快照隔离不能阻止另一个用户并发插入冲突的会议。为了保证您不会得到调度冲突,您再次需要可串行化隔离。

多人游戏 在示例 8-1 中,我们使用锁来防止丢失更新(即,确保两个玩家不能同时移动同一棋子)。然而,锁不能阻止玩家将两个不同的棋子移动到棋盘上的同一位置,或可能做出违反游戏规则的其他移动。根据您正在强制执行的规则类型,您可能可以使用唯一约束,否则您容易受到写偏斜的影响。

声明用户名 在每位用户具有唯一用户名的网站上,两个用户可能同时尝试创建具有相同用户名的账户。您可能使用事务来检查名称是否被占用,如果没有,则使用该名称创建账户。然而,与前面的例子一样,这在快照隔离下不安全。幸运的是,唯一约束在这里是一个简单的解决方案(第二个尝试注册用户名的事务将因违反约束而被中止)。

防止双重消费 允许用户消费金钱或积分的服务需要检查用户消费不超过他们拥有的金额。您可能通过将暂定消费项插入用户账户,列出账户中的所有项目,并检查总和是否为正值来实现这一点。通过写偏斜,可能两个消费项并发插入,共同导致余额变为负数,但两个事务都没有注意到另一个。

导致写偏斜的幻读

所有这些例子都遵循类似的模式:

  1. SELECT 查询通过搜索匹配某些搜索条件的行来检查是否满足某些要求(至少两位医生值班,该房间该时间没有现有预订,棋盘上的位置没有另一个棋子,用户名尚未被占用,账户中还有钱)。
  2. 根据第一个查询的结果,应用程序代码决定如何继续(也许继续操作,也许向用户报告错误并中止)。
  3. 如果应用程序决定继续,它会向数据库进行写入(INSERTUPDATEDELETE)并提交事务。

此写入的效果改变了步骤 2 决策的前提条件。换句话说,如果您在提交写入后重复步骤 1 的 SELECT 查询,您会得到不同的结果,因为写入更改了匹配搜索条件的行集合(现在少了一位医生值班,会议室现在已预订该时间,棋盘上的位置现在被移动的棋子占据,用户名现在被占用,账户中的钱现在少了)。

步骤可能以不同的顺序发生。例如,您可以先进行写入,然后进行 SELECT 查询,最后根据查询结果决定是否中止或提交。

在医生值班示例中,步骤 3 中修改的行是步骤 1 返回的行之一,因此我们可以通过锁定步骤 1 中的行(SELECT FOR UPDATE)使事务安全并避免写偏斜。然而,其他四个例子不同:它们检查匹配某些搜索条件的行的不存在,而写入添加了匹配相同条件的行。如果步骤 1 中的查询没有返回任何行,SELECT FOR UPDATE 无法附加锁到任何东西 [57]。

这种一个事务中的写入改变另一个事务搜索查询结果的效果称为幻读 [4]。快照隔离在只读查询中避免幻读,但在像我们讨论的读写事务中,幻读可能导致特别棘手的写偏斜情况。ORM 生成的 SQL 也容易受到写偏斜的影响 [51, 52]。

物化冲突

如果幻读的问题是没有我们可以附加锁的对象,也许我们可以人为地向数据库引入锁对象?

例如,在会议室预订情况下,您可以想象创建一个时间段和房间的表。此表中的每一行对应特定时间段的特定房间(例如,15 分钟)。您提前创建所有可能的房间和时间段组合的行,例如未来六个月。

现在,想要创建预订的事务可以锁定(SELECT FOR UPDATE)表中对应所需房间和时间段的行。获得锁后,它可以检查重叠的预订并插入新预订,如前所述。请注意,附加表不用于存储有关预订的信息——它纯粹是锁的集合,用于防止同一房间和时间范围的预订被并发修改。

这种方法称为物化冲突,因为它将幻读转化为数据库中存在的具体行集合上的锁冲突 [14]。不幸的是,弄清楚如何物化冲突可能很困难且容易出错,让并发控制机制泄漏到应用程序数据模型中也很丑陋。因此,如果没有其他选择,物化冲突应被视为最后的手段。在大多数情况下,可串行化隔离级别是更可取的。


可串行化

在本章中,我们看到了几个容易发生竞争条件的事务例子。某些竞争条件由读已提交和快照隔离级别防止,但其他竞争条件则不能。我们在写偏斜和幻读方面遇到了一些特别棘手的例子。这是一个可悲的情况:

  • 隔离级别很难理解,在不同数据库中实现不一致(例如,“可重复读”的含义差异显著)。
  • 如果您查看应用程序代码,很难判断在特定隔离级别下运行是否安全——特别是在大型应用程序中,您可能不知道所有可能并发发生的事情。
  • 没有好的工具帮助我们检测竞争条件。原则上,静态分析可能有帮助 [34],但研究技术尚未进入实际使用。测试并发问题很困难,因为它们通常是非确定性的——问题只有在您时机不巧时才会出现。

这不是一个新问题——自 1970 年代弱隔离级别首次引入以来一直如此 [3]。一直以来,研究人员的答案很简单:使用可串行化隔离

可串行化隔离是最强的隔离级别。它保证即使事务可能并行执行,最终结果也与它们串行执行(一个接一个,没有任何并发)相同。因此,数据库保证如果事务在单独运行时行为正确,它们在并发运行时继续正确——换句话说,数据库防止所有可能的竞争条件。

但如果可串行化隔离比弱隔离级别的混乱好得多,为什么不是每个人都在使用它?为了回答这个问题,我们需要看看实现可串行化的选项,以及它们的表现如何。今天大多数提供可串行化的数据库使用三种技术之一,我们将在本章的其余部分探讨:

  1. 按字面顺序串行执行事务(参见”实际串行执行”)
  2. 两阶段锁定(参见”两阶段锁定(2PL)”),几十年来这是唯一可行的选择
  3. 乐观并发控制技术,如可串行化快照隔离(参见”可串行化快照隔离(SSI)“)

实际串行执行

避免并发问题的最简单方法是完全消除并发:在单个线程上按串行顺序一次只执行一个事务。通过这样做,我们完全回避了检测和防止事务之间冲突的问题:由此产生的隔离性按定义是可串行化的。

尽管这似乎是一个显而易见的想法,但直到 2000 年代数据库设计人员才决定单线程循环执行事务是可行的 [58]。如果多线程并发在之前的 30 年被认为是获得良好性能的必要条件,是什么改变了使单线程执行成为可能?

两个发展导致了这种重新思考:

  1. RAM 变得足够便宜,对于许多用例来说,现在可以将整个活动数据集保留在内存中是可行的(参见”将所有内容保留在内存中”)。当事务需要访问的所有数据都在内存中时,事务可以比必须等待从磁盘加载数据时执行得快得多。
  2. 数据库设计人员意识到 OLTP 事务通常很短,只进行少量读取和写入(参见”分析型与操作型系统”)。相比之下,长时间运行的分析查询通常是只读的,因此可以在串行执行循环之外的一致快照上使用快照隔离运行。

串行执行事务的方法在 VoltDB/H-Store、Redis 和 Datomic 等中实现 [59, 60, 61]。为单线程执行设计的系统有时可以比支持并发的系统表现更好,因为它可以避免锁定的协调开销。然而,其吞吐量限于单个 CPU 核心的吞吐量。为了充分利用该单线程,事务需要以不同于其传统形式的方式构建。

在存储过程中封装事务

在数据库的早期,意图是数据库事务可以涵盖整个用户活动流程。例如,预订机票是一个多阶段过程(搜索路线、票价和可用座位;决定行程;预订行程中每个航班的座位;输入乘客详细信息;付款)。数据库设计人员认为,如果整个过程是一个事务,以便它可以原子地提交,那将是很好的。

不幸的是,人类做决定和响应非常慢。如果数据库事务需要等待用户输入,数据库需要支持潜在的大量并发事务,其中大多数处于空闲状态。大多数数据库无法有效地做到这一点,因此几乎所有 OLTP 应用程序都通过避免在事务内交互式等待用户来保持事务简短。在 Web 上,这意味着事务在同一 HTTP 请求内提交——事务不跨越多个请求。新的 HTTP 请求启动新事务。

即使人类已被排除在关键路径之外,事务仍以交互式客户端/服务器风格继续执行,一次一个语句。应用程序进行查询,读取结果,可能根据第一个查询的结果进行另一个查询,依此类推。查询和结果在应用程序代码(在一台机器上运行)和数据库服务器(在另一台机器上)之间来回发送。

在这种交互式事务风格中,大量时间花在应用程序和数据库之间的网络通信上。如果您不允许数据库中的并发,一次只处理一个事务,吞吐量将是可怕的,因为数据库将花费大部分时间等待应用程序发出当前事务的下一个查询。在这种数据库中,需要并发处理多个事务以获得合理的性能。

因此,具有单线程串行事务处理的系统不允许交互式多语句事务。相反,应用程序必须要么将自己限制为包含单个语句的事务,要么提前将整个事务代码提交给数据库,作为存储过程 [62]。

交互式事务和存储过程之间的差异如图 8-9 所示。假设事务所需的所有数据都在内存中,存储过程可以非常快速地执行,无需等待任何网络或磁盘 I/O。

图 8-9:交互式事务和存储过程之间的区别(使用图 8-8 的示例事务)

存储过程的优缺点

存储过程在关系型数据库中已经存在一段时间,自 1999 年以来一直是 SQL 标准(SQL/PSM)的一部分。它们因各种原因而声誉不佳:

  • 传统上,每个数据库供应商都有自己的存储过程语言(Oracle 有 PL/SQL,SQL Server 有 T-SQL,PostgreSQL 有 PL/pgSQL 等)。这些语言没有跟上通用编程语言的发展,因此从今天来看它们看起来相当丑陋和古老,而且它们缺乏大多数编程语言的库生态系统。
  • 在数据库中运行的代码难以管理:与应用程序服务器相比,更难调试、更难版本控制和部署、更难测试,并且难以与指标收集系统集成以进行监控。
  • 数据库通常比应用程序服务器对性能更敏感,因为单个数据库实例通常由许多应用程序服务器共享。数据库中编写不当的存储过程(例如,使用大量内存或 CPU 时间)可能比应用程序服务器中等效的编写不佳的代码造成更多麻烦。
  • 在允许租户编写自己的存储程序的多租户系统中,在与数据库内核相同的进程中执行不受信任的代码是安全风险 [63]。

然而,这些问题是可以克服的。存储过程的现代实现已经放弃了 PL/SQL,转而使用现有的通用编程语言:VoltDB 使用 Java 或 Groovy,Datomic 使用 Java 或 Clojure,Redis 使用 Lua,MongoDB 使用 JavaScript。

存储过程在应用程序逻辑不易嵌入其他地方的情况下也很有用。例如,使用 GraphQL 的应用程序可能直接通过 GraphQL 代理公开其数据库。如果代理不支持复杂的验证逻辑,您可以使用存储过程直接在数据库中嵌入此类逻辑。如果数据库不支持存储过程,您将不得不在代理和数据库之间部署验证服务来进行验证。

有了存储过程和内存数据,在单个线程上执行所有事务变得可行。当存储过程不需要等待 I/O 并避免其他并发控制机制的开销时,它们可以在单个线程上实现相当好的吞吐量。

VoltDB 还使用存储过程进行复制:不是将事务的写入从一个节点复制到另一个节点,而是在每个副本上执行相同的存储过程。因此,VoltDB 要求存储过程是确定性的(在不同节点上运行时,它们必须产生相同结果)。如果事务需要使用当前日期和时间,例如,它必须通过特殊的确定性 API 进行(有关确定性操作的更多详细信息,参见”持久执行与工作流”)。这种方法称为状态机复制,我们将在第 10 章中回到它。

分片

串行执行所有事务使并发控制更简单,但将数据库的事务吞吐量限制为单台机器上单个 CPU 核心的速度。只读事务可以在其他地方使用快照隔离执行,但对于具有高写入吞吐量的应用程序,单线程事务处理器可能成为严重的瓶颈。

为了扩展到多个 CPU 核心和多个节点,您可以对数据进行分片(参见第 7 章),这在 VoltDB 中得到支持。如果您能找到一种分片数据集的方法,使每个事务只需要在单个分片内读取和写入数据,那么每个分片可以有自己的事务处理线程,独立于其他分片运行。在这种情况下,您可以给每个 CPU 核心自己的分片,这使您的事务吞吐量随 CPU 核心数量线性扩展 [60]。

然而,对于需要访问多个分片的任何事务,数据库必须在它触及的所有分片上协调事务。存储过程需要在所有分片上同步执行,以确保整个系统的可串行化。

由于跨分片事务有额外的协调开销,它们比单分片事务慢得多。VoltDB 报告每秒约 1,000 次跨分片写入的吞吐量,这比其单分片吞吐量低几个数量级,无法通过添加更多机器来增加 [62]。最近的研究探索了使多分片事务更具可扩展性的方法 [64]。

事务是否可以是单分片很大程度上取决于应用程序使用的数据结构。简单的键值数据通常可以很容易地分片,但具有多个二级索引的数据可能需要大量跨分片协调(参见”分片与二级索引”)。

串行执行总结

串行执行事务在某些约束内已成为实现可串行化隔离的可行方式:

  • 每个事务必须小而快,因为只需要一个慢事务就会阻塞所有事务处理。
  • 它最适合活动数据集可以放入内存的情况。很少访问的数据可能潜在地移动到磁盘,但如果需要在单线程事务中访问它,系统将变得非常慢。
  • 写入吞吐量必须足够低,以在单个 CPU 核心上处理,或者事务需要分片而不需要跨分片协调。
  • 跨分片事务是可能的,但它们的吞吐量难以扩展。

两阶段锁定(2PL)

大约 30 年来,数据库中可串行化只有一种广泛使用的算法:两阶段锁定(2PL),有时称为**强严格两阶段锁定(SS2PL)**以区别于 2PL 的其他变体。

2PL 不是 2PC 两阶段锁定(2PL)和两阶段提交(2PC)是两个非常不同的东西。2PL 提供可串行化隔离,而 2PC 在分布式数据库中提供原子提交(参见”两阶段提交(2PC)”)。为避免混淆,最好将它们视为完全独立的概念,并忽略名称中不幸的相似性。

我们之前看到锁通常用于防止脏写(参见”无脏写”):如果两个事务并发尝试写入同一对象,锁确保第二个写入者必须等待第一个完成其事务(中止或提交)后才能继续。

两阶段锁定类似,但使锁定要求强得多。只要没有人写入,就允许几个事务并发读取同一对象。但一旦有人想要写入(修改或删除)对象,就需要独占访问

  • 如果事务 A 已读取对象,事务 B 想要写入该对象,B 必须等待直到 A 提交或中止才能继续。(这确保 B 不能在 A 背后意外更改对象。)
  • 如果事务 A 已写入对象,事务 B 想要读取该对象,B 必须等待直到 A 提交或中止才能继续。(在 2PL 下,读取对象的旧版本,如图 8-4 中那样,是不可接受的。)

在 2PL 中,写入者不仅阻塞其他写入者;它们还阻塞读取者,反之亦然。快照隔离有读者从不阻塞写入者,写入者从不阻塞读者的座右铭(参见”多版本并发控制(MVCC)”),这捕捉了快照隔离和两阶段锁定之间的关键差异。另一方面,因为 2PL 提供可串行化,它防止所有前面讨论的竞争条件,包括丢失更新和写偏斜。

两阶段锁定的实现

2PL 由 MySQL(InnoDB)和 SQL Server 中的可串行化隔离级别,以及 Db2 中的可重复读隔离级别使用 [30]。

读者和写入者的阻塞是通过在数据库中的每个对象上有一个锁来实现的。锁可以处于共享模式独占模式(也称为多读单写锁)。锁的使用如下:

  • 如果事务想要读取对象,它必须首先以共享模式获取锁。几个事务可以同时以共享模式持有锁,但如果另一个事务已具有对象的独占锁,这些事务必须等待。
  • 如果事务想要写入对象,它必须首先以独占模式获取锁。没有其他事务可以同时持有锁(无论是共享还是独占模式),因此如果对象上有任何现有锁,事务必须等待。
  • 如果事务首先读取然后写入对象,它可能将其共享锁升级为独占锁。升级的工作方式与直接获取独占锁相同。
  • 事务获取锁后,必须继续持有锁直到事务结束(提交或中止)。这就是”两阶段”名称的来源:第一阶段(事务执行时)是获取锁,第二阶段(事务结束时)是释放所有锁。

由于使用了如此多的锁,很容易发生事务 A 等待事务 B 释放其锁,反之亦然的情况。这种情况称为死锁。数据库自动检测事务之间的死锁并中止其中一个,以便其他事务可以取得进展。中止的事务需要由应用程序重试。

两阶段锁定的性能

两阶段锁定的大缺点,以及自 1970 年代以来它不是大多数系统默认设置的原因,是性能:两阶段锁定下的事务吞吐量和查询响应时间明显比弱隔离下差。

这部分是由于获取和释放所有这些锁的开销,但更重要的是由于并发性降低。根据设计,如果两个并发事务尝试做任何可能以任何方式导致竞争条件的事情,其中一个必须等待另一个完成。

例如,如果您有一个需要读取整个表的事务(例如备份、分析查询或完整性检查,如”快照隔离与可重复读”中所讨论的),该事务必须获取整个表上的共享锁。因此,读取事务首先必须等待所有正在进行的写入该表的事务完成;然后,在整个表被读取时(在大型表上可能需要很长时间),所有其他想要写入该表的事务都被阻塞,直到大型只读事务提交。实际上,数据库在很长一段时间内对写入不可用。

因此,运行 2PL 的数据库可能具有相当不稳定的延迟,如果工作负载中存在争用,它们在高百分位数上可能非常慢(参见”描述性能”)。可能只需要一个慢事务,或一个访问大量数据并获取许多锁的事务,就会导致系统的其余部分陷入停顿。

虽然死锁可能发生在基于锁的读已提交隔离级别,但它们在 2PL 可串行化隔离下发生得更频繁(取决于事务的访问模式)。这可能是额外的性能问题:当事务因死锁而中止并重试时,它需要重新完成所有工作。如果死锁频繁,这可能意味着大量的浪费工作。

谓词锁

在前面锁的描述中,我们忽略了一个微妙但重要的细节。在”导致写偏斜的幻读”中,我们讨论了幻读的问题——即一个事务更改另一个事务搜索查询的结果。具有可串行化隔离的数据库必须防止幻读。

在会议室预订示例中,这意味着如果一个事务已搜索特定时间段内某个房间的现有预订(参见示例 8-2),则不允许另一个事务并发插入或更新同一房间和时间范围的另一个预订。(并发插入其他房间的预订,或同一房间不同时间的预订,不影响拟议预订,是可以的。)

我们如何实现这一点?概念上,我们需要一个谓词锁 [4]。它的工作方式与前面描述的共享/独占锁类似,但它不属于特定对象(例如表中的一行),而是属于匹配某些搜索条件的所有对象,例如:

SELECT * FROM bookings
  WHERE room_id = 123 AND
    end_time   > '2025-01-01 12:00' AND
    start_time < '2025-01-01 13:00';

谓词锁限制访问如下:

  • 如果事务 A 想要读取匹配某些条件的对象,如该 SELECT 查询,它必须获取查询条件的共享模式谓词锁。如果另一个事务 B 当前对匹配这些条件的任何对象具有独占锁,A 必须等待直到 B 释放其锁,然后才被允许进行其查询。
  • 如果事务 A 想要插入、更新或删除任何对象,它必须首先检查旧值或新值是否匹配任何现有谓词锁。如果存在事务 B 持有的匹配谓词锁,则 A 必须等待直到 B 已提交或中止,然后才能继续。

这里的关键思想是,谓词锁适用于数据库中尚不存在但将来可能添加的对象(幻读)。如果两阶段锁定包括谓词锁,数据库防止所有形式的写偏斜和其他竞争条件,因此其隔离性变为可串行化。

索引范围锁

不幸的是,谓词锁性能不佳:如果有许多活动事务的锁,检查匹配锁变得耗时。因此,大多数具有 2PL 的数据库实际上实现索引范围锁定(也称为下一键锁定),这是谓词锁的简化近似 [55, 65]。

通过使谓词匹配更大的对象集来简化谓词是安全的。例如,如果您有房间 123 中午到下午 1 点预订的谓词锁,您可以通过锁定任何时间房间 123 的预订来近似它,或者您可以通过锁定中午到下午 1 点所有房间(不仅仅是房间 123)的预订来近似它。这是安全的,因为任何匹配原始谓词的写入肯定也会匹配近似。

在房间预订数据库中,您可能在 room_id 列上有索引,和/或在 start_timeend_time 上有索引(否则前面的查询在大型数据库上会非常慢):

  • 假设您的索引在 room_id 上,数据库使用此索引查找房间 123 的现有预订。现在数据库可以简单地将共享锁附加到此索引条目,指示事务已搜索房间 123 的预订。
  • 或者,如果数据库使用基于时间的索引查找现有预订,它可以将共享锁附加到该索引中的值范围,指示事务已搜索与 2025 年 1 月 1 日中午到下午 1 点时间段重叠的预订。

无论哪种方式,搜索条件的近似都附加到其中一个索引。现在,如果另一个事务想要插入、更新或删除同一房间和/或重叠时间段的预订,它将不得不更新索引的相同部分。在此过程中,它将遇到共享锁,并被迫等待直到锁被释放。

这提供了对幻读和写偏斜的有效保护。索引范围锁不如谓词锁精确(它们可能锁定比严格维护可串行化所需更大的对象范围),但由于开销低得多,它们是很好的折衷。

如果没有合适的索引可以附加范围锁,数据库可以回退到整个表上的共享锁。这对性能不利,因为它会阻止所有其他事务写入表,但这是一个安全的回退位置。

可串行化快照隔离(SSI)

本章描绘了数据库中并发控制的惨淡图景。一方面,我们有性能不佳(两阶段锁定)或扩展性不佳(串行执行)的可串行化实现。另一方面,我们有性能良好但容易出现各种竞争条件(丢失更新、写偏斜、幻读等)的弱隔离级别。可串行化隔离和良好性能从根本上是矛盾的吗?

似乎不是:一种称为**可串行化快照隔离(SSI)**的算法以仅比快照隔离小的性能损失提供完全可串行化。SSI 相对较新:它于 2008 年首次描述 [54, 66]。

今天,SSI 和类似算法用于单节点数据库(PostgreSQL [55]、SQL Server 的 In-Memory OLTP/Hekaton [67] 和 HyPer [68] 中的可串行化隔离级别)、分布式数据库(CockroachDB [5] 和 FoundationDB [8])以及嵌入式存储引擎(如 BadgerDB)。

悲观与乐观并发控制

两阶段锁定是一种所谓的悲观并发控制机制:它基于这样的原则,即如果可能出任何问题(由另一个事务持有的锁指示),最好等到情况再次安全后再做任何事情。它类似于多线程编程中用于保护数据结构的互斥。

串行执行,在某种意义上,是极端的悲观:它本质上等同于每个事务在事务持续期间持有整个数据库(或数据库的一个分片)的独占锁。我们通过使每个事务执行得非常快来补偿这种悲观,因此它只需要持有”锁”很短时间。

相比之下,可串行化快照隔离是一种乐观并发控制技术。在这种情况下,乐观意味着不是阻塞如果发生潜在危险的事情,事务无论如何继续,希望一切都会好起来。当事务想要提交时,数据库检查是否发生了任何坏事(即是否违反了隔离性);如果是,事务被中止并必须重试。只有可串行化执行的事务才被允许提交。

乐观并发控制是一个古老的想法 [69],其优缺点已被长期争论 [70]。如果存在高争用(许多事务尝试访问相同对象),它表现不佳,因为这导致需要中止的事务比例很高。如果系统已接近其最大吞吐量,来自重试事务的额外事务负载可能使性能更差。

然而,如果有足够的备用容量,并且事务之间的争用不是太高,乐观并发控制技术往往比悲观技术表现更好。争用可以通过可交换原子操作来减少:例如,如果几个事务并发想要递增计数器,递增应用的顺序无关紧要(只要事务中不读取计数器),因此可以应用并发递增而不冲突。

顾名思义,SSI 基于快照隔离——即事务中的所有读取都是从数据库的一致快照进行的(参见”快照隔离与可重复读”)。在快照隔离之上,SSI 添加了一个用于检测读写之间串行化冲突的算法,并确定要中止哪些事务。

基于过时前提的决策

当我们之前讨论快照隔离中的写偏斜时(参见”写偏斜与幻读”),我们观察到一个反复出现的模式:事务从数据库读取一些数据,检查查询结果,并根据看到的结果决定采取某些行动(写入数据库)。然而,在快照隔离下,原始查询的结果在事务提交时可能不再是最新的,因为数据可能在此期间被修改。

换句话说,事务正在基于前提(事务开始时为真的事实,例如”目前有两位医生值班”)采取行动。稍后,当事务想要提交时,原始数据可能已更改——前提可能不再为真。

当应用程序进行查询(例如”目前有多少医生值班?“)时,数据库不知道应用程序逻辑如何使用该查询的结果。为了安全起见,数据库需要假设查询结果的任何更改(前提)意味着该事务中的写入可能无效。换句话说,事务中的查询和写入之间可能存在因果依赖。为了提供可串行化隔离,数据库必须检测事务可能基于过时前提采取行动的情况,并在这种情况下中止事务。

数据库如何知道查询结果是否可能已更改?有两种情况需要考虑:

  1. 检测对过时 MVCC 对象版本的读取(未提交写入发生在读取之前)
  2. 检测影响先前读取的写入(写入发生在读取之后)

检测过时 MVCC 读取

回想一下,快照隔离通常通过**多版本并发控制(MVCC)**实现(参见”多版本并发控制(MVCC)”)。当事务从 MVCC 数据库中的一致快照读取时,它忽略由快照拍摄时尚未提交的任何其他事务所做的写入。

在图 8-10 中,事务 43 看到 Aaliyah 的 on_call = true,因为事务 42(修改了 Aaliyah 的值班状态)未提交。然而,到事务 43 想要提交时,事务 42 已经提交。这意味着读取一致快照时忽略的写入现在已经生效,事务 43 的前提不再为真。当写入者插入以前不存在的数据时,情况变得更加复杂(参见”导致写偏斜的幻读”)。我们将在”检测影响先前读取的写入”中讨论 SSI 的幻写检测。

图 8-10:检测事务何时从 MVCC 快照读取过时值

为了防止这种异常,数据库需要跟踪事务何时由于 MVCC 可见性规则而忽略另一个事务的写入。当事务想要提交时,数据库检查任何被忽略的写入现在是否已提交。如果是,事务必须被中止。

为什么要等到提交?为什么不在检测到过时时立即中止事务 43?嗯,如果事务 43 是只读事务,它不需要被中止,因为没有写偏斜的风险。当事务 43 进行读取时,数据库还不知道该事务稍后是否会执行写入。此外,事务 42 可能仍会中止或在事务 43 提交时仍未提交,因此读取可能最终并未过时。通过避免不必要的中止,SSI 保留了快照隔离对从一致快照进行长时间读取的支持。

检测影响先前读取的写入

第二种要考虑的情况是另一个事务在数据被读取后修改它。这种情况如图 8-11 所示。

图 8-11:在可串行化快照隔离中,检测一个事务何时修改另一个事务的读取

在两阶段锁定的上下文中,我们讨论了索引范围锁(参见”索引范围锁”),它允许数据库锁定匹配某些搜索查询的所有行,例如 WHERE shift_id = 1234。我们可以在这里使用类似的技术,只是 SSI 锁不阻塞其他事务。

在图 8-11 中,事务 42 和 43 都搜索班次 1234 的值班医生。如果 shift_id 上有索引,数据库可以使用索引条目 1234 来记录事务 42 和 43 读取了此数据的事实。(如果没有索引,此信息可以在表级别跟踪。)此信息只需要保留一段时间:事务完成(提交或中止)后,所有并发事务都完成后,数据库可以忘记它读取了什么数据。

当事务写入数据库时,它必须在索引中查找任何最近读取受影响数据的其他事务。这个过程类似于获取受影响键范围上的写锁,但锁不是阻塞直到读取者提交,而是充当绊线:它只是通知事务它们读取的数据可能不再是最新的。

在图 8-11 中,事务 43 通知事务 42 其先前读取已过时,反之亦然。事务 42 首先提交,并且成功:虽然事务 43 的写入影响了 42,但 43 尚未提交,因此写入尚未生效。然而,当事务 43 想要提交时,来自 42 的冲突写入已经提交,因此 43 必须中止。

可串行化快照隔离的性能

与往常一样,许多工程细节影响算法在实践中的工作效果。例如,一个权衡是跟踪事务读写活动的粒度。如果数据库非常详细地跟踪每个事务的活动,它可以精确地确定哪些事务需要中止,但记账开销可能变得显著。不太详细的跟踪更快,但可能导致比严格必要更多的事务被中止。

在某些情况下,事务读取被另一个事务覆盖的信息是可以的:根据发生的其他事情,有时可以证明执行结果仍然是可串行化的。PostgreSQL 使用此理论来减少不必要中止的数量 [14, 55]。

与两阶段锁定相比,可串行化快照隔离的大优势是一个事务不需要阻塞等待另一个事务持有的锁。与快照隔离下一样,写入者不阻塞读取者,反之亦然。这种设计原则使查询延迟更可预测且变化更小。特别是,只读查询可以在一致快照上运行而无需任何锁,这对读取密集型工作负载非常有吸引力。

与串行执行相比,可串行化快照隔离不限于单个 CPU 核心的吞吐量:例如,FoundationDB 将串行化冲突的检测分布在多台机器上,使其能够扩展到非常高的吞吐量。即使数据可能分片在多台机器上,事务也可以在多个分片中读写数据,同时确保可串行化隔离。

与非可串行化快照隔离相比,需要检查可串行化违规会引入一些性能开销。这些开销有多显著是一个争论的问题:一些人认为可串行化检查不值得 [71],而另一些人认为可串行化的性能现在如此之好,以至于不再需要较弱的快照隔离 [68]。

中止率显著影响 SSI 的整体性能。例如,长时间读写数据的事务可能会遇到冲突并中止,因此 SSI 要求读写事务相当短(长时间运行的只读事务是可以的)。然而,SSI 对慢事务的敏感性低于两阶段锁定或串行执行。


分布式事务

最后几节专注于隔离的并发控制,即 ACID 中的 I。我们看到的算法适用于单节点和分布式数据库:尽管在使并发控制算法可扩展方面存在挑战(例如,为 SSI 执行分布式可串行化检查),但分布式并发控制的高级思想与单节点并发控制相似 [8]。

当我们转向分布式事务时,一致性和持久性也没有太大变化。然而,原子性需要更多关注。

对于在单个数据库节点执行的事务,原子性通常由存储引擎实现。当客户端要求数据库节点提交事务时,数据库使事务的写入持久(通常在预写日志中;参见”使 B 树可靠”),然后将提交记录附加到磁盘上的日志。如果数据库在此过程中崩溃,事务在节点重新启动时从日志中恢复:如果提交记录在崩溃前成功写入磁盘,则事务被视为已提交;否则,该事务的任何写入都会回滚。

因此,在单节点上,事务提交关键取决于数据持久写入磁盘的顺序:首先是数据,然后是提交记录 [22]。决定事务提交或中止的关键时刻是磁盘完成写入提交记录的时刻:在此之前,仍然可以中止(由于崩溃),但在此之后,事务已提交(即使数据库崩溃)。因此,它是一个单一设备(连接到特定节点的特定磁盘驱动器的控制器)使提交原子化。

然而,如果多个节点参与事务呢?例如,也许您在分片数据库中有多对象事务,或全局二级索引(其中索引条目可能与主数据在不同节点上;参见”分片与二级索引”)。大多数”NoSQL”分布式数据存储不支持此类分布式事务,但各种分布式关系型数据库支持。

在这些情况下,简单地向所有节点发送提交请求并在每个节点上独立提交事务是不够的。很容易发生提交在某些节点上成功而在其他节点上失败的情况,如图 8-12 所示:

  • 某些节点可能检测到约束违规或冲突,需要中止,而其他节点能够成功提交。
  • 某些提交请求可能在网络中丢失,最终因超时而中止,而其他提交请求通过。
  • 某些节点可能在提交记录完全写入之前崩溃并在恢复时回滚,而其他节点成功提交。

图 8-12:当事务涉及多个数据库节点时,它可能在某些节点上提交而在其他节点上失败

如果某些节点提交事务但其他节点中止,节点之间变得不一致。一旦事务在一个节点上提交,如果后来发现它在另一个节点上被中止,它也不能再撤回。这是因为数据一旦提交,在读已提交或更强隔离下对其他事务可见。例如,在图 8-12 中,到用户 1 注意到其提交在数据库 1 上失败时,用户 2 已经读取了数据库 2 上同一事务的数据。如果用户 1 的事务稍后被中止,用户 2 的事务也必须被撤销,因为它基于被追溯声明为从未存在的数据。

更好的方法是确保参与事务的节点要么全部提交,要么全部中止,并防止两者的混合。确保这一点被称为原子提交问题

两阶段提交(2PC)

两阶段提交是一种在多个节点上实现原子事务提交的算法。它是分布式数据库中的经典算法 [13, 72, 73]。2PC 在某些数据库内部使用,也以 XA 事务的形式提供给应用程序 [74](例如,由 Java Transaction API 支持)或通过 WS-AtomicTransaction 用于 SOAP Web 服务 [75, 76]。

2PC 的基本流程如图 8-13 所示。与单节点事务的单个提交请求不同,2PC 中的提交/中止过程分为两个阶段(因此得名)。

图 8-13:两阶段提交(2PC)的成功执行

2PC 使用一个通常不出现在单节点事务中的新组件:协调者(也称为事务管理器)。协调者通常实现为请求事务的同一应用程序进程中的库(例如,嵌入在 Java EE 容器中),但它也可以是单独的进程或服务。此类协调者的例子包括 Narayana、JOTM、BTM 或 MSDTC。

使用 2PC 时,分布式事务以应用程序在多个数据库节点上正常读取和写入数据开始。我们称这些数据库节点为事务的参与者。当应用程序准备好提交时,协调者开始第一阶段:它向每个节点发送准备请求,询问它们是否能够提交。协调者然后跟踪参与者的响应:

  • 如果所有参与者回复”是”,表示它们准备好提交,则协调者在第二阶段发送提交请求,提交实际发生。
  • 如果任何参与者回复”否”,协调者在第二阶段向所有节点发送中止请求。

这个过程有点像西方文化中的传统婚礼仪式:牧师分别询问新娘和新郎是否愿意与对方结婚,通常从两者都收到”我愿意”的回答。收到两个确认后,牧师宣布这对夫妇为夫妻:事务已提交,这一喜讯向所有与会者广播。如果新娘或新郎不说”是”,仪式被中止 [77]。

承诺系统

从这个简短的描述中,可能不清楚为什么两阶段提交确保原子性,而跨多个节点的一阶段提交则不。当然,准备和提交请求在两种情况下都可能同样容易丢失。2PC 有什么不同?

为了理解为什么它有效,我们必须更详细地分解该过程:

  1. 当应用程序想要开始分布式事务时,它从协调者请求事务 ID。此事务 ID 是全局唯一的。
  2. 应用程序在每个参与者上开始单节点事务,并将全局唯一事务 ID 附加到单节点事务。所有读取和写入都在这些单节点事务之一中完成。如果此阶段出现任何问题(例如,节点崩溃或请求超时),协调者或任何参与者可以中止。
  3. 当应用程序准备好提交时,协调者向所有参与者发送准备请求,标记有全局事务 ID。如果任何这些请求失败或超时,协调者向所有参与者发送该事务 ID 的中止请求。
  4. 当参与者收到准备请求时,它确保在所有情况下都能确定提交事务。这包括将所有事务数据写入磁盘(崩溃、电源故障或磁盘空间不足不是稍后拒绝提交的借口),并检查任何冲突或约束违规。通过向协调者回复”是”,节点承诺如果请求则无误地提交事务。换句话说,参与者放弃中止事务的权利,但实际上并未提交它。
  5. 当协调者收到所有准备请求的响应时,它对是否提交或中止事务做出明确决定(仅当所有参与者投票”是”时才提交)。协调者必须将其决定写入其本地磁盘上的事务日志,以便在随后崩溃时知道它决定了哪种方式。这称为提交点
  6. 一旦协调者的决定已写入磁盘,提交或中止请求将发送给所有参与者。如果此请求失败或超时,协调者必须永远重试直到成功。没有回头路了:如果决定是提交,则必须强制执行该决定,无论需要多少次重试。如果参与者在此期间崩溃,事务将在其恢复时提交——由于参与者投票”是”,它在恢复时不能拒绝提交。

因此,协议包含两个关键的”不归路点”:当参与者投票”是”时,它承诺稍后肯定能够提交(尽管协调者仍可能选择中止);一旦协调者决定,该决定是不可撤销的。这些承诺确保 2PC 的原子性。(单节点原子提交将这两个事件合并为一个:将提交记录写入事务日志。)

回到婚姻类比,在说”我愿意”之前,您和您的新娘/新郎有通过说”不可能!“(或类似的话)来中止事务的自由。然而,在说”我愿意”之后,您不能撤回该声明。如果您在说”我愿意”后晕倒,没有听到牧师说”你们现在是夫妻了”,这不会改变事务已提交的事实。当您稍后恢复意识时,您可以通过查询牧师您的全局事务 ID 的状态来查明您是否已婚,或者您可以等待牧师对提交请求的下一次重试(因为重试将在您昏迷期间继续进行)。

协调者故障

我们已经讨论了如果在 2PC 期间参与者或网络故障会发生什么:如果任何准备请求失败或超时,协调者中止事务;如果任何提交或中止请求失败,协调者无限期重试它们。然而,如果协调者崩溃会发生什么,这一点不太清楚。

如果协调者在发送准备请求之前失败,参与者可以安全地中止事务。但一旦参与者收到准备请求并投票”是”,它就不能再单方面中止——它必须等待协调者回复事务是提交还是中止。如果此时协调者崩溃或网络故障,参与者除了等待外无能为力。处于此状态的参与者事务称为存疑不确定

这种情况如图 8-14 所示。在这个特定例子中,协调者实际上决定提交,数据库 2 收到了提交请求。然而,协调者在能够向数据库 1 发送提交请求之前崩溃,因此数据库 1 不知道要提交还是中止。即使超时在这里也没有帮助:如果数据库 1 在超时后单方面中止,它将与已提交的数据库 2 不一致。类似地,单方面提交也不安全,因为另一个参与者可能已中止。

图 8-14:参与者在投票”是”后协调者崩溃。数据库 1 不知道要提交还是中止

没有协调者的消息,参与者无法知道要提交还是中止。原则上,参与者可以相互通信以找出每个参与者如何投票并达成某种协议,但这不是 2PC 协议的一部分。

2PC 能够完成的唯一方式是等待协调者恢复。这就是为什么协调者必须在向参与者发送提交或中止请求之前,将其提交或中止决定写入磁盘上的事务日志:当协调者恢复时,它通过读取其事务日志来确定所有存疑事务的状态。协调者日志中没有提交记录的任何事务都被中止。因此,2PC 的提交点归结为协调者上的常规单节点原子提交。

三阶段提交

两阶段提交被称为阻塞原子提交协议,因为 2PC 可能陷入等待协调者恢复的困境。可以制作非阻塞原子提交协议,以便如果节点故障,它不会卡住。然而,在实践中使其工作并不那么简单。

作为 2PC 的替代方案,已提出一种称为三阶段提交(3PC)的算法 [13, 78]。然而,3PC 假设具有有界延迟的网络和具有有界响应时间的节点;在大多数具有无界网络延迟和进程暂停的实际系统中(参见第 9 章),它不能保证原子性。

实践中更好的解决方案是用容错共识协议替换单节点协调者。我们将在第 10 章中看到如何做到这一点。

跨不同系统的分布式事务

分布式事务和两阶段提交声誉参半。一方面,它们被视为提供难以以其他方式实现的重要安全保证;另一方面,它们因导致操作问题、扼杀性能以及承诺超过其交付能力而受到批评 [79, 80, 81, 82]。许多云服务选择不实现分布式事务,因为它们带来的操作问题 [83]。

分布式事务的某些实现会带来沉重的性能损失。两阶段提交固有的许多性能成本是由于崩溃恢复所需的额外磁盘强制(fsync)和额外的网络往返。

然而,与其完全否定分布式事务,我们应该更详细地研究它们,因为可以从中学到重要的教训。首先,我们应该精确我们所说的”分布式事务”的含义。两种截然不同的分布式事务类型经常被混为一谈:

数据库内部分布式事务 某些分布式数据库(即在其标准配置中使用复制和分片的数据库)支持该数据库节点之间的内部事务。例如,YugabyteDB、TiDB、FoundationDB、Spanner、VoltDB 和 MySQL Cluster 的 NDB 存储引擎具有此类内部事务支持。在这种情况下,参与事务的所有节点都运行相同的数据库软件。

异构分布式事务 在异构事务中,参与者是两种或更多不同的技术:例如,来自不同供应商的两个数据库,甚至非数据库系统,如消息代理。跨这些系统的分布式事务必须确保原子提交,即使底层系统可能完全不同。

数据库内部事务不必与任何其他系统兼容,因此它们可以使用任何协议并应用特定于该特定技术的优化。因此,数据库内部分布式事务通常可以很好地工作。另一方面,跨不同存储技术的事务更具挑战性。

恰好一次消息处理

异构分布式事务允许以强大的方式集成不同的系统。例如,消息队列中的消息可以仅当处理该消息的数据库事务成功提交时才被确认为已处理。这是通过将消息确认和数据库写入原子地提交在单个事务中来实现的。有了分布式事务支持,即使消息代理和数据库是运行在完全不同机器上的两种不相关技术,这也是可能的。

如果消息传递或数据库事务失败,两者都被中止,因此消息代理可以稍后安全地重新传递消息。因此,通过原子地提交消息及其处理的副作用,我们可以确保消息被有效处理恰好一次,即使它需要几次重试才成功。中止丢弃部分完成事务的任何副作用。这被称为恰好一次语义

只有当受事务影响的所有系统都能使用相同的原子提交协议时,这种分布式事务才可能。例如,假设处理消息的副作用是发送电子邮件,而电子邮件服务器不支持两阶段提交:如果消息处理失败并重试,可能发生电子邮件被发送两次或更多次的情况。但如果消息处理的所有副作用在事务中止时回滚,则处理步骤可以安全地重试,就像什么都没发生一样。

我们将在本章后面回到恰好一次语义的主题。首先让我们看看允许这种异构分布式事务的原子提交协议。

XA 事务

X/Open XA(eXtended Architecture 的缩写)是跨异构技术实现两阶段提交的标准 [74]。它于 1991 年引入,已被广泛实现:XA 得到许多传统关系型数据库(包括 PostgreSQL、MySQL、Db2、SQL Server 和 Oracle)和消息代理(包括 ActiveMQ、HornetQ、MSMQ 和 IBM MQ)的支持。

XA 不是网络协议——它只是与事务协调者接口的 C API。其他语言存在此 API 的绑定;例如,在 Java EE 应用程序的世界中,XA 事务使用 Java Transaction API(JTA) 实现,而 JTA 又得到许多使用 Java Database Connectivity(JDBC) API 的数据库驱动程序和使用 Java Message Service(JMS) API 的消息代理驱动程序的支持。

XA 假设您的应用程序使用网络驱动程序或客户端库与参与者数据库或消息服务通信。如果驱动程序支持 XA,这意味着它调用 XA API 以确定操作是否应该是分布式事务的一部分——如果是,它将必要信息发送到数据库服务器。驱动程序还公开回调,协调者可以通过这些回调要求参与者准备、提交或中止。

事务协调者实现 XA API。标准没有指定它应该如何实现,但实践中协调者通常只是加载到发出事务的同一应用程序进程中的库(不是单独的服务)。它跟踪事务中的参与者,在要求它们准备后收集参与者的响应(通过回调到驱动程序),并使用本地磁盘上的日志跟踪每个事务的提交/中止决定。

如果应用程序进程崩溃,或运行应用程序的机器死亡,协调者随之而去。然后,任何具有准备但未提交事务的参与者都陷入存疑。由于协调者的日志在应用程序服务器的本地磁盘上,必须重新启动该服务器,协调者库必须读取日志以恢复每个事务的提交/中止结果。只有这样,协调者才能使用数据库驱动程序的 XA 回调要求参与者提交或中止,视情况而定。数据库服务器不能直接联系协调者,因为所有通信必须通过其客户端库进行。

存疑时持有锁

为什么我们如此关心事务陷入存疑?系统的其余部分不能继续其工作,忽略存疑的事务,它最终会被清理吗?

问题在于锁定。如”读已提交”中所讨论的,数据库事务通常对它们修改的任何行获取行级排他锁,以防止脏写。此外,如果您想要可串行化隔离,使用两阶段锁定的数据库还需要对事务读取的任何行获取共享锁。

数据库在事务提交或中止之前不能释放这些锁(如图 8-13 中的阴影区域所示)。因此,使用两阶段提交时,事务必须在存疑期间一直持有锁。如果协调者崩溃并需要 20 分钟才能重新启动,这些锁将被持有 20 分钟。如果协调者的日志因某种原因完全丢失,这些锁将被永远持有——或至少直到管理员手动解决情况。

虽然持有这些锁,没有其他事务可以修改这些行。根据隔离级别,其他事务甚至可能无法读取这些行。因此,其他事务不能简单地继续其业务——如果它们想要访问相同的数据,它们将被阻塞。这可能导致应用程序的大部分在存疑事务解决之前不可用。

从协调者故障中恢复

理论上,如果协调者崩溃并重新启动,它应该从日志中干净地恢复其状态并解决任何存疑事务。然而,在实践中,孤立存疑事务确实会发生 [84, 85]——即协调者因某种原因无法决定结果的事务(例如,因为事务日志由于软件错误而丢失或损坏)。这些事务无法自动解决,因此它们永远坐在数据库中,持有锁并阻塞其他事务。

即使重新启动数据库服务器也无法解决此问题,因为 2PC 的正确实现必须在重新启动后保留存疑事务的锁(否则它将冒违反原子性保证的风险)。这是一个棘手的局面。

唯一的出路是管理员手动决定是提交还是回滚事务。管理员必须检查每个存疑事务的参与者,确定是否有参与者已提交或中止,然后将相同的结果应用于其他参与者。解决问题可能需要大量手动工作,很可能需要在严重的生产中断期间在高压力和时间压力下完成(否则,协调者为什么会处于如此糟糕的状态?)。

许多 XA 实现有一个紧急逃生舱口,称为启发式决策:允许参与者在没有协调者的明确决定的情况下单方面决定中止或提交存疑事务 [74]。明确地说,这里的启发式可能破坏原子性的委婉说法,因为启发式决策违反了两阶段提交中的承诺系统。因此,启发式决策仅用于摆脱灾难性情况,而不是常规使用。

XA 事务的问题

单节点协调者是整个系统的单点故障,将其作为应用程序服务器的一部分也是有问题的,因为协调者在其本地磁盘上的日志成为持久系统状态的关键部分——与数据库本身一样重要。

原则上,XA 事务的协调者可以像我们对任何其他重要数据库的期望一样高可用和复制。不幸的是,这仍然没有解决 XA 的根本问题,即它没有为协调者和事务参与者提供直接相互通信的方式。它们只能通过调用事务的应用程序代码和调用参与者的数据库驱动程序进行通信。

即使协调者是复制的,因此应用程序代码将是单点故障。解决这个问题需要完全重新设计应用程序代码的运行方式,使其可复制或可重新启动,这可能看起来类似于持久执行(参见”持久执行与工作流”)。然而,在实践中似乎没有真正采用这种方法的工具。

另一个问题是,由于 XA 需要与广泛的数据系统兼容,它必然是最低公分母。例如,它不能检测不同系统之间的死锁(因为这需要系统交换每个事务正在等待的锁信息的标准化协议),并且它不适用于 SSI(参见”可串行化快照隔离(SSI)”),因为这需要跨不同系统识别冲突的协议。

这些问题在跨异构技术执行事务时有些固有。然而,保持几个异构数据系统彼此一致仍然是一个真实而重要的问题,因此我们需要找到不同的解决方案。这可以做到,正如我们将在下一节和第 12 章中看到的。

数据库内部分布式事务

如前所述,跨多个异构存储技术的分布式事务与系统内部的事务之间存在很大差异——即所有参与节点都是运行相同软件的同一数据库的分片。此类内部分布式事务是”NewSQL”数据库(如 CockroachDB [5]、TiDB [6]、Spanner [7]、FoundationDB [8] 和 YugabyteDB)的定义特征。一些消息代理(如 Kafka)也支持内部分布式事务 [86]。

其中许多系统使用两阶段提交来确保写入多个分片的事务的原子性,但它们没有遭受与 XA 事务相同的问题。原因是,因为它们的分布式事务不需要与任何其他技术接口,它们避免了最低公分母陷阱——这些系统的设计者可以自由使用更可靠、更快的更好协议。

XA 的最大问题可以通过以下方式解决:

  • 复制协调者,如果主协调者崩溃,则自动故障转移到另一个协调者节点;
  • 允许协调者和数据分片直接通信,而不通过应用程序代码;
  • 复制参与分片,从而减少由于其中一个分片故障而必须中止事务的风险;以及
  • 将原子提交协议与支持跨分片死锁检测和一致读取的分布式并发控制协议相结合。

共识算法通常用于复制协调者和数据库分片。我们将在第 10 章中看到如何使用共识算法为分布式事务实现原子提交。这些算法通过自动从一台节点故障转移到另一台节点而无需任何人工干预来容忍故障,同时继续保证强一致性属性。

分布式事务提供的隔离级别取决于系统,但跨分片的快照隔离可串行化快照隔离都是可能的。其工作原理的详细信息可以在本章末尾引用的论文中找到。

重新审视恰好一次消息处理

我们在”恰好一次消息处理”中看到,分布式事务的一个重要用例是确保某些操作恰好生效一次,即使在其处理过程中发生崩溃且处理需要重试。如果您可以在消息代理和数据库之间原子地提交事务,您可以仅当消息被成功处理且处理产生的数据库写入已提交时,才向代理确认消息。

然而,您实际上并不需要这种分布式事务来实现恰好一次语义。以下是另一种方法,它只需要数据库内的事务:

  1. 假设每条消息都有唯一 ID,在数据库中您有一个已处理消息 ID 的表。当您开始处理来自代理的消息时,您在数据库上开始新事务,并检查消息 ID。如果相同的消息 ID 已存在于数据库中,您知道它已被处理,因此您可以向代理确认消息并丢弃它。
  2. 如果消息 ID 尚不在数据库中,您将其添加到表中。然后您处理消息,这可能导致同一事务中对数据库的额外写入。当您完成处理消息时,您提交数据库上的事务。
  3. 一旦数据库事务成功提交,您可以向代理确认消息。
  4. 消息成功向代理确认后,您知道它不会再次尝试处理相同的消息,因此您可以从数据库中删除消息 ID(在单独的事务中)。

如果消息处理器在提交数据库事务之前崩溃,事务被中止,消息代理将重试处理。如果它在提交之后但在向代理确认消息之前崩溃,它也将重试处理,但重试将看到数据库中的消息 ID 并丢弃它。如果它在确认消息之后但在从数据库删除消息 ID 之前崩溃,您将有一个旧消息 ID 躺在周围,除了占用一点存储空间外没有任何害处。如果重试发生在数据库事务中止之前(如果消息处理器和数据库之间的通信中断,这可能发生),消息 ID 表上的唯一性约束应防止两个并发事务插入相同的消息 ID。

因此,实现恰好一次处理只需要数据库内的事务——跨数据库和消息代理的原子性对此用例不是必需的。在数据库中记录消息 ID 使消息处理幂等,以便可以安全地重试消息处理而不会重复其副作用。Kafka Streams 等流处理框架使用类似的方法来实现恰好一次语义,正如我们将在第 12 章中看到的。

然而,数据库内部的分布式事务对此类模式的可扩展性仍然有用:例如,它们允许消息 ID 存储在一个分片上,而消息处理更新的主数据存储在其他分片上,并确保跨这些分片的事务提交的原子性。


总结

事务是一种抽象层,允许应用程序假装某些并发问题和某些类型的硬件和软件故障不存在。一大类错误被简化为简单的事务中止,应用程序只需要重试。

在本章中,我们看到了事务有助于防止的许多问题例子。并非所有应用程序都容易受到所有这些问题的影响:访问模式非常简单的应用程序,例如只读取和写入单个记录,可能可以在没有事务的情况下管理。然而,对于更复杂的访问模式,事务可以极大地减少您需要考虑的潜在错误案例数量。

没有事务,各种错误场景(进程崩溃、网络中断、电源故障、磁盘已满、意外并发等)意味着数据可能以各种方式变得不一致。例如,反规范化数据很容易与源数据失去同步。没有事务,推理复杂交互访问对数据库的影响变得非常困难。

在本章中,我们特别深入地研究了并发控制主题。我们讨论了几种广泛使用的隔离级别,特别是读已提交快照隔离(有时称为可重复读)和可串行化。我们通过讨论各种竞争条件例子来表征这些隔离级别,总结在表 8-1 中:

表 8-1:各种隔离级别可能发生的异常总结

隔离级别脏读读偏斜幻读丢失更新写偏斜
读未提交✗ 可能✗ 可能✗ 可能✗ 可能✗ 可能
读已提交✓ 防止✗ 可能✗ 可能✗ 可能✗ 可能
快照隔离✓ 防止✓ 防止✓ 防止? 取决于✗ 可能
可串行化✓ 防止✓ 防止✓ 防止✓ 防止✓ 防止
  • 脏读:一个客户端在另一个客户端的写入提交之前读取它们。读已提交隔离级别和更高级别防止脏读。
  • 脏写:一个客户端覆盖另一个客户端已写入但尚未提交的数据。几乎所有事务实现都防止脏写。
  • 读偏斜:客户端在不同时间点看到数据库的不同部分。读偏斜的某些情况也称为不可重复读。此问题最常通过快照隔离防止,它允许事务从对应于特定时间点的一致快照读取。它通常通过**多版本并发控制(MVCC)**实现。
  • 丢失更新:两个客户端并发执行读取-修改-写入循环。一个覆盖另一个的写入而不合并其更改,因此数据丢失。某些快照隔离实现自动防止此异常,而其他实现需要手动锁(SELECT FOR UPDATE)。
  • 写偏斜:事务读取某些内容,根据看到的值做出决定,并将决定写入数据库。然而,到写入时,决定的前提不再为真。只有可串行化隔离防止此异常。
  • 幻读:事务读取匹配某些搜索条件的对象。另一个客户端进行影响该搜索结果的写入。快照隔离防止简单的幻读,但写偏斜上下文中的幻读需要特殊处理,如索引范围锁

弱隔离级别防止其中一些异常,但将其他异常留给您,应用程序开发人员,手动处理(例如,使用显式锁定)。只有可串行化隔离防止所有这些问题。我们讨论了实现可串行化事务的三种不同方法:

  1. 按字面顺序串行执行事务:如果您可以使每个事务执行得非常快(通常通过使用存储过程),并且事务吞吐量足够低,可以在单个 CPU 核心上处理或可以分片,这是一个简单有效的选择。
  2. 两阶段锁定:几十年来这是实现可串行化的标准方式,但许多应用程序因其性能差而避免使用它。
  3. 可串行化快照隔离(SSI):一种相对较新的算法,避免了以前方法的大多数缺点。它使用乐观方法,允许事务继续而不阻塞。当事务想要提交时,它被检查,如果执行不是可串行化的,则被中止。

最后,我们研究了当事务分布在多个节点上时如何使用两阶段提交实现原子性。如果这些节点都运行相同的数据库软件,分布式事务可以很好地工作,但跨不同存储技术(使用 XA 事务)时,2PC 是有问题的:它对协调者和驱动事务的应用程序代码中的故障非常敏感,并且与并发控制机制交互不良。幸运的是,幂等性可以确保恰好一次语义,而无需跨不同存储技术的原子提交,我们将在后面的章节中看到更多相关内容。

本章中的示例使用关系数据模型。然而,如”多对象事务的必要性”中所讨论的,事务是有价值的数据库功能,无论使用哪种数据模型。


脚注

参考文献

[1] Steven J. Murdoch. What went wrong with Horizon: learning from the Post Office Trial. benthamsgaze.org, July 2021. Archived at perma.cc/CNM4-553F

[2] Donald D. Chamberlin, Morton M. Astrahan, Michael W. Blasgen, James N. Gray, W. Frank King, Bruce G. Lindsay, Raymond Lorie, James W. Mehl, Thomas G. Price, Franco Putzolu, Patricia Griffiths Selinger, Mario Schkolnick, Donald R. Slutz, Irving L. Traiger, Bradford W. Wade, and Robert A. Yost. A History and Evaluation of System R. Communications of the ACM, volume 24, issue 10, pages 632–646, October 1981. doi:10.1145/358769.358784

[3] Jim N. Gray, Raymond A. Lorie, Gianfranco R. Putzolu, and Irving L. Traiger. Granularity of Locks and Degrees of Consistency in a Shared Data Base. in Modelling in Data Base Management Systems: Proceedings of the IFIP Working Conference on Modelling in Data Base Management Systems, edited by G. M. Nijssen, pages 364–394, Elsevier/North Holland Publishing, 1976. Also in Readings in Database Systems, 4th edition, edited by Joseph M. Hellerstein and Michael Stonebraker, MIT Press, 2005. ISBN: 978-0-262-69314-1

[4] Kapali P. Eswaran, Jim N. Gray, Raymond A. Lorie, and Irving L. Traiger. The Notions of Consistency and Predicate Locks in a Database System. Communications of the ACM, volume 19, issue 11, pages 624–633, November 1976. doi:10.1145/360363.360369

[5] Rebecca Taft, Irfan Sharif, Andrei Matei, Nathan VanBenschoten, Jordan Lewis, Tobias Grieger, Kai Niemi, Andy Woods, Anne Birzin, Raphael Poss, Paul Bardea, Amruta Ranade, Ben Darnell, Bram Gruneir, Justin Jaffray, Lucy Zhang, and Peter Mattis. CockroachDB: The Resilient Geo-Distributed SQL Database. At ACM SIGMOD International Conference on Management of Data (SIGMOD), pages 1493–1509, June 2020. doi:10.1145/3318464.3386134

[6] Dongxu Huang, Qi Liu, Qiu Cui, Zhuhe Fang, Xiaoyu Ma, Fei Xu, Li Shen, Liu Tang, Yuxing Zhou, Menglong Huang, Wan Wei, Cong Liu, Jian Zhang, Jianjun Li, Xuelian Wu, Lingyu Song, Ruoxi Sun, Shuaipeng Yu, Lei Zhao, Nicholas Cameron, Liquan Pei, and Xin Tang. TiDB: a Raft-based HTAP database. Proceedings of the VLDB Endowment, volume 13, issue 12, pages 3072–3084. doi:10.14778/3415478.3415535

[7] James C. Corbett, Jeffrey Dean, Michael Epstein, Andrew Fikes, Christopher Frost, JJ Furman, Sanjay Ghemawat, Andrey Gubarev, Christopher Heiser, Peter Hochschild, Wilson Hsieh, Sebastian Kanthak, Eugene Kogan, Hongyi Li, Alexander Lloyd, Sergey Melnik, David Mwaura, David Nagle, Sean Quinlan, Rajesh Rao, Lindsay Rolig, Dale Woodford, Yasushi Saito, Christopher Taylor, Michal Szymaniak, and Ruth Wang. Spanner: Google’s Globally-Distributed Database. At 10th USENIX Symposium on Operating System Design and Implementation (OSDI), October 2012.

[8] Jingyu Zhou, Meng Xu, Alexander Shraer, Bala Namasivayam, Alex Miller, Evan Tschannen, Steve Atherton, Andrew J. Beamon, Rusty Sears, John Leach, Dave Rosenthal, Xin Dong, Will Wilson, Ben Collins, David Scherer, Alec Grieser, Young Liu, Alvin Moore, Bhaskar Muppana, Xiaoge Su, and Vishesh Yadav. FoundationDB: A Distributed Unbundled Transactional Key Value Store. At ACM International Conference on Management of Data (SIGMOD), June 2021. doi:10.1145/3448016.3457559

[9] Theo Härder and Andreas Reuter. Principles of Transaction-Oriented Database Recovery. ACM Computing Surveys, volume 15, issue 4, pages 287–317, December 1983. doi:10.1145/289.291

[10] Peter Bailis, Alan Fekete, Ali Ghodsi, Joseph M. Hellerstein, and Ion Stoica. HAT, not CAP: Towards Highly Available Transactions. At 14th USENIX Workshop on Hot Topics in Operating Systems (HotOS), May 2013.

[11] Armando Fox, Steven D. Gribble, Yatin Chawathe, Eric A. Brewer, and Paul Gauthier. Cluster-Based Scalable Network Services. At 16th ACM Symposium on Operating Systems Principles (SOSP), October 1997. doi:10.1145/268998.266662

[12] Tony Andrews. Enforcing Complex Constraints in Oracle. tonyandrews.blogspot.co.uk, October 2004. Archived at archive.org

[13] Philip A. Bernstein, Vassos Hadzilacos, and Nathan Goodman. Concurrency Control and Recovery in Database Systems. Addison-Wesley, 1987. ISBN: 978-0-201-10715-9, available online at microsoft.com.

[14] Alan Fekete, Dimitrios Liarokapis, Elizabeth O’Neil, Patrick O’Neil, and Dennis Shasha. Making Snapshot Isolation Serializable. ACM Transactions on Database Systems, volume 30, issue 2, pages 492–528, June 2005. doi:10.1145/1071610.1071615

[15] Mai Zheng, Joseph Tucek, Feng Qin, and Mark Lillibridge. Understanding the Robustness of SSDs Under Power Fault. At 11th USENIX Conference on File and Storage Technologies (FAST), February 2013.

[16] Laurie Denness. SSDs: A Gift and a Curse. laur.ie, June 2015. Archived at perma.cc/6GLP-BX3T

[17] Adam Surak. When Solid State Drives Are Not That Solid. blog.algolia.com, June 2015. Archived at perma.cc/CBR9-QZEE

[18] Hewlett Packard Enterprise. Bulletin: (Revision) HPE SAS Solid State Drives - Critical Firmware Upgrade Required for Certain HPE SAS Solid State Drive Models to Prevent Drive Failure at 32,768 Hours of Operation. support.hpe.com, November 2019. Archived at perma.cc/CZR4-AQBS

[19] Craig Ringer et al. PostgreSQL’s handling of fsync() errors is unsafe and risks data loss at least on XFS. Email thread on pgsql-hackers mailing list, postgresql.org, March 2018. Archived at perma.cc/5RKU-57FL

[20] Anthony Rebello, Yuvraj Patel, Ramnatthan Alagappan, Andrea C. Arpaci-Dusseau, and Remzi H. Arpaci-Dusseau. Can Applications Recover from fsync Failures? At USENIX Annual Technical Conference (ATC), July 2020.

[21] Thanumalayan Sankaranarayana Pillai, Vijay Chidambaram, Ramnatthan Alagappan, Samer Al-Kiswany, Andrea C. Arpaci-Dusseau, and Remzi H. Arpaci-Dusseau. Crash Consistency: Rethinking the Fundamental Abstractions of the File System. ACM Queue, volume 13, issue 7, pages 20–28, July 2015. doi:10.1145/2800695.2801719

[22] Thanumalayan Sankaranarayana Pillai, Vijay Chidambaram, Ramnatthan Alagappan, Samer Al-Kiswany, Andrea C. Arpaci-Dusseau, and Remzi H. Arpaci-Dusseau. All File Systems Are Not Created Equal: On the Complexity of Crafting Crash-Consistent Applications. At 11th USENIX Symposium on Operating Systems Design and Implementation (OSDI), October 2014.

[23] Chris Siebenmann. Unix’s File Durability Problem. utcc.utoronto.ca, April 2016. Archived at perma.cc/VSS8-5MC4

[24] Aishwarya Ganesan, Ramnatthan Alagappan, Andrea C. Arpaci-Dusseau, and Remzi H. Arpaci-Dusseau. Redundancy Does Not Imply Fault Tolerance: Analysis of Distributed Storage Reactions to Single Errors and Corruptions. At 15th USENIX Conference on File and Storage Technologies (FAST), February 2017.

[25] Lakshmi N. Bairavasundaram, Garth R. Goodson, Bianca Schroeder, Andrea C. Arpaci-Dusseau, and Remzi H. Arpaci-Dusseau. An Analysis of Data Corruption in the Storage Stack. At 6th USENIX Conference on File and Storage Technologies (FAST), February 2008.

[26] Richard van der Hoff. How we discovered, and recovered from, Postgres corruption on the matrix.org homeserver. matrix.org, July 2025. Archived at perma.cc/CDF5-NRBK

[27] Bianca Schroeder, Raghav Lagisetty, and Arif Merchant. Flash Reliability in Production: The Expected and the Unexpected. At 14th USENIX Conference on File and Storage Technologies (FAST), February 2016.

[28] Don Allison. SSD Storage – Ignorance of Technology Is No Excuse. blog.korelogic.com, March 2015. Archived at perma.cc/9QN4-9SNJ

[29] Gordon Mah Ung. Debunked: Your SSD won’t lose data if left unplugged after all. pcworld.com, May 2015. Archived at perma.cc/S46H-JUDU

[30] Martin Kleppmann. Hermitage: Testing the ‘I’ in ACID. martin.kleppmann.com, November 2014. Archived at perma.cc/KP2Y-AQGK

[31] Todd Warszawski and Peter Bailis. ACIDRain: Concurrency-Related Attacks on Database-Backed Web Applications. At ACM International Conference on Management of Data (SIGMOD), May 2017. doi:10.1145/3035918.3064037

[32] Tristan D’Agosta. BTC Stolen from Poloniex. bitcointalk.org, March 2014. Archived at perma.cc/YHA6-4C5D

[33] bitcointhief2. How I Stole Roughly 100 BTC from an Exchange and How I Could Have Stolen More! reddit.com, February 2014. Archived at archive.org

[34] Sudhir Jorwekar, Alan Fekete, Krithi Ramamritham, and S. Sudarshan. Automating the Detection of Snapshot Isolation Anomalies. At 33rd International Conference on Very Large Data Bases (VLDB), September 2007.

[35] Michael Melanson. Transactions: The Limits of Isolation. michaelmelanson.net, November 2014. Archived at perma.cc/RG5R-KMYZ

[36] Edward Kim. How ACH works: A developer perspective — Part 1. engineering.gusto.com, April 2014. Archived at perma.cc/7B2H-PU94

[37] Hal Berenson, Philip A. Bernstein, Jim N. Gray, Jim Melton, Elizabeth O’Neil, and Patrick O’Neil. A Critique of ANSI SQL Isolation Levels. At ACM International Conference on Management of Data (SIGMOD), May 1995. doi:10.1145/568271.223785

[38] Atul Adya. Weak Consistency: A Generalized Theory and Optimistic Implementations for Distributed Transactions. PhD Thesis, Massachusetts Institute of Technology, March 1999. Archived at perma.cc/E97M-HW5Q

[39] Peter Bailis, Aaron Davidson, Alan Fekete, Ali Ghodsi, Joseph M. Hellerstein, and Ion Stoica. Highly Available Transactions: Virtues and Limitations. At 40th International Conference on Very Large Data Bases (VLDB), September 2014.

[40] Natacha Crooks, Youer Pu, Lorenzo Alvisi, and Allen Clement. Seeing is Believing: A Client-Centric Specification of Database Isolation. At ACM Symposium on Principles of Distributed Computing (PODC), pages 73–82, July 2017. doi:10.1145/3087801.3087802

[41] Bruce Momjian. MVCC Unmasked. momjian.us, July 2014. Archived at perma.cc/KQ47-9GYB

[42] Peter Alvaro and Kyle Kingsbury. MySQL 8.0.34. jepsen.io, December 2023. Archived at perma.cc/HGE2-Z878

[43] Egor Rogov. PostgreSQL 14 Internals. postgrespro.com, April 2023. Archived at perma.cc/FRK2-D7WB

[44] Hironobu Suzuki. The Internals of PostgreSQL. interdb.jp, 2017.

[45] Rohan Reddy Alleti. Internals of MVCC in Postgres: Hidden costs of Updates vs Inserts. medium.com, March 2025. Archived at perma.cc/3ACX-DFXT

[46] Andy Pavlo and Bohan Zhang. The Part of PostgreSQL We Hate the Most. cs.cmu.edu, April 2023. Archived at perma.cc/XSP6-3JBN

[47] Yingjun Wu, Joy Arulraj, Jiexi Lin, Ran Xian, and Andrew Pavlo. An empirical evaluation of in-memory multi-version concurrency control. Proceedings of the VLDB Endowment, volume 10, issue 7, pages 781–792, March 2017. doi:10.14778/3067421.3067427

[48] Nikita Prokopov. Unofficial Guide to Datomic Internals. tonsky.me, May 2014.

[49] Daniil Svetlov. A Practical Guide to Taming Postgres Isolation Anomalies. dansvetlov.me, March 2025. Archived at perma.cc/L7LE-TDLS

[50] Nate Wiger. An Atomic Rant. nateware.com, February 2010. Archived at perma.cc/5ZYB-PE44

[51] James Coglan. Reading and writing, part 3: web applications. blog.jcoglan.com, October 2020. Archived at perma.cc/A7EK-PJVS

[52] Peter Bailis, Alan Fekete, Michael J. Franklin, Ali Ghodsi, Joseph M. Hellerstein, and Ion Stoica. Feral Concurrency Control: An Empirical Investigation of Modern Application Integrity. At ACM International Conference on Management of Data (SIGMOD), June 2015. doi:10.1145/2723372.2737784

[53] Jaana Dogan. Things I Wished More Developers Knew About Databases. rakyll.medium.com, April 2020. Archived at perma.cc/6EFK-P2TD

[54] Michael J. Cahill, Uwe Röhm, and Alan Fekete. Serializable Isolation for Snapshot Databases. At ACM International Conference on Management of Data (SIGMOD), June 2008. doi:10.1145/1376616.1376690

[55] Dan R. K. Ports and Kevin Grittner. Serializable Snapshot Isolation in PostgreSQL. At 38th International Conference on Very Large Data Bases (VLDB), August 2012.

[56] Douglas B. Terry, Marvin M. Theimer, Karin Petersen, Alan J. Demers, Mike J. Spreitzer and Carl H. Hauser. Managing Update Conflicts in Bayou, a Weakly Connected Replicated Storage System. At 15th ACM Symposium on Operating Systems Principles (SOSP), December 1995. doi:10.1145/224056.224070

[57] Hans-Jürgen Schönig. Constraints over multiple rows in PostgreSQL. cybertec-postgresql.com, June 2021. Archived at perma.cc/2TGH-XUPZ

[58] Michael Stonebraker, Samuel Madden, Daniel J. Abadi, Stavros Harizopoulos, Nabil Hachem, and Pat Helland. The End of an Architectural Era (It’s Time for a Complete Rewrite). At 33rd International Conference on Very Large Data Bases (VLDB), September 2007.

[59] John Hugg. H-Store/VoltDB Architecture vs. CEP Systems and Newer Streaming Architectures. At Data @Scale Boston, November 2014.

[60] Robert Kallman, Hideaki Kimura, Jonathan Natkins, Andrew Pavlo, Alexander Rasin, Stanley Zdonik, Evan P. C. Jones, Samuel Madden, Michael Stonebraker, Yang Zhang, John Hugg, and Daniel J. Abadi. H-Store: A High-Performance, Distributed Main Memory Transaction Processing System. Proceedings of the VLDB Endowment, volume 1, issue 2, pages 1496–1499, August 2008.

[61] Rich Hickey. The Architecture of Datomic. infoq.com, November 2012. Archived at perma.cc/5YWU-8XJK

[62] John Hugg. Debunking Myths About the VoltDB In-Memory Database. dzone.com, May 2014. Archived at perma.cc/2Z9N-HPKF

[63] Xinjing Zhou, Viktor Leis, Xiangyao Yu, and Michael Stonebraker. OLTP Through the Looking Glass 16 Years Later: Communication is the New Bottleneck. At 15th Annual Conference on Innovative Data Systems Research (CIDR), January 2025.

[64] Xinjing Zhou, Xiangyao Yu, Goetz Graefe, and Michael Stonebraker. Lotus: scalable multi-partition transactions on single-threaded partitioned databases. Proceedings of the VLDB Endowment (PVLDB), volume 15, issue 11, pages 2939–2952, July 2022. doi:10.14778/3551793.3551843

[65] Joseph M. Hellerstein, Michael Stonebraker, and James Hamilton. Architecture of a Database System. Foundations and Trends in Databases, volume 1, issue 2, pages 141–259, November 2007. doi:10.1561/1900000002

[66] Michael J. Cahill. Serializable Isolation for Snapshot Databases. PhD Thesis, University of Sydney, July 2009. Archived at perma.cc/727J-NTMP

[67] Cristian Diaconu, Craig Freedman, Erik Ismert, Per-Åke Larson, Pravin Mittal, Ryan Stonecipher, Nitin Verma, and Mike Zwilling. Hekaton: SQL Server’s Memory-Optimized OLTP Engine. At ACM SIGMOD International Conference on Management of Data (SIGMOD), pages 1243–1254, June 2013. doi:10.1145/2463676.2463710

[68] Thomas Neumann, Tobias Mühlbauer, and Alfons Kemper. Fast Serializable Multi-Version Concurrency Control for Main-Memory Database Systems. At ACM SIGMOD International Conference on Management of Data (SIGMOD), pages 677–689, May 2015. doi:10.1145/2723372.2749436

[69] D. Z. Badal. Correctness of Concurrency Control and Implications in Distributed Databases. At 3rd International IEEE Computer Software and Applications Conference (COMPSAC), November 1979. doi:10.1109/CMPSAC.1979.762563

[70] Rakesh Agrawal, Michael J. Carey, and Miron Livny. Concurrency Control Performance Modeling: Alternatives and Implications. ACM Transactions on Database Systems (TODS), volume 12, issue 4, pages 609–654, December 1987. doi:10.1145/32204.32220

[71] Marc Brooker. Snapshot Isolation vs Serializability. brooker.co.za, December 2024. Archived at perma.cc/5TRC-CR5G

[72] B. G. Lindsay, P. G. Selinger, C. Galtieri, J. N. Gray, R. A. Lorie, T. G. Price, F. Putzolu, I. L. Traiger, and B. W. Wade. Notes on Distributed Databases. IBM Research, Research Report RJ2571(33471), July 1979. Archived at perma.cc/EPZ3-MHDD

[73] C. Mohan, Bruce G. Lindsay, and Ron Obermarck. Transaction Management in the R* Distributed Database Management System. ACM Transactions on Database Systems, volume 11, issue 4, pages 378–396, December 1986. doi:10.1145/7239.7266

[74] X/Open Company Ltd. Distributed Transaction Processing: The XA Specification. Technical Standard XO/CAE/91/300, December 1991. ISBN: 978-1-872-63024-3, archived at perma.cc/Z96H-29JB

[75] Ivan Silva Neto and Francisco Reverbel. Lessons Learned from Implementing WS-Coordination and WS-AtomicTransaction. At 7th IEEE/ACIS International Conference on Computer and Information Science (ICIS), May 2008. doi:10.1109/ICIS.2008.75

[76] James E. Johnson, David E. Langworthy, Leslie Lamport, and Friedrich H. Vogt. Formal Specification of a Web Services Protocol. At 1st International Workshop on Web Services and Formal Methods (WS-FM), February 2004. doi:10.1016/j.entcs.2004.02.022

[77] Jim Gray. The Transaction Concept: Virtues and Limitations. At 7th International Conference on Very Large Data Bases (VLDB), September 1981.

[78] Dale Skeen. Nonblocking Commit Protocols. At ACM International Conference on Management of Data (SIGMOD), April 1981. doi:10.1145/582318.582339

[79] Gregor Hohpe. Your Coffee Shop Doesn’t Use Two-Phase Commit. IEEE Software, volume 22, issue 2, pages 64–66, March 2005. doi:10.1109/MS.2005.52

[80] Pat Helland. Life Beyond Distributed Transactions: An Apostate’s Opinion. At 3rd Biennial Conference on Innovative Data Systems Research (CIDR), January 2007.

[81] Jonathan Oliver. My Beef with MSDTC and Two-Phase Commits. blog.jonathanoliver.com, April 2011. Archived at perma.cc/K8HF-Z4EN

[82] Oren Eini (Ahende Rahien). The Fallacy of Distributed Transactions. ayende.com, July 2014. Archived at perma.cc/VB87-2JEF

[83] Clemens Vasters. Transactions in Windows Azure (with Service Bus) – An Email Discussion. learn.microsoft.com, July 2012. Archived at perma.cc/4EZ9-5SKW

[84] Ajmer Dhariwal. Orphaned MSDTC Transactions (-2 spids). eraofdata.com, December 2008. Archived at perma.cc/YG6F-U34C

[85] Paul Randal. Real World Story of DBCC PAGE Saving the Day. sqlskills.com, June 2013. Archived at perma.cc/2MJN-A5QH

[86] Guozhang Wang, Lei Chen, Ayusman Dikshit, Jason Gustafson, Boyang Chen, Matthias J. Sax, John Roesler, Sophie Blee-Goldman, Bruno Cadonna, Apurva Mehta, Varun Madan, and Jun Rao. Consistency and Completeness: Rethinking Distributed Stream Processing in Apache Kafka. At ACM International Conference on Management of Data (SIGMOD), June 2021. doi:10.1145/3448016.3457556