持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第24天,点击查看活动详情
二阶段锁
上次我们讲到事务的可串行化,事务之间会存在一些冲突的读写操作导致它们无法串行化或者无法等价串行化。更进一步说,如果事务已经执行完了,那么这时候我们才知道它是否可串行化是没有意义的,因此我们需要一种能够在事务执行的过程中就能够防止冲突出现的机制。
这一节介绍第一种思路,利用锁来保护数据库对象。
锁类型
首先我们回顾一下在数据库中Lock和Latch的区别
我们描述一般意义上的锁是针对事务的概念,而不是指在操作系统中会讲到的读写锁。基本的,我们能够将针对事务的最基本的锁分为两种,共享锁和排他锁,它们的兼容性如下图所示
它们的逻辑和读写锁很像,事实上,它们可以当作读写锁来用。但是这样的话,它们只能保护单个数据,无法保证事务之间是可串行化的。为此,我们要引入一个机制:两阶段锁
在DBMS中,所有事务的锁由一个总的锁管理器来管理,这是所有锁机制的前提。
两阶段锁
两阶段锁定(2PL)是一种并发控制协议,用于确定事务是否可以动态访问数据库中的对象,该协议不需要知道事务将执行的所有SQL语句,也能够实现事务的可串行化。
它的基本机制就是:事务在执行的过程中,锁的获取和释放分为两个阶段:
- Growing
事务从锁管理器那里请求获取锁,并且一直获取而不释放
- Shrinking
事务向锁管理器释放锁,并且一直释放而不获取
如下图所示,锁一直获取后在一个时间点后一直释放锁
为什么这样能够解决串行化问题呢?因为这样保证了冲突操作一定会隔离开执行,而不是连在一起导致冲突,如下图所示,T2事务获取排他锁的时间被推后到了T1的释放阶段,这样将不可串行化的操作化为了可串行化。
但是这样还有一个问题,假如T1事务在释放阶段发现出现问题,需要ABORT该次事务,而此时T2事务已经读取了T1事务修改后的值,也就是说T2事务也要ABORT,而此时T3事务已经。。可以发现,这种级联撤销问题是当前的两阶段锁无法解决的。
基于此,提出Strong Two-Phase Locking即强两阶段锁,唯一的更改就是将事务释放锁的阶段全部集中到commit之后,如下图所示
可以想到这样会牺牲一部分性能,但是很好的解决了级联撤销的问题。
如果用韦恩图来描述事务之间执行顺序的可能情况的话,我们可以发现强两阶段锁机制正好将解覆盖到了合理的范围。
死锁检测和预防
两阶段锁机制很容易出现事务之间发生死锁的情况,例如:当T1’在扩张阶段,获取了Y的读锁,并读取了Y,此时想要去获取X的写锁,却发现T2’的读锁锁定了X,而T2’也想要获取Y的写锁。简而言之,T1’不得到X是不会释放Y的,T2’不得到Y也是不会释放X的,这便陷入了循环,便形成了死锁。处理两阶段锁中的死锁有两种方法:检测和预防。
死锁检测
等待图是一种分析死锁的有效工具。它是有向图,其中节点表示事务,带有箭头的有向边表示“等待”关系,箭头的方向就是等待的方向。下图是经典的三个事务形成循环死锁。
检测死锁的方式就是定期检查等待图,看是否有闭环出现,如果检查到死锁,DBMS则会挑选一个事务进行重启或者撤销,以打破死锁循环。其中撤销操作更为常见,因为处理更为简单,但是实际上执行哪种处理主要看事务的类型:
- 如果是用户产生的事务,比如购买了什么东西,因为并发的问题被终止了,数据库就会自动的将其重启,或者反馈给用户信息提示重新操作
- 如果是定时事务如数据库日常备份,则不会撤销而是重启,因为此时没有主体会去重启这个事务。
选择死锁循环中要处理的事务也是一个问题,一般来讲,会根据多个方面来选择:
- 事务执行时间
- 事务已经执行的SQL语句数量
- 事务已经加了多少锁
- 多少事务因为它被回滚过
- 事务已经回滚了多少次
回滚方式也有完全回滚和部分回滚的区分。
死锁预防
死锁预防是在死锁发生之前阻止事务造成死锁,当一个事务试图获取另一个事务持有的锁(这可能导致死锁)时,DBMS会撤销其中一个事务。为了实现这一点,根据时间戳为事务分配优先级(较旧的事务具有较高的优先级)。实现预防有两种方案:
-
老事务等新事务
- 如果老事务要获取的锁被新事务持有,老事务等待新事务
- 如果新事务要获取的锁被老事务持有,新事务自杀
-
老事务杀新事务
- 如果老事务要获取的锁被新事务持有,老事务杀死新事务
- 如果新事务要获取的锁被老事务持有,新事务等待老事务
这样两个事务永远不会相互等待,不会形成死锁。另外为了防止饥饿问题,被杀的事务重新执行的时候时间戳要给最开始的时间
分层锁
这一部分我们重点讨论锁的粒度和意向锁
锁的粒度
当一个事务想要更新一个表中的十亿个元组,如果它遵循行锁作为锁的粒度的话,它必须向DBMS的锁管理器请求十亿个锁。这很慢,为了避免这种开销,DBMS 可以使用锁层次结构,该层次结构允许事务在系统中采用更粗粒度的锁。例如,它可以在表上获取具有 10 亿个元组的单个锁,而不是 10 亿个单独的锁。当事务获取此层次结构中对象的锁时,它将隐式获取其所有子对象的锁。如下图所示,T1事务能够对表1上一个大锁。
但这样另一个问题产生了,如果一个事务只想更改部分数据,则不会对整个表上锁,此时另一个事务想要遍历整个表,那它需要提前遍历整个表看看哪里已经有锁了吗?还是不管那么多直接对整个表上锁?
都不行,为了解决这个问题,提出了意向锁,意向锁允许锁定更高级别的节点,而无需检查所有后代节点。具体的说,有三种意向锁:
- 意向共享锁
- 意向排他锁
- 意向共享排他锁(用于边读边写)
它们之间的关系如下图所示:
下图是一个所有锁的实例:
分层锁在工程上非常好用,而二阶段锁几乎在所有的开源数据库中都有实现。
\