MySQL事务的多版本并发控制(MVCC)实现原理

本文涉及的产品
RDS MySQL Serverless 基础系列,0.5-2RCU 50GB
云数据库 RDS MySQL,集群系列 2核4GB
推荐场景:
搭建个人博客
日志服务 SLS,月写入数据量 50GB 1个月
简介: 一、什么是多版本并发控制多版本并发控制 技术的英文全称是 Multiversion Concurrency Control,简称 MVCC。多版本并发控制(MVCC) 是通过保存数据在某个时间点的快照来实现并发控制的。也就是说,不管事务执行多长时间,事务内部看到的数据是不受其它事务影响的,根据事务开始的时间不同,每个事务对同一张表,同一时刻看到的数据可能是不一样的。多版本并发控制 的思想就是保存数据的历史版本,通过对数据行的多个版本管理来实现数据库的并发控制。这样我们就可以通过比较版本号决定数据是否显示出来,读取数据的时候不需要加锁也可以保证事务的隔离效果。


一、什么是多版本并发控制



多版本并发控制 技术的英文全称是 Multiversion Concurrency Control,简称 MVCC

多版本并发控制(MVCC) 是通过保存数据在某个时间点的快照来实现并发控制的。也就是说,不管事务执行多长时间,事务内部看到的数据是不受其它事务影响的,根据事务开始的时间不同,每个事务对同一张表,同一时刻看到的数据可能是不一样的。

多版本并发控制 的思想就是保存数据的历史版本,通过对数据行的多个版本管理来实现数据库的并发控制。这样我们就可以通过比较版本号决定数据是否显示出来,读取数据的时候不需要加锁也可以保证事务的隔离效果。


通过 多版本并发控制 我们可以解决以下几个问题:

  1. 读写之间阻塞的问题,通过 MVCC 可以让读写互相不阻塞,即读不阻塞写,写不阻塞读,这样就可以提升事务并发处理能力。
  2. 降低了死锁的概率。这是因为 MVCC 采用了乐观锁的方式,读取数据时并不需要加锁,对于写操作,也只锁定必要的行。
  3. 解决一致性读的问题。一致性读也被称为快照读,当我们查询数据库在某个时间点的快照时,只能看到这个时间点之前事务提交更新的结果,而不能看到这个时间点之后事务提交的更新结果。


二、版本链



InnoDB存储引擎的表,每一行记录中都包含一些隐藏字段,其中下面两个字段对MySQL实现MVCC起到重要作用:


  • db_trx_id:最后操作(插入或更新)这条记录的事务 ID。每次一个事务对某条记录进行改动时,都会把该事务的 ID赋值给 db_trx_id 隐藏字段。
  • db_roll_ptr:回滚指针,也就是指向这个记录的 undo 日志 信息。每次一个事务对某条引记录进行改动时,都会把旧的版本记录写入到 undo 日志 中,然后 db_roll_ptr 就相当于一个指针,可以通过它来找到该记录修改前的信息。

image.png

undo 日志 又叫做 回滚日志,它可以保存事务发生期间的数据版本,可以用于回滚,同时可以提供多版本并发控制(MVCC)下的读操作。


例如有下面一张成绩单表:

CREATE TABLE `report`  (
  `id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT,
  `name` varchar(10) NOT NULL COMMENT '姓名',
  `score` tinyint(3) UNSIGNED NOT NULL COMMENT '成绩',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB CHARACTER SET=utf8mb4;
INSERT INTO `report` VALUES (1, '小明', '70');


表中现在只包含一条记录:

SELECT * FROM report;
+----+--------+-------+
| id | name   | score |
+----+--------+-------+
|  1 | 小明   |    70 |
+----+--------+-------+

假设插入该记录的事务 id 为20,之后有两个事务分别对这条记录进行 UPDATE 操作, 事务 id 分别为 30 和 40,操作流程如下:

事务 30 事务 40
BEGIN; -
- BEGIN;
UPDATE report SET score=80 WHERE id=1; -
UPDATE report SET score=81 WHERE id=1; -
COMMIT; -
- UPDATE report SET score=90 WHERE id=1;
- UPDATE report SET score=91 WHERE id=1;
- COMMIT;


每次对记录进行改动,都会记录一条 undo 日志,每条 undo 日志都有一个 db_roll_ptr 属性(INSERT操作对应的undo日志没有该属性,因为该记录并没有更早的版本),可以将这些 undo 日志都连起来,串成一条链表,如下图:

image.png


每次对记录更新后,都会将旧值放到一条 undo 日志 中,保存为该记录的一个旧版本,随着更新次数的增多,所有的版本都会被 db_roll_ptr 属性连接成一个链表,我们把这个链表称之为版本链,版本链的头节点就是当前记录最新的值,每个版本中还包含生成该版本时对应的事务ID(db_trx_id)。


三、ReadView(读视图)



对于使用 已提交读可重复读 隔离级别的事务,必须保证读到 已经提交 的事务修改的记录,也就是说假如另一个事务已经修改了记录但是尚未提交,是不能直接读取最新版本的记录的。


核心问题就是:需要判断一下版本链中的哪个版本是当前事务可见的。为此,InnoDB提出了 ReadView(读视图) 的概念,这个ReadView中主要包含4个比较重要的属性:

  • m_ids:表示在生成ReadView时当前系统中活跃的读写事务的事务id列表。
  • min_trx_id:表示在生成ReadView时当前系统中活跃的读写事务中最小的事务id,也就是 m_ids 中的最小值。
  • max_trx_id:表示生成ReadView时系统中应该分配给下一个事务的id值。
  • creator_trx_id:表示生成该ReadView的事务的事务id。


注意 max_trx_id 并不是 m_ids 中的最大值,事务id是递增分配的。比如现在有id为1,2,3这三个事务,之后id为3的事务提交了。那么一个新的读事务在生成ReadView时, m_ids 就包括1和2, min_trx_id 的值就是1, max_trx_id 的值就是4。只有在对表中的记录做改动时(执行INSERT、DELETE、UPDATE语句时)才会为事务分配事务id,否则在一个只读事务中的事务id值都默认为0。


MVCC数据可见性算法

有了 ReadView,在访问某条记录时,只需要按照如下步骤判断记录的某个版本是否可见:

  1. 如果被访问版本的 db_trx_id 值等于ReadView中的 creator_trx_id 值,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。
  2. 如果被访问版本的 db_trx_id 值小于ReadView中的 min_trx_id 值,表明生成该版本的事务在当前事务生成ReadView前已经提交,所以该版本可以被当前事务访问。
  3. 如果被访问版本的 db_trx_id 值大于或等于ReadView中的 max_trx_id 值,表明生成该版本的事务在当前事务生成ReadView后才开启,所以该版本不可以被当前事务访问。
  4. 如果被访问版本的 db_trx_id 值在ReadView的 min_trx_idmax_trx_id 之间, db_trx_id 属性值是不是在 m_ids 列表中,如果在,说明创建ReadView时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建ReadView时生成该版本的事务已经被提交,该版本可以被访问。
  5. 如果某个版本的数据对当前事务不可见,就顺着版本链找到上一个版本的数据,继续按照上边的步骤依次判断可见性,直到版本链中最早的版本。如果最早的版本也不可见,那么就意味着该条记录对该事务完全不可见,查询结果就不包含该记录。

image.png


ReadView 中已提交读可重复读隔离级别的区别

在MySQL中,已提交读可重复读 隔离级别的的一个非常大的区别就是它们生成 ReadView 的时机不同:

  • 已提交读 隔离级别在每一次进行普通 SELECT 操作前都会生成一个ReadView。
  • 可重复读 只在第一次进行普通SELECT操作前生成一个ReadView,之后的查询操作都重复使用这个ReadView。


已提交读:每次读取数据前都生成一个ReadView

对于使用 已提交读 隔离级别的事务来说,每次读取数据前都生成一个ReadView。我们用具体的实例看一下是什么效果。

我们还是以表 report 为例,现在表 report 中只有一条记录,最后修改该条记录的事务ID为 40:

SELECT * FROM report;
+----+--------+-------+
| id | name   | score |
+----+--------+-------+
|  1 | 小明   |    91 |
+----+--------+-------+

现在系统里有两个事务id分别为50、52的事务在执行:

# 事务50
BEGIN;
UPDATE report SET score = 70 WHERE id = 1;
UPDATE report SET score = 71 WHERE id = 1;
# 事务52
BEGIN;
# 更新了一些别的表的记录
...
事务执行过程中,只有在第一次真正修改记录时(比如使用INSERT、DELETE、UPDATE语句),才会被分配一个单独的事务id,这个事务id是递增的。所以我们才在 事务52 中更新一些别的表的记录,目的是让它分配事务id。

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

image.png

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

# 将当前会话的隔离级别设为读已提交
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
SHOW VARIABLES LIKE 'transaction_isolation';
+-----------------------+-----------------+
| Variable_name         | Value           |
+-----------------------+-----------------+
| transaction_isolation | READ-COMMITTED |
+-----------------------+-----------------+
# 使用读已提交隔离级别开启一个新的事务
BEGIN;
# SELECT1:事务 50、52未提交
SELECT * FROM report WHERE id = 1;
+----+--------+-------+
| id | name   | score |
+----+--------+-------+
|  1 | 小明   |    91 |
+----+--------+-------+
# 得到的列score的值为91

结合 “MVCC数据可见性算法判断流程图”,可分析出上面 SELECT1 语句的执行过程如下:

  1. 在执行SELECT语句时会先生成一个ReadView,ReadView的 m_ids 列表的内容就是 [50, 52],min_trx_id 为50,max_trx_id 为53,creator_trx_id 为0。
  2. 从版本链中挑选可见的记录,从图中可以看出,最新版本的列score的内容是71,该版本的 trx_id 值为 50,在 m_ids 列表内,所以不符合可见性要求,根据 db_roll_ptr 跳到上一个版本。
  3. 上一个版本的列score的内容是70,该版本的 trx_id 值也为 50,也在 m_ids 列表内,所以也不符合要求,继续跳到上一个版本。
  4. 上一个版本的列score的内容是91,该版本的 trx_id 值为 40,小于ReadView中的 min_trx_id 值50,所以这个版本是符合要求的,最后返回给用户的版本就是这条列score为91的记录。

之后,我们把事务id为50的事务提交一下:

# 事务 50
BEGIN;
UPDATE report SET score = 70 WHERE id = 1;
UPDATE report SET score = 71 WHERE id = 1;
COMMIT;

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

# 事务 52
BEGIN;
# 更新了一些别的表的记录
...
UPDATE report SET score = 75 WHERE id = 1;
UPDATE report SET score = 78 WHERE id = 1;


此刻,表report中id为1的记录的版本链就长这样:

image.png

然后再到刚才使用 已提交读 隔离级别的事务中继续查找这条id为1的记录,如下:

# 使用读已提交隔离级别开启一个新的事务
BEGIN;
# SELECT1:事务 50、52未提交
SELECT * FROM report WHERE id = 1;
+----+--------+-------+
| id | name   | score |
+----+--------+-------+
|  1 | 小明   |    91 |
+----+--------+-------+
# 得到的列score的值为91
# SELECT2:事务 50 提交,事务 52 未提交
SELECT * FROM report WHERE id = 1; 
+----+--------+-------+
| id | name   | score |
+----+--------+-------+
|  1 | 小明   |    71 |
+----+--------+-------+
# 得到的列score的值为71


结合 “MVCC数据可见性算法流程图”,可分析出上面 SELECT2 语句的执行过程如下:

  1. 因为当前事务的隔离级别为 已提交读,所以在执行SELECT语句时会又会单独生成一个ReadView,该ReadView的 m_ids 列表的内容就是 [52],min_trx_id 为52,max_trx_id 为53,creator_trx_id 为0。
  2. 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列score的内容是78,该版本的 trx_id 值为 52,在 m_ids 列表内,所以不符合可见性要求,根据 db_roll_ptr 跳到上一个版本。
  3. 上一个版本的列score的内容是75,该版本的 trx_id 值为 52,也在 m_ids 列表内,所以也不符合要求,继续跳到上一个版本。
  4. 上一个版本的列score的内容是71,该版本的 trx_id 值为 50,小于ReadView中的 min_trx_id 值52。所以这个版本是符合要求的,最后返回给用户的版本就是这条列score为71的记录。

以此类推,如果之后事务id为52的记录也提交了,再次在使用已提交读隔离级别的事务中查询 SELECT * FROM report WHERE id = 1; 时,得到的结果就是列score的值为78了,具体流程不再分析。


总结一下就是:使用读已提交隔离级别的事务在每次查询开始时都会生成一个独立的ReadView


可重复读:在第一次读取数据时生成一个ReadView

对于使用 可重复读 隔离级别的事务来说,只会在第一次执行查询语句时生成一个ReadView,之后的查询就不会重复生成了。我们还是用具体的实例看一下是什么效果。

我们还是以表 report 为例,现在表 report 中只有一条记录,最后修改该条记录的事务ID为 52:

SELECT * FROM report;
+----+--------+-------+
| id | name   | score |
+----+--------+-------+
|  1 | 小明   |    78 |
+----+--------+-------+

现在系统里有两个事务id分别为60、62的事务在执行:

# 事务 60
BEGIN;
UPDATE report SET score = 60 WHERE id = 1;
UPDATE report SET score = 61 WHERE id = 1;
# 事务 62
BEGIN;
# 更新了一些别的表的记录
...

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

image.png

假设现在有一个使用 可重复读 隔离级别的事务开始执行:

# 将当前会话的隔离级别设为读已提交
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SHOW VARIABLES LIKE 'transaction_isolation';
+-----------------------+-----------------+
| Variable_name         | Value           |
+-----------------------+-----------------+
| transaction_isolation | REPEATABLE-READ |
+-----------------------+-----------------+
# 使用可重复读隔离级别开启一个新的事务
BEGIN;
# SELECT1:事务 60、62未提交
SELECT * FROM report WHERE id = 1;
+----+--------+-------+
| id | name   | score |
+----+--------+-------+
|  1 | 小明   |    78 |
+----+--------+-------+
# 得到的列score的值为78

结合 “MVCC数据可见性算法判断流程图”,可分析出上面 SELECT1 语句的执行过程如下:

  1. 在执行SELECT语句时会先生成一个ReadView,ReadView的 m_ids 列表的内容就是 [60, 62],min_trx_id 为60,max_trx_id 为63,creator_trx_id 为0。
  2. 从版本链中挑选可见的记录,从图中可以看出,最新版本的列score的内容是61,该版本的 trx_id 值为 60,在 m_ids 列表内,所以不符合可见性要求,根据 db_roll_ptr 跳到上一个版本。
  3. 上一个版本的列score的内容是60,该版本的 trx_id 值也为 60,也在 m_ids 列表内,所以也不符合要求,继续跳到上一个版本。
  4. 上一个版本的列score的内容是78,该版本的 trx_id 值为 52,小于ReadView中的 min_trx_id 值60,所以这个版本是符合要求的,最后返回给用户的版本就是这条列score为78的记录。

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

# 事务 60
BEGIN;
UPDATE report SET score = 60 WHERE id = 1;
UPDATE report SET score = 61 WHERE id = 1;
COMMIT;

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

# 事务 62
BEGIN;
# 更新了一些别的表的记录
...
UPDATE report SET score = 65 WHERE id = 1;
UPDATE report SET score = 68 WHERE id = 1;

此刻,表report中id为1的记录的版本链就长这样:

image.png

然后再到刚才使用 可重复读 隔离级别开启的事务中继续查找这个id为1的记录,如下:

# 使用可重复读隔离级别开启一个新的事务
BEGIN;
# SELECT1:事务 60、62未提交
SELECT * FROM report WHERE id = 1;
+----+--------+-------+
| id | name   | score |
+----+--------+-------+
|  1 | 小明   |    78 |
+----+--------+-------+
# 得到的列score的值为78
# SELECT2:事务 60 提交,事务 62 未提交
SELECT * FROM report WHERE id = 1; 
+----+--------+-------+
| id | name   | score |
+----+--------+-------+
|  1 | 小明   |    78 |
+----+--------+-------+
# 得到的列score的值仍为78

上面 SELECT2 语句的执行过程如下:

  1. 因为当前事务的隔离级别为 可重复读,而之前在执行SELECT1 时已经生成过ReadView了,所以此时直接复用之前的ReadView,之前的ReadView的 m_ids 列表的内容就是 [60, 62],min_trx_id 为60,max_trx_id 为63,creator_trx_id 为0。
  2. 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列score的内容是68,该版本的 trx_id 值为 62,在 m_ids 列表内,所以不符合可见性要求,根据 db_roll_ptr 跳到上一个版本。
  3. 上一个版本的列score的内容是65,该版本的 trx_id 值为 62,也在 m_ids 列表内,所以也不符合要求,继续跳到上一个版本。
  4. 上一个版本的列score的内容是61,该版本的 trx_id 值为 60,而 m_ids 列表中是包含值为 60 的事务id的,所以该版本也不符合要求,同理下一个列score的内容是60的版本也不符合要求。继续跳到上一个版本。
  5. 上一个版本的列score的内容是78,该版本的 trx_id 值为52,小于ReadView中的 min_trx_id 值60,所以这个版本是符合要求的,最后返回给用户的版本就是这条列score为78的记录。


也就是说两次SELECT查询得到的结果是重复的,记录score的值都是78,这就是可重复读的意义。如果我们之后再把事务id为62的记录提交了,然后再使用 可重复读 隔离级别的事务继续查找这个id为1的记录,得到的结果还是score为78。

相关实践学习
如何快速连接云数据库RDS MySQL
本场景介绍如何通过阿里云数据管理服务DMS快速连接云数据库RDS MySQL,然后进行数据表的CRUD操作。
全面了解阿里云能为你做什么
阿里云在全球各地部署高效节能的绿色数据中心,利用清洁计算为万物互联的新世界提供源源不断的能源动力,目前开服的区域包括中国(华北、华东、华南、香港)、新加坡、美国(美东、美西)、欧洲、中东、澳大利亚、日本。目前阿里云的产品涵盖弹性计算、数据库、存储与CDN、分析与搜索、云通信、网络、管理与监控、应用服务、互联网中间件、移动服务、视频服务等。通过本课程,来了解阿里云能够为你的业务带来哪些帮助     相关的阿里云产品:云服务器ECS 云服务器 ECS(Elastic Compute Service)是一种弹性可伸缩的计算服务,助您降低 IT 成本,提升运维效率,使您更专注于核心业务创新。产品详情: https://www.aliyun.com/product/ecs
相关文章
|
10天前
|
存储 SQL 关系型数据库
MySQL进阶突击系列(03) MySQL架构原理solo九魂17环连问 | 给大厂面试官的一封信
本文介绍了MySQL架构原理、存储引擎和索引的相关知识点,涵盖查询和更新SQL的执行过程、MySQL各组件的作用、存储引擎的类型及特性、索引的建立和使用原则,以及二叉树、平衡二叉树和B树的区别。通过这些内容,帮助读者深入了解MySQL的工作机制,提高数据库管理和优化能力。
|
1月前
|
存储 关系型数据库 MySQL
MySQL MVCC全面解读:掌握并发控制的核心机制
【10月更文挑战第15天】 在数据库管理系统中,MySQL的InnoDB存储引擎采用了一种称为MVCC(Multi-Version Concurrency Control,多版本并发控制)的技术来处理事务的并发访问。MVCC不仅提高了数据库的并发性能,还保证了事务的隔离性。本文将深入探讨MySQL中的MVCC机制,为你在面试中遇到的相关问题提供全面的解答。
216 2
|
11天前
|
缓存 关系型数据库 MySQL
MySQL 索引优化与慢查询优化:原理与实践
通过本文的介绍,希望您能够深入理解MySQL索引优化与慢查询优化的原理和实践方法,并在实际项目中灵活运用这些技术,提升数据库的整体性能。
41 5
|
23天前
|
SQL 存储 关系型数据库
MySQL进阶突击系列(01)一条简单SQL搞懂MySQL架构原理 | 含实用命令参数集
本文从MySQL的架构原理出发,详细介绍其SQL查询的全过程,涵盖客户端发起SQL查询、服务端SQL接口、解析器、优化器、存储引擎及日志数据等内容。同时提供了MySQL常用的管理命令参数集,帮助读者深入了解MySQL的技术细节和优化方法。
|
24天前
|
SQL 缓存 关系型数据库
MySQL Limit实现原理
本文深入解析了MySQL中`LIMIT`子句的实现原理及其在分页、性能优化等场景下的应用技巧。文章详细介绍了`LIMIT`的基本语法、MySQL内部处理流程,以及如何通过索引优化、覆盖索引等策略提升分页查询的性能,并提供了实践建议。
|
1月前
|
关系型数据库 MySQL
mysql 5.7.x版本查看某张表、库的大小 思路方案说明
mysql 5.7.x版本查看某张表、库的大小 思路方案说明
73 5
|
1月前
|
存储 关系型数据库 MySQL
MySQL MVCC深度解析:掌握并发控制的艺术
【10月更文挑战第23天】 在数据库领域,MVCC(Multi-Version Concurrency Control,多版本并发控制)是一种重要的并发控制机制,它允许多个事务并发执行而不产生冲突。MySQL作为广泛使用的数据库系统,其InnoDB存储引擎就采用了MVCC来处理事务。本文将深入探讨MySQL中的MVCC机制,帮助你在面试中自信应对相关问题。
162 3
|
1月前
|
关系型数据库 MySQL
mysql 5.7.x版本查看某张表、库的大小 思路方案说明
mysql 5.7.x版本查看某张表、库的大小 思路方案说明
41 1
|
1月前
|
SQL 关系型数据库 MySQL
MySql5.6版本开启慢SQL功能-本次采用永久生效方式
MySql5.6版本开启慢SQL功能-本次采用永久生效方式
43 0
|
3天前
|
存储 Oracle 关系型数据库
数据库传奇:MySQL创世之父的两千金My、Maria
《数据库传奇:MySQL创世之父的两千金My、Maria》介绍了MySQL的发展历程及其分支MariaDB。MySQL由Michael Widenius等人于1994年创建,现归Oracle所有,广泛应用于阿里巴巴、腾讯等企业。2009年,Widenius因担心Oracle收购影响MySQL的开源性,创建了MariaDB,提供额外功能和改进。维基百科、Google等已逐步替换为MariaDB,以确保更好的性能和社区支持。掌握MariaDB作为备用方案,对未来发展至关重要。
13 3