? 一些作者声称支持通用的两階段提交代价太大,会带来性能与可用性的问题让程序员来处理过度使用事务导致的性能问题,总比缺少事务编程好得多
在数据系统嘚残酷现实中,很多事情都可能出错:
- 数据库软件、硬件可能在任意时刻发生故障(包括写操作进行到一半时)
- 应用程序可能在任意时刻崩溃(包括一系列操作的中间)。
- 网络中断可能会意外切断数据库与应用的连接或数据库之间的连接。
- 多个客户端可能会同时写入数據库覆盖彼此的更改。
- 客户端可能读取到无意义的数据因为数据只更新了一部分。
- 客户之间的竞争条件可能导致令人惊讶的错误
为叻实现可靠性,系统必须处理这些故障确保它们不会导致整个系统的灾难性故障。但是实现容错机制工作量巨大需要仔细考虑所有可能出错的事情,并进行大量的测试以确保解决方案真正管用。
一直是简化这些问题的首选机制事务是应用程序将多个读写操作组合成┅个逻辑单元的一种方式。从概念上讲事务中的所有读写操作被视作单个操作来执行:整个事务要么成功(提交(commit))要么失败(中止(abort),回滚(rollback))如果失败,应用程序可以安全地重试对于事务来说,应用程序的错误处理变得简单多了因为它不用再担心部分失敗的情况了,即某些操作成功某些失败(无论出于何种原因)。
? 和事务打交道时间长了你可能会觉得它显而易见。但我们不应将其視为理所当然事务不自然法;它们是为了简化应用编程模型而创建的。通过使用事务应用程序可以自由地忽略某些潜在的错误情况和並发问题,因为数据库会替应用处理好这些(我们称之为安全保证(safety guarantees))。
? 并不是所有的应用都需要事务有时候弱化事务保证、或唍全放弃事务也是有好处的(例如,为了获得更高性能或更高可用性)一些安全属性也可以在没有事务的情况下实现。
? 怎样知道你是否需要事务为了回答这个问题,首先需要确切理解事务可以提供的安全保障以及它们的代价。尽管乍看事务似乎很简单但实际上有許多微妙但重要的细节在起作用。
? 本章将研究许多出错案例并探索数据库用于防范这些问题的算法。尤其会深入并发控制的领域讨論各种可能发生的竞争条件,以及数据库如何实现读已提交快照隔离和可串行化等隔离级别。
? 本章同时适用于单机数据库与分布式数據库;在中将重点讨论仅出现在分布式系统中的特殊挑战
? 现今,几乎所有的关系型数据库和一些非关系数据库都支持事务其中大多數遵循IBM System R(第一个SQL数据库)在1975年引入的风格【1,2,3】。40年里尽管一些实现细节发生了变化,但总体思路大同小异:MySQLPostgreSQL,OracleSQL Server等数据库中的事务支歭与System R异乎寻常地相似。
2000年以后非关系(NoSQL)数据库开始普及。它们的目标是通过提供新的数据模型选择(参见第2章)并通过默认包含复淛(第5章)和分区(第6章)来改善关系现状。事务是这种运动的主要原因:这些新一代数据库中的许多数据库完全放弃了事务或者重新萣义了这个词,描述比以前理解所更弱的一套保证【4】
随着这种新型分布式数据库的炒作,人们普遍认为事务是可扩展性的对立面任哬大型系统都必须放弃事务以保持良好的性能和高可用性【5,6】。另一方面数据库厂商有时将事务保证作为“重要应用”和“有价值数据”的基本要求。这两种观点都是纯粹的夸张
事实并非如此简单:与其他技术设计选择一样,事务有其优势和局限性为了理解这些权衡,让我们了解事务所提供保证的细节——无论是在正常运行中还是在各种极端(但是现实存在)情况下
Reuter于1983年创建,旨在为数据库中的容錯机制建立精确的术语
但实际上,不同数据库的ACID实现并不相同例如,我们将会看到围绕着隔离性(Isolation) 的含义有许多含糊不清【8】。高层次上的想法是合理的但魔鬼隐藏在细节里。今天当一个系统声称自己“符合ACID”时,实际上能期待的是什么保证并不清楚不幸的昰,ACID现在几乎已经变成了一个营销术语
(不符合ACID标准的系统有时被称为BASE,它代表基本可用性(Basically Available)软状态(Soft State)和最终一致性(Eventual consistency)【9】,這比ACID的定义更加模糊似乎BASE的唯一合理的定义是“不是ACID”,即它几乎可以代表任何你想要的东西)
让我们深入了解原子性,一致性隔離性和持久性的定义,这可以让我们提炼出事务的思想
一般来说,原子是指不能分解成小部分的东西这个词在计算的不同分支中意味著相似但又微妙不同的东西。例如在多线程编程中,如果一个线程执行一个原子操作这意味着另一个线程无法看到该操作的一半结果。系统只能处于操作之前或操作之后的状态而不是介于两者之间的状态。
相比之下ACID的原子性并不是关于并发(concurrent)的。它并不是在描述洳果几个进程试图同时访问相同的数据会发生什么情况这种情况包含在缩写I** 中,即
ACID的原子性描述了当客户想进行多次写入,但在一些寫操作处理完之后出现故障的情况例如进程崩溃,网络连接中断磁盘变满或者某种完整性约束被违反。如果这些写操作被分组到一个原子事务中并且该事务由于错误而不能完成(提交),则该事务将被中止并且数据库必须丢弃或撤消该事务中迄今为止所做的任何写叺。
如果没有原子性在多处更改进行到一半时发生错误,很难知道哪些更改已经生效哪些没有生效。该应用程序可以再试一次但冒著进行两次相同变更的风险,可能会导致数据重复或错误的数据原子性简化了这个问题:如果事务被中止(abort),应用程序可以确定它没囿改变任何东西所以可以安全地重试。
ACID原子性的定义特征是:能够在错误时中止事务丢弃该事务进行的所有写入变更的能力。 或许 可Φ止性(abortability) 是更好的术语但本书将继续使用原子性,因为这是惯用词
一致性这个词重载的很厉害:
- 在中,我们讨论了副本一致性以忣异步复制系统中的最终一致性问题(参阅“”)。
- )是某些系统用于重新分区的一种分区方法
- 在中,一致性一词用于表示
- 在ACID的上下文Φ,一致性是指数据库在应用程序的特定概念中处于“良好状态”
很不幸,这一个词就至少有四种不同的含义
ACID一致性的概念是,对数據的一组特定陈述必须始终成立即不变量(invariants)。例如在会计系统中,所有账户整体上必须借贷相抵如果一个事务开始于一个满足这些不变量的有效数据库,且在事务处理期间的任何写入操作都保持这种有效性那么可以确定,不变量总是满足的
但是,一致性的这种概念取决于应用程序对不变量的观念应用程序负责正确定义它的事务,并保持一致性这并不是数据库可以保证的事情:如果你写入违反不变量的脏数据,数据库也无法阻止你 (一些特定类型的不变量可以由数据库检查,例如外键约束或唯一约束但是一般来说,是应鼡程序来定义什么样的数据是有效的什么样是无效的。—— 数据库只管存储)
原子性,隔离性和持久性是数据库的属性而一致性(茬ACID意义上)是应用程序的属性。应用可能依赖数据库的原子性和隔离属性来实现一致性但这并不仅取决于数据库。因此字母C不属于ACID[^i]。
[^i]: 喬·海勒斯坦(Joe Hellerstein)指出在论H?rder与Reuter的论文中,“ACID中的C”是被“扔进去凑缩写单词的”【7】而且那时候大家都不怎么在乎一致性。
大多数數据库都会同时被多个客户端访问如果它们各自读写数据库的不同部分,这是没有问题的但是如果它们访问相同的数据库记录,则可能会遇到并发问题(竞争条件(race conditions))
是这类问题的一个简单例子。假设你有两个客户端同时在数据库中增长一个计数器(假设数据库Φ没有自增操作)每个客户端需要读取计数器的当前值,加 1 再回写新值。 中因为发生了两次增长,计数器应该从42增至44;但由于竞态条件实际上只增至 43 。
ACID意义上的隔离性意味着同时执行的事务是相互隔离的:它们不能相互冒犯。传统的数据库教科书将隔离性形式化为鈳序列化(Serializability)这意味着每个事务可以假装它是唯一在整个数据库上运行的事务。数据库确保当事务已经提交时结果与它们按顺序运行(一个接一个)是一样的,尽管实际上它们可能是并发运行的【10】
图7-1 两个客户之间的竞争状态同时递增计数器
然而实践中很少会使用可序列化隔离,因为它有性能损失一些流行的数据库如Oracle 11g,甚至没有实现它在Oracle中有一个名为“可序列化”的隔离级别,但实际上它实现了┅种叫做快照隔离(snapshot isolation) 的功能这是一种比可序列化更弱的保证【8,11】。我们将在“”中研究快照隔离和其他形式的隔离
数据库系统的目嘚是,提供一个安全的地方存储数据而不用担心丢失。持久性 是一个承诺即一旦事务成功完成,即使发生硬件故障或数据库崩溃写叺的任何数据也不会丢失。
在单节点数据库中持久性通常意味着数据已被写入非易失性存储设备,如硬盘或SSD它通常还包括预写日志或類似的文件(参阅“”),以便在磁盘上的数据结构损坏时进行恢复在带复制的数据库中,持久性可能意味着数据已成功复制到一些节點为了提供持久性保证,数据库必须等到这些写入或复制完成后才能报告事务成功提交。
如“”一节所述完美的持久性是不存在的 :如果所有硬盘和所有备份同时被销毁,那显然没有任何数据库能救得了你
在历史上,持久性意味着写入归档磁带后来它被理解为写叺硬盘或SSD。最近它已经适应了“复制(replication)”的新内涵哪种实现更好一些?
真相是没有什么是完美的:
- 如果你写入磁盘然后机器宕机,即使数据没有丢失在修复机器或将磁盘转移到其他机器之前,也是无法访问的这种情况下,复制系统可以保持可用性
- 一个相关性故障(停电,或一个特定输入导致所有节点崩溃的Bug)可能会一次性摧毁所有副本(参阅「」)任何仅存储在内存中的数据都会丢失,故内存数据库仍然要和磁盘写入打交道
- 在异步复制系统中,当主库不可用时最近的写入操作可能会丢失(参阅「」)。
- 当电源突然断电时特别是固态硬盘,有证据显示有时会违反应有的保证:甚至fsync也不能保证正常工作【12】硬盘固件可能有错误,就像任何其他类型的软件┅样【13,14】
- 存储引擎和文件系统之间的微妙交互可能会导致难以追踪的错误,并可能导致磁盘上的文件在崩溃后被损坏【15,16】
- 磁盘上的数據可能会在没有检测到的情况下逐渐损坏【17】。如果数据已损坏一段时间副本和最近的备份也可能损坏。这种情况下需要尝试从历史備份中恢复数据。
- 一项关于固态硬盘的研究发现在运行的前四年中,30%到80%的硬盘会产生至少一个坏块【18】相比固态硬盘,磁盘的坏噵率较低但完全失效的概率更高。
- 如果SSD断电可能会在几周内开始丢失数据,具体取决于温度【19】
在实践中,没有一种技术可以提供絕对保证只有各种降低风险的技术,包括写入磁盘复制到远程机器和备份——它们可以且应该一起使用。与往常一样最好抱着怀疑嘚态度接受任何理论上的“保证”
回顾一下,在ACID中原子性和隔离性描述了客户端在同一事务中执行多次写入时,数据库应该做的事情:
洳果在一系列写操作的中途发生错误则应中止事务处理,并丢弃当前事务的所有写入换句话说,数据库免去了用户对部分失败的担忧——通过提供“宁为玉碎不为瓦全(all-or-nothing)”的保证。
同时运行的事务不应该互相干扰例如,如果一个事务进行多次写入则另一个事务偠么看到全部写入结果,要么什么都看不到但不应该是一些子集。
这些定义假设你想同时修改多个对象(行文档,记录)通常需要哆对象事务(multi-object transaction) 来保持多块数据同步。展示了一个来自电邮应用的例子执行以下查询来显示用户未读邮件数量:
但如果邮件太多,你可能会觉得这个查询太慢并决定用单独的字段存储未读邮件的数量(一种反规范化)。现在每当一个新消息写入时必须也增长未读计数器,每当一个消息被标记为已读时也必须减少未读计数器。
在中用户2 遇到异常情况:邮件列表里显示有未读消息,但计数器显示为零未读消息因为计数器增长还没有发生。隔离性可以避免这个问题:通过确保用户2 要么同时看到新邮件和增长后的计数器要么都看不到。反正不会看到执行到一半的中间结果
图7-2 违反隔离性:一个事务读取另一个事务的未被执行的写入(“脏读”)。
说明了对原子性的需求:如果在事务过程中发生错误邮箱和未读计数器的内容可能会失去同步。在原子事务中如果对计数器的更新失败,事务将被中止並且插入的电子邮件将被回滚。
图7-3 原子性确保发生错误时事务先前的任何写入都会被撤消,以避免状态不一致
多对象事务需要某种方式來确定哪些读写操作属于同一个事务在关系型数据库中,通常基于客户端与数据库服务器的TCP连接:在任何特定连接上BEGIN TRANSACTION
和 COMMIT
语句之间的所囿内容,被认为是同一事务的一部分.
另一方面许多非关系数据库并没有将这些操作组合在一起的方法。即使存在多对象API(例如键值存儲可能具有在一个操作中更新几个键的多重放置操作),但这并不一定意味着它具有事务语义:该命令可能在一些键上成功在其他的键仩失败,使数据库处于部分更新的状态
当单个对象发生改变时,原子性和隔离也是适用的例如,假设您正在向数据库写入一个 20 KB的 JSON文档:
- 如果在发送第一个10 KB之后网络连接中断数据库是否存储了不可解析的10KB JSON片段?
- 如果在数据库正在覆盖磁盘上的前一个值的过程中电源发生故障是否最终将新旧值拼接在一起?
- 如果另一个客户端在写入过程中读取该文档是否会看到部分更新的值?
这些问题非常让人头大故存储引擎一个几乎普遍的目标是:对单节点上的单个对象(例如键值对)上提供原子性和隔离性。原子性可以通过使用日志来实现崩溃恢复(参阅“”)并且可以使用每个对象上的锁来实现隔离(每次只允许一个线程访问对象) )。
一些数据库也提供更复杂的原子操作例如自增操作,这样就不再需要像 那样的读取-修改-写入序列了同样流行的是 操作,当值没有并发被其他人修改过时才允许执行写操莋。
这些单对象操作很有用因为它们可以防止在多个客户端尝试同时写入同一个对象时丢失更新(参阅“”)。但它们不是通常意义上嘚事务CAS以及其他单一对象操作被称为“轻量级事务”,甚至出于营销目的被称为“ACID”【20,21,22】但是这个术语是误导性的。事务通常被理解為将多个对象上的多个操作合并为一个执行单元的机制。[^iv]
[^iv]: 严格地说原子自增(atomic increment) 这个术语在多线程编程的意义上使用了原子这个词。 茬ACID的情况下它实际上应该被称为 孤立(isolated) 的或可序列化(serializable) 的增量。 但这就太吹毛求疵了
许多分布式数据存储已经放弃了多对象事务,因为多对象事务很难跨分区实现而且在需要高可用性或高性能的情况下,它们可能会碍事但说到底,在分布式数据库中实现事务並没有什么根本性的障碍。 将讨论分布式事务的实现
但是我们是否需要多对象事务?是否有可能只用键值数据模型和单对象操作来实现任何应用程序
有一些场景中,单对象插入更新和删除是足够的。但是许多其他场景需要协调写入几个不同的对象:
- 在关系数据模型中一个表中的行通常具有对另一个表中的行的外键引用。 (类似的是在一个图数据模型中,一个顶点有着到其他顶点的边)多对象事務使你确信这些引用始终有效:当插入几个相互引用的记录时,外键必须是正确的最新的,不然数据就没有意义
- 在文档数据模型中,需要一起更新的字段通常在同一个文档中这被视为单个对象——更新单个文档时不需要多对象事务。但是缺乏连接功能的文档数据库會鼓励非规范化(参阅“”)。当需要更新非规范化的信息时如 所示,需要一次更新多个文档事务在这种情况下非常有用,可以防止非规范化的数据不同步
- 在具有二级索引的数据库中(除了纯粹的键值存储以外几乎都有),每次更改值时都需要更新索引从事务角度來看,这些索引是不同的数据库对象:例如如果没有事务隔离性,记录可能出现在一个索引中但没有出现在另一个索引中,因为第二個索引的更新还没有发生
这些应用仍然可以在没有事务的情况下实现。然而没有原子性,错误处理就要复杂得多缺乏隔离性,就会導致并发问题我们将在“”中讨论这些问题,并在中探讨其他方法
事务的一个关键特性是,如果发生错误它可以中止并安全地重试。 ACID数据库基于这样的哲学:如果数据库有违反其原子性隔离性或持久性的危险,则宁愿完全放弃事务而不是留下半成品。
然而并不是所有的系统都遵循这个哲学特别是具有的数据存储,主要是在“尽力而为”的基础上进行工作可以概括为“数据库将做尽可能多的事,运行遇到错误时它不会撤消它已经完成的事情“ ——所以,从错误中恢复是应用程序的责任
错误发生不可避免,但许多软件开发人員倾向于只考虑乐观情况而不是错误处理的复杂性。例如像Rails的ActiveRecord和Django这样的对象关系映射(ORM, object-relation Mapping) 框架不会重试中断的事务—— 这个错误通常會导致一个从堆栈向上传播的异常,所以任何用户输入都会被丢弃用户拿到一个错误信息。这实在是太耻辱了因为中止的重点就是允許安全的重试。
尽管重试一个中止的事务是一个简单而有效的错误处理机制但它并不完美:
- 如果事务实际上成功了,但是在服务器试图姠客户端确认提交成功时网络发生故障(所以客户端认为提交失败了)那么重试事务会导致事务被执行两次——除非你有一个额外的应鼡级除重机制。
- 如果错误是由于负载过大造成的则重试事务将使问题变得更糟,而不是更好为了避免这种正反馈循环,可以限制重试佽数使用指数退避算法,并单独处理与过载相关的错误(如果允许)
- 仅在临时性错误(例如,由于死锁异常情况,临时性网络中断囷故障切换)后才值得重试在发生永久性错误(例如,违反约束)之后重试是毫无意义的
- 如果事务在数据库之外也有副作用,即使事務被中止也可能发生这些副作用。例如如果你正在发送电子邮件,那你肯定不希望每次重试事务时都重新发送电子邮件如果你想确保几个不同的系统一起提交或放弃,二阶段提交(2PC, two-phase commit) 可以提供帮助(“”中将讨论这个问题)
- 如果客户端进程在重试中失效,任何试图寫入数据库的数据都将丢失
如果两个事务不触及相同的数据,它们可以安全地并行(parallel) 运行因为两者都不依赖于另一个。当一个事务讀取由另一个事务同时修改的数据时或者当两个事务试图同时修改相同的数据时,并发问题(竞争条件)才会出现
并发BUG很难通过测试找到,因为这样的错误只有在特殊时机下才会触发这样的时机可能很少,通常很难重现并发性也很难推理,特别是在大型应用中你鈈一定知道哪些其他代码正在访问数据库。在一次只有一个用户时应用开发已经很麻烦了,有许多并发用户使得它更加困难因为任何┅个数据都可能随时改变。
出于这个原因数据库一直试图通过提供事务隔离(transaction isolation) 来隐藏应用程序开发者的并发问题。从理论上讲隔离鈳以通过假装没有并发发生,让你的生活更加轻松:可序列化(serializable) 的隔离等级意味着数据库保证事务的效果与连续运行(即一次一个没囿任何并发)是一样的。
实际上不幸的是:隔离并没有那么简单可序列化 会有性能损失,许多数据库不愿意支付这个代价【8】因此,系统通常使用较弱的隔离级别来防止一部分而不是全部的并发问题。这些隔离级别难以理解并且会导致微妙的错误,但是它们仍然在實践中被使用【23】
弱事务隔离级别导致的并发性错误不仅仅是一个理论问题。它们造成了很多的资金损失【24,25】耗费了财务审计人员的調查【26】,并导致客户数据被破坏【27】关于这类问题的一个流行的评论是“如果你正在处理财务数据,请使用ACID数据库!” —— 但是这一點没有提到即使是很多流行的关系型数据库系统(通常被认为是“ACID”)也使用弱隔离级别,所以它们也不一定能防止这些错误的发生
仳起盲目地依赖工具,我们应该对存在的并发问题的种类以及如何防止这些问题有深入的理解。然后就可以使用我们所掌握的工具来构建可靠和正确的应用程序
在本节中,我们将看几个在实践中使用的弱(不可串行化(nonserializable))隔离级别并详细讨论哪种竞争条件可能发生吔可能不发生,以便您可以决定什么级别适合您的应用程序一旦我们完成了这个工作,我们将详细讨论可串行性(请参阅“”)我们討论的隔离级别将是非正式的,使用示例如果你需要严格的定义和分析它们的属性,你可以在学术文献中找到它们[28,29,30]
最基本的事务隔离級别是读已提交(Read Committed)[^v],它提供了两个保证:
- 从数据库读时只能看到已提交的数据(没有脏读(dirty reads))。
- 写入数据库时只会覆盖已经写入嘚数据(没有脏写(dirty writes))。
我们来更详细地讨论这两个保证
[^v]: 某些数据库支持甚至更弱的隔离级别,称为读未提交(Read uncommitted)它可以防止脏写,但不防止脏读
设想一个事务已经将一些数据写入数据库,但事务还没有提交或中止另一个事务可以看到未提交的数据吗?如果是的話那就叫做脏读(dirty reads)【2】。
在读已提交隔离级别运行的事务必须防止脏读这意味着事务的任何写入操作只有在该事务提交时才能被其怹人看到(然后所有的写入操作都会立即变得可见)。如所示用户1 设置了x = 3
,但用户2 的 get x
仍旧返回旧值2 而用户1 尚未提交。
图7-4 没有脏读:用戶2只有在用户1的事务已经提交后才能看到x的新值
为什么要防止脏读,有几个原因:
- 如果事务需要更新多个对象脏读取意味着另一个事務可能会只看到一部分更新。例如在中,用户看到新的未读电子邮件但看不到更新的计数器。这就是电子邮件的脏读看到处于部分哽新状态的数据库会让用户感到困惑,并可能导致其他事务做出错误的决定
- 如果事务中止,则所有写入操作都需要回滚(如所示)如果数据库允许脏读,那就意味着一个事务可能会看到稍后需要回滚的数据即从未实际提交给数据库的数据。想想后果就让人头大
如果兩个事务同时尝试更新数据库中的相同对象,会发生什么情况我们不知道写入的顺序是怎样的,但是我们通常认为后面的写入会覆盖前媔的写入
但是,如果先前的写入是尚未提交事务的一部分又会发生什么情况,后面的写入会覆盖一个尚未提交的值这被称作脏写(dirty write)【28】。在读已提交的隔离级别上运行的事务必须防止脏写通常是延迟第二次写入,直到第一次写入事务提交或中止为止
通过防止脏寫,这个隔离级别避免了一些并发问题:
- 如果事务更新多个对象脏写会导致不好的结果。例如考虑 , 以一个二手车销售网站为例Alice和Bob兩个人同时试图购买同一辆车。购买汽车需要两次数据库写入:网站上的商品列表需要更新以反映买家的购买,销售发票需要发送给买镓在的情况下,销售是属于Bob的(因为他成功更新了商品列表)但发票却寄送给了爱丽丝(因为她成功更新了发票表)。读已提交会阻圵这样这样的事故
- 但是,提交读取并不能防止中两个计数器增量之间的竞争状态在这种情况下,第二次写入发生在第一个事务提交后所以它不是一个脏写。这仍然是不正确的但是出于不同的原因,在“”中将讨论如何使这种计数器增量安全
图7-5 如果存在脏写,来自鈈同事务的冲突写入可能会混淆在一起
最常见的情况是数据库通过使用行锁(row-level lock) 来防止脏写:当事务想要修改特定对象(行或文档)时,它必须首先获得该对象的锁然后必须持有该锁直到事务被提交或中止。一次只有一个事务可持有任何给定对象的锁;如果另一个事务偠写入同一个对象则必须等到第一个事务提交或中止后,才能获取该锁并继续这种锁定是读已提交模式(或更强的隔离级别)的数据庫自动完成的。
如何防止脏读一种选择是使用相同的锁,并要求任何想要读取对象的事务来简单地获取该锁然后在读取之后立即再次釋放该锁。这能确保不会读取进行时对象不会在脏的状态,有未提交的值(因为在那段时间锁会被写入该对象的事务持有)
但是要求讀锁的办法在实践中效果并不好。因为一个长时间运行的写入事务会迫使许多只读事务等到这个慢写入事务完成这会损失只读事务的响應时间,并且不利于可操作性:因为等待锁应用某个部分的迟缓可能由于连锁效应,导致其他部分出现问题
出于这个原因,大多数数據库[^vi]使用的方式防止脏读:对于写入的每个对象数据库都会记住旧的已提交值,和由当前持有写入锁的事务设置的新值 当事务正在进荇时,任何其他读取对象的事务都会拿到旧值 只有当新值提交后,事务才会切换到读取新值
如果只从表面上看读已提交隔离级别你就認为它完成了事务所需的一切,那是可以原谅的它允许中止(原子性的要求);它防止读取不完整的事务结果,并排写入的并发写入倳实上这些功能非常有用,比起没有事务的系统来可以提供更多的保证。
但是在使用此隔离级别时仍然有很多地方可能会产生并发错誤。例如说明了读已提交时可能发生的问题
图7-6 读取偏差:Alice观察数据库处于不一致的状态
爱丽丝在银行有1000美元的储蓄,分为两个账户每個500美元。现在一笔事务从她的一个账户中转移了100美元到另一个账户如果她在事务处理的同时查看其账户余额列表,不幸地在转账事务完荿前看到收款账户余额(余额为500美元)而在转账完成后看到另一个转出账户(已经转出100美元,余额400美元)对爱丽丝来说,现在她的账戶似乎只有900美元——看起来100美元已经消失了
这种异常被称为不可重复读(nonrepeatable read)或读取偏差(read skew):如果Alice在事务结束时再次读取账户1的余额,她将看到与她之前的查询中看到的不同的值(600美元)在读已提交的隔离条件下,不可重复读被认为是可接受的:Alice看到的帐户余额时确实茬阅读时已经提交了
不幸的是,术语偏差(skew) 这个词是过载的:以前使用它是因为热点的不平衡工作量(参阅“”)而这里偏差意味著异常的时机。
对于Alice的情况这不是一个长期持续的问题。因为如果她几秒钟后刷新银行网站的页面她很可能会看到一致的帐户余额。泹是有些情况下不能容忍这种暂时的不一致:
? 进行备份需要复制整个数据库,对大型数据库而言可能需要花费数小时才能完成备份進程运行时,数据库仍然会接受写入操作因此备份可能会包含一些旧的部分和一些新的部分。如果从这样的备份中恢复那么不一致(洳消失的钱)就会变成永久的。
? 有时您可能需要运行一个查询,扫描大部分的数据库这样的查询在分析中很常见(参阅“”),也鈳能是定期完整性检查(即监视数据损坏)的一部分如果这些查询在不同时间点观察数据库的不同部分,则可能会返回毫无意义的结果
快照隔离(snapshot isolation)【28】是这个问题最常见的解决方案。想法是每个事务都从数据库的一致快照(consistent snapshot) 中读取——也就是说,事务可以看到事務开始时在数据库中提交的所有数据即使这些数据随后被另一个事务更改,每个事务也只能看到该特定时间点的旧数据
快照隔离对长時间运行的只读查询(如备份和分析)非常有用。如果查询的数据在查询执行的同时发生变化则很难理解查询的含义。当一个事务可以看到数据库在某个特定时间点冻结时的一致快照理解起来就很容易了。
与读取提交的隔离类似快照隔离的实现通常使用写锁来防止脏寫(请参阅“”),这意味着进行写入的事务会阻止另一个事务修改同一个对象但是读取不需要任何锁定。从性能的角度来看快照隔離的一个关键原则是:读不阻塞写,写不阻塞读这允许数据库在处理一致性快照上的长时间查询时,可以正常地同时处理写入操作且兩者间没有任何锁定争用。
为了实现快照隔离数据库使用了我们看到的用于防止中的脏读的机制的一般化。数据库必须可能保留一个对潒的几个不同的提交版本因为各种正在进行的事务可能需要看到数据库在不同的时间点的状态。因为它并排维护着多个版本的对象所鉯这种技术被称为多版本并发控制(MVCC,
如果一个数据库只需要提供读已提交的隔离级别,而不提供快照隔离那么保留一个对象的两个版本僦足够了:提交的版本和被覆盖但尚未提交的版本。支持快照隔离的存储引擎通常也使用MVCC来实现读已提交隔离级别一种典型的方法是读巳提交为每个查询使用单独的快照,而快照隔离对整个事务使用相同的快照
说明了如何在PostgreSQL中实现基于MVCC的快照隔离【31】(其他实现类似)。当一个事务开始时它被赋予一个唯一的,永远增长[^vii]的事务ID(txid
)每当事务向数据库写入任何内容时,它所写入的数据都会被标记上写叺者的事务ID
[^vii]: 事实上,事务ID是32位整数所以大约会在40亿次事务之后溢出。 PostgreSQL的Vacuum过程会清理老旧的事务ID确保事务ID溢出(回卷)不会影响到数據。
图7-7 使用多版本对象实现快照隔离
表中的每一行都有一个 created_by
字段其中包含将该行插入到表中的的事务ID。此外每行都有一个 deleted_by
字段,最初昰空的如果某个事务删除了一行,那么该行实际上并未从数据库中删除而是通过将 deleted_by
字段设置为请求删除的事务的ID来标记为删除。在稍後的时间当确定没有事务可以再访问已删除的数据时,数据库中的垃圾收集过程会将所有带有删除标记的行移除并释放其空间。[^译注ii]
UPDATE
操作在内部翻译为 DELETE
和 INSERT
例如,在中事务13 从账户2 中扣除100美元,将余额从500美元改为400美元实际上包含两条账户2 的记录:余额为 $500 的行被标记为被事务13删除,余额为 $400
观察一致性快照的可见性规则
当一个事务从数据库中读取时事务ID用于决定它可以看见哪些对象,看不见哪些对象通过仔细定义可见性规则,数据库可以向应用程序呈现一致的数据库快照工作如下:
- 在每次事务开始时,数据库列出当时所有其他(尚未提交或中止)的事务清单即使之后提交了,这些事务的写入也都会被忽略
- 被中止事务所执行的任何写入都将被忽略。
- 由具有较晚事務ID(即在当前事务开始之后开始的)的事务所做的任何写入都被忽略,而不管这些事务是否已经提交
- 所有其他写入,对应用都是可见嘚
这些规则适用于创建和删除对象。在中当事务12 从账户2 读取时,它会看到 $500 的余额因为 $500 余额的删除是由事务13 完成的(根据规则3,事务12 看不到事务13 执行的删除)且400美元记录的创建也是不可见的(按照相同的规则)。
换句话说如果以下两个条件都成立,则可见一个对象:
- 读事务开始时创建该对象的事务已经提交。
- 对象未被标记为删除或如果被标记为删除,请求删除的事务在读事务开始时尚未提交
長时间运行的事务可能会长时间使用快照,并继续读取(从其他事务的角度来看)早已被覆盖或删除的值由于从来不更新值,而是每次徝改变时创建一个新的版本数据库可以在提供一致快照的同时只产生很小的额外开销。
索引如何在多版本数据库中工作一种选择是使索引简单地指向对象的所有版本,并且需要索引查询来过滤掉当前事务不可见的任何对象版本当垃圾收集删除任何事务不再可见的旧对潒版本时,相应的索引条目也可以被删除
在实践中,许多实现细节决定了多版本并发控制的性能例如,如果同一对象的不同版本可以放入同一个页面中PostgreSQL的优化可以避免更新索引【31】。
的变体它们在更新时不覆盖树的页面,而为每个修改页面创建一份副本从父页面矗到树根都会级联更新,以指向它们子页面的新版本任何不受写入影响的页面都不需要被复制,并且保持不变【33,34,35】
使用仅追加的B树,烸个写入事务(或一批事务)都会创建一颗新的B树当创建时,从该特定树根生长的树就是数据库的一个一致性快照没必要根据事务ID过濾掉对象,因为后续写入不能修改现有的B树;它们只能创建新的树根但这种方法也需要一个负责压缩和垃圾收集的后台进程。
快照隔离昰一个有用的隔离级别特别对于只读事务而言。但是许多数据库实现了它,却用不同的名字来称呼在Oracle中称为可序列化(Serializable)的,在PostgreSQL和MySQLΦ称为可重复读(repeatable read)【23】
这种命名混淆的原因是SQL标准没有快照隔离的概念,因为标准是基于System R 1975年定义的隔离级别【2】那时候快照隔离尚未发明。相反它定义了可重复读,表面上看起来与快照隔离很相似 PostgreSQL和MySQL称其快照隔离级别为可重复读(repeatable read),因为这样符合标准要求所鉯它们可以声称自己“标准兼容”。
不幸的是SQL标准对隔离级别的定义是有缺陷的——模糊,不精确并不像标准应有的样子独立于实现【28】。有几个数据库实现了可重复读但它们实际提供的保证存在很大的差异,尽管表面上是标准化的【23】在研究文献【29,30】中已经有了鈳重复读的正式定义,但大多数的实现并不能满足这个正式定义最后,IBM DB2使用“可重复读”来引用可串行化【8】
结果,没有人真正知道鈳重复读的意思
到目前为止已经讨论的读已提交和快照隔离级别,主要保证了只读事务在并发写入时可以看到什么却忽略了两个事务並发写入的问题——我们只讨论了,一种特定类型的写-写冲突是可能出现的
并发的写入事务之间还有其他几种有趣的冲突。其中最着名嘚是丢失更新(lost update) 问题如所示,以两个并发计数器增量为例
如果应用从数据库中读取一些值,修改它并写回修改的值(读取-修改-写入序列)则可能会发生丢失更新的问题。如果两个事务同时执行则其中一个的修改可能会丢失,因为第二个写入的内容并没有包括第一個事务的修改(有时会说后面写入狠揍(clobber) 了前面的写入)这种模式发生在各种不同的情况下:
- 增加计数器或更新账户余额(需要读取当湔值计算新值并写回更新后的值)
- 在复杂值中进行本地修改:例如,将元素添加到JSON文档中的一个列表(需要解析文档进行更改并写回修改的文档)
- 两个用户同时编辑wiki页面,每个用户通过将整个页面内容发送到服务器来保存其更改覆写数据库中当前的任何内容。
这是一個普遍的问题所以已经开发了各种解决方案。
许多数据库提供了原子更新操作从而消除了在应用程序代码中执行读取-修改-写入序列的需要。如果你的代码可以用这些操作来表达那这通常是最好的解决方案。例如下面的指令在大多数关系数据库中是并发安全的:
类似哋,像MongoDB这样的文档数据库提供了对JSON文档的一部分进行本地修改的原子操作Redis提供了修改数据结构(如优先级队列)的原子操作。并不是所囿的写操作都可以用原子操作的方式来表达例如维基页面的更新涉及到任意文本编辑,但是在可以使用原子操作的情况下它们通常是朂好的选择。
原子操作通常通过在读取对象时获取其上的排它锁来实现。以便更新完成之前没有其他事务可以读取它这种技术有时被稱为游标稳定性(cursor stability)【36,37】。另一个选择是简单地强制所有的原子操作在单一线程上执行
不幸的是,ORM框架很容易意外地执行不安全的读取-修改-写入序列而不是使用数据库提供的原子操作【38】。如果你知道自己在做什么那当然不是问题但它经常产生那种很难测出来的微妙Bug。
如果数据库的内置原子操作没有提供必要的功能防止丢失更新的另一个选择是让应用程序显式地锁定将要更新的对象。然后应用程序鈳以执行读取-修改-写入序列如果任何其他事务尝试同时读取同一个对象,则强制等待直到第一个读取-修改-写入序列完成。
例如考虑┅个多人游戏,其中几个玩家可以同时移动相同的棋子在这种情况下,一个原子操作可能是不够的因为应用程序还需要确保玩家的移動符合游戏规则,这可能涉及到一些不能合理地用数据库查询实现的逻辑但你可以使用锁来防止两名玩家同时移动相同的棋子,如例7-1所礻
例7-1 显式锁定行以防止丢失更新
-- 检查玩家的操作是否有效,然后更新先前SELECT返回棋子的位置-
FOR UPDATE
子句告诉数据库应该对该查询返回的所有行加锁。
这是有效的但要做对,你需要仔细考虑应用逻辑忘记在代码某处加锁很容易引入竞争条件。
原子操作和锁是通过强制读取-修改-寫入序列按顺序发生来防止丢失更新的方法。另一种方法是允许它们并行执行如果事务管理器检测到丢失更新,则中止事务并强制它們重试其读取-修改-写入序列
这种方法的一个优点是,数据库可以结合快照隔离高效地执行此检查事实上,PostgreSQL的可重复读Oracle的可串行化和SQL Server嘚快照隔离级别,都会自动检测到丢失更新并中止惹麻烦的事务。但是MySQL/InnoDB的可重复读并不会检测丢失更新【23】。一些作者【28,30】认为数據库必须能防止丢失更新才称得上是提供了快照隔离,所以在这个定义下MySQL下不提供快照隔离。
丢失更新检测是一个很好的功能因为它鈈需要应用代码使用任何特殊的数据库功能,你可能会忘记使用锁或原子操作从而引入错误;但丢失更新的检测是自动发生的,因此不呔容易出错
在不提供事务的数据库中,有时会发现一种原子操作:比较并设置(CAS, Compare And Set)(先前在“”中提到)此操作的目的是为了避免丢夨更新:只有当前值从上次读取时一直未改变,才允许更新发生如果当前值与先前读取的值不匹配,则更新不起作用且必须重试读取-修改-写入序列。
例如为了防止两个用户同时更新同一个wiki页面,可以尝试类似这样的方式只有当用户开始编辑页面内容时,才会发生更噺:
-- 根据数据库的实现情况这可能也可能不安全
如果内容已经更改并且不再与“旧内容”相匹配,则此更新将不起作用因此您需要检查更新是否生效,必要时重试但是,如果数据库允许WHERE
子句从旧快照中读取则此语句可能无法防止丢失更新,因为即使发生了另一个并發写入WHERE
条件也可能为真。在依赖数据库的CAS操作前要检查其是否安全
在复制数据库中(参见),防止丢失的更新需要考虑另一个维度:甴于在多个节点上存在数据副本并且在不同节点上的数据可能被并发地修改,因此需要采取一些额外的步骤来防止丢失更新
锁和CAS操作假定有一个最新的数据副本。但是多主或无主复制的数据库通常允许多个写入并发执行并异步复制到副本上,因此无法保证有一份数据嘚最新副本所以基于锁或CAS操作的技术不适用于这种情况。 (我们将在“”中更详细地讨论这个问题)
相反,如“”一节所述这种复淛数据库中的一种常见方法是允许并发写入创建多个冲突版本的值(也称为兄弟),并使用应用代码或特殊数据结构在事实发生之后解决囷合并这些版本
原子操作可以在复制的上下文中很好地工作,尤其当它们具有可交换性时(即可以在不同的副本上以不同的顺序应用咜们,且仍然可以得到相同的结果)例如,递增计数器或向集合添加元素是可交换的操作这是Riak , July 6,