聊到 sql null,很多人第一反应是,这不就是空值吗?用 IS NULL 判断一下不就行了。但真正开始写统计SQL、做数据清洗、做性能优化的时候,NULL带来的坑多得能把人埋进去。上个月给业务拉订单支付数据,我随手写了句 SUM(pay_amount),结果返回一行 NULL,报表直接白屏。排查下来发现是导入工具把整列写成了 NULL,聚合函数把所有行都“忽略”了。这种问题在 SQL 里太典型了,值得把整个体系梳理一遍。
不管是 MySQL 还是 SQL Server、Oracle、PostgreSQL,NULL 的处理逻辑有共性,也有很多数据库私货。今天就把 NULL 到底是什么、怎么写判断、怎么处理排序、聚合、去重、连表、索引、清洗,一次性讲清楚。全文适合日常写 SQL 的运营、数据分析、后端开发看,也适合刚入门数据库的新手,内容偏实战,不会有太多纯理论空谈。
1. NULL到底算个什么:先把三值逻辑讲透
1.1 NULL不是空值,是“未定义”
先解决一个最基础的认知问题:NULL不是空值,它是“不知道”“还没填”“没有定义”。我常拿登记表举例子:一张员工表里,婚姻状况这一栏如果空着,你不能说这个人就是“未婚”,他可能是已婚但没填,也可能是不透露,甚至可能是系统导入时忘了迁移。这个“空着的状态”,才叫NULL。
对比一下:0是“没有金额”的数字表示,空字符串是“没有任何字符”的文本,这两个都是确定的值。NULL则是“这个值压根不存在”。在SQL的世界里,NULL既不等于0,也不等于空字符串,更不等于false。很多新人把这三个概念混成一锅粥,后面写出来的SQL自然到处漏风。
这个认知直接决定了后面所有写法。你只有打心底里承认“NULL不是一个值,而是一种状态”,才可能理解为什么比较运算符在NULL面前会失灵,为什么聚合函数会忽略它,为什么索引在它面前会犹豫。
1.2 三值逻辑:为什么查着查着就数据少了
普通语言里判断条件通常只有true和false,SQL里却有个第三态:UNKNOWN。任何值与NULL做比较,结果都是UNKNOWN,只有 IS NULL 和 IS NOT NULL 能得到 TRUE/FALSE。
举例:找出工资大于10000的员工,WHERE salary > 10000不会返回salary是NULL的行,因为NULL > 10000的结果是UNKNOWN,不是FALSE也不是TRUE。如果你以为结果是“工资低于或等于10000的人”,那就错了,NULL行被静默丢掉了。
实际业务里这个坑特别隐蔽。比如查“不是管理层的员工”,写WHERE manager_id IS NULL,逻辑上没问题;但如果写WHERE manager_id = NULL,那好,一条都查不出来,因为NULL = NULL也是UNKNOWN。这个错误不会报错,所以特别容易漏过去。
CHECK约束也是三值逻辑的受害者:CHECK(age > 18)在age为NULL时是通过的,因为UNKNOWN不算违例。如果你真要求所有人必须成年,得写成CHECK(age > 18 OR age IS NULL)再配合NOT NULL,或者干脆约束age IS NOT NULL AND age > 18。这类问题在数据库设计评审时不注意,上线之后就是脏数据。
1.3 不同数据库对NULL的隐藏差异
跨数据库迁移时,NULL的差异很容易踩雷。最典型的是Oracle,空字符串''会被当成NULL。在Oracle里往一个VARCHAR2列插'',取出来就是NULL。MySQL、SQL Server、PostgreSQL则把''和NULL分得很清楚,所以从Oracle迁到MySQL时,原来“用空串表示没填”的逻辑全部要重新梳理。
排序的默认位置也不一样。MySQL认为NULL最小,升序时排在前面;SQL Server、Oracle、PostgreSQL则认为NULL最大,升序时排在最后。后面我专门讲排序时会展开,这里先提个醒。
还有唯一约束上的差异:MySQL和Oracle的唯一索引允许多个NULL存在,也就是说,可以插入多行唯一键为NULL的记录;SQL Server唯一索引却只允许一个NULL(至少旧版本是这样)。这些坑在数据迁移和主数据维护时一旦撞上,排查成本奇高,而且报错信息往往很隐晦,不会直接告诉你“是NULL导致的”。
2. 判断、转换、兜底:日常SQL里最常用的NULL处理写法
2.1 判空必须用IS NULL,不能用= NULL
判断NULL的唯一正确姿势就是 IS NULL 和 IS NOT NULL。不要用= NULL、!= NULL、<> NULL,这些写法在SQL里不会报错,但永远返回UNKNOWN,等于白写。
举个例子,你写SELECT * FROM t WHERE phone != NULL,本意是找出所有填了手机号的用户,结果查出来是空集,因为没有一行能通过条件。正确写法是WHERE phone IS NOT NULL。
注意别把SQL Server的ISNULL函数搞混。ISNULL(phone, '000')是“如果phone为NULL就返回'000'”的取值函数,和 IS NULL 判断是两码事。不同数据库这个函数的名字还不一样,MySQL叫IFNULL,Oracle叫NVL,作用都很接近,但判空的写法永远只有IS NULL这一套。
还有一个常见的组合场景:很多系统里“没填”既可能是NULL也可能是空字符串,尤其从Excel导入的数据,空格的、空串的、NULL的掺在一起。处理时要么统一口径,要么写成WHERE col IS NOT NULL AND col <> '',把两种情况都排除掉。这个写法在数据清洗时几乎是必用。
2.2 COALESCE和IFNULL:多列取首值
COALESCE是我用得最多的NULL处理函数。它接收一串参数,从左往右取第一个非NULL的值,比如COALESCE(NULL, NULL, 5)返回5。好处是参数数量不限,而且几乎所有主流数据库都支持,跨库迁移基本不用改。
用它做展示层兜底特别好:比如用户昵称为NULL时显示“匿名用户”,地址三要素拼接时某一段为空,用COALESCE(city,'') || COALESCE(area,'') || COALESCE(address,'')就能避免整段变成NULL。注意这个例子是Oracle和SQL Server的写法,MySQL拼接要用CONCAT,但思路一样。
用的时候有个细节:COALESCE的参数类型尽量保持一致。COALESCE(price, 0)没问题,但你要是把字符串和数字混在一起,数据库会做隐式转换,小表还好,大表可能影响索引使用。哪怕只是为了让SQL更可读,建议显式CAST一下,或者干脆在应用层处理完再传参。
2.3 NULLIF的妙用:反向兜底
如果说COALESCE是把NULL变成值,那NULLIF就是把特定值变成NULL,两者正好反过来。NULLIF(a, b)在a等于b时返回NULL,否则返回a。
最实用的场景是除零保护。统计人均订单金额时,如果没有订单,COUNT(*)就是0,SUM/0直接报错。写成SUM(amount) / NULLIF(COUNT(*), 0),当COUNT为0时NULLIF返回NULL,除法的结果就是NULL,外面再包一层COALESCE(... , 0),问题就解决了。很多报表系统的除法统计逻辑都是这么写的。
它还有一个妙用:把业务里的“魔法值”临时转成NULL。比如某个老系统用-1表示“未填写”,你想按NULL逻辑处理,直接SELECT NULLIF(score, -1) FROM t,后面就能用IS NULL判断了,不用改表结构,非常实用。
2.4 CASE WHEN做业务分支
当你要对NULL做多分支处理时,CASE WHEN最清楚。比如订单的优惠金额字段,可能是NULL、可能为0、可能是正数,要分三类统计,写CASE WHEN coupon IS NULL THEN '未使用' WHEN coupon = 0 THEN '无优惠' ELSE '已优惠' END就好读很多。
CASE WHEN的执行顺序是从上到下,第一个满足条件的就返回。建议把最严格、最具体的条件放前面,NULL判断放前面也可以,看业务优先。别为了少写几行用一堆嵌套函数,可读性在后期维护里比什么都重要。
我见过一个可怕的报表SQL,里面套了四层IFNULL加上两个CASE,目的是把NULL、空串、空格、'null'字符串全部归一化。这种SQL能跑,但一旦业务口径变化,改起来想死。遇到这种复杂清洗,我宁愿先开个临时表做一层预处理。
3. 排序、去重、连表:NULL最容易引发事故的三个场景
3.1 ORDER BY:NULL的位置并不固定
排序时NULL的位置在不同数据库里完全不一样:MySQL里NULL默认最小,ORDER BY col ASC时NULL在最上面;SQL Server、Oracle、PostgreSQL里NULL默认最大,ASC时NULL在最后面。你要是写过跨库报表,肯定能体会到这个不一致有多烦。
统一排序规则的方法,一个是标准语法:PostgreSQL、Oracle、MySQL 8.0都支持NULLS FIRST/LAST,比如ORDER BY col ASC NULLS LAST,意思是升序但NULL排在最后。SQL Server(包括2008 R2)不支持这个语法,得用表达式:ORDER BY CASE WHEN col IS NULL THEN 1 ELSE 0 END, col。这样NULL永远排最后。
这个特点在实际业务里很有用。比如用户列表按最后登录时间倒序,没登录过的用户你不能让他排最前面,于是ORDER BY CASE WHEN last_login IS NULL THEN 1 ELSE 0 END, last_login DESC,先让NULL沉底。排序结果对了,产品UI那边就不用再做二次处理。
3.2 DISTINCT和GROUP BY的NULL归组
DISTINCT会把所有NULL看成同一个值。也就是说,一列里只要有一个NULL,去重之后最后只会保留一个NULL行。这在大多数场景下是合理的,但你要知道它是这个行为。
GROUP BY也一样,NULL会单独成一组,这一组代表“未知的所有值”。报表上如果出现一个null分组,别愣着,说明有脏数据或者字段设计上有可空列。
还有一个容易被忽略的坑:多列去重时,只要其中有任何一列不为NULL,行就不会被合并。比如按(city, address)去重,一行是(北京, NULL),另一行是(北京, 朝阳路1号),这两行不会被合并。所以“去除空值”要在去重之前先处理,像写UPDATE把NULL统一成空串或固定占位值,然后再做DISTINCT。
3.3 JOIN与NOT IN:子查询让你悄悄丢数据
这是NULL引发的最高频事故,没有之一。原因就在于NULL = NULL结果是UNKNOWN,连接条件里只要有一方是NULL,两行就匹配不上。
拿INNER JOIN举例,A表有1000个订单,其中10个订单的user_id是NULL,JOIN用户表后,这10行直接消失。你以为是数据没匹配上,其实是NULL不参与匹配。LEFT JOIN稍好,主表行会保留,但关联表字段全是NULL。
更隐蔽的是NOT IN。假设要查没有下过单的用户,你写WHERE id NOT IN (SELECT user_id FROM orders)。只要orders.user_id列里有一个NULL,整个查询返回空集。为什么?因为NOT IN的逻辑是“id不等于子查询里任何一个值才算通过”,而id != NULL永远是UNKNOWN,没有任何行能通过。
解决方法是把NOT IN改成NOT EXISTS,或者先过滤子查询里的NULL:WHERE id NOT IN (SELECT user_id FROM orders WHERE user_id IS NOT NULL)。我个人强烈建议:凡是能写NOT EXISTS的地方就写NOT EXISTS,语义更安全,性能通常也更好。
3.4 唯一约束和主键:NULL不是“没填”那么简单
主键天然不能为NULL,这个大家都理解。但唯一索引和NULL的关系,不同数据库的处理方式差别很大。MySQL和Oracle认为“未知值不能互相比较”,所以允许插入多条唯一键为NULL的记录;SQL Server则把NULL当做一个普通唯一值,旧版本只允许一条NULL。这个差别会导致同样的表结构,在不同数据库上行为完全不同。
业务上的后果举一个:我们有个用户推荐码字段,要求唯一。没推荐码的用户在MySQL里可以插一万条NULL,都没问题;同样的表迁到SQL Server,第二次插入的时候就报唯一键冲突。遇到这种需求,推荐码字段不如直接设NOT NULL,没推荐的存空串或UUID,省得迁库时炸。
更通用的建议是:凡是会参与唯一判断的字段,最好不要允许NULL。如果业务允许“未填写”,那就用空串、用0、用默认值,把未知状态显式表达出来。数据库设计阶段多想一步,后面能少加很多班。
4. 聚合统计和窗口函数:报表里的NULL比你想象的更坑
4.1 COUNT(*)和COUNT(列)根本是两回事
COUNT(*)统计行数,不管这些行里有没有NULL;COUNT(列)只统计该列非NULL的行数;COUNT(DISTINCT 列)只统计该列去重后非NULL的取值个数。这三者的差别在报表里能直接让你算错指标。
最常见的失误是用COUNT(phone)统计总人数,结果比实际人数少。因为一部分用户没填手机号,那一行就被COUNT(列)扔掉了。正确的做法是SELECT COUNT(*) AS total_user, COUNT(phone) AS has_phone FROM user,各自表达自己的口径。
COUNT(DISTINCT)也有点反直觉:一列全是NULL时,COUNT(DISTINCT col)返回0,不是1。很多新人以为NULL也要算一个“去重后的值”,其实不算。记住这个规则,写指标口径时就不会糊了。
4.2 SUM/AVG的忽略逻辑与统计口径
SUM和AVG在计算时会自动忽略NULL行。这个“自动忽略”有时候是好事,有时候是灾难。全部行都是NULL时,SUM返回NULL而不是0,AVG也返回NULL。你在报表里如果不包COALESCE,前端拿到NULL直接展示一个空值,看着就很不专业。
更麻烦的是AVG的口径问题。假设一个班级10个人,2个人缺考,成绩列是NULL。AVG(score)算出来是8个人成绩的平均分,分母是8,不是10。如果你要算全班平均分,缺考按0分处理,就得写AVG(COALESCE(score, 0)),分母就变成了10。
这个口径差异在KPI统计里经常引发争论。我一般先问业务:NULL是应该被忽略,还是应该按默认值参与计算?确认清楚再写SQL。别自己拍脑袋,最后统计结果对不上,挨骂的还是写SQL的人。
4.3 GROUP BY和窗口函数中的NULL
GROUP BY会把NULL单独分一组,这点前面说过。报表上如果不想看到null分组,可以在分组前用COALESCE把NULL变成'未知'或者'未填写'语义值。别小看这一步,很多看报表的业务人员看到“null”两个字根本不知道什么意思。
窗口函数里NULL的坑也不少。比如RANK()和DENSE_RANK(),排序时NULL按数据库默认规则排在最前或最后,会直接影响排名结果。你希望NULL排最后,同样的,先ORDER BY一个辅助表达式控制NULL位置。
还有LAG/LEAD这种取上下文的函数,取出来的值本身可能带NULL。比如算“环比增长”,上个月金额是NULL,这个月是100,直接算差值就是NULL。稳妥做法是用LAG(amount, 1, 0)给默认值,或者外面套COALESCE。如果连默认值都没有,表示数据缺失,那比0更值得关注,需要人工排查。
5. 索引与慢SQL:带NULL的字段为什么有时快有时慢
5.1 索引对NULL的存储与利用
先说一个流传很广的说法:索引不存NULL,所以IS NULL查不到索引。这个说法不严谨。在MySQL InnoDB里,二级索引是会包含NULL值的(至少索引行存在),但优化器对IS NULL的处理确实比较保守,很多时候IS NULL条件的成本估算偏高,于是走了全表扫描。
实践中的结论是:别指望IS NULL能稳定走索引。如果你的查询热点是WHERE deleted_at IS NULL,且删除时间大部分行都是NULL,那么这列上建索引意义不大,因为要扫的数据太多了,全表扫更快。
真正能充分利用索引的条件,是那些带具体值的等值查询。比如WHERE status = 1 AND deleted_at IS NULL,status和deleted_at的复合索引通常能用上,因为优化器先用status = 1把范围缩小了。但如果你条件里只有deleted_at IS NULL,索引可能就不受待见。这种反直觉的行为,只有结合成本模型才能理解。
5.2 explain主要看哪些信息
排查慢SQL时,EXPLAIN是第一步。主要看这几列:type,代表访问类型,从好到差大致是 const、eq_ref、ref、range、index、ALL,看到ALL就得警惕全表扫描;possible_keys和key,看候选索引和实际用的索引,key等于NULL说明没走索引;rows是优化器估算的扫描行数,这个数越大基本越慢;Extra里的Using where、Using index、Using temporary、Using filesort也各有含义。
具体到NULL处理上,如果你写WHERE col = NULL这种错误表达式,执行计划往往会显示key为NULL、type为ALL,因为条件恒为UNKNOWN,优化器也不知道怎么用索引。另外,WHERE (col = 1 OR col IS NULL)这种查询,由于OR的存在,很难用到复合索引,最好拆成UNION ALL,或者改成IN (1)再专门处理NULL部分。
我踩过的一个坑是,给一个大表加了索引,EXPLAIN显示走了索引,但实际查询还是很慢。后来发现是查询里OR了多个条件,优化器在某个版本里就是不走复合索引,只能靠调整SQL结构解决。EXPLAIN只是参考,生产环境最好再用真实数据和ANALYZE验证执行计划。
5.3 建表设计:NOT NULL DEFAULT还是允许NULL
NULL问题最好在设计阶段解决,而不是在查询阶段补救。我的经验是:先问一问这个字段的NULL有没有业务含义。如果NULL表示“用户确实没有这个值”,比如离职时间,那保留NULL;如果NULL只是占位,比如创建人、状态、默认金额,那就NOT NULL DEFAULT加上合理默认值。
数值字段用0代替NULL,前提是0没有特殊业务含义。文本字段常用空字符串''代替NULL。时间字段麻烦一点,有些系统用'1970-01-01'或'9999-12-31'当默认值,但这类魔法值会让报表口径更乱,我一般只建议“必须填”的时间字段用NOT NULL,比如创建时间,而允许为NULL的时间字段就老老实实保留NULL。
最后提一句:ORM和SQL生成器也需要统一约定。Java开发用MyBatis时,<if test="field != null">里面传NULL和故意不传,可能拼出不同的SQL;代码里对NULL和空串的区分决定了数据落库后的形态。这个约定最好写进团队规范里,别等出问题时再扯皮。
6. 数据清洗实战:从脏数据到可用数据
6.1 清洗SQL:去除空值和替换空值
数据清洗最常见的需求有两个:去掉NULL行,或者把NULL替换成有业务含义的值。去掉NULL行的写法很简单:DELETE FROM t WHERE col IS NULL;生产环境不要直接DELETE大表,建议先SELECT确认数据量,再分批删。
替换NULL的常见SQL:UPDATE user SET phone = '' WHERE phone IS NULL;UPDATE user SET score = 0 WHERE score IS NULL;一遍过后,查询语句就可以直接COUNT(col)和SUM(col)了。这个操作我一般放在例行数据维护脚本里,每周跑一次,防止业务系统漏数据。
另一种情况是查询时不想动表数据,只想临时处理。MySQL可以SELECT COALESCE(NULLIF(TRIM(col), ''), '未知') AS col_name FROM t,把空格、空串、NULL统一变成'未知'。注意TRIM后面嵌套顺序:先去掉空格,空串再用NULLIF转NULL,最后COALESCE兜底。很多人不会用NULLIF,其实在这种场景里它是最优雅的工具。
6.2 完整案例:订单支付完成率统计
来个综合一点的例子。一张orders表存储订单,字段包括order_id、user_id、pay_amount、pay_time,支付时间的NULL表示该订单还没支付。现在要出一份报表:总订单数、已支付订单数、支付总额、支付用户数、人均支付金额。
SQL可以这样写:
SELECT COUNT(*) AS total_orders, COUNT(pay_time) AS paid_orders, COALESCE(SUM(pay_amount), 0) AS total_amount, COUNT(DISTINCT CASE WHEN pay_time IS NOT NULL THEN user_id END) AS paid_users, COALESCE(SUM(pay_amount) / NULLIF(COUNT(pay_time), 0), 0) AS avg_amount FROM orders;这里COUNT(pay_time)统计的是已支付订单行数,因为只有支付过的行pay_time才非NULL;SUM(pay_amount)其实就是支付金额合计,因为未支付行的pay_amount一般是NULL;最后人均支付金额用SUM/NULLIF(COUNT(pay_time), 0)避免除零。
注意:如果未支付订单pay_amount也填了0而不是NULL,那COUNT(pay_time)依然能正确区分,SUM也正常。但如果pay_amount全是NULL,SUM返回NULL,必须COALESCE。手工写报表时我会把每步结果先单独查一遍,确认口径。这个习惯帮我省了很多返工时间。
6.3 应用层与SQL配合的几个注意点
SQL层的NULL处理只是链路的最后一环。数据从应用层进来的时候,如果一开始就没约定好,后面清洗成本很高。比如Java里用MyBatis插入用户,如果phone字段是NULL,写进数据库是NULL还是空串,取决于insert语句里有没有做COALESCE和if判断。
接口对接场景也有类似问题。第三方传来的JSON里某些字段是null,到了你数据库里如果直接落库就变成了数据库的NULL;等你要做统计时,又回到前面说的那一堆坑。所以我建议在数据入口层就做转换:外部null统一按业务规则转成'未知'、0或空串,能减少很多下游麻烦。
另一个注意点是:代码里查出来的结果映射到对象时,数据库NULL到了Java里就是null,到了Python里就是None,前端拿到可能是空。这个传播链很长,任何一环没统一,最终展示都会出问题。所以团队规范里最好白纸黑字写清楚,NULL、空串、0分别代表什么。
7. 排查实录与避坑清单
7.1 常见问题速查表
整理一个常见的症状对照表,排查时对着看就行:
| 现象 | 可能原因 | 推荐解法 |
|---|---|---|
| WHERE col = NULL 查不出数据 | 比较返回UNKNOWN,非结果为真 | 改用IS NULL |
| NOT IN子查询结果空集 | 子查询结果含NULL | 改NOT EXISTS或过滤NULL |
| COUNT(列)明显比COUNT(*)少 | 列中存在NULL被忽略 | 统计总行数用COUNT(*) |
| SUM(列)显示NULL | 该列全为NULL | COALESCE(SUM(col), 0) |
| AVG(列)与业务口径不符 | NULL被忽略,分母变化 | AVG(COALESCE(col, 0)) |
| ORDER BY后NULL位置不对 | 数据库默认不同 | NULLS LAST或CASE WHEN |
| LEFT JOIN右表字段为NULL | 关联键有NULL或确实无匹配 | 查关联键是否NULL |
| 唯一索引插入多条NULL无报错 | 不同库对NULL唯一性处理不同 | 唯一字段设NOT NULL |
| 报表里多了一个null分组 | GROUP BY对NULL单独成组 | 提前COALESCE成有业务含义值 |
这张表我打印出来贴过工位,新同事来问问题,先让他们对着表自查一遍,很多问题不用我出手就解决了。
7.2 别把各种“null报错”混淆
在网上搜SQL NULL相关资料的时候,会搜出一堆和数据库无关的报错,比如NPM的“cannot read properties of null (reading 'edgesout')”、Nacos配置里的serverAddr='null'、内核日志里的kernel null pointer dereference,还有各种HTTP接口返回里的data: null。
这些都属于“应用层空指针/空对象”问题,和SQL的NULL语义完全是两码事。数据库的NULL是一种数据值,应用层的null通常表示内存里没有这个对象。排查的时候先分清报错出现在哪个层面:是SQL查询结果不对,还是程序运行时对象为null,还是框架配置中出现了字符串'null'。别被关键词误导,方向错了会白折腾很久。
我见过群里有人贴了个NPM报错问“是不是数据库NULL导致”,底下还有人认真分析,其实完全是前端依赖安装的问题。搜索关键词是一回事,定位问题还是要靠经验和上下文判断。
7.3 最后一点经验
写SQL这么多年,我个人的体会是:所有数据统计类SQL,写完必须用边界数据测一遍。所谓边界数据,就是空表、全NULL列、只带NULL关键字的行、超大值、极小值。这几个用例跑完,很多NULL相关的坑都能提前暴露。
另外就是,多跟业务方确认口径。NULL是被忽略,还是按默认值参与统计,直接决定最终数字,而这个数字会影响业务决策。宁可写慢一点,也要问清楚。
最后送上一句实用建议:团队里建一份简单的NULL处理速查卡,把判空写法、排序差异、聚合口径、NOT IN陷阱写进去。新人入职看一遍,能少踩一半的坑。这份东西我用了几年,每次有人拿着诡异的结果来问,对照速查卡基本十分钟内就能定位。