MVCC(多版本并发控制)实现原理
MVCC(多版本并发控制)实现原理
全称:Multi Version Concurrency Control
是现代数据库(MySQL、Oracle、PostgreSQL等)引擎实现中常用的处理读写冲突的方式,目的在于提高数据库高并发下的吞吐性能。如此一来不同的事务在并发过程中,select操作可以不加锁而是通过MVCC机制读取指定的版本历史记录,并通过一些手段保证读取的记录值符合事务所处的隔离级别,从而解决并发场景下的读写冲突。
下面是一个并发读写的例子,假如两个事务A和B按照如下顺序进行更新和读取操作
事务A | 事务B | |
---|---|---|
1 | ||
2 | select name from users where id = 1 | |
3 | update users set name='xiaoming' where id=1 | |
4 | select name from users where id = 1 | |
5 | ||
6 |
事务A和事务B是并发执行的两个事务,在不加任何干涉的情况下,由于事务B在第3步时对name进行了修改,以致事务A中第2步和第4步中读取到的name不一样,也就是说产生了不可重复读。
在MVCC出现之前,数据库解决该问题的方式是直接加锁,即事务A在执行时对表加个共享锁,在事务A释放锁之前,事务B一直都会处于阻塞转态,直至事务A提交后释放锁,事务B才能执行。
而MVCC正是另外一种解决上述问题的方式,通过读取某行数据的快照来解决不可重复读的
MVCC实现原理
首先,在MySQL中,每个表的记录都有两个隐藏字段(trx_id,roll_pointer,当该表没有主键和非null唯一键时还会有一个row_id字段),而MVCC的实现正是基于数据表中数据的隐藏字段来实现的
-
trx_id:每次对某条索引记录进行修改时,都会把对应的事务id赋值给trx_id隐藏列(每个事务都有一个事务id,且事务id是递增的)
-
roll_pointer:每次对某条聚簇索引记录进行改动时,都会把旧的版本写入到undo日志中,然后这个隐藏列就相当于一个指针,可以通过它找到该记录修改前的信息
-
row_id(非必要)当表中没有主键和非null唯一键时才会有row_id字段,该字段作为隐藏主键
比如我们有一张表users,表只有一条记录如下
id | name |
---|---|
1 | 张三 |
假设我们插入该记录的事务id为50,那么此刻该记录的示意图如下
id | name | trx_id | roll_pointer |
---|---|---|---|
1 | 张三 | 50 | null |
假设之后两个事务id分别为100、200的事务对这条记录进行了update操作,操作流程如下
发生时间编号 | trx 100 | trx 200 |
---|---|---|
1 | begin | |
2 | ||
3 | update users set name='李四' where id=1 | |
commit | ||
4 | begin | |
5 | update users set name='王五' where id=1 | |
6 | commit | |
7 |
每次对记录的修改都会生成一个undo日志(可以简单的理解为历史数据,修改前的数据),新生成的undo日志放在链表的头结点上,经过上述两个事务之后,该记录的版本链如下
id | name | trx_ix | roll_pointer |
---|---|---|---|
1 | 王五 | 200 | |
1 | 李四 | 100 | |
1 | 张三 | 50 | null |
第一行数据为最新数据,下面的行为undo日志,每一个roll_pointer指向下一行数据,也就是修改前的数据
ReadView
对于READ UNCOMMITTED隔离级别的事务来说,每次读取都是读取最新版本的数据。对于使用SERIALIZABLE隔离级别的事务来说,通过加锁的方式来访问记录。而对于READ COMMITTED和REPEATABLE READ隔离级别的事务,我们就用到上面所说的版本链了,核心问题就是:需要判断一下哪个版本对当前事务是可见的,所以InnoDB的设计者就提出了Read View的概念,这个Read View中主要包含了当前系统中所有活跃的事务,所有事务的id存储在一个列表中,暂称为ids。
ReadView内容
-
trx_ids:表示在生成ReadView时当前系统中活跃的事务id列表
-
min_trx_id:表示在生成ReadView时当前系统中活跃的事务中最小事务id,也就是trx_ids列表中的最小值
-
max_trx_id:表示在生成ReadView时,系统应该分配给下一个新事务的id(并不是trx_ids中的最大值,MySQL会为每一个事务分配一个事务id,且事务id是递增的)
-
creator_trx_id:表示生成ReadView的事务的事务id
这样在访问某条记录时,只需要按照下面的步骤来判断哪条记录对当前事务是可见的:
-
如果该记录的trx_id小于ReadView中最小事务min_trx_id,说明该记录是在Read View生成之前提交的,即该条记录对当前事务是可见的,若不小于最小事务id,进行第二步判断
-
该记录的trx_id大于等于ReadView中最大事务max_trx_id,说明该记录是在Read View生成之后提交的,即该条记录对当前事务是不可见的,如不大于最大事务id,进行第三步判断
-
判断该记录的trx_id在最小事务id和最大事务id之间,判断该记录的trx_id是否存在于ids中,如果存在,说明生成Read View时,创建该记录的事务还是活跃的,该记录不可以被访问;如果不在,说明创建Read View时生成该版本记录的事务已经被提交,该版本记录可以被访问。
如果某个版本的记录对当前事务不可见的话,那就顺着版本链找到下一版本的数据,按照上述步骤判断可见性,以此类推,直到版本链的最后一个版本,如果最后一个版本也对当前事务是不可见的,那么该条记录就对该事物不可见,查询结果中就不包含该记录。
假设此时users表数据如下
id | name |
---|---|
1 | 张三 |
假设我们插入该记录的事务id为50,那么此刻该记录的示意图如下
id | name | trx_id | roll_pointer |
---|---|---|---|
1 | 张三 | 50 | null |
此时有两个事务对id为1的记录操作如下
发生时间编号 | 事务A | 事务B 100 |
---|---|---|
1 | ||
2 | begin | |
3 | begin | update users set name='xiaoming' where id=1 |
4 | select name from users where id = 1 | |
5 | rollback | |
6 |
当执行完第三步时,由于事务B对记录进行了更新,此时该条记录的undo log如下
id | name | trx_id | roll_pointer |
---|---|---|---|
1 | xiaoming | 100 | |
1 | 张三 | 50 | null |
事务id只有个第一次执行insert、update、delete时才会分配
在执行第四步时,事务A会生成一个ReadView,假设此时只有事务A和B在执行,这时ReadView的trx_ids为[100],min_trx_id为100,max_trx_id为101,后面就会寻找对该事务可见的undo log,过程如下:
-
从链头开始寻找,链头数据的事务id为100,该事务id在ReadView中的trx_ids中,说明该版本记录生成时,生成该记录的事务(即事务B)还处于活跃状态,该记录对事务A不可见。
-
根据roll_pointer寻找下一条记录,即该记录的trx_id为50,小于ReadView中的trx_ids最小事务id,生成ReadView之前,该条记录的事务已经提交,说明该记录对事务A是可见的,最终查询到的name为张三
在MySQL中,READ COMMITTED和REPEATABLE READ两种隔离级别一个很大的区别就是生成Read View的时机不同
READ COMMITTED中,同一事物的每次查询都会生成Read View,看下面事务
--READ COMMITTED 隔离级别
begin
-- 生成Read View
select * from users where id=1
-- 生成Read View
select * from users where id=1
commit
REPEATABLE READ中,同一事物中只有首次查询会生成Read View
-- REPEATABLE READ 隔离级别
begin
-- 生成Read View
select * from users where id=1
-- 不会再次生成Read View
select * from users where id=1
commit
READ COMMITTED可以解决脏读,不能解决不可重复读和幻读
REPEATABLE READ可以解决脏读和不可重复读,不能解决幻读
ReadView生成策略的差异就是REPEATABLE READ隔离级别解决不可重复读的关键
依然用上面的users表例子
此时users表数据如下
id | name |
---|---|
1 | 张三 |
假设我们插入该记录的事务id为50,那么此刻该记录的示意图如下
id | name | trx_id | roll_pointer |
---|---|---|---|
1 | 张三 | 50 | null |
此时有两个事务对id为1的记录操作如下
发生时间编号 | 事务A 70 | 事务B 100 |
---|---|---|
1 | begin | begin |
2 | select name from users where id = 1 | |
3 | update users set name='xiaoming' where id=1 | |
4 | commit | |
5 | select name from users where id = 1 | |
6 |
当隔离级别为READ COMMITTED时,每次进行查询时都会生成ReadView,在事务A中的第二步时此时ReadView的trx_ids为[70,100],min_trx_id为70,假设max_trx_id为101(不会小于等于100),版本链如下
id | name | trx_id | roll_pointer |
---|---|---|---|
1 | 张三 | 50 | null |
因此此时查询到的name为张三
当执行到第五步时,会再次生成一个ReadView,此时ReadView的trx_ids为[70],min_trx_id为70,假设max_trx_id为101
id | name | trx_id | roll_pointer |
---|---|---|---|
1 | xiaoming | 100 | |
1 | 张三 | 50 | null |
通过可见性判断可知trx_id为100对当前事务可见,因此查询到的name为xiaoming,也就是在同一事务中两次查询得到的结果不一样,即产生了不可重复读
隔离级别为REPEATABLE READ
但当隔离级别为REPEATABLE READ时,此时只会在第一次执行查询时生成ReadView,该ReadView的trx_ids为[70,100],min_trx_id为70,假设max_trx_id为101
第一次查询得到的name为张三,第二次查询时,版本链如下
id | name | trx_id | roll_pointer |
---|---|---|---|
1 | xiaoming | 100 | |
1 | 张三 | 50 | null |
对链头数据进行可见性判断,由于trx_id 100在ReadView中的trx_ids中,因此该记录不可见,判断下一条记录,trx_id 50小于min_trx_id,因此该记录可见,最终查询的结果为张三
两次查询一致,解决了不可重复读