一条SQL从发出去到结果返回,数据库内部到底经历了什么?我刚带团队那会儿,经常有人拿着一条执行了十几秒的查询来找我,语句本身写得并不复杂,数据量也就几百万行。可就是慢,慢得让人摸不着头脑。追根溯源,大多数性能问题都出在一个被人忽略的认知点上:MySQL执行SQL时,并不是按照我们书写顺序来的。它有一套固定的、11步的逻辑执行顺序。谁先谁后,直接影响你写的条件能不能用上索引,影响中间结果集有多大,最终决定这条SQL是毫秒级还是秒级。
这篇文章不是给你背一遍顺序口诀就完事,而是把每一步背后的行为逻辑、常见误区和优化手段全部拆开讲清楚。无论你是刚学SQL的初学者,还是写了好几年业务查询的开发,或者正在准备面试、排查慢查询,把这11步吃透,你的SQL即使不能保证快一倍,也能帮你少踩一大半的坑。
1. 执行顺序全景:数据库到底是怎么“读”你的SQL的
先看一张非常经典的逻辑执行顺序表,然后我们再一层一层拆。这里说的顺序是“逻辑执行顺序”,MySQL优化器在真正执行时可能会做等价改写,比如把子查询转成JOIN、把条件提前,但理解逻辑顺序依然是判断SQL正确性和性能的第一基础。
| 顺序 | 关键字 | 作用 |
|---|---|---|
| 1 | FROM | 确定数据源,找到要操作的表或视图 |
| 2 | ON | 应用JOIN关联条件,过滤左表或右表的行 |
| 3 | JOIN | 根据JOIN类型,补充或剔除关联不上的行 |
| 4 | WHERE | 对单表行做逐行过滤 |
| 5 | GROUP BY | 按指定列分组 |
| 6 | HAVING | 对分组后的结果过滤 |
| 7 | SELECT | 投影列,计算表达式、生成别名 |
| 8 | DISTINCT | 对结果集去重 |
| 9 | ORDER BY | 对最终结果排序 |
| 10 | LIMIT / OFFSET | 截断返回行数 |
| 11 | UNION | 合并多个查询的结果 |
我第一次把这个顺序真正记进脑子里,是在一次线上事故排查时。当时有一条带JOIN的查询,在WHERE里和ON里各放了一个过滤条件,结果集和预想的不一样,排查了半天。后来把执行顺序在纸上画出来,才意识到ON是在JOIN之前过滤的,WHERE是在JOIN之后过滤的,两者作用的阶段完全不同。从那以后,我分析所有SQL都习惯性地先在心里过一遍这11步。
1.1 为什么数据库要按这个顺序执行
我们可以把SQL执行想象成一条生产流水线,原料是整张表的数据,经过一道道工序,最后产出结果。数据库选择这个顺序,最核心的逻辑是:先确定数据范围,再尽可能早地缩小数据量,最后才考虑输出形态。
从FROM开始,是因为数据库必须知道数据从哪来,没有数据源,后面一切无从谈起。然后通过ON和JOIN把多张表合并成一张中间大表。紧接着的WHERE,是对这张中间大表做行级过滤,把不需要的行尽早扔掉——这一步是整个执行链中性价比最高的过滤时机。GROUP BY和HAVING处理的是聚合需求,只有分组后才能做聚合过滤。等到SELECT这一步,行的集合基本定型了,才开始计算列、生成别名,所以SELECT里定义的别名可以在ORDER BY里用,却不能直接在WHERE里用,因为WHERE执行时SELECT还没跑。这个顺序里的每一步都环环相扣,理解了这个逻辑,你就不会写出“在HAVING里过滤普通字段”这种又慢又怪的SQL了。
1.2 理解“中间结果集”比背口诀更重要
很多初学者背熟了顺序,但不知道背这个顺序到底有什么用。实际上,每一步都会产出一个中间结果集,下一步操作的是上一步的结果集。整个SQL的性能优化,本质就是控制中间结果集的大小。举个例子,你有两张各100万行的表做JOIN,如果能在ON阶段就把其中一张表过滤到1万行,后续JOIN的代价会小得多;如果你把过滤条件放到WHERE里,虽然逻辑结果可能一样,但JOIN阶段已经把100万行全部关联了一遍,代价完全不同。
这就是为什么很多SQL“看起来一样,跑起来差10倍”。在后面的章节里,我会反复用到“中间结果集”这个概念,请务必带着这个视角去理解每一步。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 11步逐层拆解:每一步的真实行为与常见误区
这一节,我们把11步逐一拆开,每步都结合原理、代码和注意事项来讲。请你拿出一条自己写过的复杂SQL,对照着走一遍流程,理解会更深。
2.1 第一步 FROM:数据源从这里开始
FROM是最容易被忽略的一步,很多人觉得它只是“指定表名”,没什么可讲的。但FROM决定了后续所有操作的数据范围,涉及三个常见优化点。
第一,FROM子查询。如果你写的是FROM (SELECT ...) t,MySQL会先执行这个子查询,把结果物化成一张临时表,然后再拿它和别的表做关联。这个临时表可能没有索引,也可能占内存,如果子查询本身过滤条件不足,会产生巨大的内部临时表,拖慢整个语句。所以能用JOIN直接解决的需求,尽量不要套一层子查询。
第二,多表关联时,优化器会根据统计信息决定哪张表作为驱动表。驱动表的读取顺序、是否走索引,直接影响JOIN效率。你可以在EXPLAIN里看到执行计划,第一行通常是驱动表。手动的情况下,我们一般希望小表驱动大表,即用小结果集作为外层循环。
第三,FROM阶段还涉及一个容易被忽略的知识点:如果同一个表在FROM里出现多次,比如自连接,MySQL会把它当成两张独立的表,必须用别名区分。这里的执行顺序是,先读取两次同一张表的两个副本,再做JOIN。自连接常用于查找同组内的极值、上下级关系等场景。
sql复制-- FROM子查询示例:关联子查询结果集
SELECT t.user_id, t.total_amount
FROM (
SELECT user_id, SUM(amount) AS total_amount
FROM orders
WHERE order_date >= '2024-01-01'
GROUP BY user_id
) t
JOIN users u ON t.user_id = u.id;
注意:FROM子查询虽然直观,但内部临时表一旦大起来,性能会明显下降。这种写法能用JOIN替代时优先考虑替代方案。
2.2 第二步 ON:JOIN的关联条件在这一步先行过滤
ON在JOIN之前执行,很多人不知道这个细节。ON的作用是:在两张表做关联时,先根据ON条件对参与关联的行进行过滤。对于INNER JOIN,ON和WHERE的最终结果一样,但过滤时机不同;对于LEFT JOIN,ON和WHERE的结果可能完全不同。
我用一个实际场景说明。假设有订单表orders和用户表users,要查所有用户以及他们2024年的订单。如果写成LEFT JOIN orders o ON o.user_id = u.id AND o.order_date >= '2024-01-01',ON条件会在JOIN阶段就把右边表2024年之前的订单过滤掉,左边用户表全保留。如果写成LEFT JOIN orders o ON o.user_id = u.id WHERE o.order_date >= '2024-01-01',WHERE会在JOIN完成之后,把没有2024年订单的用户行过滤掉,结果就变成了“只有2024年下过单的用户”。前者是“所有用户+他们2024年的订单”,后者是“2024年有订单的用户”。一字之差,结果集天壤之别。
ON阶段还决定了JOIN算法能否使用索引。MySQL执行JOIN时,会选择被驱动表的连接字段上是否有索引;如果ON条件里的字段有索引,就会用索引进行关联查询,否则会走全表扫描的嵌套循环。这就是为什么关联字段必须建索引的核心原因。
sql复制-- ON过滤和WHERE过滤结果不同的典型示例
SELECT u.name, o.order_id
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
AND o.status = 'paid'; -- 只关联已支付订单
SELECT u.name, o.order_id
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE o.status = 'paid'; -- 只留下有已支付订单的用户
2.3 第三步 JOIN:把匹配不上的行补回来
在ON过滤之后,JOIN阶段根据连接类型决定最终保留哪些行。INNER JOIN只保留两边都匹配的行;LEFT JOIN保留左表全部行,右表没有匹配到的部分用NULL填充;RIGHT JOIN相反;FULL JOIN在MySQL里不直接支持,需要用LEFT JOIN加UNION实现。
这个阶段,中间结果集的行数会急剧膨胀。如果两张表各有100万行,关联字段重复度高,JOIN后的中间结果集可能远超1000万行。我之前遇到过一个经典案例:三张表JOIN,每张表都没有重复约束,结果中间结果集膨胀了几十倍,虽然最后只取了前10条,但数据库不得不把整个膨胀后的结果集算完才能取前面10条。
JOIN阶段的优化思路很明确:第一,尽量让ON条件能走索引;第二,先通过过滤条件缩小参与JOIN的行数;第三,如果发现中间结果集过大,考虑是否可以通过聚合替代JOIN,或者拆成多条SQL分步处理。
2.4 第四步 WHERE:行级过滤的黄金时机
WHERE是大多数人最熟悉的步骤,也是最容易写出性能隐患的地方。WHERE的作用是对JOIN之后的中间结果集逐行过滤,只保留满足条件的行。由于这一步发生在聚合、投影之前,它是整个执行链中缩小数据量最关键的时机。
WHERE能不能走索引,直接决定了这一步的效率。数据库选择索引时,会看WHERE条件里有没有可以利用的索引列,以及条件的可选择性。这里有几个高频问题:
- 对索引列使用函数,比如
WHERE DATE(create_time) = '2024-01-01',索引会失效,因为数据库必须先对每一行计算函数值再比较。正确的做法是改成范围条件:WHERE create_time >= '2024-01-01' AND create_time < '2024-01-02'。 - 隐式类型转换会让索引失效。比如
WHERE user_id = '123',如果user_id是整数,字符串比较时会触发转换。我建议保持字段类型和参数类型一致。 - 使用
OR时,如果OR的多个条件里有一个字段没索引,整个条件常常无法使用索引。可以用UNION ALL拆分,或者加索引,或者改成IN。
从执行顺序的角度看,WHERE最大的优势是早。能放在WHERE里的过滤条件,尽量不要放到HAVING里去,因为HAVING执行得晚,那时中间结果集已经经历过分组和聚合,数据量通常比WHERE阶段大得多。同理,能用WHERE过滤掉的无效数据,也不要留着它进入GROUP BY。
sql复制-- 索引失效的常见写法
SELECT * FROM orders WHERE DATE(create_time) = '2024-06-01';
-- 推荐写法:范围查询,让create_time索引生效
SELECT * FROM orders
WHERE create_time >= '2024-06-01 00:00:00'
AND create_time < '2024-06-02 00:00:00';
2.5 第五步 GROUP BY:分组之后,行的语义就变了
GROUP BY这一步对新手来说是个分水岭。它把中间结果集按指定列的值分组,每个组最终产出一行。这是SQL里最“改变数据形态”的一步,因为分完组之后,你无法再访问组内的原始行数据,只能访问分组列和聚合函数的结果。
这解释了为什么SELECT user_name, AVG(amount) FROM orders GROUP BY user_id这类语句在有些MySQL版本下能跑,但结果并不一定可预期。如果user_name既不在GROUP BY里,也不是聚合函数,那它在分完组后代表什么?MySQL默认的ONLY_FULL_GROUP_BY模式会直接拒绝这种写法,从MySQL 5.7开始默认开启。我建议你保持这个模式开启,能避免很多业务逻辑错误。
GROUP BY的执行效率高度依赖索引。如果分组字段上有索引,MySQL可以直接扫描索引并分段聚合,不需要额外的临时表和文件排序。如果分组字段没有索引,MySQL会把中间结果集放到内存临时表里做分组,数据量大时会转成磁盘临时表,性能断崖式下降。判断是否走了索引,可以用EXPLAIN看Extra列是否出现Using temporary。
另外,GROUP BY还有个容易踩的坑:分组之前,WHERE已经过滤过一遍了,但如果你有“分组前先筛掉某些组内数据”的需求,比如只统计某些状态的数据,请务必在WHERE里先过滤,而不是在GROUP BY之后用HAVING硬筛。因为每一行在分组前被排除掉,和分组后整组被排除掉,代价完全不同。
2.6 第六步 HAVING:分组之后才能做的过滤
HAVING和WHERE最大的区别,在于执行时机和数据语义。WHERE是分组前的行级过滤,HAVING是分组后的组级过滤。也就是说,HAVING可以使用聚合结果,比如HAVING COUNT(*) > 10,而WHERE里写聚合函数会直接报错。
从执行顺序的角度,HAVING能用的字段范围比WHERE大,因为他执行在SELECT也还是之前?实际上HAVING执行在SELECT之前、GROUP BY之后。所以HAVING可以使用SELECT中定义的别名,这是MySQL的一个特性。不过要注意,这种用法不一定在所有数据库里都兼容,跨库迁移时别依赖这个特性。
性能上,HAVING的过滤成本通常高于WHERE。因为HAVING执行时,数据已经完成了分组和聚合,中间结果集的行数等于分组数,每组的原始数据已经做过一轮汇总消耗。能用WHERE完成的过滤,如果在HAVING里再做一遍,等于让无用数据白白参与了分组计算。我见过有人写GROUP BY user_id HAVING status = 'paid',这就是典型的HAVING滥用,status应该放在WHERE里。
sql复制-- 错误示范:把单行条件放到HAVING里,浪费分组计算
SELECT user_id, COUNT(*)
FROM orders
GROUP BY user_id
HAVING status = 'paid';
-- 正确写法:status是单行属性,提前到WHERE过滤
SELECT user_id, COUNT(*)
FROM orders
WHERE status = 'paid'
GROUP BY user_id;
2.7 第七步 SELECT:投影与计算
SELECT排在第七位,意味着它在行过滤、分组、聚合都完成之后才执行。这一步要做的事是:从结果集中挑出需要的列,计算表达式,生成列别名。
理解了顺序,你就能解释为什么SELECT里的别名不能用在WHERE里,但可以用在ORDER BY和GROUP BY里。因为WHERE执行时,SELECT还没运行,别名根本不存在;而ORDER BY和GROUP BY虽然有的在SELECT之前执行(GROUP BY),有的在SELECT之后执行(ORDER BY),但MySQL对GROUP BY和ORDER BY中的别名做了一些额外的解析支持,在标准的逻辑执行顺序上这一点容易被误解。更严谨地说,MySQL在解析阶段允许GROUP BY和ORDER BY引用SELECT别名,但这不代表它们逻辑上在SELECT之后。实际执行时,还是先分组、再投影、再排序。对于WHERE,MySQL明确不支持引用SELECT别名。
SELECT阶段尽量只取需要的列,避免SELECT *。你可能觉得差不了多少,但实际上,MySQL的存储引擎读取数据时,SELECT *会把每一列都读出来,包括大字段,导致大量的磁盘IO和网络传输,也会占用更大的内存来存放中间结果集。在执行顺序里,SELECT阶段虽然靠后,但它的列清单会影响前面各阶段存储引擎读取的数据量,因此“尽早减少数据宽度”同样重要。
另外,SELECT中的计算表达式,比如amount * 0.9、CONCAT(first_name, last_name),都是在投影阶段执行的。这些计算会逐行执行,如果能在WHERE里过滤掉大量行后再计算,计算量会小很多。
2.8 第八步 DISTINCT:去重的真实代价
很多人不知道DISTINCT的代价有多大。DISTINCT的执行逻辑是:对SELECT之后的结果集进行去重,保留唯一组合。如果查询涉及多列,DISTINCT会把多列组合在一起比较,只有所有列都相同才认为是重复行。
从这个执行位置可以看出,DISTINCT是全局性的操作,它处理的是前面所有步骤产生的整套结果集。结果集有多大,去重的成本就有多大。MySQL需要把每一行和已经输出的行做比较,数据量大时往往需要临时表和文件排序。所以,SELECT DISTINCT col FROM big_table这种查询,如果col上没有索引,性能会非常差。
最有效的优化手段是通过索引消除DISTINCT的排序和去重开销。因为索引本身是有序的,MySQL扫描索引时可以直接跳过重复值。另外,能用WHERE条件过滤得足够干净,让结果集本身就很少重复,也比依赖DISTINCT硬去重要好。有些时候,你以为需要DISTINCT,实际上是JOIN产生了重复行——这种重复可以用更精准的JOIN条件消除,而不是靠最后的DISTINCT兜底。
sql复制-- 错误示范:JOIN产生重复,最后靠DISTINCT硬去重
SELECT DISTINCT u.id, u.name
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE o.status = 'paid';
-- 更优方案:先聚合订单,再关联用户,避免重复
SELECT u.id, u.name
FROM users u
JOIN (
SELECT user_id FROM orders
WHERE status = 'paid'
GROUP BY user_id
) o ON o.user_id = u.id;
2.9 第九步 ORDER BY:排序为什么怕大结果集
ORDER BY在DISTINCT之后执行,也就是对最终的结果集排序。这一步的排序成本,和结果集大小成指数相关?严格说是N*log(N)的复杂度,结果集越大,排序越慢。
MySQL排序有两种方式:利用索引有序性直接输出,或者生成临时结果集后执行文件排序(filesort)。当ORDER BY字段有索引,并且查询条件的过滤能让MySQL直接按索引顺序访问时,就会省略排序步骤。这是ORDER BY优化最重要的方向。
要注意,排序不仅看ORDER BY的字段,还要看它和WHERE中使用索引的字段是否一致。比如WHERE a = 1 ORDER BY b,如果有(a, b)联合索引,MySQL可以直接用索引取出a=1的行,而b天然有序,不需要额外排序。如果只给b建了索引,MySQL还得先把满足a=1的行找出来,再对b排序,无法复用索引顺序。
从执行顺序还能解释一个经典问题:LIMIT能减少最终返回的行数,但如果ORDER BY前面已经产生了海量排序,LIMIT并不能帮你减少排序成本。很多慢查询的根源就在这里——LIMIT 10看似只取10条,但ORDER BY还是把200万行全排了一遍。
2.10 第十步 LIMIT / OFFSET:分页的陷阱
LIMIT是执行顺序里倒数第二步,它决定最终返回多少行。单纯的LIMIT n,如果前面前面步骤输出结果很小,通常很快;但是LIMIT m, n这种带OFFSET的写法,才是分页查询中的大坑。
LIMIT 100000, 20的逻辑是:数据库先把前100020行全部找出来,然后丢弃前100000行,只返回最后的20行。你只是跳过100000行,但数据库却要为这个“跳过”付出100000行查询和传输的代价。数据量越大,翻页越深,这个开销越夸张。
优化深分页的经典姿势是延迟关联或游标分页。延迟关联的思路是:先快速定位到需要返回的主键ID范围,再用这些ID回表查完整行。这样排序和过滤只需要处理ID列,大幅减少需要读取的数据量。游标分页则是基于排序字段记住上一页最后一条记录的ID,直接用WHERE id > last_id ORDER BY id LIMIT 20来翻页,适用于App端“加载更多”场景,但对“跳转到第100页”这类需求不友好。
sql复制-- 深分页慢查询
SELECT * FROM orders ORDER BY id LIMIT 100000, 20;
-- 延迟关联优化:先查ID,再回表
SELECT o.*
FROM orders o
JOIN (
SELECT id FROM orders ORDER BY id LIMIT 100000, 20
) t ON o.id = t.id;
2.11 第十一步 UNION:集合运算的特殊位置
严格来说,UNION并不是标准的第十一步,它在SQL语句中如果出现,通常是多个SELECT的合并。逻辑上,MySQL先分别执行UNION两侧的SELECT语句,得到各自的结果集,然后合并去重(UNION)或直接合并(UNION ALL),最后再做ORDER BY和LIMIT。
这就是为什么SELECT ... UNION SELECT ... ORDER BY ...中的ORDER BY是对整个合并结果排序,而不是只对最后一个SELECT排序。同时,如果UNION两侧的查询都能各自在内部做过滤,尽量在两边都写WHERE条件,让每一侧的中间结果集都尽可能小,而不是把过滤写在UNION之后的外层嵌套里。
UNION会对结果去重,这需要额外的比较和排序开销。如果你的业务能接受重复数据,或者你确定两边结果没有交集,请使用UNION ALL。UNION ALL只是把两个结果集拼接起来,少一步去重,通常比UNION快很多。
我见过有人在分页查询里用UNION来实现复杂过滤,然后把LIMIT写在外层,结果性能惨不忍睹。正确思路是:把LIMIT下放到每一个子查询里,尽量让UNION的输入变小。
3. 吃透执行顺序后,我的SQL优化实战套路
理解了每一步的行为,优化就有章可循了。核心原则一句话:把代价高的操作,作用在更小的数据上。下面几个是我在日常工作中反复用到的优化套路,每条都能在11步执行链里找到对应的依据。
3.1 过滤时机越早越好,条件尽量前移
在JOIN之前能过滤的表,就在FROM子查询里先过滤;在WHERE阶段能过滤的行,别留到HAVING。我有个习惯,写完SQL后会从执行顺序的视角倒着推一遍:如果一条记录在WHERE阶段就会被淘汰,那它就不应该进入GROUP BY,更不应该进入ORDER BY。凡是可以提前的过滤条件,全部往前放。
举个例子,统计每个用户的已支付订单金额。很多人会写成先JOIN再过滤:
sql复制SELECT u.name, SUM(o.amount)
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE o.status = 'paid'
GROUP BY u.name;
这个SQL逻辑没错。但如果你已经知道只需要2024年的订单,可以把订单表的过滤提前到FROM子查询里,让JOIN参与的数据量更小:
sql复制SELECT u.name, SUM(o.amount)
FROM users u
JOIN (
SELECT user_id, amount
FROM orders
WHERE status = 'paid' AND order_date >= '2024-01-01'
) o ON o.user_id = u.id
GROUP BY u.name;
两条SQL最终结果一样,但第二条在JOIN阶段处理的数据量小得多,尤其是订单表行数巨大时,性能差异会非常明显。
3.2 WHERE和HAVING的分工,别再搞混
从执行顺序看,WHERE先于GROUP BY,HAVING后于GROUP BY,这决定了它们的用途:WHERE处理行级条件,HAVING处理组级条件。一个反例是:
sql复制SELECT user_id, COUNT(*)
FROM orders
GROUP BY user_id
HAVING user_id > 100;
user_id是每一行都有的属性,不是聚合结果,放在HAVING里过滤等于让所有行都参与了分组,然后才把不满足的分组扔掉。改成WHERE user_id > 100,可以先过滤掉user_id<=100的行,再分组,成本小一个数量级。判断规则很简单:条件里只涉及单行字段,放WHERE;条件里涉及聚合函数或分组后的统计值,放HAVING。
3.3 深分页优化:让LIMIT只处理主键
LIMIT/OFFSET的问题在前面讲过,但这里我要再强调一次,因为深分页是线上最常见的慢SQL来源之一。优化思路就是延迟关联,先把LIMIT下推到主键查询里:
sql复制-- 原SQL:慢
SELECT * FROM orders
WHERE status = 'paid'
ORDER BY create_time DESC
LIMIT 50000, 20;
-- 优化后:快很多
SELECT o.*
FROM orders o
JOIN (
SELECT id
FROM orders
WHERE status = 'paid'
ORDER BY create_time DESC
LIMIT 50000, 20
) t ON o.id = t.id;
子查询里只需要回表ID列,排序和LIMIT都作用在窄表上。确定ID集合后再回原表取完整行,传输量小得多。如果是App端“加载更多”场景,更推荐游标式分页:记住上一页最后一条记录的create_time和id,下次查询直接WHERE create_time < last_time OR (create_time = last_time AND id < last_id)。
3.4 让ORDER BY和GROUP BY尽量走索引
执行链里GROUP BY在第5位,ORDER BY在第9位,但它们都可以通过索引来优化。GROUP BY走索引时,MySQL可以按索引顺序分段处理,不需要额外的临时表;ORDER BY走索引时,结果天然有序,直接输出即可。
想让两者都走索引,核心是保证排序字段和索引前缀一致,且过滤条件不破坏索引顺序。比如索引是(a, b, c),那么GROUP BY a, b可以减少临时表;ORDER BY a, b也能利用索引顺序。但如果你查WHERE a = 1 ORDER BY c,索引只能用于过滤a,排序c时无法完全复用索引,因为中间隔着b。所以在设计联合索引时,要把过滤条件和排序字段一起考虑,而不是孤立地看某一个查询。
3.5 大结果集上的DISTINCT和UNION,能避则避
DISTINCT和UNION都处在执行链后端,作用在大结果集上,代价高昂。我在工作中有一条原则:先用业务逻辑消除产生重复的原因,而不是等查出来再兜底去重。比如JOIN产生重复,优先检查关联条件是否唯一;UNION需要去重,优先考虑是否能用UNION ALL,或者把两个查询合并成一个。DISTINCT最好的优化,是用索引去重,或者让前置过滤条件把结果集压到足够小。当你在一条慢SQL里看到DISTINCT和UNION同时出现时,几乎可以断定这条语句有进一步拆解和优化的空间。
4. 常见误区与排查实录
我在带团队和review代码的过程中,积累了不少关于执行顺序的典型问题。这里整理成一张排查速查表,你可以直接截图存下来,遇到类似问题照着排查。
| 现象 | 根本原因 | 解决方式 |
|---|---|---|
| LEFT JOIN后结果行数比左表多 | ON条件不唯一,一行左表匹配多行右表 | 检查关联字段是否唯一,必要时先聚合右表 |
| LEFT JOIN后使用WHERE过滤右表字段,结果等同INNER JOIN | WHERE在JOIN后过滤,把NULL行过滤掉了 | 过滤条件移到ON里,或拆成子查询 |
| WHERE里使用SELECT别名报错 | WHERE执行在SELECT之前,别名还不存在 | 直接用原始列名或派生表 |
| HAVING中使用非聚合字段过滤,SQL能跑但很慢 | 过滤时机太晚,所有行先参与分组 | 移到WHERE里过滤单行字段 |
深分页LIMIT 1000000, 20非常慢 |
OFFSET需要扫描并丢弃大量行 | 延迟关联或游标分页 |
ORDER BY RAND()取随机行慢到崩溃 |
排序在LIMIT之前,先全量排序再取行 | 用主键随机范围替代,或程序端处理 |
GROUP BY出现了Using temporary; Using filesort |
分组字段没有合适的索引 | 建立匹配的联合索引消除临时表 |
4.1 一个实战复盘的完整过程
有一次线上慢查询,SQL长这样:
sql复制SELECT p.id, p.title, c.name, COUNT(c.id) AS comment_count
FROM posts p
LEFT JOIN comments c ON c.post_id = p.id
WHERE p.status = 1
GROUP BY p.id, p.title, c.name
ORDER BY comment_count DESC
LIMIT 20;
功能是想查出状态为1的文章及其评论数,按评论数倒序取前20。当时文章表50万行,评论表200万行,这条SQL跑一次要4秒多。
我把执行顺序捋了一遍就发现问题了。第一,GROUP BY里包含了c.name,但评论表里我们只统计数量,根本不需要c.name,它进去导致了一个帖子关联多条评论时分组维度扩大,中间结果集被撑大。第二,COUNT(c.id)用的LEFT JOIN本身就有隐患,因为一个帖子如果没有评论,它会出现一行,如果有多条评论,又会变成多行。正确的做法是先把评论聚合好再关联:
sql复制SELECT p.id, p.title, c.comment_count
FROM posts p
LEFT JOIN (
SELECT post_id, COUNT(*) AS comment_count
FROM comments
GROUP BY post_id
) c ON c.post_id = p.id
WHERE p.status = 1
ORDER BY comment_count DESC
LIMIT 20;
改写后,评论表只需要做一次分组聚合,帖子表再和聚合结果关联,中间结果集大幅减少。这条SQL优化后跑到了120毫秒左右,接近30倍的提升。执行顺序的思路在这里起了决定性作用,因为每一步我都知道中间结果会长什么样。
5. 如何用执行顺序体检你的旧SQL
现在你手上如果有几条慢查询,我建议你按下面的步骤做一次“执行顺序体检”。
第一步,把SQL里的每个关键字列出来,对照11步顺序表,从FROM开始逐步推演中间结果集是什么形态,行数大概多少。这一步不需要跑SQL,纯靠逻辑推演,很快就能发现哪些步骤可能让数据量失控。
第二步,用EXPLAIN确认执行计划。重点看type字段(ALL表示全表扫描,ref或eq_ref说明走索引)、rows(预估扫描行数)、Extra列(有没有Using filesort、Using temporary)。把EXPLAIN的结果和你的推演对照,如果执行计划中出现全表扫描或者临时表,基本就是优化点所在。
第三步,针对主要瓶颈,回到执行顺序里去想:这个操作能不能提前?能不能用一种更便宜的操作替代?比如排序贵就试试用索引消除排序,去重贵就看看能不能消除重复的产生源,JOIN贵就看看能否先聚合缩小数据量。
提示:MySQL 8.0里可以给EXPLAIN加上
FORMAT=TREE,能看到更接近真实执行过程的分析树,对理解执行顺序特别有帮助。EXPLAIN ANALYZE则能实际执行并返回各阶段耗时,是排查慢SQL的利器。
我在平时带新人时,一直强调一个习惯:看到任何一条SQL,先别急着跑,别急着问为什么慢,先沿着执行顺序把它的“数据流”在脑子里画一遍。一旦你养成了这个习惯,很多问题一眼就能看出来。这不是什么高深的理论,只是把MySQL的执行模型真正变成了自己的思维工具。
说起来,这套执行顺序不仅适用于MySQL,也基本适用于其他关系型数据库。换到PostgreSQL、Oracle、SQL Server,整体逻辑大同小异,只是个别细节有差异,比如MySQL支持GROUP BY别名而Oracle不支持。啃透这一套,无论你以后换什么数据库,分析SQL的底层能力都是通用的。这大概也是我这些年见过最值得花时间去吃透的基础知识之一。
