乐趣区

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

这个系列文章一共有三篇注释和一篇番外,为了不便查找和浏览,我将其合在一起在 segmentfault 从新公布。本文用丰盛的例子,事无巨细地解说了快照隔离和可序列化这两种事务模型的区别,辨析了更新失落、写偏、幻读等概念,并剖析了一个事实利用案例。

它的指标受众是:原本就理解数据库系统中的事务是什么意思,也大抵据说过(但未必分得清)形形色色的事务隔离级别。

这个系列咱们将探讨两种事务隔离级别之间的区别:快照隔离(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。想要第一工夫读到残缺的文章(如《五十年前的一桩公案:数据库关系模型的风行史》),无妨扫描博客中的二维码关注我的公众号。

退出移动版