先说明一下,这个“6”是系列文章里的第6篇,不是MySQL的版本号。MySQL官方版本跳过6.x直接到了8.0,所以看到“MySQL 6”不用慌,咱们聊的还是那套熟悉的约束体系。数据库约束是表结构设计里最容易被新手跳过、又最值得花时间研究的一块:它能直接决定你的数据是“能存进去”还是“存进去是干净的”。这篇我把MySQL里的约束从概念、语法到坑位全部展开,尽量用实际场景说话,适合刚学完增删改查、正准备正经设计表的新人,也适合那些写过几年SQL但一直靠应用层硬校验、没怎么用约束的老同学。
1. 先搞清楚约束到底在解决什么问题
1.1 没有约束的时候,数据库会变成什么样
很多人第一次接触约束,是在建表语句里看到一行NOT NULL或UNIQUE,觉得“哦,就是加个限制嘛”,然后就没然后了。但约束真正的价值,得从“没有约束会怎样”这个角度去看。
假设你要做一个用户表,没有约束的写法很随意:
sql复制CREATE TABLE users (
id INT,
username VARCHAR(50),
email VARCHAR(100),
age INT
);
这个表能用吗?能用。但跑一段时间你就会遇到这些事:用户注册时程序漏传了username,库里存进去一条NULL;同一个邮箱注册了两回,库里出现两条记录;用户表里id重复,导致后面所有关联数据全乱;甚至有人把age写成 -5 或者 999。
这些问题的共性是什么?是数据本身不满足业务要求,但是数据库一点反应都没有,照单全收。等到你写报表、做统计、跨表JOIN的时候,才发现一堆脏数据在里面,那时候再回头洗数据,成本比当初设计表的时候高几十倍不止。
约束不是给你添麻烦的,它是数据库在入口处帮你守门。你可以这么理解:没有约束的表就像没有安检的入口,什么人都能进;有约束的表就是加了门禁,身份证、行李、票务都查一遍再放行。
1.2 约束的分类:三类完整性,一张表说清楚
MySQL的约束体系,从数据完整性的角度可以分成三类,这也是面试时经常被问到的框架:
| 约束类型 | 解决的完整性问题 | 对应的约束 | 一句话解释 |
|---|---|---|---|
| NOT NULL、DEFAULT、CHECK | 域完整性 | 列级别的合法性 | 每一列的值本身要合理 |
| PRIMARY KEY、UNIQUE | 实体完整性 | 行级别的唯一性 | 每一行都能被唯一识别 |
| FOREIGN KEY | 引用完整性 | 表之间的关联性 | 引用的数据必须真实存在 |
这张表建议收藏,它把约束的“为什么”讲清楚了。PRIMARY KEY和UNIQUE管的是“行能不能区分开”,NOT NULL、DEFAULT、CHECK管的是“列的值合不合法”,FOREIGN KEY管的是“表之间的引用是不是有效”。三类约束互相配合,数据才会进入可控状态。
MySQL 8.0里还支持CHECK约束的真正强制,这在5.7及以前是“解析但不执行”的,细节在后面说。整体来看,主流的MySQL版本约束体系已经比较完整,足够支撑正常的业务表设计。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 五大基础约束逐个拆解
2.1 NOT NULL:拒绝空值,但不背“空字符串”的锅
NOT NULL是大家最早见过的约束,字面意思就是这一列不允许为NULL。但第一个坑就藏在NULL的定义里:NULL不是空字符串,也不是0,它是“未知”的意思。
sql复制INSERT INTO users (username, email) VALUES ('', 'test@test.com');
这条语句如果username列是NOT NULL,能插入成功。因为空字符串''不是NULL,它是一个真实存在的值,只是内容为空。所以如果你想让username“既不能为NULL,也不能为空字符串”,光靠NOT NULL是不够的,还得配合CHECK约束或者应用层校验。
第二个坑是UPDATE的时候容易踩。一张老表,原来列允许NULL,里面已经存了十条NULL记录,你后来执行:
sql复制ALTER TABLE users MODIFY COLUMN username VARCHAR(50) NOT NULL;
MySQL会直接报错,告诉你Column 'username' cannot be null。原因很简单:存量数据里已经有NULL了,你非要把它改成NOT NULL,数据库得先保证现有数据满足新规则,做不到就只能拒绝执行。这是很多人在生产环境改表结构时被卡住的地方。我的习惯是先查一遍存量:
sql复制SELECT COUNT(*) FROM users WHERE username IS NULL;
确认是0之后再改,心里踏实。
2.2 UNIQUE:唯一性兜底,但要留意NULL和大小写
UNIQUE约束保证一列或一组列的值不重复。它在底层会创建唯一索引,插入时如果发现重复值,直接报1062 Duplicate entry错误。
这里最容易被误解的是“UNIQUE列能不能存多个NULL”。我明确说:能。MySQL的UNIQUE索引允许存在多个NULL值,因为NULL代表未知,未知和未知不相等,所以不违反唯一性。这个行为和Oracle的“全NULL不唯一”是一致的,但和SQL Server的“只允许一个NULL”不同。如果你业务上要求“允许为空,但空的也只能有一个”,那UNIQUE就满足不了,得另想办法。
再聊一个真实遇到过的问题:大小写。MySQL默认的排序规则是utf8mb4_general_ci,ci就是case insensitive,不区分大小写。所以在默认配置下,'abc'和'ABC'在UNIQUE索引里会被当成同一个值,第二条插入会报重复。如果你业务上要求区分大小写,建表时就得把这一列的排序规则改成utf8mb4_bin或者utf8mb4_0900_as_cs:
sql复制CREATE TABLE users (
username VARCHAR(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
UNIQUE KEY uk_username (username)
);
这种细节,用默认配置根本测不出来,直到线上有人注册了两个英文大小写不同的账号,才发现撞了唯一键。
2.3 DEFAULT:给字段一个默认姿态
DEFAULT约束的作用是:INSERT语句没给这一列指定值时,数据库自动填上默认值。这是日常开发里最常用的约束之一,典型的场景就是状态字段和创建时间:
sql复制CREATE TABLE orders (
status TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已取消',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
status给默认值0,新订单进来不用显式写状态;created_at用CURRENT_TIMESTAMP,插入时自动带时间;updated_at加了ON UPDATE,每次行更新时自动刷时间。这三件套几乎是所有业务表的标配。
DEFAULT有个容易混淆的点:如果你明确插入NULL,DEFAULT不会生效。比如有一列是DEFAULT 1,但允许NULL,你执行INSERT INTO ... VALUES (NULL),存进去的还是NULL,不是1。只有当这一列完全不出现或者显式写DEFAULT关键字时,默认值才会填充。
另外,8.0之前TEXT和BLOB类型的列不能设置默认值。这个限制让很多人踩过坑,现在8.0.13版本以后支持了表达式默认值,可以这样写:
sql复制CREATE TABLE notes (
content TEXT DEFAULT ('')
);
注意8.0的DEFAULT要用括号包起来,这是表达式默认值的语法。不过说实话,TEXT给默认值的需求并不多,大多数时候我是用VARCHAR或者应用层兜底。
2.4 PRIMARY KEY:主键是一切的锚点
PRIMARY KEY综合了NOT NULL和UNIQUE,一列或一组列的值既不能为空,也不能重复,而且一张表只能有一个主键。主键的含金量在于:InnoDB是聚簇索引表,数据行物理上就是按主键顺序组织的,主键一旦确定,整张表的存储结构都围着它转。
这里面有几个实操层面的建议。第一,主键优先用自增整数或者雪花ID,不要用业务字段当主键,比如手机号、邮箱,因为业务字段会变,变了就是连锁反应。第二,整数类型建议加UNSIGNED,配合AUTO_INCREMENT正好:
sql复制CREATE TABLE users (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
PRIMARY KEY (id)
) ENGINE=InnoDB;
第三,复合主键能用但慎用。复合主键意味着这一组列共同决定唯一性,虽然合法,但会带来一个实际问题:任何一张子表想引用这张表,外键都得带上全部主键列,索引体积和维护成本都上去了。我见过一张三列复合主键的表,后续扩展时痛不欲生。能用单列主键就别用复合,这个原则至少覆盖90%的业务场景。
再顺手解释一下AUTO_INCREMENT:它依赖主键索引才能使用,这也是为什么自增列必须定义在键上。如果你在建表时忘了写PRIMARY KEY,然后想在AUTO_INCREMENT列上补,MySQL会提示你得先有key。
2.5 FOREIGN KEY和CHECK:关系与规则的两道闸门
FOREIGN KEY外键约束管的是引用完整性。子表插入或者更新时,MySQL会去父表里检查被引用的主键是否存在,不存在就拒绝。外键要求两张表都使用InnoDB引擎,且被引用的列必须是索引(通常是主键或唯一键),子表的外键列类型要和父表列完全一致,包括UNSIGNED这类属性。
外键还有一组联动动作,创建时通过ON DELETE和ON UPDATE指定:
| 动作 | 行为 |
|---|---|
| CASCADE | 父表删除/更新时,子表联动删除/更新 |
| SET NULL | 父表删除时子表外键列置为NULL,要求子表列允许NULL |
| RESTRICT | 默认行为,有引用关系时禁止父表操作 |
| NO ACTION | 和RESTRICT等价 |
| SET DEFAULT | InnoDB解析但实际不支持,别用 |
我最常用的是RESTRICT配合ON UPDATE CASCADE:删除时严格限制,防止误删删出孤儿数据;更新ID时联动,保证引用不失效。不过话说回来,很多互联网团队为了避免跨表锁和耦合,会选择在应用层维护引用关系,不用数据库外键。这个取舍没有绝对对错,但单一系统内部用外键做数据完整性兜底,我仍然认为是值得的。
CHECK约束则是给列加业务规则。前面说过,8.0.16版本之前MySQL对CHECK是“只解析不执行”,你写了它也不报错,但也不生效,非常坑。8.0.16之后正式支持:
sql复制CREATE TABLE users (
age TINYINT UNSIGNED,
CONSTRAINT chk_age CHECK (age >= 0 AND age <= 120)
);
现在插入age=200会被直接拒绝,报3819的错误。我在生产环境用CHECK拦住过不少离谱数据,比如折扣率超过0.9、库存数量为负、状态字段落到合法枚举之外等等,这些规则写在库里,比写在应用层每个写入入口都可靠得多。
3. 实操:从建表到改表,把约束一次配齐
3.1 建表时加约束的完整示例
约束可以在列定义后面直接写(列级约束),也可以在表定义最后独立写(表级约束)。我的建议是:单列约束用列级,组合约束用表级,可读性最好。下面是一个完整的用户表和订单表示例:
sql复制CREATE TABLE users (
id INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '用户ID',
username VARCHAR(50) NOT NULL COMMENT '用户名',
email VARCHAR(100) NOT NULL COMMENT '邮箱',
phone VARCHAR(20) NULL COMMENT '手机号,允许为空',
age TINYINT UNSIGNED NULL COMMENT '年龄',
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1正常 0禁用',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_username (username),
UNIQUE KEY uk_email (email),
CONSTRAINT chk_age CHECK (age >= 18 AND age <= 60)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
CREATE TABLE orders (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id INT UNSIGNED NOT NULL COMMENT '下单用户',
order_no VARCHAR(32) NOT NULL COMMENT '订单号',
amount DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '订单金额',
status TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已取消',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_order_no (order_no),
KEY idx_user_id (user_id),
CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES users (id)
ON DELETE RESTRICT ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';
有几个细节提醒一下。orders表里user_id为什么特意建了一个普通索引idx_user_id?因为外键约束要求被引用列有索引,InnoDB在创建外键的时候如果发现子表列上没有索引,会自动帮你建一个。但我还是习惯自己显式建,索引名可控、后续维护方便,不依赖MySQL的隐式行为。
DECIMAL(10,2)是金额字段的标配,别用FLOAT和DOUBLE存钱,浮点精度问题会把你磨疯掉。CHECK约束写了age在18到60之间,这里其实是业务判断,MySQL 8.0.16以后确实会强制执行,如果线上是5.7,这个约束就形同虚设,需要心里有数。
验证一下约束是否按预期工作:
sql复制-- 正常插入
INSERT INTO users (username, email, age) VALUES ('zhangsan', 'zhangsan@test.com', 25);
-- 违反唯一约束:报1062
INSERT INTO users (username, email, age) VALUES ('zhangsan', 'other@test.com', 30);
-- 违反CHECK约束:报3819
INSERT INTO users (username, email, age) VALUES ('lisi', 'lisi@test.com', 99);
-- 违反外键约束:报1452
INSERT INTO orders (user_id, order_no, amount) VALUES (9999, 'NO20240001', 100.00);
这四条SQL我建议你亲手跑一遍,有错误码的对照,以后在日志里看到1062、3819、1452,第一时间就能反应过来是哪类约束拦下来的。
3.2 用ALTER TABLE动态追加约束
现实项目里很少有一开始就把表设计完美的情况,更多时候是先上线跑,后面再补约束。ALTER TABLE是常见的补约束手段,把常用的几种写法列出来:
sql复制-- 追加NOT NULL
ALTER TABLE users MODIFY COLUMN phone VARCHAR(20) NOT NULL;
-- 追加UNIQUE
ALTER TABLE users ADD UNIQUE KEY uk_phone (phone);
-- 追加CHECK
ALTER TABLE users ADD CONSTRAINT chk_age CHECK (age >= 18 AND age <= 60);
-- 追加DEFAULT
ALTER TABLE orders ALTER COLUMN status SET DEFAULT 0;
-- 追加外键
ALTER TABLE orders ADD CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES users (id);
-- 删除约束
ALTER TABLE users DROP INDEX uk_phone;
ALTER TABLE orders DROP FOREIGN KEY fk_orders_user;
ALTER TABLE users DROP CHECK chk_age;
这里有个重要提醒:ALTER TABLE是DDL语句,在MySQL 8.0之前大部分DDL是COPY整张表的方式执行的,会全程锁表。你在几百GB的大表上跑一条“加NOT NULL”,可能直接把线上业务卡死。MySQL 8.0引入了INSTANT算法,支持部分操作原地完成,但仍然不是所有DDL都支持。生产环境规范操作是走专用的DDL变更工具(比如pt-online-schema-change)或者挑业务低峰期执行。这个坑,说多了都是泪。
追加NOT NULL比建表时更加需要先检查存量数据,不然就有上文说的报错。加外键之前,也得先查子表里有没有指向不存在父表的记录:
sql复制SELECT * FROM orders o LEFT JOIN users u ON o.user_id = u.id WHERE u.id IS NULL;
有结果就先处理脏数据,否则外键加不上去,MySQL会报1215 Cannot add foreign key constraint。这个错误信息很笼统,后面排查部分专门讲。
3.3 查看约束,别靠记忆
表结构改过几轮之后,谁还记得表上有哪些约束?我有三招查约束,推荐记下来。
第一招,看建表语句:
sql复制SHOW CREATE TABLE users\G
这个输出最完整,主键、唯一键、外键、CHECK都会带上,也是我线上排查的第一选择。第二招,查元数据表:
sql复制SELECT TABLE_NAME, CONSTRAINT_NAME, CONSTRAINT_TYPE
FROM information_schema.TABLE_CONSTRAINTS
WHERE TABLE_SCHEMA = 'your_db' AND TABLE_NAME = 'users';
第三招,查外键引用关系:
sql复制SELECT * FROM information_schema.REFERENTIAL_CONSTRAINTS
WHERE CONSTRAINT_SCHEMA = 'your_db' AND TABLE_NAME = 'orders';
information_schema这张系统表能查到约束的类型、被引用的表、ON DELETE和ON UPDATE行为,排查外键问题非常有用。命名规范方面,我这边统一用:主键名PK开头,唯一键UNIQUE索引名用uk_字段名,外键名用fk_表名_字段名,CHECK用chk_字段名。约束名在MySQL里是必填项吗?不一定,外键不写名字MySQL会自动生成一个,结果就是后续DROP FOREIGN KEY时不知道名字,还得先去information_schema里翻。所以建约束顺手起名,成本几乎为零,收益是把坑提前填平。
4. 踩坑实录:约束使用中的高频问题与排查
4.1 四个高频错误码,对照着修
我把日常运维里出现频率最高的几个错误码整理成了一张速查表:
| 错误码 | 含义 | 常见场景 | 处理方向 |
|---|---|---|---|
| 1062 | Duplicate entry | 违反UNIQUE或PRIMARY KEY | 查重复数据来源,确认业务是否允许,清洗或换个方案 |
| 1048 | Column cannot be null | 违反NOT NULL | 检查INSERT字段是否传全,存量数据是否含NULL |
| 1452 | Cannot add or update a child row | 违反FOREIGN KEY | 确认父表记录存在,或检查外键列类型是否一致 |
| 3819 | Check constraint violated | 违反CHECK约束 | 按业务规则修正数据,或调整CHECK表达式 |
| 1215 | Cannot add foreign key constraint | 加外键失败 | 类型不匹配、索引缺失、引擎不一致、字符集不一致 |
每次看到1452,我第一反应不是“数据错了”,而是先去怀疑两边表的字段类型。举个真实例子:用户表id是INT UNSIGNED,订单表user_id是INT,没加UNSIGNED。看起来都是整数,但范围不一样:INT UNSIGNED最大42亿,INT最大21亿,MySQL认为这俩不是同一类,外键建不上,报1215。这类问题排查时最容易忽视,因为单看类型都是INT。
3819这个错误码只有8.0.16及以上才可能出现,如果你在5.7上插入违反CHECK的数据没报错,别慌,不是数据问题,是版本根本不执行。遇到这种情况要么升级版本,要么把校验逻辑挪到应用层,别指望数据库帮你拦。
4.2 外键约束的几个隐蔽坑
外键看着简单,实际用起来坑特别多。第一个就是字符集和排序规则不一致。父表列是utf8mb4_general_ci,子表列是utf8mb4_unicode_ci,字符集相同,排序规则不同,外键创建也会失败。建库的时候最好统一字符集,别让每张表自己乱设置。
第二个坑是父表列的索引类型。外键引用的父表列必须是索引,通常是主键或唯一键,但如果父表列是一个普通索引的左前缀,MySQL有时也能认。这个行为比较隐晦,我的建议是外键引用一律指向主键,别玩花活。指向非唯一索引的话,语义上就容易出现问题,比如父表出现了多条相同值的记录,外键到底引用哪一条?
第三个坑是DELETE和UPDATE的级联行为。ON DELETE CASCADE用起来很爽,删父表自动删子表,但线上事故也多半出在这里。我一个朋友删了一个用户,连带订单、支付记录、操作日志全没了,找数据找了三天。在订单、流水这类需要留痕的表上,我强烈建议外键用RESTRICT,删除时让数据库拒绝,程序里再做逻辑删除(加一个deleted标记位),而不是物理删除。这条建议值多少,你丢一次数据就清楚了。
第四个坑是外键带来的性能问题。每次子表插入和更新都要去父表校验一次,在高并发写入场景下,这个开销和隐式锁可能成为瓶颈。所以互联网大厂普遍“不用外键”,更常见的是把引用关系留给应用层去保证。如果你是做企业内部系统、业务系统,数据一致性比极端性能更重要,我建议保留外键;如果是高并发互联网产品,可以考虑去掉外键但保留索引,在应用层做完整性校验。
4.3 约束设计上的几个建议
最后分享几个我这些年总结出来的约束设计原则,不算高深,但每一条都是从事故里换来的。
第一,每张表都强制主键,即使是临时表、日志表,也建一个自增id。没有主键的InnoDB表是堆表,数据更新删除都可能出现意想不到的行为,后续要加同步工具(比如binlog解析)更是寸步难行。
第二,能用数据库约束表达的规则,不要留给应用层。应用层校验是必要的,但只靠应用层就等于把数据库的防线拆了。任何写入入口都可能漏校验,比如后台脚本、手工修数、数据迁移工具,它们不一定走应用层逻辑,但一定走数据库。
第三,约束别堆太多,够用就好。我见过一张表上七八个CHECK,业务一改,约束和代码逻辑就对不上了,改约束又是DDL锁表。合理的约束是:主键、必填字段的NOT NULL、有明确枚举范围的状态字段加CHECK、真正需要唯一业务键的字段加UNIQUE、有关联关系且一致性要求高的表加外键。其余的校验,交给应用层更灵活。
第四,命名规范和类型统一是长期项目的地基。自己项目里约束名乱起,半年后再看表结构,满屏uk1、fk2、key3,谁都分不清哪个是哪个。类型统一则是为了外键能顺利创建,INT就全INT UNSIGNED,字符串就统一utf8mb4,这些一致性在一开始花半小时定好,能省后面无数个小时。
我在实际项目里踩过最深的坑,就是一张日志表一开始没建主键,后来要做数据同步,发现binlog解析需要主键定位行,硬是花了一个大版本的时间去重构。从那以后我建表的第一行永远是主键,这句话说了无数遍,也打算一直说下去。约束这个东西,设计期多花十分钟,运行期就能少熬十个晚上。
