第19章 说过的话就一定要办到-redo日志

事先说明

本文以及接下来的几篇文章将会频繁的使用到我们前面介绍的 InnoDB 记录行格式、页面格式、索引原理、表空间的组成等各种基础知识,如果大家对这些东西理解的不透彻,那么阅读下面的文字可能会有些吃力,为保证您的阅读体验,请确保自己已经掌握了我前面介绍的这些知识。

redo日志是什么

我们知道 InnoDB 存储引擎是以页为单位来管理存储空间的,我们进行的增删改查操作其实本质上都是在访问页面(包括读页面、写页面、创建新页面等操作)。我们前面介绍 Buffer Pool 的时候说过,在真正访问页面之前,需要把在磁盘上的页缓存到内存中的 Buffer Pool 之后才可以访问。但是在介绍事务的时候又强调过一个称之为持久性的特性,就是说对于一个已经提交的事务,在事务提交后即使系统发生了崩溃,这个事务对数据库中所做的更改也不能丢失。但是如果我们只在内存的 Buffer Pool 中修改了页面,假设在事务提交后突然发生了某个故障,导致内存中的数据都失效了,那么这个已经提交了的事务对数据库中所做的更改也就跟着丢失了,这是我们所不能忍受的(想想 ATM 机已经提示狗哥转账成功,但之后由于服务器出现故障,重启之后猫爷发现自己没收到钱,猫爷就被砍死了)。那么如何保证这个持久性呢?一个很简单的做法就是在事务提交完成之前把该事务所修改的所有页面都刷新到磁盘,但是这个简单粗暴的做法有些问题:

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

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

  • 随机 IO 刷起来比较慢

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

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

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

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

  • redo 日志占用的空间非常小

    存储表空间ID、页号、偏移量以及需要更新的值所需的存储空间是很小的,关于 redo 日志的格式我们稍后会详细介绍,现在只要知道一条 redo 日志占用的空间不是很大就好了。

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

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

redo日志格式

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

image 2025 01 13 14 08 12 079

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

  • type:该条 redo 日志的类型。

    MySQL 5.7.21 这个版本中,设计 InnoDB 的大佬一共为 redo 日志设计了 53 种不同的类型,稍后会详细介绍不同类型的 redo 日志。

  • space ID:表空间 ID。

  • page number:页号。

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

简单的redo日志类型

我们前面介绍 InnoDB 的记录行格式的时候说过,如果我们没有为某个表显式的定义主键,并且表中也没有定义 Unique 键,那么 InnoDB 会自动的为表添加一个称之为 row_id 的隐藏列作为主键。为这个 row_id 隐藏列赋值的方式如下:

  • 服务器会在内存中维护一个全局变量,每当向某个包含隐藏的 row_id 列的表中插入一条记录时,就会把该变量的值当作新记录的 row_id 列的值,并且把该变量自增 1。

  • 每当这个变量的值为 256 的倍数时,就会将该变量的值刷新到系统表空间的页号为 7 的页面中一个称之为 Max Row ID 的属性处(我们前面介绍表空间结构时详细说过)。

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

这个 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_8BYTEredo 日志,MLOG_8BYTEredo 日志结构如下所示:

image 2025 01 13 14 17 31 224

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

image 2025 01 13 14 20 14 081

只要将 MLOG_WRITE_STRING 类型的 redo 日志的 len 字段填充上 1、2、4、8 这些数字,就可以分别替代 MLOG_1BYTE、MLOG_2BYTE、MLOG_4BYTE、MLOG_8BYTE 这些类型的 redo 日志,为什么还要多此一举设计这么多类型呢?还不是因为省空间啊,能不写 len 字段就不写 len 字段,省一个字节算一个字节。

复杂一些的redo日志类型

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

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

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

在语句执行过程中,INSERT 语句对所有页面的修改都得保存到 redo 日志中去。这句话说的比较轻巧,做起来可就比较麻烦了,比方说将记录插入到聚簇索引中时,如果定位到的叶子节点的剩余空间足够存储该记录时,那么只更新该叶子节点页面就好,那么只记录一条 MLOG_WRITE_STRING 类型的 redo 日志,表明在页面的某个偏移量处增加了哪些数据就好了么?那就 too young too naive 了~ 别忘了一个数据页中除了存储实际的记录之后,还有什么 File HeaderPage HeaderPage Directory 等等部分(在介绍数据页的章节有详细讲解),所以每往叶子节点代表的数据页里插入一条记录时,还有其他很多地方会跟着更新,比如说:

  • 可能更新 Page Directory 中的槽信息。

  • Page Header 中的各种页面统计信息,比如 PAGE_N_DIR_SLOTS 表示的槽数量可能会更改,PAGE_HEAP_TOP 代表的还未使用的空间最小地址可能会更改,PAGE_N_HEAP 代表的本页面中的记录数量可能会更改,等等,各种信息都可能会被修改。

  • 我们知道在数据页里的记录是按照索引列从小到大的顺序组成一个单向链表的,每插入一条记录,还需要更新上一条记录的记录头信息中的 next_record 属性来维护这个单向链表。

  • 还有别的等等的更新的地方,就不一一介绍了…​

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

image 2025 01 13 14 26 27 918

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

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

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

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

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

正因为上述两种使用物理 redo 日志的方式来记录某个页面中做了哪些修改比较浪费,设计 InnoDB 的大佬本着勤俭节约的初心,提出了一些新的 redo 日志类型,比如:

  • MLOG_REC_INSERT(对应的十进制数字为 9):表示插入一条使用非紧凑行格式的记录时的 redo 日志类型。

  • MLOG_COMP_REC_INSERT(对应的十进制数字为 38):表示插入一条使用紧凑行格式的记录时的 redo 日志类型。

    Redundant 是一种比较原始的行格式,它就是非紧凑的。而 CompactDynamic 以及 Compressed 行格式是较新的行格式,它们是紧凑的(占用更小的存储空间)。

  • MLOG_COMP_PAGE_CREATE(type 字段对应的十进制数字为 58):表示创建一个存储紧凑行格式记录的页面的 redo 日志类型。

  • MLOG_COMP_REC_DELETE(type 字段对应的十进制数字为 42):表示删除一条使用紧凑行格式记录的 redo 日志类型。

  • MLOG_COMP_LIST_START_DELETE(type 字段对应的十进制数字为 44):表示从某条给定记录开始删除页面中的一系列使用紧凑行格式记录的 redo 日志类型。

  • MLOG_COMP_LIST_END_DELETE(type 字段对应的十进制数字为 43):与 MLOG_COMP_LIST_START_DELETE 类型的 redo 日志呼应,表示删除一系列记录直到 MLOG_COMP_LIST_END_DELETE 类型的 redo 日志对应的记录为止。

    我们前面介绍 InnoDB 数据页格式的时候重点强调过,数据页中的记录是按照索引列大小的顺序组成单向链表的。有时候我们会有删除索引列的值在某个区间范围内的所有记录的需求,这时候如果我们每删除一条记录就写一条 redo 日志的话,效率可能有点低,所以提出 MLOG_COMP_LIST_START_DELETE 和 MLOG_COMP_LIST_END_DELETE 类型的 redo 日志,可以很大程度上减少 redo 日志的条数。

  • MLOG_ZIP_PAGE_COMPRESS(type 字段对应的十进制数字为 51):表示压缩一个数据页的 redo 日志类型。

  • ······还有很多很多种类型,这就不列举了,等用到再说~

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

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

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

大家看到这可能有些懵逼,我们还是以类型为 MLOG_COMP_REC_INSERT 这个代表插入一条使用紧凑行格式的记录时的 redo 日志为例来理解一下我们上面所说的 物理 层面和 逻辑 层面到底是什么意思。废话少说,直接看一下这个类型为 MLOG_COMP_REC_INSERTredo 日志的结构(由于字段太多了,我们把它们竖着看效果好些):

image 2025 01 13 14 37 03 174

这个类型为 MLOG_COMP_REC_INSERTredo 日志结构有几个地方需要大家注意:

我们前面在介绍索引的时候说过,在一个数据页里,不论是叶子节点还是非叶子节点,记录都是按照索引列从小到大的顺序排序的。对于二级索引来说,当索引列的值相同时,记录还需要按照主键值进行排序。图中 n_uniques 的值的含义是在一条记录中,需要几个字段的值才能确保记录的唯一性,这样当插入一条记录时就可以按照记录的前 n_uniques 个字段进行排序。对于聚簇索引来说,n_uniques 的值为主键的列数,对于其他二级索引来说,该值为 索引列数+主键 列数。这里需要注意的是,唯一二级索引的值可能为 NULL,所以该值仍然为 索引列数+主键 列数。

  • field1_len ~ fieldn_len 代表着该记录若干个字段占用存储空间的大小,需要注意的是,这里不管该字段的类型是固定长度大小的(比如 INT),还是可变长度大小(比如 VARCHAR(M) )的,该字段占用的大小始终要写入 redo 日志中。

  • offset 代表的是该记录的前一条记录在页面中的地址。为什么要记录前一条记录的地址呢?这是因为每向数据页插入一条记录,都需要修改该页面中维护的记录链表,每条记录的 记录头信息 中都包含一个称为 next_record 的属性,所以在插入新记录时,需要修改前一条记录的 next_record 属性。

  • 我们知道一条记录其实由 额外信息真实数据 这两部分组成,这两个部分的总大小就是一条记录占用存储空间的总大小。通过 end_seg_len 的值可以间接的计算出一条记录占用存储空间的总大小,为什么不直接存储一条记录占用存储空间的总大小呢?这是因为写 redo 日志是一个非常频繁的操作,设计 InnoDB 的大佬想方设法想减小 redo 日志本身占用的存储空间大小,所以想了一些弯弯绕的算法来实现这个目标,end_seg_len 这个字段就是为了节省 redo 日志存储空间而提出来的。至于具体设计 InnoDB 的大佬到底是用了什么神奇魔法减小 redo 日志大小的,我们这就不多介绍了,因为的确有那么一丢丢小复杂,说清楚还是有一点点麻烦的,而且说明白了也没什么用。

  • mismatch_index 的值也是为了节省 redo 日志的大小而设立的,大家可以忽略。

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

redo日志格式小结

虽然上面说了一大堆关于 redo 日志格式的内容,但是如果你不是为了写一个解析 redo 日志的工具或者自己开发一套 redo 日志系统的话,那就没必要把 InnoDB 中的各种类型的 redo 日志格式都研究的透透的,没那个必要。上面我只是象征性的介绍了几种类型的 redo 日志格式,目的还是想让大家明白:redo 日志会把事务在执行过程中对数据库所做的所有修改都记录下来,在之后系统奔溃重启后可以把事务所做的任何修改都恢复出来

为了节省 redo 日志占用的存储空间大小,设计 InnoDB 的大佬对 redo 日志中的某些数据还可能进行压缩处理,比方说 spacd ID 和 page number一般占用 4 个字节来存储,但是经过压缩后,可能使用更小的空间来存储。具体压缩算法就不介绍了。

Mini-Transaction

以组的形式写入redo日志

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

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

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

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

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

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

  • 情况一:该数据页的剩余的空闲空间充足,足够容纳这一条待插入记录,那么事情很简单,直接把记录插入到这个数据页中,记录一条类型为 MLOG_COMP_REC_INSERTredo 日志就好了,我们把这种情况称之为 乐观插入。假如某个索引对应的 B+ 树长这样:

    image 2025 01 13 14 49 55 373

    现在我们要插入一条键值为 10 的记录,很显然需要被插入到 页b 中,由于 页b 现在有足够的空间容纳一条记录,所以直接将该记录插入到 页b 中就好了,就像这样:

    image 2025 01 13 14 50 41 847
  • 情况二:该数据页剩余的空闲空间不足,那么事情就悲剧了,我们前面说过,遇到这种情况要进行所谓的 页分裂 操作,也就是新建一个叶子节点,然后把原先数据页中的一部分记录复制到这个新的数据页中,然后再把记录插入进去,把这个叶子节点插入到叶子节点链表中,最后还要在内节点中添加一条 目录项记录 指向这个新创建的页面。很显然,这个过程要对多个页面进行修改,也就意味着会产生多条 redo 日志,我们把这种情况称之为 悲观插入。假如某个索引对应的 B+ 树长这样:

image 2025 01 13 14 52 54 856

现在我们要插入一条键值为 10 的记录,很显然需要被插入到 页b 中,但是从图中也可以看出来,此时 页b 已经塞满了记录,没有更多的空闲空间来容纳这条新记录了,所以我们需要进行页面的分裂操作,就像这样:

image 2025 01 13 14 53 27 000

如果作为内节点的 页a 的剩余空闲空间也不足以容纳增加一条 目录项记录,那需要继续做内节点 页a 的分裂操作,也就意味着会修改更多的页面,从而产生更多的 redo 日志。另外,对于 悲观插入 来说,由于需要新申请数据页,还需要改动一些系统页面,比方说要修改各种段、区的统计信息信息,各种链表的统计信息(比如什么 FREE 链表、FSP_FREE_FRAG 链表等等我们在介绍表空间那一章中介绍过的各种东东)等等等等,反正总共需要记录的 redo 日志有二、三十条。

其实不光是悲观插入一条记录会生成许多条 redo 日志,设计 InnoDB 的大佬为了其他的一些功能,在乐观插入时也可能产生多条 redo 日志(具体是为了什么功能我们就不多说了,要不篇幅就受不了了~)。

设计 InnoDB 的大佬们认为向某个索引对应的 B+ 树中插入一条记录的这个过程必须是原子的,不能说插了一半之后就停止了。比方说在悲观插入过程中,新的页面已经分配好了,数据也复制过去了,新的记录也插入到页面中了,可是没有向内节点中插入一条目录项记录,这个插入过程就是不完整的,这样会形成一棵不正确的 B+ 树。我们知道 redo 日志是为了在系统奔溃重启时恢复崩溃前的状态,如果在悲观插入的过程中只记录了一部分 redo 日志,那么在系统奔溃重启时会将索引对应的 B+ 树恢复成一种不正确的状态,这是设计 InnoDB 的大佬们所不能忍受的。所以他们规定在执行这些需要保证原子性的操作时必须以组的形式来记录的 redo 日志,在进行系统奔溃重启恢复时,针对某个组中的 redo 日志,要么把全部的日志都恢复掉,要么一条也不恢复。怎么做到的呢?这得分情况讨论:

  • 有的需要保证原子性的操作会生成多条 redo 日志,比如向某个索引对应的 B+ 树中进行一次悲观插入就需要生成许多条 redo 日志。

    如何把这些 redo 日志划分到一个组里边儿呢?设计 InnoDB 的大佬做了一个很简单的小把戏,就是在该组中的最后一条 redo 日志后边加上一条特殊类型的 redo 日志,该类型名称为 MLOG_MULTI_REC_ENDtype 字段对应的十进制数字为 31,该类型的 redo 日志结构很简单,只有一个 type 字段:

    image 2025 01 13 14 59 58 467

    所以某个需要保证原子性的操作产生的一系列 redo 日志必须要以一个类型为 MLOG_MULTI_REC_END 结尾,就像这样:

    image 2025 01 13 15 00 59 696

    这样在系统奔溃重启进行恢复时,只有当解析到类型为 MLOG_MULTI_REC_ENDredo 日志,才认为解析到了一组完整的 redo 日志,才会进行恢复。否则的话直接放弃前面解析到的 redo 日志。

  • 有的需要保证原子性的操作只生成一条 redo 日志,比如更新 Max Row ID 属性的操作就只会生成一条 redo 日志。

其实在一条日志后边跟一个类型为 MLOG_MULTI_REC_ENDredo 日志也是可以的,不过设计 InnoDB 的大佬比较勤俭节约,他们不想浪费一个比特位。别忘了虽然 redo 日志的类型比较多,但撑死了也就是几十种,是小于 127 这个数字的,也就是说我们用 7 个比特位就足以包括所有的 redo 日志类型,而 type 字段其实是占用 1 个字节的,也就是说我们可以省出来一个比特位用来表示该需要保证原子性的操作只产生单一的一条 redo 日志,示意图如下:

image 2025 01 13 15 03 10 985

如果 type 字段的第一个比特位为 1,代表该需要保证原子性的操作只产生了单一的一条 redo 日志,否则表示该需要保证原子性的操作产生了一系列的 redo 日志。

Mini-Transaction的概念

设计 MySQL 的大佬把对底层页面中的一次原子访问的过程称之为一个 Mini-Transaction,简称 mtr,比如上面所说的修改一次 Max Row ID 的值算是一个 Mini-Transaction,向某个索引对应的 B+ 树中插入一条记录的过程也算是一个 Mini-Transaction。通过上面的叙述我们也知道,一个所谓的 mtr 可以包含一组 redo 日志,在进行奔溃恢复时这一组 redo 日志作为一个不可分割的整体。

一个事务可以包含若干条语句,每一条语句其实是由若干个 mtr 组成,每一个 mtr 又可以包含若干条 redo 日志,画个图表示它们的关系就是这样:

image 2025 01 13 15 05 10 862

redo日志的写入过程

redo log block

设计 InnoDB 的大佬为了更好的进行系统奔溃恢复,他们把通过 mtr 生成的 redo 日志都放在了大小为 512字节 中。为了和我们前面提到的表空间中的页做区别,我们这里把用来存储 redo 日志的页称为 block(你心里清楚页和 block 的意思其实差不多就行了)。一个 redo log block 的示意图如下:

image 2025 01 13 15 06 43 635

真正的 redo 日志都是存储到占用 496 字节大小的 log block body 中,图中的 log block headerlog block trailer 存储的是一些管理信息。我们来看看这些所谓的管理信息都是什么:

image 2025 01 13 15 07 23 258

其中 log block header 的几个属性的意思分别如下:

  • LOG_BLOCK_HDR_NO:每一个 block 都有一个大于 0 的唯一标号,本属性就表示该标号值。

  • LOG_BLOCK_HDR_DATA_LEN:表示 block 中已经使用了多少字节,初始值为 12(因为 log block body 从第 12 个字节处开始)。随着往 block 中写入的 redo 日志越来也多,本属性值也跟着增长。如果 log block body 已经被全部写满,那么本属性的值被设置为 512。

  • 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 的序号,checkpoint 是我们后续内容的重点,现在先不用清楚它的意思,稍安勿躁。

log block trailer 中属性的意思如下:

  • LOG_BLOCK_CHECKSUM:表示 block 的校验值,用于正确性校验,我们暂时不关心它。

redo日志缓冲区

我们前面说过,设计 InnoDB 的大佬为了解决磁盘速度过慢的问题而引入了 Buffer Pool。同理,写入 redo 日志时也不能直接写到磁盘上,实际上在服务器启动时就向操作系统申请了一大片称之为 redo log buffer 的连续内存空间,翻译成中文就是 redo 日志缓冲区,我们也可以简称为 log buffer。这片内存空间被划分成若干个连续的 redo log block,就像这样:

image 2025 01 13 15 11 16 146

我们可以通过启动参数 innodb_log_buffer_size 来指定 log buffer 的大小,在 MySQL 5.7.21 这个版本中,该启动参数的默认值为 16MB。

redo日志写入log buffer

log buffer 中写入 redo 日志的过程是顺序的,也就是先往前面的 block 中写,当该 block 的空闲空间用完之后再往下一个 block 中写。当我们想往 log buffer 中写入 redo 日志时,第一个遇到的问题就是应该写在哪个 block 的哪个偏移量处,所以设计 InnoDB 的大佬特意提供了一个称之为 buf_free 的全局变量,该变量指明后续写入的 redo 日志应该写入到 log buffer 中的哪个位置,如图所示:

image 2025 01 13 15 13 18 579

我们前面说过一个 mtr 执行过程中可能产生若干条 redo 日志,这些 redo 日志是一个不可分割的组,所以其实并不是每生成一条 redo 日志,就将其插入到 log buffer 中,而是每个 mtr 运行过程中产生的日志先暂时存到一个地方,当该 mtr 结束的时候,将过程中产生的一组 redo 日志再全部复制到 log buffer 中。我们现在假设有两个名为 T1T2 的事务,每个事务都包含 2mtr,我们给这几个 mtr 命名一下:

  • 事务 T1 的两个 mtr 分别称为 mtr_T1_1mtr_T1_2

  • 事务 T2 的两个 mtr 分别称为 mtr_T2_1mtr_T2_2

每个 mtr 都会产生一组 redo 日志,用示意图来描述一下这些 mtr 产生的日志情况:

image 2025 01 13 15 16 41 029

不同的事务可能是并发执行的,所以 T1T2 之间的 mtr 可能是交替执行的。每当一个 mtr 执行完成时,伴随该 mtr 生成的一组 redo 日志就需要被复制到 log buffer 中,也就是说不同事务的 mtr 可能是交替写入 log buffer 的,我们画个示意图(为了美观,我们把一个 mtr 中产生的所有的 redo 日志当作一个整体来画):

image 2025 01 13 15 18 08 012

从示意图中我们可以看出来,不同的 mtr 产生的一组 redo 日志占用的存储空间可能不一样,有的 mtr 产生的 redo 日志量很少,比如 mtr_t1_1mtr_t2_1 就被放到同一个 block 中存储,有的 mtr 产生的 redo 日志量非常大,比如 mtr_t1_2 产生的 redo 日志甚至占用了 3 个 block 来存储。

对照着上图,自己分析一下每个 block 的 LOG_BLOCK_HDR_DATA_LEN、LOG_BLOCK_FIRST_REC_GROUP 属性值都是什么~

redo日志文件

redo日志刷盘时机

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

  • log buffer 空间不足时

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

  • 事务提交时

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

  • 后台线程不停的刷刷刷

    后台有一个线程,大约每秒都会刷新一次 log buffer 中的 redo 日志到磁盘。

  • 正常关闭服务器时

  • 做所谓的 checkpoint 时(我们现在没介绍过 checkpoint 的概念,稍后会仔细介绍,稍安勿躁)

  • 其他的一些情况…​

redo日志文件组

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

  • innodb_log_group_home_dir

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

  • innodb_log_file_size

    该参数指定了每个 redo 日志文件的大小,在 MySQL 5.7.21 这个版本中的默认值为 48MB

  • innodb_log_files_in_group

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

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

image 2025 01 13 15 33 49 668

总共的 redo 日志文件大小其实就是: innodb_log_file_size × innodb_log_files_in_group

如果采用循环使用的方式向 redo 日志文件组里写数据的话,那岂不是要追尾,也就是后写入的 redo 日志覆盖掉前面写的 redo 日志?当然可能了!所以设计InnoDB 的大佬提出了 checkpoint 的概念,稍后我们重点介绍~

redo日志文件格式

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

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

  • 前 2048 个字节,也就是前 4 个 block 是用来存储一些管理信息的。

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

所以我们前面所说的 循环 使用 redo 日志文件,其实是从每个日志文件的第 2048 个字节开始算,画个示意图就是这样:

image 2025 01 13 15 36 40 043

普通 block 的格式我们在介绍 log buffer 的时候都说过了,就是 log block headerlog block bodylog block trialer 这三个部分,就不重复介绍了。这里需要介绍一下每个 redo 日志文件前 2048 个字节,也就是前 4 个特殊 block 的格式都是干嘛的,废话少说,先看图:

image 2025 01 13 15 37 46 629

从图中可以看出来,这 4 个 block 分别是:

  • log file header:描述该 redo 日志文件的一些整体属性,看一下它的结构:

    image 2025 01 13 15 40 40 246

    各个属性的具体释义如下:

    属性 长度(单位:字节) 描述

    LOG_HEADER_FORMAT

    4

    redo 日志的版本,在 MySQL 5.7.21 中该值永远为 1

    LOG_HEADER_PAD1

    4

    做字节填充用的,没什么实际意义,忽略~

    LOG_HEADER_START_LSN

    8

    标记本 redo 日志文件开始的 LSN 值,也就是文件偏移量为 2048 字节初对应的 LSN 值(关于什么是 LSN 我们稍后再看,看不懂的先忽略)。

    LOG_HEADER_CREATOR

    32

    一个字符串,标记本 redo 日志文件的创建者是谁。正常运行时该值为 MySQL 的版本号,比如:"MySQL 5.7.21",使用 mysqlbackup 命令创建的 redo 日志文件的该值为 "ibbackup" 和创建时间。

    LOG_BLOCK_CHECKSUM

    4

    本 block 的校验值,所有 block 都有,我们不关心

    设计 InnoDB 的大佬对 redo 日志的 block 格式做了很多次修改,如果你阅读的其他书籍中发现上述的属性和你阅读书籍中的属性有些出入,不要慌,正常现象,忘记以前的版本吧。另外,LSN 值我们后边才会介绍,现在千万别纠结 LSN 是什么。

  • checkpoint1:记录关于 checkpoint 的一些属性,看一下它的结构:

    image 2025 01 13 15 44 55 708

    各个属性的具体释义如下:

    属性名 长度(单位:字节) 描述

    LOG_CHECKPOINT_NO

    8

    服务器做 checkpoint 的编号,每做一次 checkpoint,该值就加 1。

    LOG_CHECKPOINT_LSN

    8

    服务器做 checkpoint 结束时对应的 LSN 值,系统奔溃恢复时将从该值开始。

    LOG_CHECKPOINT_OFFSET

    8

    上个属性中的 LSN 值在 redo 日志文件组中的偏移量

    LOG_CHECKPOINT_LOG_BUF_SIZE

    8

    服务器在做 checkpoint 操作时对应的 log buffer 的大小

    LOG_BLOCK_CHECKSUM

    4

    本 block 的校验值,所有 block 都有,我们不关心

  • 第三个 block 未使用,忽略~

  • checkpoint2:结构和 checkpoint1 一样。

Log Sequeue Number

自系统开始运行,就不断的在修改页面,也就意味着会不断的生成 redo 日志。redo 日志的量在不断的递增,就像人的年龄一样,自打出生起就不断递增,永远不可能缩减了。设计 InnoDB 的大佬为记录已经写入的 redo 日志量,设计了一个称之为 Log Sequeue Number 的全局变量,翻译过来就是:日志序列号,简称 lsn。不过不像人一出生的年龄是 0 岁,设计 InnoDB 的大佬规定初始的 lsn 值为 8704(也就是一条 redo 日志也没写入时,lsn 的值为 8704)。

我们知道在向 log buffer 中写入 redo 日志时不是一条一条写入的,而是以一个 mtr 生成的一组 redo 日志为单位进行写入的。而且实际上是把日志内容写在了 log block body 处。但是在统计 lsn 的增长量时,是按照实际写入的日志量加上占用的 log block headerlog block trailer 来计算的。我们来看一个例子:

  • 系统第一次启动后初始化 log buffer 时,buf_free(就是标记下一条 redo 日志应该写入到 log buffer 的位置的变量)就会指向第一个 block 的偏移量为 12 字节(log block header 的大小)的地方,那么 lsn 值也会跟着增加 12

    image 2025 01 13 15 59 20 034
  • 如果某个 mtr 产生的一组 redo 日志占用的存储空间比较小,也就是待插入的 block 剩余空闲空间能容纳这个 mtr 提交的日志时,lsn 增长的量就是该 mtr 生成的 redo 日志占用的字节数,就像这样:

    image 2025 01 13 16 00 38 808

    我们假设上图中 mtr_1 产生的 redo 日志量为 200 字节,那么 lsn 就要在 8716 的基础上增加 200,变为 8916

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

image 2025 01 13 16 02 31 563

我们假设上图中 mtr_2 产生的 redo 日志量为 1000 字节,为了将 mtr_2 产生的 redo 日志写入 log buffer,我们不得不额外多分配两个 block,所以 lsn 的值需要在 8916 的基础上增加 1000 + 12×2 + 4 × 2 = 1032

为什么初始的 lsn 值为 8704 呢?我也不太清楚,人家就这么规定的。其实你也可以规定你一生下来算 1 岁,只要保证随着时间的流逝,你的年龄不断增长就好了。

从上面的描述中可以看出来,每一组由 mtr 生成的 redo 日志都有一个唯一的 LSN 值与其对应,LSN 值越小,说明 redo 日志产生的越早

flushed_to_disk_lsn

redo 日志是首先写到 log buffer 中,之后才会被刷新到磁盘上的 redo 日志文件。所以设计 InnoDB 的大佬提出了一个称之为 buf_next_to_write 的全局变量,标记当前 log buffer 中已经有哪些日志被刷新到磁盘中了。画个图表示就是这样:

image 2025 01 13 16 05 05 709

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

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

    • mtr_1:8716 ~ 8916

    • mtr_2:8916 ~ 9948

    • mtr_3:9948 ~ 10000

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

    image 2025 01 13 16 09 13 407
  • 随后进行将 log buffer 中的 block 刷新到 redo 日志文件的操作,假设将 mtr_1mtr_2 的日志刷新到磁盘,那么 flushed_to_disk_lsn 就应该增长 mtr_1mtr_2 写入的日志量,所以 flushed_to_disk_lsn 的值增长到了 9948,如图:

    image 2025 01 13 16 10 38 822

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

应用程序向磁盘写入文件时其实是先写到操作系统的缓冲区中去,如果某个写入操作要等到操作系统确认已经写到磁盘时才返回,那需要调用一下操作系统提供的 fsync 函数。其实只有当系统执行了 fsync 函数后,flushed_to_disk_lsn 的值才会跟着增长,当仅仅把 log buffer 中的日志写入到操作系统缓冲区却没有显式的刷新到磁盘时,另外的一个称之为 write_lsn 的值跟着增长。不过为了大家理解上的方便,我们在讲述时把 flushed_to_disk_lsn 和 write_lsn 的概念混淆了起来。

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

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

image 2025 01 13 16 14 49 505

初始时的 LSN 值是 8704,对应文件偏移量 2048,之后每个 mtr 向磁盘中写入多少字节日志,lsn 的值就增长多少。

flush链表中的LSN

我们知道一个 mtr 代表一次对底层页面的原子访问,在访问过程中可能会产生一组不可分割的 redo 日志,在 mtr 结束时,会把这一组 redo 日志写入到 log buffer 中。除此之外,在 mtr 结束时还有一件非常重要的事情要做,就是 把在 mtr 执行过程中可能修改过的页面加入到 Buffer Pool 的 flush 链表。为了防止大家早已忘记 flush 链表 是什么,我们再看一下图:

image 2025 01 13 16 16 47 751

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

  • oldest_modification:如果某个页面被加载到 Buffer Pool 后进行第一次修改,那么就将修改该页面的 mtr 开始时对应的 lsn 值写入这个属性。

  • newest_modification:每修改一次页面,都会将修改该页面的 mtr 结束时对应的 lsn 值写入这个属性。也就是说该属性表示页面最近一次修改后对应的系统 lsn 值。

我们接着上面介绍 flushed_to_disk_lsn 的例子看一下:

  • 假设 mtr_1 执行过程中修改了 页a,那么在 mtr_1 执行结束时,就会将 页a 对应的控制块加入到 flush 链表 的头部。并且将 mtr_1 开始时对应的 lsn,也就是 8716 写入 页a 对应的控制块的 oldest_modification 属性中,把 mtr_1 结束时对应的 lsn,也就是 8916 写入 页a 对应的控制块的 newest_modification 属性中。画个图表示一下(为了让图片美观一些,我们把 oldest_modification 缩写成了 o_m,把 newest_modification 缩写成了 n_m):

    image 2025 01 13 16 21 09 039
  • 接着假设 mtr_2 执行过程中又修改了 页b页c 两个页面,那么在 mtr_2 执行结束时,就会将 页b页c 对应的控制块都加入到 flush 链表 的头部。并且将 mtr_2 开始时对应的 lsn,也就是 8916 写入 页b页c 对应的控制块的 oldest_modification 属性中,把 mtr_2 结束时对应的 lsn,也就是 9948 写入 页b页c 对应的控制块的 newest_modification 属性中。画个图表示一下:

    image 2025 01 13 16 23 09 716

    从图中可以看出来,每次新插入到 flush 链表 中的节点都是被放在了头部,也就是说 flush 链表 中前面的脏页修改的时间比较晚,后边的脏页修改时间比较早。

  • 接着假设 mtr_3 执行过程中修改了 页b页d,不过 页b 之前已经被修改过了,所以它对应的控制块已经被插入到了 flush 链表,所以在 mtr_3 执行结束时,只需要将 页d 对应的控制块都加入到 flush 链表 的头部即可。所以需要将 mtr_3 开始时对应的 lsn,也就是 9948 写入 页d 对应的控制块的 oldest_modification 属性中,把 mtr_3 结束时对应的 lsn,也就是 10000 写入 页d 对应的控制块的 newest_modification 属性中。另外,由于 页bmtr_3 执行过程中又发生了一次修改,所以需要更新 页b 对应的控制块中 newest_modification 的值为 10000。画个图表示一下:

image 2025 01 13 16 26 22 700

总结一下上面说的,就是:flush 链表中的脏页按照修改发生的时间顺序进行排序,也就是按照 oldest_modification 代表的 LSN 值进行排序,被多次更新的页面不会重复插入到 flush 链表中,但是会更新 newest_modification 属性的值。

checkpoint

有一个很不幸的事实就是我们的 redo 日志文件组容量是有限的,我们不得不选择循环使用 redo 日志文件组中的文件,但是这会造成最后写的 redo 日志与最开始写的 redo 日志 追尾,这时应该想到:redo 日志只是为了系统奔溃后恢复脏页用的,如果对应的脏页已经刷新到了磁盘,也就是说即使现在系统奔溃,那么在重启后也用不着使用 redo 日志恢复该页面了,所以该 redo 日志也就没有存在的必要了,那么它占用的磁盘空间就可以被后续的 redo 日志所重用。也就是说:判断某些 redo 日志占用的磁盘空间是否可以覆盖的依据就是它对应的脏页是否已经刷新到磁盘里。我们看一下前面一直介绍的那个例子:

image 2025 01 13 16 29 50 643

如图,虽然 mtr_1mtr_2 生成的 redo 日志都已经被写到了磁盘上,但是它们修改的脏页仍然留在 Buffer Pool 中,所以它们生成的 redo 日志在磁盘上的空间是不可以被覆盖的。之后随着系统的运行,如果 页a 被刷新到了磁盘,那么它对应的控制块就会从 flush 链表 中移除,就像这样子:

image 2025 01 13 16 30 51 842

这样 mtr_1 生成的 redo 日志就没有用了,它们占用的磁盘空间就可以被覆盖掉了。设计 InnoDB 的大佬提出了一个全局变量 checkpoint_lsn 来代表当前系统中可以被覆盖的 redo 日志总量是多少,这个变量初始值也是 8704

比方说现在 页a 被刷新到了磁盘,mtr_1 生成的 redo 日志就可以被覆盖了,所以我们可以进行一个增加 checkpoint_lsn 的操作,我们把这个过程称之为做一次 checkpoint。做一次 checkpoint 其实可以分为两个步骤:

  • 步骤一:计算一下当前系统中可以被覆盖的 redo 日志对应的 lsn 值最大是多少。

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

    比方说当前系统中 页a 已经被刷新到磁盘,那么 flush链表 的尾节点就是 页c,该节点就是当前系统中最早修改的脏页了,它的 oldest_modification 值为 8916,我们就把 8916 赋值给 checkpoint_lsn(也就是说在 redo 日志对应的 lsn 值小于 8916 时就可以被覆盖掉)。

  • 步骤二:将 checkpoint_lsn 和对应的 redo 日志文件组偏移量以及此次 checkpoint 的编号写到日志文件的管理信息(就是 checkpoint1 或者 checkpoint2)中。

设计 InnoDB 的大佬维护了一个目前系统做了多少次 checkpoint 的变量 checkpoint_no,每做一次 checkpoint,该变量的值就加 1。我们前面说过计算一个 lsn 值对应的 redo 日志文件组偏移量是很容易的,所以可以计算得到该 checkpoint_lsnredo 日志文件组中对应的偏移量 checkpoint_offset,然后把这三个值都写到 redo 日志文件组的管理信息中。

我们说过,每一个 redo 日志文件都有 2048 个字节的管理信息,但是 上述关于 checkpoint 的信息只会被写到日志文件组的第一个日志文件的管理信息中。不过我们是存储到 checkpoint1 中还是 checkpoint2 中呢?设计 InnoDB 的大佬规定,当 checkpoint_no 的值是偶数时,就写到 checkpoint1 中,是奇数时,就写到 checkpoint2 中。

记录完 checkpoint 的信息之后,redo 日志文件组中各个 lsn 值的关系就像这样:

image 2025 01 13 16 37 04 077

批量从flush链表中刷出脏页

我们在介绍 Buffer Pool 的时候说过,一般情况下都是后台的线程在对 LRU 链表flush 链表 进行刷脏操作,这主要因为刷脏操作比较慢,不想影响用户线程处理请求。但是如果当前系统修改页面的操作十分频繁,这样就导致写日志操作十分频繁,系统 lsn 值增长过快。如果后台的刷脏操作不能将脏页刷出,那么系统无法及时做 checkpoint,可能就需要用户线程同步的从 flush 链表 中把那些最早修改的脏页(oldest_modification 最小的脏页)刷新到磁盘,这样这些脏页对应的 redo 日志就没用了,然后就可以去做 checkpoint 了。

查看系统中的各种LSN值

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

mysql> SHOW ENGINE INNODB STATUS\G

(...省略前面的许多状态)
LOG
---
Log sequence number 124476971
Log flushed up to   124099769
Pages flushed up to 124052503
Last checkpoint at  124052494
0 pending log flushes, 0 pending chkp writes
24 log i/o's done, 2.00 log i/o's/second
----------------------
(...省略后边的许多状态)

其中:

  • 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 日志同步到磁盘,可以保证事务的持久性。1 也是 innodb_flush_log_at_trx_commit 的默认值。

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

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

崩溃恢复

在服务器不挂的情况下,redo 日志简直就是个大累赘,不仅没用,反而让性能变得更差。但是万一,我说万一啊,万一数据库挂了,那 redo 日志可是个宝了,我们就可以在重启时根据 redo 日志中的记录就可以将页面恢复到系统奔溃前的状态。我们接下来大致看一下恢复过程是什么样。

确定恢复的起点

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

当然,redo 日志文件组的第一个文件的管理信息中有两个 block 都存储了 checkpoint_lsn 的信息,我们当然是要选取 最近发生的那次 checkpoint 的信息。衡量 checkpoint 发生时间早晚的信息就是所谓的checkpoint_no,我们只要把 checkpoint1checkpoint2 这两个 block 中的 checkpoint_no 值读出来比一下大小,哪个的 checkpoint_no 值更大,说明哪个 block 存储的就是最近的一次 checkpoint 信息。这样我们就能拿到最近发生的 checkpoint 对应的 checkpoint_lsn 值以及它在 redo 日志文件组中的偏移量 checkpoint_offset

确定恢复的终点

redo 日志恢复的起点确定了,那终点是哪个呢?这个还得从 block 的结构说起。我们说在写 redo 日志的时候都是顺序写的,写满了一个 block 之后会再往下一个 block 中写:

image 2025 01 13 16 48 29 389

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

怎么恢复

确定了需要扫描哪些 redo 日志进行奔溃恢复之后,接下来就是怎么进行恢复了。假设现在的 redo 日志文件中有 5 条 redo 日志,如图:

image 2025 01 13 16 49 58 075

由于 redo 0checkpoint_lsn 后边,恢复时可以不管它。我们现在可以按照 redo 日志的顺序依次扫描 checkpoint_lsn 之后的各条 redo 日志,按照日志中记载的内容将对应的页面恢复出来。这样没什么问题,不过设计 InnoDB 的大佬还是想了一些办法加快这个恢复的过程:

  • 使用哈希表

    根据 redo 日志的 space IDpage number 属性计算出散列值,把 space IDpage number 相同的 redo 日志放到哈希表的同一个槽里,如果有多个 space IDpage number 都相同的 redo 日志,那么它们之间使用链表连接起来,按照生成的先后顺序链接起来的,如图所示:

    image 2025 01 13 16 52 03 038

    之后就可以遍历哈希表,因为对同一个页面进行修改的 redo 日志都放在了一个槽里,所以可以一次性将一个页面修复好(避免了很多读取页面的随机 IO),这样可以加快恢复速度。另外需要注意一点的是,同一个页面的 redo 日志是按照生成时间顺序进行排序的,所以恢复的时候也是按照这个顺序进行恢复,如果不按照生成时间顺序进行排序的话,那么可能出现错误。比如原先的修改操作是先插入一条记录,再删除该条记录,如果恢复时不按照这个顺序来,就可能变成先删除一条记录,再插入一条记录,这显然是错误的。

  • 跳过已经刷新到磁盘的页面

    我们前面说过,checkpoint_lsn 之前的 redo 日志对应的脏页确定都已经刷到磁盘了,但是 checkpoint_lsn 之后的 redo 日志我们不能确定是否已经刷到磁盘,主要是因为在最近做的一次 checkpoint 后,可能后台线程又不断的从 LRU链表flush链表 中将一些脏页刷出 Buffer Pool。这些在 checkpoint_lsn 之后的 redo 日志,如果它们对应的脏页在奔溃发生时已经刷新到磁盘,那在恢复时也就没有必要根据 redo 日志的内容修改该页面了。

    那在恢复时怎么知道某个 redo 日志对应的脏页是否在奔溃发生时已经刷新到磁盘了呢?这还得从页面的结构说起,我们前面说过每个页面都有一个称之为 File Header 的部分,在 File Header 里有一个称之为 FIL_PAGE_LSN 的属性,该属性记载了最近一次修改页面时对应的 lsn 值(其实就是页面控制块中的 newest_modification 值)。如果在做了某次 checkpoint 之后有脏页被刷新到磁盘中,那么该页对应的 FIL_PAGE_LSN 代表的 lsn 值肯定大于 checkpoint_lsn 的值,凡是符合这种情况的页面就不需要重复执行 lsn 值小于 FIL_PAGE_LSNredo 日志了,所以更进一步提升了奔溃恢复的速度。

遗漏的问题:LOG_BLOCK_HDR_NO是如何计算的

我们前面说过,对于实际存储 redo 日志的普通的 log block 来说,在 log block header 处有一个称之为 LOG_BLOCK_HDR_NO 的属性(忘记了的话回头再看看),我们说这个属性代表一个唯一的标号。这个属性是初次使用该 block 时分配的,跟当时的系统 lsn 值有关。使用下面的公式计算该 block 的 LOG_BLOCK_HDR_NO 值:

((lsn / 512) & 0x3FFFFFFFUL) + 1

这个公式里的 0x3FFFFFFFUL 可能让大家有点困惑,其实它的二进制表示可能更亲切一点:

image 2025 01 13 16 56 34 140

从图中可以看出,0x3FFFFFFFUL 对应的二进制数的前 2 位为 0,后 30 位的值都为 1。我们刚开始学计算机的时候就学过,一个二进制位与 0 做与运算( & )的结果肯定是 0,一个二进制位与 1 做与运算( & )的结果就是原值。让一个数和 0x3FFFFFFFUL 做与运算的意思就是要将该值的前 2 个比特位的值置为 0,这样该值就肯定小于或等于 0x3FFFFFFFUL 了。这也就说明了,不论 lsn 多大,((lsn / 512) & 0x3FFFFFFFUL) 的值肯定在 0~0x3FFFFFFFUL 之间,再加 1 的话肯定在 1~0x40000000UL 之间。而 0x40000000UL 这个值大家应该很熟悉,这个值就代表着 1GB。也就是说系统最多能产生不重复的 LOG_BLOCK_HDR_NO 值只有 1GB 个。设计 InnoDB 的大佬规定 redo 日志文件组中包含的所有文件大小总和不得超过 512GB,一个 block 大小是 512 字节,也就是说 redo 日志文件组中包含的 block 块最多为 1GB 个,所以有 1GB 个不重复的编号值也就够用了。

另外,LOG_BLOCK_HDR_NO 值的第一个比特位比较特殊,称之为 flush bit,如果该值为 1,代表着本 block 是在某次将 log buffer 中的 block 刷新到磁盘的操作中的第一个被刷入的 block。