做后端开发这些年,接手过的项目里最让我头皮发麻的,从来不是业务逻辑有多绕,而是打开数据库一查,同一张表里躺着三万个"张三",手机号一栏全是NULL,订单金额居然能出现 -50。这种脏数据一旦进了库,后面的统计报表、用户触达、对账逻辑全都会跟着歪。要根治这类问题,靠业务代码里写一堆if判断远远不够,必须在建表那一刻就把规矩立好——这也是MySQL表约束(上)要讲透的东西。接下来我会从实操视角拆解NOT NULL、DEFAULT、UNIQUE、PRIMARY KEY这四种最基础的约束,说清楚它们各自的底层行为、适用场景和常见误用。不管你是刚学MySQL的新手,还是建表习惯"先跑通再说"的熟手,读完都能直接照着设计出一张靠谱的表。
1. 约束是什么:为什么说它是表的"安检系统"
1.1 一次数据事故给我的教训
早些年我维护过一个会员系统,当时建表很随意,用户名字段没加任何约束,手机号也允许为空。上线三个月后,运营跑活动一拉名单就傻眼了:同一个手机号注册了七八个账号的比比皆是,还有一大片用户资料里username是空的,连发个站内信都不知道怎么称呼对方。后来排查发现,应用层确实做了校验,但总有漏网之鱼——有些老接口没接新校验逻辑,有些测试脚本直连数据库灌数据,还有些同事手工执行SQL时顺手把null写了进去。应用层的校验是"大多数情况下的自觉",数据库约束才是"不管谁来都必须遵守的底线"。
这次事故之后我养成了一个习惯:建表前先想清楚这张表要守住哪些数据底线,然后把底线直接写进表结构里。后来再遇到数据问题,大多在入库那一刻就被挡了回去,而不是等数据烂了再靠写脚本清洗。这也是为什么我每次跟团队讲MySQL基础,都要把表的约束放在最前面讲——它不炫技,但它是整个数据质量体系的地基。
1.2 约束到底在管什么
表约束的本质,是数据库在写入数据时对字段值做的规则校验。你可以把它理解成进入大楼之前的安检:符合规则的放行,不符合规则的一律拦下,谁来说情都没用。MySQL里表的约束(上)这部分主要涉及四道关卡,覆盖了从"字段是否为空"到"记录身份是否唯一"的完整链路,我用一张表把每种约束管什么、违反时会发生什么列出来:
| 约束类型 | 管的是什么 | 违反时的表现 |
|---|---|---|
| NOT NULL | 字段值是否允许为空 | INSERT/UPDATE被拒绝,报Column xxx cannot be null |
| DEFAULT | 不指定字段值时用什么 | 自动填入设定值,不会报错 |
| UNIQUE | 值是否允许重复 | Duplicate entry报错,整条语句失败 |
| PRIMARY KEY | 每条记录的唯一身份 | 既不能为NULL,也不能重复 |
这四类约束分别从"空不空、缺不缺、重不重、认不认得出"四个角度保护一张表的数据。至于外键、CHECK这类涉及表间关系和更复杂校验的约束,属于"下"篇的内容,今天先不展开。
这里我想多说一句:约束不是越多越好,但也不是能省就省。判断标准很朴素——如果某列出现脏数据会让业务逻辑出错、让用户困惑、让统计失真的,就值得加约束;只是"理论上应该这样"的,就要权衡成本和误伤率。我见过有人给备注字段也加NOT NULL,结果业务根本填不出来,最后只能塞一个空格字符串进去,反而制造了新的脏数据。
2. NULL与NOT NULL:空值比你想的更复杂
2.1 NULL不是0,也不是空字符串
很多新手对空值的理解停留在"没填就是NULL",但NULL在MySQL里的语义是"未知",它跟0、空字符串''完全是三码事。0是一个确定的数值,''是一个确定的长度为0的字符串,而NULL表示"这个值不存在,或者还没确定"。
这个区别直接影响查询结果。举个例子,一张订单表里有三行数据,金额分别为100、0、NULL:
- 查询
WHERE amount = 0只能找到0那一行,NULL那行不会被匹配。 - 查询
WHERE amount != 0找到100那一行,NULL那行依然不会被匹配。 - 只有
WHERE amount IS NULL才能把NULL那一行捞出来。
我第一次在这个问题上栽跟头时还觉得MySQL不讲道理,后来才明白:NULL参与任何普通比较运算,结果都是NULL(可以理解为"未知"),而NULL在WHERE条件里会被当成假值过滤掉。这就是数据库里著名的"三值逻辑"——TRUE、FALSE、UNKNOWN。如果你写的SQL里带有!= NULL、= NULL这种条件,那恭喜你,这条语句永远查不出任何数据。
2.2 NOT NULL约束的实战行为
NOT NULL约束的语法很简单,建表时在字段类型后面加NOT NULL即可:
CREATE TABLE user_info ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) NOT NULL COMMENT '用户名' );一旦加了这个约束,往表里插入一条username为NULL的记录,MySQL会拒绝执行。在默认的严格模式下,报错信息大致是:
ERROR 1048 (23000): Column 'username' cannot be null注意这里的"严格模式"很关键。早期MySQL为了兼容性,对不合规的数据会静默处理成"最接近的合理值",比如把非法字符串截断、把超范围数字改成边界值,而不报错。MySQL 5.7之后默认开启了严格模式,相当于把"安检"从睁一只眼闭一只眼变成了铁面无私。如果你们团队的数据库还开着宽松模式,我建议赶紧确认一下select @@sql_mode;的输出里有没有STRICT_TRANS_TABLES,没有的话数据质量会一直在裸奔。
2.3 怎么判断一个字段该不该NOT NULL
我的判断方法很简单:先问自己"这条记录如果拿不到这个字段,业务还讲得通吗"。
- 用户表的用户名、手机号,订单表的订单号、下单时间,这些是记录存在的基础,必须NOT NULL。
- 用户表的备用邮箱、昵称头像,订单表的备注、优惠券编号,这些允许用户不填,可以允许NULL。
- 还有一类"现在可有可无,但未来要参与查询统计"的字段,比如用户的城市、年龄段,强烈建议NOT NULL并配DEFAULT,避免以后用GROUP BY统计时被NULL搅浑。
另外要提醒的是,对于VARCHAR和CHAR这类字符串字段,"空字符串"和"NULL"在语义上可以统一处理,但在存储和查询上表现不同:空字符串占长度,NULL在InnoDB里通常用NULL标志位表示。我更推荐一种团队约定:凡是业务上"必填但可能暂时不知道"的字段,一律NOT NULL加默认值;凡是"明确可以不填"的字段,允许NULL并配合程序里把空值当"未设置"处理。有了统一约定,写出来的SQL才不会被同事骂。
3. DEFAULT约束:让数据库自己补上"标准答案"
3.1 DEFAULT的语法与生效时机
DEFAULT约束负责在插入语句没指定某个字段时,自动填入一个预设值。它的经典写法是:字段类型后面跟DEFAULT 值。
CREATE TABLE order_info ( order_id BIGINT PRIMARY KEY, status TINYINT NOT NULL DEFAULT 0 COMMENT '订单状态 0待支付 1已支付 2已取消' );上面的status字段,插入时不写,MySQL会自动补0。这里有一个容易搞混的细节:如果插入时显式写了status = NULL,在字段允许NULL的情况下,MySQL会尊重你的NULL,不会用默认值覆盖。只有当你完全没提这个字段时,默认值才生效。这也是为什么我强烈建议"业务上不允许为空的状态字段"一定要NOT NULL DEFAULT,双管齐下,才能同时挡住"漏填"和"显式填NULL"两种情况。
MySQL 8.0还对默认值做了一点增强——支持表达式默认值。用得最多的是时间字段的CURRENT_TIMESTAMP,后面我会单独讲。
3.2 哪些字段天然适合默认值
结合我见过的表结构,适合加DEFAULT的字段通常有这几类:
- 状态类:订单状态、审核状态、是否删除,这类字段99%的情况是从"初始态"开始,默认0是最自然的。
- 计数类:浏览次数、点赞数、库存,从0开始计数,DEFAULT 0比每次插入时显式写0省心。
- 标记类:is_active、is_deleted这类布尔标记,默认0(或默认1,具体看业务)。
- 时间类:created_at、updated_at,默认当前时间,后面细说。
这里有个基础但重要的设计点:加默认值之前先想清楚它代表什么业务含义。比如性别字段很多人喜欢DEFAULT 0表示"未知",那就要确保全项目都认可0的含义,并且查询时能正确处理。默认值一旦定错,从"未知"变"男"这种事故我也见过,最后只能靠数据订正脚本硬扛。
3.3 时间字段的默认值和自动更新
时间字段应该是DEFAULT约束里最实用的一个场景。拿MySQL 5.6.5之后版本来说,DATETIME和TIMESTAMP都支持默认当前时间:
CREATE TABLE user_info ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';注意updated_at比created_at多了一个ON UPDATE CURRENT_TIMESTAMP,它的作用是:只要这一行的数据被UPDATE,updated_at就自动刷新成当前时间,不用业务代码手动维护。这个特性非常适合"最后修改时间"这种字段,我几乎每张核心表都会用。
需要提醒的是,TIMESTAMP类型有2038年问题,而且范围受时区影响,DATETIME的取值范围更大,也更直观。新项目我基本都是用DATETIME,省得若干年后系统跨过2038年还要紧急修表。
3.4 DEFAULT与NOT NULL的经典搭配
把DEFAULT和NOT NULL组合起来用,效果是"1+1>2":NOT NULL保证不会出现空值,DEFAULT保证你漏填时有个兜底值。这也是我上面反复强调的组合。举个例子,用户表里的注册渠道字段:
channel VARCHAR(20) NOT NULL DEFAULT 'organic' COMMENT '注册渠道'如果哪天运营发现一批用户不知道从哪来的,一看channel是organic,至少能知道是自然流量,而不是一堆NULL在那里什么都查不出来。这类"枚举含义明确的字段",NOT NULL DEFAULT基本是标配。
4. UNIQUE约束:把重复数据挡在门外
4.1 唯一键与普通索引的关系
UNIQUE约束(唯一键)要求字段的值在整张表里不能重复。它在底层实现上其实是一个唯一索引——也就是说它既能约束数据,又能加速查询。这一点很多新手容易忽略:给字段加UNIQUE时,顺手白赚了一个索引,对按这个字段查数据的场景很有帮助。
语法上,可以在建表时写在字段后面,也可以在表定义末尾单独声明:
CREATE TABLE user_info ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) NOT NULL, email VARCHAR(100), UNIQUE KEY uk_username (username), UNIQUE KEY uk_email (email) );插入重复的username时,MySQL会直接报错:
ERROR 1062 (23000): Duplicate entry 'zhangsan' for key 'user_info.uk_username'注意这里的错误码是1062,应用层捕获这个错误码,就能把"数据库层面的重复校验"翻译成友好的业务提示。我在很多项目里都见过应用层先查一遍再插入的做法,其实在高并发下会有严重的竞态问题:两个请求同时查到"不存在",然后同时插入,结果还是有一条撞上唯一键。正确做法是干脆依赖UNIQUE兜底,遇到1062再提示用户。
4.2 单列唯一与联合唯一
除了单列唯一,MySQL还支持多列组成的联合唯一索引,它约束的不是"每一列都不重复",而是"这几列的组合不能重复"。这在关联表里特别常见。
举个最典型的例子,一个用户收藏文章的收藏表,希望同一个用户对同一篇文章只能收藏一次:
CREATE TABLE user_favorite ( user_id INT NOT NULL, article_id INT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_article (user_id, article_id) );有了这个联合唯一键,不管应用层怎么并发插入,同一对(user_id, article_id)都只可能成功一次。这里要特别强调:联合唯一判断重复时是有顺序的,索引最左列放在查询频率最高的字段上,对查询效率更友好。上面例子如果业务经常"查某个用户的收藏列表",user_id放前面就是正确的。
4.3 一个容易踩坑的细节:UNIQUE与NULL并存
MySQL对UNIQUE约束有一个和其他数据库不太一样的行为:唯一键列上允许多个NULL存在。原因很简单——NULL在MySQL的语义是"未知",未知和未知不相等,所以数据库认为"不知道"和"不知道"不构成重复。
我见过有人踩过这个坑:给手机号字段加了UNIQUE,以为这样就能保证手机号不重复,结果用户不填手机号时存了一堆NULL,数据库一个都没拦。如果业务上"允许不填手机号,但填了就必填唯一",这个行为正好符合预期;但如果业务上"手机号是必填且唯一",就必须再加NOT NULL,把NULL这条路彻底堵死。一句话总结:UNIQUE管重复,NOT NULL管空值,两者各管一摊,别指望UNIQUE顺手把空值也管了。
4.4 使用UNIQUE的避坑清单
我再整理几个实操中容易翻车的点:
- 给大字段(比如VARCHAR(500)的文本)加UNIQUE时要小心索引长度限制。InnoDB里索引长度按字节算,老的5.7版本限制767字节,utf8mb4下一个字符占4字节,基本只够放190个字符;8.0放宽到3072字节,但也不是无限。字段过长时,要么用前缀索引曲线救国,要么换一种校验方案。
- 唯一索引不是越多越好。每个唯一索引都是一棵B+树,写入时要额外维护,索引太多会把插入性能拖垮。只给真正承载"业务唯一性"的列加UNIQUE。
- 并发场景下,应用层的"先查后插"不能替代UNIQUE。唯一键是数据库在事务里加的约束,能真正杜绝竞态,应用层做不到。
- 修改唯一键时要先确认存量数据没有重复,否则ALTER TABLE会直接报错。我一般会先跑一条GROUP BY统计,确认没有重复再动手。
5. PRIMARY KEY:一张表的"身份证明"
5.1 主键与唯一键的区别
主键(PRIMARY KEY)是约束体系里级别最高的一种。它同时具备两个特性:非空 + 唯一。一张表只能有一个主键,但可以有多个唯一键。如果非要把约束比作身份证系统,主键就是身份证号,唯一键更像是手机号——手机号也可以唯一,但ID才是第一重身份标识。
从存储引擎角度看,区别就更实际了。InnoDB的索引组织表是聚簇索引结构,数据行本身按照主键顺序存储。主键的性能和存储布局直接决定了整张表的读写效率。这也是为什么我建表时从不允许出现"没有主键"这种设计——在InnoDB里没有主键时,MySQL会偷偷找个非空唯一索引当主键,再不行就生成一个隐藏的行ID,其实就是把主键选择权交给了数据库,这对后续数据维护是灾难。
5.2 自增主键的几个坑
最普遍的主键方案是自增整数主键:
CREATE TABLE user_info ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', username VARCHAR(32) NOT NULL, PRIMARY KEY (id) );这套方案本身没问题,但有几个细节我建议提前记住。
第一,AUTO_INCREMENT的值只增不减。DELETE掉几条记录后,下次插入的自增值不会回退,会继续往上走。只有TRUNCATE TABLE才会重置自增。所以别指望"删掉最大ID就能复用",自增ID天然就不保证连续,只保证唯一和递增。
第二,手动指定ID会影响后续自增。如果某次插入显式写了id = 1000,之后的自增值会跳到1001甚至更高,相当于把计数器抬上去了。批量导数据、主从切换、合并分库时最容易踩这个坑。
第三,数据量大的表建议用BIGINT而不是INT。INT最大约21亿,对很多业务看起来够用,但一旦遇到异常数据灌入、日志类慢增长的表,21亿真不一定撑多少年。用BIGINT UNSIGNED的额外成本很小,换来的却是几十年不用操心ID耗尽。
5.3 逻辑主键还是业务主键
这是建表时最容易纠结的问题:主键到底用自增ID,还是用业务上本来就唯一的字段,比如用户名、身份证号、订单号?
我的建议是:绝大多数业务表用逻辑主键(自增ID这类与业务无关的字段),业务唯一字段用UNIQUE兜底。理由是业务主键会随业务变化:用户名是允许修改的,身份证号涉及隐私不能明文到处关联,订单号格式变了但主键还想保持稳定……这些情况下,业务主键都会成为负担。逻辑主键是纯粹的身份标识,永远不变,业务字段改了不影响主键引用关系。
当然也有例外:某些只读配置表、码表,本身数据量小、字段稳定,直接用业务代码(比如枚举值)做主键反而更直观。判断标准还是要回到那句老话:这个"身份标识"在未来十年内会不会变,如果确定不变,才考虑业务主键。
5.4 复合主键的使用场景
复合主键就是拿两列或多列共同做主键,常见于没有自增ID需要的关联表、明细表。比如订单明细表里,用订单号加行序号作为复合主键,既能唯一定位一行,又能直接体现"这个明细属于哪个订单"。
使用复合主键时,有一个要时刻记住的特点:复合主键的列都不能为NULL。这一点和普通字段不一样,哪怕你觉得"其中一列暂时填不出来,等后面补",抱歉,MySQL不允许。所以要求参与复合主键的每一列,在插入时都必须有确定值。
另外,复合主键本身构成一个最左联合索引,查询时如果条件包含最左列,会走这个索引;如果跳过了最左列,索引基本用不上。设计复合主键的列顺序时,要把区分度高、查询频率高的列放前面。不过说实话,现代业务表我越来越倾向于"自增ID做主键 + UNIQUE约束管业务唯一性",复合主键留给真正需要它的场景,比如中间表和明细表,别为了省一个索引把所有表都搞成复合主键。
6. 建表时约束设计的实操清单
6.1 给一张新表定约束的五步法
这几年代码评审里我总结出一套给新表设计约束的固定动作,分享给大家:
- 先把字段清单列一遍,逐个回答"这条记录缺了这个字段还成立吗",成立的可以允许NULL,不成立的一定NOT NULL。
- 对必填字段里"初始状态有明确含义"的,补上DEFAULT,比如状态、渠道、创建时间。
- 找出业务上必须唯一的字段,比如用户名、手机号、订单号,加UNIQUE;如果唯一性是组合概念,用联合唯一。
- 明确主键策略:无特殊需求就用自增BIGINT,有明确规则且永不变化的业务场景再考虑业务主键。
- 最后把所有约束回读一遍,手写几条INSERT试一下,确认报错信息符合预期,顺便把字段注释写全。
这套流程看着简单,但真能拦住九成以上的脏数据问题。我优化过的表,后来几乎没有因为"空值、重复、乱序"被运营找上门。
6.2 建表后再改约束的常用SQL
实际项目里,表结构不是一次定型就完事,经常需要补充约束。我把常用的修改语句整理一下:
-- 给已有表添加主键 ALTER TABLE user_info ADD PRIMARY KEY (id); -- 给已有表添加唯一约束 ALTER TABLE user_info ADD UNIQUE KEY uk_username (username); -- 修改字段为NOT NULL(注意先处理存量NULL) ALTER TABLE user_info MODIFY COLUMN username VARCHAR(32) NOT NULL; -- 修改字段默认值 ALTER TABLE user_info ALTER COLUMN status SET DEFAULT 0; -- 删除默认值 ALTER TABLE user_info ALTER COLUMN status DROP DEFAULT;有两个操作我特别想提醒。一是把已有字段改成NOT NULL之前,一定要先处理存量数据里的NULL,否则ALTER直接报错;二是给已有大量数据的表加UNIQUE或主键时,MySQL要重建索引,期间可能锁表,大表要选在业务低谷期操作,或者用在线DDL的算法参数控制。
6.3 常见报错和应对速查
最后给一张速查表,遇到报错可以对号入座:
| 报错信息 | 对应的约束 | 处理方向 |
|---|---|---|
| Field 'xxx' doesn't have a default value | NOT NULL且无默认值,插入时漏填 | 补上字段值,或给字段加合理的DEFAULT |
| Column 'xxx' cannot be null | 插入NULL被NOT NULL拦截 | 修正插入数据,检查应用层传参 |
| Duplicate entry 'xxx' for key 'yyy' | 撞上UNIQUE或主键 | 确认业务是否需要去重,捕获1062做提示 |
| Cannot add foreign key constraint | 外键问题(下篇细讲) | 先确认关联字段类型、字符集是否一致 |
这几张表和步骤,你在实际开发中直接照着用就行。数据库约束这东西,说破天也就是"提前定规则"四个字,但提前定规则和事后擦屁股,差的可是整个数据质量体系的稳定性。
我个人这些年最大的体会是:约束不是给人添麻烦的,它是给数据库立规矩的。每一条约束背后,都对应着一类曾经踩过的坑——NULL查不出来、重复插不进去、主键没得选。把这些规则在建表时一次性立好,后面写业务代码的时候就能少操很多心。关于外键、CHECK约束、以及更复杂的表间引用关系,我会在表的约束(下)里继续聊,到时候见。