先别急着回答“我加过索引”。这个问题我经常用来问新人,也被老同事追问过,但真正能讲清楚的人不多。SQL慢查询优化不是一句“加索引”就完事的事情,索引只是最后落到执行计划里的一个手段,而完整的链路包括:你有没有留过数据库的查询统计、有没有打开慢查询日志、有没有把真正拖慢业务的Top SQL捞出来,然后才是针对性的改索引、改SQL、改数据访问方式。
这篇分享主要围绕三类读者来写:一类是刚接触数据库调优的后端开发,一类是线上出了性能事故急需排查的同学,一类是想把慢查询治理做成日常机制的技术负责人。你会发现无论是几百万行的小表还是上亿行的大表,排查思路都是共通的。文章不会讲特别玄学的“黑魔法”,更多是把我这几年在生产环境踩过的坑、验证过有效的方法讲给你听,你可以直接拿去当排查手册用。
1. 你真的关心过业务系统里的SQL耗时吗
很多系统跑着跑着就慢了,线上反馈“接口超时”“列表转圈”,第一反应去看监控、看日志、看数据库。但如果你反问一句:这个慢,到底慢在哪个SQL?哪个SQL执行了多久?平均值是多少?历史上有没有明显变慢的趋势?很多团队是答不上来的。说白了,大家不关心SQL耗时,只在它出问题的时候才被迫关心,这就是后续容易被反复折腾的根本原因。
1.1 一个连问倒一片的经典问题
我面试过不少人,简历上都写着“熟悉MySQL、做过SQL优化”,问到这个题目的时候,十个人里有六七个会说“给慢查询加索引”。再追问“你怎么知道这条查询是慢查询?慢查询日志怎么开?你怎么统计哪条SQL最慢?”就卡壳了。
这个现象在真实业务里也经常出现。有个同事接到一个“导出报表特别慢”的工单,拿着SQL看了半天,发现一张流水表连主键都没用到,全表扫描了几百万条。他张口就说“加个索引”,但是加了之后查询是快了,导出接口还是慢,因为慢的根本不在SQL,而在应用层循环调用了N次SQL。这种教训其实很常见:你以为的慢SQL不是真正的慢SQL,真正慢的地方,恰恰是你没有统计过耗时、没有留过日志的功能模块。
所以这个连问的核心价值,是逼着你先把“SQL耗时”这个事实体系建起来。没有数据就没有方向,没有方向就谈不上优化。
1.2 慢查询的统计对象到底是什么
先明确概念:慢查询是指在数据库里执行时间超过某个阈值的SQL语句,这个阈值由参数 long_query_time 控制,单位为秒,可以精确到小数。比如设成1,代表执行时间超过1秒的SQL都会被认为是慢查询,会被记录到慢查询日志里。
很多业务系统的接口,总耗时目标是200ms或500ms,如果一条SQL就要1秒以上,那这个接口基本废了。所以业务型数据库网上,我们一般把 long_query_time 设为1,如果是做离线统计分析或者复杂报表,又可以单独把阈值调高,避免日志量把磁盘打爆。
1.3 没统计数据就没法治理
关心SQL耗时,不是等出了事故才想起来去看一眼。更需要做的是把它常态化:今天有多少慢查询、比昨天多了还是少了、集中在哪几张表、哪个应用节点产生的。没有这个过程,优化做得再多,都只是在“头痛医头”。
我见过比较合理的实践,是每周来一次慢查询巡检。拿到本周的慢SQL列表后,按执行次数乘以平均耗时排出“贡献度”。这个公式特别重要,优化可以优先做贡献度最高的几个,性价比最高。有些SQL虽然一条要8秒,但一个月就跑一次;另有一些SQL一条只要1.5秒,但每秒被调用几百次,后者才是真正的隐形杀手。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 慢查询日志的正确开启姿势与统计方法
上一节把理念讲通了,这一节直接上实操。不同版本的MySQL在慢日志开启上有细微差别,我以最常用的5.7和8.0为例说明,这个思路在MariaDB、TiDB等兼容协议数据库上也基本适用。
2.1 让慢查询日志先工作起来
先检查当前实例的配置:
sql复制SHOW VARIABLES LIKE 'slow_query_log%';
SHOW VARIABLES LIKE 'long_query_time%';
SHOW VARIABLES LIKE 'log_queries_not_using_indexes';
如果 slow_query_log 显示为OFF,可以直接动态开启,不用重启实例:
sql复制SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;
SET GLOBAL log_queries_not_using_indexes = 'ON';
这里的 long_query_time=1 代表1秒,实际业务可以根据情况调整:比如核心交易库建议0.5秒,后台管理系统能接受1-2秒。log_queries_not_using_indexes 这个开关需要特别注意,一旦打开,连全表扫描的短查询也会写进慢日志,这种数据用于发现漏优化场景还挺有用,但误开会导致日志暴涨,别一直开着。
有一个细节容易踩坑:SET GLOBAL long_query_time = 1 只对新建立的会话生效,你当前正在用的这个客户端连接,阈值可能还是旧值。想立刻验证,必须再执行一次:
sql复制SET SESSION long_query_time = 1;
2.2 日志到底存在文件还是表里
MySQL支持把慢查询记录到文件,也支持写到系统库的 mysql.slow_log 表。我个人的生产习惯是写到文件,因为配合后面的分析工具更灵活;如果只是临时排查,写到表里查询起来更方便。
日志文件的滚动也要处理。长期不清理,慢日志文件能长到几十GB,不但占磁盘,还会影响后续分析。Linux下最简单的做法是用定时任务按天切分,或者干脆把日志目录放到独立磁盘分区。如果用的是云数据库,一般可以在控制台直接看到慢日志明细,省去自己捞的麻烦。
2.3 用工具统计Top慢SQL
日志文件收集好了,里面可能是几万行碎记录,靠肉眼没法看。我常用的工具是MySQL自带的 mysqldumpslow,用法很简单:
bash复制mysqldumpslow -s c -t 10 /var/lib/mysql/slow.log
参数含义:-s c 表示按次数排序,-t 10 表示只看前10条。还可以用 -s t 按总耗时,-s at 按平均耗时排序。它会自动把数字和字符串参数替换成N和S,把同一种结构不同参数的SQL归并成一条,这种输出看起来非常清爽。
如果服务器上装了Percona Toolkit,更推荐用 pt-query-digest:
bash复制pt-query-digest /var/lib/mysql/slow.log > digest_report.txt
它会生成一个HTML或文本报告,把慢SQL按查询指纹分组,并按总响应时间排序,还能看到每个查询在整份报告里占的百分比。这份报告,基本就是接下来几天你要处理的优化清单了。
顺着这条线提一句5.7之后的Performance Schema,它是系统级的SQL统计方案。如果你不想依赖慢查询日志,也可以直接查这张表:
sql复制SELECT * FROM sys.statement_analysis ORDER BY avg_latency DESC LIMIT 20;
sys.statement_analysis 把每类SQL的平均耗时、总耗时、执行次数都整理好了。开这个东西会有少量性能开销,但对经常做治理的团队来说,这点开销很划算。
3. EXPLAIN:把慢SQL的“路况”看清楚
捞到慢SQL只是第一步,第二步要回答“为什么慢”。绝大多数情况,我们靠 EXPLAIN 看执行计划就够了。如果对执行计划里的字段还不熟,不要急着用工具“一键优化”,先把几个核心字段吃透。
3.1 一条执行计划的降级之路
EXPLAIN 的用法非常简单,直接在慢SQL前面加EXPLAIN即可:
sql复制EXPLAIN SELECT * FROM t_order WHERE user_id = 1024 ORDER BY create_time DESC LIMIT 10;
返回结果里最关键的先看 type 字段,它描述了MySQL在表里找数据的方式,性能从好到差大致是:
| type | 含义 | 是否推荐 |
|---|---|---|
| system | 表只有一行 | 极罕见 |
| const | 主键或唯一索引等值查询 | 很好 |
| eq_ref | 联表查询时被驱动表通过唯一索引匹配 | 很好 |
| ref | 普通索引等值匹配 | 好 |
| range | 索引范围扫描 | 可接受 |
| index | 遍历索引树 | 一般 |
| ALL | 全表扫描 | 需要警惕 |
如果看到ALL,先别慌,表数据量小(比如不到1000行)全表扫描也不是问题。但如果是一张上百万行的表,出现ALL基本就是慢查询的头号嫌疑。
3.2 key、rows和Extra代表什么
key 表示这条SQL实际会用哪个索引;rows 是MySQL估算需要读取的行数;Extra 是最值得关注的信息之一。
看到 Using filesort 意味着MySQL需要在排序缓冲区里额外做一次排序才能满足 ORDER BY;看到 Using temporary 意味着要建立临时表,常见于GROUP BY或去重操作;看到 Using join buffer 表示联表时没有有效索引可以利用,只能把部分数据加载到内存里做暴力关联。
这三个只要出现一个,SQL基本快不了。它们的优化方向也直接:排序字段和过滤字段一起建联合索引;GROUP BY字段尽量走索引;JOIN条件列必须加索引,并且让驱动表是小结果集那一边。
3.3 别迷信rows估算,实际定位还要看Profile
执行计划里的rows是估算值,基于表统计信息,不一定准。尤其是 SELECT COUNT(*) FROM t 这种查询,执行计划看到的rows可能很高,但还需要结合真实执行耗时来确认。
在MySQL 8.0中可以用 EXPLAIN ANALYZE 实际执行SQL并输出耗时和行数真实统计:
sql复制EXPLAIN ANALYZE SELECT * FROM t_order WHERE status = 1 ORDER BY id DESC LIMIT 100000, 20;
它的返回会带上 actual time,这比静态的估算值靠谱得多。不过这个命令会真实执行SQL,写操作或者特别重的查询要慎用。
4. 慢SQL的常见根因与改写优化实战
执行计划看明白了,问题具体出在哪一类场景,优化手法也就有了针对性。下面我把线上最常见的慢查询类型一个个盘过去,并给出可以直接套用的优化方案。
4.1 索引失效的“七宗罪”
有几个经典场景会让本来好不容易建好的索引失效,我每一条都踩过:
第一是条件字段上做函数运算。比如:
sql复制SELECT * FROM t_order WHERE DATE(create_time) = '2025-01-01';
这不是写错,但DATE()把create_time列包了一层,索引就失效了。正确做法是改成范围查询:
sql复制SELECT * FROM t_order WHERE create_time >= '2025-01-01 00:00:00' AND create_time < '2025-01-02 00:00:00';
第二是隐式类型转换。字符串字段被传入整数,或者数字字段被传入字符串,MySQL会做隐式转换,同样让索引失效。需要注意参数类型和定义类型一致。
第三是前导模糊匹配:
sql复制SELECT * FROM t_user WHERE name LIKE '%张%';
这种写法在搜索引擎场景可以上全文索引,但在关系库里通常意味着全表扫描。业务能接受的话,改成 LIKE '张%' 就能走索引。
第四是OR连接。比如 WHERE user_id = 1 OR status = 2,如果只有user_id有索引,OR后面的status=2就无法用索引,整体回退成全表。改成两个查询用 UNION ALL 是比较常见的方案,或者给status也建索引让它能用index merge。
第五是 NOT IN 和 <>。这种反查场景,有时走索引效率也低,MySQL经常直接放弃索引。能不能优化不绝对,需要通过执行计划实测。更稳的办法是改写为LEFT JOIN加IS NULL的判断,我表结构复杂时一般直接用改写后的写法。
第六是联合索引的最左前缀原则被破坏。联合索引 (user_id, status, create_time) 中跳过了中间的status直接查create_time,索引可能只用到user_id部分,需要分析后调整索引顺序。
第七是条件里塞了NULL判断。WHERE status IS NULL 在某些版本和结构下也可能不走索引,需要实测。
4.2 深分页场景:为什么翻到后面越来越慢
分页查询可能是业务系统里最普遍的慢SQL源头。前几页数据量小,没什么问题,一旦有用户翻到第50页、第100页,查询耗时就直线上升。
核心原因是MySQL的标准分页写法:
sql复制SELECT * FROM t_order WHERE status = 1 ORDER BY id DESC LIMIT 100000, 20;
它需要先扫描出前100020行然后丢弃前100000行,只返回最后的20行。扫描行数太多了,谁来都慢。
我一般用两种方式来优化:
一种是延迟关联,也叫延迟关联子查询。先只查询主键,因为主键是紧凑的索引,扫描成本低,再回到原表取需要的完整列:
sql复制SELECT t.id, t.order_no, t.amount
FROM (
SELECT id
FROM t_order
WHERE status = 1
ORDER BY id DESC
LIMIT 100000, 20
) tmp
JOIN t_order t ON tmp.id = t.id;
另一种更推荐给前端交互翻页的方案是“书签法”。上一页返回结果里肯定带了一个最大或最小的id,下一页直接基于这个id继续过滤,不依赖offset:
sql复制WHERE status = 1 AND id < 上一页最小id
ORDER BY id DESC
LIMIT 20;
这个方案翻页越深优势越明显,因为每次都是基于索引定位到某条记录再往后取20条,而不是傻傻地从表头开始数。需要注意:如果业务对实时性要求特别高,且存在并发插入数据的情况,基于id的翻页可能出现漏掉或重复数据。对大多数报表、商品列表场景是可以接受的,但强一致性场景要另外做方案。
4.3 JOIN和子查询:到底谁驱动谁
多表联查慢的时候,先看执行计划里两张表的访问顺序。MySQL一般会把第一行当成驱动表,第二行当成被驱动表。基本经验是:小表驱动大表,被驱动表的连接字段必须有索引。
比如用小结果集查出2000个用户,再去大订单表里匹配订单,如果大订单表的owner_id上有索引,那么每次关联通过索引查找2000次并不慢;但如果反过来,用小结果集驱动大表且连接字段没有索引,就会变成拿2000万个订单去全表大海捞针,慢得无法容忍。
别总觉得子查询就一定慢。MySQL 5.7之后对子查询做了很多优化,有时候子查询先执行、得到一个很小的结果集,再和外表关联,效率很高。关键是看执行计划里的 select_type、table 顺序和实际耗时,而不是一刀切地说“子查询不能要”。
另外一条很隐晦的优化原则:能不联表就不联表,能少联一张就少联一张。很多慢SQL不是因为单条查询复杂,而是业务把大量字段堆在了同一张宽表查询里。如果系统并发不高,这条不算明显;一旦并发上来了,每张联表都会增加锁等待和临时表开销。
4.4 count慢、排序慢,别直接用缓存糊弄
统计类查询出现慢查询也很常见。一张几百万行的表执行 SELECT COUNT(*) FROM t_order,哪怕你对所有字段都没有过滤条件,MySQL也要扫描整个索引来数行数,这是非常大的IO和CPU消耗。当业务量增长后,这种查询会变得不可接受。
针对高频统计,我建议先区分能不能接受近似值。比如做报表的趋势展示,用 SHOW TABLE STATUS 或者 information_schema.tables.table_rows 拿到估算值,是便宜又足够的方向。如果业务必须精确计数,而且统计写入十分频繁,可以考虑独立计数器表,每次新增订单在计数器表里原子加1,计数查询变成查一行记录,耗时直接降下去。
还有个很容易被忽略的点是排序导致的慢。ORDER BY create_time LIMIT 10 这种写法,即使结果集只要10条,但前面如果没有合适索引,它也先要把上万条create_time取出来全部排好序,才能取出前10条。对排序字段建立联合索引,比如 (status, create_time),让MySQL直接按索引顺序扫描,可以彻底规避filesort。
4.5 查询列裁剪与缓存选型
优化SQL时,还要顺手做一次列裁剪。很多老代码习惯 SELECT *,把不需要的大字段、TEXT字段全查出来了,传给应用后又不使用。这会增加网络传输、SQL层的解析和内存占用。把所有用到的一列列写清楚,既是好习惯,也能避免可能出现的覆盖索引失效问题。
谈到缓存,比如“分页查询慢用Redis优化”,我的态度是:能用索引解决的分页,不要先上缓存;只有在热点数据明显、命中率极高、DB压力大时,才考虑用Redis缓存列表页数据。直接缓存整个列表会出现一个经典问题:数据更新后,缓存的第1页、第2页必须同步失效,否则翻页时前后数据对不上。所以我通常只在最热的首页做Redis列表缓存,深分页仍然走数据库优化后的SQL。
5. 真实案例:从一次“点开报表就卡”聊起
前面讲了大量原则,可能有点干,换个更细的真实排查过程来串一遍。一个内部运营后台的订单报表,运营同事反馈每次点击“待发货订单”列表要转圈好几秒,点开一个订单再返回列表又是好几秒。数据库CPU在并发点击时会飙高,DBA给了一张慢日志Top语句,问题SQL长这样:
sql复制SELECT * FROM t_order
WHERE status = 2
ORDER BY create_time DESC
LIMIT 80000, 20;
第一次看到这条SQL,表只有不到200万行,按说索引建好的话不会慢成这样。我用EXPLAIN一看,type是ALL,rows估算65万,Extra里还有Using filesort。也就是说,这个查询要从200万行表里全表过滤status=2的记录,然后排序,再丢到8万条往后取20条,三条重元素全撞上了。
后续排查链路是:先确认表上没有 (status, create_time) 联合索引,因此过滤条件和排序都无法利用索引;再确认分页offset到了8万,属于深度翻页;最后通过 EXPLAIN ANALYZE 确认实际扫描行数达到67万行,耗时1.2秒。
优化方案分三步走:
- 在
(status, create_time)上建立联合索引,让过滤和排序都走索引。 - 把主查询改成延迟关联,只先在索引里取主键id,减少回表列数。
- 和前端约定“上一页最后一个create_time传回来”,用书签法翻页,不再用大offset。
最终改写后的核心SQL:
sql复制SELECT t.id, t.order_no, t.receiver_name, t.total_amount
FROM (
SELECT id
FROM t_order
WHERE status = 2
ORDER BY create_time DESC, id DESC
LIMIT 80000, 20
) tmp
JOIN t_order t ON tmp.id = t.id;
如果采用书签法,SQL简化为:
sql复制SELECT t.id, t.order_no, t.receiver_name, t.total_amount
FROM t_order t
WHERE status = 2 AND create_time < '2025-02-20 10:23:45'
ORDER BY create_time DESC, id DESC
LIMIT 20;
优化后,原先1.2秒的查询降到30ms以内,运营再点击时基本秒开。这次事故给团队的启发不是“加个索引真棒”,而是把线上慢查询巡检这件事固化下来,不让这类SQL隐藏到大促才爆发。
6. 慢查询治理的常态化机制与避坑实录
把一次慢查询处理好,只能保住当时的业务;把慢查询体系建起来,才能降低后续出问题的概率。我翻了几年的一线记录,最后总结几个可复用的方法论和避坑清单,希望你能直接参考。
6.1 SQL上线前就做一次执行计划检查
最有效的治理是把问题拦在上线前。团队可以约定:涉及新需求或改动的SQL,必须在提测前贴出EXPLAIN结果和执行计划截图。检查的硬指标只有几条:
- 不能出现type=ALL的大表全表扫描(少于1万行的配置维度表可以例外)
- 不能出现Using temporary
- 尽量避免Using filesort,排序量大的必须建联合索引
- join连接字段要有索引
- 分页查询的offset不能过大,超过一定阈值必须改成书签法
6.2 慢查询排查技巧速查表
日常处理慢SQL时,我常按一张速查表来定位问题,可以先收藏,遇到情况照着走一遍:
| 现象 | 优先排查方向 | 常见解决方案 |
|---|---|---|
| 单条SQL执行时间突然变长 | 表数据量增长、索引失效、统计信息过期 | 重新分析表、调整索引、更新统计信息 |
| 平时正常、高峰变慢 | 并发写入、锁等待、IO瓶颈 | 分析锁等待事件、优化长事务、限流或把查询改为异步 |
| 分页翻页越来越慢 | limit offset过大 | 延迟关联或书签法 |
| 联表查询极慢 | join字段无索引、驱动表选择错误 | 给小表加连接索引、强制指定straight_join验证 |
| count(*)很慢 | 全索引扫描计数 | 计数器表、近似值、定期统计宽表 |
| order by加limit慢 | filesort | 对排序和过滤字段建联合索引 |
| 同一个SQL时快时慢 | 缓存命中率、内存状态、是否走了不同计划 | 看buffer pool命中率、执行计划是否有变化 |
6.3 最后的几条实战心得
从第一次遇到慢SQL时只会重启数据库,到现在能比较有条理地治理优化,我自己也翻了不少次车。总印象最深的一条:慢查询日志一定要从项目第一天就开启。我当时接手过一个老项目,数据库没开慢日志,也没有任何历史性能基线,后来业务暴涨后出了问题,只能靠猜,代价极高。
还有一条:优化完以后一定要对比优化前后的时间,并且观察一段时间。有些优化在测试环境有效,生产环境因为数据分布不同,可能根本不走预期索引。上线后前两天,每天扫一眼是否有新的慢查询出现,防止副作用。
如果说还有什么要特别提醒的,就是别为了优化而优化。索引不是越多越好,每个索引都会拖慢写入、增加存储;SQL写得过度花哨也不一定高性能。好的优化是在理解数据分布和业务路径后,选择一个结构最简、维护成本最低的方案。这套慢查询体系一旦建立好,后期系统再变慢,你至少不用再从零开始慌了。
