ICode9

精准搜索请尝试: 精确搜索
首页 > 数据库> 文章详细

MySQL事务隔离与MVCC

2021-03-05 20:57:41  阅读:113  来源: 互联网

标签:事务 隔离 MVCC 视图 提交 MySQL 版本 id


一、隔离性与隔离级别

提到数据库事务,你肯定会想到 ACID(Atomicity、Consistency、Isolation、Durability,即原子性、一致性、隔离性、持久性),今天我们就来说说其中的 “I”,也就是隔离性。

当数据库上有多个事务同时执行的时候,就可能出现脏读、不可重复读、幻读的问题,为了解决这些问题,就有了 “隔离级别” 的概念。

在谈隔离级别之前,首先要知道,隔离级别越高,效率就会越低。因此很多时候,我们都要在二者之间寻找一个平衡点。

SQL 标准的事务隔离级别包括:

  • 读未提交(read uncommitted):一个事务提交之前,该事务做的变更对其它事务可见。
  • 读提交(read committed):一个事务提交之前,该事务做的变更对其它事务不可见,提交之后才可见。
  • 可重复读(repeatable read):一个事务执行过程中看到的数据,总是跟该事务在启动时看到的数据是一致的。该事务未提交前所做的变更对其它事务不可见。
  • 串行化(serializable):事务对同一行记录,“写” 会加 “写锁”,“读” 会加 “读锁”。当出现读写锁冲突时,后访问的事务必须等待前一个事务执行完成并释放锁,才能继续执行。
隔离级别脏读不可重复读幻读
读未提交可能可能可能
读提交不可能可能可能
可重复读不可能不可能可能
串行化不可能不可能不可能

下面用一个例子说明这几种隔离级别。

假设数据表 T 中只有一列,其中一行的值为 1,下面是按照时间顺序执行两个事务的行为。

CREATE TABLE T(c INT) ENGINE = InnoDB;

INSERT INTO T(c) VALUES(1);
事务A事务B
启动事务
查询得到值 1启动事务
查询得到值 1
将 1 改成 2
查询得到值 V1
提交事务
查询得到值 V2
提交事务 A
查询得到值 V3

我们来看看在不同的隔离级别下,事务 A 会有哪些不同的返回结果,也就是图中 V1、V2、V3 的值分别是什么。

  • 若隔离级别是 “读未提交”,事务 B 虽然还没有提交,但是更新已经被 A 看到了。因此 V1、V2、V3 的值都是 2。
  • 若隔离级别是 “读提交”,事务 B 的更新在提交之后才能被 A 看到。因此 V1 的值是1,V2、V3 的值是 2。
  • 若隔离级别是 “可重复读”,事务 A 在执行期间看到的数据前后必须是一致的。因此V1、V2 的值是 1,V3 的值是 2。
  • 若隔离级别是 “串行化”,则在事务 B 执行 “将 1 改成 2” 的时候,会被锁住。直到事务 A 提交后, 事务 B 才可以继续执行。因此 V1、V2 值是 1,V3 的值是 2。

在实现上,数据库里面会创建一个视图,访问的时候以视图的逻辑结果为准。

在 “可重复读” 隔离级别下,这个视图是在事务启动时创建的,整个事务存在期间都用这个视图。

在 “读提交” 隔离级别下,这个视图是在每个 SQL 语句开始执行的时候创建的。

在 “读未提交” 隔离级别下,直接返回记录上的最新值,没有视图概念。

在 “串行化” 隔离级别下,直接用加锁的方式来避免并行访问。

二、事务隔离的实现

理解了事务的隔离级别,我们再来看看事务隔离具体是怎么实现的。这里我们展开说明 “可重复读”。

在 MySQL 中,实际上每条记录在更新的时候都会同时记录一条回滚操作。记录上的最新值,通过回滚操作,都可以得到前一个状态的值。

假设一个值从 1 被按顺序改成了 2、3、4,在回滚日志里面就会有类似下面的记录。

在这里插入图片描述

当前值是 4,但是在查询这条记录的时候,不同时刻启动的事务会有不同的 read-view。如图中看到的,在视图 A、B、C 里面,这一个记录的值分别是 1、2、4,同一条记录在系统中可以存在多个版本,就是数据库的多版本并发控制(MVCC)。

对于 read-view A,要得到 1,就必须将当前值依次执行图中所有的回滚操作得到。

同时你会发现,即使现在有另外一个事务正在将 4 改成 5,这个事务跟 read-view A、B、C 对应的事务是不会冲突的。

回滚日志不会一直保留,当系统里没有比这个回滚日志更早的 read-view 的时候,该系统日志会被删除。

三、快照在MVCC的工作方式

在 “可重复读” 隔离级别下,事务在启动的时候就基于整库 “拍了个快照”。

这时,你会说这看上去不太现实啊。如果一个库有 100G,那么我启动一个事务,MySQL 就要拷 贝 100G 的数据出来,这个过程得多慢啊。可是,我平时的事务执行起来很快啊。

实际上,我们并不需要拷贝出这 100G 的数据。我们先来看看这个快照是怎么实现的。

InnoDB 里面每个事务有一个唯一的事务 ID,叫做 transaction id。它是在事务开始的时候向 InnoDB 的事务系统申请的,是按申请顺序严格递增的。

而每行数据也都是有多个版本的。每次事务更新数据的时候,都会生成一个新的数据版本,并且 把 transaction id 赋值给这个数据版本的事务 ID,记为 row trx_id。同时,旧的数据版本要保留, 并且在新的数据版本中,能够有信息可以直接拿到它。

一个记录被多个事务连续更新后的状态如下图所示:

在这里插入图片描述

图中线框里是同一行数据的 4 个版本,当前最新版本是 V4,k 的值是22,它是被 transaction id 为 25 的事务更新的,因此它的 row trx_id 也是 25。

其中 V1、V2、V3 并不是物理上真实存在的,而是每次需要的时候根据当前版本和 undo log 计算出来的。比如,需要 V2 的时候,就是通过 V4 依次执行 U3、U2 算出来。

明白了多版本和 row trx_id 的概念后,我们再来想一下,InnoDB 是怎么定义那个 “100G” 的快照 的。

在实现上, InnoDB 为每个事务构造了一个数组,用来保存这个事务启动瞬间,当前活跃(启动了但还没提交)的所有事务 ID。

数组里面事务 ID 的最小值记为低水位,当前系统里面已经创建过的事务 ID 的最大值加 1 记为高水位。

这个视图数组和高水位,就组成了当前事务的一致性视图(read-view)。

这个视图数组把所有的 row trx_id 分成了几种不同的情况。

在这里插入图片描述

这样,对于当前事务的启动瞬间来说,一个数据版本的 row trx_id,有以下几种可能:

  • 如果落在绿色部分,表示这个版本是已提交的事务或者是当前事务自己生成的,这个数据是可见的;
  • 如果落在红色部分,表示这个版本是由将来启动的事务生成的,这个数据是不可见的;
  • 如果落在黄色部分,那就包括两种情况:
    a. 若 row trx_id 在数组中,表示这个版本是由还没提交的事务生成的,不可见;
    b. 若 row trx_id 不在数组中,表示这个版本是已经提交了的事务生成的,可见。

比如,对于图中的数据来说,如果有一个事务,它的低水位是 18,那么当它访问这一行数据时,就会从 V4 通过 U3 计算出 V3,所以在它看来,这一行的值是 11。

四、案例分析

案例一:

这里,我们不妨做如下假设:

  1. 事务 A 开始前,系统里面只有一个活跃事务,事务 ID 是 99;
  2. 事务 A、B、C 的版本号分别是 100、101、102,且当前系统里只有这四个事务;
  3. 三个事务开始前,(1, 1) 这一行数据的 row trx_id 是 90。

这样,事务 A 的视图数组就是 [99, 100],事务 B 的视图数组是 [99, 100, 101],事务 C 的视图数组是 [99,100,101,102]。

为了简化分析,我先把其他干扰语句去掉,只画出跟事务 A 查询逻辑有关的操作:

在这里插入图片描述

从图中可以看到,第一个有效更新是事务 C,把数据从 (1, 1) 改成了 (1, 2)。此时该数据的最新版本(row trx_id)是 102,而 90 成为了历史版本。

第二个有效更新是事务 B,把数据从 (1, 2) 改成了 (1, 3)。此时该数据的最新版本(row trx_id)是 101,而 102 成为了历史版本。

在事务 A 查询的时候,其实事务 B 还没有提交,但是它生成的 (1, 3) 这个版本已经变成当前版本了。但这个版本对事务 A 必须是不可见的,否则就变成脏读了。

现在事务 A 要来读数据了,它的视图数组是 [99, 100]。事务 A 查询语句的读数据流程是这样的:

  • 找到 (1, 3) 的时候,判断 row trx_id = 101,比高水位大,处于红色区域,不可见;
  • 接着,找到上一个历史版本,判断 row trx_id = 102,比高水位大,处于红色区域,不可见;
  • 再往前找,终于找到了 (1, 1),它的 row trx_id = 90,比低水位小,处于绿色区域,可见。

这样执行下来,虽然期间这一行数据被修改过,但是事务 A 不论在什么时候查询,看到这行数据 的结果都是一致的,所以我们称之为一致性读。

案例二:

下面是一个只有两行的表的初始化语句:

CREATE TABLE `T` (
	`id` INT(11) NOT NULL,
	`k` INT(11) DEFAULT NULL,
	PRIMARY KEY (`id`)
) ENGINE = InnoDB;

INSERT INTO T(id, k) VALUES(1, 1), (2, 2);
事务 A事务 B事务 C
start transaction with consistent snapshot;
start transaction with consistent snapshot;
update t set k = k + 1 where id = 1;
update t set k = k + 1 where id = 1;
select k from t where id = 1;
select k from t where id = 1;
commit;
commit;

这里,我们需要注意的是事务的启动时机。

begin / start transaction命令并不是一个事务的起点,在执行到它们之后的第一个操作 InnoDB 表 的语句,事务才真正启动。

如果你想要马上启动一个事务,可以使用start transaction with consistent snapshot这个命令。

在这个例子中,事务 C 没有显式地使用 begin / commit,表示这个 update 语句本身就是一个事务, 语句完成的时候会自动提交。

事务 B 在更新了行之后查询;事务 A 在一个只读事务中查询,并且时间顺序上是在事务 B 的查询之后。

这时,如果我告诉你事务 B 查到的k的值是 3,而事务 A 查到的 k 的值是1,你是不是感觉有点晕 呢?

图中,事务 B 的视图数组是先生成的,之后事务 C 才提交,不是应该看不见 (1, 2) 吗?怎么能算出 (1, 3) 来?

在这里插入图片描述

是的,如果事务 B 在更新之前查询一次数据,这个查询返回的 k 的值确实是 1。

但是,当它要去更新数据的时候,就不能再在历史版本上更新了,否则事务 C 的更新就丢失了。

因此,事务 B 此时的 set k = k + 1 是在 (1, 2) 的基础上进行的操作。

所以,这里就用到了这样一条规则:更新数据都是先读后写的,而这个读,只能读当前的值,称为 “当前读”(current read)

因此,在更新的时候,当前读拿到的数据是 (1, 2),更新后生成了新版本的数据 (1, 3),这个新版本的 row trx_id 是 101。

所以,在执行事务 B 查询语句的时候,一看自己的版本号是 101,最新数据的版本号也是 101,是自己的更新,可以直接使用,所以查询得到的 k 的值是 3。

案例三:

再往前一步,假设事务 C 不是马上提交的,而是变成了下面的事务 C’,会怎么样呢?

事务 A事务 B事务 C’
start transaction with consistent snapshot;
start transaction with consistent snapshot;
start transaction with consistent snapshot;
update t set k = k + 1 where id = 1;
update t set k = k + 1 where id = 1;
select k from t where id = 1;
commit;
select k from t where id = 1;
commit;
commit;

事务 C’ 的不同点是,更新后并没有马上提交,在它提交前,事务 B 的更新语句先执行了。

前面说过了,虽然事务 C’ 还没提交,但是 (1, 2) 这个版本也已经生成了,并且是当前的最新版本。那么,事务B的更新语句会怎么处理呢?

这时候,就轮到 “两阶段锁协议” 上场了。事务 C’ 没提交,也就是说 (1, 2) 这个版本上的写锁还没释放。

而事务 B 是当前读,必须要读最新版本,而且必须加锁,因此就被锁住了,必须等到事务 C’ 释放这个锁,才能继续它的当前读。

在这里插入图片描述

到这里,我们把一致性读、当前读和行锁就串起来了。

案例四:

我们再看一下,在读提交隔离级别下,事务 A 和事务 B 的查询语句查到的 k,分别应该是多 少呢?

下面是读提交时的状态图,可以看到这两个查询语句的创建视图数组的时机发生了变化,就是图 中的 read view 框。(注意:这里,我们用的还是事务 C 的逻辑直接提交,而不是事务 C’)

在这里插入图片描述

这时,事务 A 的查询语句的视图数组是在执行这个语句的时候创建的,时序上 (1, 2)、(1, 3) 的生成时间都在创建这个视图数组的时刻之前。

但是,在这个时刻:

  • (1, 3) 还没提交,不可见;
  • (1, 2) 提交了,可见。

所以,这时候事务 A 查询语句返回的是 k = 2,事务 B 查询结果 k = 3。

五、总结

InnoDB 的行数据有多个版本,每个数据版本有自己的 row trx_id,每个事务或者语句有自己的一 致性视图。普通查询语句是一致性读,一致性读会根据 row trx_id 和一致性视图确定数据版本的 可见性。

  • 对于可重复读,查询只承认在事务启动前就已经提交完成的数据;
  • 对于读提交,查询只承认在语句启动前就已经提交完成的数据;

而当前读,总是读取已经提交完成的最新版本。

可重复读的核心就是一致性读;而事务更新数据的时候,只能用当前读。如果当前的记录的行锁被其他事务占用的话,就需要进入锁等待。

读提交的逻辑和可重复读的逻辑类似,它们最主要的区别是:

  • 在可重复读隔离级别下,只需要在事务开始的时候创建一致性视图,之后事务里的其他查询都共用这个一致性视图;
  • 在读提交隔离级别下,每一个语句执行前都会重新算出一个新的视图。

标签:事务,隔离,MVCC,视图,提交,MySQL,版本,id
来源: https://blog.csdn.net/weixin_41105242/article/details/114388535

本站声明: 1. iCode9 技术分享网(下文简称本站)提供的所有内容,仅供技术学习、探讨和分享;
2. 关于本站的所有留言、评论、转载及引用,纯属内容发起人的个人观点,与本站观点和立场无关;
3. 关于本站的所有言论和文字,纯属内容发起人的个人观点,与本站观点和立场无关;
4. 本站文章均是网友提供,不完全保证技术分享内容的完整性、准确性、时效性、风险性和版权归属;如您发现该文章侵犯了您的权益,可联系我们第一时间进行删除;
5. 本站为非盈利性的个人网站,所有内容不会用来进行牟利,也不会利用任何形式的广告来间接获益,纯粹是为了广大技术爱好者提供技术内容和技术思想的分享性交流网站。

专注分享技术,共同学习,共同进步。侵权联系[81616952@qq.com]

Copyright (C)ICode9.com, All Rights Reserved.

ICode9版权所有