Mysql深入学习:慢sql执行

发布于:2025-08-04 ⋅ 阅读:(12) ⋅ 点赞:(0)

目录

慢查询日志

慢查询主要步骤

11种慢查询的场景分析

场景一:SQL 没有建立索引

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

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

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

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

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

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

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

场景九:IN 中元素过多,影响 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 NULLIS 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”。


网站公告

今日签到

点亮在社区的每一天
去签到