MVCC简述

阅读本文前我们需要了解脏写、脏读、不可重复读、幻读 以及事务的隔离级别

在处理多线程读写时,为了保持数据的一致性以及满足事务的各种隔离级别,我们可以通过加锁的方式实现。然而加锁必然导致并发度、效率降低。为了解决这个问题出现了MVCC(多版本并发控制)。正如其名,MVCC就是通过保存记录的多个版本(即保存在不同时间点中记录的快照),可以在不加锁的条件下,实现不阻塞地并发读写操作,且保持数据的一致性。

本文将介绍MVCC的innoDB实现。网上关于这方面的文章很多,基本分为两个版本的说法,两个说法大相径庭,让笔者一段时间内甚是困惑——到底MVCC是怎么被innoDB实现的?下面进行详细描述。

说法一

这个说法也是大部分文章千篇一律的说法。

每行数据隐藏了两列,分别是该行创建时间和删除时间。

这里的时间并非使用物理上的时间表示,使用“时间”的概念是为了区分事务执行的先后顺序,因此“时间”即可使用事务的ID来表示。因为事务ID是递增的,先开始的事务ID小于后开始的事务ID,因此用事务ID足以有能力区分先后关系。

增删改的操作将这样影响记录的创建和删除时间:

  • insert:将新插入的行的创建时间设置为当前的事务ID,随后当前事务ID+1。
  • delete:将要删除的行的删除时间设置为当前的事务ID,随后当前事务ID+1。
  • update:转换成insert + delete。将旧行的删除版本号设置为当前版本号,并将新行insert同时设置创建版本号为当前版本号,随后当前事务ID+1。

当查询时:

  • select:查询到的记录的创建时间要小于当前的事务ID删除时间为空或大于当前事务的ID。这样做是为了保证事务读取的数据是在事务开始前就已经存在的,且未删除或在当前事务之后才被删除的。

这样MVCC就实现了事务的隔离性,同时保证了并发度。

说法二

很多文章把说法一和说法二杂糅起来一起说,刚说完有innoDB为记录增加了创建时间和删除时间两个隐藏字段,又接着说innoDB的为记录添加了三个隐藏字段等等。实际上前者就是说法一的解释,是MVCC的实现的简化说法,创建时间和删除时间也并非是真正的隐藏字段。后者才是innoDB实现MVCC的真正方式,下面进行描述:

隐藏字段和版本链

首先明白innoDB为每条记录添加的真正的隐藏字段是下面三个,实际上还有一个专门的bit用于标明记录是否被删除。

  • DB_TRX_ID:创建这条记录/最后一次更新这条记录的事务ID
  • DB_ROLL_PTR:回滚指针,指向这条记录的上一个版本(存储于undo log里)
  • DB_ROW_ID:隐含的自增ID,如果数据表没有主键,InnoDB会自动以DB_ROW_ID产生一个聚簇索引

版本链:我们重点关注前两个字段,对于DB_TRX_ID是用来存储的每次对某条记录进行修改的时候的事务id,而每次对哪条记录有修改的时候,都会把老版本写入undo日志中。DB_ROLL_PTR就是存了一个指针,它指向这条聚簇索引记录的上一个版本的位置,通过它来获得上一个版本的记录信息。
假设插入了一条记录:
图片说明
如果现在有个事务id是20的事务执行的这条记录的修改语句:

-- 事务id=20
UPDATE t_stu_info SET stu_name='小明' 
WHERE stu_id = 1

则会产生一条新纪录,旧记录会进入undo日志,新纪录的DB_ROLL_PTR将指向旧纪录在undo日志中的地址:
图片说明

由上述例子我们能够理解版本链的概念,即通过指针(链表)的方式能够找到该记录的所有历史版本。

ReadView与MVCC的实现

MVCC就是靠版本链和ReadView实现的。ReadView中包含几个数据结构:

  • m_ids:未提交的事务id列表
  • low_limit_id:m_ids里最小的值,即最早的未提交的事务ID
  • up_limit_id:下一次生成事务ID最大值,即当前事务的最大ID+1
  • creator_trx_id,创建read view的事务ID,也就是自己的事务ID

我们最主要通过m_ids列表来了解MVCC是怎么实现的。在事务A开始时我们会创建一个ReadView,里面的m_ids数组包含了当前未提交的所有其他事务的ID。当事务A要开始查询时,从记录的最新版本开始找起,如果:

  • 记录的DB_TRX_ID < m_ids列表中最小的事务ID(low_limit_id),说明该条记录在事务A开始前就已经提交了,因此取该记录。
  • 记录的DB_TRX_ID在low_limit_id和up_limit_id之间,则判断一下该条记录的DB_TRX_ID是否在m_ids中。如果在:说明该条记录现在还未提交,故不取,继续沿着版本链找上个版本的记录;如果不在:说明该条记录在事务A之前已经提交,故取该记录。
  • 记录的DB_TRX_ID > up_limit_id,说明这个版本是在事务A的ReadView生成之后再生成的,不取该记录,沿着版本链找上个版本的记录。因为根本无法判断该事务是否已经提交。

如果事务A继续执行,期间有其他事务也对数据进行了修改操作,之后事务A又进行一次查询,那么
如果事务A是可重复读隔离级别:则事务A再继续使用最开始生成的ReadView进行查询,因此其查询到的版本必然是和最开始查询到的结果一样的。
如果事务A是读提交隔离级别:则事务A在执行新的一次查询时会重新生成ReadView。这意味着两次查询间被提交了的记录,将会被第二次查询所接受,因为ReadView的本质就是帮助我们取ReadView生成前的已经提交了的记录。用新生成的ReadView进行查询时会接受第一次和第二次查询中被提交的记录,即不可重复读的场景。

下面用一幅图来总结innoDB如何用ReadView和版本链来实现MVCC:
图片说明

比较&辨析

对比阅读了网上很多资料后,说法一和说法二实质上有如下联系和区别:

  • 说法一实质上是说法二中的可重复读隔离级别的简化解释:说法一的本质是:保证事务读取到数据是在事务开始前就已经存在的,且未删除或在当前事务之后才被删除的。这就是可重复读的概念。我们知道读提交是不满足这样的定义的——读提交可以读到在本事务执行过程中被提交的数据。
  • 对于说法一,寻找的记录是创建时间小于等于当前事务ID,实质上就是简化了说法二中“沿着记录的版本链,找到DB_TRX_ID字段不在m_ids列表中、且小于up_limit_id的记录”的操作。且说法一并没能给出如何避免脏读,即读到未提交的记录的解决方案,而根据说法二是绝对能保证避免脏读的。
    至于说法一中要求查询到的记录“删除时间为空、或大于当前的事务ID”,实质上根据说法二中介绍的隐藏字段,通过DB_TRX_ID字段和删除标识位就可以实现。
  • 对于网上部分对说法二中ReadView的解释,认为查询时,记录的DB_TRX_ID > m_ids中最大的事务ID,则不获取该记录。在参考了更多的资料以及根据推断,应该是不对的。m_ids中记录的是ReadView生成前所有的未提交记录。假设ReadView生成前事务9未提交,而事务10已提交。当前ReadView生成的事务的ID为11。那么根据这种解释,将不会读到事务10的修改,因为m_ids的最大事务ID是9。但此时的事务ID已经是11,理应对已提交的事务10可见。
    因此查询时应该是记录的DB_TRX_ID与up_limit_id的比较——若DB_TRX_ID > up_limit_id,即记录最后被修改的事务ID比ReadView生成时刻的最大事务ID还大,才应该对当前事务是不可见的。

总结

MVCC通过版本链和ReadView的生成,实现了对数据库的并发读写,且最多能保持到可重复读的隔离级别。ReadView生成策略的不同将决定事务是读提交还是可重复读的隔离级别。对于MVCC的innoDB实现网上会有两种主要的说法,上文做了详细的对比与辨析。

参考

https://blog.csdn.net/chosen0ne/article/details/18093187
https://www.jianshu.com/p/8845ddca3b23
https://baijiahao.baidu.com/s?id=1629409989970483292&wfr=spider&for=pc
https://wenku.baidu.com/view/69d5c129192e45361066f5fb.html
https://www.cnblogs.com/stevenczp/p/8018986.html
https://zhuanlan.zhihu.com/p/352470916