简单聊聊Innodb崩溃恢复那些事

简单聊聊Innodb崩溃恢复那些事

  • Buffer Pool 整体架构
  • Buffer Pool 管理策略
    • redo 日志
      • redo log file
      • Mini-Transaction
      • CheckPoint
        • 部分写出问题
      • 崩溃恢复
    • undo日志
      • 整体结构
      • 日志格式
      • 记录格式
      • purge
      • 回滚
  • 总结
  • 额外参考


本文想用简单精炼的语言将Innodb崩溃恢复那些事情好好拾到拾到,本文主要参考以下三本书和我个人一些感想而作:

  • Innodb技术内幕第二版
  • Mysql运维内参
  • 从根上理解Mysql

关于一些辅助资料有:

  • CMU 15-445 数据库基础课程
  • Mit 6.830 数据库基础课程
  • Aries论文
  • 数据库系统概念-原书第6版

还有一些额外参考的博客,链接已在文章末尾贴出。

本文作为一篇闲谈文章,细节不会深入讲解,后续会考虑出源码解析文章,结合源码深入聊聊崩溃恢复的整个过程。


Buffer Pool 整体架构

Innodb中的Buffer Pool作为磁盘数据页在内存中的页缓存池,负责管理索引页,数据页,undo页,插入缓存,自适应哈希索引,锁信息,数据字典信息等。

Mysql 5.7.5 之前,Buffer Pool大小在运行时不支持动态调整大小,而 5.7.5 版本后将Buffer Pool调整为由多个chunk组成,当需要扩容Buffer Pool大小时,只需要单独向操作系统以chunk为单位进行空间申请即可,无需再向操作系统申请一大片连续内存空间,然后再将旧的Buffer Pool内容复制过去。

每个chunk由若干缓存页与其对应的控制块组成,控制块具体包含如下四个部分:

  • 其对应的页面地址frame
  • 页信息结构buf_page_t , 该结构用来描述一个页面的信息,包括所属表空间ID,页面号,被修改时产生的LSN(newest_modification 和 oldest_modification),使用状态等
  • 保护该页面的互斥量mutex
  • 访问页面时对该页面上的锁lock(read/write)等
    简单聊聊Innodb崩溃恢复那些事_第1张图片

Buffer Pool通过free链表管理空闲页,通过Flush链表管理脏页,通过LRU链表存放所有被访问或者修改过的页,同时Buffer Pool内部还持有一把mutex锁,用于确保一个实例只能由一个线程访问。

Buffer Pool实际分配过程中,页面从后往前分配,而控制结构从前往后分配,因此,在一般情况下,中间的会剩余一部分没有被使用,因为剩余空间不能再放得下一个控制结构和页面了。

在初始化每一个页面之后,都需要将每个页面加入到Free链表中去。

同时为了通过表空间号+页号快速定位一个Page,Buffer Pool还需要以表空间号+页号作为key,缓存页作为value创建一个哈希表,在需要访问某个页数据时,先从哈希表中根据表空间+页号看看有无对应的缓存页,如果有,直接使用,否则,从free链表中选择一个空闲的缓存页,然后把磁盘中对应的页加载到该缓存页的位置即可。

关于LRU链表的组织,分别针对预读和全表扫描做了对应的优化:

  • 预读: 将LRU链表分为冷数据区域和热数据区域,初次从磁盘加载到Buffer Pool的页面会被加入old区域头部,如果这些预读页面后续不被访问,那么会渐渐从old区域逐出,而不会影响young区域中被频繁使用的页
  • 全表扫描: 由于innodb将从页面读取一条记录算作对页面的一次访问,所以针对全表扫描这种场景,每个被加载上来的页面短时间内都会被多次访问,但是访问完后,就不会再被访问了,因此我们可以通过时间限制来判断是否需要将某个被多次访问页面移动到热数据区域
  • 热点页面: 热点页面需要频繁被访问,所以对应页面需要频繁调整到LRU链表头部,此时我们限制只有当被访问的缓存页位于热点区域后面3/4区域时,才会被移动到LRU链表头部,以此来降低调整LRU链表的频率。

Buffer Pool作为一个共享资源,在并发环境下必定存在资源竞争问题,此时我们可以借助分而治之思想,采用多缓冲池实例的方式,将用户请求根据访问页的哈希值不同平均负载到不同的缓冲池实例上:

简单聊聊Innodb崩溃恢复那些事_第2张图片


Buffer Pool 管理策略

当我们修改数据时,DBMS需要保证两点:

  • 事务成功提交前,数据必须已经持久化成功
  • 如果事务中止,任何修改都不应该持久化

如果遇上了事务故障或者系统故障,DBMS需要通过相关恢复手段来确保数据一致性,通常有以下两种思路:

  • undo : 将中止或未完成的事务中已经指向的操作回滚
  • redo : 将提交的事务执行的操作重做

DBMS 如何支持 undo/redo 取决于它如何管理 buffer pool , 我们可以从两个角度来分析一下buffer pool的管理策略:

  • steal policy : 是否允许一个未提交事务修改持久化到磁盘
    • steal : 允许 ,崩溃恢复过程中需要将最新一次checkpoint时间点时活跃的未提交事务做出的修改操作进行回滚
    • no_steal : 不允许 ,每次checkpoint时可能都需要等待当前所有活跃事务结束,同时禁止新的事务开始,确保不会产生部分写出问题
  • force policy : 事务提交是否需要把所有更新立刻持久化到磁盘
    • force : 事务提交时必须把相关更新立刻持久化到磁盘
    • no_force : 事务提交时需要把相关更新持久化到磁盘,可以采用异步批量更新,因此我们需要记录redo log日志,防止此过程中系统崩溃,导致已经提交的事务修改丢失

Innodb采用的是 steal + no_force 策略,这也符合Aries这篇论文的核心思想 ,简单来说可以总结为三点:

  • Write - Ahead Logging (WAL)
    • 数据落盘前,所有写操作都必须记录在日志中并落盘
    • 必须使用steal + no_force 缓存管理策略
  • Redo Log
    • 当DBMS重启时,按照日志记录的内容重做数据,恢复到故障发生前的状态
  • Undo Log
    • 在undo过程中记录undo操作到日志中,确保在崩溃恢复期间再次出现故障时不会执行多次相同的undo操作

Innodb 具体实现与Aries这篇论文的思想还是有些区别的,具体区别简述如下:

  • innodb不会通过redo log记录checkpoint时的活跃事务列表,innodb在崩溃恢复过程分为两段: redo 和 undo

    • redo阶段: 从最新一次checkpoint lsn往后进行扫描,依次进行重放,确保redo阶段结束后,数据库的完整性,但是此时数据库还可能存在脏数据,因为有未提交事务的修改提前落盘了,这些修改需要进行回滚
    • undo阶段: 扫描undo日志,判断哪些undo日志在崩溃时还处于活跃状态,将这些undo日志进行重放,即回滚这些未提交事务已经做出的修改
  • aries论文中提到的redo日志会在checkpoint时的redo日志中记录当前时刻的活跃事务列表,然后崩溃恢复阶段会做以下事情:

    • 从最新一次checkpoint lsn往后扫描所有redo日志,同时区分区分哪些活跃事务在崩溃前提交了,哪些未提交
    • 将崩溃前完成了事务提交的活跃事务redo日志进行重放
    • 将崩溃前未完成事务提交的活跃事务,根据其redo日志进行回滚,同时回滚期间记录下对应的CLR(Compensation Log Records),防止崩溃恢复期间重复执行多次相同的undo操作

redo 日志

redo日志作用有哪些:

  • 崩溃恢复
  • 通过延迟脏页刷新,可以合并多次写入,省去了大量磁盘IO操作

redo日志记录的是针对页面的做出的物理修改,所以其日志基本格式为:
简单聊聊Innodb崩溃恢复那些事_第3张图片
虽然说redo记录的是对页面做出的物理层面修改记录,但是,比如一条简单的插入语句都可能会涉及多个页面的修改,如果真的是完全记录在页面哪个偏移量上做出了什么修改,可能会产生比原页面数据还要大的redo日志。

因此innodb引入了多种redo日志类型,实际来说还是逻辑日志,但是更偏向底层,使得日志占用空间比全物理日志要少很多,同时我们也可以自己编写REDO解析工具,了解数据库做了什么,类似canal监听binlog一样,所以总结一下就是:

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

逻辑日志最大缺点就是需要首先保障日志对应页面的正确性,否则会造成逻辑日志执行不成功,或者造成数据不一致的问题,这个问题在Innodb中的解决方式,就是常说的Double Write机制,核心思想就是:

  • 脏页刷盘过程是先写入double write file中,写入成功后,再将脏页刷回表空间文件中
  • 崩溃恢复中,数据库都会检查页面是否合法,如果发现一个页面校验结果不一致,则此时会用到两次写机制,用两次写空间中的数据来恢复异常页面的数据

redo log file

redo log buffer 在内存中是一段连续的内存空间,被划分为了若干512字节大小的block,而对应的redo log file 也是由若干512字节大小的block组成的:

简单聊聊Innodb崩溃恢复那些事_第4张图片
关于每个redo log file 头信息块构成如下图所示:
在这里插入图片描述
Innodb 日志文件组默认包括2个日志文件,日志最小增量为一个MTR(下节会讲),日志文件轮询一圈,采用循环写入的方式。

在InnoDB中,通过日志组来管理日志文件,是一个逻辑定义,包含若干个日志文件,一个组中的日志文件大小相等,大小通过参数来设置。现在InnoDB只 持一个日志组。在MySQL 5.5及之前的版本中,整个日志组的容量不能大于4GB(实际上是3.9GB多,因为还有一些文件头信息等),到了MySQL 5.6.3版本之后,整个日志组的容量可以设置得很大,最大可以达到512GB。
简单聊聊Innodb崩溃恢复那些事_第5张图片
日志组中的每一个日志文件,都有自己的格式,内部也是按照大小相等的页面切割,但这里的页面大小是512个字节,由于历史的原因,考虑到机械硬盘的块大小是512字节,日志块大小也如此设计。这是因为写日志其实就是为了提高数据库写入吞吐量,如果每次写入是磁盘块大小的倍数,效率才是最高的,并且日志将逻辑事务对数据库的分散随机写入转化成了顺序的512字节整数倍数据的写入,这样就大大提高了数据库的效率。


Mini-Transaction

写日志是一个物理操作,它也需要一个完整性,比如在底层页面插入一条记录,如果只修改页头信息而没有修改页尾信息,其实对于这个页面来说是不完整的,所以这个物理操作还是需要一个机制来保证它的完整性的。在Innodb中,这个机制也被称为MTR,可以理解为物理事务,因为它也是用来保证完整性的。

物理事务既然也被称为事务,那么其同样有事务的开始和提交,物理事务的开始就是对mtr_struct结构体的初始化,其包含下列属性:

  • memo : 动态数组空间,用于存储当前物理事务访问到的所有页面,这些页面都被当前物理事务加上了锁(读锁或者写锁) --> 这里说的是latch
  • log : 动态数组空间,用于存储当前物理事务在访问修改数据页面过程中产生的所有日志,也就是redo日志
  • n_log_recs : 物理事务产生的日志量
  • log_mode : 物理事务的日志模式,包括MTR_LOG_ALL(写日志),MTR_LOG_NONE(不写日志) 等
  • start_lsn : 物理事务开始前的LSN
  • end_lsn : 物理事务提交后产生的新的LSN

物理事务执行过程中,需要对访问到的页面加上对应的latch锁,页面当前是否上锁,可以通过页面控制块中记录的锁信息获知,如果获取某个页面锁成功,则将当前页面加入memo数组,否则需要等待直到锁释放。

物理事务执行过程中涉及到写操作(MTR_LOG_ALL),则需要对写操作记录日志,这里的日志就是逻辑事务中提到的Redo日志。写下相应的日志后,同样将其存储到上面的log动态数组中,同时将n_log_recs计数器自增。

下面聊聊物理事务提交的过程,首先redo日志不完全是物理日志,它包含了部分逻辑意义在里面,比如插入一行记录的时候,MTR记录的是在一个页面中写入这条记录,内容大致包括页面号,文件号及这条记录每列的值,这样就有了逻辑概念。需要注意的是,在Redo恢复时,需要保证这个页面是正确的,完整的,不然这个REDO就会失败,这也真是DOUBLE WRITE存在的意义。 如果记录的是纯物理的REDO,日志内容应该会拆分的更散,比如: 插入一条记录,它会记录页面号,表空间号,页内偏移值,并且有多条这也的记录,因为会涉及多个位置的修改,这就没有任何逻辑内容了。而针对一个插入操作,需要在一个页面内的不同位置写入不同的数据,当然如果是纯物理REDO,相应地会产生多条REDO记录,这是物理与逻辑的简单区别。

对于MTR的提交而言,一个逻辑事务是由多个物理事务组成的,物理事务可以保证一次物理修改的原子性,比如插入一条记录的过程中,会包括写一条回滚记录及插入时写入一个页面等,这些逻辑上是一个动作的物理写入,可以被认为是一个独立的物理事务,也就是写回滚记录时只需mtr_start ,写完之后只需mtr_commit ,真正插入时写入一个页面也是同样的道理。
简单聊聊Innodb崩溃恢复那些事_第6张图片
物理事务和逻辑事务一样,也是可以保证数据库操作的完整性的。一般说来,一个操作必须要在一个物理事务中完成,也就是说要么这个操作已经完成,要么什么也没有做,否则就有可能造成数据不完整的问题,因为在数据库系统做REDO操作时是以一个物理事务为单位做的,如果一个物理事务的日志是不完整的,则它对应的所有日志都不会重做。那么,如何辨别一个物理事务是否完整呢?这个问题是在物理事务提交时用了一个很巧妙的方法来保证的。在提交前,如果发现这个物理事务有日志,则在日志最后再写一些特殊的日志,这些特殊的日志就是一个物理事务结束的标志,提交时一起将这些特殊的日志写入,在重做时如果当前这一批日志信息最后面存在这个标志,则说明这些日志是完整的,否则就是不完整的,就不会重做。

一个事务可以包含多个SQL语句,每条语句由若干mtr组成,每一个mtr又可以保护若干redo日志:

简单聊聊Innodb崩溃恢复那些事_第7张图片
MTR提交时将物理事务产生的日志写入到InnoDB日志系统的日志缓冲区中,然后等待后台master线程定时将日志系统的日志缓冲区数据刷到日志文件中,这会涉及到日志刷盘时机的问题 。Mtr ,日志缓冲区与日志文件之间的关系如下:

简单聊聊Innodb崩溃恢复那些事_第8张图片
如上图所示,左边的若干MTR产生了各自的REDO LOG,有些MTR已经提交了,有些正在写入,正在写入日志的MTR,它们的日志都存储在自己的MTR结构的log动态数组中,这个MTR还是不完整的,所以还是自己保存着,而对于那些已经提交的MTR,它们对应的日志已经在提交的时候转存到了日志缓冲区中,相当于这些日志已经落盘了,除非此时数据库挂了。

物理事务提交时还有一项很重要的工作就是处理上面结构体中动态数组memo中的内容,现在已经知道这个数组中存储的是这个物理事务访问过的所有页面,并且都已经上了锁。在它提交时,如果发现这些页面中已经有被修改过的,这些页面就成了脏页,这些脏页需要被加入到InnoDBBuffer Pool中的flush链表中(讲BUFFER时已经讲过)。当然,如果已经在flush链表中,则直接跳过(不能重复加入),svr_master_thread线程会定时检查这个链表,将一定数目的脏页刷到磁盘中,加入之后还需要将这个页面上的锁释放掉,表示这个页面已经处理完成;如果页面没有被修改,或者只是用来读取数据的,则只需要直接将其共享锁(S锁)释放掉即可。

日志缓冲区也是有大小的,当多个MTR提交时,缓冲区被占满了,那么此时系统会将日志缓冲区的日志刷到日志文件中(这里涉及的另一个问题就是日志刷盘时机,这里只是一种情况,其他的后面做专门介绍),为其他新的MTR释放空间。此时,日志的流向就是从中间的日志缓冲区向右边的日志文件转移,转移其实是平移,在缓冲区是什么内容,写入文件也是什么内容,也是完全连续的,且在日志文件中,还是一个个的MTR连续存储。

最新写入日志文件的那个MTR产生的LSN值,也就是日志最新写入文件的LSN值,这个值的意义很重大,表示的是,到这个LSN为止,所有的修改都是完整的了,如果此时数据库挂了,写到这个位置的数据都是可以恢复的,而不需要去关心Buffer页面是不是被刷到磁盘。但此时在日志缓冲区中的日志所对应的操作就丢失了,这里是否会丢失事务数据与参数innodb_flush_log_at_trx_comm it有关系:

  • 如果将参数innodb_flush_log_at_trx_comm it设置为1,当前事务的提交肯定会将日志缓冲区中的日志刷到日志文件中;
  • 如果设置为2,那么日志只是写入了操作系统缓存,并没有写入磁盘,那么此时有可能丢失部分已经提交的事务,丢失多少由操作系统决定,这种情况下,即使数据库挂了,只要机器不挂,就问题不大,因为操作系统还会将它对应的缓存写入磁盘;
  • 但如果设置为0的话,就无能为力了,因为InnoDB只负责将事务对应的日志写入到日志缓冲区中,无论是操作系统,还是数据库,都不能保证日志的安全性,所以最好不要设置成这样。

CheckPoint

redo log日志文件大小是有限的,不可能无限量将日志写入日志文件中,并且redo log file本身采用循环写入,一旦日志文件填满了,就不能继续写入了,因此我们需要定时刷新脏页到磁盘,从而释放掉那些无用的redo日志,这个过程被称为checkpoint。

Innodb 通过后台Master线程定时将flush链表中的脏页刷回磁盘,脏页刷回磁盘时,会触发对应的checkpoint,推进全局checkpoint LSN的值,同时更新log file头信息块中的对应的checkpoint块。checkpoint LSN之前的redo日志对应的脏页都已经刷回磁盘,而之后的日志对应的脏页还未刷入磁盘,所以如果此时系统崩溃,重启时,需要从log file头信息块中记录的checkpoint LSN为起点,向后扫描所有redo日志,依次进行重放。

flush链表中的脏页会按照页面第一次修改时间从大到小进行排序,每个页面关联的控制块中都有以下两个属性用于记录页面何时被修改的信息:

  • oldest_modification : 如果某个页面被加载到buffer pool后进行第一次修改,那么就将该次修改关联的mtr开始时对应的LSN值赋值给该属性
  • newest_modification :每修改一次页面,都会将修改该页面的mtr结束时对应的lsn值写入这个属性,也就是说该属性代表页面最近一次修改后对应的系统的lsn值

简单聊聊Innodb崩溃恢复那些事_第9张图片
日志是循环使用的,不能跳着写,因此每次checkpoint的时候是从LSN值最小的日志开始,按照从小到大的顺序不断让这些日志失效,因此刷脏时,也是从flush链表尾部往前进行刷脏,同时将当前flush链表尾部最早被修改的脏页的oldest_modification赋值给当前checkpoint lsn,也就是完成了checkpoint指针的推进过程。

例如: 本次刷新脏页c,然后脏页a称为了最早被修改的脏页,此时将脏页a的oldest_modification赋值给当前checkpoint lsn,此时我们可以确保凡是lsn小于该值的脏页都已经落盘了,那么也意味着小于该lsn的redo日志占用空间都可以被回收了。


部分写出问题

innodb每次checkpoint时,都是从flush链表尾部取出最早被修改的脏页进行刷盘,那么这是否存在部分写出问题呢?checkpoint lsn 之前的redo日志中是否包含当前未提交事务产生的修改呢?

答: 部分写出问题肯定是存在,即便是checkpoint lsn之前的redo日志也有可能包含未提交事务做出的修改

举例:

  1. 假设缓存池中目前存在三个脏页,其对应的事务都未提交,此时我们想要加载page 4到缓存池中,然后此时需要淘汰最早被修改的page 1
  2. 此时checkpoint被更新为0
  3. 此时再将page 1加载到缓存池中,然后此时需要淘汰最早被修改的page 2
  4. 此时checkpoint被更新为120

最终可以看到checkpoint lsn被更新为了120,只是说明checkpoint lsn之前的脏页都已经落盘了,但是无法确保此时磁盘上不存在未提交事务做出的修改。

但是我们不需要担心这个问题,因为innodb对于redo日志的定位就是确保我们可以利用redo日志重放,将数据库状态恢复到崩溃前的样子,然后再利用undo日志完成未提交事务产生修改的回滚操作。


崩溃恢复

崩溃恢复整个过程由redo和undo两个阶段完成,本节我们先来看看redo阶段是如何将数据库恢复到其崩溃前的模样的。

Innodb会维护一个全局LSN变量用于记录已经向redo log buffer写入的redo日志大小,同时维护一个全局flushed_to_disk_lsn变量用于记录已经刷到磁盘上的redo log日志大小,如下图所示:
简单聊聊Innodb崩溃恢复那些事_第10张图片
关于崩溃恢复,首先我们需要确定崩溃恢复的起点:

  • checkpoint lsn之前的redo日志都可以被覆盖,因为这些redo日志对应的脏页都已经刷新到磁盘中了;
  • checkpoint lsn之后的redo日志,它们对应的脏页可能还没有刷盘,也可能刷盘了,因此需要重放这些redo日志来恢复页面;
  • 获取redo log日志文件的checkpoint block中存储的最新一次checkpoint lsn ,该值为崩溃恢复的起点

下一步是确定崩溃恢复的终点:

  • 普通block的log block header部分有一个称之为LOG_BLOCK_HDR_DATA_LEN的属性,该属性值记录了当前block里使用了多少字节的空间,对于被填满的block来说,该值永远为512。
  • 如果该属性的值不为512,那么它就是此处崩溃恢复中需要扫描的最后一个block。
    简单聊聊Innodb崩溃恢复那些事_第11张图片

最后一步就是确定如何进行恢复了:

  1. 从checkpoint lsn为起点往后依次扫描每一条redo日志

简单聊聊Innodb崩溃恢复那些事_第12张图片

  1. 根据redo日志的表空间id和页面号计算出散列值,把表空间号和页面号相同的redo日志放到哈希表同一个槽里,也就是将属于同一个页面的redo日志采用链表的形式,按照生成的先后顺序链接起来

简单聊聊Innodb崩溃恢复那些事_第13张图片

  1. 遍历哈希表,因为对同一个页面进行修改的redo日志都放在了一起,所以可以一次性将一个页面修复好,因此这里只需要依次修复每个页面即可
  2. 每个页面对应的控制块存在一个o_m和n_m,每个页面的File Header中的FIL_PAGE_LSN也会保存当前页面最近一次修改产生的lsn,该值与n_m是对应的;每个脏页被刷盘时,其FIL_PAGE_LSN的值很大概率是比checkpoint lsn的值大的,因此我们在对当前页面进行修复时,可以跳过比FIL_PAGE_LSN小的redo日志的重放。

之所以说当前被刷脏页的FIL_PAGE_LSN可能比checkpoint lsn大,一个场景就是不断有事务更新该脏页,但是该脏页确实是最早被修改的脏页。

崩溃恢复在经过了redo阶段后,就将数据库恢复到了崩溃恢复前的模样,下一步我们就需要进入undo阶段,将崩溃恢复前未提交的事务进行回滚了。

InnoDB的REDO是在UNDO之前做的,是等到物理的数据库操作都完成之后,才能在物理数据一致的基础上去做一些逻辑的操作,即UNDO回滚操作


undo日志

undo日志的作用有如下三点:

  • rollback回滚
  • mvcc非锁定读
  • 崩溃恢复的undo阶段用于回滚未提交事务产生的修改

InnoDB存储引擎对undo的管理采用段的方式。首先InnoDB存储引擎有rollback segment,每个回滚段中记录了1024个undo log segment,而在每个undo log segment段中进行undo页的申请。共享表空间偏移量为5的页(0,5)记录了所有rollback segment header所在的页,这个页的类型为FIL_PAGE_TYPE_SYS

在InnoDB1.1版本之前(不包括1.1版本),只有一个rollback segment,因此支持同时在线的事务限制为1024。虽然对绝大多数的应用来说都已经够用,但不管怎么说这是一个瓶颈。从1.1版本开始InnoDB支持最大128个rollback segment,故其支持同时在线的事务限制提高到了128*1024。

事务在undo log segment分配页并写入undo log的这个过程同样需要写入重做日志。当事务提交时,InnoDB存储引擎会做以下两件事情:

  • 将undo log放入History列表中,以供之后的purge操作
  • 判断undo log所在的页是否可以重用,若可以分配给下个事务使用

事务提交后并不能马上删除undo log及undo log所在的页。这是因为可能还有其他事务需要通过undo log来得到行记录之前的版本。故事务提交时将undo log放入History链表中,是否可以最终删除undo log及undo log所在页由purge线程来判断。

此外,若为每一个事务分配一个单独的undo页会非常浪费存储空间,特别是对于OLTP的应用类型。因为在事务提交时,可能并不能马上释放页。假设某应用的删除和更新操作的TPS(transaction per second)为1000,为每个事务分配一个undo页,那么一分钟就需要1000*60个页,大约需要的存储空间为1GB。若每秒的purge页的数量为20,这样的设计对磁盘空间有着相当高的要求。

因此,在InnoDB存储引擎的设计中对undo页可以进行重用。具体来说,当事务提交时,首先将undo log放入History链表中,然后判断undo页的使用空间是否小于3/4,若是则表示该undo页可以被重用,之后新的undo log记录在当前undo log的后面。由于存放undo log的列表是以记录进行组织的,而undo页可能存放着不同事务的undo log,因此purge操作需要涉及磁盘的离散读取操作,是一个比较缓慢的过程。


整体结构

Innodb使用5号页面来存储事务相关信息:
简单聊聊Innodb崩溃恢复那些事_第14张图片
5号页面格式解释如下:

  • TRX_SYS_TRX_ID_STORE:用来存储事务号

    • 在每次新启动一个事务时,都会去检查当前最大事务号是不是达到了TRX_SYS_TRX_ID_WRITE_MARGIN(256)的倍数,如果达到了,就会将最大的事务号写入这个位置,在下次启动时,将这个值取出来,再加上一个步长(256),来保证事务号的唯一性,其实就是一个经典取号器的实现原理。
  • TRX_SYS_FSEG_HEADER:用来存储事务段信息。

  • TRX_SYS_RSEGS:这是一个数组,InnoDB有128个回滚段,那这个数组的长度就是128,每一个元素占用8个字节,对应的一个回滚段存储的内容包括回滚段首页面的表空间ID号及页面号。

针对每个回滚段,即上面数组中的一个元素,也有其自己的存储格式:

  • TRX_RSEG_MAX_SIZE:回滚段管理页面的总数量,即所有undo段页面之和,一般为ULINT_MAX,即无上限。
  • TRX_RSEG_HISTORY_SIZE:这个表用来表示当前InnoDB里,在History List中有多少个页面,即需要做PURGE的回滚段页面的个数。
  • TRX_RSEG_HISTORY:用来存储History List的链表首地址,事务提交之后,其对应的回滚段如果还不能PURGE,就都会加入到这个链表中。
  • TRX_RSEG_FSEG_HEADER:用来存储回滚段的Inode位置信息,通过这个地址,就可以找到这个段的详细信息。
  • TRX_RSEG_UNDO_SLOTS:这个位置所存储的是一个数组,长度为1024,每一个元素是一个页面号,初始化为FIL_NULL,即空页面。

Innodb采用分段锁思想,类似JDK 7中CourrentHashMap采用大Hash内部管理多个小Hash的分段思想,单个回滚段中最后一个位置的数字,才算真正存储回滚段的位置。

因此,Innodb总共支持的回滚段个数为128 * 1024 = 131072 个 ,TRX_RSEG_UNDO_SLOTS数组中每个元素指向一个页面,该页面对应一个段,该页面号就是段首页的页面号。

每个事务开始时,都会分配一个rollback segment,就是从长度为128的数字中,根据最近使用情况,找到一个临近位置的rollback segment ,在这个事务的生命周期中,被分配的rollback segment都会被这个事务所使用。

在事务执行的过程中,会产生两种回滚日志:

  • 一种是INSERT的UNDO记录
  • 一种是UPDATE 的UNDO记录

可能有人会问DELETE哪去了?其实是包含在UPDATE的回滚记录中,因为InnoDB把UNDO分为两类,一类就是新增,也就是INSERT,一类就是修改,就是UPDATE,分类的依据就是事务提交后要不要做PURGE操作,因为INSERT是不需要PURGE的,只要事务提交了,那这个回滚记录就可以丢掉了,而对于更新和删除操作而言,如果事务提交了,还需要为MVCC服务,那就需要将这些日志放到History List中去,等待去做PURGE,以及MVCC的多版本查询等,所以分为两类。

简单聊聊Innodb崩溃恢复那些事_第15张图片
所以,一个事务被分配了一个rollback segment之后,通常情况下,如果一个事务中既有插入,又有更新(或删除),那么这个事务就会对应两个UNDO段,即在一个rollback segment的1024个槽中,要使用两个槽来存储这个事务的回滚段,一个是插入段,一个是更新段。

简单聊聊Innodb崩溃恢复那些事_第16张图片

在事务要存储回滚记录的时候,事务就要从1024个槽中,根据相应的更新类型(插入或者更新)找到空闲的槽来作为自己的UNDO段。如果已经申请过相同类型的UNDO段,就直接使用,否则就需要新创建一个段,并将段首页号写入这个rollback segment长度为1024的数组的对应位置(空闲位置)中去,这样就将具体的回滚段与整个架构联系起来了。

如果在1024个槽中找不到空闲的位置,那么这个事务就会被回滚掉,报出错误:“Toomany active concurrent transactions”,错误号为1637的异常。当然,这种情况一般不会见到,如果能把这个用完,估计数据库已经根本动不了了。


日志格式

关于undo日志中的回滚段头信息部分已经介绍过了,下面介绍一下undo段头信息内容,每个undo页面头信息内容以及单条undo日志头信息内容:

简单聊聊Innodb崩溃恢复那些事_第17张图片
undo段头信息内容如下:

  • TRX_UNDO_STATE:用来存储当前UNDO段的状态,状态包括TRX_UNDO_ACTIVE,TRX_UNDO_CACHED、TRX_UNDO_TO_FREE、TRX_UNDO_TO_PURGE、TRX_UNDO _PREPARED五种。
  • TRX_UNDO_LAST_LOG:用来存储最后一个UNDO日志的偏移位置,用来在一个UNDO段中,找到最后一个UNDO日志。
  • TRX_UNDO_FSEG_HEADER:这个位置,就是用来存储当前UNDO段的Inode信息的,通过这个信息可以知道本UNDO段的详细信息。
  • TRX_UNDO_PAGE_LIST:段内所有的页面都是通过链表连接起来的,这个位置是链表的首地址,用来管理这个链表,上面已经介绍的TRX_UNDO_PAGE_NODE则是每个节点的双链指针。

undo页面头信息内容如下:

  • TRX_UNDO_PAGE_TYPE:这个在上面已经解释过了,就包括两个值,分别是TRX_ UNDO_INSERT和TRX_UNDO_UPDATE
  • TRX_UNDO_PAGE_START:用来表示当前页面中,从什么位置开始存储了UNDO日志。
  • TRX_UNDO_PAGE_FREE:与上面的START相对,这个用来表示当前页面中,UNDO日志的结束位置,也表示从这个位置开始,可以继续追加UNDO日志,直到页面存储满为止。
  • TRX_UNDO_PAGE_NODE:一个UNDO段中所有的页面,通过一个双向链表来管理,这个位置存储的就是双向链表的指针。

undo页面中每条undo日志头信息内容如下:

  • TRX_UNDO_TRX_ID:用来存储当前UNDO日志对应事务的事务ID号。
  • TRX_UNDO_TRX_NO:事务序列号,在恢复时使用,这个序列号就是前面讲的TRX_ SYS_TRX_ID_STORE位置存储的ID值。这个与上面ID的区别是,NO用来在回滚时保持顺序使用,而ID是在事务运行时使用的。
  • TRX_UNDO_DEL_MARKS:用来表示当前UNDO日志中有没有通过打标志删除过记录的操作,并决定是不是要做PURGE操作。
  • TRX_UNDO_LOG_START:用来存储当前页面中,第一个UNDO日志的开始位置。
  • TRX_UNDO_XID_EXISTS:用来标志当前日志中有没有包含Xid事务。
  • TRX_UNDO_DICT_TRANS:用来标志当前日志对应的事务是不是DDL的,用来在回滚时判断如何操作。
  • TRX_UNDO_TABLE_ID:与上一个相关,如果上面的标志是真的,则这个标志的是DDL的表ID。
  • TRX_UNDO_NEXT_LOG:用来链接当前UNDO段中所有的UNDO日志,这个是指向下一个UNDO日志。
  • TRX_UNDO_PREV_LOG:与上一个对应,这个用来指向上一个UNDO日志,从而构成双向链表。
  • TRX_UNDO_HISTORY_NODE:用来存储在History List中的双向链表指针。而这个链表的首地址,是在之前介绍的TRX_RSEG_HISTORY位置,可以回到前面去查看相关信息。

到目前为止,关于具体一个UNDO段中每个页面及页面内容是如何管理的已经讲清楚了。当一个事务需要写入UNDO日志时,就可以直接从对应的UNDO段中找到一个页面及对应的追加日志的偏移位置,然后将对应的UNDO日志写入即可。


记录格式

UNDO日志有多个类型,针对不同的类型,其格式也不尽相同,UNDO日志的类型有下面四种:

  • TRX_UNDO_INSERT_REC:记录插入的UNDO日志类型,插入记录用于回滚时,只需要通过其主键就可以实现回滚操作,所以在UNDO日志中,只记录了表ID及主键信息。回滚时,只需要通过记录中存储的主键,在原B+树中找到对应的记录,然后将其删除即可。
  • TRX_UNDO_UPD_EXIST_REC:更新一条存在记录的UNDO日志类型。在日志内容中,需要记录的除了表ID信息之外,还需要记录每一个被更新的列的原始值和新值,同时还需要记录主键信息用于回滚时的检索。回滚时,还是根据主键信息,找到对应的记录,然后以旧换新,恢复原值即可。
  • TRX_UNDO_UPD_DEL_REC:更新一条已经打了删除标志记录的UNDO日志类型。格式与上面是一样的,回滚方法也同上。
  • TRX_UNDO_DEL_MARK_REC:删除记录时对记录打删除标志的UNDO日志类型,格式与上面插入操作的UNDO日志格式一样,只需要存储主键信息和表ID信息,用来在回滚或者PURGE时找到对应的记录即可。回滚时,根据主键信息,找到对应的记录,然后将删除标志去掉即完成回滚。

除了上面说到的Table ID信息、主键信息之外,还会包括一些公有的信息,比如回滚段指针、最近更新事务号,这样方便MVCC在回溯记录时可以找到以前的版本,关于MVCC的内容在这里就不详细展开了。

下面简单以TRX_UNDO_INSERT_REC类型的undo日志举例进行说明:

简单聊聊Innodb崩溃恢复那些事_第18张图片

需要注意的一点是,假如一个表中有多个索引,在修改一行数据时,回滚日志中也只会记录聚簇索引中的信息,而其他二级索引是不会被记录的。这是因为聚簇索引和二级索引中的每一行都是一一对应的,所以不同操作对聚簇索引操作时,也都会对二级索引有相应的操作,这样就没必要对二级索引写回滚日志了。


purge

purge用于最终完成delete和update操作。这样设计是因为InnoDB存储引擎支持MVCC,所以记录不能在事务提交时立即进行处理。这时其他事物可能正在引用这行,故InnoDB存储引擎需要保存记录之前的版本。而是否可以删除该条记录通过purge来进行判断。若该行记录已不被任何其他事务引用,那么就可以进行真正的delete操作。可见,purge操作是清理之前的delete和update操作,将上述操作“最终”完成。而实际执行的操作为delete操作,清理之前行记录的版本。

在前面介绍过,为了节省存储空间,InnoDB存储引擎的undo log设计是这样的:

  • 一个页上允许多个事务的undo log存在。虽然这不代表事务在全局过程中提交的顺序,但是后面的事务产生的undo log总在最后。
  • 此外,InnoDB存储引擎还有一个history列表(每个rollback segment一个),它根据事务提交的顺序,将undo log进行链接。

history list表示按照事务提交的顺序将undo log进行组织。在InnoDB存储引擎的设计中,先提交的事务总在尾端。undo page存放了undo log,由于可以重用,因此一个undo page中可能存放了多个不同事务的undo log。

下面举例说明innodb的purge过程:

  1. 在执行purge的过程中,InnoDB存储引擎首先从history list中找到第一个需要被清理的记录,这里为trx1
  2. 清理之后InnoDB存储引擎会在trx1的undo log所在的页中继续寻找是否存在可以被清理的记录,这里会找到事务trx3,接着找到trx5,但是发现trx5被其他事务所引用而不能清理
  3. 故去再次去history list中查找,发现这时最尾端的记录为trx2,接着找到trx2所在的页,然后依次再把事务trx6、trx4的记录进行清理。
  4. 由于undo page2中所有的页都被清理了,因此该undo page可以被重用。

简单聊聊Innodb崩溃恢复那些事_第19张图片

trx5的灰色阴影表示该undo log还被其他事务引用。

InnoDB存储引擎这种先从history list中找undo log,然后再从undo page中找undo log的设计模式是为了避免大量的随机读取操作,从而提高purge的效率。


关于purge这块,比较有意思的一点是: 如何判断某条undo日志不再被任何事物所引用了呢?为什么说长事务会占用大量undo日志资源呢?

  • 每个回滚段都有一个History链表,一个事务在某个回滚段中写入的一组update undo日志会在该事务提交之后,加入到当前回滚段的History链表中。这些存在于History链表中的undo日志需要等到当前系统中最早产生的那个ReadView不再访问它们时,才能被purge回收掉。

一个ReadView在什么时候才肯定不会访问到某个事务执行过程中产生的undo日志呢?

  • 在该RedaView生成前已经提交的事务,那么该ReadView肯定不会访问该事务运行过程中产生的undo日志了,因为该事务所改动的记录的最新版本均对该ReadView可见。
  • innodb提交时,会为当前事务生成一个no值,该值用来表示事务提交的顺序,先提交的事务的事务no值小,后提交的事务的事务no值大。
  • undo日志头信息部分有一个TRX_UNDO_TRX_NO属性,当事务提交时,就把该事务对于的事务no值填入到该属性中。
  • 因为事务no代表各个事务提交的顺序,而History链表又是按照事务提交的顺序来排列各组undo日志的,所以History链表中的各组undo日志也是按照对应的事务no来排序的。
  • ReadView中也会保护当前事务的no属性,在生成一个ReadView时,会把当前系统中最大事务no值+1的值赋值给该属性。
  • innodb中把当前系统中所有ReadView按照创建时间连成了一个链表,当执行purge操作时,只需要取出最早生成的ReadView,然后从各个回滚段的History链表中取出事务no值较小的各组undo日志。
  • 如果一组undo日志的事务no值小于当前系统中最早生成的ReadView的事务no属性值,那么意味着该组undo日志可以被purge,将其从Hisotry链表中移除,并且如果当前undo日志包含delete mark标记,还需要将对应标记为删除的记录彻底删除掉。

当前系统中最早生成的ReadView决定了purge操作可以清理哪些update undo日志以及打了删除标记的记录,如果某个事务使用了可重复读隔离级别,那么该事务会一直复用最初产生的ReadView。假如该事务运行了很久,一直没有条件,那么最早生成的ReadView会一直不释放,系统中的undo日志会越积越多,表空间对应的文件也会越来越大,一条记录的版本链会越来越长,从而影响系统性能。


回滚

前面已经介绍过,UNDO日志的正确性是通过REDO的恢复来保证的,在REDO日志恢复完成之后,UNDO操作就可以安全地进行了。数据库启动过程中,执行了用于REDO恢复的函数recv_recovery_from_checkpoint_start之后,就可以处理UNDO的数据了,InnoDB通过函数trx_sys_init_at_db_start来将所有回滚段相关的128*1024个UNDO扫描出来(如果存在就找到,不存在就忽略),找到之后,每一个UNDO段的状态都已经清楚了,然后将它们都缓存起来。

然后再通过函数trx_lists_init_at_db_start依次处理每一个UNDO段,根据UNDO段的状态,决定后面将采取什么措施,如果状态为TRX_UNDO_PREPAREDTRX_UNDO_ACTIVE,则这个UNDO段是需要做回滚操作的,否则是不需要的。决定回滚需求之后,再将最多128*1024个UNDO段按照上面提到的TRX_UNDO_TRX_NO从大到小的顺序排序。

最后在nnoDB存储引擎启动时的函数recv_recovery_from_checkpoint_finish中,来做回滚的相关工作。在这个函数的最后可以看到以下内容:
简单聊聊Innodb崩溃恢复那些事_第20张图片
它根据参数innodb_force_recovery来决定要不要做回滚操作,如果设置为3或3以上,就不回滚了,这样可能导致数据库逻辑上的不一致。

最终,InnoDB通过trx_rollback_or_clean_recovered来做回滚操作,通过扫描上面排序之后的链表,发现其还是以从大到小的顺序遍历,这个顺序很重要,因为UNDO是反向操作,所以应该是先处理新产生的事务,后处理老的事务,通过事务号来区分新老关系。

针对每一个UNDO段,InnoDB会将所有状态为ACTIVE的事务的UNDO日志扫描出来,然后一条一条地做回滚操作,UNDO日志记录格式已经明确,扫描所有的日志就变得非常简单,并且针对不同的操作,对应的回滚方式也已经清楚,等待所有的回滚段处理完成之后,整个数据库的回滚操作也就完成了。

简单聊聊Innodb崩溃恢复那些事_第21张图片
到这里,InnoDB就可以继续启动了,此时的数据库处于一个完整的、可以正确提供线上服务的状态。


总结

本文简单聊了聊Innodb崩溃恢复的整个流程,参考资料在本文开始和结束处都已给出,当然,本文含有笔者主观理解,希望大家理性看待,如果有认为不对的地方,欢迎评论区留言或私信与我讨论


额外参考

MySQL · 引擎特性 · WAL那些事儿

MySQL · 源码分析 · 庖丁解 InnoDB 之 Buffer Pool

数据库故障恢复机制的前世今生

B+树数据库加锁历史

MySQL · 源码解析 · InnoDB中undo日志的组织及实现

InnoDB 的 Redo Log 分析

源码解读:MySQL 8.0 InnoDB无锁化设计的日志系统

你可能感兴趣的:(#,Innodb存储引擎,数据结构)