8 事务

有些作者声称,通用的两阶段提交代价太高,会带来性能或可用性问题。我们认为,与其让应用程序员始终为缺少事务而另想办法,不如等事务被过度使用并形成瓶颈时,再由他们处理相应的性能问题。
James Corbett 等人,Spanner:Google 的全球分布式数据库(2012)
在数据系统的残酷现实中,许多事情都可能出错:
- 数据库软件或硬件随时可能发生故障(包括写操作进行到一半时)。
- 应用程序可能在任意时刻崩溃(包括一系列操作的中间)。
- 网络中断可能会意外切断应用程序与数据库之间,或数据库节点彼此之间的连接。
- 多个客户端可能会同时写入数据库,覆盖彼此的更改。
- 客户端可能读取到无意义的数据,因为数据只更新了一部分。
- 客户端之间的竞态条件可能导致令人惊讶的错误。
要做到可靠,系统就必须处理这些故障,确保它们不会导致整个系统灾难性地失效。然而,实现容错机制的工作量很大:既要审慎考虑所有可能出错的情况,又要经过大量测试,才能确保解决方案真正有效。
数十年来,事务(transaction)一直是简化这些问题的首选机制。应用程序通过事务将多个读写操作组合成一个逻辑单元。从概念上讲,事务中的所有读写被当作一次操作执行:整个事务要么成功(提交,commit),要么失败(中止,abort;回滚,rollback)。如果失败,应用程序可以安全重试。有了事务,应用程序的错误处理就简单多了,因为它不必担心部分失效——即无论出于何种原因,有些操作成功、有些操作失败。
如果你与事务打了多年交道,可能会觉得这一切理所当然,但事务并非自然法则。人们创造事务自有其目的:简化 访问数据库的 应用编程模型(programming model)。有了事务,应用程序便可以不去考虑某些潜在的错误场景和并发问题,因为数据库会代为处理这些问题(我们称之为 安全保证,safety guarantee)。
并非所有应用程序都需要事务;有时弱化事务保证,甚至彻底放弃事务也有好处(例如为了提高性能或可用性)。有些安全属性也可以在没有事务的情况下实现。另一方面,事务能够避免许多麻烦:例如,邮局 Horizon 丑闻(参见“可靠性有多重要?”)背后的技术原因,很可能就是底层会计系统缺少 ACID 事务1。
怎样判断自己是否需要事务?要回答这个问题,首先必须确切理解事务能提供哪些安全保证,以及需要为此付出什么代价。事务乍看简单,其中却有许多微妙而重要的细节。
本章将考察许多可能出错的案例,并探讨数据库用来防范这些问题的算法。我们尤其会深入 并发控制(concurrency control)领域,讨论可能出现的各种竞态条件,以及数据库如何实现 读已提交(read committed)、快照隔离(snapshot isolation)和 可串行化(serializable)等隔离级别。
无论对单节点数据库还是分布式数据库,并发控制都很重要。在本章稍后的“分布式事务”一节中,我们将考察 两阶段提交(two-phase commit,2PC)协议,以及在分布式事务中实现原子性所面临的挑战。
事务到底是什么?
如今,几乎所有关系型数据库和一部分非关系数据库都支持事务。它们大多沿袭 1975 年由第一个 SQL 数据库 IBM System R 开创的风格2 3 4。尽管一些实现细节发生了变化,但 50 年来总体思路几乎原封未动:MySQL、PostgreSQL、Oracle 和 SQL Server 等数据库的事务支持与 System R 异乎寻常地相似。
2000 年代后期,非关系型(NoSQL)数据库开始流行。它们试图打破关系型数据库的既有格局:不仅提供新的数据模型选择(参见第 3 章),还默认内置复制(第 6 章)和分片(第 7 章)。事务成了这场运动的主要牺牲品:这一代数据库中,许多彻底放弃了事务,或者重新定义了“事务”一词,用它来描述一套远弱于以往认识的保证。
围绕 NoSQL 分布式数据库的炒作催生了一种流行观点:事务从根本上无法伸缩,任何大规模系统若想保持良好性能和高可用性,都必须放弃事务。近来事实已经证明这种观点是错误的。CockroachDB5、TiDB6、Spanner7、FoundationDB8 和 YugabyteDB 等所谓的“NewSQL”数据库表明,事务系统同样可以扩展到很大的数据量和很高的吞吐量。这些系统将分片与共识协议(第 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 原子性的定义性特征。或许称为 可中止性(abortability)比 原子性 更贴切,但既然“原子性”是惯用说法,本书仍沿用这一术语。
一致性
一致性 这个词被严重滥用:
- 在第 6 章中,我们讨论了 副本一致性,以及异步复制系统中的 最终一致性 问题(参见“复制延迟的问题”)。
- 数据库的 一致快照(例如用于备份)是整个数据库在某一时刻的快照。更准确地说,它与先发生关系(happens-before relation)一致(参见“‘先发生’关系与并发”):如果快照包含某个时刻写入的值,那么它也会反映发生在这次写入之前的所有写入。
- 一致性哈希 是某些系统在再平衡时采用的一种分片方法(参见“一致性哈希”)。
- 在 CAP 定理中(参见第 10 章),一致性 一词指的是 线性一致性(参见“线性一致性”)。
- 在 ACID 的语境下,一致性 指的是由具体应用定义的数据库“良好状态”。
遗憾的是,同一个词竟有至少五种不同的含义。
ACID 一致性的基本思想是:关于数据的某些陈述(即 不变式,invariant)必须始终成立。例如,在会计系统中,所有账户的贷记与借记必须始终相抵。如果事务开始时数据库满足这些不变式,事务中的所有写入又能维持其有效性,就可以确信不变式始终成立。(不变式在事务执行期间可以暂时被打破,但到事务提交时必须重新得到满足。)
如果希望由数据库强制执行不变式,就需要把它们声明为模式中的 约束(constraint)。外键约束、唯一性约束或检查约束(限制单行中可以出现的值)常用来表达特定类型的不变式。更复杂的一致性要求有时也可以用触发器或物化视图来表达12。
不过,数据库通常提供的约束可能很难、甚至根本无法表达复杂的不变式。此时,应用程序就有责任正确定义事务,使其维持一致性。如果应用写入了违反不变式的错误数据,却没有事先声明这些不变式,数据库也无从阻止。因此,ACID 中的 C 往往取决于应用程序如何使用数据库,并非数据库自身的属性。
隔离性
大多数数据库都会同时接受多个客户端的访问。如果各客户端读写数据库的不同部分,自然没有问题;但如果它们访问相同的数据库记录,就可能遇到并发问题(竞态条件)。
图 8-1给出了这类问题的一个简单例子。假设两个客户端同时递增数据库中的一个计数器。每个客户端都要读取当前值,将其加 1,再把新值写回去(假设数据库没有内置的递增操作)。在图 8-1中,计数器经过两次递增,本应从 42 变成 44,却因竞态条件最终只变成了 43。

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。如果损坏已经持续了一段时间,副本和近期备份也可能同样受损。此时只能尝试从更早的历史备份中恢复数据。
- 一项 SSD 研究发现,驱动器在投入使用后的头四年里,有 30% 到 80% 会出现至少一个坏块,而且只有一部分坏块能由固件纠正26。机械硬盘的坏扇区率较低,但彻底失效的概率高于 SSD。
- 磨损严重(经历了大量写入/擦除周期)的 SSD 断电后,可能在数周至数月内开始丢失数据,具体时间取决于温度27。磨损程度较低的驱动器则没有这么严重28。
实践中,没有任何一种技术能提供绝对保证,只有写入磁盘、复制到远程机器和备份等降低风险的手段——它们可以、也应该组合使用。一如既往,面对任何理论上的“保证”,都应保持适度怀疑。
单对象与多对象操作
回顾一下,在 ACID 中,原子性和隔离性描述了客户端在同一事务中进行多次写入时,数据库应当如何处理:
- 原子性
- 如果一系列写入执行到一半时发生错误,事务就应中止,之前完成的写入也应丢弃。换句话说,数据库以“全有或全无”的保证,免去你对部分失效的担忧。
- 隔离性
- 并发运行的事务不应相互干扰。例如,一个事务进行了多次写入,另一个事务就应当要么看到全部写入,要么一项也看不到,而不能只看到其中一部分。
这些定义假设你想同时修改多个对象(行、文档或记录)。当多项数据需要保持同步时,通常就要用到这种 多对象事务。图 8-2展示了一个电子邮件应用的例子。要显示用户的未读邮件数,可以执行如下查询:

不过,邮件数量很多时,这条查询可能太慢,于是你决定把未读邮件数另存到一个单独的字段中(这是一种反规范化,参见“规范化、反规范化与连接”)。这样,每当新邮件到达时,就必须递增未读计数器;每当邮件被标为已读时,也必须递减该计数器。
在图 8-2中,用户 2 遇到了异常:邮箱列表中已经出现一封未读邮件,未读计数却仍为零,因为计数器尚未递增。(如果电子邮件应用中的错误计数无关紧要,不妨把未读计数换成客户账户余额,把邮件换成支付事务。)隔离性可以防止这个问题:它保证用户 2 要么同时看到新插入的邮件和更新后的计数,要么两者都看不到,而不会看到不一致的中间状态。
图 8-3说明了为何需要原子性:如果事务进行到一半时发生错误,邮箱内容和未读计数可能失去同步。在原子事务中,如果计数器更新失败,事务就会中止,已经插入的邮件也会回滚。

多对象事务需要某种方式来确定哪些读写操作属于同一个事务。在关系型数据库中,这通常以客户端到数据库服务器的 TCP 连接为依据:同一条连接上,BEGIN TRANSACTION 与 COMMIT 语句之间的所有操作都属于同一个事务。如果 TCP 连接中断,事务就必须中止。
另一方面,许多非关系型数据库并没有这种组合操作的机制。即便提供了多对象 API(例如键值存储可能提供一次更新多个键的 multi-put 操作),也不一定具备事务语义:命令可能只更新了其中一些键,另一些却失败了,使数据库停留在部分更新的状态。
单对象写入
原子性和隔离性也适用于单个对象的变更。例如,假设你正在向数据库写入一份 20 KB 的 JSON 文档:
- 如果前 10 KB 发送完毕后网络连接中断,数据库会不会存下一段无法解析的 10 KB JSON 残片?
- 如果数据库覆盖磁盘上的旧值时突然断电,最后会不会把新旧值拼接在一起?
- 如果另一个客户端在写入过程中读取该文档,会不会看到只更新了一部分的值?
这些问题会让人无所适从。因此,几乎所有存储引擎都力求在单个节点的单个对象(例如键值对)层面提供原子性和隔离性。原子性可以通过日志和崩溃恢复来实现(参见“使 B 树可靠”),隔离性则可以通过为每个对象加锁来实现(同一时刻只允许一个线程访问对象)。
有些数据库还提供更复杂的原子操作,例如递增操作,从而不必执行图 8-1中的读取—修改—写入循环。同样常见的还有 条件写入:只有当该值未被其他人并发修改时,写入才会发生(参见“条件写入(比较并设置)”)。它类似于共享内存并发中的比较并设置或比较并交换(CAS)操作。
严格来说,原子递增(atomic increment)中的“原子”采用的是多线程编程中的含义。在 ACID 的语境下,它其实应称为 隔离递增(isolated increment)或 可串行化递增(serializable increment),但通常并不这样叫。
这些单对象操作很有用,因为它们能避免多个客户端同时写入同一对象时发生丢失更新(参见“防止丢失更新”)。但它们并不是通常意义上的事务。例如,Cassandra 和 ScyllaDB 的“轻量级事务”功能,以及 Aerospike 的“强一致性”模式,都能在单个对象上提供线性一致的读取和条件写入(参见“线性一致性”),却不为多个对象之间的操作提供保证。
多对象事务的需求
我们真的需要多对象事务吗?能否只靠键值数据模型和单对象操作来实现任何应用程序?
有些用例只需插入、更新或删除单个对象就足够了。但在许多其他场景中,必须协调对多个不同对象的写入:
- 在关系数据模型中,一个表中的行经常通过外键引用另一个表中的行。与之类似,在图数据模型中,一个顶点会通过边指向其他顶点。多对象事务可以确保这些引用始终有效:插入若干相互引用的记录时,外键必须正确且保持最新,否则数据便失去意义。
- 在文档数据模型中,需要一同更新的字段通常位于同一份文档内,而文档被视为单个对象,因此更新一份文档无须多对象事务。不过,缺乏连接功能的文档数据库也鼓励反规范化(参见“何时使用哪种模型”)。当反规范化的信息需要更新时——例如图 8-2中的情形——就必须一次更新多份文档。事务在这里很有用,可以防止反规范化数据彼此失去同步。
- 在具有二级索引的数据库中(纯键值存储以外的数据库几乎都属于此列),每次修改值时也要更新索引。从事务的角度看,这些索引是不同的数据库对象。例如,没有事务隔离时,一条记录可能出现在某个索引中,却没有出现在另一个索引中,因为第二个索引尚未更新(参见“分片与二级索引”)。
这类应用即使没有事务也能实现。不过,没有原子性,错误处理就会复杂得多;缺少隔离性,又可能引发并发问题。我们将在“弱隔离级别”中讨论这些问题,并在“衍生数据与分布式事务”中探讨替代方案。
处理错误和中止
事务的一个关键特性是:遇到错误时,可以中止并安全重试。ACID 数据库遵循这样的原则:只要原子性、隔离性或持久性保证有遭到破坏的风险,数据库宁可彻底放弃事务,也不会让它停留在半完成状态。
不过,并非所有系统都遵循这一原则。尤其是采用无主复制的数据存储(参见“无主复制”),更多是以“尽力而为”的方式工作。概括来说就是:“数据库能做多少便做多少;如果遇到错误,也不会撤销已经完成的操作。”因此,从错误中恢复成了应用程序的责任。
错误不可避免,许多软件开发者却宁愿只考虑一切顺利的正常路径,不愿面对错误处理的复杂细节。例如,Rails 的 ActiveRecord 和 Django 等流行的对象关系映射(ORM)框架都不会重试已中止的事务——错误通常会以异常的形式沿调用栈向上传播,于是用户输入被丢弃,只收到一条错误消息。这很可惜,因为中止的意义恰恰在于允许安全重试。
尽管重试已中止的事务是一种简单有效的错误处理机制,却并非完美无缺:
- 事务可能实际上已经成功,只是在服务器向客户端确认提交成功时网络中断,导致客户端等待超时。此时重试会让事务执行两遍,除非应用层另有去重机制。
- 如果错误源于过载或并发事务之间的严重争用,重试只会雪上加霜。要避免这种反馈循环,可以限制重试次数、采用指数退避,并将过载相关错误与其他错误区别处理(参见“当过载系统无法恢复时”)。
- 只有瞬态错误(例如死锁、违反隔离、临时网络中断或故障切换)才值得重试;遇到永久性错误(例如违反约束)时,重试毫无意义。
- 如果事务还会在数据库之外产生副作用,那么即使事务中止,副作用也可能已经发生。例如,事务每重试一次就再发一封邮件,显然不是你想要的结果。如果要确保几个不同的系统共同提交或共同中止,两阶段提交可以提供帮助(参见“两阶段提交(2PC)”)。
- 如果客户端进程在重试期间崩溃,它本想写入数据库的数据便会丢失。
弱隔离级别
如果两个事务不访问相同的数据,或者两者都是只读事务,它们就可以安全地并行运行,因为彼此并无依赖。只有当一个事务读取的数据正被另一个事务并发修改,或者两个事务试图同时修改相同数据时,才会出现并发问题(竞态条件)。
并发缺陷很难通过测试发现,因为它们只有在时序碰巧不利时才会触发。这种时序问题可能极少发生,通常也难以重现。并发行为本身同样难以推理,尤其是在大型应用中,你未必知道还有哪些代码正在访问数据库。即使每次只有一个用户,应用开发也已经不容易;面对许多并发用户则更为困难,因为任何数据都可能随时发生意料之外的变化。
正因如此,数据库长期以来一直试图通过 事务隔离(transaction isolation)向应用开发者隐藏并发问题。理论上,隔离让你可以假装并发根本不存在:可串行化 隔离意味着,数据库保证事务产生的效果与 串行 运行(即逐个运行、毫无并发)相同。
遗憾的是,实践中的隔离并没有这么简单。可串行化隔离有性能代价,许多数据库不愿为此买单10。因此,系统普遍采用较弱的隔离级别,只防范 一部分 而非全部并发问题。这些隔离级别更难理解,也可能引发微妙的错误,却仍在实践中广泛使用29。
弱事务隔离导致的并发缺陷绝非纸上谈兵:它们曾造成巨额资金损失30 31 32,招致财务审计人员介入调查33,也曾破坏客户数据34。这类问题曝光后,人们常会说:“处理财务数据就该使用 ACID 数据库!”但这没有抓住要害。即使是许多通常被视为“ACID”的流行关系型数据库,也采用弱隔离,因而未必能防止这些问题。
顺带一提,银行体系很大一部分依赖通过安全 FTP 交换的文本文件35。在这种环境中,审计跟踪和某些由人参与的反欺诈措施,其实比 ACID 属性更重要。
这些例子还凸显了一个要点:即使并发问题在正常运行中很少发生,也必须考虑攻击者向 API 刻意发送一波高度并发的请求,专门利用并发缺陷的可能性30。因此,要构建可靠且安全的应用,就必须系统性地防范这类缺陷。
本节将考察实践中使用的几种弱隔离级别(即非可串行化隔离),详细讨论哪些竞态条件可能发生、哪些不会发生,以便你判断哪一种适合自己的应用。随后,我们再深入讨论可串行化(参见“可串行化”)。这里将通过例子作非形式化的说明;严格定义及其性质分析可在学术文献中找到36 37 38 39。
读已提交
最基本的事务隔离级别是 读已提交(read committed),它提供两项保证:
- 从数据库读取时,只能看到已经提交的数据(没有 脏读,dirty read)。
- 向数据库写入时,只能覆盖已经提交的数据(没有 脏写,dirty write)。
有些数据库还支持更弱的 读未提交 隔离级别。它能防止脏写,却不能防止脏读。下面详细讨论这两项保证。
没有脏读
设想一个事务已经向数据库写入数据,却尚未提交或中止。另一个事务能否看到这些未提交的数据?如果能,这就是 脏读3。
运行在读已提交隔离级别下的事务必须防止脏读。这意味着,一个事务的所有写入都要等到该事务提交时,才会同时对其他事务可见。图 8-4中,用户 1 已将 x 设为 3,但由于其事务尚未提交,用户 2 的 get x 仍返回旧值 2。

防止脏读有以下几个好处:
- 如果事务要更新多行,脏读会让另一个事务只看到其中一部分更新。例如在图 8-2中,用户看到了新到的未读邮件,却没有看到更新后的计数器。这就是对邮件的脏读。数据库呈现出部分更新的状态,不仅令人困惑,还可能诱使其他事务作出错误决定。
- 如果事务中止,其所有写入都需要回滚(如图 8-3)。允许脏读,就意味着事务可能读到后来被回滚、从未真正提交进数据库的数据。凡是读取过未提交数据的事务也必须一并中止,从而造成所谓的 级联中止。
没有脏写
如果两个事务并发更新数据库中的同一行,会发生什么?写入次序虽然无法预知,但我们通常认为后一次写入会覆盖前一次写入。
可是,如果前一次写入属于尚未提交的事务,后一次写入覆盖了这个未提交值,又会怎样?这就是 脏写36。运行在读已提交隔离级别下的事务必须防止脏写;通常的做法是推迟第二次写入,直到执行第一次写入的事务提交或中止。
防止脏写可以避免某些并发问题:
- 如果事务更新多行,脏写可能产生非常糟糕的结果。图 8-5展示了一家二手车交易网站:Aaliyah 和 Bryce 同时购买同一辆车。买车涉及两次数据库写入:网站上的商品信息要更新为买家的姓名,销售发票也要开给买家。在图 8-5所示的情况下,车辆最终卖给了 Bryce(他对
listings表的更新胜出),发票却开给了 Aaliyah(她对invoices表的更新胜出)。读已提交可以防止这种事故。 - 不过,读已提交 不能 防止图 8-1中两个计数器递增操作之间的竞态条件。这里的第二次写入发生在第一个事务提交之后,所以并非脏写。结果仍然有误,只是原因不同;“防止丢失更新”将介绍如何安全地执行这类计数器递增操作。

实现读已提交
读已提交是一种非常流行的隔离级别,也是 Oracle Database、PostgreSQL、SQL Server 等许多数据库的默认设置10。
数据库防止脏写最常见的办法是使用行级锁:事务想修改某一行(或文档等其他对象)时,必须先取得该行的锁,并一直持有到事务提交或中止。任何一行在同一时刻只能由一个事务持锁;另一个事务若想写入同一行,就必须等前一个事务提交或中止后,才能取得锁并继续执行。在读已提交模式(或更强的隔离级别)下,数据库会自动完成这些加锁操作。
那么怎样防止脏读?一种办法仍是使用同一把锁:要求任何想读取某行的事务都短暂取得该锁,读完后立即释放。这样,当该行包含未提交的脏值时就无法读取,因为执行写入的事务仍持有这把锁。
然而,要求读操作加锁在实践中效果不佳。一个长期运行的写事务,可能迫使许多只读事务一直等到它结束。这不仅损害只读事务的响应时间,也不利于可运维性:应用某个部分一旦变慢,等待锁便可能引发连锁反应,波及完全不相干的其他部分。
尽管如此,仍有一些数据库使用锁来防止脏读,例如 IBM Db2,以及将 read_committed_snapshot=off 的 Microsoft SQL Server29。
更常见的防脏读办法如图 8-4所示:对于每一行写入,数据库同时保留旧的已提交值,以及当前持有写锁的事务写入的新值。该事务进行期间,其他事务读取这一行时只会得到旧值;等新值提交后,才改为读取新值(详见“多版本并发控制(MVCC)”)。
快照隔离与可重复读
乍看之下,人们很容易以为读已提交已经具备了事务所需的一切:它允许中止(原子性所必需),避免读取事务未完成的结果,也防止并发写入相互混杂。的确,这些功能很有用,提供的保证也比完全没有事务的系统强得多。
然而,在这个隔离级别下,并发缺陷仍可能以许多方式出现。图 8-6就展示了读已提交可能遇到的一个问题。

假设 Aaliyah 在银行有 1,000 美元存款,分别存在两个账户中,每个账户 500 美元。现在有一笔事务从其中一个账户向另一个账户转账 100 美元。如果她偏偏在转账事务处理的同时查看账户余额,可能会先看到一个账户尚未收到转入款项时的余额(500 美元),再看到另一个账户已经转出款项后的余额(400 美元)。在 Aaliyah 看来,两个账户总共只剩 900 美元——仿佛有 100 美元凭空消失了。
这种异常称为 读偏差(read skew),是 不可重复读(nonrepeatable read)的一种:如果 Aaliyah 在转账事务结束后再次读取账户 1 的余额,会得到 600 美元,与上一次查询看到的值不同。读已提交隔离允许读偏差,因为 Aaliyah 读到各账户余额的那一刻,它们确实都已经提交。
遗憾的是,偏差(skew)一词有多重含义:前文曾用它指 存在热点的不均衡工作负载(参见“倾斜的工作负载与缓解热点”),这里指的则是 时序异常。
对 Aaliyah 来说,这个问题不会持续太久:几秒钟后刷新网上银行页面,她多半就能看到一致的账户余额。但有些场景无法容忍哪怕暂时的不一致:
- 备份
- 备份需要复制整个数据库,大型数据库可能要花上几个小时。备份期间,数据库仍在不断接受写入。结果,备份的一部分可能包含较旧版本的数据,另一部分却包含较新版本。一旦从这样的备份恢复,原本暂时的不一致(例如凭空消失的钱)就会固化下来。
- 分析查询和完整性检查
- 有时需要运行扫描数据库大部分内容的查询。这类查询在分析场景中很常见(参见“分析型与事务型系统”),也可能用于定期执行完整性检查,确认一切正常(即监测数据损坏)。如果查询在不同时间点观察数据库的不同部分,返回的结果很可能毫无意义。
快照隔离36是解决这个问题最常用的办法。其核心思想是,每个事务都从数据库的 一致快照 读取数据——也就是说,事务只看到它开始时数据库中已经提交的全部数据。即使其他事务后来修改了数据,它看到的仍是那个特定时刻的旧数据。
快照隔离对于备份、分析等长期运行的只读查询是一大福音。如果查询所处理的数据在执行期间不断变化,查询结果就很难解释;如果事务看到的是冻结在某个时刻的数据库一致快照,理解起来就容易多了。
快照隔离是一项常见功能:PostgreSQL、采用 InnoDB 存储引擎的 MySQL、Oracle、SQL Server 等数据库都支持它的某种变体,尽管各系统的具体行为有所不同29 40 41。Oracle、TiDB 和 Aurora DSQL 等数据库甚至把快照隔离作为自身最高的隔离级别。
多版本并发控制(MVCC)
和读已提交一样,快照隔离通常用写锁来防止脏写(参见“实现读已提交”)。因此,一个写事务可以阻塞另一个写入同一行的事务。不过,读取无须取得任何锁。从性能角度看,快照隔离的一项关键原则是:读不阻塞写,写也不阻塞读。这样,数据库可以一边在一致快照上执行长期读查询,一边正常处理写入,二者不会争用锁。
为实现快照隔离,数据库把图 8-4中的防脏读机制推广开来。数据库不再只为每行保留两个版本(已提交版本,以及覆盖它但尚未提交的新版本),而是可能需要保留多个不同的已提交版本,因为正在运行的不同事务可能要观察数据库在不同时刻的状态。由于同一行的多个版本并存,这项技术称为 多版本并发控制(multi-version concurrency control,MVCC)。
图 8-7展示了 PostgreSQL 如何基于 MVCC 实现快照隔离40 42 43(其他实现与之类似)。事务启动时会获得一个唯一且单调递增的事务 ID(txid)。事务写入数据库的任何数据,都会以写入者的事务 ID 标记。(严格来说,PostgreSQL 的事务 ID 是 32 位整数,大约经过 40 亿个事务就会溢出;vacuum 进程负责清理,确保溢出不会影响数据。)

表中每一行都有一个 inserted_by 字段,保存将该行插入表中的事务 ID;还有一个初始为空的 deleted_by 字段。事务删除某行时,并不会立即将其从数据库中移除,而只是把 deleted_by 设为请求删除的事务 ID,以此标记删除。等到确定再也没有事务能访问这些已删除数据时,数据库的垃圾收集进程才会移除带删除标记的行并释放空间。
更新在内部会转换成一次删除和一次插入44。例如,在图 8-7中,事务 13 从账户 2 扣除 100 美元,把余额从 500 美元改为 400 美元。此时 accounts 表实际上包含账户 2 的两行:余额 500 美元的行由事务 13 标记为删除,余额 400 美元的行则由事务 13 插入。
一行的所有版本都存放在同一个数据库堆中(参见“在索引中存储值”),不论写入它们的事务是否已经提交。同一行的各个版本组成链表,可以从最新版本连到最旧版本,也可以反向连接,使查询能在内部遍历该行的所有版本45 46。
观察一致快照的可见性规则
事务读取数据库时,系统根据事务 ID 判断哪些行版本可见、哪些不可见。只要审慎定义可见性规则,数据库就能向应用程序呈现一致快照。其工作方式大致如下43:
- 每个事务开始时,数据库列出当时仍在进行(尚未提交或中止)的所有其他事务。即使这些事务后来提交,其写入也一律忽略。这样便能看到不受其他事务随后提交影响的一致快照。
- 事务 ID 较晚的事务(即在当前事务开始之后启动,因而不在上述进行中事务列表里)所做的写入一律忽略,无论它们是否已经提交。
- 已中止事务的写入一律忽略,无论中止发生在何时。这样一来,事务中止后无须立刻从存储中删除它写入的行,因为可见性规则会将它们过滤掉,垃圾收集进程可以稍后再清理。
- 其余写入均对应用程序的查询可见。
这些规则既适用于插入行,也适用于删除行。在图 8-7中,事务 12 读取账户 2 时看到的余额是 500 美元:删除 500 美元余额的是事务 13,而根据规则 2,事务 12 看不到事务 13 执行的删除;同理,插入的 400 美元余额也尚不可见。
换句话说,只有同时满足以下两个条件,一行才可见:
- 读事务开始时,插入该行的事务已经提交。
- 该行没有标记为删除;或者虽已标记为删除,但请求删除的事务在读事务开始时尚未提交。
长期运行的事务可能一直使用同一个快照,继续读取在其他事务看来早已被覆盖或删除的值。数据库从不原地更新值,而是在每次变更时插入新版本,因而只需很小的开销就能提供一致快照。
索引与快照隔离
多版本数据库中的索引如何工作?最常见的做法是,每个索引项指向与其匹配的某个行版本(最旧或最新版本)。每个行版本可以引用下一个更旧或更新的版本。使用索引的查询必须沿行版本遍历,找到既对当前事务可见、值又符合查询条件的那一行。垃圾收集移除不再对任何事务可见的旧行版本时,也可以一并删除相应的索引项。
许多实现细节都会影响多版本并发控制的性能45 46。例如,如果同一行的不同版本可以容纳在同一个页面中,PostgreSQL 会通过优化避免更新索引40。另一些数据库则不保存修改后行的完整副本,只保存版本间的差异以节省空间。
CouchDB、Datomic 和 LMDB 采用另一种方法。它们同样使用 B 树(参见“B 树”),但采用 不可变(写时复制)的变体:更新时不覆盖树页面,而是为每个被修改的页面创建新副本。从其父页面一直到树根,也都会复制并更新,以指向新版子页面。未受写入影响的页面无须复制,可由新树继续共享47。
采用不可变 B 树时,每个写事务(或一批事务)都会创建一个新的 B 树根;某个特定的树根,就是它创建时数据库的一致快照。由于后续写入无法修改现有 B 树,只能创建新的树根,因此无须根据事务 ID 过滤行。这种方法同样需要后台进程执行压缩和垃圾收集。
快照隔离、可重复读和命名混淆
MVCC 是数据库常用的实现技术,也经常用来实现快照隔离。不过,不同数据库有时会用不同术语指代同一件事:例如,快照隔离在 PostgreSQL 中称为“可重复读(repeatable read)”,在 Oracle 中称为“可串行化”29。反过来,不同系统也会用同一个术语表示不同事物:PostgreSQL 的“可重复读”指快照隔离,MySQL 的“可重复读”却是一个一致性弱于快照隔离的 MVCC 实现41。
之所以会出现这种命名混乱,是因为 SQL 标准没有快照隔离的概念。该标准建立在 System R 于 1975 年定义的隔离级别之上3,而那时快照隔离尚未问世。标准定义的是表面上与快照隔离相似的“可重复读”。PostgreSQL 的快照隔离符合这项标准要求,于是将它称为“可重复读”,从而可以宣称符合标准。
遗憾的是,SQL 标准对隔离级别的定义存在缺陷:含糊、不精确,也没有做到标准理应具备的实现无关性36。好几种数据库都声称实现了可重复读,实际提供的保证却相去甚远,尽管它名义上已经标准化29。研究文献给出了可重复读的正式定义37 38,但大多数实现都不符合这个定义。更添混乱的是,IBM Db2 还用“可重复读”来指可串行化10。
结果,谁也说不清“可重复读”究竟是什么意思。
防止丢失更新
到目前为止,读已提交和快照隔离主要保证的是:存在并发写入时,只读事务能够看到什么。我们基本没有讨论两个事务并发写入的问题,只讲过一种特定的写—写冲突——脏写(参见“没有脏写”)。
并发写事务之间还可能出现其他几类值得关注的冲突,其中最著名的便是 丢失更新(lost update)。图 8-1以两个并发递增计数器的事务为例,展示了这个问题。
应用程序从数据库读取一个值,修改后再写回去,这个过程称为 读取—修改—写入循环(read-modify-write cycle),可能发生丢失更新。如果两个事务并发执行这样的循环,其中一项修改可能丢失,因为后一次写入没有包含前一个事务的修改。(有时也说后一次写入 抹掉 了前一次写入。)这种模式会出现在许多场景中:
- 递增计数器或更新账户余额(读取当前值、计算新值,再把新值写回);
- 局部修改复杂值,例如向 JSON 文档内的列表添加元素(解析文档、作出修改,再把修改后的文档写回);
- 两个用户同时编辑一个 wiki 页面,各自把整页内容发送给服务器来保存修改,从而覆盖数据库中的现有内容。
这个问题非常普遍,因此人们提出了多种解决方案48。
原子写操作
许多数据库提供 原子写操作(atomic write operation),使应用程序不必自行实现读取—修改—写入循环。如果业务逻辑可以用这些操作表达,它们通常是最佳方案。例如,下面这条语句在大多数关系型数据库中都能安全地并发执行:
类似地,MongoDB 等文档数据库提供原子操作,供应用局部修改 JSON 文档;Redis 则提供修改优先队列等数据结构的原子操作。并非所有写入都容易表示成原子操作——例如,更新 wiki 页面涉及任意文本编辑,可以用“CRDT 与操作变换”介绍的算法处理——但只要能够使用,原子操作通常就是最佳选择。
原子操作通常这样实现:读取对象时取得 排他锁(exclusive lock),使其他事务在更新完成之前无法读取该对象。另一种办法则是强制所有原子操作都在单个线程上执行。
遗憾的是,对象关系映射(ORM)框架很容易让人无意中写出不安全的读取—修改—写入循环,而没有使用数据库提供的原子操作49 50 51。由此产生的缺陷往往十分隐蔽,很难通过测试发现。
显式锁定
如果数据库内置的原子操作无法满足需求,还可以由应用程序 显式锁定(explicit locking)即将更新的对象,再执行读取—修改—写入循环。其他事务若试图并发更新或锁定同一对象,就必须等前一个循环完成。
以多人游戏为例,几个玩家都可以移动同一个棋子。这时原子操作可能还不够,因为应用还要确保走法符合游戏规则,而其中一些逻辑很难合理地写成数据库查询。此时可以加锁,防止两个玩家同时移动同一个棋子,如示例 8-1所示。
❶
FOR UPDATE子句表示数据库应当锁定这条查询返回的所有行。
这个办法行得通,但必须仔细推敲应用逻辑才能用对。代码中很容易漏掉某处必要的加锁,进而引入竞态条件。
此外,锁定多个对象会带来死锁风险:两个或更多事务彼此等待对方释放锁。许多数据库会自动检测死锁,并中止其中一个事务,让系统得以继续推进。应用程序只需重试这个被中止的事务即可。
自动检测丢失更新
原子操作和锁通过强制读取—修改—写入循环依次执行来防止丢失更新。另一种思路是允许这些循环并行执行;事务管理器一旦检测到丢失更新,便中止相应事务,迫使它重试整个循环。
这种方法的一项优势是,数据库可以结合快照隔离高效完成检查。事实上,PostgreSQL 的可重复读、Oracle 的可串行化和 SQL Server 的快照隔离,都会自动检测丢失更新并中止发生冲突的事务。MySQL/InnoDB 的可重复读则不会检测丢失更新29 41。一些作者认为36 38,数据库只有能够防止丢失更新,才算提供快照隔离;按照这个定义,MySQL 并没有提供快照隔离。
自动检测丢失更新是一项很实用的功能,因为应用代码无须使用任何特殊数据库功能。开发者可能忘记加锁或使用原子操作而引入缺陷,自动检测则不容易遗漏。不过,应用程序仍须负责重试被中止的事务。
条件写入(比较并设置)
不提供事务的数据库有时会提供 条件写入 操作(前文“单对象写入”已经提到):只有当值自上次读取后未发生变化,才允许更新,从而防止丢失更新。如果当前值与先前读到的值不符,更新便不生效,应用必须重试读取—修改—写入循环。它相当于数据库版本的原子 比较并设置(compare-and-set)或 比较并交换(compare-and-swap,CAS)指令,许多 CPU 都支持此类指令。
例如,为防止两个用户同时更新同一个 wiki 页面,可以尝试如下写法:只有当页面内容在用户开始编辑后没有变化时,更新才会发生。
如果内容已经变化,不再与 'old content' 匹配,更新就不会生效;因此需要检查更新结果,必要时重试。也可以不比较完整内容,而是使用一个每次更新都递增的版本号列,只在当前版本号未变化时应用更新。这种方法有时称为 乐观锁定(optimistic locking)52。
如果另一个事务并发修改了 content,按照 MVCC 可见性规则,新内容可能并不可见(参见“观察一致快照的可见性规则”)。许多 MVCC 实现会针对这种场景作出例外:其他事务写入的值即便在快照中不可见,计算 UPDATE 和 DELETE 查询的 WHERE 子句时仍然可见。
冲突解决与复制
在复制数据库中(参见第 6 章),防止丢失更新又多了一个维度:数据副本分布在多个节点上,并可能在不同节点被并发修改,因此还需采取额外措施。
锁和条件写入操作都假定存在唯一的最新数据副本。但采用多主复制或无主复制的数据库,通常允许多项写入并发发生,再异步复制到其他节点,因而无法保证存在唯一的最新副本。所以,基于锁或条件写入的技术并不适用于这种场景。(“线性一致性”将更深入地讨论这个问题。)
这类复制数据库通常采用另一种办法,正如“处理写入冲突”所述:允许并发写入产生同一个值的多个冲突版本(也称为 兄弟值,siblings),再由应用代码或特殊数据结构事后解决冲突并合并版本。
如果更新是可交换的——即在不同副本上按不同顺序应用,仍会得到相同结果——那么合并冲突值便可防止丢失更新。递增计数器、向集合添加元素都是可交换操作。这正是“CRDT 与操作变换”介绍的 CRDT 背后的思想。不过,条件写入等操作无法变成可交换操作。
另一方面,最后写入者胜(LWW)这种冲突解决办法很容易丢失更新,详见“最后写入者胜(丢弃并发写入)”。遗憾的是,许多复制数据库都默认采用 LWW。
写偏差与幻读
前面介绍了 脏写 和 丢失更新:不同事务并发写入相同对象时,可能出现这两种竞态条件。要避免数据损坏,就必须防止它们发生——可以由数据库自动防范,也可以手动使用锁、原子写操作等保护措施。
然而,并发写入之间的潜在竞态条件远不止这些。本节将考察一些更为微妙的冲突。
先来看一个例子:你正在编写一款供医生管理医院值班安排的应用。医院通常希望任何时候都有几位医生值班,但底线是至少必须有一位。医生可以放弃自己的班次(例如本人也生病了),前提是同一班次至少还有一位同事继续值班53 54。
假设某个班次只有 Aaliyah 和 Bryce 两位值班医生。两人都身体不适,决定请假,偏偏又几乎同时点击了退出值班的按钮。接下来发生的事情如图 8-8所示。

每个事务都先检查当前是否至少有两位医生值班;如果是,应用就认为其中一位可以安全退出。由于数据库采用快照隔离,两次检查都返回 2,两个事务于是都进入下一阶段。Aaliyah 更新自己的记录,退出值班;Bryce 也作出同样的更新。两个事务都成功提交,结果却没有任何医生值班,违反了“至少一位医生值班”的要求。
写偏差的特征
这种异常称为 写偏差(write skew)36。它既不是脏写,也不是丢失更新,因为两个事务更新的是不同对象——分别是 Aaliyah 和 Bryce 的值班记录。这里的冲突不那么明显,却确实是一种竞态条件:如果两个事务依次运行,第二位医生就无法退出值班。只有并发执行事务,才会出现这种异常行为。
可以把写偏差看作丢失更新的推广:两个事务读取相同的一组对象,随后各自更新其中一些对象(不同事务可以更新不同对象),就可能发生写偏差。若不同事务碰巧更新同一个对象,则会表现为脏写或丢失更新,具体取决于时序。
防止丢失更新的办法有很多,处理写偏差时可选手段却少得多:
单对象原子操作无济于事,因为这里涉及多个对象。
一些快照隔离实现能够自动检测丢失更新,但遗憾的是,这对写偏差也无济于事。PostgreSQL 的可重复读、MySQL/InnoDB 的可重复读、Oracle 的可串行化以及 SQL Server 的快照隔离,都不会自动检测写偏差29。要自动防止写偏差,必须采用真正的可串行化隔离(参见“可串行化”)。
有些数据库允许配置由数据库强制执行的约束,例如唯一性约束、外键约束或对特定值的限制。但要表达“至少有一位医生值班”,就需要涉及多个对象的约束。大多数数据库没有内置支持这类约束,不过可以尝试用触发器或物化视图来实现,正如“一致性”所述12。
如果无法使用可串行化隔离,这里的次优选择大概是显式锁定事务所依赖的行。在医生值班的例子中,可以这样写:
❶ 与之前一样,
FOR UPDATE要求数据库锁定这条查询返回的所有行。
写偏差的更多例子
写偏差乍看似乎很深奥,一旦意识到它的存在,就会发现它可能出现在许多场景中。再看几个例子:
- 会议室预订系统
- 假设你要保证同一间会议室在同一时段不能被重复预订55。有人发起预订时,先检查是否存在冲突(即同一房间是否有时间范围重叠的预订);若没有,再创建会议(参见示例 8-2)。
示例 8-2 会议室预订系统试图避免重复预订(在快照隔离下并不安全) 遗憾的是,快照隔离无法阻止另一个用户并发插入冲突的会议。要保证安排不会冲突,还是得采用可串行化隔离。
- 多人游戏
- 在示例 8-1中,我们用锁防止丢失更新,也就是确保两名玩家不能同时移动同一个棋子。但这把锁无法阻止玩家把两个不同棋子移动到棋盘上的同一位置,也无法阻止其他违反规则的走法。视具体规则而定,有时可以使用唯一性约束;否则就很容易发生写偏差。
- 声明用户名
- 在要求用户名唯一的网站上,两个用户可能同时尝试用同一个名字创建账户。可以用事务先检查用户名是否已被占用,如果没有,再用它创建账户。但和前面的例子一样,这在快照隔离下并不安全。好在唯一性约束就能轻松解决这个问题:第二个尝试注册该用户名的事务会因违反约束而中止。
- 防止重复消费
- 允许用户消费金钱或积分的服务,必须确保用户的支出不超过余额。一种实现方式是先在用户账户中插入暂定支出项,再列出账户中的所有项目,检查合计余额是否仍为正数。发生写偏差时,两个支出项可能被并发插入,合在一起使余额变成负数,两个事务却都没有察觉对方。
导致写偏差的幻读
这些例子都遵循相似的模式:
SELECT查询搜索符合某项条件的行,以检查是否满足要求(至少有两位医生值班、该会议室在相应时段没有预订、棋盘上的目标位置没有其他棋子、用户名尚未占用、账户仍有余额)。- 应用代码根据第一次查询的结果决定下一步:继续执行,或者向用户报告错误并中止。
- 如果决定继续,应用便向数据库写入(
INSERT、UPDATE或DELETE),然后提交事务。
这次写入改变了步骤 2 作出决定时所依据的前提。换句话说,写入提交后再次执行步骤 1 的 SELECT,结果就会不同,因为符合搜索条件的行集合已经改变(少了一位值班医生、会议室在该时段已有预订、棋盘上的目标位置已被刚移入的棋子占据、用户名已被占用,或账户余额已经减少)。
这些步骤也可以采用不同顺序。例如,可以先写入,再执行 SELECT 查询,最后根据查询结果决定中止还是提交。
在医生值班的例子中,步骤 3 修改的是步骤 1 返回的某一行,因此可以在步骤 1 中锁定这些行(SELECT FOR UPDATE),使事务安全并避免写偏差。但其余四个例子不同:它们检查的是 不存在 符合某项条件的行,随后的写入则 添加 了一行,使其符合相同条件。如果步骤 1 的查询没有返回任何行,SELECT FOR UPDATE 便无处加锁56。
一个事务的写入改变了另一个事务中搜索查询的结果,这种现象称为 幻读(phantom)4。快照隔离可以避免只读查询中的幻读,但在上述读写事务中,幻读可能引发格外棘手的写偏差。ORM 生成的 SQL 也很容易遇到写偏差50 51。
物化冲突
如果幻读的问题在于没有对象可供加锁,或许可以人为地在数据库中引入一个锁对象?
以会议室预订为例,可以创建一张房间与时段表。表中每一行对应某个房间的某个特定时段(例如 15 分钟),提前为未来六个月内所有可能的房间与时段组合建好行。
现在,创建预订的事务可以锁定(SELECT FOR UPDATE)表中与目标房间和时段对应的行。取得锁后,再照常检查重叠预订并插入新预订。请注意,这张附加表并不存储预订信息——它纯粹是一组锁,用来防止同一房间、同一时间范围内的预订被并发修改。
这种方法称为 物化冲突(materializing conflicts):它把幻读转化为数据库中一组具体行上的锁冲突14。遗憾的是,怎样物化冲突既难设计又容易出错,而且让并发控制机制渗入应用数据模型也很不美观。因此,物化冲突只能在别无选择时作为最后手段;大多数情况下,可串行化隔离要好得多。
可串行化
本章已经介绍了几类容易出现竞态条件的事务。读已提交和快照隔离可以防止其中一部分,却无法防止其他类型,写偏差和幻读尤其棘手。当前的处境着实令人沮丧:
- 隔离级别难以理解,不同数据库的实现又不一致(例如“可重复读”的含义相差很大)。
- 单看应用代码,很难判断它在特定隔离级别下运行是否安全。大型应用尤其如此,因为你未必知道还有哪些操作可能并发发生。
- 没有好用的工具可以帮助检测竞态条件。原则上静态分析或许能派上用场33,研究成果却尚未进入实际应用。并发问题通常具有非确定性,只有时序碰巧不利时才会出现,因此也很难通过测试发现。
这并不是新问题——自 20 世纪 70 年代引入弱隔离级别以来,情况一直如此3。研究人员给出的答案始终很简单:使用 可串行化 隔离!
可串行化是最强的隔离级别。它保证即使事务实际并行执行,最终结果也与它们 串行 执行(逐个执行、毫无并发)相同。因此,只要事务单独运行时行为正确,数据库就能保证它们并发运行时仍然正确。换句话说,数据库可以防止 所有 可能的竞态条件。
既然可串行化远胜于弱隔离带来的混乱,为什么大家不都使用它?要回答这个问题,就要看看可串行化有哪些实现方式,以及它们的性能如何。目前提供可串行化的大多数数据库都采用以下三种技术之一,本章余下内容将逐一探讨:
- 严格按照串行顺序执行事务(参见“实际串行执行”);
- 两阶段锁定(参见“两阶段锁定(2PL)”),几十年来这是唯一可行的选择;
- 可串行化快照隔离等乐观并发控制技术(参见“可串行化快照隔离(SSI)”)。
实际串行执行
避免并发问题最简单的办法,就是彻底消除并发:在单个线程上按照串行顺序,每次只执行一个事务。这样便完全绕开了检测和防止事务冲突的问题,而得到的隔离按定义就是可串行化的。
这个想法看似显而易见,数据库设计者却直到 2000 年代才认定,用单线程循环执行事务切实可行57。此前 30 年间,多线程并发一直被视为取得良好性能的必要条件,究竟是什么变化让单线程执行成为可能?
两项进展促成了这次重新思考:
- RAM 已经足够便宜,许多用例都能把整个活跃数据集放进内存(参见“全内存存储”)。事务所需的全部数据都在内存中时,执行速度远快于等待从磁盘加载数据。
- 数据库设计者意识到,OLTP 事务通常很短,只执行少量读写(参见“分析型与事务型系统”)。长期运行的分析查询则通常只读,可以在串行执行循环之外,基于一致快照运行(使用快照隔离)。
VoltDB/H-Store、Redis 和 Datomic 等系统采用了串行执行事务的方式58 59 60。为单线程执行而设计的系统有时反而比支持并发的系统性能更好,因为它省去了加锁的协调开销。不过,其吞吐量受限于单个 CPU 核。要充分利用这条线程,事务的组织方式必须不同于传统形式。
将事务封装在存储过程中
数据库诞生之初,人们设想事务可以涵盖用户活动的整个流程。例如,预订机票要经过多个阶段:搜索航线、票价和空余座位;确定行程;预订每一段航班的座位;填写乘客信息;最后付款。数据库设计者认为,如果把整个流程作为一个事务原子提交,会非常理想。
遗憾的是,人类作出决定和响应的速度很慢。如果数据库事务要等待用户输入,数据库就必须支持数量可能极其庞大的并发事务,而且其中大部分都处于空闲状态。绝大多数数据库无法高效做到这一点,因此几乎所有 OLTP 应用都会避免在事务中交互等待用户,以保持事务简短。在 Web 应用中,这意味着事务要在同一个 HTTP 请求内提交,不能跨越多个请求;新的 HTTP 请求会启动新的事务。
即使已经把人从关键路径中移除,事务仍往往采用交互式客户端/服务器模式,逐条执行语句。应用发出查询、读取结果,再根据第一次查询的结果决定是否发出下一条查询,如此往复。查询与结果不断在应用代码所在的机器和数据库服务器之间来回传递。
这种交互式事务把大量时间耗在应用与数据库之间的网络通信上。如果数据库禁止并发、每次只处理一个事务,吞吐量会低得可怕:数据库大部分时间都在等待应用发出当前事务的下一条查询。采用这种模式的数据库必须并发处理多个事务,才能获得合理性能。
因此,单线程串行处理事务的系统不允许交互式多语句事务。应用要么只使用单语句事务,要么提前把整个事务的代码作为 存储过程(stored procedure)提交给数据库61。
交互式事务与存储过程的差别如图 8-9所示。只要事务所需的全部数据都在内存中,存储过程就能快速执行,无须等待任何网络或磁盘 I/O。

存储过程的利弊
存储过程早已存在于关系型数据库中,并从 1999 年起成为 SQL 标准(SQL/PSM)的一部分。不过,它们因种种原因名声不佳:
- 传统上,每家数据库厂商都有自己的存储过程语言(Oracle 使用 PL/SQL,SQL Server 使用 T-SQL,PostgreSQL 使用 PL/pgSQL 等)。这些语言没有跟上通用编程语言的发展,今天看来颇为丑陋陈旧,也缺少大多数编程语言拥有的库生态。
- 在数据库中运行的代码难以管理:与应用服务器相比,它更难调试,不便进行版本控制和部署,测试更麻烦,也难以接入采集指标的监控系统。
- 数据库通常比应用服务器对性能更加敏感,因为一个数据库实例往往由许多应用服务器共享。一个写得很差的存储过程(例如占用大量内存或 CPU 时间),可能比应用服务器上同样糟糕的代码造成更大麻烦。
- 在允许租户自行编写存储过程的多租户系统中,让不受信任的代码与数据库内核运行在同一个进程里,会带来安全风险62。
不过,这些问题并非无法克服。现代存储过程实现已经放弃 PL/SQL,转而采用现有的通用编程语言:VoltDB 使用 Java 或 Groovy,Datomic 使用 Java 或 Clojure,Redis 使用 Lua,MongoDB 使用 JavaScript。
当应用逻辑不便嵌入其他位置时,存储过程也很有用。例如,使用 GraphQL 的应用可能通过 GraphQL 代理直接开放数据库。若代理不支持复杂的验证逻辑,就可以用存储过程把这类逻辑直接嵌入数据库;如果数据库不支持存储过程,则只能在代理与数据库之间另行部署验证服务。
存储过程配合内存数据,使所有事务都在单线程上执行成为可行方案。只要存储过程无须等待 I/O,又能避开其他并发控制机制的开销,单线程也可以达到相当可观的吞吐量。
VoltDB 还利用存储过程进行复制:它不把事务写入从一个节点复制到另一个节点,而是在每个副本上执行同一个存储过程。因此,VoltDB 要求存储过程必须是 确定性的(deterministic),即在不同节点运行时产生相同结果。例如,事务若要使用当前日期和时间,就必须通过特殊的确定性 API 获取(关于确定性操作的更多细节,参见“持久化执行与工作流”)。这种方法称为 状态机复制(state machine replication),我们将在第 10 章再次讨论它。
分片
串行执行所有事务大大简化了并发控制,却也把数据库的事务吞吐量限制在单台机器、单个 CPU 核的处理能力以内。只读事务可以通过快照隔离在别处执行;但对写入吞吐量很高的应用而言,单线程事务处理器可能成为严重瓶颈。
要扩展到多个 CPU 核和多个节点,可以像 VoltDB 那样对数据分片(参见第 7 章)。如果能让每个事务只读写单个分片内的数据,那么每个分片都可以拥有自己的事务处理线程,彼此独立运行。此时可以为每个 CPU 核分配一个分片,使事务吞吐量随 CPU 核数线性增长59。
但凡事务需要访问多个分片,数据库就必须在所有涉及的分片之间进行协调。存储过程要在这些分片上步调一致地执行,才能保证整个系统的可串行化。
跨分片事务有额外的协调开销,因此远慢于单分片事务。VoltDB 报告的跨分片写入吞吐量约为每秒 1,000 次,比单分片吞吐量低几个数量级,而且无法靠增加机器来提升61。近来的研究探索了让多分片事务更具可伸缩性的办法63。
事务能否限制在单个分片内,很大程度上取决于应用所用的数据结构。简单的键值数据通常很容易分片;具有多个二级索引的数据则很可能需要大量跨分片协调(参见“分片与二级索引”)。
串行执行总结
在满足特定约束的情况下,串行执行事务已经成为实现可串行化隔离的一种可行办法:
- 每个事务都必须短小迅速,因为一个慢事务就足以拖住所有事务处理。
- 这种办法最适合活跃数据集能够装进内存的场景。不常访问的数据可以移到磁盘,但单线程事务一旦需要访问这些数据,系统就会变得很慢。
- 写入吞吐量必须足够低,能由单个 CPU 核处理;否则,事务必须能够分片执行,并且不需要跨分片协调。
- 跨分片事务虽然可行,其吞吐量却很难扩展。
两阶段锁定(2PL)
大约 30 年间,数据库领域只有一种得到广泛应用的可串行化算法:两阶段锁定(two-phase locking,2PL)。它有时也称为 强严格两阶段锁定(strong strict two-phase locking,SS2PL),以区别于 2PL 的其他变体。
两阶段 锁定(2PL)与两阶段 提交(2PC)是完全不同的两回事。2PL 提供可串行化隔离,2PC 则为分布式数据库提供原子提交(参见“两阶段提交(2PC)”)。为免混淆,最好把它们当成彼此独立的概念,不必理会名称上这种不巧的相似。
前文已经看到,锁通常用于防止脏写(参见“没有脏写”):若两个事务并发写入同一对象,锁会迫使第二个写入者等待,直到第一个事务提交或中止后才能继续。
两阶段锁定与此类似,但加锁要求严格得多。只要没有写入,多个事务可以并发读取同一个对象;但只要有事务想写入(修改或删除)对象,就必须取得独占访问权:
- 如果事务 A 已读取某个对象,而事务 B 想写入该对象,B 必须等 A 提交或中止后才能继续。(这样可以确保 B 不会在 A 不知情的情况下改变对象。)
- 如果事务 A 已写入某个对象,而事务 B 想读取该对象,B 必须等 A 提交或中止后才能继续。(2PL 不允许像图 8-4那样读取对象的旧版本。)
在 2PL 中,写会阻塞其他写,也会阻塞读;反过来,读同样会阻塞写。快照隔离则奉行“读不阻塞写,写也不阻塞读”(参见“多版本并发控制(MVCC)”),这正是快照隔离与两阶段锁定的关键区别。另一方面,2PL 提供可串行化,能够防止前面讨论的所有竞态条件,包括丢失更新和写偏差。
两阶段锁定的实现
MySQL(InnoDB)和 SQL Server 的可串行化隔离级别,以及 Db2 的可重复读隔离级别,都采用 2PL29。
数据库通过为每个对象设置一把锁来阻塞读写操作。锁可以处于 共享模式(shared mode)或 独占模式(exclusive mode),这也称为 多读者单写者(multi-reader single-writer)锁。具体规则如下:
- 事务要读取对象,必须先以共享模式取得锁。多个事务可以同时持有共享锁;但如果另一个事务已持有该对象的独占锁,读事务就必须等待。
- 事务要写入对象,必须先以独占模式取得锁。此时不允许其他事务以任何模式持锁;只要对象上已有锁,写事务就必须等待。
- 事务先读后写时,可以把共享锁升级为独占锁。升级规则与直接取得独占锁相同。
- 事务取得锁后,必须一直持有到事务结束(提交或中止)。这正是“两阶段”名称的由来:第一阶段在事务执行期间获取锁,第二阶段在事务结束时一次释放所有锁。
由于大量使用锁,很容易出现事务 A 等待事务 B 释放锁、事务 B 又反过来等待事务 A 的情况,这称为 死锁(deadlock)。数据库会自动检测事务之间的死锁并中止其中一个,让其他事务能够继续推进;被中止的事务则由应用程序重试。
两阶段锁定的性能
两阶段锁定最大的缺点,也是它自 20 世纪 70 年代以来始终没有普及到所有数据库的原因,就是性能:在两阶段锁定下,事务吞吐量和查询响应时间都明显逊于弱隔离。
这部分源于获取和释放大量锁的开销,更主要的原因则是并发度降低。按照设计,只要两个并发事务执行的操作有任何可能引发竞态条件,其中一个就必须等另一个完成。
例如,一个事务需要读取整张表,用于备份、分析查询或完整性检查(参见“快照隔离与可重复读”),就必须为整张表取得共享锁。读事务首先要等待所有正在写表的事务结束;随后,在它读取整张表期间(大表可能耗时很久),所有想写入该表的事务又会被阻塞,直至这个大型只读事务提交。实际上,数据库会在很长一段时间内无法接受写入。
因此,采用 2PL 的数据库延迟可能相当不稳定;工作负载一旦存在争用,高百分位延迟就可能非常糟糕(参见“描述性能”)。仅仅一个慢事务,或者一个访问大量数据、取得许多锁的事务,就可能让系统其余部分陷入停顿。
基于锁的读已提交也会发生死锁,但在 2PL 可串行化隔离下,死锁往往更为频繁(具体取决于事务的访问模式)。这又会带来额外的性能问题:事务因死锁而中止、重试时,必须从头重做全部工作。若死锁频繁,浪费的计算量会相当可观。
谓词锁
前面对锁的描述略过了一个微妙却重要的细节。“导致写偏差的幻读”介绍过 幻读:一个事务改变了另一个事务的搜索查询结果。提供可串行化隔离的数据库必须防止幻读。
在会议室预订的例子中,如果一个事务已经搜索某个房间在特定时段内的现有预订(参见示例 8-2),另一个事务就不能并发插入或更新同一房间、同一时段的预订。(并发预订其他房间,或者预订同一房间但互不影响的其他时段,则没有问题。)
怎样实现这一点?从概念上讲,需要使用 谓词锁(predicate lock)4。它的工作方式类似前面介绍的共享锁和独占锁,却不属于某个特定对象(例如表中的一行),而是属于所有符合某项搜索条件的对象,例如:
谓词锁限制访问如下:
- 事务 A 想读取符合某项条件的对象(如上面的
SELECT),必须先在查询条件上取得共享模式的谓词锁。如果事务 B 已经对任何符合条件的对象持有独占锁,A 就要等 B 释放锁后才能查询。 - 事务 A 想插入、更新或删除对象,必须先检查旧值或新值是否符合任何已有谓词锁。如果发现事务 B 持有匹配的谓词锁,A 就要等 B 提交或中止后才能继续。
关键在于,谓词锁甚至适用于数据库中尚不存在、未来却可能加入的对象(即幻读)。两阶段锁定若包含谓词锁,数据库就能防止各种形式的写偏差和其他竞态条件,使隔离达到可串行化。
索引范围锁
遗憾的是,谓词锁性能不佳:活跃事务持有大量锁时,检查是否有匹配的锁会非常耗时。因此,大多数采用 2PL 的数据库实际实现的是 索引范围锁(index-range lock,也称为 next-key locking),即谓词锁的一种简化近似54 64。
把谓词放宽到匹配更大的对象集合,是一种安全的简化。例如,要锁定 123 号房间从中午 12 点到下午 1 点的预订,可以近似为锁定 123 号房间全天所有预订;也可以近似为锁定中午 12 点到下午 1 点所有房间的预订。这样做是安全的,因为任何符合原始谓词的写入,必然也符合近似后的条件。
会议室预订数据库很可能在 room_id 列上建有索引,或在 start_time 和 end_time 上建有索引(否则前述查询面对大型数据库会非常慢):
- 假设索引建在
room_id上,数据库用它查找 123 号房间的现有预订。数据库可以直接把共享锁附加到这条索引项上,表示某个事务已搜索过 123 号房间的预订。 - 如果数据库使用时间索引查找现有预订,则可以把共享锁附加到索引中的一段值域上,表示某个事务已经搜索过与 2025 年 1 月 1 日中午 12 点至下午 1 点重叠的预订。
无论采用哪种索引,搜索条件的近似都会附着在其中一个索引上。另一个事务若想插入、更新或删除同一房间和/或重叠时段的预订,就必须更新索引的同一部分,于是会碰到共享锁,被迫等到锁释放。
这样便能有效防止幻读和写偏差。索引范围锁不如谓词锁精确,可能会锁住比维持可串行化严格所需更大范围的对象;但它的开销低得多,是一种很好的折中。
如果没有合适的索引可供附加范围锁,数据库可以退回到对整张表加共享锁。这样会阻塞所有其他事务写表,性能很差,却是安全的后备方案。
可串行化快照隔离(SSI)
到目前为止,数据库并发控制的前景显得颇为黯淡:一边是性能不佳的两阶段锁定,以及伸缩性不佳的串行执行;另一边是性能虽好,却容易出现丢失更新、写偏差、幻读等竞态条件的弱隔离。可串行化隔离与良好性能从根本上就无法兼得吗?
看来并非如此。一种名为 可串行化快照隔离(serializable snapshot isolation,SSI)的算法能够提供完整的可串行化,与快照隔离相比只付出很小的性能代价。SSI 相对较新,最早于 2008 年提出53 65。
如今,SSI 及类似算法已经用于单节点数据库(PostgreSQL 的可串行化隔离级别54、SQL Server 的内存 OLTP/Hekaton66 和 HyPer67)、分布式数据库(CockroachDB5 和 FoundationDB8),以及 BadgerDB 等嵌入式存储引擎。
悲观并发控制与乐观并发控制
两阶段锁定属于所谓的 悲观(pessimistic)并发控制机制。它遵循这样的原则:只要有任何出错的可能(例如另一个事务持有锁),就先等待,直到局面重新安全后再行动。这类似于多线程编程中用来保护数据结构的 互斥(mutual exclusion)。
从某种意义上说,串行执行把悲观推到了极致:本质上,每个事务在执行期间都像是对整个数据库(或一个数据库分片)持有独占锁。作为补偿,每个事务都要尽快执行,使这把“锁”只持有很短时间。
相比之下,可串行化快照隔离是一种 乐观(optimistic)并发控制技术。这里的“乐观”是指:即使出现潜在危险,也不阻塞事务,而是让它继续运行,寄望最终不会出问题。等事务准备提交时,数据库再检查是否发生了坏事(即隔离是否遭到破坏);若有,就中止并重试事务。只有执行结果可串行化的事务才能提交。
乐观并发控制是个古老的思想68,其利弊也已争论多年69。存在严重争用(许多事务访问相同对象)时,它表现很差,因为很大比例的事务都不得不中止。若系统已经接近最大吞吐量,重试事务带来的额外负载还会进一步拖累性能。
不过,如果系统有充足的余量,事务之间争用又不严重,乐观并发控制往往优于悲观方案。可交换的原子操作还能降低争用:例如,多个事务并发递增计数器时,应用这些增量的顺序无关紧要(只要同一事务没有读取该计数器),因此所有并发增量都可以执行而不会冲突。
顾名思义,SSI 建立在快照隔离之上:事务中的所有读取都来自数据库的一致快照(参见“快照隔离与可重复读”)。在此基础上,SSI 加入算法来检测读写之间的可串行化冲突,并决定应当中止哪些事务。
基于过时前提的决策
前面讨论快照隔离中的写偏差时(参见“写偏差与幻读”),我们反复看到一种模式:事务从数据库读取数据、检查查询结果,再根据结果决定执行某项操作(向数据库写入)。然而在快照隔离下,等事务提交时,原查询结果可能已经过时,因为数据在此期间可能被修改。
换句话说,事务根据某个 前提 行动——即事务开始时为真的事实,例如“当前有两名医生值班”。等到事务准备提交时,原始数据可能已经变化,原先的前提不再成立。
应用发出查询(例如“当前有多少医生值班?”)时,数据库并不知道应用逻辑会如何使用查询结果。为确保安全,数据库必须假定:查询结果(即前提)一有变化,该事务中的写入就可能失效。也就是说,事务中的查询与写入之间可能存在因果依赖。为了提供可串行化隔离,数据库必须检测事务是否可能依据过时前提行事,并在这种情况下中止事务。
数据库怎样判断查询结果可能已经变化?需要考虑两种情况:
- 检测是否读取了过时的 MVCC 对象版本(未提交的写入发生在读取之前);
- 检测是否有写入影响了先前的读取(写入发生在读取之后)。
检测陈旧的 MVCC 读取
回想一下,快照隔离通常通过多版本并发控制实现(MVCC;参见“多版本并发控制(MVCC)”)。事务从 MVCC 数据库的一致快照读取时,会忽略其他事务在快照生成时尚未提交的所有写入。
在图 8-10中,事务 43 看到 Aaliyah 的 on_call = true,因为修改其值班状态的事务 42 尚未提交。但等事务 43 准备提交时,事务 42 已经提交。这意味着,读取一致快照时被忽略的写入现在已经生效,事务 43 的前提不再成立。如果写入者插入的是此前不存在的数据,情况还会更复杂(参见“导致写偏差的幻读”)。“检测影响先前读取的写入”将介绍 SSI 如何检测幻写。

为防止这种异常,数据库必须跟踪事务何时因 MVCC 可见性规则而忽略了其他事务的写入。等事务准备提交时,再检查这些被忽略的写入是否已经提交;若是,就必须中止该事务。
为什么非要等到提交?为什么不在发现过时读取时立刻中止事务 43?如果事务 43 是只读事务,就没有写偏差风险,无须中止;而在它读取时,数据库还不知道它稍后是否会写入。此外,事务 42 可能最终中止,也可能在事务 43 提交时仍未提交,于是这次读取到头来并未过时。通过避免不必要的中止,SSI 保留了快照隔离支持长期读取一致快照的优点。
检测影响先前读取的写入
第二种情况,是数据被读取后又遭另一个事务修改,如图 8-11所示。

讨论两阶段锁定时,我们介绍过索引范围锁(参见“索引范围锁”),数据库可以借此锁定所有符合某项查询条件的行,例如 WHERE shift_id = 1234。SSI 可以使用类似技术,区别在于 SSI 的锁不会阻塞其他事务。
在图 8-11中,事务 42 和 43 都查询了班次 1234 的值班医生。如果 shift_id 上有索引,数据库就能利用索引项 1234 记录事务 42 和 43 读过这项数据。(没有索引时,也可以在表级别跟踪。)这些信息只需保留一段时间:等事务结束(提交或中止),并且所有与之并发的事务也结束后,数据库就可以忘掉它读过哪些数据。
事务写入数据库时,必须在索引中查找近期读过受影响数据的其他事务。这个过程类似为受影响的键范围取得写锁,但它不会一直阻塞到读事务提交,而是像警戒线一样:只负责通知相关事务,它们读过的数据可能已经过时。
在图 8-11中,事务 43 通知事务 42:它先前读过的数据已经过时;事务 42 也反过来通知事务 43。事务 42 率先提交并获得成功:虽然事务 43 的写入影响了事务 42,但事务 43 尚未提交,所以写入还未生效。等事务 43 准备提交时,事务 42 的冲突写入已经提交,因此事务 43 必须中止。
可串行化快照隔离的性能
一如既往,许多工程细节会影响算法的实际表现。例如,需要权衡以多细的粒度跟踪事务读写。如果数据库详细记录每个事务的活动,就能精确判断哪些事务应当中止,但记账开销可能很大;较粗粒度的跟踪更快,却可能中止一些本来无须中止的事务。
有些情况下,事务读到被另一事务覆盖的信息也没有关系:结合其他事件,有时可以证明执行结果仍然可串行化。PostgreSQL 利用这一理论来减少不必要的中止14 54。
与两阶段锁定相比,可串行化快照隔离的主要优势是:事务无须因等待另一个事务持有的锁而阻塞。与快照隔离一样,写不阻塞读,读也不阻塞写。这项设计原则让查询延迟更加可预测,波动也更小。尤其是只读查询可以无须任何锁、直接运行在一致快照上,对读取密集型工作负载很有吸引力。
与串行执行相比,可串行化快照隔离不受单个 CPU 核吞吐量的限制。例如,FoundationDB 把可串行化冲突检测分散到多台机器上,因而可以扩展到很高的吞吐量。即使数据分片存放在多台机器上,事务仍可读写多个分片,同时保证可串行化隔离。
与非可串行化的快照隔离相比,检查可串行化违规会带来一些性能开销。这项开销究竟有多大,仍有争议:有人认为可串行化检查得不偿失70;也有人认为如今可串行化性能已经足够好,没必要再采用较弱的快照隔离67。
中止率会显著影响 SSI 的总体性能。一个长期读写数据的事务很可能遇到冲突而中止,因此 SSI 要求读写事务相当短小(长期运行的只读事务没有问题)。不过,与两阶段锁定或串行执行相比,SSI 对慢事务没有那么敏感。
分布式事务
前几节重点讨论了实现隔离性(即 ACID 中的 I)所需的并发控制。前面介绍的算法既适用于单节点数据库,也适用于分布式数据库。尽管并发控制算法在伸缩时会遇到挑战(例如为 SSI 执行分布式可串行化检查),分布式并发控制的总体思路仍与单节点并发控制相似8。
转向分布式事务后,一致性和持久性也没有太大变化;但原子性需要格外小心。
对于只在单个数据库节点上执行的事务,原子性通常由存储引擎实现。客户端请求数据库节点提交时,数据库先将事务写入持久化(通常写入预写日志;参见“使 B 树可靠”),再向磁盘日志追加一条提交记录。如果数据库在此过程中崩溃,节点重启后便从日志恢复事务:若提交记录在崩溃前已经成功落盘,事务视为已提交;否则,该事务的所有写入都会回滚。
因此在单节点上,事务能否提交,关键取决于数据持久化落盘的 顺序:先写数据,再写提交记录22。决定事务提交还是中止的关键时刻,就是磁盘完成提交记录写入的一刻:在此之前,事务仍可能因崩溃而中止;在此之后,即使数据库崩溃,事务也已经提交。换言之,是一个单独的设备——连接在特定节点上的某块磁盘的控制器——使提交具备原子性。
可是,如果事务涉及多个节点呢?例如,分片数据库中可能有多对象事务,也可能有全局二级索引,其中索引项和主数据位于不同节点(参见“分片与二级索引”)。大多数“NoSQL”分布式数据存储不支持这类分布式事务,但不少分布式关系型数据库支持。
此时,仅仅向所有节点发送提交请求,让每个节点独立提交事务并不够。如图 8-12所示,提交很容易在一部分节点上成功,却在另一部分节点上失败:
- 某些节点可能检测到约束违规或冲突,因而必须中止,其他节点却能成功提交;
- 某些提交请求可能在网络中丢失,最终因超时而中止,其他请求却顺利抵达;
- 某些节点可能在提交记录完全写入前崩溃,恢复时回滚事务,其他节点却成功提交。

如果有些节点提交、有些节点中止,节点之间便会出现不一致。而事务一旦在某个节点上提交,即便后来发现它在另一个节点上中止,也不能再撤回。因为数据提交后,在 读已提交 或更强隔离下就会对其他事务可见。例如,在图 8-12中,当用户 1 发现数据库 1 提交失败时,用户 2 已经在数据库 2 读到了同一事务写入的数据。如果事后再中止用户 1 的事务,就连用户 2 的事务也必须撤销,因为它所依据的数据被追溯宣布为从未存在。
更好的办法是确保参与事务的节点要么全部提交,要么全部中止,绝不允许两种结果混杂。这就是所谓的 原子提交(atomic commitment)问题。
两阶段提交(2PC)
两阶段提交是一种跨多个节点实现原子事务提交的算法,也是分布式数据库中的经典算法13 71 72。有些数据库在内部使用 2PC;它也以 XA 事务73的形式开放给应用程序(例如 Java 事务 API 就支持 XA),或者通过 WS-AtomicTransaction 用于 SOAP Web 服务74 75。
2PC 的基本流程如图 8-13所示。单节点事务只需一次提交请求,2PC 则把提交或中止过程分成两个阶段,名称也由此而来。

2PC 引入了一个单节点事务中通常没有的组件:协调者(coordinator,也称为 事务管理器,transaction manager)。协调者通常实现为一个库,与发起事务的应用运行在同一进程中(例如嵌入 Java EE 容器);也可以作为独立进程或服务运行。Narayana、JOTM、BTM 和 MSDTC 都属于此类协调者。
使用 2PC 时,分布式事务照常开始:应用在多个数据库节点上读写数据,这些节点称为事务的 参与者(participant)。应用准备提交时,协调者进入第一阶段,向每个参与者发送 准备(prepare)请求,询问它是否能够提交,并收集各参与者的响应:
- 如果所有参与者都回答“是”,表示已准备好提交,协调者就在第二阶段发出 提交 请求,真正执行提交;
- 如果任何参与者回答“否”,协调者就在第二阶段向所有节点发送 中止 请求。
这个过程有点像西方传统婚礼:牧师分别询问新娘和新郎是否愿意与对方结婚,通常两人都会回答“我愿意”。收到双方确认后,牧师宣布二人结为夫妻——事务提交,这一喜讯也广播给所有来宾。如果任何一方没有回答“愿意”,仪式就会中止76。
系统承诺
仅凭上面的简短描述,或许还看不出为什么两阶段提交能够保证原子性,跨多个节点的一阶段提交却不能。毕竟在两阶段流程中,准备和提交请求同样可能丢失。2PC 究竟有何不同?
要理解它为何有效,需要把过程进一步拆解:
- 应用要启动分布式事务时,先向协调者申请一个全局唯一的事务 ID。
- 应用在每个参与者上启动一个单节点事务,并为其附上这个全局事务 ID。所有读写都在这些单节点事务中完成。如果此阶段出现任何问题(例如节点崩溃或请求超时),协调者或任何参与者都可以中止事务。
- 应用准备提交时,协调者向所有参与者发送带全局事务 ID 的准备请求。任何一个请求失败或超时,协调者都会向所有参与者发送该事务 ID 的中止请求。
- 参与者收到准备请求后,必须确保自己在任何情况下都一定能提交事务。这既包括把全部事务数据写入磁盘(崩溃、断电或磁盘空间耗尽都不能成为以后拒绝提交的理由),也包括检查冲突和约束违规。节点向协调者回答“是”,就等于承诺:只要收到要求,事务一定能够无误提交。换句话说,参与者尚未真正提交,却已经放弃了自行中止事务的权利。
- 协调者收到所有准备请求的响应后,便对提交还是中止作出最终决定(只有所有参与者都投“是”才会提交)。协调者必须把决定写入磁盘上的事务日志,这样即使随后崩溃,恢复后也知道自己作过什么决定。这一时刻称为 提交点(commit point)。
- 协调者的决定一旦落盘,就向所有参与者发送提交或中止请求。请求若失败或超时,协调者必须不断重试,直到成功为止。此时再无回头路:如果决定提交,就必须执行到底,无论需要重试多少次。参与者若在此期间崩溃,恢复后也必须提交事务——既然它已经投了“是”,就不能反悔。
因此,协议中有两个关键的“不归点”:参与者投“是”时,承诺自己日后一定能够提交(尽管协调者仍可决定中止);协调者一旦作出决定,该决定便不可撤销。正是这套承诺保证了 2PC 的原子性。(单节点原子提交把这两件事合并成一步:将提交记录写入事务日志。)
回到婚礼的比喻:说出“我愿意”之前,你或准配偶都可以用“绝对不行!”之类的回答中止事务;一旦说出“我愿意”,就不能撤回。即使说完后昏了过去,没有听见牧师宣布“你们现在结为夫妻”,也不会改变事务已经提交的事实。恢复意识后,可以向牧师查询全局事务 ID 的状态,确认自己是否已经成婚;也可以等待牧师再次发送提交请求——在你昏迷期间,他一直都在重试。
协调者失效
我们已经讨论过 2PC 期间参与者或网络发生故障时会怎样:任何准备请求失败或超时,协调者都会中止事务;任何提交或中止请求失败,协调者都会无限重试。但协调者自身崩溃时会发生什么,就没那么清楚了。
如果协调者在发送准备请求前失效,参与者可以安全中止事务。但参与者一旦收到准备请求并投出“是”,便不能再单方面中止,必须等待协调者告知事务究竟提交还是中止。如果协调者此时崩溃或网络发生故障,参与者只能等待。这种状态下的事务称为 存疑(in doubt)或 不确定(uncertain)事务。
图 8-14展示了这种情况。在图中的例子里,协调者实际决定提交,数据库 2 也收到了提交请求;但协调者还没来得及把提交请求发给数据库 1 就崩溃了,所以数据库 1 不知道该提交还是中止。超时对此也无济于事:数据库 1 若在超时后自行中止,就会与已经提交的数据库 2 不一致;自行提交同样不安全,因为另一个参与者可能已经中止。

收不到协调者的消息,参与者就无从知道应提交还是中止。原则上,参与者可以相互通信,了解各自如何投票并达成某种协议,但这并不属于 2PC 协议。
完成 2PC 的唯一办法,就是等待协调者恢复。这正是协调者必须先把提交或中止决定写入磁盘事务日志,再向参与者发送请求的原因。协调者恢复后,通过读取事务日志来确定所有存疑事务的状态;协调者日志中没有提交记录的事务一律中止。因此,2PC 的提交点归根结底是协调者上的一次普通单节点原子提交。
三阶段提交
由于 2PC 可能卡住并一直等待协调者恢复,两阶段提交也称为 阻塞式(blocking)原子提交协议。理论上可以设计 非阻塞式(nonblocking)原子提交协议,使它在节点发生故障时不会卡住;但要在实践中做到这一点并不容易。
有人提出 三阶段提交(three-phase commit,3PC)作为 2PC 的替代方案13 77。然而,3PC 假设网络延迟有上界、节点响应时间也有上界;大多数现实系统都存在无界网络延迟和进程暂停(参见第 9 章),3PC 在其中无法保证原子性。
实践中更好的办法,是用容错共识协议替代单节点协调者。第 10 章将介绍如何做到这一点。
跨不同系统的分布式事务
分布式事务和两阶段提交的名声毁誉参半。一方面,它们提供了难以用其他方式实现的重要安全保证;另一方面,它们又因引发运维问题、严重损害性能,以及作出力所不及的承诺而备受批评78 79 80 81。许多云服务正是出于这些运维问题,选择不实现分布式事务82。
某些分布式事务实现会带来沉重的性能代价。两阶段提交的固有开销,大多来自崩溃恢复所需的额外强制刷盘(fsync)和额外网络往返。
不过,我们不应就此全盘否定分布式事务,而应仔细审视,从中汲取重要经验。首先要明确“分布式事务”究竟指什么,因为人们经常混淆两种截然不同的分布式事务:
- 数据库内部分布式事务
- 一些分布式数据库(即标准配置就使用复制和分片的数据库)支持数据库节点之间的内部事务。例如,YugabyteDB、TiDB、FoundationDB、Spanner、VoltDB,以及 MySQL Cluster 的 NDB 存储引擎都支持内部事务。此时,参与事务的所有节点运行的都是同一种数据库软件。
- 异构分布式事务
- 异构 事务的参与者来自两种或更多不同技术,例如不同厂商的两种数据库,甚至还包括消息代理等非数据库系统。即使底层系统截然不同,跨系统分布式事务仍必须保证原子提交。
数据库内部事务无须与其他系统兼容,因而可以采用任意协议,并针对具体技术进行优化。所以,数据库内部的分布式事务通常运转良好;跨异构技术的事务则困难得多。
恰好一次消息处理
异构分布式事务可以用强有力的方式集成不同系统。例如,当且仅当处理消息的数据库事务成功提交,才确认消息队列中的那条消息已经处理。实现方法是在同一个事务中原子提交 消息确认 和 数据库写入。有了分布式事务,即使消息代理与数据库是运行在不同机器上的两种互不相关的技术,也能做到这一点。
如果消息传递或数据库事务任一失败,两者都会中止,消息代理稍后便可安全地重新投递。通过原子提交消息及其处理副作用,可以确保消息实际上 恰好处理一次,即使成功之前重试了好几次。每次中止都会丢弃未完成事务产生的所有副作用。这就是 恰好一次语义(exactly-once semantics)。
不过,只有事务影响的所有系统都能采用同一种原子提交协议,这类分布式事务才有可能实现。例如,假设处理消息的副作用是发送邮件,而邮件服务器不支持两阶段提交;一旦消息处理失败并重试,邮件就可能发送两次甚至更多次。反之,如果事务中止时,消息处理产生的所有副作用都能回滚,那么处理过程便可安全重试,就像什么也没有发生一样。
本章稍后还会回到恰好一次语义。现在先来看看支持这类异构分布式事务的原子提交协议。
XA 事务
X/Open XA(eXtended Architecture,即扩展架构的缩写)是跨异构技术实现两阶段提交的标准73。它于 1991 年发布,如今已得到广泛实现:许多传统关系型数据库(包括 PostgreSQL、MySQL、Db2、SQL Server 和 Oracle)和消息代理(包括 ActiveMQ、HornetQ、MSMQ 和 IBM MQ)都支持 XA。
XA 并不是网络协议,而只是一套与事务协调者交互的 C API。其他语言也提供相应绑定。例如,在 Java EE 应用中,XA 事务通过 Java 事务 API(JTA)实现;许多使用 Java 数据库连接(JDBC)的数据库驱动,以及使用 Java 消息服务(JMS)API 的消息代理驱动都支持 JTA。
XA 假定应用通过网络驱动或客户端库,与参与者数据库或消息服务通信。驱动支持 XA,意味着它会调用 XA API,判断某项操作是否属于分布式事务;若是,就把必要信息发送给数据库服务器。驱动还会暴露回调接口,供协调者要求参与者准备、提交或中止。
事务协调者负责实现 XA API。标准没有规定具体实现方式;实践中,协调者通常只是一个加载到事务发起应用同一进程中的库,而不是独立服务。它跟踪事务中的所有参与者,要求它们准备后通过驱动回调收集响应,并用本地磁盘日志记录每个事务的提交或中止决定。
如果应用进程崩溃,或运行应用的机器宕机,协调者也会随之消失。所有持有已准备但尚未提交事务的参与者,都会陷入存疑状态。协调者日志位于应用服务器的本地磁盘上,因此必须重启这台服务器,再由协调者库读取日志,恢复各事务的提交或中止结果。之后,协调者才能通过数据库驱动的 XA 回调,要求参与者提交或中止。数据库服务器无法直接联系协调者,因为所有通信都必须经过客户端库。
存疑时持有锁
为什么要如此在意存疑事务?难道系统其他部分不能照常工作,暂且忽略这些最终总会清理掉的事务吗?
问题出在 锁 上。正如“读已提交”所述,数据库事务通常会对修改的每一行取得行级独占锁,以防止脏写。如果还要求可串行化隔离,那么采用两阶段锁定的数据库也必须为事务 读取 的每一行取得共享锁。
事务提交或中止之前,数据库不能释放这些锁(如图 8-13中的阴影部分所示)。所以使用两阶段提交时,事务必须在整个存疑期间持锁。协调者若崩溃后要花 20 分钟重启,锁就会持有 20 分钟;协调者日志若因故彻底丢失,锁甚至会永远保留——至少要一直等到管理员手动解决问题。
持锁期间,其他事务无法修改这些行;视隔离级别而定,甚至连读取也可能被阻塞。因此,其他事务无法若无其事地继续运行——只要访问同一份数据,就会卡住。应用程序的大部分功能都可能因此不可用,直至存疑事务得到解决。
从协调者失效中恢复
理论上,协调者崩溃重启后,应该能从日志完整恢复状态,并解决所有存疑事务。但实践中确实会出现 孤立的 存疑事务83 84:协调者由于某种原因无法确定事务结果,例如软件缺陷导致事务日志丢失或损坏。这些事务无法自动解决,只能一直留在数据库中,持有锁并阻塞其他事务。
重启数据库服务器也解决不了问题。正确的 2PC 实现必须跨重启保留存疑事务的锁,否则就有破坏原子性保证的风险。这种局面十分棘手。
唯一的出路,是由管理员手动决定提交还是回滚。管理员必须检查每个存疑事务的所有参与者,确定是否已有参与者提交或中止,再把同样的结果应用到其余参与者。这项工作可能耗费大量人力,而且往往要在严重生产中断期间,承受巨大的精神与时间压力来完成——否则协调者也不会陷入如此糟糕的状态。
许多 XA 实现都留有一个称为 启发式决策(heuristic decision)的紧急出口:即使协调者没有给出明确决定,也允许参与者单方面中止或提交存疑事务73。需要直说的是,这里的“启发式”只是“很可能破坏原子性”的委婉说法,因为它违背了两阶段提交的承诺体系。因此,启发式决策只用于摆脱灾难性局面,绝不能作为常规手段。
XA 事务的问题
单节点协调者是整个系统的单点故障。把协调者放进应用服务器同样有问题,因为协调者本地磁盘上的日志会成为持久系统状态的关键部分,其重要性不亚于数据库本身。
原则上,XA 事务的协调者可以像其他重要数据库一样实现高可用和复制。遗憾的是,这仍解决不了 XA 的一个根本问题:它没有提供协调者与事务参与者直接通信的办法。二者只能通过发起事务的应用代码,以及应用用来调用参与者的数据库驱动进行通信。
因此,即使复制了协调者,应用代码依然是单点故障。要解决这个问题,必须彻底重构应用代码的运行方式,使其可复制或可重启,形式上可能类似持久化执行(参见“持久化执行与工作流”)。但实践中似乎没有工具真正采用这种方案。
另一个问题是,XA 必须兼容形形色色的数据系统,因而只能取它们的最低公分母。例如,它无法检测横跨不同系统的死锁,因为这需要一套标准协议,让系统交换每个事务正在等待哪些锁;它也无法配合 SSI 使用(参见“可串行化快照隔离(SSI)”),因为 SSI 需要一套跨系统识别冲突的协议。
这些问题在一定程度上是跨异构技术执行事务的固有困难。然而,让多个异构数据系统彼此保持一致仍是一个真实而重要的需求,所以必须另寻解决方案。办法的确存在,下一节和“衍生数据与分布式事务”将作介绍。
数据库内部的分布式事务
如前所述,横跨多种异构存储技术的分布式事务,与系统内部的分布式事务大不相同。在后者中,所有参与节点都是同一数据库的分片,运行相同软件。内部的分布式事务是 CockroachDB5、TiDB6、Spanner7、FoundationDB8 和 YugabyteDB 等“NewSQL”数据库的标志性特征。Kafka 等消息代理也支持内部的分布式事务85。
这些系统中有许多使用两阶段提交,来保证写入多个分片的事务具备原子性,却不会遇到 XA 事务的那些问题。因为其分布式事务无须对接其他技术,所以能够避开最低公分母陷阱;系统设计者可以自由采用更可靠、更快速的协议。
XA 最严重的几个问题可以这样解决:
- 复制协调者;主协调者崩溃时,自动故障切换到另一个协调者节点;
- 允许协调者和数据分片直接通信,不再经过应用代码;
- 复制参与事务的分片,降低因某个分片故障而不得不中止事务的风险;
- 将原子提交协议与分布式并发控制协议结合起来,由后者支持跨分片死锁检测和一致读取。
协调者和数据库分片通常采用共识算法复制。第 10 章将介绍怎样用共识算法实现分布式事务的原子提交。这些算法无需人工干预,就能自动从发生故障的节点切换到另一个节点,在容忍故障的同时继续保证强一致性属性。
分布式事务提供哪种隔离级别,取决于具体系统;但跨分片实现快照隔离和可串行化快照隔离都是可行的。其工作原理详见本章末尾引用的论文。
再谈恰好一次消息处理
“恰好一次消息处理”介绍过,分布式事务的一个重要用途,是保证某项操作恰好生效一次,即使处理期间发生崩溃、不得不重试也不例外。如果能跨消息代理和数据库原子提交事务,就可以做到:当且仅当消息成功处理、处理产生的数据库写入也成功提交时,才向代理确认消息。
不过,要实现恰好一次语义,其实并不需要这样的分布式事务。下面的替代方案只要求数据库本身支持事务:
- 假设每条消息都有唯一 ID,数据库中另有一张表,记录已经处理过的消息 ID。从代理取得消息并开始处理时,先在数据库中启动新事务,再检查消息 ID。如果数据库中已有相同 ID,就知道该消息已经处理过,可以向代理确认并丢弃这条消息。
- 如果数据库中尚无该消息 ID,就将其加入表中,然后处理消息。处理过程可能在同一个事务中对数据库执行更多写入。消息处理完成后,提交数据库事务。
- 数据库事务成功提交后,便可向代理确认消息。
- 消息成功向代理确认后,就知道代理不会再次尝试处理同一条消息,因此可以另开一个事务,从数据库删除该消息 ID。
如果消息处理器在提交数据库事务前崩溃,事务会中止,消息代理随后重试。如果它在提交后、向代理确认前崩溃,代理同样会重试;但重试时能在数据库中看到消息 ID,于是直接丢弃该消息。如果它在确认后、从数据库删除消息 ID 前崩溃,数据库只会残留一条旧消息 ID,除了占用少量空间,不会造成其他危害。重试也可能发生在原数据库事务中止之前——例如消息处理器与数据库之间的通信中断——此时消息 ID 表上的唯一性约束应能防止两个并发事务插入相同 ID。
因此,实现恰好一次处理只需要数据库内部的事务;这个用例并不要求数据库与消息代理之间具备原子性。把消息 ID 记入数据库,可以让消息处理具备 幂等性(idempotence),因而能够安全重试而不重复产生副作用。Kafka Streams 等流处理框架也用类似办法实现恰好一次语义,详见“容错”。
不过,数据库内部的分布式事务仍有助于扩展这类模式。例如,消息 ID 可以存放在一个分片上,消息处理所更新的业务数据放在其他分片上,再由内部事务保证跨分片提交的原子性。
总结
事务是一层抽象,让应用程序可以假装某些并发问题,以及某些软硬件故障并不存在。种类繁多的错误都被简化成一次 事务中止,应用程序只需重试即可。
本章考察了许多事务有助于防范的问题。并非所有应用都会遇到其中每一种问题:访问模式非常简单的应用(例如只读写单条记录),没有事务或许也能应付。但面对更复杂的访问模式,事务可以大幅减少需要考虑的潜在错误场景。
没有事务,进程崩溃、网络中断、断电、磁盘空间耗尽、意外并发等各种错误场景,都可能以不同方式造成数据不一致。例如,反规范化数据很容易与源数据失去同步。缺少事务时,复杂而相互影响的访问究竟会给数据库带来什么后果,很难推理。
本章尤其深入地探讨了并发控制。我们介绍了几种广泛使用的隔离级别,特别是 读已提交、快照隔离(有时称为 可重复读)和 可串行化,并通过各种竞态条件来刻画它们。表 8-1汇总了这些内容:
| 隔离级别 | 脏读 | 读偏差 | 幻读 | 丢失更新 | 写偏差 |
|---|---|---|---|---|---|
| 读未提交 | ✗ 可能 | ✗ 可能 | ✗ 可能 | ✗ 可能 | ✗ 可能 |
| 读已提交 | ✓ 防止 | ✗ 可能 | ✗ 可能 | ✗ 可能 | ✗ 可能 |
| 快照隔离 | ✓ 防止 | ✓ 防止 | ✓ 防止 | ? 视情况 | ✗ 可能 |
| 可串行化 | ✓ 防止 | ✓ 防止 | ✓ 防止 | ✓ 防止 | ✓ 防止 |
- 脏读
- 一个客户端在另一个客户端的写入提交前就读到了这些数据。读已提交及更强的隔离级别可以防止脏读。
- 脏写
- 一个客户端覆盖另一个客户端已经写入、但尚未提交的数据。几乎所有事务实现都会防止脏写。
- 读偏差
- 客户端在不同时间点观察数据库的不同部分。有些读偏差也称为 不可重复读。防止这个问题最常用的办法是快照隔离,它允许事务读取与某个特定时刻对应的一致快照,通常以 多版本并发控制(MVCC)实现。
- 丢失更新
- 两个客户端并发执行读取—修改—写入循环,其中一个覆盖另一个的写入,却没有合并对方的修改,因而造成数据丢失。有些快照隔离实现会自动防止这种异常,另一些则需要手动加锁(
SELECT FOR UPDATE)。 - 写偏差
- 事务读取数据,根据看到的值作出决定,再把决定写入数据库。但真正写入时,作出决定所依据的前提已经不再成立。只有可串行化隔离能够防止这种异常。
- 幻读
- 事务读取符合某项搜索条件的对象,另一个客户端随后写入,改变了搜索结果。快照隔离可以防止直接的幻读,但写偏差场景中的幻读需要特殊处理,例如使用索引范围锁。
弱隔离级别能防止其中一部分异常,却把其他异常留给应用开发者手动处理(例如显式加锁)。只有可串行化隔离能防范所有这些问题。我们讨论了三种实现可串行化事务的办法:
- 严格按串行顺序执行事务
- 如果每个事务都能极快完成(通常借助存储过程),而且事务吞吐量足够低,能由单个 CPU 核处理,或者事务能够分片执行,那么这是一种简单有效的选择。
- 两阶段锁定
- 几十年来,这一直是实现可串行化的标准方法,但由于性能不佳,许多应用会避开它。
- 可串行化快照隔离(SSI)
- 一种相对较新的算法,避开了前两种方案的大部分缺点。它采用乐观方式,允许事务不受阻塞地继续运行;事务准备提交时再接受检查,若执行结果不可串行化,就会中止。
最后,我们研究了如何用两阶段提交,为跨多个节点的事务实现原子性。如果所有节点都运行相同的数据库软件,分布式事务往往能良好运转;但一旦横跨不同存储技术(使用 XA 事务),2PC 就会问题重重:它对协调者和驱动事务的应用代码中的故障非常敏感,与并发控制机制也难以配合。所幸,幂等性可以在不要求跨异构存储原子提交的情况下,保证恰好一次语义。后续章节还会进一步讨论这个主题。
本章的示例采用关系数据模型。不过,正如“多对象事务的需求”所说,无论采用哪一种数据模型,事务都是一项很有价值的数据库功能。
参考文献
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 ↩︎ ↩︎ ↩︎ ↩︎
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 ↩︎ ↩︎ ↩︎
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 ↩︎ ↩︎ ↩︎
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 ↩︎ ↩︎ ↩︎ ↩︎
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 ↩︎
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. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
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 ↩︎
Tony Andrews. Enforcing Complex Constraints in Oracle. tonyandrews.blogspot.co.uk, October 2004. Archived at archive.org ↩︎ ↩︎
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. ↩︎ ↩︎ ↩︎
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 ↩︎ ↩︎ ↩︎
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 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
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 ↩︎ ↩︎
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. ↩︎ ↩︎
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 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
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. ↩︎ ↩︎ ↩︎
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 ↩︎
Bruce Momjian. MVCC Unmasked. momjian.us, July 2014. Archived at perma.cc/KQ47-9GYB ↩︎ ↩︎ ↩︎
Peter Alvaro and Kyle Kingsbury. MySQL 8.0.34. jepsen.io, December 2023. Archived at perma.cc/HGE2-Z878 ↩︎ ↩︎ ↩︎
Egor Rogov. PostgreSQL 14 Internals. postgrespro.com, April 2023. Archived at perma.cc/FRK2-D7WB ↩︎
Hironobu Suzuki. The Internals of PostgreSQL. interdb.jp, 2017. ↩︎ ↩︎
Rohan Reddy Alleti. Internals of MVCC in Postgres: Hidden costs of Updates vs Inserts. medium.com, March 2025. Archived at perma.cc/3ACX-DFXT ↩︎
Andy Pavlo and Bohan Zhang. The Part of PostgreSQL We Hate the Most. cs.cmu.edu, April 2023. Archived at perma.cc/XSP6-3JBN ↩︎ ↩︎
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 ↩︎ ↩︎
Nikita Prokopov. Unofficial Guide to Datomic Internals. tonsky.me, May 2014. ↩︎
Daniil Svetlov. A Practical Guide to Taming Postgres Isolation Anomalies. dansvetlov.me, March 2025. Archived at perma.cc/L7LE-TDLS ↩︎
Nate Wiger. An Atomic Rant. nateware.com, February 2010. Archived at perma.cc/5ZYB-PE44 ↩︎
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 ↩︎ ↩︎
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 ↩︎ ↩︎
Dan R. K. Ports and Kevin Grittner. Serializable Snapshot Isolation in PostgreSQL. At 38th International Conference on Very Large Databases (VLDB), August 2012. ↩︎ ↩︎ ↩︎ ↩︎
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 ↩︎
Hans-Jürgen Schönig. Constraints over multiple rows in PostgreSQL. cybertec-postgresql.com, June 2021. Archived at perma.cc/2TGH-XUPZ ↩︎
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. ↩︎
John Hugg. H-Store/VoltDB Architecture vs. CEP Systems and Newer Streaming Architectures. At Data @Scale Boston, November 2014. ↩︎
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. ↩︎ ↩︎
Rich Hickey. The Architecture of Datomic. infoq.com, November 2012. Archived at perma.cc/5YWU-8XJK ↩︎
John Hugg. Debunking Myths About the VoltDB In-Memory Database. dzone.com, May 2014. Archived at perma.cc/2Z9N-HPKF ↩︎ ↩︎
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 ↩︎ ↩︎
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 ↩︎
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 ↩︎
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 ↩︎
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 ↩︎ ↩︎ ↩︎
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 ↩︎
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 ↩︎
Jim Gray. The Transaction Concept: Virtues and Limitations. At 7th International Conference on Very Large Data Bases (VLDB), September 1981. ↩︎
Dale Skeen. Nonblocking Commit Protocols. At ACM International Conference on Management of Data (SIGMOD), April 1981. doi:10.1145/582318.582339 ↩︎
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 ↩︎
Pat Helland. Life Beyond Distributed Transactions: An Apostate’s Opinion. At 3rd Biennial Conference on Innovative Data Systems Research (CIDR), January 2007. ↩︎
Jonathan Oliver. My Beef with MSDTC and Two-Phase Commits. blog.jonathanoliver.com, April 2011. Archived at perma.cc/K8HF-Z4EN ↩︎
Oren Eini (Ahende Rahien). The Fallacy of Distributed Transactions. ayende.com, July 2014. Archived at perma.cc/VB87-2JEF ↩︎
Clemens Vasters. Transactions in Windows Azure (with Service Bus) – An Email Discussion. learn.microsoft.com, July 2012. Archived at perma.cc/4EZ9-5SKW ↩︎
Ajmer Dhariwal. Orphaned MSDTC Transactions (-2 spids). eraofdata.com, December 2008. Archived at perma.cc/YG6F-U34C ↩︎
Paul Randal. Real World Story of DBCC PAGE Saving the Day. sqlskills.com, June 2013. Archived at perma.cc/2MJN-A5QH ↩︎
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 ↩︎