约束这词听起来像限制,实际是给数据库表结构“定规矩”。我在做 MySQL 表设计时,见过太多因为约束缺失导致的脏数据问题:重复的订单号、为空的外键、超出范围的数值。数据库不是 Excel,它应该替你挡住非法数据,而不是事后用UPDATE去“擦屁股”。这篇把 MySQL 里最常见的六种约束从头捋一遍,讲清它们各自解决什么问题、怎么用、会踩哪些坑,适合刚学完增删改查、准备认真做表设计的朋友。
1. 约束到底在解决什么问题?
1.1 约束的本质:数据库的“关卡”
约束(Constraint)是 MySQL 在数据写入层面设置的规则,你可以把它想象成游乐场入口的安检闸机——只有符合要求的乘客才能上车。
如果没有约束,一张用户表可能存进age = -5、重复的邮箱、没有所属部门的员工记录。这些脏数据在写入时看起来没什么,但一到了统计报表、联表查询甚至后续迁移阶段,就会变成灾难。我在实际项目里就遇到过一张没有任何约束的历史表,里面有十几条user_id = NULL的订单记录,导致每次汇总销售额都得额外写排除逻辑。
约束不是一个可有可无的“附加题”,它是表设计的第一道防线。MySQL 支持NOT NULL、UNIQUE、PRIMARY KEY、FOREIGN KEY、CHECK、DEFAULT六种常见约束,它们分别是:字段能不能为空、值能否重复、如何唯一标识一行、如何维护表间关系、值能否超出范围、不传该字段时用什么兜底。理解了这六个问题,你基本就理解了关系型数据库的表结构设计。
1.2 约束选型的优先级:先主键,再非空,再看关系和范围
新手最容易犯的错误是“每个字段都加约束,把表堆成铁桶”。实际上,约束是需要按场景权衡的。我一般遵循这个顺序:
- 先确定主键:任何表都要有一个能被稳定识别的唯一标识,否则后续更新、删除、关联都无从谈起。
- 再处理必填字段:业务上必须存在的字段,比如订单金额、用户名,必须加
NOT NULL。 - 然后处理唯一性:业务上天然唯一的字段,如邮箱、身份证号、订单号,用
UNIQUE或唯一索引兜底。 - 最后考虑跨表关系和取值范围:需要关联父表时加外键;取值范围受限时加
CHECK。
这个顺序不是绝对的,但它能避免你在一开始就陷入“这个字段该不该加外键”的纠结。约束不是越多越好,加多了会降低写入性能、增加维护成本,加对了才是对数据质量负责。
2. 六种约束逐个拆解
2.1 NOT NULL:让字段必须“有货”
NOT NULL是最朴素也最容易被人忽略的约束。它保证字段在插入和更新时不能为空值NULL。
很多人分不清NULL和空字符串'':NULL表示“值不存在”,''表示“值为一个长度为0的字符串”。这是两个完全不同的东西。比如电话号码字段,用户没填写,应该存NULL;用户填了一个空字符串,那反而不合理。所以NOT NULL的语义是“业务上必须存在的值”,而不是“不能为空字符串”。
建表时的写法很简单:
CREATE TABLE user ( id INT PRIMARY KEY, name VARCHAR(50) NOT NULL, phone VARCHAR(20) NULL );这里phone允许为空,因为不是所有人都愿意留电话。而name不允许为空,因为一条用户记录如果没有名字,后续维护和识别都会出问题。
实操中要注意:ALTER TABLE给已有数据表加NOT NULL约束时,如果表里已经存在NULL数据,MySQL 会直接报错。你得先处理旧数据,把NULL更新成合法值,再执行修改语句。
2.2 UNIQUE:给字段加“防重锁”
UNIQUE约束保证字段或字段组合的值在整张表中不重复。它和索引是伴生的——加了UNIQUE约束,MySQL 会自动创建一个唯一索引。
常见应用场景是业务上的唯一标识,比如:
- 用户表的邮箱
- 订单表的订单编号
- 商品表的商品编码
CREATE TABLE user ( id INT PRIMARY KEY, email VARCHAR(100) UNIQUE, name VARCHAR(50) NOT NULL );这里如果试图插入两条相同email的记录,第二条会报Duplicate entry 'xxx@example.com' for key 'user.email'。
UNIQUE约束还可以组合使用,比如一个“用户收藏商品”的表,要求同一个用户不能重复收藏同一个商品:
CREATE TABLE user_favorite ( user_id INT NOT NULL, product_id INT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_product (user_id, product_id) );组合唯一索引允许单个字段出现重复,但字段组合不能重复。这里也顺带说一个容易踩的坑:UNIQUE约束对NULL是网开一面的,多个NULL不会被视为重复。也就是说,如果某字段允许为NULL,那么你可以放心地插入多条NULL记录,UNIQUE不会拦你。这在语义上说得通:既然“值不存在”,那就不存在“重复”的问题。
2.3 PRIMARY KEY:主键约束的隐藏逻辑
主键是NOT NULL和UNIQUE的结合体,它规定字段既不能为空、也不能重复,并且一张表只能有一个主键。主键有两个关键作用:
- 唯一标识一行记录,方便通过主键快速定位和更新数据。
- 作为其他表外键关联的目标,表结构设计里主键就是“身份证号”。
实际开发中,我几乎总是用自增整数或雪花ID作为主键,而不是用业务字段。因为业务字段(比如身份证号)虽然唯一,但可能会变更,一旦变更,所有关联这个字段的外键表都要跟着改。用无业务含义的id列做主键,能隔离业务变化对关联关系的影响。
主键的定义方式有两种常见的写法:
-- 方式一:列级约束 CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL ); -- 方式二:表级约束,适合复合主键 CREATE TABLE order_item ( order_id INT NOT NULL, product_id INT NOT NULL, quantity INT, PRIMARY KEY (order_id, product_id) );复合主键表示两条记录只有在所有主键字段都相同时才算重复。但它会带来一个问题:后续其他表想引用这张表时,外键也必须带上全部主键字段,维护成本较高。所以设计时优先考虑单列主键,除非场景真的需要复合唯一性。
2.4 FOREIGN KEY:外键约束的爱与痛
外键用于维护表与表之间的引用完整性。比如订单表里的user_id必须来自用户表的id,否则就成“孤儿订单”了。
CREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, amount DECIMAL(10, 2) NOT NULL, CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES user(id) );外键约束会自动检查插入、更新、删除操作是否符合引用关系。比如你想删除用户表中一个仍有订单的用户,MySQL 会默认拒绝删除,直到你先把该用户的订单处理掉。
外键还支持ON DELETE和ON UPDATE规则,常见的有:
CASCADE:级联更新或删除。SET NULL:父记录删除时,子表外键字段置为NULL。RESTRICT:拒绝操作,这也是 MySQL 默认行为。
我个人的建议是:中小型项目里外键能用则用,但它有代价。外键会在每次写操作时额外检查父表,影响写入性能;在数据迁移、批量导入时也会造成各种束缚。很多互联网大厂干脆禁用外键,把引用关系交给应用层去保证。这并不是说外键不好,而是不同场景取舍不同。如果你是学习阶段,建议亲手建一次外键、体验一下约束行为;如果是团队项目,要先和同事约定好外键策略。
另外,外键还有一个硬性前提:关联的两张表必须都是 InnoDB 引擎,而且关联字段的类型必须完全一致。比如父表是INT UNSIGNED,子表是INT,外键会创建失败。
2.5 CHECK:MySQL 8.0.16 之前之后的两个世界
CHECK约束用来限定字段的取值范围,比如年龄必须大于0、分数必须在0到100之间。但 MySQL 对 CHECK 的支持有个重要的分水岭:8.0.16 之前,CHECK 约束会被解析但不会强制执行。
什么叫“解析但不执行”?就是你写age INT CHECK (age > 0),建表不会报错,但你可以插入age = -5,MySQL 也会乖乖接受。这是早期 MySQL 的一个历史坑,很多老书和旧文章都因为这个说“MySQL 不支持 CHECK”,实际上不是不支持,是它偷懒没干活。
从 8.0.16 开始,MySQL 终于开始强制执行 CHECK 约束了。比如:
CREATE TABLE student ( id INT PRIMARY KEY, name VARCHAR(50) NOT NULL, age INT CHECK (age >= 6 AND age <= 100) );插入age = 5会直接报错:
ERROR 3819 (HY000): Check constraint 'student_chk_1' is violated.如果你用的还是 MySQL 5.7 或更早版本,请记住:别指望 CHECK 帮你挡非法数据,要么通过BEFORE INSERT触发器去校验,要么在应用层做判断。
2.6 DEFAULT:默认值不是约束?其实是“隐性约束”
严格说,DEFAULT定义的是字段的默认值,它属于列属性而不算传统意义上的约束。但在实际表设计中,它和约束经常一起出现,目的也是减少脏数据。
最常见的默认值是时间戳:
CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP );这样插入数据时不写created_at,MySQL 会自动填当前时间。ON UPDATE CURRENT_TIMESTAMP还能在记录被更新时自动刷新updated_at,省去应用层手动维护的时间字段。
注意一个细节:MySQL 的DEFAULT不支持函数表达式,只支持固定值或少数内置函数(如CURRENT_TIMESTAMP)。如果你希望默认值是UUID(),在 8.0.13 之前是不行的,之后才允许部分内置函数作为默认值。
3. 实操:建表时如何组合约束
3.1 一个用户订单系统的约束设计
纸上谈兵不如直接实战。假设我们要做一个简单的用户订单系统,包含用户表、商品表、订单表、订单明细表,看看约束怎么组合。
第一步,建用户表。用户必须有用户名,手机号唯一但允许为空(用户可能不填),创建时间有默认值:
CREATE TABLE `user` ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, username VARCHAR(50) NOT NULL, phone VARCHAR(20) UNIQUE, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;第二步,建商品表。商品价格必须大于0,库存不能为负数:
CREATE TABLE product ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, name VARCHAR(100) NOT NULL, price DECIMAL(10,2) NOT NULL CHECK (price > 0), stock INT NOT NULL DEFAULT 0 CHECK (stock >= 0), PRIMARY KEY (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;第三步,建订单表。订单金额必须大于0,用户外键关联用户表,删除用户时不允许直接删(RESTRICT):
CREATE TABLE orders ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, user_id INT UNSIGNED NOT NULL, total_amount DECIMAL(10,2) NOT NULL CHECK (total_amount > 0), status TINYINT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES user (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;第四步,建订单明细表。明细里的数量和价格不能为负,同时一个订单里不能有重复商品(用复合唯一键):
CREATE TABLE order_item ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, order_id INT UNSIGNED NOT NULL, product_id INT UNSIGNED NOT NULL, quantity INT NOT NULL CHECK (quantity > 0), price DECIMAL(10,2) NOT NULL CHECK (price >= 0), PRIMARY KEY (id), UNIQUE KEY uk_order_product (order_id, product_id), CONSTRAINT fk_order_item_order FOREIGN KEY (order_id) REFERENCES orders (id), CONSTRAINT fk_order_item_product FOREIGN KEY (product_id) REFERENCES product (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这套设计下来,任何一张表都不会被写入“负数价格”“空用户ID”“重复商品行”这类基本脏数据。你可以亲手执行一遍,再试着插入几条违规数据,感受一下约束的拦截效果。这比单纯背概念有用得多。
3.2 ALTER TABLE 动态添加和删除约束
表已经建好之后,用ALTER TABLE也可以随时增删约束。常用语法如下:
-- 添加非空约束 ALTER TABLE user MODIFY username VARCHAR(50) NOT NULL; -- 添加唯一约束 ALTER TABLE user ADD UNIQUE KEY uk_phone (phone); -- 添加主键 ALTER TABLE order_item ADD PRIMARY KEY (id); -- 添加外键 ALTER TABLE order_item ADD CONSTRAINT fk_item_order FOREIGN KEY (order_id) REFERENCES orders (id); -- 添加检查约束(MySQL 8.0.16+) ALTER TABLE product ADD CONSTRAINT chk_price CHECK (price > 0); -- 删除约束 ALTER TABLE order_item DROP FOREIGN KEY fk_item_order; ALTER TABLE order_item DROP INDEX uk_order_product;这里有个重要提醒:删除外键用的是DROP FOREIGN KEY,但删除唯一约束用的是DROP INDEX。主键删除则是ALTER TABLE table_name DROP PRIMARY KEY。很多新手把外键约束名的语法套到唯一约束上,结果报ERROR 1091 (42000): Can't DROP 'xxx'; check that column/key exists,其实就是用的语法不对。
还有一个经验:在ALTER TABLE之前,先执行SHOW CREATE TABLE table_name\G查看当前表结构和约束名。约束名如果没显式指定,MySQL 会自动生成类似表名_chk_1、表名_ibfk_1这样的名字,删的时候需要用到它。
3.3 约束命名规范与查看方式
约束名看似不起眼,但在排错时特别重要。MySQL 里每个约束都有自己的名字,规则如下:
| 约束类型 | 默认命名 | 建议命名 |
|---|---|---|
| PRIMARY KEY | PRIMARY | PRIMARY |
| UNIQUE | 字段名 | uk_表名_字段名 |
| FOREIGN KEY | 表名_ibfk_序号 | fk_子表_父表 |
| CHECK | 表名_chk_序号 | chk_表名_含义 |
比如fk_orders_user,一看就知道是订单表关联用户表的外键。在团队协作时,约束名统一规范能少很多沟通成本。
查看一张表的全部约束,最快的方法:
SHOW CREATE TABLE orders\G或者在information_schema表里查:
SELECT CONSTRAINT_NAME, CONSTRAINT_TYPE FROM information_schema.TABLE_CONSTRAINTS WHERE TABLE_SCHEMA = '你的数据库名' AND TABLE_NAME = 'orders';每次排错前先看约束定义,比瞎猜报错原因要高效得多。
4. 常见问题与排查技巧实录
4.1 约束冲突报错:Duplicate entry 和 Check constraint violated
写入数据时最常见的约束报错有两个。
第一个是Duplicate entry 'xxx' for key 'uk_xxx',说明触发了唯一约束。常见原因不是真的业务重复,而是字段字符集或排序规则不一致,导致 MySQL 认为两条本应不同的数据是重复的。比如utf8mb4_general_ci是不区分大小写的排序规则,Abc@example.com和abc@example.com会被判定为重复。解决方法是换用区分大小写的排序规则,比如utf8mb4_bin。
第二个是Check constraint 'xxx_chk_1' is violated,这是 MySQL 8.0.16 以后才会出现的报错。排错时先查看 CHECK 约束定义,再回看插入的数据,确认是值超出范围还是逻辑判断写错。注意 CHECK 表达式里不能用子查询,也不能引用其他表的字段,只能在当前行值上做判断。
4.2 外键创建失败的几类原因
外键报错通常比唯一约束更复杂。我的经验是依次排查下面四点:
- 两张表必须都是 InnoDB 引擎。
- 两个关联字段的数据类型必须一致,包括长度和
UNSIGNED属性。比如父表id INT UNSIGNED,子表user_id INT,就会报ERROR 3780 (HY000): Referencing column 'user_id' and referenced column 'id' ...。 - 关联字段必须有索引,父表的关联字段必须是主键或唯一键。
- 字符集和排序规则要一致。父表是
utf8mb4,子表是utf8,也会失败。
遇到外键失败,先用SHOW CREATE TABLE检查引擎和字符集,再比较两列定义,大多数问题都能定位。
4.3 约束与性能:别把表设计成“铁桶”
约束能保证数据质量,但不是免费午餐。UNIQUE和PRIMARY KEY会创建索引,每次写操作都要维护索引;FOREIGN KEY写操作时要检查父表;CHECK在 8.0.16+ 每次写入时都要执行表达式判断。
我见过的糟糕设计是:一张流水记录表上,给十几个字段都加了唯一约束,结果并发写入时频繁撞约束,性能一塌糊涂。约束应该放在业务上必须唯一的字段上,而不是所有你觉得“以后可能用得上”的字段。
还有一个常见坑:对含有大字段(如TEXT、VARCHAR(255))的列加唯一索引时,如果字符集是utf8mb4,索引长度可能超过 MySQL 的 768 字节限制。解决方法是给前缀加索引,比如UNIQUE KEY uk_content (content(100)),或者用HASH字段存内容指纹再做唯一约束。
4.4 约束与数据迁移的冲突
数据导入场景下,约束经常变成“拦路虎”。比如用mysqldump备份还原时,如果目标库已有部分数据,还原过程会因为主键或唯一约束冲突而中断。
我的做法是:批量导入大量数据前,先临时关闭外键检查(只在当前会话生效):
SET FOREIGN_KEY_CHECKS = 0; -- 执行导入 SET FOREIGN_KEY_CHECKS = 1;但注意,关闭约束检查不等于数据合法。导入后最好再做一次校验,确认没有产生孤儿数据。日常开发中,约束是“防君子不防小人”的规则,它减少的是人工失误,而不是替代业务逻辑校验。
我个人在实操中最大的感悟是:约束不是写代码时随便加上去的装饰,而是表设计阶段和业务方反复确认后的“硬规则”。你在建表时花十分钟想清楚哪个字段必须非空、哪个字段必须唯一,后面省下的可能是几个通宵排查脏数据的时间。设计约束时,多问问自己:这个字段如果为空会有什么后果?如果重复会出现什么风险?如果违反范围会带来什么损失?想清楚这三个问题,再动手写 SQL,表结构才是真正能扛事的表结构。