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
相关文章
MySQL实现文档全文搜索,分词匹配多段落重排展示,知识库搜索原理分享
本文介绍了在文档管理系统中实现高效全文搜索的方案。为解决原有ES搜索引擎私有化部署复杂、运维成本高的问题,我们转而使用MySQL实现搜索功能。通过对用户输入预处理、数据库模糊匹配、结果分段与关键字标红等步骤,实现了精准且高效的搜索效果。目前方案适用于中小企业,未来将根据需求优化并可能重新引入专业搜索引擎以提升性能。
206 5
MySQL原理简介—9.MySQL索引原理
本文详细介绍了MySQL索引的设计与使用原则,涵盖磁盘数据页的存储结构、页分裂机制、主键索引设计及查询过程、聚簇索引和二级索引的原理、B+树索引的维护、联合索引的使用规则、SQL排序和分组时如何利用索引、回表查询对性能的影响以及索引覆盖的概念。此外还讨论了索引设计的案例,包括如何处理where筛选和order by排序之间的冲突、低基数字段的处理方式、范围查询字段的位置安排,以及通过辅助索引来优化特定查询场景。总结了设计索引的原则,如尽量包含where、order by、group by中的字段,选择离散度高的字段作为索引,限制索引数量,并针对频繁查询的低基数字段进行特殊处理等。
220 18
MySQL原理简介—9.MySQL索引原理
RDS用多了,你还知道MySQL主从复制底层原理和实现方案吗?
随着数据量增长和业务扩展,单个数据库难以满足需求,需调整为集群模式以实现负载均衡和读写分离。MySQL主从复制是常见的高可用架构,通过binlog日志同步数据,确保主从数据一致性。本文详细介绍MySQL主从复制原理及配置步骤,包括一主二从集群的搭建过程,帮助读者实现稳定可靠的数据库高可用架构。
308 9
RDS用多了,你还知道MySQL主从复制底层原理和实现方案吗?
MySQL底层概述—6.索引原理
本文详细回顾了:索引原理、二叉查找树、平衡二叉树(AVL树)、红黑树、B-Tree、B+Tree、Hash索引、聚簇索引与非聚簇索引。
194 11
MySQL底层概述—6.索引原理
京东面试:MySQL MVCC是如何实现的?如何通过MVCC实现读已提交、可重复读隔离级别的?
1.请解释什么是MVCC,它在数据库中的作用是什么? 2.在MySQL中,MVCC是如何实现的?请简述其工作原理。 3.MVCC是如何解决读-写和写-写冲突的? 4.在并发环境中,当多个事务同时读取同一行数据时,MVCC是如何保证每个事务看到的数据版本是一致的? 5.MVCC如何帮助提高数据库的并发性能?
京东面试:MySQL MVCC是如何实现的?如何通过MVCC实现读已提交、可重复读隔离级别的?
MySQL原理简介—12.MySQL主从同步
本文介绍了四种为MySQL搭建主从复制架构的方法:异步复制、半同步复制、GTID复制和并行复制。异步复制通过配置主库和从库实现简单的主从架构,但存在数据丢失风险;半同步复制确保日志复制到从库后再提交事务,提高了数据安全性;GTID复制简化了配置过程,增强了复制的可靠性和管理性;并行复制通过多线程技术降低主从同步延迟,保证数据一致性。此外,还讨论了如何使用工具监控主从延迟及应对策略,如强制读主库以确保即时读取最新数据。
MySQL原理简介—12.MySQL主从同步
MySQL原理简介—8.MySQL并发事务处理
这段内容深入探讨了SQL语句执行原理、事务并发问题、MySQL事务隔离级别及其实现机制、锁机制以及数据库性能优化等多个方面。
181 13
MySQL原理简介—11.优化案例介绍
本文介绍了四个SQL性能优化案例,涵盖不同场景下的问题分析与解决方案: 1. 禁止或改写SQL避免自动半连接优化。 2. 指定索引避免按聚簇索引全表扫描大表。 3. 按聚簇索引扫描小表减少回表次数。 4. 避免产生长事务长时间执行。
MySQL原理简介—10.SQL语句和执行计划
本文介绍了MySQL执行计划的相关概念及其优化方法。首先解释了什么是执行计划,它是SQL语句在查询时如何检索、筛选和排序数据的过程。接着详细描述了执行计划中常见的访问类型,如const、ref、range、index和all等,并分析了它们的性能特点。文中还探讨了多表关联查询的原理及优化策略,包括驱动表和被驱动表的选择。此外,文章讨论了全表扫描和索引的成本计算方法,以及MySQL如何通过成本估算选择最优执行计划。最后,介绍了explain命令的各个参数含义,帮助理解查询优化器的工作机制。通过这些内容,读者可以更好地理解和优化SQL查询性能。
数据库运维:mysql 数据库迁移方法-mysqldump
本文介绍了MySQL数据库迁移的方法与技巧,重点探讨了数据量大小对迁移方式的影响。对于10GB以下的小型数据库,推荐使用mysqldump进行逻辑导出和source导入;10GB以上可考虑mydumper与myloader工具;100GB以上则建议物理迁移。文中还提供了统计数据库及表空间大小的SQL语句,并讲解了如何使用mysqldump导出存储过程、函数和数据结构。通过结合实际应用场景选择合适的工具与方法,可实现高效的数据迁移。
120 1

推荐镜像

更多
AI助理

你好,我是AI助理

可以解答问题、推荐解决方案等

登录插画

登录以查看您的控制台资源

管理云资源
状态一览
快捷访问