LEFT JOIN重复是语义使然,并非SQL错误;需通过右表预聚合(GROUP BY)、窗口函数(ROW_NUMBER)或加唯一约束来控制行数。

LEFT JOIN 出现重复数据不是 SQL 写错了,而是它本该如此——只要右表对同一个关联键有多条记录,左表那行就会被复制多次。想靠 DISTINCT 或外层 GROUP BY “擦掉”重复,往往掩盖问题、拖慢查询,还可能选错值。
LEFT JOIN 为什么会撑开行数
这是关系代数的自然行为:左表每行,匹配右表所有符合条件的行。比如 t_user 有 100 行,t_login_log 里 user_id = 5 的记录有 7 条,那么用户 ID 为 5 的那行在结果里就出现 7 次。
- 常见错误现象:
SELECT COUNT(*) FROM t_user u LEFT JOIN t_login_log l ON u.id = l.user_id返回 320,但SELECT COUNT(*) FROM t_user只有 100 —— 说明平均每人有 3.2 条日志 -
DISTINCT无效?因为右表带了log_time、ip_addr等不同值,整行不重复,去不了 - 加
WHERE l.ip_addr IS NOT NULL仍重复?这只是筛掉 NULL 行,没动匹配逻辑本身 - 右表缺少
UNIQUE(user_id)或PRIMARY KEY约束,重复就必然发生;建索引不能解决这个逻辑问题
用子查询 + 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; -
PARTITION BY user_id分组,ORDER BY log_time DESC排序,rn = 1确保只取最新一条 - 如果要取“最早一条”,把
DESC换成ASC即可 - 若右表字段含
NULL,ORDER BY中需显式处理,例如ORDER BY log_time DESC NULLS LAST(PostgreSQL)或用COALESCE(log_time, '1970-01-01')(MySQL)
为什么别在 JOIN 后硬加 DISTINCT
DISTINCT 是按整行比对的“照相机”,不是“去重开关”。它不理解业务逻辑,只认字面差异。
- 如果
SELECT中包含右表字段(如t_log.ip_addr),DISTINCT会随机保留其中一行的值,不可控 -
DISTINCT在某些数据库中会隐式触发排序,大表查询性能明显下降 - 它掩盖了真正的语义问题:比如本该一对一却出现多对一,后续查数据一致性缺陷更难定位
- 真正安全的写法是让右表在 JOIN 前就“变瘦”——要么聚合,要么窗口筛选,而不是事后擦除
最容易被忽略的一点:子查询里的 WHERE 条件和 ON 条件位置不同,语义完全不同。把过滤条件错放到外层 WHERE,可能把 LEFT JOIN 变成 INNER JOIN;而漏掉子查询中的关键过滤,又会让预聚合失去意义。写完务必用小数据集 SELECT * 验证中间结果是否真是一行一主键。










