相比NOT IN,NOT EXISTS往往更稳定可靠。由于NULL的存在,NOT IN可能始终返回UNKNOWN,导致整个查询结果为空;而NOT EXISTS仅判断是否存在匹配行,不受NULL值影响,只需正确编写相关子查询即可。

NOT EXISTS比NOT IN更安全,因为NULL会导致NOT IN永远返回空结果
假如你那张表的id列里混入了NULL,那么问题就来了——WHERE id NOT IN (SELECT id FROM t)这个条件会直接变成恒为UNKNOWN,所有记录全部被过滤,最终结果永远是空集。这并非bug,也不是数据库故意为难你,而是SQL三值逻辑的必然结果。实际操作中,第一件事永远是执行SELECT COUNT(*), COUNT(id) FROM t。如果两个数字不相等,不用怀疑,NULL已经悄悄混进来了。
使用NOT EXISTS就没有这种烦恼,它只关注“是否存在匹配行”,完全不进行值比较。但代价是你必须正确写成相关子查询:WHERE NOT EXISTS (SELECT 1 FROM t t2 WHERE t2.id = t1.id + 1)。如果漏掉t2.id = t1.id + 1这个关联条件,就会变成全表扫描加恒真判断——结果与你期望的相差甚远。
LEFT JOIN + IS NULL适用于查找“主表有、右表无”的明确缺失
比如你要查orders表中哪些user_id在users表中不存在,最直观的写法就是LEFT JOIN users u ON o.user_id = u.id WHERE u.id IS NULL。这里有两个坑需要特别留意:
IS NULL绝不能写成= NULL,后者在SQL世界中永远不成立,你等多久也等不到结果。- 连接字段的类型必须对齐。例如
orders.user_id是VARCHAR而users.id是INT,隐式转换不仅会导致索引失效,严重时连匹配结果都会出错。
先生成全量序列再LEFT JOIN,比反向推导更可控
如果你明确知道ID的范围(比如1到1000),最稳妥的做法是“先生成一个完整的数字序列,然后左连原表,最后找NULL”,而不是依靠id+1自连接或NOT EXISTS去猜测。为什么?三个原因:
- 递归CTE(
WITH RECURSIVE seq(n) AS (...))可以一次性生成上万级别的数字,毫秒级完成,效率高下立判。 - 避免了
NOT EXISTS在稀疏数据(比如表里只孤立地存了1、100、200)时扫描大量无效id的灾难。 - 生成的上限别头脑发热设成1000000,否则CTE本身的构建就会成为瓶颈。更实用的做法是设成
(SELECT MAX(id) FROM t)。
MySQL低版本没有递归CTE?用变量或自连接模拟连续数
MySQL 5.7及更早版本不支持WITH RECURSIVE,硬写UNION ALL十几层太脆弱,稍一扩展就会崩溃。这里有几种可行的替代方案:
- 用用户变量生成序列:
SELECT @row := @row + 1 AS n FROM (SELECT 1 UNION ALL SELECT 2) t, (SELECT @row := 0) r LIMIT 1000。但需要留意——检查sql_mode中是否有ONLY_FULL_GROUP_BY干扰。 - 用
information_schema.tables这类系统表做笛卡尔积来凑数(慎用,不同MySQL版本的行为差异可能让你抓狂)。 - 真正稳定的做法:建一张
nums辅助表,预存1到10000的整数,CREATE TABLE nums (n INT PRIMARY KEY),之后反复LEFT JOIN即可,一劳永逸。
实际执行时,最容易被忽略的问题是索引与类型对齐——哪怕逻辑完全正确,如果id字段没有索引,或者JOIN两边类型不一致,查询可能从毫秒级别直接变成分钟级别的灾难。
