先讲一次真实的翻车经历。上个月帮朋友公司排查一个线上报表任务,SQL 不长,逻辑也顺理成章——从用户表里排除黑名单用户,把剩下的人导出给运营。核心就这一句:where user_id not in (select banned_user_id from blacklist)。这行 SQL 跑了几个月,一直风平浪静。直到某天运营同学截图过来:报表全空了,一列数据都没有。
我第一反应是数据源挂了,检查了一遍用户表,几万行数据都在。再回头去看黑名单表,发现新导入的数据里,banned_user_id有一行是 NULL。就这一个小小 NULL,让整条查询静默地返回了空集。没有报错,没有警告,连日志都没有。这种"逻辑黑洞"最可怕的地方就在这里:它不是崩溃,而是悄悄把结果变空、变少、变错,而且你很难第一时间察觉。
这个坑几乎每个写过 SQL 的人都踩过,只是踩的姿势不同——有的人是 NOT IN 失效,有的人是统计口径对不上,有的人是 JOIN 之后行数莫名减少。根子都在同一个地方:SQL 里的 NULL 根本不是我们直觉中理解的"空"或者"没有",它代表"未知"。一旦"未知"参与运算,结果大概率还是"未知",而过滤条件只保留"真"的结果,于是所有沾上 NULL 的行,全被无声丢弃。这篇文章就围绕这个主题,把 NULL 的底层逻辑、NOT IN 失效的完整推演、其他同类黑洞,以及一套可复用的排查和防御方法,一次讲透。
1. 先还原事故现场:一条NULL是怎么让整张报表"查无此人"的
1.1 一个最小可复现的翻车案例
先把场景压缩到最简,方便你直接在本地数据库里验证。两张表:一张用户表,一张黑名单表,黑名单里故意塞了一条 NULL。
-- 用户表 create table users ( user_id int primary key, user_name varchar(50) ); -- 黑名单表 create table blacklist ( banned_user_id int ); insert into users values (1, '小明'), (2, '小红'), (3, '小刚'), (4, '小丽'); insert into blacklist values (2), (null); -- 这条查询的结果是:空集 select user_id, user_name from users where user_id not in (select banned_user_id from blacklist);按直觉,用户 1、3、4 都不在黑名单里,应该返回三行。但实际返回 0 行。数据没丢,是查询的过滤逻辑把每一行都判成了"不符合条件"。为了理解这件事,必须先搞清楚数据库底层那套和我们直觉完全不同的判断机制。
1.2 三值逻辑:数据库比直觉多了一个"不知道"
我们日常写代码判断真假,习惯的是二值逻辑:要么 true,要么 false。但 SQL 是关系模型的产物,它要表达"这个数据缺失""这个条件还不知道成不成立"这类状态,所以在 WHERE 条件的求值里,引入了一个额外的结果——UNKNOWN(未知)。
也就是说,任何一个谓词(比较、逻辑运算),求值结果不是两种,而是三种:
| 谓词 | 求值结果 |
|---|---|
1 = 1 | TRUE |
1 = 2 | FALSE |
1 = NULL | UNKNOWN |
NULL = NULL | UNKNOWN |
1 <> NULL | UNKNOWN |
而 WHERE 子句的行为是:只保留求值结果为 TRUE 的行。UNKNOWN 和 FALSE 一样,都被过滤掉。
这就是关键:NULL和任何值做比较,结果不是 FALSE,而是 UNKNOWN。这个"多出来的第三态",就是我们这类逻辑黑洞的总根源。
1.3 NULL 不是"空",是"不知道"
很多初学者会把 NULL 理解成 0、空字符串''、或者"没有"。"没有"在我们日常语言里是一个明确的答案——没有就是没有嘛。但 SQL 里的 NULL 语义比这更弱,它代表的是"不知道"。
举一个直白的例子:你问朋友"那个人月薪超过一万吗"。如果朋友回答"没有",你得到了一个明确答案;但如果朋友回答"我不知道",那这个问题对你来说就是"未知"。你不能说"未知"等于"没超过",也不能说它等于"超过"。
NULL 在 SQL 里的角色就是那个"我不知道"。所以它和 0、空字符串有本质区别:
0是一个值,参与四则运算有意义:0 + 1 = 1''是一个值,字符串拼接有意义:'a' || '' = 'a'NULL不是值,任何运算碰到它都会传染:NULL + 1结果是 NULL,'a' || NULL结果还是 NULL
把 NULL 当成 0 或者空串去写 SQL,是所有空值事故的共同起点。后面的 NOT IN 失效、统计口径错乱、JOIN 丢行,本质上都是这个"未知位"在传播。
2. NOT IN 翻车的逻辑推演:UNKNOWN 如何在过滤条件里"吞掉"所有行
2.1 把 NOT IN 拆开看:等价于一串 AND 连接的不等比较
很多人只知道 NOT IN 会踩坑,却不明白它为什么踩坑。搞清楚的最好方式,是把 NOT IN 展开成基础逻辑。
x NOT IN (a, b, c)在语义上等价于:
x <> a AND x <> b AND x <> c回到事故案例,user_id NOT IN (select banned_user_id from blacklist),黑名单集合是{2, NULL},所以对每一个用户,判断被展开成:
user_id <> 2 AND user_id <> NULL问题就出在user_id <> NULL这一项。前面说过,任何值和 NULL 比较,结果都是 UNKNOWN。于是对于用户 1(小明):
1 <> 2→ TRUE1 <> NULL→ UNKNOWNTRUE AND UNKNOWN→ UNKNOWN
对于用户 2(小红):
2 <> 2→ FALSE2 <> NULL→ UNKNOWNFALSE AND UNKNOWN→ FALSE
无论走哪条分支,整个表达式都得不到 TRUE。WHERE 只保留 TRUE,所以全表清零,一张报表就这么空了。
2.2 一个反直觉的细节:手写 NOT(IN(...)) 也救不了
有些同学遇到这个问题,第一反应是把表达式"反过来"写:
where not (user_id in (select banned_user_id from blacklist))以为换个姿势能绕过。但逻辑上,NOT (x IN (...))和x NOT IN (...)是完全等价的,而且在三值逻辑里:
NOT UNKNOWN = UNKNOWN所以把"未知"取反,得到的还是"未知"。改写法不会改变结果,照样空集。这恰恰是 NULL 逻辑比普通布尔逻辑更反直觉的地方——在二值逻辑里,not false是true;但在三值逻辑里,对 UNKNOWN 取反,依然是 UNKNOWN,你永远得不到一个确定答案。
2.3 为什么 IN 有部分行,而 NOT IN 直接全灭
细心的读者可能已经发现问题:那 IN 碰到 NULL 会怎样?会更好吗?
我们看user_id IN (2, NULL),它等价于:
user_id = 2 OR user_id = NULL以用户 1 为例:1 = 2是 FALSE,1 = NULL是 UNKNOWN,FALSE OR UNKNOWN结果是 UNKNOWN,这行被过滤。但对用户 2 来说:2 = 2是 TRUE,TRUE OR UNKNOWN结果是 TRUE,这行保留。
所以 IN 遇到 NULL,表现是"部分正确"——能匹配上的具体值行还在,但集合里如果只有 NULL,比如WHERE user_id IN (SELECT ...)的子查询结果是个单一 NULL,那照样查不出任何行。
真正的差异在 NOT IN 这侧更极端:一旦集合里出现一个 NULL,所有行的"排除判断"都陷入 UNKNOWN,整体直接全灭。为什么会不对称?因为"等于"还有机会通过其他 OR 分支碰到 TRUE,而"不等于"的每一支都必须贡献 TRUE,其中一支是 UNKNOWN,整个 AND 就永远成不了 TRUE。
三种写法的行为对比如下:
| 查询写法 | 子查询集合含 NULL 时的行为 | 是否推荐 |
|---|---|---|
x IN (子查询) | 会保留能匹配上具体值的行,但集合只有 NULL 时照样空集,行为偏"保守" | 有条件使用 |
x NOT IN (子查询) | 集合含 NULL 时,整个查询几乎必然返回空集 | 不推荐 |
NOT EXISTS (相关子查询) | 逐行判断"是否存在一条记录满足匹配",NULL 不参与匹配,行为完全符合直觉 | 强烈推荐 |
2.4 哪些时候 NOT IN 其实还能用
不是说 NOT IN 这个语法本身该死,而是它有个隐含前提:参与比较的集合里不能有 NULL。同时满足下面任一条件时,NOT IN 还是安全的:
- 子查询的列在表结构上声明了
NOT NULL,数据库层面保证不会出现 NULL; - 子查询里显式过滤了空值,比如
select banned_user_id from blacklist where banned_user_id is not null; - 集合来自字面量,你确认里面没有 NULL。
但问题在于,这些前提在写代码时容易成立,运行几个月后却不一定会保持——同事往表里导了个新数据源、上游接口字段变成了空、清洗逻辑改了一行,NULL 就溜进来了。所以我个人的习惯是:涉及"排除某集合"的语义,一律用 NOT EXISTS 打底,不给自己留隐患。
3. 连锁反应不止 NOT IN:WHERE、JOIN、聚合函数里的同类黑洞
看明白三值逻辑之后,你会发现 NOT IN 只是冰山一角。NULL 的"未知"传染性,会在 SQL 的各个角落里默默改写你的结果。
3.1 最经典的三句话:= NULL、<> '张三'、CASE WHEN
几乎每个新手都写过where name = null,然后对着空结果发呆。这个错误很好解释:name = NULL的结果是 UNKNOWN,不是 TRUE,WHERE 当然不放行。正确写法是where name is null。
比这个更隐蔽的是"排除某个值"时的反直觉行为。比如:
select user_id, user_name from users where user_name <> '张三';你想排除叫"张三"的人,结果发现"名字没填"的那些人(user_name 为 NULL)也一起消失了。为什么?因为NULL <> '张三'的结果是 UNKNOWN,不是 TRUE。你的本意是"不是张三的都给我",但数据库拿到的判断是"这行到底是不是张三,我不知道"——不知道,那就别放行。
这个 bug 在真实业务里特别常见。比如筛选非注销用户、排除特定渠道来源的订单,如果目标列存在空值,往往会把不该丢的数据一并丢掉。排查思路和 NOT IN 完全一致:先问一句,这个比较列里有没有 NULL?
CASE WHEN 同理:
case when remark = null then '无备注' else remark end这段永远走不到"无备注"分支,因为remark = null永远是 UNKNOWN。必须写成remark is null。
3.2 JOIN 关联键上的 NULL:匹配不上,还被静默处理
连接操作里 NULL 同样制造困惑。INNER JOIN的关联条件a.user_id = b.user_id,只要两边任意一边是 NULL,比较结果就是 UNKNOWN,这一行不会进入结果集。如果你的关联键存在空值,JOIN 之后行数会悄悄变少,而且没有警告。
LEFT JOIN则表现得更微妙:左表某行的关联键是 NULL,它不会被匹配到任何右表行,但因为 LEFT JOIN 会保留左表所有行,所以这行还会出现在结果集里,只是右表的字段全是 NULL。很多时候你以为关联上了,其实并没有,给下游造成的错觉甚至比 INNER JOIN 丢行更危险。
对账、数据同步场景里还有个经典需求:想判断两条记录是否一致,NULL和NULL应该视为"相同",但普通的等值判断NULL = NULL结果是 UNKNOWN,匹配不上。这时候可以用 NULL-safe 的等值判断:
-- PostgreSQL、SQL Server 2022 等支持 IS NOT DISTINCT FROM select a.id from table_a a full join table_b b on a.id is not distinct from b.id where a.id is null or b.id is null or a.val is distinct from b.val;如果用的 MySQL,对应的是<=>运算符。这类运算符把两个 NULL 当成相等,专门用于"未知对未知"的对账场景。日常开发可能用不上,但一旦遇到数据比对的需求,你会庆幸知道它。
3.3 聚合函数的口径陷阱:COUNT、SUM、AVG 各自的理解
聚合函数对 NULL 的处理各不相同,做报表的人最容易在这里栽跟头。我把最常见的差别整理成一张表:
| 函数 | 对 NULL 的处理 | 容易踩的坑 |
|---|---|---|
COUNT(*) | 统计所有行,不管字段是否 NULL | 业务想统计"非空数量"时口径偏大 |
COUNT(col) | 只统计该列非 NULL 的行 | 空值一多,结果远小于预期 |
SUM(col) | 忽略 NULL 行;如果全部是 NULL,返回 NULL | 直接拿去展示,被当成 0 或者报错 |
AVG(col) | 忽略 NULL 行,分母是非空行数 | 缺失值没有被计入分母,指标虚高 |
举一个真正发生过的业务例子。团队统计"客户平均消费额",SQL 直接写:
select avg(amount) from orders;结果人均消费高得离谱。原因很直接:很多客户根本没下过单,他们不在 orders 表里;而在 orders 表里但amount是空值的记录,又被 AVG 跳过了。这个"平均"只在有消费金额的人里面计算,分母天然偏小。如果你想让"没消费"按 0 参与统计,必须显式翻译:
select avg(coalesce(amount, 0)) from orders;COALESCE的作用是在 NULL 出现时替换成你指定的默认值。用什么默认值,取决于业务语义——NULL 到底是"该为 0"还是"根本不该参与",你要在写 SQL 的时候就想清楚,而不是让数据库替你决定。
3.4 CASE WHEN 和 ORDER BY 的隐藏行为
CASE WHEN 除了前面说的= NULL写错之外,还有一个容易忽略的点:如果所有分支都没有匹配,且没有写 ELSE,那么 CASE 表达式的结果就是 NULL。这个 NULL 再传给下游计算,又会引发新一轮传染。所以写 CASE 时,我通常会习惯性补一个else 默认值,把"未知兜底"这句显式写出来。
排序也值得关注。ORDER BY对 NULL 的位置,不同数据库默认行为并不一致,而且常常和直觉相反:
| 数据库 | ASC 时 NULL 的位置 | DESC 时 NULL 的位置 |
|---|---|---|
| MySQL | 最前 | 最后 |
| SQL Server | 最前 | 最后 |
| PostgreSQL | 最后 | 最初 |
| Oracle | 最后 | 最初 |
如果你的报表要求"空值永远排在最后",直接用默认行为是不可靠的,跨数据库还会打架。标准 SQL 提供了显式控制:
order by create_time asc nulls last;写清楚NULLS FIRST还是NULLS LAST,无论在哪个数据库上行为都一致。这也是我在多数据库项目中坚持的写法。
3.5 去重与分组:所有 NULL 会被归成一类
最后说一个跟"去重"强相关的坑:GROUP BY会把所有 NULL 归到同一个组里,SELECT DISTINCT对可空字段也只输出一个 NULL。这个特性有好有坏。
好的方面:按"地区"分组统计时,未填地区的记录会归成"未知地区"一组,你至少看得到数据。
坏的方面:如果你以为"没填"和"填了不同值"是同一层面的事,就容易误判。比如用ROW_NUMBER() OVER (PARTITION BY 某可空字段 ORDER BY ...)做去重,所有 NULL 会进同一个分区,每批 NULL 之间按窗口内规则互相"竞争"名次,可能让本该保留多条的历史数据只留下一条。做数据清洗时,这类"分组归并"行为一定要提前想到。
4. 一份完整的排查复盘:从报表变空到定位根因的实操链路
理论说了一堆,回到开头的真实事故,我把当时的排查过程完整复盘一遍。以后你碰上"结果少了、空了、口径不对"这类问题,可以直接照着走。
4.1 第一步:先排除"数据本身没了"的嫌疑
拿到"报表全空"的反馈,先别急着读业务逻辑。第一步永远是用最小代价确认数据源是否正常:
select count(*) from users; -- 用户表总行数,确认源数据还在 select banned_user_id from blacklist; -- 直接看子查询的集合内容 select count(*) from blacklist where banned_user_id is null; -- 专门扫空值我当时就是在第二步看见结果集里除了2还有一行NULL,心里基本就有了判断。这个"直接看子查询输出"的动作,成本极低,却能把问题锁定到极小范围。
4.2 第二步:用 NOT EXISTS 做对照实验
边缘情况再怎么推理,都不如一个对照实验实在。把 NOT IN 等价改写成 NOT EXISTS,跑一遍看结果:
select user_id, user_name from users u where not exists ( select 1 from blacklist b where b.banned_user_id = u.user_id );十几秒内返回了三行:小明、小刚、小丽。与空集形成鲜明对比,说明数据完全没问题,就是谓词逻辑在 NULL 面前失效了。到这一步,根因已经 90% 锁定。
4.3 第三步:继续下钻,定位是"哪一个 NULL"
为了把原理和现象彻底对上,我还做了一组二分实验:
-- 实验 A:子查询过滤掉 NULL,NOT IN 恢复 select user_id, user_name from users where user_id not in ( select banned_user_id from blacklist where banned_user_id is not null ); -- 实验 B:给集合额外塞一个 NULL,观察是否再次翻车 insert into blacklist values (null); -- 重新跑最原始的 NOT IN 查询实验 A 返回三行,实验 B 加上 NULL 后又变回空集。到这里,"NULL 是触发条件"这个结论已经没有任何疑问了。这个二分法以后可以一直用:把可疑元素从集合中移除,再放回去,看结果是否在两个状态之间切换,很快就能锁定真凶。
4.4 第四步:把教训固化到日常防线
排查完之后,真正值钱的是如何防止它再次发生。我在团队里做了三件事:
- 代码审查清单:凡是出现
NOT IN、= NULL、<> 常量这类写法,审查时必须确认目标列无 NULL,或者显式做了空值处理。 - 数据质量监控:对参与排除、连接、统计口径的可空字段,建一个空值率监控任务。比如每天统计
blacklist.banned_user_id is null的行数和占比,超过阈值告警。 - 回归测试 SQL:把这次事故的查询固化成一个测试用例。以后改表结构、改数据导入逻辑时,先跑这个用例,确保 NOT EXISTS 版本和 NOT IN 版本的结果一致。
这三条看着笨,但数据问题本来就是"防大于修"。NULL 不会报错,它只会安静地污染结果,你必须有机制在污染发生前就叫停。
5. 防御性写法的优先级排序:让 NULL 出现在它该在的地方
前面讲了很多"为什么",最后落回到"怎么写"。我按推荐优先级给出一套实践顺序,你写排除类查询时直接照着选。
5.1 第一优先:NOT EXISTS
涉及"排除某集合"的语义,我会默认写 NOT EXISTS:
select user_id, user_name from users u where not exists ( select 1 from blacklist b where b.banned_user_id = u.user_id );它逐行检查"是否存在一条黑名单记录与当前用户匹配",NULL 永远无法等于某个具体用户 ID,所以 NULL 行不会参与匹配,结果完全符合业务直觉。另外,select 1只是标记存在性,写select *也可以,优化器通常会把这类子查询转换成 anti-join 或半连接,配合索引,性能不一定比 NOT IN 差。
5.2 第二优先:LEFT JOIN ... IS NULL
第二种常用写法是 LEFT JOIN 后找"没匹配上"的行:
select u.user_id, u.user_name from users u left join blacklist b on u.user_id = b.banned_user_id where b.banned_user_id is null;注意一个额外陷阱:如果黑名单表在banned_user_id上存在重复数据,LEFT JOIN 会按重复行把用户表行复制多份,最后结果行数虚高。所以这种写法最好配合去重:
select u.user_id, u.user_name from users u left join ( select distinct banned_user_id from blacklist ) b on u.user_id = b.banned_user_id where b.banned_user_id is null;这也是为什么我在团队里会更倾向 NOT EXISTS——它天然对重复数据不敏感,少一个隐患。
5.3 第三优先:显式翻译 NULL 的业务语义
有些场景没法绕开 NULL,必须在语义层面把它翻译成业务认可的值。常用工具是COALESCE——它接受多个参数,从左到右返回第一个非 NULL 值;以及 MySQL 的IFNULL、SQL Server 的ISNULL。
-- 统计口径:NULL 记为 0 select user_id, sum(coalesce(amount, 0)) as total_amount from orders group by user_id; -- 展示层:NULL 翻译成阅读友好的文本 select user_name, coalesce(remark, '无备注') as remark from users;用哨兵值替代的时候要格外小心。比如有人会写coalesce(banned_user_id, -1)配合 NOT IN,思路是"把 NULL 换成不可能出现的 -1",但如果哪天业务里真的出现了 -1 或者负数 ID,碰撞就会制造新的错误。我的建议是:能过滤 NULL 就过滤,能换写法就换写法,哨兵翻译只在聚合和展示层使用,不要在排除逻辑里赌一个"永远不会碰撞"的值。
5.4 建表阶段消灭野生 NULL
最根治的办法,是让 NULL 根本没机会进入关键字段。建表时如果某个字段的业务语义是"必须有值",就直接加约束:
create table orders ( order_id bigint primary key, user_id bigint not null, amount decimal(12,2) not null default 0, check (amount >= 0) );NOT NULL加DEFAULT的组合,能在源头挡住很多空值事故。对于确实允许未知值的字段,我更建议用显式的状态枚举来表达"未知",而不是留给 NULL。比如客户地区未知,与其让字段是 NULL,不如用一个业务字段region_code = 'UNKNOWN'配合注释。这样做的好处是:所有下游 SQL 对"未知"的处理有一个明确的业务语义,而不是依赖数据库三值逻辑去猜。
5.5 别忘了索引和性能的隐性影响
最后补充一个性能角度的提醒。有些开发者为了规避 NULL,习惯写where coalesce(col, '') = ''来筛选空值,但函数包一层字段之后,普通 B-tree 索引通常就失效了,大表查询会退化成全表扫描。
更合理的写法是把它拆成等价的显式条件:
-- 不推荐:函数包裹导致索引失效 where coalesce(status, '') = '' -- 推荐:等价语义,尽量保持字段裸用 where status is null or status = ''如果某个列绝大部分是 NULL,只有少量行有值,你真正关心的往往是"那些有值的行"。这时候可以在 PostgreSQL 里建部分索引:
create index idx_orders_amount_not_null on orders(amount) where amount is not null;MySQL 5.7+ 则可以用函数索引解决类问题。总之,NULL 不仅影响逻辑正确性,还会影响执行计划。写法上对 NULL 的处理越"原生",优化器越容易帮你生成好的执行计划。
做 SQL 多年,我越来越觉得,和 NULL 打交道拼的不是技巧,而是纪律。把这些细节定成团队规范,写进 checklist,比临时抱佛脚查文档靠谱得多。最后分享一个我常用的土办法:凡是想不通"结果为什么少了"的查询,先把所有可疑字段的空值率扫一遍,十有八九,黑洞就藏在那里。