NOT IN 在 MySQL 中易变慢出错,因无法有效利用索引且子查询含 NULL 时结果恒为 FALSE;推荐用 LEFT JOIN ... IS NULL 或 NOT EXISTS 替代,并确保关联字段类型一致、有索引。

为什么 NOT IN 在 MySQL 里容易变慢甚至出错
直接说结论:NOT IN 子查询在 MySQL 中默认无法有效利用索引,且只要子查询结果中包含任意一个 NULL,整个条件就恒为 FALSE(连 NULL 都不返回),这是最常被忽略的语义陷阱。
典型现象是:明明主表有 10 万行,子查询只返回几百条 ID,但 SELECT * FROM a WHERE id NOT IN (SELECT id FROM b) 执行十几秒甚至超时;或者查不到任何数据,但手动检查发现确实存在“不在 b 表里的 a 记录”。
根本原因有两个:
- MySQL 对
NOT IN子查询通常走DEPENDENT SUBQUERY,对主表每行都执行一次子查询,无法批量下推条件 -
expr NOT IN (val1, val2, NULL)等价于expr != val1 AND expr != val2 AND expr != NULL,而任何值与NULL比较结果都是UNKNOWN,整条逻辑表达式即为FALSE
LEFT JOIN ... IS NULL 是更可靠、更易优化的替代写法
用左连接排除法,本质是把“不在集合中”转化为“左表有、右表无匹配”,MySQL 能天然走索引驱动表 + 被驱动表的 ref 或 range 访问,执行计划清晰可控。
实操建议如下:
- 确保右表(被排除的表)用于关联的字段有索引,例如
b.id上建了INDEX或为主键 - 写法必须带
IS NULL判断右表关联字段,不能只写LEFT JOIN不加WHERE过滤 - 避免在
ON条件里写复杂表达式(如ON a.id = b.id + 1),否则可能使索引失效
示例对比:
SELECT a.* FROM a LEFT JOIN b ON a.id = b.id WHERE b.id IS NULL;
这个查询在 a.id 和 b.id 均有索引时,通常能跑出 type: ref 或 type: eq_ref,执行时间从秒级降到毫秒级。
当 b 表数据量极大时,LEFT JOIN 也可能卡住?试试 NOT EXISTS
如果 b 表记录数远超 a 表(比如千万级),且 b.id 上没有合适索引,LEFT JOIN 可能因驱动表选择不当导致全表扫描 b。这时 NOT EXISTS 更稳——它天然以主表为外层循环,且支持半连接优化(semijoin / firstmatch)。
注意点:
-
NOT EXISTS子查询里必须关联外层字段,否则变成恒真/恒假,例如WHERE NOT EXISTS (SELECT 1 FROM b WHERE b.id = a.id) - 子查询中不要写
SELECT *,用SELECT 1即可,减少字段解析开销 - 5.7+ 默认开启
semijoin,但若optimizer_switch里关了,性能会回落到类似NOT IN
执行计划中看到 FirstMatch(a) 或 LooseScan(b) 就说明优化生效了。
别忘了检查 NULL 和数据类型隐式转换
即使改用 LEFT JOIN 或 NOT EXISTS,如果关联字段类型不一致(比如 a.id 是 BIGINT,b.id 是 VARCHAR),MySQL 仍会放弃索引,转为全表扫描或临时表。
排查方法很简单:
- 用
EXPLAIN FORMAT=TRADITIONAL看type是否为ALL或index,key列是否为NULL - 用
SHOW CREATE TABLE对比两边字段定义,尤其注意CHARSET、COLLATION、符号位(SIGNEDvsUNSIGNED) - 显式加
CAST强制类型一致,但这是补救手段,上线前应统一字段类型
真正容易被忽略的是:业务表里 id 字段如果是 INT UNSIGNED,但关联表用了普通 INT,MySQL 会悄悄做有符号转无符号转换,索引就废了——这种细节不看 EXPLAIN 几乎发现不了。



















