前言
为了降低应用程序开发人员的心智负担,数据系统提供事务这一机制来简化各种各样的常见问题。事务将应用程序的多个读写操作捆绑在一起,称为一个逻辑操作单元。
执行事务只有两种结果,要么成功要么失败,且失败无副作用,不会产生任何修改;因此,如果失败,那么应用程序可以放心重试。有了事务,应用程序可以不用考虑某些内部潜在的错误以及复杂的并发性问题,这些都由数据库自己来处理,因此又被称之为安全性保证。
一、理解事务
我们知道事务是数据库提供的安全性保证,用于屏蔽数据库内内部的错误以及并发问题,那么这个安全性保证具体包含哪些方面呢?这便是我们常说的 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脏读 的解决
一种方式是直接加读写锁,一旦数据正在被写入,则排斥读操作,但是这会导致只读事务的执行受阻,严重影响只读事务的响应延迟;因此大多数数据库使用另一种方式来实现:对于每个待更新的对象,数据库都会维护其旧值和当前持锁事务将要设置的新值两个版本,在这个事务提交之前,其他事务读取到的都是旧值,也就防止了 A脏读。
b. 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读倾斜 很有可能发生:
- 备份场景:在备份过程中数据库数据在不断更新,备份的数据可能新旧混杂,一致性被破坏;
- 分析查询与完整性检查:由于数据在不断更新,这类查询和检查无法实现。
换个角度来看,事务 T2 实际上在 T1 执行的过程中,修改了 T1 的查询结果(对比正常查询),这使得 T1 出现了被称之为 E幻读 的问题。
2.2.1 C读倾斜 的解决
显然,事务在执行过程中应当读取一致的数据(同一个版本的数据),这为我们指明了版本控制这一思路。在读-提交中,为了解决 A脏读,我们维护了数据的新旧值,当写事务为提交时读旧值,提交后读新值;为了解决 C读倾斜,我们要更进一步,就算写事务已经提交,其旧值甚至更旧的值仍可能需要维护:有之前正在执行的事务需要读这个数据。
这意味着旧值不再是一个临时维护的值,而是跟新值一样,是长期需要管理的值;考虑到多个正在进行的事务可能会在不同的时间点查看数据库状态,所以数据库保留了对象多个不同的提交版本,这种技术因此也被称为多版本并发控制(Multi-Version Concurrency Control,MVCC)。
事务执行在数据库完整的一个版本快照上,因此又被称之为快照级别隔离。当事务执行时,首先赋予一个唯一的、单调递增的事务 ID。每当事务向数据库内写入新内容是,所写的数据都会被标记写入者的事务 ID。
那么对于一个事务 Tk,其能访问的数据是哪些版本呢?考虑下图的场景:
对于 事务 Tk 开始时:
- 还未提交的事务 Ta:显然这部分数据不可见;
- 中止的事务:由原子性保证这部分数据不可见;
- 晚于 Tk 的事务:显然不可见;
- 其他所有写入都应可见。
简单来说,只有事务开始时,数据库内已提交的事务才可见;事务开始后数据的可见性不再变化,形成了“快照”。
多版本看起来并不复杂,但是要考虑其与数据库其他功能的兼容性,最重要的便是索引与快照级别隔离兼容的处理。
2.2.2 索引与快照级别隔离
显然,在多版本数据库中,索引指向的数据有多个版本,最简单的方法便是索引指向所有版本,找出后再根据版本进行过滤;对于使用 B-Tree 的数据库,还可以使用写时复制的技术:
如图所示,在 B-Tree 中想要修改 D,写入新数据 D’,那么要修改其对应父节点中存储的指针,因此父节点也创建了新版本,同理一直往上,直到创建了新版本的根节点 R’。其中不受影响的 B、C 节点及其子节点,在两个树中被共享。
在每个写入事务开始时创建一个新的版本树,然后在整个事务流程过程中都使用这个新树的根节点开始查找,无论是数据还是索引都是如此;在这个树中所能见到的数据都是这个事务所应该能看到的数据,因此无需进行版本过滤;显然,这些根节点就形成一系列快照。
2.3 防止 F更新丢失
就算是快照级别隔离也存在问题没有解决,F更新丢失 便是其中一个。F更新丢失 发生在 read-modify-write 这样的操作流程里,这个流程十分常见,比如:
- 计数器:一定是要读取当前值再进行修改
- 修改复杂对象的一部分
广义来说,F更新丢失 也会出现在其他场景,比如 有关数据系统的一些简要笔记 中的写冲突部分,当两个用户并发写同一个位置的数据时,便会形成写冲突,某些解决方案会最终采用定义上最新的数据,造成 F更新丢失。
2.3.1 原子写操作
读-修改-写产生的问题不是新问题,此事在经典多线程程序中亦有记载,可以使用原子操作来解决。在多数关系数据库中,简单的 UPDATE 都是并发安全的,是数据库提供的原子更新操作。要实现原子操作,通常采取对读取对象加独占锁的方式来实现,这种技术有时被称为游标稳定性;另一种方式是强制所有的原子操作都在指定的单线程上执行,根据使用场景,这种方式的效率也并不低。
2.3.2 显式加锁
由于原子操作提供的保证有限,无法适应一些应用层逻辑上的安全保证,因此数据库都提供了常见的锁机制。例如:SELECT … FOR UPDATE,将满足指定条件的行都选出来,标记为 FOR UPDATE,即加上锁。
如果数据库没有提供前文提到的原子写操作,用锁也可以很容易自己实现。
2.3.3 自动检测更新丢失
前文提到的两种方式都是把操作串行化来避免 F更新丢失,是一种悲观的策略;实际上,操作并行化,并非一定会出现 F更新丢失,例如一个操作在读-修改-写,另一个操作在读-修改-写到别的位置,实际上并未冲突。因此可以采取一些乐观的策略,比如自动检测更新丢失。
借助于多版本,我们可以很简单的检测出 F更新丢失:当事务在读-修改-写时,最后写回时发现值的版本更新了,那么说明遇到了冲突,如果写入便会发生 F更新丢失;当检测到后,可以回退事务,并使用原子的更新操作来重新执行。
2.3.4 原子比较和设置
熟悉多线程编程便会知道,CAS 原子指令很有用,其实可以看成是弱化版的自动 F更新丢失 检测。自动更新丢失检测是使用版本号来判断,而 CAS 是根据值来判断。
如果数据库提供了原子性的 CAS 指令,要谨慎使用,原因是多版本技术下,CAS 不一定能检测到版本变化,要注意其适用范围。
2.3.5 冲突解决与复制
前文提到的方式实际上都是避免冲突发生,从而避免更新丢失的,然而在多副本数据库中,需要加锁的部分可能横跨多个节点,锁的实现需要考虑很多额外的东西;在多主或者无主系统中,甚至无法使用锁,因此只能在冲突发生后来进行解决,可以参照有关数据系统的一些简要笔记写冲突部分。
2.4 D写倾斜
前文介绍了一些隔离级别的常见问题,是比较直接的;但在实际的数据系统中,数据间可能暗含一些约束关系,很容易在对数据的操作中破坏这些关系,造成数据不一致,这便是 D写倾斜。
2.4.1 D写倾斜
举一个简单的例子,假设有两个用户同时发起一个事务操作,内容是查询一件物品的空闲情况,如果是空闲则进行预定;我们可以预想到这样一个情况:两个用户都查询了物品的空闲情况,均为可用,因此都进行了预定操作。
如果预定操作需要修改物品的状态(例如填入预定者信息),那么两个事务尝试更新同一条数据,可能出现前文提到的 F更新丢失 的问题;如果预定操作不需要修改物品的状态,而是在用户各自的数据中进行记录,那么两个事务执行完成后会破坏数据的一致性关系:两个用户都预定了同一件物品,显然是不允许的。这便是 D写倾斜。
与 C读倾斜(不可重复读) 类似,D写倾斜 发生在事务执行的过程中,操作了不同版本的数据。在 C读倾斜 中比较明显,两次读操作可能读取到的是不同版本的数据,造成了问题;而在 D写倾斜 中,这一关系比较隐含。
以上文提到的例子来进行说明:物品只能由一个人预定,这一约束实际上将两个用户的数据关联在了一起(同一个物品只能在两个用户数据中出现一次),可以视为一个隐含的整体对象,当然,如果这真的是一个对象的话,两个事务在最后操作时会触发 F更新丢失 的问题,可以通过检查数据版本来进行检测;然而这样一个对象是不存在的,它们只是因约束关系而关联在一起,而版本检测无法在两个对象上进行,当然原子操作也不能在两个对象上进行。
因此 D写倾斜 实际上是 F更新丢失 的 Max Pro 版本,许多方法在这个问题上失效了。通过总结 D写倾斜 的特征,我们可以发现,问题出现在读-写事务的执行过程中,如果一个事务的写会影响另一个事务的读,那么便可能出现 D写倾斜。
2.4.2 D写倾斜的解决
前文提到 D写倾斜 之所以比 F更新丢失 复杂,是因为 D写倾斜 的约束关系是隐含的,而 F更新丢失 是显式的;因此我们可以通过显式的约束来避免 D写倾斜,这便是实体化冲突:在数据库中构建具体的对象,这些对象对应具体的隐含约束关系,通过对这些对象加锁来避免 D写倾斜。
显然,我们把约束关系实例化为数据模型来管理,是十分复杂的,因此在实际中尽量避免使用这一方法;我们还可以使用可串行化隔离来避免 D写倾斜。
说是避免 D写倾斜,实际上并不是针对 D写倾斜 的,而是通过可串行化隔离来避免所有的并发问题。可串行化隔离是最严格的隔离级别,所有的事务都在一个全局的顺序中执行,因此当然不会出现 D写倾斜 的问题;因此我们在下一节单独对串行化进行探讨。
三、 串行化
事务的并发问题太多了,尤其是最后还碰上了很不好解决的 D写倾斜,弱隔离级别无法解决所有问题;因此我们可以考虑将所有的事务都串行化来避免这些问题,这便是可串行化隔离,是处理事务问题的大招。通过对串行化隔离的思考,大致可以分为以下几种实现方式:
- 严格串行执行事务
- 两阶段加锁 2PL
- 可串行化的快照隔离
可见串行化也不一定要求严格串行执行,只需保证事务就算并行执行,最终结果与每次一个一个执行的结果相同。
3.1 严格串行执行事务
这是最直白的想法,在单线程上执行所有的事务;提到单线程,便立马能够联想到 Redis,确实,Redis 便是串行执行解决事务问题的代表之一;单线程严格串行执行,除了能够忽视并发问题外,还避免了大量锁的开销,性能也不会太差。
显然,这种方案的吞吐量上限是单个 CPU 核的吞吐量,因此更适合搭配主频高的 CPU 使用。
随着 CPU 性能的逐年增长,Redis 的串行执行在大多数情况下不构成瓶颈,反而是接收请求和发送响应的 IO 部分逐渐力不从心了,因此新版本 Redis 进行了多线程改造,但只是在 IO 部分进行了多线程优化,指令(事务)执行仍然是单线程的。
单线程串行执行要求指令必须简洁快速,不然会严重影响后续指令的执行,这意味着活动数据集必须能够完全载入内存,不然事务线程碰到 IO,那么所有指令都要原地等待了。
同样的,这也意味着单线程串行执行的系统往往不支持交互式的多语句事务,没有办法等待用户交互式地输入指令执行,这会阻塞其他的指令;这里的交互式也包括这种常见场景:应用程序中创建一个事务 TX,然后在 TX 中逐一执行各种逻辑和指令。
在这样的场景下,应用程序别无选择,只能一次提交整个事务代码交由数据库执行,这便形成了存储过程这一概念。
3.1.1 存储过程
显然,就算数据库支持事务并发,如果网络开销较大,也可以使用这种方式进行优化;而且,由于一次性提供了整个事务代码,数据库还能据此进行优化和数据预载等操作;此外,还能预先在数据库中定义存储过程,应用只需调用执行即可。
由于存储过程本质上是把应用执行的逻辑放到数据库上来执行,因此编写存储过程还需要单独的语言支持,通常是 SQL 的扩展语言,例如 PL/SQL、T-SQL 等,这增加了存储过程使用的门槛;好在许多数据库支持了使用现有的通用编程语言来实现存储过程,例如 Redis 的 Lua 等。
由于存储过程在数据库服务器上运行,因此在运行时难以进行调试和管理;而数据库通常会连接大量应用使用,本身的性能压力就比较大,存储过程的执行会进一步加重数据库的负担,因此在使用存储过程时,通常需要对其进行性能分析和调优。
3.1.2 分区
虽然单线程的性能还不错,但如今 CPU 早已不再是单核的时代了,需要想办法利用其余的 CPU 核心来提升性能。为此,我们可以将数据进行分区,让每个分区互相隔离,只在对应的核心上进行事务的执行,这样便能线性地提升数据库的事务性能。
显然,分区后很有可能遇到一个事务需要跨多个分区的情况,这种情况下便只能将相应分区加锁后执行了,这会造成极大的性能损耗,算是分区的一个缺点;因此分区通常只能使用在简单的键值数据上,如果涉及二级索引,则很有可能跨分区操作造成性能下降。
3.2 两阶段加锁 2PL
2PL 实际上就是给事务写操作的对象加上互斥锁,因此前文提到的 D写倾斜 便得到了解决:D写倾斜 的前提,便是一个事务的写会影响另一个事务的读,而 2PL 则是通过加锁来避免了这种情况。
3.2.1 2PL 的性能
当有许多事务在执行时,由于 2PL 的存在,会出现许多读写锁的使用,很容易出现死锁的情况;因此在使用 2PL 时,通常会使用一些死锁检测机制来避免死锁的发生,这要求数据库能够检测并中止事务的执行
其次,其主要缺点在于锁的大量使用带来的性能下降;另外,支持事务并发的数据库大多都支持交互式事务,这就意味着事务的执行时间可能很长,如果其获取的锁阻碍了其他事务,很有可能导致其他事务长时间等待;因此在开启 2PL 后数据库的访问延迟有非常大的不确定性。
3.2.2 谓词锁
2PL 看起来是一个很好的解决方案,但是实际上还有一些问题需要处理:通过快照隔离简单解决掉的 E幻读 问题,2PL 却无法通过对对象加锁来解决。例如一个事务的查询是某个条件下的所有对象,而另一个事务是新增一个满足这个条件的对象,前者无法对不存在的对象加锁,因此其查询结果能够被后者所修改;因此 2PL 需要使用谓词锁来解决这个问题。
谓词锁是对一个条件的加锁,而不是对一个对象的加锁;例如在上面的例子中,前者可以对满足条件的所有对象加锁,这样便能避免后者的插入操作影响前者的查询结果。具体的,可以在创建对象时检查其是否符合某个谓词锁的条件来实现。
3.2.3 索引区间锁
使用谓词锁时,每次创建对象时,都要挨个检查谓词锁列表,看看是否符合其中某个条件,这会造成性能的下降;因此大多数使用 2PL 的数据库实际上是使用索引区间锁来实现的。
索引区间锁实际上是对谓词锁的简化,同时为了保证安全性,简化的过程只是将谓词锁的范围扩大了,不会带来额外的风险。
相较于谓词锁的具体条件,索引区间锁通过对索引的相应范围进行加锁来实现;由于条件通常使用索引进行查询,因此把锁放到索引上,当对象创建,构建相应的索引时便能够检测到。显然索引范围与谓词锁的具体范围是没法完全对应的,不过通过扩大索引加锁范围可以覆盖谓词锁的范围。
虽然加锁范围变大了,但是检查的开销却小了许多;因此索引区间锁是一个折中的方案。当条件中没有合适的索引可以使用时,便退化为给整个表加锁。
3.3 可串行化的快照隔离 SSI
前面提到的串行方式,总是想着在一开始就避免问题发生的可能性,确实保证了事务并发安全,但是却带来了性能的严重下降,这是对事务执行的悲观并发控制机制;实际上,就算事务可能操作同一个对象,也不一定会发生冲突,因此我们可以在事务执行时,允许其并发执行,等到最后提交时再进行检查,如果没有冲突则提交,否则回滚。
这便是可串行化的快照隔离,一种乐观并发控制机制。这要求数据库有能力检测冲突以及中止相应的事务,这也是 SSI 的核心。由于 SSI 基于快照隔离,因此许多问题已经有成熟的解决方案了,我们只需关心如何解决 D写倾斜。
首先,如果两个事务没有任何关联,那么显然并发执行是安全的;如果两个事务操作的对象有关联,才需要进行冲突判断。经过前文的分析我们知道 D写倾斜 出现于一个事务修改了另一个事务的查询结果,我们针对这一点进行解决。
- 从读取者的视角来看,可以检查读取时,对象是否已经有未提交的修改了;
- 从写入者的视角来看,可以检查自己写入的对象有没有被其他事务读取过。
当然,并不需要在检测到时立即终止,因为在提交时有可能冲突已经消失了,因此是否中止放在最后,也就是提交时再进行判断。
对于情况 1,读取事务注意到有未提交的修改,仍然正常进行执行处理,到最后提交时,如果这个未提交的修改被提交了,说明之前的操作基于过期的数据,因此要进行中止;如果还是处于未提交状态,则无需中止。
对于情况 2,写入事务需要能够快速检测到相关对象是否有被读取过,因此可以参照索引区间锁的实现,在索引上添加特殊标记,由于冲突不一定发生,因此这个锁不会阻塞该事务的执行,而是关注其状态变化。到最后提交时,如果这个读取事务还未结束(事务还未提交),说明自己的写入会产生冲突,因此要进行中止;如果读取事务已经结束,说明自己的写入不会产生冲突,则可以提交。
单靠其中一者是无法完成冲突检测的,因此两者同时在进行检测的操作。
显然,当事务处理的对象较多时,既增大了冲突的可能性,也增大了检测的开销,因此通常要求读-写型事务要简短。总体来讲 SSI 下,执行缓慢事务的影响要更小一些;事务中止的比例会显著影响 SSI 的性能表现。
总结
为了降低应用程序的复杂度,数据库提供了事务这一工具,使得应用程序得以放心操作数据而不必考虑许多基础问题;为了满足应用程序的需求(ACID 和性能),数据库支持了事务的并发执行;同时并发执行带来了许多问题,数据库为了解决这些问题,提供了多种隔离级别来满足不同的需求。
通过对数据系统中这些事务并发问题处理方式的学习,应当能够认识到,在开发过程中随着问题复杂度的提升,通常不会有简单有效的办法能够一劳永逸的解决问题,我们更需要需要多种方案结合使用,并在其带来的性能损耗中进行权衡。