欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

MVCC(多版本并发控制)实现原理

程序员文章站 2022-06-14 19:11:06
...

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 COMMITTEDREPEATABLE 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

这样在访问某条记录时,只需要按照下面的步骤来判断哪条记录对当前事务是可见的:

  1. 如果该记录的trx_id小于ReadView中最小事务min_trx_id,说明该记录是在Read View生成之前提交的,即该条记录对当前事务是可见的,若不小于最小事务id,进行第二步判断

  2. 该记录的trx_id大于等于ReadView中最大事务max_trx_id,说明该记录是在Read View生成之后提交的,即该条记录对当前事务是不可见的,如不大于最大事务id,进行第三步判断

  3. 判断该记录的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,过程如下:

  1. 从链头开始寻找,链头数据的事务id为100,该事务id在ReadView中的trx_ids中,说明该版本记录生成时,生成该记录的事务(即事务B)还处于活跃状态,该记录对事务A不可见。

  2. 根据roll_pointer寻找下一条记录,即该记录的trx_id为50,小于ReadView中的trx_ids最小事务id,生成ReadView之前,该条记录的事务已经提交,说明该记录对事务A是可见的,最终查询到的name为张三

 

 

在MySQL中,READ COMMITTEDREPEATABLE 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,因此该记录可见,最终查询的结果为张三

两次查询一致,解决了不可重复读

相关标签: mysql mvcc