背景

adaptive flushing 分析

InnoDB 采用 adaptive flushing (自适应刷脏)的刷脏策略来处理从 Buffer Pool 写入脏页至磁盘. 如何理解 adaptive flushing 的作用,我们可以假设不采用自适应刷脏策略,我们该如何进行刷脏? 假如没有自适应刷脏算法,我们可以利用阈值的方式来进行刷脏,比如 Buffer Pool 的脏页比例达到了 70% 就触发刷脏,在一般的业务压力下,这个方法没有问题. 但是对于用户业务不确定的场景, 简单的采用阈值的方式容易造成在用户业务压力大的情况下数据库的剧烈抖动. 所以采用自适应的刷脏策略,尽可能在所有的用户场景达到系统平滑运行.

相关参数

  • innodb_flushing_avg_loops: 重新生成刷脏建议的间隔时间, 默认30s.
  • innodb_adaptive_flushing: 是否打开自适应刷脏.
  • innodb_adaptive_flushing_lwm: 设置 Redo Log 空闲容量的低水位.
  • innodb_max_dirty_pages_pct_lwm: 设置一个脏页低水位, 当 InnoDB中 的脏页比例超过innodb_max_dirty_pages_pct_lwm的值时, InnoDB 就会触发刷脏.
  • innodb_max_dirty_pages_pct: 设置一个脏页比例上限,假如脏页比例超过这个值,将会触发激烈刷脏(达到系统 IO 上限).
  • innodb_io_capacity: 系统 IO 吞吐能力.
  • innodb_io_capacity_max: 系统最大的 IO 吞吐能力.

原理分析

当启用 innodb_max_dirty_pages_pct_lwm 参数时, 表示设置了预刷脏,Buffer Pool 的刷脏线程会避免脏页比超过这个值. 后台刷脏的动作由后台刷脏协调线程触发,该线程的所有工作内容均由buf_flush_page_cleaner_coordinator() 函数完成. 在执行刷脏任务前,会调用 page_cleaner_flush_pages_recommendation() 生成刷脏建议.

函数page_cleaner_flush_pages_recommendation() 生成的建议刷脏的 Page 数量是 adaptive flushing 自适应刷脏策略的核心,它每隔srv_flushing_avg_loops秒(默认30s)重新根据redo log产生的速度,参考当前刷脏的平均数量和设置的系统IO参数(innodb_io_capacity, innodb_io_capacity_max) 三者的平均值生成一个合理的建议刷脏的Page数量. 下面我们分别对这三个调节因子做出对应的解释.

Redo Log 产生的平均速度

因为刷脏协调线程会每隔srv_flushing_avg_loops生成一次刷脏建议,关于 Redo Log 产生的平均速度公式即为:

/*
 * cur_lsn: 当前最大的lsn
 * prev_lsn: 上次记录的lsn
 * time_elapsed: 间隔时间
 * 计算当前 redo log 的产生速度. */
lsn_rate = static_cast<lsn_t>(static_cast<double>(cur_lsn - prev_lsn) / time_elapsed);

/* 计算与上次 Redo Log 产生速度的平均值 */
lsn_avg_rate = (lsn_avg_rate + lsn_rate) / 2;

计算 Redo Log 产生的平均速度这个比较好理解,Redo Log 产生的平均速度反应了当前系统的压力情况,压力越大,Redo Log 产生的速度越快. 在 MySQL 中 Redo Log 是复用的,经过 Log Checkpoint 操作之前的 Redo Log 都可以被复用, 所以 Log Checkpoint 本身就会推进 Buffer Pool 的刷脏, 所以为了保证数据库有足够空闲的 Redo Log 空闲, 自适应刷脏同样需要考虑 Redo Log 产生的速度.

InnoDB 根据当前lsn_avg_rate来估算一个target_lsn, flush_list所有oldest_modification_lsn小于该 lsn 值的 Page 都被考虑进行刷盘. 估算公式如下:

lsn_t target_lsn = oldest_lsn + lsn_avg_rate * buf_flush_lsn_scan_factor;

因子buf_flush_lsn_scan_factor被硬编码为3. 由上面的公式 InnoDB 的刷脏协调线程遍历 Buffer Pool 估算出flush_list需要被刷脏的 Page 数量, 但最后的数量会再除以buf_flush_lsn_scan_factor.

当前刷脏的平均速度

考虑将当前刷脏的平均速度作为影响因子可能的原因应该是避免生成的建议刷脏 Page 数量与上次刷脏的数量差距过大或过小. 这也符合自适应刷脏的初衷,尽力避免IO抖动.

系统IO参数

通过我们设置的innodb_io_capacityinnodb_io_capacity_max可以得出系统IO的能力, 通过计算flush_list占所有 Page 数量的百分比我们可以得出脏页比. 根据脏页比与innodb_max_dirty_pages_pct大小比较我们决定是否触发激烈刷脏, 假如超过了innodb_max_dirty_pages_pct设定的大小,我们即认为需要全力进行刷脏了, 这里会充分调动系统的IO能力. 否则则需要与innodb_max_dirty_pages_pct_lwm比较,从而考虑利用系统多少的IO带宽.

通过上面的计算,我们将这三个因子的建议刷脏 Page 数量计算平均值,得出综合建议刷脏Page数量,由变量n_pages保存. 接下来,这个建议刷新的总量n_pagesinnodb_io_capacity_max这个参数进行比较,即建议刷新的总量最大不能超过所设置的磁盘最大随机IO能力。

最后我们需要为每个 Buffer Pool 设置n_pages_requested, 即要求的刷脏 Page 数量. 具体的细节我们将在下节的源码分析展出.

源码分析

static ulint page_cleaner_flush_pages_recommendation(lsn_t *lsn_limit,
                                                     ulint last_pages_in) {
  /* 请注意以下5个变量类型均为static. */
  static lsn_t prev_lsn = 0;
  static ulint sum_pages = 0;
  static ulint avg_page_rate = 0;
  static ulint n_iterations = 0;
  static time_t prev_time;

  lsn_t oldest_lsn;
  lsn_t cur_lsn;
  lsn_t age;
  lsn_t lsn_rate;
  ulint n_pages = 0;
  ulint pct_for_dirty = 0;
  ulint pct_for_lsn = 0;
  ulint pct_total = 0;

  /* 当前写入redo log最大的lsn. */
  cur_lsn = log_buffer_dirty_pages_added_up_to_lsn(*log_sys);

  if (prev_lsn == 0) {
    /* 第一次进入该函数, 更新prev_lsn, prev_time. */
    prev_lsn = cur_lsn;
    prev_time = ut_time();
    return (0);
  }

  /* 假如 prev_lsn 等于 cur_lsn 即没有 Redo Log 产生, 直接返回. */
  if (prev_lsn == cur_lsn) {
    return (0);
  }

  /* 累计刷脏的 Page 数量, last_pages_in是上次 Flush 的脏页数量. */
  sum_pages += last_pages_in;

  time_t curr_time = ut_time();
  double time_elapsed = difftime(curr_time, prev_time);

  /* 计算是否超过srv_flushing_avg_loops, InnoDB 设置间隔 srv_flushing_avg_loops 生成一次刷脏建议. */
  if (++n_iterations >= srv_flushing_avg_loops ||
      time_elapsed >= srv_flushing_avg_loops) {
    if (time_elapsed < 1) {
      time_elapsed = 1;
    }

    /* 计算刷脏的平均 Page 数量. */
    avg_page_rate = static_cast<ulint>(
        ((static_cast<double>(sum_pages) / time_elapsed) + avg_page_rate) / 2);

    /* 计算上次 Redo Log 的产生速度. */
    lsn_rate = static_cast<lsn_t>(static_cast<double>(cur_lsn - prev_lsn) /
                                  time_elapsed);

    /* 计算 Redo Log 的平均产生速度. */
    lsn_avg_rate = (lsn_avg_rate + lsn_rate) / 2;

    /* 更新 prev_lsn, prev_time. */
    prev_lsn = cur_lsn;
    prev_time = curr_time;

    n_iterations = 0;

    sum_pages = 0;
  }

  /* 获取 flush_list 中最老的oldest_modification. */
  oldest_lsn = buf_pool_get_oldest_modification_approx();

  ut_ad(oldest_lsn <= log_get_lsn(*log_sys));

  /* 计算lsn的增量. */
  age = cur_lsn > oldest_lsn ? cur_lsn - oldest_lsn : 0;

  /* 计算根据脏页比需要使用 io_capacity 的百分比. 假如超过了 srv_max_buf_pool_modified_pct, 需要使用激烈刷脏即100%. */
  pct_for_dirty = af_get_pct_for_dirty();

  /* 计算根据 Redo Log 产生速率需要调动 io_capacity 的百分比. 假如超过了 srv_max_buf_pool_modified_pct, 需要使用激烈刷脏即100%. */
  pct_for_lsn = af_get_pct_for_lsn(age);

  /* 取一个最大值. */
  pct_total = ut_max(pct_for_dirty, pct_for_lsn);

  ulint sum_pages_for_lsn = 0;

  /* 下面的for循环即为根据 lsn_avg_rate 估算 Buffer Pool 中的 instance 刷脏目标 Page 数量. */
  lsn_t target_lsn = oldest_lsn + lsn_avg_rate * buf_flush_lsn_scan_factor;

  for (ulint i = 0; i < srv_buf_pool_instances; i++) {
    buf_pool_t *buf_pool = buf_pool_from_array(i);
    ulint pages_for_lsn = 0;

    /* 遍历 Buffer Pool 中的 instance 的 flush_list, 根据符合 target_lsn 的 Page, 递增 pages_for_lsn */
    buf_flush_list_mutex_enter(buf_pool);
    for (buf_page_t *b = UT_LIST_GET_LAST(buf_pool->flush_list); b != NULL;
         b = UT_LIST_GET_PREV(list, b)) {
      if (b->oldest_modification > target_lsn) {
        break;
      }
      ++pages_for_lsn;
    }
    buf_flush_list_mutex_exit(buf_pool);

    sum_pages_for_lsn += pages_for_lsn;

    mutex_enter(&page_cleaner->mutex);
    ut_ad(page_cleaner->slots[i].state == PAGE_CLEANER_STATE_NONE);

    /* 更新page_cleaner的n_pages_requested, 除以 buf_flush_lsn_scan_factor 的原因是之前计算
     * target_lsn的时候乘以了 buf_flush_lsn_scan_factor 因子. */
    page_cleaner->slots[i].n_pages_requested =
        pages_for_lsn / buf_flush_lsn_scan_factor + 1;
    mutex_exit(&page_cleaner->mutex);
  }

  /* sum_pages_for 是根据 lsn_avg_rate 估算的全局刷脏 Page 总的数量. 这里除以 buf_flush_lsn_scan_factor 因子即恢复.*/
  sum_pages_for_lsn /= buf_flush_lsn_scan_factor;
  if (sum_pages_for_lsn < 1) {
    sum_pages_for_lsn = 1;
  }

  /* Cap the maximum IO capacity that we are going to use by
  max_io_capacity. Limit the value to avoid too quick increase */
  ulint pages_for_lsn =
      std::min<ulint>(sum_pages_for_lsn, srv_max_io_capacity * 2);

  /* 根据 srv_io_capacity、历次 flush 脏页的平均数量和 redo log 产生速度需要 flush 的 Page 数量三者的平均值.
   * pct_total 代表根据脏页比 和 redo log 产生的速率来决定使用多大的 IO 吞吐. */

  n_pages = (PCT_IO(pct_total) + avg_page_rate + pages_for_lsn) / 3;

  if (n_pages > srv_max_io_capacity) {
    /* n_pages不能超过设置的srv_max_io_capacity. */
    n_pages = srv_max_io_capacity;
  }

  mutex_enter(&page_cleaner->mutex);

  /* ... */

  for (ulint i = 0; i < srv_buf_pool_instances; i++) {
    /* 为每一个page_cleanr设置刷脏的目标数量:
     * 1. 假如 pct_for_lsn 超过了 30,这里可以理解为 Buffer Pool 的 instance 存在 flush_list 中还有较旧的脏页,
          因此根据之前计算的 n_pages_requested, 从而使存在较旧脏页的 instance 刷更多的脏页, 所以这里的脏页数量分配并不是均匀的.
     * 2. 否则采用平均分配的方法直接分配给各个page_cleaner. */
    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;
  }
  mutex_exit(&page_cleaner->mutex);


  /* ... */
  *lsn_limit = LSN_MAX;

  /* 返回根据自适应刷脏生成的刷脏数量建议. */
  return (n_pages);
}

总结

InnoDB 的自适应刷脏比较容易理解,重要的是提供了一种对于系统开发过程中对于容易造成性能瓶颈的关键路径优化思路,例如基于 LSM 设计的 RocksDB 中的 compaction 过程经常造成IO瓶颈从而饱受诟病,参考 InnoDB 的自适应刷脏算法针对不同的IO压力选择合适的 compaction 时机是否能使系统更平滑?