MySQL · 引擎特性 · InnoDB崩溃恢复

前言

数据库系统与文件系统最大的区别在于数据库能保证操做的原子性,一个操做要么不作要么都作,即便在数据库宕机的状况下,也不会出现操做一半的状况,这个就须要数据库的日志和一套完善的崩溃恢复机制来保证。本文仔细剖析了InnoDB的崩溃恢复流程,代码基于5.6分支。mysql

基础知识

lsn: 能够理解为数据库从建立以来产生的redo日志量,这个值越大,说明数据库的更新越多,也能够理解为更新的时刻。此外,每一个数据页上也有一个lsn,表示最后被修改时的lsn,值越大表示越晚被修改。好比,数据页A的lsn为100,数据页B的lsn为200,checkpoint lsn为150,系统lsn为300,表示当前系统已经更新到300,小于150的数据页已经被刷到磁盘上,所以数据页A的最新数据必定在磁盘上,而数据页B则不必定,有可能还在内存中。
redo日志: 现代数据库都须要写redo日志,例如修改一条数据,首先写redo日志,而后再写数据。在写完redo日志后,就直接给客户端返回成功。这样虽然看过去多写了一次盘,可是因为把对磁盘的随机写入(写数据)转换成了顺序的写入(写redo日志),性能有很大幅度的提升。当数据库挂了以后,经过扫描redo日志,就能找出那些没有刷盘的数据页(在崩溃以前可能数据页仅仅在内存中修改了,可是还没来得及写盘),保证数据不丢。
undo日志: 数据库还提供相似撤销的功能,当你发现修改错一些数据时,可使用rollback指令回滚以前的操做。这个功能须要undo日志来支持。此外,现代的关系型数据库为了提升并发(同一条记录,不一样线程的读取不冲突,读写和写读不冲突,只有同时写才冲突),都实现了相似MVCC的机制,在InnoDB中,这个也依赖undo日志。为了实现统一的管理,与redo日志不一样,undo日志在Buffer Pool中有对应的数据页,与普通的数据页一块儿管理,依据LRU规则也会被淘汰出内存,后续再从磁盘读取。与普通的数据页同样,对undo页的修改,也须要先写redo日志。
检查点: 英文名为checkpoint。数据库为了提升性能,数据页在内存修改后并非每次都会刷到磁盘上。checkpoint以前的数据页保证必定落盘了,这样以前的日志就没有用了(因为InnoDB redolog日志循环使用,这时这部分日志就能够被覆盖),checkpoint以后的数据页有可能落盘,也有可能没有落盘,因此checkpoint以后的日志在崩溃恢复的时候仍是须要被使用的。InnoDB会依据脏页的刷新状况,按期推动checkpoint,从而减小数据库崩溃恢复的时间。检查点的信息在第一个日志文件的头部。
崩溃恢复: 用户修改了数据,而且收到了成功的消息,然而对数据库来讲,可能这个时候修改后的数据尚未落盘,若是这时候数据库挂了,重启后,数据库须要从日志中把这些修改后的数据给捞出来,从新写入磁盘,保证用户的数据不丢。这个从日志中捞数据的过程就是崩溃恢复的主要任务,也能够成为数据库前滚。固然,在崩溃恢复中还须要回滚没有提交的事务,提交没有提交成功的事务。因为回滚操做须要undo日志的支持,undo日志的完整性和可靠性须要redo日志来保证,因此崩溃恢复先作redo前滚,而后作undo回滚。sql

咱们从源码角度仔细剖析一下数据库崩溃恢复过程。整个过程都在引擎初始化阶段完成(innobase_init),其中最主要的函数是innobase_start_or_create_for_mysql,innodb经过这个函数完成建立和初始化,包括崩溃恢复。首先来介绍一下数据库的前滚。数据库

redo日志前滚数据库

前滚数据库,主要分为两阶段,首先是日志扫描阶段,扫描阶段按照数据页的space_id和page_no分发redo日志到hash_table中,保证同一个数据页的日志被分发到同一个哈希桶中,且按照lsn大小从小到大排序。扫描完后,再遍历整个哈希表,依次应用每一个数据页的日志,应用完后,在数据页的状态上至少恢复到了崩溃以前的状态。咱们来详细分析一下代码。
首先,打开全部的ibdata文件(open_or_create_data_files)(ibdata能够有多个),每一个ibdata文件有个flush_lsn在头部,计算出这些文件中的max_flush_lsn和min_flush_lsn,由于ibdata也有可能有数据没写完整,须要恢复,后续(recv_recovery_from_checkpoint_start_func)经过比较checkpont_lsn和这两个值来肯定是否须要对ibdata前滚。
接着,打开系统表空间和日志表空间的全部文件(fil_open_log_and_system_tablespace_files),防止出现文件句柄不足,清空buffer pool(buf_pool_invalidate)。接下来就进入最最核心的函数:recv_recovery_from_checkpoint_start_func,注意,即便数据库是正常关闭的,也会进入。
虽然recv_recovery_from_checkpoint_start_func看过去很冗长,可是不少代码都是为了LOG_ARCHIVE特性而编写的,真正数据崩溃恢复的代码其实很少。
首先,初始化一些变量,查看srv_force_recovery这个变量,若是用户设置跳过前滚阶段,函数直接返回。
接着,初始化recv_sys结构,分配hash_table的大小,同时初始化flush list rbtree。recv_sys结构主要在崩溃恢复前滚阶段使用。hash_table就是以前说的用来存不一样数据页日志的哈希表,哈希表的大小被初始化为buffer_size_in_bytes/512, 这个是哈希表最大的长度,超过就存不下了,幸运的是,须要恢复的数据页的个数不会超过这个值,由于buffer poll最多(数据库崩溃以前脏页的上线)只能存放buffer_size_in_bytes/16KB个数据页,即便考虑压缩页,最多也只有buffer_size_in_bytes/1KB个,此外关于这个哈希表内存分配的大小,能够参考bug#53122。flush list rbtree这个主要是为了加入插入脏页列表,InnoDB的flush list必须按照数据页的最老修改lsn(oldest_modifcation)从小到大排序,在数据库正常运行时,能够经过log_sys->mutex和log_sys->log_flush_order_mutex保证顺序,在崩溃恢复则没有这种保证,应用数据的时候,是从第一个元素开始遍历哈希表,不能保证数据页按照最老修改lsn(oldest_modifcation)从小到大排序,这样就须要线性遍历flush_list来寻找插入位置,效率过低,所以引入红黑树,加快查找插入的位置。
接着,从ib_logfile0的头中读取checkpoint信息,主要包括checkpoint_lsn和checkpoint_no。因为InnoDB日志是循环使用的,且最少要有2个,因此ib_logfile0必定存在,把checkpoint信息存在里面很安全,不用担忧被删除。checkpoint信息其实会写在文件头的两个地方,两个checkpoint域轮流写。为何要两个地方轮流写呢?假设只有一个checkpoint域,一直更新这个域,而checkpoint域有512字节(OS_FILE_LOG_BLOCK_SIZE),若是恰好在写这个512字节的时候,数据库挂了,服务器也挂了(先不考虑硬件的原子写特性,早期的硬件没有这个特性),这个512字节可能只写了一半,致使整个checkpoint域不可用。这样数据库将没法作崩溃恢复,从而没法启动。若是有两个checkpoint域,那么即便一个写坏了,还能够用另一个尝试恢复,虽然有可能这个时候日志已经被覆盖,可是至少提升了恢复成功的几率。两个checkpoint域轮流写,也能减小磁盘扇区故障带来的影响。checkpoint_lsn以前的数据页都已经落盘,不须要前滚,以后的数据页可能还没落盘,须要从新恢复出来,即便已经落盘也不要紧,由于redo日志时幂等的,应用一次和应用两次都同样(底层实现: 若是数据页上的lsn大于等于当前redo日志的lsn,就不该用,不然应用。checkpoint_no能够理解为checkpoint域写盘的次数,每次刷盘递增1,同时这个值取模2能够用来实现checkpoint_no域的轮流写。正常逻辑下,选取checkpoint_no值大的做为最终的checkpoint信息,用来作后续崩溃恢复扫描的起始点。
接着,使用checkpoint域的信息初始化recv_sys结构体的一些信息后,就进入日志解析的核心函数recv_group_scan_log_recs,这个函数后续咱们再分析,主要做用就是解析redo日志,若是内存不够了,就直接调用应用(recv_apply_hashed_log_recs)日志,而后再接着解析。若是须要应用的日志不多,就仅仅解析分发日志,到recv_recovery_from_checkpoint_finish函数中在应用日志。
接着,依据当前刷盘的数据页状态作一次checkpoint,由于在recv_group_scan_log_recs里可能已经应用部分日志了。至此recv_recovery_from_checkpoint_start_func函数结束。
recv_recovery_from_checkpoint_finish函数中,若是srv_force_recovery设置正确,就开始调用函数recv_apply_hashed_log_recs应用日志,而后等待刷脏的线程退出(线程是崩溃恢复时临时启动的),最后释放recv_sys的相关资源以及hash_table占用的内存。
至此,数据库前滚结束。接下来,咱们详细分析一下redo日志解析函数以及redo日志应用函数的实现细节。安全

redo日志解析函数

解析函数的最上层是recv_group_scan_log_recs,这个函数调用底层函数(log_group_read_log_seg),按照RECV_SCAN_SIZE(64KB)大小分批读取。读取出来后,首先经过block_no和lsn之间的关系以及日志checksum判断是否读到了日志最后(因此能够看出,并没一个标记在日志头标记日志的有效位置,彻底是按照上述两个条件判断是否到达了日志尾部),若是读到最后则返回(以前说了,即便数据库是正常关闭的,也要走崩溃恢复逻辑,那么在这里就返回了,由于正常关闭的checkpoint值必定是指向日志最后),不然则把日志去头掐尾放到一个recv_sys->buf中,日志头里面存了一些控制信息和checksum值,只是用来校验和定位,在真正的应用中没有用。在放到recv_sys->buf以前,须要检验一下recv_sys->buf有没有满(RECV_PARSING_BUF_SIZE,2M),满了就报错(若是上一批解析有不完整的日志,日志解析函数不会分发,而是把这些不完整的日志留在recv_sys->buf中,直到解析到完整的日志)。接下的事情就是从recv_sys->buf中解析日志(recv_parse_log_recs)。日志分两种:single_rec和multi_rec,前者表示只对一个数据页进行一种操做,后者表示对一个或者多个数据页进行多种操做。日志中还包括对应数据页的space_id,page_no,操做的type以及操做的内容(recv_parse_log_rec)。解析出相应的日志后,按照space_id和page_no进行哈希(若是对应的表空间在内存中不存在,则表示表已经被删除了),放到hash_table里面(日志真正存放的位置依然在buffer pool)便可,等待后续应用。这里有几个点值得注意:服务器

  • 若是是multi_rec类型,则只有遇到MLOG_MULTI_REC_END这个标记,日志才算完整,才会被分发到hash_table中。查看代码,咱们能够发现multi_rec类型的日志被解析了两次,一次用来校验完整性(寻找MLOG_MULTI_REC_END),第二次才用来分发日志,感受这是一个能够优化的点。
  • 目前日志的操做type有50多种,每种操做后面的内容都不同,因此长度也不同,目前日志的解析逻辑,须要依次解析出全部的内容,而后肯定长度,从而定位下一条日志的开始位置。这种方法效率略低,其实能够在每种操做的头上加上一个字段,存储后面内容的长度,这样就不须要解析太多的内容,从而提升解析速度,进一步提升崩溃恢复速度,从结果看,能够提升一倍的速度(从38秒到14秒,详情能够参见bug#82937)。
  • 若是发现checkpoint以后还有日志,说明数据库以前没有正常关闭,须要作崩溃恢复,所以须要作一些额外的操做(recv_init_crash_recovery),好比在错误日志中打印咱们常见的“Database was not shutdown normally!”和“Starting crash recovery.”,还要从double write buffer中检查是否发生了数据页半写,若是有须要恢复(buf_dblwr_process),还须要启动一个线程用来刷新应用日志产生的脏页(由于这个时候buf_flush_page_cleaner_thread尚未启动)。最后还须要打开全部的表空间。。注意是全部的表。。。咱们在阿里云RDS MySQL的运维中,经常发现数据库hang在了崩溃恢复阶段,在错误日志中有相似“Reading tablespace information from the .ibd files...”字样,这就表示数据库正在打开全部的表,而后一看表的数量,发现有几十甚至上百万张表。。。数据库之因此要打开全部的表,是由于在分发日志的时候,须要肯定space_id对应哪一个ibd文件,经过打开全部的表,读取space_id信息来肯定,另一个缘由是方便double write buffer检查半写数据页。针对这个表数量过多致使恢复过慢的问题,MySQL 5.7作了优化,WL#7142, 主要思想就是在每次checkpoint后,在第一次修改某个表时,先写一个新日志mlog_file_name(包括space_id和filename的映射),来表示对这个表进行了操做,后续对这个表的操做就不用写这个新日志了,当须要崩溃恢复时候,多一次扫描,经过搜集mlog_file_name来肯定哪些表被修改过,这样就不须要打开全部的表来肯定space_id了。
  • 最后一个值得注意的地方是内存。以前说过,若是有太多的日志已经被分发,占用了太多的内存,日志解析函数会在适当的时候应用日志,而不是等到最后才一块儿应用。那么问题来了,使用了多大的内存就会出发应用日志逻辑。答案是:buffer_pool_size_in_bytes - 512 * buffer_pool_instance_num * 16KB。因为buffer_pool_instance_num通常不会太大,因此能够任务,buffer pool的大部份内存都被用来存放日志。剩下的那些主要留给应用日志时读取的数据页,由于目前来讲日志应用是单线程的,读取一个日志,把全部日志应用完,而后就能够刷回磁盘了,不须要太多的内存。

redo日志应用函数

应用日志的上层函数为recv_apply_hashed_log_recs(应用日志也可能在io_helper函数中进行),主要做用就是遍历hash_table,从磁盘读取对每一个数据页,依次应用哈希桶中的日志。应用完全部的日志后,若是须要则把buffer_pool的页面都刷盘,毕竟空间有限。有如下几点值得注意:并发

  • 同一个数据页的日志必须按照lsn从小到大应用,不然数据会被覆盖。只应用redo日志lsn大于page_lsn的日志,只有这些日志须要重作,其他的忽略。应用完日志后,把脏页加入脏页列表,因为脏页列表是按照最老修改lsn(oldest_modification)来排序的,这里经过引入一颗红黑树来加速查找插入的位置,时间复杂度从以前的线性查找降为对数级别。
  • 当须要某个数据页的时候,若是发现其没有在Buffer Pool中,则会查看这个数据页周围32个数据页,是否也须要作恢复,若是须要则能够一块儿读取出来,至关于作了一次io合并,减小io操做(recv_read_in_area)。因为这个是异步读取,因此最终应用日志的活儿是由io_helper线程来作的(buf_page_io_complete),此外,为了防止短期发起太多的io,在代码中加了流量控制的逻辑(buf_read_recv_pages)。若是发现某个数据页在内存中,则直接调用recv_recover_page应用日志。由此咱们能够看出,InnoDB应用日志其实并非单线程的来应用日志的,除了崩溃恢复的主线程外,io_helper线程也会参与恢复。并发线程数取决于io_helper中读取线程的个数。

执行完了redo前滚数据库,数据库的全部数据页已经处于一致的状态,undo回滚数据库就能够安全的执行了。数据库崩溃的时候可能有一些没有提交的事务或者已经提交的事务,这个时候就须要决定是否提交。主要分为三步,首先是扫描undo日志,从新创建起undo日志链表,接着是,依据上一步创建起的链表,重建崩溃前的事务,即恢复当时事务的状态。最后,就是依据事务的不一样状态,进行回滚或者提交。app

undo日志回滚数据库

recv_recovery_from_checkpoint_start_func以后,recv_recovery_from_checkpoint_finish以前,调用了trx_sys_init_at_db_start,这个函数作了上述三步中的前两步。
第一步在函数trx_rseg_array_init中处理,遍历整个undo日志空间(最多TRX_SYS_N_RSEGS(128)个segment),若是发现某个undo segment非空,就进行初始化(trx_rseg_create_instance)。整个每一个undo segment,若是发现undo slot非空(最多TRX_RSEG_N_SLOTS(1024)个slot),也就行初始化(trx_undo_lists_init)。在初始化undo slot后,就把不一样类型的undo日志放到不一样链表中(trx_undo_mem_create_at_db_start)。undo日志主要分为两种:TRX_UNDO_INSERT和TRX_UNDO_UPDATE。前者主要是提供给insert操做用的,后者是给update和delete操做使用。以前说过,undo日志有两种做用,事务回滚时候用和MVCC快照读取时候用。因为insert的数据不须要提供给其余线程用,因此只要事务提交,就能够删除TRX_UNDO_INSERT类型的undo日志。TRX_UNDO_UPDATE在事务提交后还不能删除,须要保证没有快照使用它的时候,才能经过后台的purge线程清理。
第二步在函数trx_lists_init_at_db_start中进行,因为第一步中,已经在内存中创建起了undo_insert_list和undo_update_list(链表每一个undo segment独立),因此这一步只须要遍历全部链表,重建起事务的状态(trx_resurrect_inserttrx_resurrect_update)。简单的说,若是undo日志的状态是TRX_UNDO_ACTIVE,则事务的状态为TRX_ACTIVE,若是undo日志的状态是TRX_UNDO_PREPARED,则事务的状态为TRX_PREPARED。这里还要考虑变量srv_force_recovery的设置,若是这个变量值为非0,全部的事务都会回滚(即事务被设置为TRX_ACTIVE),即便事务的状态应该为TRX_STATE_PREPARED。重建起事务后,按照事务id加入到trx_sys->trx_list链表中。最后,在函数trx_sys_init_at_db_start中,会统计全部须要回滚的事务(事务状态为TRX_ACTIVE)一共须要回滚多少行数据,输出到错误日志中,相似:5 transaction(s) which must be rolled back or cleaned up。InnoDB: in total 342232 row operations to undo的字样。
第三步的操做在两个地方被调用。一个是在recv_recovery_from_checkpoint_finish的最后,另一个是在recv_recovery_rollback_active中。前者主要是回滚对数据字典的操做,也就是回滚DDL语句的操做,后者是回滚DML语句。前者是在数据库可提供服务以前必须完成,后者则能够在数据库提供服务(也便是崩溃恢复结束)以后继续进行(经过新开一个后台线程trx_rollback_or_clean_all_recovered来处理)。由于InnoDB认为数据字典是最重要的,必需要回滚到一致的状态才行,而用户表的数据能够稍微慢一点,对外提供服务后,慢慢恢复便可。所以咱们经常在会发现数据库已经启动起来了,而后错误日志中还在不断的打印回滚事务的信息。事务回滚的核心函数是trx_rollback_or_clean_recovered,逻辑很简单,只须要遍历trx_sys->trx_list,按照事务不一样的状态回滚或者提交便可(trx_rollback_resurrected)。这里要注意的是,若是事务是TRX_STATE_PREPARED状态,那么在InnoDB层,不作处理,须要在Server层依据binlog的状况来决定是否回滚事务,若是binlog已经写了,事务就提交,由于binlog写了就可能被传到备库,若是主库回滚会致使主备数据不一致,若是binlog没有写,就回滚事务。运维

崩溃恢复相关参数解析

innodb_fast_shutdown:
innodb_fast_shutdown = 0。这个表示在MySQL关闭的时候,执行slow shutdown,不但包括日志的刷盘,数据页的刷盘,还包括数据的清理(purge),ibuf的合并,buffer pool dump以及lazy table drop操做(若是表上有未完成的操做,即便执行了drop table且返回成功了,表也不必定马上被删除)。
innodb_fast_shutdown = 1。这个是默认值,表示在MySQL关闭的时候,仅仅把日志和数据刷盘。
innodb_fast_shutdown = 2。这个表示关闭的时候,仅仅日志刷盘,其余什么都不作,就好像MySQL crash了同样。
这个参数值越大,MySQL关闭的速度越快,可是启动速度越慢,至关于把关闭时候须要作的工做挪到了崩溃恢复上。另外,若是MySQL要升级,建议使用第一种方式进行一次干净的shutdown。异步

innodb_force_recovery:
这个参数主要用来控制InnoDB启动时候作哪些工做,数值越大,作的工做越少,启动也更加容易,可是数据不一致的风险也越大。当MySQL由于某些不可控的缘由不能启动时,能够设置这个参数,从1开始逐步递增,知道MySQL启动,而后使用SELECT INTO OUTFILE把数据导出,尽最大的努力减小数据丢失。
innodb_force_recovery = 0。这个是默认的参数,启动的时候会作全部的事情,包括redo日志应用,undo日志回滚,启动后台master和purge线程,ibuf合并。检测到了数据页损坏了,若是是系统表空间的,则会crash,用户表空间的,则打错误日志。
innodb_force_recovery = 1。若是检测到数据页损坏了,不会crash也不会报错(buf_page_io_complete),启动的时候也不会校验表空间第一个数据页的正确性(fil_check_first_page),表空间没法访问也继续作崩溃恢复(fil_open_single_table_tablespacefil_load_single_table_tablespace),ddl操做不能进行(check_if_supported_inplace_alter),同时数据库也被不能进行写入操做(row_insert_for_mysqlrow_update_for_mysql等),全部的prepare事务也会被回滚(trx_resurrect_inserttrx_resurrect_update_in_prepared_state)。这个选项仍是很经常使用的,数据页多是由于磁盘坏了而损坏了,设置为1,能保证数据库正常启动。
innodb_force_recovery = 2。除了设置1以后的操做不会运行,后台的master和purge线程就不会启动了(srv_master_threadsrv_purge_coordinator_thread等),当你发现数据库由于这两个线程的缘由而没法启动时,能够设置。
innodb_force_recovery = 3。除了设置2以后的操做不会运行,undo回滚数据库也不会进行,可是回滚段依然会被扫描,undo链表也依然会被建立(trx_sys_init_at_db_start)。srv_read_only_mode会被打开。
innodb_force_recovery = 4。除了设置3以后的操做不会运行,ibuf的操做也不会运行(ibuf_merge_or_delete_for_page),表信息统计的线程也不会运行(由于一个坏的索引页会致使数据库崩溃)(info_lowdict_stats_update等)。从这个选项开始,以后的全部选项,都会损坏数据,慎重使用。
innodb_force_recovery = 5。除了设置4以后的操做不会运行,回滚段也不会被扫描(recv_recovery_rollback_active),undo链表也不会被建立,这个主要用在undo日志被写坏的状况下。
innodb_force_recovery = 6。除了设置5以后的操做不会运行,数据库前滚操做也不会进行,包括解析和应用(recv_recovery_from_checkpoint_start_func)。函数

总结

InnoDB实现了一套完善的崩溃恢复机制,保证在任何状态下(包括在崩溃恢复状态下)数据库挂了,都能正常恢复,这个是与文件系统最大的差异。此外,崩溃恢复经过redo日志这种物理日志来应用数据页的方法,给MySQL Replication带来了新的思路,备库是否能够经过相似应用redo日志的方式来同步数据呢?阿里云RDS MySQL团队在后续的产品中,给你们带来了相似的特性,敬请期待。

相关文章
相关标签/搜索