mirror of
https://github.com/Vonng/ddia.git
synced 2024-12-06 15:20:12 +08:00
finish chapter 7
This commit is contained in:
parent
6fd412b916
commit
e40b346cd9
4
ch12.md
4
ch12.md
@ -434,7 +434,7 @@
|
||||
|
||||
虽然传统的事务方法并没有走远,但我也相信在使应用正确而灵活地处理错误方面上,事务并不是最后的遗言。在本节中,我将提出一些在数据流架构中考量正确性的方式。
|
||||
|
||||
### 为数据库使用端到端的参数
|
||||
### 数据库端到端的争论
|
||||
|
||||
应用仅仅是使用具有相对较强安全属性的数据系统(例如可序列化的事务),并不意味着就可以保证没有数据丢失或损坏。例如,如果某个应用有个Bug,导致它写入不正确的数据,或者从数据库中删除数据,那么可序列化的事务也救不了你。
|
||||
|
||||
@ -710,7 +710,7 @@ COMMIT;
|
||||
|
||||
如果我们不能完全相信系统的每个组件都不会损坏 —— 每一个硬件都没缺陷,每一个软件都没有Bug —— 那我们至少必须定期检查数据的完整性。如果我们不检查,我们就不能发现损坏,直到无可挽回地导致对下游的破坏时,那时候再去追踪问题就要难得多,且代价也要高的多。
|
||||
|
||||
检查数据系统的完整性,最好是以端到端的方式进行(参阅“[数据库的端到端争论](#数据库的端到端争论)”):我们能在完整性检查中涵盖的系统越多,某些处理阶中出现不被察觉损坏的几率就越小。如果我们能检查整个衍生数据管道端到端的正确性,那么沿着这一路径的任何磁盘,网络,服务,以及算法的正确性检查都隐含在其中了。
|
||||
检查数据系统的完整性,最好是以端到端的方式进行(参阅“[数据库的端到端 ](#数据库的端到端争论)”):我们能在完整性检查中涵盖的系统越多,某些处理阶中出现不被察觉损坏的几率就越小。如果我们能检查整个衍生数据管道端到端的正确性,那么沿着这一路径的任何磁盘,网络,服务,以及算法的正确性检查都隐含在其中了。
|
||||
|
||||
持续的端到端完整性检查可以不断提高你对系统正确性的信心,从而使你能更快地进步【70】。与自动化测试一样,审计提高了快速发现错误的可能性,从而降低了系统变更或新存储技术可能导致损失的风险。如果你不害怕进行变更,就可以更好地充分演化一个应用,使其满足不断变化的需求。
|
||||
|
||||
|
199
ch7.md
199
ch7.md
@ -29,7 +29,7 @@
|
||||
|
||||
怎样知道你是否需要事务?为了回答这个问题,首先需要确切理解事务可以提供的安全保障,以及它们的代价。尽管乍看事务似乎很简单,但实际上有许多微妙但重要的细节在起作用。
|
||||
|
||||
本章将研究许多出错案例,并探索数据库用于防范这些问题的算法。尤其会深入**并发控制**的领域,讨论各种可能发生的竞争条件,以及数据库如何实现**读已提交**,**快照隔离**和**可串行化**等隔离级别。
|
||||
本章将研究许多出错案例,并探索数据库用于防范这些问题的算法。尤其会深入**并发控制**的领域,讨论各种可能发生的竞争条件,以及数据库如何实现**读已提交(read committed)**,**快照隔离(snapshot isolation)**和**可串行化(serializability)**等隔离级别。
|
||||
|
||||
本章同时适用于单机数据库与分布式数据库;在[第8章](ch8.md)中将重点讨论仅出现在分布式系统中的特殊挑战。
|
||||
|
||||
@ -39,15 +39,15 @@
|
||||
|
||||
现今,几乎所有的关系型数据库和一些非关系数据库都支持**事务**。其中大多数遵循IBM System R(第一个SQL数据库)在1975年引入的风格【1,2,3】。40年里,尽管一些实现细节发生了变化,但总体思路大同小异:MySQL,PostgreSQL,Oracle,SQL Server等数据库中的事务支持与System R异乎寻常地相似。
|
||||
|
||||
2000年以后,非关系(NoSQL)数据库开始普及。它们的目标是通过提供新的数据模型选择(参见第2章),并通过默认包含复制(第5章)和分区(第6章)来改善关系现状。事务是这种运动的主要原因:这些新一代数据库中的许多数据库完全放弃了事务,或者重新定义了这个词,描述比以前理解所更弱的一套保证【4】。
|
||||
2000年以后,非关系(NoSQL)数据库开始普及。它们的目标是在关系数据库的现状基础上,通过提供新的数据模型选择(参见[第2章](ch2.md))并默认包含复制(第5章)和分区(第6章)来进一步提升。事务是这次运动的主要牺牲品:这些新一代数据库中的许多数据库完全放弃了事务,或者重新定义了这个词,描述比以前所理解的更弱得多的一套保证【4】。
|
||||
|
||||
随着这种新型分布式数据库的炒作,人们普遍认为事务是可伸缩性的对立面,任何大型系统都必须放弃事务以保持良好的性能和高可用性【5,6】。另一方面,数据库厂商有时将事务保证作为“重要应用”和“有价值数据”的基本要求。这两种观点都是**纯粹的夸张**。
|
||||
|
||||
事实并非如此简单:与其他技术设计选择一样,事务有其优势和局限性。为了理解这些权衡,让我们了解事务所提供保证的细节——无论是在正常运行中还是在各种极端(但是现实存在)情况下。
|
||||
事实并非如此简单:与其他技术设计选择一样,事务有其优势和局限性。为了理解这些权衡,让我们了解事务所提供的保证的细节——无论是在正常运行中还是在各种极端(但是现实存在)的情况下。
|
||||
|
||||
### ACID的含义
|
||||
|
||||
事务所提供的安全保证,通常由众所周知的首字母缩略词ACID来描述,ACID代表**原子性(Atomicity)**,**一致性(Consistency)**,**隔离性(Isolation)**和**持久性(Durability)**。它由TheoHärder和Andreas Reuter于1983年创建,旨在为数据库中的容错机制建立精确的术语。
|
||||
事务所提供的安全保证,通常由众所周知的首字母缩略词ACID来描述,ACID代表**原子性(Atomicity)**,**一致性(Consistency)**,**隔离性(Isolation)**和**持久性(Durability)**。它由TheoHärder和Andreas Reuter于1983年提出,旨在为数据库中的容错机制建立精确的术语。
|
||||
|
||||
但实际上,不同数据库的ACID实现并不相同。例如,我们将会看到,关于**隔离性(Isolation)** 的含义就有许多含糊不清【8】。高层次上的想法很美好,但魔鬼隐藏在细节里。今天,当一个系统声称自己“符合ACID”时,实际上能期待的是什么保证并不清楚。不幸的是,ACID现在几乎已经变成了一个营销术语。
|
||||
|
||||
@ -61,7 +61,7 @@
|
||||
|
||||
相比之下,ACID的原子性并**不**是关于 **并发(concurrent)** 的。它并不是在描述如果几个进程试图同时访问相同的数据会发生什么情况,这种情况包含在缩写 ***I*** 中,即[**隔离性(Isolation)**](#隔离性(Isolation))
|
||||
|
||||
ACID的原子性而是描述了当客户想进行多次写入,但在一些写操作处理完之后出现故障的情况。例如进程崩溃,网络连接中断,磁盘变满或者某种完整性约束被违反。如果这些写操作被分组到一个原子事务中,并且该事务由于错误而不能完成(提交),则该事务将被中止,并且数据库必须丢弃或撤消该事务中迄今为止所做的任何写入。
|
||||
ACID的原子性描述了当客户想进行多次写入,但在一些写操作处理完之后出现故障的情况。例如进程崩溃,网络连接中断,磁盘变满或者某种完整性约束被违反。如果这些写操作被分组到一个原子事务中,并且该事务由于错误而不能完成(提交),则该事务将被中止,并且数据库必须丢弃或撤消该事务中迄今为止所做的任何写入。
|
||||
|
||||
如果没有原子性,在多处更改进行到一半时发生错误,很难知道哪些更改已经生效,哪些没有生效。该应用程序可以再试一次,但冒着进行两次相同变更的风险,可能会导致数据重复或错误的数据。原子性简化了这个问题:如果事务被**中止(abort)**,应用程序可以确定它没有改变任何东西,所以可以安全地重试。
|
||||
|
||||
@ -72,15 +72,15 @@ ACID原子性的定义特征是:**能够在错误时中止事务,丢弃该
|
||||
一致性这个词被赋予太多含义:
|
||||
|
||||
* 在[第5章](ch5.md)中,我们讨论了副本一致性,以及异步复制系统中的最终一致性问题(参阅“[复制延迟问题](ch5.md#复制延迟问题)”)。
|
||||
* [一致性散列(Consistency Hash)](ch6.md#一致性散列))是某些系统用于重新分区的一种分区方法。
|
||||
* 在[CAP定理](ch9.md#CAP定理)中,一致性一词用于表示[可线性化](ch9.md#线性化)。
|
||||
* [一致性哈希(Consistency Hashing)](ch6.md#一致性哈希))是某些系统用于重新分区的一种分区方法。
|
||||
* 在[CAP定理](ch9.md#CAP定理)中,一致性一词用于表示[线性一致性](ch9.md#线性一致性)。
|
||||
* 在ACID的上下文中,**一致性**是指数据库在应用程序的特定概念中处于“良好状态”。
|
||||
|
||||
很不幸,这一个词就至少有四种不同的含义。
|
||||
|
||||
ACID一致性的概念是,**对数据的一组特定约束必须始终成立**。即**不变量(invariants)**。例如,在会计系统中,所有账户整体上必须借贷相抵。如果一个事务开始于一个满足这些不变量的有效数据库,且在事务处理期间的任何写入操作都保持这种有效性,那么可以确定,不变量总是满足的。
|
||||
|
||||
但是,一致性的这种概念取决于应用程序对不变量的观念,应用程序负责正确定义它的事务,并保持一致性。这并不是数据库可以保证的事情:如果你写入违反不变量的脏数据,数据库也无法阻止你。 (一些特定类型的不变量可以由数据库检查,例如外键约束或唯一约束,但是一般来说,是应用程序来定义什么样的数据是有效的,什么样是无效的。—— 数据库只管存储。)
|
||||
但是,一致性的这种概念取决于应用程序对不变量的理解,应用程序负责正确定义它的事务,并保持一致性。这并不是数据库可以保证的事情:如果你写入违反不变量的脏数据,数据库也无法阻止你。 (一些特定类型的不变量可以由数据库检查,例如外键约束或唯一约束,但是一般来说,是应用程序来定义什么样的数据是有效的,什么样是无效的。—— 数据库只管存储。)
|
||||
|
||||
原子性,隔离性和持久性是数据库的属性,而一致性(在ACID意义上)是应用程序的属性。应用可能依赖数据库的原子性和隔离属性来实现一致性,但这并不仅取决于数据库。因此,字母C不属于ACID[^i]。
|
||||
|
||||
@ -90,15 +90,15 @@ ACID一致性的概念是,**对数据的一组特定约束必须始终成立**
|
||||
|
||||
大多数数据库都会同时被多个客户端访问。如果它们各自读写数据库的不同部分,这是没有问题的,但是如果它们访问相同的数据库记录,则可能会遇到**并发**问题(**竞争条件(race conditions)**)。
|
||||
|
||||
[图7-1](img/fig7-1.png)是这类问题的一个简单例子。假设你有两个客户端同时在数据库中增长一个计数器。(假设数据库中没有自增操作)每个客户端需要读取计数器的当前值,加 1 ,再回写新值。[图7-1](img/fig7-1.png) 中,因为发生了两次增长,计数器应该从42增至44;但由于竞态条件,实际上只增至 43 。
|
||||
[图7-1](img/fig7-1.png)是这类问题的一个简单例子。假设你有两个客户端同时在数据库中增长一个计数器。(假设数据库没有内建的自增操作)每个客户端需要读取计数器的当前值,加 1 ,再回写新值。[图7-1](img/fig7-1.png) 中,因为发生了两次增长,计数器应该从42增至44;但由于竞态条件,实际上只增至 43 。
|
||||
|
||||
ACID意义上的隔离性意味着,**同时执行的事务是相互隔离的**:它们不能相互冒犯。传统的数据库教科书将隔离性形式化为**可序列化(Serializability)**,这意味着每个事务可以假装它是唯一在整个数据库上运行的事务。数据库确保当事务已经提交时,结果与它们按顺序运行(一个接一个)是一样的,尽管实际上它们可能是并发运行的【10】。
|
||||
ACID意义上的隔离性意味着,**同时执行的事务是相互隔离的**:它们不能相互冒犯。传统的数据库教科书将隔离性形式化为**可串行化(Serializability)**,这意味着每个事务可以假装它是唯一在整个数据库上运行的事务。数据库确保当多个事务被提交时,结果与它们串行运行(一个接一个)是一样的,尽管实际上它们可能是并发运行的【10】。
|
||||
|
||||
![](img/fig7-1.png)
|
||||
|
||||
**图7-1 两个客户之间的竞争状态同时递增计数器**
|
||||
|
||||
然而实践中很少会使用可序列化隔离,因为它有性能损失。一些流行的数据库如Oracle 11g,甚至没有实现它。在Oracle中有一个名为“可序列化”的隔离级别,但实际上它实现了一种叫做**快照隔离(snapshot isolation)** 的功能,**这是一种比可序列化更弱的保证**【8,11】。我们将在“[弱隔离级别](#弱隔离级别)”中研究快照隔离和其他形式的隔离。
|
||||
然而实践中很少会使用可串行的隔离,因为它有性能损失。一些流行的数据库如Oracle 11g,甚至没有实现它。在Oracle中有一个名为“可串行的”隔离级别,但实际上它实现了一种叫做**快照隔离(snapshot isolation)** 的功能,**这是一种比可串行化更弱的保证**【8,11】。我们将在“[弱隔离级别](#弱隔离级别)”中研究快照隔离和其他形式的隔离。
|
||||
|
||||
#### 持久性(Durability)
|
||||
|
||||
@ -110,7 +110,7 @@ ACID意义上的隔离性意味着,**同时执行的事务是相互隔离的**
|
||||
|
||||
> #### 复制和持久性
|
||||
>
|
||||
> 在历史上,持久性意味着写入归档磁带。后来它被理解为写入硬盘或SSD。最近它已经适应了“复制(replication)”的新内涵。哪种实现更好一些?
|
||||
> 在历史上,持久性意味着写入归档磁带。后来它被理解为写入磁盘或SSD。再后来它又有了新的内涵即“复制(replication)”。哪种实现更好一些?
|
||||
>
|
||||
> 真相是,没有什么是完美的:
|
||||
>
|
||||
@ -123,7 +123,7 @@ ACID意义上的隔离性意味着,**同时执行的事务是相互隔离的**
|
||||
> * 一项关于固态硬盘的研究发现,在运行的前四年中,30%到80%的硬盘会产生至少一个坏块【18】。相比固态硬盘,磁盘的坏道率较低,但完全失效的概率更高。
|
||||
> * 如果SSD断电,可能会在几周内开始丢失数据,具体取决于温度【19】。
|
||||
>
|
||||
> 在实践中,没有一种技术可以提供绝对保证。只有各种降低风险的技术,包括写入磁盘,复制到远程机器和备份——它们可以且应该一起使用。与往常一样,最好抱着怀疑的态度接受任何理论上的“保证”
|
||||
> 在实践中,没有一种技术可以提供绝对保证。只有各种降低风险的技术,包括写入磁盘,复制到远程机器和备份——它们可以且应该一起使用。与往常一样,最好抱着怀疑的态度接受任何理论上的“保证”。
|
||||
|
||||
### 单对象和多对象操作
|
||||
|
||||
@ -163,23 +163,23 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true
|
||||
|
||||
[^iii]: 这并不完美。如果TCP连接中断,则事务必须中止。如果中断发生在客户端请求提交之后,但在服务器确认提交发生之前,客户端并不知道事务是否已提交。为了解决这个问题,事务管理器可以通过一个唯一事务标识符来对操作进行分组,这个标识符并未绑定到特定TCP连接。后续再“[数据库端到端的争论](ch12.md#数据库端到端的争论)”一节将回到这个主题。
|
||||
|
||||
另一方面,许多非关系数据库并没有将这些操作组合在一起的方法。即使存在多对象API(例如,键值存储可能具有在一个操作中更新几个键的数个put操作),但这并不一定意味着它具有事务语义:该命令可能在一些键上成功,在其他的键上失败,使数据库处于部分更新的状态。
|
||||
另一方面,许多非关系数据库并没有将这些操作组合在一起的方法。即使存在多对象API(例如,某键值存储可能具有在一个操作中更新几个键的multi-put操作),但这并不一定意味着它具有事务语义:该命令可能在一些键上成功,在其他的键上失败,使数据库处于部分更新的状态。
|
||||
|
||||
#### 单对象写入
|
||||
|
||||
当单个对象发生改变时,原子性和隔离也是适用的。例如,假设您正在向数据库写入一个 20 KB的 JSON文档:
|
||||
当单个对象发生改变时,原子性和隔离性也是适用的。例如,假设您正在向数据库写入一个 20 KB的 JSON文档:
|
||||
|
||||
- 如果在发送第一个10 KB之后网络连接中断,数据库是否存储了不可解析的10KB JSON片段?
|
||||
- 如果在数据库正在覆盖磁盘上的前一个值的过程中电源发生故障,是否最终将新旧值拼接在一起?
|
||||
- 如果另一个客户端在写入过程中读取该文档,是否会看到部分更新的值?
|
||||
|
||||
这些问题非常让人头大,故存储引擎一个几乎普遍的目标是:对单节点上的单个对象(例如键值对)上提供原子性和隔离性。原子性可以通过使用日志来实现崩溃恢复(参阅“[使B树可靠]()”),并且可以使用每个对象上的锁来实现隔离(每次只允许一个线程访问对象) )。
|
||||
这些问题非常让人头大,故存储引擎一个几乎普遍的目标是:对单节点上的单个对象(例如键值对)上提供原子性和隔离性。原子性可以通过使用日志来实现崩溃恢复(参阅“[让B树更可靠](ch3.md#让B树更可靠)”),并且可以使用每个对象上的锁来实现隔离(每次只允许一个线程访问对象) 。
|
||||
|
||||
一些数据库也提供更复杂的原子操作,例如自增操作,这样就不再需要像 [图7-1](img/fig7-1.png) 那样的读取-修改-写入序列了。同样流行的是 **[比较和设置(CAS, compare-and-set)](#比较并设置(CAS))** 操作,当值没有被其他并发修改过时,才允许执行写操作。
|
||||
一些数据库也提供更复杂的原子操作[^iv],例如自增操作,这样就不再需要像 [图7-1](img/fig7-1.png) 那样的读取-修改-写入序列了。同样流行的是 **[比较和设置(CAS, compare-and-set)](#比较并设置(CAS))** 操作,仅当值没有被其他并发修改过时,才允许执行写操作。
|
||||
|
||||
这些单对象操作很有用,因为它们可以防止在多个客户端尝试同时写入同一个对象时丢失更新(参阅“[防止丢失更新](#防止丢失更新)”)。但它们不是通常意义上的事务。CAS以及其他单一对象操作被称为“轻量级事务”,甚至出于营销目的被称为“ACID”【20,21,22】,但是这个术语是误导性的。事务通常被理解为,**将多个对象上的多个操作合并为一个执行单元的机制**。[^iv]
|
||||
[^iv]: 严格地说,**原子自增(atomic increment)** 这个术语在多线程编程的意义上使用了原子这个词。 在ACID的情况下,它实际上应该被称为 **隔离的(isolated)** 的或**可串行的(serializable)** 的增量。 但这就太吹毛求疵了。
|
||||
|
||||
[^iv]: 严格地说,**原子自增(atomic increment)** 这个术语在多线程编程的意义上使用了原子这个词。 在ACID的情况下,它实际上应该被称为 **孤立(isolated)** 的或**可序列化(serializable)** 的增量。 但这就太吹毛求疵了。
|
||||
这些单对象操作很有用,因为它们可以防止在多个客户端尝试同时写入同一个对象时丢失更新(参阅“[防止丢失更新](#防止丢失更新)”)。但它们不是通常意义上的事务。CAS以及其他单一对象操作被称为“轻量级事务”,甚至出于营销目的被称为“ACID”【20,21,22】,但是这个术语是误导性的。事务通常被理解为,**将多个对象上的多个操作合并为一个执行单元的机制**。
|
||||
|
||||
#### 多对象事务的需求
|
||||
|
||||
@ -189,17 +189,17 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true
|
||||
|
||||
有一些场景中,单对象插入,更新和删除是足够的。但是许多其他场景需要协调写入几个不同的对象:
|
||||
|
||||
* 在关系数据模型中,一个表中的行通常具有对另一个表中的行的外键引用。(类似的是,在一个图数据模型中,一个顶点有着到其他顶点的边)。多对象事务使你确信这些引用始终有效:当插入几个相互引用的记录时,外键必须是正确的,最新的,不然数据就没有意义。
|
||||
* 在关系数据模型中,一个表中的行通常具有对另一个表中的行的外键引用。(类似的是,在一个图数据模型中,一个顶点有着到其他顶点的边)。多对象事务使你确保这些引用始终有效:当插入几个相互引用的记录时,外键必须是正确的和最新的,不然数据就没有意义。
|
||||
* 在文档数据模型中,需要一起更新的字段通常在同一个文档中,这被视为单个对象——更新单个文档时不需要多对象事务。但是,缺乏连接功能的文档数据库会鼓励非规范化(参阅“[关系型数据库与文档数据库在今日的对比](ch2.md#关系型数据库与文档数据库在今日的对比)”)。当需要更新非规范化的信息时,如 [图7-2](img/fig7-2.png) 所示,需要一次更新多个文档。事务在这种情况下非常有用,可以防止非规范化的数据不同步。
|
||||
* 在具有二级索引的数据库中(除了纯粹的键值存储以外几乎都有),每次更改值时都需要更新索引。从事务角度来看,这些索引是不同的数据库对象:例如,如果没有事务隔离性,记录可能出现在一个索引中,但没有出现在另一个索引中,因为第二个索引的更新还没有发生。
|
||||
|
||||
这些应用仍然可以在没有事务的情况下实现。然而,**没有原子性,错误处理就要复杂得多,缺乏隔离性,就会导致并发问题**。我们将在“[弱隔离级别](#弱隔离级别)”中讨论这些问题,并在[第12章]()中探讨其他方法。
|
||||
这些应用仍然可以在没有事务的情况下实现。然而,**没有原子性,错误处理就要复杂得多,缺乏隔离性,就会导致并发问题**。我们将在“[弱隔离级别](#弱隔离级别)”中讨论这些问题,并在[第12章](ch12.md)中探讨其他方法。
|
||||
|
||||
#### 处理错误和中止
|
||||
|
||||
事务的一个关键特性是,如果发生错误,它可以中止并安全地重试。 ACID数据库基于这样的哲学:如果数据库有违反其原子性,隔离性或持久性的危险,则宁愿完全放弃事务,而不是留下半成品。
|
||||
|
||||
然而并不是所有的系统都遵循这个哲学。特别是具有[无主复制](ch6.md#无主复制)的数据存储,主要是在“尽力而为”的基础上进行工作。可以概括为“数据库将做尽可能多的事,运行遇到错误时,它不会撤消它已经完成的事情“ ——所以,从错误中恢复是应用程序的责任。
|
||||
然而并不是所有的系统都遵循这个哲学。特别是具有[无主复制](ch5.md#无主复制)的数据存储,主要是在“尽力而为”的基础上进行工作。可以概括为“数据库将做尽可能多的事,运行遇到错误时,它不会撤消它已经完成的事情“ ——所以,从错误中恢复是应用程序的责任。
|
||||
|
||||
错误发生不可避免,但许多软件开发人员倾向于只考虑乐观情况,而不是错误处理的复杂性。例如,像Rails的ActiveRecord和Django这样的**对象关系映射(ORM, object-relation Mapping)** 框架不会重试中断的事务—— 这个错误通常会导致一个从堆栈向上传播的异常,所以任何用户输入都会被丢弃,用户拿到一个错误信息。这实在是太耻辱了,因为中止的重点就是允许安全的重试。
|
||||
|
||||
@ -211,10 +211,6 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true
|
||||
- 如果事务在数据库之外也有副作用,即使事务被中止,也可能发生这些副作用。例如,如果你正在发送电子邮件,那你肯定不希望每次重试事务时都重新发送电子邮件。如果你想确保几个不同的系统一起提交或放弃,**二阶段提交(2PC, two-phase commit)** 可以提供帮助(“[原子提交和两阶段提交(2PC)](ch9.md#原子提交与二阶段提交(2PC))”中将讨论这个问题)。
|
||||
- 如果客户端进程在重试中失效,任何试图写入数据库的数据都将丢失。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## 弱隔离级别
|
||||
|
||||
如果两个事务不触及相同的数据,它们可以安全地**并行(parallel)** 运行,因为两者都不依赖于另一个。当一个事务读取由另一个事务同时修改的数据时,或者当两个事务试图同时修改相同的数据时,并发问题(竞争条件)才会出现。
|
||||
@ -223,15 +219,15 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true
|
||||
|
||||
[^译注i]: 轶事:偶然出现的瞬时错误有时称为***Heisenbug***,而确定性的问题对应地称为***Bohrbugs***
|
||||
|
||||
出于这个原因,数据库一直试图通过提供**事务隔离(transaction isolation)** 来隐藏应用程序开发者的并发问题。从理论上讲,隔离可以通过假装没有并发发生,让你的生活更加轻松:**可序列化(serializable)** 的隔离等级意味着数据库保证事务的效果如同连续运行(即一次一个,没有任何并发)。
|
||||
出于这个原因,数据库一直试图通过提供**事务隔离(transaction isolation)** 来隐藏应用程序开发者的并发问题。从理论上讲,隔离可以通过假装没有并发发生,让你的生活更加轻松:**可串行的(serializable)** 隔离等级意味着数据库保证事务的效果如同串行运行(即一次一个,没有任何并发)。
|
||||
|
||||
实际上不幸的是:隔离并没有那么简单。**可序列化** 会有性能损失,许多数据库不愿意支付这个代价【8】。因此,系统通常使用较弱的隔离级别来防止一部分,而不是全部的并发问题。这些隔离级别难以理解,并且会导致微妙的错误,但是它们仍然在实践中被使用【23】。
|
||||
实际上不幸的是:隔离并没有那么简单。**可串行的隔离**会有性能损失,许多数据库不愿意支付这个代价【8】。因此,系统通常使用较弱的隔离级别来防止一部分,而不是全部的并发问题。这些隔离级别难以理解,并且会导致微妙的错误,但是它们仍然在实践中被使用【23】。
|
||||
|
||||
弱事务隔离级别导致的并发性错误不仅仅是一个理论问题。它们造成了很多的资金损失【24,25】,耗费了财务审计人员的调查【26】,并导致客户数据被破坏【27】。关于这类问题的一个流行的评论是“如果你正在处理财务数据,请使用ACID数据库!” —— 但是这一点没有提到。即使是很多流行的关系型数据库系统(通常被认为是“ACID”)也使用弱隔离级别,所以它们也不一定能防止这些错误的发生。
|
||||
|
||||
比起盲目地依赖工具,我们应该对存在的并发问题的种类,以及如何防止这些问题有深入的理解。然后就可以使用我们所掌握的工具来构建可靠和正确的应用程序。
|
||||
|
||||
在本节中,我们将看几个在实践中使用的弱(**不可串行化(nonserializable)**)隔离级别,并详细讨论哪种竞争条件可能发生也可能不发生,以便您可以决定什么级别适合您的应用程序。一旦我们完成了这个工作,我们将详细讨论可串行性(请参阅“[可序列化](#可序列化)”)。我们讨论的隔离级别将是非正式的,使用示例。如果你需要严格的定义和分析它们的属性,你可以在学术文献中找到它们[28,29,30]。
|
||||
在本节中,我们将看几个在实践中使用的弱(**非串行的(nonserializable)**)隔离级别,并详细讨论哪种竞争条件可能发生也可能不发生,以便您可以决定什么级别适合您的应用程序。一旦我们完成了这个工作,我们将详细讨论可串行化(请参阅“[可串行化](#可串行化)”)。我们讨论的隔离级别将是非正式的,通过示例来进行。如果你需要严格的定义和分析它们的属性,你可以在学术文献中找到它们[28,29,30]。
|
||||
|
||||
### 读已提交
|
||||
|
||||
@ -248,7 +244,7 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true
|
||||
|
||||
设想一个事务已经将一些数据写入数据库,但事务还没有提交或中止。另一个事务可以看到未提交的数据吗?如果是的话,那就叫做**脏读(dirty reads)**【2】。
|
||||
|
||||
在**读已提交**隔离级别运行的事务必须防止脏读。这意味着事务的任何写入操作只有在该事务提交时才能被其他人看到(然后所有的写入操作都会立即变得可见)。如[图7-4]()所示,用户1 设置了`x = 3`,但用户2 的 `get x `仍旧返回旧值2 ,而用户1 尚未提交。
|
||||
在**读已提交**隔离级别运行的事务必须防止脏读。这意味着事务的任何写入操作只有在该事务提交时才能被其他人看到(然后所有的写入操作都会立即变得可见)。如[图7-4](img/fig7-4.png)所示,用户1 设置了`x = 3`,但用户2 的 `get x `仍旧返回旧值2 (当用户1 尚未提交时)。
|
||||
|
||||
![](img/fig7-4.png)
|
||||
|
||||
@ -267,8 +263,8 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true
|
||||
|
||||
通过防止脏写,这个隔离级别避免了一些并发问题:
|
||||
|
||||
- 如果事务更新多个对象,脏写会导致不好的结果。例如,考虑 [图7-5](img/fig7-5.png),[图7-5](img/fig7-5.png) 以一个二手车销售网站为例,Alice和Bob两个人同时试图购买同一辆车。购买汽车需要两次数据库写入:网站上的商品列表需要更新,以反映买家的购买,销售发票需要发送给买家。在[图7-5](img/fig7-5.png)的情况下,销售是属于Bob的(因为他成功更新了商品列表),但发票却寄送给了爱丽丝(因为她成功更新了发票表)。读已提交会阻止这样这样的事故。
|
||||
- 但是,提交读取并不能防止[图7-1](img/fig7-1.png)中两个计数器增量之间的竞争状态。在这种情况下,第二次写入发生在第一个事务提交后,所以它不是一个脏写。这仍然是不正确的,但是出于不同的原因,在“[防止更新丢失](#防止丢失更新)”中将讨论如何使这种计数器增量安全。
|
||||
- 如果事务更新多个对象,脏写会导致不好的结果。例如,考虑 [图7-5](img/fig7-5.png),以一个二手车销售网站为例,Alice和Bob两个人同时试图购买同一辆车。购买汽车需要两次数据库写入:网站上的商品列表需要更新,以反映买家的购买,销售发票需要发送给买家。在[图7-5](img/fig7-5.png)的情况下,销售是属于Bob的(因为他成功更新了商品列表),但发票却寄送给了爱丽丝(因为她成功更新了发票表)。读已提交会阻止这样的事故。
|
||||
- 但是,读已提交并不能防止[图7-1](img/fig7-1.png)中两个计数器增量之间的竞争状态。在这种情况下,第二次写入发生在第一个事务提交后,所以它不是一个脏写。这仍然是不正确的,但是出于不同的原因,在“[防止更新丢失](#防止丢失更新)”中将讨论如何使这种计数器增量安全。
|
||||
|
||||
![](img/fig7-5.png)
|
||||
|
||||
@ -280,7 +276,7 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true
|
||||
|
||||
最常见的情况是,数据库通过使用**行锁(row-level lock)** 来防止脏写:当事务想要修改特定对象(行或文档)时,它必须首先获得该对象的锁。然后必须持有该锁直到事务被提交或中止。一次只有一个事务可持有任何给定对象的锁;如果另一个事务要写入同一个对象,则必须等到第一个事务提交或中止后,才能获取该锁并继续。这种锁定是读已提交模式(或更强的隔离级别)的数据库自动完成的。
|
||||
|
||||
如何防止脏读?一种选择是使用相同的锁,并要求任何想要读取对象的事务来简单地获取该锁,然后在读取之后立即再次释放该锁。这能确保不会读取进行时,对象不会在脏的状态,有未提交的值(因为在那段时间锁会被写入该对象的事务持有)。
|
||||
如何防止脏读?一种选择是使用相同的锁,并要求任何想要读取对象的事务来简单地获取该锁,然后在读取之后立即再次释放该锁。这能确保在读取进行时,对象不会在脏的、有未提交的值的状态(因为在那段时间锁会被写入该对象的事务持有)。
|
||||
|
||||
但是要求读锁的办法在实践中效果并不好。因为一个长时间运行的写入事务会迫使许多只读事务等到这个慢写入事务完成。这会损失只读事务的响应时间,并且不利于可操作性:因为等待锁,应用某个部分的迟缓可能由于连锁效应,导致其他部分出现问题。
|
||||
|
||||
@ -298,7 +294,7 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true
|
||||
|
||||
**图7-6 读取偏差:Alice观察数据库处于不一致的状态**
|
||||
|
||||
爱丽丝在银行有1000美元的储蓄,分为两个账户,每个500美元。现在一笔事务从她的一个账户中转移了100美元到另一个账户。如果她在事务处理的同时查看其账户余额列表,不幸地在转账事务完成前看到收款账户余额(余额为500美元),而在转账完成后看到另一个转出账户(已经转出100美元,余额400美元)。对爱丽丝来说,现在她的账户似乎只有900美元——看起来100美元已经消失了。
|
||||
爱丽丝在银行有1000美元的储蓄,分为两个账户,每个500美元。现在一笔事务从她的一个账户转移了100美元到另一个账户。如果她在事务处理的同时查看其账户余额列表,她可能会碰巧在收款到达前看到收款账户的余额仍然是500美元,而在付款产生后看到付款账户的余额已经是400美元。对爱丽丝来说,现在她的账户似乎总共只有900美元——看起来有100美元已经凭空消失了。
|
||||
|
||||
这种异常被称为**不可重复读(nonrepeatable read)**或**读取偏差(read skew)**:如果Alice在事务结束时再次读取账户1的余额,她将看到与她之前的查询中看到的不同的值(600美元)。在读已提交的隔离条件下,**不可重复读**被认为是可接受的:Alice看到的帐户余额时确实在阅读时已经提交了。
|
||||
|
||||
@ -312,7 +308,7 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true
|
||||
|
||||
***分析查询和完整性检查***
|
||||
|
||||
有时,您可能需要运行一个查询,扫描大部分的数据库。这样的查询在分析中很常见(参阅“[事务处理或分析?](ch3.md#事务处理还是分析?)”),也可能是定期完整性检查(即监视数据损坏)的一部分。如果这些查询在不同时间点观察数据库的不同部分,则可能会返回毫无意义的结果。
|
||||
有时,您可能需要运行一个查询,扫描大部分的数据库。这样的查询在分析中很常见(参阅“[事务处理还是分析?](ch3.md#事务处理还是分析?)”),也可能是定期完整性检查(即监视数据损坏)的一部分。如果这些查询在不同时间点观察数据库的不同部分,则可能会返回毫无意义的结果。
|
||||
|
||||
**快照隔离(snapshot isolation)**【28】是这个问题最常见的解决方案。想法是,每个事务都从数据库的**一致快照(consistent snapshot)** 中读取——也就是说,事务可以看到事务开始时在数据库中提交的所有数据。即使这些数据随后被另一个事务更改,每个事务也只能看到该特定时间点的旧数据。
|
||||
|
||||
@ -324,7 +320,7 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true
|
||||
|
||||
与读取提交的隔离类似,快照隔离的实现通常使用写锁来防止脏写(请参阅“[读已提交](#读已提交)”),这意味着进行写入的事务会阻止另一个事务修改同一个对象。但是读取不需要任何锁定。从性能的角度来看,快照隔离的一个关键原则是:**读不阻塞写,写不阻塞读**。这允许数据库在处理一致性快照上的长时间查询时,可以正常地同时处理写入操作。且两者间没有任何锁定争用。
|
||||
|
||||
为了实现快照隔离,数据库使用了我们看到的用于防止[图7-4](img/fig7-4.png)中的脏读的机制的一般化。数据库必须可能保留一个对象的几个不同的提交版本,因为各种正在进行的事务可能需要看到数据库在不同的时间点的状态。因为它并排维护着多个版本的对象,所以这种技术被称为**多版本并发控制(MVCC, multi-version concurrency control)**。
|
||||
为了实现快照隔离,数据库使用了我们看到的用于防止[图7-4](img/fig7-4.png)中的脏读的机制的一般化。数据库必须可能保留一个对象的几个不同的提交版本,因为各种正在进行的事务可能需要看到数据库在不同的时间点的状态。因为它同时维护着单个对象的多个版本,所以这种技术被称为**多版本并发控制(MVCC, multi-version concurrency control)**。
|
||||
|
||||
如果一个数据库只需要提供**读已提交**的隔离级别,而不提供**快照隔离**,那么保留一个对象的两个版本就足够了:提交的版本和被覆盖但尚未提交的版本。支持快照隔离的存储引擎通常也使用MVCC来实现**读已提交**隔离级别。一种典型的方法是**读已提交**为每个查询使用单独的快照,而**快照隔离**对整个事务使用相同的快照。
|
||||
|
||||
@ -366,13 +362,13 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true
|
||||
|
||||
在实践中,许多实现细节决定了多版本并发控制的性能。例如,如果同一对象的不同版本可以放入同一个页面中,PostgreSQL的优化可以避免更新索引【31】。
|
||||
|
||||
在CouchDB,Datomic和LMDB中使用另一种方法。虽然它们也使用[B树](ch2.md#B树),但它们使用的是一种**仅追加/写时拷贝(append-only/copy-on-write)** 的变体,它们在更新时不覆盖树的页面,而为每个修改页面创建一份副本。从父页面直到树根都会级联更新,以指向它们子页面的新版本。任何不受写入影响的页面都不需要被复制,并且保持不变【33,34,35】。
|
||||
在CouchDB,Datomic和LMDB中使用另一种方法。虽然它们也使用[B树](ch3.md#B树),但它们使用的是一种**仅追加/写时拷贝(append-only/copy-on-write)** 的变体,它们在更新时不覆盖树的页面,而为每个修改页面创建一份副本。从父页面直到树根都会级联更新,以指向它们子页面的新版本。任何不受写入影响的页面都不需要被复制,并且保持不变【33,34,35】。
|
||||
|
||||
使用仅追加的B树,每个写入事务(或一批事务)都会创建一颗新的B树,当创建时,从该特定树根生长的树就是数据库的一个一致性快照。没必要根据事务ID过滤掉对象,因为后续写入不能修改现有的B树;它们只能创建新的树根。但这种方法也需要一个负责压缩和垃圾收集的后台进程。
|
||||
|
||||
#### 可重复读与命名混淆
|
||||
|
||||
快照隔离是一个有用的隔离级别,特别对于只读事务而言。但是,许多数据库实现了它,却用不同的名字来称呼。在Oracle中称为**可序列化(Serializable)**的,在PostgreSQL和MySQL中称为**可重复读(repeatable read)**【23】。
|
||||
快照隔离是一个有用的隔离级别,特别对于只读事务而言。但是,许多数据库实现了它,却用不同的名字来称呼。在Oracle中称为**可串行化(Serializable)**的,在PostgreSQL和MySQL中称为**可重复读(repeatable read)**【23】。
|
||||
|
||||
这种命名混淆的原因是SQL标准没有**快照隔离**的概念,因为标准是基于System R 1975年定义的隔离级别【2】,那时候**快照隔离**尚未发明。相反,它定义了**可重复读**,表面上看起来与快照隔离很相似。 PostgreSQL和MySQL称其**快照隔离**级别为**可重复读(repeatable read)**,因为这样符合标准要求,所以它们可以声称自己“标准兼容”。
|
||||
|
||||
@ -382,7 +378,7 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true
|
||||
|
||||
### 防止丢失更新
|
||||
|
||||
到目前为止已经讨论的**读已提交**和**快照隔离**级别,主要保证了**只读事务在并发写入时**可以看到什么。却忽略了两个事务并发写入的问题——我们只讨论了[脏写](#脏写),一种特定类型的写-写冲突是可能出现的。
|
||||
到目前为止已经讨论的**读已提交**和**快照隔离**级别,主要保证了**只读事务在并发写入时**可以看到什么。却忽略了两个事务并发写入的问题——我们只讨论了脏写(请参阅“[没有脏写](#没有脏写)”),一种特定类型的写-写冲突是可能出现的。
|
||||
|
||||
并发的写入事务之间还有其他几种有趣的冲突。其中最着名的是**丢失更新(lost update)** 问题,如[图7-1](img/fig7-1.png)所示,以两个并发计数器增量为例。
|
||||
|
||||
@ -404,7 +400,7 @@ UPDATE counters SET value = value + 1 WHERE key = 'foo';
|
||||
|
||||
类似地,像MongoDB这样的文档数据库提供了对JSON文档的一部分进行本地修改的原子操作,Redis提供了修改数据结构(如优先级队列)的原子操作。并不是所有的写操作都可以用原子操作的方式来表达,例如维基页面的更新涉及到任意文本编辑[^viii],但是在可以使用原子操作的情况下,它们通常是最好的选择。
|
||||
|
||||
[^viii]: 将文本文档的编辑表示为原子的变化流是可能的,尽管相当复杂。参阅“[自动冲突解决](ch5.md#题外话:自动冲突解决)”。
|
||||
[^viii]: 将文本文档的编辑表示为原子的变化流是可能的,尽管相当复杂。参阅“[自动冲突解决](ch5.md#自动冲突解决)”。
|
||||
|
||||
原子操作通常通过在读取对象时,获取其上的排它锁来实现。以便更新完成之前没有其他事务可以读取它。这种技术有时被称为**游标稳定性(cursor stability)**【36,37】。另一个选择是简单地强制所有的原子操作在单一线程上执行。
|
||||
|
||||
@ -443,12 +439,12 @@ COMMIT;
|
||||
|
||||
#### 比较并设置(CAS)
|
||||
|
||||
在不提供事务的数据库中,有时会发现一种原子操作:**比较并设置(CAS, Compare And Set)**(先前在“[单对象写入]()”中提到)。此操作的目的是为了避免丢失更新:只有当前值从上次读取时一直未改变,才允许更新发生。如果当前值与先前读取的值不匹配,则更新不起作用,且必须重试读取-修改-写入序列。
|
||||
在不提供事务的数据库中,有时会发现一种原子操作:**比较并设置(CAS, Compare And Set)**(先前在“[单对象写入](#单对象写入)”中提到)。此操作的目的是为了避免丢失更新:只有当前值从上次读取时一直未改变,才允许更新发生。如果当前值与先前读取的值不匹配,则更新不起作用,且必须重试读取-修改-写入序列。
|
||||
|
||||
例如,为了防止两个用户同时更新同一个wiki页面,可以尝试类似这样的方式,只有当用户开始编辑页面内容时,才会发生更新:
|
||||
|
||||
```sql
|
||||
-- 根据数据库的实现情况,这可能也可能不安全
|
||||
-- 根据数据库的实现情况,这可能安全也可能不安全
|
||||
UPDATE wiki_pages SET content = '新内容'
|
||||
WHERE id = 1234 AND content = '旧内容';
|
||||
```
|
||||
@ -459,7 +455,7 @@ UPDATE wiki_pages SET content = '新内容'
|
||||
|
||||
在复制数据库中(参见[第5章](ch5.md)),防止丢失的更新需要考虑另一个维度:由于在多个节点上存在数据副本,并且在不同节点上的数据可能被并发地修改,因此需要采取一些额外的步骤来防止丢失更新。
|
||||
|
||||
锁和CAS操作假定只有一个最新的数据副本。但是多主或无主复制的数据库通常允许多个写入并发执行,并异步复制到副本上,因此无法保证只有一个最新数据的副本。所以基于锁或CAS操作的技术不适用于这种情况。 (我们将在“[线性化](ch9.md#线性化)”中更详细地讨论这个问题。)
|
||||
锁和CAS操作假定只有一个最新的数据副本。但是多主或无主复制的数据库通常允许多个写入并发执行,并异步复制到副本上,因此无法保证只有一个最新数据的副本。所以基于锁或CAS操作的技术不适用于这种情况。 (我们将在“[线性一致性](ch9.md#线性一致性)”中更详细地讨论这个问题。)
|
||||
|
||||
相反,如“[检测并发写入](ch5.md#检测并发写入)”一节所述,这种复制数据库中的一种常见方法是允许并发写入创建多个冲突版本的值(也称为兄弟),并使用应用代码或特殊数据结构在事实发生之后解决和合并这些版本。
|
||||
|
||||
@ -467,7 +463,7 @@ UPDATE wiki_pages SET content = '新内容'
|
||||
|
||||
另一方面,最后写入胜利(LWW)的冲突解决方法很容易丢失更新,如“[最后写入胜利(丢弃并发写入)](ch5.md#最后写入胜利(丢弃并发写入))”中所述。不幸的是,LWW是许多复制数据库中的默认方案。
|
||||
|
||||
#### 写入偏差与幻读
|
||||
### 写入偏差与幻读
|
||||
|
||||
前面的章节中,我们看到了**脏写**和**丢失更新**,当不同的事务并发地尝试写入相同的对象时,会出现这两种竞争条件。为了避免数据损坏,这些竞争条件需要被阻止——既可以由数据库自动执行,也可以通过锁和原子写操作这类手动安全措施来防止。
|
||||
|
||||
@ -489,12 +485,12 @@ UPDATE wiki_pages SET content = '新内容'
|
||||
|
||||
可以将写入偏差视为丢失更新问题的一般化。如果两个事务读取相同的对象,然后更新其中一些对象(不同的事务可能更新不同的对象),则可能发生写入偏差。在多个事务更新同一个对象的特殊情况下,就会发生脏写或丢失更新(取决于时机)。
|
||||
|
||||
我们看到,有各种不同的方法来防止丢失的更新。随着写偏差,我们的选择更受限制:
|
||||
我们已经看到,有各种不同的方法来防止丢失的更新。但对于写偏差,我们的选择更受限制:
|
||||
|
||||
* 由于涉及多个对象,单对象的原子操作不起作用。
|
||||
* 不幸的是,在一些快照隔离的实现中,自动检测丢失更新对此并没有帮助。在PostgreSQL的可重复读,MySQL/InnoDB的可重复读,Oracle可序列化或SQL Server的快照隔离级别中,都不会自动检测写入偏差【23】。自动防止写入偏差需要真正的可序列化隔离(请参见“[可序列化](#可序列化)”)。
|
||||
* 不幸的是,在一些快照隔离的实现中,自动检测丢失更新对此并没有帮助。在PostgreSQL的可重复读,MySQL/InnoDB的可重复读,Oracle可串行化或SQL Server的快照隔离级别中,都不会自动检测写入偏差【23】。自动防止写入偏差需要真正的可串行化隔离(请参见“[可串行化](#可串行化)”)。
|
||||
* 某些数据库允许配置约束,然后由数据库强制执行(例如,唯一性,外键约束或特定值限制)。但是为了指定至少有一名医生必须在线,需要一个涉及多个对象的约束。大多数数据库没有内置对这种约束的支持,但是你可以使用触发器,或者物化视图来实现它们,这取决于不同的数据库【42】。
|
||||
* 如果无法使用可序列化的隔离级别,则此情况下的次优选项可能是显式锁定事务所依赖的行。在例子中,你可以写下如下的代码:
|
||||
* 如果无法使用可串行化的隔离级别,则此情况下的次优选项可能是显式锁定事务所依赖的行。在例子中,你可以写下如下的代码:
|
||||
|
||||
```sql
|
||||
BEGIN TRANSACTION;
|
||||
@ -510,7 +506,7 @@ UPDATE doctors
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
* 和以前一样,`FOR UPDATE`告诉数据库锁定返回的所有行用于更新。
|
||||
* 和以前一样,`FOR UPDATE`告诉数据库锁定返回的所有行以用于更新。
|
||||
|
||||
#### 写偏差的更多例子
|
||||
|
||||
@ -518,7 +514,7 @@ COMMIT;
|
||||
|
||||
***会议室预订系统***
|
||||
|
||||
假设你想强制执行,同一时间不能同时在两个会议室预订【43】。当有人想要预订时,首先检查是否存在相互冲突的预订(即预订时间范围重叠的同一房间),如果没有找到,则创建会议(请参见示例7-2)[^ix]。
|
||||
比如你想要规定不能在同一时间对同一个会议室进行多次的预订【43】。当有人想要预订时,首先检查是否存在相互冲突的预订(即预订时间范围重叠的同一房间),如果没有找到,则创建会议(请参见示例7-2)[^ix]。
|
||||
|
||||
[^ix]: 在PostgreSQL中,您可以使用范围类型优雅地执行此操作,但在其他数据库中并未得到广泛支持。
|
||||
|
||||
@ -539,11 +535,11 @@ INSERT INTO bookings(room_id, start_time, end_time, user_id)
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
不幸的是,快照隔离并不能防止另一个用户同时插入冲突的会议。为了确保不会遇到调度冲突,你又需要可序列化的隔离级别了。
|
||||
不幸的是,快照隔离并不能防止另一个用户同时插入冲突的会议。为了确保不会遇到调度冲突,你又需要可串行化的隔离级别了。
|
||||
|
||||
***多人游戏***
|
||||
|
||||
在[例7-1]()中,我们使用一个锁来防止丢失更新(也就是确保两个玩家不能同时移动同一个棋子)。但是锁定并不妨碍玩家将两个不同的棋子移动到棋盘上的相同位置,或者采取其他违反游戏规则的行为。按照您正在执行的规则类型,也许可以使用唯一约束,否则您很容易发生写入偏差。
|
||||
在[例7-1]()中,我们使用一个锁来防止丢失更新(也就是确保两个玩家不能同时移动同一个棋子)。但是锁定并不妨碍玩家将两个不同的棋子移动到棋盘上的相同位置,或者采取其他违反游戏规则的行为。按照您正在执行的规则类型,也许可以使用唯一约束(unique constraint),否则您很容易发生写入偏差。
|
||||
|
||||
***抢注用户名***
|
||||
|
||||
@ -563,7 +559,7 @@ COMMIT;
|
||||
|
||||
3. 如果应用决定继续操作,就执行写入(插入、更新或删除),并提交事务。
|
||||
|
||||
这个写入的效果改变了步骤2 中的先决条件。换句话说,如果在提交写入后,重复执行一次步骤1 的SELECT查询,将会得到不同的结果。因为写入改变符合搜索条件的行集(现在少了一个医生值班,那时候的会议室现在已经被预订了,棋盘上的这个位置已经被占据了,用户名已经被抢注,账户余额不够了)。
|
||||
这个写入的效果改变了步骤2 中的先决条件。换句话说,如果在提交写入后,重复执行一次步骤1 的SELECT查询,将会得到不同的结果。因为写入改变了符合搜索条件的行集(现在少了一个医生值班,那时候的会议室现在已经被预订了,棋盘上的这个位置已经被占据了,用户名已经被抢注,账户余额不够了)。
|
||||
|
||||
这些步骤可能以不同的顺序发生。例如可以首先进行写入,然后进行SELECT查询,最后根据查询结果决定是放弃还是提交。
|
||||
|
||||
@ -579,11 +575,10 @@ COMMIT;
|
||||
|
||||
现在,要创建预订的事务可以锁定(`SELECT FOR UPDATE`)表中与所需房间和时间段对应的行。在获得锁定之后,它可以检查重叠的预订并像以前一样插入新的预订。请注意,这个表并不是用来存储预订相关的信息——它完全就是一组锁,用于防止同时修改同一房间和时间范围内的预订。
|
||||
|
||||
这种方法被称为**物化冲突(materializing conflicts)**,因为它将幻读变为数据库中一组具体行上的锁冲突【11】。不幸的是,弄清楚如何物化冲突可能很难,也很容易出错,而让并发控制机制泄漏到应用数据模型是很丑陋的做法。出于这些原因,如果没有其他办法可以实现,物化冲突应被视为最后的手段。在大多数情况下。**可序列化(Serializable)** 的隔离级别是更可取的。
|
||||
这种方法被称为**物化冲突(materializing conflicts)**,因为它将幻读变为数据库中一组具体行上的锁冲突【11】。不幸的是,弄清楚如何物化冲突可能很难,也很容易出错,而让并发控制机制泄漏到应用数据模型是很丑陋的做法。出于这些原因,如果没有其他办法可以实现,物化冲突应被视为最后的手段。在大多数情况下。**可串行化(Serializable)** 的隔离级别是更可取的。
|
||||
|
||||
|
||||
|
||||
## 可序列化
|
||||
## 可串行化
|
||||
|
||||
在本章中,已经看到了几个易于出现竞争条件的事务例子。**读已提交**和**快照隔离**级别会阻止某些竞争条件,但不会阻止另一些。我们遇到了一些特别棘手的例子,**写入偏差**和**幻读**。这是一个可悲的情况:
|
||||
|
||||
@ -591,30 +586,30 @@ COMMIT;
|
||||
- 光检查应用代码很难判断在特定的隔离级别运行是否安全。 特别是在大型应用程序中,您可能并不知道并发发生的所有事情。
|
||||
- 没有检测竞争条件的好工具。原则上来说,静态分析可能会有帮助【26】,但研究中的技术还没法实际应用。并发问题的测试是很难的,因为它们通常是非确定性的 —— 只有在倒霉的时机下才会出现问题。
|
||||
|
||||
这不是一个新问题,从20世纪70年代以来就一直是这样了,当时首先引入了较弱的隔离级别【2】。一直以来,研究人员的答案都很简单:使用**可序列化(serializable)** 的隔离级别!
|
||||
这不是一个新问题,从20世纪70年代以来就一直是这样了,当时首先引入了较弱的隔离级别【2】。一直以来,研究人员的答案都很简单:使用**可串行化(serializable)** 的隔离级别!
|
||||
|
||||
**可序列化(Serializability)**隔离通常被认为是最强的隔离级别。它保证即使事务可以并行执行,最终的结果也是一样的,就好像它们没有任何并发性,连续挨个执行一样。因此数据库保证,如果事务在单独运行时正常运行,则它们在并发运行时继续保持正确 —— 换句话说,数据库可以防止**所有**可能的竞争条件。
|
||||
**可串行化(Serializability)**隔离通常被认为是最强的隔离级别。它保证即使事务可以并行执行,最终的结果也是一样的,就好像它们没有任何并发性,连续挨个执行一样。因此数据库保证,如果事务在单独运行时正常运行,则它们在并发运行时继续保持正确 —— 换句话说,数据库可以防止**所有**可能的竞争条件。
|
||||
|
||||
但如果可序列化隔离级别比弱隔离级别的烂摊子要好得多,那为什么没有人见人爱?为了回答这个问题,我们需要看看实现可序列化的选项,以及它们如何执行。目前大多数提供可序列化的数据库都使用了三种技术之一,本章的剩余部分将会介绍这些技术。
|
||||
但如果可串行化隔离级别比弱隔离级别的烂摊子要好得多,那为什么没有人见人爱?为了回答这个问题,我们需要看看实现可串行化的选项,以及它们如何执行。目前大多数提供可串行化的数据库都使用了三种技术之一,本章的剩余部分将会介绍这些技术。
|
||||
|
||||
- 字面意义上地串行顺序执行事务(参见“[真的串行执行](#真的串行执行)”)
|
||||
- **两相锁定(2PL, two-phase locking)**,几十年来唯一可行的选择。(参见“[两相锁定(2PL)](#两阶段锁定(2PL))”)
|
||||
- 乐观并发控制技术,例如**可序列化的快照隔离(serializable snapshot isolation)**(参阅“[可序列化的快照隔离(SSI)](#序列化快照隔离(SSI))”
|
||||
- **两阶段锁定(2PL, two-phase locking)**,几十年来唯一可行的选择。(参见“[两阶段锁定(2PL)](#两阶段锁定(2PL))”)
|
||||
- 乐观并发控制技术,例如**可串行化快照隔离(serializable snapshot isolation)**(参阅“[可串行化快照隔离(SSI)](#可串行化快照隔离(SSI))”
|
||||
|
||||
现在将主要在单节点数据库的背景下讨论这些技术;在[第9章](ch9.md)中,我们将研究如何将它们推广到涉及分布式系统中多个节点的事务。
|
||||
|
||||
#### 真的串行执行
|
||||
### 真的串行执行
|
||||
|
||||
避免并发问题的最简单方法就是完全不要并发:在单个线程上按顺序一次只执行一个事务。这样做就完全绕开了检测/防止事务间冲突的问题,由此产生的隔离,正是可序列化的定义。
|
||||
避免并发问题的最简单方法就是完全不要并发:在单个线程上按顺序一次只执行一个事务。这样做就完全绕开了检测/防止事务间冲突的问题,由此产生的隔离,正是可串行化的定义。
|
||||
|
||||
尽管这似乎是一个明显的主意,但数据库设计人员只是在2007年左右才决定,单线程循环执行事务是可行的【45】。如果多线程并发在过去的30年中被认为是获得良好性能的关键所在,那么究竟是什么改变致使单线程执行变为可能呢?
|
||||
|
||||
两个进展引发了这个反思:
|
||||
|
||||
- RAM足够便宜了,许多场景现在都可以将完整的活跃数据集保存在内存中。(参阅“[在内存中存储一切](ch3.md#在内存中存储一切)”)。当事务需要访问的所有数据都在内存中时,事务处理的执行速度要比等待数据从磁盘加载时快得多。
|
||||
- 数据库设计人员意识到OLTP事务通常很短,而且只进行少量的读写操作(参阅“[事务处理或分析?](ch3.md#事务处理还是分析?)”)。相比之下,长时间运行的分析查询通常是只读的,因此它们可以在串行执行循环之外的一致快照(使用快照隔离)上运行。
|
||||
- 数据库设计人员意识到OLTP事务通常很短,而且只进行少量的读写操作(参阅“[事务处理还是分析?](ch3.md#事务处理还是分析?)”)。相比之下,长时间运行的分析查询通常是只读的,因此它们可以在串行执行循环之外的一致快照(使用快照隔离)上运行。
|
||||
|
||||
串行执行事务的方法在VoltDB/H-Store,Redis和Datomic中实现【46,47,48】。设计用于单线程执行的系统有时可以比支持并发的系统更好,因为它可以避免锁的协调开销。但是其吞吐量仅限于单个CPU核的吞吐量。为了充分利用单一线程,需要与传统形式不同的结构的事务。
|
||||
串行执行事务的方法在VoltDB/H-Store,Redis和Datomic中实现【46,47,48】。设计用于单线程执行的系统有时可以比支持并发的系统更好,因为它可以避免锁的协调开销。但是其吞吐量仅限于单个CPU核的吞吐量。为了充分利用单一线程,需要与传统形式的事务不同的结构。
|
||||
|
||||
#### 在存储过程中封装事务
|
||||
|
||||
@ -622,7 +617,7 @@ COMMIT;
|
||||
|
||||
不幸的是,人类做出决定和回应的速度非常缓慢。如果数据库事务需要等待来自用户的输入,则数据库需要支持潜在的大量并发事务,其中大部分是空闲的。大多数数据库不能高效完成这项工作,因此几乎所有的OLTP应用程序都避免在事务中等待交互式的用户输入,以此来保持事务的简短。在Web上,这意味着事务在同一个HTTP请求中被提交——一个事务不会跨越多个请求。一个新的HTTP请求开始一个新的事务。
|
||||
|
||||
即使人类已经找到了关键路径,事务仍然以交互式的客户端/服务器风格执行,一次一个语句。应用程序进行查询,读取结果,可能根据第一个查询的结果进行另一个查询,依此类推。查询和结果在应用程序代码(在一台机器上运行)和数据库服务器(在另一台机器上)之间来回发送。
|
||||
即使已经将人类从关键路径中排除,事务仍然以交互式的客户端/服务器风格执行,一次一个语句。应用程序进行查询,读取结果,可能根据第一个查询的结果进行另一个查询,依此类推。查询和结果在应用程序代码(在一台机器上运行)和数据库服务器(在另一台机器上)之间来回发送。
|
||||
|
||||
在这种交互式的事务方式中,应用程序和数据库之间的网络通信耗费了大量的时间。如果不允许在数据库中进行并发处理,且一次只处理一个事务,则吞吐量将会非常糟糕,因为数据库大部分的时间都花费在等待应用程序发出当前事务的下一个查询。在这种数据库中,为了获得合理的性能,需要同时处理多个事务。
|
||||
|
||||
@ -637,7 +632,7 @@ COMMIT;
|
||||
存储过程在关系型数据库中已经存在了一段时间了,自1999年以来它们一直是SQL标准(SQL/PSM)的一部分。出于各种原因,它们的名声有点不太好:
|
||||
|
||||
- 每个数据库厂商都有自己的存储过程语言(Oracle有PL/SQL,SQL Server有T-SQL,PostgreSQL有PL/pgSQL等)。这些语言并没有跟上通用编程语言的发展,所以从今天的角度来看,它们看起来相当丑陋和陈旧,而且缺乏大多数编程语言中能找到的库的生态系统。
|
||||
- 与应用服务器相,比在数据库中运行的管理困难,调试困难,版本控制和部署起来也更为尴尬,更难测试,更难和用于监控的指标收集系统相集成。
|
||||
- 与应用服务器相比,在数据库中运行的代码管理困难,调试困难,版本控制和部署起来也更为尴尬,更难测试,更难和用于监控的指标收集系统相集成。
|
||||
- 数据库通常比应用服务器对性能敏感的多,因为单个数据库实例通常由许多应用服务器共享。数据库中一个写得不好的存储过程(例如,占用大量内存或CPU时间)会比在应用服务器中相同的代码造成更多的麻烦。
|
||||
|
||||
但是这些问题都是可以克服的。现代的存储过程实现放弃了PL/SQL,而是使用现有的通用编程语言:VoltDB使用Java或Groovy,Datomic使用Java或Clojure,而Redis使用Lua。
|
||||
@ -660,37 +655,37 @@ VoltDB还使用存储过程进行复制:但不是将事务的写入结果从
|
||||
|
||||
#### 串行执行小结
|
||||
|
||||
在特定约束条件下,真的串行执行事务,已经成为一种实现可序列化隔离等级的可行办法。
|
||||
在特定约束条件下,真的串行执行事务,已经成为一种实现可串行化隔离等级的可行办法。
|
||||
|
||||
- 每个事务都必须小而快,只要有一个缓慢的事务,就会拖慢所有事务处理。
|
||||
- 仅限于活跃数据集可以放入内存的情况。很少访问的数据可能会被移动到磁盘,但如果需要在单线程执行的事务中访问,系统就会变得非常慢[^x]。
|
||||
- 写入吞吐量必须低到能在单个CPU核上处理,如若不然,事务需要能划分至单个分区,且不需要跨分区协调。
|
||||
- 跨分区事务是可能的,但是它们的使用程度有很大的限制。
|
||||
- 跨分区事务是可能的,但是它们能被使用的程度有很大的限制。
|
||||
|
||||
[^x]: 如果事务需要访问不在内存中的数据,最好的解决方案可能是中止事务,异步地将数据提取到内存中,同时继续处理其他事务,然后在数据加载完毕时重新启动事务。这种方法被称为**反缓存(anti-caching)**,正如前面在第88页“将所有内容保存在内存”中所述。
|
||||
[^x]: 如果事务需要访问不在内存中的数据,最好的解决方案可能是中止事务,异步地将数据提取到内存中,同时继续处理其他事务,然后在数据加载完毕时重新启动事务。这种方法被称为**反缓存(anti-caching)**,正如前面在“[在内存中存储一切](ch3.md#在内存中存储一切)”中所述。
|
||||
|
||||
### 两阶段锁定(2PL)
|
||||
|
||||
大约30年来,在数据库中只有一种广泛使用的序列化算法:**两阶段锁定(2PL,two-phase locking)** [^xi]
|
||||
大约30年来,在数据库中只有一种广泛使用的串行化算法:**两阶段锁定(2PL,two-phase locking)** [^xi]
|
||||
|
||||
[^xi]: 有时也称为**严格两阶段锁定(SS2PL, strict two-phase locking)**,以便和其他2PL变体区分。
|
||||
[^xi]: 有时也称为**严格两阶段锁定(SS2PL, strong strict two-phase locking)**,以便和其他2PL变体区分。
|
||||
|
||||
> #### 2PL不是2PC
|
||||
>
|
||||
> 请注意,虽然两阶段锁定(2PL)听起来非常类似于两阶段提交(2PC),但它们是完全不同的东西。我们将在第9章讨论2PC。
|
||||
> 请注意,虽然两阶段锁定(2PL)听起来非常类似于两阶段提交(2PC),但它们是完全不同的东西。我们将在[第9章](ch9.md)讨论2PC。
|
||||
|
||||
之前我们看到锁通常用于防止脏写(参阅“[没有脏写](没有脏写)”一节):如果两个事务同时尝试写入同一个对象,则锁可确保第二个写入必须等到第一个写入完成事务(中止或提交),然后才能继续。
|
||||
之前我们看到锁通常用于防止脏写(参阅“[没有脏写](#没有脏写)”一节):如果两个事务同时尝试写入同一个对象,则锁可确保第二个写入必须等到第一个写入完成事务(中止或提交),然后才能继续。
|
||||
|
||||
两阶段锁定定类似,但使锁的要求更强。只要没有写入,就允许多个事务同时读取同一个对象。但对象只要有写入(修改或删除),就需要**独占访问(exclusive access)** 权限:
|
||||
两阶段锁定类似,但是锁的要求更强得多。只要没有写入,就允许多个事务同时读取同一个对象。但对象只要有写入(修改或删除),就需要**独占访问(exclusive access)** 权限:
|
||||
|
||||
- 如果事务A读取了一个对象,并且事务B想要写入该对象,那么B必须等到A提交或中止才能继续。 (这确保B不能在A底下意外地改变对象。)
|
||||
- 如果事务A写入了一个对象,并且事务B想要读取该对象,则B必须等到A提交或中止才能继续。 (像[图7-1](img/fig7-1.png)那样读取旧版本的对象在2PL下是不可接受的。)
|
||||
|
||||
在2PL中,写入不仅会阻塞其他写入,也会阻塞读,反之亦然。快照隔离使得**读不阻塞写,写也不阻塞读**(参阅“[实现快照隔离](#实现快照隔离)”),这是2PL和快照隔离之间的关键区别。另一方面,因为2PL提供了可序列化的性质,它可以防止早先讨论的所有竞争条件,包括丢失更新和写入偏差。
|
||||
在2PL中,写入不仅会阻塞其他写入,也会阻塞读,反之亦然。快照隔离使得**读不阻塞写,写也不阻塞读**(参阅“[实现快照隔离](#实现快照隔离)”),这是2PL和快照隔离之间的关键区别。另一方面,因为2PL提供了可串行化的性质,它可以防止早先讨论的所有竞争条件,包括丢失更新和写入偏差。
|
||||
|
||||
#### 实现两阶段锁
|
||||
|
||||
2PL用于MySQL(InnoDB)和SQL Server中的可序列化隔离级别,以及DB2中的可重复读隔离级别【23,36】。
|
||||
2PL用于MySQL(InnoDB)和SQL Server中的可串行化隔离级别,以及DB2中的可重复读隔离级别【23,36】。
|
||||
|
||||
读与写的阻塞是通过为数据库中每个对象添加锁来实现的。锁可以处于**共享模式(shared mode)**或**独占模式(exclusive mode)**。锁使用如下:
|
||||
|
||||
@ -711,11 +706,11 @@ VoltDB还使用存储过程进行复制:但不是将事务的写入结果从
|
||||
|
||||
因此,运行2PL的数据库可能具有相当不稳定的延迟,如果在工作负载中存在争用,那么可能高百分位点处的响应会非常的慢(参阅“[描述性能](ch1.md#描述性能)”)。可能只需要一个缓慢的事务,或者一个访问大量数据并获取许多锁的事务,就能把系统的其他部分拖慢,甚至迫使系统停机。当需要稳健的操作时,这种不稳定性是有问题的。
|
||||
|
||||
基于锁实现的读已提交隔离级别可能发生死锁,但在基于2PL实现的可序列化隔离级别中,它们会出现的频繁的多(取决于事务的访问模式)。这可能是一个额外的性能问题:当事务由于死锁而被中止并被重试时,它需要从头重做它的工作。如果死锁很频繁,这可能意味着巨大的浪费。
|
||||
基于锁实现的读已提交隔离级别可能发生死锁,但在基于2PL实现的可串行化隔离级别中,它们会出现的频繁的多(取决于事务的访问模式)。这可能是一个额外的性能问题:当事务由于死锁而被中止并被重试时,它需要从头重做它的工作。如果死锁很频繁,这可能意味着巨大的浪费。
|
||||
|
||||
#### 谓词锁
|
||||
|
||||
在前面关于锁的描述中,我们掩盖了一个微妙而重要的细节。在“[导致写入偏差的幻读](#导致写入偏差的幻读)”中,我们讨论了**幻读(phantoms)**的问题。即一个事务改变另一个事务的搜索查询的结果。具有可序列化隔离级别的数据库必须防止**幻读**。
|
||||
在前面关于锁的描述中,我们掩盖了一个微妙而重要的细节。在“[导致写入偏差的幻读](#导致写入偏差的幻读)”中,我们讨论了**幻读(phantoms)**的问题。即一个事务改变另一个事务的搜索查询的结果。具有可串行化隔离级别的数据库必须防止**幻读**。
|
||||
|
||||
在会议室预订的例子中,这意味着如果一个事务在某个时间窗口内搜索了一个房间的现有预订(见[例7-2]()),则另一个事务不能同时插入或更新同一时间窗口与同一房间的另一个预订 (可以同时插入其他房间的预订,或在不影响另一个预定的条件下预定同一房间的其他时间段)。
|
||||
|
||||
@ -753,14 +748,13 @@ WHERE room_id = 123 AND
|
||||
如果没有可以挂载间隙锁的索引,数据库可以退化到使用整个表上的共享锁。这对性能不利,因为它会阻止所有其他事务写入表格,但这是一个安全的回退位置。
|
||||
|
||||
|
||||
### 可串行化快照隔离(SSI)
|
||||
|
||||
### 序列化快照隔离(SSI)
|
||||
本章描绘了数据库中并发控制的黯淡画面。一方面,我们实现了性能不好(2PL)或者伸缩性不好(串行执行)的可串行化隔离级别。另一方面,我们有性能良好的弱隔离级别,但容易出现各种竞争条件(丢失更新,写入偏差,幻读等)。串行化的隔离级别和高性能是从根本上相互矛盾的吗?
|
||||
|
||||
本章描绘了数据库中并发控制的黯淡画面。一方面,我们实现了性能不好(2PL)或者伸缩性不好(串行执行)的可序列化隔离级别。另一方面,我们有性能良好的弱隔离级别,但容易出现各种竞争条件(丢失更新,写入偏差,幻读等)。序列化的隔离级别和高性能是从根本上相互矛盾的吗?
|
||||
也许不是:一个称为**可串行化快照隔离(SSI, serializable snapshot isolation)** 的算法是非常有前途的。它提供了完整的可串行化隔离级别,但与快照隔离相比只有很小的性能损失。 SSI是相当新的:它在2008年首次被描述【40】,并且是Michael Cahill的博士论文【51】的主题。
|
||||
|
||||
也许不是:一个称为**可序列化快照隔离(SSI, serializable snapshot isolation)** 的算法是非常有前途的。它提供了完整的可序列化隔离级别,但与快照隔离相比只有很小的性能损失。 SSI是相当新的:它在2008年首次被描述【40】,并且是Michael Cahill的博士论文【51】的主题。
|
||||
|
||||
今天,SSI既用于单节点数据库(PostgreSQL9.1 以后的可序列化隔离级别)和分布式数据库(FoundationDB使用类似的算法)。由于SSI与其他并发控制机制相比还很年轻,还处于在实践中证明自己表现的阶段。但它有可能因为足够快而在未来成为新的默认选项。
|
||||
今天,SSI既用于单节点数据库(PostgreSQL9.1 以后的可串行化隔离级别)和分布式数据库(FoundationDB使用类似的算法)。由于SSI与其他并发控制机制相比还很年轻,还处于在实践中证明自己表现的阶段。但它有可能因为足够快而在未来成为新的默认选项。
|
||||
|
||||
#### 悲观与乐观的并发控制
|
||||
|
||||
@ -768,21 +762,21 @@ WHERE room_id = 123 AND
|
||||
|
||||
从某种意义上说,串行执行可以称为悲观到了极致:在事务持续期间,每个事务对整个数据库(或数据库的一个分区)具有排它锁,作为对悲观的补偿,我们让每笔事务执行得非常快,所以只需要短时间持有“锁”。
|
||||
|
||||
相比之下,**序列化快照隔离**是一种**乐观(optimistic)** 的并发控制技术。在这种情况下,乐观意味着,如果存在潜在的危险也不阻止事务,而是继续执行事务,希望一切都会好起来。当一个事务想要提交时,数据库检查是否有什么不好的事情发生(即隔离是否被违反);如果是的话,事务将被中止,并且必须重试。只有可序列化的事务才被允许提交。
|
||||
相比之下,**串行化快照隔离**是一种**乐观(optimistic)** 的并发控制技术。在这种情况下,乐观意味着,如果存在潜在的危险也不阻止事务,而是继续执行事务,希望一切都会好起来。当一个事务想要提交时,数据库检查是否有什么不好的事情发生(即隔离是否被违反);如果是的话,事务将被中止,并且必须重试。只有可串行化的事务才被允许提交。
|
||||
|
||||
乐观并发控制是一个古老的想法【52】,其优点和缺点已经争论了很长时间【53】。如果存在很多**争用(contention)**(很多事务试图访问相同的对象),则表现不佳,因为这会导致很大一部分事务需要中止。如果系统已经接近最大吞吐量,来自重试事务的额外负载可能会使性能变差。
|
||||
|
||||
但是,如果有足够的备用容量,并且事务之间的争用不是太高,乐观的并发控制技术往往比悲观的要好。可交换的原子操作可以减少争用:例如,如果多个事务同时要增加一个计数器,那么应用增量的顺序(只要计数器不在同一个事务中读取)就无关紧要了,所以并发增量可以全部应用且无需冲突。
|
||||
|
||||
顾名思义,SSI基于快照隔离——也就是说,事务中的所有读取都是来自数据库的一致性快照(参见“[快照隔离和可重复读取](#快照隔离和可重复读)”)。与早期的乐观并发控制技术相比这是主要的区别。在快照隔离的基础上,SSI添加了一种算法来检测写入之间的序列化冲突,并确定要中止哪些事务。
|
||||
顾名思义,SSI基于快照隔离——也就是说,事务中的所有读取都是来自数据库的一致性快照(参见“[快照隔离和可重复读取](#快照隔离和可重复读)”)。与早期的乐观并发控制技术相比这是主要的区别。在快照隔离的基础上,SSI添加了一种算法来检测写入之间的串行化冲突,并确定要中止哪些事务。
|
||||
|
||||
#### 基于过时前提的决策
|
||||
|
||||
先前讨论了快照隔离中的写入偏差(参阅“[写入偏差和幻像](#写入偏差与幻读)”)时,我们观察到一个循环模式:事务从数据库读取一些数据,检查查询的结果,并根据它看到的结果决定采取一些操作(写入数据库)。但是,在快照隔离的情况下,原始查询的结果在事务提交时可能不再是最新的,因为数据可能在同一时间被修改。
|
||||
先前讨论了快照隔离中的写入偏差(参阅“[写入偏差与幻读](#写入偏差与幻读)”)时,我们观察到一个循环模式:事务从数据库读取一些数据,检查查询的结果,并根据它看到的结果决定采取一些操作(写入数据库)。但是,在快照隔离的情况下,原始查询的结果在事务提交时可能不再是最新的,因为数据可能在同一时间被修改。
|
||||
|
||||
换句话说,事务基于一个**前提(premise)** 采取行动(事务开始时候的事实,例如:“目前有两名医生正在值班”)。之后当事务要提交时,原始数据可能已经改变——前提可能不再成立。
|
||||
|
||||
当应用程序进行查询时(例如,“当前有多少医生正在值班?”),数据库不知道应用逻辑如何使用该查询结果。在这种情况下为了安全,数据库需要假设任何对该结果集的变更都可能会使该事务中的写入变得无效。 换而言之,事务中的查询与写入可能存在因果依赖。为了提供可序列化的隔离级别,如果事务在过时的前提下执行操作,数据库必须能检测到这种情况,并中止事务。
|
||||
当应用程序进行查询时(例如,“当前有多少医生正在值班?”),数据库不知道应用逻辑如何使用该查询结果。在这种情况下为了安全,数据库需要假设任何对该结果集的变更都可能会使该事务中的写入变得无效。 换而言之,事务中的查询与写入可能存在因果依赖。为了提供可串行化的隔离级别,如果事务在过时的前提下执行操作,数据库必须能检测到这种情况,并中止事务。
|
||||
|
||||
数据库如何知道查询结果是否可能已经改变?有两种情况需要考虑:
|
||||
|
||||
@ -799,7 +793,7 @@ WHERE room_id = 123 AND
|
||||
|
||||
为了防止这种异常,数据库需要跟踪一个事务由于MVCC可见性规则而忽略另一个事务的写入。当事务想要提交时,数据库检查是否有任何被忽略的写入现在已经被提交。如果是这样,事务必须中止。
|
||||
|
||||
为什么要等到提交?当检测到陈旧的读取时,为什么不立即中止事务43 ?因为如果事务43 是只读事务,则不需要中止,因为没有写入偏差的风险。当事务43 进行读取时,数据库还不知道事务是否要稍后执行写操作。此外,事务42 可能在事务43 被提交的时候中止或者可能仍然未被提交,因此读取可能终究不是陈旧的。通过避免不必要的中止,SSI 保留快照隔离对从一致快照中长时间运行的读取的支持。
|
||||
为什么要等到提交?当检测到陈旧的读取时,为什么不立即中止事务43 ?因为如果事务43 是只读事务,则不需要中止,因为没有写入偏差的风险。当事务43 进行读取时,数据库还不知道事务是否要稍后执行写操作。此外,事务42 可能在事务43 被提交的时候中止或者可能仍然未被提交,因此读取可能终究不是陈旧的。通过避免不必要的中止,SSI 保留了快照隔离从一致快照中长时间读取的能力。
|
||||
|
||||
#### 检测影响之前读取的写入
|
||||
|
||||
@ -807,9 +801,9 @@ WHERE room_id = 123 AND
|
||||
|
||||
![](img/fig7-11.png)
|
||||
|
||||
**图7-11 在可序列化快照隔离中,检测一个事务何时修改另一个事务的读取。**
|
||||
**图7-11 在可串行化快照隔离中,检测一个事务何时修改另一个事务的读取。**
|
||||
|
||||
在两阶段锁定的上下文中,我们讨论了[索引范围锁]()(请参阅“[索引范围锁]()”),它允许数据库锁定与某个搜索查询匹配的所有行的访问权,例如 `WHERE shift_id = 1234`。可以在这里使用类似的技术,除了SSI锁不会阻塞其他事务。
|
||||
在两阶段锁定的上下文中,我们讨论了索引范围锁(请参阅“[索引范围锁](#索引范围锁)”),它允许数据库锁定与某个搜索查询匹配的所有行的访问权,例如 `WHERE shift_id = 1234`。可以在这里使用类似的技术,除了SSI锁不会阻塞其他事务。
|
||||
|
||||
在[图7-11]()中,事务42 和43 都在班次1234 查找值班医生。如果在`shift_id`上有索引,则数据库可以使用索引项1234 来记录事务42 和43 读取这个数据的事实。 (如果没有索引,这个信息可以在表级别进行跟踪)。这个信息只需要保留一段时间:在一个事务完成(提交或中止),并且所有的并发事务完成之后,数据库就可以忘记它读取的数据了。
|
||||
|
||||
@ -817,18 +811,17 @@ WHERE room_id = 123 AND
|
||||
|
||||
在[图7-11](img/fig7-11.png)中,事务43 通知事务42 其先前读已过时,反之亦然。事务42首先提交并成功,尽管事务43 的写影响了42 ,但因为事务43 尚未提交,所以写入尚未生效。然而当事务43 想要提交时,来自事务42 的冲突写入已经被提交,所以事务43 必须中止。
|
||||
|
||||
#### 可序列化的快照隔离的性能
|
||||
#### 可串行化快照隔离的性能
|
||||
|
||||
与往常一样,许多工程细节会影响算法的实际表现。例如一个权衡是跟踪事务的读取和写入的**粒度(granularity)**。如果数据库详细地跟踪每个事务的活动(细粒度),那么可以准确地确定哪些事务需要中止,但是簿记开销可能变得很显著。简略的跟踪速度更快(粗粒度),但可能会导致更多不必要的事务中止。
|
||||
|
||||
在某些情况下,事务可以读取被另一个事务覆盖的信息:这取决于发生了什么,有时可以证明执行结果无论如何都是可序列化的。 PostgreSQL使用这个理论来减少不必要的中止次数【11,41】。
|
||||
在某些情况下,事务可以读取被另一个事务覆盖的信息:这取决于发生了什么,有时可以证明执行结果无论如何都是可串行化的。 PostgreSQL使用这个理论来减少不必要的中止次数【11,41】。
|
||||
|
||||
与两阶段锁定相比,可序列化快照隔离的最大优点是一个事务不需要阻塞等待另一个事务所持有的锁。就像在快照隔离下一样,写不会阻塞读,反之亦然。这种设计原则使得查询延迟更可预测,变量更少。特别是,只读查询可以运行在一致的快照上,而不需要任何锁定,这对于读取繁重的工作负载非常有吸引力。
|
||||
与两阶段锁定相比,可串行化快照隔离的最大优点是一个事务不需要阻塞等待另一个事务所持有的锁。就像在快照隔离下一样,写不会阻塞读,反之亦然。这种设计原则使得查询延迟更可预测,变量更少。特别是,只读查询可以运行在一致快照上,而不需要任何锁定,这对于读取繁重的工作负载非常有吸引力。
|
||||
|
||||
与串行执行相比,可序列化快照隔离并不局限于单个CPU核的吞吐量:FoundationDB将检测到的序列化冲突分布在多台机器上,允许扩展到很高的吞吐量。即使数据可能跨多台机器进行分区,事务也可以在保证可序列化隔离等级的同时读写多个分区中的数据【54】。
|
||||
|
||||
中止率显著影响SSI的整体表现。例如,长时间读取和写入数据的事务很可能会发生冲突并中止,因此SSI要求同时读写的事务尽量短(只读长事务可能没问题)。对于慢事务,SSI可能比两阶段锁定或串行执行更不敏感。
|
||||
与串行执行相比,可串行化快照隔离并不局限于单个CPU核的吞吐量:FoundationDB将检测到的串行化冲突分布在多台机器上,允许扩展到很高的吞吐量。即使数据可能跨多台机器进行分区,事务也可以在保证可串行化隔离等级的同时读写多个分区中的数据【54】。
|
||||
|
||||
中止率显著影响SSI的整体表现。例如,长时间读取和写入数据的事务很可能会发生冲突并中止,因此SSI要求同时读写的事务尽量短(只读的长事务可能没问题)。对于慢事务,SSI可能比两阶段锁定或串行执行更不敏感。
|
||||
|
||||
|
||||
## 本章小结
|
||||
@ -839,7 +832,7 @@ WHERE room_id = 123 AND
|
||||
|
||||
如果没有事务处理,各种错误情况(进程崩溃,网络中断,停电,磁盘已满,意外并发等)意味着数据可能以各种方式变得不一致。例如,非规范化的数据可能很容易与源数据不同步。如果没有事务处理,就很难推断复杂的交互访问可能对数据库造成的影响。
|
||||
|
||||
本章深入讨论了**并发控制**的话题。我们讨论了几个广泛使用的隔离级别,特别是**读已提交**,**快照隔离**(有时称为可重复读)和**可序列化**。并通过研究竞争条件的各种例子,来描述这些隔离等级:
|
||||
本章深入讨论了**并发控制**的话题。我们讨论了几个广泛使用的隔离级别,特别是**读已提交**,**快照隔离**(有时称为可重复读)和**可串行化**。并通过研究竞争条件的各种例子,来描述这些隔离等级:
|
||||
|
||||
***脏读***
|
||||
|
||||
@ -859,13 +852,13 @@ WHERE room_id = 123 AND
|
||||
|
||||
***写偏差***
|
||||
|
||||
一个事务读取一些东西,根据它所看到的值作出决定,并将该决定写入数据库。但是,写入时,该决定的前提不再是真实的。只有可序列化的隔离才能防止这种异常。
|
||||
一个事务读取一些东西,根据它所看到的值作出决定,并将该决定写入数据库。但是,写入时,该决定的前提不再是真实的。只有可串行化的隔离才能防止这种异常。
|
||||
|
||||
***幻读***
|
||||
|
||||
事务读取符合某些搜索条件的对象。另一个客户端进行写入,影响搜索结果。快照隔离可以防止直接的幻像读取,但是写入偏差上下文中的幻读需要特殊处理,例如索引范围锁定。
|
||||
|
||||
弱隔离级别可以防止其中一些异常情况,此外让应用程序开发人员手动处理剩余那些(例如,使用显式锁定)。只有可序列化的隔离才能防范所有这些问题。我们讨论了实现可序列化事务的三种不同方法:
|
||||
弱隔离级别可以防止其中一些异常情况,但要求你,也就是应用程序开发人员手动处理剩余那些(例如,使用显式锁定)。只有可串行化的隔离才能防范所有这些问题。我们讨论了实现可串行化事务的三种不同方法:
|
||||
|
||||
***字面意义上的串行执行***
|
||||
|
||||
@ -873,15 +866,15 @@ WHERE room_id = 123 AND
|
||||
|
||||
***两阶段锁定***
|
||||
|
||||
数十年来,两阶段锁定一直是实现可序列化的标准方式,但是许多应用出于性能问题的考虑避免使用它。
|
||||
数十年来,两阶段锁定一直是实现可串行化的标准方式,但是许多应用出于性能问题的考虑避免使用它。
|
||||
|
||||
***可串行化快照隔离(SSI)***
|
||||
|
||||
一个相当新的算法,避免了先前方法的大部分缺点。它使用乐观的方法,允许事务执行而无需阻塞。当一个事务想要提交时,它会进行检查,如果执行不可序列化,事务就会被中止。
|
||||
一个相当新的算法,避免了先前方法的大部分缺点。它使用乐观的方法,允许事务执行而无需阻塞。当一个事务想要提交时,它会进行检查,如果执行不可串行化,事务就会被中止。
|
||||
|
||||
本章中的示例主要是在关系数据模型的上下文中。使用关系数据模型。但是,正如在讨论中,无论使用哪种数据模型,如“**[多对象事务的需求](#多对象事务的需求)**”中所讨论的,事务都是重要的数据库功能。
|
||||
本章中的示例主要是在关系数据模型的上下文中。但是,正如在讨论中,无论使用哪种数据模型,如“**[多对象事务的需求](#多对象事务的需求)**”中所讨论的,事务都是有价值的数据库功能。
|
||||
|
||||
本章主要是在单机数据库的上下文中,探讨了各种概念与想法。分布式数据库中的事务,则引入了一系列新的困难挑战,将在接下来的两章中讨论。
|
||||
本章主要是在单机数据库的上下文中,探讨了各种想法和算法。分布式数据库中的事务,则引入了一系列新的困难挑战,我们将在接下来的两章中讨论。
|
||||
|
||||
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user