SQL 排障:NOT IN 遇到 NULL,为什么查不到预期数据

举报
yd_239005675 发表于 2026/10/18 01:20:01 2026/10/18
【摘要】 用 SQLite 复现 NOT IN 因 NULL 返回空集的问题,验证 NOT EXISTS、过滤空值和外侧编号为空时的差异,并给出回归测试与数据约束建议。

做排除查询时,一个常见需求是“找出不在屏蔽名单中的客户”。数据不多,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 辅助整理,并已在本地内存数据库运行验证。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。