《MySQL是怎样运行的 —— 从跟上理解MySQL》—— 第十九章
一、redo log简介
InnoDB
存储引擎是以页为单位来管理存储空间的,在真正访问页面之前,需要把在磁盘上的页缓存到内存中的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
日志刷新到磁盘的好处如下:
redo log
占用的空间非常小redo log
是顺序写入磁盘的:在执行事务的过程中,每执行一条语句,就可能产生若干条redo log
,这些日志是按照产生的顺序写入磁盘的,也就是使用顺序IO。
二、redo log 格式
redo log
本质上只是记录了一下事务对数据库做了哪些修改。
type
:该条redo
日志的类型。space ID
:表空间ID。page number
:页号。data
:该条redo
日志的具体内容。
具体的格式详情见书
明确一点:redo log 会把事务在执行过程中对数据库所做的所有修改都记录下来,在之后系统奔溃重启后可以把事务所做的任何修改都恢复出来。
三、Mini-Transaction
3.1 以组的形式写入 redo log
语句在执行过程中可能修改若干个页面,由于对这些页面的更改都发生在Buffer Pool
中,所以在修改完页面之后,需要记录一下相应的redo
日志。在执行语句的过程中产生的redo
日志被InnoDB
划分成了若干个不可分割的组。
对不可分割的理解,以向某个索引对应的B+
树插入一条记录为例,在向B+
树中插入这条记录之前,需要先定位到这条记录应该被插入到哪个叶子节点代表的数据页中,定位到具体的数据页之后,有两种可能的情况:
情况一:该数据页的剩余的空闲空间充足,足够容纳这一条待插入记录,那么事情很简单,直接把记录插入到这个数据页中,记录一条类型为
MLOG_COMP_REC_INSERT
的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
日志,InnoDB
在该组中的最后一条redo
日志后边加上一条特殊类型的redo
日志,该类型名称为MLOG_MULTI_REC_END
,type
字段对应的十进制数字为31
。所以某个需要保证原子性的操作产生的一系列redo
日志必须要以一个类型为MLOG_MULTI_REC_END
结尾。这样在系统奔溃重启进行恢复时,只有当解析到类型为MLOG_MULTI_REC_END
的redo
日志,才认为解析到了一组完整的redo
日志,才会进行恢复。否则的话直接放弃前面解析到的redo
日志。 - 有的需要保证原子性的操作只生成一条
redo
日志。
3.2 Mini-Transaction概念
MySQL
把对底层页面中的一次原子访问的过程称之为一个Mini-Transaction(MTR)
,比如修改一次Max Row ID
的值算是一个Mini-Transaction
,向某个索引对应的B+
树中插入一条记录的过程也算是一个Mini-Transaction
。
一个所谓的MTR
可以包含一组redo
日志,在进行奔溃恢复时这一组redo
日志作为一个不可分割的整体。一个事务可以包含若干条语句,每一条语句其实是由若干个MTR
组成,每一个MTR
又可以包含若干条redo
日志。
四、redo log 写入过程
4.1 redo log block
InnoDB
为了更好的进行系统奔溃恢复,将通过MTR
生成的redo
日志都放在了大小为512字节
的页
中。把用来存储redo
日志的页称为block
。
真正的redo
日志都是存储到占用496
字节大小的log block body
中,图中的log block header
和log block trailer
存储的是一些管理信息。
log block header
的几个属性:
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
日志记录(redo log record
),一个MTR
会生产多条redo
日志记录,这些redo
日志记录被称之为一个redo
日志记录组(redo log record group
)。LOG_BLOCK_FIRST_REC_GROUP
就代表该block中第一个MTR
生成的redo
日志记录组的偏移量(即这个block里第一个MTR
生成的第一条redo
日志的偏移量)。LOG_BLOCK_CHECKPOINT_NO
:表示checkpoint
的序号
log block trailer
中属性:
LOG_BLOCK_CHECKSUM
:表示block的校验值,用于正确性校验
LOG_BLOCK_HDR_NO是如何计算的?
对于实际存储
redo
日志的普通的log block
来说,在log block header
处有一个称之为LOG_BLOCK_HDR_NO
的属性,这个属性代表一个唯一的标号。这个属性是初次使用该block时分配的,跟当时的系统lsn
值有关。使用下面的公式计算该block的LOG_BLOCK_HDR_NO
值:
((lsn / 512) & 0x3FFFFFFFUL) + 1
0x3FFFFFFFUL
对应的二进制数的前2位为0,后30位的值都为1
。让一个数和0x3FFFFFFFUL
做与运算的意思就是要将该值的前2个比特位的值置为0,这样该值就肯定小于或等于0x3FFFFFFFUL
了。这也就说明了,不论lsn多大,((lsn / 512) & 0x3FFFFFFFUL)
的值肯定在0
~0x3FFFFFFFUL
之间,再加1的话肯定在1
~0x40000000UL
之间。而0x40000000UL
这个值就代表着1GB
。也就是说系统最多能产生不重复的LOG_BLOCK_HDR_NO
值只有1GB
个。InnoDB规定redo
日志文件组中包含的所有文件大小总和不得超过512GB,一个block大小是512字节,也就是说redo日志文件组中包含的block块最多为1GB个,所以有1GB个不重复的编号值也就够用了。另外,
LOG_BLOCK_HDR_NO
值的第一个比特位比较特殊,称之为flush bit
,如果该值为1,代表着本block是在某次将log buffer
中的block刷新到磁盘的操作中的第一个被刷入的block。
4.2 redo log 缓冲区
在服务器启动时就向操作系统申请了一大片称之为redo log buffer
的连续内存空间(类似于
buffer pool
),这片内存空间被划分成若干个连续的redo log block
,就像这样:
可以通过启动参数innodb_log_buffer_size
来指定log buffer
的大小,在MySQL 5.7.21
这个版本中,该启动参数的默认值为16MB
。
4.3 redo log 写入 log buffer
向log buffer
中写入redo
日志的过程是顺序的。当往log buffer
中写入redo
日志时,第一个遇到的问题就是应该写在哪个block
的哪个偏移量处,所以InnoDB
提供了一个称之为buf_free
的全局变量,该变量指明后续写入的redo
日志应该写入到log buffer
中的哪个位置。
一个MTR
执行过程中可能产生若干条redo
日志,这些redo
日志是一个不可分割的组,所以其实并不是每生成一条redo
日志,就将其插入到log buffer
中,而是每个MTR
运行过程中产生的日志先暂时存到一个地方,当该MTR
结束的时候,将过程中产生的一组redo
日志再全部复制到log buffer
中。
五、redo log 文件
5.1 redo log 刷盘时机
在一些情况下,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
时
5.2 redo log 文件组
MySQL
的数据目录(使用SHOW VARIABLES LIKE 'datadir'
查看)下默认有两个名为ib_logfile0
和ib_logfile1
的文件,log buffer
中的日志默认情况下就是刷新到这两个磁盘文件中。可以通过下面几个启动参数来调节默认的redo
日志文件:
innodb_log_group_home_dir
:该参数指定了redo
日志文件所在的目录,默认值就是当前的数据目录。innodb_log_file_size
:该参数指定了每个redo
日志文件的大小,在MySQL 5.7.21
这个版本中的默认值为48MB
。innodb_log_files_in_group
:该参数指定redo
日志文件的个数,默认值为2,最大值为100。
磁盘上的redo
日志文件不只一个,而是以一个日志文件组
的形式出现的。这些文件以ib_logfile[数字]
的形式进行命名。在将redo
日志写入日志文件组
时,是从ib_logfile0
开始写,如果ib_logfile0
写满了,就接着ib_logfile1
写,同理,ib_logfile1
写满了就去写ib_logfile2
,依此类推。
总共的redo
日志文件大小innodb_log_file_size × innodb_log_files_in_group
。
5.3 redo log 文件格式
log buffer
本质上是一片连续的内存空间,被划分成了若干个512
字节大小的block
。将log
buffer中的redo日志刷新到磁盘的本质就是把block的镜像写入日志文件中,所以redo
日志文件其实也是由若干个512
字节大小的block组成。
redo
日志文件组中的每个文件大小都一样,格式也一样,都是由两部分组成:
- 前2048个字节,也就是前4个block是用来存储一些管理信息的。
- 从第2048字节往后是用来存储
log buffer
中的block镜像的。
循环
使用redo日志文件,其实是从每个日志文件的第2048个字节开始算:
问题:每个redo
日志文件前2048个字节,也就是前4个特殊block的格式都是干嘛的?
log file header
:描述该redo
日志文件的一些整体属性属性名 长度(单位:字节) 描述 LOG_HEADER_FORMAT
4
redo
日志的版本,在MySQL 5.7.21
中该值永远为1LOG_HEADER_PAD1
4
做字节填充用的,没什么实际意义, LOG_HEADER_START_LSN
8
标记本 redo
日志文件开始的LSN值,也就是文件偏移量为2048字节初对应的LSN值。LOG_HEADER_CREATOR
32
一个字符串,标记本 redo
日志文件的创建者是谁。正常运行时该值为MySQL
的版本号,比如:"MySQL 5.7.21"
,使用mysqlbackup
命令创建的redo
日志文件的该值为"ibbackup"
和创建时间。LOG_BLOCK_CHECKSUM
4
本block的校验值,所有block都有 checkpoint1
:记录关于checkpoint
的一些属性:属性名 长度(单位:字节) 描述 LOG_CHECKPOINT_NO
8
服务器做 checkpoint
的编号,每做一次checkpoint
,该值就加1。LOG_CHECKPOINT_LSN
8
服务器做 checkpoint
结束时对应的LSN
值,系统奔溃恢复时将从该值开始。LOG_CHECKPOINT_OFFSET
8
上个属性中的 LSN
值在redo
日志文件组中的偏移量LOG_CHECKPOINT_LOG_BUF_SIZE
8
服务器在做 checkpoint
操作时对应的log buffer
的大小LOG_BLOCK_CHECKSUM
4
本block的校验值,所有block都有 checkpoint2
:结构和checkpoint1
一样。
六、Log Sequeue Number
自系统开始运行,就不断的在修改页面,也就意味着会不断的生成redo
日志。redo
日志的量在不断的递增。InnoDB
为记录已经写入的redo
日志量,设计了一个称之为Log Sequeue Number
的全局变量。InnoDB
规定初始的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
产生的一组redo
日志占用的存储空间比较大,也就是待插入的block剩余空闲空间不足以容纳这个mtr
提交的日志时,lsn
增长的量就是该mtr
生成的redo
日志占用的字节数加上额外占用的log block header
和log block trailer
的字节数
每一组由mtr生成的redo日志都有一个唯一的LSN值与其对应,LSN值越小,说明redo日志产生的越早。
6.1 flushed_to_disk_lsn
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
的值拉开了差距。
当有新的redo
日志写入到log buffer
时,首先lsn
的值会增长,但flushed_to_disk_lsn
不变,随后随着不断有log buffer
中的日志被刷新到磁盘上,flushed_to_disk_lsn
的值也跟着增长。如果两者的值相同时,说明log
buffer中的所有redo日志都已经刷新到磁盘中了。
6.2 lsn值和redo log偏移量的对应关系
lsn
的值是代表系统写入的redo
日志量的一个总和,一个mtr
中产生多少日志,lsn
的值就增加多少,这样mtr
产生的日志写到磁盘中时,很容易计算某一个lsn
值在redo
日志文件组中的偏移量。
6.3 flush链表中的lsn
一个MTR
代表一次对底层页面的原子访问,在访问过程中可能会产生一组不可分割的redo
日志,在MTR
结束时,会把这一组redo
日志写入到log buffer
中。除此之外,在MTR
结束时还有一件非常重要的事情要做,就是把在MTR执行过程中可能修改过的页面加入到Buffer
Pool的flush链表。
当第一次修改某个缓存在Buffer Pool
中的页面时,就会把这个页面对应的控制块插入到flush链表
的头部,之后再修改该页面时由于它已经在flush
链表中了,就不再次插入了。也就是说flush链表中的脏页是按照页面的第一次修改时间从大到小进行排序的。在这个过程中会在缓存页对应的控制块中记录两个关于页面何时修改的属性:
oldest_modification
:如果某个页面被加载到Buffer Pool
后进行第一次修改,那么就将修改该页面的mtr
开始时对应的lsn
值写入这个属性。newest_modification
:每修改一次页面,都会将修改该页面的mtr
结束时对应的lsn
值写入这个属性。也就是说该属性表示页面最近一次修改后对应的系统lsn
值。
【栗子🌰】
假设
mtr_1
执行过程中修改了页a
,那么在mtr_1
执行结束时,就会将页a
对应的控制块加入到flush链表
的头部。并且将mtr_1
开始时对应的lsn
,也就是8716
写入页a
对应的控制块的oldest_modification
属性中,把mtr_1
结束时对应的lsn
,也就是8916写入页a
对应的控制块的newest_modification
属性中。接着假设
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
属性中。接着假设
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属性的值。
八、checkpoint
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
。 - 将
checkpoint_lsn
和对应的redo
日志文件组偏移量以及此次checkpint
的编号写到日志文件的管理信息(就是checkpoint1
或者checkpoint2
)中。InnoDB
维护了一个目前系统做了多少次checkpoint
的变量checkpoint_no
,每做一次checkpoint
,该变量的值就加1。
每一个redo
日志文件都有2048
个字节的管理信息,但是上述关于checkpoint的信息只会被写到日志文件组的第一个日志文件的管理信息中。不过是存储到checkpoint1
中还是checkpoint2
中呢?InnoDB
规定当checkpoint_no
的值是偶数时,就写到checkpoint1
中,是奇数时,就写到checkpoint2
中。
记录完checkpoint
的信息之后,redo
日志文件组中各个lsn
值的关系就像这样:
一般情况下都是后台的线程在对
LRU链表
和flush链表
进行刷脏操作,这主要因为刷脏操作比较慢,不想影响用户线程处理请求。但是如果当前系统修改页面的操作十分频繁,这样就导致写日志操作十分频繁,系统lsn
值增长过快。如果后台的刷脏操作不能将脏页刷出,那么系统无法及时做checkpoint
,可能就需要用户线程同步的从flush链表
中把那些最早修改的脏页(oldest_modification
最小的脏页)刷新到磁盘,这样这些脏页对应的redo
日志就没用了,然后就可以去做checkpoint
了。可以使用
SHOW ENGINE INNODB STATUS
命令查看当前InnoDB
存储引擎中的各种LSN
值的情况。
九、innodb_flush_log_at_trx_commit
为了保证事务的持久性
,用户线程在事务提交时需要将该事务执行过程中产生的所有redo
日志都刷新到磁盘上。这一条要求会很明显的降低数据库性能。如果对事务的持久性
要求不是那么强烈的话,可以选择修改一个称为innodb_flush_log_at_trx_commit
的系统变量的值,该变量有3个可选的值:
0
:当该系统变量值为0时,表示在事务提交时不立即向磁盘中同步redo
日志,这个任务是交给后台线程做的。这样很明显会加快请求处理速度,但是如果事务提交后服务器挂了,后台线程没有及时将redo
日志刷新到磁盘,那么该事务对页面的修改会丢失。1
:当该系统变量值为1时,表示在事务提交时需要将redo
日志同步到磁盘,可以保证事务的持久性
。1
也是innodb_flush_log_at_trx_commit
的默认值。2
:当该系统变量值为2时,表示在事务提交时需要将redo
日志写到操作系统的缓冲区中,但并不需要保证将日志真正的刷新到磁盘。这种情况下如果数据库挂了,操作系统没挂的话,事务的持久性
还是可以保证的,但是操作系统也挂了的话,那就不能保证持久性
了。
十、崩溃恢复
10.1 确定恢复的起点
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
。
10.2 确定恢复的终点
普通block的log block header
部分有一个称之为LOG_BLOCK_HDR_DATA_LEN
的属性,该属性值记录了当前block里使用了多少字节的空间。对于被填满的block来说,该值永远为512
。如果该属性的值不为512
,那么就是它了,它就是此次奔溃恢复中需要扫描的最后一个block。
10.3 怎么恢复
假设现在的redo
日志文件中有5条redo
日志,如图:
由于redo 0
在checkpoint_lsn
后边,恢复时可以不管它。现在可以按照redo
日志的顺序依次扫描checkpoint_lsn
之后的各条redo日志,按照日志中记载的内容将对应的页面恢复出来。
InnoDB
想了一些办法加快这个恢复的过程:
使用哈希表:根据
redo
日志的space ID
和page number
属性计算出散列值,把space ID
和page number
相同的redo
日志放到哈希表的同一个槽里,如果有多个space ID
和page number
都相同的redo
日志,那么它们之间使用链表连接起来,按照生成的先后顺序链接起来的:之后就可以遍历哈希表,因为对同一个页面进行修改的
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日志了,所以更进一步提升了奔溃恢复的速度。
本文通篇在强调如何让已经提交的事务保持持久性。但是,如果在一个事务执行了一半的时候服务器突然崩溃,假如这个事务执行过程中所写的 redo日志尚未刷新到磁盘,也就是还停留在 log buffer 中,那么服务器崩也就崩了吧,相当于该事务啥也没做。但是,如果这些 redo 日志都已经刷新到了磁盘中,那么在下次开机重启时会根据这些 redo 日志把页面恢复过来,可是这就造成一个事务处于只执行了一半的状态。这不就违背了原子性了嘛?其实,这些只执行了了一半的事务对页面所做的修改都会被撤销,这就是 undo 日志所发挥出的神奇功效。