我先说一个真实场景。去年有个同事跑统计报表,发现某条业务线的平均成交金额怎么算都不对,查了大半天,最后发现是有两单的金额字段是NULL,被AVG函数直接忽略了。这类问题在SQL Server里几乎每天都在发生,而且很多写了好几年SQL的开发者都会在NULL上栽跟头。要说SQL里哪个知识点最不起眼、同时又最影响结果,我一定投NULL值一票。
这篇笔记是《SQL Server笔记》系列的第七篇,专门把NULL值从判断、聚合、拼接、传参到索引约束一层层剥开。不管你是刚接触SQL的初学者,还是写了好几年业务SQL的开发者,只要你的代码里出现过“怎么查不到数据”“为什么结果比预期少一截”这类疑问,这篇笔记大概率能帮你找到根源。下面直接上干货。
1. 先把NULL的本质搞清楚:它不是空,是未知
1.1 NULL和0、空字符串到底差在哪
在解释NULL之前,我习惯先做一个区分:NULL、0、空字符串这三者不是一回事。0是一个真实的数值,你可以对它做加减乘除;空字符串''是一个真实的字符值,长度是0;而NULL表达的是“未定义”或者“不知道”,它表示这一行的这个列没有保存任何有效值。
用调查问卷打比方:年龄一栏填0,说明受访者认为自己是0岁;空着没填,才是NULL;填了个空格,是一个包含空格的字符串。这三种数据在SQL里的行为完全不同,千万不要混为一谈。
从存储层面看,NULL也不同于类型的默认值。SQL Server建表时,如果列没有指定NOT NULL,就允许这列为NULL。一个INT列可以为NULL,一个VARCHAR列也可以为NULL,NULL不是该数据类型的某个具体取值,而是一个独立的“未知状态”标记。这个区别决定了后面所有操作的行为。
我记得有一次线上数据排查,发现某个客户的手机号字段里全是字符串"null",而不是数据库里的NULL。这是程序端把NULL转成字符串写进去了,结果导致所有手机号校验、去重逻辑全部失效。这种问题比NULL本身更难查,因为它看起来有值,实际上却是脏数据。
1.2 三值逻辑:查询条件其实是“三选一”
大多数编程语言里,布尔表达式只有两个结果:真和假。但SQL逻辑里存在第三个结果:UNKNOWN,也就是“未知”。这个设计完全是为了处理NULL而存在的。
举个例子,你执行下面这条查询:
sql复制SELECT * FROM users WHERE age = 18;
如果某一行的age是NULL,那么age = 18这个表达式的计算结果不是FALSE,而是UNKNOWN。WHERE子句只接受TRUE的结果,UNKNOWN和FALSE都会被过滤掉。所以这一行不会出现在结果集里。
这里有一个新手必踩的坑:NULL = NULL 的结果也是UNKNOWN,而不是TRUE。也就是说,不能用等号判断两个列都为NULL。比如你查“手机号和备用手机号相同的人”,写WHERE phone = backup_phone,如果两个手机号都是NULL,这行也不会返回。因为数据库认为两个“未填写”的手机号是否相等,是一个无法回答的问题。
理解了三值逻辑,后面所有的坑就都能推出来。为什么= NULL查不到数据?因为结果是UNKNOWN。为什么NOT IN有时结果为空?因为条件变成了UNKNOWN。为什么CHECK约束拦不住NULL?因为约束只阻止结果为FALSE的情况,而UNKNOWN不算FALSE。这些我们下面一个一个展开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 判断和过滤:为什么 = NULL 永远查不到记录
2.1 正确姿势:IS NULL / IS NOT NULL
如果你想查某个列是否为NULL,唯一正确的写法是:
sql复制-- 查手机号没填的用户
SELECT * FROM users WHERE phone IS NULL;
-- 查手机号已填的用户
SELECT * FROM users WHERE phone IS NOT NULL;
很多刚入门的人会在代码里写WHERE phone = NULL,然后查不出结果就怀疑数据库坏了。这不是数据库的问题,而是SQL标准规定的行为:任何与NULL直接比较的表达式,结果都是UNKNOWN。=不行,!=也不行,>、<、>=、<=统统不行。
我带人的时候给新人总结过一个顺口溜:判断NULL不用等号,IS NULL才是解药。这句话看起来简单,但实战里能帮你节省大量排查时间。另一个容易忽略的地方是,程序端判断也有类似问题,比如C#里用DataTable读取数据库,某一列是NULL时,拿到的不是null,而是DBNull.Value,所以写代码判断时也要用专门的方式。
2.2 NOT IN 遇到 NULL 会翻车,重要程度五颗星
这是NULL相关话题里最经典的坑,没有之一。很多开发者在写排除逻辑时,喜欢用NOT IN,一旦子查询结果里混入NULL,整个查询结果就可能变成空集。
看这个例子:
sql复制SELECT * FROM orders
WHERE customer_id NOT IN (
SELECT customer_id FROM blacklist
);
如果blacklist表里存在customer_id为NULL的记录,这条查询很可能一条数据都查不出来。原因要从NOT IN的语义推起:customer_id NOT IN (1, 2, NULL) 等价于 customer_id != 1 AND customer_id != 2 AND customer_id != NULL。前面两个条件可能是TRUE,但最后一个customer_id != NULL的结果永远是UNKNOWN。在SQL的三值逻辑里,TRUE AND TRUE AND UNKNOWN 的结果是UNKNOWN,所以整个WHERE条件不成立。
这个坑最迷惑人的地方在于:orders表里根本没有customer_id为NULL的数据,结果照样翻车。问题出在子查询右侧的NULL,而不是主查询的数据。我见过不止一次,生产环境的查询偶尔返回空结果,排查半天才发现是黑名单表里有一条customer_id未填的脏数据。
解决办法很简单,用NOT EXISTS替代:
sql复制SELECT * FROM orders o
WHERE NOT EXISTS (
SELECT 1 FROM blacklist b
WHERE b.customer_id = o.customer_id
);
NOT EXISTS的逻辑是逐行匹配,只有当子查询真的匹配到关联键时才返回TRUE。如果b.customer_id是NULL,那么b.customer_id = o.customer_id的结果是UNKNOWN,不会形成匹配,因此不会影响结果。这样写虽然多了一行代码,但能彻底避开NULL带来的隐性问题。
2.3 用 EXISTS / NOT EXISTS 替代 IN 系列
IN的坑虽然没有NOT IN那么致命,但同样需要注意。举个例子:
sql复制SELECT * FROM orders WHERE customer_id IN (1, NULL);
如果有一行customer_id是2,那么条件2 IN (1, NULL)会变成(2 = 1 OR 2 = NULL),最后结果是FALSE OR UNKNOWN,等于UNKNOWN。结果这一行依旧不会返回。所以即使是IN,只要列表或子查询里出现NULL,原本合法匹配的行也可能被悄悄过滤掉。
在实际业务中,我建议凡是子查询可能返回NULL的场景,一律优先使用EXISTS或NOT EXISTS。它们对NULL的处理更贴近人的直觉:匹配就返回TRUE,不匹配就返回FALSE,不会产生第三种“未知”状态。这也算是SQL开发里一个“宁可多写两行,也不要留隐患”的典型例子。
另外补充一个相关性很强的坑:CHECK约束不拦NULL。比如建表时写了CHECK (price > 0),然后插入price为NULL的记录,SQL Server会允许插入。因为NULL > 0是UNKNOWN,约束只拒绝FALSE,不拒绝UNKNOWN。如果你真的想禁止NULL,必须在列定义上加NOT NULL,或者把CHECK写成price > 0 AND price IS NOT NULL。凡是建表规范里说“价格必须大于0”,千万别以为一个CHECK就够了。
3. 聚合统计里的NULL:报表数字对不上,多半是这里
3.1 COUNT(*) 和 COUNT(列) 差得不是一星半点
聚合函数是NULL的另一个重灾区,尤其是COUNT。很多报表数字对不上,根源就在这里。
先说结论:COUNT(*)统计的是表里的行数,不管这一行里有没有NULL;COUNT(列)统计的是该列非NULL值的个数。两者在数据完整的情况下结果一样,但只要这一列存在NULL,结果就会立刻出现偏差。
举个例子,orders表有100行,amount列有5行是NULL。那么COUNT(*)返回100,COUNT(amount)返回95。如果你本来想统计订单总数,却习惯性写了COUNT(amount),就会少算5单。反过来,如果你统计“有金额的订单数”,那COUNT(amount)才是对的。
我自己的习惯是:统计行数永远用COUNT(*)或者COUNT(1),不要随手填一列进去。只有明确业务上要“统计该列有值的数量”时,才用COUNT(具体列)。这个习惯能避免一大类报表错误。
3.2 SUM、AVG、MIN、MAX 对NULL的处置
SUM会忽略NULL行,只对非NULL值求和。但有个隐蔽问题:如果某一组数据全是NULL,SUM返回的不是0,而是NULL。报表里一旦没做处理,显示出来的就是空白,而不是0。这时候需要用ISNULL或COALESCE包一层:
sql复制SELECT COALESCE(SUM(amount), 0) FROM sales WHERE salesperson_id = 100;
AVG比SUM更容易出错。AVG忽略NULL,而且只用非NULL值的数量做分母。比如5个学生考试成绩,一个缺考,成绩列为NULL,剩下4个人是60、70、80、90。AVG(score)的结果是(60+70+80+90)/4 = 75,不是(60+70+80+90+0)/5 = 60。
这在业务上到底该不该把缺考当成0分,取决于规则。但很多报表默认AVG就是“所有人平均分”,结果一遇到缺考就偏了。如果你的业务要求缺考算0分,写SQL时要显式转换:
sql复制SELECT SUM(ISNULL(score, 0)) / COUNT(*) AS avg_score
FROM score_table;
MIN和MAX同样忽略NULL,只取非NULL值里的极值。这在大多数场景下符合预期,但如果你想知道“包括NULL在内的排序”,那就得另想办法了。
3.3 GROUP BY 会把NULL单独归为一组
分组统计时,如果分组列里有NULL,SQL Server会把所有NULL行归到同一组,并在结果里显示一个NULL分组。比如按region分组统计订单数,有一部分订单没有填region,结果就会出现一行region为NULL的记录。
如果你不希望在报表里显示NULL组,可以在分组前用ISNULL处理:
sql复制SELECT ISNULL(region, '未知区域') AS region, COUNT(*)
FROM sales
GROUP BY ISNULL(region, '未知区域');
这里有个细节要注意:GROUP BY后面跟的是转换后的表达式,SELECT里的别名不能直接用在GROUP BY里(至少在SQL Server的语法限制下,不能依赖SELECT别名)。所以要在GROUP BY里重复写一遍ISNULL表达式,或者在外面包一层子查询。另外,ORDER BY升序时,NULL默认排在最前面,如果你想让NULL排在最后,需要显式处理,比如ORDER BY (CASE WHEN col IS NULL THEN 1 ELSE 0 END), col。
4. 字符串拼接和日期比较:NULL的“连锁反应”
4.1 + 拼字符串:一个NULL毁掉整条数据
SQL Server里用加号拼接字符串,和大多数编程语言不一样。在C#里,"abc" + null 的结果是"abc",但在SQL Server里,'abc' + NULL的结果是NULL。这个差异坑了无数人。
常见的场景是拼接用户名展示:
sql复制SELECT first_name + ' ' + last_name AS full_name
FROM users;
只要first_name或last_name任意一个为NULL,整个full_name就是NULL,而不是另一个名字。用户端看到的就不是“缺了姓”,而是整个字段空白。
解决办法有两个。SQL Server 2012及以上版本可以用CONCAT函数:
sql复制SELECT CONCAT(first_name, ' ', last_name) AS full_name
FROM users;
CONCAT会把NULL当成空字符串处理,结果是“姓 + 空格 + 名”,逻辑接近直觉。如果你的环境还停留在老版本,或者项目规范要求兼容旧SQL Server,就用ISNULL手动补:
sql复制SELECT ISNULL(first_name, '') + ' ' + ISNULL(last_name, '') AS full_name
FROM users;
这里我提醒一句:CONCAT在参数全部为NULL时,返回的是空字符串'',不是NULL。有些业务逻辑希望返回NULL,那就不能直接用CONCAT,需要先判断。
4.2 日期列里的NULL:视图里查“当天”要注意
日期列是NULL的另一个高发区。和NULL日期比较,结果同样是UNKNOWN,所以日期为NULL的行绝不会出现在任何范围条件里。
有一个很常见的需求:创建视图只显示当天数据,网上也经常有人问“视图过滤只显示当天的情况,要如何查询历史数据”。问题往往出在视图里写死了当天条件。比如:
sql复制CREATE VIEW v_today_orders AS
SELECT * FROM orders
WHERE order_date >= CAST(GETDATE() AS DATE)
AND order_date < DATEADD(DAY, 1, CAST(GETDATE() AS DATE));
这样创建的视图,天然只包含今天的数据,而且order_date是NULL的行永远不在视图里。如果业务临时想查昨天或前天的数据,这个视图就变成了一堵墙,只能改视图或者写另一套查询。
我的建议是,视图尽量保留全量数据,把“当天”这个条件放到调用SQL里去写,或者用表值函数传入日期参数,而不是把日期写死。另外,在日期列上直接写CAST(order_date AS DATE) = CAST(GETDATE() AS DATE)会导致索引失效,因为对列做了函数转换。SQL Server更愿意走索引的写法是范围条件:order_date >= '当天零点' AND order_date < '明天零点'。这个习惯对大数据量查询很重要。
4.3 空字符串和NULL:别为了“避免NULL”把数据搞脏
有些开发者在设计表时,为了省事,把所有字符串列都设置成NOT NULL DEFAULT '',想用空字符串代替NULL。这个做法我在项目里见过不少,但它带来的麻烦往往比NULL本身更多。
比如用户地址,如果“省”存的是空字符串,而“市”是NULL,那查询时要么拼出奇怪的字符串,要么统计时COUNT(省)和COUNT(市)口径不一致。更麻烦的是,空字符串和NULL在程序端判断逻辑完全不同,写代码的人要同时处理两种“空”的状态。
我的观点是:如果业务上确实存在“未填写”的状态,就老老实实允许该列为NULL;如果业务要求“必须有值”,就设置NOT NULL。把NULL强行转换成空字符串,只是把问题从一个坑挪到另一个坑,并没有真正解决。
5. C#程序传参:给datetime赋NULL值的前前后后
5.1 可空类型 DateTime? 怎么用
前面讲的是SQL层面,但实际开发中,SQL Server经常要和C#这类程序语言打交道。热度很高的一个问题是“c# datetime怎么赋null值”,这里面有几个容易混淆的点。
C#里DateTime是值类型,本身不能赋null。想要表达“没有这个日期”,要用可空类型DateTime?,也就是Nullable
csharp复制DateTime? birthday = null;
if (model.Birthday.HasValue)
{
DateTime dt = model.Birthday.Value;
}
else
{
// 未填写
}
等号赋值null没问题,但最后写入数据库时,不能直接把null塞给SqlParameter,要用DBNull.Value。这是很多新手卡住的地方。
5.2 SqlParameter 的坑:null 和 DBNull.Value 不是一回事
在ADO.NET里,SqlParameter.Value属性如果是null,ADO.NET可能会认为这个参数“没有提供”,而不是“要写入NULL”,最终可能导致列使用默认值,或者在严格模式下直接报错。正确做法是显式赋值DBNull.Value:
csharp复制cmd.Parameters.Add("@birthday", SqlDbType.DateTime).Value =
birthday.HasValue ? (object)birthday.Value : DBNull.Value;
注意这里要把birthday.Value转成object,因为三元运算符两边类型要一致,而DBNull.Value是object类型。
读取的时候同样要注意。DataReader或者DataRow从数据库拿到NULL列时,返回的是DBNull.Value,不是null:
csharp复制if (reader["birthday"] != DBNull.Value)
{
DateTime dt = Convert.ToDateTime(reader["birthday"]);
}
如果直接用reader["birthday"] == null去判断,永远为false,因为这个判断的是DBNull.Value是否等于null,而DBNull.Value是一个非null对象。
如果你用EF Core这类ORM,框架会自动在CLR的可空类型和数据库的NULL之间做转换,省事很多。但手写ADO.NET、处理DataTable或写存储过程参数时,DBNull.Value仍然是绕不开的基础知识。
5.3 数据库层面如何接收NULL
从数据库角度看,要把NULL写入某一列,前提是这个列允许NULL。建表时如果写了NOT NULL,那么程序端传DBNull.Value过来就会插入失败,报类似“不能将值NULL插入列”的错误。
所以表结构设计要先想清楚:哪些列代表“可选信息”,哪些列是“必填信息”。可选的用NULL表示未填写,必填的用NOT NULL约束。这个决定越早做,后面程序端处理越省心。
另外,如果一个VARCHAR列既可能存NULL,也可能存空字符串,程序端读取时就得同时判断两种状态。建议在团队规范里统一约定:字符串列要么允许NULL,用NULL表示空;要么NOT NULL DEFAULT '',用空字符串表示空。不要两种混着来,否则查询统计会非常痛苦。
6. 处理函数选型:COALESCE、ISNULL、NULLIF 怎么用不乱
6.1 COALESCE 和 ISNULL:看着像,骨子里不一样
ISNULL和COALESCE都能做“为空时返回默认值”,但它们的差别不止是名字。
COALESCE是SQL标准语法,而且可以传多个参数,返回第一个非NULL的值:
sql复制SELECT COALESCE(phone, mobile, '无联系方式') FROM users;
ISNULL是SQL Server专有函数,只能传两个参数,第一个是判断表达式,第二个是替换值:
sql复制SELECT ISNULL(phone, '无联系方式') FROM users;
两者在简单场景下长得差不多,但在类型处理上有隐藏差异。看这个例子:
sql复制DECLARE @a CHAR(3) = NULL;
SELECT ISNULL(@a, 'abcdef'); -- 结果是 abc
SELECT COALESCE(@a, 'abcdef'); -- 结果是 abcdef
原因是ISNULL以第一个参数的类型为准,@a是CHAR(3),所以字符串'abcdef'被截断成'abc';COALESCE则根据类型优先级选择结果类型,字符串字面量的优先级更高,所以保留完整的'abcdef'。
这个差异在实际开发中很容易导致数据被悄悄截断。如果拿ISNULL的结果去插入一个CHAR(3)列,可能看起来没问题,但输出长度已经变了。我的建议是:项目里如果追求可移植性,优先用COALESCE;如果只是临时在SQL Server里做简单替换,ISNULL也没问题,但要留意返回类型。
6.2 NULLIF 防除零:最实用的一个骚操作
NULLIF(a, b)的作用是:如果a等于b,返回NULL;否则返回a。最常见的用途是防止除零错误。
比如统计平均客单价:
sql复制SELECT total_amount / NULLIF(quantity, 0) AS avg_price
FROM sales;
当quantity为0时,NULLIF返回NULL,total_amount除以NULL的结果是NULL,不会报“除以零”的错误。如果希望显示成0,再包一层COALESCE:
sql复制SELECT COALESCE(total_amount / NULLIF(quantity, 0), 0) AS avg_price
FROM sales;
这比写CASE WHEN要短,而且逻辑更清晰。很多开发者在报表SQL里会频繁用到这个技巧,尤其是分母来自汇总结果时,0和NULL都可能出现,NULLIF能把这两种情况统一处理掉。
6.3 组合拳:一次替换、一次转换完成业务兜底
在实际项目里,光用一个函数往往不够。比如手机号字段,既可能是NULL,也可能是空字符串,想统一显示成“未填写”,可以这样组合:
sql复制SELECT COALESCE(NULLIF(ISNULL(phone, ''), ''), '未填写') AS phone_display
FROM users;
拆开看:ISNULL把NULL变成空字符串;NULLIF把空字符串变回NULL;COALESCE再把NULL替换成“未填写”。这套组合能同时处理NULL和空字符串两种脏数据,在清洗数据场景里非常实用。
当然,这种嵌套表达式可读性一般,如果项目里出现频率很高,也可以在视图或计算列里封装,让业务层只读结果。或者用CASE WHEN写得更直白:
sql复制SELECT CASE WHEN phone IS NULL OR phone = '' THEN '未填写' ELSE phone END
FROM users;
两种写法都行,具体选哪种取决于团队风格。我个人的原则是:表达式超过两层嵌套,优先用CASE,避免后面维护的人看半天才懂。
7. NULL与约束、索引:数据库层面怎么“收留”NULL
7.1 UNIQUE约束下多个NULL不算重复
SQL Server里,UNIQUE约束允许多行同时为NULL。看这个例子:
sql复制CREATE TABLE users (
id INT PRIMARY KEY,
phone VARCHAR(20) UNIQUE
);
INSERT INTO users (id, phone) VALUES (1, NULL);
INSERT INTO users (id, phone) VALUES (2, NULL); -- 不会报错
两条记录的phone都是NULL,却都能插入成功。原因是SQL Server在判断唯一性时,把NULL和NULL当作“不相同”,因为NULL = NULL是UNKNOWN,所以不构成重复。这在大多数场景下符合业务直觉:手机号唯一,但没填手机号的用户不限制数量。
如果你确实希望“没填手机号的用户也只能有一个”,普通UNIQUE约束做不到。常见的替代方案是:把列设为NOT NULL,用默认值如''表示未填,这样UNIQUE约束就能控制空值唯一了。或者用过滤索引等方式实现,但复杂度高一些。设计方案时,先明确业务上“未
