前言
为了降低应用程序开发人员的心智负担,数据系统提供事务这一机制来简化各种各样的常见问题。事务将应用程序的多个读写操作捆绑在一起,称为一个逻辑操作单元。
执行事务只有两种结果,要么成功要么失败,且失败无副作用,不会产生任何修改;因此,如果失败,那么应用程序可以放心重试。有了事务,应用程序可以不用考虑某些内部潜在的错误以及复杂的并发性问题,这些都由数据库自己来处理,因此又被称之为安全性保证。
一、理解事务
我们知道事务是数据库提供的安全性保证,用于屏蔽数据库内内部的错误以及并发问题,那么这个安全性保证具体包含哪些方面呢?这便是我们常说的 ACID。
1.1 ACID
ACID,即 Atomicity、Consistency、Isolation 和 Durability。显然,各个数据库能提供的安全性保证是不一样的,因此 ACID 在各个数据库上的表现也会有所区别,但是还是有比较笼统的概念的。
1.1.1 原子性 Atomicity
想象这样一个场景,客户端发起了包含多个写操作的请求,而这个请求执行到一半时因为各种原因失败了(数据库错误/执行时判断失败),此时客户端该如何继续变成了问题;为了让客户端不用管理请求的执行进度,我们将其包装为一个整体的“原子操作”:如果执行失败,那么数据库必须丢弃或者撤销那些局部完成的修改。
通过原子性保证,如果事务中止,应用程序可以确定没有实质发生任何更改,所以可以安全地重试(因此又被称为可中止性)。
1.1.2 一致性 Consistency
ACID 中的一致性指的是数据层面状态的一致性,例如数据库中 A 的值一定是 B 的值的两倍,那么从满足该一致性的状态开始,执行一个合法的事务后,得到的结果也应满足这个约束。显然,数据之中的约束是应用层定义的,数据库通常只能提供有限的约束工具,例如外键约束和唯一性约束,因此一致性通常是由应用程序本身来维护的。
1.1.3 隔离性 Isolation
当多个客户端同时使用数据库时,如果访问同一记录,那么便会产生并发问题。为了让应用程序无需考虑并发竞争,数据库提供隔离性保证,这意味着应用执行事务时,从其视角来看所有的事务都是按照顺序在执行的,而数据库也要使用各种机制,确保这些事务在执行完成后所得的结果与顺序执行结果一致,这样才能符合应用的预期;实际中由于性能问题,数据库很少能够提供这样严格的隔离性,因此各个数据库提供的隔离性可能不同。
1.1.4 持久性 Durability
持久性是大部分数据存储系统的基础保证,即数据写入(事务提交)完成后,不管发生什么情况,写入的数据都不应丢失。显然,持久性也是有限,不同数据库所提供的持久性保证都不一样:分布式数据库与单机数据库相比,提供的持久性通常会更好。
综上所述,原子性保证对于各类数据库来说比较统一;一致性保证与数据库提供的工具有关,且责任在应用层;隔离性保证太过绝对,在各类数据库中都不太一样;持久性保证也很模糊,各类数据库或者说实际情况下都不一样。总而言之,也就原子性比较统一,其他概念在实际实现中都是比较模糊的。
1.2 单对象写入与多对象事务
在前文提到了 ACID 的安全性保证,在数据库设计中,通常只用关心原子性和隔离性。对于持久性,这是存储系统的基石,无需额外讨论;对于一致性,如果应用提供正确的操作,而数据库保证操作的原子性和隔离性,那么便能保证一致性,因此也不单独讨论,因此我们主要聚焦原子性与隔离性在数据库中的设计和实现。
接下来我们简单思考一下事务的一些注意事项。
1.2.1 单对象写入
我们知道,最底层的原子性保证来源于 CPU 的设计,且其只保证了极其有限的操作;对于数据库存储的对象来说,那就更不存在原子性了,因此就算是单对象,其原子性和隔离性也不是天然的:例如数据库写入 100KB 的单对象时,在写的中途也要保证不被读到部分更新的数据(隔离性);在写的中途失败时,写造成的修改也要能够撤销(原子性)。如果没有单对象的原子性和隔离性保证,那么就更别谈多对象的处理了。
因此存储引擎在设计时便考虑了单对象的原子性和隔离性的支持,例如使用日志来实现原子性(可恢复可撤销),而使用锁来实现对象的隔离性。因此在我们通常的讨论中,单对象的原子性和隔离性太过基础,我们默认其是底层提供的基础保证,事务(多操作聚合)是在此基础之上的讨论,这也是为什么这部分标题并未称为“单对象事务”。
1.2.2 多对象事务
在分布式数据库中,多对象事务还是有一定复杂度的,原因在于这些对象可能由于分区存在于不同的子数据库实例中。多对象事务对于一致性的保证有重要的帮助作用,例如同步更新的二级索引、外键引用的有效性、多文档同时更新等。
1.2.3 处理错误与中止
前文我们提到事务提供了失败之后可以安全重试的保障,因此对于错误和中止,我们尽量尝试重试,但重试机制还有许多问题需要注意:
- 事务执行成功,但应用认为执行失败(各种原因导致未收到成功确认),此时产生的重试可能出现问题:可以提供事务 id 以确保已提交的事务不会重复执行;
- 外因中止,例如系统超负荷运行导致事务执行失败,反复重试反而会加重系统负担:设定重试次数上限;
- 事务本身操作违反约束导致的失败:应用应调整操作内容而不是继续重试;
- 事务只能保证数据库内无副作用,如果有机制与数据库外操作相关,重试可能触发数据库外的副作用,例如更新时发送邮件,重试可能导致多次发送:涉及到多系统事务,可以使用两阶段提交;
- 本应进行重试的应用失效,导致重试操作丢失,可能导致数据丢失:应用也要具有持久化的能力。
二、弱隔离级别
接下来我们探讨事务的隔离性,由于这一部分比较复杂,因此单独进行说明。
前文提到隔离性是为了解决数据库的并发访问问题而抽象出的概念,严格的隔离性使得并发操作执行的结果,与其按照顺序执行的结果一致,这意味着在应用执行事务时,无需关心并发的问题。然而在实际中,这种串行化的隔离性严重限制了数据库的性能,因此通常数据库提供较弱一些的隔离性保证,或是提供一些可选的隔离性配置。
在非串行化隔离下,总会出现各种各样的问题,我们将问题归纳为以下几种进行标记,并讨论各种隔离级别对这些常见问题的解决:
- A脏读
- B脏写
- C读倾斜
- D写倾斜
- E幻读
- F更新丢失
2.1 读-提交
读-提交通常是最基础的隔离级别,它只提供两个保证:
- 解决 A脏读:读数据库时,只能看到已成功提交的数据;
- 解决 B脏写:写数据库时,只会覆盖已成功提交的数据。
关于为什么要防止脏读,原因很明显就不再赘述;我们重点关注脏写的问题:
当有两个事务同时在执行时,前一个事务的写还未提交,而后一个事务想要写数据,如果在未提交的数据上覆盖,则称为脏写,最简单的处理方式是等待直到待写的数据被提交再进行。
脏写会带来什么问题呢,想象这样一个场景,用户提交的事务需要修改 A、B 两处数据,同时有两个用户提交了事务在执行:
最终导致 A 为用户 2 的数据,B 为用户 1 的数据;脏写会导致不同事务的并发写入最终混杂在一起。
2.1.1 读-提交的实现
数据库要实现读-提交,那么就要解决 A脏读 和 B脏写。
A脏读 的解决
一种方式是直接加读写锁,一旦数据正在被写入,则排斥读操作,但是这会导致只读事务的执行受阻,严重影响只读事务的响应延迟;因此大多数数据库使用另一种方式来实现:对于每个待更新的对象,数据库都会维护其旧值和当前持锁事务将要设置的新值两个版本,在这个事务提交之前,其他事务读取到的都是旧值,也就防止了 A脏读。
B脏写 的解决
显然对于并发写,最简单的方法便是加写锁,数据库通常采用行级锁来防止 B脏写。
2.2 快照级别隔离与可重复读
显然读-提交级别还有解决不了的问题,那便是 C读倾斜。考虑以下场景,用户正在使用事务 T1 查询两个账户的总余额,而后台有个程序正在用事务 T2 执行两个账户之间的转账:
T2 更新了两个账户的余额,完成转账操作,两个账户的总额不变仍是 200;用户在查询 Account1 的余额时,由于 T2 还未提交,因此读取到的是读-提交保证的旧值 100,当其查询 Account2 的余额时,由于 T2 已经提交,因此读取到的是新值 200,符合读-提交的隔离性保证,但因此得出的总余额结果却是 300,这便是 C读倾斜,又可称为 C不可重复度读。
C读倾斜 产生的原因是同一个事务内读取到了两个版本的数据,旧的 Account1 和新的 Account2,倘若 T1 在最后添加一个 Account1 的余额查询操作,便会发现前后两次查询操作结果不一致,这便是 C不可重复度读 这个名称的由来。
在以下场景中 C读倾斜 很有可能发生:
- 备份场景:在备份过程中数据库数据在不断更新,备份的数据可能新旧混杂,一致性被破坏;
- 分析查询与完整性检查:由于数据在不断更新,这类查询和检查无法实现。
C读倾斜 的解决
显然,事务在执行过程中应当读取一致的数据(同一个版本的数据),这为我们指明了版本控制这一思路。在读-提交中,为了解决 A脏读,我们维护了数据的新旧值,当写事务为提交时读旧值,提交后读新值;为了解决 C读倾斜,我们要更进一步,就算写事务已经提交,其旧值甚至更旧的值仍可能需要维护:有之前正在执行的事务需要读这个数据。
这意味着旧值不再是一个临时维护的值,而是跟新值一样,是长期需要管理的值;考虑到多个正在进行的事务可能会在不同的时间点查看数据库状态,所以数据库保留了对象多个不同的提交版本,这种技术因此也被称为多版本并发控制(Multi-Version Concurrency Control,MVCC)。
事务执行在数据库完整的一个版本快照上,因此又被称之为快照级别隔离。当事务执行时,首先赋予一个唯一的、单调递增的事务 ID。每当事务向数据库内写入新内容是,所写的数据都会被标记写入者的事务 ID。
那么对于一个事务 Tk,其能访问的数据是哪些版本呢?考虑下图的场景:
对于 事务 Tk 开始时:
- 还未提交的事务 Ta:显然这部分数据不可见;
- 中止的事务:由原子性保证这部分数据不可见;
- 晚于 Tk 的事务:显然不可见;
- 其他所有写入都应可见。
简单来说,只有事务开始时,数据库内已提交的事务才可见;事务开始后数据的可见性不再变化,形成了“快照”。
多版本看起来并不复杂,但是要考虑其与数据库其他功能的兼容性,最重要的便是索引与快照级别隔离兼容的处理。
索引与快照级别隔离
显然,在多版本数据库中,索引指向的数据有多个版本,最简单的方法便是索引指向所有版本,找出后再根据版本进行过滤;对于使用 B-Tree 的数据库,还可以使用写时复制的技术:
如图所示,在 B-Tree 中想要修改 D,写入新数据 D’,那么要修改其对应父节点中存储的指针,因此父节点也创建了新版本,同理一直往上,直到创建了新版本的根节点 R’。其中不受影响的 B、C 节点及其子节点,在两个树中被共享。
在每个写入事务开始时创建一个新的版本树,然后在整个事务流程过程中都使用这个新树的根节点开始查找,无论是数据还是索引都是如此;在这个树中所能见到的数据都是这个事务所应该能看到的数据,因此无需进行版本过滤;显然,这些根节点就形成一系列快照。
2.3 防止 F更新丢失
就算是快照级别隔离也存在问题没有解决,F更新丢失便是其中一个。F更新丢失发生在 read-modify-write 这样的操作流程里,这个流程十分常见,比如:
- 计数器:一定是要读取当前值再进行修改
- 修改复杂对象的一部分
广义来说,F更新丢失也会出现在其他场景,比如 有关数据系统的一些简要笔记 中的写冲突部分,当两个用户并发写同一个位置的数据时,便会形成写冲突,某些解决方案会最终采用定义上最新的数据,造成F更新丢失。
原子写操作
读-修改-写产生的问题不是新问题,此事在经典多线程程序中亦有记载,可以使用原子操作来解决。在多数关系数据库中,简单的 UPDATE 都是并发安全的,是数据库提供的原子更新操作。要实现原子操作,通常采取对读取对象加独占锁的方式来实现,这种技术有时被称为游标稳定性;另一种方式是强制所有的原子操作都在指定的单线程上执行,根据使用场景,这种方式的效率也并不低。
显式加锁
由于原子操作提供的保证有限,无法适应一些应用层逻辑上的安全保证,因此数据库都提供了常见的锁机制。例如:SELECT … FOR UPDATE,将满足指定条件的行都选出来,标记为 FOR UPDATE,即加上锁。
如果数据库没有提供前文提到的原子写操作,用锁也可以很容易自己实现。
自动检测更新丢失
前文提到的两种方式都是把操作串行化来避免F更新丢失,是一种悲观的策略;实际上,操作并行化,并非一定会出现F更新丢失,例如一个操作在读-修改-写,另一个操作在读-修改-写到别的位置,实际上并未冲突。因此可以采取一些乐观的策略,比如自动检测更新丢失。
借助于多版本,我们可以很简单的检测出F更新丢失:当事务在读-修改-写时,最后写回时发现值的版本更新了,那么说明遇到了冲突,如果写入便会发生F更新丢失;当检测到后,可以回退事务,并使用原子的更新操作来重新执行。
原子比较和设置
熟悉多线程编程便会知道,CAS 原子指令很有用,其实可以看成是弱化版的自动F更新丢失检测。自动更新丢失检测是使用版本号来判断,而 CAS 是根据值来判断。
如果数据库提供了原子性的 CAS 指令,要谨慎使用,原因是多版本技术下,CAS 不一定能检测到版本变化,要注意其适用范围。
冲突解决与复制
前文提到的方式实际上都是避免冲突发生,从而避免更新丢失的,然而在多副本数据库中,需要加锁的部分可能横跨多个节点,锁的实现需要考虑很多额外的东西;在多主或者无主系统中,甚至无法使用锁,因此只能在冲突发生后来进行解决,可以参照有关数据系统的一些简要笔记写冲突部分。