简化容错,不惧失败

在实际的生产环境中,分布式数据系统面临着命运的裁决,诸多不幸随时可能发生:

  • 系统侧:数据库软件和硬件系统在任何时间都有可能会发生故障
  • 应用侧:应用程序在任意时刻都可能会崩溃
  • 网络侧:数据库与应用、或与其他数据库节点的连接随时可能断开
  • 并发:多个客户端并发写入时,可能会有竞态条件和相互覆盖
  • 半读:一个客户端可能会读到部分更新的数据库

复杂度是不灭的,只能转移。为了实现可靠性,数据库必须处理这些故障,如果数据库对这些故障不做任何处理,应用层就需要处理上述所有相关问题,会极大的增加应用侧编程的复杂度。事务,就是为简化应用编程模型而生的,事务为应用程序提供了安全保证(safety guarantees),使得应用程序可以自由地忽略某些潜在的错误情况和并发问题。

简单来说,事务(transaction) 是将多个读写操作组合成一个逻辑单元进行执行,并提供一种保证,事务中的所有操作被视作单个操作来执行:整个事务要么成功(提交 commit),要么失败(被动终止 abort,或主动回滚 rollback)。如果事务执行失败,应用程序可以安全地重试,不用担心存在部分失败的情况,即某些操作成功,某些操作由于某种原因失败的一种中间状态。

时间的角度看,事务在一个生命周期中保证了一组操作的整体性,从空间的角度看,事务在多个事务间做好了并发控制。

当然,事务不是天然存在的,事务简化了应用编程模型,但任何便利性都是有代价的,使用事务的时候一定程度上牺牲了性能和可用性。如果有多个客户端的事务并发执行,还会涉及到隔离性的问题。

从另一个角度来说,也不是所有的应用都需要事务,有时候弱化事务保证或者完全放弃事务也是完全可以接受的,因为这样可以获得更高的性能或者更高可用性,而且一些安全属性也可以在没有事务的情况下实现。一般来说,数据库允许用户在隔离级别与性能之间做选择。

棘手的概念

现在几乎所有的关系型数据库和一些非关系型数据库都支持事务,大多数遵循 IBM System R(第一个 SQL 数据库)在 1975 年引入的风格。

但是近些年,NoSQL 的发展对事务的概念造成了一些冲击,在 2000 年后为了支持大规模分布式数据的存储,NoSQL 引入了分区、冗余,部分放弃了对原有事务的完整支持。部分新一代数据库通过重新定义“事务”来号称仍然支持事务,亦或是为了商业的宣传引入近似的名词。

其中不乏两种极端的观点,一种认为事务与可伸缩性不可兼得,大型的分布式系统必须放弃事务以保持高可用和高性能;另一种认为事务是保证高可用的应用不丢失数据的必要条件。这两种观点都有失偏颇,与任何技术一样,事务有其优点和局限性,为了理解这些权衡,我们需要了解事务在正常情况与极端情况下锁提供保证的细节。

ACID 的含义

事务提供的安全保证通常由缩略词 ACID 来描述,ACID 代表原子性(Atomicity)一致性(Consistency)隔离性(Isolation)持久性(Durability)。它由 Theo Härder 和 Andreas Reuter 于 1983 年提出,目的是为数据库中的容错保证提供一种相对精确的描述。但是不同数据库对 ACID 的支持并不相同,尤其是 Isolation-隔离性。如今,ACID 更多的沦为一个营销术语。

与 ACID 一同提到的另一个标准为 BASE,代表了基本可用性(Basically Available)软状态(Soft State)最终一致性(Eventual consistency)。它比 ACID 的定义更模糊,因此 BASE 更多用来描述不符合 ACID 标准的系统。

下面将逐一探究 Atomicity、Consistency、Isolation 和 Durability 的精确含义,以此提炼出事务的思想。

原子性(Atomicity)

原子一般指不可分割的最小单位。在并发编程中,一个线程执行一个原子操作,这意味着另一个线程无法看到该操作执行到一半的中间结果。系统只能处于操作之前或操作之后的状态,而不是介于两者之间的状态。

但是 ACID 的原子性并不是关于并发的,它更多描述的是单个客户端/线程内,一组操作可以被原子执行,如果执行到一半,已经执行的操作可以被全部回滚。原子性提供的保证是在发生错误时,会回滚该事务所有已经写入的变更

如果没有原子性,在多处变更进行到一半时发生错误(例如进程崩溃、网络连接中断、磁盘变满或某种完整性约束被违反),很难知道哪些变更已经生效,哪些变更没有生效。此时应用程序可以再试一次,但是这需要承担某些变更两次执行生效的风险,这可能会导致重复数据或者错误的数据。原子性简化了这个问题,使得引用程序可以放心和安全地重试。以此看来,原子性或许叫可中止性(abortability) 会更好。

一致性(Consistency)

一致性是一个被广泛使用的词,在不同的上下文中,有着不同的含义:

  1. 多副本:包括多副本一致性和异步复制带来的最终一致性问题
  2. 一致性哈希:一种分区和调度的方式,在增删机器节点后,可以以较小代价进行副本迁移和负载均衡。
  3. CAP 定理:其中的一致性指的是线性一致性(强一致性),是多副本间一致性的一种特例,基本想法是让一个系统看起来好像只有一个数据副本,而且所有的操作都是原子性的。
  4. ACID:数据库在应用程序的视角处于某种”一致性的状态“。

在使用一致性的术语时,需要明确其所属上下文,才能进而明确其含义。具体到 ACID 中,一致性是指对数据的一组特定约束必须始终成立,即对某些不变性(invariants) 的维持。所谓的不变性即某些约束条件,比如说在银行账户中,在任何时刻账户的余额必须等于收入减去支出,又如会计系统中,所有账户整体上必须借贷相抵。

不同于 ACID 的其他特性,一致性是需要应用侧与数据库侧共同维护的:

  1. 应用侧要保证写入满足应用侧视角约束要求的数据。尽管一些特定的不变式可以由数据库来检查,如唯一约束或外键约束等,但一般来说,应用侧可以定义什么样的数据是有效的,什么样的数据是无效的,而数据库只管存储。
  2. 数据库侧要保证多次写入前后,尤其是遇到问题时,维持该约束。

我们可以这么说,应用侧可能依赖于数据库提供的原子性和隔离性来实现一致性。可见,一致性并不仅取决于数据库,它表现更多的是应用侧的一种属性。

乔・海勒斯坦(Joe Hellerstein)指出,在 Härder 与 Reuter 的论文中,“ACID 中的 C”是被“扔进去凑缩写单词的”,而且那时候大家都不怎么在乎一致性。

隔离性(Isolation)

多个客户端并发访问相同的数据库记录时,会产生并发问题,或者称为竞态条件(race condition)

下图是一个简单例子。假设有两个客户端同时在数据库中递增同一个计数器,每个客户端需要先读取计数器的值,加 1,再写回新值。如图所示,计数器的值本应从 42 增长至 44,由于竞态条件,实际上只增长至 43。

两个客户端并发递增计数器

ACID 的隔离性用于解决这种问题。隔离性的定义是指每个事务的执行都是互相隔离的,每个事务都认为自己是系统中唯一正在运行的事务。在传统教科书上,这种事务隔离形式被称为可串行化(Serializability),即如果事务都串行执行,则任意时刻必然只有一个事务在执行,从而在根本上消除了可能存在的并发问题。用另一句话说,数据库的隔离性确保了多个事务并行执行的结果应当与这些事务串行执行(一个接一个)的结果是一样的

在实际生产中很少使用可串行化这么强的隔离性,因为它会带来性能损失。实际上隔离性强弱类似于一个光谱,数据库系统提供商一般会实现其中几个,用户可以根据业务情况在隔离性和性能间进行选择。后文我们会详细讨论除可串行化外的几种弱隔离级别

持久性(Durability)

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

在单节点(单机)数据库中,持久性意味着以数据页(Page)或日志形式(WAL)写入了非易失性存储。在多副本(replication)数据库中,持久性意味着数据已经复制到了多数节点中。

然而,完美的持久性是不存在的,它只能做到某种程度的保证:如果所有硬盘和所有副本备份同时被销毁,那显然没有任何数据库能救得了你,数据必然丢失。

在现实世界中,存储涉及到的所有环节都不是完美的:

  • 写入磁盘后宕机,虽然数据没丢失,但是在机器修复或磁盘转移前,数据服务是不可用的。多副本冗余(Replication)系统可以解决这个问题。
  • 一个关联性的故障,如软件 bug 或者机房断电,可以同时摧毁一个机房中的所有副本,任何仅存储在内存中的数据都会丢失。因此内存数据库仍然需要定期持久化到外存。
  • 异步复制系统中,当主副本不可用时,由于数据没来得及同步到多数节点,最近成功写入主副本的数据可能会丢失。
  • 当突然断电时,固态硬盘不能保证数据已经完全刷盘,甚至用户显式调用 fsync 都无济于事。此外,磁盘驱动也可能有 bug。
  • 磁盘上的数据可能随着时间逐渐损坏,甚至副本数据也可能同时损坏,此时只能依赖于历史备份来恢复数据。

在实践中,没有一种技术可以提供绝对保证。因此数据的持久性要通过多种手段来保证,如强制刷盘、校验码、异地多机房复制、定期备份等,但这也只能做到部分的保证,而非绝对保证。与往常一样,最好抱着怀疑的态度接受任何理论上的“保证”。

单对象和多对象操作

在 ACID 中,原子性和隔离性描述了客户端在同一事务中执行多次写入时数据库提供的保证,并且它们通常假设一个事务中会同时修改多个对象(行,文档,记录)。比起单对象事务,这种多对象事务是一种更强的保证,且更为实用,因为通常多个写入不会只针对单个对象。

假设有一个电子邮件应用,通过以下语句来查询显示用户未读邮件的数量:

1
SELECT COUNT(*FROM emails WHERE recipient_id = 2 AND unread_flag = true

如果邮件太多,查询可能会比较慢,你可能会使用单独一个字段来存储未读邮件的数量(反范式化denormalization),每次新增和读过邮件都需要更新该字段值。

在下图中,用户 2 遇到了异常情况:邮件列表中显示有未读消息,但是未读数字段值却显示为 0,因为此时未读数字段值还未递增。你可能觉得未读邮件数错误不是什么很重要的事,换种角度,如果这个是客户账户余额,将邮件收发看成支付交易,这种错误将造成不可估量的影响。

违反隔离性:一个事务读取另一个事务的未被执行的写入(“脏读”)

所幸隔离性可以解决这种问题,使得用户 2 要么看到用户 1 的所有更新,要么看不到任何更新。

在下图中,原子性提供了保证。如果事务执行过程中发生了错误,原子性会保证如果未读数字段值更新失败,新增的邮件也会被回滚。

原子性确保发生错误时,事务先前的任何写入都会被撤消,以避免状态不一致

多对象事务需要通过某种方式来确定哪些操作是属于同一事务的:

  1. 从物理上来看,可以通过 TCP 连接确定,在同一个连接中,BEGIN TRANSACTIONCOMMIT之间的所有内容,可以认为是属于同一事务的。其中也会有些差错,如客户端在提交请求后,服务器确认提交之前发生网络中断,此时客户端无从得知事务是否已被成功提交。
  2. 从逻辑上来看,可以使用事务管理器,为每个事务分配一个唯一标识符,从而对操作进行分组。MySQL 中 MVCC 的实现就是如此,为每个事务分配了一个独一无二的 trx_id 以标识操作属于哪个事务。

另一方面,许多非关系型数据库并没有将这些操作组合成一个逻辑单元的方法,即使可能存在 BATCH API,但它们不一定具有事务语义,可能有些对象操作成功,有些对象操作失败。

单对象写入

当对单个对象进行变更时,原子性和隔离性依然能提供保障。例如,假设你需要向数据库写入一个 20KB 的 JSON 文档:

  • 如果在发送第一个 10KB 后网络连接中断,数据库是否存储了前 10KB 无法解析的 JSON 片段?
  • 如果该操作是在覆盖一个老版本同 id 数据,覆盖一半时发生了电源故障,数据库是否会存在一半新值一半旧值的情况?
  • 如果有另一个客户端同时在读取该文档,是否会看到半更新状态?

这些问题让人头大,如果数据库不提供任何保证,应用侧需要写很多错误处理逻辑。因此存储引擎一个几乎普遍的目标是:对单节点的单个对象(如键值对)上提供原子性和隔离性保证。原子性使得数据库可以通过日志(WAL)来实现崩溃恢复,且可以使用每个对象上的锁来实现隔离(每次只允许一个线程访问该对象)。

另外一些数据库也会提供更复杂的原子操作支持,如原子自增操作,避免了上文讨论隔离性时多个客户端并发自增计数器导致的交错更新。另一种更加泛化的原子性保证是提供单个对象上的 CAS 操作,运行用户原子执行针对单个对象的 read-modify-write 操作。细想一下,原子自增(atomic increment)在 ACID 中实际上是指隔离性(Isolation)的范畴,此处的原子自增则是多线程中的概念。

在许多 NoSQL 服务中,CAS 以及其他单一对象操作被称为“轻量级事务”,甚至处于营销目的重新定义为“ACID”,但是这个术语是具有误导性的。事务通常被理解为将多个对象的多个操作合并为一个执行单元的机制

界定对多对象事务的需求

由于多对象事务很难跨分区实现,且会可能会非常损失性能,许多分布式数据存储都放弃了多对象事务。但是有的场景确实需要多对象事务,因此一些数据库将其是否打开事务作为一个选项供用户选择。

因此,在用户侧,数据库选型时,需要审视一下是否真的需要多对象事务,是否只用键值数据模型和单对象操作就能满足需求。一些情况下是可以的,但更多的场景还是需要协同更新多个对象:

  • 在关系型数据库中,一些表通常存有外键,在更新时需要进行同步更新。
  • 在文档型数据库中,一些相关数据通常会存放在同一个文档中,单个文档被视作单个对象,更新单个文档时确实不需要多对象事务。但是由于大部分的文档数据库不支持连接操作,因此不得不使用前文提到的数据库非规范化 denormalization 对数据进行冗余存储,此时就产生了同步更新的需求。
  • 在支持次级索引的数据库中,数据和对应的多个索引需要进行同步更新。

如果数据库没有实现多对象事务,那么这些保证只能在应用侧实现,徒增了复杂度,且很容易出错。

故障与中止

事务一个关键特征是如果发生错误,它可以中止并安全地重试。ACID 数据库就基于这样的哲学:当出现违反原子性、一致性或持久性的危险,宁愿完全丢弃已经执行的变更,而不是留下部分执行成功的半成品。

然而不是所有的数据库系统都遵循这个哲学。例如多副本中的无主模型,就采用了“尽力而为”的模型,即尽可能保证完成任务,如不能完成,也不会回滚已经发生的修改。因此,从错误中恢复是应用程序的责任。

尽管无脑重试被中止的事务简单而有效,但是这并不是万能的:

  1. 事务被成功提交,但是返回给用户时出错。用户如果简单重试,会使得该事务中的操作被执行两次,造成错误数据。除非用户在应用侧进行去重(如保证多次执行这些语句的结果都是一致的)。
  2. 由于系统负载过高而导致事务执行失败。如果简单重试,会进一步加重系统的负担。此时可以使用指数后退方式重试,并且限制最大重试次数。TCP 协议中的保活机制就是一个例子。
  3. 一些临时错误,如死锁、异常、网络抖动和故障切换时,重试才有效;而对于一些永久性故障,如磁盘损坏,此时重试是没有意义的。
  4. 某事务在数据库之外如果有副作用,重试事务时会导致副作用多次发生。如果某个副作用是发送邮件,则肯定不希望事务每次重试时都发送一次电子邮件。如果想进行多个系统间的协同,可以考虑两阶段提交(2PC,two-phase commit)
  5. 如果客户端进程在重试中失效,任何试图写入数据库的数据都将丢失。

弱隔离级别

如果两个事务需要变更的数据之间没有交集,则可以安全地 并行(parallel) 执行,否则会出现竞态条件。并发 BUG 很难通过测试找到,因为这样的错误只在特殊时序下才会触发,而这样的时序问题可能非常少发生,通常很难复现。在大型应用中,单客户端情况的开发已经很麻烦了,有多个客户端并发访问更加剧了开发难度。

数据库通过 事务隔离(transaction isolation) 来给用户提供一种隔离保证,隐藏应用程序开发者的并发问题,从而简化应用程序的开发。从理论上讲,隔离可以通过假装没有并发来发生:即 可串行化(Serializability) 的隔离等级,意味着数据库保证事务的效果如同串行执行,任何时刻都只有一个事务在执行。

从实现的角度对几种隔离级别进行理解,会简单一些。如 ANSI SQL 定义的四种隔离级别: 读未提交(Read Uncommited)读已提交(Read Commited)可重复读(Repeatable Read)可串行化(Serializability) ,都可以从使用锁实现事务的角度来理解。

最强隔离性的隔离级别——可串行化,可以理解为一把全局的排它锁,每个事务启动时使用,在提交、回滚或终止时释放,这种隔离级别无疑性能最差。这侧面反映了其它几种弱隔离级别的意义:提高性能,缩小加锁的粒度、减小加锁的时间,从而牺牲一部分事务保证来换取性能。从上锁的强度考虑,有互斥锁(Mutex Lock,也称为写锁)和共享锁(Shared Lock,又称为读锁);从上锁的长短来考虑,有长时锁(Long Period Lock,在事务开始时获取锁,尽管中途需要保证事务的动作已经执行完成,也要到事务结束时才释放锁)和短时锁(Short Period Lock,执行动作前申请锁,执行结束后立即释放锁);从上锁的粗细来考虑,有对象锁(Row Lock,在关系型数据库中描述为锁住一行数据)和谓词锁(Predicate Lock,锁住一个范围内的数据)。

以锁来考虑隔离级别并没有覆盖到一个常见的隔离级别——快照隔离(SI,Snapshot Isolation),因为它引出了另一个实现技术——多版本并发控制(MVCC,multi-version concurrency control)。由于属于不同的实现,快照隔离和可重复读在隔离级别的光谱上属于一个偏序关系,不能说谁强于谁。

接下来将讨论几种弱隔离级别,以及隔离级别不够导致的几种现象——丢失更新(Lost Update)、写偏序(Write Skew)和幻读(Phantom Read)。

读已提交

性能最好的隔离级别是完全不上任何锁,但是其中会存在脏读和脏写的问题,为了避免脏写,需要给要更改的对象加长时写锁,读数据时并不加锁,此时隔离级别为读未提交(RU,Read Uncommitted)。但是此时仍然会有脏读,为了避免脏读,可以对要读取的对象加短时读锁,此时的隔离级别就是读已提交(RC,Read Committed)。

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

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

没有脏读

如果一个事务 A 能够读到另一个未提交事务 B 的中间状态,则称为脏读(dirty reads)。在读已提交的隔离级别上运行的事务是不会有脏读的。举个例子,如下图所示,在用户 1 提交之前,用户 2 读到的值一直是 2。

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

试想一下允许脏读的情况:

  1. 一个事务可以看到另一个未提交事务的中间状态。如上文的邮件未读数的例子,读取到部分更新状态的数据库会让用户感到迷惑,并可能导致其他事务做出错误的决定。
  2. 如果事务中止,回滚所有操作,允许脏读会让另一个事务读取到被回滚的数据。

没有脏写

两个事务同时尝试更新数据库中的相同对象,写入的顺序我们是无法得知的,但是我们知道后面的写入通常会覆盖前面的写入。如果先写入的是尚未提交事务的一部分,那么后一个写入会覆盖这个尚未提交的值,这被称为脏写(dirty write)。

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

通过禁止脏写,可以避免一些并发产生的不一致问题:

  1. 如果多个事务同时更新相交的多个对象,脏写可能会产生错误的结果。如下图的二手车销售,购买汽车需要两个步骤:更新购买列表、将发票发给买家。如果 Alice 和 Bob 的购买事务允许脏写,则可能出现 Bob 购买到了商品(他成功更新了商品列表),而发票却发给了 Alice 的情况(她成功更新了发票表)。
  2. 但是读已提交并不能防止讨论隔离性时提到的更新计数器的竞态条件问题,因为这属于一种更新丢失现象。两个事务都是读的已提交的数据(因此不是脏读),且写入时,另一个事务写入发生在前一个事务之后(因此不是脏写),但仍然不能避免写入丢失的问题(只增加了一次)。

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

实现

读已提交是一个常见的隔离级别,是 Oracle 11g、PostgreSQL、SQL Server 2012、MemSQL 和其他许多数据库的默认设置。

那么如何实现读已提交的隔离级别呢?

首先是脏写,最简单且最常见的解决办法是使用行锁(起源于关系型数据库),即针对单条数据的长时写锁(Long Period Write Lock)。当事务想要修改某个对象时,先获取该对象的锁,如果已经被获取,则等待;如果成功获取,则可以写入数据,等待事务提交时才释放锁。

其次是脏读,可以使用针对单条数据的短时读锁来解决脏读问题。读锁可以并发,但是与上述的写锁是互斥的,这可以保证有脏数据(未提交的更改)时,其他事务针对该对象的读取都会被阻塞。但使用行锁的性能也不是很好,因为一个长写事务,可能会把其他读取该对象的读事务给“饿死”,损失性能且造成长时间延迟。

处于这个原因,大多数数据库会使用非锁的形式实现读已提交:对于写入的某个对象,数据库会记住旧的已提交值,和由当前持有写入锁的事务设置的新值。当写事务正在进行时,任何其他读取对象都会读到旧值,只有当新值提交时,其余读事务才会读取到新值。将其泛化一下,就是我们常说的 MVCC。

快照隔离和可重复读

读已提交似乎满足了事务所需的一切。它允许中止,满足了原子性的要求;它防止读取不完整的事务结果,并且防止并发写入造成的混乱,满足了隔离性的要求。

但是使用此隔离级别的时候,仍有很多地方可能会产生并发错误。如下图所示,考虑这么一种场景,Alice 分两个账户,各存了 500 块,但如果其两次分别查看两个账户期间,发生了一笔转账交易,则两次查看的余额加起来并不等于 100。对 Alice 来说,现在她的账户似乎总共只有 900 块——看起来有 100 块已经凭空消失了。

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

这种异常被称为不可重复读(non-repeatable read)或者叫读倾斜(read skew,skew 有点被过度使用)。读已提交隔离级别是允许不可重复读的,如上述例子,每次读取到的都是已提交的内容。

上述例子中的不一致状态只是暂时的,但在某些情况下,这种暂时的不一致也是不可接受的:

  1. 备份:备份可能需要花费很长时间,由于备份过程中会有读写存在,从而导致备份时数据的不一致。如果之后再使用此备份进行恢复,则会造成永久的不一致。
  2. 分析型查询和完整性检查:这个操作与备份一样,耗时都比较长。如果执行过程中有其他事务并发导致出现不一致的现象,就会导致返回的结果有问题。

快照隔离(snapshot isolation)的隔离级别可以解决上述的问题,使用快照隔离级别时,每个事务都可以取得一个某个时间点的一致性快照(consistent snapshot),在整个事务期间,读取到的状态都是该时间点的快照,其他事务的修改并不会影响到该快照的数据。

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

快照隔离的实现

与读已提交一样,快照隔离也使用加锁的方式来防止脏写,但是进行数据读取的时候不使用锁。快照隔离的一个关键原则就是“读不阻塞写,写也不阻塞读”,从而允许用户在长时间查询时不影响新的写入。

为了实现快照隔离,保证读不阻塞写,且避免脏读,数据库需要对同一对象保留多个已提交的版本,我们称之为多版本并发控制(MVCC,multi-version concurrency control)。

如果一个数据只需要实现到读已提交级别,那么保留两个版本就够了(旧版本和新版本)。但是如果要实现快照隔离级别,一般使用 MVCC。相对于锁而言,MVCC 是一种事务实现的流派,而且在近些年来很受欢迎。当然,MVCC 也是一种思想,具体到实现,有 MVTO(Timestamp Ordering)、MVOCC(Optimistic Currenccy Control)、MV2PL(2 Phrase Lock)等,即基于多版本,加上一种避免写写冲突的方式。

具体来说,使用 MVCC 流派,也可以实现读未提交、读已提交、快照隔离、可串行化等隔离级别。

  1. 读已提交在查询语句粒度使用单独的快照,且快照粒度更小,因此性能更好。
  2. 快照隔离在事务粒度使用相同的快照,主要是为了解决不可重复读问题。

MVCC 的基本要点如下:

  1. 每个事务开始时会获取一个自增的、唯一的事务 ID(txid),该 txid=max(existing txid) + 1。
  2. 该事务在修改数据时,不会修改以前的版本,而是会新增一个具有 txid 版本的数据。
  3. 该事务只能访问所有版本<=txid 的数据。
  4. 在写入时,如果发现某个数据存在>txid 的版本,则存在写写冲突。

下图是 PostgreSQL 中基于 MVCC 实现快照隔离的示意图,场景是两个账户,每个账户各有 500 块。例子通过使用两个版本信息字段:created_by 和 deleted_by 来标记一个数据版本的生命周期。如果某个事务删除了一行,那么该行实际上并未从数据库中删除,而是通过在 deleted_by 字段打上请求删除事务的 txid 来标记删除,在稍后时间当确定没有事务访问该已删除数据时,数据库中的垃圾回收机制就会将所有带有删除标记的行移除,并释放其空间。

在 PostgreSQL 中,created_by 的实际名称为 xmin,deleted_by 的实际名称为 xmax

使用多版本对象实现快照隔离

在上述过程中,UPDATE操作被翻译为DELETEINSERT,余额为 500 块的行会被标记为被事务 13 删除,而余额为 400 块的行由事务 13 创建。

可见性规则

在多版本并发控制中,对每个对象来说,最重要的是控制其版本对事务的可见性,保证事务能够看到一致性的视图。

在多版本并发控制中,每个对象都有多个版本,上文提到一个事务只能访问到所有版本<=txid 的数据其实是较为粗略的说法,展开来讲:

  1. 事务开始时,所有正在进行的事务(包括已经开始但是未提交或终止的事务),所做的任何写入都会被忽略。
  2. 被中止的事务,所做的任何写入都会被忽略。
  3. 具有较晚事务 ID 的事务所做的任何写入都会被忽略。
  4. 剩余其他的数据都对此事务可见。

如果事务 txid 是自增的,可以理解为:

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

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

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

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

索引与快照隔离

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

在实践中,有许多实现细节共同决定了多版本并发控制的性能。例如,如果同一对象的不同版本可以放入同一个数据页中,PostgreSQL 的优化使得对应的索引指向可以不用更新。 Bruce Momjian: “MVCC Unmasked,” momjian.us, July 2014.

CouchDB、Datomic 和 LMDB 使用的是另一种方式,仅 追加 / 写时拷贝(append-only/copy-on-write) 的 B 树变体,是一种多版本技术的变体。boltdb就是参考的 LMDB,也可以归为此类。此类 B 树每次修改,都会引起叶子节点(所有数据都会落到叶子节点)到根节点的一条路径级联更新(叶子节点变了,其父节点内容——指针,也要跟着修改,因此引起级联更新),如果引起节点的分裂或合并,会引发更大范围的更新和修改。

这种修改不会覆盖旧的页面,每个修改页面都会创建一份副本,更新的节点会指向其子页面的新版本。使用仅追加的 B 树,每个写入事务都会创建一颗新的 B 树,当创建时,从该特定树根节点生长的树就是数据库的一个一致性视图。没必要根据事务 ID 来过滤掉事务,因为后续写入的事务都不能修改现有的 B 树,它们只能创建新的树根(副本)来修改。很显然,这种方式也需要一个负责压缩和垃圾收集的后台进程。

命名困惑

在 1975 年 System R 定义 ANSI SQL 标准的隔离级别时,只定义了 RU、RC、RR 和 Serializability。当时,快照隔离还没有被发明,但是上述四种级别汇总有一个和快照隔离类似的级别:可重复读(RR,Repeatable Read)。

许多数据库实现了快照隔离,却使用不同的名字来称呼。在 Oracle 中称为 可串行化(Serializable) 的,在 PostgreSQL 和 MySQL 中称为 可重复读(repeatable read)

这种命名混淆的原因在于 SQL 标准没有快照隔离的概念,因为那时快照隔离的概念还没正式下定义。相反,它定义了可重复读,看起来和快照隔离很像,于是 PostgreSQL 和 MySQL 称其快照隔离级别为可重复读,因为这样可以符合 SQL 标准要求,以号称兼容 SQL 标准。

严格来说,SQL 标准对隔离级别的定义是有缺陷的,模糊且不精确的。虽然一些文献中有对可重复读进行了精确定义,但大部分实现并不严格满足此定义。到最后,没有人知道可重复读的真正含义。

防止丢失更新

读已提交和快照隔离级别主要保证了只读事务在并发写入时可以读到什么,却忽略了两个事务并发写入的问题,一种特定类型的写 - 写冲突可能出现的。

并发写入事务之间的冲突中,最著名的就是丢失更新问题,像前文提到的俩客户端并发递增计数器的例子。

更新丢失问题发生的关键在于,两个事务中都有读后写序列(读取 - 修改 - 写入序列,写偏序也是这个序列,但是是针对多个对象),即写依赖于之前的读。如果读到的内容被其他事务修改,则本事务稍后的依赖于此读的写就会发生问题:

  1. 并发更新计数器和余额。
  2. 将本地修改写入一个复杂值中:例如,将元素添加到 JSON 文档中的一个列表(需要解析文档,进行更改再写回修改的文档)。
  3. 两个用户同时修改 wiki 页面,并且都是修改后将页面完整覆写回。

从以上可以看出这是一个普遍的问题,所以已经有了各种解决方案。

原子写

简单来看就是将 read-modify-write 的操作打包成一个原子操作。如下指令,它在大多数关系型数据库中执行是并发安全的:

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

像 MongoDB 这样的文档数据库也提供了对 JSON 文档的一部分进行本地修改的原子操作,Redis 中也提供了修改数据结构(如优先队列)的原子操作。但是不是所有的操作都能被解释为原子操作,如 wiki 页面的更新,设计到任意文本编辑,其实将其表示为原子的变化流也是可以实现的,但是比较复杂。

原子操作通常在读取对象时获取其上的排它锁实现,以便更新完成时没有其他事务可以读取它。这种技术被称为游标稳定性(cursor stability),另一种选择是简单地强制所有针对同一个对象的操作在单一线程上执行,将任何单个对象的执行序列化。

显式上锁

即应用在有针对单个对象的 read-modify-write 序列时,将是否上锁的决策交给应用层,显式地锁定将要更新的对象。通常的 SQL 语法如下:

1
select xx where xx for update;

考虑一个场景,一个多人游戏中,几个玩家可以同时移动相同的棋子。由于规则限制,一个原子操作可能不够。此时可以使用数据库提供的语法进行显式上锁,来防止两个玩家移动有交集的棋子集合。

1
2
3
4
5
6
7
8
BEGIN TRANSACTION;
SELECT * FROM figures
WHERE name = 'robot' AND game_id = 222
FOR UPDATE;

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

具体的使用还是需要根据应用需求来定。

自动检测丢失更新

除了悲观地强制执行原子操作外,还可以使用乐观的方式,允许其并发执行,检测到更新丢失后再重试。

在快照隔离级别的基础上,可以高效地对更新丢失进行检测。事实上,PostgreSQL 的可重复读、Oracle 的可串行化和 SQL Server 的快照隔离级别都能自动机检测丢失更新的冲突,并中止事务的执行。但是 MySQL/InnoDB 的可重复读不会检测丢失更新,一些开发者认为,数据库必须能防止丢失更新,才能称得上是提供了快照隔离,因此,在这个定义下,MySQL 不提供快照隔离的隔离级别。

丢失更新检测不需要应用程序代码使用任何特殊的数据库功能,不太容易出错。

比较并设置(CAS)

比较并设置(CAS,Compare And Set)是不加锁实现原子操作的一种常见方式,其使用的是内存共享的方式。目的是为了避免丢失更新:只有当前值从上次读取时一直未改变,才允许更新生效。如果当前值与先前读取的值不一致,则更新不生效,必须重新读取,再次尝试。

下面是一个例子,防止两个用户同时更新一个 wiki 页面,可以尝试这种方式:

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

如果更新后的值与旧值一致,此次更新涉及到一些版本问题的话也是不行的,可以加一个更新时间戳字段或唯一的版本号作为标识。

多副本冲突与解决

在多副本数据库中,解决丢失更新的问题要更难一些。

在多住和无主模型中,允许数据进行并发写入和异步复制,无法保证只有一个最新数据的副本。所以 CAS 与基于锁的技术不适用于这种情况。这种多副本数据库中的一种常见方法是允许并发写入创建多个冲突版本的值(称为兄弟值),并使用应用程序或特殊的数据结构在事件发生后解决和合并这些版本。

特殊情况下,当多个操作满足“交换律”(即,可以在不同的副本上以不同的顺序应用它们,且仍然可以得到相同的结果)时,原子操作看一看在多副本数据中正常工作,如计数器场景就满足交换律,在 Riak2.0 之后就支持并发更新,且会自动合并结果,不会有丢失更新问题。

另一方面,后者胜(LWW,last write win)的冲突解决策略是会造成丢失更新问题的,虽然很多多副本数据库都默认使用这种策略来进行冲突解决。

写偏序和幻读

前文中我们提到了脏写和丢失更新,当不同事务并发写入相同对象时,会出现这两种竞态问题。它们可以在数据库层面自动解决,也可以在应用侧通过显式调用原子操作或加锁来解决。

除了上述并发写入问题外,还有一些比较微妙的冲突例子,涉及到多个对象的访问。

考虑如下的一个场景,一个医生在值班,议员通常要求几个医生同时值班,即使有特殊情况,也要保证有不少于一个医生值班。假设在某天,轮到 Alice 和 Bob 两人值班,不巧的是,他们都感觉自己身体不适,且恰好同时申请请假。

写入偏差导致应用程序错误的示例

假定数据库允许在快照隔离级别下,Alice 和 Bob 同时开启事务,同时查询了今天的值班情况,两次查询都返回 2,所以两个事务都进入下一个阶段。Alice 更新自己的记录休班,Bob 也做了同样的事情,两个事务都成功提交了,现在没有医生值班了。这违反了至少有一名医生值班的规定。

写偏序的特点

上述产生的异常被称为写偏序,它既不是脏写,也不是丢失更新,因为这两个事务在更新两个不同的对象。在这里发生的冲突虽然不明显,但显然也是一个竞态条件:如果两个事务串行执行,那么后一个申请的医生就不能歇班了。这种异常行为只有在事务并发进行时才有可能发生。

我们可以把写偏序视为丢失更新问题的一种泛化体现。写偏序的本质也是 read-modify-write,虽然涉及多个对象,但本质仍然是一个事务的写入导致另一个事务读取到的信息失效。写偏序是由 MVCC 实现的快照隔离级别特有的缺陷,它出现的原因是多个事务的读依赖于同一个不变的快照。

解决丢失更新的许多手段都无法用在解决写偏序上:

  • 由于涉及到多个对象,单对象的原子操作不起作用。
  • 在快照隔离级别实现中,想要自动防止写偏序必须实现真正的可串行化隔离。
  • 虽然有些数据允许执行约束,但是往往是单对象的简单约束,如唯一约束、外键约束等,当然,可以使用触发器或物化视图来实现。
  • 如果没办法使用可串行化的隔离级别,还可以使用数据库提供的显式加锁(for update)机制来显式加锁。
1
2
3
4
5
6
7
8
9
10
11
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;

其他写偏序例子

写偏序的特定在于:

  1. 涉及多个对象。
  2. 一个事务的写入会导致另外事务的读取失效,进而影响其写入决策。

以下是一些例子

  • 会议室预定系统

基本的流程是先检查预定是否有冲突,如果没有,则创建会议。

1
2
3
4
5
6
7
8
9
10
11
12
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;

在快照隔离级别下,使用上述语句无法避免多个用户并发预定时,预定到同一个会议室的时段。只能使用可串行化来避免冲突。

  • 多人棋牌游戏

前面提到的多人棋类游戏,对棋子对象加锁虽然可以防止两个玩家同时移动同一个棋子,但是不能避免两个玩家将不同的棋子移动到同一个位置。

  • 抢注用户名

在每个用户具有唯一用户名的网站上,两个用户并发尝试创建相同用户名的账户。如果使用检查名字是否可用->没有则允许注册的流程,在快照隔离级别下,是无法避免两个用户注册到相同用户名的。不过这种情况可以通过给用户名列加唯一性约束来保证该特性。

  • 防止双重开支

允许用户花钱和使用点券的服务,通常会在用户消费时检查其是否透支。可以通过在账户余额中插入一个临时的试探性项目来实现这一点,列出账户中所有项目,并检查总和是否为正值。在写偏序的场景下,可能会发生两个支出项目同时插入,导致余额为负数,但是这两个事务都不会注意到另一个。

幻读导致写偏序

以上的例子都可以归纳为以下流程:

  1. 通过 select 语句 + 条件过滤出符合所有行。
  2. 根据上述结果,应用侧决定是否继续。
  3. 如果应用侧决定继续,就执行更改,并提交事务。

其中步骤 3 可能会导致另一个事务的步骤 1 失效,即如果另一个事务此时重新执行 1 的 select 查询,会得到不一致的结果。进而影响步骤 2 的决策。

上述例子中,医生值班的例子可以通过FOR UPDATE锁定步骤 1 的行来避免写偏序。但是其他的例子不同:它们检查是否不存在某些满足条件的行,而写入会添加一个匹配相同条件的行。如果步骤 1 没有返回任何行,那么SELECT FOR UPDATE是锁不了任何东西的。

这种一个事务的写入会改变另一个事务的查询结果的现象,称为幻读(Phantom Read)。快照隔离能够避免只读事务中的幻读,但是对于读写事务,就可能会出现由幻读引起的写偏序问题。

物化冲突

如果幻读的问题是在步骤一查询不出任何对象以加锁,那么我们自然会想,能否手动添加一些对象来使得加锁称为可能?

在会议室预定的场景中,可以想象一个关于时间槽和房间的表。这个表中的每一行对应特定时间段的特定房间,比如每 15 分钟一个时间段。可以提前插入房间和时间的所有可能组合行。如果现在一个事务想要预定某个会议室某个时间段,就可以在表中将对应的对象锁住,然后执行预定的操作。

值得强调的是,该表只为了防止同时预定同一个会议室的同一时间段,并不用来存储预定信息,可以理解为一个锁表,每一行都是一把锁。

这种方法被称为物化冲突(materializing conflicts),因为它将幻读转化为了数据库中一组具体行上的锁冲突。不过弄清楚如何物化冲突很难,也很容易出错,而且这种方法将解决并发冲突的细节暴露给了应用层(应用层需要感知物化出来的表),是一种万不得已才会采用的方法。如果数据库本来就支持可串行化,那么大多数情况下,直接使用可串行化隔离级别是更可取的。

总结

这篇文章可以说是《DDIA》第七章的阅读笔记,内容基本都参考于此。

本文从事务棘手的概念入手,剖析了 ACID 的各个含义。其中原子性保证了发生错误时会回滚事务期间生效的所有变更;一致性需要根据上下文来明确语义,在 ACID 中是指对某些不变性的维持,需要由应用侧和数据库共同维护,更多指的是应用侧的属性;隔离性保证了每个执行的结果是相互隔离的,每个事务都可以认为自己是系统中唯一正在运行的事务。其实隔离性强弱类似一个光谱,因此引申出了弱隔离性。持久性保证了一旦事务提交,即使发生硬件故障或数据库崩溃,已经写入的任何数据都不会丢失。但是完美的持久性是不存在的,应当采取多种技术去兜底,抱着怀疑的态度接受任何理论上的“保证”。

然后我们从单对象和多对象的角度切入,阐明了两者间事务实现的差别,为后文弱隔离性的介绍奠定基础。

本文还从非正式的角度举例讨论了几种弱隔离级别,针对这几种隔离级别由于事务保证不充分导致的各种问题,以及这些问题的解决方法。读已提交隔离级别通过两个快照来避免脏读,通过添加行锁来防止脏写;快照隔离级别通过实现 MVCC 来避免了不可重复读的问题;可串行化隔离级别则解决了数据库事务并发的所有问题,包括快照隔离无法解决的丢失更新问题、幻读和写偏序问题。

参考与推荐阅读