什么是 MVCC

MVCC,即多版本并发控制,通过记录多个修改的历史版本替代锁,实现事务间的隔离效果,保证非阻塞读。MVCC 机制也是乐观锁的一种体现。

MVCC 原理

想弄明白 MVCC 关键要抓住它的三个核心要素:

  1. 表的隐藏列:记录事务 id 及上个版本数据地址;
  2. undo log:记录数据各版本修改历史即事务链;
  3. Read Viev:读视图,用于判断哪些版本可见;

接下来我们逐一分析一下。

隐藏列

在 MySQL 中每一行记录有三个隐藏键,分别为DATA_TRX_ID、DATA_ROLL_PTR、DB_ROW_ID,其中:

  • DATA_TRX_ID:记录最近更新这条行记录的事务 ID,大小为 6 个字节
  • DATA_ROLL_PTR:表示指向该行回滚段(rollback segment)的指针,大小为 7 个字节,InnoDB 便是通过这个指针找到之前版本的数据。该行记录上所有旧版本,在 undo 中都通过链表的形式组织。
  • DB_ROW_ID:行标识(隐藏单调自增 ID),大小为 6 字节,如果表没有主键,InnoDB 会自动生成一个隐藏主键,即此列。

与 MVCC 息息相关的是前两个。

undo log 版本链

上一节说到了隐藏列,其中 data_roll_ptr 就是指向当前事务生成的 undo log。我们画一幅图来更清晰地了解版本链。
undolian.png
简单给大家讲一下这张图,首先事务 A 向表中添加值为 x,事务ID 为 10 的数据,由于它是第一条,所以回滚段指针为 null;之后事务 B 将这条数据的值更改为 Y,此时事务 ID 为 20,这时候回滚段指针指向上个版本数据的地址,即事务 A 的地址;然后当前事务 C 重复这个动作。而之前版本(事务A,事务 B)就存在 undo log 中。

Read View

当执行一个事务的时候,就会生成一个对应的 ReadView,里面比较关键的东西有 4 个

  • m_ids,这个就是说明此时有哪些事务在 MySQL 里执行还没有提交
  • min_trx_id,就是 m_ids 里最小的值
  • max_trx_ids,MySQL 下一个要生成的事务 id,就是最大事务 id
  • creator_trx_id,就是你这个事务的 id

那么现在我们来举个例子,让大家通过离子来理解这个 Read View 是怎么用的。

假设原来数据库里有一行数据,很早以前就有事务插入过了,事务 id 是 32,他的值就是初始值,如下图:
image.png

接下来,两个事务并发执行了,一个是事务 A (id = 45),一个是事务 B (id = 59),事务 B 是要去更新这行数据的,事务 A 是要去读取这行数据的值的。

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

这个时候事务 A 第一次查询这行数据,就会走一个判断,就是判断一下当前这行数据的 trx_id 是否小于 ReadView 中的 min_trx_id,此时发现 trx_id = 32,是小于 ReadView 里的 min_trx_id 45 的,说明你事务开启之前,修改这行数据的人事务早就提交了,所以此时可以查到这行数据,如下图所示。
image.png
接着事务 B 开始动手了,他把这行数据的值修改了值 B,然后这行数据的 trx_id 设置为自己的 id,也就是 59,同时 roll_pointer 指向了修改之前生成的一个 undo log,接着这个事务 B 就提交了,如下图所示。
image.png
这个时候事务 A 再次查询,此时查询的时候会发现一个问题,那就是此时数据行里的 trx_id = 59,那么这个 trx_id 是大于 ReadView 里的 min_trx_id(45),同时小于 ReadView 里的 max_trx_id(60)的,这说明更新的事务很可能跟自己差不多同时开启,于是会看一下 trx_id = 59 是否在 ReadView 的 m_ids 列表里。然后果然在列表里发现了有 45 和 59 两个事务 id,直接证实了这个修改数据的事务是跟自己同一时段并发执行然后提交的,所以这行数据是不能查询的!

既然这行数据不能查,那查什么呢?我们只需顺着这条数据的 roll_pointer undo log 日志链往下找,就会找到最近一条 undo log,trx_id = 32,此时发现 trx_id = 32 是小于 ReadView 里的 min_trx_id(45) 的,说明这个 undo log 版本必然是在事务 A 开启之前就执行且提交的。所以,查询最近的那个 undo log 里的值就好了,这就是 undo log 多版本链条的作用,他可以保存一个快照链条,让你可以读到之前的快照。
image.png

Read Committed 是如何基于 ReadView 实现的

Read Committed 简称 RC,这个隔离级别下,只要别的事务修改数据还提交了,你就可以读到人家修改的数据的,所以会发生不可重复读,幻读的问题。那么如何基于 ReadView 机制来实现 RC 隔离级别呢?

当一个事务设置 RC 隔离级别的时候,它是每次发起查询,都重新生成一个 ReadView!

假设我们数据库里有一行数据,是事务 id = 50 的一个事务,之前就插进去了。然后现在活跃着两个事务,一个是事务 A (id = 60),一个是事务 B(id = 70)
image.png
现在情况是,事务 B 发起了一次 update 操作,更新了这条数据,把这条数据的值改为了 B,所以此时数据的 trx_id 会变为事务 B 的 id = 70,同时会生成一条 undo log,由 roll_pointer 来指向。

这时事务 A 来查询会生成一个 ReadView,此时 ReadView 里面的 min_trx_id = 60,max_trx_id = 71,creator_trx_id = 60。

当事务 B 还没有提交时,事务 A 发起查询,发现当前这条数据的 trx_id 是 70,也就是说,属于 ReadView 的事务 id 范围之间,说明他生成 ReadView 之前就有这个活跃的事务,是这个事务修改了这条数据。但由于还没有提交,所以 ReadView 活跃事务列表 m_ids 中有 [60, 70] 两个 id,所以根据 ReadView 机制,此时事务 A 无法查询到事务 B 修改的值。因此事务 A 会顺着 undo log 版本链向下查询,找到原始值。

当事务 B 提交了呢,事务 A 又是如何发现的?很简单,就是让事务 A 下次发起查询,再次生成一个 ReadView。此时再次生成 ReadView,数据库内活跃的事务只有 A 了,因此 min_trx_id 是 60,max_trx_id 是 71,但是 m_ids 这个活跃事务列表里,只会有 60 了,事务 B的 id = 70 不会出现现在 m__ids 活跃事务列表里了。
image.png
此时事务 A 再次基于这个 ReadView 去查询,会发现这条数据的 trx_id = 70,虽然在 ReadView 的 min_trx_id 和 max_trx_id 范围之间,但是此时并不在 m_ids 列表内,说明事务 B 在生成本次 ReadView 之前就已经提交了。那么既然在本次 ReadView 之前,说明这次你查询就可以查到事务 B 修改的这个值了,此时事务 A 就会查到值 B

Repeatable Read 是如何基于 ReadView 实现的

Repeatable Read,简称 RR,即可重复读,是 MySQL 默认的隔离级别,MySQL 避免了不可重复读和幻读的问题。在 RR 级别下,你的事务读一条数据,无论多少次,都是一个值,别的事务修改数据后哪怕提交了,你也是看不到人家修改的值,这就避免了不可重复读的问题。同时,如果别的事务插入一些新的数据,你也是读不到的,这样你就可以避免幻读的问题。

那么这是如何实现的呢?RR 的实现又和 RC 有什么区别呢?

先说结论:RC 隔离级别的时候,它是每次发起查询,都重新生成一个 ReadView,而 RR 不会。

接下来我们一起看看是如何实现的。

首先我们假设有一条事务 id = 5 的数据插入,此时有事务 A 和 事务 B 在同时运行,事务 A 的 id 是 60,事务 B 的 id 是 70。
image.png
这个时候,事务 A 发起了一个查询,它是第一次查询就会生成一个 ReadView,此时 ReadView 里的 creator_trx_id 是 60,min_trx_id 是 71,m_ids 是 [60,70]
image.png
这时候事务 A 基于这个 ReadView 去查这条数据,会发现这条数据的 trx_id 为 50,是小于 ReadView 里的 min_trx_id 的,说明他发起查询之前,早就有事务插入这条数据还提交了,所以此时可以查到这条原始值。

紧接着事务 B 此时更新了这条数据的值为 B,此时会修改 trx_id 为 70,同时生成一个 undo log,而且关键是事务 B 此时还提交了,也就是说此时事务 B 已经结束了。
image.png
那么此时 ReadView 中的 m_ids 还会是 60 和 70 吗?就像开头我们说的那样,在 RR 级别下,每次查询不会产生新的 ReadView。所以 m_ids 里还是会有 60 和 70 两个事务 id。那么紧接着此时事务 A 去插叙你这条数据的值,它会发现此时数据的 trx_id 是 70,而 70 也是在 ReadView 的 ,min_trx_id 和 max_trx_id 的范围区间内的,同时还在 m_ids 列表中。这说明当事务 A 开启查询的时候,id 为 70 的事务 B 还是在运行的,然后由这个事务 B 更新了数据,所以此时事务 A 是不能查询到事务 B 更新的这个值的,因此这个时候继续顺着指针往历史版本链上去找。
image.png
这样就避免了不可重复的问题。那么幻读呢?

现在有一个事务 C 插入一条数据,然后提交了
image.png
紧接着,此时事务 A 再次查询,此时会发现符合条件的有 2 条数据,一条是原始的那个数据,一条是事务 C 插入的那条数据。但是事务 C 插入的那条数据的 trx_id 是 80,这个 80 是大于自己的 ReadView 的 max_trx_id的,说明是自己发起查询之后,这个事务才启动的,所以此时这条数据是不能查询的。因此事务 A 本次查询,还是只能查到原始值一条数据。

现在大家可以看到,其实事务 A 根本不会发生幻读,即使他根据条件范围查询的时候,每次读到的数据都是一样的,不会读到人家插入进去的数据,这都是依托 ReadView 机制实现的。

总结

本次我和大家一起浏览了一遍 MVCC 相关知识,了解了 MVCC 原理,undo log 版本链和 ReadView。同时也明白了 RC 级别下为什么可以看到别人提交的数据和 RR 级别下为什么可以防止不可重复读和幻读。希望大家喜欢,如果有疑问可以和我联系。

站在巨人肩膀

【儒园技术窝】- https://apppukyptrl1086.pc.xiaoe-tech.com/detail/p_5e0c2a35dbbc9_MNDGDYba/6