MySQL之InnoDB存储引擎:浅谈Redo Log重做日志
前面我们提到InnoDB存储引擎是支持事务的。这里我们就来了解如何实现事务的持久性,即所谓的Redo Log重做日志
楔子
我们对MySQL中记录的修改是在内存中的,具体地是在Buffer Pool缓冲池中。那么一旦出现意外(服务器故障、断电等)即会导致内存数据丢失,这显然无法满足事务的持久性要求。那么容易想到的持久性的实现方案是实时同步——即每次事务提交完成后就将内存中相关被修改的页同步回硬盘。但是该方案缺点也很明显,首先,我们知道内存与硬盘交互的基本单位是页,也就是说即使我们只是修改了页中几个字节的数据,也需要将整个页同步到硬盘中;其次,一条SQL语句有时却可能还需要修改其它的若干个页。比如在插入一条记录时,我们还需要适时调整其它页来维护整个聚簇索引的B+树。这个时候如果这些需要调整的页还不是相邻的,那么就需要通过多次的随机IO将其加载到内存中,待修改完再同步到硬盘中。总而言之,即该方案效率十分低下。基于此InnoDB引擎提出了redo log重做日志,对于redo log中的一条记录而言,其负责记录了用户修改的位置、数据内容。故占用空间较小;其次,对于若干条redo log的记录而言,其是按生成顺序写到磁盘的,即所谓的顺序IO
redo log的记录格式
前面提到,redo log重做日志用于记录我们对数据库数据所做的修改。虽然redo log中有很多类型的记录。但总的来说,不论是简单类型还是复杂类型,其记录格式均是统一的,如下所示。可以看到记录中包括记录的类型、表空间ID、页号和日志内容。众所周知,通过表空间ID+页号即可唯一定位需要进行修改的页。其中type类型字段使用1个字节
简单类型
有时候我们仅仅只是修改某个页中的若干个字节,且该修改并不会影响到其它页中的数据。这个时候即可通过简单类型记录来记录所做的修改。即在日志内容中记录页面偏移量来确定修改页内位置和修改的数据内容即可,如下图所示
具体地,根据修改的数据内容的字节数可细分为以下类型的日志记录
MLOG_1BYTE:该类型记录的type字段值为0x01,意为在某页面指定偏移量的位置处写入1个字节的数据 MLOG_2BYTE:该类型记录的type字段值为0x02,意为在某页面指定偏移量的位置处写入2个字节的数据 MLOG_4BYTE:该类型记录的type字段值为0x04,意为在某页面指定偏移量的位置处写入4个字节的数据 MLOG_8BYTE:该类型记录的type字段值为0x08,意为在某页面指定偏移量的位置处写入8个字节的数据
另外,还有支持不定长数据的日志记录类型——MLOG_WRITE_STRING。其会在页面偏移量后记录数据的长度(即字节数)
复杂类型
对于有些数据库操作而言,例如向数据表插入一条新的用户记录。一方面,其可能导致页分裂并通过调整来维护该B+树;另一方面,对于新记录所在的数据页而言,其修改操作也远不止仅仅添加一条新的用户记录那么简单(还需维护Page Header的统计信息、Page Directory的槽信息、页内记录间的单向链表)。总而言之,一个数据库操作所涉及到的修改有时候可能会非常非常多。在此种情况下,如果是将该操作对应的多处修改均使用上面提到的简单类型的redo log日志进行记录,那么很可能多条redo log记录占用的空间比一个页都大;而如果将对该页中 需修改的第一个字节 到 需修改的最后一个字节 视为一个整体的话,使用一条redo log记录进行记录的话。也是比较浪费空间的,因为这中间还有大部分数据其实是无需修改的。故针对不同的数据库操作,InnoDB提供了各自类型的redo log记录,即所谓的复杂类型。常见地有
MLOG_REC_INSERT:该类型记录的type字段值为0x09,用于表示 插入一条非紧凑行格式的记录 MLOG_COMP_REC_INSERT:该类型记录的type字段值为0x26,用于表示 插入一条紧凑行格式的记录 MLOG_COMP_REC_DELETE:该类型记录的type字段值为0x2A,用于表示 删除一条紧凑行格式的记录 MLOG_COMP_LIST_START_DELETE、MLOG_COMP_LIST_END_DELETE:该两种类型记录的type字段值分别为0x2C、0x2B,分别用于表示批量删除紧凑行格式的记录时的起始记录、结束记录 MLOG_ZIP_PAGE_COMPRESS:该类型记录的type字段值为0x33,用于表示 压缩一个数据页
对于这些复杂类型的redo log记录而言,其只会在记录的日志内容处记录进行相关的数据库操作时必要的数据。当MySQL在崩溃恢复时会将该redo log记录的日志内容作为参数,通过调用相关函数实现插入、删除等数据库操作。至于页内数据(例如Page Header的统计信息、Page Directory的槽信息等等)将会在相关函数执行过程中进行调整、修改。即可以将复杂类型的redo log视为逻辑日志
Note
所谓非紧凑类型的行格式,有Redundant行格式;而对于紧凑类型的行格式而言,典型地有Compact、Dynamic、Compressed等行格式
Mini-Transaction
前面我们提到,对于一条SQL语句操作可能会产生多条redo log记录。典型地,在页分裂操作时会生成多条redo log记录,如果恢复过程只恢复了部分,即会导致B+树被破坏掉。这里我们将对底层页中的一次原子访问、操作的过程称为Mini-Transaction(MTR) 。InnoDB适当的将这些redo log记录划分为若干个组,每个组中含有若干条redo log记录,如下图所示。对于这一个组中所有的redo log记录而言,为了保证操作的原子性,即其是不可再进行分割的。在MySQL崩溃后重启恢复时,对于一个组中的redo日志,要么全部进行恢复,要么一条也不恢复。总而言之,一个MTR包含一组redo log记录,其是恢复时的最小执行单元
为了实现将MTR组内的多条redo log记录作为一个整体进行恢复。其会在组内的最后一条redo log记录后追加一条类型为MLOG_MULTI_REC_END的redo log记录,该类型记录的type字段值为0x1F。与我们前面所介绍的各种类型的redo log记录不同的是,该记录只含有type字段,无其他组成部分。在恢复过程中,直到解析了该类型的redo log记录时,才认为解析了完整的一组redo log记录;否则将直接丢弃之前解析的redo log记录。特别地,如果某个需要保证原子性操作只生成了一条redo log记录,即该组内只含一条redo log记录时,其不是通过在后面追加类型为MLOG_MULTI_REC_END的redo log记录作为组标识的。因为type字段虽然占用1个字节的空间,但其只使用了7个比特位用于表示redo log记录的类型。故其可以通过type字段中未使用的1个比特位作为组标识,即当该比特位为1时,表示该记录是组内唯一的一条redo log记录
存储方式
Redo Log Block
我们知道,MySQL下使用各种类型的页来存放不同类型的数据。对于redo log也不例外,通常我们将用于存储redo log的页称之为Block,其中一个block占用512B的空间。其内部结构如下所示
log block header
该部分使用12B的空间大小。其含有的属性及意义如下
LOG_BLOCK_HDR_NO:用于标示Block的唯一编号 LOG_BLOCK_HDR_DATA_LEN:意为该Block已使用的字节数。若该Block的log block body中无redo log,则该值为12。因为log block header部分使用了12个字节;若该Block的log block body中已被写满,则该值为512 LOG_BLOCK_FIRST_REC_GROUP:该Block中第一个MTR起始处的偏移量 LOG_BLOCK_CHECKPOINT_NO:checkpoint的序号
log block body
该部分使用496B的空间大小。用于存储redo log记录
log block trailer
该部分使用4B的空间大小。其只有一个LOG_BLOCK_CHECKSUM属性,意为该Block的校验和
Redo Log Buffer
前面我们说为了提高页在内存、硬盘之间交互时的效率,InnoDB引入了Buffer Pool缓冲池。类似地针对redo log来说,InnoDB同样为其引入了所谓的Redo Log Buffer,即redo log缓冲区。MySQL服务在启动后会向OS申请一块连续的内存空间将其作为Redo Log Buffer,并将其分为若干个连续的Redo Log Block。如下图所示
具体地,可通过配置文件中配置参数innodb_log_buffer_size来设置Redo Log Buffer的大小,单位为字节
[server]
# 设置Redo Log Buffer为16MB
innodb_log_buffer_size = 16777216;
此外,还可以通过(全局)系统变量innodb_log_buffer_size来进行查看、修改
-- 查看(全局)系统变量 innodb_log_buffer_size
show global variables like 'innodb_log_buffer_size';
-- 修改(全局)系统变量 innodb_log_buffer_size, 设置Redo Log Buffer为16MB
set global innodb_log_buffer_size = 16777216;
在MySQL运行过程中,其会将MTR所生成的redo log记录先存放在其他地方。直到该MTR结束生成了该组全部的redo log记录后,再将该组全部的redo log记录复制到Redo Log Buffer中。具体地,在向Redo Log Buffer中写入redo log记录时,是顺序使用Redo Log Buffer中各Block的,即先使用前面的Block再使用后面的Block。与此同时,其内部通过全局变量buf_free指向Redo Log Buffer中空闲区域的起始位置
众所周知在MySQL实际运行过程中,可能会有多个事务并发执行。当一个事务的一个MTR结束之时,其所生成该组的redo log记录就需要全部写入Redo Log Buffer中。换言之,多个事务的redo log记录会是以MTR为单位被交替写入到Redo Log Buffer中。假设这里有事务1、事务2,这两个事务均有两个MTR。当事务1、事务2之间一旦有某个MTR结束,即会将所其生成的若干条redo log记录顺序写入到Redo Log Buffer,则有可能会出现如下图所示的交替写入的情况
Redo Log File
配置
存放在Redo Log Buffer中的redo log记录毕竟依然还是在内存当中,一旦发生意外(服务器宕机、断电等)就会导致记录数据丢失。服务重启后的数据恢复工作由于没有redo log记录更是无从谈起。故MySQL需要将Redo Log Buffer中的数据同步到硬盘中。一般地,在MySQL的数据目录下会有两个分别名为ib_logfile0、ib_logfile1的日志文件,用于存储redo log。其中可通过 show variables like 'datadir' 语句查看数据目录
具体地,可通过配置文件中配置参数来进行相关配置。其中下面各配置项亦是同名的只读的全局系统变量,即可通过 show variables 语句进行查看
[server]
# 设置Redo Log 日志文件的存放路径
innodb_log_group_home_dir=E:/MySQLData/redoLog
# 设置每个redo log日志文件的大小为4MB,该配置项单位为字节
innodb_log_file_size=4194304
# 设置redo log日志文件的数量
innodb_log_files_in_group=5
易知,对于redo log file使用的空间大小为 innodb_log_file_size × innodb_log_files_in_group。日志文件的文件名则是通过数字进行编号的。例如当我们设置了innodb_log_files_in_group为5,则就会有5个日志文件,且命名分别为ib_logfile0、ib_logfile1、...、ib_logfile4。通常我们将这些日志文件称之为日志文件组。在将Redo Log Buffer中的内容写入磁盘时,也是顺序写入这些日志文件的。即先写第一个日志文件ib_logfile0,如果满了就写到ib_logfile1文件中,依次类推。如果最后一个日志文件ib_logfile4也满了,则又会从第一个日志文件ib_logfile0开始写,即所谓的循环写入
内部结构
前面我们提到Redo Log Buffer中是由若干个Block组成的。事实上对于Redo Log File而言,其内部同样是由若干个512B大小的Block组成的,其内部结构示意图如下所示
可以看到对于一个Redo Log File日志文件来说。其大致可分为两部分:前4个Block用于存储一些属性、描述信息;剩余Block则用来存放Redo Log Buffer中的Redo Log Block。这里我们重点介绍下日志文件的前4个Block的结构
log file header
LOG_HEADER_FORMAT:该属性使用4个字节,标识redo log的版本 LOG_HEADER_PAD1:该属性使用4个字节,用于填充字节、无实际用途 LOG_HEADER_START_LSN:该属性使用8个字节,标识该日志文件开始的LSN值。即文件内偏移量为2048字节处对应的LSN值 LOG_HEADER_CREATOR:该属性使用32个字节,标识该日志文件的创建者信息。通常情况下,其内容为MySQL的版本信息;如果该日志文件是通过mysqlbackup命令进行创建的,则该属性内容为"ibbackup"和创建时间 Not Used:未使用,占用460个字节 LOG_BLOCK_CHECKSUM:该属性使用4个字节,标识该Block的校验和
checkpoint 1、checkpoint 2
对于checkpoint 1、checkpoint 2的这两个Block而言,其内部结构完全一样
LOG_CHECKPOINT_NO:该属性使用8个字节,标识checkpoint操作的序号 LOG_CHECKPOINT_LSN:该属性使用8个字节,标识checkpoint操作时对应的LSN值。恢复数据时从该值开始 LOG_CHECKPOINT_OFFSET:该属性使用8个字节,标识LOG_CHECKPOINT_LSN属性的LSN值在日志文件组中的偏移量 LOG_CHECKPOINT_LOG_BUF_SIZE:该属性使用8个字节,用于标识在checkpoint操作中对应的Redo Log Buffer的大小 Not Used:未使用,占用476个字节 LOG_BLOCK_CHECKSUM:该属性使用4个字节,标识该Block的校验和
redo log同步
所谓redo log同步指的就是将内存中的redo log写入到硬盘中。具体地,是将Redo Log Buffer中的Block镜像写入到日志文件剩余的Block中。通常在以下几种场景中redo log会被写入到磁盘的日志文件中
Redo Log Buffer 中的剩余空间不多时 在提交事务时,可以不立刻将Buffer Pool中被修改的页同步到硬盘上。但为了保证持久性,需要将相应的redo log刷新到硬盘上 后台线程会定时地将Redo Log Buffer中的Block同步到硬盘上 执行checkpoint操作时 MySQL服务正常关闭时
这里我们就在提交事务时是否需要相应的redo log从Redo Log Buffer同步到硬盘上来展开说明。事实上,InnoDB引擎提供了一个全局系统变量innodb_flush_log_at_trx_commit来让用户自行选择提交事务时是否立即同步其产生的redo log。具体地,其有以下值可选
0:提交事务时不立即同步redo log到硬盘中,而是通过后台线程进行同步。显然此举可以明显加快处理请求的速度 1:提交事务时立即同步redo log到硬盘中。该全局系统变量默认为1 2:提交事务时只是将redo log先写到OS的缓冲区,而不是真正地写到硬盘中。这样即使MySQL服务意外停止了,只要OS还在正常运行,事务的持久性依然是可以保证的
可通过下面的SQL语句进行查看、修改
-- 查看全局系统变量 innodb_flush_log_at_trx_commit
show variables like 'innodb_flush_log_at_trx_commit';
-- 修改全局系统变量 innodb_flush_log_at_trx_commit
set global innodb_flush_log_at_trx_commit = 1;
前面我们提到,在InnoDB内部有一个全局变量buf_free指向Redo Log Buffer中空闲区域的起始位置。与此同时,在InnoDB内部还有一个全局变量buf_next_to_write指向Redo Log Buffer中下一次需要写入硬盘的起始位置。则Redo Log Buffer中这两个全局变量之间的关系如下图所示
Log Sequence Number
在MySQL运行过程中,不断会有redo log被写入到Redo Log Buffer中。为此,InnoDB提出了一个Log Sequence Number(LSN)日志序列号的概念,用于表示写入Redo Log Buffer中的redo log量,其中LSN值的变化趋势是单调递增的。对于任一一个redo log都有唯一的LSN值与之对应。这里关于LSN值的计算有两点值得注意
当MySQL服务第一次启动时LSN值的初值不是0,而是8704。当MySQL服务被停止后再次启动时(即非第一次启动),将继续使用上一次服务停止前最新的LSN值 虽然MTR中的一组redo log只是写到Redo Log Buffer的log block body当中。但是在计算LSN的增长量时,不仅需要依据写入的redo log量(即redo log的字节数),还需要考虑在写入该组的redo log时其实际使用的log block header、log block trailer部分的字节数
这里结合一个例子来说明LSN是如何计算的,这里假设MySQL服务是第一次启动。某个时刻下事务3中的一个MTR结束并生成了若干条redo log,其大小为300B。此时将这大小为300B的redo log写入到Redo Log Buffer的第一个Block中,则此时的LSN值为8704 + 12 + 300 = 9016。至于为什么要加12原因在上面的第2点也说了,因为存储该redo log的同时,实际上还使用了该Block的log block header(该部分占12个字节);随后,当事务4中的一个MTR也结束并生成了若干条redo log,大小为900B。则将其写入Redo Log Buffer后,LSN值更新为 9016 + 900 + 12*2 + 4*2= 9948。示意图如下所示
在Redo Log Buffer中,全局变量buf_free与LSN值是相对应的;类似地对于全局变量buf_next_to_write而言,其同样也有一个与之对应的全局变量——flushed_to_disk_lsn,用于表示Redo Log Buffer中哪些redo log被写入到磁盘中。示意图如下所示
前面我们提到当我们修改页数据时,需要将脏页的控制块加入Buffer Pool的flush链表进行管理。其中控制块中还有两个属性:oldest_modification、newest_modification。其分别表示该页被缓存到Buffer Pool后第一次修改时其MTR开始时的LSN值、被缓存到Buffer Pool中每次修改该页面的MTR结束时的LSN值。具体地,如果该脏页的控制块不在flush链表中则从头部插入到flush链表中,并向该控制块的oldest_modification、newest_modification属性写入该修改操作开始、结束时对应的LSN值;如果该脏页的控制块已经在flush链表中只需在每次修改的MTR结束时利用LSN值来更新该控制块的newest_modification属性。换言之,对于flush链表来说,其是按照控制块的oldest_modification属性进行降序排序的。即根据该页缓存到Buffer Pool后发生第一次修改的时间由近到远进行排序
Checkpoint机制
前面我们在介绍Redo Log File的写入顺序时提到,其是循环写入的。即当最后一个日志文件写满了,其会从继续从第一个日志文件开始写。之所以进行循环写入,是因为日志文件所占用的空间不可能无限增大。聪明的朋友可能发现了,这样会有问题啊,它会导致之前写到日志文件的内容被覆盖了。为此Checkpoint机制应运而生
我们知道一旦Buffer Pool的flush链表中的脏页被同步到硬盘后,则其所对应的redo log也就没有存在的意义了。而前面提到flush链表是按控制块的oldest_modification属性进行降序排序的,故链表尾部控制块的oldest_modification属性是最小的,这里我们假设其值是12345。结合该属性的意义和LSN值单调递增的特性,我们可知如果redo log对应的LSN值小于12345的话,则该redo log就没有继续保留的意义了。因为该redo log所对应的脏页已经被写入到硬盘中了。这就是Redo Log File可以被重用以进行覆盖写入的原因了。具体地,在进行Checkpoint操作时大致有以下两个步骤
通过flush链表最后一个控制块的oldest_modification属性,获取无用redo log对应的最大的LSN值。特别地,InnoDB中使用了一个全局变量checkpoint_lsn来存储该值。在上文的例子中,该值即为12345 将本次checkpoint的信息保存到第一个redo log file(即ib_logfile0文件)中。根据redo log file的结构可知,其内部有两个Block可用——checkpoint 1、checkpoint 2。这两个Block内部结构完全一致,具体使用哪个Block进行存储。实际上取决于InnoDb中用于统计进行checkpoint次数的checkpoint_no变量,每进行一次checkpoint操作,该变量自增一次。当checkpoint_no变量的值为偶数时,使用checkpoint 1 进行存储;反之,则使用checkpoint 2进行存储
可以看到通过Checkpoint机制,其解决了下面几个关键问题
MySQL服务发生崩溃、重启后,在恢复数据的过程中,减少了数据恢复的工作量,缩短了数据恢复的时间 Buffer Pool容量是有限的,一旦不够用时即需要根据LRU算法进行淘汰。若该页为脏页,则需要将其同步到硬盘上,并进行Checkpoint操作 Redo Log File同样容量有限,一旦不够用需要覆盖之前的日志内容时,为保证被覆盖的日志内容是不再需要的、无用的,则需要将Buffer Pool中的脏页同步到硬盘中,并进行Checkpoint操作
至此我们将InnoDB中几个重要的LSN值进行了介绍,我们可通过下面的语句进行查看
show engine innodb status;
这里就其中涉及关于LSN的输出结果进行解释
...
---
LOG
---
# LSN值,即MySQL服务已经生成、写入到Redo Log Buffer的日志量
Log sequence number 215412149596
# flushed_to_disk_lsn值,即MySQL服务写入到磁盘的日志量
Log flushed up to 215412149596
# Buffer Pool的flush链表中最后一个页面(即最早被修改)的页所对应的oldest_modification属性值
Pages flushed up to 215412149596
# checkpoint_lsn值, 即MySQL服务最近一次checkpoint操作时的LSN值
Last checkpoint at 215412149587
...
恢复数据
前面说了很多redo log相关的内容,现在到了真正发挥redo log价值的时刻了。一旦MySQL服务突然挂了,Buffer Pool中的脏页还没来得及写入到硬盘中。此时重启MySQL服务后,就需要利用redo log来恢复数据了
恢复数据的起点
前面我们说了每次checkpoint操作后,会将此次checkpoint的信息保存到第一个redo log file(即ib_logfile0文件)中。而其内部有两个存储checkpoint的Block——checkpoint 1、checkpoint 2。显然我们要从最近一次的checkpoint操作开始恢复数据。因为对于任何redo log对应的LSN小于checkpoint_lsn的情况,其修改操作的脏页均已被同步到硬盘中,也就是前面所说的checkpoint机制可以减少恢复数据时的工作量、缩短恢复数据的时间。具体地,对于上文的两个Block——checkpoint 1、checkpoint 2而言,通过比较各自的checkpoint_no属性即可,较大的者即为最近一次的checkpoint信息。然后利用该Block中的checkpoint_lsn、checkpoint_offset信息,即可进一步确定日志文件组中恢复数据所需redo log的起点
恢复数据的终点
前面我们提到Block中有一个LOG_BLOCK_HDR_DATA_LEN属性,用于表示该Block已使用的字节数。当该Block的空间全部使用完毕,则该值为512。则若某个Block该属性值不为512,则其即为恢复数据时所需redo log的终点
优化恢复
哈希表:减少页面加载次数
确定了恢复数据所需的redo log后,即可顺序使用这些redo log将相关页面加载到内存进行相应的修改操作。但是为了减少页面加载的次数,减少数据恢复过程中的时间。InnoDB利用哈希表进行了优化。具体地,将redo log中的表空间ID、页号作为哈希表的键,而哈希表的值则为一个链表,用于存放对同一个页进行修改的所有redo log。其中链表中的redo log则是按生成时间的顺序由远到近进行排序的。这里链表中各redo log顺序不能错乱,否则在利用这些redo log进行数据恢复时即可能会出现错误。例如当我们先向表中插入一条数据,然后又删除了该数据。而如果我们在恢复数据时,按 先删除该数据再插入该数据 的顺序进行恢复,显然是不符的
当哈希表建立完成后,我们就可以不用顺序遍历所有的redo log进行数据恢复了。而是通过遍历哈希表进行恢复,因为此时对某个页进行所有修改的redo log均在该键所对应的链表中。这样即可一次性完成对该页的恢复、修改,减少了随机IO的次数
跳过已经同步到硬盘的页
对于任何redo log对应的LSN小于checkpoint_lsn的情况,其修改操作的脏页均已被同步到硬盘中。但是对于redo log对应的LSN大于checkpoint_lsn的情况来说,其修改操作的脏页是否同步到硬盘中是不确定的。即可能在一次checkpoint后,存在少部分的脏页已经被同步到硬盘中了。这时我们在恢复数据时,对于这些页是无需使用全部的redo log进行修改、恢复的
我们知道在页的File Header部分有一个FIL_PAGE_LSN属性,其记录的是最近一次修改该页结束时的LSN值(即flush链表中控制块的newest_modification值)。故如果在checkpoint操作后该脏页被同步到硬盘了,则该页的FIL_PAGE_LSN属性必然是大于checkpoint_lsn的。故此时在数据恢复的过程中,如果发现redo log对应的LSN值小于FIL_PAGE_LSN属性,则可直接跳过该redo log。即无需进行重复的修改。显然此举可以进一步提高数据恢复的效率
参考文献
MySQL是怎样运行的