上一篇我们聊了函数、聚合和分组,理解了
WHERE和HAVING的执行顺序。这一篇,我们进入子查询的世界。子查询是 SQL 中表达力最强的工具之一------它让一条 SQL 可以嵌套另一条 SQL,把复杂逻辑拆解成可读的步骤。但子查询也是性能问题的高发区,尤其是相关子查询。这一篇,我们把子查询按类型拆开,逐一理解它们的执行逻辑和适用场景。

一、子查询的分类:四种形态
子查询是嵌套在另一条 SQL 中的 SELECT 语句。按返回结果的结构,可以分为四类:
| 类型 | 返回什么 | 典型位置 | 示例 |
|---|---|---|---|
| 标量子查询 | 单行单列(一个值) | SELECT、WHERE、HAVING |
WHERE amount > (SELECT avg(amount) FROM orders) |
| 行子查询 | 单行多列 | WHERE 行比较 |
WHERE (a, b) = (SELECT x, y FROM t) |
| 表子查询 | 多行多列(一张表) | FROM、IN、EXISTS |
FROM (SELECT ...) AS t |
| 相关子查询 | 依赖外层查询的每一行 | WHERE、SELECT |
WHERE EXISTS (SELECT 1 FROM o WHERE o.uid = u.id) |
前三种是"非相关子查询"------子查询可以独立执行,不依赖外层。第四种是"相关子查询"------子查询的执行依赖外层查询的当前行,每处理外层一行,子查询就可能执行一次。这个区别,是理解子查询性能的关键。
二、标量子查询:返回一个值
标量子查询返回单行单列,可以出现在任何需要"一个值"的位置。
sql
-- 找出金额高于平均订单金额的订单
SELECT order_id, amount
FROM orders
WHERE amount > (SELECT avg(amount) FROM orders);
这里的 (SELECT avg(amount) FROM orders) 就是标量子查询。它只执行一次,返回一个标量值,然后外层 WHERE 拿这个值做比较。
标量子查询的陷阱: 如果子查询返回多行,PostgreSQL 会报错 more than one row returned by a subquery used as an expression。如果返回零行,结果是 NULL------这可能导致外层条件静默地不匹配任何行,而不是报错。所以写标量子查询时,要确认子查询的结果确实唯一。
sql
-- 危险:如果用户有多个订单,这里会报错
SELECT username,
(SELECT amount FROM orders WHERE user_id = users.id) AS last_amount
FROM users;
如果业务上确实需要"取最新一条",应该用 ORDER BY ... LIMIT 1 显式限定,或者改用 LATERAL JOIN。
三、行子查询:单行多列的"元组比较"
行子查询返回一行多列,通常用于元组比较:
sql
-- 找出与某个用户同城市、同年龄的其他用户
SELECT *
FROM users
WHERE (city, age) = (SELECT city, age FROM users WHERE username = 'alice');
这种写法在语法上是合法的,但可读性不高。更常见的行子查询用法是 IN 配合多列:
sql
SELECT *
FROM orders
WHERE (user_id, status) IN (
SELECT user_id, 'paid' FROM users WHERE vip = true
);
注意: 行子查询的列数和类型必须与左侧元组严格匹配。PostgreSQL 在这方面的类型检查比较严格,(int, text) 和 (bigint, varchar) 可能因为类型不完全一致而报错,需要显式转换。
四、表子查询:在 FROM 中"造一张临时表"
表子查询返回多行多列,最常见的用法是放在 FROM 子句中,作为派生表(Derived Table):
sql
SELECT status, avg_order_amount
FROM (
SELECT status, avg(amount) AS avg_order_amount
FROM orders
GROUP BY status
) AS t
WHERE avg_order_amount > 100;
这里的内层查询先按状态聚合,外层再对聚合结果过滤。为什么不能直接在 WHERE 中过滤 avg(amount)? 因为 WHERE 在 GROUP BY 之前执行,聚合值还不存在。派生表把聚合结果"物化"成一张临时表,外层就可以像普通表一样过滤它。
派生表必须起别名(AS t),这是 PostgreSQL 的强制要求。别名让外层可以引用派生表的列。
表子查询与 CTE 的选择
表子查询和 CTE(WITH 子句)功能上有重叠。选择原则:
- 只用一次:派生表更简洁;
- 多处引用:CTE 更清晰,且 PostgreSQL 12+ 会内联单次引用的 CTE,性能与派生表接近;
- 递归查询 :必须用 CTE(
WITH RECURSIVE)。
五、IN 与 EXISTS:两种"存在性判断"
这是子查询中最常被比较的一对。它们解决的是同一类问题:判断某个值是否存在于另一个结果集中。
IN:值是否在集合中
sql
-- 找出有已支付订单的用户
SELECT *
FROM users
WHERE user_id IN (
SELECT user_id FROM orders WHERE status = 'paid'
);
IN 的语义是:外层值是否等于子查询结果集中的任意一个值。子查询返回一个集合,PostgreSQL 会检查外层值是否在这个集合中。
IN 的 NULL 陷阱: 如果子查询结果集中包含 NULL,IN 的行为会变得反直觉:
sql
SELECT 1 IN (1, 2, NULL); -- true
SELECT 3 IN (1, 2, NULL); -- NULL,不是 false!
因为 3 = NULL 的结果是 NULL,而 NULL 在 WHERE 中不满足条件。这意味着,如果子查询可能返回 NULL,IN 可能过滤掉本应保留的行。解决方案是在子查询中加 WHERE column IS NOT NULL。
EXISTS:是否存在满足条件的行
sql
-- 同样的需求,用 EXISTS 写
SELECT *
FROM users u
WHERE EXISTS (
SELECT 1 FROM orders o
WHERE o.user_id = u.user_id AND o.status = 'paid'
);
EXISTS 的语义是:子查询是否返回至少一行。它不关心返回什么列 ,只关心"有没有"。所以 SELECT 1 是惯用写法,SELECT * 也可以,但没必要。
EXISTS 是相关子查询的典型代表。 子查询中的 o.user_id = u.user_id 引用了外层表的列 u.user_id。这意味着,对于 users 表的每一行,子查询都可能执行一次。
IN vs EXISTS:怎么选?
| 维度 | IN | EXISTS |
|---|---|---|
| 子查询是否相关 | 通常非相关 | 通常相关 |
| 子查询结果大小 | 适合小结果集 | 适合大结果集 |
| NULL 处理 | 有 NULL 陷阱 | 不受 NULL 影响 |
| 短路行为 | 找到匹配即返回 | 找到匹配即返回 |
经验法则:
- 子查询结果集小,用
IN更直观; - 子查询结果集大,或外层表小、内层表大,用
EXISTS通常更快; - 子查询可能返回
NULL,优先用EXISTS避免陷阱。
但现代 PostgreSQL 优化器已经非常智能,很多情况下 IN 和 EXISTS 会被改写成相同的执行计划(如半连接 Semi-Join)。所以不要过度纠结语法选择,先看执行计划。
六、ANY 与 ALL:带比较符的集合判断
ANY 和 ALL 是 IN 的"泛化版本"------它们允许你使用任意比较运算符,而不仅仅是 =。
sql
-- 等价于 IN
WHERE amount = ANY (SELECT amount FROM orders WHERE status = 'paid')
-- 大于任意一个:等价于 > 最小值
WHERE amount > ANY (SELECT amount FROM orders WHERE status = 'paid')
-- 大于所有:等价于 > 最大值
WHERE amount > ALL (SELECT amount FROM orders WHERE status = 'paid')
-- 不等于任意一个:等价于 NOT IN,但有 NULL 陷阱
WHERE amount <> ALL (SELECT amount FROM orders WHERE status = 'paid')
> ANY 等价于 > min(),> ALL 等价于 > max()。 理解这个等价关系,可以帮助你判断何时可以用聚合函数替代子查询,从而获得更好的性能。
sql
-- 用聚合替代 ANY,通常更快
WHERE amount > (SELECT min(amount) FROM orders WHERE status = 'paid')
ANY/ALL 与聚合函数的等价关系:
| 子查询写法 | 聚合等价 |
|---|---|
> ANY (...) |
> (SELECT min(...) ...) |
< ANY (...) |
< (SELECT max(...) ...) |
> ALL (...) |
> (SELECT max(...) ...) |
< ALL (...) |
< (SELECT min(...) ...) |
七、相关子查询为什么可能很慢?
这是本篇最核心的问题。
相关子查询的执行逻辑是:外层查询每处理一行,子查询就执行一次。 如果外层表有 10 万行,子查询就要执行 10 万次。如果子查询本身还需要扫描大表或走索引,总成本会迅速膨胀。
sql
-- 相关子查询:每个用户都执行一次子查询
SELECT u.username,
(SELECT count(*) FROM orders o WHERE o.user_id = u.user_id) AS order_count
FROM users u;
如果 users 有 10 万行,orders 有 100 万行,且 orders.user_id 上有索引,那么总成本约为 10 万 × 索引查找成本。这比"先聚合 orders 再 JOIN users"的方案可能慢一个数量级。
用 JOIN 改写:从"逐行执行"到"批量处理"
上面的查询可以改写为:
sql
SELECT u.username, coalesce(o.order_count, 0) AS order_count
FROM users u
LEFT JOIN (
SELECT user_id, count(*) AS order_count
FROM orders
GROUP BY user_id
) o ON o.user_id = u.user_id;
改写后的执行逻辑是:先对 orders 做一次聚合(一次扫描),再与 users 做哈希连接或合并连接(一次扫描)。 总成本从"外层行数 × 子查询成本"降为"两次扫描 + 一次连接"。
对于 EXISTS 类型的相关子查询,可以用 IN 或 JOIN 改写:
sql
-- 相关 EXISTS
SELECT * FROM users u
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.user_id AND o.status = 'paid');
-- 改写为 JOIN + DISTINCT
SELECT DISTINCT u.*
FROM users u
JOIN orders o ON o.user_id = u.user_id
WHERE o.status = 'paid';
-- 或改写为 IN
SELECT * FROM users
WHERE user_id IN (SELECT user_id FROM orders WHERE status = 'paid');
但相关子查询不一定慢
需要澄清一个误区:相关子查询不是"性能毒药"。 在以下场景,它可能反而更快:
- 外层表很小,子查询能走索引;
- 子查询有
LIMIT 1,找到第一条就停止; - 优化器能把相关子查询改写成半连接或反连接。
sql
-- 这个相关子查询可能很快:外层小,内层索引,找到即停
SELECT u.username
FROM users u
WHERE EXISTS (
SELECT 1 FROM orders o
WHERE o.user_id = u.user_id AND o.amount > 10000
LIMIT 1
);
关键不是"相关子查询一定慢",而是"要理解它的执行逻辑,并用 EXPLAIN 验证实际成本"。
八、实战:用 EXPLAIN 对比两种写法
理论说再多,不如看执行计划。
sql
-- 写法一:相关子查询
EXPLAIN ANALYZE
SELECT u.username,
(SELECT count(*) FROM orders o WHERE o.user_id = u.user_id) AS cnt
FROM users u;
-- 写法二:JOIN + 聚合
EXPLAIN ANALYZE
SELECT u.username, coalesce(o.cnt, 0)
FROM users u
LEFT JOIN (
SELECT user_id, count(*) AS cnt FROM orders GROUP BY user_id
) o ON o.user_id = u.user_id;
在 users 表较大、orders 表较大的场景下,写法二通常会显示更低的 actual time。但在 users 很小、orders.user_id 索引选择性很高的场景下,两者可能接近,甚至写法一更快。
结论:不要凭感觉选写法,用 EXPLAIN ANALYZE 看真实成本。
九、总结
这一篇覆盖了子查询的四类形态和常用操作符:
| 类型 | 返回 | 关键点 |
|---|---|---|
| 标量子查询 | 单行单列 | 结果必须唯一,零行返回 NULL |
| 行子查询 | 单行多列 | 元组比较,列数类型必须匹配 |
| 表子查询 | 多行多列 | 派生表必须起别名,可被 CTE 替代 |
| 相关子查询 | 依赖外层行 | 外层每行执行一次,是性能关键 |
| 操作符 | 语义 | 注意 |
|---|---|---|
IN |
等于集合中任意一个 | 子查询含 NULL 时有陷阱 |
EXISTS |
子查询是否返回行 | 不受 NULL 影响,适合大结果集 |
ANY |
与任意一个比较 | > ANY 等价于 > min() |
ALL |
与所有比较 | > ALL 等价于 > max() |
一句话:子查询的"慢",本质是相关子查询"逐行执行"的代价。理解执行逻辑,用 JOIN 改写,用 EXPLAIN 验证,才能写出又快又对的 SQL。
下一篇,我们进入子查询与嵌套查询(下):派生表、LATERAL、CTE、递归查询------那些让复杂查询变得可读、可维护的高级技巧。
思考题:
你有没有写过相关子查询导致性能问题的 SQL?用 JOIN 改写后,执行时间变化有多大?另外,你平时写 IN 还是 EXISTS,有没有因为子查询返回 NULL 踩过坑?欢迎在评论区聊聊。