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; |
那么,在 “读已提交” 的隔离级别下,会产生这样的一条版本链:
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;
因此,在创建 Read View 之后,记录中的 trx_id 就可以划分为这三种情况:
所以,当一个事务访问记录时,就可以通过 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,并以链表的方式串联起来,形成版本链:
如图所示,由于事务 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 |
而在二三次时,记录的字段如下:
我们来分析下为什么事务 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
- 链接:https:/blog.tuwilt.top/article/25e1a7fe-f4a7-45be-8ca7-982d67c2e6f5
- 声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。