一直没想明白一个问题:同样是查一组数据,为什么有人写JOIN秒回,有人写JOIN直接拖垮整个接口;同样是查不在某个集合里的记录,为什么IN和NOT IN的表现天差地别;还有一个需求,分了两段SQL查出来都很快,合并成UNION后却等了好几秒。如果你经历过这些,那这篇MySQL查询优化的内容应该正好对你胃口。
无论你是刚接触MySQL的开发新人,还是维护过千万级数据表的后端工程师,JOIN、子查询、UNION这三个关键字都会反复出现在你的SQL里。它们并不高深,但被误用的概率极高。很多人习惯性拿到需求就写多表JOIN,遇到集合判断就套子查询,需要合并结果就上UNION,却没想过这三种写法的底层执行逻辑完全不同,性能差异可以到达几个数量级。这篇文章会把三者的区别、适用场景、优化器行为、常见坑位一次讲透,并配合实际可复现的SQL用例和EXPLAIN解读,帮助你在写SQL时先对执行成本有个大致预判,而不是等线上慢查询出来了再去救火。
1. 三种写法的本质差异:先把执行逻辑在脑中跑一遍
在谈性能之前,我建议你先在心里问一个问题:我到底想让数据库做什么?很多SQL写出来慢,不是数据库不行,而是开发者根本没想清楚自己要的是"行的扩展"还是"列的扩展",是"先得到集合A再判断和集合B的关系",还是"把两个结果堆在一起"。
JOIN的核心动作是配对,它做的事情是“横向扩展”。假设有两张表,一张是用户表,一张是订单表,通过用户ID配对后,输出结果里的列是"用户表的列 + 订单表的列",行的数量取决于匹配关系,一对一返回一行,一对多就会复制出多行。这就像你把两本电话簿用同一个手机号拼在一起,如果一个人名下有三部电话,就会在结果里出现三行。
子查询的核心动作是分步,先算出内部的结果集,再把这个结果交给外层去用。最典型的三类:标量子查询返回一个值,列子查询返回一列值,表子查询返回一张临时表。它相当于先拿一张小纸条,把部门ID列表抄下来,再去主表里挨个对照。由于MySQL优化器对子查询的处理策略特别多,同一个子查询在不同版本、不同索引条件下表现可能迥异,这也是后面要重点展开的地方。
UNION的核心动作是纵向堆叠,把所有分支的结果集按行上下拼接,唯一的硬性要求是每个分支的列数相同、对应列的类型兼容。判断用JOIN还是UNION其实有个笨办法:你想让结果变宽,找JOIN;你想让结果变长,找UNION。如果你最初只想要两段互相独立的数据列表放在同一份报表里,UNION显然是语义最贴切的选择,这时候硬要用JOIN反而会把问题复杂化。
这三种写法的性能差异,本质上来自它们各自引发的执行动作不同。JOIN会关心驱动表和被驱动表的连接方式;子查询会关心是否被优化为半连接、物化表还是逐行执行;UNION会关心是否产生去重临时表。把它们先看清,才知道后面怎么调。
| 对比项 | JOIN | 子查询 | UNION |
|---|---|---|---|
| 扩展方向 | 横向加列 | 纵向过滤或提供集合 | 纵向加行 |
| 核心动作 | 行配对 | 先算范围再判断 | 结果集堆叠 |
| 典型应用 | 多表字段合并 | where/from条件筛选 | 分表、分条件数据聚合 |
| 常见痛点 | 一对多行数膨胀 | 相关子查询逐行执行 | UNION默认去重开销 |
| 最常出现的执行计划信息 | Nested Loop、Hash Join | Dependent subquery、Materialize | Using temporary、Using filesort |
1.1 JOIN是对行的配对,理解一对多放大是第一步
很多慢SQL都是因为JOIN到了一张多明细的表上。比如订单主表和订单明细表关联,一个订单有5条明细,JOIN后原来一行订单会被撑成5行。如果后面还有聚合计算,这会直接放大聚合代价。所以在写JOIN的时候,第一步该确认的不是用什么连接类型,而是连接双方在关联键上是否唯一。连接键有唯一性约束的那边,叫做"维度侧";没有唯一性、会产生多行匹配的那边,叫做"事实侧"。最理想的情况是主键或业务编号驱动小表,去连接唯一键。
1.2 子查询是"先定范围再行动",但要小心它变成逐行执行
子查询在语义上是"分步"的,但优化器不一定会真的先物化一个集合。MySQL会尝试把很多子查询改写成JOIN或半连接,改不了的时候就可能变成相关子查询,也就是外层每一行都触发一次内层查询。这也是"子查询慢"最大的来源。后面我会用EXPLAIN带你识别它到底走了哪条路。
1.3 UNION是结果集叠加,别让它偷偷多做一件去重的事
UNION和UNION ALL最大的差异就一个字:去重。UNION默认会去掉重复行,UNION ALL不会。别小看去重,为了实现它,MySQL可能要把结果放进临时表,给结果列建唯一索引,甚至走文件排序,数据量一大就会非常难受。当你能用业务逻辑保证两个分支的结果没有交集时,直接上UNION ALL是更务实的选择。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. JOIN性能调优:驱动表、索引与"为什么大厂不建议多用多表JOIN"的工程原因
回到JOIN本身的性能问题。MySQL执行JOIN,最常见的方式还是嵌套循环,也就是从驱动表取一行,去被驱动表里找匹配行,循环往复。等值连接且关联列没索引时,MySQL 8.0以后会退化成Hash Join,这比逐行扫描强一些,但本质上还是因为没有可用的连接索引。如果你在EXPLAIN里看到了Hash Join,先别高兴,第一反应应该是:右边那张表的关联列是不是漏建索引了?
这个执行模型决定了一条最基本的优化铁律:驱动表要小,被驱动表的连接列必须有索引。原因很直白,驱动表每多一行,都要去被驱动表做一次索引查找,所以驱动表的行数对查询总成本是近似倍数关系的影响。MySQL优化器虽然会根据表统计信息自动选择驱动表,但统计信息不一定准,或者因为表数据分布变化,它也可能选错。遇到这种情况,可以先尝试ANALYZE TABLE刷新统计信息,如果仍然选错,再用STRAIGHT_JOIN强制指定连接顺序来验证。
2.1 不要被表面语义带偏:LEFT JOIN和RIGHT JOIN该怎么选
需要关联表时,很多人随口就写LEFT JOIN,但LEFT JOIN和RIGHT JOIN本质是同一个东西的镜像。LEFT JOIN是以左表为保留表,右表没匹配上就补NULL;RIGHT JOIN则是反过来。既然可以靠调换表的顺序表达同样语义,绝大多实践里统一用LEFT JOIN就够了,RIGHT JOIN显得多余且容易让阅读者犯迷糊。
真正要注意的坑是:LEFT JOIN在加了对右表字段的过滤后,会被数据库“悄悄”变成INNER JOIN。比如下面这条SQL:
sql复制SELECT u.id, u.name, o.order_no
FROM user u
LEFT JOIN orders o ON o.user_id = u.id
WHERE o.status = 1;
这条SQL的语义其实已经不是左连接了。因为WHERE里对右表的status做了非空筛选,NULL值会被过滤掉,就变成了只保留有匹配订单的用户,效果和INNER JOIN一致。优化器会利用这个等价关系直接转换成内连接来执行。如果你本意是想统计所有用户,哪怕没有订单也要出现,这里就会漏数据。
用LEFT JOIN还有一个常见的反直觉点:判断"某个用户没有任何订单"时,要用WHERE右表主键IS NULL来找,比如:
sql复制SELECT u.id, u.name
FROM user u
LEFT JOIN orders o ON o.user_id = u.id
WHERE o.id IS NULL;
这个技巧在很多需要排除场景时是有效的,而且比NOT IN子查询更容易走好索引。但它也要求被驱动表orders.user_id上有索引,否则左表每行都得全表扫右表。
2.2 连接条件要不要走索引,直接看被驱动表的关联列
连接索引问题在实操中最常见。下面用两个例子说明:
无索引时EXPLAIN的结果往往是:
text复制+----+-------------+-------+------+---------------+------+---------+------+--------+-------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+-------+------+---------------+------+---------+------+--------+-------+
| 1 | SIMPLE | u | ALL | NULL | NULL | NULL | NULL | 100000 | NULL |
| 1 | SIMPLE | o | ALL | NULL | NULL | NULL | NULL | 5000000| Using where |
+----+-------------+-------+------+---------------+------+---------+------+--------+-------+
两张表都是ALL全表扫描,这是非常危险的信号。当你看到被驱动表type是ALL,第一反应就是检查JOIN条件列上到底有没有索引。常见原因包括:关联列的两个字段字符集不一致、排序规则不一致、类型不一致(比如一边是int一边是varchar),这些都会让索引失效。我排查慢SQL时经常遇到表结构设计早年间没统一规范,A表订单号的user_id是int,B表却建成了varchar,连JOIN都走不上索引,转换后再关联就恢复了。
反过来,正确索引下EXPLAIN会看到被驱动表的type变成ref或者eq_ref,ref里能看到驱动表字段的名字,这说明每一行进入被驱动表时都能走索引查询。
2.3 大厂不推荐多表JOIN,不是JOIN本身的错
“为什么大厂不建议使用多表join”,这个问题的答案不在语法层,而是在工程层。单库内两三个小表JOIN本身并不是什么大问题。但一旦表数量超过3张、数据量又大,问题就来了:一是每一层JOIN都会放大或缩小估算误差,优化器依据的行数估算误差会层层累积;二是当系统做了分库分表后,数据被拆到不同物理库,SQL层面的JOIN根本没法跨库执行,只能拆成多次查询在应用层做内存关联;三是多表JOIN很容易掩盖数据模型的问题,让结构演进变得更难。所以大型团队会出于整体架构考虑,要求默认避免多表JOIN,而不是说JOIN这个关键字必须禁用。
那什么场景可以放心JOIN?两张表有一侧连接列是唯一索引,或者右表数据量小,这些场景下JOIN很舒服。超出这个范围,比如A、B、C三张大表全都要参与关联,我更倾向于拆成多次单表查询。别觉得多一次网络往返就亏了,配合Redis或应用内缓存,多数业务场景的整体延迟是可控的,而且查询的可读性和可维护性会大幅提升。
3. 子查询:从IN与EXISTS的经典争论到优化器的自动改写
子查询是另一个被过度简化的话题。网上总有人总结“用EXISTS不用IN”“用JOIN不用子查询”,但MySQL优化器近几年变化很大,很多结论已经过时。实际怎么走,不能靠口诀,要看数据库版本和EXPLAIN。
3.1 IN子查询到底怎么执行?可能是半连接,也可能是物化
先看一个最常见IN写法:
sql复制SELECT id, name
FROM user
WHERE dept_id IN (SELECT id FROM department WHERE status = 1);
在MySQL 5.6之后,这类不相关的IN子查询会被重写成半连接(semi-join)。所谓半连接,可以理解为“如果右表有匹配行就返回左表一行,不关心右表有多少行匹配”。因为不关心匹配次数,所以它天然避免了JOIN后的行膨胀问题,也不需要加DISTINCT去重。EXPLAIN输出里可能看到“Start temporary”和“End temporary”这样的标记,这就是半连接优化生效的迹象。
优化器还会根据表大小选择物化(Materialization)策略,也就是先把department表中满足条件的id查出来,放到临时表里,再去外层表匹配。如果物化表很小,MySQL会为它建立哈希索引,整体效率相当高。所以说,现代MySQL里简单的IN子查询,并不天然比JOIN慢,甚至经常比JOIN好用。
真正要警惕的是另一类子查询——相关子查询。
3.2 相关子查询慢在哪?看EXPLAIN里的Dependent subquery就明白了
相关子查询是指内层查询需要引用外层查询的列,外层每返回一行,内层都可能要执行一次。像这种查每个用户最近一笔订单的经典写法:
sql复制SELECT u.id, u.name,
(SELECT order_no FROM orders o
WHERE o.user_id = u.id
ORDER BY o.create_time DESC LIMIT 1) AS latest_order_no
FROM user u;
外层user表如果有10万行,内层orders查询理论最多执行10万次。虽然每次如果走了(user_id, create_time)的联合索引会很快,但10万次索引查找叠加起来依然不低,而且如果内层没有合适索引,每次都是一次全表扫描,那就是灾难级的性能问题。在EXPLAIN里,你会看到select_type列为DEPENDENT SUBQUERY,Extra里可能还有Using where之类的标志。看到这个标志,就该反思是否能用关联查询、窗口函数或者预先聚合的临时表替代。
MySQL 8.0提供了窗口函数,像ROW_NUMBER()按用户分组排序打标签,很多“取每个分组最新一条”的子查询写法都能被窗口函数优雅替代。如果公司用的还是MySQL 5.7,那没有窗口函数,只能靠派生表先找最大时间再关联,这也是常见的优化手段。
3.3 把子查询改成JOIN时,小心行数膨胀这个新坑
再来看看反过来的一种情况:有人遇到子查询慢,第一反应就是改成JOIN,但如果忽略了关联字段不唯一,就会踩到行数膨胀的坑。
举个例子,要查所有在售商品类目下有哪些商品:
sql复制SELECT DISTINCT c.id, c.name
FROM category c
JOIN product p ON p.category_id = c.id
WHERE p.status = 1;
商品表里同一个类目下有成千上万个在售商品,JOIN之后类目会被复制出成千上万行,再加DISTINCT去重。DISTINCT是个很容易引发Using temporary的操作,最终可能比原来的EXISTS子查询慢得多。下面这个EXISTS写法在语义上和上面JOIN等价,但内层一匹配到就结束,不会制造重复行:
sql复制SELECT id, name
FROM category c
WHERE EXISTS (
SELECT 1 FROM product p
WHERE p.category_id = c.id AND p.status = 1
);
所以不是说子查询改JOIN就一定更好。它们各自的适用条件不同。判断标准只有一条:EXPLAIN看执行计划里到底走了什么索引,产生了多大的行数估算,Extra里有没有临时的排序去重动作。
3.4 更新场景里的子查询限制,绕过方式要记牢
热搜里有一个“mysql中更新子查询”的词条,这涉及一个几乎所有MySQL开发者都会遇到的报错。先看SQL:
sql复制UPDATE employee
SET salary = salary * 1.1
WHERE dept_id IN (SELECT dept_id FROM employee WHERE manager_id = 100);
这条SQL很多数据库能跑,但MySQL会直接报错:
text复制You can't specify target table 'employee' for update in FROM clause
原因很明确:MySQL不允许在UPDATE一张表时,同一语句中又对该表做SELECT。我见过不少人在这个报错上翻车。常见的绕法有三种,第一种是包一层派生表,让MySQL先物化出一张临时结果:
sql复制UPDATE employee
SET salary = salary * 1.1
WHERE dept_id IN (
SELECT dept_id FROM (
SELECT DISTINCT dept_id FROM employee WHERE manager_id = 100
) AS tmp
);
第二种是改成JOIN UPDATE:
sql复制UPDATE employee e
JOIN (
SELECT DISTINCT dept_id FROM employee WHERE manager_id = 100
) d ON d.dept_id = e.dept_id
SET e.salary = e.salary * 1.1;
两种方式都可以。我更推荐JOIN UPDATE,因为它结构更清晰,而且容易在UPDATE的同时关联其他过滤条件。还有一种绕法是直接改成两条SQL,先查出目标主键集合,再在应用侧二次执行UPDATE,但要注意两条SQL之间有事务边界,业务上需要一致性的地方不要拆得太随意。
4. UNION与UNION ALL:去掉重复要付出多少代价,你真的清楚吗
UNION最容易被忽视的是默认去重。很多人把几个SELECT分支上下拼在一起,想当然用UNION,然后发现数据量一大就慢得离谱,还找不到原因,其实问题就出在这个默认去重动作上。
4.1 UNION默认去重的实现代价
MySQL要完成UNION的去重,常见的做法是把各个分支的结果插入一个临时表,临时表上会对所有结果列建立唯一索引。如果结果集数据量大,内存临时表装不下,就会落到磁盘临时表,并伴随文件排序。EXPLAIN这条UNION查询时,你会看到Extra里出现Using temporary,有些版本还会显示Using filesort。这两个关键词出现在同一行时,基本可以断定这条UNION的主要成本不是查询本身,而是去重排序。
UNION ALL则完全不同。它只需要把所有分支结果依次拼接,完全没有去重步骤。在分支之间业务上保证绝对没有重复数据的情况下,UNION ALL总是优于UNION。所以我的习惯是:默认写UNION ALL,只在业务确实需要跨多个结果集去重时才用UNION。
4.2 UNION和UNION ALL的适用场景该怎么判断
怎么判断需不需要去重?要看业务本质。比如分表结构:一月流水表存一月的单,二月流水表存二月的单,两个表按月份物理隔离,月份边界上没有重复,这时合并展示就肯定用UNION ALL。再比如一个“潜力客户名单”和一个“活跃客户名单”合并导出,两个名单来源规则不同,同一个人很可能同时出现在两份名单里,而最后的报表要求每个客户只能出现一次,这种场景才真正轮到UNION负责全局去重。
不过还有一个很多时候可以避免UNION的优化思路:如果两个结果集本身来自同一张表,只是筛选条件不同,比如一个是状态为1的数据,一个是状态为2的数据,而且两个条件覆盖了全集,那么完全可以用一次GROUP BY加条件聚合来替代两次扫描。用UNION意味着表被扫了两遍,而用条件聚合只扫一遍,性能差距也是实打实的。
4.3 日常写UNION最容易翻车的两个细节
第一个细节是ORDER BY和LIMIT的作用域。UNION的每个分支如果只写ORDER BY而不带LIMIT,MySQL优化器会直接忽略这个排序,因为对整体结果来说没有意义。例如:
sql复制SELECT id, name FROM user_a
ORDER BY create_time DESC
UNION ALL
SELECT id, name FROM user_b;
你以为user_a分支排好序了,实际执行时排序被忽略。哪怕是加上LIMIT的排序也要注意,LIMIT只会作用于当前分支,不是作用于整个UNION结果。正确的整体排序应该把整个UNION包装成子查询,再在外层排序:
sql复制SELECT * FROM (
SELECT id, name, create_time FROM user_a
UNION ALL
SELECT id, name, create_time FROM user_b
) t
ORDER BY t.create_time DESC
LIMIT 10;
第二个细节是collation冲突,也就是热搜词里那个“illegal mix of collations for operation 'union'”。即使是同一种字符集,但两列的排序规则不一样,也常常会触发这个报错,比如一张表用了utf8mb4_general_ci,另一张表用了utf8mb4_unicode_ci。修复方式很简单,给其中一侧加上COLLATE关键字,让参与比较的列统一排序规则,比如:
sql复制SELECT col_a COLLATE utf8mb4_unicode_ci FROM table_a
UNION ALL
SELECT col_b FROM table_b;
或者用CONVERT把列整体转成目标字符集再合并。这里想提醒的是,ETL接多张表数据时,最好先检查源表的字符集和排序规则。结构一致不意味着排序规则一致,结构一致只保证字段名一致,排序规则不同照样在UNION时冲突。
5. 实战案例:订单明细聚合统计下,JOIN和子查询的真实表现对比
理论说了一堆,还是要落到一个真实场景中验证。我拿一个非常经典的需求来演示:查询每个客户最近一笔订单,以及这笔订单的明细总金额。
为了模拟实际数据量,假设user表有10万行,orders表有500万行,order_item订单明细表有2000万行。orders表有用户ID、订单号、下单时间,order_item表有订单号、商品金额字段。这个需求同时涉及了“取最近订单”和“对明细做聚合”两个逻辑,恰恰能把JOIN、子查询的优劣都暴露出来。
5.1 方案一:先JOIN明细再分组,行数膨胀导致失去先手
很多人的第一反应是直接把三张表JOIN起来再GROUP BY:
sql复制SELECT u.id, u.name, o.order_id, SUM(oi.amount) AS total_amount
FROM user u
JOIN orders o ON o.user_id = u.id
JOIN order_item oi ON oi.order_id = o.order_id
WHERE u.id = 12345
GROUP BY u.id, o.order_id;
单看某个用户时问题不算大,但如果去掉WHERE条件、全量跑,这个SQL的执行逻辑就变成:先把orders和order_item按订单号关联,一个订单如果有10条明细,结果集就会膨胀10倍,然后还要在膨胀后的集合上做GROUP BY,所有临时聚合数据都要进临时表,代价非常高。这个场景下即便有索引,由于中间结果集已经被放大,后续计算仍然吃力。
5.2 方案二:先聚合子查询再把结果JOIN回去,先缩后连
优化的核心思路是先缩后连,而不是先连再缩。我们应该先把order_item按订单号聚合出一行一订单的总金额,这一步能大幅压缩中间结果集,然后再把结果和orders、user关联:
sql复制SELECT u.id, u.name, o.order_id, t.total_amount
FROM user u
JOIN orders o ON o.user_id = u.id
JOIN (
SELECT order_id, SUM(amount) AS total_amount
FROM order_item
GROUP BY order_id
) t ON t.order_id = o.order_id
WHERE u.id = 12345;
如果存在过滤条件只查一个用户,我甚至建议先查出该用户的最近订单ID,再去明细表里只聚合那少量订单,配合二级索引会快得非常明显。这种写法的好处是:子查询中先完成了最重的分组聚合,聚合后每个订单只剩一行,再去做关联时不会把数据行数放大。执行计划里GROUP BY的临时表范围被限制在子查询内,外层JOIN的扫描行数明显下降。
5.3 三种方案的EXPLAIN信息对照:不要靠猜,要看rows和Extra
把上述场景展开来看,几个关键对比维度我已经整理成下表,方便你在自己的库里复现时对照:
| 方案 | 中间结果放大 | 是否出现GROUP BY临时表 | 是否容易走覆盖索引 | 典型执行计划关键词 |
|---|---|---|---|---|
| 先JOIN明细再分组 | 放大明细倍数 | 是,范围大 | 依赖明细表索引 | Using temporary; Using filesort |
| 先聚合明细再关联 | 缩小为订单数 | 是,但范围小 | 更容易 | 子查询内有Group By,外层走ref |
| 窗口函数ROW_NUMBER筛选最近一单 | 不放大 | 否 | 较容易 | Using index condition / Using temporary视版本而定 |
看完表你大概能体会:第一种方案是天然劣势,因为它把明细层放大后的结果集作为后续计算输入。第二种方案的精髓在于让每个维度只保留一行,避免因多行匹配造成的基数膨胀。第三种方案在MySQL 8.0里也值得尝试,适合取“每组最新一条”且明细表巨大、不想先GROUP BY的场景。
5.4 建索引时不要只盯单列,联合索引才能兼顾过滤和排序
这一节要说说索引该怎么设计。很多人在这个案例里只给order_item.order_id建了单列索引,给orders.user_id建了单列索引,看起来没错,但遇到“查某个用户最近订单”这类需求时,可能会先按user_id把该用户所有订单找出来,再在内存里按时间排序。如果该用户历史订单特别多,排序就不太舒服。更合理的设计是在orders表上建联合索引 (user_id, create_time),这样既能用user_id快速过滤,又能直接利用create_time字段避免排序,然后配合LIMIT 1就可以很快取到最近订单。
明细表order_item则适合在order_id上建普通索引之外,再考虑对高频聚合字段加上覆盖索引,比如 (order_id, amount)。这样SUM(amount)时只需扫描索引页,不需要回表读取整行数据。Extra里如果能看到Using index,说明这次查询已经通过覆盖索引拿到了所有需要的列,这是非常理想的信号。
6. EXPLAIN读得细,选型才不靠猜:一眼定位性能信号的实操方法
前面反复提到EXPLAIN,这里系统讲一下我日常是怎么读它的。EXPLAIN输出了一长串字段,不需要每个都盯,真正影响选型判断的核心是type、key、rows、Extra这四个,把它们看明白,80%的性能问题都有谱了。
6.1 type从system到ALL,代表访问方式的阶梯
type字段表示MySQL找到所需行的方式,常见取值从上到下大致是:system、const、eq_ref、ref、range、index、ALL。
system和const是最理想的状态,说明表里最多一行匹配,或按主键直接定位到一行。eq_ref是JOIN场景里被驱动表通过主键或唯一索引等值匹配时的状态,效率很高。ref说明使用了普通二级索引等值匹配,每次可能返回多条记录,但也够用。range表示走索引范围扫描,比如使用了BETWEEN、IN、>以及前缀模糊匹配的边界查询。index表示扫描了整棵索引树,通常是因为SELECT的列刚好都在索引里,虽然扫描行数不少,但至少不用回表。ALL就是最不想看到的全表扫描,这是优先要消灭的状态。
看到某张表type是ALL,第一反应是查它的连接条件或WHERE条件上有没有可用索引。如果明明有索引却还是ALL,那就可能是写错了字段类型导致隐式转换,也可能是选择性太差,优化器认为走索引还不如全表扫。最后这种情况发生在数据分布严重倾斜时,可能需要用FORCE INDEX或者改写SQL来引导。
6.2 rows和filtered结合看驱动表有没有选错
rows是优化器估算要扫描的行数,filtered表示经过WHERE条件过滤后剩余的比例。估算扫描行数乘以剩余比例,大致就是这张表阶段性的输出行数。如果驱动表估算出来的输出行数非常大,但它在JOIN里却被放在前面驱动,那就需要警惕:是不是该用数据量更小的表做驱动表?
如果EXPLAIN显示的连接顺序不符合预期,而你又想强行验证“小表驱动大表”是否更快,可以用STRAIGHT_JOIN强制让表按书写顺序连接。比如:
sql复制SELECT STRAIGHT_JOIN u.name, o.order_no
FROM orders o
JOIN user u ON u.id = o.user_id;
这样会强制orders先驱动。它主要用来做对比测试,线上不建议长期保留,因为表数据量变化后,强制顺序可能反而不优。
6.3 Extra里的几个关键词值得时刻警惕
Extra字段是最能体现执行细节的地方。我只要看到Using temporary或者Using filesort出现在两张以上大表的复杂查询里,就会下意识警觉。Using temporary意味着这条SQL为了去重、排序或分组,需要建立临时表;如果临时表超过阈值还会落到磁盘,性能会下一个台阶。Using filesort意味着排序没能利用索引,数据在内存或磁盘中额外排了一次序。注意Filesort未必一定发生在磁盘,小数据集在内存排序也很快,但它是一个重要信号,提示应该思考能否通过索引直接消除排序。
Index condition是MySQL 5.6之后引入的索引下推优化,Extra中看到Using index condition不是坏事,说明部分WHERE条件在存储引擎层就被过滤了。Using index则是前面提过的覆盖索引,属于加分项。
排查临时表和文件排序时可以用一个小技巧:把慢SQL拿出来,逐步去掉GROUP BY、ORDER BY、DISTINCT再看Extra变化。是哪一步引入了Using temporary,通常一眼就能定位。这个定位方法在公司里带新人时我反复推荐过,比自己瞎猜有用得多。
6.4 结合慢查询日志和Profile把问题锁定到阶段
EXPLAIN只是执行计划的预判,真正确认问题还需要实际运行时间佐证。线上排查时我会先看慢查询日志,MySQL里开启方式很简单:
sql复制SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 1;
long_query_time设成1秒,超过1秒的SQL会被记录在慢日志文件里。拿到慢SQL后先用EXPLAIN看执行计划,再从慢日志中确认扫描行数与实际执行时间。
如果进一步想知道时间主要花在哪个阶段,可以用PROFILE。MySQL 8.0之后SHOW PROFILE虽然被标记为废弃,但很多环境仍然可用;更通用的做法是查询performance_schema中的events_statements_history_long。不过对大多数场景,EXPLAIN加上实际执行时间的对比已经足够判断SQL方向对不对。我更推荐大家平时有意识地拿同一条SQL的不同写法做对照,把EXPLAIN的预估和真实执行时间同时记录,形成自己的判断直觉。
在我这几年实际写SQL和排查慢查询的过程里,最大的体会是:没有任何一种查询写法可以通吃所有场景。我现在的习惯是,拿到一个查询需求先花十几秒想清楚需要的行和列分别是什么形态,再用EXPLAIN验证,看到临时表或文件排序的迹象就换个写法试试,而不是依赖“JOIN一定慢”“子查询一定慢”这样的刻板结论。不同的数据分布、索引设计和MySQL版本都会改变答案,多留几组不同写法的测试SQL,优化起来才能真正心里有数。
