月报日:八张业务报表
月底。老板要「公司经营月报」:品类、城市、复购、客单价……你一上午都在跟口径较劲。
学 · 10 min
01报表口径三要素:粒度(一行代表什么)、过滤(算哪些数据)、度量(算什么指标)
场景月报日,八张报表排着队。每张动手前,先写三行字。
拿到需求先答三个问题再写 SQL:一行代表什么(粒度)、哪些数据算进来(过滤)、算什么指标(度量)。比如「复购率」:粒度 = 用户;过滤 = 统计期内下过单的用户;度量 = 其中下单 ≥ 2 次的占比。三行写出来,SQL 就是把它翻译成代码。
-- 「复购率」的三要素翻译成 SQL select round(100.0 * count(*) filter (where 单数 >= 2) / count(*), 1) as 复购率 from ( select user_id, count(*) as 单数 from orders group by user_id ) t;易错八张报表最容易口径打架的是分母:GMV 按全部订单还是已支付订单?全月统一一个口径,并写进报表备注。
练 · 95 min · 每题单条 SQL
- GMV 三个口径对账:全部订单 / 只算已支付 / 已支付且剔除负金额
参考答案
月报第一件事是把口径钉死:同一个「GMV」,三种算法三个数--全部订单(含未支付、已取消)、只算已支付、再把 D01 埋的负金额压成 0。三列并排摆出来,差额来自哪一眼就看见。用 LEFT JOIN 是因为 city 可能为空,别让 INNER 把订单吃掉。选哪个口径都算对,错的是不把选择写进报表备注。
select coalesce(u.city, '未知') as 城市, sum(o.total_amount) as 全部口径, sum(o.total_amount) filter (where o.status = 2) as 已支付口径, sum(greatest(o.total_amount, 0)) filter (where o.status = 2) as 剔负口径 from orders o left join users u on u.id = o.user_id group by 1 order by 已支付口径 desc nulls last; - 本月每日订单数与 GMV,没有订单的日期要补 0
参考答案
D06 的补零套路原样搬过来,只把「近 30 天」换成「本月 1 号到今天」:日历在左、订单在右,LEFT JOIN。两个坑都在右表没匹配上的那天--必须 count(o.id) 不能 count(*)(没订单的那天 LEFT JOIN 也给一行,count(*) 会数成 1),sum 对空集得 NULL 不是 0,要 coalesce 兜住。
select d.日期::date as 日期, count(o.id) as 单数, coalesce(sum(o.total_amount), 0) as gmv from generate_series(date_trunc('month', current_date), current_date, interval '1 day') as d(日期) left join orders o on o.created_at::date = d.日期::date group by d.日期 order by d.日期; - 客单价最高的 TOP10 商品
参考答案
口径题:分母选「购买该商品的用户数」(去重),不是订单数更不是件数--三种分母三个结果。选哪个都算对,错的是不把口径写进报表备注。
select p.name as 商品, sum(i.qty * i.unit_price) as gmv, count(distinct o.user_id) as 购买用户数, round(sum(i.qty * i.unit_price) / count(distinct o.user_id), 2) as 客单价 from order_items i join orders o on o.id = i.order_id join products p on p.id = i.product_id group by p.id, p.name order by 客单价 desc limit 10; - 下单超 24 小时仍未支付的订单明细
参考答案
「未支付」用 status = 1 圈定,别用 paid_at is null--已取消的单也没有 paid_at,口径会混进取消单。这里是在列明细不是聚合,一对多放大是应该的;千万别顺手 sum(o.total_amount)。
select o.id as 单号, o.created_at as 下单时间, p.name as 商品, i.qty as 数量, i.unit_price as 单价 from orders o join order_items i on i.order_id = o.id join products p on p.id = i.product_id where o.status = 1 and o.created_at < now() - interval '24 hours' order by o.created_at, o.id; - 各城市 GMV 排名
参考答案
「排名」在这里就是排序:ORDER BY 能引用 SELECT 里的别名 gmv,读到第几行就是第几名。city 为 NULL 的组照常成一组,展示时标「未知」。真要把名次落成一列、还要处理并列,那是 rank() 的活,D23 讲。
select u.city, sum(o.total_amount) as gmv from orders o join users u on o.user_id = u.id group by u.city order by gmv desc; - 从未下过单的注册用户数及其占比
参考答案
D09/D10/D11 三个点凑一块:LEFT JOIN 之后一个用户被放大成多行(下过 N 单就 N 行),所以分母必须 count(distinct u.id) 而不是 count(*);o.id is null 就是 D10 的反连接,圈出没匹配上的那批;FILTER 让分子分母在同一条 SQL 里出来。自洽校验:从未下单 + 下过单 = 注册用户数。D12 用 EXCEPT 查的是同一批人,这里多要了一个占比。
select count(distinct u.id) as 注册用户数, count(distinct u.id) filter (where o.id is null) as 从未下单, round(100.0 * count(distinct u.id) filter (where o.id is null) / count(distinct u.id), 1) as 占比 from users u left join orders o on o.user_id = u.id; - 每个用户的首单时间与首单金额
参考答案
D12 的 DISTINCT ON 直接派上用场:第二排序键改成升序,留下的就是最早一单。窗口函数 row_number() 也能做,W4 展开。
select distinct on (o.user_id) o.user_id, u.name, o.created_at as 首单时间, o.total_amount as 首单金额 from orders o join users u on u.id = o.user_id order by o.user_id, o.created_at; - 各状态订单的平均支付时长(paid_at 与 created_at 之差)
参考答案
只有已支付单有 paid_at,其余状态 avg 直接得 NULL(聚合跳空)--「平均支付时长」这个指标只对 status = 2 有意义。另外那 20 单脏数据(支付早于下单)是负数,会把均值拉低一点,正式月报该剔除。
select coalesce(status::text, '未知') as 状态, count(*) as 单数, count(paid_at) as 有支付时间, round(avg(extract(epoch from (paid_at - created_at))) / 60, 1) as 平均支付时长_分钟 from orders group by 1 order by 1;
今日复盘
- 同一查询用 UNION 和 UNION ALL 各跑一次,对比 EXPLAIN 里多出来的节点
参考答案
UNION ALL 的计划只有一个 Append;UNION 在 Append 之上多出 HashAggregate(或 Sort + Unique)去重节点--去重是真实开销。两段结果天然互斥(一个用户只登记一个城市),确认不重叠就写 ALL。
explain select name, city from users where city = '上海' union all select name, city from users where city = '北京'; explain select name, city from users where city = '上海' union select name, city from users where city = '北京'; - 造两张 3 行小表,四种 JOIN 各跑一次,把结果抄进 notes.md
参考答案
LEFT 多出的 (一,NULL) 就是「右边匹配不上补 NULL」的实体证据--第 4 题的反连接全靠它工作。四行结果抄进 notes.md。
create table a (id int, label text); insert into a values (1,'一'), (2,'二'), (3,'三'); create table b (id int, val int); insert into b values (2,20), (3,30), (4,40); select a.label, b.val from a join b on a.id = b.id; -- 2 行:(二,20)(三,30) select a.label, b.val from a left join b on a.id = b.id; -- 3 行:多出 (一,NULL) select a.label, b.val from a right join b on a.id = b.id; -- 3 行:多出 (NULL,40) select a.label, b.val from a full join b on a.id = b.id; -- 4 行 -- drop table a, b; -- 下面几题还要用,全部实验做完再收走 - date / timestamp / interval;date_trunc 按天周月截断、extract 取分量
易错点
date_trunc('week') 的周从<b>周一</b>开始;业务如果按周日切周,得自己算偏移。