MYSQL MVCC 多版本并发控制底层原理及实现机制

本贴最后更新于 1372 天前,其中的信息可能已经物是人非

什么是 MVCC

Multiversion Concurrency Control:多版本并发控制,提供并发访问数据库时,对事务内读取的到的内存做处理,用来避免写操作堵塞读操作的并发问题(提高并发读写性能)。

MVCC 主要适用于 Mysql 的 RC(读已提交),RR(可重复读)隔离级别

问题(痛点)

A 正在读数据库中某些内容,而 B 正在给这些内容做修改(A,B 为两个单独的事务),A 可能看到一个不一致的数据,在 B 没有提交前,如何让 A 能够一直读到的数据都是一致的或读到的数据一直是最新的呢?

每个用户连接数据库时,看到的都是某一特定时刻的数据库快照,在 B 的事务没有提交之前,A 始终读到的是某一特定时刻的数据库快照,不会读到 B 事务中的数据修改情况,直到 B 事务提交,才会读取 B 的修改内容。

一个支持MVCC的数据库,在更新某些数据时,并非使用新数据覆盖旧数据,而是标记旧数据是过时的,同时在其他地方新增一个数据版本。因此,同一份数据有多个版本存储,但只有一个是最新的。

MVCC 提供了 时间一致性的 处理思路,在 MVCC 下读事务时,通常使用一个时间戳或者事务 ID 来确定访问哪个状态的数据库及哪些版本的数据。读事务跟写事务彼此是隔离开来的,彼此之间不会影响。假设同一份数据,既有读事务访问,又有写事务操作,实际上,写事务会新建一个新的数据版本,而读事务访问的是旧的数据版本,直到写事务提交,读事务才会访问到这个新的数据版本。

InnoDB 的 MVCC 实现机制

  • 每条记录都会保存两个隐藏列,事务id(trx_id)和 回滚指针(roll_point)。
  • 每次操作都会生成一条 undo log 日志,回滚指针指向前一条记录。

版本链

`roll_pointer`每次对记录修改的时候,都会把老版本写入undo log中。新纪录中`roll_pointer`就是存了一个指针,它指向这条记录的上一个版本的位置,通过它来获得上一个版本的记录信息。

测试表结构如下:

CREATE TABLE `t` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `c` varchar(11) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `n_index` (`c`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

假设现在有个事务(事务 id:80)进行了一个 insert 操作如下:
INSERT INTO t(id, c) VALUES (1, '刘备');

那么此刻的真实数据如下面这个样子:

id c trx_id roll_point
1 刘备 80 上一个版本的地址

示意图
image.png

INSERT 操作对应的 undo日志 没有该属性,因为该记录并没有更早的版本。

假设之后两个 事务id 分别为 100200 的事务对这条记录进行 UPDATE 操作,操作流程如下:
image.png
每次对记录进行改动,都会记录一条 undo日志,每条 undo日志 也都有一个 roll_pointer 属性,可以将这些 undo日志 都连起来,串成一个链表,所以现在的情况就像下图一样:
image.png
对该记录每次更新后,都会将旧值放到一条 undo日志 中,就算是该记录的一个旧版本,随着更新次数的增多,所有的版本都会被 roll_pointer 属性连接成一个链表,我们把这个链表称之为 版本链,版本链的头节点就是当前记录最新的值。另外,每个版本中还包含生成该版本时对应的事务 id,这个信息很重要,我们稍后就会用到。

ReadView

对于使用 READ UNCOMMITTED 隔离级别的事务来说,直接读取记录的最新版本就好了,对于使用 SERIALIZABLE 隔离级别的事务来说,使用加锁的方式来访问记录。对于使用 READ COMMITTEDREPEATABLE READ 隔离级别的事务来说,就需要用到我们上边所说的 版本链 了,核心问题就是:<font color=#A52A2A size=4 > 需要判断一下版本链中的哪个版本是当前事务可见的。所以后面提出了一个 ReadView 的概念,<font color=blue size=4 > 这个 ReadView 中主要包含当前系统中还有哪些活跃的(未提交的)读写事务,把它们的事务 id 放到一个列表中,我们把这个列表命名为为 m_ids 和表示生成该 ReadView 的快照读操作产生的事务 id(creator_trx_id)。这样在访问某条记录时,只需要按照下边的步骤判断记录的某个版本是否可见:
<font color=red size=3 >

  • 如果被访问版本的 trx_id 属性值与 ReadView 中的 creator_trx_id 值相同,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。

    <font color=#C71585 size=3 >

  • 如果被访问版本的 trx_id 属性值小于 m_ids 列表中最小的事务 id,表明生成该版本的事务在生成 ReadView 前已经提交,所以该版本可以被当前事务访问。

  • 如果被访问版本的 trx_id 属性值大于 m_ids 列表中最大的事务 id,表明生成该版本的事务在生成 ReadView 后才生成,所以该版本不可以被当前事务访问。
  • 如果被访问版本的 trx_id 属性值在 m_ids 列表中最大的事务 id 和最小事务 id 之间,那就需要判断一下 trx_id 属性值是不是在 m_ids 列表中,如果在,说明创建 ReadView 时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建 ReadView 时生成该版本的事务已经被提交,该版本可以被访问。

如果某个版本的数据对当前事务不可见的话,那就顺着版本链找到下一个版本的数据,继续按照上边的步骤判断可见性,依此类推,直到版本链中的最后一个版本,如果最后一个版本也不可见的话,那么就意味着该条记录对该事务不可见,查询结果就不包含该记录。

MySQL 中,READ COMMITTEDREPEATABLE READ 隔离级别的的一个非常大的区别就是它们生成 ReadView 的时机不同。

READ COMMITTED --- 每次读取数据前都生成一个 ReadView

比方说现在系统里有两个 id 分别为 100200 的事务在执行:

# Transaction 100
BEGIN;

UPDATE t SET c = '关羽' WHERE id = 1;

UPDATE t SET c = '张飞' WHERE id = 1;
# Transaction 200
BEGIN;

# 更新了一些别的表的记录
...

此刻,表 tid1 的记录得到的版本链表如下所示:
image.png

假设现在有一个使用 READ COMMITTED 隔离级别的事务开始执行:

# 使用READ COMMITTED隔离级别的事务
BEGIN;

# SELECT1:Transaction 100、200未提交
SELECT * FROM t WHERE id = 1; # 得到的列c的值为'刘备'

这个 SELECT1 的执行过程如下:

  • 在执行 SELECT 语句时会先生成一个 ReadViewReadViewm_ids 列表的内容就是 [100, 200]
  • 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列 c 的内容是 '张飞',该版本的 trx_id 值为 100,在 m_ids 列表内,所以不符合可见性要求,根据 roll_pointer 跳到下一个版本。
  • 下一个版本的列 c 的内容是 '关羽',该版本的 trx_id 值也为 100,也在 m_ids 列表内,所以也不符合要求,继续跳到下一个版本。
  • 下一个版本的列 c 的内容是 '刘备',该版本的 trx_id 值为 80,小于 m_ids 列表中最小的事务 id 100,所以这个版本是符合要求的,最后返回给用户的版本就是这条列 c'刘备' 的记录。

之后,我们把事务 id 为 100 的事务提交一下,就像这样:

# Transaction 100
BEGIN;

UPDATE t SET c = '关羽' WHERE id = 1;

UPDATE t SET c = '张飞' WHERE id = 1;

COMMIT;

然后再到事务 id 为 200 的事务中更新一下表 tid 为 1 的记录:

# Transaction 200
BEGIN;

# 更新了一些别的表的记录
...

UPDATE t SET c = '赵云' WHERE id = 1;

UPDATE t SET c = '诸葛亮' WHERE id = 1;

此刻,表 tid1 的记录的版本链就长这样:
image.png
然后再到刚才使用 READ COMMITTED 隔离级别的事务中继续查找这个 id 为 1 的记录,如下:

# 使用READ COMMITTED隔离级别的事务
BEGIN;

# SELECT1:Transaction 100、200均未提交
SELECT * FROM t WHERE id = 1; # 得到的列c的值为'刘备'

# SELECT2:Transaction 100提交,Transaction 200未提交
SELECT * FROM t WHERE id = 1; # 得到的列c的值为'张飞'

这个 SELECT2 的执行过程如下:

  • 在执行 SELECT 语句时会先生成一个 ReadViewReadViewm_ids 列表的内容就是 [200]
  • 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列 c 的内容是 '诸葛亮',该版本的 trx_id 值为 200,在 m_ids 列表内,所以不符合可见性要求,根据 roll_pointer 跳到下一个版本。
  • 下一个版本的列 c 的内容是 '赵云',该版本的 trx_id 值为 200,也在 m_ids 列表内,所以也不符合要求,继续跳到下一个版本。
  • 下一个版本的列 c 的内容是 '张飞',该版本的 trx_id 值为 100,比 m_ids 列表中最小的事务 id 200 还要小,所以这个版本是符合要求的,最后返回给用户的版本就是这条列 c'张飞' 的记录。

以此类推,如果之后事务 id 为 200 的记录也提交了,再此在使用 READ COMMITTED 隔离级别的事务中查询表 tid 值为 1 的记录时,得到的结果就是 '诸葛亮' 了,具体流程我们就不分析了。<font color=Crimson size=3 > 总结一下就是:使用 READ COMMITTED 隔离级别的事务在每次查询开始时都会生成一个独立的 ReadView。

REPEATABLE READ ---在第一次读取数据时生成一个 ReadView

对于使用 REPEATABLE READ 隔离级别的事务来说,只会在第一次执行查询语句时生成一个 ReadView,之后的查询就不会重复生成了。我们还是用例子看一下是什么效果。
比如现在系统里有两个 id 分别为 100200 的事务在执行:

# Transaction 100
BEGIN;

UPDATE t SET c = '关羽' WHERE id = 1;

UPDATE t SET c = '张飞' WHERE id = 1;
# Transaction 200
BEGIN;

# 更新了一些别的表的记录
...

此刻,表 tid1 的记录得到的版本链表如下所示:
image.png
假设现在有一个使用 REPEATABLE READ 隔离级别的事务开始执行:

# 使用REPEATABLE READ隔离级别的事务
BEGIN;

# SELECT1:Transaction 100、200未提交
SELECT * FROM t WHERE id = 1; # 得到的列c的值为'刘备'

这个 SELECT1 的执行过程如下:

  • 在执行 SELECT 语句时会先生成一个 ReadViewReadViewm_ids 列表的内容就是 [100, 200]
  • 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列 c 的内容是 '张飞',该版本的 trx_id 值为 100,在 m_ids 列表内,所以不符合可见性要求,根据 roll_pointer 跳到下一个版本。
  • 下一个版本的列 c 的内容是 '关羽',该版本的 trx_id 值也为 100,也在 m_ids 列表内,所以也不符合要求,继续跳到下一个版本。
  • 下一个版本的列 c 的内容是 '刘备',该版本的 trx_id 值为 80,小于 m_ids 列表中最小的事务 id 100,所以这个版本是符合要求的,最后返回给用户的版本就是这条列 c'刘备' 的记录。

之后,我们把事务 id 为 100 的事务提交一下,就像这样:

# Transaction 100
BEGIN;

UPDATE t SET c = '关羽' WHERE id = 1;

UPDATE t SET c = '张飞' WHERE id = 1;

COMMIT;

然后再到事务 id 为 200 的事务中更新一下表 tid 为 1 的记录:

# Transaction 200
BEGIN;

# 更新了一些别的表的记录
...

UPDATE t SET c = '赵云' WHERE id = 1;

UPDATE t SET c = '诸葛亮' WHERE id = 1;

此刻,表 tid1 的记录的版本链就长这样:
image.png

然后再到刚才使用 REPEATABLE READ 隔离级别的事务中继续查找这个 id 为 1 的记录,如下:

# 使用REPEATABLE READ隔离级别的事务
BEGIN;

# SELECT1:Transaction 100、200均未提交
SELECT * FROM t WHERE id = 1; # 得到的列c的值为'刘备'

# SELECT2:Transaction 100提交,Transaction 200未提交
SELECT * FROM t WHERE id = 1; # 得到的列c的值仍为'刘备'

这个 SELECT2 的执行过程如下:

  • 因为之前已经生成过 ReadView 了,所以此时直接复用之前的 ReadView,之前的 ReadView 中的 m_ids 列表就是 [100, 200]
  • 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列 c 的内容是 '诸葛亮',该版本的 trx_id 值为 200,在 m_ids 列表内,所以不符合可见性要求,根据 roll_pointer 跳到下一个版本。
  • 下一个版本的列 c 的内容是 '赵云',该版本的 trx_id 值为 200,也在 m_ids 列表内,所以也不符合要求,继续跳到下一个版本。
  • 下一个版本的列 c 的内容是 '张飞',该版本的 trx_id 值为 100,而 m_ids 列表中是包含值为 100 的事务 id 的,所以该版本也不符合要求,同理下一个列 c 的内容是 '关羽' 的版本也不符合要求。继续跳到下一个版本。
  • 下一个版本的列 c 的内容是 '刘备',该版本的 trx_id 值为 8080 小于 m_ids 列表中最小的事务 id 100,所以这个版本是符合要求的,最后返回给用户的版本就是这条列 c'刘备' 的记录。

MVCC 总结

MVCC(Multi-Version Concurrency Control ,多版本并发控制)指的就是在使用`READ COMMITTD`、`REPEATABLE READ`这两种隔离级别的事务在执行普通的`SEELCT`操作时访问记录的版本链的过程,这样子可以使不同事务的`读-写`、`写-读`操作并发执行,从而提升系统性能。`READ COMMITTD`、`REPEATABLE READ`这两个隔离级别的一个很大不同就是生成`ReadView`的时机不同,`READ COMMITTD`在每一次进行普通`SELECT`操作前都会生成一个`ReadView`,而`REPEATABLE READ`只在第一次进行普通`SELECT`操作前生成一个`ReadView`,之后的查询操作都重复这个`ReadView`就好了。

掘金原文链接

1 操作
liqitian3344 在 2020-07-24 11:43:29 更新了该帖

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...