
约束这词听起来像限制实际是给数据库表结构“定规矩”。我在做 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 xxxexample.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 CHECKMySQL 8.0.16 之前之后的两个世界CHECK约束用来限定字段的取值范围比如年龄必须大于0、分数必须在0到100之间。但 MySQL 对 CHECK 的支持有个重要的分水岭8.0.16 之前CHECK 约束会被解析但不会强制执行。什么叫“解析但不执行”就是你写age INT CHECK (age 0)建表不会报错但你可以插入age -5MySQL 也会乖乖接受。这是早期 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_atMySQL 会自动填当前时间。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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;第二步建商品表。商品价格必须大于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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;第三步建订单表。订单金额必须大于0用户外键关联用户表删除用户时不允许直接删RESTRICTCREATE 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;第四步建订单明细表。明细里的数量和价格不能为负同时一个订单里不能有重复商品用复合唯一键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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这套设计下来任何一张表都不会被写入“负数价格”“空用户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): Cant DROP xxx; check that column/key exists其实就是用的语法不对。还有一个经验在ALTER TABLE之前先执行SHOW CREATE TABLE table_name\G查看当前表结构和约束名。约束名如果没显式指定MySQL 会自动生成类似表名_chk_1、表名_ibfk_1这样的名字删的时候需要用到它。3.3 约束命名规范与查看方式约束名看似不起眼但在排错时特别重要。MySQL 里每个约束都有自己的名字规则如下约束类型默认命名建议命名PRIMARY KEYPRIMARYPRIMARYUNIQUE字段名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是不区分大小写的排序规则Abcexample.com和abcexample.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表结构才是真正能扛事的表结构。