数据库事务隔离级别的深坑:默认值应修改为SERIALIZABLE

19-05-04 banq
                   

本文提出将数据库的默认级别修改为可串行化SERIALIZABLE,不用担心性能降低,他们发现在一个设计良好的系统中,SERIALIZABLE和READ COMMITTED之间的性能差异可以忽略不计!

几十年来,数据库系统为用户提供了多种隔离级别可供选择,范围从高端的“可串行化”到低端的“读取提交”或“未提交读取”。这些不同的隔离级别将带来应用程序不同类型的并发错误。尽管如此,许多数据库用户仍然坚持使用他们数据库系统的默认隔离级别,并且不必考虑哪个隔离级别对于他们的应用程序是最佳的,这种做法很危险!

绝大多数广泛使用的数据库系统 - 包括Oracle,IBM DB2,Microsoft SQL Server,SAP HANA,MySQL和PostgreSQL--默认情况下不保证任何可串行化的保证。我们将在下面详细介绍,隔离级别弱于可串行化化可能导致应用程序中的并发错误和负面用户体验,因此数据库用户了解数据库系统保证的隔离级别以及可能出现的并发错误非常重要。

许多数据库用户坚持使用默认的隔离级别......并且不考虑哪种隔离级别对于他们的应用程序是最佳的

在这篇文章中,我们提供了有关数据库隔离级别的教程,较低隔离级别带来的优势,以及这些较低级别可能允许的并发错误类型。我们在这篇文章中的主要关注点是可串行化隔离级别与将应用程序暴露给特定类型导致的并发异常,以及较低级别之间的区别。在可串行化隔离的类别中也存在重要差异(例如,“严格可串行化”产生与“单拷贝可串行化”是不同的一组保证)。

什么是“隔离级别”?

数据库隔离是指数据库允许事务执行的能力,就好像没有其他并发运行的事务一样(即使实际上可能存在大量并发运行的事务)。总体目标是防止对并发事务写入产生的临时,中止或其他不正确数据进行读写操作。

确实有完美隔离这样的东西(我们将在下面定义)。不幸的是,完美性通常会付出性能成本:产生延迟(事务完成之前多长时间)或吞吐量(系统完成每秒事务数)下降。

根据特定系统的架构方式,完美隔离变得更容易或更难实现。在设计不良的系统中,实现完美性会带来令人望而却步的性能成本,并且这些系统的用户将被推动接受明显缺乏完美性的保证。

即使在设计良好的系统中,通过接受不完美的保证通常也可以获得非平凡的性能优势。因此,隔离级别的出现:它们为系统用户提供了在性能与数据正确性之间的权衡能力。

完美的隔离

让我们通过提供“完美”隔离的概念开始讨论数据库隔离级别。我们将上面的隔离定义为数据库系统允许事务执行的能力,就好像没有其他并发运行的事务一样(即使实际上可能是大量并发运行的事务)。在这方面完美是什么意思?乍一看,似乎完美是不可能的。如果两个事务都读取和写入相同的数据项,那么它们相互影响是至关重要的。如果他们互相忽略,那么无论哪个事务完成,写入最后都会破坏第一个事务,从而产生相同的最终状态,就好像它从未运行过一样。

数据库系统是最早的可扩展并发系统之一,并且已成为随后开发的许多其他并发系统的原型。几十年前,数据库系统社区开发了一种非常强大(但可能未被充分认识)的机制来处理实现并发程序的复杂性。

这个想法如下:人类在推理并发方面从根本上是不好的。编写一个无错误的非并发程序是很困难的。但是一旦你添加了并发性,就会出现一个接近无限的竞争条件。几乎不可能不考虑不同线程中的程序执行可能彼此重叠的所有不同方式,以及不同类型的重叠如何导致不同的最终状态。

相反,数据库系统为应用程序开发人员提供了一个漂亮的抽象 - 一个“事务”。事务可能包含任意代码,但它基本上是单线程的。

应用程序开发人员只需要关注事务中的代码 - 以确保在系统中没有其他并发进程运行时它是正确的。给定数据库的任何起始状态,代码不得违反应用程序的语义。确保代码的正确性并非易事,但是当代码单独运行时,确保代码的正确性要比确保代码在与可能尝试读取或写入共享数据的其他代码一起运行时更正确更容易。 

如果应用程序开发人员能够在没有其他并发进程运行时确保其代码的正确性,那么保证完美隔离的系统将确保代码保持正确,即使系统中可能同时运行的其他代码可能会读取或写入相同的数据。

实现这种完美程度听起来很困难,但实际上它实际上相当简单。我们已经假设在没有任何启动状态的并发运行时代码是正确的。因此,如果事务代码是串行运行的 - 一个接一个地运行(串行) - 那么最终状态也将是正确的。

因此,为了实现完美的隔离,所有系统必须做的是确保当事务同时运行时,最终状态等同于系统状态,如果它们是串行运行的,那么它将会实现最终状态等同于系统状态。

有几种方法可以实现这一点 - 例如通过锁定,验证或多版本化,我们的目的的关键点是我们将“完美隔离”定义为系统并行运行事务的能力,但其方式相当于它们一个接一个地运行。在SQL标准中,这种完美的隔离级别称为可串行化。

分布式系统中的隔离级别变得更加复杂。许多分布式系统实现了可串行化隔离级别的变体,例如一个拷贝可串行化(1SR), 严格可串行化(严格1SR)或更新可串行化(US)。

其中,  “严格可串行化”是这些可串行​​​​​​​化选项中最完美的选择。

并发系统中的异常​​​​​​​

SQL标准定义了几个低于可串行化的隔离级别。此外,商业数据库系统中常见的其他隔离级别 - 最明显的是快照隔离 - 不包含在SQL标准中。

在我们讨论这些不同的隔离级别之前,让我们讨论一些众所周知的应用程序错误/异常情况,这些错误可能发生在低于可串行化的隔离级别。我们将使用零售示例来描述这些错误。

假设每当客户购买一个小部件时,就会运行以下“购买”交易:

  1. 读取旧库存
  2. 写入新库存,比第1步中读取的库存要少一个
  3. 将与此次购买相对应的新订单插入到订单表中

如果此类订单交易连续运行,则将始终考虑所有初始库存。如果有42个小部件,那么在任何时候,剩余的所有库存和订单表中的订单的总和应该是42。(banq注:等同于DDD的聚合不变性)

但是,如果这个事务以低于可串行化的隔离级别同时运行呢?

例如,假设两个并发运行的事务读取相同的初始库存(42),然后两者都尝试写入除了新订单之外的一个小于他们读取的值(41)的新库存。

在这种情况下,最终状态是41的库存,但订单表中有两个新订单(总共有43个小部件被占用)。我们创造了一个无中生有的小部件!显然,这是一个错误。它被称为丢失更新异常。 

作为另一个例子,假设这两个相同的事务同时运行,但这次第二个事务在第一个事务的步骤(2)和(3)之间开始。在这种情况下,第二个事务在减少后读取库存的值 - 即它读取值41并将其减少到40,并写入订单。与此同时,第一笔交易在写入订单时中止(例如由于信用卡下降)。

在这种情况下,在中止过程中,第一个事务在它开始之前(当库存为42时)恢复到数据库的状态。因此,最终状态是42的库存,并且写入一个订单(来自第二个交易)。这一次,我们又创造了一个无中生有的小部件!这被称为脏写异常 (因为第二个事务在决定是否提交或中止之前覆盖了第一个事务的写入值)。

作为第三个例子,假设一个单独的事务执行库存和订单表的读取,以便对所有存在的小部件进行计算。如果它在购买事务的步骤(2)和(3)之间运行,它将看到数据库的临时状态,其中窗口小部件已从清单中消失,但尚未作为订单出现。看起来小部件已经丢失 - 我们的应用程序中的另一个错误。这被称为脏读异常,因为允许事务读取购买交易的临时(不完整)状态。

作为第四个例子,假设一个单独的事务检查库存并在剩下少于10个小部件的情况下获取更多小部件:

  1. IF (READ(Inventory) = (10 OR 11 OR 12))运送一些新的小部件以通过标准运输补充库存
  2. IF (READ(Inventory) < 10)通过快递运送一些新的小部件来补充库存

请注意,此事务会两次读取库存。如果购买交易在此交易的步骤(1)和(3)之间运行,则每次将读取不同的库存值。如果在购买交易之前的初始库存是10,这将导致相同的重新进货请求进行两次 - 标准运输一次和快递一次。这称为不可重复的读异常

作为第五个例子,假设一个事务扫描订单表以计算订单的最高价格,然后再次扫描以查找平均订单价格。在这两次扫描之间,插入了一个非常昂贵的订单,使得平均值偏差太大,以至于它变得高于前一次扫描中找到的最高价格。此交易返回的平均价格大于最高价格 - 显然是不可能的,并且在可序列化系统中永远不会发生错误。这个错误与不可重复的读取异常略有不同,因为事务读取的每个值在两次扫描之间保持不变 - 错误的来源是在这两次扫描之间插入了额外的记录。这被称为幻像读异常

作为最后一个示例,假设应用程序允许小部件的价格根据库存进行更改。例如,随着航班库存的减少,许多航空公司提高了机票价格。假设应用程序使用公式来约束这两个变量如何相互关联 - 例如I + P> = $ 500(其中I是库存而P是价格)。在允许购买成功之前,购买事务必须检查库存和价格,以确保不违反上述约束。如果不违反约束,则可以继续通过该购买事务更新库存。

同样,实施特殊促销折扣的单独交易可以检查库存和价格,以确保在作为促销的一部分更新价格时不违反约束。如果不违反,价格可以更新。

现在,假设这两个交易同时运行 - 它们都读取了I和P的旧值,并独立地决定它们的库存和价格更新分别不会违反约束。因此,他们继续进行更新。不幸的是,这可能会导致I和P的新值违反约束!如果一个在另一个之前运行,第一个将成功,另一个将在第一个完成后读取I和P的值,并检测到它们的更新将违反约束,因此不会继续。但由于它们同时运行,它们都会看到旧值并错误地决定它们可以继续进行更新。这个错误称为写偏斜异常 因为它发生在两个事务读取相同数据但更新已读取数据的不相交子集时。

ISO SQL标准中的定义

SQL标准根据哪些异常可能来定义降低的隔离级别。特别是,它包含下表:

隔离级别                           脏读     不可重复读   幻影读
READ UNCOMMITTED                     可能     可能        可能
READ COMMITTED                       不可能    可能        可能   
重复读                             不可能   不可能       可能
SERIALIZABLE串行化                     不可能   不可能      不可能

SQL标准如何定义这些隔离级别有很多很多问题。大多数这些问题已经在1995年指出,但是莫名其妙地,修改SQL标准之后的修订已经从那时开始发布而没有解决这些问题。

第一个问题 是标准只使用三种类型的异常来定义每个隔离级别 - 脏读,不可重复读和幻读。但是,有许多类型的并发错误还是能在实践中出现。

 

第二个问题是,使用异常来定义隔离级别只会让最终用户能够确保哪些特定类型的并发错误是不可能的。它没有给出任何特定事务可见的潜在数据库状态的精确定义。在学术文献中给出了几种改进的和更精确的隔离水平定义。Atul Adya的博士论文基于如何交错来自不同事务的读写,给出了SQL标准隔离级别的精确定义。然而,从系统的角度给出了这些定义。在最近由娜塔莎Crooks等工作。从用户的角度来看,al给出了优雅和精确的定义。

第三个问题是标准没有定义,也没有对实际使用的最流行的降低隔离级别之一提供正确性约束:快照隔离(也没有任何多种变体 - PSINMSIRead Atomic等)。由于未能提供快照隔离的定义,因此跨系统出现了快照隔离所允许的并发漏洞的差异。通常,快照隔离执行从包含仅提交数据的数据库状态的特定快照开始的所有数据读取。此快照在事务的整个生命周期内保持不变,因此保证所有读取都是可重复的(除了仅提交数据)。此外,写入相同数据的并发事务检测到彼此冲突,并且通常通过中止其中一个冲突事务来解决此冲突。这可以防止丢失更新异常。但是,只有在冲突的事务写入重叠的数据集时才会检测到冲突。如果写集是不相交的,这些冲突将无法被发现。因此,快照隔离容易受到写入偏斜异常的影响。某些实现也容易受到幻像读取异常的影响。

第四个问题是SQL标准似乎给出了串行化SERIALIZABLE隔离级别的两个不同定义。首先,它正确定义了SERIALIZABLE:最终结果必须等同于没有并发时可能发生的结果。但是,它提供了上面的表,这似乎意味着只要隔离级别就能够实现不允许脏读,不可重复读或幻读,它就可以称为串行化SERIALIZABLE?

Oracle历来利用这种模糊性来证明其实现快照隔离“SERIALIZABLE”的合理性。说实话,我认为大多数读过ISO  SQL标准的人 我们会相信前面给出的更准确的SERIALIZABLE定义(这是正确的)是作者的意图。

尽管如此,我想甲骨文的律师已经对它进行了研究,并确定该文件中有足够的模糊性来合法地证明他们依赖其他定义。

最重要的是:几乎不可能给出应用程序开发人员可用的实际隔离级别的明确定义,因为SQL标准中的模糊性和模糊性导致了跨实现/系统的语义差异。 

你应该选择什么样的隔离级别?

对应用程序员的建议如下:降低隔离级别是危险的!很难弄清楚哪些并发错误可能会出现。

如果每个系统使用Crooks等的方法定义它们的隔离级别,至少你会对其相关保证有一个精确和正式的定义。遗憾的是,对于大多数数据库用户来说,Crooks论文的形式主义过于先进,因此数据库供应商不太可能很快在他们的文档中采用这些形式。

降低隔离水平是危险的......降低隔离水平的定义在实践中仍然含糊不清,使用风险很大

正确的选择通常是避免比可串行化隔离更低的隔离级别!对于绝大多数数据库系统,您实际上必须更改默认值才能完成此任务!

但是,有三个警告:

  1. 正如我上面提到的,一些系统使用“SERIALIZABLE”这个词来表示比真正的可串行化隔离更弱的东西。不幸的是,这意味着只需在数据库系统中选择表面上的“SERIALIZABLE隔离级别”可能在实际中不能确保可串行化。您需要检查文档以确保它以下列方式定义SERIALIZABLE:数据库的可见状态始终等效于在没有并发时可能发生的状态。否则,您的应用程序可能容易受到写入偏斜异常的影响。
  2. 如上所述,可串行化隔离级别带来性能成本。根据系统架构的质量,可串行化的性能成本可能很大或很小。在我最近与Jose Faleiro和Joe Hellerstein一起撰写的一篇研究论文中,我们发现在一个设计良好的系统中,SERIALIZABLE和READ COMMITTED之间的性能差异可以忽略不计......在某些情况下,SERIALIZABLE隔离级别可能是(令人惊讶的是)优于READ COMMITTED隔离级别。如果您发现系统中可序列化隔离的成本过高,那么您应该考虑使用不同于您考虑降低隔离级别的数据库系统。
  3. 在分布式系统中,即使在可串行化的隔离级别中,也可能(并且确实)出现重要的异常。对于这样的系统,重要的是要理解可序列化隔离类的元素之间的细微差别(已知严格的可串行化是最安全的)。我们将在以后的文章中阐述这个问题。

                   

2