1. 慢查询日志的开启方式:参数不是改完就结束
先把结论放这:慢查询日志是MySQL排查性能问题最直接的入口,适合所有用了MySQL但还没系统看过慢查询日志的团队。很多人以为只要把 slow_query_log 打开,慢SQL就会自动出现在日志里,但真正配置过的人都知道,这里面的门道比你想象的多。
1.1 我见过的大多数默认配置,几乎等于没开
MySQL安装完默认情况下,slow_query_log 是关闭的,long_query_time 默认是10秒。10秒意味着什么?一个页面查询超过10秒,用户早就刷新页面或者关掉浏览器了,你等慢日志捞出来黄花菜都凉了。
所以在生产上,我一般建议把阈值调到1秒以内。不是说所有超过1秒的SQL都必须优化,而是先记录下来,再去分辨哪些值得处理。低于1秒的SQL一般不至于产生严重的线上故障,但业务流量上来之后,单次几十毫秒的SQL在并发下也会拖垮数据库,那是另一个层面的问题。
推荐的开启配置如下(以MySQL 8.0为例):
ini复制[mysqld]
slow_query_log = 1
slow_query_log_file = /data/mysql/logs/slow-query.log
long_query_time = 1
log_queries_not_using_indexes = 0
log_throttle_queries_not_using_indexes = 10
log_output = FILE
动态修改可以执行:
sql复制SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;
SET GLOBAL log_queries_not_using_indexes = 'OFF';
注意,long_query_time 的修改只对新建立的连接生效,已经存在的连接池连接会一直沿用旧值。所以我通常会建议修改完配置文件之后,再重启MySQL或至少观察一下当前连接是否已经拿到新变量值。
1.2 log_query_not_using_indexes到底要不要开
这个参数一开,所有没走索引的查询都会写进慢日志,它的初衷是帮你找出漏网之鱼。但在实际生产中,我强烈建议先不要贸然打开。
原因很简单:很多业务表本身数据量极小,几百行甚至几十行,这种表MySQL优化器认为全表扫描比走索引更快,你偏偏要求它“必须使用索引”,结果就是满屏都是这种SQL的慢日志记录,真正需要关注的慢查询反而被淹没在里面。
如果非要开,请把 log_throttle_queries_not_using_indexes 也配上,这个参数限制每分钟最多记录多少条“未使用索引”的查询,避免慢日志文件瞬间暴涨打满磁盘。
1.3 慢日志格式里最容易忽略的三个时间字段
一条典型的慢日志记录长这样:
code复制# Query_time: 2.543521 Lock_time: 0.000331 Rows_sent: 28 Rows_examined: 409690
# Thread_id: 2897033 Schema: shopdb Errno: 0
# Rows_examined: 409690 Rows_sent: 28
SET timestamp=1718611200;
SELECT user_id, order_id, amount FROM order_info WHERE status = 1 ORDER BY create_time DESC LIMIT 28;
一般人会直接看 Query_time,觉得这一项大就该优化。但有个细节:Query_time 包含了 Lock_time,如果你发现某条慢日志的 Query_time 很长而实际执行时间很短,问题大概率出在锁等待上,而不是SQL本身。后面我会专门用一个章节展开讲这种情况。
还有一个容易忽略的点是 Rows_examined 和 Rows_sent 的比例。刚才这条日志扫描了40万行,最后只返回28行,这种扫描行数与返回行数差距巨大的SQL,是典型的索引设计不合理。如果一条慢查询的 Rows_examined 只有几百行,那就算执行时间到了1秒,也未必单纯是SQL问题,可能是服务器CPU负载、磁盘IO或者其他外部因素导致的,处理思路并不一样。
1.4 分析慢日志:mysqldumpslow和pt-query-digest怎么配合
日志是文本,直接打开看会很痛苦。MySQL自带的 mysqldumpslow 工具能帮你按不同的维度汇总:
bash复制# 按照平均查询时间排序,取前10条
mysqldumpslow -s at -t 10 /data/mysql/logs/slow-query.log
# 按查询次数排序,看哪些SQL经常出现
mysqldumpslow -s c -t 20 /data/mysql/logs/slow-query.log
这个工具会把SQL中的具体数字参数替换成N或S,这样同一条SQL的不同参数值能汇总成一条记录。比如 WHERE user_id = 123 和 WHERE user_id = 456 在汇总后会合并成 WHERE user_id = N,你可以一眼看出哪类SQL模式是慢查询中的大头。
Percona Toolkit里的 pt-query-digest 更专业,它会按“查询指纹”把SQL归类,并输出每个指纹的总耗时、平均耗时、出现次数、响应时间占比,还能按天生成报告。
bash复制pt-query-digest /data/mysql/logs/slow-query.log > /tmp/slow_report.txt
查看报告时,优先关注顶部“Profile”里的“Response time ratio”这一列。如果某条SQL的响应时间占比超过20%,哪怕它出现的次数不多,也值得优先排查。
提示:mysqldumpslow和pt-query-digest统计的是“同一类SQL模式”,不是每条具体的SQL。你命中一个热点查询模式后,要用
EXPLAIN去分析它的执行计划,这比继续翻慢日志更高效。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 用EXPLAIN看执行计划时,我到底在看什么
定位到了具体的慢SQL,下一步就是用 EXPLAIN 看执行计划。这里我不打算从头讲每个字段的含义,只说实际分析时最值得盯住的几个点,以及那些容易理解错的地方。
2.1 type列:从ALL到const的访问路径变化
执行计划里的 type 列,反映的是MySQL访问数据的方式。从优到差大致是:system > const > eq_ref > ref > range > index > ALL。
ALL 表示全表扫描,是最需要警惕的。只要表的数据量达到几十万行以上,出现 ALL 的查询基本都是需要优化。
index 这个值容易被误判为“用了索引就是好事”,但这里的 index 代表的是“完整扫描二级索引”,和全表扫描的区别只是扫描的是索引文件而不是数据文件。如果查询需要的数据都能从索引覆盖,那还有救;如果需要回表,index 扫描反而可能比全表扫描更慢。
ref 比较常见,代表通过普通索引等值匹配找到若干行。eq_ref 则是在join查询中,被驱动表通过主键或唯一索引等值匹配一行。
还有一个容易被忽略的 range,它在索引范围扫描时出现。很多开发者看到结果里有range就认为走了索引没问题,但要知道 range 只能保证“这段范围内的数据在索引中有序”,如果查询里还需要排序、分组或者关联,range之后往往还跟着filesort或temporary。
我实际分析时,最关注的是 rows 列和数据实际量的差距。MySQL估算的rows不总是准确,尤其是当查询条件里有复杂的表达式或者多表关联时,估算偏差可能达到几十倍。你可以先看 rows 评估的量级,如果这个量级与表的实际规模明显不符,再考虑用 ANALYZE TABLE 更新一下统计信息。
2.2 Extra列里最值得警觉的两种标记
Using filesort 和 Using temporary 是慢查询里最常看到的两种额外标记,几乎每次看到都说明这条SQL还有优化空间。
Using filesort 不是真的意味在磁盘上建了一个文件来做排序,它的意思是MySQL无法直接利用索引的有序性来完成排序,必须额外执行一个排序步骤。排序的数据量小可能只在内存的排序缓冲区完成,数据量大了就会用磁盘临时文件辅助排序,这样性能会有明显的下降。
Using temporary 表示MySQL为了完成查询需要创建临时表,常见于GROUP BY、DISTINCT、UNION、ORDER BY与GROUP BY字段不一致等情况。
我见过不少同事看到 Using temporary 就很紧张,但实际上得看临时表是在内存还是磁盘。EXPLAIN 不会告诉你这一点,要结合 SHOW STATUS LIKE 'Created_tmp_disk_tables' 前后对比来看。
2.3 key_len的计算藏着联合索引用到哪一层的秘密
在MySQL 5.7及8.0版本中,key_len 表示本次执行计划实际用到的索引键长度(单位是字节)。通过这个值可以反推一个联合索引到底用到了第几列。
举个例子:
sql复制CREATE TABLE user_order (
id INT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL,
status TINYINT NOT NULL DEFAULT 1,
create_time DATETIME NOT NULL,
KEY idx_user_status_time (user_id, status, create_time)
) ENGINE=InnoDB;
执行下面的查询:
sql复制EXPLAIN SELECT * FROM user_order WHERE user_id = 10086 AND status = 1 ORDER BY create_time DESC LIMIT 20;
idx_user_status_time 这个索引有三列。如果执行计划里显示的 key_len 是4+1+5=10左右,说明三列都用上了;如果 key_len 只有4,说明只用了 user_id 这一列,那么后面还有可能在Extra里出现filesort,因为status条件并没有让排序走索引。
一个容易出错的地方是字段的可空设计会影响 key_len 的长度。NOT NULL 的字段不占用可空标志位,允许NULL的字段需要额外一个字节;VARCHAR 类型的字段在utf8mb4字符集下,一个字符最大4字节,变长字段还要额外的2字节长度信息。
所以不要死记具体数值,而是知道这个字段的“最小、最大可能占用”分别对应什么情况,再结合你建表时的字段定义去判断。
2.4 过滤条件下索引字段的函数操作
这是隐式杀手。很多慢SQL表面看查询条件里写了索引字段,但实际执行时索引完全用不上。最常见的就是在索引字段上套了函数:
sql复制-- 索引失效:对date_add/func()后的索引列做条件
SELECT * FROM user_order WHERE DATE(create_time) = '2024-06-01';
-- 正确的写法:索引直接比较
SELECT * FROM user_order
WHERE create_time >= '2024-06-01 00:00:00'
AND create_time < '2024-06-02 00:00:00';
后者可以把索引上用上范围查询,性能差异在数据量大时会非常明显。
另一种情况发生在隐式类型转换上。字段类型是 VARCHAR,查询条件却传了数字,MySQL会把索引列的字符串转成数字再比较,索引就失效了。这类问题不出现在慢日志的SQL文本里,但你执行计划里会看到type变成ALL,找半天发现其实只是少了一个引号。
3. 深分页查询优化的完整过程:从扫描10万行到扫描100行
分页慢的问题在慢日志里出现频率极高,很多团队到了数据量大了之后才意识到“上一页很快,翻到后面越来越慢”,这时候就开始考虑各种缓存方案,甚至有人会提出用Redis来缓存分页结果。先别急,我们逐步拆解一下问题。
3.1 一个典型的深分页慢查询长什么样
假设业务上有一个订单列表页,用户在下单记录里翻页,翻到很深的时候卡顿严重。原始SQL大致如下:
sql复制SELECT *
FROM order_info
WHERE buyer_id = 9527
AND order_status = 1
ORDER BY create_time DESC
LIMIT 10000, 20;
这条SQL看起来字段都建了索引:idx_buyer_status_time (buyer_id, order_status, create_time),用 EXPLAIN 去看也确实能命中索引,type大概是 range 或 ref。那为什么翻到第500页会慢?
原因在于 LIMIT 10000, 20 的含义是“先取出10020条记录,再把前10000条丢掉”。即便通过索引定位数据,MySQL仍要为这10020条记录逐一回表读取完整行,然后只返回最后20条。回表是随机IO,量一大,延迟自然就上来了。
3.2 第一次优化:延迟关联,先投影主键再回表
对于这类深分页问题,优先考虑“延迟关联”的思路:先走索引拿到需要返回的主键ID,再和原表进行关联,取出完整行。
sql复制SELECT o.*
FROM order_info o
INNER JOIN (
SELECT order_id
FROM order_info
WHERE buyer_id = 9527
AND order_status = 1
ORDER BY create_time DESC
LIMIT 10000, 20
) AS tmp ON o.order_id = tmp.order_id
ORDER BY o.create_time DESC;
内层子查询的 SELECT order_id 在二级索引 idx_buyer_status_time 上就可以完成,因为 order_id 作为主键被包含在二级索引页中,不需要回表。MySQL扫描这10020条索引记录的过程是顺序IO,比回表10020次快得多。最后再通过子查询得到20个主键ID回原表取数据,只回表20次。
我在一个几十万行数据的测试表上做过对比,优化前该慢查询执行时间稳定在1.8秒左右,优化后降到了0.08秒,执行计划里也不再看到大量的 Using index condition 回表迹象。
但要注意:这个优化方案在处理翻页“最后一页”的场景时,可能还是不够快。比如总共只有12000条数据,用户翻到最后一页(LIMIT 11980, 20),内层子查询仍然需要扫描并丢弃11980条索引记录。这时候可以考虑换用“基于游标”的分页方案。
3.3 第二次优化:游标分页,翻页不再是“跳过”
基于游标的分页核心逻辑是:记住上一页最后一条记录的位置,下一页直接从这个位置往后取。以 create_time DESC 为例,利用上一页最后一条记录的 create_time 和 order_id 做条件:
sql复制SELECT *
FROM order_info
WHERE buyer_id = 9527
AND order_status = 1
AND (create_time < '2024-06-01 10:30:00'
OR (create_time = '2024-06-01 10:30:00' AND order_id < 12345))
ORDER BY create_time DESC, order_id DESC
LIMIT 20;
这里 ORDER BY 里加上了 order_id DESC 作为第二排序字段,是为了让 create_time 相同的记录也有稳定的顺序,避免分页过程中出现数据重复或丢失。
这种写法的好处是:不管翻到多深,每次查询都只扫描20条记录,执行时间非常稳定。我实际压测下来,从第1页到第1000页,耗时基本都在30毫秒以内。代价是SQL变得复杂,且前端无法再直接传“页码+每页条数”来跳转,必须把上一页的游标传过来。如果你的产品允许改成“下拉加载更多”或“上一页/下一页”模式,这个方案是性价比最高的。
重要提醒:不要一看到分页慢就想着上Redis做缓存。通过
LIMIT offset这种硬跳的分页结果本身就很难缓存命中,用户翻页位置各不相同,缓存命中率很低。先把SQL层面的深翻页问题解决,是成本最低的做法。用缓存之前你还要考虑缓存穿透/击穿问题,增加复杂度,未必能从根本上缩短单次查询时间。
4. 一个ORDER BY排序触发filesort的案例
排查慢日志时经常遇到这类SQL不是查一行慢,而是“一个分组统计结果慢得离谱”。比如运营后台要看每天每个订单状态的订单量:
sql复制SELECT order_status, COUNT(*) AS cnt, DATE(create_time) AS day
FROM order_info
WHERE create_time >= '2024-01-01'
GROUP BY order_status, DATE(create_time)
ORDER BY day DESC;
这条SQL在数据量上去之后慢慢变成了慢日志的常客。用 EXPLAIN 一看,虽然 WHERE create_time 范围条件走了索引,Extra列里却出现了 Using temporary; Using filesort。为什么?
4.1 在索引列上套函数会导致统计无法利用索引顺序
DATE(create_time) 对 create_time 做了处理,MySQL需要先将每行记录的 create_time 转换成日期值,才能进行分组。如果分组字段是经过函数计算得到的,索引的有序性就没法直接为分组服务,只能创建临时表来做聚合和排序。
这种SQL优化思路是把函数条件转换为范围条件,并确保最终分组的字段顺序能命中索引顺序。
先改造 WHERE create_time 部分:
sql复制SELECT order_status, COUNT(*) AS cnt, DATE(create_time) AS day
FROM order_info
WHERE create_time >= '2024-01-01'
AND create_time < '2024-01-02'
GROUP BY order_status, DATE(create_time)
ORDER BY day DESC;
上面只改了一天的统计,比较适合定时任务每天跑一次。如果业务上确实需要直接查一个时间区间,我们把GROUP BY字段尽量设计成与索引键列顺序一致,让MySQL可以直接在索引扫描过程中完成聚合,减少临时表和排序。
但要注意,DATE(create_time) 这个表达式列始终存在,MySQL无法完全避免对函数结果的分组。所以更进一步的做法是考虑增加一个冗余字段:
sql复制ALTER TABLE order_info ADD COLUMN create_date DATE NOT NULL DEFAULT '1970-01-01';
每次写入或更新订单时,同时写入 create_date = DATE(create_time),然后给该字段建索引:
sql复制ALTER TABLE order_info ADD INDEX idx_date_status (create_date, order_status);
查询改成:
sql复制SELECT order_status, COUNT(*) AS cnt
FROM order_info
WHERE create_date >= '2024-01-01'
AND create_date < '2024-01-02'
GROUP BY order_status, create_date
ORDER BY create_date DESC;
这种优化后,统计完全可以在索引上完成,执行计划里不再有 Using temporary,扫描行数也大幅下降。我在一次报表统计优化里,把原来需要5秒左右的聚合查询降到了0.2秒。
4.2 不要忘了GROUP BY和ORDER BY字段一致这条老规矩
很多版本升级后还保留了旧习惯——写 GROUP BY 时没加 ORDER BY NULL。这里需要区分版本:
- MySQL 5.6/5.7 中
GROUP BY默认会隐式地对分组字段排序,如果不需要排序结果,可以加ORDER BY NULL让优化器跳过排序步骤(实际5.7很多case中也可以优化,但保险起见显式写出最稳妥)。 - MySQL 8.0 起,
GROUP BY不再默认排序,这时候再写ORDER BY NULL反而会被当成多余的排序指令忽略,影响不大。
如果你的业务本身就不需要分组后的排序,建议在兼容5.7和8.0的代码里都显式写清楚:
sql复制SELECT order_status, COUNT(*) AS cnt
FROM order_info
WHERE create_date >= '2024-01-01'
GROUP BY order_status, create_date
ORDER BY NULL;
这样既保证了5.7环境下不会隐式引入额外排序,也不会影响8.0下的执行计划。
4.3 filesort并不是永远需要消除
我看到一些文章标题喜欢说“必须消除filesort”,这在大多数场景下没错,但要注意极端情况。如果排序的数据量本身比较小(比如排序结果集只有几百行),额外的排序成本可以忽略。这时如果为了消除filesort而强行创建一个宽联合索引,反而可能导致写入变慢、索引占用空间变大。
判断是否需要消除filesort的简单标准:看Extra里出现filesort的SQL,其过滤后的结果集量级和表总行数的比例。如果结果集只是表中很小的子集,filesort一般不是主要瓶颈,主要瓶颈反而在定位这些子集的扫描路径上。反过来,如果结果集占了表的大部分数据,filesort和临时表可能成为瓶颈,能通过索引消除就尽量消除。
5. 慢查询日志之外的另类“慢SQL”:锁等待和元数据锁
我排查线上慢查询时,经常遇到一种困惑:慢日志里明明记录了一条SQL很慢,但把它单独拿出来执行却飞快。这种“只在某个时间段慢”的SQL,问题往往不在SQL本身,而在于它执行时被其他事务卡住了。
5.1 行锁等待:SQL本身很快,实际执行却在排队
MySQL的InnoDB引擎默认行锁等待超时时间是50秒。如果一个事务迟迟不提交或者回滚,它持有的行锁就会阻塞其他事务的更新操作,而这些被阻塞的SQL在执行时间上会表现为慢查询。
我处理过一个案例:某张订单状态表每天凌晨有批处理任务更新一批记录,批处理逻辑里先查出数据,再逐条更新。这个过程中因为一个事务里做了太多的事情,导致其他业务的 UPDATE 和 SELECT ... FOR UPDATE 排起了长队。慢日志里看到的是多条需要几十秒的UPDATE,但每条SQL单独执行只要几毫秒。
排查方法比较简单,可以先看当前正在执行的线程:
sql复制SHOW FULL PROCESSLIST;
当看到大量线程处于 Updating 或 Waiting for handler commit 状态时,重点观察它们的 Time 列。很多线程卡了很长时间的话,再进一步查看InnoDB事务锁信息:
sql复制SELECT * FROM performance_schema.data_lock_waits\G
SELECT * FROM performance_schema.data_locks\G
注意这两个表在MySQL 5.7和8.0中存在差异,如果你用的是MySQL 5.6之前的老版本,需要查 INNODB_TRX、INNODB_LOCK_WAITS 和 INNODB_LOCKS 三张信息架构表。
这类问题的处理重点是找到持锁事务并优化它的执行逻辑,比如让大事务拆分成多个小事务、减少单次UPDATE影响的行数、优化更新语句的索引等。单纯调整 innodb_lock_wait_timeout 只是在缩短等待时间,不能解决根因。
5.2 元数据锁:ALTER TABLE操作引发的连锁反应
还有一种情况比行锁更加隐蔽,Metadata Lock元数据锁。任何对表的结构操作(ALTER TABLE、CREATE INDEX)都会获取表的元数据锁,如果在执行前有一个长事务一直占着这张表的元数据读锁,后面的 ALTER TABLE 就会阻塞,并进一步阻塞后续所有对该表的读写查询。
我在一个业务大表上加索引时遇到过:ALTER TABLE 执行了很久,期间所有涉及到该表的查询都开始堆积,等到慢查询日志把表数据导出来后,才发现大量 SELECT 的 State 都停留在 Waiting for table metadata lock。
遇到这类问题的排查思路:先找到阻塞源头的会话,看哪一条事务一直未提交,把它处理掉之后,ALTER TABLE 才能真正开始执行。如果是大表的Online DDL,也建议用 pt-online-schema-change 这类工具来降低锁影响时间,不要直接在生产环境上裸跑。
5.3 慢查询日志与监控联动才能完整定位
慢查询日志只是一份“事后记录”,它不能实时告诉你当前数据库正在发生什么。如果希望尽早发现问题,日常工作里我建议至少持续开着慢日志,并配合一个轻量的监控巡检。
巡检脚本可以定时拉取下面的信息,用于发现当前会话是否有堆积:
sql复制SHOW GLOBAL STATUS LIKE 'Threads_connected';
SHOW GLOBAL STATUS LIKE 'Threads_running';
SHOW FULL PROCESSLIST;
当 Threads_running 连续多分钟高于 innodb_thread_concurrency 的设置时,数据库大概率已经处于过载状态,再把慢日志里的SQL拉出来分析会更有针对性。
个人经验:真正能稳定定位慢SQL根因的组合动作是“慢查询日志 + EXPLAIN执行计划 + 当前会话快照”三者互相对照。日志告诉你哪些SQL慢,执行计划告诉你这条SQL为什么慢,会话快照告诉你在某个时间点是不是有别的因素拖慢了它。三个信息缺一个,排查起来都会事倍功半。
这篇文章里提到的延迟关联、游标分页、分组字段冗余和锁等待排查,是我在订单列表、报表统计、状态更新等业务场景下都实际用过的招数。慢查询的核心不是“消灭慢日志”,而是让每一条慢日志都变成你理解数据库运行状态的线索。碰到问题先别急着加缓存、上分库分表,慢日志和EXPLAIN通常已经能告诉你大部分真相了。
