写业务代码时,嵌套查询几乎是绕不开的写法:一个报表要关联五六张表,外层再套两三个子查询做过滤统计,SQL动辄几十行。这种语句一旦跑得慢或者结果不对,直接看SQL文本往往一头雾水,因为你看不到数据库到底是怎么执行的。EXPLAIN的作用就在这里,它能把优化器的执行计划摆在你面前,告诉你每一步用了什么索引、预估扫了多少行、连接顺序是什么。掌握了它,调试复杂嵌套查询就不再靠猜。

嵌套查询到底慢在哪里:先理解执行模型
很多人以为嵌套查询的性能问题出在SQL写得太长,其实真正的瓶颈通常来自执行方式。对于相关子查询(Correlated Subquery),也就是内层查询依赖外层每一行数据的写法,如果优化器没有做去关联优化,数据库可能要对内层子查询重复执行成千上万次。假设外层表有一万行,内层子查询每次扫描一百行,总代价就是一百万行的访问量,这是嵌套查询变慢的最典型原因。
第二类问题出在FROM子句里的子查询,也就是派生表(Derived Table)。在MySQL 5.6之前的版本,派生表会被物化成一张临时表,而临时表是没有索引的,外层查询对它的关联只能全表扫描。即便在新版本中,某些包含聚合函数、LIMIT或UNION的派生表依然无法合并到外层查询,同样会走物化路径。第三类问题是大结果集的IN子查询,如果优化器选择先执行子查询再对外层做半连接,子查询结果集过大时,临时表的构建和匹配开销会急剧上升。
理解了这三类执行模型,再去看EXPLAIN的输出就有了明确方向:你要找的是哪一步在重复扫描、哪一步在物化、哪一步没走上索引。
EXPLAIN的基本用法与核心字段解读
用法本身很简单,在任何SELECT语句前面加上EXPLAIN关键字即可,数据库不会真正执行查询,只返回执行计划。MySQL的输出是一张表格,每行代表一个查询块(外层查询、每个子查询、每个派生表各占一行),PostgreSQL则输出树状结构,从内往外读。下面以MySQL为例,先看一条典型语句:
EXPLAIN
SELECT o.order_no, o.amount
FROM orders o
WHERE o.customer_id IN (
SELECT c.id
FROM customers c
WHERE c.level = 'VIP'
);执行计划中最需要盯紧的是这几个字段。第一个是type,也就是访问类型,从好到差大致是const、eq_ref、ref、range、index、ALL。如果嵌套查询的某一行出现了ALL,说明这一步在做全表扫描,往往是问题所在。第二个是rows,它是优化器预估的扫描行数,两行数据的rows相乘,可以粗略估算嵌套循环的总代价。第三个是Extra,这里的信息量最大:出现Using temporary表示用了临时表,Using filesort表示需要额外排序,出现Dependent subquery则说明子查询是相关的、会被外层逐行驱动,看到这个就要警惕了。
还要注意select_type列。DERIVED表示派生表,SUBQUERY表示非相关子查询,DEPENDENT SUBQUERY表示相关子查询。如果原本写的不是相关子查询,执行计划里却出现了DEPENDENT SUBQUERY,说明优化器把它改写了,这种情况在老版本MySQL的IN子查询中很常见,是性能突然下降的高发原因。
实战案例:定位一个慢的派生表查询
来看一个实际场景。订单报表需要统计每个客户最近的订单金额,同时又只关心消费总额超过一万的客户,很多人会这样写:
SELECT t.customer_id, t.last_amount
FROM (
SELECT customer_id, MAX(id) AS last_id
FROM orders
GROUP BY customer_id
) t
JOIN orders o ON o.id = t.last_id
JOIN (
SELECT customer_id, SUM(amount) AS total
FROM orders
GROUP BY customer_id
) s ON s.customer_id = t.customer_id
WHERE s.total > 10000;这条语句在orders表有百万行数据时可能要跑十几秒。用EXPLAIN一看,两个派生表的select_type都是DERIVED,Extra里出现了Using temporary,说明orders表被完整扫描并物化了两次。问题的根源是两个派生表各自独立聚合,同一份数据被读了两遍。
解决办法是把两个聚合合并成一个派生表,一次扫描同时算出最近订单和总金额:
SELECT r.customer_id, o2.amount AS last_amount
FROM (
SELECT customer_id,
MAX(id) AS last_id,
SUM(amount) AS total
FROM orders
GROUP BY customer_id
) r
JOIN orders o2 ON o2.id = r.last_id
WHERE r.total > 10000;改写后再看EXPLAIN,派生表只剩一个,orders只被扫描一次,整体耗时通常能下降一半以上。这个例子体现的调试思路是:先用EXPLAIN找到DERIVED和Using temporary出现的位置,确认物化发生的次数和扫描范围,再通过改写SQL减少重复扫描。
调试嵌套查询的系统化步骤与进阶工具
把前面的经验整理成固定流程,遇到慢的嵌套查询可以按四步走。第一步,对整条语句执行EXPLAIN,观察每个查询块的type和rows,先定位扫描量最大的那一行。第二步,检查select_type,确认子查询是否被优化器改写成了DEPENDENT SUBQUERY,如果是,考虑用JOIN改写或者调整子查询写法。第三步,检查Extra中的Using temporary和Using filesort,判断是否发生了物化,评估能否通过合并派生表或调整索引来消除。第四步,单独把子查询拿出来执行,对比它单独的耗时和整体耗时,判断时间花在子查询本身还是关联环节。
除了普通EXPLAIN,还有几个进阶工具值得用上。MySQL的EXPLAIN ANALYZE会真正执行语句并输出每一步的实际耗时和实际返回行数,比预估的rows准确得多,特别适合验证你对瓶颈的判断。PostgreSQL的EXPLAIN ANALYZE用法类似,配合BUFFERS选项还能看到物理IO情况。另外EXPLAIN FORMAT=JSON能给出更完整的成本信息,包括每个查询块的估算成本占比。
最后提醒一点:EXPLAIN的结果依赖于统计信息,如果表的统计信息过期,执行计划可能误导你。调试前先执行ANALYZE TABLE(MySQL)或VACUUM ANALYZE(PostgreSQL)刷新统计信息,再来看执行计划,结论才可靠。养成先EXPLAIN再上线的习惯,复杂嵌套查询的绝大多数性能问题都能在开发阶段发现并解决。