InnoDB redo log

MySQL 数据库对页中数据的修改都是在 Buffer Pool 中进行的,如果一个事务修改了 Buffer Pool 中的一个数据页,但是该数据页尚未刷新回磁盘,此时服务器挂了,Buffer Pool 中的数据修改就会丢失。
那么这个 持久性 怎么保证?MySQL 通过 redo log 来保证基于 Buffer Pool 的数据修改在服务器恢复后不会丢失,也就是会在事务执行期间将对数据库的修改通过 redo log 保存下来,待服务器恢复后通过重放该 redo log 达到数据不丢失的目的。

redo log 简介

redo log 实际上就是记录了事务执行过程中对数据库的修改,和刷新内存中数据页到磁盘相比,将 redo log 刷新到磁盘具有以下好处:

  • redo log 占用的空间小,因为只记录数据页修改的部分
  • 事务的一条语句产生的多条 redo log 是顺序写入磁盘的,也就是顺序IO

一条 redo log 主要由 type(日志类型)、space ID(表空间ID)、page number(页号) 和 data(日志的具体内容) 组成。

Mini-Transaction

MySQL 把对底层页面中的一次原子访问的过程称之为一个 Mini-Transaction,也就是一组更小的事务(对该页面的这组操作要么都完成要么都不完成)。比如上边所说的修改一次 Max Row ID 的值算是一个 Mini-Transaction,向某个索引对应的 B+ 树中插入一条记录的过程也算是一个 Mini-Transaction。

一个事务可以包含多条 SQL 语句,一条 SQL 语句可以包含多个 mtr,一个 mtr 可以产生多条 redo log。

redo log 写入过程

redo log block

InnoDB 把通过 mtr 生成的 redo log 放在大小为 512 字节的 redo log block 中,该 block 由 log block header(12字节)、log block body 和 log block trailer(4字节) 组成。

真正的 redo log 信息都存储在 log block body 中,log block header 和 log block trailer 存储了一些管理信息,包括以下几个信息:

  • LOG_BLOCK_HDR_NO:该 block 的唯一标记号

  • LOG_BLOCK_HDR_DATA_LEN :表示 block 中已经使用了多少字节,初始值为 12

  • LOG_BLOCK_FIRST_REC_GROUP:这个 block 里第一个 mtr 生成的第一条 redo log 的偏移量

  • LOG_BLOCK_CHECKPOINT_NO:checkpoint 的序号

redo log buffer

由于磁盘写入速度慢的问题,redo log 并不能直接写入磁盘,所在在服务器启动的时候就向操作系统申请一片称之为 redo log buffer 的连续内存空间。该内存被划分为若干个 redo log block

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

写入 redo log Buffer

  • 通过全局变量 buf_free 指明后续的 redo log 应该写到 redo log buffer 的哪个位置。

  • redo log 写入到 redo log Buffer 并不是一条一条写的,而是以 mtr 为单位写入的。不同事务的 mtr 的 redo log 可以交替写入。

redo 日志文件

刷盘时机

上文说到 redo log 在写入磁盘前会先保存到 redo log buffer 中,但是总要刷新到磁盘啊。在以下几种情况,redo log buffer 中的 redo log 会写入磁盘:

  • redo log buffer 空间不足时

    如果当前写入 redo log buffer 的 redo log 已经占满了 redo log buffer 总容量的大约一半左右,就需要把这些日志刷新到磁盘上。

  • 事务提交时

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

  • 后台线程定时执行

    后台有个线程,大约每秒会刷新到磁盘一次。

  • 正常关闭服务器时

  • checkpoint 时

redo 日志文件组

MySQL 的数据目录(使用 SHOW VARIABLES LIKE 'datadir' 查看)下默认有两个名为 ib_logfile0ib_logfile1 的文件,redo log buffer 中的日志默认情况下就是刷新到这两个磁盘文件中。也可以通过以下参数调整:

  • 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 日志文件大小其实就是:innodb_log_file_size × innodb_log_files_in_group

按顺序写入日志文件,如果最后一个日志文件也写满的话,则重新从第一个日志文件开始,也就是形成一个循环。那这样的话不是会覆盖掉前面写入的日志文件么?这就涉及到下面要说的 checkpoint 了。

redo 日志文件格式

前面说到 redo log buffer 由若干个 512 字节的 redo log block 组成,所以每个 redo 日志文件其实也是由若干个 512 字节的 block 组成。每个 redo 日志文件的前 4 个 block 用来存储管理信息,从第 5 个 block 开始存储 redo log。

那么前 4 个 block 存了些什么东西呢?

  • 第一个 block 为 log file header :描述该 redo 日志文件的一些整体属性
    • LOG_HEADER_FORMAT:日志版本
    • LOG_HEADER_START_LSN:标记本 redo 日志文件开始的 LSN 值
  • 第二个 block 为 checkpoint1:记录关于 checkpoint 的一些属性
    • LOG_CHECKPOINT_NO:checkpoint 的编号
    • LOG_CHECKPOINT_LSN:checkpoint 结束时的 LSN 值
    • LOG_CHECKPOINT_OFFSET:上个属性中的 LSN 值在 redo 日志文件组中的偏移量
    • LOG_CHECKPOINT_LOG_BUF_SIZE:服务器在做 checkpoint 操作时对应的 redo log buffer 的大小
  • 第三个 block 暂未使用
  • 第四个 block 为 checkpoint2:同 checkpoint1

上面这一大串的 checkpoint 和 LSN 下面会说到。

Log Sequeue Number

  • 规定初始的 lsn 值为 8704(也就是一条 redo 日志也没写入时,lsn 的值为 8704)
  • 系统第一次启动后初始化 redo log buffer 时,buf_free 就会指向第一个 block 的偏移量为12字节(log block header的大小)的地方,那么 lsn 值也会跟着增加 12
  • 如果某个 mtr 产生的一组 redo 日志占用的存储空间比较小,也就是待插入的 block 剩余空闲空间能容纳这个 mtr 提交的日志时,lsn 增长的量就是该 mtr 生成的 redo 日志占用的字节数
  • 如果某个 mtr 产生的一组 redo 日志占用的存储空间比较大,也就是待插入的 block 剩余空闲空间不足以容纳这个 mtr 提交的日志时,lsn 增长的量就是该 mtr 生成的 redo 日志占用的字节数加上额外占用的 log block header 和 log block trailer 的字节数
  • 每一组由 mtr 生成的 redo 日志都有一个唯一的 LSN 值与其对应,LSN 值越小,说明 redo 日志产生的越早。

flushed_to_disk_lsn

  • 全局变量 buf_next_to_write ,标记当前 redo log buffer 中已经有哪些日志被刷新到磁盘中了

  • 全局变量 flushed_to_disk_lsn,表示刷新到磁盘中的 redo 日志量

    系统第一次启动时,该变量的值和初始的 lsn 值是相同的,都是 8704。随着系统的运行,redo 日志被不断写入log buffer,但是并不会立即刷新到磁盘,lsn 的值增加,flushed_to_disk_lsn 的值不变,随着不断有 redo log buffer 中的日志被刷新到磁盘上,flushed_to_disk_lsn 的值也跟着增长。如果两者的值相同时,说明 redo log buffer 中的所有 redo 日志都已经刷新到磁盘中了。

flush链表中的 LSN

Buffer Pool 中数据页结构大致如下:

  • 每个缓存页有一个对应的控制块
  • 修改缓存页后会将对应的控制块加入到 flush 链表

  • 在 mtr 结束时,会把这一组 redo 日志写入到 redo log buffer 中。除此之外,在 mtr 结束时还要把在 mtr 执行过程中可能修改过的页面加入到 Buffer Pool 的 flush 链表

  • 当第一次修改某个缓存在 Buffer Pool 中的页面时,就会把这个页面对应的控制块插入到 flush 链表的头部,之后再修改对应控制块中记录两个关于页面何时修改的属性:

    • oldest_modification:如果某个页面被加载到 Buffer Pool 后进行第一次修改,那么就将修改该页面的 mtr 开始时对应的 lsn 值写入这个属性。
    • newest_modification: 每修改一次页面,都会将修改该页面的 mtr 结束时对应的 lsn 值写入这个属性。也就是说该属性表示页面最近一次修改后对应的系统 lsn 值。
  • flush 链表中的脏页按照修改发生的时间顺序进行排序,也就是按照 oldest_modification 代表的 LSN 值进行排序,被多次更新的页面不会重复插入到 flush 链表中,但是会更新 newest_modification 属性的值。

checkpoint

前面介绍日志文件组的时候说到,如果最后一个日志文件写满了的话会从回到第一个日志文件开始写。这样不就把第一个日志文件的内容覆盖了么?什么情况下可以覆盖呢?如果当日志文件中的 redo log 对应的脏页已经刷新回磁盘了,这个 redo log 部分就可以覆盖掉。

我们通过全局变量 checkpoint_lsn (代表当前系统中可以被覆盖的 redo 日志总量是多少,初始值也是8704)可以知道是否能覆盖。那么这个 checkpoint_lsn 值怎么来的呢?

比方说 Buffer Pool 数据页 A 刷新回磁盘了,此时做一次 checkpoint(也就是增加 checkpoint_lsn 值)。过程可以分为两步:

  1. 计算当前系统中可以被覆盖的 redo log 的 lsn 值最大是多少

    只要我们计算出当前系统中被最早修改的脏页对应的 oldest_modification 值,那凡是在系统 lsn 值小于该节点的 oldest_modification 值时产生的 redo 日志都是可以被覆盖掉的(因为对于缓存页已经刷新回磁盘了),我们就把该脏页的 oldest_modification 赋值给 checkpoint_lsn。

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

innodb_flush_log_at_trx_commit

innodb_flush_log_at_trx_commit 用来控制事务提交时是否需要立即向磁盘同步 redo 日志:

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

  • 值为 1:表示在事务提交时需要将 redo 日志同步到磁盘,可以保证事务的持久性;默认值

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

崩溃恢复

下面说说系统崩溃重启后怎么通过 redo log 来恢复数据。

确定起点

前面说到了一个 checkpoint_lsn ,小于它的 redo log 对应的脏页已经刷新回磁盘了,所以不用恢复了。但是大于 checkpoint_lsn 的 redo log 对应的脏页可能刷盘了也可能没刷盘,不能确定。所以需要从 checkpoint_lsn 开始读取 redo 日志来恢复页面。

从日志文件组的第一个文件的管理信息中的 checkpoint 的信息中获取 checkpoint_lsn 值以及它在 redo 日志文件组中的偏移量 checkpoint_offset。

确定终点

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

如何恢复

知道了起点和终点,那么按照顺序依次恢复即可,但是 InnoDB 做了两个优化:

  • 使用哈希表

    根据 redo 日志的 space ID 和 page number 属性计算出散列值,把 space ID 和 page number 相同的 redo 日志放到哈希表的同一个槽里;如果多个 redo log 的 space ID 和 page number 信息相同,也就是对应同一个数据页,那么它们之间按照生成顺序使用链表连接起来;

    之后就可以遍历哈希表,因为对同一个页面进行修改的 redo 日志都放在了一个槽里,所以可以一次性将一个页面修复好(避免了很多读取页面的随机IO),这样可以加快恢复速度。

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

    上面说到大于 checkpoint_lsn 值的 redo log 需不需要恢复是不确定的,原因是可能在做 checkpoint 之后,后台线程可能又从 LRU 链表和 flush 链表刷新了脏页回磁盘。那么怎么知道某个 redo log 对应的脏页是否在崩溃前就刷新回磁盘了呢?

    页面的 File Header 里有一个称之为 FIL_PAGE_LSN 的属性,该属性记载了最近一次修改页面时对应的 lsn 值,如果在做了某次 checkpoint 之后有脏页被刷新到磁盘中,那么该页对应的 FIL_PAGE_LSN 代表的 lsn 值肯定大于 checkpoint_lsn 的值。凡是符合这种情况的页面就不需要重复执行lsn值小于 FIL_PAGE_LSN 的 redo 日志了,所以更进一步提升了奔溃恢复的速度。

总结

  • 事务执行过程中对数据库的修改首先写入 redo log buffer,buffer 由 redo log block 组成
  • redo 日志文件组可以有多个日志文件,循环链表形式写入 redo log
  • lsn 作为 redo log 的 ‘年龄’,越早产生的 redo log 年龄越小
  • 全局变量 buf_free 表示后续 redo log 应该写到 redo log buffer 什么位置
  • 全局变量 buf_next_to_write 表示 redo log buffer 中该位置之前的 redo log 已经写到磁盘日志文件了
  • 全局变量 flushed_to_disk_lsn 表示刷新到磁盘中的 redo 日志量
  • 全局变量 checkpoint_lsn 表示日志文件哪些可以被覆盖(该值之前的 redo log 对应页已经刷新回磁盘了)
  • 崩溃恢复以 checkpoint 为起点,采用哈希表等优化手段加速恢复

参考书籍: