AI时代首先数据库-PostgreSQL实战笔记 | 15 PostgreSQL子查询与嵌套查询(上)

上一篇我们聊了函数、聚合和分组,理解了 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 踩过坑?欢迎在评论区聊聊。

相关推荐
电梯界知识分子1 小时前
江西赣州赣县三层设计师自建房:1700mm中空极限利用,全观光铝合金电梯的精度与防震处理
大数据·服务器·网络·人工智能·家用电梯
跨境数据猎手2 小时前
2027最新跨境电商独立站模式拆解
大数据·人工智能
博谷2 小时前
2026AI信源证据链怎么建?BUGOOAI布谷GEO的T0-T3分级与追溯方法
人工智能
悟天特斯2 小时前
智慧园区的“体验升级“:从管理工具到服务生态的人本运营
大数据·人工智能
织码weavecodes4 小时前
织码在线教育系统--主观题 AI 智能阅卷:批量初评与教师复核如何协同
人工智能
Shockang10 小时前
llama.cpp 本地运行 9B 模型:部署、工具调用与微调数据
人工智能
Shockang10 小时前
n8n 智能体工作流实战:触发、工具、记忆与发布护栏
人工智能
冬奇Lab11 小时前
开源项目第241期:YuE2 —— 用符号乐谱当“白盒“接口的开源音乐生成模型,SongBench 超越 Suno v5/v6
人工智能