玩命加载中 . . .

19-redo日志


在真正访问页面之前,需要把在磁盘上的页缓存到内存中的 Buffer Pool 之后才可以访问,对于一个已经提交的事务,在事务提交后即使系统发生了崩溃,这个事务对数据库中所做的更改也不能丢失,但是如果我们只在内存的 Buffer Pool 中修改了页面,假设在事务提交后突然发生了某个故障,导致内存中的数据都失效了,那么这个已经提交了的事务对数据库中所做的更改也就跟着丢失了

如何保证这个持久性呢?一个很简单的做法就是在事务提交完成之前把该事务所修改的所有页面都刷新到磁盘,但是这个做法有些问题:

  • 刷新一个完整的数据页太浪费了,因为修改了一个字节就要刷新16KB的数据到磁盘上显然是太浪费了
  • 随机IO刷起来比较慢,将某个事务修改的 Buffer Pool 中的页面刷新到磁盘时,需要进行很多的随机IO

我们只是想让已经提交了的事务对数据库中数据所做的修改永久生效,即使后来系统崩溃,在重启后也能把这种修改恢复出来。所以其实没有必要在每次事务提交时就把该事务在内存中修改过的全部页面刷新到磁盘,只需要把修改了哪些东西记录一下就好

所以引出了redo日志,只记录对数据库进行了哪些修改

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

redo 日志本质上只是记录了一下事务对数据库做了哪些修改

19.3 redo日志格式

  • type:该条 redo 日志的类型。一共有53种不同的类型
  • space ID:表空间ID
  • page number:页号
  • data:该条 redo 日志的具体内容

19.3.1 简单的redo日志类型

只需要记录一下在某个页面的某个偏移量处修改了几个字节的值,具体被修改的内容是啥就好了,称为物理日志

19.3.2 复杂一些的redo日志类型

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

INSERT语句对所有页面的修改都得保存到redo日志中

把一条记录插入到一个页面时需要更改的地方非常多,针对不同的修改对应不同的redo日志类型

  • MLOG_REC_INSERT(type字段对应的十进制数字为9):表示插入一条使用非紧凑行格式的记录时的redo日志类型
  • MLOG_COMP_REC_INSERT(type字段对应的十进制数字为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日志对应的记录为止
  1. 物理层面看,这些日志都指明了对哪个表空间的哪个页进行了修改
  2. 逻辑层面看,在系统奔溃重启时,并不能直接根据这些日志里的记载,将页面内的某个偏移量处恢复成某个数据,而是需要调用一些事先准备好的函数,执行完这些函数后才可以将页面恢复成系统奔溃前的样子

redo日志会把事务在执行过程中对数据库所做的所有修改都记录下来,在之后系统奔溃重启后可以把事务所做的任何修改都恢复出来

19.4 Mini-Transaction

19.4.1 以组的形式写入redo日志

在执行语句的过程中产生的 redo 日志被划分成了若干个不可分割的组,例如

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

以向某个索引对应的 B+ 树插入一条记录为例

情况一:该数据页的剩余的空闲空间充足,足够容纳这一条待插入记录,那么直接把记录插入到这个数据页中,记录一条类型为MLOG_COMP_REC_INSERTredo日志就好了,这种情况称之为乐观插入

情况二:该数据页剩余的空闲空间不足,要进行页分裂操作,也就是新建一个叶子节点,然后把原先数据页中的一部分记录复制到这个新的数据页中,然后再把记录插入进去,把这个叶子节点插入到叶子节点链表中,最后还要在内节点中添加一条目录项记录指向这个新创建的页面。这个过程要对多个页面进行修改,也就意味着会产生多条 redo 日志,这种情况称之为悲观插入

执行这些需要保证原子性的操作时必须以组的形式来记录的 redo 日志,在进行恢复时,针对某个组中的redo日志,要么把全部的日志都恢复掉,要么一条也不恢复

如何把这些 redo 日志划分到一个组里边儿呢?在该组中的最后一条 redo 日志后边加上一条特殊类型的redo日志,该类型名称为 MLOG_MULTI_REC_ENDtype字段对应的十进制数字为31,结构很简单,只有一个type字段

某个需要保证原子性的操作产生的一系列 redo 日志必须要以一个类型为 MLOG_MULTI_REC_END 结尾

这样在系统奔溃重启进行恢复时,只有当解析到类型为MLOG_MULTI_REC_END的 redo 日志,才认为解析到了一组完整的 redo 日志,才会进行恢复。否则的话直接放弃前边解析到的 redo 日志

如果 type 字段的第一个比特位为 1,代表该需要保证原子性的操作只产生了单一的一条 redo 日志,否则表示该需要保证原子性的操作产生了一系列的 redo 日志

19.4.2 Mini-Transaction的概念

对底层页面中的一次原子访问的过程称之为一个 Mini-Transaction ,简称 MTR

一个 MTR 可以包含一组 redo 日志

一个事务可以包含若干条语句,每一条语句其实是由若干个 MTR 组成,每一个 MTR 又可以包含若干条 redo 日 志

19.5 redo日志的写入过程

把通过 mtr 生成的 redo 日志都放在了大小为 512 字节的页中

这里把用来存储 redo 日志的页称为 block,一个 redo log 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个字节处开始)。随着往block中写入的redo日志越来也多,本属性值也跟着增长。如果 log block body 已经被全部写满,那么本属性的值被设置为 512
  • LOG_BLOCK_FIRST_REC_GROUP:一条 redo 日志也可以称之为一条 redo 日志记录,一个 mtr 会生产多条 redo 日志记录,这些 redo 日志记录被称之为一个 redo 日志记录组,LOG_BLOCK_FIRST_REC_GROUP 就代表该block中第一个 mtr 生成的 redo 日志记录组的偏移量
  • LOG_BLOCK_CHECKPOINT_NO:表示 checkpoint 的序号
  • LOG_BLOCK_CHECKSUM:表示block的校验值,用于正确性校验

写入 redo 日志时也不能直接直接写到磁盘上,实际上在服务器启动时就向操作系统申请了一大片称之为 redo log buffer 的连续内存空间,翻译成中文就是 redo 日志缓冲区,我们也可以简称为 log buffer。这片内存空间被划分成若干个连续的 redo log block

一个 mtr 执行过程中可能产生若干条 redo 日志,这些 redo 日志是一个不可分割的组,每个 mtr 运行过程中产生的日志会先暂时存到一个地方,当该 mtr 结束的时候,将过程中产生的一组 redo 日志再全部复制到 log buffer 中

19.6 redo日志文件

redo日志需要被刷新到磁盘里

  • log buffer 空间不足时
  • 事务提交时
  • 后台有一个线程,大约以每秒一次的频率将log buffer中的redo日志刷新到磁盘
  • 正常关闭服务器时
  • 做 checkpoint 时

将 log buffer 中的 redo 日志刷新到磁盘的本质就是把block的镜像写入日志文件中,redo 日志文件其实也是由若干个 512 字节大小的block组成

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

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

19.7 Log Sequeue Number

MySQL 为记录已经写入的 redo 日志量,设计了一个称之为Log Sequeue Number的全局变量,即日志序列号 ,简称 lsn ,规定初始的 lsn 值为 8704

每一组由mtr生成的redo日志都有一个唯一的LSN值与其对应,LSN值越小,说明 redo日志产生的越早

redo 日志是首先写到 log buffer 中,之后才会被刷新到磁盘上的 redo 日志文件

有一个buf_next_to_write的全局变量,标记当前 log buffer 中已经有哪些日志被刷新到磁盘中了

表示刷新到磁盘中的 redo 日志量的全局变量,称之为flushed_to_disk_lsn

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

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

19.7.3 flush链表中的lsn

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

flush链表中的脏页是按照页面的第 一次修改时间从大到小进行排序的

会在缓存页对应的控制块中记录两个关于页面何时修改的属性

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

每次新插入到 flush 链表中的节点都是被放在了头部,前边的脏页修改的时间比较晚,后边的脏页修改时间比较早

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

19.8 checkpoint

redo 日志文件组容量是有限的,我们不得不选择循环使用 redo 日志文件组中的文件

redo日志只是为了系统 奔溃后恢复脏页用的,如果对应的脏页已经刷新到了磁盘,也就是说即使现在系统奔溃,那么在重启后也用不着 使用redo日志恢复该页面了,所以该redo日志也就没有存在的必要了,那么它占用的磁盘空间就可以被后续的 redo日志所重用

判断某些redo日志占用的磁盘空间是否可以覆盖的依据就是它对应的脏页是否已经 刷新到磁盘里

虽然 mtr_1 和 mtr_2 生成的 redo 日志都已经被写到了磁盘上,但是它们修改的脏页仍然留在 Buffer Pool 中,所以它们生成的 redo 日志在磁盘上的空间是不可以被覆盖的,如果页a被刷新到了磁盘,那么它对应的控制块就会从 flush 链表中移除

这样 mtr_1 生成的 redo 日志就没有用了,它们占用的磁盘空间就可以被覆盖掉了

一个全局变量checkpoint_lsn来代表当前系统中可以被覆盖的 redo 日志总量是多少,这个变量初始值也是 8704

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

  • 计算一下当前系统中可以被覆盖的 redo 日志对应的 lsn 值最大是多少。redo 日志可以被覆盖,意味着它对应的脏页被刷到了磁盘,只要计算出当前系统中被最早修改的脏页对应的oldest_modification值,那凡是在系统 lsn 值小于该节点的oldest_modification值时产生的 redo 日志都是可以被覆盖掉的,我们就把该脏页的oldest_modification赋值给checkpoint_lsn

  • checkpoint_lsn和对应的redo日志文件组偏移量以及此次checkpint的编号写到日志文件的管理信息

19.12 崩溃恢复

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

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

因崩溃而恢复系统时,只需要从checkpoint_lsn在日志文件组中对应的偏移量开始,一直扫描 redo 日志文件中的block,直到某个block的LOG_BLOCK_HDR_DATA_LEN值不等于512为止

加快恢复的过程

  • 使用哈希表

根据 redo 日志的space IDpage number属性计算出散列值,把space IDpage number相同的 redo 日志放到哈希表的同一个槽里,如果有多个space IDpage number都相同的 redo 日志,那么它们之间使用链表连接起来,按照生成的先后顺序链接起来的

因为对同一个页面进行修改的 redo 日志都放在了一个槽里,所以可以一次性将一个页面修复好(避免了很多读取页面的随机IO),这样可以加快恢复速度

同一个页面的 redo 日志是按照生成时间顺序进行排序的,所以恢复的时候也是按照这个顺序进行恢复,如果不按照生成时间顺序进行排序的话,那么可能出现错误

  • 跳过已经刷新到磁盘的页面

我们前边说过,checkpoint_lsn之前的 redo 日志对应的脏页确定都已经刷到磁盘了,但是checkpoint_lsn之后的 redo 日志我们不能确定是否已经刷到磁盘,主要是因为在最近做的一次checkpoint后,可能后台线程又不断的从 LRU 链表 和 flush 链表中将一些脏页刷出 Buffer Pool。这些在checkpoint_lsn之后的 redo 日志,如果它们对应的脏页在奔溃发生时已经刷新到磁盘,那在恢复时也就没有必要根据 redo 日志的内容修改该页面了

每个页面都有一个称之为File Header的部分,在File Header里有一个称之为FIL_PAGE_LSN的属性,该属性记载了最近一次修改页面时对应的 lsn 值(其实就是页面控制块中的newest_modification值)。如果在做了某次checkpoint之后有脏页被刷新到磁盘中,那么该页对应的FIL_PAGE_LSN代表的 lsn 值肯定大于checkpoint_lsn的值,凡是符合这种情况的页面就不需要重复执行 lsn 值小于FIL_PAGE_LSN的 redo 日志了,所以更进一步提升了奔溃恢复的速度


文章作者: kunpeng
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 kunpeng !
  目录