【7. 事务的底层原理和 MVCC】

事务的底层原理和 MVCC

​ 在事务的实现机制上,MySQL 采用的是 WAL(Write-ahead logging,预写式日志)机制来实现的。

​ 在使用 WAL 的系统中,所有的修改都先被写入到日志中,然后再被应用到 系统中。通常包含 redo 和 undo 两部分信息。

​ 为什么需要使用 WAL,然后包含 redo 和 undo 信息呢?举个例子,如果一 个系统直接将变更应用到系统状态中,那么在机器掉电重启之后系统需要知道操 作是成功了,还是只有部分成功或者是失败了(为了恢复状态)。如果使用了 WAL,那么在重启之后系统可以通过比较日志和系统状态来决定是继续完成操作还是撤销操作。

​ redo log 称为重做日志,每当有操作时,在数据变更之前将操作写入 redo log, 这样当发生掉电之类的情况时系统可以在重启后继续操作。

​ undo log 称为撤销日志(撤销(回滚)是事务的特性,目的是保证原子性,数据的完整性),当一些变更执行到一半无法完成时,可以根据撤销 日志恢复到变更之间的状态。

​ MySQL 中用 redo log 来在系统 Crash 重启之类的情况时修复数据(事务的持 久性),而 undo log 来保证事务的原子性。

redo 日志

redo 日志的作用

​ InnoDB 存储引擎是以页为单位来管理存储空间的,我们进行的增删改查操 作其实本质上都是在访问页面(包括读页面、写页面、创建新页面等操作)。在 Buffer Pool 的时候说过,在真正访问页面之前,需要把在磁盘上的页缓存到内存 中的 Buffer Pool 之后才可以访问。但是在事务的时候又强调过一个称之为持久性 的特性,就是说对于一个已经提交的事务,在事务提交后即使系统发生了崩溃, 这个事务对数据库中所做的更改也不能丢失。

​ 如果我们只在内存的 Buffer Pool 中修改了页面,假设在事务提交后突然发生 了某个故障,导致内存中的数据都失效了,那么这个已经提交了的事务对数据库 中所做的更改也就跟着丢失了,这是我们所不能忍受的。那么如何保证这个持久 性呢?一个很简单的做法就是在事务提交完成之前把该事务所修改的所有页面 都刷新到磁盘,但是这个简单粗暴的做法有些问题:

刷新一个完整的数据页太浪费了

​ 有时候我们仅仅修改了某个页面中的一个字节,但是我们知道在 InnoDB 中 是以页为单位来进行磁盘 IO 的,也就是说我们在该事务提交时不得不将一个完 整的页面从内存中刷新到磁盘,我们又知道一个页面默认是 16KB 大小,只修改 一个字节就要刷新 16KB 的数据到磁盘上显然是太浪费了。

随机 IO 刷起来比较慢

​ 一个事务可能包含很多语句,即使是一条语句也可能修改许多页面,该事务 修改的这些页面可能并不相邻,这就意味着在将某个事务修改的 Buffer Pool 中的 页面刷新到磁盘时,需要进行很多的随机 IO,随机 IO 比顺序 IO 要慢,尤其对于 传统的机械硬盘来说。

​ 怎么办呢?我们只是想让已经提交了的事务对数据库中数据所做的修改永 久生效,即使后来系统崩溃,在重启后也能把这种修改恢复出来。所以我们其实 没有必要在每次事务提交时就把该事务在内存中修改过的全部页面刷新到磁盘, 只需要把修改了哪些东西记录一下就好,比方说某个事务将系统表空间中的第 100 号页面中偏移量为 1000 处的那个字节的值 1 改成 2 我们只需要记录一下:

将第 0 号表空间的 100 号页面的偏移量为 1000 处的值更新为 2。

​ 这样我们在事务提交时,把上述内容刷新到磁盘中,即使之后系统崩溃了, 重启之后只要按照上述内容所记录的步骤重新更新一下数据页,那么该事务对数 据库中所做的修改又可以被恢复出来,也就意味着满足持久性的要求。因为在系 统崩溃重启时需要按照上述内容所记录的步骤重新更新数据页,所以上述内容也 被称之为重做日志,英文名为 redo log,也可以称之为 redo 日志。与在事务提交 时将所有修改过的内存中的页面刷新到磁盘中相比,只将该事务执行过程中产生 的 redo 日志刷新到磁盘的好处如下:

​ 1、redo 日志占用的空间非常小

​ 存储表空间 ID、页号、偏移量以及需要更新的值所需的存储空间是很小的。

​ 2、redo 日志是顺序写入磁盘的

​ 在执行事务的过程中,每执行一条语句,就可能产生若干条 redo 日志,这 些日志是按照产生的顺序写入磁盘的,也就是使用顺序 IO。

redo 日志格式

​ 通过上边的内容我们知道,redo 日志本质上只是记录了一下事务对数据库 做了哪些修改。 InnoDB 们针对事务对数据库的不同修改场景定义了多种类型的 redo 日志,但是绝大部分类型的 redo 日志都有下边这种通用的结构:

在这里插入图片描述

各个部分的详细释义如下:

​ type:该条 redo 日志的类型,redo 日志设计大约有 53 种不同的类型日志。

​ space ID:表空间 ID。

​ page number:页号。

​ data:该条 redo 日志的具体内容。

简单的 redo 日志类型

​ 我们用一个简单的例子来说明最基本的 redo 日志类型。我们前边介绍 InnoDB 的记录行格式的时候说过,如果我们没有为某个表显式的定义主键,并 且表中也没有定义 Unique 键,那么 InnoDB 会自动的为表添加一个称之为 row_id 的隐藏列作为主键。为这个 row_id 隐藏列赋值的方式如下:

​ 服务器会在内存中维护一个全局变量,每当向某个包含隐藏的 row_id 列的 表中插入一条记录时,就会把该变量的值当作新记录的 row_id 列的值,并且把 该变量自增 1。(MAX Row ID 是表可能使用的主键)

​ 每当这个变量的值为 256 的倍数时,就会将该变量的值刷新到系统表空间的 页号为 7 的页面中一个称之为 Max Row ID 的属性处。

当系统启动时,会将上边提到的 Max Row ID 属性加载到内存中,将该值加 上 256 之后赋值给我们前边提到的全局变量

这个 Max Row ID 属性占用的存储空间是 8 个字节,当某个事务向某个包含 row_id 隐藏列的表插入一条记录,并且为该记录分配的 row_id 值为 256 的倍数 时,就会向系统表空间页号为 7 的页面的相应偏移量处写入 8 个字节的值。但是 我们要知道,这个写入实际上是在 Buffer Pool 中完成的,我们需要为这个页面的 修改记录一条 redo 日志,以便在系统崩溃后能将已经提交的该事务对该页面所 做的修改恢复出来。这种情况下对页面的修改是极其简单的,redo 日志中只需 要记录一下在某个页面的某个偏移量处修改了几个字节的值,具体被修改的内容 是啥就好了,InnoDB 把这种极其简单的 redo 日志称之为物理日志,并且根据在 页面中写入数据的多少划分了几种不同的 redo 日志类型:

MLOG_1BYTE(type 字段对应的十进制数字为 1):表示在页面的某个偏移 量处写入 1 个字节的 redo 日志类型。

MLOG_2BYTE(type 字段对应的十进制数字为 2):表示在页面的某个偏移 量处写入 2 个字节的 redo 日志类型。

MLOG_4BYTE(type 字段对应的十进制数字为 4):表示在页面的某个偏移 量处写入 4 个字节的 redo 日志类型。

MLOG_8BYTE(type 字段对应的十进制数字为 8):表示在页面的某个偏移 量处写入 8 个字节的 redo 日志类型。

MLOG_WRITE_STRING(type 字段对应的十进制数字为 30):表示在页面的 某个偏移量处写入一串数据。

​ 我们上边提到的 Max Row ID 属性实际占用 8 个字节的存储空间,所以在修 改页面中的该属性时,会记录一条类型为MLOG_8BYTE的redo日志,MLOG_8BYTE 的 redo 日志结构如下所示:

​ offset 代表在页面中的偏移量。

在这里插入图片描述

​ 其余 MLOG_1BYTE、MLOG_2BYTE、MLOG_4BYTE 类型的 redo 日志结构和 MLOG_8BYTE 的类似,只不过具体数据中包含对应个字节的数据罢了。 MLOG_WRITE_STRING 类型的 redo 日志表示写入一串数据,但是因为不能确定写 入的具体数据占用多少字节,所以需要在日志结构中还会多一个 len 字段。

复杂一些的 redo 日志类型

​ 有时候执行一条语句会修改非常多的页面,包括系统数据页面和用户数据页 面(用户数据指的就是聚簇索引和二级索引对应的 B+树)。以一条 INSERT 语句 为例,它除了要向 B+树的页面中插入数据,也可能更新系统数据 Max Row ID 的 值,不过对于我们用户来说,平时更关心的是语句对 B+树所做更新:

​ 表中包含多少个索引,一条 INSERT 语句就可能更新多少棵 B+树。

​ 针对某一棵 B+树来说,既可能更新叶子节点页面,也可能更新非叶子节点 页面,也可能创建新的页面(在该记录插入的叶子节点的剩余空间比较少,不足 以存放该记录时,会进行页面的分裂,在非叶子节点页面中添加目录项记录)。

​ 在语句执行过程中,INSERT 语句对所有页面的修改都得保存到 redo 日志中 去。实现起来是非常麻烦的,比方说将记录插入到聚簇索引中时,如果定位到的 叶子节点的剩余空间足够存储该记录时,那么只更新该叶子节点页面就好,那么 只记录一条 MLOG_WRITE_STRING 类型的 redo 日志,表明在页面的某个偏移量 处增加了哪些数据就好了么?

​ 别忘了一个数据页中除了存储实际的记录之后,还有什么 File Header、Page Header、Page Directory 等等部分,所以每往叶子节点代表的数据页里插入一条 记录时,还有其他很多地方会跟着更新,比如说:

​ 可能更新 Page Directory 中的槽信息、Page Header 中的各种页面统计信息, 比如槽数量可能会更改,还未使用的空间最小地址可能会更改,本页面中的记录 数量可能会更改,各种信息都可能会被修改,同时数据页里的记录是按照索引列从小到大的顺序组成一个单向链表的,每插入一条记录,还需要更新上一条记录 的记录头信息中的 next_record 属性来维护这个单向链表。

​ 画一个简易的示意图就像是这样:

在这里插入图片描述

​ 其实说到底,**把一条记录插入到一个页面时需要更改的地方非常多。**这时我们如 果使用上边介绍的简单的物理 redo 日志来记录这些修改时,可以有两种解决方 案:

​ 方案一:在每个修改的地方都记录一条 redo 日志。

​ 也就是如上图所示,有多少个加粗的块,就写多少条物理 redo 日志。这样 子记录 redo 日志的缺点是显而易见的,因为被修改的地方是在太多了,可能记 录的 redo 日志占用的空间都比整个页面占用的空间都多了。

​ 方案二:将整个页面的第一个被修改的字节到最后一个修改的字节之间所有 的数据当成是一条物理 redo 日志中的具体数据。

​ 从图中也可以看出来,第一个被修改的字节到最后一个修改的字节之间仍然 有许多没有修改过的数据,我们把这些没有修改的数据也加入到 redo 日志中去 依然很浪费。

​ 正因为上述两种使用物理 redo 日志的方式来记录某个页面中做了哪些修改 比较浪费,InnoDB 中就有非常多的 redo 日志类型来做记录。

​ 这些类型的 redo 日志既包含物理层面的意思,也包含逻辑层面的意思,具 体指:

​ 物理层面看,这些日志都指明了对哪个表空间的哪个页进行了修改。

​ 逻辑层面看,在系统崩溃重启时,并不能直接根据这些日志里的记载,将页 面内的某个偏移量处恢复成某个数据,而是需要调用一些事先准备好的函数,执 行完这些函数后才可以将页面恢复成系统崩溃前的样子。

​ 简单来说,一个 redo 日志类型而只是把在本页面中变动(比如插入、修改) 一条记录所有必备的要素记了下来,之后系统崩溃重启时,服务器会调用相关向 某个页面变动(比如插入、修改)一条记录的那个函数,而 redo 日志中的那些 数据就可以被当成是调用这个函数所需的参数,在调用完该函数后,页面中的相 关值也就都被恢复到系统崩溃前的样子了。这就是所谓的逻辑日志的意思。

​ 当然,如果不是为了写一个解析 redo 日志的工具或者自己开发一套 redo 日 志系统的话,那就不需要去研究 InnoDB 中的 redo 日志具体格式。

***redo 日志会把事务在执行过程中对数据库所做的所有修改 都记录下来,在之后系统崩溃重启后可以把事务所做的任何修改都恢复出来。***

Mini-Transaction

以组的形式写入 redo 日志

​ 语句在执行过程中可能修改若干个页面。比如我们前边说的一条 INSERT 语 句可能修改系统表空间页号为 7 的页面的 Max Row ID 属性(当然也可能更新别 的系统页面,只不过我们没有都列举出来而已),还会更新聚簇索引和二级索引 对应 B+树中的页面。由于对这些页面的更改都发生在 Buffer Pool 中,所以在修 改完页面之后,需要记录一下相应的 redo 日志。

​ 在这个执行语句的过程中产生的 redo 日志被 InnoDB 人为的划分成了若干个 不可分割的组,比如:

​ 1、更新 Max Row ID 属性时产生的 redo 日志是不可分割的。

​ 2、向聚簇索引对应 B+树的页面中插入一条记录时产生的 redo 日志是不可分割的。

​ 3、向某个二级索引对应 B+树的页面中插入一条记录时产生的 redo 日志是 不可分割的。

​ 4、还有其他的一些对页面的访问操作时产生的 redo 日志是不可分割的….。

​ 怎么理解这个不可分割的意思呢?我们以向某个索引对应的 B+树插入一条 记录为例,在向 B+树中插入这条记录之前,需要先定位到这条记录应该被插入 到哪个叶子节点代表的数据页中,定位到具体的数据页之后,有两种可能的情况:

​ 情况一:该数据页的剩余的空闲空间充足,足够容纳这一条待插入记录,那 么事情很简单,直接把记录插入到这个数据页中,记录一条 redo 日志就好了, 我们把这种情况称之为乐观插入

​ 情况二:该数据页剩余的空闲空间不足,那么事情就很麻烦了,遇到这种情 况要进行所谓的页分裂操作:

​ 1、新建一个叶子节点;

​ 2、然后把原先数据页中的一部分记录复制到这个新的数据页中;

​ 3、然后再把记录插入进去,把这个叶子节点插入到叶子节点链表中;

​ 4、非叶子节点中添加一条目录项记录指向这个新创建的页面;

​ 5、非叶子节点空间不足,继续分裂。

​ 很显然,这个过程要对多个页面进行修改,也就意味着会产生很多条 redo 日志,我们把这种情况称之为悲观插入

​ 另外,这个过程中,由于需要新申请数据页,还需要改动一些系统页面,比 方说要修改各种段、区的统计信息信息,各种链表的统计信息,也会产生 redo 日志。

​ 当然在乐观插入时也可能产生多条 redo 日志。

InnoDB 认为向某个索引对应的 B+树中插入一条记录的这个过程必须是原子 的,不能说插了一半之后就停止了。比方说在悲观插入过程中,新的页面已经分 配好了,数据也复制过去了,新的记录也插入到页面中了,可是没有向非叶子节 点中插入一条目录项记录,这个插入过程就是不完整的,这样会形成一棵不正确 的 B+树。(正确的B+数多么重要,关系到mysql的稳定性, 可见redo日志的重要性)

​ 我们知道 redo 日志是为了在系统崩溃重启时恢复崩溃前的状态,如果在悲 观插入的过程中只记录了一部分 redo 日志,那么在系统崩溃重启时会将索引对 应的 B+树恢复成一种不正确的状态。

​ 所以规定在执行这些需要保证原子性的操作时必须以组的形式来记录的 redo 日志,在进行系统崩溃重启恢复时,针对某个组中的 redo 日志,要么把全 部的日志都恢复掉,要么一条也不恢复。在实现上,根据多个 redo 日志的不同, 使用了特殊的 redo 日志类型作为组的结尾,来表示一组完整的 redo 日志。

Mini-Transaction 的概念

​ 所以 MySQL 把对底层页面中的一次原子访问的过程称之为一个 Mini-Transaction,比如上边所说的修改一次 Max Row ID 的值算是一个 Mini-Transaction,向某个索引对应的 B+树中插入一条记录的过程也算是一个 Mini-Transaction。

​ 一个所谓的 Mini-Transaction 可以包含一组 redo 日志,在进行崩溃恢复时这 一组 redo 日志作为一个不可分割的整体。

一个事务可以包含若干条语句,每一条语句其实是由若干个 Mini-Transaction 组成,每一个 Mini-Transaction 又可以包含若干条 redo 日志,最终形成了一个树 形结构。

redo 日志的写入过程

redo log block 和日志缓冲区

​ InnoDB 为了更好的进行系统崩溃恢复,把通过 Mini-Transaction 生成的 redo 日志都放在了大小为 512 字节的块(block)中

​ 我们前边说过,为了解决磁盘速度过慢的问题而引入了 Buffer Pool(读磁盘)。同理, 写入 redo 日志时也不能直接直接写到磁盘上,实际上在服务器启动时就向操作 系统申请了一大片称之为 redo log buffer 的连续内存空间(写磁盘),翻译成中文就是 redo 日志缓冲区,我们也可以简称为 log buffer。这片内存空间被划分成若干个连续 的 redo log block,我们可以通过启动参数 innodb_log_buffer_size 来指定 log buffer 的大小,该启动参数的默认值为 16MB

​ 向 log buffer 中写入 redo 日志的过程是顺序的,也就是先往前边的 block 中 写,当该 block 的空闲空间用完之后再往下一个 block 中写。

​ 我们前边说过一个 Mini-Transaction 执行过程中可能产生若干条 redo 日志, 这些 redo 日志是一个不可分割的组,所以其实并不是每生成一条 redo 日志,就 将其插入到 log buffer 中,而是每个 Mini-Transaction 运行过程中产生的日志先暂时存到一个地方,当该 Mini-Transaction 结束的时候,将过程中产生的一组 redo 日志再全部复制到 log buffer 中。(依然做了暂存处理)

redo 日志刷盘时机

​ 我们前边说 Mini-Transaction 运行过程中产生的一组 redo 日志在 Mini-Transaction 结束时会被复制到 log buffer 中,可是这些日志总在内存里呆着 也不是个办法,在一些情况下它们会被刷新到磁盘里,比如:

​ 1、**log buffer 空间不足时,**log buffer 的大小是有限的(通过系统变量 innodb_log_buffer_size 指定),如果不停的往这个有限大小的 log buffer 里塞入 日志,很快它就会被填满。InnoDB 认为如果当前写入 log buffer 的 redo 日志量已 经占满了 log buffer 总容量的大约一半左右,就需要把这些日志刷新到磁盘上。

​ 2、事务提交时,我们前边说过之所以使用 redo 日志主要是因为它占用的空 间少,还是顺序写,在事务提交时可以不把修改过的 Buffer Pool 页面刷新到磁盘, 但是为了保证持久性,必须要把修改这些页面对应的 redo 日志刷新到磁盘。

​ 3、**后台有一个线程,大约每秒都会刷新一次 log buffer 中的 redo 日志到磁 盘。 **(1s)

​ 4、正常关闭服务器时等等。

redo 日志文件组

​ MySQL 的数据目录(使用 SHOW VARIABLES LIKE ‘datadir’ 查看)下默认有两 个名为 ib_logfile0 和 ib_logfile1 的文件,log buffer 中的日志默认情况下就是刷新 到这两个磁盘文件中。如果我们对默认的 redo 日志文件不满意,可以通过下边 几个启动参数来调节:

mysql>  SHOW VARIABLES LIKE 'datadir' \g
+---------------+-----------------+
| Variable_name | Value           |
+---------------+-----------------+
| datadir       | /var/lib/mysql/ |
+---------------+-----------------+
1 row in set (0.00 sec)

[root@localhost mysql]# ll
total 211000
...
-rw-r-----. 1 mysql mysql 12582912 Dec 12 10:20  ibdata1
-rw-r-----. 1 mysql mysql 50331648 Dec 12 10:20  ib_logfile0 ## here
-rw-r-----. 1 mysql mysql 50331648 Dec  7 06:11  ib_logfile1 ## 

innodb_log_group_home_dir,该参数指定了 redo 日志文件所在的目录,默 认值就是当前的数据目录

innodb_log_file_size,该参数指定了每个 redo 日志文件的大小,默认值为 48MB,

innodb_log_files_in_group,该参数指定 redo 日志文件的个数,默认值为 2, 最大值为 100。

所以磁盘上的 redo 日志文件可以不只一个,而是以一个日志文件组的形式 出现的。这些文件以 ib_logfile[数字](数字可以是 0、1、2…)的形式进行命名。 在将 redo 日志写入日志文件组时,是从 ib_logfile0 开始写,如果 ib_logfile0 写满 了,就接着 ib_logfile1 写,同理,ib_logfile1 写满了就去写 ib_logfile2,依此类推。 如果写到最后一个文件该咋办?那就重新转到 ib_logfile0 继续写。

redo 日志文件格式

​ 我们前边说过 log buffer 本质上是一片连续的内存空间,被划分成了若干个 512 字节大小的 block。将 log buffer 中的 redo 日志刷新到磁盘的本质就是把 block 的镜像写入日志文件中,所以 redo 日志文件其实也是由若干个 512 字节大小的 block 组成。

redo 日志文件组中的每个文件大小都一样,格式也一样,都是由两部分组 成前 2048 个字节,也就是前 4 个 block 是用来存储一些管理信息的。

​ 从第 2048 字节往后是用来存储 log buffer 中的 block 镜像的。

Log Sequence Number

​ 自系统开始运行,就不断的在修改页面,也就意味着会不断的生成 redo 日 志。redo 日志的量在不断的递增,就像人的年龄一样,自打出生起就不断递增, 永远不可能缩减了。(MAX Row ID 是表可能使用的主键)

​ InnoDB 为记录已经写入的 redo 日志量,设计了一个称之为 Log Sequence Number 的全局变量,翻译过来就是:日志序列号,简称 LSN。规定初始的 lsn 值为 8704(也就是一条 redo 日志也没写入时,LSN 的值为 8704)。

​ 我们知道在向 log buffer 中写入 redo 日志时不是一条一条写入的,而是以一 个 Mini-Transaction 生成的一组 redo 日志为单位进行写入的。从上边的描述中可 以看出来,每一组由 Mini-Transaction 生成的 redo 日志都有一个唯一的 LSN 值与 其对应,LSN 值越小,说明 redo 日志产生的越早。

flushed_to_disk_lsn

​ redo 日志是首先写到 log buffer 中,之后才会被刷新到磁盘上的 redo 日志文 件。InnoDB 中有一个称之为 buf_next_to_write 的全局变量,标记当前 log buffer 中已经有哪些日志被刷新到磁盘中了

我们前边说 lsn 是表示当前系统中写入的 redo 日志量这包括了写到 log buffer 而没有刷新到磁盘的日志,相应的,InnoDB 也有一个表示刷新到磁盘中的 redo 日志量的全局变量,称之为 flushed_to_disk_lsn。系统第一次启动时,该变 量的值和初始的 lsn 值是相同的,都是 8704。随着系统的运行,redo 日志被不断 写入 log buffer,但是并不会立即刷新到磁盘,**lsn 的值就和 flushed_to_disk_lsn 的值拉开了差距。**我们演示一下:

​ 系统第一次启动后,向 log buffer 中写入了 mtr_1、mtr_2、mtr_3 这三个 mtr 产生的 redo 日志,假设这三个 mtr 开始和结束时对应的 lsn 值分别是:

mtr_1:8716 ~ 8916

mtr_2:8916 ~ 9948

mtr_3:9948 ~ 10000

​ 此时的 lsn 已经增长到了 10000,但是由于没有刷新操作,所以此时 flushed_to_disk_lsn 的值仍为 8704。

​ 随后进行将 log buffer 中的 block 刷新到 redo 日志文件的操作,假设将 mtr_1 和 mtr_2 的日志刷新到磁盘,那么 flushed_to_disk_lsn 就应该增长 mtr_1 和 mtr_2 写入的日志量,所以 flushed_to_disk_lsn 的值增长到了 9948。

​ 综上所述,当有新的 redo 日志写入到 log buffer 时,首先 lsn 的值会增长, 但flushed_to_disk_lsn不变,随后随着不断有log buffer中的日志被刷新到磁盘上, flushed_to_disk_lsn 的值也跟着增长如果两者的值相同时,说明 log buffer 中的 所有 redo 日志都已经刷新到磁盘中了

​ Tips:应用程序向磁盘写入文件时其实是先写到操作系统的缓冲区中去,如 果某个写入操作要等到操作系统确认已经写到磁盘时才返回,那需要调用一下操 作系统提供的 fsync 函数。其实只有当系统执行了 fsync 函数后, flushed_to_disk_lsn 的值才会跟着增长,当仅仅把 log buffer 中的日志写入到操作 系统缓冲区却没有显式的刷新到磁盘时,另外的一个称之为 write_lsn 的值跟着 增长。

当然系统的 LSN 值远不止我们前面描述的 lsn,还有很多。

查看系统中的各种 LSN 值

​ 我们可以使用 SHOW ENGINE INNODB STATUS 命令查看当前 InnoDB 存储引擎 中的各种 LSN 值的情况,比如:

SHOW ENGINE INNODB STATUS\G

---
LOG
---
Log sequence number          35322917
Log buffer assigned up to    35322917
Log buffer completed up to   35322917
Log written up to            35322917
Log flushed up to            35322917
Added dirty pages up to      35322917
Pages flushed up to          35322917
Last checkpoint at           35322917
79 log i/o's done, 0.00 log i/o's/second
----------------------
BUFFER POOL AND MEMORY
----------------------

​ Log sequence number:代表系统中的 lsn 值,也就是当前系统已经写入的 redo 日志量,包括写入 log buffer 中的日志。

​ Log flushed up to:代表 flushed_to_disk_lsn 的值,也就是当前系统已经写入 磁盘的 redo 日志量。

​ Pages flushed up to:代表 flush 链表中被最早修改的那个页面对应的 oldest_modification 属性值。

​ Last checkpoint at:当前系统的 checkpoint_lsn 值。

innodb_flush_log_at_trx_commit 的用法

​ 我们前边说为了保证事务的持久性,用户线程在事务提交时需要将该事务执 行过程中产生的所有 redo 日志都刷新到磁盘上。会很明显的降低数据库性能。 如果对事务的持久性要求不是那么强烈的话,可以选择修改一个称为 innodb_flush_log_at_trx_commit 的系统变量的值,该变量有 3 个可选的值:

​ 0:当该系统变量值为 0 时,表示在事务提交时不立即向磁盘中同步 redo 日 志,这个任务是交给后台线程做的。

​ 这样很明显会加快请求处理速度,但是如果事务提交后服务器挂了,后台线 程没有及时将 redo 日志刷新到磁盘,那么该事务对页面的修改会丢失。

​ 1:当该系统变量值为 1 时,表示在事务提交时需要将 redo 日志同步到磁盘, 可以保证事务的持久性(我们平时crud的持久性是事务的不是数据的哈, 数据的持久性必须的,或者可以理解成狭义的事务持久性是redo日志的持久性,平时说的持久性包含事务和数据)。1 也是 innodb_flush_log_at_trx_commit 的默认值。

mysql> show variables like 'innodb_flush_log_at_trx_commit';
+--------------------------------+-------+
| Variable_name                  | Value |
+--------------------------------+-------+
| innodb_flush_log_at_trx_commit | 1     |
+--------------------------------+-------+
1 row in set (0.00 sec)

​ 2:当该系统变量值为 2 时,表示在事务提交时需要将 redo 日志写到操作系 统的缓冲区中,但并不需要保证将日志真正的刷新到磁盘。

​ 这种情况下如果数据库挂了,操作系统没挂的话,事务的持久性还是可以保 证的,但是操作系统也挂了的话,那就不能保证持久性了。

崩溃后的恢复

​ 数据的落盘了不担心崩溃,但是正在执行的事务很容易出现不一致性了, 我已可见redo日志的重要性

恢复机制

在服务器不挂的情况下,redo 日志简直就是个大累赘,不仅没用,反而让 性能变得更差。但是万一数据库挂了,就可以在重启时根据 redo 日志中的记录 就可以将页面恢复到系统崩溃前的状态。

MySQL 可以根据 redo 日志中的各种 LSN 值,来确定恢复的起点和终点。然 后将 redo 日志中的数据,以哈希表的形式,将一个页面下的放到哈希表的一个 槽中。之后就可以遍历哈希表,因为对同一个页面进行修改的 redo 日志都放在 了一个槽里,所以可以一次性将一个页面修复好(避免了很多读取页面的随机 IO)。 并且通过各种机制,避免无谓的页面修复,比如已经刷新的页面,进而提升崩溃 恢复的速度。

崩溃后的恢复为什么不用 binlog?

​ 1、这两者使用方式不一样

​ binlog 会记录表所有更改操作,包括更新删除数据,更改表结构等等,主要 用于人工恢复数据,**而 redo log 对于我们是不可见的,**它是 InnoDB 用于保证 crash-safe 能力的,也就是在事务提交后 MySQL 崩溃的话,可以保证事务的持久 性,即事务提交后其更改是永久性的。

一句话概括:binlog 是用作人工恢复数据,redo log 是 MySQL 自己使用, 用于保证在数据库崩溃时的事务持久性。

​ 2、redo log 是 InnoDB 引擎特有的,binlog 是 MySQL 的 Server 层实现的, 所有引擎都可以使用。

​ 3、redo log 是物理日志(所以一般人根本处理不了),记录的是“在某个数据页上做了什么修改”,恢复 的速度更快;binlog 是逻辑日志,记录的是这个语句的原始逻辑,比如“给 ID=2 这的 c 字段加 1 ” ;

​ 4、redo log 是“循环写”的日志文件,redo log 只会记录未刷盘的日志,已 经刷入磁盘的数据都会从 redo log 这个有限大小的日志文件里删除。binlog 是 追加日志,保存的是全量的日志。

​ 5、最重要的是,当数据库 crash 后,想要恢复未刷盘但已经写入 redo log 和 binlog 的数据到内存时,binlog 是无法恢复的。虽然 binlog 拥有全量的日志, 但没有一个标志让 innoDB 判断哪些数据已经入表(写入磁盘),哪些数据还没有(就是因为它是全量的所以不知道,而redo log 提交的事务的数据会全部删除掉,所以redo log的数据更小了)。

​ 比如,binlog 记录了两条日志:

​ 给 ID=2 这一行的 c 字段加 1

​ 给 ID=2 这一行的 c 字段加 1

​ 在记录 1 入表后,记录 2 未入表时,数据库 crash。重启后,只通过 binlog 数据库无法判断这两条记录哪条已经写入磁盘,哪条没有写入磁盘,不管是两条 都恢复至内存,还是都不恢复,对 ID=2 这行数据来说,都不对。

​ ***但 redo log 不一样,只要刷入磁盘的数据,都会从 redo log 中抹掉,数据 库重启后,直接把 redo log 中的数据都恢复至内存就可以了***。

undo 日志

redo log是为了保证事务的持久性, undo日志是为了保证事务的原子性

事务回滚的需求

​ 我们说过事务需要保证原子性,也就是事务中的操作要么全部完成,要么什 么也不做。但是偏偏有时候事务执行到一半会出现一些情况,比如:

​ 情况一:事务执行过程中可能遇到各种错误,比如服务器本身的错误,操作 系统错误,甚至是突然断电导致的错误。

​ 情况二:程序员可以在事务执行过程中手动输入 ROLLBACK 语句结束当前的 事务的执行。

​ 这两种情况都会导致事务执行到一半就结束,但是事务执行过程中可能已经 修改了很多东西,为了保证事务的原子性,我们需要把东西改回原先的样子,这 个过程就称之为回滚(英文名:rollback),这样就可以造成这个事务看起来什 么都没做,所以符合原子性要求。

​ 每当我们要对一条记录做改动时(这里的改动可以指 INSERT、DELETE、 UPDATE),都需要把回滚时所需的东西都给记下来。比方说:

​ 你插入一条记录时,至少要把这条记录的主键值记下来,之后回滚的时候只 需要把这个主键值对应的记录删掉。

​ 你删除了一条记录,至少要把这条记录中的内容都记下来,这样之后回滚时 再把由这些内容组成的记录插入到表中。

​ 你修改了一条记录,至少要把修改这条记录前的旧值都记录下来,这样之后 回滚时再把这条记录更新为旧值。(bin log 只记录原始操作的sql 逻辑语句, 不记录之前的值, 因为它不考虑回滚问题)

这些为了回滚而记录的这些东西称之为撤销日志,英文名为 undo log/undo 日志。这里需要注意的一点是,由于查询操作(SELECT)并不会修改任何用户记 录,所以在查询操作执行时,并不需要记录相应的 undo 日志。

​ 当然,在真实的 InnoDB 中,undo 日志其实并不像我们上边所说的那么简单, 不同类型的操作产生的 undo 日志的格式也是不同的。

事务 id

给事务分配 id 的时机

​ 一个事务可以是一个只读事务,或者是一个读写事务:

​ 我们可以通过 START TRANSACTION READ ONLY 语句开启一个只读事务。

在只读事务中不可以对普通的表(其他事务也能访问到的表)进行增、删、 改操作,但可以对用户临时表做增、删、改操作

​ 我们可以通过 START TRANSACTION READ WRITE 语句开启一个读写事务,或 者使用 BEGIN、START TRANSACTION 语句开启的事务默认也算是读写事务。

​ 在读写事务中可以对表执行增删改查操作。

​ 如果某个事务执行过程中对某个表执行了增、删、改操作,那么 InnoDB 存 储引擎就会给它分配一个独一无二的事务 id,分配方式如下:

​ 对于只读事务来说,只有在它第一次对某个用户创建的临时表执行增、删、 改操作时才会为这个事务分配一个事务 id,否则的话是不分配事务 id 的。

​ 我们前边说过对某个查询语句执行 EXPLAIN 分析它的查询计划时,有时候在 Extra 列会看到 Using temporary 的提示,这个表明在执行该查询语句时会用到内 部临时表。这个所谓的内部临时表和我们手动用 CREATE TEMPORARY TABLE 创建 的用户临时表并不一样,在事务回滚时并不需要把执行 SELECT 语句过程中用到 的内部临时表也回滚,在执行 SELECT 语句用到内部临时表时并不会为它分配事 务 id。

​ 对于读写事务来说,只有在它第一次对某个表(包括用户创建的临时表)执 行增、删、改操作时才会为这个事务分配一个事务 id,否则的话也是不分配事务 id 的。

有的时候虽然我们开启了一个读写事务,但是在这个事务中全是查询语句, 并没有执行增、删、改的语句,那也就意味着这个事务并不会被分配一个事务 id。

上边描述的事务 id 分配策略是针对 MySQL 5.7 来说的,前边的版本的分配方 式可能不同

事务 id 生成机制

​ 这个事务 id 本质上就是一个数字,它的分配策略和我们前边提到的对隐藏 列 row_id(当用户没有为表创建主键和 UNIQUE 键时 InnoDB 自动创建的列)的 分配策略大抵相同,具体策略如下:

​ 服务器会在内存中维护一个全局变量,每当需要为某个事务分配一个事务 id 时,就会把该变量的值当作事务 id 分配给该事务,并且把该变量自增 1。(当前的一个值是没有被使用的)

每当这个变量的值为 256 的倍数时(似曾相识),就会将该变量的值刷新到系统表空间的 页号为 5 的页面中一个称之为 Max Trx ID 的属性处,这个属性占用 8 个字节的存 储空间。

​ 当系统下一次重新启动时,会将上边提到的 Max Trx ID 属性加载到内存中, 将该值加上 256 之后赋值给我们前边提到的全局变量(因为在上次关机时该全局 变量的值可能大于 Max Trx ID 属性值)。

​ 这样就可以保证整个系统中分配的事务 id 值是一个递增的数字。先被分配 id 的事务得到的是较小的事务 id,后被分配 id 的事务得到的是较大的事务 id。

trx_id 隐藏列

​ 我们在学习 InnoDB 记录行格式的时候重点强调过:聚簇索引的记录除了会 保存完整的用户数据以外,而且还会自动添加名为 trx_id、roll_pointer 的隐藏列, 如果用户没有在表中定义主键以及 UNIQUE 键,还会自动添加一个名为 row_id 的隐藏列。(row_id不一定会有,但是一定会有一个同样功能的字段)

在这里插入图片描述

​ 其中的 trx_id 列就是某个对这个聚簇索引记录做改动的语句所在的事务对 应的事务 id 而已(此处的改动可以是 INSERT、DELETE、UPDATE 操作)。至于 roll_pointer 隐藏列我们后边分析。

undo 日志的格式

为了实现事务的原子性,InnoDB 存储引擎在实际进行增、删、改一条记录 时,都需要先把对应的 undo 日志记下来。一般每对一条记录做一次改动,就对 应着一条 undo 日志,但在某些更新记录的操作中,也可能会对应着 2 条 undo 日志。

​ 一个事务在执行过程中可能新增、删除、更新若干条记录,也就是说需要记 录很多条对应的 undo 日志,这些 undo 日志会被从 0 开始编号,也就是说根据 生成的顺序分别被称为第 0 号 undo 日志、第 1 号 undo 日志、…、第 n 号 undo 日志等,这个编号也被称之为 undo no

​ 这些 undo 日志是被记录到类型为 FIL_PAGE_UNDO_LOG 的页面中。这些页 面可以从系统表空间中分配,也可以从一种专门存放 undo 日志的表空间,也就 是所谓的 undo tablespace 中分配。先来看看不同操作都会产生什么样子的 undo 日志。

INSERT 操作对应的 undo 日志

​ 当我们向表中插入一条记录时最终导致的结果就是这条记录被放到了一个 数据页中。如果希望回滚这个插入操作,那么把这条记录删除就好了,也就是说在写对应的 undo 日志时,主要是把这条记录的主键信息记上。InnoDB 的设计了 一个类型为 TRX_UNDO_INSERT_REC 的 undo 日志

​ 如果记录中的主键只包含一个列,那么在类型为 TRX_UNDO_INSERT_REC 的 undo 日志中只需要把该列占用的存储空间大小和真实值记录下来,如果记录中 的主键包含多个列,那么每个列占用的存储空间大小和对应的真实值都需要记录 下来。

​ 当我们向某个表中插入一条记录时,实际上需要向聚簇索引和所有的二级索 引都插入一条记录。不过记录 undo 日志时,我们只需要考虑向聚簇索引插入记 录时的情况就好了,因为其实聚簇索引记录和二级索引记录是一一对应的,我们 在回滚插入操作时,只需要知道这条记录的主键信息,然后根据主键信息做对应 的删除操作,做删除操作时就会顺带着把所有二级索引中相应的记录也删除掉后边说到的 DELETE 操作和 UPDATE 操作对应的 undo 日志也都是针对聚簇索引记录而言的。

roll_pointer 的作用

​ roll_pointer 本质上就是一个指向记录对应的 undo 日志的一个指针。比方说 我们向表里插入了 2 条记录,每条记录都有与其对应的一条 undo 日志。记录被 存储到了类型为 FIL_PAGE_INDEX 的页面中(就是我们前边一直所说的数据页), undo 日志被存放到了类型为 FIL_PAGE_UNDO_LOG 的页面中。roll_pointer 本质就 是一个指针,指向记录对应的 undo 日志。

DELETE 操作对应的 undo 日志

​ 我们知道插入到页面中的记录会根据记录头信息中的 next_record 属性组成 一个单向链表,我们把这个链表称之为正常记录链表;被删除的记录其实也会根 据记录头信息中的 next_record 属性组成一个链表,只不过这个链表中的记录占 用的存储空间可以被重新利用,所以也称这个链表为垃圾链表。Page Header 部 分有一个称之为 PAGE_FREE 的属性,它指向由被删除记录组成的垃圾链表中的 头节点。

​ 我们知道插入到页面中的记录会根据记录头信息中的 next_record 属性组成 一个单向链表,我们把这个链表称之为正常记录链表;被删除的记录其实也会根 据记录头信息中的 next_record 属性组成一个链表,只不过这个链表中的记录占 用的存储空间可以被重新利用,所以也称这个链表为垃圾链表。Page Header 部 分有一个称之为 PAGE_FREE 的属性,它指向由被删除记录组成的垃圾链表中的 头节点。

在这里插入图片描述

​ 我们只把记录的 delete_mask 标志位展示了出来。从图中可以看出,正常记 录链表中包含了 3 条正常记录,垃圾链表里包含了 2 条已删除记录。页面的 Page Header 部分的 PAGE_FREE 属性的值代表指向垃圾链表头节点的指针。

​ 假设现在我们准备使用 DELETE 语句把正常记录链表中的最后一条记录给删 除掉,其实这个删除的过程需要经历两个阶段:

​ 阶段一:将记录的delete_mask标识位设置为1,这个阶段称之为delete mark。

在这里插入图片描述

​ 可以看到,正常记录链表中的最后一条记录的 delete_mask 值被设置为 1, 但是并没有被加入到垃圾链表。也就是此时记录处于一个中间状态。在删除语句 所在的事务提交之前,被删除的记录一直都处于这种所谓的中间状态。

为啥会有这种奇怪的中间状态呢?其实主要是为了实现一个称之为 MVCC 的功能,稍后再介绍。

​ 阶段二:当该删除语句所在的事务提交之后,会有专门的线程后来真正的把 记录删除掉。所谓真正的删除就是把该记录从正常记录链表中移除,并且加入到 垃圾链表中,然后还要调整一些页面的其他信息,比如页面中的用户记录数量 PAGE_N_RECS、上次插入记录的位置 PAGE_LAST_INSERT、垃圾链表头节点的指 针 PAGE_FREE、页面中可重用的字节数量 PAGE_GARBAGE、还有页目录的一些信 息等等。这个阶段称之为 purge

​ 把阶段二执行完了,这条记录就算是真正的被删除掉了。这条已删除记录占 用的存储空间也可以被重新利用了

在这里插入图片描述

​ 从上边的描述中我们也可以看出来,在删除语句所在的事务提交之前,只会 经历阶段一,也就是 delete mark 阶段(提交之后我们就不用回滚了,所以只需 考虑对删除操作的阶段一做的影响进行回滚)。InnoDB 中就会产生一种称之为 TRX_UNDO_DEL_MARK_REC 类型的 undo 日志。

版本链

​ 同时,**在对一条记录进行 delete mark 操作前,需要把该记录的旧的 trx_id 和 roll_pointer 隐藏列的值都给记到对应的 undo 日志中来,**就是我们图中显示的 old trx_id 和 old roll_pointer 属性。这样有一个好处,那就是可以通过 undo 日志 的 old roll_pointer 找到记录在修改之前对应的 undo 日志。比方说在一个事务中, 我们先插入了一条记录,然后又执行对该记录的删除操作,这个过程的示意图就 是这样:

在这里插入图片描述

​ 从图中可以看出来,执行完delete mark操作后,它对应的undo日志和INSERT 操作对应的 undo 日志就串成了一个链表。这个链表就称之为版本链。

UPDATE 操作对应的 undo 日志

​ 在执行 UPDATE 语句时,InnoDB 对更新主键和不更新主键这两种情况有截然 不同的处理方案。

不更新主键的情况

​ 在不更新主键的情况下,又可以细分为被更新的列占用的存储空间不发生变 化和发生变化的情况。

就地更新(in-place update)

​ 更新记录时,对于被更新的每个列来说,如果更新后的列和更新前的列占用 的存储空间都一样大,那么就可以进行就地更新,也就是直接在原记录的基础上 修改对应列的值。再次强调一边,是每个列在更新前后占用的存储空间一样大, 有任何一个被更新的列更新前比更新后占用的存储空间大,或者更新前比更新后 占用的存储空间小都不能进行就地更新。

先删除掉旧记录,再插入新记录

在不更新主键的情况下,如果有任何一个被更新的列更新前和更新后占用的 存储空间大小不一致,那么就需要先把这条旧的记录从聚簇索引页面中删除掉, 然后再根据更新后列的值创建一条新的记录插入到页面中。

​ 请注意一下,我们这里所说的删除并不是 delete mark 操作,而是真正的删 除掉,也就是把这条记录从正常记录链表中移除并加入到垃圾链表中,并且修改 页面中相应的统计信息(比如 PAGE_FREE、PAGE_GARBAGE 等这些信息)。由用 户线程同步执行真正的删除操作,真正删除之后紧接着就要根据各个列更新后的 值创建的新记录插入

​ 这里如果新创建的记录占用的存储空间大小不超过旧记录占用的空间,那么 可以直接重用被加入到垃圾链表中的旧记录所占用的存储空间,否则的话需要在 页面中新申请一段空间以供新记录使用,如果本页面内已经没有可用的空间的话, 那就需要进行页面分裂操作,然后再插入新记录

​ 针对 UPDATE 不更新主键的情况(包括上边所说的就地更新和先删除旧记录 再插入新记录),InnoDB 设计了一种类型为 TRX_UNDO_UPD_EXIST_REC 的 undo 日志。

更新主键的情况

​ 在聚簇索引中,记录是按照主键值的大小连成了一个单向链表的,如果我们 更新了某条记录的主键值,意味着这条记录在聚簇索引中的位置将会发生改变, 比如你将记录的主键值从 1 更新为 10000,如果还有非常多的记录的主键值分布 在 1 ~ 10000 之间的话,那么这两条记录在聚簇索引中就有可能离得非常远,甚 至中间隔了好多个页面。针对 UPDATE 语句中更新了记录主键值的这种情况, InnoDB 在聚簇索引中分了两步处理:

将旧记录进行 delete mark 操作

​ 也就是说在 UPDATE 语句所在的事务提交前,**对旧记录只做一个 delete mark 操作,在事务提交后才由专门的线程做 purge 操作,**把它加入到垃圾链表中。这 里一定要和我们上边所说的在不更新记录主键值时,先真正删除旧记录,再插入 新记录的方式区分开!

之所以只对旧记录做 delete mark 操作,是因为别的事务同时也可能访问这 条记录,如果把它真正的删除加入到垃圾链表后,别的事务就访问不到了。这个 功能就是所谓的 MVCC。

创建一条新记录

​ 根据更新后各列的值创建一条新记录,并将其插入到聚簇索引中(需重新定 位插入的位置)。

​ 由于更新后的记录主键值发生了改变,所以需要重新从聚簇索引中定位这条 记录所在的位置,然后把它插进去。

​ 针对 UPDATE 语句更新记录主键值的这种情况,在对该记录进行 delete mark 操作前,会记录一条类型为 TRX_UNDO_DEL_MARK_REC (和删除的格式一样)的 undo 日志;之后插入 新记录时,会记录一条类型为 TRX_UNDO_INSERT_REC(和插入格式一样) 的 undo 日志,也就是说 每对一条记录的主键值做改动时,会记录 2 条 undo 日志。

FIL_PAGE_UNDO_LOG 页面

​ 我们前边说明表空间的时候说过,表空间其实是由许许多多的页面构成的, 页面默认大小为 16KB。这些页面有不同的类型,比如类型为 FIL_PAGE_INDEX 的 页面用于存储聚簇索引以及二级索引,类型为 FIL_PAGE_TYPE_FSP_HDR 的页面用 于存储表空间头部信息的,还有其他各种类型的页面,其中有一种称之为 FIL_PAGE_UNDO_LOG 类型的页面是专门用来存储 undo 日志的。

MVCC

​ 全称 Multi-Version Concurrency Control,即多版本并发控制,主要是为了提 高数据库的并发性能。

​ 同一行数据平时发生读写请求时,会上锁阻塞住。但 MVCC 用更好的方式去 处理读—写请求,做到在发生读—写请求冲突时不用加锁。

这个读是指的快照读,而不是当前读,当前读是一种加锁操作,是悲观锁。

​ 那它到底是怎么做到读—写不用加锁的,快照读和当前读是指什么?我们后 面都会学到。

MVCC 原理

复习事务隔离级别

事务并发执行遇到的问题

脏读, 不可重复读, 幻读

在这里插入图片描述

​ MySQL 在 REPEATABLE READ 隔离级别下,是可以很大程度避免幻读问题的 发生的,MySQL 是怎么做到的?

版本链

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

​ trx_id:每次一个事务对某条聚簇索引记录进行改动时,都会把该事务的事 务 id 赋值给 trx_id 隐藏列。

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

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

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

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

​ INSERT INTO teacher VALUES(1, ‘Jark’, ‘源码系列’);

​ 假设插入该记录的事务 id 为 60,那么此刻该条记录的示意图如下所示:

20211215000246883

​ 假设之后两个事务 id 分别为 80、120 的事务对这条记录进行 UPDATE 操作,操 作流程如下:

Trx 80Trx 120
BEGIN
BEGIN
UPDATE teacher SET name = ‘Mark’ WHERE number = 1;
UPDATE teacher SET name = ‘James’ WHERE number = 1;
COMMIT
UPDATE teacher SET name = ‘King’ WHERE number = 1;
UPDATE teacher SET name = ‘大飞’ WHERE number = 1;

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

在这里插入图片描述

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

ReadView

在这里插入图片描述

​ 对于使用 READ UNCOMMITTED 隔离级别的事务来说,由于可以读到未提交 事务修改过的记录,所以直接读取记录的最新版本就好了。(最快最简单)

​ 对于使用 SERIALIZABLE 隔离级别的事务来说,InnoDB 使用加锁的方式来访 问记录。

​ 对于使用 READ COMMITTED 和 REPEATABLE READ 隔离级别的事务来说,都 必须保证读到已经提交了的事务修改过的记录,也就是说假如另一个事务已经修 改了记录但是尚未提交,是不能直接读取最新版本的记录的,核心问题就是: READ COMMITTED 和 REPEATABLE READ 隔离级别在不可重复读和幻读上的区别 是从哪里来的,其实结合前面的知识,这两种隔离级别关键是需要判断一下版本 链中的哪个版本是当前事务可见的

​ 为此,InnoDB 提出了一个 ReadView 的概念,这个 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

​ creator_trx_id:表示生成该 ReadView 的事务的事务 id。

​ 有了这个 ReadView,这样在访问某条记录时,只需要按照下边的步骤判断 记录的某个版本是否可见:(访问者的不一定有事务id,可不可以访问的依据是版本链中的事务是否被提交了或者如果是事务访问看有没有自己的事务版本)

​ 1、如果被访问版本的 trx_id 属性值与 ReadView 中的 creator_trx_id 值相同, 意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。

​ 2、如果被访问版本的 trx_id 属性值小于 ReadView 中的 min_trx_id 值,表明 生成该版本的事务在当前事务生成 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 时生成该版本的事务已经 被提交,该版本可以被访问。

​ 5、如果某个版本的数据对当前事务不可见的话,那就顺着版本链找到下一 个版本的数据,继续按照上边的步骤判断可见性,依此类推,直到版本链中的最 后一个版本。如果最后一个版本也不可见的话,那么就意味着该条记录对该事务 完全不可见,查询结果就不包含该记录。

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

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

READ COMMITTED —— 每次读取数据前都生成一个 ReadView

​ 比方说现在系统里有两个事务 id 分别为 80、120 的事务在执行:

​ # Transaction 80

​ UPDATE teacher SET name = ‘Mark’ WHERE number = 1;

​ UPDATE teacher SET name = ‘James’ WHERE number = 1;

​ 此刻,表 teacher 中 number 为 1 的记录得到的版本链表如下所示:
在这里插入图片描述

​ 假设现在有一个使用 READ COMMITTED 隔离级别的事务开始执行:

​ # 使用 READ COMMITTED 隔离级别的事务

​ BEGIN;

​ # SELECE1:Transaction 80、120 未提交

​ SELECT * FROM teacher WHERE number = 1; # 得到的列 name 的值为’Jack’

​ 这个 SELECE1 的执行过程如下:

在执行 SELECT 语句时会先生成一个 ReadView,ReadView 的 m_ids 列表的内 容就是[80, 120],min_trx_id 为 80,max_trx_id 为 121,creator_trx_id 为 0。(0表示非事务执行)

​ 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列 name 的 内容是’James’,该版本的 trx_id 值为 80,在 m_ids 列表内,所以不符合可见性要 求(4、如果被访问版本的 trx_id 属性值在 ReadView 的 min_trx_id 和 max_trx_id 之间(min_trx_id < trx_id < max_trx_id),那就需要判断一下 trx_id 属性值是不是在 m_ids 列表中,如果在,说明创建 ReadView 时生成该版本的事务还是活跃的, 该版本不可以被访问;如果不在,说明创建 ReadView 时生成该版本的事务已经 被提交,该版本可以被访问),根据 roll_pointer 跳到下一个版本。

​ 下一个版本的列name的内容是’Mark’,该版本的trx_id值也为80,也在m_ids 列表内,所以也不符合要求,继续跳到下一个版本。

​ 下一个版本的列 name 的内容是’‘Jack’’,该版本的 trx_id 值为 60,小于 ReadView 中的 min_trx_id 值,所以这个版本是符合要求的(2、如果被访问版本 的 trx_id 属性值小于 ReadView 中的 min_trx_id 值,表明生成该版本的事务在当前事务生成 ReadView 前已经提交,所以该版本可以被当前事务访问),最后返 回给用户的版本就是这条列 name 为’‘Jack’'的记录。

​ 之后,我们把事务 id 为 80 的事务提交一下,

​ 然后再到事务id为120的事务中更新一下表teacher 中 number 为1 的记录:

​ # Transaction120

​ BEGIN;

​ # 更新了一些别的表的记录

​ UPDATE teacher SET name = ‘King’ 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 的值为’‘Jack’’

​ # SELECE2:Transaction 80 提交,Transaction 120 未提交

​ SELECT * FROM teacher WHERE number = 1; # 得到的列 name 的值为’‘James’

​ 这个 SELECE2 的执行过程如下:

在执行 SELECT 语句时会又会单独生成一个 ReadView,该 ReadView 的 m_ids 列表的内容就是[120](事务 id 为 80 的那个事务已经提交了,所以再次生成快照 时就没有它了),min_trx_id 为 120,max_trx_id 为 121,creator_trx_id 为 0。(所以读已提交是每次查询时候都重新生成一个ReadView, 所以它的活跃列表, 最大最小事务id可能变化了)

​ 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列 name 的 内容是’大飞’,该版本的 trx_id 值为 120,在 m_ids 列表内,所以不符合可见性要 求,根据 roll_pointer 跳到下一个版本。

​ 下一个版本的列 name 的内容是’King’,该版本的 trx_id 值为 120,也在 m_ids 列表内,所以也不符合要求,继续跳到下一个版本

​ 下一个版本的列 name 的内容是’James’,该版本的 trx_id 值为 80,小于 ReadView 中的 min_trx_id 值 120,所以这个版本是符合要求的,最后返回给用户 的版本就是这条列 name 为’‘James’'的记录。

​ 以此类推,如果之后事务 id 为 120 的记录也提交了,再次在使用 READ COMMITTED 隔离级别的事务中查询表 teacher 中 number 值为 1 的记录时,得到 的结果就是’大飞’了,具体流程我们就不分析了。总结一下就是:使用 READ COMMITTED 隔离级别的事务在每次查询开始时都会生成一个独立的 ReadView。

REPEATABLE READ —— 在第一次读取数据时生成一个 ReadView

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

​ 比方说现在系统里有两个事务 id 分别为 80、120 的事务在执行:

​ # Transaction 80

​ UPDATE teacher SET name = ‘Mark’ WHERE number = 1;

​ UPDATE teacher SET name = ‘James’ WHERE number = 1;

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

在这里插入图片描述

​ 假设现在有一个使用 REPEATABLE READ 隔离级别的事务开始执行:

​ # 使用 REPEATABLE READ 隔离级别的事务

​ BEGIN;

​ # SELECE1:Transaction 100、200 未提交

​ SELECT * FROM teacher WHERE number = 1; # 得到的列 name 的值为’Jack’

​ 这个 SELECE1 的执行过程如下:

在执行 SELECT 语句时会先生成一个 ReadView,ReadView 的 m_ids 列表的内 容就是[80, 120],min_trx_id 为 80,max_trx_id 为 121,creator_trx_id 为 0。

​ 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列 name 的 内容是’James’,该版本的 trx_id 值为 80,在 m_ids 列表内,所以不符合可见性要 求,根据 roll_pointer 跳到下一个版本。

​ 下一个版本的列name的内容是’Mark’,该版本的trx_id值也为80,也在m_ids 列表内,所以也不符合要求,继续跳到下一个版本

​ 下一个版本的列 name 的内容是’‘Jack’’,该版本的 trx_id 值为 60,小于 ReadView 中的 min_trx_id 值,所以这个版本是符合要求的,最后返回给用户的 版本就是这条列 name 为’‘Jack’'的记录。

​ 之后,我们把事务 id 为 80 的事务提交一下,

​ 然后再到事务id为120的事务中更新一下表teacher 中 number 为1 的记录:

​ BEGIN;

​ # 更新了一些别的表的记录

​ UPDATE teacher SET name = ‘King’ WHERE number = 1;

​ UPDATE teacher SET name = ‘大飞’ WHERE number = 1;

​ 此刻,表 teacher 中 number 为 1 的记录的版本链就长这样:

在这里插入图片描述

​ 然后再到刚才使用 REPEATABLE READ 隔离级别的事务中继续查找这个 number 为 1 的记录,如下:

​ # 使用 REPEATABLE READ 隔离级别的事务

​ BEGIN;

​ # SELECE1:Transaction 80、120 均未提交

​ SELECT * FROM teacher WHERE number = 1; # 得到的列 name 的值为’‘Jack’’

​ # SELECE2:Transaction 80 提交,Transaction 120 未提交

​ SELECT * FROM teacher WHERE number = 1; # 得到的列 name 的值为’‘Jack’’’

​ 这个 SELECE2 的执行过程如下:

因为当前事务的隔离级别为 REPEATABLE READ,而之前在执行 SELECE1 时已 经生成过 ReadView 了,所以此时直接复用之前的 ReadView,之前的 ReadView 的 m_ids 列表的内容就是[80, 120],min_trx_id 为 80,max_trx_id 为 121, creator_trx_id 为 0。(之前的视图信息没有变化,新的事务(120)也不会被读取到, 如果重新生成的话,视图活跃事务列表应该只剩下[120])

​ 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列 name 的 内容是’大飞’,该版本的 trx_id 值为 120,在 m_ids 列表内,所以不符合可见性要 求,根据 roll_pointer 跳到下一个版本。

​ 下一个版本的列 name 的内容是’King’,该版本的 trx_id 值为 120,也在 m_ids 列表内,所以也不符合要求,继续跳到下一个版本

​ 下一个版本的列 name 的内容是’James’,该版本的 trx_id 值为 80,而 m_ids 列表中是包含值为 80 的事务 id 的,所以该版本也不符合要求,同理下一个列 name 的内容是’Mark’的版本也不符合要求。继续跳到下一个版本。

​ 下一个版本的列name的内容是’Jack’,该版本的trx_id值为60,小于ReadView 中的 min_trx_id 值 80,所以这个版本是符合要求的,最后返回给用户的版本就 是这条列 c 为’‘Jack’'的记录。

也就是说两次 SELECT 查询得到的结果是重复的,记录的列 c 值都是’’‘Jack’’’, 这就是可重复读的含义。如果我们之后再把事务 id 为 120 的记录提交了,然后 再到刚才使用 REPEATABLE READ 隔离级别的事务中继续查找这个 number 为 1 的 记录,得到的结果还是’Jack’,具体执行过程大家可以自己分析一下。

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,‘Luffy’,‘ELK’);,我们往表中插入了一条 number = 30 的记录。

​ 此时回到事务 T1,执行:

在这里插入图片描述

​ update teacher set domain=‘RabbitMQ’ where number=30; ## T1已经开启了事务,但是之前没有生成事务ID,会更新当前记录的trx_id, 同时会将undo log添加到版本链表中

​ select * from teacher where number = 30;

​ 嗯,怎么回事?事务 T1 很明显出现了幻读现象。

​ 在 REPEATABLE READ 隔离级别下,T1 第一次执行普通的 SELECT 语句时生成 了一个 ReadView,之后 T2 向 teacher 表中新插入一条记录并提交。

​ ReadView 并不能阻止 T1 执行 UPDATE 或者 DELETE 语句来改动这个新插入 的记录(由于 T2 已经提交,因此改动该记录并不会造成阻塞),但是这样一来, 这条新记录的 trx_id 隐藏列的值就变成了 T1 的事务 id(T2未提交是T2的值,T2提交后是0, T1执行update后变成了T1 的值)。之后 T1 再使用普通的 SELECT 语句去查询这条记录时就可以看到这条记录了也就可以把这条记录返 回给客户端。因为这个特殊现象的存在,我们也可以认为 MVCC 并不能完全禁 止幻读。

MVCC 小结

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

​ READ COMMITTD、REPEATABLE READ 这两个隔离级别的一个很大不同就是: 生成 ReadView 的时机不同,READ COMMITTD 在每一次进行普通 SELECT 操作前 都会生成一个 ReadView,而 REPEATABLE READ 只在第一次进行普通 SELECT 操作 前生成一个 ReadView,之后的查询操作都重复使用这个 ReadView 就好了,从而 基本上可以避免幻读现象。

​ 我们之前说执行 DELETE 语句或者更新主键的 UPDATE 语句并不会立即把对 应的记录完全从页面中删除,而是执行一个所谓的 delete mark 操作,相当于只 是对记录打上了一个删除标志位,这主要就是为 MVCC 服务的。

​ 另外,所谓的 MVCC 只是在我们进行普通的 SEELCT 查询时才生效,截止到 目前我们所见的所有 SELECT 语句都算是普通的查询

  • 5
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

岁月玲珑

你的鼓励将是我创作的最大动力

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

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

打赏作者

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

抵扣说明:

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

余额充值