青菜年糕汤

一箪一瓢,一期一会。以文会友,以友辅仁。

2020年8月24日

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

作者:青菜年糕汤

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

正文到第三篇就结束了,在它结尾时我提到:

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

这是开源项目FoundationDB Record Layer的一部分,所以无妨公开讨论。

与大多数传统数据库一样,这个数据库系统有这样到的机制:当二级索引(secondary index,下文都用“索引”指代“二级索引”)建成、开始正常运行之后,每次增、删、改数据,都会同时增、删、改数据对应的索引。

但我们在这个故事里,关注的是最初建成这个索引的过程。

如果在还没有数据的时候就定义了索引,那数据库天生就有正确的空白的索引。但如果当数据库已经有不少数据时,再定义新的索引,就需要经历构建索引这个过程,为现有的数据创建索引。

这个过程,就是会有个后台进程,扫描所有有关的数据,把数据按照新索引的定义写入索引。

这个过程中,要保证在读数据到写索引之间数据没有发生改变,最好用可序列化(serializable)隔离级别。

但如果数据很多,这个过程不可避免地要花很长时间。在这段时间中,不可避免地有新的增、删、改发生。数据发生改变,这个后台的索引事务就会不可避免地发生冲突,从而失败。

解决方法是把数据拆分成小段,每个事务各自负责一段。因为每段中的数据足够少,发生改变的概率就小,即使偶尔失败了也能很快重试成功。

上面讲的都是故事背景,下面开始讲最近出现的问题:

在一个流量很大的数据库中,无时不刻都有好多新数据被插入进来,而且这些数据都插入到差不多的位置。

为什么会都插入到差不多的位置呢?可以想像,假如说数据的主键(primary key)是一个自增的数列,或者是创建时的时间戳,那么新数据永远都在最后那段。

当要为那一段数据构建索引时,即使原来一条数据都没有,也会在构建的过程中不断有新数据插入。那构建索引的事务就会一直与新插入的数据起冲突,然后不得无休止地失败、重试。

一种解决方案是把这个范围再变得更小,只要小到极点,最终总是能解决的。但这要耗费大量的事务,实现起来也挺麻烦。

而另一种方案就是,尝试使用快照隔离(snapshot isolation),避免冲突。

让我们来分析一下,快照隔离可行吗?

首先,不会有更新丢失(lost update)的问题。因为每个事务都读的是原数据、写的是索引,对任何变量都不会有“读-修改-写”这样的操作。

但写偏(write skew)的问题是有的。

在事务读一些数据,到写入这些数据的索引的过程中,数据库仍在活跃运行,是可能会有用户增、删、改这个范围内的数据的。

我们不妨把插入与删改两种情况分开讨论。

如果有用户在我们读入数据之后、写入索引之前插入了新数据,构建索引的事务不会看到新数据,便不会把它写入索引。但没关系,用户插入新数据的那个事务自己会同时插入索引。因此这种情况不会对业务产生影响。

但如果有用户在我们读入数据之后、写入索引之前删除或修改了数据,构建索引的事务没看到它被删改,还是把原先的版本写入了索引。要记得,用户删改数据的那个事务本身已经正确地删改了索引,但现在又用原先的版本覆盖了它。这种情况是会产生错误的。

为了避免这个问题,我们只要把构建索引的过程中读到的所有数据都加入冲突检查(或上锁),就可以解决正确性的问题。

从性能上说,考虑到我们要解决的主要问题是频繁的插入,而删改并不那么频繁,因此把读到的数据加入冲突检查并不会真的造成太大的冲突。

综上,我们可以稍加处理,避免写偏的问题。

既然写偏不成问题,就更不用担心因幻读(phantom)而造成的写偏。

问题解决了。

其实从另一个角度想,这个做法是把我们前面讨论的幻读反其道而行之:

在这个例子中,正是因为我们不用在意幻读造成的问题,所以可以避免(使用可序列化)把整个读的范围都加入冲突检查,而只要(在快照隔离的基础上)把实际读取的一条条记录加入冲突检查,就可以保证正确性。

对尽可能少的数据进行冲突检查,就能尽可能减少冲突,带来更好的并发度。

你在工作中有没有也碰到过有关事务隔离性的挑战呢?欢迎留言交流。


题外话。

这里我们全程都有个假设:同一个数据被重复多次创建索引,结果还是一样的,不妨碍正确性。

这种性质被称为幂等(idempotent)。对于普通的二级索引,这个是性质是能满足的,所以我们能放心地一边让数据库接受新的增、删、改,一边分段构建索引。

但事实上,在Foundation DB Record Layer中的“索引”会比这更广义,以至于一些索引未必是幂等的。

比如说,你可以定义一个索引用于统计某个字段不同取值出现的次数。每插入一条记录,就会对相应取值的计数器加一。对于这样的索引,重复创建索引就不可接受了。

对于不幂等的索引应该如何构建,并不属于这个系列的主题,就留给大家探索吧。