LEFT JOIN后行数翻倍是右表连接键一对多导致的必然结果;应先用SELECT join_column, COUNT() FROM right_table GROUP BY join_column HAVING COUNT() > 1确认重复,再对比COUNT(*)与COUNT(DISTINCT left_id),并将过滤条件置于ON子句或通过子查询预聚合解决。

LEFT JOIN 后行数翻倍不是 SQL 出错了,是它严格按连接键做行级匹配的必然结果——只要右表对同一个 user_id、order_id 等键有 N 条记录,左表那行就会被复制 N 次。
怎么快速确认是不是一对多导致的翻倍
别急着改 SQL,先验证根源:
- 查右表连接字段是否真有重复:
SELECT user_id, COUNT(*) FROM t_log GROUP BY user_id HAVING COUNT(*) > 1 - 对比两个数:
COUNT(*)(JOIN 后总行数) vsCOUNT(DISTINCT u.id)(左表唯一主键去重数),前者远大于后者就基本坐实了 - 执行计划里看
rows_examined是否突然暴涨——比如左表 10 万行,这一步变成 70 万,大概率就是膨胀源 - 右表如果是视图或子查询?把它单独跑一遍,看输出里
user_id有没有重复
为什么把过滤条件写在 WHERE 里会让问题更隐蔽
WHERE l.status = 'active' 看似合理,但一写进去,LEFT JOIN 就悄悄退化成 INNER JOIN:没匹配上或状态不符的左表行全被踢掉,你可能以为“数据少了”,其实只是膨胀被掩盖了。
正确做法是把条件塞进 ON 子句:
LEFT JOIN t_log l ON u.id = l.user_id AND l.status = 'active'
这样左表完整保留,右表只拉符合条件的行进来——从源头控量。注意:AND l.status = 'active' 必须在 ON 里才生效;写在 WHERE 里,优化器会先完成全量 JOIN 再筛,膨胀早已发生。
用子查询预聚合是最稳妥的解法
核心思路:不让右表以“多行”姿态参与 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 != 'cancelled' -- 过滤必须放子查询内! GROUP BY user_id -- 必须和 ON 条件里的字段完全一致 ) t ON u.id = t.user_id
容易漏的三个细节:
-
GROUP BY字段必须和外层ON中右表字段严格一致,比如子查询按user_id分组,就不能写成ON u.id = t.id - LEFT JOIN 未匹配时,
t.total_amount是NULL,SUM(NULL)返回NULL而非0,得用COALESCE(t.total_amount, 0) - 业务过滤(如只算有效订单)必须写在子查询里,漏掉就会导致聚合基数偏大
ROW_NUMBER() 取单条时,rn = 1 必须写在 ON 里
当你需要最新一条日志、最近一次订单这类完整明细,ROW_NUMBER() 比 GROUP BY 更精准。
关键陷阱:rn = 1 绝不能写在 WHERE 子句里:
LEFT JOIN ( SELECT *, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn FROM t_log ) l ON u.id = l.user_id AND l.rn = 1
如果写成 WHERE l.rn = 1,没日志的用户直接消失——LEFT JOIN 彻底失效。另外,ORDER BY 字段最好有索引,否则窗口函数开销巨大;MySQL 8.0.22+ 支持 NULLS LAST,若排序字段允许 NULL,需显式声明。
真正难处理的不是语法,而是分不清“业务上该有多少行”:如果一个用户真有 5 个角色、3 个收货地址,那翻倍反而是事实;强行预聚合等于丢信息。先问清楚要的是“汇总值”还是“单条代表记录”,再选方法。

















