作者:jiayangchen

Young的博客(公众号)

提醒:喜欢记得右下角关注一波

本文详细的介绍了什么是 MVCC?为什么要有MVCC?以及MVCC的内部实现原理:包括Undo Log的版本链是如何组织的,RR、RC两个级别下一致性读是如何实现的等。通过案例、插图,以最通俗易懂的方式,让你彻底掌握MVCC的来龙去脉。

1 什么是MVCC

​MVCC (Multiversion Concurrency Control)​​​  中文全称叫 多版本并发控制 ,是现代数据库(包括  ​​MySQL​​​ 、 ​​Oracle​​​ 、 ​​PostgreSQL​​​  等)引擎实现中常用的处理读写冲突的手段, 目的在于提高 ​​数据库​​ 高并发场景下的吞吐性能 。

如此一来不同的事务在并发过程中, ​​SELECT​​​  操作可以不加锁而是通过  ​​MVCC​​  机制读取指定的版本历史记录,并通过一些手段保证保证读取的记录值符合事务所处的隔离级别,从而解决并发场景下的读写冲突。

下面举一个多版本读的例子,例如两个事务  ​​A​​​  和  ​​B​​  按照如下顺序进行更新和读取操作

MVCC 专题_隔离级别

在事务  ​​A​​​  提交前后,事务  ​​B​​​  读取到的  ​​x​​​  的值是什么呢?答案是:事务  ​​B​​  在不同的隔离级别下,读取到的值不一样。

  1. 如果事务  ​​B​​  的隔离级别是读未提交(RU),那么两次读取均读取到  ​​x​​  的最新值,即  ​​20​​ 。
  2. 如果事务  ​​B​​  的隔离级别是读已提交(RC),那么第一次读取到旧值  ​​10​​ ,第二次因为事务  ​​A​​  已经提交,则读取到新值 20。
  3. 如果事务  ​​B​​  的隔离级别是可重复读或者串行(RR,S),则两次均读到旧值  ​​10​​ ,不论事务  ​​A​​  是否已经提交。

可见在不同的隔离级别下,数据库通过  ​​MVCC​​  和隔离级别,让事务之间并行操作遵循了某种规则,来保证单个事务内前后数据的一致性。

2  为什么需要MVCC

​InnoDB​​​  相比  ​​MyISAM​​  有两大特点,一是支持事务而是支持行级锁,事务的引入带来了一些新的挑战。相对于串行处理来说,并发事务处理能大大增加数据库资源的利用率,提高数据库系统的事务吞吐量,从而可以支持可以支持更多的用户。但并发事务处理也会带来一些问题,主要包括以下几种情况:

  1. 更新丢失( ​​Lost Update​​ ):当两个或多个事务选择同一行,然后基于最初选定的值更新该行时,由于每个事务都不知道其他事务的存在,就会发生丢失更新问题 —— 最后的更新覆盖了其他事务所做的更新。如何避免这个问题呢,最好在一个事务对数据进行更改但还未提交时,其他事务不能访问修改同一个数据。
  2. 脏读( ​​Dirty Reads​​ ):一个事务正在对一条记录做修改,在这个事务并提交前,这条记录的数据就处于不一致状态;这时,另一个事务也来读取同一条记录,如果不加控制,第二个事务读取了这些尚未提交的脏数据,并据此做进一步的处理,就会产生未提交的数据依赖关系。这种现象被形象地叫做  “脏读” 。
  3. 不可重复读( ​​Non-Repeatable Reads​​ ):一个事务在读取某些数据已经发生了改变、或某些记录已经被删除了!这种现象叫做“不可重复读”。
  4. 幻读( ​​Phantom Reads​​ ):一个事务按相同的查询条件重新读取以前检索过的数据,却发现其他事务插入了满足其查询条件的新数据,这种现象就称为  “幻读” 。

以上是并发事务过程中会存在的问题,解决更新丢失可以交给应用,但是后三者需要数据库提供事务间的隔离机制来解决。 实现隔离机制的方法主要有两种 :

  1. 加读写锁
  2. 一致性快照读,即  ​​MVCC​

但本质上,隔离级别是一种在并发性能和并发产生的副作用间的妥协,通常数据库均倾向于采用  ​​Weak Isolation​​ 。

3 InnoDB MVCC实现原理

​InnoDB​​​  中  ​​MVCC​​​  的实现方式为:每一行记录都有两个隐藏列: ​​DATA_TRX_ID​​​ 、 ​​DATA_ROLL_PTR​​ (如果没有主键,则还会多一个隐藏的主键列)。

MVCC 专题_Mysql_02

DATA_TRX_ID

记录最近更新这条行记录的 ​​事务 ID​​​ ,大小为  ​​6​​  个字节

DATA_ROLL_PTR

表示指向该行回滚段 ​​(rollback segment)​​​ 的指针,大小为  ​​7​​​  个字节, ​​InnoDB​​​  便是通过这个指针找到之前版本的数据。该行记录上所有旧版本,在  ​​undo​​  中都通过链表的形式组织。

DB_ROW_ID

行标识(隐藏单调自增  ​​ID​​​ ),大小为  ​​6​​​  字节,如果表没有主键, ​​InnoDB​​​  会自动生成一个隐藏主键,因此会出现这个列。另外,每条记录的头信息( ​​record header​​​ )里都有一个专门的  ​​bit​​​ ( ​​deleted_flag​​ )来表示当前记录是否已经被删除。

3.1 如何组织版本链

关于 Redo Log 和 Undo Log 的相关概念可见之前的文章 InnoDB 中的 redo 和 undo log

上文提到,在多个事务并行操作某行数据的情况下,不同事务对该行数据的 UPDATE 会产生多个版本,然后通过回滚指针组织成一条  ​​Undo Log​​​  链,这节我们通过一个简单的例子来看一下  ​​Undo Log​​​  链是如何组织的, ​​DATA_TRX_ID​​​  和  ​​DATA_ROLL_PTR​​  两个参数在其中又起到什么样的作用。

还是以上文  ​​MVCC​​​  的例子,事务  ​​A​​​  对值  ​​x​​​  进行更新之后,该行即产生一个新版本和旧版本。假设之前插入该行的事务  ​​ID​​​  为  ​​100​​​ ,事务  ​​A​​​  的  ​​ID​​​  为  ​​200​​​ ,该行的隐藏主键为  ​​1​​ 。

MVCC 专题_MVCC_03

事务  ​​A​​  的操作过程为:

  1. 对  ​​DB_ROW_ID = 1​​  的这行记录加排他锁
  2. 把该行原本的值拷贝到  ​​undo log​​  中, ​​DB_TRX_ID​​  和  ​​DB_ROLL_PTR​​  都不动
  3. 修改该行的值这时产生一个新版本,更新  ​​DATA_TRX_ID​​  为修改记录的事务  ​​ID​​ ,将  ​​DATA_ROLL_PTR​​  指向刚刚拷贝到  ​​undo log​​  链中的旧版本记录,这样就能通过  ​​DB_ROLL_PTR​​  找到这条记录的历史版本。如果对同一行记录执行连续的  ​​UPDATE​​ , ​​Undo Log​​  会组成一个链表,遍历这个链表可以看到这条记录的变迁
  4. 记录  ​​redo log​​ ,包括  ​​undo log​​  中的修改

那么  ​​INSERT​​​  和  ​​DELETE​​​  会怎么做呢?其实相比  ​​UPDATE​​​  这二者很简单, ​​INSERT​​​  会产生一条新纪录,它的  ​​DATA_TRX_ID​​​  为当前插入记录的事务  ​​ID​​​ ; ​​DELETE​​​  某条记录时可看成是一种特殊的  ​​UPDATE​​​ ,其实是软删,真正执行删除操作会在  ​​commit​​​  时, ​​DATA_TRX_ID​​​  则记录下删除该记录的事务  ​​ID​​ 。

3.2 如何实现一致性读-ReadView

在  ​​RU​​​  隔离级别下,直接读取版本的最新记录就 OK,对于  ​​SERIALIZABLE​​​  隔离级别,则是通过加锁互斥来访问数据,因此不需要  ​​MVCC​​​  的帮助。因此  ​​MVCC​​​  运行在  ​​RC​​​  和  ​​RR​​​ 这两个隔离级别下,当  ​​InnoDB​​​  隔离级别设置为二者其一时,在  ​​SELECT​​  数据时就会用到版本链

核心问题是版本链中哪些版本对当前事务可见?

​InnoDB​​​  为了解决这个问题,设计了  ​​ReadView​​ (可读视图)的概念。

3.2.1 RR下ReadView的生成

在  ​​RR​​​  隔离级别下,每个事务  ​​touch first read​​​  时(本质上就是执行第一个  ​​SELECT​​​ 语句时,后续所有的  ​​SELECT​​​  都是复用这个  ​​ReadView​​​ ,其它  ​​update​​​ ,  ​​delete​​​ ,  ​​insert​​​  语句和一致性读  ​​snapshot​​​  的建立没有关系),会将当前系统中的所有的活跃事务拷贝到一个列表生成 ​​ ReadView​​ 。

下图中事务  ​​A​​​  第一条  ​​SELECT​​​  语句在事务  ​​B​​​  更新数据前,因此生成的  ​​ReadView​​​  在事务  ​​A​​​  过程中不发生变化,即使事务  ​​B​​​  在事务  ​​A​​​  之前提交,但是事务  ​​A​​​  第二条查询语句依旧无法读到事务  ​​B​​  的修改。

MVCC 专题_MVCC_04

下图中,事务  ​​A​​​  的第一条  ​​SELECT​​​  语句在事务  ​​B​​​  的修改提交之后,因此可以读到事务  ​​B​​​ 的修改。 但是注意,如果事务  ​​A​​​  的第一条  ​​SELECT​​​  语句查询时,事务  ​​B​​​  还未提交,那么事务  ​​A​​​  也查不到事务  ​​B​​  的修改。

MVCC 专题_MVCC_05

3.2.2 RC下ReadView的生成

在  ​​RC​​​  隔离级别下,每个  ​​SELECT​​​  语句开始时,都会重新将当前系统中的所有的活跃事务拷贝到一个列表生成  ​​ReadView​​​ 。二者的区别就在于生成  ​​ReadView​​​  的时间点不同,一个是事务之后第一个  ​​SELECT​​​  语句开始、一个是事务中每条  ​​SELECT​​  语句开始。

​ReadView​​​  中是当前活跃的事务  ​​ID​​​  列表,称之为  ​​m_ids​​​ ,其中最小值为  ​​up_limit_id​​​ ,最大值为  ​​low_limit_id​​​ ,事务  ​​ID​​​  是事务开启时  ​​InnoDB​​​  分配的,其大小决定了事务开启的先后顺序,因此我们可以通过  ​​ID​​  的大小关系来决定版本记录的可见性,具体判断流程如下:

  1. 如果被访问版本的  ​​trx_id​​  小于  ​​m_ids​​  中的最小值  ​​up_limit_id​​ ,说明生成该版本的事务在  ​​ReadView​​  生成前就已经提交了,所以该版本可以被当前事务访问。
  2. 如果被访问版本的  ​​trx_id​​  大于  ​​m_ids​​  列表中的最大值  ​​low_limit_id​​ ,说明生成该版本的事务在生成  ​​ReadView​​  后才生成,所以该版本不可以被当前事务访问。需要根据  ​​Undo Log​​  链找到前一个版本,然后根据该版本的 DB_TRX_ID 重新判断可见性。
  3. 如果被访问版本的  ​​trx_id​​  属性值在  ​​m_ids​​  列表中最大值和最小值之间(包含),那就需要判断一下  ​​trx_id​​  的值是不是在  ​​m_ids​​  列表中。如果在,说明创建  ​​ReadView​​  时生成该版本所属事务还是活跃的,因此该版本不可以被访问,需要查找 Undo Log 链得到上一个版本,然后根据该版本的  ​​DB_TRX_ID​​  再从头计算一次可见性;如果不在,说明创建  ​​ReadView​​  时生成该版本的事务已经被提交,该版本可以被访问。
  4. 此时经过一系列判断我们已经得到了这条记录相对  ​​ReadView​​  来说的可见结果。此时,如果这条记录的  ​​delete_flag​​  为  ​​true​​ ,说明这条记录已被删除,不返回。否则说明此记录可以安全返回给客户端。

MVCC 专题_MVCC_06

4 举个例子

4.1 RC下的MVCC判断流程

我们现在回看刚刚的查询过程,为什么事务  ​​B​​​  在  ​​RC​​​  隔离级别下,两次查询的  ​​x​​​  值不同。 ​​RC​​​  下  ​​ReadView​​  是在语句粒度上生成的。

当事务  ​​A​​​  未提交时,事务  ​​B​​​  进行查询,假设事务  ​​B​​​  的事务  ​​ID​​​  为  ​​300​​​ ,此时生成  ​​ReadView​​​  的  ​​m_ids​​​  为 [200,300],而最新版本的  ​​trx_id​​​  为  ​​200​​​ ,处于  ​​m_ids​​​ 中,则该版本记录不可被访问,查询版本链得到上一条记录的 trx_id 为  ​​100​​​ ,小于  ​​m_ids​​​ 的最小值  ​​200​​​ ,因此可以被访问,此时事务  ​​B​​​  就查询到值  ​​10​​​  而非  ​​20​​ 。

待事务  ​​A​​​  提交之后,事务  ​​B​​​  进行查询,此时生成的  ​​ReadView​​​  的  ​​m_ids​​​  为 [300],而最新的版本记录中  ​​trx_id​​​  为  ​​200​​​ ,小于  ​​m_ids​​​  的最小值  ​​300​​​ ,因此可以被访问到,此时事务  ​​B​​​  就查询到  ​​20​​ 。

4.2 RR下的MVCC判断流程

如果在  ​​RR​​​  隔离级别下,为什么事务  ​​B​​​  前后两次均查询到  ​​10​​​  呢? ​​RR​​​  下生成  ​​ReadView​​​  是在事务开始时,m_ids 为 [200,300],后面不发生变化,因此即使事务  ​​A​​​  提交了, ​​trx_id​​​  为  ​​200​​​  的记录依旧处于  ​​m_ids​​​  中,不能被访问,只能访问版本链中的记录  ​​10​​ 。

5 一个争论点

其实并非所有的情况都能套用  ​​MVCC​​​  读的判断流程, 特别是针对在事务进行过程中,另一个事务已经提交修改的情况下 ,这时不论是  ​​RC​​​  还是  ​​RR​​​ ,直接套用  ​​MVCC​​​  判断都会有问题,例如  ​​RC​​  下:

MVCC 专题_Mysql_07

事务  ​​A​​​  的  ​​trx_id = 200​​​ ,事务  ​​B​​​  的  ​​trx_id = 300​​​ ,且事务  ​​B​​​  修改了数据之后在事务  ​​A​​​  之前提交,此时  ​​RC​​​  下事务  ​​A​​​  读到的数据为事务  ​​B​​​  修改后的值,这是很显然的。下面我们套用下  ​​MVCC​​​  的判断流程,考虑到事务  ​​A​​​  第二次  ​​SELECT​​​  时, ​​m_ids​​​  应该为 [200],此时该行数据最新的版本  ​​DATA_TRX_ID = 300​​​  比  ​​200​​​  大,照理应该不能被访问,但实际上事务  ​​A​​  选取了这条记录返回。

这里其实应该结合  ​​RC​​​  的本质来看, ​​RC​​​  的本质就是事务中每一条  ​​SELECT​​  语句均可以看到其他已提交事务对数据的修改,那么只要该事物已经提交其结果就是可见的,与这两个事务开始的先后顺序无关, 不完全适用于 MVCC 读 。

​RR​​  级别下还是用之前那张图:

MVCC 专题_MVCC_05

这张图的流程中,事务  ​​B​​​  的  ​​trx_id = 300​​​  比事务  ​​A​​​   ​​200​​​  小,且事务  ​​B​​​  先于事务  ​​A​​​  提交,按照  ​​MVCC​​​  的判断流程,事务  ​​A​​​  生成的  ​​ReadView​​​  为 [200],最新版本的行记录  ​​DATA_TRX_ID = 300​​​  比  ​​200​​​  大,照理不能访问到,但是事务  ​​A​​​  实际上读到了事务  ​​B​​​  已经提交的修改。这里还是结合  ​​RR​​​  本质进行解释, ​​RR​​​  的本质是从第一个  ​​SELECT​​​  语句生成  ​​ReadView​​  开始,任何已经提交过的事务的修改均可见。

6  写在最后

​RC​​​ 、 ​​RR​​​  两种隔离级别的事务在执行普通的读操作时,通过访问版本链的方法,使得事务间的读写操作得以并发执行,从而提升系统性能。 ​​RC​​​ 、 ​​RR​​​  这两个隔离级别的一个很大不同就是生成  ​​ReadView​​​  的时间点不同, ​​RC​​​  在每一次  ​​SELECT​​​  语句前都会生成一个  ​​ReadView​​​ ,事务期间会更新,因此在其他事务提交前后所得到的  ​​m_ids​​​  列表可能发生变化,使得先前不可见的版本后续又突然可见了。而  ​​RR​​​  只在事务的第一个  ​​SELECT​​​  语句时生成一个  ​​ReadView​​ ,事务操作期间不更新。

 ​​https://www.codercto.com/a/88775.html​

 

 

 

 

 

在并发读写数据库时,读操作可能会不一致的数据(脏读)。为了避免这种情况,需要实现数据库的并发访问控制,最简单的方式就是加锁访问。由于,加锁会将读写操作串行化,所以不会出现不一致的状态。但是,读操作会被写操作阻塞,大幅降低读性能。在java concurrent包中,有copyonwrite系列的类,专门用于优化读远大于写的情况。而其优化的手段就是,在进行写操作时,将数据copy一份,不会影响原有数据,然后进行修改,修改完成后原子替换掉旧的数据,而读操作只会读取原有数据。通过这种方式实现写操作不会阻塞读操作,从而优化读效率。而写操作之间是要互斥的,并且每次写操作都会有一次copy,所以只适合读大于写的情况。

MVCC的原理与copyonwrite类似,全称是Multi-Version Concurrent Control,即多版本并发控制。在MVCC协议下,每个读操作会看到一个一致性的snapshot,并且可以实现非阻塞的读。MVCC允许数据具有多个版本,这个版本可以是时间戳或者是全局递增的事务ID,在同一个时间点,不同的事务看到的数据是不同的。

实现原理: 

------------------------------------------------------------------------------------------> 时间轴

|-------R(T1)-----|

|-----------U(T2)-----------|

如上图,假设有两个并发操作R(T1)和U(T2),T1和T2是事务ID,T1小于T2,系统中包含数据a = 1(T1),R和W的操作如下:

R:read a (T1)

U:a = 2    (T2)

R(读操作)的版本T1表示要读取数据的版本,而之后写操作才会更新版本,读操作不会。在时间轴上,R晚于U,而由于U在R开始之后提交,所以对于R是不可见的。所以,R只会读取T1版本的数据,即a = 1。

由于在update操作提交之前,不能影响已有数据的一致性,所以不会改变旧的数据,update操作会被拆分成insert + delete。需要标记删除旧的数据,insert新的数据。只有update提交之后,才会影响后续的读操作。而对于读操作而且,只能读到在其之前的所有的写操作,正在执行中的写操作对其是不可见的。

上面说了一堆的虚的理论,下面来点干活,看一下​​MySQL​​的innodb引擎是如何实现MVCC的。innodb会为每一行添加两个字段,分别表示该行创建的版本删除的版本,填入的是事务的版本号,这个版本号随着事务的创建不断递增。在repeated read的隔离级别(​​事务的隔离级别请看这篇文章​​)下,具体各种数据库操作的实现:

select:满足以下两个条件innodb会返回该行数据:(1)该行的创建版本号小于等于当前版本号,用于保证在select操作之前所有的操作已经执行落地。(2)该行的删除版本号大于当前版本或者为空。删除版本号大于当前版本意味着有一个并发事务将该行删除了。

insert:将新插入的行的创建版本号设置为当前系统的版本号。

delete:将要删除的行的删除版本号设置为当前系统的版本号。

update:不执行原地update,而是转换成insert + delete。将旧行的删除版本号设置为当前版本号,并将新行insert同时设置创建版本号为当前版本号。

其中,写操作(insert、delete和update)执行时,需要将系统版本号递增。

由于旧数据并不真正的删除,所以必须对这些数据进行清理,innodb会开启一个后台线程执行清理工作,具体的规则是将删除版本号小于当前系统版本的行删除,这个过程叫做purge。

通过MVCC很好的实现了事务的隔离性,可以达到repeated read级别,要实现serializable还必须加锁。