本文属于「数据库系统学习实践」系列文章之一,这一系列着重于「数据库系统知识的学习与实践」。由于文章内容随时可能发生更新变动,欢迎关注和收藏数据库系统系列文章汇总目录一文以作备忘。需要特别说明的是,为了透彻理解和全面掌握数据库系统,本系列文章中参考了诸多博客、教程、文档、书籍等资料,限于时间精力有限,这里无法一一列出。部分重要资料的不完全参考目录如下所示,在后续学习整理中还会逐渐补充:
- 数据库系统概念 第六版
Database System Concepts, Sixth Edition
,作者是Abraham Silberschatz, Henry F. Korth, S. Sudarshan
,机械工业出版社- 数据库系统概论 第五版,王珊 萨师煊编著,高等教育出版社
文章目录
本章参考文献
数据库的重要特征是能为多个用户提供数据共享,即数据库是一个共享资源(不是独占资源),可供多个用户使用。允许多个用户同时使用同一个数据库的数据库系统,称为多用户数据库系统。例如飞机订票数据库系统、银行数据库系统等都是多用户数据库系统。在这样的系统中,在同一时刻并发运行的事务数可达数百上千个(同一时间/段才是并发?同一时刻是并行?)。
- 事务可以一个一个地串行执行,即每个时刻只有一个事务运行,其他事务必须等待这个事务结束后方能运行,如下图所示。事务在执行过程中需要不同的资源,有时需要CPU、有时需求存取数据库、有时需要I/O、有时需要通信。如果事务串行执行,则许多系统资源将处于空闲状态。因此,为充分利用系统资源,发挥数据库共享资源的特点,应该允许多个事务并行地执行。
- 在单处理机系统中,事务的并行执行实际上是这些并行事务的并行操作轮流交叉执行,如下图(b)所示。这种并行执行方式称为交叉并发方式
interleaved concurrency
。虽然单处理机系统中的并行事务并没有真正的并行运行,但是减少了处理机的空闲时间,提高了系统的效率。在多处理机系统中,每个处理机可以运行一个事务,多个处理机可以同时运行多个事务,实现多个事务真正的并行运行。这种并行执行方式称为同时并发方式simultaneous concurrency
。
本章讨论的数据库系统并发控制 concurrency control in database system
技术,是以单处理机系统为基础的,但该理论可以推广到多处理机的情况。
当多个用户并发地存取数据库时,就会产生多个事务同时存取同一数据的情况。若对并发操作不加控制,就可能存取不正确的数据,破坏事务的一致性和数据库的一致性。所以数据库管理系统必须提供并发控制机制,用来协调并发用户的并发操作,以保证并发事务的隔离性和一致性、保证数据库的一致性。并发控制机制是衡量一个数据库管理系统性能的重要标志之一。
11.1 并发控制概述
在第10章中已经讲到,事务是并发控制的基本单位,保证事务的ACID特性是事务处理的重要任务,而事务的ACID特性可能遭到破坏的原因之一是「多个事务对数据库的并发操作」。为了保证事务的隔离性和一致性,DBMS需要对并发操作进行正确调度。这就是DBMS中并发控制机制的责任。
下面先看一个例子,说明并发操作带来的数据的不一致性问题。
【例11.1】考虑飞机订票系统中的一个活动序列:
① 甲售票点(事务
T
1
T_1
T1 )读出某航班的机票余额
A
=
16
A = 16
A=16 ;
② 乙售票点(事务
T
2
T_2
T2 )读出同一航班的机票余额
A
=
16
A = 16
A=16 ,和①一样;
③ 甲售票点出售一张机票,修改余额
A
←
A
−
1
A \larr A - 1
A←A−1 ,即
A
=
15
A = 15
A=15 ,把
A
A
A 写回数据库;
④ 乙售票点也出售一张机票,修改余额
A
←
A
−
1
A \larr A - 1
A←A−1 ,即
A
=
15
A = 15
A=15 ,把
A
A
A 写回数据库
结果,明明售出两张机票,数据库中机票余额只减少
1
1
1 。
这种情况称为数据库的不一致性。这种不一致性是由并发操作引起的。在并发操作的情况下,对 T 1 , T 2 T_1, T_2 T1,T2 两个事务的操作序列的调度是随机的。若按上面的调度序列执行, T 1 T_1 T1 事务的修改就丢失了,这是由于第④步中 T 2 T_2 T2 事务修改 A A A 并写回后,覆盖了 T 1 T_1 T1 事务的缘故。
下面把事务读数据 x x x 记为 R ( x ) R(x) R(x) ,写数据 x x x 记为 W ( x ) W(x) W(x) 。并发操作带来的数据不一致性,包括丢失修改、不可重复读和脏读。
- 丢失修改
lost update
两个事务 T 1 , T 2 T_1, T_2 T1,T2 读入同一数据并修改, T 2 T_2 T2 提交的结果破坏了 T 1 T_1 T1 提交的结果,导致 T 1 T_1 T1 的修改丢失,如下图所示。例11.1的飞机订票例子就属此类。 - 不可重复读
non-repeatable read
不可重复读是指事务 T 1 T_1 T1 读取数据后,事务 T 2 T_2 T2 执行更新操作,使 T 1 T_1 T1 无法再现前一次的读取结果,具体来说,不可重复读包括三种情况(事务 T 2 T_2 T2 修改、删除、增加),后两种不可重复读有时也称为幻影phantom row
现象:
(1)事务 T 1 T_1 T1 读取某一数据后,事务 T 2 T_2 T2 对其进行了修改,当事务 T 1 T_1 T1 再次读取该数据时,得到与前一次不同的值。例如下图中, T 1 T_1 T1 读取 B = 100 B = 100 B=100 进行运算, T 2 T_2 T2 读取同一数据 B B B ,对其进行修改后将 B = 200 B = 200 B=200 写回数据库。 T 1 T_1 T1 为了对读取值校对,重读 B B B ,发现 B B B 已为 200 200 200 ,与第一次读取的值不一致。
(2)事务 T 1 T_1 T1 按一定条件从数据库中,读取了某些数据记录后,事务 T 2 T_2 T2 删除了其中部分记录,当 T 1 T_1 T1 再次按相同条件读取数据时,发现某些记录神秘地消失了。
(3)事务 T 1 T_1 T1 按一定条件从数据库中,读取了某些数据记录后,事务 T 2 T_2 T2 插入了一些记录,当 T 1 T_1 T1 再次按相同条件读取数据时,发现多了一些记录。 - 脏读
dirty read
脏读是指事务 T 1 T_1 T1 修改某一数据并将其写回磁盘,事务 T 2 T_2 T2 读取同一数据后, T 1 T_1 T1 由于某种原因被撤销,这时被 T 1 T_1 T1 修改过的数据恢复原值, T 2 T_2 T2 读到的数据就与数据库中的数据不一致,则 T 2 T_2 T2 读到的数据就为脏数据(即不正确的数据)。例如下图中, T 1 T_1 T1 修改 C ← 200 C \larr 200 C←200 , T 2 T_2 T2 读到 C = 200 C= 200 C=200 ,而 T 1 T_1 T1 由于某种原因被撤销,其修改作废, C C C 恢复原值 100 100 100 ,这时 T 2 T_2 T2 读到的 C C C 为 200 200 200 ,与数据库内容不一致,就是“脏”数据。
产生上述三类数据不一致性的主要原因是「并发操作破坏了事务的隔离性」。并发控制机制就是要用正确的方式调度并发操作,使一个用户事务的执行不受其他事务的干扰,从而避免造成数据的不一致性。
另一方面,对数据库的应用有时允许某些不一致性。例如,有些统计工作涉及数据量很大,读到一些脏数据对统计精度没什么影响,这时可以降低对一致性的要求,以减少系统开销。
并发控制的主要技术有封锁 locking
、时间戳 timestamp
、乐观控制法 optimistic scheduler
、多版本并发控制 multi-version concurrency control, MVCC
等。 本章讲解基本的封锁方法,也是众多数据库产品采用的基本方法。不同的DBMS提供的封锁类型、封锁协议、达到的系统一致性级别不尽相同,但是其依据的基本原理和技术是相同的。
11.2 封锁
封锁是实现并发控制的一个非常重要的技术。所谓封锁就是事务 T T T 在对某个数据对象(例如表、记录等)操作之前,先向系统发出请求,对其加锁。加锁后事务 T T T 就对该数据对象有了一定的控制,在事务 T T T 释放它的锁之前,其他事务不能更新此数据对象。例如,在例11.1中,事务 T 1 T_1 T1 要修改 A A A ,若在读出 A A A 前先锁住 A A A ,其他事务就不能再读取和修改 A A A 了,直到 T 1 T_1 T1 修改并写回 A A A 后、解除了对 A A A 的封锁为止。这样,就不会丢失 T 1 T_1 T1 的修改。
确切的控制由封锁的类型决定。基本的封锁类型有两种:排他锁 exclusive locks
(简称
X
X
X 锁)和共享锁 share locks
(简称
S
S
S 锁)。
- 排他锁又称为写锁。若事务 T T T 对数据对象 A A A 加上 X X X 锁,则只允许 T T T 读取和修改 A A A ,其他任何事务都不能再对 A A A 加任何类型的锁,直到 T T T 释放 A A A 上的锁为止。这就保证了其他事务在 T T T 释放 A A A 上的锁之前,不能再读取和修改 A A A 。
- 共享锁又称为读锁。若事务 T T T 对数据对象 A A A 加上 S S S 锁,则事务 T T T 可以读 A A A 但不能修改 A A A ,其他事务只能再对 A A A 加 S S S 锁、而不能加 X X X 锁,直到 T T T 释放 A A A 上的 S S S 锁为止。这就保证了其他事务可以读 A A A ,但在 T T T 释放 A A A 上的 S S S 锁之前,不能再对 A A A 做任何修改。
排他锁和共享锁的控制方式,可以用下图所示的相容矩阵 compatibility matrix
来表示。下图的封锁类型相容矩阵中,最上边一列表示事务
T
1
T_1
T1 已经获得的数据对象上的锁的类型,横线表示没有加锁;最左边一列表示事务
T
2
T_2
T2 对同一数据对象发出的封锁请求。
T
2
T_2
T2 的封锁请求能否被满足,用矩阵中的
Y
,
N
Y, N
Y,N 表示——
Y
Y
Y 表示事务
T
2
T_2
T2 的封锁请求与
T
1
T_1
T1 已持有的锁相容,封锁请求可以满足;
N
N
N 表示事务
T
2
T_2
T2 的封锁请求与
T
1
T_1
T1 已持有的锁冲突,
T
2
T_2
T2 的请求被拒绝。
11.3 封锁协议
在运用
X
X
X 锁和
S
S
S 锁这两种基本封锁,对数据对象加锁时,还需要约定一些规则。例如何时申请
X
X
X 锁或
S
S
S 锁、持续时间、何时释放等。这些规则称为封锁协议 locking protocol
。对封锁方式制定不同的规则,就形成了各种不同的封锁协议。
本节主要介绍三级封锁协议。对并发操作的不正确调度可能带来丢失修改、不可重复读、脏读等不一致性问题,三级封锁协议分别在不同程度上解决了这些问题,为并发操作的正确调度提供了一定的保证。不同级别的封锁协议,达到的系统一致性是不同的。
11.3.1 一级封锁协议
一级封锁协议是指,事务
T
T
T 在修改数据
R
R
R 之前必须先对其加
X
X
X 锁,直到事务结束才释放。 事务结束包括正常结束 commit
和非正常结束 rollback
。
一级封锁协议可防止丢失修改,并保证事务
T
T
T 是可恢复的。例如下图使用一级封锁协议,解决了11.1节中丢失修改的问题。下图中,事务
T
1
T_1
T1 在读
A
A
A 并进行修改之前,先对
A
A
A 加
X
X
X 锁;当
T
2
T_2
T2 再请求对
A
A
A 加
X
X
X 锁时被拒绝,
T
2
T_2
T2 只能等待
T
1
T_1
T1 释放
A
A
A 上的锁后,再获得对
A
A
A 的
X
X
X 锁,这时它读到的
A
A
A 已经是
T
1
T_1
T1 更新过的值
15
15
15 ,再按此新的
A
A
A 值计算,并将结果值
A
=
14
A = 14
A=14 写回到磁盘。这样就避免了丢失
T
1
T_1
T1 的修改。
在一级封锁协议中,如果仅仅读数据、而不对其进行修改,是不需要加锁的,所以它不能保证可重复读、不脏读。
11.3.2 二级封锁协议
二级封锁协议是指,在一级封锁协议的基础上,增加事务 T T T 在读取数据 R R R 之前、必须先对其加 S S S 锁,读完后即可释放 S S S 锁。
二级封锁协议除了防止丢失修改,还可进一步防止脏读。例如下图使用二级封锁协议,解决了11.1节中脏读的问题。下图中,事务
T
1
T_1
T1 在对
C
C
C 进行修改之前,先对
C
C
C 加
X
X
X 锁,修改其值后写回磁盘(一级封锁协议)。这时
T
2
T_2
T2 为了读取数据
C
C
C ,要先请求在
C
C
C 上加
S
S
S 锁(二级封锁协议),因
T
1
T_1
T1 已在
C
C
C 上加了
X
X
X 锁,
T
2
T_2
T2 只能等待。
T
1
T_1
T1 因为某种原因被撤销,
C
C
C 恢复原值
100
100
100 ,
T
1
T_1
T1 释放
C
C
C 上的
X
X
X 锁后。而后
T
2
T_2
T2 获得了
C
C
C 上的
S
S
S 锁,读
C
=
100
C= 100
C=100 。这就避免了
T
2
T_2
T2 读脏数据。
在二级封锁协议中,由于读完数据后即可释放 S S S 锁,所以它不能保证可重复读。
11.3.3 三级封锁协议
三级封锁协议是指,在一级封锁协议的基础上,增加事务 T T T 在读取数据 R R R 之前,必须先对其加 S S S 锁,直到事务结束后才释放。
三级封锁协议除了防止丢失修改和脏读外,还进一步防止了不可重复读。例如下图使用三级封锁协议,解决了11.1节中不可重复读的问题。下图中,事务
T
1
T_1
T1 在读
A
,
B
A, B
A,B 之前,先对
A
,
B
A, B
A,B 加
S
S
S 锁,这样其他事务只能再对
A
,
B
A, B
A,B 加
S
S
S 锁,而不能加
X
X
X 锁,即其他事务只能读
A
,
B
A, B
A,B ,而不能修改它。所以当
T
2
T_2
T2 为修改(可能是更新、增加、删除)
B
B
B 而申请对
B
B
B 加
X
X
X 锁时被拒绝,只能等待
T
1
T_1
T1 释放
B
B
B 上的锁。
T
1
T_1
T1 为验算再读
A
,
B
A, B
A,B ,这时读出的
B
B
B 仍是
100
100
100 ,求和结果仍为
150
150
150 ,即可重复读。
T
1
T_1
T1 结束时才释放
A
,
B
A, B
A,B 上的
S
S
S 锁,
T
2
T_2
T2 才获得对
B
B
B 的
X
X
X 锁。
上述三级协议的主要区别在于,什么操作需要申请封锁、何时释放锁(即持锁时间)。
11.3.4 封锁协议总结
三级封锁协议可总结为下表。不同的封锁协议使事务达到的一致性级别是不同的,封锁协议级别越高,一致性程度越高。
11.4 活锁和死锁
和操作系统一样,封锁的方法可能引起活锁和死锁等问题。
11.4.1 活锁
如果事务
T
1
T_1
T1 封锁了数据
R
R
R ,事务
T
2
T_2
T2 又请求封锁
R
R
R ,于是
T
2
T_2
T2 等待;
T
3
T_3
T3 也请求封锁
R
R
R ,当
T
1
T_1
T1 释放了
R
R
R 上的封锁之后,系统首先批准了
T
3
T_3
T3 的请求,
T
2
T_2
T2 仍然等待;然后
T
4
T_4
T4 又请求封锁
R
R
R ,当
T
3
T_3
T3 释放了
R
R
R 上的封锁之后,系统又批准了
T
4
T_4
T4 的请求……
T
2
T_2
T2 有可能永远等待,这就是活锁的情形。如下图所示:
避免活锁的简单方法是采用「先来先服务」的策略。当多个事务请求封锁同一数据对象时,封锁子系统按请求封锁的先后次序对事务排队,数据对象上的锁一旦释放,就批准申请队列中第一个事务获得锁。
11.4.2 死锁
如果事务
T
1
T_1
T1 封锁了数据
R
1
R_1
R1 ,
T
2
T_2
T2 封锁了数据
R
2
R_2
R2 ,然后
T
1
T_1
T1 又请求封锁
R
2
R_2
R2 ,因
T
2
T_2
T2 已封锁了
R
2
R_2
R2 ,于是
T
1
T_1
T1 等待
T
2
T_2
T2 释放
R
2
R_2
R2 上的锁;接着
T
2
T_2
T2 又申请封锁
R
1
R_1
R1 ,因
T
1
T_1
T1 已封锁了
R
1
R_1
R1 ,
T
2
T_2
T2 也只能等待
T
1
T_1
T1 释放
R
1
R_1
R1 上的锁。这样就出现了
T
1
T_1
T1 在等待
T
2
T_2
T2 ,而
T
2
T_2
T2 又在等待
T
1
T_1
T1 的局面,
T
1
T_1
T1 和
T
2
T_2
T2 两个事务永远不能结束,形成死锁。
死锁的问题在操作系统和一般并行处理中,已做了深入研究,目前在数据库中解决死锁问题主要有两类方法,一类方法是采取一定措施来预防死锁的发生,另一类方法是允许发生死锁,采用一定手段定期诊断系统中有无死锁,若有则解除之。
1. 死锁的预防
在数据库中,产生死锁的原因是两个或多个事务都已封锁了一些数据对象,然后又都请求对「已被其他事务封锁的数据对象」加锁,从而出现死等待。防止死锁的发生,其实就是要破坏产生死锁的条件。预防死锁通常有以下两种方法。
- 一次封锁法:要求每个事务必须一次将所有要使用的数据全部加锁,否则就不能继续执行。上图(b)死锁中,如果事务
T
1
T_1
T1 将数据对象
R
1
,
R
2
R_1, R_2
R1,R2 一次加锁,
T
1
T_1
T1 就可以执行下去,而
T
2
T_2
T2 等待。
T
1
T_1
T1 执行完后释放
R
1
,
R
2
R_1, R_2
R1,R2 上的锁,
T
2
T_2
T2 继续执行。这样就不会发生死锁。
一次封锁法虽然可以有效地防止死锁的发生,但也存在问题:- 第一,一次就将以后要用到的全部数据加锁,势必扩大了封锁的范围,从而降低了系统的并发度;
- 第二,数据库中数据是不断变化的,原来不要求封锁的数据,在执行过程中可能会变成封锁对象,所以很难事先精确地确定每个事务所要封锁的数据对象,为此只能扩大封锁范围,将事务在执行过程中、可能要封锁的数据对象全部加锁,这就进一步降低了并发度。
- 顺序封锁法:预先对数据对象规定一个封锁顺序,所有事务都按这个顺序实施封锁。例如在
B
B
B 树结构的索引中,可规定封锁的顺序必须是从根结点开始,然后是下一级的子结点,逐级封锁。
顺序封锁法可以有效地防止死锁,但也同样存在问题:- 第一,数据库系统中封锁的数据对象极多,并且随着数据的插入、删除等操作而不断地变化,要维护这样的资源的封锁顺序非常困难,成本很高;
- 第二,事务的封锁请求可以随着事务的执行而动态地决定,很难事先确定每个事务要封锁哪些对象,因此也就很难按规定的顺序去施加封锁。
可见,在操作系统中广为采用的、预防死锁的策略,并不太适合数据库的特点,因此数据库管理系统在解决死锁的问题上,普遍采用的是诊断并解除死锁的方法。
2. 死锁的诊断和解除
数据库系统中诊断死锁的方法与操作系统类似,一般使用超时法或事物等待图法。
- 超时法:如果一个事务的等待时间超过了规定的时限,就认为发生了死锁。超时法实现简单,但其不足也很明显,一是有可能误判死锁,如事务因其他原因而使等待时间超过时限,系统会误认为发生了死锁;二是时限若设置得太长,死锁发生后不能及时发现。
- 等待图法:事务等待图是一个有向图
G
=
(
T
,
U
)
G = (T, U)
G=(T,U) ,
T
T
T 为节点的集合,每个节点表示正运行的事务;
U
U
U 为边的集合,每条边表示事务等待的情况。若
T
1
T_1
T1 等待
T
2
T_2
T2 ,则在
T
1
,
T
2
T_1, T_2
T1,T2 之间画一条从
T
1
T_1
T1 指向
T
2
T_2
T2 的有向边。如下图所示。
事务等待图动态地反映了所有事务的等待情况。并发控制子系统周期性地(比如每隔数秒)生成事务等待图,并进行检测。如果发现图中存在回路,则表示系统中出现了死锁。如上图(a)表示事务 T 1 T_1 T1 等待 T 2 T_2 T2 、 T 2 T_2 T2 等待 T 1 T_1 T1 ,产生了死锁;上图(b)表示事务 T 1 T_1 T1 等待 T 2 T_2 T2 、 T 2 T_2 T2 等待 T 3 T_3 T3 、 T 3 T_3 T3 等待 T 4 T_4 T4 、 T 4 T_4 T4 又等待 T 1 T_1 T1 ,产生了死锁。
当然,死锁的情况可以多种多样。例如上图(b)的事务 T 3 T_3 T3 可能还等待 T 2 T_2 T2 ,即在大回路中又有小的回路。这些情况人们都已经做了很深入的研究。
数据库管理系统的并发控制子系统,一旦检测到系统中存在死锁,就要设法解除。通常采用的方法是,选择一个处理死锁代价最小的事务,将其撤销,释放此事务持有的所有的锁,使其他事务得以继续运行下去。当然,对撤销的事务所执行的数据修改操作,必须加以恢复。
11.5 并发调度的可串行性
数据库管理系统对并发事务不同的调度,可能会产生不同的结果,那么什么样的调度是正确的呢?显然,串行调度是正确的,执行结果等价于串行调度的调度也是正确的。这样的调度称为可串行化调度。
11.5.1 可串行化调度
多个事务的并发执行是正确的,当且仅当其结果与「按某一次序串行地执行这些事务时的结果」相同,称这种调度策略为可串行化 serializable
调度。
可串行性 serializability
是并发事务正确调度的判别准则。按这个准则规定,一个给定的并发调度,当且仅当它是可串行化的,才被并发控制机制认为是正确调度。
【例11.2】现有两个事务,分别包含以下操作:
(1)事务
T
1
T_1
T1 :读
B
B
B ;
A
=
B
+
1
A = B + 1
A=B+1 ;写回
A
A
A 。
(2)事务
T
2
T_2
T2 :读
A
A
A ;
B
=
A
+
1
B = A + 1
B=A+1 ;写回
B
B
B 。
假设
A
,
B
A, B
A,B 的初值均为
2
2
2 。按
T
1
→
T
2
T_1 \to T_2
T1→T2 次序执行,结果为
A
=
3
,
B
=
4
A = 3, B = 4
A=3,B=4 ;按
T
2
→
T
1
T_2 \to T_1
T2→T1 次序执行,结果为
B
=
3
,
A
=
4
B = 3, A = 4
B=3,A=4 。
下图给出了对这两个事务不同的调度策略。其中,串行调度(a)和串行调度(b)为两种不同的串行调度策略,虽然执行结果不同,但它们都是正确的调度;调度c)执行结果与(a)、(b)的结果都不同,所以是错误的调度;调度(d)执行结果与串行调度(a)执行结果相同,所以是正确的调度(即可串行化的调度)。
11.5.2 冲突可串行化调度
具有什么样性质的调度是可串行化的调度?如何判断调度是可串行化的调度?下面给出判断可串行化调度的充分条件。首先介绍冲突操作的概念。
冲突操作是指不同的事务对同一个数据的读写操作与写写操作,其他操作是不冲突操作:
- R i ( x ) R_i(x) Ri(x) 与 W j ( x ) W_j(x) Wj(x) :事务 T i T_i Ti 读 x x x , T j T_j Tj 写 x x x ,其中 i ≠ j i \ne j i=j
- W i ( x ) W_i(x) Wi(x) 与 W j ( x ) W_j(x) Wj(x) :事务 T i T_i Ti 写 x x x , T j T_j Tj 写 x x x ,其中 i ≠ j i\ne j i=j
「不同事务的两个冲突操作」和「同一事务的两个操作」都是不能交换 swap
的,特别要注意前者。对于
R
i
(
x
)
R_i(x)
Ri(x) 与
W
j
(
x
)
W_j(x)
Wj(x) ,若改变二者的次序,则事务
T
i
T_i
Ti 看到的数据库状态就发生了改变,自然会影响到事务
T
i
T_i
Ti 后面的行为;对于
W
i
(
x
)
W_i(x)
Wi(x) 与
W
j
(
x
)
W_j(x)
Wj(x) ,改变二者的次序也会影响到数据库的状态,
x
x
x 的值由等于
T
j
T_j
Tj 的结果变成等于
T
i
T_i
Ti 的结果。
一个调度 S c Sc Sc 在保证冲突操作的次序不变的情况下,通过交换两个事务不冲突操作的次序,得到另一个调度 S c ′ Sc' Sc′ ,如果 S c ′ Sc' Sc′ 是串行的,称调度 S c Sc Sc 为冲突可串行化的调度。若一个调度是冲突可串行化的,则一定是可串行化的调度。因此可用这种方法判断一个调度是否是可串行化的。
【例11.3】今有调度
S
c
1
=
r
1
(
A
)
w
1
(
A
)
r
2
(
A
)
w
2
(
A
)
r
1
(
B
)
w
1
(
B
)
r
2
(
B
)
w
2
(
B
)
Sc_1 = r_1(A) w_1(A) r_2(A)\ w_2(A)r_1(B)w_1(B)\ r_2(B) w_2(B)
Sc1=r1(A)w1(A)r2(A) w2(A)r1(B)w1(B) r2(B)w2(B) 。可把
w
2
(
A
)
w_2(A)
w2(A) 与
r
1
(
B
)
w
1
(
B
)
r_1(B)w_1(B)
r1(B)w1(B) 交换,得到:
r
1
(
A
)
w
1
(
A
)
r
2
(
A
)
r
1
(
B
)
w
1
(
B
)
w
2
(
A
)
r
2
(
B
)
w
2
(
B
)
r_1(A) w_1(A) r_2(A)\ r_1(B)w_1(B)w_2(A) \ r_2(B) w_2(B)
r1(A)w1(A)r2(A) r1(B)w1(B)w2(A) r2(B)w2(B) 再把
r
2
(
A
)
r_2(A)
r2(A) 和
r
1
(
B
)
w
1
(
B
)
r_1(B)w_1(B)
r1(B)w1(B) 交换,得到:
S
c
2
=
r
1
(
A
)
w
1
(
A
)
r
1
(
B
)
w
1
(
B
)
r
2
(
A
)
w
2
(
A
)
r
2
(
B
)
w
2
(
B
)
Sc_2 = r_1(A) w_1(A) r_1(B)w_1(B)\ r_2(A)w_2(A) r_2(B) w_2(B)
Sc2=r1(A)w1(A)r1(B)w1(B) r2(A)w2(A)r2(B)w2(B)
S
c
2
Sc_2
Sc2 等价于一个串行调度
T
1
→
T
2
T_1 \to T_2
T1→T2 。所以
S
c
1
Sc_1
Sc1 为冲突可串行化的调度。
应该指出的是,冲突可串行化调度是可串行化调度的充分条件,不是必要条件(充分条件和必要条件怎么区分 ? - 暮野的回答)。还有不满足冲突可串行化条件的可串行化调度。
【例11.4】有三个事务
T
1
=
w
1
(
y
)
w
1
(
x
)
T_1 = w_1(y) w_1(x)
T1=w1(y)w1(x) ,
T
2
=
w
2
(
y
)
w
2
(
x
)
T_2 = w_2(y)w_2(x)
T2=w2(y)w2(x) ,
T
3
=
w
3
(
x
)
T_3 = w_3(x)
T3=w3(x) 。
(1)调度
L
1
=
w
1
(
y
)
w
1
(
x
)
w
2
(
y
)
w
2
(
x
)
w
3
(
x
)
L_1 = w_1(y) w_1(x)\ w_2(y) w_2(x)\ w_3(x)
L1=w1(y)w1(x) w2(y)w2(x) w3(x) 是一个串行调度。
(2)调度
L
2
=
w
1
(
y
)
w
2
(
y
)
w
2
(
x
)
w
1
(
x
)
w
3
(
x
)
L_2 = \color{red}w_1(y) \color{black}w_2(y) w_2(x) \color{red} w_1(x) \color{black}w_3(x)
L2=w1(y)w2(y)w2(x)w1(x)w3(x) 不满足冲突可串行化,但是调度
L
2
L_2
L2 是可串行化的,因为
L
2
L_2
L2 执行的结果与调度
L
1
L_1
L1 相同,
y
y
y 的值都等于
T
2
T_2
T2 的值,
x
x
x 的值都等于
T
3
T_3
T3 的值。
前面已经讲过,商用数据库管理系统的并发控制一般采用封锁的方法来实现,那么如何使用封锁机制才能产生可串行化调度呢?下面讲解的两段锁协议,就可以实现可串行化调度。
11.6 两段锁协议
为了保证并发调度的正确性,数据库管理系统的并发控制机制必须提供一定的手段,以保证调度是可串行化的。目前,DBMS普遍采用两段锁 TwoPhase Locking
(简称 2PL
)协议的方法,实现并发调度的可串行性,从而保证调度的正确性。
所谓两段锁协议是指,所有事务必须分两个阶段对数据项加锁和解锁:
- 在对任何数据进行读、写操作之前,首先要申请并获得对该数据的封锁;
- 在释放一个封锁之后,事务不再申请和获得任何其他封锁
所谓“两段”锁的含义是:事务分为两个阶段,第一阶段是获得封锁,也称为扩展阶段,在这个阶段,事务可以申请获得任何数据项上的、任何类型的锁,但不能释放任何锁;第二阶段是释放封锁,也称为收缩阶段,在这个阶段,事务可以释放任何数据项上的、任何类型的锁,但不能再申请任何锁。
例如,事务
T
i
T_i
Ti 遵守两段锁协议,其封锁序列是:
又如,事务
T
j
T_j
Tj 不遵守两段锁协议,其封锁序列是:
可以证明,若并发执行的所有事务均遵守两段锁协议,则对这些事务的任何并发调度策略都是可串行化的。例如,下图所示的调度是遵守两段锁协议的,因此一定是一个可串行化调度。
可以验证如下:忽略图中的加锁操作和解锁操作,按时间的先后次序可得如下的调度:
L
1
=
R
1
(
A
)
R
2
(
C
)
W
1
(
A
)
W
2
(
C
)
R
1
(
B
)
W
1
(
B
)
R
2
(
A
)
W
2
(
A
)
L_1 = R_1(A)R_2(C)W_1(A) W_2(C) R_1(B) W_1(B) R_2(A) W_2(A)
L1=R1(A)R2(C)W1(A)W2(C)R1(B)W1(B)R2(A)W2(A) 通过交换两个不冲突操作的次序(先把
R
2
(
C
)
R_2(C)
R2(C) 与
W
1
(
A
)
W_1(A)
W1(A) 交换,再把
R
1
(
B
)
W
1
(
B
)
R_1(B)W_1(B)
R1(B)W1(B) 与
R
2
(
C
)
W
2
(
C
)
R_2(C)W_2(C)
R2(C)W2(C) 交换),可得到一个串行调度:
L
2
=
R
1
(
A
)
W
1
(
A
)
R
1
(
B
)
W
1
(
B
)
R
2
(
C
)
W
2
(
C
)
R
2
(
A
)
W
2
(
A
)
L_2 = R_1(A)W_1(A)R_1(B) W_1(B)\ R_2(C) W_2(C) R_2(A) W_2(A)
L2=R1(A)W1(A)R1(B)W1(B) R2(C)W2(C)R2(A)W2(A) 因此
L
1
L_1
L1 是一个可串行化调度。
需要说明的是,事务遵守两段锁协议是可串行化调度的充分条件,而不是必要条件。也就是说,若并发事务都遵守两段锁协议,则对这些事务的任何并发调度策略都是可串行化的;但是,若并发事务的一个调度是可串行化的,不一定所有事务都符合两段锁协议。例如,11.5.1节「可串行化的调度」一图是可串行化调度,但 T 1 , T 2 T_1,T_2 T1,T2 不遵守两段锁协议。
另外,要注意两段锁协议和防止死锁的一次封锁法的异同之处——一次封锁法要求每个事务必须一次将所有要使用的数据全部加锁,否则就不能继续执行,因此一次封锁法遵守两段锁协议;但是两段锁协议并不要求事务必须一次将所有要使用的数据全部加锁,因此遵守两段锁协议的事务可能发生死锁,如下图所示:
11.7 封锁的粒度
封锁对象的大小称为封锁粒度 granularity
。封锁对象可以是逻辑单元,也可以是物理单元。以关系数据库为例,封锁对象可以是这样一些逻辑单元:属性值、属性值的集合、元组、关系、索引项、整个索引直至整个数据库,也可以是这样一些物理单元:页(数据页或索引页)、物理记录等。
封锁粒度与系统的并发度、并发控制的开销密切相关。直观地看,封锁的粒度越大,数据库所能封锁的数据单元就越少,并发度就越小,系统开销也越小;反之,封锁的粒度越小,数据库所能封锁的数据单元就越多,并发度就越大,系统开销也越大。
例如,若封锁粒度是数据页,事务 T 1 T_1 T1 需要修改元组 L 1 L_1 L1 ,则 T 1 T_1 T1 必须对包含 L 1 L_1 L1 的整个数据页 A A A 加锁。如果 T 1 T_1 T1 对 A A A 加锁后,事务 T 2 T_2 T2 要修改 A A A 中的元组 L 2 L_2 L2 ,则 T 2 T_2 T2 被迫等待,直到 T 1 T_1 T1 释放 A A A 上的锁。如果封锁粒度是元组,则 T 1 , T 2 T_1, T_2 T1,T2 可以同时、分别对 L 1 , L 2 L_1, L_2 L1,L2 加锁,不需要互相等待,从而提高了系统的并行度。又如,事务 T T T 需要读取整个表,若封锁粒度是元组, T T T 必须对表中的每个元组加锁,显然开销极大。
因此,如果在一个系统中同时支持多种封锁粒度、供不同的事务选择,是比较理想的,这种封锁方法称为多粒度封锁 multiple granularity locking
。选择封锁粒度时应同时考虑封锁开销和并发度两个因素,适当选择封锁粒度以求得最优的效果。一般来说,需要处理某个关系的大量元组的事务,可以关系为封锁粒度;需要处理多个关系的大量元组的事务,可以数据库为封锁粒度;而对于一个处理少量元组的用户事务,以元组为封锁粒度就比较合适了。
11.7.1 多粒度封锁
下面讨论多粒度封锁,首先定义多粒度树。多粒度树的根结点是整个数据库,表示最大的数据粒度,叶结点表示最小的数据粒度。下图给出了一个三级粒度树,根结点为数据库,数据库的子结点为关系,关系的子结点为元组。也可以定义四级粒度树,例如数据库、数据分区、数据文件、数据记录:
然后讨论多粒度封锁的封锁协议,多粒度封锁协议允许多粒度树中的每个结点被独立地加锁。对一个结点加锁,意味着这个结点的所有子孙结点也被加以同样类型的锁。因此,在多粒度封锁中,一个数据对象可能以两种方式封锁,显式封锁和隐式封锁:
- 显式封锁:应事务的要求直接加到数据对象上的锁;
- 隐式封锁:该数据对象没有被独立加锁,是由于其祖先结点加锁、而使该数据对象加上了锁
多粒度封锁方法中,显式封锁和隐式封锁的效果是一样的,因此系统检查封锁冲突时,不仅要检查显式封锁,还要检查隐式封锁。例如事务 T T T 要对关系 R 1 R_1 R1 加 X X X 锁,系统必须搜索其祖先结点(数据库)、关系 R 1 R_1 R1 以及 R 1 R_1 R1 的子孙结点(即 R 1 R_1 R1 中的每个元组),上下搜索,如果其中某个数据对象已经加了不相容锁,则 T T T 必须等待。
一般地,对某个数据对象加锁,系统要检查该数据对象上有无显式封锁与之冲突;再检查其所有祖先结点,看本事务的显式封锁是否与该数据对象上的隐式封锁(即由于祖先结点已加的封锁造成的)冲突;还要检查其所有子孙结点,看它们的显式封锁是否与本事务的隐式封锁(将加到子孙结点的封锁)冲突。显然,这样的检查方法效率很低。为此,人们引入了一种新型锁,称为意向锁 intention lock
。有了意向锁,数据库管理系统就无须逐个检查下一级结点的显式封锁。
11.7.2 意向锁
意向锁的含义是,如果对一个结点加意向锁,则说明该结点的下层结点正在被加锁;对任一结点加锁时,必须先对它的上层结点加意向锁。例如,对任一元组加锁时,必须先对它所在的数据库和关系加意向锁。
下面介绍三种常用的意向锁:意向共享锁 intent share lock
(简称
I
S
IS
IS 锁);意向排他锁 intent exclusive lock
(简称
I
X
IX
IX 锁);共享意向排他锁 share intent exclusive lock
(简称
S
I
X
SIX
SIX 锁):
- I S IS IS 锁:如果对一个数据对象加 I S IS IS 锁,表示它的子孙结点拟(意向)加 S S S 锁。例如,事务 T 1 T_1 T1 要对 R 1 R_1 R1 中某个元组加 S S S 锁,则要首先对关系 R 1 R_1 R1 和数据库加 I S IS IS 锁。
- I X IX IX 锁:如果对一个数据对象加 I X IX IX 锁,表示它的子孙结点拟(意向)加 X X X 锁。例如,事务 T 1 T_1 T1 要对 R 1 R_1 R1 中某个元组加 X X X 锁,则要首先对关系 R 1 R_1 R1 和数据库加 I X IX IX 锁。
- S I X SIX SIX 锁(特殊的锁):如果对一个数据对象加 S I X SIX SIX 锁,表示对它加 S S S 锁、再加 I X IX IX 锁,即 S I X = S + I X SIX = S+IX SIX=S+IX 。例如,对某个表加 S I X SIX SIX 锁,则表示该事务要读整个表(所以要对该表加 S S S 锁),同时会更新个别元组(所以要对该表加 I X IX IX 锁)。
下图(a)给出了这些锁的相容矩阵,从中可以发现这五种锁的强度如右下图所示的偏序关系,所谓锁的强度是指「它对其他锁的排斥程度」。一个事务在申请封锁时以强锁代替弱锁是安全的,反之则不然。 特别注意,加了
S
S
S 锁就不能加
I
X
IX
IX 锁,加了
I
X
IX
IX 锁后也不能加
S
S
S 锁,加了
S
I
X
SIX
SIX 锁后不可以加
S
S
S 锁、也不可以加
I
X
IX
IX 锁:
在具有意向锁的多粒度封锁方法中,任意事务
T
T
T 要对一个数据对象加锁,必须先对它的上层结点加意向锁。申请封锁时应按自上而下的次序进行,释放封锁时则应按自下而上的次序进行。例如,事务
T
1
T_1
T1 要对关系
R
1
R_1
R1 加
S
S
S 锁,则要首先对数据库加
I
S
IS
IS 锁,再检查数据库和
R
1
R_1
R1 是否已加入了不相容的锁(
X
X
X 或
I
X
IX
IX 锁),不再需要搜索和检查
R
1
R_1
R1 中的元组是否加了不相容的锁(
X
X
X 锁)——如果加了,则
R
1
R_1
R1 和数据库上会有
I
X
IX
IX 锁。
具有意向锁的多粒度封锁方法,提高了系统的并发度,减少了加锁和解锁的开销,已经在实际的DBMS产品中得到广泛应用。
11.8 其他并发控制协议*
并发控制的方法除了封锁技术外,还有时间戳方法、乐观控制法、多版本并发控制等。这里做一个概要的介绍。
- 时间戳方法给每个事务盖上一个时标,即事务开始执行的时间。每个事务具有唯一的时间戳,并按照这个时间戳来解决事务的冲突操作。如果发生了冲突操作,就回滚具有较早时间戳的事务,以保证其他事务的正常执行,被回滚的事务被赋予新的时间戳、并从头开始执行。
- 乐观控制法认为事务执行时很少发生冲突,因此不对事务进行特殊的管制,而是让它自由执行,事务提交前再进行正确性检查。如果检查后发现该事务执行中出现过冲突、并影响了可串行性,则拒绝提交并回滚该事务。乐观控制法又被称为验证方法
certifier
。 - 多版本并发控制是指,在数据库中通过维护数据对象的多个版本信息、以实现高效并发控制的一种策略。
11.8.1 多版本并发控制
版本 version
是指「数据库中数据对象的一个快照」,记录了数据对象某个时刻的状态。随着计算机系统存储设备价格的不断降低,可考虑为数据库系统的数据对象保留多个版本,以提高系统的并发操作程度。
例如,有一个数据对象
A
A
A 和两个事务,
T
1
T_1
T1 是写事务、
T
2
T_2
T2 是读事务。假定先启动
T
1
T_1
T1 事务、后启动
T
2
T_2
T2 事务。按照传统的封锁协议,
T
2
T_2
T2 事务必须等待
T
1
T_1
T1 事务执行结束、释放
A
A
A 上的封锁后,才能获得对
A
A
A 的封锁,也就是说,
T
1
,
T
2
T_1, T_2
T1,T2 实际上是串行执行的。如果在
T
1
T_1
T1 准备写
A
A
A 时不是等待,而是为
A
A
A 生成一个新的版本(表示为
A
′
A'
A′),那么
T
2
T_2
T2 就可以继续在
A
′
A'
A′ 上执行。只是在
T
2
T_2
T2 准备提交时,要检查一下事务
T
1
T_1
T1 是否已经完成——如果
T
1
T_1
T1 已经完成了,
T
2
T_2
T2 就可以放心地提交;如果
T
1
T_1
T1 还没有完成,那么
T
2
T_2
T2 必须等待直到
T
1
T_1
T1 完成。这样,既能保证事务执行的可串行性,又提高了事务执行的并发度,如下图所示。
在多版本机制中,每个
w
r
i
t
e
(
Q
)
write(Q)
write(Q) 操作都创建
Q
Q
Q 的一个新版本,这样一个数据对象就有一个版本序列
Q
1
,
Q
2
,
…
,
Q
m
Q_1, Q_2, \dots , Q_m
Q1,Q2,…,Qm 与之相关联。每个版本
Q
k
Q_k
Qk 拥有版本的值、创建
Q
k
Q_k
Qk 的事务的时间戳 W-timestamp(Qk)
和成功读取
Q
k
Q_k
Qk 的事务的最大时间戳 R-timestamp(Qk)
。其中,W-timestamp(Q)
表示在数据项
Q
Q
Q 上成功执行
w
r
i
t
e
(
Q
)
write(Q)
write(Q) 操作的所有事务中的最大时间戳,R-timestamp(Q)
表示在数据项
Q
Q
Q 上成功执行
r
e
a
d
(
Q
)
read(Q)
read(Q) 操作的所有事务中的最大时间戳。
用 T S ( T ) TS(T) TS(T) 表示事务 T T T 的时间戳, T S ( T i ) < T S ( T j ) TS(T_i) < TS(T_j) TS(Ti)<TS(Tj) 表示事务 T i T_i Ti 在事务 T j T_j Tj 之前开始执行。多版本协议描述如下:
- 假设版本 Q k Q_k Qk 具有小于或等于 T S ( T ) TS(T) TS(T) 的最大时间戳。
- 若事务 T T T 发出 r e a d ( Q ) read(Q) read(Q) ,则返回版本 Q k Q_k Qk 的内容。
- 若事务
T
T
T 发出
w
r
i
t
e
(
Q
)
write(Q)
write(Q) ,则:
- 当
T
S
(
T
)
<
TS(T) <
TS(T)<
R-timestamp(Qk)
时,回滚 T T T ; - 当
T
S
(
T
)
=
TS(T) =
TS(T)=
W-timestamp(Qk)
时,覆盖 Q k Q_k Qk 的内容。
- 当
T
S
(
T
)
<
TS(T) <
TS(T)<
- 否则,创建 Q Q Q 的新版本。
若一个数据对象的两个版本
Q
k
,
Q
l
Q_k, Q_l
Qk,Ql ,其 W-timestamp
都小于系统中最老的事务的时间戳,那么这两个版本中较旧的那个版本将不再被用到,因而可以从系统中删除。
多版本并发控制利用物理存储上的多版本,以维护数据的一致性。这就意味着当检索数据库时,每个事务都看到一个数据的一段时间前的快照,而不管正在处理的数据当前的状态。多版本并发控制和封锁机制相比,主要好处是消除了数据库中数据对象读和写操作的冲突,有效地提高了系统的性能。
多版本并发控制方法有利于提高事务的并发度,但也会产生大量的无效版本。而且在事务结束时刻,其所影响的元组的有效性不能马上确定,这就为保存事务执行过程中的状态提出了难题。这些都是实现多版本并发控制的一些关键技术。
11.8.2 改进的多版本并发控制
多版本协议可以进一步改进。区分事务的类型为只读事务和更新事务,对于只读事务,发生冲突的可能性很小,可以采用多版本时间戳;对于更新事务,采用较保守的两阶段封锁协议 2PL
。这样的混合协议称为 MV2PL
。具体做法如下。
除了传统的读锁(共享锁)和写锁(排他锁)外,还引入一个新的封锁类型,称为验证锁 certify-lock
(简称
C
C
C 锁)。封锁的相容矩阵如下图所示:
R-Lock | W-Lock | C-Lock | |
---|---|---|---|
R-Lock | Y | Y | N |
W-Lock | Y | N | N |
C-Lock | N | N | N |
注意,在这个相容矩阵中,读锁和写锁变得相容了。这样当某个事务写数据对象的时候,允许其他事务读数据(当然,写操作将生成一个新的版本,而读操作就是在旧的版本上读)。一旦写事务要提交的时候,必须首先获得在那些加了写锁的数据对象上的验证锁。由于验证锁和读锁是不相容的,所以为了得到验证锁,写事务不得不延迟它的提交,直到所有被它加上写锁的数据对象,都被所有那些正在读它们的事务释放。一旦写事务获得验证锁,系统就可以丢弃数据对象的旧值,代之以新版本,然后释放验证锁,提交事务。
在这里,系统最多只要维护数据对象的两个版本,多个读操作可以和一个写操作并发地执行。这种情况是传统的 2PL
所不允许的,提高了读写事务之间的并发度。
目前的很多商用数据库系统,比如Oracle、国产的金仓数据库Kingbase ES都是采用 MV2PL
协议的。MV2PL
把封锁机制和时间戳方法相结合,维护一个数据的多个版本,即对于关系表上的每个写操作产生
r
r
r 的一个新版本,同时会保存前一次修改的数据版本。MV2PL
和封锁机制相比,主要好处是在多版本并发控制中,对读数据的锁要求和写数据的锁要求不冲突,所以读不会阻塞写,而写也从不阻塞读,从而使读写操作没有冲突,有效地提高了系统的并发性。
现在的许多数据库产品,都使用了多版本并发控制技术,但是各个产品的实现细节各不相同,可参考文献 [13]~[16]
和相关产品介绍。