讲解多版本控制之前,先说一下结论吧:
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;