一条"清理脚本"写了 DELETE FROM t WHERE status != 'paid',跑完后"未支付"的记录还在——因为那些记录的 status 是 NULL,而"NULL != 'paid'"的结果不是 true,而是 unknown。于是它们"既不在 = 'paid' 里,也不在 != 'paid' 里".
SQL 的 NULL 不是"空字符串",也不是"零",而是"未知"。于是它的比较遵循"三值逻辑":true / false / unknown——而 WHERE 只保留 true,unknown 被当成"不满足".
1. 三值逻辑的三条规则
| 表达式 | 结果 |
|---|---|
NULL = NULL |
unknown |
NULL != 'x' |
unknown |
NOT unknown |
unknown |
第二项的"心理陷阱"是"日常语言的直觉":人会说"它不等于 paid,所以该删"——而 SQL 说的是"我不知道它等不等于 paid",于是"不动它".
第二项是"清理脚本失效"的直接原因:"不等于"在 NULL 上"既不是真也不是假"**——**于是它"被筛掉了".
"三值逻辑"在"关联条件"里也会"吞行",而它比 WHERE 更难发现:
-- 反例:关联键可能为 NULL,行会"消失"
SELECT * FROM orders o JOIN users u ON o.user_id = u.id;
-- 若 o.user_id 为 NULL:该订单"不匹配任何用户",于是"不出现在结果里"
-- 判据:关联键"是否可能为 NULL"——可能,就要用 LEFT JOIN 或"先补默认值"
2. 六种"被 NULL 坑"的写法
| 写法 | 实际含义 |
|---|---|
col != 'x' |
不含 NULL 行 |
NOT (col = 'x') |
同上 |
col NOT IN (1,2) |
列表里有 NULL 时全 unknown |
COUNT(col) |
不计 NULL |
SUM(col) |
忽略 NULL |
col = '' |
只匹配"空串",不含 NULL |
第三项最"炸":NOT IN (1, NULL) 会"一行都不返回"第三项的"为什么"值得记住:NOT IN 等价于"对每个元素做 <> 然后取合取",而"任一元素为 NULL 时该项为 unknown"——**unknown 与任何值合取仍是 unknown.
——**因为"col 与 NULL 比较"永远是 unknown".
"三值逻辑"可以用"一次小实验"看见,比读文档快:
-- 三行实验:看 unknown 怎么被吞
SELECT NULL = NULL, NULL != 'x', NOT (NULL = 'x'); -- 全为 NULL
SELECT COUNT(*) FROM t WHERE status != 'paid'; -- 不含 NULL 行
SELECT COUNT(*) FROM t WHERE status IS NULL OR status != 'paid'; -- 含
-- 判据:两行计数"不等" → 表里有 NULL
3. "要包含 NULL"的三种写法
| 写法 | 说明 |
|---|---|
col IS NULL OR col != 'x' |
最直观 |
COALESCE(col,'') != 'x' |
简洁(会"用默认值替代") |
col <=> 'x'(MySQL) |
NULL 安全等于(NULL <=> NULL 为真) |
第一项的"写法"要注意"括号":(col IS NULL OR col != 'x')——少了括号,AND 优先级会让条件"变成另一个意思".
第一项是"最稳"的:"显式写出两种情形"**——**而它"读起来"就告诉下一个人"这里考虑过 NULL".
"报表给 0 而不是空"这件事,要"在 SQL 层兜住",而不是"在前端判空":
-- 三种"空变 0"
1 COALESCE(SUM(amount), 0) -- 最常用
2 IFNULL(SUM(amount), 0) -- MySQL 同义
3 SUM(COALESCE(amount, 0)) -- 把"未知"当 0(语义不同,慎用)
-- 判据:1/2 是"没有行时给 0";3 是"把未知改成 0"——两者别混
4. "聚合"上的三种陷阱
| 陷阱 | 表现 |
|---|---|
AVG(col) 忽略 NULL |
分母变小(不是"当 0") |
COUNT(*) vs COUNT(col) |
差"NULL 行数" |
SUM(col) 全 NULL 时返回 NULL |
不是 0 |
第三项会让"报表里出现空值":"本月无销售"的 SUM 是 NULL,而"0"要"COALESCE(SUM(x),0)".
"唯一 + 非空"的组合要"一起加",否则"约束只挡一半":
-- 反例:只加唯一索引(允许多个 NULL)
ALTER TABLE user ADD UNIQUE KEY uk_phone (phone);
-- 正解:先"非空 + 默认值",再"唯一"
ALTER TABLE user MODIFY phone VARCHAR(20) NOT NULL DEFAULT '';
-- 判据:唯一键上的字段"是否可能为 NULL"——可能,就要一起改
5. "索引与 NULL"的三件事
| 事实 | 影响 |
|---|---|
唯一索引允许多个 NULL(多数库) |
"唯一"不等于"非空" |
IS NULL 可走索引(部分库/版本) |
视优化器 |
col != 'x' 常"用不上索引" |
全表扫 |
第一项是"业务漏洞":"唯一索引"挡不住"多条 NULL"第一项在"迁移"时最容易出事:"加了唯一索引"却没加 NOT NULL——**于是"上线后"数据里"多条 NULL 共存",而"业务以为是唯一的".
——**于是"手机号唯一"要先"NOT NULL".
"NOT IN 换 NOT EXISTS"是最常用的一次性修法,而它"顺便"解决了"列表里有 NULL":
-- 反例:列表含 NULL 时,整条查询返回空
SELECT * FROM a WHERE a.id NOT IN (SELECT b.a_id FROM b);
-- 正解:NOT EXISTS(对 NULL 不敏感)
SELECT * FROM a WHERE NOT EXISTS (SELECT 1 FROM b WHERE b.a_id = a.id);
-- 判据:子查询"可能返回 NULL"时,一律用 NOT EXISTS
6. 排错:五种"NULL 问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
!= 漏行 |
三值逻辑 | 加 IS NULL |
NOT IN 返回空 |
列表含 NULL |
用 NOT EXISTS |
| 报表"空"而非 0 | SUM 全 NULL |
COALESCE |
| 唯一约束"没生效" | 多个 NULL 允许 |
加 NOT NULL |
| 关联"少了行" | = 匹配不到 NULL |
用"外连接"或"<=>" |
第五项的"修法"要"看语义":"要保留左表全部行"用 LEFT JOIN,"要 NULL 也能配上"用"<=> 或 COALESCE 连接键"——而两者"结果不同",选错就"多行或少行".
第五项在"关联"里很常见:"a.col = b.col 遇到 NULL 不匹配"**——**于是"左表有、右表空"的行"消失".
7. 边界:什么情况"NULL"是正确表达
| 场景 | 为什么 | 做法 |
|---|---|---|
| "未知"与"零"要区分(如"未测量"vs"测得 0") | 语义不同 | 用 NULL |
| 可选关联(未关联时无值) | 天然 | 用 NULL |
| 第三方数据缺失 | 确实未知 | 用 NULL |
| "已删除"与"从未有"要区分 | 业务含义不同 | 用 NULL + 状态位 |
判断口径一句话:"这个字段的'没有',是"未知"还是"零/空""。是"未知",NULL 是对的表达(但要"每处比较都记得处理");是"零/空",用"默认值 + NOT NULL"更省心——而"把'未知'塞成 0",会在"平均值/告警阈值"上造成更难发现的错.
8. 自测
NULL与"空字符串""零"的区别是什么?为什么它需要"三值逻辑"?- 为什么
col != 'x'"筛不出空值"?WHERE对unknown怎么处理? NOT IN (1, NULL)为什么会"一行都不返回"?- "要包含
NULL"的三种写法里,哪一种"读起来就告诉下一个人你考虑过"? SUM全NULL时返回什么?"报表里出现空值"的修法是什么?- 判断"该用
NULL还是默认值"的那个问题是什么?
补一句"实践"建议:"新表默认 NOT NULL"应当写进建表规范——因为"后期把 NULL 改成 NOT NULL"要"清洗存量数据 + 改所有比较处",成本是"当初不写 NULL"的十倍。而"确实需要未知"的字段,"用 NULL 但配注释"比"用 0 混过去"更诚实——**而"诚实"在数据上,就是"可审计".
而"吞行"这件事,在"对账"里表现为"总数对不上",而在"列表页"里表现为"少了几条"——同一个原因,两种抱怨。
所以"查吞行"应当"先查关联键的 NULL",而不是"先怀疑索引"。
9. 一句话总结
NULL 是"未知",不是"空"——于是"所有比较"都要"多考虑一种情形"。而工程上的稳妥做法只有两条:要么"让字段 NOT NULL + 默认值"(把三值逻辑压回两值),要么"在每个比较处显式写 IS NULL"。而"漏一处"的代价,往往是一条"跑完没报错、但数据没改"的脚本。