1. 线上数据凭空消失:默认值为NULL的列是如何背锅的
1.1 一次活动数据"少了三行"的完整排查过程
去年我接手一个活动运营系统,运营同事反馈某个页面的报名人数和数据库里的实际记录对不上,页面显示的数据比库里少了三行。第一反应是缓存问题,清了缓存没用;第二反应是查询条件写错了,翻了一遍SQL也没发现异常。
最后是把查询结果一条条和全表对比,才发现少的几行都有一个共同特征:某个字段值是NULL。那个字段建表时没指定默认值,列又是允许NULL的,于是插入时没传值就落成了NULL。查询用的是WHERE status != 1,MySQL里NULL != 1的结果不是true也不是false,而是NULL,最终被当作不成立处理。
这就是默认值NULL最常见的坑:不等值查询(!=、NOT IN、<>)会把NULL行全部过滤掉。线上数据不是真的丢了,而是查询语义把NULL排除了,但业务方看到的就是"数不对"。
1.2 等值、不等值、IN子句,NULL在WHERE里的三宗罪
整理一下我在实际项目里遇到过的NULL查询问题,基本上集中在三类写法:
| 查询写法 | 查询意图 | NULL行的实际表现 |
|---|---|---|
WHERE name = 'a' | 找名字为a的记录 | NULL行不会命中,这是正常的 |
WHERE name != 'a' | 找名字不是a的记录 | NULL行也被排除,业务上通常期望"没有名字"也被算作"不是a" |
WHERE name NOT IN ('a', 'b') | 找名字非a且非b的记录 | NULL行全被过滤,且如果子查询结果里出现NULL,整个NOT IN会返回空集 |
WHERE name = NULL | 找名字为空的记录 | 永远查不到数据,正确写法是IS NULL |
前两类问题隐藏在业务语义里:开发写status != 1想表达"排除已完成状态",但完全不记得status可能为NULL。等线上出现"数据少了"的反馈,排查成本已经很高了,因为要一行一行对照才会发现是NULL的锅。
而NOT IN的坑比这个更隐蔽。子查询只要有NULL,NOT IN整体结果就是空集。比如:
SELECT * FROM user WHERE name NOT IN (SELECT name FROM blacklist);只要blacklist表里有一条name为NULL的记录,主查询就一条结果都不返回。这是我见过最"玄学"的一种现象,很多人查半天查不出原因,因为它不是数据少了,是整个结果集为空。在黑名单、白名单、排除列表这类的业务里特别容易踩中。
1.3 为什么老项目里这个问题特别常见
老项目容易出现NULL默认值,原因不外乎两个:一是早期建表时赶工期,字段随手一写,没显式指定DEFAULT;二是为了迁就业务逻辑,"这个字段有时候不一定有值"就直接允许NULL了。等到系统跑上半年一年,数据里混进来大量NULL,再想收口就很难。
还有一个隐性原因:很多ORM框架自动建表时,对空值字段默认生成"允许NULL"的列定义。用框架的auto migrate功能建出来的表,一半以上的字段都是可空的。这不是MySQL的问题,是工具链默认策略埋下的雷。等到开发写查询时,根本不知道哪些列是NULL安全的,只能祈祷业务数据刚好跳过那些"雷区"字段。
2. 先理解NULL的语义:它不是空,是"未定义"
2.1 NULL、空字符串、0,三者有本质区别
很多新手把NULL当成"空"来理解,这是最大的误区。NULL在SQL标准里的定义是"未知"(unknown),表示一个不存在或尚未确定的值。
举一个生活例子:一个用户的手机号字段,如果值是空字符串'',表达的是"我知道他没有绑定手机号";如果值是NULL,表达的是"我不知道他有没有手机号"。这两种状态在业务上是完全不同的信息。
- '' 是一个确定的值,长度为0,可以和字符串做比较。
- 0 是一个确定的数字,参与算术运算有明确结果。
- NULL 是"未知",参与任何运算都会"传染"未知性。
这个区别直接决定了后续所有行为:空字符串可以做等值匹配,NULL只能用IS NULL判断。很多从其他编程语言转过来的开发者尤其容易在这上面翻车,因为在Java、Python里null == null通常是成立的,但SQL的三值逻辑里NULL = NULL却是UNKNOWN。
2.2 为什么NULL的比较结果既不是true也不是false
执行下面两个语句看看:
SELECT NULL = NULL; -- 结果是NULL SELECT NULL != 1; -- 结果是NULLNULL参与的任何一个比较运算,结果都是NULL。而在WHERE条件里,NULL被当成false处理。这就是SQL的三值逻辑(true / false / unknown)——和我们日常编程里常用的二值逻辑不是一个世界观。
开发者写WHERE name = NULL查不到数据,就是因为name = NULL这个表达式本身返回的是NULL而不是true,等值判断根本不会命中任何行。正确写法必须是:
SELECT * FROM user WHERE name IS NULL; SELECT * FROM user WHERE name IS NOT NULL;说个题外话,这个语义在JOIN里也一样。两张表用ON a.name = b.name关联,如果两边name都是NULL,这一对记录是不会关联上的——因为NULL = NULL不为真。想做"NULL也参与匹配"的关联,得靠<=>这个NULL安全等于运算符,或者先IFNULL(a.name, '') = IFNULL(b.name, '')转化一下。
2.3 AND与OR组合条件下,NULL怎么"杀死"整个条件
三值逻辑在组合条件里更坑:
TRUE AND NULL→ NULL,整个条件不成立FALSE AND NULL→ FALSETRUE OR NULL→ TRUEFALSE OR NULL→ NULL
也就是说,WHERE a = 1 AND b != 2,如果a满足而b为NULL,整条记录被排除。开发看到a = 1明明命中了,但结果集里没有这条记录,会非常困惑。
这种问题在排查时特别耗时间,因为单独看每个条件都是对的,组合起来就错。而且你没法靠"反转条件"来补救——WHERE NOT (a = 1 AND b != 2)的结果不是"排除的那部分",因为NULL参与NOT运算又是另一套行为。调试这类SQL我个人的建议是:拿具体行的值逐步拆解每个条件,列出真值表来看。
3. 聚合统计、唯一约束、排序分组中的NULL暗坑清单
3.1 COUNT、SUM、AVG遇到NULL时"静悄悄"地丢失数据
聚合函数对NULL的处理方式和直觉完全相反,它们默认会跳过NULL:
COUNT(column)只统计非NULL的行数,COUNT(*)统计全表行数SUM(column)忽略NULL;如果列全部为NULL,SUM结果是NULL而非0AVG(column)忽略NULL,相当于只对非NULL行求平均
举一个实际例子,订单表的order_amount字段允许NULL,业务语义是"未填写金额"(虽然这本身该用0),统计时执行:
SELECT COUNT(id), SUM(order_amount) FROM orders;COUNT统计的是id非NULL的行数,这个没问题;但SUM会直接漏掉所有NULL金额的订单,报表的营收数字就会低于真实情况。
更隐蔽的是AVG。很多报表开发做人均值统计时,直接写AVG(score),但NULL的缺考记录被静默忽略,算出来的人均分反而比实际要高。如果业务需要"缺考记0分",必须先转一下:
SELECT AVG(COALESCE(score, 0)) AS avg_score FROM student_exam;3.2 唯一索引不拦NULL:重复数据就是这样产生的
唯一索引(UNIQUE)的约束规则里,NULL值之间互相不冲突。也就是说,一张表的phone列建了唯一索引,你可以插入任意多行phone = NULL。
这在"半选填唯一字段"的场景里很致命。比如用户表的invite_code邀请码字段,业务上要求唯一,但很多用户没有邀请码。如果列允许NULL,NULL值可以无限重复,唯一约束形同虚设。后续一旦有脏程序写入两个相同邀请码的非NULL值,唯一索引还能拦一下;但NULL本身完全不受管。
如果既想让唯一约束生效、又想兼容"未填写"场景,正确的做法是把列设为NOT NULL DEFAULT '',用空字符串表达"未填写"。空字符串在唯一索引里是会被拦截的,''只能有一行,这就保证了语义完整性。
3.3 ORDER BY和GROUP BY里NULL的排序归宿
排序和分组是NULL容易造成"看起来不对"的另一类场景。
MySQL的ORDER BY默认规则是:升序时NULL排最前,降序时NULL排最后。作为对比,Oracle默认却是反过来。团队如果同时维护两套数据库,很容易出现"同样的SQL,排序结果不一样"的困惑,跨库迁移时这类琐碎差异特别消耗人力。
更麻烦的是,业务上通常期望"NULL排最后"或"NULL排最前",而默认行为往往不匹配。这时候要显式指定排序位置:
-- 让NULL排在最后,其他行按last_login降序 SELECT * FROM user ORDER BY ISNULL(last_login), last_login DESC; -- 让NULL排在最前 SELECT * FROM user ORDER BY IFNULL(last_login, 0) ASC;用ISNULL()函数作为第一排序键是最稳妥的做法,它把NULL的行强制放到指定位置,剩余的排序键再决定正常行的顺序。
分组里也一样,所有NULL会被归入同一组。如果报表里出现一行"分组字段为空、但数据量巨大"的记录,大概率是NULL值在作祟。做数据汇总前,最好先对分组字段做一次IS NULL审计。
4. 规避方案:从建表立规矩,到代码层兜底
4.1 建表规范:显式NOT NULL + DEFAULT,给每个字段一个"确定"的默认值
规避NULL默认值坑,最根本的方法是建表时就堵住:所有业务字段显式声明NOT NULL,并给一个符合业务语义的默认值。
对比两种建表方式:
-- 错误示范:字段隐含默认NULL CREATE TABLE user_order ( id INT PRIMARY KEY, user_id INT, -- 允许NULL,没有默认值 → 隐含NULL status TINYINT, -- 允许NULL,没有默认值 → 隐含NULL remark VARCHAR(255) -- 允许NULL,没有默认值 → 隐含NULL ); -- 正确示范:显式NOT NULL + 合适默认值 CREATE TABLE user_order ( id INT PRIMARY KEY, user_id INT NOT NULL COMMENT '用户ID', status TINYINT NOT NULL DEFAULT 0 COMMENT '订单状态:0待支付 1已支付 2已取消', remark VARCHAR(255) NOT NULL DEFAULT '' COMMENT '备注' );默认值的选择要有业务含义,不能随手写一个。我这里给出一套常用的映射:
- 数值型:
DEFAULT 0,如金额、次数、状态码 - 字符串型:
DEFAULT '',如姓名、备注、编码 - 时间型:
DEFAULT CURRENT_TIMESTAMP,如创建时间;更新时间用DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP - 布尔型:
DEFAULT 0或DEFAULT 1,看业务约定
一个值得注意的点:MySQL 8.0里DATETIME和TIMESTAMP都支持用DEFAULT CURRENT_TIMESTAMP了。旧版本只有TIMESTAMP支持,老项目里的DATETIME字段创建时间经常因为没有默认值而为NULL,这种历史遗留问题在迁移时要单独处理。
4.2 到底什么时候该保留NULL?两个值得用NULL的场景
我不是"一刀切禁止NULL"的极端派。NULL作为一个语义完善的数据库概念,有两个场景值得保留:
第一,区分"未设置"与"空值/零值"。最典型的就是软删除字段deleted_at,NULL表示未被删除,有值表示删除时间,这种"是否有值"本身就承载了业务状态。改成DEFAULT '0000-00-00'或者DEFAULT '1970-01-01',反而引入了魔法时间值,代码里到处要做特判。
第二,外部系统交互的可空字段。比如第三方平台返回的某个可选字段,没有返回就不写入,这时NULL保存的是"上游没有给数据"的事实。硬塞一个默认值反而会污染数据真实性。
需要强调的是,即便这两类场景用NULL,也不代表查询没有坑。deleted_at IS NULL这种写法很明确,不会踩坑;但如果有人写deleted_at = NULL或者deleted_at != '2024-01-01',照样会踩三值逻辑的坑。所以保留NULL是一回事,写SQL的规范又是另一回事。
4.3 代码层双保险:ORM映射与写入兜底
建表规范只能约束"新表",存量逻辑还需要代码层兜底。我在项目里用三招:
第一,ORM实体里声明默认值。Java的MyBatis-Plus字段用插入策略控制,默认情况下实体属性为null时该字段不参与插入,这会触发数据库默认值;如果数据库端也是NULL,就得在实体初始化时给默认值:
private Integer status = 0; private String remark = "";JPA这边可以在列定义上写死:
@Column(nullable = false, columnDefinition = "int default 0") private Integer status;让数据库和代码保持同一套默认值约定,避免两边各说各话。如果代码和数据库的默认值不一致,写入的数据就会超出预期,这类问题在联调阶段往往还发现不了,要等跑数据时才炸。
第二,写一个统一的数据兜底逻辑。对从数据库读出来的数据做COALESCE处理,尤其在返回前端JSON之前,把可能为NULL的数值型字段统一转成0、字符串转成空串:
SELECT user_id, COALESCE(status, 0) AS status, COALESCE(remark, '') AS remark FROM user_order;这样可以减少前端处理null的负担,也避免前端拿到null之后页面渲染出错。
第三,确认SQL_MODE里开了严格模式。严格模式能让INSERT在缺少值且没有DEFAULT时报错,而不是静默写入隐式值。很多老库是在宽松模式下创建的,这个一定要查:
SELECT @@sql_mode; -- 理想结果里应该包含 STRICT_TRANS_TABLES 或 STRICT_ALL_TABLES严格模式的作用是逼着你把默认值写明确。它不能直接转化NULL,但能阻止"没给值也不报错"的默认行径,从源头减少NULL的产生。
5. 存量系统改造:从NULL刷数到ALTER变更的实操记录
5.1 动手前先做三件事:盘点、语义确认、影响评估
改存量表不能上来就UPDATE ... SET col = 0 WHERE col IS NULL,得先盘清楚,至少要过三个步骤。
第一步,找出所有"默认值是NULL但业务上不该为NULL"的列。可以通过查询information_schema快速盘点:
SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'your_db' AND IS_NULLABLE = 'YES' AND COLUMN_DEFAULT IS NULL AND DATA_TYPE NOT IN ('text', 'blob') ORDER BY TABLE_NAME, ORDINAL_POSITION;第二步,逐个字段和业务方确认:这个字段为NULL到底有没有业务含义?如果有,说明不需要改它;如果没有,就要确认改成什么值更合理。我见过有人把金额类型的NULL直接刷成0,结果把"用户实际没填金额"和"用户填了0元"混为一谈,报表直接失真。
第三步,评估数据量级和影响面。几百万行可以一把UPDATE;上千万行的表,一把UPDATE会造成长事务和主从延迟,必须分批走,还要安排到业务低峰期执行。
5.2 标准操作:先刷数据,再改结构,顺序别反了
我推荐的顺序是:先UPDATE数据,再ALTER变更列属性。
先刷数据:
-- 数值型字段 UPDATE user_order SET status = 0 WHERE status IS NULL; -- 字符串型字段 UPDATE user_profile SET remark = '' WHERE remark IS NULL; -- 时间型字段,确认语义后再刷 UPDATE article SET updated_at = created_at WHERE updated_at IS NULL;刷完数据,确认残余NULL为0,再执行结构变更:
ALTER TABLE user_order MODIFY COLUMN status TINYINT NOT NULL DEFAULT 0 COMMENT '订单状态:0待支付 1已支付 2已取消', MODIFY COLUMN remark VARCHAR(255) NOT NULL DEFAULT '' COMMENT '备注';为什么不反过来?如果先改结构把列改成NOT NULL,而表里还有NULL数据,ALTER会直接报错。更麻烦的是,MySQL在修改表结构时通常需要重建表,遇到大表会长时间阻塞写操作。所以务必先把数据清理干净、缩短变更窗口。
重建表期间写操作被阻塞的问题,推荐在正式环境用pt-online-schema-change这类工具做在线变更。它会通过触发器把增量数据同步到新表,业务基本无感。Percona Toolkit自带的这套工具在千万级用户的表上改默认值时非常稳,我在生产环境实测过,比原生ALTER平滑太多。
5.3 批量刷数的性能优化与失败回滚策略
大表分批刷数据时要注意,MySQL的UPDATE虽然支持LIMIT,但直接用UPDATE ... SET status = 0 WHERE status IS NULL LIMIT 10000配合全表扫描,每批都要重新扫一遍历史范围,执行效率会越来越低。
更实用的做法是用主键范围分段:
-- 每次处理一个主键区间 UPDATE user_order SET status = 0 WHERE status IS NULL AND id BETWEEN 1 AND 100000; UPDATE user_order SET status = 0 WHERE status IS NULL AND id BETWEEN 100001 AND 200000;或者在业务低峰期,用存储过程循环处理:
DELIMITER $$ CREATE PROCEDURE batch_fix_null_default() BEGIN DECLARE batch_size INT DEFAULT 10000; DECLARE affected_rows INT DEFAULT 1; WHILE affected_rows > 0 DO UPDATE user_order SET status = 0 WHERE status IS NULL LIMIT batch_size; SET affected_rows = ROW_COUNT(); -- 让主从复制喘口气 DO SLEEP(1); END WHILE; END$$ DELIMITER ; CALL batch_fix_null_default(); DROP PROCEDURE IF EXISTS batch_fix_null_default;存储过程方式在生产环境第一次跑之前,建议先在测试库把执行时长和主从延迟都测一遍,确认锁的范围和影响面。因为DO SLEEP(1)只能缓解主从复制的压力,不能完全避免主库上的锁等待。
另外很重要的一点:UPDATE之前一定先备份。用CREATE TABLE user_order_bak_20250101 AS SELECT * FROM user_order;做一张备份表,或者至少用mysqldump导一份数据出来。虽然只是刷NULL,但万一业务方后面说"NULL其实有业务含义的",没有备份就得靠binlog回滚,那就太折腾了。
5.4 改完之后,应用代码还动不动?
表结构改完了,不代表万事大吉。应用代码里如果有依赖NULL语义的判断,改动之后行为会变化。常见的两类影响:
一是查询条件。之前WHERE status != 1会把NULL过滤掉,代码可能已经依赖了"过滤NULL行"的行为。改成status NOT NULL DEFAULT 0之后,同样的SQL过滤逻辑结果可能完全不同,必须回归测试一遍。尤其要注意统计报表类接口,改动前后数值对不上是常态。
二是实体映射。如果代码里用Integer接收一个原来可能为NULL的字段,现在是0,没问题;但如果字段被定义为int基本类型,MySQL返回NULL时在部分ORM框架里会直接报错或映射失败。这种问题一般上线前就能暴露,但要提前排查实体类里有没有int基本类型字段对接了可空列。
我个人做法是,迁移完成后给所有相关接口做一轮全量回归,重点关注列表页查询和统计报表两个功能,这两个最容易受NULL数据影响。
到这里,核心的坑和规避方案基本讲完了。最后分享一个我常用的土办法:建表时先自问一句"这个字段允许NULL,到底表达的是哪种未知状态?" 能回答得上来,就留着NULL;回答不上来,老老实实NOT NULL DEFAULT。数据库的表结构是长期资产,NULL默认值的债拖得越久,还起来越痛。希望这篇整理能帮你少踩一次线上数据丢失的坑。