最近在帮同事排一个导出报表的工单。“已申请但未发货的订单”这一栏一直显示 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 审查时看到这种直接打回,不用商量。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. WHERE、JOIN 与反连接:三个最容易让整条 SQL 报废的 NULL 场景
2.1 NOT IN 陷阱:子查询里出现一个 NULL,整条 SQL 可能全军覆没
NOT IN 是 NULL 问题最集中的雷区,很多人写反连接时会首选它,因为它看起来最直观:
sql复制SELECT *
FROM dbo.Users u
WHERE u.UserId NOT IN (
SELECT b.UserId
FROM dbo.BlackList b
);
这段 SQL 想表达的是“找出所有不在黑名单里的用户”。如果 BlackList.UserId 这一列没有 NULL,结果确实符合预期。可只要黑名单表里存在哪怕一行 UserId 为 NULL 的记录,整个查询的结果就会变成空集。
原因仍然要回到三值逻辑。NOT IN 本质上等价于“不等于子查询结果中的任何一个值”,也就是说要逐个执行 u.UserId <> b.UserId 的判断。假设有一个用户 ID 是 10086,黑名单里有一条 UserId = 10087,还有一条 UserId = NULL:
sql复制10086 <> 10087 -- TRUE
10086 <> NULL -- UNKNOWN
两个条件组合成“不等于任何一个”时,必须所有项都为 TRUE,结果才为 TRUE。只要有一项是 UNKNOWN,最终结果就不是 TRUE,于是这个用户被过滤掉。更可怕的是黑名单里每条记录都会执行类似判断,最终所有用户都被排除。
这类问题的隐蔽性在于查询不报错、不超时,只是返回一个空结果,业务方看到的是“数据消失了”,不会第一时间想到 SQL 本身有逻辑问题。
2.2 NOT EXISTS vs LEFT JOIN:NULL 场景下如何做反连接
如果你用 NOT EXISTS 来写同一个需求,就不会受到子查询列是否可空的影响:
sql复制SELECT *
FROM dbo.Users u
WHERE NOT EXISTS (
SELECT 1
FROM dbo.BlackList b
WHERE b.UserId = u.UserId
);
EXISTS 和 NOT EXISTS 只关心子查询里有没有返回行,不关心子查询里的具体值是否为 NULL,所以在“判断是否存在匹配记录”这个场景下天然免疫 NULL 陷阱。这也是我多年来更推荐 NOT EXISTS 的原因。
熟悉 JOIN 的开发者可能还会用 LEFT JOIN + IS NULL 实现同样的效果:
sql复制SELECT u.*
FROM dbo.Users u
LEFT JOIN dbo.BlackList b ON b.UserId = u.UserId
WHERE b.UserId IS NULL;
这种写法逻辑上没问题,但要注意 WHERE 里用来判断“没有匹配上”的列必须是非空列,通常取子表主键。如果 BlackList 表本身没有主键,或者关联列允许为空,用 b.UserId IS NULL 做判断就可能把真实存在但关联字段为 NULL 的记录误判成“未匹配”。
所以我的经验是:反连接优先用 NOT EXISTS;遇到嵌套子查询里可能有 NULL 的 NOT IN 场景,直接改写。
2.3 排错还原:从“返回空集”到锁定 NOT IN 的完整链路
这里把之前一次排错过程完整写下来,方便遇到类似问题的人按同样的思路去复现。
当时现象是接口查出来永远是空数组,但直接查业务表明明有数据。我的排查链路是这样的:
第一步,先确认这不是缓存或权限问题。我把存储过程里的 SQL 单独拿出来在 SSMS 里执行,发现确实返回空。
第二步,缩小范围。把 NOT IN 那一段子查询单独跑出来看数据,发现子查询结果整体没毛病,都是正常的 ID 列表。
第三步,检查字段是否可空。这件事最容易漏,因为很多人看子查询结果时不会特意去看列里有没有 NULL。我在子查询外手动补了一个 WHERE UserId IS NOT NULL,再跑一次,数据就出来了。
第四步,定位到问题后,我没有在原 SQL 上继续打补丁,而是直接改写成 NOT EXISTS,彻底绕开 NULL 比较问题。
那次踩坑之后,我在团队里立的规矩是:SQL 中出现 NOT IN 时,评审必须追问子查询列可空吗?如果可空,请用 NOT EXISTS。这个习惯帮我省下了很多后续排查时间。
3. 聚合、分组与拼接:统计结果被 NULL 偷偷改写
3.1 COUNT(*) 和 COUNT(可空列):两种完全不同的口径
聚合函数对 NULL 的处理规则很微妙,最典型的是 COUNT。
sql复制SELECT
COUNT(*) AS total_rows,
COUNT(Phone) AS has_phone
FROM dbo.Customers;
COUNT(*) 统计的是行数,无论这行里某个字段是否为 NULL,只要这行存在就算进去。COUNT(Phone) 则只统计 Phone 不为 NULL 的行。假设表里有 100 个客户,其中 30 个人没有填手机号,那 COUNT(*) 返回 100,COUNT(Phone) 返回 70。
做报表时如果把这两个混用,很容易出现统计口径不一致的问题。比如想算“手机号缺失率”,正确公式是 COUNT(*) - COUNT(Phone) 作为分子,而不是用 COUNT(Phone) 直接当总数。
3.2 SUM/AVG/MAX/MIN 的忽略规则:什么时候该用 ISNULL 兜底
SUM、AVG、MAX、MIN 这四个函数会忽略 NULL 值。这个设计本身很合理:求和时跳过未知项,平均值也只对已知值求平均。
但正因为 AVG 会忽略 NULL,很多人容易算错平均分。假设一次测验 4 个人参加,2 个人缺考,成绩表里是 80、NULL、90、NULL。AVG(score) 返回的是 85,因为分母是 2 而不是 4。如果你想把缺考的当 0 分算进去,就要自己写:
sql复制SELECT AVG(ISNULL(score, 0)) FROM dbo.ExamScore;
这两种口径没有谁对谁错,但一定要在需求评审时确认清楚,否则报表上差 20 分都有可能。
还有一个容易忽略的点:如果某列所有值都是 NULL,SUM(col) 和 AVG(col) 返回的是 NULL,而不是 0。很多报表展示层没做处理,页面上就会出现“平均金额: ”这种空白内容。遇到这种情况,前面套一层 ISNULL(AVG(col), 0) 可以避免很多尴尬。
3.3 字符串拼接:一条 NULL 就可能让结果整段消失
SQL Server 里执行 'abc' + NULL,在默认配置下结果还是 NULL。也就是说一条记录里只要拼接的某个字段为 NULL,整个拼接结果就一起变成 NULL。这在生成完整地址、导出明细、打印回执单时非常容易出现“整列都没值”的诡异现象。
sql复制SELECT
CustName + ',电话:' + Phone + ',备注:' + Remark AS Info
FROM dbo.Customers;
如果某行 Remark 为 NULL,不管 Phone、CustName 里有没有值,整条 Info 直接消失。不是变成“备注为空”,而是整列变 NULL。
解决办法有几种:
- 用
ISNULL(Remark, '')包裹每一个可能为空的字段,缺点是比较啰嗦。 - 从 SQL Server 2012 开始使用
CONCAT函数:CONCAT(CustName, ',电话:', Phone, ',备注:', Remark)。CONCAT 会自动把 NULL 参数当成空字符串处理,不会污染整体结果。 - SQL Server 2017 以上还有
CONCAT_WS,可以指定分隔符并自动跳过 NULL,适合做地址、姓名这类拼接。
线上遇到“拼接结果无故为 NULL”时,先看 CONCAT_NULL_YIELDS_NULL 这个会话设置是不是 ON。默认就是 ON,也不必刻意去改,更靠谱的做法是使用 CONCAT 这类不会被 NULL 影响的函数。
3.4 GROUP BY 对 NULL 单独分组
GROUP BY 会把 NULL 当作同一个组来处理。比如订单表里 RefundTime 是空的行代表未退款,GROUP BY RefundTime 时所有未退款的订单会归到一组,这一组的 RefundTime 显示为 NULL。
这个行为本身并不复杂,但它经常和 HAVING、聚合表达式一起制造混淆。比如你想筛出“退款次数大于 2 的日期”,日期的 NULL 组如果数量恰好也满足条件就会出现在结果里,这时需要在 HAVING 里额外排除 NULL:
sql复制SELECT RefundTime, COUNT(*)
FROM dbo.Orders
WHERE RefundTime IS NOT NULL
GROUP BY RefundTime
HAVING COUNT(*) > 2;
WHERE 在 GROUP BY 之前过滤,比在 HAVING 里排除更高效,语义也更清晰。
4. ORDER BY、约束与索引:设计表之前先把 NULL 的脾气摸清
4.1 SQL Server 没有 NULLS FIRST/LAST,要“排到末尾”请自己写
不同数据库对 NULL 在排序中的默认位置不一致。PostgreSQL、Oracle 和 SQL Server 的默认行为各有差异,这也是数据库迁移时最容易暴雷的隐藏点。
在 SQL Server 里,如果执行 ORDER BY col ASC,NULL 默认排在最前面;如果执行 ORDER BY col DESC,NULL 会排到最后面。你可以理解为 SQL Server 把 NULL 当成最小值来处理。
但业务上经常会有“按时间倒序,时间最新的排最前,没填时间的放最后”这类需求。如果不做额外处理,直接 ORDER BY CreateTime DESC,NULL 天然在最后,刚好符合需求。可如果需求变成“按时间正序,最早的排最前,没填时间的放最后”,直接用 ASC 就会出现一组 NULL 在开头。
标准 SQL 的 NULLS LAST 在 SQL Server 里不受支持,所以需要自己用 CASE 表达式控制分组:
sql复制SELECT TaskName, FinishTime
FROM dbo.Tasks
ORDER BY
CASE WHEN FinishTime IS NULL THEN 1 ELSE 0 END,
FinishTime ASC;
这样所有 FinishTime 不为空的行按时间正序排,为空的行统一放到最后。这个写法比 ISNULL(FinishTime, '9999-12-31') 更清晰,也避免选一个并不存在的“最大日期”来占位。
4.2 主键、UNIQUE、CHECK 对 NULL 的态度完全不同
主键列不允许 NULL,这是硬性规定,因为主键的存在意义就是唯一标识一行数据。
UNIQUE 约束或唯一索引则允许多个 NULL。在 SQL Server 里,普通唯一索引会认为多个 NULL 是互不相同的值,因此一张表可以插入无数行 Code 为 NULL 的数据,只要非 NULL 的 Code 不重复就行。
这是很多新人会困惑的点:明明建了唯一索引,为什么 NULL 还能重复插入。理解之后反而要利用这一点。有些业务场景里,列大部分行都是 NULL,只要求少数有值的行不能重复,比如“兑换码”列,普通唯一索引就已经完美满足需求。
CHECK 约束前面说过,UNKNOWN 不会报错,所以如果想限制列必须有值,主键和 NOT NULL 才是真正的约束,CHECK 管不了 NULL 问题。
如果你遇到更罕见的需求——要求这一列最多只能有一个 NULL——SQL Server 也可以实现,做法是创建一个过滤唯一索引,只索引 NULL 行:
sql复制CREATE UNIQUE INDEX uq_t_col_empty
ON dbo.T (Col)
WHERE Col IS NULL;
这条索引能让第二行 NULL 插入时报唯一键冲突。不过实际业务中这种需求极少,不用刻意追求,知道有这么个技巧就够了。
4.3 过滤索引:大量 NULL + 少量业务值时的查询优化
可空列上建普通索引是可行的,但有个问题:如果这列 95% 的行都是 NULL,索引会变得又大又低效,里面存了大量实际上很少被查询的 NULL 条目。
SQL Server 从 2008 版本开始提供过滤索引,可以只对符合条件的数据建立索引。如果你的表里大部分行某个字段为 NULL,而查询又集中在非 NULL 的数据上,过滤索引是很划算的选择。
举个例子,订单表里只有已退款订单才会有 RefundTime,绝大多数行都是 NULL。业务经常需要查“最近 7 天退款成功的订单”,可以这样建:
sql复制CREATE INDEX IX_Orders_RefundTime
ON dbo.Orders (RefundTime)
WHERE RefundTime IS NOT NULL;
查询时写成:
sql复制SELECT OrderId
FROM dbo.Orders
WHERE RefundTime IS NOT NULL
AND RefundTime >= DATEADD(DAY, -7, GETDATE());
查询优化器能命中这个过滤索引,扫描的数据量比其他方案小得多。
因为过滤索引的 WHERE 条件必须出现在查询里才能被使用,所以写查询时不要偷懒省略 WHERE RefundTime IS NOT NULL。还有一点,如果后来把这个索引的过滤条件改了,旧统计信息不会自动做完整更新,索引维护也比普通索引更讲究一些,生产环境上线前最好在测试库跑一轮真实数据量验证。
可空列有大量 NULL 时,还有一个 SQL Server 特有选项叫“稀疏列”(SPARSE)。它能在存储层面把 NULL 值压缩掉,节省很多空间,但读取和更新的开销会略微增加,适合“90% 以上都是 NULL、但业务又确实需要这列”的表。我一般只在存储空间吃紧的归档表上用,常规业务表尽量不引入,避免增加维护成本。
5. 开发侧防御:在 SQL 里和应用程序传参处同时设好防线
5.1 ISNULL 与 COALESCE:除了参数个数,还有很多容易被忽略的差异
处理 NULL 时最常用的两个函数是 ISNULL 和 COALESCE,它们都能返回参数列表里第一个非 NULL 的值。
ISNULL 是 SQL Server 专有的写法,只接受两个参数。COALESCE 是标准 SQL 写法,可以传多个参数,返回第一个非 NULL 值。
很多人觉得既然功能差不多,那就随便用一个。但有个类型截断的坑容易踩。ISNULL 的结果类型由第一个表达式决定,COALESCE 则会综合所有参数的类型和长度推断结果类型。比如:
sql复制DECLARE @v VARCHAR(4) = NULL;
SELECT ISNULL(@v, '12345'); -- 结果是 1234,被截断了
SELECT COALESCE(@v, '12345'); -- 结果是 12345,类型推断取了更长的长度
VARCHAR(4) 只能存 4 个字符,ISNULL 把结果类型定为 VARCHAR(4),所以替补值被硬生生截断成 4 位。COALESCE 在推断时发现后面有更长的字符串,会把结果类型放宽。这类问题在生成固定长度编码时特别隐蔽,数据错了一时半会查不出来。
另外提醒一下,在 WHERE 条件中频繁使用 ISNULL(col, '') = '' 或 COALESCE(col, '') = '' 来匹配空值时,会破坏索引的 SARG 性质,导致查询无法做索引查找。如果某个可空列需要既查 NULL 又查空串,直接写成 col IS NULL OR col = '' 更有利于优化器使用索引。
5.2 NULLIF:用 NULL 来让聚合函数“无视”0
NULLIF 是个容易被低估的函数,它接收两个参数,如果两个值相等就返回 NULL,否则返回第一个值。
最常见的用法是防除零:
sql复制SELECT SUM(Amount) / NULLIF(COUNT(*), 0)
FROM dbo.Payments;
这样当 COUNT(*) 为 0 时,除号右边会变成 NULL,整个除法返回 NULL,而不是抛“除以零”错误。返回 NULL 之后应用层可以自己处理,总比数据库直接报错强。
NULLIF 还有另一个用途:用 NULL 排除某些值对聚合函数的影响。前面提过 AVG 会忽略 NULL,那就可以利用 NULLIF 把不想参与计算的值变成 NULL。比如统计订单平均退款金额时,RefundAmount = 0 表示没有退款,如果直接 AVG 会把 0 也算进去拉低均值,写成下面这样就只统计真正退了款的订单:
sql复制SELECT AVG(NULLIF(RefundAmount, 0))
FROM dbo.Orders;
业务口径要配合好,不是所有“0”都应该被排除,但这种组合是处理聚合逻辑时很顺手的一招。
5.3 可空参数的传参:不要把程序里的 null 当成数据库的 NULL
最后想聊聊后台代码传参时对 NULL 的处理,因为很多数据库层的灵异问题其实从参数类型就开始错了。
以 C# 为例,DateTime 是值类型,本身不能为 null,表达“没有值”时要用 DateTime? 或者 DateTimeOffset?。可是到了 ADO.NET 层,如果 model.ShipDate 是空的可空类型,直接把它扔给 SqlParameter,有些封装会正确转成 DBNull.Value,有些则可能
