本文想用简单精炼的语言将Innodb崩溃恢复那些事情好好拾到拾到,本文主要参考以下三本书和我个人一些感想而作:
关于一些辅助资料有:
还有一些额外参考的博客,链接已在文章末尾贴出。
本文作为一篇闲谈文章,细节不会深入讲解,后续会考虑出源码解析文章,结合源码深入聊聊崩溃恢复的整个过程。
Innodb中的Buffer Pool作为磁盘数据页在内存中的页缓存池,负责管理索引页,数据页,undo页,插入缓存,自适应哈希索引,锁信息,数据字典信息等。
Mysql 5.7.5 之前,Buffer Pool大小在运行时不支持动态调整大小,而 5.7.5 版本后将Buffer Pool调整为由多个chunk组成,当需要扩容Buffer Pool大小时,只需要单独向操作系统以chunk为单位进行空间申请即可,无需再向操作系统申请一大片连续内存空间,然后再将旧的Buffer Pool内容复制过去。
每个chunk由若干缓存页与其对应的控制块组成,控制块具体包含如下四个部分:
Buffer Pool通过free链表管理空闲页,通过Flush链表管理脏页,通过LRU链表存放所有被访问或者修改过的页,同时Buffer Pool内部还持有一把mutex锁,用于确保一个实例只能由一个线程访问。
Buffer Pool实际分配过程中,页面从后往前分配,而控制结构从前往后分配,因此,在一般情况下,中间的会剩余一部分没有被使用,因为剩余空间不能再放得下一个控制结构和页面了。
在初始化每一个页面之后,都需要将每个页面加入到Free链表中去。
同时为了通过表空间号+页号快速定位一个Page,Buffer Pool还需要以表空间号+页号作为key,缓存页作为value创建一个哈希表,在需要访问某个页数据时,先从哈希表中根据表空间+页号看看有无对应的缓存页,如果有,直接使用,否则,从free链表中选择一个空闲的缓存页,然后把磁盘中对应的页加载到该缓存页的位置即可。
关于LRU链表的组织,分别针对预读和全表扫描做了对应的优化:
Buffer Pool作为一个共享资源,在并发环境下必定存在资源竞争问题,此时我们可以借助分而治之思想,采用多缓冲池实例的方式,将用户请求根据访问页的哈希值不同平均负载到不同的缓冲池实例上:
当我们修改数据时,DBMS需要保证两点:
如果遇上了事务故障或者系统故障,DBMS需要通过相关恢复手段来确保数据一致性,通常有以下两种思路:
DBMS 如何支持 undo/redo 取决于它如何管理 buffer pool , 我们可以从两个角度来分析一下buffer pool的管理策略:
Innodb采用的是 steal + no_force 策略,这也符合Aries这篇论文的核心思想 ,简单来说可以总结为三点:
Innodb 具体实现与Aries这篇论文的思想还是有些区别的,具体区别简述如下:
innodb不会通过redo log记录checkpoint时的活跃事务列表,innodb在崩溃恢复过程分为两段: redo 和 undo
aries论文中提到的redo日志会在checkpoint时的redo日志中记录当前时刻的活跃事务列表,然后崩溃恢复阶段会做以下事情:
redo日志作用有哪些:
redo日志记录的是针对页面的做出的物理修改,所以其日志基本格式为:
虽然说redo记录的是对页面做出的物理层面修改记录,但是,比如一条简单的插入语句都可能会涉及多个页面的修改,如果真的是完全记录在页面哪个偏移量上做出了什么修改,可能会产生比原页面数据还要大的redo日志。
因此innodb引入了多种redo日志类型,实际来说还是逻辑日志,但是更偏向底层,使得日志占用空间比全物理日志要少很多,同时我们也可以自己编写REDO解析工具,了解数据库做了什么,类似canal监听binlog一样,所以总结一下就是:
逻辑日志最大缺点就是需要首先保障日志对应页面的正确性,否则会造成逻辑日志执行不成功,或者造成数据不一致的问题,这个问题在Innodb中的解决方式,就是常说的Double Write机制,核心思想就是:
redo log buffer 在内存中是一段连续的内存空间,被划分为了若干512字节大小的block,而对应的redo log file 也是由若干512字节大小的block组成的:
关于每个redo log file 头信息块构成如下图所示:
Innodb 日志文件组默认包括2个日志文件,日志最小增量为一个MTR(下节会讲),日志文件轮询一圈,采用循环写入的方式。
在InnoDB中,通过日志组来管理日志文件,是一个逻辑定义,包含若干个日志文件,一个组中的日志文件大小相等,大小通过参数来设置。现在InnoDB只 持一个日志组。在MySQL 5.5及之前的版本中,整个日志组的容量不能大于4GB(实际上是3.9GB多,因为还有一些文件头信息等),到了MySQL 5.6.3版本之后,整个日志组的容量可以设置得很大,最大可以达到512GB。
日志组中的每一个日志文件,都有自己的格式,内部也是按照大小相等的页面切割,但这里的页面大小是512个字节,由于历史的原因,考虑到机械硬盘的块大小是512字节,日志块大小也如此设计。这是因为写日志其实就是为了提高数据库写入吞吐量,如果每次写入是磁盘块大小的倍数,效率才是最高的,并且日志将逻辑事务对数据库的分散随机写入转化成了顺序的512字节整数倍数据的写入,这样就大大提高了数据库的效率。
写日志是一个物理操作,它也需要一个完整性,比如在底层页面插入一条记录,如果只修改页头信息而没有修改页尾信息,其实对于这个页面来说是不完整的,所以这个物理操作还是需要一个机制来保证它的完整性的。在Innodb中,这个机制也被称为MTR,可以理解为物理事务,因为它也是用来保证完整性的。
物理事务既然也被称为事务,那么其同样有事务的开始和提交,物理事务的开始就是对mtr_struct结构体的初始化,其包含下列属性:
物理事务执行过程中,需要对访问到的页面加上对应的latch锁,页面当前是否上锁,可以通过页面控制块中记录的锁信息获知,如果获取某个页面锁成功,则将当前页面加入memo数组,否则需要等待直到锁释放。
物理事务执行过程中涉及到写操作(MTR_LOG_ALL),则需要对写操作记录日志,这里的日志就是逻辑事务中提到的Redo日志。写下相应的日志后,同样将其存储到上面的log动态数组中,同时将n_log_recs计数器自增。
下面聊聊物理事务提交的过程,首先redo日志不完全是物理日志,它包含了部分逻辑意义在里面,比如插入一行记录的时候,MTR记录的是在一个页面中写入这条记录,内容大致包括页面号,文件号及这条记录每列的值,这样就有了逻辑概念。需要注意的是,在Redo恢复时,需要保证这个页面是正确的,完整的,不然这个REDO就会失败,这也真是DOUBLE WRITE存在的意义。 如果记录的是纯物理的REDO,日志内容应该会拆分的更散,比如: 插入一条记录,它会记录页面号,表空间号,页内偏移值,并且有多条这也的记录,因为会涉及多个位置的修改,这就没有任何逻辑内容了。而针对一个插入操作,需要在一个页面内的不同位置写入不同的数据,当然如果是纯物理REDO,相应地会产生多条REDO记录,这是物理与逻辑的简单区别。
对于MTR的提交而言,一个逻辑事务是由多个物理事务组成的,物理事务可以保证一次物理修改的原子性,比如插入一条记录的过程中,会包括写一条回滚记录及插入时写入一个页面等,这些逻辑上是一个动作的物理写入,可以被认为是一个独立的物理事务,也就是写回滚记录时只需mtr_start ,写完之后只需mtr_commit ,真正插入时写入一个页面也是同样的道理。
物理事务和逻辑事务一样,也是可以保证数据库操作的完整性的。一般说来,一个操作必须要在一个物理事务中完成,也就是说要么这个操作已经完成,要么什么也没有做,否则就有可能造成数据不完整的问题,因为在数据库系统做REDO操作时是以一个物理事务为单位做的,如果一个物理事务的日志是不完整的,则它对应的所有日志都不会重做。那么,如何辨别一个物理事务是否完整呢?这个问题是在物理事务提交时用了一个很巧妙的方法来保证的。在提交前,如果发现这个物理事务有日志,则在日志最后再写一些特殊的日志,这些特殊的日志就是一个物理事务结束的标志,提交时一起将这些特殊的日志写入,在重做时如果当前这一批日志信息最后面存在这个标志,则说明这些日志是完整的,否则就是不完整的,就不会重做。
一个事务可以包含多个SQL语句,每条语句由若干mtr组成,每一个mtr又可以保护若干redo日志:
MTR提交时将物理事务产生的日志写入到InnoDB日志系统的日志缓冲区中,然后等待后台master线程定时将日志系统的日志缓冲区数据刷到日志文件中,这会涉及到日志刷盘时机的问题 。Mtr ,日志缓冲区与日志文件之间的关系如下:
如上图所示,左边的若干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有关系:
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链表中的脏页会按照页面第一次修改时间从大到小进行排序,每个页面关联的控制块中都有以下两个属性用于记录页面何时被修改的信息:
日志是循环使用的,不能跳着写,因此每次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日志也有可能包含未提交事务做出的修改
举例:
最终可以看到checkpoint lsn被更新为了120,只是说明checkpoint lsn之前的脏页都已经落盘了,但是无法确保此时磁盘上不存在未提交事务做出的修改。
但是我们不需要担心这个问题,因为innodb对于redo日志的定位就是确保我们可以利用redo日志重放,将数据库状态恢复到崩溃前的样子,然后再利用undo日志完成未提交事务产生修改的回滚操作。
崩溃恢复整个过程由redo和undo两个阶段完成,本节我们先来看看redo阶段是如何将数据库恢复到其崩溃前的模样的。
Innodb会维护一个全局LSN变量用于记录已经向redo log buffer写入的redo日志大小,同时维护一个全局flushed_to_disk_lsn变量用于记录已经刷到磁盘上的redo log日志大小,如下图所示:
关于崩溃恢复,首先我们需要确定崩溃恢复的起点:
下一步是确定崩溃恢复的终点:
最后一步就是确定如何进行恢复了:
之所以说当前被刷脏页的FIL_PAGE_LSN可能比checkpoint lsn大,一个场景就是不断有事务更新该脏页,但是该脏页确实是最早被修改的脏页。
崩溃恢复在经过了redo阶段后,就将数据库恢复到了崩溃恢复前的模样,下一步我们就需要进入undo阶段,将崩溃恢复前未提交的事务进行回滚了。
InnoDB的REDO是在UNDO之前做的,是等到物理的数据库操作都完成之后,才能在物理数据一致的基础上去做一些逻辑的操作,即UNDO回滚操作
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及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号页面来存储事务相关信息:
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都会被这个事务所使用。
在事务执行的过程中,会产生两种回滚日志:
可能有人会问DELETE哪去了?其实是包含在UPDATE的回滚记录中,因为InnoDB把UNDO分为两类,一类就是新增,也就是INSERT,一类就是修改,就是UPDATE,分类的依据就是事务提交后要不要做PURGE操作,因为INSERT是不需要PURGE的,只要事务提交了,那这个回滚记录就可以丢掉了,而对于更新和删除操作而言,如果事务提交了,还需要为MVCC服务,那就需要将这些日志放到History List中去,等待去做PURGE,以及MVCC的多版本查询等,所以分为两类。
所以,一个事务被分配了一个rollback segment之后,通常情况下,如果一个事务中既有插入,又有更新(或删除),那么这个事务就会对应两个UNDO段,即在一个rollback segment的1024个槽中,要使用两个槽来存储这个事务的回滚段,一个是插入段,一个是更新段。
在事务要存储回滚记录的时候,事务就要从1024个槽中,根据相应的更新类型(插入或者更新)找到空闲的槽来作为自己的UNDO段。如果已经申请过相同类型的UNDO段,就直接使用,否则就需要新创建一个段,并将段首页号写入这个rollback segment长度为1024的数组的对应位置(空闲位置)中去,这样就将具体的回滚段与整个架构联系起来了。
如果在1024个槽中找不到空闲的位置,那么这个事务就会被回滚掉,报出错误:“Toomany active concurrent transactions”,错误号为1637的异常。当然,这种情况一般不会见到,如果能把这个用完,估计数据库已经根本动不了了。
关于undo日志中的回滚段头信息部分已经介绍过了,下面介绍一下undo段头信息内容,每个undo页面头信息内容以及单条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日志举例进行说明:
需要注意的一点是,假如一个表中有多个索引,在修改一行数据时,回滚日志中也只会记录聚簇索引中的信息,而其他二级索引是不会被记录的。这是因为聚簇索引和二级索引中的每一行都是一一对应的,所以不同操作对聚簇索引操作时,也都会对二级索引有相应的操作,这样就没必要对二级索引写回滚日志了。
purge用于最终完成delete和update操作。这样设计是因为InnoDB存储引擎支持MVCC,所以记录不能在事务提交时立即进行处理。这时其他事物可能正在引用这行,故InnoDB存储引擎需要保存记录之前的版本。而是否可以删除该条记录通过purge来进行判断。若该行记录已不被任何其他事务引用,那么就可以进行真正的delete操作。可见,purge操作是清理之前的delete和update操作,将上述操作“最终”完成。而实际执行的操作为delete操作,清理之前行记录的版本。
在前面介绍过,为了节省存储空间,InnoDB存储引擎的undo log设计是这样的:
history list表示按照事务提交的顺序将undo log进行组织。在InnoDB存储引擎的设计中,先提交的事务总在尾端。undo page存放了undo log,由于可以重用,因此一个undo page中可能存放了多个不同事务的undo log。
下面举例说明innodb的purge过程:
trx5的灰色阴影表示该undo log还被其他事务引用。
InnoDB存储引擎这种先从history list中找undo log,然后再从undo page中找undo log的设计模式是为了避免大量的随机读取操作,从而提高purge的效率。
关于purge这块,比较有意思的一点是: 如何判断某条undo日志不再被任何事物所引用了呢?为什么说长事务会占用大量undo日志资源呢?
一个ReadView在什么时候才肯定不会访问到某个事务执行过程中产生的undo日志呢?
TRX_UNDO_TRX_NO
属性,当事务提交时,就把该事务对于的事务no值填入到该属性中。当前系统中最早生成的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_PREPARED
和TRX_UNDO_ACTIVE
,则这个UNDO段是需要做回滚操作的,否则是不需要的。决定回滚需求之后,再将最多128*1024个UNDO段按照上面提到的TRX_UNDO_TRX_NO
从大到小的顺序排序。
最后在nnoDB存储引擎启动时的函数recv_recovery_from_checkpoint_finish
中,来做回滚的相关工作。在这个函数的最后可以看到以下内容:
它根据参数innodb_force_recovery来决定要不要做回滚操作,如果设置为3或3以上,就不回滚了,这样可能导致数据库逻辑上的不一致。
最终,InnoDB通过trx_rollback_or_clean_recovered来做回滚操作,通过扫描上面排序之后的链表,发现其还是以从大到小的顺序遍历,这个顺序很重要,因为UNDO是反向操作,所以应该是先处理新产生的事务,后处理老的事务,通过事务号来区分新老关系。
针对每一个UNDO段,InnoDB会将所有状态为ACTIVE的事务的UNDO日志扫描出来,然后一条一条地做回滚操作,UNDO日志记录格式已经明确,扫描所有的日志就变得非常简单,并且针对不同的操作,对应的回滚方式也已经清楚,等待所有的回滚段处理完成之后,整个数据库的回滚操作也就完成了。
到这里,InnoDB就可以继续启动了,此时的数据库处于一个完整的、可以正确提供线上服务的状态。
本文简单聊了聊Innodb崩溃恢复的整个流程,参考资料在本文开始和结束处都已给出,当然,本文含有笔者主观理解,希望大家理性看待,如果有认为不对的地方,欢迎评论区留言或私信与我讨论
MySQL · 引擎特性 · WAL那些事儿
MySQL · 源码分析 · 庖丁解 InnoDB 之 Buffer Pool
数据库故障恢复机制的前世今生
B+树数据库加锁历史
MySQL · 源码解析 · InnoDB中undo日志的组织及实现
InnoDB 的 Redo Log 分析
源码解读:MySQL 8.0 InnoDB无锁化设计的日志系统