mysql运行原理笔记-redo日志和undo日志

Scroll Down

redo日志

我们知道,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日志占用的空间非常小:在存储表空间ID、页号、偏移量以及需要更新的值时,需要的存储空间很小。
  • redo日志是顺序写入磁盘的:在执行事务的过程中,每执行一条语句,就可能产生若干条redo日志,这些日志是按照产生的顺序写入磁盘的,也就是使用顺序IO。

redo日志格式

由前文可知,redo日志本质上只是记录了一下事务对数据库进行了哪些修改。InnoDB存储引擎针对事务对数据库的不同修改场景,定义了多种类型的redo日志,但是绝大部分类型的redo日志都是如下所示:
redo-log-format.drawio
redo日志中各个部分的详细解释如下:

  • type:这条redo日志的类型。
  • spaceID:表空间ID。
  • page number:页号。
  • data:这条redo日志的具体内容。

简单的redo日志类型

前文介绍InnoDB记录行格式的时候说过,如果没有为某个行显式地定义主键,并且表中也没有定义不允许定义不允许存储NULL值得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_1BYTE:表示在页面的某个偏移量处写入1字节的redo日志类型。
  • MLOG_2BYTE:表示在页面的某个偏移量处写入2字节的redo日志类型。
  • MLOG_4BYTE:表示在页面的某个偏移量处写入4字节的redo日志类型。
  • MLOG_8BYTE:表示在页面的某个偏移量处写入8字节的redo日志类型。
  • MLOG_WRITE_STRING:表示在页面的某个偏移量处写入一个字节序列。

复杂一些的redo日志类型

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

  • 表中包含多少个索引,一条INSERT语句就可能更新多少颗B+树。
  • 针对一颗B+树,既可能更新叶子节点页面,也有可能更新内节点页面,还可能创建新的页面。(在该记录插入的叶子节点的剩余空间比较少,不足以存放该记录时,会进行页面分裂,在内节点页面中添加目录项记录)。
    在语句执行过程中,INSERT语句对所有页面的修改都得保存到redo日志中去。这句话说的比较轻巧,做起来可就比较麻烦了。比如,在将记录插入到聚簇索引中时,如果定位到的叶子节点的剩余空间足够存储该记录,那么只更新该叶子节点页面,并只记录一条MLOG_WRITE_STRING类型的redo日志,表明在页面的某个偏移量处增加了哪些数据不就好了,别忘了,一个数据页中除了存储实际的记录之外,还有File Header、Page Header、Page Directory等部分。所以每往叶子节点代表的数据页中插入一条记录,还有其他很多地方会跟着更新。比如:
  • 可能更新Page Directory中的槽信息。
  • 可能更新Page Header中的各种页面统计信息。
  • 数据页中的记录按照索引列从小到大的顺序组成一个单向链表,每插入一条记录,还需要更新上一条记录的记录头信息中的next_record属性来维护这个单向链表。
    在把一条记录插入到一个页面时,需要更改的地方非常多。这时如果使用前面介绍的简单的物理redo日志来记录这些修改,可以有两种解决方案。
  • 方案1:在每个修改的地方都记录一条redo日志。
  • 方案2:将整个页面第一个被修改的字节到最后一个被修改的字节之间所有的数据当成一条物理redo日志中的具体数据。

第一个被修改的字节到最后一个被修改的字节之间仍然有许多没有修改过的数据,把这些没有修改过的数据页加入到redo日志中岂不是太浪费空间了。

正因为此,提出了一些新的redo日志类型:

  • MLOG_REC_INSERT:表示在插入一条使用非紧凑行格式的记录时,redo日志类型。
  • MLOG_COMP_REC_INSERT:表示在插入一条使用紧凑格式的记录时,redo日志类型。
  • MLOG_COMP_PAGE_CREATE:表示在创建一个存储紧凑行格式的页面时,redo日志类型。
  • MLOG_COMP_REC_DELETE:表示在删除一条使用紧凑行格式记录时,redo日志类型。
  • MLOG_COMP_LIST_START_DELETE:表示在删除一个存储紧凑行格式的记录时,redo日志类型。
  • MLOG_COMP_LIST_END_DELETE:表示在删除一个存储紧凑行格式的记录时为止,redo日志类型。
  • MLOG_ZIP_PAGE_COMPRESS(type字段对应的十进制数字为51):表示在压缩一个数据页时,redo日志的类型。

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

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

在这个MLOG_COMP_REC_INSERT类型的redo日志结构中,有下面几个地方需要注意。

  • 前面说过,在一个数据页中,无论是叶子节点还是非叶子节点,记录都是按照索引列的值从小到大排序的。对于二级索引来说,当索引列的值相同时,记录还需要按照主键值进行排序。在上图中n_uniques的含义是在一条记录中,需要几个字段的值才能确保记录的唯一性,这样在插入一条记录时,就可以按照记录的前n_uniques个字段进行排序。对于聚簇索引来说,n_quniques的值为主键列数;对于二级索引来说,该值为索引列中包含的列数+主键列数。这里需要注意的是,唯一二级索引的值可能为NULL,所以该值仍然为索引列中包含的列数+主键列数。
  • field1_1~fieldn_len代表该记录若干个字段占用存储空间的大小。需要注意的是,这里无论该字段的类型是固定长度类型还是可变长度类型,该字段占用的存储空间大小始终要写入到redo日志中。
  • offset代表该记录的前一条记录在页面中的地址。因为每向数据页插入一条记录,都需要修改该页面中维护的记录链表。每条记录的记录头信息中都包含一个名为next_record的属性,所以在插入新纪录时,需要修改前一条记录的next_record属性。
  • 我们知道,一条记录其实由额外信息和真实数据这两部分组成,这两个部分的总大小就是一条记录占用存储空间的总大小。通过end_seg_len的值可以间接地计算出一条记录占用存储空间的总大小。
  • mismatch_index也是为了节省redo日志的大小而设立的,大家可以忽略。

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

redo日志格式小结

redo日志会把事务在执行过程中对数据库所做的所有修改都记录下来,在之后的系统因崩溃而重启后可以把事务所做的任何修改都恢复过来。

为了节省redo日志占用的存储空间大小,InnoDB还对redo日志中的某些数据进行了压缩处理。比如,spaceID和page number一般占用4字节来存储,但是经过压缩后可以使用更小的空间来存储。

Mini-Transaction

以组的形式写入redo日志

语句在执行过程中可能会修改若干个页面。比如我们前面说的一条INSERT语句可能修改系统表空间页号为7的页面的Max Row ID属性,还会更新聚簇索引和二级索引对应B+树中的页面。由于这些页面的更改都发生在Buffer Pool中,所以在修改完页面之后,需要记录相应的redo日志,在执行语句的过程中产生的redo日志,被认为划分成了若干个不可分割的组。

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

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

  • 情况1:该数据页剩余的空闲空间相当充足,足够容纳一条待插入记录。这样一来,事情很简单,直接把该记录插入到这个数据页中,然后记录一条MLOG_COMP_REC_INSERT类型的redo日志就好了。这种情况称为乐观插入。
  • 情况2:该数据页剩余空间不足。遇到这种情况需要进行页分裂操作,也就是新建一个叶子节点,把原先数据页中的一部分记录复制到这个新的数据页中,然后再把记录插入进去;再把这个叶子节点插入到叶子节点链表中,最后还要在内节点中添加一条目录项记录来指向这个新创建的页面。很显然,这个过程需要对多个页面进行修改,这意味着产生多条redo日志。这种情况称为悲观插入。

假如某个索引对应的B+树,现在要插入一条键值为10的记录,很显然需要插入到页b中。但是此时页b已经塞满了记录,没有更多的空闲空间来容纳这条新记录,所以需要进行页面的分裂操作。
如果作为内节点的页a的剩余空间也不足以容纳新增的一条目录项记录,则需要继续对内节点页a进行分裂操作,这也就意味着会修改更多的页面,从而产生更多的redo日志。另外,对于悲观插入来说,由于需要新申请数据页,因此还需要改动一些系统页面。比如要修改段、区的统计信息,修改各种链表的统计信息等,反正总共需要记录的redo日志有二三十条。
MySQL认为向某个索引对应的B+树中插入一条记录的过程必须是原子的,不能说插一半就停止了。比如在悲观插入过程中,新的页面已经分配好了,数据也复制过去了,新的记录也插入到页面中了,但是没有向内节点中插入一条目录项记录。那么,这个插入过程就是不完整的,这就会形成一个不正确的B+树。
我们知道,redo日志是为了在系统因崩溃而重启时恢复崩溃前的状态而提出的,如果在悲观插入的过程中只记录了一部分redo日志,那么在系统在重启时会将索引对应的B+树恢复成一种不正确的状态。这是MySQL所不能忍受的,所以我们规定在执行这些需要保证原子性的操作时,必须以组的形式来记录redo日志。在进行恢复时,针对某个组中的redo日志,要么把全部的日志恢复,要么一条也不恢复。怎么做到的呢?

  • 情况1:有些需要保证原子性的操作会生成多条redo日志。比如向某个索引对应的B+树中进行一次悲观插入时,就需要生成许多条redo日志。
    如何把这些redo日志划分到一个组里面呢?MySQL在该组中的最后一条redo日志后面加上一条特殊类型的redo日志。该类型的redo日志的名称为MLOG_MULTI_REC_END,结构很简单,只有一个type字段,所以,某个需要保证原子性的操作所产生的的一系列redo日志,必须以一条类型为MLOG_MULTI_REC_END的redo日志结尾。
    这样在系统因崩溃而重启恢复时,只有解析到类型为MLOG_MULTI_REC_END的redo日志时,才认为解析到了一组完整的redo日志,才会进行恢复;否则直接放弃前面解析到的redo日志。
  • 情况2:有些需要保证原子性的操作只生成一条redo日志。比如更新Max Row ID属性的操作就只会生成一条redo日志。
    如果type字段的第1个比特为1,代表这个需要保证原子性的操作只产生了一条单一的redo日志;否则就表示这个需要保证原子性的操作产生了一系列的redo日志。

Mini-Transaction的概念

MySQL把对底层页面进行一次原子访问的过程称为一个MIni-Transaction(MTR)。比如前文所说的修改一次Max Row ID的值算是一个Mini-Transaction,向某个索引对应的B+树中插入一条记录的过程也算是一个Mini-Transaction。通过前面的叙述我们也知道一个MTR可以包含一组redo日志,在进行崩溃恢复时,需要把这一组redo日志作为一个不可分割的整体来处理。
一个事务可以包含若干条语句,每一条语句又包含若干条MTR。每一个MTR又可以包含若干条redo日志。

redo日志的写入过程

redo log block

为了更好的管理redo日志,设计者把通过MTR生成的redo日志都放在了大小为512字节的页中。一个redo log back的示意图如下:
mysql-redo-block.drawio
其中,log block header中几个属性的意思分别如下。

  • LOG_BLOCK_HDR_NO:每一个block都有一个大于0的唯一编号,该属性就表示该编号值。
  • LOG_BLOCK_HDR_DATA_LEN:表示block中已经使用了多少字节;初始值为12。随着往block中写入的redo日志越来越多,该属性值也跟着增长。如果log block body已经被全部写满,那么该属性的值被设置为512。
  • LOG_BLOCK_FIRST_REC_GROUP:一条redo日志也可以称为一条redo日志记录。一个MTR会生成多条redo日志记录,这个MTR生成的这些redo日志记录被称为一个redo日志记录组(redo log record group)。LOG_BLOCK_FIRST_REC_GROUP就代表该block中第一个MTR生成的redo日志记录组的偏移量其实也就是这个block中第一个MTR生成的第一条redo日志记录的偏移量(如果一个MTR生成的redo日志横跨了好多个block,那么最后一个LOG_BLOCK_FIRST_REC_GROUP属性就表示这个MTR对应的redo日志结束的地方,也就是下一个MTR生成redo日志开始的地方)。
  • LOG_BLOCK_CHECKPOINT_NO:表示checkpoint的序号。
  • LOG_BLOCK_CHECKSUM:表示该block的校验值,用于正确性校验。

redo日志缓冲区

前文说过,InnoDB存储引擎为了解决磁盘速度过慢的问题而引入了Buffer Pool。同理,写入redo日志时也不能直接写到磁盘中,实际上在服务器启动时就向操作系统申请了一片称为redo log buffer(redo日志缓冲区)的连续内存空间,也可以将其简单称为log buffer。这篇内存空间被划分成若干个连续的redo log block。
mysql-redo-note002.drawio
我们可以通过启动选项innodb_log_buffer_size来指定log buffer的大小。在MySQL5.7.22版本中,该启动项的默认值为16MB。

redo日志写入log buffer

向log buffer中写入redo日志的过程是顺序写入的,也就是先往前面的block中写,当该block的空闲空间用完之后再往下一个block中写。当想往log buffer中写入redo日志时,MySQL提供了一个称为buf_free的全局变量,该变量指明后续写入的redo日志应该写到log buffer中的哪个位置。
我们前面说过,一个MTR执行过程中可能产生若干条redo日志,这些redo日志是一个不可分割的组,所以并不是每生成一条redo日志就将其插入到log buffer中,而是将每个MTR运行过程中产生的日志先暂时存到一个地方:当该MTR结束的时候,再将过程中产生的一组redo日志全部复制到log buffer中。现在假设有名为T1、T2的两个事务,每个事务都包含2个MTR,这几个MTR的名字如下:

  • 事务T1的两个MTR分别称为mtr_t1_1和mtr_t1_2;
  • 事务T2的两个MTR分别称为mtr_t2_1和mtr_t2_2。

redo日志文件

redo日志刷盘时机

MTR运行过程中产生的一组redo日志在MTR结束时会被复制到log buffer中。在一些情况下它们会被刷新到磁盘中。

  • log buffer空间不足时。log buffer是有限的,如果不停地向这个有限大小的log buffer中塞入日志,很快就会被塞满、MySQL认为,如果当前写入log buffer的redo日志量已经占满了log buffer总容量的50%左右,就需要把这些日志刷新到磁盘中。
  • 事务提交时。前面说过,之所以提出redo日志概念,主要是因为它占用空间少,而且可以将其顺序写入磁盘。因此redo日志后,虽然在事务提交时可以不把修改过的Buffer Pool页面立即刷新到磁盘,但是为了保证持久性,必须要把页面修改时所对应的redo日志刷新到磁盘;否则系统崩溃后,无法将该事务对页面所做的修改恢复过来。
  • 后台有一个线程,大约每秒一次的频率将Log buffer中的redo日志刷新到磁盘。
  • 正常关闭服务器时
  • 做checkpoint时

redo日志文件组

MySQL的数据目录下默认有名为ib_logfile0和jb_logfile1的两个文件,log buffer中的日志在默认情况下就是刷新到这两个磁盘文件中。在将redo日志写入文件组中时,从ib_logfile0开始写起;如果ib_logfile0写满了,就接着ib_logfile1写;同理ib_logfile1写满了就去写ib_logfile2;以此类推。如果最后一个文件写满了,就重新转到ib_logfile0开始写。

log sequence number

自系统开始运行,就在不断地修改页面,这也就意味着会不断生成redo日志。redo日志量在不断递增。MySQL设计了一个名为lsn(log sequence number)的全局变量,用来记录当前总共已经写入的redo日志量。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日志产生得越早。

flushed_to_disk_lsn

redo日志是先写到log buffer中,之后才会被刷新到磁盘redo日志文件中。所以InnoDB提出了一个名为buf_next_to_write的全局变量,用来标记当前log_buffer中已经有哪些日志被刷新到磁盘中了。
前面说过,lsn表示当前系统中写入的redo日志量,这包括了写到log buffer但没有刷新到磁盘的redo日志。相应地,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日志都已经刷新到磁盘上。

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

因为lsn的值代表系统写入的redo日志量的一个总和。一个MTR中产生了多少个redo日志,lsn的值就增加多少。这样MTR产生的redo日志写到磁盘中时,很容易计算出一个lsn值在redo日志文件组中的偏移量。

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写入页a对应的控制块oldest_modification属性中;把mtr_1结束时对应的lsn写入页a对应的控制块newest_modification属性中。
假设mtr_2执行过程中修改了页b和页c这两个页面,那么在mtr_2执行结束时,就会将页b和页c的控制块都加入到flush链表的头部。接着需要把mtr_2开始时对应的lsn写入页b和页c对应的控制块的oldest_modification属性中,把mtr_2结束时对应的lsn写入页b和页c对应的控制块的newest_modification属性中。
mysql mtr.drawio
从上图可以看出,每次新插入到flush链表中的节点都放在了头部。也就是说在flush链表中,前面的脏页修改的时间比较晚,后面的脏页修改时间比较早。
接着假设mtr_3执行过程中修改了页b和页d,不过页b之前已经被修改过了,也就是它对应的控制块已经插入到flush链表中了,所以在mtr_3执行结束时,只需要将页d对应的控制块加入到flush链表的头部即可。接着需要把mtr_3开始时对应的lsn写入页d对应的控制块的oldest_modification属性中;把mtr_3结束时对应的lsn写入到页d对应的控制块的newest_modification属性中。另外,由于页b在mtr_3执行过程中又发生了一次修改,所以将页b对应的控制块中newest_modification的值更新为10000。
对上面所说的内容进行总结,就是flush链表中的脏页按照第一次修改发生的时间顺序进行排序,也就是按照oldest_modification代表的lsn值进行排序;被多次更新的页面不会重复插入到flush链表中,但是会更新oldest_modification属性的值。

checkpoint

有一个很不幸的事实就是redo日志文件组的容量是有限的,我们不得不选择循环使用redo日志文件组中的文件,但是这会造成最后写入的redo日志与最开始写入的redo日志追尾。这时应该想到:redo日志只是为了在系统崩溃后恢复脏页用的,如果对应的脏页已经刷新到磁盘中,那么即使现在系统崩溃,在重启后也用不着使用redo日志恢复该页面了。所以该redo日志也就没有存在的必要了,它占用的磁盘空间就可以被后续的redo日志所重用。也就是说,判断某些redo日志占用的磁盘空间是否可以覆盖的依据,就是它对应的脏页是否已经被刷新到磁盘中。
InnoDB提出了一个全局变量checkpoint_lsn,用来表示当前系统中可以被覆盖的redo日志总量是多少。这个变量的初始值是8704。
比如,现在页a被刷新到磁盘上,mtr_1生成的redo日志就可以被覆盖了,所以可以进行一个增加checkpoint_lsn操作。我们把这个过程称为执行一次checkpoint。
执行一次checkpoint可以分为两个步骤:

  • 步骤1:计算当前系统中可以覆盖的redo日志对应的lsn值最大是多少。
    redo日志可以被覆盖,这意味着它对应的脏页被刷新到磁盘中。只要我们计算出当前系统中最早修改的脏页对应的oldest_modification值,那么凡是系统在lsn值小于该节点的oldest_modification值时产生的redo日志都可以被覆盖掉。我们把该脏页的oldest_modification赋值给checkpoint_lsn。
  • 步骤2:将checkpoint_lsn与对应的redo日志文件组偏移量以及以此checkpoint的编号写到日志文件的管理信息中。

Sharp CheckPoint

刷新时机:当数据库关闭时将所有脏页都刷新回磁盘,这是默认的方式。

Fuzzy CheckPoint

刷新时机:
1、Master Thread Checkpoint
以每秒或每十秒的速度从缓冲池的脏页列表中刷新一定比例的页回磁盘,这个过程是异步的,此时InnoDB存储引擎可以进行其他的操作,用户查询线程不会阻塞。
2、FLUSH_LRU_LIST Checkpoint
若没有100个可用空闲页,那么InnoDB存储引擎会将LRU列表尾端的页移除。如果这些页中有脏页,那么需要进行Checkpoint
3、Async/Sync Flush Checkpoint
重做日志文件不可用的情况,这时需要强制将一些页刷新回磁盘
4、Dirty Page too much
脏页的数量太多,导致InnoDB存储引擎强制进行Checkpoint。
InnoDB维护了一个checkpoint_no变量,用来统计目前系统执行了多少次checkpoint;每执行一次checkpoint,该变量的值就增加1。
刷脏其实就是checkpoint

用户线程批量从flush链表中刷出脏页

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

崩溃恢复

确定恢复起点

前面说过,对于对应的lsn值小于checkpoint_lsn的redo日志来说,它们是可以被覆盖的。也就是说这些redo日志对应的脏页都已经被刷新到磁盘中了。既然这些脏页已经被刷盘,也就没必要恢复它们了。对于对应的lsn值不小于checkpoint_lsn的redo日志,它们对应的脏页可能没有刷盘,也可能被刷盘了,我们不能确定,所以需要从对应的lsn值为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之后再往下一个block中写。普通的block的log back header部分有一个名为LOG_BLOCK_HDR_DATA_LEN的属性,该属性值记录了当前block中使用了多少字节的空间。对于被填满的block来说,该值永远为512,。如果该属性值不为512,那么它就是此次崩溃恢复中需要扫描的最后一个block。也就是说在因崩溃恢复系统时,只需要从checkpoint_lsn在日志文件组中对应的偏移量开始,一直扫描redo日志文件中的block,直到某个block的LOG_BLOCK_DATA_LEN值不等于512为止。

怎么恢复

依次检查redo日志中对应的lsn值小于checkpoint_lsn,恢复时可以不管它。我们按照redo日志的顺序依次扫描checkpoint_lsn之后的各条redo日志,按照日志中记载的内容将对应的页面恢复过来。

  • 使用哈希表
    根据redo日志的spaceID和page number属性计算出哈希值,把spaceID和page number相同的redo日志放到哈希表的同一个槽中。如果有多个spaceID和page number都相同的redo日志,那么它们之间使用链表连接起来(按照生成的先后顺序连接)。
    之后就可以遍历哈希表。因为对同一页面进行修改的redo日志都放在了一个槽中,所以可以一次性将一个页面修复好(避免了很多读取页面的随机IO),这样就可以加速恢复速度。另外需要注意的一点是,同一个页面的redo日志是按照生成时间顺序进行排序的,所以恢复时也是按照这个顺序进行恢复。如果不按照生成时间顺序进行排序,那么可能出现错误。
  • 跳过已经刷新到磁盘中的页面
    前面说过,对于lsn值小于checkpoint_lsn的日志,它所对应的脏页肯定都已经刷到磁盘中了,但是对于lsn值不小于checkpoint_lsn的redo日志,它所对应的脏页不能确定是否已经刷新到磁盘中。原因是最近执行一次checkpoint后,后台线程可能又不断地从LRU链表和flush链表中将一些脏页刷出Buffer Pool。对于这些lsn值不小于checkpoint_lsn的redo日志,如果它们对应的脏页在崩溃发生时已经刷新到磁盘,那么在恢复时也就没有必要根据redo日志的内容修改该页面了。
    前面说过,每个页面都有一个称为File Header的部分。在Filer Header中有一个称为FIL_PAGE_LSN的属性,该属性记载了最近一次修改页面时对应的lsn(其实就是页面控制块中的newest_modification值)。如果在执行了某次checkpoint之后,有脏页被刷新到磁盘中,那么该页对应的FIL_PAGE_LSN代表的lsn值肯定大于checkpoint_lsn的值,凡是符合这种情况的页面就不需要根据lsn值小于FIL_PAGE_LSN的redo日志进行恢复了,所以这进一步提升了崩溃恢复的速度。

总结

redo日志记录了事务执行过程中都修改了哪些内容。
事务提交时只将执行过程中产生的redo日志刷新到磁盘,而不是将所有修改过的页面都刷新到磁盘。这样做有两个好处:

  • redo日志占用的空间非常小
  • redo日志是顺序写入磁盘的

一条redo日志一般由下面几部分组成。

  • type:这条redo日志的类型
  • spaceID:表空间ID
  • page number:页号
  • data:这条redo日志的具体内容

redo日志类型有简单和复杂之分。简单类型的redo日志是纯粹的物理日志,复杂类型的redo日志兼有物理日志和逻辑日志的特性。
一个MTR可以包含一组redo日志。再进行崩溃恢复时,这一组redo日志作为一个不可分割的整体来处理。
redo日志存放在大小为512字节的block中,每一个block被分为三部分。

  • log block header
  • log block body
  • log block trailer

redo日志缓冲区是一片连续的内存空间,由若干个block组成;可以通过启动选项innodb_log_buffer_size来调整它的大小。
redo日志文件由若干个日志文件组成,这些redo日志是被循环使用的。redo日志文件组中每个文件大小一样,格式也一样,都是有两部分组成:

  • 前2048个字节,用来存储一些管理信息
  • 从第2048字节往后的字节用来存储log_buffer中的block镜像

lsn指已经写入的redo日志量,flushed_to_disk_lsn指刷新到磁盘中的redo日志量。flush链表中的脏页按照修改发生的时间顺序进行排序,也就是按照old_modification代表的lsn值进行排序。被多次更新的页面不会重复插入到flush链表中,但是会更新newest_modification属性的值。checkpoint_lsn表示当前系统中可以被覆盖的redo日志总量是多少。
redo日志占用磁盘空间在它对应的脏页已经被刷新到磁盘后即可被覆盖。执行一次checkpoint的意思就是增加checkpoint_lsn的值,然后把相关的信息放到日志文件的管理信息中。
innodb_flush_log_at_trx_commit系统变量控制着在事务提交时是否将该事务运行过程中产生的redo刷新到磁盘。
在崩溃恢复过程中,从redo日志文件组第一个文件的管理信息中取出最近发生的那次checkpoint信息,然后从checkpoint_lsn在日志文件组中对应的偏移量开始,一直扫描日志文件中的block,直到某个block的LOG_BLOCK_HDR_DATA_LEN值不等于512为止。在恢复过程中,使用哈希表可加快恢复过程,并且会跳过已经刷新到磁盘的页面。

undo日志

事务回滚的需求

事务需要保证原子性,也就是事务中的操作要么全部完成,要么什么都不做。但是有事务执行一半会出现一些情况。

  • 事务执行过程中可能遇到各种错误,比如服务器本身的错误、操作系统错误、甚至是突然断电导致的错误;
  • 手动输入ROLLBACK命令。

这两种情况都会导致事务执行到一半就结束,但是事务在执行过程中可能已经修改了很多东西。为了保证事务的原子性,我们需要改回原来的样子,这个过程称为回滚(Rollback)。这就造成了一个假象:这个事务看起来什么都没做,所以符合原子性要求(有时候仅需要对部分语句进行回滚,有时候需要对整个事务进行回滚)。

设计者把这些为了回滚而记录的东西称为撤销日志(undo log)。这里需要注意的一点是,由于查询操作(SELECT)并不会修改任何用户记录,所以在执行查询操作时,并不需要记录相应的undo日志。

事务id

分配事务id的时机

一个事务可以是只读事务,也可以是一个读写事务。

  • 我们可以通过START TRANSACTION READ ONLY语句开启一个只读事务。在只读事务中,不可以对普通的表进行增删改操作。但可以对临时表进行增删改操作。
  • 我们可以通过START TRANSACTION READ WRITE语句开启一个读写事务。使用BEGIN、START TRANSACTION语句开启的事务默认也算是读写事务。在读写事务中可以对表执行增删改操作。

如果某个事务在执行过程中对某个表执行了增删改操作,那么innodb存储引擎就会给它分配一个独一无二的事务ID,分配方式如下:

  • 对于只读事务来说,只要在它第一次对某个用户创建的临时表执行增删改操作时,才会为这个事务分配事务ID,否则不分配事务ID。
  • 对于读写事务来说,只有在它第一次对某个表执行增删改操作时,才会为这个事务分配一个事务id,否则是不分配事务id的。

只有在事务对表中的记录进行改动时才会为这个事务分配一个唯一的事务id。

如果不为某个事务分配事务id,那么它的事务id的值为默认值0。

事务id是怎么生成的

这个事务id本质上是一个数字,它的分配策略与隐藏列row_id分配策略相同。

  • 服务器会在内存中维护一个全局变量,每当需要为某个事务分配事务id时,就会把该变量的值当作事务id分配给该事务,并且把该变量自增1。
  • 每当这个变量的值为256的倍数时,就会将该变量的值刷新到系统表空间中页号为5的页面中一个名为Max Trx ID的属性中,这个属性占用8字节的存储空间。
  • 当系统下一次重新启动时,会将这个Max Trx ID属性加载到内存中,将该值加上256之后赋值给前面提到的全局变量。

这样就可以保证整个系统中分配的事务ID值是一个递增的数字。先分配事务id的事务得到的是较小的事务id,后分配的事务id的事务得到的是较大的事务id。

trx_id隐藏列

前文在InnoDB记录行格式的时候重点强调过,聚簇索引的记录除了会保存完整的用户数据外,而且还会自动添加名为trx_id、roll_point的隐藏列。如果用户没有在表中定义主键以及不允许存储NULL值的UNIQUE键,还会自动添加一个名为rox_id的隐藏列。隐藏列row_id并不是必须的,其中trx_id列就是对这个聚簇索引记录进行改动的语句所在的事务对应的事务id而已。

undo日志的格式

为了实现事务的原子性,InnoDB存储引擎在实际进行记录的增删改操作时,都需要先把对应的undo日志记下来。一般每对一条记录进行一次改动,就对应着一条undo日志。一个事务在执行过程中可能新增、删除、更新若干条记录,也就是说需要记录很多条对应的undo日志,这些undo日志会从0开始编号,也就是说根据生成的顺序分别称为第0号undo日志、第1号undo日志等。这个编号也称为undo no。
这些undo日志被记录到类型为FIL_PAGE_UNDO_LOG的页面中。这些页面可以从系统表空间中分配,也可以从一种专门存放undo日志的表空间中分配。

INSERT操作对应的undo日志

前文说过,当向表中插入一条记录时会有乐观插入和悲观插入的区分。但是不管怎么插入,最终导致的就是这条记录被放到一个数据页中。如果希望回滚这个插入操作,那么把这条记录删除就好了。也就是说,在写对应的undo日志时,只要把这条记录的主键信息记上就好了。所以InnoDB设计了一个类型为TRX_UNDO_INSERT_REC的undo日志。
mysql undo.drawio

  • undo no 在一个事务中是从0开始递增的。也就是说,只要事务没有提交,每生成一条undo日志,那么该条日志的undo no就增1。
  • 如果记录中的主键只包含一列,那么在类型为TRX_UNDO_INSERT_REC的undo日志中,只需把该列占用的存储空间大小和真实记录下来。如果记录中的主键包含多个列,那么每个列的存储空间大小和对应的真实值都需要记录下来。

当我们向某个表中插入一条记录时,需要向聚簇索引和所有二级索引都插入一条记录,不过在记录undo日志时,我们只需要针对聚簇索引记录来记录一条undo日志就好了。聚簇索引记录和二级索引记录是一一对应的,我们在回滚insert操作时,只需要知道这条记录的主键信息,然后根据主键信息进行对应的删除操作。在执行删除操作时,就会把聚簇索引和所有二级索引中相应的记录都删掉。

现在向undo_demo表中插入两条记录:

BEGIN;
INSERT INTO undo_demo(id, key1, col) VALUES (1,'AWM','狙击枪'),(2,'M416','步枪');

因为记录的主键只包含一个id列,所以在对应的undo日志中只需要将待插入记录的id列占用的存储空间长度(id列的类型为INT,而INT类型占用的存储空间长度为4字节)和真实值记录下来。本例中插入了两条记录,所以会产生两条类型为TRX_UNDO_INSERT_REC的undo日志。
1、第一条undo日志的undo no为0,记录主键占用的存储空间长度为4,真实值为1如图所示。
mysql-undo-insert01.drawio
2、第二条undo日志的undo no为1,记录主键占用的存储空间长度为4,真实值为2如图所示。
mysql-undo-insert02.drawio
roll_point本质上就是一个指向记录对应的undo日志的指针。比如,我们在前面向undo_demo表中插入了2条记录,就意味着向聚簇索引和二级索引idx_key1中分别插入了2条记录,不过我们只需要针对聚簇索引来记录undo日志就好了。聚簇索引记录存放到类型为FIL_PAGE_INDEX的页面中(就是我们前边一直所说的数据页),undo日志存放到类型为FIL_PAGE_UNDO_LOG页面中。
mysql-roll-pointer.drawio
从图中可以看出,roll_pointer本质上就是一个指针,指向记录对应的undo日志。

DELETE操作对应的undo日志

我们知道,插入到页面中的记录会根据记录头信息中的next_record属性组成一个单向链表,我们可以把这个链表称为正常记录的链表。被删除的记录其实也会根据记录头信息中的next_record属性组成一个链表,只不过这个链表中的记录占用的存储空间时可以被重新利用,所以也称这个链表为垃圾链表。PAGE Header部分中有一个名为PAGE_FREE属性,它指向由被删除记录组成的垃圾链表中的头节点。
mysql-undo-note001.drawio
假设现在准备使用DELETE语句把正常记录链表中的最后一条记录删除,这个删除过程需要经历两个阶段。

  • 阶段1:仅仅将记录的deleted_flag标识位设置为1,其他的不做修改。这个阶段称为delete mark
    mysql-undo-note001.drawio (1)
    可以看到,在正常记录链表中,最后一条记录的deleted_flag值被设置为1,但是这条记录并没有加入到垃圾链表中。也就是说,此时记录既不是正常记录,也不是已删除记录,而是一种处于中间状态的记录。在删除语句所在的事务提交之前,被删除的记录一直处于这种中间状态。

mysql-undo-version-note-001.drawio

  • 阶段2:当该删除语句所在的事务提交之后,会有专门的线程来真正地把记录删除掉。所谓真正的删除,就是把该记录从正常记录链表中移除,并且加入到垃圾链表中。然后还需要调整一些页面的其他信息,比如页面中的用户记录的数量PAGE_N_RECS、上一次插入记录的位置PAGE_LAST_INSERT、垃圾链表头节点的指针PAGE_FREE、页面中可重用的字节数量PAGE_GARBAGE、以及页目录的一些信息等。InnoDB把这个阶段称为purge。
    在阶段2执行完后,这条记录就算是真正的被删除掉了。这条已删除记录占用的存储空间也就可以重新利用了。
    mysql-undo-note001.drawio (2)
    在将被删除记录加入到垃圾链表中时,实际上是加入到链表的头节点处,还会跟着PAGE_FREE属性的值。

前面说过在数据页结构时说过,页面的Page Header部分有一个名为PAGE_GARBAGE的属性。该属性记录着当前页面中可重用存储空间占用的总字节数。每当有已删除记录加入到垃圾链表后,都会把这个PAGE_GARBAGE属性的值加上已删除记录占用的存储空间大小。PAGE_FREE指向垃圾链表的头节点,之后每当新插入记录时,首先判断垃圾链表头节点代表的已删除记录所占用的存储空间是否足够容纳这条新插入的记录。如果无法容纳,就直接向页面申请新的空间来存储这条记录。如果可以容纳,那么直接重用这条已删除记录的存储空间,并让PAGE_FREE指向垃圾链表中的下一条已删除记录。这里还有一个问题:如果新插入的那条记录占用的存储空间,小于垃圾链表头节点对应的已删除记录占用的存储空间,那就意味着头节点对应的记录所占用的存储空间中,有一部分空间用不到,这部分空间就算是一个碎片空间。随着新记录越插越多,由此产生的碎片空间也可能越来越多。这些碎片空间岂不是永远都用不到了?其实也不是,这些碎片空间占用的存储空间大小会被统计到PAGE_GARBAGE属性中,这些碎片空间在整个页面快使用完前并不会重新利用。不过当页面快满时,如果再插入一条新记录,此时页面中并不能分配一条完整记录的空间。这个时候,会先看PAGE_GARBAGE的空间和剩余可利用的空间相加之后是否可以容纳这条记录。如果可以,InnoDB会尝试重新组织页内的记录。重新组织的过程就是先开辟一个临时页面,把页面内的记录依次插入一遍。因为依次插入记录时并不会产生碎片,之后再把临时页面的内容复制到本页面,这样就可以把那些碎片空间都解放出来。(很显然,重新组织页面内的记录会比较耗费性能)

从前面的描述可以看出,在执行一条删除语句的过程中,在删除语句所在的事务提交之前,只会经历阶段1,也就是delete mark阶段。而一旦事务提交,我们也就不需要再回滚这个事务了。所以在设计undo日志时,只需要考虑对删除操作在阶段1所做的影响进行回滚就好了。于是InnoDB设计了一个名为TRX_UNDO_DEL_MARK_REC类型的undo日志。
mysql-undo-delete-log.drawio

  • 在对一条记录进行delete_mark属性前,需要把该记录的trx_id和roll_point隐藏列的旧值都记到对应的undo日志中的trx_id和roll_point属性中。这样有一个好处就是可以通过undo日志的roll_point属性找到上一次对该记录进行改动时产生的undo日志。
  • 与类型为TRX_UNDO_INSERT_REC的undo日志不同,类型为TRX_UNDO_DEL_MARK_REC的undo日志还多了一个索引列各列信息的内容。也就是说,如果某个列被包含在某个索引中,那么它的相关信息就应该记录到这个索引列各列的信息部分。所谓的相关信息包括该列在记录中的位置(用pos表示)、该列占用的存储空间大小(用len表示)、该列实际值(用value表示)。所以,索引列各列的信息存储的实质内容就是一个<pos, len, value>的一个列表。这部分信息主要在事务提交后使用,用来对中间状态的记录进行真正的删除。
BEGIN;
INSERT INTO undo_demo(id, key1, col) VALUES(1,'AWM','狙击枪'),(2,'M16',;步枪;);
DELETE FROM undo_demo WHERE id = 1;
  • 因为这条undo日志是id为100的事务中产生的第3条undo日志,所以它对应的undo_no就是2。
  • 在对记录执行delete_mark操作时,记录的trx_id隐藏列的值是100(也就是说,该记录最近一次修改就发生在本事务中),所以把100填入日志的trx_id属性中。然后把记录的roll_pointer隐藏列的值取出来,填入undo日志的roll_pointer属性中。这样就可以通过undo日志的roll_pointer属性值找到上一次对该记录进行改动时产生的undo日志。
  • 由于undo_demo表中有2个索引(聚簇索引、二级索引idx_key1),因此只要包含在索引中的列,那么这个列在记录中的位置、占用存储空间的大小和实际值就需要存储到undo日志中。
    对于主键来说,它只包含了一个id列,存储到undo日志中的相关信息如下:
  • pos:id列是主键,也就是在记录的第一列,它对应的pos值为0。pos使用1字节来存储。
  • len:id列的类型为INT,占用4字节,所以len的值为4。len使用1字节来存储。
  • value:在被删除的记录中,id列的值为1,也就是value的值为1,value使用4字节来存储。

对于idx_key1来说,只包含了一个key1列,存储到undo日志中的相关信息如下:

  • pos:key1列排在id列、trx_id列、roll_pointer列之后,它对应的pos值为3,pos使用1字节来存储。
  • len:key1列的存储类型为VARCHAR(100),使用utf8字符集,被删除的记录实际存储的内容是’AWM’,所以一共占用3字节。也就是说len的值为3,len使用1字节来存储。
  • value:在被删除的记录中,key1列的值为’AWM’,也就是说value的值使用3字节来存储。

UPDATE操作对应的undo日志

在执行UPDATE语句时,InnoDB对更新主键和不更新主键这两种情况有截然不同的处理方案。

1、不更新主键
在不更新主键的情况下,又可以细分为被更新的列占用的存储空间不发生变化和发生变化两种情况。

  • 就地更新:在更新记录时,对于被更新的每个列来说,如果更新后的列与更新前的列占用的存储空间一样大,那么可以进行就地更新,也就是直接在原记录的基础上修改对应列的值。再强调一点,是每个列在更新前后占用的存储空间一样大,只要有任何一个被更新的列在更新前比更新后占用的空间大,或者在更新前比更新后占用的存储空间小,就不能进行就地更新。
  • 先删除旧记录,再插入新记录:在不更新主键的情况下,如果有任何一个被更新的列在更新前和更新后占用的存储空间大小不一致,那么就需要先把这条旧记录从聚簇索引页面中删除,然后再根据更新后列的值创建一条新的记录并插入到页面中。
  • 请注意,我们这里所说的删除并不是delete mark操作,而是真正的删除掉,也就是把这条记录从正常记录链表中移除并加入到垃圾链表中,并且修改页面中相应的统计信息。不过,这里执行真正删除操作的线程并不是在DELETE语句中进行purge操作时使用的专门的线程,而是由用户线程同步执行真正的删除操作。在真正删除操作之后,紧接着就要根据各个列更新后的值来创建一条新记录,然后把这条新记录插入到页面中。
  • 如果新创建的记录占用的存储空间不超过旧记录占用的空间,那么可以直接重用加入到垃圾链表中的旧记录所占用的存储空间,否则就需要在页面中新申请一块空间供新记录使用。如果本页面内已经没有可用空间,就需要进行页面分裂操作,然后再插入新记录。

针对UPDATE操作不更新主键的情况,InnoDB设计了一种类型为TRX_UNDO_DEL_MARK_REC类型的undo日志。
mysql-undo-update01.drawio
其实这个undo日志的大部分属性与前面介绍过的TRX_UNDO_DEL_MARK_REC类型的undo日志是类似的,不过还要注意下面几点。

  • n_updated属性表示在本条UPDATE语句执行后将有几个列被更新,后边跟着<pos, old_len, old_value>列表中的pos, old_len, old_value分别表示被更新列在记录中的位置、更新前该列占用的存储空间大小、更新该前列的真实值。
  • 如果在update语句中更新的列包含索引列,那么也会添加索引列各列的信息这个部分,否则不会添加这个部分。
    2、更新主键
    在聚簇索引中,记录按照主键值的大小连成了一个单向链表。如果我们更新了某条记录的主键值,意味着这条记录在聚簇索引中的位置将会发生改变。针对UPDATE语句中更新了记录主键值的这种情况,InnoDB在聚簇索引中分了两步进行处理。
  • 步骤1:将旧记录进行delete mark操作。
  • 步骤2:根据更新后各列的值创建一条新的记录,并将其插入到聚簇索引中。由于更新后的记录的主键值发生了该表,所以需要重新从聚簇索引中定位这条记录所在的位置,然后把它插进去。

针对UPDATE语句更新记录的主键值的这种情况,在对该记录进行delete mark操作时,会记录一条类型为TRX_UNDO_DEL_MARK_REC的undo日志;之后插入新记录时,会记录一条类型为TRX_UNDO_INSERT_REC的undo日志。也就是说,每对一条记录的主键值进行改动,都会记录2条undo日志。注意,此时由于更新主键值,所以两个操作undo日志属于不同的版本链。

增删改操作对二级索引的影响

更新二级索引记录的键值,就意味着要进行下面这两个操作。

  • 对旧的二级索引记录执行delete mark操作。
  • 根据更新后的值创建一条新的二级索引记录,然后在二级索引对应的B+树中重新定位它的位置并插进去。

另外需要强调的是,虽然只有聚簇索引记录才有trx_id、roll_pointer这些属性,不过每当我们增删改一条二级索引记录时,都会影响这条二级索引记录所在页面的Page Header部分中一个名为PAGE_MAX_TRX_ID的属性。这个属性代表修改当前页的最大事务id。

通用链表结构

前面主要讲了为什么需要undo日志,以及insert、delete、update这些用来改动数据的语句都会产生什么类型的undo日志,还有不同类型的undo日志的具体格式是什么。下面继续将这些undo日志被具体写到什么地方,以及在写入过程中需要注意的问题。在写入undo日志的过程中,会用到多个链表。很多链表都有相同的节点结构。
mysql-undo-list.drawio
在某个表空间内,我们可以通过一个页的页号与在页内的偏移量来唯一定位一个节点的位置。这两个信息相当于指向这个节点的一个指针,所以:

  • Prev Node Page Number和Prev Node Offset的组合就是指向前一个节点的指针。
  • Next Node Page Number和Next Node Offset的组合就是指向后一个节点的指针。

整个链表占用12字节的存储空间。
为了更好的管理链表,InnoDB还提出了一个基节点的结构。这个结构里面存储了这个链表的头节点、尾节点以及链表长度信息。链表基节点的结构示意图如下:

mysql-undo-head-list.drawio
其中:

  • List Length:表明该链表一共有多少个节点。
  • Prev Node Page Number和Prev Node Offset的组合就是指向前一个节点的指针。
  • Next Node Page Number和Next Node Offset的组合就是指向后一个节点的指针。

整个链表占用16字节的存储空间。

FIL_PAGE_UNDO_LOG页面

前面讲过,表空间其实是由许许多多的页面构成的,页面的默认大小为16kb。这些页面有不同的类型,比如类型为FIL_PAGE_INDEX的页面用于存储聚簇索引以及二级索引,类型为FIL_PAGE_TYPE_FSP_HDR的页面用于存储表空间头部信息。此外,还有其他各种类型的页面,其中还有一种名为FIL_PAGE_UNDO_LOG类型的页面是专门用来存储undo日志的。这种类型的页面的通用结构如图所示:
mysql-undo-page01.drawio
undo页面其中undo page header各个属性的意思如下:

  • TRX_UNDO_PAGE_TYPE:本页面准备存储什么类型的undo日志。
    前文介绍了好几种类型的undo日志,它们可以被分为两个大类。
  • TRX_UNDO_INSERT(使用十进制1表示):类型为TRX_UNDO_INSERT_REC的undo日志属于这个大类,一般由INSERT语句产生;当UPDATE语句有更新主键的情况时也会产生此类型的undo日志。我们把属于这个TRX_UNDO_INSERT大类的undo日志简称为insert undo日志。
  • TRX_UNDO_UPDATE(使用十进制2表示):除了类型为TRX_UNDO_INSERT_REC的undo日志,其他类型的undo日志都属于这个大类,比如前面说的TRX_UNDO_DEL_MARK_REC、TRX_UNDO_UPD_EXITS_REC等。一般由DELETE、UPDATE语句产生的undo日志属于这个大类。我们把属于这个TRX_UNDO_UPDATE大类的undo日志简称为update undo日志。

这个TRX_UNDO_PAGE_TYPE属性的可选值就是上面的两个,用来标记本页面用于存储哪个大类的undo日志。不同大类的undo日志不能混着存储,比如一个undo页面的TRX_UNDO_PAGE_TYPE属性值为TRX_UNDO_INSERT,那么这个页面就只能存储类型为TRX_UNDO_INSERT_REC的日志,其他类型的undo日志就不能放到这个页面中了。

之所以把undo日志分成2大类,是因为类型为TRX_UNDO_INSERT_REC的undo日志在事务提交后可以直接删除掉,而其他类型的undo日志还需要为MVVC服务,不能直接删除掉。

  • TRX_UNDO_PAGE_START:表示当前页面中从什么位置开始存储undo日志,或者说表示第一条undo日志在本页面中的起始偏移量。
  • TRX_UNDO_PAGE_FREE:与上面的TRX_UNDO_PAGE_START对应,表示当前页面中存储的最后一条undo日志结束时的偏移量;或者说从这个位置开始,可以继续写入新的undo日志。
  • TRX_UNDO_PAGE_NODE:代表一个链表节点结构。

undo页面链表

单个事务中的undo页面链表

因为一个事务可能包含多个语句,而且一个语句可能会对若干条记录进行改动,而对这条记录进行改动前(再强调一下,这里指的是聚簇索引记录),都需要记录1条或2条undo日志。所以在一个事务执行过程中可能产生很多undo日志。这些日志可能在同一个页面中放不下,需要放到多个页面中。这些页面就是通过前文介绍的TRX_UNDO_PAGE_NODE属性连成了链表。如下图:
mysql-undo-page-node.drawio
在一个事务的执行过程中,可能还会混着INSERT、DELETE、UPDATE语句,这也就意味着会产生不同类型的undo日志。但是前面强调过,同一个undo页面要么只存储TRX_UNDO_INSERT大类的日志,要么只存储TRX_UNDO_UPDATE大类的undo日志,不能混着存储。所以在一个事务的执行过程中就可能需要2个undo页面的链表;一个称为insert undo链表;另一个称为update undo链表。
mysql-undo-page-insert-update.drawio
另外,InnoDB规定,在对普通表和临时表的记录改动时所产生的的undo日志要分别记录。所以在一个事务中最多有4个以undo页面为节点组成的链表。
当然,并不是事务一开始就为它分配这4个链表,具体分配规则如下:

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

总是就是:按需分配,啥时候需要啥时候分配,不需要就不分配。

多个事务中的undo页面链表

为了尽可能提高undo日志的写入效率,不同事务执行过程中产生的undo日志需要写入不同的undo页面链表中。比如现在有事务id分别为1和2两个事务,我们分别称之为trx_1和trx_2。假设在这两个事务执行过程中,发生了如下操作。

  • trx_1对普通表执行DELETE操作,对临时表执行了INSERT和UPDATE操作。InnoDB会为trx_1分配了3个链表,分别是:
    针对普通表的update undo链表
    针对临时表的insert undo链表
    针对临时表的update undo链表
  • trx_2对普通表执行了INSERT、UPDATE和DELETE操作,没有改动临时表。InnoDB会为trx_2分配2个链表,分别是:
    针对普通表的insert undo链表
    针对普通表的update undo链表

综上所述,trx_1和trx_2的执行过程中,InnoDB共需为这两个事务分配5个undo页面链表。
mysql-undo-node-trx.drawio
如果有更多的事务,就意味着可能会产生更多的undo页面链表。

undo日志具体写入过程

段的概念

简单来讲,段是一个逻辑上的概念,本质上是由若干个零散的页面和若干个完整的区组成的。
比如,一个B+树索引被划分成两个段:一个是叶子节点段和一个非叶子节点段。这样叶子节点就可以被尽可能地存放到一起,非叶子节点被尽可能地存放到一起。每个段对应一个INODE_Entry结构。这个INDOE_Entry结构描述了这个段的各种信息,比如段的ID、段内的各种链表基节点、零散页面的页号有哪些等。

undo log segement header

InnoDB规定,每一个undo页面链表都对应着一个段,称为undo log segement。也就是说,链表中的页面都是从这个段中申请的,所以它们在undo页面链表的第一个页面中设计了undo log segement header的部分。这个部分包含了该链表对应的段的segement header信息,以及其他一些关于这个段的信息。
undo页面链表的第一个页面比普通页面多了个undo log segment header。

  • TRX_UNDO_STATE:本undo页面链表处于什么状态。可能的状态有下面几种。
    TRX_UNDO_ACTIVE:活跃状态,也就是一个活跃的事务正在向这个undo页面链表中写入undo日志。
    TRX_UNDO_CACHED:被缓存的状态,处于该状态的undo页面链表等待之后被其他事务重用。
    TRX_UNDO_TO_FREE:等待被释放的状态。对于insert unto链表来说,如果在它对应的事务提交之后,该链表不能被重用,那么就会处于这种状态。
    TRX_UNDO_TO_PURGE:等待被purge状态。对于update undo链表来说,如果在它对应的事务提交之后,该链表不能被重用,那么就会处于这种状态。
    TRX_UNDO_PREPARED:处于此状态的undo页面链表用于存储处于PREPARE阶段的事务产生的日志。
  • TRX_UNDO_LAST_LOG:本undo页面链表中最后一个undo log header的位置。
  • TRX_UNDO_FSEG_HEADER:本undo页面链表对应的段的segment header信息。
  • TRX_UNDO_PAGE_LIST:undo页面链表的基节点。

前面讲到,undo页面的undo page header部分有一个12字节大小的TRX_UNDO_PAGE_NODE属性,这个属性代表一个链表节点结构。每一个undo页面都包含TRX_UNDO_PAGE_NODE属性,这些页面可以通过这个属性连成一个链表。这个TRX_UNDO_PAGE_LIST属性代表这个链表的基节点,当然这个基节点只存在于undo页面链表的第一个页面中。

undo log header

一个事务在向undo日志页面中写入undo日志时,采用的方式是直接写入。写完一个页面后,再从段中申请一个新页面,然后把这个页面插入到undo日志链表中,继续往这个新申请的页面中写undo日志。
InnoDB认为,同一个事务向一个undo页面链表中写入的undo日志算是一个组。在每写入一组undo日志时,都会在这组undo日志前先记录一下关于这个组的一些属性。InnoDB把存储这些属性的地方称为undo log header。所以undo页面链表的第一个页面在真正写入undo日志前,其实都会被填充undo page header、undo log segement header、undo log header这3个部分。
mysql-undo-log-header.drawio

  • TRX_UNDO_TRX_ID:生成本组undo日志的事务id。
  • TRX_UNDO_TRX_NO:事务提交后生成的一个序号,此序号用来标记事务的提交顺序(先提交的序号小,后提交的序号大)。
  • TRX_UNDO_DEL_MARKS:标记本组undo日志中是否包含由delete mark产生的undo日志。
  • TRX_UNDO_LOG_START:表示本组undo日志中第一条undo日志在页面中的偏移量。
  • TRX_UNDO_XID_EXITS:本组undo日志是否包含XID信息。
  • TRX_UNDO_DICT_TRANS:标记本组undo日志是不是由DDL语句产生的。
  • TRX_UNDO_TABLE_ID:如果TRX_UNDO_DICT_TRANS为真,那么本属性表示DDL语句操作的表的table_id。
  • TRX_UNDO_NEXT_LOG:下一组undo日志在页面中开始的偏移量。
  • TRX_UNDO_PREV_LOG:上一组undo日志在页面中开始的偏移量

一般来说,一个undo页面链表只存储一个事务执行过程中产生的一组undo日志。但是在某些情况下,可能会在一个事务提交之后,后续开启的事务又重复利用这个undo页面链表,这就会导致一个undo页面中可能存放多组undo日志。TRX_UNDO_NEXT_LOG和TRX_UNDO_PREV_LOG就是用来标记下一组和上一组undo日志在页面中的偏移量的。

对于没有被重用的undo页面链表来说,链表的第一个页面(也就是first undo page)在真正写入undo日志之前,会填充undo page header、undo log segment header、undo log header这3个部分,之后才开始正式写入undo日志。对于其他页面(也就是normal undo page)来说,在真正写入undo日志前,只会填充undo page header。链表基节点存放到first undo page的undo log segment header部分,链表节点信息存放到每一个undo页面的undo page header部分。

重用undo页面

前面说到,为了提高并发执行的多个事务写入undo日志的性能,InnoDB决定为每个事务单独分配相应的undo页面链表(最多可能单独分配4个链表)。但是这也造成了一些问题,比如大部分事务在执行过程中可能只修改了一条或几条记录,针对某个undo页面链表只产生了非常少的undo日志,这些undo日志可能只占用一点点存储空间。每开启一个事务就新创建一个undo页面链表来存储这么一点undo日志岂不是太浪费了。于是InnoDB决定决定在事务提交后的某些情况下重用该事务的undo页面链表。一个undo页面链表如果可以被重用,那么他需要符合下面两个条件。

  • 该链表中只包含一个undo页面。如果一个事务在执行过程中产生了非常多的undo日志,那么它可能申请非常多的页面加入到undo页面链表中,在该事务提交后,如果将整个链表的页面都重用,那就意味着即使新的事务并没有向该undo页面链表中写入很多undo日志,该链表也得维护非常多的页面。那些用不到的页面也不能被别的事务所使用,这样就造成了另一种浪费。所以InnoDB规定,只有在undo页面链表中只包含一个undo页面时,该链表才可以被下一个事务所重用。
  • 该undo页面已经使用的空间小于整个页面空间的3/4。如果该undo页面已经使用了本页绝大部分的存储空间,那么重用该undo页面也得不到更多好处。

前面说过,按照存储的undo日志所属的大类,undo页面链表可以被分为insert undo链表和update undo链表两种。这两种链表在被重用时,策略也是不同的,我们分别看下。

  • insert undo链表:insert undo链表中只存储类型为TRX_UNDO_INSERT_REC的undo日志。这种类型的undo日志在事务提交之后就没用了,可以被清除掉。所以在某个事务提交后,在重用这个事务的insert undo链表时,可以直接把之前事务写入的一组undo日志覆盖掉,从头开始写入新事务的一组undo日志。假设有一个事务使用的insert undo链表。在事务提交时,只向insert undo链表中插入3条undo日志。这个insert undo链表只申请了一个undo页面。如果此时该页面已使用的空间小于整个页面大小的3/4,那么下一个事务就可以重用这个insert undo链表。假设此时有一个新事务重用了该insert undo链表,那么可以直接把一组旧的undo日志覆盖掉,写入一组新的undo日志。
  • update undo链表:在一个事务提交后,它的update undo链表中的undo日志不能立即删除掉。如果之后的事务想重用update undo链表,就不能覆盖之前事务写入的undo日志。

回滚段

我们知道,一个事务在执行过程中最多可以分配4个undo页面链表。在同一时刻,不同事务拥有的undo页面链表是不一样的,系统在同一时刻其实可以存在许多个undo页面链表。为了更好地管理这些链表,InnoDB又设计了一个名为Rollback Segment Header的页面。这个页面中存放了各个undo页面链表的first undo page的页号,这些页号称为undo slot。
InnoDB规定,每一个Rollback Segment Header页面都对应着一个段,这个段称为回滚段(Rollback Segment)。
Rollback Segment Header的页面中,各个部分的含义如下:

  • TRX_RSEG_MAX_SIZE:这个回滚段中管理的所有undo页面链表中的undo页面数量之和的最大值。换句话说,在这个回滚段中,所有undo页面链表中的undo页面数量之和不能超过TRX_RSEG_MAX_SIZE代表的值。该属性的值默认为无限大。
  • TRX_RSEG_HISTORY_SIZE:history链表中占用的页面数量。
  • TRX_RSEG_HISTORY:history链表的基节点。
  • TRX_RSEG_FSEG_HEADER:这个回滚段对应的10字节大小的segment header结构,通过它可以找到本回滚段对应的INODE ENTRY。
  • TRX_RSEG_UNDO_SLOTS:各个undo页面链表的first undo page的页号集合,也就是undo slot集合。

从回滚段中申请undo页面链表

在初始情况下,由于未向任何事务分配任何undo页面链表,所以对于一个Rollback Segment Header页面来说,它的各个undo slot都被设置为一个特殊的值:FIL_NULL,这表示该undo slot不指向任何页面。
随着时间的流逝,开始有事务需要分配undo页面链表了。于是从回滚段的第一个undo slot开始,看看该undo slot的值是否为FIL_NULL。

  • 如果是FIL_NULL,那么就在表空间中新创建一个段(也就是undo log segment),然后从段中申请一个页面作为undo页面链表的first undo page,最后把undo slot的值设置为刚刚申请的这个页面的地址。这也就意味着这个undo slot被分配了这个事务。
  • 如果不是FIL_NULL,说明该undo slot已经指向了一个undo链表。也就是说这个undo slot已经被别的事务占用了,这就需要跳到下一个undo slot,判断该undo slot的值是否为FIL_NULL,并重复上面的步骤。

一个Rollback Segment Header页面中包含1024个undo slot。如果这1024个undo slot的值都不为FIL_NULL已经被别的事务占用了。此时,由于新事务无法再获得新的undo页面链表,就会停止执行这个事务并且向用户报错。
当一个事务提交时,它所占用的undo slot有两种命运。

  • 如果该undo slot指向的undo页面链表符合被重用的条件,该undo slot就处于被缓存的状态。被缓存的undo slot都会被加入到一个链表中。不同的类型undo页面链表对应的undo slot会被加入到不同的链表中。
    如果对应的undo页面链表是insert undo链表,则该undo slot会被加入到insert undo cached链表中。
    如果对应的undo页面链表是update undo链表,则该undo slot会被加入到update undo cached链表中。
    一个回滚段对应着上述两个cached链表。如果有新事务要分配undo slot,都先从对应的cached链表中找。如果没有被缓存的undo slot,才会到回滚段的Rollback Segment Header页面中寻找。
  • 如果该undo slot指向的undo页面链表不符合被重用的条件,那么根据该undo slot对应的undo页面链表的类型不同,也会有不同的处理。
    如果对应的undo页面链表是insert undo链表,则该undo页面链表的TRX_UNDO_STATE属性会被设置为TRX_UNDO_TO_FREE。之后该undo页面链表对应的段会被释放掉,然后把该undo slot的值设置为FIL_NULL。
    如果对应的undo页面链表是update undo链表,则该undo页面链表的TRX_UNDO_STATE属性会被设置为TRX_UNDO_TO_PRUGE,并将该undo slot的值设置为FIL_NULL,然后将本次事务写入的一组undo日志放到history链表中。

roll_pointer的组成

前文说到,聚簇索引记录中包含一个名为roll_pointer的隐藏列,有些类型的undo日志包含一个名为roll_pointer的属性,这个属性的本质上就是一个指针,它指向一条undo日志的地址。这个roll_pointer由7字节组成,共包含四个属性
mysql roll pointer.drawio
其中各个属性的含义如下:

  • is_insert:表示该指针指向的Undo日志是否是TRX_UNDO_INSERT大类的undo日志。
  • rseg_id:表示该指针指向的undo日志的回滚段编号。
  • page_number:表示该指针指向的undo日志在页面的页号。
  • offset:表示该指针指向的undo日志在页面中的偏移量。

为事务分配undo页面链表的详细过程

1、事务在执行过程中对普通表的记录进行首次改动之前,首先会到系统表空间的第5号页面中分配一个回滚段(其实就是获取一个rollback segement header页面的地址)。一旦某个回滚段分配给了这个事务,那么之后该事务再对普通表的记录进行改动时,就不会重复分配了。
2、在分配到回滚段后,首先看下这个回滚段的两个cache链表有没有已经缓存的undo slot。如果事务执行的是insert操作,就去回滚段对应的insert undo cached链表中看看有没有缓存的undo slot;如果事务执行的是DELETE操作,就去回滚段对应的update undo cached链表中看看有没有缓存的undo slot。如果有缓存的undo slot,就把这个缓存的undo slot分配给该事务。
3、如果没有缓存undo slot可供分配,那么就要到rollback segement header页面中找一个可用的undo slot分配给当前事务。
4、找到可用的undo slot后,如果该undo slot是从cached链表中获取的,那么它对应的undo log segement就已经分配了;否则需要重新分配一个undo log segement,然后从该undo log segement中申请一个页面作为undo页面链表的first undo page,并把该页的页号填入获取的undo slot中。
5、然后事务就可以把undo日志写入到上面申请的undo链表中了。

undo日志在崩溃恢复时的作用

在服务器因为崩溃而恢复的过程中,首先按照redo日志将各个页面的数据恢复到崩溃之前的状态,这样就可以保证已经提交的事务的持久性。但是这里仍然存在一个问题,就是那些没有提交的事务写的redo日志可能也已经刷盘了,那么这些未提交的事务修改过的页面在MySQL服务器重启时可能也被恢复了。
为了保证事务的原子性,有必要在服务器重启时将这些未提交的事务回滚掉。
我们可以通过系统表空间的第5号页面定位到128个回滚段的位置,在每一个回滚段的1024个undo slot中找到那些值不为FIL_NULL的undo slot,每一个undo slot对应着一个undo页面链表。然后从undo页面链表的第一个页面的undo segement header找到TRX_UNDO_STATE属性,该属性标识当前undo页面链表所处的状态。如果该属性的值为TRX_UNDO_ACTIVE,则意味着有一个活跃的事务正在向这个undo页面链表中写入undo日志。然后在undo segement header中找到TRX_UNDO_LAST_LOG属性,通过该属性可以找到本undo页面链表最后一个undo log header的位置。从该undo log header中可以找到对应事务的事务id以及一些其他信息,则该事务id对应的事务就是未提交的事务。通过undo日志中记录的信息将该事务对页面所做的更改全部回滚掉,这样就保证了事务的原子性。

总结

为了保证事务的原子性,设计者引入了undo日志,undo日志记载了回滚一个操作所需的必要内容。
在事务对表中的记录进行改动时,才会为这个事务分配一个唯一的事务id。事务id值是一个递增的数据。先被分配id的事务得到的是较小的事务id,后被分配id的事务得到的是较大的事务id。未被分配事务id的事务的事务id默认是0.聚簇索引记录中有一个trx_id隐藏列,它代表对这个聚簇索引记录进行改动的语句所在的事务对应的事务id。
类型为FIL_PAGE_UNION_LOG的页面是专门用来存储undo日志的,我们简称为Undo页面。
在一个事务执行过程中,最多分配4个Undo页面链表,分别是:

  • 针对普通表的insert undo链表
  • 针对普通表的update undo链表
  • 针对临时表的insert undo链表
  • 针对临时表的update undo链表

只有在真正用到这些链表的时候才回去创建它们。
每个Undo页面链表都对应一个Undo Log Segment。Undo页面链表的第一个页面中有一个名为undo log segement header 部分。专门用来存储关于这个段的一些信息。