半夜两点钟手机震个不停,某电商订单系统的监控群连续弹了三页告警,核心查询接口的响应时间从平时的200毫秒直接飙到了4秒开外。第一次排查慢查询的人可能手忙脚乱,但经验多了你会发现,大部分SQL性能问题翻来覆去就那么几类:索引没走对、数据量涨了、写法触发了隐式转换、或者干脆就是分页逻辑写得太粗暴。
SQL优化这件事,本质上就是搞清楚数据库在“等”什么:等磁盘读数据、等排序、等嵌套循环里反复扫描,然后把等待的时间压缩掉。这篇文章从我实际踩过的坑出发,把慢查询定位、执行计划解读、索引设计、SQL改写和优化效果验证这条链路完整捋一遍,希望你看完能直接套用到自己的项目里。
1. 慢查询到底慢在哪:先分清“数据多”和“路径差”
1.1 一个容易误导人的表象问题
很多刚接触性能优化的同学,一看到慢查询就本能地想“是不是数据量太大了”。这个判断方向不能说错,但往往会把排查带偏。
举个例子,某订单表当时已经有将近800万行数据,单表确实不小。但真正翻车的那条SQL长这样:
SELECT order_id, user_id, status, total_amount FROM t_order WHERE status = 1 AND create_time BETWEEN '2024-01-01 00:00:00' AND '2024-06-30 23:59:59' ORDER BY create_time DESC LIMIT 20;乍一看,数据量接近千万级,查半年数据返回20条,慢是不是很合理?合理个鬼。问题根本不在数据量,而在status和create_time这两个条件字段上根本没有可用的复合索引,MySQL只能从头到尾扫一遍那800万行,把符合条件的行统统捞回server层,再按照create_time做一次文件排序。这就是典型的“数据访问路径差”——数据就摆在那儿,但没有索引这条“目录”,你就得一页一页翻。
慢查询的根因,90%以上不是数据量,而是访问路径不高效。磁盘顺序读800万行和走索引读几十行,是两个数量级的差距。所以第一步永远别急着给表扩容,先看清楚这条SQL是怎么访问数据的。
1.2 执行计划里的两个核心信号:type和rows
定位慢查询最好的抓手就是EXPLAIN。很多人用EXPLAIN只是扫一眼“没走索引啊”,但对最关键的字段理解得不够深。
正常情况下,执行计划重点看这几个字段:
| 字段 | 含义 | 健康的信号 |
|---|---|---|
| type | 访问类型 | const/ref/range是好的,ALL要警惕 |
| key | 实际使用的索引 | 不为NULL,且索引列覆盖查询条件 |
| rows | 预估扫描行数 | 越小越好,偏离实际可能要重新分析 |
| filtered | 过滤比例 | 100%最好,越低说明扫描后再过滤的多 |
| Extra | 额外信息 | Using filesort / Using temporary 都是警告信号 |
拿上面那条订单SQL来说,当时EXPLAIN的结果就是 type=ALL、rows=8_000_000左右、Extra里有Using filesort。这三样凑齐基本就实锤了:全表扫描加外部排序。
这里我多说一句rows字段的事。EXPLAIN里的rows是优化器根据索引基数(cardinality)估算出来的,不是真实扫描行数。如果统计信息长期没更新,rows会严重失真。这时候可以用ANALYZE TABLE重建统计信息,再看执行计划就准多了。
执行计划就是数据库的“路线图”,我们优化SQL说白了就是在改这张路线图,让它从“挨家挨户敲门”变成“直接翻到目录页”。
2. 定位慢查询的完整链路:告警、日志、执行计划三件套
2.1 慢查询日志怎么开才不误伤
慢查询日志是排查线上问题的第一手材料。但很多项目的慢日志阈值设得太随意,默认的10秒等于没开,设成0秒又会在高并发下刷爆磁盘IO。
建议的配置逻辑是:先观察一段时间业务高峰期的平均响应时间,把阈值设定在P95响应时间的两倍左右。比如某接口平常P95是300毫秒,慢查询阈值就设成1秒,既能捞出真正有问题的SQL,又不会天天被低频但合法的重型查询刷屏。
这套配置可以写在MySQL的配置文件中:
slow_query_log = ON slow_query_log_file = /var/log/mysql/slow-query.log long_query_time = 1 log_queries_not_using_indexes = ONlog_queries_not_using_indexes这个参数我建议打开,它能记录下所有没走索引的查询,哪怕执行时间很短。很多隐性慢查询——比如某个联表没走索引但数据量还没上来所以没暴露——就是靠这个提前抓出来的。等数据量涨了再想找,告警已经炸了。
顺便说一句,线上环境的慢日志文件记得做按天切割,用logrotate之类的工具,不然一个文件撑几个月,排查时光打开文件都要卡死。
2.2 EXPLAIN与EXPLAIN ANALYZE的搭配打法
EXPLAIN给出的是执行计划预估,EXPLAIN ANALYZE则是MySQL 8.0后真正把SQL跑一遍,输出每个步骤的实际耗时和行数。两条命令配合着用,一个看路径,一个看真相。
针对前面那条订单查询,我当时的排查过程是先在测试库跑EXPLAIN ANALYZE:
EXPLAIN ANALYZE SELECT order_id, user_id, status, total_amount FROM t_order WHERE status = 1 AND create_time BETWEEN '2024-01-01 00:00:00' AND '2024-06-30 23:59:59' ORDER BY create_time DESC LIMIT 20;输出的倒数几行有个关键信息:actual time=xx..xx rows=... loops=...。注意看两个东西,一个是排序步骤的actual time,一个是扫描步骤里rows是不是接近全表。如果扫描阶段实际扫了接近800万行,而返回的只有20行,说明这个查询绝大多数成本都花在了“读进来再筛掉”上。
EXPLAIN ANALYZE的输出比EXPLAIN细得多,但它真的会把SQL跑完。生产环境慎重,statement超过几秒的大型查询别直接拿线上库试。稳妥的做法是导出结构到测试环境灌入抽样数据,再慢慢分析。
2.3 performance_schema帮你捞历史慢SQL
线上慢查询日志都开着,也未必每一条都能当时处理。这时候需要定期从历史数据里捞重犯。MySQL的performance_schema库里有张表专门记录语句执行汇总,日常排查很实用:
SELECT SCHEMA_NAME, DIGEST_TEXT, COUNT_STAR, AVG_TIMER_WAIT / 1000000000 AS avg_ms, MAX_TIMER_WAIT / 1000000000 AS max_ms, SUM_ROWS_EXAMINED FROM performance_schema.events_statements_summary_by_digest WHERE SCHEMA_NAME IS NOT NULL ORDER BY AVG_TIMER_WAIT DESC LIMIT 20;按平均耗时倒序排列,一眼就能看出哪几条SQL是“惯犯”。有些时候慢日志里抓的是偶发,但perf schema的汇总表能暴露频率高、单次不算太慢但加起来损耗巨大的“沉默杀手”。
这套链路走完,基本能回答三个问题:哪些SQL慢、它们长什么样、它们是怎么执行数据的。知道这些之后,才进入真正的动手优化环节。
3. 三类典型慢查询的实战拆解:索引失效、回表与深分页
3.1 索引失效的三种常规现场
实战里索引失效的坑翻来覆去就那么几个,我一个个拆开说。
隐式类型转换。最常见的坑。某用户表user_id字段是varchar(32),但业务代码里写的是WHERE user_id = 123456,参数类型是int。MySQL会把字段类型转换成数字去比,等于在索引列上做了CAST(user_id AS SIGNED),索引就废了,type直接变成ALL。排查手法很简单,拿EXPLAIN看type,如果索引存在但没走,第一反应就是查看SQL里传入参数和字段类型是否一致。
函数运算包裹索引列。有人图省事写WHERE DATE(create_time) = '2024-06-01',这等于让数据库对每一行的create_time都调用DATE()函数,索引完全失去意义。改成create_time BETWEEN '2024-06-01 00:00:00' AND '2024-06-01 23:59:59'就能走range访问。
最左前缀原则被无视。联合索引(status, create_time),如果查询条件只带create_time不带status,MySQL没法用这个索引的完整前缀,只能退化成全表或者部分扫描。在实际业务里,有些查询条件本来就是可选的,这就需要在设计索引时评估“最常出现的组合”,没法兼顾全部场景。
3.2 回表重灾区:覆盖索引是天然解药
走索引并不代表万事大吉。二级索引查到主键后,还要根据主键回到聚簇索引里把整行数据读出来,这个过程叫回表。如果查询要返回的列不在索引里,每条满足条件的记录都要回表一次,扫描行数越大代价越高。
某运营后台有一个按月汇总订单金额的报表SQL,当时跑了3秒多。拆分下来看,它查询的列是status、create_time、total_amount,但二级索引只覆盖了price相关的一个短索引,查询根据条件筛出近一万行,每一行都要回表读取。
解决思路是让索引覆盖查询需要的所有列。也就是把报表常用字段都塞进索引:
ALTER TABLE t_order ADD INDEX idx_status_create_time_amount (status, create_time, total_amount);加完这个覆盖索引,查询不需要回到聚簇索引里找数据了,直接从索引的叶子节点上拿到全部所需字段,实测从3秒降到120毫秒。
但覆盖索引不是无脑加。索引本身就是额外的存储空间,写操作还得同步维护。一张表索引过多,写入性能、binlog体积、备份恢复时间都会跟着上涨。一般建议覆盖索引只针对核心高频查询来建,别为了一个一个月跑两次的报表堆三四个长索引。
3.3 深度分页:LIMIT 100000, 20为什么越来越慢
后台列表页的分页逻辑,十有八九是直接LIMIT offset, size。数据量在百万以下时感觉不明显,offset一旦超过几十万,SQL性能就断崖式下跌。原因很直接:MySQL要把前offset行全部读出来丢掉,才返回后续的size行。offset=100000,意味着哪怕你最后只显示20条,数据库也得老老实实读100020条记录。
某内部订单管理后台翻页翻到深水区,单页查询耗时高达2.8秒。翻页越深越慢,到最后用户点下一页要等三四秒。
改造方案用延迟关联,先通过子查询定位到该页起始的主键ID,再回到原表关联取完整数据:
SELECT o.order_id, o.user_id, o.status, o.total_amount FROM t_order o INNER JOIN ( SELECT order_id FROM t_order WHERE status = 1 AND create_time >= '2024-01-01' ORDER BY create_time DESC LIMIT 100000, 20 ) t ON o.order_id = t.order_id ORDER BY o.create_time DESC;子查询里虽然也要扫过100000行,但MySQL在覆盖索引上只取主键,回表只发生在最终的20行结果上,成本从2.8秒降到40毫秒左右。
更彻底的做法是“游标分页”——也就是基于上次查询的最后一条记录的排序字段往下翻。比如记住上一页最后一条记录的create_time和order_id,下一页直接WHERE create_time < ? OR (create_time = ? AND order_id < ?)。这种方案不依赖页码,翻到任何深度性能都稳定。但产品交互上需要有“下一页”而非“跳页”的配合。如果产品经理坚持要跳页,延迟关联基本是最优解了。
4. 优化方案的落地:索引设计、SQL改写与表结构调整
4.1 索引设计不是“看到慢就加”
慢查询定位完,最容易犯的错误就是“看到条件字段就加个索引”。我见过某张表被运维连续加了9个单列索引,查询没快多少,写入反而慢了30%。
索引设计的核心原则是先看“查询模式”,再看“选择性”。
举个例子,status字段通常只有0、1、2几种取值,选择性极低,单独建索引意义不大。但(status, create_time)这种组合就很有战斗力:status把范围缩小到未支付/已支付/已退款,create_time再在子集内排好序,还能顺带解决ORDER BY create_time的排序问题,Extra里的Using filesort直接消失。
建立索引前建议先试一下字段区分度,用这条SQL估算:
SELECT COUNT(DISTINCT status) / COUNT(*) AS selectivity FROM t_order;selectivity越接近1,代表区分度越高。低于0.1的字段就别单独立索引了,它是“配角字段”,要跟高区分度字段搭配成联合索引才有价值。
索引还有一个潜规则:字段顺序。把等值查询的字段放前面,范围查询的字段放后面。WHERE status = 1 AND create_time BETWEEN ...这种模式,索引结构应当是(status, create_time),而不是(create_time, status)。顺序反了,一些查询就用不上完整索引。
4.2 SQL改写里那些“不起眼”的加速点
有些优化不需要加索引,改写法就能见效,这在紧急止血时尤其好用。
能过滤就先过滤。之前看到一条联表查询:
SELECT ... FROM t_order o LEFT JOIN t_user u ON o.user_id = u.user_id WHERE o.status = 1 AND u.user_level = 3;LEFT JOIN之后再对右表字段加WHERE条件,等于把LEFT JOIN退化成了INNER JOIN,优化器还要多费劲做条件推导。直接在JOIN条件里带上过滤条件更清晰:
SELECT ... FROM t_order o LEFT JOIN t_user u ON o.user_id = u.user_id AND u.user_level = 3 WHERE o.status = 1;避免在JOIN时先做笛卡尔积再过滤。很多慢查询的Extra里会出现Using temporary,多表和子查询的写法会导致中间结果集膨胀。比如IN子查询改成JOIN方式,通常都能帮助优化器走更好的执行路径。
OR条件改成UNION ALL。WHERE status = 1 OR status = 2碰上可用索引时,优化器还能帮你Merge Index;但如果OR两边各关联不同的索引,它可能干脆放弃所有索引做全表扫。这种时候把OR拆成两个查询用UNION ALL合并,效果往往立竿见影:
SELECT order_id FROM t_order WHERE status = 1 AND create_time >= '2024-01-01' UNION ALL SELECT order_id FROM t_order WHERE status = 2 AND create_time >= '2024-01-01';注意,UNION ALL和UNION还是有区别的。UNION会做一次去重,有额外开销。如果业务上能保证两段查询结果集没有重叠,一定用UNION ALL。
4.3 表结构层面:分区、归档与汇总表
当单表数据量真的大到索引都吃力时,就该动表结构了。
分区表适合有明确时间范围查询的业务。订单表按月分区之后,查询年1月到6月的数据,优化器能直接裁剪掉其它分区,只扫半年的物理文件。分区字段一定要选查询条件里高频出现的过滤维度,且不能用在分区键上做运算。
冷热数据归档是更粗暴但很实用的方案。某日志平台把90天前的数据迁移到归档表,线上主查询只面对最近三个月的热数据,整体性能立刻改善。归档可以做成定时任务,按天把超时数据INSERT到归档表并DELETE原表记录,跑完再OPTIMIZE TABLE回收空间。
**汇总表(预聚合)**适合报表类需求。运营要看“每日订单总额”,直接跑聚合函数扫全表,怎么建索引都白搭。建一张t_order_daily_summary,每天定时把汇总结果算好存进去,报表查询只扫一天一行,从“计算密集型”变成“读取密集型”,性能提升是几个数量级。
这三种手段都有各自的运维成本,不是所有系统都需要。数据量还在几百万以内时,老老实实优化索引和SQL就够了,别过度设计。
5. 效果验证与回归:别让“优化完”变成“又慢了”
5.1 优化前后的基准怎么打才靠谱
很多人优化完SQL,跑一遍觉得“确实快了”就完事了。这种验证方式不能说完全没用,但很容易产生偏差——数据库Cache命中的偶然性、系统当前负载不同,都会影响单次测试结果。
我习惯的做法是优化前后各跑5到10次,去掉最大值最小值,取中间值对比。执行计划变化也要记录下来,before和after做一个EXPLAIN对照:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| type | ALL | ref |
| key | NULL | idx_status_create_time_amount |
| rows | 8,000,000 | 12,000 |
| 实际耗时 | 4.2s | 85ms |
这个对比表看着简单,其实很能说明问题。rows从800万降到1.2万,实际耗时从4.2秒降到85毫秒,两者是匹配的。如果出现rows大幅下降但耗时变化不大的情况,就要注意是不是瓶颈不在于查询本身,而在于网络、应用层序列化或者锁等待。
5.2 线上灰度观察的注意事项
测试库跑得飞起,不代表线上就没问题。上线前建议分批发布,先在低峰期用一小部分流量验证。重点观察两个额外指标:慢查询数量有没有下降、数据库CPU/IO有没有异常波动。
加索引或改SQL的副作用有时不会立刻暴露。比如新增一个覆盖索引,表面上查询变快了,但高频写入场景下二级索引的维护成本会推高整体负载。高峰期的锁等待时间、redo log写入频率,都要纳入观察范围。
如果出现“查询快了,写入慢了”的矛盾,就该衡量业务里读写比例。读多写少的报表系统,多建索引是划算的;写入密集型业务,索引数量就得克制。
5.3 容易被忽略的隐藏代价:磁盘与备份
这里提一个很多人不留意的地方——索引占用的额外磁盘空间,以及它对备份恢复时间的影响。
某个系统原本数据文件200GB,加了几个覆盖索引后变成260GB。备份脚本是按天全量dump的,原先是2小时跑完,后面延长到将近3小时。如果你们有RTO要求,这一点就得提前评估好。
另外,ALTER TABLE加索引在锁表策略不当时会阻塞线上读写。小表无所谓,大表用pt-online-schema-change这类在线DDL工具更稳,至少能在不锁写的情况下完成索引添加。这个点虽然偏运维,但真心建议写代码的人都了解一下,省得DBA半夜打电话找你。
6. 实战之后的一些个人经验与建议
SQL优化这行干久了,最大的体会是:慢查询排查不难,难的是每次都能保持清晰的排查思路,而不是一头扎进细节里。
我个人的固定流程已经形成肌肉记忆了:先开慢日志看Top SQL,再用EXPLAIN/EXPLAIN ANALYZE确认访问路径和实际耗时,然后判断瓶颈属于索引失效、回表、排序还是分页逻辑的问题,接着按“覆盖索引优先、SQL改写次之、表结构调整兜底”的顺序动手,最后一定做前后基准对比和线上灰度观察。
这中间还有一个小技巧值得分享:平时给SQL写注释时,把“这条SQL的核心过滤条件和预期返回行数”也写进去。半年后你自己或者同事回来看这段代码,一眼就能知道它业务上要什么、数据边界在哪,排查问题会省很多力气。
还有就是要定期做“慢查询复盘”,不是等告警炸了才看。每月花半小时把慢日志里的Top20过一遍,能发现大量“数据量涨了所以才慢”的隐患。等到用户投诉页面打不开再看日志,性质就变了。
优化永无止境,但大多数系统只要能保持“索引匹配查询模式、访问路径最短、排序避免文件排序、深分页被重写”这几个底线,响应时间基本不会太离谱。希望这篇文章能帮你在下次深夜告警时,少一点手忙脚乱,多一点胸有成竹。