mirror of
https://github.com/Vonng/ddia.git
synced 2024-12-06 15:20:12 +08:00
ch7 90%
This commit is contained in:
parent
436a7e4dce
commit
44043e6790
524
ddia/ch7.md
524
ddia/ch7.md
@ -191,209 +191,208 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true
|
||||
|
||||
有一些场景中,单对象插入,更新和删除是足够的。但是许多其他场景需要协调写入几个不同的对象:
|
||||
|
||||
* 在关系数据模型中,一个表中的行通常具有对另一个表中的行的外键引用。 (同样,在一个类似图形的数据模型中,一个顶点与其他顶点有边)。多对象事务允许你确保这些引用保持有效:当插入几个相互引用的记录时,外键有是正确的和最新的,或者数据变得荒谬。
|
||||
* 在文档数据模型中,需要一起更新的字段通常在同一个文档中,这被视为单个对象 - 更新单个文档时不需要多对象事务。但是,缺乏连接功能的文档数据库也会鼓励非规范化(请参阅第38页上的“与文档数据库相关的对比”)。当需要更新非规范化的信息时,如图7-2所示,您需要一次更新多个文档。事务在这种情况下非常有用,可以防止非规范化的数据不同步。
|
||||
* 在具有二级索引的数据库中(几乎除了纯粹的键值存储以外的所有内容),每次更改值时都需要更新索引。从事务角度来看,这些索引是不同的数据库对象:例如,如果没有事务隔离,记录可能出现在一个索引中,而不是另一个索引中,因为第二个索引的更新还没有发生。
|
||||
* 在关系数据模型中,一个表中的行通常具有对另一个表中的行的外键引用。 (同样,在一个类似图形的数据模型中,一个顶点与其他顶点有边)。多对象事务允许你确保这些引用保持有效:当插入几个相互引用的记录时,外键必须是正确的,最新的,不然数据就会变的没有意义。
|
||||
* 在文档数据模型中,需要一起更新的字段通常在同一个文档中,这被视为单个对象 - 更新单个文档时不需要多对象事务。但是,缺乏连接功能的文档数据库也会鼓励非规范化(请参阅第38页上的“[与文档数据库相关的对比]()”)。当需要更新非规范化的信息时,如图7-2所示,您需要一次更新多个文档。事务在这种情况下非常有用,可以防止非规范化的数据不同步。
|
||||
* 在具有二级索引的数据库中(几乎除了纯粹的键值存储以外几乎都有),每次更改值时都需要更新索引。从事务角度来看,这些索引是不同的数据库对象:例如,如果没有事务隔离,记录可能出现在一个索引中,但没有出现在另一个索引中,因为第二个索引的更新还没有发生。
|
||||
|
||||
这些应用程序仍然可以在没有事务的情况然而,没有原子性,错误处理就变得复杂得多,缺乏隔离性会导致并发问题。我们将在第233页的“弱隔离级别”中讨论这些问题,并在第12章中探讨其他方法。
|
||||
这些应用程序仍然可以在没有事务的情况下实现。然而,没有原子性,错误处理就变得复杂得多,缺乏隔离性,就会导致并发问题。我们将在第233页的“[弱隔离级别]()”中讨论这些问题,并在[第12章]()中探讨其他方法。
|
||||
|
||||
#### 处理错误和中止
|
||||
|
||||
事务的一个关键特性是,如果发生错误,它可以被中止并安全地重试。 ACID数据库是基于这样一种理念:如果数据库有违反其原子性,隔离性或持久性的危险,则完全放弃事务,而不是完全放弃事务。
|
||||
事务的一个关键特性是,如果发生错误,它可以中止并安全地重试。 ACID数据库是基于这样一种想法:如果数据库有违反其原子性,隔离性或持久性的危险,宁愿完全放弃事务,而不是留下完成的一半。
|
||||
|
||||
不是所有的系统都遵循这个理念。具体而言,具有无引导复制的数据存储(请参见第167页的“无引导复制”)在“尽力而为”的基础上工作得更多,可以概括为“数据库将尽其所能,并且运行到一个错误,它不会撤消它已经完成的事情“ - 所以这是应用程序的责任,从错误中恢复。
|
||||
不是所有的系统都遵循这个理念。具体来讲,具有无主复制的数据存储(请参见第167页的“无引导复制”)在“尽力而为”的基础做了不少工作,可以概括为“数据库将尽其所能,当运行遇到错误时,它不会撤消它已经完成的事情“ ——所以这是应用程序的责任,从错误中恢复。
|
||||
|
||||
错误将不可避免地发生,但许多软件开发人员更喜欢只考虑快乐的道路,而不是错误处理的复杂性。例如,像Rails的ActiveRecord和Django这样的弹性对象关系映射(ORM)框架不会重试异常事务 - 这个错误通常会导致一个异常,导致堆栈出现异常,所以任何用户输入都会被丢弃,一个错误信息。这是一个耻辱,因为整个中止的重点是安全的重试。
|
||||
错误将不可避免地发生,但许多软件开发人员更喜欢只考虑乐观的情况,而不是错误处理的复杂性。例如,像Rails的ActiveRecord和Django这样的**对象关系映射(ORM, object-relation Mapping)**框架不会重试中断的事务—— 这个错误通常会导致一个异常,从堆栈向上传播。所以任何用户输入都会被丢弃,用户拿到一个错误信息。这实在是太耻辱了,因为中止的重点就是允许安全的重试。
|
||||
|
||||
虽然重试一个中止的事务是一个简单而有效的错误处理机制,但它并不完美:
|
||||
|
||||
- 如果事务实际上成功了,但是在服务器试图确认成功提交给客户端(所以客户端认为失败)时网络发生故障,那么重试事务会导致它被执行两次,除非你有一个额外的应用程序,级别的重复数据删除机制已到位。
|
||||
- 如果错误是由于过载造成的,则重试事务将使问题变得更糟,而不是更好。为了避免这种反馈周期,您可以限制重试次数,使用指数回退,并处理与过载相关的错误(与可能的情况不同)。
|
||||
- 仅在暂时性错误(例如,由于死锁,异常情况,临时性网络中断和故障转移)之后才值得重试。在发生永久性错误(例如,违反约束)之后,重试将毫无意义。
|
||||
- 如果事务在数据库之外也有副作用,即使事务被中止,也可能发生这些副作用。例如,如果您正在发送电子邮件,则每次重试事务时都不会再发送电子邮件。如果您想确保几个不同的系统提交或放弃在一起,两阶段提交可以提供帮助(我们将在第354页的“原子提交和两阶段提交(2PC)”中讨论这个问题)。
|
||||
- 如果事务实际上成功了,但是在服务器试图确认成功提交给客户端(所以客户端认为失败)时网络发生故障,那么重试事务会导致它被执行两次,除非你有一个额外的应用级除重机制。
|
||||
- 如果错误是由于负载过大造成的,则重试事务将使问题变得更糟,而不是更好。为了避免这种正反馈循环,您可以限制重试次数,使用指数退避算法,并单独处理与过载相关的错误(如果允许)。
|
||||
- 仅在临时性错误(例如,由于死锁,异常情况,临时性网络中断和故障转移)之后才值得重试。在发生永久性错误(例如,违反约束)之后重试是毫无意义的。
|
||||
- 如果事务在数据库之外也有副作用,即使事务被中止,也可能发生这些副作用。例如,如果您正在发送电子邮件,则每次重试事务时都重新发送电子邮件肯定不是你想要的。如果您想确保几个不同的系统提交或放弃在一起,**二阶段提交(2PC, two-phase commit)**可以提供帮助(我们将在第354页的“[原子提交和两阶段提交(2PC)]()”中讨论这个问题)。
|
||||
- 如果客户端进程在重试时失败,则任何试图写入数据库的数据都将丢失。
|
||||
|
||||
|
||||
|
||||
## 弱隔离级别
|
||||
|
||||
如果两个事务不触及相同的数据,它们可以安全地并行运行,因为两者都不依赖于另一个。当一个事务读取由另一个事务同时修改的数据时,或者当两个事务试图同时修改相同的数据时,并发问题(竞争条件)才起作用。
|
||||
如果两个事务不触及相同的数据,它们可以安全地**并行(parallel)**运行,因为两者都不依赖于另一个。当一个事务读取由另一个事务同时修改的数据时,或者当两个事务试图同时修改相同的数据时,并发问题(竞争条件)才会出现。
|
||||
|
||||
并发性错误很难通过测试找到,因为这样的错误只有在运行不正常时才会触发。这样的时间问题可能很少发生,通常很难重现。并发性也很难推理,特别是在大型应用程序中,您不一定知道哪些其他代码正在访问数据库。如果您一次只有一个用户,应用程序开发就非常困难;有许多并发用户使得它更加困难,因为任何一个数据块都可能随时改变。
|
||||
并发性错误很难通过测试找到,因为这样的错误只有在运行不正常时才会触发。这样的时机可能很少发生,通常很难重现。并发性也很难推理,特别是在大型应用程序中,您不一定知道哪些其他代码正在访问数据库。应用程序开发在一次只有一个用户时就很麻烦了,有许多并发用户使得它更加困难,因为任何一个数据都可能随时改变。
|
||||
|
||||
出于这个原因,数据库一直试图通过提供事务隔离来隐藏应用程序开发者的并发问题。从理论上讲,隔离应该让你假装没有并发发生,让你的生活更轻松:可序列化的隔离意味着数据库保证事务的效果与连续运行(即一次一个没有任何并发)是一样的。
|
||||
出于这个原因,数据库一直试图通过提供**事务隔离(transaction isolation)**来隐藏应用程序开发者的并发问题。从理论上讲,隔离可以通过假装没有并发发生,让你的生活更加轻松:**可序列化(serializable)**的隔离登记意味着数据库保证事务的效果与连续运行(即一次一个,没有任何并发)是一样的。
|
||||
|
||||
实际上,隔离不幸并不那么简单。可序列化的隔离性能很高,许多数据库不愿意支付这个价格[8]。因此,系统通常使用较弱的隔离级别来防止一些并发问题,但不是全部。这些孤立的程度难以理解,并且会导致微妙的错误,但是它们仍然在实践中被使用[23]。
|
||||
实际上不幸的是:隔离并没有那么简单。**可序列化**会有性能损失,许多数据库不愿意付这个代价[8]。因此,系统通常使用较弱的隔离级别来防止一些并发问题,但不是全部。这些隔离级别难以理解,并且会导致微妙的错误,但是它们仍然在实践中被使用[23]。
|
||||
|
||||
并发性错误导致的并发性错误不仅仅是一个理论问题。他们造成了大量的资金损失[24,25],导致了财务审计人员的调查[26],并导致客户数据被破坏[27]。关于这类问题的一个流行的评论是“如果你正在处理财务数据,请使用一个ACID数据库!” - 但是这一点没有提到。即使是很多流行的关系型数据库系统(通常被认为是“ACID”)也使用弱隔离,所以它们不一定能防止这些错误的发生。
|
||||
并发性错误导致的并发性错误不仅仅是一个理论问题。他们造成了很多的资金损失[24,25],耗费了财务审计人员的调查[26],并导致客户数据被破坏[27]。关于这类问题的一个流行的评论是“如果你正在处理财务数据,请使用ACID数据库!” ——但是这一点没有提到。即使是很多流行的关系型数据库系统(通常被认为是“ACID”)也使用弱隔离级别,所以它们也不一定能防止这些错误的发生。
|
||||
|
||||
我们不必盲目依赖工具,而应该对存在的并发问题的种类以及如何防止这些问题有一个很好的理解。然后,我们可以使用我们所掌握的工具来构建可靠和正确的应用程序。
|
||||
比起盲目地依赖工具,我们应该对存在的并发问题的种类,以及如何防止这些问题有深入的理解。然后就可以使用我们所掌握的工具来构建可靠和正确的应用程序。
|
||||
|
||||
在本节中,我们将看几个在实践中使用的弱(不可串行化)隔离级别,并详细讨论哪种竞争条件可以发生也可能不发生,以便您可以决定什么级别适合您的应用程序。一旦我们完成了这个工作,我们将详细讨论可串行性(请参阅“可序列化”第259页)。我们讨论的隔离级别将是非正式的,使用示例。如果你需要严格的定义和分析它们的属性,你可以在学术文献中找到它们[28,29,30]。
|
||||
在本节中,我们将看几个在实践中使用的弱(**不可串行化(nonserializable)**)隔离级别,并详细讨论哪种竞争条件可能发生也可能不发生,以便您可以决定什么级别适合您的应用程序。一旦我们完成了这个工作,我们将详细讨论可串行性(请参阅“[可序列化]()”第259页)。我们讨论的隔离级别将是非正式的,使用示例。如果你需要严格的定义和分析它们的属性,你可以在学术文献中找到它们[28,29,30]。
|
||||
|
||||
### 读已提交
|
||||
|
||||
最基本的事务隔离级别是read committed.v它提供了两个保证:
|
||||
最基本的事务隔离级别是**读已提交(Read Committed)**[^v],它提供了两个保证:
|
||||
|
||||
1. 从数据库读取时,只能看到已提交的数据(没有脏读)。
|
||||
2. 写入数据库时,只会覆盖已经写入的数据(没有脏写入)。
|
||||
1. 从数据库读时,只能看到已提交的数据(没有**脏读(dirty reads)**)。
|
||||
2. 写入数据库时,只会覆盖已经写入的数据(没有**脏写(dirty writes)**)。
|
||||
|
||||
我们来更详细地讨论这两个保证。
|
||||
|
||||
[^v]: 某些数据库支持甚至更弱的隔离级别,称为**读未提交(Read uncommitted)**。它可以防止脏写,但不防止脏读。
|
||||
|
||||
#### 没有脏读
|
||||
|
||||
设想一个事务已经将一些数据写入数据库,但事务还没有提交或中止。另一个事务可以看到未提交的数据吗?如果是的话,那就叫做脏读[2][2]。
|
||||
设想一个事务已经将一些数据写入数据库,但事务还没有提交或中止。另一个事务可以看到未提交的数据吗?如果是的话,那就叫做**脏读(dirty reads)**【2】。
|
||||
|
||||
在读提交隔离级别运行的事务必须防止脏读。这意味着事务的任何写入操作只有在该事务提交时才能被其他人看到(然后所有的写入操作都会立即可见)。如图7-4所示,用户1设置了`x = 3`,但用户2的`get x`仍旧返回旧值2,而用户1尚未提交。
|
||||
在**读已提交**隔离级别运行的事务必须防止脏读。这意味着事务的任何写入操作只有在该事务提交时才能被其他人看到(然后所有的写入操作都会立即变得可见)。如[图7-4]()所示,用户1设置了`x = 3`,但用户2的`get x`仍旧返回旧值2,而用户1尚未提交。
|
||||
|
||||
![](img/fig7-4.png)
|
||||
|
||||
**图7-4 没有脏读:用户2只有在用户1的事务已经提交后才能看到x的新值。**
|
||||
|
||||
[^v]: 某些数据库支持甚至更弱的隔离级别,称为读取未提交。它可以防止脏写入,但不防止脏读。
|
||||
为什么要防止脏读,有几个原因:
|
||||
|
||||
有几个原因为什么需要防止脏读:
|
||||
|
||||
- 如果事务需要更新多个对象,脏读取意味着另一个事务可能会看到一些更新,而不是其他更新。例如,在图7-2中,用户看到新的未读电子邮件,但看不到更新的计数器。这是电子邮件的一个肮脏的阅读。看到处于部分更新状态的数据库会对用户造成混淆,并可能导致其他事务做出错误的决定。
|
||||
- 如果事务中止,则所有写入操作都需要回滚(如图7-3所示)。如果数据库允许脏读,那就意味着一个事务可能会看到稍后回滚的数据,即从未实际提交给数据库的数据。有关后果的推理很快变成了令人费解的想法。
|
||||
- 如果事务需要更新多个对象,脏读取意味着另一个事务可能会只看到一部分更新。例如,在图7-2中,用户看到新的未读电子邮件,但看不到更新的计数器。这就是电子邮件的脏读。看到处于部分更新状态的数据库会让用户感到困惑,并可能导致其他事务做出错误的决定。
|
||||
- 如果事务中止,则所有写入操作都需要回滚(如图7-3所示)。如果数据库允许脏读,那就意味着一个事务可能会看到稍后需要回滚的数据,即从未实际提交给数据库的数据。想想后果就让人头大。
|
||||
|
||||
#### 没有脏写
|
||||
|
||||
如果两个事务同时尝试更新数据库中的相同对象,会发生什么情况?我们不知道写入的顺序是怎样的,但是我们通常认为后面的写入会覆盖前面的写入。
|
||||
|
||||
但是,如果先前的写入是尚未提交的事务的一部分,那么会发生什么情况,因此后面的写入会覆盖未提交的值?这被称为肮脏的写作[28]。在读取提交的隔离级别上运行的事务必须防止脏写入,通常是延迟第二次写入,直到第一次写入事务提交或中止为止。
|
||||
但是,如果先前的写入是尚未提交事务的一部分,又会发生什么情况,后面的写入会覆盖一个尚未提交的值?这被称作**脏写(dirty write)**[28]。在**读已提交**的隔离级别上运行的事务必须防止脏写,通常是延迟第二次写入,直到第一次写入事务提交或中止为止。
|
||||
|
||||
通过防止脏写,这个隔离级别避免了一些并发问题:
|
||||
|
||||
- 如果事务更新多个对象,脏写入会导致不好的结果。例如,考虑图7-5,图7-5说明了一个二手车销售网站,Alice和Bob两个人同时试图购买同一辆车。购买汽车需要两次数据库写入:网站上的商品需要更新以反映买家,销售发票需要发送给买家。在图7-5的情况下,销售授予鲍勃(因为他执行获奖更新列表表),但发票发送给爱丽丝(因为她执行获奖更新发票表)。阅读承诺这样的事故。
|
||||
- 但是,提交读取并不能防止图7-1中两个计数器增量之间的竞争状态。在这种情况下,第二次写入发生在第一个事务提交后,所以它不是一个肮脏的写。这仍然是不正确的,但是出于不同的原因,在第242页的“防止丢失的更新”中,我们将讨论如何使这种计数器增量安全。
|
||||
- 如果事务更新多个对象,脏写会导致不好的结果。例如,考虑图7-5,图7-5以一个二手车销售网站为例,Alice和Bob两个人同时试图购买同一辆车。购买汽车需要两次数据库写入:网站上的商品列表需要更新,以反映买家的购买,销售发票需要发送给买家。在图7-5的情况下,销售是属于Bob的(因为他成功更新了商品列表),但发票却寄送给了爱丽丝(因为她成功更新了发票表)。读已提交会阻止这样这样的事故。
|
||||
- 但是,提交读取并不能防止[图7-1]()中两个计数器增量之间的竞争状态。在这种情况下,第二次写入发生在第一个事务提交后,所以它不是一个脏写。这仍然是不正确的,但是出于不同的原因,在第242页的“防止更新的丢失”中将讨论如何使这种计数器增量安全。
|
||||
|
||||
![](img/fig7-5.png)
|
||||
|
||||
**图7-5 使用脏写,来自不同事务的冲突写入可能会混淆在一起**
|
||||
**图7-5 如果存在脏写,来自不同事务的冲突写入可能会混淆在一起**
|
||||
|
||||
#### 实现读已提交
|
||||
|
||||
读提交是一个非常流行的隔离级别。这是Oracle 11g,PostgreSQL,SQL Server 2012,MemSQL和其他许多数据库的默认设置[8]。
|
||||
|
||||
最常见的情况是,数据库通过使用行级锁来防止脏写入:当事务想要修改特定对象(行或文档)时,它必须首先获得该对象的锁。然后必须保持该锁,直到事务被提交或中止。只有一个事务可以保存任何给定对象的锁;如果另一个事务要写入同一个对象,则必须等到第一个事务被提交或中止后才能获取该锁并继续。这种锁定是通过读取提交模式(或更强的隔离级别)中的数据库自动完成的。
|
||||
最常见的情况是,数据库通过使用**行锁(row-level lock)**来防止脏写:当事务想要修改特定对象(行或文档)时,它必须首先获得该对象的锁。然后必须持有该锁直到事务被提交或中止。一次只有一个事务可持有任何给定对象的锁;如果另一个事务要写入同一个对象,则必须等到第一个事务提交或中止后才能获取该锁并继续。这种锁定是读已提交模式(或更强的隔离级别)的数据库自动完成的。
|
||||
|
||||
我们如何防止脏读?一种选择是使用相同的锁,并要求任何想要读取对象的事务来简单地获取该锁,然后在读取之后立即再次释放该锁。这将确保读取不会发生,而对象有一个肮脏的,未提交的值(因为在那段时间锁将由举行了写的事务)。
|
||||
如何防止脏读?一种选择是使用相同的锁,并要求任何想要读取对象的事务来简单地获取该锁,然后在读取之后立即再次释放该锁。这能确保不会读取进行时,对象不会在脏的状态,有未提交的值(因为在那段时间锁会被写入该对象的事务持有)。
|
||||
|
||||
但是,要求读取锁定的方法在实际中并不奏效,因为长时间运行的写入事务会强制许多只读事务等待长时间运行的事务完成。这会损害只读事务的响应时间,并且不利于可操作性:由于等待锁定,应用程序的某个部分的减速可能会在完全不同的应用程序中产生连锁效应。
|
||||
但是要求读锁的办法在实践中效果并不好。因为一个长时间运行的写入事务会迫使许多只读事务等到这个慢写入事务完成。这会损失只读事务的响应时间,并且不利于可操作性:因为等待锁,应用某个部分的迟缓可能由于连锁效应,导致其他部分出现问题。
|
||||
|
||||
出于这个原因,大多数数据库[^vi]使用上述方法防止脏读。如图7-4:对于写入的每个对象,数据库都会记住旧的提交值和由当前持有写入锁的事务设置的新值。 当事务正在进行时,任何其他读取对象的事务都被赋予旧的价值。 只有当提交新值时,事务才会切换到读取新值。
|
||||
出于这个原因,大多数数据库[^vi]使用[图7-4]()的方式防止脏读:对于写入的每个对象,数据库都会记住旧的已提交值,和由当前持有写入锁的事务设置的新值。 当事务正在进行时,任何其他读取对象的事务都会拿到旧值。 只有当新值提交后,事务才会切换到读取新值。
|
||||
|
||||
### 快照隔离和可重复读取
|
||||
[^vi]: 在撰写本文时,唯一在读已提交隔离级别使用读锁的主流数据库是使用`read_committed_snapshot = off`配置的IBM DB2和Microsoft SQL Server [23,36]。
|
||||
|
||||
如果你从表面上看读取承诺的隔离,你可以原谅它认为事务需要做的一切事情:它允许中止(原子性要求),它防止读取不完整的事务结果,并排写入的并发写入。事实上,这些是非常有用的功能,而且比没有事务的系统可以得到更多的保证。
|
||||
### 快照隔离和可重复读
|
||||
|
||||
但是,在使用此隔离级别时,仍然有很多方法可能会导致并发错误。例如,图7-6说明了提交读取时可能发生的问题。
|
||||
如果只从表面上看读已提交隔离级别你就认为它完成了事务所需的一切,那是可以原谅的。它允许**中止**(原子性的要求);它防止读取不完整的事务结果,并排写入的并发写入。事实上这些功能非常有用,比起没有事务的系统来,可以提供更多的保证。
|
||||
|
||||
但是在使用此隔离级别时,仍然有很多地方可能会产生并发错误。例如,图7-6说明了读已提交时可能发生的问题。
|
||||
|
||||
![](img/fig7-6.png)
|
||||
|
||||
**图7-6 读取偏斜:Alice观察数据库处于不一致的状态**
|
||||
**图7-6 读取偏差:Alice观察数据库处于不一致的状态**
|
||||
|
||||
说爱丽丝在银行有1000美元的储蓄,分两个账户,每个500美元。现在一笔事务从她的账户中转移了100美元到另一笔账户。如果她不幸在事务正在处理的同一时间查看其账户余额列表,则可以在收到付款之前的一段时间看到一个账户余额(余额为500美元),另一个外汇转账完成后的账户(新余额为400美元)。对于爱丽丝来说,现在她的账户似乎只有900美元 - 看起来100美元已经消失了。
|
||||
爱丽丝在银行有1000美元的储蓄,分为两个账户,每个500美元。现在一笔事务从她的一个账户中转移了100美元到另一个账户。如果她在事务处理的同时查看其账户余额列表,不幸地在转账事务完成前看到收款账户余额(余额为500美元),而在转账完成后看到另一个转出账户(已经转出100美元,余额400美元)。对爱丽丝来说,现在她的账户似乎只有900美元——看起来100美元已经消失了。
|
||||
|
||||
[^vi]: 在撰写本文时,唯一使用读取提交隔离锁定的主流数据库是`read_committed_snapshot = off`配置中的IBM DB2和Microsoft SQL Server [23,36]。
|
||||
这种异常被称为**不可重复读(nonrepeatable read)**或**读取偏差(read skew)**:如果Alice在事务结束时再次读取账户1的余额,她将看到与她之前的查询中看到的不同的值(600美元)。在读已提交的隔离条件下,**不可重复读**被认为是可接受的:Alice看到的帐户余额时确实在阅读时已经提交了。
|
||||
|
||||
这种异常被称为不可重复读取或读取歪斜:如果Alice在事务结束时再次读取账户1的余额,她将看到与她之前的查询中看到的不同的值(600美元)。在阅读承诺的隔离条件下,阅读偏差被认为是可接受的:Alice看到的帐户余额确实是在阅读时确定的。
|
||||
> 不幸的是,术语**偏差(skew)** 这个词是过载的:我们以前使用它是因为热点的不平衡工作量(参阅“[偏斜的工作负荷和减轻热点]()”),而这里偏差意味着异常的时机。
|
||||
|
||||
> 不幸的是,skew 这个词倾斜是超负荷的:我们以前使用它是因为热点的不平衡工作量(请参阅第205页上的“偏斜的工作负荷和减轻热点”),而这意味着计时异常。
|
||||
对于Alice的情况,这不是一个长期持续的问题。因为如果她几秒钟后刷新银行网站的页面,她很可能会看到一致的帐户余额。但是有些情况下,不能容忍这种暂时的不一致:
|
||||
|
||||
***备份***
|
||||
|
||||
进行备份需要复制整个数据库,对大型数据库而言可能需要花费数小时才能完成。备份进程运行时,数据库仍然会接受写入操作。因此备份可能会包含一些旧的部分和一些新的部分。如果从这样的备份中恢复,那么不一致(如消失的钱)就会变成永久的。
|
||||
|
||||
在Alice的情况下,这不是一个长期的问题,因为如果她几秒钟后重新加载在线银行网站,她很可能会看到一致的帐户余额。但是,有些情况不能容忍这种暂时的不一致:
|
||||
***分析查询和完整性检查***
|
||||
|
||||
- *备份*
|
||||
有时,您可能需要运行一个查询,扫描大部分的数据库。这样的查询在分析中很常见(参阅“[事务处理或分析?]()”),也可能是定期完整性检查(即监视数据损坏)的一部分。如果这些查询在不同时间点观察数据库的不同部分,则可能会返回毫无意义的结果。
|
||||
|
||||
进行备份需要复制整个数据库,这可能需要花费数小时才能完成。在备份过程正在运行的过程中,将继续写入数据库。因此,您可能会得到包含较旧版本数据的备份部分以及包含较新版本的其他部分。如果您需要从这样的备份中恢复,那么不一致(如消失的钱)就会变成永久的。
|
||||
**快照隔离(snapshot isolation)**[28]是这个问题最常见的解决方案。想法是,每个事务都从数据库的**一致快照(consistent snapshot)**中读取——也就是说,事务可以看到事务开始时在数据库中提交的所有数据。即使这些数据随后被另一个事务更改,每个事务也只能看到该特定时间点的旧数据。
|
||||
|
||||
- *分析查询和完整性检查*
|
||||
快照隔离对长时间运行的只读查询(如备份和分析)非常有用。如果查询的数据在查询执行的同时发生变化,则很难理解查询的含义。当一个事务可以看到数据库在某个特定时间点冻结时的一致快照,理解起来就很容易了。
|
||||
|
||||
有时,您可能需要运行一个查询,扫描大部分的数据库。这样的查询在分析中很常见(请参阅第90页的“事务处理或分析?”),也可能是定期完整性检查(即监视数据损坏)的一部分。如果这些查询在不同时间点观察数据库的某些部分,则这些查询可能会返回无意义的结果。
|
||||
|
||||
快照隔离[28]是这个问题最常见的解决方案。这个想法是,每个事务都从数据库的一致快照中读取 - 也就是说,事务处理可以看到事务开始时在数据库中提交的所有数据。即使数据随后被另一个事务更改,每个事务也只能看到该特定时间点的旧数据。
|
||||
|
||||
快照隔离对长时间运行的只读查询(如备份和分析)非常有用。如果查询的运行数据在查询执行的同时发生变化,则很难推理查询的含义。当一个事务可以看到数据库的一致快照,并在某个特定的时间点被冻结时,它会更容易理解。
|
||||
|
||||
快照隔离是一个流行的功能:PostgreSQL支持,MySQL与InnoDB存储引擎,Oracle,SQL Server等[23,31,32]。
|
||||
快照隔离是一个流行的功能:PostgreSQL,使用InnoDB引擎的MySQL,Oracle,SQL Server等都支持[23,31,32]。
|
||||
|
||||
#### 实现快照隔离
|
||||
|
||||
与读取提交的隔离类似,快照隔离的实现通常使用写入锁来防止脏写入(请参阅“实施读取已提交”(第217页)),这意味着进行写入的事务可以阻止另一个写入同一事务的进程目的。但是,读取不需要任何锁定。从性能的角度来看,快照隔离的一个关键原则是读者不会阻止作者,作者也不会阻止读者。这允许数据库在处理正常写入的同时处理一致快照上的长时间运行的读取查询,而两者之间没有任何锁定争用。
|
||||
与读取提交的隔离类似,快照隔离的实现通常使用写锁来防止脏写(请参阅“[读已提交](#读已提交)”),这意味着进行写入的事务会阻止另一个事务修改同一个对象。但是读取不需要任何锁定。从性能的角度来看,快照隔离的一个关键原则是:**读不阻塞写,写不阻塞读**。这允许数据库在处理一致性快照上的长时间查询时,可以正常地同时处理写入操作。且两者间没有任何锁定争用。
|
||||
|
||||
为了实现快照隔离,数据库使用了我们看到的用于防止图7-4中的脏读的机制的一般化。数据库必须可能保留一个对象的几个不同的提交版本,因为各种正在进行的事务可能需要在不同的时间点看到数据库的状态。因为它并排维护着多个版本的对象,所以这种技术被称为多版本并发控制(MVCC)。
|
||||
为了实现快照隔离,数据库使用了我们看到的用于防止[图7-4]()中的脏读的机制的一般化。数据库必须可能保留一个对象的几个不同的提交版本,因为各种正在进行的事务可能需要看到数据库在不同的时间点的状态。因为它并排维护着多个版本的对象,所以这种技术被称为**多版本并发控制(MVCC, multi-version concurrentcy control)**。
|
||||
|
||||
如果一个数据库只需要提供读提交的隔离,而不提供快照的隔离,那么保留一个对象的两个版本就足够了:提交的版本和被覆盖但尚未提交的版本。但是,支持快照隔离的存储引擎通常也使用MVCC作为读提交隔离级别。一种典型的方法是提交读取为每个查询使用单独的快照,而快照隔离对整个事务使用相同的快照。
|
||||
如果一个数据库只需要提供**读已提交**的隔离级别,而不提供**快照隔离**,那么保留一个对象的两个版本就足够了:提交的版本和被覆盖但尚未提交的版本。支持快照隔离的存储引擎通常也使用MVCC来实现**读已提交**隔离级别。一种典型的方法是**读已提交**为每个查询使用单独的快照,而**快照隔离**对整个事务使用相同的快照。
|
||||
|
||||
图7-7说明了如何在PostgreSQL中实现基于MVCC的快照隔离[31](其他实现类似)。当一个事务开始时,它被赋予一个唯一的,永远增长的事务ID(txid)。每当事务向数据库写入任何内容时,它所写入的数据都会被标记为写入者的事务ID。
|
||||
[图7-7]()说明了如何在PostgreSQL中实现基于MVCC的快照隔离[31](其他实现类似)。当一个事务开始时,它被赋予一个唯一的,永远增长[^vii]的事务ID(`txid`)。每当事务向数据库写入任何内容时,它所写入的数据都会被标记上写入者的事务ID。
|
||||
|
||||
[^vii]: 事实上,事务ID是32位整数,所以它们在大约40亿次事务之后溢出。 PostgreSQL的真空过程执行清理,确保溢出不会影响数据。
|
||||
[^vii]: 事实上,事务ID是32位整数,所以大约会在40亿次事务之后溢出。 PostgreSQL的Vacuum过程会清理老旧的事务ID,确保事务ID溢出(回卷)不会影响到数据。
|
||||
|
||||
![](img/fig7-7.png)
|
||||
|
||||
**图7-7 使用多版本对象实现快照隔离**
|
||||
|
||||
表中的每一行都有一个created_by字段,其中包含将该行插入到表中的事务的ID。此外,每行都有一个deleted_by字段,最初是空的。如果某个事务删除了一行,那么该行实际上并未从数据库中删除,而是通过将deleted_by字段设置为请求删除的事务的ID来标记为删除。在稍后的时间,当确定没有事务可以再访问已删除的数据时,数据库中的垃圾收集过程将删除标记为删除的行并释放它们的空间。
|
||||
表中的每一行都有一个`created_by`字段,其中包含将该行插入到表中的的事务ID。此外,每行都有一个`deleted_by`字段,最初是空的。如果某个事务删除了一行,那么该行实际上并未从数据库中删除,而是通过将`deleted_by`字段设置为请求删除的事务的ID来标记为删除。在稍后的时间,当确定没有事务可以再访问已删除的数据时,数据库中的垃圾收集过程会将所有带有删除标记的行移除,并释放其空间。
|
||||
|
||||
更新内部翻译成删除和创建。例如,在图7-7中,事务13从账户2中扣除100美元,将余额从500美元改为400美元。账户表现在实际上包含账户2的两行:一笔余额为\$500的行被标记为被事务13删除,一行的余额为\$400,由事务13创建。
|
||||
UPDATE操作在内部翻译为DELETE和INSERT。例如,在[图7-7]()中,事务13从账户2中扣除100美元,将余额从500美元改为400美元。实际上包含两条账户2的记录:余额为\$500的行被标记为**被事务13删除**,余额为\$400的行**由事务13创建**。
|
||||
|
||||
#### 可见性规则用于观察一致的快照
|
||||
#### 观察一致性快照的可见性规则
|
||||
|
||||
当一个事务从数据库中读取时,事务ID被用来决定它可以看到哪些对象,哪些是不可见的。通过仔细定义可见性规则,
|
||||
当一个事务从数据库中读取时,事务ID用于决定它可以看见哪些对象,看不见哪些对象。通过仔细定义可见性规则,数据库可以向应用程序呈现一致的数据库快照。工作如下:
|
||||
|
||||
数据库可以向应用程序呈现一致的数据库快照。这工作如下:
|
||||
1. 在每次事务开始时,数据库列出当时所有其他(尚未提交或中止)的事务清单。,即使之后提交了,这些事务的写入也都会被忽略。
|
||||
2. 被中止事务执行的任何写入都将被忽略。
|
||||
3. 由具有较晚事务ID(即,在当前事务开始之后开始的)的事务所做的任何写入都被忽略,而不管这些事务是否已经提交。
|
||||
4. 所有其他写入,对应用都是可见的。
|
||||
|
||||
1. 在每次事务开始时,数据库列出当时所有其他事务(尚未提交或中止)的事务清单。即使事务随后提交,任何写入的事务都会被忽略。
|
||||
2. 被中止的事务所做的任何写入都被忽略。
|
||||
3. 由具有较晚的事务ID(即,在当前事务开始之后开始的)的事务所做的任何写入都被忽略,而不管这些事务是否已经提交。
|
||||
4. 所有其他写入对应用程序的查询都是可见的。
|
||||
|
||||
这些规则适用于创建和删除对象。在图7-7中,当事务12从账户2读取时,它看到$ 500的余额,因为$ 500余额的删除是由事务13完成的(根据规则3,事务12看不到事务13所做的删除),并且400美元的余额的创建还不可见(按照相同的规则)。
|
||||
这些规则适用于创建和删除对象。在[图7-7]()中,当事务12从账户2读取时,它会看到\$500的余额,因为\$500余额的删除是由事务13完成的(根据规则3,事务12看不到事务13执行的删除),且400美元记录的创建也是不可见的(按照相同的规则)。
|
||||
|
||||
换句话说,如果以下两个条件都成立,则可见一个对象:
|
||||
|
||||
- 读者事务开始时,创建该对象的事务已经提交。
|
||||
- 对象未被标记为删除,或者如果是,请求删除的事务在读者事务开始时尚未提交。
|
||||
- 读事务开始时,创建该对象的事务已经提交。
|
||||
- 对象未被标记为删除,或如果被标记为删除,请求删除的事务在读事务开始时尚未提交。
|
||||
|
||||
长时间运行的事务可能会长时间继续使用快照,并继续读取(从其他事务的角度来看)早已被覆盖或删除的值。由于从不更新值,而是每次更改值时创建一个新版本,数据库可以提供一致的快照,同时只产生一个小的开销。
|
||||
长时间运行的事务可能会长时间使用快照,并继续读取(从其他事务的角度来看)早已被覆盖或删除的值。由于从来不更新值,而是每次值改变时创建一个新的版本,数据库可以在提供一致快照的同时只产生很小的额外开销。
|
||||
|
||||
#### 索引和快照隔离
|
||||
|
||||
索引如何在多版本数据库中工作?一种选择是使索引简单地指向对象的所有版本并且需要索引查询来过滤掉当前事务不可见的任何对象版本。当垃圾收集删除任何事务不再可见的旧对象版本时,相应的索引条目也可以被删除。
|
||||
在实践中,许多实现细节决定了多版本并发控制的性能。例如,如果同一对象的不同版本可以放在同一个页面上,PostgreSQL就可以避免索引更新的优化[31]。
|
||||
索引如何在多版本数据库中工作?一种选择是使索引简单地指向对象的所有版本,并且需要索引查询来过滤掉当前事务不可见的任何对象版本。当垃圾收集删除任何事务不再可见的旧对象版本时,相应的索引条目也可以被删除。
|
||||
|
||||
在CouchDB,Datomic和LMDB中使用另一种方法。虽然它们也使用B树(请参阅第79页上的“B树”),但它们使用仅附加/正在写入的变体,它们在更新时不覆盖树的页面,而是创建一个新的每个修改页面的副本。父页面,直到树的根,被复制和更新,以指向他们的子页面的新版本。任何不受写入影响的页面都不需要被复制,并且保持不变[33,34,35]。
|
||||
在实践中,许多实现细节决定了多版本并发控制的性能。例如,如果同一对象的不同版本可以放入同一个页面中,PostgreSQL的优化可以避免更新索引[31]。
|
||||
|
||||
使用仅附加B树,每个写入事务(或一批事务)都会创建一个新的B树根,而且特定的根在数据库创建时是一致的快照。没有必要根据事务ID过滤掉对象,因为后续写入不能修改现有的B-树;他们只能创建新的树根。但是,这种方法也需要一个压缩和垃圾收集的后台进程。
|
||||
在CouchDB,Datomic和LMDB中使用另一种方法。虽然它们也使用B树(请参阅第79页上的“B树”),但它们使用的是一种**仅追加/写时拷贝(append-only/copy-on-write)**的变体,它们在更新时不覆盖树的页面,而为每个修改页面创建一份副本。从父页面直到树根都会级联更新,以指向它们子页面的新版本。任何不受写入影响的页面都不需要被复制,并且保持不变[33,34,35]。
|
||||
|
||||
#### 可重复的读取和命名混淆
|
||||
使用仅追加的B树,每个写入事务(或一批事务)都会创建一颗新的B树,当创建时,从该特定树根生长的树就是数据库的一个一致性快照。没必要根据事务ID过滤掉对象,因为后续写入不能修改现有的B树;它们只能创建新的树根。但这种方法也需要一个负责压缩和垃圾收集的后台进程。
|
||||
|
||||
快照隔离是一个有用的隔离级别,特别是对于只读事务。但是,许多实现它的数据库都是通过不同的名称来调用它。在Oracle中称为可序列化的,在PostgreSQL和MySQL中称为可重复读取[23]。
|
||||
#### 可重复读与命名混淆
|
||||
|
||||
这种命名混淆的原因是SQL标准没有快照隔离的概念,因为标准是基于System R 1975年定义的隔离级别[2],并且快照隔离尚未发明然后。相反,它定义了可重复读取,这看起来表面上与快照隔离很相似。 PostgreSQL和MySQL调用其快照隔离级别可重复读取,因为它符合标准的要求,所以他们可以要求遵守标准。
|
||||
快照隔离是一个有用的隔离级别,特别对于只读事务而言。但是,许多数据库实现了它,却用不同的名字来称呼。在Oracle中称为**可序列化(Serializable)**的,在PostgreSQL和MySQL中称为**可重复读(repeatable read)**[23]。
|
||||
|
||||
不幸的是,SQL标准对隔离级别的定义是有缺陷的 - 它是模糊的,不精确的,而不是像标准那样独立于实现[28]。尽管有几个数据库实现了可重复读取,但它们实际提供的保证存在很大的差异,尽管表面上是标准化的[23]。在研究文献[29,30]中已经有了可重复阅读的正式定义,但是大多数的实现并不能满足这个正式的定义。最后,IBM DB2使用“可重复读”来引用可串行化[8]。
|
||||
这种命名混淆的原因是SQL标准没有快照隔离的概念,因为标准是基于System R 1975年定义的隔离级别[2],那时候**快照隔离**尚未发明。相反,它定义了**可重复读**,表面上看起来与快照隔离很相似。 PostgreSQL和MySQL称其**快照隔离**级别为**可重复读(repeatable read)**,因为它符合标准要求,所以它们可以称自己“兼容标准”。
|
||||
|
||||
结果,没有人真正知道可重复的读取手段。
|
||||
不幸的是,SQL标准对隔离级别的定义是有缺陷的——模糊,不精确,并不像标准应该的那样独立于实现[28]。有几个数据库实现了可重复读,但它们实际提供的保证存在很大的差异,尽管表面上是标准化的[23]。在研究文献[29,30]中已经有了可重复阅读的正式定义,但是大多数的实现并不能满足这个正式的定义。最后,IBM DB2使用“可重复读”来引用可串行化[8]。
|
||||
|
||||
### 防止丢失的更新
|
||||
结果,没有人真正知道**可重复读**的意思。
|
||||
|
||||
到目前为止,我们讨论的读取提交和快照隔离级别主要是保证只读事务在并发写入时可以看到什么。我们主要忽略了同时写入两个事务的问题 - 我们只讨论了脏写入(请参阅第235页的“无肮脏的写入”),可能会发生一种特定类型的写入 - 写入冲突。
|
||||
### 防止丢失更新
|
||||
|
||||
并发写作事务之间还有其他几种有趣的冲突。其中最着名的是丢失更新问题,如图7-1所示,以两个并发计数器增量为例。
|
||||
到目前为止,我们讨论的读已提交和快照隔离级别主要是:**保证了只读事务在并发写入时可以看到什么**。却忽略了两个事务并发写入的问题——我们只讨论了脏写(参阅“脏写”),一种特定类型的写-写冲突是可能出现的。
|
||||
|
||||
如果应用程序从数据库中读取一些值,修改它并写回修改的值(读取 - 修改 - 写入周期),则可能会发生丢失的更新问题。如果两个事务同时执行,则其中一个修改可能会丢失,因为第二个写入不包括第一个修改。 (我们有时会说后面写的是先前写的。)这种模式发生在各种不同的情况下:
|
||||
并发的写入事务之间还有其他几种有趣的冲突。其中最着名的是**丢失更新(lost update)**问题,如[图7-1]()所示,以两个并发计数器增量为例。
|
||||
|
||||
如果应用从数据库中读取一些值,修改它并写回修改的值(读取-修改-写入序列),则可能会发生丢失更新的问题。如果两个事务同时执行,则其中一个的修改可能会丢失,因为第二个写入的内容并没有包括第一个事务的修改(有时会说后面写入**狠揍(clobber)**了前面的写入)这种模式发生在各种不同的情况下:
|
||||
|
||||
- 增加计数器或更新账户余额(需要读取当前值,计算新值并写回更新后的值)
|
||||
- 将本地更改设置为复杂的值,例如,将元素添加到JSON文档中的列表(需要解析文档,进行更改并写回修改的文档)
|
||||
- 两个用户同时编辑wiki页面,每个用户通过将整个页面内容发送到服务器来保存其更改,覆盖数据库中当前的任何内容
|
||||
- 在复杂值中进行本地修改:例如,将元素添加到JSON文档中的一个列表(需要解析文档,进行更改并写回修改的文档)
|
||||
- 两个用户同时编辑wiki页面,每个用户通过将整个页面内容发送到服务器来保存其更改,覆写数据库中当前的任何内容。
|
||||
|
||||
由于这是一个普遍的问题,所以已经开发了各种解决方案。
|
||||
|
||||
#### 原子写
|
||||
|
||||
许多数据库提供了原子更新操作,从而消除了在应用程序代码中执行读取 - 修改 - 写入循环的需要。如果您的代码可以用这些操作来表示,那么它们通常是最好的解决方案。例如,下面的指令在大多数关系数据库中是并发安全的:
|
||||
许多数据库提供了原子更新操作,从而消除了在应用程序代码中执行读取-修改-写入序列的需要。如果你的代码可以用这些操作来表达,那这通常是最好的解决方案。例如,下面的指令在大多数关系数据库中是并发安全的:
|
||||
|
||||
```sql
|
||||
UPDATE counters SET value = value + 1 WHERE key = 'foo';
|
||||
@ -401,230 +400,229 @@ UPDATE counters SET value = value + 1 WHERE key = 'foo';
|
||||
|
||||
类似地,像MongoDB这样的文档数据库提供了对JSON文档的一部分进行本地修改的原子操作,Redis提供了修改数据结构(如优先级队列)的原子操作。并不是所有的写操作都可以用原子操作的方式来表达,例如维基页面的更新涉及到任意文本编辑[^viii],但是在可以使用原子操作的情况下,它们通常是最好的选择。
|
||||
|
||||
原子操作通常通过在读取对象时对其进行独占锁定来实现,以便在更新完成之前没有其他事务可以读取它
|
||||
[^viii]: 将文本文档的编辑表示为原子的变化流是可能的,尽管相当复杂。参阅第174页上的“自动冲突解决”。
|
||||
|
||||
[^viii]: 将文本文档的编辑表示为原子突变流是可能的,尽管相当复杂。有关指针的信息,请参阅第174页上的“自动冲突解决”。
|
||||
原子操作通常通过在读取对象时,获取其上的排它锁来实现。以便更新完成之前没有其他事务可以读取它。这种技术有时被称为**游标稳定性(cursor stability)**[36,37]。另一个选择是简单地强制所有的原子操作在单一线程上执行。
|
||||
|
||||
应用。这种技术有时被称为游标稳定性[36,37]。另一个选择是仅仅强制所有的原子操作在一个线程上执行。
|
||||
|
||||
不幸的是,对象关系映射框架可以很容易地意外编写执行不安全读取 - 修改 - 写入循环的代码,而不是使用数据库提供的原子操作[38]。如果你知道自己在做什么,这不是一个问题,但它可能是测试难以发现的微妙错误的来源。
|
||||
不幸的是,ORM框架很容易意外地执行不安全的读取-修改-写入循环,而不是使用数据库提供的原子操作[38]。如果你知道自己在做什么那当然不是问题,但它经常产生那种很难测出来的微妙Bug。
|
||||
|
||||
#### 显式锁定
|
||||
|
||||
如果数据库的内置原子操作没有提供必要的功能,防止丢失更新的另一个选择是让应用程序显式地锁定将要更新的对象。然后应用程序可以执行读 - 修改 - 写周期,如果任何其他事务尝试同时读取同一个对象,则强制等待,直到第一个读 - 修改 - 写周期完成。
|
||||
如果数据库的内置原子操作没有提供必要的功能,防止丢失更新的另一个选择是让应用程序显式地锁定将要更新的对象。然后应用程序可以执行读取-修改-写入序列,如果任何其他事务尝试同时读取同一个对象,则强制等待,直到第一个**读取-修改-写入序列**完成。
|
||||
|
||||
例如,考虑一个多人游戏,其中几个玩家可以同时移动相同的数字。在这种情况下,一个原子操作可能是不够的,因为应用程序还需要确保一个玩家的移动遵守游戏规则,这涉及到一些你不能合理地实现的作为数据库查询的逻辑。相反,您可以使用锁来防止两名玩家同时移动相同的棋子,如例7-1所示。
|
||||
例如,考虑一个多人游戏,其中几个玩家可以同时移动相同的棋子。在这种情况下,一个原子操作可能是不够的,因为应用程序还需要确保玩家的移动符合游戏规则,这可能涉及到一些不能合理地用数据库查询实现的逻辑。但你可以使用锁来防止两名玩家同时移动相同的棋子,如例7-1所示。
|
||||
|
||||
##### 例7-1 显式锁定行以防止丢失更新
|
||||
|
||||
```plsql
|
||||
BEGIN TRANSACTION;
|
||||
SELECT *
|
||||
FROM figures
|
||||
WHERE name = 'robot' AND game_id = 222
|
||||
SELECT * FROM figures
|
||||
WHERE name = 'robot' AND game_id = 222
|
||||
FOR UPDATE;
|
||||
-- Check whether move is valid, then update the position -- of the piece that was returned by the previous SELECT. UPDATE figures SET position = 'c4' WHERE id = 1234;
|
||||
|
||||
-- 检查玩家的操作是否有效,然后更新先前SELECT返回棋子的位置。
|
||||
UPDATE figures SET position = 'c4' WHERE id = 1234;
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
- FOR UPDATE子句指示数据库应该对所有行进行锁定由此查询返回。
|
||||
- `FOR UPDATE`子句告诉数据库应该对该查询返回的所有行加锁。
|
||||
|
||||
这是有效的,但要做到这一点,你需要仔细考虑你的应用逻辑。忘记在代码的某个地方添加一个必要的锁,很容易引入竞争条件。
|
||||
这是有效的,但要做对,你需要仔细考虑应用逻辑。忘记在代码某处加锁很容易引入竞争条件。
|
||||
|
||||
#### 自动检测丢失的更新
|
||||
|
||||
原子操作和锁定是通过强制读取 - 修改 - 写入循环顺序发生来防止丢失更新的方法。另一种方法是允许它们并行执行,如果事务管理器检测到丢失的更新,则中止事务并强制它重试其读 - 修改 - 写周期。
|
||||
原子操作和锁是通过强制**读取-修改-写入序列**按顺序发生,来防止丢失更新的方法。另一种方法是允许它们并行执行,如果事务管理器检测到丢失更新,则中止事务并强制它们重试其**读取-修改-写入序列**。
|
||||
|
||||
这种方法的一个优点是数据库可以结合快照隔离高效地执行此检查。事实上,PostgreSQL的可重复读取,Oracle的可串行化和SQL Server的快照隔离级别会自动检测丢失更新何时发生,并中止违规事务。但是,MySQL / InnoDB的可重复读取没有检测到丢失的更新[23]。一些作者[28,30]认为,数据库必须防止丢失的更新,以便有资格提供快照隔离,所以MySQL在这个定义下不提供快照隔离。
|
||||
这种方法的一个优点是,数据库可以结合快照隔离高效地执行此检查。事实上,PostgreSQL的可重复读,Oracle的可串行化和SQL Server的快照隔离级别,都会自动检测到丢失更新,并中止惹麻烦的事务。但是,MySQL/InnoDB的可重复读并不会检测**丢失更新**[23]。一些作者[28,30]认为,数据库必须能防止丢失更新才称得上是提供了**快照隔离**,所以在这个定义下,MySQL下不提供快照隔离。
|
||||
|
||||
丢失更新检测是一个很好的功能,因为它不需要应用程序代码来使用任何特殊的数据库功能,您可能会忘记使用锁定或原子操作,从而引入一个错误,但丢失的更新检测自动发生,因此不太容易出错。
|
||||
丢失更新检测是一个很好的功能,因为它不需要应用代码使用任何特殊的数据库功能,你可能会忘记使用锁或原子操作,从而引入一个错误;但丢失更新的检测是自动发生的,因此不太容易出错。
|
||||
|
||||
#### 比较和设置
|
||||
#### 比较并设置(CAS)
|
||||
|
||||
在不提供事务的数据库中,有时会发现一种原子操作:比较并设置(CAS, Compare And Set)(先前在“[单对象写入]()”中提到)。此操作的目的是为了避免丢失更新:只有当前值从上次读取时一直未改变,才允许更新发生。如果当前值与先前读取的值不匹配,则更新不起作用,且必须重试读取-修改-写入序列。
|
||||
|
||||
在不提供事务的数据库中,您有时会发现原子比较和设置操作(先前在第230页上的“单对象写入”中提到)。此操作的目的是为了避免丢失的更新,只有在上次读取该值时才更新。如果当前值与先前读取的值不匹配,则更新不起作用,并且必须重试读取 - 修改 - 写入周期。
|
||||
例如,为了防止两个用户同时更新同一个wiki页面,可以尝试类似这样的方式,只有当用户开始编辑页面内容时,才会发生更新:
|
||||
|
||||
根据数据库的实现情况,这可能也可能不安全
|
||||
|
||||
```sql
|
||||
-- This may or may not be safe, depending on the database implementation
|
||||
UPDATE wiki_pages
|
||||
SET content = 'new content'
|
||||
WHERE id = 1234 AND content = 'old content';
|
||||
-- 根据数据库的实现情况,这可能也可能不安全
|
||||
UPDATE wiki_pages SET content = '新内容'
|
||||
WHERE id = 1234 AND content = '旧内容';
|
||||
```
|
||||
|
||||
如果内容已经更改并且不再与“旧内容”相匹配,则此更新将不起作用,因此您需要检查更新是否生效,并在必要时重试。但是,如果数据库允许从旧快照中读取WHERE子句,则此语句可能无法防止丢失的更新,因为即使发生了另一个并发写入,条件也可能为真。在依赖数据库之前,检查数据库的比较和设置操作是否安全。
|
||||
如果内容已经更改并且不再与“旧内容”相匹配,则此更新将不起作用,因此您需要检查更新是否生效,必要时重试。但是,如果数据库允许`WHERE`子句从旧快照中读取,则此语句可能无法防止丢失更新,因为即使发生了另一个并发写入,`WHERE`条件也可能为真。在依赖数据库的CAS操作前要检查其是否安全。
|
||||
|
||||
#### 冲突解决和复制
|
||||
|
||||
在复制的数据库中(参见第5章),防止丢失的更新需要另一个维度:由于它们具有多个节点上的数据副本,并且可能会在不同节点上同时修改数据,因此需要采取一些额外的步骤来防止丢失更新。
|
||||
在复制数据库中(参见第5章),防止丢失的更新需要考虑另一个维度:由于在多个节点上存在数据副本,并且在不同节点上的数据可能被并发地修改,因此需要采取一些额外的步骤来防止丢失更新。
|
||||
|
||||
锁和比较和设置操作假定有一个最新的数据副本。但是,具有多领导者或无领导者复制的数据库通常允许同时发生多个写入并异步复制,因此他们无法保证数据的最新副本。因此,基于锁或比较和设置的技术不适用于这种情况。 (我们将在第324页的“Linearizability”中更详细地讨论这个问题。)
|
||||
锁和CAS操作假定有一个最新的数据副本。但是多主或无主复制的数据库通常允许多个写入并发执行,并异步复制到副本上,因此无法保证有一份数据的最新副本。所以基于锁或CAS操作的技术不适用于这种情况。 (我们将在“[线性化]()”中更详细地讨论这个问题。)
|
||||
|
||||
相反,如“检测并发写入”一节第184页中所述,这种复制数据库中的一种常见方法是允许并发写入创建多个冲突版本的值(也称为同级),并使用应用程序代码或特殊数据结构在事实之后解决和合并这些版本。
|
||||
相反,如“[检测并发写入]()”一节所述,这种复制数据库中的一种常见方法是允许并发写入创建多个冲突版本的值(也称为兄弟),并使用应用代码或特殊数据结构在事实发生之后解决和合并这些版本。
|
||||
|
||||
原子操作可以在复制的上下文中很好地工作,特别是如果它们是兼容的(即,可以在不同的副本上以不同的顺序应用它们,并且仍然可以得到相同的结果)。例如,递增计数器或向元素添加元素是可交换的操作。这是Riak 2.0数据类型背后的思想,它可以防止复制副本丢失更新。当不同的客户端同时更新一个值时,Riak自动将更新合并在一起,以免丢失更新[39]。
|
||||
原子操作可以在复制的上下文中很好地工作,尤其当它们具有可交换性时(即,可以在不同的副本上以不同的顺序应用它们,且仍然可以得到相同的结果)。例如,递增计数器或向集合添加元素是可交换的操作。这是Riak 2.0数据类型背后的思想,它可以防止复制副本丢失更新。当不同的客户端同时更新一个值时,Riak自动将更新合并在一起,以免丢失更新[39]。
|
||||
|
||||
另一方面,最后一次写入胜利(LWW)冲突解决方法很容易丢失更新,如第188页的“上次写入胜出(放弃并发写入)”中所述。不幸的是,LWW是许多复制数据库中的默认值。
|
||||
另一方面,最后写入为准(LWW)的冲突解决方法很容易丢失更新,如“[最后写入为准(放弃并发写入)]()”中所述。不幸的是,LWW是许多复制数据库中的默认值。
|
||||
|
||||
#### 写倾斜和幻读
|
||||
#### 写入偏差与幻读
|
||||
|
||||
前面的章节中,我们看到了肮脏的写入和丢失的更新,当不同的事务并发地尝试写入相同的对象时,会出现两种竞争条件。为了避免数据损坏,这些竞争条件需要被阻止 - 既可以由数据库自动执行,也可以通过手动安全措施(如使用锁定或原子写入操作)来防止。
|
||||
前面的章节中,我们看到了**脏写**和**丢失更新**,当不同的事务并发地尝试写入相同的对象时,会出现两种竞争条件。为了避免数据损坏,这些竞争条件需要被阻止——既可以由数据库自动执行,也可以通过锁和原子写操作这类手动安全措施来防止。
|
||||
|
||||
但是,这不是并发写入之间可能发生的竞争条件列表的末尾。在本节中,我们将看到一些更微妙的冲突的例子。
|
||||
首先,想象一下这个例子:你正在写医生的一个应用程序来管理他们在医院的轮班。医院通常会同时要求几位医生随叫随到,但必须至少有一位医生随时待命。医生
|
||||
但是,并发写入间可能发生的竞争条件还没有完。在本节中,我们将看到一些更微妙的冲突例子。
|
||||
|
||||
可以放弃他们的班次(例如,如果他们自己生病了),只要至少有一个同事在这个班次中继续工作[40,41]。
|
||||
现在想象一下,爱丽丝和鲍勃是两位值班医生。两人都感到不适,所以他们都决定请假。不幸的是,他们恰好在同一时间点击按钮关闭电话。图7-8说明了接下来的事情。
|
||||
首先,想象一下这个例子:你正在为医院写一个医生轮班管理程序。医院通常会同时要求几位医生待命,但底线是至少有一位医生在待命。医生可以放弃他们的班次(例如,如果他们自己生病了),只要至少有一个同事在这一班中继续工作[40,41]。
|
||||
|
||||
现在想象一下,Alice和Bob是两位值班医生。两人都感到不适,所以他们都决定请假。不幸的是,他们恰好在同一时间点击按钮关闭电话。图7-8说明了接下来的事情。
|
||||
|
||||
![](img/fig7-8.png)
|
||||
|
||||
**图7-8 写入歪斜导致应用程序错误的示例**
|
||||
**图7-8 写入偏差导致应用程序错误的示例**
|
||||
|
||||
在每次事务中,您的申请首先检查两个或两个以上的医生是否正在通话;如果是的话,它假定一名医生可以安全地接通电话。由于数据库使用快照隔离,两个检查都返回2,所以两个事务都进入下一个阶段。爱丽丝更新自己的记录,让自己脱离呼叫,而鲍勃也更新自己的记录。这两个事务承诺,现在没有医生在接电话。您的电话至少有一名医生的要求已被违反。
|
||||
在两个事务中,应用首先检查是否有两个或以上的医生正在值班;如果是的话,它就假定一名医生可以安全地翘班。由于数据库使用快照隔离,两次检查都返回2,所以两个事务都进入下一个阶段。爱丽丝更新自己的记录翘班了,而鲍勃也干了一样的事情。两个事务都成功提交了,现在没有医生值班了。违反了至少有一名医生在值班的要求。
|
||||
|
||||
#### 表征写入歪斜
|
||||
#### 写入偏差的特征
|
||||
|
||||
这种异常称为写歪斜[28]。它既不是一个肮脏的写作,也不是一个丢失的更新,因为这两个事务正在更新两个不同的对象(分别是Alice和Bob的待命记录)。在这里发生冲突并不那么明显,但是这显然是一个竞争条件:如果两个事务一个接一个地运行,那么第二个
|
||||
这种异常称为**写入偏差**[28]。它既不是**脏写**,也不是**丢失更新**,因为这两个事务正在更新两个不同的对象(Alice和Bob各自的待命记录)。在这里发生的冲突并不是那么明显,但是这显然是一个竞争条件:如果两个事务一个接一个地运行,那么第二个医生就不能翘班了。异常行为只有在事务并发进行时才有可能。
|
||||
|
||||
可以将写入偏差视为丢失更新问题的一般化。如果两个事务读取相同的对象,然后更新其中一些对象(不同的事务可能更新不同的对象),则可能发生写入偏差。在多个事务更新同一个对象的特殊情况下,就会发生脏写或丢失更新(取决于时机)。
|
||||
|
||||
医生会被阻止接听电话。异常行为只有在事务同时进行时才有可能。
|
||||
您可以将写入歪斜视为丢失更新问题的一般化。如果两个事务读取相同的对象,然后更新其中一些对象(不同的事务可能更新不同的对象),则可能发生写入歪斜。在不同的事务更新相同的对象的特殊情况下,你会得到一个脏写或丢失更新异常(取决于时间)。
|
||||
我们看到,有各种不同的方法来防止丢失的更新。随着写歪斜,我们的选择更受限制:
|
||||
•由于涉及多个对象,原子单对象操作不起作用。
|
||||
在一些快照隔离的实现中,你发现丢失的更新的自动检测不幸并没有帮助:在PostgreSQL的可重复读取,MySQL / InnoDB的可重复读取,Oracle可序列化或SQL Server的快照隔离中,不会自动检测到写入歪斜级别[23]。自动防止写入歪斜需要真正的可序列化隔离(请参见“可串行化”(第217页))。
|
||||
•某些数据库允许您配置约束,然后由数据库强制执行(例如,唯一性,外键约束或特定值限制)。但是,为了指定至少有一名医生必须在线,您需要一个涉及多个对象的约束。大多数数据库没有内置的对这种约束的支持,但是你可以使用触发器或者实体化视图来实现它们,这取决于数据库[42]。
|
||||
•如果无法使用可序列化的隔离级别,则此情况下的次优选项可能是显式锁定事务所依赖的行。在例子中,你可以写下如下的代码:
|
||||
|
||||
* 由于涉及多个对象,单对象的原子操作不起作用。
|
||||
* 不幸的是,在一些快照隔离的实现中,自动检测丢失更新对此并没有帮助。在PostgreSQL的可重复读,MySQL/InnoDB的可重复读,Oracle可序列化或SQL Server的快照隔离级别中,都不会自动检测写入偏差[23]。自动防止写入偏差需要真正的可序列化隔离(请参见“[可序列化]()”(第217页))。
|
||||
* 某些数据库允许配置约束,然后由数据库强制执行(例如,唯一性,外键约束或特定值限制)。但是为了指定至少有一名医生必须在线,需要一个涉及多个对象的约束。大多数数据库没有内置对这种约束的支持,但是你可以使用触发器,或者物化视图来实现它们,这取决于不同的数据库[42]。
|
||||
* 如果无法使用可序列化的隔离级别,则此情况下的次优选项可能是显式锁定事务所依赖的行。在例子中,你可以写下如下的代码:
|
||||
|
||||
```sql
|
||||
BEGIN TRANSACTION;
|
||||
SELECT *
|
||||
FROM doctors
|
||||
WHERE on_call = TRUE
|
||||
AND shift_id = 1234
|
||||
FOR UPDATE;
|
||||
SELECT * FROM doctors
|
||||
WHERE on_call = TRUE
|
||||
AND shift_id = 1234 FOR UPDATE;
|
||||
|
||||
UPDATE doctors
|
||||
SET on_call = FALSE
|
||||
WHERE name = 'Alice' AND shift_id = 1234;
|
||||
SET on_call = FALSE
|
||||
WHERE name = 'Alice'
|
||||
AND shift_id = 1234;
|
||||
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
和以前一样,FOR UPDATE告诉数据库锁定这个返回的所有行用于查询。
|
||||
* 和以前一样,`FOR UPDATE`告诉数据库锁定返回的所有行用于更新。
|
||||
|
||||
#### 更多的写歪斜的例子
|
||||
#### 写入偏差的更多例子
|
||||
|
||||
写起来歪斜起初可能看起来像是一个深奥的问题,但是一旦你意识到这一点,你可能会注意到更多的情况发生。以下是一些例子:
|
||||
写偏差乍看像是一个深奥的问题,但一旦你意识到这一点,就可能会注意到更多可能发生的情况。以下是一些例子:
|
||||
|
||||
***会议室预订系统***
|
||||
|
||||
假设你想强制执行,同一时间不能同时在两个会议室预订[43]。当有人想要预订时,首先检查是否存在相互冲突的预订(即预订时间范围重叠的同一房间),如果没有找到,则创建会议(请参见示例7-2)[^ix]。
|
||||
|
||||
[^ix]: 在PostgreSQL中,您可以使用范围类型优雅地执行此操作,但在其他数据库中并未得到广泛支持。
|
||||
|
||||
**例7-2 会议室预订系统试图避免重复预订(在快照隔离下不安全)**
|
||||
|
||||
```sql
|
||||
BEGIN TRANSACTION;
|
||||
-- Check for any existing bookings that overlap with the period of noon-1pm
|
||||
SELECT COUNT(*)
|
||||
FROM bookings
|
||||
WHERE room_id = 123 AND
|
||||
end_time > '2015-01-01 12:00' AND start_time < '2015-01-01 13:00';
|
||||
-- If the previous query returned zero:
|
||||
INSERT INTO bookings
|
||||
(room_id, start_time, end_time, user_id)
|
||||
VALUES (123, '2015-01-01 12:00', '2015-01-01 13:00', 666);
|
||||
|
||||
-- 检查所有现存的与12:00~13:00重叠的预定
|
||||
SELECT COUNT(*) FROM bookings
|
||||
WHERE room_id = 123 AND
|
||||
end_time > '2015-01-01 12:00' AND start_time < '2015-01-01 13:00';
|
||||
|
||||
-- 如果之前的查询返回0
|
||||
INSERT INTO bookings(room_id, start_time, end_time, user_id)
|
||||
VALUES (123, '2015-01-01 12:00', '2015-01-01 13:00', 666);
|
||||
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
不幸的是,快照隔离并不能防止另一个用户同时插入冲突的会议。为了保证你不会调度冲突,你再一次需要可序列化的隔离。
|
||||
不幸的是,快照隔离并不能防止另一个用户同时插入冲突的会议。为了确保不会遇到调度冲突,你又需要可序列化的隔离级别了。
|
||||
|
||||
***多人游戏***
|
||||
|
||||
在例7-1中,我们使用一个锁来防止丢失的更新(也就是确保两个玩家不能同时移动同一个数字)。但是,锁定并不妨碍玩家将两个不同的数字移动到棋盘上的相同位置,或者可能采取其他违反游戏规则的行为。根据您正在执行的规则类型,您可能可以使用唯一的约束,但否则您很容易发生写入歪斜。
|
||||
在[例7-1]()中,我们使用一个锁来防止丢失更新(也就是确保两个玩家不能同时移动同一个棋子)。但是锁定并不妨碍玩家将两个不同的棋子移动到棋盘上的相同位置,或者采取其他违反游戏规则的行为。按照您正在执行的规则类型,也许可以使用唯一约束,否则您很容易发生写入偏差。
|
||||
|
||||
***声称一个用户名***
|
||||
***抢注用户名***
|
||||
|
||||
在每个用户拥有唯一用户名的网站上,两个用户可能会尝试同时创建具有相同用户名的帐户。您可以使用事务来检查名称是否被采用,如果没有,则使用该名称创建账户。但是,像在前面的例子中那样,在快照隔离下是不安全的。幸运的是,一个唯一的约束是一个简单的解决方案(第二个事务,试图注册用户名将被中止,由于违反约束)。
|
||||
在每个用户拥有唯一用户名的网站上,两个用户可能会尝试同时创建具有相同用户名的帐户。可以在事务检查名称是否被抢占,如果没有则使用该名称创建账户。但是像在前面的例子中那样,在快照隔离下这是不安全的。幸运的是,唯一约束是一个简单的解决办法(第二个事务在提交时会因为违反用户名唯一约束而被中止)。
|
||||
|
||||
***防止双重开支***
|
||||
|
||||
允许用户花钱或积分的服务需要检查用户花费的不多。你可以通过在用户的帐户中插入一个消费项目来实现这一点,列出帐户中的所有项目,并检查总和是否为正值[44]。有了写歪斜,可能会发生两个支出项目同时插入,一起导致余额变为负值,但这两个事务都不会注意到另一个。
|
||||
允许用户花钱或积分的服务,需要检查用户的支付数额不超过其余额。可以通过在用户的帐户中插入一个试探性的消费项目来实现这一点,列出帐户中的所有项目,并检查总和是否为正值[44]。有了写入偏差,可能会发生两个支出项目同时插入,一起导致余额变为负值,但这两个事务都不会注意到另一个。
|
||||
|
||||
[^ix]: 在PostgreSQL中,您可以使用范围类型更优雅地执行此操作,但在其他数据库中并未得到广泛支持。
|
||||
|
||||
#### 导致写入歪斜的幻影
|
||||
#### 导致写入偏差的幻读
|
||||
|
||||
所有这些例子都遵循类似的模式:
|
||||
|
||||
1. SELECT查询通过搜索与某些搜索条件相匹配的行来检查是否满足某些要求(至少有两名医生正在通话,当时没有该房间的现有预订,该板上的位置不已经有了另外一个数字,用户名还没有被占用,账户里还有钱)。
|
||||
2. 根据第一个查询的结果,应用程序代码决定如何继续(可能继续操作,或者向用户报告错误并中止)。
|
||||
3. 如果应用程序决定继续执行,它将向数据库写入(INSERT,UPDATE或DELETE)并提交事务。
|
||||
1. 一个`SELECT`查询找出符合条件的行,并检查是否符合一些要求。(例如:至少有两名医生在值班;不存在对该会议室同一时段的预定;棋盘上的位置没有被其他棋子占据;用户名还没有被抢注;账户里还有足够余额)
|
||||
|
||||
这个写的效果改变了步骤2的决定的前提条件。换句话说,如果在提交写入后重复从步骤1开始的SELECT查询,将得到不同的结果,因为写改变了一组行符合搜索条件(现在有一个电话的医生减少了,那时候会议室现在已经预订了,现在这个位置已经被移动了,现在用户名已经被占用,现在有了更少的钱在帐户中)。
|
||||
2. 按照第一个查询的结果,应用代码决定是否继续。(可能会继续操作,也可能中止并报错)
|
||||
|
||||
这些步骤可能以不同的顺序发生。例如,您可以首先进行写入,然后进行SELECT查询,最后根据查询结果决定是放弃还是提交。在医生调用示例的情况下,在步骤3中修改的行是在步骤1中返回的行之一,所以我们可以通过锁定步骤1中的行(SELECT FOR UPDATE)来使事务安全并避免写入歪斜。但是,其他四个例子是不同的:它们检查是否存在与某些搜索条件相匹配的行,写入会添加一个匹配相同条件的行。如果步骤1中的查询没有返回任何行,则SELECT FOR UPDATE不能将锁附加到任何内容。
|
||||
3. 如果应用决定继续操作,就执行写入(插入、更新或删除),并提交事务。
|
||||
|
||||
这种效应,在一个事务中的写入改变另一个事务中的搜索查询的结果,被称为幻影[3]。快照隔离避免了只读查询中的幻影,但是在像我们讨论的例子那样的读写事务中,幻影会导致特别棘手的写歪斜情况。
|
||||
这个写入的效果改变了步骤2中的前决条件。换句话说,如果在提交写入后,重复执行一次步骤1的SELECT查询,将会得到不同的结果。因为写入改变符合搜索条件的行集(现在少了一个医生值班,那时候的会议室现在已经被预订了,棋盘上的这个位置已经被占据了,用户名已经被抢注,账户余额不够了)。
|
||||
|
||||
#### 实现冲突
|
||||
这些步骤可能以不同的顺序发生。例如可以首先进行写入,然后进行SELECT查询,最后根据查询结果决定是放弃还是提交。
|
||||
|
||||
如果虚幻的问题是没有对象可以附加锁,也许我们可以人为地在数据库中引入一个锁对象?
|
||||
在医生值班的例子中,在步骤3中修改的行,是步骤1中返回的行之一,所以我们可以通过锁定步骤1中的行(`SELECT FOR UPDATE`)来使事务安全并避免写入偏差。但是其他四个例子是不同的:它们检查是否**不存在**某些满足条件的行,写入会**添加**一个匹配相同条件的行。如果步骤1中的查询没有返回任何行,则`SELECT FOR UPDATE`锁不了任何东西。
|
||||
|
||||
例如,在会议室预订案例中,您可以想象创建一个时间表和房间表。此表中的每一行对应于特定时间段(例如15分钟)的特定房间。您可以提前创建所有可能的房间和时间组合的行,例如在接下来的六个月。
|
||||
这种效应:一个事务中的写入改变另一个事务的搜索查询的结果,被称为**幻读**[3]。快照隔离避免了只读查询中的幻影,但是在像我们讨论的例子那样的读写事务中,幻影会导致特别棘手的写歪斜情况。
|
||||
|
||||
现在,要创建预订的事务可以锁定(SELECT FOR UPDATE)表中与所需房间和时间段对应的行。在获得锁定之后,它可以检查重叠的预订并像以前一样插入新的预订。请注意,附加表格不用于存储有关预订的信息 - 它完全是一组锁,用于防止同时修改同一房间和时间范围内的预订。
|
||||
#### 物化冲突
|
||||
|
||||
这种方法被称为物化冲突,因为它需要一个幻像,并将其变成数据库中存在的一组具体行的锁定冲突[11]。不幸的是,弄清楚如何实现冲突可能很难,也很容易出错,而让并发控制机制泄漏到应用程序数据模型是很难的。出于这些原因,如果没有其他办法可以实现,冲突的实现应被视为最后的手段。在大多数情况下,可序列化的隔离级别是更可取的。
|
||||
如果幻读的问题是没有对象可以加锁,也许可以人为地在数据库中引入一个锁对象?
|
||||
|
||||
例如,在会议室预订的场景中,可以想象创建一个关于时间槽和房间的表。此表中的每一行对应于特定时间段(例如15分钟)的特定房间。可以提前插入房间和时间的所有可能组合行(例如接下来的六个月)。
|
||||
|
||||
现在,要创建预订的事务可以锁定(`SELECT FOR UPDATE`)表中与所需房间和时间段对应的行。在获得锁定之后,它可以检查重叠的预订并像以前一样插入新的预订。请注意,这个表并不是用来存储预订相关的信息——它完全就是一组锁,用于防止同时修改同一房间和时间范围内的预订。
|
||||
|
||||
这种方法被称为**物化冲突(materializing conflicts)**,因为它将幻读变为数据库中一组具体行上的锁冲突[11]。不幸的是,弄清楚如何物化冲突可能很难,也很容易出错,而让并发控制机制泄漏到应用数据模型是很丑陋的做法。出于这些原因,如果没有其他办法可以实现,物化冲突应被视为最后的手段。在大多数情况下。**可序列化(Serializable)**的隔离级别是更可取的。
|
||||
|
||||
|
||||
|
||||
## 串行执行
|
||||
## 可序列化(Serializability)
|
||||
|
||||
在本章中,我们已经看到了几个易于出现竞争条件的事务的例子。读取提交和快照隔离级别会阻止某些竞争条件,但其他竞争条件则不会。我们遇到了一些特别棘手的例子,写有歪斜和幻影。这是一个可悲的情况:
|
||||
在本章中,已经看到了几个易于出现竞争条件的事务例子。**读已提交**和**快照隔离**级别会阻止某些竞争条件,但不会阻止另一些。我们遇到了一些特别棘手的例子,**写入偏差**和**幻读**。这是一个可悲的情况:
|
||||
|
||||
- 隔离级别难以理解,并且在不同的数据库中不一致地实现(例如,“可重复读取”的含义差别很大)。
|
||||
- 如果您查看应用程序代码,很难判断在特定的隔离级别运行是否安全 - 特别是在大型应用程序中,您可能并不知道可能同时发生的所有事情。
|
||||
- 隔离级别难以理解,并且在不同的数据库中实现的不一致(例如,“可重复读”的含义天差地别)。
|
||||
- 光检查应用代码很难判断在特定的隔离级别运行是否安全。 特别是在大型应用程序中,您可能并不知道并发发生的所有事情。
|
||||
- 没有检测竞争条件的好工具。原则上来说,静态分析可能会有帮助[26],但研究中的技术还没法实际应用。并发问题的测试是很难的,因为它们通常是非确定性的——只有在倒霉的时机下才会出现问题。
|
||||
|
||||
没有好的工具来帮助我们检测竞争状况。原则上,静态分析可能有助于[26],但研究技术还没有找到实际应用的方法。并发问题的测试是很难的,因为它们通常是非确定性的 - 只有在不及时的情况下才会出现问题。
|
||||
这不是一个新问题,从20世纪70年代以来就一直是这样了,当时首先引入了较弱的隔离级别[2]。一直以来,研究人员的答案都很简单:使用**可序列化(serializable)**的隔离级别!
|
||||
|
||||
这不是一个新问题,自20世纪70年代以来,这种情况一直是这样,当时首先引入了较弱的隔离级别[2]。一直以来,研究人员的答案都很简单:使用可序列化的隔离!
|
||||
**可序列化(Serializability)**隔离通常被认为是最强的隔离级别。它保证即使事务可以并行执行,最终的结果也是一样的,就好像它们没有任何并发性,连续挨个执行一样。因此数据库保证,如果事务在单独运行时正常运行,则它们在并发运行时继续保持正确——换句话说,数据库可以防止**所有**可能的竞争条件。
|
||||
|
||||
可串行隔离通常被认为是最强的隔离级别。它保证即使事务可以并行执行,最终的结果也是一样的,就好像它们连续执行一个一样,没有任何并发性。因此,数据库保证,如果事务在单独运行时正常运行,则它们在并发运行时继续保持正确 - 换句话说,数据库可以防止所有可能的竞争条件。
|
||||
但如果可序列化隔离级别比弱隔离级别的烂摊子要好得多,那为什么没有人见人爱?为了回答这个问题,我们需要看看实现可序列化的选项,以及它们如何执行。目前大多数提供可序列化的数据库都使用了三种技术之一,本章的剩余部分将会介绍这些技术。
|
||||
|
||||
但是,如果可序列化隔离比弱隔离级别的混乱好得多,那么为什么不是每个人都使用它?为了回答这个问题,我们需要看看实现可串行化的选项,以及它们如何执行。目前大多数提供可串行化的数据库都使用了三种技术之一,本章后面将会介绍这些技术。
|
||||
- 字面意义上地串行顺序执行事务(参见“[真的串行执行]()”)
|
||||
- **两相锁定(2PL, two-phase locking)**,几十年来唯一可行的选择。(参见“[两相锁定(2PL)]()”)
|
||||
- 乐观并发控制技术,例如**可序列化的快照隔离(serializable snapshot isolation)**(参阅“[可序列化的快照隔离(SSI)]()”
|
||||
|
||||
- 以串行顺序从字面上执行事务(请参见第252页的“实际的串行执行”)
|
||||
- 两相锁定(参见第257页上的“两相锁定(2PL)”),几十年来唯一可行的选择
|
||||
- 开放式并发控制技术,例如可序列化的快照隔离(请参阅“可序列化的快照隔离(SSI)”
|
||||
现在将主要在单节点数据库的背景下讨论这些技术;在第9章中,我们将研究如何将它们推广到涉及分布式系统中多个节点的事务。
|
||||
|
||||
目前,我们将主要在单节点数据库的背景下讨论这些技术;在第9章中,我们将研究如何将它们推广到涉及分布式系统中多个节点的事务。
|
||||
#### 真的串行执行
|
||||
|
||||
避免并发问题的最简单方法就是完全不要并发:在单个线程上按顺序一次只执行一个事务。这样做就完全绕开了检测/防止事务间冲突的问题,由此产生的隔离,正是可序列化的定义。
|
||||
|
||||
尽管这似乎是一个明显的主意,但数据库设计人员只是在2007年左右才决定,单线程循环执行事务是可行的[45]。如果多线程并发在过去的30年中被认为是获得良好性能的关键所在,那么究竟是什么改变致使单线程执行变为可能呢?
|
||||
|
||||
#### 现实世界中的串行执行
|
||||
两个进展引起了这个反思:
|
||||
|
||||
避免并发问题的最简单方法是完全去除并发:在一个线程上按顺序一次只执行一个事务。通过这样做,我们完全回
|
||||
- RAM足够便宜了,许多场景现在都可以将完整的活跃数据集保存在内存中。(参阅“[将所有内容保留在内存中]()”)。当事务需要访问的所有数据都在内存中时,事务处理的执行速度要比等待数据从磁盘加载时快得多。
|
||||
- 数据库设计人员意识到OLTP事务通常很短,而且只进行少量的读写操作(参阅“[事务处理或分析?]()”)。相比之下,长时间运行的分析查询通常是只读的,因此它们可以在串行执行循环之外的一致快照(使用快照隔离)上运行。
|
||||
|
||||
避了检测和防止事务间冲突的问题:由此产生的隔离按定义是可序列化的。
|
||||
|
||||
尽管这似乎是一个明显的想法,但数据库设计人员只是在2007年左右才决定,执行事务的单线程循环是可行的[45]。如果多线程并发在过去的30年中被认为是获得良好性能的关键所在,那么为了使单线程执行成为可能,改变了什么呢?
|
||||
|
||||
两个事态发展引起了这个反思:
|
||||
|
||||
- RAM变得足够便宜,现在许多用例可以将整个活动数据集保存在内存中(请参阅第88页的“将所有内容保留在内存中”)。当事务需要访问的所有数据都在内存中时,事务处理的执行速度要比等待数据从磁盘加载时快得多。
|
||||
- 数据库设计人员意识到OLTP事务通常很短,只能进行少量的读写操作(请参阅“事务处理或分析?”)。相比之下,长时间运行的分析查询通常是只读的,因此它们可以在串行执行循环之外的一致快照(使用快照隔离)上运行。
|
||||
|
||||
串行执行事务的方法在VoltDB / H-Store,Redis和Datomic中实现[46,47,48]。设计用于单线程执行的系统有时可以比支持并发的系统更好,因为它可以避免锁定的协调开销。但是,其吞吐量仅限于单个CPU内核的吞吐量。为了充分利用单一线索,事务需要与传统形式不同的结构。
|
||||
串行执行事务的方法在VoltDB / H-Store,Redis和Datomic中实现[46,47,48]。设计用于单线程执行的系统有时可以比支持并发的系统更好,因为它可以避免锁的协调开销。但是其吞吐量仅限于单个CPU核的吞吐量。为了充分利用单一线程,需要与传统形式不同的结构的事务。
|
||||
|
||||
#### 在存储过程中封装事务
|
||||
|
||||
在数据库的早期阶段,意图是数据库事务可以包含整个用户活动流程。例如,预订机票是一个多阶段的过程(搜索路线,票价和可用座位,决定行程,在行程的每个航班上预订座位,输入旅客详细信息,付款)。数据库设计者认为,如果整个过程是一个事务,那么它就可以被原子化地执行。
|
||||
在数据库的早期阶段,意图是数据库事务可以包含整个用户活动流程。例如,预订机票是一个多阶段的过程(搜索路线,票价和可用座位,决定行程,在每段行程的航班上订座,输入乘客信息,付款)。数据库设计者认为,如果整个过程是一个事务,那么它就可以被原子化地执行。
|
||||
|
||||
不幸的是,人类做出决定和回应的速度非常缓慢。如果数据库事务需要等待来自用户的输入,则数据库需要支持潜在的大量并发事务,其中大部分是空闲的。大多数数据库不能高效地完成这项工作,因此几乎所有的OLTP应用程序都通过避免交互式地等待事务中的用户来保持事务的简短。在Web上,这意味着事务在同一个HTTP请求中被提交 - 一个事务不会跨越多个请求。一个新的HTTP请求开始一个新的事务。
|
||||
不幸的是,人类做出决定和回应的速度非常缓慢。如果数据库事务需要等待来自用户的输入,则数据库需要支持潜在的大量并发事务,其中大部分是空闲的。大多数数据库不能高效完成这项工作,因此几乎所有的OLTP应用程序都避免在事务中等待交互式的用户输入,以此来保持事务的简短。在Web上,这意味着事务在同一个HTTP请求中被提交——一个事务不会跨越多个请求。一个新的HTTP请求开始一个新的事务。
|
||||
|
||||
即使人类已经找出了关键路径,事务仍然以交互式的客户端/服务器风格执行,一次一个语句。应用程序进行查询,读取结果,可能根据第一个查询的结果进行另一个查询,依此类推。查询和结果在应用程序代码(在一台机器上运行)和数据库服务器(在另一台机器上)之间来回发送。
|
||||
即使人类已经找到了关键路径,事务仍然以交互式的客户端/服务器风格执行,一次一个语句。应用程序进行查询,读取结果,可能根据第一个查询的结果进行另一个查询,依此类推。查询和结果在应用程序代码(在一台机器上运行)和数据库服务器(在另一台机器上)之间来回发送。
|
||||
|
||||
在这种交互式的事务方式中,应用程序和数据库之间的网络通信耗费了大量的时间。如果您不允许在数据库中进行并发处理,并且一次只处理一个事务,则吞吐量将会非常糟糕,因为数据库将花费大部分时间等待应用程序发出当前事务的下一个查询。在这种数据库中,为了获得合理的性能,需要同时处理多个事务。
|
||||
在这种交互式的事务方式中,应用程序和数据库之间的网络通信耗费了大量的时间。如果不允许在数据库中进行并发处理,且一次只处理一个事务,则吞吐量将会非常糟糕,因为数据库大部分的时间都花费在等待应用程序发出当前事务的下一个查询。在这种数据库中,为了获得合理的性能,需要同时处理多个事务。
|
||||
|
||||
出于这个原因,具有单线程串行事务处理的系统不允许交互式多语句事务。相反,应用程序必须提前将整个事务代码作为存储过程提交给数据库。这些方法之间的差异如图7-9所示。如果事务所需的所有数据都在内存中,则存储过程可以非常快地执行,而不用等待任何网络或磁盘I/O。
|
||||
出于这个原因,具有单线程串行事务处理的系统不允许交互式的多语句事务。取而代之,应用程序必须提前将整个事务代码作为存储过程提交给数据库。这些方法之间的差异如图7-9所示。如果事务所需的所有数据都在内存中,则存储过程可以非常快地执行,而不用等待任何网络或磁盘I/O。
|
||||
|
||||
![](img/fig7-9.png)
|
||||
|
||||
@ -632,139 +630,141 @@ COMMIT;
|
||||
|
||||
#### 存储过程的优点和缺点
|
||||
|
||||
存储过程在关系型数据库中已经存在了一段时间了,自1999年以来它们一直是SQL标准(SQL/PSM)的一部分。由于各种原因,它们的名声不太好:
|
||||
存储过程在关系型数据库中已经存在了一段时间了,自1999年以来它们一直是SQL标准(SQL/PSM)的一部分。出于各种原因,它们的名声有点不太好:
|
||||
|
||||
- 每个数据库供应商都有自己的存储过程语言(Oracle有PL/SQL,SQL Server有T-SQL,PostgreSQL有PL/pgSQL等)。这些语言并没有跟上通用编程语言的发展,所以从今天的角度来看它们看起来相当丑陋和陈旧,而且它们缺乏大多数编程语言中能找到的库的生态系统。
|
||||
- 与应用程序服务器相,比在数据库中运行的代码难以管理,调试更为困难,版本控制和部署起来也比较尴尬,更难测试,难与监控系统集成。
|
||||
- 数据库通常比应用程序服务器对性能敏感的多,因为单个数据库实例通常由许多应用程序服务器共享。数据库中一个写得不好的存储过程(例如,使用大量内存或CPU时间)会比在应用程序服务器中写入相同的代码造成更多的麻烦。
|
||||
- 每个数据库厂商都有自己的存储过程语言(Oracle有PL/SQL,SQL Server有T-SQL,PostgreSQL有PL/pgSQL等)。这些语言并没有跟上通用编程语言的发展,所以从今天的角度来看,它们看起来相当丑陋和陈旧,而且缺乏大多数编程语言中能找到的库的生态系统。
|
||||
- 与应用服务器相,比在数据库中运行的管理困难,调试困难,版本控制和部署起来也更为尴尬,更难测试,更难和用于监控的指标收集系统相集成。
|
||||
- 数据库通常比应用服务器对性能敏感的多,因为单个数据库实例通常由许多应用服务器共享。数据库中一个写得不好的存储过程(例如,占用大量内存或CPU时间)会比在应用服务器中相同的代码造成更多的麻烦。
|
||||
|
||||
但是这些问题都是可以克服的。现代的存储过程实现放弃了PL/SQL,而是使用现有的通用编程语言:VoltDB使用Java或Groovy,Datomic使用Java或Clojure,而Redis使用Lua。
|
||||
|
||||
使用存储过程和内存数据,在单个线程上执行所有事务变得可行。由于它们不需要等待I / O,并且避免了其他并发控制机制的开销,它们可以在单个线程上实现相当好的吞吐量。
|
||||
存储过程与内存数据存储,使得在单个线程上执行所有事务变得可行。由于它们不需要等待I / O,并且避免了其他并发控制机制的开销,它们可以在单个线程上实现相当好的吞吐量。
|
||||
|
||||
VoltDB还使用存储过程进行复制:不是将事务的写入从一个节点复制到另一个节点,而是在每个节点上执行相同的存储过程。因此,VoltDB要求存储过程是确定性的(当在不同的节点上运行时,它们必须产生相同的结果)。如果事务需要使用当前的日期和时间,则必须通过特定的确定性API来实现。
|
||||
VoltDB还使用存储过程进行复制:但不是将事务的写入结果从一个节点复制到另一个节点,而是在每个节点上执行相同的存储过程。因此VoltDB要求存储过程是**确定性的**(当在不同的节点上运行时,它们必须产生相同的结果)。举个例子,如果事务需要使用当前的日期和时间,则必须通过特殊的确定性API来实现。
|
||||
|
||||
#### 分区
|
||||
|
||||
并发执行所有事务使得并发控制更为简单,但是将数据库的事务吞吐量限制在单个机器上单个CPU内核的速度。只读事务可以使用快照隔离在其他地方执行,但对于写入吞吐量较高的应用程序,单线程事务处理器可能成为一个严重的瓶颈。
|
||||
顺序执行所有事务使并发控制简单多了,但是数据库的事务吞吐量被限制在了单个机器上单个CPU核的速度。只读事务可以使用快照隔离在其他地方执行,但对于写入吞吐量较高的应用程序,单线程事务处理器可能成为一个严重的瓶颈。
|
||||
|
||||
为了扩展到多个CPU核心和多个节点,您可以对您的数据进行分区(参见第6章),这在VoltDB中是受支持的。如果您可以找到一种对数据集进行分区的方法,以便每个事务只需要在单个分区中读写数据,那么每个分区就可以拥有自己独立运行的事务处理线程。在这种情况下,您可以为每个CPU内核分配一个自己的分区,这样您的事务吞吐量就可以与CPU内核数量成线性比例关系[47]。
|
||||
为了扩展到多个CPU核心和多个节点,您可以对您的数据进行分区(参见第6章),在VoltDB中这是支持的。如果您可以找到一种对数据集进行分区的方法,以便每个事务只需要在单个分区中读写数据,那么每个分区就可以拥有自己独立运行的事务处理线程。在这种情况下可以为每个分区指派一个独立的CPU核,事务吞吐量就可以与CPU核数保持线性扩展[47]。
|
||||
|
||||
但是,对于需要访问多个分区的任何事务,数据库必须在触及的所有分区之间协调事务。存储过程需要跨越所有分区锁定执行,以确保整个系统的可串行性。
|
||||
|
||||
由于跨分区事务具有额外的协调开销,所以它们比单分区事务慢得多。 VoltDB报告的吞吐量大约是每秒1000个跨分区写入,比单分区吞吐量低几个数量级,并且不能通过增加更多的机器来增加[49]。
|
||||
|
||||
事务是否可以是单分区很大程度上取决于应用程序使用的数据的结构。简单的键值数据通常可以非常容易地进行分区,但是具有多个二级索引的数据可能需要大量的跨分区协调(请参阅第206页的“分区和二级索引”)。
|
||||
事务是否可以是划分至单个分区很大程度上取决于应用数据的结构。简单的键值数据通常可以非常容易地进行分区,但是有多个二级索引的数据可能需要大量的跨分区协调(请参阅第206页的“分区和二级索引”)。
|
||||
|
||||
#### 串行执行摘要
|
||||
#### 串行执行小结
|
||||
|
||||
事务的串行执行已成为在一定的约束条件下实现可序列化的隔离的一种可行方法:
|
||||
在特定约束条件下,串行执行事务已经成为一种实现可序列化隔离等级的可行办法。
|
||||
|
||||
- 每笔事务都必须小而快,因为只需一个缓慢的事务即可拖延所有事务处理。
|
||||
- 仅限于使用活动数据集可以放入内存的情况。很少访问的数据可能会被移动到磁盘,但是如果需要在单线程事中访问,系统会变得非常慢[^x]。
|
||||
- 写入吞吐量必须足够低才能在单个CPU内核上处理,否则事务需要进行分区而不需要跨分区协调。
|
||||
- 交叉分区事务是可能的,但是它们的使用程度有很大的限制。
|
||||
- 每个事务都必须小而快,因为只要一个缓慢的事务即可拖慢所有事务处理。
|
||||
- 仅限于活跃数据集可以放入内存的情况。很少访问的数据可能会被移动到磁盘,但如果需要在单线程执行的事务中访问,系统就会变得非常慢[^x]。
|
||||
- 写入吞吐量必须低到能在单个CPU核上处理,如若不然,事务需要能划分至单个分区,且不需要跨分区协调。
|
||||
- 跨分区事务是可能的,但是它们的使用程度有很大的限制。
|
||||
|
||||
[^x]: 如果事务需要访问不在内存中的数据,最好的解决方案可能是放弃事务,异步地将数据提取到内存中,同时继续处理其他事务,然后在数据加载时重新启动事务。这种方法被称为反高速缓存,正如前面在第88页“将所有内容保存在内存”中所述。
|
||||
[^x]: 如果事务需要访问不在内存中的数据,最好的解决方案可能是中止事务,异步地将数据提取到内存中,同时继续处理其他事务,然后在数据加载完毕时重新启动事务。这种方法被称为**反缓存(anti-caching)**,正如前面在第88页“将所有内容保存在内存”中所述。
|
||||
|
||||
### 两相锁定(2PL)
|
||||
### 两阶段锁(2PL)
|
||||
|
||||
大约30年来,在数据库中只有一种广泛使用的可串行化算法:双相锁定(2PL)
|
||||
大约30年来,在数据库中只有一种广泛使用的序列化算法:**两阶段锁(2PL,two-phase locking)**[^xi]
|
||||
|
||||
[^xi]: 有时也称为严格两阶段锁(SS2PL, strict two-phas locking),以便和其他2PL变体区分。
|
||||
|
||||
> #### 2PL不是2PC
|
||||
>
|
||||
> 请注意,虽然两阶段锁定(2PL)听起来非常类似于两阶段提交(2PC),但它们是完全不同的东西。我们将在第9章讨论2PC。
|
||||
> 请注意,虽然两阶段锁(2PL)听起来非常类似于两阶段提交(2PC),但它们是完全不同的东西。我们将在第9章讨论2PC。
|
||||
|
||||
之前我们看到锁通常用于防止脏写入(请参阅“无脏写”一节第217页):如果两个事务同时尝试写入同一个对象,则锁可确保第二个写入器必须等到第一个写入器完成事务(中止或承诺),然后才能继续。
|
||||
之前我们看到锁通常用于防止脏写(参阅“[无脏写]()”一节):如果两个事务同时尝试写入同一个对象,则锁可确保第二个写入必须等到第一个写入完成事务(中止或提交),然后才能继续。
|
||||
|
||||
两相锁定类似,但使锁定要求更强。只要没有人写信,就允许多个事务同时读取同一个对象。但只要有人想写(修改或删除)对象,就需要独占访问权限:
|
||||
两相锁定类似,但使锁的要求更强。只要没有写入,就允许多个事务同时读取同一个对象。但对象只要有写入(修改或删除),就需要**独占访问(exclusive access)**权限:
|
||||
|
||||
- 如果事务A读取了一个对象,并且事务B想要写入该对象,那么B必须等到A提交或中止才能继续。 (这确保B不能在A后面意外地改变对象。)
|
||||
- 如果事务A写入了一个对象,并且事务B想要读取该对象,则B必须等到A提交或中止才能继续。 (如图7-1所示,读取旧版本的对象在2PL下是不可接受的。)
|
||||
- 如果事务A读取了一个对象,并且事务B想要写入该对象,那么B必须等到A提交或中止才能继续。 (这确保B不能在A底下意外地改变对象。)
|
||||
- 如果事务A写入了一个对象,并且事务B想要读取该对象,则B必须等到A提交或中止才能继续。 (像[图7-1]()那样读取旧版本的对象在2PL下是不可接受的。)
|
||||
|
||||
在第二方物流中,作家不只是阻碍其他作家,他们也阻挡读者,反之亦然。快照隔离使得读者永远不会阻止写入者,编写者也不会阻止读取者(请参阅“实施快照隔离”在本页221),该功能捕获快照隔离和两阶段锁定之间的这一关键区别。另一方面,因为2PL提供了可串行性,它可以防止早先讨论的所有竞争条件,包括丢失更新和写入歪斜。
|
||||
在2PL中,写入不仅会阻塞其他写入,也会阻塞读,反之亦然。快照隔离使得**读不阻塞写,写也不阻塞读**(参阅“[实现快照隔离](#实现快照隔离)”),这是2PL和快照隔离之间的关键区别。另一方面,因为2PL提供了可序列化的性质,它可以防止早先讨论的所有竞争条件,包括丢失更新和写入偏差。
|
||||
|
||||
#### 执行两阶段锁定
|
||||
#### 实现两阶段锁
|
||||
|
||||
2PL用于MySQL(InnoDB)和SQL Server中的可序列化隔离级别,以及DB2中的可重复读取隔离级别[23,36]。
|
||||
2PL用于MySQL(InnoDB)和SQL Server中的可序列化隔离级别,以及DB2中的可重复读隔离级别[23,36]。
|
||||
|
||||
[^xi]: 有时被称为强有力的严格的两阶段锁定(SS2PL),以区别于2PL的其他变种。
|
||||
|
||||
读写器的阻塞是通过锁定数据库中的每个对象来实现的。锁可以处于共享模式或独占模式。锁使用如下:
|
||||
读与写的阻塞是通过为数据库中每个对象添加锁来实现的。锁可以处于**共享模式(shared mode)**或**独占模式(exclusive mode)**。锁使用如下:
|
||||
|
||||
- 如果事务要读取对象,则必须先以共享模式获取锁。允许多个事务同时保持共享模式下的锁定,但是如果另一个事务已经在对象上拥有独占锁定,则这些事务必须等待。
|
||||
- 如果一个事务要写入一个对象,它必须首先以独占模式获取该锁。没有其他事务可以同时持有锁(共享或独占模式),所以如果对象上存在任何锁,事务必须等待。
|
||||
- 如果事务首先读取并写入对象,则可能会将其共享锁升级为独占锁。升级工作与直接获得排他锁相同。
|
||||
- 事务获得锁之后,必须继续保持锁直到事务结束(提交或中止)。这就是“两阶段”这个名字的来源:第一阶段(当事务正在执行时)是获取锁的时间,第二阶段(在事务结束时)是所有的锁被释放。
|
||||
- 若事务要读取对象,则须先以共享模式获取锁。允许多个事务同时持有共享锁。但如果另一个事务已经在对象上持有排它锁,则这些事务必须等待。
|
||||
- 若事务要写入一个对象,它必须首先以独占模式获取该锁。没有其他事务可以同时持有锁(无论是共享模式还是独占模式),所以如果对象上存在任何锁,该事务必须等待。
|
||||
- 如果事务先读取再写入对象,则它可能会将其共享锁升级为独占锁。升级锁的工作与直接获得排他锁相同。
|
||||
- 事务获得锁之后,必须继续持有锁直到事务结束(提交或中止)。这就是“两阶段”这个名字的来源:第一阶段(当事务正在执行时)获取锁,第二阶段(在事务结束时)释放所有的锁。
|
||||
|
||||
由于使用了这么多的锁,因此事务A可能很容易发生,等待事务B释放它的锁,反之亦然。这种情况叫做死锁。数据库自动检测事务之间的死锁,并中止其中的一个,以便其他人可以取得进展。被中止的事务需要被应用程序重试。
|
||||
由于使用了这么多的锁,因此很可能会发生:事务A等待事务B释放它的锁,反之亦然。这种情况叫做**死锁(Deadlock)**。数据库会自动检测事务之间的死锁,并中止其中一个,以便另一个继续执行。被中止的事务需要由应用程序重试。
|
||||
|
||||
#### 二阶段锁定的性能
|
||||
|
||||
两阶段锁定的大缺点以及自1970年代以来没有被大家使用的原因是性能:两阶段锁定下的查询的事务吞吐量和响应时间要比弱隔离下的要严重得多。
|
||||
二阶段锁定的巨大缺点,以及70年代以来没有被所有人使用的原因,是其性能问题。两阶段锁定下的事务吞吐量与查询响应时间要比弱隔离级别下要差得多。
|
||||
|
||||
这部分是由于获取和释放所有这些锁的开销,但更重要的是由于并发性的降低。按照设计,如果两个并发事务试图做任何可能导致竞争条件的事情,那么必须等待另一个完成。
|
||||
这一部分是由于获取和释放所有这些锁的开销,但更重要的是由于并发性的降低。按照设计,如果两个并发事务试图做任何可能导致竞争条件的事情,那么必须等待另一个完成。
|
||||
|
||||
传统的关系数据库不限制事务的持续时间,因为它们是为等待人类输入的交互式应用而设计的。因此,当一个事务需要等待另一个事务时,可以等待多长时间没有限制。即使你保证你所有的事务都是短暂的,如果有多个事务想要访问同一个对象,那么可能会形成一个队列,所以事务可能需要等待几个其他事务才能完成。
|
||||
传统的关系数据库不限制事务的持续时间,因为它们是为等待人类输入的交互式应用而设计的。因此,当一个事务需要等待另一个事务时,等待的时长并没有限制。即使你保证所有的事务都很短,如果有多个事务想要访问同一个对象,那么可能会形成一个队列,所以事务可能需要等待几个其他事务才能完成。
|
||||
|
||||
因此,运行2PL的数据库可能具有相当不稳定的等待时间,如果在工作负载中存在争用,那么在高百分比情况下它们可能非常慢(请参阅第13页的“描述性能”)。它可能只需要一个缓慢的事务,或者一个访问大量数据并获取许多锁的事务,导致系统的其他部分停下来。当需要稳健的操作时,这种不稳定性是有问题的。
|
||||
因此,运行2PL的数据库可能具有相当不稳定的延迟,如果在工作负载中存在争用,那么可能高百分位点处的响应会非常的慢(参阅“[描述性能](ch1.md#描述性能)”)。可能只需要一个缓慢的事务,或者一个访问大量数据并获取许多锁的事务,就能把系统的其他部分拖慢,甚至迫使系统停机。当需要稳健的操作时,这种不稳定性是有问题的。
|
||||
|
||||
虽然基于锁定的读取提交隔离级别可能发生死锁,但在2PL可序列化隔离(取决于事务的访问模式)下,它们发生得更为频繁。这可能是一个额外的性能问题:当事务由于死锁而被中止并被重试时,它需要重新做它的工作。如果僵局频繁,这可能意味着重大的浪费。
|
||||
基于锁实现的读已提交隔离级别可能发生死锁,但在基于2PL实现的可序列化隔离级别中,它们会出现的频繁的多(取决于事务的访问模式)。这可能是一个额外的性能问题:当事务由于死锁而被中止并被重试时,它需要从头重做它的工作。如果死锁很频繁,这可能意味着巨大的浪费。
|
||||
|
||||
#### 预测锁
|
||||
#### 谓词锁
|
||||
|
||||
在前面关于锁的描述中,我们掩盖了一个微妙而重要的细节。在第250页的“导致写入歪斜的幻像”中,我们讨论了一些性能问题,即一个事务改变另一个事务的搜索查询的结果。具有可序列化隔离的数据库必须防止幻像。
|
||||
在前面关于锁的描述中,我们掩盖了一个微妙而重要的细节。在第250页的“[导致写入偏差的幻读](#导致写入偏差的幻读)”中,我们讨论了**幻读(phantoms)**的问题。即一个事务改变另一个事务的搜索查询的结果。具有可序列化隔离级别的数据库必须防止**幻读**。
|
||||
|
||||
在会议室预订的例子中,这意味着如果一个事务在某个时间窗口内搜索了一个房间的现有预订(见例7-2),则另一个事务不能同时插入或更新同一房间的另一个预订,时间范围。 (可以同时插入其他房间的预订,或在不影响预定预订的不同时间的同一房间)。
|
||||
在会议室预订的例子中,这意味着如果一个事务在某个时间窗口内搜索了一个房间的现有预订(见例7-2),则另一个事务不能同时插入或更新同一时间窗口与同一房间的另一个预订 (可以同时插入其他房间的预订,或在不影响另一个预定的条件下预定同一房间的其他时间段)。
|
||||
|
||||
我们如何实现这一点?从概念上讲,我们需要一个谓词锁定[3]。它类似于前面描述的共享/排它锁,但不属于特定的对象(例如,表中的一行),它属于所有符合某些搜索条件的对象,如:
|
||||
如何实现这一点?从概念上讲,我们需要一个**谓词锁(predicate lock)**[3]。它类似于前面描述的共享/排它锁,但不属于特定的对象(例如,表中的一行),它属于所有符合某些搜索条件的对象,如:
|
||||
|
||||
```sql
|
||||
SELECT *
|
||||
FROM bookings
|
||||
SELECT * FROM bookings
|
||||
WHERE room_id = 123 AND
|
||||
end_time > '2018-01-01 12:00' AND start_time < '2018-01-01 13:00';
|
||||
end_time > '2018-01-01 12:00' AND
|
||||
start_time < '2018-01-01 13:00';
|
||||
```
|
||||
|
||||
谓词锁限制访问,如下所示:
|
||||
|
||||
- 如果事务A想要读取匹配某些条件的对象,就像在该SELECT查询中那样,它必须获取对查询条件的共享模式谓词锁定。如果另一个事务B当前对与这些条件匹配的任何对象具有排他锁,那么A必须等到B释放它的锁之后才允许它进行查询。
|
||||
- 如果事务A想要读取匹配某些条件的对象,就像在这个`SELECT`查询中那样,它必须获取查询条件上的**共享谓词锁(shared-mode predicate lock)**。如果另一个事务B持有任何满足这一查询条件对象的排它锁,那么A必须等到B释放它的锁之后才允许进行查询。
|
||||
- 如果事务A想要插入,更新或删除任何对象,则必须首先检查旧值或新值是否与任何现有的谓词锁匹配。如果事务B持有匹配的谓词锁,那么A必须等到B已经提交或中止后才能继续。
|
||||
|
||||
这里的关键思想是,谓词锁定甚至适用于数据库中尚不存在的对象,但可能在将来添加(幻像)对象。如果两阶段锁定包含谓词锁定,则数据库将阻止所有形式的写入歪斜和其他竞争条件,因此其隔离可以串行化。
|
||||
这里的关键思想是,谓词锁甚至适用于数据库中尚不存在,但将来可能会添加的对象(幻象)。如果两阶段锁定包含谓词锁,则数据库将阻止所有形式的写入偏差和其他竞争条件,因此其隔离实现了可串行化。
|
||||
|
||||
#### 索引范围的锁
|
||||
#### 索引范围锁
|
||||
|
||||
不幸的是,谓词锁定性能不佳:如果活动事务中存在许多锁定,则检查匹配的锁定会变得非常耗时。因此,大多数使用2PL的数据库实际上实现了索引范围锁定(也称为下一个锁定),这是一个简化的谓词锁定近似[41,50]。
|
||||
不幸的是谓词锁性能不佳:**如果活跃事务持有很多锁,检查匹配的锁会非常耗时。**因此,大多数使用2PL的数据库实际上实现了索引范围锁(也称为**间隙锁(next-key locking)**),这是一个简化的近似版谓词锁[41,50]。
|
||||
|
||||
通过使谓词与更多的对象相匹配来简化谓词是安全的。例如,如果在中午和下午1点之间预订123号房间的谓词锁,您可以随时锁定123号房间的预订,或者通过锁定所有房间(不只是123号房间)中午至下午1点这是安全的,因为任何与原始谓词相匹配的书写都肯定会匹配近似值。
|
||||
通过使谓词匹配到一个更大的集合来简化谓词锁是安全的。例如,如果你有在中午和下午1点之间预订123号房间的谓词锁,则锁定123号房间的所有时间段,或者锁定12:00~13:00时间段的所有房间(不只是123号房间)是一个安全的近似,因为任何满足原始谓词的写入也一定会满足这种更松散的近似。
|
||||
|
||||
在房间预订数据库中,您可能会在room_id列上有一个索引,并且/或者在start_time和end_time上有索引(否则前面的查询在大型数据库上的速度会非常慢):
|
||||
在房间预订数据库中,您可能会在`room_id`列上有一个索引,并且/或者在`start_time`和`end_time`上有索引(否则前面的查询在大型数据库上的速度会非常慢):
|
||||
|
||||
- 假设您的索引位于room_id上,并且数据库使用此索引查找123号房间的现有预订。现在数据库可以简单地将共享锁附加到此索引条目,指示事务已搜索123号房间的预订。
|
||||
- 或者,如果数据库使用基于时间的索引来查找现有预订,那么它可以将共享锁附加到该索引中的一系列值,指示事务已搜索与中午的时间段重叠的预订到下午1点2018年1月1日
|
||||
- 假设您的索引位于`room_id`上,并且数据库使用此索引查找123号房间的现有预订。现在数据库可以简单地将共享锁附加到这个索引项上,指示事务已搜索123号房间用于预订。
|
||||
- 或者,如果数据库使用基于时间的索引来查找现有预订,那么它可以将共享锁附加到该索引中的一系列值,指示事务已经将12:00~13:00时间段标记为用于预定。
|
||||
|
||||
无论哪种方式,搜索条件的近似值都附加到其中一个索引上。现在,如果另一个事务想要插入,更新或删除同一个房间和/或重叠时间段的预订,则它将不得不更新索引的相同部分。在这样做的过程中,它会遇到共享锁,它将被迫等到锁被释放。
|
||||
|
||||
这提供了有效的防止幻影和写歪斜。索引范围锁并不像谓词锁那样精确(它们可能会锁定更大范围的对象,而不是维持可串行化所必需的范围),但是由于它们的开销较低,所以它们是一个很好的折衷。
|
||||
这种方法能够有效防止幻读和写入偏差。索引范围锁并不像谓词锁那样精确(它们可能会锁定更大范围的对象,而不是维持可串行化所必需的范围),但是由于它们的开销较低,所以是一个很好的折衷。
|
||||
|
||||
如果在可以连接范围锁的地方没有合适的索引,则数据库可以回退到整个表上的共享锁。这对性能不利,因为它会阻止所有其他事务写入表格,但这是一个安全的回退位置。
|
||||
如果没有可以挂载间隙锁的索引,数据库可以退化到使用整个表上的共享锁。这对性能不利,因为它会阻止所有其他事务写入表格,但这是一个安全的回退位置。
|
||||
|
||||
|
||||
|
||||
### 可串行快照隔离(SSI)
|
||||
|
||||
本章描绘了数据库中并发控制的黯淡画面。一方面,我们实现了不能很好执行的串行化(两阶段锁定)或不能很好地扩展(串行执行)。另一方面,我们有较弱的隔离级别,性能良好,但容易出现各种竞争条件(丢失更新,写歪斜,幻影等)。序列化的隔离和良好的性能从根本上相互矛盾?
|
||||
本章描绘了数据库中并发控制的黯淡画面。一方面,我们实现了性能不好(2PL)或者扩展性不好(串行执行)的可序列化隔离级别。另一方面,我们有性能良好的弱隔离级别,但容易出现各种竞争条件(丢失更新,写入偏差,幻读等)。序列化的隔离级别和高性能是从根本上相互矛盾的吗?
|
||||
|
||||
也许不是:一个称为可序列化快照隔离(SSI)的算法是非常有前途的。它提供了完全的可串行性,但与快照隔离相比,性能损失只有很小的一部分。 SSI是相当新的:它在2008年首次被描述[40],并且是Michael Cahill的博士论文[51]的主题。
|
||||
也许不是:一个称为**可序列化快照隔离(SSI, serializable snapshot isolation)**的算法是非常有前途的。它提供了完整的可序列化隔离级别,但与快照隔离相比只有只有很小的性能损失。 SSI是相当新的:它在2008年首次被描述[40],并且是Michael Cahill的博士论文[51]的主题。
|
||||
|
||||
今天,SSI既用于单节点数据库(自9.1版以来PostgreSQL中的可序列化隔离级别)和分布式数据库(FoundationDB使用类似的算法)。由于SSI与其他并发控制机制相比还很年轻,它在实践中仍然可以证明其性能,但它有可能成为未来的新的违约。
|
||||
今天,SSI既用于单节点数据库(9.1版以来PostgreSQL中的可序列化隔离级别)和分布式数据库(FoundationDB使用类似的算法)。由于SSI与其他并发控制机制相比还很年轻,还处于在实践中证明自己表现的阶段。但它有可能因为足够快而在未来成为新的默认选项。
|
||||
|
||||
#### 悲观与乐观的并发控制
|
||||
|
||||
两阶段锁定是一种所谓的悲观并发控制机制:它是基于如果有什么可能出错(如另一个事务所持有的锁所表示的)的原则,最好等到情况再次安全做任何事情。这就像互斥,用于保护多线程编程中的数据结构。
|
||||
两阶段锁定是一种所谓的**悲观并发控制机制(pessimistic)**:它是基于如果有什么可能出错(如另一个事务所持有的锁所表示的)的原则,最好等到情况再次安全做任何事情。这就像互斥,用于保护多线程编程中的数据结构。
|
||||
|
||||
从某种意义上说,串行执行是极端悲观的:在事务持续期间,每个事务对整个数据库(或数据库的一个分区)具有独占锁定,这基本相当。我们通过使每笔事务执行得非常快来弥补悲观情绪,所以只需要短时间保持“锁定”。
|
||||
从某种意义上说,串行执行可以称为悲观到了极致:在事务持续期间,每个事务对整个数据库(或数据库的一个分区)具有独占锁定,这基本相当。我们通过使每笔事务执行得非常快来弥补悲观情绪,所以只需要短时间保持“锁定”。
|
||||
|
||||
相比之下,可串行化的快照隔离是一种乐观的并发控制技术。在这种情况下乐观意味着,如果发生某种可能的危险,不要阻止事务,反而继续事务,希望一切都会好起来。当一个事务想要提交时,数据库检查是否有什么不好的事情发生(即隔离是否被违反);如果是的话,事务将被中止,并且必须重试。只有可序列化的事务才被允许提交。
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user