Innodb引擎 · 基础模块篇(三) · 详解redo log存储结构

8,974 阅读18分钟

1. redo log简介

在介绍Buffer Pool相关内容时我们知道,当有数据页被修改时(变成了脏页dirty page),Innodb引擎会把对应的缓存页添加到Buffer Pool的flush list当中,并没有实时的将脏页同步到磁盘,而是在将来的某个时机才将脏页同步到磁盘。Mysql数据库是可以保证数据的持久性的,在脏页没有从内存同步到磁盘时,如果数据库宕机,内存数据就会丢失,这个时候得有一种机制能将丢失的脏页数据给恢复过来,才能保证Mysql数据库再次启动的时候脏页还能恢复成原来的样子,Innodb引擎提供的Redo log就是为了完成这项工作而设计的!

中国古代兵法有云:“兵马未到,粮草先行”。这句话应用到数据库设计上同样有用,不过得改一下说法:“数据未同步到磁盘,日志先行!”,意思就是说:当Buffer Pool中的脏页还没有同步到磁盘上的时候,先将缓存页做了哪些修改的操作以日志的形式先记录下来,这样即使某个时间点数据库宕机了,等数据库再次启动时,也可以将数据页再重新加载到Buffer Pool中来,再根据修改日志将数据页恢复成原来的样子。我们这里提到的记录数据页修改的日志就是本节要详细介绍的Redo log,我们所谓的日志先行的说法其实就是所谓的WAL技术(Write-Ahead Logging)。

我们再来回顾一下Innodb引擎的架构图:

Innodb引擎架构图

我们看到,Innodb引擎在数据交互过程中产生的redo log通过内存中Log Buffer持久化到磁盘文件上的一组日志文件中(ib_logfile0、iblogfile1)。本节我们会详细介绍一下redo log的存储结构,主要包括:

  • redo log的日志格式
  • Log Buffer是怎样存储redo log信息的
  • 磁盘上的redo log文件组是怎样存储redo log信息的

此外,通过本文读者还会对Innodb引擎中的Mini-Transactionredo log block有更加深入的认识,话不多说,下边就开始我们的redo log学习之旅吧~

2.Redo log的日志格式

我们现在知道了Redo log记录了用户对数据做了哪些更改,对数据进行修改的场景有很多,我们常用的增、删、改可能修改一个数据页,也可能修改多个数据页,这些数据页还不一定都是数据库中的同一张表,所以Redo log有很多种格式。结合我们前边讲到的数据页的结构,我们可以抽象出一种比较通用Redo log日志类型,即我们对哪个空间下的哪个数据页做了哪些具体的修改,下边就是比较通用的Redo log日志类型结构示意图:

redo log通用日志类型

其中type表示Redo log的日志类型,Innodb引擎针对不同场景对数据页的修改,制定了几十种不同类型的Redo日志。Space IDPage Number是前边我们反复提到的表空间id和数据页id,根据它们能唯一标示一个数据页,再然后的data就是对该数据页到底做了哪些修改了。我们下边介绍几种简单些的redo log日志类型,然后再看一下稍复杂些的redo log日志类型是如何设计的。

2.1 几种简单的redo log日志类型

我们先考虑一种最简单的场景,假如我们只简单更新了一个数据页中一条记录的某个字段的值。这个时候我们只需要记录下在这个页面从某个偏移量修改了几个字节的值,将这几个字节的具体内容也记录一下就好了,我们将这种极其简单的redo log日志我们称之为物理日志。Innodb引擎根据具体在数据页中修改了数据的多少划分了划分了几种不同类型的redo log:

MLOG_1BYTE(type字段对应的十进制数字为1):表示在页面的某个偏移量处写入1个字节的redo日志类型。

MLOG_2BYTE(type字段对应的十进制数字为2):表示在页面的某个偏移量处写入2个字节的redo日志类型。

MLOG_4BYTE(type字段对应的十进制数字为4):表示在页面的某个偏移量处写入4个字节的redo日志类型。

MLOG_8BYTE(type字段对应的十进制数字为8):表示在页面的某个偏移量处写入8个字节的redo日志类型。

MLOG_WRITE_STRING(type字段对应的十进制数字为30):表示在页面的某个偏移量处写入一串数据。

就拿更通用的MLOG_WRITE_STRING类型的日志来说吧,我们在data部分需要记录三种信息:

  • 要修改的内容在数据页中的偏移量(offset)
  • 修改了多少个字节的数据(len)
  • 修改后的数据内容是什么(content)

MLOG_WRITE_STRING类型redo log日志结构

细心的读者可能发现了,我们将MLOG_WRITE_STRING日志类型的len处分别填充上十进制的1、2、4、8不就是MLOG_1BYTEMLOG_2BYTEMLOG_4BYTEMLOG_8BYTE这几种日志类型吗?确实是这样的,但是Innodb引擎为什么还要单独定义几种type类型的redo log日志呢?无非就是想剩空间嘛,定义了这几种不同的日志类型,len字段是不是就可以省去不记录了呢?当然是这样的!从上边的redo log日志结构我们也可以看出,redo log占用的空间是非常小的。

2.2 稍复杂些的redo log日志类型

我们再看一个稍复杂些的场景,向一张表中批量插入数据,暂且不说插入的这批数据能够被一个数据页所容纳,按照我们上边描述的使用简单的redo log日志类型结构,我们要为插入的每一条记录都创建一条redo log吗?如果说表结构中还创建了二级索引,那么Innodb引擎还要修改对应的二级索引页;还记得我们前边讲的数据页结构吗?我们还要在数据页File Header、Page Header、Page Directory等等部分更新一下数据页的其他信息;还有数据记录的next_record属性,我们说它记录着距离下一条记录的偏移量,它也要做更改。总之,向一张表中批量插入数据会涉及到多个数据页的多个地方的修改,我们用示例图简单表示一下只修改一个数据页的情况:

数据页修改前后对比图

笔者想表达的意思就是将一批数据插入到一个数据页的时候,需要修改的地方特别多。在这种情况下我们想要以上边提到的使用简单的redo log日志类型将这个页面所做的修改给记录下来,无非两种方式:

  • 将数据页中的每一处修改都记录一条redo log,这种方式会产生特别多的redo log。
  • 每个数据页所有的更改只记录一条redo log,但是在数据部分记录的是改页面第一个更新的字节到最后一个变更的字节的数据,我们知道一个数据页大小是16kb,这其中还有太多没有发生变化的地方,这种记录方式虽然可行,但是redo log的日志体积会非常大。

Innodb引擎的开发人员是什么样的人物?他们会用这种方式来设计redo log吗?当然不会!但是想要解决这个问题确实复杂,Innodb引擎为此也设计了很多其他类型的redo log。我们来看几种最常用的:

  • MLOG_COMP_REC_INSERT(对应的十进制数字为38):表示插入一条使用紧凑行格式的记录时的redo日志类型。

  • MLOG_COMP_PAGE_CREATE(type字段对应的十进制数字为58):表示创建一个存储紧凑行格式记录的页面的redo日志类型。

  • MLOG_COMP_REC_DELETE(type字段对应的十进制数字为42):表示删除一条使用紧凑行格式记录的redo日志类型。

  • MLOG_COMP_LIST_START_DELETE(type字段对应的十进制数字为44):表示从某条给定记录开始删除页面中的一系列使用紧凑行格式记录的redo日志类型。

  • MLOG_COMP_LIST_END_DELETE(type字段对应的十进制数字为43):与MLOG_COMP_LIST_START_DELETE类型的redo日志呼应,表示删除一系列记录直到MLOG_COMP_LIST_END_DELETE类型的redo日志对应的记录为止。

还有很多没有列举完的redo log日志类型,总之只要是我们业务中经常用的插入一条记录、批量插入多条记录、批量更新多条记录、批量删除多条记录这些都有相应格式的redo log日志类型与之相对应。但是这些redo log记录具体数据页变更部分就不仅仅包含物理日志了,还包含一定的逻辑日志,也就是说使用这种类型的redo log日志进行数据页恢复时,就不能仅仅将变更后的数据原原本本拷贝到数据页相应的地方就可以了,还需要调用函数做一些额外的转换处理才行,下边是一个复杂类型的redo log恢复一个数据页的过程:

复杂类型的redo log恢复一个数据页的过程

3. 什么是Mini-Transaction

关于Mini-Transaction,Mysql是这样进行定义的:Innodb引擎对底层页的一次原子访问的过程叫做Mini-Transaction。什么意思呢?我们来举一个例子,假如我们在创建有索引的数据表中插入一条记录,那么我们需要在Innodb引擎的聚簇索引B+树的数据页中插入这条记录,更改这条记录的上一条记录的next_record的内容,使其指向这条记录;然后还需要在辅助索引B+树的数据页的中插入这条记录的索引信息;上边描述的这个过程就称为对底层页的一次原子访问,也就是说这次原子访问可能修改了多个数据页的信息,这个过程是不可分割的。怎么理解这个不可分割呢?就是说我们不能只在聚簇索引B+树的数据页中插入了一条记录,而相应的辅助索引B+树的数据页却没有做变更,我们就把这个不可分割的访问过程称为Mini-Transaction(翻译成中文就是“小事务”)。

读者可能会想这个Mini-Transaction和我们本节讲的redo log有什么关系呢?那关系可大了去了!就拿上边这个例子来说,向有索引的数据表中插入一条记录会产生多条redo log,假如这些redo log只插入了其中一部分时,数据库宕机了。我们知道redo log是做数据页恢复用的,假如我们只恢复了一部分redo log,那么这张数据表所对应的B+树就处于不正确的状态了。Innodb引擎当然不会允许这种事情发生,所以对于一个Mini-Transactoin产生的redo log日志都会被划分到一个组当中去,在进行系统崩溃重启恢复时,针对某个组中的redo日志,要么把全部的日志都恢复掉,要么就一条也不恢复。

为了实现Redo log日志组的这个功能,Innodb引擎设计了一种特殊的redo log日志类型,该类型名称为MLOG_MULTI_REC_END,type字段对应的十进制数字为31,该类型的redo log日志结构很简单,只有一个type字段,所以一个Mini-Transaction操作产生的一组redo log 日志就像这样:

redo log日志组

我们前边说过有些非常简单的redo log日志类型,这些redo log在记录时是否也都需要跟上一条MLOG_MULTI_REC_END类型的redo log, 表示这条日志是单独的一个组呢?其实不是的,我们知道redo log的通用结构中,type字段占8个比特位,而Innodb引擎只设计了几十种redo log日志类型,所以使用7个比特位来表示redo log的日志类型已经够用了,剩下一个比特位就可以专门表示当前的redo log是否是一条单独的日志了,这样就省去了每条简单的redo日志都跟上一条MLOG_MULTI_REC_END类型的redo日志来划分组信息了。这是多么明智的决策啊!

单一redo log日志字段标示

到这里详细大家已经明白了Mini-Transaction和redo log之间的关系了,有些读者可能会想我们这里提到的Mini-Transaction和Innodb引擎本身提供的Transaction有什么样的关系呢?其实这个问题很容易回答,假如Innodb引擎中的一个Transaction由多条Sql语句组成,每条Sql语句又可以由多个mtr组成,每个mtr又包含一组不可分割的redo log日志,所以TransactionMini-Transactionredo log之间的关系就是这样一种包含的对应关系:

事务、Mini-Transaction和redo log之间的关系

4. Log Buffer是怎样存储redo log信息的?

Innodb引擎为了方便业务数据的管理,设计了“数据页”来存放记录;同样的,为了更加方便的使用redo log,Innodb也设计了一种结构:redo log block, 用来存放我们前边提到的redo log。

4.1 redo log block组成结构

我们将redo log block称为“存放redo log的小池子”,说它小,是因为相比较于16KB的数据页而言,一个redo log block只有512B;结构也是非常简单的:

redo log block结构

真正存储redo log数据的仅仅是log block body的那496个字节;log block header占12个字节,log block tailer占4个字节,log block tailer存储的LOG_BLOCK_CHECKSUM是用来校验数据完整性使用的,我们不用关心,我们主要来看看log block header的几个属性吧。

  • LOG_BLOCK_HDR_NO:每一个block都有一个大于0的唯一标号,本属性就表示该标号值。

  • LOG_BLOCK_HDR_DATA_LEN:表示block中已经使用了多少字节,初始值为12(因为log block body从第12个字节处开始)。随着往block中写入的redo日志越来也多,本属性值也跟着增长。如果log block body已经被全部写满,那么本属性的值被设置为512。

  • LOG_BLOCK_FIRST_REC_GROUP:一条redo日志也可以称之为一条redo日志记录(redo log record),一个mtr会生产多条redo日志记录,这些redo日志记录被称之为一个redo日志记录组(redo log record group)。LOG_BLOCK_FIRST_REC_GROUP就代表该block中第一个mtr生成的redo日志记录组的偏移量(其实也就是这个block里第一个mtr生成的第一条redo日志的偏移量)。

  • LOG_BLOCK_CHECKPOINT_NO:表示所谓的checkpoint的序号,checkpoint是我们后续内容的重点,现在先不用清楚它的意思。

4.2 redo log是怎样写入到log buffer中的

我们前边提到Innodb引擎的内存架构主要分两部分,一部分是Buffer Pool,我们已经详细介绍过它了,Buffer Pool主要是为了解决每次数据页更新都同步磁盘的问题;相同的,Innodb引擎的内存架构的另一部分Log Buffer,也是为了解决redo log日志直接写磁盘带来的性能损耗问题。在Mysql服务器启动时就向操作系统申请了一大片称之为redo log buffer的连续内存空间,Log Buffer内存空间的大小由启动参数innodb_log_buffer_size来指定,默认是16MB,这片内存空间被划分成若干个连续的redo log block,这也是Log Buffer的内存结构,Innodb引擎还定义了一个称之为buf_free的全局变量,标示redo log日志写到了Log Buffer的哪个位置,如下图所示:

Log Buffer结构示意图

在Mysql5.7版本,向log buffer中写入日志是串行的,严格按照Mini-Transaction产生的日志顺序提交的,我们前边分析过一个事务可能包含多个Mini-Transaction,每个Mini-Transaction都会包含一组redo日志,在Innodb引擎中,事务是并发执行的,所以多个事务的Mini-Transaction产生的Redo log也可能是交叉写入到log buffer中的。比如我们有两个事务T1和T2,为了方便我们下文中将Mini-Transaction简称为“MTR”,T1会产生T1-MTR1、T1-MTR2、T1-MTR3三组redo log,T2会产生T2-MTR1、T2-MTR2两组日志,每一个MTR产生的日志大小不同,如下图所示(我们将MTR产生的一组日志用一个色块结构表示),T1和T2事务并发执行过程中,redo log向Log Buffer中写入的日志可能是这样子的: 多个事务产生的MTR日志交替被写入到Log Buffer

其中T1的MTR1先写入到Log Buffer,并没有占满一个block,紧接着T2的MTR1也写入了Log Buffer并横跨了三个block,紧接着又提交了MTR,然后T1的剩余MTR才提交,都是一些小的日志,填充到一个block中。

Mysql8.0版本实现了MTR日志的并行提交,大体的实现思路就是一个当MTR产生一组redo log时,它占用的空间就已经是确定的了,由此就可以计算出每个MTR应该将日志写入到Log Buffer的什么位置,从而可以实现多线程的日志并行复制,但是并行复制有快有慢,由此产生的日志空洞、刷脏顺序等问题还需要额外的手段解决,官方的博客对此项技术有深入的讲解: MySQL 8.0: New Lock free, scalable WAL design

5. 磁盘上的redo log

将redo log写入Log Buffer并不能实现数据持久化,redo log落盘是毋庸置疑的。本节我们就来看看磁盘上的redo log文件是怎样设计的。

InnoDB的redo log可以通过参数innodb_log_files_in_group配置成多个文件,另外一个参数innodb_log_file_size表示每个文件的大小。因此总的redo log大小为innodb_log_files_in_group * innodb_log_file_size。Redo log文件以ib_logfile[number]命名,日志目录可以通过参数innodb_log_group_home_dir控制。Redo log 以顺序的方式写入文件文件,写满时则回溯到第一个文件,进行覆盖写。整个过程如下图所示:

redo log日志文件组

我们上小节提到log buffer本质上是由若干个512字节大小的block组成的一片连续的内存空间,redo log落盘也是以block为单位写到日志组文件中去的。所以磁盘上的每一个日志组文件也都是由512字节的block组成的,并且每一个日志组文件的前4个block(2048个字节)都是用来存储管理信息使用的。如下图所示:

redo日志组文件结构示意图

我们再来看看这4个block都存储了哪些相关信息:

redo log日志文件组前4个block存储的日志文件信息

因为checkpoint1和checkpoint2结构都是一样的,所以只画出了其中一个的示意图。下边通过表格简单描述一下,大家如果不懂LSNcheckpoint是什么,先不用着急,笔者打算后边的章节再详细讲解这一部分内容。

log file header的组成结构:

属性名长度(单位:字节)描述
LOG_HEADER_FORMAT4redo日志的版本
LOG_HEADER_PAD14做字节填充用的,没什么实际意义
LOG_HEADER_START_LSN8标记本redo日志文件开始的LSN值,也就是文件偏移量为2048字节初对应的LSN值
LOG_HEADER_CREATOR32一个字符串,标记本redo日志文件的创建者是谁。
LOG_BLOCK_CHECKSUM4block的校验值,所有block都有

checkpoint的组成结构:

属性名长度(单位:字节)描述
LOG_CHECKPOINT_NO8服务器做checkpoint的编号,每做一次checkpoint,该值就加1
LOG_CHECKPOINT_LSN8服务器做checkpoint结束时对应的LSN值,系统崩溃恢复时将从该值开始
LOG_CHECKPOINT_OFFSET8上个属性中的LSN值在redo日志文件组中的偏移量
LOG_CHECKPOINT_LOG_BUF_SIZE8服务器在做checkpoint操作时对应的log buffer的大小
LOG_BLOCK_CHECKSUM4block的校验值

6.小结与预告

本节我们详细介绍了redo log的存储结构(内存的log buffer中如何存储redo log的,在磁盘上的日志文件组中如何存储redo log的)。了解这些是我们分析Innodb引擎中内存和磁盘交互的基础。我们上边也提到了LSNcheckpoint,本节我们并没有单独展开来说,这部分内容还是比较复杂的,包含了很多设计技巧和小细节,学习这些对于我们的业务提升有很大的帮助,下一小节我们就来详细谈谈Innodb引擎中的LSNcheckpoint。本文内容可能稍显枯燥,不过还是建议大家到后边遇到有迷惑的地方,再回过头来看看,今天的内容就这些了,谢谢大家~