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