数据库的事务隔离级别是一个老生常谈的话题。早在ANSI SQL-92标准里,基于对3种“怪象”(phenomena)的避免的能力,定义了四种级别:
• READ-UNCOMMITTED:未定义,在[CASIL ]论文中引入了避免脏写(dirty-write)这一条
• READ-COMMITTED:避免脏读(dirty-read)
• REPEATABLE-READ:避免不可重复读
• SERIALIZABLE:避免幻读(phantom-read)
这四种隔离级别在我们讨论数据库事务的时候常常被当成标准答案。
我们也常常在阅读数据库并发控制相关的话题时,听到读写锁这个概念。再在后来阅读MySQL之类的数据库文档时,听到快照对于读写锁的并发性优化这个话题。
本文将探讨这两把锁和快照机制是如何作用于事务隔离级别的。通过阅读本文,我们也会发现,基于锁和快照实现的事务隔离级别其实不止上述四种,“怪象”也不仅仅是上述四种。
1 ANSI SQL-92到底在说什么
ANSI SQL-92作为一个不关心如何实现的标准,它的责任是给出定义。而它定义事务隔离级别的方法就是看一个数据库的实现是不是成功的避免了某些“怪象”,根据实现能避免多少种“怪象”来判定它做到了什么级别的隔离性。
我们有3个假设:
• 在SQL-92的语境下共享数据只有一份
• 回滚的意思就是让数据回到本事务修改前的值
• 由于只有一份,回滚的实现基于这样的办法:事务每次修改一个共享数据,本事务都会记住这个数据在修改之前的值,以便回滚时把它设置回去
怪象到隔离性的关系已在本文开头叙述了,这里只讲讲上文提到的4个“怪象”是什么意思
1.1 脏写
某个事务对数据x做了写入操作,在本事务结束前,另一个事务也对x做了写操作。
怎么就脏了?试想两个场景。
1.1.1 脏写场景1
假设有个一致性约束叫做数据x必须等于数据y。两个并发的事务都按照这个约束去写数据,时序如下:
【下文中我们会常常采用这样的表示格式,w1(x=2)表示事务1对x这个数据做了一个w操作(写操作),将x的值改成了2】
示例中的两个都事务逻辑都在保证x=y(一个打算让x=y=1,另一个打算让x=y=2),但是最终提交后,x=2,y=1,不满足x=y的约束了。
1.1.2 脏写场景2
场景2的约束更简单:只要修改x,它就必须被修改成2
这里a1代表事务1做了a操作(abort操作/回滚操作),c2代表事务2做了c操作(commit操作)。
这个例子里,事务1把x置为了2,接着心情突然不好,回滚了。它想起将x修改为2之前其值是0,要回滚嘛,于是就把值修改回了0。事务2把x修改成了2,高兴地提交了,然后一看,x等于0,没有没修改成2。
1.1.3 脏写的意思
上述两个示例可以发现,脏写的“脏”,是脏在一致性约束被打破:每个人都以自己觉得一致的方式去纯写数据,可是写完大家发现,数据不满足任何一个人对一致性的预期。是为“脏写”。
可见,存在脏写的事务实现,其实是不具备任何隔离型的。一旦把脏写避免了,隔离型级别就从完全没有升级成了READ-UNCOMMITTED。
1.2 脏读
事务1对x做了写操作,在事务1结束前,事务2对x做了读操作。
怎么就脏了?
看个场景:
转账的约束很显然是x+y不变。这个例子里,由于数据只有一份,事务2读到了事务1未提交的值(x=1),没想事务1接下来回滚了,回滚后x这唯一一份数据从1改回了0,没毛病。但是事务2接着做转账了,x减1,y加1,然后提交,也没毛病。但是最终的结果x+y变了。
脏在事务2“读”到了一个不存在的值(x=1),是为脏读。避免了脏读,隔离级别就从READ-UNCOMMITTED升级到了READ-COMMITTED。
1.3 不可重复读
一个事务读取了x的值,在结束前,另一个事务又去写了x。
为什么就不可重复了?
看个场景:
约束是x+y值恒为20,然而该示例中,事务1看到的x+y=18,不满足约束。即,一旦“一个事务读取了x的值,在结束前允许了另一个事务又去写x”,就会造成不一致。
读者一定会问:为什么叫“不可重复读”?上述示例中x没有在一个事务中被重复度过啊。这个是SQL-92最原始的定义是“事务重复两次读取x之间x被另一个事务写”,后续演进了一版变成了本文的样子。怎么演进的本文不讨论了,姑且这么理解吧:一旦允许“一个事务读取了x的值,在结束前允许了另一个事务又去写x”这种情况发生,事务1在事务2提交后再去读x时,就会看到和首次读不同的值,所以失去了重复读的能力。
1.4 幻读
一个事务读了一个谓词(人话转述是“读了where条件的判定结果或者执行了一次where条件判断”)后,在完成前,另一个事务对满足此谓词的某条数据做了写(人话是“插入了一条满足前面where条件的新数据、从满足前面where条件的数据里删除了一条、将不满足前面where条件的数据改得满足、见满足前面where条件的某条数据改成不满足”)。
和不可重复读的区别就是:把x(具体记录)换成了谓词。
幻读怎么就不对了呢?
看个场景:
假设有两张表:苹果表、苹果表的摘要表。苹果表的一行记录代表一个苹果,摘要表(abstr)只有一行记录,就是苹果表的行数,即苹果总数。
一致性约束:摘要表唯一一行记录的值必须等于苹果表的行数。
初始状态:苹果表有10行,摘要表记录取值10。
事务1读了一个苹果表的谓词(select count(*) from 苹果表 where true,谓词是true),事务2在事务1完成前又插入了一条满足谓词true的记录。触发了幻读:
事务2看到的故事是:我往苹果表insert了一行记录,然后打算给摘要表记录做个加1操作(首先读取摘要记录,在为读到的摘要记录+1然后写会摘要记录),此时摘要表记录值是11,然后提交。事务2没毛病:保持了摘要值和苹果表记录数都是11,一致的;
事务1看到的故事:我count了一下苹果表(谓词很简单,直接是true),看到是10,然后我读取了一下摘要表,看到的是11。不一致!我Phatom了!
所以幻读不好,因为它会破坏一致性。为了升级为更高的隔离级别SERIALIZABLE,避免幻读是必要条件(按照SQL-92的意思,也是充分条件)。
2 两把悲观锁
ANSI SQL-92定义的4种隔离级别说清楚了。在那个数据假设只有一份的语境下,如何利用两把悲观锁来实现上述4种隔离级别呢?
2.1 “加锁”的分类
我们从三个维度对“加锁”这件事分类,每个维度两种取值,于是“加锁”的类型取值空间为8。
三种维度分别是:
• 按照锁模式,分成共享锁(读锁)和排它锁(写锁),写锁和读/写锁互斥
• 按照锁的范围,分成针对记录的锁和针对谓词的锁(通常用gap-lock或者表级别锁来实现)
• 按照锁释放的时机,所有的锁都是事务中各个读写操作(SQL)开始前持有,但是有的锁持有了要等到事务结束后才释放,有的只要对应SQL本身完成了,就释放。
2.2 没有隔离能力
事务中,只在写SQL开始时对记录和谓词加锁,SQL执行结束立即释放。
意味着这种方式只保证了这条SQL本身的原子性。
显然一个事务对x做了写,还么提交时,另一个事务也可以写x(只要前面事务的x写操作本身SQL完成,锁就是放了,x的锁又可以被获得)
2.3 READ-UNCOMMITTED
和上小节的区别在于,写操作的锁,一直到事务完成才释放。如此依赖,事务1对x做了写,只要事务1没结束,x的写锁就不会释放,其他事务就无法对x做写入,因此脏写被避免。
但是显然会有脏读,因为读取操作不加锁,无法避免没有提交写的x被其他事务读到。
2.4 READ-COMMITTED
在上小节基础上,对读操作加短读锁。没有提交写的x,无法被其他事务读取到,因为x的写锁没释放,其他事务无法获取读锁。从而脏读被避免。
然而仍然存在不可重复读问题,因为事务读了x,SQL结束后立即释放锁,其他事务具备获取x写锁的能力,从而仍可能在读x的事务结束前,写x。
2.5 REPEATABLE-READ
在上小节的基础上,读操作针对记录的锁,直到事务完成后才释放。这样一来,事务读取了x,只要没结束,x的读锁都不会释放,其他事务无法写x。不可重复读怪象被避免。
然而对谓词的读无法阻碍其他事务修改谓词依赖的数据,因此幻读仍然存在。
2.6 SERIALIZABLE
最后,满足两个条件:
• 所有读写都加完整的锁
• 加锁过程满足两阶段锁协议(2PL)。由于数据库也不知道每一条SQL之后还有没有新的SQL,实际中两阶段锁通常就是事务结束时释放
时,serializable可以达到。
3 多版本并发控制-MVCC
我们基于共享排他锁(读锁写锁)实现了4种隔离级别。然而显而易见,完全基于锁的实现,想达到read-committed以上的隔离级别,对特定一条记录的读和写是无法并发的。而很多时候这会影响我们的吞度量。当更新x所在事务(写事务)需要耗费长时间时,后续高并发的新事务只要不修改x,其实可以允许执行,读取一个写事务开始前的最新值。这种情况对于x是某种开关、配置参数的情况很适用。
为实现上述情况,我们需要假设数据库里同一条记录存储了多个版本。现代的数据库基本基于此假设,即,上一章纯粹用两把悲观锁来实现隔离级别的方案基本没有被采用了。但它是我们后续讨论的基础。
3.1 实现1:快照读+乐观锁写
每条记录的每个版本都有一个字段记录版本号,假设叫commit_timestamp。
事务开始时,先获取事务开始的时钟值(可以是逻辑时钟、也可以是物理时钟),叫它start_timestamp。
• 对于写操作:在提交之前,都将其存储在事务内部的临时区域内。事务提交时,获取此刻的时钟值(可以是逻辑时钟、也可以是物理时钟),他就是即将写入记录的版本号信息commit_timestamp。如果发现此时记录的commit_timestamp已经大于start_timestamp,说明该记录已经别别的事务修改提交了,此时乐观锁的写方案会认为冲突发生,从而拒绝提交。
• 对于读操作(快照读):一个事务内读取到的是事务开始时看到的快照以及基于此快照本事务的写增量。
我们来讨论下这种方案能否避免前文所述的几种怪象
3.1.1 脏写
写冲突机制保证了只有一人写成功、只要不提交数据变更保存在事务内部,不共享。所以1.1.1、1.1.2两种情况不会发生。脏写不存在。
3.1.2 脏读
事务读取到的一定是提交之后的数据,脏读不存在。
3.1.3 不可重复读/幻读
快照读到的数据是事务开始时看到的版本,后续的提交会导致新的拥有更高版本号的一份数据,本事无读到,故无论重复读期间穿插了多少别的事务提交,本事务读到的数据都是一样的。
3.1.4 write skew
SQL-92里定义的4种怪象都被快照读+乐观锁写的方案规避掉了。是否意味着它达到了serializable级别了呢?
不是。
因为write skew这种新怪象随着本方案引入了:
两个事务都按照本方案执行着,两个事务都按照约束做更新,事务1更新x时觉得给x减了15最后x+y应该等于5,大于0的,没毛病。事务2亦然。由于他们俩一个更改x,一个更改y,没有冲突故c1 c2两个提交操作都成功,也没毛病。但是最后的结果x+y=-10,违背了约束。
3.1.5 什么级别?
这种方案下,达到的隔离级别叫做SNAPSHOT-ISOLATION,它和REPEATABLE-READ互有优劣。
3.2 实现2:快照读+两把悲观锁
在两把锁的read-commited/repeatable-read场景下,将读分出一个变种叫做快照读,非快照读(我们叫它“传统读”或者直接叫它“读”,和上一章看到的“读”概念一致)和两把锁方案基本一致。即,这种MVCC的实现有快照读和读(传统读)两种。
它的优劣我们下一章探讨。
4 基于多版本的MySQL
本文中,为了和前述概念一致,当我们说MySQL的“读”或者“传统读”时,我们指的是SELECT ... LOCK IN SHARE MODE;而不带后缀的SELECT,本文加它“快照读”。
MySQL是基于快照读变种方案的MVCC。我们都知道它也有4个事务隔离级别,名字正好还和前文(ANSI-SQL-92、两把锁)语境下定义的一样,但是按照两把锁的语境,MySQL在不走快照读这个变种而只用传统读时,它的隔离级别名字和前文同名级
别的意思完全不同:
• MySQL-READ-UNCOMMITTED:类似前文READ-UNCOMMITTED
• MySQL-READ-COMMITTED:类似前文REPEATABLE-READ
• 加锁持续到事务结束(两阶段锁)、不对谓词加锁仅对数据本身加锁
• MySQL-REPEATABLE-READ:类似前文SERIALIZABLE
• 加锁持续到事务结束(两阶段锁,(不考虑semi-consistent read这种优化情况))、同时对谓词和数据本身加锁,满足了前文SERIALIZABLE的充分条件
• MySQL-SERIALIZABLE:在前文SERIALIZABLE的基础上,把MySQL的快照读也变成传统读了
而在MySQL最常用的方式——只走快照读不走传统读——下,四个隔离级别的意思又不太一样:
• MySQL-READ-UNCOMMITTED:类似前文READ-UNCOMMITTED
• MySQL-READ-COMMITTED:类似前文READ-COMMITTED
• MySQL-REPEATABLE-READ比较复杂,见
https://blog.pythian.com/understanding-mysql-isolation-levels-repeatable-read 这篇文章
• 只读情况下(只考察事务中的快照读)类似前文SERIALIZABLE:没有幻读;
• 读写混合时(INSERT INTO tb1 (SELECT * FROM tb2))类似前文READ-COMMITTED
• MySQL-SERIALIZABLE:等同于只走传统读的MySQL-SERIALIZABLE
5 总结
基于单版本+共享/排它悲观锁,以及基于乐观锁、悲观锁两种多版本方案,它们的隔离级别演进如上图。
事务隔离级别有多少种?
原文发布时间为:2018-04-2
本文作者:邱硕