MySQL系列(7)— 事务持久性之RedoLog

3,470 阅读20分钟

系列文章:MySQL系列专栏

Redo Log

redo log 的意义

通过前面的文章我们已经了解到数据增删改的一个大致过程如下:

  • 先从索引中找到数据所在的表空间ID以及在表空间中的数据页的页号
  • 然后通过表空间ID+页号作为Key,去缓存页哈希表中查找Buffer Pool是否已经加载了这个缓存页。如果已经加载了缓存页,就直接读取这个缓存页。
  • 如果没有这个缓存页,就需要从磁盘表空间中加载数据页到内存,此时需要从Free链表获取一个空闲页加入LRU链表中,加载的数据页就会放到这个空闲的缓存页中。
  • 接着在对应的缓存页中执行增删改操作,被修改过的缓存页就变成了脏页,会加入Flush链表中。
  • 最后,后台线程会在一些时机将LRU链表尾部的冷数据和Flush链表中的脏页刷盘。

这个过程有个最大的问题就是,数据修改且事务已经提交了,但只是修改了Buffer Pool中的缓存页,数据并没有持久化到磁盘,如果此时数据库宕机,那数据不就丢失了!

但是也不可能每次事务一提交,就把事务更新的缓存页都刷新回磁盘文件里去,因为缓存页刷新到磁盘文件里是随机磁盘读写,性能是很差的,这会导致数据库性能和并发能力都很差。

所以此时就引入了一个 redo log 机制,在提交事务的时候,先把对缓存页的修改以日志的形式,写到 redo log 文件里去,而且保证写入文件成功才算事务提交成功。而且redo log顺序写入磁盘文件,每次都是追加到磁盘文件末尾去,速度是非常快的。之后再在某个时机将修改的缓存页刷入磁盘,这时就算数据库宕机,也可以利用redo log来恢复数据。

这就是MySQL里经常说到的WAL技术,WAL 的全称是Write-Ahead Logging,它的关键点就是先写日志,再写磁盘

redo log 格式

redo log 本质上记录的就是对某个表空间的某个数据页的某个偏移量的地方修改了几个字节的值,它需要记录的其实就是 表空间号+数据页号+偏移量+修改的长度+具体的值,所以 redo log 占用的空间非常小,一条 redo log 也就几个字节到几十个字节的样子。

针对不对的修改场景,InnoDB定义了多种类型的 redo log,不同类型的 redo log 基本上就是下面这样的一个结构。

image.png

日志类型就有50多种,其中最简单的几种类型就是根据修改了几个字节的值来划分的:

  • MLOG_1BYTE:修改了1字节的值。
  • MLOG_2BYTE:修改了2字节的值。
  • MLOG_4BYTE:修改了4字节的值。
  • MLOG_8BYTE:修改了8字节的值。
  • MLOG_WRITE_STRING:写入一串数据。

MLOG_WRITE_STRING类型的 redo log 表示写入一串数据,但是因为不能确定写入的数据占多少字节,所以需要在日志结构中添加一个长度字段来表示写入了多长的数据。

image.png

除此之外,还有一些复杂的redo log类型来记录一些复杂的操作。例如插入一条数据,并不仅仅只是在数据页中插入一条数据,还可能会导致数据页和索引页的分裂,可能要修改数据页中的头信息(Page Header)、目录槽信息(Page Directory)等等。

例如下面的一些复杂日志类型:

  • MLOG_REC_INSERT:插入一条非紧凑行格式的记录的 redo log。
  • MLOG_COMP_REC_INSERT:插入一条紧凑行格式的记录的 redo log。
  • MLOG_COMP_REC_DELETE::删除一条使用紧凑行格式记录的 redo log。
  • MLOG_COMP_PAGE_CREATE:创建一个存储紧凑行格式记录的页面的 redo log。

关于日志格式我们知道这么多就行了,对日志的结构和类型有个大概的认识就可以了。

Mini-Transaction

一个事务中可能有多个增删改的SQL语句,而一个SQL语句在执行过程中可能修改若干个页面,会有多个操作。

例如一个 INSERT 语句:

  • 如果表没有主键,会去更新内存中的Max Row ID属性,并在其值为256的倍数时,将其刷新到系统表空间的页号为7Max Row ID属性处。

  • 接着向聚簇索引插入数据,这个过程要根据索引找到要插入的缓存页位置,向数据页插入记录。这个过程还可能会涉及数据页和索引页的分裂,那就会增加或修改一些缓存页,移动页中的记录。

  • 如果有二级索引,还会向二级索引中插入记录。

  • 最后还可能要改动一些系统页面,比如要修改各种段、区的统计信息,各种链表的统计信息等等。

也就是说一个SQL语句在底层可能会有很多操作,会记录很多条 redo log,但是一些操作是不可分割的,是一个原子的。例如向聚簇索引插入记录,这个操作是不可分割,不能只完成其中一部分。

所以InnoDB将执行语句的过程中产生的redo log划分成了若干个不可分割的组,一组redo log就是对底层页面的一次原子访问,这个原子访问也称为 Mini-Transaction,简称 mtr。一个 mtr 就包含一组redo log,在崩溃恢复时这一组redo log就是一个不可分割的整体。

一个事务可以包含若干条SQL语句,每一条SQL语句其实是由若干个mtr组成,每一个mtr又可以包含若干条redo log,看起来就是下图所示的结构。

image.png

写入 Redo Log

redo log block

redo log 并不是一条一条写入磁盘的日志文件中的,而且一个原子操作的 mtr 包含一组 redo log,一条一条的写就无法保证写磁盘的原子性了。

InnoDB设计了一个 redo log block 的数据结构,称为重做日志块(block),重做日志块跟缓存页有点类似,只不过日志块记录的是一条条 redo log。一个 mtr 中的 redo log 实际上是先写到一个地方,然后再将一个 mtr 的日志记录复制到block中,最后在一些时机将block刷新到磁盘日志文件中。

一个 redo log block 固定 512字节 大小,由三个部分组成:12字节的header块头,496字节的body块体,4字节的trailer块尾。redo log 就是存放在 body 块体中,也就是一个块实际只有 496字节 用来存储 redo log。

image.png

block header 块头记录了四个信息:

  • LOG_BLOCK_HDR_NO:表示块的唯一编号。

  • LOG_BLOCK_HDR_DATA_LEN:表示 block 中已经使用了多少字节,初始值为12,因为body从第12个字节处开始。如果block body已经被全部写满,那么本属性的值就被设置为512

  • LOG_BLOCK_FIRST_REC_GROUP:表示block中第一个mtr日志组中的第一条 redo log 的偏移量。

  • LOG_BLOCK_CHECKPOINT_NO:表示 checkpoint 的序号,后面会介绍。

block trailer 只记录了一个信息:

  • LOG_BLOCK_CHECKSUM:表示block的校验值。

redo log buffer

Buffer Pool 类似的,服务器启动时,就会申请一块连续的内存空间,作为 redo log block 的缓冲区也就是 redo log buffer。然后这片内存空间会被划分成若干个连续的 redo log block,redo log 就是先写到 redo log buffer 中的 redo log block 中的。

image.png

可以通过启动参数innodb_log_buffer_size来指定log buffer的大小,该参数的默认值为16MB

mysql> SHOW VARIABLES LIKE 'innodb_log_buffer_size';
+------------------------+----------+
| Variable_name          | Value    |
+------------------------+----------+
| innodb_log_buffer_size | 16777216 |
+------------------------+----------+

redo log 是以一个 mtr 为单位写入 block 中的,多个事务并发执行可能会有多组mtr,也就是说不同事务的 mtr 可能会交叉写入 block 中。

比如有两个事务T1、T2:

  • T1 事务产生了两组日志:mtr_T1_1,mtr_T1_2
  • T2 事务也产生了两组日志:mtr_T2_1,mtr_T2_2

看起来可能就像下图这样,两个事务中的两组mtr交叉写入block中,每个mtr的大小也不一样,有些大的mtr甚至会占超出一个block的大小。

image.png

图中还有一个buf_free,这是InnoDB设计的一个全局变量,用来指向 log buffer 中可以写入log的位置。

redo log 刷盘

log block 跟 Buffer Pool 中的缓存页一样,会在一些时机刷入磁盘中。

刷盘时机

主要有下面的一些时机会刷盘:

  • log buffer 空间不足时

如果写入 log buffer 的日志占据了 log buffer 总容量的一半了,默认情况下也就是超过8MB的时候,此时就会把他们刷入到磁盘文件里去。

这种情况一般在高并发的场景下可能会出现,每秒执行了很多增删改SQL语句,产生的redo log 瞬间超过了8M,然后就立马触发刷新 log block 到磁盘。不过这种情况一般比较少。

  • 事务提交时

一个事务提交的时候,必须把它的redo log都刷入到磁盘文件里去,只有这样,才能保证事务的持久性,才算事务提交成功了(这就是force log at commit机制,即在事务提交的时候,必须先将该事务的所有事务日志写入到磁盘上的日志文件中进行持久化)。如果在写入的过程中MySQL宕机了,那事务也就失败了。

比如前面的事务T2的 redo log 占据了3个block,在提交T2事务时,就必须把这3个block都刷入磁盘。

  • 后台线程刷盘:后台有一个线程会每隔1秒,把redo log block刷到磁盘文件里去。

  • MySQL关闭的时候,redo log block都会刷入到磁盘里去。

  • checkpoint 的时候。这个后面会说。

需要注意的是,不管什么时机刷盘,redo log block 始终是顺序刷盘的,比如事务提交的时候,会把这个事务mtr之前的block都刷入磁盘。

比如下面的T1、T2事务,在事务T1提交的时候,虽然事务T2还没完成,但会把图中箭头所指的位置之前的block都刷入磁盘。这个刷盘是时时刻刻都在进行的,所以一次刷盘也不会有很多block。

image.png

刷盘策略

在提交事务的时候,InnoDB会根据配置的策略来将 redo log 刷盘,这个参数可以通过 innodb_flush_log_at_trx_commit 来配置。

可以配置如下几个值:

  • 0:事务提交时不会立即向磁盘中同步 redo log,而是由后台线程来刷。这种策略可以提升数据库的性能,但事务的持久性无法保证。

  • 1:事务提交时会将 redo log 刷到磁盘,这可以保证事务的持久性,这也是默认值。其实数据会先写到操作系统的缓冲区(os cache),这种策略会调用 fsync 强制将 os cache 中的数据刷到磁盘。

  • 2:事务提交时会将 redo log 写到操作系统的缓冲区中,可能隔一小段时间后才会从系统缓冲区同步到磁盘文件。这种情况下,如果机器宕机了,而系统缓冲区中的数据还没同步到磁盘的话,就会丢失数据。

为了保证事务的持久性,一般使用默认值,将 innodb_flush_log_at_trx_commit 设置为1即可。

redo log 文件组

MySQL会不停的执行增删改SQL语句,然后不断的产生 redo log,那这么多 redo log 不可能全部存到磁盘文件中。其实也没必要,因为 redo log 只是用来恢复数据的,那已经持久化到表空间的数据就不会用 redo log 来恢复了,也就是说可用的 redo log 的量其实是比较少的。下面来看下 redo log 是如何写入磁盘文件的。

redo log 会写入一个目录下的日志文件中,其实是一组日志文件。

这个目录默认就是数据目录,可以通过如下命令查看:

mysql> SHOW VARIABLES LIKE 'datadir';
+---------------+-----------------+
| Variable_name | Value           |
+---------------+-----------------+
| datadir       | /var/lib/mysql/ |
+---------------+-----------------+

默认在数据目录下可以看到有 ib_logfile0ib_logfile1 两个文件,这就是一组日志文件。默认一组中有两个日志文件,文件名的格式为 ib_logfile[x]x 为从0开始的数字)。

image.png

我们可以通过如下参数来调整 log buffer 的配置:

  • innodb_log_buffer_size:指定 redo log buffer 的大小,默认为 16MB
  • innodb_log_group_home_dir:指定redo log文件所在的目录,默认值就是当前的数据目录。
  • innodb_log_file_size:指定每个redo log文件的大小,默认值为48MB
  • innodb_log_files_in_group:指定redo log文件的个数,默认值为2,最大值为100
mysql> SHOW VARIABLES LIKE 'innodb_log_%';
+-----------------------------+----------+
| Variable_name               | Value    |
+-----------------------------+----------+
| innodb_log_buffer_size      | 16777216 |
| innodb_log_checksums        | ON       |
| innodb_log_compressed_pages | ON       |
| innodb_log_file_size        | 50331648 |
| innodb_log_files_in_group   | 2        |
| innodb_log_group_home_dir   | ./       |
| innodb_log_write_ahead_size | 8192     |
+-----------------------------+----------+

在将 redo log 写入日志文件组时,是从 ib_logfile0 开始写,如果 ib_logfile0 写满了,就接着ib_logfile1 写,ib_logfile1 写满了就去写 ib_logfile2,依此类推。如果写到最后一个文件也满了,就会重新转到ib_logfile0覆盖写入。

整个过程如下图所示:

image.png

redo log 文件格式

前面已经知道,redo log 是先写入 redo log buffer 中的 redo log block 中的,然后事务提交时,会将 log block 写入磁盘中的 redo log 文件。redo log 文件是一组日志文件,默认在数据目录下就有两个 48MB 的日志文件。

log block 固定为512字节大小,redo log 文件也是一样按512字节来划分的,每个 redo log 文件的格式也是一样的,都由若干个512字节的块组成。

每个 redo log 文件由两部分组成:

  • 2048字节,也就是前4个block是用来存储一些管理信息。其中第1个 block 存储文件头信息,第2个和第4个存储checkpoint,第3个block保留未没用。

  • 从第2048字节往后是用来存储 redo log block 的。

所以在循环写日志文件的时候,其实是从每个日志文件的第2048字节 开始的。但需要注意的是,一组日志文件中,只有第1个日志文件的前4个block才会存储管理信息,其余的日志文件只是保留这些空间,不存储信息。

image.png

其中,文件头信息和两个checkpoint包含的信息如下图所示。

image.png

header 中的各个属性:

  • LOG_HEADER_FORMAT:redo日志的版本
  • LOG_HEADER_PAD1:做字节填充用的,没什么实际意义
  • LOG_HEADER_START_LSN:标记本日志文件开始的LSN值,初始值就2048,指向文件偏移量2048字节处。
  • LOG_HEADER_CREATOR:标记本日志文件的创建者。
  • LOG_BLOCK_CHECKSUM:本block的校验值

checkpoint 中的各个属性:

  • LOG_CHECKPOINT_NO:服务器做checkpoint的编号,每做一次checkpoint,该值就加1
  • LOG_CHECKPOINT_LSN:服务器做checkpoint结束时对应的LSN值,系统崩溃恢复时将从该值开始。
  • LOG_CHECKPOINT_OFFSET:上个属性中的LSN值在redo日志文件组中的偏移量。
  • LOG_CHECKPOINT_LOG_BUF_SIZE:服务器在做checkpoint操作时对应的log buffer的大小。
  • LOG_BLOCK_CHECKSUM:本block的校验值。

LSN

前面已经知道,redo log 是循环写入日志文件组中的,那么就会有个问题,如何保证哪些 redo log 是可以被覆盖的呢?redo log 是用来恢复数据的,其实只要 redo log 对应的脏页已经刷到磁盘了,那这部分 redo log 就没用了。那恢复数据的时候又应该恢复哪部分数据呢?这一切都和LSN有关系。

LSN

InnoDB设计了一个全局变量 Log Sequence Number,简称 LSN,就是日志序列号的意思。LSN就代表写入的日志总量,LSN 的初始值是 8704,占用8个字节,且是单调递增的。

还是以前面T1、T2事务为例,假设T1、T2事务产生的mtr大小如下:

  • T1事务:mtr_T1_1 120字节,mtr_T1_2 200字节
  • T2事务:mtr_T2_1 862字节,跨了3个block,mtr_T2_2 100字节

LSN 不仅包含 redo log 的大小,还包含了 block 的块头和块尾。下面这张图就展示了伴随着T1、T2事务mtr的写入,LSN的变化情况。

image.png

可以看出,每一组mtr都有一个唯一的LSN值与其对应,LSN 值越小,说明对应mtr中的redo log产生的越早。

Flush链表中的LSN

事务产生的mtr写入log block后,会将修改的脏页加入到Flush链表头部,Flush链表对应的描述信息块中会有两个属性来记录LSN信息:

  • oldest_modification:记录mtr开始的LSN值。
  • newest_modification:记录mtr结束时的LSN值。

接着另一个mtr写入后,可能Flush链表中已经存在了对应的脏页,此时会将mtr结束时的LSN值写入newest_modification,原本的oldest_modification则保持不变。

实际上Flush链表中的脏页就是按照修改发生的时间顺序进行排序,也就是按照oldest_modification代表的LSN值进行排序的。链表靠近尾部的是最早修改的,链表头部则是最新修改的。

页中的LSN

前面介绍过数据页的结构,在它的File Header中有一个属性 FIL_PAGE_LSN,它表示页面最后被修改时的日志序列位置LSN。这个属性在用 redo log 来恢复数据的时候也起着重要的作用。

在事务中执行增删改SQL语句时,会更新LRU链表中的缓存页,然后将这些缓存页加入Flush链表的头部,在向log block中写入一个mtr后,就会将最新的LSN值写入所在页中的FIL_PAGE_LSN属性。

还是以上面那张T1、T2事务的图为例。比如写入了mtr_T1_1后,这个mtr中的 redo logo 相关的缓存页都会加入 Flush链表中,然后这些缓存页中的FIL_PAGE_LSN都会更新为 9448。在写入了 mtr_T1_2 后,相关的缓存页中的FIL_PAGE_LSN都会更新为10542

checkpoint

回到开头的问题,刷入磁盘中的哪部分redo log可以被覆盖呢?

redo log 只是为了系统崩溃后恢复脏页用的,如果对应的脏页已经刷新到了磁盘,那么就算崩溃后也用不着这部分 redo log 了,那么它占用的磁盘空间就可以被覆盖重用。如果脏页没有刷入磁盘,那么对应的 redo log 就必须保留着。

InnoDB 设计了一个全局变量 checkpoint_lsn 来代表当前系统中可以被覆盖的redo log总量是多少,这个变量初始值也是8704。当脏页被刷入磁盘时,就会做一次 checkpoint 来计算 checkpoint_lsn 的值,并写入 redo log 文件中。

做 checkpoint 主要有两个步骤:

  • 计算checkpoint_lsn

脏页只要已经刷入磁盘,那他们对应的redo log就可以被覆盖,那如何判断哪些脏页已经刷入磁盘呢?

前面说过 Flush链表 中的脏页是按修改时间,也就是oldest_modification代表的LSN值排序的,链表尾部的脏页就是最早修改的,它所对应的oldest_modification就是最小的一个LSN值,那这个LSN之前的脏页就是已经刷入磁盘的。

在做 checkpoint 时,其实就是将Flush链表尾部的脏页的oldest_modification赋值给checkpoint_lsn

  • 写入checkpoint

接着根据checkpoint_lsn计算对应的redo log文件日志偏移量checkpoint_offset

InnoDB还设计了一个全局变量checkpoint_no,代表checkpoint的次数,每做一次checkpoint,这个值就会加1

然后就会将这些信息写入日志文件组中的第一个日志文件的checkpoint中。至于存到 checkpoint1 还是 checkpoint2,则根据checkpoint_no来计算,如果是偶数,就写到checkpoint1,如果是奇数,就写入checkpoint2

可以看到checkpoint中就有三个属性来存储这些信息:

  • checkpoint_no 写入 LOG_CHECKPOINT_NO
  • checkpoint_lsn 写入 LOG_CHECKPOINT_LSN
  • checkpoint_offset 写入 LOG_CHECKPOINT_OFFSET

image.png

查看系统中的LSN信息

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

---
LOG
---
Log sequence number 294669958009
Log flushed up to   294669958009
Pages flushed up to 294669957358
Last checkpoint at  294669957349
0 pending log flushes, 0 pending chkp writes
21957055 log i/o's done, 1.98 log i/o's/second

其中的信息如下:

  • Log sequence number:代表系统中的LSN值,也就是当前系统已经写入的redo log总量。

  • Log flushed up to:代表当前系统已经写入磁盘的redo log量。

  • Pages flushed up to:代表Flush链表尾部最早被修改的那个页面对应的oldest_modification属性值。

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

例如上面的信息中,Log sequence numberLog flushed up to 相等,说明 redo log buffer 中的redo log 都已经刷到 redo log 文件了。但是 Last checkpoint at 小于 Log sequence number,说明还有一部分脏页在Flush链表中没有刷到磁盘。

恢复

InnoDB在启动时不管上次数据库是否正常关闭,都会尝试进行恢复操作。如果数据库是正常关闭,redo log 其实没什么用,但如果数据库宕机,redo log 就可以用来恢复数据了。

恢复的起点

首先要读取日志组中的第一个 redo log 文件头部的两个 checkpoint,先比较其中的 checkpoint_no,哪个大就使用哪个 checkpoint。

然后读取 checkpoint_lsn,这个值之前的都是已经刷盘了的,但之后的可能刷盘了,也可能没有刷盘。所以恢复的起点就是 checkpoint_lsn 对应的文件偏移量,从这个偏移量开始读取 redo log 来恢复页面。

恢复的终点

redo log block 的头部header中有一个属性 LOG_BLOCK_HDR_DATA_LEN 记录了当前block里使用了多少字节的空间,对于被写满的block来说,该属性就是512。如果该属性的值不为512,说明这个block还没写满,那终点就是这个block了。

使用哈希表

读取到内存中的 redo log,并不是直接就按顺序去重做页的。而是使用了一个哈希表来加快恢复的速度。

它会根据 redo log 的表空间ID页号计算出散列值,以此作为哈希表的 Key,哈希表的 Value 则是一个链表,相同表空间ID和页号的 redo log 就会挨个按顺序加入这个链表中。

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

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

checkpoint_lsn 之前的可以保证 redo log 对应的脏页已经刷盘了,但是之后的就不能确定了。因为在做 checkpoint 之后,可能一些脏页会不断的被刷到磁盘中,那这部分 redo log 就不能在页中重做一遍。

这个时候就会用到前面说过的页中的FIL_PAGE_LSN属性,这个属性记录了最近一次修改页面对应的LSN值。

如果在做了某次checkpoint之后有脏页被刷新到磁盘中,那么该页对应的FIL_PAGE_LSN代表的LSN值肯定大于checkpoint_lsn的值,对于这种页面就不需要在应用 redo log 了。