type
status
date
slug
summary
tags
category
icon
password
通过上篇文章,我们知道了对于 “读已提交” 和 “可重复读” 隔离级别,我们需要使用 Read View 来实现,而这就要引申出 MVCC 的概念。

什么是 MVCC

MVCC(Multi-Version Concurrency Control)中文叫做多版本并发控制协议,是 MySQL InnoDB 引擎用于控制数据并发访问的协议。
它主要有以下重要概念:
  • 隔离级别
  • 版本链
  • Read View
这些概念共同作用,使得 MVCC 能够提供高度并发的数据库访问,同时保持事务的隔离性。不同数据库系统可能在实现上有一些差异,但这些基本概念是 MVCC 机制的核心。

隔离级别

详见MySQL事务(一) — 事务的起源,这里不再赘述。

版本链

为了实现 MVCC,InnoDB 引擎给每一条聚簇索引记录都加了两个隐藏字段 trx_id 和 roll_ptr
  • trx_id:事务 id,也叫做事务版本号。每一个事务在开始的时候就会获得一个 id,当一个事务对某条聚簇索引记录进行改动时,就会把该事务的事务 id 记录在 trx_id 隐藏列里
  • roll_pointer:回滚指针。InnoDB 通过 roll_pointer 把每一行的历史版本串联在一起。每次对某条聚簇索引记录进行改动时,都会把旧版本的记录写入到 undo 日志中,然后这个隐藏列是个指针,指向每一个旧版本记录,于是就可以通过它找到修改前的记录。

例子

假设之后两个事务id分别为 100、200 的事务对这条记录进行 UPDATE 操作,操作流程如下:
发生时间序号
trx_id 100
trx_id 200
1
BEGIN;
2
BEGIN;
3
UPDATE user SET balance = 200 WHERE id = 1;
4
UPDATE user SET balance = 500 WHERE id = 1;
5
COMMIT;
6
UPDATE user SET name = '李四' WHERE id = 1;
7
UPDATE user SET balance = 100 WHERE id = 1;
8
COMMIT;
那么,在 “读已提交” 的隔离级别下,会产生这样的一条版本链:
notion image

Read View

对于 Read View,我们首先需要了解它的四个重要的字段:
  • creator_trx_id :创建该 Read View 事务的事务 id
    • m_ids :指的是在创建 Read View 时,当前数据库中 活跃事务(启动了但还没提交的事务)事务 id 列表
  • min_trx_id :指的是在创建 Read View 时,当前数据库中 “活跃事务” 中事务 id 最小的事务,也就是 m_ids 中的最小值。
  • max_trx_id :这个并不是 m_ids 的最大值,而是创建 Read View 时当前数据库中应该给下一个事务的 id 值,也就是全局事务中最大的事务 id 值 + 1;
notion image
因此,在创建 Read View 之后,记录中的 trx_id 就可以划分为这三种情况:
notion image
所以,当一个事务访问记录时,就可以通过 Read View 来判断当前记录是否为可见记录:
  • 如果记录的 trx_id 值等于 Read View 中的 creator_rtx_id 值,表示当前事务是在访问自己修改过的记录,所以该版本的记录对当前事务可见
  • 如果记录的 trx_id 值小于 Read View 中的 min_trx_id 值,表示这个版本的记录是在创建 Read View 已经提交的事务生成的,所以该版本的记录对当前事务可见
  • 如果记录的 trx_id 值大于等于 Read View 中的 max_trx_id 值,表示这个版本的记录是在创建 Read View 才启动的事务生成的,所以该版本的记录对当前事务不可见
  • 如果记录的 trx_id 值在 Read View 的 min_trx_id 和 max_trx_id 之间,则需要判断 trx_id 是否在 m_ids 列表中:
    • 如果记录的 trx_id m_ids 列表中,表示生成该版本记录的活跃事务依然活跃着(还没提交事务),所以该版本的记录对当前事务不可见
    • 如果记录的 trx_id 不在 m_ids列表中,表示生成该版本记录的活跃事务已经被提交,所以该版本的记录对当前事务可见

可重复读是如何使用 MVCC 的?

可重复读隔离级别是启动事务时生成一个 Read View,然后整个事务期间都在用这个 Read View
假设事务 A (事务 id 为51)启动后,紧接着事务 B (事务 id 为52)也启动了,那这两个事务创建的 Read View 如下:
Read View 字段
Session A
Session B
creator_trx_id
51
52
m_ids
[51]
[51,52]
min_trx_id
51
51
max_trx_id
52
53
此时记录的字段为:
id
name
balance
trx_id
roll_pointer
1
张三
100
50
事务 A 和 事务 B 的 Read View 具体内容如下:
  • 在事务 A 的 Read View 中,它的事务 id 是 51,由于它是第一个启动的事务,所以此时活跃事务的事务 id 列表就只有 51,活跃事务的事务 id 列表中最小的事务 id 是事务 A 本身,下一个事务 id 则是 52。
  • 在事务 B 的 Read View 中,它的事务 id 是 52,由于事务 A 是活跃的,所以此时活跃事务的事务 id 列表是 51 和 52,活跃的事务 id 中最小的事务 id 是事务 A,下一个事务 id 则应该是 53。
接下来执行如下操作:
发生时间序号
Session A
Session B
1
BEGIN;
2
BEGIN;
3
SELECT balance FROM user WHERE id = 1; (此时读取到的结果为 100)
4
UPDATE user SET balance = 200 WHERE id = 1;
5
SELECT balance FROM user WHERE id = 1; (此时读取到的结果为 100)
6
COMMIT;
7
SELECT balance FROM user WHERE id = 1; (此时读取到的结果为 100)
8
COMMIT;
为什么会产生这样的结果呢?具体分析如下:
事务 B 第一次读 id = 1 的记录的 balance 字段,在找到记录后,它会先看这条记录的 trx_id,此时发现 trx_id 为 50,比事务 B 的 Read View 中的 min_trx_id 值(51)还小,这意味着修改这条记录的事务早就在事务 B 启动前提交过了,所以该版本的记录对事务 B 可见的,也就是说事务 B 可以获取到这条记录,所以读取到的结果为 100
接着,事务 A 通过 update 语句将这条记录修改了(此时未提交),将 id = 1 的记录的 balance 字段 改成 200 ,这时 MySQL 会记录相应的 undo log,并以链表的方式串联起来,形成版本链
notion image
如图所示,由于事务 A 修改了该记录,以前的记录就变成旧版本记录了,于是最新记录和旧版本记录通过链表的方式串起来,而且最新记录的 trx_id 是事务 A 的事务 id(trx_id = 51)。
然后事务 B 第二次去读取该记录,发现这条记录的 trx_id 值为 51,在事务 B 的 Read View 的 min_trx_id 和 max_trx_id 之间,则需要判断 trx_id 值是否在 m_ids 范围内,判断的结果是在的,那么说明这条记录是被还未提交的事务修改的,这时事务 B 并不会读取这个版本的记录。而是沿着 undo log 链条往下找旧版本的记录,直到找到 trx_id 小于 事务 B 的 Read View 中的 min_trx_id 值的第一条记录,所以事务 B 能读取到的是 trx_id 为 50 的记录,也就是 balance 值是 100 的这条记录。
最后,当事物 A 提交事务后,由于隔离级别为 “可重复读”,所以事务 B 再次读取记录时,还是基于启动事务时创建的 Read View 来判断当前版本的记录是否可见。所以,即使事务 A 提交了事务, 当事务 B 第三次读取记录时,读到的记录依然是余额为 100 的这条记录。
就是通过这样的方式实现了,可重复读隔离级别下在事务期间读到的记录都是事务启动前的记录。

读已提交是如何使用 MVCC 的?

与 “可重复读” 不同的是,“读已提交” 隔离级别是在每次读取数据时,都会生成一个新的 Read View
也意味着,事务期间的多次读取同一条数据,前后两次读的数据可能会出现不一致,因为可能这期间另外一个事务修改了该记录,并提交了事务。
仍旧以可重复读中的例子来演示:
发生时间序号
Session A
Session B
1
BEGIN;
2
BEGIN;
3
SELECT balance FROM user WHERE id = 1; (此时读取到的结果为 100)
4
UPDATE user SET balance = 200 WHERE id = 1;
5
SELECT balance FROM user WHERE id = 1; (此时读取到的结果为 100)
6
COMMIT;
7
SELECT balance FROM user WHERE id = 1; (此时读取到的结果为 200)
8
COMMIT;
Session B 每次产生的 Read View 情况如下:
Read View 字段
Session B(第一次)
Session B(第二次)
Session B(第三次)
creator_trx_id
51
52
52
m_ids
[51]
[51,52]
[52]
min_trx_id
51
51
52
max_trx_id
52
53
53
而在二三次时,记录的字段如下:
notion image
我们来分析下为什么事务 B 第二次读数据时,读不到事务 A (还未提交事务)修改的数据?
然后事务 B 第二次去读取该记录,发现这条记录的 trx_id 值为 51,在事务 B 的 Read View 的 min_trx_id 和 max_trx_id 之间,则需要判断 trx_id 值是否在 m_ids 范围内,判断的结果是在的,那么说明这条记录是被还未提交的事务修改的,这时事务 B 并不会读取这个版本的记录。而是沿着 undo log 链条往下找旧版本的记录,直到找到 trx_id 小于 事务 B 的 Read View 中的 min_trx_id 值的第一条记录,所以事务 B 能读取到的是 trx_id 为 50 的记录,也就是 balance 值是 100 的这条记录。
接着我们来分析下为什么事务 A 提交后,事务 B 就可以读到事务 A 修改的数据?
在事务 A 提交后,由于隔离级别是 “读已提交”,所以事务 B 在每次读数据的时候,会重新创建 Read View
事务 B 在找到 id = 1这条记录时,会发现这条记录的 trx_id 是 51,比事务 B 的 Read View 中的 min_trx_id 值(52)还小,这意味着修改这条记录的事务早就在创建 Read View 前提交过了,所以该版本的记录对事务 B 是可见的
也正是因为在读提交隔离级别下,事务每次读数据时都重新创建 Read View,那么在事务期间的多次读取同一条数据,前后两次读的数据可能会出现不一致,因为可能这期间另外一个事务修改了该记录,并提交了事务。

参考资料:
  • 《MySQL是怎样运行的:从根儿上理解MySQL》
Tuwilt
Tuwilt
言辞不必有力