
子查询和 JOIN 哪个性能更好这个问题我在面试里问过不少人也在无数技术群里看过大家争论。标题说“90% 的开发者都用错了”虽然这个数字没法考证但有一件事是确定的绝大多数人写 SQL 都靠直觉写出来的结果能跑对就觉得万事大吉压根不去看执行计划。等线上慢查询报表贴出来才发现当初那个“看起来很自然”的写法把数据库坑得不轻。这篇文章我准备换个角度聊。不武断地说“子查询一定差”或者“JOIN 一定好”而是从执行计划、优化器行为、数据分布这几个维度拆开讲清楚什么场景下哪种写法会出问题什么场景下其实两者没区别以及怎么判断你的 SQL 到底选对没有。这篇文章适合正在被慢查询折磨的同学也适合写了几年 SQL 但从来没认真读过 EXPLAIN 输出的人。1. 子查询和 JOIN 的本质区别先搞懂数据库是怎么执行的1.1 一个是“嵌套思维”一个是“平面思维”很多开发者在刚学 SQL 的时候都会形成一种思维定式子查询就是“先查一个结果再用这个结果去过滤外层”JOIN 就是“把两张表拼在一起”。逻辑上这么理解没问题但在数据库执行引擎里这个想法往往和实际执行路径差着十万八千里。子查询的逻辑起点是嵌套。你写了一个括号块数据库可以把它当作一个独立单元先计算也可以把它“炸开”融进外层查询。JOIN 的逻辑起点是关联。数据库会基于连接条件选择驱动表外表和被驱动表内表然后对每一行去匹配。这两种逻辑模型在优化器眼里是可以互相转化的——你的 SQL 写成子查询优化器可能给你改写成 JOIN你写成 JOIN优化器也可能改写成子查询甚至改写成完全不相干的执行方式。我举个最典型的例子。你要查出“下过订单的用户”可以写-- 写法AJOIN DISTINCT SELECT DISTINCT u.* FROM users u JOIN orders o ON o.user_id u.id; -- 写法BEXISTS 子查询 SELECT * FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id u.id); -- 写法CIN 子查询 SELECT * FROM users u WHERE u.id IN (SELECT user_id FROM orders);这三条 SQL 在 MySQL 8.0 里往往会生成类似的执行计划因为优化器会把 IN 子查询改写成半连接semi-join把 EXISTS 保留为相关子查询但实际执行时也可能走同一条路径。你看到这里可能会问那是不是写什么都无所谓不是这只是“可能被改写”的情况。优化器改写是有条件的数据量、索引、统计信息、版本差异都会影响它到底改写还是不改写。1.2 现代优化器早就学会了“偷偷改写”如果你还停留在“子查询就一定是先执行内层、再执行外层”这个认知上那你对现代数据库的误解就太大了。MySQL 5.6 开始引入了半连接优化和子查询物化MySQL 5.7 加入了 derived_merge 优化MySQL 8.0 又进一步增强了优化器的改写能力。PostgreSQL 和 Oracle 在这方面的能力更不用多说。举一个常见的例子。你要查“最近30天下过单但没退过货的用户”写成SELECT * FROM users u WHERE u.id IN ( SELECT user_id FROM orders WHERE created_at NOW() - INTERVAL 30 DAY ) AND u.id NOT IN ( SELECT user_id FROM refunds WHERE created_at NOW() - INTERVAL 30 DAY );在很多版本的 MySQL 里这条 SQL 会被优化器改写成两个半连接。但如果你用的是老版本或者子查询里有聚合函数、LIMIT、GROUP BY 等复杂结构优化器可能就放弃改写了直接选择物化子查询——也就是把子查询的结果集先塞进一张临时表再拿临时表去和你外层表关联。这里的核心启示是不要用自己的“逻辑直觉”去断言 SQL 的性能一切以执行计划为准。你写的是子查询还是 JOIN 没那么重要重要的是数据库最终选择了什么样的执行路径。2. 90% 开发者踩坑的典型场景这些写法最容易翻车2.1 相关子查询最容易被忽视的逐行执行很多开发者在对比“子查询 vs JOIN”的时候心里想的是“非相关子查询”——比如WHERE id IN (SELECT ...)这种子查询和外层查询没有直接引用关系。但在实际业务代码里相关子查询Correlated Subquery才是真正的重灾区。相关子查询是指子查询里引用了外层查询的列。举个例子你要查出“每个用户最新的一笔订单”SELECT u.id, u.name, (SELECT o.order_no FROM orders o WHERE o.user_id u.id ORDER BY o.created_at DESC LIMIT 1) AS latest_order_no FROM users u;这条 SQL 看起来挺优雅的——外层遍历用户内层给每个用户查最新订单。但问题的严重性就在“外层遍历用户”这几个字上。如果 users 表有 10 万行这个标量子查询就要执行 10 万次。哪怕内层查询走了索引10 万次索引查找累加起来也是一个非常可观的耗时。我见过最离谱的案例是一个订单导出功能先查了 5000 个用户然后在循环里对每个用户执行一次“查订单”的 SQL。开发同学说“每条 SQL 都很快1 毫秒都不到”但 5000 条就是 5 秒。他把循环里的 SQL 写成了相关子查询问题一模一样。相关子查询的性能问题不是单次执行的耗时而是执行的次数。怎么定位这类问题看 EXPLAIN 输出里的dependent关键字。MySQL 会在 select_type 字段显示DEPENDENT SUBQUERY看到这个词就要警觉了这往往意味着外层每一行都会触发一次子查询。当然优化器有时候也会把相关子查询改写成 JOIN但改写的条件比较苛刻不如直接在写法上就避免。2.2 标量子查询优雅背后的 N1 问题比相关子查询更容易被忽略的是 SELECT 子句里的标量子查询。它本质上也是一种相关子查询只不过藏在投影列中不像 WHERE 条件里的子查询那么显眼。SELECT u.name, (SELECT COUNT(*) FROM orders o WHERE o.user_id u.id) AS order_count FROM users u;这段 SQL 依然是用户表每一行执行一次子查询。当 users 表有几十万行时如果 orders 表上没有合适的索引这条看起来“短小精悍”的 SQL 可能会让数据库 CPU 直接飙满。有些同学可能会想“那我用 JOIN 改写不就行了”SELECT u.name, COUNT(o.id) FROM users u LEFT JOIN orders o ON o.user_id u.id GROUP BY u.id, u.name;这个写法确实避免了逐行执行子查询但引入了一个新的问题JOIN 之后数据量会膨胀。如果一个用户有 100 个订单JOIN 结果里这个用户的记录会被重复 100 次再 GROUP BY 聚合。数据膨胀严重的时候临时表会变得很大性能反而不如子查询。这就引出了一个关键结论没有万能的写法只有合适的场景。标量子查询适合“外层表小、内层查询走索引”的情况JOIN GROUP BY 适合“关联字段有索引、数据量均衡”的情况。两者都可能翻车关键是你得知道自己在什么场景里。2.3 派生表临时表不是免费的说到“先算出一个结果集再参与外层关联”很多人的第一反应是 FROM 子句里的子查询也就是派生表Derived TableSELECT d.dept_name, AVG(d.avg_salary) FROM ( SELECT dept_id, AVG(salary) AS avg_salary FROM employees GROUP BY dept_id ) d JOIN departments dep ON dep.id d.dept_id GROUP BY d.dept_name;在 MySQL 5.7 之前派生表几乎必然被物化成一张临时表。物化意味着要落内存或落磁盘如果子查询结果集特别大磁盘临时表会带来巨大的 I/O 开销。你可能觉得“就一条 SQL能有多大开销”但你想想这条 SQL 每秒钟被调用十几次每次都要物化一张几十万行的临时表再关联再聚合数据库能不慢吗MySQL 5.7 之后优化器有机会把派生表合并到外层查询derived_merge从而省略掉物化这一步但前提是派生表里不能用聚合函数、DISTINCT、GROUP BY、LIMIT 等复杂结构。一旦用了这些结构优化器就无能为力了。所以你写的派生表到底会不会物化直接决定这条 SQL 是快是慢。我在做性能排查时有个习惯看到 FROM 子句里有子查询第一反应就是去 EXPLAIN 看 Extra 列有没有 Using temporary。如果有再结合 explain analyzeMySQL 8.0.18看看实际耗时大概率能找到问题。2.4 JOIN 失控重复行和数据膨胀聊完子查询的坑回过头看 JOIN。很多开发者以为“JOIN 就是性能最优解”这种想法也不对。JOIN 最大的隐藏问题就是结果集膨胀。举个例子一个用户有多个地址你要查用户的订单数SELECT u.id, COUNT(o.id) FROM users u LEFT JOIN orders o ON o.user_id u.id LEFT JOIN user_addresses addr ON addr.user_id u.id GROUP BY u.id;这条 SQL 看起来没问题但实际执行时orders 和 user_addresses 两张表会先分别和 users 关联然后中间结果集是“订单数 × 地址数”。如果一个用户有 10 个订单、5 个地址JOIN 之后这个用户就有 50 行。COUNT(o.id)的结果就完全错了——不是 10而是 50。即使你改用 COUNT(DISTINCT o.id) 来修正性能也已经亏了数据库为了去重必须排序或者建哈希这个开销完全是可以避免的。更麻烦的是当多个 JOIN 串在一起时中间结果集的膨胀是乘数级的。两个一对多 JOIN 一叠结果集可能翻几十倍。数据库优化器可以在一定程度上提前聚合来避免膨胀但并非所有情况都能做到。JOIN 不是免费的也不是永远安全的。它不是“性能问题的解药”只是另一种实现手段。你选择 JOIN就必须清楚关联字段的基数关系——是一对一、一对多还是多对多这会直接决定 JOIN 后会不会产生重复数据。3. 性能大比拼到底什么场景该选谁3.1 存在性检查EXISTS、IN、JOIN 的真实对比“判断某条记录在另外一张表里是否存在”是最常见的需求也是“子查询 vs JOIN”争论最多的场景。我用一张表把几种写法的特点总结一下写法语义主要风险适用场景JOIN DISTINCT关联后去重中间结果集膨胀去重代价高小表关联且需要返回关联表的字段EXISTS半连接找到即停相关子查询时可能逐行执行外查询小、内查询大且有索引IN 子查询可能被改写成半连接或物化老版本可能物化子查询结果集不大NOT IN对 NULL 敏感结果可能错子查询有 NULL 会全部返回空明确无 NULL或排除空值我直接给结论在判断“是否存在”时MySQL 8.0 和 PostgreSQL 里EXISTS 和 IN 子查询经过优化器改写后执行计划往往已经一样了。真正容易出事的反而是 JOIN DISTINCT——它在语义上没问题但 DISTINCT 通常要比 EXISTS 做更多工作特别是在大表关联且只需要判断存在性的时候完全没有必要把整行数据捞出来再去重。如果是在“判断不存在”的场景情况又不一样了。很多人习惯写SELECT * FROM users u WHERE u.id NOT IN (SELECT user_id FROM orders);这条 SQL 在 order_id 为 NULL 时会出大问题——子查询结果里只要有一个 NULLNOT IN 的最终结果就是“完全没有结果”。要修就得加WHERE user_id IS NOT NULL或者改用 NOT EXISTS 和 LEFT JOIN ... IS NULL。我在代码评审里看到 NOT IN 就会条件反射地提醒一句确认过 NULL 再这么写。3.2 一对多关联JOIN 胜出的理由在绝大多数一对多关联查询里JOIN 是比逐行子查询更优的选择。原因很简单JOIN 是集合运算数据库可以基于索引进行批量匹配而逐行的相关子查询是“一行一查”无论索引多好都无法掩盖调用次数过多的问题。比如“查出每个用户的订单数和总金额”用 JOIN GROUP BY 一次扫描就能完成SELECT u.id, u.name, COUNT(o.id) AS order_count, COALESCE(SUM(o.amount), 0) AS total_amount FROM users u LEFT JOIN orders o ON o.user_id u.id GROUP BY u.id, u.name;这里有个小前提users 表是主表查询结果以用户为主体。LEFT JOIN 保证没有订单的用户也会出现在结果里COUNT 和 SUM 搭配 COALESCE 处理空值。这个写法在 orders.user_id 有索引的情况下执行效率远高于逐行子查询。不过要注意GROUP BY 的列必须和外层查询返回的非聚合列完全一致否则 MySQL 的 ONLY_FULL_GROUP_BY 模式会直接报错。这也是很多开发者在 JOIN 改写时卡壳的原因。建议 GROUP BY 直接跟主键比如GROUP BY u.id这样既能保证语义正确也能避免 GROUP BY 大量文本字段带来的额外开销。3.3 聚合场景子查询常常更直观JOIN 容易翻车有一类场景子查询反而比 JOIN 更好那就是“和聚合结果做比较”。比如你要查“所有超过平均金额的订单”SELECT * FROM orders WHERE amount (SELECT AVG(amount) FROM orders);这个标量子查询只执行一次开销极小语句又直观。你要是强行用 JOIN 改写就得先算平均值再做关联写起来绕一大圈性能还不一定更好。所以不用因为“子查询有坑”就一棍子打死子查询唯一的性能优势就在这里——标量聚合子查询只执行一次耗时几乎可以忽略。再比如“每个部门中工资高于该部门平均工资的员工”如果不用窗口函数就只能用相关子查询或者 JOIN 派生表-- 相关子查询写法简单但可能慢 SELECT e.* FROM employees e WHERE e.salary ( SELECT AVG(salary) FROM employees e2 WHERE e2.dept_id e.dept_id );当部门数量很少、员工数量很多时这个相关子查询执行次数 员工数每个员工都要对同部门的员工做一次聚合。如果部门表只有 20 个那就是 20 次聚合的结果被反复计算了 20 万次。更合理的做法是先把部门平均值算出来再 JOINSELECT e.* FROM employees e JOIN ( SELECT dept_id, AVG(salary) AS avg_salary FROM employees GROUP BY dept_id ) d ON d.dept_id e.dept_id WHERE e.salary d.avg_salary;这个改写把聚合从“每个员工各算一遍”变成了“每个部门算一遍”虽然多了一次派生表物化的可能性但如果部门数量不多代价完全可接受性能通常会有数量级的提升。3.4 大数据量下优化器改写是决定性因素聊了这么多场景你会发现一个规律同样的 SQL 写法在不同数据库、不同版本、不同数据分布下的表现可能完全相反。这背后的决定性因素就是优化器的改写能力。以 MySQL 为例看一下 IN 子查询的处理逻辑MySQL 5.5 及更早版本IN 子查询通常先物化再和外层表做关联子查询结果集一大性能就崩。MySQL 5.6引入了半连接优化执行器可以把 IN 改写成类似 EXISTS 或 JOIN 的语义找到一条匹配记录就停止。MySQL 5.7优化器会在物化和半连接之间根据成本模型自动选择。MySQL 8.0引入了哈希连接hash join等值关联在无索引情况下也可以走哈希关联这是重大进步。所以同样是WHERE user_id IN (SELECT ...)在 MySQL 5.5 和 5.7 上的性能可能相差几十倍。你要是在网上看到一篇 2015 年的文章说“子查询性能奇差”放在今天可能完全不适用。这也是为什么我一直强调判断性能问题不要依赖经验主义一定要看当前版本的实际执行计划。PostgreSQL 的优化器比 MySQL 更激进。它会把 EXISTS、IN、JOIN 统一转化为相同的内部表示再通过代价模型决定最终的执行方式。所以在 PostgreSQL 里纠结“用 EXISTS 还是 JOIN”往往没有意义。但 PostgreSQL 处理相关子查询的能力同样有限逐行执行的代价依然存在。Oracle 的优化器则提供了丰富的 hint 来控制改写行为充分体现了“优化器是人写的不是万能的”这句话。4. 实操指南从执行计划到 SQL 改写一步步把 SQL 调优落地4.1 用 EXPLAIN 看清真实执行路径无论你写的是子查询还是 JOIN上线之前都应该花一分钟看执行计划。这是成本最低、收益最高的性能保障手段。MySQL 里直接跑EXPLAIN SELECT u.id, u.name, COUNT(o.id) FROM users u LEFT JOIN orders o ON o.user_id u.id GROUP BY u.id;重点看这几列typeALL 表示全表扫描ref / eq_ref 表示走了索引。性能从好到差大概是 const eq_ref ref range index ALL。Extra 里的 Using temporary表示查询过程创建了临时表需要重点关注。Extra 里的 Using filesort表示走了排序数据量大时可能要优化。rows优化器估算的扫描行数。如果和实际行数误差很大可能是统计信息过期了。MySQL 8.0.18 之后还可以用EXPLAIN ANALYZE获取实际执行时间和循环次数这个输出比 EXPLAIN 更直观。比如EXPLAIN ANALYZE SELECT u.id, u.name, COUNT(o.id) FROM users u LEFT JOIN orders o ON o.user_id u.id GROUP BY u.id;输出里会包含每个步骤的实际耗时和执行行数你一眼就能看出哪一步最耗时。如果是 PostgreSQL对应的命令是EXPLAIN (ANALYZE, BUFFERS)Oracle 则是DBMS_XPLAN.DISPLAY_CURSOR。注意EXPLAIN 是估算EXPLAIN ANALYZE 是实际执行。在只有 EXPLAIN 的情况下不要把 rows 字段当成精确值它只是优化器基于统计信息的猜测。4.2 三步改写法换写法、加索引、调顺序当一条 SQL 因为子查询或 JOIN 写法导致性能问题时我建议按下面这个顺序去处理第一步确认执行计划的瓶颈点。是临时表太大还是驱动表全表扫描还是排序代价高先定位再动手。第二步选择改写策略。相关子查询慢优先考虑改写为 JOIN 或派生表 JOIN 聚合外层表小、内层表大且有索引时保留 EXISTS 反而更好如果子查询结果集很小比如几百行用 IN 物化也没问题。没有固定答案只有针对瓶颈的针对性改写。第三步检查索引是否匹配。很多时候不是 SQL 写法的问题而是索引缺失。比如外键列orders.user_id没有索引那么无论你写 JOIN 还是子查询关联都会变成全表扫描。为高频关联字段建立索引通常比纠结写法更有效。举一个具体的例子。假设有一张用户表 20 万行订单表 500 万行要查“下过订单的用户”。如果 orders.user_id 没有索引EXISTS 写法和 JOINGROUP BY 都会很慢因为被驱动表要做全表扫描。但加上CREATE INDEX idx_user_id ON orders(user_id)之后无论哪种写法都可以走索引性能差异会大幅缩小。不要过度相信某一种固定套路。SQL 调优是“成本模型”的游戏写法的优劣最终由数据库的代价估算决定。4.3 大厂“不推荐多表 JOIN”的真实原因“为什么大厂不建议使用多表 JOIN”这个问题在热搜里出现了后台也经常有读者问我。我的回答是大厂不是“不建议 JOIN”而是“不建议在大规模分布式场景下滥用多表 JOIN”。原因有几个层面。第一数据量大之后单表数据动辄上亿行多表 JOIN 的关联成本呈非线性增长一次关联就可能吃掉大量 CPU 和内存。第二业务发展到一定规模会做分库分表一张订单表拆成几十个分片跨分片 JOIN 在数据库层面根本没法执行只能靠应用层把数据捞出来再 Join。第三微服务架构下用户数据在用户服务、订单数据在订单服务、商品数据在商品服务你不可能让数据库跨服务去 JOIN只能通过接口调用把数据拼起来。所以大厂的做法是用宽表冗余、搜索引擎、OLAP 引擎、缓存等方式把“实时多表 JOIN”提前消灭掉。比如订单列表需要展示用户昵称那就直接在订单表里冗余一个 user_name 字段查询时单表搞定需要跑复杂的多维分析就同步到 ClickHouse 或者数据仓库里在 OLAP 引擎里随便 JOIN。但我要强调一点这是“业务规模倒逼的架构取舍”不是“数据库 JOIN 本身就不好”。在你的业务还在百万行级别的时候正常使用 JOIN 完全没问题强行拆开反而增加系统复杂度。你首先要做的是把 SQL 写对、把索引建对而不是参考大厂架构来削足适履。5. 常见问题与排查技巧实录5.1 慢查询排查的五个步骤——从看日志到定位根因我每次处理线上慢查询基本都按下面五步走打开慢查询日志找到具体的慢 SQL。MySQL 里用slow_query_log开关同时设置long_query_time阈值。没有具体 SQL一切优化都是空谈。用 EXPLAIN 或 EXPLAIN ANALYZE 分析执行计划。看有没有全表扫描、临时表、文件排序。一般到了这一步就能发现一大半问题。确认是不是统计信息过期了。很多“突然变慢”的 SQL其实是数据量涨了但统计信息没更新优化器选错了执行计划。执行一次ANALYZE TABLE再试试。根据瓶颈做针对性优化。缺索引加索引写法有问题改写数据倾斜就换策略。上线后观察效果不只是看单条 SQL 耗时。要看数据库整体负载、QPS、临时表创建频率等指标防止“按下一个葫芦起来一个瓢”。这个流程适用于绝大多数关系型数据库换到 PostgreSQL、Oracle 只是命令不同思路一致。5.2 典型问题速查表我整理了一份速查表你可以直接收藏遇到类似问题对照着排查现象可能原因推荐排查手段相关子查询让 CPU 飙高外层每一行都执行子查询EXPLAIN 里看 DEPENDENT SUBQUERY改写为 JOIN子查询结果集大导致慢优化器选择了物化临时表看 Extra 列 Using temporary改用 JOIN 或 EXISTSJOIN 后结果重复、COUNT 不准一对多关联导致中间结果膨胀用 EXISTS 或 COUNT(DISTINCT) 规避NOT IN 查不出任何数据子查询结果包含 NULL改写为 NOT EXISTS 或 LEFT JOIN IS NULLGROUP BY 时非聚合列过多ONLY_FULL_GROUP_BY 模式报错GROUP BY 主键或用 ANY_VALUE加索引后还是慢优化器没选到新索引ANALYZE TABLE或用 FORCE INDEX 临时验证大表 JOIN 无索引等值关联无法高效匹配建关联字段索引或考虑冗余宽表5.3 我踩过的坑一次线上“JOIN 改子查询”的复盘最后分享一个我自己踩过的坑。之前维护过一个积分系统需求是“查出有积分记录的用户列表”。一开始同事写的是 JOINSELECT DISTINCT u.* FROM users u JOIN points_log p ON p.user_id u.id WHERE p.created_at 2024-01-01;线上用户量 30 万积分记录 800 万行。这条 SQL 跑一次要 3 秒多DISTINCT 在全表关联后执行代价极高。我当时想当然地以为“子查询一定比 JOIN 好”改成了 INSELECT * FROM users u WHERE u.id IN ( SELECT user_id FROM points_log WHERE created_at 2024-01-01 );结果更慢了5 秒多。为什么因为子查询返回的 user_id 列表有几万行MySQL 选择了物化临时表临时表还没索引用 user_id 去关联 users 的时候只能全表扫临时表。后来我静下心看执行计划发现真正的瓶颈是points_log表上created_at没有索引导致无论怎么改写都要扫 800 万行。加了索引(created_at, user_id)之后EXISTS 写法 0.2 秒就出结果。复盘下来有三点教训先看执行计划再下结论别凭感觉选 SQL 写法。复合索引往往比 SQL 写法更关键。(created_at, user_id)既覆盖了过滤条件又覆盖了关联字段一次索引扫描就能完成。改写 SQL 只是手段不是目的。如果索引和统计信息没排查过改写只是在错误基础上做无用功。这个案例也再次印证了文章开头那句话两种写法本身没有绝对优劣真正决定性能的是数据库的执行路径。而执行路径由数据分布、索引设计、优化器版本共同决定。你把这三者了解清楚了写出来的 SQL 自然会又快又稳。