跳转到主要内容

38 公用表表达式(WITH)

使用 WITH 组织查询,以原子方式移动行,并递归遍历树结构。

公用表表达式(CTE)是一个由 WITH name AS (SELECT ...) 引入的命名子查询。你可以从主查询(以及后续的 CTE)中引用它,就像引用一张表一样。CTE 让复杂查询更易读:每个 CTE 都有自己的名称和任务,主查询从上到下阅读起来像一个故事。PostgreSQL 还支持递归 CTE(WITH RECURSIVE),这使你可以只用 SQL 遍历树、图和链。

本章要点

  • 使用 WITH name AS (...) 定义 CTE
  • 在一个查询中串联多个 CTE
  • 数据修改 CTE:在 WITH 中使用 INSERT、UPDATE、DELETE
  • 用递归 CTE 处理层级结构和图
  • 性能方面的权衡(MATERIALIZED / NOT MATERIALIZED)

示例

示例 38.1 公用表表达式(WITH)
-- A single CTE
WITH spend AS (
  SELECT customer_id, sum(total) AS total_spent
  FROM orders_example
  GROUP BY customer_id
)
SELECT c.name, COALESCE(s.total_spent, 0) AS spent
FROM customers_example c
LEFT JOIN spend s ON s.customer_id = c.id
ORDER BY c.id;

-- Multiple CTEs, each named and easy to read
WITH per_customer AS (
  SELECT customer_id, sum(total) AS total_spent, count(*) AS order_count
  FROM orders_example
  GROUP BY customer_id
),
big_spenders AS (
  SELECT * FROM per_customer WHERE total_spent > 30
)
SELECT c.name, b.total_spent, b.order_count
FROM big_spenders b
JOIN customers_example c ON c.id = b.customer_id
ORDER BY b.total_spent DESC;

-- Data-modifying CTE: archive then return what was archived
DROP TABLE IF EXISTS orders_archive;
CREATE TABLE orders_archive (LIKE orders_example INCLUDING ALL);

WITH archived AS (
  DELETE FROM orders_example
  WHERE total < 20
  RETURNING *
)
INSERT INTO orders_archive
SELECT * FROM archived
RETURNING id;

-- RECURSIVE CTE: walk an employee hierarchy
WITH RECURSIVE reports AS (
  -- anchor: top-level employees (no manager)
  SELECT id, name, manager_id, 1 AS depth
  FROM employees_example
  WHERE manager_id IS NULL
  UNION ALL
  -- recursive step: rows whose manager appears in the previous level
  SELECT e.id, e.name, e.manager_id, r.depth + 1
  FROM employees_example e
  JOIN reports r ON e.manager_id = r.id
)
SELECT depth, name FROM reports ORDER BY depth, id;

CTE 就是一个命名子查询。你可以定义多个 CTE,用逗号分隔,而且每个 CTE 都可以引用前面定义的 CTE。按从上到下的顺序阅读,每一步都是一个清晰的逻辑单元。与深层嵌套的派生表集合相比,CTE 通常更容易理解,也更容易修改。

数据修改 CTE 是 PostgreSQL 的特色:带有 RETURNING 的 INSERT、UPDATE 或 DELETE 可以作为 CTE 的主体,其返回的行会流入下一条语句——整个过程以原子方式在一次往返中完成。上面展示的“从一张表删除,再插入另一张表”归档模式是典型示例。

递归 CTE 由 UNION ALL 组合成两个部分:一个生成初始行的锚点查询,以及一个引用 CTE 名称来添加更多行的递归查询。PostgreSQL 会持续迭代,直到不再产生新行。可以用它们处理层级结构(员工 → 经理)、图遍历(查找所有传递依赖)和序列生成器(数字、日期)。

运行示例

运行方式(需要前面课程中的表):

$ psql -f source/cte.sql postgres
 name  | spent
-------+-------
 Alice | 80.00
 Bob   |  0.00
 Carol |  0.00
(3 rows)
 ...
 depth | name
-------+-------
     1 | Alice
     2 | Bob
     2 | Carol
     3 | Dave
     3 | Eve
(5 rows)

常见陷阱

  • 在 PostgreSQL 12 及更高版本中,一个只引用一次、无副作用的非递归 SELECT CTE 通常会折叠到其父查询中——规划器会像处理子查询一样处理它。如果你想要较早版本“始终物化”的行为,请写成 WITH x AS MATERIALIZED (...)。
  • WITH RECURSIVE 同时接受 UNION ALL 和 UNION。UNION 会消除完整行的重复项,包括此前生成的行;UNION ALL 则会保留它们。根据递归方式及其终止条件作出选择。
  • 不会收敛的递归 CTE(递归步骤持续添加行)会形成无限循环。查询可能无限运行、溢写到磁盘,或失败;work_mem 不是执行限制。拿不准时,请添加终止条件(例如 WHERE depth < 100)。
  • WITH 中的数据修改 CTE 彼此之间的求值顺序未指定——不要依赖它们之间的副作用。主语句与它们共享同一快照,且主语句与这些 CTE 之间的修改顺序没有保证。应通过 RETURNING 传递数据,而不要依赖可见的副作用。
提示

当查询超过约 20 行,或包含两个以上派生表时,把它重构为命名 CTE 几乎总是更好。你甚至可以暂时保留一个 CTE,把另一个内联,看看代价来自哪里。

动手练习

写一个 CTE 计算订单总额的平均值,然后在主查询中显示每个订单与该平均值的差值。然后试试用递归 CTE 生成 1 到 10 的数字(WITH RECURSIVE t(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM t WHERE n < 10) SELECT * FROM t;)。

下载

SQL 文件:cte.sql · 全部示例文件 · 准备工作