redo log 基本概念

我们知道 InnoDB 存储引擎是以页为单位来管理存储空间的,在真正访问页之前,需要把在磁盘上的页缓存到内存中的 Buffer Pool 后才可以访问。但是,事务机制又提供了持久性的特性,就是说对于一个已经提交的事务,在事务提交后即使系统崩溃,这个事务对数据库中所做的更改也不能丢失。假如我们只在内存的 Buffer Pool 中修改了页面,当事务提交后突然发生了某个故障,导致内存中的数据丢失,那么这个已经提交了的事务对数据库中所做的更改也就跟着丢失了,而这是我们不能忍受的。

那么如何保证事务的持久性呢?最简单的做法是在事务提交完成前把该事务修改的所有页面都刷新到磁盘,但这个简单粗暴的做法有以下两个问题:

  • 刷新一个完整的数据页太浪费了。有时我们仅修改了某个页面中的一个字节,但我们在该事务提交时却不得不将一个完整的页面从内存中刷新到磁盘,一个页面默认是 16KB 大小,这显然是太浪费了。


  • 随机 IO 刷盘速度较慢。一个事务可能包含多条语句,即使一条语句也可能修改多个页,并且这些页面可能并不相邻,这意味着在将某个事务修改的 Buffer Pool 中的脏页刷新到磁盘时,需要进行多次随机 IO。

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

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

这样我们在事务提交时,把上述内容刷新到磁盘中,即使之后系统崩溃了,重启后只要按照上述内容所记录的步骤重新更新一下数据页,那么该事务对数据库中所做的修改就可以被恢复出来,满足了持久性要求。因为在系统奔溃重启时需要按照上述内容所记录的步骤重新更新数据页,所以该文件也被称为重做日志(redo log)。

与在事务提交时将所有修改过的内存中的页面刷新到磁盘中相比,只将该事务执行过程中产生的 redo 日志刷新到磁盘的好处如下:

  • redo log 占用的空间非常小,存储表空间 ID、页号、偏移量以及需要更新的值所需的存储空间是很小的。
  • redo log 是顺序写磁盘的,相比随机 IO,顺序 IO 的速度很快。

redo log 写入过程

1. Mini-Transaction

语句在执行过程中可能修改多个页面,由于对这些页面的更改都发生在 Buffer Pool 中,所以在修改完页后需要记录一下相应的 redo 日志。在执行过程中产生的 redo 日志被 InnoDB 划分为若干个不可分割的组。比如:

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

不可分割的意思的指,当我们向某个索引对应的 B+ 树插入一条记录时,如果该记录对应的数据页的剩余空闲空间充足,足够容纳这一条待插入记录,那么直接把记录插入到这个数据页中即可,这个过程称为乐观插入。但如果该数据页剩余的空闲空间不足,此时会进行页分裂操作,也就是新建一个叶子节点,然后把原先数据页中的一部分记录复制到这个新的数据页中,然后再把记录插入进去,把这个叶子节点插入到叶子节点链表中,最后还要在非叶子节点中添加一条目录项记录指向这个新创建的页面,这个过程称为悲观插入。显然,这个过程要对多个页面进行修改,也意味着会产生多条 redo 日志。

InnoDB 认为向某个索引对应的 B+ 树中插入一条记录的这个过程必须是原子的,否则会形成错误的 B+ 树。我们知道 redo 日志是为了在系统奔溃重启时恢复崩溃前的状态,如果在悲观插入的过程中只记录了一部分 redo 日志,那么在系统奔溃重启时会将索引对应的 B+ 树恢复成一种不正确的状态。所以,InnoDB 规定在执行这些需要保证原子性的操作时必须以组的形式来记录的 redo 日志,在进行系统奔溃重启恢复时,针对某个组中的 redo 日志,要么把全部的日志都恢复掉,要么一条也不恢复。

如何把一次操作中生成的多条 redo 日志划分到一个组里呢?InnoDB 会在该组中的最后一条 redo 日志后边加上一条特殊类型的 redo 日志,该类型名称为 MLOG_MULTI_REC_END,用来标识一组 redo 日志的结尾。这样在系统奔溃重启进行恢复时,只有当解析到类型为 MLOG_MULTI_REC_END 的 redo 日志,才认为解析到了一组完整的 redo 日志,才会进行恢复。否则的话直接放弃前边解析到的 redo 日志。
image.png
MySQL 把对底层页面中的一次原子访问的过程称为一个 Mini-Transaction,简称 mtr。比如,向某个索引对应的 B+ 树中插入一条记录的过程就算是一个 Mini-Transaction。一个 mtr 可以包含一组 redo 日志,在进行奔溃恢复时这一组 redo 日志作为一个不可分割的整体。

一个事务可以包含多条语句,每一条语句其实是由若干个 mtr 组成,每一个 mtr 又可以包含多条 redo 日志,它们之间的关系如下图所示:
image.png

2. redo log block

InnoDB 为了更好的进行系统奔溃恢复,于是把通过 mtr 生成的多条 redo 日志都放在了大小为 512 字节的 block 中了,一个 redo log block 的示意图如下:
image.png
真正的 redo 日志都是存储到占用 496 字节大小的 log block body 中,图中的 log block header 和 log block trailer 存储的是一些管理信息。我们来看下这些管理信息都是啥:
image.png

  • LOG_BLOCK_HDR_NO:每一个 block 都有一个大于 0 的唯一标号,本属性就表示该标号值。
  • LOG_BLOCK_HDR_DATA_LEN:表示 block 中已经使用了多少字节。
  • LOG_BLOCK_FIRST_REC_GROUP:一条 redo 日志也可以称之为一条 redo 日志记录,一个 mtr 会产生多条 redo 日志记录,这些 redo 日志记录被称为一个 redo 日志记录组。该属性就代表该 block 中第一个 mtr 生成的 redo 日志记录组的偏移量,即这个 block 里第一个 mtr 生成的第一条 redo 日志的偏移量。
  • LOG_BLOCK_CHECKPOINT_NO:表示 checkpoint 序号。
  • LOG_BLOCK_CHECKSUM:表示 block 的校验值,用于正确性校验。

    3. redo log buffer

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

    4. redo log 写入 redo log buffer

    向 redo log buffer 中写入 redo 日志的过程是顺序的,也就是先往前边的 block 中写,当该 block 的空闲空间用完后再往下一个 block 中写。InnoDB 提供了一个 buf_free 的全局变量,该变量指明后续写入的 redo 日志应该写入到 redo log buffer 中的哪个位置,如下图所示:
    image.png
    我们前边说过一个 mtr 执行过程中可能产生若干条 redo 日志,这些 redo 日志是一个不可分割的组,所以其实并不是每生成一条 redo 日志,就将其插入到 redo log buffer 中,而是每个 mtr 运行过程中产生的日志先暂时存到一个地方,当该 mtr 结束时,将过程中产生的一组 redo 日志再全部复制到 redo log buffer 中。

组提交机制:

在实际使用时,由于不同事务之间是可以并行执行的,且每个事务执行会产生多个 mtr,每个 mtr 都会产生一组 redo 日志。所以,不同事务之间的 mtr 可能是交替执行的。每当一个 mtr 执行完成时,伴随该 mtr 生成的一组 redo 日志就需要被复制到 redo log buffer 中,即不同事务的 mtr 可能是交替写入 redo log buffer 的。
image.png
在这种情况下,由于事务 t1 是先被执行的,当事务 t1 执行完成准备把 redo log 刷盘时,由于事务 t2 也写入了一部分 redo log,LSN 的值会增大,事务 t1 刷盘时携带的 LSN 的值就是当前的值,因此当事务 t1 刷盘时,会将最大的 LSN 值之前的 redo log 刷新到磁盘中,这对事务 t2 来说执行完成后就可以直接返回了。所以,一次组提交里面,组员越多,节约磁盘 IOPS 的效果越好。

redo log 文件

1. redo log 刷盘时机

在某些情况下,位于缓冲区(redo log buffer)中的 redo 日志会被刷新到磁盘里,具体时机如下:

  • 如果当前写入 redo log buffer 中的 redo 日志量已经占了 redo log buffer 总容量的一半,就需要把这些日志刷新到磁盘上。注意,由于这个事务并没有提交,所以这个写盘动作只 write,而没有 fsync。

  • 事务提交。在事务提交时可以不把修改过的 Buffer Pool 页面刷新到磁盘,但是为了保证持久性,必须要把修改这些页面对应的 redo 日志刷新到磁盘。

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

  • 正常关闭服务器

  • checkpoint 操作

针对在事务提交时将该事务执行过程中产生的所有 redo 日志都刷新到磁盘上的这条要求,如果我们对事务的持久性要求不高,可以选择修改系统变量 innodb_flush_log_at_trx_commit 的值,该变量有三个可选值:

  • 设置为 0 时,表示每次事务提交时都只是把 redo log 写在 redo log buffer 中。后续写磁盘由后台线程每隔 1 秒调用 write 写入 page cache,再 fsync 持久化到磁盘。当系统崩溃时会丢失 1 秒的数据。


  • 设置为 1 时,表示每次事务提交时,都会调用 fsync 将 redo log 直接持久化到磁盘中。这种方式即使系统崩溃也不会丢失任何数据,但因为每次提交都写入磁盘,IO的性能较差。


  • 设置为 2 时,表示每次事务提交时都只是把 redo log 写到 page cache 中。后续的 fsync 过程由操作系统来进行调度。这种情况下如果数据库挂了,操作系统没挂,事务的持久性还是可以保证的,但是操作系统也挂了的话,就不能保证持久性了。

image.png

2. redo log 文件组

在 MySQL 的数据目录(系统变量 datadir)下默认有两个名为 ib_logfile0 和 ib_logfile1 的文件,log buffer 中的日志默认情况下就是刷新到这两个磁盘文件中。
image.png
如果我们对默认的 redo 日志文件不满意,可以通过下边几个启动参数来调节:

  1. # 指定redo日志文件所在目录,默认值就是当前的数据目录
  2. innodb_log_group_home_dir
  3. # 指定每个redo日志文件的大小,默认为48MB
  4. innodb_log_file_size
  5. # 指定redo日志文件的个数,默认值为2,最大值为100
  6. innodb_log_files_in_group

从配置项中可以看到,磁盘上的 redo log 以一个日志文件组的形式出现,这些文件以 ib_logfile[数字] 格式进行命名。在将 redo 日志写入日志文件组时,从 ib_logfile0 开始写,如果 ib_logfile0 写满了,就接着 ib_logfile1 写,依此类推。如果写满了最后一个文件,就重新转到 ib_logfile0 继续写,所以整个过程如下图所示:
image.png
因此,总共的 redo 日志文件大小其实就是:innodb_log_file_size × innodb_log_files_in_group。

3. LSN

随着系统的运行,redo 日志的量也在不断递增,InnoDB 为了记录已经写入的 redo 日志量,设计了一个称之为 Log Sequeue Number(lsn)的全局变量,即日志序列号。默认情况下,lsn 的初始值为 8704。

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

3.1 flushed_to_disk_lsn

redo 日志是首先写到 redo log buffer 中,之后才会被刷新到磁盘上的 redo 日志文件。所以 InnoDB 提供了一个称之为 buf_next_to_write 的全局变量,用来标记当前 redo log buffer 中有哪些日志已经刷新到磁盘了。
image.png
上面说到 lsn 是表示当前系统中写入的 redo 日志量,这包括了写到 redo log buffer 而没有刷新到磁盘的日志,相应的,InnoDB 也提供了一个表示刷新到磁盘中的 redo 日志量的全局变量,称为 flushed_to_disk_lsn。系统第一次启动时,该变量的值和初始的 lsn 值是相同的,都是 8704。随着系统的运行,redo 日志被不断写入 redo log buffer 中,但是并不会立即刷新到磁盘,lsn 的值就和 flushed_to_disk_lsn 的值拉开了差距。

  • buf_next_to_write 是用来定位 redo log 中哪些日志已经刷新到磁盘上的指针。
  • flushed_to_disk_lsn 是一个不断递增的值,用来与 lsn 值对齐。

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

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

3.2 flush 链表中的 lsn

我们知道一个 mtr 代表一次对底层页面的原子访问,在访问过程中可能会产生一组不可分割的 redo 日志,在 mtr 结束时,会把这一组 redo 日志写入到 reod log buffer 中。此外,在 mtr 结束时还会把在 mtr 执行过程中可能修改过的页面加入到 Buffer Pool 的 flush 链表中,表示对应页面已变为脏页。

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

  • oldest_modification:如果某个页面被加载到 Buffer Pool 后进行第一次修改,那么就将修改该页面的 mtr 开始时对应的 lsn 值写入这个属性。
  • newest_modification:每修改一次页面,都会将修改该页面的 mtr 结束时对应的 lsn 值写入这个属性。即该属性表示页面最近一次修改后对应的系统 lsn 值。

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

3.3 checkpoint_lsn

由于 redo 日志文件组的容量是有限的,所以 InnoDB 选择循环使用 redo 日志文件组中的文件,但这会造成最后写的 redo 日志与最开始写的 redo 日志追尾。实际上,如果 redo 日志对应的脏页已经刷新到了磁盘,系统崩溃恢复就不会丢失数据,所以该 redo 日志占用的磁盘空间就可以被后续 redo 日志重用。因此,判断某些 redo 日志占用的磁盘空间是否可以覆盖的依据就是它对应的脏页是否已经刷新到了磁盘里。为此,InnoDB 提供了一个全局变量 checkpoint_lsn 来代表当前系统中可以被覆盖的 redo 日志的总量,该变量初始值也是 8704。

当脏页被刷入磁盘时,就会做一次 checkpoint 操作来计算 checkpoint_lsn 的值。redo 日志可以被覆盖,意味着它对应的脏页被刷到了磁盘,只要我们计算出当前系统中被最早修改的脏页对应的 oldest_modification 值,那凡是在系统 lsn 值小于该节点的 oldest_modification 值时产生的 redo 日志都是可以被覆盖掉的,所以,在做 checkpoint 时,其实就是将 flush 链表尾部的脏页的 oldest_modification 赋值给 checkpoint_lsn。

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

checkpoint_lsn 和 flushed_to_disk_lsn 的区别?
flushed_to_disk_lsn 记录的是已经刷新到磁盘上的 redo 日志的 lsn 的值,但是 redo 日志对应的修改的脏页可能仍留在 Buffer Pool 中,所以,这些脏页对应的 redo 日志在磁盘上的空间是不可以被覆盖的。因为如果这些脏页没有刷新回磁盘就因为异常宕机而丢失了,需要 redo log 中记录的数据来进行恢复。所以 checkpoint_lsn 的值通常会小于 flushed_to_disk_lsn 的值。
image.png

4. 查看系统 LSN 值

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

  • Log sequence number:代表系统中 lsn 的值,即已写入的 redo log 量,包括 redo log buffer 中的日志。
  • Log flushed up to:代表 flushed_to_disk_lsn 的值,即当前系统已写入磁盘的 redo 日志量。
  • Pages flushed up to:代表 flush 链表中被最早修改的那个页面对应的 oldest_modification 属性值。
  • Last checkpoint at:当前系统的 checkpoint_lsn 值。

    redo log 数据恢复

    前面说过,checkpoint_lsn 之前的 redo 日志都可以被覆盖,因为这些 redo 日志对应的脏页都已经被刷新到磁盘中了。对于 checkpoint_lsn 之后的 redo 日志,它们对应的脏页我们无法确定是否已经刷盘,所以需要从 checkpoint_lsn 开始读取 redo 日志来恢复页面。此外,redo log 具有幂等性,多次操作得到同一结果的行为在日志中只会记录一次,所以不管是正常启动还是异常恢复,都会读取 redo log 数据进行恢复。
    image.png
    InnoDB 为了加快 redo 日志的读取速度,没有选择顺序读取 redo 日志后加载页面内容,而是使用了哈希表的结构。首先根据 redo 日志的 space_ID 和 page number 属性计算出散列值,然后把散列值相同的 redo 日志放到哈希表的同一个槽里,多个相同散列值的 redo 日志间按先后顺序使用链表连接起来,如下图所示:
    image.png
    之后就可以遍历哈希表,因为对同一个页面进行修改的 redo 日志都放在了一个槽里,所以可以一次性将一个页面修复好(避免了很多读取页面的随机 IO),这样可以加快恢复速度。由于同一个页面的 redo 日志是按照生成时间顺序进行排序的,所以恢复时也会按照这个顺序进行恢复。

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

为此,在数据页的 File Header 部分里有一个 FIL_PAGE_LSN 的属性,该属性记载了最近一次修改页面时对应的 lsn 值(其实就是页控制块中的 newest_modification 值)。如果在做了某次 checkpoint 后有脏页被刷新到磁盘中,那么该页对应的 FIL_PAGE_LSN 代表的 lsn 值肯定大于 checkpoint_lsn 的值,凡是符合这种情况的页面就可以直接跳过,这更进一步提升了崩溃恢复的速度。