写在开头
项目里的表用逻辑删除:删一条不是真删,而是把 delete_flag 置为 1。LEFT JOIN 也是平时天天写的语句。这两件事我都没当回事。
结果这个月,LEFT JOIN 连着让我栽了两个跟头:一次分页查询该出的数据没出来,total 还跟着变小;另一次自连接统计,数据反而多了,有的用户被算了两遍。
两次表现相反,根子是同一件事:右表的过滤条件该放哪,两次都没写对。
一、第一次,条件写在 WHERE 里,左表的数据跟着没了
第一次是个很普通的分页查询。左表是主表,右表是它的明细,用 LEFT JOIN。业务上要求主表记录必须全部显示,哪怕这一季度一条明细都没有,也得出现在列表里。明细要按年份、季度筛选,只看 2026 年第一季度的。
这个筛选条件我当时顺手写在了 WHERE 后面。
SELECT p.id, p.name, c.amount
FROM t_parent p
LEFT JOIN t_child c
ON p.id = c.parent_id
WHERE p.delete_flag = 0
AND c.year = 2026
AND c.quarter = 1
跑出来以后,列表少了几行,total 也跟着小了。条件筛的是右表,右表少了行我认,可左表的行凭什么也跟着没了?问题就出在“顺手”这两个字上。
二、第二次,右表出了问题
第二次是自连接,一张表连它自己。左表取今年的数据,右表取去年的数据,按 user_id 对起来,看哪条记录是今年新增的。
SELECT l.id, l.user_id
FROM t l
LEFT JOIN t r
ON l.user_id = r.user_id
AND r.year = 2025
WHERE l.year = 2026
AND l.delete_flag = 0
写的时候只记得滤掉左表删掉的行,右表那个 delete_flag 没出现。表里以前删过数据,留着不少 delete_flag = 1 的行。这些行在右表上也能匹配。统计出来,有的用户被算了两遍。
三、两次是同一个原因
两次的根子在这里:ON 和 WHERE 管的不是一回事。
ON 决定两张表怎么配对,WHERE 决定配对完之后哪些行不要。
放进 LEFT JOIN 里,这个区别会被放大。LEFT JOIN 的承诺是左表每一行都留下,这个承诺是 ON 在守的,WHERE 不认。执行时大致是这么走的:先拿左表每一行去右表找能配上的;配对时,右表先按 ON 后面的条件筛一遍,配上就填值,配不上就整行填成 NULL;最后一步,拿这个结果去做 WHERE 过滤。
关键就在最后一步。配不上的行,右表字段全是 NULL,拿 NULL 去跟任何值比都是假,于是被整行清掉。 LEFT JOIN 就这样退化成了 INNER JOIN。不报错,不警告,只是行数悄悄变少。
三条主表记录、一张子表,跑了一遍对照。两张表长这样:
| 主表记录 |
明细(都是 2026 年的) |
| 张三(没删) |
第一季度 100,第二季度 200 |
| 李四(没删) |
只有第二季度 300,第一季度一条明细都没有 |
| 王五(已经删了) |
第一季度 400 |
查的是 2026 年第一季度,要求主表全部显示。三种写法分别返回什么,看下面:
| 条件写在哪 |
查出来的结果 |
| 右表的 year、quarter 写 ON |
张三 100 · 李四(明细列是空的) |
| 右表的 year、quarter 写 WHERE |
张三 100 —— 李四整个没了 |
| 左表条件也挪进 ON |
张三 100 · 李四(空)· 王五(空) |
第一行是对的,第二行是当时写的,第三行是后来试的:把左表的条件也塞进 ON,结果连删掉的主表都拦不住。所以,左表的条件该写 WHERE。
第二次那个重复,机制是同一个。左表一行,右表能配上好几行,结果就展开成好几行。去年的记录留了不止一条,右表又不过滤 delete_flag,它俩全配上,这个用户在统计里就出现了两遍。
这组也跑了对照。表里今年有三条没删的记录 101、102、103,其中 103 是今年才冒出来的,去年没有它。去年的记录里,102 留了两条,一条没删、一条删过。
| 右表怎么处理 |
查出来的记录 |
判为今年新增的 |
| 右表条件写 ON(正解) |
101 · 102 · 103,各一条 |
103 |
| 右表条件写 WHERE |
只剩 101 · 102 |
一条都没有 |
| 不去管它(当时的写法) |
101 · 102 · 102 · 103 |
103 |
中间那一行要特别注意。发现右表没限制,第一反应多半是把它加进 WHERE 里。加进去,查询就变成内连接,右表配不上的行全被清掉。可是今年新增的那批用户,在右表上本来就配不到。条件往 WHERE 一写,要找的东西自己就被筛掉了,查出来的新增是空的,而且它还是不报错。
所以正确的写法是分开的:左表的条件写 WHERE,右表的条件写 ON。
| 你想干什么 |
条件写在哪 |
| 过滤右表,同时保住左表全部行 |
ON |
| 过滤左表自己 |
WHERE |
| 只想要配得上的行 |
直接写 INNER JOIN,别用 LEFT JOIN 加 WHERE 假装 |
| 想要配不上的行 |
条件写 ON,再在 WHERE 里加右表主键 IS NULL |
四、MyBatis-Plus 在这里帮不了你
看到这儿有人会想,我用的是 MyBatis-Plus,逻辑删除不是有 @TableLogic 吗?加个注解,框架不就帮你把条件加上了。
搭了个最小工程实测:Spring Boot 2.7.18 加 MyBatis-Plus 3.5.3.2,配 H2 内存库。BaseMapper 自带的方法,比如 userMapper.selectList(null),MP 实际执行的 SQL 是这样:
SELECT id,name,delete_flag
FROM t_user
WHERE delete_flag=0
那个 WHERE delete_flag=0 是它自己拼上去的,一个字不用写。
换成手写 XML 的 join,它就一个字都不加了。原样执行,返回 5 行,里面既有已经删掉的用户,也有已经删掉的订单,不报错,也不提醒。同一个 mapper 接口里,两种行为都在:selectList 帮你过滤好了,手写的那句全靠自己。而手写 join 恰好是 LEFT JOIN 最容易写错的地方,这两件事凑在一块了。
写在结尾
两次踩坑,最后收在三句话上。
一,左连接里,左表的条件写 WHERE,右表的条件写 ON。
二,拿不准的时候问自己一句:右表没配上的那行,我想不想让它从结果里消失?不想,就写 ON。
三,框架帮你加的条件,只加在它自己生成的 SQL 上。手写的那句,得自己负责。