19.2 redo日志是啥
InnoDB存储引擎是以页为单位来管理存储空间的,在进行增删改查操作时本质上都是在访问页面。
如果只在内存的Buffer Pool中修改了页面,假设在事务提交后突然发生故障,导致内存中的数据都失效了,那么这个已经提交的事务在数据库中所做的更改也随之丢失。
简单粗暴的解决办法就是在事务提交完成之前,把该事务修改的所有页面都刷新到磁盘,但是存在以下问题:
刷新一个完整的数据页太浪费,有时仅修改某个页面中的一个字节,但是InnoDB是以页为单位进行磁盘I/O的,也就是说不得不将一个完整的页从内存中刷新到磁盘。
随机I/O刷新起来比较慢。一个事务可能包含很多语句,即使是一条语句也有可能修改许多页面,而且该事务修改的页面可能并不相邻,这就意味着将Buffer Pool中的页面刷新到磁盘时,需要进行许多随机I/O。
所以没必要再每次提交事务时就把事务在内存中修改过的全部页面刷新到磁盘,只需要把修改的内容记录一下即可。在事务提交时候,只需要将这些记录刷新到磁盘中,之后即使系统崩溃了,重启后只要按照上述内容记录的步骤重新更新一下数据页即可。
上述内容也叫重做日志(redo日志)。只将redo日志刷新到磁盘的好处如下:
redo日志占用的空间非常小:在存储表空间ID、页号、偏移量以及需要更新的值时,需要的存储空间很小;
redo日志是顺序写入磁盘的:在执行事务的过程中,每执行一条语句,就可能产生若干条redo日志,这些日志是顺序写入磁盘的。
19.3 redo日志格式
- type:这条redo日志的类型;
- space ID:表空间ID;
- page number:页号;
- data:这条redo日志的具体内容。
简单的redo日志类型
如果没有为某个表显式定义主键,并且表中也没有定义不允许存储NULL值的UNIQUE键,那么InnoDB会自动为表添加一个名为row_id的隐藏列作为主键。为该列赋值的方式如下:
服务器在内存中维护一个全局变量,每当向某个包含row_id隐藏列的表中插入一条记录时,就把该全局变量的值赋给row_id,并把该全局变量自增1;
每当该全局变量的值为256倍数时,就会将该变量的值刷新到系统表空间页号为7的页面中一个名为Max Row ID的属性中。
当系统启动时,将这个Max Row ID属性加载到内存中,并将该值加上256后复制给那个全局变量(因为在系统上次关机时,该全局变量值可能大于磁盘页面中的Max Row ID)。
这个Max Row ID属性占用的存储空间是8字节。每当需要写Max Row ID的时候,就要向系统表空间页号为7的页面的相应偏移量写入8字节的值。但是实际上写入操作是在Buffer Pool中完成的,需要把这次对该页面的修改以redo日志的形式记录下来。
在这种非常简单的情况下,redo日志只需要记录下在某个页面的某个偏移量处修改了几字节、具体修改后内容是什么即可——物理日志。根据具体写入多少数据,划分几种类型:
此处Max Row ID实际占用8字节,所以会记录一条MLOG_8BYTE的redo日志:
其余类型均类似,除了MLOG_WRITE_STRING类型,不确定写入具体数据占用多少字节,所以要在日志结构中添加一个len字段:
19.4 Mini-Transaction
以组的形式写入redo日志
在修改完页面之后,需要记录相应的redo日志,这些redo日志被InnoDB划分为若干个不可分割的组:
更新Max Row ID时产生的redo日志为一组;
向聚簇索引对应B+树的页面中插入一条记录时产生的redo日志是一组;
向某个二级索引对应B+树的页面中插入一条记录时产生的redo日志是一组;
其他……
此处的不可分割,以向某个索引对应的B+树插入一条记录为例。在插入之前,要先定位这条记录应该被插入到哪个叶子节点代表的数据页中。定位到具体数据页后,有两种情况:
情况1:该数据页剩余空闲空间充足,足够容纳这条待插入记录。此时,直接插入记录,然后记录一条MLOG_COMP_REC_INSERT类型的redo日志即可。这种情况为乐观插入。
情况2:该数据页剩余的空闲空间不足,要进行页分裂操作,即新建一个叶子节点,把原先数据页中的一部分记录复制到这个新的数据页中,再把记录插入进去;再把这个叶子节点插入到叶子节点链表中,最后还要在内节点中添加一条目录项记录指向该新创建的页面。显然这个过程要修改多个页面,所以会产生多条redo日志。所以被称为悲观插入。
InnoDB规定,向某个索引对应的B+树中插入一条记录的过程必须是原子的(atomic)。在执行这些需要保证原子性的操作时,必须以组的形式记录redo日志。那么如何在恢复时,针对某个组中的redo日志,要么全部恢复,要么一条也不恢复呢?要分情况讨论:
- 有些需要保证原子性的操作会生成多条redo日志。e.g. 悲观插入。
InnoDB为确保将这些redo日志划分到一个组,在该组中的最后一条redo日志后加上一条特殊类型的redo日志——MLOG_MULTI_REC_END,只有一个type字段。
所以某个需要保证原子性的操作所产生的一系列redo日志,必须以一条类型为MLOG_MULTI_REC_END的redo日志结尾:
这样子系统因崩溃而重启恢复时,只有解析到类型为MLOG_MULTI_REC_END的redo日志时,才认为解析到了一组完整的redo日志,才会进行恢复;否则直接放弃前面解析到的redo日志。
- 有些需要保证原子性的操作只生成一条redo日志。e.g. 更新Max Row ID属性的操作。
这种情况在一条日志后面跟一个MLOG_MULTI_REC_END类型的redo日志也可,但是InnoDB比较节俭。
redo日志的类型(type)只有几十种,小于127,所以只用7比特即可表示所有redo日志类型,而type字段占用了1字节,多出来了1个bit。所以,如果type字段的第1个bit为1,就说明这个需要保证原子性的操作只产生了一条单一的redo日志;否则表示产生了一系列的redo日志。
Mini-Transaction的概念
MySQL将对底层页面进行一次原子访问的过程称为一个Mini-Transaction(MTR)。一个MTR可以包含一组redo日志,在进行崩溃恢复时,需要将这一组redo日志作为一个不可分割的整体来处理。
19.5 redo日志的写入过程
redo log block
InnoDB把通过MTR生成的redo日志放入大小为512字节的页中,从而更好地管理redo日志。为与表空间中的页区分开,用来存储redo日志的页称为block。
真正的redo日志都存储到占用496字节的log block body中:
log block header 和 log block trailer 存储一些管理信息:
LOG_BLOCK_HDR_NO:每个block都有一个大于0的唯一编号;
LOG_BLOCK_HDR_DATA_LEN:表示block中已经使用多少字节。初始值为12,因为log block body从第12个字节开始。如果log block body被写满,该属性的值被设为512;
LOG_BLOCK_FIRST_REC_GROUP:代表该block中第一个MTR生成的redo日志记录组的偏移量;
LOG_BLOCK_CHECKPOINT_NO:表示checkpoint序号;
LOG_BLOCK_CHECKSUM:表示该block的校验值。
redo日志缓冲区
写入redo日志时,不能直接写到磁盘,在服务器启动时就向OS申请了一大片称为redo log buffer的连续内存空间,这篇内存空间被划分为若干个连续的redo log block:
redo日志写入log buffer
向log buffer中写入redo日志的过程是顺序写入的。当写入时 ,第一个问题就是,应该写在哪个block的哪个偏移量处。InnoDB提供一个全局变量buf_free,指明后续写入的redo日志应该写到log buffer中的哪个位置:
19.6 redo日志文件
redo日志刷盘时机
MTR运行过程中产生的一组redo日志会先暂存,待MTR结束时被复制到log buffer中。后续在一些情况下,会被刷新到磁盘:
- log buffer空间不足
如果当前写入log buffer的redo日志量已经占满了log buffer总容量的50%左右,就需要把这些日志刷新到磁盘中。
- 事务提交时
事务提交时可以不把修改过的Buffer Pool页面立刻刷新到磁盘,但是为了保证持久性,必须把页面修改时对应的redo日志刷新到磁盘。
后台有一个线程,大约以每秒一次的频率将log buffer中的redo日志刷新到磁盘
正常关闭服务器时
做checkpoint时
redo日志文件组
MySQL的数据目录下默认有名为ib_logfile0 和 ib_logfile1 的两个文件,log buffer中的日志默认情况下刷新到这两个磁盘文件中。
redo日志文件格式
log buffer 中的redo日志文件刷新到磁盘,本质就是将block的镜像写入日志文件,所以redo日志文件其实也是由若干个512字节大小的block组成。
在redo日志文件组中,每个文件大小都一样,都是由下面两部分组成:
- 前2048个字节(即前4个block)用来存储一些管理信息;
- 从第2048字节开始存储log buffer 中的block镜像。
每个redo日志文件的前2048个字节(即前4个特殊block):
其中,
- log file header:描述该redo日志文件的一些整体属性。
- checkpoint1:记录关于checkpoint的一些属性。
第三个block未使用,忽略
checkpoint2:结构与checkpoint1一样。
19.7 log sequence number
InnoDB设计了一个名为lsn(log sequence number)的全局变量,记录当前总共已写入的redo日志量。不过它的初始值为8704。
也就是每一组由MTR生成的redo日志都有一个唯一的lsn值与之对应;lsn值越小,说明redo日志产生得越早。
flushed_to_disk_lsn
redo日志先写到log buffer中,之后才会刷新到磁盘的redo日志文件中。所以InnoDB提出一个全局变量buf_next_to_write,用来标记当前log buffer中已经有哪些日志被刷新到磁盘中。
lsn表示当前系统中写入的redo日志量,包括了写到log buffer但没有刷新到磁盘的redo日志;所以提出一个全局变量flushed_to_disk_lsn表示刷新到磁盘中的redo日志量。
系统启动时候,lsn与flushed_to_disk_lsn值一样都是8704,但是随着系统的运行,redo日志被不断写入log buffer但并不会立刻刷新到磁盘,二者的值就拉开了差距。
flush链表中的lsn
MTR 除了在结束时将一组 redo 日志写入 log buffer 中,还会把在执行过程中修改过的页面加入到 Buffer Poo l的 flush 链表中。
当第一次修改某个已经加载到 Buffer Pool 中的页面时,就把这个页面对应的控制块插入到 flush 链表头部;之后再修改该页面时,由于已经在 flush 链表中,所以就不再次插入了。在此过程中,Buffer Pool 中的缓冲页控制块会有两个记录关于页面何时修改的属性:
oldest_modification:第一次修改 Buffer Pool 中的某个缓冲页时,就将修改该页面的MTR开始时对应的lsn值写入该属性;
newest_modification:每修改一次页面,都会将修改该页面的MTR结束时对应的lsn值写入该属性。(即该属性表示页面最近一次修改后对应的lsn值)
总结: flush 链表中的脏页按照第一次修改发生的时间顺序进行排序,也就是按照 oldest_modification 代表的 lsn 排序;被多次更新的页面不会重复插入到 flush 链表中,但是会更新 newest_modification 。
19.8 checkpoint
redo日志文件组的容量是有限的,所以不得不循环使用redo日志文件组中的文件,但是会造成最后写入的redo日志与最开始写入的redo日志追尾。
然而redo日志只是为了在系统崩溃后恢复脏页用,如果对应的脏页已经刷新到磁盘中,那么即使现在系统崩溃,重启后也用不着redo日志恢复该页面了。
所以只要脏页已经从Buffer Pool刷新到磁盘,redo日志就没有继续存在的必要了。
InnoDB提出一个全局变量checkpoint_lsn表示当前系统中可以覆盖的redo日志总量是多少,初始值也是8704。
执行一次 checkpoint 可以分为两个步骤:
- 计算当前系统中可以覆盖的redo日志对应的lsn值最大是多少。
redo 日志可以被覆盖,意味着它对应的脏页被刷新到了磁盘中,只要计算出最早修改的脏页对应的 oldest_modification 值,那么凡是系统在 lsn 小于该 oldest_modification 值时产生的 redo 日志都可以被覆盖掉。把该脏页的 oldest_modification 赋值给 checkpoint_lsn 。
- 将 checkpoint_lsn 与对应的 redo 日志文件组偏移量以及此次 checkpoint 的编号写到日志文件的管理信息中(即 checkpoint1 或 checkpoint2 )
InnoDB 维护了一个变量 checkpoint_no 用于统计当前系统执行了多少次 checkpoint 。随后可以计算得到该 checkpoint_lsn 在 redo 日志文件组中对应的偏移量 checkpoint_offset 。
上述关于 checkpoint 的信息只会写入日志文件组中第一个日志文件的管理信息中。而当 checkpoint_no 值为偶数时,写到 checkpoint1 中;为奇数时,就写到 checkpoint2 中。
HINT:将脏页刷新到磁盘中和执行一次 checkpoint 是两件不同的事。可以看出每执行一次 checkpoint 都要修改 redo 日志文件的管理信息,也就是说执行 checkpoint 是由代价的。
19.9 用户线程批量从 flush 链表中刷出脏页
一般情况下都是后台线程对 LRU 链表和 flush 链表进行刷脏操作,主要是因为刷脏操作比较慢,不想影响用户线程处理请求。
但是当系统频繁修改页面,导致频繁写 redo 日志,系统 lsn 增长过快。如果后台线程的刷脏操作不能将脏页快速刷出,系统将无法及时执行 checkpoint ,这就可能需要用户线程从flush链表中把最早修改的的脏页( oldest_modification 较小的脏页)同步刷新到磁盘。
19.11 innodb_flush_log_at_trx_commit的用法
为了保证事务的持久性,用户线程在事务提交时需要将该事务执行过程中产生的所有 redo 日志都刷新到磁盘中。但是这会明显降低数据库性能。如果对事务的持久性要求不那么强烈,可以修改一个系统变量:innodb_flush_log_at_trx_commit,有三个可选值:
0:表示在事务提交时不立刻向磁盘同步redo日志,这个任务交给后台线程来处理。
1:表示在事务提交时需要将 redo 日志同步到磁盘。
2:表示在事务提交时需要将 redo 日志写到 OS 的缓冲区中,但并不保证将日志真正刷新到磁盘。
19.12 崩溃恢复
确定恢复的起点
对于lsn值小于checkpoint_lsn的redo日志,它们可以被覆盖,因为对应的脏页已经刷到磁盘中了,所以不需要恢复。对于lsn值不小于checkpoint_lsn,对应的脏页可能没有刷盘、也可能刷盘了,所以需要从对应的lsn值为checkpoint_lsn的redo日志开始恢复页面。
在 redo 日志文件组第一个文件的管理信息中,有两个 block 都存储了 checkpoint_lsn 信息,为了选取最近发生的那次 checkpoint 信息,要比对 checkpoint1 和 checkpoint2 这两个 block 中的 checkpoint_no 值。哪个更大,哪个 block 就存储最近一次 checkpoint 的信息。
这样就得到了最近发生的 checkpoint 对应的 checkpoint_lsn 值已经它在redo日志文件组中的偏移量 checkpoint_offset 。
确定恢复的终点
普通的 block 的 log block header 部分有一个名为 LOG_BLOCK_HDR_DATA 的属性,记录了当前 block 中使用了多少字节空间。对于被填满的 block 来说,该值永远为512。如果不为512,那么就是此次崩溃恢复中需要扫描的最后一个 block。
恢复方式
按照 redo 日志顺序一次扫描 checkpoint_lsn 之后的各条 redo 日志,按照日志记载内容恢复对应页面。
而 InnoDB 使用了一些方法加快恢复过程:
- 使用哈希表
根据 redo 日志的 space ID 和 page number 属性计算出哈希值,把 space ID 和 page number 相同的 redo 日志放到哈希表的同一个槽中。如果有多条 space ID 和 page number 相同的 redo 日志,就用链表串起来。
之后就可以遍历哈希表。因为对同一个页面进行修改的 redo 日志都放在了同一个槽,所以可以一次性将一个页面修复好(避免了许多随机 I/O )。
- 跳过已经刷新到磁盘中的页面
对于 lsn 值不小于 checkpoint_lsn 的 redo 日志,对应的脏页不能确定是否已经刷盘,原因是在最近执行的一次 checkpoint 后,后台线程可能又不断地从 LRU 链表和 flush 链表中将一些脏页刷出 Buffer Pool。如果对应的脏页在崩溃发生时已经刷新到磁盘,那么在恢复时也无须根据 redo 日志修改该页面。
在每个页面中都有一个 File Header 部分,其中有 FIL_PAGE_LSN 属性,记录最近一次修改页面时对应的lsn值(就是 Buffer Pool 中的页面控制块中的 newest_modification )。