赞
踩
我们知道 InnoDB
存储引擎是以页为单位来管理存储空间的,我们进行的增删改查操作其实本质上都是在访问页面(包括读页面、写页面、创建新页面等操作)。
我们前边唠叨Buffer Pool
的时候说过,在真正访问页面之前,需要把在磁盘上的页缓存到内存中的 Buffer Pool 之后才可以访问。但是在唠叨事务的时候又强调过一个称之为 持久性
的特性,就是说对于一个已经提交的事务,在事务提交后即使系统发生了崩溃,这个事务对数据库中所做的更改也不能丢失。但是如果我们只在内存的 Buffer Pool 中修改了页面,假设在事务提交后突然发生了某个故障,导致内存中的数据都失效了,那么这个已经提交了的事务对数据库中所做的更改也就跟着丢失了,这是我们所不能忍受的(想想ATM机已经提示狗哥转账成功,但之后由于服务器出现故障,重启之后猫爷发现自己没收到钱,猫爷就被砍死了)。那么如何保证这个 持久性 呢?一个很简单的做法就是在事务提交完成之前把该事务所修改的所有页面都刷新到磁盘,但是这个简单粗暴的做法有些问题:
刷新一个完整的数据页太浪费了
有时候我们仅仅修改了某个页面中的一个字节,但是我们知道在 InnoDB 中是以页为单位来进行磁盘IO的,也就是说我们在该事务提交时不得不将一个完整的页面从内存中刷新到磁盘,我们又知道一个页面默认是16KB大小,只修改一个字节就要刷新16KB的数据到磁盘上显然是太浪费了。
随机IO刷起来比较慢
一个事务可能包含很多语句,即使是一条语句也可能修改许多页面,倒霉催的是该事务修改的这些页面可能并不相邻,这就意味着在将某个事务修改的 Buffer Pool 中的页面刷新到磁盘时,需要进行很多的随机IO,随机IO比顺序IO要慢,尤其对于传统的机械硬盘来说。
我们只是想让已经提交了的事务对数据库中数据所做的修改永久生效,即使后来系统崩溃,在重启后也能把这种修改恢复出来。所以我们其实没有必要在每次事务提交时就把该事务在内存中修改过的全部页面刷新到磁盘,只需要把修改了哪些东西记录一下就好,比方说某个事务将系统表空间中的第100号页面中偏移量为1000处的那个字节的值 1 改成 2 我们只需要记录一下:
将第0号表空间的100号页面的偏移量为1000处的值更新为 2 。
这样我们在事务提交时,把上述内容刷新到磁盘中,即使之后系统崩溃了,重启之后只要按照上述内容所记录的步骤重新更新一下数据页,那么该事务对数据库中所做的修改又可以被恢复出来,也就意味着满足 持久性
的要求。
所以上述内容也被称之为 重做日志 ,英文名为 redo log
。
与在事务提交时将所有修改过的内存中的页面刷新到磁盘中相比,只将该事务执行过程中产生的 redo 日志刷新到磁盘的好处如下:
redo 日志占用的空间非常小
存储表空间ID、页号、偏移量以及需要更新的值所需的存储空间是很小的。
redo 日志是顺序写入磁盘的
在执行事务的过程中,每执行一条语句,就可能产生若干条 redo 日志,这些日志是按照产生的顺序写入磁盘的,也就是使用顺序IO。
通过上边的内容我们知道, redo 日志本质上只是记录了一下事务对数据库做了哪些修改。 设计 InnoDB 的大叔们针对事务对数据库的不同修改场景定义了多种类型的 redo 日志,但是绝大部分类型的 redo 日志都有下边这种通用的结构:
各个部分的详细释义如下:
type
:该条 redo 日志的类型。
设计 InnoDB 的大叔一共为 redo 日志设计了53种不同的类型。
space ID
:表空间ID。
page number
:页号。
data
:该条 redo 日志的具体内容。
redo日志格式总结:
redo日志会把事务在执行过程中对数据库所做的所有修改都记录下来,在之后系统奔溃重启后可以把事务所做的任何修改都恢复出来。
语句在执行过程中可能修改若干个页面。比如我们前边说的一条 INSERT 语句可能修改系统表空间页号为 7 的页面的 Max Row ID 属性(当然也可能更新别的系统页面,只不过我们没有都列举出来而已),还会更新聚簇索引和二级索引对应 B+ 树中的页面。由于对这些页面的更改都发生在 Buffer Pool 中,所以在修改完页面之后,需要记录一下相应的 redo 日志。在执行语句的过程中产生的 redo 日志被设计 InnoDB 的大叔人为的划分成了若干个不可分割的组,比如:
怎么理解这个 不可分割 的意思呢?我们以向某个索引对应的 B+ 树插入一条记录为例,在向 B+ 树中插入这条记录之前,需要先定位到这条记录应该被插入到哪个叶子节点代表的数据页中,定位到具体的数据页之后,有两种可能的情况:
该数据页的剩余的空闲空间充足,足够容纳这一条待插入记录,那么事情很简单,直接把记录插入到这个数据页中,记录一条redo 日志就好了,我们把这种情况称之为 乐观插入 。假如某个索引对应的 B+ 树长这样:
现在我们要插入一条键值为 10 的记录,很显然需要被插入到 页b 中,由于 页b 现在有足够的空间容纳一条记录,所以直接将该记录插入到 页b 中就好了,就像这样:
该数据页剩余的空闲空间不足,那么事情就悲剧了,我们前边说过,遇到这种情况要进行所谓的 页分裂 操作,也就是新建一个叶子节点,然后把原先数据页中的一部分记录复制到这个新的数据页中,然后再把记录插入进去,把这个叶子节点插入到叶子节点链表中,最后还要在内节点中添加一条 目录项记录 指向这个新创建的页面。很显然,这个过程要对多个页面进行修改,也就意味着会产生多条 redo 日志,我们把这种情况称之为 悲观插入 。假如某个索引对应的 B+ 树长这样:
现在我们要插入一条键值为 10 的记录,很显然需要被插入到 页b 中,但是从图中也可以看出来,此时 页b已经塞满了记录,没有更多的空闲空间来容纳这条新记录了,所以我们需要进行页面的分裂操作,就像这样
如果作为内节点的 页a 的剩余空闲空间也不足以容纳增加一条 目录项记录 ,那需要继续做内节点 页a 的分裂操作,也就意味着会修改更多的页面,从而产生更多的 redo 日志。另外,对于 悲观插入 来说,由于需要新申请数据页,还需要改动一些系统页面,比方说要修改各种段、区的统计信息信息,各种链表的统计信息等等,反正总共需要记录的 redo 日志有二、三十条。
设计 InnoDB 的大叔们认为向某个索引对应的 B+ 树中插入一条记录的这个过程必须是原子的,不能说插了一半之后就停止了。
比方说在悲观插入过程中,新的页面已经分配好了,数据也复制过去了,新的记录也插入到页面中了,可是没有向内节点中插入一条 目录项记录 ,这个插入过程就是不完整的,这样会形成一棵不正确的 B+ 树。我们知道 redo 日志是为了在系统奔溃重启时恢复崩溃前的状态,如果在悲观插入的过程中只记录了一部分 redo日志,那么在系统奔溃重启时会将索引对应的 B+ 树恢复成一种不正确的状态,这是设计 InnoDB 的大叔们所不能忍受的。所以他们规定在执行这些需要保证原子性的操作时必须以 组 的形式来记录的 redo 日志,在进行系统奔溃重启恢复时,针对某个组中的 redo 日志,要么把全部的日志都恢复掉,要么一条也不恢复。怎么做到的呢?这得分情况讨论:
有的需要保证原子性的操作会生成多条 redo 日志,比如向某个索引对应的 B+ 树中进行一次悲观插入就需要生成许多条 redo 日志。
如何把这些 redo 日志划分到一个组里边儿呢?设计 InnoDB 的大叔做了一个很简单的小把戏,就是在该组中的最后一条 redo 日志后边加上一条特殊类型的 redo 日志,该类型名称为 MLOG_MULTI_REC_END
, type 字段对应的十进制数字为 31 ,该类型的 redo 日志结构很简单,只有一个 type 字段:
这样在系统奔溃重启进行恢复时,只有当解析到类型为MLOG_MULTI_REC_END
的 redo 日志,才认为解析到了一组完整的 redo 日志,才会进行恢复。否则的话直接放弃前边解析到的 redo 日志。
有的需要保证原子性的操作只生成一条 redo 日志,比如更新 Max Row ID 属性的操作就只会生成一条 redo日志。
其实在一条日志后边跟一个类型为 MLOG_MULTI_REC_END 的 redo 日志也是可以的,不过设计 InnoDB 的大叔比较勤俭节约,他们不想浪费一个比特位。别忘了虽然 redo 日志的类型比较多,但撑死了也就是几十种,是小于 127 这个数字的,也就是说我们用7个比特位就足以包括所有的 redo 日志类型,而 type 字段其实是占用1个字节的,也就是说我们可以省出来一个比特位用来表示该需要保证原子性的操作只产生单一的一条redo 日志,示意图如下:
如果 type 字段的第一个比特位为 1 ,代表该需要保证原子性的操作只产生了单一的一条 redo 日志,否则表示该需要保证原子性的操作产生了一系列的 redo 日志。
设计 MySQL 的大叔把对底层页面中的一次原子访问的过程称之为一个 MiniTransaction
,简称 mtr
,比如上边所说的修改一次 Max Row ID 的值算是一个 Mini-Transaction
,向某个索引对应的 B+ 树中插入一条记录的过程也算是一个 Mini-Transaction
。通过上边的叙述我们也知道,一个所谓的 mtr 可以包含一组redo 日志,在进行奔溃恢复时这一组 redo 日志作为一个不可分割的整体。
一个事务可以包含若干条语句,每一条语句其实是由若干个 mtr 组成,每一个 mtr 又可以包含若干条 redo 日志,画个图表示它们的关系就是这样:
设计 InnoDB 的大叔为了更好的进行系统奔溃恢复,他们把通过 mtr 生成的 redo日志都放在了大小为 512字节的 页 中。为了和我们前边提到的表空间中的页做区别,我们这里把用来存储 redo 日志的页称为 block (你心里清楚页和block的意思其实差不多就行了)。一个 redo log block 的示意图如下:
真正的 redo 日志都是存储到占用 496 字节大小的 log block body
中,图中的 log block header
和 log blocktrailer
存储的是一些管理信息。我们来看看这些所谓的 管理信息 都是啥:
我们前边说过,设计 InnoDB 的大叔为了解决磁盘速度过慢的问题而引入了Buffer Pool
。同理,写入 redo 日志时也不能直接直接写到磁盘上,实际上在服务器启动时就向操作系统申请了一大片称之为 redo log buffer
的连续内存空间,翻译成中文就是 redo日志缓冲区 ,我们也可以简称为 log buffer 。这片内存空间被划分成若干个连续的redo log block
,就像这样:
向log buffer
中写入 redo 日志的过程是顺序的,也就是先往前边的block中写,当该block的空闲空间用完之后再往下一个block中写。当我们想往 log buffer 中写入 redo 日志时,第一个遇到的问题就是应该写在哪个block 的哪个偏移量处,所以设计 InnoDB 的大叔特意提供了一个称之为buf_free
的全局变量,该变量指明后续写入的 redo 日志应该写入到 log buffer 中的哪个位置,如图所示:
我们前边说过一个 mtr 执行过程中可能产生若干条 redo 日志,这些 redo 日志是一个不可分割的组,所以其实并不是每生成一条 redo 日志,就将其插入到 log buffer 中,而是每个 mtr 运行过程中产生的日志先暂时存到一个地方,当该 mtr 结束的时候,将过程中产生的一组 redo 日志再全部复制到 log buffer 中。我们现在假设有两个名为 T1 、 T2 的事务,每个事务都包含2个 mtr ,我们给这几个 mtr 命名一下:
每个 mtr 都会产生一组 redo 日志,用示意图来描述一下这些 mtr 产生的日志情况:
不同的事务可能是并发执行的,所以 T1 、 T2 之间的 mtr 可能是交替执行的。每当一个 mtr 执行完成时,伴随该 mtr 生成的一组 redo 日志就需要被复制到 log buffer 中,也就是说不同事务的 mtr 可能是交替写入 logbuffer 的。
从示意图中我们可以看出来,不同的 mtr 产生的一组 redo 日志占用的存储空间可能不一样,有的 mtr 产生的redo 日志量很少,比如 mtr_t1_1 、 mtr_t2_1 就被放到同一个block中存储,有的 mtr 产生的 redo 日志量非常大,比如 mtr_t1_2 产生的 redo 日志甚至占用了3个block来存储。
我们前边说 mtr 运行过程中产生的一组 redo 日志在 mtr 结束时会被复制到log buffer
中,可是这些日志总在内存里呆着也不是个办法,在一些情况下它们会被刷新到磁盘里,比如:
log buffer 空间不足时
log buffer
的大小是有限的(通过系统变量 innodb_log_buffer_size
指定),如果不停的往这个有限大小的 log buffer
里塞入日志,很快它就会被填满。设计 InnoDB 的大叔认为如果当前写入 log buffer 的redo
日志量已经占满了 log buffer 总容量的大约一半左右,就需要把这些日志刷新到磁盘上。
事务提交时
我们前边说过之所以使用 redo 日志主要是因为它占用的空间少,还是顺序写,在事务提交时可以不把修改过的 Buffer Pool
页面刷新到磁盘,但是为了保证持久性,必须要把修改这些页面对应的 redo 日志刷新到磁盘。
后台线程不停的刷
后台有一个线程,大约每秒都会刷新一次 log buffer 中的 redo 日志到磁盘。
正常关闭服务器时
做所谓的 checkpoint 时
自系统开始运行,就不断的在修改页面,也就意味着会不断的生成 redo 日志。 redo 日志的量在不断的递增,就像人的年龄一样,自打出生起就不断递增,永远不可能缩减了。设计 InnoDB 的大叔为记录已经写入的 redo 日志量,设计了一个称之为 Log Sequeue Number
的全局变量,翻译过来就是: 日志序列号 ,简称 lsn 。不过不像人一出生的年龄是 0 岁,设计 InnoDB 的大叔规定初始的 lsn 值为 8704 (也就是一条 redo 日志也没写入时,lsn 的值为 8704 )。
我们知道在向log buffer
中写入 redo 日志时不是一条一条写入的,而是以一个 mtr 生成的一组redo 日志为单位进行写入的。而且实际上是把日志内容写在了log block body
处。但是在统计lsn 的增长量时,是按照实际写入的日志量加上占用的 log block header
和 log block trailer
来计算的。我们来看一个例子:
系统第一次启动后初始化 log buffer
时, buf_free
(就是标记下一条 redo 日志应该写入到 log buffer的位置的变量)就会指向第一个 block 的偏移量为12字节( log block header 的大小)的地方,那么 lsn值也会跟着增加12:
如果某个 mtr 产生的一组 redo 日志占用的存储空间比较小,也就是待插入的block剩余空闲空间能容纳这个 mtr 提交的日志时, lsn 增长的量就是该 mtr 生成的 redo 日志占用的字节数,就像这样:
我们假设上图中 mtr_1 产生的 redo 日志量为200字节,那么 lsn 就要在 8716 的基础上增加 200 ,变为8916 。
如果某个 mtr 产生的一组 redo 日志占用的存储空间比较大,也就是待插入的block剩余空闲空间不足以容纳这个 mtr 提交的日志时, lsn 增长的量就是该 mtr 生成的 redo 日志占用的字节数加上额外占用的logblock header
和 log block trailer
的字节数,就像这样:
我们假设上图中 mtr_2 产生的 redo 日志量为1000字节,为了将 mtr_2 产生的 redo 日志写入 logbuffer
,我们不得不额外多分配两个block,所以 lsn 的值需要在 8916 的基础上增加 1000 + 12×2 + 4 × 2 = 1032 。
从上边的描述中可以看出来,每一组由mtr生成的redo日志都有一个唯一的LSN
值与其对应,LSN
值越小,说明redo日志
产生的越早。
redo 日志是首先写到 log buffer
中,之后才会被刷新到磁盘上的 redo 日志文件。所以设计 InnoDB 的大叔提出了一个称之为 buf_next_to_write
的全局变量,标记当前 log buffer 中已经有哪些日志被刷新到磁盘中了。画个图表示就是这样:
我们前边说 lsn
是表示当前系统中写入的 redo 日志量,这包括了写到 log buffer 而没有刷新到磁盘的日志,相应的,设计 InnoDB 的大叔提出了一个表示刷新到磁盘中的 redo 日志量的全局变量,称之为flushed_to_disk_lsn
。系统第一次启动时,该变量的值和初始的 lsn 值是相同的,都是 8704 。随着系统的运行, redo 日志被不断写入 log buffer
,但是并不会立即刷新到磁盘, lsn
的值就和 flushed_to_disk_lsn
的值拉开了差距。我们演示一下:
系统第一次启动后,此时的 lsn 已经增长到了10000,但是由于没有刷新操作,所以此时 flushed_to_disk_lsn 的值仍为8704 ,如图:
随后进行将 log buffer 中的block刷新到 redo 日志文件的操作,假设将 mtr_1 和 mtr_2 的日志刷新到磁盘,那么 flushed_to_disk_lsn 就应该增长 mtr_1 和 mtr_2 写入的日志量,所以 flushed_to_disk_lsn 的值增长到了 9948 ,如图:
综上所述,当有新的 redo 日志写入到 log buffer
时,首先lsn
的值会增长,但 flushed_to_disk_lsn
不变,随后随着不断有 log buffer 中的日志被刷新到磁盘上, flushed_to_disk_lsn
的值也跟着增长。如果两者的值相同时,说明log buffer中的所有redo日志都已经刷新到磁盘中了。
我们知道一个 mtr 代表一次对底层页面的原子访问,在访问过程中可能会产生一组不可分割的 redo 日志,在mtr 结束时,会把这一组 redo 日志写入到 log buffer 中。除此之外,在 mtr 结束时还有一件非常重要的事情要做,就是把在mtr执行过程中可能修改过的页面加入到Buffer Pool的flush链表。为了防止大家早已忘记 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_m ,把 newest_modification
缩写成了 n_m ):
接着假设 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
属性中。画个图表示一下:
从图中可以看出来,每次新插入到 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
属性中。另外,由于 页b 在 mtr_3 执行过程中又发生了一次修改,所以需要更新 页b 对应的控制块中 newest_modification
的值为10000。画个图表示一下:
总结一下上边说的,就是:flush链表中的脏页按照修改发生的时间顺序进行排序,也就是按照oldest_modification
代表的LSN值进行排序,被多次更新的页面不会重复插入到flush链表中,但是会更新newest_modification
属性的值。
有一个很不幸的事实就是我们的 redo 日志文件组容量是有限的,我们不得不选择循环使用 redo 日志文件组中的文件,但是这会造成最后写的 redo 日志与最开始写的 redo 日志 追尾 ,这时应该想到:**redo日志只是为了系统奔溃后恢复脏页用的,如果对应的脏页已经刷新到了磁盘,也就是说即使现在系统奔溃,那么在重启后也用不着使用redo日志恢复该页面了,所以该redo日志也就没有存在的必要了,那么它占用的磁盘空间就可以被后续的redo日志所重用。**也就是说:**判断某些redo日志占用的磁盘空间是否可以覆盖的依据就是它对应的脏页是否已经刷新到磁盘里。**我们看一下前边一直唠叨的那个例子:
如图,虽然 mtr_1 和 mtr_2 生成的 redo 日志都已经被写到了磁盘上,但是它们修改的脏页仍然留在 BufferPool 中,所以它们生成的 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 )中。
我们说过,每一个 redo 日志文件都有 2048 个字节的管理信息,但是上述关于checkpoint的信息只会被写到日志文件组的第一个日志文件的管理信息中。不过我们是存储到 checkpoint1 中还是 checkpoint2 中呢?设计 InnoDB 的大叔规定,当 checkpoint_no 的值是偶数时,就写到 checkpoint1 中,是奇数时,就写到checkpoint2 中。
记录完 checkpoint 的信息之后, redo 日志文件组中各个 lsn 值的关系就像这样:
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。