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

上一篇我们拆了子查询的四种形态,重点讲了相关子查询"逐行执行"的代价。这一篇,我们进入子查询真正体现设计深度的部分:派生表、LATERAL、CTE 和递归查询。如果说上一篇是在讲"子查询是什么",这一篇就是在讲"什么时候该让优化器自由发挥,什么时候该由你强制它走某条路"。

一、派生表:FROM 里的"临时视图"

派生表(Derived Table)就是写在 FROM 或 JOIN 子句里的子查询。它必须起别名,否则 PostgreSQL 会拒绝执行。

sql 复制代码
SELECT status, avg_amount
FROM (
    SELECT status, avg(amount) AS avg_amount
    FROM orders
    GROUP BY status
) AS t
WHERE avg_amount > 100;

派生表最常见的用途,是把聚合结果 或窗口函数结果 "物化"成一层,让外层可以像查普通表一样过滤它。因为 WHERE 在 GROUP BY 之前执行,你无法直接在 WHERE 中过滤 avg(amount),派生表提供了一个合法的中间层。

但这里有一个容易被忽略的事实:派生表并不一定会被"物化"。 PostgreSQL 的优化器会尝试把派生表折叠(flatten) 到主查询中,把内外层的条件、连接和过滤合并成一个单一的查询树。这意味着,派生表在大多数情况下只是一个书写上的层次,而不是执行上的屏障。优化器仍然可以自由地重排连接顺序、下推谓词、使用索引。

这引出了本篇的一个核心主题:PostgreSQL 给了你多种"分层"的工具,但它们对优化器的约束力完全不同。 派生表最"松",CTE 默认也松但可以收紧,LATERAL 则天然是"逐行执行"的强约束。

二、LATERAL:让子查询"看见"左边的行

普通派生表有一个硬限制:它不能引用同一 FROM 子句中排在前面的表的列。 子查询在逻辑上是"先独立算好,再拿来 JOIN"。

LATERAL 打破了这个限制。它允许右侧子查询引用左侧表的列,执行语义变成:左侧每产出一行,右侧子查询就针对这一行执行一次。

sql 复制代码
SELECT *
FROM foo,
LATERAL (SELECT * FROM bar WHERE bar.id = foo.bar_id) AS ss;

一个更实用的场景是"每组取最新一行":

sql 复制代码
SELECT t.id, latest.*
FROM taxpayers t
JOIN LATERAL (
    SELECT insight_id, created_at
    FROM insights i
    WHERE i.taxpayer_id = t.id
    ORDER BY created_at DESC
    LIMIT 1
) AS latest ON true;

这个写法把"取每个纳税人最新一条 insight"的逻辑,从"先建完整映射再 JOIN"变成了"对每个纳税人做一次 ORDER BY ... LIMIT 1 的索引查找"。

为什么 LATERAL 有时能快 1000 倍

Mergify 团队分享过一个真实案例:一个查询在生产环境超时,EXPLAIN ANALYZE 显示它扫描了 66 万行 来返回50 行结果。原来的写法先用子查询/CTE 构建了整个 SHA 到 PR 的映射表,然后再与 50 行做 JOIN,过滤条件在映射表构建完之后才生效。

改用 LATERAL 后,执行策略完全改变:先在 test_metrics 上应用高选择性的过滤,得到约 50 行,然后对每一行做一次索引查找。总操作从"扫描 66 万行 + 排序 + 去重 + 连接"变成了"50 行 × 每次 O(log n) 索引查找",响应时间缩短了三个数量级。

LATERAL 的适用边界

LATERAL 不是万能加速器。它的适用条件很明确:

  • 外层查询选择性高:过滤后只剩少量行;
  • 内侧关联列上有合适的索引:每次查找是索引扫描而不是全表扫描;
  • 典型模式是"每组最新一行" ,用 ORDER BY ... LIMIT 1 配合。

反过来,如果外层返回大量行,而内侧没有索引 ,LATERAL 会把一次大扫描变成大量小扫描,性能可能反而更差。它的本质是用"逐行索引查找"替代"批量扫描",在"少量外侧行 + 大内侧表 + 好索引"的组合下收益最大。

三、CTE:从"优化屏障"到"可控内联"

CTE(WITH 子句)让复杂查询可以按步骤命名,提升可读性。但 CTE 在 PostgreSQL 中的执行行为,经历过一次重要的变化。

PostgreSQL 12 之前:默认物化,天然屏障

在 PG 12 之前,CTE 总是被物化 :先独立计算 CTE 的结果,存成一份临时结构,然后外层查询再引用它。这带来一个副作用:外层查询的 WHERE 条件无法下推到 CTE 内部。CTE 实际上是一道"优化屏障",优化器不能跨过它做联合优化。

PostgreSQL 12 及之后:默认内联,但可强制物化

从 PG 12 开始,默认行为反转了:如果一个 CTE 是非递归、无副作用、且只被引用一次 ,优化器会把它内联(fold) 到父查询中,允许两层联合优化。这意味着 CTE 不再自动成为性能瓶颈,它更像一个命名的子查询。

但 PostgreSQL 保留了两个显式的控制开关:

sql 复制代码
-- 强制物化:先算好 CTE,再执行外层
WITH big AS MATERIALIZED (
    SELECT * FROM big_table
)
SELECT * FROM big WHERE key = 123;

-- 强制内联:即使被引用多次,也合并进父查询
WITH big AS NOT MATERIALIZED (
    SELECT * FROM big_table
)
SELECT * FROM big w1 JOIN big w2 ON w1.key = w2.ref
WHERE w2.key = 123;

MATERIALIZED 在以下场景仍然有价值:

  • CTE 中包含昂贵函数,物化保证它只计算一次,而不是被内联后重复计算;
  • 你明确希望 CTE 作为一个"优化屏障",阻止优化器做出错误的连接顺序决策;
  • CTE 被多次引用,物化避免重复执行。

NOT MATERIALIZED 则适合:CTE 被多次引用,但每次引用只需要它输出的一小部分,内联后可以让外层谓词下推、利用索引。

CTE 和临时表到底有什么区别?

这是一个高频混淆点。两者的核心差异不在"是否存数据",而在作用域、优化器关系和索引能力:

维度 CTE 临时表
作用域 单条查询内 整个会话 ,显式 DROP 或会话结束才消失
优化器关系 PG 12+ 默认内联,可 MATERIALIZED 强制物化 独立对象,有统计信息,优化器像普通表一样处理
能否建索引 不能 可以,这是关键差异
适合的数据量 小结果集、单次引用 大结果集、多次引用、需要索引

一个量化对比:在一个涉及 200 万行订单聚合的仪表盘查询中,用 CTE 的版本耗时 18.4 秒,因为聚合结果无法建索引,WHERE lifetime_value > 500 只能在物化后的全量数据上过滤。改用临时表并在 lifetime_value 上建索引后,总耗时降到 4.09 秒,约 4.5 倍提升,原因正是"订单只扫描一次 + 外层过滤走临时表索引 + 优化器有准确统计信息"。

选择原则:

  • 单条查询内复用、结果集小、不需要索引 → CTE,可读性好;
  • 结果集大、需要多次引用、需要在外层过滤/连接时走索引 → 临时表;
  • 递归查询 → 只能用 CTE(下一节)。

四、递归 CTE:用 SQL 处理树形结构

递归 CTE 是 PostgreSQL 中处理层级数据的标准工具。它的结构固定为三部分:锚点 + UNION ALL + 递归成员。

sql 复制代码
WITH RECURSIVE dept_tree AS (
    -- 锚点:从根部门开始
    SELECT id, name, parent_id, 0 AS depth
    FROM department
    WHERE id = 1

    UNION ALL

    -- 递归成员:通过当前层找到下一层
    SELECT d.id, d.name, d.parent_id, dt.depth + 1
    FROM department d
    INNER JOIN dept_tree dt ON d.parent_id = dt.id
)
SELECT * FROM dept_tree
ORDER BY depth, id;

执行逻辑是迭代式 的:先跑锚点拿到第一层,然后拿第一层的结果去跑递归成员拿到第二层,再拿第二层去跑第三层,直到递归成员不再返回新行。depth 字段记录层级,既方便排序,也可以用来限制递归深度。

递归 CTE 的典型场景

递归 CTE 的核心价值是"一次查询处理任意深度",而不是在应用层写循环查库。

方向 场景 锚点 递归关联
从根到叶 组织架构、商品分类 WHERE parent_id IS NULL 或指定根 ID child.parent_id = tree.id
从叶到根 面包屑、权限继承 WHERE id = 当前节点 parent.id = tree.parent_id
带路径 行政区域、BOM 清单 指定起始节点 用 CONCAT(tree.path, ' > ', node.name) 拼接路径

递归 CTE 的四个性能陷阱

递归 CTE 的语法不难,难的是避免踩坑。

陷阱一:关联列没有索引。 递归的每一层都会执行一次 JOIN。如果 parent_id 上没有索引,每一层都要全表扫描,深度越大性能越差。关联列上的索引是递归 CTE 的生命线。

陷阱二:锚点写错。 锚点决定"从哪里开始"。如果业务中用 parent_id = 0 表示根节点,锚点写成 parent_id IS NULL 就查不到任何数据。锚点必须与数据的实际表示一致。

陷阱三:数据中存在环。 如果 A.parent_id = B.id 且 B.parent_id = A.id,递归会无限执行。必须加深度限制 ,例如在递归成员中加 WHERE dt.depth < 20,或在最外层加 LIMIT。这是生产环境中保护数据库不被打挂的基本措施。

陷阱四:大数据量下递归本身成为瓶颈。 当树形结构达到百万级节点且查询频繁时,即使索引和深度限制都做了,递归 CTE 也可能不够快。这时需要考虑闭包表 或路径枚举,用空间换时间,适合读多写少的场景。

五、四种工具的选择地图

工具 优化器约束 典型场景 关键风险
派生表 最松,默认折叠 聚合/窗口结果的中间层 几乎无额外风险
CTE PG 12+ 默认内联,可 MATERIALIZED 收紧 提升可读性、单次引用的小结果集 误以为 CTE 是屏障而依赖旧行为
LATERAL 强约束,逐行执行 每组最新一行、外层选择性高、内侧有索引 外层行多且内侧无索引时反而更慢
递归 CTE 必须过程式展开 树形结构、层级遍历、图遍历 缺索引、环、深度失控

一句话总结这一篇:派生表和默认的 CTE 是"声明式"的,你写逻辑,优化器决定怎么执行;LATERAL 和 MATERIALIZED 是"过程式"的,你写逻辑的同时,也在约束优化器的选择空间。递归 CTE 则是唯一必须过程式展开的结构------它的力量来自迭代,它的风险也来自迭代。

下一篇,我们进入 SQL 高级查询:窗口函数、CASE、COALESCE------窗口函数和聚合函数到底差在哪里?如何用窗口函数做同环比和排名?


思考题:

你有没有写过"每组取最新一行"的查询?当时用的是 LATERAL、窗口函数还是子查询?如果用过 CTE,你知道它在你的 PostgreSQL 版本中默认是内联还是物化吗?另外,你的树形结构查询用的是递归 CTE,还是闭包表/路径枚举?欢迎在评论区聊聊你的选择和踩过的坑。

相关推荐
川石课堂软件测试1 小时前
涨薪技术|0到1学会性能测试第8课-Analysis分析图
服务器·数据库·python·sql·jmeter·mysql·容器
SelectDB技术团队1 小时前
云数仓成本结构拆解:五层计费模型与 Apache Doris 托管形态对照
大数据·数据库·elk·全文检索·snowflake
云水初1 小时前
【项目篇】基于LanGraph的自动处理客服邮件的 Agent汇总
开发语言·笔记·python·学习·fastapi·langgraph
我命由我123452 小时前
SQL 检测脚本
java·数据库·sql·功能测试·单元测试·压力测试·可用性测试
Zenova EdgeOS2 小时前
工业网关数据全链路加密实战:从传输、静态数据到密钥轮
数据库·oracle·边缘计算·工业网关
太理摆烂哥2 小时前
MySQL表的约束
数据库·mysql
Crayon Lin3 小时前
Milvus向量数据库入门
数据库·milvus
cspttty3 小时前
2027校招准备:数学与应用数学专业如何补齐管理咨询的业务、分析与表达能力
大数据·数据库
曹牧3 小时前
C#:字符串占位符与值数量不一致
开发语言·数据库·c#