在SQL开发中,“找出A表有而B表没有的记录”是个经典需求。先说结论:最靠谱的写法就是 LEFT JOIN + WHERE B.id IS NULL。原理不复杂——以A表为左表驱动,逐行尝试与B表匹配;如果某行在B中找不到对应记录,B表的所有字段就会全变成NULL,此时用 IS NULL 一抓一个准。但要注意几个关键细节,否则很容易翻车。

用 LEFT JOIN + IS NULL 筛出A表独有记录
直接上结论:INNER JOIN 和 RIGHT JOIN 都派不上用场,必须用 LEFT JOIN 配合 WHERE B.id IS NULL。这套组合拳是业界公认最可靠、可读性最强、数据库优化器也最容易识别的写法。
为什么这么说?因为它的执行逻辑足够透明:以左表(A表)为驱动,每行都尝试匹配B表;匹配不上时,B表字段全空,再通过 IS NULL 精准过滤。相比其他方案,这个写法的坑最少:
- 别碰
NOT IN (SELECT ...)—— 只要B表的关联列里藏着任何一个NULL,整个结果就会被吞掉,这是SQL三值逻辑的经典陷阱。 NOT EXISTS语义上没问题,但某些旧版MySQL或低版本执行计划下,性能可能不如LEFT JOIN来得干脆。- 务必确保
ON条件里的字段类型和空值处理一致。比如A.id是INT,B.a_id是INT UNSIGNED,隐式转换失败时你连错在哪都查不出来。
ON 条件写错会导致结果完全失效
ON 不是过滤条件,它定义的是连接逻辑。很多人习惯把本应放在 WHERE 的过滤条件提前塞进 ON,结果查出错误数据还不自知。
举个例子:想查 “A 表中 status=1 且不在 B 表中的记录”,错误示范是这样的:
SELECT A.* FROM A LEFT JOIN B ON A.id = B.a_id AND A.status = 1 WHERE B.a_id IS NULL
这么写会把A表中 status ≠ 1 的行也拉进来——因为 ON 里的 A.status = 1 只影响连接匹配,并不筛A表本身。正确的写法应该是:
SELECT A.* FROM A LEFT JOIN B ON A.id = B.a_id WHERE A.status = 1 AND B.a_id IS NULL
记住两条铁律:
ON只管“怎么连”,WHERE才管“要哪些”。- 如果B表有复合主键(比如
(a_id, type)),ON必须完整匹配所有字段,漏一个就可能触发笛卡尔积。 - MySQL 8.0+ 虽然支持
LATERAL,但日常场景完全没必要,反而增加理解成本。
NULL 值和索引会让 IS NULL 判断变慢?
很多新手担心 B.a_id IS NULL 这个判断本身会慢。其实关键不在 IS NULL,而在于B表的关联字段有没有索引。没有索引时,LEFT JOIN 会让A表每行都去扫一遍B表全表,这才是性能噩梦。
- 给B表的外键字段(比如
a_id)建索引是必要条件,否则查询会指数级变慢。 IS NULL在有索引时能走索引(B-tree 索引天然包含 NULL 位置),但前提是该字段允许 NULL。如果字段定义为NOT NULL,那IS NULL永远不成立,查询直接返回空,等于白忙活。- PostgreSQL 对
IS NULL有专门的索引支持(比如CREATE INDEX idx ON b (a_id) WHERE a_id IS NULL),但绝大多数场景下,普通索引就足够了。
用 EXCEPT / MINUS 会更简单?
部分数据库(PostgreSQL、SQL Server)支持 EXCEPT,Oracle 用 MINUS,写起来确实短——一行搞定:
SELECT id FROM A EXCEPT SELECT a_id FROM B
但别被它的简洁迷惑,里面藏着不少硬伤:
- 字段数量、类型、顺序必须严格一致,多一个字段或者类型隐式转换失败就直接报错。
UNION类操作默认会去重,如果A表里有重复的id,结果会少行——而LEFT JOIN能忠实保留原始重复记录。- 无法在结果里同时取A表其他字段(比如
A.name、A.created_at),除非整行SELECT *再EXCEPT,但那时要求所有字段都可比较,实际场景很难满足。
所以除非你只查单字段ID且确定无重复,否则别为了省几个字去换写法。老老实实用 LEFT JOIN + IS NULL,稳。
