NULL 不是空,是”未知”:一次凌晨两点的订单失踪,揪出 SQL 三值逻辑的真凶

2026-10-27 13 0

先说一个我至今忘不掉的线上现场。

凌晨两点,监控报警:一批订单在生成成功后没有落库,客服的工单一张接一张。查日志,业务代码明明打印了 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 → UNKNOWN
  • TRUE OR UNKNOWN → TRUE
  • FALSE AND UNKNOWN → FALSE
  • NOT 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'。世界清净了。

数据库不报错的时候,才是最值得检查的时候。

相关文章

Rust 三大痛点,其实只有一个:所有权。读完你会想删掉所有 GC
TCP 被 QUIC 撕碎重写:HTTP/3 到底快在哪,又在哪儿翻车
你的 SQL 为什么这么慢?——从一条慢查询开始的性能破案
你的服务连不上数据库,可能是 DNS 在打盹:一次从 200ms 抖动到全站 502 的排障实录
Go 的 interface 为什么这么难用?——nil 陷阱、方法集与隐式契约,一次说清
缓存说旧了,数据库说新了:别再让这两个家伙互相甩锅

发布评论