「80% 的 GMV 是谁贡献的」:占比与百分位

老板问了个致命问题:「我们 80% 的 GMV 是多少头部用户贡献的?」--组内占比与百分位专场。

学 35 min
练 70 min
盘 15 min
共 120 分钟

学 · 35 min

  1. 01sum / avg / count / max 作为窗口函数使用

    场景明细行旁边要同时出现「组内总和、组内均值」--普通聚合做不到同框。

    任何聚合函数加 over 都是窗口版:sum() over (partition by ...) 在每行旁边贴上组内总和。这比「先 group by 算汇总,再 join 回明细」少一步、快一截、还更可读。

    select id, user_id, total_amount,
           sum(total_amount) over (partition by user_id) as 该用户总额,
           avg(total_amount) over (partition by user_id) as 该用户均值
    from orders;

    易错窗口版聚合和普通聚合不是二选一:要明细带汇总用窗口,要纯汇总报表用 group by。

  2. 02组内占比的通用写法:x / sum(x) OVER (PARTITION BY g)

    场景「每个商品销售额占其类目的比例」--分母是组内总和。

    占比 = 分子 / sum(分子) over (partition by 组),一行搞定,不用子查询。要同时算「占类目%」和「占全站%」就嵌两个不同分区的 sum:partition by 类目 和 空括号(全表)。分母分区是什么,占比就是什么口径。

    select p.name as 商品, s.销售额,
           round(100.0 * s.销售额 / sum(s.销售额) over (partition by s.类目), 1) as 占类目pct,
           round(100.0 * s.销售额 / sum(s.销售额) over (),                  1) as 占全站pct
    from (
      select p.id, p.name, c.name as 类目, sum(i.qty * i.unit_price) as 销售额
      from order_items i
      join products p on p.id = i.product_id
      join categories c on c.id = p.category_id
      group by p.id, p.name, c.name
    ) s;

    易错占比列求和应 = 100% 是自检手段;round 后可能是 99.9 / 100.1,属于舍入误差,交付时说一句即可。

  3. 03明细行同时带组均值、与均值的差

    场景哪些订单「明显高出该用户的平均水平」--离群排查。

    x - avg(x) over (partition by 组):每行直接标出离组均值多远。加上第 1 条的组均值列,一张表看懂「这个用户的正常水位在哪、这单偏了多少」。

    select id, user_id, total_amount,
           avg(total_amount) over (partition by user_id) as 用户均值,
           total_amount - avg(total_amount) over (partition by user_id) as 偏离均值
    from orders;

    易错avg 跳过 NULL 行(D4 的分母口径问题在窗口版同样存在)--列里有 NULL 时先想清楚分母。

  4. 04percentile_cont 算分组中位数

    场景「每个类目的价格中位数」--中位数没有窗口函数版,语法也和别的聚合长得不一样。

    percentile_cont(0.5) within group (order by 列) 算任意分位数(0.5 = 中位数),配合 group by 出分组结果。它是分组聚合不是窗口函数,PG 也不支持给它加 over(ordered-set 聚合没有窗口版,直接报错)--想要「明细行带组内中位数」,把分组结果写成 CTE 再 join 回明细。

    -- 每个一级类目的价格中位数(分组聚合版)
    select c.name as 类目,
           percentile_cont(0.5) within group (order by p.price) as 价格中位数
    from products p
    join categories c on c.id = p.category_id
    group by c.name;
    
    -- 明细带组内中位数:分组结果当 CTE 再 join 回去(没有窗口版可走捷径)
    with m as (
      select category_id,
             percentile_cont(0.5) within group (order by price) as 中位数
      from products
      group by 1
    )
    select p.name, p.price, m.中位数
    from products p join m on m.category_id = p.category_id;

    易错PG 没有 median() 函数;within group 这个子句别漏--漏了语法就错。给 ordered-set 聚合加 over 也是语法错。

练 · 70 min

  1. 每个商品销售额占其所属一级类目的比例
    参考答案

    分母 = sum(销售额) over (partition by 类目):partition by 是谁,占比就是什么口径。自检手段:按类目把占类目pct求和应等于 100(round 后 99.9/100.1 属舍入误差)。

    select 商品, 类目, 销售额,
           round(100.0 * 销售额 / sum(销售额) over (partition by 类目), 1) as 占类目pct
    from (
      select p.name as 商品, c1.name as 类目,
             sum(i.qty * i.unit_price) as 销售额
      from order_items i
      join products p    on p.id = i.product_id
      join categories c2 on c2.id = p.category_id      -- 二级
      join categories c1 on c1.id = c2.parent_id       -- 自连接到一级
      group by p.id, p.name, c1.name
    ) s
    order by 类目, 销售额 desc;
  2. 每个一级类目销售额占全站的比例(同一条 SQL 里两个占比都要有)
    参考答案

    两个分母两个口径:partition by 类目 vs 空括号(全表)。这就是「先 group by 算汇总再 join 回明细」的窗口一步到位版。

    select 商品, 类目, 销售额,
           round(100.0 * 销售额 / sum(销售额) over (partition by 类目), 1) as 占类目pct,
           round(100.0 * 销售额 / sum(销售额) over (),                  1) as 占全站pct
    from (
      select p.name as 商品, c1.name as 类目,
             sum(i.qty * i.unit_price) as 销售额
      from order_items i
      join products p    on p.id = i.product_id
      join categories c2 on c2.id = p.category_id
      join categories c1 on c1.id = c2.parent_id
      group by p.id, p.name, c1.name
    ) s
    order by 类目, 销售额 desc;
  3. 每个用户消费额的百分位排名
    参考答案

    percent_rank = (rank - 1) / (总行数 - 1),区间 [0, 1],0 表示排第一、并列给相同值。想知道「我和比我强的共占多少」用 cume_dist。

    select user_id, 消费额,
           round(percent_rank() over (order by 消费额 desc)::numeric, 3) as 前百分之几
    from (select user_id, sum(total_amount) as 消费额 from orders group by 1) t
    order by 消费额 desc
    limit 20;
  4. 每个一级类目的价格中位数
    参考答案

    PG 没有 median();percentile_cont(0.5) within group (order by 列) 是唯一正解,within group 子句漏了直接语法错。想给明细行带组内中位数,PG16 不支持它的窗口写法,得把分组结果当 CTE 再 join 回明细。

    select c1.name as 一级类目,
           percentile_cont(0.5) within group (order by p.price) as 价格中位数
    from products p
    join categories c2 on c2.id = p.category_id
    join categories c1 on c1.id = c2.parent_id
    group by c1.name
    order by 一级类目;
  5. 每笔订单金额与该用户平均客单价的差额
    参考答案

    明细行同框组均值 + 偏离值,一眼看出哪单明显高于该用户的正常水位(离群排查)。注意 avg 窗口版同样跳过 NULL 行--D4 的分母口径问题跟着窗口一起继承。

    select id, user_id, total_amount,
           round(avg(total_amount) over (partition by user_id), 2) as 用户平均客单价,
           round(total_amount - avg(total_amount) over (partition by user_id), 2) as 偏离均值
    from orders
    order by user_id, 偏离均值 desc;
过关标准 一条 SQL 输出「商品 | 销售额 | 占类目% | 占全站%」四列,两个占比列各自求和自洽。