事务的棘手概念

​ 现今,几乎所有的关系型数据库和一些非关系数据库都支持事务。其中大多数遵循IBM System R(第一个SQL数据库)在1975年引入的风格【1,2,3】。40年里,尽管一些实现细节发生了变化,但总体思路大同小异:MySQL,PostgreSQL,Oracle,SQL Server等数据库中的事务支持与System R异乎寻常地相似。

2000年以后,非关系(NoSQL)数据库开始普及。它们的目标是通过提供新的数据模型选择(参见第2章),并通过默认包含复制(第5章)和分区(第6章)来改善关系现状。事务是这种运动的主要原因:这些新一代数据库中的许多数据库完全放弃了事务,或者重新定义了这个词,描述比以前理解所更弱的一套保证【4】。

随着这种新型分布式数据库的炒作,人们普遍认为事务是可扩展性的对立面,任何大型系统都必须放弃事务以保持良好的性能和高可用性【5,6】。另一方面,数据库厂商有时将事务保证作为“重要应用”和“有价值数据”的基本要求。这两种观点都是纯粹的夸张

事实并非如此简单:与其他技术设计选择一样,事务有其优势和局限性。为了理解这些权衡,让我们了解事务所提供保证的细节——无论是在正常运行中还是在各种极端(但是现实存在)情况下。

ACID的含义

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

但实际上,不同数据库的ACID实现并不相同。例如,我们将会看到,围绕着隔离性(Isolation) 的含义有许多含糊不清【8】。高层次上的想法是合理的,但魔鬼隐藏在细节里。今天,当一个系统声称自己“符合ACID”时,实际上能期待的是什么保证并不清楚。不幸的是,ACID现在几乎已经变成了一个营销术语。

(不符合ACID标准的系统有时被称为BASE,它代表基本可用性(Basically Available)软状态(Soft State)最终一致性(Eventual consistency)【9】,这比ACID的定义更加模糊,似乎BASE的唯一合理的定义是“不是ACID”,即它几乎可以代表任何你想要的东西。)

让我们深入了解原子性,一致性,隔离性和持久性的定义,这可以让我们提炼出事务的思想。

原子性(Atomicity)

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

相比之下,ACID的原子性并是关于并发(concurrent)的。它并不是在描述如果几个进程试图同时访问相同的数据会发生什么情况,这种情况包含在缩写I 中,即隔离性(Isolation)

ACID的原子性描述了,当客户想进行多次写入,但在一些写操作处理完之后出现故障的情况。例如进程崩溃,网络连接中断,磁盘变满或者某种完整性约束被违反。如果这些写操作被分组到一个原子事务中,并且该事务由于错误而不能完成(提交),则该事务将被中止,并且数据库必须丢弃或撤消该事务中迄今为止所做的任何写入。

如果没有原子性,在多处更改进行到一半时发生错误,很难知道哪些更改已经生效,哪些没有生效。该应用程序可以再试一次,但冒着进行两次相同变更的风险,可能会导致数据重复或错误的数据。原子性简化了这个问题:如果事务被中止(abort),应用程序可以确定它没有改变任何东西,所以可以安全地重试。

ACID原子性的定义特征是:能够在错误时中止事务,丢弃该事务进行的所有写入变更的能力。 或许 可中止性(abortability) 是更好的术语,但本书将继续使用原子性,因为这是惯用词。

一致性(Consistency)

一致性这个词重载的很厉害:

很不幸,这一个词就至少有四种不同的含义。

ACID一致性的概念是,对数据的一组特定陈述必须始终成立。即不变量(invariants)。例如,在会计系统中,所有账户整体上必须借贷相抵。如果一个事务开始于一个满足这些不变量的有效数据库,且在事务处理期间的任何写入操作都保持这种有效性,那么可以确定,不变量总是满足的。

但是,一致性的这种概念取决于应用程序对不变量的观念,应用程序负责正确定义它的事务,并保持一致性。这并不是数据库可以保证的事情:如果你写入违反不变量的脏数据,数据库也无法阻止你。 (一些特定类型的不变量可以由数据库检查,例如外键约束或唯一约束,但是一般来说,是应用程序来定义什么样的数据是有效的,什么样是无效的。—— 数据库只管存储。)

原子性,隔离性和持久性是数据库的属性,而一致性(在ACID意义上)是应用程序的属性。应用可能依赖数据库的原子性和隔离属性来实现一致性,但这并不仅取决于数据库。因此,字母C不属于ACID[^i]。

[^i]: 乔·海勒斯坦(Joe Hellerstein)指出,在论Härder与Reuter的论文中,“ACID中的C”是被“扔进去凑缩写单词的”【7】,而且那时候大家都不怎么在乎一致性。

隔离性(Isolation)

大多数数据库都会同时被多个客户端访问。如果它们各自读写数据库的不同部分,这是没有问题的,但是如果它们访问相同的数据库记录,则可能会遇到并发问题(竞争条件(race conditions))。

图7-1是这类问题的一个简单例子。假设你有两个客户端同时在数据库中增长一个计数器。(假设数据库中没有自增操作)每个客户端需要读取计数器的当前值,加 1 ,再回写新值。图7-1 中,因为发生了两次增长,计数器应该从42增至44;但由于竞态条件,实际上只增至 43 。

ACID意义上的隔离性意味着,同时执行的事务是相互隔离的:它们不能相互冒犯。传统的数据库教科书将隔离性形式化为可序列化(Serializability),这意味着每个事务可以假装它是唯一在整个数据库上运行的事务。数据库确保当事务已经提交时,结果与它们按顺序运行(一个接一个)是一样的,尽管实际上它们可能是并发运行的【10】。

事务的棘手概念 - 图1

图7-1 两个客户之间的竞争状态同时递增计数器

然而实践中很少会使用可序列化隔离,因为它有性能损失。一些流行的数据库如Oracle 11g,甚至没有实现它。在Oracle中有一个名为“可序列化”的隔离级别,但实际上它实现了一种叫做快照隔离(snapshot isolation) 的功能,这是一种比可序列化更弱的保证【8,11】。我们将在“弱隔离等级”中研究快照隔离和其他形式的隔离。

持久性(Durability)

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

在单节点数据库中,持久性通常意味着数据已被写入非易失性存储设备,如硬盘或SSD。它通常还包括预写日志或类似的文件(参阅“让B树更可靠”),以便在磁盘上的数据结构损坏时进行恢复。在带复制的数据库中,持久性可能意味着数据已成功复制到一些节点。为了提供持久性保证,数据库必须等到这些写入或复制完成后,才能报告事务成功提交。

如“可靠性”一节所述,完美的持久性是不存在的 :如果所有硬盘和所有备份同时被销毁,那显然没有任何数据库能救得了你。

复制和持久性

在历史上,持久性意味着写入归档磁带。后来它被理解为写入硬盘或SSD。最近它已经适应了“复制(replication)”的新内涵。哪种实现更好一些?

真相是,没有什么是完美的:

  • 如果你写入磁盘然后机器宕机,即使数据没有丢失,在修复机器或将磁盘转移到其他机器之前,也是无法访问的。这种情况下,复制系统可以保持可用性。
  • 一个相关性故障(停电,或一个特定输入导致所有节点崩溃的Bug)可能会一次性摧毁所有副本(参阅「可靠性」),任何仅存储在内存中的数据都会丢失,故内存数据库仍然要和磁盘写入打交道。
  • 在异步复制系统中,当主库不可用时,最近的写入操作可能会丢失(参阅「处理节点宕机」)。
  • 当电源突然断电时,特别是固态硬盘,有证据显示有时会违反应有的保证:甚至fsync也不能保证正常工作【12】。硬盘固件可能有错误,就像任何其他类型的软件一样【13,14】。
  • 存储引擎和文件系统之间的微妙交互可能会导致难以追踪的错误,并可能导致磁盘上的文件在崩溃后被损坏【15,16】。
  • 磁盘上的数据可能会在没有检测到的情况下逐渐损坏【17】。如果数据已损坏一段时间,副本和最近的备份也可能损坏。这种情况下,需要尝试从历史备份中恢复数据。
  • 一项关于固态硬盘的研究发现,在运行的前四年中,30%到80%的硬盘会产生至少一个坏块【18】。相比固态硬盘,磁盘的坏道率较低,但完全失效的概率更高。
  • 如果SSD断电,可能会在几周内开始丢失数据,具体取决于温度【19】。

在实践中,没有一种技术可以提供绝对保证。只有各种降低风险的技术,包括写入磁盘,复制到远程机器和备份——它们可以且应该一起使用。与往常一样,最好抱着怀疑的态度接受任何理论上的“保证”

单对象和多对象操作

回顾一下,在ACID中,原子性和隔离性描述了客户端在同一事务中执行多次写入时,数据库应该做的事情:

原子性

如果在一系列写操作的中途发生错误,则应中止事务处理,并丢弃当前事务的所有写入。换句话说,数据库免去了用户对部分失败的担忧——通过提供“宁为玉碎,不为瓦全(all-or-nothing)”的保证。

隔离性

同时运行的事务不应该互相干扰。例如,如果一个事务进行多次写入,则另一个事务要么看到全部写入结果,要么什么都看不到,但不应该是一些子集。

这些定义假设你想同时修改多个对象(行,文档,记录)。通常需要多对象事务(multi-object transaction) 来保持多块数据同步。图7-2展示了一个来自电邮应用的例子。执行以下查询来显示用户未读邮件数量:

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

但如果邮件太多,你可能会觉得这个查询太慢,并决定用单独的字段存储未读邮件的数量(一种反规范化)。现在每当一个新消息写入时,必须也增长未读计数器,每当一个消息被标记为已读时,也必须减少未读计数器。

图7-2中,用户2 遇到异常情况:邮件列表里显示有未读消息,但计数器显示为零未读消息,因为计数器增长还没有发生^ii。隔离性可以避免这个问题:通过确保用户2 要么同时看到新邮件和增长后的计数器,要么都看不到。反正不会看到执行到一半的中间结果。

事务的棘手概念 - 图2

图7-2 违反隔离性:一个事务读取另一个事务的未被执行的写入(“脏读”)。

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

事务的棘手概念 - 图3

图7-3 原子性确保发生错误时,事务先前的任何写入都会被撤消,以避免状态不一致

多对象事务需要某种方式来确定哪些读写操作属于同一个事务。在关系型数据库中,通常基于客户端与数据库服务器的TCP连接:在任何特定连接上,BEGIN TRANSACTIONCOMMIT 语句之间的所有内容,被认为是同一事务的一部分.^iii

另一方面,许多非关系数据库并没有将这些操作组合在一起的方法。即使存在多对象API(例如,键值存储可能具有在一个操作中更新几个键的多重放置操作),但这并不一定意味着它具有事务语义:该命令可能在一些键上成功,在其他的键上失败,使数据库处于部分更新的状态。

单对象写入

当单个对象发生改变时,原子性和隔离也是适用的。例如,假设您正在向数据库写入一个 20 KB的 JSON文档:

  • 如果在发送第一个10 KB之后网络连接中断,数据库是否存储了不可解析的10KB JSON片段?
  • 如果在数据库正在覆盖磁盘上的前一个值的过程中电源发生故障,是否最终将新旧值拼接在一起?
  • 如果另一个客户端在写入过程中读取该文档,是否会看到部分更新的值?

这些问题非常让人头大,故存储引擎一个几乎普遍的目标是:对单节点上的单个对象(例如键值对)上提供原子性和隔离性。原子性可以通过使用日志来实现崩溃恢复(参阅“使B树可靠”),并且可以使用每个对象上的锁来实现隔离(每次只允许一个线程访问对象) )。

一些数据库也提供更复杂的原子操作,例如自增操作,这样就不再需要像 图7-1 那样的读取-修改-写入序列了。同样流行的是 比较和设置(CAS, compare-and-set) 操作,当值没有并发被其他人修改过时,才允许执行写操作。

这些单对象操作很有用,因为它们可以防止在多个客户端尝试同时写入同一个对象时丢失更新(参阅“防止丢失更新”)。但它们不是通常意义上的事务。CAS以及其他单一对象操作被称为“轻量级事务”,甚至出于营销目的被称为“ACID”【20,21,22】,但是这个术语是误导性的。事务通常被理解为,将多个对象上的多个操作合并为一个执行单元的机制。[^iv]

[^iv]: 严格地说,原子自增(atomic increment) 这个术语在多线程编程的意义上使用了原子这个词。 在ACID的情况下,它实际上应该被称为 孤立(isolated) 的或可序列化(serializable) 的增量。 但这就太吹毛求疵了。

多对象事务的需求

许多分布式数据存储已经放弃了多对象事务,因为多对象事务很难跨分区实现,而且在需要高可用性或高性能的情况下,它们可能会碍事。但说到底,在分布式数据库中实现事务,并没有什么根本性的障碍。第9章 将讨论分布式事务的实现。

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

有一些场景中,单对象插入,更新和删除是足够的。但是许多其他场景需要协调写入几个不同的对象:

  • 在关系数据模型中,一个表中的行通常具有对另一个表中的行的外键引用。 (类似的是,在一个图数据模型中,一个顶点有着到其他顶点的边)。多对象事务使你确信这些引用始终有效:当插入几个相互引用的记录时,外键必须是正确的,最新的,不然数据就没有意义。
  • 在文档数据模型中,需要一起更新的字段通常在同一个文档中,这被视为单个对象——更新单个文档时不需要多对象事务。但是,缺乏连接功能的文档数据库会鼓励非规范化(参阅“关系型数据库与文档数据库在今日的对比”)。当需要更新非规范化的信息时,如 图7-2 所示,需要一次更新多个文档。事务在这种情况下非常有用,可以防止非规范化的数据不同步。
  • 在具有二级索引的数据库中(除了纯粹的键值存储以外几乎都有),每次更改值时都需要更新索引。从事务角度来看,这些索引是不同的数据库对象:例如,如果没有事务隔离性,记录可能出现在一个索引中,但没有出现在另一个索引中,因为第二个索引的更新还没有发生。

这些应用仍然可以在没有事务的情况下实现。然而,没有原子性,错误处理就要复杂得多,缺乏隔离性,就会导致并发问题。我们将在“弱隔离级别”中讨论这些问题,并在第12章中探讨其他方法。

处理错误和中止

事务的一个关键特性是,如果发生错误,它可以中止并安全地重试。 ACID数据库基于这样的哲学:如果数据库有违反其原子性,隔离性或持久性的危险,则宁愿完全放弃事务,而不是留下半成品。

然而并不是所有的系统都遵循这个哲学。特别是具有无主复制的数据存储,主要是在“尽力而为”的基础上进行工作。可以概括为“数据库将做尽可能多的事,运行遇到错误时,它不会撤消它已经完成的事情“ ——所以,从错误中恢复是应用程序的责任。

错误发生不可避免,但许多软件开发人员倾向于只考虑乐观情况,而不是错误处理的复杂性。例如,像Rails的ActiveRecord和Django这样的对象关系映射(ORM, object-relation Mapping) 框架不会重试中断的事务—— 这个错误通常会导致一个从堆栈向上传播的异常,所以任何用户输入都会被丢弃,用户拿到一个错误信息。这实在是太耻辱了,因为中止的重点就是允许安全的重试。

尽管重试一个中止的事务是一个简单而有效的错误处理机制,但它并不完美:

  • 如果事务实际上成功了,但是在服务器试图向客户端确认提交成功时网络发生故障(所以客户端认为提交失败了),那么重试事务会导致事务被执行两次——除非你有一个额外的应用级除重机制。
  • 如果错误是由于负载过大造成的,则重试事务将使问题变得更糟,而不是更好。为了避免这种正反馈循环,可以限制重试次数,使用指数退避算法,并单独处理与过载相关的错误(如果允许)。
  • 仅在临时性错误(例如,由于死锁,异常情况,临时性网络中断和故障切换)后才值得重试。在发生永久性错误(例如,违反约束)之后重试是毫无意义的。
  • 如果事务在数据库之外也有副作用,即使事务被中止,也可能发生这些副作用。例如,如果你正在发送电子邮件,那你肯定不希望每次重试事务时都重新发送电子邮件。如果你想确保几个不同的系统一起提交或放弃,二阶段提交(2PC, two-phase commit) 可以提供帮助(“原子提交和两阶段提交(2PC)”中将讨论这个问题)。
  • 如果客户端进程在重试中失效,任何试图写入数据库的数据都将丢失。