cmu15-445笔记十一 并发控制:二阶段锁

这节课主要介绍了悲观并发控制的手段,两阶段锁。还从锁层次上介绍了意向锁,最后是锁实践。

朴素加锁事务执行

在事务T1访问A之前,先通过DBMS的锁管理器(Lock Manager)获取A的锁并且注册(记录下来“A的锁当前归T1所有”),之后事务T2想访问A,于是也要获得A的锁,锁管理器便会拒绝它的请求,T2之后便阻塞在这里,直到T1完成了对A的全部操作后通过锁管理器释放A的锁,T2才可以通过锁管理器获取A的锁,并且完成对A的全部操作后释放A的锁。

在带有Lock的情况下,事务执行的过程如下:

  • 事务获取对应的锁
  • 锁管理器授权或阻塞事务
  • 访问某个数据完毕,事务释放锁

虽然有锁了,但事务还是不一致。上图所示。

两阶段锁

二阶段锁是一个并发控制协议,它规定了一个事务在运行的过程中如何跟其他事务之间协调锁,从而实现可串行化。

  • 增长阶段(Growing)
    在这个阶段事务只能不断地获得锁,不能释放锁

  • 缩小阶段(Shrinking)
    在这个阶段只能释放释放锁,不能再获取新的锁

最终锁释放完毕,事务提交。

使用二阶段锁便可以使得不可串行化的执行调度的最终执行结果具有一致性。如下所示,在两阶段锁协议下,事务T1执行完W(A)后并不会立即释放A的锁,因为二阶段锁协议的规定就是“先一直获取各个锁,然后把所有获取的锁逐个释放”,直到R(A)执行完了之后T1才会释放锁(如果按照之前的策略,先获取X-Lock,再释放X-Lock,然后再获取S-Lock,之后再释放S-Lock,这就违反了两阶段锁的协议)

在使用了二阶段锁协议后,相应的执行调度对应的依赖图(Dependency Graph)一定没有环,二阶段锁可以严格地保证冲突可串行化。

级联回滚问题(脏读问题)

如图所示,事务1进行回滚,但事务2读到了事务1的数据(脏读),此时事务2也不得不跟着回滚。

级联回滚本质上的原因是T2事务在T1事务更新得到的临时版本的数据上进行了操作。事务T1释放锁后处于shrink阶段,虽然shrink阶段不能获得锁,但仍然对未释放锁的对象进行读取,事务本身正在进行。这就造成了读未提交事务的情况。

SS2PL

严格二阶段锁(Strong Strict 2PL,简称SS2PL)。在严格二阶段锁协议下,事务只有在提交时才允许释放排他锁(只有事务完成所有操作后,锁才能释放)。

This requirement ensures that any data written by an uncommitted transaction are locked in exclusive mode until the transaction commits, preventing any other transaction from reading the data.

在ss2pl下,任何未提交事务所做的改变只有在提交的时候才能被看到(这就阻止了脏读的出现)。

严格二阶段锁协议的特点是事务所修改的数据在事务结束之前,其他事务都不能读写。

还有一个变种,rigorous two-phase locking protocol,所有锁(共享锁和排它锁)都能只能在最后提交时释放。

死锁问题

严格二阶段锁协议可能会导致死锁。上图所示BA和AB的加锁顺序。

解决死锁问题

Deadlock Detection,死锁检测

DBMS内部会维护一个锁等待图(waits-for graph),它记录了当前所有并发的事务里谁在等谁的锁,图中每个节点对应一个事务,每条有向边对应一个锁的等待关系(从Ti指向Tj的有向边代表着事务Ti等待Tj释放一个锁),DBMS会周期性地检查这个图,看看图里有没有成环,如果有的话就会想办法把环给解开

如果DBMS检测到锁等待图里出现了环,那就会选择一个victim事务,让它回滚,这样环就会解开,死锁被拆除(这和哲学家吃饭问题很像)。

被选择的victim事务要么会重启要么会中止,这和它是怎么被调用的有关:如果这个事务是DBMS用户的业务的一部分,就可以把它abort,因为用户的业务代码里会有一些应对abort情况的逻辑(比如说转账的事务进行到一半然后被abort,那么就会在前端告诉用户“转账失败,请稍后再试”);如果用户要求DBMS定时地触发一些SQL语句,到了定好的时间,用户的业务代码可能不在执行,因此如果abort了的话用户可能就不知道,这种情况下就需要DBMS去重启事务。并且这个策略里有一些trade-off,因为DBMS是周期性地检查锁等待图,如果周期的频率很高的话,处理死锁的开销就比较大,因此不易检查地太频繁;但频率太低也不好,这有可能导致一些陷入死锁的事务被卡了好久

不光是死锁检测的频率要做trade-off,选哪个事务当victim也要权衡,我们可以综合考虑“这个事务已经执行了多长时间”(让一个已经执行了很长时间的事务回滚,这不太合理),“事务执行了多少”(可以以每个事务都执行了多少条SQL语句这样的指标来衡量),“这个事务已经得到了多少个锁”(DBMS倾向于让得到的锁多的事务回滚,因为得到的锁越多,就有可能让更多的其他事务陷入阻塞,这样的事务回滚了之后其他事务就都能继续往下执行了),以及如下所示的其他因素

1
2
3
4
→ By age (lowest timestamp)
→ By progress (least/most queries executed)
→ By the # of items already locked
→ By the # of txns that we have to rollback with it

回滚事务时也有两个方案:

Approach 1 完全回滚,让victim事务回滚到它开始执行时的状态,就好像它没发生过
Approach 2 最小化地去回滚,去判断到底是哪几个SQL语句造成的死锁,回滚到这些语句还没开始执行的状态即可,没必要完全回滚,并且与此同时让其他事务继续执行

Deadlock Prevention,死锁预防

前面介绍的处理死锁的策略是通过建图来检测是否已经发生了死锁,并且在已经构成死锁后去解开死锁。

Deadlock Prevention这个策略是去预防死锁,不让死锁发生。When a txn tries to acquire a lock that is held by another txn, the DBMS kills one of them to prevent a deadlock.当一个事务想要获取其他事务的锁时,DBMS就会杀死其中一个事务。

比如,根据时间戳给各个事务优先级,规定越先开始的事务它的优先级越高。

  • Wait-Die,高优先级的事务想获取低优先级事务已经拥有了的锁时,那么它将等待低优先级的事务去释放锁;如果低优先级的事务想获取高优先级的事务已经拥有了的锁,那么这个事务直接abort回滚

  • Wound-Wait,高优先级的事务想获取低优先级的事务已经持有的锁时,持有锁的低优先级事务会abort并且释放锁;低优先级的事务想获取高优先级事务已经持有的锁时,它会等待高优先级事务释放这个锁

Wait-die 事务是个绅士,高级事务想要锁时,主动等待低级事务释放锁。低级事务想要锁时,主动谦让自己的锁。

Wound-wait 事务是暴力分子。高级事务想要锁时,主动强迫低级事务释放锁。低级事务想要锁时,也不放弃自己的锁,等待高级事务执行完毕。

这两个方案本质上是不让事务之间互相等待,因为事务之间互相等待就有可能死锁。

此外还要注意,因为预防死锁被abort了的事务重新开始执行时,它的时间戳(即优先级)不会发生变化,不然就有可能一直因为优先级太低被abort,造成饥饿。

锁粒度

到目前为止锁探讨的锁的粒度一般都是DBMS中如tuple这种的对象的锁,如果一个事务想修改很多很多个tuple,那么它就要不停地获取/释放tuple的锁,这会带来很大的开销,导致性能变差。

因此我们不妨加大锁的粒度,当事务想获取锁时,DBMS可以根据实际情况对锁的粒度进行调整(锁的是attribute还是tuple还是数据库文件里的一个页,还是整个表),从而减少事务需要获取的锁的数量。

DBMS中锁的粒度层级如下所示:

意向锁

在众多锁粒度分层的情况下,如果想获取table的锁,需要检查它的全部tuple的锁的情况,只要其中有一个tuple的锁被其他事务持有,那当前事务就暂时不能获取这个table的锁。如果检查到了最后一个tuple才发现有tuple被其他事务锁住,这便十分低效,尤其是表很大tuple、很多的情况下。

Intention locks allow a higher level node to be locked in shared mode or exclusive mode without having to check all descendant nodes.

意向锁(Intention Locks)的存在可以解决上面的问题:通过对table这种更高层级的对象加一些标记来表明它是否含有被锁住的tuple,有了这样的意向标记(它并没有真的锁住table),想获取table的锁的事务就不必逐个检查这个table里的tuple。

  • IS e.g. table含有的tuple中有被上共享锁的
  • IX e.g. table含有的tuple中有被上排他锁的
  • SIX e.g. table含有的tuple中有被上排他锁的,并且整个table也被上了共享锁

意向锁存在情况下加锁原则:Each txn obtains appropriate lock at highest level of the database hierarchy. 每个 txn 在数据库层次结构的最高级别获得适当的锁。

  • To get S or IS lock on a node, the txn must hold at least IS on parent node.

    如果想对tuple加S/IS锁,那必须先对tuple所在的table加IS锁

  • To get X, IX, or SIX on a node, must hold at least IX on parent node.

    如果想对tuple加X/IX/SIX锁,那必须先对其所在的table加IX锁

意向锁存在情况下解锁原则:Transaction Ti can unlock a node Q only if Ti currently has none of the children of Q locked. 事务能够对某个节点进行解锁,前提是那个节点的任何子节点都已经解锁。

Observe that the multiple-granularity protocol requires that locks be acquired in top- down (root-to-leaf) order, whereas locks must be released in bottom-up (leaf-to-root) order.可以观察到,多层次锁存在下,加锁原则是从上到下的,解锁则是从下到上。

The DBMS can automatically switch to coarser- grained locks when a txn acquires too many low- level locks. 当一个事务想要太多低层次的锁时,DBMS可以将之转化为粗粒度的锁。

锁升级

考虑这样一个例子,事务T8需要对a1进行读写,事务T9同样对a1进行读。然而,在二阶段锁协议下,T8一开始需要获得a1的排他锁,这将使得两个事务串行进行。

注意到,如果事务T8能够在一开始获得a1的共享锁,最后执行a1的写操作时,将共享锁转化为排它锁,这样两个事务就能并排进行。

有锁升级upgrade,同样有锁降级downgrade。

Rather, upgrading can take place in only the growing phase, whereas downgrading can take place in only the shrinking phase. 升级只能在扩张阶段进行,降级只能在缩小阶段进行。

锁实践

  • 应用程序通常不会手动获取事务的锁(即显式 SQL 命令)。
  • 有时可以主动加锁以提高并发性(给予DBMS提示)

总结

2PL是数据库常用的加锁方式。

2PL会出现级联回滚问题,因此出现SS2PL,将释放锁的时机推迟到事务提交。

2PL还会有死锁问题,死锁检测在死锁出现之后,构造锁等待图,并选择事务牺牲以接触死锁。死锁预防在出现事务锁争抢时及时杀死事务,有wait-die和wound-wait两种方式。

不同粒度的锁能提高并发。

cmu15-445笔记十一 并发控制:二阶段锁

https://xyz.desirer233.fun/2024/05/14/cmu15445/Lec11/

作者

Desirer

发布于

2024-05-14

更新于

2026-02-21

许可协议