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

用户头像
青菜年糕汤
关注
发布于: 2020 年 08 月 30 日
更新丢失、写偏、幻读:数据库事务从快照隔离到可序列化

这个系列文章一共有三篇正文和一篇番外,为了方便查找和阅读,我将其合在一起在InfoQ重新发布。本文用丰富的例子,事无巨细地讲解了快照隔离和可序列化这两种事务模型的区别,辨析了更新丢失、写偏、幻读等概念,并分析了一个现实应用案例。



它的目标受众是:本来就了解数据库系统中的事务是什么意思,也大致听说过(但未必分得清)五花八门的事务隔离级别。

这个系列我们将讨论两种事务隔离级别之间的区别:快照隔离(snapshot isolation)和可序列化(serializable)。



在数据系统领域,有不少挺让人费解的概念。



即使作为数据库系统的开发者 ,也不会每天都碰到这些概念;当偶尔突然碰到了,也被这些问题迷糊一下。更不要提更广大的数据库使用者、后端开发者了。



这个混乱很大程度上源自于不同系统在实现时的自由度。出于不同现实需求,不同的系统有不同的侧重点,就会有不同的实现方式,最后所谓的标准就与五花八门的事实标准不一致了。



比如可重复读(repeatable read)这个概念就是一例。它与快照隔离相近又不完全一样,来龙去脉可以单独写篇文章讨论,但在这里我们为简便起见,只采用快照隔离这一说法。



说回到数据库的事务隔离级别。关于这个话题,有很多详略各异的综述。



比如维基百科的“事务隔离”词条可以用于初次了解,或是在忘了之后激活记忆。



再比如我近来很推荐的书《数据密集型应用系统设计》(Designing Data-Intensive Applications)的第七章“事务”,既能理解理论定义,也可以了解几个常用的数据库系统的现实情况。



珠玉在前,我的文章无意于完整地构建整个体系,只是想从快照隔离与可序列化的差别这一角度入手,略窥一斑。



如果要用一句话解释这两个隔离级别提供的保证,我会这么说:

  • 快照隔离的一个事务读到的数据都来自于数据库某同一个时刻的状态(“快照”得名于此),然后所有写都发生在之后的某同一个时刻。

  • 可序列化的每个事务都是完全独立的,一个事务完成后才会做下一个。



注意这里说的是满足该隔离性后事务运行的效果,是帮助数据库使用者想清楚概念的思维模型。



为了保证优秀的并发度和速度,事实上实现它们要比这里说的复杂。但只要实现得正确,它们的运行结果一定是在这个简化模型下可能发生的。因此使用者不妨以为事实就跟简化模型一样。



比较两种隔离级别,可以看到可序列化的隔离型更强,用户理解起来、用起来也更简单。



快照隔离(以及其它更弱的隔离级别)之所以存在,之所以还在折磨着数据库开发者和使用者,是因为它们在性能上的优势。



我们在这里完全只比较两者概念模型的区别,尽量不涉及具体实现和性能。



那么快照隔离究竟比可序列化弱在哪里呢?



假设你在数据库里有个值X开始是0。现在事务甲给X加10,事务乙要给X加20。



在可序列化隔离性下,不管甲乙哪个先发生、哪个后发生,最后X都会是30。(比如甲读到X是0,把它改成10写入,然后乙读到X是10,把它改成30写入。)



而在快照隔离下,有可能两个事务读X时都读到的是它开始时的值0,然后甲试图把它改成10写入,乙试图把它改成20写入。不管最后是谁覆盖了谁,结果都不是正确的30。



这种现象就被称为“更新丢失”(update lost)。



快照隔离已经是很高的隔离性了,但按照上面的定义,它依旧会产生更新丢失这样显而易见的问题。



事实上,这个问题不需要太大的代价就能解决。所以很多快照隔离的数据库事实上都会检测、避免更新丢失的情况。以至于一般当人们讲“快照隔离”时都是默认避免了更新丢失的情况的。



那除此之外,快照隔离与可序列化还有差距吗?当然,那就是写偏(write skew)和幻读(phantom)。

为了讲清楚,我们不妨从一系列的例子入手。请移步第二篇



我发现,很多时候,人类学习(其实机器学习更是如此)新概念的难度来自于作为参考的案例不多。



为了讲清楚快照隔离(snapshot ioslation)相对于可序列化(serializable)可能产生的问题,我想多举几个例子,再针对它们的相同和不同之处进行讨论。所以这一篇全都是例子,等下一篇再分析。



例1



出自卡内基梅隆大学高级数据库系统(15-721 Advanced Database Systems)课程对写偏(write skew)的解释



有四个棋子分别为黑、黑、白、白。



事务甲:把所有白色棋子变成黑色。

事务乙:把所有黑色棋子变成白色。



每个事务要做的事情都是:第一步,查找所有白(黑)色棋子;第二步,把找到的棋子改成黑(白)色。



如果是可序列化隔离级别,可以假装两个事务先后发生,最后结果要不是全黑,要不是全白。



但在快照隔离下,如果两个事务都是在对方做第二步之前就做了自己的第一步,事务甲会把那两个原先黑的改成白的,事务乙把那两个原先白的改成黑的,最后变成了白、白、黑、黑。这是在两个事务先后时不可能出现的情况。

例2



出自《数据密集型应用系统设计》。



值班系统记录了每个医生分别是否在值班。为了保证至少有一位医生在值班,当一个医生要下班时,会运行这样一个事务:



查找有多少医生正在值班,如果数量大于1,可以把这个医生改为下班,否则不行。



假如现在有两个医生正在值班,她们近乎同时想要下班。



快照隔离下,两个医生分别使用一个事务,两个事务有可能同时查找有多少医生正在值班,等它们都得到了结果(结果都是2)之后,才分别改为下班。这样,值班的医生就一个都没有了。



而在可序列化下两个事务先后进行就不会出现这样的问题。



例3



也出自《数据密集型应用系统设计》。



一个网站的用户管理系统,规定每个用户都要用独一无二的用户名。



假设这个表的主键(primary key)就是用户名。当有一个用户要创建账号时,会有个事务先查找是否有这个用户名的记录,如果没有,就可以创建。



跟例2一样,在快照隔离下,可能两个同时发生的事务想创建相同的用户名,它们查找时都发现这个账号不存在,便重复为这个用户名创建了账号。后果可能是一个账号的信息覆盖了另一个。



例4



我在例3的基础上稍加创作。



因为疫情,霍格沃茨今年将入学的方式改成了使用门钥匙。但为了有足够的时间做核酸检测和隔离,要求每个学生入学时间至少相差60秒。学生可以在魔法部门钥匙办公室的系统申请入学时间,时间可以精确到比纳秒还小。



为了实现这个需求,魔法部使用了一个有序的(而不是基于哈希的)数据库,在其中用入学时间作为主键。



每当有学生申请一个入学时间,就有个事务会范围搜索(range query)在它前后各60秒的范围内是否有其它人入学,如果没有才能成功创建。



某对调皮的双胞胎不想分开入学,就几乎同时申请了几乎同时的入学时间。在快照隔离下,两个事务有可能会都没看到对方插入的记录,从而让两条记录都创建成功。



例子终于讲完了,下面请移步至第三篇看我对这四个例子的分析。



第二篇讲了四个例子,都是可序列化(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),完全可以作为其他类型的数据库的底层,关于这个话题以后也可以讨论讨论。



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



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



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



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



欢迎留言交流。



番外

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



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

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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



问题解决了。



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



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



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



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



本文首发于我的博客“青菜年糕汤”上。我每周会写一篇技术或非技术的原创博文,不定期选择性转发到segmentfault。想要第一时间读到完整的文章(如《五十年前的一桩公案:数据库关系模型的流行史》),不妨扫描博客中的二维码关注我的公众号。

发布于: 2020 年 08 月 30 日 阅读数: 35
用户头像

青菜年糕汤

关注

还未添加个人签名 2020.05.03 加入

还未添加个人简介

评论

发布
暂无评论
更新丢失、写偏、幻读:数据库事务从快照隔离到可序列化