如何理解MVCC

server/2024/11/15 5:01:37/

MVCC是什么?

MVCC,是MultiVersion Concurrency Control的缩写,翻译成中文就是多版本并发控制,多个事务同时访问同一数据时,调控每一个事务获取到数据的具体版本。和数据库锁一样,它也是一种并发控制的解决方案。主要用于解决脏读可重复读部分幻读问题,所以MVCC主要被运用于读提交可重复读这两种事务隔离级别下。

MVCC解决并发读写问题主要依赖于UndoLog隐藏字段ReadView读视图这三个部分。其中最重要的就是ReadView隐藏字段。

MVCC解决了哪些问题

读未提交事务隔离级别下,直接读取UndoLog版本链中的最新记录即可。

读提交事务隔离级别下,通过MVCC中每次读取数据的时候生成一次ReadView解决脏读问题

可重复读事务隔离级别下,通过MVCC中每次开启一次事务的时候生成一次ReadView解决不可重复读问题

可重复读事务隔离级别下,通过MVCC中每次开启一次事务的时候生成一次ReadView读视图,后续的查询都是根据这个ReadView读视图来判断哪些数据是可读的,即使中途有其他事务插入了新纪录,也是查询不出来这条数据的,所以就很好了避免幻读问题。

快照读与当前读

MVCC在MySQL InnoDB中的实现主要是为了提高数据库并发性能,用更好的方式去处理 读-写冲突 ,做到即使有读写冲突时,也能做到 不加锁 ,即非阻塞并发读 ,而这个读指的就是 快照读 , 而非 当前读当前读实际上是一种加锁的操作,是悲观锁的实现。而MVCC本质是采用乐观锁思想的一种方式。

快照读

快照读又叫一致性读,读取的是快照数据。不加锁的简单的 SELECT 都属于快照读,即不加锁的非阻塞 读;比如这样:

SELECT * FROM player WHERE ...

之所以出现快照读的情况,是基于提高并发性能的考虑,快照读的实现是基于MVCC,它在很多情况下, 避免了加锁操作,降低了开销。

既然是基于多版本,那么快照读可能读到的并不一定是数据的最新版本,而有可能是之前的历史版本。

快照读的前提是隔离级别不是串行级别,串行级别下的快照读会退化成当前读。

当前读

当前读读取的是记录的最新版本(最新数据,而不是历史版本的数据),读取时还要保证其他并发事务 不能修改当前记录,会对读取的记录进行加锁。加锁的 SELECT,或者对数据进行增删改都会进行当前 读。比如:

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

Read View 在 MVCC 里如何工作的?

我们需要了解两个知识:

  • Read View 中四个字段作用;
  • 聚簇索引记录中两个跟事务有关的隐藏列;

什么是ReadView?

那 Read View 到底是个什么东西?

Read View 是事务开始或读取数据时创建的一致性视图,记录了当前系统中活跃事务的集合。这个视图帮助事务确定哪些版本的数据是可见的。通过 Read View,事务能够看到在其开始时已经提交的事务产生的数据版本,而忽略未提交的事务的数据版本。

Read View 有四个重要的字段:

  • m_ids :当前活跃的事务编号列表。指的是在创建 Read View 时,当前数据库中「活跃事务」的事务id编号列表,注意是一个列表,“活跃事务”指的就是,启动了但还没提交的事务
  • min_trx_id :最小活跃事务编号。指的是在创建 Read View 时,当前数据库中「活跃事务」中事务 id编号最小的事务,也就是 m_ids 的最小值。
  • max_trx_id :预分配事务编号。这个并不是 m_ids 的最大值,而是创建 Read View 时当前数据库中应该给下一个事务的 id 值,也就是全局事务中最大的事务 id 值加1;
  • creator_trx_id :ReadView 创建者的事务编号。指的是创建该 Read View 的事务的事务 id

ReadView结构如下图所示:

知道了 Read View 的字段,我们还需要了解聚簇索引记录中的两个隐藏列。

隐藏列和Undo Log版本链

假设在账户余额表插入一条小林余额为 100 万的记录,然后我把这两个隐藏列也画出来,该记录的整个示意图如下:

对于使用 InnoDB 存储引擎的数据库表,它的聚簇索引记录中都包含下面两个隐藏列:

  • trx_id,当一个事务对某条聚簇索引记录进行改动时,就会把该事务的事务 id赋值给trx_id 隐藏列
  • roll_pointer,每次对某条聚簇索引记录进行改动时,都会把旧版本的记录写入到 undo 日志中,然后这个隐藏列是个指针,指向每一个旧版本记录,于是就可以通过它找到修改前的记录。

创建student表,表结构如下:

假设此时插入一条新记录,该记录的事务id为8,那么此刻该条记录的示意图如下所示:

注意:insert undo只在事务回滚时起作用,当事务提交后,该类型的undo日志就没用了,它占用的Undo Log Segment也会被系统回收(也就是该undo日志占用的Undo页面链表要么被重用,要么被释放)。

假设之后两个事务id分别为 10 、 20 的事务对这条记录进行UPDATE 更新操作,操作流程如下图:

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

对该记录每次更新后,都会将旧值放到一条 undo日志 中,就算是该记录的一个旧版本,随着更新次数 的增多,所有的版本都会被 roll_pointer 属性连接成一个链表,我们把这个链表称之为 版本链 ,版本链的头节点就是当前记录最新的值。

每个版本中还包含生成该版本时对应的事务id。

判断规则

在创建 Read View 后,我们可以将记录中的 trx_id 划分这三种情况:

一个事务去访问记录的时候,除了自己的更新记录总是可见之外,还有这几种判断规则:

  • 如果被访问表行记录的 trx_id 值与ReadView中的 creator_trx_id 值相同,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问,该版本的记录对当前事务可见
  • 如果被访问表行记录的 trx_id 值小于 Read View 中的 min_trx_id,表示这个版本的记录是在创建 Read View 已经提交的事务生成的,所以该版本的记录对当前事务可见
  • 如果被访问表行记录的 trx_id 值大于等于 Read View 中的 max_trx_id,表示这个版本的记录是在创建 Read View 才启动的事务生成的,所以该版本的记录对当前事务不可见
  • 如果被访问表行记录的 trx_id 值在 Read View 的 min_trx_idmax_trx_id 之间,需要判断 trx_id 是否在 m_ids 列表中:
    • 如果表行记录的 trx_id 在 m_ids 列表中,表示生成该版本记录的活跃事务依然活跃着(还没提交事务),所以该版本的记录对当前事务不可见
    • 如果表行记录的 trx_id 不在 m_ids列表中,表示生成该版本记录的活跃事务已经被提交,所以该版本的记录对当前事务可见

判断方法

判断方法是先根据 Read View 中的 4 个重要字段,先去 Undo Log 中最新的数据行进行比对,如果满足下面 Read View 的判断规则中的可见规则,则返回当前行的数据,如果不满足则继续从Undo Log 链中查找当前行的数据的下一个历史版本数据,直到找到满足的条件的数据为止,如果查询完Undolog版本链,发现没有满足条件的数据,则返回 NULL。

可重复读是如何工作的?

可重复读隔离级别是启动事务时生成一个 Read View,然后整个事务期间都在用这个 Read View

假设事务 A (事务 id 为51)启动后,紧接着事务 B (事务 id 为52)也启动了,那这两个事务创建的 Read View 如下:

事务 A 和 事务 B 的 Read View 具体内容如下:

  • 在事务 A 的 Read View 中,它的事务 id 是 51,由于它是第一个启动的事务,所以此时活跃事务的事务 id 列表就只有 51,活跃事务的事务 id 列表中最小的事务 id 是事务 A 本身,下一个事务 id 则是 52。
  • 在事务 B 的 Read View 中,它的事务 id 是 52,由于事务 A 是活跃的,所以此时活跃事务的事务 id 列表是 51 和 52,活跃的事务 id 中最小的事务 id 是事务 A,下一个事务 id 应该是 53。

接着,在可重复读隔离级别下,事务 A 和事务 B 按顺序执行了以下操作:

  • 事务 B 读取小林的账户余额记录,读到余额是 100 万;
  • 事务 A 将小林的账户余额记录修改成 200 万,并没有提交事务;
  • 事务 B 读取小林的账户余额记录,读到余额还是 100 万;
  • 事务 A 提交事务;
  • 事务 B 读取小林的账户余额记录,读到余额依然还是 100 万;

接下来,跟大家具体分析下。

事务 B 第一次读小林的账户余额记录,在找到记录后,它会先看这条记录的 trx_id,此时发现 trx_id 为 50,比事务 B 的 Read View 中的 min_trx_id 值(51)还小,这意味着修改这条记录的事务早就在事务 B 启动前提交过了,所以该版本的记录对事务 B 可见的,也就是事务 B 可以获取到这条记录。

接着,事务 A 通过 update 语句将这条记录修改了(还未提交事务),将小林的余额改成 200 万,这时 MySQL 会记录相应的 undo log,并以链表的方式串联起来,形成版本链,如下图:

你可以在上图的「记录的字段」看到,由于事务 A 修改了该记录,以前的记录就变成旧版本记录了,于是最新记录和旧版本记录通过链表的方式串起来,而且最新记录的 trx_id 是事务 A 的事务 id(trx_id = 51)。

然后事务 B 第二次去读取该记录,发现这条记录的 trx_id 值为 51,在事务 B 的 Read View 的 min_trx_id 和 max_trx_id 之间,则需要判断 trx_id 值是否在 m_ids 范围内,判断的结果是在的,那么说明这条记录是被还未提交的事务修改的,这时事务 B 并不会读取这个版本的记录。而是沿着 undo log 链条往下找旧版本的记录,直到找到 trx_id 「小于」事务 B 的 Read View 中的 min_trx_id 值的第一条记录,所以事务 B 能读取到的是 trx_id 为 50 的记录,也就是小林余额是 100 万的这条记录。

最后,当事物 A 提交事务后,由于隔离级别时「可重复读」,所以事务 B 再次读取记录时,还是基于启动事务时创建的 Read View 来判断当前版本的记录是否可见。所以,即使事物 A 将小林余额修改为 200 万并提交了事务, 事务 B 第三次读取记录时,读到的记录都是小林余额是 100 万的这条记录

就是通过这样的方式实现了,「可重复读」隔离级别下在事务期间读到的记录都是事务启动前的记录。

读提交是如何工作的?

读提交隔离级别是在每次读取数据时,都会生成一个新的 Read View

也意味着,事务期间的多次读取同一条数据,前后两次读的数据可能会出现不一致,因为可能这期间另外一个事务修改了该记录,并提交了事务。

那读提交隔离级别是怎么工作呢?我们还是以前面的例子来聊聊。

假设事务 A (事务 id 为51)启动后,紧接着事务 B (事务 id 为52)也启动了,接着按顺序执行了以下操作:

  • 事务 B 读取数据(创建 Read View),小林的账户余额为 100 万;
  • 事务 A 修改数据(还没提交事务),将小林的账户余额从 100 万修改成了 200 万;
  • 事务 B 读取数据(创建 Read View),小林的账户余额为 100 万;
  • 事务 A 提交事务;
  • 事务 B 读取数据(创建 Read View),小林的账户余额为 200 万;

那具体怎么做到的呢?我们重点看事务 B 每次读取数据时创建的 Read View。前两次 事务 B 读取数据时创建的 Read View 如下图:

我们来分析下为什么事务 B 第二次读数据时,读不到事务 A (还未提交事务)修改的数据?

事务 B 在找到小林这条记录时,会看这条记录的 trx_id 是 51,在事务 B 的 Read View 的 min_trx_id 和 max_trx_id 之间,接下来需要判断 trx_id 值是否在 m_ids 范围内,判断的结果是在的,那么说明这条记录是被还未提交的事务修改的,这时事务 B 并不会读取这个版本的记录。而是,沿着 undo log 链条往下找旧版本的记录,直到找到 trx_id 「小于」事务 B 的 Read View 中的 min_trx_id 值的第一条记录,所以事务 B 能读取到的是 trx_id 为 50 的记录,也就是小林余额是 100 万的这条记录。

我们来分析下为什么事务 A 提交后,事务 B 就可以读到事务 A 修改的数据?

在事务 A 提交后,由于隔离级别是「读提交」,所以事务 B 在每次读数据的时候,会重新创建 Read View,此时事务 B 第三次读取数据时创建的 Read View 如下:

事务 B 在找到小林这条记录时,会发现这条记录的 trx_id 是 51,比事务 B 的 Read View 中的 min_trx_id 值(52)还小,这意味着修改这条记录的事务早就在创建 Read View 前提交过了,所以该版本的记录对事务 B 是可见的

正是因为在读提交隔离级别下,事务每次读数据时都重新创建 Read View,那么在事务期间的多次读取同一条数据,前后两次读的数据可能会出现不一致,因为可能这期间另外一个事务修改了该记录,并提交了事务。

可重复读事务隔离级别下使用MVCC可能发生幻读现象的场景有哪些?

1. 快照读和当前读穿插的场景下使用MVCC依旧会发生幻读

当第一个事务如果先使用快照读来读取范围数据,读取期间第二个事务对这个范围进行插入数据的操作,然后第一个事务又使用当前读来读取这个范围的数据,此时就会出现幻读问题。

例如如下场景:

  1. T1 时刻:事务 A 先执行「快照读语句」:select * from t_test where id > 100 得到了 3 条记录。
  2. T2 时刻:事务 B 往插入一个 id= 200 的记录并提交;
  3. T3 时刻:事务 A 再执行「当前读语句」 select * from t_test where id > 100 for update 就会得到 4 条记录,此时也发生了幻读现象。

要避免这类特殊场景下发生幻读的现象的话,就是尽量在开启事务之后,马上执行 select ... for update 这类当前读的语句,因为它会对记录加 next-key lock,从而避免其他事务插入一条新记录。


http://www.ppmy.cn/server/121054.html

相关文章

蓝桥杯嵌入式的学习总结

一. 前言 嵌入式竞赛实训平台(CT117E-M4) 是北京国信长天科技有限公司设计,生产的一款 “ 蓝桥杯全国软件与信息技术专业人才大赛-嵌入式设计与开发科目 “ 专用竞赛平台,平台以STM32G431RBT6为主控芯片,预留扩展板接口,可为用户提…

Docker 镜像制作(Dockerfile)

1 Dockerfile 概念 Dockerfile 是什么? 镜像的定制实际上就是定制每一层所添加的配置、文件。如果我们可以把每一层修改、安装、构建、操作的命令都写入一个脚本,用这个脚本来构建、定制镜像,这个脚本就是 Dockerfile。 Dockerfile 是一个文本文件&a…

通信工程学习:什么是VM虚拟机

VM:虚拟机 VM虚拟机(Virtual Machine)是一种通过软件模拟的计算机系统,它能够在物理计算机上模拟并运行多个独立的虚拟计算机系统。以下是关于VM虚拟机的详细解释: 一、VM虚拟机的定义与原理 定义: VM虚拟…

LLM安全风险及应对

LLM安全风险主要从四个维度分析:用户输入、训练数据、模型本身以及工具和插件。 风险类别具体风险风险解释应对措施具体举例用户输入相关风险提示注入(Prompt Injection)攻击者通过设计特定输入,使模型生成恶意或不安全的输出。- …

Go语言笔记

目录 一、变量声明 二、流程控制 if(条件判断) for(循环结构) Switch(简化if) goto(跳出循环) 三、运算符 1、算数运算符 2、关系运算符 3、逻辑运算符 4、位运算符 5、…

数据库第五章实验——表数据操作

前置工作:创建并选中数据库 create database if not exists storeexpm; use storeexpm; (1). 向 Goods 表插人样本数据。 CREATE TABLE Goods ( 商品号 INT PRIMARY KEY, 商品名称 VARCHAR(255), 商品类型 VARCHAR(255), 单价 DE…

操作符(下)

目录 1.移位操作符 1.1 左移操作符 1 .2 右移操作符 2.位操作符:&、|、^、~ 不能创建临时变量(第三个变量),实现两个整数的交换 编写代码实现:求⼀个整数存储在内存中的⼆进制中1的个数 3.单⽬操作符 4.逗号表…

5. 条件 Conditionals

作业系统链接 Python 条件语句与代码风格学习笔记 一、if 语句 1. 基本用法 定义与流程:if 语句用于基于条件做出决策。条件为 True 时,执行相应的代码块。示例:def f(x):print("A", end"")if x 0:print("B&quo…