本片文章对MySQL中的事物进行了详解。其中包含了事物的特性、为什么要有事物、查看事物版本支持、事物常见操作、事物的隔离界别等等内容进行详细举例解释。同时还深入讲解了事物的隔离性,模拟实现MVCC多版本并发控制,也讲解了RR和RC的本质区别。希望本篇文章会对你有所帮助!
文章目录[1、1 CURD不加控制,会有什么问题](#1、1 CURD不加控制,会有什么问题)
[1、2 什么是事务](#1、2 什么是事务)
[1、3 为什么会出现事务](#1、3 为什么会出现事务)
[1、4 查看事务的版本支持](#1、4 查看事务的版本支持)
[1、5 事务提交方式](#1、5 事务提交方式)
[3、1 如何理解隔离性](#3、1 如何理解隔离性)
[3、2 查看与设置隔离性](#3、2 查看与设置隔离性)
[3、3 隔离级别详解](#3、3 隔离级别详解)
[3、3、1 读未提交(Read Uncommitted)](#3、3、1 读未提交(Read Uncommitted))
[3、3、2 读提交(Read Committed)](#3、3、2 读提交(Read Committed))
[3、3、3 可重复读(Repeatable Read)](#3、3、3 可重复读(Repeatable Read))
[3、3、4 串行化(Serializable)](#3、3、4 串行化(Serializable))
[3、4 隔离性总结](#3、4 隔离性总结)
[4、1 多版本并发控制( MVCC )](#4、1 多版本并发控制( MVCC ))
[4、1、1 三个隐藏字段](#4、1、1 三个隐藏字段)
[4、1、2 undo 日志](#4、1、2 undo 日志)
[4、2 模拟MVCC](#4、2 模拟MVCC)
[4、3 当前读 vs 快照读](#4、3 当前读 vs 快照读)
[4、4 Read View](#4、4 Read View)
[4、5 RC 与 RR的本质区别](#4、5 RC 与 RR的本质区别)
🙋♂️ 作者:@Ggggggtm 🙋♂️
👀 专栏:MySQL 👀
💥 标题:MySQL中的事物💥
❣️ 寄语:与其忙着诉苦,不如低头赶路,奋路前行,终将遇到一番好风景 ❣️
一、引入事物
1、1 CURD****不加控制,会有什么问题
我们知道mysqld也是一套网络服务,那么未来就会面临着多个客户端连接。那么多个客户端并发的访问数据库,对数据库进行CURD操作,会不会出现问题呢?
数据一致性问题:在多个并发的操作中,没有数据控制可以导致数据的不一致性。例如,在同时进行插入和删除操作时,可能会导致数据的丢失或错误。
并发冲突问题:如果多个用户同时对同一数据进行修改,没有有效的控制机制可能导致数据冲突。这可能会导致数据的覆盖或产生不一致的结果。
数据完整性问题:没有控制的CURD操作可能导致数据的完整性受到破坏。例如,在删除操作时没有考虑到关联的数据,可能导致外键约束的失败或数据被意外删除。
数据安全性问题:没有控制的CURD操作可能导致数据被非法访问、篡改或损坏。例如,没有对敏感数据进行权限控制,可能导致数据泄露或未经授权的修改。
最简单的一个例子,如下图:
那怎么能够很好的解决上述的问题呢?通过什么能够保证上述数据正确呢?接下来我们看一下MySQL中的事物。
1、2 什么是事务
事务就是一组 DML 语句组成,这些语句在逻辑上存在相关性,这一组 DML 语句要么全部成功,要么全部回滚失败,是一个整体。
我们通常完成一件事情所用到的SQL语句大概率是多条的,那么我们就称完成一件事情所用到多条的SQL语句的集合为一个事务,这多条SQL语句在逻辑上是存在一定的关联的。 MySQL提供一种机制,保证事物要么全部成功执行,要么全部回滚到初始状态 。
正如我们上面所说,一个 MySQL 数据库,可不止你一个事务在运行,同一时刻,甚至有大量的请求被包装成事务, 在向 MySQL 服务器发起事务处理请求。而每条事务至少一条 SQL ,最多很多 SQL , 这样如果大家都访问同样的表数 据,在不加保护的情况,就绝对会出现问题。甚至,因为事务由多条 SQL 构成,那么,也会存在执行到一半出错或者 不想再执行的情况,那么已经执行的怎么办呢? 所有,一个完整的事务,绝对不是简单的 SQL 集。
所以 一个完整的事务,绝对不是简单的 SQL 集合,还需要满足如下四个属性:
- 原子性: 一个事务( transaction )中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节。事务在执行过程中发生错误,会被回滚(Rollback )到事务开始前的状态,就像这个事务从来没有执行过一样。
- 一致性: 在事务开始之前和事务结束以后,数据库的完整性没有被破坏。这表示写入的资料必须完全符合所有的预设规则,这包含资料的精确度、串联性以及后续数据库可以自发性地完成预定的工作。
- 隔离性: 数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。事务隔离分为不同级别,包括读未提交( Read uncommitted )、读提交 ( read committed )、可重复读( repeatable read )和串行化( Serializable )。
- 持久性: 事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。
上面的四个属性简称ACID:
- 原子性(Atomicity,又称不可分割性)。
- 一致性(Consistency)。
- 隔离性(Isolation,又称独立性)。
- 持久性(Durability)。
1、3 为什么会出现事务
MySQL中最开始就有事物的概念吗?实际上并不是的!事务被MySQL编写者设计出来,本质是为了当应用程序访问数据库的时候,事务能够简化我们的编程模型,不需要用户自己去考虑各种各样的潜在错误和并发问题 。
如果MySQL只是单纯的提供数据存储服务,那么用户在访问数据库时就需要自行考虑各种潜在问题,包括网络异常、服务器宕机等。因此事务本质是为了应用服务的,而不是伴随着数据库系统天生就有的。
1、4 查看事务的版本支持
在 MySQL 中只有使用了 Innodb 数据库引擎的数据库或表才支持事务, MyISAM 是不支持事物的。我们可以查看数据库引擎,如下图:
对上述查询结果进行简单解释:
- Engine:存储引擎的名称。
- Support:表示服务器对存储引擎的支持级别,YES表示支持,NO表示不支持,DEFAULT表示数据库默认使用的存储引擎,DISABLED表示支持引擎但已将其禁用。
- Comment:关于该存储引擎的附加信息或备注。
- Transactions:如果该存储引擎支持事务处理,则显示YES;否则,显示NO。
- XA:如果该存储引擎支持XA事务,则显示YES;否则,显示NO。
- Savepoints:如果该存储引擎支持保存点(Savepoints),则显示YES;否则,显示NO。
1、5 事务提交方式
事务的提交方式常见的有两种: 自动提交 、手动提交。
自动提交(Auto-commit):这是MySQL默认的提交方式。在自动提交模式下,每个SQL语句都被视为一个单独的事务,并在执行完毕后立即提交。这意味着如果你不明确使用事务控制语句(如BEGIN、COMMIT或ROLLBACK),每个语句都会自动提交。可以通过设置autocommit参数来开启或关闭自动提交模式。
手动提交(Manual commit):手动提交方式需要显示地使用事务控制语句来开始、提交或回滚一个事务。手动提交的好处是你可以控制事务的范围和处理错误情况。
后续会对上述两种提交方式进行详细解释。现在我们先来学习一下查看事务提交方式。通过show命令查看autocommit全局变量,可以查看事务的自动提交是否被打开。如下图:
通过上图可以看到,默认情况下自动提交是被打开的,图中的 'ON' 即为打开的意思。我们也可以通过set命令设置autocommit全局变量的值,可以打开或关闭事务的自动提交。具体如下图:
通过上图我们也可发现: 将autocommit的值设置为1表示打开自动提交,设置为0表示关闭自动提交,也就是将事务提交方式设置为手动提交。
二、事物常见的操作
在做实验之间,我们先把隔离级别设置成读未提交,方便我们观察实验现象。如下图:
但是我们设置了全局的事物隔离级别后,并没有影响我们的当前会话的隔离级别。如下图:
这是需要我们重新登陆一下mysql客户端,后续会讲解原因。如下图:
然后我们再创建一个测试表,SQL语句如下:
sqlcreate table if not exists account( id int primary key, name varchar(50) not null default '', blance decimal(10,2) not null default 0.0 )ENGINE=InnoDB DEFAULT CHARSET=UTF8;
具体如下图:
实验一:事物的开始与回滚在MySQL中,开始一个事务可以用start transaction,也可以直接而是用begin 。具体如下图:
又如下图:
现在我们同时开启两个终端都分别登录同一个mysql用户进行并发操作,如下图:
上图的中 savepoint 就是设置了一个保存点s1。savepoint是一种用于创建和管理事务中保存点的机制。它允许您在事务执行期间将事务的状态保存为一个特定点,以便在发生错误或需要回滚时能够回到该点。
以下是使用savepoint的一般流程:
- 开始事务:使用start transaction或begin命令开始一个新的事务。
- 创建savepoint:使用savepoint命令创建一个savepoint,指定一个唯一的标识符来标记它,例如savepoint s1。
- 执行一些操作:在事务中执行一系列操作,可能会修改数据库的状态。
- 回滚到savepoint:如果在执行操作期间发生了错误或需要回滚到先前的状态,您可以使用rollback to命令将事务回滚到指定的savepoint,例如rollback to s1。
- 提交或回滚事务:根据需要,你可以选择在事务的其他部分继续执行操作,然后最终使用commit提交事务,或者使用rollback完全回滚事务。
需要注意的是,savepoint只在当前事务中有效。一旦事务被提交或回滚,保存点将被释放,并且不能再次使用。
我们根据上述对 savepoint 的理解接着往下看,根据实例在进行深入理解。我们现在想表中插入多条数据,并且在设置保存点。如下图:
我们现在需要进行回滚操作,先来观察一下回滚到s3的情况。如下图:
通过上图我们能够发现,当我们回滚到s3时,我们之前插入的数据就不见了。我们现在直接使用rollback,观察一下效果。如下图:
当我们直接使用rollback时,相当于回滚到事物的最开始,这也就意味着本次事物已经结束了 。其次,假如我们并不想回滚事物,而是想正常的提交我们所修改的数据,可以使用 commit。commit是用来提交事务,提交事务后就不能回滚了。如下图:
实验二:证明未commit,客户端崩溃,MySQL自动会回滚。首先,我们的隔离级别依然是读未提交,然后是自动提交。如下图:
然后我们同时运行两个事物进行观察,一个事务用来插入数据然后客户端崩溃,另一个事务进行观察表中的数据。如下图:
现在我们只需要让一个事务插入数据,另一个事务进行观察,如下图:
假如插入数据的一个事物并没有进行commit提交数据,而是直接异常中断了或者退出了,会出现什么情况呢?如下图:
注意,begin是开始一个事务,commit是提交一个事物,或者rollback完全回滚事务,只有当把事物进行commit提交或者rollback完全回滚事务时,这个事物才算结束。
通过上述实验我们能够看出,事务在提交之前因为某些原因异常终止或者断开连接,那么事务会自动回滚到最开始,这时如上图的右边终端所示,再次查看就看不到之前插入的记录了。
实验三:证明commit了,客户端崩溃,MySQL数据不会在受影响,已经持久化。我们同样开启两个事物,一个负责插入数据,另一个负责查询数据,如下图:
现在需要的是插入数据后,把事物进行commit提交。然后mysql异常终止或者直接退出,再次观察数据是否插入。如下图:
通过上图我们能够发现,事务在提交后与mysql断开连接,这时仍然可以看到之前插入的数据,因为事务提交后数据就被持久化了。
实验四:证明begin操作会自动更改提交方式,不会受MySQL是否自动提交影响。我们在实验二中,是打开的自动提交。我们在做一次实验二,只不过是现在把自动提交进行关闭。如下图:
然后我们再次观察两种实验结果是否相同。先插入两条数据,观察一下。如下图:
插入数据的终端直接退出MySQL,再次观察实验数据,如下图:
通过上图,我们也能够看到实验四的结果与实验二的结果完全相同。这里就证明了自动提交的设置与begin开启的事物并没有任何关系 。注意:我们可以认为begin开启的事物就是手动提交方式,与自动提交并无任何关系。自动提交的方式与单SQL语句有关。我们接着往下看。
实验五:证明单条 SQL 与事务的关系。现在我们依然开启两个终端,但并不使用begin开启事物。然后我们把自动提交方式进行关闭。如下图:
然后我们进行插入两条数据观察一下,如下图:
可以看到数据是插入到表中了。假如插入数据的终端退出mysql数据还在吗?如下图:
我们惊奇的发现,当插入数据的终端退出mysql时,数据竟然不在了。此时我们并没有开启事物,为什么依然进行了回滚操作呢?原因就是单条SQL语句也会被封装成事物。并且我们把自动提交设置成了关闭状态。那么这是需要我们手动commit,数据才会持久化。如下图:
现在我们手动commit,然后再退出mysql,再次观察数据是否被持久化。如下图:
实验六:验证自动提交打开状态,会自动提交单SQL语句事物。当我们把自动提交打开时,然后插入数据先观察一下:
此时数据是已经写入到表中了。我们已经知道当自动提交关闭时,又因为单SQL会被封装成事物,此时插入数据的终端退出mysql后,数据会自动回滚。但是自动提交现在已经打开,我们再观察一下,如下图:
我们发现数据依然是提交了,并没有进行回滚。原因很简单,autocommit自动提交是打开的,单SQL事务执行后自动就被提交了。
针对上述实验,我们来总结一下:
- 只要输入begin或者start transaction,事务便必须要通过commit提交(通过commit才能提交,不就是手动提交吗!),才会持久化,与是否设置set autocommit无关。
- 事务可以手动回滚,同时当操作异常且没有commit之前,MySQL会自动回滚。
- 对于 InnoDB 每一条 SQL 语言都默认封装成事务,且默认是自动提交。与是否设置set autocommit有关。
- 从上面的实验中,我们能看到事务本身的原子性(回滚),持久性(commit)。
- 如果没有设置保存点,也可以回滚,只能回滚到事务的开始。直接使用 rollback(前提是事务还没有提交)。如果设置保存点,可以使用 rollback to 选择回退到哪个保存点。
- 如果一个事务被提交了(commit),则不可以回退(rollback) 。
- InnoDB 支持事务, MyISAM 不支持事务
- 开始事务可以使 start transaction 或者 begin ,结束事物可使用 rollback 或者 commit。
三、事物的隔离级别
3、1 如何理解隔离性
- MySQL服务可能会同时被多个客户端进程(线程)访问,访问的方式以事务方式进行。
- 一个事务可能由多条SQL构成,也就意味着,任何一个事务,都有执行前,执行中,执行后的阶段。而所谓的原子性,其实就是让用户层,要么看到执行前,要么看到执行后。执行中出现问题,可以随时回滚。所以单个事务,对用户表现出来的特性,就是原子性。
- 毕竟所有事务都要有个执行过程,那么在多个事务各自执行多个 SQL 的时候,就还是有可能会出现互相影响的情况。比如:多个事务同时访问同一张表,甚至同一行数据。
- 数据库中,为了保证事务执行过程中尽量不受干扰,事物就有了一个重要特征:隔离性。所谓事务隔离性是指多个并发事务执行时,为了各事务之间的相互影响程度尽量减小,就将并发执行的事务进行了隔离。
- 而不同的隔离程度会造成不同的效果。在 数据库中允许事务受不同程度的干扰,事物就有了一种重要特征:隔离级别。
MySQL提供了四个事务隔离级别,分别为读未提交(Read Uncommitted)、读提交(Read Committed)、可重复读(Repeatable Read)和串行化(Serializable)。
- 读未提交(Read Uncommitted): 在该隔离级别下,所有的事务都可以看到其他事务没有提交的执行结果,实际生产中不可能使用这种隔离级别,因为这种隔离级别相当于没有任何隔离性,会存在很多并发问题,如脏读、幻读、不可重复读等。
- 读提交(Read Committed): 该隔离级别是大多数数据库的默认隔离级别,但它不是MySQL默认的隔离级别,它满足了隔离的简单定义:一个事务只能看到其他已经提交的事务所做的改变,但这种隔离级别存在不可重复读和幻读的问题。
- 可重复读(Repeatable Read): 这是MySQL默认的隔离级别,该隔离级别确保同一个事务在执行过程中,多次读取操作数据时会看到同样的数据,即解决了不可重复读的问题,但这种隔离级别下仍然存在幻读的问题。
- 串行化(Serializable): 这是事务的最高隔离级别,该隔离级别通过强制事务排序,使之不可能相互冲突,从而解决了幻读问题。它在每个读的数据行上面加上共享锁,但是可能会导致超时和锁竞争问题,这种隔离级别太极端,实际生成中基本不使用。
3、2 查看与设置隔离性
查看全局隔离级别:select @@global.tx_isolation。如下图:
查看当前会话的隔离级别:select @@session.tx_isolation。具体如下图:
另外,还可以使用:select @@tx_isolation 来查询当前会话的隔离级别。如下图:
全局的隔离界别和当前会话的隔离级被之间有什么区别呢?注意:全局隔离级别 初始化 当前会话的隔离级别,也就是每次启动mysql客户端时,当前会话的隔离级别都是与全局的隔离级别相同的。
接下来我们看一下怎么去设置隔离级别。设置当前会话 or 全局隔离级别语法如下:
sqlSET [SESSION | GLOBAL] TRANSACTION ISOLATION LEVEL {READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ | SERIALIZABLE}
我们来看一个实例,如下:
如上图,我们是设置当前会话的隔离级被。那么会影响全局的隔离级别吗?实际上是并不影响的。我们再看如下图:
需要注意的是:设置全局隔离级别会影响后续的新会话,但当前会话的隔离级别没有发生变化,如果要让当前会话的隔离级别也改变,则需要重启会话。
下面我们就来看看各个隔离级别到底是什么,它们之间又有什么区别呢?
3、3 隔离级别详解
3、3、1读未提交(Read Uncommitted)
我们先启动两个终端,然后把隔离级别均设置为读未提交。如下图:
在前面我们也了解到了在读未提交隔离级别下,所有的事务都可以看到其他事务没有提交的执行结果。那我们现在并发的去执行两个事物,观察一下结果。如下图:
现在我们在左边的事务中对account表进行修改,如下图:
通过上图我们发现,当左边的事物修改后但并未提交,右边的事物就能够查看到修改的数据了。但是当左边的事物最终并没有commit提交,而是直接退出mysql了。那么右边的事物读到的数据还会变吗?如下图:
通过上图我们发现,右边的事物读到的数据与最开始未修改的数据一样。左边事物相当于回滚了。
我们发现,读未提交的隔离性意思就是能够读取到其他事务没有提交的数据。这样几乎就没有隔离性。同时一个事务在执行过程中,读取到另一个执行中的事务所做的修改,但是该事务还没有进行提交,这种现象叫做脏读。
脏读合理吗?我们站在事物的角度去思考一下:事物是具有原子性的,即使每个事物都是有执行的过程,但是相对于其他事物来说是原子的。既然都是原子的了,那么两个并发的事物能够读取到另一个事物还未提交的数据显然就不合理了。
3、3、2 读提交(Read Committed)
我们先把全局的隔离级别设置成都提交。然后重启一下两个mysql客户端,那么当前会话的隔离级别就与全局的隔离界别相同了。如下图:
我们再重启一下mysql客户端,如下图:
读提交的隔离级别与读未提交的隔离级别区别:读提交只能读到其他事物提交后的数据,读未提交能够读到其他事务还未提交的数据。
接下来我们来观察一下。首先启动两个事物,如下图:
现在我们在左边的事务中插入数据,再从右边的事物中观察一下,如下图:
从上图中我们能够看到,当左边的事物插入数据并未提交时,右边的事物并不能查看到还未提交的数据。我们再来观察一下左边事物提交后的数据,如下图:
当左边的事物进行提交后,右边的事物就可以查看出左边事物已经提交的数据。但是右边的事物在同样的select语句中查出了不同的结果,我么们可以理解为该事务中两个select语句之间 时间 间隔很短,也并未做其他任何操作,但是读出的结果却不相同,这是一个问题吗?实际上确实是一个问题。我们也称该现象为不可重复读。
3、3、3 可重复读(Repeatable Read)
这里我们就不再过多解释如何设置隔离级别了。直接看如下图:
现在两个终端的mysql都是可重复读隔离级别。可重复读的隔离级别是确保同一个事务在执行过程中,多次读取操作数据时会看到同样的数据,也就是无论其他事务是否提交,该事务都读取不到其他事务的数据。下面我们来做实验验证一下。
老样子,同时开启两个事物。如下图:
现在我们在左边的事物中进行插入数据操作,观察一下。如下图:
如上图所示,并不能读取到其他事务未提交的数据。下面我们将左边的事物进行commit提交,再来观察一下。如下图:
综上:可重复读的隔离性是读取不到其他事物的任何数据的,无论是未提交还是已经提交的数据。那么数据到底插入到表中没?如下图:
综上总结一下:
- 在可重复读隔离级别下,一个事务在执行过程中,相同的select查询得到的是相同的数据,这就是所谓的可重复读。
- 一般的数据库在可重复读隔离级别下,update数据是满足可重复读的,但insert数据会存在幻读问题,因为隔离性是通过对数据加锁完成的,而新插入的数据原本是不存在的,因此一般的加锁无法屏蔽这类问题。
- 一个事务在执行过程中,相同的select查询得到了新的数据,如同出现了幻觉,这种现象叫做幻读。
- 很明显, MySQL 在 RR 级别的时候,是解决了幻读问题的 ( 解决的方式是用 Next-Key锁 (GAP+ 行锁 ) 解决的 ) 。
3、3、4 串行化(Serializable)
串行化隔离级别是对所有操作全部加锁,进行串行化,不会有问题,但是只要串行化,效率很低,几乎完全不会被采。通俗的理解就是将所有的事物进行的串行执行。下面我们来观察一下。
接下来我们同开启两个事物,如下图:
如果这两个事务都对表进行的是读操作,那么这两个事务可以并发执行,不会被阻塞。那么要是其中一个事物进行修改操作呢?如下图:
我们发现这个事物会立即被阻塞。当另一个事务commit提交后,那么阻塞的事物就会立刻执行。如下图:
串行化是事务的最高隔离级别,多个事务同时进行读操作时加的是共享锁,因此可以并发执行读操作,但一旦需要进行写操作,就会进行串行化,效率很低,几乎不会使用。也可结合下图理解:
3、4 隔离性总结
我们先对隔离级别进行总结:
- 其中隔离级别越严格,安全性越高,但数据库的并发性能也就越低,往往需要在两者之间找一个平衡点。
- 不可重复读的重点是修改和删除:同样的条件, 你读取过的数据,再次读取出来发现值不一样了。幻读的重点在于新增(insert):同样的条件, 第1次和第2次读出来的记录数不一样。
- 说明: mysql 默认的隔离级别是可重复读,一般情况下不要修改。
- 上面的例子可以看出,事务也有长短事务这样的概念。事务间互相影响,指的是事务在并行执行的时候,即都没有commit的时候,影响会比较大。
MySQL在操作上并没有过多的去维护一致性。那么怎么保证一致性呢?就是通过事物的原子性、隔离性和持久性来保证的。关于一致性的问题:
- 事务执行的结果,必须使数据库从一个一致性状态,变到另一个一致性状态,当数据库只包含事务成功提交的结果时,数据库就处于一致性状态。
- 如果系统运行发生中断,某个事务尚未完成而被迫中断,而改未完成的事务对数据库所做的修改已被写入数据库,此时数据库就处于一种不正确(不一致)的状态。因此一致性是通过原子性来保证的。
- 多个事务同时访问同一份数据时,必须保证这多个事务在并发执行时,不会因为由于交叉执行而导致数据的不一致,即一致性需要隔离性来保证。
- 事务处理结束后,对数据的修改必须是永久的,即便系统故障也不能丢失,即一致性需要持久性来保证。
- 其实一致性和用户的业务逻辑强相关,一般MySQL提供技术支持,但是一致性还是要用户业务逻辑做支撑,也就是一致性也由用户决定的。
- 而技术上,通过AID保证C。
四、再次深入理解隔离性
数据库并发的场景有三种:
- 读 - 读 :不存在任何问题,也不需要并发控制;
- 读 - 写 :有线程安全问题,可能会造成事务隔离性问题,可能遇到脏读,幻读,不可重复读;
- 写 - 写 :有线程安全问题,可能会存在更新丢失问题。比如第一类更新丢失,第二类更新丢失。
读-读并发不需要进行并发控制,写-写并发肯定需要加锁进行串行化控制的。实际上在数据库中,读-写并发是最高频的场景。我们接下来就重点来探究一下读写并发的场景,看看是如何做到多事物的情况下做到高并发的!
4、1 多版本并发控制( MVCC )
多版本并发控制( MVCC )是一种用来解决 读- 写冲突 的无锁并发控制。 怎么做到的呢?主要是依赖记录中的隐藏字段、undo日志和Read View。接下来我们先来看一下三个隐藏字段。
4、1、1 三个隐藏字段
数据库表中的每条记录除了会记录我们自己所创建的字段外,还都会有如下3个隐藏字段:
DB_TRX_ID
:6字节,创建或最近一次修改该记录的事务ID,记录创建这条记录/最后一次修改该记录的事务 ID。DB_ROW_ID
:6字节,隐含的自增ID(隐藏主键),如果数据表没有主键,InnoDB 会自动以DB_ROW_ID
产生一个聚簇索引。DB_ROLL_PTR
:7字节,回滚指针,指向这条记录的上一个版本。- 补充:实际还有一个删除 flag 隐藏字段 , 既记录被更新或删除并不代表真的删除,而是删除 flag 变了。
下面我们来举个例子解释一下。假设测试表结构SQL语句如下:
sqlcreate table if not exists student( name varchar(11) not null, age int not null );
创建好后如下图:
当我们要插入一条记录时,如下图:
实际上该纪录中不仅仅包含 name,age 两个字段,还包含了三个隐藏字段。具体如下图:
说明一下:
DB_ROW_ID
是数据库默认为该行记录生成的唯一隐式主键。- 每个事务都会有自己的事物ID,根据事物的先后顺序会给事物分配事物ID。
DB_TRX_ID
是当前操作该记录的事务 ID,上述我们假设是事物1插入的该条记录,所以DB_TRX_ID为1。
- 一般情况下第一题记录的回滚指针为null,因为没有历史版本。如果回滚指针不为null,那么就是指向的上一条记录。
4、1、2 undo 日志
我们知道MySQL服务器在启动的时候会预先申请一块内存空间来进行各种缓存,这块内存空间叫做Buffer Pool,后续磁盘中加载的数据就会保存在Buffer Pool中。其中undo long 就是在Buffer Pool 中进行存储的。具体如下图:
所以我们这里理解undo log,简单理解成就是 MySQL 中的一段内存缓冲区,用来保存日志数据的就行。
4、2 模拟MVCC
现在有一个事务ID为10的事务,要将刚才插入学生表中的记录的学生姓名改为"李四":
- 因为是要进行写操作,所以需要先给该记录加行锁。
- 修改前,先将该行记录拷贝到undo log中,此时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中形成了一个基于链表记录的历史版本链。所谓的回滚,无非就是用历史数据,覆盖当前数据。
- 在undo log中的一个个的历史版本就称为一个个的快照。
这里有一些思考问题:
- 上面是以更新( `upadte` )主讲的, 如果是 `delete` 呢?不要忘了还有一个隐藏字段flag,删数据不是清空,而是设置 flag 为删除即可。也可以形成版本。
- 如果是 `insert` 呢?因为 `insert` 是插入,也就是之前没有数据,那么 `insert` 也就没有历史版本。但是一般为了回滚操作,insert 的数据也是要被放入 undo log 中,如果当前事务 commit 了,那么这个 undo log 的历史 insert记录就可以被清空了。
- 对于新插入的记录来说,没有其他事务会访问它的历史版本,因此新插入的记录在提交后就可以将undo log中的版本链清除了 。
- 删改的undo log版本链什么时候清空呢?
- 在undo log中形成的版本链不仅仅是为了进行回滚操作,其他事务在执行过程中也可能读取版本链中的某个版本,也就是快照读。
- 因此,只有当某条记录的最新版本已经修改并提交,并且此时没有其他事务与该记录的历史版本有关了,这时该记录在undo log中的版本链才可以被清除。
4、3 当前读 vs 快照读
我们再来理解一下MVCC多版本并发控制。MVCC 多版本并发控制是 在undo log中维持一个数据的多个版本,使得读写操作没有冲突 的概念,为什么读写没有冲突呢?其实很简单,就是读数据读取的是历史版本,写数据都是在写的当前最新版本。读写都不是对同一份数据,所以就可以很好的支持无锁并发控制了。下文还会详解读历史版本和写当前版本。接下来我们先来看一下当前读和快照读的概念。
- 当前读:就是读取记录的最新版本。增删改,都叫做当前读。select也有可能当前读,比如:select lock in share mode(共享锁)
- 快照读:读取历史版本(一般而言),就叫做快照读。
我们可以看到,在多个事务同时删改查(写写并发)的时候,都是当前读,是要加锁的。那同时有select过来,如果也要读取最新版(当前读),那么也就需要加锁,这就是串行化。
但如果是快照读,读取历史版本的话,是不受加锁限制的。也就是可以并行执行!换言之,提高了效率,即 MVCC 的意义所在。
那么select读取,是读取最新的版本(当前读)呢?还是读取历史版本(快照读)?如果要读取历史版本,又应该读取那个历史版本呢?这就取决于隔离级别了。
4、4 Read View
Read View就是事务进行****快照读 操作的时候生产的 读视图 (Read View)。 在该事务执行的快照读的那一刻,会生成数据库系统当前的一个快照,记录并维护系统当前活跃事务的ID(当每个事务开启时,都会被分配一个ID, 这个ID是递增的,所以最新的事务,ID值越大)。
Read View 在 MySQL 源码中,就是一个类,本质是用来进行可见性判断的。 即当我们某个事务执行快照读的时候,对该记录创建一个 Read View 读视图,把它比作条件,用来判断当前事务能够看到哪个版本的数据,既可能是当前最新的数据,也有可能是该行记录的 undo log 里面的某个版本的数据。
ReadView类的源码主要部分如下:
cppclass 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:创建视图时的活跃事务ID列表,即在创建该视图时还未提交的事务ID列表。
m_low_limit_id:ReadView生成时刻系统尚未分配的下一个事务ID,也就是目前已出现过的事务ID的最大值+1。表示高水位,所有大于等于此事务ID的事务都不可见。
m_up_limit_id:记录m_ids列表中事务ID最小的ID。表示低水位,小于此事务ID的事务均可见。
m_creator_trx_id:表示创建该Read View的事务ID。每个Read View都会与一个特定的事务关联。
其实现在我们根据 m_low_limit_id 和 m_up_limit_id 将所有的事物划分为三个部分,如下图:
- 小于m_up_limit_id的事物均为已经提交的事物,那么与现在正在执行的事物并无任何关联,相当于他们就是先后串行执行的(实际上他们并不一定串行执行),所以当前执行的事物可以看到小于m_up_limit_id的事物。
- 大于m_low_limit_id的事物都是在形成快照之后才形成的事物,因为事物具有原子性,我们不应该看到大于m_low_limit_id的事物。
- 位于m_up_limit_id和m_low_limit_id之间的事务,在生成Read View时可能正处于活跃状态,也可能该事务已经提交了,这时需要通过判断事务ID是否存在于m_ids中来判断该事务是否已经提交。如果在则证明活跃,如果不在则证明已经提交。对于活跃的事物,当前事务不应该看到其他活跃的事物。如果已经提交了,那么证明也是在形成快照前提交的,可以看到该事务的数据。
我们在实际读取数据版本链的时候,是能读取到每一个版本对应的事务ID的,即:当前记录的 DB_TRX_ID 。那么,我们现在手里面有的东西就有,当前快照读的 ReadView 和 版本链中的某一个记录的 DB_TRX_ID 。所以现在的问题就是,当前快照读,到底应不应该读到当前版本记录。那么下图就会有一个很好的解释:
上述思路对应源码策略:
cppbool 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,判断当前事务能否看到这个版本。
4、5 RC 与 RR的本质区别
接下来先来做一个实验。 先准备一张实验表,SQL语句如下:
sqlcreate table if not exists account( id int primary key, name varchar(50) not null default '', blance decimal(10,2) not null default 0.0 )ENGINE=InnoDB DEFAULT CHARSET=UTF8;
实验一:然后把全局的隔离级别设置成可重复读,这里就不再演示,上文中均有演示讲解。然后我们同时开启两个事物,在一个事物中先查询一下表的记录,如下图:
现在在左边的事务中进行对数据更新,我们在右边的事物中再次查询表中的记录时,是看不到其他事务修改的数据的。如下图:
当我们把左边的事务进行提交后,然后再在右边的事物中进行当前读,我们再观察一下现象。如下图:
从上图也能说明两点:
- 默认的 select 都是快照读;
- 当前读可以读到最新的数据,也就是当前修该后的数据。
事务A 事务B 开启事务 开启事务 快照读(无影响) 快照读 更新数据 提交事务 select 快照读
select lock in share mode 当前读
实验二:现在我们再更改一下SQL的执行顺序,在两个终端各自启动一个事务后,直接让左边事务对表中的信息进行修改并提交,然后再让右边的事务进行查看,这时右终端中的事务就直接看到了修改后的数据。如下:
我们再在右边的终端进行当前读,观察一下。如下图:
事务A 事务B 开启事务 开启事务 快照读(无影响) 更新数据 提交事务 select 快照读
select lock in share mode 当前读
同时启动的两个事物,在可重复读的隔离级别下,为什么还能够读到其他事务已经提交的数据呢?而且与我们刚刚做的两个实验结果为什么又有所区别呢?
首先,第二个实验即使右边的事物已经读到了左边事物已经提交的数据,但是右边的事物仍然是一种可重复读的状态,也就是在多次读取数据的情况下并不会出现问题。
其次,相信学完Read View后,大家可能已经知道原因出自于哪里了。两次实验,唯一不同的地方就是第一次进行快照读的时间不同。实验一的右边事物在最开始就进行了快照读,实验二右边的事物在更新完后再进行的快照读。因此两个实验形成的Read View时间就有所不同,这就导致了实验结果不相同!
RR与RC****的本质区别
- 正是Read View生成时机的不同,从而造成RC,RR级别下快照读的结果的不同。
- 在RR级别下的某个事务的对某条记录的第一次快照读会创建一个快照及Read View, 将当前系统活跃的其他事务记录起来。此后在调用快照读的时候,还是使用的是同一个Read View,所以只要当前事务在其他事务提交更新之前使用过快照读,那么之后的快照读使用的都是同一个Read View,所以对之后的修改不可见。
- 即 RR 级别下,快照读生成 Read View 时, Read View 会记录此时所有其他活动事务的快照,这些事务的修改对于当前事务都是不可见的。而早于Read View 创建的事务所做的修改均是可见。
- 而在 RC 级别下的,事务中每次快照读都会新生成一个快照和 Read View, 这就是我们在 RC 级别下的事务中可以看到别的事务提交的更新的原因。
- 总之在 RC 隔离级别下,是每个快照读都会生成并获取最新的 Read View ;而在 RR 隔离级别下,则是同一个事务中的第一个快照读才会创建Read View, 之后的快照读获取的都是同一个 Read View。
- 正是 RC 每次快照读,都会形成 Read View ,所以, RC 才会有不可重复读问题。
- 在可重复读的隔离级被下,事务中快照读的结果是非常依赖该事务首次出现快照读的地方,即某个事务中首次出现快照读,决定该事务后续快照读结果的能力。
现在就可以很清楚的知道select到底应该读取那个版本 了,完全根据Read View和版本链表中的DB_TRX_ID字段。
其次为什么要有隔离级别呢?事务都是原子的。所以无论如何,事务总有先有后。 但是经过上面的操作我们发现,事务从 begin->CURD->commit ,是有一个阶段的。也就是事务有执行前,执行中,执行后的阶段。但不管怎么启动多个事务,总是有先有后的。 那么多个事务在执行中, CURD 操作是会交织在一起的。那么,为了保证事务的 " 有先有后 " ,是不是应该让不同的事务看到它该 看到的内容,这就是所谓的隔离性与隔离级别要解决的问题。