LEFT JOIN重复是正常行为,非SQL错误;右表每键多行会导致左表对应行重复;应通过子查询GROUP BY预聚合或ROW_NUMBER()取指定行来避免膨胀。

LEFT JOIN重复不是SQL写错了,是它本该如此
只要右表对同一个关联键(比如 user_id)有多条记录,左表那行就会被复制多次——这是关系代数的自然行为,不是bug。比如 t_user 有100行,t_login_log 中 user_id = 5 的记录有7条,那么用户ID为5的那行在结果里就出现7次。
常见误判点:
- 用
COUNT(*)直接统计 JOIN 结果当“用户数”,结果虚高 - 后续再 JOIN 第三张表时,拿膨胀后的结果当主表,错误级联放大
- 看到重复就加
DISTINCT,但没意识到它只对最终字段组合去重,不解决逻辑膨胀
用子查询 + GROUP BY 预聚合右表
核心思路:不让原始右表直接上场,而是先用 GROUP BY 把它压成“一行一主键”,再 JOIN。适合你只需要右表某个汇总值(最新 IP、登录次数、任意一条)的场景。
取每个用户的最新登录 IP:
SELECT u.id, u.name, p.latest_ip FROM t_user u LEFT JOIN ( SELECT user_id, MAX(ip_addr) AS latest_ip FROM t_login_log GROUP BY user_id ) p ON p.user_id = u.id;
-
GROUP BY user_id确保子查询最多返回 1 行 / 用户,JOIN 后左表不再膨胀 - 聚合函数选
MAX()、MIN()、COUNT()还是ANY_VALUE(),取决于你要哪条数据;MySQL 5.7+ 严格模式下,非分组字段必须套聚合函数 - 子查询里加
WHERE status = 'success'能提前过滤,比在外层WHERE更高效
用子查询 + ROW_NUMBER() 取指定完整行
当你需要右表的全部字段(不止一个值),且明确要某一条(比如最新、最早、最高分),ROW_NUMBER() 是最可控的方式。注意:MySQL 8.0+、PostgreSQL、SQL Server 支持;MySQL 5.7 不支持。
取每个用户的最后一条登录记录:
SELECT u.id, u.name, p.ip_addr, p.log_time
FROM t_user u
LEFT JOIN (
SELECT user_id, ip_addr, log_time,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY log_time DESC) AS rn
FROM t_login_log
) p ON p.user_id = u.id AND p.rn = 1;
-
AND p.rn = 1必须写在ON子句里,不能放WHERE,否则会把没日志的用户也过滤掉 -
PARTITION BY user_id是分组依据,ORDER BY log_time DESC决定哪条排第一 - 如果要取最早一条,把
DESC换成ASC
DISTINCT 和 EXISTS 的适用边界
DISTINCT 只对 SELECT 字段组合去重。只要任意一列不同,整行就不算重复。比如查 u.id, u.name, l.ip_addr, l.log_time,哪怕同一个用户有5条日志,l.ip_addr 或 l.log_time 都不同,DISTINCT 就不会合并任何一行。
更危险的是这种写法:
SELECT DISTINCT u.id, u.name FROM users u LEFT JOIN t_log l ON u.id = l.user_id
它看似“去重了”,但实际丢失了所有日志字段;而一旦你加上 l.ip_addr,重复立刻回来。
如果你只关心“是否存在匹配”,而不是取右表字段,EXISTS 天然不产生重复,且性能更好:
SELECT * FROM users u WHERE EXISTS ( SELECT 1 FROM t_log l WHERE l.user_id = u.id AND l.log_time >= '2024-01-01' );
真正难处理的,是业务上要求“每个用户只展示一条最新日志,但字段必须全”,这时必须用 ROW_NUMBER(),且要注意数据库版本和 ON 条件写法——漏掉 AND p.rn = 1 或错放到 WHERE,结果就全乱了。











