InnoDB页面持久化 - saviochen/mysql_docs GitHub Wiki
数据库有别于文件系统的特征之一为能实现崩溃恢复。为了实现这一特征,数据库引擎需要采用WAL(Write-ahead logging)的方式来持久化数据文件,即在数据文件落盘之前,先将描述数据变更的日志先落盘。当数据文件落盘时意外崩溃时,虽然数据文件没落盘成功,数据库能却还能使用提前落盘的日志重新将数据文件恢复成崩溃前的状态,不丢失数据。innodb同样也是使用WAL的方式来持久化数据文件。innodb存储数据变更的日志称为redo log。
1. Redo log文件结构
redo log文件由多个物理文件组成,文件大小用innodb_log_buffer_size配置,文件个数用innodb_log_files_in_group配置,文件名依次为ib_logfile0,ib_logfile1,...,依此类推。ib_logfileN在innodb引擎中被认为是逻辑前后相邻,首尾相连,即redo log在ib_logfile N中从前往后写入,写到ib_logfileN最后再从ib_logfile0开始写,如下图所示:
redo log用LSN(log sequence number)标识。LSN是redo log的逻辑物理偏移,代表redo log在逻辑ib_logfile中的物理偏移量(仅为日志数据的物理偏移,不包括ib_logfile BLOCK中的系统信息)。循环写ib_logfile过程中,为了避免覆盖仍然有效的旧redo log,需要主动失效redo log。由于redo log归根结底是描述页面修改的日志,当redo log描述的所有脏页已经刷盘成功,对应的redo log即失效。主动将一部分redo log对应的脏页刷盘,使对应的redo log失效的行为称为checkpoint。innodb通过checkpoint机制能循环使用ib_logfile,不会覆盖仍然有效的旧redo log。
ib_logfile以512B为单位划分成block,block分为系统block和记录redo log的log block,所有block最后4个字节记录用于校验block一致性的checksum。每个ib_logfile文件的前4个block为系统block,后续是存储redo log的log block。ib_logfile的宏观结构如下:
上述iblogfile的系统block的信息含义如下(详见log0log.h文件):
| 偏移 | 名称 | 含义 |
|---|---|---|
| 0 | LOG_HEADER_FORMAT | 旧版本称为LOG_GROUP_ID,表示redo log归档功能中redo log的编号。5.7中该功能已被废弃,只存在一份redo log,因此此值为0。 |
| 4 | LOG_HEADER_PAD1 | 暂未使用的字段,此值为0。 |
| 8 | LOG_HEADER_START_LSN | 此日志文件中数据开始的 LSN,也就是文件偏移量为2048字节初对应的LSN值。 |
| 16 | LOG_HEADER_CREATOR | 标识redo log是由mysql或者mysqlbackup创建,以及对应的版本号 |
| 48 | LOG_HEADER_FLAGS | 第一个bit标识redo log是否disable,第二个bit标识server是否无法使用redo log恢复。其他bit尚未使用。 |
| 512 | LOG_CHECKPOINT_1 | 日志头中的第一个checkpoint字段。 当创建新的checkpoint时,交替在两个checkpoint字段中写入checkpoint信息。仅在ib_logfile0中定义。 |
| 512*N-4 | LOG_BLOCK_CHECKSUM | LOG BLOCK的checksum,用于校验其完整性。 |
| 1024 | LOG_ENCRYPTION | redo log加密信息。 |
| 1536 | LOG_CHECKPOINT_2 | 日志头中的第二个checkpoint字段。 在写入新checkpoint时与LOG_CHECKPOINT_1交替使用。仅在ib_logfile0中定义。 |
如上所述,LOG_CHECKPOINT_1和LOG_CHECKPOINT_2用于交替记录checkpoint的信息,具体如下:
| 偏移 | 名称 | 含义 |
|---|---|---|
| 0 | LOG_CHECKPOINT_NO | checkpoint编号。如前所述,checkpoint对应的lsn之前的redo log均已失效。crash recovery会从最大的checkpoint编号对应的lsn开始扫描redo log。 |
| 8 | LOG_CHECKPOINT_LSN | checkpoint 对应当lsn。小于8.0版本时该值为准确值。8.0版本中由于flush list不再按严格顺序排列,flush_list存在一定程度乱序,该值不再精准,需要减去recent_closed的大小2M。recent_closed稍后会介绍。 |
| 16 | LOG_CHECKPOINT_OFFSET | checkpoint lsn的偏移,用于校准checkpoint lsn。 |
| 24 | LOG_CHECKPOINT_LOG_BUF_SIZE | 创建checkpoint时,log buffer的大小。当log buffer resize时,写入checkpoint的线程会被暂停,因此该值总能记录准确的log buffer大小。 |
redo log存储于log block中,log block头部有12位系统信息,具体介绍如下:
| 偏移 | 名称 | 含义 |
|---|---|---|
| 0 | LOG_BLOCK_HDR_NO | block的大于0的唯一标识,允许在达到1G时圆整,如果是第一个call fil_io的block,会将最高bit设置为1。 |
| 4 | LOG_BLOCK_HDR_DATA_LEN | 写入此block的redo log数据量,包括log header的12字节。最高bit用来表示是否加密。 |
| 6 | LOG_BLOCK_FIRST_REC_GROUP | 用来表示该block内,mtr(mini-transaction)中第一条redo log的位置,如果没有则为0。mtr的概念后续会展开介绍。 |
| 8 | LOG_BLOCK_CHECKPOINT_NO | 表示block最后被写入时log_sys->next_checkpoint_no的值。 |
2. Redo log类型
Redo log主要分为以下几类:
2.1. 用于页面的纯物理日志
此类redo log记录的是某space id,某page_no的page中,某offset下的物理变更。此类redo log type包括:MLOG_1BYTE、MLOG_2BYTES、MLOG_4BYTES、MLOG_8BYTES、MLOG_WRITE_STRING等。原则上MLOG_1BYTE、MLOG_2BYTES、MLOG_4BYTES统统可以合并成到MLOG_8BYTES中,但innodb为了尽可能减小redo log大小,减少不必要的IO,避免存储空间浪费,将其进行了分类。此类redo log的格式通常如下:
| type | space_id | page_no | offset | data |
|---|---|---|---|---|
| 类型 | 表空间编号 | 页面偏移 | 页内偏移 | 数据 |
对于MLOG_1BYTE这种写数字的redo 类型而言,data是加密后的unsigned int(mlog_write_ulint)。对于MLOG_WRITE_STRING这种写字符串的类型而言,data是len + string,即2字节的string长度,再加上string的内容(mlog_log_string)。
2.2. 用于页面的逻辑物理日志
除在某处更改数字或字符外,innodb有时需要处理更加复杂的数据更改,例如新增一个数据页面后对页头页尾信息执行标准初始化(MLOG_PAGE_CREATE);插入数据时除了在cluster index多个二级索引同时需要插入数据,并且可能遇到索引对应的B+数分裂等情况(MLOG_REC_INSERT);初始化change buffer bitmap(MLOG_IBUF_BITMAP_INIT)等等。在处理此类复杂数据变更时,仅仅通过纯物理日志,将产生大量redo log,造成大量IO,浪费大量存储空间,影响innodb性能,因此,innodb还存在一种逻辑物理日志。用以通知某space_id,某page_no的page中发生了某事件,在crash recovery过程中,innodb识别出事件类型,能够按照既定的规则重做该事件,同样起到了防止数据变更丢失的效果。由于逻辑物理日志的日志量少,较纯物理日志的优势明显,因此在redo log中被广为使用。除了之前介绍的MLOG_PAGE_CREATE、MLOG_REC_INSERT、MLOG_IBUF_BITMAP_INIT类型外,逻辑物理日志还包括:
| 类型 | 含义 |
|---|---|
| MLOG_REC_CLUST_DELETE_MARK | 将一条cluster index记录标记为逻辑删除,在记录的info bits中delete mark。 |
| MLOG_REC_SEC_DELETE_MARK | 将一条secondary index记录标记为逻辑删除,在记录的info bits中delete mark。 |
| MLOG_REC_UPDATE_IN_PLACE | 原地更新一条记录。 |
| MLOG_REC_DELETE | 物理删除一条记录。当一条记录不再被MVCC需要,purge会物理删除该记录,并产生此日志。 |
| MLOG_LIST_END_DELETE | 在索引页面上批量删除记录的终点。 |
| MLOG_LIST_START_DELETE | 在索引页面上批量删除记录的起点。 |
| MLOG_LIST_END_COPY_CREATED | 将记录列表拷贝到新的索引页面。 |
| MLOG_PAGE_REORGANIZE | 重新整理ROW_FORMAT为REDUNDANT类型页面中的记录。 |
| MLOG_UNDO_INSERT | 插入一条undo记录。 |
| MLOG_UNDO_ERASE_END | 插入undo log,如果发现page空间不足,需要跨undo page时,会擦除空间不足的undo页面上的end segment,并产生此日志。 |
| MLOG_UNDO_INIT | 初始化一个undo页面。 |
| MLOG_UNDO_HDR_REUSE | 重用一个undo log header。 |
| MLOG_UNDO_HDR_CREATE | 创建一个undo log header。 |
| MLOG_REC_MIN_MARK | 在非叶子结点层的最小记录的info bits加上min_mark标记。 |
| MLOG_DUMMY_RECORD | 用于crash recovery,用于填充log block的虚拟日志记录。 |
| MLOG_COMP_LIST_END_DELETE | 在compact类型的索引页面上批量删除记录的终点。 |
| MLOG_COMP_LIST_START_DELETE | 在compact类型的索引页面上批量删除记录的起点。 |
| MLOG_COMP_LIST_END_COPY_CREATED | 将compact记录列表拷贝到新的索引页面。 |
| MLOG_COMP_PAGE_REORGANIZE | 重新索引页面中的记录。 |
| MLOG_ZIP_WRITE_NODE_PTR | 在压缩的非叶子节点上记录节点指针。 |
| MLOG_ZIP_WRITE_BLOB_PTR | 在压缩页面上写入外部存储列的 BLOB 指针。 |
| MLOG_ZIP_WRITE_HEADER | 写入压缩页page header。 |
| MLOG_ZIP_PAGE_COMPRESS | 压缩一个索引页。 |
| MLOG_ZIP_PAGE_COMPRESS_NO_DATA | 写一个压缩索引页的日志记录,该页上没有数据。 |
| MLOG_ZIP_PAGE_REORGANIZE | 重新整理一个压缩页。 |
| MLOG_PAGE_CREATE_RTREE | 创建一个R-tree页面。 |
| MLOG_COMP_PAGE_CREATE_RTREE | 创建一个compact类型的R-tree页面。 |
| MLOG_INIT_FILE_PAGE2 | 初始化一个新的表空间页面。用于替代旧的MLOG_INIT_FILE_PAGE类型。 |
| MLOG_TABLE_DYNAMIC_META | 持久化元数据更改时记录的日志。 |
| MLOG_PAGE_CREATE_SDI | 创建SDI页面。 |
| MLOG_COMP_PAGE_CREATE_SDI | 创建compact类型的SDI页面。 |
不同的物理物理日志类型对应的格式不尽相同,下面以MLOG_COMP_REC_INSERT类型举例介绍其格式(page_cur_insert_rec_write_log):
| type | space_id | page_no | n_filed | n_unique | field_len | current rec offset | record length and storage flag | info bits | record origin offset | mismatch index | record data |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 类型 | 表空间编号 | 页面偏移 | 列个数 | 能唯一决定一条记录的列个数 | 每个列的长度 | 当前列的页内偏移(需要修改该记录的next_record指针) | 新记录的end segment length和extra info storage flag | 新记录的extra_size和data_len和上一记录相同时不写入,不同时写入新记录的info bits、record origin offset、mismatch index | 为节省空间只存储与上一记录不同的数据部分 |
为了尽可能让redo log节省空间,MLOG_COMP_REC_INSERT会尽可能复用插入的记录的前一记录的数据,只存储插入的记录与上一记录不同的数据部分,上表中mismatch index表示插入的记录与当前记录比对中,不一致的长度,record data中则记录了这一部分数据。
2.3. 用于表空间或批量页面的逻辑物理日志
此类redo log记录对一个space文件的创建、重命名、修改,或者记录批量页面的修改。由于文件操作的REDO是在文件操作结束后才记录的,因此在恢复的过程中看到这类日志时,说明文件操作已经成功,因此在crash recovery中大多只是做对文件状态的检查。此类redo log具体类型和对应的含义如下:
| 类型 | 含义 |
|---|---|
| MLOG_FILE_DELETE | 对应ibd文件的删除操作。 |
| MLOG_FILE_CREATE2 | 文件创建时写入。 |
| MLOG_FILE_RENAME2 | 重命名表空间文件。 |
| MLOG_FILE_NAME | 在checkpoint后第一次修改。 |
| MLOG_CHECKPOINT | 检查点之后的所有日志对应的space映射已经完全建立起来了。在它之后不存在MLOG_FILE_NAME记录缺失。不过在8.0也被废弃了。 |
| MLOG_INDEX_LOAD | 提示正在批量载入索引数据,不写入单个页面的redo log日志。 |
| MLOG_TABLE_DYNAMIC_META | 持久化元数据更改时记录的日志。 |
| MLOG_FILE_EXTEND | 拓展表空间。 |
space文件的相关日志格式中都包含文件的路径,page_no都填零(fil_op_write_log),下面以MLOG_FILE_RENAME类型举例介绍其格式:
| type | space_id | page_no | path_len | path | new_path_len | new_path |
|---|---|---|---|---|---|---|
| 类型 | 表空间编号 | 0 | 文件路径长度 | 文件路径 | 新文件路径长度 | 新文件路径 |
crash recovery中一个重要步骤是确认space_id和数据文件路径的对应关系。下面简单介绍各版本中对此问题的解决方案:
- 5.6通过扫描所有数据文件,读取第一个数据页获取space_id,以此建立space_id和数据文件路径的映射关系,这种方式需要扫描多余的数据文件,影响crash recovery的速度。
- 5.7为了避免crash recovery把所有数据文件都打开一次的问题,引入了MLOG_FILE_NAME日志:在每次checkpoint之后第一次打开修改数据文件页面时,对该文件记录MLOG_FILE_NAME日志,记录数据文件路径与space_id的关系。但仅仅用MLOG_FILE_NAME日志存在并发问题:无法保证所有修改的数据页对应的MLOG_FILE_NAME日志记录在checkpoint之后都存在,即checkpoint可能正好位于某个数据文件对应的MLOG_FILE_NAME日志之后,在该数据文件对应的数据页修改日志记录之前。因此,又引入MLOG_CHECKPOINT日志作为checkpoint结束标记:在系统做checkpoint时,扫描内存中的所有space,如果被修改过就写入MLOG_FILE_NAME,当所有MLOG_FILE_NAME写完,补充一个MLOG_CHECKPOINT日志,表示在此时,checkpoint之后的所有space_id映射已经完全建立起来了。由于MLOG_FILE_NAME和MLOG_CHECKPOINT的引入,crash recovery时确定了恢复起点checkpoint后,还需要确认对应的MLOG_CHECKPOINT。crash recovery变得非常复杂,也容易出现bug。
- 8.0对如何建立space_id和对应文件路径的过程再此进行了重构,废弃了MLOG_FILE_NAME和MLOG_CHECKPOINT,采用了一种全新的方式:单独创建了系统映射文件,将space id及路径信息轮换着写到两个指定的系统文件tablespaces.open.1和tablespaces.open.2中。InnoDB对文件进行打开,关闭,创建,删除,重命名等操作进行追踪,先在内存cache(Fil_Open::m_spaces)中更新状态,再写入对应的MLOG_FILE_*日志。内存的cache会在文件路径变更(如rename tablespace)时刷盘,在创建checkpoint前也会清理一次cache,删除掉状态为DELETED/MISSING的无效表空间记并刷盘。当系统正常关闭时,InnoDB会去将系统文件中的信息全部清除掉(fil_tablespace_open_clear),因为崩溃恢复无需用到。
2.4. log group标记
前文已经提到在log block header中LOG_BLOCK_FIRST_REC_GROUP用于标记该block内,mtr(mini-transaction)中第一条redo log的位置。redo log类型中同样有一种与mtr相关的类型MLOG_MULTI_REC_END。在介绍这些内容之前,先来了解mtr。
mtr(mini-transaction)是数据变更的一个基本单位,例如在索引页面上插入一条记录时,该记录的前一条记录next_record指针需要修改,页头INDEX header中的PAGE_LAST_INSERT、PAGE_N_RECS、PAGE_N_DIRECTION 等信息需要修改、页面尾部的slot也需要修改。与此同时,二级索引也需要进行数据更新。如果记录插入导致了索B+树分裂,还需要初始化新的索引页面,移动索引记录。前述操作的修改要么全不做,要么全部完成,这是一个基本的动作单位,不允许出现中间状态。上述的所有动作合在一起称为mini-transaction(mtr),其产生的日志称为log group。由此可见,一个mtr由一到多条redo log组成,一个sql语句由一到多个mtr组成,一个事务由多一到多条sql操作组成,如下图所示:

为了在crash recovery的时候标记哪些日志属于一个mtr,需要在一个mtr的最后添加一条MLOG_MULTI_REC_END日志,用以标记mtr的结束。如果多个mtr都只有一条redo log,每条redo log后都记录一条MLOG_MULTI_REC_END会非常浪费IO和存储资源。为了避免此情况发生,innodb会在一个字节大小的redo log type的最高bit上设置MLOG_SINGLE_REC_FLAG标记,来标识只有一条redo log的mtr。
崩溃恢复时,只有解析到完整的redo log group,才会将该redo log group应用到数据页面上。如果mtr的第一个redo log无MLOG_SINGLE_REC_FLAG标志,也无法找到其后续的MLOG_MULTI_REC_END日志,则说明redo log损坏了,此段日志将被抛弃。
3. Redo log的生成与落盘
3.1 Redo log的写入
前文已提及mtr的作用,现介绍mtr在内存中运行过程。用户线程有修改页面的需求时需要开启mtr(mtr_start),mtr启动会将用户对页面的修改内容以redo log的方式记录在mtr->Impl->m_log中。当mtr提交时(mtr_commit),用户线程先申请代表redo log内容编号的log_t::sn(Sequence Number),sn是redo log掐头去尾后的内容部分,sn可以与lsn(Log Sequence Number)进行转化,计算出需要在redo log buffer中占据的空间大小。用户线程拷贝mtr->Impl->m_log中的日志中,等待日志刷盘,并将被修改的脏页按照oldest modification LSN的顺序插入到flush list中,如果脏页已经在flush list中的话则不重复插入,也不修改脏页的顺序。
上述过程在5.7中的运行过程如图所示:
5.7以及更早的设计当中,使用log_sys_t::mutex确保mtr->Impl->m_log中的日志有序拷贝到redo log buffer中,使用log_sys_t::flush_order_mutex确保脏页能有序插入flush list。这样的设计其实已经使用了锁拆分的思想,mtr->Impl->m_log的日志拷贝和脏页插入flush list使用一把大锁是没有问题的,而拆分成两个锁,可以让脏页插入flush list的过程不影响mtr->Impl->m_log的日志拷贝到redo log buffer。即使如此,当大量的用户提交mtr时,上述模块仍然是性能瓶颈点。
8.0优化了上述架构,实现了无锁化。简而言之,mtr在提交之前,用户线程已知晓其日志量,因此先更新全局的log_t::sn拿到mtr对应的sn区间,再通过sn到lsn的转化(log_translate_sn_to_lsn)以及lsn与redo log buffer的对应关系,计算出此mtr的日志在log buffer中位置。所有mtr都能通过上述方式并发计算出其mtr->Impl->m_log在redo log buffer中的对应位置,再并发拷贝。通过这种并发拷贝的方式,避免了维护redo log buffer拷贝顺序的log_sys_t::mutex。但由此而来的问题是不同mtr的拷贝速度不同,lsn大的mtr可能先完成redo log的拷贝,lsn小的mtr可能尚未拷贝完成,即redo log buffer中出现了空洞。出现空洞的redo log buffer无法持久化到磁盘中,需要找到一个办法找出redo log buffer中不存在空洞的最大lsn。
为了解决这个问题,innodb引入了一个新的数据结构link_buf,其为大小固定、元素类型为std::atomic的数组。innodb用于跟踪每个mtr的redo log拷贝情况的link_buf称为recent_written,每个mtr完成redo log拷贝后,会在start_len处记录拷贝的redo log长度。下面举例说明其用法:假设数组大小为10,初始化为[0 0 0 0 0 0 0 0 0 0],有3个mtr并发写入redo log,申请到的lsn区间分别是mtr1: [0,4]、mtr2: [5,7]、mtr3:[8,9]。假设mtr3先完成redo log拷贝,则数组变为[0 0 0 0 0 0 0 0 2 0]。假设mtr2随后完成拷贝,则数组变为[0 0 0 0 0 3 0 0 2 0]。最后mtr1完成拷贝,数组变为了[5 0 0 0 0 3 0 0 2 0]。通过recent_written,innodb可以跟踪所有redo log的拷贝情况,找到已经完成连续拷贝的最大lsn。
除了优化掉log_sys_t::mutex外,8.0同样优化掉了log_sys_t::flush_order_mutex。去除log_sys_t::flush_order_mutex后,innodb允许加入flush_list的脏页不再严格按照oldest modification LSN有序,而是允许一定程度的并发,因此可能出现oldest modification LSN大的脏页先加入flush_list,oldest modification LSN小的脏页后加入flush_list,并且在flush_list中的顺序也较靠后。为解决flush list的乱序问题,InnoDB引入了第二个link_buf称为recent_closed,大小为2M(INNODB_LOG_RECENT_CLOSED_SIZE_DEFAULT)。recent_closed与recent_written内容相同,如上述三个mtr的场景,最终recent_closed的内容也将会是[5 0 0 0 0 3 0 0 2 0],但二者更新时机不同:redo log拷贝到redo log buffer时更新recent_written,将页面挂在flush list时更新recent_closed。recent_closed用来追踪已经连续挂入flush list的lsn,其队尾recent_closed.m_tail代表此之前的lsn脏页都已经挂入flush_list。在recent_closed的帮助下,flush list的oldest modification LSN乱序仅存在于局部范围,即[recent_closed.m_tail, recent_closed.m_tail+2M),recent_closed.m_tail不断推进,才能让flush list不断接受oldest modification LSN更大的页面。换言之,当前flush list最小的oldest modification LSN - 2M前的页面可以认为都已经刷入磁盘。关于recent_closed的更多使用详见下一章Checkpoint。
参与redo log生成的线程比较多,包括:用户线程、log_writer、log_flusher、log_closer、log_write_notifier、log_flush_notifier等。每个线程的权责如下:
- 用户线程:用户线程为mtr申请到redo log buffer中的位置后,并发写入redo log buffer。如果redo log buffer空间不足,则用户线程会唤醒log_writer线程将连续的redo log写入page cache然后释放出redo log buffer。顺序写入redo log buffer后用户线程更新reccent_written对应区间的标记,接着将脏页挂到flush list上,更新recent_closed上对应区间的标记,等待log_flusher通知日志落盘。
- log_writer:等待被用户线程唤醒或者timeout自我唤醒。醒来后扫描recent_written,向前推进已经连续写入的redo log buffer,并将其写入page cache中,唤醒等待log_writer的用户线程或者用于辅助通知的log_write_notifier线程。写完page cache后唤醒log_flusher线程刷盘。
- log_flusher:等待log_writer唤醒或者被用户线程调用flush_to_disk模式的log_write_up_to直接唤醒。log_flusher如果发现当前写入page cache的write_lsn大于上次刷盘的lsn,即产生了新的刷盘需求,则将增量的部分刷入磁盘,唤醒用户线程或者用户辅助通知的log_flush_notifier线程。
- log_closer:不等待任何条件变量,每隔一段时间扫描recent_closed,向前推进,recent_closed.m_tail代表之前的lsn已经都挂在flush_list上了,用来取checkpoint时用。
- log_write_notifier/log_flush_notifier:write_events[],flush_events[]两个数组默认有2048个slot,目的是为了尽可能精确通知目标线程,防止无效唤醒。如果本次写入的lsn落在相同的slot中,则log_writer和log_flusher会选择直接通知相应的用户,如果落在多个slot中,则log_writer和log_flusher会选择唤醒log_write_notifier/log_flush_notifier,避免亲自做大量的通知工作,影响redo log的写入效率。
4. Checkpoint
Checkpoint是innodb的一个重要概念。它指的是一个lsn值,在此lsn值之前所有脏页都已经持久化,对应的redo log是没有必要的,在崩溃恢复时(crash recovery)时,只需要从checkpoint lsn起开始扫描redo log日志即可。前文已经提及,在innodb运行过程中产生的checkpoint信息会记录在ib_logfile中会在前4个系统block。下面介绍checkpoint的产生时机,以及checkpoint的lsn如何选取。
checkpoint总体分为sharp和fuzzy两种类型。生成sharp checkpoint时需要提供目标lsn,它会强迫innodb将所有脏页刷盘,并在ib_logfile0系统block记录checkpoint信息。fuzzy checkpoint的checkpoint lsn选择当前合适的lsn,不强迫innodb刷脏页,只是将当前的checkpoint信息写入ib_logfile0的系统block中。checkpoint由log_checkpointer线程生成,log_checkpointer等待在1秒的timeout或者log.checkpointer_event事件上。能够通过log.checkpointer_event唤醒log_checkpointer线程的场景很多,举例如下:
- log_writer检测发现redo log buffer空间不足时,会唤醒log_checkpointer生成异步fuzzy checkpoint,失效redo log buffer中的旧redo log(log_writer_wait_on_checkpoint)。
- 手动设置innodb_checkpoint_now_set创建sharp checkpoint、import表空间后(row_import_cleanup)、表空间升级后(dd_upgrade_finish)等场景下读取当前最新的lsn,并且等待所有脏页刷至上述lsn,创建sharp checkpoint(log_make_latest_checkpoint)。
- ibuf merge page、btree的bulk操作、innodb_ddl_log表的插入删除等操作时,开启mtr前会校验redo log中的空间是否足够,不足则唤醒log_checkpointer生成checkpoint,等待足够的redo log buffer(log_free_check_wait)。
- 用户设置innodb_log_checkpoint_fuzzy_now、克隆实例中的归档页面等生成fuzzy checkpoint(log_request_checkpoint)。
log_checkpointer被唤醒后,先推进recent_closed的尾部,再计算当前最适合作为checkpoint lsn的值。此时需要使用到的lsn值如下(log_compute_available_for_checkpoint_lsn):
- recent_closed.m_tail,它代表在此之前的lsn对应的脏页都已经挂在了flush_list上。(value1)
- flush_list上取最前端页面的lsn - recent_closed大小(2M)。(value2)
- flushed_to_disk_lsn,它代表此之前lsn对应的redo log都已经刷到盘上。(value3)
innodb取checkpoint lsn的方法是min(min(value1, value2), value3)。min(value1, value2)表示最大已经持久化到页面的lsn,此值用value4来表示的话,min(value4, value3)表示落盘的redo log文件内最大可以失效的值。落盘日志lsn大于落盘页面lsn较为常见(value4 <= value3),最大checkpoint lsn只能选落盘页面lsn;落盘页面lsn大于落盘日志lsn较不常见(value4 > value3),表示通过io合并提前将尚未落盘的日志对应的页面已经刷盘(落盘的页面即包含旧修改也包含新修改,在落盘旧修改时将新修改合并落盘了)。由于不是所有页面都能那么巧,顺利合并落盘,可能存在lsn处于value3和value4,但还有没有落盘的页面,此时checkpoint lsn只能选择最大的落盘日志lsn。
并非每次log_checkpointer被唤醒后,都能顺利写入checkpoint,是否写checkpoint的原则如下(log_should_checkpoint):
- 距离上次checkpoint时间是否已经超过1s,可以使用innodb_log_checkpoint_every配置,参数的单位是毫秒。
- 上次checkpoint的lsn与当前最新的lsn的距离(checkpoint_age)是否超过了max_checkpoint_age_async。
- last_checkpoint_lsn < requested_checkpoint_lsn <= log.available_for_checkpoint_lsn。即被请求的checkpoint_lsn小于当前最大允许checkpoint_lsn。 得到checkpoint写入许可后,log_checkpointer调用log_checkpoint生成checkpoint,在ib_logfile0的系统block中记录checkpoint信息。
5. 页面刷盘
5.1. Page cleaner
innodb buffer pool中的被修改过的页面称为脏页,将脏页持久化到磁盘的过程称之为刷脏。5.6版本之前,刷脏工作交给innodb master线程完成。innodb master线程负责完成例如ibuf的合并、redo log的flush操作、cpu使用率统计、唤醒purge线程、大表异步删除等操作,每秒循环一次。由于master线程的工作繁忙,在5.6版本引入了新的称为page cleaner的线程专门负责刷脏,而后又在5.7.4版本中引入了多个page cleaner线程提高了刷脏的效率。下面介绍page cleaner的实现原理。
每个buffer pool instance的刷脏状态用一个slot(page_cleaner_slot_t)表示,它们一一对应。slot中记录了当前buffer pool instance的刷脏状态(page_cleaner_state_t,包括已发起刷脏请求PAGE_CLEANER_STATE_REQUESTED、正在刷脏PAGE_CLEANER_STATE_FLUSHING和完成刷脏PAGE_CLEANER_STATE_FINISHED三个状态)、本轮刷脏在此buffer pool instance中的目标刷脏页面数(n_pages_requested)、本轮分别在LRU和flush list中刷脏的页面数(n_flushed_lru和n_flushed_list)、以及分别在LRU和flush list中刷脏所使用的时间(flush_lru_time和flush_list_time)等信息。
page cleaner由一个协调线程(buf_flush_page_coordinator_thread)和多个工作线程(buf_flush_page_cleaner_thread)组成,协调线程也会进行刷脏工作,与工作线程共同承担刷脏任务。全局的page_cleaner_t结构体中记录了刷脏线程需要的信息,为所有page cleaner线程共享。page_cleaner_t结构体包括的主要内容如下:
- mutex:保护page_cleaner_t,page cleaner线程修改page_cleaner_t中的大部分信息都需要持有此锁。
- is_requested/is_finished:两个同步事件。前者用于激活等待中的page cleaner线程,后者用于通知协调线程本次刷脏结束。
- requested:表示本次刷脏操作是否要刷flush_list。为false的话,本次刷脏只刷LRU list。
- lsn_limit:页面最早修改的lsn小于该值的都需要刷盘。
- slots:所有buffer pool instance的刷脏状态,元素为page_cleaner_slot_t的数组。
- n_slots/n_slots_requested/n_slots_flushing/n_slots_finished:slot总数/当前请求刷脏的slot数量、正在刷脏的slot数量、完成刷脏的slot数量。
- flush_time:page cleaner线程的刷slot的时间汇总。
- is_running:page_cleaner的状态,当shutdown server时该值为false。
page cleaner线程与slot(或者说与buffer pool instance)之间没有一一对应的关系。协调线程以及各工作线程间在page_cleaner→mutex保护下争夺PAGE_CLEANER_STATE_REQUESTED状态的slot,page_cleaner线程抢到slot后将slot从PAGE_CLEANER_STATE_REQUESTED状态更改为PAGE_CLEANER_STATE_FLUSHING状态,并执行刷脏。由于slot数与buffer pool instance的数量相等,因此innodb规定page cleaner线程的数量不允许超过buffer pool instance的数量,用户配置的innodb_page_cleaners如果过大,会被自动设置为与innodb_buffer_pool_instances相同的值。当配置的innodb_page_cleaners数量小于innodb_buffer_pool_instances时,即page cleaner线程数小于slot数量时,page cleaner协调线程在分发完任务后(pc_request),重复调用刷slot的函数(pc_flush_slot),直到所有slot都被刷脏。所有slot的刷脏任务都被分发出去后,协调线程等待所有刷脏任务结束(pc_wait_finished),并汇总本轮刷脏的信息。buffer pool instance、slot、page cleaner线程之间的关系如下图所示:

page cleaner线程刷脏有三种模式,刷脏行为与使用场景如下:
- BUF_FLUSH_LRU:对LRU尾部进行常规刷脏,腾出空闲页面(buf_flush_LRU_list)。page cleaner协调线程以最多1s的间隔或者收到buf_flush_event事件就会触发进行一轮的刷脏。每轮刷脏都会对LRU进行刷脏,扫描深度受innodb_lru_scan_depth配置,但不会对non-old的LRU部分进行扫描。刷脏后的页面被立刻淘汰出buffer pool。
- BUF_FLUSH_LIST:对flush list进行刷脏,将最早修改LSN最小的一批页面刷脏(buf_do_flush_list_batch)。page cleaner每轮刷脏过程中如果有明确的lsn目标(buf_flush_sync_lsn)则会进行flush list刷脏。适用于生成checkpoint,需要失效指定redo log buffer的场景(log_checkpointer->log_consider_sync_flush→log_request_sync_flush→buf_flush_request_force)。刷脏后的页面不淘汰出buffer pool。
- BUF_FLUSH_SINGLE_PAGE:对单个页面进行刷脏。适用的场景之一是:当LRU读入新页面时,无法从free_list获得空闲页面,需要从LRU的最尾部开始选择一个可以淘汰的页面。如果页面需要落盘则会选择此模式(buf_flush_single_page_from_LRU)。此模式为同步刷脏。刷脏后的页面是否淘汰受调用者控制。
刷脏时是否将脏页的相邻页面受innodb_flush_neighbors参数控制:
- 0 禁用 innodb_flush_neighbors。 相同范围内的脏页不会被刷新。
- 1 会刷新相同extent的连续脏页。
- 2 会刷新相同extent内的脏页。
5.2. 自适应刷脏
innodb的刷脏工作与buffer pool中的脏页比例有关,当脏页的百分比达到由 innodb_max_dirty_pages_pct_lwm 变量定义的低水位标记值时,将启动缓冲池脏页刷盘,该值默认为10%,将其设置为0会禁止这种早期刷脏行为。当buffer pool中的脏页比例达到innodb_max_dirty_pages_pct时,在Adaptive_flush::page_recommendation中会给出更积极的推荐值。
innodb负载突变可能带来的大量IO变化,如果刷脏工作不及时调整,可能需要生成sharp checkpoint,进而导致innodb的性能抖动。为避免上述情况发生,innodb引入了自适应刷脏功能,能够自动确保刷脏工作与innodb负载同步,保证平滑的整体性能。自适应刷脏的实现对应Adaptive_flush namespace。
page cleaner协调线程每轮刷脏都会在Adaptive_flush::page_recommendation的推荐下设置本轮刷脏的目标。Adaptive_flush::page_recommendation的lsn推荐值和page数量推荐值的计算基于当前的lsn产生速率、当前周期所有slot的LRU、flush_list的刷脏时间和次数总和等当前负载的基础信息。为了避免自适应刷脏过于敏感,innodb引入innodb_flushing_avg_loops参数来控制每次计算出新的基础信息后,持续使用的轮次。超过innodb_flushing_avg_loops轮刷脏后,才重新采样(Adaptive_flush::set_average)。在当前负载的基础上,根据lsn变化和checkpoint age设置页面刷新目标(set_flush_target_by_lsn),基于buffer pool中脏页的情况计算本轮目标的刷脏page数量(set_flush_target_by_page)。
自适应刷脏工作还与三个参数有关:
- innodb_max_dirty_pages_pct_lwm/innodb_max_dirty_pages_pct:当脏页的百分比达到由 innodb_max_dirty_pages_pct_lwm 变量定义的低水位标记值时,将启动缓冲池脏页刷盘,该值默认为10%,将其设置为0会禁止这种早起刷脏行为。当buffer pool中的脏页比例达到innodb_max_dirty_pages_pct时,在Adaptive_flush::page_recommendation中会给出更积极的推荐值。
- innodb_adaptive_flushing/innodb_adaptive_flushing_lwm:innodb_adaptive_flushing是自适应刷脏的开关。innodb_adaptive_flushing_lwm代表一个redo log buffer的容量阈值,当redo log buffer小于该大小时不会启用自适应刷脏。当lsn age超过max_modified_age_async时即使没有启动自适应刷脏,也会强行启动自适应刷脏。
- innodb_idle_flush_pct:当innodb处于idle状态时可以使用该值来限制刷脏量。在空闲期间限制页面刷新有助于延长固态存储设备的使用寿命。
5.3. 刷脏优化
关于刷脏功能,mysql官方团队已经做了比较完整。但是还是有一些值得优化的点,例如刷脏线程数的配置为read-only模式的,只能在启动时设置;刷脏线程的优先级可以适当调高等。可能的优化方案如下:
- 刷脏线程数动态设置:为每个page cleaner工作线程编号。在page cleaner协调线程的每轮循环中检查当前刷脏线程个数配置是否变化,如果新配置个数增加则创建编号为old_size到new_size的page cleaner工作线程;如果新配置的个数减少,则在协调线程中设置m_page_cleaner_ask_abort[i]为false,page cleaner工作线程在循环中监测到该信号后自动退出。在支持刷脏线程数动态设置后,仍需遵守最大个数不超过innodb_buffer_pool_instances的规定。
- 刷脏线程的系统优先级配置:mysql-8.0支持了为刷脏线程提高优先级的功能,让刷脏线程在操作系统中优先得到调度。但官方的功能要求mysqld必须具备root权限,否则无法完成优先级设置。给所有实例开通root权限对云数据库厂商而言不是一个安全的解决方案,这相当于给黑客开了一道后门。txsql为解决此问题,额外支持通过用户不可见的特权参数设置一个具备root功能的工具。使用该工具,既能满足设置优先级必须具备root权限的要求,又能避免安全漏洞。
6 Double write
InnoDB默认的页面大小是16K,文件系统IO的最小的单位是4K。假设内存中脏页写到磁盘的过程中,只写了4K就掉电了,那么磁盘中多这个数据页前4K是新的数据,后12K是旧的数据,这种情况称为部分写(Partial Write)。
6.1 Double write的工作原理
Redo log能将一个页面从完整的状态,变为另一个完整的状态,对于不完整状态的页面,redo log也束手无策。为了解决这个问题,InnoDB引入了两次写逻辑(Double wrie),其工作原理如下:
- 磁盘上的Double write的空间在实例初始化时已经在系统表空间ibdata中划分好,其大小为2M,分为两个block,占连续的128页:page64-127划属第一个block,page 128-191划属第二个block。内存中的Double write buffer与磁盘上的空间对应,也占2M,分为两部分。
- 当一系列机制触发数Buffer pool刷脏时,并不直接写入磁盘数据文件中,而是先拷贝至内存中的Double write buffer中,两个block交替使用,例如内存中block1在刷盘时,则向内存中block2拷贝页面。当内存block中的页面满了以后就将整个block持久化到系统表空间对应的区域。
- 待第二步完成后,再将Double write buffer中的脏页数据写入实际的各个表空间文件(离散写),脏页数据固化后,即进行标记对应Double write数据可覆盖。
虽然Double write虽然导致在刷脏时调用更多fsync操作,而硬盘的fsync性能是很慢的, 所以它会降低mysql的整体性能。但是Double write buffer写入磁盘共享表空间这个过程是连续存储,是顺序写,性能影响不太大(约占写的10%),牺牲一点写性能来保证数据页的完整还是很有必要的。
6.2 Double write的崩溃恢复
如果操作系统在将页写入磁盘的过程中发生崩溃,在恢复过程中发现页面Fil_header和Fil_trailer中前后checksum不一致,innodb存储引擎可以从共享表空间的Double write中找到该页的一个最近的副本,将其复制到表空间文件,再应用redo log,就完成了恢复过程。
7 Crash recovery
在服务器没有崩溃的情况下,redo log就像是累赘,不仅性能变差,也使得数据写入异常复杂。但等到服务器崩溃时,redo log的作用就显现出来了。
7.1 确定恢复的起点
checkpoint_lsn之前的页面修改都已经刷入磁盘,需要重新写入数据页面的是最后一次checkpoint之后的redo log对应的修改。在第一部分Redo log文件结构中已经提到,ib_logfile0的第2和第4个block记录了最后两次checkpoint的信息,包括LOG_CHECKPOINT_NO和LOG_CHECKPOINT_LSN等。只需比较LOG_CHECKPOINT_1和LOG_CHECKPOINT_2的大小,即可确定redo log的恢复起点。需要注意的是,8.0版本由于引入了recent_closed来加速页面刷盘,LOG_CHECKPOINT_LSN不再是准确值,需要减去recent_closed的大小2M。
7.2 确定恢复的终点
log block头部的12位系统信息中,LOG_BLOCK_HDR_DATA_LEN标记了写入此block的redo log数据量。对于被填满的log block来说,该值永远为512。如果该属性的值不为512,那么此为最后一个log block。
如前文所述,redo log group对应一个mini-transaction,其中的日志要么全应用,要么全抛弃。因此找到最后一个log block后,需要判断最后一个mtr是否有MLOG_SINGLE_REC_FLAG标志,或者mtr最后是否有MLOG_MULTI_REC_END日志,如果没有则说明最后一个mtr不完整,其日志将被抛弃。崩溃恢复的终点是最后一个完整的redo log group。
7.3 Redo log前滚
确定了崩溃恢复的redo log的起点和终点后,理论上只需要把所有redo log依次恢复到对应的页面上即可。但是逐条redo log应用的速度太慢,并且会因随机IO影响恢复效率,InnoDB在此阶段引入了哈希表:扫描redo log的起点和终点中间的redo log,根据redo log对应页面到的space id和page no计算散列值,把space id和page no相同的redo log放到哈希表的同一个槽里,如果有多个space id和page no都相同的redo log,那么它们之间使用链表连接起来,按照生成的先后顺序链接起来的。
扫描完所有待恢复到redo log后,遍历哈希表,由于对同一个页面的redo log都放在了一个槽里,所以可以一次性将一个页面恢复完成,避免随机IO,加速崩溃恢复效率。在redo log应用前需要先判断页面FIl_header中FIL_PAGE_LSN中的值是否大于等于redo log 的lsn,是的话说明此redo log对应的修改已经在页面刷盘时通过合并IO的方式持久化到页面中了,此redo log应被抛弃,不能再次应用到页面上。
7.4 Undo log回滚
在redo log完整前滚后,需要通过undo log回滚掉服务器崩溃时中间状态的事务。
undo log回滚的大致步骤为:先扫描所有回滚段的undo slot,根据undo slot处于的active、prepared等状态恢复崩溃之前的事务场景。提交处于非active、prepared状态的事务,回滚处于active状态的事务,最后留下prepared状态的事务。随后进入XA Recover阶段,MySQL使用内部XA,即通过Binlog和InnoDB做XA恢复。在初始化完成引擎后,Server层会开始扫描Binlog文件中记录的XID。由于binlog rotate的binlog文件中对应的事务一定是已经提交的,所以只需要扫描最后一个Binlog文件即可。比较Binlog文件中XID和InnoDB层的事务XID,如果如果XID已经存在于Binlog中了,对应的事务需要提交;否则需要回滚事务。关于undo log的更多介绍详见《InnoDB Undo Log详解》。