聊一聊事务的隔离级别以及常见的几个误区

聊一聊事务的隔离级别以及常见的几个误区

前言

读书真是一件非常神奇的事情,本篇文章的大部分内容来源《数据密集型应用系统设计》的第七章事务,当时看的时候很是模糊,尤其是弱隔离级别一块,觉得自己似乎都能看得懂,但就是不明白作者在表达什么,像是没有一根清晰的主线串起来,找不到主旨。
但过了一段时间学习其他的知识的时候,有种突然打通了任督二脉的感觉,特别激动,故写下本文以做总结。

本质

从本质上来说,事务的隔离级别是为并发控制服务的。在应用程序的开发中我们经常用锁进行并发控制,确保临界区的资源不会出现被多个线程同时读写的情况,这其实对应的就是数据库的可串行化级别
那为什么应用层可以提供可串行化的隔离级别而数据库不能提供呢?
在我的理解中,这是由于应用层对临界资源的访问都是内存操作,而数据库要保证持久性它需要把临界区的数据flush到磁盘中,这个IO操作是要比内存操作慢好几个数量级的,这导致临界区持有锁的时间变得不可接受、锁冲突的频率变大,数据库的性能会大大降低。

隔离级别

第一个误区:可重复读级别不担保解决丢失更新问题

维基百科上的数据库隔离级别我认为是有误的

image.png
这里的问题在于事实上Repeatable Read可重复读级别并不担保不出现丢失更新问题,它只担保解决不可重复读问题。

反例证明如下(MySQL RR级别):
image.png
事务A、B同时进行卖出item A的过程,事务A售出4件,事务B售出1件,理论上库存记录应该为原始的10减去4减去1得到5才对,但最后库存的记录显示为9。这是典型的丢失更新问题,即2个事务同时开启read-modify-write操作序列,出现了一个事务覆盖了另外一个事务的写入,但这个过程中没有利用/包含/读取/获取到对方更新后的最新值(这表明事务A已经commit过了,有别于脏写),换句话说也就是事务B没有利用到事务A的更新结果,仿佛事务A的更新被丢失了一样。

下图是数据库隔离级别和可能出现的问题表格,“?”表明这个级别是否发生对应问题取决于数据库的具体实现
image.png

下图是MySQL的隔离级别,我们在前面的例子中已经解释过了MySQL RR不解决丢失更新问题,我们随后会解释为什么它也没有解决幻读问题。
image.png

异常情况

脏写

描述

事务A覆盖了其他事务未提交的写入

解决

通过行级锁即可解决,在任何隔离级别下都不会发生

脏读

描述

事务A读取了其他事务未提交的写入

解决

提供读已提交隔离级别以上的数据库都可以防止,MySQL中是通过RC级别下的MVCC解决的

读倾斜/不可重复读

描述

事务A在执行过程中,对同一个数据行在不同的时间点前后读取的结果不一致

解决

提供读可重复读隔离级别以上的数据库都可以防止,MySQL中是通过RR级别下的MVCC解决的

丢失更新

描述

两个事务同时执行read-modify-write的过程,出现了事务A覆盖了事务B的写入,但并没有包含事务B修改后的最新值(已commit),导致事务B的更新好像丢失了一样的结果。

解决

原子写操作

如果数据库提供了原子写操作,那就使用它以完成read-modify-write操作,这是推荐的最佳方案。例如在多数关系型数据库中UPDATE counters SET value=value+1 WHERE key='test'语句是安全的

显式加锁

如果数据库不支持内置原子操作,可以通过对查询结果显式加锁来解决。对于mysql来说,就是 select for update,通过for update告诉数据库,查询出来的数据行稍后是需要更新的,需要加锁防止其他的事务也来读取更新导致更新丢失。

自动检测更新丢失

数据库先让事务都并发执行,如果事务管理器检测到有更新丢失的风险,直接中止当前事务,然后强制回退到安全的read-modify-write方式。

原子比较和设置

在上次读取到的数据没有发生变化时才允许更新,如果发生变化了则回退到安全的read-modify-write方式。例如:update table set value=newvalue where id=* and value=oldvalue
但是该方式有一个问题,如果where条件的判断是基于某一个旧快照来执行的,那么where的判断是没有意义的。所以如果要采用原子比较和设置来避免更新丢失,那么一定要确认数据库比较-设置操作的安全运行条件。

补充说明

举例

假设有一个文章计数器变量x,事务A、B同时访问它并准备给文章访问量进行加1操作;当x=20时,如果出现事务A读取到了x=20并准备执行+1操作的这个中间过程(也就是说还没有+1并commit的过程)中,事务B已经完成了对x的修改并且把x=21的结果提交了。这个时候我们说事务A的x=20需要作废的,否则B的更新就好像被丢失了,但实际上A还是会执行他自己的x=x+1=20+1=21并且提交x=21更新;
丢失更新在写锁机制下(只在写操作上上锁,而不是整个read-modify-write过程)以及MySQL的RR级别下都没办法解决这个问题!

脏写&丢失更新 对比

  • 丢失更新强调read-modify-write过程,这往往是个有状态的过程
  • 脏写覆盖的是其他事务未提交的更新,丢失更新覆盖了其他事务已提交的更新,导致其他事务的更新看起来像是丢失了一样

图例:
image.png

幻读

描述

事务A先查询了某些符合条件的语句,事务B随后执行了写入改变了事务A在相同条件下的查询结果(通常表现为A select出来的数据行数多/少了)

解决

MySQL RR级别下通过MVCC快照读可以避免查询时的幻读

写倾斜

描述

事务A根据条件查询数据库,并根据所查询初的结果做出相应的某些动作,然后修改数据库。但当事务A提交的时候,支持它做出相应修改的条件已经不成立了(之前Select出的结果不一致);
写倾斜是幻读的一种情况;写倾斜也可以理解为广义的更新丢失问题,假如事务A、B读取同一组对象,然后更新其中的一部分;

  • 不同事务更新不同的对象-->可能发生写倾斜
  • 不同事务更新同一个对象-->可能发生脏写/更新丢失

解决方案

  • 显式加锁
  • 可串行化隔离级别

第二个误区:MySQL RR级别究竟有没有解决幻读问题?

在这里我认为问题的关键在于幻读这个概念是否限定于只读事务的语境下,或者说写倾斜属不属于幻读的范畴内:
如果你认为写倾斜属于幻读的范畴内,那么仅依靠MySQL RR级别下仅依靠MVCC(实现原理和乐观锁有类似之处)的形式是无法避免幻读问题的,需要额外显示地加上next-key lock(悲观锁)去避免;
如果你认为写倾斜不属于幻读的范畴内,那么RR级别已经解决了幻读(限定在只读事务语境下)问题,RR级别下保证了同一事务前后的快照读结果都是一致的。
总得而言,RR级别解决了快照读的幻读问题,但无法避免当前读的幻读问题,这种情况下需要next-key lock配合解决。总得而言,我认为仅依靠RR级别并没有解决幻读问题

image.png
在上面这个例子中,我们可以看到在事务B新增了一行数据以后,事务A还是只能读取到5行数据而没有事务B插入的"Frank"的资料,并没有发生幻读问题;
但假设现在事务A需要为分数最高的前三名玩家增加1个credit,依照上图可以看到前三名的玩家分别是Alice、Carol和Bob,其中Bob的分数最低为740,所以你可以通过UPDATE gamer SET credit=credit+1 WHERE score>=740的原子语句进行更新,在这种情况下事务A的更新是否只影响他目前所能看到的5个玩家的资料呢?实验结果如下图:
image.png
可以看到事务A执行更新语句后再次select查询可以看到Frank也查询出来了,发生幻读问题。不仅如此,我们原本预期事务A只为前三名的玩家增加credit,而现在我们为4个玩家增加了credit,比原定的3个还多,这种情况属于写倾斜

进一步解释

实际上RR级别下 MVCC快照读的概念大概可以理解为,当一个事务开启时对数据库的状态做一个snapshot,然后事务内只能看到这个snapshot的内容以及自己所做的更改,而无法读取到其他事务对数据库所做的更新,这决定了不会出现不可重复读的现象;
但在MySQL InnoDB实践中可以发现这个规则只限定于SELECT(DQL)指令,而INSERT、UPDATE、DELETE等DML指令看到的不是当前事务创建时的snapshot,而是具体指令执行时数据库被commit过的最新状态。
所以在上面的例子中事务A在执行第一次SELECT的时候看到的是snapshot(快照读避免幻读现象),而当它执行UPDATE的时候就看到了数据库最新的状态,获取到了玩家Frank并为他增加credit(MySQL RR级别的当前读 无法解决幻读问题

同样是基于快照隔离实现的RR隔离级别的PostgreSQL,由于它的snapshot不仅作用于DQL指令,也作用于DML指令,所以幻读问题在PostgreSQL的RR级别不会发生;

解决办法

基于前面给出的写倾斜解决方案,我们在这里可以显式的加锁,例如用MySQL的 Share Lock或是Exclusive Lock指令或者先进行一次SELECT FOR UPDATE,阻塞其它想更改资料的事务即可避免幻读。

例如将事务A修改为 即可解决问题(select for update显式上锁,在事务A commit/rollback之前 事务B会一直阻塞,解决幻读)

begin
SELECT * FROM gamer FOR UPDATE;
UPDATE gamer SET credit=credit+1 WHERE score>=740;
SELECT * FROM gamer;
commit;

image.png

Reference

[1] Isolation(database systems)-wikipedia
[2] 對於 MySQL Repeatable Read Isolation 常見的三個誤解
[3] 《数据密集型应用系统设计》第七章

个人能力有限 本文内容如有错误欢迎批评指正!