类目上线:树形结构与自连接

商品要归类了。类目是「电子 > 手机 > 配件」这样的树,存在 parent_id 里。第一个需求:按一级类目看销售。

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

学 · 40 min

  1. 01自关联表:parent_id 指回本表

    场景类目是棵树:「电子 > 手机 > 配件」。树要怎么塞进一张表?

    每行一个类目,加一列 parent_id 指向本表里父类目的 id,顶级类目的 parent_id 为 NULL。这叫邻接表模型:树结构不需要多张表,一列自引用就够,而且加层级不用改表结构。

    -- 表里长这样:父和子是同一张表里的行
    select id, name, parent_id from categories order by id limit 6;

    易错树的深度没有上限。任何「固定跳两层 JOIN」的写法都偷偷假设了「树只有两层」--需求一说「所有层级」就得换递归(D18)。

  2. 02自连接:同一张表起两个别名连自己

    场景要输出「一级类目名 | 二级类目名」对照表,可父和子在同一张表里。

    同一张表在 FROM 里出现两次、起两个别名(c 演父、s 演子),连接条件写 s.parent_id = c.id。对数据库来说是两个独立的数据源,对你来说是同一张表的两个角色--这就是自连接的全部秘密。

    select c.name as 一级类目, s.name as 二级类目
    from categories c
    join categories s on s.parent_id = c.id
    order by 1, 2;

    易错方向别写反:c.parent_id = s.id 查出来的是「父挂子名」,全表错位但不报错。

  3. 03类目树的三种基本查询:全表平铺 / 按一级分组 / 查直接孩子

    场景拿到一棵树,日常打交道的就是这三个动作。

    ① 平铺:直接 select *,树形靠 parent_id 脑补;② 按一级分组:先筛出顶级类目(parent_id is null),再自连接挂上孩子;③ 查某类目的直接孩子:where parent_id = 某id,只下一层。

    -- ② 一级类目和它的直接孩子
    select c.name as 一级, s.name as 孩子
    from categories c
    join categories s on s.parent_id = c.id
    order by 1;
    
    -- ③ 「手机」的直接孩子
    select id, name from categories
    where parent_id = (select id from categories where name = '手机');

    易错「按一级分组」要先问一句:商品的 category_id 挂在几级?可能挂二级、可能直接挂一级,路径不定就是递归的活。

  4. 04「按一级类目汇总」的连接链要跳两层(明细 -> 商品 -> 二级 -> 一级)

    场景老板要按一级类目看 GMV。order_items 里只有 product_id,商品的 category_id 还未必是顶级。

    连接链四步:order_items ->(product_id)products 拿 category_id ->(category_id)categories 第一次进,拿到二级类目 ->(parent_id)categories 第二次进,拿到一级类目的名字。categories 表进两次、两个别名,一个演二级一个演一级。

    select c1.name as 一级类目,
           sum(i.qty * i.unit_price) as gmv
    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 1
    order by gmv desc;

    易错类目树不止两层时,c2 可能本身就是顶级(parent_id 为 NULL),这些商品的 GMV 会整段消失。先 coalesce 兜底或统一归到根。

练 · 65 min

  1. 跑 seed.sql「W3」段:建 categories 并回填 products.category_id
    参考答案

    答案不是抄 SQL,是把 §C 段的三条语句找出来按顺序跑掉:第 2 条插二级依赖第 1 条的父类目,第 3 条回填依赖前两条,顺序不能乱。

    -- seed.sql 的 §C 段共三条:插 30 个一级、插 45 个二级、回填 products.category_id
    -- 在 psql 里逐条执行,灌完核对行数
    select count(*) from categories;                       -- 75 = 30 + 45
    select count(*) from products where category_id is not null;  -- 500:全部回填
  2. 自连接输出「一级类目名 | 二级类目名」全表
    参考答案

    自连接的全部秘密:同一张表进 FROM 两次、起两个别名,c 演父、s 演子。方向写反(c.parent_id = s.id)不报错但整表错位。

    select c.name as 一级类目, s.name as 二级类目
    from categories c
    join categories s on s.parent_id = c.id
    order by 1, 2;
  3. 按一级类目统计 GMV(order_items -> products -> categories 连两次)
    参考答案

    连接链四步画出来:明细 -> 商品 -> 二级 -> 一级,categories 进两次。seed 里商品全挂二级所以不丢行;树更深时 c1 会 join 不上(parent_id 为 NULL),防御性写法是 left join + coalesce(c1.name, c2.name) 兜底。

    select c1.name as 一级类目,
           sum(i.qty * i.unit_price) as gmv
    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 1
    order by gmv desc;
  4. 给定一个二级类目,查出它的父类目
    参考答案

    还是自连接,只是方向反过来:从子出发找父。seed 里类目名是「子分类N」,把「子分类1」换成任意二级类目名即可。

    select c.name as 二级类目, p.name as 父类目
    from categories c
    join categories p on p.id = c.parent_id
    where c.name = '子分类1';
  5. 想想:为什么 products 不直接冗余一级类目 id?写两句利弊进 notes.md
    参考答案

    利:查询少跳一次 JOIN,按一级出报表更快、SQL 更短。弊:商品换挂类目或层级调整时要同时维护两列,两列很容易不一致,报表悄悄出错还查不到原因。当前阶段用「连接换正确性」;等第 6 周数据量上来、真的慢了,再谈反范式冗余。

过关标准 按一级类目的 GMV 报表跑通,连接链能画出来。