最近在帮同事排一个导出报表的工单。“已申请但未发货的订单”这一栏一直显示 0,业务方盯着看半天,怀疑数据同步出了问题。我打开存储过程,一眼就看到这么一行:WHERE ship_date = NULL。写 SQL 写到一定年头,看到这种条件基本就可以断定问题在 NULL 身上,不需要再去怀疑环境和同步了。
如果你在 SQL Server 里也踩过“明明有数据却查不出来”“NOT IN 始终返回空集”“统计一列平均值得出的数字解释不通”这类问题,多半都跟 NULL 的三值逻辑有关。这篇笔记我把第 7 章的内容重新整理了一遍,从比较运算、聚合拼接、排序约束一直写到索引和代码传参,尽量把 NULL 在 SQL Server 里的全部“脾气”一次讲完。
1. 三值逻辑:先把 NULL 当成“未知状态”,而不是一个值
1.1 用“未知”来推导比较结果
很多刚接触数据库的人会把 NULL 理解成 0、空字符串、或者“什么都没有”,但从数据库的角度看,NULL 更准确的说法是“未知”。它不是某个具体值,而是一种状态,表示当前这一行的这个字段还没有被填上,或者说“我们还不知道它是什么”。
一旦把 NULL 当作“未知”,很多规则就顺理成章了。拿快递单号举例:左边这个包裹的单号是未知的,右边那个包裹的单号也是未知的,你能断言这两个单号一定相同吗?不能。你甚至不能说它们一定不同。所谓“未知与未知之间仍然未知”,就是这个道理。
SQL Server 的判断结果因此从二值逻辑变成了三值逻辑:
| 比较表达式 | 结果 |
|---|---|
| 1 = 1 | TRUE |
| 1 = 2 | FALSE |
| NULL = NULL | UNKNOWN |
| NULL = 1 | UNKNOWN |
| NULL <> 1 | UNKNOWN |
注意最后一行的 NULL <> 1 也是 UNKNOWN,这一点特别容易踩坑。很多人的直觉是“NULL 不等于 1,那条件应该成立呀”,但在三值逻辑下,NULL 与任何值比较的结果都不会是 TRUE,只会是 UNKNOWN。
WHERE 子句只保留结果为 TRUE 的行,FALSE 要排除,UNKNOWN 同样要排除。所以 WHERE ship_date = NULL 永远查不出任何数据,即使表里确实有一堆 ship_date 为空的记录,正确的写法只有一种:WHERE ship_date IS NULL。
1.2 同样的规则在 WHERE 和 CHECK 里表现为何不同
三值逻辑的影响范围不止 WHERE,它还延伸到 ON、HAVING、CHECK 约束等场景,但不同场景对 UNKNOWN 的处理策略不太一样。
WHERE 要求只有 TRUE 才放行,UNKNOWN 当过滤条件不成立处理。
CHECK 约束的逻辑则是:只要结果不是 FALSE,就允许写入。换句话说,UNKNOWN 在 CHECK 约束里是被放行的。
比如建表时写了 CHECK (Quantity > 0),插入一条 Quantity = NULL 的数据,SQL Server 不会报错。因为 NULL > 0 的结果是 UNKNOWN,UNKNOWN 不等于 FALSE,数据库认为“既然无法确定它违反约束,那就允许它进入”。这在标准 SQL 里是符合规范的行为,但很多开发者并不清楚。
所以设计时如果某列必须有值,不要指望 CHECK 帮你兜底,老老实实加 NOT NULL 才是正道。这个规则我曾经在评审代码时强调过很多次:CHECK 约束 + 可空列并不能替代 NOT NULL。
1.3 SET ANSI_NULLS:旧代码里为什么会出现 = NULL
有些老系统里能看到 WHERE col = NULL 这种写法,而且当年好像还能查出数据,原因在于 SQL Server 的 SET ANSI_NULLS OFF 选项。这个选项关闭以后,等号与 NULL 的比较会被当成普通值比较处理,col = NULL 就会匹配那些 col 为 NULL 的行。
但这属于历史遗留行为,不仅不符合 SQL 标准,还会让代码的可读性和迁移性变得极差。SQL Server 文档里已经明确未来会移除对 SET ANSI_NULLS OFF 的支持,新写的存储过程、视图、函数也不要指望靠这个选项来救急。
我自己的项目里有一条硬性规范:判断 NULL 一律写 IS NULL / IS NOT NULL,任何地方都不允许出现 = NULL 或 <> NULL。SQL
