3、查询重写技巧:子查询优化为JOIN、OR条件改写为UNION、使用EXISTS代替IN、避免SELECT *
查询重写,说白了就是给SQL换个写法,让数据库执行得更快。我见过太多开发人员写完SQL就跑,结果线上慢查询一堆。其实很多时候,换个写法就能让性能翻倍。今天咱们就聊聊四个最实用的重写技巧。
核心原则:查询重写的本质是让优化器更容易找到最优执行计划。你写得越直白,数据库就越省力。
3.1 子查询优化为JOIN
子查询这东西,看着挺直观,但性能往往是个坑。尤其是相关子查询——外层每查一行,内层就要跑一次。你想想看,如果外层有10万行,内层子查询就得执行10万次,这谁受得了?
我在项目中遇到过这样一个案例:一个报表查询跑了40多秒,里面嵌套了三层子查询。改成JOIN之后,直接降到0.8秒。差距就是这么夸张。
3.1.1 标量子查询 → JOIN
先看个最常见的场景:查每个部门的最新员工入职日期。
-- ❌ 慢:标量子查询
SELECT
d.department_name,
(SELECT MAX(hire_date)
FROM employees e
WHERE e.department_id = d.department_id) AS latest_hire
FROM departments d;
-- ✅ 快:改为LEFT JOIN + 聚合
SELECT
d.department_name,
MAX(e.hire_date) AS latest_hire
FROM departments d
LEFT JOIN employees e ON d.department_id = e.department_id
GROUP BY d.department_id, d.department_name;
为什么JOIN更快?因为数据库可以一次性扫描两张表,然后做哈希连接或排序合并。而子查询是逐行触发的,索引再好也扛不住。
3.1.2 IN子查询 → JOIN
另一个高频场景:查哪些部门有员工。
-- ❌ 慢:IN子查询
SELECT department_name
FROM departments
WHERE department_id IN (
SELECT department_id
FROM employees
WHERE salary > 10000
);
-- ✅ 快:改为JOIN + DISTINCT
SELECT DISTINCT d.department_name
FROM departments d
JOIN employees e ON d.department_id = e.department_id
WHERE e.salary > 10000;
小提示:改成JOIN后别忘了加DISTINCT,否则可能产生重复行。我见过有人忘了加,结果数据翻了好几倍,排查了半天才发现。
3.1.3 NOT IN子查询 → LEFT JOIN + IS NULL
查哪些部门没有员工,这个坑更大。
-- ❌ 慢:NOT IN(还要小心NULL陷阱)
SELECT department_name
FROM departments
WHERE department_id NOT IN (
SELECT department_id
FROM employees
);
-- ✅ 快:LEFT JOIN + IS NULL
SELECT d.department_name
FROM departments d
LEFT JOIN employees e ON d.department_id = e.department_id
WHERE e.department_id IS NULL;
⚠️ 注意:NOT IN有个隐藏陷阱——如果子查询结果集中包含NULL,整个NOT IN条件会返回空集!我曾经因为这个bug,线上数据漏了整整一周才发现。用NOT EXISTS或LEFT JOIN + IS NULL可以完美避开这个问题。
3.2 OR条件改写为UNION
OR条件看着简单,但数据库优化器往往拿它没办法。为什么呢?因为OR意味着两个条件都可能成立,索引选择会变得很纠结。
我习惯把OR条件拆成UNION ALL,尤其是当OR两边的条件分别能走不同索引的时候。
-- ❌ 慢:OR条件(可能全表扫描)
SELECT * FROM orders
WHERE status = 'PENDING'
OR created_at > '2024-01-01';
-- ✅ 快:拆成UNION ALL(各自走索引)
SELECT * FROM orders
WHERE status = 'PENDING'
UNION ALL
SELECT * FROM orders
WHERE created_at > '2024-01-01'
AND status != 'PENDING'; -- 去重逻辑
为什么要加status != 'PENDING'?因为UNION ALL不会去重,如果某条记录同时满足两个条件,就会重复出现。如果你需要去重,用UNION(但UNION有排序开销,性能不如UNION ALL)。
| 写法 | 索引利用 | 去重 | 适用场景 |
|---|---|---|---|
| OR | 通常只能用一个索引 | 天然去重 | 条件简单、数据量小 |
| UNION ALL | 每个分支独立走索引 | 不自动去重 | 条件互斥、数据量大 |
| UNION | 每个分支独立走索引 | 自动去重(有排序开销) | 需要去重、数据量适中 |
实战经验:如果OR两边的条件互斥(比如status='PENDING'和status='SHIPPED'),直接用UNION ALL,连去重都不用考虑。我在电商订单查询中这么改过,响应时间从3秒降到0.2秒。
3.3 使用EXISTS代替IN
IN和EXISTS,到底哪个快?这个问题我面试时经常问。答案是:看数据量。
简单说:
- 子查询结果集小(比如几百条):IN可能更快,因为可以一次性加载到内存
- 外层表小、子查询大:EXISTS更快,因为它是半连接,找到一条就停
但实际项目中,我默认用EXISTS。为什么?因为大多数场景下,子查询的结果集都比外层大。而且EXISTS的语义更清晰——我只关心"有没有",不关心"有多少"。
-- ❌ 可能慢:IN(子查询结果集大时)
SELECT customer_name
FROM customers
WHERE customer_id IN (
SELECT customer_id
FROM orders
WHERE order_date > '2024-01-01'
);
-- ✅ 推荐:EXISTS(找到一条就停止)
SELECT customer_name
FROM customers c
WHERE EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = c.customer_id
AND o.order_date > '2024-01-01'
);
注意:EXISTS里写SELECT 1还是SELECT *,性能没区别。数据库优化器不会真的去取数据,它只关心有没有行返回。我习惯写SELECT 1,纯粹是看着清爽。
3.4 避免SELECT *
这个我多说两句。SELECT * 简直是性能杀手,但很多人就是改不掉这个习惯。
为什么不要用SELECT *?
- 浪费IO:你不需要的字段也被读出来了
- 无法走覆盖索引:索引里没有所有字段,数据库只能回表查
- 网络传输开销:数据量越大,传输越慢
- 隐藏的兼容性问题:表结构变了,你的查询结果也跟着变
-- ❌ 坏习惯:SELECT *
SELECT * FROM users WHERE email = 'test@example.com';
-- ✅ 好习惯:只取需要的字段
SELECT id, username, email, created_at
FROM users
WHERE email = 'test@example.com';
我记得有一次排查线上慢查询,发现一个接口每次返回200多个字段,但前端只用到了5个。改完之后,接口响应时间从1.2秒降到0.3秒。你想想看,这中间省了多少IO和网络传输?
⚠️ 特别提醒:如果你在写ORM框架的查询,也尽量用select()指定字段,别偷懒用findAll()或select('*')。ORM的便利性不能成为性能的代价。
3.5 知识体系总览
下面这张图把今天讲的四个技巧串起来了。你可以把它当作查询重写的"检查清单"。
这四个技巧,说白了就是让数据库少做无用功。子查询改JOIN,减少重复扫描;OR改UNION,让索引各走各的路;IN改EXISTS,找到就停;避免SELECT *,只拿你需要的。
嗯,今天就先聊到这儿。这些技巧看着简单,但真正用好需要你在实际项目中多练。下次写SQL的时候,不妨多问自己一句:这个写法,还能不能更好?