COUNT/SUM在JOIN后翻倍是因一对多关系导致行复制,聚合基于膨胀后的物理行计算;应先用子查询对从表按关联键预聚合(如SELECT user_id, SUM(amount) FROM orders GROUP BY user_id),再LEFT JOIN主表,并将过滤条件写入子查询ON中,避免膨胀。

为什么COUNT/SUM在JOIN后总是翻倍
不是函数写错了,是数据在JOIN阶段就被复制了。比如users表1行关联orders表3行,JOIN后就变成3行——后续所有COUNT(*)、SUM(amount)都基于这3行计算,结果自然放大3倍。常见现象包括:SUM(payments.amount)比实际高几倍、COUNT(*)远超主表行数、EXPLAIN里某步rows突增10倍以上。
用子查询预聚合代替直接JOIN后GROUP BY
这是最通用、兼容性最强的解法,适用于MySQL 5.7+、PostgreSQL、SQL Server等所有主流数据库。
- 把从表按关联键先聚一次,保证每行唯一:
SELECT user_id, SUM(amount) AS total_amount FROM orders GROUP BY user_id - 再和主表
LEFT JOIN,避免主表行被复制:SELECT u.id, COALESCE(t.total_amount, 0) FROM users u LEFT JOIN ( ... ) t ON u.id = t.user_id - 子查询必须带别名(MySQL报错
Error Code: 1248),且过滤条件(如WHERE status = 'paid')必须写在子查询内部,不能丢到外层WHERE
LEFT JOIN时过滤条件必须写进ON,不能放WHERE
放在WHERE会把LEFT JOIN退化成INNER JOIN,还可能放大中间膨胀。
- 错误写法:
LEFT JOIN orders o ON u.id = o.user_id WHERE o.status = 'shipped'→ 先全量JOIN(含NULL),再过滤,白跑空匹配 - 正确写法:
LEFT JOIN orders o ON u.id = o.user_id AND o.status = 'shipped'→ 只拉已发货订单,不生成无效行 - 如果维度表字段允许NULL(如
nickname),又想保留主表所有行,过滤必须进ON,否则WHERE o.nickname IS NOT NULL会丢掉无昵称用户
需要明细字段时,用窗口函数或LATERAL控制粒度
当既要“每个用户的最新订单时间”,又要“该用户所有订单总金额”,子查询没法一锅端——窗口函数能分层计算,互不干扰。
-
FIRST_VALUE(o.created_at) OVER (PARTITION BY o.user_id ORDER BY o.created_at DESC)算最新下单时间 -
SUM(oi.price) OVER (PARTITION BY oi.user_id)算总金额,两者独立运行,不因JOIN产生交叉膨胀 - MySQL 8.0+ / PostgreSQL支持
LATERAL子查询,可引用外层变量,避免提前物化整个从表:LEFT JOIN LATERAL (SELECT * FROM payments p WHERE p.order_id = o.id ORDER BY paid_at DESC LIMIT 1) p ON true - 注意:
OVER必须写在SELECT列表,不能出现在WHERE或ON里;窗口函数和普通聚合函数(如SUM()不带OVER)不能混用在同一级SELECT
关键点不在“怎么写更漂亮”,而在于聚合必须发生在膨胀之前。只要从表存在一对多关系,任何没预聚合的JOIN + GROUP BY都是在错误的数据集上算数——结果看起来像对了,其实是巧合。

















