子查询改写为 JOIN 的优化

子查询改写为 JOIN 的优化

子查询的性能问题

很多开发人员习惯使用子查询(Subquery),因为它的语法更贴近自然语言。但子查询在 MySQL 中的执行效率通常不如等效的 JOIN。

-- 子查询版本(自然但可能慢)
SELECT * FROM order
WHERE user_id IN (SELECT id FROM user WHERE status = 1);

-- JOIN 版本(通常更快)
SELECT o.* FROM order o
JOIN user u ON o.user_id = u.id
WHERE u.status = 1;

为什么子查询通常比 JOIN 慢

原因 1:执行顺序不同

-- 子查询的执行方式:
-- 1. 先执行外部查询:SELECT * FROM order(扫描 100 万行)
-- 2. 对每一行,执行子查询:SELECT id FROM user WHERE status = 1
-- 3. 如果子查询结果集包含当前行的 user_id,则加入结果
-- 问题:子查询被重复执行多次

-- 子查询在 MySQL 5.5 及更早版本中的执行:
-- 被"逐行执行"——对 order 的每一行都执行一次子查询
-- 这在 order 表很大时是灾难

原因 2:临时表+去重开销

-- 某些子查询类型需要创建临时表
SELECT * FROM order
WHERE user_id IN (SELECT DISTINCT user_id FROM order_item WHERE quantity > 10);
-- 子查询需要先构建 DISTINCT 的结果集,再生成临时表
-- JOIN 改写后在 ORDER BY 阶段才需要去重

原因 3:优化器限制

MySQL 5.6 之前,子查询优化能力有限。虽然 MySQL 8.0 已经大大改进,但在复杂场景下,子查询仍然可能产生次优执行计划。

具体改写场景

场景 1:IN 子查询改写为 JOIN

-- 原始:查询有过订单的用户
SELECT * FROM user
WHERE id IN (SELECT user_id FROM order);

-- 改写为 JOIN(注意去重)
SELECT DISTINCT u.* FROM user u
JOIN order o ON u.id = o.user_id;

注意:如果子查询可能产生重复值,JOIN 会引入重复行,需要用 DISTINCT 去重。

场景 2:NOT IN 子查询改写为 LEFT JOIN

-- 原始:查询没有订单的用户
SELECT * FROM user
WHERE id NOT IN (SELECT user_id FROM order);

-- 改写为 LEFT JOIN
SELECT u.* FROM user u
LEFT JOIN order o ON u.id = o.user_id
WHERE o.id IS NULL;

性能优势:NOT IN 在子查询结果集有 NULL 时会返回空集(这是重大隐患),而 LEFT JOIN 没有这个问题。

场景 3:EXISTS 子查询改写为 Semi-Join

MySQL 8.0 的优化器会自动将 IN/EXISTS 子查询转换为 Semi-Join,但某些复杂条件下优化器可能无法转换:

-- 改写前
SELECT * FROM user u
WHERE EXISTS (SELECT 1 FROM order o 
              WHERE o.user_id = u.id AND o.amount > 1000);

-- 改写后(MySQL 优化器可能自动转换为其内部形式)
-- 如果 EXPLAIN 中显示 "Start temporary" 和 "End temporary"
-- 说明已经用了 Semi-Join 优化

场景 4:标量子查询改写为 LEFT JOIN

-- 原始:查询每个用户的最新订单
SELECT u.*, 
    (SELECT MAX(amount) FROM order WHERE user_id = u.id) AS max_amount
FROM user u;

-- 改写为 JOIN + GROUP BY
SELECT u.*, o.max_amount
FROM user u
LEFT JOIN (
    SELECT user_id, MAX(amount) AS max_amount
    FROM order
    GROUP BY user_id
) o ON u.id = o.user_id;

-- 标量子查询的问题:每行 user 都会执行一次子查询
-- 改写后只需要 GROUP BY 一次所有订单,然后 LEFT JOIN

场景 5:关联子查询

-- 原始:查询价格高于平均价的商品
SELECT * FROM product p
WHERE price > (SELECT AVG(price) FROM product WHERE category_id = p.category_id);

-- 改写
SELECT p.* FROM product p
JOIN (
    SELECT category_id, AVG(price) AS avg_price
    FROM product
    GROUP BY category_id
) avg_p ON p.category_id = avg_p.category_id
WHERE p.price > avg_p.avg_price;

改写注意事项

1. 去重问题

-- 子查询:IN 自动去重
SELECT * FROM user WHERE id IN (SELECT user_id FROM order);

-- JOIN(有重复行!)
SELECT u.* FROM user u JOIN order o ON u.id = o.user_id;
-- 如果一个用户有 10 个订单,这个用户会被返回 10 次

-- 正确改写:加 DISTINCT
SELECT DISTINCT u.* FROM user u JOIN order o ON u.id = o.user_id;

2. 聚合函数的影响

SELECT * FROM order
WHERE amount > (SELECT AVG(amount) FROM order);
-- 子查询只执行一次,结果固定
-- 改写时要小心 GROUP BY 带来的语义变化

3. 检查执行计划

改写完成后一定要用 EXPLAIN 验证:

EXPLAIN SELECT DISTINCT u.* FROM user u JOIN order o ON u.id = o.user_id;
-- 查看 type 列:ref/eq_ref 或 ALL?
-- 查看 Extra 列:Using temporary 还是 Using index?
-- 比较改前后的 rows 和 key

什么时候子查询反而更好

不是所有子查询都应该改写为 JOIN。以下场景子查询更优:

  1. 聚合函数场景WHERE amount > (SELECT AVG(amount) FROM order) 只需要计算一次聚合
  2. 外层表很小:子查询对外层表每个值执行,外层表很小时代价低
  3. LIMIT 配合SELECT * FROM user WHERE id = (SELECT user_id FROM order LIMIT 1) 只需要获取一条

面试要点

  • 子查询改为 JOIN 是经典优化手段,但不是万能方案
  • IN 子查询的结果集较大时,改为 JOIN 收益显著
  • NOT IN 必须改为 LEFT JOIN(避免 NULL 的问题)
  • 改成 JOIN 后要注意 DISTINCT 去重
  • 改写后务必用 EXPLAIN 验证是否真的更快
  • MySQL 8.0 的子查询优化已经很好,但仍不如熟练的手动业务改写可靠
© 版权声明
THE END
喜欢就支持一下吧
点赞13 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容