PostgreSQL 的 NOT IN 子查询含 NULL,查询返回零行
PostgreSQL 的 NOT IN 子查询只要混入 NULL,就可能让本应保留的记录全部消失。检查实际排除集合,按编号匹配改写 NOT EXISTS,并明确外层空值和空集合的处理规则。
一条查询原本用于找出“没有出现在另一张表中的记录”,SQL 没有报错,却突然一行也查不到。单独看主表,确实存在未被排除的记录;再检查 NOT IN 右侧的子查询,发现它返回的某一行是 NULL。
对于单列比较,只要右侧结果含有 NULL,NOT IN 就无法得到 TRUE:匹配到非空值时结果是 FALSE,没有匹配到时则可能是未知值。放在 WHERE 中,两者都不会保留行。这里的“没有返回记录”不等于 SQL 执行失败。
本文处理 PostgreSQL 普通单列值的反向筛选,示例使用整数编号。它属于 SQL 的空值比较规则,不是某个 PostgreSQL 补丁版本的故障。多列行比较、自定义运算符和 ORM 生成的其他条件需要另行分析。
先判断丢失的是行,还是判断结果
下面是一段不创建实体表的示例查询。VALUES 提供三条待检查记录和两条排除记录,可以在本机测试数据库中查看每一行的判断结果:
SELECT c.id,
c.id NOT IN (
SELECT e.id
FROM (VALUES (20), (NULL::integer)) AS e(id)
) AS not_in_result
FROM (VALUES (10), (20), (30)) AS c(id)
ORDER BY c.id;
按比较规则,编号 20 的判断结果应为 FALSE,编号 10、30 的结果应为 NULL。客户端可能把 SQL 空值显示成空白,不要把它当成空字符串。如果把同一个判断放到 WHERE 中,这三行都会被过滤。
WHERE 只保留结果为 TRUE 的行。NULL 在这里表示结果未知,并不等于 FALSE;对未知值取反仍然是未知值。因此,在条件外再包一层 NOT 不能把这些行找回来。
真实业务查询可以暂时把 NOT IN 判断移到输出列,并限定一小组已知编号,观察哪些结果是空值。保留原有的租户、日期和状态过滤,避免为了排错把查询范围扩大到整张大表。
检查子查询实际输出的那一列
假设查询要从 app.customers 中找出未出现在 app.excluded_customers 中的客户,原 SQL 是:
SELECT c.id
FROM app.customers AS c
WHERE c.id NOT IN (
SELECT e.customer_id
FROM app.excluded_customers AS e
);
这里的表名、schema 和字段名都是示例,需替换成业务中的实际对象。下面的查询只回答排除集合里有没有空编号:
SELECT EXISTS (
SELECT 1
FROM app.excluded_customers AS e
WHERE e.customer_id IS NULL
) AS has_null_customer_id;
如果结果为 TRUE,就能确认排除集合含有空值。实际子查询带有其他条件时,要把那些条件一起保留在检查中。关联子查询还要使用发生异常的那条外层记录对应的条件;不能用整张表是否有空值,代替它对特定记录产生的结果。
检查对象应是子查询最终返回的表达式。即使基础表字段不允许空值,外连接或其他表达式仍可能产生 NULL。反过来,表里有空值,也不代表它一定进入了带过滤条件的子查询。
不要把检查写成 e.customer_id = NULL 或 e.customer_id <> NULL。空值检测使用 IS NULL 和 IS NOT NULL;普通等号和不等号不能完成这项检测。
只按有效编号排除时,改用相关 NOT EXISTS
如果业务含义是“找不到同编号的排除记录”,可以直接表达这个条件:
SELECT c.id
FROM app.customers AS c
WHERE NOT EXISTS (
SELECT 1
FROM app.excluded_customers AS e
WHERE e.customer_id = c.id
);
内层查询使用 e.customer_id = c.id 关联当前客户。存在相同编号时,EXISTS 为真,外层 NOT EXISTS 把该客户排除;没有相同编号时保留。排除表里的空编号不会通过这个相等条件,因此不会使其他编号一起消失。
不要漏掉关联条件。没有 e.customer_id = c.id 时,内层查询只要有任意一行,就会让所有外层记录都被排除。
原子查询若限定某个租户、名单状态或生效日期,改写后也必须保留这些条件。不要把原本只在某个范围内排除客户的查询,改成全表排除。这里选择 NOT EXISTS 是为了表达匹配语义,不能据此断言它在所有数据量和索引条件下都更快。
保留 NOT IN 时,需要明确忽略右侧空值
如果外层编号已由主键或 NOT NULL 约束保证非空,而且排除集合中的空编号确实应被忽略,也可以只修改子查询:
SELECT c.id
FROM app.customers AS c
WHERE c.id NOT IN (
SELECT e.customer_id
FROM app.excluded_customers AS e
WHERE e.customer_id IS NOT NULL
);
这会从参与比较的集合中去掉空值,不会删除表中的记录。它成立的前提是空编号没有额外的业务含义,例如不能把“空编号代表排除全部客户”的业务约定擅自改成“忽略该行”。
也不要用一个随意挑选的数字替换空值。把空值替成 0、-1 等哨兵值,会引入新的匹配规则;一旦实际编号可以取到这个值,结果就会受到影响。
外层编号也可能为空时,先决定要不要保留
外层字段不是非空主键时,上面两种改写不能直接视为等价。
使用普通等号的 NOT EXISTS 会保留外层空编号,因为内层找不到一个比较结果为真的相等编号。而过滤了右侧空值的 NOT IN,面对非空的右侧集合仍会过滤外层空编号;当右侧集合一行都没有时,它又会保留该行。
如果业务要求“外层编号为空就不参与结果”,可在 NOT EXISTS 前明确加上非空条件:
SELECT c.id
FROM app.customers AS c
WHERE c.id IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM app.excluded_customers AS e
WHERE e.customer_id = c.id
);
如果业务把两边的空编号视为同一种可匹配的值,则需要另一种表达方式:
SELECT c.id
FROM app.customers AS c
WHERE NOT EXISTS (
SELECT 1
FROM app.excluded_customers AS e
WHERE e.customer_id IS NOT DISTINCT FROM c.id
);
IS NOT DISTINCT FROM 在两边都为空时为真,只有一边为空时为假;两边都非空时与等号一致。采用它意味着排除集合有空编号时,外层空编号也会被排除。是否采用它取决于业务如何定义空编号,不能直接用它替换所有等号。
核对结果边界,再替换应用中的查询
在本机测试数据中至少覆盖这些情况:编号匹配、编号不匹配、排除集合混有空值、排除集合只有空值、排除集合完全为空。如果外层编号也允许空值,再分别检查它在这些集合下应当保留还是排除。
对于前面的非空编号示例,排除集合是 20 和空值时,按“仅匹配有效编号”的规则应保留 10、30;空值不应使二者一起消失。排除集合为空时,三个非空编号都应保留。验证时查看具体编号,不只比较总行数,以免两种不同的错误恰好返回相同数量。
修改前保存应用中的原 SQL 和参数条件;修改后先核对读取结果,再恢复依赖这份结果的后续操作。如果查询被用于生成删除、禁用或批量更新的目标集合,不能把新条件直接投入写操作:从零行变成大量记录,可能正是修复后的语义,却也会放大操作影响。
本文的查询改写不需要清空排除表、修改已有数据或放宽约束。结果不符合业务规则时,可以回退到保存的查询版本并暂停依赖它的写操作,再重新确认两侧空值的含义;回退本身并不会修复旧查询的空值问题。


