上周一条慢查询把我整得挺难受:十几万行的订单表,一个统计用户累计消费的报表SQL,跑完要三秒多,把CPU打到一个核的百分之百。EXPLAIN拉出来,子查询那一行赫然写着 DEPENDENT SUBQUERY。光是看到这七个字母,我基本就能断定问题出在哪——子查询被当成“相关子查询”在执行,外层表每扫出一行,MySQL就重新执行一遍子查询,十几万行就是十几万次查询,不慢才怪。
“MySQL不使用子查询的原因”这条规范,在很多团队的开发规范里都出现过。但我一直觉得,光记住“子查询慢、JOIN快”这个结论远远不够。要知道MySQL这些年版本差异非常大,5.5、5.6、5.7、8.0对子查询的处理逻辑完全不同,一刀切地“禁用子查询”会错失很多优雅写法,而盲目“全部用JOIN”也可能写出更差的执行计划。这篇我打算把子查询慢的根源、各种版本差异、实际排查方法、以及替换方案一次讲透。
1. 最根子的问题:相关子查询的逐行执行模式
1.1 先分清“相关子查询”和“普通子查询”
很多人在讨论子查询性能时,第一句话就问错了问题。子查询本身快不快,其实要看它是不是“相关子查询”。
所谓相关子查询,就是子查询内部引用了外层查询的列,内外层之间存在关联关系。比如:
sql复制SELECT *
FROM users u
WHERE EXISTS (
SELECT 1
FROM orders o
WHERE o.user_id = u.user_id
);
子查询里的 o.user_id = u.user_id 引用了外层 u 表,这就是相关子查询。而普通子查询(非相关)是这样的:
sql复制SELECT *
FROM users
WHERE user_id IN (
SELECT DISTINCT user_id
FROM orders
);
子查询内部不引用外层任何东西,它是独立的,可以在执行时只计算一次,然后物化成临时结果供外层使用。这两种执行方式完全不同。相关子查询的坑,恰恰在于它没法“只算一次”。
1.2 相关子查询为什么慢:N+1查询风暴
MySQL从最早期版本一直到目前8.0,对相关子查询的默认处理方式都极其朴素:外层每取得一行,就把这一行的值代入子查询,重新执行一次子查询。这个动作专业点叫“逐行执行”(row-by-row execution),说人话就是——表里有10万行,子查询就跑了10万次。
举个例子,我们要找“下单次数超过2次的用户信息”:
sql复制SELECT *
FROM users u
WHERE (
SELECT COUNT(*)
FROM orders o
WHERE o.user_id = u.user_id
) > 2;
执行过程是这样的:
- 从
users表取出第一行(假设走了全表扫描); - 把这一行的
user_id代入子查询,去orders表做一次WHERE user_id = ?查询; - 统计子查询返回的
COUNT(*),判断是否大于2; - 从
users表取第二行,重复上述操作; - 直到扫完
users表所有行。
假设 users 表10万行,这个SQL实际会触发:1次全表扫描 + 10万次带索引的查询。即便每次查询只花0.1毫秒,累计也要10秒以上。这就是所谓的N+1查询问题,是关系型数据库性能优化里最经典、最容易被踩的坑之一。
N+1问题的可怕之处在于,它的问题规模是相乘的。单看单次子查询,执行计划挑不出任何毛病,走了索引、扫描行数很少、用上了最优的访问路径。但是把单次性能乘上外层行数,结果就是灾难。
1.3 EXPLAIN现场:DEPENDENT SUBQUERY长什么样
判断一个子查询是不是相关子查询,最直接的方法就是看执行计划。
sql复制EXPLAIN
SELECT *
FROM users u
WHERE (
SELECT COUNT(*)
FROM orders o
WHERE o.user_id = u.user_id
) > 2;
输出是这样的:
| id | select_type | table | type | key | key_len | ref | rows | Extra |
|---|---|---|---|---|---|---|---|---|
| 1 | PRIMARY | u | ALL | NULL | NULL | NULL | 100000 | Using where |
| 2 | DEPENDENT SUBQUERY | o | ref | idx_user | 4 | func | 3 | Using index |
注意第二行的 select_type,只要看到 DEPENDENT SUBQUERY,就意味着这个子查询是相关的,外层每扫一行,它就会执行一次。同时第一行里 type 是 ALL,说明外层走了全表扫描;rows=100000,那么子查询大概会被触发10万次。
一个健康的执行计划里,你不应该在主查询行和子查询行之间看到这种“外层全表 + 内层逐行”的组合,除非外层本身返回的结果集很小(比如只有几百行)。
注意:
DEPENDENT SUBQUERY并不一定每次都慢到无法接受。如果外层表数据量很小,比如只有几十行,那子查询执行几十次也没啥问题。关键看外层返回的行数,也就是N+1里的“N”到底有多大。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. IN与EXISTS的经典陷阱:老版本里被改写坏掉的子查询
2.1 为什么5.6之前的IN会变成相关EXISTS
MySQL 5.6之前,优化器对 WHERE col IN (SELECT ...) 的处理方式是统一改写成相关EXISTS。也就是说:
sql复制SELECT *
FROM orders
WHERE user_id IN (
SELECT user_id
FROM users
WHERE city_id = 1
);
逻辑上,MySQL会把它改写成等价于:
sql复制SELECT *
FROM orders o
WHERE EXISTS (
SELECT 1
FROM users u
WHERE u.city_id = 1
AND u.user_id = o.user_id
);
注意这时候EXISTS子查询引用了外层 o.user_id,它就变成了相关子查询。外层 orders 表每扫一行,就会通过 user_id 去 users 表查一次。
这种改写本身不算错,如果 orders 表几十行,性能毫无问题。但生产环境里 orders 表往往远大于 users 表,外层全表扫描的代价就非常高。更麻烦的是,老版本优化器固定选择把IN改成相关EXISTS,完全没有“把这个非相关子查询先物化一次,再JOIN一次”这个选项。于是原本可以“先查出符合条件的user_id,然后一次性关联”的子查询,被强制变成了逐行探测。
2.2 同样的SQL,数据分布不同,结果一个天上一个地下
我早年踩过一个非常典型的坑。当时线上有两张表:orders 表200万行,users 表20万行。要统计“北京用户的订单”。SQL写成了:
sql复制SELECT *
FROM orders
WHERE user_id IN (
SELECT user_id
FROM users
WHERE city = '北京'
);
北京用户当时大概有5万人。这条SQL在5.5版本的线上库跑了将近8秒。执行计划显示 orders 全表扫描,每行都去 users 表反查一次。而另一个测试环境,同样的表结构和数据量,只是 city = '上海',上海用户只有2000人,SQL跑起来只要1秒多一点。同一个执行计划,为什么性能差这么多?
原因在于 orders 表 WHERE user_id = ? 的索引等值查询,单次在百万级数据里其实很快。如果外层的目标记录在总表里占比很低(比如上海用户只关联很少的order),逐行探测的次数虽然大,但单次够快,总耗时还能接受。可当北京用户关联的order数量多,重复探测的次数也多,索引访问的累积成本就上去了。数据分布不均时,一个看似合理的执行计划会突然性能爆炸。
后来我们把SQL改成了JOIN:
sql复制SELECT o.*
FROM orders o
INNER JOIN users u ON u.user_id = o.user_id
WHERE u.city = '北京';
执行计划变成了 users 表先过滤出5万行,再嵌套循环去 orders 表通过 user_id 索引找订单。外层扫描量从200万降到了5万,总耗时降到1.5秒以内。
2.3 NOT IN带来的NULL陷阱,和NOT EXISTS的差别
跟IN相关的另一个高频坑是 NOT IN。如果说IN在老版本里是“执行效率的坑”,那NOT IN就是一个“逻辑正确性的坑”。
先看这么一条SQL:
sql复制SELECT *
FROM orders
WHERE user_id NOT IN (
SELECT user_id
FROM users
WHERE status = 1
);
如果 users 表里 user_id 字段有任何一行是 NULL,那么 NOT IN 的结果就永远是空集。原因是SQL的三值逻辑:NULL 和任何值比较,结果都是“未知”;col NOT IN (1, 2, NULL) 等价于 col <> 1 AND col <> 2 AND col <> NULL,最后一项永远为未知,整个条件永远不为TRUE。
很多系统在 user_id 这类字段上不会允许NULL,但如果子查询里有人用它查到过带NULL的中间结果,或者字段本身没加非空约束,这个坑就埋下了。
性能上,老版本MySQL对 NOT IN 的处理同样是改写成相关子查询,而且没法使用“物化 + 半连接”这样的优化策略。更稳妥的写法是 NOT EXISTS:
sql复制SELECT *
FROM orders o
WHERE NOT EXISTS (
SELECT 1
FROM users u
WHERE u.user_id = o.user_id
AND u.status = 1
);
NOT EXISTS 同样存在逐行执行的潜在问题,但它在逻辑上避开NULL陷阱,而且更符合“反连接”语义。到了MySQL 8.0,优化器对 NOT EXISTS 能更好地转换成反连接执行计划,NOT IN 有时也能做,但NULL相关的检查永远多一层顾虑。
3. 派生表物化的额外成本:临时表、索引丢失与连接顺序锁定
3.1 派生表为什么会被物化
除了WHERE条件里的子查询,FROM 子句里的派生表是另一种非常常见的写法。举一个场景:查“每个品类销量最高的商品”。很多人会写成:
sql复制SELECT *
FROM (
SELECT product_id, category_id,
ROW_NUMBER() OVER (PARTITION BY category_id ORDER BY sales_amount DESC) AS rn
FROM products
) t
WHERE t.rn = 1;
从MySQL 5.7开始支持窗口函数后,这个写法变得越来越普遍。但 FROM 子句里的子查询,叫派生表(Derived Table)。对于派生表,MySQL的默认策略是“物化”——先把子查询结果完整地算出来,再把这个结果当成临时表继续往下走。
物化是什么意思?就是执行完子查询后,把结果集写到一个内部临时表里,可能放在内存,也可能落到磁盘。之后MySQL就用这个临时表和外部查询做关联。
3.2 物化临时表的代价:内存Copy、磁盘溢出、谓词下推失败
物化的成本分几块:
第一是计算成本。子查询本身要完整执行一遍;就算外面只需要它的一小部分数据,子查询也不会为了你偷懒,它会把所有行都算完。
第二是存储成本。结果集写临时表要分配内存,要逐行拷贝。如果临时表太大,还会从内存临时表转成磁盘临时表,走一遍文件I/O。Created_tmp_disk_tables 状态值蹭蹭往上涨,你就能看到磁盘I/O飙高。
第三是谓词下推失败。什么叫谓词下推?就是把 WHERE 条件尽可能提前到表扫描阶段过滤。对于派生表,MySQL优化器通常没法把外层 WHERE 条件直接压到子查询内部去执行。也就是说,外层过滤条件明明能砍掉90%的数据,优化器却必须先算出完整的子查询结果,再在外面过滤一遍。
比如上面那个例子,如果外层改成:
sql复制SELECT *
FROM (
SELECT product_id, category_id, sales_amount,
ROW_NUMBER() OVER (PARTITION BY category_id ORDER BY sales_amount DESC) AS rn
FROM products
) t
WHERE t.rn = 1
AND t.category_id = 100;
MySQL不知道你的 category_id = 100 可以提前在 products 表扫描时就过滤,它还是会把整个 products 表的窗口函数算完,物化成临时表,再在外面加一个过滤条件。这个浪费在小表上不明显,但对百万行数据量级的表,差异是秒级甚至分钟级的。
3.3 派生表合并带来的转机,以及8.0的LATERAL
MySQL也不是一直这么笨。MySQL 5.7引入了“派生表合并”(Derived Table Merge)优化:如果派生表可以被合并,优化器会把子查询的逻辑直接展开到外层查询里,不再物化临时表。这相当于把:
sql复制SELECT *
FROM (SELECT user_id, name FROM users) u
WHERE u.city_id = 1;
改写成:
sql复制SELECT u.user_id, u.name
FROM users u
WHERE u.city_id = 1;
这样就能直接利用 users 表上 city_id 的索引,过滤也在最底层就完成了。但要注意,并非所有派生表都能被合并。只要派生表里有聚合函数、窗口函数、GROUP BY、DISTINCT、LIMIT等,MySQL就只能退回到物化模式。换句话说,真正需要“先算出一个结果集再关联”的场景,优化器并不能省掉物化这一步。
MySQL 8.0.14之后又增加了LATERAL派生表,允许子查询引用外层查询的列,这个功能在某些复杂报表里能写出很精炼的逻辑,但它本质上还是相关子查询的执行语义,性能上更要谨慎。我自己在业务代码里几乎不用LATERAL,因为它容易写出没有性能上限的查询。
注意:判断派生表有没有被物化,EXPLAIN里看
select_type和table。如果table列显示的是形如<derived2>的临时表引用,就说明发生了物化;如果看到的是原始表名,就说明成功合并了。
4. 标量子查询:SELECT清单里最隐蔽的性能地雷
4.1 一条统计SQL如何被标量子查询拖垮
前面章节讨论的都是WHERE和FROM里的子查询,还有一个同样高频的“雷区”在SELECT清单里。比如查用户的基础信息和最近一笔订单时间:
sql复制SELECT
u.user_id,
u.name,
(
SELECT MAX(o.created_at)
FROM orders o
WHERE o.user_id = u.user_id
) AS last_order_time
FROM users u;
这个子查询在SELECT清单里,每次返回一个值,叫标量子查询。它的执行方式和相关子查询一样——外层 users 表每扫一行,子查询就执行一次。 users 表10万行,就是10万次 MAX(created_at) 查询。
如果 orders 表 user_id 上有索引,单次子查询可能也就0.2毫秒,10万次就是20秒。这还没算上如果有GROUP BY、ORDER BY,排序过程还会进一步加剧CPU消耗。
同样是这个需求,改成LEFT JOIN + GROUP BY:
sql复制SELECT
u.user_id,
u.name,
MAX(o.created_at) AS last_order_time
FROM users u
LEFT JOIN orders o ON o.user_id = u.user_id
GROUP BY u.user_id, u.name;
MySQL只需扫描一张 users 全表,通过驱动表 users 去关联 orders 的索引,然后再做一次分组聚合。整个过程扫过的行数和IO次数都会比标量子查询少很多。像这种“外层大表的每一行都需要计算一个聚合值”的场景,几乎是标量子查询最典型的反面教材。
4.2 标量子查询能否命中缓存?别指望
有时候有人会想:子查询查出来的值是不是可以缓存?比如同一个user_id被查了两次,MySQL是不是能复用第一次的结果?
答案是:MySQL不会对标量子查询的结果做通用缓存。MySQL 8.0之前有Query Cache,但那是整个SQL结果的缓存,不是子查询粒度的缓存。标量子查询的执行模型就是“每行独立执行”,没有任何跨行的结果复用机制。就算外层表的同一个user_id出现了两次,子查询也会被再次执行。
所以,对于“想把子查询结果缓存复用”这个需求,唯一靠谱的解法是在应用层做缓存,或者把结果集一次性算出来放到临时表/Map里,再在内存里做关联。数据库SQL层面没有捷径。
4.3 一段实测数据:JOIN和标量子查询的对比
我之前在一台测试服务器上做过一次简单对比,两张表:
users:5万行orders:80万行,user_id有索引
查询目标:统计每个用户的订单数。
方案A,标量子查询:
sql复制SELECT
u.user_id,
(SELECT COUNT(*) FROM orders o WHERE o.user_id = u.user_id) AS cnt
FROM users u;
方案B,LEFT JOIN + GROUP BY:
sql复制SELECT
u.user_id,
COUNT(o.order_id) AS cnt
FROM users u
LEFT JOIN orders o ON o.user_id = u.user_id
GROUP BY u.user_id;
方案A执行时间约4.2秒,方案B约1.8秒。差距约2.3倍。如果表再大一点,外层行数再多一点,这个差距会进一步拉大。而且方案A对外层扫描方式非常敏感,一旦外层无法走索引,退化成全表扫描,5万行数据量就足以让查询变慢。
5. MySQL 8.0到底改了什么:子查询不再可怕了吗
5.1 半连接优化与哈希连接的演进脉络
前面讲的都是老旧版本的行为。MySQL 5.6开始引入“半连接优化”(Semi-join),专门针对 IN (SELECT ...) 和 EXISTS 这类子查询。在此之前的MySQL把IN改写成相关EXISTS是一种强制策略,但5.6之后,优化器有了更多选择:可以把子查询的结果物化,也可以把子查询转成半连接,可以用FirstMatch的方式,也可以用LooseScan的方式。这就让 IN 子查询在很多情况下执行计划和直接JOIN基本相同。
到了MySQL 8.0.18,又引入了哈希连接(Hash Join)。在没有可用索引的关联条件下,优化器可以选择把关联字段计算成哈希表,而不是对每一行做嵌套循环。对于“大表关联小表但没有索引”的场景,查询性能有了质的提升。
所以准确讲:MySQL 8.0里,WHERE user_id IN (SELECT user_id FROM users WHERE ...) 这种普通非相关子查询,大多数情况下性能已经不会比JOIN差了。优化器会自动决定是否要物化,是否要转成半连接,是否要用哈希连接。
5.2 8.0中仍保持老执行方式的场景
但有几个场景,8.0依然没法自动优化:
第一,带聚合的相关子查询。像第一节里那个“统计每个用户订单数再过滤”的写法,相关子查询仍然会逐行执行。8.0的优化器不会自动把这类相关子查询改写成JOIN。有人说8.0优化器更强了,但面对相关子查询,它的执行策略仍然是嵌套循环——除非你手动改写。
第二,窗口函数+派生表。MySQL 8.0虽然支持窗口函数,但窗口函数计算结果默认还是要生成临时表,这个环节同样存在物化开销。如果查询里既有窗口函数又有外层过滤,优化器没法把过滤条件下推。这也是8.0下我最常遇到的派生表慢查询案例。
第三,NOT IN 对NULL的处理。即使到了8.0,NULL语义带来的逻辑风险还在。优化器可以把NOT IN转换成反连接,但如果被转换后执行计划里出现 Impossible WHERE 或者奇怪的 Materialize 行为,现象可能千奇百怪。
5.3 用EXPLAIN ANALYZE做实测判断
8.0新增的 EXPLAIN ANALYZE 是一个特别有用的工具,它能真正把每个执行步骤的耗时和行数打印出来。遇到子查询性能问题,最推荐的办法就是直接跑:
sql复制EXPLAIN ANALYZE
SELECT *
FROM users u
WHERE (
SELECT COUNT(*)
FROM orders o
WHERE o.user_id = u.user_id
) > 2;
输出里会包含每个迭代步骤的平均耗时和实际行数。如果你看到类似 Nested loop inner join 后面跟着一个很大的循环次数,就能直观感受到逐行执行到底有多耗费。
EXPLAIN ANALYZE 和普通 EXPLAIN 不同:前者是真的执行SQL,会占用资源,生产环境要谨慎;但它给出的“实际行数×循环次数”对定位慢查询确实最直接。
6. 改写实战:从子查询到JOIN的完整替换方案
6.1 相关聚合子查询改JOIN+GROUP BY
最典型的改写模式:WHERE或者SELECT里的相关子查询,改写成JOIN。
场景1:查“下单次数超过2次的所有用户”。
改之前:
sql复制SELECT *
FROM users u
WHERE (
SELECT COUNT(*)
FROM orders o
WHERE o.user_id = u.user_id
) > 2;
改之后:
sql复制SELECT u.*
FROM users u
INNER JOIN (
SELECT user_id, COUNT(*) AS cnt
FROM orders
GROUP BY user_id
HAVING COUNT(*) > 2
) t ON t.user_id = u.user_id;
如果 orders 表数据量大,派生表 GROUP BY 这一步仍然要全表扫一遍加分组。相比相关子查询的N+1次访问,至少把循环次数压缩到了1次。如果 orders.user_id 有索引,更好;没有索引,8.0还能用哈希连接完成这个JOIN。
如果外层只关注“用户及其订单数”,更简单的方法是用窗口函数(8.0+):
sql复制SELECT user_id, cnt
FROM (
SELECT user_id, COUNT(*) AS cnt
FROM orders
GROUP BY user_id
HAVING COUNT(*) > 2
) t;
这里其实没用到窗口函数,就是普通的派生表。也不复杂。
6.2 EXISTS/IN改半连接的注意事项
在8.0里,IN 和 EXISTS 大多数情况下不需要手动改写。但有一个经验:如果你用的是MySQL 5.6/5.7,且确认子查询被改写成相关EXISTS,那么优先尝试改成JOIN或半连接。怎么确认?用EXPLAIN看执行计划,看到 DEPENDENT SUBQUERY 就说明没吃到半连接优化。
在8.0里,我通常保留 IN 结构,因为它可读性更好、去重逻辑也更清晰:
sql复制SELECT *
FROM orders
WHERE user_id IN (
SELECT user_id
FROM users
WHERE status = 1
);
这比JOIN版:
sql复制SELECT DISTINCT o.*
FROM orders o
INNER JOIN users u ON u.user_id = o.user_id
WHERE u.status = 1;
更简单,而且不用手动加 DISTINCT。优化器对IN子查询自动去重,JOIN却可能导致重复行。这时候IN反而是更好的语义表达。
6.3 版本兼容与执行计划验证清单
最后给出我在实际项目中总结的验证步骤:
- 先确认MySQL版本。5.5和5.6对IN的处理方式完全不同,8.0和5.7又不一样。同一个SQL在5.5上慢,不代表在8.0上也慢。
EXPLAIN看有没有DEPENDENT SUBQUERY。有,重点关注外层行数,N是否很大。- 看有没有
<derivedN>这样的临时表。有,确认子查询是否聚合了、是否有窗口函数,能不能改写合并。 - 执行
EXPLAIN ANALYZE(8.0+),对比实际耗时最大的节点。 - 改写后务必对比执行计划,别只对比耗时。因为耗时受缓存和系统负载影响大,执行计划结构稳定很多。
- 生产环境测试时,用具体页面/接口的真实SQL,不要只拿几行数据的手工查询判断。
我自己处理生产慢查询的习惯是:不管子查询还是JOIN,先把SQL“翻译”成执行计划能看懂的形式,再用 EXPLAIN 跑一遍。只有看到 DEPENDENT SUBQUERY 或者 <derived> 这种特殊标记时,才进入“要不要改写”的决策流程。
还有一个重要心得:MySQL版本升级到8.0之后,很多老的子查询“禁用手册”其实可以丢掉了。像IN子查询、非相关子查询,8.0优化器已经能自动选路。真正需要警惕的,永远是“相关子查询”和“带聚合/窗口函数的派生表”。把这两个场景的改写练熟,比死记“不用子查询”这条规则有用得多。
