我用不等于条件查"未完成"的订单,结果一批 status 为 NULL 的订单全被漏掉了、数量怎么都对不上,我对着 SQL 的三值逻辑排查了大半天的复盘
这是一个让我对 SQL 里 NULL 的"三值逻辑"刻骨铭心的故事。我有一张订单表,有个 status 字段。我要查"所有未完成的订单"(即 status 不是 'done' 的),就顺手写了 WHERE status != 'done'。逻辑再自然不过。可对账时,运营反馈数量对不上:明明有一批订单是"未完成"的,却没出现在我的查询结果里。我反复检查,发现所有被漏掉的订单,它们的 status 都是 NULL(那些还没来得及设置状态的新订单)。status != 'done' 这个条件,把 NULL 的行,统统漏掉了!可 NULL 明明不等于 'done' 啊,为什么 != 'done' 没把它们选出来?
我顺着这个矛盾深挖,才终于揭开真相,补上了我对 SQL 一个最根本、却极易被忽略的认知漏洞:问题的核心,是 SQL 里的 NULL,遵循的是"三值逻辑(three-valued logic)"。我一直想当然地以为,逻辑判断只有"真(true)"和"假(false)"两种结果;可在 SQL 里,因为 NULL 的存在,还有第三种结果:"未知(unknown)"。而 NULL 的语义,是"未知/不存在的值"——既然它"未知",那么拿它和任何值去比较(包括 =、!=、>、甚至 = NULL 自己),结果都不是 true、也不是 false,而是 unknown。具体到我的查询:NULL != 'done' 的结果,不是 true(那样它就该被选出来了),而是 unknown——因为"一个未知的值,等不等于 'done'?答案是未知"。而 WHERE 子句,只会选出那些条件结果为 true 的行;对于结果是 unknown(或 false)的行,一律排除。于是,我那些 status 为 NULL 的订单,因为 NULL != 'done' 是 unknown、不是 true,就被 WHERE 静默地、无声无息地过滤掉了。我这才痛彻地明白:SQL 的 NULL,不是一个普通的"值",而是一个"未知"的特殊标记;它会把逻辑判断,从"非真即假"的两值世界,拉进"真/假/未知"的三值世界;而任何涉及 NULL 的比较,都会陷入"unknown",并被 WHERE 当成"不满足"而排除。这意味着,凡是字段可能为 NULL 的地方,你的查询条件,都必须显式地、专门地考虑 NULL 这种情况(用 IS NULL),否则,那些 NULL 的行,就会以一种极其隐蔽的方式,被你的查询悄悄遗漏,导致数据"对不上、查不全"。
故障现场:!= 条件把 NULL 的行静默漏掉
我把这个"NULL 被漏掉"的现场,摊开给你看:
-- ✗ 灾难: != 条件把 status 为 NULL 的行静默漏掉
SELECT * FROM orders WHERE status != 'done';
-- ✗ status 为 NULL 的订单, 全都没被选出来!
-- 为什么? NULL 的三值逻辑:
-- NULL != 'done' → 结果是 unknown(不是 true!)
-- WHERE 只选条件为 true 的行 → unknown 被排除 → NULL 行漏掉。
-- 一组反直觉的比较(全是 unknown / NULL, 不是 true/false):
SELECT NULL = NULL; -- ✗ NULL(不是 true!两个未知不能说相等)
SELECT NULL != 'done'; -- ✗ NULL(unknown)
SELECT NULL = 'done'; -- ✗ NULL
SELECT NULL > 5; -- ✗ NULL
SELECT 1 = NULL; -- ✗ NULL —— 所以 WHERE col = NULL 永远查不到!
-- WHERE 的判定: 只保留结果为 TRUE 的行
-- TRUE → 保留
-- FALSE → 排除
-- UNKNOWN(NULL参与)→ 排除(! 这就是 NULL 行被漏掉的原因)
-- 更隐蔽的坑: NOT IN + 子查询里有 NULL → 整个结果为空!
SELECT * FROM a WHERE id NOT IN (SELECT bid FROM b);
-- ✗ 若 b.bid 里有一个 NULL, 这条查询会返回"空结果"!
-- 因为 id NOT IN (1, 2, NULL) → id != 1 AND id != 2 AND id != NULL
-- 而 id != NULL 是 unknown → 整个 AND 永远不为 true → 全排除。
-- 聚合也受影响:
-- COUNT(col) 不统计 NULL; COUNT(*) 统计所有行。
-- SUM/AVG 忽略 NULL(可能和你预期不同)。
-- 根因: NULL 是三值逻辑的"unknown", 与任何值比较都得 unknown(非true),
-- WHERE 排除非 true 的行, 所以 != / = / NOT IN 等会静默漏掉 NULL 行。
看着这一串"结果都是 NULL"的比较,我才算彻底想明白了根源。问题的核心,是 NULL 的三值逻辑:NULL != 'done' 的结果是 unknown、不是 true;而 WHERE 只选条件为 true 的行,所以 unknown 被排除、NULL 行漏掉。这套逻辑反直觉得很:NULL = NULL 不是 true 而是 NULL(两个未知不能说相等)、NULL != 'done' / NULL > 5 全是 NULL、col = NULL 永远查不到(所以判空不能用 = NULL)。WHERE 的判定规则是:TRUE 保留、FALSE 排除、UNKNOWN(NULL 参与)也排除——这就是 NULL 行被漏掉的原因。还有个更隐蔽的坑:NOT IN (子查询) 里只要有一个 NULL,整个查询就返回空结果!因为 id NOT IN (1,2,NULL) 等价于 id != 1 AND id != 2 AND id != NULL,而 id != NULL 是 unknown,整个 AND 永远不为 true、全被排除。聚合也受影响:COUNT(col) 不统计 NULL、SUM/AVG 忽略 NULL(可能和预期不同)。归根结底:NULL 是三值逻辑的 unknown,与任何值比较都得 unknown(非 true),WHERE 排除非 true 的行,所以 !=/=/NOT IN 会静默漏掉 NULL 行——这,就是根源。
第一件事:搞懂 NULL 与三值逻辑
定位到根源,我必须把 SQL 的 NULL 和三值逻辑从根上彻底搞清楚:
SQL 的 NULL = "未知"; 三值逻辑 true/false/unknown; WHERE 只留 true
# NULL 是什么?
# - 不是 0, 不是空字符串, 不是 false —— 它表示"未知 / 不存在的值"。
# - 既然"未知", 它和任何值的比较, 结果也"未知(unknown)"。
# 三值逻辑(关键!):
# - 普通语言: 条件只有 true / false。
# - SQL: 因为 NULL, 条件有 true / false / unknown 三种。
# - 任何 NULL 参与的比较(= != > < ...) → unknown。
# NULL = 5 → unknown; NULL = NULL → unknown(!); NULL != 'x' → unknown
# WHERE / ON / HAVING 的判定: 只保留结果为 TRUE 的行
# - unknown 和 false 一样, 都被排除。
# → 这就是"NULL 行被各种条件静默漏掉"的总根源。
# 三值逻辑下的 AND / OR(也反直觉):
# - TRUE AND UNKNOWN = UNKNOWN
# - FALSE AND UNKNOWN = FALSE
# - TRUE OR UNKNOWN = TRUE
# - NOT UNKNOWN = UNKNOWN
# 判空的正确姿势(只有这两个能判 NULL):
# - col IS NULL ✓ (别用 col = NULL, 永远 unknown)
# - col IS NOT NULL ✓
# 哪些地方会被 NULL 坑?
# - WHERE 用 = / != / > 等比较可空列 → 漏掉 NULL 行。
# - NOT IN (子查询含NULL) → 整个结果为空。
# - 唯一约束: 多数库允许多个 NULL(NULL != NULL, 不算重复)。
# - JOIN ON 条件含 NULL; 聚合 COUNT/SUM 忽略 NULL; ORDER BY 中 NULL 的位置。
# 关键认知: 只要列可能为 NULL, 写条件就必须显式处理 NULL。
# 核心: NULL 是"未知", 与任何值比较得 unknown(三值逻辑), WHERE 只留 true 故漏 NULL 行;
# 判空只能用 IS NULL/IS NOT NULL; 可空列写条件必须显式考虑 NULL。
原理终于清晰了。NULL 是什么?——不是 0、不是空字符串、不是 false,它表示"未知/不存在的值";既然"未知",它和任何值的比较,结果也"未知(unknown)"。这就引出了三值逻辑:普通语言条件只有 true/false;SQL 因为 NULL,条件有 true/false/unknown 三种;任何 NULL 参与的比较都是 unknown(NULL = 5、NULL = NULL、NULL != 'x' 全是 unknown)。而 WHERE/ON/HAVING 的判定是:只保留结果为 TRUE 的行,unknown 和 false 一样都被排除——这就是"NULL 行被各种条件静默漏掉"的总根源。三值逻辑下的 AND/OR 也反直觉(TRUE AND UNKNOWN = UNKNOWN、FALSE AND UNKNOWN = FALSE、TRUE OR UNKNOWN = TRUE)。判空的正确姿势:只有 col IS NULL / col IS NOT NULL 能判 NULL(别用 col = NULL,永远 unknown)。哪些地方会被 NULL 坑?WHERE 用比较运算符查可空列(漏 NULL 行)、NOT IN(子查询含 NULL → 结果为空)、唯一约束(多数库允许多个 NULL)、JOIN ON、聚合忽略 NULL、ORDER BY 中 NULL 的位置。由此,我刻下一个关键认知:只要列可能为 NULL,写条件就必须显式处理 NULL。归根结底:NULL 是"未知"、与任何值比较得 unknown(三值逻辑)、WHERE 只留 true 故漏 NULL 行;判空只能用 IS NULL/IS NOT NULL;可空列写条件必须显式考虑 NULL。
第二件事:正解——显式处理 NULL
搞懂了原理,正解就清晰了:凡是可空列的条件,都要显式地把 NULL 考虑进去(用 IS NULL、OR col IS NULL、COALESCE),NOT IN 改 NOT EXISTS。
-- ✓ 正解一: 条件里显式带上 NULL 的情况
SELECT * FROM orders
WHERE status != 'done' OR status IS NULL; -- ✓ 把 NULL 行也算进"未完成"
-- ✓ 正解二: 用 COALESCE 给 NULL 一个默认值再比较
SELECT * FROM orders
WHERE COALESCE(status, 'pending') != 'done'; -- ✓ NULL 当成 'pending', != 'done' 为 true
-- (注意: 这样可能用不上索引, 数据量大时优先用方案一)
-- ✓ 正解三: 判空一律用 IS NULL / IS NOT NULL
SELECT * FROM orders WHERE status IS NULL; -- ✓ 查 NULL
SELECT * FROM orders WHERE status IS NOT NULL; -- ✓ 查非 NULL
-- ✗ 永远别写 WHERE status = NULL(永远查不到任何行)
-- ✓ 正解四: NOT IN 改成 NOT EXISTS(避免子查询 NULL 导致全空)
-- ✗ 危险: SELECT * FROM a WHERE id NOT IN (SELECT bid FROM b); (b.bid 有NULL就全空)
-- ✓ 安全:
SELECT * FROM a
WHERE NOT EXISTS (SELECT 1 FROM b WHERE b.bid = a.id); -- ✓ NULL 不影响
-- ✓ 正解五: 从源头减少 NULL —— 字段设 NOT NULL + DEFAULT
CREATE TABLE orders (
status VARCHAR(20) NOT NULL DEFAULT 'pending' -- ✓ 不允许 NULL, 给默认值
);
-- → 没有 NULL, 就没有三值逻辑的坑(能不用 NULL 就别用)。
-- ✓ 正解六: 处理 NULL 的常用函数
-- COALESCE(a, b, c): 返回第一个非 NULL 的
-- IFNULL(a, b) / NVL(a, b): a 为 NULL 则返回 b
-- NULLIF(a, b): a=b 时返回 NULL
-- 核心: 可空列条件显式带 OR col IS NULL 或 COALESCE; 判空用 IS NULL;
-- NOT IN 改 NOT EXISTS; 源头能 NOT NULL+DEFAULT 就别留 NULL。
修复的方向,是"让 NULL 无所遁形"。正解一,条件显式带上 NULL:WHERE status != 'done' OR status IS NULL,把 NULL 行也算进"未完成"。正解二,用 COALESCE 给 NULL 默认值再比较:COALESCE(status, 'pending') != 'done'(注意这样可能用不上索引,数据量大时优先方案一)。正解三,判空一律用 IS NULL/IS NOT NULL(永远别写 = NULL)。正解四,NOT IN 改 NOT EXISTS:避免子查询里的 NULL 导致整个结果为空(NOT EXISTS 不受 NULL 影响)。正解五(治本),从源头减少 NULL:字段设 NOT NULL DEFAULT '...'——没有 NULL,就没有三值逻辑的坑;能不用 NULL 就别用。正解六,处理 NULL 的常用函数:COALESCE(返回第一个非 NULL)、IFNULL/NVL(为 NULL 则取默认)、NULLIF。归根结底:可空列条件显式带 OR col IS NULL 或 COALESCE;判空用 IS NULL;NOT IN 改 NOT EXISTS;源头能 NOT NULL DEFAULT 就别留 NULL。
第三件事:NULL 还会在这些地方坑你
这次踩坑后,我把 NULL 在 SQL 里其他容易坑人的地方,系统梳理了一遍——它的影响,远不止 WHERE 条件:
NULL 的其他坑(影响远不止 WHERE != )
# 1. 聚合函数忽略 NULL
# - COUNT(col) 不数 NULL; COUNT(*) 数所有行 → 两者结果可能不同。
# - SUM/AVG/MAX/MIN 都忽略 NULL → AVG 的分母是"非NULL的个数"。
# - 例: AVG(score) 若有人 score 是 NULL, 不会被算进平均(可能非你所愿)。
# 2. NULL 参与算术 → 结果 NULL
# - price * quantity, 任一为 NULL → 结果 NULL(不是报错, 是悄悄变 NULL)。
# - 'a' || NULL(拼接)→ NULL(部分库)→ 整个拼接结果没了。
# → 用 COALESCE 兜底: COALESCE(price,0) * COALESCE(quantity,0)。
# 3. ORDER BY 中 NULL 的位置
# - 不同数据库 NULL 排在最前还是最后不一样。
# - 用 ORDER BY col NULLS FIRST / NULLS LAST 显式指定(或 ISNULL 技巧)。
# 4. 唯一约束 + NULL
# - 多数库认为 NULL != NULL, 所以唯一列上可以有多个 NULL!
# - 想"唯一且不允许多个空", 要加 NOT NULL 或用其他手段。
# 5. JOIN 时 ON 条件的 NULL
# - a.x = b.x, 若 x 有 NULL, 这些行 join 不上(NULL=NULL 是 unknown)。
# - 想让 NULL 也能匹配, 用 a.x = b.x OR (a.x IS NULL AND b.x IS NULL)。
# 6. NULL 与布尔/CHECK 约束
# - CHECK (col > 0) 对 col IS NULL 的行是"通过"的(unknown 不算违反)!
# 关键认知: NULL 几乎渗透 SQL 的每个角落, 都按"未知"的三值逻辑处理。
# 核心: NULL 还会坑聚合(忽略它)、算术(变NULL)、排序(位置不定)、唯一约束(多个NULL)、
# JOIN(匹配不上)、CHECK约束; 凡涉及可空列都要专门考虑 NULL。
原来 NULL 的影响,渗透到了 SQL 的每个角落。聚合忽略 NULL:COUNT(col) 不数 NULL(和 COUNT(*) 可能不同)、AVG 的分母是"非 NULL 个数"(NULL 不算进平均);NULL 参与算术结果 NULL:price * quantity 任一为 NULL 结果就悄悄变 NULL(要 COALESCE 兜底);ORDER BY 中 NULL 的位置不同库不同(用 NULLS FIRST/LAST 显式指定)。唯一约束 + NULL:多数库认为 NULL != NULL,所以唯一列上可以有多个 NULL;JOIN 的 ON 条件含 NULL:a.x = b.x 时 NULL 行join 不上;CHECK 约束:CHECK (col > 0) 对 col IS NULL 的行竟然是"通过"的(unknown 不算违反)。它们的共同根源,都是 NULL 那套"按未知处理的三值逻辑"。归根结底:NULL 还会坑聚合(忽略它)、算术(变 NULL)、排序(位置不定)、唯一约束(多个 NULL)、JOIN(匹配不上)、CHECK 约束;凡涉及可空列都要专门考虑 NULL。
下面这张图,是这次"NULL 被漏掉"的成因与解法:
第四件事:NULL 比较结果速查
这次踩坑后,我把各种涉及 NULL 的比较结果,整理成一张速查表,以后写条件时心里就有数了。
| 表达式 | 结果 | 能被 WHERE 选中吗 |
|---|---|---|
| NULL = NULL | unknown | ✗ 不能(别用它判相等) |
| NULL = 'x' | unknown | ✗ 不能 |
| NULL != 'x' | unknown | ✗ 不能(本文的坑) |
| NULL > 5 | unknown | ✗ 不能 |
| col = NULL | unknown | ✗ 永远查不到 |
| col IS NULL | true/false | ✓ 能(正确判空) |
| col IS NOT NULL | true/false | ✓ 能 |
这张表,把"NULL 比较为什么总查不到"讲得明明白白。看那一列结果:凡是用普通比较运算符(=、!=、>)碰 NULL 的,结果全是 unknown,而 unknown 不会被 WHERE 选中;唯独 IS NULL / IS NOT NULL,结果是明确的 true/false,能被正确选中。这就给出了一条铁律:判断"是不是 NULL",只能用 IS NULL / IS NOT NULL;任何想用 =/!=/= NULL 来"碰" NULL 的,都注定查不到。它给我的启发是:NULL 在 SQL 里,是一个"有特殊脾气"的存在——它拒绝参与普通的相等比较,你必须用专门的 IS 语法去"招呼"它;把这张表的规律(普通比较碰 NULL = unknown = 查不到;IS NULL 才行)刻进脑子,就能在写每一个涉及可空列的条件时,本能地想到"这里的 NULL 我处理了吗"。
第五件事:到底该不该用 NULL?
NULL 坑这么多,那是不是该能不用就不用?这次踩坑也让我重新思考了"该不该用 NULL"。我梳理了一套判断。
| 场景 | 用 NULL | 用默认值/NOT NULL |
|---|---|---|
| 值"确实未知/不适用" | ✓ 用 NULL 表达"没有这个值"最准确 | — |
| 有合理默认值的字段 | — | ✓ NOT NULL DEFAULT(如 status='pending') |
| 数值统计字段(金额/计数) | 慎用(NULL 参与算术变 NULL) | ✓ DEFAULT 0 更安全 |
| 需要参与 != / NOT IN 的列 | 慎用(会被静默漏掉) | ✓ NOT NULL 避免三值逻辑坑 |
| 外键/可选关联 | ✓ NULL 表示"无关联"是合理的 | — |
| 布尔语义字段 | 慎用三态(true/false/NULL易乱) | ✓ 明确 true/false + 默认 |
这张表,让我对 NULL 有了更辩证的看法——它不是洪水猛兽,但也绝不能滥用。NULL 该用的场景,是它能准确表达一种"确实没有/未知/不适用"的语义时:比如一个可选的外键(无关联就是 NULL)、一个确实"还不知道"的值——这时,用 NULL 比"硬塞一个假的默认值"更诚实、更准确。而 该慎用/避免的场景,是那些会被 NULL 的三值逻辑坑到的地方:有合理默认值的字段(用 NOT NULL DEFAULT)、数值统计字段(DEFAULT 0,免得算术变 NULL)、需要参与 !=/NOT IN 的列(免得被静默漏掉)、布尔语义字段(别整 true/false/NULL 的"三态"易乱)。它给我的最大启发是:NULL 是一把双刃剑:用对地方,它精准地表达了"无";用错地方,它就成了数据查询里防不胜防的"幽灵"。设计表结构时,要对每一个可空字段,都认真想一想:"这里真的需要 NULL 吗?它表达的'无',是一种有意义的状态,还是我偷懒没给默认值?";能用 NOT NULL + DEFAULT 明确表达的,就别留 NULL——从源头减少 NULL,就是从源头减少了一整类隐蔽的查询 bug。
第六件事:写一个涉及可空列的查询时,我现在会怎么决策
现在,每当我写一个涉及"可能为 NULL 的列"的查询,脑子里都会过一遍这张决策图——核心就一问:这列可能是 NULL 吗?我处理它了吗?
这张图的灵魂,是那个必问的问题:这列可能是 NULL 吗?我处理它了吗?如果不可能(NOT NULL),正常写条件即可;如果可能为 NULL,就必须显式处理:判空用 IS NULL/IS NOT NULL(别用 = NULL)、!=/范围比较补 OR col IS NULL 或 COALESCE、NOT IN 改 NOT EXISTS、算术/拼接用 COALESCE 兜底、聚合注意 COUNT/SUM 忽略 NULL。而且每次都顺手反思一句:这列源头能不能 NOT NULL DEFAULT,从根上消除 NULL?这套判断,让我写涉及可空列的查询时,不再被 NULL 静默地漏掉数据——核心始终是:看到可空列,就条件反射地问"NULL 怎么办"。
我立下的几条规矩
这场"NULL 被漏掉"的事故,换来了我写 SQL 时,刻进骨子里的几条铁律:
- NULL 是"未知",遵循三值逻辑。它和任何值比较都是 unknown(非 true),WHERE 只留 true,所以含 NULL 的行会被静默漏掉。
- 判空只能用 IS NULL / IS NOT NULL。= NULL / != NULL 永远是 unknown、查不到任何行。
- 可空列的 != / 范围条件,要补 OR col IS NULL。否则 NULL 行被漏;或用 COALESCE 给默认值。
- NOT IN 子查询改用 NOT EXISTS。子查询里只要有一个 NULL,NOT IN 就返回空结果,NOT EXISTS 不受影响。
- NULL 参与算术/拼接结果是 NULL。price*qty 任一为 NULL 就变 NULL,用 COALESCE 兜底。
- 聚合忽略 NULL。COUNT(col) 不数 NULL、AVG 分母是非 NULL 个数,心里要有数。
- 能 NOT NULL+DEFAULT 就别留 NULL。从源头减少 NULL,就从源头减少了一整类隐蔽 bug。
附:亲手跑一遍,看清 NULL 是怎么漏数据的
口说无凭。下面这段 SQL,建个小表、塞几行(含 NULL),亲手跑一遍各种条件,你会清清楚楚看到 NULL 是怎么被漏掉的:
-- 建表并插入测试数据(含 NULL)
CREATE TABLE orders (id INT, status VARCHAR(20));
INSERT INTO orders VALUES (1, 'done'), (2, 'pending'), (3, NULL), (4, 'shipping');
-- 总共 4 行, 其中 status 为 NULL 的有 1 行(id=3)
-- ✗ 实验1: != 'done' —— 漏掉了 NULL 行!
SELECT * FROM orders WHERE status != 'done';
-- 只返回 id=2,4 (pending/shipping) —— id=3(NULL)被漏掉!
-- 你以为"未完成"有 3 个(2,3,4), 实际只查到 2 个。
-- ✗ 实验2: = NULL 永远查不到
SELECT * FROM orders WHERE status = NULL;
-- 返回 0 行! (NULL = NULL 是 unknown)
-- ✓ 实验3: IS NULL 才能查到 NULL 行
SELECT * FROM orders WHERE status IS NULL;
-- 返回 id=3 ✓
-- ✓ 实验4: 正确查"所有未完成"(含 NULL)
SELECT * FROM orders WHERE status != 'done' OR status IS NULL;
-- 返回 id=2,3,4 ✓ 这才是完整的"未完成"
-- ✗ 实验5: COUNT 的差异
SELECT COUNT(*) FROM orders; -- 4 (所有行)
SELECT COUNT(status) FROM orders; -- 3 (不数 NULL!)
-- ✗ 实验6: NOT IN 含 NULL 全空
SELECT * FROM orders
WHERE id NOT IN (SELECT id FROM orders WHERE status IS NULL OR id = 1);
-- 子查询含 NULL? 这里没有, 但若子查询返回了 NULL, 整个结果会变空 —— 自己造个NULL试试。
-- 核心: 跑一遍就懂 —— != 漏 NULL、= NULL 查不到、IS NULL 才行、COUNT(col) 不数 NULL;
-- 亲眼看到 id=3 那行如何被各种条件静默漏掉, 比记十条规则都管用。
这段可以亲手跑的 SQL,把 NULL 漏数据的过程,一行行演示得明明白白。表里就 4 行、其中 id=3 的 status 是 NULL:实验 1 的 != 'done' 只返回了 2 行(id=2,4),id=3 被静默漏掉——你以为"未完成"有 3 个,实际只查到 2 个,这就是对账对不上的真相;实验 2 的 = NULL 返回 0 行;实验 3 的 IS NULL 才正确查到了 id=3;实验 4 的 != 'done' OR status IS NULL 才返回了完整的 3 行。实验 5 还展示了 COUNT(*) 是 4、COUNT(status) 却是 3(不数 NULL)的差异。这,正是我想用这段 SQL,留给每一个写 SQL 的人的最后一课:对于 NULL 这种"反直觉、又极其隐蔽"的行为,最好的学习方式,就是建个小表、塞几行带 NULL 的数据,亲手把各种条件跑一遍,用自己的眼睛,看着那行 NULL 数据,如何被 != 静默地漏掉、又如何被 IS NULL 准确地捞回。一次"亲眼看着数据被漏掉"的实验,带来的震撼和记忆,远胜过十遍"NULL 要用 IS NULL 判断"的背诵。把抽象的规则,变成你亲手验证过的、活生生的现象——这,是真正搞懂任何一个数据库特性的不二法门。
一个容易被忽略的延伸:NULL 与"空字符串""0"完全不同
这次踩坑还顺带纠正了我一个模糊的认识:NULL、空字符串 ''、数字 0,是三个截然不同的东西,绝不能混为一谈。很多人(包括曾经的我)会下意识地以为它们"差不多,都表示'没有'",但在数据库里:NULL 表示"未知 / 这个值根本不存在";空字符串 '' 表示"有一个值,而这个值是'空的字符串'"(它是一个实实在在、长度为 0 的字符串);数字 0 则是"有一个值,这个值是数字零"。它们的差别,在查询时会实实在在地体现出来:WHERE col = '' 查得到空字符串、却查不到 NULL;WHERE col IS NULL 查得到 NULL、却查不到空字符串;WHERE col = 0 查的是数字 0,和前两者更是风马牛不相及。(尤其要注意:Oracle 有个历史特例,会把空字符串当成 NULL 处理,而 MySQL/PostgreSQL 则严格区分二者——跨库时这又是一个坑。)所以,当你想表达"这里没有值"时,一定要先想清楚:你说的"没有",到底是"未知/不存在"(该用 NULL),还是"一个空的值"(该用 '' 或 0)?这两者一旦在设计和查询时被混淆,就会产生"明明存了'空',却用 IS NULL 查不到"、或"明明是 NULL,却用 = '' 查不到"这类令人抓狂的问题。分清"未知"和"空",是用好 NULL 的又一块基石。
写在最后
回头看,这场由 NULL 引发的、数据被静默漏掉的事故,真正教给我的,是一个比"记得处理 NULL"本身更深的道理:我们的日常思维,默认是"非黑即白"的两值逻辑(一件事,不是真,就是假);可现实世界(以及精确建模它的 SQL),却存在着大量"第三种状态"——"未知";而当我们用"两值"的直觉,去处理一个"三值"的世界时,就会在那个被我们忽略的"第三种状态"上,悄无声息地栽跟头。NULL,正是 SQL 对现实中"我不知道这个值"这种状态的诚实建模;而我犯的错,是用"非 done 即不等于 done"的两值直觉,去套一个有"未知"参与的三值逻辑,于是那些"状态未知"的订单,就掉进了我思维的盲区。这让我深刻地领悟到:编程,很多时候是在用代码,精确地建模这个充满"不确定、缺失、未知"的真实世界;而一个严谨的工程师,必须训练自己,跳出"非此即彼"的简单二元思维,去认真对待那些"中间状态、边界情况、缺失的数据"——因为真实世界的复杂性,恰恰就藏在这些"不那么非黑即白"的角落里。所以,无论是设计数据库、写条件判断,还是处理任何数据,我都会多问一句:"除了'有'和'没有'、'是'和'否',会不会还有'不知道'这第三种情况?我考虑到它了吗?"。承认并妥善处理"未知"——这,是我用一次"NULL 漏数据"的事故,换来的、关于 SQL、也关于"如何严谨地建模现实"的、最朴素也最深刻的领悟。如果这篇复盘,能让你在下一次写涉及可空列的条件时,本能地想到那些 NULL 的行,那我对着那份对不上的数据熬的这大半天,就值了。
—— 别看了 · 2026