SQL优化这事,说小也不小。很多团队一遇到慢SQL就急着加索引,结果加了索引还是慢,又去翻配置调参数,折腾一圈发现根本没动到根子上。我做了十几年数据库优化,接手过的慢SQL案例少说也有几百个,核心其实就两条主线:索引策略和查询重写。索引决定数据怎么被快速找到,查询重写决定SQL本身是不是在用最优的方式表达需求。两个方向只要有一个有问题,性能就上不去。
这篇东西我不打算讲教科书上的大道理,而是从我实际排查和优化慢SQL的经验出发,先把定位问题的方法讲明白,再拆解索引设计里最容易被忽视的细节,接着聊聊查询重写常见的套路,最后给几个可以直接照着改的实战案例。不管你是后端开发、DBA还是运维,只要你每天要跟数据库打交道,这篇文章都能让你的调优思路更清晰,少走弯路。
1. 慢SQL定位:先找到真正的元凶
很多人做优化时上来就写SQL,或者凭空猜“这个查询可能要全表扫描”,这是大忌。没有执行计划做依据,任何优化都是盲人摸象。慢SQL优化的第一步,永远是把问题SQL抓出来,看清楚它慢在哪一步。
1.1 慢查询日志怎么开、怎么看
以MySQL为例,慢查询日志是定位慢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秒的SQL都会被记录。log_queries_not_using_indexes会把没走索引的查询也记下来,这个参数很有用,很多问题SQL不是慢在单次执行时间,而是慢在每次都在全表扫,积攒下来的压力非常可怕。生产环境建议临时开一两天,抓到典型SQL后及时关闭,避免日志文件过大。
慢日志文件里会记录时间、用户、主机、查询耗时、扫描行数、返回行数和SQL文本。有一个细节很多人不重视:扫描行数。同样的返回结果,有的SQL要扫几百万行,有的只扫几十行,这就是索引和SQL写法带来的差距。排查时优先关注那些扫描行数远大于返回行数的语句,它们往往就是隐藏的“性能杀手”。
1.2 EXPLAIN主要看哪几个字段
抓到慢SQL之后,下一步就是执行EXPLAIN看执行计划。这一步也是热词里问得最多的:“EXPLAIN主要看哪些信息”。其实不需要记十几个字段,核心就这几个:
- type:访问类型,从好到坏一般是
const、eq_ref、ref、range、index、ALL。看到ALL就是全表扫描,需要高度警惕。看到index也不一定安全,虽然用了索引,但可能是在扫全索引,比如SELECT COUNT(*)走二级索引,数据量大时一样有压力。 - key:实际用到的索引。如果这里为NULL,说明没走索引。
- key_len:使用索引的长度。联合索引里可以通过它判断到底用到了哪几列。
- rows:预估扫描行数。这个值是优化器估算的,不一定准,但数量级很有参考价值。
- Extra:最容易出问题的地方。
Using filesort表示排序没有走索引,Using temporary表示用了临时表,Using index表示覆盖索引扫描,这是好事,Using where是普通条件下推。
我见过很多新手只看type=ALL就以为有索引失效,其实type=ALL也可能是因为表数据量本身很小,全表扫描代价最低,优化器做了正确选择。所以看执行计划不能只看单个字段,要把type、key、rows、Extra综合起来看。
1.3 执行计划里最坑的几个信号
执行计划里有一些信号,看起来好像走了索引,实际上性能并不好。比如type=range配合key用上了索引,但rows估算很高,说明索引范围扫描还是扫了大量数据。再比如Extra里出现Using index condition是索引下推,这个通常是好事,但如果同时出现Using temporary,那就要看看是不是GROUP BY或DISTINCT引起的临时表开销。
我遇到过最坑的一个场景是:key字段明明有索引,type却是index。这往往是因为查询条件里的列没有放在联合索引的最左侧,优化器被迫扫描整个索引树来覆盖查询范围。比如有一个联合索引(a, b, c),查询条件只写了b=1,优化器不会用这个索引做快速定位,只能做全索引扫描。这种问题光看key很容易误判,一定要结合key_len和type一起分析。
经验之谈:拿到一个慢SQL,我习惯先看
type和rows,再对照Extra。如果rows超过表总行数的10%以上,即使走了索引,这个SQL的设计也还有优化空间。
2. 索引策略:用最少的索引解决最多的问题
索引是数据库调优的第一把武器,但也最容易用过头。索引不是越多越好,每多一个索引,写入时就要多维护一颗B+树。真正的目标是用合理的索引让大多数查询都受益,同时不拖累写入。
2.1 为什么索引能快:B+树结构的直观理解
索引能提速的根本原因,是它把“从头到尾逐行翻表”变成了“按目录找章节”。B+树这个结构,所有数据都存在叶子节点上,并且叶子节点之间用链表相连,非常适合范围查询。当执行WHERE id = 5时,查询从根节点出发,一路二分查找,很快就能定位到对应的叶子节点,IO次数通常只需要两三次树高。
这也是为什么不需要索引扫描所有数据页。全表扫描要读整个表的聚簇索引,而二级索引通常比聚簇索引小很多,覆盖索引甚至只需要读索引页,不需要回表。理解这层原理,你就明白“为什么不建议SELECT *”了。如果只需要查一两个字段,而这一两个字段正好能组成覆盖索引,那查询连表数据都碰不到,速度自然飞快。
2.2 索引失效的常见场景
索引失效是最容易踩的坑,也是面试里经常问的话题。我整理几个在实际生产里最常见的场景:
- 对索引列使用了函数或运算。比如
WHERE DATE(create_time) = '2025-01-01',这个条件上了索引也没用,因为优化器要先算函数才能比较。正确的写法是create_time >= '2025-01-01' AND create_time < '2025-01-02'。 - 隐式类型转换。字段是字符串类型,查询参数却传了数字,MySQL会尝试把字符串转成数字,导致索引列上触发类型转换,索引失效。典型例子是
WHERE phone = 13800000000,如果phone字段是VARCHAR,这就会全表扫描。 - LIKE左模糊。
LIKE '%keyword'因为不知道前缀是什么,无法利用B+树有序性。但LIKE 'keyword%'是可以走索引的。 - OR条件中存在非索引列。
WHERE id = 1 OR name = 'x',如果name没有索引,优化器可能选择全表扫,因为要合并两个结果集。 - 对索引列进行NULL判断。
IS NULL在某些情况下未必走索引,虽然MySQL 8.0处理得比以前好,但还是要看执行计划。
这些场景不是绝对的,但大概率会让优化器放弃索引。排查时直接看执行计划,比背规则可靠得多。
2.3 联合索引设计:最左前缀、覆盖与排序
联合索引是索引设计里的重头戏。一个(a, b, c)联合索引,实际能支持a、a,b、a,b,c三种查询条件的组合,也就是最左前缀原则。设计时要把最常用、区分度最高的列放在最左侧。
覆盖索引是另一个提升性能的利器。如果查询需要的所有列都在索引里,就不需要回表。比如有一个索引(status, order_time, amount),而查询是:
SELECT order_time, amount FROM orders WHERE status = 1;这个查询就能通过覆盖索引直接完成,Extra会显示Using index。实际业务里,高频查询可以专门设计覆盖索引来压掉回表IO。
很多人忽略了联合索引对排序的优化。ORDER BY字段如果能匹配索引顺序,就能避免Using filesort。比如索引(user_id, create_time),查询WHERE user_id = 10 ORDER BY create_time DESC,优化器直接按索引顺序读,不需要额外排序。但如果条件变成了WHERE user_id IN (10, 20) ORDER BY create_time,排序就可能无法用到索引,因为跨了多个区间。
一个重要提醒:联合索引不是把几个查询条件的索引合并起来。经常看到有索引
(a)和索引(b),然后查询WHERE a=1 AND b=2,优化器可能会用index merge合并索引,但这种做法效率往往不如一个(a,b)联合索引。联合索引的列顺序是有讲究的,设计前先列出线上所有高频查询条件,再做合并分析。
3. 查询重写:不建索引也能提速的手段
有时候索引已经建得很合理,SQL却还是慢,问题就出在写法上。查询重写不是让你改业务逻辑,而是用等价的方式表达同一个查询需求,让优化器有更多选择空间。这一节列几个我每天都在用的重写套路。
3.1 子查询与JOIN的取舍
很多开发喜欢写IN (SELECT ...)的写法,但子查询不一定会被优化成半连接,尤其是MySQL 5.6之前的版本,会导致子查询被反复执行。从5.6开始优化器有了半连接优化,但在某些场景下依然不如显式JOIN可控。
举个例子,查找有订单的用户:
SELECT * FROM users WHERE user_id IN (SELECT user_id FROM orders WHERE status = 1);如果orders表上status过滤后数据量很大,优化器可能先把所有满足条件的user_id物化出来,再和users表关联。改成JOIN写法:
SELECT DISTINCT u.* FROM users u JOIN orders o ON o.user_id = u.user_id WHERE o.status = 1;这种写法更直观,也更容易让优化器选择小表驱动大表。不过要注意,JOIN去重需要DISTINCT,如果不需要去重那就要思考业务到底要什么。EXISTS在某些情况下比IN更合适,比如只需要判断存在性的子查询。判断标准其实很简单:看执行计划,别靠猜。
3.2 OR改UNION ALL与深分页优化
OR条件如果涉及的字段都有索引,优化器可能会用index merge,但更多时候是放弃索引。比如:
SELECT * FROM orders WHERE status = 1 OR pay_type = 2;假如status和pay_type分别有索引,优化器可以index_merge_union。但如果两者区分度差异很大,或者其中一个字段没索引,就会变成全表扫描。这种情况下我通常会重写成两部分:
SELECT * FROM orders WHERE status = 1 UNION ALL SELECT * FROM orders WHERE pay_type = 2 AND status <> 1;UNION ALL比UNION快,因为少了去重步骤。当然,如果业务上两个结果集不可能重复,直接UNION ALL没毛病。如果可能存在重复并且业务允许重复,也不要用UNION。
深分页是另一个经典痛点。LIMIT 100000, 20这种翻页,MySQL会把前100020行都查出来再丢掉前100000行,代价极高。常见解法是延迟关联:
SELECT t.id, t.name FROM orders t JOIN (SELECT id FROM orders ORDER BY id LIMIT 100000, 20) tmp ON t.id = tmp.id;内层子查询只取主键id,走覆盖索引扫描,代价小很多;外层再用主键回表取完整行。数据量越大,这种写法的优势越明显。对于真正海量的翻页场景,更推荐用游标或基于上一页最后一条记录的主键位置来分页。
3.3 条件改写、函数与隐式转换
查询重写里最不起眼但也最容易见效的是条件改写。我见过太多SQL把简单条件写复杂了。比如要查某个时间段的数据,有人写成:
WHERE create_time BETWEEN NOW() - INTERVAL 7 DAY AND NOW()这种写法没问题。但如果是WHERE create_time BETWEEN DATE_SUB(NOW(), INTERVAL 7 DAY) AND NOW(),也完全等价。关键是别把函数套在索引列上,比如WHERE NOW() - create_time < 2592000,这一定全表扫,因为每次比较都要算函数。
还有一个高频问题:隐式类型转换。WHERE order_no = 12345678901234567890,如果order_no是VARCHAR,这里会发生转换导致索引失效。更隐蔽的是字符集不统一,比如WHERE user_name = utf8mb4的列,如果另一张表传过来的参数是utf8,也会因为字符集转换导致索引失效。这类问题在多元字符集环境中非常常见,建议统一表的字符集,或者查询前显式CAST。
4. 实战案例:三条慢SQL的完整优化过程
纸上谈兵没有意思,我挑三个真实环境里改过的慢SQL,把完整优化链路写出来。这三个案例分别覆盖深分页、隐式转换、PL/SQL逐行处理,都是平时最常见的坑。
4.1 场景一:深分页导致的全表扫描
线上的订单列表页,每次翻到后面几页就特别慢,SQL长这样:
SELECT order_id, order_no, user_id, amount, create_time FROM orders WHERE status = 1 ORDER BY create_time DESC LIMIT 200000, 20;看执行计划,type=ALL,rows=1800000左右,Extra里有Using filesort。即使有status上的索引,深分页加排序让优化器选择了全表扫。优化思路是先把分页范围缩成主键范围,我改成了:
SELECT o.order_id, o.order_no, o.user_id, o.amount, o.create_time FROM orders o JOIN ( SELECT order_id FROM orders WHERE status = 1 ORDER BY create_time DESC, order_id DESC LIMIT 200000, 20 ) t ON t.order_id = o.order_id;内层查询强制走(status, create_time)联合索引,只取主键,排序和扫描代价大幅降低。改完后执行计划type=range,rows降到了几十万,查询从2.3秒降到了0.06秒。这里的关键是给orders表建了(status, create_time, order_id)联合索引,让排序和覆盖都能用上。
4.2 场景二:索引没被用上的隐式转换
有个会员系统的SQL,执行计划显示type=ALL,但WHERE条件对应的字段明明有索引。SQL是这样的:
SELECT * FROM member WHERE mobile = 13800138000;问题就出在mobile字段定义是VARCHAR(20),查询条件里是整数。MySQL会把mobile隐式转成数字,等于对索引列做了运算,索引自然就废了。排查时我也没看表结构,先看执行计划发现key=NULL,再去SHOW CREATE TABLE确认字段类型。
修复很简单,把参数改成字符串即可:
SELECT * FROM member WHERE mobile = '13800138000';改完后type变成了const,查询从全表扫描变成直接走二级索引。这个案例虽然简单,但生产环境里特别容易犯,尤其是接口层传参时没有规范类型。建议在ORM层统一把手机号、订单号这类字段定义为字符串,避免数据库端隐式转换。
4.3 场景三:PL/SQL中逐行处理引发的性能灾难
有一次帮同事看Oracle里的存储过程,一个表里几十万行数据要更新,PL/SQL写的循环逐行更新,跑了快一小时还没结束。核心逻辑大概是:
FOR rec IN (SELECT id, amount FROM temp_orders WHERE flag = 0) LOOP UPDATE orders SET discount = rec.amount WHERE id = rec.id; END LOOP;逐行UPDATE意味着几十万次上下文切换,每次都要解析、执行、提交,性能不可能好。Oracle里优化方案是用BULK COLLECT加FORALL批量绑定,或者干脆一条UPDATE加子查询搞定:
UPDATE orders o SET discount = ( SELECT t.amount FROM temp_orders t WHERE t.id = o.id AND t.flag = 0 ) WHERE EXISTS (SELECT 1 FROM temp_orders t WHERE t.id = o.id AND t.flag = 0);这里要提醒一句,批量更新前务必确认好关联条件,避免数据错乱。改成FORALL后,同样的数据量几十秒就能跑完,差别是数量级的。这也是为什么PL/SQL优化的基础一定要记住:尽量用集合操作替代逐行操作,少用游标循环。
5. 常用优化方法与多数据库场景速查
SQL优化到了最后,方法其实就那些,关键是要在合适的场景下组合使用。热词里有人问“SQL优化常用的几种方法”“SQL Server 查询如何优化”“并行SQL优化”,这里一起做个归纳。
5.1 SQL优化常用的几种方法
我按优先级排序,常用的方法基本可以概括成六类:
- 定位慢SQL:慢查询日志、性能监控、
SHOW PROCESSLIST抓当前慢查询。 - 分析执行计划:MySQL看
EXPLAIN,Oracle看执行计划,SQL Server看图形化执行计划,核心关注type、key、rows、Extra。 - 优化索引:添加缺失索引、调整联合索引列顺序、清理冗余索引、使用覆盖索引。
- 改写查询:子查询改JOIN、OR改UNION ALL、避免
SELECT *、深分页延迟关联。 - 优化表结构:字段类型是否合理,是否能用更小的整数代替字符串,是否需要分表分区。
- 调整数据库参数:MySQL的
innodb_buffer_pool_size、sort_buffer_size,Oracle的SGA/PGA等,这类调整一定要建立在前面几步做完的基础上。
很多团队把精力放在第6步,结果发现调了半天参数,SQL还是慢。我的建议是先做前面五步,参数调优最后再说。
5.2 SQL Server与Oracle并行优化场景
热词里提到“SQL Server 查询如何优化”和“并行SQL优化”,这里单独说一下。
SQL Server里,最方便的排查工具是图形化执行计划加上SET STATISTICS IO ON和SET STATISTICS TIME ON。这两个命令会在消息里返回逻辑读次数、CPU耗时等核心指标。逻辑读越高,说明访问的数据页越多,索引设计就越有问题。SQL Server还有一个“缺失索引”提示,就是执行计划里那个绿色不知道哪一个索引的提示,但别盲目加。它给的是一个动态管理视图sys.dm_db_missing_index_details,建议用它做参考,再结合业务条件设计联合索引。
并行SQL优化的核心场景是大查询。MySQL 8.0有innodb_parallel_read_threads,但主要影响CHECK TABLE这类操作,对普通查询的并行支持并不强。Oracle的并行查询是另一套逻辑,比如:
SELECT /*+ PARALLEL(o, 4) */ COUNT(*) FROM orders o WHERE create_time >= SYSDATE - 30;但并行不是越多越好,并行度过高会导致资源争抢,反而拖慢系统。Oracle并行更适合数仓大表扫描这类场景,在线交易系统要慎用。SQL Server的并行度由max degree of parallelism控制,默认0是自动,有时SQL Server会选一个过高的并行度,导致CPU飙高,可以考虑限制MAXDOP=4。这些参数要反复测,不能照搬网上的配置。
5.3 常见问题排查表与维护建议
我把实际排查中经常遇到的现象、可能原因和解决方向整理成一张速查表:
| 现象 | 可能原因 | 排查/解决方向 |
|---|---|---|
type=ALL全表扫描 | 无索引、索引失效、优化器选择 | 检查执行计划,看possible_keys,必要时FORCE INDEX |
rows数量远超预期 | 统计信息不准、条件过滤性差 | ANALYZE TABLE更新统计信息,检查列分布 |
Extra出现Using temporary | GROUP BY/ORDER BY列不匹配索引 | 调整联合索引,或重写SQL |
Extra出现Using filesort | 排序字段没有对应索引 | 设计排序字段的联合索引 |
| 查询快但CPU很高 | 大量并发、SQL频繁重复解析 | 启用预处理语句、绑定变量、连接池优化 |
| 加了索引还是慢 | 冗余索引、没走最优索引 | 看key和key_len,清理冗余索引,调整索引列顺序 |
索引维护是我特别想强调的一点。每周至少跑一次冗余索引检查,可以用pt-duplicate-key-checker,把重复率高的索引清理掉。同时关注SHOW INDEX里的Cardinality,如果它的值相比表行数太小,说明这个索引的区分度很低,查询能过滤掉的数据少,价值不大。不过Cardinality是估算值,也不能只看它,还是要结合实际查询条件判断。
经验之谈:索引不是一次建好就完了,业务在变,查询模式也在变。建议每季度做一次慢SQL复盘,把新增的查询模式和现有索引对照一遍,该加的加,该删的删。数据库优化是个持续过程,最怕的就是“建完索引就不管了”。
我个人在实际操作中还有一个习惯:无论哪个数据库,拿到慢SQL后我都不急着写优化方案,而是先盯着一行执行计划看三分钟。key、rows、Extra这三个字段能告诉我优化器是怎么想的,然后再想业务上怎么把它表达得更好。索引和查询重写就像左右手,左手负责让数据访问更高效,右手负责让SQL表达更合理。把这两个基本功打扎实,比什么玄乎的高级技巧都管用。
最后再分享一个实用小技巧。如果某个查询在测试环境很快,一上线就慢,先别怀疑索引,把统计信息更新一下。很多慢SQL其实是表数据分布变了,而优化器还用着旧统计信息,做出的执行计划完全跑偏。ANALYZE TABLE之后,很多“诡异”的慢查询会自动消失。这招我已经用了无数次,成本极低,效果却常常出人意料。