Transaction Serializable隔离之两阶段锁

我们在前面的《Transaction Serializable隔离之串行执行》中介绍了Serializable隔离的第一个实现方法:串行执行。本文来介绍第二个实现方法:两阶段锁(Two-Phase Locking)。这个方法也是一个由古到今一直流行的方法,需要注意的是他和我们通常说的两阶段提交(Two-Phase Commit)是完全不同的两个概念,我们会在后面的文章中再详细介绍两阶段提交的概念。

我们都知道锁的作用:就是有transaction想同时写一个object,这个锁可以保证两个写中的一个必须等待另外一个完成。两阶段锁也是类似的,只是说它更加强一点。假如一个object没有人在写,那么多个读可以同时进行。但是只要有人想要写这个object,那么就有下面这样的限制:

  • 假如Transaction A已经读了这个object,Transaction B想要写这个object,那么它就需要等待A提交或者abort。
  • 假如Transaction A已经写了这个object,Transaction B想要读这个Object,那么B必须等待A提交或者Abort。

从上面我们可以看出来,在两阶段锁中,写不仅仅会block别的写,它还会同时block别的读。反过来也是这样。

两阶段锁的实现

目前MySQL和SQL Server都在使用两阶段锁来实现Serializable隔离,DB2使用它来做Repeatable的读隔离。

两阶段锁的具体实现是在数据库中的每一个object上使用一个锁,这个锁有两种模式Shared Mode或者exclusive mode,具体的规则如下所示:

  • 假如一个transaction想要读一个object,它必须首先获得这个锁的shared mode。可以有多个transaction同时以shared mode获得锁,但是假如别的transaction以exclusive 模式获得这个锁,那么所有别的transaction必须等待。
  • 假如一个transaction想要写一个object,它必须获得锁的exclusive mode。这种模式下别的任何transaction都不能以任何形式获得这个锁,假如这个锁以任何形式被获取,都需要等待。
  • 假如一个transaction先读了一个object,再写一个object,就可能需要升级锁,从shared lock升级到exclusive lock。这个升级的过程和单独获取是一样的。
  • Transaction获得锁之后,需要一直持有,直到transaction提交或者abort了。这也是我们为什么称之为两阶段锁,第一个阶段就是获取锁,第二阶段是释放所有的锁。

从这个实现我们看到,这里有很多锁的操作,也就很容易发生死锁的状况,就是Transaction A在等待Transaction B释放某一个锁,而Transaction B在等待Transaction A释放另外一个锁。对于死锁的状况,数据库会自动检测到,并做相应的处理。

两阶段锁的性能

两阶段锁最大的问题就是性能问题,使用了两阶段锁之后query的性能和响应速度会显著变慢。这里的性能问题一部分原因是因为获取锁和释放锁的cost,另外一个原因是任何可能有冲突的transaction都被串行执行了。

还有一个原因就是我们上面提到的死锁问题,虽然他是死锁可以通过重试来解决,但是重试就意味着之前做的工作都废了,性能显然也会受到影响。

Predicate锁

我们在之前《Transaction弱隔离之Write Skew和Phantoms》中介绍过Phantoms – 就是一个transaction修改了另外一个transaction的查询结果。既然Serializable隔离号称可以解决所有问题,那么毋庸置疑,这个问题也是需要解决的。

还记得我们之前提到的会议室预定系统吗?假如一个transaction已经查询了某一个时间段的某一个会议室是否可以使用,那么另外一个transaction就不允许同时修改这个时间段的这个会议室。如何来解决这个问题呢?这里我们使用到predicate锁,和我们上面提到的shared/exclusive锁类似,只是说这个锁不是在每一个object这个level,而是在所有查询出来的数据行的level上,如下所示:

Predicate锁会按照下面的规则来进行限制:

  • 假如Transaction A想要读取一系列符合某个查询要求的objects,那么它必须首先获得一个shared mode的predicate锁。假如这个时候另外一个transaction B正好在这些objects上有一个exclusive锁,那么A必须等待B释放了这个锁才能继续查询。
  • 假如transaction A想要插入,更新或者删除一个object,那么它必须首先检查这个新的或者旧的值是否符合某一个已经存在的predicate锁,假如有这样一个match的锁被B获取,那么A必须等待B提交或者abort之后才能继续。

总的思想就是这个predicate锁需要能够应用到还没有存在的object上,但是又可能是未来加入(如上面规则中的修改后的值)。假如我们把两阶段锁和predicate锁结合起来,那么就满足了write skew和其他冲突情况的保护,也就是我们想要的serializable。

索引区间锁(Index-Range Lock)

虽然上面提到的Predicate锁可以达到我们想要的效果,但是它有一个大的问题,就是检查匹配锁的时候非常耗时间。所以很多使用两阶段锁的数据库他们真实实现并没有使用Predicate锁,而是使用了另外一个简化版实现:索引区间锁。

还是上面提到的订会议室的例子,假如你想进行会议室123在时间1:00到2:00的匹配锁,你可以使用另外一种方法,就是lock所有的会议室在1:00到2:00的匹配或者lock会议室123在所有时间上的匹配,这样一来就可以达到同样的效果,只是相对来说限制的更多了一点。这样做的好处就是假如你有一个索引在会议室id上或者在start_time和end_time上,那么你的查询就会变得很快:

  • 假如你的index是在会议室id上,然后数据库查询所有的会议室123上的预定情况,就可以简单的把一个shared lock加到index的entry上,用来表示这个transaction已经搜索了会议室123的预定。
  • 另外一种实现就是index是在开始和结束时间上,你可以把shared lock加入到index的这个值范围内,用来表示transaction已经查询了这段时间范围内的会议室。

总结

本文介绍了Serializable隔离的一个最常见的实现就是两阶段锁,希望大家在阅读之后能够有所收获。

You may also like...

1 Response

  1. June 16, 2021

    […] 我们在《Transaction Serializable隔离之两阶段锁》中提到的两阶段锁其实就是一个悲观的同步控制策略:任何有可能出现冲突的数据,我们都加锁(不管是不是真的会有冲突),就有点类似多线程的编程。而《Transaction Serializable隔离之串行执行》中提到的串行执行,更是把悲观做到了极致,直接变成串行的单线程编程了。 […]

Leave a Reply

Your email address will not be published.