基于时间戳的协议
之前已经讲过了调度的概念,SQL 标准规定了四种隔离性级别,在不同的隔离性级别下,我们对调度的要求也不同
但应当注意到,调度一般是由操作系统执行的,数据库系统无法直接控制并行事务的调度方式,因此,数据库系统需要通过一些方法,间接地影响并行事务的调度方式,保证只产生需要的调度,从而实现 SQL 规定的四种隔离性级别
常见的能够间接影响并行事务调度方式的方法有:时间戳、锁、有效性检查、快照隔离等,数据库系统通常用这些方法来实现可串行化的隔离性级别,还有一些其它方法可以用来实现其它隔离性级别
时间戳
基于时间戳的协议,通常会给每个事务分配一个时间戳,时间戳一般是一个整数,不同事务的时间戳一般不同,并且开始时间更晚的事务的时间戳值一般更大
我们有两个很简单的方式给事务分配时间戳:
- 一个事务的时间戳等于这个事务开始执行时,系统时钟的值
- 维护一个计数器,当一个事务开始执行时,将计数器的值当作这个事务的时间戳,之后将计数器值加一
时间戳排序协议
基本规则
时间戳排序协议的目的是,产生的调度都和一个串行调度冲突等价,并且这个串行调度的串行顺序是事务的时间戳顺序
给每个可能被访问到的数据项维护两个值, W − t i m e W-time W−time 和 R − t i m e R-time R−time , W − t i m e ( d ) W-time(d) W−time(d) 维护所有成功执行 w r i t e ( d ) write(d) write(d) 的事务的时间戳最大值, R − t i m e ( d ) R-time(d) R−time(d) 维护所有成功执行 r e a d ( d ) read(d) read(d) 的事务的时间戳最大值
设 t i m e ( T ) time(T) time(T) 表示事务 T T T 的时间戳,当事务 T T T 试图执行 r e a d ( d ) read(d) read(d) 时:
- 若 t i m e ( T ) < W − t i m e ( d ) time(T)<W-time(d) time(T)<W−time(d) ,说明 T T T 需要读的值已经被覆盖,因此操作失败,事务 T T T 回滚
- 若 t i m e ( T ) ≥ W − t i m e ( d ) time(T) \ge W-time(d) time(T)≥W−time(d) ,则可以执行 r e a d ( d ) read(d) read(d) , R − t i m e ( d ) R-time(d) R−time(d) 被设置为 t i m e ( T ) time(T) time(T) 和 R − t i m e ( d ) R-time(d) R−time(d) 的最大值
当事务 T T T 试图执行 w r i t e ( d ) write(d) write(d) 时:
- 若 t i m e ( T ) < R − t i m e ( d ) time(T)<R-time(d) time(T)<R−time(d) ,则事务 T T T 执行 w r i t e ( d ) write(d) write(d) 会导致其它某些事务 r e a d ( d ) read(d) read(d) 读取的值不正确,因此操作失败,事务 T T T 回滚
- 若 t i m e ( T ) < W − t i m e ( d ) time(T)<W-time(d) time(T)<W−time(d) ,则事务 T T T 试图写入的值已过时,因此操作失败,事务 T T T 回滚
- 其它情况下,可以执行 w r i t e ( d ) write(d) write(d) , W − t i m e ( d ) W-time(d) W−time(d) 被设置为 t i m e ( T ) time(T) time(T)
当一个事务因为以上某个原因回滚时,这个事务之后会重启,重启时这个事务将获得一个新的时间戳
根据以上规则和冲突可串行化的定义,可以发现,如果数据库系统采用了时间戳排序协议,就可以保证产生的调度是冲突可串行化的
需要注意的是,仅仅利用以上规则,不能保证产生的调度是可恢复调度,因此时间戳排序协议通常需要一些额外规则,来保证产生的调度是可恢复调度,或者更进一步,是无级联调度,例如,当一个事务 T T T 成功执行了 w r i t e ( d ) write(d) write(d) 后,将 d d d 打上一个未提交标记,当 T T T 提交时,清除该标记,当另一个事务执行 r e a d ( d ) read(d) read(d) 时,若发现 d d d 有未提交标记,则需进行等待,直到未提交标记清除
视图可串行化
对于同一组事务的两个调度 S S S 和 S ′ S' S′ ,如果满足以下条件:
- 如果调度 S S S 中事务 T T T 的一个 r e a d ( d ) read(d) read(d) 操作读取的是 d d d 在调度开始时的初始值,那么调度 S ′ S' S′ 中事务 T T T 的同一个 r e a d ( d ) read(d) read(d) 操作读取的也是 d d d 的初始值
- 如果调度 S S S 中事务 T 1 T_1 T1 的一个 r e a d ( d ) read(d) read(d) 操作读取的是事务 T 2 T_2 T2 的一个 w r i t e ( d ) write(d) write(d) 操作写入的值,那么调度 S ′ S' S′ 中事务 T 1 T_1 T1 的同一个 r e a d ( d ) read(d) read(d) 操作读取的是 T 2 T_2 T2 的同一个 w r i t e ( d ) write(d) write(d) 操作写入的值
- 如果调度 S S S 中最后一个 w r i t e ( d ) write(d) write(d) 操作是由事务 T T T 执行的,那么调度 S ′ S' S′ 中最后一个 w r i t e ( d ) write(d) write(d) 操作也由事务 T T T 执行
那么我们称调度 S S S 和调度 S ′ S' S′ 视图等价
如果一个调度 S S S 和某个串行调度视图等价,则我们称 S S S 是视图可串行化的
我们已经知道冲突可串行化的概念了,设冲突可串行化调度的集合为 P 1 P_1 P1 ,视图可串行化调度的集合为 P 2 P_2 P2 ,等价于某个串行调度的调度的集合为 P 3 P_3 P3 ,则 P 1 P_1 P1 是 P 2 P_2 P2 的子集, P 2 P_2 P2 是 P 3 P_3 P3 的子集
遗憾的是,对于一个调度是否为视图可串行化调度的判定是一个 NP 完全问题,相比于只需要使用拓扑排序就能判定的冲突可串行化调度,视图可串行化调度的判定速度太慢
Thomas 写规则
可以对时间戳排序协议加以改进,从而提高事务的并发度
当事务 T T T 试图执行 w r i t e ( d ) write(d) write(d) 操作时,若 t i m e ( T ) < W − t i m e ( d ) time(T)<W-time(d) time(T)<W−time(d) ,原本的做法是将 T T T 回滚,但事实上,若 R − t i m e ( d ) ≤ t i m e ( T ) < W − t i m e ( d ) R-time(d) \le time(T)<W-time(d) R−time(d)≤time(T)<W−time(d) ,那么事务 T T T 执行 w r i t e ( d ) write(d) write(d) 写入的值,根本不会被任何事务读取到
当另一个事务 T ′ T' T′ 试图执行 r e a d ( d ) read(d) read(d) 操作时,若 t i m e ( T ′ ) < W − t i m e ( d ) time(T')<W-time(d) time(T′)<W−time(d) ,那么 T ′ T' T′ 应该回滚,若 t i m e ( T ′ ) ≥ W − t i m e ( d ) time(T') \ge W-time(d) time(T′)≥W−time(d) ,那么在 T ′ T' T′ 执行 r e a d ( d ) read(d) read(d) 操作时,事务 T T T 写入的数据早已被覆盖,因此无论如何, T ′ T' T′ 都不会读取到事务 T T T 写入的值
既然 R − t i m e ( d ) ≤ t i m e ( T ) < W − t i m e ( d ) R-time(d) \le time(T)<W-time(d) R−time(d)≤time(T)<W−time(d) 时,事务 T T T 执行 w r i t e ( d ) write(d) write(d) 写入的值,根本不会被任何事务读取到,那我们简单地忽略掉这个 w r i t e ( d ) write(d) write(d) 操作即可
这种修改后的写规则被称为 Thomas 写规则,采用了 Thomas 写规则后,由于避免了某些情况下的事务回滚,时间戳排序协议的并发度提高,同时,采用了 Thomas 写规则的时间戳排序协议产生的调度不再一定是冲突可串行化调度了,但一定是视图可串行化调度
多版本时间戳排序协议
多版本时间戳排序协议在普通的时间戳排序协议的基础上,加上了多版本机制,对于一个数据项 d d d ,在加入多版本机制之前,我们只保存 d d d 的最新值,而加入了多版本机制后,我们保存多个版本的 d d d 值,形成一个序列 d 1 , d 2 , ⋯ , d n d_1,d_2, \cdots ,d_n d1,d2,⋯,dn , d d d 的值每被修改一次,就为 d d d 创建一个新的版本
W − t i m e ( d i ) W-time(d_i) W−time(di) 现在表示创建版本 d i d_i di 的事务的时间戳, R − t i m e ( d i ) R-time(d_i) R−time(di) 维护所有读取版本 d i d_i di 的事务的时间戳最大值
当事务 T T T 执行 r e a d ( d ) read(d) read(d) 操作或 w r i t e ( d ) write(d) write(d) 操作时,首先需要定位到正确的版本,也就是说需要先找到 d d d 的一个版本 d i d_i di ,使得 d i d_i di 的 W − t i m e W-time W−time 是所有小于等于 t i m e ( T ) time(T) time(T) 的 W − t i m e W-time W−time 中最大的一个
定位到正确的版本 d i d_i di 之后,根据执行的操作类型分类讨论:
- 当执行 read(d) 操作时,直接返回 d i d_i di 的值,同时将 R − t i m e ( d i ) R-time(d_i) R−time(di) 设置为 R − t i m e ( d i ) R-time(d_i) R−time(di) 和 t i m e ( T ) time(T) time(T) 的最大值
- 当执行 write(d) 操作时,若 t i m e ( T ) < R − t i m e ( d i ) time(T)<R-time(d_i) time(T)<R−time(di) ,则事务 T T T 执行 w r i t e ( d ) write(d) write(d) 会导致其它某些事务 r e a d ( d ) read(d) read(d) 读取的值不正确,因此操作失败,事务 T T T 回滚,若 t i m e ( T ) ≥ R − t i m e ( d i ) time(T) \ge R-time(d_i) time(T)≥R−time(di) 且 t i m e ( T ) = W − t i m e ( d i ) time(T)=W-time(d_i) time(T)=W−time(di) ,则直接修改 d i d_i di 的值,不用为 d d d 创建新版本,若 t i m e ( T ) ≥ R − t i m e ( d i ) time(T) \ge R-time(d_i) time(T)≥R−time(di) 且 t i m e ( T ) > W − t i m e ( d i ) time(T)>W-time(d_i) time(T)>W−time(di) ,则为 d d d 创建一个新版本, w r i t e ( d ) write(d) write(d) 操作将值写入新版本
显然,多版本时间戳排序协议产生的调度一定是冲突可串行化调度,和时间戳排序协议一样,多版本时间戳排序协议也不保证产生的调度是可恢复调度,需要添加一些额外的规则,才能保证多版本时间戳排序协议产生的调度一定是可恢复调度,或者更进一步,一定是无级联调度
当然,有些版本的数据项可能会因为太旧而不会再被使用到,如果当前还在执行的事务中,事务 T T T 的时间戳最小,那么对于某个数据项的所有 W − t i m e < t i m e ( T ) W-time<time(T) W−time<time(T) 的版本,只需要保存它们中 W − t i m e W-time W−time 最大的一个
多版本时间戳排序协议比起时间戳排序协议有一个好处,那就是在执行读取操作时保证一定成功,不会因为读取操作失败而导致事务回滚,事务中读取操作所占的比例越大,这种好处就越明显
基于锁的协议
如果不同的事务以互斥的方式对数据项进行访问,就可以确保隔离性,基于锁的协议就是采用了这种思想
锁
我们首先建立一个关于锁的简化的模型,只考虑两种锁,共享锁(又叫作 S 锁)和排他锁(又叫作 X 锁),这两种锁都是加在数据项上的
事务 T T T 获得了数据项 d d d 上的共享锁后, T T T 可以对 d d d 进行读取,但不能进行写入,事务 T T T 获得了数据项 d d d 上的排他锁后, T T T 可以对 d d d 进行读取和写入,若事务 T T T 没有任何数据项 d d d 上的锁,则 T T T 不可以对 d d d 进行读取或写入
事务 T T T 可以在任何时候向锁管理器申请某个数据项上的某种锁,设数据项为 d d d ,申请的锁的类型为 A A A ,锁管理器检查当前在 d d d 上是否有和 A A A 类锁不相容的锁,如果没有,锁管理器就给 T T T 授予一个 d d d 上的 A A A 类锁,否则事务 T T T 就需要等待,直到锁管理器给 T T T 授予这个锁
事务 T T T 也可以在任何时候向锁管理器申请释放其拥有的某个锁,释放的申请一定能通过
顾名思义,共享锁和共享锁之间相容,排他锁和排他锁之间不相容,共享锁和排他锁之间不相容,具体可以参见如下的相容性矩阵:
有些时候,一个事务 T T T 在事务一开始就要读取数据项 d d d 的值,然后直到事务快结束时才需要对 d d d 进行写入,加入 T T T 在一开始就申请 d d d 上的排他锁,会导致其它很多事务无法并行地读取 d d d
针对上述情况,我们可以引入锁转换的机制,用升级锁表示将一个共享锁转换为同一数据项上的排他锁,用降级表示将一个排他锁转换为同一数据项上的共享锁,锁转换也可以理解为事务先释放旧锁,然后申请新锁,有时也将一个未来会升级为排他锁的共享锁称作更新锁(又叫作 U 锁)
使用锁时经常会遇到两种问题:死锁和饿死,死锁是指多个事务并行执行时,每个事务都在等待另一个事务释放某个锁,并且出现了循环等待,而饿死是指,一个事务等待申请某个锁,但由于某种原因,等待的时间过长或无限长,例如一个事务等待申请某个数据项上的排他锁,但不停地有新事务获得这个数据项上的共享锁,因此该事务始终申请不到排他锁
如何处理死锁会在之后讲,饿死相对来说比较好处理,当一个事务 T T T 申请数据项 d d d 上的 A A A 类锁时,锁管理器通过该申请的条件是:
- 当前 d d d 上不存在与 A A A 类锁不相容的锁
- 不存在一个正在等待对 d d d 加锁的更早的申请
这样新事务要想获得排他锁,必须等待更早的排他锁申请被批准,因此不会出现饿死现象
锁管理器
锁管理器接收事务的消息并反馈消息,事务可以向锁管理器请求一个锁或申请释放一个锁,锁管理器返回是否批准该申请
锁管理器分别为每个数据项维护加在其上的锁,如果有事务申请某个数据项上的锁,锁管理器就为该数据项维护一个链表,链表中每个节点都保存了关于申请锁的信息,例如申请锁的事务 ID 、申请的锁的类型、申请的状态(已经批准或仍在等待)等,一个新的申请会被添加在链表尾部,因此所有申请按申请时间在链表中顺序排列
为了快速找到一个数据项对应的链表,所有链表的头节点被保存在一个散列表中,散列表以数据项的名称为索引,这样一个散列表套链表的结构被称作锁表
当锁管理器接收到某个事务对一个数据项加锁的申请时,如果在散列表中存在该数据项对应的链表,则先对链表进行检查,当链表中不存在处于等待状态的申请并且该数据项上每一个锁都与此次申请的锁相容时,此次申请被批准,否则此次申请进入等待状态,最后在链表尾部添加一个包含此次申请信息的节点,如果在散列表中不存在该数据项对应的链表,则创建一个新的链表,只包含一个节点,然后将新的链表存入散列表,并且此次申请一定会被批准,无论申请是否被批准,锁管理器都会将结果反馈给发出申请的事务
当锁管理器接收到某个事务释放锁的申请时,先找到该数据项对应的链表,然后在链表中删除对应的节点,之后从链表中第一个处于等待状态的申请开始,检查之后处于等待状态的申请能否被批准,如果可以,则批准该申请并继续检查下一个处于等待状态的申请,否则停止检查
当某个事务进入失败状态时,该事务会通知锁管理器,锁管理器会删除该事务的所有处于等待状态的申请,当该事务进行回滚时,锁管理器会释放该事务所持有的所有锁,这要求锁管理器额外记录每个事务持有了申请了哪些锁
死锁处理
死锁的必要条件
死锁的产生需要满足四个必要条件:
- 互斥:当一个事务对某个数据项加排他锁时,另一个事务无法对该数据项加锁
- 不可抢占:一个事务获得的锁不能被其它事务抢占,也就是说一个锁只能被获得这个锁的事务主动释放,其它事务无法干涉
- 占有并等待:当一个事务因为申请某个锁而进入等待状态时,该事务不会释放已经获得的锁
- 循环等待:对于一组进入等待状态的事务 T 1 , T 2 , ⋯ , T n T_1,T_2, \cdots ,T_n T1,T2,⋯,Tn ,构建一个被称为等待图的有向图,每个事务对应于图上的一个点,如果事务 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 申请一个锁时,发现数据项上存在另一个 T 2 T_2 T2 已持有的锁和该锁不相容,此时锁管理器根据 T 1 T_1 T1 和 T 2 T_2 T2 的时间戳进行冲突处理,冲突处理有两种机制:
- wait-die 机制:若 T 1 T_1 T1 的时间戳小于 T 2 T_2 T2 的时间戳,则允许 T 1 T_1 T1 等待,否则 T 1 T_1 T1 回滚
- wound-wait 机制:若 T 1 T_1 T1 的时间戳小于 T 2 T_2 T2 的时间戳,则 T 2 T_2 T2 回滚,否则 T 1 T_1 T1 等待
两种机制都预防了循环等待,wait-die 机制只允许时间戳小的事务等待时间戳大的事务,wound-wait 机制只允许时间戳大的事务等待时间戳小的事务
基于时间戳的方法的优点在于它不要求事务在开始执行之前清楚自己需要哪些锁,而缺点在于该方法通常会带来很多不必要的回滚,回滚的开销一般比较大
最后还有一种基于锁超时的方法,首先设定一个阈值,若一个申请等待的时间超过阈值,发出该申请的事务就进行回滚
基于锁超时的方法缺点在于,很难确定阈值应该定为多少,如果阈值太小,会造成大量不必要的回滚,如果阈值太大,死锁真正发生时产生的延迟又太高,此外,该方法无法避免饿死
死锁检测与恢复
死锁检测的方法在之前已经提到过,就是判断等待图上是否有环,只需要定期在等待图上执行一次拓扑排序,就能实现死锁检测
检测到死锁之后,就要将调度从死锁中恢复过来,通常采取的做法是回滚一些事务,从而消除等待图上的环,回滚事务时,可以简单地让事务回到开始执行前的状态,也可以部分回滚,让事务回退到能够恢复死锁的一个历史状态,部分回滚需要每个事务保存自己的历史状态
若回滚一些事务可以使调度从死锁中恢复过来,那这些事务就构成一个候选回滚事务集,数据库系统需要在候选回滚事务集中选择一个事务集进行回滚,这通常要考虑多方面因素,选择综合代价最小的一个候选事务集,这些因素通常包括:
- 事务集的大小
- 事务集中的事务已经执行了多久,预计还需执行多久
- 事务集中的事务已获得了多少个锁,预计还需申请多少个锁
- 事务集中的事务已经回滚了多少次
其中加入第四个因素的目的是,避免某个事务集因回滚的代价很小而多次回滚,导致饿死
两阶段封锁协议
基本规则
两阶段封锁协议要求每个事务分成两个阶段执行,首先进入增长阶段,然后进入缩减阶段,在增长阶段,事务在增长阶段中只能申请锁或升级锁,不能释放锁或降级锁,在缩减阶段只能释放锁或降级锁,不能申请锁或升级锁,也就是说,一旦某个事务第一次释放锁或降级锁,这个事务就从增长阶段转入缩减阶段,之后不能再申请锁或升级锁
遵循两阶段封锁协议是为了产生冲突可串行化调度,若使用两阶段封锁协议产生了一个无死锁的调度 S S S ,可以将 S S S 中每个事务按照最后一次申请锁或升级锁的时间排序,将这个顺序作为串行调度 S ′ S' S′ 的串行顺序,不难证明 S S S 和 S ′ S' S′ 冲突等价
但是应当注意,以上推导的前提是使用两阶段封锁协议能产生一个无死锁的调度,而有些情况下,使用两阶段封锁协议产生的调度中存在死锁,我们可以用之前提到的处理死锁的方法来改进两阶段封锁协议
此外,两阶段封锁协议不保证产生可恢复调度,不过只需增加一条额外的规则,一个事务持有的排他锁必须在事务提交时才能释放,这样就能保证产生无级联调度,修改后的协议也被称作严格两阶段封锁协议,此外还有强两阶段封锁协议,即一个事务持有的所有锁必须在事务提交时才能释放
多版本两阶段封锁协议
多版本两阶段封锁协议在两阶段封锁协议的基础上,添加了多版本机制,该协议对只读事务和更新事务进行了区分
在事务执行的过程中,对一个数据项需要维护不同版本,每个版本都有一个版本号,数据库系统维护着一个计数器,每有一个更新事务提交,计数器就加一
只读事务在开始执行时,记录当前计数器的值 c n t cnt cnt ,之后该事务执行 r e a d ( d ) read(d) read(d) 时,读取 d d d 的所有版本号小于 c n t cnt cnt 的版本中,版本号最大的版本,并且不需要加锁,因为一个已经创建的版本中的数据不会再改变
更新事务执行 r e a d ( d ) read(d) read(d) 时,申请 d d d 上的共享锁,申请被批准之后读取 d d d 的最新版本,更新事务执行 w r i t e ( d ) write(d) write(d) 时,申请 d d d 上的排他锁,然后为 d d d 创建一个新版本,将要修改的值写入刚创建的版本中,这个新创建的版本暂时是没有版本号的,因此该版本暂时对其它事务不可见
当更新事务提交时,设当前计数器的值为 c n t cnt cnt ,将该事务创建的所有版本的版本号设置为 c n t cnt cnt ,使这些版本对其它事务可见,然后计数器的值加一,同一时间只允许一个更新事务提交
该协议产生的调度一定是冲突可串行化的,并且是无级联调度,该协议的优点在于,只读事务不需要申请锁,因此永远不需要等待,缺点在于可能会产生死锁
类似于多版本时间戳排序协议,太旧的版本可以被删除,如果在当前仍在执行的只读事务中,记录的计数器值最小为 c n t cnt cnt ,则对于某个数据项的所有版本号小于 c n t cnt cnt 的版本,只需要保留它们中版本号最大的一个
树形协议
基本规则
树形协议要求构建一棵有根树,树中的一个点代表一个数据项,树形协议中事务只能对数据项加排他锁,即使事务只需要读取某个数据项,也必须申请该数据项上的排他锁
在树形协议中,一个事务 T T T 需要满足如下规则:
- T T T 第一次可以在任何数据项上加锁,但之后对数据项 d d d 加锁前,需要持有 d d d 的父亲的锁
- T T T 可以在任何时候申请锁或释放锁,但如果 T T T 对数据项 d d d 加锁后又释放该锁,之后 T T T 不能再对 d d d 加锁
树形协议产生的调度一定是冲突可串行化调度,同时保证不会出现死锁,因为所有事务按拓扑顺序加锁,不会出现循环等待
仅遵循以上规则,树形规则不能保证产生的调度一定是可恢复调度,可以额外添加一条规则,要求事务持有的排他锁必须在事务提交时释放,这样可以保证产生可恢复调度和无级联调度
树形协议的优点在于,不需要为处理死锁而进行回滚,但缺点在于,一个事务可能需要对它根本用不到的数据项加锁,并且由于只能给数据项加排他锁,因此两个事务对同一数据项的读操作也是不相容的
可以对树形协议加以扩展,将数据项组织成有向无环图的形式,扩展后的协议比较复杂,暂时不讲
优化
树形协议的并发度还有可以提高的空间,在构建出有根树以后,我们可以在树上每一对父子节点之间添加一个虚点,构成一棵新的有根树,在新的树上执行树形协议能够提高并发度
假设原来的有根树上,节点 A A A 是节点 B B B 的父节点,一开始,事务 T 1 T_1 T1 给 B B B 加锁,事务 T 2 T_2 T2 给 A A A 加锁,之后事务 T 2 T_2 T2 执行完了所有对节点 A A A 对应的数据项的读写操作,开始申请给 B B B 加锁,由于此时 T 1 T_1 T1 仍持有 B B B 的锁,因此 T 2 T_2 T2 需要等待,注意, T 2 T_2 T2 在等待期间不能释放 A A A 上的锁,因为在给 B B B 加锁时,必须持有 A A A 上的锁,若此时有另一个事务 T 3 T_3 T3 申请 A A A 上的锁,则也需要等待
在优化之后,节点 A A A 和 B B B 之间新增了一个虚点 C C C ,对于之前的情况,事务 T 2 T_2 T2 可以在拿到 C C C 上的锁之后释放 A A A 上的锁,这样 T 3 T_3 T3 申请 A A A 上的锁时不需要等待
多粒度
到目前为止,加锁的对象始终是数据项,如果一个事务扫描了整个关系,那么这个事务需要给这个关系中所有数据项加锁,这可能会耗费锁管理器大量的时间,如果这个事务能直接提出给整个关系加锁的申请,管理锁的效率就可以得到提升
因此我们需要一种多粒度加锁的机制,一个事务既可以申请给一个关系加锁,也可以申请给一个数据项加锁
我们用一个树形结构来表示不同的粒度,树上的一个节点在下一层被以更细的粒度划分成若干个子节点,例如一个关系对应的节点,它的子节点恰好为关系中所有记录对应的节点,一个节点被加锁,意味着给该节点的所有后代节点都加上了对应的锁
下面考虑如何在这个树形结构上给某个节点加锁,要想给某个节点 p p p 加类型为 A A A 的锁,就要看 p p p 上是否有和 A A A 类锁不相容的锁,光查看节点 p p p 是不够的,因为 p p p 的祖先节点上的锁,也隐式地加在了 p p p 上,此外,还存在着问题,即使树根到 p p p 的路径中所有的节点上都没有和 A A A 类锁不相容的锁,也不能立刻就给 p p p 加上 A A A 类锁,因为 p p p 的后代节点,可能在之前被加上了和 A A A 类锁不相容的锁
解决这个问题的方法是引入新类型的锁,这些新的锁统称为意向锁,包括共享型意向锁(又叫作 IS 锁)、排他型意向锁(又叫作 IX 锁)以及共享排他型意向锁(又叫作 SIX 锁)
如果我们要给节点 p p p 的后代节点加共享锁,则需要先给 p p p 加共享型意向锁,如果我们要给 p p p 的后代节点加排他锁,则需要先给 p p p 加排他型意向锁
由于新增了几种锁,因此相容性矩阵也需要更新:
如果我们要扫描整个关系,并修改其中少量的记录,我们可能希望给要修改的记录加排他锁,给剩下的记录加共享锁,我们有几种选择:
- 给关系对应的节点加 IX 锁,然后再给每条记录对应的节点根据使用情况加 S 锁或 X 锁,但这样做的问题在于,没有发挥出多粒度的好处,我们还是给每条记录分别加锁了
- 给关系对应的节点加 X 锁,这样做显然会影响事务的并发度
- 给关系对应的节点加 S 锁和 IX 锁,然后再给需要修改的记录对应的节点加 X 锁,这样看起来是一个很好的解决方案,但问题在于,S 锁和 IX 锁是不相容的,我们不能给一个节点既加 S 锁又加 IX 锁
这就是为什么我们需要 SIX 锁,如果我们需要读取节点 p p p 所对应的全部内容,并且修改其中一部分,我们可以给 p p p 加 SIX 锁,之后给需要修改的后代节点加 X 锁
最终,我们构建出了完整的多粒度机制,当一个事务 T T T 想要给树上的某个节点加锁时,需要遵守以下规则:
- 首先 T T T 需要申请根节点上的锁,类型不限
- 只有持有节点 p p p 的父节点上的 IS 锁或 IX 锁时, T T T 才可以申请 p p p 上的 S 锁或 IS 锁
- 只有持有节点 p p p 的父节点上的 SIX 锁或 IX 锁时, T T T 才可以申请 p p p 上的 IX 锁、SIX 锁或 X 锁
- 只有 T T T 不持有节点 p p p 的子节点的锁时, T T T 才可以申请释放 p p p 上的锁
其它协议
基于有效性检查的协议
在基于有效性检查的协议中,每个事务被划分为三个阶段:读阶段、有效性检查阶段和写阶段
在读阶段中,事务在读取数据时没有限制,写入数据时,事务将待写入的值暂时保存在内存中的临时变量中,而不对数据库进行真正的更新
在有效性检查阶段中,数据库系统对事务进行有效性检查,判断如果真的执行该事务的所有写操作,会不会影响调度的可串行性,如果会影响,则该事务中止
在写阶段中,待写入的数据从临时变量写入到数据库中
为了进行有效性检查,对于一个事务 T T T ,数据库系统需要知道三个时间: T T T 开始执行的时间、 T T T 开始有效性检查的时间、 T T T 完成写阶段的时间,因此 T T T 会被打上三个时间戳: s t a r t ( T ) start(T) start(T) 、 v a l i d a t i o n ( T ) validation(T) validation(T) 和 f i n i s h ( T ) finish(T) finish(T)
基于有效性检查的协议的目标是产生的调度冲突等价于一个串行调度,并且这个串行调度的串行顺序为每个事务开始有效性检查的时间顺序,为了实现这个目标,如果 v a l i d a t i o n ( T 1 ) < v a l i d a t i o n ( T 2 ) validation(T_1)<validation(T_2) validation(T1)<validation(T2) ,则以下两个条件至少需要满足一个:
- f i n i s h ( T 1 ) < s t a r t ( T 2 ) finish(T_1)<start(T_2) finish(T1)<start(T2)
- T 1 T_1 T1 所写的数据项集和 T 2 T_2 T2 所读的数据项集没有交集,且 f i n i s h ( T 1 ) < v a l i d a t i o n ( T 2 ) finish(T_1)<validation(T_2) finish(T1)<validation(T2)
满足条件 1 意味着 T 1 T_1 T1 和 T 2 T_2 T2 串行执行,若满足条件 2 ,可以证明产生的调度一定冲突等价于先执行 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 先执行 r e a d ( d ) read(d) read(d) , T 1 T_1 T1 后执行 w r i t e ( d ) write(d) write(d) 的情况,由于 f i n i s h ( T 1 ) < v a l i d a t i o n ( T 2 ) finish(T_1)<validation(T_2) finish(T1)<validation(T2) ,不可能出现 T 2 T_2 T2 先执行 w r i t e ( d ) write(d) write(d) , T 1 T_1 T1 后执行 r e a d ( d ) read(d) read(d) 或 w r i t e ( d ) write(d) write(d) 的情况
事务在有效性检查阶段执行的有效性检查,就是为了检查上述条件是否成立
只读事务越多,基于有效性检查的协议产生的调度并发度越高,并且该协议产生的调度一定是无级联调度,因为不存在未提交读
但是基于有效性检查的协议可能会导致长事务饿死,因为如果时刻存在着和长事务冲突的短事务,长事务总是会更晚地进入有效性检查阶段,然后被回滚,如果数据库系统检测到一个事务频繁被回滚,应该暂时阻塞和这个事务冲突的其它事务
快照隔离
快照隔离是另一种并发控制机制,每个事务在开始执行时,记录数据库当时的状态,相当于获得数据库的一个快照,之后事务读写数据都在快照中进行,当事务提交时,将快照中和当前数据库中不同的部分增量更新到当前的数据库中
有一个很明显的问题,更新数据库的事务在提交时,必须处理与其它并发更新事务之间的潜在冲突,仅当没有其它事务与该事务冲突时,才允许该事务提交,否则该事务回滚
在处理多个事务之间的冲突的方法如下:一个事务提交时,检查它要更新的所有数据项,如果其中存在某个数据项,在该事务的执行过程中被另一事务修改,则提交失败,该事务回滚,否则提交成功,执行该事务对数据库的修改
快照隔离被很多数据库系统用于实现可串行化的隔离级别,但事实上,快照隔离并不保证一定产生可串行化的调度,例如有两个数据项 A A A 和 B B B ,事务 T 1 T_1 T1 和 T 2 T_2 T2 先读取 A A A 和 B B B ,之后 T 1 T_1 T1 更新 A A A , T 2 T_2 T2 更新 B B B ,由于两个事务更新不同的数据项,因此它们都能提交,但产生的调度显然不是一个可串行化的调度
二级一致性
二级一致性用于实现 SQL 标准中已提交读的隔离级别
二级一致性是一种基于锁的协议,和严格两阶段封锁协议不同的是,一个事务可以在任何时候申请锁,并且共享锁可以在任何时候释放,和严格两阶段封锁协议相同的是,排他锁必须在事务提交时释放
插入和删除
之前为了简单,没有考虑插入数据项和删除数据项的情况,现在,我们把插入一个数据项 d d d 记作 i n s e r t ( d ) insert(d) insert(d) ,删除一个数据项 d d d 记作 d e l e t e ( d ) delete(d) delete(d)
如果我们的目标还是产生一个冲突可串行化的调度,则 i n s e r t ( d ) insert(d) insert(d) 在调度中不能和 r e a d ( d ) read(d) read(d) 、 w r i t e ( d ) write(d) write(d) 或 d e l e t e ( d ) delete(d) delete(d) 交换调度顺序,同理, d e l e t e ( d ) delete(d) delete(d) 在调度中不能和 r e a d ( d ) read(d) read(d) 、 w r i t e ( d ) write(d) write(d) 或 i n s e r t ( d ) insert(d) insert(d) 交换调度顺序
插入
在两阶段封锁协议中,如果事务 T T T 执行 i n s e r t ( d ) insert(d) insert(d) ,那么 T T T 在插入数据项 d d d 的同时,获得 d d d 上的排他锁
在时间戳排序协议中,如果事务 T T T 执行 i n s e r t ( d ) insert(d) insert(d) ,那么就把 R − t i m e ( d ) R-time(d) R−time(d) 和 W − t i m e ( d ) W-time(d) W−time(d) 设置为 t i m e ( T ) time(T) time(T)
删除
在两阶段封锁协议中,如果事务 T T T 试图执行 d e l e t e ( d ) delete(d) delete(d) ,那么 T T T 需要先申请 d d d 上的排他锁
在时间戳排序协议中,事务 T T T 试图执行 d e l e t e ( d ) delete(d) delete(d) 时:
- 若 t i m e ( T ) < R − t i m e ( d ) time(T)<R-time(d) time(T)<R−time(d) ,则事务 T T T 执行 d e l e t e ( d ) delete(d) delete(d) 会导致其它某些事务 r e a d ( d ) read(d) read(d) 读取了一个被删除的数据项,因此操作失败,事务 T T T 回滚
- 若 t i m e ( T ) < W − t i m e ( d ) time(T)<W-time(d) time(T)<W−time(d) ,则事务 T T T 执行 d e l e t e ( d ) delete(d) delete(d) 会导致其它某些事务 w r i t e ( d ) write(d) write(d) 修改了一个被删除的数据项,因此操作失败,事务 T T T 回滚
- 其它情况下,可以执行 d e l e t e ( d ) delete(d) delete(d)
B+ 树索引的并发
在执行插入、删除和修改操作时,不光要对关系中的数据项进行操作,还要对索引进行操作,因此支持多个事务在同一个索引中并行地执行操作是很有必要的,本节主要考虑在 B+ 树索引中的并发
蟹行协议
我们可以像对数据项加锁那样对 B+ 树的节点加锁,在 B+ 树索引的插入、删除和查找的基础上额外添加封锁规则,就构成了蟹行协议
当事务 T T T 在索引中查询一个搜索码的时候, T T T 先申请索引根节点上的共享锁,然后执行正常的查询步骤,也就是从根节点开始向下查找,直到找到叶节点,每访问到一个节点之前, T T T 都要申请这个节点上的共享锁,当申请被批准后, T T T 就可以释放该节点的祖先节点上的共享锁
当事务 T T T 在索引中插入或删除一个搜索码时, T T T 先如之前所述,查找到该搜索码对应的叶节点,查找的过程中, T T T 只申请和释放共享锁,查找到对应的叶节点后, T T T 申请将叶节点上的锁从共享锁升级为排他锁,申请被批准之后, T T T 在叶节点上执行插入或删除
当然,插入或删除还可能会导致节点的分裂、节点的合并、节点之间的重新分配,出现以上情况之一时, T T T 还要再申请这些节点的父节点上的排他锁,完成操作后, T T T 释放子节点上的锁,若子节点的合并或分裂引起父节点的合并或分裂,则需要再申请父节点的父节点上的锁,然后继续合并或分裂下去
共享锁的申请是自上而下的,而排他锁的申请是自下而上的,因此在蟹行协议中可能会出现死锁,幸运的是这种冲突很容易检测出来,并且可以通过让申请共享锁的查找操作从树根重启来解决
B-link 树
蟹行协议的并发度还有待提高,因为在查找的过程中,一个事务在获得子节点上的共享锁之前不会释放父节点上的共享锁,在合并、分裂、重新分配的过程中,一个事务在获得父节点上的排他锁之前不会释放子节点上的排他锁,若申请等待了很久才被批准,则会大大影响并发度,此外,这种占有且等待的行为也会导致死锁
我们可以对蟹行协议稍加修改,在查询过程中,一个节点上的共享锁在访问完该节点之后立即释放,即使此时还没有申请到子节点上的共享锁,在合并或分裂的过程中,一个节点的排他锁在修改完该节点之后立即释放,即使此时还没有申请到父节点上的排他锁
当然,这样做会导致一些问题,例如,一个事务 T 1 T_1 T1 要访问节点 p p p 并找到 p p p 中的搜索码 v v v ,则 T 1 T_1 T1 会先释放 p p p 的父节点上的共享锁,然后申请 p p p 上的共享锁,在 T T T 等待申请被批准的过程中,另一个事务 T 2 T_2 T2 正持有 p p p 上的排他锁, T 2 T_2 T2 向 p p p 中插入了一个搜索码,导致 p p p 分裂为 p p p 和 p ′ p' p′ , v v v 被分配到了 p ′ p' p′ 中,这样等 T 1 T_1 T1 获得 p p p 上的共享锁之后,就会发现 p p p 中没有自己想要查找的 v v v
如果遵循蟹行协议,这样的问题就不会出现,事务 T 1 T_1 T1 等待对 p p p 加共享锁时仍保留 p p p 的父节点上的共享锁,由于 T 2 T_2 T2 导致 p p p 分裂,因此 T 2 T_2 T2 应该保留 p p p 上的排他锁并申请 p p p 的父节点上的排他锁,这样会出现死锁, T 1 T_1 T1 的查询操作会从树根上重启
如果既想避免出现这个问题,又为了提高并发度而不想使用蟹行协议,我们可以对 B+ 树的结构稍加修改,原本 B+ 树中只有叶节点会使用一个指针指向同一层中 DFS 序顺序的下一个节点,现在给非叶节点也加上这样的指针,让非叶节点也指向同一层中 DFS 序顺序的下一个节点,这样修改之后的 B+ 树叫作 B-link 树
在 B-link 树中,如果出现之前的问题,就很好处理了,若 T 1 T_1 T1 在 p p p 没有找到自己想要查找的搜索码 v v v ,则可以沿着指针跳到 p ′ p' p′ 中继续找,规范地讲,在 B-link 树中,如果在某个节点中没有找到合适的搜索码,就可以通过指向 DFS 序顺序的下一个节点的指针,跳到下一个节点中继续找
通过使用 B-link 树,我们很好地处理了分裂导致的问题,但如果之前的例子中, T 2 T_2 T2 在 p p p 中不是插入搜索码而是删除搜索码,从而导致 p p p 和另一个节点合并,可能导致 T 1 T_1 T1 申请了一个已被删除的节点上的锁并试图访问这个不存在的节点,为了避免这种问题,通常在 B-link 树中,我们不进行节点的合并,由于数据库中的插入操作通常要比删除操作多,因此两个需要合并的节点可能很快又被插入新的搜索码,即使不进行合并,对索引的性能影响也不大
幻象现象
事实上,我们之前提到的每个协议,都可以通过添加几条额外的规则来支持插入和删除操作,但插入和删除操作带来的更大的问题在于,这两个操作可能会引起幻象现象
幻象现象是指,一个事务 T T T 在执行时,另一个并行事务插入或删除了一些满足某个条件的数据项,导致 T T T 出现各种问题,例如,事务 T 1 T_1 T1 删除了所有满足谓词 p p p 的数据项,然后事务 T 2 T_2 T2 插入了一条满足谓词 p p p 的数据项,在 T 2 T_2 T2 插入之后 T 1 T_1 T1 再查询满足谓词 p p p 的数据项,就会发现有一个数据项莫名其妙地没有被删除,虽然 T 1 T_1 T1 和 T 2 T_2 T2 没有数据项之间的访问冲突,但确实产生了一个不可串行化的调度,这就是一种幻象现象,此外,之前讲过的幻读也是幻象现象的一种
为了避免出现幻象现象,有两种常用的方法:索引封锁和谓词锁
索引封锁
之前讲了 B+ 树索引的并发技术,对蟹行协议或 B-link 树稍加修改,就可以利用它们来避免幻象现象
一个关系能够通过索引封锁来避免幻象现象的前提是,该关系上至少有一个索引(本节中只考虑 B+ 树索引),之后再遵循如下规则:
- 一个事务 T T T 在查询满足某个谓词的元组时,必须通过一个或多个 B+ 树索引进行查找,如果谓词条件所对应的属性上没有索引,则通过遍历一个索引(最好是聚集索引)的叶节点来代替全表扫描
- 一个事务 T T T 在向关系中插入或删除一些元组之前,必须先在所有的索引中插入或删除这些元组对应的搜索码
- 在索引上进行插入、删除和查找的过程中,根据之前讲的索引并发技术对 B+ 树的节点加锁,并且稍加修改,在叶节点上加锁和解锁要遵循两阶段封锁协议
这样就可以避免幻象现象,例如,事务 T 1 T_1 T1 需要先后两次查询满足谓词 p p p 的元组,第一次通过索引 I I I 查找到了所有满足谓词 p p p 的元组对应的搜索码,并给这些搜索码所在的叶节点加上共享锁,在 T 1 T_1 T1 的两次查询之间,事务 T 2 T_2 T2 想要删除满足谓词 p p p 的某个元组,根据两阶段封锁协议,此时 T 1 T_1 T1 没有释放 I I I 的叶节点上的共享锁,因此 T 2 T_2 T2 在试图获得 I I I 的叶节点上的排他锁时,需要等待, T 1 T_1 T1 第二次查询完毕后释放 I I I 的叶节点上的锁,之后 T 2 T_2 T2 可以开始删除
如果事务 T T T 在查询关系 r r r 中满足谓词 p p p 的元组时, p p p 对应的属性上没有索引,根据上面的规则,会导致 r r r 的某个索引上所有叶节点被加锁,之后其它事务对 r r r 的插入和删除都会暂时被阻塞,直到 T T T 将这些锁释放
考虑之前的例子, I I I 的某个叶节点上可能只有一个满足谓词 p p p 的元组对应的搜索码,但 T 1 T_1 T1 依然要给整个叶节点加锁,之后其它事务即使想删除这个叶节点上某个不满足谓词 p p p 的元组对应的搜索码,也需要等待 T 1 T_1 T1 解锁,这说明我们加锁的粒度可能有些粗,如果 T 1 T_1 T1 按两阶段封锁协议保持的锁是加在搜索码上的,之前的问题就不会出现了,我们把给搜索码加锁称作码值封锁
谓词锁
谓词锁从另一个角度避免了幻象现象,在这种机制下,当事务 T T T 查询关系 r r r 中满足谓词 p p p 的元组时,需要申请一个加在谓词 p p p 上的共享锁,在这个共享锁存在期间,其它事务若想向 r r r 中插入或删除元组,需要先检查待插入或待删除的元组是否满足谓词 p p p ,若满足则必须等待 T T T 释放谓词锁,否则可以执行插入或删除操作