1、MVCC机制概述
MVCC
(Multi-Version Concurrency Control
),中文是多版本并发控制,是指在使用**READ COMMITTED**
、**REPEATABLE READ**
这两种隔离级别的事务在执行SELECT
操作时访问记录的版本链的过程,从而在不加锁的前提下使不同事务的读写操作能够并发安全执行,提升系统性能。(读不加锁,读写不冲突)MVCC
机制的核心是在做SELECT
操作前会生产一个ReadView
,通过这个ReadView
可以确认版本链中哪个版本的数据对当前事务可见。READ COMMITTED
隔离级别的事务在每次进行SELECT
操作前都会成1个ReadView
,REPEATABLE READ
隔离级别的事务只在第1次进行SELECT
操作前生成1个ReadView
,之后的查询操作都重复使用这个ReadView
。通过ReadView
找到符合条件的记录版本(记录版本是由undo
日志构建的),其实就像是在生成ReadView
的那个时刻做了1次快照,因此利用MVCC
机制读取数据又叫快照读,也叫一致性读。
需要注意以下几点:
- 之前介绍事务时提到过事务并发引起的四种异常场景:脏写、脏读、不可重复读和幻读。对于脏写 MySQL 是通过加锁的方式解决的,MVCC 机制解决的是脏读、不可重复读和幻读;
READ COMMITTED
隔离级别和REPEATABLE READ
隔离级别可以通过MVCC
机制保证,SERIALIZABLE
隔离级别是通过加锁保证的,READ UNCOMMITTED
隔离级别由于什么措施也没做,因此会允许脏读、不可重复和幻读发生。2、MVCC版本链的形成
前面介绍行格式时提到过隐藏列,对于使用 InnoDB 存储引擎的表来说,它的聚簇索引记录中都包含两个必要的隐藏列:
trx_id
:每次一个事务对某条聚簇索引记录进行改动时,都会把该事务的事务id
赋值给trx_id
隐藏列。注意:只有在对表中的记录做**INSERT**
、**DELETE**
和**UPDATE**
这些修改表中记录的操作时才会给事务分配事务id,且事务id的分配是递增的,一个只读事务的trx_id为0;roll_pointer
:每次对某条聚簇索引记录进行改动时,都会把旧的版本写入到undo
日志中,roll_pointer
就相当于一个指针,可以通过它来找到该记录修改前的信息。
如果此时表中只有1条记录,且插入该记录的事务id为80,此时该记录的行格式简化版如下:
假设之后两个事务id分别为100、200的事务对这条记录进行UPDATE
操作,操作流程如下:
每次对记录进行改动,都会记录一条undo
日志,每条undo
日志也都有一个roll_pointer
属性(INSERT
操作对应的undo
日志没有该属性,因为该记录并没有更早的版本),可以将这些undo
日志串连起来形成一个链表,如下图:
对该记录的每次更新操作(UPDATE
)都会将旧值放到一条undo
日志中,即对该记录的一个历史版本,随着更新次数的增多产生的undo
日志也增多,所有undo
日志被roll_pointer
属性连接成一个链表,这个链表就是版本链。关于版本链有以下点需要注意:
- 版本链是针对某条记录的,即是一条用户记录的不同版本组成的链表;
- 事务
COMMIT
之前对记录的修改也会放到undo
日志,作为记录的一个历史版本组成版本链; - 在版本链中插入
undo
日志是遵循“头插法”,即每次都是将最近生成的undo
日志插入在版本链的链表头部,即版本链头结点对应的记录版本是最新的; 查询版本链时,也是从链表头部遍历,即从最新版本的
undo
日志记录向老版本的undo
日志记录遍历查询。3、ReadView(快照)
3.1 ReadView的形成(重点)
为了保证
READ COMMITTED
和REPEATABLE READ
隔离级别的事务,尚未提交的记录修改对其他事务不可见,InnoDB
提出了ReadView
的概念,ReadView
主要由以下四部分组成:m_ids
:表示在生成ReadView
时当前系统中“活跃”的读写事务的事务id列表,注意事务尚未提交时的状态为“活跃”状态;min_trx_id
:表示在生成ReadView
时当前系统中活跃的(尚未提交的)读写事务中最小的事务id,也就是m_ids
中的最小值;max_trx_id
:表示生成ReadView
时系统中应该分配给下一个(尚未生成的)事务的id
值;creator_trx_id
:表示生成该ReadView
的事务的事务id
。举例:现在有id为1,2,3这三个事务,之后id为3的事务提交了,一个新的读事务在生成ReadView时,m_ids就包括1和2,min_trx_id的值就是1,max_trx_id的值就是4。
如何根据某个读事务生成的ReadView
快照,判断版本链上的某个版本对该查询事务是否可见呢?遵循以下步骤:
- 如果被访问版本的
trx_id
属性值与ReadView
中的creator_trx_id
值相同,意味着当前读事务在访问它自己修改过的记录,所以该版本对当前事务可见; - 如果被访问版本的
trx_id
属性值小于ReadView
中的min_trx_id
值,表明生成该版本的事务在当前事务生成ReadView
前已经提交,所以该版本对当前事务可见; - 如果被访问版本的
trx_id
属性值大于或等于ReadView
中的max_trx_id
值,表明生成该版本的事务在当前事务生成ReadView
后才开启,该版本对当前事务不可见; - 如果被访问版本的
trx_id
属性值在ReadView
的min_trx_id
和max_trx_id
之间,那就需要判断一下trx_id
属性值是不是在m_ids
列表中,如果在,说明创建ReadView
时生成该版本的事务还是活跃的,该版本对当前事务不可见;如果不在,说明创建ReadView
时生成该版本的事务已经被提交,该版本对当前事务可见。面试语录
对于当前事务的启动瞬间来说,一个数据版本的 row trx_id,有以下几种可能:
- 如果落在绿色部分,表示这个版本是已提交的事务或者是当前事务自己生成的,这个数据是可见的;
- 如果落在红色部分,表示这个版本是由将来启动的事务生成的,是肯定不可见的;
- 如果落在黄色部分,那就包括两种情况
a. 若 row trx_id 在数组中,表示这个版本是由还没提交的事务生成的,不可见;
b. 若 row trx_id 不在数组中,表示这个版本是已经提交了的事务生成的,可见。
3.2 READ COMMITTED
比如现在系统里有两个事务id分别为100、200的事务在执行,记录初始时name
值为“刘备”,如下:
# 事务id为100的事务执行如下语句,注意还没有COMMIT,即事务id为100的事务处于“活跃”状态
BEGIN;
UPDATE hero SET name = '关羽' WHERE number = 1;
UPDATE hero SET name = '张飞' WHERE number = 1;
# 事务id为200的事务在对其他表进行操作,目的是让该事务能够分配到一个事务id
BEGIN;
# 更新了一些别的表的记录
...
此刻,表hero
中number
为1
的记录得到的版本链表如下所示:
假设现在有一个使用READ COMMITTED
隔离级别的查询事务开始执行如下语句:
# 使用READ COMMITTED隔离级别的事务,事务id为0
BEGIN;
# SELECT1:Transaction 100、200未提交
# 得到的列name的值为'刘备'
SELECT * FROM hero WHERE number = 1;
这个SELECT1
的执行过程如下:
- 在执行
SELECT
语句时会先生成一个ReadView
,ReadView
的m_ids
列表的内容就是[100, 200]
,min_trx_id
为100
,max_trx_id
为201
,creator_trx_id
为0
; - 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列
name
的内容是'张飞'
,该版本的trx_id
值为100
,在m_ids
列表内,所以不符合可见性要求,根据roll_pointer
跳到下一个版本; - 下一个版本的列
name
的内容是'关羽'
,该版本的trx_id
值也为100
,也在m_ids
列表内,所以也不符合要求,继续跳到下一个版本; - 下一个版本的列
name
的内容是'刘备'
,该版本的trx_id
值为80
,小于ReadView
中的min_trx_id
值100
,所以这个版本是符合要求的,最后返回给用户的版本就是这条列name
为'刘备'
的记录。
之后,我们把事务id为100的事务提交一下,就像这样:
# Transaction 100
BEGIN;
UPDATE hero SET name = '关羽' WHERE number = 1;
UPDATE hero SET name = '张飞' WHERE number = 1;
COMMIT;
然后再到事务id为200的事务中更新一下表hero
中number
为1
的记录,做如下UPDATE
操作:
# Transaction 200
BEGIN;
# 更新了一些别的表的记录
...
UPDATE hero SET name = '赵云' WHERE number = 1;
UPDATE hero SET name = '诸葛亮' WHERE number = 1;
此刻,表hero
中number
为1
的记录的版本链就长这样:
然后再到刚才使用READ COMMITTED
隔离级别的事务中继续查找这个number
为1
的记录,如下:
# 使用READ COMMITTED隔离级别的事务
BEGIN;
# SELECT1:Transaction 100、200均未提交
SELECT * FROM hero WHERE number = 1; # 得到的列name的值为'刘备'
# SELECT2:Transaction 100提交,Transaction 200未提交
SELECT * FROM hero WHERE number = 1; # 得到的列name的值为'张飞'
这个SELECT2
的执行过程如下:
- 在执行
SELECT
语句时会又会单独生成一个ReadView
,该ReadView
的m_ids
列表的内容就是[200]
(事务id
为100
的那个事务已经提交了,所以再次生成快照时就没有它了),min_trx_id
为200
,max_trx_id
为201
,creator_trx_id
为0
; - 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列
name
的内容是'诸葛亮'
,该版本的trx_id
值为200
,在m_ids
列表内,所以不符合可见性要求,根据roll_pointer
跳到下一个版本。 - 下一个版本的列
name
的内容是'赵云'
,该版本的trx_id
值为200
,也在m_ids
列表内,所以也不符合要求,继续跳到下一个版本。 - 下一个版本的列
name
的内容是'张飞'
,该版本的trx_id
值为100
,小于ReadView
中的min_trx_id
值200
,所以这个版本是符合要求的,最后返回给用户的版本就是这条列name
为'张飞'
的记录。
从上面过程可以总结出:使用READ COMMITTED
隔离级别的事务在每次查询开始时都会生成一个独立的ReadView
,且在READ COMMITTED
隔离级别下,正是由于每次查询时事务都会生成一个最新的ReadView
,这个ReadView
太新了,导致每次查询出来的记录可能是不同的(比如SELECT1
查询出来的记录是“刘备”,SELECT2
查询出来的记录是“张飞”),因此READ COMMITTED
隔离级别可以避免脏读,但不能避免不可重复读。
3.3 REPEATABLE READ
比如现在系统里有两个事务id分别为100、200的事务在执行:
# Transaction 100,尚未COMMIT
BEGIN;
UPDATE hero SET name = '关羽' WHERE number = 1;
UPDATE hero SET name = '张飞' WHERE number = 1;
# Transaction 200
BEGIN;
# 更新了一些别的表的记录
...
此刻,表hero
中number
为1
的记录得到的版本链表如下所示:
现在有一个使用REPEATABLE READ
隔离级别的事务开始执行查询操作:
# 使用REPEATABLE READ隔离级别的事务执行select操作
BEGIN;
# SELECT1:Transaction 100、200未提交
SELECT * FROM hero WHERE number = 1; # 得到的列name的值为'刘备'
这个SELECT1
的执行过程如下:
- 在执行
SELECT
语句时会先生成一个ReadView
,ReadView
的m_ids
列表的内容就是[100, 200]
,min_trx_id
为100
,max_trx_id
为201
,creator_trx_id
为0
; - 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列
name
的内容是'张飞'
,该版本的trx_id
值为100
,在m_ids
列表内,所以不符合可见性要求,根据roll_pointer
跳到下一个版本; - 下一个版本的列
name
的内容是'关羽'
,该版本的trx_id
值也为100
,也在m_ids
列表内,所以也不符合要求,继续跳到下一个版本; - 下一个版本的列
name
的内容是'刘备'
,该版本的trx_id
值为80
,小于ReadView
中的min_trx_id
值100
,所以这个版本是符合要求的,最后返回给用户的版本就是这条列name
为'刘备'
的记录。
之后,我们把事务id为100的事务提交一下,就像这样:
# Transaction 100
BEGIN;
UPDATE hero SET name = '关羽' WHERE number = 1;
UPDATE hero SET name = '张飞' WHERE number = 1;
COMMIT;
然后再到事务id为200的事务中更新一下表hero
中number
为1
的记录:
# Transaction 200
BEGIN;
# 更新了一些别的表的记录
...
UPDATE hero SET name = '赵云' WHERE number = 1;
UPDATE hero SET name = '诸葛亮' WHERE number = 1;
此刻,表hero
中number
为1
的记录的版本链就长这样:
然后再到刚才使用REPEATABLE READ
隔离级别的事务中继续查找这个number
为1
的记录,如下:
# 使用REPEATABLE READ隔离级别的事务
BEGIN;
# SELECT1:Transaction 100、200均未提交
# 得到的列name的值为'刘备'
SELECT * FROM hero WHERE number = 1;
# SELECT2:Transaction 100提交,Transaction 200未提交
# 得到的列name的值仍为'刘备'
SELECT * FROM hero WHERE number = 1;
这个SELECT2
的执行过程如下:
- 因为当前事务的隔离级别为
**REPEATABLE READ**
,而之前在执行**SELECT1**
时已经生成过**ReadView**
了,所以此时直接复用之前的**ReadView**
,之前的ReadView
的m_ids
列表的内容就是[100, 200]
,min_trx_id
为100
,max_trx_id
为201
,creator_trx_id
为0
; - 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列
name
的内容是'诸葛亮'
,该版本的trx_id
值为200
,在m_ids
列表内,所以不符合可见性要求,根据roll_pointer
跳到下一个版本。 - 下一个版本的列
name
的内容是'赵云'
,该版本的trx_id
值为200
,也在m_ids
列表内,所以也不符合要求,继续跳到下一个版本; - 下一个版本的列
name
的内容是'张飞'
,该版本的trx_id
值为100
,而m_ids
列表中是包含值为100
的事务id
的,所以该版本也不符合要求,同理下一个列name
的内容是'关羽'
的版本也不符合要求。继续跳到下一个版本; - 下一个版本的列
name
的内容是'关羽'
,该版本的trx_id
值为100
,而m_ids
列表中是包含值为100
的事务id
的,所以该版本也不符合要求,同理下一个列name
的内容是'关羽'
的版本也不符合要求。继续跳到下一个版本; - 下一个版本的列
name
的内容是'刘备'
,该版本的trx_id
值为80
,小于ReadView
中的min_trx_id
值100
,所以这个版本是符合要求的,最后返回给用户的版本就是这条列c
为'刘备'
的记录。
从上面过程可以总结出:使用REPEATABLE READ
隔离级别的事务在查询时,仅会使用第一次select
时生成的ReadView
,相比READ COMMITTED
隔离级别每次查询时都会生成一个ReadView
,REPEATABLE READ
隔离级别查询时使用的ReadView
版本会没那么新,因此有些最新UPDATE
并已经提交的事务对记录做的修改操作对查询事务就会不可见(避免了不可重复读现象的产生),因此REPEATABLE READ
隔离级别可以同时避免脏读和不可重复读。
4、MySQL是如何解决幻读的
上面介绍了MySQL
针对读事务是如何解决脏读和不可重复读,而InnoDB
存储引擎RR
事务隔离级别下幻读也不会发生,那是怎么做到的呢?先说结论:在**RR**
的隔离级别下,**InnoDB**
使用**MVCC**
和**next-key locks**
解决幻读,**MVCC**
解决的是普通读(快照读)的幻读,**next-key locks**
解决的是当前读情况下的幻读。
当前读是指加锁(
S
锁或者X
锁)的SELECT
、UPDATE
和DELETE
等语句
4.1 InnoDB解决快照读的幻读
RR
事务隔离级别下,对一条记录进行增删改查操作对应如下:
**SELECT**
:会从最新记录开始遍历版本链,遇到同时满足下面条件的**undo**
记录会返回:- 版本链中
**undo**
记录的**trx_id**
小于或者等于当前读事务的**id**
;(意味着数据在这个事务之前被创建) **undo**
记录中的删除版本号为空或者删除版本号大于当前事务**id**
。(意味着删除操作在这个事务之后发生)
- 版本链中
**INSERT**
:将当前事务的id
保存至undo
日志的trx_id
;**UPDATE**
:会做以下两件事:- 新插入一行
undo
日志,并且新插入的undo
日志的trx_id
为当前事务的id
,新插入的undo
记录的值是更新后的; - 同时将原
undo
日志的记录行的删除版本号设置为当前事务的id
。
- 新插入一行
**DELETE**
:将当前事务的id
保存至undo
日志对应的删除版本号中。
比如我插入一条记录,事务id 假设是1,那么记录如下:
也就是说,创建版本号就是事务版本号。
id | name | createversion | deleteversion |
---|---|---|---|
1 | wxt | 1 |
如果我更新的话,事务id假设是2。这里是把 name 更新为 taotao,原来的数据 deleteversion 版本号为这个事务的id,并且新增一条
id | name | createversion | deleteversion |
---|---|---|---|
1 | wxt | 1 | 2 |
2 | taotao | 2 |
如果我删除的话,假设事务是id=3。
id | name | createversion | deleteversion |
---|---|---|---|
3 | taotao | 2 | 3 |
就变成现在这个样子。
关键点来了
现在我读取的话,必须同时满足两个条件的:
- 读取创建版本小于或等于当前事务版本号,这意味着数据在这个事务之前被创建。
- 删除版本为空或大于当前事务版本号的记录, 这意味着删除操作在这个事务之后发生。
假设此时数据库的状态:
id | name | createversion | deleteversion |
---|---|---|---|
4 | a | 2 | |
5 | b | 5 |
假设事务A的 id=10,执行这条语句update table set name=“hh” where id>3;
id | name | createversion | deleteversion |
---|---|---|---|
4 | a | 2 | 10 |
5 | b | 5 | 10 |
4 | hh | 10 | |
5 | hh | 10 |
事务B的 id=11,执行insert into table values(11, uu);
id | name | createversion | deleteversion |
---|---|---|---|
4 | a | 2 | 10 |
5 | b | 5 | 10 |
4 | hh | 10 | |
5 | hh | 10 | |
11 | uu | 11 |
最后事务A(id=10)在此读取,select * from table where id>3;
根据上述的规则,读取创建版本号小于等于当前事务的,那么(4,a),(5,b),(4,hh),(5,hh)
。
上面规则的输出作为下面规则的输入的话,删除版本为空或大于当前事务版本号的记录:(4,hh),(5,hh)
。
如此读取就没有读取到事务B新插入的那行,解决幻读。
如果事务B是更新 id=4 的数据 name=cc 呢?
id | name | createversion | deleteversion |
---|---|---|---|
4 | a | 2 | 10 |
5 | b | 5 | 10 |
4 | hh | 10 | 11 |
5 | hh | 10 | |
4 | cc | 11 |
然后根据select
的规则去读取的话,得到的还是(4,hh),(5,hh)
。
4.2 InnoDB解决当前读的幻读
所谓当前读,是指加锁(S
锁或者X
锁)的SELECT
、UPDATE
和DELETE
等语句。在RR事务隔离级别下,InnoDB
会使用行锁中的next-key locks
来锁住本条记录以及间隙,避免其他事务插入新的记录。
举例:RR事务隔离级别下,一个读事务加了X
锁进行如下查询:
SELECT * FROM t WHERE id > 3 FOR UPDATE;
InnoDB
存储引擎会将id=3
这条记录和id>3
的范围间隙加上next-key locks
锁,锁住索引中该记录以及记录id>3
的范围,避免其他事务修改当前记录或删除当前记录,避免其他事务在next-key locks
范围区间插入新的记录,进而避免产生幻影记录。
MySQL锁
面试语录
1. 查询一条记录,基于MVCC,是怎么样的流程?
- 获取事务自己的版本号,即事务ID
- 获取Read View
- 查询得到的数据,然后Read View中的事务版本号进行比较
- 如果不符合Read View的可见性规则,即就需要Undo log中历史快照
- 最后返回符合规则的数据
InnoDB实现MVCC,是通过Read View + Undo log实现的,Undo log保存了历史快照,Read View可见性规则帮助判断当前版本的数据是否可见。