MySQL-InnoDB-MVCC多版本并发控制

Posted c.

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了MySQL-InnoDB-MVCC多版本并发控制相关的知识,希望对你有一定的参考价值。

文章目录

mysql-InnoDB-MVCC多版本并发控制

InnoDB is a multi-version storage engine. It keeps information about old versions of changed rows to support transactional features such as concurrency and rollback.

参考:InnoDB Multi-Versioning

所以意思就是InnoDB是一个多版本的存储引擎,它通过保留记录的旧版本信息来支持事务功能,比如说并发还有回滚。所以这里说的多版本就是我们说的MVCC(Multi-Version Concurrency Control),多版本并发控制。

为什么需要有MVCC?

学习一样东西我们当然是先要问为什么,然后我们才去学原理。那我们为什么需要有MVCC呢?或者说MVCC给我们带来了什么好处?

MVCC 在 MySQL InnoDB 中的实现主要是为了提高数据库并发性能,用更好的方式去处理读-写冲突。这里说的更好的方式是什么呢?常规我们解决并发问题的时候,最常用的方法就是上锁(悲观锁),而MVCC使用更好的方式去做到即使有读写冲突时,也能做到不加锁非阻塞并发读

我们知道读-读是不会有并发问题的,而读-写冲突的话就会出现我们之前博文提到的事务隔离性问题,可能遇到脏读,不可重复读,幻读。而MVCC就是可以做到在读操作时不用阻塞写操作,写操作也不用阻塞读操作,提高了数据库并发读写的性能。也能够解决部分的事务隔离性问题,比如脏读,不可重复读,幻读的话MVCC不能完全解决(++Innodb利用MVCC解决了RR级别下快照读中的幻读问题,当前读中的幻读问题需要使用GAP lock解决,也就是间隙锁++)。而对于写-写冲突的话,MVCC不能解决,写-写冲突会存在更新丢失问题,比如第一类更新丢失,第二类更新丢失。

关于更新丢失的问题,这里就不过多解释了,可以看看:什么是脏读、不可重复读、幻读?

所以总的来说MVCC就是为了解决之前采用悲观锁这种性能不够好的方式去解决读-写冲突而提出来的方案。所以MVCC解决了读-写冲突,而写-写冲突则是用悲观锁或者乐观锁的方式去解决。

什么是当前读和快照读?

前面提到了MVCC是为了解决读写冲突,做到不加锁非阻塞并发读。那MySQL中所有的读都是使用MVCC这种不加锁的方式吗?实际上这里的读指的是快照读。

那我们这里就需要讲一下什么是快照读什么是当前读了。

快照读

我们平时直接使用的不加锁的select就是使用的快照读,即不加锁的非阻塞读;快照读的前提是隔离级别不是串行级别,串行级别下的快照读会退化成当前读;之所以出现快照读的情况,是基于提高并发性能的考虑,快照读的实现是基于 MVCC 。快照读可能读到的并不一定是数据的最新版本,而有可能是之前的历史版本。

当前读

select lock in share mode (共享锁), select for update; update; insert; delete (排他锁)这些操作都是一种当前读,为什么叫当前读?就是它读取的是记录的最新版本,读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁

MVCC 的实现原理

MVCC 的目的就是多版本并发控制,在数据库中的实现,就是为了解决读写冲突,它的实现原理主要是依赖记录中的 3个隐式字段,undo日志 ,Read View 来实现的。

三个隐藏的字段

InnoDB的内部中为每一条记录中添加了三个隐藏的字段。

  1. A 6-byte DB_TRX_ID field indicates the transaction identifier for the last transaction that inserted or updated the row. Also, a deletion is treated internally as an update where a special bit in the row is set to mark it as deleted.

  2. A 7-byte DB_ROLL_PTR field called the roll pointer. The roll pointer points to an undo log record written to the rollback segment. If the row was updated, the undo log record contains the information necessary to rebuild the content of the row before it was updated.

  3. A 6-byte DB_ROW_ID field contains a row ID that increases monotonically as new rows are inserted. If InnoDB generates a clustered index automatically, the index contains row ID values. Otherwise, the DB_ROW_ID column does not appear in any index.

  1. 首先我们来解释一下这个DB_TRX_ID, 我们都知道InnoDB是支持事务的,在innodb中每一个事物创建时都会分配一个自增的ID作为事物为唯一标志,也就是事物ID,而DB_TRX_ID是用来存储创建或者最后一次修改此记录的事物ID。同时在官网中还提到了Also, a deletion is treated internally as an update where a special bit in the row is set to mark it as deleted.这说明MySQL中的删除并不是真的删除,实际还有一个 delete flag 隐藏字段,即记录被更新或删除,这里的删除并不代表立即被物理删除,而是将这条记录的 delete flag 改为 true。最终的删除操作是purge线程完成的

对于MySQL的删除官网是这么解释的:In the InnoDB multi-versioning scheme, a row is not physically removed from the database immediately when you delete it with an SQL statement. InnoDB only physically removes the corresponding row and its index records when it discards the update undo log record written for the deletion. This removal operation is called a purge, and it is quite fast, usually taking the same order of time as the SQL statement that did the deletion.

  1. DB_ROLL_PTR:回滚指针,指向这条记录的上一个版本
  2. DB_ROW_ID:隐含的自增 ID(隐藏主键),如果数据表没有主键,InnoDB 会自动以DB_ROW_ID产生一个聚簇索引

Undo Log

undo log有两个作用:提供回滚多个行版本控制(MVCC)

在数据修改的时候,不仅记录了redo,还记录了相对应的undo,如果因为某些原因导致事务失败或回滚了,可以借助该undo进行回滚。

可以简单的认为当delete一条记录时,undo log中会记录一条对应的insert记录,反之亦然,当update一条记录时,它记录一条对应相反的update记录。

当执行rollback时,就可以从undo log中的逻辑记录读取到相应的内容并进行回滚。有时候应用到行版本控制的时候,也是通过undo log来实现的:当读取的某一行被其他事务锁定时,它可以从undo log中分析出该行记录以前的数据是什么,从而提供该行版本信息,让用户实现非锁定一致性读取。

Undo log中存储的是老版本数据,当一个旧的事务需要读取数据时,为了能读取到老版本的数据,需要顺着undo链找到满足其可见性的记录。

所以undo log 分为下面两类:

  1. insert undo log

    大多数对数据的变更操作包括INSERT/DELETE/UPDATE,其中INSERT操作在事务提交前只对当前事务可见,而且Insert Undo log只在事务回滚时需要,因此产生的Undo日志可以在事务提交后直接删除(因为对刚插入的数据其实是没有可见性需求)。

  2. update undo log

    而对于UPDATE/DELETE则需要维护多版本信息,在InnoDB里,UPDATE和DELETE操作产生的Undo日志被归成一类,即update undo log。update undo log在事务回滚时需要,而且在快照读时也需要,所以需要维护多个版本信息。只有在快照读和事务回滚不涉及该日志时,对应的日志才会被 purge 线程统一删除。purge 线程会清理 undo log 的历史版本,同样也会清理 del flag 标记的记录。

而且从存储结构上来看的话,也是分成了insert undo log 和 update undo log

  1. 对于 insert undo log。存储记录中不含回滚指针DB_ROLL_PTR(没有旧值,insert之前是没有的)

  2. 对于 update undo log。有回滚指针DB_ROLL_PTR,有旧值。

undo log是采用段(segment)的方式来记录的,每个undo操作在记录的时候占用一个undo log segment。innodb存储引擎对undo的管理采用段的方式。rollback segment称为回滚段,每个回滚段中有1024个undo log segment。所以对于MVCC真正有作用的是update undo log,依靠rollback segmentundo log segment构成的旧记录链来实现的

记录链图解

假设我们现在有一张person表,上面有两个字段,一个name一个age。

现在我们有一个事务1插入了一条新记录,记录是name为Kevin,age为18,然后因为没有主键,所以会有一个隐式的主键是1,事务ID我们这里假设是1,然后回滚指针是null

现在来了一个事务2对记录name做出了修改,改成了Jerry

  • 首先事务2修改该记录的时候,数据库首先会对这一行记录加上排它锁,不允许其他事务对它进行修改。
  • 然后会把改行数据拷贝到undo log中,作为旧的记录,也就是在undo log中有旧数据的副本。
  • 完成拷贝之后,才会把该行的数据修改成Jerry,并且将该行记录的隐藏字段的事务ID设置为当前事务ID,也就是2,然后回滚指针指向undo log中的副本记录,指明该行数据的上一个版本
  • 事务提交之后,锁释放

所以一旦对该记录有改动,最新的旧数据副本会在链表头,而最早的旧数据副本会在链尾,这样就构成了一条记录链,当然在这些数据副本也不会一直保存着,前面提到了会有专门的pure线程来删除掉这些不再需要的数据副本。

所以MVCC就是通过这样的记录链去找到旧的数据副本。

Read View 读视图

Read View 就是事务进行快照读操作的时候生产的读视图 (Read View),在该事务执行的快照读的那一刻,会生成数据库系统当前的一个快照,记录并维护系统当前活跃事务的 ID (当每个事务开启时,都会被分配一个 ID , 这个 ID 是递增的,所以最新的事务,ID 值越大)

Read View 主要是用来做可见性判断的, 即当我们某个事务执行快照读的时候(进行普通的select操作的时候),会对该记录创建一个 Read View 读视图,然后用这个Read View 读视图作为判断条件,来判断当前事务能够看到哪个版本的数据,既可能是当前最新的数据,也有可能是该行记录的undo log里面的某个版本的数据。

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

那我们下面就来讨论这个判断条件是什么?

从Mysql的源码来看的话,Read View 简单的理解成有三个全局属性

  • trx_list
    用于维护 Read View 生成时刻系统 正活跃的事务 ID 列表,也就是当前未commit的事务ID的列表

  • up_limit_id
    是 trx_list 列表中事务 ID 最小的 ID

  • low_limit_id
    ReadView 生成时刻系统尚未分配的下一个事务 ID ,也就是 目前已出现过的事务 ID 的最大值 + 1, 也就是系统此刻可分配的事务 ID 的最小值

接下来就是判断逻辑了。

但我们进行select 操作的时候会产生Read View, 这个时候我们需要select出来当前事务可见的数据,这个时候就需要进行逻辑判断了。首先我们会找到这条记录的记录链,也就是我们的undo log,顺着undo log 链去找满足其可见性的记录。 那下面的就是满足的条件了,我们会先从链首获取到一个DB_TRX_ID,然后开始去比较DB_TRX_ID.

  • 首先比较 DB_TRX_ID < up_limit_id , 如果小于,也就是说明DB_TRX_ID在Read View 生成时刻的未commit的事务ID list之前,也就是不在未commit的事务ID list里面,说明DB_TRX_ID已经是commit的事务了,则当前事务肯定是能看到 DB_TRX_ID 所在的记录。如果DB_TRX_ID >= up_limit_id 则进入下面的判断。
  • 接下来判断 DB_TRX_ID >= low_limit_id, 如果大于等于则代表 DB_TRX_ID 所在的记录在 Read View 生成后才出现的,那对当前事务肯定不可见,如果小于则进入下一个判断
  • 接下来判断DB_TRX_ID是否在trx_list中,也就是判断DB_TRX_ID是否在Read View 生成时刻的未commit的事务中,如果在,则代表,Read View生成的时候,DB_TRX_ID事务还是未commit的,为commit的数据,当前事务也是看不到的。如果不在,则说明,你这个事务在Read View生成之前就commit了,那修改的结果,当前事务是能看见的。

如果上面都比较都是不可见的话,那就通过 DB_ROLL_PTR 回滚指针去取出 Undo Log 中的 DB_TRX_ID 再进行上面的比较,直到找到可见的记录

参考来自:CSDN博主「SnailMann」的原创文章。原文链接:https://blog.csdn.net/SnailMann/article/details/94724197

MVCC的整体流程

我们现在在了解过隐藏字段,undo log,以及Read View的概念之后,就可以开始看看MVCC实现的整体流程。

下面有4个事务,在事务2中为某行数据执行了快照读,数据库为该行数据生成一个Read View读视图。事务4在快照读前已经修改和提交了,事务1和事务2都是活跃的事务。

事务1事务2事务3事务4
事务开始事务开始事务开始事务开始
修改且已提交
进行中快照读进行中

根据我们上面的描述,在执行快照读的时候维护了这一时刻所有活跃事务的ID列表,这个列表trx_list,这个trx_list里面现在有活跃的事务1和事务3,所以trx_list:[1,3]

Read View 不仅仅会通过一个列表 trx_list 来维护事务 2执行快照读那刻系统正活跃的事务 ID 列表,还会有两个属性 up_limit_id( trx_list 列表中事务 ID 最小的 ID ),low_limit_id ( 快照读时刻系统尚未分配的下一个事务 ID ,也就是目前已出现过的事务ID的最大值 + 1)

所以在这里例子中 up_limit_id 就是1,low_limit_id 就是 4 + 1 = 5,trx_list 集合的值是 [1, 3],Read View 如下图

在事务2执行快照读之前只有事务4执行了修改并提交了,所以在快照读时刻的undo log如下:

现在我们利用Read View的可见性逻辑去判断一下,记录链上的哪条记录是可见的。

首先我们一样的事务2在进行快照读的时候会生成Read View,Read View的三个属性分别是trx_list:[1,3],up_limit_id:1,low_limit_id: 5。

之后我们就会从记录链的链首开始找,也就是从记录链的最新记录开始找。

首先拿到最新记录的DB_TRX_ID为4. 用4去跟Read View的up_limit_id比较,4不小于up_limit_id(1), 继续判断。 记下来判断4是否大于等于low_limit_id(5), 也不符合条件。 最后判断4是否处于trx_list([1,3])中,发现4不处于活跃事务列表中,符合可见性条件。所以事务4修改后提交的最新结果对事务2的快照读时是可见的。所以事务2能够读到最新记录是事务4所提交的版本,而事务4提交的版本也是全局角度上最新的版本。

接下来我们再来看看一个完整流程图, 流程图来自:https://blog.csdn.net/SnailMann/article/details/94724197

参考来自:CSDN博主「SnailMann」的原创文章。原文链接:https://blog.csdn.net/SnailMann/article/details/94724197

RR级别下和RC级别下 Read View生成的时机

前面我们说到Read View 就是事务进行快照读操作的时候生产的读视图 (Read View),在该事务执行的快照读的那一刻,会生成数据库系统当前的一个快照。这个描述还不够准确。

因为在RR级别下和RC级别下,Read View的生成时机是不同的。所以才会出现不可重复读和可重复度两种情况。

首先RC级别也就是不可重复读(read-committed),也就是在本次事务中,对自己未操作过数据,进行多次读取,结果数据出现不一致。在这个级别下,Read View的生成时机是每次进行select操作也就是进行快照读的时候就会生成一次Read View。所以每一次的快照都是刷新的,所以自然会读取到不一样的结果。我们通过一个表格来体现会更明显。

序列事务A事务B
1开启事务开启事务
2快照读(无影响),查询金额为500select 快照读,查询金额为500
3更新金额为400
4提交事务
5select 快照读,查询金额为400
6select lock in share mode当前读金额为400

这里事务B在序列2时刻读取到金额是500,但是到了序列5的时刻读取到的金额就变成了400,所以这里就产生了不可重复读。

为什么会发生这种现象呢,就是因为在RC级别下,在进行快照读,也就是在序列2和序列5的时刻,分别都生成了Read View,但是在序列2的时刻Read View中事务A还是活跃事务,但是在序列5时刻事务A不是活跃事务了,因为他已经commit了,所以生成的Read View是不一样的,所以就会导致为什么在序列5的时候能读取到事务A已经commit的数据了。

一样的例子我们来看看在RR级别下是怎样的。

首先RR级别也就是可重复读(repeatable-read),是Mysql的默认隔离级别,在他能够解决不可重复读的。那他是怎么解决的呢,其实就是Read View的创建时机是跟RC不一样的,在RR级别下Read View 的创建时机是在事务首次进行select快照读的时候才会生成Read View,之后再进行快照读的时候都不会生成新的Read View,而是用之前生成的Read View来进行判断可见性。

接下来我们来看例子

序列事务A事务B
1开启事务开启事务
2快照读(无影响),查询金额为500select 快照读,查询金额为500
3更新金额为400
4提交事务
5select 快照读,查询金额为500
6select lock in share mode当前读金额为400

对于结果有疑问的同学,可以自己到Mysql中自己试一试结果。我自己验证的结果是一致的。

所以这里在RR级别下,在事务B首次进行快照读的时候才会生成Read View,也就是在序列2的时刻才会生成Read View,而后续在序列5的时刻再次进行快照读的时候并不会再产生Read View了,所以还是沿用了序列2时刻产生的Read View,所以在序列5时刻进行快照读,还是一样的Read View,所以读取到的快照结果依旧是500。但是当使用select lock in share mode的时候,读取到的结果肯定是400,因为读取的是最新的数据。

一样是在RR的级别下,我们再来看看一个例子:

序列事务A事务B
1开启事务开启事务
2快照读(无影响),查询金额为500
3更新金额为400
4提交事务
5select 快照读,查询金额为400
6select lock in share mode当前读金额为400

这里跟上面例子的区别就是,首次进行快照读的时机不一样,之前首次快照读的时机是在序列2的时刻,现在首次快照读的时刻是在序列5的时刻,我们知道在RR级别下,事务中快照读的结果是非常依赖该事务首次出现快照读的地方。即某个事务中首次出现快照读的地方非常关键,它有决定该事务后续快照读结果的能力。所以这里首次快照读的时刻是在序列5的时刻,所以这个时候是在序列5的时刻才生成了Read View,因为这个时刻,事务A已经完成了修改操作并且提交了。所以这个时候是事务B的快照读也是能读取到最新的数据的。

所以从RC和RR级别下的Read View生成时机可以看出来Read View的设计挺巧妙的,RR和RC两个级别可以共用完全同样的Read View逻辑,甚至单从Read View来看,RR级别比RC级别更少消耗系统资源,也难怪为啥mysql默认级别是RR

总结

正是 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在不加锁的情况下解决了脏读,不可重复读和快照读下的幻读,一定不要认为幻读完全是MVCC解决的,因为MVCC只是解决了快照读下的幻读,因为有Read View和记录链,所以未来产生的新记录对于当前事务的快照读是不可见的,所以自然解决了快照读的幻读。但是当前读的幻读MVCC是没法解决的。当前读的幻读是通过间隙锁解决的
  • 对当前读、快照读理解,简单点说加锁就是当前读,不加锁的就是快照读。
  • MVCC 实现的三大要素: 两哥隐式字段、回滚日志(undo log)、Read View。
  • 两个隐式字段:DB_TRX_ID:记录创建这条记录最后一次修改该记录的事务ID;DB_ROLL_PTR:回滚指针,指向这条记录的上一个版本
  • undo log 在更新数据时会产生版本链,是 Read View 获取数据的前提。

参考

InnoDB Multi-Versioning

MySQL删除操作其实是假删除

懵了!女朋友突然问我MVCC实现原理

Mysql中的MVCC机制

【MySQL笔记】正确的理解MySQL的MVCC及实现原理

详细分析MySQL事务日志(redo log和undo log)

MySQL 详细解读undo log :insert undo,update undo

两种undo log记录格式

MySQL 在 RC 隔离级别下是如何实现读不阻塞的?

以上是关于MySQL-InnoDB-MVCC多版本并发控制的主要内容,如果未能解决你的问题,请参考以下文章

MySQL-InnoDB-MVCC多版本并发控制

MVCC(多版本并发控制)原理

MVCC(多版本并发控制)原理

Postgres多版本控制

MySql MVCC是如何实现的-MVCC多版本并发控制?

MySQL中的多版本并发控制(MVCC)