干数据库这一行,建表的时候偷懒一时,后面填坑就填一年。很多新手学MySQL,CRUD写得很溜,一到约束就犯迷糊:什么时候用UNIQUE,什么时候用主键,NULL和空字符串到底差在哪,为什么把手机号设成唯一约束之后还是能插进重复的NULL……今天这篇专门聊MySQL里最常用的四类约束:非空、唯一、默认值和主键。这是约束系列的第一篇,外键和CHECK我会放到下一篇展开。
先说明白,这篇不是让你背语法的,而是让你看完之后能直接在自己的项目里用对、用稳。文章会围绕一张用户表从头建到尾,每一步都会解释为什么这么设计,表结构已经存在的话怎么补约束,以及实际踩坑之后怎么排查。无论是刚入门的学生,还是写了两三年业务开发的工程师,这几页东西都值得过一遍。
1. 约束到底是什么:数据完整性从哪来
约束说白了就是数据库层面的“规矩”。你不想让用户填一个空手机号注册,不想让订单号出现两条一模一样的记录,不想让员工年龄填个负数——业务代码里当然可以写一堆if判断,但代码是人写的,总会有漏网之鱼。把规则直接钉在表结构上,数据库会帮你在写入的那一刻把关,不合格的数据根本进不去。
这个“把关”的背后有一个概念叫数据完整性。完整性分三类:实体完整性、域完整性、引用完整性。实体完整性要求每行数据能被唯一标识,靠主键解决;域完整性要求某列的值必须落在合法范围内,靠非空、默认值、CHECK解决;引用完整性要求表与表之间的关联关系必须成立,靠外键解决。这三类基本把所有约束都装进去了。
MySQL里实际能用的约束就六种:
| 约束类型 | 关键字 | 核心作用 |
|---|---|---|
| 非空约束 | NOT NULL | 值不能为空 |
| 唯一约束 | UNIQUE | 值不能重复 |
| 主键约束 | PRIMARY KEY | 每行的唯一标识,自带非空+唯一 |
| 默认值约束 | DEFAULT | 不传值时用指定默认值 |
| 外键约束 | FOREIGN KEY | 保证子表引用父表的完整性 |
| 检查约束 | CHECK | 值必须满足指定条件,8.0.16后才真正生效 |
今天重点讲前四种,因为它们的出现频率最高,也是所有表设计的基础。外键牵扯到父子表、级联操作、性能取舍,CHECK在8.0版本前后的行为差异又很大,单独开一篇更合适。
2. 从一张用户表开始:四条约束的建表实战
我习惯用一个贯穿到底的例子。假设我们正在设计用户模块的表,业务需求大概是:用户名必填且不能重复,手机号和邮箱可以暂时不填但一旦填了就不能重复,年龄不传默认18,账号状态默认正常,每条记录需要一个自增主键。对应的建表语句如下:
CREATE TABLE users ( id INT AUTO_INCREMENT COMMENT '用户ID', username VARCHAR(50) NOT NULL COMMENT '用户名', phone CHAR(11) COMMENT '手机号', email VARCHAR(100) COMMENT '邮箱', age TINYINT UNSIGNED DEFAULT 18 COMMENT '年龄', status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1正常 0禁用', created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (id), UNIQUE KEY uk_phone (phone), UNIQUE KEY uk_email (email) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';这张表里既有列级约束(写在列定义里的NOT NULL、DEFAULT),也有表级约束(写在最后面的PRIMARY KEY、UNIQUE KEY)。列级约束只管当前这一列,表级约束可以横跨多列,比如联合唯一、联合主键,这种写法后面会讲到。
建好之后立刻验证一下约束真的在起作用。插入一条正常数据:
INSERT INTO users (username, phone, email) VALUES ('user_a', '13800138000', 'a@example.com');username、phone、email都传了,age和status没传,于是DEFAULT生效,age变成18,status变成1。再插入一条违反非空约束的数据:
INSERT INTO users (phone, email) VALUES ('13900139000', 'b@example.com');执行会报错:Field 'username' doesn't have a default value。因为username有NOT NULL又没有DEFAULT,不传就只能收到一个错误,这条脏数据根本落不了库。
再试试重复手机号:
INSERT INTO users (username, phone, email) VALUES ('user_c', '13800138000', 'c@example.com');手机号还是13800138000,唯一约束直接拦截,报Duplicate entry '13800138000' for key 'users.uk_phone'。整个过程可以看到,约束不是在应用层靠if判断拦截的,而是数据库在提交阶段就把数据挡住了。
2.1 非空约束:别把NULL和空字符串搞混
非空约束的语法最简单,列后面加NOT NULL就是:
CREATE TABLE t1 ( id INT NOT NULL, name VARCHAR(20) NOT NULL );但很多人忽略了一个关键细节:NULL和空字符串完全是两个东西。NULL表示“没有值”,空字符串表示“有一个值,这个值是空的”。在NOT NULL约束下,插入空字符串可以成功,插入NULL才会报错。
INSERT INTO t1 (id, name) VALUES (1, ''); -- 成功 INSERT INTO t1 (id, name) VALUES (2, NULL); -- 报错业务里经常出现“用户填了个空值不报错”的困惑,十有八九就是空字符串和NULL的语义没分清。设计表的时候想清楚:这列的“没有值”用NULL表示还是不存在的状态?比如用户的备注字段,NULL和空字符串都无所谓;但手机号这种必须明确的字段,最好用NOT NULL加上默认的空串来兜底。
2.2 唯一约束:防止重复的坑中坑
唯一约束保证列值不重复,语法有两种写法。单列唯一可以直接放在列定义里:
CREATE TABLE t2 ( email VARCHAR(100) UNIQUE );也可以独立声明并给约束起名,方便后续删除:
CREATE TABLE t2 ( email VARCHAR(100), UNIQUE KEY uk_email (email) );需要多个列联合不许重复的时候,必须用表级写法:
CREATE TABLE follow ( user_id INT, follow_id INT, UNIQUE KEY uk_user_follow (user_id, follow_id) );这句的意思是:同一个user_id不能重复关注同一个follow_id,但是一个用户仍然可以关注很多不同的人。联合唯一在社交关注、购物车、订单明细这些场景里非常常见。
唯一约束最大的坑在NULL上。MySQL默认允许唯一约束的列存在多条NULL记录,因为NULL不等于任何值,NULL也不等于NULL。所以上面那张users表,phone加了唯一约束,但你可以插一千行phone为NULL的数据,一条都不会报错。业务上如果“手机号不能为NULL而且只能有一个NULL都不行”,那就得额外用默认值兜底,或者直接设定手机号既NOT NULL又UNIQUE,再配合默认空字符串。
2.3 默认值约束:让偷懒也变得安全
DEFAULT的作用是在插入时不显式给值的情况下,自动填入指定内容。它最常用的搭配对象是状态位和时间字段:
status TINYINT NOT NULL DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP注意CURRENT_TIMESTAMP是MySQL专门给时间字段的默认值函数,可以把记录的创建时间自动填进去。8.0开始还支持DEFAULT (表达式),比如用UUID()生成随机值作为默认值,灵活性更大。
用DEFAULT有个容易混淆的点:如果你插入时显式写了NULL,默认值不会生效,除非这列同时允许NULL。换句话说,默认值只在你“不写这一列”或者“显式写DEFAULT关键字”的时候才会填进去:
INSERT INTO users (username, phone, email, age) VALUES ('user_d', '13700137000', 'd@example.com', DEFAULT);这种隐式逻辑不踩一次很难记住,我在带新人的时候发现普遍会搞错,以为写了DEFAULT就会自动替代NULL,其实正好相反。
3. 主键约束:每一张表的定海神针
3.1 为什么要选自增整数主键
主键的本质是给每一行数据一个身份证号。它同时继承了非空和唯一两条约束,而且一张表只能有一个主键。很多人一开始纠结:主键和唯一约束都能保证不重复,那两者有什么区别?
第一个区别是主键必须非空,唯一约束允许NULL。第二个区别是主键承载的意义不一样,它不仅是约束,更是InnoDB组织数据的方式。InnoDB是索引组织表,数据按照主键索引的顺序物理存储,通过主键访问数据是最快的路径。第三个区别是唯一约束可以建多个,主键只能建一个。
主键选型上,我强烈建议业务无关的自增整数ID。不要用手机号做主键,手机号可能会被注销、换号;不要用邮箱做主键,邮箱可能变更;更不要用UUID做主键,UUID随机性强,写入时会造成大量页分裂,性能会很差。自增ID稳定、有序、占用空间小,是最省心的选择。
3.2 创建主键的三种姿势
第一种,列级约束:
CREATE TABLE t3 ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) );第二种,表级约束并命名:
CREATE TABLE t3 ( id INT AUTO_INCREMENT, name VARCHAR(50), CONSTRAINT pk_id PRIMARY KEY (id) );第三种,联合主键:
CREATE TABLE t3 ( order_id INT, item_id INT, PRIMARY KEY (order_id, item_id) );联合主键的意思是两列组合起来才能唯一确定一行。不过日常开发中联合主键用得越来越少,除非是纯粹的关系表比如订单明细、标签关联。大多数场景我更推荐代理主键加联合唯一约束,原因后面讲索引的时候会提到,多用一张表的主键索引开销和更新逻辑都会复杂很多。
3.3 自增主键的三个细节
AUTO_INCREMENT必须搭配索引使用,一般直接放在主键上。它有三个容易踩的细节要记牢:
第一个,InnoDB的自增值不会因为删除最大ID而回退。假设当前最大ID是100,删掉第100行,再插入新数据,新ID是101而不是100。这个行为在MySQL 8.0之前由内存维护,重启后可能会有变化;8.0开始自增值持久化到redo log和磁盘,重启后也是101。别惊讶,这是设计如此。
第二个,TRUNCATE和DELETE重置自增的行为完全不同。TRUNCATE清空表之后,自增从头开始;DELETE删除全部数据之后,自增继续保持原值:
TRUNCATE TABLE users; -- 自增重置为1 DELETE FROM users; -- 自增仍延续原来的值第三个,可以手动改自增的起始值,适合做分库分表时避免ID冲突:
ALTER TABLE users AUTO_INCREMENT = 10000;4. 约束的修改与管理:建完表之后怎么补
实际开发中很少能一锤定音建出完美的表,需求一变,约束就得跟着改。约束的管理全部围绕ALTER TABLE展开,我把常见的操作整理成一张速查表:
| 操作 | SQL语句 |
|---|---|
| 添加非空约束 | ALTER TABLE users MODIFY email VARCHAR(100) NOT NULL; |
| 删除非空约束 | ALTER TABLE users MODIFY email VARCHAR(100) NULL; |
| 添加唯一约束 | ALTER TABLE users ADD UNIQUE KEY uk_email (email); |
| 删除唯一约束 | ALTER TABLE users DROP INDEX uk_email; |
| 添加默认值 | ALTER TABLE users ALTER status SET DEFAULT 1; |
| 删除默认值 | ALTER TABLE users ALTER status DROP DEFAULT; |
| 添加主键 | ALTER TABLE users ADD PRIMARY KEY (id); |
| 删除主键 | ALTER TABLE users DROP PRIMARY KEY; |
注意几个容易翻车的细节。
用MODIFY添加非空约束时,等于是把整列重定义了一遍,必须把原来的数据类型和长度完整写出来,同时把想保留的属性比如DEFAULT、注释都带上。少写一个DEFAULT,默认值就丢了;少写一个COMMENT,注释也没了。这种“顺手把别的约束弄丢”的问题我见过太多次:
-- 原列定义:email VARCHAR(100) DEFAULT NULL -- 现在只想加非空,却不小心丢掉DEFAULT和COMMENT ALTER TABLE users MODIFY email VARCHAR(100) NOT NULL;正确做法是把完整定义写全:
ALTER TABLE users MODIFY email VARCHAR(100) NOT NULL DEFAULT '' COMMENT '邮箱';删除主键之前,如果主键列还挂着AUTO_INCREMENT,会直接报错ERROR 1075 (42000): Incorrect table definition。得先去掉自增属性,再删主键:
ALTER TABLE demo MODIFY id INT; -- 去掉自增,注意类型带上 ALTER TABLE demo DROP PRIMARY KEY;最后分享一个习惯:每次改完表结构,习惯性地跑一下SHOW CREATE TABLE users;,看一眼最终列定义。这比猜约束有没有生效靠谱得多,改表之前也建议先看一眼这个输出,确认当前结构再动手。
5. 常见问题与排查技巧实录
建约束的时候大家遇到的问题其实高度重复,我把这几年带新人遇到的典型问题整理出来,按“症状、原因、解法”的方式列一下,方便你直接对号入座。
症状:给手机号加了UNIQUE,成功插入两条phone为NULL的数据,没有报错。 原因:MySQL认为NULL不等于任何值,多个NULL之间互不冲突。 解法:业务上如果绝对不能接受NULL,就把列改成NOT NULL DEFAULT '',用空字符串兜底,再配合UNIQUE。
症状:给一张已有数据的表加UNIQUE,报错Duplicate entry。 原因:表里已经有了重复值,约束校验失败。 解法:先查重、清理或合并重复数据,再添加约束:
SELECT phone, COUNT(*) FROM users GROUP BY phone HAVING COUNT(*) > 1;症状:执行ALTER TABLE加非空约束,报错Invalid use of NULL value。 原因:当前列里已经存在NULL,给整列加NOT NULL时校验不过。 解法:先用UPDATE把NULL补齐,例如:
UPDATE users SET email = '' WHERE email IS NULL; ALTER TABLE users MODIFY email VARCHAR(100) NOT NULL DEFAULT '';症状:MODIFY改完列之后,发现之前建的索引或注释丢了。 原因:MODIFY是整列重定义,不是局部修改。 解法:重写完整列定义,索引如果需要保留也一并重建。
症状:删除主键报错ERROR 1075。 原因:主键列还带着AUTO_INCREMENT。 解法:先去掉自增属性,再删除主键,顺序不能反。
症状:插入数据时设置了一个违反CHECK的负数,结果没有被拦截。 原因:MySQL 8.0之前CHECK是“只解析不执行”的,8.0.16之后才真正强制校验。 解法:升级到8.0.16以上,或者用触发器兜底。
这些坑看起来零散,背后其实是一条主线索:约束的行为和列定义的状态是紧紧绑在一起的,改列的时候不看现状直接拼SQL,几乎必然出事。
6. 约束命名规范:老手都在坚持的原则
最后聊一个很多人根本不在意的点——约束命名。用上面讲的方法直接写UNIQUE KEY,而不显式命名的话,MySQL会自动生成一个名字。这个名字通常是列名加上一串随机后缀,比如email_2、phone_3这类。乍一看能用,等你想删这个约束的时候就会卡住:
ALTER TABLE users DROP INDEX email_2;你根本猜不到自动名字到底是什么,还得先SHOW CREATE TABLE查一遍。所以我建议从头就养成手动命名的习惯:
CONSTRAINT uk_email UNIQUE (email), CONSTRAINT uk_phone UNIQUE (phone), CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES users(id)命名规则不用花哨,前缀清晰就行:主键用pk_,唯一约束用uk_,外键用fk_,检查约束用ck_,后面跟着表名和列名,一眼就能猜个八九不离十。删约束的时候直接DROP INDEX uk_email,再也不用到系统表里翻名字。
我在实际工作中还养成了一个习惯:新表设计完之后,SQL先不用急着执行,脑子里过一遍这几件事——主键是不是自增、有没有业务字段被误设成唯一、必填字段有没有全部加上NOT NULL、时间字段有没有默认值、有没有靠代码逻辑才能保证的完整性。这一遍过完再落库,后面基本能少踩一半的坑。
说到底,约束是数据质量的最后一道防线。它不复杂,但很琐碎,值得你认认真真对待。下一篇我会把外键约束和CHECK约束展开讲,包括外键的级联策略选择,以及什么时候宁可不用外键也要保证性能,感兴趣的话咱们下篇见。