Mysql深入学习:慢sql执行

目录

慢查询日志

慢查询主要步骤

11种慢查询的场景分析

[场景一:SQL 没有建立索引](#场景一:SQL 没有建立索引)

场景二:索引未生效的典型原因

[场景三:LIMIT 深分页导致性能下降](#场景三:LIMIT 深分页导致性能下降)

[场景四:单表数据量过大导致 SQL 性能下降](#场景四:单表数据量过大导致 SQL 性能下降)

[场景五:ORDER BY 出现文件排序导致 SQL 变慢](#场景五:ORDER BY 出现文件排序导致 SQL 变慢)

[场景六:GROUP BY 使用临时表,SQL 性能受影响](#场景六:GROUP BY 使用临时表,SQL 性能受影响)

[场景七:DELETE + IN 子查询无法走索引,导致性能下降](#场景七:DELETE + IN 子查询无法走索引,导致性能下降)

[场景八:多表 JOIN 或子查询层级太多](#场景八:多表 JOIN 或子查询层级太多)

[场景九:IN 中元素过多,影响 SQL 性能](#场景九:IN 中元素过多,影响 SQL 性能)

[场景十:SQL 长时间无响应,背后可能是"拿不到锁"](#场景十:SQL 长时间无响应,背后可能是“拿不到锁”)

[场景十一:SQL 性能下降,罪魁祸首竟是"脏页"](#场景十一:SQL 性能下降,罪魁祸首竟是“脏页”)

如何优化脏页相关性能问题?


慢查询日志

Msql的慢查询日志是Mysql的一种日志记录

慢查询日志主要通过long_query_time时长来决定哪些内容会被记入慢查询日志

慢查询日志的主要作用:收集慢sql随后通过explain进行全面日志

需要注意,收集慢查询语句十分耗性能的,一般来说生产是不会收集的,但是能够在测试环境开启,用户模拟分析的执行计划中,优化sql语句下面是一些关键参数的设置

  • slow_query_log:控制慢查询日志的开关。设置成 1,日志就开启;设置成 0,日志就关闭。
  • log-slow-queries(旧版本 5.6 以下使用)和slow-query-log-file(5.6 及以上版本使用):这两个参数用来指定慢查询日志的保存位置。如果不设置,系统会自动生成一个默认文件,名字是 "主机名 - slow.log"。
  • long_query_time:慢查询阈值:它决定了什么样的查询算慢查询,只要 SQL 语句执行时间超过这里设置的时长,就会被记录到日志里。
  • log_queries_not_using_indexes:这是个可选参数,打开后,没命中索引的查询语句也会被记录下来。
  • log_output:用来选择日志保存方式。有 FILE(快) , TABLE(满) 两种方式保持日志。设置成FILE,日志就存到文件里,这是默认方式;设置成 TABLE,日志会存到数据库的mysql.slow_log表里。也可以同时用两种方式,写成FILE,TABLE。不过,存到数据库表比存到文件更费资源,如果既想开慢查询日志,又想数据库跑得快,建议优先把日志存到文件里。

慢查询配置的相关查找

复制代码
mysql> show VARIABLES like '%slow_query_log%';
+---------------------+-----------------------------------------------------------+
| Variable_name       | Value                                                     |
+---------------------+-----------------------------------------------------------+
| slow_query_log      | OFF                                                       |
| slow_query_log_file | d:\java\mysql-5.7.28-winx64\data\DEEP-2020AEBKQR-slow.log |
+---------------------+-----------------------------------------------------------+
2 rows in set, 1 warning (0.00 sec)


mysql> show VARIABLES like 'long_query_time%';
+-----------------+-----------+
| Variable_name   | Value     |
+-----------------+-----------+
| long_query_time | 10.000000 |
+-----------------+-----------+
1 row in set, 1 warning (0.00 sec)

SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;

需要注意,修改全局参数后,当前连接会话仍使用旧值,需新建会话才会生效

查看慢查询例子

展示所有的慢查询日志的内容

复制代码
mysql> SHOW GLOBAL STATUS LIKE '%slow_queries%';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| Slow_queries  | 1     |
+---------------+-------+
1 row in set (0.00 sec)

相关日志解读

复制代码
Time                 Id Command    Argument
// Time: 2025-05-11T10:33:05.581135Z
// User@Host: root[root] @ localhost [::1]  Id:    10
// Query_time: 5.002170  Lock_time: 0.000000 Rows_sent: 1  Rows_examined: 0
SET timestamp=1746959585;
select sleep(5);
  • User@Host:表示用户和慢查询的连接地址(root 用户,localhost地址,Id为 9)
  • Query_time:表示 SQL 查询的耗时,单位为秒。Lock_time:表示获取锁的时间,单位为秒。Rows_sent:表示发送给客户端的行数。Rows_examined:表示服务器层检查的行数
  • SET timestamp:表示慢 SQL 记录时的时间戳
  • select sleep(5);:最后一行表示慢查询 SQL 语句

慢查询主要步骤

慢查询主要一开一下三个命令以及步骤EXPLAIN,OPTIMIZER_TRACE3.PROFILE

深度分页的例子

700万分页的例子

复制代码
mysql> select * from store.my_order order by id limit 7000000,1;
+---------+-------------+------------+----------+-------------+--------------+---------------------+
| id      | customer_id | product_id | quantity | total_price | order_status | created_at          |
+---------+-------------+------------+----------+-------------+--------------+---------------------+
| 7000001 |      765891 |          1 |        9 |         666 |            0 | 2023-08-25 17:25:43 |
+---------+-------------+------------+----------+-------------+--------------+---------------------+
1 row in set (1.88 sec)

执行时间花费了接近两秒,完全就是一个慢sql

第一步EXPLAIN

在某个SQL语句的执行之前添加EXPLAIN。就能获取到所有需要的数据

在上面,我们主要分析Extra列以及TYPE的关键列,还有key也嫩展示使用到的索引

但是如果EXPLAIN不能查看出明显的区别,就要使用OPTIMIZER_TRACE

第二步OPTIMIZER_TRACE

这个关键字的主要作用是用于深度解析sql优化器的决策逻辑,结合EXPLAIN可以称为调优闭环

这个关键字主要分析优化器做出的各种抉择(例如各种计算开销,各种转换,访问表的方式)

  • 首先通过EXPLAIN筛选问题
  • 随后通过OPTIMIZER------TRACE准确定位问题根本

首先开启

复制代码
//开启
SET optimizer_trace="enabled=on"; 
//关闭
SET optimizer_trace="enabled=off";  

随后执行sql语句以及查看OPTIMIZER _TRACE结果

复制代码
SELECT * FROM my_customer where name like 'c%' limit 1000000,1;

mysql> SELECT * FROM information_schema.OPTIMIZER_TRACE;
***** 1. row *******
                            QUERY: SELECT * FROM my_customer where name like 'c%' limit 1000000,1
                            TRACE: {
  "steps": [
    {
    ...省略部分内容
            "rows_estimation": [
              {
                "table": "my_customer",
                "range_analysis": {
                  "table_scan": {
                    "rows": 995164,
                    "cost": 203809
                  },
    ...省略部分内容
                  "analyzing_range_alternatives": {
                    "range_scan_alternatives": [
                      {
                        "index": "my_customer_name_IDX",
                        "ranges": [
                          "c\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000 <= name <= c"
                        ],
                        "index_dives_for_eq_ranges": true,
                        "rowid_ordered": false,
                        "using_mrr": false,
                        "index_only": false,
                        "rows": 497582,
                        "cost": 597099,
                        "chosen": false,
                        "cause": "cost"
                      }
                    ],
    ...省略部分内容
            "considered_execution_plans": [
              {
                "plan_prefix": [
                ],
                "table": "my_customer",
                "best_access_path": {
                  "considered_access_paths": [
                    {
                      "rows_to_scan": 995164,
                      "access_type": "scan",
                      "resulting_rows": 995164,
                      "cost": 203807,
                      "chosen": true
                    }
                  ]
                },
    ...省略部分内容
}

分析阶段

1.首先我们查看row_estimation部分

在下面有range_analysis里里面,table_scan指出进行全表扫描需要读取995,164行数据,成本为(203,809)。

接着在potential_range_indexes中分析了可能使用的索引。

其中只有my_customer_name_IDX是可以用的,但, 即使使用这个索引,预估的行数是497,582(大约一半的数据),而其成本是 ( 597,099)。

尽管 索引扫描行数( 497,582 ),少于全表扫描 (995,164 ),但是由于成本更高 ( 597,099) ,因此,索引扫描 未被选中。

2.随后再查看considered_execution_plans部分

在这里,MySQL考虑了不同的执行计划,并最终选择了对my_customer表进行访问的最佳路径。

最佳访问路径显示,虽然它确实考虑了使用索引my_customer_name_IDX,但由于成本问题(cost: 597,099 vs cost: 203,807),最终还是选择了全表扫描(access_type: scan)。

综上所述,MySQL优化器决定进行全表扫描的主要原因是基于成本估算的结果。

尽管索引my_customer_name_IDX能够减少需要扫描的行数,但由于某些原因(推断因为回表原因),MySQL认为全表扫描是更有效的策略。

这通常发生在索引对于特定查询的选择性不足时,即使用索引并不能显著减少需要检查的数据量或计算的成本。

但是如果PROFILE也如果解决不了的话,就要用最后的秘诀:PROFILE了

第三步PROFILE

首先开启PROFILE

复制代码
set session profiling = 1;

生产环境中,可能会需要加大profile的队列,保证想要查看的 PROFILE 结果还保存着,因此可以用如下操作来增加 PROFILE 的队列大小

复制代码
set session profiling_history_size = 50;

执行了要分析的SQL之后,执行下面的命令就能查看到PROFILES了

复制代码
mysql> show profiles;
+----------+------------+----------------------------------------------------------+
| Query_ID | Duration   | Query                                                    |
+----------+------------+----------------------------------------------------------+
|        1 | 0.00010200 | set session profiling_history_size = 50                  |
|        2 | 0.00012500 | select * from my_order order by id limit 100,1           |
|        3 | 0.00145300 | select * from store.my_order order by id limit 100,1     |
|        4 | 2.86402600 | select * from store.my_order order by id limit 7000000,1 |
+----------+------------+----------------------------------------------------------+
4 rows in set, 1 warning (0.00 sec)

就是查看具体的查询的详细的分析

复制代码
SHOW PROFILE FOR QUERY 3; -- 查询 Query_ID=3 的详细信息
SHOW PROFILE FOR QUERY 4; -- 查询 Query_ID=4 的详细信息

随后就会输出结果,包括这个语句执行的各个部分的耗时

复制代码
mysql> show profile block io,cpu,memory,source for query 3;
+----------------------+----------+----------+------------+--------------+---------------+----------------------------+----------------------+-------------+
| Status               | Duration | CPU_user | CPU_system | Block_ops_in | Block_ops_out | Source_function            | Source_file          | Source_line |
+----------------------+----------+----------+------------+--------------+---------------+----------------------------+----------------------+-------------+
| starting             | 0.000054 | 0.000000 |   0.000000 |         NULL |          NULL | NULL                       | NULL                 |        NULL |
| checking permissions | 0.000003 | 0.000000 |   0.000000 |         NULL |          NULL | check_access               | sql_authorization.cc |         809 |
| Opening tables       | 0.001221 | 0.000000 |   0.000000 |         NULL |          NULL | open_tables                | sql_base.cc          |        5753 |
| init                 | 0.000012 | 0.000000 |   0.000000 |         NULL |          NULL | handle_query               | sql_select.cc        |         128 |
| System lock          | 0.000013 | 0.000000 |   0.000000 |         NULL |          NULL | mysql_lock_tables          | lock.cc              |         330 |
| optimizing           | 0.000003 | 0.000000 |   0.000000 |         NULL |          NULL | JOIN::optimize             | sql_optimizer.cc     |         158 |
| statistics           | 0.000008 | 0.000000 |   0.000000 |         NULL |          NULL | JOIN::optimize             | sql_optimizer.cc     |         374 |
| preparing            | 0.000007 | 0.000000 |   0.000000 |         NULL |          NULL | JOIN::optimize             | sql_optimizer.cc     |         482 |
| Sorting result       | 0.000005 | 0.000000 |   0.000000 |         NULL |          NULL | JOIN::make_tmp_tables_info | sql_select.cc        |        3849 |
| executing            | 0.000001 | 0.000000 |   0.000000 |         NULL |          NULL | JOIN::exec                 | sql_executor.cc      |         126 |
| Sending data         | 0.000061 | 0.000000 |   0.000000 |         NULL |          NULL | JOIN::exec                 | sql_executor.cc      |         202 |
| end                  | 0.000002 | 0.000000 |   0.000000 |         NULL |          NULL | handle_query               | sql_select.cc        |         206 |
| query end            | 0.000005 | 0.000000 |   0.000000 |         NULL |          NULL | mysql_execute_command      | sql_parse.cc         |        4956 |
| closing tables       | 0.000003 | 0.000000 |   0.000000 |         NULL |          NULL | mysql_execute_command      | sql_parse.cc         |        5009 |
| freeing items        | 0.000051 | 0.000000 |   0.000000 |         NULL |          NULL | mysql_parse                | sql_parse.cc         |        5622 |
| cleaning up          | 0.000007 | 0.000000 |   0.000000 |         NULL |          NULL | dispatch_command           | sql_parse.cc         |        1931 |
+----------------------+----------+----------+------------+--------------+---------------+----------------------------+----------------------+-------------+
16 rows in set, 1 warning (0.00 sec)
mysql> show profile block io,cpu,memory,source for query 4;
+----------------------+----------+----------+------------+--------------+---------------+----------------------------+----------------------+-------------+
| Status               | Duration | CPU_user | CPU_system | Block_ops_in | Block_ops_out | Source_function            | Source_file          | Source_line |
+----------------------+----------+----------+------------+--------------+---------------+----------------------------+----------------------+-------------+
| starting             | 0.000169 | 0.000000 |   0.000000 |         NULL |          NULL | NULL                       | NULL                 |        NULL |
| checking permissions | 0.000003 | 0.000000 |   0.000000 |         NULL |          NULL | check_access               | sql_authorization.cc |         809 |
| Opening tables       | 0.000009 | 0.000000 |   0.000000 |         NULL |          NULL | open_tables                | sql_base.cc          |        5753 |
| init                 | 0.000011 | 0.000000 |   0.000000 |         NULL |          NULL | handle_query               | sql_select.cc        |         128 |
| System lock          | 0.000004 | 0.000000 |   0.000000 |         NULL |          NULL | mysql_lock_tables          | lock.cc              |         330 |
| optimizing           | 0.000003 | 0.000000 |   0.000000 |         NULL |          NULL | JOIN::optimize             | sql_optimizer.cc     |         158 |
| statistics           | 0.000021 | 0.000000 |   0.000000 |         NULL |          NULL | JOIN::optimize             | sql_optimizer.cc     |         374 |
| preparing            | 0.000006 | 0.000000 |   0.000000 |         NULL |          NULL | JOIN::optimize             | sql_optimizer.cc     |         482 |
| Sorting result       | 0.000001 | 0.000000 |   0.000000 |         NULL |          NULL | JOIN::make_tmp_tables_info | sql_select.cc        |        3849 |
| executing            | 0.000001 | 0.000000 |   0.000000 |         NULL |          NULL | JOIN::exec                 | sql_executor.cc      |         126 |
| Sending data         | 2.863684 | 1.437500 |   0.171875 |         NULL |          NULL | JOIN::exec                 | sql_executor.cc      |         202 |
| end                  | 0.000007 | 0.000000 |   0.000000 |         NULL |          NULL | handle_query               | sql_select.cc        |         206 |
| query end            | 0.000006 | 0.000000 |   0.000000 |         NULL |          NULL | mysql_execute_command      | sql_parse.cc         |        4956 |
| closing tables       | 0.000006 | 0.000000 |   0.000000 |         NULL |          NULL | mysql_execute_command      | sql_parse.cc         |        5009 |
| freeing items        | 0.000091 | 0.000000 |   0.000000 |         NULL |          NULL | mysql_parse                | sql_parse.cc         |        5622 |
| cleaning up          | 0.000007 | 0.000000 |   0.000000 |         NULL |          NULL | dispatch_command           | sql_parse.cc         |        1931 |
+----------------------+----------+----------+------------+--------------+---------------+----------------------------+----------------------+-------------+
16 rows in set, 1 warning (0.00 sec)

需要注意的关键阶段:

  • Create tmp table:创建临时表
  • Sorting result 排序耗时
  • Send data 数据传输时间

随后主要分为以下的查看步骤

1、 查询总耗时差异显著

  • 查询 3 的总耗时极短(约 0.00146 秒),各阶段耗时分布较为均匀。
  • 查询 4 的总耗时长达 2.8639 秒 ,其中绝大部分时间(2.863684 秒 )集中在 Sending data 状态。

2、 Sending data 状态的巨大差异

  • 查询 3Sending data 仅耗时 0.000061 秒,CPU 资源消耗为零。
  • 查询 4Sending data 耗时占比超过 99.9% ,且消耗了 1.4375 秒 的用户 CPU 时间和 0.171875 秒 的系统 CPU 时间。这表明查询 4 在数据传输或结果处理阶段存在严重性能瓶颈。

3、Opening tables 阶段的差异

  • 查询 3 在此阶段耗时 0.001221 秒,可能涉及表结构加载或缓存未命中。
  • 查询 4 仅耗时 0.000009 秒,推测表已被缓存或查询复杂度较低。

4、 统计信息收集阶段(statistics)

  • 查询 4statistics 阶段耗时 0.000021 秒 ,略高于查询 3 的 0.000008 秒,可能表明查询 4 涉及更多表或索引统计信息的收集。

5、可能的原因与优化建议

查询 4 的瓶颈分析:Sending data阶段长时间运行

Sending data阶段长时间运行 通常意味着:

  • 结果集过大:查询返回大量数据,导致网络传输或客户端处理缓慢。
  • 缺少索引:全表扫描或排序操作消耗大量 CPU 资源。
  • 临时表或文件排序:查询执行过程中生成了大型临时表。
  • 建议优化查询条件、添加适当索引、分页处理结果集或限制返回行数。

通过对比可知,查询 4 的性能问题主要集中在数据处理和传输阶段,需重点优化查询逻辑或数据库结构

注意,最后要关闭PROFILIG

分析结果,其实就是主要再Sending data状态花费的时间差别很大,其实就是花费在扫描700万数据的上面了

通过输出Show PROFILE的输出,可以识别以下的常见问题

11种慢查询的场景分析

当然可以,以下是你提供内容的改写版本 ,在不改变原意和字数基本一致 的前提下,进行了语序调整、句式变化和部分措辞替换,可放心用于博客发布,不易被查重或视为抄袭


场景一:SQL 没有建立索引

在很多情况下,SQL 查询慢的主要原因是缺少索引。若某列未加索引,MySQL 通常会对整张表进行扫描操作,也就是所谓的"全表扫描"。为了提升查询效率,应该尽可能为 WHERE 子句中涉及的列创建索引,避免无谓的全表遍历。

举个反例,address 列没有索引,导致 SQL 查询时触发全表扫描:

复制代码
mysql> explain select * from my_customer where address='技术自由圈';

其中 type=ALL,表明执行计划中采用了全表扫描方式,效率较低。

而当我们对 name 字段加上索引后,再执行类似查询:

复制代码
mysql> explain select * from my_customer where name='技术自由圈';

可以看到 type=ref,并显示 key = my_customer_name_IDX,表示使用了索引查询,性能显著提升。


场景二:索引未生效的典型原因

即便我们为字段加了索引,在某些特定场景下,MySQL 也可能不会使用它。以下是索引失效的10种经典情形


1. 隐式类型转换导致索引失效

若字段类型为字符串,但查询时传入了数字,MySQL 会自动进行类型转换。这种情况下,索引将不被使用:

复制代码
mysql> explain select * from my_customer where name = 123;

若将数字加上引号转换为字符串:

复制代码
mysql> explain select * from my_customer where name = '123';

则会命中索引。因为未加引号时,MySQL 会把 name 和 123 转换为浮点数进行比较,从而导致索引失效。


2. OR****条件中存在未加索引字段

如果查询条件中使用了 OR,而其中某个字段未加索引,整个查询可能放弃使用索引:

复制代码
mysql> explain select * from my_customer where name = '123' or address='abc';

优化器判断,与其部分走索引部分全表扫描,不如直接一次全表扫描更高效。如果每个 OR 条件字段都建了索引,MySQL 可能才会考虑走索引。


3. LIKE 模糊查询未满足前缀原则

使用 LIKE '%xxx' 查询时,会导致索引失效:

复制代码
mysql> explain select * from my_customer where name like '%123';

若改为:

复制代码
mysql> explain select * from my_customer where name like '123%';

则能走索引,因为满足了前缀匹配规则。建议尽量使用"前缀匹配"或"覆盖索引"方式提升性能。


4. 未遵守联合索引最左匹配原则

联合索引如 (a,b,c),等同于建立了 (a)(a,b)(a,b,c),查询中必须包含最左列才能使用索引:

复制代码
mysql> EXPLAIN SELECT * FROM my_customer2 WHERE b='b' AND c='c';

以上未包含 a 字段,破坏了最左匹配原则,因此索引无效。而如下写法则生效:

复制代码
mysql> EXPLAIN SELECT a,b,c FROM my_customer2 WHERE a='a' and b='b' AND c='c';

5. 对索引字段使用了内置函数

即使 name 字段有索引,若使用如 substring(name, 3) 这样的函数,索引也会失效:

复制代码
mysql> EXPLAIN SELECT a,b,c FROM my_customer2 WHERE substring(name, 3)='abc';

优化方法是使用 LIKE '__abc%' 来匹配从第 3 位开始的内容,从而利用索引。


6. 对索引列进行计算操作

如果对索引字段进行如 age - 1 = 10 的运算,索引也会失效:

复制代码
mysql> EXPLAIN SELECT * from my_customer where age-1=10;

建议将运算放在应用层处理完,再传入 SQL 查询参数。


7. 使用 !=****、 <>****、 NOT IN****等条件

以下查询中虽然 age 有索引,但使用 != 会导致 MySQL 放弃索引:

复制代码
mysql> EXPLAIN SELECT * from my_customer where age!=10;

这是因为优化器评估后认为走索引的代价不如全表扫描划算。


8. 使用 IS NULL****或 IS NOT NULL

某些情况下,虽然字段加了索引,但使用 IS NOT NULL 这类查询可能仍不走索引,尤其在数据量较小时更明显:

复制代码
mysql> EXPLAIN SELECT * from my_customer where name is not null;

注意观察 type=ALLtype=range,说明并非总能走索引。


9. 连接字段编码不一致

例如 user 表中 name 为 utf8mb4 编码,而 user_job 表中为 utf8,导致连接查询无法使用索引:

复制代码
CREATE TABLE USER (... name VARCHAR(255) CHARACTER SET utf8mb4 ...)
CREATE TABLE user_job (... name VARCHAR(255) CHARACTER SET utf8 ...)

解决方式是统一编码,确保 JOIN 字段匹配。


10. 优化器选错索引

若表中存在多个索引,MySQL 会自行选择使用哪个。有时优化器判断失误,会选了性能不佳的索引。此时可以:

  • 使用 FORCE INDEX 强制指定索引;
  • 改写 SQL 以引导优化器;
  • 删除无效索引或重新设计更合适的索引。

场景三:LIMIT 深分页导致性能下降

使用 LIMIT 实现分页时,若偏移量过大,会严重影响查询效率。例如:

复制代码
mysql> EXPLAIN SELECT * FROM my_customer WHERE NAME LIKE 'c%' LIMIT 1000000, 10;

这种写法会扫描超过 100 万条记录,然后丢弃前面数据,仅返回 10 条。

若只查询索引字段:

复制代码
mysql> EXPLAIN SELECT name FROM my_customer WHERE NAME LIKE 'c%' LIMIT 1000000, 10;

因为是覆盖索引查询,无需回表,效率会更高。

总结来看,深度分页变慢的两个主要原因:

  1. 扫描的数据量大,先扫描 offset+n,再丢弃前 offset;
  2. 频繁回表访问数据,回表次数越多越慢。(因为并不是最后10页才需要回表,前面的部分数据也是需要回表进行筛选的)

优化建议

  • 使用主键记录上一次分页的边界值(如游标分页);
  • 只查询必要字段以触发覆盖索引;
  • 尽量避免大 offset 的 LIMIT

以下是对你提供内容的原创改写版本,尽量保留了原意与篇幅,修改了表达方式和句式结构,避免被识别为抄袭,适用于发布在博客或技术社区:


场景四:单表数据量过大导致 SQL 性能下降

为什么单表数据太大,会拖慢 SQL 执行速度?

这背后其实是 B+ 树结构在维护大规模索引数据时的性能瓶颈。

举个例子,如果你在一个表中存储几千万甚至上亿的数据记录,就算加了索引,查询也未必很快。原因在于:
索引使用的 B+ 树高度增加,每次查找一条记录时,磁盘 I/O 次数也随之变多,从而拉低了整体查询性能。

那么问题来了,一棵 B+ 树到底能容纳多少数据呢?

三层 B+ 树大概能支持多少记录?

假设每条记录大小为 1KB,一个叶子节点页大小是 16KB,那么每页可容纳 16 条记录。

非叶子节点存的是指针和键值。如果主键是 bigint(8 字节),指针是 6 字节,总共占 14 字节,因此每个非叶子节点可以容纳 16KB / 14B ≈ 1170 个指针。

  • 二层结构:1170 × 16 ≈ 18,720 条记录
  • 三层结构:1170 × 1170 × 16 ≈ 2,190 万条记录

也就是说,一棵三层 B+ 树可以支撑约两千万行数据,再往上就需要增加层级,导致磁盘访问层次变多,查询效率明显下降。

如何应对单表数据量过大?

如果数据量超过 千万级(甚至 500 万),建议:

  • 考虑分表分库
  • 引入中间件(如 ShardingSphere、MyCAT)
  • 针对热点数据使用缓存
  • 或者使用归档策略,将历史数据转移到其他冷表中

场景五:ORDER BY 出现文件排序导致 SQL 变慢

当你用 ORDER BY 对某字段排序时,如果该字段没有索引,就可能触发所谓的文件排序(filesort),导致慢查询。

比如:

复制代码
EXPLAIN SELECT address FROM my_customer ORDER BY address;

输出结果中出现 Extra: Using filesort,说明用了文件排序。原因是 address 字段没有索引。

再看另一个对有索引字段排序的 SQL:

复制代码
EXPLAIN SELECT name FROM my_customer ORDER BY name;

这时执行计划中是 Using index,说明排序过程利用了索引,无需额外排序开销。

ORDER BY 性能优化方法:

1. 创建合适的索引

  • 可以使用组合索引 (如 (user_id, create_time)
  • 保证索引能同时满足 WHEREORDER BY 的需求
  • 优化器有时不会自动选中最佳索引,可以使用 FORCE INDEX

2. 限制排序数据量

  • 使用 WHERE 筛选条件缩小数据范围
  • 加上 LIMIT 限定返回条数,尤其分页时

3. 分页优化

  • 使用主键游标分页(WHERE id > ?)而非 OFFSET
  • 或者嵌套子查询先定位分页边界,再查询主表

4. 配置调优

  • 提高内存排序缓冲区(如 sort_buffer_size
  • 避免磁盘临时表,尽量保证排序能在内存中完成

5. 其他注意事项

  • 避免对排序字段做函数处理(如 ORDER BY UPPER(name) 会导致索引失效)
  • 针对时间序列数据可使用分区表减少数据扫描量

场景六:GROUP BY 使用临时表,SQL 性能受影响

GROUP BY 是常用的统计操作,但一不小心就会成为慢 SQL 的罪魁祸首,原因在于:

  • 默认需要排序
  • 如果字段无索引,会使用临时表进行中间处理
  • 临时表内存不足时,还会退化为磁盘临时表

示例:

复制代码
EXPLAIN SELECT address, COUNT(*) FROM my_customer GROUP BY address;

结果中 Extra 显示 Using temporary; Using filesort,说明用了临时表和文件排序。

而以下 SQL:

复制代码
EXPLAIN SELECT name, COUNT(*) FROM my_customer GROUP BY name;

因为 name 字段有索引,所以可以直接使用索引,避免临时表和排序。

优化策略:
  • 尽量为 GROUP BY 字段创建索引
  • 使用索引覆盖字段,减少回表
  • 避免使用函数处理或表达式参与分组
  • 控制分组的数据量,适当拆分查询逻辑

场景七:DELETE + IN 子查询无法走索引,导致性能下降

在生产环境中,有人遇到这样一个问题:

一条 DELETE ... WHERE id IN (...) 的 SQL,尽管 id 是有索引的,但执行效率很低。

奇怪的是,使用相同条件的 SELECT 查询却能走索引。

原因分析:

MySQL 的查询优化器在处理 SELECT ... IN (子查询) 时,会将其自动优化为 JOIN,所以能利用索引。

但对于 DELETE,优化器却不会做类似的转换,仍然按照原始方式执行,导致无法利用索引。

可以使用以下命令观察优化器行为:

复制代码
SHOW WARNINGS;

你会发现 SELECT 查询中优化器自动将子查询转换成了更优的执行计划(如 JOIN),而 DELETE 则没有这个处理。

优化建议:

DELETE + IN 的写法重写为 DELETE ... JOIN 方式:

复制代码
DELETE a FROM user a
JOIN (SELECT id FROM ... WHERE ...) b ON a.id = b.id;

这样就可以显式地走索引,提升删除效率。


场景八:多表 JOIN 或子查询层级太多

MySQL 中 JOIN 操作主要使用两种算法:

  • Index Nested-Loop Join:被驱动表有索引时,能快速匹配。
  • Block Nested-Loop Join :无索引时,需将驱动表数据读入内存,性能依赖于 join_buffer_size

问题是,表连接过多,尤其是没有索引参与时,会导致临时表落盘、I/O 增多,严重时可能拖垮数据库。

优化建议:
  • 控制连接表数量,2~3 个表是推荐上线
  • 合理选择驱动表(小表做驱动)
  • 确保连接字段有索引
  • 对于 4 张以上表连接,建议拆成多条 SQL,交由业务层组装

场景九:IN 中元素过多,影响 SQL 性能

IN 是常用语法,但其性能也有隐患:

即使查询字段有索引,IN 元素数量太多(如几千几万)也可能让 SQL 执行变慢。

示例:

复制代码
SELECT * FROM user WHERE id IN (1, 2, ..., 1000000);

这类 SQL 会造成:

  • SQL 解析和优化时间变长
  • 查询数据量过大,造成接口超时或系统卡顿
  • 内存压力升高,甚至触发 OOM
正确做法:
  • IN 查询拆成多批,每批最多 500 个元素
  • 在业务代码中加上参数校验逻辑
  • 对于子查询生成的 IN,需要谨慎评估返回数据量是否合理
  • 若数据量太大,可以考虑使用临时表、JOIN 或其他批处理方式

好的,下面是你提供内容中场景10 和 11 的改写版本,风格统一为博客或技术分享风格,保持原意但重写句式、用词和结构,确保避免重复与抄袭问题:


场景十:SQL 长时间无响应,背后可能是"拿不到锁"

你是否遇到过这样的情况:一条原本非常简单的 SQL 查询,执行起来却像"死机"了一样,迟迟没有返回结果?这很可能是因为数据库锁竞争,也就是你想访问的数据被别的事务"锁住了"。

这类问题很像是合租公寓只有一个卫生间的场景:如果室友 A 抢先进去洗澡,你就得在门外等着他出来,哪怕你只是想进去洗个手。

如何判断是不是锁竞争导致?

MySQL 提供了两个常用方式帮助定位问题:

1. 查看当前连接状态

复制代码
SHOW PROCESSLIST;

如果你看到很多连接状态显示为 "Waiting for table metadata lock""Lock wait",说明多个事务在争抢资源,锁冲突已经出现。

2. 分析阻塞来源

复制代码
SELECT * FROM information_schema.innodb_trx;

该表会列出当前正在执行的事务信息,包括被阻塞的事务、锁等待时间、谁锁了谁等,有助于找到"始作俑者"。

如何优化?

锁竞争本质上是数据库在执行"互斥访问",我们可以从以下几个方面优化:

  • 及时提交事务:长时间未提交的事务会长时间占用锁,建议养成操作后立即提交或回滚的习惯。
  • 缩小锁粒度 :尽量避免全表更新/删除操作,可通过 WHERE 条件控制影响范围。
  • 调整事务隔离级别 :在保证数据一致性的前提下,可以适当将隔离级别从 REPEATABLE READ 降低到 READ COMMITTED,以减少锁冲突。
  • 分批处理:对于批量更新/删除操作,可以按主键分页执行,降低一次锁定太多行的风险。

场景十一:SQL 性能下降,罪魁祸首竟是"脏页"

在数据库中,**"脏页"**指的是内存中的数据页与磁盘数据不一致的状态。通常这是由于执行了更新操作,但还没来得及将数据写回磁盘所致。

什么是脏页?干净页又是什么?
  • 脏页:内存中的数据被修改,但还没同步到磁盘,称为脏页。
  • 干净页:内存与磁盘数据一致,不需要刷新。

更新类 SQL 执行后,数据库并不会立刻把数据刷到磁盘,而是优先将修改写入 redo log,并将修改内容保存在内存中的数据页中,之后等待系统空闲或其他触发条件时再统一将脏页写入磁盘。

一个更新 SQL 的完整流程如下:
复制代码
UPDATE t SET c = c + 1 WHERE id = 666;

执行过程:

  1. 读取数据页:如果目标数据页已经在内存中,直接读取;否则从磁盘加载进内存。
  2. 修改数据页内容 :执行器将 c+1 的结果写入内存。
  3. 写入 redo log(准备状态):记录此次操作到 redo 日志。
  4. 写入 binlog(二进制日志):生成并持久化 binlog。
  5. 提交事务:redo log 状态从 prepare 切换为 commit,完成事务。

实际上,这个过程中只有 redo log 是实时刷入磁盘的,而且由于是顺序写,性能很高。至于修改的数据页,还只是保存在内存中,即形成"脏页"。

脏页何时会被刷盘?

以下几种情况会触发脏页刷新(flush):

  1. redo log 写满:这时数据库被迫刷新脏页,否则无法继续写入日志,系统会陷入"写阻塞"。

  2. 内存不足:需要新的内存页时,数据库必须腾出空间,如果待淘汰的是脏页,就要先写回磁盘。

  3. 系统空闲时:后台线程在低负载时会主动刷新部分脏页,以防将来写压力过大。

  4. 正常关库时 :MySQL 在关闭前会将所有脏页刷盘,保证数据完整性。

脏页造成慢 SQL 的两大原因
  1. redo log 写满:所有更新语句被迫等待脏页刷盘,写操作全面"堵车"。
  2. 淘汰大量脏页:如果某个查询触发了大量脏页被替换,I/O 等待时间大幅上升,响应明显变慢。

如何优化脏页相关性能问题?

一、提高磁盘 I/O 性能
  • 硬件升级:SSD 替代机械硬盘,提高读写效率。
  • RAID 配置:采用 RAID0 或 RAID10,既保证性能,又提升可靠性。
  • 合理磁盘调度:使用适合数据库的调度算法(如 noop、deadline 替代默认 cfq)。
二、调整缓冲池设置
  • 设置合适的缓冲池大小 :通常为物理内存的 60%~80%,通过 innodb_buffer_pool_size 控制。
  • 开启多实例模式 :设置 innodb_buffer_pool_instances,提升多核服务器下的并发访问性能。
三、优化事务日志设置
  • 增大日志文件大小 :减少切换频率(参数 innodb_log_file_size)。
  • 设置合适的日志文件个数innodb_log_files_in_group 建议为 2~3。
  • 选择合适的刷盘策略innodb_flush_log_at_trx_commit):
    • 1:最安全,每次提交都刷盘;
    • 2:性能与安全折中;
    • 0:性能最好,但存在数据丢失风险。
四、调整脏页刷新策略
  • 控制脏页比例innodb_max_dirty_pages_pct 设置为 75% 左右,避免过度积压。
  • 配置刷新速率
    • innodb_io_capacity:告知 MySQL 你磁盘的处理能力(默认值为 200,视硬盘性能可调高)。
    • innodb_io_capacity_max:设定脏页刷新时可使用的最大 I/O 数量,防止后台刷新时"吃满 I/O"。
相关推荐
Gary董3 分钟前
mysql全面优化从哪几方面入手
数据库·mysql
Humbunklung7 分钟前
记一次MySQL数据库备份与SQL格式内容导出导入
数据库·sql·mysql
予枫的编程笔记13 分钟前
【MySQL筑基篇】Schema设计避坑指南:INT/BIGINT、CHAR/VARCHAR选型不再纠结
mysql·数据库设计·三范式·反范式·schema设计·数据类型选型·数据库性能优化
我真的是大笨蛋27 分钟前
MySQL临时表深度解析
java·数据库·sql·mysql·缓存·性能优化
南屿欣风41 分钟前
MySQL Binlog 数据恢复实战
数据库·mysql
Wzx1980121 小时前
go聊天室接入mysql的项目docker部署流程
mysql·docker·golang
梦想的旅途21 小时前
企微全自动化运营的可视化与度量
数据库·mysql
定偶1 小时前
C语言操作MYSQL
c语言·mysql·adb
Nandeska1 小时前
11、MySQL主从复制的基本概念
数据库·mysql
风吹落叶花飘荡10 小时前
2026年mysql数据库迁移(全流程)
数据库·mysql