Mysql MVCC

一、MVCC概述

MVCC,全称Multi-Version Concurrency Control,即多版本并发控制。整个MVCC多并发控制的目的就是为了实现读-写冲突不加锁,提高并发读写性能,而这个读指的就是快照度, 而非当前读,当前读实际上是一种加锁的操作,是悲观锁的实现。 

  • 当前读

  读取的是记录数据的最新版本,并且当前读返回的记录都会加上锁,保证其他事务不会再并发的修改这条记录 

  • 快照读

  读取的是记录数据的可见版本(可能是过期的数据),不用加锁。

总结来说MVCC的好处:

  • 在并发读写数据库时,可以做到在读操作时不用阻塞写操作,写操作也不用阻塞读操作,提高了数据库并发读写的性能

  • 同时还可以解决脏读,幻读,不可重复读等事务隔离问题,但不能解决更新丢失问题

  • MVCC解决读写冲突,悲观锁或者乐观锁解决写写冲突

二、mvcc原理

MVCC的目的就是多版本并发控制,目的是为了解决读写冲突,总的来说MVCC通过保存数据在某个时间点的快照来实现的,意味着在同一个时刻不同事务看到的相同表里的数据可能是不同的(即多版本)。如下示例:事务1和事务3可读到不同的数据快照。

时间点 事务1 事务2 事务3
T1 开始事务 开始事务 开始事务
T2 查询A的账户,金额为100    
T3   修改A的账户,金额从100改为200  
T4   提交事务  
T5 查询A的账户,金额为100   查询A的账户,金额为200

MVCC最大的优点是读不加锁,因此读写不冲突,并发性能好。InnoDB实现MVCC,多个版本的数据可以共存,它的实现原理主要是依赖记录中的 3个隐式字段、undo日志 和Read View 来实现的。

1、隐藏字段

每行记录除了我们自定义的字段外,还有数据库隐式定义的DB_TRX_ID,DB_ROLL_PTR,DB_ROW_ID等字段

  • DB_TRX_ID

  6byte,最近操作(修改/插入)事务ID:记录创建这条记录或者最后一次修改该记录的事务ID

  • DB_ROLL_PTR

  7byte,回滚指针,指向这条记录的上一个版本(存储于rollback segment里)

  • DB_ROW_ID

  6byte,隐含的自增ID(隐藏主键),如果数据表没有主键,InnoDB会自动以DB_ROW_ID产生一个聚簇索引

如上图,DB_ROW_ID是数据库默认为该行记录生成的唯一隐式主键,DB_TRX_ID是当前操作该记录的事务ID,而DB_ROLL_PTR是一个回滚指针,用于配合undo日志,指向上一个旧版本。

2、undo日志

undo log主要分为两种:

  • insert undo log
    代表事务在insert新记录时产生的undo log, 只在事务回滚时需要,并且在事务提交后可以被立即丢弃
  • update undo log
    事务在进行updatedelete时产生的undo log; 不仅在事务回滚时需要,在快照读时也需要;所以不能随便删除,只有在快速读或事务回滚不涉及该日志时,对应的日志才会被purge线程统一清除

因此,对MVCC有帮助的实质是update undo log ,undo log实际上就是存在rollback segment中旧记录链,它的执行流程如下:

比如事务0插入person表一条新记录,name为Jerry, age为24,隐式主键是1,事务ID回滚指针,我们假设为NULL,如下图

 

现在来了一个事务1对该记录的name做出了修改,改为Tom

  • 事务1修改该行数据时,数据库会先对该行加排他锁
  • 然后把该行数据拷贝到undo log中,作为旧记录,既在undo log中有当前行的拷贝副本
  • 拷贝完毕后,修改该行name为Tom,并且修改隐藏字段的事务ID为当前事务1的ID, 我们默认从1开始,之后递增,回滚指针指向拷贝到undo log的副本记录,既表示我的上一个版本就是它
  • 事务提交后,释放锁

又来了个事务2修改person表的同一个记录,将age修改为30岁

  • 事务2修改该行数据时,数据库也先为该行加锁
  • 然后把该行数据拷贝到undo log中,作为旧记录,发现该行记录已经有undo log了,那么最新的旧数据作为链表的表头,插在该行记录的undo log最前面
  • 修改该行age为30岁,并且修改隐藏字段的事务ID为当前事务2的ID, 那就是2,回滚指针指向刚刚拷贝到undo log的副本记录
  • 事务提交,释放锁

从上面,我们就可以看出,不同事务或者相同事务的对同一记录的修改,会导致该记录的undo log成为一条记录版本线性表,既链表,undo log的链首就是最新的旧记录,链尾就是最早的旧记录(该undo log的节点可能是会purge线程清除掉,向图中的第一条insert undo log,其实在事务提交之后可能就被删除丢失了,不过这里为了演示,所以还放在这里)

3、Read View(读视图)

Read View就是事务进行快照读操作的时候生产的读视图(Read View),记录并维护系统当前活跃事务的ID(当每个事务开启时,都会被分配一个ID, 这个ID是递增的,所以最新的事务,ID值越大),所以我们知道 Read View主要是用来做可见性判断的, 即当我们某个事务执行快照读的时候,对该记录创建一个Read View读视图,把它比作条件用来判断当前事务能够看到哪个版本的数据,既可能是当前最新的数据,也有可能是该行记录的undo log里面的某个版本的数据。

Read View遵循一个可见性算法,主要是将要被修改的数据的最新记录中的DB_TRX_ID(即当前事务ID)取出来,与系统当前其他活跃事务的ID去对比(由Read View维护),如果DB_TRX_ID跟Read View的属性做了某些比较,不符合可见性,那就通过DB_ROLL_PTR回滚指针去取出Undo Log中的DB_TRX_ID再比较,即遍历链表的DB_TRX_ID(从链首到链尾,即从最近的一次修改查起),直到找到满足特定条件的DB_TRX_ID, 那么这个DB_TRX_ID所在的旧记录就是当前事务能看见的最新老版本。

我先简化一下Read View,我们可以把Read View简单的理解成有三个全局属性

  • rw_trx_ids
    一个数值列表,用来维护Read View生成时刻系统正活跃的事务ID
  • up_limit_id
    记录rw_trx_ids列表中事务ID最小的ID
  • low_limit_id
    ReadView生成时刻系统尚未分配的下一个事务ID,也就是目前已出现过的事务ID的最大值+1

判断逻辑如下:

  • 首先比较DB_TRX_ID < up_limit_id, 如果小于,则当前事务能看到DB_TRX_ID 所在的记录,如果大于等于进入下一个判断

  • 接下来判断 DB_TRX_ID 大于等于 low_limit_id , 如果大于等于则代表DB_TRX_ID 所在的记录在Read View生成后才出现的,那对当前事务肯定不可见,如果小于则进入下一个判断

  • 判断DB_TRX_ID 是否在活跃事务之中,rw_trx_ids.contains(DB_TRX_ID),如果在,则代表我Read View生成时刻,你这个事务还在活跃,还没有Commit,你修改的数据,我当前事务也是看不见的;如果不在,则说明,你这个事务在Read View生成之前就已经Commit了,你修改的结果,我当前事务是能看见的

四、整体流程

我们在了解了隐式字段undo log, 以及Read View的概念之后,就可以来看看MVCC实现的整体流程是怎么样了。

  • 事务2对某行数据执行了快照读,数据库为该行数据生成一个Read View读视图,假设当前事务ID为2,此时还有事务1事务3在活跃中,事务4事务2快照读前一刻提交更新了,所以Read View记录了系统当前活跃事务1,3的ID,维护在一个列表rw_trx_ids上
事务1事务2事务3事务4
事务开始 事务开始 事务开始 事务开始
修改且已提交
进行中 快照读 进行中  
 
  • Read View不仅仅会通过一个列表rw_trx_ids来维护事务2执行快照读那刻系统正活跃的事务ID,还会有两个属性up_limit_id(记录rw_trx_ids列表中事务ID最小的ID),low_limit_id(记录rw_trx_ids列表中事务ID最大的ID,也有人说快照读那刻系统尚未分配的下一个事务ID也就是目前已出现过的事务ID的最大值+1,我更倾向于后者;所以在这里例子中up_limit_id就是1,low_limit_id就是4 + 1 = 5,rw_trx_ids集合的值是1,3。
  • 我们的例子中,只有事务4修改过该行记录,并在事务2执行快照读前,就提交了事务,所以当前该行数据的undo log如下图所示;我们的事务2在快照读该行记录的时候,就会拿该行记录的DB_TRX_ID去跟up_limit_id,low_limit_idrw_trx_ids进行比较,判断当前事务2能看到该记录的版本是哪个。
  • 所以先拿该记录DB_TRX_ID字段记录的事务ID4去跟Read View的的up_limit_id比较,看4是否小于up_limit_id(1),所以不符合条件,继续判断 4 是否大于等于 low_limit_id(5),也不符合条件,最后判断4是否处于rw_trx_ids中的活跃事务, 最后发现事务ID为4的事务不在当前活跃事务列表中, 符合可见性条件,所以事务4修改后提交的最新结果对事务2快照读时是可见的,所以事务2能读到的最新数据记录是事务4所提交的版本,而事务4提交的版本也是全局角度上最新的版本
  • 也正是Read View生成时机的不同,从而造成RC,RR级别下快照读的结果的不同

五、mvcc示例解析

下面以RR隔离级别为例,结合前文提到的几个问题分别说明。

(1)脏读

时间点 事务1 事务2
T1 开始事务 开始事务
T2   修改A的金额,将金额从100改为200
T3 查询A的金额,为100,避免了脏读  
T4   提交事务

当事务1在T3时刻读取A的余额前,会生成ReadView,由于此时事务2没有提交仍然活跃,因此其事务id一定在ReadView的rw_trx_ids中,因此根据前面介绍的规则,事务B的修改对ReadView不可见。接下来,事务A根据指针指向的undo log查询上一版本的数据,得到A的余额为100。这样事务1就避免了脏读。

(2)不可重复读 

时间点 事务1 事务2
T1 开始事务 开始事务
T2 快照读A账户,为100  
T3   修改A的金额,将金额从100改为200
T4   提交事务
T5 快照读A的金额,为100,避免了不可重复读


当事务1在T2时刻读取A的金额前,会生成ReadView。此时事务2分两种情况讨论,一种是如图中所示,事务已经开始但没有提交,此时其事务id在ReadView的rw_trx_ids中;一种是事务2还没有开始,此时其事务id大于等于ReadView的low_limit_id。无论是哪种情况,根据前面介绍的规则,事务2的修改对ReadView都不可见。当事务1在T5时刻再次读取A的余额时,会根据T2时刻生成的ReadView对数据的可见性进行判断,从而判断出事务2的修改不可见;因此事务1根据指针指向的undo log查询上一版本的数据,得到A的余额为100,从而避免了不可重复读。

(3)幻读 

时间点 事务1 事务2
T1 开始事务 开始事务
T2

查询0<id<5的所有用户的金额

A:100(id=1)

 
T3  

账户表中插入新用户

id=2

T4   提交事务
T5

查询0<id<5的所有用户的金额

A:100(id=1)避免了幻读



MVCC避免幻读的机制与避免不可重复读非常类似。当事务1在T2时刻读取0<id<5的用户余额前,会生成ReadView。此时事务2分两种情况讨论,一种是如图中所示,事务已经开始但没有提交,此时其事务id在ReadView的rw_trx_ids中;一种是事务2还没有开始,此时其事务id大于等于ReadView的low_limit_id。无论是哪种情况,根据前面介绍的规则,事务2的修改对ReadView都不可见。当事务A在T5时刻再次读取0<id<5的用户余额时,会根据T2时刻生成的ReadView对数据的可见性进行判断,从而判断出事务2的修改不可见。因此对于新插入的数据lisi(id=2),事务1根据其指针指向的undo log查询上一版本的数据,发现该数据并不存在,从而避免了幻读。

六、RC,RR级别下的InnoDB快照读有什么不同?

正是Read View生成时机的不同,从而造成RC,RR级别下快照读的结果的不同

  • 在RR级别下的某个事务的对某条记录的第一次快照读会创建一个快照及Read View, 将当前系统活跃的其他事务记录起来,此后在调用快照读的时候,还是使用的是同一个Read View,所以只要当前事务在其他事务提交更新之前使用过快照读,那么之后的快照读使用的都是同一个Read View,所以对之后的修改不可见;
  • 即RR级别下,快照读生成Read View时,Read View会记录此时所有其他活动事务的快照,这些事务的修改对于当前事务都是不可见的。而早于Read View创建的事务所做的修改均是可见
  • 而在RC级别下的,事务中,每次快照读都会新生成一个快照和Read View, 这就是我们在RC级别下的事务中可以看到别的事务提交的更新的原因

总之在RC隔离级别下,是每个快照读都会生成并获取最新的Read View;而在RR隔离级别下,则是同一个事务中的第一个快照读才会创建Read View, 之后的快照读获取的都是同一个Read View。

七、总结

这里介绍了MVCC的原理,而且结合事务的隔离级别RR进行了说明。总的来说,InnoDB实现的RR,通过锁机制、MVCC等,实现了一定程度的隔离性,避免了脏读、不可重复读、幻读、更新覆盖问题。不过需要说明的是,RR虽然避免了幻读问题,但是毕竟不是Serializable,不能保证完全的隔离,比如如果在事务中第一次读取采用非加锁读,第二次读取采用加锁读,则如果在两次读取之间数据发生了变化,两次读取到的结果不一样,因为加锁读时不会采用MVCC。

八、参考资料

1、https://blog.csdn.net/SnailMann/article/details/94724197

2、https://www.cnblogs.com/kismetv/p/10331633.html

原文地址:https://www.cnblogs.com/qtiger/p/14115276.html