☰
MySQL建表为什么推荐NOT NULL?从三值逻辑到实战改造
2026/10/20 17:18:27 网站建设 项目流程

1. 先说说那次“含泪叮嘱”的现场

入行第三年,我在某公司接手过一个内部订单系统。当时刚过试用期,正是自信心爆棚的阶段,看什么都想“优雅地抽象一下”。建表的时候我想着“字段宽松一点,以后扩展方便”,于是把user_name、order_status、pay_time这些字段全做成了允许 NULL,甚至觉得“NULL 也是一种信息嘛,比如 pay_time 为空代表还没支付”。当时还觉得自己设计得挺合理,直到领导把我们拉到会议室,指着屏幕说了一句让我至今都后背发凉的话:

“MySQL 建表字段,能用 not null 就一律 not null,你再乱写就收拾包袱滚蛋。”

语气半玩笑半认真,但那一版表结构最终还是被全部打回重做。当时觉得委屈,后来踩过足够多的坑才明白,领导不是在抠规范,而是在用一个最简单的约束,挡住未来可能出现的一整片数据灾难。今天就把这个“含泪叮嘱”背后的技术逻辑全拆开讲清楚,希望能帮你少走弯路。

这篇文章适合谁?如果你是刚入行的后端开发、数据分析师,或者正在做系统重构的架构师,都应该停下来看一下。它主要解决三件事:第一,为什么 NOT NULL 在 MySQL 里如此重要;第二,建表时字段到底该怎么用默认值、怎么定约束;第三,线上存量表要改成 NOT NULL 时,具体怎么做、会遇到什么坑。

2. NULL 在 MySQL 中的真实成本,比你想的贵得多

2.1 三值逻辑:一个 NULL 毁掉一整条 WHERE

很多新手会把 NULL 理解成“空字符串”或者“0”,这是最危险的误解。在 MySQL 里,NULL 代表“未知”,它参与任何比较运算时,结果永远是“未知”,不会返回 TRUE,也不会返回 FALSE。

举个例子,你在订单表里查WHERE pay_time = '2024-01-01',那些 pay_time 为 NULL 的行会老老实实被过滤掉,这没问题。但如果你写的是WHERE pay_time <> '2024-01-01',以为能筛出“还没在这一天支付”的所有订单,结果会发现该查出来的记录少了——因为 NULL 不等于任何值,它不满足<>条件,也不会被查出来。

这就是所谓的三值逻辑:MySQL 里每个条件判断除了 TRUE、FALSE,还有第三种状态 UNKNOWN。一旦某个字段允许 NULL,所有 WHERE、JOIN、CASE WHEN 的逻辑都要开始小心“NULL 传播”。我在维护老系统时见过一个典型的线上事故,某天对账报表发现金额对不上,查了半天发现是一段 SQL 用WHERE refund_time != create_time去筛异常订单,而 refund_time 为 NULL 的记录全被漏掉了,导致几千笔异常订单没有被报表捕获。

这类问题最恶心的地方在于,单条 SQL 看着逻辑完全没问题,数据量小的时候也测不出来,等到数据量大、字段值真的出现 NULL 时,才开始在业务口径上不断制造“灵异事件”。

2.2 索引与统计层面的隐形损耗

在 InnoDB 引擎里,NULL 不是“没有存储空间”,它依然要占用记录中的标志位,而且对二级索引并不友好。你要知道,普通二级索引是可以包含 NULL 值的,多个 NULL 在索引中不会被当作重复值。这意味着如果你在一个允许 NULL 的字段上建唯一索引,唯一性约束基本形同虚设——因为UNIQUE约束允许任意多个 NULL 同时存在。

这会直接引发两类问题:

问题场景期望行为实际行为
用户表 user_email 建唯一索引每个邮箱只能注册一次只要没填邮箱,可以无限插入多条 NULL 记录
商品表 sku_code 建唯一索引编码不重复NULL 记录无法通过索引约束去重

而且,从查询计划的角度来说,优化器统计信息里对 NULL 的处理也相对保守。如果你经常用IS NULL去索引字段,某些情况下 MySQL 也会选择全表扫描,因为优化器认为“NULL 分散在各处,走索引未必划算”。这个决策不一定错,但它增加了不确定性。性能问题往往不是一次出现,而是在数据量翻倍之后突然暴露,很难排查。

还有存储层的小细节:每行记录里都要额外存一个 NULL 值标志位。虽然单条记录省不了多少字节,一旦到了几亿行、几十个可空字段的表,存储成本、IO成本都会跟着涨。对中小公司来说这部分开销也许可以忽略,但养成“能 NOT NULL 就不留 NULL”的习惯,从源头就能省掉这笔账。

2.3 不可见的口径混乱

这是被讨论得最少、杀伤力却最大的一层。只要一个字段允许 NULL,团队里的人就会对“空值”产生各自的理解:有人写代码时用if (userName == null)判空,有人用if (userName.isEmpty()),有人用if (StringUtils.isBlank(userName)),还有人直接拿 NULL 当成“未设置”传给前端渲染成“-”。

一旦口径不统一,数据分析和后端服务之间就会开始扯皮:

  • 报表统计COUNT(user_name)时,NULL 不计入,空字符串计入,结果对不上。
  • 运营导出 Excel 时,NULL 显示为空白,空字符串也显示为空白,但导出的 CSV 里一个是没有引号包裹的空,一个是带引号的空,下游清洗数据又出问题。
  • 接口返回 JSON 时,NULL 字段经常被框架直接省略,前端拿到缺字段的对象,渲染时再报一遍错。

这些问题都根源于同一件事:数据库层允许了“不存在的值”出现,应用层就必须补一堆判空逻辑,而每个开发者的判空习惯不同,最终形成了千奇百怪的隐性契约。NOT NULL 加默认值,等于在数据库层就定了规矩:这个字段一定有值,没有值就填兜底值。让数据库去强制约束,比让人用自觉去约束可靠得多。

3. 正确实践:字段到底怎么设计才不算“四不像”

3.1 NOT NULL 不是拍脑袋,而是基于业务边界

我刚入行时犯过另一个极端:把所有字段全设成 NOT NULL,默认值随便填,比如数字填 0,字符串填空串。这样做虽然不会再被领导骂,但会在业务上产生新的麻烦。比如一个“订单备注”字段,业务上的确可能没填写,你硬给默认值'无',那查询时想筛选“无备注的订单”就得写成WHERE remark = '无',而不是更自然的WHERE remark IS NULL,语义反而更绕。

所以核心在于分清字段的两类性质:

第一类是“业务上天然必须有值”的字段。比如主键、创建时间、订单状态、用户ID、商品ID、金额、数量。这些字段在业务上不可能不存在,如果允许 NULL 就是在给未来埋雷。典型写法就是not null default 0、not null default ''或者not null default current_timestamp。

第二类是“业务上确实是可选值,但你可以用默认值表达‘未设置’”的字段。比如手机号、邮箱、支付时间、审核时间。这里不应该用 NULL 表示“没填”,而应该用约定的默认值表示。比如状态类字段用0表示待处理,时间类字段用'1970-01-01 00:00:00'或'0000-00-00 00:00:00'表示未发生,字符串类字段用空串表示无内容。这样字段始终保持 NOT NULL,查询逻辑永远只会是等值判断、范围判断,不会突然冒出三值逻辑的坑。

这一点可以拿现实生活来类比:你在填一个纸质表单,如果留白代表“不知道”,那别人拿到这张表后就要猜。但你写一个“-”或者“无”,所有人看到都会明白是“没有”。数据库字段的默认值,就是这个“-”。

3.2 用 DEFAULT 填充兜底值的实战配方

具体到建表语句,我常用的配方可以总结成下面几类,基本覆盖了绝大多数业务表:

-- 状态类字段:0 表示默认/未处理 status TINYINT NOT NULL DEFAULT 0, -- 计数类字段:0 表示无 cnt INT NOT NULL DEFAULT 0, -- 金额类字段:0 表示无金额 amount DECIMAL(10,2) NOT NULL DEFAULT 0, -- 字符串短字段:空串表示无内容 nickname VARCHAR(64) NOT NULL DEFAULT '', -- 字符串长字段:空串同样可行,但无默认值的枚举要小心 remark VARCHAR(255) NOT NULL DEFAULT '', -- 时间字段:epoch 时间作为“未发生/未知”的哨兵 create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, pay_time DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00', -- 逻辑删除标记:0 代表存在,1 代表已删除 is_deleted TINYINT NOT NULL DEFAULT 0

有人会觉得把支付时间默认成'1970-01-01 00:00:00'很怪,但实际在报表筛选“这批订单是否已支付”时,pay_time > '1970-01-01'秒懂,程序里也只需要判断是不是等于这个哨兵值。比起 NULL,它最大的好处是查询优化器能充分利用索引,而且应用层拿到值后不用判空。

还有一个小诀窍:.NET或Java这类强类型语言里,DateTime类型读到一个 NULL 时经常搞出各种序列化问题,而读到一个边界时间就非常稳定。把数据库层的“未知”转成应用层可识别的具体值,能省掉大量if (xx != null)的样板代码。

3.3 什么时候真的该用 NULL

尽管我一直强调宁可用 NOT NULL 加默认值,但也不能走极端。实际开发里确实有些场景,NULL 在语义上不可替代:

  • 数值字段:当 0 是一个有业务含义的值,而“没有记录”也需要单独表达时,比如“收益率”,0% 和“没有收益率数据”是两回事,此时 NULL 反而更准确。
  • 外键字段:某项数据可选关联,比如“上级审核人ID”,在没有审核行为时不指向任何人。虽然也能用 0 表示“无”,但如果你骑虎难下、历史代码已经依赖 IS NULL 做判断,且产品指标确实需要区分“无”和“必填”,可以保留 NULL。
  • JSON 字段或 TEXT 长文本:有些业务里“字段不存在”和“字段为空白”在语义上要被区分,这种情况保留 NULL 也说得过去。

不过我要提醒一句:这些是“例外”,不是“默认”。每次你决定用 NULL,都应该在表注释里写清楚“这里的 NULL 代表什么业务含义”,并且通过统一规范让团队都知晓。否则三年之后,没人记得当时是怎么约定的。

4. 实操复盘:从建表脚本到存量表改造

4.1 一张标准订单表的正确写法

先把前面聊到的原则落到一张真实的订单表上。模拟项目“某电商订单系统”里的 orders 表,完整建表脚本如下:

CREATE TABLE `orders` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, `order_no` VARCHAR(32) NOT NULL COMMENT '订单编号,业务唯一', `user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户ID', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '订单状态,0待支付 1已支付 2已发货 3已完成 4已取消', `total_amount` DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '订单总金额', `pay_time` DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT '支付时间,默认哨兵值表示未支付', `receiver_name` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '收货人姓名', `receiver_phone` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '收货人手机号', `remark` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '用户备注,无则是空串', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', KEY `idx_user_id` (`user_id`), KEY `idx_status` (`status`), UNIQUE KEY `uk_order_no` (`order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单主表';

这张表的所有字段都是 NOT NULL,每一个可缺省的字段都有明确的默认值。你可能注意到order_no用的是业务单号而非自增主键做唯一键,这是另一个话题,但至少能看出,约束一旦定清楚,后面的查询逻辑会非常干净。

写出来容易,真正难的是理解每一步的意图。user_id不设默认值直接 NOT NULL,因为业务上订单不可能没有买家;receiver_name默认空串而非 NULL,因为查历史订单时总得有个字符串给页面渲染;remark允许为空但我们用空串兜底,这样任何CONCAT拼接字符串时都不会冒出“NULL”字样。

4.2 存量表改 NOT NULL 的六步走

线上已经存在的表,想改成 NOT NULL 从来不是ALTER TABLE一句命令那么简单。最怕的就是直接执行ALTER TABLE ... MODIFY COLUMN ... NOT NULL,结果发现 100 万行里有 30 万行是 NULL,直接报错或者把数据吃掉。我整理了一套相对稳妥的六步流程,每一步都踩过坑:

  1. 扫描数据分布,摸清 NULL 占比。
SELECT count(*) AS total_rows, sum(CASE WHEN pay_time IS NULL THEN 1 ELSE 0 END) AS null_rows FROM orders;

先确认你要改的字段到底有多少 NULL。如果占比极高,那要立刻停下来想清楚:这些 NULL 是否代表业务上真的有“未发生”的语义?如果有,参考第 3.3 节,是否真该用 NULL 而不是硬造一个默认值。

  1. 确定兜底值,写清楚业务映射。这一步要和产品、运营对齐口径。比如pay_time的 NULL 表示未支付,那么统一改成'1970-01-01 00:00:00'。要有一个映射文档,将来查数据时知道这个哨兵值的含义。
  2. 先在测试环境或预发环境模拟执行,导出执行计划和影响行数。
  3. 分批更新 NULL 为默认值,不要一梭子全跑。数据量大时用主键分批:
UPDATE orders SET pay_time = '1970-01-01 00:00:00' WHERE pay_time IS NULL AND id BETWEEN 1 AND 100000;

每批 5 到 10 万行比较稳,跑完一批等几秒,观察慢查询和锁等待情况。

  1. 执行ALTER TABLE修改字段约束。注意事项是:如果表很大,ALTER TABLE ... MODIFY会触发 online DDL,但 MySQL 5.7 下对某些操作仍然会锁表,最好选择业务低峰期执行。
  2. 全量校验,对比改动前后记录数、去重数、关键统计值。写一段校验脚本,确保没有数据丢失。

这一步里最容易出问题的是时间字段的默认值。MySQL 的sql_mode如果包含NO_ZERO_DATE,你用'0000-00-00 00:00:00'会被拒绝,所以我才一直推荐用'1970-01-01 00:00:00'这个哨兵值,它在任何模式下都是合法的日期时间。

4.3 改造成败的关键参数和校验脚本

改造过程里,有几个关键配置会影响成败,提前确认能省很多事:

配置项影响
sql_mode是否允许零日期、是否严格模式,决定默认值能否顺利写入
innodb_strict_mode开启后,MODIFY 遇到非法默认值会直接报错,而不是残忍“清洗”数据
lock_wait_timeoutALTER TABLE 等待元数据锁的超时时间,线上并发高时要调小
max_execution_time防止某个 UPDATE/ALTER 语句因为慢查询把数据库拖死

改造完以后,我习惯跑一个“双层校验”脚本,先看总数:

SELECT count(*) AS cnt, count(pay_time) AS not_null_cnt, sum(CASE WHEN pay_time = '1970-01-01 00:00:00' THEN 1 ELSE 0 END) AS sentinel_cnt FROM orders;

再看业务抽样数据,随机取一段时间内的订单,人工核对状态和时间是否合理。比如之前统计“未支付订单”用的是WHERE pay_time IS NULL,改造后要改成WHERE pay_time = '1970-01-01 00:00:00'。这类 SQL 里所有依赖 NULL 判断的地方都得同步改掉,这是改造中最容易被忽略的一环。建议在改造前用文本搜索把代码仓库里的IS NULL全部扫出来,逐个确认改动方案。

5. 踩坑记录:NOT NULL 改造时的真实问题与排查技巧

5.1 唯一索引与 NULL 的相爱相杀

前面说过,唯一索引在 MySQL 里允许存在多个 NULL 值。这个特性在改造时表现得特别坑。有一次我在某公司给用户表加手机号唯一约束,准备把phone字段改成 NOT NULL,原以为 80 万行里 NULL 手机号的用户只有几百个,结果 AlTER 一跑,报错“Duplicate entry”。排查后发现,当年业务人员手动补数据时,给好几千个空手机号用户都写成了同一个占位值'00000000000',但因为有大量 NULL 的存在,唯一索引一直没有拦截住这些占位值。等我把 NULL 全部改成默认空串''后,才发现重复的''直接撞车。

解决方案也很直接:先把重复占位数据找出来,统一洗成不同的业务编号,再执行约束修改。排查 SQL 如下:

SELECT phone, COUNT(*) FROM users GROUP BY phone HAVING COUNT(*) > 1;

这个教训告诉我们,做 NOT NULL 改造前,一定要先做重复值检测,别只看 NULL 数量和总体数据量。

5.2 默认值表达式在不同版本的行为差异

MySQL 5.7 和 8.0 对默认值表达式的支持有很大差别。5.7 里,时间字段默认值能写CURRENT_TIMESTAMP,但如果你想给一个DATETIME字段设置表达式默认值,很多写法是被拒绝的。8.0 引入了更灵活的表达式默认值,比如可以写DEFAULT (DATE_ADD(NOW(), INTERVAL 1 DAY))。

对开发同学来说,这些版本差异通常不会主动关注,但迁移时一旦忽视,会产生很诡异的现象:测试环境是 MySQL 8.0,生产环境是 5.7,同一套建表脚本在一个环境正常、另一个环境直接报语法错误。我的建议是,建表脚本统一在最低版本的数据库上验证一遍,并且尽量别用太“新潮”的默认值表达式,保持朴素写法,跨版本兼容性更好。

5.3 时间字段、状态字段、字符串字段的坑

时间字段最常见的坑就是'0000-00-00 00:00:00'。很多老系统为了方便,把未发生的时间写成零日期,但客户端连接加了zeroDateTimeBehavior=convertToNull的 JDBC 参数时,读取出来就变成了 NULL。这样你在数据库看表结构明明是 NOT NULL,代码里却还是需要判空。绕了一圈,当初的约束等于白做。所以我在团队里强制要求,时间字段哨兵值统一用'1970-01-01 00:00:00',别再用零日期。

状态字段的坑在于“0 是否合法”。比如订单状态,你定义了0=待支付,那NOT NULL DEFAULT 0没问题。但如果业务状态里没有 0 这个值,数据却出现了 0,那大概率是代码里没传状态、默认值兜底导致的脏数据。排查这类问题有一个小技巧:给状态字段加TINYINT UNSIGNED且COMMENT里写明所有枚举值的含义,然后定期跑一遍校验脚本,挑出不在枚举范围内的“幽灵值”。

字符串字段的坑则集中在字符串长度和默认值的字符集上。VARCHAR默认值如果有中文,要确保建表语句本身是 utf8mb4,否则可能出现字符集转换导致默认值乱码。还有一点,VARCHAR的默认值如果超过字段长度,MySQL 在非严格模式下会自动截断,严格模式下直接报错,这个容易在批量导入时触发,批量操作前最好先跑一遍SELECT验证。

5.4 排查技巧速查表

现象可能原因排查方向
WHERE 条件查不到 NULL 记录用了!=或NOT IN改成IS NULL或明确表达“排除哨兵值”
COUNT 统计结果偏小COUNT(字段) 不统计 NULL改成 COUNT(*) 或先改造填充默认值
SUM/AVG 结果与预期不符聚合函数自动忽略 NULL用COALESCE(field, 0)或先兜底
唯一索引能插入重复值字段允许 NULL,多个 NULL 不冲突先改造 NOT NULL,再重建唯一索引
ALTER TABLE 报 Duplicate entry默认值和其他行重复先按默认值 group by 找重复行
应用层读取时间为 NULL零日期被客户端转成 NULL统一哨兵时间为 1970-01-01

写在最后的几点体会

那次被领导“含泪叮嘱”之后,我开始养成一个习惯:每次建表,写字段之前先问自己三遍,“这个字段真的可以为空吗?空代表什么业务含义?能不能用默认值表达?”大部分情况下问完第三遍,答案都是“能”。

老实说,严格执行 NOT NULL 并不会让你的系统速度飞起来,也不会直接避免所有性能问题。但它会改变整个团队对数据的认知方式。当数据库里每一个字段都有确定的值,写 SQL 的人、写代码的人、做报表的人,都不需要再互相猜“这里会不会有 NULL”,这是一个非常巨大的协作成本节约。

最后再分享一个小技巧,也是我现在带人时最喜欢说的一句话:永远不要相信你的同事在代码里写if (xxx != null)。你能做的,是让所有新建的表根本没有“xxx 为 null”的可能。从源头堵住问题,永远比事后打补丁舒服得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询