LEFT JOIN后GROUP BY的膨胀源于JOIN阶段的物理行复制,而非GROUP BY本身;需先验证连接键唯一性,再通过子查询聚合、LATERAL或EXISTS收敛右表粒度,避免DISTINCT硬扛。

LEFT JOIN后GROUP BY的膨胀不是GROUP BY的问题
膨胀发生在GROUP BY之前,是JOIN阶段就完成的物理行复制。GROUP BY只是对已经错乱的中间结果做分组汇总,越汇总越失真。比如1个用户关联3条订单、2个地址,JOIN后变成6行;此时再GROUP BY user_id,COUNT(*)返回6,但你想统计的是“用户数”或“地址数”,结果全错了。
检查连接键是否真唯一,别信业务假设
很多人以为“user_id在users表里肯定是唯一的”,但没验证过。实际可能有脏数据、历史冗余、或视图/子查询引入重复。必须动手查:
SELECT user_id, COUNT(*) FROM users GROUP BY user_id HAVING COUNT(*) > 1SELECT order_id, COUNT(*) FROM order_items GROUP BY order_id HAVING COUNT(*) > 1- 如果用的是视图,把视图定义里的子查询单独执行一遍,同样跑
HAVING COUNT(*) > 1
只要任意一张右表的连接字段存在重复,膨胀就必然发生——和你写不写GROUP BY无关。
GROUP BY前必须先收敛右表粒度
不能指望GROUP BY“修复”JOIN带来的逻辑混乱。正确路径是:让每张右表在JOIN前就只输出一行/一个聚合值。常用方式有三类:
- 要汇总值(如总金额、登录次数):
(SELECT order_id, SUM(amount) AS total_amount FROM order_items GROUP BY order_id),再LEFT JOIN - 要单条明细(如最新支付时间):
LATERAL (SELECT paid_at FROM payments WHERE order_id = o.id ORDER BY paid_at DESC LIMIT 1)(PostgreSQL / MySQL 8.0+),或用ROW_NUMBER() OVER (PARTITION BY order_id ORDER BY paid_at DESC)+ 子查询过滤rn = 1 - 只判断是否存在:
EXISTS (SELECT 1 FROM order_items oi WHERE oi.order_id = o.id),比LEFT JOIN更轻、无膨胀、语义清晰
注意:ROW_NUMBER()的过滤条件必须写在ON里(如ON p.order_id = o.id AND p.rn = 1),写在WHERE会把没匹配的左表行也过滤掉,LEFT JOIN就失效了。
别用DISTINCT或COUNT(*)硬扛膨胀
DISTINCT是对整行去重,不是按业务主键去重。只要任意一列不同(比如时间戳、IP、状态码),就算不同行。它掩盖问题、拖慢查询(常触发隐式排序),还可能误删合法差异记录。
同理,COUNT(*)在膨胀后结果上运行,得到的是物理行数,不是业务实体数。真要统计用户数,得写COUNT(DISTINCT u.id);要统计每个用户的订单数,得写COUNT(DISTINCT o.order_id)——但前提是u.id和o.order_id确实能唯一标识业务对象。
最易被忽略的一点:一旦你把膨胀后的结果当作中间表再去JOIN第三张表,错误会指数级放大。控制粒度这件事,必须在第一次JOIN前就做完。

















