LEFT JOIN 产生重复行是语义正确结果,非bug;因左表每行匹配右表所有满足条件的行,一对多导致行膨胀;DISTINCT仅对SELECT字段组合去重,无法解决逻辑膨胀;应通过GROUP BY预聚合或ROW_NUMBER()取单行来控制右表输出。

LEFT JOIN 重复不是 bug,是语义正确执行的结果
当你看到 users 表 100 行,LEFT JOIN t_log 后变成 327 行,别急着改 SQL —— 这恰恰说明 JOIN 没出错。LEFT JOIN 的定义就是:左表每行,匹配右表所有满足 ON 条件的行。如果一个 user_id 在 t_log 里有 3 条登录记录,那对应用户信息就会被复制 3 次。
常见误判点:
- 用
COUNT(*)直接统计 JOIN 结果,当成“用户数”,结果虚高 - 后续再
JOIN第三张表时,拿膨胀后的结果当主表,错误级联放大 - 看到重复就加
DISTINCT,但没意识到它只对最终字段组合去重,不解决逻辑膨胀
为什么 DISTINCT 经常失效甚至误导人
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,重复立刻回来。这不是 DISTINCT 不好,而是它根本没在解决“一对多导致行膨胀”这个源头问题。
真正有效的解法:在 JOIN 前让右表只输出一行
核心思路只有一个:别让右表带着多条记录直接上场。必须先压缩它,再 JOIN。两种主流方式适用不同场景:
需要汇总值(如最新 IP、登录次数)→ 用 GROUP BY 子查询:
SELECT u.id, u.name, p.max_ip, p.cnt FROM users u LEFT JOIN ( SELECT user_id, MAX(ip_addr) AS max_ip, COUNT(*) AS cnt FROM t_log GROUP BY user_id ) p ON p.user_id = u.id
需要完整单条记录(如最新一条日志全部字段)→ 用 ROW_NUMBER() 窗口函数:
SELECT u.id, u.name, p.ip_addr, p.log_time
FROM users 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_log
) p ON p.user_id = u.id AND p.rn = 1
注意:AND p.rn = 1 必须写在 ON 子句里,不能放 WHERE,否则会把没日志的用户也过滤掉。
容易被忽略的关键细节
右表是否建了 UNIQUE(user_id) 或 PRIMARY KEY 约束,和 JOIN 是否重复**毫无关系**。约束只影响写入校验,不影响 SELECT 时的匹配逻辑。
如果你的右表关联字段天然不唯一(比如日志表、订单明细表),又没做预聚合或取号,那重复就是确定性行为,不是数据脏或 SQL 写错。
最常漏掉的一环:确认右表膨胀倍数。执行这两条语句对比:
SELECT COUNT(*) FROM users;
SELECT COUNT(*) FROM users u LEFT JOIN t_log l ON u.id = l.user_id;
比值大于 1,就说明存在一对多——这时该做的不是调 DISTINCT,而是回头检查业务需求:你到底要“每个用户的最新一条日志”,还是“每个用户的登录总次数”,答案决定了该用 ROW_NUMBER() 还是 GROUP BY。

















