LEFT JOIN后SUM/COUNT翻倍是执行顺序导致的必然现象,因JOIN阶段已产生行膨胀,后续聚合仅基于膨胀结果计算;根治方案是子查询预聚合右表再JOIN,并确保过滤条件置于子查询内、GROUP BY字段与JOIN键严格一致。

LEFT JOIN后聚合函数结果翻倍不是Bug,是SQL执行顺序决定的必然现象——膨胀发生在JOIN阶段,GROUP BY和SUM/COUNT只是对已膨胀的中间结果做计算,无法“修复”。
为什么SUM/COUNT会翻倍,先看执行顺序
SQL实际执行顺序是:FROM → JOIN → WHERE → GROUP BY → SELECT。一对多关系(比如1个user_id在订单表出现3次)会让左表1行变成3行,SUM(amount)就真加了3遍。这不是数据库算错,是它老老实实按物理行算的。
常见误判点:
- 只看
GROUP BY后的行数是否正常——其实膨胀早已发生,GROUP BY只是把3行合并成1行,但SUM值已经虚高 - 查
EXPLAIN时忽略rows_examined:如果右表仅1万行,但JOIN步骤显示扫描50万行,基本就是膨胀信号 - 用
COUNT(*)代替COUNT(DISTINCT main_table.id)来统计主表数量,结果自然偏大
LEFT JOIN过滤条件必须写在ON里,不能放WHERE
这是线上最高频的逻辑陷阱。放在WHERE里,等于先全量配对再砍掉不匹配行,中间结果早已爆炸。
错误写法:LEFT JOIN orders o ON u.id = o.user_id WHERE o.status = 'paid'
→ 实际等效INNER JOIN,且白跑一遍NULL匹配,users中没订单的行直接消失
正确写法:LEFT JOIN orders o ON u.id = o.user_id AND o.status = 'paid'
→ 右表只拉status = 'paid'的记录,左表所有用户都保留,从源头控量
特别注意:MySQL 5.7+、PostgreSQL支持良好,但某些旧版SQLite或ODBC驱动可能不认AND后的条件,上线前务必实测
子查询预聚合才是根治方案
核心思路:不让明细数据直接参与最终GROUP BY,先按关联键(如user_id、order_id)各自压缩成一行,再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 WHERE status = 'paid' -- 过滤必须放子查询内! GROUP BY user_id -- 不可省,否则变交叉连接 ) t ON u.id = t.user_id;
关键细节:
-
GROUP BY字段必须和外层ON条件里的右表字段完全一致,比如子查询按user_id分组,外层就得ON u.id = t.user_id - LEFT JOIN未匹配时,
t.total_amount为NULL,SUM(NULL)返回NULL而非0,必须用COALESCE(t.total_amount, 0) - 子查询里漏掉业务过滤(如
WHERE status != 'cancelled'),会导致聚合基数偏大
窗口函数SUM OVER适合特定场景
当你要聚合的是主表自身字段(比如订单金额),且主表粒度唯一,可用SUM() OVER (PARTITION BY order_id)替代普通SUM(),避免行级膨胀。
示例:
SELECT o.order_id, o.order_date,
SUM(o.amount) OVER (PARTITION BY o.order_id) AS total_order_amount
FROM orders o
LEFT JOIN order_items oi ON o.order_id = oi.order_id;但注意限制:
- 不能用于聚合关联表字段(如
COUNT(DISTINCT oi.product_id) OVER (...)仍会重复计数) -
PARTITION BY字段必须在主表中逻辑唯一,否则分区无效 - MySQL 5.7及以前不支持
OVER,必须改用子查询或相关子查询
真正容易被忽略的是:子查询的GROUP BY字段和外层ON条件没对齐,或者忘了处理NULL;这些细节不显眼,但一出错就全盘失准。

















