CTE 公用表表达式:把一条大 SQL 写成一条流水线
以播客平台「回声FM」的取数需求为主线,学会用 WITH 把嵌套子查询改写成从上往下读的流水线:多次引用、物化与内联的方言差异、CTE 参与 DML,以及"CTE 不是临时表"这条性能底线。
1. 场景:三层嵌套的查询,没人想读第二遍
回声FM要做”优质节目白名单”,逻辑分三步:
- 算出每个节目的平均播放量;
- 筛出平均播放量超过 4 万的节目;
- 把这些节目下的全部单集拉出来,标注所属节目。
用学过的子查询硬写,是这个形状:
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,公用表表达式)就是给”中间结果”起名字的语法,把洋葱摊平成流水线:
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
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,后面的引用前面的
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 在主查询里可以出现多次,子查询不行(想用两次就得复制粘贴两遍)。
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 的列起名字
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 若被内联,等于执行两遍:
-- 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;过滤虽然便宜不了多少,但真实场景里”贵查询被引用两次”非常常见(比如对同一批结果做交叉统计)。想算一次存起来,显式物化:
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 起可以用提示点名:
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 在工程里第二常见的用法。
-- 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 的三条硬规则
-- 规则 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. 坑点清单与自检
- PG 12+ 双重引用默认内联:贵的 CTE 被引用两次可能算两遍,用
EXPLAIN确认,必要时MATERIALIZED。 - 把 CTE 当临时表用:不能建索引、没有统计信息,大结果集多次复用请换临时表。
- CTE 名与真表重名:静默遮蔽,排查时先看 WITH 列表。
- 在语句外引用 CTE:作用域只有一条语句,报”表不存在”先检查是不是跨语句了。
- MySQL 5.x 环境:没有 CTE,先确认版本再写,或改写为派生表。
- DML 前不验证:UPDATE/DELETE 挂 CTE 前先把 WITH 部分单独跑一遍,确认目标行数。
8. 练习
基于回声FM的表:
- 用一个 CTE 找出平均播放量最高的节目,再列出该节目的全部单集。
- 用”CTE 自引用”写法求每个分类的播放总量和它对全平台的占比(提示:两个 CTE,一个分组求和,一个求总和)。
- 把 2.3 节的月度环比改写成 MySQL 8.4 能跑的版本。
- 写一条带 CTE 的 DELETE:删除”从未被任何听众收听过”的单集(自建最小收听表或用 ratings 表模拟)。
- (思考题)PG 12 里,
WITH x AS MATERIALIZED (SELECT ... WHERE 恒假)被引用两次,主查询会扫几次基表?改成NOT MATERIALIZED呢?
下一步
- 递归 CTE:WITH RECURSIVE 的完整语义与终止条件。
- 递归 CTE 遍历树结构:组织架构、评论树、环检测实战。
- LATERAL 派生表:CTE 做不到的”逐行引用左侧表”。
- 窗口函数:CTE + 窗口函数的 Top-N 组合拳。