1. 数据库系统揭秘-Part 1:事务隔离级别(Transaction Isolationi Levels)的简介

Part-1: An Introduction to Transaction Isolation Levels

  • 原文链接:https://fauna.com/blog/introduction-to-transaction-isolation-levels
  • 一些术语的翻译:
    • Serializability/Serializable:可串行性、序列化
    • Anomaly:异常

几十年来,数据库系统为用户提供了多种隔离级别可供选择,从最高级别的 "可串行化Serializability "到低级别的 "读已提交 Read Commited "或 “读未提交 Read Commited”。这些不同的隔离级别使应用程序面临明显不同类型的并发错误。尽管如此,许多数据库用户还是坚持使用数据库系统所提供的默认隔离级别,而不去考虑哪种隔离级别最适合他们的应用程序。这种做法是危险的–绝大多数广泛使用的数据库系统,包括 Oracle、IBM DB2、Microsoft SQL Server、SAP HANA、MySQL 和 PostgreSQL,默认情况下都不保证可串行化。正如我们将在下文详述的那样,隔离级别弱于Serializability可能会导致应用程序出现并发错误,并给用户带来负面体验。数据库用户必须了解数据库系统所保证的隔离级别,以及因此可能出现的并发错误,这一点非常重要。
许多数据库用户坚持使用默认隔离级别…而懒得考虑哪种隔离级别最适合其应用程序.

在这篇文章中,我们将介绍数据库隔离级别、较低隔离级别带来的优势以及这些较低级别可能允许的并发错误类型。本篇文章的重点是可串行化的隔离级别与较低级别的隔离等级之间的区别,主要是使用这些低级别隔离等级可能面临的各种并发异常。(补充:最高级别的可串行化隔离等级,其实也可细分为更细的类别,例如Strict-Serializability and One-Copy Serializability,它们同样会面临到一些更复杂的并发异常问题,但并非本文的重点,在这里先将它们归为一类。)
不过,为了与本篇 "介绍/Introduction "数据库隔离级别的文章标题保持一致,我们将忽略可串行化隔离类别中的细微差别,而将重点放在该类别中所有元素的共性上–将这种共性称为 “可串行化”。在今后的一篇介绍性文章中,我们将更详细地研究可序列化隔离类别。

什么是隔离级别(Isolation Levels)

数据库隔离性是指数据库允许一个事务在执行时像没有其他并发运行的事务一样执行的能力(尽管实际上可能有大量并发运行的事务)。其总体目标是防止对由并发事务写入的临时数据、中止数据或其他错误数据的读取和写入。
的确有方法能实现“完美”的隔离(将在下文中给出定义)。但遗憾的是,完美通常需要付出性能代价–在事务延迟(事务完成的时间)或吞吐量(系统每秒能完成多少事务)方面。根据特定系统的架构,实现完美隔离会变得更容易或更困难。在设计不佳的系统中,实现完美隔离伴随着过高的性能代价,因此使用这类系统的用户不得不接受远低于完美隔离的保证。然而,即使在设计良好的系统中,接受不完美的保证往往也能带来显著的性能优势。因此,隔离级别应运而生:它们为系统用户提供了以提高性能换取隔离保证的能力。(即用户可以主动选择,在性能和隔离保证上做出权衡)

完美隔离的定义(Perfect Isolation)

在开始讨论数据库隔离级别时,我们先来了解一下什么是 "完美 "隔离。我们在上文将隔离(Isolation)定义为数据库系统允许一个事务“串行”执行的能力,就好像没有其他并发运行的事务一样(尽管现实中可能有大量并发运行的事务)。在这方面,完美意味着什么?乍一看,完美似乎是不可能的。因为如果两个事务要同时读写同一个数据项(data item),那么它们必须会相互影响。如果它们相互忽视,那么最后完成写操作的事务可能会覆盖第一个事务的结果,导致最终状态就像第一个事务从未运行过一样。

数据库系统是最早的可扩展并发系统之一,也是后来开发的许多其他并发系统的原型。数据库系统社区在几十年前就开发出了一种极其强大(并且可能还不够被重视)的机制,用于处理实现并发程序的复杂性。
这个想法如下:人类在并发推理方面基本上是不擅长的。编写一个无错误的非并发程序已经足够难了。但一旦你添加了并发,就会出现几乎无限多的竞态条件——举例:如果第一个线程到达程序的第17行时,第二个线程已经执行完第3行但还没有执行到第5行,就可能发生一个问题。这个问题在其他任何并发执行的情况下都可能不存在,只有在这种特定的执行顺序下才会出现。要考虑不同线程中程序执行的所有不同重叠方式,以及不同类型的重叠如何导致不同的最终状态,几乎是不可能的。

The idea is as follows: human beings are fundamentally bad at reasoning about concurrency.

相反,数据库系统为应用程序开发人员提供了一个漂亮的抽象概念–“事务/Transaction”。一个事务可以包含任意代码,但从根本上说是单线程的。

应用程序开发人员只需关注事务中的代码:即确保在系统中没有其他并发进程运行时该事物的代码是正确的。在数据库的任何起始状态下,代码都不能违反应用程序的语义。确保代码的正确性并非易事,但当代码独立运行时保证其正确性要比在可能尝试读取或写入共享数据的其他代码并行运行时确保其正确性容易得多。

A transaction may contain arbitrary code, but it is fundamentally single-threaded

如果应用程序开发人员能够确保其代码在没有其他并发进程运行时的正确性,那么即使系统中存在其他可能读写相同数据的并发运行代码,保证完美隔离的系统也能确保代码在并发场景的正确性。换句话说,数据库系统能让开发人员在编写代码时不必担心潜在并发性的复杂性,同时还能并发处理这些代码,而不会引入新的错误或违反应用程序的语义。

实现这种程度的完美听起来很难,但实际上实现起来相当简单。我们已经假设,代码在任何起始状态下不并发运行时都是正确的。因此,只要串行运行每个事务(一个接一个),那么最终的状态也将是正确的。因此,为了实现完美隔离,系统所要做的就是确保事务并发运行时,最终状态等同于事务串行运行时的系统状态。有几种方法可以实现这一目标,例如如锁定(Locking)、验证(Validation)或多版本(Multi-Versioning),但不在本文讨论范围之内。我们的目的是对 "完美隔离 "的定义:系统拥有并行运行事务的能力,而且其方式(最终的状态)就好像事务一个接一个串行地运行。在 SQL 标准中,这种完美的隔离级别被称为可串行化/Serializability。

  • 补充:
    • 可序列化 = 可串行化
    • 分布式系统中的隔离级别变得更加复杂。许多分布式系统实现了可序列化隔离级别的各种变体,如One-Copy可序列化(1SR)、严格可序列化(严格 1SR)或更新可序列化(US)。其中,"严格可序列化 "是最完美的可序列化选项。不过,正如我们在前面提到的,为了将本篇文章的讨论重点放在数据库隔离背后的核心概念上,我们将把这些更高级的概念推迟到以后的文章中讨论。
并发系统中可能遇到的异常:Anomalies in Concurrent Systems

SQL标准定义了几个低于可串行化的隔离级别。此外,商业数据库系统中通常还有其他隔离级别——最值得注意的是快照隔离——这些并没有包括在SQL标准中。在讨论这些不同的隔离级别之前,让我们先讨论一些在低于可串行化的隔离级别下可能发生的一些众所周知的应用程序错误/异常。我们将使用零售示例来描述这些错误。

  • 假设每当客户在购买一个小部件时,就会运行下面的 "购买 "事务:
    • 购买事务/Purchase Transaction:
      • 1)Read old Inventory
        • 读旧的库存数目
      • 2)Write new inventory which is one less than what was read in step 1
        • 将第一步读到的库存数目减1,然后写回为新的库存数目
      • 3)Insert a new order corresponding to this purchase into the orders table
        • 在订单表中新建(Insert)一个与该采购相对应的新订单

如果这样的购买事务串行运行,那么所有初始库存将始终得到核算(保持一致性)。如果我们起初有42个小部件,那么在任何时候,剩余的所有库存加上订单表中的订单总和将是42。

但是,如果这些事务在低于可序列化的隔离级别上并发运行呢?会发生什么?

  • 以下为举例以及可能遇到的异常问题
    • Lost-Update Anomaly,更新丢失异常
      • 假设两个购买事务并发执行,读取到了相同的库存数目42,然后都尝试写回新的库存数目41,并创建新的订单数据项
      • 在这种情况下,库存数目的最终状态还是41,但订单表中却新增了两条订单
      • 库存+订单 的数目大于了42,这时即出现了更新丢失异常,先执行完的事务修改的值被后执行完的事务所覆盖
    • Dirty-write Anomaly,脏写异常
      • 现在,还是假设两个购买事务并发执行,但这次第二个事务开始于第一个事务的 2)和 3)步骤之间
      • 这种情况下,第二个事务读取到了第一个事务修改后的库存数目值41,并将其修改为41,然后写回,并创建订单
      • 在此期间,第一个事务在创建订单时(因为某些原因)而中止,在这种情况下,事务触发回滚操作,又将第二步修改的库存数目恢复为42。最终,库存数目为42,而 库存+订单大于了42,出现了不一致性
        • 也可能为41,即第二个事务也中止,触发回滚,将40恢复期它所读到的旧值41
      • 这种异常被称为脏写异常,因为第二个事务在决定提交还是中止之前,会覆盖第一个事务的写入值
    • Dirty-read Anomaly,脏读异常
      • 第三个例子,假设现在有一个新的事务:both read 库存表和订单表,以便进行统计
        • 这个事务开始于一个购买事务的2/3步骤之间
      • 这时,这个小部件统计事务就会看到一个临时的数据库状态:库存数目减少了1,但并没有产生对应的订单
      • 这被称为脏读异常,原因在于统计事务被允许读取购买事务执行过程中产生的临时(incomplete)状态
    • Non-repeatable Read Anomaly,不可重复读异常
      • 第四个例子,假设又有一个新的事务:检查部件库存数目并进行不同的补货策略
        • 检查补货事务/Check&Restock Transaction:
            1. IF (READ(Invetory) = (10 or 11 or 12)) :下单通过慢递补货
            1. IF (READ(Invetory) < (10 or 11 or 12)) :下单通过快递补货
        • 需要注意,这个事务需要对Inventory进行两次读操作
          • 如果购买事务恰好发生在补货事务两个步骤之间,那么这两次读操作就会读到不同的值
            • 如果购买事务发生前初始库存为10,那么该事物将触发两次补货请求,并且分别是通过慢递和快递
      • 该异常被称为不可重复读异常
    • Phantom read Anomaly,幻读异常
      • 第五个例子,假设有一个新的事务:对订单表进行两次扫描操作
        • 1)第一次扫描寻找所有订单中成交金额最大的值 maximum price
        • 2)第二次扫描寻找计算所有订单的平均价格 average price
      • 如果在一个事务进行两次扫描计算操作之间,产生了一条具有极大成交金额的订单
        • 这时返回的平均成交价格反而大于了第一次扫描计算得到的最大成交价格
        • 这是一个明显的bug,在串行化系统中是不会发生的
        • 并且bug产生的原因与不可重复读异常并不相同,因为两次扫描的每个值都没有发生变化
      • bug产生的原因在于新增了一条额外的记录,这被称为幻读异常
    • Write-Skew Anomaly,写偏离异常
      • 最后一个例子场景,假设该应用可以根据Inventory中库存数量调整商品价格。
        • 假设一个事务会根据库存量的减少而对价格进行一定的上浮,e.g. 10I + P >= $500, I means inventory and P means Price。在进行一次购买事务前,会以这个式子进行检查,确保没有违反,然后购买事务成功后会对Inventory进行更新
        • 同时,有另外一个促销折扣事务也会同时读取Inventory、Price 的值,然后在不违反约束条件的前提下,对价格Price进行更新。
      • 这时,如果这两个事务并发同时执行,它们分别读取到了 I、P的旧值,然后都通过了检查并对I、P的值进行了更新。不幸的是,它们分别更新后的I、P值导致了约束条件的违反。
        • 而如果是串行执行,后一个事务会读到前一个事务所修改的新值,从而决定不更新数据
        • 但因为它们并发执行,同时读取到了相同的旧值,从而做出错误的更新决策
      • 这个错误被称为写偏斜异常,因为它会发生在两个事务读取相同的数据,但更新的数据子集却不相连的情况下。
ISO SQL 标准中对隔离级别的定义

SQL 标准根据以上可能出现的异常情况定义了逐级降低的隔离级别。具体而言,它包含以下几个级别:

  • Table 1: Isolation Levels defined by ISO SQL

    Isolation LevleDirty ReadNon-Repeatable ReadPhantom Read
    READ UNCOMMITTEDPossiblePossiblePossible
    READ COMMITTEDNot PossiblePossiblePossible
    REPEATABLE READNot PossibleNot PossiblePossible
    SERILIZABLENot PossibleNot PossibleNot Possible
    • Read Uncommitted:读未提交,指一个事务执行过程中可以读取到其他未提交事务对数据的修改
    • Read Committed:读已提交,指一个事务执行过程中会读取到其他已提交事务对数据的修改
    • Repeatable Read:可重复读,指一个事务执行过程中多次读取同一个数据,其值都是相同的,不受其他提交事务的影响
    • Serializable:可串行化,指所有的事务呈现出像是串行读写相同数据的执行顺序

SQL 标准在如何定义这些隔离级别方面存在很多很多问题。其中大部分问题早在 1995 年就已经指出,但令人费解的是,从那时起,SQL 标准的修订版一个接一个地发布,却没有解决这些问题。

  • 第一个问题是:

    • 该标准只使用了三种异常情况来定义每个隔离级别–脏读、不可重复读和幻读。然而,在实践中可能出现的并发错误类型有很多,远不止这三种异常。仅在本篇文章中,我们就描述了六种独特的异常类型。
    • SQL 标准没有提及 READ UNCOMMITTED、READ COMMITTED 和 REPEATABLE READ 隔离级别是否易受丢失更新异常、脏写异常或写偏斜异常的影响。因此,每个商业系统都可以自行决定这些降低的隔离级别容易受到哪些其他异常的影响,而且在许多情况下,这些异常的文档记录都很不完善,从而导致应用程序开发人员的困惑和不可预测的错误。

      The SQL standard only uses three types of anomalies to define each isolation…however, there are many types of concurrency bugs that can appear in practice

  • 第二个问题是:

    • 使用异常情况来定义隔离级别只能向最终用户保证哪些特定类型的并发错误是不可能发生的。它并没有给出任何特定事务可查看的潜在数据库状态的精确定义。学术文献中给出了几种经过改进的、更精确的隔离级别定义。
    • Atul Adya 的博士论文根据不同事务的读写如何交错给出了 SQL 标准隔离级别的精确定义。不过,这些定义都是从系统的角度给出的。Natacha Crooks 等人最近的研究则从用户的角度给出了优雅而精确的定义。
  • 第三个问题是:

    • 该标准既没有定义,也没有对实际应用中最常用的简化隔离级别之一:快照隔离(或其众多变体中的任何一种–PSI、NMSI、Read Atomic 等)提供正确性约束。由于没有提供快照隔离的定义,快照隔离所允许的并发漏洞在不同系统中出现了差异。
    • 一般来说,快照隔离会根据数据库状态的特定快照执行所有数据读取,该快照只包含已提交数据。该快照在整个事务生命周期内保持不变,因此所有读取都保证是可重复的(除了只读取已提交数据外)。此外,对相同数据写操作的并发事务会检测到彼此间的冲突,通常会通过中止其中一个冲突事务来解决冲突。
    • 这样就能避免更新丢失的异常情况。不过,只有在冲突的事务写入重叠的数据集时,才会检测到冲突。如果写入数据集互不关联,这些冲突就不会被检测到。因此,快照隔离容易受到写偏移异常的影响。某些实现还容易受到幻读异常的影响。
  • 第四个问题是:

    • SQL 标准似乎对 SERIALIZABLE 隔离级别给出了两种不同的定义。首先,它对 SERIALIZABLE 的定义是正确的:最终结果必须等同于没有并发时可能出现的结果。但随后,它又给出了上表,似乎暗示只要隔离级别不允许脏读、不可重复读或幻读,就可以称为 SERIALIZABLE。
    • 甲骨文公司历来利用这种模糊性,将其快照隔离实施称为 “SERIALIZABLE”。
    • 老实说,我认为大多数阅读过 ISO SQL 标准文档的人都会认为,文档前面给出的更精确的 SERIALIZABLE 定义(这才是正确的定义)才是文档作者的本意(指对隔离级别的准确定义)。
      • 幽默冷知识:尽管如此,我猜甲骨文的律师们还是研究了一下,认为文件中存在足够的歧义,可以从法律上证明他们对另一个定义的依赖是合理的。(如果我的读者中有人知道有任何真正的诉讼是来自应用程序开发人员,他们认为自己获得了一个 SERIALIZABLE 隔离级别,但在实践中却遇到了写偏差异常,我很想听听他们的说法。或者,如果您是一名应用程序开发人员,并且遇到过这种情况,我也很想听听您的看法)。

问题的关键在于:要为应用程序开发人员提供实际隔离级别的明确定义几乎是不可能的,因为 SQL 标准的含糊不清和模棱两可导致了不同实现/系统之间的语义差异。

如何选择合适的隔离级别?

我给应用开发程序员的建议如下:降低隔离级别是很危险的。你很难弄清哪些并发错误可能会出现。如果每个系统都使用我上面引用的 Crooks 等人的方法来定义其隔离级别,那么至少你会对其相关保证有一个精确而正式的定义。遗憾的是,对于大多数数据库用户来说,Crooks 论文中的形式主义过于先进,因此数据库供应商不可能很快在其文档中采用这些形式主义。与此同时,降低隔离级别的定义在实践中仍然模糊不清,使用起来风险很大。

Reduced isolation levels are dangerous…the definition of reduced isolation levels remain vague in practice and risky to use.

此外,即使您能准确地知道特定隔离级别可能会出现哪些并发错误,但在编写应用程序时要确保这些错误在实际中不会发生(或者即使发生了,也不会给应用程序的用户带来负面体验)也是非常具有挑战性的。如果您的数据库系统允许您做出选择,那么正确的选择通常是避免使用比可序列化隔离更低的隔离级别。对于绝大多数数据库系统来说,要实现这一点,实际上必须更改默认设置(隔离级别的配置)。

最后,还有三个需要注意的地方:

  • 1)如上所述,有些系统使用 "SERIALIZABLE "一词来表示比真正的可序列化隔离更弱的含义。 不幸的是,这意味着仅仅简单地在数据库系统中选择 SERIALIZABLE 隔离级别可能不足以真正确保可序列化。您需要检查文档,以确保它是按以下方式定义 SERIALIZABLE 的:数据库的可见状态始终等同于无并发时可能出现的状态。否则,您的应用程序很可能会受到写偏移异常的影响。
  • 2)如上所述,可序列化的隔离级别需要付出性能代价。根据系统架构的质量,可序列化的性能代价可大可小。在我最近与 Jose Faleiro 和 Joe Hellerstein 共同撰写的一篇研究论文中,我们发现在设计良好的系统中,SERIALIZABLE 和 READ COMMITTED 之间的性能差异可以忽略不计…而且在某些情况下,SERIALIZABLE 隔离级别的性能有可能(出人意料地)超过 READ COMMITTED 隔离级别。如果发现在系统中使用可序列化隔离的成本过高,那么在考虑降低隔离级别之前,或许应该先考虑使用其他数据库系统。
  • 3)在分布式系统中,即使在可序列化的隔离级别类别中,也可能(而且确实)出现一些重要的异常情况。对于此类系统,了解可序列化隔离级别(众所周知严格序列化是最安全的)各元素之间的细微差别非常重要。我们将在今后的文章中进一步阐述这一问题。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值