CTE · 让一条 SQL 学会「递归」

WITH 子句把复杂查询拆成命名的中间步骤;WITH RECURSIVE 让 SQL 能自我引用,一条语句遍历树、生成序列;而「改写型 CTE」能在 WITH 里跑 INSERT/UPDATE/DELETE 再 RETURNING 给下一步——这是 PostgreSQL 的独门绝技。

作者 David更新于 第 5 篇(共 17 篇)

CTE · 让一条 SQL 学会「递归」

WITH 子句(公用表表达式,Common Table Expression)第一眼像是「给子查询起个名字」的语法糖,但它其实藏着三样很不一样的能力:拆解复杂查询、递归遍历、以及在查询里改数据。第三样,是 PostgreSQL 少有人知却极其好用的独门功夫。

普通 CTE:把复杂查询拆成人能读的步骤

最基础的用法,是把一个大查询拆成几个命名的中间结果,像写代码时拆函数:

WITH top_sales AS (
  SELECT name FROM emp WHERE dept = 'Sales' ORDER BY amount DESC LIMIT 3
), top_others AS (
  SELECT name FROM emp WHERE dept <> 'Sales' ORDER BY amount DESC LIMIT 3
)
SELECT name FROM top_sales UNION ALL SELECT name FROM top_others;
-- -> ['Carol', 'Alice', 'Frank', 'Dave']

嵌套三层的子查询,换成 WITH 一字排开,可读性天差地别。

一个影响性能的历史细节:PG 12 之前,CTE 是「优化栅栏」——每个 WITH 块都被强制物化(算出完整中间结果再给下一步用),优化器不能把条件下推进去。PG 12 起改为默认内联(只被引用一次、无副作用的 CTE 会被展开优化),你也能用 WITH x AS MATERIALIZED (...) 或 AS NOT MATERIALIZED 手动指定。所以在老版本上,别以为 WITH 只是语法糖——它可能悄悄改变执行计划。

递归 CTE:一条 SQL 遍历树

WITH RECURSIVE 让 CTE 能引用它自己,于是能表达循环 / 递归。结构永远是两段,用 UNION ALL 连起来:

WITH RECURSIVE fib(n, a, b) AS (
  SELECT 1, 0, 1                              -- 锚点:递归的起点
  UNION ALL
  SELECT n + 1, b, a + b FROM fib WHERE n < 10  -- 递归项:引用自己
)
SELECT n, a FROM fib;
-- -> [(1,0), (2,1), (3,1), (4,2), (5,3), (6,5), (7,8), (8,13), (9,21), (10,34)]

递归 CTE 的执行是迭代的,不是真的函数递归:PostgreSQL 维护一张「工作表」,先放入锚点结果,然后反复执行递归项——每一轮把上一轮新产生的行当输入,直到某一轮不再产生新行为止。理解这一点,你就知道为什么递归项里的 WHERE n < 10 是终止条件:少了它,或图里有环,递归就停不下来。

真正的用武之地是树 / 图遍历。比如「找出 Alice 的所有下属(含各级)」:

WITH RECURSIVE subs AS (
  SELECT id, name FROM emp WHERE name = 'Alice'         -- 从 Alice 出发
  UNION ALL
  SELECT e.id, e.name FROM emp e JOIN subs s ON e.manager_id = s.id  -- 逐层往下钻
)
SELECT name FROM subs;
-- Alice → Bob → Carol → Dave → Eve → Frank(整棵子树)

组织架构、评论楼中楼、物料清单(BOM)展开、分类目录——凡是「父子关系存在一张表里」的结构,一条递归 CTE 就能整棵展开,不用在应用层写循环反复查库。

改写型 CTE:在查询里改数据(PG 独门)

这是最容易被忽略、却最有价值的一招:WITH 块里可以跑 INSERT / UPDATE / DELETE,用 RETURNING 把受影响的行交给后续步骤。于是「从一张表删、往另一张表写」能在一条原子语句里完成:

WITH moved AS (
  DELETE FROM active WHERE status = 'done' RETURNING *
)
INSERT INTO archived SELECT * FROM moved;
-- active 剩 2 行 / archived 拿到 2 行,一条 SQL 原子完成 ✓

删除和归档在同一个语句、同一个快照里发生,不会出现「删了但没归档成功」的中间态。做「软删除转历史表」「拆分订单」「审计双写」这类操作,这比开事务写两条语句更简洁、更不易出错。

改写型 CTE 有个反直觉的规矩:所有子语句看到的是同一个开始快照,WITH 块之间的执行顺序不保证。所以别指望「先 UPDATE 的 CTE 改了数据,后面的 SELECT 能看见」——它们看到的都是语句开始时的数据。要有先后依赖,就用 RETURNING 把数据显式传下去,而不是靠副作用。

横向对比

能力PostgreSQLMySQL 8.0+OracleSQL Server
普通 CTE有有有有
递归 CTE有(SQL 标准)有CONNECT BY 或递归 CTE有
CTE 内联优化PG 12+ 可控有有有
改写型 CTE + RETURNING有,链式❌ 不支持❌ 不支持有限(OUTPUT)

递归 CTE 如今是标配(Oracle 老式的 CONNECT BY 也能做树遍历)。但在 WITH 里 DELETE ... RETURNING 再喂给 INSERT 这种链式改写,是 PostgreSQL 明显领先的地方——MySQL 至今做不到。

应用场景

  • 层级遍历:组织架构、评论树、地区树、BOM 物料展开——递归 CTE 一条搞定。
  • 序列 / 日历生成:递归 CTE(或 generate_series)造出连续日期、补齐时间序列的空洞。
  • 原子搬数据:删除 + 归档、拆单、状态迁移双写,改写型 CTE 保证原子。
  • 复杂报表:多个中间聚合用 WITH 串起来,可读性远胜嵌套子查询。

小结与下一章

CTE 把「拆解」「递归」「原子改写」三件事收进一个 WITH 语法里。递归 CTE 让 SQL 有了遍历树和图的能力,改写型 CTE 则让「查」和「改」能在一条语句里协作——后者是 PostgreSQL 相对 MySQL 的一个实打实的优势。

下一章的窗口函数,解决另一类「以为得写代码」的需求:排名、环比、累计、每组 Top N——不合并行,却能算出每行「在它那一组里的位置」。👉 窗口函数:不合并行的分组统计。

参考文献

本页目录