写SQL这几年,IN、NOT IN和NULL这三样东西凑在一起,几乎每换一个项目组都会被问一轮:为什么我的NOT IN子查询明明有数据,外层查询却一条都查不出来?我第一次遇到这个问题的时候,还把锅甩给了"数据库有bug"。后来把SQL拆开一点点看,才发现问题出在NULL身上。这玩意儿不像报错那样显眼,但它会让你查询结果莫名其妙地变少,甚至直接变成空集,而你翻遍数据都找不出原因。
这篇文章就把IN、NOT IN和NULL这三者的关系彻底讲清楚。看完之后你能明白三值逻辑到底是怎么回事、为什么NOT IN遇上NULL会翻车、以及实际开发里应该怎么改写SQL才能避开这个坑。后端开发、数据分析师、DBA,还有准备面试的同学,这篇文章都值得花十分钟读完。
1. 先说结论:NULL根本不是"没有值",而是"不知道的值"
很多人在理解NULL的时候,下意识会把它当成"空"或者"0"。比如一个员工的手机号字段是NULL,你会觉得"这个人没填手机号"。但在SQL的逻辑体系里,NULL的含义要比"空"更复杂——它代表的是"未知",是"当前不知道这个值是多少"。
这个区别非常关键。你把NULL当成0,那0 = 0是成立的,NULL = NULL也应该是成立的。但实际上在SQL里,NULL = NULL的结果不是真也不是假,而是"未知"。
1.1 三值逻辑:SQL里的布尔值有三个
常规编程语言里,条件只有真和假两种,但SQL是一个三值逻辑系统,任何条件判断的结果都可能是三种之一:
TRUE(真)FALSE(假)UNKNOWN(未知)
NULL参与任何比较运算,结果大概率是UNKNOWN。而WHERE子句有一个硬性规定:只返回条件结果为TRUE的行。FALSE和UNKNOWN的行都会被过滤掉。
这就是为什么你经常遇到"某个字段是NULL,结果它在查询里消失了"的情况。不是数据丢了,是它参与比较后产出了UNKNOWN,被WHERE过滤了。
三值逻辑的运算规则可以浓缩成下面这张表:
| 运算 | 结果 |
|---|---|
NULL = NULL |
UNKNOWN |
NULL = 1 |
UNKNOWN |
NULL <> 1 |
UNKNOWN |
TRUE AND UNKNOWN |
UNKNOWN |
FALSE AND UNKNOWN |
FALSE |
TRUE OR UNKNOWN |
TRUE |
FALSE OR UNKNOWN |
UNKNOWN |
这张表建议收藏。接下来要讲的IN和NOT IN的所有坑,逻辑根源都在这张表里。
1.2 NULL的传导性:一旦沾上NULL,结果就变成UNKNOWN
SQL里还有一个让人头疼的特性:NULL的传导性。只要一条表达式里任何一个操作数是NULL,整个表达式的结果往往就是UNKNOWN,不管另一边是什么。
举个例子。1 + NULL的结果是NULL,'abc' || NULL的结果在多数数据库里是NULL,NULL > 100的结果是UNKNOWN。这就像你往一碗汤里滴了一滴毒药,整碗汤都不能喝了。
这个传导性的威力在组合条件里特别明显。你写WHERE a = 1 OR b = 2的时候,如果a是NULL而且b不等于2,那整个条件会变成UNKNOWN OR FALSE,结果是UNKNOWN,这一行照样被丢掉。很多人在排查多条件查询丢数据的时候,往往不会往这个方向想,但真相经常就是某个字段里有几条NULL数据在捣鬼。
理解了NULL是"未知"、比较会产出UNKNOWN、WHERE只留TRUE这三条规则,接下来IN和NOT IN的坑就都能推导出来了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. IN遇上NULL:看起来正常,其实偷偷丢数据
IN是大家最常用的操作符之一。它本质上是一串OR的缩写,比如id IN (1, 2, 3),就等价于id = 1 OR id = 2 OR id = 3。IN的普通用法没什么坑,但一旦列表里混进了NULL,行为就微妙起来。
2.1 IN在普通情况下的行为,先建立直觉
先看一个正常的例子。有一张员工表employee:
| emp_id | emp_name | dept_id |
|---|---|---|
| 1 | 张三 | 10 |
| 2 | 李四 | 20 |
| 3 | 王五 | NULL |
执行下面这条SQL:
sql复制SELECT emp_id, emp_name
FROM employee
WHERE dept_id IN (10, 20);
结果自然返回张三和李四,王五因为dept_id是NULL,不满足NULL = 10也不满足NULL = 20,两个比较都是UNKNOWN,于是被过滤掉。这是很多人能预料到的结果。
但注意,这个查询里王五被过滤的原因,并不是"他的部门不是10和20",而是"他的部门值未知,无法判断是不是10和20"。这个语义上的区别,在NOT IN里会被放大成灾难。
2.2 IN列表里有NULL,会发生什么
再看一个稍微隐蔽的场景。查询dept_id IN (10, NULL):
sql复制SELECT emp_id, emp_name
FROM employee
WHERE dept_id IN (10, NULL);
这条SQL展开之后是dept_id = 10 OR dept_id = NULL。
- 张三的
dept_id是10,TRUE OR UNKNOWN,结果是TRUE,返回。 - 李四的
dept_id是20,FALSE OR UNKNOWN,结果是UNKNOWN,不返回。 - 王五的
dept_id是NULL,UNKNOWN OR UNKNOWN,结果是UNKNOWN,不返回。
所以结果集和WHERE dept_id IN (10)一模一样,只有张三。
这里就有个常见误解:有人觉得"我明明在IN列表里写了NULL,那王五这个NULL应该被查出来啊"。但SQL不会这么做。王五的dept_id是NULL,它跟列表里的那个NULL比较时,结果是UNKNOWN,不是TRUE。NULL不等于NULL,这在SQL里是铁律。
2.3 实战案例:给员工查部门,漏掉了没部门的人
实际业务里,用IN和子查询关联是再常见不过的写法。比如你想查"所有有部门记录的员工",可能会这么写:
sql复制SELECT emp_id, emp_name
FROM employee
WHERE dept_id IN (SELECT dept_id FROM department);
这个写法本身没问题。但如果department表里存在一条dept_id为NULL的记录,那子查询返回的列表里就包含了NULL。于是员工表里那些dept_id为NULL的员工,依然不会被查出来——因为他们拿NULL去匹配子查询里的NULL,得到的还是UNKNOWN。
换句话说,IN加NULL的典型症状是:查询结果比预期少,但少得不太明显。如果你不刻意去核对"是不是漏掉了NULL字段的那几行",这个问题很容易悄悄溜过去。
提示:在
IN的语境里,NULL只会让你"少查到"那些字段本身为NULL的记录,不会影响其他正常值的匹配。所以它造成的损失相对有限,真正的重灾区在NOT IN。
3. NOT IN加上NULL:这是最容易翻车的组合,没有之一
如果说IN遇上NULL只是"少查几条",那NOT IN遇上NULL就是"全军覆没"。这条SQL会直接返回空结果集,而且你肉眼检查数据的时候会觉得完全无法理解:子查询里明明有值,为什么外面的都不匹配?
3.1 教科书式的翻车:NOT IN子查询带NULL,查出来一片空白
还是用员工和部门的例子。现在我想查"没有分配任何有效部门的员工",最直觉的写法是:
sql复制SELECT emp_id, emp_name
FROM employee
WHERE dept_id NOT IN (SELECT dept_id FROM department);
假设department表的数据是:
| dept_id | dept_name |
|---|---|
| 10 | 研发部 |
| 20 | 市场部 |
| NULL | 临时部门 |
你预期结果是王五——因为他的dept_id是NULL,不在10和20里。但实际执行这条SQL,结果一行都没有,连王五都不返回。这就是经典翻车现场。
很多人第一次遇到这个情况会反复检查:是不是子查询写错了?是不是部门表数据有问题?其实SQL执行得一点没错,错的是NOT IN遇上NULL时的逻辑。
3.2 拆开看一下NOT IN的逻辑展开式
要理解为什么结果为空,把NOT IN展开成AND的组合就一目了然了。
dept_id NOT IN (10, 20, NULL)等价于:
text复制dept_id <> 10 AND dept_id <> 20 AND dept_id <> NULL
然后你拿每个员工的dept_id进去算:
- 张三,
dept_id = 10:FALSE AND TRUE AND UNKNOWN,结果是FALSE,不返回。 - 李四,
dept_id = 20:TRUE AND FALSE AND UNKNOWN,结果是FALSE,不返回。 - 王五,
dept_id = NULL:UNKNOWN AND UNKNOWN AND UNKNOWN,结果是UNKNOWN,不返回。
看到问题了吗?不管dept_id是什么值,只要AND的链条里出现了UNKNOWN,整体结果就不可能变成TRUE。因为想要AND的结果为TRUE,所有条件都得是TRUE,但x <> NULL这个条件永远不可能是TRUE,它只会是UNKNOWN。
所以结论是:只要NOT IN后面的列表(或子查询结果)里包含任何一个NULL,整个查询的结果一定是空集。这不是概率问题,是逻辑上的必然。
这个坑的杀伤力在于,子查询返回的列里有NULL这件事,往往不是你一眼能看出来的。比如订单表里有历史遗留的脏数据,或者两张表关联时一个外键列没加NOT NULL约束,NULL就这样悄悄混进去了。
注意:
NOT IN遇到空子查询时,比如WHERE dept_id NOT IN (SELECT dept_id FROM department WHERE 1=0),结果反而是全部返回的。因为没有任何值需要比较,条件恒为真。真正致命的是NOT IN列表里"有值但也有NULL"的情况。
3.3 为什么NOT EXISTS没事?EXISTS根本不比大小
网上所有解决方案都告诉你"把NOT IN改成NOT EXISTS",但很多人不理解为什么。这里把原理讲透。
EXISTS是存在性判断,它不关心子查询返回的具体值,只关心子查询有没有返回行。NOT EXISTS就是"子查询没有返回行则为真"。
同样的需求用NOT EXISTS写:
sql复制SELECT emp_id, emp_name
FROM employee e
WHERE NOT EXISTS (
SELECT 1
FROM department d
WHERE d.dept_id = e.dept_id
);
这条SQL的执行逻辑是:遍历employee表的每一行,把当前行的dept_id拿到子查询里去匹配,只要子查询中存在dept_id相等的一行,就说明该员工有部门,过滤掉;如果子查询一条都没匹配上,就保留该员工。
这里的关键区别在于:关联条件是d.dept_id = e.dept_id,是逐行比较的,而且用到的优化方式通常是"反连接"(anti join)。对于王五(dept_id是NULL),子查询里没有任何一行能和NULL匹配上,NOT EXISTS判定为真,所以王五能返回。
反观NOT IN,它得先把子查询的结果(10, 20, NULL)整个算出来,然后拿每个员工的dept_id去跟这三个值逐一比较。一旦遇到那个NULL,整条比较链就报废了。这就是"逐行比较"和"集合包含"两种模式在NULL语义上的根本差异。
4. 避坑方案:NOT EXISTS、显式判空和其他正确姿势
知道了原理,接下来就是实际操作。根据不同的场景,有几种不同的改法,每种都有自己的适用条件。我按推荐程度从高到低排列。
4.1 第一反应:用NOT EXISTS替代NOT IN
如果你要判断的是"父表里那些在子表中不存在关联记录的行",NOT EXISTS是最稳妥的方案。它的语义最清晰,也不受NULL干扰。
sql复制SELECT emp_id, emp_name
FROM employee e
WHERE NOT EXISTS (
SELECT 1
FROM department d
WHERE d.dept_id = e.dept_id
);
这里有个细节:子查询里写SELECT 1而不是SELECT dept_id,是个好习惯。因为EXISTS只关心有没有行,不关心具体查什么列,写1能明确表达意图,也避免一些数据库优化器做无谓的列提取。
用NOT EXISTS时还有一个好处:它是相关子查询,执行时可以利用department.dept_id上的索引,性能在实践中通常也可控。很多号称"NOT EXISTS比NOT IN快"的说法,主要指的是老版本数据库优化器对NOT IN没法把它转换成反连接来执行,而NOT EXISTS天然走的就是反连接的执行计划。
4.2 如果非用NOT IN不可,那就显式排除NULL
有些团队规范里统一用IN/NOT IN,或者你的SQL是动态拼接出来的,临时改成NOT EXISTS成本高。那还有一个简单的补救方式:先确保子查询结果里不存在NULL。
sql复制SELECT emp_id, emp_name
FROM employee
WHERE dept_id NOT IN (
SELECT dept_id
FROM department
WHERE dept_id IS NOT NULL
);
核心就是在子查询里加一个WHERE dept_id IS NOT NULL,把NULL提前过滤掉。这样一来,NOT IN的列表里只剩下实实在在的值,比较链里不再出现UNKNOWN,逻辑就恢复正常了。
这个方案适合你明确知道子查询的列可能包含NULL,并且业务上确实应该忽略这些NULL的场景。但它有个隐患:如果后续别人改了子查询,忘了加IS NOT NULL,坑会再次出现。所以代码评审时留意一下NOT IN后面跟的子查询,是个很有必要的习惯。
4.3 用COALESCE等函数兜底,把NULL转成哨兵值
还有一种思路,是把目标列的NULL转成一个业务上不可能出现的值,再参与比较。比如用COALESCE函数:
sql复制SELECT emp_id, emp_name
FROM employee
WHERE COALESCE(dept_id, -1) NOT IN (
SELECT COALESCE(dept_id, -1)
FROM department
);
这里把NULL统一转换成了-1。于是员工表里dept_id为NULL的人,会变成-1;部门表里dept_id为NULL的记录,也变成-1。两边一比较,-1等于-1,王五就会因为"拥有部门表中的临时部门"而被排除掉。
这个方案有一个明显的坑:哨兵值的选择必须保证不会跟业务真实数据冲突。如果你业务里恰好有dept_id = -1的部门,那就会误伤。实际开发中我更建议用一个绝对不会出现的值,比如负数、极大值、或者特殊字符串。但说实话,这种COALESCE方案读起来不够直观,维护成本偏高,适合在确实需要兼容旧逻辑的场景里用。
4.4 正确性之外,再顺带说说IN/NOT IN的性能问题
很多人一聊到IN和NOT IN,第一反应是性能。我的建议是:先保证正确性,再谈性能。你写出一个结果错误的SQL,跑得再快也没意义。
简单说下这几年的变化。在早期的MySQL、Oracle版本里,优化器对NOT IN的执行计划往往不理想,NOT EXISTS在多数场景下表现更好。但现代数据库的优化器已经越来越聪明,比如MySQL 8.0对IN和EXISTS做了大量优化,很多情况下会转换成半连接(semi join)执行;NOT IN和NOT EXISTS的差距也在缩小。
我的经验是:数据量小的时候,怎么写都无所谓,优化器会兜底;数据量大的时候,执行计划才是决定性的,建议直接看EXPLAIN。把正确性先做对,再做性能分析,不要凭经验一刀切说"NOT IN一定慢"。
5. 面试考点与自测:三分钟讲清楚这个知识点
IN、NOT IN和NULL是SQL面试的高频考点,因为这道题能同时考察基础概念、逻辑思维和实战经验。在热搜词里能看到"sql面试题"的出现,说明这个知识点被问到的频率确实不低。这里整理一个可以直接用的回答版本。
5.1 一句话版本:面试官想听什么
完整的回答逻辑分三步走:
-
NULL在SQL里代表"未知",参与任何比较运算的结果都是UNKNOWN,不是TRUE也不是FALSE。WHERE子句只接受结果为TRUE的条件。 -
NOT IN展开之后是一连串的AND条件,例如x NOT IN (1, 2, NULL)等价于x <> 1 AND x <> 2 AND x <> NULL。最后一个x <> NULL永远产生UNKNOWN,导致整个AND的结果永远不可能是TRUE,所以查询结果永远为空。 -
解决方案有两个方向:一是改成
NOT EXISTS,因为EXISTS只判断子查询有没有返回行,不涉及NULL值比较;二是在NOT IN的子查询里显式加WHERE 列 IS NOT NULL,把NULL排除掉。
把这个逻辑说清楚,面试官就知道你不仅背过答案,还理解原理。
5.2 三道自测题,验证你是不是真的懂了
给你三道题,先别查资料,自己在脑子里推演一遍结果是什么:
自测题1:
sql复制SELECT 1 WHERE NULL IN (1, 2, NULL);
答案是空结果。因为NULL = 1是UNKNOWN,NULL = 2是UNKNOWN,NULL = NULL也是UNKNOWN,三个OR连起来还是UNKNOWN,WHERE只留TRUE,所以查不出任何东西。
自测题2:
sql复制SELECT 1 WHERE 3 NOT IN (1, 2, NULL);
答案是空结果。展开是3 <> 1 AND 3 <> 2 AND 3 <> NULL,前两个是TRUE,第三个是UNKNOWN,整体TRUE AND TRUE AND UNKNOWN等于UNKNOWN,所以不返回。
自测题3:
sql复制SELECT 1 WHERE 3 NOT IN (1, 2);
这个反而能返回结果。因为3 <> 1是TRUE,3 <> 2是TRUE,整体是TRUE。注意跟上一题对比:列表里有没有NULL,结果天壤之别。
这三道题如果你都能快速说对,说明你是真懂了三值逻辑。如果答错了,建议把前面第3节再读一遍。
6. 我踩过的三个坑和最后的小建议
理论讲完了,分享几个真实翻车现场,都是我这些年实际遇到过的案例,每一个都花了不少时间排查。
6.1 三个真实翻车现场
第一个坑是批量删除。有一回要做数据清理,删掉"没有订单的用户",我写的是:
sql复制DELETE FROM users
WHERE user_id NOT IN (SELECT user_id FROM orders);
结果一条都没删掉。排查之后发现,orders表里有一条user_id为NULL的历史脏数据,是当年接口写入异常留下的。这一条NULL让整个NOT IN逻辑全部失效。最后改成NOT EXISTS才正常执行。从那以后,我凡是写NOT IN,一定会先查一下子查询列里有没有NULL。
第二个坑是报表统计。有一次做部门人数统计,用IN关联部门表,发现始终有几名员工没被统计进去。查数据时发现这几名员工的dept_id确实是NULL,但业务上他们属于"未分配部门",老板希望他们也能出现在报表里。解决方案不是改SQL,而是推动业务方给这些人补了默认部门。这个案例说明,NULL的处理有时候不只是技术问题,还涉及业务定义的确认。你不能想当然地觉得"NULL就应该被忽略",得问清楚业务方到底想要什么。
第三个坑是前端传参。有些后台管理系统做筛选功能,前端会拼SQL,条件里可能传入空字符串或者NULL。我在排查一个查询慢的问题时发现,SQL被动态拼成了status NOT IN ('done', NULL),不仅结果完全错误,而且因为无法使用索引,全表扫描拖垮了数据库。那之后我在代码审查里特别关注动态SQL拼接的部分,要求所有参数要么默认给具体值,要么用IS NULL来显式判断。
6.2 给新手的几条实操建议
最后说几条比较实在的建议,都是我写SQL这几年总结出来的经验:
第一,建表的时候,能加NOT NULL约束的列就加上。很多NULL问题的根源是表结构设计得不够严谨。当然,可空列依然有业务价值,但你要明确知道哪些列允许NULL,哪些不应该有。知道数据里有没有NULL,是写出正确SQL的前提。
第二,写任何涉及IN、NOT IN的SQL之前,先问自己三个问题:这个列会为NULL吗?子查询返回的列包含NULL吗?如果有人改了这个子查询,会不会把NULL带进来?这三问能帮你挡掉大部分坑。
第三,把NOT EXISTS当成优先选择。在日常编码里,NOT EXISTS的语义清晰,不受NULL影响,优化器处理得也不差。除非你的SQL是那种IN列表里全是常量的简单场景,否则用NOT EXISTS基本不会错。
第四,排查这类问题的时候,别只盯着结果集看,把SQL拆开,一步一步看中间结果。我常用的方法是先单独跑子查询,看返回的列表里有没有NULL,有的话基本就能定位了。用EXPLAIN看执行计划也是个好办法,有时候一眼就能看出SQL走了什么操作。
写SQL这件事,看起来是一个个语法糖堆出来的,但越往深走越会发现,真正让你翻车的往往是那些"你以为你懂、其实没完全懂"的基础概念。NULL就是其中最典型的一个。希望这篇文章能把IN、NOT IN和NULL的那些坑讲透,让你以后写查询的时候少踩几次泥。
