数据库——redo日志和undo日志

redo日志

InnoDB存储引擎是以页为单位来管理存储空间,我们进行的增删改查实质都是在访问页面。在真正访问页面之前,需要把磁盘上的页缓存到内存中的Buffer Pool之后才可以访问。

在前面提到事务的四个特性时,持久性指示对于一个已经提交的事务,在事务提交后即使系统发生崩溃,这个事务对数据库所作的修改也不能丢失。如果我们只在Buffer Pool中修改了页面,假设事务提交后突然出现故障,导致内存中的数据失效,那么已提交的事务的修改也随之丢失了。那么我们又该如何保证这个持久性呢:在事务调教完成前,把事务修改的所有页面都刷新到磁盘。但这个做法有问题:

  • InnoDB是以页为单位进行磁盘IO的,有时如果我们仅修改了页面的一个字节,就把一个完整的页面从内存刷新到磁盘,这未免也态浪费了。
  • 一个事务可能包含很多语句,即使是一条语句也可能修改许多页面,这些页面可能并不相邻,需要进行很多随机IO。

因此我们可以记录修改的哪些东西,这样即使系统崩溃了,重启后按照记录的步骤更新数据页即可。记录这些修改步骤的内容称为redo日志。

 

Mini-Transaction

以组的形式写入redo日志

执行语句的过程中产生的redo日志被划分为若干个不可分割的组,比如:

  • 更新name属性产生的redo日志是不可分割的;
  • 向聚簇索引对应B+树的页面中插入一条记录产生的redo日志是不可分割的。

以向某个索引对应B+树的页面中插入一条记录为例,在定位到这条记录需要插入到的数据页之后,可能有两种情况:

(1)该数据页的剩余空间足够容纳一条待插入记录,这种情况称为乐观插入。

(2)该数据页的剩余空间不足,则需要进行页分裂:新建一个叶子节点,把原数据页种的一部分复制到这个新数据页中,再把待插入记录添加进去,最后还要在内节点中添加一条目录项记录来指向这个新创建的页。这种情况称为悲观插入。这个步骤需要对多个页面进行修改,意味着会产生多条redo日志。

插入一条记录的过程必须是原子的,也就是说上面的所有步骤要么全部完成,要么没有执行。因此在执行这些需要原子性操作时,必须要以的形式来记录redo日志。某个组中的redo日志,要么全部恢复,要么一条也不恢复。

Mini-Transaction的概念

Mini-Transaction是指对底层页面中的一次原子访问过程,简称mtr。比如向某个索引对应的B+树种插入一条记录算是一个mtr,

事务中的每一条语句是由若干个mtr组成,而一个mtr可以包含若干条redo日志。

 

redo日志 的组成

Redo日志分为:

  • redo日志缓存redo log buffer,它保存在内存中,是易失的。
  • redo日志文件redo log file,它保存在磁盘中,是持久的。

redo日志缓存

通过mtr生成的redo日志放在一个页中,该页称为block。由于磁盘速度过慢,在写入redo日志时,不能直接写到磁盘。因此服务器在启动时就向OS申请了一片 被称为redo日志缓存的连续内存空间。每生成一条redo日志,就将其暂存到一个地方。等mtr结束后,将该过程产生的一组redo日志全部赋值到redo日志缓存中。

redo日志缓存中写入redo日志的过程是顺序的:先往前边的block页写,写慢后往下一个block页写。

数据库——redo日志和undo日志

redo日志文件

redo日志缓存中的日志默认刷新到MySQL数据目录下的ib_logfile0和ib_logfile1的⽂件中。

磁盘上的redo日志文件是以一个日志文件组的形式出现的,这些文件以ib_logfile[数字]的形式进行命名。在将redo日志写入日志文件组时,是从ib_logfile0开始写,若写满了则从下一个文件开始写。若最后一个文件写满了,则重新转到ib_logfile0继续写。

数据库——redo日志和undo日志

 

redo的刷盘时机

在一些情况下redo日志缓存里的一组redo日志会被刷新到磁盘里:

  • redo日志缓存空间不足。当写入redo日志缓存的redo日志里已占了一半容量时,需要把这些日志刷新到磁盘上。
  • 事务提交时。
  • 后台有一个负责将redo日志缓存中的redo日志刷新到磁盘的的线程,大约每秒刷新一次。
  • 正常关闭服务器时。

 

何时写入redo

(1)在数据页修改完成,脏页刷出磁盘之前写入redo日志。

(2)redo日志比数据页先写回磁盘

 

redo的流程

以一个更新事务为例:

(1)将原始数据从磁盘读到内存中,修改数据的内存拷贝。

(2)生成一条重做日志,并写入重做日志缓存,记录数据被修改后的值

(3)当事务提交后,将重做日志缓存中的内容刷新到重做日志文件,对重做日志文件采用追加写的方式,一个文件写满后到下一个文件写

(4)定期将内存中修改的数据刷新到磁盘中

 

redo如何保证事务的持久性

InnoDB存储引擎通过Force Log at Commit 机制实现事务的持久性:当事务提交时,先将重做日志缓存 写入 重做日志文件进行持久化。等到事务的提交操作完成时才算完成。这种做法也称为 Write-Ahead Log(预先日志持久化):在持久化一个数据页前,先将内存中相应的日志页持久化。

每次将重做日志缓存 写入重载日志文件时都会调用一次操作系统的fsync操作。这是因为MySQL工作在用户空间,其重做日志缓存处于用户空间的内存中,要写入到磁盘上的重做日志文件中,中间还需要经过操作系统内核空间的os buffer。调用fsync()的作用就是讲os buffer中的日志刷到磁盘的重做日志文件中。

 

Log Sequeue Number

自系统开始运行,就不断的修改页面,不断的生成redo日志。为了记录已经写入的redo日志两,便有了全局变量Log Sequeue Number,它的意思是日志***,简称lsn。每一组由mtr生成的redo日志都有一个唯一的lsn值与其对应,lsn值越小,说明redo日志产生的越早。

 

checkpoint

一个redo日志文件组的容量是有限的,我们不得不循环使用redo日志文件组中的文件,但这会造成最后写的redo日志与最开始写的redo日志追尾。如果redo日志对应的脏页已经刷新到磁盘,那么它占有的磁盘空间可以被后续的redo日志重用。

数据库——redo日志和undo日志

如上图所示,mtr_1和mtr_2⽣成的redo⽇志都已经被写到了磁盘上,但是它们修改的脏⻚仍然留在Buffer Pool中,所以它们⽣成的redo⽇志在磁盘上的空间是不可以被覆盖的。之后随着系统的运⾏,如果⻚a被刷新到了磁盘,那么它对应的控制块就会从flush链表中移除

数据库——redo日志和undo日志

这样mtr_1⽣成的redo⽇志就没有⽤了,它们占⽤的磁盘空间就可以被覆盖掉了。

全局变量checkpoint_lsn用来代表当前系统可以被覆盖的redo日志总量是多少,此例中页a已被刷新到磁盘,mtr_1生成的redo日志可以被覆盖。我们可进行一个增加checkpoint_lsn的操作,我们把该过程称为一次checkpoint

 

崩溃恢复过程

确定恢复的起点

checkpoint_lsn之前的redo日志对应的脏页已经被刷新到磁盘中,这部分的日志我们无需恢复。对于checkpoint_lsn之后的redo日志,它们对应的redo日志可能没被刷盘,也可能刷盘了。因此需要从checkpoint_lsn开始读取redo日志来恢复页面。

确定恢复的终点

block页的log block header部分有⼀个LOG_BLOCK_HDR_DATA_LEN属性,它记录了当前block页使用了多少字节页面。对于被填满的block页而言,该值为512。若某一block页的该属性值不为512,则该block页为终点。

怎么恢复

假设现在的redo日志文件中有5条redo日志:

数据库——redo日志和undo日志

redo0在checkpoint_lsn后面,恢复时可以不必管他。我们可以按照redo日志的顺序依次扫描checkpoint_lsn之后的各条redo日志。按日志中记载的内容将对应的页面恢复。

 


undo日志

事务需要保证原子性,但有时事务执行到一半会出现一些情况:

  • 事务执行过程中可能遇到各种错误,比如服务器本身的错误,操作系统的错误,突然断电导致的错误。
  • 事务执行的过程中手动输入ROLLBACK语句结束当前事务的执行

这两种情况都会导致事务执行一半就结束,但在事务执行过程中可能已经修改了很多东西。为保证原子性,我们需要把东西改回原先的样子,这个过程称为回滚。为了回滚而记录的东西称为undo日志。

当执行ROLLBACK时,就可以从undo日志中的逻辑记录 读取到相应内容并回滚。此外undo日志还用于MVCC。

undo 日志的存储方式

表空间是由许多页面构成的,这些页面有不同类型,其中的一种FIL_PAGE_UNDO_LOG类型的⻚⾯是专⻔⽤来存储undo⽇志的,我们简称为undo页面。

数据库——redo日志和undo日志

单个事务的undo页面链表

一个事务可能包含多个语句,一个语句可能对若干条语句进行改动,对每条记录进行改动前,都需要记录1条或2条undo日志。一个事务执行过程中可能产生很多undo日志,一个页面可能放不了这些日志,因此需要多个页面来构成一个链表。

数据库——redo日志和undo日志

一个事务执行过程中可能需要2个undo页面的链表:insert undo链表和update undo链表。此外,对普通表和临时表的记录的改动是要分别记录,因此一个事务最多有4个以undo页面为节点组成的链表

4个链表的分配策略为:

  • 刚开启事务时⼀个Undo⻚⾯链表也不分配。
  • 当事务执⾏过程中向普通表中插⼊记录或者执⾏更新记录主键的操作之后,就会为其分配⼀个普通表的insert undo链表。
  • 当事务执⾏过程中删除或者更新了普通表中的记录之后,就会为其分配⼀个普通表的update undo链表。
  • 当事务执⾏过程中向临时表中插⼊记录或者执⾏更新记录主键的操作之后,就会为其分配⼀个临时表的insert undo链表。
  • 当事务执⾏过程中删除或者更新了临时表中的记录之后,就会为其分配⼀个临时表的update undo链表。

多个事务的undo页面链表

此情况的链表结构和上述大致相同,不过不同事务执行过程中产生的undo日志需要被写入到不同的undo页面链表中。

undo日志报文段

每个undo页面链表对应着一个段,称为undo日志段,链表中的页面都是从该段中申请的。当undo页面写慢undo日志后,就会再申请一个新页面

回滚段

前面我们知道一个事务执行过程中最多可以分配4个undo链表,同一时刻系统里有许多undo链表存在,为了管理这些链表,有一个称为Rollback Segment Header的页面,该页面存放了各个undo页面链表的头页面的页号,我们将这些页号叫做undo slot。每一个Rollback Segment Header页面对应一个段,称为回滚段

从回滚段中申请Undo⻚⾯链表

初始情况下,由于未向任何事务分配任何undo页面链表,对于任何一个Rollback Segment Header页面而言,它的各个undo slot都设置为特殊值FIL_NULL,表示undo slot不指向任何页面。

当有事务需要分配undo页面链表了,从回滚段中的第一个undo slot开始,看看该值是否为FIL_NULL:

  • 若是,则在表空间中新创建一个undo日志段,从该段中申请一个页面作为undo页面链表的first undo page,然后把undo slot的值设置为刚申请的页面的地址。这意味着这个undo slot被分配给这个事务
  • 若不是,则跳到下一个undo slot,重复上述步骤。

一个回滚段页面包含1024个undo slot,若这1024个undo slot的值都不为FIL_NULL,则回滚这个事务并给用户报错:

Too many active concurrent transactions       用户可以选择重新执行这个事务。

 

分配undo页面链表的过程

1)事务在执行过程中对普通表的记录首次做改动前,首先到系统表空间的第5号页面分配一个回滚段(获取一个Rollback Segment Header页面的地址)。某个回滚段被分配给该事务后,就无需重复分配了。

2)在分配到回滚段后,⾸先看⼀下这个回滚段的两个cached链表有没有已经缓存了的undo slot,

⽐如如果事务做的是INSERT操作,就去回滚段对应的 insert undo cached链表中看看有没有缓存的undo slot;如果事务做的是DELETE操作,就去回滚段对应的update undo cached链表中看看有没有缓存的 undo slot。如果有缓存的undo slot,那么就把这个缓存的undo slot分配给该事务。

3)如果没有缓存的undo slot可供分配,那么就要到Rollback Segment Header⻚⾯中找⼀个可⽤的undo slot分配给当前事务。

4)找到可用的undo slot后,如果该undo slot是从cached链表中获取的,那么它对应的undo日志段已经分配了,否则的话需要重新分配⼀个undo日志段,然后从该undo日志段中申请⼀个⻚⾯作为Undo⻚⾯链表的first undo page。

5)最后事务就可以把undo日志写入到undo页面了。

 

undo日志是否是redo日志的逆过程

undo日志是逻辑日志,对事务回滚时,是将数据库逻辑地址恢复原样;

redo日志是物理日志,记录的是数据页的物理变化。

答案是否定的。

 


参考资料

《MySQL是怎样运行的:从根儿上理解MySQL》

https://juejin.im/post/5c3c5c0451882525487c498d#heading-11