MySQL · 引擎特性 · InnoDB Buffer Page 生命周期

前言

InnoDB 没有使用操作系统自己的 Page Cache 机制,而是自己设计了一套 Buffer Pool 来进行 Page 的管理,关于 InnoDB Buffer Pool 的介绍,可以参考这篇文章,里面对 InnoDB Buffer Pool 作了比较深入的介绍。本文尝试从另外一个角度介绍一下一个 Buffer Page 的生命周期。本文给出的所有示例代码均基于 MySQL 8.0.18 版本。

申请

Page 读取

Page 的读取有一个统一的入口函数 buffer_page_get_gen ,该方法的主要入参为 page_id ,即获取指定的页,MySQL 8.0 中的主要流程如下:

/* 以 Buf_fetch_normal 为例 */
|--> fetch.single_page
|    |--> get(block) // loop
|    |    |--> lookup
|    |    |    |--> buf_page_hash_get_low // 检查 page_hash 中是否存在
|    |    |--> buf_block_fix // buf_fix_count 计数 +1
|    |    |
|    |    |--> read_page
|    |    |    |--> buf_read_page // 从文件中读取 page
|    |    |    |    |--> buf_read_page_low
|    |    |    |    |    |--> buf_page_init_for_read
|    |    |    |    |    |    |--> buf_LRU_get_free_block // 申请 1 个 block
|    |    |    |    |    |    |--> buf_page_hash_get_low // 再次检查 page_hash 中是否存在
|    |    |    |    |    |    |--> buf_page_init
|    |    |    |    |    |    |    |--> buf_block_init_low
|    |    |    |    |    |    |    |--> buf_page_init_low
|    |    |    |    |    |    |    |--> HASH_INSERT // 插入 page_hash
|    |    |    |    |    |    |--> buf_page_set_io_fix // io_fix 设置为 BUF_IO_READ
|    |    |    |    |    |    |--> buf_LRU_add_block // 添加到 LRU
|    |    |    |    |    |
|    |    |    |    |    |--> _fil_io // 读取文件
|    |    |    |    |    |--> buf_page_io_complete // 同步模式 IO 完成
|    |    |    |    |    |    |--> buf_page_set_io_fix // io_fix 设置为 BUF_IO_NONE
|    |
|    |--> buf_page_make_young_if_needed
|    |
|    |--> buf_read_ahead_linear

读取 1 个 Page 时,首先会检查 page_hash ,如果 page_hash 中存在,则直接读取并设置 buf_fix_count 后返回;否则需要从文件中读取 Page,从文件中读取 Page 时首先需要申请 1 个 Block(具体申请过程在后面介绍),然后添加到 page_hashLRU 列表中,最后进行数据的读取。对于 1 个新的 Page 的创建过程,入口函数为 buf_page_create ,基本流程如下:

/* buf_page_create */
|--> buf_page_create
|    |--> buf_LRU_get_free_block // 申请 1 个 block
|    |--> buf_page_hash_get_low // 检查 page_hash 中是否存在
|    |
|    |--> buf_page_init
|    |    |--> buf_block_init_low
|    |    |--> buf_page_init_low
|    |    |--> HASH_INSERT // 插入 page_hash
|    |--> buf_block_buf_fix_inc // buf_fix_count 计数 +1
|    |--> buf_LRU_add_block // 添加到 LRU

Block 申请

Block 申请的入口函数为 buf_LRU_get_free_block ,该方法会从 Buffer Pool 中申请 1 个 Block 供后续的 Page 读取使用。Block 申请的主要流程如下:

|--> buf_LRU_get_free_block // loop
|    |--> buf_LRU_get_free_only // 从 free_list 分配
|    |
|    |--> buf_LRU_scan_and_free_block // 从 LRU 中回收
|    |    |--> buf_LRU_free_from_unzip_LRU_list
|    |    |    |--> buf_LRU_free_page
|    |    |--> buf_LRU_free_from_common_LRU_list
|    |    |    |--> buf_flush_ready_for_replace
|    |    |    |--> buf_LRU_free_page
|    |
|    |--> os_event_set(buf_flush_event) // 唤醒刷脏线程
|    |
|    |--> buf_flush_single_page_from_LRU // 从 LRU 中刷脏
|    |    |--> buf_LRU_free_page
|    |    |--> buf_flush_page

Buffer Pool 中维护了三个列表:free_listLRUflush_list 。其中 free_list 列表是当前可供使用的 Block,LRU 列表中保存了当前所有已经使用的 Block,flush_list 列表中保存了所有脏页 Block。申请 1 个 Block 时:

首先判断当前 free_list 列表是否为空,若 free_list 列表非空,则直接从 free_list 列表中进行分配。若无法直接从 free_list 列表分配,则会尝试从 LRU 列表中进行回收。 LRU 是一个非严格的最近使用列表,从 LRU 列表回收时会从列表尾部往前遍历(加入 LRU 列表时从头部加入),如果找到可回收的 Page(遇到脏页会跳过),则会释放 Page 并将对应的 Block 重新放入 free_list 列表中。LRU 列表的遍历过程并不是无限的,例如:在第一次遍历时,当检查的 Page 数目达到 BUF_LRU_SEARCH_SCAN_THRESHOLD 时会退出遍历过程。 如果无法从 LRU 列表中回收 Block,则会唤醒刷脏线程,刷脏线程的处理流程在下面会做介绍。 同时还会从 LRU 列表中进行刷脏,该过程是同步的,依然是遍历 LRU 列表,但此时不会跳过脏页,遇到脏页直接进行刷脏。

管理

加入 flush_list

前面提到过 flush_list 列表中保存的是所有脏页 Block,脏页在 mtr 提交时会加入 flush_list 中,基本过程如下:

|--> mtr_t::Command::execute()
|    |--> add_dirty_page_to_flush_list
|    |    |--> buf_flush_note_modification
|    |    |    |--> buf_flush_insert_into_flush_list
|    |    |    |    |--> UT_LIST_ADD_FIRST // 插入 flush_list 头部

注意:flush_list 是一个非严格有序的列表(可以看做按照 oldest_modification 有序),脏页插入列表后位置不再修改,再次修改时仅修改 newest_modification

加入 LRU

LRU 列表中保存了当前所有已经使用的 Block,申请完 1 个 Block 并完成初始化后会加到 LRU 列表中(默认会加入到 old 区域头部),加入 LRU 列表的基本过程如下:

|--> buf_LRU_add_block
|    |--> buf_LRU_add_block_low
|    |    |--> UT_LIST_ADD_FIRST // 插入 young 区域头部
|    |    |--> UT_LIST_INSERT_AFTER // 插入 old 区域头部
|    |    |
|    |    |--> buf_LRU_old_adjust_len // 调整 LRU

管理 LRU

前面提到 LRU 是一个非严格的最近使用列表,InnoDB 将 LRU 列表划分为两个区域:young 区域和 old 区域。LRU 列表的示意图如下:

/** LRU 列表示意图
                                                LRU_old
                                                   |
**********************young************************|********old*********
|==================================================|===================|

几个主要的常量:
BUF_LRU_OLD_TOLERANCE      20
BUF_LRU_NON_OLD_MIN_LEN    5
BUF_LRU_OLD_MIN_LEN        512
BUF_LRU_OLD_RATIO_DIV      1024

参数控制:
innodb_old_block_pct       old 区域占比

*/

buf_LRU_old_adjust_len 方法会根据 innodb_old_block_pct 参数,维护 young 区域和 old 区域的长度,主要逻辑如下:

LRU 长度小于 ` BUF_LRU_OLD_MIN_LEN` 时,不划分区域。 不是每次操作 LRU 列表后都需要立即调整,BUF_LRU_OLD_TOLERANCE 可以看成是容忍范围。 当 old 区域变大时,LRU_old 指针向前移动;反之向后移动。

当 Block 被再次访问时,会触发 buf_page_make_young_if_needed 函数进行 Block 位置的调整,基本过程如下:

|--> buf_page_make_young_if_needed
|    |--> buf_page_peek_if_too_old // 判断访问间隔
|    |    |--> buf_page_peek_if_young // 判断 young 区域位置
|    |
|    |--> buf_page_make_young
|    |    |--> buf_LRU_make_block_young
|    |    |    |--> buf_LRU_remove_block
|    |    |    |--> buf_LRU_add_block_low

buf_page_make_young_if_needed 移动 Block 时需要考虑:

访问间隔需要大于 buf_LRU_old_threshold_ms 。 当 Block 在 young 区域前 1/4 时,不需要移动。

InnoDB 中 LRU 列表的设计虽然简单,但是也有许多优化在里面,感兴趣的同学可以仔细研究,本文仅是一个简单的介绍。

回收

释放 Page

前面提到,当 free_list 列表为空时,会首先尝试从 LRU 列表中进行回收,Page 的释放入口函数为 buf_LRU_free_page ,该方法的主要处理流程如下:

|--> buf_LRU_free_page
|    |--> buf_page_can_relocate // 检查 buf_fix_count 计数和 io_fix 状态
|    |
|    |--> buf_LRU_block_remove_hashed // 从 LRU 和 page_hash 中删除
|    |    |--> buf_LRU_remove_block
|    |    |    |--> buf_LRU_old_adjust_len
|    |    |--> HASH_DELETE
|    |
|    |--> btr_search_drop_page_hash_index // 从 AHI 中删除
|    |
|    |--> buf_LRU_block_free_hashed_page // 放回 free_list
|    |    |--> buf_LRU_block_free_non_file_page

释放 1 个 Page 时,首先需要检查 io_fix 状态和 buf_fix_count 计数,确保当前 Page 没有被使用,然后将 Block 从依次从 LRU 列表、page_hashAHI 中删除,最后将 Block 重新放入到 free_list 列表中。

同步刷脏

同步刷脏的入口函数为 buf_flush_page ,同步刷脏过程仅会刷 1 个 Page,保证能够获取到 1 个可用的 Block,主要处理流程如下:

|--> buf_flush_page // 刷单个 page
|    |--> buf_page_set_io_fix // io_fix 设置为 BUF_IO_WRITE
|    |--> buf_flush_write_block_low
|    |    |--> log_write_up_to // 写 redo
|    |    |
|    |    |--> fil_io
|    |    |--> buf_dblwr_write_single_page // 写数据页
|    |    |
|    |    |--> fil_flush
|    |    |--> buf_page_io_complete
|    |    |    |--> buf_flush_write_complete
|    |    |    |    |--> buf_flush_remove
|    |    |    |    |--> buf_page_set_io_fix // io_fix 设置为 BUF_IO_NONE
|    |    |    |
|    |    |    |--> buf_LRU_free_page

InnoDB 通过严格的 WAL 机制保证数据的一致性,刷脏过程同样如此。首先需要保证对应的日志文件落盘,然后再写入数据页。最后将 Block 从 flush_list 列表中移除,此时 Page 变成可回收状态,再次调用 buf_LRU_free_page 进行回收。

同步刷脏的过程不仅在获取 Block 时会被调用,在表删除的时候同样会被调用,表删除时会根据 space_id 进行批量的刷脏,入口函数为 buf_LRU_flush_or_remove_pages ,处理流程如下:

|--> buf_LRU_flush_or_remove_pages // 根据 space_id 刷脏
|    |--> buf_LRU_drop_page_hash_for_tablespace // 遍历 LRU
|    |    |--> buf_LRU_drop_page_hash_batch
|    |    |    |--> btr_search_drop_page_hash_when_freed
|    |    |    |    |--> buf_page_get_gen
|    |    |    |    |--> btr_search_drop_page_hash_index // 从 AHI 中删除
|    |
|    |--> buf_LRU_remove_pages
|    |    |--> buf_LRU_remove_all_pages // 遍历 LRU
|    |    |    |--> buf_LRU_block_remove_hashed // 从 LRU 和 page_hash 中删除
|    |    |    |--> buf_LRU_block_free_hashed_page // 放回 free_list
|    |    |
|    |    |--> buf_flush_dirty_pages
|    |    |    |--> buf_flush_or_remove_pages // 遍历 flush_list
|    |    |    |    |--> buf_flush_or_remove_page
|    |    |    |    |    |--> buf_flush_remove
|    |    |    |    |    |
|    |    |    |    |    |--> buf_flush_page

具体的过程在此不再赘述,大家可以自己去阅读相应的代码。需要注意的是:如果单个 session 中使用了临时表,那么在 session 退出的时候,也会进入到上述的刷脏流程,当 LRU 列表很大时,session 退出的性能将会受到很大的影响。AliSQL 对此进行了优化,欢迎试用。

异步刷脏

除了同步刷脏之外,MySQL 中还引入单独的刷脏线程进行异步刷脏。刷脏线程按照功能划分包括两种:coordinator 线程和 cleaner 线程。coordinator 线程会计算最大的刷脏量,然后分配刷脏任务给 cleaner 线程,cleaner 线程进行实际的刷脏工作(coordinator 线程本身也会参与刷脏)。异步刷脏的入口函数为 buf_flush_page_cleaner_init ,基本流程如下:

|--> buf_flush_page_coordinator_thread
|    |--> os_event_wait(buf_flush_event)
|    
|    /* loop */
|    |--> page_cleaner_flush_pages_recommendation // 计算最大刷脏量
|    |--> pc_request // 任务分发,slot 数目等于 bp_instance 数目
|    |    |--> os_event_set(page_cleaner->is_requested)
|    |--> pc_flush_slot // 参与刷脏
|    |--> pc_wait_finished


|--> buf_flush_page_cleaner_thread
|    |--> os_event_wait(page_cleaner->is_requested)
|    |--> pc_flush_slot // 1 个线程处理 1 个 bp_instance
|    |    |--> buf_flush_LRU_list // 从 LRU 中刷脏
|    |    |    |--> buf_flush_do_batch(BUF_FLUSH_LRU)
|    |    |
|    |    |--> buf_flush_do_batch(BUF_FLUSH_LIST) // 从 flush_list 刷脏
|    |    |    |--> buf_flush_batch
|    |    |    |    |--> buf_do_LRU_batch
|    |    |    |    |    |--> buf_free_from_unzip_LRU_list_batch
|    |    |    |    |    |--> buf_flush_LRU_list_batch
|    |    |    |    |    |    |--> buf_LRU_free_page
|    |    |    |    |    |    |--> buf_flush_page_and_try_neighbors
|    |    |    |    |    |    |    |--> buf_flush_try_neighbors
|    |    |    |    |    |    |    |    |--> buf_flush_page
|    |    |    |    |
|    |    |    |    |--> buf_do_flush_list_batch
|    |    |    |    |    |--> buf_flush_page_and_try_neighbors

异步刷脏的具体过程可以参考这篇文章,异步刷脏过程中有一个非常重要的点就是 page_cleaner_flush_pages_recommendation 计算最大刷脏量,相关的细节在此不再展开,后面有机会再单独整理一篇各种后台线程的更新逻辑。

总结

本文从申请、管理、回收三部分对 InnoDB Buffer Page 的生命周期管理进行了介绍,文中的内容只是一个基本概要,更多的细节还需要读者在阅读代码的过程中慢慢发掘。

文章来源:

Author:数据库内核月报
link:http://10.101.233.47:4000/monthly/2020/08/04/