子查询写起来直观,逻辑上也贴近业务语言的描述方式,所以在业务代码里出现的频率非常高。但子查询在MySQL里并不总是被高效地执行,尤其是嵌套在WHERE条件里的IN子查询和EXISTS子查询,一旦优化器没有把它转换成半连接,EXPLAIN的输出里就会多出一行DEPENDENT SUBQUERY。这个标记意味着外层表每扫描一行,内层子查询就要重新执行一次,如果外层有一百万行数据,子查询就要执行一百万次,执行计划瞬间变得惨不忍睹。这篇文章就来分析子查询执行计划变差的原因,并给出用JOIN改写的具体方案。

一、先看懂EXPLAIN里的DEPENDENT SUBQUERY
判断一条子查询有没有被优化好,第一步永远是看EXPLAIN的输出。下面这条SQL是一个典型的写法, orders 表有几百万行订单数据,我们想查出 VIP 客户的订单:
SELECT * FROM orders
WHERE customer_id IN (
SELECT id FROM customers WHERE level = 'VIP'
);
在理想情况下,MySQL 5.6之后的优化器会把这种IN子查询转换成semi-join(半连接),EXPLAIN的extra列会显示 Semi-join 或者 FirstMatch 之类的策略,执行效率接近普通的JOIN。但现实往往不理想,一旦子查询里出现了UNION、GROUP BY、聚合函数,或者外层查询本身就带有GROUP BY,半连接转换就会失效,此时EXPLAIN的select_type会变成DEPENDENT SUBQUERY。
DEPENDENT SUBQUERY的执行逻辑是这样的:优化器把子查询改写成依赖外层变量的形式,等价于对orders表的每一行都执行一次 SELECT id FROM customers WHERE level='VIP' AND id=orders.customer_id。假设customers表上有level字段的索引还好,如果索引不存在或者因为隐式类型转换失效,每一次执行都是一次全表扫描,总耗时就是外层行数乘以内层扫描成本,呈乘积关系膨胀。很多在测试环境只有几千行数据时感觉不到的问题,到了生产环境百万级数据下就暴露出来了。
还有一种情况容易被忽视:FROM子句里的子查询(派生表)。在MySQL 5.6之前,派生表会被完全物化成一个临时表,外层查询无法利用内层的索引;5.6之后虽然引入了派生表合并优化,但如果子查询里含有LIMIT、聚合函数或者用户变量,合并依然会失败,临时表就避免不了。物化临时表没有索引,外层对它的关联只能走全表扫描,这也是执行计划变差的一个常见来源。
二、用JOIN改写IN子查询和EXISTS子查询
最直接的改写思路是把IN子查询变成JOIN。上面的例子可以改写成:
SELECT o.* FROM orders o INNER JOIN customers c ON o.customer_id = c.id WHERE c.level = 'VIP';
改写后EXPLAIN里只有两行,驱动表是经过level条件过滤后的customers(如果能过滤出几千个VIP客户),被驱动表orders走customer_id上的索引进行ref访问,整体扫描量从乘积关系变成了两次索引查找的加法关系。实测在orders有500万行、customers有20万行的场景下,改写前耗时8秒多,改写后稳定在0.05秒以内,差距非常明显。
需要注意的是,IN子查询和JOIN并不是在所有语义下都等价。如果子查询的内层表存在重复值,比如customer_id在customers表里不唯一,JOIN会产生重复的订单行,而IN不会。保险的做法有两种:一是给JOIN的内层加上DISTINCT,二是确认业务上内表关联字段有唯一约束。对于customers.id这种主键字段,直接JOIN就是安全的。
EXISTS子查询的改写方式类似。像下面这种相关子查询:
SELECT * FROM orders o
WHERE EXISTS (
SELECT 1 FROM blacklist b WHERE b.customer_id = o.customer_id
);
它表达的是半连接语义,即只判断存在性、不关心具体匹配几条。改写成JOIN时要加DISTINCT去重,或者改用LEFT JOIN加IS NULL的反向写法来处理NOT EXISTS:
-- NOT EXISTS 改写为 LEFT JOIN ... IS NULL SELECT DISTINCT o.* FROM orders o LEFT JOIN blacklist b ON o.customer_id = b.customer_id WHERE b.customer_id IS NULL;
反向改写对NOT IN尤其有价值。NOT IN遇到NULL值时结果集会直接为空,而LEFT JOIN加IS NULL的写法既没有NULL陷阱,通常也能拿到更稳定的执行计划。
三、改写之外的优化手段和验证方法
JOIN不是万能药,改写之后仍然要回头检查索引设计。JOIN改写能生效的前提是被驱动表的关联字段上有索引,orders.customer_id如果没有索引,改写后依然是两个全表扫描的笛卡尔积,比原子查询还慢。所以在动手改写SQL之前,先用EXPLAIN确认两边的关联字段是否都有可用的索引,没有的话先补索引再谈改写。
控制结果集大小同样重要。JOIN的驱动表选择依赖优化器的成本估算,如果统计信息过期,优化器可能选错驱动表。可以在业务低峰期执行 ANALYZE TABLE 更新统计信息,必要时用 STRAIGHT_JOIN 强制JOIN顺序。另外当关联双方过滤后仍然是大结果集时,可以考虑在应用层做分批处理,每批用一个主键范围限定扫描量,避免单条SQL长时间占用资源。
最后一步永远是对比验证。改写前后各跑一次EXPLAIN,重点对比type列(是否从ALL变成ref或eq_ref)、rows列的估算扫描量、extra列是否消除了DEPENDENT SUBQUERY。再用EXPLAIN ANALYZE(MySQL 8.0.18以上支持)查看真实的执行耗时分布,确认优化落在了预期的地方。上线前建议开启慢查询日志观察一段时间,把改写后的SQL放到真实的并发压力下检验,避免测试环境的数据分布掩盖了潜在问题。
总结一下,子查询执行计划变差的根源在于半连接转换失败导致的逐行重复执行,以及派生表物化带来的无索引临时表。用JOIN改写配合合理的索引设计,能把乘积级的扫描量降为加法级,这在数据量增长后带来的收益会越来越明显。写SQL时养成先看EXPLAIN的习惯,比事后救火要省心得多。