[db] 5 - Snapshot Isolation

Posted by Dongbo on April 6, 2022

之前在事务的介绍中,我们提过事务定义有 Serializable、Repeatable_Read、Read_Committed、Read_Uncommitted 四种隔离等级。我们今天要介绍的快照隔离(Snapshot Isolation)也是隔离等级的一种,是采用MVCC实现并发控制时所得到的隔离级别,大概位于 Serializable 和 Repeatable_Read 之间,也就是说快照隔离比可重复读的隔离级别要高,不会出现幻象问题;但是又没有完全达到冲突可串行化,可能会出现别的问题。我们今天就来一探究竟,看看快照隔离到底会出现什么问题,该如何解决。

关于快照隔离的历史我们暂且不深究,大概了解到之前之所以只提四种隔离级别没有快照隔离是因为 SQL-92 标准是基于数据库锁机制提出的,对 MVCC 没有充分的了解1。还是来简单看下快照隔离的概念:使用多版本并发控制机制(Multiversion Concurrentcy Control)来进行并发控制,在事务开始执行时,写事务会先生成数据库的一份“快照”,在快照上进行修改,因为快照只有当前事务可见,所以该写事务与其他事务是完全隔离开的,只有它提交之后快照中进行的修改才会被应用到数据库中;因此读事务的实现就变得简单了,无需等待写事务的完成,直接读取当前数据库最新的状态即可,如果写事务回滚了其他读事务完全不会看到它曾进行的修改。

不过有一个问题需要解决,还是一个教材上的例子:如果两个写事务同时对数据 D=0 进行修改,它们都获取了 D=0 的快照,随后事务 T1 将 D 的值改为 10,事务 T2 将 D 改为 20,在两个事务提交之后我们应该看到 D 的值应该为多少?根据我们上面的介绍,应该是后提交的那个事务修改的值,似乎并没有什么不妥,既然先提交事务进行的修改会被覆盖,那我们观测不到它也无可厚非。但这是教材上对问题的描述不够直观,用[3]举例说明,如果是创建用户或者账单等以一个递增的ID作为唯一标识的场景下,两个事务的执行成功意味着我们应该看到两个新账户,但是现在先提交事务进行的修改会被覆盖,只完成了一个新账户的创建;或者说D是账户的余额,那么T1和T2两个事务将给D总计转入30元,而现在却丢失了一笔转帐,只能得到10或20元。这就是更新丢失问题。

所以为了避免我们的账户收不到汇款,需要一些机制来防止更新丢失,教材2给出了先提交者获胜(first committer wins)和先更新者获胜(first updater wins)两种方法,并在习题 15.19 给出了关于如何实现 first committer wins 策略的提示3,我们这里就主要介绍这种方法的具体步骤。

first committer wins 的思路是:在事务T进入提交状态时,检查是否有与T并发执行的事务也修改了T写入的数据,如果有则T中止;如果没有则T可以正常提交,将更新写入数据库。

  • 实现1是每个事务维护一个开始时间戳和提交时间戳,以及修改数据的集合,提交时检查如果有其他事务的提交时间戳在本事务开始之后、提交之前,并且修改的数据集合与本事务有交集,则说明T修改的数据也被并发执行的事务修改了,为了防止更新丢失本事务需要中止。
  • 实现2则无需维护修改数据集合,而是每个数据项分配一个写时间戳,事务本身同样持有开始时间戳和提交时间戳(可以采用逻辑时间戳保证唯一性),在提交时将数据项的时间戳更新为事务的提交时间戳。因此检验提交是否有效的过程变为:提交前查询数据库中该时间戳实际值小于本事务的开始时间戳,如果是则说明这期间该数据没有被修改,可以正常提交并更新数据的时间戳为提交时间戳;如果数据的写时间戳大于等于本事务的开始时间戳,则说明其他事务并发修改了该数据,本事务应当中止。

现在一切听上去都很完美,读事务获得了更高的并发度,我们也不会产生幻象问题,写事务之间的并发依然保留着我们对于串行化的要求。但是可惜事与愿违,快照隔离还是不能保证可串行化。

现在来设想这样的情况2,4:我们有数据 {D1:0, D2: 0},事务 T1、T2 同时获取快照并进行修改,T1中数据改为 {D1:10, D2:0},T2中改为 {D1:0, D2:20},那么提交时我们应该看到什么状态?从逻辑上说,我们执行了两个事务,它们分别把数据 D1 设为10 和 D2 设为 20,按照可串行化这两个更新我们都应该要看到,但是现在如果 T1 后提交我们只能看到 D1 的修改;如果 T2 后提交我们就只能看到 D2 的修改。尽管两个事务都顺利提交了,但是因为获取快照时另一个事务的修改还没有发生,导致我们提交后其中一个事务的更新不见了。

另外可以用优先图分析这一过程:T1先读 D2 和 T2 随后写 D2 是两条冲突指令,所以图中有一条 T1 指向 T2 的边;而 T2 先读 D1,T1 随后修改 D1,这同样也是两条冲突指令,图中也存在一条 T2 指向 T1 的边。现在优先图存在一个环了,说明这样的调度不是可串行化的。这样的情况称为写偏斜(write skew)。上面解决更新丢失的有效性检验机制也无法解决这一问题,因为两个事务修改的不是同一个数据项。

实际上快照隔离级别并没有解决写偏斜问题,但是DMA如果知道当前使用的是快照隔离级别,同时想要避免写偏斜问题,可以在查询语句中使用 for update 令数据库将 select 语句作为一条更新语句对待,这样上述例子中 T1 和 T2 在提交时会检查 D1、D2 的值做否被其他事务修改,最终其中一个事务会发现其他事务先于自己修改了数据而中止。但这不是数据库开发人员解决了写偏斜(否则快照隔离就能成为可串行化了)

总结来说,快照隔离等级下,没有RR级别中会出现的幻象问题,但是会出现写偏斜(有些地方似乎会把写偏斜也称谓 write skew style phantom,我们这里还是把二者区分开)可能会破坏数据库的一致性,使用时可根据实际场景判断是否需要更高的隔离级别。

update


update 2022-07-15
没想到这么快就需要纠正自己的错误了;看了ANSI SQL Isolation Levels的论文5之后,发现这1995年的论文对于MVCC对应的SI隔离等级其实已经有一个相当明确的陈述了。根据论文的描述,SI其实并不能说高于RR,因为SI会出现RR中不可能出现的写偏斜 A5B;但是SI不会出现狭义的幻读A3,尽管SI允许广义的幻读P3;因此论文里将SI和RR放在同一个层级上;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
w1[x]表示事务1写了数据项x,r2[x]表示事务2读数据项x,
r1[P]/w1[P]分别表示事务1读取/写入满足某个谓词P的数据项;c1/a1表示提交/中止;

P0: w1[x]...w2[x]...(c1 or a1) && (c1 or a2)            Dirty Write

P1: w1[x]...r2[x]...(c1 or a1) && (c2 or a2)
A1: w1[x]...r2[x]...(a1 and c2 in either order)	        Dirty Read

P2: r1[x]...w2[x]...(c1 or a1) && (c2 or a2)
A2: r1[x]...w2[x]...c2...r1[x]...c1                     Non-Repeatable Read or Fussy Read

P3: r1[P]...w2[y in P]...(c1 or a1) && (c2 or a2)
A3: r1[P]...w2[y in P]...c2....r1[P]...c1               Phantom

P4: r1[x]...w2[x]...w1[x]...c1                          Lost Update

A5A: r1[x]...w2[x]...w2[y]...c2...r1[y]...(c1 or a1)    Read Skew

A5B: r1[x]...r2[y]...w1[y]...w2[x]...(c1 and c2 occur)  Write Skew

// TODO: A6 anomaly


The End