把绕需求写成人话:CTE 与五步拆解法
运营的原话:「近 3 个月,每月新客的 GMV 和老客的 GMV 分开算。」你决定以后拿到需求先拆步骤再动手,用 CTE 把每一步写成能读的段落。
学 · 40 min
01WITH 基本语法与多级 CTE
场景派生表套派生表,三层括号已经读不懂了--需要给每个中间结果起名字。
with a as (...), b as (...引用 a...), c as (...引用 b...) select ...:后面的能用前面的,自顶向下一条流水线。给每段起业务名(清洗 / 聚合 / 排名),SQL 从一坨嵌套变成分段的作文--CTE 的核心价值是给思路命名。with 聚合 as ( select user_id, sum(total_amount) as gmv from orders group by 1 ), 带名字 as ( select u.name, a.gmv from 聚合 a join users u on u.id = a.user_id ) select * from 带名字 order by gmv desc limit 10;易错CTE 名别和真实表名撞(撞了会遮蔽原表,查错极难发现);只能引用「写在它前面」的 CTE。
02PG 12 起 CTE 默认可内联,之前版本是优化栅栏
场景老资料说「CTE 能防止条件推入、能提速」--按版本说话。
PG 11 及以前:CTE 一律物化(算一次存临时表),并且是优化栅栏--外层的 where 推不进去。PG 12 起:只被引用一次的 CTE 默认内联(像视图一样展开进主查询,栅栏没了);被引用多次的仍然物化。
select version(); -- 先知道自己站在哪个版本上讨论 -- PG 12+:这个 CTE 会被内联,user_id 条件能推入 with t as (select * from orders where status = 2) select * from t where user_id = '...';易错面试聊 CTE 必带版本号。不加版本说「CTE 是优化栅栏」,遇到懂 PG 的面试官直接扣分。
03MATERIALIZED / NOT MATERIALIZED 的显式控制
场景一个昂贵 的 CTE 要被引用两次--到底算一遍还是两遍?
with t as materialized (...)强制算一次存结果(引用 N 次也只算一次);as not materialized强制内联(每次引用展开一次)。默认规则记不住没关系--在意就显式写,语义摆在自己手里。with t as materialized ( -- 昂贵计算只跑一次 select user_id, count(*) as n from orders group by 1 ) select (select count(*) from t where n = 1) as 只下一单, (select count(*) from t where n >= 2) as 复购;易错物化 = 写临时结果(可能落盘),有真实代价;不是免费缓存。「引用多次的昂贵 CTE」才值得物化。
04澄清误区:CTE 本身不是性能优化手段
场景网上文章标题《用 CTE 让查询快 10 倍》--你想知道它到底优化了什么。
CTE 解决的是可读性和拆解,不是速度。内联的 CTE 和一坨子查询在优化器眼里是同一个东西;物化倒是省了重复计算,但那是「你把逻辑组织对了」的副产品。快是因为索引、是因为少算了东西,从来不是因为 with 这个关键字。
易错为了「性能」把查询套 10 层 CTE,既不快也不可读--方向就错了。
05复杂需求五步法:定粒度 -> 找主表 -> 逐步补维度 -> 定过滤位置 -> 最后聚合排序
场景运营原话:「近 3 个月,每月新客的 GMV 和老客的 GMV 分开算。」--先在注释里拆,再写 SQL。
① 定粒度:一行 = 月份 × 新老客;② 找主表:事实在 orders;③ 补维度:「新客」要判定(首单是否落在当月)--先算每个用户的首单月份;④ 定过滤:近 3 个月在哪一步过滤;⑤ 聚合排序最后写。五步写在注释里,SQL 一段对应一步,写完的需求自己都能复查。
-- ① 粒度:月 × 新老客 ② 主表:orders with 首单 as ( -- ③ 维度:首单月份 select user_id, min(created_at)::date as 首单日 from orders group by 1 ) select date_trunc('month', o.created_at) as 月份, case when date_trunc('month', f.首单日) = date_trunc('month', o.created_at) then '新客' else '老客' end as 客群, sum(o.total_amount) as gmv -- ⑤ 最后聚合 from orders o join 首单 f on f.user_id = o.user_id where o.created_at >= date_trunc('month', current_date) - interval '3 months' -- ④ 过滤 group by 1, 2 order by 1, 2;易错不拆就写的人,一半概率写到一半发现粒度错了全部推翻重来。五步法是慢就是快。
练 · 65 min
- D13 的最后一笔欠账:「每月新增用户数与当月下单用户数」--两段各自聚合,再对齐成一张表
参考答案
两个数字来自不同的表、不同的时间列,一条 SQL 聚不出来--必须先各自聚成「月份表」再对齐,这正是 CTE 存在的理由:给两段中间结果起名字,主查询只负责拼。为什么是 FULL JOIN:用户注册横跨两年、订单只有近 90 天,两边的月份集合互相都有对方没有的,LEFT 会丢掉只有订单的月、INNER 两头都丢。对齐后 coalesce 兜两处:月份本身(哪边有取哪边)和计数(没有就是 0)。
with new_u as ( -- 注册侧:按月数新增用户 select date_trunc('month', created_at) as 月份, count(*) as 新增用户数 from users group by 1 ), act as ( -- 下单侧:按月数活跃用户(去重) select date_trunc('month', created_at) as 月份, count(distinct user_id) as 当月下单用户数 from orders group by 1 ) select coalesce(n.月份, a.月份) as 月份, coalesce(n.新增用户数, 0) as 新增用户数, coalesce(a.当月下单用户数, 0) as 当月下单用户数 from new_u n full join act a on a.月份 = n.月份 order by 1; - 写一个三级 CTE:清洗 -> 聚合 -> 排名
参考答案
每个 CTE 只干一件事、名字用业务动词,后面的引用前面的,自顶向下一条流水线。CTE 的核心价值是给思路命名,不是提速。
with 清洗 as ( -- ① 剔除脏数据:负金额、空状态 select * from orders where total_amount > 0 and status is not null ), 聚合 as ( -- ② 每用户算指标 select user_id, count(*) as 单数, sum(total_amount) as gmv from 清洗 group by 1 ), 排名 as ( -- ③ 按消费额排名 select *, row_number() over (order by gmv desc) as 排名 from 聚合 ) select * from 排名 where 排名 <= 10; - 同一查询加与不加
MATERIALIZED,对比执行计划差异参考答案
看计划的差异点:内联版里 orders 的扫描节点直接挂着 user_id 过滤(扫的行数少);物化版多一个 CTE Scan 节点,先算出 3 万多行已支付订单再过滤。uuid 是灌数脚本的确定性映射,编号 42 对应这个值。
-- 默认(PG 12+,只引用一次会内联):user_id 条件被推进底层扫描 explain analyze with t as (select * from orders where status = 2) select count(*) from t where user_id = '00000000-0000-0000-0000-000000000042'; -- 强制物化:先算出整个 CTE,再 CTE Scan 过滤 explain analyze with t as materialized (select * from orders where status = 2) select count(*) from t where user_id = '00000000-0000-0000-0000-000000000042'; - 「近 3 个月每月新客 GMV 与老客 GMV」:先写五步拆解,再写 SQL
参考答案
最容易错的是③和④的配合:首单必须用全历史算,「近 3 个月」只过滤订单本身。如果先过滤再算首单,四个月前就下过单的用户会被误判成新客--五步法里「过滤放在哪一步」要想清楚再动手。
-- ① 粒度:月 × 新老客 ② 主表:orders(事实在订单上) with 首单 as ( -- ③ 维度:每个用户的首单时间(用全历史算) select user_id, min(created_at) as 首单时间 from orders group by 1 ) select date_trunc('month', o.created_at) as 月份, case when date_trunc('month', f.首单时间) = date_trunc('month', o.created_at) then '新客' else '老客' end as 客群, sum(o.total_amount) as gmv -- ⑤ 最后聚合 from orders o join 首单 f on f.user_id = o.user_id where o.created_at >= date_trunc('month', current_date) - interval '3 months' -- ④ 过滤 group by 1, 2 order by 1, 2; - 写一个被引用两次的 CTE,观察是否被计算两次
参考答案
对比两版 explain:默认版只有一个 GroupAggregate,CTE 结果被两个子查询共用;not materialized 版分组节点出现两份。结论:被引用多次的昂贵 CTE,默认的物化是在帮你省钱。
-- t 被引用两次:PG 会物化,分组只算一次 with t as ( select user_id, count(*) as n from orders group by 1 ) select (select count(*) from t where n = 1) as 只下一单, (select count(*) from t where n >= 2) as 复购用户数; -- 强制不物化:同样的分组算两遍 with t as not materialized ( select user_id, count(*) as n from orders group by 1 ) select (select count(*) from t where n = 1) as 只下一单, (select count(*) from t where n >= 2) as 复购用户数;
今日复盘
- 查每个分类的完整路径(如「电子 > 手机 > 配件」)
参考答案
随身列模式:路径 = 父.路径 || ' > ' || 本名。seed 里类目名是「分类N / 子分类N」,所以路径长成「分类3 > 子分类17」;名字里的 ::text 是为了钉死路径列的类型。
with recursive tree as ( select id, name, parent_id, name::text as 路径, 1 as 层级 from categories where parent_id is null -- 初始项:从全部顶级开始 union all select c.id, c.name, c.parent_id, t.路径 || ' > ' || c.name, t.层级 + 1 -- 随身列:路径和深度都从父行继承 from categories c join tree t on c.parent_id = t.id ) select name as 类目, 路径, 层级 from tree order by 路径; - 用标量子查询输出「每个用户消费额 − 全站平均消费额」
参考答案
口径先说清:「全站平均消费额」的分母是用户数(先按用户聚合再 avg),不是订单数。标量子查询是非相关的,整条 SQL 里只算一次、结果当常量用。
select user_id, sum(total_amount) as 消费额, round(sum(total_amount) - (select avg(消费额) from (select user_id, sum(total_amount) as 消费额 from orders group by 1) x), 2) as 与全站平均的差额 from orders group by 1; - UNION vs UNION ALL:去重的代价
易错点
确认两段结果不可能重叠,就写 ALL;用它做「去重」是顺便的副作用,不是设计目标。