想从候选表里排除已经处理的编号,NOT IN 很像一句自然语言:“编号不在排除表里,就留下。”但如果排除表多了一条 NULL,结果可能突然变成空集。我用 SQLite 内存数据库做了一个小实验:没有删除任何记录,漏掉的是查询结果。
一条 NULL,怎样让排除查询返回空集
CREATE TABLE candidates(id INTEGER); CREATE TABLE excluded(id INTEGER); INSERT INTO candidates VALUES (1), (2), (3), (NULL); INSERT INTO excluded VALUES (2), (NULL); SELECT id FROM candidates WHERE id NOT IN (SELECT id FROM excluded) ORDER BY id;
在本次 SQLite 3.53.1 实验中,这条查询返回零行。为了分清“查不到”与“数据没了”,所有操作都只在内存测试库进行;候选仍有 1、2、3 和一个空值。
我再检查单个表达式:1 NOT IN (2, NULL) 得到 NULL,2 NOT IN (2, NULL) 得到 0,而 1 NOT IN (2) 得到 1。SQLite 官方结果矩阵说明:左值非空、没有匹配项、右侧非空且含 NULL 时,NOT IN 的结果是 NULL,而不是 true。[2] 因此,不能把“没找到相等的已知编号”直接理解成“确定不在集合中”。
第一种修正:明确排除表不使用空值
SELECT id FROM candidates
WHERE id NOT IN (
SELECT id FROM excluded WHERE id IS NOT NULL
)
ORDER BY id;
这次得到 1 和 3。它适用于排除表里的 NULL 只是缺失编号、不应参与匹配的情况。注意:左侧候选的 NULL 仍未进入结果。过滤右侧,不等于已经回答了左侧空值该保留还是剔除。
第二种修正:按“是否存在匹配行”表达
SELECT c.id FROM candidates AS c
WHERE NOT EXISTS (
SELECT 1 FROM excluded AS e WHERE e.id = c.id
)
ORDER BY c.id;
这次得到 NULL、1、3。排除表的 NULL 没有挡住普通编号,左侧 NULL 也被保留了。官方文档说明 EXISTS 根据子查询有无返回行得到 0 或 1;返回行里的具体值不决定 EXISTS 本身的结果。[2] 但这里是否存在行,仍取决于我们写的相等条件。
若业务只接受有效的非空编号,再加 c.id IS NOT NULL AND,实测结果便是 1 和 3。反过来,若业务希望两边空值也算同一种匹配,就需要单独定义空值匹配规则,不能把上面的普通等号当成已经完成的方案。
我不会机械地把所有 NOT IN 换掉
这次实验的价值,是把三件事分开:右侧是否允许空值、左侧空值如何处理、业务需要的是集合排除还是不存在匹配行。两个写法在普通非空编号上可能给出相同结果,在边界输入上却不一定相同。
我还测了空的排除子查询:SQLite 的 NOT IN 返回全部候选,包括左侧 NULL。这是本次验证的重要反例——“左侧空值永远不会被 NOT IN 保留”同样是过强结论。[2] 本文只讨论 SQLite 及实际运行的查询,不把它外推为所有数据库的全部语法和性能表现。
最小回归集:不要只测两张干净的表
- 正常编号有匹配项:应被排除。
- 正常编号没有匹配项:应被保留。
- 排除表含 NULL:核对是否导致非空编号从结果中漏掉。
- 候选本身含 NULL:按业务要求核对保留与剔除。
- 排除子查询为空:单独核对结果,不复用非空集合的直觉。
先确定这些边界的预期,再选择 SQL。我不把 NOT EXISTS 当成通用性能优化,也没有做线上吞吐测试。一个排除查询是否正确,首先取决于它是否忠实表达了缺失值的业务含义。
本文由作者组织的小型实验与 AI 辅助整理而成。实验用 Python 3.14.7 调用 SQLite 3.53.1,全部为合成数据;官方文档核对于 2026 年 10 月 13 日,不对应生产数据修复。
一手资料
SQLite 官方文档:IN、NOT IN 与 EXISTS。文档未在所用段落提供独立发布日期,本文记录核对日,不把核对日当作规则发布时间。