LEFT JOIN 行数膨胀是正常现象,因右表一对多导致左表行被复制;应通过预聚合、窗口函数或 EXISTS 控制右表粒度,而非依赖 DISTINCT 或 WHERE 过滤。

这不是 SQL 出错了,是 JOIN 正常执行的结果——只要右表对同一个连接键(比如 user_id)有多条记录,左表那行就会被复制多次。
LEFT JOIN 后行数暴增,是因为右表没“收敛”就直接参与连接
比如 users 表 100 行,t_log 表里某个 user_id 出现了 7 次,那这条用户数据在结果里就占 7 行。数据库完全按标准执行,不是 bug,但后续统计或导出时容易误算。
- 用
COUNT(*)查 JOIN 结果行数,再对比COUNT(*)查左表原始行数,能快速确认膨胀倍数 - 执行
SELECT u.id, COUNT(*) OVER (PARTITION BY u.id)看每条用户被展开几次,>1 就说明右表存在一对多 -
WHERE条件写在 JOIN 后却过滤右表字段(如WHERE l.status = 'active'),会把 LEFT JOIN 变成事实上的 INNER JOIN;应改到ON子句:LEFT JOIN t_log l ON u.id = l.user_id AND l.status = 'active'
为什么加了 DISTINCT 还有重复?
DISTINCT 是对整行字段组合去重,不是按 user_id 单列去重。只要任意一列不同(比如 log_time、ip_addr 或 order_no),就算“不重复”。它解决不了逻辑膨胀,只是事后擦除,还可能掩盖问题。
- 写
SELECT DISTINCT u.id, u.name FROM users u LEFT JOIN t_log l ON ...看似去重,但一旦加上l.log_time,重复立刻回来 -
DISTINCT在某些引擎中会触发隐式排序,大表查询明显变慢 - 后续再 JOIN 第三张表时,拿这个已膨胀的结果当主表,错误会级联放大
真正该做的:在 JOIN 前让右表只输出你需要的那一行
核心是控制右表的输出粒度,而不是在结果上“打补丁”。选哪种方式,取决于你要什么:
- 要汇总值(如登录次数、最新 IP):用子查询 +
GROUP BY预聚合SELECT u.id, u.name, p.cnt, p.max_ip FROM users u LEFT JOIN (SELECT user_id, COUNT(*) AS cnt, MAX(ip_addr) AS max_ip FROM t_log GROUP BY user_id) p ON p.user_id = u.id - 要完整单条明细(如最新一条日志):用
ROW_NUMBER()窗口函数,且rn = 1必须写在ON条件里ON p.user_id = u.id AND p.rn = 1—— 写在WHERE会丢掉没日志的用户 - 只判断“有没有关联记录”:改用
EXISTS,逻辑清晰、无重复、性能更好SELECT * FROM users u WHERE EXISTS (SELECT 1 FROM t_log l WHERE l.user_id = u.id)
最容易被忽略的一点:JOIN 后的结果集,已经不是原始左表的行集合了
哪怕你只 SELECT 左表字段,中间过程也早已完成笛卡尔式匹配。把它当“用户列表”继续做聚合、导出或二次 JOIN,错误就从这一步开始。每次写完 JOIN,花 10 秒跑个 COUNT(*) 对比,比后面花几小时排查更省事。

















