前言

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 中的主要流程如下:

  1. /* 以 Buf_fetch_normal 为例 */
  2. |--> fetch.single_page
  3. | |--> get(block) // loop
  4. | | |--> lookup
  5. | | | |--> buf_page_hash_get_low // 检查 page_hash 中是否存在
  6. | | |--> buf_block_fix // buf_fix_count 计数 +1
  7. | | |
  8. | | |--> read_page
  9. | | | |--> buf_read_page // 从文件中读取 page
  10. | | | | |--> buf_read_page_low
  11. | | | | | |--> buf_page_init_for_read
  12. | | | | | | |--> buf_LRU_get_free_block // 申请 1 个 block
  13. | | | | | | |--> buf_page_hash_get_low // 再次检查 page_hash 中是否存在
  14. | | | | | | |--> buf_page_init
  15. | | | | | | | |--> buf_block_init_low
  16. | | | | | | | |--> buf_page_init_low
  17. | | | | | | | |--> HASH_INSERT // 插入 page_hash
  18. | | | | | | |--> buf_page_set_io_fix // io_fix 设置为 BUF_IO_READ
  19. | | | | | | |--> buf_LRU_add_block // 添加到 LRU
  20. | | | | | |
  21. | | | | | |--> _fil_io // 读取文件
  22. | | | | | |--> buf_page_io_complete // 同步模式 IO 完成
  23. | | | | | | |--> buf_page_set_io_fix // io_fix 设置为 BUF_IO_NONE
  24. | |
  25. | |--> buf_page_make_young_if_needed
  26. | |
  27. | |--> buf_read_ahead_linear

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

  1. /* buf_page_create */
  2. |--> buf_page_create
  3. | |--> buf_LRU_get_free_block // 申请 1 个 block
  4. | |--> buf_page_hash_get_low // 检查 page_hash 中是否存在
  5. | |
  6. | |--> buf_page_init
  7. | | |--> buf_block_init_low
  8. | | |--> buf_page_init_low
  9. | | |--> HASH_INSERT // 插入 page_hash
  10. | |--> buf_block_buf_fix_inc // buf_fix_count 计数 +1
  11. | |--> buf_LRU_add_block // 添加到 LRU

Block 申请

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

  1. |--> buf_LRU_get_free_block // loop
  2. | |--> buf_LRU_get_free_only // 从 free_list 分配
  3. | |
  4. | |--> buf_LRU_scan_and_free_block // 从 LRU 中回收
  5. | | |--> buf_LRU_free_from_unzip_LRU_list
  6. | | | |--> buf_LRU_free_page
  7. | | |--> buf_LRU_free_from_common_LRU_list
  8. | | | |--> buf_flush_ready_for_replace
  9. | | | |--> buf_LRU_free_page
  10. | |
  11. | |--> os_event_set(buf_flush_event) // 唤醒刷脏线程
  12. | |
  13. | |--> buf_flush_single_page_from_LRU // 从 LRU 中刷脏
  14. | | |--> buf_LRU_free_page
  15. | | |--> buf_flush_page

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

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

管理

加入 flush_list

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

  1. |--> mtr_t::Command::execute()
  2. | |--> add_dirty_page_to_flush_list
  3. | | |--> buf_flush_note_modification
  4. | | | |--> buf_flush_insert_into_flush_list
  5. | | | | |--> UT_LIST_ADD_FIRST // 插入 flush_list 头部

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

加入 LRU

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

  1. |--> buf_LRU_add_block
  2. | |--> buf_LRU_add_block_low
  3. | | |--> UT_LIST_ADD_FIRST // 插入 young 区域头部
  4. | | |--> UT_LIST_INSERT_AFTER // 插入 old 区域头部
  5. | | |
  6. | | |--> buf_LRU_old_adjust_len // 调整 LRU

管理 LRU

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

  1. /** LRU 列表示意图
  2. LRU_old
  3. |
  4. **********************young************************|********old*********
  5. |==================================================|===================|
  6. 几个主要的常量:
  7. BUF_LRU_OLD_TOLERANCE 20
  8. BUF_LRU_NON_OLD_MIN_LEN 5
  9. BUF_LRU_OLD_MIN_LEN 512
  10. BUF_LRU_OLD_RATIO_DIV 1024
  11. 参数控制:
  12. innodb_old_block_pct old 区域占比
  13. */

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

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

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

  1. |--> buf_page_make_young_if_needed
  2. | |--> buf_page_peek_if_too_old // 判断访问间隔
  3. | | |--> buf_page_peek_if_young // 判断 young 区域位置
  4. | |
  5. | |--> buf_page_make_young
  6. | | |--> buf_LRU_make_block_young
  7. | | | |--> buf_LRU_remove_block
  8. | | | |--> buf_LRU_add_block_low

buf_page_make_young_if_needed 移动 Block 时需要考虑:

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

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

回收

释放 Page

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

  1. |--> buf_LRU_free_page
  2. | |--> buf_page_can_relocate // 检查 buf_fix_count 计数和 io_fix 状态
  3. | |
  4. | |--> buf_LRU_block_remove_hashed // 从 LRU 和 page_hash 中删除
  5. | | |--> buf_LRU_remove_block
  6. | | | |--> buf_LRU_old_adjust_len
  7. | | |--> HASH_DELETE
  8. | |
  9. | |--> btr_search_drop_page_hash_index // 从 AHI 中删除
  10. | |
  11. | |--> buf_LRU_block_free_hashed_page // 放回 free_list
  12. | | |--> 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,主要处理流程如下:

  1. |--> buf_flush_page // 刷单个 page
  2. | |--> buf_page_set_io_fix // io_fix 设置为 BUF_IO_WRITE
  3. | |--> buf_flush_write_block_low
  4. | | |--> log_write_up_to // 写 redo
  5. | | |
  6. | | |--> fil_io
  7. | | |--> buf_dblwr_write_single_page // 写数据页
  8. | | |
  9. | | |--> fil_flush
  10. | | |--> buf_page_io_complete
  11. | | | |--> buf_flush_write_complete
  12. | | | | |--> buf_flush_remove
  13. | | | | |--> buf_page_set_io_fix // io_fix 设置为 BUF_IO_NONE
  14. | | | |
  15. | | | |--> buf_LRU_free_page

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

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

  1. |--> buf_LRU_flush_or_remove_pages // 根据 space_id 刷脏
  2. | |--> buf_LRU_drop_page_hash_for_tablespace // 遍历 LRU
  3. | | |--> buf_LRU_drop_page_hash_batch
  4. | | | |--> btr_search_drop_page_hash_when_freed
  5. | | | | |--> buf_page_get_gen
  6. | | | | |--> btr_search_drop_page_hash_index // 从 AHI 中删除
  7. | |
  8. | |--> buf_LRU_remove_pages
  9. | | |--> buf_LRU_remove_all_pages // 遍历 LRU
  10. | | | |--> buf_LRU_block_remove_hashed // 从 LRU 和 page_hash 中删除
  11. | | | |--> buf_LRU_block_free_hashed_page // 放回 free_list
  12. | | |
  13. | | |--> buf_flush_dirty_pages
  14. | | | |--> buf_flush_or_remove_pages // 遍历 flush_list
  15. | | | | |--> buf_flush_or_remove_page
  16. | | | | | |--> buf_flush_remove
  17. | | | | | |
  18. | | | | | |--> buf_flush_page

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

异步刷脏

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

  1. |--> buf_flush_page_coordinator_thread
  2. | |--> os_event_wait(buf_flush_event)
  3. |
  4. | /* loop */
  5. | |--> page_cleaner_flush_pages_recommendation // 计算最大刷脏量
  6. | |--> pc_request // 任务分发,slot 数目等于 bp_instance 数目
  7. | | |--> os_event_set(page_cleaner->is_requested)
  8. | |--> pc_flush_slot // 参与刷脏
  9. | |--> pc_wait_finished
  10. |--> buf_flush_page_cleaner_thread
  11. | |--> os_event_wait(page_cleaner->is_requested)
  12. | |--> pc_flush_slot // 1 个线程处理 1 个 bp_instance
  13. | | |--> buf_flush_LRU_list // 从 LRU 中刷脏
  14. | | | |--> buf_flush_do_batch(BUF_FLUSH_LRU)
  15. | | |
  16. | | |--> buf_flush_do_batch(BUF_FLUSH_LIST) // 从 flush_list 刷脏
  17. | | | |--> buf_flush_batch
  18. | | | | |--> buf_do_LRU_batch
  19. | | | | | |--> buf_free_from_unzip_LRU_list_batch
  20. | | | | | |--> buf_flush_LRU_list_batch
  21. | | | | | | |--> buf_LRU_free_page
  22. | | | | | | |--> buf_flush_page_and_try_neighbors
  23. | | | | | | | |--> buf_flush_try_neighbors
  24. | | | | | | | | |--> buf_flush_page
  25. | | | | |
  26. | | | | |--> buf_do_flush_list_batch
  27. | | | | | |--> buf_flush_page_and_try_neighbors

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

总结

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