1.事务锁:我们之前讨论的可串行化概念都假设在构建调度时已知所有的读写操作,但我们需要一种实时保证正确性的方法。DBMS使用锁来动态地为事务生成可串行化地执行调度。当存在多个读取者和写入者时,这些锁在并发访问期间保护数据库对象。DBMS包含一个中心化锁管理器,由它绝对顶事务是否可以获取锁。
重要地是,锁与你在B+树锁 蟹算法中使用地锁存器(Latches)不同:
-
Latches:保护 DBMS 的内部数据结构(如 Page 内存、B+ 树节点)免受并发线程的影响。其生命周期很短。
-
Locks:保护数据库中的逻辑内容(值)免受并发事务的影响。
例如:在一个B+树中,进行扫描时你只需要持有单个叶子节点地Latches,因为这足以确保物理结构的正确性,但如果一个事务试图扫描叶子节点,而另一个事务试图同时修改两个任意值,那么扫描事务需要锁定整个表(不仅仅是当前的叶子节点),以避免只看到两次写入中的一次(保证逻辑一致性)。
基本锁类型:锁有两种基本类型:
-
共享锁 (S-LOCK):允许跨事务并发。如果一个事务持有了共享锁,另一个事务也可以获取同一个对象的共享锁,用于读取。
-
互斥锁 (X-LOCK):允许事务修改对象。此锁阻止其他事务在该对象上获取任何锁(无论是 S 锁还是 X 锁)。同一时间只能有一个事务持有互斥锁。
锁管理机制:
-
请求:事务必须向锁管理器请求获取锁或进行锁升级。
-
授权/阻塞:锁管理器根据其他事务当前持有的锁状态来批准或阻塞请求。
-
释放:事务在不再需要对象时必须释放锁,以释放资源。
-
内部表:锁管理器维护一张内部锁表(Lock Table),记录哪些事务持有锁,哪些在排队等待。
持久性说明:DBMS的锁表不需要持久化。因为如果DBMS崩溃,所有当时活跃的事务都会自动中止,锁状态也就随之失效。
然而,光有锁是不够的,锁必须配合并发控制协议使用,才能确保以满足正确性保证的方式使用锁。
二阶段锁:二阶段锁即2PL是一种悲观并发控制协议,它通过锁来实时决定是否允许事务访问数据库中的对象,该协议不需要提前知道事务将要执行的所有查询内容。
阶段1:增长阶段。在增长阶段,每个事务向 DBMS 的锁管理器请求它所需的锁。锁管理器根据当前情况批准或拒绝这些锁请求。
阶段2:缩减阶段。事务在释放第一个锁后立即进入缩减阶段。在缩减阶段,事务只被允许释放锁,不允许获取新锁。一旦开始释放锁,就再也不能回头去拿新锁。
如果单单使用之前的共享锁和互斥锁的话,那么只能保证单个数据对象在一瞬间不被破坏,但无法保护多个数据对象之间的全局业务一致性。如果不遵守二阶段锁的规则,系统就会产生我们在上一节课中遇到的环(死锁或数据错乱)。
对于2PL的增长阶段,你会发现每一个事务都会经历一个重要时刻:此时它有它需要的所有锁,并且一个都没有释放。这个时刻在数据库理论中被称为锁点。
2PL的精妙之处在于,事务之间的冲突,实际上是按照它们到达锁点的先后顺序来排队的。
2PL的特性:
-
正确性:2PL 足以保证冲突可串行化。它生成的调度所对应的优先图是无环的。
-
缺陷:
-
级联中止:当一个事务中止时,另一个读取了其数据的事务也必须跟着回滚,这导致了计算资源的浪费。
-
脏读 (Dirty Reads):基础 2PL 仍可能出现脏读。
-
死锁 (Deadlocks):2PL 可能导致死锁。
-
并发限制:存在一些可串行化的调度但 2PL 并不允许执行(锁定限制了并发度)。
-
强严格二阶段锁:如果一个事务写入的任何值在其事务提交之前,都不被其他事务读取或覆盖,则称该调度是严格的。
强严格2PL是2PL的一个变体,要求事务只有在提交时才释放所有锁。
-
优点:DBMS 不会产生级联中止。此外,通过恢复被修改元组的原始值,DBMS 可以轻松撤销已中止事务的更改。
-
缺点:生成的调度更加谨慎/悲观,进一步限制了并发性能。
各调度集合的包含关系如下:串行调度 ⊂ 强严格 2PL ⊂ 冲突可串行化调度 ⊂ 视图可串行化调度 ⊂ 所有调度。
死锁处理:死锁是指多个事务互相等待对方释放锁,从而形成的一个闭环。在二阶段锁(2PL)中,处理死锁有两种主要策略:检测和预防。
方法一:死锁检测。为了检测死锁,DBMS会在后台创建一个等待图。
-
节点:代表事务。
-
边:如果事务
Ti正在等待事务Tj释放某个锁,就会画一条从Ti指向Tj的有向边。
系统会定期(通常通过一个后台线程)扫描这个等待图寻找环。如果发现环,系统就会介入打破它。
-
无锁遍历:构建等待图时甚至不需要加锁存器保护数据结构。因为如果 DBMS 在某次扫描中碰巧漏掉了一个死锁,它总会在下一次扫描中把它抓出来。
-
核心权衡:死锁检查的频率(频繁检查会浪费 CPU 周期)与打破死锁前的等待延迟之间,需要做一个平衡。
一旦发现死锁,DBMS必须选中一个事务作为牺牲者并将其强制中止,从而打破闭环。根据应用程序的调用方式,牺牲者可能会自动重启或彻底失败。
DBMS 会综合考虑以下属性来挑选牺牲者:
-
按年龄:最新启动的,或者最老/运行最久的事务。
-
按进度:执行查询最少,或者最多的事务。
-
按锁定数量:已经持有了多少个锁。
-
按回滚代价:中止它会导致多少其他事务也需要跟着回滚。
-
按历史重启次数:为了防止某个事务永远被当成替罪羊(避免"饥饿"现象)。
这几种因素没有绝对的优劣,许多商业系统会组合使用它们。选中牺牲者后,DBMS 还可以决定是把这个事务彻底回滚,还是仅仅局部回滚几个查询以解开死锁即可。
方法二:死锁预防
与其让事务随心所欲地申请锁然后再去收拾死锁的烂摊子,死锁预防策略选择在死锁发生之前就把危险掐灭。
当一个事务尝试获取另一个事务持有的锁,DBMS会尝试直接杀掉其中一个。为了实现这一点,系统会给所有事务分配优先级(通常基于时间戳,越老的事务优先级越高)。这种机制保证了绝对不会出现死锁,因为在等待图中,箭头只能单向流动。当事务被杀并重启时,DBMS会保留它最初的时间戳。
锁的粒度:如果一个事务想要更新十亿个元组,它就必须向 DBMS 的锁管理器申请十亿把锁。这将非常缓慢,因为事务在获取/释放锁时,必须在锁管理器的内部锁表数据结构中获取锁存器。
反之,如果一个事务只为了读取一个值而锁定了整张表,那么并行执行的机会就会减少。为了处理这种权衡,DBMS 使用锁层次结构来同时处理不同粒度级别的锁。例如,它可以在拥有一十亿元组的表上获取单个锁,而不是十亿个单独的锁。
当事务在层次结构中获取某个对象的锁时,它隐式地获取了该对象所有子对象的锁(例如锁了表,就等于锁了表里的所有行)。因此,那个表级写锁就不需要再去抓取任何元组锁了。然而,如果表上没有持有锁,系统则允许在不同的元组上持有多个元组级锁,从而允许并行执行。
数据库锁层次结构:
-
数据库级 (Database level) - (较少见)
-
表级 (Table level) - (非常常见,BusTub 中会用到)
-
页级 (Page level) - (常见)
-
元组级 (Tuple level) - (非常常见,BusTub 中会用到)
-
属性级 (Attribute level) - (罕见)
意向锁:重要的一点是,如果一个事务正在使用元组级锁,他需要告知系统,其他事务不能获取页级锁,因为这回产生冲突。为了实现这一点,意向锁被引入作为一种隐式锁,用于发出信号,表明在更低级别上持有显示锁。
-
意向共享锁 (IS, Intention-Shared): 表示意图在更低级别上获取共享锁 (S)。
- 例子: 你想读表里的某一行,你得先给表加个 IS 锁。
-
意向互斥锁 (IX, Intention-Exclusive): 表示意图在更低级别上获取互斥锁 (X) 或共享锁。
- 例子: 你想改表里的某一行,你得先给表加个 IX 锁。
-
共享+意向互斥锁 (SIX, Shared+Intention-Exclusive): 表示以该节点为根的子树被显式锁定在共享模式 (S) 下(即我看这整张表),同时我将在更低级别上进行互斥模式 (X) 的显式锁定(即我要改其中某几行)。
- 例子: 事务要扫描整张表(需要 S 权限),同时要更新其中几个特定的行(需要 X 权限)。这比持有 S 锁再持有 IX 锁更高效,合二为一变成 SIX。