年前帮一家电商客户做数据库巡检,遇到一个特别典型的慢查询:订单表明明建了索引,几千万行却走了全表扫描,接口响应从 80ms 一下子涨到 3.8 秒。EXPLAIN 拉出来一看,问题出在一个后来新增的字段上——建表脚本里写着DEFAULT NULL,存量数据里这个字段八成以上都是 NULL。更巧的是,同一批上线的还有另外两个字段,也全部默认 NULL,客户那边"列表页有些订单查不出来""财务统计对不上"的工单,顺着查下去全是这个不起眼的默认值在捣鬼。
今天这篇就想认真聊一聊:为什么我不建议给数据库字段加默认值 NULL。这不是洁癖,是这几年在生产环境实打实踩坑踩出来的结论。全文会从 NULL 的 SQL 语义讲起,再拆索引、统计、代码三层的真实影响,最后给出一套可以直接上手的字段整改方案。正在设计表结构、维护存量库、或者写 SQL 总被 NULL 坑到的同学,这篇值得看完。
1. DEFAULT NULL 到底是什么?先看清 SQL 的"三值逻辑"
1.1 NULL 不是"空",而是"未知"
很多人对 NULL 的理解停留在"空值"——觉得 NULL 就是没值,跟空字符串 ''、数字 0 差不多。这是最大的认知误区。在 SQL 标准里,NULL 表达的是"未知"(unknown),它不是一个具体的值,而是一种"状态"。
SQL 是比较逻辑是三值逻辑:除了 TRUE、FALSE,还有一个 UNKNOWN。任何普通值与 NULL 做比较,结果都是 UNKNOWN;UNKNOWN 在 WHERE 条件里会被当作"不成立"处理。这个特性会衍生出很多匪夷所思的行为:
-- 下面这条 SQL 永远查不到数据,因为没有任何值会等于 NULL SELECT * FROM order_info WHERE payer_name = NULL; -- 应该写成这样 SELECT * FROM order_info WHERE payer_name IS NULL; -- 表达式里掺入 NULL,结果也是 NULL SELECT 1 + NULL; -- NULL SELECT CONCAT('订单', NULL); -- NULL(MySQL 的 CONCAT 遇到 NULL 返回 NULL)我见过不少新手在代码里写WHERE column = NULL,执行完发现一条数据都没有,第一反应是"表是不是空的",而不是去怀疑 NULL 的语义。这种问题查起来非常浪费时间,因为 SQL 不报错,只给你一个"空结果"。
1.2 DEFAULT NULL 和"不写默认值"是两码事
有的朋友会说:"那我建表时不写 DEFAULT,字段不就默认是 NULL 了吗?"没错,在 MySQL 里,如果一个列允许为空(没有 NOT NULL 约束),不写 DEFAULT 时隐含的默认值就是 NULL。但DEFAULT NULL 是显式声明"默认就为空",两者在行为上结果一样,在意图上完全不同。
显式写DEFAULT NULL,相当于告诉后来维护这张表的人:我允许这个字段为空,而且默认就是空。很多同学建表时习惯性给每个字段都补一个DEFAULT NULL,甚至不管什么字段都来这么一句。这种脚本一旦流入生产,哪天字段真出问题了,排查的时候你很难分清这是"设计时故意允许为空"还是"顺手写错了"。
更危险的是,如果列已经带上了 NOT NULL 约束,再写DEFAULT NULL就是自相矛盾。新版数据库(如 MySQL 8.0)会直接报错Invalid default value;就算某些老版本在非严格模式下放过了,后面也会埋下同步、迁移时的兼容性隐患。
2. 默认值 NULL 的真实危害:索引、统计、代码三线崩盘
2.1 索引层面:NULL 会让优化器"绕开"索引
先说结论:**允许 NULL 的字段,索引效果通常比 NOT NULL 字段差,而且组合索引更容易失效。**InnoDB 的二级索引不是完全不存 NULL,它是会记录的,但优化器在评估查询计划时,会考虑 NULL 值对范围扫描、排序、去重的影响,结果往往选择全表扫描。
举一个我实际排查过的案例。用户表加了mobile字段的索引,业务查询主要是按手机号查账号。因为历史原因,mobile 默认 NULL,存量数据里大约 20% 的行是 NULL。EXPLAIN 的结果显示type=ALL,优化器认为走这个索引需要回表、再过滤掉 NULL 行,代价不如全表扫。后来把 mobile 统一改为NOT NULL DEFAULT '',并刷新统计信息,同样的 SQL 走了索引,查询时间从 1.2 秒降到 30 毫秒左右。
排序也会被影响。MySQL 里,升序排序时 NULL 默认排在最前面;Oracle 里默认 NULL 排在最后;SQL Server 又是另一种行为。同样一条 SQL,在不同数据库上跑出来的列表顺序完全不一样,如果前端做分页,很容易出现"数据显示不完整、翻页串数据"的诡异问题。
2.2 统计与聚合:COUNT、SUM、GROUP BY 全被 NULL 带偏
NULL 的第二个重灾区是统计口径。直接上示例:
CREATE TABLE user_action ( id INT PRIMARY KEY, user_id INT NOT NULL, action_type VARCHAR(20) DEFAULT NULL, -- 有些行没有动作类型 score INT DEFAULT NULL -- 有些行没有分数 ); -- COUNT(*) 统计行数 SELECT COUNT(*) FROM user_action; -- 1000 -- COUNT(action_type) 只统计非 NULL 的行数 SELECT COUNT(action_type) FROM user_action; -- 850,少了150行 -- SUM(score) 忽略 NULL,但如果全是 NULL,结果是 NULL 而不是 0 SELECT SUM(score) FROM user_action WHERE score IS NULL; -- NULL -- AVG(score) 的分母不包含 NULL 行,导致平均值虚高 SELECT AVG(score) FROM user_action; -- 只算有分数的行对于报表开发来说,这个特性是致命的。很多 BI 工程师直接用SUM(amount)统计金额,结果某个月字段默认 NULL 的行特别多,汇总数据悄悄少了一截;等到财务对账发现不平,已经在错误数据的基础上跑了很久。**更稳妥的做法是:统计时明确用IFNULL(column, 0)或COALESCE(column, 0)兜底,并且建表时就把字段默认值定为 0。**统计模块一旦被 NULL 坑过,你才会理解"默认值 0 和默认值 NULL"之间的差别有多大。
GROUP BY 同样会出问题:所有 NULL 会被分到同一组,这一组在报表里的展示名通常是空字符串,业务方根本看不懂。
2.3 应用层:Java 的 NPE 与 ORM 的"更新失效"
NULL 的破坏力不止在数据库内部,它会顺着数据访问层一路炸到业务代码。最常见的就是 Java 开发里的 NullPointerException:
// 订单金额字段如果是 NULL,下面这段代码直接抛 NPE BigDecimal amount = order.getAmount(); BigDecimal tax = amount.multiply(new BigDecimal("0.06"));字符串拼接更隐蔽:"订单号:" + order.getOrderNo()遇到 NULL 会变成"订单号:null",前端展示的时候莫名其妙多出"null"字样,用户看到还会以为系统出了 bug。
ORM 框架的"更新丢失"问题同样值得警惕。以 MyBatis-Plus 为例,默认的字段更新策略是NOT_NULL,也就是实体里某个属性为 null 时,生成 UPDATE 语句时会自动忽略这个字段。这个设计的初衷是避免误覆盖,但副作用是:当你真的想把这个字段清空时,UPDATE 语句根本不会包含该列。想让数据库字段从有值改成 NULL,得额外加注解或者写自定义 SQL。生产环境里我接过不少这种工单:"我明明把字段置空了,保存后再查还是有值。"十有八九是默认值 NULL + ORM 更新策略的双重问题。
顺带说一个 ERP 领域的例子。像 ACDOCA 这种核心财务表,二次开发加自定义字段时,顾问为了"避免报错",经常把增强字段定义为可空。结果月末报表一跑,空值记录参与汇总,财务怎么对都对不上,最后还得靠IFNULL一层层补丁。扩展字段尽量给明确的默认值(空字符串、0、既定枚举),而不是放任 NULL,这条经验在 SAP 物料主数据扩展(比如 BAPI_MATERIAL_SAVEDATA 传扩展字段)里同样适用——你传一个 null 进去,下游逻辑根本不知道是"没传"还是"传了个空"。
3. 表结构怎么改?一套可直接抄作业的整改流程
3.1 建表阶段:默认值就应该"有明确的含义"
先看反例和正例的对照:
-- 反例:字段默认值全是 NULL CREATE TABLE user_profile ( id BIGINT PRIMARY KEY, nickname VARCHAR(32) DEFAULT NULL, age INT DEFAULT NULL, avatar_url VARCHAR(255) DEFAULT NULL, bio VARCHAR(500) DEFAULT NULL, status TINYINT DEFAULT NULL ); -- 正例:明确默认值,能用 NOT NULL 就用 NOT NULL CREATE TABLE user_profile ( id BIGINT PRIMARY KEY, nickname VARCHAR(32) NOT NULL DEFAULT '', age INT NOT NULL DEFAULT 0, avatar_url VARCHAR(255) NOT NULL DEFAULT '', bio VARCHAR(500) NOT NULL DEFAULT '', status TINYINT NOT NULL DEFAULT 1 );设计原则我总结成三句话:
- 字符串字段:用
NOT NULL DEFAULT ''。空字符串表达"没有内容",但它参与字符串拼接、比较、索引时都比 NULL 友好得多。 - 数值字段:用
NOT NULL DEFAULT 0。但要确认业务上 0 到底有没有特殊含义。比如"订单金额"默认 0 没问题,但"年龄"默认 0 就有点假,这种字段如果业务上确实可能"未知",可以考虑其他哨兵值(如 -1)做兜底,或者允许 NULL 但应用层做好防御。 - 时间字段:区分"业务时间"和"记录时间"。记录时间可以直接用
DEFAULT CURRENT_TIMESTAMP;业务时间(如"审核时间")没产生之前就是没有,这种我建议允许 NULL,但查询时一律用IS NULL/IS NOT NULL,不要用等值比较。
3.2 存量表:三步完成 NULL 字段的清理和改造
老项目不可能推倒重来,存量表的改造才是重头戏。我一般按三步走:
第一步:先摸清家底,找出所有允许为 NULL 的字段:
-- MySQL:查出一个库里所有可空字段 SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, COLUMN_DEFAULT FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'your_db_name' AND IS_NULLABLE = 'YES' ORDER BY TABLE_NAME, ORDINAL_POSITION;第二步:逐个字段做业务评估。打开这张清单,和业务方逐个确认:这个字段有没有"空"的实际业务含义?哪些行真的会是空?如果只是默认没值,那就该改成 NOT NULL + 默认值。
第三步:谨慎执行变更。核心点是先更新数据、再改表结构,顺序不能乱:
-- 1. 先把存量 NULL 替换为默认值(大表务必分批,别直接 UPDATE 全表) UPDATE user_profile SET nickname = '' WHERE nickname IS NULL LIMIT 5000; -- 循环执行 UPDATE user_profile SET age = 0 WHERE age IS NULL LIMIT 5000; UPDATE user_profile SET bio = '' WHERE bio IS NULL LIMIT 5000; -- 2. 修改列定义 ALTER TABLE user_profile MODIFY COLUMN nickname VARCHAR(32) NOT NULL DEFAULT '', MODIFY COLUMN age INT NOT NULL DEFAULT 0, MODIFY COLUMN bio VARCHAR(500) NOT NULL DEFAULT '';这里必须提醒一句:**大表 ALTER TABLE 会造成长时间元数据锁,生产环境不能直接跑。**更稳妥的做法是借助在线 DDL 工具(如 gh-ost、pt-online-schema-change),或者把流程改成"新建一张新表 → 双写 → 灰度切换"。我自己在线上用 gh-ost 改过一张 5000 万行的表,几乎无感,比在原表上直接 MODIFY 安全太多。
3.3 什么时候允许 NULL 不是坏事?
强调一下:**我不是说所有字段都不能有 NULL。**数据库字段该不该允许 NULL,要看这个"空"是"明确没有"还是"暂时未知、未来可能有"。
适合保留 NULL 的场景:
- 可选外键:比如积分明细里的订单号,用户还没下单时确实没有关联对象,这时用 NULL 表示"暂未关联"比编一个假的订单号靠谱。
- 低频业务属性:比如员工表的"离职日期",在职员工这个字段天然为空,强行 NOT NULL 反而要伪造一个 2099-12-31,查询全都乱了。
- 扩展预留字段:业务上明确"大部分行都不会填内容"的字段,NULL 比空字符串更能表达"未提供"。
这些场景保留 NULL 没问题,但配套纪律必须跟上:Java 代码里所有读取路径都要做空值兜底;SQL 里一律用IS NULL/IS NOT NULL;ORM 实体字段用包装类型(Integer而不是int),避免 NPE。
4. 常见问题速查与我的避坑清单
4.1 高频问题排查速查表
| 典型现象 | 根因 | 解决办法 |
|---|---|---|
WHERE col = NULL查不到任何数据 | 误用等值比较 NULL,三值逻辑导致 UNKNOWN | 改为IS NULL/IS NOT NULL |
| 明明建了索引,SQL 还是全表扫描 | 列允许 NULL,优化器放弃索引 | 改成 NOT NULL + 合理默认值,必要时刷新统计信息 |
COUNT(字段)和COUNT(*)对不上 | COUNT(字段) 不统计 NULL 行 | 明确口径,不需要行数统计时统一用 COUNT(*) |
SUM(amount)汇总结果偏小或返回 NULL | SUM 忽略 NULL,全为 NULL 时返回 NULL | 用COALESCE(SUM(amount), 0)兜底 |
| Java 后端接口突然报 NullPointerException | 数据库查到 NULL 映射到包装类型,运算直接炸 | 建表避免 NULL 默认值 + 代码层统一判空 |
| MyBatis-Plus 把字段置空后保存无效 | 默认更新策略忽略 NULL 字段 | 改用 LambdaUpdateWrapper 显式 set 字段为 null |
| 列表分页顺序不稳定 | 不同数据库对 NULL 排序规则不一致 | 排序字段设计为 NOT NULL,排序条件加NULLS LAST/FIRST |
| 唯一索引保护"邮箱不重复"失效 | 多个 NULL 行不参与唯一约束比较 | 业务上需要"空也唯一"时,将空字符串替代 NULL |
这张表是我日常排查问题前必看的清单,很多看着毫无头绪的数据库诡异问题,最终都能收敛到"某个字段是 NULL"这个根因上。
4.2 踩坑多年总结的避坑清单
**第一,建表脚本先过审,DEFAULT NULL 亮红灯。**我现在的习惯是,所有新建表的字段默认都写成NOT NULL,只有明确需要"未知"语义的列才放开。代码评审阶段,看到DEFAULT NULL会直接被问一句:这个字段真的需要默认空吗?不能填默认值吗?问完这一句,至少能挡掉一半隐患。
**第二,改造存量字段时,先小步灰度再全量。**不要一边跑业务一边直接 ALTER 大表,也不要一开始就 update 所有历史数据。我在生产环境惯用的流程是:先写 SQL 查出 NULL 比例,评估影响面;然后用影子表验证修改后的读写逻辑;最后通过在线 DDL 工具逐步切换。整个过程要能随时回滚。
**第三,不同数据库的 NULL 行为差异要心里有数。**MySQL、PostgreSQL、达梦、GBase 这些数据库,对 NULL 的排序规则、唯一索引行为、统计函数处理都有细节差异。同一个表结构要同时兼容多种数据库时(比如做数据库同步工具的项目),DEFAULT NULL 带来的坑会被放大好几倍。设计阶段就统一口径,后面会省下大把排查时间。
**第四,给 ORM 加一道"空值防火墙"。**如果团队代码里已经有大量依赖 NULL 字段的老逻辑,改动表结构之前,先在应用层加统一的字段映射处理:查询结果里的 NULL 统一转成空字符串或 0,再往上传。这一步能避免"数据库改了、应用炸了"的尴尬。
最后再分享一个个人习惯:我每次接到慢查询或者数据对不上的工单,第一步不是看 SQL 本身,而是先SHOW CREATE TABLE扫一眼所有字段的默认值。只要看到一片 DEFAULT NULL,心里基本就有数了。这些年处理的数据事故里,相当大一部分能追到字段默认值设计不规范上。希望这篇能帮你少踩几个坑。