SQL 排障:NOT IN 遇到 NULL,为什么查不到预期数据
做排除查询时,一个常见需求是“找出不在屏蔽名单中的客户”。数据不多,SQL 也没有报错,但查询结果却突然变成空集。此时可以先检查排除名单里有没有 NULL。
下面用 SQLite 内存数据库复现,所有表都是临时实验数据,不连接业务数据库。例子聚焦单列比较;复合键、类型转换和排序规则需要另外验证。
一、只多了一条空编号,结果就不同了
先创建三位客户,以及一份包含客户 2 和一个空编号的屏蔽名单:
CREATE TABLE customers (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE blocked_customers (
customer_id INTEGER
);
INSERT INTO customers(id, name) VALUES
(1, 'Alice'), (2, 'Bob'), (3, 'Carol');
INSERT INTO blocked_customers(customer_id) VALUES (2), (NULL);
按照“排除客户 2”的直觉,下面这条查询应返回 1 和 3:
SELECT c.id
FROM customers AS c
WHERE c.id NOT IN (
SELECT b.customer_id FROM blocked_customers AS b
)
ORDER BY c.id;
实际结果是零行。这不是表被清空了,也不是 ORDER BY 丢掉了数据,而是过滤条件没有对任何一位客户得到真值。
二、把判断结果直接查出来
在 SQL 中,包含缺失值的比较可能得到未知结果。WHERE 只保留条件为真的行,假和未知都会被过滤。可以把表达式放进 SELECT,先观察它们的返回值:
SELECT
1 NOT IN (2, NULL) AS unmatched_with_null,
2 NOT IN (2, NULL) AS matched_value,
NULL IS NULL AS is_missing,
NULL = NULL AS ordinary_equality;
在本次 SQLite 测试中,四列依次是 NULL、0、1、NULL。Python 的 sqlite3 接口把其中的 SQL NULL 表示为 None,它与数值 0 并不是同一个结果。
对于本例的两个标量候选值,可以把 x NOT IN (2, NULL) 理解为同时判断 x <> 2 和 x <> NULL。客户 1 没有与 2 相等,但与未知编号比较也无法得到确定的不等结论;客户 2 则已经与名单中的 2 相等。因此两者都不能通过排除条件。
这里也能看出,检查缺失值要使用 IS NULL,不能写成 = NULL。把所有未知结果简单当成“数据库算错了 false”,会让后面的改写继续偏离需求。
三、按“不存在匹配记录”表达需求
如果需求明确是“屏蔽表中不存在相同客户编号的记录”,可以写成关联的 NOT EXISTS:
SELECT c.id
FROM customers AS c
WHERE NOT EXISTS (
SELECT 1
FROM blocked_customers AS b
WHERE b.customer_id = c.id
)
ORDER BY c.id;
本次结果为 1、3。对于客户 1,编号为空的那条屏蔽记录不能满足等值条件,子查询也找不到其他匹配记录,所以客户 1 被保留。客户 2 能找到匹配行,因此被排除。
SELECT 1 只是在表达“检查有没有行”。不要把这里的 1 理解成需要取出的业务字段,也不要在修改时漏掉 b.customer_id = c.id。如果漏掉关联条件,检查的就会变成整张屏蔽表是否为空,意义完全不同。
四、过滤子查询中的 NULL,也要看外侧字段
在当前例子中,customers.id 是非空的整数主键,也可以明确过滤名单中的空编号:
SELECT c.id
FROM customers AS c
WHERE c.id NOT IN (
SELECT b.customer_id
FROM blocked_customers AS b
WHERE b.customer_id IS NOT NULL
)
ORDER BY c.id;
这次同样返回 1、3。这里的前提很重要:外侧客户编号不为空。如果把这段查询复制到导入暂存表,而暂存表允许空编号,两种写法就可能产生不同结果。
下面为暂存数据添加一个空编号,并把两种判断并排展示:
CREATE TABLE incoming_customers (id INTEGER);
INSERT INTO incoming_customers(id) VALUES (1), (2), (NULL);
SELECT
i.id,
NOT EXISTS (
SELECT 1 FROM blocked_customers AS b
WHERE b.customer_id = i.id
) AS keep_by_exists,
i.id NOT IN (
SELECT b.customer_id FROM blocked_customers AS b
WHERE b.customer_id IS NOT NULL
) AS keep_by_not_in
FROM incoming_customers AS i
ORDER BY i.id;
本次结果如下,表中的 NULL 表示未知或缺失:
| 暂存编号 | NOT EXISTS 判断 | 过滤右侧 NULL 后的 NOT IN 判断 |
|---|---|---|
| NULL | 1 | NULL |
| 1 | 1 | 1 |
| 2 | 0 | 0 |
这意味着:在相同的 WHERE 位置,第一种写法会保留空编号行,第二种在本例中会过滤它。查询是否应该接受“编号缺失的客户”,应由业务规则决定。
如果规则要求暂存编号必须存在,再应用屏蔽名单,可以明确写出两个条件:
SELECT i.id
FROM incoming_customers AS i
WHERE i.id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM blocked_customers AS b
WHERE b.customer_id = i.id
)
ORDER BY i.id;
结果只有 1。这样读查询的人可以直接看到空编号处理策略,不需要从某个操作符的附带效果中推断它。
五、把边界案例写进回归验证
这次在 Python 3.13.11、SQLite 3.51.0 中执行了文章中的全部 SQL,并额外检查了以下情况:
- 屏蔽名单没有 NULL:原查询和两种改写都返回 1、3。
- 屏蔽名单只有 NULL:原查询返回零行;两种改写对非空客户编号都返回 1、2、3。
- 屏蔽名单完全为空:三个查询都返回所有客户。
- 屏蔽编号 2 重复出现:不会改变“不存在匹配记录”的判断结果。
- 外侧编号为空:分别验证是否需要显式排除,避免把两种写法当成无条件等价。
还要区分“空集合”和“集合里有一个 NULL”。在 SQLite 中,即使左侧为 NULL,针对空子查询的 NOT IN 也返回真。这不意味着可以把 SQLite 允许的空字面列表 IN () 原样搬到其他数据库;跨库时应按目标数据库的语法和行为做测试。
六、排障结束后,处理数据约束
查询修正以后,可以继续统计屏蔽表中的空编号:
SELECT COUNT(*) AS missing_customer_ids
FROM blocked_customers
WHERE customer_id IS NULL;
本例返回 1。如果屏蔽记录必须关联到具体客户,就应在导入或写入时拒绝缺失编号,并评估为字段增加 NOT NULL 约束。既有数据需要先判断来源和业务含义,再决定清理方式;不要直接用一个“通常不会出现”的数字替代 NULL,否则可能把缺失值变成真实编号。
本例验证的是查询语义,没有做吞吐量或执行计划基准。选择写法时先确定预期结果,再使用接近实际规模的数据检查索引和查询计划,不能只凭 NOT EXISTS 或 NOT IN 的名字判断哪一个更快。
参考:SQLite 官方文档《SQL Language Expressions》的 IN、NOT IN 与 EXISTS 说明;PostgreSQL 官方文档《Subquery Expressions》的 NOT IN 说明。示例数据与验证用例围绕排除查询场景编写,文章由 AI 辅助整理,并已在本地内存数据库运行验证。
- 点赞
- 收藏
- 关注作者
评论(0)