DDIA 阅读笔记第七章:事务

摘要

本章围绕事务这一主题,介绍了事务的含义、强弱隔离级别的实现,以及解决的并发问题。

简介

在数据系统中,很多事情都可能出错:

  • 数据库:数据库软硬件可能随时发生故障(包括写操作进行到一半时)。
  • 应用:应用程序可能随时崩溃(包括一系列操作的中间)。
  • 网络:网络中断可能会意外切断数据库与应用的连接,或数据库之间的连接。
  • 客户端并发:多个客户端可能会同时写入数据库,覆盖彼此的更改。
    • 客户端可能读取到无意义的数据,因为数据只更新了一部分。
    • 客户之间的竞争条件可能导致奇怪的错误。

如果每个应用程序都要处理这些问题,负担很重。为了简化应用编程模型 ,数据库提供了事务(transaction)机制。使用事务,应用程序可以将多个读写操作组合成一个逻辑单元。从概念上讲,事务中的所有读写操作被视作单个操作来执行:

  • 整个事务要么成功  提交(commit),要么失败:中止(abort)或  回滚(rollback)。
  • 如果失败,应用程序可以安全地重试

通过使用事务,应用程序可以忽略某些潜在的错误情况和并发问题,因为数据库会替应用处理好这些。(称之为  安全保证,即 safety guarantees)。

事务是有代价的,并不是所有的应用都需要事务:

  • 有时候弱化事务保证、或完全放弃事务也是有好处的(例如,为了获得更高性能或更高可用性)
  • 一些安全属性也可以在没有事务的情况下实现。

本章将研究许多出错案例,并探索数据库用于防范这些问题的算法。尤其会深入  并发控制  的领域,讨论各种可能发生的竞争条件,以及数据库如何实现常用的隔离级别:

  • 读已提交(read committed)
  • 快照隔离(snapshot isolation),也称为可重复读(repeatable read)
  • ** 可串行化(serializability)

本章同时适用于单机数据库与分布式数据库,在  第八章  中将重点讨论仅出现在分布式系统中的特殊挑战。

事务的棘手概念

现今,几乎所有的关系型数据库和一些非关系数据库都支持  事务。其中大多数遵循 IBM System R(第一个 SQL 数据库)在 1975 年引入的风格【1,2,3】。40 年里,尽管一些实现细节发生了变化,但总体思路大同小异:MySQL、PostgreSQL、Oracle 和 SQL Server 等数据库中的事务支持与 System R 异乎寻常地相似。

ACID 的含义

事务所提供的安全保证,通常由首字母缩略词 ACID 来描述:

  • 原子性(Atomicity):如果在一系列写操作的中途发生错误,则中止事务,并丢弃当前事务的所有写入。换句话说,免去用户对部分失败的担忧 —— 提供 “all-or-nothing” 的保证。
  • 一致性(Consistency):应用程序可基于数据库提供的原子性、隔离性保证,定义并维护数据库的一致性。
  • 隔离性(Isolation)同时运行的事务不会互相干扰。例如,如果一个事务进行多次写入,则另一个事务要么看到全部写入结果,要么什么都看不到,但不能看到中间状态。
  • 持久性(Durability):事务一旦提交,不会丢失。

这些是旨在为数据库中的容错机制建立精确的术语。

但实际上,不同数据库的 ACID 实现并不相同。例如,围绕着隔离性(Isolation)  的含义有许多含糊不清。

  • 高层次上的想法是合理的,但魔鬼隐藏在细节里。
  • 今天,当一个系统声称自己 “符合 ACID” 时,实际上能期待的是什么保证并不清楚。不幸的是,ACID 现在几乎已经变成了一个营销术语

不符合 ACID 标准的系统有时被称为 BASE,它代表:

  • 基本可用性(Basically Available)
  • 软状态(Soft State)
  • 最终一致性(Eventual consistency)

BASE 比 ACID 的定义更加模糊,似乎 BASE 的唯一合理的定义是 “不是 ACID”,即它几乎可以代表任何你想要的东西。

原子性(Atomicity)

一般来说,原子是指不能分解成小部分的东西。这个词在计算的不同分支中意味着相似但又微妙不同的东西。

  • 在多线程编程中,如果一个线程执行一个原子操作,这意味着另一个线程无法看到该操作的一半结果。系统只能处于操作之前或操作之后的状态,而不是介于两者之间的状态。

ACID 的原子性并是关于并发(concurrent)的,它的含义是:在多次写入过程中,如果发生错误、事务无法完成(提交)时,事务将被终止,且数据库会回滚该事务所有已经写入的变更

  • 如果没有原子性,在多个写操作进行到一半时发生错误,很难知道哪些更改已经生效,哪些没有生效。贸然重试可能会导致数据重复或错误。
  • 原子性简化了这个问题:如果事务没有提交,应用程序可以确定它没有改变任何东西,所以可以安全地重试

ACID 原子性的定义特征是:能够在错误时中止事务,丢弃该事务进行的所有写入变更的能力。  或许  可中止性(abortability)  是更好的术语。

一致性(Consistency)

一致性有不同的含义,取决于场景和上下文:

  • 多副本:多个副本上的保存数据是否相同。第 5 章中,讨论的副本一致性,以及异步复制系统中的最终一致性问题。
  • 一致性哈希(Consistency Hash):一种分区方法,用于解决普通哈希在重平衡时成本高的问题。
  • CAP 定理:一致性一词用于表示线性一致性
  • ACID :一致性是指数据库在应用程序的特定概念中处于 “良好状态”。

在 ACID 中,一致性是对某些不变性(invariants)  的保证,即某些约束条件。如,在银行账户中,在任何时刻,账户余额须等于收入减去支出。

不同于 ACID 中其他性质,一致性基本是由应用侧定义与维护的,依赖于数据库的安全保证,但不直接由数据库实现:

  1. 应用侧:定义一致性;写操作需要满足一致性约束
  2. 数据库:提供原子性、隔离性等能力,以及基础的外键约束等

因此,一致性可以表述为,应用侧依赖数据库提供的原子性、隔离性来实现一致性。

  • 一致性并非数据库事务本身的一种特性,更多的是应用侧的一种属性。
  • 据此,乔・海勒斯坦(Joe Hellerstein)认为,在 Härder 与 Reuter 的论文中,“ACID 中的 C” 是 “用来凑数的”。

隔离性(Isolation)

大多数数据库都会同时被多个客户端访问。如果多个客户端访问相同的数据库记录,则可能会遇到并发问题,也称竞争条件(race conditions)

图 7-1 两个客户端之间的竞争状态,同时递增计数器

设有一个计数器,且数据库没有内置原子的自增操作,有两个用户,各自读取当前值,加 1 后写回。如图,期望计数器由 42 变为 44,但由于并发问题,最终变成了 43。

ACID 意义上的隔离性意味着,同时执行的事务是相互隔离的:它们不会互相影响

传统的数据库教科书将隔离性形式化为可串行化(Serializability),这意味着每个事务可以假装它是唯一在整个数据库上运行的事务。数据库确保当事务已经提交时,结果与它们串行运行(一个接一个)是一样的,尽管实际上它们可能是并发运行的。

然而实践中很少会使用可串行化隔离,因为它有性能损失。一些流行的数据库如 Oracle 11g,甚至没有实现它。在 Oracle 中有一个名为 “可序列化” 的隔离级别,但实际上它实现了一种叫做快照隔离(snapshot isolation)  的功能,这是一种比可序列化更弱的保证【8,11】。

持久性(Durability)

数据库系统的目的是,提供一个安全的地方存储数据,而不用担心丢失。持久性  是一个承诺:一旦事务成功提交,写入的任何数据也不会丢失,即使发生硬件故障或数据库崩溃。

持久性可以有多重含义:

  • 落盘:在单节点数据库中,持久性通常意味着数据已被写入非易失性存储设备,如硬盘或 SSD。
    • 它通常还包括预写日志或类似的文件,以便在磁盘上的数据结构损坏时进行恢复。
  • 复制:在多副本数据库中,持久性可能意味着数据已成功复制到一些节点。
    • 为了保证持久性,数据库必须等到这些写入或复制完成后,才能报告事务成功提交。
    • 也就是同步复制

完美的持久性是不存在的 :如果所有硬盘和所有备份同时被销毁,那显然没有任何数据库能救得了你。

写入硬盘和写入副本,两种对持久性的实现没有绝对的优劣:

  • 如果写入磁盘然后机器宕机,即使数据没有丢失,在修复机器或将磁盘转移到其他机器之前,也是无法访问的。这种情况下,复制系统可以保持可用性。
  • 一个相关性故障(停电,或一个特定输入导致所有节点崩溃的 Bug)可能会一次性摧毁所有副本,任何仅存储在内存中的数据都会丢失,故内存数据库仍然要和磁盘写入打交道。
  • 在异步复制系统中,当主库不可用时,最近的写入操作可能会丢失。
  • 当电源突然断电时,特别是固态硬盘,有证据显示有时会违反应有的保证:甚至 fsync 也不能保证正常工作。硬盘固件可能有错误,就像任何其他类型的软件一样。
  • 存储引擎和文件系统之间的微妙交互可能会导致难以追踪的错误,并可能导致磁盘上的文件在崩溃后被损坏。
  • 磁盘上的数据可能会在没有检测到的情况下逐渐损坏。如果数据已损坏一段时间,副本和最近的备份也可能损坏。这种情况下,需要尝试从历史备份中恢复数据。
  • 一项关于固态硬盘的研究发现,在运行的前四年中,30%到 80%的硬盘会产生至少一个坏块。相比固态硬盘,磁盘的坏道率较低,但完全失效的概率更高。
  • 如果 SSD 断电,可能会在几周内开始丢失数据,具体取决于温度。

在实践中,没有一种技术可以提供绝对保证,只有各种降低风险的技术,包括写入磁盘,复制到远程机器和备份 —— 它们可以且应该一起使用。与往常一样,最好抱着怀疑的态度接受任何理论上的 “保证”

原子性 vs 隔离性

一个例子,用户 A 向 B 发送一封新邮件,需要更新两个对象:

  • 用户 B 的收件箱新增一封邮件
  • 用户 B 的全局未读数 + 1

这两个变更在一个事务中。与此同时,用户 B 读取了未读数和收件箱。那么:

  • 隔离性:用户 B 要么同时看到未读数和收件箱更新;要么都看不到
    • 重点:事务的中间状态不可能被其他事务读到
  • 原子性:如果未读数更新失败,新增的邮件也会被撤销
    • 重点:事务的中间状态不可能出现在磁盘上

这么一对比就很清晰了。

单对象和多对象操作

如果想同时修改多个对象(行,文档,记录)。通常需要  多对象事务(multi-object transaction)  来保证数据同步。

多对象事务中,如何确定多个操作是否属于同一事务:

  1. 通过 TCP 连接来确定,在同一个连接中,BEGIN TRANSACTION  和  COMMIT 语句之间的所有内容,可以认为属于同一个事务。
    1. 无法处理的情况:如在客户端提交事务请求后,服务器回应提交之前,网络中断,连接断开,此时客户端则无从得知事务是否被成功提交。
  2. 使用事务管理器,为每个事务分配一个唯一标识符,从而对操作进行分组。

实际中基本上使用第二种方法。

有一些非关系型数据库,虽然提供 Batch 操作接口,但它们并不一定有事务语义,即可能有些对象成功,另外一些对象操作却失败。

  • 例如 redis 的 mset

单对象写入

当只更改单个对象时,仍会面临原子性和隔离性的问题。假设,在文档数据库中,你正在写入一个 20 KB 的 JSON 文档:

  1. 如果发送了前 10 kb 数据后,网络断开,数据库是否已经存储了这不完整的 10k 数据?
  2. 如果该操作是正在覆盖一个老版本同 id 数据,覆盖一半时电源发生故障,数据库是否会存在一半旧值一半新值?
  3. 如果有另一个客户端同时在读取该文档,是否会看到半更新状态?

这些问题都非常棘手,如果数据库不提供任何保证,用户侧得写很多的错误处理逻辑。因此,一般的数据库哪怕不支持完整的事务,也会提供针对单个对象的原子性和隔离性。比如:

  • 原子性:可以使用 WAL 来保证
  • 隔离性:使用锁来保证

其他一些数据库,也会提供更复杂的原子支持,如原子的自增操作,从而避免图 7-1 中的交错更新。另一种更泛化的原子性保证是提供单个对象上的 CAS(compare and set) 操作,允许用户原子的执行针对单个对象的 read-modify-write 操作。

  • 如果咬文嚼字一下,原子自增(atomic increment),在 ACID 中应该是属于隔离性(Isolation)的范畴,此处的原子自增其实是多线程的概念。但这有点吹毛求疵了。

有的 NoSQL 数据库将上述支持宣传为 “轻量级事务”,甚而营销为 “ACID”。但这是极其不负责任的,通常来说,事务是一种将针对多个对象多个操作封装为一个执行单元的机制。

多对象事务的需求

许多分布式数据存储已经放弃了多对象事务,因为多对象事务很难跨分区实现,而且在需要高可用性或高性能的情况下,它们可能会碍事。但说到底,在分布式数据库中实现事务,并没有什么根本性的障碍。第九章 将讨论分布式事务的实现。

但是我们是否需要多对象事务?是否有可能只用键值数据模型和单对象操作来实现任何应用程序?

有一些场景中,单对象插入,更新和删除是足够的。但是许多其他场景需要协调写入几个不同的对象:

  • 在关系数据模型中,一个表中的行通常具有对另一个表中的行的外键引用。多对象事务可以确保这些引用始终有效:当插入几个相互引用的记录时,外键必须是正确的和最新的,不然数据就没有意义。
  • 在文档数据模型中,需要一起更新的字段通常在同一个文档中,这被视为单个对象 —— 更新单个文档时不需要多对象事务。但是,没有 join 功能的文档数据库会鼓励非规范化。当需要更新非规范化的信息时,需要一次更新多个文档。事务在这种情况下非常有用,可以防止非规范化的数据不同步。
  • 在具有二级索引的数据库中(除了纯粹的键值存储以外几乎都有),每次更改值时都需要更新索引。从事务角度来看,这些索引是不同的数据库对象:例如,如果没有事务隔离性,记录可能出现在一个索引中,但没有出现在另一个索引中,因为第二个索引的更新还没有发生。

这些应用仍然可以在没有事务的情况下实现。然而:

  • 没有原子性,错误处理就要复杂得多
  • 缺乏隔离性,就会导致并发问题

处理错误和中止

事务的一个关键特性是,如果发生错误,它可以中止并安全地重试。 ACID 数据库基于这样的哲学:如果数据库有违反其原子性,隔离性或持久性的危险,则宁愿完全放弃事务,而不是留下半成品。

并不是所有的系统都遵循这个哲学。特别是具有   无主复制 的数据存储,主要是在 “尽力而为” 的基础上进行工作。可以概括为 “数据库将做尽可能多的事,运行遇到错误时,它不会撤消它已经完成的事情 “ —— 所以,从错误中恢复是应用程序的责任。

错误发生不可避免,但许多软件开发人员倾向于只考虑乐观情况,而不是错误处理的复杂性。例如,像 Rails 的 ActiveRecord 和 Django 这样的  对象关系映射(ORM, object-relation Mapping)  框架不会重试中断的事务 —— 这个错误通常会导致一个从堆栈向上传播的异常,所以任何用户输入都会被丢弃,用户拿到一个错误信息。这实在是太耻辱了,因为中止的重点就是允许安全重试

尽管重试一个中止的事务是一个简单而有效的错误处理机制,但它并不完美:

  • 回复丢包导致的重试:如果事务实际上成功了,但是在服务器试图向客户端确认提交成功时网络发生故障(所以客户端认为提交失败了),那么重试事务会导致事务被执行两次 —— 除非你有一个额外的应用级去重机制
  • 如果错误是由于负载过大造成的,则重试事务将使问题变得更糟。为了避免这种正反馈循环,可以限制重试次数,使用指数退避算法,并单独处理与过载相关的错误(如果允许)。
  • 仅在临时性错误(例如,由于死锁,异常情况,临时性网络中断和故障切换)后才值得重试。在发生永久性错误(例如,违反约束)之后重试是毫无意义的。
  • 如果事务在数据库之外也有副作用,即使事务被中止,也可能发生这些副作用。例如,如果你正在发送电子邮件,那你肯定不希望每次重试事务时都重新发送电子邮件。如果你想确保几个不同的系统一起提交或放弃,两阶段提交(2PC, two-phase commit)  可以提供帮助。
  • 如果客户端进程在重试中失效,任何试图写入数据库的数据都将丢失。

弱隔离级别

事务读取由另一事务同时修改的数据时,或者当两个事务试图同时修改相同的数据时,并发问题(竞争条件)才会出现。

  • 并发 BUG 很难通过测试找到,因为这样的错误只有在特殊时机下才会触发

数据库通过提供事务隔离(transaction isolation)来隐藏应用程序的并发问题。从理论上讲,隔离性可以隐藏并发事实:

  • 可串行化(serializable)  的隔离等级意味着数据库保证事务的效果与串行运行(没有任何并发)是一样的

可串行化属于强隔离级别,有性能损失,许多数据库不愿意支付这个代价。因此,系统通常使用较弱的隔离级别来防止一部分,而不是全部的并发问题。

  • 弱隔离级别通常难以理解,并且会导致微妙的错误,但是它们仍然在实践中被使用。

本节介绍常用的弱隔离级别,讨论它们可解决和无法处理的并发问题。

读已提交

最基本的事务隔离级别是读已提交(Read Committed),它提供了两个保证:

  1. 没有脏读(dirty reads):从数据库读时,只能看到已提交的数据
  2. 没有脏写(dirty writes):写入数据库时,只会覆盖已提交的数据

某些数据库支持甚至更弱的隔离级别,称为  读未提交(Read uncommitted)。它可以防止脏写,但不防止脏读。

没有脏读

如果事务 A 能够读到另一未提交事务 B 的中间状态,称为脏读(dirty reads)。

图 7-4 没有脏读:用户 2 只有在用户 1 的事务已经提交后才能看到 x 的新值。

读已提交隔离级别运行的事务必须防止脏读。这意味着:事务的所有写入操作只有在该事务提交时才能被其他事务看到

为什么要防止脏读,有几个原因:

  • 如果事务需要更新多个对象脏读取意味着另一个事务可能会只看到一部分更新。这种中间状态可能是不一致的,例如银行转账场景,先减钱再加钱。
  • 如果事务中止,则所有写入操作都需要回滚。如果数据库允许脏读,那就意味着一个事务可能会读到稍后需要回滚的数据,即从未实际提交给数据库的数据。

没有脏写

如果两个事务同时尝试更新数据库中的相同对象,会发生什么情况?我们不知道写入的顺序是怎样的,但是我们通常认为后面的写入会覆盖前面的写入

脏写(dirty write):事务 B 覆盖另一未提交事务 A 的写入。

读已提交的隔离级别上运行的事务必须防止脏写,通常是延迟第二次写入,直到第一次写入事务提交或中止为止。

通过防止脏写,这个隔离级别避免了一些并发问题:

  • 如果事务更新多个对象,脏写会导致不好的结果。例如图 7-5 的二手车销售网站,Alice 和 Bob 两个人同时试图购买同一辆车。购买汽车需要两次数据库写入:更新商品列表,将销售发票发送给买家。在图 7-5 的情况下,Bob 成功更新了商品列表,但 Alice 成功更新了发票表。读已提交会阻止这样这样的事故。
  • 但是,读已提交并不能解决写丢失问题,防止图 7-1 中两个计数器增量之间的竞争状态。在这种情况下,第二次写入发生在第一个事务提交后,所以它不是一个脏写。这仍然是不正确的,但是出于不同的原因,在 “防止更新丢失” 中将讨论如何使这种计数器增量安全。

图 7-5 如果存在脏写,来自不同事务的冲突写入可能会混淆在一起

实现读已提交

读已提交是一个非常流行的隔离级别。这是 Oracle 11g,PostgreSQL,SQL Server 2012,MemSQL 和其他许多数据库的默认设置【8】。

防止脏写

使用行锁(row-level lock):当事务想要修改特定对象(行或文档)时,它必须首先获得该对象的锁,持有该锁直到事务被提交或中止

  • 互斥:同时只有一个事务可持有任何给定对象的锁
  • 阻塞:如果另一个事务要写入同一个对象,则必须等到第一个事务提交或中止后,才能获取该锁并继续
  • 这种锁定是读已提交模式(或更强的隔离级别)的数据库自动完成的

防止脏读

一种选择是使用相同的锁,并要求任何想要读取对象的事务来简单地获取该锁,然后在读取之后立即再次释放该锁。这能确保不会读取进行时,对象不会在脏的状态,有未提交的值(因为在那段时间锁会被写入该对象的事务持有)。

但是要求读锁的办法在实践中效果并不好。因为一个长时间运行的写入事务会迫使许多只读事务等到这个慢写入事务完成。这会损失只读事务的响应时间,并且不利于可操作性:因为等待锁,应用某个部分的迟缓可能由于连锁效应,导致其他部分出现问题。

出于这个原因,大多数数据库使用图 7-4 的方式防止脏读:

  • 对于写入的每个对象,数据库都会记住旧的已提交值,和由当前持有写入锁的事务设置的新值
  • 事务正在进行时,任何其他读取对象的事务都会拿到旧值。 只有当新值提交后,事务才会切换到读取新值。

快照隔离和可重复读

读已提交隔离级别提供的保证:

  • 原子性:允许中止
  • 防止脏读:避免读取不完整的事务结果
  • 防止脏写:避免并发写入造成的混乱

但是,它不能保证一个事务中的多次读取,读到的结果总是一致的。一个例子如下:

Alice 在银行有 1000 美元的储蓄,分为两个账户,每个 500 美元。现在有一笔事务从她的一个账户转移了 100 美元到另一个账户。如果她非常不幸地在事务处理的过程中查看其账户余额列表,她可能会:

  • 在收到付款之前先看到账户 1 的余额(收款账户,余额仍为 500 美元)
  • 在发出转账之后再看到账户 2 的余额(付款账户,新余额为 400 美元)

对 Alice 来说,现在她的账户似乎总共只有 900 美元 —— 看起来有 100 美元已经凭空消失了。

图 7-6 读取偏差:Alice 观察数据库处于不一致的状态

这种异常被称为  不可重复读(nonrepeatable read)  或  读取偏差(read skew)。如果 Alice 在事务结束时再次读取账户 1 的余额,她将看到与之前的查询中看到的不同的值(600 美元)。在读已提交的隔离条件下,不可重复读  被认为是可接受的:Alice 看到的帐户余额确实在读时已经提交了。

个人理解:不可重复读问题可以理解为脏读的泛化

  • 脏读是事务中的单次读读到了不一致数据
  • 不可重复读是同一事务中的多次读取,不属于同一个版本,导致不一致

不幸的是,术语 偏差(skew)  这个词是过载的:

  • 分片场景:skew 描述分片热点的工作量不平衡
  • 事务场景:skew 意味着时序异常

对于 Alice 的情况,这不是一个长期持续的问题。因为如果她几秒钟后刷新银行网站的页面,她很可能会看到一致的帐户余额。但是有些情况下,不能容忍这种暂时的不一致:

  • 备份 进行备份需要复制整个数据库,对大型数据库而言可能需要花费数小时才能完成。备份进程运行时,数据库仍然会接受写入操作。因此备份可能会包含一些旧的部分和一些新的部分。如果从这样的备份中恢复,不一致就会变成永久的
  • 分析查询和完整性检查 有时,你可能需要运行一个查询,扫描大部分的数据库。这样的查询在分析中很常见,也可能是定期完整性检查(即监视数据损坏)的一部分。如果这些查询在不同时间点观察数据库的不同部分,则可能会返回毫无意义的结果

快照隔离(snapshot isolation)是这个问题最常见的解决方案。想法是,每个事务都从数据库的  一致快照(consistent snapshot)  中读取。

  • 事务可以看到事务开始时在数据库中提交的所有数据
  • 即使这些数据随后被另一事务更改,每个事务也只能看到特定时间点的旧数据

快照隔离对长时间运行的只读查询(如备份和分析)非常有用。如果查询的数据在查询执行的同时发生变化,则很难理解查询的含义。当一个事务可以看到数据库在某个特定时间点冻结时的一致快照,理解起来就很容易了。

快照隔离是一个流行的功能:PostgreSQL、使用 InnoDB 引擎的 MySQL、Oracle、SQL Server 等都支持。

实现快照隔离

与读取提交的隔离类似,快照隔离的实现通常使用写锁来防止脏写,这意味着进行写入的事务会阻止另一个事务修改同一个对象。但是读取不需要任何锁定。

从性能的角度来看,快照隔离的一个关键原则是:读不阻塞写,写不阻塞读。这允许数据库在处理一致性快照上的长时间查询时,可以正常地同时处理写入操作。且两者间没有任何锁定争用。

为了实现快照隔离,数据库使用了用于防止图 7-4 中的脏读的机制的一般化。数据库必须可能保留一个对象的几个不同的提交版本,因为各种正在进行的事务可能需要看到数据库在不同的时间点的状态。因为它同时维护着多个版本的对象,所以这种技术被称为多版本并发控制(MVCC, multi-version concurrentcy control)

支持快照隔离的存储引擎通常也使用 MVCC 来实现读已提交隔离级别。一种典型的方法是:

  • 读已提交每个查询使用单独的快照
  • 快照隔离整个事务使用相同的快照

图 7-7 说明了如何在 PostgreSQL 中实现基于 MVCC 的快照隔离(其他实现类似)。当一个事务开始时,它被赋予一个唯一的,永远增长的事务 ID(txid)。每当事务向数据库写入任何内容时,它所写入的数据都会被标记上写入者的事务 ID。

事实上,事务 ID 是 32 位整数,所以大约会在 40 亿次事务之后溢出。 PostgreSQL 的 Vacuum 过程会清理老旧的事务 ID,确保事务 ID 溢出(回卷)不会影响到数据。

图 7-7 MVCC 实现快照隔离

表中每一行中使用额外字段存储修改该行的事务 ID

  • created_by :将该行插入到表中的的事务 ID
  • deleted_by :将该行从表中删除的事务 ID
    • 最初是空的。如果某个事务删除了一行,那么该行实际上并未从数据库中删除,而是通过将  deleted_by  字段设置为请求删除的事务的 ID 来标记为删除
    • 之后,当确定没有事务可以再访问已删除的数据时,数据库中的垃圾收集过程会将所有带有删除标记的行移除,并释放其空间。

在 PostgreSQL 中:

  • created_by 的实际名称为 xmin,即能读到改行的最小 txid
  • deleted_by 的实际名称为 xmax,即能读到改行的最大 txid

UPDATE  操作在内部翻译为  DELETE  和  INSERT 。例如,在图 7-7 中,事务 13 从账户 2 中扣除 100 美元,将余额从 500 美元改为 400 美元。实际上包含两条账户 2 的记录:

  • 余额为 $500 的行被标记为被事务 13 删除
  • 余额为 $400 的行由事务 13 创建

观察一致性快照的可见性规则

当一个事务从数据库中读取时,事务 ID 用于决定它可以看见哪些对象,看不见哪些对象。通过仔细定义可见性规则,数据库可以向应用程序呈现一致的数据库快照。原理如下:

  • 进行中的事务的操作不可见:在每次事务开始时,数据库列出当时所有其他尚未提交或中止的事务清单,即使之后提交了,这些事务的写入也都会被忽略
  • 中止事务所执行的任何写入都将被忽略
  • 新事务的操作不可见:由具有较大的事务 ID(即,在当前事务开始之后开始的)的事务所做的任何写入都被忽略,而不管这些事务是否已经提交
  • 所有其他写入,对应用都是可见的

这些规则适用于创建和删除对象。可以理解为,“快照” 就包含了进行中的事务 ID。

如果事务 txid 是严格自增的,则可以理解为,对于 txid = x 的事务来说:

  1. 对于所有 txid < x 的事务,如果已经中止或正在进行,则其所写数据不可见
  2. 对于所有 txid > x 的事务,所写数据皆不可见

换句话说,如果以下两个条件都成立,则对象的版本可见:

  • 读事务开始时,创建该对象的事务已经提交
  • 对象未被标记为删除,或如果被标记为删除,请求删除的事务在读事务开始时尚未提交

长时间运行的事务可能会长时间使用快照,并继续读取(从其他事务的角度来看)早已被覆盖或删除的值。由于从来不更新值,而是每次值改变时创建一个新的版本,数据库可以在提供一致快照的同时只产生很小的额外开销

索引和快照隔离

索引如何在多版本数据库中工作?一种选择是使索引简单地指向对象的所有版本,并且需要索引查询来过滤掉当前事务不可见的任何对象版本。当垃圾收集删除任何事务不再可见的旧对象版本时,相应的索引条目也可以被删除。

在实践中,许多实现细节决定了多版本并发控制的性能。例如,如果同一对象的不同版本可以放入同一个页面中,PostgreSQL 的优化可以避免更新索引【31】。

在 CouchDB,Datomic 和 LMDB 中使用另一种方法。虽然它们也使用 B 树,但它们使用的是一种仅追加 / 写时拷贝(append-only/copy-on-write)  的变体,它们在更新时不覆盖树的页面,而为每个修改页面创建一份副本。从父页面直到树根都会级联更新,以指向它们子页面的新版本。任何不受写入影响的页面都不需要被复制,并且保持不变【33,34,35】。

使用仅追加的 B 树,每个写入事务(或一批事务)都会创建一颗新的 B 树,当创建时,从该特定树根生长的树就是数据库的一个一致性快照。没必要根据事务 ID 过滤掉对象,因为后续写入不能修改现有的 B 树;它们只能创建新的树根。但这种方法也需要一个负责压缩和垃圾收集的后台进程。

可重复读与命名混淆

快照隔离是一个有用的隔离级别,特别对于只读事务而言。但是,许多数据库实现了它,却用不同的名字来称呼。

  • 在 Oracle 中称为可序列化(Serializable)
  • 在 PostgreSQL 和 MySQL 中称为 ** 可重复读(repeatable read)

这种命名混淆的原因是 SQL 标准没有快照隔离的概念,因为标准是基于 System R 1975 年定义的隔离级别,那时候快照隔离尚未发明。相反,它定义了可重复读,表面上看起来与快照隔离很相似。 PostgreSQL 和 MySQL 称其快照隔离级别为可重复读(repeatable read),因为这样符合标准要求,所以它们可以声称自己 “标准兼容”。

不幸的是,SQL 标准对隔离级别的定义是有缺陷的 —— 模糊,不精确,并不像标准应有的一样,独立于实现。有几个数据库实现了可重复读,但它们实际提供的保证存在很大的差异,尽管表面上是标准化的【23】。在研究文献【29,30】中已经有了可重复读的正式定义,但大多数的实现并不能满足这个正式定义。最后,IBM DB2 使用 “可重复读” 来引用可串行化【8】。

到最后,没有人真正知道可重复读的意思

防止丢失更新

到目前为止已经讨论的读已提交快照隔离级别:

  • 主要保证了只读事务在并发写入时可以看到什么,处理读写冲突
  • 两个事务并发写入的问题,只讨论了脏写,忽视了其他问题

并发的写入事务之间还有其他几种有趣的冲突。其中最著名的是丢失更新(lost update)  问题,典型的例子如并发更新计数器。

两个事务同时从数据库中读取值,修改它并先后写回修改的值(a read-modify-write cycle),那么第一个的修改就会丢失,因为第二个写入的内容并没有包括第一个事务的修改。这种模式发生在各种不同的情况下:

  • 增加计数器或更新账户余额(需要读取当前值,计算新值并写回更新后的值)
  • 在复杂值中进行本地修改:例如,将元素添加到 JSON 文档中的一个列表(需要解析文档,进行更改并写回修改的文档)
  • 两个用户同时编辑 wiki 页面,每个用户通过将整个页面内容发送到服务器来保存其更改,覆写数据库中当前的任何内容。

这是一个普遍的问题,所以已经开发了各种解决方案。

原子写

许多数据库提供了原子更新操作,从而消除了在应用程序代码中执行读取 - 修改 - 写入序列的需要。如果代码可以用这些操作来表达,这通常是最好的解决方案。例如,下面的指令在大多数关系数据库中是并发安全的:

1
UPDATE counters SET value = value + 1 WHERE key = 'foo';

原子操作通常通过在读取对象时,获取排它锁来实现,以便更新完成之前没有其他事务可以读取它。这种技术有时被称为  游标稳定性(cursor stability)【36,37】。

不幸的是,ORM 框架很容易意外地执行不安全的读取 - 修改 - 写入序列,而不是使用数据库提供的原子操作,经常产生那种很难测出来的微妙 Bug。

显式锁定

如果数据库没有内置提供原子操作,防止丢失更新的另一个选择是让应用程序显式地锁定将要更新的对象。然后应用程序可以执行读取 - 修改 - 写入序列,如果任何其他事务尝试同时读取同一个对象,则强制等待,直到第一个读取 - 修改 - 写入序列完成。

例如,考虑一个多人游戏,其中几个玩家可以同时移动相同的棋子。在这种情况下,一个原子操作可能是不够的。但可以使用锁来防止两名玩家同时移动相同的棋子,如例 7-1 所示。

例 7-1 显式锁定行以防止丢失更新

1
2
3
4
5
6
7
8
9
10
BEGIN TRANSACTION;
-- 对 222 这局游戏的 robot 棋子加锁
SELECT * FROM figures
WHERE name = 'robot' AND game_id = 222
FOR UPDATE;

-- 检查玩家的操作是否有效,然后更新先前SELECT返回棋子的位置。
UPDATE figures SET position = 'c4' WHERE id = 1234;
COMMIT;

  • FOR UPDATE 子句告诉数据库应该对该查询返回的所有行加锁

这是有效的,但做对需要仔细考虑应用逻辑。忘记在代码某处加锁很容易引入竞争条件。

自动检测丢失更新

原子操作和锁是通过强制读取 - 修改 - 写入序列按顺序发生,来防止丢失更新的方法。另一种方法是允许它们并行执行,如果事务管理器检测到丢失更新,则中止事务并强制它们重试其读取 - 修改 - 写入序列

这种方法的一个优点是,数据库可以结合快照隔离高效地执行此检查,只需要检查 read 和 write 覆盖的版本是否一致即可。

事实上,PostgreSQL 的可重复读,Oracle 的可串行化和 SQL Server 的快照隔离级别,都会自动检测到丢失更新,并中止惹麻烦的事务。但是,MySQL/InnoDB 的可重复读并不会检测丢失更新。一些作者认为,数据库必须能防止丢失更新才称得上是提供了快照隔离,所以在这个定义下,MySQL 下不提供快照隔离。

丢失更新检测是一个很好的功能,因为它不需要应用代码使用任何特殊的数据库功能,你可能会忘记使用锁或原子操作,从而引入错误;但丢失更新的检测是自动发生的,因此不太容易出错。

比较并设置(CAS)

在不提供事务的数据库中,有时会发现一种原子操作:比较并设置(CAS, Compare And Set):

  • 此操作的目的是为了避免丢失更新:只有当前值从上次读取时一直未改变,才允许更新发生
  • 如果当前值与先前读取的值不匹配,则更新不起作用,且必须重试读取 - 修改 - 写入序列

例如,为了防止两个用户同时更新同一个 wiki 页面,可以尝试类似这样的方式,只有当用户开始编辑页面内容时,才会发生更新:

1
2
3
4
-- 根据数据库的实现情况,这可能也可能不安全
UPDATE wiki_pages SET content = '新内容'
WHERE id = 1234 AND content = '旧内容';

如果内容已经更改并且不再与 “旧内容” 相匹配,则此更新将不起作用,因此需要检查更新是否生效,必要时重试。

冲突解决和复制

在复制数据库中,存在多个数据副本,并且在不同节点上的数据可能被并发地修,防止丢失更新还需要采取一些额外的步骤来防止丢失更新。

锁和 CAS 操作假定有一个最新的数据副本。但是多主或无主复制的数据库通常允许多个写入并发执行,并异步复制到副本上,因此无法保证有一份数据的最新副本。所以基于锁或 CAS 操作的技术不适用于这种情况

相反,这种复制数据库中的一种常见方法是允许并发写入创建多个冲突版本的值,并使用应用代码或特殊数据结构在之后解决和合并这些版本。

原子操作可以在复制的上下文中很好地工作,尤其当它们具有可交换性时(即,可以在不同的副本上以不同的顺序应用它们,且仍然可以得到相同的结果)。例如,递增计数器或向集合添加元素是可交换的操作。这是 Riak 2.0 数据类型背后的思想,它可以防止复制副本丢失更新。当不同的客户端同时更新一个值时,Riak 自动将更新合并在一起,以免丢失更新。

另一方面,最后写入为准(LWW)的冲突解决方法很容易丢失更新。但不幸的是,LWW 是许多复制数据库中的默认设置。

写入偏差与幻读

当不同的事务并发地尝试写入单个对象时:会出现两种竞争条件,脏写丢失更新的问题。既可以由数据库自动解决,也可以通过锁和原子写操作手动防止。

但是,并发写入间可能发生的竞争条件,可能还会涉及多个不同对象

考虑医生值班(on-call)场景。医院通常会同时要求几位医生待命,但至少有一位医生在待命。医生可以放弃他们的班次(shift),例如他们生病了,只要满足至少有一个同事在这一班中继续工作。

现在想象一下,Alice 和 Bob 是两位值班医生。两人都感到不适,所以他们都决定请假。不幸的是,他们恰好在同一时间点击按钮下班,如图 7-8 所示。

图 7-8 写入偏差导致应用程序错误的示例

在两个事务中,应用首先检查是否有两个或以上的医生正在值班;如果是的话,它就假定一名医生可以安全地休班。由于数据库使用快照隔离,两次检查都返回 2 ,所以两个事务都进入下一个阶段。Alice 更新自己的记录休班了,而 Bob 也做了一样的事情。两个事务都成功提交了,现在没有医生值班了。违反了至少有一名医生在值班的要求。

写偏差的特征

这种异常称为 ** 写偏差(write skew)。

  • 它既不是脏写,也不是丢失更新,因为这两个事务正在更新两个不同的对象(Alice 和 Bob 各自的待命记录)。
  • 在这里发生的冲突并不是那么明显,但是这显然是一个竞争条件:如果两个事务串行运行,那么第二个医生就不能歇班了。异常行为只有在事务并发进行时才有可能。

可以将写入偏差视为丢失更新问题的一般化。写偏差中也存在一个 read-modify-cycle,如上面的例子,读取了所有值班人,并修改写入值班状态。

  • 在快照隔离下,多事务的读写并发并不阻塞,而是读取过期的一致快照。这使得两个事务都读到了两个值班人,取消自己的值班。

防止丢失更新的做法,并不完全适配写丢失问题:

  • 由于涉及多个对象,单对象的原子操作不起作用
  • 不幸的是,在一些快照隔离的实现中,自动检测丢失更新对此并没有帮助。在 PostgreSQL 的可重复读,MySQL/InnoDB 的可重复读,Oracle 可序列化或 SQL Server 的快照隔离级别中,都不会自动检测写入偏差【23】。自动防止写入偏差需要真正的可序列化隔离
  • 某些数据库允许配置约束,然后由数据库强制执行(例如,唯一性,外键约束或特定值限制)。但是为了指定至少有一名医生必须在线,需要一个涉及多个对象的约束。大多数数据库没有内置对涉及多个对象约束的支持,但是可以使用触发器,或者物化视图来实现它们,这取决于不同的数据库
  • 如果无法使用可序列化的隔离级别,则此情况下的次优选项可能是显式锁定事务所依赖的行。在例子中,你可以写下如下的代码:
1
2
3
4
5
6
7
8
9
10
11
12
BEGIN TRANSACTION;
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;

COMMIT;

  • 和以前一样,FOR UPDATE 告诉数据库锁定返回的所有行用于更新。

写偏差的更多例子

写偏差乍看像是一个深奥的问题,但一旦意识到这一点,很容易会注意到更多可能的情况。以下是一些例子:

会议室预订系统

假设你想强制执行,同一时间不能同时在两个会议室预订【43】。当有人想要预订时,首先检查是否存在相互冲突的预订(即预订时间范围重叠的同一房间),如果没有找到,则创建会议(请参见示例 7-2)。

在 PostgreSQL 中,你可以使用范围类型优雅地执行此操作,但在其他数据库中并未得到广泛支持。

例 7-2 会议室预订系统试图避免重复预订(在快照隔离下不安全)

1
2
3
4
5
6
7
8
9
10
11
12
13
BEGIN TRANSACTION;

-- 检查所有现存的与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 中,使用一个锁来防止丢失更新(确保两个玩家不能同时移动同一个棋子)。但是对棋子加锁并不妨碍玩家将两个不同的棋子移动到棋盘上的相同位置,或者采取其他违反游戏规则的行为。按照正在执行的规则类型,也许可以使用唯一约束,否则很容易发生写入偏差。

抢注用户名

在每个用户拥有唯一用户名的网站上,两个用户可能会尝试同时创建具有相同用户名的帐户。可以在事务检查名称是否被抢占,如果没有则使用该名称创建账户。但是像在前面的例子中那样,在快照隔离下这是不安全的。幸运的是,唯一约束是一个简单的解决办法(第二个事务在提交时会因为违反用户名唯一约束而被中止)。

防止账户透支

允许用户花钱或积分的服务,需要检查用户的支付数额不超过其余额。可以通过在用户的帐户中插入一个试探性的消费项目来实现这一点,列出帐户中的所有项目,并检查总和是否为正值。有了写入偏差,可能会发生两个支出项目同时插入,一起导致余额变为负值,但这两个事务都不会注意到另一个。

导致写入偏差的幻读

所有这些例子都遵循类似的模式:

  1. 一个 SELECT 查询找出符合条件的行,并检查是否符合一些要求。(例如:至少有两名医生在值班;不存在对该会议室同一时段的预定;棋盘上的位置没有被其他棋子占据;用户名还没有被抢注;账户里还有足够余额)
  2. 按照第一个查询的结果,应用代码决定是否继续。(可能会继续操作,也可能中止并报错)
  3. 如果应用决定继续操作,就执行写入(插入、更新或删除),并提交事务。 这个写入的效果改变了步骤 2 中的先决条件。换句话说,如果在提交写入后,重复执行一次步骤 1 的 SELECT 查询,将会得到不同的结果。因为写入改变符合搜索条件的行集(现在少了一个医生值班,那时候的会议室现在已经被预订了,棋盘上的这个位置已经被占据了,用户名已经被抢注,账户余额不够了)。

这些步骤可能以不同的顺序发生。例如可以首先进行写入,然后进行 SELECT 查询,最后根据查询结果决定是放弃还是提交。

在医生值班的例子中,在步骤 3 中修改的行,是步骤 1 中返回的行之一,所以我们可以通过锁定步骤 1 中的行(SELECT FOR UPDATE)来使事务安全并避免写入偏差。但是其他四个例子是不同的:它们检查是否不存在某些满足条件的行,写入会添加一个匹配相同条件的行。如果步骤 1 中的查询没有返回任何行,则 SELECT FOR UPDATE 锁不了任何东西

这种效应:一个事务中的写入改变另一个事务的搜索查询的结果,被称为幻读【3】。快照隔离避免了只读查询中幻读,但是在读写事务中,幻读会导致特别棘手的写偏差情况。

物化冲突

幻读的问题是没有对象可以加锁,可以人为地在数据库中引入一个锁对象。

例如,在会议室预订的场景中,可以想象创建一个关于时间槽和房间的表。此表中的每一行对应于特定时间段(例如 15 分钟)的特定房间。可以提前插入房间和时间的所有可能组合行(例如接下来的六个月)。

现在,要创建预订的事务可以锁定(SELECT FOR UPDATE)表中与所需房间和时间段对应的行。在获得锁定之后,它可以检查重叠的预订并像以前一样插入新的预订。请注意,这个表并不是用来存储预订相关的信息 —— 它完全就是一组锁,用于防止同时修改同一房间和时间范围内的预订。

这种方法被称为物化冲突(materializing conflicts),因为它将幻读变为数据库中一组具体行上的锁冲突。不幸的是,弄清楚如何物化冲突可能很难,也很容易出错,而让并发控制机制泄漏到应用数据模型是很丑陋的做法。出于这些原因,如果没有其他办法可以实现,物化冲突应被视为最后的手段。在大多数情况下。可序列化(Serializable)  的隔离级别是更可取的。

可序列化

总结一下已讨论的隔离级别如下表。注意写丢失问题并不是隔离级别本身解决的。

隔离级别 描述 解决的问题 未解决的问题
读已提交 事务只能读取到其他事务已提交的对象 脏读、脏写 不可重复读、写丢失、写偏差、幻读
快照隔离 从一致快照中读,读写互不冲突 脏读、脏写、不可重复读 写丢失、写偏差、幻读

弱隔离级别的固有问题:

  • 数据库侧:隔离级别难以理解,并且在不同的数据库中实现的不一致(例如,“可重复读” 的含义天差地别)。
  • 应用侧:只检查应用代码很难判断在特定的隔离级别运行是否安全。 特别是在大型应用程序中,可能并不知道并发发生的所有事情。
  • 工具侧:没有检测竞争条件的好工具。原则上来说,静态分析可能会有帮助,但研究中的技术还没法实际应用。并发问题的测试是很难的,因为它们通常是非确定性的 —— 只有在倒霉的时序下才会出现问题。

可串行化(Serializability)  隔离通常被认为是最强的隔离级别。它保证即使事务可以并行执行,最终的结果也与串行执行一样的,就好像它们没有任何并发性。因此数据库保证,如果事务在单独运行时正常运行,则它们在并发运行时继续保持正确 —— 换句话说,数据库可以防止所有可能的竞争条件

但可串行化也是有代价的,因此没有得到广泛使用。目前大多数提供可串行化的数据库都使用了三种技术之一:

  • 真的串行顺序执行事务
  • 两阶段锁定(2PL, two-phase locking),几十年来唯一可行的选择
  • 乐观并发控制技术,例如  可串行化快照隔离(serializable snapshot isolation)

现在将主要在单节点数据库的背景下讨论这些技术;在第九章中,我们将研究如何将它们推广到涉及分布式系统中多个节点的事务。

真的串行执行

避免并发问题的最简单方法就是完全不要并发:在单个线程上按顺序串行执行事务,这正是可串行化的定义。

尽管这似乎是一个明显的主意,但数据库设计人员只是在 2007 年左右才决定,单线程循环执行事务是可行的【45】。如果多线程并发在过去的 30 年中被认为是获得良好性能的关键所在,那么究竟是什么改变致使单线程执行变为可能呢?

两个进展引发了这个反思:

  • RAM 足够便宜了,许多场景现在都可以将完整的活跃数据集保存在内存中。当事务需要访问的所有数据都在内存中时,事务处理的执行速度要比等待数据从磁盘加载时快得多。
  • 数据库设计人员意识到 OLTP 事务通常很短,而且只进行少量的读写操作。相比之下,长时间运行的分析查询通常是只读的,因此它们可以在串行执行循环之外的一致快照(使用快照隔离)上运行。

串行执行事务的方法在 VoltDB/H-Store,Redis 和 Datomic 中实现。设计用于单线程执行的系统有时可以比支持并发的系统性能更好,因为它可以避免锁的协调开销。但是其吞吐量仅限于单个 CPU 核的吞吐量。为了充分利用单一线程,需要与传统形式的事务不同的结构。

在存储过程中封装事务

在数据库的早期阶段,意图是数据库事务可以包含整个用户活动流程。例如,预订机票是一个多阶段的过程(搜索路线,票价和可用座位,决定行程,在每段行程的航班上订座,输入乘客信息,付款)。数据库设计者认为,如果整个过程是一个事务,那么它就可以被原子化地执行。

不幸的是,人类做出决定和回应的速度非常缓慢。如果数据库事务需要等待来自用户的输入,则数据库需要支持潜在的大量并发事务,其中大部分是空闲的。大多数数据库不能高效完成这项工作,因此几乎所有的 OLTP 应用程序都避免在事务中等待交互式的用户输入,以此来保持事务的简短。在 Web 上,这意味着事务在同一个 HTTP 请求中被提交 —— 一个事务不会跨越多个请求。一个新的 HTTP 请求开始一个新的事务。

即使已经将人类从关键路径中排除,事务仍然以交互式的客户端 / 服务器风格执行,一次一个语句。应用程序进行查询,读取结果,可能根据第一个查询的结果进行另一个查询,依此类推。查询和结果在应用程序代码(在一台机器上运行)和数据库服务器(在另一台机器上)之间来回发送。

在这种交互式的事务方式中,应用程序和数据库之间的网络通信耗费了大量的时间。如果不允许在数据库中进行并发处理,且一次只处理一个事务,则吞吐量将会非常糟糕,因为数据库大部分的时间都花费在等待应用程序发出当前事务的下一个查询。在这种数据库中,为了获得合理的性能,需要同时处理多个事务。

出于这个原因,具有单线程串行事务处理的系统不允许交互式的多语句事务。取而代之,应用程序必须提前将整个事务代码作为存储过程提交给数据库。这些方法之间的差异如   图 7-9 所示。如果事务所需的所有数据都在内存中,则存储过程可以非常快地执行,而不用等待任何网络或磁盘 I/O。

图 7-9 交互式事务和存储过程之间的区别(使用图 7-8 的示例事务)

存储过程的优点和缺点

存储过程在关系型数据库中已经存在了一段时间了,自 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

存储过程与内存存储,使得在单个线程上执行所有事务变得可行。由于不需要等待 I/O,且避免了并发控制机制的开销,它们可以在单个线程上实现相当好的吞吐量。

VoltDB 还使用存储过程进行复制:但不是将事务的写入结果从一个节点复制到另一个节点,而是在每个节点上执行相同的存储过程。因此 VoltDB 要求存储过程是  确定性的(在不同的节点上运行时,它们必须产生相同的结果)。举个例子,如果事务需要使用当前的日期和时间,则必须通过特殊的确定性 API 来实现。

分区

顺序执行所有事务使并发控制简单多了,但数据库的事务吞吐量被限制为单机单核的速度。只读事务可以使用快照隔离在其它地方执行,但对于写入吞吐量较高的应用,单线程事务处理器可能成为一个严重的瓶颈。

为了伸缩至多个 CPU 核心和多个节点,可以对数据进行分区,在 VoltDB 中支持这样做。如果你可以找到一种对数据集进行分区的方法,以便每个事务只需要在单个分区中读写数据,那么每个分区就可以拥有自己独立运行的事务处理线程。在这种情况下可以为每个分区指派一个独立的 CPU 核,事务吞吐量就可以与 CPU 核数保持线性伸缩。

但是,对于需要访问多个分区的任何事务,数据库必须在触及的所有分区之间协调事务。存储过程需要跨越所有分区锁定执行,以确保整个系统的可串行性。

跨分区事务具有额外的协调开销,所以它们比单分区事务慢得多。 VoltDB 报告的吞吐量大约是每秒 1000 个跨分区写入,比单分区吞吐量低几个数量级,并且不能通过增加更多的机器来增加吞吐量【49】。

事务是否可以是划分至单个分区很大程度上取决于应用数据的结构。简单的键值数据通常可以非常容易地进行分区,但是具有多个次级索引的数据可能需要大量的跨分区协调。

串行执行小结

在特定约束条件下,真的串行执行事务,已经成为一种实现可串行化隔离等级的可行办法。

  • 每个事务都必须小而快,只要有一个缓慢的事务,就会拖慢所有事务处理。
  • 仅限于活跃数据集可以放入内存的情况。很少访问的数据可能会被移动到磁盘,但如果需要在单线程执行的事务中访问,系统就会变得非常慢。
    • 如果事务需要访问不在内存中的数据,最好的解决方案可能是中止事务,异步地将数据提取到内存中,同时继续处理其他事务,然后在数据加载完毕时重新启动事务。这种方法被称为  反缓存(anti-caching)
  • 写入吞吐量必须低到能在单个 CPU 核上处理,如若不然,事务需要能划分至单个分区,且不需要跨分区协调。
  • 跨分区事务是可能的,但是它们能被使用的程度有很大的限制。

两阶段锁定

大约 30 年来,在数据库中只有一种广泛使用的串行化算法:两阶段锁定(2PL,two-phase locking)

  • 有时也称为  严格两阶段锁定(SS2PL, strong strict two-phase locking),以便和其他 2PL 变体区分。

2PL 不是 2PC 请注意,虽然两阶段锁定(2PL)听起来非常类似于两阶段提交(2PC),但它们是完全不同的东西。

之前我们看到锁通常用于防止脏写:如果两个事务同时尝试写入同一个对象,则锁可确保第二个写入必须等到第一个写入完成事务(中止或提交),然后才能继续。

两阶段锁定类似,但是锁的要求更强得多。只要没有写入,就允许多个事务同时读取同一个对象。但对象只要有写入(修改或删除),就需要  独占访问(exclusive access)  权限:

  • 如果事务 A 读取了一个对象,并且事务 B 想要写入该对象,那么 B 必须等到 A 提交或中止才能继续(这确保 B 不能在 A 底下意外地改变对象)。
  • 如果事务 A 写入了一个对象,并且事务 B 想要读取该对象,则 B 必须等到 A 提交或中止才能继续(像   图 7-1 那样读取旧版本的对象在 2PL 下是不可接受的)。

在 2PL 中,写入不仅会阻塞其他写入,也会阻塞读,反之亦然。快照隔离使得  读不阻塞写,写也不阻塞读,这是 2PL 和快照隔离之间的关键区别。另一方面,因为 2PL 提供了可串行化的性质,它可以防止早先讨论的所有竞争条件,包括丢失更新和写偏差

实现两阶段锁

2PL 用于 MySQL(InnoDB)和 SQL Server 中的可串行化隔离级别,以及 DB2 中的可重复读隔离级别。

读与写的阻塞是通过为数据库中每个对象添加锁来实现的。锁可以处于  共享模式(shared mode)  或  独占模式(exclusive mode)。锁使用如下:

  • 若事务要读取对象,须先以共享模式获取锁。允许多个事务同时持有共享锁。但如果另一个事务已经在对象上持有排它锁,则这些事务必须等待。
  • 若事务要写入一个对象,必须先以独占模式获取该锁。没有其他事务可以同时持有锁(无论是共享模式还是独占模式),所以如果对象上存在任何锁,该事务必须等待。
  • 如果事务先读取再写入对象,则它可能会将其共享锁升级为独占锁。升级锁的工作与直接获得独占锁相同。
  • 事务获得锁之后,必须继续持有锁直到事务结束(提交或中止)。这就是 “两阶段” 这个名字的来源:
    • 第一阶段:当事务正在执行时,获取锁
    • 第二阶段:在事务结束时,释放所有的锁

由于使用了这么多的锁,因此很可能会发生:事务 A 等待事务 B 释放它的锁,反之亦然。这种情况叫做  死锁(Deadlock)数据库会自动检测事务之间的死锁,并中止其中一个,以便另一个继续执行。被中止的事务需要由应用程序重试。

两阶段锁定的性能

两阶段锁定的巨大缺点,以及 70 年代以来没有被所有人使用的原因,是其性能问题。两阶段锁定下的事务吞吐量与查询响应时间要比弱隔离级别下要差得多

这一部分是由于获取和释放所有这些锁的开销,但更重要的是由于并发性的降低。按照设计,如果两个并发事务试图做任何可能导致竞争条件的事情,那么必须等待另一个完成。

传统的关系数据库不限制事务的持续时间,因为它们是为等待人类输入的交互式应用而设计的。因此,当一个事务需要等待另一个事务时,等待的时长并没有限制。即使你保证所有的事务都很短,如果有多个事务想要访问同一个对象,那么可能会形成一个队列,所以事务可能需要等待几个其他事务才能完成。

因此,运行 2PL 的数据库可能具有相当不稳定的延迟,如果在工作负载中存在争用,那么可能高百分位点处的响应会非常的慢。可能只需要一个缓慢的事务,或者一个访问大量数据并获取许多锁的事务,就能把系统的其他部分拖慢,甚至迫使系统停机。当需要稳健的操作时,这种不稳定性是有问题的。

在基于 2PL 实现的可串行化隔离级别中,死锁会出现的频繁的多(取决于事务的访问模式)。这可能是一个额外的性能问题:当事务由于死锁而被中止并被重试时,它需要从头重做它的工作。如果死锁很频繁,这可能意味着巨大的浪费。

谓词锁

可串行化隔离级别的数据库必须防止幻读

在会议室预订的例子中,这意味着如果一个事务在某个时间窗口内搜索了一个房间的现有预订(见例 7-2),则另一个事务不能同时插入或更新同一时间窗口与同一房间的另一个预订 (可以同时插入其他房间的预订,或在不影响另一个预定的条件下预定同一房间的其他时间段)。

如何实现这一点?需要一个  ** 谓词锁(predicate lock)。它类似于前面描述的共享 / 排它锁,但不属于特定的对象(例如,表中的一行),它属于所有符合某些搜索条件的对象,如:

1
2
3
4
SELECT * FROM bookings
WHERE room_id = 123 AND
end_time > '2018-01-01 12:00' AND
start_time < '2018-01-01 13:00';

谓词锁限制访问:

  • 如果事务 A 想要读取匹配某些条件的对象,就像在这个  SELECT  查询中那样,它必须获取查询条件上的  共享谓词锁(shared-mode predicate lock)。如果另一个事务 B 持有任何满足这一查询条件对象的排它锁,那么 A 必须等到 B 释放它的锁之后才允许进行查询。
  • 如果事务 A 想要插入,更新或删除任何对象,则必须首先检查旧值或新值是否与任何现有的谓词锁匹配。如果事务 B 持有匹配的谓词锁,那么 A 必须等到 B 已经提交或中止后才能继续

这里的关键思想是,谓词锁适用于数据库中尚不存在,但将来可能会添加的对象。如果两阶段锁定包含谓词锁,则数据库将阻止所有形式的写入偏差和其他竞争条件,因此其隔离实现了可串行化。

索引范围锁 / 临键锁

不幸的是,谓词锁性能不佳:如果活跃事务持有很多锁,检查匹配的锁会非常耗时。  因此,大多数使用 2PL 的数据库实际上实现了索引范围锁(index-range locking,也称为  next-key locking),这是一个简化的近似版谓词锁

通过将谓词匹配放大到更大的集合,来简化谓词锁,这是安全的。例如,如果你有在中午和下午 1 点之间预订 123 号房间的谓词锁,则锁定 123 号房间的所有时间段,或者锁定 12:00~13:00 时间段的所有房间(不只是 123 号房间)是一个安全的近似,因为任何满足原始谓词的写入也一定会满足这种更宽松的近似

在房间预订数据库中,你可能会在  room_id  列上有一个索引,并且 / 或者在  start_time  和  end_time  上有索引(否则查询在大型数据库上的速度会非常慢):

  • 假设你的索引位于  room_id  上,并且数据库使用此索引查找 123 号房间的现有预订。现在数据库可以简单地将共享锁附加到这个索引项上,表示事务已搜索 123 号房间用于预订。
  • 或者,如果数据库使用基于时间的索引来查找现有预订,它可以将共享锁附加到该索引中的一系列值,表示事务已经将 12:00~13:00 时间段标记为用于预定。

无论哪种方式,搜索条件的近似都加到索引上。现在,如果另一个事务想要插入、更新或删除同一个房间和 / 或重叠时间段的预订,则它将不得不更新索引的相同部分。在这样做的过程中,它会遇到共享锁,将被迫等到锁被释放。

这种方法能够有效防止幻读和写入偏差。索引范围锁并不像谓词锁那样精确(它们可能会锁定更大范围的对象,而不是维持可串行化所必需的范围),但是由于它们的开销较低,所以是一个很好的折衷

如果没有可以挂载范围锁的索引,数据库可以退化到使用整个表上的共享锁。这对性能不利,因为它会阻止所有其他事务写入表格,但这是一个安全的回退位置。

防止写丢失

乍一看,2PL 下可能还是会出现写丢失,两个事务用 share mode 读到了同一个对象,修改之后,先后升级锁写入,进而出现写丢失。

但上面的情况不会出现,原因在于:上面的两个事务在写入时,需要把锁从 shared mode 升级为 exclusive mode。而锁升级需要事务独占锁时才可完成,它们需要等待对方释放锁,进而出现死锁,被数据库中止。

因此,2PL 下不会出现写丢失的问题。

可串行化快照隔离

本章描绘了数据库中并发控制的惨淡现状:

  • 可串行化隔离:安全,但性能不好(2PL)或者伸缩性不好(串行执行)的
  • 弱隔离级别:性能良好,但容易出现各种竞争条件(丢失更新,写入偏差,幻读等)

串行化的隔离级别和高性能是从根本上相互矛盾的吗?

也许不是:一个称为  可串行化快照隔离(SSI, serializable snapshot isolation)  的算法是非常有前途的。它提供了完整的可串行化隔离级别,但与快照隔离相比只有很小的性能损失。 SSI 是相当新的:它在 2008 年首次被描述【40】,并且是 Michael Cahill 的博士论文【51】的主题。

今天,SSI 既用于单节点数据库(PostgreSQL9.1 以后的可串行化隔离级别),也用于分布式数据库(FoundationDB 使用类似的算法)。由于 SSI 与其他并发控制机制相比还很年轻,还处于在实践中证明自己表现的阶段。但它有可能因为足够快而在未来成为新的默认选项。

悲观与乐观的并发控制

两阶段锁是一种所谓的  悲观并发控制机制(pessimistic) ,它是基于这样的原则:如果有事情可能出错,最好等到情况安全后再做任何事情。这就像互斥,用于保护多线程编程中的数据结构。

从某种意义上说,串行执行可以称为悲观到了极致:在事务持续期间,每个事务对整个数据库(或数据库的一个分区)具有排它锁,作为对悲观的补偿,让每笔事务执行得非常快,所以只需要短时间持有 “锁”。

相比之下,串行化快照隔离  是一种  乐观(optimistic)  的并发控制技术,这意味着,即使存在可能的危险也不中止事务,而是继续执行,希望一切都会好起来。当事务想要提交时,数据库检查是否有什么不好的事情发生(即隔离性是否被违反);如果是的话,事务将被中止,并且必须重试。只有可串行化的事务才被允许提交。

乐观并发控制是一个古老的想法,其优点和缺点已经争论了很长时间。

  • 缺点:如果存在很多争用(contention,即很多事务试图访问相同的对象),则乐观锁表现不佳,因为这会导致很大一部分事务需要中止。如果系统已经接近最大吞吐量,来自重试事务的额外负载可能会使性能变差。
  • 优点:如果有足够的空闲容量,并且事务之间的争用不是太高,乐观的并发控制技术往往比悲观的性能好
    • 可交换的原子操作可以减少争用,例如计数器

顾名思义,SSI 基于快照隔离 —— 也就是说,事务中的所有读取都是来自数据库的一致性快照。与早期的乐观并发控制技术相比这是主要的区别。在快照隔离的基础上,SSI 添加了一种算法来检测写入之间的串行化冲突,并确定要中止哪些事务。

基于前提失效的决策

快照隔离中的写入偏差遵循一个循环模式:

  1. 读取:事务从数据库读取一些数据
  2. 决策:根据查询的结果决定采取一些操作
  3. 写入:写入数据库
  4. 提交

但是,在快照隔离的情况下,原始查询的结果在事务提交时可能不再是最新的,因为数据可能在同一时间被修改。

换句话说,事务基于一个  前提(premise)  采取行动(事务开始时候的事实,例如:“目前有两名医生正在值班”)。之后当事务要提交时,原始数据可能已经改变 —— 前提可能不再成立

总结来说,事务中的查询与写入可能存在因果依赖。为了提供可串行化的隔离级别,如果事务在过时的前提下执行操作,数据库必须能检测到这种情况,并中止事务。

数据库如何知道查询结果是否可能已经改变?有两种情况需要考虑:

  • 读取时:读到了旧 MVCC 对象版本的读取(存在更新,但未提交的写入)
  • 写入时:可能影响先前的读取(读之后发生写入)

检测旧 MVCC 读取

当一个事务从 MVCC 数据库中的一致快照读时,它将忽略取快照时尚未提交的任何其他事务所做的写入。在图 7-10 中,事务 43 认为 Alice 的  on_call = true ,因为事务 42(修改 Alice 的待命状态)未被提交。然而,在事务 43 想要提交时,事务 42 已经提交。这意味着在读一致性快照时被忽略的写入已经生效,事务 43 的前提不再为真

图 7-10 检测事务何时从 MVCC 快照读取过时的值

为了防止这种异常,数据库需要跟踪一个事务由于 MVCC 可见性规则而忽略另一个事务的写入。当事务想要提交时,数据库检查是否有任何被忽略的写入现在已经被提交。如果是这样,事务必须中止。

延迟到提交时检测,而不是发现过时读取立即中止,是为了避免不必要的中止:

  • 数据库并不知道事务之后是否会发生基于这些读取的写入操作,有可能是只读查询
  • 如果事务 42 可能在事务 43 被提交的时候中止或者可能仍然未被提交,因此读取可能不是陈旧的

SSI 保留了快照隔离从一致快照中长时间读取的能力

检测影响之前读取的写入

第二种情况要考虑的是另一个事务在读取数据之后修改数据。这种情况如图 7-11 所示。

图 7-11 在可串行化快照隔离中,检测一个事务何时修改另一个事务的读取。

在两阶段锁中,我们讨论了索引范围锁,它允许数据库锁定与某个搜索查询匹配的所有行的访问权,例如  WHERE shift_id = 1234。可以在这里使用类似的技术,除了 SSI 锁不会阻塞其他事务

在图 7-11 中,事务 42 和 43 都在班次 1234 查找值班医生。如果在  shift_id  上有索引,则数据库可以使用索引项 1234 来记录事务 42 和 43 读取这个数据的事实。(如果没有索引,这个信息可以在表级别进行跟踪)。这个信息只需要保留一段时间:在一个事务完成(提交或中止),并且所有的并发事务完成之后,数据库就可以忘记它读取的数据了。

当事务写入数据库时,它必须在索引中查找最近曾读取受影响数据的其他事务。这个过程类似于在受影响的键范围上获取写锁,但这并不会阻塞当前事务,而是像警戒线一样只是简单通知其他事务:你们读过的数据可能不是最新的啦

在图 7-11 中,事务 43 通知事务 42 其先前读已过时,反之亦然。事务 42 首先提交并成功,尽管事务 43 的写影响了 42 ,但因为事务 43 尚未提交,所以写入尚未生效。然而当事务 43 想要提交时,来自事务 42 的冲突写入已经被提交,所以事务 43 必须中止。

可串行化快照隔离的性能

许多工程细节会影响算法的实际表现。例如一个权衡是跟踪事务的读取和写入的粒度(granularity)

  • 细粒度:数据库详细地跟踪每个事务的活动,那么可以准确地确定哪些事务需要中止,但是会有显著的记录开销
  • 粗粒度:记录开销低,但可能会导致更多不必要的事务中止。

在某些情况下,事务可以读取被另一个事务覆盖的信息:这取决于发生了什么,有时可以证明执行结果无论如何都是可串行化的。 PostgreSQL 使用这个理论来减少不必要的中止次数。

与两阶段锁定相比,可串行化快照隔离的最大优点是一个事务不需要阻塞等待另一个事务所持有的锁。就像在快照隔离下一样,写不会阻塞读,反之亦然。这种设计原则使得查询延迟更可预测,波动更少。特别是,只读查询可以运行在一致快照上,而不需要任何锁定,这对于读取繁重的工作负载非常有吸引力。

与串行执行相比,可串行化快照隔离并不局限于单个 CPU 核的吞吐量:FoundationDB 将串行化冲突的检测分布在多台机器上,允许扩展到很高的吞吐量。即使数据可能跨多台机器进行分区,事务也可以在保证可串行化隔离等级的同时读写多个分区中的数据【54】。

中止率显著影响 SSI 的整体表现。例如,长时间读取和写入数据的事务很可能会发生冲突并中止,因此 SSI 要求同时读写的事务尽量短(只读的长事务可能没问题)。对于慢事务,相较于两阶段锁定或串行执行,SSI 可能更不敏感。

总结

事务是一个抽象层,允许应用程序假装某些并发问题和某些类型的硬件和软件故障不存在。各式各样的错误被简化为一种简单情况:事务中止(transaction abort),而应用需要的仅仅是重试。

在本章中介绍了很多问题,事务有助于防止这些问题发生。并非所有应用都易受此类问题影响:具有非常简单访问模式的应用(例如每次读写单条记录)可能无需事务。但是对于更复杂的访问模式,事务可以大大减少需要考虑的潜在错误情景数量

如果没有事务处理,各种错误情况(进程崩溃,网络中断,停电,磁盘已满,意外并发等)意味着数据可能以各种方式变得不一致。例如,非规范化的数据可能很容易与源数据不同步。如果没有事务处理,就很难推断复杂的交互访问可能对数据库造成的影响。

本章深入讨论了  并发控制  的话题。我们讨论了几个广泛使用的隔离级别,特别是  读已提交快照隔离(有时称为可重复读)和  可串行化。并通过研究竞争条件的各种例子,来描述这些隔离等级:

问题 描述 解决
脏读 读取到另一客户端尚未提交的写入 保存已提交和最新版本的对象,读已提交  或更强的隔离级别可以防止脏读
脏写 覆盖另一客户端尚未提交的写入 加锁,几乎所有的事务实现都可以防止脏写
读取偏差 (不可重复读) 同一事务中,客户端在不同的时间点会看见数据库的不同状态 快照隔离,允许事务从一个特定时间点的一致性快照中读取数据。快照隔离通常使用  多版本并发控制(MVCC)**  来实现
丢失更新 两个客户端同时执行  读取 - 修改 - 写入序列。其中一个写操作,在没有合并另一个写入变更情况下,直接覆盖了另一个写操作的结果。所以导致数据丢失。 快照隔离的一些实现可以自动防止这种异常,而另一些实现则需要手动锁定(SELECT FOR UPDATE
写偏差 事务读取一些东西,根据它所看到的值作出决定,并将该决定写入数据库。但是,写入时,该决定的前提不再成立。 只有可串行化的隔离才能防止这种异常
幻读 事务读取符合某些搜索条件的对象。另一个客户端进行写入,影响搜索结果 快照隔离可以防止直接的幻读,但是写入偏差上下文中的幻读需要特殊处理,例如索引范围锁定

弱隔离级别可以防止其中一些异常情况,但要求应用程序手动处理剩余那些(例如,使用显式锁定)。只有可串行化的隔离才能防范所有这些问题。本章讨论了实现可串行化事务的三种不同方法:

  • 字面意义上的串行执行 如果每个事务的执行速度非常快,并且事务吞吐量足够低,足以在单个 CPU 核上处理,这是一个简单而有效的选择。
  • 两阶段锁定 数十年来,两阶段锁定一直是实现可串行化的标准方式,但是许多应用出于性能问题的考虑避免使用它。
  • 可串行化快照隔离(SSI) 一个相当新的算法,避免了先前方法的大部分缺点。它使用乐观的方法,允许事务执行而无需阻塞。当一个事务想要提交时,它会进行检查,如果执行不可串行化,事务就会被中止。

本章中的示例主要是在关系数据模型的上下文中。但是,正如在多对象事务的需求中所讨论的,无论使用哪种数据模型,事务都是有价值的数据库功能

本章主要是在单机数据库的上下文中,探讨了各种想法和算法。分布式数据库中的事务,引入了一系列新的困难挑战,将在接下来的两章中讨论。

参考