dbaplus社群 05月31日 09:57
MySQL 优化利器 SHOW PROFILE 的实现原理
index_new5.html
../../../zaker_core/zaker_tpl_static/wap/tpl_guoji1.html

 

本文作者陈臣通过一个实际案例,深入分析了MySQL可传输表空间导入过程中System lock阶段耗时过长的问题。文章首先展示了使用SHOW PROFILE定位问题的过程,然后详细介绍了SHOW PROFILE的基本用法和实现原理,包括数据的采集(埋点)和计算。通过分析源码,揭示了System lock状态的误导性,并为优化MySQL数据库性能提供了有价值的参考。

🔑 **问题背景**: 通过可传输表空间导入一个4GB大小的表,耗时13分钟,`PROFILE` 定位显示大部分耗时在 `System lock` 阶段,`show processlist` 也显示同样的状态,但这种状态具有误导性。

🛠️ **SHOW PROFILE基本用法**: 通过 `set profiling=1` 开启 profiling,执行 SQL 后,使用 `show profiles` 查看 SQL 对应的 `Query_ID`,再通过 `show profile for query N` 查看各个阶段的执行耗时。指定 `all` 可以输出更详细的统计信息,包括CPU、上下文切换、磁盘IO等。

📊 **SHOW PROFILE实现原理**: `SHOW PROFILE` 的实现分为数据采集和数据计算两部分。数据采集通过在代码中“埋点”实现,不同阶段对应不同的“埋点”地址,可通过 `show profile source` 查看。数据计算则是通过查询 `information_schema.profiling`,并调用 `PROFILING::fill_statistics_info` 来填充数据,计算各个阶段的耗时。

2025-05-30 09:59 广东

最近碰到一个 case,通过可传输表空间的方式导入一个 4GB 大小的表,耗时 13 分钟。

作者介绍

陈臣,甲骨文 MySQL 数据库专家,《MySQL实战》作者,有超过10年的数据库管理与架构经验,对 MySQL、Redis 源码略有研究。

一、背景

最近碰到一个 case,通过可传输表空间的方式导入一个 4GB 大小的表,耗时 13 分钟。

通过PROFILE定位,发现大部分耗时竟然是在System lock阶段。

    mysql> set profiling=1;

    Query OK, 0 rows affected, 1 warning (0.00 sec)

    mysql> alter table sbtest2 import tablespace;

    Query OK, 0 rows affected (13 min 8.99 sec)

    mysql> show profile for query 1;

    +--------------------------------+------------+

    | Status                         | Duration   |

    +--------------------------------+------------+

    | starting                       |   0.000119 |

    | Executing hook on transaction  |   0.000004 |

    | starting                       |   0.000055 |

    | checking permissions           |   0.000010 |

    | discard_or_import_tablespace   |   0.000007 |

    | Opening tables                 |   0.000156 |

    | System lock                    | 788.966338 |

    | end                            |   0.007391 |

    | waiting for handler commit     |   0.000041 |

    | waiting for handler commit     |   0.011179 |

    | query end                      |   0.000022 |

    | closing tables                 |   0.000019 |

    | waiting for handler commit     |   0.000031 |

    | freeing items                  |   0.000035 |

    | cleaning up                    |   0.000043 |

    +--------------------------------+------------+

    15 rows in set1 warning (0.03 sec)

    不仅如此,SQL 在执行的过程中,show processlist中的状态显示的也是System lock。

      mysql> show processlist;

      +----+-----------------+-----------+--------+---------+------+------------------------+---------------------------------------+

      | Id | User            | Host      | db     | Command | Time | State                  | Info                                  |

      +----+-----------------+-----------+--------+---------+------+------------------------+---------------------------------------+

      |  5 | event_scheduler | localhost | NULL   | Daemon  |  818 | Waiting on empty queue | NULL                                  |

      | 10 | root            | localhost | sbtest | Query   |  648 | System lock            | alter table sbtest2 import tablespace |

      | 14 | root            | localhost | NULL   | Query   |    0 | init                   | show processlist                      |

      +----+-----------------+-----------+--------+---------+------+------------------------+---------------------------------------+

      3 rows in set1 warning (0.00 sec)

      这个状态其实有很大的误导性。

      接下来我们从SHOW PROFILE的基本用法出发,从源码角度分析它的实现原理。

      最后在分析的基础上,看看 case 中的表空间导入操作为什么大部分耗时是在System lock阶段。

      二、SHOW PROFILE 的基本用法

      下面通过一个示例来看看SHOW PROFILE的用法。

        # 开启 Profiling

        mysql> set profiling=1;

        Query OK, 0 rows affected, 1 warning (0.00 sec)

        # 执行需要分析的 SQL

        mysql> select count(*from slowtech.t1;

        +----------+

        | count(*|

        +----------+

        |  1048576 |

        +----------+

        1 row in set (1.09 sec)

        # 通过 show profiles 查看 SQL 对应的 Query_ID

        mysql> show profiles;

        +----------+------------+----------------------------------+

        | Query_ID | Duration   | Query                            |

        +----------+------------+----------------------------------+

        |        1 | 1.09378600 | select count(*from slowtech.t1 |

        +----------+------------+----------------------------------+

        1 row in set1 warning (0.00 sec)

        # 查看该 SQL 各个阶段的执行耗时情况,其中,1 是该 SQL 对应的 Query_ID

        mysql> show profile for query 1;

        +--------------------------------+----------+

        | Status                         | Duration |

        +--------------------------------+----------+

        | starting                       | 0.000157 |

        | Executing hook on transaction  | 0.000009 |

        | starting                       | 0.000020 |

        | checking permissions           | 0.000012 |

        | Opening tables                 | 0.000076 |

        | init                           | 0.000011 |

        | System lock                    | 0.000026 |

        | optimizing                     | 0.000013 |

        | statistics                     | 0.000033 |

        | preparing                      | 0.000032 |

        | executing                      | 1.093124 |

        | end                            | 0.000025 |

        | query end                      | 0.000013 |

        | waiting for handler commit     | 0.000078 |

        | closing tables                 | 0.000048 |

        | freeing items                  | 0.000076 |

        | cleaning up                    | 0.000037 |

        +--------------------------------+----------+

        17 rows in set1 warning (0.01 sec)

        如果指定 all 还会输出更详细的统计信息,包括 CPU、上下文切换、磁盘IO、IPC(进程间通信)发送/接受的消息数量、页面故障次数、交换次数等。

        需要注意的是,这里的统计信息是针对整个进程的,不是单个 SQL 的。如果在执行上述 SQL 的同时还有其它 SQL 在执行,那么这些数据就不能用来评估该 SQL 的资源使用情况。

          mysql> show profile all for query 1\G

          ...

          *************************** 11. row ***************************

                       Status: executing

                     Duration: 0.825417

                     CPU_user: 1.486951

                   CPU_system: 0.007982

            Context_voluntary: 0

          Context_involuntary: 553

                 Block_ops_in: 0

                Block_ops_out: 0

                Messages_sent: 0

            Messages_received: 0

            Page_faults_major: 0

            Page_faults_minor: 24

                        Swaps: 0

              Source_function: ExecuteIteratorQuery

                  Source_file: sql_union.cc

                  Source_line: 1678

          ...

          17 rows in set, 1 warning (0.00 sec)

          三、SHOW PROFILE 的实现原理

          SHOW PROFILE 主要是在sql_profile.cc中实现的。它的实现主要分为两部分:

            数据的采集。

            数据的计算。

          下面我们分别从这两个维度来看看 SHOW PROFILE 的实现原理。

          1、数据的采集

          数据的采集实际上是通过“埋点”实现的。不同阶段对应的“埋点”地址可通过show profile source查看。

            mysql> show profile source for query 1;

            +--------------------------------+----------+-------------------------+----------------------+-------------+

            | Status                         | Duration | Source_function         | Source_file          | Source_line |

            +--------------------------------+----------+-------------------------+----------------------+-------------+

            | starting                       | 0.000157 | NULL                    | NULL                 |        NULL |

            | Executing hook on transaction  | 0.000009 | launch_hook_trans_begin | rpl_handler.cc       |        1484 |

            | starting                       | 0.000020 | launch_hook_trans_begin | rpl_handler.cc       |        1486 |

            | checking permissions           | 0.000012 | check_access            | sql_authorization.cc |        2173 |

            | Opening tables                 | 0.000076 | open_tables             | sql_base.cc          |        5911 |

            | init                           | 0.000011 | execute                 | sql_select.cc        |         760 |

            | System lock                    | 0.000026 | mysql_lock_tables       | lock.cc              |         332 |

            | optimizing                     | 0.000013 | optimize                | sql_optimizer.cc     |         379 |

            | statistics                     | 0.000033 | optimize                | sql_optimizer.cc     |         721 |

            | preparing                      | 0.000032 | optimize                | sql_optimizer.cc     |         806 |

            | executing                      | 1.093124 | ExecuteIteratorQuery    | sql_union.cc         |        1677 |

            | end                            | 0.000025 | execute                 | sql_select.cc        |         796 |

            | query end                      | 0.000013 | mysql_execute_command   | sql_parse.cc         |        4896 |

            | waiting for handler commit     | 0.000078 | ha_commit_trans         | handler.cc           |        1636 |

            | closing tables                 | 0.000048 | mysql_execute_command   | sql_parse.cc         |        4960 |

            | freeing items                  | 0.000076 | dispatch_sql_command    | sql_parse.cc         |        5434 |

            | cleaning up                    | 0.000037 | dispatch_command        | sql_parse.cc         |        2478 |

            +--------------------------------+----------+-------------------------+----------------------+-------------+

            17 rows in set1 warning (0.00 sec)

            以executing为例,它对应的“埋点”地址是sql_union.cc文件的第 1677 行,该行对应的代码是:

                THD_STAGE_INFO(thd, stage_executing);

              其它的“埋点”地址也类似,调用的都是THD_STAGE_INFO,唯一不一样的是 stage 的名称。

              THD_STAGE_INFO 主要会做两件事情:

                采集数据。

                将采集到的数据添加到队列中。

              下面我们结合代码看看具体的实现细节。

                void QUERY_PROFILE::new_status(const char *status_arg, const char *function_arg,

                                               const char *file_arg, unsigned int line_arg) {

                  PROF_MEASUREMENT *prof;

                  ...

                  // 初始化 PROF_MEASUREMENT,初始化的过程中会采集数据。

                  if ((function_arg != nullptr) && (file_arg != nullptr))

                    prof = new PROF_MEASUREMENT(this, status_arg, function_arg,

                                                base_name(file_arg), line_arg);

                  else

                    prof = new PROF_MEASUREMENT(this, status_arg);

                  // m_seq 是阶段的序号,对应 information_schema.profiling 中的 SEQ。

                  prof->m_seq = m_seq_counter++; 

                  // time_usecs 是采集到的系统当前时间。

                  m_end_time_usecs = prof->time_usecs; 

                  // 将采集到的数据添加到队列中,这个队列在查询时会用到。

                  entries.push_back(prof); 

                  ...

                }

                继续分析PROF_MEASUREMENT的初始化逻辑。

                  PROF_MEASUREMENT::PROF_MEASUREMENT(QUERY_PROFILE *profile_arg,

                                                     const char *status_arg,

                                                     const char *function_arg,

                                                     const char *file_arg, unsigned int line_arg)

                      : profile(profile_arg) {

                    collect();

                    set_label(status_arg, function_arg, file_arg, line_arg);

                  }

                  void PROF_MEASUREMENT::collect() {

                    time_usecs = (double)my_getsystime() / 10.0/* 1 sec was 1e7, now is 1e6 */

                  #ifdef HAVE_GETRUSAGE

                    getrusage(RUSAGE_SELF, &rusage);

                  #elif defined(_WIN32)

                    ...

                  #endif

                  }

                  PROF_MEASUREMENT 在初始化时会调用collect函数,collect()函数非常关键,它会做两件事情:

                    通过my_getsystime()获取系统的当前时间。

                    通过getrusage(RUSAGE_SELF, &rusage)获取当前进程(注意是进程,不是当前 SQL)的资源使用情况。

                  getrusage是一个用于获取进程或线程资源使用情况的系统调用。它返回进程在执行期间所消耗的资源信息,包括 CPU 时间、内存使用、页面故障、上下文切换等信息。

                  PROF_MEASUREMENT 初始化完毕后,会将其添加到 entries 中。entries 是一个队列(Queue<PROF_MEASUREMENT> entries)。这个队列,会在执行show profile for query N或者information_schema.profiling时用到。

                  说完数据的采集,接下来我们看看数据的计算,毕竟“埋点”收集的只是系统当前时间,而我们在show profile for query N中看到的Duration 是一个时长。

                  2、数据的计算

                  当我们在执行show profile for query N时,实际上查询的是information_schema.profiling,此时,会调用PROFILING::fill_statistics_info来填充数据。

                  下面我们看看该函数的实现逻辑。

                    int PROFILING::fill_statistics_info(THD *thd_arg, Table_ref *tables) {

                      DBUG_TRACE;

                      TABLE *table = tables->table;

                      ulonglong row_number = 0;

                      QUERY_PROFILE *query;

                      // 循环 history 队列,队列中的元素是 QUERY_PROFILE,每一个查询对应一个 QUERY_PROFILE。

                      // 队列的大小由参数 profiling_history_size 决定,默认是 15。

                      void *history_iterator;

                      for (history_iterator = history.new_iterator(); history_iterator != nullptr;

                           history_iterator = history.iterator_next(history_iterator)) {

                        query = history.iterator_value(history_iterator);

                        ulong seq;

                        void *entry_iterator;

                        PROF_MEASUREMENT *entry, *previous = nullptr;

                        // 循环每个查询中的 entries,entries 存储了每个阶段的系统当前时间。

                        for (entry_iterator = query->entries.new_iterator();

                             entry_iterator != nullptr;

                             entry_iterator = query->entries.iterator_next(entry_iterator),

                            previous = entry, row_number++) {

                          entry = query->entries.iterator_value(entry_iterator);

                          seq = entry->m_seq;

                          if (previous == nullptrcontinue;

                          if (thd_arg->lex->sql_command == SQLCOM_SHOW_PROFILE) {

                            if (thd_arg->lex->show_profile_query_id ==

                                0/* 0 == show final query */

                            {

                              if (query != last) continue;

                            } else {

                              // 如果记录中的 Query_ID 跟 show profile for query query_id 中的不一致,则继续判断下一条记录

                              if (thd_arg->lex->show_profile_query_id != query->profiling_query_id) 

                                continue;

                            }

                          }

                          restore_record(table, s->default_values);

                          // query->profiling_query_id 用来填充 information_schema.profiling 中的 QUERY_ID

                          table->field[0]->store((ulonglong)query->profiling_query_id, true);

                          // seq 用来填充 information_schema.profiling 中的 SEQ

                          table->field[1]->store((ulonglong)seq,

                                                 true); 

                          // status 用来填充 information_schema.profiling 中的 STATE

                          // 注意,这里是上一条记录的 status,不是当前记录的 status

                          table->field[2]->store(previous->status, strlen(previous->status),

                                                 system_charset_info);

                          // 当前记录的 time_usecs 减去上一条记录的 time_usecs 的值,换算成秒,用来填充 information_schema.profiling 中的 DURATION

                          my_decimal duration_decimal;

                          double2my_decimal(

                              E_DEC_FATAL_ERROR,

                              (entry->time_usecs - previous->time_usecs) / (1000.0 * 1000),

                              &duration_decimal); 

                          table->field[3]->store_decimal(&duration_decimal);

                    #ifdef HAVE_GETRUSAGE

                          my_decimal cpu_utime_decimal, cpu_stime_decimal;

                          // 当前记录的 ru_utime 减去上一条记录的 ru_utime,用来填充 information_schema.profiling 中的 CPU_USER

                          double2my_decimal(

                              E_DEC_FATAL_ERROR,

                              RUSAGE_DIFF_USEC(entry->rusage.ru_utime, previous->rusage.ru_utime) /

                                  (1000.0 * 1000),

                              &cpu_utime_decimal);

                          ...

                          table->field[4]->store_decimal(&cpu_utime_decimal);

                    ...

                      return 0;

                    }

                    可以看到,information_schema.profiling中的第三列(STATE,对应 show profile for query N 中的 Status)存储的是上一条记录的 status(阶段名),而第四列(DURATION)的值等于当前记录的采集时间(entry->time_usecs)减去上一条记录的采集时间(previous->time_usecs)。

                    所以,我们在show profile for query N中看到的 Duration 实际上通过下一个阶段的采集时间减去当前阶段的采集时间得到的,并不是show profile source中函数(Source_function)的执行时长。

                    这种实现方式在判断操作当前状态和分析各个阶段耗时时存在一定的误导性。

                    回到开头的 case。

                    四、表空间导入操作为什么大部分耗时是在 System lock 阶段?

                    表空间导入操作是在mysql_discard_or_import_tablespace函数中实现的。

                    下面是该函数简化后的代码。

                      bool Sql_cmd_discard_import_tablespace::mysql_discard_or_import_tablespace(

                          THD *thd, Table_ref *table_list) {

                        ... 

                        THD_STAGE_INFO(thd, stage_discard_or_import_tablespace);

                        ...

                        if (open_and_lock_tables(thd, table_list, 0, &alter_prelocking_strategy)) {

                          return true;

                        }

                        ...

                        const bool discard =

                            (m_alter_info->flags & Alter_info::ALTER_DISCARD_TABLESPACE);

                        error = table_list->table->file->ha_discard_or_import_tablespace(discard,

                                                                                         table_def); 

                        THD_STAGE_INFO(thd, stage_end);

                        ...

                        return true;

                      }

                      可以看到,该函数实际调用的是 THD_STAGE_INFO(thd, stage_discard_or_import_tablespace)。

                      只不过,在调用 THD_STAGE_INFO(thd, stage_discard_or_import_tablespace) 后,调用了 open_and_lock_tables。

                      而 open_and_lock_tables 最后会调用 THD_STAGE_INFO(thd, stage_system_lock)。

                      这也就是为什么上述函数中虽然调用了 THD_STAGE_INFO(thd, stage_discard_or_import_tablespace),但show profile和show processlist的输出中却显示System lock。

                      但基于对耗时的分析,我们发现这么显示其实并不合理。

                      在开头的 case 中,虽然System lock阶段显示的耗时是 788.966338 秒,但实际上open_and_lock_tables这个函数只消耗了 0.000179 秒,真正的耗时是来自 table_list->table->file->ha_discard_or_import_tablespace,其执行时间长达 788.965481 秒。

                      为什么这个函数需要执行这么久呢?主要是表空间在导入的过程中会检查并更新表空间中的每个页,包括验证页是否损坏、更新表空间 ID 和 LSN、处理 Btree 页(如设置索引 ID、清除 delete marked 记录等)、将页标记为脏页等。表越大,检查校验的时候会越久。

                      如此来看,针对表空间导入操作,将其状态显示为discard_or_import_tablespace更能反映操作的真实情况。

                      五、总结

                      在SHOW PROFILE中显示的每个阶段的耗时,实际上是由下一个阶段的采集时间减去当前阶段的采集时间得出的。

                      每个阶段的采集时间是通过在代码的不同路径中植入 THD_STAGE_INFO(thd, stage_xxx) 实现的,采集的是系统当前时间。

                      这种实现方式在判断操作当前状态(通过 SHOW PROCESSLIST)和分析各个阶段耗时(通过 SHOW PROFILE )时存在一定的误导性,主要是因为预定义的阶段数量是有限的。

                      在 MySQL 8.4 中,共定义了 98 个阶段,具体的阶段名可在mysqld.cc中的all_server_stages数组找到。

                      在表空间导入操作中,虽然大部分耗时显示为System lock阶段,但实际上,使用discard_or_import_tablespace来描述这一过程会更为准确。

                      >>>>

                      参考资料

                      作者丨陈臣

                      来源丨公众号:MySQL实战(ID:MySQLInAction)

                      dbaplus社群欢迎广大技术人员投稿,投稿邮箱:editor@dbaplus.cn

                      阅读原文

                      跳转微信打开

                      Fish AI Reader

                      Fish AI Reader

                      AI辅助创作,多种专业模板,深度分析,高质量内容生成。从观点提取到深度思考,FishAI为您提供全方位的创作支持。新版本引入自定义参数,让您的创作更加个性化和精准。

                      FishAI

                      FishAI

                      鱼阅,AI 时代的下一个智能信息助手,助你摆脱信息焦虑

                      联系邮箱 441953276@qq.com

                      相关标签

                      MySQL 可传输表空间 SHOW PROFILE 性能分析
                      相关文章