【MySQL】事务管理

2023-10-26 14:51:15 浏览数 (1)

一、事务基础知识

1、什么是事务

事务是一组有逻辑关系的 SQL 语句的集合,这些 SQL语句合起来完成某一项功能,并且这一组 SQL 语句执行时要么全部成功,要么全部失败,是一个整体。MySQL 提供一种机制保证我们达到这样的效果,这就是 MySQL 中的事务。

以大家很熟悉的12306购票系统为例,假设 “将用户的状态设置为已购票” 以及 “系统剩余票数减一” 分别代表一条 SQL 语句,那么这两条 SQL 语句组合起来就代表 “购票” 这个事务;这个事务中的 SQL 语句要么全部执行成功,代表购票成功,要么全部执行失败,代表购票失败,而不能出现某一部分执行成功,而另一部分执行失败的情况。

为什么要要有事物

其实 MySQL 最开始的时候是没有事务这个概念的,事务被 MySQL 编写者设计出来,本质是为了当应用程序访问数据库的时候,事务能够简化我们的编程模型,即不需要程序员去考虑各种各样的潜在错误和并发问题。因此事务本质上是为了应用层服务的,而不是伴随着数据库系统天生就有的

所以,我们也应该站在 MySQL 的上层,即用户的视角 (具体的业务逻辑) 来看待事务,而不能从程序员 (简单的几条 SQL 语句) 的角度来看待事物


2、事务的 ACID 特性

在一个数据库服务 mysqld 中,绝大多数情况下都不止一个事务在运行,甚至在某一个时间范围内,会有大量来自不同 mysql 客户端的请求被包装成事务,向 mysqld 发起事务处理请求。而每个事物都是一条或多条 SQL 语句,那么如果大家都访问同样的表数据,在不加保护的情况,就绝对会出现问题。甚至因为事务由多条 SQL 构成,可能还会发生事务执行到一半出错或者不想再执行的情况,这在某些事务场景下就会出现问题,比如上面的购票。

所以,一个完整的事务,绝对不是简单的 sql 集合,还需要满足如下四个属性:

  • 原子性:一个事务(transaction)中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节。如果事务在执行过程中发生错误,则会被回滚(Rollback)到事务开始前的状态,就像这个事务从来没有执行过一样。
  • 一致性:在事务开始之前和事务结束以后,数据库的完整性不会被破坏。这表示写入的资料必须完全符合所有的预设规则,这包含资料的精确度、串联性以及后续数据库可以自发性地完成预定的工作。
  • 隔离性:数据库允许多个并发事务同时对相同数据进行读写和修改,而隔离性则是为了防止多个事务并发执行时由于交叉执行而导致数据的不一致。事务隔离分为不同级别,包括读未提交 (RU)、读提交 (RC)、可重复读 (RR) 和串行化 (Serializable )。
  • 持久性:事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失,即事务的执行结果会被刷新保存到磁盘中。

这四个属性,可以简称为事务的 ACID 特性:原子性 (Atomicity) 、一致性 (Consistency)、隔离性 (Isolation) 和持久性 (Durability).

如何理解事务的 ACID 特性

  • 事务的原子性和持久性很好理解:一个事务执行时要么全部成功,要么全部失败,不会成功一半的情况,它通过回滚机制来实现,也就是说不会出现购票时用户已购票但系统剩余票数未减少,或者转账时我的账号余额减少,而对方的账户余额未增加的情况。持久性就是将数据持久化到磁盘中。
  • 而事务的一致性是指事务执行的结果,必须使数据库从一个一致性状态变到另一个一致性状态。当数据库只包含事务成功提交的结果时,数据库处于一致性状态。如果系统运行发生中断,某个事务尚未完成而被迫中断,而改未完成的事务对数据库所做的修改已被写入数据库,此时数据库就处于一种不一致的状态,但事务原子性中的回滚机制会让这种不一致转变为一致。 简单来说,事务的一致性就是指事务的执行结果是可预期的 – 要么全部成功,要么全部不成功。 最后,对于事务的一致性,MySQL 仅提供技术支持,即只要程序员将事务交给 MySQL,MySQL 能够通过 AID 来保证数据库是可预期的,即一定是从一个一致性状态变为另一个一致性状态,不会出现不一致的情况。但是这个事务逻辑上的正确性,即事务本身的正确性是需要由程序员决定的,比如事务中的SQL语句是否正确、完整。总的来说,事务的一致性是由程序员和 MySQL 共同决定
  • 最后,关于事务的隔离性及隔离级别的理解,后面我们会详细讲解。

3、事务的版本支持

在 MySQL 数据库最流行的两种存储引擎 InnoDBMyISAM中,只有InnoDB支持事务,而MyISAM是不支持事务的。我们可以通过show engines指令来查看不同存储引擎是否支持事务 (transaction)。

4、事务的提交方式

事务的提交方式常见的有两种:

  • 自动提交。
  • 手动提交。

我们可以通过以下语句来查看事务的提交方式:

  • 查看全局事务提交方式:show global variables like 'autocommit';
  • 查看会话事务提交方式:show session variables like 'autocommit'; 或者 show variables like 'autocommit';

其中 “ON” 代表自动提交开启,“OFF” 代表自动提交关闭。

我们也可以通过 set 来改变事务提交模式:

代码语言:javascript复制
set [global/session] autocommit = 'OFF';

二、事务隔离性及隔离级别

1、如何理解隔离性

上面我们说过,MySQL 服务 (mysqld) 可能会同时被多个客户端进程 (线程) 访问,访问的方式以事务方式进行。同时,一个事务可能由多条 SQL 语句构成,而 SQL 语句的执行是需要时间的,这也就意味着任何一个事务都有执行前、执行中以及执行后的阶段。特别是对于长事务来说,执行中这个阶段会比较明显。

需要注意的是,事务有执行前、执行中、执行后三个阶段与事务的原子性并不冲突:

  • 事务的原子性强调的是一个事务的执行要么全部成功,要么全部失败,即程序员只关心事务执行前与执行后的状态,而不关心执行中,因为执行中一旦出现错误,会有回滚机制。所以单个事务,对用户表现出来的特性,就是原子性。
  • 但是,毕竟所有事务都要有个执行过程,那么在多个事务各自执行多个 SQL 的时候,就还是有可能会现互相影响的情况,比如多个执行中的事务同时访问同一张表,甚至同一行数据。

在数据库中,为了保证事务执行过程中尽量不受干扰,就有了一个重要特性 – 隔离性;同时,数据库也允许事务受一定不同程度的干扰,所以为隔离性设置了不同的隔离级别

2、隔离级别

2.1 四种隔离级别

MySQL 事务一共有四种隔离级别:

  • 读未提交 [Read Uncommitted]:在此隔离级别下,所有的事务都可以看到其他事务没有提交的执行结果。读未提交相当于没有任何隔离性,所以并发度很高,但这也伴随着很多并发问题,如脏读、幻读、不可重复读等。(注:在实际生产中几乎不可能使用这种隔离级别)
  • 读提交 [Read Committed]:此隔离级别是大多数数据库的默认的隔离级别 (不是 MySQL 的默认隔离级别),它满足了隔离的简单定义,即一个事务只能看到其他的已经提交的事务所做的改变,同时也具有较好的并发性。此隔离级别下不会出现赃读问题,但存在幻读与不可重复读问题。
  • 可重复读 [Repeatable Read]:这是 MySQL 默认的隔离级别,它确保同一个事务在执行中多次读取操作数据时,会看到同样的数据行,并且不存在脏读、幻读、不可重复读等并发问题。(注:MySQL InnoDB 存储引擎使用间隙锁解决了 RR 隔离级别下绝大多数的幻读问题,但在一般数据库中,RR 隔离级别下是存在幻读问题的)
  • 串行化 [Serializable]:这是事务的最高隔离级别,它通过强制事务排序,即在每个读的数据行上面加上共享锁,使之不可能相互冲突,解决了幻读问题,但可能会导致超时和锁竞争。(注:这种隔离级别太极端,实际生产基本也不使用)

-隔离级别

-赃读

不可重复读

幻读

加锁读

读未提交 RU

存在

存在

存在

不加锁

读提交 RC

不存在

存在

存在

不加锁

可重复度 RR

不存在

不存在

不存在 (仅针对 MySQL)

不加锁

串行化 Serializable

不存在

不存在

不存在

加锁

2.2 查看与设置隔离级别

MySQL 中隔离级别分为全局隔离级别与会话隔离级别,全局隔离级别是指以后每次登录 mysql 的默认隔离级别,会话隔离级别是指当前会话的隔离级别,它通常与全局隔离级别相同。

查看隔离级别 (tx 是 transaction 的缩写,代表事务,isolation 代表隔离):

  • 查看全局隔离级别:select @@global.tx_isolation
  • 查看会话隔离级别:select @@session.tx_isloation或者select @@tx_isolation

设置隔离级别:

  • 设置全局隔离级别:set global transaction isolation level 隔离级别
  • 设置会话隔离级别:set session transaction isolation level 隔离级别或者set transaction isolation level 隔离级别

需要注意的是,我们设置全局隔离级别并不会影响当前正在运行的会话的隔离级别,只会影响后续新起会话的隔离级别。

2.3 读未提交 RU

准备测试数据 (my.cnf 中配置了创建表默认使用 InnoDB 存储引擎):

代码语言:javascript复制
create table account (
id int primary key,
name varchar(20) not null,
balance float(8,2) not null
);
代码语言:javascript复制
insert into account values(1, '张三', 1234);
insert into account values(2, '李四', 2538);

将当前会话隔离级别设置为读未提交 [read uncommitted]:

现在,我们同时启动两个事务,来模拟多客户端多事务并发的场景:(启动事务:start transaction 或者 begin,提交事务:commit)

可以看到,RU 隔离模式下,在两个并发执行的事务中,只要一个数据对表中的数据做修改之后另一个事务马上就能看到,即使执行修改操作的事务并没有提交。这种一个事务在执行中读到另一个执行中事务的更新/删除/修改但是未 commit 的数据的现象叫做赃读

需要注意的是,由于事务原子性的存在,只要我们将修改事务 commit 之后,数据的修改就是永久的;当然,我们也可以在事务未 commit 之前进行 rollback 回滚,这样修改操作就会被复原。

2.4 读提交 RC

将当前会话隔离级别设置为读提交 [read committed]:

同时启动两个事务后观察发现:在事务 A 中无论我们对表数据进行增加还是删除,只要事务 A 还没有 commit,那么事务 B 的查询结果都是不变的;只有当事务 A commit 之后,事务 B 才能看到表数据的变化。这就是读提交 – 一个执行中的事务只能读取到其他已提交事务的修改数据。

同时,读提交可能会造成一个执行中的事务前后两次 select 相同的表查询到的结果不同,这种现象叫做不可重复读

2.4 可重复读 RR

将当前会话隔离级别设置为可重复读 [repeatable read]:

同时启动两个事务我们可以发现:无论事务 A 是否 commit,事务 A 对表数据的修改 (insert/update/delete) 事务 B 都不可见;而只有当事务 B 也提交之后,再下一个事务才能看到事务 A 对表数据的修改。这样执行中的事务就完全看不到其他事务对表数据的修改,也就不会产生不可重复读问题了。

需要注意的是,我们在学习事务之前敲的一条条 SQL 指令,其实也会被封装成事务,只是由于系统的事务自动提交方式默认是打开的,即系统会自动将但 SQL 语句封装成事务,然后自动 commit,所以我们感知不到。如果我们将事务自动提交关掉,那么 SQL 语句就需要手动 commit 了。

同时,我们发现 MySQL 在 RR 模式下,其他事务无论是插入、更新还是删除数据,都不会影响当前执行中的事务。但其实一般的数据库在 RR 模式下,并无法屏蔽其它事务 insert 的数据;这是由于因为隔离性实现是对数据加锁完成的,而insert待插入的数据因为并不存在,所以一般加锁无法屏蔽这类问题。

这样就会造成虽然大部分内容是可重复读的,但是 insert 的数据仍然会在可重复读情况被读取出来,导致多次查找时,会多查找出来新的记录,就如同产生了幻觉,这种现象就叫做幻读。而 MySQL 在 RR 隔离级别下使用 Next-Key 间隙锁解决了幻读问题

2.5 串行化 Serializable

将当前会话隔离级别设置为串行化 [serializable]:

同时启动两个事务我们可以发现:由于串行化是对所有临界资源的写操作添加共享锁 (只能多事务共享读,不能写),所以如果事务 B 在读取数据,而事务 A 要插入数据,那么事务 A 必须等待事务 B commit 释放共享锁,然后事务 A 成功申请到数据的排他锁 (只能单事务写,不能读) 后才能修改数据。

由于串行化需要通过加锁来达到事务串行的进行 “写” 的目的,所以并发度非常低 (只有读并发,没有写并发),效率慢,所以实际开发中基本不使用。

最后,需要说明的是,我们上面的实验都是基于事务读写并发的场景进行的,这也是数据库面临最多的情况,另外,不同隔离级别下的赃读、不可重复读、幻读这些问题也只会发送在读与写的并发执行中发生;而关于事务的其他两个并发场景我们简单理解即可:

  • 读读并发:由于不会修改数据,所以直接读即可,不需要进行并发控制;
  • 写写并发:由于一定会修改数据,所以存在线程安全与丢失更新问题,每次读取都需要加锁来保证临界资源的安全。

3、总结

事务隔离级别越严格,安全性越高,但数据库的并发性能也就越低,所以往往需要在两者之间找一个平衡点,而 MySQL默认的隔离级别是可重复读,在此隔离级别下数据不会发生赃读、不可重复读以及幻读问题,且不用加锁,并发度较高,是一个比较不错的选择。

但是数据库不能只提供可重复度这一种隔离级别,因为数据库的应用场景是多样的,用户需要 MySQL 提供不同的隔离级别来供用户在不同场景下进行选择。

MySQL 四种隔离级别具体的特点如下:

-隔离级别

-赃读

不可重复读

幻读

加锁读

读未提交 RU

存在

存在

存在

不加锁

读提交 RC

不存在

存在

存在

不加锁

可重复度 RR

不存在

不存在

不存在 (仅针对 MySQL)

不加锁

串行化 Serializable

不存在

不存在

不存在

加锁

MySQL 隔离级别以及各种锁 (包括间隙锁) 相关文章阅读推荐:

https://tech.meituan.com/2014/08/20/innodb-lock.html

https://www.cnblogs.com/aspirant/p/9177978.html


三、多版本并发控制 MVCC

我们上面学习了事务的隔离性,知道了事务有不同的隔离级别,那么不同隔离级别到底是如何解决多事务并发过程数据的赃读、不可重复读以及幻读问题的呢?特别是 RC 与 RR 模式的区别到底是如何做到的?答案就是多版本并发控制 MVCC (Multi-Version Concurrency Control)。

多版本并发控制( MVCC )是一种用来解决 读-写冲突 的无锁并发控制。要理解 MVCC,我们需要先学习三个前提知识:

  • 表的三个记录隐藏列字段。
  • undo 日志。
  • read view 类。

1、表的三个隐藏列字段

在 MySQL 中,我们在创建表时,除了我们自己手动指定的列信息,实际上 MySQL 还会自动为表添加三个隐藏列:

  • DB_TRX_ID:大小为6字节,记录创建这条记录或者最后一次修改 (插入/更新) 这条记录的事务ID。(在 MySQL 中,由于事务是原子的,所以 MySQL 会为每个事务都分配一个单向增长的事务ID,它可以用来标记事务到来的先后顺序。)
  • DB_ROLL_PTR:大小为7字节,回滚指针,指向这条记录的上一个版本。(简单理解为指向保存在 undo log 中的记录的历史版本即可)
  • DB_ROW_ID:大小为6字节,隐藏的自增主键,如果数据表没有手动指明主键,那么 InnoDB 会默认以它为主键创建B 树聚簇索引。

其实除了上面这三个列字段,还有一个 flag 隐藏字段, 它用来标记一个记录是否被删除。这样,当需要删除表中的某个记录时,只需要将对应的 flag 标记为置为删除状态即可,而不需要真的在 Page 中进行线性移动来删除,从而提高效率;同时也有助于数据恢复。

我们以一个 student 表为例,假设我们创建的表结构如下 (没有手动指明主键):

代码语言:javascript复制
create table student (
name varchar(20) not null,
age tinyint unsigned not null
);
代码语言:javascript复制
insert into student values ('张三', 28);

那么,在我们用户看来,student 表的标结构如下:

-name

-age

张三

28

而实际上在 MySQL 数据库中 student 表的结构如下:

-name

age-

DB_TRX_ID(创建该记录的事务ID)

DB_ROW_ID(隐藏主键)

DB_ROLL_PTR(回滚指针)

张三

28

null

1

null

注:由于我们并不知道创建该记录的事务ID与隐藏主键,所以默认设为 null、1;同时,第一条记录也没有以前版本,我们设置回滚指针为 null。

2、undo 日志

在上一节学习索引的时候我们说过,MySQL 是以服务进程即 mysqld 的形式在内存中运行的,也就是说,我们之前所进行的所有操作,包括索引、事务 (即表的CURD操作),隔离性、日志等都是在内存中进行的。

同时,我们前面还提到,为了提高效率以及更好的管理 MySQL 中的各种数据,mysqld 会在启动时申请一块大的内存空间 buffer poll (默认大小为128M),以后新增 Page 就直接从 buffer poll 中申请空间,就类似于 C 中的空间配置器。所以,MySQL 中的各种操作其实是在 buffer poll 中完成并保存的。最后 MySQL 会在合适的时候,通过OS文件系统以16KB为单位将相关数据刷新到磁盘当中。

undo log 其实就是 buffer poll 中的一块内存空间,它专门用来保存快照日志数据,便于后续的回滚操作以及多版本并发控制。(其实 buffer poll 除了 undo log 之外,还有其他的类型的 log,但这里我们不深入探究)

3、模拟 MVCC

为了理解 MVCC,我们可以来手动模拟一下 MVCC。

假设现在我们有一个事务10(随机编号,仅仅为了好区分),要对 student 表中的记录进行修改 (update):将 name (张三) 改成 name (李四);那么其执行过程如下:

  1. 由于要进行修改操作,所以需要对 student 中 ‘张三’ 记录加锁。
  2. 在正式修改前,我们需要将行原始记录拷贝到 undo log 中,此时 undo log 中就有了一行 ‘张三’ 副本数据。(类似于写时拷贝)
  3. 此时 MySQL 中有两行相同的 ‘张三’ 记录,现在我们将位于 Page 中的原始记录中的 name 改成 ‘李四’,并且修改原始记录的隐藏字段 DB_TRX_ID 为当前事务ID 10,修改回滚指针 DB_ROLL_PTR 指向 undo log 中的 ‘张三’ 副本数据,表示我的上一个版本就是它;而 undo log 中的记录保持不变。
  4. 最后 commit 事务 10,释放锁;此时,最新的记录是’李四‘那条记录。

示意图如下:(注意:实际上 undo log 中只会保存 ‘张三’ 这一行的信息,不会保存最上面的属性行,这里带上只是为了方便看)

现在又有一个事务11 (事务ID是自增长的),要对 student 表中的记录进行修改 (update):将 age(28) 改成 age(38);那么其执行过程如下:

  1. 事务11要修改数据,所以先加锁;注意,此时加锁的行是最新的 ‘李四’ 行,而不是 undo log 中的 ‘张三’ 行。
  2. 和事务10一样,修改前需要先将行数据拷贝到 undo log 中,所以,undo log 中现在又多了一条 ‘李四’ 副本数据,对于新的副本数据,我们采用头插的方式插入 undo log
  3. 现在修改原始记录中的 age 为38,并且修改原始记录的隐藏字段 DB_TRX_ID 为当前事务ID 11,修改回滚指针 DB_ROLL_PTR 指向 undo log 中的 ‘李四’ 副本数据,表示我的上一个版本就是它。
  4. 最后 commit 事务 11,释放锁;此时,最新的记录是’李四‘ age 为38那条记录。

示意图如下:

这样,我们就得到了一个基于链表结构的记录的历史版本链。而所谓的回滚,其实就是用 undo log 中的某一历史版本数据来覆盖 Page 中的当前数据;所谓的隔离性,其实就是让不同的事务看到不同版本的数据。

上面的一个一个版本,我们可以称之为一个一个的快照

关于快照的事务ID问题

对于回滚操作来说,如果事务要对表数据进行修改,那么 MySQL 一般会在第一次修改前保存一份快照,后面每一个 setpoint 操作再形成一个快照;这样以后如果想在事务未 commit 时回滚,就可以通过版本链找到对应的快照进行回滚。而由于这些修改操作都是同一个事务进行的,所以这些快照中的事务ID都是相同的。 而对于版本链中不同事务ID的快照,则主要用于实现隔离性,即当前事务执行修改操作但未commit时,其他事务访问的是该行数据的哪一个历史版本。 我们上面举例使用的是不同事务ID的快照,这些快照不能用于回滚,只能用于事务隔离。

关于 undo log 空间不足的问题

有的同学可能会有疑问,既然每条记录的每次修改操作都会形成对应的快照保存到 undo log 中,那么会不会出现 undo log 被打满的情况呢? 答案是并不会 – 对于 undo log 中的快照,如果只有一个事务在访问当前行,那么该事务 commit 之后 undo log 中该行的版本链就会被 free 掉;如果有其他事务也在访问该行 (比如RR隔离等级下其他事务在 select 该行的某个快照),那么该行的版本链会等待其他事务都访问完毕后,即其他事务 commit 后再 free。

注意事项

我们上面是以修改 (update) 操作为主学习的 MVCC,那么如果是 delete 操作呢?其实是一样的,因为 MySQL 删数据不是真的删除,而是设置 flag 为删除状态,它也可以形成快照。 如果是 insert 操作呢?由于 insert 之前表中并没有对应的数据,那么 insert 也就没有历史版本;但是一般为了回滚操作,insert 的数据也会放入 undo log 中,当执行 insert 操作的事务 commit 之后,undo log 中对应的 insert 快照也就可以被 free 了。(因为没有其他事务在此前会访问表中并不存在的数据)

4、read view

快照读 && 当前读

我们上面讨论了 update/insert/delete 操作的快照问题,那么 select 呢?首先可以明确的是,由于 select 并不会修改数据,所以并没有必要为 select 维护多版本。

其实 select 的问题不在形成多版本,而在读取时应该读取哪个版本?是应该读取最新的版本呢?还是应该读取历史的某一个版本?针对这个问题,select 被分为了当前读和快照读:

  • 当前读:即读取最新的记录,update/insert/delete 都可以叫做当前读,因为修改一定是修改最新的数据。
  • 快照读:即读取历史版本。

通过之前的实验我们可以看到,在多事务写写并发的情况下,由于都要修改数据,所以需要加锁进行当前读。而在多事务读写并发时,如果读事务需要读取最新数据 (当前读),那么此时就必须串行化。

而如果读事务读取的历史数据 (快照读) 的话,我们就可以不必加锁,让当前读与快照读并发执行,从而提高效率,这也是 MVCC 存在的意义之一。

而到底是当前读还是快照读是取决于事务的隔离性的 – 在 RU 隔离级别下,并发事务读取的总是最新的数据,所以是当前读;而在 RC 与 RR 隔离级别下,并发事务读取的是历史版本的数据,此时就是快照读。(Serializable 需要加锁,不考虑)

read view

那么如何保证并发的不同事务看到自己应该看到的历史版本呢?即如何实现隔离级别呢?答案是 read view

Read View 就是事务进行 快照读 操作的时候生产的 读视图 (Read View);在该事务执行快照读的那一刻,会生成数据库系统当前的一个快照,此快照记录并维护系统当前活跃事务的ID (当每个事务开启时,都会被分配一个ID, 这个ID是递增的,所以最新的事务,ID值越大)

Read View 在 MySQL 源码中就是一个类,本质是用来进行可见性判断的。 即当某个事务执行快照读的时候,会对该记录创建一个 Read View 读视图,把它比作条件,用来判断当前事务能够看到哪个版本的数据,既可能是当前最新的数据,也有可能是该行记录的 undo log 里面的某个版本的数据。

read view 类的部分成员如下:

代码语言:javascript复制
class ReadView {
 // 省略...
private:
 /** 高水位,大于等于这个ID的事务均不可见*/
 trx_id_t m_low_limit_id;
    
  /** 低水位:小于这个ID的事务均可见 */
 trx_id_t m_up_limit_id;
    
 /** 创建该 Read View 的事务ID*/
 trx_id_t m_creator_trx_id;
    
 /** 创建视图时的活跃事务id列表*/
 ids_t m_ids;
    
 /** 配合purge,标识该视图不需要小于m_low_limit_no的UNDO LOG,
 * 如果其他视图也不需要,则可以删除小于m_low_limit_no的UNDO LOG*/
 trx_id_t m_low_limit_no;
    
 /** 标记视图是否被关闭*/
 bool m_closed;
    
 // 省略...
};

其中最核心的字段有如下四个:

代码语言:javascript复制
m_ids;           // 一张列表,用来维护Read View生成时刻,系统正活跃的事务ID
up_limit_id;     // 记录m_ids列表中事务ID最小的ID (没有写错)
low_limit_id;    // ReadView生成时刻系统尚未分配的下一个事务ID,也就是目前已出现过的事务ID的最大值 1 (也没有写错)
creator_trx_id   // 创建该ReadView的事务ID

现在我们在进行快照读的时候,即可以根据事务ID找到不同版本的快照,又可以通过 read view 对象取出上面四个核心变量;那么我们如何判断当前快照读应该读取哪个版本的快照呢?我们用一张图来说明:

上图一共被分为了三段,我们来一一解读:

  • 已经被提交的事务:我们用当前 up_limit_id 与快照中的事务ID进行对比,由于 up_limit_id 代表的是生成 read view 这一时刻与我并发执行的所有事务的最小ID,如果快照ID比此ID要小,那么说明修改此数据的事务在我生成 read view 时已经提交了,所以我应该看到这个数据,快照选择成功。
  • 快照后新来的事务:我们用当前 low_limit_id 与快照中的事务ID进行对比,由于 low_limit_id 代表的是生成 read view 这一时刻系统尚未分配的下一个事务ID,如果快照ID比此ID要大或者相等,那么说明修改此数据的事务在我生成 read view 时还没有开始运行,只是由于它的执行时间较短 (可能为短事务),所以要比我先执行完毕,所以我不应该看到这个数据,选择失败,顺着版本链继续向后选择。
  • 正在操作的事务:如果快照ID大于等于 up_limit_id 并且小于 low_limit_id,那么说明此快照是由在我生成 read view 这一时刻与我并发执行的事务形成的;此时分为两种情况:
    1. 快照ID在 m_ids 中,说明该事物在我生成 read view 时和我是并发的,都是活跃事务,没有 commit,所以不应该看到,继续向后选择。
    2. 快照ID不在 m_ids 中,说明在我生成 read view 这一时刻该事物已经 commit 了,此时不管是 RC 还是 RR 隔离级别下都应该看到,选择成功。
  • 注意:由于事务最新修改形成的快照是头插到版本链中的,所以我们在选择快照版本时,一定是从事务ID最大的快照开始往后查找的,这样就避免了我读到的是过老版本的数据。

MySQL 中关于上面快照选择逻辑的源码如下:

代码语言:javascript复制
/**
 *  可见性判断流程
 * @param view  当前事务 readview 快照
 * @param trx_id 数据行对应的事务 id
 * @return
 */
bool read_view_sees_trx_id(
	const read_view_t*	view,	
	trx_id_t		trx_id)
{
    // 如果小于当前事务的最小 id
	if (trx_id < view->up_limit_id) {

		return(true);
  // 如果大于等于当前事务快照的最大 id
	} else if (trx_id >= view->low_limit_id) {

		return(false);
	} else {
    // 如果在两者之间
		ulint	lower = 0;
		ulint	upper = view->n_trx_ids - 1;

		ut_a(view->n_trx_ids > 0);
    // 基于当前活跃的事务数组,通过二分法查找比较 trx_id 是否存在其中
		do {
			ulint		mid	= (lower   upper) >> 1;
			trx_id_t	mid_id	= view->trx_ids[mid];

			if (mid_id == trx_id) {
				return(FALSE);
			} else if (mid_id < trx_id) {
				if (mid > 0) {
					upper = mid - 1;
				} else {
					break;
				}
			} else {
				lower = mid   1;
			}
		} while (lower <= upper);
	}
    // 不在活跃事务中则当前数据行可见
	return(true);
}

下面我们举一个例子来复盘一下快照读的流程:

假设当前有记录如下:

-name

age-

DB_TRX_ID(创建该记录的事务ID)

DB_ROW_ID(隐藏主键)

DB_ROLL_PTR(回滚指针)

张三

28

null

1

null

事务操作如下:

-事务1 [id=1]

-事务2 [id=2]

事务3 [id=3]

事务4 [id=4]

事务开始

事务开始

事务开始

事务开始

修改且已提交

进行中

快照读

进行中

事务四执行的修改操作如下:修改 name (张三) 变成 name (李四);此时的版本链如下:

当事务2对某行数据执行了快照读,数据库会为该行数据生成一个 Read View 读视图,如下:

代码语言:javascript复制
// 事务2的 read view
m_ids;           // 1,3
up_limit_id;     // 1
low_limit_id;    // 4   1 = 5,ReadView生成时刻,系统尚未分配的下一个事务ID
creator_trx_id   // 2

由于只有事务4修改过该行记录,并在事务2执行快照读前,就提交了事务,所以我们的事务2在快照读该行记录的时候,就会拿该行记录的 DB_TRX_ID 去跟 up_limit_id , low_limit_id 和活跃事务ID列表 (trx_list) 进行比较,判断当前事务2能看到该记录的版本。如下:

代码语言:javascript复制
// 事务4提交的记录对应的事务ID
DB_TRX_ID=4
    
// 比较步骤
DB_TRX_ID(4)< up_limit_id(1) ?  不小于,下一步
DB_TRX_ID(4)>= low_limit_id(5) ? 不大于,下一步
m_ids.contains(DB_TRX_ID) ? 不包含,说明,事务4不在当前的活跃事务中。

由于快照ID大于等于事务2的 up_limit_id ,小于 low_limit_id,并且不在事务2的 m_ids 中,事务2能读到的最新数据记录是事务4所提交的版本,而事务4提交的版本也是全局角度上最新的版本。

5、RR 与 RC 的本质区别

我们可以通过一个简单的测试来引出 RR 与 RC 的本质区别。

设置会话模式 RR 隔离级别:

我们依旧以 user 表为例:

代码语言:javascript复制
create table if not exists user(
id int primary key,
age tinyint unsigned not null,
name varchar(20) not null default ''
)ENGINE=InnoDB DEFAULT CHARSET=UTF8;
代码语言:javascript复制
insert into user values (1, 15,'黄蓉');

测试一的事务操作如下:

-事务A操作

-事务A描述

事务B描述

事务B操作

begin

启动事务

启动事务

begin

select * from user

快照读

快照读

select * from user

update user set age=18 where id=1

更新 age=18

-

-

commit

提交事务

select 快照读,没有读到age=18

select * from user

select lock in share mode当前读 , 读到age=18

select * from user lock in share mode

测试二的事务操作如下:

-事务A操作

-事务A描述

事务B描述

事务B操作

begin

启动事务

启动事务

begin

select * from user

快照读

-

-

update user set age=28 where id=1

更新 age=28

-

-

commit

提交事务

select 快照读,age=28

select * from user

select lock in share mode 当前读 , 读到age=28

select * from user lock in share mode

对比测试一和测试二,我们可以发现测试一与测试二唯一的区别在于测试一的事务B在事务A未 commit 之前进行了一次快照读;而测试二的事务B是在事务A commit 之后才进行快照读的。但是读出来的结果却大相径庭。

所以结论就是事务快照读的结果取决于该事物首次出现快照读 (创建 read view 对象) 的地方,即某个事务中首次出现的快照读决定了该事务后续快照读结果的能力。delete 也是如此。

有了这个结论之后,RR 与 RC 的本质区别也就出来了:

  • 快照读 (read view 对象) 生成时机的不同,造成了 RR 与 RC 隔离级别下快照读结果的不同。
  • 在 RR 隔离级别下,某个事务在首次进行快照读时会创建一个快照及 read view 对象, 将当前系统活跃的其他事务记录下来; 此后在调用快照读的时候,使用的还是同一个Read View 对象,所以只要当前事务在其他事务提交更新之前使用过快照读,那么之后的快照读使用的都是同一个Read View,所以对之后的修改不可见;(正在操作的事务且在 m_ids 中)
  • 而在 RC 隔离级别下,某个事务在每次进行快照读时都会重新创建快照及 read view 对象,所以只要我们在 select 之前该事务将修改后的数据 commit 了,我们就可以读取到。(正在操作的事务且不在 m_ids 中)
  • 总结:在RC隔离级别下,每个快照读都会生成并获取最新的Read View;而在RR隔离级别下,则是同一个事务中的第一个快照读才会创建Read View, 之后的快照读获取的都是同一个Read View。
  • 所以,RR 与 RC 的本质区别在于每次进行快照读时是否会重新创建快照以及 read view 对象

6、总结

多版本并发控制 (MVCC) 是一种用来解决 读-写冲突 的无锁并发控制。MySQL 为事务分配单向增长的事务ID,也为每个修改保存一个版本,并且让版本与事务ID关联。此后读操作 (快照读) 只读该事务开始前数据在 undo log 中的快照,写操作 (当前读) 只修改 Page 中的最新数据。

基于上面的机制,MVCC 可以为数据库解决以下问题:

  • 在多事务并发读写数据库时,可以做到在读操作时不用阻塞写操作,写操作也不用阻塞读操作,从而提高了数据库并发读写的性能。(读的是快照数据,写的是最新数据)
  • 同时还可以解决赃读、幻读与不可重复读问题 (不同隔离级别事务看到不同版本的快照),但是不能解决更新丢失问题 (丢失更新属于写写并发的内容,我们不考虑)。

注意:这里的读操作指的是快照读,写操作指的是当前读,当前读与当前读是不能同时进行的,这属于写写并发。

0 人点赞