likes
comments
collection
share

MySQL8事务篇2-MySQL事务日志redo log

作者站长头像
站长
· 阅读数 10

事务有4种特性:原子性、一致性、隔离性和持久性。那么事务的四种特性到底是基于什么机制实现呢?

  • 事务的隔离性由 锁机制 实现。

  • 而事务的原子性、一致性和持久性由事务的 redo 日志和undo 日志来保证。

    • REDO LOG 称为 重做日志 ,提供再写入操作,恢复提交事务修改的页操作,用来保证事务的持久性
    • UNDO LOG 称为 回滚日志 ,回滚行记录到某个特定版本,用来保证事务的原子性、一致性。 有的DBA或许会认为 UNDOREDO 的逆过程,其实不然。REDOUNDO都可以视为是一种恢复操作,但是:
  • redo log:是存储引擎层(innodb)生成的日志,记录的是"物理级别"上的页修改操作,比如页号xxx、偏移量yyy,写入了zzz数据。主要为了保证数据的可靠性;

  • undo log:是存储引擎层(innodb)生成的日志,记录的是逻辑操作日志,比如对某一行数据进行了INSERT语句操作,那么undo log就记录一条与之相反的DELETE操作。主要用于事务的回滚(undo log记录的是每个修改操作的逆操作)和一致性非锁定读(undo log回滚行记录到某种特定的版本-MVCC,即多版本并发控制)。

1. 为什么需要REDO日志

InnoDB 存储引擎是以页为单位来管理存储空间的。在真正访问页面之前,需要把在磁盘上的页缓存到内存中的Buffer Pool之后才可以访问。所有的变更都必须先更新缓冲池中的数据,然后缓冲池中的脏页会以一定的频率被刷入磁盘(checkPoint机制),通过缓冲池来优化CPU和磁盘之间的鸿沟,这样就可以保证整体的性能不会下降太快。

通过REDOLOG保证了mysql的持久性

  • 一方面,缓冲池可以帮助我们消除CPU和磁盘之间的鸿沟,checkpoint机制可以保证数据的最终落盘,然而由于checkpoint 并不是每次变更的时候就触发 的,而是master线程隔一段时间去处理的。所以最坏的情况就是事务提交后,刚写完缓冲池,数据库宕机了,那么这段数据就是丢失的,无法恢复。

  • 另一方面,事务包含 持久性 的特性,就是说对于一个已经提交的事务,在事务提交后即使系统发生了崩溃,这个事务对数据库中所做的更改也不能丢失。

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

  • 修改量与刷新磁盘工作量严重不成比例

    有时候我们仅仅修改了某个页面中的一个字节,但是我们知道在InnoDB中是以页为单位来进行磁盘IO的,也就是说我们在该事务提交时不得不将一个完整的页面从内存中刷新到磁盘,我们又知道一个页面默认是16KB大小,只修改一个字节就要刷新16KB的数据到磁盘上显然是太小题大做

  • 随机1O刷新较慢

    一个事务可能包含很多语句,即使是一条语句也可能修改许多页面,假如该事务修改的这些页面可能并不相邻,这就意味着在将某个事务修改的Buffer Pool中的页面刷新到磁盘时,需要进行很多的随机IO,随机IO比顺序IO要慢,尤其对于传统的机械硬盘来说。

另一个解决的思路 :我们只是想让已经提交了的事务对数据库中数据所做的修改永久生效,即使后来系统崩溃,在重启后也能把这种修改恢复出来。所以我们其实没有必要在每次事务提交时就把该事务在内存中修改过的全部页面刷新到磁盘,只需要把 修改 了哪些东西 记录一下 就好。比如,某个事务将系统表空间中 第10号 页面中偏移量为100处的那个字节的值 1 改成 2 。我们只需要记录一下:将第0号表空间的10号页面的偏移量为100处的值更新为 2 。

InnoDB引擎的事务采用了WAL技术(Write-Ahead Logging),这种技术的思想就是先写日志,再写磁盘,只有日志写入成功,才算事务提交成功,这里的日志就是redo log。当发生宕机且数据未刷到磁盘的时候,可以通过redo log来恢复,保证ACID中的D,这就是redo log的作用MySQL8事务篇2-MySQL事务日志redo log

2. REDO日志的好处、特点

  • 好处
    • redo日志降低了刷盘频率
    • redo日志占用的空间非常小 存储表空间D、页号、偏移量以及需要更新的值,所需的存储空间是很小的,刷盘快。
  • 特点
    • redo日志是顺序写入磁盘的 在执行事务的过程中,每执行一条语句,就可能产生若干条rdo日志,这些日志是按照产生的顺序写入磁盘的,也就是使用顺序IO,效率比随机IO快。
    • 事务执行过程中,redo log不断记录

redo logbin log的区别,redo log存储引擎层产生的,而bin log是数据库层产生的。假设一个事务,对表做10万行的记录插入,在这个过程中,一直不断的往redo log顺序记录,而bin log 不会记录,直到这个事务提交,才会一次写入到bin log文件中。

3. redo 日志格式

通过上面的内容我们知道,redo日志本质上只是记录了一下事务对数据库做了哪些修改。 设计InnoDB的大佬们针对事务对数据库的不同修改场景定义了多种类型的redo日志,但是绝大部分类型的redo日志都有下面这种通用的结构:

MySQL8事务篇2-MySQL事务日志redo log

  各个部分的详细释义如下:

  • type:该条redo日志的类型。

      在MySQL 5.7.21这个版本中,设计InnoDB的大佬一共为redo日志设计了53种不同的类型,稍后会详细介绍不同类型的redo日志。

  • space ID:表空间ID。

  • page number:页号。

  • data:该条redo日志的具体内容。

3.1 简单的redo日志类型

  我们前面介绍InnoDB的记录行格式的时候说过,如果我们没有为某个表显式的定义主键,并且表中也没有定义Unique键,那么InnoDB会自动的为表添加一个称之为row_id的隐藏列作为主键。为这个row_id隐藏列赋值的方式如下:

  • 服务器会在内存中维护一个全局变量,每当向某个包含隐藏的row_id列的表中插入一条记录时,就会把该变量的值当作新记录的row_id列的值,并且把该变量自增1。
  • 每当这个变量的值为256的倍数时,就会将该变量的值刷新到系统表空间的页号为7的页面中一个称之为Max Row ID的属性处。
  • 当系统启动时,会将上面提到的Max Row ID属性加载到内存中,将该值加上256之后赋值给我们前面提到的全局变量(因为在上次关机时该全局变量的值可能大于Max Row ID属性值)。

  这个Max Row ID属性占用的存储空间是8个字节,当某个事务向某个包含row_id隐藏列的表插入一条记录,并且为该记录分配的row_id值为256的倍数时,就会向系统表空间页号为7的页面的相应偏移量处写入8个字节的值。但是我们要知道,这个写入实际上是在Buffer Pool中完成的,我们需要为这个页面的修改记录一条redo日志,以便在系统奔溃后能将已经提交的该事务对该页面所做的修改恢复出来。这种情况下对页面的修改是极其简单的,redo日志中只需要记录一下在某个页面的某个偏移量处修改了几个字节的值,具体被修改的内容是什么就好了,设计InnoDB的大佬把这种极其简单的redo日志称之为物理日志,并且根据在页面中写入数据的多少划分了几种不同的redo日志类型:

  • MLOG_1BYTEtype字段对应的十进制数字为1):表示在页面的某个偏移量处写入1个字节的redo日志类型。
  • MLOG_2BYTEtype字段对应的十进制数字为2):表示在页面的某个偏移量处写入2个字节的redo日志类型。
  • MLOG_4BYTEtype字段对应的十进制数字为4):表示在页面的某个偏移量处写入4个字节的redo日志类型。
  • MLOG_8BYTEtype字段对应的十进制数字为8):表示在页面的某个偏移量处写入8个字节的redo日志类型。
  • MLOG_WRITE_STRINGtype字段对应的十进制数字为30):表示在页面的某个偏移量处写入一串数据。 我们上面提到的Max Row ID属性实际占用8个字节的存储空间,所以在修改页面中的该属性时,会记录一条类型为MLOG_8BYTEredo日志,MLOG_8BYTEredo日志结构如下所示:

MySQL8事务篇2-MySQL事务日志redo log

  其余MLOG_1BYTEMLOG_2BYTEMLOG_4BYTE类型的redo日志结构和MLOG_8BYTE的类似,只不过具体数据中包含对应个字节的数据罢了。MLOG_WRITE_STRING类型的redo日志表示写入一串数据,但是因为不能确定写入的具体数据占用多少字节,所以需要在日志结构中添加一个len字段:

MySQL8事务篇2-MySQL事务日志redo log

提示: 只要将MLOG_WRITE_STRING类型的redo日志的len字段填充上1、2、4、8这些数字,就可以分别替代MLOG_1BYTE、MLOG_2BYTE、MLOG_4BYTE、MLOG_8BYTE这些类型的redo日志,为什么还要多此一举设计这么多类型呢?还不是因为省空间啊,能不写len字段就不写len字段,省一个字节算一个字节。

3.2 复杂一些的redo日志类型

  有时候执行一条语句会修改非常多的页面,包括系统数据页面和用户数据页面(用户数据指的就是聚簇索引和二级索引对应的B+树)。以一条INSERT语句为例,它除了要向B+树的页面中插入数据,也可能更新系统数据Max Row ID的值,不过对于我们用户来说,平时更关心的是语句对B+树所做更新:

  • 表中包含多少个索引,一条INSERT语句就可能更新多少棵B+树。
  • 针对某一棵B+树来说,既可能更新叶子节点页面,也可能更新内节点页面,也可能创建新的页面(在该记录插入的叶子节点的剩余空间比较少,不足以存放该记录时,会进行页面的分裂,在内节点页面中添加目录项记录)。

  在语句执行过程中,INSERT语句对所有页面的修改都得保存到redo日志中去。这句话说的比较轻巧,做起来可就比较麻烦了,比方说将记录插入到聚簇索引中时,如果定位到的叶子节点的剩余空间足够存储该记录时,那么只更新该叶子节点页面就好,那么只记录一条MLOG_WRITE_STRING类型的redo日志,表明在页面的某个偏移量处增加了哪些数据就好了么? 别忘了一个数据页中除了存储实际的记录之后,还有什么File HeaderPage HeaderPage Directory等等部分(在介绍数据页的章节有详细讲解),所以每往叶子节点代表的数据页里插入一条记录时,还有其他很多地方会跟着更新,比如说:

  • 可能更新Page Directory中的槽信息。
  • Page Header中的各种页面统计信息,比如PAGE_N_DIR_SLOTS表示的槽数量可能会更改,PAGE_HEAP_TOP代表的还未使用的空间最小地址可能会更改,PAGE_N_HEAP代表的本页面中的记录数量可能会更改,等等,各种信息都可能会被修改。
  • 我们知道在数据页里的记录是按照索引列从小到大的顺序组成一个单向链表的,每插入一条记录,还需要更新上一条记录的记录头信息中的next_record属性来维护这个单向链表。
  • 还有别的等等的更新的地方,就不一一介绍了...

  画一个简易的示意图就像是这样:

MySQL8事务篇2-MySQL事务日志redo log

  说了这么多,就是想表达:把一条记录插入到一个页面时需要更改的地方非常多。这时我们如果使用上面介绍的简单的物理redo日志来记录这些修改时,可以有两种解决方案:

  • 方案一:在每个修改的地方都记录一条redo日志。

    也就是如上图所示,有多少个加粗的块,就写多少条物理redo日志。这样子记录redo日志的缺点是显而易见的,因为被修改的地方是在太多了,可能记录的redo日志占用的空间都比整个页面占用的空间都多了~

  • 方案二:将整个页面的第一个被修改的字节最后一个修改的字节之间所有的数据当成是一条物理redo日志中的具体数据。

      从图中也可以看出来,第一个被修改的字节最后一个修改的字节之间仍然有许多没有修改过的数据,我们把这些没有修改的数据也加入到redo日志中去岂不是太浪费了~

      正因为上述两种使用物理redo日志的方式来记录某个页面中做了哪些修改比较浪费,设计InnoDB的大佬本着勤俭节约的初心,提出了一些新的redo日志类型,比如:

  • MLOG_REC_INSERT(对应的十进制数字为9):表示插入一条使用非紧凑行格式的记录时的redo日志类型。

  • MLOG_COMP_REC_INSERT(对应的十进制数字为38):表示插入一条使用紧凑行格式的记录时的redo日志类型。

提示: Redundant是一种比较原始的行格式,它就是非紧凑的。而Compact、Dynamic以及Compressed行格式是较新的行格式,它们是紧凑的(占用更小的存储空间)。

  • MLOG_COMP_PAGE_CREATEtype字段对应的十进制数字为58):表示创建一个存储紧凑行格式记录的页面的redo日志类型。
  • MLOG_COMP_REC_DELETEtype字段对应的十进制数字为42):表示删除一条使用紧凑行格式记录的redo日志类型。
  • MLOG_COMP_LIST_START_DELETEtype字段对应的十进制数字为44):表示从某条给定记录开始删除页面中的一系列使用紧凑行格式记录的redo日志类型。
  • MLOG_COMP_LIST_END_DELETEtype字段对应的十进制数字为43):与MLOG_COMP_LIST_START_DELETE类型的redo日志呼应,表示删除一系列记录直到MLOG_COMP_LIST_END_DELETE类型的redo日志对应的记录为止。

提示: 我们前面介绍InnoDB数据页格式的时候重点强调过,数据页中的记录是按照索引列大小的顺序组成单向链表的。有时候我们会有删除索引列的值在某个区间范围内的所有记录的需求,这时候如果我们每删除一条记录就写一条redo日志的话,效率可能有点低,所以提出MLOG_COMP_LIST_START_DELETE和MLOG_COMP_LIST_END_DELETE类型的redo日志,可以很大程度上减少redo日志的条数。

  • MLOG_ZIP_PAGE_COMPRESStype字段对应的十进制数字为51):表示压缩一个数据页的redo日志类型。
  • ······还有很多很多种类型,这就不列举了,等用到再说~

  这些类型的redo日志既包含物理层面的意思,也包含逻辑层面的意思,具体指:

  • 物理层面看,这些日志都指明了对哪个表空间的哪个页进行了修改。
  • 逻辑层面看,在系统奔溃重启时,并不能直接根据这些日志里的记载,将页面内的某个偏移量处恢复成某个数据,而是需要调用一些事先准备好的函数,执行完这些函数后才可以将页面恢复成系统奔溃前的样子。

  大家看到这可能有些懵逼,我们还是以类型为MLOG_COMP_REC_INSERT这个代表插入一条使用紧凑行格式的记录时的redo日志为例来理解一下我们上面所说的物理层面和逻辑层面到底是什么意思。废话少说,直接看一下这个类型为MLOG_COMP_REC_INSERTredo日志的结构(由于字段太多了,我们把它们竖着看效果好些):

MySQL8事务篇2-MySQL事务日志redo log

  这个类型为MLOG_COMP_REC_INSERTredo日志结构有几个地方需要大家注意:

  • 我们前面在介绍索引的时候说过,在一个数据页里,不论是叶子节点还是非叶子节点,记录都是按照索引列从小到大的顺序排序的。对于二级索引来说,当索引列的值相同时,记录还需要按照主键值进行排序。图中n_uniques的值的含义是在一条记录中,需要几个字段的值才能确保记录的唯一性,这样当插入一条记录时就可以按照记录的前n_uniques个字段进行排序。对于聚簇索引来说,n_uniques的值为主键的列数,对于其他二级索引来说,该值为索引列数+主键列数。这里需要注意的是,唯一二级索引的值可能为NULL,所以该值仍然为索引列数+主键列数。
  • field1_len ~ fieldn_len代表着该记录若干个字段占用存储空间的大小,需要注意的是,这里不管该字段的类型是固定长度大小的(比如INT),还是可变长度大小(比如VARCHAR(M))的,该字段占用的大小始终要写入redo日志中。
  • offset代表的是该记录的前一条记录在页面中的地址。为什么要记录前一条记录的地址呢?这是因为每向数据页插入一条记录,都需要修改该页面中维护的记录链表,每条记录的记录头信息中都包含一个称为next_record的属性,所以在插入新记录时,需要修改前一条记录的next_record属性。
  • 我们知道一条记录其实由额外信息真实数据这两部分组成,这两个部分的总大小就是一条记录占用存储空间的总大小。通过end_seg_len的值可以间接的计算出一条记录占用存储空间的总大小,为什么不直接存储一条记录占用存储空间的总大小呢?这是因为写redo日志是一个非常频繁的操作,设计InnoDB的大佬想方设法想减小redo日志本身占用的存储空间大小,所以想了一些弯弯绕的算法来实现这个目标,end_seg_len这个字段就是为了节省redo日志存储空间而提出来的。至于具体设计InnoDB的大佬到底是用了什么神奇魔法减小redo日志大小的,我们这就不多介绍了,因为的确有那么一丢丢小复杂,说清楚还是有一点点麻烦的,而且说明白了也没什么用。
  • mismatch_index的值也是为了节省redo日志的大小而设立的,大家可以忽略。

  很显然这个类型为MLOG_COMP_REC_INSERTredo日志并没有记录PAGE_N_DIR_SLOTS的值修改为了什么,PAGE_HEAP_TOP的值修改为了什么,PAGE_N_HEAP的值修改为了什么等等这些信息,而只是把在本页面中插入一条记录所有必备的要素记了下来,之后系统奔溃重启时,服务器会调用相关向某个页面插入一条记录的那个函数,而redo日志中的那些数据就可以被当成是调用这个函数所需的参数,在调用完该函数后,页面中的PAGE_N_DIR_SLOTSPAGE_HEAP_TOPPAGE_N_HEAP等等的值也就都被恢复到系统奔溃前的样子了。这就是所谓的逻辑日志的意思。

3.3 redo日志格式小结

  虽然上面说了一大堆关于redo日志格式的内容,但是如果你不是为了写一个解析redo日志的工具或者自己开发一套redo日志系统的话,那就没必要把InnoDB中的各种类型的redo日志格式都研究的透透的,没那个必要。上面我只是象征性的介绍了几种类型的redo日志格式,目的还是想让大家明白:redo日志会把事务在执行过程中对数据库所做的所有修改都记录下来,在之后系统奔溃重启后可以把事务所做的任何修改都恢复出来。

提示: 为了节省redo日志占用的存储空间大小,设计InnoDB的大佬对redo日志中的某些数据还可能进行压缩处理,比方说spacd ID和page number一般占用4个字节来存储,但是经过压缩后,可能使用更小的空间来存储。具体压缩算法就不介绍了。

4. redo的组成

Redo log可以简单分为以下两个部分:redo log bufferredo log file

  • 重做日志的缓冲 (redo log buffer),保存在内存中,是易失的。
    • 在服务器启动时就向操作系统申请了一大片称之为redo log buffert的连续内存空间,翻译成中文就是redo日志缓冲区。这片内存空间被划分成若干个连续的redo log block。一个redo log block占用512字节大小。

MySQL8事务篇2-MySQL事务日志redo log

参数设置:innodb_log_buffer_size: redo log buffer 大小,默认 16M最大值是4096M,最小值为1M。

mysql> show variables like '%innodb_log_buffer_size%';
+------------------------+----------+
| Variable_name          | Value    |
+------------------------+----------+
| innodb_log_buffer_size | 16777216 |
+------------------------+----------+
  • 重做日志文件 (redo log file),保存在硬盘中,是持久的。
    • REDO 日志文件如图所示,其中ib_logfile0和ib_logfile1 即为REDO日志。

MySQL8事务篇2-MySQL事务日志redo log

5. redo的整体流程

以一个更新事务为例,redo log 流转过程,如下图所示:

MySQL8事务篇2-MySQL事务日志redo log

  • 第1步:先将原始数据从磁盘中读入内存中来,修改数据的内存拷贝
  • 第2步:生成一条重做日志并写入redo log buffer,记录的是数据被修改后的值
  • 第3步:当事务commit时,将redo log buffer中的内容刷新到 redo log file,对 redo log file采用追加写的方式
  • 第4步:定期将内存中修改的数据刷新到磁盘中

提示: Write-Ahead Log(预先日志持久化):在持久化一个数据页之前,先将内存中相应的日志页持久化。

6. redo log的刷盘策略

redo log的写入并不是直接写入磁盘的,InnoDB引擎会在写redo log的时候先写redo log buffer,之后以 一定的频率 刷入到真正的redo log file 中。(先写入对应内存的开辟的空间redo log buffer,然后根据某个策略写入磁盘redo log file)这里的一定频率怎么看待呢?这就是我们要说的刷盘策略。

MySQL8事务篇2-MySQL事务日志redo log

注意,redo log buffer刷盘到redo log file的过程并不是真正的刷到磁盘中去,只是刷入到 文件系统缓存(page cache)中去(这是现代操作系统为了提高文件写入效率做的一个优化),真正的写入会交给系统自己来决定(比如page cache足够大了)。那么对于InnoDB来说就存在一个问题,如果交给系统来同步,同样如果系统宕机,那么数据也丢失了(虽然整个系统宕机的概率还是比较小的)。

针对这种情况,InnoDB给出 innodb_flush_log_at_trx_commit参数,该参数控制 commit提交事务时,如何将 redo log buffer 中的日志刷新到 redo log file 中。它支持三种策略:

  • 设置为0 :表示每次事务提交时不进行刷盘操作。(通过系统默认master thread每隔1s进行一次重做日志的同步)
  • 设置为1 :表示每次事务提交时都将进行同步,刷盘操作( 默认值 )
  • 设置为2 :表示每次事务提交时都只把 redo log buffer 内容写入 page cache(操作系统的缓存),不进行同步。由os自己决定什么时候同步到磁盘文件。

另外,InnoDB存储引擎有一个后台线程,每隔1秒,就会把redo log buffer中的内容写到文件系统缓存(page cache),然后调用刷盘操作。 MySQL8事务篇2-MySQL事务日志redo log

也就是说,一个没有提交事务的redo log记录,也可能会刷盘。因为在事务执行过程redo log记录是会写入redo log buffer中,这些redo log记录会被后台线程刷盘。

MySQL8事务篇2-MySQL事务日志redo log

除了后台线程每秒1次的轮询操作,还有一种情况,当redo log buffer占用的空间即将达到innodb_log_buffer_size(这个参数默认是16M)的一半的时候,后台线程会主动刷盘。

7. 不同刷盘策略演示

  1. 流程图

当参数为 1 的情况 MySQL8事务篇2-MySQL事务日志redo log

小结:innodb_fush_log_at_trx_commit 为1时,只要事务提交成功,redo log记录就一定在硬盘里,不会有任何数据丢失。

如果事务执行期间MySQL挂了或宕机,这部分日志丢了,但是事务并没有提交,所以日志丢了也不会有损失。可以保证ACID的D,数据绝对不会丢失,但是效率最差的。

建议使用默认值,虽然操作系统宕机的概率理论小于数据库宕机的概率,但是一般既然使用了事务,那么数据的安全相对来说更重要些。

当参数为 2 的情况:

MySQL8事务篇2-MySQL事务日志redo log

小结 innodb_flush_log_at_trx_commit=2 为2时,只要事务提交成功,redo log buffer中的内容只写入文件系统缓存(page cache)。 如果仅仅只是MySQL挂了不会有任何数据丢失,但是操作系统宕机可能会有1秒数据的丢失,这种情况下无法满足ACID中的D。但是数值2肯定是效率最高的。

当参数为 0 的情况:

MySQL8事务篇2-MySQL事务日志redo log

小结: innodb_fush_log_at_trx_commit=0 为0时,master thread中每1秒进行一次重做日志的fsync操作,因此实例crash最多丢失1秒钟内的事务。 (master thread是负责将缓冲池中的数据异步刷新到磁盘,保证数据的一致性) 数值0的话,是一种折中的做法,它的IO效率理论是高于1的,低于2的,这种策略也有丢失数据的风险,也无法保证D。

8. Mini-Transaction

8.1 以组的形式写入redo日志

  语句在执行过程中可能修改若干个页面。比如我们前面说的一条INSERT语句可能修改系统表空间页号为7的页面的Max Row ID属性(当然也可能更新别的系统页面,只不过我们没有都列举出来而已),还会更新聚簇索引和二级索引对应B+树中的页面。由于对这些页面的更改都发生在Buffer Pool中,所以在修改完页面之后,需要记录一下相应的redo日志。在执行语句的过程中产生的redo日志被设计InnoDB的大佬人为的划分成了若干个不可分割的组,比如:

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

  怎么理解这个不可分割的意思呢?我们以向某个索引对应的B+树插入一条记录为例,在向B+树中插入这条记录之前,需要先定位到这条记录应该被插入到哪个叶子节点代表的数据页中,定位到具体的数据页之后,有两种可能的情况:

  • 情况一:该数据页的剩余的空闲空间充足,足够容纳这一条待插入记录,那么事情很简单,直接把记录插入到这个数据页中,记录一条类型为MLOG_COMP_REC_INSERTredo日志就好了,我们把这种情况称之为乐观插入。假如某个索引对应的B+树长这样:

    MySQL8事务篇2-MySQL事务日志redo log

      现在我们要插入一条键值为10的记录,很显然需要被插入到页b中,由于页b现在有足够的空间容纳一条记录,所以直接将该记录插入到页b中就好了,就像这样:

    MySQL8事务篇2-MySQL事务日志redo log

  • 情况二:该数据页剩余的空闲空间不足,那么事情就悲剧了,我们前面说过,遇到这种情况要进行所谓的页分裂操作,也就是新建一个叶子节点,然后把原先数据页中的一部分记录复制到这个新的数据页中,然后再把记录插入进去,把这个叶子节点插入到叶子节点链表中,最后还要在内节点中添加一条目录项记录指向这个新创建的页面。很显然,这个过程要对多个页面进行修改,也就意味着会产生多条redo日志,我们把这种情况称之为悲观插入。假如某个索引对应的B+树长这样:

    MySQL8事务篇2-MySQL事务日志redo log

      现在我们要插入一条键值为10的记录,很显然需要被插入到页b中,但是从图中也可以看出来,此时页b已经塞满了记录,没有更多的空闲空间来容纳这条新记录了,所以我们需要进行页面的分裂操作,就像这样:

    MySQL8事务篇2-MySQL事务日志redo log

      如果作为内节点的页a的剩余空闲空间也不足以容纳增加一条目录项记录,那需要继续做内节点页a的分裂操作,也就意味着会修改更多的页面,从而产生更多的redo日志。另外,对于悲观插入来说,由于需要新申请数据页,还需要改动一些系统页面,比方说要修改各种段、区的统计信息信息,各种链表的统计信息(比如什么FREE链表、FSP_FREE_FRAG链表等等我们在介绍表空间那一章中介绍过的各种东东)等等等等,反正总共需要记录的redo日志有二、三十条。

提示: 其实不光是悲观插入一条记录会生成许多条redo日志,设计InnoDB的大佬为了其他的一些功能,在乐观插入时也可能产生多条redo日志(具体是为了什么功能我们就不多说了,要不篇幅就受不了了~)。

  设计InnoDB的大佬们认为向某个索引对应的B+树中插入一条记录的这个过程必须是原子的,不能说插了一半之后就停止了。比方说在悲观插入过程中,新的页面已经分配好了,数据也复制过去了,新的记录也插入到页面中了,可是没有向内节点中插入一条目录项记录,这个插入过程就是不完整的,这样会形成一棵不正确的B+树。我们知道redo日志是为了在系统奔溃重启时恢复崩溃前的状态,如果在悲观插入的过程中只记录了一部分redo日志,那么在系统奔溃重启时会将索引对应的B+树恢复成一种不正确的状态,这是设计InnoDB的大佬们所不能忍受的。所以他们规定在执行这些需要保证原子性的操作时必须以的形式来记录的redo日志,在进行系统奔溃重启恢复时,针对某个组中的redo日志,要么把全部的日志都恢复掉,要么一条也不恢复。怎么做到的呢?这得分情况讨论:

  • 有的需要保证原子性的操作会生成多条redo日志,比如向某个索引对应的B+树中进行一次悲观插入就需要生成许多条redo日志。

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

    MySQL8事务篇2-MySQL事务日志redo log

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

    MySQL8事务篇2-MySQL事务日志redo log

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

  • 有的需要保证原子性的操作只生成一条redo日志,比如更新Max Row ID属性的操作就只会生成一条redo日志。

      其实在一条日志后边跟一个类型为MLOG_MULTI_REC_ENDredo日志也是可以的,不过设计InnoDB的大佬比较勤俭节约,他们不想浪费一个比特位。别忘了虽然redo日志的类型比较多,但撑死了也就是几十种,是小于127这个数字的,也就是说我们用7个比特位就足以包括所有的redo日志类型,而type字段其实是占用1个字节的,也就是说我们可以省出来一个比特位用来表示该需要保证原子性的操作只产生单一的一条redo日志,示意图如下:

    MySQL8事务篇2-MySQL事务日志redo log

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

8.2 Mini-Transaction的概念

  设计MySQL的大佬把对底层页面中的一次原子访问的过程称之为一个Mini-Transaction,简称mtr,比如上面所说的修改一次Max Row ID的值算是一个Mini-Transaction,向某个索引对应的B+树中插入一条记录的过程也算是一个Mini-Transaction。通过上面的叙述我们也知道,一个所谓的mtr可以包含一组redo日志,在进行奔溃恢复时这一组redo日志作为一个不可分割的整体。

  一个事务可以包含若干条语句,每一条语句其实是由若干个mtr组成,每一个mtr又可以包含若干条redo日志,画个图表示它们的关系就是这样:

MySQL8事务篇2-MySQL事务日志redo log

9 redo日志的写入过程

9.1 redo log block的结构图

一个redo log block是由日志头、日志体、日志尾组成。日志头占用12字节,日志尾占用8字节,所以一个block真正能存储的数据就是512-12-8=492字节。

为什么一个block设计成512字节?

  • 这个和磁盘的扇区有关,机械磁盘默认的扇区就是512字节,如果你要写入的数据大于512字节,那么要写入的扇区肯定不止一个,这时就要涉及到盘片的转动,找到下一个扇区,假设现在需要写入两个扇区A和B,如果扇区A写入成功,而扇区B写入失败,那么就会出现非原子性的写入,而如果每次只写入和扇区的大小一样的512字节,那么每次的写入都是原子性的。

MySQL8事务篇2-MySQL事务日志redo log

真正的redo日志都是存储到占用496字节大小的log block body中,图中的log block headerlog block trailer存储的是一些管理信息。我们来看看这些所谓的管理信息都有什么。

MySQL8事务篇2-MySQL事务日志redo log

  • log block header的属性分别如下:

    • LOG_BLOCK_HDR_NO: log buffer是由log block组成,在内部log buffer就好以一个数组,因此LOG_BLOCK_HDR_NO用来标记这个数组中的位置。其是递增并且循环使用的,占用4个字节,但是由于第一位用来判断是否是flush bit,所以最大的值为2G。

    • LOG_BLOCK_HDR_DATA_LEN:表示block中已经使用了多少字节,初始值为12(因为log b1ock 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_HDR_DATA_LEN相同,则表示当前log block?不包含新的日志,

    • LOG_BL0CK_CHECKPOINT_NO:占用4字节,表示该log block最后被写入时的checkpoint。

  • 1og block trai1er中属性的意思如下:

    • LOG_BLOCK_CHECKSUM:表示block的校验值,用于正确性校验(其值和LOG_BLOCK_HDR_NO相同),我们暂时不关心它。

9.2 redo 日志写入log buffer

log buffer中写入redo日志的过程是顺序的,也就是先往前边的block中写,当该block的空闲空间用完之后再往下一个block中写。当我们想往log buffer中写入redo日志时,第一个遇到的问题就是应该写在哪个block的哪个偏移量处,所以InnoDB的设计者特意提供了一个称之为buf_free的全局变量,该变量指明后续写入的redo日志应该写入到log buffer中的明哪个位置,如图所示:

MySQL8事务篇2-MySQL事务日志redo log 每个mtr都会产生一组redo日志,用示意图来描述一下这些mtr产生的日志情况:

MySQL8事务篇2-MySQL事务日志redo log

不同的事务可能是 并发 执行的,所以 T1、 T2 之间的 mtr可能是 交替执行 的。每当一个mt执行完成时,伴随该mtr生成的一组redo日志就需要被复制到log buffer中,也就是说不同事务的mtr可能是交替写入log buffer的,我们画个示意图(为了美观,我们把一个mtr中产生的所有的redo日志当作一个整体来画):

MySQL8事务篇2-MySQL事务日志redo log

有的mtr产生的redo日志量非常大,比如mtr_t1_2产生的redo日志占用空间比较大,占用了3个block来存储。

10. redo log file 日志文件

10.1 相关参数设置

  • innodb_log_group_home_dir :指定 redo log 文件组所在的路径,默认值为./,表示在数据库的数据目录下。MySQL的默认数据目录( var/lib/mysql )下默认有两个名为 ib_logfile0 和ib_logfile1 的文件,log buffer中的日志默认情况下就是刷新到这两个磁盘文件中。此redo日志文件位置还可以修改。
  • innodb_log_files_in_group(log_files文件个数):指明redo log file的个数,命名方式如:ib_logfile0,iblogfile1… iblogfilen。默认2个,最大100个。
mysql> show variables like 'innodb_log_files_in_group';
+---------------------------+-------+
| Variable_name       | Value |
+---------------------------+-------+
| innodb_log_files_in_group | 2   |
+---------------------------+-------+
#ib_logfile0
#ib_logfile1
  • innodb_flush_log_at_trx_commit:控制 redo log 刷新到磁盘的策略,默认为1。
  • innodb_log_file_size(log_files文件个数中单个大小):单个 redo log 文件设置大小,默认值为 48M 。最大值为512G,注意最大值指的是整个 redo log 系列文件之和,即(innodb_log_files_in_group * innodb_log_file_size )不能大于最大值512G。
mysql> show variables like 'innodb_log_file_size';
+----------------------+----------+
| Variable_name    | Value  |
+----------------------+----------+
| innodb_log_file_size | 50331648 |
+----------------------+----------+

根据业务修改其大小,以便容纳较大的事务。编辑my.cnf文件并重启数据库生效,如下所示

[root@localhost ~]# vim /etc/my.cnf
innodb_log_file_size=200M

在数据库实例更新比较频繁的情况下,可以适当加大redo log组数和大小。但也不推荐redo log设置过大,在MySQL崩溃恢复时会重新执行REDO日志中的记录。

10.2 日志文件组

从上边的描述中可以看到,磁盘上的redo日志文件不只一个,而是以一个日志文件组的形式出现的。这些文件以ib_logfi1e[数字](数宇可以是0、1、2…)的形式进行命名,每个的redo日志文件大小都是一样的。

在将redo日志写入日志文件组时,是从ib-1ogfile0开始写,如果ib_1ogfile0写满了,就接着ib_1ogfile1写。同理,ib_1ogfile1写满了就去写ib_1ogfile2,依此类推。如果写到最后一个文件该昨办?那就重新转到ib_logfile0继续写,所以整个过程如下图所示:

MySQL8事务篇2-MySQL事务日志redo log

总共的redo日志文件大小其实就是: innodb_log_file_size × innodb_log_files_in_group 。

采用循环使用的方式向redo日志文件组里写数据的话,会导致后写入的redo日志覆盖掉前边写的redo日志? 当然!所以InnoDB的设计者提出了checkpoint的概念。

10.3 redo日志文件格式

我们前面说过log buffer本质上是一片连续的内存空间,被划分成了若干个512字节大小的block。将log buffer中的redo日志刷新到磁盘的本质就是把block的镜像写入日志文件中,所以redo日志文件其实也是由若干个512字节大小的block组成。

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

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

  所以我们前面所说的循环使用redo日志文件,其实是从每个日志文件的第2048个字节开始算,画个示意图就是这样:

MySQL8事务篇2-MySQL事务日志redo log

  普通block的格式我们在介绍log buffer的时候都说过了,就是log block headerlog block bodylog block trialer这三个部分,就不重复介绍了。这里需要介绍一下每个redo日志文件前2048个字节,也就是前4个特殊block的格式都是干嘛的,废话少说,先看图:

MySQL8事务篇2-MySQL事务日志redo log 从图中可以看出来,这4个block分别是:

  • log file header:描述该redo日志文件的一些整体属性,看一下它的结构:

    MySQL8事务篇2-MySQL事务日志redo log

      各个属性的具体释义如下:

    属性名长度(单位:字节)描述
    LOG_HEADER_FORMAT4redo日志的版本,在MySQL 5.7.21中该值永远为1
    LOG_HEADER_PAD14做字节填充用的,没什么实际意义,忽略~
    LOG_HEADER_START_LSN8标记本redo日志文件开始的LSN值,也就是文件偏移量为2048字节初对应的LSN值(关于什么是LSN我们稍后再看,看不懂的先忽略)。
    LOG_HEADER_CREATOR32一个字符串,标记本redo日志文件的创建者是谁。正常运行时该值为MySQL的版本号,比如:"MySQL 5.7.21",使用mysqlbackup命令创建的redo日志文件的该值为"ibbackup"和创建时间。
    LOG_BLOCK_CHECKSUM4本block的校验值,所有block都有,我们不关心

    提示: 设计InnoDB的大佬对redo日志的block格式做了很多次修改,如果你阅读的其他书籍中发现上述的属性和你阅读书籍中的属性有些出入,不要慌,正常现象,忘记以前的版本吧。另外,LSN值我们后边才会介绍,现在千万别纠结LSN是什么。

  • checkpoint1:记录关于checkpoint的一些属性,看一下它的结构:

    MySQL8事务篇2-MySQL事务日志redo log

    各个属性的具体释义如下:

    属性名长度(单位:字节)描述
    LOG_CHECKPOINT_NO8服务器做checkpoint的编号,每做一次checkpoint,该值就加1。
    LOG_CHECKPOINT_LSN8服务器做checkpoint结束时对应的LSN值,系统奔溃恢复时将从该值开始。
    LOG_CHECKPOINT_OFFSET8上个属性中的LSN值在redo日志文件组中的偏移量
    LOG_CHECKPOINT_LOG_BUF_SIZE8服务器在做checkpoint操作时对应的log buffer的大小
    LOG_BLOCK_CHECKSUM4本block的校验值,所有block都有,我们不关心

    提示: 现在看不懂上面这些关于checkpoint和LSN的属性的释义是很正常的,我就是想让大家对上面这些属性混个脸熟,后边我们后详细介绍的。Copy to clipboardErrorCopied

  • 第三个block未使用,忽略~

  • checkpoint2:结构和checkpoint1一样。

10.4 Log Sequeue Number

  自系统开始运行,就不断的在修改页面,也就意味着会不断的生成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 headerlog block trailer来计算的。我们来看一个例子:

  • 系统第一次启动后初始化log buffer时,buf_free(就是标记下一条redo日志应该写入到log buffer的位置的变量)就会指向第一个block的偏移量为12字节(log block header的大小)的地方,那么lsn值也会跟着增加12:

    MySQL8事务篇2-MySQL事务日志redo log

  • 如果某个mtr产生的一组redo日志占用的存储空间比较小,也就是待插入的block剩余空闲空间能容纳这个mtr提交的日志时,lsn增长的量就是该mtr生成的redo日志占用的字节数,就像这样:

    MySQL8事务篇2-MySQL事务日志redo log

      我们假设上图中mtr_1产生的redo日志量为200字节,那么lsn就要在8716的基础上增加200,变为8916

  • 如果某个mtr产生的一组redo日志占用的存储空间比较大,也就是待插入的block剩余空闲空间不足以容纳这个mtr提交的日志时,lsn增长的量就是该mtr生成的redo日志占用的字节数加上额外占用的log block headerlog block trailer的字节数,就像这样:

    MySQL8事务篇2-MySQL事务日志redo log

      我们假设上图中mtr_2产生的redo日志量为1000字节,为了将mtr_2产生的redo日志写入log buffer,我们不得不额外多分配两个block,所以lsn的值需要在8916的基础上增加1000 + 12×2 + 4 × 2 = 1032

    小贴士:为什么初始的lsn值为8704呢?我也不太清楚,人家就这么规定的。其实你也可以规定你一生下来算1岁,只要保证随着时间的流逝,你的年龄不断增长就好了。Copy to clipboardErrorCopied
    

      从上面的描述中可以看出来,每一组由mtr生成的redo日志都有一个唯一的LSN值与其对应,LSN值越小,说明redo日志产生的越早。

10.4.1 flushed_to_disk_lsn

  redo日志是首先写到log buffer中,之后才会被刷新到磁盘上的redo日志文件。所以设计InnoDB的大佬提出了一个称之为buf_next_to_write的全局变量,标记当前log buffer中已经有哪些日志被刷新到磁盘中了。画个图表示就是这样:

MySQL8事务篇2-MySQL事务日志redo log

  我们前面说lsn是表示当前系统中写入的redo日志量,这包括了写到log buffer而没有刷新到磁盘的日志,相应的,设计InnoDB的大佬提出了一个表示刷新到磁盘中的redo日志量的全局变量,称之为flushed_to_disk_lsn。系统第一次启动时,该变量的值和初始的lsn值是相同的,都是8704。随着系统的运行,redo日志被不断写入log buffer,但是并不会立即刷新到磁盘,lsn的值就和flushed_to_disk_lsn的值拉开了差距。我们演示一下:

  • 系统第一次启动后,向log buffer中写入了mtr_1mtr_2mtr_3这三个mtr产生的redo日志,假设这三个mtr开始和结束时对应的lsn值分别是:

    • mtr_1:8716 ~ 8916

    • mtr_2:8916 ~ 9948

    • mtr_3:9948 ~ 10000

        此时的lsn已经增长到了10000,但是由于没有刷新操作,所以此时flushed_to_disk_lsn的值仍为8704,如图:

      MySQL8事务篇2-MySQL事务日志redo log

  • 随后进行将log buffer中的block刷新到redo日志文件的操作,假设将mtr_1mtr_2的日志刷新到磁盘,那么flushed_to_disk_lsn就应该增长mtr_1mtr_2写入的日志量,所以flushed_to_disk_lsn的值增长到了9948,如图:

    MySQL8事务篇2-MySQL事务日志redo log

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

提示: 应用程序向磁盘写入文件时其实是先写到操作系统的缓冲区中去,如果某个写入操作要等到操作系统确认已经写到磁盘时才返回,那需要调用一下操作系统提供的fsync函数。其实只有当系统执行了fsync函数后,flushed_to_disk_lsn的值才会跟着增长,当仅仅把log buffer中的日志写入到操作系统缓冲区却没有显式的刷新到磁盘时,另外的一个称之为write_lsn的值跟着增长。不过为了大家理解上的方便,我们在讲述时把flushed_to_disk_lsn和write_lsn的概念混淆了起来。

10.4.2 lsn值和redo日志文件偏移量的对应关系

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

MySQL8事务篇2-MySQL事务日志redo log

  初始时的LSN值是8704,对应文件偏移量2048,之后每个mtr向磁盘中写入多少字节日志,lsn的值就增长多少。

10.4.3 flush链表中的LSN

  我们知道一个mtr代表一次对底层页面的原子访问,在访问过程中可能会产生一组不可分割的redo日志,在mtr结束时,会把这一组redo日志写入到log buffer中。除此之外,在mtr结束时还有一件非常重要的事情要做,就是把在mtr执行过程中可能修改过的页面加入到Buffer Pool的flush链表。为了防止大家早已忘记flush链表是什么,我们再看一下图:

MySQL8事务篇2-MySQL事务日志redo log

  当第一次修改某个缓存在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):

    MySQL8事务篇2-MySQL事务日志redo log

  • 接着假设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属性中。画个图表示一下:

    MySQL8事务篇2-MySQL事务日志redo log

      从图中可以看出来,每次新插入到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属性中。另外,由于页bmtr_3执行过程中又发生了一次修改,所以需要更新页b对应的控制块中newest_modification的值为10000。画个图表示一下:

    MySQL8事务篇2-MySQL事务日志redo log

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

10.4 checkpoint

有一个很不幸的事实就是我们的redo日志文件组容量是有限的,我们不得不选择循环使用redo日志文件组中的文件,但是这会造成最后写的redo日志与最开始写的redo日志追尾,这时应该想到:redo日志只是为了系统奔溃后恢复脏页用的,如果对应的脏页已经刷新到了磁盘,也就是说即使现在系统奔溃,那么在重启后也用不着使用redo日志恢复该页面了,所以该redo日志也就没有存在的必要了,那么它占用的磁盘空间就可以被后续的redo日志所重用。也就是说:判断某些redo日志占用的磁盘空间是否可以覆盖的依据就是它对应的脏页是否已经刷新到磁盘里。我们看一下前面一直介绍的那个例子:

MySQL8事务篇2-MySQL事务日志redo log

  如图,虽然mtr_1mtr_2生成的redo日志都已经被写到了磁盘上,但是它们修改的脏页仍然留在Buffer Pool中,所以它们生成的redo日志在磁盘上的空间是不可以被覆盖的。之后随着系统的运行,如果页a被刷新到了磁盘,那么它对应的控制块就会从flush链表中移除,就像这样子:

MySQL8事务篇2-MySQL事务日志redo log

  这样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_lsnredo日志文件组中对应的偏移量checkpoint_offset,然后把这三个值都写到redo日志文件组的管理信息中。

      我们说过,每一个redo日志文件都有2048个字节的管理信息,但是上述关于checkpoint的信息只会被写到日志文件组的第一个日志文件的管理信息中。不过我们是存储到checkpoint1中还是checkpoint2中呢?设计InnoDB的大佬规定,当checkpoint_no的值是偶数时,就写到checkpoint1中,是奇数时,就写到checkpoint2中。

  记录完checkpoint的信息之后,redo日志文件组中各个lsn值的关系就像这样:

MySQL8事务篇2-MySQL事务日志redo log

在整个日志文件组中还有两个重要的属性,分别是write pos、checkpoint

  • write pos是当前记录的位置,一边写一边后移
  • checkpoint是当前要擦除的位置,也是往后推移

每次刷盘redo log记录到日志文件组中,write pos位置就会后移更新。每次MySQL加载日志文件组恢复数据时,会清空加载过的redo log记录,并把checkpoint后移更新。write pos和checkpoint.之间的还空着的部分可以 用来写入新的redo log记录。

MySQL8事务篇2-MySQL事务日志redo log

如果 write pos 追上 checkpoint ,表示日志文件组满了,这时候不能再写入新的 redo log记录,MySQL 得停下来,清空一些记录,把 checkpoint 推进一下。

MySQL8事务篇2-MySQL事务日志redo log

10.5 批量从flush链表中刷出脏页

  我们在介绍Buffer Pool的时候说过,一般情况下都是后台的线程在对LRU链表flush链表进行刷脏操作,这主要因为刷脏操作比较慢,不想影响用户线程处理请求。但是如果当前系统修改页面的操作十分频繁,这样就导致写日志操作十分频繁,系统lsn值增长过快。如果后台的刷脏操作不能将脏页刷出,那么系统无法及时做checkpoint,可能就需要用户线程同步的从flush链表中把那些最早修改的脏页(oldest_modification最小的脏页)刷新到磁盘,这样这些脏页对应的redo日志就没用了,然后就可以去做checkpoint了。

10.6 查看系统中的各种LSN值

  我们可以使用SHOW ENGINE INNODB STATUS命令查看当前InnoDB存储引擎中的各种LSN值的情况,比如:

mysql> SHOW ENGINE INNODB STATUS\G

(...省略前面的许多状态)
LOG
---
Log sequence number 124476971
Log flushed up to   124099769
Pages flushed up to 124052503
Last checkpoint at  124052494
0 pending log flushes, 0 pending chkp writes
24 log i/o's done, 2.00 log i/o's/second
----------------------
(...省略后边的许多状态)Copy to clipboardErrorCopied

其中:

  • Log sequence number:代表系统中的lsn值,也就是当前系统已经写入的redo日志量,包括写入log buffer中的日志。
  • Log flushed up to:代表flushed_to_disk_lsn的值,也就是当前系统已经写入磁盘的redo日志量。
  • Pages flushed up to:代表flush链表中被最早修改的那个页面对应的oldest_modification属性值。
  • Last checkpoint at:当前系统的checkpoint_lsn值。

11. 崩溃恢复

在服务器不挂的情况下,redo日志简直就是个大累赘,不仅没用,反而让性能变得更差。但是万一,我说万一啊,万一数据库挂了,那redo日志可是个宝了,我们就可以在重启时根据redo日志中的记录就可以将页面恢复到系统奔溃前的状态。我们接下来大致看一下恢复过程是什么样。

11.1 确定恢复的起点

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

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

11.2 确定恢复的终点

  redo日志恢复的起点确定了,那终点是哪个呢?这个还得从block的结构说起。我们说在写redo日志的时候都是顺序写的,写满了一个block之后会再往下一个block中写:

MySQL8事务篇2-MySQL事务日志redo log

  普通block的log block header部分有一个称之为LOG_BLOCK_HDR_DATA_LEN的属性,该属性值记录了当前block里使用了多少字节的空间。对于被填满的block来说,该值永远为512。如果该属性的值不为512,那么就是它了,它就是此次奔溃恢复中需要扫描的最后一个block。

11.3 怎么恢复

  确定了需要扫描哪些redo日志进行奔溃恢复之后,接下来就是怎么进行恢复了。假设现在的redo日志文件中有5条redo日志,如图:

MySQL8事务篇2-MySQL事务日志redo log

  由于redo 0checkpoint_lsn后边,恢复时可以不管它。我们现在可以按照redo日志的顺序依次扫描checkpoint_lsn之后的各条redo日志,按照日志中记载的内容将对应的页面恢复出来。这样没什么问题,不过设计InnoDB的大佬还是想了一些办法加快这个恢复的过程:

  • 使用哈希表

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

    MySQL8事务篇2-MySQL事务日志redo log

      之后就可以遍历哈希表,因为对同一个页面进行修改的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日志了,所以更进一步提升了奔溃恢复的速度。

12. 遗漏的问题:LOG_BLOCK_HDR_NO是如何计算的

  我们前面说过,对于实际存储redo日志的普通的log block来说,在log block header处有一个称之为LOG_BLOCK_HDR_NO的属性(忘记了的话回头再看看),我们说这个属性代表一个唯一的标号。这个属性是初次使用该block时分配的,跟当时的系统lsn值有关。使用下面的公式计算该block的LOG_BLOCK_HDR_NO值:

((lsn / 512) & 0x3FFFFFFFUL) + 1Copy to clipboardErrorCopied

  这个公式里的0x3FFFFFFFUL可能让大家有点困惑,其实它的二进制表示可能更亲切一点:

MySQL8事务篇2-MySQL事务日志redo log

  从图中可以看出,0x3FFFFFFFUL对应的二进制数的前2位为0,后30位的值都为1。我们刚开始学计算机的时候就学过,一个二进制位与0做与运算(&)的结果肯定是0,一个二进制位与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。

13. 小结

相信大家都知道redo log的用和它的刷盘时机、存储形式:

InnoDB的更新操作采用的是Write Ahead Log(预先日志特久化)策略,即先写日志,再写入磁盘。

MySQL8事务篇2-MySQL事务日志redo log

参考文章

MySQL从入门到精通 MySQL是怎样运行的 从根儿上理解MySQL 第20和21章 《MySQL技术内幕:InnoDB存储引擎(第2版)》 《数据库索引设计与优化》