分享

InnoDB 的 MVCC 实现原理

 古明地觉O_o 2022-12-08 发布于北京

楔子

MySQL 的隔离级别默认采用的是可重复读(简称 RR),可以避免脏写、脏读、不可重复读,一个事务在查询的过程中,即使别的事务将值修改了,该事务查询到的结果也是不变的。

那么这是怎么实现的呢?下面来解答一下。


undo log 版本链

先给出结论,RR 是使用 MVCC(多版本并发控制)实现的,但是介绍 MVCC 之前,需要先了解一下什么是 undo log 版本链。理解它,才能更好地理解 MVCC。

前面介绍 MySQL 表数据的时候说过,MySQL 会给数据表添加三个隐藏字段:

如果创建表时没有指定主键,那么 MySQL 会创建一个隐藏的列 DB_ROW_ID 作为主键,所以该字段实际是可选的。但是剩余的两个隐藏字段是必须要有的,其中 DB_TRX_ID 表示最近更新该数据的事务 ID;DB_ROLL_PTR 指向上一次更新该数据的事务所产生的 undo log,因为对记录进行改动时,都会把旧值写入 undo log 中,所以这个隐藏列就相当于一个指针,可以通过它来找到该记录修改前的信息。

举个例子,假设现在有一个事务A(事务 ID 等于 50),插入了一条数据。因为事务A 的 ID 是 50,所以这条数据的 DB_TRX_ID 就是 50,DB_ROLL_PTR 指向一个空的 undo log,因为之前这条数据是没有的。

接着又来了一个事务B,id 是 58,将值修改为 "值B"。

trx_id 就是 DB_TRX_ID,roll_pointer 就是 DB_ROLL_PTR。事务B 将值修改为 "值B",此时表里的那行数据的值就是 "值B" 了,事务ID 就是 58,roll_pointer 指向了 undo log,这个 undo log 记录了更新之前的那条数据的值。

接着假设事务C 又来修改了一下这个值,改为 "值C",事务 id 是 69,此时会把数据行里的事务 ID 改成 69,然后生成一条 undo log,记录之前事务B 修改的那个值。

所以重点就在这里,先不管多个事务是如何并发执行的,起码先搞清楚一点,就是多个事务串行执行的时候,每次修改了数据,都会更新隐藏字段 DB_TRX_ID DB_ROLL_PTR,或者说 trx_id 和 roll_pointer。同时之前多个数据快照对应的 undo log,会通过指针串联起来,形成一个重要的版本链。


ReadView 机制

相信大家都听过 ReadView,它是基于 undo log 版本链的方式实现的,是保证隔离的关键。每执行一个事务,就生成一个 ReadView,里面比较关键的东西有 4 个:

  • m_ids:已经执行但还没有提交的事务的 ID;

  • min_trx_id:m_ids 里面的最小值;

  • max_trx_id:MySQL 下一个要生成的事务的 ID,即最大事务 ID,事务 ID 是自增的;

  • creator_trx_id:当前的事务 ID;

我们举个例子,假设原来数据库里就有一行数据,由很早以前的某个事务写入,事务 ID 是 32,它的值就是 "初始值",如下图所示。

接着有两个事务并发过来执行了,一个是事务A(id=45),一个是事务B(id=59),事务A 要去读取这行数据,事务B 要去更新这行数据,此时两个事务如下图所示。

现在事务A 直接开启一个 ReadView,这个 ReadView 里的 m_ids 就包含了事务A 和事务B 的两个 id,45 和 59。然后 min_trx_id就是45,max_trx_id就是60,creator_trx_id 就是45,是事务A 自己。

这个时候事务A 是第一次查询这行数据,会走一个判断,判断当前这行数据的 txr_id 是否小于 ReadView 中的 min_trx_id。此时发现 txr_id=32,小于 ReadView 里的 min_trx_id、即 45。说明事务开启之前,修改这行数据的事务就已经提交了,所以此时可以查到这行数据,如下图所示。

接着事务把这行数据的值修改为了 "值B" 并提交,显然这行数据的 txr_id 会被设置为事务B 的 id、即 59。同时 roll_pointer 指向了修改之前生成的一个 undo log如下图所示。

然后事务A 再次查询,此时查询的时候,会发现数据行里的 txr_id=59。那么这个 txr_id 大于 ReadView 里的min_txr_id(45),同时小于 ReadView 里的 max_trx_id(60)。说明更新这条数据的事务,很可能是跟自己差不多同时开启的,于是会看一下这个 txr_id=59,是否在 ReadView 的 m_ids 列表里?

很明显 59 在 ReadView 的 m_ids 列表里,说明修改这条数据的事务跟自己是同一时段并发执行然后提交的,所以对这行数据是不能查询的。

既然这行数据不能查询,那查什么呢?很简单,顺着这条数据的 roll_pointer 指向的 undo log 版本链条往下找,就会找到最近的一条 undo log,其 trx_id 是 32。发现它小于 ReadView 里的 min_trx_id,说明这个 undo log 版本必然是在事务A 开启之前就执行且提交的。

因此就会查询最近的 undo log 里的值,这就是 undo log 多版本链的作用,它可以保存一个快照链条,让事务可以读到之前的快照值,如下图。

因此事务A 和事务B 并发执行的时候,通过这套 ReadView+undo log版本链的机制,就可以保证事务A 不会读到并发执行的事务B 更新的值,只会读到之前最近的值。

接着假设事务A 自己更新了这行数据的值,改成值 A,那么 trx_id 就会变成 45,同时保存之前事务B 修改的值的快照,如下图所示。

如果此时事务A 再来查询这条数据的值,会发现这个 trx_id=45,居然跟自己的 ReadView 里的 creator_trx_id(45)是一样的,说明这行数据就是自己修改的。而自己修改的值,自己当然可以看到了。

接着在事务A 执行的过程中,突然开启了一个事务 C,事务 id 是 78,然后将这行数据的值更新为 "值C" 并提交,如下图所示。

这个时候事务A 再去查询,会发现当前数据的 trx_id=78,大于自己的ReadView 中的 max_trx_id(60),此时说明什么呢?说明 id 为 78 的事务,是在事务A 开启之后才开启的,两者不是同一时间段并发开启的,所以所做的修改对于事务A 而言也是不可见的。

此时就会顺着 undo log 版本链往下找,自然会找到事务A 自己之前修改过的那个版本,因为那个版本的 trx_id=45 跟自己 ReadView里的 creator_trx_id 是一样的,所以会读取自己之前修改的那个版本。

到此,相信你应该理解 ReadView 的运行机制了。通过 undo log 多版本链、开启事务时生成的 ReadView,以及查询时根据 ReadView 进行判断的机制,MySQL 就知道应该读取哪个版本的数据。

以上就保证了一个事务只能读到自己更新的值,以及自己开启之前由别的提交事务更新的值。如果在事务开启之后,值被更新了,那么该事务是绝对读不到的。

通过这套机制就实现了多个事务并发执行时的数据隔离。

注意:我们说可重复读(RR)是用到了 ReadView 机制,但其实读已提交(RC)也用到了 ReadView 机制,那么这两者有什么区别呢?下面来解析一下。


RC 是如何基于 ReadView 实现的

如果是 RC 隔离级别,那么事务在运行期间,可以读取到别的事务修改并提交的数据,所以会发生不可重复读以及幻读的问题。

而所谓的 ReadView,它是基于 undo log 版本链实现的一套读视图机制,事务执行时会生成一个 ReadView。然后如果是该事务自己更新的值,或者是在生成 ReadView 之前由其它已提交的事务修改的值,那么可以读取到的。

但如果生成 ReadView 之后,某个活跃事务修改了数据并提交,那么该事务是读不到的。这就是 ReadView 机制的实现原理,那么如何基于该机制实现 RC 隔离级别呢?

其实这里的一个非常核心的要点在于,当一个事务处于 RC隔离级别时,每次发起查询,都会重新生成一个 ReadView。这点非常重要,下面就来分析一下这是怎么实现的。

假设我们的数据库里有一行数据,是 id 为 50 的事务之前就插入进去的,然后现在有两个活跃事务,一个是事务A(id=60),一个是事务B(id=70),此时如下图所示。

然后事务B 发起了一次 update 操作,把这条数据的值修改为 "值B",所以此时数据的  trx_id 会变为 70,同时会生成一条 undo log,由 roll_pointer 指向:

这个时候,事务A 要发起一次查询操作,此时会生成一个新的 ReadView,里面的 min_trx_id=60,max_trx_id=71,creator_trx_id=60。

当 A 查询时发现当前这条数据的 trx_id 是70,也就是说属于 ReadView 的事务id 范围之间,说明生成 ReadView 之前就有这个活跃的事务(事务B),是这个事务修改了这条数据的值。但此时事务B 还没提交,所以 ReadView 的 m_ids 活跃事务列表里,是有 [60, 70] 两个 id 的,根据 ReadView 的机制,此时事务A 无法查到事务B 修改的值。

接着会顺着 undo log 版本链往下找,找到一个原始值,发现它的 trx_id 是50,小于当前 ReadView 里的 min_trx_id。说明在生成 ReadView 之前,就有一个事务插入了这个值并且提交了,因此可以查到这个原始值。

接着事务B 提交了,既然提交就说明事务B 不会活跃于数据库里了。而按照 RC 隔离级别的定义,显然事务A 下次查询,可以读到事务B 修改过的值,因为事务B 提交了。

那么问题来了,怎么才能让它读取到呢?

很简单,事务A 下次发起查询时,再生成一个 ReadView 即可。由于事务B 已经提交,数据库内活跃的事务只剩下A,因此新生成的 ReadView,它的 m_ids 活跃事务列表里只会有一个 60,而 70 不会在里面。

所以新的 ReadView 和老的 ReadView 相比,别的字段都没有变化,但新的 ReadView 的 m_ids 里面只有 60,没有 70。此时事务A 再次基于这个 ReadView 去查询,会发现这条数据的 trx_id=70,虽然在 min_trx_id 和 max_trx_id 范围之间,但并不在 m_ids 列表内,说明事务B 在生成本次 ReadView 之前就已经提交了。

既然在生成本次 ReadView 之前,事务B 就已经提交了,就说明这次查询可以查到事务B 修改过的值了,所以此时事务A 就会查到 "值B",如下图所示。

到此为止,相信你一定明白 RC 隔离级别是如何实现的了,关键点在于每次查询都生成新的 ReadView。如果在这次查询之前,有事务修改了数据并成功提交,那么这次查询生成的 ReadView 的 m_ids 里就不会再包含这个已提交的事务了。既然不包含已提交的事务了,那么当然可以读到人家修改过的值了。

这就是基于 ReadView 实现 RC 隔离级别的原理,实际上,基于 undo log 多版本链以及 ReadView 机制实现的多事务并发执行的 RC 隔离级别、RR 隔离级别,就是数据库的 MVCC 多版本并发控制机制。本质上就是协调多个事务并发运行时,面对并发读写的同一批数据,应该如何协调彼此对数据的可见性。


RR 是如何基于 ReadView 实现的

说完了 RC 再来聊聊 RR,其实 RR 我觉得已经不需要再解释了,相信你已经清楚背后的原理了。RC 隔离级别,每发起一次查询,都会生成一个新的 ReadView;但 RR 隔离级别,ReadView 一旦生成就不会再改变了。

还是上面的例子,事务B 提交之前,m_ids 为 [60, 70]。当事务B 提交之后,由于在 RC 隔离级别查询会生成新的 ReadView,所以它的 m_ids 就变成了 [60];但对于 RR 隔离级别,事务A 在开启之后 ReadView 就不变了,所以不管事务B 有没有提交,它的 m_ids 都为 [60, 70]。

既然事务B 位于事务活跃列表中,那么此时就不能读取事务B 更新的值,而是应该顺着 undo log 版本链往回查找。所以除非事务A 自己将数据修改了,否则不管读多少次,读到的结果都是一样的。

所以这就是 RC 和 RR 实现原理,都是基于 undo log 和 ReadView 实现的。不同的是,在事务 RC 隔离级别下,每次查询都会生成一个新的 ReadView;而在 RR 隔离级别下,一旦事务开启,那么 ReadView 就固定了,后续不会再改变。

因此 RC 和 RR 两个隔离级别没有想象的那么神秘。

然后是幻读,你也许听过 RR 隔离级别还可以解决幻读的问题,那么它是怎么实现的呢?假设当前的表里面只有一条数据,且 id = 1,然后 SELECT 显然只能查询到这一条数据;这时别的事务又插入一条 id = 2 的数据,根据 ReadView 机制,id  = 2 这条数据显然该事务是不可见的。

所以如果只是查询,那么 RR 可以解决幻读,但若是包含更新,RR 就无法解决幻读了。


小结

以上就是 MySQL 中多事务并发运行的隔离原理,其实这套隔离原理,说白了就是 MVCC(multi-version concurrent control),即多版本并发控制,专门控制多个事务并发运行的时候,互相之间会如何影响。

而多个事务并发运行,同时读写一个数据时,可能会出现脏写、脏读、不可重复读、幻读几个问题。

  • 脏写:两个事务都更新一条数据,后更新的将先更新的给覆盖了;

  • 脏读:一个事务读取到另一个事务更新但还未提交的数据,结果另一个事务回滚了,该事务就读不到了;

  • 不可重复读:多次读取一条数据,读取到的结果不一样;

  • 幻读:多次执行范围查询,查询到的记录数不一样,会有数据像幻影一样多出来;

针对这些问题,才会有 RU, RC, RR, 串行四个隔离级别。

  • RU:可以读取到别的事务还未提交的数据,只能避免脏写问题(基于锁实现,后续说);

  • RC:可以读取到别的事务已经提交的数据,可以避免脏写和脏读问题;

  • RR:不会读取到别的事务已经提交的数据,可以避免脏写、脏读和不可重复读问题;

  • 串行化:让所有事务都串行执行,可以避免所有问题,但效率会变得极差;

其中 RC 和 RR 这两个隔离级别都是基于 MVCC 实现的,而 MVCC 机制则是基于 undo log 多版本链+ReadView机制来做的。RC 隔离级别下每一次查询都会创建一个新的 ReadView,而 RR 隔离级别下 ReadView 一旦创建,后续就不会再变了。

MySQL 默认是 RR 隔离级别,基本上可以解决上述所有问题。虽然还会存在幻读的问题,但只要负责查询的事务不做更新操作,那么是看不到别的事务插入的数据的,所以在一定程度上也算是避免了幻读的问题。


本文参考自:

  • 儒猿技术窝《MySQL 实战高手》

    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多