概述
笔者在初次看到多版本并发控制(Multi-Version Concurrency Control, MVCC,后文统一用MVCC代替)时,看的一头雾水,尤其是《高性能MySQL》一书中对于MVCC的讲解,言语过于简略,遂通过查阅多方资料,才算大致搞懂MVCC。
本文就笔者个人对于MVCC的理解进行一下比较白话文的介绍,意在用更容易理解的方式让大家明白MVCC到底是个什么东西。如有笔者理解有误的地方,恳请各位大佬指正!
本文从事务的隔离性(Isolation)出发,引出并发环境下易出现的问题,之后介绍四种隔离级别,最后介绍MVCC的来龙去脉!
事务
事务是指一组满足原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability)这四个特性(简称ACID特性)的操作。
其中原子性、持久性由 redo log (重做日志)来实现, undo log 用来保证事务的一致性。
而隔离性的实现,是本文要讨论的重点,我们先来复述一下隔离性的定义,内容摘自《高性能MySQL》
通常来说,一个事务所做的修改在最终提交以前,对其它事务是不可见的,
注意一下 “通常来说” 这四个字,这是最骚的,这说明这个隔离性定义并非那么苛刻,本文会在介绍隔离级别时再次提到这个问题。而且我们会发现,就算严格按照上述定义保证了隔离性,依然会有一些我们不能接受的情况产生!
并发环境下易出现的问题
1.丢失修改
T1 和 T2 两个事务都对一个数据进行修改,T1 先修改,T2 随后修改,T2 的修改覆盖了 T1 的修改。
2.读脏数据
T1 修改一个数据,T2 随后读取这个数据。如果 T1 撤销了这次修改,那么 T2 读取的数据是脏数据。
3.不可重复读
T2 读取一个数据,T1 对该数据做了修改。如果 T2 再次读取这个数据,此时读取的结果和第一次读取的结果不同。
注意:不可重复读与脏读的区别,脏读读的是未提交的数据,不可重复读读的是已经提交的数据。
4.幻影读
T1 读取某个范围的数据,T2 在这个范围内插入新的数据,T1 再次读取这个范围的数据,此时读取的结果和和第一次读取的结果不同。
以上所列出的这四种问题,其主要原因就是事务并发操作同一记录!
那么如何解决上述问题?我们可以采取一些并发控制手段,最容易想到的就是通过加锁来实现,但由于加锁操作复杂,还需要用户自己控制,于是数据库管理系统提供了事务的隔离级别,让用户以一种更轻松的方式处理这些并发问题。
隔离级别
隔离性其实远比我们想象的复杂,在SQL标准中定义了四种隔离级别,每一种级别都规定了一个事务中所做的修改,哪些在事务内和事务间是可见的,哪些是不可见的。较低的隔离级别通常可以执行更高的并发,系统的开销也更低。
1.未提交读(READ UNCOMMITTED)
事务中的修改,即使没有提交,对其它事务也是可见的,这就会导致脏读的发生。它是最低层次的隔离级别,并发情况下,容易出现大量并发问题,实际业务场景中几乎不会使用。
2.提交读(READ COMMITTED)
一个事务只能读取已经提交的事务所做的修改。换句话说,一个事务所做的修改在提交之前对其它事务是不可见的,说到这,我们就能发现,这个级别就已经实现了上面我们所定义的事务的隔离性了!它能有效的解决脏读的问题,但是值得注意的是,假如一个事务T从开始到提交这一期间,可能有很多其他事务都已经提交了,如果这些其他的提交过的事务修改了事务T中所用到的数据,这就会导致不可重复读的问题发生。
3.可重复读(REPEATABLE READ)
保证在同一个事务中多次读取同样数据的结果是一样的。这解决了脏读的问题,也解决了不可重复读的问题。
但是请注意,可重复读隔离级别下,仅仅保证了对同样的数据的读取结果是一致的,但是对于范围读取,依然会存在幻影读的情况!
4.可串行化(SERIALIZABLE)
这是最暴力的手段,是一种悲观的并发策略,它会在读取的每一行数据上都加锁,所以可能导致大量的超时和锁争用的问题,但由于事务都串行化执行了,那么也没有并发问题了。实际应用中很少使用这个隔离级别,除非在那种非常需要保证数据一致性和可以接受没有并发的情况下,才会考虑采用这个隔离级别。
下图是各种隔离级别对于脏读、不可重复读、幻影读这三种并发不一致问题的规避情况。
打 √ 的代表该种隔离级别下,无法避免该种并发问题。
多版本并发控制(MVCC)
铺垫了这么久,终于轮到本文的主角——MVCC登场了,首先应该明确一点,MVCC并不是MySQL独有的,Oracle,PostgreSQL等都在使用。本文就MySQL中的InnoDB引擎所实现的MVCC来进行介绍。
多版本并发控制(Multi-Version Concurrency Control, MVCC)是 MySQL 的 InnoDB 存储引擎实现隔离级别的一种具体方式,用于实现提交读和可重复读这两种隔离级别。而未提交读隔离级别总是读取最新的数据行,无需使用 MVCC。可串行化隔离级别需要对所有读取的行都加锁,单纯使用 MVCC 无法实现。(MySQL的默认隔离级别是可重复读)
在正式的介绍MVCC如何工作之前,需要了解一下版本号、快照这几个概念
系统版本号:是一个递增的数字,每开始一个新的事务,系统版本号就会自动递增。
事务版本号:事务开始时的系统版本号。
创建版本号:指示创建一个数据行的快照时的系统版本号;
删除版本号:如果该快照的删除版本号大于当前事务版本号表示该快照有效,否则表示该快照已经被删除了。
数据行快照,正如其名称所示那样,是数据行在某一时间点的视图。
MVCC 使用到的快照存储在 Undo 日志中,该日志通过回滚指针把一个数据行(Record)的所有快照连接起来。
MVCC 在每个数据行记录后面都保存着两个隐藏的列,用来存储两个版本号,这两个版本号就是上面我们所介绍的创建版本号和删除版本号
接下来我们来看一下MVCC的实现过程(针对可重复读这一隔离级别)
当开始一个事务时,该事务的版本号肯定大于当前所有数据行快照的创建版本号,理解这一点很关键。数据行快照的创建版本号是创建数据行快照时的系统版本号,系统版本号随着创建事务而递增,因此新创建一个事务时,这个事务的系统版本号比之前的系统版本号都大,也就是比所有数据行快照的创建版本号都大。
在可重复读隔离级别下,事务从开始到提交这一期间,对它所读取的同一行记录的快照数据,永远都是事务开始时的那个行数据版本(而在提交读隔离级别下,事务从开始到提交这一期间,对它所读取的同一行记录的快照数据,永远都是最新的那一份快照数据)
1.INSERT
将事务版本号作为数据行快照的创建版本号。
比如我们执行一个插入操作,开启事务的时候系统版本号为 1,那么这次的事务版本号就是 1,从而下表中的创建版本号就是 1
id | name | create version | delete version |
---|---|---|---|
1 | test | 1 |
2.UPDATE
将事务版本号作为更新前的数据行快照的删除版本号,并将事务版本号作为更新后的数据行快照的创建版本号。可以理解为先执行 DELETE 后执行 INSERT。
比如我们开启一个新的事务,承接上文,现在的系统版本号是 2了,那么我们当前的事务版本号也是 2,现在我们在这个事务里面执行一个更新操作 :
update table set name = ‘aaa’ where id = 1;
数据行快照变更如下:
id | name | create version | delete version |
---|---|---|---|
1 | test | 1 | 2 |
1 | aaa | 2 |
3.DELETE
将事务版本号作为数据行快照的删除版本号。
比如我们又开启了一个事务,承接上文,现在的系统版本号是 3了,那么我们当前的事务版本号也是 3,现在我们在这个事务里面执行一个删除操作 :
delete from table where id=1;
数据行快照变更如下:
id | name | create version | delete version |
---|---|---|---|
1 | aaa | 2 | 3 |
4.SELECT
查询时要符合以下两个条件的记录才能被事务查询出来:
i) 删除版本号 大于 当前事务版本号,就是说删除操作是在当前事务启动之后做的。
ii) 创建版本号 小于或者等于 当前事务版本号 ,就是说记录创建是在事务中(等于的情况)或者事务启动之前。
对于这两个条件初次看不明白的读者,我们可以倒过来想一下。
第一个,如果事务版本号 大于 删除版本号,那么说明在事务开启之前,该数据行快照已经被删除了,那我们读它还有什么意义呢?
第二个,如果事务版本号小于创建版本号,说明当前的数据行快照还没未被其它事务提交!那么就不能读它!
现在四种操作我们都介绍完了,我们可以看到,通过保存两个额外的系统版本号、读取数据行快照,使得大多数的读操作(到现在还没有涉及到加锁,但是后面解决幻读问题需要加锁,所以这里我说的是大多数的读操作,而不是所有的读操作)都可以不用加锁(就算被读取的数据行正在执行DELETE或者UPDATE操作,读操作也不会去等待该行上锁的释放,相反地,InnoDB存储引擎会去读取行的一个快照数据!)。这样设计使得读数据操作简单,性能很好,并且也能保证只会读取到符合标准的行。不足之处是每行记录都需要额外的存储空间,需要做更多的检查工作,以及一些额外的维护工作。
很多资料会将MVCC中的读操作可以分成两类:快照读、当前读
快照读:使用 MVCC 读取的就是快照中的数据,这样可以减少加锁所带来的开销。
当前读:这种情况就是允许用户自己手动加锁,读取的是最新的数据。
加锁方式如下:
注意: 以下第一个语句需要加 S 锁,其它都需要加 X 锁。
select * from table where ? lock in share mode;
select * from table where ? for update;
insert;
update;
delete;
解决幻读问题
万里长征就剩最后一步了,我们刚刚也看到了InnoDB的MVCC实现了可重复读的隔离级别,但这个隔离级别,无法解决幻读的问题,本文的最后,来解释一下InnoDB存储引擎是如何解决这个问题的。
先容笔者简单的来介绍InnoDB的三种行锁算法。。。
Record Locks
锁定一个记录上的索引,而不是记录本身。如果表没有设置索引,InnoDB 会自动在主键上创建隐藏的聚簇索引,因此 Record Locks 依然可以使用。
Gap Locks(间隙锁)
锁定索引之间的间隙,但是不包含索引本身。例如当一个事务执行以下语句,其它事务就不能在 table.num 中插入 10。但是能插入5和25,可以将它理解为左开右开的区间被锁定 (5,25)
SELECT num FROM table WHERE num BETWEEN 5 and 25 FOR UPDATE;
Next-Key Locks
它是 Record Locks 和 Gap Locks 的结合,不仅锁定一个记录上的索引,也锁定索引之间的间隙。例如一个索引包含以下值:5, 9, 20, and 25,那么就需要锁定以下区间(左开右闭):
(-∞, 5] , (5, 9] , (9, 20] , (20, 25] , (25, +∞)
Next-Key Locks 就是为了解决幻影读这个问题而存在的。在可重复读(REPEATABLE READ)隔离级别下,使用 MVCC + Next-Key Locks 就可以解决幻读问题!(范围被锁住了,既插不进去也没法从范围内删除某个已存在的数据)
总结
相信很多从头看到这的读者大致上也应该明白了,这MVCC到底是个啥?总的来说,InnoDB实现的MVCC在很多情况下避免了加锁操作(尤其是针对读操作,我们整篇文章重点都在介绍如何解决读的问题),也就是在大多数的时间内读操作是非阻塞的,写操作也只锁定必要的行,因此开销更低。具体的实现手段就是通过版本号之间的比较。然后通过Next-key Locks协助解决了幻影读的问题,实现了最高的隔离级别(即SQL标准的SERIALIZABLE隔离级别),保证了数据安全性和一致性。怎么样,是不是一种让你不禁通过一连串的卧槽来表示惊讶的乐观并发策略?