从设计的角度思考MVCC

112 阅读8分钟

1、为什么要有MVCC

我们先看MVCC的全名即:

(Multi-Version Concurrency Control)多版本并发控制。

其出现的目的主要是为了解决在高并发环境下实现对数据库的访问。

如果我们使用传统锁的方式的话,在高并发环境下会造成大量阻塞,是极度低效的一种方法。为了解决这个问题,MVCC出现了。

2、MVCC是怎么工作的

这里主要想阐述的是MVCC为何要如此设计、工作的问题,并不聚焦于具体实现,因此仅简单对其工作流程进行叙述,详细的流程可以参考这篇文章

流程简述

在多个事务并发操作某一行数据的时候,会产生多个版本,为了保证在同一事务内读到的数据是可重复的(解决不可重复读问题),MVCC维护了一条版本链,如下所示:

无标题-2022-10-08-1150.png

在事务A开始时,会为该事务第一个select打上一个快照,其中包含:

  • trx_ids: 当前系统中那些活跃(未提交)的读写事务ID, 它数据结构为一个List。其中不包含事务A。

  • low_limit_id: 下一个将被分配的事务ID

  • up_limit_id: trx_ids中最小的事务ID

访问某条记录的时候如何判断该记录是否可见,具体规则如下:

  • 情况1、如果被访问版本的 事务ID = creator_trx_id,那么表示当前事务访问的是自己修改过的记录,那么该版本对当前事务可见;

  • 情况2、如果被访问版本的 事务ID < up_limit_id,那么表示生成该版本的事务在当前事务生成 ReadView 前已经提交,所以该版本可以被当前事务访问。

  • 情况3、如果被访问版本的 事务ID > low_limit_id 值,那么表示生成该版本的事务在当前事务生成 ReadView 后才开启,所以该版本不可以被当前事务访问。

  • 情况4、如果被访问版本的 事务ID在 up_limit_id和m_low_limit_id 之间,那就需要判断一下版本的事务ID是不是在 trx_ids 列表中,如果在,说明创建 ReadView 时生成该版本的事务还是活跃的,该版本不可以被访问; 如果不在,说明创建 ReadView 时生成该版本的事务已经被提交,该版本可以被访问。

3、MVCC解决了什么

并发:MVCC通过维护版本链的方式,实现了多个事务对同一字段的并发访问。

对于我们经常说的脏读,不可重复读,幻读,MVCC对前两者作了很好的解决。

脏读:脏读指的本事务(事务A,以下都如此代称)是读到别的事务(事务B)未提交的数据,对于MVCC而言,在第一次快照时,就会发现事务B仍处于活跃状态(未提交),因此不会读取该数据。

不可重复读:不可重复读指的是在事务A中前后两次读出的结果不一样(因此叫不可重复读),而事务A在第一次select时作了快照,因此即使在第一次select后事务B修改了,因为第一次快照时事务B是活跃的(未提交),根据上文情况4,并不会读取到事务B修改的数据。

4、当前读/快照读?

提到幻读网上的博客往往会提到 快照读/当前读,摘取其中一篇对其的描述

当前读读取的是数据库记录,都是当前最新的版本。updatedelete等使用的是当前读。

快照读的实现就是基于多版本并发控制,即MVCC,既然是多版本,那么快照读读到的数据不一定是当前的最新的数据,有可能是之前历史版本的数据。select使用的是快照读。

然而很容易给读者造成误解:问题一:当前读读了最新版本,自然会将快照更新,那么这样不是也破坏了先前的可重复读条件?

同样的,根据快照读还可以给出一个问题:问题二:既然当前读可以保证都看到的都是同一个版本,那么无论是select还是update等都使用快照读不就可以了?这样就连幻读都不会产生


事实上,这是对当前读的解释/理解有误差造成的。

我们先摒弃掉之前定义的当前读的概念,想想在A事务中update是怎么做的。

无标题-2022-10-08-1555.png

注意红框标注的,update语句的做法并不是制造新的快照,而是更新了行的版本号! 而该版本号的变化正是导致了幻读的原因!--(原本行版本号是B的,因此不能读取到)

为此,有必要对当前读与快照读作一个更为详细的解释(一家之言并不完善):当前读和快照读都可以读取到数据库实时的信息,然而快照读由于MVCC的原因,会无视掉不符合的版本的信息,就好像看不到最新信息一样;而当前读中的update,delete等,由于其本身会对数据行进行操作,会更改行的版本号,因此能够在之后读取到实时的数据,这里的实时是由操作的性质导致的(修改); 至于当前读中的for update与in share mode操作放到后面再讲。

问题的回答

问题一:如果仔细阅读了上面的片段,那么问题一自然迎刃而解。

问题二:在实现上我们固然能够使update等语句也变为快照读的性质。但是,考虑以下情景:在并发条件下,事务B内删除了一条数据并且提交,之后事务A修改了这条数据。如果使用快照读会发生会发生诡异的情况 “明明B中已经删除了,在它之后由于A中修改了,这条删除了的数据又冒了出来”,这是极度不符合常理的,会造成严重的业务逻辑错误。因此可以作一个小归纳:修改字段的语句由于其本身性质的需要,必须为当前读。

4、如何解决幻读?

幻读的发生其实就是我们在第三小节图示的那般,既然MVCC的当前读无法解决幻读,快照读又会引起逻辑谬误,那么需要怎么做呢?答案是请外援,MVCC本身的机制并不能解决幻读,需要加入锁来协助。 可以采用select ... in share mode或者select ... for update的方式手动加锁。 由于需要手动加锁,因此往往可以看到一句话,RR级别下既可以说解决了幻读问题,也可以说没解决。

解决

既然幻读是因为表中新增了数据导致的,最简单粗暴的方式是锁住整张表,然而这样的方式实在是太粗暴了,会造成大量的性能损耗。

如果对多线程有过了解的话,对锁分段的概念应该不陌生,事实上,mysql中为解决幻读而采取的临界锁的思想与其类似。mysql会将select ... for update(select ... in share mode)涉及到的区间/行加锁,使得别的事务阻塞直到本事务提交。因为只锁一部分,因此对全局的影响不会太大。

不妨假设表上有id值: 0 , 2 ,3 ,4 ,5;那么就有(-∞ ,0],(0,2],(2,3],(3,4],(4,5],(5,+∞]

  • 如果使用如 where id = 1(等值查询),分为两种情况:

    • 查询出没有该字段( where id = 1),则用间隙锁定包含该索引值的最小区间如(0,2],因为别的事务可能会在其间插入id为1的字段导致之后与一开始查到的不一样,即造成幻读。或许这里有同学会疑惑,直接锁id=1不就可以了,但是不存在这一行如何锁定?只能锁定这个区间。

    • 查询出的确有该字段( where id = 2),使用行锁锁定该行,因为已经存在这一行了,只要保障它不会被修改即可

  • 如果使用范围查询 如 where id>0 and id < 4,则会将(2,3],(3,4]全部锁住,这是为了防止其他事务在区间插入数据导致之后与一开始查到的不一样,即发生幻读。

无标题-2022-10-08-1647.png

5、小结

回看标题,我们有必要对全文思路作概况,想想MVCC的设计流程,不妨用出现问题与解决问题的方式来思考

  • 如何高并发访问数据库?=》采取牺牲部分空间的方式,引入MVCC进行版本控制为每个事务与行添加版本号,特定事务只能访问特定行

  • 事务中MVCC的实现?=》我们将事务中的操作分为两类

    • 快照读:主要针对select语句,可以看到实时更新的数据,但是由于快照的原因,会选择性的忽略掉快照之后添加的数据
    • 当前读:针对update,delete等语句,由于其自身会更新字段版本号,因此可以读到实时的数据(以及导致幻读)。
  • 如何规避幻读?=》MVCC本身不能解决,采取临键锁进行帮助,对select涉及到的区间根据具体情况加锁,使得别的事务对这些区间的操作会阻塞到本事务提交。

参考

看一遍就懂:MVCC原理详解

Mysql那些最需要掌握的原理