我用 NOT IN 子查询过滤数据,结果返回了空集、明明该有很多行,还有个 != 查询莫名其妙漏了一批数据,我对着 SQL 里 NULL 的三值逻辑排查了大半天的复盘
那是我写的两段再普通不过的 SQL。一段用 NOT IN (子查询) 来"排除某些用户",另一段用 WHERE status != 'active' 来"查出非活跃用户"。逻辑读起来天经地义。可结果诡异极了:NOT IN 那段,明明应该返回一大批用户,却返回了空集(一行都没有);而 != 那段,查出来的"非活跃用户",竟然漏掉了一批 status 为 NULL 的用户(它们明明也不是 'active' 啊)。我对着 SQL 反复看,逻辑没有任何问题。我甚至怀疑是数据库的 bug。排查了大半天,我才真正理解了 SQL 里那个让无数人栽过跟头的概念:NULL 的三值逻辑(three-valued logic)。这篇就把这场"NULL 让 SQL 结果反直觉"的事故,从头复盘一遍。
故障现场:NOT IN 返回空集,!= 漏掉 NULL 行
先看现场。两个诡异的结果,根子都在 NULL:
-- 坑1: NOT IN 子查询, 子查询结果里含 NULL → 整个返回空集!
SELECT * FROM users
WHERE id NOT IN (SELECT blocked_id FROM blacklist);
-- 如果 blacklist.blocked_id 这一列里【有 NULL】, 整个查询返回【空集】! ✗
-- (明明应该返回"不在黑名单里的所有用户")
-- 坑2: != / <> 比较, 漏掉了值为 NULL 的行
SELECT * FROM users WHERE status != 'active';
-- 这个查询【不会返回 status 为 NULL 的行】! ✗
-- (status 为 NULL 的用户, 明明也"不是 active", 却被漏掉了)
-- 为什么? SQL 的 NULL 不是普通的值, 它代表"未知(unknown)":
-- 1. NULL 表示"未知/缺失", 不是"空"也不是"0"或""。
-- 2. 任何和 NULL 的比较, 结果都是【UNKNOWN(未知)】, 不是 true 也不是 false:
-- NULL = 1 → UNKNOWN
-- NULL = NULL → UNKNOWN (! NULL 不等于它自己!)
-- NULL != 'active' → UNKNOWN
-- 1 > NULL → UNKNOWN
-- 3. SQL 是"三值逻辑": true / false / unknown。
-- WHERE 只返回结果为 【true】 的行; unknown 和 false 都【不返回】!
-- 解释坑1(NOT IN含NULL):
-- id NOT IN (1, 2, NULL) 等价于: id!=1 AND id!=2 AND id!=NULL
-- 而 id!=NULL 永远是 UNKNOWN → 整个 AND 结果永远不可能是 true
-- → 所以每一行都不满足, 返回空集!
-- 解释坑2(!=漏NULL):
-- status != 'active', 当 status 是 NULL 时: NULL != 'active' → UNKNOWN
-- → WHERE 不返回 unknown 的行 → status为NULL的行被漏掉!
-- 现象拼图:
-- - NULL 代表"未知", 任何和它的比较结果都是 UNKNOWN(不是true/false)。
-- - SQL 是三值逻辑, WHERE 只返回结果为 true 的行(unknown/false 都不返回)。
-- - NOT IN 含 NULL → 条件永远不可能为true → 空集。
-- - != / = / > 等比较涉及 NULL → 结果 unknown → 那些行被漏掉。
-- - ★ 根因: 我把 NULL 当成了"一个普通的值"去比较, 但它是"未知",
-- 和它比较的结果是 unknown, 而 WHERE 会过滤掉 unknown 的行。
看清真相后,我才明白这两个诡异结果的根子,都在 NULL。问题的根源,是 SQL 的 NULL 不是普通的值,它代表"未知(unknown)"。任何和 NULL 的比较,结果都是 UNKNOWN(不是 true 也不是 false):NULL = 1、NULL = NULL(NULL 不等于它自己!)、NULL != 'active' 全是 UNKNOWN;SQL 是"三值逻辑"(true/false/unknown),而 WHERE 只返回结果为 true 的行,unknown 和 false 都不返回。所以:坑 1(NOT IN 含 NULL):id NOT IN (1,2,NULL) 等价于 id!=1 AND id!=2 AND id!=NULL,而 id!=NULL 永远是 UNKNOWN,整个 AND 永远不可能为 true,所以每行都不满足、返回空集;坑 2(!= 漏 NULL):status != 'active' 当 status 是 NULL 时是 NULL != 'active' → UNKNOWN,被 WHERE 过滤掉。根因是:我把 NULL 当成了"一个普通的值"去比较,但它是"未知",和它比较的结果是 unknown,而 WHERE 会过滤掉 unknown 的行。
第一件事:搞懂 NULL 的三值逻辑
要解决它,得先彻底搞懂 SQL 的 NULL 和它带来的三值逻辑。
SQL 的 NULL 与三值逻辑
# 一、NULL 是什么? —— "未知/缺失", 不是"空值"
# - NULL 表示"这个值未知或不存在", 它【不是】0、不是空字符串""、不是false。
# - 你不能说"两个未知的东西相等", 所以 NULL = NULL 也是"未知"。
# 二、三值逻辑: true / false / unknown
# - 普通编程是"两值逻辑"(true/false), 但 SQL 是"三值逻辑", 多了 unknown。
# - 任何涉及 NULL 的比较运算, 结果都是 unknown:
# NULL = x, NULL != x, NULL > x, NULL = NULL ... 全是 unknown。
# - 逻辑运算里 unknown 的传播:
# true AND unknown = unknown; false AND unknown = false
# true OR unknown = true; false OR unknown = unknown
# NOT unknown = unknown
# 三、关键: WHERE/JOIN 的 ON 只保留结果为 true 的行!
# - WHERE 条件结果是 unknown 或 false 的行, 都【不会被返回】。
# - 所以: 涉及 NULL 的比较(结果unknown)→ 那些行被悄悄过滤掉。
# 四、几个最坑的具体表现:
# 1. NOT IN (含NULL的集合) → 永远返回空(本文坑1)。
# 2. != / <> / = / > / < 比较, 涉及NULL的行被漏掉(本文坑2)。
# 3. NULL = NULL 是 unknown(不是true)→ 不能用 = 判断是否为NULL!
# 4. 聚合函数(SUM/AVG/COUNT(列))会【忽略NULL】(但COUNT(*)算所有行)。
# 5. NULL 参与算术运算 → 结果是NULL(NULL + 1 = NULL)。
# 五、怎么正确判断 NULL?
# - 判断是不是NULL: 用 IS NULL / IS NOT NULL(不能用 = NULL / != NULL!)。
# - WHERE col = NULL ✗ 永远不返回任何行(因为 = NULL 是 unknown)。
# - WHERE col IS NULL ✓ 正确判断 col 为 NULL 的行。
# 核心: NULL代表"未知"非空值; 涉及NULL的比较结果都是unknown(三值逻辑), WHERE只返回true的行
# 故unknown行被漏; NOT IN含NULL返回空、!=漏NULL行; 判NULL要用IS NULL不能用=NULL。
想透 NULL 的三值逻辑,这两个坑就清楚了。一、NULL 是什么?——"未知/缺失",不是 0、不是空字符串、不是 false;你不能说"两个未知的东西相等",所以 NULL = NULL 也是"未知"。二、三值逻辑:true/false/unknown——普通编程是两值逻辑,SQL 多了 unknown;任何涉及 NULL 的比较结果都是 unknown。三、关键:WHERE/JOIN 的 ON 只保留结果为 true 的行——结果是 unknown 或 false 的行都不返回,所以涉及 NULL 的比较(结果 unknown)那些行被悄悄过滤掉。四、几个最坑的表现:NOT IN 含 NULL 返回空(坑1)、比较涉及 NULL 的行被漏(坑2)、NULL = NULL 是 unknown(不能用 = 判 NULL)、聚合函数忽略 NULL、NULL 参与算术结果是 NULL。五、怎么正确判断 NULL?——用 IS NULL/IS NOT NULL(不能用 = NULL/!= NULL);WHERE col = NULL 永远不返回任何行,WHERE col IS NULL 才对。
第二件事:正解——用 NOT EXISTS、显式处理 NULL、IS NULL
搞懂了原理,正解就清晰了:NOT IN 改用 NOT EXISTS、比较时显式处理 NULL、判断 NULL 用 IS NULL、用 COALESCE 给默认值。
-- ====== 正解一: NOT IN 改用 NOT EXISTS(不受NULL影响)======
-- ✗ NOT IN 含NULL返回空:
-- SELECT * FROM users WHERE id NOT IN (SELECT blocked_id FROM blacklist);
-- ✓ 改用 NOT EXISTS:
SELECT * FROM users u
WHERE NOT EXISTS (
SELECT 1 FROM blacklist b WHERE b.blocked_id = u.id
);
-- → NOT EXISTS 是"存在性判断", 不受子查询里NULL的影响, 行为符合直觉。
-- (或者: 在子查询里过滤掉NULL: WHERE blocked_id IS NOT NULL)
-- ====== 正解二: != 比较要显式带上 NULL 的情况 ======
-- ✗ 漏掉 status 为 NULL 的行:
-- SELECT * FROM users WHERE status != 'active';
-- ✓ 显式把 NULL 也算进来:
SELECT * FROM users
WHERE status != 'active' OR status IS NULL;
-- → 如果你想要"非active(包括没填status的)", 必须显式 OR status IS NULL。
-- ====== 正解三: 判断 NULL 用 IS NULL / IS NOT NULL ======
SELECT * FROM users WHERE deleted_at IS NULL; -- ✓ 查未删除的
SELECT * FROM users WHERE deleted_at IS NOT NULL; -- ✓ 查已删除的
-- ✗ WHERE deleted_at = NULL 永远返回空! 别这么写。
-- ====== 正解四: 用 COALESCE / IFNULL 给 NULL 一个默认值 ======
-- COALESCE(col, 默认值): 如果col是NULL, 就用默认值。
SELECT * FROM users WHERE COALESCE(status, 'unknown') != 'active';
-- → 把NULL先变成'unknown', 再比较, NULL的行就不会被漏掉。
-- 算术也一样: SELECT price + COALESCE(discount, 0) ... -- 防 +NULL 变NULL
-- ====== 正解五: 从设计上减少 NULL ======
-- - 能给默认值的列, 设 NOT NULL DEFAULT(如 status DEFAULT 'inactive')。
-- → 列里没有NULL, 就不会有这些坑。
-- - 但: 有些"确实未知/不适用"的语义, NULL 是合理的, 别强行避免。
-- - 权衡: 减少"无意义的NULL", 保留"真正表示未知的NULL", 并在查询时小心处理。
-- ====== 正解六: 排序时 NULL 的位置 ======
-- ORDER BY 时, NULL 的排序位置因数据库而异(MySQL: NULL最小排最前)。
-- 想控制: ORDER BY col IS NULL, col (把NULL排最后), 或用 NULLS FIRST/LAST(PG)。
-- 核心: NOT IN改用NOT EXISTS(不受NULL影响)、!=比较显式加OR col IS NULL、判NULL用IS NULL、
-- 用COALESCE给NULL默认值再比较/运算; 设计上能NOT NULL DEFAULT就设, 减少无意义的NULL。
修复的核心,是"显式地、正确地处理 NULL,而不是把它当普通值"。正解一:NOT IN 改用 NOT EXISTS——NOT EXISTS 是"存在性判断",不受子查询里 NULL 的影响、行为符合直觉(或在子查询里 WHERE blocked_id IS NOT NULL 过滤掉 NULL)。正解二:!= 比较要显式带上 NULL 的情况——想要"非 active(包括没填的)",必须 status != 'active' OR status IS NULL。正解三:判断 NULL 用 IS NULL/IS NOT NULL(= NULL 永远返回空)。正解四:用 COALESCE/IFNULL 给 NULL 默认值——COALESCE(status, 'unknown') != 'active' 把 NULL 先变成 'unknown' 再比较,NULL 行就不会被漏;算术也一样(price + COALESCE(discount, 0) 防 +NULL 变 NULL)。正解五:从设计上减少 NULL——能给默认值的列设 NOT NULL DEFAULT,但有些"确实未知/不适用"的语义 NULL 是合理的、别强行避免。正解六:排序时 NULL 的位置因库而异(可用 ORDER BY col IS NULL, col 控制)。归根结底:NOT IN 改 NOT EXISTS、!= 显式加 OR col IS NULL、判 NULL 用 IS NULL、用 COALESCE 给默认值;设计上能 NOT NULL DEFAULT 就设。
第三件事:NULL 引发的其他常见陷阱
排查后我把 NULL 引发的其他常见陷阱也系统梳理了一遍,它们一样隐蔽。
NULL 引发的其他常见陷阱
# 1. NOT IN 含NULL返回空(本文)。→ 用 NOT EXISTS。
# 2. != / = / > 等比较漏掉NULL行(本文)。→ 显式 OR col IS NULL, 或 COALESCE。
# 3. 用 = NULL 判断(永远返回空)。→ 用 IS NULL。
# 4. 聚合函数忽略NULL:
# - COUNT(列): 不算NULL的行; COUNT(*): 算所有行(包括NULL)。
# - AVG(列): 是"非NULL值的平均", 不是"除以总行数"! 容易算错平均。
# - SUM(列): 忽略NULL(全NULL时返回NULL不是0!)。
# 5. NULL 参与算术/拼接 → 结果是NULL:
# NULL + 1 = NULL; CONCAT('a', NULL) 在某些库=NULL(整个变NULL)。
# → 用 COALESCE 兜底。
# 6. 唯一索引允许多个NULL:
# - 大多数库(MySQL)的唯一索引, 允许有多行该列为NULL(NULL不算重复)。
# → 想"该列唯一且不能为空", 要 NOT NULL + UNIQUE。
# 7. JOIN 时 NULL 不匹配:
# a.x = b.x 当x为NULL时不匹配 → 该行在inner join里丢失。
# 8. DISTINCT 和 GROUP BY 里, 多个NULL被当成"同一组":
# (这里NULL又被当成相等了, 和比较时的行为不一致, 容易混)
# 核心: NULL的坑遍布 NOT IN/比较/=NULL判断/聚合忽略NULL/算术拼接变NULL/唯一索引多NULL/
# JOIN不匹配/分组当同组; 处理NULL要用 IS NULL/NOT EXISTS/COALESCE, 时刻记得它是"未知"。
排查让我把 NULL 的其他坑也梳理清了。一、NOT IN 含 NULL 返回空(本文)。二、比较漏掉 NULL 行(本文)。三、用 = NULL 判断(永远返回空)。四、聚合函数忽略 NULL——COUNT(列) 不算 NULL 行、AVG(列) 是"非 NULL 值的平均"(容易算错)、SUM 全 NULL 时返回 NULL 不是 0。五、NULL 参与算术/拼接结果是 NULL(NULL + 1 = NULL)。六、唯一索引允许多个 NULL(NULL 不算重复,想唯一且非空要 NOT NULL + UNIQUE)。七、JOIN 时 NULL 不匹配(该行在 inner join 里丢失)。八、DISTINCT/GROUP BY 里多个 NULL 被当成同一组(这里 NULL 又被当相等,和比较时不一致、易混)。它们的核心是:处理 NULL 要用 IS NULL/NOT EXISTS/COALESCE,时刻记得它是"未知"。下面这张图,是这次 NULL 让 SQL 反直觉的成因与解法:
第四件事:NULL 行为速查
这次踩坑后,我把 NULL 在各种操作下的行为整理成一张表,写 SQL 时对照着想。
| 操作 | NULL 的行为 | 正确做法 |
|---|---|---|
| = NULL / != NULL | 永远 unknown(不返回行) | 用 IS NULL / IS NOT NULL |
| NULL = NULL | unknown(不是 true) | NULL 不等于自己,别用 = 判 |
| NOT IN (含NULL) | 返回空集 | 用 NOT EXISTS |
| != 'x' | 漏掉 NULL 行 | OR col IS NULL / COALESCE |
| NULL + 1 / 算术 | 结果 NULL | COALESCE(col, 0) |
| COUNT(列) / SUM / AVG | 忽略 NULL | 注意 AVG/SUM 算错,按需 COALESCE |
| JOIN ON a.x=b.x | NULL 不匹配 | 需要时显式处理 NULL |
| 唯一索引 | 允许多个 NULL | 要唯一非空用 NOT NULL+UNIQUE |
这张表,把 NULL 在各种操作下的行为一网打尽了。它给我的最大启发是:NULL 的行为之所以让人晕,是因为它在不同场景下"表现不一致":比较时它"谁都不等于(包括自己)",但在 GROUP BY/DISTINCT 里多个 NULL 又被当成"同一组(相等)";聚合时它被"忽略",算术时它又"传染(结果变 NULL)"。这种"因场景而异、甚至自相矛盾"的行为,正是 NULL 难掌握的原因。但归根结底,它们都源于一个统一的解释:NULL 代表"未知"——"未知"和谁比较都是"未知"(所以比较时谁都不等),"未知"参与计算结果还是"未知"(所以算术传染),而聚合/分组时则是出于实用考虑做了特殊处理。它给我的启发是:理解一个"行为复杂多变"的东西,关键是抓住它"背后那个统一的本质"(NULL = 未知);一旦抓住本质,那些看似零散、矛盾的行为,大部分就能从本质推导出来、不必死记。从本质出发理解、而非死记一堆特例——这是掌握 NULL(以及任何复杂概念)的正道。
第五件事:要不要用 NULL?设计上的权衡
这次也让我重新思考"该不该用 NULL"。我把它的权衡整理了一下。
| 场景 | 用 NULL | 用 NOT NULL + 默认值 |
|---|---|---|
| "确实未知/不适用" | ✓ 合理(NULL 表达"未知"很贴切) | △ 用默认值可能误导 |
| 有合理默认值的 | △ 易引入NULL坑 | ✓ 推荐(如 status DEFAULT) |
| 计数/金额等数值 | △ 算术会变NULL | ✓ DEFAULT 0 更安全 |
| 布尔标志 | △ 三态(true/false/NULL)易乱 | ✓ DEFAULT false |
| 外键/可选关联 | ✓ NULL表示"无关联"合理 | — |
这张表,帮我理清了"该不该用 NULL"。核心原则:"确实表示未知/不适用/无关联"的语义,用 NULL 是贴切的(这正是 NULL 存在的意义);而"有合理默认值"的(计数、金额、布尔标志、状态),用 NOT NULL DEFAULT 更安全(避免 NULL 的一堆坑)。它给我的最大启发是:NULL 本身不是"坏东西"——它是一个用来表达"未知/缺失"这种真实语义的、有价值的工具;问题在于"滥用"——把本该有默认值的列也设成可 NULL,就引入了不必要的复杂和坑。这让我领悟到一个数据建模(乃至 API 设计)的原则:"是否允许某个值为'空/未知'(NULL/null/可选)",是一个重要的设计决策,要根据"这个字段的真实语义"来定,而不是随手就允许它为空;每允许一处"可空",就引入了一种"需要处理的额外状态(空的情况)"和相应的复杂度。所以,设计时要克制地、有意识地决定"哪里真的需要可空、哪里应该给个明确的默认值不允许空"——用 NULL 表达真正的"未知",而不是用它来逃避"想清楚默认值"——这是减少 NULL 相关坑、也让数据语义更清晰的关键。
第六件事:写涉及可空列的 SQL 时,我现在的习惯
现在每当我写涉及"可能为 NULL 的列"的 SQL,我都会按这张图先想清楚 NULL 的处理:
这张图的精髓,是"写 SQL 时,先确认列会不会为 NULL,再针对性处理"。第一问 "这列可能为 NULL 吗":NOT NULL 的正常写;可能为 NULL 的针对操作处理。按操作:NOT IN 改 NOT EXISTS、比较显式 OR col IS NULL 或 COALESCE、判 NULL 用 IS NULL、算术用 COALESCE 防变 NULL、聚合注意忽略 NULL。最后一步是我现在的硬习惯:用含 NULL 的测试数据跑一下,验证结果对不对(这次的坑正是因为测试数据里没有 NULL、没暴露)。这套习惯,让我写 SQL 时,从"把可空列当普通列写"变成了"先确认 NULL、针对性处理、用含 NULL 数据验证"——核心始终是:NULL 是未知、比较结果是 unknown,涉及可空列的 SQL 要显式处理 NULL,并用含 NULL 的数据测试。
我立下的几条规矩
这场"NULL 让 SQL 反直觉"的事故,换来了我写 SQL 时,刻进骨子里的几条铁律:
- NULL 代表"未知",不是空值。任何和它的比较结果都是 unknown,不是 true/false。
- WHERE 只返回结果为 true 的行。涉及 NULL 的比较(unknown)那些行会被悄悄漏掉。
- NOT IN 含 NULL 返回空集。改用 NOT EXISTS,或子查询里过滤掉 NULL。
- != / = 比较会漏 NULL 行。显式 OR col IS NULL,或用 COALESCE。
- 判断 NULL 用 IS NULL,别用 = NULL。= NULL 永远返回空。
- 算术/聚合注意 NULL。NULL 参与算术变 NULL,聚合忽略 NULL,用 COALESCE 兜底。
- 测试要用含 NULL 的数据。别只测有值的,要测 NULL 的情况。
附:一组亲手验证 NULL 三值逻辑的 SQL
口说无凭。下面这组 SQL,把 NULL 各种反直觉的行为,一一查出来,在数据库里跑一遍胜过看十遍:
-- ====== 实验1: NULL 的比较都是 unknown(查不出 true)======
SELECT NULL = NULL; -- NULL (不是1/true! NULL不等于自己)
SELECT NULL = 1; -- NULL
SELECT NULL != 1; -- NULL
SELECT 1 = 1; -- 1 (true)
-- → 凡涉及NULL的比较, 结果都是NULL(代表unknown), 不是true/false。
-- ====== 实验2: WHERE 只返回结果为 true 的行 ======
-- 建个测试表
CREATE TABLE t (id INT, status VARCHAR(10));
INSERT INTO t VALUES (1, 'active'), (2, 'inactive'), (3, NULL);
SELECT * FROM t WHERE status != 'active';
-- 只返回 id=2(inactive)! id=3(NULL)被漏掉! (NULL != 'active' 是unknown)
SELECT * FROM t WHERE status != 'active' OR status IS NULL;
-- ✓ 返回 id=2 和 id=3 (显式带上NULL)
-- ====== 实验3: NOT IN 含 NULL 返回空 ======
SELECT * FROM t WHERE id NOT IN (1, NULL);
-- 返回【空集】! (id NOT IN (1,NULL) = id!=1 AND id!=NULL, 后者永远unknown)
SELECT * FROM t WHERE NOT EXISTS (
SELECT 1 FROM (SELECT 1 AS x UNION SELECT NULL) v WHERE v.x = t.id
);
-- ✓ NOT EXISTS 不受NULL影响, 返回 id=2,3
-- ====== 实验4: = NULL vs IS NULL ======
SELECT * FROM t WHERE status = NULL; -- 空集! (= NULL 永远unknown)
SELECT * FROM t WHERE status IS NULL; -- ✓ 返回 id=3
-- ====== 实验5: 聚合忽略 NULL ======
SELECT COUNT(*), COUNT(status) FROM t; -- 3, 2 (COUNT(*)算所有, COUNT(列)忽略NULL)
SELECT AVG(id) FROM t; -- 2 (3行id的平均, id无NULL)
-- 若某列全是NULL: SELECT SUM(全NULL列) → NULL (不是0!)
-- 清理
DROP TABLE t;
-- 核心: 跑这组SQL亲眼看 NULL=NULL是NULL、!=漏NULL行、NOT IN含NULL返回空、=NULL查不出、
-- 聚合忽略NULL; 这些反直觉行为, 在数据库里亲手查一遍, 就再也不会搞错了。
这组 SQL,把"NULL 的三值逻辑"这些抽象、反直觉的规则,变成了可以在数据库里亲手查、亲眼看的结果。它覆盖了 NULL 最坑人的几个行为:NULL = NULL 返回 NULL(不是 true)、!= 漏掉 NULL 行、NOT IN 含 NULL 返回空集、= NULL 查不出、聚合函数忽略 NULL;每一条都配了"错误写法的结果"和"正确写法的结果"的对比。跑一遍,这些"说起来绕、容易搞错"的规则,就变成了你亲眼见过的确凿事实。这,正是我想用这组 SQL,留给每个写 SQL 的人的最后一课:SQL 是一门"声明式、且有自己独特逻辑(三值逻辑)"的语言,它的很多行为(尤其 NULL 相关)无法靠"编程直觉"想当然,而必须通过"在真实数据库里查一查、看看实际返回什么"来建立准确的认知。而且,SQL 还特别适合这种"即时验证"——你可以随手写个 SELECT,在几秒内就看到任何一个表达式的真实结果(SELECT NULL = NULL; 直接告诉你答案),这是验证 SQL 行为最便捷的方式。所以,每当我对一段 SQL 的行为(尤其涉及 NULL、JOIN、聚合、子查询)拿不准时,我都会构造一点小数据、把这段逻辑在数据库里实际跑一遍,看真实结果,而不是凭直觉假设。这,也是我整个系列复盘反复强调的方法,在 SQL 领域的体现:对任何拿不准的、反直觉的行为,别猜,在真实环境里跑一遍,让结果告诉你答案。尤其对 SQL 的 NULL——它的逻辑和你的编程直觉不一样,唯有亲手验证,才能避开那些悄悄漏数据、悄悄返回空集的坑。
延伸:为什么 SQL 要设计三值逻辑这个"麻烦"
解决完这个问题,我也好奇地想:SQL 为什么要搞出"三值逻辑"这个看起来如此麻烦、如此坑人的设计?它就不能像普通编程一样只有 true/false 吗?深究之后,我对它多了一份理解,甚至是敬意。问题的根源,在于数据库要诚实地面对一个现实:现实世界的数据,常常是"缺失的、未知的"——一个用户没填生日、一笔订单还没确定收货地址、一个传感器某个时刻没采到数据。对于这些"确实不知道"的情况,数据库需要一种方式来诚实地表达"我这里没有这个值/我不知道",而不是用一个"假的默认值"(比如用 0 表示未知的年龄、用空字符串表示未知的地址)去糊弄——因为用假默认值会带来更隐蔽的错误(把"未知的年龄"当成 0 去算平均,结果就错了)。而一旦引入了"未知(NULL)"这个概念,"三值逻辑"就成了逻辑上的必然:因为"一个未知的值,是否等于 5?"这个问题,诚实的回答只能是"不知道(unknown)",而不能是 true 或 false(你都不知道那个值是多少,怎么能说它等于或不等于 5 呢?)。所以,三值逻辑不是 SQL 故意找麻烦,而是它"诚实地处理'未知数据'"所必须付出的代价——它把"我不知道"这个真实状态,如实地、贯穿始终地反映在了逻辑运算里。这让我领悟到一个更深的道理:很多看起来"反直觉、麻烦"的设计,背后其实是设计者在诚实地面对某个我们容易忽略的复杂现实(这里是"数据可能缺失");它的"麻烦",恰恰是为了不掩盖、不糊弄那个真实的复杂性。与其抱怨它麻烦,不如理解它"为什么不得不这样"——一旦理解了它背后那个"诚实地表达未知"的初衷,NULL 的种种行为,就从"莫名其妙的坑",变成了"合乎逻辑的、可以推导的规则"。理解设计背后的"为什么",是真正掌握它、而非被它坑的关键——这,也是我从这场 NULL 事故里,额外收获的一份对"设计意图"的体悟。
写在最后
回头看,这场由 SQL 的 NULL 引发的、查询结果反直觉的事故,真正教给我的,远不止"NOT IN 改 NOT EXISTS"这一套技巧。它让我对"不同系统有不同的'逻辑规则'"有了深刻的体会。我栽跟头,是因为我把日常编程的"两值逻辑"直觉(一个条件要么真要么假),带到了SQL 这个"三值逻辑"的世界里。在我熟悉的世界里,"不是 active"就涵盖了所有非 active 的情况;可在 SQL 里,因为有了"unknown"这第三种值,"status != 'active'"竟然不涵盖"status 未知(NULL)"的情况——这完全超出了我的两值逻辑直觉。这让我领悟到一个跨系统工作时极其重要的道理:不同的系统/语言/领域,可能建立在不同的"基础逻辑/公理"之上;而那些我们以为"放之四海皆准"的基本直觉(如两值逻辑),在一个采用了不同基础规则的系统里(如 SQL 的三值逻辑),可能根本不成立。SQL 引入"unknown",是为了诚实地处理"数据可能缺失/未知"这个现实问题,这本身是合理的设计;但它改变了"逻辑运算"这个最底层的规则,而我没意识到这个改变、还用旧规则去推理,自然就错了。所以,这件事给我的最大警示是:进入一个新的系统/领域时,要警觉地问一句"它的最基础的规则(数据类型、逻辑、运算)和我熟悉的一样吗?";尤其要留意那些"名字一样、规则却不同"的基础概念(SQL 的"比较"、它的"逻辑",和编程里的不完全一样);不能想当然地用旧世界的规则,去推理新世界的行为。尊重每个系统自己的基础规则、不盲目套用旧直觉——这,是我用一次"NULL 反直觉"的事故,换来的、关于数据库、也关于"不同系统的基础逻辑"的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次写涉及可空列的 SQL 时,想起"NULL 是未知、比较是三值逻辑",那我对着那个空集和漏掉的数据熬的这大半天,就值了。
—— 别看了 · 2026