讲解多版本控制之前,先说一下结论吧:
mvcc
指的就是在使用READ COMMITTD
、REPEATABLE READ
这两种隔离级别的事务在执行普通的SELECT
操作时访问记录的版本链的过程,这样子可以使不同事务的读-写
、写-读
操作并发执行,从而提升系统性能;READ COMMITED
和REPEATABLE READ
两个隔离级别的不同在于生成READ VIEW
的时机不同:READ COMMITED
在每一次进行普通的select
时,都会重新生成一个READ VIEW
;REPEATABLE READ
只在第一次进行普通的select
时,生成一个READ VIEW
,之后的查询操作都重复使用这一个READ VIEW
;
准备工作
先创建一个表:
CREATE TABLE hero (
num INT,
name VARCHAR(100),
country varchar(100),
PRIMARY KEY (number)
) Engine=InnoDB CHARSET=utf8;
这里将hero
表的主键命名为num,而不是
id,主要是为了与后面要用到的
事务id`做区别。
版本链
对于使用InnoDB
存储引擎的表来说,其聚簇索引的记录中都包含两个必要的隐藏列(PS:row_id
非必要,当表中有主键或者非NULL
的UNIQUE
键时,row_id
就不会以隐藏列的形式存在了):
trx_id
:每次一个事务对某条聚簇索引记录进行修改时,都会把该事务的事务id
赋值给trx_id
隐藏列;roll_pointer
:每次对一个聚簇索引记录进行修改时,都会把旧版本写入undo
日志中,然后这个隐藏列就相当于一个指针,可以通过它来找到该记录修改前的信息;
我们先插入一条记录:
INSERT INTO `hero`(`num`,`name`,`country`) VALUES(1,'刘备','蜀');
假设插入该记录的事务id
为80,那么此刻该条记录的示意图如下所示:
需要注意的是,insert undo
只在事务回滚时发挥作用,当事务提交后,该类型的undo
日志就没用了,随之会被回收。但是,虽然被回收了,但roll_pointer
的值并不会被清除,roll_pointer
属性占用7个字节,第一个比特位就标记着它指向的undo
日志类型。如果比特位为1,就代表着它指向的undo
日志类型为insert undo
。
假设现在有两个事务id
分别为100、200的事务对这条记录进行UPDATE
操作,流程如下:
先后顺序 | trx_100 | trx_200 |
---|---|---|
1 | begin; | |
2 | begin; | |
3 | update hero set name='关羽' where num=1; | |
4 | update hero set name='张飞' where num=1; | |
5 | commit; | |
6 | update hero set name='赵云' where num=1; | |
7 | update hero set name='诸葛亮' where num=1; | |
8 | commit |
每次使用update
对记录进行改动时,都会记录一条undo
日志,每条undo
日志也都有一个roll_pointer
属性(insert
操作对应的undo
日志没有该属性,因为该记录并没有最早的版本),可以将这些undo
日志连接起来形成一个链表,如下所示:
对该条记录每次更新后,都会将旧值放到一条undo
日志中,随着更新次数的增加,最新的版本加上之前的所有旧版本就会被roll_pointer
属性连接成一个链表,这个链表就是版本链
,版本链的头节点就是当前记录最新的值。
版本链表中的每个版本还包含着生成该版本时对应的事务id
,这个信息我们后面会用到。
READVIEW
对于不同的隔离级别,使用普通的select
读取数据时读取到的数据有所不同:
- 对于
READ UNCOMMITED
级别,由于可以直接读取到未提交事务修改过的记录,所以直接读取记录的最新版本就可以了; - 对于
SERIALIZABLE
级别,INNDODB
内部使用锁机制来保证读取到的数据; - 对于
READ COMMITED
和REPEATABLE READ
级别,都必须保证读到已提交了的事务修改过的记录,也就是说假如另一个事务以及修改了记录但还未提交,是不能直接读取最新版本的记录的,核心问题在于,判断一下版本链的哪个版本是当前事务可见的。
因此,为了解决READ COMMITED
和REPEATABLE READ
级别下读取数据的问题,INNODB
的设计者提出了READVIEW
的概念,READVIEW
中包含以下几个参数:
m_ids
:表示在生成READVIEW
时当前系统中活跃的读写事务的事务id
列表,活跃的是指当前系统中那些尚未提交的事务;min_trx_id
:表示在生成READVIEW
时当前系统中活跃的读写事务中最小的事务id
,也就是m_ids
中的最小值;max_trx_id
:表示生成READVIEW
时系统中应该分配给下一个事务的事务id
值,由于事务id一般是递增分配的,所以max_trx_id
就是m_ids
中最大的那个id再加上1;creator_trx_id
:表示生成该READVIEW
的事务id,由于只有在对表中记录做改动(增删改)时才会为事务分配事务id,所以一个读取数据的事务中的事务id默认为0;
比如,现在有id
分别为1、2和3的三个事务,当事务3提交了之后,如果此时一个新的读事务正在生成READVIEW
,那么m_ids
中就只有1和2,min_trx_id
就为1,max_trx_id
就为4。
有了这个READVIEW
,就可以在访问某条记录时,按照如下的规则进行判断就可以确定版本链中哪个版本对当前读事务是否可见:
- 版本的
trx_id
==READVIEW
中的creator_trx_id
,表示当前读事务正在读取被自己修改过的记录,该版本可以被当前事务访问; - 版本的
trx_id
<min_trx_id
,表明生成该版本的事务在当前事务生成READVIEW
前已经提交了,所以该版本可以被当前事务访问; - 版本的
trx_id
>max_trx_id
,表明生成该版本的事务在当前事务生成READVIEW
后才开启的,该版本不可被当前事务访问; - 版本的
trx_id
在READVIEW
的min_trx_id
和max_trx_id
之间,那就需要判断一下trx_id
属性值是不是在m_ids
中。如果在这个范围内,说明创建READVIEW
时该事务还处于活跃状态,该版本不可以被当前事务访问;如果不在,说明创建READVIEW
时生成该版本的事务已经被提交,该版本可以被当前事务访问;
总结如下:
情况 | 是否可以被当前事务访问 |
---|---|
trx_id==creator_trx_id | 可以 |
trx_id < min_trx_id | 可以 |
trx_id > max_trx_id | 不可以 |
min_trx_id < trx_id < max_trx_id && trx_id in m_ids | 不可以 |
min_trx_id < trx_id < max_trx_id && trx_id not in m_ids | 可以 |
如果某个版本的数据对当前事务不可见的话,那么就顺着版本链找到下一个版本的数据,继续按照上面的规则继续进行判断,以此类推,若是到了最后一个版本,该版本的数据仍对当前事务不可见,那么就表明该条记录对该事务完全不可见,查询结果就不会包含该条记录。
下面说一下,READ COMMITED
和REPEATABLE READ
在生成READVIEW
时的区别:
- 对于
READ COMMITED
级别,每次读取数据前都会生成一个新的READVIEW; - 对于
REPEATABLE READ
级别,在第一次读取数据时生成一个READVIEW,之后的查询都会使用这个READVIEW;