交易
一些作者声称,一般的两阶段提交支持成本过高,因为它带来的性能或可用性问题。我们认为,让应用程序开发人员处理由于事务过度使用而导致的性能问题更为合适,而不是总是围绕缺乏事务进行编码。
James Corbett 等,Spanner:谷歌的全球分布式数据库(2012)
在数据系统的严酷现实中,许多事情可能会出错:
-
数据库软件或硬件可能会在任何时候发生故障(包括在写操作的中间)。
-
应用程序可能随时崩溃(包括在一系列操作进行到一半时)。
-
网络中的中断可能会意外地将应用程序与数据库断开连接,或者将一个数据库节点与另一个节点断开连接。
-
多个客户端可能会同时写入数据库,互相覆盖对方的更改。
-
客户端可能会读取到不合理的数据,因为数据仅部分更新。
-
客户端之间的竞争条件可能会导致意想不到的错误。
为了可靠,系统必须处理这些故障,并确保它们不会导致整个系统的灾难性失败。然而,实现容错机制需要大量的工作。这需要仔细考虑所有可能出错的情况,并进行大量测试以确保解决方案确实有效。
几十年来,事务一直是简化这些问题的首选机制。事务是应用程序将多个读写操作组合成一个逻辑单元的一种方式。从概念上讲,事务中的所有读写操作作为一个操作执行:要么整个事务成功(提交),要么失败(中止,回滚)。如果失败,应用程序可以安全地重试。通过事务,错误处理对应用程序变得简单得多,因为它不需要担心部分失败——即某些操作成功而某些操作失败(无论出于何种原因)的情况。
如果你花了多年时间处理事务,它们可能看起来很显而易见,但我们不应该理所当然地对待它们。事务并不是自然法则;它们是出于某种目的而创建的,即简化访问数据库的应用程序的编程模型。通过使用事务,应用程序可以忽略某些潜在的错误场景和并发问题,因为数据库会处理这些问题(我们称之为安全保证)。
并不是每个应用程序都需要事务,有时削弱事务保证或完全放弃它们会有优势(例如,为了实现更高的性能或更高的可用性)。某些安全属性可以在没有事务的情况下实现。另一方面,事务可以防止很多麻烦:例如,邮政局地平线丑闻背后的技术原因(见“可靠性有多重要?”)可能是底层会计系统缺乏 ACID 事务1。
你如何判断是否需要事务?为了回答这个问题,我们首先需要确切理解事务可以提供哪些安全保障,以及与之相关的成本。尽管事务乍一看似乎很简单,但实际上有许多微妙但重要的细节需要考虑。
在本章中,我们将检查许多可能出错的例子,并探讨数据库用来防范这些问题的算法。我们将特别深入讨论并发控制领域,讨论可能发生的各种竞争条件,以及数据库如何实现如已提交读、快照隔离和可串行化等隔离级别。
本章适用于单节点和分布式数据库;在[链接即将到来]中,我们将重点讨论仅在分布式系统中出现的特定挑战。
什么是事务?
几乎所有现代关系数据库,以及一些非关系数据库,都支持事务。它们中的大多数遵循 1975 年 IBM System R 首次引入的风格,这是第一个 SQL 数据库234。尽管一些实现细节发生了变化,但一般思想在过去 50 年中几乎保持不变:MySQL、PostgreSQL、Oracle、SQL Server 等的事务支持与 System R 的事务支持惊人地相似。
在 2000 年代后期,非关系(NoSQL)数据库开始流行。它们旨在通过提供新的数据模型选择(见第 3 章),并默认包含复制(第 6 章)和分片(第 7 章),来改善关系数据库的现状。事务是这一运动的主要牺牲品:这一代数据库中的许多完全放弃了事务,或者重新定义了这个词,以描述比以前理解的要弱得多的保证集。
围绕 NoSQL 分布式数据库的炒作导致了一个普遍的看法,即事务在根本上是不可扩展的,任何大规模系统都必须放弃事务以维持良好的性能和高可用性。最近,这一看法被证明是错误的。所谓的“NewSQL”数据库,如 CockroachDB5、TiDB6、Spanner7、FoundationDB8和 Yugabyte,已经证明事务系统可以扩展到大数据量和高吞吐量。这些系统结合了分片和共识协议([链接即将发布]),在大规模下提供强大的 ACID 保证。
然而,这并不意味着每个系统都必须是事务性的:与其他技术设计选择一样,事务也有其优点和局限性。为了理解这些权衡,让我们深入探讨事务可以提供的保证——无论是在正常操作中还是在各种极端(但现实的)情况下。
ACID 的含义
事务提供的安全保证通常用著名的首字母缩略词 ACID 来描述,代表原子性、一致性、隔离性和持久性。这个术语是由 Theo Härder 和 Andreas Reuter 于 1983 年创造的,旨在为数据库中的容错机制建立精确的术语。
然而,在实践中,一个数据库对 ACID 的实现并不等同于另一个数据库的实现。例如,正如我们将看到的,关于隔离性的含义存在很多模糊之处。这个高层次的概念是合理的,但细节中隐藏着问题。如今,当一个系统声称“符合 ACID”时,实际上你可以期待什么样的保证并不明确。遗憾的是,ACID 大多已成为一个营销术语。
(不符合 ACID 标准的系统有时被称为 BASE,代表基本可用、软状态和最终一致性。这比 ACID 的定义更模糊。似乎 BASE 唯一合理的定义就是“不是 ACID”;也就是说,它几乎可以意味着你想要的任何东西。)
让我们深入探讨原子性、一致性、隔离性和持久性的定义,因为这将使我们能够细化对事务的理解。
原子性
一般来说,原子性指的是无法被分解为更小部分的事物。这个词在计算机的不同领域中意味着相似但又略有不同的东西。例如,在多线程编程中,如果一个线程执行了一个原子操作,这意味着另一个线程无法看到该操作的半成品结果。系统只能处于操作之前的状态或操作之后的状态,而不是介于两者之间的状态。
相比之下,在 ACID 的上下文中,原子性并不是关于并发的。它并不描述如果多个进程同时尝试访问相同数据会发生什么,因为这属于字母 I,即隔离(见“隔离”)。
相反,ACID 原子性描述的是如果客户端想要进行多次写入,但在某些写入处理后发生故障的情况——例如,进程崩溃、网络连接中断、磁盘满了或某些完整性约束被违反。如果这些写入被组合成一个原子事务,并且由于故障无法完成(提交)该事务,那么该事务将被中止,数据库必须丢弃或撤销在该事务中迄今为止所做的任何写入。
没有原子性,如果在进行多个更改的过程中发生错误,就很难知道哪些更改已经生效,哪些尚未生效。应用程序可以尝试重新执行,但这有可能导致相同的更改被执行两次,从而导致重复或不正确的数据。原子性简化了这个问题:如果一个事务被中止,应用程序可以确信它没有更改任何内容,因此可以安全地重试。
在发生错误时中止事务并丢弃该事务的所有写入是 ACID 原子性的定义特征。也许“可中止性”这个词比“原子性”更合适,但我们将坚持使用“原子性”,因为这是通常使用的词。
一致性
“一致性”这个词的含义非常复杂:
-
在第 6 章中,我们讨论了副本一致性以及在异步复制系统中出现的最终一致性问题(参见“复制延迟的问题”)。
-
数据库的一致快照,例如用于备份,是在某一时刻数据库的完整快照。更准确地说,它与发生在之前的关系一致(参见“发生在之前的关系和并发”):也就是说,如果快照包含在特定时间写入的值,那么它也反映了在该值写入之前发生的所有写入。
-
一致性哈希是一种分片方法,一些系统使用它来进行负载均衡(见“一致性哈希”)。
-
在 CAP 定理中(见[链接待补充]),一致性一词用于表示线性化(见[链接待补充])。
-
在 ACID 的上下文中,一致性指的是数据库处于“良好状态”的应用特定概念。
不幸的是,同一个词至少有五种不同的含义。
ACID 一致性的理念是,你对数据有某些必须始终为真的陈述(不变式)——例如,在一个会计系统中,所有账户的贷方和借方必须始终保持平衡。如果一个事务以一个根据这些不变式有效的数据库开始,并且在事务期间的任何写入都保持有效性,那么你可以确保不变式始终得到满足。(在事务执行期间,不变式可能会暂时被违反,但在事务提交时应该再次得到满足。)
如果你希望数据库强制执行你的不变式,你需要将它们声明为模式的一部分约束。例如,外键约束、唯一性约束或检查约束(限制可以出现在单个行中的值)通常用于建模特定类型的不变式。更复杂的一致性要求有时可以通过触发器或物化视图来建模9。
然而,复杂的不变式可能很难或不可能使用数据库通常提供的约束来建模。在这种情况下,应用程序有责任正确地定义其事务,以确保它们保持一致性。如果你写入了违反不变式的错误数据,但你没有声明这些不变式,数据库无法阻止你。因此,ACID 中的 C 往往取决于应用程序如何使用数据库,而不仅仅是数据库本身的属性。
隔离性
大多数数据库同时被多个客户端访问。如果它们读取和写入数据库的不同部分,这没有问题,但如果它们访问相同的数据库记录,就可能会遇到并发问题(竞争条件)。
图 8-1 是这种问题的一个简单示例。假设你有两个客户端同时在一个数据库中递增一个计数器。每个客户端需要读取当前值,加 1,然后将新值写回(假设数据库中没有内置的递增操作)。在图 8-1 中,计数器应该从 42 增加到 44,因为发生了两个递增,但实际上只增加到了 43,这是由于竞争条件造成的。
ACID 中的隔离意味着并发执行的事务彼此隔离:它们不能相互干扰。经典的数据库教科书将隔离形式化为可串行性,这意味着每个事务可以假装它是唯一在整个数据库上运行的事务。数据库确保当事务提交时,结果与它们串行运行(一个接一个)时的结果相同,即使在实际情况中它们可能是并发运行的10。

然而,串行化具有性能成本。在实践中,许多数据库使用的隔离形式比串行化要弱:也就是说,它们允许并发事务以有限的方式相互干扰。一些流行的数据库,如 Oracle,甚至不实现它(Oracle 有一个称为“可串行化”的隔离级别,但它实际上实现的是快照隔离,这比串行化提供的保证要弱1112)。这意味着某些类型的竞争条件仍然可能发生。我们将在“弱隔离级别”中探讨快照隔离和其他形式的隔离。
持久性
数据库系统的目的是提供一个安全的地方,以便数据可以存储而不必担心丢失。持久性是指一旦事务成功提交,它所写入的任何数据都不会被遗忘,即使发生硬件故障或数据库崩溃。
在单节点数据库中,持久性通常意味着数据已被写入非易失性存储,如硬盘或 SSD。常规文件写入通常在发送到磁盘之前会缓存在内存中,这意味着如果发生突然的电源故障,这些数据将会丢失;因此,许多数据库使用 fsync() 系统调用来确保数据确实已写入磁盘。数据库通常还具有写前日志或类似机制(参见“使 B 树可靠”),这使它们能够在写入过程中发生崩溃时进行恢复。
在复制数据库中,持久性可能意味着数据已成功复制到一定数量的节点。为了提供持久性保证,数据库必须等到这些写入或复制完成后,才能将事务报告为成功提交。然而,正如在“可靠性和容错性”中讨论的那样,完美的持久性是不存在的:如果所有硬盘和所有备份同时被销毁,显然数据库无法为您提供任何保护。
复制与持久性
历史上,持久性意味着写入归档磁带。后来被理解为写入磁盘或 SSD。最近,它被调整为意味着复制。哪种实现更好?
事实是,没有什么是完美的:
-
如果你写入磁盘而机器崩溃,即使你的数据没有丢失,但在你修复机器或将磁盘转移到另一台机器之前,它是无法访问的。复制系统可以保持可用。
-
相关故障——如停电或导致特定输入下每个节点崩溃的 bug——可以同时使所有副本失效(见“可靠性和容错”),从而丢失仅在内存中的任何数据。因此,写入磁盘对于复制数据库仍然是相关的。
-
在异步复制系统中,当领导者不可用时,最近的写入可能会丢失(参见“处理节点故障”)。
-当电源突然中断时,特别是 SSD 有时会违反它们应该提供的保证:甚至 fsync 也不能保证正常工作13。磁盘固件可能会有 bug,就像其他任何类型的软件一样1415,例如,导致驱动器在运行 32,768 小时后恰好失败16。而且 fsync 使用起来很困难;即使是 PostgreSQL 也错误地使用了它超过 20 年171819。
-
存储引擎与文件系统实现之间的微妙交互可能导致难以追踪的 bug,并可能导致磁盘上的文件在崩溃后被损坏2021。一个副本上的文件系统错误有时也会传播到其他副本22。
-
磁盘上的数据可能会逐渐变得损坏而未被检测到23。如果数据已经损坏了一段时间,副本和最近的备份也可能会被损坏。在这种情况下,您需要尝试从历史备份中恢复数据。
-
一项关于 SSD 的研究发现,在前四年的使用过程中,30%到 80%的驱动器会出现至少一个坏块,而这些坏块中只有一部分可以通过固件修复24。磁盘硬盘的坏扇区发生率较低,但完全故障的发生率高于 SSD。
-
当一个磨损严重的 SSD(经历了多次写入/擦除循环)断电时,它可能会在几周到几个月的时间内开始丢失数据,这取决于温度25。对于磨损程度较低的驱动器,这个问题较小26。
在实际操作中,没有一种技术可以提供绝对的保证。只有各种风险降低技术,包括写入磁盘、复制到远程机器和备份——它们可以并且应该一起使用。像往常一样,对任何理论上的“保证”保持适度的怀疑是明智的。
单对象和多对象操作
回顾一下,在 ACID 中,原子性和隔离性描述了如果客户端在同一事务中进行多次写入,数据库应该如何处理:
原子性
如果在一系列写入的过程中发生错误,事务应该被中止,并且到那时为止的写入应该被丢弃。换句话说,数据库让你不必担心部分失败,因为它提供了全有或全无的保证。
隔离性
并发运行的事务不应相互干扰。例如,如果一个事务进行了多次写入,那么另一个事务应该看到这些写入的全部或没有,而不是某个子集。
这些定义假设您希望一次修改多个对象(行、文档、记录)。如果需要保持多个数据片段同步,则通常需要这种多对象事务。图 8-2 展示了一个来自电子邮件应用的示例。为了显示用户的未读消息数量,您可以查询类似于以下内容:
SELECT COUNT(\*) FROM emails WHERE recipient_id = 2 AND unread_flag = true
然而,如果邮件数量很多,您可能会发现这个查询太慢,并决定将未读消息的数量存储在一个单独的字段中(这是一种反规范化,我们在“规范化、反规范化和连接”中讨论)。现在,每当有新消息到达时,您还必须增加未读计数器,每当一条消息被标记为已读时,您也必须减少未读计数器。
在图 8-2 中,用户 2 遇到了一个异常:邮箱列表显示有一封未读消息,但计数器显示未读消息为零,因为计数器的增量尚未发生。(如果电子邮件应用中的错误计数器看起来微不足道,可以考虑客户账户余额而不是未读计数器,以及支付交易而不是电子邮件。)隔离可以防止这个问题,确保用户 2 要么看到插入的电子邮件和更新的计数器,要么都看不到,而不是处于不一致的中间状态。
图 8-3 说明了原子性的重要性:如果在交易过程中某个地方发生错误,邮箱的内容和未读计数器可能会不同步。在原子交易中,如果计数器的更新失败,交易将被中止,插入的电子邮件将被回滚。

多对象事务需要某种方式来确定哪些读写操作属于同一事务。在关系数据库中,通常是基于客户端与数据库服务器的 TCP 连接来完成的:在任何特定连接上,位于 BEGIN TRANSACTION 和 COMMIT 语句之间的所有内容都被视为同一事务的一部分。如果 TCP 连接中断,则必须中止该事务。
另一方面,许多非关系数据库没有将操作分组在一起的方式。即使存在多对象 API(例如,键值存储可能有一个多写操作,可以在一次操作中更新多个键),这也不一定意味着它具有事务语义:该命令可能对某些键成功而对其他键失败,从而使数据库处于部分更新状态。
单对象写入
原子性和隔离性在更改单个对象时也适用。例如,假设您正在将一个 20 KB 的 JSON 文档写入数据库:
-
如果在发送完前 10 KB 后网络连接中断,数据库会存储那段无法解析的 10 KB JSON 片段吗?
-
如果在数据库正在覆盖磁盘上先前值的过程中电源故障,最终会得到旧值和新值拼接在一起吗?
-
如果另一个客户端在写入过程中读取该文档,它会看到部分更新的值吗?
-
这些问题会非常令人困惑,因此存储引擎几乎普遍旨在提供单个对象(例如键值对)在一个节点上的原子性和隔离性。原子性可以通过日志实现以进行崩溃恢复(参见“使 B 树可靠”),而隔离性可以通过对每个对象加锁来实现(允许在任何时刻只有一个线程访问一个对象)。
-
一些数据库还提供更复杂的原子操作,例如增量操作,这消除了像图 8-1 中那样的读-修改-写周期的需要。同样受欢迎的是条件写入操作,它允许写入仅在值未被其他人同时更改的情况下发生(参见“条件写入(比较并交换)”),这类似于共享内存并发中的比较并交换(CAS)操作。
严格来说,术语原子增量在多线程编程的意义上使用了“原子”这个词。在 ACID 的上下文中,它实际上应该被称为隔离增量或可序列化增量,但这并不是通常的术语。
这些单对象操作是有用的,因为它们可以防止多个客户端同时尝试写入同一对象时发生的更新丢失(参见“防止更新丢失”)。然而,它们并不是通常意义上的事务。例如,Cassandra 和 ScyllaDB 的“轻量级事务”功能,以及 Aerospike 的“强一致性”模式,提供了对单个对象的线性化(参见[链接即将到来])读取和条件写入,但对多个对象没有保证。
多对象事务的需求
我们真的需要多对象事务吗?仅仅使用键值数据模型和单对象操作是否可以实现任何应用程序?
在某些用例中,单个对象的插入、更新和删除是足够的。然而,在许多其他情况下,对多个不同对象的写入需要进行协调:
-
在关系数据模型中,一个表中的行通常有一个外键引用指向另一个表中的行。同样,在图形数据模型中,一个顶点与其他顶点之间有边。多对象事务允许您确保这些引用保持有效:在插入相互引用的多个记录时,外键必须正确且最新,否则数据将变得毫无意义。
-
在文档数据模型中,需要一起更新的字段通常在同一个文档内,该文档被视为一个单一对象——在更新单个文档时不需要多对象事务。然而,缺乏连接功能的文档数据库也鼓励去规范化(参见“何时使用哪种模型”)。当去规范化的信息需要更新时,如图 8-2 中的示例,您需要一次性更新多个文档。在这种情况下,事务非常有用,以防止去规范化的数据不同步。
-
在具有二级索引的数据库中(几乎所有的数据库,除了纯键值存储),每次更改值时,索引也需要更新。这些索引从事务的角度来看是不同的数据库对象:例如,在没有事务隔离的情况下,可能会出现某个记录在一个索引中存在而在另一个索引中不存在的情况,因为对第二个索引的更新尚未发生(参见“分片和二级索引”)。
这样的应用程序仍然可以在没有事务的情况下实现。然而,没有原子性时,错误处理变得复杂得多,缺乏隔离可能会导致并发问题。我们将在“弱隔离级别”中讨论这些问题,并在[链接即将到来]中探索替代方法。
处理错误和中止
事务的一个关键特性是,如果发生错误,它可以被中止并安全地重试。ACID 数据库基于这一理念:如果数据库面临违反原子性、隔离性或持久性的保证的风险,它宁愿完全放弃事务,也不允许事务处于半完成状态。
并不是所有系统都遵循这种理念。特别是,采用无领导复制的数据库存储(见“无领导复制”)在很大程度上是基于“尽力而为”的原则,可以总结为“数据库会尽其所能,如果遇到错误,它不会撤销已经完成的操作”——因此,恢复错误的责任在于应用程序。
错误不可避免地会发生,但许多软件开发人员更倾向于只关注顺利的路径,而不是错误处理的复杂性。例如,流行的对象关系映射(ORM)框架,如 Rails 的 ActiveRecord 和 Django,并不会重试中止的事务——错误通常会导致异常向上冒泡,因此任何用户输入都会被丢弃,用户会收到错误消息。这很遗憾,因为中止的整个意义在于能够安全地重试。
尽管重试中止的事务是一种简单有效的错误处理机制,但它并不完美:
-
如果事务实际上成功了,但在服务器尝试向客户端确认成功提交时网络中断(因此从客户端的角度来看超时),那么重试该事务会导致其被执行两次——除非您有额外的应用层去重机制。
-
如果错误是由于过载或并发事务之间的高争用引起的,重试事务只会使问题变得更糟,而不是更好。为了避免这种反馈循环,您可以限制重试次数,使用指数退避,并将与过载相关的错误与其他错误区别处理(参见“当过载系统无法恢复时”)。
-
只有在瞬态错误(例如死锁、隔离违规、临时网络中断和故障转移)之后重试才是值得的;在永久性错误(例如约束违规)之后,重试将毫无意义。
-
如果事务在数据库外还有副作用,即使事务被中止,这些副作用也可能发生。例如,如果您正在发送电子邮件,您不希望在每次重试事务时都重新发送电子邮件。如果您想确保多个不同的系统要么一起提交,要么一起中止,二阶段提交可以提供帮助(我们将在[链接待发布]中讨论这个问题)。
如果客户端进程在重试时崩溃,它试图写入数据库的任何数据都会丢失。
弱隔离级别
如果两个事务不访问相同的数据,或者如果两个事务都是只读的,它们可以安全地并行运行,因为它们彼此之间没有依赖关系。并发问题(竞争条件)仅在一个事务读取被另一个事务同时修改的数据,或者当两个事务试图修改相同的数据时才会出现。
并发错误很难通过测试发现,因为这些错误只有在时机不佳时才会被触发。这种时机问题可能非常罕见,并且通常难以重现。并发问题也很难推理,尤其是在大型应用程序中,你不一定知道哪些其他代码片段正在访问数据库。如果一次只有一个用户,应用程序开发已经够困难了;而有多个并发用户则更难,因为任何数据在任何时候都可能意外改变。
因此,数据库长期以来一直试图通过提供事务隔离来隐藏应用程序开发者面临的并发问题。理论上,隔离应该让你的生活更轻松,因为它让你可以假装没有发生并发:可串行化隔离意味着数据库保证事务的效果就像是串行执行(即一次一个,没有任何并发)。
在实践中,隔离 unfortunately 并不是那么简单。可串行化隔离有性能成本,许多数据库不愿意支付这个代价11。因此,系统通常使用较弱的隔离级别,这些级别可以防止某些并发问题,但并不是所有问题。这些隔离级别更难以理解,并且可能导致微妙的错误,但它们在实践中仍然被使用27。
由于弱事务隔离引起的并发错误不仅仅是一个理论问题。它们造成了巨大的经济损失282930,导致财务审计人员的调查31,并导致客户数据被损坏32。关于此类问题的流行评论是“如果你处理财务数据,请使用 ACID 数据库!”——但这忽略了关键点。即使是许多流行的关系数据库系统(通常被认为是“ACID”)也使用弱隔离,因此它们并不一定能防止这些错误的发生。
顺便提一下,许多银行系统依赖于通过安全 FTP 交换的文本文件33。在这种情况下,拥有审计跟踪和一些人类级别的欺诈预防措施实际上比 ACID 属性更为重要。
这些例子还强调了一个重要观点:即使在正常操作中并发问题很少见,您也必须考虑攻击者故意向您的 API 发送大量高度并发请求以试图故意利用并发漏洞的可能性28。因此,为了构建可靠和安全的应用程序,您必须确保系统性地防止此类漏洞。
在本节中,我们将查看几种在实践中使用的弱(非可串行化)隔离级别,并详细讨论可能和不可能发生的竞争条件,以便您可以决定哪个级别适合您的应用程序。一旦我们完成了这一点,我们将详细讨论可串行性(见“可串行性”)。我们对隔离级别的讨论将是非正式的,使用示例。如果您想要严格的定义和属性分析,可以在学术文献中找到它们 [36, 37, 38, 39]。
已提交读取
事务隔离的最基本级别是已提交读。它提供了两个保证:
-
在从数据库读取时,您只会看到已提交的数据(没有脏读)。
-
在向数据库写入时,您只会覆盖已提交的数据(没有脏写)。
一些数据库支持一种更弱的隔离级别,称为未提交读。它防止脏写,但不防止脏读。让我们更详细地讨论这两个保证。
无脏读
想象一下,一个事务已经向数据库写入了一些数据,但该事务尚未提交或中止。另一个事务能看到这些未提交的数据吗?如果可以,那就称为脏读3。
在读取已提交隔离级别下运行的事务必须防止脏读。这意味着事务的任何写入只有在该事务提交后才对其他事务可见(然后它的所有写入会同时变得可见)。如图 8-4 所示,用户 1 将 x 设置为 3,但用户 2 的获取 x 仍然返回旧值 2,因为用户 1 尚未提交。

防止脏读有几个原因:
-
如果一个事务需要更新多行数据,脏读意味着另一个事务可能会看到某些更新但看不到其他更新。例如,在图 8-2 中,用户看到了新的未读邮件,但没有看到更新的计数器。这是对邮件的脏读。以部分更新的状态查看数据库会让用户感到困惑,并可能导致其他事务做出错误的决策。
-
如果一个事务中止,它所做的任何写入都需要回滚(如图 8-3 所示)。如果数据库允许脏读,这意味着一个事务可能会看到后来被回滚的数据——即,实际上从未提交到数据库的数据。任何读取未提交数据的事务也需要被中止,这会导致一个称为级联中止的问题。
无脏写
如果两个事务同时尝试更新数据库中的同一行,会发生什么?我们不知道写入的顺序,但通常假设后来的写入会覆盖早期的写入。
然而,如果早期的写入是一个尚未提交的事务的一部分,那么后续的写入会覆盖一个未提交的值,这会发生什么呢?这被称为脏写34。在读已提交隔离级别下运行的事务必须防止脏写,通常通过延迟第二次写入,直到第一次写入的事务已提交或中止。
通过防止脏写,这种隔离级别避免了一些类型的并发问题:
-
如果事务更新多个行,脏写可能导致不良结果。例如,考虑图 8-5,它展示了一个二手车销售网站,两个用户,爱丽丝和鲍勃,正在同时尝试购买同一辆车。购买一辆车需要进行两次数据库写入:网站上的列表需要更新以反映买家,销售发票需要发送给买家。在图 8-5 的情况下,销售被授予鲍勃(因为他对 listings 表进行了获胜的更新),但发票却发送给了爱丽丝(因为她对 invoices 表进行了获胜的更新)。读已提交防止了这种失误。
-
然而,读已提交并不能防止图 8-1 中两个计数器递增之间的竞争条件。在这种情况下,第二次写入发生在第一次事务提交之后,因此这不是脏写。它仍然是不正确的,但原因不同——在“防止丢失更新”中,我们将讨论如何使这样的计数器递增安全。

实现 Read Committed(已提交读)
Read Committed(已提交读)是一个非常流行的隔离级别。它是 Oracle 数据库、PostgreSQL、SQL Server 和许多其他数据库的默认设置11。
最常见的,数据库通过使用行级锁来防止脏写:当一个事务想要修改特定的行(或文档或其他对象)时,它必须首先获取该行的锁。然后,它必须保持该锁,直到事务被提交或中止。任何给定行只能由一个事务持有锁;如果另一个事务想要写入同一行,它必须等到第一个事务被提交或中止后才能获取锁并继续。这种锁定是在读取已提交模式(或更强的隔离级别)下由数据库自动完成的。
我们如何防止脏读?一种选择是使用相同的锁,并要求任何想要读取某行的事务暂时获取锁,然后在读取后立即释放它。这将确保在行具有脏的、未提交的值时无法进行读取(因为在此期间锁将由进行写入的事务持有)。
然而,要求读取锁的这种方法在实践中效果不佳,因为一个长时间运行的写事务可能会迫使许多其他事务等待,直到该长时间运行的事务完成,即使其他事务只是读取而不向数据库写入任何内容。这会损害只读事务的响应时间,并且对可操作性不利:应用程序某一部分的减速可能会对应用程序的完全不同部分产生连锁反应,因为需要等待锁。
然而,在某些数据库中,例如 IBM Db2 和 Microsoft SQL Server,在 read_committed_snapshot=off 设置中使用锁来防止脏读27。
防止脏读的更常用的方法是图 8-4 中所示的:对于每一行被写入的记录,数据库会记住旧的已提交值和当前持有写锁的事务设置的新值。在事务进行期间,任何其他读取该行的事务都将简单地获得旧值。只有当新值被提交时,事务才会切换到读取新值(有关更多细节,请参见“多版本并发控制(MVCC)”)。
快照隔离与可重复读
如果你表面上看待已提交读隔离,你可能会认为它满足事务所需的一切:它允许中止(这是原子性所必需的),它防止读取事务的不完整结果,并且它防止并发写入交错在一起。确实,这些都是有用的特性,比没有事务的系统提供了更强的保证。
然而,在使用这种隔离级别时,仍然有很多方式可能导致并发错误。例如,图 8-6 展示了在已提交读中可能出现的问题。

假设爱丽丝在银行有 1,000 美元的存款,分布在两个账户中,每个账户 500 美元。现在进行了一笔交易,将 100 美元从她的一个账户转移到另一个账户。如果她不幸在交易处理的同时查看她的账户余额列表,她可能会在收到付款之前看到一个账户的余额(500 美元),而在发出转账后看到另一个账户(新余额为 400 美元)。对爱丽丝来说,现在似乎她的账户总共只有 900 美元——似乎 100 美元突然消失了。
这种异常现象被称为读取偏差,它是不可重复读取的一个例子:如果爱丽丝在交易结束时再次读取账户 1 的余额,她会看到一个与之前查询不同的值(600 美元)。在读取已提交隔离级别下,读取偏差被认为是可以接受的:爱丽丝看到的账户余额确实是在她读取时已提交的。
不幸的是,术语偏斜被过度使用:我们之前在“偏斜工作负载和缓解热点”中使用它来指代不平衡的工作负载和热点,而在这里它指的是时序异常。
在爱丽丝的案例中,这不是一个持久性的问题,因为如果她在几秒钟后重新加载在线银行网站,她很可能会看到一致的账户余额。然而,有些情况无法容忍这种临时不一致:
备份
进行备份需要复制整个数据库,这在大型数据库上可能需要几个小时。在备份过程运行期间,写入操作将继续进行到数据库中。因此,您可能会发现备份的某些部分包含旧版本的数据,而其他部分则包含新版本。如果您需要从这样的备份中恢复,不一致性(例如消失的钱)将变得永久。
分析查询和完整性检查
有时,您可能想要运行一个查询,该查询扫描数据库的大部分内容。这类查询在分析中很常见(参见“分析系统与操作系统”),或者可能是定期完整性检查的一部分,以确保一切正常(监测数据损坏)。如果这些查询在不同时间点观察数据库的不同部分,可能会返回无意义的结果。
快照隔离34 是解决此问题最常见的方案。其理念是每个事务从数据库的一致快照中读取数据——也就是说,事务看到的是在事务开始时已提交的所有数据。即使数据随后被另一个事务更改,每个事务也只会看到特定时间点的旧数据。
快照隔离对长时间运行的只读查询(如备份和分析)是一个福音。如果在查询执行的同时,操作的数据正在变化,那么很难推理查询的含义。当一个事务能够看到数据库的一致快照,并在特定时间点被冻结时,理解起来就容易得多。
快照隔离是一种流行的特性:PostgreSQL、使用 InnoDB 存储引擎的 MySQL、Oracle、SQL Server 等都支持其变体,尽管详细行为因系统而异273536。一些数据库,如 Oracle、TiDB 和 Aurora DSQL,甚至选择快照隔离作为其最高隔离级别。
多版本并发控制(MVCC)
与已提交读隔离类似,快照隔离的实现通常使用写锁来防止脏写(参见“实现已提交读”),这意味着进行写操作的事务可能会阻塞另一个写入同一行的事务的进展。然而,读取操作不需要任何锁。从性能的角度来看,快照隔离的一个关键原则是读取者永远不会阻塞写入者,而写入者也永远不会阻塞读取者。这使得数据库能够在处理写入操作的同时,处理基于一致快照的长时间运行的读取查询,而两者之间没有任何锁竞争。
为了实现快照隔离,数据库使用了一种我们在图 8-4 中看到的防止脏读机制的推广。数据库必须可能保留同一行的多个不同已提交版本,而不是每行两个版本(已提交版本和被覆盖但尚未提交的版本),因为各种进行中的事务可能需要在不同时间点查看数据库的状态。由于它并排维护多版本的行,这种技术被称为多版本并发控制(MVCC)。
图 8-7 说明了基于 MVCC 的快照隔离在 PostgreSQL 中的实现353738(其他实现类似)。当一个事务开始时,它会被分配一个唯一的、始终递增的事务 ID( txid )。每当一个事务向数据库写入任何内容时,它写入的数据会标记上写入者的事务 ID。(准确地说,PostgreSQL 中的事务 ID 是 32 位整数,因此在大约 40 亿个事务后会溢出。清理过程会执行清理以确保溢出不会影响数据。)

表中的每一行都有一个 inserted_by 字段,包含将该行插入表中的事务的 ID。此外,每一行还有一个 deleted_by 字段,最初是空的。如果一个事务删除了一行,该行并不会实际从数据库中移除,而是通过将 deleted_by 字段设置为请求删除的事务的 ID 来标记为删除。在稍后的某个时间,当确定没有事务可以再访问已删除的数据时,数据库中的垃圾回收过程会移除任何标记为删除的行并释放它们的空间。
更新在内部被转换为删除和插入。例如,在图 8-7 中,事务 13 从账户 2 中扣除 100 美元,将余额从 500 美元更改为 400 美元。现在 accounts 表实际上包含账户 2 的两行:一行余额为 500 美元,已被事务 13 标记为删除,另一行余额为 400 美元,由事务 13 插入。
所有版本的行都存储在同一个数据库堆中(参见“在索引中存储值”),无论写入它们的事务是否已提交。相同行的版本形成一个链表,可以从最新版本到最旧版本,或反向迭代,以便查询可以在内部遍历行的所有版本3940。
观察一致快照的可见性规则
当一个事务从数据库读取时,使用事务 ID 来决定它可以看到哪些行版本以及哪些是不可见的。通过仔细定义可见性规则,数据库可以向应用程序呈现数据库的一致快照。其大致工作原理如下38:
-
在每个事务开始时,数据库会列出当时所有正在进行的其他事务(尚未提交或中止)。那些事务所做的任何写入都会被忽略,即使这些事务随后提交。这确保我们看到的快照是一致的,不会受到其他事务提交的影响。
-
任何由后续事务 ID(即在当前事务开始后开始的事务,因此不包括在进行中的事务列表中)所做的写入都会被忽略,无论这些事务是否已提交。
-
任何被中止事务所做的写入都会被忽略,无论该中止发生在何时。这有一个好处,即当事务中止时,我们不需要立即从存储中删除它所写的行,因为可见性规则会将它们过滤掉。垃圾回收过程可以稍后再将它们删除。
-
所有其他写入对应用程序的查询都是可见的。
这些规则适用于行的插入和删除。在图 8-7 中,当事务 12 从账户 2 读取时,它看到的余额为 500 美元,因为 500 美元余额的删除是由事务 13 进行的(根据规则 2,事务 12 无法看到事务 13 进行的删除),而 400 美元余额的插入尚不可见(根据同一规则)。
换句话说,如果以下两个条件都成立,则该行是可见的:
-
在读取者的事务开始时,插入该行的事务已经提交。
-
该行未标记为删除,或者如果标记了删除,则请求删除的事务在读取事务开始时尚未提交。
长时间运行的事务可能会继续使用快照很长时间,继续读取那些(从其他事务的角度来看)早已被覆盖或删除的值。通过每次更改值时插入新版本而不是就地更新值,数据库可以提供一致的快照,同时仅产生少量开销。
索引和快照隔离
多版本数据库中的索引是如何工作的?最常见的方法是每个索引条目指向与该条目匹配的行的一个版本(要么是最旧的版本,要么是最新的版本)。每个行版本可能包含对下一个最旧或下一个最新版本的引用。使用索引的查询必须遍历行以找到一个可见的,并且其值与查询所寻找的匹配。当垃圾回收移除不再对任何事务可见的旧行版本时,相应的索引条目也可以被移除。
许多实现细节会影响多版本并发控制的性能3940。例如,PostgreSQL 具有优化功能,可以避免在同一页面上存放同一行的不同版本时更新索引35。其他一些数据库则避免存储修改行的完整副本,仅存储版本之间的差异以节省空间。
另一种方法在 CouchDB、Datomic 和 LMDB 中使用。尽管它们也使用 B 树(见“B 树”),但它们使用一种不可变(写时复制)变体,在更新时不会覆盖树的页面,而是创建每个修改页面的新副本。父页面一直复制到树的根部,并更新以指向其子页面的新版本。任何未受到写入影响的页面不需要被复制,可以与新树共享41。
使用不可变的 B 树,每个写事务(或事务批次)都会创建一个新的 B 树根,而特定的根是在创建时数据库的一致快照。无需根据事务 ID 过滤行,因为后续的写入无法修改现有的 B 树;它们只能创建新的树根。这种方法还需要一个后台进程进行压缩和垃圾回收。
快照隔离、可重复读和命名混淆
MVCC 是一种常用的数据库实现技术,通常用于实现快照隔离。然而,不同的数据库有时会使用不同的术语来指代同一事物:例如,在 PostgreSQL 中,快照隔离被称为“可重复读”,而在 Oracle 中则称为“可串行化”27。有时,不同的系统使用相同的术语来表示不同的含义:例如,在 PostgreSQL 中,“可重复读”意味着快照隔离,而在 MySQL 中则意味着一种实现了 MVCC 的一致性弱于快照隔离的方式36。
这种命名混淆的原因在于 SQL 标准并没有快照隔离的概念,因为该标准是基于 System R 在 1975 年对隔离级别的定义3,而那时快照隔离尚未被发明。相反,它定义了可重复读,这在表面上看起来与快照隔离相似。PostgreSQL 将其快照隔离级别称为“可重复读”,因为它符合标准的要求,因此可以声称符合标准。
不幸的是,SQL 标准对隔离级别的定义存在缺陷——它模糊、不精确,并且没有达到标准应有的独立于实现的程度34。尽管有几个数据库实现了可重复读,但它们实际提供的保证存在很大差异,尽管表面上看是标准化的27。在研究文献中有对可重复读的正式定义4243,但大多数实现并不满足该正式定义。而且,IBM Db2 使用“可重复读”来指代可串行化性11。
因此,没人真正知道可重复读意味着什么。
防止丢失更新
我们迄今讨论的已提交读和快照隔离级别主要是关于在并发写入的情况下,读事务可以看到的保证。我们大多忽略了两个事务并发写入的问题——我们只讨论了脏写(见“无脏写”),这是一种可能发生的特定类型的写写冲突。
在并发写入事务之间可能发生几种其他有趣的冲突。其中最著名的是丢失更新问题,如图 8-1 所示,例子是两个并发的计数器递增。
丢失更新问题可能发生在应用程序从数据库读取某个值,修改它,然后写回修改后的值(读-修改-写循环)。如果两个事务同时执行此操作,则其中一个修改可能会丢失,因为第二次写入不包括第一次修改。(我们有时会说后面的写入覆盖了前面的写入。)这种模式在不同的场景中都会出现:
-
递增计数器或更新账户余额(需要读取当前值、计算新值并写回更新后的值)
-
对复杂值进行本地更改,例如,在 JSON 文档中向列表添加元素(需要解析文档、进行更改并写回修改后的文档)
-
两个用户同时编辑一个维基页面,每个用户通过将整个页面内容发送到服务器来保存他们的更改,覆盖数据库中当前的内容。
由于这是一个非常常见的问题,因此开发了多种解决方案。
原子写操作
许多数据库提供原子更新操作,这消除了在应用程序代码中实现读-修改-写循环的需要。如果您的代码可以用这些操作来表达,它们通常是最佳解决方案。例如,以下指令在大多数关系数据库中是并发安全的:
UPDATE counters SET value = value + 1 WHERE key = 'foo';类似地,像 MongoDB 这样的文档数据库提供原子操作,用于对 JSON 文档的一部分进行本地修改,而 Redis 提供原子操作,用于修改优先队列等数据结构。并非所有写入操作都可以轻松地用原子操作来表达——例如,维基页面的更新涉及任意文本编辑,这可以通过“CRDTs 和操作转换”中讨论的算法来处理——但在可以使用原子操作的情况下,它们通常是最佳选择。
原子操作通常通过在读取对象时对其进行独占锁定来实现,以便在更新应用之前,其他事务无法读取该对象。另一个选项是强制所有原子操作在单个线程上执行。
不幸的是,对象关系映射(ORM)框架使得意外编写执行不安全的读-修改-写循环的代码变得容易,而不是使用数据库提供的原子操作。这可能是难以通过测试发现的微妙错误的来源。
显式锁定
如果数据库的内置原子操作没有提供必要的功能,防止丢失更新的另一个选项是应用程序显式锁定将要更新的对象。然后,应用程序可以执行读-修改-写循环,如果其他事务尝试同时更新或锁定同一对象,则必须等待第一个读-修改-写循环完成。
例如,考虑一个多人游戏,其中多个玩家可以同时移动同一个棋子。在这种情况下,原子操作可能不足够,因为应用程序还需要确保玩家的移动遵循游戏规则,这涉及一些逻辑,而这些逻辑无法合理地实现为数据库查询。相反,您可以使用锁来防止两个玩家同时移动同一棋子,如示例 8-1 所示。
示例 8-1. 显式锁定行以防止更新丢失
BEGIN TRANSACTION;
SELECT \* FROM figures
WHERE name = 'robot' AND game_id = 222
FOR UPDATE;
-- Check whether move is valid, then update the position
-- of the piece that was returned by the previous SELECT.
UPDATE figures SET position = 'c4' WHERE id = 1234;
COMMIT;
FOR UPDATE子句表示数据库应对该查询返回的所有行加锁。
这可以工作,但要做到这一点,您需要仔细考虑您的应用程序逻辑。在代码中很容易忘记添加必要的锁,从而引入竞争条件。
此外,如果您锁定多个对象,就存在死锁的风险,即两个或多个事务在等待彼此释放锁。许多数据库会自动检测死锁,并中止其中一个相关事务,以便系统能够继续进行。您可以通过重试被中止的事务在应用程序层面处理这种情况。
自动检测丢失更新
原子操作和锁是通过强制读-修改-写周期顺序执行来防止丢失更新的方法。另一种选择是允许它们并行执行,如果事务管理器检测到丢失更新,则中止事务并强制其重试读-修改-写周期。
这种方法的一个优点是数据库可以高效地与快照隔离一起执行此检查。实际上,PostgreSQL 的可重复读、Oracle 的可串行化和 SQL Server 的快照隔离级别会自动检测到何时发生了丢失更新,并中止有问题的事务。然而,MySQL/InnoDB 的可重复读并不检测丢失更新2736。一些作者3443 认为,数据库必须防止丢失更新才能被认为提供快照隔离,因此根据这个定义,MySQL 并不提供快照隔离。
丢失更新检测是一个很好的特性,因为它不需要应用程序代码使用任何特殊的数据库功能——你可能会忘记使用锁或原子操作,从而引入一个错误,但丢失更新检测是自动发生的,因此出错的可能性较小。然而,你也必须在应用程序层面重试被中止的事务。
条件写入(比较并交换)
在不提供事务的数据库中,有时会发现一种条件写操作,它可以通过允许更新仅在自上次读取后值未发生变化的情况下进行,从而防止丢失更新(在“单对象写入”中提到过)。如果当前值与您之前读取的值不匹配,则更新将无效,读-修改-写循环必须重试。这相当于许多 CPU 支持的原子比较和交换(CAS)指令。
例如,为了防止两个用户同时更新同一维基页面,您可能会尝试类似这样的做法,期望只有在用户开始编辑后页面内容没有变化的情况下才进行更新:
-- This may or may not be safe, depending on the database implementation
UPDATE wiki_pages SET content = 'new content'
WHERE id = 1234 AND content = 'old content';如果内容已更改且不再匹配 'old content' ,则此更新将无效,因此您需要检查更新是否生效,并在必要时重试。您也可以使用一个版本号列,在每次更新时递增,并仅在当前版本号未更改的情况下应用更新,而不是比较完整内容。这种方法有时被称为乐观锁定44。
请注意,如果另一个事务同时修改了 content ,则根据 MVCC 可见性规则,新内容可能不可见(请参见“观察一致快照的可见性规则”)。许多 MVCC 的实现对此场景的可见性规则有一个例外,即其他事务写入的值在 UPDATE 和 DELETE 查询的 WHERE 子句的评估中是可见的,即使这些写入在快照中并不可见。
冲突解决和复制
在复制数据库中(请参见第 6 章),防止丢失更新又有了另一个维度:由于它们在多个节点上有数据的副本,并且数据可能在不同节点上同时被修改,因此需要采取一些额外步骤以防止丢失更新。
锁和条件写操作假设数据有一个最新的单一副本。然而,具有多主或无主复制的数据库通常允许多个写操作同时发生并异步复制,因此无法保证数据有一个最新的单一副本。因此,基于锁或条件写的技术在这种情况下不适用。(我们将在[链接待更新]中更详细地讨论这个问题。)
相反,正如在“处理冲突写入”中讨论的那样,在这种复制数据库中,一个常见的方法是允许并发写入创建多个冲突的值版本(也称为兄弟版本),并使用应用程序代码或特殊数据结构在事后解决和合并这些版本。
合并冲突值可以防止丢失更新,如果这些更新是可交换的(即,你可以在不同的副本上以不同的顺序应用它们,仍然得到相同的结果)。例如,递增计数器或向集合中添加元素是可交换操作。这就是我们在“CRDTs 和操作转换”中遇到的 CRDT 的理念。然而,一些操作,如条件写入,无法做到可交换。
另一方面,最后写入胜出(LWW)冲突解决方法容易导致丢失更新,如“最后写入胜出(丢弃并发写入)”中所讨论的。不幸的是,LWW 是许多复制数据库中的默认设置。
写偏斜和幻影
在前面的部分中,我们看到了脏写和丢失更新,这两种竞争条件可能发生在不同的事务同时尝试写入同一对象时。为了避免数据损坏,这些竞争条件需要被防止——要么由数据库自动处理,要么通过手动保护措施,如使用锁或原子写操作。
然而,这并不是潜在竞争条件列表的结束,这些条件可能在并发写入之间发生。在本节中,我们将看到一些更微妙的冲突示例。
首先,想象这个例子:你正在为医生编写一个应用程序,以管理他们在医院的值班。医院通常会尽量确保在任何时候都有几位医生值班,但绝对必须至少有一位医生在值班。医生可以放弃他们的班次(例如,如果他们自己生病),前提是至少有一位同事在该班次中保持值班4546。
现在想象一下,爱丽丝和鲍勃是某个特定班次的两位值班医生。他们都感到不适,因此都决定请假。不幸的是,他们恰好在大约同一时间点击了下线的按钮。接下来发生的事情如图 8-8 所示。

在每个事务中,您的应用程序首先检查是否有两个或更多的医生正在值班;如果是,它假设一个医生可以安全地下班。由于数据库使用快照隔离,两个检查都返回 2 ,因此两个事务都继续到下一个阶段。爱丽丝更新她自己的记录以将自己下班,鲍勃也同样更新自己的记录。两个事务都提交,现在没有医生在值班。您对至少有一名医生值班的要求被违反了。
特征化写偏差
这种异常被称为写偏差34。它既不是脏写也不是丢失更新,因为这两个事务正在更新两个不同的对象(分别是爱丽丝和鲍勃的值班记录)。这里发生冲突并不那么明显,但这绝对是一个竞争条件:如果这两个事务一个接一个地运行,第二位医生将被阻止下班。异常行为的发生仅仅是因为这两个事务并发运行。
您可以将写偏斜视为丢失更新问题的一个概括。如果两个事务读取相同的对象,然后更新其中一些对象(不同的事务可能更新不同的对象),则可能发生写偏斜。在不同事务更新同一对象的特殊情况下,您会遇到脏写或丢失更新异常(具体取决于时机)。
我们看到有多种不同的方法来防止丢失更新。对于写偏斜,我们的选择更加有限:
-
原子单对象操作无济于事,因为涉及多个对象。
-
不幸的是,在某些快照隔离的实现中发现的丢失更新自动检测也无能为力:在 PostgreSQL 的可重复读、MySQL/InnoDB 的可重复读、Oracle 的可串行化或 SQL Server 的快照隔离级别中,写偏差不会被自动检测到27。自动防止写偏差需要真正的可串行化隔离(见“可串行化”)。
-
一些数据库允许您配置约束,这些约束随后由数据库强制执行(例如,唯一性、外键约束或对特定值的限制)。然而,为了指定至少必须有一名医生值班,您需要一个涉及多个对象的约束。大多数数据库不支持此类内置约束,但您可以通过触发器或物化视图来实现,如“一致性”一节中所讨论的9。
如果无法使用可串行化的隔离级别,那么在这种情况下,第二好的选择可能是显式锁定事务所依赖的行。在医生的例子中,你可以写如下内容:
BEGIN TRANSACTION;
SELECT \* FROM doctors
WHERE on_call = true
AND shift_id = 1234 FOR UPDATE;
UPDATE doctors
SET on_call = false
WHERE name = 'Alice'
AND shift_id = 1234;
COMMIT;与之前一样,
FOR UPDATE告诉数据库锁定此查询返回的所有行。
更多写偏斜的例子
写偏斜乍一看可能是一个深奥的问题,但一旦你意识到它的存在,你可能会注意到更多可能发生这种情况的场景。以下是一些更多的例子:
会议室预订系统
假设您想要确保同一时间内不能有两个预订同一会议室的情况。当有人想要进行预订时,您首先检查是否有任何冲突的预订(即,同一房间的预订时间范围重叠),如果没有找到冲突,您就可以创建会议(见示例 8-2)。
示例 8-2. 会议室预订系统尝试避免重复预订(在快照隔离下不安全)
BEGIN TRANSACTION;
-- Check for any existing bookings that overlap with the period of noon-1pm
SELECT COUNT(\*) FROM bookings
WHERE room_id = 123 AND
end_time > '2025-01-01 12:00' AND start_time < '2025-01-01 13:00';
-- If the previous query returned zero:
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 中,我们使用锁来防止丢失更新(即确保两个玩家不能同时移动同一个棋子)。然而,锁并不能防止玩家将两个不同的棋子移动到棋盘上的同一位置,或者可能进行其他违反游戏规则的操作。根据你所执行的规则类型,你可能能够使用唯一约束,但否则你就容易受到写偏差的影响。
声明用户名
在一个每个用户都有唯一用户名的网站上,两个用户可能会同时尝试创建相同用户名的账户。您可以使用事务来检查一个名字是否被占用,如果没有,则创建一个该名字的账户。然而,正如之前的例子所示,这在快照隔离下并不安全。幸运的是,唯一约束在这里是一个简单的解决方案(第二个尝试注册该用户名的事务将因违反约束而被中止)。
防止双重消费
一个允许用户花费金钱或积分的服务需要检查用户是否没有超过他们的余额。您可以通过在用户的账户中插入一个暂定的消费项目,列出账户中的所有项目,并检查总和是否为正来实现这一点。在写偏斜的情况下,可能会发生两个消费项目同时插入,导致余额变为负数,但这两个事务都没有注意到对方。
导致写偏斜的幻影
所有这些例子遵循类似的模式:
-
一个
SELECT查询通过搜索匹配某些搜索条件的行来检查某些要求是否得到满足(至少有两个医生在值班,该时间段内该房间没有现有预订,董事会上的位置上没有其他棋子,用户名尚未被占用,账户中仍有余额)。 -
根据第一次查询的结果,应用程序代码决定如何继续(可能继续进行操作,或者向用户报告错误并中止)。
-
如果应用程序决定继续,它会向数据库进行写入( INSERT 、 UPDATE 或 DELETE )并提交事务。
此写入的效果改变了步骤 2 决策的前提条件。换句话说,如果在提交写入后重复步骤 1 中的 SELECT 查询,您将得到不同的结果,因为写入改变了与搜索条件匹配的行集(现在值班的医生少了一位,会议室现在已被预订,董事会的职位现在被移动的图形占据,用户名现在已被占用,账户中的钱现在减少了)。
这些步骤可能以不同的顺序发生。例如,您可以先进行写入,然后进行 SELECT 查询,最后根据查询的结果决定是中止还是提交。
在值班医生的例子中,步骤 3 中被修改的行是步骤 1 中返回的行之一,因此我们可以通过在步骤 1 中锁定这些行来确保事务的安全性并避免写偏斜( SELECT FOR UPDATE )。然而,其他四个例子则不同:它们检查是否没有行匹配某些搜索条件,而写操作则添加一行匹配相同条件。如果步骤 1 中的查询没有返回任何行, SELECT FOR UPDATE 就无法对任何内容加锁47。
这种现象,即一个事务中的写操作改变了另一个事务中的搜索查询结果,被称为幻读4。快照隔离可以避免只读查询中的幻读,但在我们讨论的读写事务中,幻读可能导致特别棘手的写偏斜情况。ORM 生成的 SQL 也容易出现写偏斜4849。
实现冲突
如果幻读的问题在于没有对象可以附加锁,那么我们是否可以在数据库中人为引入一个锁对象呢?
例如,在会议室预订的案例中,你可以想象创建一个时间段和房间的表格。该表中的每一行对应于特定房间在特定时间段(例如,15 分钟)内的情况。你提前为所有可能的房间和时间段组合创建行,例如,为接下来的六个月。
现在,一个想要创建预订的事务可以锁定( SELECT FOR UPDATE )表中对应于所需房间和时间段的行。在获取锁后,它可以检查是否有重叠的预订,并像之前一样插入新的预订。请注意,额外的表并不是用来存储预订信息的——它纯粹是一个锁的集合,用于防止对同一房间和时间范围的预订进行并发修改。
这种方法被称为物化冲突,因为它将一个幻影转变为数据库中一组具体行的锁冲突12。不幸的是,弄清楚如何物化冲突可能很困难且容易出错,并且让并发控制机制渗透到应用程序数据模型中是很丑陋的。出于这些原因,物化冲突应被视为最后的手段,如果没有其他选择的话。在大多数情况下,可串行化的隔离级别更为可取。
可串行化
在本章中,我们看到了一些容易发生竞争条件的事务示例。一些竞争条件通过已提交读取和快照隔离级别得以防止,但其他的则没有。我们遇到了一些特别棘手的例子,如写偏斜和幻影。这是一个悲惨的情况:
-
隔离级别难以理解,并且在不同的数据库中实现不一致(例如,“可重复读”的含义差异显著)。
-
如果你查看你的应用程序代码,很难判断在特定的隔离级别下运行是否安全——尤其是在大型应用程序中,你可能并不知道所有可能同时发生的事情。
-
目前没有好的工具来帮助我们检测竞争条件。原则上,静态分析可能有所帮助31,但研究技术尚未找到实际应用的途径。测试并发问题很困难,因为它们通常是非确定性的——问题只会在时机不巧时发生。
这并不是一个新问题——自从 1970 年代首次引入弱隔离级别以来情况一直如此3。研究人员一直给出的答案很简单:使用可串行化隔离!
可串行化隔离是最强的隔离级别。它保证即使事务可能并行执行,最终结果也与它们一个接一个串行执行时的结果相同,没有任何并发。因此,数据库保证如果事务在单独运行时表现正确,那么在并发运行时也会继续正确——换句话说,数据库防止所有可能的竞争条件。
但是,如果可串行化隔离比弱隔离级别的混乱要好得多,那么为什么不是每个人都在使用它呢?要回答这个问题,我们需要看看实现可串行化的选项,以及它们的性能。今天提供可串行化的大多数数据库使用三种技术中的一种,我们将在本章的其余部分进行探讨:
-
按串行顺序实际执行事务(见“实际串行执行”)
-
两阶段锁定(见“两阶段锁定(2PL)”),这是几十年来唯一可行的选项
-
乐观并发控制技术,如可串行化快照隔离(见“可串行化快照隔离(SSI)”)
目前,我们将主要在单节点数据库的上下文中讨论这些技术;在[链接即将到来]中,我们将研究它们如何推广到涉及分布式系统中多个节点的事务。
实际串行执行
避免并发问题的最简单方法是完全消除并发:在单个线程上按串行顺序一次只执行一个事务。通过这样做,我们完全避免了检测和防止事务之间冲突的问题:由此产生的隔离在定义上是可串行化的。
尽管这看起来是一个显而易见的想法,但直到 2000 年代,数据库设计师才决定单线程循环执行事务是可行的。如果在过去的 30 年中,多线程并发被认为是获得良好性能的必要条件,那么是什么改变使得单线程执行成为可能?
两个发展促使了这种重新思考:
RAM 变得足够便宜,以至于对于许多用例,现在在内存中保持整个活动数据集是可行的(见“将所有内容保留在内存中”)。当事务需要访问的所有数据都在内存中时,事务的执行速度比必须等待从磁盘加载数据时要快得多。
数据库设计者意识到,OLTP 事务通常很短,并且只进行少量的读写操作(见“分析系统与操作系统”)。相比之下,长时间运行的分析查询通常是只读的,因此可以在一致的快照上运行(使用快照隔离),而不需要在串行执行循环中进行。
例如,VoltDB/H-Store、Redis 和 Datomic 等系统实现了串行执行事务的方法 [ 56, 57, 58]。为单线程执行设计的系统有时可以比支持并发的系统表现得更好,因为它可以避免锁定的协调开销。然而,它的吞吐量仅限于单个 CPU 核心的吞吐量。为了充分利用这个单线程,事务需要以不同于传统形式的方式进行结构化。
将事务封装在存储过程内
在数据库的早期阶段,设计的初衷是数据库事务可以涵盖整个用户活动的流程。例如,预订机票是一个多阶段的过程(搜索航线、票价和可用座位;决定行程;预订行程中每个航班的座位;输入乘客信息;进行支付)。数据库设计者认为,如果整个过程都作为一个事务来处理,那将是很好的,这样就可以原子性地提交。
不幸的是,人类在做决定和响应方面非常缓慢。如果一个数据库事务需要等待用户的输入,数据库就需要支持潜在的巨量并发事务,其中大多数处于闲置状态。大多数数据库无法高效地做到这一点,因此几乎所有的 OLTP 应用程序都通过避免在事务中交互式地等待用户来保持事务的简短。在网络上,这意味着一个事务在同一个 HTTP 请求内提交——一个事务不会跨越多个请求。一个新的 HTTP 请求会启动一个新的事务。
尽管人类已被移出关键路径,但事务仍以交互式客户端/服务器的方式逐条执行。一个应用程序发出查询,读取结果,可能根据第一个查询的结果发出另一个查询,依此类推。查询和结果在运行于一台机器上的应用程序代码与运行于另一台机器上的数据库服务器之间来回传递。
在这种交互式事务风格中,应用程序与数据库之间的网络通信消耗了大量时间。如果在数据库中禁止并发,只处理一个事务,那么吞吐量将会非常糟糕,因为数据库大部分时间都在等待应用程序发出当前事务的下一个查询。在这种数据库中,必须并发处理多个事务才能获得合理的性能。
因此,采用单线程串行事务处理的系统不允许交互式多语句事务。相反,应用程序必须限制自己仅使用包含单个语句的事务,或者提前将整个事务代码提交给数据库,作为存储过程50。
交互式事务和存储过程之间的差异如图 8-9 所示。只要事务所需的所有数据都在内存中,存储过程就可以非常快速地执行,而无需等待任何网络或磁盘 I/O。

存储过程的优缺点
存储过程在关系数据库中存在已久,自 1999 年以来,它们已成为 SQL 标准(SQL/PSM)的一部分。由于各种原因,它们获得了相对较差的声誉:
-
传统上,每个数据库供应商都有自己用于存储过程的语言(Oracle 有 PL/SQL,SQL Server 有 T-SQL,PostgreSQL 有 PL/pgSQL 等)。这些语言没有跟上通用编程语言的发展,因此从今天的角度来看,它们显得相当丑陋和过时,并且缺乏大多数编程语言所拥有的库生态系统。
-
在数据库中运行的代码难以管理:与应用服务器相比,它更难调试,更难进行版本控制和部署,更难测试,并且难以与用于监控的指标收集系统集成。
-
数据库通常比应用服务器对性能更敏感,因为单个数据库实例通常被多个应用服务器共享。在数据库中编写不当的存储过程(例如,使用大量内存或 CPU 时间)可能会造成比在应用服务器中编写不当的代码更大的麻烦。
-
在一个允许租户编写自己存储过程的多租户系统中,在与数据库内核同一进程中执行不受信任的代码是一个安全风险51。
然而,这些问题是可以克服的。现代存储过程的实现已经放弃了 PL/SQL,而是使用现有的通用编程语言:VoltDB 使用 Java 或 Groovy,Datomic 使用 Java 或 Clojure,Redis 使用 Lua,MongoDB 使用 Javascript。
存储过程在应用逻辑无法轻易嵌入其他地方的情况下也很有用。例如,使用 GraphQL 的应用程序可能会通过 GraphQL 代理直接暴露其数据库。如果代理不支持复杂的验证逻辑,您可以直接在数据库中使用存储过程嵌入此类逻辑。如果数据库不支持存储过程,您将不得不在代理和数据库之间部署一个验证服务来进行验证。
通过存储过程和内存数据,在单个线程上执行所有事务变得可行。当存储过程不需要等待 I/O 并避免其他并发控制机制的开销时,它们可以在单个线程上实现相当好的吞吐量。
VoltDB 也使用存储过程进行复制:它不是将一个节点的事务写入复制到另一个节点,而是在每个副本上执行相同的存储过程。因此,VoltDB 要求存储过程是确定性的(在不同节点上运行时,必须产生相同的结果)。例如,如果一个事务需要使用当前的日期和时间,它必须通过特殊的确定性 API 来实现(有关确定性操作的更多细节,请参见“持久执行和工作流”)。这种方法称为状态机复制,我们将在[链接即将到来]中回到这个话题。
分片
串行执行所有事务使得并发控制变得简单,但将数据库的事务吞吐量限制在单台机器上单个 CPU 核心的速度。只读事务可以在其他地方执行,使用快照隔离,但对于高写入吞吐量的应用程序,单线程事务处理器可能成为一个严重的瓶颈。
为了扩展到多个 CPU 核心和多个节点,您可以对数据进行分片(见第 7 章),这在 VoltDB 中得到了支持。如果您能找到一种方法对数据集进行分片,使得每个事务只需要在单个分片内读取和写入数据,那么每个分片可以有自己的事务处理线程,与其他分片独立运行。在这种情况下,您可以为每个 CPU 核心分配一个分片,这样可以使您的事务吞吐量随着 CPU 核心数量的增加而线性扩展[^57]。
然而,对于任何需要访问多个分片的事务,数据库必须协调所有涉及的分片之间的事务。存储过程需要在所有分片上以锁步方式执行,以确保整个系统的可串行性。
由于跨分片事务具有额外的协调开销,因此它们的速度远远慢于单分片事务。VoltDB 报告的跨分片写入吞吐量约为每秒 1,000 次,这比其单分片吞吐量低几个数量级,并且无法通过增加更多机器来提高50。最近的研究探讨了使多分片事务更具可扩展性的方法52。
事务是否可以是单分片在很大程度上取决于应用程序使用的数据结构。简单的键值数据通常可以很容易地进行分片,但具有多个二级索引的数据可能需要大量的跨分片协调(见“分片和二级索引”)。
串行执行的总结
在某些限制条件下,事务的串行执行已成为实现可串行隔离的可行方式:
-
每个事务必须小且快,因为只需一个慢事务就能阻塞所有事务处理。
-
在活动数据集可以适合内存的情况下最为合适。很少访问的数据可以移动到磁盘,但如果需要在单线程事务中访问,系统会变得非常慢。
-
写入吞吐量必须足够低,以便在单个 CPU 核心上处理,否则事务需要分片,而不需要跨分片协调。
-
跨分片事务是可能的,但它们的吞吐量很难扩展。
两阶段锁定(2PL)
在大约 30 年的时间里,数据库中只有一种广泛使用的可串行化算法:两阶段锁定(2PL),有时称为强严格两阶段锁定(SS2PL),以区别于 2PL 的其他变体。
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 中的可重复读隔离级别27。
读者和写入者的阻塞是通过对数据库中每个对象加锁来实现的。锁可以是共享模式或独占模式(也称为多读者单写者锁)。锁的使用方式如下:
-
如果一个事务想要读取一个对象,它必须首先以共享模式获取锁。多个事务可以同时以共享模式持有锁,但如果另一个事务已经对该对象拥有独占锁,这些事务必须等待。
-
如果一个事务想要写入一个对象,它必须首先以独占模式获取锁。此时没有其他事务可以同时持有该锁(无论是共享模式还是独占模式),因此如果对象上已经存在锁,事务必须等待。
-
如果一个事务先读取然后写入一个对象,它可以将其共享锁升级为独占锁。升级的过程与直接获取独占锁是一样的。
-
在一个事务获取锁后,它必须继续持有该锁直到事务结束(提交或中止)。这就是“二阶段”名称的由来:第一阶段(当事务正在执行时)是获取锁的阶段,第二阶段(在事务结束时)是释放所有锁的阶段。
由于有如此多的锁在使用中,事务 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 的数据库实际上实现了索引范围锁定(也称为下一个键锁定),这是一种对谓词锁定的简化近似4653。
通过使谓词匹配更大集合的对象来简化谓词是安全的。例如,如果你有一个针对中午 12 点到下午 1 点期间房间 123 的预订的谓词锁,你可以通过锁定房间 123 在任何时间的预订来近似它,或者你可以通过锁定中午 12 点到下午 1 点期间的所有房间(不仅仅是房间 123)来近似它。这是安全的,因为任何匹配原始谓词的写入肯定也会匹配这些近似值。
在房间预订数据库中,您可能会在 room_id 列上建立索引,和/或在 start_time 和 end_time 上建立索引(否则在大型数据库上,前面的查询会非常慢):
-
假设您的索引在
room_id上,数据库使用此索引查找房间 123 的现有预订。现在,数据库可以简单地在此索引条目上附加一个共享锁,表示一个事务已搜索房间 123 的预订。 -
或者,如果数据库使用基于时间的索引来查找现有的预订,它可以在该索引中的一系列值上附加一个共享锁,表示一个事务已经搜索了与 2025 年 1 月 1 日中午 12 点到下午 1 点的时间段重叠的预订。
无论如何,搜索条件的近似值会附加到其中一个索引上。现在,如果另一个事务想要插入、更新或删除同一房间和/或重叠时间段的预订,它将不得不更新索引的同一部分。在此过程中,它会遇到共享锁,并被迫等待直到锁被释放。
这有效地防止了幻读和写 skew。索引范围锁的精确度不如谓词锁(它们可能会锁定比维持可串行性所严格必要的更大范围的对象),但由于它们的开销要低得多,因此是一个很好的折中方案。
如果没有合适的索引可以附加范围锁,数据库可以退回到对整个表的共享锁。这对性能不利,因为它会阻止所有其他事务写入该表,但这是一个安全的后备方案。
可串行快照隔离(SSI)
本章描绘了数据库中并发控制的严峻形势。一方面,我们有性能不佳的可串行化实现(两阶段锁定)或扩展性差的实现(串行执行)。另一方面,我们有性能良好的弱隔离级别,但容易出现各种竞争条件(丢失更新、写 skew、幻读等)。可串行化隔离和良好性能之间是否根本存在矛盾?
似乎不是:一种称为可序列化快照隔离(SSI)的算法提供了完全的可序列化性,与快照隔离相比,仅有小的性能损失。SSI 相对较新:它首次在 2008 年被描述4554。
今天,SSI 和类似的算法被用于单节点数据库(PostgreSQL 中的可序列化隔离级别46,SQL Server 的内存 OLTP/Hekaton55,以及 HyPer56),分布式数据库(CockroachDB5和 FoundationDB8),以及嵌入式存储引擎如 BadgerDB。
悲观与乐观并发控制
两阶段锁定是一种所谓的悲观并发控制机制:它基于这样的原则,即如果任何事情可能出错(如另一个事务持有的锁所示),那么最好等到情况再次安全后再采取任何行动。这就像互斥,用于保护多线程编程中的数据结构。
串行执行在某种意义上是极端的悲观:它本质上等同于每个事务在事务执行期间对整个数据库(或数据库的一个分片)拥有独占锁。我们通过使每个事务的执行非常快速来补偿这种悲观,因此它只需要短时间持有“锁”。
相比之下,可串行快照隔离是一种乐观的并发控制技术。在这个上下文中,乐观意味着如果发生潜在危险的事情,事务不会被阻塞,而是继续执行,希望一切都会顺利。当一个事务想要提交时,数据库会检查是否发生了任何不好的事情(即是否违反了隔离);如果发生了,事务将被中止并需要重试。只有以串行方式执行的事务才被允许提交。
乐观并发控制是一个古老的概念57,其优缺点已经被讨论了很长时间58。如果存在高争用(许多事务试图访问相同的对象),它的表现会很差,因为这会导致大量事务需要中止。如果系统已经接近其最大吞吐量,重试事务带来的额外负载可能会使性能变得更糟。
然而,如果有足够的备用容量,并且事务之间的争用不是太高,乐观并发控制技术往往比悲观的表现更好。通过可交换的原子操作可以减少争用:例如,如果多个事务同时想要增加一个计数器,那么增加的顺序并不重要(只要在同一事务中不读取计数器),因此可以在不冲突的情况下应用所有并发的增加操作。
如其名所示,SSI 基于快照隔离——也就是说,事务中的所有读取都是基于数据库的一致快照(参见“快照隔离和可重复读取”)。在快照隔离的基础上,SSI 增加了一种算法,用于检测读取和写入之间的序列化冲突,并确定哪些事务需要中止。
基于过时前提的决策
当我们之前讨论快照隔离中的写偏差时(参见“写偏差和幻影”),我们观察到一个反复出现的模式:一个事务从数据库中读取一些数据,检查查询结果,并根据它看到的结果决定采取某些行动(写入数据库)。然而,在快照隔离下,原始查询的结果在事务提交时可能已经不再是最新的,因为数据可能在此期间被修改。
换句话说,事务是基于一个前提(在事务开始时为真的事实,例如,“目前有两位医生在值班”)采取行动的。后来,当事务想要提交时,原始数据可能已经改变——这个前提可能不再成立。
当应用程序发出查询时(例如,“当前有多少医生在值班?”),数据库并不知道应用程序逻辑如何使用该查询的结果。为了安全起见,数据库需要假设查询结果的任何变化(前提)意味着该事务中的写入可能是无效的。换句话说,查询和事务中的写入之间可能存在因果依赖关系。为了提供可串行化的隔离,数据库必须检测事务可能基于过时前提进行操作的情况,并在这种情况下中止该事务。
数据库如何知道查询结果是否可能已更改?需要考虑两种情况:
-
检测读取过时的 MVCC 对象版本(未提交的写入发生在读取之前)
-
检测影响先前读取的写入(写入发生在读取之后)
检测过时的 MVCC 读取
请记住,快照隔离通常通过多版本并发控制(MVCC;参见“多版本并发控制(MVCC)”)来实现。当一个事务从 MVCC 数据库中的一致快照读取时,它会忽略在快照生成时尚未提交的其他事务所做的写入。
在图 8-10 中,事务 43 看到 Alice 的状态为 on_call = true ,因为事务 42(修改了 Alice 的待命状态)尚未提交。然而,当事务 43 想要提交时,事务 42 已经提交。这意味着在从一致快照读取时被忽略的写入现在已经生效,事务 43 的前提不再成立。当一个写入者插入之前不存在的数据时,情况变得更加复杂(参见“导致写偏斜的幻影”)。我们将在“检测影响先前读取的写入”中讨论检测幻影写入的 SSI。

为了防止这种异常,数据库需要跟踪何时由于 MVCC 可见性规则而忽略另一个事务的写入。当事务想要提交时,数据库检查是否有任何被忽略的写入现在已被提交。如果是这样,事务必须被中止。
为什么要等到提交时再处理?为什么不在检测到过时读取时立即中止事务 43?如果事务 43 是只读事务,就不需要中止,因为没有写偏差的风险。在事务 43 进行读取时,数据库尚不知道该事务是否会在后续执行写操作。此外,事务 42 在事务 43 提交时可能仍会中止或仍未提交,因此读取结果最终可能并没有过时。通过避免不必要的中止,SSI 保持了快照隔离对长时间运行的读取的支持,这些读取来自一致的快照。
检测影响先前读取的写操作
第二种需要考虑的情况是另一个事务在数据被读取后修改了数据。此情况在图 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 利用这一理论来减少不必要的中止数量1246。
与两阶段锁定相比,可串行化快照隔离的一个主要优势是一个事务不需要等待另一个事务持有的锁而被阻塞。与快照隔离下的情况一样,写入者不会阻塞读取者,反之亦然。这一设计原则使得查询延迟变得更加可预测且变化更小。特别是,只读查询可以在一致的快照上运行,而不需要任何锁,这对于读密集型工作负载非常有吸引力。
与串行执行相比,可串行快照隔离不受单个 CPU 核心吞吐量的限制:例如,FoundationDB 将序列化冲突的检测分布到多个机器上,从而允许其扩展到非常高的吞吐量。即使数据可能分布在多个机器上,事务仍然可以在多个分片中读取和写入数据,同时确保可串行隔离。
与非可串行快照隔离相比,检查可串行性违规的需求引入了一些性能开销。这些开销的显著性是一个有争议的问题:一些人认为可串行性检查不值得59,而另一些人则认为可串行性的性能现在已经很好,以至于不再需要使用较弱的快照隔离56。
中止的频率显著影响 SSI 的整体性能。例如,一个长时间读取和写入数据的事务可能会遇到冲突并中止,因此 SSI 要求读写事务相对较短(长时间运行的只读事务是可以的)。然而,SSI 对慢事务的敏感性低于两阶段锁定或串行执行。
总结
事务是一个抽象层,允许应用程序假装某些并发问题以及某些类型的硬件和软件故障不存在。大量错误被简化为一个简单的事务中止,应用程序只需重试即可。
在本章中,我们看到了许多事务帮助防止的问题示例。并不是所有应用程序都容易受到这些问题的影响:对于访问模式非常简单的应用程序,例如仅读取和写入单个记录,可能可以在没有事务的情况下进行管理。然而,对于更复杂的访问模式,事务可以大大减少您需要考虑的潜在错误情况的数量。
没有事务,各种错误场景(进程崩溃、网络中断、停电、磁盘满、意外并发等)意味着数据可能以各种方式变得不一致。例如,非规范化的数据很容易与源数据不同步。没有事务,推理复杂交互访问对数据库的影响变得非常困难。
在本章中,我们特别深入探讨了并发控制的主题。我们讨论了几种广泛使用的隔离级别,特别是已提交读、快照隔离(有时称为可重复读)和可串行化。我们通过讨论各种竞争条件的例子来描述这些隔离级别,汇总在表 8-1 中:
表 8-1. 各种隔离级别下可能发生的异常总结
| 隔离级别 | 脏读 | 读取偏斜 | 幻读 | 丢失更新 | 写入偏差 |
|---|---|---|---|---|---|
| Read uncommitted | ✗ 可能 | ✗ 可能 | ✗ 可能 | ✗ 可能 | ✗ 可能 |
| Read committed | ✓ 已防止 | ✗ 可能 | ✗ 可能 | ✗ 可能 | ✗ 可能 |
| 快照隔离 | ✓ 已防止 | ✓ 已防止 | ✓ 已防止 | ? 视情况而定 | ✗ 可能 |
| 可序列化 | ✓ 已防止 | ✓ 已防止 | ✓ 已防止 | ✓ 已防止 | ✓ 已防止 |
脏读
一个客户端在另一个客户端的写入尚未提交之前读取了这些写入。读取已提交的隔离级别及更强的级别可以防止脏读。
脏写
一个客户端覆盖了另一个客户端已写入但尚未提交的数据。几乎所有的事务实现都防止脏写。
读取偏斜
一个客户端在不同时间点看到数据库的不同部分。某些读取偏斜的情况也被称为不可重复读取。这个问题通常通过快照隔离来防止,快照隔离允许事务从与某一特定时间点相对应的一致快照中读取数据。它通常通过多版本并发控制(MVCC)来实现。
丢失更新
两个客户端同时执行读-修改-写循环。一个客户端覆盖了另一个客户端的写入,而没有包含其更改,因此数据丢失。一些快照隔离的实现会自动防止这种异常,而其他实现则需要手动锁定( SELECT FOR UPDATE )。
写入偏差
一个事务读取某些内容,根据它看到的值做出决策,并将该决策写入数据库。然而,在写入完成时,决策的前提条件不再成立。只有可串行化隔离可以防止这种异常。
幻读
一个事务读取符合某些搜索条件的对象。另一个客户端进行了一次写入,影响了该搜索的结果。快照隔离防止了简单的幻读,但在写入偏斜的情况下,幻读需要特殊处理,例如索引范围锁。
弱隔离级别可以防止一些异常,但让你,作为应用程序开发者,手动处理其他异常(例如,使用显式锁定)。只有可串行化隔离可以防止所有这些问题。我们讨论了实现可串行化事务的三种不同方法:
字面上按顺序执行事务
如果你能让每个事务的执行速度非常快(通常通过使用存储过程),并且事务吞吐量低到可以在单个 CPU 核心上处理或可以进行分片,那么这是一个简单有效的选项。
两阶段锁定
几十年来,这一直是实现可串行性的标准方法,但许多应用程序因为性能差而避免使用它。
可序列化快照隔离(SSI)
一种相对较新的算法,避免了之前方法的大多数缺点。它采用乐观的方法,允许事务在不阻塞的情况下进行。当一个事务想要提交时,会进行检查,如果执行不是可串行的,则会中止该事务。
本章中的示例使用了关系数据模型。然而,正如在“多对象事务的必要性”中讨论的那样,事务是一个有价值的数据库特性,无论使用哪种数据模型。
在本章中,我们主要在单机数据库的上下文中探讨了思想和算法。分布式数据库中的事务带来了新的困难挑战,我们将在接下来的两章中讨论这些挑战。
参考文献
Footnotes
-
Steven J. Murdoch. What went wrong with Horizon: learning from the Post Office Trial. benthamsgaze.org, July 2021. Archived at perma.cc/CNM4-553F ↩
-
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 ↩
-
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 ↩ ↩2 ↩3 ↩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 ↩ ↩2 ↩3
-
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 ↩ ↩2
-
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 ↩
-
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. ↩
-
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 ↩ ↩2
-
Tony Andrews. Enforcing Complex Constraints in Oracle. tonyandrews.blogspot.co.uk, October 2004. Archived at archive.org ↩ ↩2
-
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. ↩
-
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. ↩ ↩2 ↩3 ↩4
-
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 ↩ ↩2 ↩3
-
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. ↩
-
Laurie Denness. SSDs: A Gift and a Curse. laur.ie, June 2015. Archived at perma.cc/6GLP-BX3T ↩
-
Adam Surak. When Solid State Drives Are Not That Solid. blog.algolia.com, June 2015. Archived at perma.cc/CBR9-QZEE ↩
-
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 ↩
-
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 ↩
-
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. ↩
-
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 ↩
-
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. ↩
-
Chris Siebenmann. Unix’s File Durability Problem. utcc.utoronto.ca, April 2016. Archived at perma.cc/VSS8-5MC4 ↩
-
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. ↩
-
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. ↩
-
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. ↩
-
Don Allison. SSD Storage – Ignorance of Technology Is No Excuse. blog.korelogic.com, March 2015. Archived at perma.cc/9QN4-9SNJ ↩
-
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 ↩
-
Martin Kleppmann. Hermitage: Testing the ‘I’ in ACID. martin.kleppmann.com, November 2014. Archived at perma.cc/KP2Y-AQGK ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8
-
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 ↩ ↩2
-
Tristan D’Agosta. BTC Stolen from Poloniex. bitcointalk.org, March 2014. Archived at perma.cc/YHA6-4C5D ↩
-
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 ↩
-
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. ↩ ↩2
-
Michael Melanson. Transactions: The Limits of Isolation. michaelmelanson.net, November 2014. Archived at perma.cc/RG5R-KMYZ ↩
-
Edward Kim. How ACH works: A developer perspective — Part 1. engineering.gusto.com, April 2014. Archived at perma.cc/7B2H-PU94 ↩
-
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 ↩ ↩2 ↩3 ↩4 ↩5
-
Bruce Momjian. MVCC Unmasked. momjian.us, July 2014. Archived at perma.cc/KQ47-9GYB ↩ ↩2 ↩3
-
Peter Alvaro and Kyle Kingsbury. MySQL 8.0.34. jepsen.io, December 2023. Archived at perma.cc/HGE2-Z878 ↩ ↩2 ↩3
-
Egor Rogov. PostgreSQL 14 Internals. postgrespro.com, April 2023. Archived at perma.cc/FRK2-D7WB ↩
-
Hironobu Suzuki. The Internals of PostgreSQL. interdb.jp, 2017. ↩ ↩2
-
Andy Pavlo and Bohan Zhang. The Part of PostgreSQL We Hate the Most. cs.cmu.edu, April 2023. Archived at perma.cc/XSP6-3JBN ↩ ↩2
-
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 ↩ ↩2
-
Nikita Prokopov. Unofficial Guide to Datomic Internals. tonsky.me, May 2014. ↩
-
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 ↩
-
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. ↩ ↩2
-
Jaana Dogan. Things I Wished More Developers Knew About Databases. rakyll.medium.com, April 2020. Archived at perma.cc/6EFK-P2TD ↩
-
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 ↩ ↩2
-
Dan R. K. Ports and Kevin Grittner. Serializable Snapshot Isolation in PostgreSQL. At 38th International Conference on Very Large Databases (VLDB), August 2012. ↩ ↩2 ↩3 ↩4
-
Hans-Jürgen Schönig. Constraints over multiple rows in PostgreSQL. cybertec-postgresql.com, June 2021. Archived at perma.cc/2TGH-XUPZ ↩
-
James Coglan. Reading and writing, part 3: web applications. blog.jcoglan.com, October 2020. Archived at perma.cc/A7EK-PJVS ↩
-
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 ↩
-
John Hugg. Debunking Myths About the VoltDB In-Memory Database. dzone.com, May 2014. Archived at perma.cc/2Z9N-HPKF ↩ ↩2
-
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. ↩
-
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 ↩
-
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 ↩
-
Michael J. Cahill. Serializable Isolation for Snapshot Databases. PhD Thesis, University of Sydney, July 2009. Archived at perma.cc/727J-NTMP ↩
-
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 ↩
-
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 ↩ ↩2
-
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 ↩
-
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 ↩
-
Marc Brooker. Snapshot Isolation vs Serializability. brooker.co.za, December 2024. Archived at perma.cc/5TRC-CR5G ↩