青菜年糕汤

一周一篇,一期一会。以文会友,以友辅仁。

2020年8月23日

更新丢失、写偏、幻读:数据库事务从快照隔离到可序列化(三)

作者:青菜年糕汤

更新丢失、写偏、幻读:数据库事务从快照隔离到可序列化(,三,番外

第二篇讲了四个例子,都是可序列化(serializable)行而快照隔离(snapshot isolation)不行的情况。我们可以从中说三件事。

一、它们都不是上篇讲的“更新丢失”(lost update)。

有些例子可能很像,但都不是。

更新丢失的特点是两个事务都读并写了同一个记录。对单个事务来说,需要依次发生“读-修改-写”某个变量的操作。

例1中事务甲写了前两个棋子的记录,事务乙写了后两个棋子的记录,没有写同一个。

例2中两个事务分别写了两个医生的记录,也不是同一个。

例3、4中每个事务根本没有读到任何记录。

二、它们都发生了“写偏”(write skew)。

我们发现这四个例子的操作有个共同点:它都先对数据库做了查询,根据查询的结果决定之后怎么写。

例1中查询到了值为黑(白)的棋子,决定了需要改写哪些棋子。

例2中查询了正在值班的所有医生的人数,决定了是否改写想下班的医生的值班状态。

例3中查询了是否存在该用户名,决定了是否插入用户名。

例4中查询了是否存在相近的入学时间,决定了是否插入入学时间。

让我们重复一下快照隔离的效果是:

一个事务读到的数据都来自于数据库某同一个时刻(时刻甲)的状态,然后所有写都发生在之后的某同一个时刻(时刻乙)。

这里的矛盾的矛盾就清楚了:

时刻甲时数据有个状态,等到了时刻乙,数据的状态可能不一样了。根据时刻甲的状态作出写的决定,这个决定到时刻乙真正写时,就未必适用了。

有点刻舟求剑的味道。

(可以注意到,要形成写偏,一个事务的写操作未必需要能够改变这个事务自己前面的读的信息,而是只要其它的事务改变了这个事务读的信息就行。这道理不难想,但好像不太容易构造出一个足够漂亮的例子。)

三、例3、4中发生了“幻读”(phantom)。

对于上面提到的写偏,如果不考虑效率,只考虑正确性,可以想到一种很直观的解决办法:

记录下这个事务读过哪些数据,等提交时,检查这些数据没有在这个事务期间被别人改写过。如果有就中断,如果没有就成功。

还有一种办法更悲观一些:

锁住读过的数据,不让其它事务写这些记录。

这两种办法都能解决例1、2的情况。但例3、4却可以说明这两种方法是不足以把快照隔离变成可序列化的。

我们可以先对比一下例2和例3。

在例2中,这个事务读过哪些数据?所有医生的值班状态(在没有二级索引的情况下,为了数有多少医生正在值班,这个事务会需要遍历所有医生的状态)。只要把所有医生的数据都加入检查(或锁上),就可以阻止同时的改动。

而在例3中,这个事务读过哪些数据?没有。因而检查(或锁上)读的数据,完全无助于例3。

有人会注意到,例3中虽然没读到任何数据,但尝试查询了这个用户名的记录。可以稍微修改一下解决办法:不管有没有读到数据,只要尝试读了这个数据,就要把它加入检查(或锁上)中。

那么对于例4该怎么办呢?

例3中我们的查找是离散的,只尝试读取了一个数据点。而例4却是想查找一个范围,它需要把这个范围内的所有数据点都加入检查(或锁上)中,即使它们无穷无尽,即使它们尚未出现。

解决方法可以是把整个范围都加入到检查(或锁上)。

这在具体的实现上,未必会像听上去的那么简单。有的数据库设计得很难对任意范围上锁,就会预先划分好每块范围,对覆盖这个范围的几块范围都上锁。这么做会稍微多锁一些,但不会有正确性的问题。

这样,幻读的问题就解决了。

当解决更新丢失、写偏、幻读这三个问题,快照隔离的数据库就能被改造成一个可序列化的数据库。

这里的数据库可以是传统的关系型数据库,也可以是键值数据库、文档数据库等等。在这个问题上,它们都是一样的,本质上都是键、值的读写。事实上,一个好的键值数据库(如FoundationDB、Spanner),完全可以作为其他类型的数据库的底层,关于这个话题以后也可以讨论讨论。

当然,不是谁都每天有闲心去这么改装的。对于数据库的使用者,如果换个角度看,可能会得到另一结论:

只要能够通过其它途径解决更新丢失、写偏、幻读的问题,或者证明它们不会对业务造成影响,我们就可以用快照隔离这个更松更高效的隔离级别,来达到可序列化级别的正确性。

我之后会另发一篇文章,讲一个最近工作中遇到的问题,为上面这段话做注解。

到这里,我们这个从快照隔离到可序列化的旅程可以算告一段落了。

欢迎留言交流。