MVCC在Mysql中的运用

本文涉及的产品
RDS MySQL DuckDB 分析主实例,集群系列 4核8GB
简介: MVCC在Mysql中的运用

MVCC在Mysql中的运用

MVCC到底是个啥?

定义: 多版本并发控制,字面理解,在并发过程中利用多个版本进行合理控制(反正我就是从字面这么理解),很明显,这个东西是个抽象的概念,事实也是如此。它主要是出现在一些数据管理软件中。

维持一个数据的多个版本,使得读写操作没有冲突。

  • 为什么会有这个东西呢?

我们都知道,数据管理程序提供的功能就是对数据的查询和修改,但是读写过程中如何怎么解决冲突问题呢,为了维护数据的一致性且保持较高的性能,到即使有读写冲突时,也能做到不加锁,非阻塞并发读, MVCC 这种并发控制算法就出现了。

MVCC基础原理

MVCC 是允许一个对象的多个版本同时存在。也就是说,他拥有 "当前" 版本和一个或多个以前的版本。当你在获取版本时可以根据需要使用它的不同版本来解决你面对的问题。在此运行期间, "作者" 可以创建和发布新的对象版本,该版本将成为对象的最新版本, "读者" 依旧也可以使用之前的版本。

到底使用哪些版本提供给 "读者" 呢?这个就和你的真实需求相关了,例如 Mysql 中的隔离级别,不同级别对相同并发操作后的结果看到不一致,我们可以根据需求来合理展示数据对象的版本,在不同隔离级别上实现不同的效果。

既然知道了 MVCC 的抽象,我们再去看看它的实现, MVCC 被利拥到很多的数据管理程序上,无疑证明他是一个很好的设计思路,例如在 MysqlInnodb引擎Etcd存储PostgreSQL、oracle 等等,我们就以 Innodb 引擎为例,观察一下他是如何实现 MVCC

Innodb引擎MVCC探究

Innodb 引擎中,我们需要先了解一些基础概念: undolog版本链ReadView

undolog

我们知道,事务是具有原子性的,但是当系统故障,或者手动回滚时,如何保证这次提交的数据全部进行恢复呢,有时候都可能事务只执行到一半,我们要保证它和原来一样,这个事务看起来什么都没有做,举一个生活中的例子:如象棋,当下错子的时候可以申请悔棋悔棋 就是一种回滚操作,实际上就是之前执行的操作再次执行一次逆向操作,数据库中的回滚跟 悔棋 差不多,你插入一条记录,回滚操作日志对应的就是删除这个记录;你更新了一条记录,回滚操作对应的就是把该记录更新为旧值;你删除一条记录,对应回滚操作就是插入。

这些为了回滚记录的日志称为撤销日志 undolog ,在真实的 InnoDB 中, undo日志 其实并不像我们上边所说的那么简单,不同类型的操作产生的 undo日志 的格式也是不同的,不过先暂时把这些容易让人脑子糊的具体细节放一放,注意我们文章的重点,接着往下看。

版本链

版本链式用来存储该数据行的历史,每次对记录进行更新后,都会将旧值放到一条 undo日志 中,形成该记录的一个旧版本,随着更新次数的增加,所有版本都会被数据行的 roll_pointer 属性连接成一个链表,我们把这个链表称之为版本链,版本链的头节点就是当前记录的最新值。另外,每个版本还包含生成该版本的 事务ID ,大概逻辑如下图所示:

ReadView

RCRR 隔离级别中(READ UNCOMMITTED、SERIALIZABLE 是没有使用的Read View的),都必须保证已经读到已经提交了的事务修改过的记录,也就是说假设另一个记录已经修改了但是尚未提交,是不能直接读取最新版本的记录,核心问题就是:需要判断一下版本链中的哪个版本是当前事务可见的。Read View 这个概念就是为了解决这个问题,它包含了4个内容

  1. m_ids 生成 ReadView 时当前系统中活跃的读写事务ID列表
  2. min_trx_id 生成 ReadView 时当前系统中活跃事务最小的事务ID,即 m_ids 中的最小值
  3. max_trx_id 生成 ReadView 时系统中应该分配给下一个事务ID
  4. creator_trx_id 生成 ReadView 时的事务ID只有在对表中的记录做改动时(执行INSERT、DELETE、UPDATE这些语句时)才会为事务分配事务id,否则在一个只读事务中的事务id值都默认为0。

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

  • 如果被访问版本的 trx_id 属性值与 ReadView 中的 creator_trx_id 值相同,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。
  • 如果被访问版本的 trx_id 属性值小于 ReadView 中的 min_trx_id 值,表明生成该版本的事务在当前事务生成 ReadView 前已经提交,所以该版本可以被当前事务访问。
  • 如果被访问版本的 trx_id 属性值大于或等于 ReadView 中的max_trx_id值,表明生成该版本的事务在当前事务生成 ReadView 后才开启,所以该版本不可以被当前事务访问。
  • 如果被访问版本的 trx_id 属性值在 ReadViewmin_trx_idmax_trx_id 之间,那就需要判断一下 trx_id 属性值是不是在 m_ids 列表中,如果在,说明创建 ReadView 时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建 ReadView 时生成该版本的事务已经被提交,该版本可以被访问。

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

而生成 ReadView 的时机不同也直接影响了查询操作的结果,在 Mysql 中, RCRR 隔离级别最大的区别就是生成 ReadView 的时机不同。

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

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

# Transaction 100
BEGIN;
UPDATE hero SET name = '关羽' WHERE number = 1;
UPDATE hero SET name = '张飞' WHERE number = 1;
# Transaction 200
BEGIN;
# 更新了一些别的表的记录
...

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

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

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

# 使用READ COMMITTED隔离级别的事务
BEGIN;
# SELECT1:Transaction 100、200未提交
SELECT * FROM hero WHERE number = 1; # 得到的列name的值为'刘备'

这个SELECT1的执行过程如下:

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

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

# Transaction 100
BEGIN;
UPDATE hero SET name = '关羽' WHERE number = 1;
UPDATE hero SET name = '张飞' WHERE number = 1;
COMMIT;

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

# Transaction 200
BEGIN;
# 更新了一些别的表的记录
...
UPDATE hero SET name = '赵云' WHERE number = 1;
UPDATE hero SET name = '诸葛亮' WHERE number = 1;

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

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

# 使用READ COMMITTED隔离级别的事务
BEGIN;
# SELECT1:Transaction 100、200均未提交
SELECT * FROM hero WHERE number = 1; # 得到的列name的值为'刘备'
# SELECT2:Transaction 100提交,Transaction 200未提交
SELECT * FROM hero WHERE number = 1; # 得到的列name的值为'张飞'

这个SELECT2的执行过程如下:

  • 在执行SELECT语句时会又会单独生成一个ReadView,该ReadViewm_ids列表的内容就是[200](事务id为100的那个事务已经提交了,所以再次生成快照时就没有它了),min_trx_id为200,max_trx_id为201,creator_trx_id为0。
  • 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列name的内容是'诸葛亮',该版本的trx_id值为200,在m_ids列表内,所以不符合可见性要求,根据roll_pointer跳到下一个版本。
  • 下一个版本的列name的内容是'赵云',该版本的trx_id值为200,也在m_ids列表内,所以也不符合要求,继续跳到下一个版本。
  • 下一个版本的列name的内容是'张飞',该版本的trx_id值为100,小于ReadView中的min_trx_id值200,所以这个版本是符合要求的,最后返回给用户的版本就是这条列name为'张飞'的记录。

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

REPEATABLE READ —— 在第一次读取数据时生成一个ReadView,在一个事务中保证多个查询结果一致

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

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

# Transaction 100
BEGIN;
UPDATE hero SET name = '关羽' WHERE number = 1;
UPDATE hero SET name = '张飞' WHERE number = 1;
# Transaction 200
BEGIN;
# 更新了一些别的表的记录
...

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

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

# 使用REPEATABLE READ隔离级别的事务
BEGIN;
# SELECT1:Transaction 100、200未提交
SELECT * FROM hero WHERE number = 1; # 得到的列name的值为'刘备'

这个SELECT1的执行过程如下:

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

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

# Transaction 100
BEGIN;
UPDATE hero SET name = '关羽' WHERE number = 1;
UPDATE hero SET name = '张飞' WHERE number = 1;
COMMIT;

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

# Transaction 200
BEGIN;
# 更新了一些别的表的记录
...
UPDATE hero SET name = '赵云' WHERE number = 1;
UPDATE hero SET name = '诸葛亮' WHERE number = 1;

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

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

# 使用REPEATABLE READ隔离级别的事务
BEGIN;
# SELECT1:Transaction 100、200均未提交
SELECT * FROM hero WHERE number = 1; # 得到的列name的值为'刘备'
# SELECT2:Transaction 100提交,Transaction 200未提交
SELECT * FROM hero WHERE number = 1; # 得到的列name的值仍为'刘备'

这个SELECT2的执行过程如下:

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

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

总结一下

看了上面的 undolog版本链ReadView 后,我们大致也有了一些想法, ReadView 主要是对记录的历史进行可见性规则制定和规则校验,而记录的历史则是采用链表结构存储在 undolog 个,三者结合解决了 MVCC 中的读写问题。而 mysql 中所谓的 MVCC 指的就是在使用 RCRR 两种隔离级别的事务在执行普通 SELECT 操作时访问版本链的过程,这样子可以使不同事务的读写,写写操作并发执行,从而提升系统性能,而他们最大的不同点就在于生成 ReadView 的时机不同

References

[1] MySQL 是怎样运行的:从根儿上理解 MySQL: https://juejin.cn/book/6844733769996304392/section/6844733770071801870

相关实践学习
每个IT人都想学的“Web应用上云经典架构”实战
本实验从Web应用上云这个最基本的、最普遍的需求出发,帮助IT从业者们通过“阿里云Web应用上云解决方案”,了解一个企业级Web应用上云的常见架构,了解如何构建一个高可用、可扩展的企业级应用架构。
MySQL数据库入门学习
本课程通过最流行的开源数据库MySQL带你了解数据库的世界。   相关的阿里云产品:云数据库RDS MySQL 版 阿里云关系型数据库RDS(Relational Database Service)是一种稳定可靠、可弹性伸缩的在线数据库服务,提供容灾、备份、恢复、迁移等方面的全套解决方案,彻底解决数据库运维的烦恼。 了解产品详情: https://www.aliyun.com/product/rds/mysql 
目录
相关文章
|
分布式计算 监控 大数据
MaxCompute产品使用合集之CASE WHEN语句如何开窗函数一起使用
MaxCompute作为一款全面的大数据处理平台,广泛应用于各类大数据分析、数据挖掘、BI及机器学习场景。掌握其核心功能、熟练操作流程、遵循最佳实践,可以帮助用户高效、安全地管理和利用海量数据。以下是一个关于MaxCompute产品使用的合集,涵盖了其核心功能、应用场景、操作流程以及最佳实践等内容。
265 2
|
SQL 关系型数据库 MySQL
必知的 MySQL 索引失效场景【包括实践验证】,别再踩坑了!(上)
必知的 MySQL 索引失效场景【包括实践验证】,别再踩坑了!
1712 2
|
1天前
|
存储 JavaScript 前端开发
JavaScript基础
本节讲解JavaScript基础核心知识:涵盖值类型与引用类型区别、typeof检测类型及局限性、===与==差异及应用场景、内置函数与对象、原型链五规则、属性查找机制、instanceof原理,以及this指向和箭头函数中this的绑定时机。重点突出类型判断、原型继承与this机制,助力深入理解JS面向对象机制。(238字)
|
2天前
|
安全 数据可视化 网络安全
安全无小事|阿里云先知众测,为企业筑牢防线
专为企业打造的漏洞信息收集平台
1303 2
|
3天前
|
云安全 人工智能
2025,阿里云安全的“年度报告”
拥抱AI时代,阿里云安全为你护航~
1448 2
|
2天前
|
人工智能 自然语言处理 API
n8n:流程自动化、智能化利器
流程自动化助你在重复的业务流程中节省时间,可通过自然语言直接创建工作流啦。
339 4
n8n:流程自动化、智能化利器
|
10天前
|
机器学习/深度学习 安全 API
MAI-UI 开源:通用 GUI 智能体基座登顶 SOTA!
MAI-UI是通义实验室推出的全尺寸GUI智能体基座模型,原生集成用户交互、MCP工具调用与端云协同能力。支持跨App操作、模糊语义理解与主动提问澄清,通过大规模在线强化学习实现复杂任务自动化,在出行、办公等高频场景中表现卓越,已登顶ScreenSpot-Pro、MobileWorld等多项SOTA评测。
1429 7
|
22小时前
|
Linux 数据库
Linux 环境 Polardb-X 数据库 单机版 rpm 包 安装教程
本文介绍在CentOS 7.9环境下安装PolarDB-X单机版数据库的完整流程,涵盖系统环境准备、本地Yum源配置、RPM包安装、用户与目录初始化、依赖库解决、数据库启动及客户端连接等步骤,助您快速部署运行PolarDB-X。
214 1
Linux 环境 Polardb-X 数据库 单机版 rpm 包 安装教程
|
3天前
|
人工智能 API 开发工具
Skills比MCP更重要?更省钱的多!Python大佬这观点老金测了一周终于懂了
加我进AI学习群,公众号右下角“联系方式”。文末有老金开源知识库·全免费。本文详解Claude Skills为何比MCP更轻量高效:极简配置、按需加载、省90% token,适合多数场景。MCP仍适用于复杂集成,但日常任务首选Skills。推荐先用SKILL.md解决,再考虑协议。附实测对比与配置建议,助你提升效率,节省精力。关注老金,一起玩转AI工具。
|
11天前
|
人工智能 Rust 运维
这个神器让你白嫖ClaudeOpus 4.5,Gemini 3!还能接Claude Code等任意平台
加我进AI讨论学习群,公众号右下角“联系方式”文末有老金的 开源知识库地址·全免费
1313 16