MVCC原理
创建一个表
CREATE TABLE hero(
number INT,
name VARCHAR(100),
country VARCHAR(100),
PRIMARY KEY (number)
)Engine=InnoDB CHARSET=utf8;
向这个表插入一条记录
insert into hero VALUES(1,'刘备','蜀');
版本链
在使用innodb
存储引擎的表时,它的聚集索引记录中都包含下面两个必要的隐藏列(row_id
并不是必要的;在创建的表中有主键时,或者有不允许为NULL
的UNIQUE
键时,都不会包含row_id
列)。
trx_id
:一个事务每次对某条聚集索引记录进行改动时,都会把该事务的事务id赋值给trx_id
隐藏列roll_pointer
:每次对某条聚集索引记录进行改动时,都会把旧的版本写入到undo日志中。这个隐藏列就相当于一个指针,可以通过它找到该记录修改前的信息。
比如,表hero中现有只有一条记录
SELECT * FROM hero;
number | name | country |
---|---|---|
1 | 刘备 | 蜀 |
假设插入该记录的事务id为80,那么此刻该条记录的示意图
实际上insert undo
只在事务回滚时发生作用。当事务提交后,该类型的undo日志就没有用了。
假设之后两个事务id分别为100,200的事务对这条记录进行UPDATE
操作,操作流程如图所示
每对记录进行一次改动,都会记录一条undo日志。每条undo日志也都有一个roll_pointer
属性。(INSERT
操作对应的undo日志没有该属性,因为INSERT
操作的记录并没有更早的版本)。通过这个属性可以将这些undo日志串成一个链表。
在每次更新该记录后,都会将旧值放到一条undo日志中(就算是该记录的一个旧版本)。随着更新次数的增多,所有的版本都会被roll_pointer
属性连接成一个链表,这个链表称为版本链。
版本链的头节点就是当前记录的最新值。另外,每个版本中还包含生成该版本时对应的事务id。
我们会利用这个记录的版本链来控制并发访问相同记录时的行为,这种机制称之为多版本并发控制(MVCC)。
ReadView
对于使用READ UNCOMMITTED
隔离级别的事务来说,由于可以读到未提交事务修改过的记录,所以直接读取记录的最新版本就好了;对于使用SERIALIZABLE
隔离级别的事务来说,innodb规定使用加锁的方式来访问记录;对于使用READ COMMITTED
和REPEATABLE READ
隔离级别的事务来说,都必须保证读到已经提交的事务修改过的记录。
innodb提出了ReadView
(一致性视图)的概念。这个ReadView
包含四个比较重要的内容。
m_ids
:在生成ReadView
时,当前系统中活跃的读写事务的事务id列表min_trx_id
:在生成ReadView
时,当前系统中活跃的读写事务中最小的事务id,也就是m_ids
中的最小值max_trx_id
:在生成ReadView
时,系统应该分配给下一个事务的事务idmax_trx_id
并不是m_ids
中的最大值。事务id是递增分配的。比如现在有事务id分别为1,2,3的这3个事务,之后事务id为3的事务提交了,那么一个新的读事务在生成ReadView
时,m_ids
就包括1,2,min_trx_id
的值就是1,max_trx_id
的值就是4
creator_trx_id
:生成该ReadView
的事务的事务id
只有对表中的记录进行改动时(执行INSERT,DELETE,UPDATE)才会为事务分配唯一的事务id,否则一个事务的事务id值默认为0
有了这个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
时生成该版本的事务已经提交,该版本可以被访问。
如果某个版本的数据对当前事务不可见,那就顺着版本链找到下一个版本的数据,并继续上面的步骤来判断记录的可见性;以此类推,直到版本链的最后一个版本。如果记录的最后一个版本也不可见,就意味着该条记录对当前事务完全不可见,查询结果就不包含该记录。
在mysql中,READ COMMITTED与REPEATABLE READ隔离级别之间一个非常大的区别就是它们生成ReadView的时机不同
READ COMMITTED——每次读取数据前生成一个ReadView
比如,现在系统有两个事务id分别为100,200的事务正在执行
#Transaction 100 | #Transaction 200 |
---|---|
BEGIN; | BEGIN; |
UPDATE hero SET name=‘关羽’ WHERE number=1; | #更新一些别的表记录 |
UPDATE hero SET name=‘张飞’ WHERE number=1; | … |
此时,表hero中number为1的记录对应的版本链如图所示
假设现在有一个使用READ COMMITTED
隔离级别的新事物开始执行(不是事务id为100,200的那两个事务)
# 使用READ COMMITTED隔离级别的事务
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。- 这个新开启的事务并没有对任何记录进行任何改动,所以系统并不会为它分配唯一的事务id,它的事务id默认是0,所以creator_trx_id为0。
- 步骤2,然后从版本链中挑选可见的记录,从图中可以看出,最新版本的name列的内容是“张飞”,该版本的
trx_id
是100,在m_ids
列表中,因此不符合可见性要求,根据roll_pointer
跳到下一个版本。 - 步骤3,下一个版本的name列是“关羽”,该版本的
trx_id
值也是100,也在m_ids
列表内,因此也不符合要求;继续条道下一个版本。 - 步骤4,下一个版本的name列是“刘备”,该版本的
trx_id
值是80,小于ReadVIew
中的min_trx_id
值100,所以这个版本是符合要求的;最后返回给用户的版本就是这条name列为“刘备”的记录
之后,我们把事务id为100的事务进行提交,如下所示
#Transaction 100
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;
#未提交
然后再到刚才使用READ COMMITTED
隔离级别的事务中执行SELECT2
,继续查找这个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
的执行过程如下
- 步骤1,在执行
SELECT
语句时又会单独生成一个ReadView
。该ReadView
的m_ids
列表的内容就是[200](事务id为100的那个事务已经提交了,所以再次生成ReadView
时就没有它了),min_trx_id
为200,max_trx_id
为201,creator_trx_id
为0 - 步骤2,从版本链中挑选可见的记录,从图中可以看出,最新版本的name列的内容时‘诸葛亮’,该版本的
trx_id
值为200,在m_ids
列表中,因此不符合可见性要求;根据roll_pointer
跳到下一个版本 - 步骤3,下一个版本的name列的内容是“赵云”,该版本的
trx_id
值为200,也在m_ids
中;继续跳到下一个版本 - 步骤4,下一个版本的name列是“张飞”,该版本的
trx_id
值为100,小于ReadView
中的min_trx_id
值200,所以这个版本是符合要求的;最后返回给用户的版本就是这条name列为“张飞”的记录
总结一下就是,使用READ COMMITTED隔离级别的事务在每次查询开始时都会生成一个独立的ReadView
REPEATABLE READ——在第一次读取数据时生成一个ReadView
对于使用REPEATABLE READ
隔离级别的事务来说,只会在第一次执行查询语句时生成一个ReadView
,之后的查询就不会重复生成ReadView
。
比如,现在系统中有两个事务id分别为100,200的事务正在执行:
#Transaction 100 | #Transaction 200 |
---|---|
BEGIN; | BEGIN; |
UPDATE hero SET name=‘关羽’ WHERE number=1; | #更新一些别的表记录 |
UPDATE hero SET name=‘张飞’ WHERE number=1; | … |
此时,表hero中的number为1的记录的版本链如图所示
假设现在有一个使用REPEATABLE READ
隔离级别的新事务开始执行
# 使用READ COMMITTED隔离级别的事务
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。- 这个新开启的事务并没有对任何记录进行任何改动,所以系统并不会为它分配唯一的事务id,它的事务id默认是0,所以creator_trx_id为0。
- 步骤2,然后从版本链中挑选可见的记录,从图中可以看出,最新版本的name列的内容是“张飞”,该版本的
trx_id
是100,在m_ids
列表中,因此不符合可见性要求,根据roll_pointer
跳到下一个版本。 - 步骤3,下一个版本的name列是“关羽”,该版本的
trx_id
值也是100,也在m_ids
列表内,因此也不符合要求;继续条道下一个版本。 - 步骤4,下一个版本的name列是“刘备”,该版本的
trx_id
值是80,小于ReadVIew
中的min_trx_id
值100,所以这个版本是符合要求的;最后返回给用户的版本就是这条name列为“刘备”的记录
之后,我们把事务id为100的事务进行提交,如下所示
#Transaction 100
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的记录,如下:
#使用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
的执行过程如下。
- 步骤1,因为当前事务的隔离级别为
REPEATABLE READ
,而之前在执行SELECT1
已经生成过ReadView
了,所以此时直接复用之前的ReadView
。之前的ReadView
的m_ids
列表的内容就是[100,200],min_trx_id
为100,max_trx_id
为201,creator_trx_id
为0 - 步骤2,然后从版本链中挑选可见的记录。从图中可以看出,最新版本的name列内容是“诸葛亮”,该版本的
trx_id
的值为200,在m_ids
列表中,因此不符合可见性要求,根据roll_pointer
跳到下一个版本中 - 步骤3,下一个版本的name列的内容是“赵云”,该版本的
trx_id
值为200,也在m_ids
列表内,因此也不符合要求;继续跳到下一个版本 - 步骤4,下一个版本的name列的内容是“张飞”,该版本的
trx_id
值为100,而m_ids
列表中包含值为100的事务id,所以这个版本也不符合要求。同理,下一个name列的内容是“关羽”的版本也不符合要求;继续跳到下一个版本 - 步骤5,下一个版本的name列的内容是“刘备”,该版本的
trx_id
值为80,小于ReadView
中的min_trx_id
值100,所以这个版本是符合要求的;最后返回给用户的版本就是name列为“刘备”的记录
也就是说,在REPEATABLE READ
隔离级别下,事务的两次查询得到的结果是一样的,记录的name列值都是“刘备”。这就是可重复读的含义。
二级索引与MVCC
只有在聚集索引中才有trx_id
和roll_pointer
隐藏列。如果某个查询语句使用二级索引来执行查询,该如何判断可见性。
比如下面这个事务。
BEGIN;
SELECT name FROM hero WHERE name='刘备';
假设查询优化器决定先到二级索引idx_name
中定位name
值为“刘备”的二级索引记录,那么怎么知道这条二级索引记录对这个查询事务是否可见呢?
判断可见性的过程大致分为下面两步
- 二级索引页面的
Page header
部分有一个名为PAGE_MAX_TRX_ID
的属性,每当对该页面中的记录执行增删改操作时,如果执行该操作的事务的事务id
大于PAGE_MAX_TRX_ID
属性值,就会把PAGE_MAX_TRX_ID
属性设置为执行该操作的事务的事务id
。这也就意味着PAGE_MAX_TRX_ID
属性值代表着修改该二级索引页面的最大事务id
是什么。当SELECT
语句访问某个二级索引记录时,首先会看一下对应的ReadView
的min_trx_id
是否大于该页面的PAGE_MAX_TRX_ID
属性值,如果是,说明该页面中的所有记录都对该ReadView
可见,否则就得执行步骤2,在回表之后再判断可见性 - 利用二级索引记录中的主键值进行回表操作,得到对应的聚集索引记录后再按照前面讲过的方式找到对该
ReadView
可见的第一个版本,然后判断该版本中相应的二级索引列的值是否与利用该二级索引查询时的值相同。本例中就是判断找到的第一个可见版本的name
值是不是“刘备”。如果是,就把这条记录发送到客户端(如果WHERE子句中还有其他搜索条件的话还需继续判断),否则就跳过该记录
MVCC小结
所谓的MVCC指的就是在使用READ COMMITTED,REPEATABLE READ这两种隔离级别的事务执行普通的SELECT操作时,访问记录的版本链的过程。 这样可以使不同事务的读-写,写-读操作并发执行,从而提升系统性能。READ COMMITTED,REPEATABLE READ
这两个隔离级别有一个很大的不同,就是生成ReadView
的实时机不同:READ COMMITTED
在每一次进行普通SELECT
操作前都会生成一个ReadView
;而REPEATABLE READ
只在第一次进行普通SELECT
操作前生成一个ReadView
,之后的查询操作都重复使用这个ReadView
。
只有我们进行普通的SELECT查询时,MVCC才生效。