MySQL MVCC 多版本并发控制

MVCC 多版本并发控制

一、引言

本文深入剖析 MySQL InnoDB 存储引擎中 MVCC(多版本并发控制)工作原理,涵盖快照读、当前读概念,MVCC 关键构成(隐藏字段、Undo Log、ReadView),及其在不同事务隔离级别下的数据可见性处理与幻读问题解决,且附实例展示读提交和可重复读隔离级别下的 MVCC 操作流程。

二、MVCC 概述

(一)定义

MVCC(Multiversion Concurrency Control)借由管理数据行多版本达成数据库事务并发时的一致性读。当事务读取正被更新行时,可获取更新前版本,有效化解读写冲突,此技术确保 InnoDB 事务隔离级别下一致性读操作顺畅执行,各 DBMS 实现方式有别且无统一标准,本文聚焦 InnoDB 实现机制(MySQL 其他存储引擎不支持)。

(二)快照读与当前读

1. 快照读

2. 当前读

1
2
3
4
5
SELECT * FROM student LOCK IN SHARE MODE; # 共享锁
SELECT * FROM student FOR UPDATE; # 排他锁
INSERT INTO student values ... # 排他锁
DELETE FROM student WHERE ... # 排他锁
UPDATE student SET ... # 排他锁

三、MVCC 三剑客

(一)回顾隔离级别

事务隔离级别调控并发事务对资源访问隔离程度,越高则事务干扰越小、安全性越高,主要级别含读未提交、读提交、可重复读(MySQL 默认)、串行化,各有特性与读问题表现:

(二)隐藏字段、Undo Log 版本链

1. 隐藏字段

InnoDB 表聚簇索引记录含关键隐藏列:

2. Undo Log(回滚日志)

事务写操作时,MySQL 先存旧版本数据于 Undo Log,再更新数据库,事务提交前用于回滚,提交后助提供历史版本读取数据,其版本链为链表结构,各版本依时间或事务推进有序排列,头部为最新版本,MVCC 依此定位检索历史版本,保数据完整性与一致性,助事务回滚与多版本并发控制。

(三)ReadView

1. ReadView(读视图)简约版

事务快照读时生成 ReadView,快照记录系统活跃事务 id(递增),主用于数据可见性判定。含关键全局属性:

2. 设计思路

3. ReadView 的规则

依 ReadView 判断记录版本可见性规则如下:

4. MVCC 整体操作流程

MVCC 查询记录流程为:

四、举例说明 MVCC 流程

img

(一)读提交的 MVCC 流程

读提交:事务每次读到的都是最新已提交的数据。底层由MVVC实现 ,每次读取数据前都生成一个ReadView。只解决脏读问题。

读提交的MVCC原理:每次读都生成Read View,不断对比版本链各版本的trx_id,直到发现某版本trx_id比Read View的活跃事务列表里最小trx_id还小,该版本则是读前最新已提交的数据

现在有两个 事务id 分别为 10 、 20 的事务在执行:

1
2
3
4
5
6
7
8
# Transaction 10
BEGIN;
UPDATE student SET name="李四" WHERE id=1;
UPDATE student SET name="王五" WHERE id=1;
# Transaction 20
BEGIN;
# 更新了一些别的表的记录
...

说明:事务执行过程中,只有在第一次真正修改记录时(比如使用INSERT、DELETE、UPDATE语句),才会被分配一个单独的事务id,这个事务id是递增的。所以我们才在事务2中更新一些别的表的记录,目的是让它分配事务id。

此刻,表student 中 id 为 1 的记录得到的版本链表如下所示:

img

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

1
2
3
4
5
# 使用READ COMMITTED隔离级别的事务
BEGIN;

# SELECT1:Transaction 1020未提交
SELECT * FROM student WHERE id = 1; # 得到的列name的值为'张三',对应事务id为8

这个 SELECT1 的执行过程如下:

  1. 在执行 SELECT 语句(快照读)时会先生成一个 ReadView。ReadView 的 trx_ids 列表的内容就是[10,20]。up_limit_id为10,low_limit_id为21,creator_trx_id为0。

  2. 从版本链中挑选可见的记录,从图中看出,最新版本的列 name 的内容是王五,该版本的 trx_id 值为10,在trx_ids 列表内,所以不符合可见性要求,根据 roll_pointer 跳到下一个版本。

  3. 下一个版本的列name 的内容是,李四,该版本的 trx_id值也为 10,也在 trx_ids 列表内,所以也不符合要求,继续跳到下一个版本。

  4. 下一个版本的列name 的内容是,张三,该版本的trx_id值为 8,小于ReadView中的最小活跃事务up_limit_id值10,说明该版本是生成ReadView 之前的最近版本,所以这个版本是符合要求的,最后返回给用户的版本就是这条列 name 为,张三的记录。

之后,我们把 事务id10 的事务提交一下:

1
2
3
4
5
# Transaction 10
BEGIN;
UPDATE student SET name="李四" WHERE id=1;
UPDATE student SET name="王五" WHERE id=1;
COMMIT;

然后再到 事务id20 的事务中更新一下表 studentid1 的记录:

1
2
3
4
5
6
# Transaction 20
BEGIN;
# 更新了一些别的表的记录
...
UPDATE student SET name="钱七" WHERE id=1;
UPDATE student SET name="宋八" WHERE id=1;

img

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

1
2
3
4
5
6
7
8
# 使用READ COMMITTED隔离级别的事务
BEGIN;

# SELECT1:Transaction 1020均未提交
SELECT * FROM student WHERE id = 1; # 得到的列name的值为'张三'

# SELECT2:Transaction 10提交,Transaction 20未提交
SELECT * FROM student WHERE id = 1; # 得到的列name的值为'王五'

这个 SELECT2 的执行过程如下

  1. 在执行 SELECT 语句时会又会单独生成一个 ReadView,该ReadView的 trx_ids 列表的内容[20],就是up_limit_id为20,low_limit_id为21, creator_trx_id为0。

  2. 从版本链中挑选可见的记录,从图中看出,最新版本的列 name 的内容是,宋八,该版本的 trx_id 值为20,在 trx_ids 列表内,所以不符合可见性要求,根据 roll_pointer 跳到下一个版本。

  3. 下一个版本的列name 的内容是钱七’,该版本的 trx_id值为 28,也在 trx_ids 列表内,所以也不符合要求,继续跳到下一个版本。

  4. 下一个版本的列name 的内容是’王五’,该版本的trx_id值为10,小于ReadView 中的up_limit_id值28,所以这个版本是符合要求的,最后返回给用户的版本就是这条列 name 为,王五’的记录。

(二)可重复读的 MVCC 流程

只在第一次查询时生成ReadView,之后查询用第一次快照读时生成的ReadView。

比如,系统里有两个 事务id 分别为 1020 的事务在执行:

1
2
3
4
5
6
7
8
# Transaction 10
BEGIN;
UPDATE student SET name="李四" WHERE id=1;
UPDATE student SET name="王五" WHERE id=1;
# Transaction 20
BEGIN;
# 更新了一些别的表的记录
...

此刻,表student 中 id 为 1 的记录得到的版本链表如下所示:img

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

1
2
3
4
5
# 使用REPEATABLE READ隔离级别的事务
BEGIN;

# SELECT1:Transaction 1020未提交
SELECT * FROM student WHERE id = 1; # 得到的列name的值为'张三'

这个SELECT1 的执行过程如下:

  1. 在执行 SELECT 语句时会先生成一个Readview,ReadView的 trx_ids 列表的内容就是[10,20].up_limit_id为10,low_limit_id为21,creator_trx_id为0。

  2. 然后从版本链中挑选可见的记录,从图中看出,最新版本的列 name 的内容是,王五’,该版本的 trx_id值为 10,在trx_ids 列表内,所以不符合可见性要求,根据 rol1_pointer 跳到下一个版本.

  3. 下一个版本的列name 的内容是,李四’,该版本的trx_id值也为 10,也在 trx_ids 列表内,所以也不符合要求,继续跳到下一个版本。

  4. 下一个版本的列name 的内容是’张三’,该版本的trx_id值为 8,小于Readview 中的up_limit_id值10,所以这个版本是符合要求的,最后返回给用户的版本就是这条列 name 为,张三’的记录。

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

1
2
3
4
5
6
7
# Transaction 10
BEGIN;

UPDATE student SET name="李四" WHERE id=1;
UPDATE student SET name="王五" WHERE id=1;

COMMIT;

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

1
2
3
4
5
6
# Transaction 20
BEGIN;
# 更新了一些别的表的记录
...
UPDATE student SET name="钱七" WHERE id=1;
UPDATE student SET name="宋八" WHERE id=1;

此刻,表student 中 id1 的记录的版本链长这样:

img

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

1
2
3
4
5
6
# 使用REPEATABLE READ隔离级别的事务
BEGIN;
# SELECT1:Transaction 1020均未提交
SELECT * FROM student WHERE id = 1; # 得到的列name的值为'张三'
# SELECT2:Transaction 10提交,Transaction 20未提交
SELECT * FROM student WHERE id = 1; # 得到的列name的值仍为'张三'

SELECT2 的执行过程如下:

  1. 因为当前事务的隔离级别为 REPEATABLE READ,而之前在执行SELECT1 时已经生成过 ReadView了,所以此时直接复用之前的 ReadView,之前的 ReadView的 trx_ids 列表的内容就是[10,20],up_limit_id为1, low_limit_id为21, creator_trx_id为0

  2. 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列 name 的内容是宋八’,该版本的trx_id值为29,在trx_ds列表内,所以不符合可见性要求,根据ro11 pointer 跳到下一个版本

  3. 下一个版本的列name 的内容是’钱七’,该版本的 trx_id值为20,也在 trx_ids 列表内,所以也不符合要求,继续跳到下一个版本。

  4. 下一个版本的列name 的内容是,王五’,该版本的trx_id值为10,而trx_ids 列表中是包含值为 10的事务id 的,所以该版本也不符合要求,同理下一个列 name 的内容是,李四的版本也不符合要求。继续跳到下个版本

  5. 下一个版本的列name 的内容是’张三’,该版本的trx_id值为 80,小于 ReadView 中的up_limit_id值18,所以这个版本是符合要求的,最后返回给用户的版本就是这条列 c为,张三’的记录。

这次SELECT查询得到的结果是重复的,记录的列c值都是张三,这就是可重复读的含义。如果我们之后再把事务id为20的记录提交了,然后再到刚才使用REPEATABLE READ隔离级别的事务中继续查找这个id为1的记录,得到的结果还是张三,具体执行过程大家可以自己分析一下。

(三)InnoDB 解决幻读问题

接下来说明InnoDB 是如何解决幻读的。

假设现在表 student 中只有一条数据,数据内容中,主键 id=1,隐藏的 trx_id=10,它的 undo log 如下图所示。

img

假设现在有事务 A 和事务 B 并发执行,事务 A 的事务 id 为 20 , 事务 B 的事务 id 为 30

步骤1:事务 A 开始第一次查询数据,查询的 SQL 语句如下。

1
select * from student where id >= 1;

在开始查询之前,MySQL 会为事务 A 产生一个 ReadView,此时 ReadView 的内容如下: trx_ids= [20,30] , up_limit_id=20 , low_limit_id=31 , creator_trx_id=20 。

由于此时表 student 中只有一条数据,且符合 where id>=1 条件,因此会查询出来。然后根据 ReadView 机制,发现该行数据的trx_id=10,小于事务 A 的 ReadView 里 up_limit_id,这表示这条数据是事务 A 开启之前,其他事务就已经提交了的数据,因此事务 A 可以读取到。

结论:事务 A 的第一次查询,能读取到一条数据,id=1。

步骤2:接着事务 B(trx_id=30),往表 student 中新插入两条数据,并提交事务。

1
2
insert into student(id,name) values(2,'李四');
insert into student(id,name) values(3,'王五');

此时表student 中就有三条数据了,对应的 undo 如下图所示:

img

步骤3:接着事务 A 开启第二次查询,根据可重复读隔离级别的规则,此时事务 A 并不会再重新生成 ReadView。此时表 student 中的 3 条数据都满足 where id>=1 的条件,因此会先查出来。然后根据 ReadView 机制,判断每条数据是不是都可以被事务 A 看到。

1)首先 id=1 的这条数据,前面已经说过了,可以被事务 A 看到。

2)然后是 id=2 的数据,它的 trx_id=30,此时事务 A 发现,这个值处于 up_limit_id 和 low_limit_id 之 间,因此还需要再判断 30 是否处于 trx_ids 数组内。由于事务 A 的 trx_ids=[20,30],因此在数组内,这表 示 id=2 的这条数据是与事务 A 在同一时刻启动的其他事务提交的,所以这条数据不能让事务 A 看到。

3)同理,id=3 的这条数据,trx_id 也为 30,因此也不能被事务 A 看见。
img

结论:最终事务 A 的第二次查询,只能查询出 id=1 的这条数据。这和事务 A 的第一次查询的结果是一样 的,因此没有出现幻读现象,所以说在 MySQL 的可重复读隔离级别下,不存在幻读问题。

五、总结

这里介绍了 MVCC 在 READ COMMITTD 、 REPEATABLE READ 这两种隔离级别的事务在执行快照读操作时 访问记录的版本链的过程。这样使不同事务的 读-写 、 写-读 操作并发执行,从而提升系统性能。

核心点在于 ReadView 的原理, READ COMMITTD 、 REPEATABLE READ 这两个隔离级别的一个很大不同 就是生成ReadView的时机不同:

说明: 我们之前说执行DELETE语句或者更新主键的UPDATE语句并不会立即把对应的记录完全从页面中删除而是执行一个所谓的delete mark操作,相当于只是对记录打上了一个删除标志位,这主要就是为MVCC服务的。

通过MVCC我们可以解决:

last update time 2024-12-11