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,直接报错或者把数据吃掉。我整理了一套相对稳妥的六步流程,每一步都踩过坑:
- 扫描数据分布,摸清 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 而不是硬造一个默认值。
- 确定兜底值,写清楚业务映射。这一步要和产品、运营对齐口径。比如
pay_time的 NULL 表示未支付,那么统一改成'1970-01-01 00:00:00'。要有一个映射文档,将来查数据时知道这个哨兵值的含义。 - 先在测试环境或预发环境模拟执行,导出执行计划和影响行数。
- 分批更新 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 万行比较稳,跑完一批等几秒,观察慢查询和锁等待情况。
- 执行
ALTER TABLE修改字段约束。注意事项是:如果表很大,ALTER TABLE ... MODIFY会触发 online DDL,但 MySQL 5.7 下对某些操作仍然会锁表,最好选择业务低峰期执行。 - 全量校验,对比改动前后记录数、去重数、关键统计值。写一段校验脚本,确保没有数据丢失。
这一步里最容易出问题的是时间字段的默认值。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_timeout | ALTER 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”的可能。从源头堵住问题,永远比事后打补丁舒服得多。