每个类目销量前三:LATERAL
运营要做「每个类目热销 TOP3」看板。你上次用整表聚合再过滤的写法,数据一大就慢;这次学个新武器。
学 · 40 min
01LATERAL 让右侧子查询能引用左侧的列
场景「每个用户最近 3 笔订单」:子查询里得写「这个用户的」--普通子查询够不着外层。
join lateral (子查询):右侧子查询里可以直接用左边表的列。执行直觉:对左表每一行,把它的值代进右查询跑一遍,结果并上来。它是「能返回一整组行的相关子查询」。select u.name, t.* from users u join lateral ( select id, total_amount, created_at from orders o where o.user_id = u.id -- 引用了左边的 u.id order by created_at desc limit 3 ) t on true order by u.name, t.created_at desc;易错LATERAL 只能出现在 FROM / JOIN 的右侧;子查询里引用的别名必须在它左边出现过。
02LEFT JOIN LATERAL (...) ON true 的固定写法
场景有的用户没下过单--inner join lateral 会把他们整个吞掉。
lateral 子查询没有传统意义的连接条件,语法上用
on true占位;改成left join lateral (...) on true,右查询空结果也保留左行(右侧补 NULL)。「每个 X 及其最新 Y,没有也要」= 这句话。select u.name, t.id, t.total_amount from users u left join lateral ( select id, total_amount from orders o where o.user_id = u.id order by created_at desc limit 1 ) t on true;易错少了 on true 是语法错;该用 left 的场景写成 inner,没下过单的用户静默消失。
03Top-N per group 的三种解法对比
场景同一个需求三条路:整表 row_number、LATERAL、聚合后过滤。选哪条?
①
row_number() over (partition by 组)+ 外层过滤:通用、组数无所谓;② LATERAL:每个组只扫自己要的那几行(前提:排序列上有索引),组多且每组取少量时常胜;③ 老式聚合拼接:别用了。D23 会正式展开 ①,今天先用 ② 的身体记住这个对比。-- LATERAL 版:每个一级类目销量前 3 的商品 select c.name as 类目, t.name as 商品, t.销量 from categories c join lateral ( select p.name, sum(i.qty) as 销量 from products p join order_items i on i.product_id = p.id where p.category_id = c.id group by p.id, p.name order by 销量 desc limit 3 ) t on true order by c.name, t.销量 desc;易错LATERAL 快的前提是「组内排序键有索引支撑」。没有索引时它退化成每组一次排序,可能更慢--所以 D20 练习要做耗时对比表。
04LATERAL 与相关子查询的关系
场景越看越像 D16 的相关子查询--本来就是一家。
本质都是「对外层每行执行一次的子查询」。区别在位置和产出:相关子查询放在 WHERE / SELECT 里,返回标量或布尔;LATERAL 放在 FROM 里,返回一组行、成为数据源。心法:要一个数,用相关子查询;要几行几列,用 LATERAL。
-- 要一个数(每用户订单数) select u.name, (select count(*) from orders o where o.user_id = u.id) as n from users u; -- 要几行(每用户最近 3 单)-- 上面 LATERAL 的例子易错SELECT 列表里的子查询必须标量;想让它返回多行多列,硬写只会报错,换 LATERAL。
练 · 65 min
- 用 LATERAL 查每个用户最近 3 笔订单
参考答案
LATERAL 让右侧子查询引用左侧的列:对每个用户把 u.id 代进右查询跑一遍。想保留没下过单的用户,把 join lateral 换成 left join lateral ... on true,右侧补 NULL。
select u.name, t.id as 订单id, t.total_amount, t.created_at from users u join lateral ( select id, total_amount, created_at from orders o where o.user_id = u.id -- 子查询里直接用左边的 u.id order by created_at desc limit 3 ) t on true order by u.name, t.created_at desc; - 用 LATERAL 查每个一级类目销量前 3 的商品
参考答案
别忘了商品挂在二级:LATERAL 里要经二级跳到一级(c2.parent_id = c.id)。外层没显式筛顶级也没错--二级类目匹配不到任何商品,LATERAL 返回空集被 inner join 自动丢弃;显式加 where c.parent_id is null 会更清楚。类目树更多层时,换成 D18 的递归先算子孙集合。
select c.name as 一级类目, t.name as 商品, t.销量 from categories c join lateral ( select p.name, sum(i.qty) as 销量 from products p join order_items i on i.product_id = p.id join categories c2 on c2.id = p.category_id where c2.parent_id = c.id -- 该一级类目下面所有二级的商品 group by p.id, p.name order by 销量 desc limit 3 ) t on true order by c.name, t.销量 desc; - 用
ROW_NUMBER把第 2 题再写一遍(预习下周窗口函数语法)参考答案
整表聚合一遍、窗口函数编号、外层过滤三段式,下周的主角今天先混个脸熟。它不依赖索引,通用性最好;和 LATERAL 的性能分水岭见第 5 题。
select 一级类目, 商品, 销量 from ( select c1.name as 一级类目, p.name as 商品, sum(i.qty) as 销量, row_number() over (partition by c1.name order by sum(i.qty) desc) as rn 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 c1.name, p.name ) t where rn <= 3; - 用
DISTINCT ON写 N=1 的版本参考答案
distinct on (列) = 每组保留排序后的第一行,所以只能做 N=1。两个约束:order by 必须以 distinct on 的列开头,后面的排序键决定每组留哪一行。PG 方言,别的库没有。
select distinct on (c1.name) c1.name as 一级类目, p.name as 商品, sum(i.qty) 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 c1.name, p.name order by c1.name, sum(i.qty) desc; - 三种写法都跑
EXPLAIN ANALYZE,记录耗时做成对比表参考答案
方法:把第 2 / 3 / 4 题各跑 explain analyze 三次,记录 Execution Time 做成「写法 | 计划要点 | 耗时」三列的表。预期:12 万明细、几十个组,三种都在几十毫秒量级、难分胜负;LATERAL 的优势要等「组多、每组只取少量、组内排序键有索引」才显现(每组只扫自己要的那几行)。结论先记方法,第 6 周百万行时回来重跑对比。