好文: redolog

日志是 mysql 数据库的重要组成部分,记录着数据库运行期间各种状态信息。mysql日志主要包括错误日志、查询日志、慢查询日志、事务日志、二进制日志几大类。作为开发,我们重点需要关注的是二进制日志( binlog )和事务日志(包括redo logundo log ),本文接下来会详细介绍这三种日志。

binlog

binlog 用于记录数据库执行的写入性操作(不包括查询)信息,以二进制的形式保存在磁盘中。binlogmysql的逻辑日志,并且由 Server 层进行记录,使用任何存储引擎的 mysql 数据库都会记录 binlog 日志。

  • 逻辑日志:可以简单理解为记录的就是sql语句 。
  • 物理日志mysql 数据最终是保存在数据页中的,物理日志记录的就是数据页变更 。

binlog 是通过追加的方式进行写入的,可以通过max_binlog_size 参数设置每个 binlog文件的大小,当文件大小达到给定值之后,会生成新的文件来保存日志。

binlog使用场景

在实际应用中, binlog 的主要使用场景有两个,分别是 主从复制数据恢复

  1. 主从复制 :在 Master 端开启 binlog ,然后将 binlog发送到各个 Slave 端, Slave 端重放 binlog 从而达到主从数据一致。
  2. 数据恢复 :通过使用 mysqlbinlog 工具来恢复数据。

binlog刷盘时机

对于 InnoDB 存储引擎而言,只有在事务提交时才会记录binlog ,此时记录还在内存中,那么 binlog是什么时候刷到磁盘中的呢?mysql 通过 sync_binlog 参数控制 binlog 的刷盘时机,取值范围是 0-N

  • 0:不去强制要求,由系统自行判断何时写入磁盘;
  • 1:每次 commit 的时候都要将 binlog 写入磁盘;
  • N:每N个事务,才会将 binlog 写入磁盘。

从上面可以看出, sync_binlog 最安全的是设置是 1 ,这也是MySQL 5.7.7之后版本的默认值。但是设置一个大一些的值可以提升数据库性能,因此实际情况下也可以将值适当调大,牺牲一定的一致性来获取更好的性能。

binlog日志格式

binlog 日志有三种格式,分别为 STATMENTROWMIXED

MySQL 5.7.7

之前,默认的格式是 STATEMENT

MySQL 5.7.7

之后,默认值是 ROW

。日志格式通过 binlog-format

指定。

  • STATMENT:基于SQL 语句的复制( statement-based replication, SBR ),每一条会修改数据的sql语句会记录到binlog 中 。
    • 优点:不需要记录每一行的变化,减少了 binlog 日志量,节约了 IO , 从而提高了性能;
    • 缺点:在某些情况下会导致主从数据不一致,比如执行sysdate() 、 slepp() 等 。
  • ROW:基于行的复制(row-based replication, RBR ),不记录每条sql语句的上下文信息,仅需记录哪条数据被修改了 。
    • 优点:不会出现某些特定情况下的存储过程、或function、或trigger的调用和触发无法被正确复制的问题 ;
    • 缺点:会产生大量的日志,尤其是alter table 的时候会让日志暴涨
  • MIXED:基于STATMENTROW 两种模式的混合复制(mixed-based replication, MBR ),一般的复制使用STATEMENT 模式保存 binlog ,对于 STATEMENT 模式无法复制的操作使用 ROW 模式保存 binlog

redo log

为什么需要redo log

我们都知道,事务的四大特性里面有一个是 持久性 ,具体来说就是只要事务提交成功,那么对数据库做的修改就被永久保存下来了,不可能因为任何原因再回到原来的状态 。那么 mysql是如何保证持久性的呢?最简单的做法是在每次事务提交的时候,将该事务涉及修改的数据页全部刷新到磁盘中。但是这么做会有严重的性能问题,主要体现在两个方面:

  1. 因为 Innodb 是以 为单位进行磁盘交互的,而一个事务很可能只修改一个数据页里面的几个字节,这个时候将完整的数据页刷到磁盘的话,太浪费资源了!
  2. 一个事务可能涉及修改多个数据页,并且这些数据页在物理上并不连续,使用随机IO写入性能太差!

因此 mysql 设计了 redo log具体来说就是只记录事务对数据页做了哪些修改,这样就能完美地解决性能问题了(相对而言文件更小并且是顺序IO)。

redo log基本概念

redo log 包括两部分:一个是内存中的日志缓冲( redo log buffer ),另一个是磁盘上的日志文件( redo logfile)。

mysql 每执行一条 DML 语句,先将记录写入 redo log buffer,后续某个时间点再一次性将多个操作记录写到 redo log file。这种 先写日志,再写磁盘 的技术就是 MySQL里经常说到的 #「WAL(Write-Ahead Logging)」技术。

在计算机操作系统中,用户空间( user space )下的缓冲区数据一般情况下是无法直接写入磁盘的,中间必须经过操作系统内核空间( kernel space )缓冲区( OS Buffer )。因此, redo log buffer 写入 redo logfile 实际上是先写入 OS Buffer ,然后再通过系统调用 fsync() 将其刷到 redo log file

中,过程如下:

MySQL-日志 - 图1

mysql 支持三种将 redo log buffer 写入 redo log file 的时机,可以通过 innodb_flush_log_at_trx_commit 参数配置,各参数值含义如下:

MySQL-日志 - 图2

MySQL-日志 - 图3

redo log记录形式

前面说过, redo log 实际上记录数据页的变更,而这种变更记录是没必要全部保存,因此 redo log实现上采用了大小固定,循环写入的方式,当写到结尾时,会回到开头循环写日志。如下图:MySQL-日志 - 图4

同时我们很容易得知, 在innodb中,既有redo log 需要刷盘,还有 数据页 也需要刷盘, redo log存在的意义主要就是降低对 数据页 刷盘的要求 。在上图中, write pos 表示 redo log 当前记录的 LSN (逻辑序列号)位置, check point 表示 数据页更改记录** 刷盘后对应 redo log 所处的 LSN(逻辑序列号)位置。write poscheck point 之间的部分是 redo log 空着的部分,用于记录新的记录;check pointwrite pos 之间是 redo log 待落盘的数据页更改记录。当 write pos追上check point 时,会先推动 check point 向前移动,空出位置再记录新的日志。启动 innodb 的时候,不管上次是正常关闭还是异常关闭,总是会进行恢复操作。因为 redo log记录的是数据页的物理变化,因此恢复的时候速度比逻辑日志(如 binlog )要快很多。重启innodb 时,首先会检查磁盘中数据页的 LSN ,如果数据页的LSN 小于日志中的 LSN ,则会从 checkpoint 开始恢复。还有一种情况,在宕机前正处于checkpoint 的刷盘过程,且数据页的刷盘进度超过了日志页的刷盘进度,此时会出现数据页中记录的 LSN 大于日志中的 LSN,这时超出日志进度的部分将不会重做,因为这本身就表示已经做过的事情,无需再重做。

redo log 格式

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

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

MySQL-日志 - 图5

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

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

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

MySQL-日志 - 图6

除此之外,还有一些复杂的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。

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

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才会存储管理信息,其余的日志文件只是保留这些空间,不存储信息。

MySQL-日志 - 图7

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

MySQL-日志 - 图8

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有关系。

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的变化情况。

MySQL-日志 - 图9

可以看出,每一组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

MySQL-日志 - 图10

查看系统中的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 number 和 Log 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 了。

redo log与binlog区别

MySQL-日志 - 图11

binlogredo log 的区别可知:binlog 日志只用于归档,只依靠 binlog 是没有 crash-safe 能力的。但只有 redo log 也不行,因为 redo logInnoDB特有的,且日志上的记录落盘后会被覆盖掉。因此需要 binlogredo log二者同时记录,才能保证当数据库发生宕机重启时,数据不会丢失。

undo log

数据库事务四大特性中有一个是 原子性 ,具体来说就是 原子性是指对数据库的一系列操作,要么全部成功,要么全部失败,不可能出现部分成功的情况。实际上, 原子性 底层就是通过 undo log 实现的。undo log主要记录了数据的逻辑变化,比如一条 INSERT 语句,对应一条DELETEundo log ,对于每个 UPDATE 语句,对应一条相反的 UPDATEundo log ,这样在发生错误时,就能回滚到事务之前的数据状态。同时, undo log 也是 MVCC(多版本并发控制)实现的关键。