NULL 的深渊:数据库三值逻辑如何在工程现场反复埋雷

2026-11-08 10 0

NULL 的深渊:数据库三值逻辑如何在工程现场反复埋雷

写代码这些年,我最怕的不是高并发,也不是分布式一致性,而是 NULL。它安静、体贴、不吵,但在你的 SQL 里悄悄改写语义,把一个本该返回 1 条记录的查询变成 0 条,把一条本该更新的数据变成静默跳过。线上排查三小时,最后发现是一个 = NULL。

这篇文章聊聊三值逻辑的真实工程影响,以及怎么在自己的代码里把它彻底驯服。


一、先把话说清楚:为什么它叫"三"值

经典 SQL 里,逻辑判断不只是真和假两种结果。当操作数中出现 NULL 时,逻辑运算的结果有三种:TRUE、FALSE、UNKNOWN。

关键在于 UNKNOWN 在 WHERE 子句里被当成 FALSE 处理,而在 CHECK 约束里被当成 TRUE(即不违反)处理。这一不对称,是绝大多数事故的源头。

举个例子:

-- 用户表,phone 字段可空
SELECT * FROM users WHERE phone = NULL;

这段 SQL 永远返回空集。因为 phone = NULL 的运算结果永远是 UNKNOWN,而 UNKNOWN 不通过 WHERE 过滤。正确的写法是:

SELECT * FROM users WHERE phone IS NULL;
-- 或
SELECT * FROM users WHERE phone <=> NULL;

第二个是 MySQL 的空值安全等于运算符,很多人不知道它存在,但它在"这个字段到底要不要过滤"的业务代码里非常实用。


二、NULL 与索引:一次隐式转换引发的全表扫描

这是我最想吐槽的一段"常识"。假设表上有这样的索引:

CREATE INDEX idx_phone ON users(phone);
CREATE INDEX idx_status ON users(status);

然后你写下这个查询:

SELECT * FROM users WHERE phone = 13800138000 AND status = 'active';

看起来完美走索引,直到某天有人"优化"了一下代码:

// Java / Go 里把参数写成字符串,SQL 里再隐式转换
"WHERE phone = '" + phoneStr + "'"

字符串字面量与数值列比较时,MySQL 会把列值转成字符串再比较,索引直接失效。于是这条查询从毫秒级变成全表扫描,数据量涨上去以后,连接池被拖垮,整个服务的 P99 跟着雪崩。

更隐蔽的坑是隐式类型转换的"方向性":

把字符串列和数字比较,是把列转成数字;把数字列和字符串比较,是把数字转成字符串。哪种转换让索引失效,取决于列上有没有索引方向相反的转换路径。总之:永远让两边类型一致,别让数据库猜。

同理,函数包裹索引列也会失效:

-- 索引失效
SELECT * FROM users WHERE DATE(created_at) = '01-01';
WHERE LOWER(email) = 'a@b.com';

-- 索引有效(MySQL 8.0 起支持函数索引也可以,但改写更稳)
SELECT * FROM users WHERE created_at >= '01-01 00:00:00'
  AND created_at <  '01-02 00:00:00';

MySQL 8.0.13 之后确实支持函数索引(CREATE INDEX idx ON t((func(col)))),但如果你的代码库一半用函数、一半用范围,索引维护成本和理解成本都会上升。把变换从 SQL 挪到应用层,通常是更划算的工程决策。


三、NOT IN 与 NULL:返回空集的唯一解释

这是第二个经典事故现场。

SELECT * FROM orders
WHERE user_id NOT IN (SELECT user_id FROM blacklist);

只要 blacklist.user_id 里存在一行是 NULL,整个查询返回空集。原因是 user_id NOT IN (1, 2, NULL) 对任何非空 user_id 都求值为 UNKNOWN,全部被过滤。

这个 bug 极难定位,因为黑名单表里多了一行空值,逻辑上"看起来"完全合理——一个还没填用户 ID 的黑名单条目,不是很正常吗?

解决方案有三种,按推荐程度排序:

-- 1. 最优:NOT EXISTS(NULL 安全,语义清晰)
SELECT o.* FROM orders o
WHERE NOT EXISTS (
  SELECT 1 FROM blacklist b WHERE b.user_id = o.user_id
);

-- 2. 次选:过滤掉 NULL 子查询
SELECT * FROM orders
WHERE user_id NOT IN (
  SELECT user_id FROM blacklist WHERE user_id IS NOT NULL
);

-- 3. 兜底:COALESCE
WHERE user_id NOT IN (SELECT COALESCE(user_id, -1) FROM blacklist)

顺带一提,NOT EXISTS 在大多数执行计划下比 NOT IN 更快,而且它对 NULL 是免疫的。我个人在代码规范里直接把 NOT IN 列为禁用关键字,除非子查询结果集在数学上可证明非空——这在代码评审里是没办法证明的,所以等于禁用。


四、COUNT 的三种语义:一个函数三种答案

很多人以为 COUNT(*)、COUNT(1)、COUNT(列名) 是一回事。它们不是。

  • COUNT(*):统计所有行,包括该列为 NULL 的行。
  • COUNT(1):几乎总是等价于 COUNT(*),因为常量 1 非空。
  • COUNT(列名):跳过该列为 NULL 的行。

这个差异在"统计有效手机号数量"这类需求下直接变成业务事故:

-- 你以为统计所有用户,实际只统计了有手机号的
SELECT COUNT(phone) FROM users;

更糟的是,在 ORM 里写 count(*) 和 count(field) 的行为差异,框架不同、方言不同,很容易在不同团队之间产生"同样的查询不同结果"的灵异事件。所以我团队的规约很粗暴:统计行数一律用 COUNT(*),需要统计非空值时显式写 COUNT(col) 并在注释里说明为什么。


五、聚合函数与 GROUP BY:NULL 自成一家

另一个容易踩的点:GROUP BY 把所有 NULL 归为同一组。

SELECT country, COUNT(*) FROM users GROUP BY country;

如果表里有 3 个用户没填国家,你看到的不是三行,而是一行,country 为 NULL,COUNT(*) 为 3。做报表的时候这一行经常被遗忘,然后在月底对账时对不上。

处理办法取决于语义:

-- 语义一:把 NULL 归入"未知/其他"
SELECT COALESCE(country, '未知') AS country, COUNT(*)
FROM users GROUP BY COALESCE(country, '未知');

-- 语义二:把 NULL 排除在外
SELECT country, COUNT(*) FROM users
WHERE country IS NOT NULL GROUP BY country;

注意语义一的一个陷阱:如果直接 GROUP BY country 然后在 SELECT 里写 COALESCE(country,'未知'),结果是错的——因为分组依据的是原始列,渲染层再替换,等于把多组压成了一组显示,COUNT 会显得巨大。分组键和输出表达式必须一致。

同样地,SUM、AVG、MAX、MIN 都会忽略 NULL 行,而 SUM 全为 NULL 时返回 NULL 而不是 0。做金额汇总时,SUM 返回 NULL 参与下游计算(比如再加法或转字符串),很容易出现"字段变 null"或"字符串拼接出 null 字样"这类怪异结果。稳妥做法:

SELECT COALESCE(SUM(amount), 0) FROM orders;

六、三值逻辑在业务代码里的翻译

数据库的三值逻辑会顺着 ORM、序列化和业务判断一路传播,最后以一个诡异的形式出现在生产事故里。举几个真实见过的:

案例一:状态字段的"三态"设计。 用 NULL 表示"未处理",0 表示"已驳回",1 表示"已通过"。代码里写 if (!status),未处理的记录会被当成驳回处理——这是把三值逻辑降维成两值的经典错误。用枚举表达状态,永远比用 NULL 表达"第三种状态"更安全。

案例二:JSON 序列化。 Go 里把 sql.NullString 直接序列化,输出的 JSON 带着 "Valid": false, "String": "" 这种奇怪结构;前端拿到后判断 data.name === "" 判断不出来。同理 Java 的 Optional、Python 的 None,在跨语言边界上都容易变成字符串 "null"、空数组、缺字段三种不同形态。我的做法是:在 API 出参前统一过一遍,把可空字段显式转成 null,让"没有值"和"空字符串"在协议层就是两回事。

案例三:缓存穿透的空值语义。 数据库查不到记录时,很多实现缓存一个空标记来防止穿透。但如果查询条件本身是 WHERE phone = ? 而 phone 是可空的,NULL 语义会让你误以为"查到了但值为空",从而错误地写入空值缓存,把不存在的手机号永久拉黑。


七、我的工程规约

总结成几条能直接抄进团队规范的东西:

  1. SQL 中永不使用 = NULL、<> NULL、!= NULL,统一用 IS NULL / IS NOT NULL。
  2. 禁用 NOT IN 子查询,改用 NOT EXISTS。
  3. 禁止对索引列做函数包装或隐式类型转换,把变换挪到应用层,或改写成范围查询。
  4. 所有字段默认 NOT NULL,用空字符串、独立的"未知"枚举或单独的标记位表达缺失语义。三态逻辑只允许存在于明确建模过的地方。
  5. 对外接口层做一次空值归一化,让协议中的"缺失"只有一种表示。
  6. 建表时给关键字段设默认值并显式声明,减少 NULL 在数据入口处的产生。

还有一条元规则:把 NULL 当作一种独立的业务状态来建模,而不是"空"。数据库的 NULL 表达的是"未知/不适用",而业务上的"空"通常是可以确定的。两者的区别,在你写第一个 COALESCE 的晚上会被彻底理解。


写在最后

NULL 不会因为框架升级而消失,它已经存在了四十年,并且还将继续存在下去。真正让人痛苦的从来不是它的存在,而是它伪装成普通值的那段时间——直到线上告警响起,你才发现自己三个月前写下的一个 =,一直在悄悄改变语义。

所以我现在的做法是:任何一条我要长期维护的 SQL,先问自己一句——"如果某个字段是 NULL,这行代码会做什么?" 这个问题花不了十秒,但救过的故障远不止十次。

至于那些说"这个字段不可能为空所以不用管 NULL"的人——代码里最危险的一句话,永远是"不可能"。

相关文章

MySQL 说「提交成功」,用户却说「支付失败」:那个卡在中间的订单
你的服务卡了 30 秒,可能只是因为一个域名:DNS 解析那些没人愿意背的锅
重试风暴来的时候,你的服务还活着吗?——幂等性设计的实战笔记
CPU 才 50% 却卡成狗:你的服务没算力不够,是被内核掐了脖子
你的 JSON 序列化,正在偷偷吃掉 30% 的 CPU:Go 后端的 pprof 现场
你的 SQL 没写错,为什么还是会死锁?MySQL 锁机制实战与排查手册

发布评论