19. 事务简介

事务指的是满足 ACID 特性的一组操作,可以通过 Commit 提交一个事务,也可以使用 Rollback 进行回滚

  1. 原子性(Atomicity)

事务被视为不可分割的最小单元,事务的所有操作要么全部提交成功,要么全部失败回滚。
回滚可以用回滚日志(Undo Log)来实现,回滚日志记录着事务所执行的修改操作,在回滚时反向执行这些修改操作即可。

  1. 一致性(Consistency)

数据库在事务执行前后都保持一致性状态。在一致性状态下,所有事务对同一个数据的读取结果都是相同的。 :::info

  • 如果数据库中的数据全部符合现实世界中的约束(all defined rules),我们说这些数据就是一致的,或者说符合 一致性 的
  • 数据库某些操作的原子性和隔离性都是保证一致性的一种手段,在操作执行完成后保证符合所有既定的约束则是一种结果。 :::
  1. 隔离性(Isolation)

一个事务所做的修改在最终提交以前,对其它事务是不可见的。

  1. 持久性(Durability)

一旦事务提交,则其所做的修改将会永远保存到数据库中。即使系统发生崩溃,事务执行的结果也不能丢失。
系统发生崩溃可以用重做日志(Redo Log)进行恢复,从而实现持久性。与回滚日志记录数据的逻辑修改不同,重做日志记录的是数据页的物理修改

事务的概念

事务 是一个抽象的概念,它其实对应着一个或多个数据库操作,根据这些操作所执行的不同阶段把 事务 大致上划分成了这么几个状态: :::warning

  • 活动的(active)

事务对应的数据库操作正在执行过程中时,我们就说该事务处在 活动的 状态。

  • 部分提交的(partially committed)

当事务中的最后一个操作执行完成,但由于操作都在内存中执行,所造成的影响并没有刷新到磁盘时,我们就说该事务处在 部分提交的 状态。

  • 失败的(failed)

当事务处在 活动的 或者 部分提交的 状态时,可能遇到了某些错误而无法继续执行,或者人为的停止当前事务的执行,我们就说该事务处在 失败的 状态。

  • 中止的(aborted)

如果事务执行了半截而变为 失败的 状态,就要撤销失败事务对当前数据库造成的影响。我们把这个撤销的过程称之为 回滚 。当 回滚 操作执行完毕时,也就是数据库恢复到了执行事务之前的状态,我们就说该事务处在了 中止的 状态。

  • 提交的(committed)

当一个处在 部分提交的 状态的事务将修改过的数据都同步到磁盘上之后,我们就可以说该事务处在了 提交 的 状态。 ::: fig0301.svg :::danger 只有当事务处于提交的或者中止的状态时,一个事务的生命周期才算是结束了 :::

MySQL中事务的语法

开启事务

  1. BEGIN [WORK];
  2. START TRANSACTION;
  • BEGIN 语句代表开启一个事务,后边的单词 WORK 可有可无。开启事务后,就可以继续写若干条语句,这些语句都属于刚刚开启的这个事务。
  • START TRANSACTION比 BEGIN 语句牛逼一点儿的是,可以在 START TRANSACTION 语句后边跟随几个 修饰符

    • READ ONLY :标识当前事务是一个只读事务
    • READ WRITE :标识当前事务是一个读写事务
    • WITH CONSISTENT SNAPSHOT :启动一致性读
      1. START TRANSACTION READ ONLY, WITH CONSISTENT SNAPSHOT;

      提交事务

      COMMIT [WORK]
      1. mysql> BEGIN;
      2. Query OK, 0 rows affected (0.00 sec)
      3. mysql> UPDATE account SET balance = balance - 10 WHERE id = 1;
      4. Query OK, 1 row affected (0.02 sec)
      5. Rows matched: 1 Changed: 1 Warnings: 0
      6. mysql> UPDATE account SET balance = balance + 10 WHERE id = 2;
      7. Query OK, 1 row affected (0.00 sec)
      8. Rows matched: 1 Changed: 1 Warnings: 0
      9. mysql> COMMIT;
      10. Query OK, 0 rows affected (0.00 sec)
      COMMIT 语句就代表提交一个事务,后边的 WORK 可有可无。

      手动中止事务

      如果我们写了几条语句之后发现上边的某条语句写错了,我们可以手动的使用下边这个语句来将数据库恢复到事务执行之前的样子
      ROLLBACK [WORK]
      ROLLBACK 语句就代表中止并回滚一个事务,后边的 WORK 可有可无类似的。 :::warning MySQL 中并不是所有存储引擎都支持事务的功能,目前只有 InnoDB 和 NDB 存储引擎支持 :::

      自动提交

      默认情况下,如果我们不显式的使用 START TRANSACTION 或者 BEGIN 语句开启一个事务,那么每一条语句都算是一个独立的事务,这种特性称之为事务的 自动提交.

      隐式提交

      当我们使用 START TRANSACTION 或者 BEGIN 语句开启了一个事务,或者把系统变量 autocommit 的值设置为 OFF时,事务就不会进行 自动提交 ,但是如果我们输入了某些语句之后就会 悄悄的 提交掉,就像我们输入了COMMIT 语句了一样,这种因为某些特殊的语句而导致事务提交的情况称为 隐式提交

      保存点

      如果你开启了一个事务,并且已经敲了很多语句,忽然发现上一条语句有点问题,你只好使用 ROLLBACK 语句来让数据库状态恢复到事务执行之前的样子,然后一切从头再来,总有一种一夜回到解放前的感觉。所以设计数据库的大叔们提出了一个 保存点 (英文: savepoint )的概念,就是在事务对应的数据库语句中打几个点,我们在调用 ROLLBACK 语句时可以指定会滚到哪个点,而不是回到最初的原点
      1. mysql> BEGIN;
      2. Query OK, 0 rows affected (0.00 sec)
      3. mysql> UPDATE account SET balance = balance - 10 WHERE id = 1;
      4. Query OK, 1 row affected (0.01 sec)
      5. Rows matched: 1 Changed: 1 Warnings: 0
      6. mysql> SAVEPOINT s1; # 一个保存点
      7. Query OK, 0 rows affected (0.00 sec)
      8. mysql> SELECT * FROM account;
      9. +----+--------+---------+
      10. | id | name | balance |
      11. +----+--------+---------+
      12. | 1 | 狗哥 | 1 |
      13. | 2 | 猫爷 | 2 |
      14. +----+--------+---------+
      15. 2 rows in set (0.00 sec)
      16. mysql> UPDATE account SET balance = balance + 1 WHERE id = 2; # 更新错了
      17. Query OK, 1 row affected (0.00 sec)
      18. Rows matched: 1 Changed: 1 Warnings: 0
      19. mysql> ROLLBACK TO s1; # 回滚到保存点s1处
      20. Query OK, 0 rows affected (0.00 sec)

      20. redo日志

      redo日志是什么

      简单来说,如果想要保证数据库系统的持久性,最简单粗暴的办法就是在事物提交完成后,把修改的所有页面都刷新到磁盘上。
      但是有缺陷: :::tips
  • 刷新一个完整的页太浪费:可能只改变了几个字节

  • 随机IO刷新起来比较慢 ::: 我们只是想让已经提交了的事务对数据库中数据所做的修改永久生效,即使后来系统崩溃,在重启后也能把这种修改恢复出来。所以我们其实没有必要在每次事务提交时就把该事务在内存中修改过的全部页面刷新到磁盘,只需要把修改了哪些东西记录一下就好。
    redo日志记录的内容: :::tips 修改了哪里,偏移多少,页号多少,哪个表空间…… :::

  • redo日志占用空间非常小

  • redo日志是顺序写入磁盘的。

    redo日志的格式

image.png

  • type:redo日志的类型
  • space ID: 表空间的ID
  • page number: 页号
  • data:该redo日志的具体内容。

    简单redo日志类型

    一个例子: :::tips 之前介绍过:如果我们没有为某个表显式的定义主键,并且表中也没有定义Unique 键,那么 InnoDB 会自动的为表添加一个称之为 row_id 的隐藏列作为主键。
    row_id赋值的过程如下:
  1. 服务器会在内存中维护一个全局变量,每当向某个包含隐藏的 row_id 列的表中插入一条记录时,就会把该变量的值当作新记录的 row_id 列的值,并且把该变量自增1。
  2. 每当这个变量的值为256的倍数时,就会将该变量的值刷新到系统表空间的页号为 7 的页面中一个称之为Max Row ID的属性处。
  3. 当系统启动时,会将上边提到的 Max Row ID 属性加载到内存中,将该值加上256之后赋值给我们前边提到的全局变量。 ::: 比如说给MAXROWID写入偏移量的过程,实在buffer Pool中进行的,比如给这种修改添加一个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 ):表示在页面的某个偏移量处写入一串数据。

比如这里MAXROWID是8所以用MLOG_8BYTE类型的redo日志
image.png

复杂一些的redo日志类型

实际操作中可能一条语句会造成多个页面中有内容被更改,比如一条插入语句(INSERT),不仅MAXROWID会更新,B+树也会更新,如果发生页分裂的话更新的内容会更多。
如果还用简单的物理redo日志来记录,有两种方案:

  • 在每个修改的地方都记录一条redo日志
  • 将整个页面从第一个被修改的字节到最后一个被修改的字节之间当作一条redo日志

这两种方案都是浪费很大且很麻烦。于是设计了一些新的redo日志类型。

  • MLOG_REC_INSERT (对应的十进制数字为 9 ):表示插入一条使用非紧凑行格式的记录时的 redo 日志类型。
    • 例如Redundant就是个非紧凑的。
  • MLOG_COMP_REC_INSERT (对应的十进制数字为 38 ):表示插入一条使用紧凑行格式的记录时的 redo 日志类型。
  • MLOG_COMP_PAGE_CREATE ( type 字段对应的十进制数字为 58 ):表示创建一个存储紧凑行格式记录的页
  • 面的 redo 日志类型。
  • MLOG_COMP_REC_DELETE ( type 字段对应的十进制数字为 42 ):表示删除一条使用紧凑行格式记录的redo 日志类型。

还有很多类型,不一一列举。

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

  1. 物理层面看,这些日志都指明了对哪个表空间的哪个页进行了修改。
  2. 逻辑层面看,在系统奔溃重启时,并不能直接根据这些日志里的记载,将页面内的某个偏移量处恢复成某个数据,而是需要调用一些事先准备好的函数,执行完这些函数后才可以将页面恢复成系统奔溃前的样子。

例如以MLOG_COMP_REC_INSERT类型的redo日志为例,其记录格式如下
image.png
相关参数解释:

  • n_uniques:n_uniques 的值的含义是在一条记录中,需要几个字段的值才能确保记录的唯一性,这样当插入一条记录时就可以按照记录的前 n_uniques 个字段进行排序。例如聚簇索引只需要用主键即可。
  • field1_len~fieldn_len:记录的若干个字段占用的存储空间的大小,不管类型是啥都要记下来!
  • offset:前一条记录在页面中的地址(为了维护next_record的属性(需要修改前一个记录))
  • end_seg_len:为了节省 redo 日志存储空间,通过此值可以间接计算处记录占用空间的大小。

所谓的逻辑日志 :::tips 很显然这个类型为 MLOG_COMP_REC_INSERT 的 redo 日志并没有记录 PAGE_N_DIR_SLOTS 的值修改为了啥,PAGE_HEAP_TOP 的值修改为了啥, PAGE_N_HEAP 的值修改为了啥等等这些信息,而只是把在本页面中插入一条记录所有必备的要素记了下来,之后系统奔溃重启时,服务器会调用相关向某个页面插入一条记录的那个函数,而redo 日志中的那些数据就可以被当成是调用这个函数所需的参数,在调用完该函数后,页面中的PAGE_N_DIR_SLOTS 、 PAGE_HEAP_TOP 、 PAGE_N_HEAP 等等的值也就都被恢复到系统奔溃前的样子了。 :::

Mini-Transaction

以组的形式写入redo日志

因为如之前所说,一个语句在执行过程中可能修改多个页面,因此在执行过程中的redo日志被划分成若干个不可分割的组,例如:

  • 更新 Max Row ID 属性时产生的 redo 日志是不可分割的。
  • 向聚簇索引对应 B+ 树的页面中插入一条记录时产生的 redo 日志是不可分割的。
  • 向某个二级索引对应 B+ 树的页面中插入一条记录时产生的 redo 日志是不可分割的。

如何定义不可分割?我认为也是一种原子性的体现,这些redo日志要么全部重做,要么全部不重做。
比如插入一条记录

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

image.png

  1. 情况二:该数据页剩余的空闲空间不足,遇到这种情况要进行所谓的 页分裂 操作,也就是新建一个叶子节点,然后把原先数据页中的一部分记录复制到这个新的数据页中,然后再把记录插入进去,把这个叶子节点插入到叶子节点链表中,最后还要在内节点中添加一条 目录项记录 指向这个新创建的页面。很显然,这个过程要对多个页面进行修改,也就意味着会产生多条 redo 日志,我们把这种情况称之为 悲观插入 。

image.png
对于 悲观插入 来说,由于需要新申请数据页,还需要改动一些系统页面,比方说要修改各种段、区的统计信息信息,各种链表的统计信息等等,反正总共需要记录的 redo 日志有二、三十条。因为插入一条记录的过程必须是原子的,不能插入一半后就停止了,同理,redo记录是为了让系统在崩溃后能恢复到崩溃前的状态,他也应该是完整的、原子的,不能恢复成一种不正确的状态。
因此规定在执行这些需要保证原子性的操作时必须以 组 的形式来记录的 redo 日志,在进行系统奔溃重启恢复时,针对某个组中的 redo 日志,要么把全部的日志都恢复掉,要么一条也不恢复。
如何做到需要分情况讨论:

  1. 有的需要保证原子性的操作会生成多条 redo 日志,在该组中的最后一条 redo 日志后边加上一条特殊类型的 redo 日志,该类型名称为MLOG_MULTI_REC_END , type 字段对应的十进制数字为 31 ,该类型的 redo 日志结构很简单,只有一个 type 字段。

image.png

  1. 所以某个需要保证原子性的操作产生的一系列 redo 日志必须要以一个类型为 MLOG_MULTI_REC_END 结尾。
  2. 这样在系统奔溃重启进行恢复时,只有当解析到类型为MLOG_MULTI_REC_END 的 redo 日志,才认为解析到了一组完整的 redo 日志,才会进行恢复。否则的话直接放弃前边解析到的 redo 日志。
    1. 有的需要保证原子性的操作只生成一条 redo 日志,如果 type 字段的第一个比特位为 1 ,代表该需要保证原子性的操作只产生了单一的一条 redo 日志,否则表示该需要保证原子性的操作产生了一系列的 redo 日志。

image.png

Mini-Transaction的概念

:::tips 对底层页面中的一次原子访问的过程称之为一个 Mini-Transaction ,简称 mtr ,比如向某个索引对应的 B+ 树中插入一条记录的过程算是一个 Mini-Transaction 。通过上边的叙述我们也知道,一个所谓的 mtr 可以包含一组 redo 日志,在进行奔溃恢复时这一组 redo 日志作为一个不可分割的整体。 ::: 一个事务可以包含若干条语句,每一条语句其实是由若干个 mtr 组成,每一个 mtr 又可以包含若干条 redo 日志
fig0301.svg

redo日志写入的过程

redo log block

通过 mtr 生成的 redo 日志都放在了大小为 512字节的 页 中。为了和我们前边提到的表空间中的页做区别,我们这里把用来存储 redo 日志的页称为 block
image.png
真正的 redo 日志都是存储到占用 496 字节大小的 log block body 中,图中的 log block header 和 log block trailer 存储的是一些管理信息。

  • LOG_BLOCK_HDR_NO :每一个block都有一个大于0的唯一标号,本属性就表示该标号值。
  • LOG_BLOCK_HDR_DATA_LEN :表示block中已经使用了多少字节,初始值为 12 (因为 log block body 从第12个字节处开始)。
  • LOG_BLOCK_FIRST_REC_GROUP :一条 redo 日志也可以称之为一条 redo 日志记录( redo log record ),一个 mtr 会生产多条 redo 日志记录,这些 redo 日志记录被称之为一个 redo 日志记录组( redo log record group )。 LOG_BLOCK_FIRST_REC_GROUP 就代表该block中第一个 mtr 生成的 redo 日志记录组的偏移量(其实也就是这个block里第一个 mtr 生成的第一条 redo 日志的偏移量)。
  • LOG_BLOCK_CHECKPOINT_NO :表示所谓的 checkpoint 的序号

    redo日志缓冲区

    写入 redo 日志时也不能直接直接写到磁盘上,实际上在服务器启动时就向操作系统申请了一大片称之为 redo log buffer 的连续内存空间,即redo日志缓冲区
    image.png
    向 log buffer 中写入 redo 日志的过程是顺序的,也就是先往前边的block中写,当该block的空闲空间用完之后再往下一个block中写。通过一个全局变量buf_free来确定空闲block的起始位置
    image.png
  1. 并不是每生成一条 redo 日志,就将其插入到 log buffer 中,而是每个 mtr 运行过程中产生的日志先暂时存到一个地方,当该 mtr 结束的时候,将过程中产生的一组 redo 日志再全部复制到 log buffer 中。
  2. 不同的事务可能是并发执行的。每当一个 mtr 执行完成时,伴随该 mtr 生成的一组 redo 日志就需要被复制到 log buffer 中,也就是说不同事务的 mtr 可能是交替写入 log buffer 的的

    21. redo日志(下)

    redo日志文件

    redo日志刷盘时机

  3. log buffer 空间不足时

  4. 事务提交时
  5. 后台有一个线程,大约每秒都会刷新一次 log buffer 中的 redo 日志到磁盘。
  6. 正常关闭服务器时
  7. 做所谓的 checkpoint 时

    redo日志文件组

    :::tips MySQL 的数据目录(使用 SHOW VARIABLES LIKE ‘datadir’ 查看)下默认有两个名为 ib_logfile0 和 ib_logfile1 的文件, log buffer 中的日志默认情况下就是刷新到这两个磁盘文件中。 ::: 磁盘上的 redo 日志文件不只一个,而是以一个 日志文件组 的形式出现的。这些文件 以 ib_logfile[数字] ( 数字 可以是 0 、 1 、 2 …)的形式进行命名。在将 redo 日志写入 日志文件组 时,是 从 ib_logfile0 开始写,如果 ib_logfile0 写满了,就接着 ib_logfile1 写。如果写到最后一个文件那就重新转到 ib_logfile0 继续写。
    fig0301.svg

    redo日志文件格式

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

redo 日志文件组中的每个文件大小都一样,格式也一样,都是由两部分组成:

  1. 前2048个字节,也就是前4个block是用来存储一些管理信息的
  2. 从第2048字节往后是用来存储 log buffer 中的block镜像的。

image.png

  • log file header :描述该 redo 日志文件的一些整体属性,具体如下 | 属性名 | 长度(字节) | 描述 | | —- | —- | —- | | LOG_HEADER_FORMAT | 4 | redo 日志的版本,在 MySQL 5.7.21 中该值永远为1 | | LOG_HEADER_PAD1 | 4 | 做字节填充用的,没什么实际意义, | | LOG_HEADER_START_LSN | 8 | 标记本 redo 日志文件开始的LSN值,也就是文件偏移量为2048字节初对应的 LSN值。 | | LOG_HEADER_CREATOR | 32 | 一个字符串,标记本 redo 日志文件的创建者是谁。正常运行时该值为 MySQL 的版本号,比如: “MySQL 5.7.21” ,使用mysqlbackup 命令创建的 redo 日志文件的该值为 “ibbackup” 和创建时间。 | | LOG_BLOCK_CHECKSUM | 4 | 本block的校验值,所有block都有,我们不关心 |

  • checkpoint1 :记录关于 checkpoint 的一些属性

  • checkpoint2 :结构和 checkpoint1 一样。

image.png

Log Sequeue Number

日志的数量会不断增长,为了记录有多少日志生成了,定义了一个变量:log sequeue number,LSN。
因为日志是按照MTR为单位,一组一组写入的在统计 LSN 的增长量时,是按照实际写入的日志量加上占用的 log block header 和 log block trailer 来计算的。

  • 当系统刚启动时,初始化log buffer,就会指向第一个 block 的偏移量为12字节。

image.png

  • 如果某个 mtr 产生的一组 redo 日志占用的存储空间比较小,也就是待插入的block剩余空闲空间能容纳这个 mtr 提交的日志时, LSN增长的量就是该 mtr 生成的 redo 日志占用的字节数
  • 如果某个 mtr 产生的一组 redo 日志占用的存储空间比较大,也就是待插入的block剩余空闲空间不足以容纳这个 mtr 提交的日志时, lsn 增长的量就是该 mtr 生成的 redo 日志占用的字节数加上额外占用的 log block header 和 log block trailer 的字节数

image.png :::warning 每一组由mtr生成的redo日志都有一个唯一的LSN值与其对应,LSN值越小,说明redo日志产生的越早。 :::

flushed_to_disk_lsn

redo 日志是首先写到 log buffer 中,之后才会被刷新到磁盘上的 redo 日志文件。所以提出了一个称之为 buf_next_to_write 的全局变量,标记当前 log buffer 中已经有哪些日志被刷新到磁盘中了
image.png
一开始二者是一样的,但是随着把MTR不断从log buffer刷入log file(从内存刷入磁盘),flushed_to_disk_lsn和LSN会不断产生差距

lsn值和redo日志文件偏移量的对应关系

因为 lsn 的值是代表系统写入的 redo 日志量的一个总和,一个 mtr 中产生多少日志, lsn 的值就增加多少(当然有时候要加上 log block header 和 log block trailer 的大小),这样 mtr 产生的日志写到磁盘中时,很容易计算某一个 lsn 值在 redo 日志文件组中的偏移量。
image.png

flush链表中的LSN

在一个mtr结束时

  1. 会将一组redo日志写入到log buffer中
  2. 会把mtr执行过程中修改的页面加入到flush链表中

当第一次修改某个缓存在 Buffer Pool 中的页面时,就会把这个页面对应的控制块插入到 flush链表 的头部,之后再修改该页面时由于它已经在 flush 链表中了,就不再次插入了。也就是说flush链表中的脏页是按照页面的第一次修改时间从大到小进行排序的
在这个过程中会在缓存页对应的控制块中记录两个关于页面何时修改的属性:

  1. oldest_modification :如果某个页面被加载到 Buffer Pool 后进行第一次修改,那么就将修改该页面的mtr 开始时对应的 lsn 值写入这个属性。
  2. newest_modification :每修改一次页面,都会将修改该页面的 mtr 结束时对应的 lsn 值写入这个属性。也就是说该属性表示页面最近一次修改后对应的系统 lsn 值。 :::warning 注意,一个是开始时,一个是结束时 :::
  • 每次新插入到 flush链表 中的节点都是被放在了头部,也就是说 flush链表 中前边的脏页修改的时间比较晚,后边的脏页修改时间比较早
  • 被多次更新的页面不会重复插入到flush链表中,但是会更新newest_modification属性的值。

    checkpoint

    redo日志文件组是有限的,一直写会造成“追尾”
    redo日志只是为了系统奔溃后恢复脏页用的,如果对应的脏页已经刷新到了磁盘,也就是说即使现在系统奔溃,那么在重启后也用不着使用redo日志恢复该页面了,所以该redo日志也就没有存在的必要了,那么它占用的磁盘空间就可以被后续的redo日志所重用。也就是说:判断某些redo日志占用的磁盘空间是否可以覆盖的依据就是它对应的脏页是否已经刷新到磁盘里。

    例子

    image.png
    如图,如果页a还没有被刷新到磁盘里时,虽然说mtr1已经被刷到磁盘里了,但是因为flush链表中页a还没被刷到磁盘,所以不可以覆盖
    image.png
    当flush链表中页a已经被刷到磁盘后,这时mtr1便是可以覆盖的了,因为即使崩溃了也能回到此时的状态。

checkpoint_lsn

如何确定哪些redo日志时可以被覆盖的?

  • 全局变量 checkpoint_lsn 来代表当前系统中可以被覆盖的 redo 日志总量是多少,这个变量初始值也是8704 。
  • 比方说现在 页a 被刷新到了磁盘, mtr_1 生成的 redo 日志就可以被覆盖了,所以我们可以进行一个增加checkpoint_lsn 的操作,我们把这个过程称之为做一次 checkpoint 。分为两步:
  1. 步骤一:计算一下当前系统中可以被覆盖的 redo 日志对应的 lsn 值最大是多少。

redo 日志可以被覆盖,意味着它对应的脏页被刷到了磁盘,只要我们计算出当前系统中被最早修改的脏页对应的 oldest_modification 值,那凡是在系统lsn值小于该节点的oldest_modification值时产生的redo日志都是可以被覆盖掉的,我们就把该脏页的 oldest_modification 赋值给 checkpoint_lsn 。

  1. 步骤二:将 checkpoint_lsn 和对应的 redo 日志文件组偏移量以及此次 checkpoint 的编号写到日志文件的

管理信息(就是 checkpoint1 或者 checkpoint2 )中。

:::tips 上述关于checkpoint的信息只会被写到日志文件组的第一个日志文件的管理信息中。不过我们是存储到 checkpoint1 中还是 checkpoint2 中呢?
规定,当 checkpoint_no 的值是偶数时,就写到 checkpoint1 中,是奇数时,就写到checkpoint2 中。 ::: 做完之后的log file:
image.png

查看系统中的各种LSN值

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

  1. mysql> SHOW ENGINE INNODB STATUS\G
  2. (...省略前边的许多状态)
  3. LOG
  4. ---
  5. Log sequence number 124476971
  6. Log flushed up to 124099769
  7. Pages flushed up to 124052503
  8. Last checkpoint at 124052494
  9. 0 pending log flushes, 0 pending chkp writes
  10. 24 log i/o's done, 2.00 log i/o's/second
  11. ----------------------
  12. (...省略后边的许多状态)

崩溃恢复

如果系统崩溃了,怎么利用redo log来恢复?

确定恢复的起点

  1. checkpoint_lsn 之前的 redo 日志都可以被覆盖,也就是说这些 redo 日志对应的脏页都已经被刷新到磁盘中了,既然它们已经被刷盘,我们就没必要恢复它们了
  2. 对于 checkpoint_lsn 之后的 redo 日志,它们对应的脏页可能没被刷盘,也可能被刷盘了,我们不能确定,所以需要从 checkpoint_lsn 开始读取 redo 日志来恢复页面。

:::info 所以要选取最近发生的那次checkpoint的信息。
衡量 checkpoint 发生时间早晚的信息就是所谓的 checkpoint_no ,我们只要把 checkpoint1 和 checkpoint2 这两个block中的 checkpoint_no 值读出来比一下大小,哪个的checkpoint_no 值更大,说明哪个block存储的就是最近的一次 checkpoint 信息。 :::

确定恢复的终点

普通block的 log block header 部分有一个称之为 LOG_BLOCK_HDR_DATA_LEN 的属性,该属性值记录了当前block里使用了多少字节的空间。对于被填满的block来说,该值永远为 512 。如果该属性的值不为 512 ,那么就是它了,它就是此次奔溃恢复中需要扫描的最后一个block。
image.png

怎么恢复

简单的想:按照顺序执行即可
但是这样会造成很多随机IO,很慢。有优化方式:

  • 哈希表

fig0301.svg

根据表空间和页号来做哈希表,同一个页的修改一起修改,这样能避免随机IO

  • 跳过已经刷新的页面

有的页面实际上在崩溃前已经被刷新了,在 File Header 里有一个称之为FIL_PAGE_LSN 的属性,该属性记载了最近一次修改页面时对应的 lsn 值(其实就是页面控制块中的newest_modification 值)。如果在做了某次 checkpoint 之后有脏页被刷新到磁盘中,那么该页对应的FIL_PAGE_LSN 代表的 lsn 值肯定大于 checkpoint_lsn 的值,凡是符合这种情况的页面就不需要重复执行。

22. undo日志

事务回滚的需求

事务需要保持原子性,即要么做完,要么不做,但是实际上会出现做了一半的情况,于是需要回滚到操作之前的状态,因此有undo日志。

和redo log的区别是啥?undo log是为了保证事务原子性,redo log是为了系统崩溃后能复原。

:::info 每当我们要对一条记录做改动时(这里的 改动 可以指 INSERT 、 DELETE 、 UPDATE ),都需要留一手 —— 把回滚时所需的东西都给记下来。这些为了回滚而记录的这些东西称之为撤销日志,英文名为 undo log ::: 由于查询操作(SELECT)并不会修改任何用户记录,所以在查询操作执行时,并不需要记录相应的 undo日志 。在真实的 InnoDB 中, undo日志 其实并不像我们上边所说的那么简单,不同类型的操作产生的 undo日志 的格式也是不同的

事务id

为了能够唯一地确定事务,或者说区分事务,对于各个事务要给他一个标签:事务id
事务可以是只读事务和读写事务,要区分对待。

给事务分配id的时机

  1. 对于只读事务来说,只有在它第一次对某个用户创建的临时表执行增、删、改操作时才会为这个事务分配一个事务id ,否则的话是不分配 事务id 的。
  2. 对于读写事务来说,只有在它第一次对某个表(包括用户创建的临时表)执行增、删、改操作时才会为这个事务分配一个 事务id ,否则的话也是不分配 事务id 的。
    1. 有的时候虽然我们开启了一个读写事务,但是在这个事务中全是查询语句,并没有执行增、删、改的语句,那也就意味着这个事务并不会被分配一个 事务id 。

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

trx_id隐藏列

聚簇索引的记录除了会保存完整的用户数据以外,而且还会自动添加名为trx_idroll_pointer的隐藏列,如果用户没有在表中定义主键以及UNIQUE键,还会自动添加一个名为row_id的隐藏列。
fig0301.svg
trx_id 列是某个对这个聚簇索引记录做改动的语句所在的事务对应的 事务id

undo日志的格式

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

INSERT操作对应的undo日志

fig0301.svg
当我们向表中插入一条记录时会有 乐观插入 和 悲观插入 的区分,但是不管怎么插入,最终导致的结果就是这条记录被放到了一个数据页中。如果希望回滚这个插入操作,那么把这条记录删除就好了,也就是说在写对应的 undo 日志时,主要是把这条记录的主键信息记上。
如果记录中的主键只包含一个列,那么在类型为 TRX_UNDO_INSERT_REC 的 undo日志 中只需要把该列占用的存储空间大小和真实值记录下来,如果记录中的主键包含多个列,那么每个列占用的存储空间大小和对应的真实值都需要记录下来。形成一种**<len,value>**的键值对

roll pointer隐藏列的含义

roll_pointer本质上就是一个指向记录对应的 undo日志 的一个指针。
fig0301.svg

  • 记录被存储到了类型为 FIL_PAGE_INDEX 的页面中
  • undo日志 被存放到了类型为 FIL_PAGE_UNDO_LOG 的页面中。
  • roll_pointer将他们链接

DELETE操作对应的undo日志

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

fig0301.svg
在内存中存储的情况如上图,如果我们要删除一条记录,实际上分为两个阶段

  • 阶段一 delete mark
    • 仅仅将记录的 delete_mask 标识位设置为 1 ,其他的不做修改(其实会修改记录的 trx_id 、roll_pointer 这些隐藏列的值)。此时记录处于一个 中间状态,在删除语句所在的事务提交之前,被删除的记录一直都处于这种所谓的 中间状态
  • 阶段二 purge
    • 当该删除语句所在的事务提交之后,会有专门的线程后来真正的把记录删除掉。所谓真正的删除就是把该记录从 正常记录链表 中移除,并且加入到 垃圾链表 中,然后还要调整一些页面的其他信息

fig0301.svg :::warning 在删除语句所在的事务提交之前,只会经历 阶段一 ,也就是 delete mark 阶段(提交之后我们就不用回滚了,所以只需考虑对删除操作的 阶段一 做的影响进行回滚) :::

TRX_UNDO_DEL_MARK_REC

对应的有一种 TRX_UNDO_DEL_MARK_REC 类型的 undo日志
fig0301.svg

  • 在对一条记录进行 delete mark 操作前,需要把该记录的旧的 trx_id 和 roll_pointer 隐藏列的值都给记到对应的 undo日志 中来。这样有一个好处,那就是可以通过 undo日志 的 old roll_pointer 找到记录在修改之前对应的 undo 日志。

这个链表就称之为 版本链
fig0301.svg

  • 类型为 TRX_UNDO_DEL_MARK_REC 的 undo 日志还多了一个 索引列各列信息 的内容,包括该列在记录中的位置(用 pos 表示),该列占用的存储空间大小(用 len 表示),该列实际值(用 value 表示)。所以 索引列各列信息 存储的内容实质上就是<pos, len, value>的一个列表。这部分信息主要是用在事务提交后,对该 中间状态记录 做真正删除的阶段二,也就是 purge 阶段中使用的

UPDATE操作对应的undo日志

InnoDB 对更新主键不更新主键这两种情况有截然不同的处理方案

不更新主键的情况

又可以细分为被更新的列占用的存储空间不发生变化发生变化的情况。

  • 就地更新(in-place update)如果更新后的列和更新前的列占用的存储空间都一样大,那么就可以进行 就地更新 ,也就是直接在原记录的基础上修改对应列的值。
  • 先删除掉旧记录,再插入新记录:在不更新主键的情况下,如果有任何一个被更新的列更新前和更新后占用的存储空间大小不一致,那么就需要先把这条旧的记录从聚簇索引页面中删除掉,然后再根据更新后列的值创建一条新的记录插入到页面中
    • 我们这里所说的 删除 并不是 delete mark 操作,而是真正的删除掉

针对 UPDATE 不更新主键的情况(包括上边所说的就地更新和先删除旧记录再插入新记录),有一种 TRX_UNDO_UPD_EXIST_REC 的 undo日志, 其大部分属性和我们介绍过的 TRX_UNDO_DEL_MARK_REC 类型的 undo日志 是类似的
fig0301.svg

  • n_updated 属性表示本条 UPDATE 语句执行后将有几个列被更新,后边跟着的<pos, old_len, old_value>分别表示被更新列在记录中的位置、更新前该列占用的存储空间大小、更新前该列的真实值
  • 如果在 UPDATE 语句中更新的列包含索引列,那么也会添加 索引列各列信息 这个部分,否则的话是不会添加这个部分的。

    更新主键的情况

    如果我们更新了某条记录的主键值,意味着这条记录在聚簇索引中的位置将会发生改变,针对 UPDATE 语句中更新了记录主键值的这种情况, InnoDB 在聚簇索引中分了两步处理

  • 将旧记录进行 delete mark 操作:这里是delete mark操作!也就是说在 UPDATE语句所在的事务提交前,对旧记录只做一个 delete mark 操作,在事务提交后才由专门的线程做purge操作,把它加入到垃圾链表中。

这里一定要和我们上边所说的在不更新记录主键值时,先真正删除旧记录,再插入新记录的方式区分开

  • 根据更新后各列的值创建一条新记录,并将其插入到聚簇索引中(需重新定位插入的位置)。 :::warning 在对该记录进行 delete mark 操作前,会记录一条类型为TRX_UNDO_DEL_MARK_REC 的 undo日志 ;之后插入新记录时,会记录一条类型为 TRX_UNDO_INSERT_REC 的 undo日志 ,也就是说每对一条记录的主键值做改动时,会记录2条 undo日志 。 :::

    23. undo日志(下)

    FIL_PAGE_UNDO_LOG

    表空间其实是由许许多多的页面构成的,页面默认大小为 16KB 。这些页面有
    不同的类型,其中有一种称之为FIL_PAGE_UNDO_LOG 类型的页面是专门用来存储 undo日志 的
    fig0301.svg
    Undo Page Header 是 Undo页面 所特有的,其结构如下:
    fig0301.svg
  1. TRX_UNDO_PAGE_TYPE :本页面准备存储什么种类的 undo日志 。
  2. TRX_UNDO_PAGE_START :表示在当前页面中是从什么位置开始存储 undo日志 的,或者说表示第一条 undo日志 在本页面中的起始偏移量。
  3. TRX_UNDO_PAGE_FREE :与上边的 TRX_UNDO_PAGE_START 对应,表示当前页面中存储的最后一条 undo 日志结束时的偏移量,或者说从这个位置开始,可以继续写入新的 undo日志 。
  4. TRX_UNDO_PAGE_NODE :代表一个 List Node 结构

    Undo页面链表

    单个事务中的Undo页面链表

    fig0301.svg
  • 第一个页称为first undo page,除了记录 Undo Page Header 之外,还会记录其他的一些管理信息
  • 在一个事务执行过程中,可能混着执行 INSERT 、 DELETE 、 UPDATE 语句,也就意味着会产生不同类型的 undo日志 。所以在一个事务执行过程中就可能需要2个 Undo页面 的链表,一个称之为 insert undo链表 ,另一个称之为 update undo链表
  • 此外对普通表临时表的记录改动时产生的 undo日志 要分别记录,所以在一个事务中最多有4个以 Undo页面 为节点组成的链表

fig0301.svg
按需分配,啥时候需要啥时候再分配,不需要就不分配。

多个事务中的Undo页面链表

为了尽可能提高 undo日志 的写入效率,不同事务执行过程中产生的undo日志需要被写入到不同的Undo页面链表中

undo日志具体写入过程

段(Segment)的概念

段的概念是为了让页面在物理上尽可能连续。每一个段对应一个 INODE Entry 结构,这个 INODE Entry 结构描述了这个段的各种信息,比如段的 ID ,段内的各种链表基节点,零散页面的页号有哪些等信息。为了定位一个 INODE Entry ,有一个 Segment Header 的结构。
fig0301.svg

Undo Log Segment Header

每一个 Undo页面 链表都对应着一个 段 ,称之为 Undo Log Segment。也就是说链表中的页面都是从这个段里边申请的。 :::warning 所以在 Undo页面 链表的第一个页面,也就是上边提到的 first undo page 中设计了一个称之为 Undo Log Segment Header 的部分,这个部分中包含了该链表对应的段的 segment header 信息以及其他的一些关于这个段的信息 ::: fig0301.svg

  • TRX_UNDO_STATE :本 Undo页面 链表处在什么状态:
    • TRX_UNDO_ACTIVE :活跃状态,也就是一个活跃的事务正在往这个段里边写入 undo日志 。
    • TRX_UNDO_CACHED :被缓存的状态。处在该状态的 Undo页面 链表等待着之后被其他事务重用。
    • TRX_UNDO_TO_FREE
    • TRX_UNDO_TO_PURGE
    • TRX_UNDO_PREPARED
  • TRX_UNDO_LAST_LOG :本 Undo页面 链表中最后一个 Undo Log Header 的位置。
  • TRX_UNDO_FSEG_HEADER :本 Undo页面 链表对应的段的 Segment Header信息
  • TRX_UNDO_PAGE_LIST : Undo页面 链表的基节点

    Undo Log Header

    事务向undo页面中插入记录是紧密相连的,一条接着一条不留空隙。
    同一个事务向一个 Undo页面 链表中写入的 undo日志 算是一个,在一开始会记录这个组的属性,此结构称为Undo Log Header
    image.png

  • TRX_UNDO_TRX_ID :生成本组 undo日志 的事务 id 。

  • TRX_UNDO_LOG_START :表示本组 undo 日志中第一条 undo日志 的在页面中的偏移量。
  • TRX_UNDO_NEXT_LOG:下一组的 undo日志 在页面中开始的偏移量。
  • TRX_UNDO_PREV_LOG :上一组的 undo日志 在页面中开始的偏移量。

    小结

    对于没有被重用的 Undo页面 链表来说,链表的第一个页面,也就是 first undo page 在真正写入 undo日志前,会填充 Undo Page HeaderUndo Log Segment HeaderUndo Log Header 这3个部分,之后才开始正式写入 undo日志 。对于其他的页面来说,也就是 normal undo page 在真正写入 undo日志 前,只会填充 Undo Page Header 。链表的 List Base Node 存放到 first undo page 的 Undo Log Segment Header部分, List Node 信息存放到每一个 Undo页面 的 undo Page Header 部分fig0301.svg

    重用undo页面

    大部分事务执行过程中可能只修改了一条或几条记录,针对某个 Undo页面 链表只产生了非常少的 undo日志 ,这些 undo日志 可能只占用很少存储空间,每开启一个事务就新创建一个 Undo页面 链表(虽然这个链表中只有一个页面)来存储极少的 undo日志 太浪费了 :::warning 事务提交后在某些情况下可以重用该事务的undo page list: :::

  • 该链表中只包含一个 Undo页面 。

  • 该 Undo页面 已经使用的空间小于整个页面空间的3/4。
  • insert undo链表:可直接覆盖

    why其他情况就不符合了?因为已经用了太多太复杂的话重用维护起来会很麻烦

fig0301.svg

  • update undo链表:在一个事务提交后,它的 update undo链表 中的 undo日志 也不能立即删除掉(这些日志用于MVCC

fig0301.svg

回滚段

一个事务在执行过程中最多可以分配4个 Undo页面 链表,在同一时刻不同事务拥有的 Undo页面 链表是不一样的,同一时刻系统里其实可以有许许多多个 Undo页面 链表存在。为了更好的管理这些链表,设计了一个称之为 Rollback Segment Header 的页面 :::info 此页面中存了undo list的first undo page(也就是相当于存了链表的第一个节点,通过头节点可以访问这个链表)称之为undo slot,大概结构是这样的
fig0301.svg
TRX_RSEG_UNDO_SLOTS就是存undo slots的地方。
这个结构虽然是一个页,但是单独开辟一个段来存储,即为所谓的回滚段 :::

从回滚段中申请Undo页面链表

  1. 正常情况下,如果回滚段中没有任何undo页面链表,那么所有的undo slots被设置为一个特殊数值FIL_NULL:0xFFFFFFFF表示不指向任何数值。
  2. 当开始分配undo页面链表时,先找第一个undo slot,如果是 FIL_NULL ,那么在表空间中新创建一个段(也就是 Undo Log Segment),然后从段里申请一个页面作为 Undo页面 链表的 first undo page ,然后把该 undo slot 的值设置为刚刚申请的这个页面的地址,这样也就意味着这个 undo slot 被分配给了这个事务。
  3. 如果不是FIL_NULL,那就跳到下一个 undo slot,重复上边的步骤。

fig0301.svg
当一个事务提交了,他的undo slot有两种选择:

  1. 如果该 undo slot 指向的 Undo页面 链表符合被重用的条件,该 undo slot 就处于被缓存的状态,该 Undo页面 链表的 TRX_UNDO_STATE 属性会被设置为 TRX_UNDO_CACHED。 :::warning 被缓存的 undo slot 都会被加入到一个链表,根据对应的 Undo页面 链表的类型不同,也会被加入到不同的链表: insert undo cached链表&& update undo cached链表 ::: 如果该 undo slot 指向的 Undo页面 链表不符合被重用的条件,那么针对该 undo slot 对应的 Undo页面 链表类型不同,也会有不同的处理:

  2. 如果对应的 Undo页面 链表是 insert undo链表 ,则该 Undo页面 链表的 TRX_UNDO_STATE 属性会被设置为 TRX_UNDO_TO_FREE ,之后该 Undo页面 链表对应的段会被释放掉

  3. 如果对应的 Undo页面 链表是 update undo链表 ,则该 Undo页面 链表的 TRX_UNDO_STATE 属性会被设置为 TRX_UNDO_TO_PRUGE ,则会将该 undo slot 的值设置为 FIL_NULL ,然后将本次事务写入的一组undo 日志放到所谓的 History链表

一个回滚段最多支持1024个undo slot,实际上在InnoDB中有128个回滚段 也就相当于有了 128 × 1024 = 131072 个 undo slot 。假设一个读写事务执行过程中只分配 1 个 Undo页面 链表,那么就可以同时支持 131072 个读写事务并发执行

自然就要有128个 Rollback Segment Header 页面,在系统表空间的第 5 号页面的某个区域也有128个格子,格子的内容就是Rollback Segment Header所在的位置
fig0301.svg
因为不同的回滚段可能在不同的表空间中,因此需要space ID
fig0301.svg
实际结构如上!

回滚段的分类

这128个回滚段被分成了两类:

  1. 第 0, 33~127 号回滚段属于一类:修改普通表时undo log存在这,其中0号必须在系统表空间中,其他随意
  2. 第 1~32 号回滚段属于一类。这些回滚段必须在临时表空间,如果一个事务在执行过程中由于对临时表的记录做了改动需要分配 Undo页面 链表时,必须从这一类的段中分配相应的 undo slot

为何要分临时表和普通表?
在修改针对普通表的回滚段中的Undo页面时,需要记录对应的redo日志,而修改针对临时表的回滚段中的Undo页面时,不需要记录对应的redo日志。

为事务分配Undo页面链表详细过程

  1. 首先会到系统表空间的第 5 号页面中分配一个回滚段。一旦某个回滚段被分配给了这个事务,那么之后该事务中再对普通表的记录做改动时,就不会重复分配了。
  2. 这个回滚段的两个 cached链表 有没有已经缓存了的 undo slot,如果有缓存的 undo slot ,那么就把这个缓存的 undo slot 分配给该事务。
  3. 如果没有缓存的 undo slot 可供分配,那么就要到 Rollback Segment Header 页面中找一个可用的 undo slot 分配给当前事务。
  4. 找到可用的 undo slot 后,如果该 undo slot 是从 cached链表 中获取的,那么它对应的 Undo Log Segment 已经分配了,否则的话需要重新分配一个 Undo Log Segment ,然后从该 Undo Log Segment 中申请一个页面作为 Undo页面 链表的 first undo page 。
  5. 写入到上边申请的 Undo页面 链表

fig0301.svg