MySQL必知之事务(三)—— MVCC

MVCC原理

事务隔离级别

隔离级别脏读不可重复度幻读
READ UNCOMMITTED (读未提交)可能可能可能
READ COMMITTED(读已提交)-可能可能
REPEATABLE READ(可重复读)---
READ UNCOMMITTED(可串行化)---

MySQL在REPEATABLE READ隔离级别下,是可以很大程度避免幻读问题的发生的(好像解决了,但又没完全解决),MySQL是怎么做到的呢?

版本链

必须要知道的概念(每个版本链针对的一条数据)

我们知道,对于使用InnoDB存储引擎的表来说,它的聚集索引记录中都包含两个必要的隐藏列( row_id 并不是必要的,我们创建的表中有主键或非NULLUNIQUE键时都不会包含 row_id列)。

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

补充点
undo日志:为了实现事务的原子性,InnoDB存储引擎在实际进行增、删、改、查一条记录时,都需要先把对应的undo日志记下来。一般每对一条记录做一次改动,就对应着一条undo日志,但在有些更新记录的操作中,也可能会对应着2条undo日志。一个事务在执行过程中可能新增、删除、更新若干条记录,也就是说需要记录很多条对应的undo日志,这些undo日志会被从0开始编写,也就是说根据生成的顺序分别称为第0号undo日志,第1号undo日志、…、第n号undo日志等,这个编号也被称为undo no。

为了说明这个问题,我们创建一个演示表

CREATE TABLE teacher (
number INT,
name VARCHAR(100),
domain varchar(100),
PRIMARY KEY (number)
) Engine=InnoDB CHARSET=utf8;

然后向这个表里插入一条数据

INSERT INTO teacher VALUES(1, '李瑾', 'JVM系列');

现在表里的数据就是这样
在这里插入图片描述
假设插入该记录的事务ID为60,那么此刻该记录的示意图如下所以
在这里插入图片描述

假设之后我们两个事务ID分别为80、120的事务对这条记录进行 UPDATE 操作,操作流程如下
在这里插入图片描述

每次对记录进行改动,都会记录一条 undo 日志,每条 undo 日志也都有 roll_pointer 属性( insert 操作对应的 undo 日志没有该属性,因为该记录并没有更早的版本),可以将这些 undo 日志都连起来,串成一个链表,所以现在的情况就像下图一样
在这里插入图片描述

对该记录每次更新后,就会将值放到一条undo日志中,就算是该记录的一个旧版本,随着更新次数的增加,所有的版本都会被 roll_pointer 属性连接成一个链表,我们把该链表称之为版本链,版本链的头结点就是当前记录的最新的值。另外,每个版本还包含生成该版本时对应的事务ID。于是可以利用这个记录的版本链来控制并发事务访问相同记录的行为,那么这种机制被称为多版本并发控制(Multi-Version Concurrency Control MVCC)

ReadView

必须要知道的概念(作用于SQL查询语句)
对于使用READ UNCOMMITTED隔离级别的事务来说,由于可以读到未提交事务修改过的记录,所以直接读取记录的最新版本就好了(所以就会出现脏读,不可重复读、幻读)。
在这里插入图片描述
对于使用SERIALIZABLE隔离级别的事务来说,InnoDB使用加锁的方式来访问记录(也就是所有的事务都是串行的,当然不会出现脏读、不可重复读和幻读)。
在这里插入图片描述

对于使用READ COMMITTEDREPEATABLE READ隔离级别的事务来说,都必须保证读到已经提交了的事务修改过的记录,也就是说假如另外一个事务已经修改好了记录但是尚未提交,是不能直接读取最新版本的记录的,核心问题就是READ COMMITTEDREPEATABLE READ隔离级别在不可重复读和幻读上的区别是从哪里来的。其实结合前面的知识,这两种隔离级别关键是需要判断一下版本链中的哪个版本是当前事务可见的
为此,InnoDB提出了一个 ReadView 的概念(作用于SQL查询语句),这个 ReadView 中主要包含4个比较重要的内容

  • m_ids:表示在生成 ReadView 时当前系统中活跃的读写事务ID表示
  • min_trx_id:表示在生成 ReadView 时当前系统中获取与的读写事务中最小的事务ID,也就是 m_ids 中的最小值。
  • max_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。
  • create_trx_id:表示生成该 ReadView 的事务的事务ID。

READ COMMITTED

脏读问题的解决

READ COMMTTED隔离级别的事务在每次查询开始时都会生成一个独立的 ReadView 。

在MySQL中,READ COMMITTED的隔离级别的一个非常大的区别就是他们生成 ReadView 的时机不同。

我们还是以表 teach 为例。
假设现在表 teacher 中只有一条由事务ID为60的事务插入的一条记录,接下来看一下READ COMMITTEDREPEATABLE READ所谓的生成 ReadView 的时机不同到底不同在哪里。

READ COMMITTED——每次读取数据前都会生成一个 ReadView 。
比方说现在系统里有两个事务,ID 分别为80、120的事务在执行:Transaction 80

UPDATE teacher  SET name = '马' WHERE number = 1;
UPDATE teacher  SET name = '连' WHERE number = 1;

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

在这里插入图片描述
假设现在有一个使用READ COMMITTED的隔离级别的事务开始执行
在这里插入图片描述

使用READ COMMITTED隔离级别的事务
BEGIN;
SELECE1:Transaction 80、120未提
SELECT * FROM teacher WHERE number = 1; # 得到的列name的值为’李瑾’

第一次 select 的时间点如下图
在这里插入图片描述
这个select1的执行过程如下
在执行select语句时会先生成一个 ReadView

  1. ReadView的 m_ids 列表的内容就是【80,120】,min_trx_id 为80,max_trx_id 为121,creator_trx_id 为0。
  2. 然后从版本链中挑选可见的记录。从图中可看出,最新版的列 name 的内容“连”,该版本的 trx_id 值为80,在m_idx 列表内,所以不符合可见性要求(trx_id属性值在 ReadView 的 min_trx_id和 max_trx_id 之间说明创建ReadView时生成该版本的事务时是活跃的,该版本不可以被访问;如果不在,说明创建 ReadView 时生成该版本的事务已经提交,该版本可以被访问),根据roll_pointer跳到下一个版本。
  3. 下一个版本的列 name 的内容“马”,该版本的trx_id 的值也为80,也在 m_ids 列表内,所以也不符合要求,继续跳到下一个版本。
  4. 下一个版本的列 name 的内容是”李瑾“,该版本的 trx_id 的值为60,小于 ReadView 的 min_trx_id 值,所以这个版本是符合要求的,最后返回给用户的版本就是这条列为 name 为”李瑾“的记录。

所以有了这种机制,就不会发生脏读问题!因为会去判断活跃版本,必须是不在活跃版本的才能用,不可能读到没有commit的记录
在这里插入图片描述

不可重复读问题

然后我们把事务ID为80的事务提交一下,然后再到事务ID为120的事务中更新一下表teacher中number为1的记录。
在这里插入图片描述

Transaction120
BEGIN;
更新了一些别的表的记录
UPDATE teacher SET name = ‘严’ WHERE number = 1;
UPDATE teacher SET name = ‘晁’ WHERE number = 1;

此刻,表 teacher 中 number 为1的记录的版本就该长这样
在这里插入图片描述
然后再到刚才使用READ COMMITTED隔离级别的事务中继续查找这个 number 为1的记录,如下:

使用READ COMMITTED隔离级别的事务

BEGIN;
SELECE1:Transaction 80、120均未提交
SELECT * FROM teacher WHERE number = 1; # 得到的列name的值为’李瑾’
SELECE2:Transaction 80提交,Transaction 120未提交
SELECT * FROM teacher WHERE number = 1; # 得到的列name的值为’连’

第2次 select 的时间点 如下图:
在这里插入图片描述
这个 select2 的执行过程如下

select * from teacher where number = 1;

在执行 select 语句中会单独生成一个 ReadView ,该 ReadView 信息如下

  1. m_ids 列表的内容就是【120】(事务ID为80的那个事务已经提交了,所以再次生成快照就没有它了),min_trx_id 为120,max_trx_id 为121 ,creator_trx_id 为0。
  2. 然后从版本链中挑选出可见的记录,从图中可以看出,最新版本的列name的内容就是”晁“,该版本的trx_id 的值为120,在 m_ids 列表内,所以不符合可见性要求,根据roll_pointer跳到下一个版本。
  3. 下一个版本的列name的内容是”严“,该版本的 trx_id 值为120,也在 m_ids 列表中,所以也不符合要求,继续跳到下一个版本。
  4. 下一个版本的列 name 的内容是”连“,该版本的 trx_id 的值为80,小于 ReadView 中的 min_trx_id 值120,所以这个版本是符合要求的,最后返回给用户的版本就是这条列 name 为”连“的记录。

依次类推,如果之后事务ID为120的记录也提交了,再次使用 READ COMMITTED 隔离级别的事务中查询teacher中 number 的值为1的记录时,得到的结果就是”晁“,具体流程我们就不分析了。

在这里插入图片描述

但会出现不可重复读问题

明显一个事务中两次

在这里插入图片描述

REPEATABLE READ

REPEATABLE READ 解决不可重复读问题
REPEATABLE READ —— 在第一次读取数据时生成一个 ReadView

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

比如说现在系统里由两个事务ID分别为80,120的事务在执行
Transaction 80

UPDATE teacher  SET name = '马' WHERE number = 1;
UPDATE teacher  SET name = '连' WHERE number = 1;

此刻,表 teacher 中 number 为1的记录得到的版本链表如下所示
在这里插入图片描述
假设现在有一个使用 REPEATABLE READ 隔离级别的事务开始执行
在这里插入图片描述
使用 READ COMMITTED 隔离级别的事务

BEGIN;
SELECE1:Transaction 80、120未提交
SELECT * FROM teacher WHERE number = 1; # 得到的列name的值为’李瑾’

在这个 select1 的执行过程如下:
在执行 select 语句时会先生成一个ReadView

  1. ReadView的m_ids列表内容就是【80,120】,min_trx_id为80,max_trx_id为121,creator_trx_id 为0。
  2. 然后从版本链中挑选可见的记录,从图中可以看出,最新的版本的列 name 的内容是“连”,该版本的 trx_id 值为80,在m_ids列表内,所以不符合可见性要求(trx_id属性值在 ReadView 的 min_trx 和 max_trx_id 之间说明创建 ReadView 时生成该版本的事务是活跃的,该版本不可以被访问;如果不在,说明创建 ReadView 时生成该版本的事务还是活跃的,该版本不可以被访问,如果不在,说明创建 ReadView 时生成该版本的事务已经被提交,该版本可以被访问,根据 rolll_pointer 跳到下一个版本)
  3. 下一版本的列 name 的内容是“马”,该版本的trx_id 值为80,也在 m_ids 列表内,所以不符合要求,继续跳到下一个版本
  4. 下一个版本的列 name 内容是“李瑾”,该版本的 trx_id 值为60,小于 ReadView 中的 min_trx_id值,所以这个版本是符合要求的,最后返回给用户的版本就是这条 name 为“李瑾”的记录
  5. 之后,我们把事务Id为80的事务提交一下,然后再到事务ID为120的事务中更新一下表 teacher 中 number 为1的记录
    在这里插入图片描述

transaction120
begin;
更新了一些别的表的记录
update teacher set name = ‘严’ where number = 1;
update teacher set name = ‘晁’ where number = 1;

此刻,表teacher中number为1的记录的版本链就长这样
在这里插入图片描述
然后再到刚才使用REPEATABLE READ隔离级别的事务中继续查找这个 number = 1的记录,如下
使用READ COMMITTED隔离级别的事务

begin;
select1: transaction 80、120均为提交
select * from teacher where number = 1; #得到的列 name 的值为‘李瑾’
select2: transaction80 提交 transaction120 未提交
select * from teacher where number = 1; #得到的列name的值为‘李瑾’

这个 select2 执行过程如下
因为当前事务的隔离级别是 REPEATABLE READ,而之前在执行 select1 时已经生成了 ReadView了,所以此时直接复用之前的 ReadView ,之前的 ReadView 的 m_ids 列表的内容就是【80,120】,min_trx_id 为80,max_trx_id 为121,creator_trx_id 为0。

根据前面分析,返回的值还是“李瑾”。
也就是说两次 select 查询得到的结果是重复的,记录列name值都是“李瑾”,这就是可重复读的含义。
在这里插入图片描述
总结一下
ReadView 中的比较规则(前两条)

  1. 如果被访问版本的 trx_id 属性值于 ReadView 中 creator_trx_id 值相同,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问
  2. 如果该访问版本的 trx_id 属性值小于 ReadView 中的 min_trx_id 值,表明生成该版本的事务在当前事务生成的 ReadView 前已经生成,所以该版本可以被当前事务访问。

MVCC下幻读解决和幻读现象

前面我们已经知道了,REPEATABLE READ 隔离级别下 MVCC 可以解决不可重复读问题,那么幻读呢?MVCC 是怎么解决了的?幻读是一个事务按照某个相同条件多次读取记录时,后读取时读到了之前没有读到的记录,而这个记录来自另一个事务添加的新纪录。
我们可以想想,在 REPEATABLE READ 隔离级别下的事务T1先根据某个搜素条件读取到多条记录,然后事务T2插入一条符合相应搜索条件的记录并提交,然后事务T1再根据相同搜索条件执行查询,结果会是什么?按照Readview中的比较规则(后两条)
3. 如果被访问版本的 trx_id 属性值大于或等于 ReadView 中的 max_trx_id 值,表明生成该版本的事务在当前事务生成 ReadView 后才开启,所以该版本不可以被当前事务访问。
4. 如果被访问版本的 trx_id 属性值在 ReadView 的 min_trx_id 和 max_trx_id 之间( min_trx_id < trx_id < max_trx_id),那就需要判断一下 trx_id 属性值是不是在 m_ids 列表中,如果在,说明创建 ReadView 时生成的该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建 ReadView 时生成该版本的事务已经被提交了,该版本可被访问。

不管事务T2比事务T1是否先开启,事务T1都是看不到T2提交的。请自行按照上面介绍的版本链、ReadView 以及判断可见性的规则来分析一下。

但是,在 REPEATABLE READ 隔离级别下 InnoDB 中的 MVCC 可以很大程度地避免幻读现象,而不是完全禁止幻读。怎么回事?我们来看下面的情况。
在这里插入图片描述
我们首先在事务T1中:

select * from teacher where number = 30;

很明显,这个时候是找不到number = 30的记录的
我们在事务T2中,执行

insert into teacher values(30,'豹',‘数据湖’);

通过执行上述SQL,我们往表中插入一条 number = 30的记录。
此时回到事务T1,执行

update teacher set domain = 'RocketMQ' where number = 30;
select * from teacher where number  = 30;

在这里插入图片描述
嗯,怎么回事?事务T1很明显出现了幻读现象。
在 REPEATABLE READ 隔离级别下,T1 第一次执行普通的 select 语句时,生成了一个 ReadView (但是版本链没有),之后 T2 向 teacher 表中新插入一条记录并提交,然后 T1 也进行了一个 update 语句。
ReadView 并不能阻止 T1 执行 UPDATE 或者 DELETE 语句来改动这个新插入的记录,但是这样一来,这条记录的 trx_id 隐藏列的值就变成了 T1 的事务ID。
在这里插入图片描述
之后 T1 再使用普通的select语句去查询这条记录时就可以看到这条记录了,也就可以把这条记录返回给客户端。因为这个特殊现象的存在,我们也可以认为 MVCC 并不能完全禁止幻读(就是第一次读是空的情况,且在自己的事务中进行了该条数据的修改)。

MVCC小结

从上边的描述中,我们可以看出来,所谓的 MVCC(Multi-Version Concurrency Control,多版本并发控制)指的就是在使用READ COMMITTEDREPEATABLE READ这两种隔离级别的事务在执行普通的select操作时访问记录的版本链的过程,这样子可以使不同事务的读写、写读操作并发执行,从而提高系统性能。

READ COMMITTEDREPEATBLE READ,这两个隔离级别的一个很大的不同就是,生成 ReadView 的时机不同,READ COMMITTED在每一个进行普通select操作前都会生成一个 ReadView ,而REPEATABLE READ只在第一次进行普通select操作前生成一个 ReadView ,之后的查询操作都是重复使用这个 ReadView 就好了,从而基本上可以避免幻读现象(就是第一次读,如果 ReadView 是空的情况中的某些情况则避免不了)。

另外,所谓的 MVCC 只是在我们进行普通的 select 查询时才生效,截至到目前我们所见的所有的 select 语句都算是普通的查询,至于什么是个不普通的查询,后续文章就会谈到(锁定读)。

### MySQL 事务MVCC 实现原理 #### 一、事务的概念及其特性 在数据库环境中,事务是指一系列作为一个整体执行的操作序列。这一系列操作要么全部成功完成并永久生效;如果其中任何一个环节失败,则整个事务都会回滚至初始状态,确保数据一致性[^1]。 - **原子性(Atomicity)**:事务中的所有操作视为单一工作单元,该单元内各项更改应同生共死。 - **一致性(Consistency)**:事务前后需保持一致的状态转换,即遵循预定义规则。 - **隔离性(Isolation)**:各并发运行的事务间相互独立不受干扰。 - **持久性(Durability)**:一旦提交,即使发生系统崩溃也能够恢复成果。 #### 二、MVCC概述 为了提升并发处理能力的同时保障读写的正常交互,MySQL引入了多版本并发控制(MVCC)[^2]。其核心在于维护同一份记录的不同时间点上的多个版本,以此支持不同类型的读取行为——快照读与当前读: - **快照读(Snapshot Read)**:无需加锁即可获取指定时刻的数据视图,适用于大多数SELECT查询场景; - **当前读(Current Read)**:涉及锁定机制以防止其他会话修改正在访问的对象实例,常用于UPDATE/DELETE以及特定条件下带有FOR UPDATE或LOCK IN SHARE MODE修饰符的SELECT语句中。 #### 、InnoDB存储引擎下的MVCC实现细节 作为MySQL默认使用的高性能事务型表管理器,InnoDB实现了上述提到的两种主要读取模式,并通过以下组件共同作用达成高效稳定的并发控制效果[^3]: - **隐藏列**:每行记录额外携带两个隐含属性`DB_TRX_ID`(最近更新此条目的事务ID)`DB_ROLL_PTR`(指向undo日志的位置),用作判断可见性的依据之一; - **Undo Log**:当某笔交易对现有资料进行了变更时,旧版会被复制到undo log区域保存起来直到不再需要为止(比如超出了要的保留期限或是相关联的事物已经结束); - **ReadView**:每当启动一个新的只读类事物之前都要构建一个read view对象,里面包含了创建瞬间活跃着的所有活动事物列表以及其他要信息用来决定哪些历史版本是可以被看见的。 #### 四、可见性逻辑判定流程 针对某个给定的记录版本R,假设现在有一个正在进行中的事务T想要对其进行读取,那么根据如下准则来确定是否能观察到它: 1. 若R是在T开启之后才产生的变动,则不可见; 2. 假设R是由另一个尚未完结且位于T之前的事务S所引起的变化,此时要看S是否处于已准备提交但还未正式commit阶段: - 是 -> R暂时不可见; - 否 -> 继续下一步骤; 3. 检查产生R的那个事务U是否存在于当前T持有的readview之内: - 存在-> 不可见 ; - 缺失-> 可见; 此外还有专门针对某些特殊情况设计的小于最低限度id比较规则等辅助手段帮助更精准地界定可视范围边界条件[^4]。 ```sql -- 示例SQL展示如何查看当前系统的最小未分配事务ID SHOW ENGINE INNODB STATUS\G ```
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Coffee_Driven_Dev

你的鼓励是我写作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值