SQL NULL 三值逻辑:为什么“不等于”会漏掉空值

10-01 3阅读

想找出“状态不是已完成”的记录,写了 status 不等于 done,却发现未知状态的行没有出现。这不是数据库随机漏数据,而是 NULL 参与比较时会产生未知结果。本文使用 PostgreSQL 18 的普通标量表达式说明,示例均可在测试连接中只读执行;其他数据库的具体语法与特殊兼容设置需要另行核对。后文 tasks 与 blocked_tasks 是示例表名,假定已在测试库准备相应字段;不要把它们直接替换成未知生产表就运行。

SQL NULL 三值逻辑:为什么“不等于”会漏掉空值

AI生成概念配图:查询条件可能产生真、假和未知三种状态。仅辅助理解,不代表真实界面或实测结果。

先让未知结果变得可见

WITH sample(status) AS (
  VALUES ('done'::text), ('pending'), (NULL)
)
SELECT status,
       status <> 'done' AS not_done,
       (status <> 'done') IS UNKNOWN AS unknown_result
FROM sample;

普通比较的一方为 NULL 时,结果通常为 NULL,逻辑上表示 UNKNOWN。WHERE 只保留条件为 TRUE 的行,FALSE 和 UNKNOWN 都不会通过。把条件外面加 NOT 也不会把未知自动变成真,所以“不是符合某条件”与“包括所有无法确认的记录”不是同一项业务要求。

不要写 status = NULL 来找空值,应使用 IS NULL。这里的 NULL 也不等于空字符串、零或文本 "NULL";这些都是实际值。导入数据时若把几者混在一起,后续筛选很难补救,因此字段含义、缺失策略和转换规则应在入口就保持一致。

把是否包含未知写进条件

-- 未完成,并且把未知状态也纳入
SELECT id, status
FROM tasks
WHERE status <> 'done' OR status IS NULL;

-- PostgreSQL 的空值安全比较
SELECT id, status
FROM tasks
WHERE status IS DISTINCT FROM 'done';

两种写法在这个标量场景下表达相同意图。IS DISTINCT FROM 把空值比较纳入确定的真或假结果,适合检测新旧值是否不同;反向的 IS NOT DISTINCT FROM 可用于需要把两个 NULL 视作相同的比较。是否这样解释缺失值,仍然是业务选择,不应该把它机械替换到所有连接条件中。

特别小心 NOT IN 的右侧空值

SELECT 3 NOT IN (1, 2, NULL) AS result;

SELECT t.id
FROM tasks AS t
WHERE NOT EXISTS (
  SELECT 1 FROM blocked_tasks AS b
  WHERE b.task_id = t.id
);

第一条得到未知,而不是直觉上的真。只要未找到相等项,右侧的 NULL 就可能让否定判断无法成立。第二条用 NOT EXISTS 表达“不存在相等的关联行”,避免右侧无关空值污染整项否定。它假定 t.id 是非空标识;若左侧也允许 NULL,必须明确是否要纳入这些行,不能声称两种写法在所有输入下完全等价。

对性能和正确性都更稳妥的做法,是先用小样本写出预期集合,再比较查询结果。不要为了让 NOT IN 看起来正确就随意过滤业务上有意义的空值;如果字段根本不应为空,应在确认现有数据后通过表结构约束解决,而不是只在一条查询里掩盖问题。

约束与外连接也遵循各自的规则

PostgreSQL 的 CHECK 约束在表达式为真或未知时都可以通过。因此 CHECK(price > 0) 不能单独禁止 price 为 NULL,还需要按业务要求添加 NOT NULL。与之相反,WHERE 会丢弃未知。把查询筛选经验直接套到写入约束上,是容易漏掉无效数据的原因。

LEFT JOIN 后在 WHERE 中筛右表字段,也可能把补出的 NULL 行排除,使结果不再包含未匹配的左表行。是否把限制放进 ON,取决于你是在限制匹配对象,还是筛选最终结果。先分别验证有匹配、无匹配和匹配值为空的三类样本,再调整位置。

最终应把每个可空字段的缺失含义写清楚,并为普通比较、空值安全比较、否定集合和外连接准备回归样本。理解 UNKNOWN 的去向后,SQL 的行为就可以被准确解释,而不必靠不断添加 COALESCE 来碰运气。

参考资料

文章版权声明:除非注明,否则均为云鹊BLOG原创文章,转载或复制请以超链接形式并注明出处。