先说一个我至今忘不掉的线上现场。
凌晨两点,监控报警:一批订单在生成成功后没有落库,客服的工单一张接一张。查日志,业务代码明明打印了 order created, id=100237,但去库里 SELECT * FROM orders WHERE id = 100237,零行。
第一反应是事务回滚了。第二反应是读写分离读到了从库。第三反应是有人手贱删了数据。都不是。
真实原因是:插入语句是拼接出来的,其中一个字段为空——不是空字符串,也不是 0,是数据库里的 NULL。而那条 SQL 后面挂着一个 WHERE status != 'PAID' 的查询条件,status 这一列恰好是 NULL。
NULL != 'PAID' 的结果不是 TRUE,而是 UNKNOWN。在 SQL 里,UNKNOWN 不会被 WHERE 留下。于是订单"存在但查不到",重试又写一遍,数据越滚越乱。
那天我盯着 NULL 看了很久。它是数据库里最贵的两个字——不是性能上的贵,是你以为你懂、其实完全没懂的贵。
一、NULL 不是"空",是"未知"
大多数人的第一认知是:NULL 就是空值,跟 Java 里的 null、Go 里的 nil 差不多。这是最贵的误解。
SQL 标准里 NULL 表示 UNKNOWN(未知)——不是"这里有个值但值是空的",而是"这里的值我不知道"。这个区别不是哲学洁癖,它有直接后果:
如果 status 是 NULL,那么 status = 'PAID' 不是 FALSE,而是 UNKNOWN。status != 'PAID' 也不是 TRUE,同样是 UNKNOWN。
所以 SQL 的布尔值有三个,不是两个:TRUE / FALSE / UNKNOWN。这就是著名的三值逻辑(Three-Valued Logic, 3VL)。
WHERE 只留下 TRUE 的行。
FALSE和UNKNOWN一起被丢掉。这就是为什么"这行数据明明不符合条件却查不到"——它不是不符合条件,是条件的结果你没法判定。
逻辑运算符在三值逻辑下的真值表是这样的:
TRUE AND UNKNOWN→UNKNOWNTRUE OR UNKNOWN→TRUEFALSE AND UNKNOWN→FALSENOT UNKNOWN→UNKNOWN
看出规律了吗:AND 只要有一个 FALSE 就短路成 FALSE;OR 只要有一个 TRUE 就短路成 TRUE;NOT 对 UNKNOWN 无能为力,UNKNOWN 还是 UNKNOWN。
这套逻辑优雅、严谨、正确。问题在于,它和人类"非真即假"的直觉完全冲突,而 SQL 语言允许你写出在直觉上完全自洽、在语义上完全空转的语句。
二、经典事故现场:NOT IN 遇上 NULL
这是 3VL 最经典的一次翻车,也是我劝你背下来的第一个知识点。
-- 想找出:没有出现在退款表里的订单
SELECT * FROM orders o
WHERE o.id NOT IN (SELECT r.order_id FROM refunds r);
如果 refunds.order_id 这一列里没有任何 NULL,这句 SQL 完全正确。
但只要有一条退款记录的 order_id 是 NULL(可能是历史数据、可能是"内部冲正"这类无关联退款),整个查询会返回 0 行。
为什么?展开来看:对于某个订单 id=42,42 NOT IN (100, 200, NULL) 等价于 42 <> 100 AND 42 <> 200 AND 42 <> NULL。前两项是 TRUE,最后一项是 UNKNOWN,三者 AND 起来还是 UNKNOWN——被 WHERE 丢掉了。
一个 NULL 污染整张结果表。
这不是数据库的 bug,是标准定义下的正确行为。MySQL、PostgreSQL、Oracle、SQL Server 行为一致,谁都一样。
正确写法是 NOT EXISTS,它用相关子查询 + 显式匹配,遇到 NULL 会跳过而不是污染:
SELECT o.*
FROM orders o
WHERE NOT EXISTS (
SELECT 1 FROM refunds r WHERE r.order_id = o.id
);
顺带说个性能上的事:老派 DBA 喜欢看执行计划,看到 NOT IN 就皱眉,因为 MySQL 对它经常做物化 + 一次性全量扫描,子查询结果大的时候直接变慢查询;NOT EXISTS 配合 order_id 上的索引,是半连接(semi-join)优化,能走索引。所以 NOT EXISTS 不只是"语义正确",通常还更快。一举两得。
三、= NULL 永远不成立
第二个必须刻进脑子的:判断 NULL 只能用 IS NULL 和 IS NOT NULL,写 = 一定得不到你想要的。
SELECT * FROM users WHERE deleted_at = NULL; -- 永远 0 行
SELECT * FROM users WHERE deleted_at IS NULL; -- 这才对
原因在第一节已经说了:右边的 NULL 不是常量,而是"未知值",跟任何东西比较结果都是 UNKNOWN。这跟它是不是常量无关,是运算符语义决定的。
还有个连工程老手都会栽的坑:LEFT JOIN 之后再在 WHERE 里判断右表字段,WHERE 会把"右表无匹配"(r.status 为 NULL)的行全部过滤掉——LEFT JOIN 被降级成 INNER JOIN。想保留就把条件挪进 ON 子句,或者写成 WHERE r.status IS NULL(经典的 anti-join:找出没有退款记录的订单)。
四、聚合函数与 NULL:财务对账的隐形杀手
这一节的坑不报错、不告警,只是数字悄悄对不上。
SUM、AVG、COUNT(col) 全部忽略 NULL。
CREATE TABLE scores (
id INT,
score INT
);
INSERT INTO scores VALUES (1, 90), (2, 80), (3, NULL);
SELECT SUM(score), AVG(score), COUNT(score), COUNT(*), MAX(score)
FROM scores;
结果是:SUM = 170,AVG = 85,COUNT(score) = 2,COUNT(*) = 3。
看起来没毛病?但如果第三个学生的分数是"缺考"而非"零分",你想统计的"平均分"分母到底算不算他?
AVG(score)= 85:分母是 2(只算非空),这是 SQL 的标准语义。AVG(COALESCE(score, 0))= 56.7:分母是 3,缺考按零分计入。
两种都对,取决于业务。麻烦的是报表和明细的口径不一致:明细页显示 3 个学生,汇总页的"参与统计人数"却是 2。对账的时候没人知道该信哪个。
COUNT(*) 与 COUNT(col) 的区别也要记住:COUNT(*) 数行数,COUNT(col) 数非 NULL 值个数;COUNT(1) 和 COUNT(*) 在主流数据库里性能一致,别再拿这个当优化手段了。
更狠的是 SUM 全为 NULL 时返回 NULL 而不是 0——你的 Java 代码 int total = rs.getInt("total") 还能活,但如果是 Go 里 var total int64 拿到 NULL 就会报错。而 SUM 结果为 NULL 时,sum != 0 也是 UNKNOWN,判断一样不成立。
实操建议:所有对外暴露的聚合结果,出一层 COALESCE 兜底,SUM(COALESCE(amount, 0))、COALESCE(SUM(amount), 0)。别指望数据库给你默认值。
五、UNIQUE 约束遇上 NULL:重复数据是怎么漏进来的
这一条能让你的数据库悄悄脏掉。
多数人以为给列加了 UNIQUE 就万事大吉。但标准 SQL 认为:NULL 与 NULL 不相等(因为无法判定是否相同),所以:
CREATE TABLE users (
email VARCHAR(100) UNIQUE
);
INSERT INTO users (email) VALUES (NULL);
INSERT INTO users (email) VALUES (NULL); -- 成功!两行都是 NULL
MySQL、PostgreSQL 默认都是这个行为(PostgreSQL 15 之前同样如此)。所以你精心设计的"邮箱唯一"约束,对"邮箱未填写"的那批脏数据形同虚设。
解法有几种,各有取舍:
- MySQL 8 / PostgreSQL 15+:支持
NULLS NOT DISTINCT唯一约束(PostgreSQL 原生支持;MySQL 没有等价语法,得靠生成列)。 - 生成列 + 唯一索引(MySQL 通用解法):
ALTER TABLE users ADD COLUMN email_key VARCHAR(100) GENERATED ALWAYS AS (COALESCE(email, '')) STORED, ADD UNIQUE KEY uk_email_key (email_key); - 最省心的方案:干脆不让它为 NULL。
NOT NULL DEFAULT '',把"没有邮箱"变成一个明确的空串哨兵值。业务上如果"没填邮箱"和"填了空邮箱"要区分,那就再加一列has_email布尔位。
第三条听起来土,但工程上最稳。NULL 适合表达"未知",不适合表达"业务上确实为空"——这两者的处理逻辑往往完全不同,用一个哨兵值把它们区分开,是把复杂度挡在数据库之外。
六、三个数据库对 NULL 的态度并不一致
这是跨库迁移最容易翻车的地方。
- 排序:MySQL 的
ORDER BY col ASC把 NULL 排在最前,DESC排在最后;PostgreSQL 默认NULLS LAST(ASC时);SQLite 把 NULL 当最小值。也就是说 MySQL 和 PG 的ASC结果完全相反,需要显式写NULLS FIRST/NULLS LAST才能对齐。 - 唯一约束:如上,MySQL 和 PG(15 以前)都允许多行 NULL;Oracle 的行为不同,NULL 视作"彼此相等",所以 Oracle 下一个 UNIQUE 列只能有一个 NULL。
- 类型转换:PG 里
NULL与任何类型都能比较;MySQL 里NULL = 0是UNKNOWN,但NULL <=> 0(NULL 安全等于运算符)返回 1。 - CONCAT:MySQL 的
CONCAT('a', NULL)返回NULL(整体作废);PostgreSQL 的'a' || NULL同样返回NULL,但concat('a', NULL)(函数形式)返回'a'。
迁移工具(Debezium、Flink CDC、gh-ost)最常见的数据校验告警就是"源库和目标库行数一致但 checksum 不一致",根因排查到最后八成是 NULL 语义差异。这类问题在测试环境数据量小的时候根本不会触发,等到生产才发现,修复成本已经很高了。
七、真正该改的:把"可空"当成一个需要被设计的属性
上面的坑都源于同一个坏习惯:把 NULL 当默认值用。字段没值就写 NULL,图省事。但 NULL 在 SQL 里是一个有重量的语义单元,不是"空格子"。
更靠谱的做法分三层:
1. 数据库层:默认收紧。能用 NOT NULL DEFAULT 的绝不用 NULL。业务上真的"可能没有"的字段(是否续费、备注、备用邮箱),才留 NULL,并且明确它的业务含义。
2. 应用层:把可空性显式化。
// Go:不要用 sql.NullString 混进业务逻辑,
// 在 DTO 层就把它变成有类型的"可能不存在"
type Order struct {
ID string
PaidAt *time.Time // nil 明确表示"尚未支付"
CouponID *string // nil 明确表示"未使用优惠券"
}
Java 生态用 Optional,Rust 用 Option<T>,Go 社区虽然偏爱零值,但涉及"三态"的字段用指针类型是共识。关键不是用哪种语言习惯,而是别让 NULL 悄悄流进业务判断里——一旦 == null 和 != null 散落在业务代码里,早晚出现"我明明判断了不为空,怎么还是走了空分支"。
3. 查询层:写查询的时候,脑子里跑一遍三值逻辑。
写完 WHERE 条件问自己三个问题:这个条件对 NULL 行会返回什么?结果是 TRUE 吗?如果不是 TRUE,这行会被留下吗?
这听起来很烦,但比凌晨两点被报警叫醒划算得多。
八、顺手送几个立即能用的防御性写法
-- 1. 判断空值:永远用 IS NULL
WHERE deleted_at IS NULL AND deleted_at IS NOT NULL
-- 2. 反连接:永远用 NOT EXISTS,不要用 NOT IN
WHERE NOT EXISTS (SELECT 1 FROM refunds r WHERE r.order_id = o.id)
-- 3. 去重判等:用 IS DISTINCT FROM 表达"相等或都为 NULL"
WHERE a.col IS NOT DISTINCT FROM b.col -- PostgreSQL / SQLite
-- MySQL 用 <=> 运算符:a.col <=> b.col
-- 4. 聚合输出一律兜底
SELECT COALESCE(SUM(amount), 0) AS total FROM orders;
-- 5. 排序口径显式化
ORDER BY priority ASC NULLS FIRST, created_at ASC;
-- 6. 存在性判断优先用 EXISTS 而不是 IN(可走半连接优化)
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)
九、最后
NULL 是 SQL 里唯一一个"看起来像空、实际上是完全另一种东西"的类型。它不是 Java 的 null,不是 Excel 的空单元格,不是 0,也不是空字符串。它是"我不知道"。
而所有围绕它的坑,本质上都源于同一个坏毛病:人脑只有两个值,数据库有三个。
所以我现在的规矩很死:NOT IN 一律改成 NOT EXISTS;= NULL 一律改成 IS NULL;对外的聚合结果一律 COALESCE;能 NOT NULL 的字段一律 NOT NULL;跨库迁移的表,一律把 NULL 的行数、唯一约束行为、排序位置列进检查清单。
四条规则,比背十篇 SQL 教程管用。
至于那个凌晨两点的订单——最后定位到的原因,是那张表里 status 列有 NULL,而一条排查用的 SQL 顺手写了 status != 'PAID'。改完 SQL,我顺手把那一列改成了 NOT NULL DEFAULT 'CREATED'。世界清净了。
数据库不报错的时候,才是最值得检查的时候。