关键词:子查询;JOIN;半连接;物化;查询优化器;SQL改写
大家好,我是小耶,写功课只是为了我踩过的坑,你们别再踩了!
上周我们讲了COUNT进阶,这周回到SQL改写的一个经典话题:子查询和JOIN,到底哪个更快?网上有很多说法,“能用JOIN就别用子查询”,但我在实际工作中见过反例——有时候子查询反而更快。今天我们从原理到实测,彻底讲透这件事。
一、子查询与JOIN的本质差异
- JOIN:你把两个朋友圈(表A和表B)的所有人直接配对,找出共同认识的人。相当于先做笛卡尔积再过滤,但优化器会通过索引、哈希等算法优化。
- 子查询:你先从表B中找出符合条件的人(子查询结果),然后拿着这个名单去表A中找对应的人。相当于分两步走。
从关系代数角度看,一个子查询可以改写为JOIN的前提是:子查询的结果集可以被“物化”为一个临时表,然后再与原表进行连接。但优化器是否会这样做,取决于代价估算。
二、优化器如何处理子查询
MySQL优化器对子查询的处理策略主要有以下几种:
| 策略 | 机制 | 适用场景 | 实例 |
|---|---|---|---|
| 半连接(semi-join) | 将IN/EXISTS子查询转换为类似JOIN的操作,但只返回外表的行 | 子查询不复杂,结果集不大 | SELECT * FROM t1 WHERE col IN (SELECT col FROM t2) |
| 物化 | 先执行子查询,将结果存入临时表(带索引),再与外表连接 | 子查询结果集较小 | 同上半连接 |
| EXISTS改写 | 对每一行外表,执行子查询判断是否存在 | 子查询结果集非常大,且外表小 | SELECT * FROM t1 WHERE EXISTS (SELECT 1 FROM t2 WHERE t2.id=t1.id) |
| 相关子查询 | 子查询依赖外表的当前行,每行执行一次 | 无法改写为JOIN时 | SELECT * FROM t1 WHERE col = (SELECT MAX(col) FROM t2 WHERE t2.id=t1.id) |
重要:MySQL从5.6版本开始引入了半连接优化,很多IN子查询会自动转为半连接,性能与JOIN相当。所以“子查询慢”的说法在MySQL 5.6+已经不成立了。
三、实测对比:子查询 vs JOIN
环境:MySQL 8.0.32,8C32G,表t1(1000万行),表t2(500万行),都有索引。
场景1:IN子查询(可转为半连接)
-- 子查询 SELECT * FROM t1 WHERE id IN (SELECT t1_id FROM t2 WHERE status = 1); -- JOIN写法 SELECT t1.* FROM t1 JOIN t2 ON t1.id = t2.t1_id WHERE t2.status = 1;实测结果:两者执行时间都在1.2-1.5秒之间,几乎无差别。查看执行计划,子查询被优化为半连接,实际执行相同的操作。
场景2:相关子查询(无法转为JOIN)
-- 子查询(每行执行一次) SELECT * FROM t1 WHERE amount > (SELECT AVG(amount) FROM t2 WHERE t2.cat_id = t1.cat_id);这种相关子查询无法直接转为JOIN,改写JOIN需要先聚合再连接:
SELECT t1.* FROM t1 JOIN (SELECT cat_id, AVG(amount) as avg_amount FROM t2 GROUP BY cat_id) t2_avg ON t1.cat_id = t2_avg.cat_id AND t1.amount > t2_avg.avg_amount;实测:子查询耗时38秒(每行执行一次子查询,1000万次),JOIN+聚合耗时0.9秒。此时JOIN完胜。
结论:能否优化的关键不在于“子查询还是JOIN”,而在于子查询是否可以被优化器转为半连接或物化。可转换的,性能相近;不可转换的(尤其是相关子查询),必须改写。
四、什么时候子查询可能比JOIN更快?
- 子查询结果集极小:物化临时表比JOIN的哈希表构建更快。
- 外表极大,子查询高度过滤:先执行子查询过滤掉大部分数据,再与外表连接,减少扫描量。
- 子查询使用了覆盖索引:子查询直接从索引返回数据,避免回表。
示例:SELECT * FROM t1 WHERE id IN (SELECT id FROM t2 WHERE status = 1 LIMIT 10),子查询只返回10个id,物化成本极低,可能比JOIN全表扫描快。
五、系统化的改写决策
| 子查询类型 | 优化器处理 | 是否建议改写 | 改写方法 |
|---|---|---|---|
IN (SELECT ...)子查询不依赖外表 | 半连接/物化 | 通常不需要 | 可保持原样 |
EXISTS (SELECT ...)外表小、子查询大 | 外层循环 | 可改写为JOIN | 改为JOIN或IN |
| 相关子查询(WHERE条件依赖外表) | 每行执行一次 | 强烈建议 | 改为JOIN+聚合 |
NOT IN子查询 | 可能产生全表扫描 | 建议 | 改为NOT EXISTS或LEFT JOIN ... IS NULL |
IN子查询结果集极大 | 物化开销大 | 可考虑改写为EXISTS | 改为EXISTS |
特别注意:NOT IN子查询在子查询结果包含NULL时,结果永远是空(因为NOT IN (NULL, 1, 2)的语义),这是SQL标准行为。建议改用NOT EXISTS或LEFT JOIN,避免逻辑错误。
六、实战案例:优化一个慢查询
原SQL(耗时26秒):
SELECT * FROM orders o WHERE o.status = 'PENDING' AND o.order_date > '2026-01-01' AND o.user_id IN (SELECT user_id FROM users WHERE register_date < '2025-01-01');执行计划显示:子查询被物化(用了索引),但物化表有200万行,然后外层表扫描后与物化表半连接,效率尚可,但仍有优化空间。
改写为JOIN(耗时18秒):
SELECT o.* FROM orders o JOIN users u ON o.user_id = u.user_id WHERE o.status = 'PENDING' AND o.order_date > '2026-01-01' AND u.register_date < '2025-01-01';为什么只快了一点?因为orders表本身有1000万行,索引过滤后仍有300万行,JOIN也需要扫描这些行。进一步优化:在orders(user_id, status, order_date)上建复合索引,并将查询改为覆盖索引,再回表取其他列,最终降到6秒。
启示:不要只纠结子查询vs JOIN,索引设计往往影响更大。
七、总结
子查询和JOIN孰优孰劣,没有绝对答案,取决于优化器版本、数据分布、索引设计、子查询类型。掌握优化器处理子查询的机制(半连接、物化、相关),结合EXPLAIN分析执行计划,才能做出正确的改写决策。盲目相信“永远用JOIN”可能会错过子查询在某些场景下的优势。
小耶在手,SQL 不愁
还有什么想了解的,欢迎留言!小耶一定知无不言言无不尽……我们下次见~
参考文献
- MySQL官方文档:《Subquery Optimization》
- 《高性能MySQL》第4版,第9章:查询优化