按城市、按状态看销售:JOIN 与 GROUP BY 合体

老板的正式需求下来了:一张表里要同时看到总单、已付、取消和支付率--维度越来越多。

学 40 min
练 65 min
盘 15 min
共 120 分钟

学 · 40 min

  1. 01SELECT 列表的约束:必须在 GROUP BY 里或被聚合包裹

    场景「按城市统计订单数」你顺手 select 了 u.city, o.total_amount, count(*)--报错:column must appear in the GROUP BY clause。

    分组之后每组只剩一行,SELECT 的每一列要么进过 GROUP BY(组内相同,有唯一值),要么被聚合函数包裹(压成单值)。o.total_amount 组内有几百个值,一个格子放不下,PG 拒绝猜。MySQL 关掉 only_full_group_by 时会随便取一个值不报错--著名的坑。

    -- 报错:total_amount 既不在 GROUP BY 也没被聚合
    select u.city, o.total_amount, count(*)
    from orders o join users u on o.user_id = u.id
    group by u.city;
    
    -- 修法:聚合它
    select u.city, sum(o.total_amount) as gmv, count(*) as 单数
    from orders o join users u on o.user_id = u.id
    group by u.city;

    易错PG 的严格检查是帮你挡错的;看到 MySQL「不报错但结果怪」,先查 GROUP BY 列全不全。

  2. 02多列分组的粒度理解

    场景老板要「城市 × 状态」交叉表:同一张 orders,维度从一列变两列。

    group by u.city, o.status 先按城市分、城市内再按状态分,每组一行。粒度 = GROUP BY 列的全体:多加一列,组数变多、每组的度量变小。写任何报表前先问自己「一行代表什么」--答案就是你的 GROUP BY。

    select u.city, o.status,
           count(*) as 单数,
           sum(o.total_amount) as gmv
    from orders o join users u on o.user_id = u.id
    group by u.city, o.status
    order by u.city, o.status;

    易错少写一个分组列,是报表「数字看起来对、粒度不对」的头号原因。

  3. 03PG 特性:count(*) FILTER (WHERE ...)

    场景老板要在一张表里同时看到总单、已付、取消。三个数字三条 SQL、三个结果对着粘?不用。

    count(*) filter (where 条件) 在聚合内部做条件计数:一次扫表,不同条件各数各的。比 sum(case when ...) 少一层嵌套、条件直读。这是 PG 特有语法(MySQL 没有),面试里写出来是加分项。

    select u.city,
           count(*)                             as 总单,
           count(*) filter (where o.status = 2) as 已付,
           count(*) filter (where o.status = 3) as 取消,
           round(100.0 * count(*) filter (where o.status = 2) / count(*), 1) as 支付率
    from orders o join users u on o.user_id = u.id
    group by u.city;

    易错filter 里只能写行级条件,不能引用别的聚合结果(那个要子查询或窗口函数)。

  4. 04GROUPING SETS / ROLLUP:小计与总计

    场景老板:「各城市销售额,顺便给个城市小计,最后来个总计。」--要在一个结果里出三种粒度。

    rollup (u.city) 在按城市分组的结果之外追加一行「所有城市合计」(该列显示 NULL);rollup(a, b) 会出 a 小计、(a,b) 明细、总计多层。GROUPING SETS 是完全体:想要哪几种粒度自己点菜。

    select coalesce(u.city, '【总计】') as 城市,
           count(*) as 单数,
           sum(o.total_amount) as gmv
    from orders o join users u on o.user_id = u.id
    group by rollup (u.city);

    易错小计行的 NULL 和业务 NULL 撞车:city 本身为 NULL 的用户会被 coalesce 吞进「总计」。用 grouping(city) 函数区分(返回 1 = 这是小计行)。

  5. 05报表口径三要素:粒度(一行代表什么)、过滤度量

    场景正式需求下来了,先别碰键盘--这一节是今天所有查询的方法论。

    任何报表口径 = 粒度(一行代表什么:一个用户?一个城市?)+ 过滤(哪些数据算进来:已支付才计 GMV?)+ 度量(算什么指标、分母是谁)。把这三行念给提需求的人确认过再写 SQL,能消灭一半返工。

    易错「支付率」的分母是全部订单还是排除已取消?两个口径都合理、数字差一截--不写清楚必被追问。

练 · 65 min

  1. 按「城市 × 状态」双维度统计订单数与 GMV
    参考答案

    粒度 = GROUP BY 列的全体:一行代表「某城市的某状态」。city 为 NULL、status 为 NULL 的行各自成组,这是口径的一部分,不是脏输出。

    select u.city, o.status,
           count(*) as 单数,
           sum(o.total_amount) as gmv
    from orders o
    join users u on o.user_id = u.id
    group by u.city, o.status
    order by u.city nulls last, o.status;
  2. 用 FILTER 在一条 SQL 里同时算:总单数、已支付单数、已取消单数
    参考答案

    一次扫表、多个条件各数各的,比三条查询省两次扫描;filter 是 PG 特产,条件直读。

    select u.city,
           count(*)                             as 总单,
           count(*) filter (where o.status = 2) as 已付,
           count(*) filter (where o.status = 3) as 取消
    from orders o
    join users u on o.user_id = u.id
    group by u.city
    order by u.city nulls last;
  3. sum(case when ...) 再写一遍第 2 题,对比可读性
    参考答案

    结果与 FILTER 版一字不差,胜在跨库通用(MySQL 也认);FILTER 胜在少一层嵌套、条件直读。两个都要会:面试写 FILTER 加分,接手老项目读得懂 CASE。

    select u.city,
           count(*)                                      as 总单,
           sum(case when o.status = 2 then 1 else 0 end)  as 已付,
           sum(case when o.status = 3 then 1 else 0 end)  as 取消
    from orders o
    join users u on o.user_id = u.id
    group by u.city
    order by u.city nulls last;
  4. 用 ROLLUP 输出「城市销售额 + 小计 + 总计」
    参考答案

    rollup 追加一行总计(city 显示 NULL)。撞车现场:city 本身为 NULL 的组也显示 NULL,会被 coalesce 一并标成「【总计】」--结果里出现两行总计就是它。严格区分用 grouping(u.city) = 1 判断小计行。

    select coalesce(u.city, '【总计】') as 城市,
           count(*) as 单数,
           sum(o.total_amount) as gmv
    from orders o
    join users u on o.user_id = u.id
    group by rollup (u.city);
  5. 算各城市客单价(GMV / 订单数),并回答:city 为 NULL 的用户算哪个口径
    参考答案

    city 为 NULL 的用户(约 2%)是独立的「未知城市」口径:既不该被小计吞掉,也不该静默丢弃--报表备注写明「未知城市占 x%」。口径问题的正解永远是说清楚,不是藏起来。

    select u.city,
           count(*) as 单数,
           sum(o.total_amount) as gmv,
           round(sum(o.total_amount) / count(*), 2) as 客单价
    from orders o
    join users u on o.user_id = u.id
    group by u.city
    order by 客单价 desc;
过关标准 一条 SQL 输出「城市 | 总单 | 已付 | 取消 | 支付率」五列。