相关子查询默认走nested loops,因其无法被优化器重写为join,只能逐行求值,天然匹配nl语义;即使内层有索引,仍需执行n次,导致时间与外层行数线性增长。

相关子查询为什么默认走Nested Loops
因为数据库优化器几乎无法将相关标量子查询(比如 SELECT (SELECT COUNT(*) FROM logs l WHERE l.user_id = u.id) FROM users u)重写为等价 JOIN,只能逐行求值:外层每返回一行 u,就执行一次内层查询。这天然对应 Nested Loops 的语义结构——没有“批量匹配”的前提,也就谈不上 Hash 或 Merge Join。
常见错误现象是:明明 logs.user_id 有索引,EXPLAIN 却显示内层是 Index Scan + Nested Loop,且 Rows Removed by Filter 很高;或者执行时间随外层行数线性增长,100 行要 1 秒,1000 行就 10 秒。
- PostgreSQL 和 SQL Server 都不支持把相关标量子查询自动转成半连接(semi-join)或聚合 JOIN,MySQL 8.0+ 也仅对极少数简单模式做 subquery unnesting,多数情况仍保留嵌套逻辑
- 即使内层加了索引,也只是把单次查找从全表扫描降为索引定位,但“调用 N 次”这个行为本身没变
- 如果子查询里用了
ORDER BY ... LIMIT 1或窗口函数,优化器更不敢展开,宁可保守走 NL
怎么确认是不是相关子查询拖慢了查询
看执行计划中最关键的两个信号:
- 外层节点(如
Seq Scan on users)下方紧跟着一个带Nested Loop的节点,且该节点的Inner Unique是false(PostgreSQL)或dependent subquery(MySQLtype字段) - 内层节点的
Actual Total Time× 外层Actual Loops接近总耗时 —— 这说明时间确实花在反复调用上 - 用
EXPLAIN (ANALYZE, BUFFERS)查Shared Hit Blocks:如果内层每次只读少量块(比如几十),但Loops达到上千,就是典型的相关子查询放大 I/O
改写为 JOIN 时最容易漏掉的细节
把 (SELECT COUNT(*) FROM logs l WHERE l.user_id = u.id) 改成 LEFT JOIN ... GROUP BY 看似简单,但实际踩坑率极高:
- 一对多关系会放大外层行数:如果一个用户有 5 条日志,
JOIN后users行就被复制 5 次,COUNT(*)没问题,但若同时查u.name就可能重复返回 - 必须加
DISTINCT或用GROUP BY u.id, u.name,否则聚合前的数据膨胀会让结果错乱 - 如果原意是“取最新一条日志的字段”,不能直接
JOIN,得先用ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC)做预过滤 -
LEFT JOIN保证用户不丢,但若日志表为空,COUNT(*)返回0;而原标量子查询在无匹配时也返回0,语义一致;但若用INNER JOIN就会漏掉没日志的用户
什么时候不该强行改写
不是所有相关子查询都适合 JOIN 化。以下情况保持原样反而更稳:
- 子查询只查主键或唯一约束列(如
(SELECT email FROM profiles p WHERE p.user_id = u.id)),且profiles.user_id是唯一索引:此时 NL 实际就是一次索引等值查找,比 JOIN + GROUP BY 的开销还低 - 外层结果集极小(
- 数据库版本太老(如 PostgreSQL 9.6 以下、MySQL 5.7),优化器对复杂 JOIN 的代价估算不准,强行改写可能触发更差的执行计划
真正卡住性能的,往往不是“能不能改写”,而是没确认外层过滤后还剩多少行、内层索引是否真被用上、以及改写后聚合维度有没有对齐原始语义——这三个点没对齐,JOIN 改写只会引入新 bug。










