MySQL事务管理

2023-10-16 11:18:46 浏览数 (1)

事务并不是程序员的术语,而是用户层面上的业务。

MySQL增删查改时的问题

由于访问mysqld的设备不止一个,因此对于mysqld内部的数据,每一个设备都可以将其进行修改。而修改的过程是以多线程的方式并发控制的,这个时候,就大概率会产生一系列的线程安全问题。

比如:

因此,为了防止上述的错误,我们就需要将CURD进行一系列的限制,而这个限制我们将其称之为MySQL的事务管理。为了避免上述情况的发生,就需要指定规则,即:

CURD需要满足以下属性

  1. 买票的过程需要是原子性的。
  2. 买票的过程不能互相影响。
  3. 买完票这个动作是永久性的。
  4. 买前买后的状态是确定的,即有票还是没票。

一.什么是事务?

事务的本质,是站在MySQL之上的,即使用者的角度。这个功能可能由多条SQL构成,在具体业务场景进行的需求转换成的多条SQL。

因此,什么是事务?

事务: 我们将一条或多条SQL构成的集合体,这个集合体所要完成一系列的任务,我们将这一系列的任务统称为MySQL事务。

事务就是一组DML语句组成,这些语句在逻辑上存在相关性,这一组DML语句要么全部成功,要么全部失败,是一个整体。MySQL提供一种机制,保证我们达到这样的效果。事务还规定不同的客户端看到的数据是不相同的。事务就是要做的或所做的事情,主要用于处理操作量大,复杂度高的数据。假设一种场景:你毕业了,学校的教务系统后台 MySQL 中,不在需要你的数据,要删除你的所有信息(一般不会:) ), 那么要删除你的基本信息(姓名,电话,籍贯等)的同时,也删除和你有关的其他信息,比如:你的各科成绩,你在校表现,甚至你在论坛发过的文章等。这样,就需要多条 MySQL 语句构成,那么所有这些操作合起来,就构成了一个事务。

正如我们上面所说,一个 MySQL 数据库,可不止你一个事务在运行,同一时刻,甚至有大量的请求被包装成事务,在向 MySQL 服务器发起事务处理请求。而每条事务至少一条 SQL ,最多很多 SQL ,这样如果大家都访问同样的表数据,在不加保护的情况,就绝对会出现问题。甚至,因为事务由多条 SQL 构成,那么,也会存在执行到一半出错或者不想再执行的情况,那么已经执行的怎么办呢?

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

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

上面四个属性,可以简称为 ACID 。

  1. 原子性(Atomicity,或称不可分割性)
  2. 一致性(Consistency)
  3. 隔离性(Isolation,又称独立性)
  4. 持久性(Durability)。

二.为什么会出现事务?

事务被 MySQL 编写者设计出来,本质是为了当应用程序访问数据库的时候,事务能够简化我们的编程模型,不需要我们去考虑各种各样的潜在错误和并发问题.可以想一下当我们使用事务时,要么提交,要么回滚,我们不会去考虑网络异常了,服务器宕机了,同时更改一个数据这些情况。因此事务本质上是为了应用层服务的.而不是伴随着数据库系统天生就有的.

共识:我们后面把 MySQL 中的一行信息,称为一行记录。

三.事务的其他属性

1. 事务的版本支持

在 MySQL 中只有使用了 Innodb 数据库引擎的数据库或表才支持事务, MyISAM 不支持。

通过以下命令可以产科数据库支持的存储引擎:

表格显示:

代码语言:javascript复制
show engines;

行显示:

代码语言:javascript复制
show engines G ;

其中,Support中的default代表默认引擎,Transactions表示是否支持事务。

2. 事务的提交方式

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

  • 自动提交
  • 手动提交

查看事务提交方式:

可见,事务的默认提交方式是自动提交的。

我们也可以通过set手动的去修改事物的提交方式,比如将自动提交关闭,那么就变成了手动提交:

若想重新设置回自动提交,只需将0变成1:

四.事务的准备工作

mysql的客户端在/usr/bin/mysql路径下;而mysqld,即服务端在/usr/sbin/mysqld路径下。

而我们使用的就是客户端。客户端有很多种,包括有图形化界面的和无图形化界面的,各种语言版本的。mysql也是一套网络服务,因此我们也可以用远程连接的方式进行操作。因此,一个mysqld,即一个服务端可以被多个客户端访问。

为了便于演示,我们将mysql的默认隔离级别设置成读未提交。具体操作后面专门会讲,现在以使用为主。

这样设置之后,我们需要重新登陆MySQL,设置的才能有效。

重新打开一个窗口,登陆mysql,检查隔离级别,发现他的隔离级别也是读未提交。

我们将如上两个客户端充当并发访问mysqld的客户端,用如上两个客户端来演示各种并发场景。此外,我们将隔离级别设置成最低,即只要一方操作,另一方就会马上反应。(若调成最高,则不能同时观察到)

下面,创建一张员工的工资表:

如果我们再用一个客户端连接mysqld,我们可以查看当前有多少人在连接mysql:

此时可以看到,Time所对应的时间可以推出正在访问的有两个客户端。下面就用这两个客户端进行操作。

五.事务的操作

1. 事务的正常操作

证明事务的开始与回滚

首先,我们看一下提交方式,发现提交方式是自动提交的:

下面就是启动事务,启动事务之后,下面的所有操作都是同一个事务的操作。

代码语言:javascript复制
start transaction;        -- 开始一个事务begin也可以,推荐begin

我们将在左侧的事务插入数据,左侧的事务并未结束,但是我们可以在右侧的事务查询到左侧事务操作的结果:

此时我们再设置一个保存点s2,相当于标记,表示后续能够通过这个标记返回,方便我们后续定向回滚。

此时继续插入数据:

然后继续设置保存点s3,即插入一条数据,设置一个保存点:

左侧的所有操作让我们的mysql原子性的,持久化的,具有隔离性的一次保存在数据库里,这称之为一个事务。

但此时,我们不想去插入王五了,那么我们就可以根据设置的s3保存点向前回滚,将王五这条数据撤销。

代码语言:javascript复制
rollback to s3;

通过另一个事务查看,可以发现王五的这条数据不存在了。同理,如果我们想回滚到s2,回滚到s1,都是可以的。如下,我们就回滚到s1,即没有数据的时候:

最终,如果我们不先想操作这个事务,即结束掉左侧的事务,就执行:

代码语言:javascript复制
commit;

最终的结果依然是空的,因为事务提交时的结果就是没有数据。

如果不设置保存点,直接进行rollback,那就是将从事务开始到目前的所有操作全都回滚掉。

再commit结束掉,此时就属于正常的sql了。

再启动一次这两个事务,并且不回滚,直接commit,就会发现数据最终保存到了数据库中,即便之后再rollback,也不起作用。

因此,我们所提到的回滚操作,是在事务运行期间才可以进行回滚,事务一旦结束,就无法回滚。

上面我们所提到的都是事务的正常操作所得到的的结果。事务的产生实际上是为了应对那些非正常操作的情况,而我们的保存点就是在非正常操作的情况才会产生真正的作用,比如在操作时sql的客户端突然挂掉了,或者服务端被强制关闭了等等这样的情况,下面就来看看事务的异常操作。

2. 事务的异常验证与产出结论

首先,我们的提交方式仍然是自动提交的:

下面,我们来看看各种非正常操作中,事务是如何进行处理的。我们先将这两个事务启动:

非正常演示1 - 证明未commit,客户端崩溃,MySQL自动会回滚(隔离级别设置为读未提交)

在原有的基础上,插入新的数据:

然后,为了营造客户端崩溃的场景,左侧的事务中直接快捷键ctrl 让客户端崩溃。

再次观察右侧,发现新的数据消失了,这实际上就是事务因异常情况从而自动回滚:

同样的,在commit之前若将客户端直接关闭,也会产生回滚。

非正常演示2 - 证明commit了,客户端崩溃,MySQL数据不会在受影响,已经持久化

只要在产生异常或者退出客户端之前就进行commit,数据不会丢失,因为已经插入到数据库中了。也就是说,commit之前可以随时回滚,commit之后不能回滚。

非正常演示3 - 对比试验。证明begin操作会自动更改提交方式,但不会受MySQL是否自动提交所影响

我们将自动提交关掉,并进行与演示1相同的操作:

启动事务,在原有的基础上,插入新的数据,并进行commit:

左侧快捷键ctrl ,使mysql客户端异常终止,观察右侧表中数据:

所以,mysql的提交方式无论是自动还是手动,并不会影响我们事务的手动提交。

非正常演示4 - 证明单条 SQL 与事务的关系: (此时MySQL的提交方式是OFF)

示例一:

首先,如果在事务中,我们begin之后,删除田七的数据,并进行commit,就将这个执行永久执行了。

示例二: 如果我们不在事务中了,也就是不执行begin,同样的也不执行commit,就是纯粹的SQL进行操作,删除id=2的数据,会发现和预料到的一样,右侧终端显示已经被删除:

但是当左侧直接将mysql关掉,这时候,右侧继续显示,发现删除的id=2的数据又回来了:

那如果仍然不启动事务,将自动提交打开,再删除一次id=1的数据,然后让左侧的崩溃,发现数据不会回来,删了就是删了,这也与我们的预期一样。

因此,通过两个示例的对比我们不难发现:

  • 此对比就是对单SQL语句在是否开启自动提交的情况下进行比较的。
  • 自动提交只对单SQL,即非事务的SQL产生效果。
  • 每一个SQL语句本身实际上都是一个事务,在没有提交时实际上都不在数据库中。
  • 如果不是自动提交的,那么在出现故障时,单SQL就会产生回滚,使该SQL不影响数据库中的内容。
  • 如果是自动提交的,那么在出现故障时,单SQL就会自动提交,改变数据库中的内容,进行持久化。

因此,我们可以再次验证一下,单SQL如果在自动提交关闭的情况下进行commit,会发生什么情况:

我们发现,同样永久修改了数据库。因此可以证明,每一条SQL语句都是一个事务。只不过以前存在自动提交,我们并不能发现。

最终结论:

  • 只要输入begin或者start transaction,事务便必须要通过commit提交,才会持久化,与是 否设置set autocommit无关。
  • 事务可以手动回滚,同时,当操作异常,MySQL会自动回滚。
  • 对于 InnoDB 每一条 SQL 语言都默认封装成事务,自动提交。(select有特殊情况,因为 MySQL 有 MVCC )
  • 从上面的例子,我们能看到事务本身的原子性(回滚),持久性(commit)

事务操作注意事项

  • 如果没有设置保存点,也可以回滚,只能回滚到事务的开始。直接使用 rollback(前提是事务 还没有提交)。
  • 如果一个事务被提交了(commit),则不可以回退(rollback)。
  • 可以选择回退到哪个保存点。
  • 开始事务可以使 start transaction 或者 begin

通过此演示,我们已经了解到了事务的原子性和持久性,那么隔离性、一致性下面就开始介绍:

六.事务的隔离级别

1. 事务隔离级别概念

  • MySQL服务可能会同时被多个客户端进程(线程)访问,访问的方式以事务的方式进行。
  • 一个事务可能由多条SQL语句构成,也就意味着任何一个事务,都有执行前、执行中和执行后三个阶段,而所谓的原子性就是让用户层要么看到执行前,要么看到执行后,执行中如果出现问题,可以随时进行回滚,所以单个事务对用户表现出来的特性就是原子性。
  • 但毕竟每个事务都有一个执行的过程,在多个事务各自执行自己的多条SQL时,仍然可能会出现互相影响的情况,比如多个事务同时访问同一张表,甚至是表中的同一条记录。
  • 数据库为了保证事务执行过程中尽量不受干扰,于是出现了隔离性的概念,而数据库为了允许事务在执行过程中受到不同程度的干扰,于是出现了隔离级别的概念。

数据库事务的隔离级别有以下四种:

  • 读未提交(Read Uncommitted): 在该隔离级别下,所有的事务都可以看到其他事务没有提交的执行结果,实际生产中不可能使用这种隔离级别,因为这种隔离级别相当于没有任何隔离性,会存在很多并发问题,如脏读、幻读、不可重复读等。
  • 读提交(Read Committed): 该隔离级别是大多数数据库的默认隔离级别,但它不是MySQL默认的隔离级别,它满足了隔离的简单定义:一个事务只能看到其他已经提交的事务所做的改变,但这种隔离级别存在不可重复读和幻读的问题。
  • 可重复读(Repeatable Read): 这是MySQL默认的隔离级别,该隔离级别确保同一个事务在执行过程中,多次读取操作数据时会看到同样的数据,即解决了不可重复读的问题,但这种隔离级别下仍然存在幻读的问题。
  • 串行化(Serializable): 这是事务的最高隔离级别,该隔离级别通过强制事务排序,使之不可能相互冲突,从而解决了幻读问题。它在每个读的数据行上面加上共享锁,但是可能会导致超时和锁竞争问题,这种隔离级别太极端,实际生成中基本不使用。

说明一下:

  • 虽然数据库事务的隔离级别有以上四种,但一个稳态的数据库只会选择这其中的一种,作为自己的默认隔离级别。但数据库默认的隔离级别有时可能并不满足上层的业务需求,因此数据库提供了这四种隔离级别,可以让我们自行设置。
  • 隔离级别基本上都是通过加锁的方式实现的,不同的隔离级别对锁的使用是不同的,常见的有表锁、行锁、写锁、间隙锁(GAP)、Next-Key锁(GAP 行锁)等。

2. 查看与设置事务的隔离级别

有三种查看方式:

  1. 查看全局隔离级别
代码语言:javascript复制
SELECT @@global.tx_isolation;

  1. 查看会话(当前)全局隔级别
代码语言:javascript复制
SELECT @@session.tx_isolation; 

  1. 与第二种方式一样
代码语言:javascript复制
SELECT @@tx_isolation;

说明一下:

  • 其中,第二种和第三种方式是一样的,第三种是第二种的缩写。
  • 而对于第一种的global来说,相比于session,global是session的默认配置。
  • 当我们登陆mysql后,默认mysql会读取全局配置好的隔离级别,即global的隔离级别,用来初始化本次登陆的会话(session)隔离级别。
  • 因此,更改session的隔离级别只会影响本次会话的隔离级别;而更改global的隔离级别则会影响后续所有登陆的会话的隔离级别。
  • 当我们登陆一次xshell并且连接成功,叫做一次会话。

设置隔离级别

语法:

代码语言:javascript复制
SET [SESSION | GLOBAL] TRANSACTION ISOLATION LEVEL {READ UNCOMMITTED | READ
COMMITTED | REPEATABLE READ | SERIALIZABLE}

说明一下:

  • []内部表示可选项,表示选择session还是global的设置。
  • 级别有四种,同样选择其一,即:READ UNCOMMITTED 、 READ COMMITTED 、 REPEATABLE READ |、SERIALIZABLE,分别代表读未提交、读提交、可重复读、串行化。

比如,我们想以会话(session)的方式将事务设置成读提交:

代码语言:javascript复制
set session transaction isolation level read committed;

我们也可以验证一下,其他会话的隔离级别是否改变:

可见,session的方式修改事务的隔离级别只会改变当前会话的隔离级别,不会影响其他会话的隔离级别。

需要注意的是,一旦修改了global的隔离级别,当前会话必须重新登陆,隔离级别才会被修改。

3. 隔离级别—读未提交(Read Uncommitted)

对于读未提交,我们在 五.事务的操作 中实际上展示的就是读未提交,只不过没有说出来而已。

首先,我们先把隔离级别设置为读未提交,按照global方式进行修改后,需重新登陆:

此时我们打开另一个终端,作为客户端访问数据库。此时开始实验:

同时启动事务,表示两个事务并发在跑

当前并没有commit,因此这两个事务都存在执行前和执行中,接下来先让右侧的事务对数据先进行查看:

此时我们需要记住,在begin和commit之间的所有操作打成一个包,才叫做一个事务,所以目前为止,这两个事务并不完整。

由于事务具有原子性,在左侧的事务未结束即并未commit,原则上右侧的事务不能观察到左侧未结束的事务,但是我们设置了读未提交,结果就是在左侧事务的执行过程中,右侧的事务同时也能观察左侧事务的一举一动:

我们知道,事务要么不做,要么全做。而此时,我们左侧的事务执行了一半,右侧的另一个事务就能看到了,因此对于这个右侧的事务来说,此隔离级别就是读到了,但是并未提交,也就是读未提交。

那么我们rollback左侧的事务,将其全部回滚之后,右侧也就能看到之前的修改操作全部没了:

此时这个事务也算结束了。

因此,在事务中,一个事务没有结束并且正在进行CURD操作,另一个事务也能立刻查看这个事务的数据,我们将这种情况称之为读未提交。

这就好比在多线程中,对同一份资源做修改,另一个线程立马能看到,一定是因为没有加锁。所以对于读未提交来说,这个隔离级别几乎没有加锁,虽然效率高,但是存在很多问题。

读未提交隔离级别的问题:

读未提交会产生脏读。一个事务在执行中,读到另一个执行中事务的更新(或其他操作)但是未commit的数据,这种现象叫做脏读(dirty read)。这种现象很不合理,因为作为原子性的操作,必须要等到一个事务结束,另一个事务才能查看。

4. 隔离级别—读提交(Read Committed)

首先,我们要把隔离级别改成读提交。

当我们其中的一个客户端修改或增加mysql的数据时,实际上已经写进mysql了,但只对自己本身有效,其他的客户端并不会生效。比如下面的更改,左侧的客户端能够看到自己已修改的数据,但由于未commit,右侧的mysql并不能得到一样的显示:

只有提交之后,才会显示:

那么什么是读提交呢?

一个事务做增删查改操作,另一个事务在此期间不能观察到现象,只有提交之后才能得到对应的结果,我们将这种隔离级别称之为读提交。

不可重复读

那么,我们可以发现,两侧的数据显示是不同的,这其实是合理的,就比如不同世纪的人经历的事情并不一样。那么,仅仅对于右侧的现象,我们并不知道左侧什么时候提交的,但会发现右侧的查询会有不一样的结果,我们将这种现象叫做不可重复读。

不可重复读: 即在事务的高并发情况下,任何一个事务都极有可能在修改时的同时导致另一个事务看不到,当这个事务提交了,其他事务才能看到,就会导致同样的调用select查看数据在1秒前和1秒后的现象是不一样的。

不可重复读真的算是问题吗?

不可重复读就是一个问题,其是在隔离级别为读提交的场景下的。事务是具有原子性的,如果两个事务并行的在跑,其中一个事务提交之后,另一个事务还未提交就会看到,这就导致了另一个事务受到了这个事务的影响,这就与事务的原子性相冲突。因为只有在当开始下一个事务时受到之前事务的影响,这种情况才不与原子性相违背。

举个例子:

对于员工工资来说,一旦提高了该员工的工资,而恰好此时在根据不同的薪资情况给员工发奖金。这两个情况一旦并行,就极有可能导致某个员工在涨薪前后,查看薪资范围时查看了该员工两次,从而发给该员工两次奖金,这种情况一定是不合理的,所以不可重复读一定是存在问题的。因此,读提交隔离级别仍然不严谨。

5. 隔离级别—可重复读(Repeatable Read)

什么是可重复读?

可重复读隔离级别就是为了处理上面的不可重复读而存在的。一个事务与另一个事务并行执行,其中一个事务提交,也不会影响另一个事务的运行(CURD),我们将这种隔离级别称之为可重复读。

下面就演示一下,首先将默认隔离级别改成可重复读,并重启生效:

开始事务,并查看原始信息:

当我们修改数据并且提交,我们仍发现另一个事务的数据没有被影响:

当我们将右侧事务也提交,我们继续查看表信息:

发现当事务结束后,就可以查看到最新的数据信息了。即可重复读意味着并发的事务在运行期间不会受到影响,事务结束,数据就会更新。这也是mysql的默认隔离级别。

幻读情况:专门针对insert

select多次查看,发现终端A在对应事务中insert的数据,在终端B的事务周期中,也没有什么影响,也符合可重复的特点。但是,一般的数据库在可重复读情况的时候,无法屏蔽其他事务insert的数据(为什么?因为隔离性实现是对数据加锁完成的,而insert待插入的数据因为并不存在,那么一般加锁无法屏蔽这类问题),会造成虽然大部分内容是可重复读的,但是insert的数据在可重复读情况被读取出来,导致多次查找时,会多查找出来新的记录,就如同产生了幻觉。这种现象,叫做幻读(phantom read)。很明显,我使用的MySQL5.7在RR级别的时候,是解决了幻读问题的(解决的方式是用Next-Key锁(GAP 行锁)解决的),insert并没有产生幻读。

6. 隔离级别—串行化(Serializable)

经过上三种隔离方式,我们已经发现隔离就是为了在高并发时互相不影响,但是上三种或多或少都存在着彼此影响的情况。而串行化从根本上解决了这个问题,因为同一时间串行化只能运行一个事务在运行,这也就导致了串行化的效率非常低下,几乎不会被采用。

什么是串行化?

串行化是事务的最高隔离级别,多个事务同时进行读操作时加的是共享锁,因此可以并发执行读操作,但一旦需要进行写操作,就会进行串行化,效率很低,几乎不会使用。

下面就演示一下,首先将默认隔离级别改成串行化,并重启生效:

观察此时的数据信息:

在两个终端各自启动一个事务,如果这两个事务都对表进行的是读操作,那么这两个事务可以并发执行,不会被阻塞。如下:

但如果这两个事务中有一个事务要对表进行写操作,那么这个事务就会立即被阻塞。如下:

直到访问这张表的其他事务都提交后,这个被阻塞的事务才会被唤醒,然后才能对表进行修改操作。如下:

7. 隔离级别总结

对MySQL中的隔离级别总结如下:

隔离级别

脏读

不可重复读

幻读

加锁读

读未提交(read uncommitted)

不加锁

读已提交(read committed)

X

不加锁

可重复读(repeatable read)

X

X

X

不加锁

可串行化(serializable)

X

X

X

加锁

√:会发生该问题 X:不会发生该问题

说明一下:

  • 隔离级别越严格,安全性越高,但数据库的并发性能也就越低,在选择隔离级别时往往需要在两者之间找一个平衡点。
  • 表中只写出了各种隔离级别下进行读操作时是否需要加锁,因为无论哪种隔离级别,只要需要进行写操作就一定需要加锁。

七.一致性(Consistency)的理解

事务执行的结果,必须使数据库从一个一致性状态,变到另一个一致性状态,当数据库只包含事务成功提交的结果时,数据库就处于一致性状态。

  • 事务在执行过程中如果发生错误,则需要自动回滚到事务最开始的状态,就像这个事务从来没有执行过一样,即一致性需要原子性来保证。
  • 事务处理结束后,对数据的修改必须是永久的,即便系统故障也不能丢失,即一致性需要持久性来保证。
  • 多个事务同时访问同一份数据时,必须保证这多个事务在并发执行时,不会因为由于交叉执行而导致数据的不一致,即一致性需要隔离性来保证。
  • 此外,一致性与用户的业务逻辑强相关,如果用户本身的业务逻辑有问题,最终也会让数据库处于一种不一致的状态。

也就是说,一致性实际是数据库最终要达到的效果,一致性不仅需要原子性、持久性和隔离性来保证,还需要上层用户编写出正确的业务逻辑。

八.如何理解隔离性(提高)

对于事务的隔离级别,通过之前的演示,我们已经理解了各种情况下的隔离级别。其中,作为首位和末尾的读未提交和串行化隔离级别,他们的原理无可厚非很好理解。但在中间的两个隔离级别:读提交(RC)和可重复读(RR),这两个的是如何做到的呢,原理是什么样的呢?

在RR级别的时候,多个事务的update,多个事务的insert,多个事务的delete,也是存在加锁现象的。所以对于并发性这种情况来说,一般更多是集中在读写并发上,写写在RR级别上一定要加锁,毕竟这个写一半那个又要写肯定是有问题的。

1. 数据库的并发场景

针对于隔离性来说,根据不同的场景有不同的并发策略。

  • 读-读:不存在任何问题,也不需要并发控制。
  • 读-写:有线程安全问题,可能会造成事务隔离性问题,可能遇到脏读,幻读,不可重复读。
  • 写-写:有线程安全问题,可能会存在更新丢失问题,比如第一类更新丢失,第二类更新丢失(后面补充)

在我们的操作中,大多都是读写并发,因此读-读写-写不在讨论范畴之中,下面就深入探讨一下读-写的并发场景。

2. 读-写的并发场景(多版本并发控制MVCC)

在之前的演示中,我们已经证实了在隔离时读的数据和写的数据可以不一样,其实这已经证明了读的数据和写的数据并不是同一份。因此,如果在读-写并发下,想要实现很好的隔离性的话,我们要实现的核心技术之一:多版本并发控制(MVCC)。

什么是多版本并发控制?

多版本并发控制( MVCC )是一种用来解决 读-写冲突 的无锁并发控制

1. 事务的开始有先有后,那么是如何控制先后顺序的呢?

MySQL会为事务分配单向增长的事务ID,事务ID与事务是一对一的关系,数字越小来的越早,执行优先级越高。因此每个事务都要有自己的事务ID,可以根据事务ID的大小来决定事务到来的先后顺序。

2. mysqld同一时期处理多个事务

  • 通过不同的客户端,mysqld可能会面临处理多个事务的情况。
  • 在事务的执行过程中,事务也有自己的生命周期(begin,回滚,commit等),mysqld要对多个事务进行管理,所以,mysqld内部一定要对事务进行:先描述,再组织!
  • 故事务在我们看来,mysqld中一定对应了一个或一套结构体对象/类对象。
  • 这就推出,事务也会有自己的结构体,包括提到过的事务ID,这些结构体会被指定的数据结构管理起来。
  • 因此我们对事务的任何处理,也就变成了对数据结构的增删查改。

要学习隔离级别,我们首先要了解MVCC,而要理解MVCC,还需要知道下面的三个前提知识。

3. 三个前提知识

理解 MVCC 需要知道三个前提知识:

  • 3个记录隐藏字段
  • undo 日志
  • Read View
记录中的三个隐藏字段

数据库表中的每条记录都会有如下3个隐藏字段:

  • DB_TRX_ID:6字节,创建或最近一次修改该记录的事务ID。
  • DB_ROW_ID:6字节,隐含的自增ID(隐藏主键)。
  • DB_ROLL_PTR:7字节,回滚指针,指向这条记录的上一个版本。

说明一下:

  • 采用InnoDB存储引擎建立的每张表都会有一个主键,如果用户没有设置,InnoDB就会自动以DB_ROW_ID产生一个聚簇索引。
  • 此外,数据库表中的每条记录还有一个删除flag隐藏字段,用于表示该条记录是否被删除,便于进行数据回滚。

示例

创建一个学生表,表中包含学生的姓名和年龄。如下:

当向表中插入一条记录后,该记录不仅包含name和age字段,还包含三个隐藏字段。如下:

说明一下:

  • 假设插入该记录的事务的事务ID为9,那么该记录的DB_TRX_ID字段填的就是9。
  • 因为这是插入的第一条记录,所以隐式主键DB_ROW_ID字段填的就是1。
  • 由于这条记录是新插入的,没有历史版本,所以回滚指针DB_ROLL_PTR的值设置为null。
  • MVCC重点需要的就是这三个隐藏字段,实际还有其他隐藏字段,只不过没有画出。
undo日志

MySQL的三大日志如下:

  • redo log:重做日志,用于MySQL崩溃后进行数据恢复,保证数据的持久性。
  • bin log:逻辑日志,用于主从数据备份时进行数据同步,保证数据的一致性。
  • undo log:回滚日志,用于对已经执行的操作进行回滚,保证事务的原子性。

MySQL会为上述三大日志开辟对应的缓冲区,用于存储日志相关的信息,必要时会将缓冲区中的数据刷新到磁盘。

说明一下:

  • MVCC的实现主要依赖三大日志中的undo log,记录的历史版本就是存储在undo log对应的缓冲区中的。
快照的概念

现在有一个事务ID为10的事务,要将刚才插入学生表中的记录的学生姓名改为“李四”:

  • 因为是要进行写操作,所以需要先给该记录加行锁。
  • 修改前,先将该行记录拷贝到undo log中,并将该记录中的DB_TRX_ID改为10,回滚指针DB_ROLL_PTR设置成undo log中副本数据的地址,从而指向该记录的上一个版本。
  • 最后当事务10提交后释放锁,这时最新的记录就是学生姓名为“李四”的那条记录。

修改后的示意图如下:

现在又有一个事务ID为11的事务,要将刚才学生表中的那条记录的学生年龄改为38:

  • 因为是要进行写操作,所以需要先给该记录(最新的记录)加行锁。
  • 修改前,先将该行记录拷贝到undo log中,此时undo log中就又有了一行副本数据。
  • 然后再将原始记录中的学生年龄改为38,并将该记录的DB_TRX_ID改为11,回滚指针DB_ROLL_PTR设置成刚才拷贝到undo log中的副本数据的地址,从而指向该记录的上一个版本。
  • 最后当事务11提交后释放锁,这时最新的记录就是学生年龄为38的那条记录。

修改后的示意图如下:

此时我们就有了一个基于链表记录的历史版本链,而undo log中的一个个的历史版本就称为一个个的快照。

说明一下:

  • 所谓的回滚实际就是用undo log中的历史数据覆盖当前数据,而所谓的创建保存点就可以理解成是给某些版本做了标记,让我们可以直接用这些版本数据来覆盖当前数据。
  • 这种技术实际就是基于版本的写时拷贝,当需要进行写操作时先将最新版本拷贝一份到undo log中,然后再进行写操作,和父子进程为了保证独立性而进行的写时拷贝是类似的。

insert和delete的记录如何维护版本链?

  • 删除记录并不是真的把数据删除了,而是先将该记录拷贝一份放入undo log中,然后将该记录的删除flag隐藏字段设置为1,这样回滚后该记录的删除flag隐藏字段就又变回0了,相当于删除的数据又恢复了。
  • 新插入的记录是没有历史版本的,但是一般为了回滚操作,新插入的记录也需要拷贝一份放入undo log中,只不过被拷贝到undo log中的记录的删除flag隐藏字段被设置为1,这样回滚后就相当于新插入的数据就被删除了。

也就是说,增加、删除和修改数据都是可以形成版本链的。

当前读 VS 快照读

  • 当前读:读取最新的记录,就叫做当前读。
  • 快照读:读取历史版本,就叫做快照读。

事务在进行增删查改的时候,并不是都需要进行加锁保护:

  • 事务对数据进行增删改的时候,操作的都是最新记录,即当前读,需要进行加锁保护。
  • 事务在进行select查询的时候,既可能是当前读也可能是快照读,如果是当前读,那也需要进行加锁保护,但如果是快照读,那就不需要加锁,因为历史版本不会被修改,也就是可以并发执行,提高了效率,这也就是MVCC的意义所在。

而select查询时应该进行当前读还是快照读,则是由隔离级别决定的,在读未提交和串行化隔离级别下,进行的都是当前读,而在读提交和可重复读隔离级别下,既可能进行当前读也可能进行快照读。

undo log中的版本链何时才会被清除?

  • 在undo log中形成的版本链不仅仅是为了进行回滚操作,其他事务在执行过程中也可能读取版本链中的某个版本,也就是快照读。
  • 因此,只有当某条记录的最新版本已经修改并提交,并且此时没有其他事务与该记录的历史版本有关了,这时该记录在undo log中的版本链才可以被清除。

说明一下:

  • 对于新插入的记录来说,没有其他事务会访问它的历史版本,因此新插入的记录在提交后就可以将undo log中的版本链清除了。
  • 因此版本链在undo log中可能会存在很长时间,尤其是有其他事务和这个版本链相关联的时候,但这也没有坏处,这说明它是一个热数据。

4. Read View

  • 事务在进行快照读操作时会生成读视图Read View,在该事务执行快照读的那一刻,会生成数据库系统当前的一个快照,记录并维护系统当前活跃的事务ID。
  • Read View在MySQL源码中就是一个类,本质是用来进行可见性判断的,当事务对某个记录执行快照读的时候,对该记录创建一个Read View,根据这个Read View来判断,当前事务能够看到该记录的哪个版本的数据。

ReadView类的源码如下:

代码语言: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;
	
	// 省略...
};

部分成员说明:

  • m_ids: 一张列表,记录Read View生成时刻,系统中活跃的事务ID。
  • m_up_limit_id: 记录m_ids列表中事务ID最小的ID。
  • m_low_limit_id: 记录Read View生成时刻,系统尚未分配的下一个事务ID。
  • m_creator_trx_id: 记录创建该Read View的事务的事务ID。

由于事务ID是单向增长的,因此根据Read View中的m_up_limit_id和m_low_limit_id,可以将事务ID分为三个部分:

  • 事务ID小于m_up_limit_id的事务,一定是生成Read View时已经提交的事务,因为m_up_limit_id是生成Read View时刻系统中活跃事务ID中的最小ID,因此事务ID比它小的事务在生成Read View时一定已经提交了。
  • 事务ID大于等于m_low_limit_id的事务,一定是生成Read View时还没有启动的事务,因为m_low_limit_id是生成Read View时刻,系统尚未分配的下一个事务ID。
  • 事务ID位于m_up_limit_id和m_low_limit_id之间的事务,在生成Read View时可能正处于活跃状态,也可能已经提交了,这时需要通过判断事务ID是否存在于m_ids中来判断该事务是否已经提交。

示意图如下:

  • 一个事务在进行读操作时,只应该看到自己或已经提交的事务所作的修改,因此我们可以根据Read View来判断当前事务能否看到另一个事务所作的修改。
  • 版本链中的每个版本的记录都有自己的DB_TRX_ID,即创建或最近一次修改该记录的事务ID,因此可以依次遍历版本链中的各个版本,通过Read View来判断当前事务能否看到这个版本,如果不能则继续遍历下一个版本。

源码策略如下:

代码语言:javascript复制
bool changes_visible(trx_id_t id, const table_name_t& name) const 
	MY_ATTRIBUTE((warn_unused_result))
{
	ut_ad(id > 0);
	//1、事务id小于m_up_limit_id(已提交)或事务id为创建该Read View的事务的id,则可见
	if (id < m_up_limit_id || id == m_creator_trx_id) {
		return(true);
	}
	check_trx_id_sanity(id, name);
	//2、事务id大于等于m_low_limit_id(生成Read View时还没有启动的事务),则不可见
	if (id >= m_low_limit_id) {
		return(false);
	}
	//3、事务id位于m_up_limit_id和m_low_limit_id之间,并且活跃事务id列表为空(即不在活跃列表中),则可见
	else if (m_ids.empty()) {
		return(true);
	}
	const ids_t::value_type* p = m_ids.data();
	//4、事务id位于m_up_limit_id和m_low_limit_id之间,如果在活跃事务id列表中则不可见,如果不在则可见
	return (!std::binary_search(p, p   m_ids.size(), id));
}

说明一下: 使用该函数时将版本的DB_TRX_ID传给参数id,该函数的作用就是根据Read View,判断当前事务能否看到这个版本。

九.RR与RC的本质区别

现象演示一:

启动两个终端,将事务的隔离级别都改为可重复读。并查看此时user表中的数据:

在两个终端各自启动一个事务,在左终端中的事务操作之前,先让右终端中的事务查看一下表中的信息。如下:

左终端中的事务对表中的信息进行修改并提交,针对可重复读的隔离级别,右终端中的事务看不到修改后的数据,即便左侧commit,在右侧的事务只要未停止,那么右终端中的事务就看不到修改后的数据,因为这种读都被称之为快照读。如下:

在右终端中使用select ... lock in share mode命令进行当前读,可以看到表中的数据确实是被修改了,只是右终端中的事务看不到而已。如下:

现象演示二:

我们将左右两侧事务begin以后,不让右侧进行快照读,仅仅只是让左侧的事务进行修改数据并提交,提交之后,右侧的事务在进行快照读与当前读,我们发现这两个结果是一样的,都是当前读的数据:

那么这种现象与可重复读的隔离性冲突吗,实际上一点也不冲突,因为左侧事务已经完成了,不可能会影响到右侧事务了。

说明一下:03

  • 上面两次实验的唯一区别在于,右终端中的事务在左终端中的事务修改数据之前是否进行过快照读。
  • 由于RR级别下要求事务内每次读取到的结果必须是相同的,因此事务首次进行快照读的地方,决定了该事务后续快照读结果的能力。

RR与RC的本质区别

  • 正是因为Read View生成时机的不同,从而造成了RC和RR级别下快照读的结果的不同。
  • 在RR级别下,事务第一次进行快照读时会创建一个Read View,将当前系统中活跃的事务记录下来,此后再进行快照读时就会直接使用这个Read View进行可见性判断,因此当前事务看不到第一次快照读之后其他事务所作的修改。
  • 而在RC级别下,事务每次进行快照读时都会创建一个Read View,然后根据这个Read View进行可见性判断,因此每次快照读时都能读取到被提交了的最新的数据。
  • RR级别下快照读只会创建一次Read View,所以RR级别是可重复读的,而RC级别下每次快照读都会创建新的Read View,所以RC级别是不可重复读的。

0 人点赞