写SQL这活儿,很多同学都栽在同一个地方:明明结果是对的,性能却差得离谱。我早年排查过一个慢查询,一条统计订单的SQL跑了快两分钟,加了索引都没救回来。后来发现问题根本不在索引,而在于我把一个聚合子查询放到了SELECT里,等于对每一行都执行了一次独立计算。从那时候开始,我才真正把MySQL执行顺序当回事,而不是只把它当面试题背。
这条经验后来帮了我很多次。SQL执行顺序决定了数据在哪个环节被削减、在哪个环节被放大,也直接决定了索引能不能发挥作用、排序会不会走文件、临时表会不会被撑爆。这篇文章就用一个贯穿始终的订单查询案例,把MySQL的11步执行顺序彻底拆开,每一步对应什么原理、有什么性能风险、该怎么利用它优化,一次讲透。适合正在学SQL的新手、写业务代码的开发者,以及正在为慢SQL头疼的DBA。
1. 先搞懂:为什么执行顺序决定了SQL的性能
1.1 逻辑执行顺序和物理执行计划是两回事
很多人有个误区,觉得SQL执行顺序就是数据库真正干活儿的顺序。其实不是。SQL是声明式语言,你只告诉数据库“我要什么”,数据库自己决定“怎么干”。MySQL内部有个优化器,拿到你的SQL之后会先做语法解析、逻辑重写、成本估算,最后生成一个物理执行计划。优化器可能会调整表连接顺序、把子查询改写成JOIN、把过滤条件提前下推,这些都是它自己的事儿。
那为什么还要学执行顺序?因为优化器也不是万能的,它重写SQL有边界。如果你写的SQL在逻辑层面就存在严重的数据放大问题,比如把标量子查询放在SELECT里、把可以提前过滤的条件放到了HAVING里,优化器很难自动帮你救回来。我们常说的执行顺序,其实是“逻辑执行顺序”,是数据库在理想状态下处理数据的顺序,也是衡量你SQL写得好不好的基准线。
我习惯把它理解成一个工厂流水线。原料从仓库出来,先经过清洗、切割、组装、质检、包装,最后出货。SQL也一样:先取表、再关联、再过滤、再分组、再排序、再截断。每一步拿到的是上一步的产出,数据量在一路变化。你如果能清楚地知道每一步数据量有多少,自然就知道瓶颈在哪。
1.2 一次慢查询带来的教训
那次印象深刻的排查,SQL大概是这样的:要查一批订单,显示用户昵称、订单编号、订单金额,外加这个用户的历史总消费。当时的写法是在SELECT里放了一个标量子查询,用户总消费是独立查出来的。单看这一条SQL,逻辑完全没问题,但执行的时候,外层订单表扫描出来比如一万行,每一行都要跑一次子查询去聚合用户的所有历史订单,哪怕有索引,也被这一万次重复调用拖垮了。
最后的优化方向很简单:把用户总消费改成JOIN加GROUP BY,先在订单表上按用户分组聚合,再和主表关联。SQL改写之后,聚合动作从第7步的SELECT阶段,提前到了第5步的GROUP BY阶段,避免了逐行执行子查询。同一个业务需求,执行时间从113秒降到了0.8秒左右。这个案例让我彻底明白了一个道理:SQL性能差的根源,很多时候不是索引不够,而是数据在错误的执行阶段被处理了。
1.3 执行顺序的性能漏斗模型
我们用一个“漏斗模型”来看执行顺序,会非常直观。一条查询从FROM开始,数据量可能是几百万行;经过ON条件和WHERE过滤后,可能就剩几万行;GROUP BY再进行聚合压缩,变成几千行;再到ORDER BY排序、LIMIT截断,最终输出几十行。整个链路是一个数据量逐步缩减的漏斗,而执行顺序决定了每一道阀门的安放位置。
关键点在于,过滤动作越靠前,收益是乘法级的。比如一张一千万行的订单表,如果在WHERE阶段能把范围过滤到十万行,那后面的GROUP BY聚合、ORDER BY排序全都只处理十万行。反过来,如果过滤条件没写好,所有数据都堆到排序阶段再处理,那再用力的硬件也扛不住。理解了这一点,你再看SQL优化,方向感会完全不一样。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 图解MySQL的11步执行顺序
2.1 11步总览:先建立整体认知
下面这条SQL是我这些年讲解执行顺序时的固定案例,整篇文章都会围绕它展开。它是典型的“用户-订单-订单明细”三层关联查询,而且覆盖了过滤、分组、聚合、排序、取前N条全部核心操作。
sql复制SELECT
u.name,
o.order_id,
SUM(oi.amount * oi.quantity) AS order_amount
FROM users u
JOIN orders o ON u.id = o.user_id
JOIN order_items oi ON o.id = oi.order_id
WHERE u.status = 'active'
AND o.created_at >= '2024-01-01'
GROUP BY u.name, o.order_id
HAVING SUM(oi.amount * oi.quantity) > 1000
ORDER BY order_amount DESC
LIMIT 10;
MySQL的逻辑执行顺序,可以把这11步画成一条流水线:
text复制1 FROM -- 确定数据源,加载表
2 ON -- 先按连接条件过滤,再连接
3 JOIN -- 把多张表组装成一张宽表
4 WHERE -- 对宽表做行级过滤
5 GROUP BY -- 按指定列分组
6 HAVING -- 对分组结果做聚合后过滤
7 SELECT -- 计算投影列,包括聚合表达式
8 DISTINCT -- 对结果去重
9 ORDER BY -- 排序
10 LIMIT -- 截断行数
11 UNION -- 合并多个查询结果
为了方便记忆,我把每一步的数据量影响和优化重点整理成了速查表:
| 步骤 | 关键字 | 数据量影响 | 核心优化点 |
|---|---|---|---|
| 1 | FROM | 读取全表或分区 | 减少扫描范围,利用统计信息 |
| 2 | ON | 提前过滤关联行 | 连接字段类型一致、建索引 |
| 3 | JOIN | 生成中间结果集 | 小表驱动大表,避免大结果放大 |
| 4 | WHERE | 大幅削减行数 | 索引命中、避免函数和隐式转换 |
| 5 | GROUP BY | 压缩行数 | 索引分组,减少临时表和排序 |
| 6 | HAVING | 过滤分组结果 | 能提前过滤的一定提前到WHERE |
| 7 | SELECT | 控制列宽 | 只查所需列,用覆盖索引 |
| 8 | DISTINCT | 去重 | 能用EXISTS替代则替代 |
| 9 | ORDER BY | 结果排序 | 走索引排序,避免filesort |
| 10 | LIMIT | 截断输出 | 深分页用延迟关联 |
| 11 | UNION | 合并结果 | 能用UNION ALL就不用UNION |
这张表后面会反复用到。建议你把“每一步处理哪些数据、减少多少行”这个视角带上,再往下看具体的步骤分析。
2.2 前三步:数据源的组装(FROM、ON、JOIN)
执行顺序的前三步是在组装数据源。FROM确定要读取哪些表,MySQL会根据统计信息估算每张表的行数,并选择访问方式,是全表扫描还是走索引。这里有个容易被忽略的细节:虽然逻辑顺序是FROM固定第一,但优化器实际决定连接顺序时,可能把任意一张表当驱动表,它选的是成本最低的方案。
ON和JOIN是配合使用的。ON定义连接条件,JOIN执行连接。INNER JOIN时,ON和WHERE实质效果相同,优化器也可能把ON里的条件优化到WHERE阶段,但LEFT JOIN、RIGHT JOIN这类外连接一定要留意,ON子句的过滤是发生在连接之前的,如果把本该属于ON的过滤条件写到WHERE里,结果可能不一样,而且数据量处理路径也会变。比如左侧驱动表是订单表,右表是用户表,ON里写u.status = 'active',并不会过滤掉未激活用户对应产生的订单,但同样的条件写到WHERE里,就会把“未激活用户的订单”整行删除。结果不同,成本也不同。
实操层面,连接字段的类型一致性是必须检查的。u.id是INT,o.user_id是VARCHAR,虽然MySQL能隐式转换,但一旦转换发生,索引基本就废了。我见过很多慢查询,原因就是主外键字段类型不一致,导致关联时每一行都要做一次转换。把字段类型对齐,让索引直接可用,这一步是零成本优化。
JOIN算法在MySQL 8.0之后也有变化。老版本依赖Nested Loop Join和Block Nested Loop,新版本引入了Hash Join,尤其适合等值连接且关联字段没有可用索引的场景。理解JOIN的执行方式,实际排查时能帮你判断EXPLAIN里的“Using join buffer”到底是什么含义。如果你看到一条JOIN查询明明数据量不大却特别慢,多半是驱动表选择不当,或者连接字段没索引,导致每条驱动表的记录都要全表扫匹配。
2.3 WHERE:全程最重要的减负节点
WHERE这一步是整个执行顺序里最关键的数据削减点,它决定了后续GROUP BY和ORDER BY要处理多少行。你要理解一个底层事实:WHERE是在所有连接完成之后才执行的。逻辑上,JOIN之后已经生成了一张包含所有字段的中间宽表,WHERE再对这张宽表逐行过滤。优化器确实会做谓词下推,把部分WHERE条件提前,但你自己在写SQL时,仍然要主动把过滤条件写清楚,让优化器更容易做改写。
在WHERE条件里,索引能不能命中,是性能的分水岭。最常见的索引失效场景有三个:一是在索引列上使用函数,比如WHERE DATE(created_at) = '2024-01-01',它会让created_at上的索引完全失效,正确的写法是WHERE created_at >= '2024-01-01 00:00:00' AND created_at < '2024-01-02 00:00:00';二是隐式类型转换,比如WHERE mobile = 13800138000,如果mobile是VARCHAR类型,MySQL会先把列转成数字再去比较,索引失效;三是LIKE前置通配符,比如WHERE name LIKE '%张',左侧模糊无法匹配B+树的顺序查找,只能全扫。
还有一个容易被忽视的点:OR条件也可能让索引失效。WHERE status = 'active' OR status = 'pending',如果status上只有单列索引,MySQL可能放弃索引走全表扫描。改写思路是变成IN:WHERE status IN ('active', 'pending'),或者拆成两条查询用UNION ALL合并。这里的核心逻辑是,执行顺序里WHERE阶段的每一个条件,都应尽量保证是“可索引查找”而不是“逐行判断”。
再回到我们的案例SQL,WHERE u.status = 'active' AND o.created_at >= '2024-01-01',这两个条件分别过滤用户表和订单表。如果users.status选择性高,orders.created_at上有索引,它们就能在各自的基表扫描阶段就完成过滤,而不是等三张表全连接完再过滤。这也是为什么我建议写SQL时,把过滤条件紧跟它所属的表写,别把用户表的过滤条件写在订单表后面,虽然逻辑结果一样,但优化器解析起来更轻松。
2.4 GROUP BY和HAVING:分组与过滤的配合
WHERE把行过滤完之后,就进入了分组阶段。GROUP BY的执行逻辑,是把上一阶段的中间结果按指定列进行分组,然后为每个分组计算聚合值。注意,这里有两个隐藏消耗:一是分组需要比较每一行的分组键,如果分组键没有索引,就可能在内存或磁盘上建临时表;二是在MySQL 8.0之前,GROUP BY默认还会对分组键做一次隐式排序,这个排序本身也是开销。虽然8.0之后的默认行为变了,但理解这段历史,能帮你解释为什么有些老库建了联合索引但分组还是很慢。
在案例SQL里,GROUP BY u.name, o.order_id,这里有个值得深入思考的点:我们明明已经知道u.id和o.order_id是唯一的,为什么还要用u.name做分组键?实际业务里,如果u.name存在重名,按name分组就会把不同用户的订单混在一起。更合理的写法是GROUP BY u.id, o.order_id,然后再SELECT u.name。这样既保证了分组的语义正确性,又让分组键尽量跟索引主键对齐。从执行顺序的角度看,分组键越短越简单,分组比较的开销越小。
HAVING的执行位置紧跟在GROUP BY之后,它只能使用分组后的结果,比如聚合函数SUM(oi.amount * oi.quantity) > 1000。这里要反复强调的优化原则是:能用WHERE过滤掉的,绝不用HAVING。比如我们想过滤掉“未激活用户”的订单,或者“2024年之前”的订单,这些条件放WHERE里完全是合理的,因为它们在分组之前就能判断,能大幅减少分组的数据量。但如果条件里包含聚合结果,比如“订单金额大于1000”,那只能放在HAVING里,因为数值要等分组聚合完才算得出来。
说到这个,我想到很多业务同学写SQL时会犯的经典错误:把非聚合条件也放在HAVING里,比如HAVING u.status = 'active'。这在MySQL的宽松模式下可能能跑,但它会让过滤发生在分组之后,浪费了WHERE的减负机会。开启ONLY_FULL_GROUP_BY模式后,这种写法直接报错。从执行顺序上理解,这跟把安检放在登机口而不是机场入口一样,效率自然差。
2.5 SELECT和DISTINCT:列投影与去重的开销
执行顺序走到SELECT这一步,GROUP BY和HAVING已经确定了最终的行集合,这一步要做的事情相对简单,就是计算需要输出的列,包括表达式和聚合值的计算。这里最常见的性能问题是SELECT *。SELECT * 会把所有列的数据都从存储引擎读出来,不仅增加了IO,还会让覆盖索引失效。覆盖索引的意思是,查询所需的列都包含在索引里,存储引擎直接返回索引数据,不需要回表。一旦SELECT *,几乎必然触发回表,扫描行数一样,代价翻倍。
DISTINCT的执行位置在SELECT之后,它会对整个结果集做去重。去重的实现方式通常是排序或者哈希,无论哪种,都是额外开销。而且因为它在SELECT之后才执行,它处理的是已经投影好的所有列,数据宽度越大,去重成本越高。很多场景下,DISTINCT可以用更高效的写法替代。
举个例子,如果业务是“查所有下过单的用户ID”,你可能会写SELECT DISTINCT user_id FROM orders,理论上order表如果非常大,这个去重会把所有订单的user_id投影出来再排序去重。其实可以用EXISTS改写:SELECT id FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)。后者的优点是它能走orders表上的索引,只要找到第一条匹配记录就立即返回,而不需要遍历所有的订单数据。当然,两种写法在数据分布不同时各有优劣,但当orders表特别大、users表相对小时,EXISTS改写通常更划算。
在我们案例SQL里,SELECT u.name, o.order_id, SUM(oi.amount * oi.quantity) AS order_amount,这部分没有DISTINCT,也没用SELECT *,列宽控制得不错。但如果我要往回调整,会把SUM的表达式稍微做点优化,确保amount和quantity在没索引的情况下也不会拖累太多,因为这种聚合计算通常要等所有数据都分组完才能出结果。
2.6 ORDER BY和LIMIT:排序与输出的最后一道关卡
ORDER BY的执行位置比较靠后,这意味着它面对的是经过分组、投影之后的较小数据集。但“较小”是相对的,如果WHERE过滤不到位,ORDER BY仍然可能要处理大数据量的排序。MySQL执行排序有两种方式:一种是索引本身就满足排序顺序,直接从索引读取就是有序的,不会产生额外排序开销,这叫索引排序;另一种是内存或磁盘排序,会调用filesort,先把结果集放入sort_buffer,如果内存不够就写临时文件再归并排序。
想让ORDER BY走索引排序,最直接的原则是:ORDER BY字段的顺序要和索引定义的顺序一致。比如联合索引idx_user_order(user_id, created_at),查询里写ORDER BY user_id, created_at就能用索引;如果写ORDER BY created_at,或者中途加入一个索引里没有的字段,就没得用了。另一个容易被忽略的点是排序方向的一致性,索引是升序的,如果ORDER BY user_id ASC, created_at DESC,新旧版本的MySQL处理方式不同,8.0虽然支持降序索引,但老库仍然会出现filesort。
LIMIT的执行位置紧跟在ORDER BY之后,它只负责截断输出。很多人觉得LIMIT 10是最后一步,数据量小,性能问题不大,但有一种特别典型的慢查询叫深分页。比如LIMIT 1000000, 10,MySQL会先把1000010条记录全捞出来,排序完之后再跳过前一百万条,最后返回10条。这个场景下,LIMIT本身不是瓶颈,前边的ORDER BY和全量排序才是。优化的常用手段是延迟关联,先查出主键,再做关联取完整数据。这部分我在第三章会详细演示。
回到案例SQL,ORDER BY order_amount DESC LIMIT 10,如果order_amount是聚合结果,那这个排序只能走filesort,无法用索引,因为它是在GROUP BY之后才产生的计算结果。这也说明,有时候无法用索引排序并不是写法问题,而是业务本身就需要计算聚合值之后排序。这种情况下的优化思路不是去掉排序,而是尽量保证排序前的数据集足够小,让filesort处理的行数可控。
2.7 UNION和窗口函数的执行位置说明
我们的案例SQL没有涉及UNION,但执行顺序的11步里UNION确实占了一个位置。逻辑上,UNION的作用是把多个SELECT的结果合并,它会在各个子查询完成自己的SELECT、DISTINCT、ORDER BY、LIMIT之后执行。这里最明显的性能点是:UNION和UNION ALL的差别。UNION会做一次隐式的DISTINCT去重,去重操作需要把所有结果集放到临时表中进行比较,开销不小。如果业务上能确定子查询结果没有重复,直接用UNION ALL是稳赚不赔的优化。
MySQL 8.0还引入了窗口函数,它的执行位置也很特殊。按官方文档的逻辑,窗口函数是在GROUP BY和HAVING之后、ORDER BY之前计算的,也就是在SELECT阶段对每一行计算。这也是为什么窗口函数的PARTITION BY和ORDER BY子句可以跟查询本身的GROUP BY、ORDER BY完全不同,因为它们是在各自的阶段独立执行。比如写ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC),它会按用户分区、再按时间排序给每条记录编号,完全是另一条计算链路。
理解窗口函数的位置,能帮你避免一个常见误区:想用窗口函数取Top N,却在外面套了ORDER BY LIMIT。从执行顺序看,窗口函数在排序之前就已经算完,所以你在WHERE里是没法引用窗口函数的别名的,比如WHERE rn = 1会直接报错。必须先子查询计算出rn,再在子查询外面过滤。这恰好是执行顺序知识落地的一个小场景。
3. 把执行顺序当武器:5个可直接落地的优化手段
3.1 过滤前置:WHERE能做到的事,绝不留到HAVING
这个原则执行顺序里讲了一遍,这里给一个实际可比对的例子。假设我们要查“2024年在‘北京’地区、订单金额大于500的用户和订单数”,再来一个条件“每个用户至少有3笔订单”,很多人的第一版SQL会写成这样:
sql复制SELECT
user_id,
COUNT(*) AS order_cnt
FROM orders
WHERE created_at >= '2024-01-01'
AND created_at < '2025-01-01'
GROUP BY user_id
HAVING order_cnt >= 3
AND region = '北京';
这个写法的逻辑结果是正确的,但region = '北京'被放在了HAVING里。如果region字段本来就属于orders表,它完全可以放到WHERE里提前过滤,让分组阶段少处理一大批订单。改成:
sql复制SELECT
user_id,
COUNT(*) AS order_cnt
FROM orders
WHERE region = '北京'
AND created_at >= '2024-01-01'
AND created_at < '2025-01-01'
GROUP BY user_id
HAVING order_cnt >= 3;
两条SQL的数据处理路径差别很大。第一版先把全国订单都分组,把用户ID和订单数量算出来,再去HIVE里过滤地区;第二版先只取出北京市的订单,再分组。如果全国订单有一千万行,北京订单只有十万行,那第一版的分组操作要多处理约990万行。这个优化没有引入任何额外索引,只是把过滤动作从第6步挪到了第4步,数据量立刻降了一个量级。执行顺序的漏斗模型在这里体现得淋漓尽致。
3.2 把SELECT标量子查询改成JOIN聚合
开篇提到的案例就是这一类问题,这里再展开一个可直接复用的模式。假设要查“每个订单的总额,以及每个用户的消费总额”,第一版可能这样写:
sql复制SELECT
o.order_id,
(SELECT SUM(amount)
FROM order_items oi
WHERE oi.order_id = o.order_id) AS order_total,
(SELECT SUM(amount)
FROM orders o2
WHERE o2.user_id = o.user_id) AS user_total
FROM orders o;
从执行顺序看,第一个子查询在SELECT阶段对每一个订单行执行一次聚合,第二个子查询又对每一个订单行执行一次用户级聚合。外层订单表一万行,这两个子查询就要各跑一万次,哪怕每次都有索引,一万次的开销也会累计到不可接受。
改写思路是用JOIN先去重聚合,再关联:
sql复制SELECT
o.order_id,
oi_sum.order_total,
u_sum.user_total
FROM orders o
LEFT JOIN (
SELECT order_id, SUM(amount) AS order_total
FROM order_items
GROUP BY order_id
) oi_sum ON o.id = oi_sum.order_id
LEFT JOIN (
SELECT user_id, SUM(amount) AS user_total
FROM orders
GROUP BY user_id
) u_sum ON o.user_id = u_sum.user_id;
改写后,两个聚合成为了两个独立子查询,在FROM阶段就完成一次性的分组聚合,然后通过JOIN一次性关联,不再逐行执行。这种改写能规避的执行顺序风险是:让聚合计算从第7步的投影阶段,提前到第1步到第3步的FROM和JOIN阶段。逻辑上还是那些数据,但计算次数差了一个数量级。性能提升往往不是百分之几十,而是几十倍。
3.3 给GROUP BY和ORDER BY建一个联合索引
之前提到GROUP BY和ORDER BY都可能触发临时表和filesort,如果能通过联合索引同时满足分组和排序,消耗会大幅下降。举个例子:
sql复制SELECT user_id, status, COUNT(*)
FROM orders
WHERE created_at >= '2024-01-01'
GROUP BY user_id, status
ORDER BY user_id, status;
针对这个查询,最合适的索引是联合索引idx_user_status(user_id, status, created_at)。虽然ORDER BY和GROUP BY字段一致,排序字段其实也被分组字段覆盖了。如果更极端一点,GROUP BY user_id, status,ORDER BY user_id, status,这样的联合索引可以让分组时直接按索引顺序读取,不需要临时表,也不需要中间排序。这里需要注意的是,联合索引的字段顺序必须和GROUP BY字段顺序一致,这叫最左前缀原则。
另一个经验是,如果业务要求ORDER BY排序方向跟GROUP BY一致,但字段不同,比如GROUP BY user_id,ORDER BY created_at,这个组合很难通过一个索引同时搞定,通常只能二选一优化。写SQL时能提前识别这一点,就不至于为一个慢性SQL反复加索引试错。用EXPLAIN看一眼有没有Using temporary和Using filesort,就知道联合索引有没有真正生效。
3.4 深分页用延迟关联优化
LIMIT深分页这个坑,很多大数据量业务都踩过。先看一段典型慢SQL:
sql复制SELECT id, order_no, user_id, amount
FROM orders
WHERE created_at >= '2024-01-01'
ORDER BY created_at DESC
LIMIT 1000000, 20;
这个查询在ORDER BY阶段要对所有满足条件的订单排序,然后取第1000001到1000020行。换句话说,它要先把1000020行的排序结果全部生成出来,再扔掉前100万行。数据量一大,这个排序操作就是灾难。
延迟关联的优化思路,是先用覆盖索引查LIMIT的目标主键,再用主键关联回原表取完整数据:
sql复制SELECT o.id, o.order_no, o.user_id, o.amount
FROM orders o
JOIN (
SELECT id
FROM orders
WHERE created_at >= '2024-01-01'
ORDER BY created_at DESC
LIMIT 1000000, 20
) tmp ON o.id = tmp.id
ORDER BY o.created_at DESC;
为什么它更快?因为内层子查询只SELECT id,这个字段如果正好在索引里,MySQL就可以在索引上完成排序和LIMIT,完全不需要访问表数据。等找出20个主键之后,再一次性回表取完整行。从执行顺序的角度理解,内层子查询把LIMIT的截断动作提前到了子查询自身,外层JOIN接收到的已经是极小的数据集。这个优化在面对几千万行、几百页深翻页时尤其有效。
3.5 用EXISTS替代IN和DISTINCT
最后分享一个看起来很小但实际能救命的优化习惯。当子查询数据量巨大时,IN和EXISTS的选择经常影响性能。比如查“所有有有效订单的用户信息”:
sql复制SELECT *
FROM users
WHERE id IN (
SELECT DISTINCT user_id
FROM orders
WHERE status = 'valid'
);
这个写法在MySQL的老版本里,会把子查询结果物化成一个临时表,再去做IN判断。如果子查询返回的数据集很大,临时表本身和JOIN判断都会开销很大。更推荐的方式是EXISTS:
sql复制SELECT *
FROM users u
WHERE EXISTS (
SELECT 1
FROM orders o
WHERE o.user_id = u.id
AND o.status = 'valid'
);
EXISTS的处理逻辑是:对users表中的每一行,去orders表里按user_id和status条件找一找,只要找到一条就返回TRUE,立即停止寻找。配合orders表上的(user_id, status)联合索引,查询效率会非常高。这里还有一个关键点:EXISTS通常能避免DISTINCT,因为它本身只关心“存不存在”,不需要把所有符合条件的user_id全部列出来再去做去重。放在执行顺序的语境里,就是你直接在JOIN阶段的关联条件里就把状态判断做掉了,避免把主表全部行投影出来之后再做一次跨表过滤。
4. 常见问题与排查技巧实录
4.1 用EXPLAIN验证执行顺序相关优化
我平时排查慢SQL,第一步永远是EXPLAIN。EXPLAIN的结果会告诉你MySQL实际采用的物理执行计划,比如访问类型、扫描行数、是否使用索引、Extra列里有没有Using filesort或者Using temporary。把这些信息跟逻辑执行顺序对照,很容易定位问题。
重点看几个字段。type列,从好到差大致是system、const、eq_ref、ref、range、index、ALL。如果看到ALL,说明全表扫描,通常意味着WHERE和JOIN条件没有用上索引,数据到了这一步完全没有削减。key列,看实际命中的索引。rows列是优化器估算的扫描行数,把它跟上一级的rows做对比,就能理解数据漏斗的每一级损耗在哪里。Extra列尤其关键,Using temporary说明GROUP BY或DISTINCT走了临时表;Using filesort说明ORDER BY没走索引排序;Using index说明查询是覆盖索引,这个是加分项。
一个常见的分析流程是这样的:先看查询涉及的所有WHERE条件,确认每个条件是否能命中索引;再查GROUP BY和ORDER BY字段,判断是否和表上的索引定义匹配;最后看LIMIT有没有深分页风险。这三步做完,慢SQL的原因基本能锁定。注意,EXPLAIN显示的rows是估算值,跟实际扫描行数有偏差,但在定位问题方向上它是够用的。
4.2 常见慢SQL模式与执行顺序问题对照
排查多了,会发现大部分慢SQL都能归到少数几种模式。我把最常见的现象和它们对应的执行顺序问题整理成了一张速查表,后面再遇到类似问题可以直接对照:
| 现象 | 执行顺序中的问题 | 优化方向 |
|---|---|---|
| 查询慢但结果集很小 | WHERE过滤太晚,数据在JOIN阶段就膨胀 | 把条件前移,增加索引 |
| GROUP BY后出现Using temporary | GROUP BY字段未匹配索引 | 创建联合索引覆盖分组字段 |
| ORDER BY出现Using filesort且数据量大 | 排序在SELECT之后大结果集上执行 | 为排序字段建索引,或先缩小数据 |
| SELECT * 且扫描行数巨大 | SELECT投影过宽,覆盖索引失效 | 只查必要列 |
| 子查询在SELECT里导致逐行执行 | 聚合动作发生在投影阶段 | 改写为JOIN和GROUP BY提前聚合 |
| LIMIT深分页极慢 | ORDER BY大排序后才截断 | 延迟关联 |
| 多个OR条件导致全表扫描 | WHERE条件无法合并索引 | 改IN或UNION ALL |
| 字段隐式转换导致索引失效 | WHERE阶段无法走索引 | 统一字段类型 |
这张表是我实际工作里自己总结的一份“排查索引”,不是教科书上的标准列表。每次接到慢SQL工单,我先把SQL按这张表对一遍,很多时候不需要看很多行就能判断问题。把执行顺序的每一步拆开,跟EXPLAIN的输出对照,这种排查方式非常高效。
4.3 一个完整排查案例实录
最后分享一个真实的排查过程,方便你看到执行顺序知识是怎么被一步步用起来的。当时业务报了一个页面查询很慢,SQL简化后是这样:
sql复制SELECT
o.order_id,
o.amount,
c.coupon_name
FROM orders o
LEFT JOIN order_coupons c ON o.id = c.order_id
WHERE o.pay_time >= '2024-06-01'
AND o.status = 'paid'
ORDER BY o.pay_time DESC
LIMIT 20;
乍一看SQL很简单,订单表关联优惠券表,过滤条件也都写了,LIMIT也只有20。但EXPLAIN显示,orders表type是ALL,扫描行数估算为400万行,Extra里有Using filesort。这就能看出问题:WHERE条件里的pay_time字段虽然建了索引,但是跟status一起使用以后,优化器可能只选择了其中一个,甚至某个字段的过滤能力太弱导致优化器干脆选择了全表扫描。更重要的是,ORDER BY pay_time和WHERE里对pay_time的范围过滤产生了冲突,导致排序没法走索引,最终触发了大结果集filesort。
我当时做的调整有几个方面。第一,确认order表上有没有(pay_time, status)这样的联合索引,如果没有就建一个,让WHERE过滤能直接用上索引。第二,把ORDER BY o.pay_time DESC改成和索引方向一致。第三,查看业务是否真的需要LEFT JOIN,如果所有订单都会关联优惠券,换成INNER JOIN可以让优化器有更多连接顺序的选择,也避免了连接阶段生成大量NULL扩展行。这几步调整完之后,EXPLAIN的type从ALL变成了range,Extra里的Using filesort也消失了,查询从原来的2.9秒降到了0.05秒。
这个案例让我印象很深,因为它不是那种特别偏门的优化技巧,每一步都是执行顺序的基础知识:WHERE阶段尽量把数据缩小,排序阶段尽量走索引,JOIN阶段避免无畏的数据膨胀。把这些基础做到位,SQL的提速幅度常常远超预期。你不需要背什么高深的调优理论,只需要把11步执行顺序当作一张地图,知道每一步该干什么、不该干什么,再配合EXPLAIN确认优化器实际的选择,九成的慢SQL都能在十分钟内定位清楚。这也是我至今还愿意花时间整理这套笔记的原因。
