类目上线:树形结构与自连接
商品要归类了。类目是「电子 > 手机 > 配件」这样的树,存在 parent_id 里。第一个需求:按一级类目看销售。
学 · 40 min
01自关联表:parent_id 指回本表
场景类目是棵树:「电子 > 手机 > 配件」。树要怎么塞进一张表?
每行一个类目,加一列
parent_id指向本表里父类目的 id,顶级类目的 parent_id 为 NULL。这叫邻接表模型:树结构不需要多张表,一列自引用就够,而且加层级不用改表结构。-- 表里长这样:父和子是同一张表里的行 select id, name, parent_id from categories order by id limit 6;易错树的深度没有上限。任何「固定跳两层 JOIN」的写法都偷偷假设了「树只有两层」--需求一说「所有层级」就得换递归(D18)。
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查出来的是「父挂子名」,全表错位但不报错。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 挂在几级?可能挂二级、可能直接挂一级,路径不定就是递归的活。
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
- 跑 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:全部回填 - 自连接输出「一级类目名 | 二级类目名」全表
参考答案
自连接的全部秘密:同一张表进 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; - 按一级类目统计 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; - 给定一个二级类目,查出它的父类目
参考答案
还是自连接,只是方向反过来:从子出发找父。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'; - 想想:为什么 products 不直接冗余一级类目 id?写两句利弊进 notes.md
参考答案
利:查询少跳一次 JOIN,按一级出报表更快、SQL 更短。弊:商品换挂类目或层级调整时要同时维护两列,两列很容易不一致,报表悄悄出错还查不到原因。当前阶段用「连接换正确性」;等第 6 周数据量上来、真的慢了,再谈反范式冗余。