SQL 相关子查询:性能杀手及其优化方法
SQL 相关子查询:性能杀手及其优化方法
您刚刚将新的 SQL 查询部署到生产环境。在测试期间它运行得完美无缺——顺畅、快速,没有问题。但现在,三个小时后,您的老板站在您的办公桌前,问为什么仪表板超时了。
您检查日志。在测试数据库中只有 100 行数据时,这个查询只需 0.2 秒,而在生产环境中 10,000 行数据时,现在却需要 45 秒。同样的查询。同样的逻辑。什么改变了?
让我向您展示罪魁祸首:
-- 找出薪资高于部门平均值的员工
SELECT first_name, last_name, salary, department_id
FROM employees e
WHERE salary > (SELECT AVG(salary)FROM employeesWHERE department_id = e.department_id
);
看起来无辜,对吧?这是一个相关子查询,它运行了 10,001 次而不是一次。到本文结束时,您将知道如何识别这些性能杀手,并用三种不同的方式修复它们。让我们深入探讨。
什么让相关子查询如此缓慢?
这里是您需要理解的关键区别:
非相关子查询运行一次并返回结果:
-- 这只运行一次,然后过滤所有行
SELECT first_name, salary
FROM employees
WHERE salary > (SELECT AVG(salary) FROM employees);
执行:1 次子查询 + 1 次外层查询 = 总共 2 次操作
相关子查询引用外层查询,并每行运行一次:
-- 这每位员工运行一次
SELECT first_name, salary, department_id
FROM employees e
WHERE salary > (SELECT AVG(salary)FROM employeesWHERE department_id = e.department_id -- 引用外层查询!
);
执行:10,000 行 × 每行 1 次子查询 = 10,001 次操作
这里是视觉分解:
| 方面 | 非相关子查询 | 相关子查询 |
|---|---|---|
| 引用外层查询? | ❌ 否 | ✅ 是 |
| 执行次数 | 总共 1 次 | 每行一次 |
| 10K 行 = | 2 次操作 | 10,001 次操作 |
| 性能 | 快速 ⚡ | 缓慢 🐌 |
如何识别它们
在子查询中寻找外层表的引用:
WHERE price > (SELECT AVG(price) FROM products WHERE category_id = p.category_id -- ⚠️ 引用外层表 "p"
)
💡
经验法则:如果您的子查询内部有 e.、p. 或任何外层表别名,它就是相关的,并且可能很慢。
现在您能识别问题了,让我们修复它。
3 步修复框架
在深入代码之前,这里是您的决策树:
JOIN + GROUP BY — 最适合聚合(AVG、SUM、COUNT)
窗口函数 — 最适合需要详细行和聚合时
EXISTS 优化 — 最适合存在检查(已经高效,但可以改进)
快速指南:
需要与平均值或总数比较? → JOIN + GROUP BY
需要原始行加上计算? → 窗口函数
只检查是否存在? → EXISTS(或转换为 JOIN)
让我们用真实示例探索每个解决方案。
解决方案 1:重写为 JOIN + GROUP BY
这是聚合比较的最常见修复。策略:先预计算聚合,然后连接到它们。
原始(缓慢) ❌
-- 找出薪资高于部门平均值的员工
SELECT first_name, last_name, salary, department_id
FROM employees e
WHERE salary > (SELECT AVG(salary)FROM employeesWHERE department_id = e.department_id
);
-- 10,000 行 = 10,001 次子查询执行
重写(快速) ✅
-- 预计算部门平均值,然后连接
SELECT e.first_name, e.last_name, e.salary, e.department_id
FROM employees e
JOIN (SELECT department_id, AVG(salary) AS avg_salaryFROM employeesGROUP BY department_id
) dept_avgON e.department_id = dept_avg.department_id
WHERE e.salary > dept_avg.avg_salary;
-- 总共 2 次操作:聚合一次,连接一次
性能差异
| 方法 | 操作次数 | 时间(10K 行) |
|---|---|---|
| 相关子查询 | 10,001 | 45 秒 ⏱️ |
| JOIN + GROUP BY | 2 | 0.8 秒 ⚡ |
改进:快 56 倍!
另一个示例:产品高于类别平均值
-- 使用派生表的快速版本
SELECT p.product_name, p.price, p.category_id, cat_avg.avg_price
FROM products p
JOIN (SELECT category_id, AVG(price) AS avg_priceFROM productsGROUP BY category_id
) cat_avgON p.category_id = cat_avg.category_id
WHERE p.price > cat_avg.avg_price;
为什么有效:内层查询运行一次,为所有类别计算平均值,然后外层查询连接到它。您不是计算平均值 15 次(每个产品一次),而是计算 3 次(每个类别一次)并连接。
✅
专业提示:您可以免费将平均值作为结果中的一列添加——只需在 SELECT 子句中包含 cat_avg.avg_price!
解决方案 2:使用窗口函数
这是优雅的解决方案,当您需要一个结果集中既有细节又有聚合时。
为什么用窗口函数?
有时您想要:
原始行(员工姓名、个人薪资)
聚合计算(部门平均值)
所有在一个干净的结果集中
JOIN 可以做到,但窗口函数更干净、更易读。
原始(缓慢相关) ❌
SELECT first_name,salary,(SELECT AVG(salary)FROM employees e2WHERE e2.department_id = e1.department_id) AS dept_avg
FROM employees e1;
-- 计算平均值 10,000 次!
使用窗口函数重写 ✅
SELECT first_name,salary,department_id,AVG(salary) OVER (PARTITION BY department_id) AS dept_avg,salary - AVG(salary) OVER (PARTITION BY department_id) AS diff_from_avg
FROM employees;
-- 每个部门计算平均值一次,而不是每行一次!
奖金:现在您可以轻松显示每个员工薪资与部门平均值的差异!
真实示例:产品定价分析
SELECT product_name,price,category_id,AVG(price) OVER (PARTITION BY category_id) AS category_avg,price - AVG(price) OVER (PARTITION BY category_id) AS price_vs_avg,RANK() OVER (PARTITION BY category_id ORDER BY price DESC) AS price_rank_in_category
FROM products
ORDER BY category_id, price_rank_in_category;
这个单一查询为您提供:
每个产品的价格
类别平均价格
每个产品高于/低于平均值的金额
类别内排名
试试用相关子查询高效地做到这一点!
💡
初学者提示:将 OVER (PARTITION BY ...) 视为“GROUP BY,但保留所有行。”您获得聚合而不折叠结果集。
解决方案 3:优化 EXISTS 模式
这里有个好消息:带有 EXISTS 的相关子查询已经相当高效。一旦找到第一个匹配,它们就会停止搜索。但有时 JOIN 还是更快。
原始(已经相当快) ⚡
-- 找出下过订单的客户
SELECT customer_id, first_name, last_name
FROM customers c
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id
);
-- 在第一个匹配订单处停止(高效!)
替代:JOIN(大数据集上通常更快) ⚡⚡
-- 相同结果,在大数据集上可能更快
SELECT DISTINCT c.customer_id, c.first_name, c.last_name
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id;
-- 现代数据库优化得很好
何时坚持使用 EXISTS
✅ 当以下情况时使用 EXISTS:
您的子查询有多个条件
可读性比 0.1 秒差异更重要
您在检查 NOT EXISTS(比 LEFT JOIN ... WHERE NULL 更安全)
NOT EXISTS 模式
-- 从未下过订单的客户
SELECT customer_id, first_name, last_name
FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id
);
对于这个模式,坚持使用 NOT EXISTS。它更清晰、更安全。
⚠️
重要:EXISTS 只检查存在性。内部 SELECT 什么并不重要——使用 SELECT 1 作为惯例。它比 SELECT * 略微高效。
您的行动计划:今天修复缓慢查询
准备优化您的查询了吗?遵循这个路线图:
立即行动
- 审计您的查询——在代码库中搜索引用外层表的子查询:
-- 寻找这样的模式:
WHERE column > (SELECT ... WHERE table.id = outer.id)
WHERE EXISTS (SELECT ... WHERE table.id = outer.id)
- 基准测试前后——使用 EXPLAIN ANALYZE (PostgreSQL) 或 EXPLAIN (MySQL):
EXPLAIN ANALYZE
SELECT ... -- 您的原始查询EXPLAIN ANALYZE
SELECT ... -- 您的优化版本
比较执行时间。您应该看到显著改进。
- 从最严重的开始——关注:
SELECT 子句中带有相关子查询的查询
运行在 10,000+ 行表上的查询
用户抱怨的查询
练习挑战
尝试用三种不同方式重写这个缓慢查询:
-- 挑战:找出价格高于类别平均值的产品
-- 原始(缓慢):
SELECT product_name, price
FROM products p
WHERE price > (SELECT AVG(price) FROM products WHERE category_id = p.category_id
);
轮到您了:
使用 JOIN + GROUP BY 重写
使用窗口函数重写
您会选择哪种方法,为什么?
在评论中发布您的解决方案——我很想看到您的做法!
