一,什么是redo日志

InnoDB存储引擎是以页为单位来管理存储空间的,我们的CRUD操作其实都是在访问页面。在真正访问页面之前,需要把磁盘中的页加载到内存的BufferPool,之后才能访问,但是因为事务要保持持久性,如果我们仅仅在内存的缓冲池修改了页面,假设事务提交后突然发生故障,导致内存的数据都消失了,那么这个已经提交的事务在数据库做的更改就丢失了。

如何保证持久性呢?可以在事务提交完成之前,把事务修改的所有页面都刷新到磁盘。不过这样做存在一些问题:

  1. 刷新一个完整的数据页过于浪费
  2. 随机IO效率比较低

事实上仅仅是为了保证事务的持久性,没有必要每次提交事务的时候就把该事务在内存修改过的全部页面刷新到磁盘,只需要把修改的内容记录一下就好,这样在事务提交的时候,就会把这个记录刷新到磁盘。即使系统因为崩溃而重启只需要按照记录的内容重新更新数据页即可恢复数据,上述记录修改的内容就叫做重做日志(redo log)。

相比于在事务提交的时候将所有修改过的内存中的页面刷新到磁盘,重做日志有以下好处:

  1. redo日志占用空间小:在存储表空间ID,页号,偏移量以及需要更新的值时,需要的存储空间很小。
  2. redo日志是顺序写入磁盘的:在执行事务过程中,每执行一条语句,就可能产生若干条redo日志,这些日志是按照产生的顺序写入磁盘的,也就是使用顺序IO。

二,redo日志格式

重做日志本质上仅仅是记录了一下事务对数据库进行了哪些修改。针对事务对数据库的不同修改场景MySQL定义了很多种重做日志,但是大部分类型的重做日志都有以下的通用结构。

1.png

type 重做日志的类型
space ID 表空间ID
page number 页号
Data 日志的具体内容

1. 简单的redo日志类型

行格式里面有一个隐藏列叫做row_id。为row_id进行赋值的方式如下:

  1. 服务器会在内存中维护一个全局变量,每当像某个包含row_id隐藏列的表插入一条记录的时候,就会把这个全局变量的值当做新记录row_id的值,并且把这个全局变量自增1。
  2. 每当这个全局变量的值是256的整数倍的时候,就会把这个变量的值刷新到系统表空间页号为7的页面中一个叫做Max Row ID的属性中。
  3. 当系统启动的时候,会将Max Row ID属性加载到内存,并把这个值加上256之后赋值给前面提到的全局变量。

这个Max Row ID占用的存储空间是8字节。当某个事务向某个包含row_id的表插入一条记录并且该记录分配的row_id值为256的整数倍的时候,就会像系统表空间页号为7的页面的相应偏移量处写入8字节的值。但是这个写入操作实际上是在内存缓冲区完成的,我们需要把这次修改以redo日志的形式记录下来,这样在事务提交之后,即使系统崩溃,也可以将该页面恢复成崩溃前的状态。在这种对页面的修改特别简单的时候,重做日志仅仅需要记录一下在某个页面的某个偏移量处修改了几个字节的值,具体修改后的内容是什么就可以了。这也叫做物理日志。

2.png

offset表示页面中的偏移量。如果写入的是字节序列类型的重做日志,还需要有一个len属性记录实际写入的长度。

2.复杂的redo日志类型

有时候执行一条语句会修改非常多的页面,包括系统数据页面和用户数据页面(用户数据指的就是聚簇索引和二级索引对应的B+树)。

这时我们如果使用简单的物理redo日志来记录这些修改时,可以有两种解决方案:

  • 方案一:在每个修改的地方都记录一条redo日志。
    也就是有多少个修改的记录,就写多少条物理redo日志。这样子记录redo日志的缺点是显而易见的,因为被修改的地方是在太多了,可能记录的redo日志占用的空间都比整个页面占用的空间都多。
  • 方案二:将整个页面的第一个被修改的字节最后一个修改的字节之间所有的数据当成是一条物理redo日志中的具体数据。
    第一个被修改的字节最后一个修改的字节之间仍然有许多没有修改过的数据,我们把这些没有修改的数据也加入到redo日志中太浪费了。

正因为上述两种使用物理redo日志的方式来记录某个页面中做了哪些修改比较浪费,InnoDB提出了一些新的redo日志类型。

这些类型的redo日志既包含物理层面的意思,也包含逻辑层面的意思,具体指:

  • 物理层面看,这些日志都指明了对哪个表空间的哪个页进行了修改。
  • 逻辑层面看,在系统崩溃重启时,并不能直接根据这些日志里的记载,将页面内的某个偏移量处恢复成某个数据,而是需要调用一些事先准备好的函数,执行完这些函数后才可以将页面恢复成系统崩溃前的样子。

3.jpg

这个类型为MLOG_COMP_REC_INSERTredo日志并没有记录PAGE_N_DIR_SLOTS的值修改为了什么,PAGE_HEAP_TOP的值修改为了什么,PAGE_N_HEAP的值修改为了什么等等这些信息,而只是把在本页面中插入一条记录所有必备的要素记了下来,之后系统崩溃重启时,服务器会调用相关向某个页面插入一条记录的那个函数,而redo日志中的那些数据就可以被当成是调用这个函数所需的参数,在调用完该函数后,页面中的PAGE_N_DIR_SLOTSPAGE_HEAP_TOPPAGE_N_HEAP等等的值也就都被恢复到系统崩溃前的样子了。这就是所谓的逻辑日志的意思。

日志格式说了一堆核心其实就是:重做日志会把事务执行过程中对数据库所做的所有修改都记录下来,在之后系统因为崩溃而重启后可以把事务所做的任何修改都恢复过来。

为了节省重做日志占用的空间大小,InnoDB还对重做日志中的某些数据进行了压缩处理,比如表空间ID&page number 一般占用4字节来存储,但是经过压缩之后占用的空间就更小了。

三,Mini-Transcation

1.以组的形式写入redo日志

语句在执行过程中可能修改若干个页面。比如我们前边说的一条INSERT语句可能修改系统表空间页号为7的页面的Max Row ID属性(当然也可能更新别的系统页面,只不过我们没有都列举出来而已),还会更新聚簇索引和二级索引对应B+树中的页面。由于对这些页面的更改都发生在Buffer Pool中,所以在修改完页面之后,需要记录一下相应的redo日志。在执行语句的过程中产生的redo日志被InnoDB人为的划分成了若干个不可分割的组,比如:

  • 更新Max Row ID属性时产生的redo日志是不可分割的。
  • 向聚簇索引对应B+树的页面中插入一条记录时产生的redo日志是不可分割的。
  • 向某个二级索引对应B+树的页面中插入一条记录时产生的redo日志是不可分割的。
  • 还有其他的一些对页面的访问操作时产生的redo日志是不可分割的。。。

怎么理解这个不可分割的意思呢?我们以向某个索引对应的B+树插入一条记录为例,在向B+树中插入这条记录之前,需要先定位到这条记录应该被插入到哪个叶子节点代表的数据页中,定位到具体的数据页之后,有两种可能的情况:

  • 情况一:该数据页的剩余的空闲空间充足,足够容纳这一条待插入记录,那么事情很简单,直接把记录插入到这个数据页中,记录一条类型为MLOG_COMP_REC_INSERTredo日志就好了,我们把这种情况称之为乐观插入。假如某个索引对应的B+树长这样:
    4.png
    现在我们要插入一条键值为10的记录,很显然需要被插入到页b中,由于页b现在有足够的空间容纳一条记录,所以直接将该记录插入到页b中就好了,就像这样:
    5.png
  • 情况二:该数据页剩余的空闲空间不足,那么事情就悲剧了,我们前边说过,遇到这种情况要进行所谓的页分裂操作,也就是新建一个叶子节点,然后把原先数据页中的一部分记录复制到这个新的数据页中,然后再把记录插入进去,把这个叶子节点插入到叶子节点链表中,最后还要在内节点中添加一条目录项记录指向这个新创建的页面。很显然,这个过程要对多个页面进行修改,也就意味着会产生多条redo日志,我们把这种情况称之为悲观插入。假如某个索引对应的B+树长这样:
    6.png
    现在我们要插入一条键值为10的记录,很显然需要被插入到页b中,但是从图中也可以看出来,此时页b已经塞满了记录,没有更多的空闲空间来容纳这条新记录了,所以我们需要进行页面的分裂操作,就像这样:
    7.png
    如果作为内节点的页a的剩余空闲空间也不足以容纳增加一条目录项记录,那需要继续做内节点页a的分裂操作,也就意味着会修改更多的页面,从而产生更多的redo日志。另外,对于悲观插入来说,由于需要新申请数据页,还需要改动一些系统页面,比方说要修改各种段、区的统计信息信息,各种链表的统计信息(比如什么FREE链表、FSP_FREE_FRAG链表等,我们在介绍表空间那一篇中介绍过的各种东西),反正总共需要记录的redo日志有二、三十条。

其实不光是悲观插入一条记录会生成许多条redo日志,InnoDB为了其他的一些功能,在乐观插入时也可能产生多条redo日志。

InnoDB认为向某个索引对应的B+树中插入一条记录的这个过程必须是原子的,不能说插了一半之后就停止了。比方说在悲观插入过程中,新的页面已经分配好了,数据也复制过去了,新的记录也插入到页面中了,可是没有向内节点中插入一条目录项记录,这个插入过程就是不完整的,这样会形成一棵不正确的B+树。我们知道redo日志是为了在系统崩溃重启时恢复崩溃前的状态,如果在悲观插入的过程中只记录了一部分redo日志,那么在系统崩溃重启时会将索引对应的B+树恢复成一种不正确的状态,这是InnoDB所不能忍受的。所以他们规定在执行这些需要保证原子性的操作时必须以的形式来记录的redo日志,在进行系统崩溃重启恢复时,针对某个组中的redo日志,要么把全部的日志都恢复掉,要么一条也不恢复。怎么做到的呢?这得分情况讨论:

  • 有的需要保证原子性的操作会生成多条redo日志,比如向某个索引对应的B+树中进行一次悲观插入就需要生成许多条redo日志。
    如何把这些redo日志划分到一个组里边儿呢?InnoDB做了一个很简单的操作,就是在该组中的最后一条redo日志后边加上一条特殊类型的redo日志,该类型名称为MLOG_MULTI_REC_ENDtype字段对应的十进制数字为31,该类型的redo日志结构很简单,只有一个type字段:
    8.png
    所以某个需要保证原子性的操作产生的一系列redo日志必须要以一个类型为MLOG_MULTI_REC_END结尾,就像这样:
    9.png
    这样在系统崩溃重启进行恢复时,只有当解析到类型为MLOG_MULTI_REC_ENDredo日志,才认为解析到了一组完整的redo日志,才会进行恢复。否则的话直接放弃前边解析到的redo日志。
  • 有的需要保证原子性的操作只生成一条redo日志,比如更新Max Row ID属性的操作就只会生成一条redo日志。
    其实在一条日志后边跟一个类型为MLOG_MULTI_REC_ENDredo日志也是可以的,InnoDB不想浪费一个比特位。虽然redo日志的类型比较多,但撑死了也就是几十种,是小于127这个数字的,也就是说我们用7个比特位就足以包括所有的redo日志类型,而type字段其实是占用1个字节的,也就是说我们可以省出来一个比特位用来表示该需要保证原子性的操作只产生单一的一条redo日志,示意图如下:
    10.png
    如果type字段的第一个比特位为1,代表该需要保证原子性的操作只产生了单一的一条redo日志,否则表示该需要保证原子性的操作产生了一系列的redo日志。

2.Mini-Transaction

MySQL把对底层页面中的一次原子访问的过程称之为一个Mini-Transaction,简称mtr,比如上边所说的修改一次Max Row ID的值算是一个Mini-Transaction,向某个索引对应的B+树中插入一条记录的过程也算是一个Mini-Transaction。一个所谓的mtr可以包含一组redo日志,在进行崩溃恢复时这一组redo日志作为一个不可分割的整体。

一个事务可以包含若干条语句,每一条语句其实是由若干个mtr组成,每一个mtr又可以包含若干条redo日志,画个图表示它们的关系就是这样:

11.png

四,redo日志的写入过程

1.redo log block

InnoDB为了更好的进行系统崩溃恢复,他们把通过mtr生成的redo日志都放在了大小为512字节中。为了和表空间中的页做区别,我们这里把用来存储redo日志的页称为block。一个redo log block的示意图如下:

image.png

真正的redo日志都是存储到占用496字节大小的log block body中,图中的log block headerlog block trailer存储的是一些管理信息。

image.png

其中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是我们后续内容的重点,现在先不用清楚它的意思,稍安勿躁。

log block trailer中属性的意思如下:

  • LOG_BLOCK_CHECKSUM:表示block的校验值,用于正确性校验,我们暂时不关心它。

2.redo 日志缓冲区

InnoDB为了解决磁盘速度过慢的问题而引入了Buffer Pool。同理,写入redo日志时也不能直接直接写到磁盘上,实际上在服务器启动时就向操作系统申请了一大片称之为redo log buffer的连续内存空间,翻译成中文就是redo日志缓冲区,也可以简称为log buffer。这片内存空间被划分成若干个连续的redo log block,就像这样:

14.png

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

3.redo log 日志写入log buffer

log buffer中写入redo日志的过程是顺序的,也就是先往前边的block中写,当该block的空闲空间用完之后再往下一个block中写。当我们想往log buffer中写入redo日志时,第一个遇到的问题就是应该写在哪个block的哪个偏移量处,所以InnoDB特意提供了一个称之为buf_free的全局变量,该变量指明后续写入的redo日志应该写入到log buffer中的哪个位置,如图所示:

15.png

一个mtr执行过程中可能产生若干条redo日志,这些redo日志是一个不可分割的组,所以其实并不是每生成一条redo日志,就将其插入到log buffer中,而是每个mtr运行过程中产生的日志先暂时存到一个地方,当该mtr结束的时候,将过程中产生的一组redo日志再全部复制到log buffer中。我们现在假设有两个名为T1T2的事务,每个事务都包含2个mtr,我们给这几个mtr命名一下:

  • 事务T1的两个mtr分别称为mtr_T1_1mtr_T1_2
  • 事务T2的两个mtr分别称为mtr_T2_1mtr_T2_2

每个mtr都会产生一组redo日志,不同的事务可能是并发执行的,所以T1T2之间的mtr可能是交替执行的。每当一个mtr执行完成时,伴随该mtr生成的一组redo日志就需要被复制到log buffer中,也就是说不同事务的mtr可能是交替写入log buffer的,我们画个示意图(为了美观,我们把一个mtr中产生的所有的redo日志当作一个整体来画):

16.png

从示意图中我们可以看出来,不同的mtr产生的一组redo日志占用的存储空间可能不一样,有的mtr产生的redo日志量很少,比如mtr_t1_1mtr_t2_1就被放到同一个block中存储,有的mtr产生的redo日志量非常大,比如mtr_t1_2产生的redo日志甚至占用了3个block来存储。

五,redo 日志文件

1.redo日志刷盘时机

mtr运行过程中产生的一组redo日志在mtr结束时会被复制到log buffer中,在一些情况下它们会被刷新到磁盘里,比如:

  • log buffer空间不足时
    log buffer的大小是有限的(通过系统变量innodb_log_buffer_size指定),如果不停的往这个有限大小的log buffer里塞入日志,很快它就会被填满。InnoDB认为如果当前写入log bufferredo日志量已经占满了log buffer总容量的大约一半左右,就需要把这些日志刷新到磁盘上。
  • 事务提交时
    之所以使用redo日志主要是因为它占用的空间少,还是顺序写,在事务提交时可以不把修改过的Buffer Pool页面刷新到磁盘,但是为了保证持久性,必须要把修改这些页面对应的redo日志刷新到磁盘。
  • 将某个脏页刷新到磁盘前,会保证先将该脏页对应的 redo 日志刷新到磁盘中(再一次 强调,redo 日志是顺序刷新的,所以在将某个脏页对应的 redo 日志从 redo log buffer 刷新到磁盘时,也会保证将在其之前产生的 redo 日志也刷新到磁盘)。
  • 后台线程不停的刷
    后台有一个线程,大约每秒都会刷新一次log buffer中的redo日志到磁盘。
  • 正常关闭服务器时
  • 做所谓的checkpoint
  • 其他的一些情况…

2.redo日志文件组

MySQL的数据目录(使用SHOW VARIABLES LIKE 'datadir'查看)下默认有两个名为ib_logfile0ib_logfile1的文件,log buffer中的日志默认情况下就是刷新到这两个磁盘文件中。如果我们对默认的redo日志文件不满意,可以通过下边几个启动参数来调节:

  • 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日志文件不只一个,而是以一个日志文件组的形式出现的。这些文件以ib_logfile[数字]数字可以是012…)的形式进行命名。在将redo日志写入日志文件组时,是从ib_logfile0开始写,如果ib_logfile0写满了,就接着ib_logfile1写,同理,ib_logfile1写满了就去写ib_logfile2,依此类推。如果写到最后一个文件该咋办?那就重新转到ib_logfile0继续写,所以整个过程如下图所示:

17.png

总共的redo日志文件大小其实就是:innodb_log_file_size × innodb_log_files_in_group

如果采用循环使用的方式向redo日志文件组里写数据的话,那岂不是要追尾,也就是后写入的redo日志覆盖掉前边写的redo日志?当然可能了!所以InnoDB提出了checkpoint的概念。

3.redo日志文件格式

log buffer本质上是一片连续的内存空间,被划分成了若干个512字节大小的block。将log buffer中的redo日志刷新到磁盘的本质就是把block的镜像写入日志文件中,所以redo日志文件其实也是由若干个512字节大小的block组成。

redo日志文件组中的每个文件大小都一样,格式也一样,都是由两部分组成:

  • 前2048个字节,也就是前4个block是用来存储一些管理信息的。
  • 从第2048字节往后是用来存储log buffer中的block镜像的。

所以我们前边所说的循环使用redo日志文件,其实是从每个日志文件的第2048个字节开始算,画个示意图就是这样:

18.png

普通block的格式我们在了解log buffer的时候都说过了,就是log block headerlog block bodylog block trialer这三个部分。这里需要介绍一下每个redo日志文件前2048个字节,也就是前4个特殊block的格式都是什么作用。

19.png
从图中可以看出来,这4个block分别是:

  • log file header:描述该redo日志文件的一些整体属性
    20.jpg
    各个属性的具体释义如下:
    | 属性名 | 长度(单位:字节) | 描述 | | —- | —- | —- | | LOG_HEADER_FORMAT | 4 | redo日志的版本,在MySQL 5.7.21中该值永远为1 | | LOG_HEADER_PAD1 | 4 | 做字节填充用的,没什么实际意义,忽略~ | | LOG_HEADER_START_LSN | 8 | 标记本redo日志文件开始的LSN值,也就是文件偏移量为2048字节初对应的LSN值。 | | LOG_HEADER_CREATOR | 32 | 一个字符串,标记本redo日志文件的创建者是谁。正常运行时该值为MySQL的版本号,比如:"MySQL 5.7.21",使用mysqlbackup命令创建的redo日志文件的该值为"ibbackup"和创建时间。 | | LOG_BLOCK_CHECKSUM | 4 | 本block的校验值,所有block都有,我们不关心 |

  • checkpoint1:记录关于checkpoint的一些属性,看一下它的结构:
    21.jpg
    各个属性的具体释义如下:
    | 属性名 | 长度(单位:字节) | 描述 | | —- | —- | —- | | LOG_CHECKPOINT_NO | 8 | 服务器做checkpoint的编号,每做一次checkpoint,该值就加1。 | | LOG_CHECKPOINT_LSN | 8 | 服务器做checkpoint结束时对应的LSN值,系统崩溃恢复时将从该值开始。 | | LOG_CHECKPOINT_OFFSET | 8 | 上个属性中的LSN值在redo日志文件组中的偏移量 | | LOG_CHECKPOINT_LOG_BUF_SIZE | 8 | 服务器在做checkpoint操作时对应的log buffer的大小 | | LOG_BLOCK_CHECKSUM | 4 | 本block的校验值,所有block都有,我们不关心 |

  • 第三个block未使用,忽略

  • checkpoint2:结构和checkpoint1一样。

六,Log Sequence Number

自系统开始运行,就不断的在修改页面,也就意味着会不断的生成redo日志。redo日志的量在不断的递增。InnoDB为记录已经写入的redo日志量,设计了一个称之为Log Sequence Number的全局变量,翻译过来就是:日志序列号,简称lsnInnoDB规定初始的lsn值为8704(也就是一条redo日志也没写入时,lsn的值为8704)。

在向log buffer中写入redo日志时不是一条一条写入的,而是以一个mtr生成的一组redo日志为单位进行写入的。而且实际上是把日志内容写在了log block body处。但是在统计lsn的增长量时,是按照实际写入的日志量加上占用的log block headerlog block trailer来计算的。我们来看一个例子:

  • 系统第一次启动后初始化log buffer时,buf_free(就是标记下一条redo日志应该写入到log buffer的位置的变量)就会指向第一个block的偏移量为12字节(log block header的大小)的地方,那么lsn值也会跟着增加12:
    22.png
  • 如果某个mtr产生的一组redo日志占用的存储空间比较小,也就是待插入的block剩余空闲空间能容纳这个mtr提交的日志时,lsn增长的量就是该mtr生成的redo日志占用的字节数,就像这样:
    23.png
    我们假设上图中mtr_1产生的redo日志量为200字节,那么lsn就要在8716的基础上增加200,变为8916
  • 如果某个mtr产生的一组redo日志占用的存储空间比较大,也就是待插入的block剩余空闲空间不足以容纳这个mtr提交的日志时,lsn增长的量就是该mtr生成的redo日志占用的字节数加上额外占用的log block headerlog block trailer的字节数,就像这样:
    24.png
    我们假设上图中mtr_2产生的redo日志量为1000字节,为了将mtr_2产生的redo日志写入log buffer,我们不得不额外多分配两个block,所以lsn的值需要在8916的基础上增加1000 + 12×2 + 4 × 2 = 1032

从上边的描述中可以看出来,每一组由mtr生成的redo日志都有一个唯一的LSN值与其对应,LSN值越小,说明redo日志产生的越早。

1.flushed_to_disk_lsn

redo日志是首先写到log buffer中,之后才会被刷新到磁盘上的redo日志文件。所以InnoDB提出了一个称之为buf_next_to_write的全局变量,标记当前log buffer中已经有哪些日志被刷新到磁盘中了。画个图表示就是这样:

25.png

lsn是表示当前系统中写入的redo日志量,这包括了写到log buffer而没有刷新到磁盘的日志,相应的,InnoDB提出了一个表示刷新到磁盘中的redo日志量的全局变量,称之为flushed_to_disk_lsn。系统第一次启动时,该变量的值和初始的lsn值是相同的,都是8704。随着系统的运行,redo日志被不断写入log buffer,但是并不会立即刷新到磁盘,lsn的值就和flushed_to_disk_lsn的值拉开了差距。我们推理一下:

  • 系统第一次启动后,向log buffer中写入了mtr_1mtr_2mtr_3这三个mtr产生的redo日志,假设这三个mtr开始和结束时对应的lsn值分别是:
    • mtr_1:8716 ~ 8916
    • mtr_2:8916 ~ 9948
    • mtr_3:9948 ~ 10000

此时的lsn已经增长到了10000,但是由于没有刷新操作,所以此时flushed_to_disk_lsn的值仍为8704,如图:
26.png

  • 随后进行将log buffer中的block刷新到redo日志文件的操作,假设将mtr_1mtr_2的日志刷新到磁盘,那么flushed_to_disk_lsn就应该增长mtr_1mtr_2写入的日志量,所以flushed_to_disk_lsn的值增长到了9948,如图:
    27.png

综上所述,当有新的redo日志写入到log buffer时,首先lsn的值会增长,但flushed_to_disk_lsn不变,随后随着不断有log buffer中的日志被刷新到磁盘上,flushed_to_disk_lsn的值也跟着增长。如果两者的值相同时,说明log buffer中的所有redo日志都已经刷新到磁盘中了。

应用程序向磁盘写入文件时其实是先写到操作系统的缓冲区中去,如果某个写入操作要等到操作系统确认已经写到磁盘时才返回,那需要调用一下操作系统提供的fsync函数。其实只有当系统执行了fsync函数后,flushed_to_disk_lsn的值才会跟着增长,当仅仅把log buffer中的日志写入到操作系统缓冲区却没有显式的刷新到磁盘时,另外的一个称之为write_lsn的值跟着增长。

2.lsn值和redo日志文件偏移量的对应关系

因为lsn的值是代表系统写入的redo日志量的一个总和,一个mtr中产生多少日志,lsn的值就增加多少(当然有时候要加上log block headerlog block trailer的大小),这样mtr产生的日志写到磁盘中时,很容易计算某一个lsn值在redo日志文件组中的偏移量,如图:

28.png

初始时的LSN值是8704,对应文件偏移量2048,之后每个mtr向磁盘中写入多少字节日志,lsn的值就增长多少。

3.flush链表中的LSN

一个mtr代表一次对底层页面的原子访问,在访问过程中可能会产生一组不可分割的redo日志,在mtr结束时,会把这一组redo日志写入到log buffer中。除此之外,在mtr结束时还有一件非常重要的事情要做,就是把在mtr执行过程中可能修改过的页面加入到Buffer Pool的flush链表。

当第一次修改某个缓存在Buffer Pool中的页面时,就会把这个页面对应的控制块插入到flush链表的头部,之后再修改该页面时由于它已经在flush链表中了,就不再次插入了。也就是说flush链表中的脏页是按照页面的第一次修改时间从大到小进行排序的。在这个过程中会在缓存页对应的控制块中记录两个关于页面何时修改的属性:

  • oldest_modification:如果某个页面被加载到Buffer Pool后进行第一次修改,那么就将修改该页面的mtr开始时对应的lsn值写入这个属性。
  • newest_modification:每修改一次页面,都会将修改该页面的mtr结束时对应的lsn值写入这个属性。也就是说该属性表示页面最近一次修改后对应的系统lsn值。

接着上边flushed_to_disk_lsn的例子看一下:

  • 假设mtr_1执行过程中修改了页a,那么在mtr_1执行结束时,就会将页a对应的控制块加入到flush链表的头部。并且将mtr_1开始时对应的lsn,也就是8716写入页a对应的控制块的oldest_modification属性中,把mtr_1结束时对应的lsn,也就是8916写入页a对应的控制块的newest_modification属性中。画个图表示一下(oldest_modification缩写成了o_mnewest_modification缩写成了n_m):
    29.png
  • 接着假设mtr_2执行过程中又修改了页b页c两个页面,那么在mtr_2执行结束时,就会将页b页c对应的控制块都加入到flush链表的头部。并且将mtr_2开始时对应的lsn,也就是8916写入页b页c对应的控制块的oldest_modification属性中,把mtr_2结束时对应的lsn,也就是9948写入页b页c对应的控制块的newest_modification属性中。画个图表示一下:
    30.png
    从图中可以看出来,每次新插入到flush链表中的节点都是被放在了头部,也就是说flush链表中前边的脏页修改的时间比较晚,后边的脏页修改时间比较早。
  • 接着假设mtr_3执行过程中修改了页b页d,不过页b之前已经被修改过了,所以它对应的控制块已经被插入到了flush链表,所以在mtr_3执行结束时,只需要将页d对应的控制块都加入到flush链表的头部即可。所以需要将mtr_3开始时对应的lsn,也就是9948写入页d对应的控制块的oldest_modification属性中,把mtr_3结束时对应的lsn,也就是10000写入页d对应的控制块的newest_modification属性中。另外,由于页bmtr_3执行过程中又发生了一次修改,所以需要更新页b对应的控制块中newest_modification的值为10000。画个图表示一下:
    31.png

总结一下上边说的,就是:flush链表中的脏页按照修改发生的时间顺序进行排序,也就是按照oldest_modification代表的LSN值进行排序,被多次更新的页面不会重复插入到flush链表中,但是会更新newest_modification属性的值。

七,checkpoint

redo日志文件组容量是有限的,我们不得不选择循环使用redo日志文件组中的文件,但是这会造成最后写的redo日志与最开始写的redo日志追尾,这时应该想到:redo日志只是为了系统崩溃后恢复脏页用的,如果对应的脏页已经刷新到了磁盘,也就是说即使现在系统崩溃,那么在重启后也用不着使用redo日志恢复该页面了,所以该redo日志也就没有存在的必要了,那么它占用的磁盘空间就可以被后续的redo日志所重用。也就是说:判断某些redo日志占用的磁盘空间是否可以覆盖的依据就是它对应的脏页是否已经刷新到磁盘里。我们看一下前边的那个例子:

32.png

如图,虽然mtr_1mtr_2生成的redo日志都已经被写到了磁盘上,但是它们修改的脏页仍然留在Buffer Pool中,所以它们生成的redo日志在磁盘上的空间是不可以被覆盖的。之后随着系统的运行,如果页a被刷新到了磁盘,那么它对应的控制块就会从flush链表中移除,就像这样子:

33.png

这样mtr_1生成的redo日志就没有用了,它们占用的磁盘空间就可以被覆盖掉了。InnoDB提出了一个全局变量checkpoint_lsn来代表当前系统中可以被覆盖的redo日志总量是多少,这个变量初始值也是8704

比方说现在页a被刷新到了磁盘,mtr_1生成的redo日志就可以被覆盖了,所以我们可以进行一个增加checkpoint_lsn的操作,我们把这个过程称之为做一次checkpoint。做一次checkpoint其实可以分为两个步骤:

  • 步骤一:计算一下当前系统中可以被覆盖的redo日志对应的lsn值最大是多少。
    redo日志可以被覆盖,意味着它对应的脏页被刷到了磁盘,只要我们计算出当前系统中被最早修改的脏页对应的oldest_modification值,那凡是在系统lsn值小于该节点的oldest_modification值时产生的redo日志都是可以被覆盖掉的,我们就把该脏页的oldest_modification赋值给checkpoint_lsn
    比方说当前系统中页a已经被刷新到磁盘,那么flush链表的尾节点就是页c,该节点就是当前系统中最早修改的脏页了,它的oldest_modification值为8916,我们就把8916赋值给checkpoint_lsn(也就是说在redo日志对应的lsn值小于8916时就可以被覆盖掉)。
  • 步骤二:将checkpoint_lsn和对应的redo日志文件组偏移量以及此次checkpint的编号写到日志文件的管理信息(就是checkpoint1或者checkpoint2)中。
    InnoDB维护了一个目前系统做了多少次checkpoint的变量checkpoint_no,每做一次checkpoint,该变量的值就加1。我们前边说过计算一个lsn值对应的redo日志文件组偏移量是很容易的,所以可以计算得到该checkpoint_lsnredo日志文件组中对应的偏移量checkpoint_offset,然后把这三个值都写到redo日志文件组的管理信息中。
    我们说过,每一个redo日志文件都有2048个字节的管理信息,但是上述关于checkpoint的信息只会被写到日志文件组的第一个日志文件的管理信息中。不过我们是存储到checkpoint1中还是checkpoint2中呢?InnoDB规定,当checkpoint_no的值是偶数时,就写到checkpoint1中,是奇数时,就写到checkpoint2中。

记录完checkpoint的信息之后,redo日志文件组中各个lsn值的关系就像这样:

34.png

1.批量从flush链表中刷出脏页

一般情况下都是后台的线程在对LRU链表flush链表进行刷脏操作,这主要因为刷脏操作比较慢,不想影响用户线程处理请求。但是如果当前系统修改页面的操作十分频繁,这样就导致写日志操作十分频繁,系统lsn值增长过快。如果后台的刷脏操作不能将脏页刷出,那么系统无法及时做checkpoint,可能就需要用户线程同步的从flush链表中把那些最早修改的脏页(oldest_modification最小的脏页)刷新到磁盘,这样这些脏页对应的redo日志就没用了,然后就可以去做checkpoint了。

2.查看系统中的各种LSN值

我们可以使用SHOW ENGINE INNODB STATUS命令查看当前InnoDB存储引擎中的各种LSN值的情况,比如:

  1. mysql> SHOW ENGINE INNODB STATUS\G
  2. (...省略前边的许多状态)
  3. LOG
  4. ---
  5. Log sequence number 124476971
  6. Log flushed up to 124099769
  7. Pages flushed up to 124052503
  8. Last checkpoint at 124052494
  9. 0 pending log flushes, 0 pending chkp writes
  10. 24 log i/o's done, 2.00 log i/o's/second
  11. ----------------------
  12. (...省略后边的许多状态)

其中:

  • Log sequence number:代表系统中的lsn值,也就是当前系统已经写入的redo日志量,包括写入log buffer中的日志。
  • Log flushed up to:代表flushed_to_disk_lsn的值,也就是当前系统已经写入磁盘的redo日志量。
  • Pages flushed up to:代表flush链表中被最早修改的那个页面对应的oldest_modification属性值。
  • Last checkpoint at:当前系统的checkpoint_lsn值。

3.innodb_flush_log_at_trx_commit的用法

为了保证事务的持久性,用户线程在事务提交时需要将该事务执行过程中产生的所有redo日志都刷新到磁盘上。这一条要求太狠了,会很明显的降低数据库性能。如果对事务的持久性要求不是那么强烈的话,可以选择修改一个称为innodb_flush_log_at_trx_commit的系统变量的值,该变量有3个可选的值:

  • 0:当该系统变量值为0时,表示在事务提交时不立即向磁盘中同步redo日志,这个任务是交给后台线程做的。
    这样很明显会加快请求处理速度,但是如果事务提交后服务器挂了,后台线程没有及时将redo日志刷新到磁盘,那么该事务对页面的修改会丢失。
  • 1:当该系统变量值为1时,表示在事务提交时需要将redo日志同步到磁盘,可以保证事务的持久性1也是innodb_flush_log_at_trx_commit的默认值。
  • 2:当该系统变量值为2时,表示在事务提交时需要将redo日志写到操作系统的缓冲区中,但并不需要保证将日志真正的刷新到磁盘。
    这种情况下如果数据库挂了,操作系统没挂的话,事务的持久性还是可以保证的,但是操作系统也挂了的话,那就不能保证持久性了。

八,崩溃恢复

在服务器不挂的情况下,redo日志不仅没用,反而让性能变得更差。但是万一数据库挂了,我们就可以在重启时根据redo日志中的记录就可以将页面恢复到系统崩溃前的状态。我们接下来大致看一下恢复过程。

1.确定恢复的起点

checkpoint_lsn之前的redo日志都可以被覆盖,也就是说这些redo日志对应的脏页都已经被刷新到磁盘中了,既然它们已经被刷盘,我们就没必要恢复它们了。对于checkpoint_lsn之后的redo日志,它们对应的脏页可能没被刷盘,也可能被刷盘了,我们不能确定,所以需要从checkpoint_lsn开始读取redo日志来恢复页面。

当然,redo日志文件组的第一个文件的管理信息中有两个block都存储了checkpoint_lsn的信息,我们当然是要选取最近发生的那次checkpoint的信息。衡量checkpoint发生时间早晚的信息就是所谓的checkpoint_no,只要把checkpoint1checkpoint2这两个block中的checkpoint_no值读出来比一下大小,哪个的checkpoint_no值更大,说明哪个block存储的就是最近的一次checkpoint信息。这样我们就能拿到最近发生的checkpoint对应的checkpoint_lsn值以及它在redo日志文件组中的偏移量checkpoint_offset

2.确定恢复的终点

redo日志恢复的起点确定了,那终点是哪个呢?这个还得从block的结构说起。在写redo日志的时候都是顺序写的,写满了一个block之后会再往下一个block中写。

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

3.怎么恢复

确定了需要扫描哪些redo日志进行崩溃恢复之后,接下来就是怎么进行恢复了。假设现在的redo日志文件中有5条redo日志,如图:

35.png

由于redo 0checkpoint_lsn后前边,恢复时可以不管它。现在可以按照redo日志的顺序依次扫描checkpoint_lsn之后的各条redo日志,按照日志中记载的内容将对应的页面恢复出来。这样没什么问题,不过InnoDB还是想了一些办法加快这个恢复的过程:

  • 使用哈希表
    根据redo日志的space IDpage number属性计算出散列值,把space IDpage number相同的redo日志放到哈希表的同一个槽里,如果有多个space IDpage number都相同的redo日志,那么它们之间使用链表连接起来,按照生成的先后顺序链接起来的,如图所示:
    36.png
    之后就可以遍历哈希表,因为对同一个页面进行修改的redo日志都放在了一个槽里,所以可以一次性将一个页面修复好(避免了很多读取页面的随机IO),这样可以加快恢复速度。另外需要注意一点的是,同一个页面的redo日志是按照生成时间顺序进行排序的,所以恢复的时候也是按照这个顺序进行恢复,如果不按照生成时间顺序进行排序的话,那么可能出现错误。比如原先的修改操作是先插入一条记录,再删除该条记录,如果恢复时不按照这个顺序来,就可能变成先删除一条记录,再插入一条记录,这显然是错误的。
  • 跳过已经刷新到磁盘的页面
    checkpoint_lsn之前的redo日志对应的脏页确定都已经刷到磁盘了,但是checkpoint_lsn之后的redo日志我们不能确定是否已经刷到磁盘,主要是因为在最近做的一次checkpoint后,可能后台线程又不断的从LRU链表flush链表中将一些脏页刷出Buffer Pool。这些在checkpoint_lsn之后的redo日志,如果它们对应的脏页在崩溃发生时已经刷新到磁盘,那在恢复时也就没有必要根据redo日志的内容修改该页面了。
    那在恢复时怎么知道某个redo日志对应的脏页是否在崩溃发生时已经刷新到磁盘了呢?这还得从页面的结构说起,每个页面都有一个称之为File Header的部分,在File Header里有一个称之为FIL_PAGE_LSN的属性,该属性记载了最近一次修改页面时对应的lsn值(其实就是页面控制块中的newest_modification值)。如果在做了某次checkpoint之后有脏页被刷新到磁盘中,那么该页对应的FIL_PAGE_LSN代表的lsn值肯定大于checkpoint_lsn的值,凡是符合这种情况的页面就不需要重复执行lsn值小于FIL_PAGE_LSN的redo日志了,所以更进一步提升了崩溃恢复的速度。

九,LOG_BLOCK_HDR_NO是如何计算的

对于实际存储redo日志的普通的log block来说,在log block header处有一个称之为LOG_BLOCK_HDR_NO的属性,我们说这个属性代表一个唯一的标号。这个属性是初次使用该block时分配的,跟当时的系统lsn值有关。使用下边的公式计算该block的LOG_BLOCK_HDR_NO值:

  1. ((lsn / 512) & 0x3FFFFFFFUL) + 1

37.png

从图中可以看出,0x3FFFFFFFUL对应的二进制数的前2位为0,后30位的值都为1。一个二进制位与0做与运算(&)的结果肯定是0,一个二进制位与1做与运算(&)的结果就是原值。让一个数和0x3FFFFFFFUL做与运算的意思就是要将该值的前2个比特位的值置为0,这样该值就肯定小于或等于0x3FFFFFFFUL了。这也就说明了,不论lsn多大,((lsn / 512) & 0x3FFFFFFFUL)的值肯定在0``~~0x3FFFFFFFUL~~之间,再加1的话肯定在~~1~~``0x40000000UL之间。而0x40000000UL这个值就代表着1GB。也就是说系统最多能产生不重复的LOG_BLOCK_HDR_NO值只有1GB个。InnoDB规定redo日志文件组中包含的所有文件大小总和不得超过512GB,一个block大小是512字节,也就是说redo日志文件组中包含的block块最多为1GB个,所以有1GB个不重复的编号值也就够用了。

另外,LOG_BLOCK_HDR_NO值的第一个比特位比较特殊,称之为flush bit,如果该值为1,代表着本block是在某次将log buffer中的block刷新到磁盘的操作中的第一个被刷入的block。

十,double write


1.脏页刷盘风险

关于IO的最小单位:

  1. 数据库IO的最小单位是16K(MySQL默认,oracle是8K)
  2. 文件系统IO的最小单位是4K(也有1K的)
  3. 磁盘IO的最小单位是512字节

因此,存在IO写入导致page损坏的风险:

MySQL[十四]redo日志 - 图38

2.doublewrite:两次写

提高innodb的可靠性,用来解决部分写失败(partial page write页断裂)。

2.1 Double write解决了什么问题

一个数据页的大小是16K,假设在把内存中的脏页写到数据库的时候,写了2K突然掉电,也就是说前2K数据是新的,后14K是旧的,那么磁盘数据库这个数据页就是不完整的,是一个坏掉的数据页。redo只能加上旧、校检完整的数据页恢复一个脏块,不能修复坏掉的数据页,所以这个数据就丢失了,可能会造成数据不一致,所以需要double write。

2.2使用情景

当数据库正在从内存想磁盘写一个数据页是,数据库宕机,从而导致这个页只写了部分数据,这就是部分写失效,它会导致数据丢失。这时是无法通过重做日志恢复的,因为重做日志记录的是对页的物理修改,如果页本身已经损坏,重做日志也无能为力。

2.3 double write工作流程

MySQL[十四]redo日志 - 图39

doublewrite由两部分组成,一部分为内存中的doublewrite buffer,其大小为2MB,另一部分是磁盘上共享表空间(ibdata x)中连续的128个页,即2个区(extent),大小也是2M。

  1. 当一系列机制触发数据缓冲池中的脏页刷新时,并不直接写入磁盘数据文件中,而是先拷贝至内存中的doublewrite buffer中;
  2. 接着从两次写缓冲区分两次写入磁盘共享表空间中(连续存储,顺序写,性能很高),每次写1MB;
  3. 待第二步完成后,再将doublewrite buffer中的脏页数据写入实际的各个表空间文件(离散写);(脏页数据固化后,即进行标记对应doublewrite数据可覆盖)

2.4 doublewrite的崩溃恢复

如果操作系统在将页写入磁盘的过程中发生崩溃,在恢复过程中,innodb存储引擎可以从共享表空间的doublewrite中找到该页的一个最近的副本,将其复制到表空间文件,再应用redo log,就完成了恢复过程。因为有副本所以也不担心表空间中数据页是否损坏。

Q:为什么_log write_不需要_doublewrite_的支持? A:因为_redolog_写入的单位就是512字节,也就是磁盘IO的最小单位,所以无所谓数据损坏。

3.doublewrite的副作用

3.1 double write带来的写负载

  1. double write是一个buffer, 但其实它是开在物理文件上的一个buffer, 其实也就是file, 所以它会导致系统有更多的fsync操作, 而硬盘的fsync性能是很慢的, 所以它会降低mysql的整体性能。
  2. 但是,doublewrite buffer写入磁盘共享表空间这个过程是连续存储,是顺序写,性能非常高,(约占写的10%),牺牲一点写性能来保证数据页的完整还是很有必要的。

    3.2 监控double write工作负载

  1. mysql> show global status like '%dblwr%';
  2. +----------------------------+-------+
  3. | Variable_name | Value |
  4. +----------------------------+-------+
  5. | Innodb_dblwr_pages_written | 7 |
  6. | Innodb_dblwr_writes | 3 |
  7. +----------------------------+-------+
  8. 2 rows in set (0.00 sec)

关注点:Innodb_dblwr_pages_written / Innodb_dblwr_writes

开启doublewrite后,每次脏页刷新必须要先写doublewrite,而doublewrite存在于磁盘上的是两个连续的区,每个区由连续的页组成,一般情况下一个区最多有64个页,所以一次IO写入应该可以最多写64个页。

而根据以上系统Innodb_dblwr_pages_written与Innodb_dblwr_writes的比例来看,大概在3左右,远远还没到64(如果约等于64,那么说明系统的写压力非常大,有大量的脏页要往磁盘上写),所以从这个角度也可以看出,系统写入压力并不高。

3.3 关闭double write适合的场景

  1. 海量DML
  2. 不惧怕数据损坏和丢失
  3. 系统写负载成为主要负载

    1. mysql> show variables like '%double%';
    2. +--------------------+-------+
    3. | Variable_name | Value |
    4. +--------------------+-------+
    5. | innodb_doublewrite | ON |
    6. +--------------------+-------+
    7. 1 row in set (0.04 sec)

    作为InnoDB的一个关键特性,doublewrite功能默认是开启的,但是在上述特殊的一些场景也可以视情况关闭,来提高数据库写性能。静态参数,配置文件修改,重启数据库。

    3.4 为什么没有把double write里面的数据写到data page里面呢?

  4. double write里面的数据是连续的,如果直接写到data page里面,而data page的页又是离散的,写入会很慢。

  5. double write里面的数据没有办法被及时的覆盖掉,导致double write的压力很大;短时间内可能会出现double write溢出的情况。

    十一,总结

redo日志记录了事务执行过程中都修改了哪些内容。

事务提交时只将执行过程中产生的redo日志刷新到磁盘,而不是将所有修改过的页面都刷新到磁盘。这样做有两个好处:

  1. redo日志占用的空间非常小
  2. redo日志是顺序写入磁盘的

一条redo日志由下面几部分组成。

  1. type:这条redo日志的类型
  2. space ID:表空间ID
  3. page number :页号
  4. data:这条redo日志的具体内容

redo日志的类型有简单和复杂之分。简单类型的redo日志是纯粹的物理日志,复杂类型的redo日志兼有物理日志和逻辑日志的特性。

一个MTR可以包含一组redo日志。在进行崩溃恢复时,这一组redo日志作为一个不可分割的整体来处理。

redo日志存放在大小为512字节的block中。每一个block被分为3部分:

  1. log block header
  2. log block body
  3. log block trailer

redo日志缓冲区是一片连续的内存空间,由若干个block组成;可以通过启动选项innodb_log_buffer_size 来调整他的大小。

redo日志文件组由若干个日志文件组成,这些redo日志文件是被循环使用的。redo日志文件组中每个文件的大小都一样,格式也一样,都是由两部分组成的:

  1. 前2048字节用来存储一些管理信息
  2. 从第2048字节往后的字节用来存储log buffer中的block镜像

lsn指已经写入的redo日志量,flushed_to_disk_lsn指刷新到磁盘中的redo日志量,flush链表中的脏页按照修改发生的时间顺序进行排序,也就是按照oldest_modification代表的lsn值进行排序。被多次更新的页面不会重复插入到flush链表,但是会更新newest_modification属性的值。checkpoint_lsn表示当前系统中可以被覆盖的redo日志总量是多少。

redo日志占用的磁盘空间在他对应的脏页已经被刷新到磁盘后即可被覆盖。执行一次checkpoint的意思就是增加checkpoint_lsn的值,然后把相关信息放到日志文件的管理信息中。

innodb_flush_log_at_trx_commit系统变量控制着在事务提交时是否将该事务运行过程中产生的redo刷新到磁盘。

在崩溃恢复过程中,从redo日志文件组第一个文件的管理信息中取出最近发生的那次checkpoint信息,然后从checkpoint_lsn在日志文件组中对应的偏移量开始,一直扫描日志文件中的block,直到某个block的LOG_BLOCK_HDR_DATA_LEN值不等于512为止。再恢复过程中,使用hash表可加快恢复过程,并且会跳过已经刷新到磁盘的页面。