Mysql深入学习:慢sql执行
目录
慢查询日志
慢查询主要步骤
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,1TRACE: {"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 状态的巨大差异
- 查询 3的
Sending data
仅耗时 0.000061 秒,CPU 资源消耗为零。 - 查询 4的
Sending data
耗时占比超过 99.9%,且消耗了 1.4375 秒 的用户 CPU 时间和 0.171875 秒 的系统 CPU 时间。这表明查询 4 在数据传输或结果处理阶段存在严重性能瓶颈。
3、Opening tables 阶段的差异
- 查询 3在此阶段耗时 0.001221 秒,可能涉及表结构加载或缓存未命中。
- 查询 4仅耗时 0.000009 秒,推测表已被缓存或查询复杂度较低。
4、 统计信息收集阶段(statistics)
- 查询 4的
statistics
阶段耗时 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=ALL
或 type=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;
因为是覆盖索引查询,无需回表,效率会更高。
总结来看,深度分页变慢的两个主要原因:
- 扫描的数据量大,先扫描 offset+n,再丢弃前 offset;
- 频繁回表访问数据,回表次数越多越慢。(因为并不是最后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)
) - 保证索引能同时满足
WHERE
和ORDER 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;
执行过程:
- 读取数据页:如果目标数据页已经在内存中,直接读取;否则从磁盘加载进内存。
- 修改数据页内容:执行器将
c+1
的结果写入内存。 - 写入 redo log(准备状态):记录此次操作到 redo 日志。
- 写入 binlog(二进制日志):生成并持久化 binlog。
- 提交事务:redo log 状态从 prepare 切换为 commit,完成事务。
实际上,这个过程中只有 redo log 是实时刷入磁盘的,而且由于是顺序写,性能很高。至于修改的数据页,还只是保存在内存中,即形成“脏页”。
脏页何时会被刷盘?
以下几种情况会触发脏页刷新(flush):
- redo log 写满:这时数据库被迫刷新脏页,否则无法继续写入日志,系统会陷入“写阻塞”。
- 内存不足:需要新的内存页时,数据库必须腾出空间,如果待淘汰的是脏页,就要先写回磁盘。
- 系统空闲时:后台线程在低负载时会主动刷新部分脏页,以防将来写压力过大。
- 正常关库时:MySQL 在关闭前会将所有脏页刷盘,保证数据完整性。
脏页造成慢 SQL 的两大原因
- redo log 写满:所有更新语句被迫等待脏页刷盘,写操作全面“堵车”。
- 淘汰大量脏页:如果某个查询触发了大量脏页被替换,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”。