上一篇我们拆了子查询的四种形态,重点讲了相关子查询"逐行执行"的代价。这一篇,我们进入子查询真正体现设计深度的部分:派生表、
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,还是闭包表/路径枚举?欢迎在评论区聊聊你的选择和踩过的坑。