我用 NOT IN 子查询过滤数据,结果返回了空集、明明该有很多行,还有个 != 查询莫名其妙漏了一批数据,我对着 SQL 里 NULL 的三值逻辑排查了大半天的复盘

两段普通 SQL:一段 NOT IN 子查询排除用户,一段 WHERE status != active 查非活跃用户。结果诡异:NOT IN 那段明明该返回一大批却返回空集;!= 那段查出的非活跃用户竟漏掉了一批 status 为 NULL 的(它们明明也不是 active)。盯着 SQL 反复看逻辑没毛病甚至怀疑是数据库 bug,排查大半天才理解让无数人栽跟头的概念——NULL 的三值逻辑:NULL 代表未知不是空值,任何和 NULL 的比较结果都是 unknown(不是true/false),SQL 是三值逻辑(true/false/unknown)而 WHERE 只返回结果为 true 的行;NOT IN(1,2,NULL) = id!=1 AND id!=2 AND id!=NULL,而 id!=NULL 永远 unknown 整个永不为 true 故返空;status!=active 当 status 是 NULL 时是 unknown 被漏掉。这篇从 NULL 与三值逻辑、NOT IN 改 NOT EXISTS/比较显式加 OR col IS NULL/判 NULL 用 IS NULL/COALESCE 给默认的正解、NULL 其他陷阱(=NULL判断/聚合忽略NULL/算术变NULL/唯一索引多NULL/JOIN不匹配)、NULL 行为速查、要不要用 NULL 的设计权衡、决策图与铁律、亲手验证 NULL 三值逻辑的 SQL,到延伸:为何 SQL 要设计三值逻辑(诚实处理未知数据的必然代价)。核心领悟:不同系统建立在不同基础逻辑公理上,以为放之四海皆准的直觉(两值逻辑)在 SQL 三值逻辑里不成立,别用旧世界规则推理新世界;反直觉麻烦的设计背后常是诚实面对被忽略的复杂现实,理解为什么才能真正掌握。

我用 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 = 1NULL = 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 时,刻进骨子里的几条铁律:

  1. NULL 代表"未知",不是空值。任何和它的比较结果都是 unknown,不是 true/false。
  2. WHERE 只返回结果为 true 的行。涉及 NULL 的比较(unknown)那些行会被悄悄漏掉。
  3. NOT IN 含 NULL 返回空集。改用 NOT EXISTS,或子查询里过滤掉 NULL。
  4. != / = 比较会漏 NULL 行。显式 OR col IS NULL,或用 COALESCE。
  5. 判断 NULL 用 IS NULL,别用 = NULL。= NULL 永远返回空。
  6. 算术/聚合注意 NULL。NULL 参与算术变 NULL,聚合忽略 NULL,用 COALESCE 兜底。
  7. 测试要用含 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
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

我自定义对象重写了 equals 判断相等,放进 HashSet 却还是有重复、用它当 HashMap 的 key 也取不到值,我对着 equals 和 hashCode 排查了大半天的复盘

2026-6-2 8:46:11

技术教程

我发布了前端新版本,可一大批用户死活还是旧页面、改的 bug 在他们那儿没修复,我对着 HTTP 缓存的 Cache-Control 排查了大半天的复盘

2026-6-2 8:59:13

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索