之前的一篇内核月报MySQL · 引擎特性 · InnoDB Buffer Pool 中对InnoDB Buffer pool的整体进行了详细的介绍。文章已经提到了LRU List以及刷脏的工作原理。本篇文章着重从MySQL 5.7源码层面对LRU List刷脏的工作原理,以及Percona针对MySQL LRU Flush的一些性能问题所做的改进,进行一下分析。

    在MySQL中,如果当前数据库需要操作的数据集比Buffer pool中的空闲页面大的话,当前Buffer pool中的数据页就必须进行脏页淘汰,以便腾出足够的空闲页面供当前的查询使用。如果数据库负载太高,对于空闲页面的需求超出了page cleaner的淘汰能力,这时候是否能够快速获取空闲页面,会直接影响到数据库的处理能力。我们将从下面三个阶段来看一下MySQL以及Percona对LRU List刷脏的改进过程。

    众所周知,MySQL操作任何一个数据页面都需要读到Buffer pool进行才会进行操作。所以任何一个读写请求都需要从Buffer pool来获取所需页面。如果需要的页面已经存在于Buffer pool,那么直接利用当前页面进行操作就行。但是如果所需页面不在Buffer pool,比如UPDATE操作,那么就需要从Buffer pool中新申请空闲页面,将需要读取的数据放到Buffer pool中进行操作。那么官方MySQL 5.7.4之前的版本如何从buffer pool中获取一个页面呢?请看如下代码段:

    
    2. buf_block_t*
    3. buf_LRU_get_free_block(
    4. /*===================*/
    5. buf_pool_t* buf_pool) /*!< in/out: buffer pool instance */
    6. {
    7. buf_block_t*  block   = NULL;
    8. bool    freed   = false;
    9. ulint   n_iterations  = 0;
    10. ulint   flush_failures  = 0;
    11. bool    mon_value_was = false;
    12. bool    started_monitor = false;
    
    14. MONITOR_INC(MONITOR_LRU_GET_FREE_SEARCH);
    15. loop:
    16. buf_pool_mutex_enter(buf_pool); // 这里需要对当前buf_pool使用mutex,存在锁竞争
    
    18. // 当前函数会检查一些非数据对象,比如AHI, lock 所占用的buf_pool是否太高并发出警告
    19. buf_LRU_check_size_of_non_data_objects(buf_pool);
    
    21. /* If there is a block in the free list, take it */
    22. block = buf_LRU_get_free_only(buf_pool);
    
    24. // 如果获取到了空闲页面,清零之后就直接使用。否则就需要进行LRU页面淘汰
    25. if (block != NULL) {
    
    27. buf_pool_mutex_exit(buf_pool);
    28. ut_ad(buf_pool_from_block(block) == buf_pool);
    29. memset(&block->page.zip, 0, sizeof block->page.zip);
    
    31. if (started_monitor) {
    32. srv_print_innodb_monitor =
    33. static_cast<my_bool>(mon_value_was);
    34. }
    
    36. block->skip_flush_check = false;
    37. block->page.flush_observer = NULL;
    38. return(block);
    39. }
    
    41. MONITOR_INC( MONITOR_LRU_GET_FREE_LOOPS );
    
    43. freed = false;
    44. /**
    45. 这里会重复进行空闲页扫描,如果没有空闲页面,会根据LRU list对页面进行淘汰。
    46. 这里设置buf_pool->try_LRU_scan是做了一个优化,如果当前用户线程扫描的时候
    47. 发现没有空闲页面,那么其他用户线程就不需要进行同样的扫描。
    48. */
    
    50. if (buf_pool->try_LRU_scan || n_iterations > 0) {
    51. /* If no block was in the free list, search from the
    52. end of the LRU list and try to free a block there.
    53. If we are doing for the first time we'll scan only
    54. tail of the LRU list otherwise we scan the whole LRU
    55. list. */
    56. freed = buf_LRU_scan_and_free_block(
    57. buf_pool, n_iterations > 0);
    
    59. if (!freed && n_iterations == 0) {
    60. /* Tell other threads that there is no point
    61. in scanning the LRU list. This flag is set to
    62. TRUE again when we flush a batch from this
    63. buffer pool. */
    64. buf_pool->try_LRU_scan = FALSE;
    65. }
    66. }
    
    68. buf_pool_mutex_exit(buf_pool);
    
    70. if (freed) {
    71. goto loop;
    72. }
    
    74. if (n_iterations > 20
    75. && srv_buf_pool_old_size == srv_buf_pool_size) {
    76. // 如果循环获取空闲页的次数大于20次,系统将发出报警信息
    77. ...
    78. }
    79. /* If we have scanned the whole LRU and still are unable to
    80. find a free block then we should sleep here to let the
    81. page_cleaner do an LRU batch for us. */
    
    83. if (!srv_read_only_mode) {
    84. os_event_set(buf_flush_event);
    85. }
    
    87. if (n_iterations > 1) {
    
    89. MONITOR_INC( MONITOR_LRU_GET_FREE_WAITS );
    90. // 这里每次循环释放空闲页面会间隔10ms
    91. os_thread_sleep(10000);
    92. }
    
    94. /* 如果buffer pool里面没有发现可以直接替换的页面(所谓直接替换的页面,
    95. 是指页面没有被修改, 也没有别的线程进行引用,同时当前页已经被载入buffer pool),
    96. 注意:上面的页面淘汰过程至少会尝试
    97. innodb_lru_scan_depth个页面。如果上面不存在可以淘汰的页面。那么系统将尝试淘汰一个
    98. 脏页面(可替换页面或者已经被载入buffer pool的脏页面)。
    99. */
    100. if (!buf_flush_single_page_from_LRU(buf_pool)) {
    101. MONITOR_INC(MONITOR_LRU_SINGLE_FLUSH_FAILURE_COUNT);
    102. ++flush_failures;
    103. }
    
    105. srv_stats.buf_pool_wait_free.add(n_iterations, 1);
    
    107. n_iterations++;
    
    109. goto loop;
    110. }
    
    

    从上面获取一个空闲页的源码逻辑可以看出,buf_LRU_get_free_block会循环尝试去淘汰LRU list上的页面。每次循环都会去访问free list,查看是否有足够的空闲页面。如果没有将继续从LRU list去淘汰。这样的循环在负载比较高的情况下,会加剧对free list以及LRU list的mutex竞争。

    MySQL空闲页面的获取依赖于page cleaner的刷新能力,如果page cleaner不能即时的刷新足够的空闲页面,那么系统就会使用上面的逻辑来为用户线程申请空闲页面。但如果让page cleaner加快刷新,又会导致频繁刷新脏数据,引发性能问题。 为了改善系统负载太高的情况下,page cleaner刷脏能力不足,进而用户线程调用LRU刷脏导致锁竞争加剧影响数据库性能,Percona对此进行了改善,引入独立的线程负责LRU list的刷脏。目的是为了让独立线程根据系统负载动态调整LRU的刷脏能力。由于LRU list的刷脏从page cleaner线程中脱离出来,调整LRU list的刷脏能力不再会影响到page cleaner。下面我们看一下相关的源码:

    
    1. /**
    2. 该函数会根据系统的负载情况,或者是buffer pool的空闲页面的情况来动态调整lru_manager_thread的  刷脏能力。
    3. */
    4. static
    5. void
    6. lru_manager_adapt_sleep_time(
    7. /*==============================*/
    8. ulint*  lru_sleep_time) /*!< in/out: desired page cleaner thread sleep
    9. time for LRU flushes  */
    10. {
    11. /* 实际的空闲页 */
    12. ulint free_len = buf_get_total_free_list_length();
    13. /* 期望至少保持的空闲页 */
    14. ulint max_free_len = srv_LRU_scan_depth * srv_buf_pool_instances;
    
    16. /* 下面的逻辑会根据当前的空闲页面与期望的空闲页面之间的比对,
    17. 来调整lru_manager_thread的刷脏频率
    18. */
    19. if (free_len < max_free_len / 100) {
    
    21. /* 实际的空闲页面小于期望的1%,系统会触使lru_manager_thread不断刷脏。*/
    22. *lru_sleep_time = 0;
    23. } else if (free_len > max_free_len / 5) {
    
    25. /* Free lists filled more than 20%, sleep a bit more */
    26. *lru_sleep_time += 50;
    27. if (*lru_sleep_time > srv_cleaner_max_lru_time) {
    28. *lru_sleep_time = srv_cleaner_max_lru_time;
    29. }
    30. } else if (free_len < max_free_len / 20 && *lru_sleep_time >= 50) {
    
    32. /* Free lists filled less than 5%, sleep a bit less */
    33. *lru_sleep_time -= 50;
    34. } else {
    
    36. /* Free lists filled between 5% and 20%, no change */
    37. }
    38. }
    
    40. extern "C" UNIV_INTERN
    41. os_thread_ret_t
    42. DECLARE_THREAD(buf_flush_lru_manager_thread)(
    43. /*==========================================*/
    44. void* arg __attribute__((unused)))
    45. /*!< in: a dummy parameter required by
    46. os_thread_create */
    47. {
    48. ulint next_loop_time = ut_time_ms() + 1000;
    49. ulint lru_sleep_time = srv_cleaner_max_lru_time;
    
    51. #ifdef UNIV_PFS_THREAD
    52. pfs_register_thread(buf_lru_manager_thread_key);
    53. #endif /* UNIV_PFS_THREAD */
    
    55. #ifdef UNIV_DEBUG_THREAD_CREATION
    56. fprintf(stderr, "InnoDB: lru_manager thread running, id %lu\n",
    57. os_thread_pf(os_thread_get_curr_id()));
    58. #endif /* UNIV_DEBUG_THREAD_CREATION */
    
    60. buf_lru_manager_is_active = true;
    61. /* On server shutdown, the LRU manager thread runs through cleanup
    62. phase to provide free pages for the master and purge threads.  */
    63. while (srv_shutdown_state == SRV_SHUTDOWN_NONE
    64. || srv_shutdown_state == SRV_SHUTDOWN_CLEANUP) {
    65. /* 根据系统负载情况,动态调整lru_manager_thread的工作频率 */
    66. lru_manager_sleep_if_needed(next_loop_time);
    
    68. lru_manager_adapt_sleep_time(&lru_sleep_time);
    
    70. next_loop_time = ut_time_ms() + lru_sleep_time;
    
    72. /**
    73. 这里lru_manager_thread轮询每个buffer pool instances,尝试从LRU的尾部开始淘汰            innodb_lru_scan_depth个页面
    74. */
    75. buf_flush_LRU_tail();
    76. }
    
    78. buf_lru_manager_is_active = false;
    
    80. os_event_free(buf_lru_event);
    81. /* We count the number of threads in os_thread_exit(). A created
    82. thread should always use that to exit and not use return() to exit. */
    83. os_thread_exit(NULL);
    
    85. OS_THREAD_DUMMY_RETURN;
    86. }
    
    

    从上面的源码可以看到,LRU list的刷脏依赖于LRU_mangager_thread, 当然正常的page cleaner也会对LRU list进行刷脏。但是整个Buffer pool的所有instances都依赖于一个LRU list刷脏线程,负载比较高的情况下也很有可能成为瓶颈。

    官方MySQL 5.7版本为了缓解单个page cleaner线程进行刷脏的压力,在5.7.4中引入了multiple page cleaner threads这个feature,用来增强刷脏速度,但是从下面的测试可以发现,即便是multiple page cleaner threads在高负载的情况下,还是会对系统性能有影响。下面的测试结果也显示了性能方面受到的影响。

    5.7-mpc.png

    就multiple page cleaner刷脏能力受到限制,主要是因为存在以下问题: 1) LRU List刷脏在先,Flush list的刷脏在后,但是是互斥的。也就是说在进Flush list刷脏的时候,LRU list不能继续去刷脏,必须等到下一个循环周期才能进行。 2) 另外一个问题就是,刷脏的时候,page cleaner coodinator会等待所有的page cleaner线程完成之后才会继续响应刷脏请求。这带来的问题就是如果某个buffer pool instance比较热的话,page cleaner就不能及时进行响应。

    针对上面的问题,Percona改进了原来的单线程LRU list刷脏的方式,继续将LRU list独立于page cleaner threads并将LRU list单线程刷脏增加为多线程刷脏。page cleaner只负责flush list的刷脏,lru_manager_thread只负责LRU List刷脏。这样的分离,可以使得LRU list刷脏和Flush List刷脏并行执行。看一下修改之后的测试情况:

    pc-mlf.png

    下面用Multiple LRU list flush threads的源码patch简单介绍一下Percona所做的更改。

    
    1. @@ -2922,26 +2876,12 @@ pc_flush_slot(void)
    2. }
    
    4. if (!page_cleaner->is_running) {
    5. -     slot->n_flushed_lru = 0;
    6. slot->n_flushed_list = 0;
    7. goto finish_mutex;
    8. }
    
    10. mutex_exit(&page_cleaner->mutex);
    
    12. /* 这里的patch可以看出LRU list的刷脏从page cleaner线程里隔离开来 */
    13. -   lru_tm = ut_time_ms();
    14. -
    15. -   /* Flush pages from end of LRU if required */
    16. -   slot->n_flushed_lru = buf_flush_LRU_list(buf_pool);
    17. -
    18. -   lru_tm = ut_time_ms() - lru_tm;
    19. -   lru_pass++;
    20. -
    
    22. @@ -1881,6 +1880,13 @@ innobase_start_or_create_for_mysql(void)
    23. NULL, NULL);
    24. }
    25. /* 这里在MySQL启动的时候,会同时启动和Buffer pool instances同样数量的LRU list刷脏线程。 */
    26. + for (i = 0; i < srv_buf_pool_instances; i++) {
    27. /* 这里每个LRU list线程负责自己对应的Buffer pool instance的LRU list刷脏 */
    28. +   os_thread_create(buf_lru_manager, reinterpret_cast<void *>(i),
    29. +        NULL);
    30. + }
    31. +
    32. + buf_lru_manager_is_active = true;
    33. +
    
    

    综上所述,本篇文章主要从源码层面对Percona以及官方对于LRU list刷脏方面所做的改进进行了分析。Percona对于LRU list刷脏问题做了很大的贡献。从测试结果可以看到,如果负载较高,空闲页不足的情况下,Percona的改进起到了明显的作用。

    原文:http://mysql.taobao.org/monthly/2017/11/05/