☰
MySQL子查询到底能不能用?从慢查询到EXPLAIN的实践分析
2026/10/5 11:17:02 网站建设 项目流程

MySQL的子查询到底能不能用?这个问题我被问过不下一百次。每次我都先反问他一句:你口中的"不能用",是听别人说的,还是自己用EXPLAIN验证过?大多数人会愣住,然后含糊地说"网上都这么说,子查询性能差"。其实这话不算错,但严重失准。今天我想用一次线上事故和一组实测数据,把"MySQL不使用子查询的原因"这件事彻底讲透,也聊聊哪些子查询该保留、哪些必须改。这篇文章适合还在背结论的开发同学,也适合遇到慢SQL不知道从哪下手的运维和DBA。你不需要成为优化器源码专家,只要掌握几个判断维度,就能在性能和可读性之间找到比较舒服的平衡点。

1. 从一次高峰期慢查询说起:子查询的问题是怎么暴露的

1.1 事故现场:一条IN子查询打满数据库CPU

先交代一下背景。那是一套电商系统,订单表和用户表分开存储。业务要做的是"最近30天内有成功支付订单的用户列表",开发同学很自然地在接口里写了一条IN子查询:

SELECT u.id, u.nickname, u.mobile FROM users u WHERE u.id IN ( SELECT o.user_id FROM orders o WHERE o.status = 'paid' AND o.pay_time >= NOW() - INTERVAL 30 DAY );

从语法角度看,这句SQL没有任何毛病,语义也一目了然,甚至比JOIN更好懂。但上线之后,这个接口在峰值时段频繁超时。我接手排查时,数据库的CPU已经长时间徘徊在85%以上,慢查询日志里这条SQL动不动跑出两秒多。第一反应就是先EXPLAIN。

MySQL执行计划出来之后,问题其实已经比较清楚了:users表走了全量扫描,rows列写着五十万;orders表倒是走了索引,但Extra列里出现了Materialize、Start temporary、End temporary等字样,说明优化器把orders子查询的结果整个物化成了一张临时表。也就是说,这条SQL根本不是"先找出符合条件的订单,再回users匹配",而是先把一堆订单数据完整落进内存/临时表,再去和50万行users做外层扫描。几十万行订单一旦全部物化,内存、排序、扫描全部压上来,不快才奇怪。

这个场景我后来复盘了很多次。它最大的迷惑性在于:子查询本身在优化器看来没什么语法错误,数据分布也没问题,但它选择了一条看起来合理、实际上非常重的执行路径。你如果不去看EXPLAIN,完全是"为什么慢都不知道"。

1.2 是子查询的锅,还是写法的锅?

故障解决之后,团队里自然有人提出"以后禁止用子查询,一律改JOIN"。我没有直接同意。因为这次事故的真正问题不是"子查询"这三个字,而是执行计划选择了物化路径。说白了,同样的语义,如果优化器在某条统计信息下恰好改成了半连接(semi-join),那SQL可能跑得跟JOIN一样快。禁止一个语法,不如学会看懂执行计划。

这也是为什么我后来带团队时,第一课永远是EXPLAIN而不是SQL规范。你可以有倾向性地推荐JOIN,但不能把子查询定义为"禁用",因为在MySQL里,子查询被嫌弃是有历史原因的:早年优化器对子查询的处理非常简陋,几乎没有任何转换,慢是真慢;现在优化器先进了,却仍然有不少场景会打开"物化"或"逐行执行"这两条开销很大的路径。要把原因讲清楚,我们得先看优化器到底在哪些情况下会走极端。

2. 子查询慢的根源:逐行执行、物化临时表与NOT IN的NULL陷阱

2.1 关联子查询的逐行地狱

先说最经典的一种坑:关联子查询。它的特征就是内层SQL引用了外层表的某个字段,内外层产生依赖。比如很多人推崇的EXISTS写法:

SELECT u.* FROM users u WHERE EXISTS ( SELECT 1 FROM orders o WHERE o.user_id = u.id );

看到EXISTS,很多开发第一反应是"EXISTS比IN快"。我年轻时也这么以为,直到用EXPLAIN看了执行计划才发现,只要优化器没有做进一步转换,这类关联子查询就会按嵌套循环的方式执行:外层users有多少行,内层orders查询就跑多少次。users有50万行,那orders的索引查找就要执行50万次,哪怕是逻辑很简单的主键索引查找,乘以这个倍数也扛不住。

这里有个很直观的代价模型:总成本约等于外层行数乘以内层单次查询成本。外层行数N是50万时,内层单次哪怕只要0.1毫秒,合计也要50秒;要是内层查询本身需要排序或回表,单次成本轻松上毫秒,那条SQL基本就别想用了。所以遇到关联子查询,第一件事就是看外层表有多大、内层有没有可用的索引。只有外层小、内层有索引,这个写法才安全。

2.2 非关联子查询的物化陷阱

和内层独立执行的关联子查询不同,非关联子查询的内层SQL不依赖外层字段,理论上优化器有更多发挥空间。MySQL从5.6开始引入物化和半连接优化,5.7继续增强,8.0也一直在演进。但问题在于:物化不等于免费。

什么是物化?简单说,优化器把子查询的结果先算出来,放进一张临时表,后续再拿着这张表和外层表匹配。临时表可能在内存,也可能因为太大落到磁盘。建临时表、写入数据、建立索引或排序,每一步都有成本。如果子查询的结果集有几十万行,而外层经过过滤后其实只需要匹配几百行,那优化器就做了一个又重又亏的中间步骤。

更要命的是,半连接转换不是所有子查询都能触发。MySQL官方文档里明确列出了一些情况,子查询只要包含GROUP BY、聚合函数、LIMIT、ORDER BY等,就无法走半连接,优化器只能选择物化或逐行执行。很多开发只知道"IN子查询会被优化成JOIN",但实际写出来的子查询往往因为带了聚合或排序,根本没触发半连接,只有物化甚至是更差的执行路径在等着。

2.3 NOT IN与NULL:最容易被忽略的语义雷

前面两条讲的是性能,这一条直接关系到结果正确性。SQL里有三值逻辑:除了TRUE和FALSE,还有UNKNOWN。NULL参与比较就会产生UNKNOWN。而NOT IN遇到子查询结果中出现NULL时,整个条件会变成UNKNOWN,最终结果集就是空的。

举一个很简单的例子:

SELECT id FROM users WHERE id NOT IN ( SELECT user_id FROM orders );

只要orders表里任意一行的user_id是NULL,这条SQL就什么都查不出来。无论业务上这些用户是否下过订单,结果都是空。更麻烦的是,优化器不会因为你心里想的是"反连接"就自动处理NULL,它必须严格按三值逻辑计算,执行计划也因此会变得保守。

所以我在代码评审里一直强调:如果要用"A表有、B表没有"这种反连接语义,优先写NOT EXISTS,或者LEFT JOIN再加IS NULL过滤。这样语义明确,执行路径也可控,比起NOT IN那种"表面简单、暗藏杀机"的写法安全得多。这个坑,我见过不止一个团队在数据校验场景里踩进去,而且因为"返回为空"看起来太正常,往往要等业务反馈数据对不上才发现。

3. 实测对比:IN、EXISTS与JOIN在200万行订单表上的差距

3.1 测试环境、表结构与造数

原理讲再多,不如跑一组数据直观。我专门搭了一个测试环境,MySQL 8.0.32,8核16G的普通云主机,模拟业务场景造了两张表。users表50万行,orders表200万行,orders的user_id字段建了普通索引,status也建了索引。表结构大致如下:

CREATE TABLE users ( id INT PRIMARY KEY, nickname VARCHAR(50) ) ENGINE=InnoDB; CREATE TABLE orders ( id INT PRIMARY KEY, user_id INT NOT NULL, status VARCHAR(10), amount DECIMAL(12,2), pay_time DATETIME, KEY idx_user_id (user_id), KEY idx_status_amount (status, amount) ) ENGINE=InnoDB;

数据用存储过程循环插入,金额、状态、时间都做了随机分布。测试目标是:找出"有金额大于100元且状态为paid订单的用户"。我用三种等价SQL分别跑,每一条都先执行几次做热身后再计时取均值。

3.2 三条等价SQL的耗时对比

第一种,IN子查询:

SELECT u.id, u.nickname FROM users u WHERE u.id IN ( SELECT o.user_id FROM orders o WHERE o.status = 'paid' AND o.amount > 100 );

第二种,EXISTS关联子查询:

SELECT u.id, u.nickname FROM users u WHERE EXISTS ( SELECT 1 FROM orders o WHERE o.user_id = u.id AND o.status = 'paid' AND o.amount > 100 );

第三种,JOIN后去重:

SELECT DISTINCT u.id, u.nickname FROM users u JOIN orders o ON o.user_id = u.id WHERE o.status = 'paid' AND o.amount > 100;

让我直接说结果。我这个环境下的平均耗时大致是:IN子查询约1.9秒,EXISTS关联子查询约3.2秒,JOIN+DISTINCT约0.8秒。执行计划核心信息我也整理了一下:

写法平均耗时执行计划关键点
IN子查询约1.9秒物化临时表,外层全表扫描
EXISTS关联子查询约3.2秒外层全表扫描,内层索引查找,DEPENDENT SUBQUERY
JOIN + DISTINCT约0.8秒过滤后订单集做驱动,users主键回表

这个数字肯定随数据分布波动,但差距方向是稳定的。三个版本里最慢的居然是被很多人视为"更高效"的EXISTS,这不是说EXISTS写法不好,而是在这个数据模型下,优化器没能在它身上选到一条更聪明的路径,只能老老实实地逐行执行。反而是IN子查询,虽然走了物化,但物化之后的匹配方式比逐行EXISTS还快些。JOIN能赢,靠的是优化器对两表连接成本评估的老练。

3.3 为什么JOIN赢在给了优化器更多选择

JOIN版本之所以快,不是因为"JOIN语法比子查询魔法",而是因为它给了优化器更完整的统计和路径选择空间。优化器在处理JOIN时,可以基于orders表过滤后的结果集大小决定谁是驱动表,可以按需选择嵌套循环、哈希连接(MySQL 8.0后也有hash join),还可以借助主键回表高效拿users字段。而子查询尤其是物化路径下,优化器要面对一张临时生成的结果集,统计信息往往不完整,连接顺序选择就容易保守。

我也要说明一点:如果IN子查询恰好被优化器转换成了半连接,那它的执行计划会和JOIN非常接近,性能自然也不差。所以别把"IN一定慢"当成真理。我平时判断一条子查询到底该不该改,从来不是看它写了IN还是EXISTS,而是看EXPLAIN最终怎么落地。落地成半连接,那就不折腾;落地成物化或DEPENDENT SUBQUERY且行数巨大,那就立刻考虑改写。

4. 不是所有子查询都要改:哪些场景可以放心保留

4.1 子查询结果集很小或外层结果集很小时

一个技术结论如果只用一句"不要用"来概括,一定会在某些场景下误导人。子查询之所以被嫌弃,核心是执行路径重。但如果结果集本身很小,"重"也就不存在了。

第一种常见场景,子查询结果集小。比如状态表、分类表、白名单表,可能就几十行几百行,物化成本可以忽略。这种IN子查询直观好读,强行改JOIN反而显得绕。

SELECT * FROM products WHERE category_id IN ( SELECT id FROM categories WHERE status = 1 );

第二种场景,外层结果集很小。我前面说过关联子查询最怕外层表大,反过来,如果外层经过WHERE过滤后只剩几十行,内层又有索引命中,那逐行执行也不再是问题,EXISTS甚至可以用得很安逸。问题从来不是"用了某个语法",而是"语法匹配的数据规模不匹配"。

4.2 LIMIT取每组TOP N:相关子查询反而更直观

还有一个场景是JOIN改写很容易把人绕晕的:每组取TOP N。比如要查"每个用户最近一笔订单",用GROUP BY加MAX(pay_time)再回表当然可以,但SQL很长,索引也不好设计。用相关子查询加LIMIT 1反而直观:

SELECT o.* FROM orders o WHERE o.id = ( SELECT o2.id FROM orders o2 WHERE o2.user_id = o.user_id ORDER BY o2.pay_time DESC, o2.id DESC LIMIT 1 );

前提是orders表上有针对(user_id, pay_time, id)的联合索引,否则内层每一次都要全表排序,等于把子查询的缺点全部引爆。索引正确时,这个写法比很多聚合改写都干净。当然MySQL 8.0有窗口函数ROW_NUMBER()可以用,但窗口函数要构建分区窗口,内存开销大,压测不过的时候回退到相关子查询也是常见操作。

4.3 MySQL 8.0优化器进步后还需要"禁止子查询"吗

过去那句"MySQL子查询慢如牛"的经验,绝大多数诞生在5.1、5.5年代。那时优化器对子查询的处理确实简陋,很多子查询只能逐行跑,甚至有些物化和派生表优化还没有。今天用5.7或8.0的团队,如果你还沿用"逢子查询必改JOIN"的规矩,可能会改掉不少本来就很快的语句。

MySQL 8.0在派生表合并、半连接、哈希连接上都做了不少改进,尤其是连续多个小版本里都有人专门针对IN/EXISTS相关子查询调优。所以我的观点是:版本升级后,规则不是"可以乱用子查询",而是"必须在EXPLAIN的引导下使用"。你完全可以立一条规矩——所有带子查询的SQL,上线前必须贴EXPLAIN;看到materialize或dependent subquery就重点评估。这个规矩比"不允许子查询"科学得多,也灵活得多。

5. 慢SQL排查:如何判断这条子查询是否需要改写

5.1 看EXPLAIN的四个关键线索

排查子查询相关慢SQL,我会先抓四个字段:

  • type列:外层表出现ALL(全表扫描)。如果外层是大表,基本就是性能隐患。
  • rows列:估算行数是不是和外层表一个量级。如果EXPLAIN里rows显示的是几十万,潜意识就可以确认优化器没有缩小数据范围。
  • Extra列:出现DEPENDENT SUBQUERY、SUBQUERY、Materialize、Start temporary、End temporary。这些关键词分别对应逐行执行和物化两条重路径。
  • key列:内层子查询如果没有走任何索引(key为NULL),那内层查询可能每次都在扫全表,这条SQL基本无解。

其中Extra列最容易被忽略。很多人EXPLAIN只看type还算不算好,不看Extra,结果漏掉了物化和临时表这两个真正的性能杀手。

5.2 我习惯的改写SOP:先验证结果集,再改语法

我自己处理这类慢SQL有一套固定顺序,简化下来是这样:

  1. 先EXPLAIN原SQL,记录执行路径和时间。
  2. 记录当前SQL返回的结果集行数,作为改写的基准。
  3. 根据业务语义选择改写方案:关联子查询优先想EXISTS或JOIN;反连接用NOT EXISTS或LEFT JOIN IS NULL;普通IN看能不能拆成JOIN。
  4. 改写后先对比结果集行数,再抽样几条数据确认字段一致。尤其注意JOIN产生的重复行,该加DISTINCT就加DISTINCT。
  5. 再次EXPLAIN和计时,确认执行路径真的变了,比如不再物化、驱动表更合理。

第4步是绝大多数人跳过的。我见过好几次团队把IN改成JOIN后忘了去重,返回结果翻了几倍,因为业务侧没有直接报错,直到BI报表对不上才发现。改SQL不是改作文,语义一致性永远是第一位的。

5.3 一点个人心得:索引没到位,改JOIN也救不了你

最后聊个很现实的事。我复盘过的子查询慢SQL里,真正需要靠"改写语法"解决的不到一半,更多时候根因是索引缺失。比如内层子查询在orders.user_id上有索引,但status和amount没有联合索引,导致每次过滤都要回表或走文件排序。这种问题你改成JOIN一样慢,因为重路径只是换了件马甲。

所以我现在看到同事写子查询,第一反应不是让他改成JOIN,而是让他先把EXPLAIN发过来,一起看索引和rows。等索引到位之后,再判断"这个子查询是否还有必要改写"。语法是死的,人是活的;但真正能让执行计划发生质变的是索引、统计信息和数据分布。把这三样搞懂,子查询用还是不用,你心里自然有数。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询