
这个标题看起来像是有经验的工程师在团队内做过一次技术分享或者是在某个代码评审之后整理出来的笔记。MySQL里关于子查询的争议确实存在了很多年尤其从旧版本过来的DBA和开发大多都有过被慢查询折腾的经历。但子查询到底哪里不好是SQL写法的问题还是MySQL执行器的策略问题很多人其实没有完全吃透。先说一个我自己的真实经历。某年做电商订单中心的数据报表一张订单明细表大概两千万行要统计每个用户最近一次下单时间当时第一版SQL用了相关子查询结构非常清晰一眼能看明白业务逻辑但一跑到线上就卡死直接打满临时表空间。后来改成JOIN分组执行时间从几十秒降到了两秒左右。这个案例就是标题对应的核心场景MySQL不用子查询不是不能写而是优化器在某些版本里对子查询的处理并不聪明容易生成糟糕的执行计划。想搞清楚这个问题的根源还是得回到MySQL的执行策略和优化器行为上。1. 子查询的“性能账本”为什么看起来很美的写法会坑人1.1 相关子查询的逐行重复执行困境子查询听起来是大白话连业务人员都能看懂先查出一个结果集再把它当条件去查外层。数据库本身也是这么设计的但问题是MySQL的优化器在早期版本里底层执行逻辑并不是真正“先算内层、再算外层”尤其是相关子查询correlated subquery内层查询要引用外层表的字段执行引擎会对外层每一行都执行一次内层查询。你可以把这种执行方式理解成一个嵌套循环外层表有一万行内层子查询就会执行一万次。哪怕单次执行只需要0.1毫秒一万次也到了一秒的量级如果内层再走全表扫描每一行都要扫整张表时间就是天文数字。这个问题在IN、EXISTS、比较运算相关的子查询里都存在。MySQL 5.5及更早的版本几乎完全没有对这类子查询做优化就是老老实实逐行执行这也导致当时很多SQL规范直接写出“禁止使用子查询”。到了5.6/5.7优化器引入了半连接semi-join和物化materialization优化但也不是无条件的很多写法依然会退化成逐行执行的老路子。1.2 优化器不一定总按你想象的顺序执行很多人还有个误解认为把子查询写在后面优化器就会先执行子查询。其实MySQL优化器在生成执行计划时会先整体分析SQL的各个部分而不是严格按语句书写顺序执行。它会基于统计信息选择驱动表、决定连接顺序甚至把WHERE里的子查询改写为JOIN或EXISTS但这些改写是否成功取决于子查询结构和版本能力。在5.7和8.0里对于非相关的IN子查询优化器通常会做半连接转换把子查询物化成临时表再进行连接而对于相关子查询优化器也可能通过“子查询缓存”来减少执行次数但要满足严格条件比如内层查询不涉及外层字段对应表的变化缓存才能复用。一旦某个子查询没法命中优化路径就会出现我开头说的那种情况SQL逻辑无可挑剔性能却一塌糊涂。1.3 索引利用受限和临时表放大子查询主要依赖外层WHERE条件走索引但内层子查询的驱动方式和索引利用往往没有JOIN改写来得直接。比如一个IN子查询内层返回的结果集很大时物化成临时表可能没有索引外层判断IN条件时就要全表扫描临时表性能反而比直接JOIN更差。临时表还可能落到磁盘造成大量的I/O。MySQL的临时表分为内存临时表和磁盘临时表如果子查询结果集超过tmp_table_size或max_heap_table_size的限制内存临时表就会转成磁盘临时表再走磁盘I/O性能直线下降。这也是OLTP系统里最怕出现的隐患一个看似无害的子查询瞬间抽走几GB临时表空间。2. 哪些子查询模式最容易踩坑要说“不能使用子查询”太绝对但有一些模式无论在5.7还是8.0里出现我都建议优先考虑改写。下面按风险等级列一下我们的经验2.1 IN子查询的隐藏陷阱WHERE id IN (SELECT user_id FROM ...)这样的写法是最常见的。如果子查询是非相关的也就是不引用外层表字段5.7以上版本一般能优化成半连接性能基本可以接受。但有两个例外子查询里带有LIMIT、ORDER BY、聚合函数或GROUP BY时半连接转换会被禁止优化器只能物化子查询再与外层做匹配而物化结果往往缺乏有效索引。子查询结果集包含NULL值时NOT IN会出现严重问题如果子查询结果里有一行NULLNOT IN会对所有外层行都返回“未知”即FALSE直接导致结果集为空而且还会让优化器放弃半连接优化。这就是很多数据明明存在的SQL却查不出数据的“灵异事件”的真正元凶。2.2 相关子查询与EXISTS的误用EXISTS子查询在语义上是“存在即真”优化器可以及早短路所以很多人推荐用EXISTS替代IN这在老旧版本里确实有效可一旦EXISTS里带了相关条件例如SELECT * FROM orders o WHERE EXISTS ( SELECT 1 FROM products p WHERE p.id o.product_id AND p.stock 0 );这样做如果外层表比较大MySQL还是会对外层每一行执行一次相关子查询即使每次匹配很快总成本也一样很高。除非优化器能通过某种方式把EXISTS重写为半连接否则性能仍然堪忧。更麻烦的是EXISTS子查询的SELECT列表里如果写了具体字段看起来是只取一行实际执行时仍要扫描满足条件的全部行——因为优化器并不喜欢看到SELECT后面的字段它只关心“是否存在”这个布尔值。写成SELECT 1在部分版本里能减少计算量但也要看优化器的具体行为。2.3 FROM子句中的派生表SELECT * FROM (SELECT ...) t WHERE ...属于派生表derived tableMySQL 8.0提供了合并优化把派生表直接合并进外层查询以便更好地利用索引。但如果派生表包含LIMIT、聚合函数、窗口函数或者有ORDER BY没法合并只能物化成临时表这个临时表的代价和索引问题又会冒出来。这种写法的风险在5.7版本尤其明显早期版本的物化策略比较笨拙经常在派生表上强行全表扫描。8.0虽然改进了很多但对于大数据量、高并发的场景我仍然建议优先用CTE公用表表达式或临时表能明确控制物化行为。2.4 多重嵌套的“俄罗斯套娃”写法一个子查询里再套子查询不仅阅读成本高优化器的改写成功率也会大打折扣。因为优化器需要逐层分析每一层能否做转换中间某一层被物化后上层对临时表的访问就没有原表索引可用了。这种SQL大概率会成为慢查询日志里的常客。我们的代码评审规范里有一条约定超过两层的子查询嵌套必须返回重写。逻辑可以用JOIN或临时表拆解绝对不能为了追求SQL表达能力去叠层数。3. 用JOIN和临时表改写子查询的实操方案3.1 IN子查询改写为JOIN把WHERE id IN (SELECT user_id FROM ...)改写为JOIN最大的好处是让优化器可以直接控制驱动表和连接顺序也能让内层表走索引。改写示例-- 改前 SELECT * FROM users WHERE id IN ( SELECT user_id FROM orders WHERE status 1 ); -- 改后 SELECT DISTINCT u.* FROM users u JOIN orders o ON o.user_id u.id WHERE o.status 1;这里有个细节要特别提醒IN子查询遇上重复值不会导致结果变多因为IN本身就是判断存在性但改成JOIN以后一个用户对应多条订单记录结果集就会膨胀所以必须加DISTINCT。如果原表数据量很大DISTINCT本身又是一次全量排序可能反而抵消了JOIN的收益所以更稳妥的写法是先把订单表去重SELECT u.* FROM users u JOIN ( SELECT DISTINCT user_id FROM orders WHERE status 1 ) t ON t.user_id u.id;这种写法在MySQL 8.0下通常会被优化器重新合并或物化两种极端情况都做了保护。3.2 EXISTS改写为半连接或反向JOIN如果业务只是判断存在推荐写法是-- 改前 SELECT * FROM products p WHERE EXISTS ( SELECT 1 FROM order_items oi WHERE oi.product_id p.id AND oi.quantity 10 ); -- 改写为半连接语义的JOIN SELECT DISTINCT p.* FROM products p JOIN order_items oi ON oi.product_id p.id WHERE oi.quantity 10;如果你确实需要保留EXISTS的短路特性还有一种方式是把它放在NOT EXISTS里反向过滤例如找出“没有子订单的主订单”这种方式改写为LEFT JOIN IS NULL也属于经典优化手段-- 改前 SELECT * FROM orders o WHERE NOT EXISTS ( SELECT 1 FROM payments p WHERE p.order_id o.id ); -- 改后 SELECT o.* FROM orders o LEFT JOIN payments p ON p.order_id o.id WHERE p.id IS NULL;LEFT JOIN ... WHERE右表主键IS NULL这种写法在MySQL里通常能命中索引执行计划比NOT EXISTS那版稳定得多。3.3 聚合类子查询改成临时表遇到“每个用户最近一次下单时间”这类需求子查询写法通常是SELECT * FROM orders o WHERE o.order_time ( SELECT MAX(order_time) FROM orders WHERE user_id o.user_id );这几乎就是最差的那类相关子查询了内外层都扫描重复计算严重。我的标准解法是先建临时表把聚合结果一次性算好再用主表去关联CREATE TEMPORARY TABLE tmp_user_last_order AS SELECT user_id, MAX(order_time) AS last_order_time FROM orders GROUP BY user_id; ALTER TABLE tmp_user_last_order ADD INDEX idx_user (user_id); SELECT o.* FROM orders o JOIN tmp_user_last_order t ON t.user_id o.user_id AND o.order_time t.last_order_time;临时表虽然要额外建一次但聚合只跑一遍后续的关联完全可以走索引整体性价比非常高。在数据量大的场景下这是我个人强烈推荐的方向。3.4 用户变量或窗口函数的替代方案MySQL 8.0的窗口函数提供了新的思路比如上面那个取“每个用户最近一次下单时间”的例子用窗口函数会直接很多SELECT * FROM ( SELECT o.*, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY order_time DESC) AS rn FROM orders o ) t WHERE t.rn 1;窗口函数在内部也会有排序或物化开销但相比相关子查询的逐行执行复杂度通常低一个量级。如果不想依赖窗口函数5.7及以下版本还可以用用户变量模拟分组排序那个办法能跑但维护性差我一般不推荐新代码用。4. 如何判断你的子查询该不该改写EXPLAIN与慢日志实操4.1 用EXPLAIN看执行计划里的关键标识实际工作中判断子查询性能最直接的手段就是EXPLAIN。看到以下关键特征基本就能断定要改了select_type为DEPENDENT SUBQUERY或DEPENDENT UNION说明是相关子查询走的是逐行执行的老路。table列显示subquery2或derived3表示这块被物化成临时表了需要看Extra列有没有Using where; Using index如果没有多半要全表扫描临时表。type列出现ALL且rows估算值非常大大概率是全表扫描。Extra列包含Using temporary或Using filesort说明临时表或排序的代价已在执行计划里体现。在MySQL 8.0里EXPLAIN FORMATTREE还能更清晰地看出半连接转换是否生效。如果看到- Hash join或- Nested loop inner join说明优化器已经帮你做了改写这时候子查询的写法问题就不大了。4.2 慢查询日志与optimizer_trace的配合线上判断更靠谱的方法是看慢查询日志找出具体SQL之后再做优化避免凭感觉改SQL。开启慢日志的方式按常规做法配就行关键是分析时要关注三个指标Rows_examined、Rows_sent和临时表使用情况。如果一个SQL返回100行却扫描了500万行那显然有大量无效扫描子查询或相关子查询的可能性极高。MySQL 5.7/8.0还提供了optimizer_trace可以看优化器对子查询做的每一步决策SET optimizer_trace enabledon; SELECT ...; -- 执行要分析的SQL SELECT * FROM information_schema.OPTIMIZER_TRACE;输出的JSON里能直接看到subquery_to_semi_join之类的字段告诉你优化器有没有把子查询转成半连接以及是哪种半连接策略。这个字段对理解“为什么我的子查询慢”很有帮助。4.3 按场景判断的决策参考表很多情况下没有必要盲目改写所有子查询我的建议是区分场景。整理成表格供参考子查询类型典型写法5.7/8.0风险改写建议非相关INid IN (SELECT ...)低能半连接结果集大时改成JOINDISTINCT相关INid IN (SELECT ... WHERE x 外层字段)高逐行执行改写为EXISTS或JOINNOT IN含NULLid NOT IN (SELECT ...)极高逻辑性能双风险改成NOT EXISTS或LEFT JOIN IS NULL相关EXISTSEXISTS (SELECT 1 WHERE ...)中高视优化器改成半连接JOINFROM子句派生表FROM (SELECT ...) t中可能物化拆成CTE或临时表聚合相关子查询外层字段 (SELECT MAX(...) ...)极高临时表JOIN或窗口函数深层嵌套子查询套子查询高改写失败概率大必须拆分层级4.4 影响范围思考从单条SQL到整个系统子查询的问题有时候不只是一个慢查询那么简单它会牵动整个数据库的资源分配。一个重度子查询可能占满一个连接池里的线程池资源进而把同一个实例上的其他业务拖垮。在低配置的机器上临时表落到磁盘还会引发I/O争用整个实例的响应时间都会受影响。这也是为什么很多公司的数据库开发规范直接把子查询列为SQL review的必查项。规范的本质不是禁止使用而是提醒涉及子查询时必须有EXPLAIN佐证性能否则默认改成JOIN、临时表或窗口函数来实现。这是一条非常务实的红线。5. 实际踩坑和排查记录三个典型例子5.1 一个隐藏很深的NOT IN NULL问题之前某系统的用户标签查询运营反馈“有一部分用户明明打了标却查不到”。排查到最后发现SQL写的是SELECT * FROM user_tags WHERE user_id NOT IN ( SELECT user_id FROM banned_users );而banned_users表里恰好有一行的user_id是NULL。由于SQL的NULL传播特性整个NOT IN条件对所有行都返回UNKNOWN查询结果为空。把SQL改成NOT EXISTS后问题立刻消失。这个坑不一定和性能有关但比性能更隐蔽正好印证了“不要用子查询”的另一个理由语义陷阱太多。5.2 三层嵌套子查询把一个接口拖到30秒某报表接口SQL大意是外层查订单中间层取订单明细聚合最内层再过滤商品分类。EXPLAIN一看中间层被物化成临时表临时表上没有索引外层只能全表扫临时表。当时订单表300万行中间层结果集也有近百万行接口直接超时。后来把最内层分类过滤提到JOIN条件上再配合临时表加索引整体耗时降到0.5秒效果立竿见影。这个案例说明子查询未必一定要完全消掉但不合理的层级和物化位置一定不能留。5.3 8.0的Hash Join让某些子查询反而可用了MySQL 8.0在等值连接场景引入了Hash Join这让一些原本性能很差的非相关子查询在物化后可以通过Hash Join实现高效匹配。之前某次升级后原本计划改写的IN子查询实测执行计划已经走Hash Join性能比预期的JOIN改写还好。这提醒我不要抱着“子查询永远是错的”这样的老观念升级版本后要重新验证优化器会进步但前提是你懂得看执行计划去判断。6. 不同版本下的行为差异与升级评估子查询性能问题的讨论很大程度上受MySQL版本影响。如果你还在用5.5或更早的版本任何子查询都建议谨慎使用因为执行器几乎没有优化能力逐行执行的代价非常高。5.6引入了半连接和物化的雏形5.7进一步成熟8.0加入了Hash Join和更完善的转换逻辑。版本升级后原有SQL的性能可能发生很大变化。我们曾经在升级8.0之后发现原本慢的子查询自动被优化得不错但也有过相反的例子原本依赖某种隐式优化的SQL因为优化器策略变化导致执行计划退化。所以每当我听到“某某写法一定慢”或者“某某写法一定快”的论调都会建议先看当前版本的实际执行计划再下结论。在评估是否升级或者是否要在新代码里使用子查询时我一般会关注三个点线上MySQL版本、目标SQL的子查询结构、以及EXPLAIN输出中select_type和Extra的具体表现。版本越新可用的优化手段越多但判断依据永远来自执行计划这个事实标准。最后说点个人建议写了这么多还是想强调子查询本身不是洪水猛兽MySQL真正的问题在于不同版本下优化策略的不确定性。与其说“不用子查询”不如说“不盲目使用子查询”——用之前先用EXPLAIN看清代价用之后用慢日志验证效果把决策建立在数据和执行计划上而不是听信传言。我个人的兜底执念是任何涉及大数据量表、高并发接口的SQL如果发现子查询没法确认走半连接或物化且索引可控就立刻拆开重写哪怕多写几行SQL也别留隐患。MySQL的优化器虽然一直在进步但线上业务不会给你试错的余量。你的SQL写得再优雅执行计划不认账照样会把数据库拖垮。以上都是我在真实项目里反复踩坑、反复验证后积累的经验。下次再看到子查询的性能问题建议先EXPLAIN看一眼再决定是保留、改写还是升级完再来观察。毕竟优化SQL这件事永远以实际执行为准而不是以“哪个写法好看”为准。