订阅更新

WITH子句(CTE)不只是让SQL好看,还有这些门道

SQL手册 第 19 题 约 10 分钟读完

本文目录
  1. 总结
  2. CTE基本语法
  3. CTE vs 子查询:可读性的质变
  4. CTE的性能:它不是临时表
  5. PostgreSQL的特殊处理
  6. CTE的实战用法
  7. CTE vs 临时表 vs 视图

总结

要点说明
------------
CTE的本质语法糖,提升可读性
性能大多数引擎内联到主查询,等同于子查询
多次引用小心重复计算,必要时物化
递归CTE处理层级数据,PG/MySQL 8.0支持
开发技巧逐步构建、逐步验证

一句话建议:嵌套超过2层的子查询一律改CTE,你的同事会感谢你的。

明天 DAY 20 是这一季的收官篇——SQL面试必知的执行顺序和底层原理总结,把前面19天的知识串成一条线。

你在工作中CTE用得多吗?有没有遇到过CTE的性能问题?评论区聊聊~

关注「Wood聊大数据」| 9年大数据老兵,聊技术、聊职场、聊成长

先把这段讲顺;面试官追问时,再展开下面的原理和细节。

详细讲解

如果你还在写嵌套五六层的子查询,是时候拥抱CTE了。CTE(Common Table Expression,公用表表达式)是SQL可读性的救星,但在大数据场景下,它还有一些容易被忽视的性能问题。

CTE基本语法

WITH cte_name AS (
  SELECT ...
)
SELECT * FROM cte_name;

可以定义多个CTE,后面的CTE可以引用前面的:

WITH
step1 AS (
  SELECT user_id, MIN(login_date) AS first_login
  FROM user_login
  GROUP BY user_id
),
step2 AS (
  SELECT first_login, COUNT(1) AS new_users
  FROM step1
  GROUP BY first_login
)
SELECT * FROM step2 ORDER BY first_login;

CTE vs 子查询:可读性的质变

看一个实际例子——计算每个渠道最近7天的日均消耗排名。

子查询版(嵌套地狱):

SELECT * FROM (
  SELECT
    channel,
    avg_cost,
    ROW_NUMBER() OVER (ORDER BY avg_cost DESC) AS rn
  FROM (
    SELECT
      channel,
      ROUND(AVG(cost), 2) AS avg_cost
    FROM (
      SELECT * FROM ad_daily_cost
      WHERE dt >= DATE_SUB(current_date(), 7)
    ) filtered
    GROUP BY channel
  ) aggregated
) ranked
WHERE rn <= 5

读这种SQL就像剥洋葱,从里到外一层层看,每层还要在脑子里记住上下文。

CTE版(逻辑清晰):

WITH filtered AS (
  -- 第一步:过滤最近7天
  SELECT * FROM ad_daily_cost
  WHERE dt >= DATE_SUB(current_date(), 7)
),
aggregated AS (
  -- 第二步:按渠道算日均
  SELECT channel, ROUND(AVG(cost), 2) AS avg_cost
  FROM filtered
  GROUP BY channel
),
ranked AS (
  -- 第三步:排名
  SELECT *, ROW_NUMBER() OVER (ORDER BY avg_cost DESC) AS rn
  FROM aggregated
)
-- 第四步:取Top 5
SELECT * FROM ranked WHERE rn <= 5

每一步做什么一目了然,就像写代码一样逐步推进。我在做SQL代码质量治理的时候,明确要求团队用CTE替代超过2层的嵌套子查询,代码review效率提升了很多。

CTE的性能:它不是临时表

这是很多人的误解:CTE不会把结果物化(存储到临时表/内存中)。

在大多数引擎中,CTE只是一个"语法糖"——优化器会把CTE内联(inline)到主查询中,效果和子查询完全一样。

-- 这两种写法在Hive/Spark中的执行计划完全相同
WITH filtered AS (SELECT * FROM big_table WHERE dt = '2025-01-01')
SELECT * FROM filtered;
 
SELECT * FROM (SELECT * FROM big_table WHERE dt = '2025-01-01') filtered;

但如果同一个CTE被引用了多次,问题就来了:

WITH user_stats AS (
  -- 这个子查询涉及大量计算
  SELECT user_id, SUM(cost) AS total_cost
  FROM user_order
  GROUP BY user_id
)
-- 被引用了两次
SELECT a.user_id, a.total_cost, b.total_cost AS other_cost
FROM user_stats a
JOIN user_stats b ON a.user_id != b.user_id  -- 极端例子

在Hive/Spark中,user_stats这个CTE会被执行两次——因为它只是语法展开,不会缓存结果。如果CTE内部计算量很大,这就是双倍的开销。

解法:手动物化

-- 用临时表/临时视图物化CTE的结果
CREATE TEMPORARY VIEW user_stats AS
SELECT user_id, SUM(cost) AS total_cost
FROM user_order
GROUP BY user_id;
 
-- Spark中还可以用cache
CACHE TABLE user_stats;
 
SELECT ... FROM user_stats a JOIN user_stats b ...

不过大多数场景下CTE只被引用一次,不用担心这个问题。

PostgreSQL的特殊处理

值得一提的是,PostgreSQL 12之前的版本对CTE的处理和Hive/Spark不同——PostgreSQL会把CTE的结果物化(相当于自动建临时表),优化器不会把CTE内联到主查询中。

这导致两个后果:

1. CTE被引用多次时不会重复计算(好处)

2. 优化器无法对CTE内部做谓词下推等优化(坏处)

-- 在PostgreSQL 11中,WHERE条件不会被下推到CTE内部
WITH all_orders AS (
  SELECT * FROM user_order  -- 全表扫描
)
SELECT * FROM all_orders WHERE dt = '2025-01-01';
-- dt的过滤发生在CTE物化之后,全表已经被读出来了

PostgreSQL 12引入了MATERIALIZED / NOT MATERIALIZED关键字来控制:

WITH all_orders AS NOT MATERIALIZED (
  SELECT * FROM user_order
)
SELECT * FROM all_orders WHERE dt = '2025-01-01';
-- NOT MATERIALIZED允许优化器内联CTE,谓词可以下推

面试的时候如果对方用的是PostgreSQL,提到这个版本差异是加分项。

CTE的实战用法

用法一:分步调试

开发复杂SQL时,CTE可以逐步构建、逐步验证:

-- 开发时,先跑step1看看结果对不对
WITH step1 AS (...)
SELECT * FROM step1 LIMIT 10;
 
-- step1确认没问题,加上step2
WITH step1 AS (...),
     step2 AS (SELECT ... FROM step1 ...)
SELECT * FROM step2 LIMIT 10;
 
-- 逐步加,直到完整SQL

我写复杂的广告归因SQL都是这么开发的——先把数据清洗步骤写成CTE,确认每一步的中间结果正确,再往下接。比一口气写完再debug效率高太多了。

用法二:重复引用简化

虽然CTE不会自动缓存,但它可以避免你在SQL里复制粘贴同样的子查询:

WITH base_metrics AS (
  SELECT
    dt,
    channel,
    SUM(cost) AS cost,
    SUM(impressions) AS impressions,
    SUM(clicks) AS clicks
  FROM ad_daily_cost
  WHERE dt BETWEEN '2025-01-01' AND '2025-01-31'
  GROUP BY dt, channel
)
SELECT
  a.dt,
  a.channel,
  a.cost,
  -- 日环比
  a.cost - b.cost AS dod_diff,
  -- 渠道占比
  a.cost * 100.0 / c.daily_total AS channel_pct
FROM base_metrics a
LEFT JOIN base_metrics b
  ON a.channel = b.channel
  AND a.dt = DATE_ADD(b.dt, 1)
CROSS JOIN (
  SELECT dt, SUM(cost) AS daily_total
  FROM base_metrics
  GROUP BY dt
) c
WHERE a.dt = c.dt

用法三:递归CTE

CTE还支持递归,可以处理层级关系数据(比如组织架构、分类树):

-- 查找某个员工的所有上级
WITH RECURSIVE management_chain AS (
  -- 起点:目标员工
  SELECT emp_id, emp_name, manager_id, 1 AS level
  FROM employee
  WHERE emp_id = 'E100'
 
  UNION ALL
 
  -- 递归:找上级的上级
  SELECT e.emp_id, e.emp_name, e.manager_id, mc.level + 1
  FROM employee e
  JOIN management_chain mc ON e.emp_id = mc.manager_id
)
SELECT * FROM management_chain ORDER BY level;

递归CTE在面试中偶尔会考到,但Hive不支持递归CTE,Spark SQL从3.x开始有限支持。PostgreSQL和MySQL 8.0+完整支持。

CTE vs 临时表 vs 视图

特性CTE临时表视图
-------------------------
作用范围单条SQL内当前Session永久(除非DROP)
是否物化通常不(PG特殊)是否
被多次引用可能重复计算只计算一次每次引用都重新计算
适用场景SQL内的逻辑拆分中间结果需要复用公共逻辑封装

选择原则:

只在一条SQL内用一次:CTE

同一条SQL内用多次且计算量大:临时表

多条SQL、多个任务都要用:视图

刷题进度保存在当前浏览器