准备

MySQL版本: 8.0

用户使用数据库进行交互数据的,为了弥补磁盘与CPU的速度差距,一般都会采用Cache的方法. 在MySQL中,Buffer Pool就是用来在内存中缓存数据page的,而换入换出的算法采用的LRU算法.

Buffer Pool的结构

buffer_pool

Buffer Pool分为多个Instance,具体的数量由设定的size决定,每个Instance包含多个Chunk, 而Chunk又由多个Page组成.

重要数据结构

每个Instnace即buf_pool_t主要包含以下结构 :

  • LRU: 淘汰算法LRU维护的链表
  • flush_list: 被修改过的Block链表:
  • free: 可用的Block链表
  • page_hash : Hash表, 避免查询的时候全量扫描LRU,可以通过space idpage no获取对应的Page, 用于Buffer Pool的Block定位.

Block控制页结构:

1
2
3
4
struct buf_block_t {
buf_page_t page; // Page的元信息,id, size之类
byte *frame; // Page的数据
}

Buffer Pool的初始化

  • buf_pool_init(): Buffer Pool初始化入口函数
  • buf_pool_create(): 创建Chunk
  • buf_chunk_init():
    • Chunk的初始化, 使用os_mem_alloc_large()分配内存
    • 每一个Block包含block->frame数据页和block->page页信息管理
    • 将每一个Block的Page信息结构即block->page加入buf_pool->free链表.
  • LRU_list分为 youngold 两部分, young 部分存储经常被使用的热点page,新读入的Page默认被加在 old 部分,只有满足一定条件后,才被移到 young 上,主要是为了预读的数据页和全表扫描污染Buffer Pool。LRU_old作为一个游标来作为滑动的指针。初始化阶段最后会设定LRU的比率参数:buf_LRU_old_ratio_update(100 * 3 / 8, FALSE). (LRU_old可以由buf_LRU_old_ratio_update()来更新)
    LRU_list

Buffer Pool的操作

当我们需要一个Page的时候会通过Buffer Pool去申请, 具体的逻辑:
storage/innobase/fsp/fsp0fsp.cc:fsp_page_create()

物理页申请Block:

buf_page_create()

  • 通过buf_poolfree_list中查找空闲的block(buf_LRU_get_free_block()).
  • 假如free_list中为空,即没有空闲的Block, 则需要去LRU_list中寻找.
  • LRU_list搜索策略:
    • 第一次搜索:
      • 假如buf_pool->try_LRU_scan被设置了true, 则通过lru_scan_itr从尾部往前搜索100个, 假如找到空闲的,放入free_list.
      • 假如仍然没有找到空闲的block, 则需要从LRU_list尾部选择一个脏页进行刷盘(这里进行的是手工刷盘,即buf_flush_page()), 将其从page_hashLRU中移除,然后放入free_list.
    • 第二次搜索:
      • 需要搜索整个LRU_list
    • 第三次搜索及以后:
      • 仍然搜索整个LRU_list但每次sleep 10ms
    • 假如LRU_list没有找到合适的空闲Block, 需要将选择Buffer Pool中的Block进行淘汰或者Flush之后加入free_list,即下次循环查找时就可以从free_list中获得空闲的Block.
  • 判断Buffer Pool是否存在查找的该Block, 假如存在直接返回.
  • 否则需要通过buf_page_init()初始化Block数据结构,将查找到的空闲的Block的信息替换为需要申请的这个Block, 插入page_hash.
  • 将该Block加入LRU_listLRU_young部分buf_page_make_young_if_needed(). 这里引入了时间的限制, 即假如第二次的访问时间必须超过buf_LRU_old_threshold_ms才会将其移动到 young 部分.

Buffer Pool与Mini transaction

Mini transaction的start(), commit()操作过程其实就是与Buffer Pool进行交互,完成数据页的获取,读写和修改。所以我们可以通过mini transaction的操作过程来理解Buffer Pool的工作原理。

Mini transaction获取数据页

当mini transaction需要获取数据页时,首先会通过buf_page_get_gen去Buffer Pool中获取:

  • buf_pool_get(page_id)通过page id获取所对应的Buffer Pool的Instance. Instance与Page的对应关系很简单: page_number >> 6然后求模: % srv_buf_pool_instances.
  • buf_page_hash_get_low(buf_pool, page_id)通过前面提及的page_hash能快速的找到该Page.
  • 假如Page在LRU链表中处于 old 的部分,需要将其加至 young 部分.
  • 根据参数rw_latch对Page加不同的锁.

Buffer Pool读取物理文件

假如查找的Page不存在于Buffer Pool中,会从文件中读文件至Buffer Pool中(buf_read_page_low()):

  • buf_page_init_for_read()调用buf_page_init()在Buffer Pool中初始化一个Page, 将该Block加至 old 部分.
  • 调用fil_io()读取文件

Mini transaction提交脏页

当mini transaction完成Commit的时候,假如该Block是第一次进行修改,会Block插入到Buffer Pool的flush_list,以后的修改在无需重复插入flush_list:

1
2
3
4
5
6
7
8
// oldest_modification == 0表示该Page是第一次修改的, 因为oldest_modification初始化为0
if (block->page.oldest_modification == 0) {
buf_pool_t *buf_pool = buf_pool_from_block(block);
// 将修改过的block插入flush list
buf_flush_insert_into_flush_list(buf_pool, block, start_lsn);
} else if (start_lsn != 0) {
ut_ad(block->page.oldest_modification <= start_lsn);
}
  • 更新Page的oldest_modification, 即mtr修改这个Block的起始lsn
  • 添加到buf_pool->flush_list链表

Buffer Pool的刷脏线程

buf/buf0flu.cc:buf_flush_page_coordinator_thread()

Buffer Pool提供多个Flush线程进行Flush操作,前面我们也提及到假如目前需要一个空闲的Page, 用户线程会进行手工Flush: 从flush_list尾部选择一个Page进行Flush操作. 而我们下面分别介绍flush_listLRU_list的刷脏线程buf_flush_page_coordinator_thread:

  • buf_flush_page_coordinator_thread是由用户创建的后台Flush协调线程.
  • 刷盘主线程会新建N-1个buf_flush_page_cleaner_thread线程, 创建的任务线程会wait在page_cleaner->is_requested这个等待事件.
  • 进入主逻辑的while循环(while (srv_shutdown_state == SRV_SHUTDOWN_NONE)):
  • 判断是否需要sleep:
1
2
3
4
5
6
7
8
9
10
11
12
if (srv_check_activity(last_activity) || buf_get_n_pending_read_ios() ||
n_flushed == 0) {
ret_sleep = pc_sleep_if_needed(next_loop_time, sig_count);

if (srv_shutdown_state != SRV_SHUTDOWN_NONE) {
break;
}
} else if (ut_time_ms() > next_loop_time) {
ret_sleep = OS_SYNC_TIME_EXCEEDED;
} else {
ret_sleep = 0;
}
  • 假如目前数据库有活跃的操作或者Buffer Pool的读任务, 则需要睡眠1s.
  • 假如目前的时间超过了上一次Flush设置的下一次刷盘时间(ut_time_ms() > next_loop_time), 则设置OS_SYNC_TIME_EXCEEDED状态.
  • 否则不进入sleep
    • 假如存在活跃的操作,则进入活跃刷新分支if (srv_check_activity(last_activity)):
  • 通过page_cleaner_flush_pages_recommendation()对每个Buffer Pool的Instance生成刷新多少个脏页的建议.
  • Flush协调线程会调用os_event_set(page_cleaner->is_requested)唤醒等待中的任务线程. 被唤醒的任务线程会调用pc_flush_slot()来做刷盘操作, 而Flush协调线程自身也会调用pc_flush_slot().
  • pc_flush_slot()根据类型BUF_FLUSH_LRU或者BUF_FLUSH_LIST来选择对应的刷盘函数:
    • BUF_FLUSH_LRU: buf_flush_LRU_list_batch()
    • BUF_FLUSH_LIST: buf_do_flush_list_batch()遍历buf_pool->flush_list, 假如设置了srv_flush_neighbors=1即检查该Page的相邻的页是否允许Flush, 之后通过buf_flush_ready_for_flush()选择符合Flush规则的Page进行刷盘buf_flush_page(). 使用fil_io把Page写入文件.
    • 后台刷新的协调线程会作为刷新调度总负责人的角色,它会确保每个Buffer Pool都已经开始执行刷新。如果哪个buffer pool的刷新请求还没有被处理,则由刷新协调线程亲自刷新,且直到所有的Buffer Pool的Instance都已开始/进行了刷新.

LRU淘汰规则

buf_flush_ready_for_replace():

1
2
3
return (bpage->oldest_modification == 0 &&
bpage->buf_fix_count == 0 &&
buf_page_get_io_fix(bpage) == BUF_IO_NONE)
  • oldest_modification == 0: 表示这个Block没有被修改.
  • bpage->buf_fix_count == 0: buf_fix_count表示有多少操作在fix该页.
  • buf_page_get_io_fix(bpage) == BUF_IO_NONE: 表示该页目前没有任何操作.

Flush规则

buf_flush_ready_for_flush():

1
2
3
4
if (bpage->oldest_modification == 0
|| buf_page_get_io_fix(bpage) != BUF_IO_NONE {
return (false);
}
  • oldest_modification == 0: 表示这个Block没有被修改,即无须Flush
  • buf_page_get_io_fix(bpage) != BUF_IO_NONE: 表示目前该Page存在操作,不允许进行Flush.

Buffer Pool的自适应刷脏算法

涉及刷脏的变量

介绍刷脏算法之前,我们先来介绍几个关于刷脏的变量:

  • innodb_max_dirty_pages_pct

    设定Buffer Pool中的脏页比, 在MySQL8.0.3的版本中,默认值是90%

  • innodb_max_dirty_pages_pct_lwm

    用来指定”低水位”值,其表示使用预刷脏来控制脏页比例的百分比,防止脏页的百分比达到innodb_max_dirty_pages_pct的值,innodb_max_dirty_pages_pct_lwm默认0,禁用预刷脏行为。

  • innodb_io_capacity

    设置InnoDB的后台线程允许每秒做多少次I/0

  • innodb_io_capacity_max

    如果刷新活动落后,InnoDB可以比innodb_io_capacity施加的限制更积极地刷新。innodb_io_capacity_max定义了InnoDB后台任务在这种情况下每秒执行I/O操作的上限。

刷脏算法

  • 计算当前刷脏的平均速度avg_page_rate:

    1
    2
    3
    4
    5
    6
    /* sum_pages: 上次刷脏的数量
    * time_elapsed: 两次计算的间隔时间
    * avg_page_rate: 上次计算的刷脏平均速度
    */
    avg_page_rate = static_cast<ulint>(
    ((static_cast<double>(sum_pages) / time_elapsed) + avg_page_rate) / 2);
  • 计算Redo Log产生的平均速度lsn_avg_rate

    1
    2
    3
    4
    5
    6
    7
    8
    9
    /*
    * 根据innodb_flushing_avg_loops的设置,默认是每个30次计算一次Redo Log产生的平均速度
    * prev_lsn 上次计算时的lsn
    * cur_lsn 目前最新的lsn
    * time_elapsed 两次计算相隔的时间
    */
    lsn_rate = static_cast<lsn_t>(static_cast<double>(cur_lsn - prev_lsn) /
    time_elapsed);
    lsn_avg_rate = (lsn_avg_rate + lsn_rate) / 2;
  • 根据lsn_avg_rate计算刷脏的target_lsn

  • 遍历Buffer Pool的每一个Instance中的flush_list, 将每一个Block的oldest_modifiactiontarget_lsn比较.
  • 对每一个小于target_lsn的Block进行计数,直到该Block大于target_lsn即break跳出

    1
    2
    3
    4
    5
    6
    /* 根据脏页占比生成的刷脏数量建议 + Redo Log产生的平均速度`avg_page_rate` + Redo Log产生的速度生成的刷脏数量建议 / 3 的平均值来生成刷脏的数量建议 */
    n_pages = (PCT_IO(pct_total) + avg_page_rate + pages_for_lsn) / 3;
    /* 刷脏建议的数量不能超过参数设置的最大I/O次数 */
    if (n_pages > srv_max_io_capacity) {
    n_pages = srv_max_io_capacity;
    }
  • 生成每一个Instance的刷脏数量建议:

    1
    2
    3
    4
    5
    6
    7
    8
    for (ulint i = 0; i < srv_buf_pool_instances; i++) {
    /* 以pct_for_lsn为参考, 假如Redo Log还有足够的空间,则每个Instance刷脏的页数相同,
    * 否则根据每个Instance的脏页数量,计算出不同的刷脏页数.
    */
    page_cleaner->slots[i].n_pages_requested =
    pct_for_lsn > 30 ? page_cleaner->slots[i].n_pages_requested * n_pages / sum_pages_for_lsn + 1
    : n_pages / srv_buf_pool_instances;
    }

手动触发Flush

  • set global innodb_buf_flush_list_now = 1手动强制进行flush_list的刷脏

innodb_lru_scan_depth

free_list小于innodb_lru_scan_depth值时也会触发脏页刷新机制, 该值默认为1024

Buffer Pool与Redo log buffer的区别

  • Buffer Pool缓存的是数据页
  • Redo log buffer缓存的是Redo log

总结

InnoDB根据Redo Log的生成速度和当前的刷脏速度,使用一种自适应的算法来估计下一次的刷新速度,从而保持整个数据库的性能平缓, 不会突然因为脏页的增多从而影响数据库的吞吐, 通过刷脏的算法我们可以看到InnoDB设计的巧妙,不是简单的通过限定某一个脏页比来决定是否刷脏.