前置知识: SQL

CTE 公用表表达式:把一条大 SQL 写成一条流水线

7 min中级

以播客平台「回声FM」的取数需求为主线,学会用 WITH 把嵌套子查询改写成从上往下读的流水线:多次引用、物化与内联的方言差异、CTE 参与 DML,以及"CTE 不是临时表"这条性能底线。

1. 场景:三层嵌套的查询,没人想读第二遍

回声FM要做”优质节目白名单”,逻辑分三步:

  1. 算出每个节目的平均播放量;
  2. 筛出平均播放量超过 4 万的节目;
  3. 把这些节目下的全部单集拉出来,标注所属节目。

用学过的子查询硬写,是这个形状:

sql
SELECT * FROM (
  SELECT e.*, t.avg_plays
  FROM episodes e
  JOIN (
    SELECT show_id, AVG(play_count) AS avg_plays
    FROM episodes
    GROUP BY show_id
  ) t ON t.show_id = e.show_id
  WHERE t.avg_plays > 40000
) result
ORDER BY result.avg_plays DESC;

能跑,但要从最里层往外剥洋葱才能看懂。需求再加一步(“再按分类过滤”),就得再包一层。CTE(Common Table Expression,公用表表达式)就是给”中间结果”起名字的语法,把洋葱摊平成流水线:

sql
WITH show_avg AS (                       -- 第 1 步:每个节目的平均播放量
  SELECT show_id, AVG(play_count) AS avg_plays
  FROM episodes
  GROUP BY show_id
),
good_shows AS (                          -- 第 2 步:筛出优质节目
  SELECT show_id, avg_plays
  FROM show_avg
  WHERE avg_plays > 40000
)
SELECT e.*, g.avg_plays                  -- 第 3 步:拉出全部单集
FROM episodes e
JOIN good_shows g ON g.show_id = e.show_id
ORDER BY g.avg_plays DESC;

同样的结果,但从上往下读就是需求文档。这篇讲透 WITH 的写法、它背后的执行模型(什么时候真的会”存一份中间结果”),以及它在 INSERT/UPDATE/DELETE 里的用法。

1.1 练习场数据

沿用数据查询基础的回声FM三张表(shows/episodes/listeners),本文不需要新表。没建过的读者回上一篇把脚本跑一遍即可。

2. 动手:WITH 的四种基本形状

2.1 单个 CTE

sql
WITH hit_episodes AS (
  SELECT id, title, play_count
  FROM episodes
  WHERE play_count >= 20000
)
SELECT * FROM hit_episodes ORDER BY play_count DESC;

形状固定:WITH 名字 AS (一条 SELECT),主查询把它当表用。语义上是”给一个子查询起别名”,作用范围只有紧跟着的这一条语句,语句结束名字就消失。

2.2 多个 CTE,后面的引用前面的

sql
WITH
city_stats AS (
  SELECT city, COUNT(*) AS listener_cnt
  FROM listeners
  GROUP BY city
),
big_city AS (
  SELECT city FROM city_stats WHERE listener_cnt >= 2   -- 引用了 city_stats
)
SELECT b.city, c.listener_cnt
FROM big_city b
JOIN city_stats c ON c.city = b.city;

规则:多个 CTE 用逗号分隔;后面的可以引用前面的,前面的不能引用后面的(SQL 是从上往下”定义”的)。这让你能把复杂逻辑拆成任意多步,每步都可以单独拿出来验证。

2.3 CTE 被引用多次:环比的最短写法

CTE 与普通子查询最大的实用差别:一个 CTE 在主查询里可以出现多次,子查询不行(想用两次就得复制粘贴两遍)。

sql
WITH monthly AS (
  SELECT DATE_TRUNC('month', published_at)::DATE AS month,
         SUM(play_count) AS plays
  FROM episodes
  GROUP BY 1
)
SELECT
  cur.month,
  cur.plays  AS this_month,
  prev.plays AS last_month,
  cur.plays - prev.plays AS mom_diff
FROM monthly cur
LEFT JOIN monthly prev
  ON prev.month = cur.month - INTERVAL '1 month';

monthly 出现了两次,CTE 只定义一遍。MySQL 把 DATE_TRUNC('month', published_at)::DATE 换成 DATE_FORMAT(published_at, '%Y-%m-01')、- INTERVAL '1 month' 换成 DATE_SUB(month, INTERVAL 1 MONTH) 即可。

2.4 给 CTE 的列起名字

sql
WITH top2(show_id, avg_plays) AS (
  SELECT show_id, AVG(play_count)
  FROM episodes GROUP BY show_id
  ORDER BY 2 DESC LIMIT 2
)
SELECT * FROM top2;

括号里的列名列表按位置给 CTE 的输出列命名,适合内部列名是表达式、不想在内部到处写 AS 的场合。

3. CTE vs 派生表子查询 vs 临时表

三者都能装”中间结果”,定位完全不同:

特性CTE派生表子查询临时表
可读性高,流水线低,嵌套深高
多次引用可以不可以(要复制)可以
递归支持(RECURSIVE)不支持不支持
能否建索引不能不能可以
生命周期单条语句单条语句会话级,手动清理
跨语句复用不能不能能

一句话选型:一条语句内的可读性问题用 CTE;中间结果要被多条语句、多个步骤反复用并且量大到需要索引,用临时表。CTE 不是表——没有统计信息、不能建索引、不能被 ANALYZE,这是它和临时表的本质区别,也是后面所有性能话题的根源。

4. 执行模型:CTE 到底会不会”存一份”

这是 CTE 唯一的深水区,也是方言差异最大的地方。核心问题:主查询引用 CTE 时,数据库是把它的结果物化成临时结果集(算一次存起来),还是内联展开(把 CTE 文本拼回主查询交给优化器)?

4.1 各家默认行为

  • PostgreSQL 12+:默认内联展开(只被引用一次时);被引用多次时物化。可用 MATERIALIZED / NOT MATERIALIZED 强制指定。PG 11 及以前一律物化。
  • MySQL 8.0+:优化器自动选择,能合并(merge,等价内联)就合并,不能就物化;可用提示干预(见下)。
  • SQLite:引用一次内联,多次引用物化,不可干预。

版本门槛先记住:CTE 与 WITH RECURSIVE 在 PostgreSQL 8.4+、MySQL 8.0+、MariaDB 10.2+、SQLite 3.8.3+ 可用。MySQL 5.x 完全没有 CTE——遗留系统迁移时,这是改写 SQL 工作量的大头。

4.2 PostgreSQL:两次引用 = 算两次,除非你说了算

PG 12+ 默认策略下,被引用两次的 CTE 若被内联,等于执行两遍:

sql
-- PG 12+ 默认内联:expensive 会被执行两次
WITH expensive AS (
  SELECT * FROM episodes WHERE play_count > 20000   -- 假设这个过滤很贵
)
SELECT * FROM expensive WHERE show_id = 1
UNION ALL
SELECT * FROM expensive WHERE show_id = 4;

过滤虽然便宜不了多少,但真实场景里”贵查询被引用两次”非常常见(比如对同一批结果做交叉统计)。想算一次存起来,显式物化:

sql
WITH expensive AS MATERIALIZED (
  SELECT * FROM episodes WHERE play_count > 20000
)
SELECT * FROM expensive WHERE show_id = 1
UNION ALL
SELECT * FROM expensive WHERE show_id = 4;

反过来,NOT MATERIALIZED 强制内联,把过滤条件下推给优化器。选择标准:

  • CTE 计算贵、结果小、被引用多次 → MATERIALIZED(算一次);
  • CTE 简单、只引用一次、希望优化器把外层条件下推 → 默认或 NOT MATERIALIZED。

4.3 MySQL:MERGE 与 NO_MERGE 提示

MySQL 的合并/物化由优化器自动决定,8.0.20 起可以用提示点名:

sql
WITH stats AS (
  SELECT show_id, AVG(play_count) AS avg_plays
  FROM episodes GROUP BY show_id
)
SELECT /*+ NO_MERGE(stats) */ * FROM stats WHERE avg_plays > 40000;

MERGE(名字) 要求内联合并,NO_MERGE(名字) 要求物化。更老的行为开关是 derived_merge(5.7 引入,同时管派生表和 CTE),生产上不建议动全局开关,用语句级提示就够。想看优化器实际选了哪条路,把 EXPLAIN 挂在 WITH 前面,物化时会出现 DERIVED 节点。

5. CTE 进 DML:先想清楚再动手

CTE 不只能配 SELECT。批量改数据前”先把目标行算出来”,是 CTE 在工程里第二常见的用法。

sql
-- INSERT:把 8 月上架的单集补进周榜候选表 weekly_pool(PostgreSQL,含防重)
-- 假设该表已存在且 episode_id 上有唯一约束
WITH august AS (
  SELECT id, title FROM episodes
  WHERE published_at >= '2026-08-01' AND published_at < '2026-09-01'
)
INSERT INTO weekly_pool (episode_id, title)
SELECT id, title FROM august
ON CONFLICT (episode_id) DO NOTHING;

-- UPDATE:把低播放单集下架(PostgreSQL 的 UPDATE ... FROM 写法)
WITH cold AS (
  SELECT id FROM episodes WHERE play_count < 10000
)
UPDATE episodes e
SET duration_min = NULL
FROM cold
WHERE e.id = cold.id;

-- 同一件事 MySQL 8.0 的写法(没有 UPDATE ... FROM,用 IN 子查询)
WITH cold AS (
  SELECT id FROM episodes WHERE play_count < 10000
)
UPDATE episodes
SET duration_min = NULL
WHERE id IN (SELECT id FROM cold);

-- DELETE:删掉没有任何评分的单集(两个方言通用)
WITH no_rating AS (
  SELECT e.id
  FROM episodes e
  LEFT JOIN ratings r ON r.episode_id = e.id
  WHERE r.episode_id IS NULL
)
DELETE FROM episodes WHERE id IN (SELECT id FROM no_rating);

三种 DML 都遵循同一个模式:WITH 算出目标主键集合,主体语句只负责改。好处是目标集合可以被单独 SELECT 验证——改数据前先跑一遍 WITH 部分数一数行数,是第 12 篇强调过的安全习惯。

(PostgreSQL 还有更激进的 data-modifying CTE:WITH 里直接写 INSERT/UPDATE/DELETE。能力更强但可见性规则更绕,初学不碰,需要时查官方文档。)

6. CTE 的三条硬规则

sql
-- 规则 1:作用域只有一条语句。下一条语句里 top2 已经不存在
WITH top2 AS (SELECT 1 AS x) SELECT * FROM top2;
SELECT * FROM top2;   -- ERROR: relation "top2" does not exist

-- 规则 2:后面的 CTE 能引用前面的,反之不行
WITH a AS (SELECT 1 AS x),
     b AS (SELECT x + 1 AS y FROM a)   -- 合法:b 引用 a
SELECT * FROM b;

-- 规则 3:普通 CTE 不能引用自己,自引用必须加 RECURSIVE
WITH c AS (SELECT * FROM c)            -- ERROR
SELECT * FROM c;

WITH RECURSIVE counter(n) AS (
  SELECT 1
  UNION ALL
  SELECT n + 1 FROM counter WHERE n < 10
)
SELECT * FROM counter;

规则 3 是递归 CTE 的入口。沿组织树向下找”某主播团队的所有单集”、遍历评论楼中楼,都属于递归 CTE,完整展开在递归 CTE与树结构遍历。

另外两个实用细节:

  • CTE 名会遮蔽真表:WITH episodes AS (...) 会让你在语句里拿到的是 CTE 而不是表。起名别和真实表重名,不然排查问题时极易把自己绕晕。
  • CTE 与窗口函数是黄金搭档:窗口函数不能写在 WHERE 里,标准做法是”CTE 里编号,外层过滤”——每组 Top N 的固定套路,见窗口函数第 3 节。

7. 坑点清单与自检

  1. PG 12+ 双重引用默认内联:贵的 CTE 被引用两次可能算两遍,用 EXPLAIN 确认,必要时 MATERIALIZED。
  2. 把 CTE 当临时表用:不能建索引、没有统计信息,大结果集多次复用请换临时表。
  3. CTE 名与真表重名:静默遮蔽,排查时先看 WITH 列表。
  4. 在语句外引用 CTE:作用域只有一条语句,报”表不存在”先检查是不是跨语句了。
  5. MySQL 5.x 环境:没有 CTE,先确认版本再写,或改写为派生表。
  6. DML 前不验证:UPDATE/DELETE 挂 CTE 前先把 WITH 部分单独跑一遍,确认目标行数。

8. 练习

基于回声FM的表:

  1. 用一个 CTE 找出平均播放量最高的节目,再列出该节目的全部单集。
  2. 用”CTE 自引用”写法求每个分类的播放总量和它对全平台的占比(提示:两个 CTE,一个分组求和,一个求总和)。
  3. 把 2.3 节的月度环比改写成 MySQL 8.4 能跑的版本。
  4. 写一条带 CTE 的 DELETE:删除”从未被任何听众收听过”的单集(自建最小收听表或用 ratings 表模拟)。
  5. (思考题)PG 12 里,WITH x AS MATERIALIZED (SELECT ... WHERE 恒假) 被引用两次,主查询会扫几次基表?改成 NOT MATERIALIZED 呢?

下一步