有人在生产环境里,为了让一段查询逻辑“看起来简单”,硬生生把一个三层嵌套子查询写进了线上代码。等报警邮件涌进邮箱的时候,第一反应不是去查索引,而是怀疑是不是连接池爆了。后来EXPLAIN一拉,才发现那个最内层的子查询被当成依赖子查询,外表有多少行,它就执行多少次。后来业务方问:MySQL为什么不能用子查询?我说,不是不能用,是你根本不知道它凭什么这么慢。
先说结论:MySQL不使用子查询这句话,放到今天的版本里,已经不完全成立。但在相当多的实战场景里,子查询确实更容易踩坑。这个坑的根源不在SQL写法本身,而在优化器对子查询的“特殊待遇”。MySQL的优化器对子查询,或者说对整个查询计划的重写能力,长期以来都比PostgreSQL、Oracle这些产品要保守。很多你以为会被优化器自动改写成JOIN的子查询,实际执行计划里可能还是原始的逐行探测。所以这道题本质不是在讨论“能不能用”,而是在讨论“你到底该怎么判断”。这篇文章我把这些年在MySQL里用子查询踩过的坑、拆过的慢查询、以及最后沉淀下来的判断标准一次性讲清楚。
1. 子查询为什么慢:问题不在SQL,在优化器的处理路径
1.1 先看执行计划再说结论
很多人一提起子查询,第一反应就是“性能差”。但你让他解释到底差在哪个环节,往往说不出个所以然。我建议所有对子查询有疑虑的人,先学会一件事:看执行计划。
sql复制EXPLAIN
SELECT customer_id
FROM orders
WHERE order_id IN (
SELECT order_id
FROM order_items
WHERE product_id = 2048
);
在MySQL 5.6以前的版本里,这个查询大概率会走一种非常糟糕的执行方式:对orders表的每一行,都去执行一次IN后面的子查询,然后判断当前行的order_id是否出现在子查询结果里。这就是经典的DEPENDENT SUBQUERY,也被叫做相关子查询。外表10万行,子查询就会被重新执行10万次。哪怕子查询里的order_items表上有索引,这个放大系数也扛不住。
到了MySQL 5.6以后,优化器引入了一个关键能力:子查询物化。简单说,优化器会把子查询的结果先在内部临时表里跑一遍,去重之后缓存起来,然后再和外表做连接判断。这个优化叫做“物化子查询”,在EXPLAIN里能看到这样的标记:
select_type为PRIMARY,外层查询。select_type为MATERIALIZED,子查询被物化。table为子查询物化产生的临时表。
如果看到这行,说明你手里的MySQL版本不算老,优化器已经出手帮你做了一层转换。但问题也出在这里:物化不是免费的。子查询结果集如果非常大,临时表可能要落盘,物化本身要付出完整的扫描和去重成本。
所以我一直强调:不要背“子查询一定会被改写成JOIN”这种结论,也不要背“子查询一定慢”这种结论。真实情况是,子和不子,最终要看执行计划里优化器选择了哪条路。
1.2 优化器给子查询的特判:半连接与物化陷阱
MySQL里有一个重要的优化机制叫半连接。半连接从语义上讲,就是“左边表中,只要在右边集合里找到一条匹配记录,就返回这一行”。这是专门为IN和EXISTS这一类子查询准备的。MySQL 5.6之后,半连接的优化策略包括:
Semijoin(半连接合并)Materialization(物化)Duplicate weedout(去重)
这几个名字看起来很专业,但落到执行计划里,就是优化器试图把“子查询”和“外层查询”两者打通,不再做逐行探测。
问题来了:半连接优化并不是对所有子查询都生效。我自己在代码评审里经常看到这几种“优化器无法下嘴”的子查询:
- 子查询中带有
LIMIT,无法做半连接转换。 - 子查询中带有聚合函数,比如
MAX、SUM,优化器无法简单转换为连接。 - 子查询中带有
UNION,并且外层使用IN,部分版本无法自动物化。 - 子查询里引用外层查询的字段,这种叫相关子查询,优化器想改写成连接,很多时候也做不到。
所以,你看到的“性能垃圾”的子查询,多半是踩中了这些优化器无法处理的情况。它只能退回最原始的执行方式:一行一行去扫描判断。
1.3 用一个生活化类比说清楚“为什么逐行执行这么痛苦”
想象一下,你是一个快递站的分拣员,手上有10000个订单,每个订单上都要写收货人的姓名。你手里的表格只有一排一排的姓名,每拿到一个订单,你就要从头到尾把人名表翻一遍,找到匹配的人名,再决定这个订单放到哪个区域。这是“逐行探测”,表小还好,表一大,时间全浪费在翻表格上。
现在换成物化:你先把所有下单用户的姓名去重,复印一份小名单,然后让分拣员拿着小名单去对照。这个“复印小名单”的动作本身有过一次成本,但后续所有订单都只用对一份小表,速度就快起来了。
子查询的问题在于,优化器不是每次都心甘情愿帮你“复印小名单”。它有自己的成本估算逻辑。如果它觉得小名单太大,复印不划算,或是因为SQL里的某类函数导致它根本没往这个方向想,最后它就会退回去做逐行探测。这就是你在执行计划里能看到DEPENDENT SUBQUERY的原因。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 同样是慢,IN、EXISTS、JOIN的慢法完全不同
2.1 三种写法对应的执行路径差异
我一直认为,MySQL优化器对不同写法的“脾气”差异很大。同样一个问题,有人用IN,有人用EXISTS,有人用JOIN,实际跑出来的执行计划可能差一个数量级。我给你一个我在真实项目中反复用过的例子。
业务场景:查所有下过订单的用户昵称。一般有三种写法。
写法一,IN加子查询:
sql复制SELECT nickname
FROM users
WHERE user_id IN (
SELECT DISTINCT user_id
FROM orders
);
写法二,EXISTS加相关子查询:
sql复制SELECT nickname
FROM users u
WHERE EXISTS (
SELECT 1
FROM orders o
WHERE o.user_id = u.user_id
);
写法三,JOIN加DISTINCT:
sql复制SELECT DISTINCT u.nickname
FROM users u
JOIN orders o ON o.user_id = u.user_id;
三个查询的逻辑结果一样,但背后执行路径差别非常大。
IN写法在MySQL 5.6以后,如果orders表非常大,优化器很可能会选择物化临时表,先把orders.user_id去重放到临时表里,再和users做连接。这个方案在orders表极大的情况下,优点是只扫一遍orders,但缺点是物化临时表可能需要落盘。
EXISTS写法在这是一个相关子查询。对users表的每一行,MySQL都要去orders表里探查一次,看有没有匹配的user_id。如果orders表在user_id上有一个索引,这个探查是索引查找,速度很快。尤其是外层用户表小、内层订单表大的时候,EXISTS往往不高。
JOIN写法在逻辑上最直接,但有一个隐患:如果orders表里同一个用户下过很多单,JOIN会把用户昵称重复拉出来多次。你不得不用DISTINCT去重。这里的DISTINCT本身也有成本,而且如果关联字段没有索引,JOIN同样会退化成一表全扫描加另一表逐行探测。
所以,不要问IN和EXISTS哪个好,而要问:你的表长什么样,数据分布怎么样,执行计划走了哪条路。
2.2 一个常见误判:小表驱动大表
网上流传很广的一个说法是,EXISTS适合小表驱动大表,IN适合大表驱动小表。这个说法本身没有错,但很多人忽略了后半句:这都是建立在执行计划真的按你预期的方向走的前提下。
MySQL优化器不一定会按照“小表驱动大表”来执行。它有自己的连接顺序优化逻辑,特别是引入了 semi-join 物化和 join buffer 之后,真实执行计划和你脑子里想的那套可能是两码事。
我记得在MySQL 5.7版本中,如果外层表很大、子查询表很小,优化器有时候会选择物化子查询;如果外层表很小、子查询表很大,优化器反而可能选择EXISTS那种探测方式。你会发现这和“教科书结论”是反着的。因为优化器的成本模型在做估算,不是靠死记硬背。
所以我们的判断沉底到两件事:第一,子查询结果集本身是否巨大,需不需要物化;第二,外层表每行探测时,内层表关联字段有没有可用索引。把这两件事想明白了,IN和EXISTS的取舍就不难。
2.3 DISTINCT去重带来的隐性成本
JOIN方案里DISTINCT是一个很容易被忽略的隐形杀手。很多人在写完大JOIN之后发现查询慢,一查执行计划,Extra列里出现了Using temporary,这才意识到DISTINCT引发了临时表去重。
DISTINCT去重和GROUP BY一样,如果目标字段没有索引,MySQL就需要在内存或磁盘上创建临时表来做哈希去重。这个临时表方案有时比子查询本身还要慢。所以,如果JOIN之后会产生大量重复行,而你又必须去重,那DISTINCT的成本就得算进总账。
这里我个人的经验是:优先用EXISTS,因为EXISTS不会产生重复行。它只判断“存在性”,不会因为内层匹配到多条就返回多条。这是EXISTS在语义上的天然优势。IN加DISTINCT次之,JOIN加DISTINCT最费劲。
3. 跑得慢的真正场景:哪些子查询必须动手拆
3.1 分组取最新值:最容易被写烂的查询
如果说我有没有一种“看见就想重写”的子查询,那一定是分组取最新值的写法。比如,查出每个分类下最新发布的商品。新手最爱的做法是这样的:
sql复制SELECT p.product_id, p.category_id, p.name, p.create_time
FROM products p
WHERE p.create_time = (
SELECT MAX(create_time)
FROM products p2
WHERE p2.category_id = p.category_id
);
这个查询看起来逻辑完整,实际上惨不忍睹。products表有多少行,子查询MAX(create_time)就会执行多少次。category_id的索引如果不在,每个分类下的最新时间判断都需要额外的全表扫描。最坑的是,如果同一个分类下有两个商品刚好时间相等,这个查询会返回两条,逻辑上都不严谨。
遇上这种查询,我的标准拆法有两种。
第一,用派生表加JOIN:
sql复制SELECT p.product_id, p.category_id, p.name, p.create_time
FROM products p
JOIN (
SELECT category_id, MAX(create_time) AS max_create_time
FROM products
GROUP BY category_id
) t ON p.category_id = t.category_id AND p.create_time = t.max_create_time;
第二,MySQL 8.0直接上窗口函数:
sql复制SELECT product_id, category_id, name, create_time
FROM (
SELECT product_id, category_id, name, create_time,
ROW_NUMBER() OVER (PARTITION BY category_id ORDER BY create_time DESC) AS rn
FROM products
) t
WHERE rn = 1;
窗口函数写法上更干净,执行计划也更容易让优化器生成合理的分组排序逻辑。在8.0版本里,遇到分组取最新这种需求,我优先推荐窗口函数,因为它避免了你手工构造子查询容易犯的“相关子查询导致逐行执行”的错误。
3.2 多层嵌套子查询:物化的层层叠加
还有一类我经常在慢查询日志里看到的,是多层嵌套子查询。比如查“最近30天下单超过3次并且最后一次下单时间在昨天的用户”。有人会这么写:
sql复制SELECT user_id
FROM (
SELECT user_id, COUNT(*) AS cnt, MAX(create_time) AS last_order_time
FROM orders
WHERE create_time >= DATE_SUB(NOW(), INTERVAL 30 DAY)
GROUP BY user_id
) t
WHERE cnt > 3
AND last_order_time BETWEEN DATE_SUB(NOW(), INTERVAL 1 DAY) AND NOW();
这个写法其实不算太烂,因为外层只是包了个简单过滤,优化器在MySQL 5.7之后可以对派生表进行合并。但如果内层查询里加了DISTINCT、GROUP BY、UNION、LIMIT,MySQL就没办法把派生表直接合并到外层,派生表就必须物化成临时表。物化临时表本身就是额外成本,再往下,如果外层还套了ORDER BY和LIMIT,那整个查询的执行流程就是:先物化内层临时表,再基于临时表做排序,最后取前N条。每一步都有资源开销。
多层嵌套真正可怕的地方在于,你很难一眼看出哪一层在影响性能。排查手段也很单一:一步步把内层子查询单独拿出来跑,测它的返回行数和耗时,再一层一层往上加。
3.3 大集合IN:临时表落盘与长事务风险
第三个高频坑是 IN (SELECT ...) 返回的结果集特别大。假设你查所有在黑名单里的用户,而黑名单是一个动态生成的子查询:
sql复制SELECT id, name
FROM users
WHERE id IN (
SELECT user_id
FROM user_blacklist
WHERE expire_time > NOW()
);
如果user_blacklist里有几十万有效记录,物化去重后临时表会非常大。MySQL的临时表在超过 tmp_table_size 和 max_heap_table_size 的约束后,会从内存临时表转成磁盘临时表。一旦走磁盘,整个查询的响应时间就彻底失控。
这类大集合IN,我通常拆两步处理:第一步,先把子查询结果导出到一张临时表或者用程序端缓存;第二步,再把外层查询切分成批次,用分页或分段方式处理。简单说,就是别让一个SQL背全部的锅,把量的压力拆到业务层去。
4. 如果确实要写子查询,怎么写才能让优化器帮你干活
4.1 优先写在FROM子句:派生表的合并与物化逻辑
既然用了子查询,位置也有讲究。MySQL对FROM子句里的子查询,也就是派生表,在5.7版本之后做了很大的改进。优化器会尝试把派生表合并到外层查询中,而不是物化它。这样外层查询可以直接访问底层表,条件能够被下推到内层,执行计划更漂亮。
但合并不是无条件的。如果派生表中有以下构造,MySQL会放弃合并,转而物化派生表:
- 派生表使用了聚合函数,比如
MAX、SUM、AVG。 - 派生表使用了
GROUP BY。 - 派生表使用了
DISTINCT。 - 派生表使用了
UNION。 - 派生表里有用户变量或窗口函数。
所以,如果你要写派生表,尽量让内层查询保持简单。比如筛选条件下推到内层,避免内层先查全表再做聚合。
4.2 给子查询关联字段加索引的讲究
很多人提到优化就是“给WHERE字段加索引”这句话,但一旦涉及子查询,索引加在哪里是有讲究的。
如果是 IN (SELECT key_col FROM table2) 这种子查询,你需要在子查询内部的WHERE条件和SELECT返回列上建联合索引。例如:
sql复制SELECT *
FROM table1
WHERE col1 IN (
SELECT col1
FROM table2
WHERE col2 = 'active'
);
这里的索引建议建在 table2(col2, col1) 上,让子查询在定位到active行后,可以直接索引扫描取出col1。如果只在col1上建索引,子查询就要先全表扫描找到符合条件的行,然后再回表取col1,效率低很多。
如果是 EXISTS (SELECT 1 FROM table2 WHERE table2.col1 = table1.col1) 这种相关子查询,索引要建在table2.col1上。因为外表每一行都会用这个字段去探测内表,没有索引等于每次探测都要扫一遍全表。这个索引对内表来说是决定性的,缺了它,相关子查询就是灾难。
4.3 用执行计划反推SQL怎么改
我写SQL的习惯是:写完之后先不急着跑数据,先跑EXPLAIN,看看执行计划里有没有出现这些危险信号:
DEPENDENT SUBQUERY:相关子查询,外表行数越多越危险。DEPENDENT UNION:UNION内部又引用了外层字段,基本等于逐行执行。Materialized且临时表行数很大:子查询被物化,且物化结果不小。Using temporary:DISTINCT或GROUP BY引起临时表,结合结果集大小判断是否可接受。Using filesort:结果集排序,如果排序数据超过内存,会有额外磁盘IO。
看到这些信号,就该考虑改写SQL,或者干脆拆成多条SQL处理。我自己很少去死磕让优化器“理解”某条复杂的子查询,而是选择让SQL的结构更贴合优化器擅长处理的方式。优化器擅长的是JOIN,擅长的是基于索引的等值匹配,擅长的是窗口函数里定义好的排序和分组。顺着它的脾气来,才能减少惊吓。
4.4 一个实际案例:从子查询到临时表的分步改造
我再分享一个真实案例。有一个统计报表,查询目标是:统计过去7天内,每个注册渠道下有有效订单的用户数。最早的写法是这样的:
sql复制SELECT u.channel_id,
COUNT(DISTINCT o.user_id) AS active_users
FROM users u
LEFT JOIN orders o ON o.user_id = u.user_id
WHERE o.create_time >= DATE_SUB(NOW(), INTERVAL 7 DAY)
GROUP BY u.channel_id;
这个SQL在数据量大的时候慢得离谱,原因是LEFT JOIN把users和orders所有匹配行都拉出来了,然后再用COUNT(DISTINCT)去重。orders表一旦大,连接结果集膨胀得很厉害。后来改成:
sql复制SELECT u.channel_id,
COUNT(*) AS active_users
FROM users u
JOIN (
SELECT DISTINCT user_id
FROM orders
WHERE create_time >= DATE_SUB(NOW(), INTERVAL 7 DAY)
) t ON t.user_id = u.user_id
GROUP BY u.channel_id;
改写之后,先从orders表里物化出一个过去7天下过单的去重用户ID集合,再和users做连接。实际执行中,派生表不大,走的是内存临时表,整体查询时间从原来的秒级降到了百毫秒级。这个案例最大的启示是:把“多对多膨胀”变成“先压缩再连接”,比任何优化技巧都直观有效。
5. 从EXPLAIN到optimizer_switch:落地排查时的验证方法
5.1 用EXPLAIN看真实执行路径
排查子查询性能问题时,EXPLAIN能告诉我们很多细节。我最常用的排查顺序是:
- 先看整体的select_type。出现PRIMARY、SUBQUERY、MATERIALIZED、DEPENDENT SUBQUERY这些关键字时,心里先有个谱。
- 再看table列,如果是派生表,会显示为
<derived2>这种形式,数字对应的是查询编号。 - 看type列,重点关注有没有ALL全表扫描。如果有,就要确认是不是子查询引起的。
- 看rows列,这是优化器预估的扫描行数。如果内层子查询预估的rows特别大,而且是物化临时表,那就要重点评估物化成本。
- 看Extra列,出现Using temporary、Using filesort、Using index condition,分别代表不同的性能消耗点。
EXPLAIN输出之后,我经常还会再跑一次 EXPLAIN ANALYZE。这个命令在MySQL 8.0.18之后的版本里可用,能给出实际执行时间和每步操作的行数。比EXPLAIN的估算值准确得多。
5.2 用optimizer_trace看优化器为什么不改写
有一类很头疼的问题:SQL看起来可以被改写成JOIN,优化器偏偏不干。这时候就要打开optimizer_trace,看优化器的成本计算过程。
sql复制SET optimizer_trace = 'enabled=on';
SELECT ...;
SELECT * FROM information_schema.OPTIMIZER_TRACE;
SET optimizer_trace = 'enabled=off';
在trace信息里,重点看两个方面:一是反复出现的 semijoin 相关条目,二是 condition_processing 里对子查询条件的评估。如果优化器判定某个子查询无法转换为semi-join,trace里会直接给出原因,比如“子查询包含聚合函数”或者“无法在物化子查询上执行索引查找”。
这个方法对钻牛角尖的时候特别有用。日常开发里不一定每次都开trace,但我建议在代码评审期间发现子查询性能异常时,花几分钟时间看看trace,比瞎猜索引没生效要靠谱得多。
5.3 调整optimizer_switch阈值
MySQL的优化器开关叫optimizer_switch,里面有一组和子查询相关的选项:
materialization:是否允许子查询物化。semijoin:是否允许半连接优化。subquery_materialization_cost_based:是否基于成本决定是否物化子查询。
在极端场景下,如果临时表的物化成本估算有误,你可以选择关闭基于成本的物化判断,强制优化器走物化,或者反过来禁用物化,让它走其他执行方式。
sql复制SET SESSION optimizer_switch = 'materialization=on,semijoin=on';
但请注意,这属于偏方,不到万不得已不要在生产环境全局调整。绝大多数情况下,优化器的选择是合理的,真正的问题出在索引缺失或者SQL结构本身过于复杂。改开关属于治标不治本,只适合在特定长查询里做会话级别的尝试。
6. 面试和代码评审里的话术与经验沉淀
6.1 面试问题答法:分层作答,别背结论
“MySQL为什么不用子查询?”这个问题如果出现在面试里,重点不是让你批判子查询,而是考察你对优化器的理解深度。我给你的建议是分层作答。
第一层,说明子查询的问题场景:相关子查询会导致逐行执行,外表行数大时性能差;大结果集子查询需要物化临时表,可能落盘;聚合函数和LIMIT等特殊场景优化器无法改写。
第二层,说明MySQL版本演进:5.6引入了物化子查询和半连接优化,5.7优化了派生表合并,8.0引入窗口函数,很多老问题已经不再成立。
第三层,说明自己的实践方法:先看EXPLAIN,再看数据量级,再决定是否拆成多条SQL。能用JOIN就用JOIN,能用窗口函数就用窗口函数,但也不排斥小数据集下用子查询。这样既显得有深度,又表达了动手能力。
6.2 代码评审里看到子查询时的快速判断流程
我自己在评审代码时,看到子查询不会直接打回,而是按一套快速判断流程走:
- 先问:数据集规模多大?子查询返回行数多少?如果内表很小,即使逐行探测也不会有明显问题。
- 再问:子查询是否相关?有没有引用外表字段?如果有,重点关注外表行数和内表关联字段索引。
- 三问:能否改写为JOIN或窗口函数?改写后执行计划是否更优?
- 四看:有没有必要保留子查询的可读性?如果只是一条低频后台查询,且数据量可控,保留子查询完全没问题。
这套流程走下来,既能保证代码质量,也不会因为“禁止子查询”这种教条把开发逼到去写一堆复杂JOIN。
6.3 一句老话:先测量,再优化,别拿感觉当依据
最后说点我个人经验。我在早期也是那种一看到子查询就头疼的人,总觉得子查询一定会把性能拖垮。可后来在好几个项目里,把子查询手工改写成JOIN之后,跑出来的执行计划反而更差。原因很简单:改写后的JOIN产生了大量重复行,DISTINCT去重的成本比原来的索引探测还高。
从那之后我做任何SQL优化,都先跑一遍EXPLAIN,再对比实际执行时间。在没有测量数据之前,任何“我觉得这个查询慢”都属于感觉,不是事实。子查询当然有它的坑,但更重要的是一套行之有效的判断方法。只要你能把执行计划读清楚,把数据集量级估清楚,子查询在很多场景下依然是可用的、清晰的、甚至是比强行JOIN更优雅的写法。
