LEFT JOIN后SUM或COUNT翻倍是因为一对多关联导致主表一行被复制成N行参与计算,是JOIN的必然结果而非Bug;需通过子查询预聚合(如SELECT user_id, SUM(amount) FROM orders GROUP BY user_id)再JOIN主表,并确保GROUP BY字段与外层JOIN键一致,同时将过滤条件置于子查询内而非外层WHERE。

为什么LEFT JOIN后SUM或COUNT会翻倍
因为一对多关联时,主表一行被复制成N行参与后续计算——这不是数据库出错,是JOIN的必然结果。比如users LEFT JOIN orders(1用户→N订单),再直接SUM(orders.amount),金额就被累加了N次。
常见现象:COUNT(*)比预期高几倍、SUM()明显偏大、分组后行数异常增多。执行计划里若某步rows突增10倍以上,基本就是膨胀源。
- 先确认是否真是一对多:用
SELECT user_id, COUNT(*) FROM orders GROUP BY user_id看分布 - 别只看最终
GROUP BY后的行数——膨胀发生在它之前 -
DISTINCT只能救急,且只对COUNT(DISTINCT id)有效;SUM(DISTINCT amount)语义错误,不能用
子查询预聚合的标准写法
核心是让“多”侧表先按关联键压缩成单行,再和主表JOIN。子查询必须返回唯一键+聚合结果,不带歧义字段。
例如统计每个用户的订单总金额:
SELECT u.id, u.name, COALESCE(t.total_amount, 0) AS total_amount FROM users u LEFT JOIN ( SELECT user_id, SUM(amount) AS total_amount FROM orders GROUP BY user_id ) t ON u.id = t.user_id;
-
GROUP BY user_id不可省,否则子查询变成交叉连接 - 子查询必须有别名(如
t),否则MySQL报Error Code: 1248 -
COALESCE(t.total_amount, 0)把无订单用户的NULL转为0,避免前端异常 - 若需加条件(如只算已支付订单),必须写在子查询内部:
WHERE status = 'paid',不能丢到外层WHERE
ON里放条件 vs WHERE里放条件的区别
这是物理执行顺序问题,不是语义区别。放错位置会让膨胀提前发生,子查询也救不了。
错误写法(先全量JOIN,再过滤):
LEFT JOIN customers c ON o.customer_id = c.id WHERE c.status = 'active'
正确写法(从源头控右表基数):
LEFT JOIN customers c ON o.customer_id = c.id AND c.status = 'active'
- 前者先JOIN出所有客户行,再踢掉非active和NULL,膨胀已发生
- 后者右表只拉符合条件的行进来,JOIN前就控量
- 如果右表本身有重复(如历史客户含多条同id记录),光靠
ON不够,必须先子查询去重或取最新一条
什么时候子查询预聚合也不够用
它只解决“只要聚合值”的需求。一旦需要原明细中的非分组字段(比如最新订单的status或created_at),子查询就力不从心。
此时得换方案:
- PostgreSQL可用
LATERAL,MySQL 8.0+可用窗口函数+ROW_NUMBER()标记后过滤 - 需要“每个订单的最新支付时间 + 所有明细总金额”,就不能三表直连
GROUP BY,得用窗口函数分层计算 - 子查询里漏了个
AND deleted = 0,结果还是错的——真正难的不是写法,是贴着业务逻辑抠清楚:哪张表该聚合、按什么字段、要不要加状态或时间范围

















