场景引入
你有一个复杂的查询:先算出每个部门的总销售额,再筛选出销售额超过平均值的部门,接着关联员工表计算奖金。如果用子查询嵌套,代码会变成这样:
sql
SELECT e.name, d.total_sales,
d.total_sales * 0.1 AS bonus
FROM employees e
JOIN (
SELECT dept_id, SUM(amount) AS total_sales
FROM sales
WHERE sale_date >= '2026-01-01'
GROUP BY dept_id
) d ON e.dept_id = d.dept_id
WHERE d.total_sales > (
SELECT AVG(total_sales)
FROM (
SELECT dept_id, SUM(amount) AS total_sales
FROM sales
WHERE sale_date >= '2026-01-01'
GROUP BY dept_id
) avg_tbl
);
❌ 问题:同一段子查询写了两次,可读性极差,维护就是噩梦。
你需要的是一张臨时的"命名查询"------CTE(公用表表达式)。
一、基本 CTE 语法
语句结构
sql
WITH cte_name AS (
-- 这里写你的子查询
SELECT column1, column2, ...
FROM table
WHERE conditions
)
SELECT *
FROM cte_name
WHERE ...;
CTE 创建的临时结果集只在当前 SQL 语句中有效,查询结束后自动销毁,不会写入磁盘。
场景示例:部门销售汇总
sql
WITH dept_sales AS (
SELECT dept_id,
SUM(amount) AS total_sales,
COUNT(*) AS order_count
FROM sales
WHERE sale_date >= '2026-01-01'
GROUP BY dept_id
)
SELECT d.dept_name, s.total_sales, s.order_count
FROM dept_sales s
JOIN departments d ON s.dept_id = d.dept_id
ORDER BY s.total_sales DESC;
执行结果示意:
| dept_name | total_sales | order_count |
|---|---|---|
| 技术部 | 1,280,000 | 342 |
| 市场部 | 950,000 | 215 |
| 运营部 | 620,000 | 178 |
对比开头的三层嵌套版本,CTE 版本逻辑清晰得像在读一篇文章。
二、多 CTE --- 一个 WITH 搞定多条"临时表"
一个 WITH 后面可以跟多个 CTE,用逗号分隔。每个 CTE 可以引用前面定义的 CTE。
场景示例:先算部门销售,再算奖金
sql
WITH dept_sales AS (
SELECT dept_id, SUM(amount) AS total_sales
FROM sales
WHERE sale_date >= '2026-01-01'
GROUP BY dept_id
),
avg_sales AS (
SELECT AVG(total_sales) AS avg_amount
FROM dept_sales
),
high_performers AS (
SELECT ds.dept_id, ds.total_sales
FROM dept_sales ds
CROSS JOIN avg_sales av
WHERE ds.total_sales > av.avg_amount
)
SELECT e.name, e.dept_id, hp.total_sales,
ROUND(hp.total_sales * 0.08, 2) AS bonus
FROM employees e
JOIN high_performers hp ON e.dept_id = hp.dept_id;
执行结果示意:
| name | dept_id | total_sales | bonus |
|---|---|---|---|
| 张三 | 101 | 1,280,000 | 102,400 |
| 李四 | 101 | 1,280,000 | 102,400 |
| 王五 | 102 | 950,000 | 76,000 |
要点:
- 多个 CTE 之间用逗号分隔,最后一个 CTE 后面没逗号
- CTE 可以链式引用 :
high_performers引用了dept_sales和avg_sales - 最终主查询引用最后一个 CTE(或任意一个)
三、CTE vs 子查询 --- 性能和可读性对比
可读性对比
sql
-- ❌ 子查询嵌套(地狱级阅读体验)
SELECT name FROM employees
WHERE dept_id IN (
SELECT dept_id FROM (
SELECT dept_id, RANK() OVER (ORDER BY sales DESC) AS rk
FROM dept_stats
) ranked WHERE rk <= 3
);
-- ✅ CTE 版本(线性逻辑,一目了然)
WITH ranked_depts AS (
SELECT dept_id, RANK() OVER (ORDER BY sales DESC) AS rk
FROM dept_stats
)
SELECT name FROM employees
WHERE dept_id IN (
SELECT dept_id FROM ranked_depts WHERE rk <= 3
);
性能对比
| 对比维度 | 子查询 | CTE |
|---|---|---|
| 执行计划 | 每次出现都独立执行(非物化) | 大多数数据库会内联展开 |
| 是否物化 | ❌ 不物化(除非使用 MATERIALIZED 关键字) | PostgreSQL 支持 WITH AS MATERIALIZED |
| 多次引用 | 子查询写多次 → 执行多次 | 写一次,但数据库可能内联多次 |
| 索引利用 | 取决于优化器 | 取决于优化器,CTE 本身不额外消耗 |
⚠️ CTE 不是性能银弹 。 数据库查询优化器通常会把 CTE 内联到主查询中,所以不一定比子查询快。 但如果 CTE 被主查询多次引用(例如 JOIN 了两次),数据库可能会执行 CTE 两次。 PostgreSQL 12+ 提供
MATERIALIZED/NOT MATERIALIZED关键字来显式控制。
sql
-- PostgreSQL 显式控制物化
WITH dept_sales AS MATERIALIZED (
SELECT dept_id, SUM(amount) AS total
FROM sales GROUP BY dept_id
)
SELECT * FROM dept_sales WHERE total > 100000;
什么时候选 CTE?
- 同一个子查询被引用多次 → 用 CTE 避免重复代码
- 查询逻辑复杂需要拆解 → 用 CTE 分步表达
- 需要递归查询(下一节) → 必须用 CTE
- 子查询只用一次且逻辑简单 → 直接用子查询更短
四、递归 CTE --- 处理树形结构(拓展)
CTE 最大的杀手锏是支持递归,子查询做不到。
语法结构
sql
WITH RECURSIVE cte_name AS (
-- 锚点:非递归的初始查询
SELECT ... FROM table WHERE condition
UNION ALL
-- 递归部分:引用 CTE 自身
SELECT ... FROM table JOIN cte_name ON ...
)
SELECT * FROM cte_name;
场景:组织架构树
sql
WITH RECURSIVE org_tree AS (
-- 锚点:找到 CEO(没有上级)
SELECT id, name, manager_id, 1 AS level
FROM employees
WHERE manager_id IS NULL
UNION ALL
-- 递归:找到每个下属,层级 +1
SELECT e.id, e.name, e.manager_id, ot.level + 1
FROM employees e
JOIN org_tree ot ON e.manager_id = ot.id
)
SELECT id, name, level,
REPEAT(' ', level - 1) || name AS tree_display
FROM org_tree
ORDER BY level, id;
执行结果示意:
| id | name | level | tree_display |
|---|---|---|---|
| 1 | 陈总 | 1 | 陈总 |
| 2 | 张总监 | 2 | 张总监 |
| 3 | 李经理 | 3 | 李经理 |
| 4 | 王组长 | 4 | 王组长 |
| 5 | 赵经理 | 3 | 赵经理 |
注意事项:
- 必须有
RECURSIVE关键字 - 递归部分必须用
UNION ALL(不能是UNION) - 必须有终止条件 (JOIN 条件中的
ot.id),否则死循环 - MySQL 5.7 及更低版本不支持递归 CTE
五、实际应用场景
1. 数据分页 + 排名
sql
WITH user_rank AS (
SELECT id, name, score,
ROW_NUMBER() OVER (ORDER BY score DESC) AS rn
FROM users
)
SELECT *
FROM user_rank
WHERE rn BETWEEN 11 AND 20;
2. 多步聚合:先分组汇总,再排序
sql
WITH monthly_stats AS (
SELECT DATE_TRUNC('month', order_date) AS month,
SUM(amount) AS revenue
FROM orders
GROUP BY DATE_TRUNC('month', order_date)
)
SELECT month, revenue,
revenue - LAG(revenue) OVER (ORDER BY month) AS mom_change
FROM monthly_stats;
3. 数据清洗中的中间步骤
sql
WITH cleaned AS (
SELECT id, email, TRIM(name) AS name,
COALESCE(phone, '未知') AS phone
FROM raw_users
WHERE email IS NOT NULL
),
duplicated AS (
SELECT id, name, email,
ROW_NUMBER() OVER (PARTITION BY email ORDER BY id) AS rn
FROM cleaned
)
SELECT id, name, email, phone
FROM duplicated WHERE rn = 1;
注意事项
- CTE 名在当前语句中必须唯一,不能重名
- CTE 不是临时表 --- 它不会写入磁盘,无法被其他会话访问
- MySQL 5.6 及之前不支持 CTE(5.7 开始支持非递归,8.0 支持递归)
- SQLite 3.34+ 支持 CTE 和递归 CTE
- Oracle 早在 9i 就支持了,语法略有不同(使用
WITH ... AS (SELECT ...) SELECT ...一致) - SQL Server 支持,CTE 后必须紧跟使用它的语句(不能分号结束再查询)
- 调试时可以用
SELECT * FROM cte_name分段验证每个 CTE 的结果
今日总结
| 知识点 | 一句话 |
|---|---|
| 基本 CTE | WITH name AS (query) SELECT ... FROM name |
| 多 CTE | 逗号分隔、可链式引用 |
| CTE vs 子查询 | CTE 提升可读性,性能各有优劣 |
| 递归 CTE | WITH RECURSIVE 处理树形结构 |
| 适用场景 | 分步复杂查询、多次引用、递归树形 |
一句话口诀:子查询让你活下来,CTE 让你活得优雅。