先说事务的持久性(Durablitiy):对于一个已经提交的事务,在事务提交后即使系统发生了崩溃,这个事务对数据库中所做的更改也不能丢失。我们知道InnoDB存储引擎是以页为单位来管理存储空间的,我们进行的增删改查操作其实本质上都是在访问页面(包括读页面、写页面、创建新页面等操作)。我们前面介绍Buffer Pool的时候说过,在真正访问页面之前,需要把在磁盘上的页缓存到内存中的Buffer Pool之后才可以访问。对于一个事务,如果我们只在内存的Buffer Pool中修改了页面,假设在事务提交后突然发生了某个故障,导致内存中的数据都失效了,那么这个已经提交了的事务对数据库中所做的更改也就跟着丢失了,这是我们所不能忍受的。当然很简单的一个做法就是在事务提交完成之前把该事务所修改的所有页面都刷新到磁盘,但是这个简单粗暴的做法有些问题:
刷新一个完整的数据页太浪费了:
有时候我们仅仅修改了某个页面中的一个字节,但是我们知道在InnoDB中是以页为单位来进行磁盘IO的,
也就是说我们在该事务提交时不得不将一个完整的页面从内存中刷新到磁盘,
我们又知道一个页面默认是16KB大小,只修改一个字节就要刷新16KB的数据到磁盘上显然是太浪费了。
随机IO刷起来比较慢:
一个事务可能包含很多语句,即使是一条语句也可能修改许多页面,倒霉催的是该事务修改的这些页面可能并不相邻,
这就意味着在将某个事务修改的Buffer Pool中的页面刷新到磁盘时,需要进行很多的随机IO,
随机IO比顺序IO要慢,尤其对于传统的机械硬盘来说。
咋办呢?再次回到我们的初心:我们只是想让已经提交了的事务对数据库中数据所做的修改永久生效,即使后来系统崩溃,在重启后也能把这种修改恢复出来。所以我们其实 没有必要在每次事务提交时就把该事务在内存中修改过的全部页面刷新到磁盘,只需要把修改了哪些东西记录一下就好,比方说某个事务将系统表空间中的第100号页面中偏移量为1000处的那个字节的值1改成2我们只需要记录一下:将第0号表空间的100号页面的偏移量为1000处的值更新为2。 这样我们在事务提交时,把上述内容刷新到磁盘中,即使之后系统崩溃了,重启之后只要按照上述内容所记录的步骤重新更新一下数据页,那么该事务对数据库中所做的修改又可以被恢复出来,也就意味着满足持久性的要求。因为在系统奔溃重启时需要按照上述内容所记录的步骤重新更新数据页,所以上述内容也被称之为重做日志,英文名为redo log,我们也可以土洋结合,称之为redo日志。
通过mtr(Mini-Transaction)生成的redo日志都放在了大小为512字节的页中。为了和我们前面提到的表空间中的页做区别,我们这里把用来存储redo日志的页称为block(你心里清楚页和block的意思其实差不多就行了)。
写入redo日志时也不能直接直接写到磁盘上,实际上在服务器启动时就向操作系统申请了一大片称之为redo log buffer的连续内存空间,翻译成中文就是redo日志缓冲区,我们也可以简称为 log buffer。这片内存空间被划分成若干个连续的redo log block (大小为 512 字节),就像这样:
可以通过启动参数innodb_log_buffer_size来指定log buffer的大小,在MySQL 5.7.21这个版本中,该启动参数的默认值为16MB。
MySQL的数据目录(使用SHOW VARIABLES LIKE 'datadir’查看)下默认有两个名为ib_logfile0和ib_logfile1的文件,log buffer中的日志默认情况下就是刷新到这两个磁盘文件中。
log buffer 本质上是一片连续的内存空间,被划分成了若干个512字节大小的block。将log buffer中的redo日志刷新到磁盘的本质就是把block的镜像写入日志文件中,所以redo日志文件其实也是由若干个512字节大小的block组成。
我们前面说mtr运行过程中产生的一组redo日志在mtr结束时会被复制到 log buffer中,可是这些日志总在内存里呆着也不是个办法,在一些情况下它们会被刷新到磁盘里,比如:
自系统开始运行,就不断的在修改页面,也就意味着会不断的生成redo日志。redo日志的量在不断的递增,就像人的年龄一样,自打出生起就不断递增,永远不可能缩减了。设计InnoDB的大佬为记录已经写入的redo日志量,设计了一个称之为 Log Sequeue Number 的全局变量,翻译过来就是:日志序列号,简称lsn。不过不像人一出生的年龄是0岁,设计InnoDB的大佬规定初始的lsn值为8704(也就是一条redo日志也没写入时,lsn的值为8704)。
redo日志是首先写到log buffer中,之后才会被刷新到磁盘上的redo日志文件。所以设计InnoDB的大佬提出了一个称之为 buf_next_to_write 的全局变量,标记当前log buffer中已经有哪些日志被刷新到磁盘中了。画个图表示就是这样:
有一个很不幸的事实就是我们的redo日志文件组容量是有限的,我们不得不选择循环使用redo日志文件组中的文件,但是这会造成最后写的redo日志与最开始写的redo日志追尾,这时应该想到:redo日志只是为了系统奔溃后恢复脏页用的,如果对应的脏页已经刷新到了磁盘,也就是说即使现在系统奔溃,那么在重启后也用不着使用redo日志恢复该页面了,所以该redo日志也就没有存在的必要了,那么它占用的磁盘空间就可以被后续的redo日志所重用。也就是说:判断某些redo日志占用的磁盘空间是否可以覆盖的依据就是它对应的脏页是否已经刷新到磁盘里。我们看一下前面一直介绍的那个例子:
如图,虽然mtr_1和mtr_2生成的redo日志都已经被写到了磁盘上,但是它们修改的脏页仍然留在Buffer Pool中,所以它们生成的redo日志在磁盘上的空间是不可以被覆盖的。 之后随着系统的运行,如果页a被刷新到了磁盘, 那么它对应的控制块就会从flush链表中移除,就像这样子:
这样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_lsn 在redo日志文件组中对应的偏移量 checkpoint_offset,然后把这三个值都写到redo日志文件组的管理信息中。我们说过,每一个 redo日志文件都有2048个字节的管理信息,但是上述关于checkpoint的信息只会被写到日志文件组的第一个日志文件的管理信息中。不过我们是存储到checkpoint1中还是checkpoint2中呢?设计InnoDB的大佬规定,当checkpoint_no的值是偶数时,就写到checkpoint1中,是奇数时,就写到checkpoint2中。
记录完checkpoint的信息之后,redo日志文件组中各个lsn值的关系就像这样:
在服务器不挂的情况下,redo日志简直就是个大累赘,不仅没用,反而让性能变得更差。但是万一,我说万一啊,万一数据库挂了,那redo日志可是个宝了,我们就可以在重启时根据redo日志中的记录就可以将页面恢复到系统奔溃前的状态。我们接下来大致看一下恢复过程是什么样。
我们前面说过,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。
redo日志恢复的起点确定了,那终点是哪个呢?这个还得从block的结构说起。我们说在写redo日志的时候都是顺序写的,写满了一个block之后会再往下一个block中写:
普通block的log block header部分有一个称之为LOG_BLOCK_HDR_DATA_LEN的属性,该属性值记录了当前block里使用了多少字节的空间。对于被填满的block来说,该值永远为512。如果该属性的值不为512,那么就是它了,它就是此次奔溃恢复中需要扫描的最后一个block。
确定了需要扫描哪些redo日志进行奔溃恢复之后,接下来就是怎么进行恢复了。假设现在的redo日志文件中有5条redo日志,如图:
由于redo 0在checkpoint_lsn后边,恢复时可以不管它。我们现在可以按照redo日志的顺序依次扫描checkpoint_lsn之后的各条redo日志,按照日志中记载的内容将对应的页面恢复出来。这样没什么问题,不过设计InnoDB的大佬还是想了一些办法加快这个恢复的过程:
使用哈希表
之后就可以遍历哈希表,因为对同一个页面进行修改的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日志了,所以更进一步提升了奔溃恢复的速度。