PostgreSQL CTE 与递归查询

系统讲解 PostgreSQL 公共表表达式与递归查询。涵盖 WITH 子句语义、非递归 CTE 的内联与物化规则、WITH RECURSIVE 处理树形与图结构的写法、物化提示 MATERIALIZED 与 NOT MATERIALIZED 的执行计划差异、数据修改型 CTE,以及递归查询的终止条件与性能陷阱。

公共表表达式(Common Table Expression,CTE)用 WITH 子句给一个查询块起名字,让主查询可以像引用表一样引用它。它最初被设计为提升可读性的工具,但在 PostgreSQL 12 之后,非递归 CTE 的求值策略发生了重要变化——优化器默认可以把它内联展开,而不再无条件物化。这意味着同一段 WITH 代码,在不同版本、不同写法下可能走完全不同的执行计划。真正让 CTE 不可替代的,是 WITH RECURSIVE:它让 SQL 第一次具备了处理树、图、层级与路径问题的原生能力。

核心认知:CTE 不是临时表,而是带名字的查询块。PostgreSQL 12 之后非递归 CTE 默认可能被内联,MATERIALIZED / NOT MATERIALIZED 才是控制它是否物化的开关。


一、CTE 基础与语义

1.1 从子查询到命名查询块

传统写法把逻辑嵌套在子查询里,层级一深就无法阅读:

-- 嵌套子查询:难以复用、难以阅读
SELECT c.name, o.total
FROM customers c
JOIN (
    SELECT customer_id, sum(amount) AS total
    FROM orders
    WHERE created_at >= now() - interval '30 days'
    GROUP BY customer_id
) o ON o.customer_id = c.id
WHERE o.total > 1000;

用 CTE 改写后,每个逻辑单元都有名字:

-- CTE:结构清晰,可被多次引用
WITH recent_orders AS (
    SELECT customer_id, sum(amount) AS total
    FROM orders
    WHERE created_at >= now() - interval '30 days'
    GROUP BY customer_id
)
SELECT c.name, r.total
FROM customers c
JOIN recent_orders r ON r.customer_id = c.id
WHERE r.total > 1000;

1.2 CTE 的作用域与可见性

一个 WITH 子句里可以定义多个 CTE,后面的 CTE 可以引用前面定义的 CTE,反过来不行。这形成了类似 let 绑定的顺序依赖:

WITH
base AS (
    SELECT id, customer_id, amount FROM orders WHERE status = 'paid'
),
agg AS (
    -- 合法:agg 可以引用先定义的 base
    SELECT customer_id, count(*) AS cnt, sum(amount) AS total
    FROM base
    GROUP BY customer_id
)
SELECT * FROM agg ORDER BY total DESC LIMIT 20;

CTE 的作用域只在当前语句内,语句结束即消失。它不是视图,不存储元数据;也不是临时表,不占用 pg_class。

1.3 CTE 与视图、临时表的对比

特性CTE视图临时表
生命周期单条语句持久(元数据)会话/事务
是否可写部分(数据修改型 CTE)可写(PG 9.3+)可写
是否可建索引否否是
统计信息无依赖基表需 ANALYZE
优化器可见可内联可展开独立对象
适用场景复杂逻辑分解复用查询定义中间结果暂存

当中间结果需要被反复扫描、且行数很大时,临时表配合 ANALYZE 往往比 CTE 更可控,因为优化器能拿到真实的行数估计。


二、递归 CTE 的结构与执行模型

2.1 WITH RECURSIVE 的三段式

递归 CTE 的语法骨架是固定的三段:非递归项(锚点)、UNION [ALL]、递归项。

WITH RECURSIVE cte_name (col1, col2) AS (
    -- 1. 锚点项(non-recursive term):起始行
    SELECT 1, 'root'
    UNION ALL
    -- 2. 递归项:引用 cte_name 自身
    SELECT col1 + 1, col2 || '>' || (col1 + 1)
    FROM cte_name
    WHERE col1 < 5          -- 3. 终止条件
)
SELECT * FROM cte_name;

输出:

 col1 |    col2
------+-------------
    1 | root
    2 | root>2
    3 | root>2>3
    4 | root>2>3>4
    5 | root>2>3>4>5

2.2 迭代求值模型

PostgreSQL 执行递归 CTE 时采用工作表(working table)迭代模型:

1. 求值锚点项 → 结果集 R,工作表 W = R
2. while W 非空:
     a. 求值递归项,其中 cte_name 引用 W
     b. 得到新行集 N
     c. 把 N 追加到最终结果(UNION ALL)或去重后追加(UNION)
     d. W = N
3. 返回最终结果

理解这个模型非常重要,因为它解释了三个现象:递归深度受 W 的规模影响;UNION(去重)需要维护全局已见集合,代价高;WHERE 条件写在递归项里才是剪枝,写在最外层只是过滤结果。

2.3 UNION 与 UNION ALL 的代价差异

-- UNION ALL:不去重,快,但要求数据本身无环或已剪枝
WITH RECURSIVE t(n) AS (
    SELECT 1 UNION ALL SELECT n + 1 FROM t WHERE n < 1000
) SELECT count(*) FROM t;

-- UNION:每轮对新行做去重,防止死循环,但代价随规模上升
WITH RECURSIVE t(n) AS (
    SELECT 1 UNION SELECT n + 1 FROM t WHERE n < 1000
) SELECT count(*) FROM t;

UNION 的去重是按整行做的,因此它天然能防止环导致的无限递归。如果数据可能成环,又希望用 UNION ALL 的性能,就必须自己维护路径数组或已访问集合来剪枝。


三、树形与图查询实战

3.1 建表与样例数据

CREATE TABLE employees (
    id          int PRIMARY KEY,
    name        text NOT NULL,
    manager_id  int REFERENCES employees(id),
    department  text
);

INSERT INTO employees VALUES
    (1, 'CEO',        NULL, 'exec'),
    (2, 'CTO',           1, 'eng'),
    (3, 'CFO',           1, 'fin'),
    (4, 'Backend Lead',  2, 'eng'),
    (5, 'Frontend Lead', 2, 'eng'),
    (6, 'Engineer A',    4, 'eng'),
    (7, 'Engineer B',    4, 'eng'),
    (8, 'Accountant',    3, 'fin');

3.2 自顶向下遍历:求某人的所有下属

WITH RECURSIVE subordinates AS (
    -- 锚点:起点是 CTO(id=2) 的直接下属
    SELECT id, name, manager_id, 1 AS depth,
           ARRAY[id] AS path
    FROM employees
    WHERE manager_id = 2
    UNION ALL
    -- 递归:沿着 manager_id 继续向下
    SELECT e.id, e.name, e.manager_id, s.depth + 1,
           s.path || e.id
    FROM employees e
    JOIN subordinates s ON e.manager_id = s.id
    WHERE NOT e.id = ANY(s.path)      -- 防环剪枝
)
SELECT id, name, depth, path
FROM subordinates
ORDER BY path;

path 数组既记录了访问路径,又充当防环的已访问集合。NOT e.id = ANY(s.path) 是标准的剪枝写法。

3.3 自底向上遍历:求某人的完整汇报链

WITH RECURSIVE chain AS (
    SELECT id, name, manager_id, 0 AS level
    FROM employees
    WHERE id = 6                       -- 从 Engineer A 出发
    UNION ALL
    SELECT m.id, m.name, m.manager_id, c.level + 1
    FROM employees m
    JOIN chain c ON m.id = c.manager_id
)
SELECT id, name, level
FROM chain
ORDER BY level;

自底向上同样用递归 CTE,只是连接方向反转:从当前行向上找 manager_id 指向的父节点。

3.4 计算层级缩进展示

WITH RECURSIVE tree AS (
    SELECT id, name, 0 AS depth, name::text AS indented
    FROM employees
    WHERE manager_id IS NULL
    UNION ALL
    SELECT e.id, e.name, t.depth + 1,
           repeat('    ', t.depth + 1) || e.name
    FROM employees e
    JOIN tree t ON e.manager_id = t.id
)
SELECT indented FROM tree ORDER BY indented;

3.5 图的最短路径 BFS

递归 CTE 天然是广度优先的,适合求无权图最短路径:

CREATE TABLE edges (src int, dst int);
INSERT INTO edges VALUES (1,2),(2,3),(3,4),(1,4),(4,5);

WITH RECURSIVE bfs AS (
    SELECT 1 AS node, ARRAY[1] AS path, 0 AS hops
    UNION ALL
    SELECT e.dst, b.path || e.dst, b.hops + 1
    FROM edges e
    JOIN bfs b ON e.src = b.node
    WHERE NOT e.dst = ANY(b.path)      -- 不重复访问
      AND b.hops < 10                  -- 深度上限兜底
)
SELECT path, hops FROM bfs WHERE node = 5 ORDER BY hops LIMIT 1;

四、CTE 的物化与优化器行为

4.1 PostgreSQL 12 的分水岭

PostgreSQL 12 之前,所有 CTE 都是优化栅栏(optimization fence):CTE 先被完整求值并物化到临时区域,主查询再扫描它。这带来两个后果——CTE 内的谓词无法下推,外层的 WHERE 也不能传进 CTE。PostgreSQL 12 之后,非递归 CTE 默认行为改为:如果主查询只引用它一次,就内联展开;引用多次才物化。

-- PG12+:CTE 只被引用一次 → 内联,谓词可下推
WITH big AS (
    SELECT * FROM orders
)
SELECT * FROM big WHERE customer_id = 42;   -- customer_id 谓词会被下推到基表

-- CTE 被引用两次 → 物化,避免重复计算
WITH big AS (SELECT * FROM orders)
SELECT (SELECT count(*) FROM big), (SELECT count(*) FROM big WHERE amount > 0);

4.2 显式物化控制

-- 强制物化:适合 CTE 结果被多次使用且计算昂贵
WITH heavy AS MATERIALIZED (
    SELECT customer_id, sum(amount) AS total
    FROM orders GROUP BY customer_id
)
SELECT * FROM heavy WHERE total > 10000;

-- 强制内联:适合 CTE 结果被过滤后行数很少
WITH filtered AS NOT MATERIALIZED (
    SELECT * FROM orders WHERE status = 'paid'
)
SELECT * FROM filtered WHERE customer_id = 42;

4.3 执行计划对比

用 EXPLAIN (ANALYZE, BUFFERS) 观察差异:

EXPLAIN (ANALYZE, BUFFERS)
WITH c AS MATERIALIZED (SELECT * FROM orders WHERE status = 'paid')
SELECT * FROM c WHERE customer_id = 42;

物化版本的计划中会出现 CTE Scan 节点,且 rows 是 CTE 的完整行数:

 CTE Scan on c  (cost=... rows=... width=...)
   Filter: (customer_id = 42)
   CTE c
     ->  Seq Scan on orders  (rows=120000)
           Filter: (status = 'paid')

内联版本则把谓词合并,直接走索引:

 Index Scan using orders_customer_id_idx on orders
   Index Cond: (customer_id = 42)
   Filter: (status = 'paid')

两者差距可能达到数百倍,取决于 customer_id = 42 的选择性。

4.4 常见误用:把 CTE 当成万能优化

-- 反例:CTE 被引用一次却写成物化,导致全表扫描
WITH paid AS MATERIALIZED (SELECT * FROM orders WHERE status = 'paid')
SELECT count(*) FROM paid WHERE created_at > now() - interval '1 day';

在 PostgreSQL 12+ 上,去掉 MATERIALIZED 后优化器会自动内联,把 created_at 谓词下推,走时间索引。升级到 12 之后,很多历史 CTE 的性能会莫名其妙变好,原因就在这里。


五、数据修改型 CTE

5.1 在一条语句里完成写与读

数据修改型 CTE(data-modifying CTE)允许 INSERT / UPDATE / DELETE 出现在 WITH 中,并用 RETURNING 把结果交给主查询:

WITH moved AS (
    DELETE FROM orders
    WHERE status = 'expired'
    RETURNING id, customer_id, amount
)
INSERT INTO orders_archive (id, customer_id, amount, archived_at)
SELECT id, customer_id, amount, now() FROM moved;

这条语句在一次原子操作里完成了归档:先删除过期订单,再插入归档表,不存在中间状态被其他事务看到的窗口。

5.2 原子性保证

所有子语句与主查询处于同一个事务、同一个快照下。这意味着:

- 数据修改型 CTE 的各个分支看到的是同一个快照
- 任一子语句失败,整条语句回滚
- 不能在同一个 CTE 里对同一行既 UPDATE 又 DELETE

5.3 典型陷阱:同一行被多次修改

-- 反例:同一条语句里对同一行做两次 UPDATE,结果不可预测
WITH a AS (
    UPDATE counters SET value = value + 1 WHERE id = 1 RETURNING id
)
UPDATE counters SET value = value + 10 WHERE id = 1;

两个修改都不会看到对方的效果(同一快照),实际写入结果取决于物理执行顺序,是未定义行为。正确做法是把两次修改合并为一次。

5.4 用 CTE 实现批量 upsert 的预过滤

WITH incoming (id, amount) AS (
    VALUES (1, 100), (2, 200), (3, 300)
),
updated AS (
    UPDATE accounts a
    SET balance = a.balance + i.amount
    FROM incoming i
    WHERE a.id = i.id
    RETURNING a.id
)
INSERT INTO accounts (id, balance)
SELECT i.id, i.amount
FROM incoming i
LEFT JOIN updated u ON u.id = i.id
WHERE u.id IS NULL;

先尝试更新,未命中的行再插入,用 LEFT JOIN ... IS NULL 精确筛出未更新成功的行。


六、性能调优与踩坑

6.1 递归深度与内存

递归 CTE 的工作表存在 work_mem 里,深度大或宽度大时会溢写到磁盘:

-- 查看当前 work_mem
SHOW work_mem;   -- 默认 4MB

-- 会话级临时放大,用于一次性深递归任务
SET work_mem = '64MB';
递归行数 × 每行宽度 × 去重开销 > work_mem → 落盘 → 性能骤降

6.2 递归 CTE 无法使用索引下推

递归项的求值每轮都要重新连接基表,如果基表连接列没有索引,每轮都是顺序扫描:

-- 为递归连接列建索引,通常是性能提升最明显的一步
CREATE INDEX idx_employees_manager_id ON employees(manager_id);

6.3 用 GENERATED 列替代部分递归

某些层级字段可以在写入时维护,避免查询时递归:

ALTER TABLE employees ADD COLUMN path ltree;
-- 或用物化视图定期刷新层级路径
CREATE MATERIALIZED VIEW employee_tree AS
WITH RECURSIVE t AS (
    SELECT id, name, NULL::int AS parent, 0 AS depth FROM employees WHERE manager_id IS NULL
    UNION ALL
    SELECT e.id, e.name, e.manager_id, t.depth + 1
    FROM employees e JOIN t ON e.manager_id = t.id
)
SELECT * FROM t;

CREATE INDEX ON employee_tree(depth);

6.4 监控与诊断

-- 观察递归 CTE 是否落盘:查看临时文件使用
SELECT temp_files, temp_bytes, pg_size_pretty(temp_bytes)
FROM pg_stat_database WHERE datname = current_database();

-- 观察 CTE 节点的实际行数与估计行数偏差
EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
WITH RECURSIVE t AS (
    SELECT 1 AS n UNION ALL SELECT n + 1 FROM t WHERE n < 100
) SELECT * FROM t;

如果 EXPLAIN 里出现 CTE Scan 而你认为该内联,就检查 CTE 是否被引用了多次,或是否显式写了 MATERIALIZED。


常见问题(FAQ)

递归 CTE 的无限循环风险

会,只要数据成环且没写剪枝。UNION(而非 UNION ALL)会按整行去重,能挡住大部分环;但更稳妥的做法是维护 path 数组并用 NOT x = ANY(path) 剪枝,同时加一个深度上限 hops < N 作为兜底。

CTE 和子查询的性能对比

在 PostgreSQL 12+ 且 CTE 只被引用一次时,两者计划通常相同(CTE 被内联)。区别在于 CTE 被引用多次时会被物化,避免重复计算;而重复子查询则会被重复求值。因此多次引用时 CTE 往往更快,单次引用时二者等价。

升级到 PostgreSQL 12 后 CTE 变快的原因

因为 12 之前 CTE 是强制物化的优化栅栏,谓词无法下推;12 之后非递归 CTE 默认可内联,外层 WHERE 能下推到基表走索引。如果某个 CTE 变慢了,通常是它被引用多次触发了物化,或需要显式 NOT MATERIALIZED。

递归 CTE 能否用于 UPDATE

不能直接在递归项里写 UPDATE。但可以用数据修改型 CTE 包裹:外层 WITH RECURSIVE 计算出行集合,主语句是 UPDATE ... FROM cte。注意递归 CTE 与数据修改型 CTE 的求值时机不同,递归 CTE 会先完整求值。

递归 CTE 结果能否建索引

不能直接建。若需要反复查询层级结果,把它落到临时表或物化视图后再建索引,或者改用 ltree 扩展的 GiST 索引来加速路径查询。


相关阅读

延伸阅读


完整示例(一键复制)

-- ========== 1. 建表与样例数据 ==========
CREATE TABLE employees (
    id          int PRIMARY KEY,
    name        text NOT NULL,
    manager_id  int REFERENCES employees(id),
    department  text
);

INSERT INTO employees VALUES
    (1, 'CEO',           NULL, 'exec'),
    (2, 'CTO',              1, 'eng'),
    (3, 'CFO',              1, 'fin'),
    (4, 'Backend Lead',     2, 'eng'),
    (5, 'Frontend Lead',    2, 'eng'),
    (6, 'Engineer A',       4, 'eng'),
    (7, 'Engineer B',       4, 'eng'),
    (8, 'Accountant',       3, 'fin');

CREATE INDEX idx_employees_manager_id ON employees(manager_id);

-- ========== 2. 非递归 CTE ==========
WITH recent_orders AS (
    SELECT customer_id, sum(amount) AS total
    FROM orders
    WHERE created_at >= now() - interval '30 days'
    GROUP BY customer_id
)
SELECT c.name, r.total
FROM customers c
JOIN recent_orders r ON r.customer_id = c.id
WHERE r.total > 1000;

-- ========== 3. 递归:自顶向下求所有下属 ==========
WITH RECURSIVE subordinates AS (
    SELECT id, name, manager_id, 1 AS depth, ARRAY[id] AS path
    FROM employees
    WHERE manager_id = 2
    UNION ALL
    SELECT e.id, e.name, e.manager_id, s.depth + 1, s.path || e.id
    FROM employees e
    JOIN subordinates s ON e.manager_id = s.id
    WHERE NOT e.id = ANY(s.path)
)
SELECT id, name, depth, path FROM subordinates ORDER BY path;

-- ========== 4. 递归:自底向上求汇报链 ==========
WITH RECURSIVE chain AS (
    SELECT id, name, manager_id, 0 AS level FROM employees WHERE id = 6
    UNION ALL
    SELECT m.id, m.name, m.manager_id, c.level + 1
    FROM employees m JOIN chain c ON m.id = c.manager_id
)
SELECT id, name, level FROM chain ORDER BY level;

-- ========== 5. 物化控制对比 ==========
EXPLAIN (ANALYZE, BUFFERS)
WITH c AS MATERIALIZED (SELECT * FROM orders WHERE status = 'paid')
SELECT * FROM c WHERE customer_id = 42;

EXPLAIN (ANALYZE, BUFFERS)
WITH c AS NOT MATERIALIZED (SELECT * FROM orders WHERE status = 'paid')
SELECT * FROM c WHERE customer_id = 42;

-- ========== 6. 数据修改型 CTE:归档 ==========
WITH moved AS (
    DELETE FROM orders WHERE status = 'expired'
    RETURNING id, customer_id, amount
)
INSERT INTO orders_archive (id, customer_id, amount, archived_at)
SELECT id, customer_id, amount, now() FROM moved;

-- ========== 7. 诊断 ==========
SELECT temp_files, pg_size_pretty(temp_bytes) AS temp_used
FROM pg_stat_database WHERE datname = current_database();

SHOW work_mem;

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「database」更多文章

  1. PostgreSQL 时序数据工作负载
  2. PostgreSQL 数据类型深入
  3. PostgreSQL 大版本升级