约束(Constraint)这个概念,很多刚接触 MySQL 的开发者都不太当回事儿。建表的时候不写约束,数据想怎么塞就怎么塞,等到线上数据脏了、报表对不上了、订单金额变成负数了、用户重复注册了,才开始回头补课。我自己最早也是这么过来的,做某电商系统的订单表那会儿连外键都没建,后来对账发现一堆孤儿订单,一查全是当年偷懒埋的雷。所以这篇东西,我想把 MySQL 约束这件事从头到尾讲透,从六大约束类型到建表实操,再到线上改表踩过的坑,一次说清楚。不管你是刚入门的学生,还是写了几年 SQL 想系统梳理一下的开发者,这篇都适合你跟着过一遍。
1. 约束的整体设计思路:先想清楚数据的“底线”
1.1 约束到底是什么
约束说白了就是数据库层面的数据校验规则,是 MySQL 强制要求写入的数据必须满足的条件。很多人在应用层做了校验就觉得万事大吉,但应用层代码是人写的,会改、会漏、会出 bug,数据库约束才是最后一道谁也没法绕过的防线。我习惯把约束比作交通规则——车可以随便开,但红灯必须停、实线不能变道,违反规则的直接处罚,这样路上才不会乱套。
MySQL 的约束体系围绕四种完整性展开:
- 实体完整性:保证每行数据能被唯一区分,对应主键约束、唯一约束;
- 域完整性:保证字段的取值在合法范围内,对应非空约束、检查约束、默认值约束;
- 引用完整性:保证表与表之间的关联关系不失效,对应外键约束;
- 用户自定义完整性:业务上额外的规则,通常用检查约束或触发器实现。
理解这四类完整性,比死记硬背约束语法有用得多。因为你在设计表结构时,本质上就是在回答四个问题:这行数据怎么区分?这个字段能填什么?这张表和别的表什么关系?业务上还有什么特殊要求?
1.2 为什么建表时必须先想清楚约束
约束设计最理想的时间点,永远是建表的那一刻。原因很简单,约束一旦要回头补,代价是成倍增长的。在一张几千万行的大表上执行 ALTER TABLE 加约束,MySQL 可能要重建整张表,期间产生的锁和 IO 压力能把线上业务拖垮。我实测过一张 8000 万行的订单表,往上面加一个唯一约束,跑了将近 20 分钟,期间该表的写操作全部被阻塞,大促期间这种操作想都不敢想。
另一个容易被忽略的点是,约束本身就是在给后续的开发者写文档。一个字段是 NOT NULL 还是允许 NULL,直接反映了业务上这个属性是不是必填的;外键关系直接告诉后来的人这两张表的关联逻辑。有经验的 DBA 拿到一份建表脚本,不看业务文档,光看约束就能猜出七八分业务逻辑。所以约束设计得清楚,后面接手的同事能少踩一半坑。
1.3 约束的分类:列级约束与表级约束
约束按定义位置可以分成列级约束和表级约束。列级约束直接写在字段定义后面,只作用于这一个字段;表级约束独立写在所有字段定义完之后,可以作用于多个字段的组合。
一个很典型的例子是联合唯一约束。比如订单明细表里,同一个订单号下不允许出现重复的商品 ID,这个约束必须同时作用在 order_id 和 product_id 两个字段上,列级约束就做不到,只能用表级约束写成 UNIQUE KEYuk_order_product(order_id,product_id)。
从 MySQL 实际存储的角度看,约束最终都会落到索引、外键元数据或者字段属性上。主键和唯一约束会创建对应的索引,外键会自动在从表上建索引,理解这一点对后面排查性能问题很有帮助。
2. 六大约束类型逐个拆解
2.1 NOT NULL:最容易被忽略的底线
NOT NULL 约束要求字段不允许存储 NULL 值。这个约束看起来简单,实际用起来坑不少。最大的坑是很多人分不清 NULL 和空字符串的区别。在 MySQL 里,NULL 是“没有值”,空字符串是“一个长度为 0 的值”,两者完全不同。比如手机号字段,如果允许 NULL,那么一条记录手机号是 NULL,另一条是空字符串,业务上可能都表示“没填手机号”,但查询时WHERE phone = ''查不到 NULL 的记录,WHERE phone IS NULL又查不到空字符串的记录,一对账就是一堆脏数据。
我的建议是:业务上明确必填的字段,一律 NOT NULL;如果不确定要不要填,宁可给 DEFAULT 值也不要轻易放开 NULL。很多公司规范里干脆约定“所有字段一律 NOT NULL,用默认值兜底”,这虽然激进,但确实能省掉无数跟 NULL 纠缠的麻烦。NULL 还会影响索引效率,因为有 NULL 的列在索引中需要额外标记,查询优化器处理起来也更费劲。
2.2 UNIQUE:唯一性背后的索引代价
UNIQUE 约束保证字段或字段组合的值不重复,它在实现上会自动创建唯一索引。这里要特别注意:唯一约束对 NULL 是网开一面的,一个字段上可以有多个 NULL 值,因为 MySQL 认为“NULL 不等于任何值,包括它自己”。所以如果你用 UNIQUE 约束一个允许 NULL 的字段,并不能真正阻止“重复的未填值”出现。标准做法是把这个字段改成 NOT NULL,或者用一个生成列来兜底,这个后面在问题排查章节我会详细展开。
联合唯一约束是最常用的形式,比如用户标签表里 (user_id, tag_id) 联合唯一,从根本上杜绝了重复打标。这里有个设计上的取舍:联合唯一约束建好之后,查询时如果只按联合索引的第一字段查,是可以走索引的;但如果跳过了第一个字段直接查第二个字段,索引大概率用不上。所以联合唯一约束的字段顺序,要尽量跟业务查询的频率对齐,高频过滤字段放前面。
2.3 PRIMARY KEY:主键选择的艺术
主键约束是唯一约束的加强版,一个表只能有一个主键,它同时具备 NOT NULL 和 UNIQUE 的特点。主键在 InnoDB 里还有特殊地位,它是聚簇索引的入口,表数据最终是按照主键顺序物理存储的。
主键选型上我吃过不少亏,分享几个结论。自增整数主键是最省心的选择,写入顺序跟主键顺序一致,页分裂最少,性能最稳定。业务主键比如用订单号字符串做主键,虽然业务上直观,但字符串随机性大,插入时会频繁触发页分裂,而且聚簇索引占用的空间也大。UUID 主键更坑,随机插入对 InnoDB 很不友好,如果非要用 UUID,建议改成有序的版本或者用雪花算法生成的整数 ID。
主键还有一个容易犯的错误:用可变的业务字段做主键。比如用身份证号做主键,一旦业务规则调整(比如允许修改身份证号),主键修改会牵连所有引用它的表和索引,线上操作基本是灾难。所以主键尽量选跟业务无关、不会变化的代理键,业务字段的唯一性用 UNIQUE 约束去保证,两者职责分开。
2.4 FOREIGN KEY:保证引用完整性的双刃剑
外键约束用来保证两张表之间的引用完整性。比如订单表的 user_id 引用用户表的 id,有外键在,就不可能出现“订单指向一个不存在的用户”这种孤儿数据。
外键定义时可以指定引用动作,这是最容易让人迷惑的地方。常见的动作有:
- CASCADE:父表删除或更新时,子表跟着删除或更新;
- SET NULL:父表删除或更新时,子表对应字段设为 NULL(子表字段必须允许 NULL);
- RESTRICT / NO ACTION:父表有子表引用时,禁止删除或更新。
我见过很多团队一刀切禁用外键,理由是性能差、影响并发。但实际上,外键带来的额外开销主要是每次插入、更新时要检查父表,这个代价在绝大多数业务场景下是可以接受的。真正的问题在于:如果你在应用层做级联删除,代码要处理所有分支,漏掉一个就是脏数据;外键至少给你兜住了底线。我的建议是:核心业务、数据一致性要求高的场景,该用外键就用;分库分表之后外键没法用,再退而求其次靠应用层保证。另外记住,外键约束要求两张表都是 InnoDB 引擎,字段类型必须一致,被引用的字段必须是有索引的(通常就是主键)。
2.5 CHECK:8.0 才真正“复活”的检查约束
CHECK 约束用来限制字段的取值范围,比如年龄必须大于 0、性别只能取 'M' 或 'F'。MySQL 5.7 及之前版本对 CHECK 约束是“假装支持”的,语法能过,但实际不生效,任何违反 CHECK 的值都能写入,这个坑害了不少从文档学 MySQL 的新手。
MySQL 8.0.16 开始,CHECK 约束真正被强制执行了。写法上支持两种形式:列级 CHECK 直接写在字段后面,表级 CHECK 写在字段列表之后,还支持给约束命名方便管理。比如:
CREATE TABLE employee ( id INT PRIMARY KEY, age INT, salary DECIMAL(10,2), CHECK (age >= 18 AND age <= 60), CONSTRAINT chk_salary_positive CHECK (salary >= 0) );CHECK 约束的设计要克制,别把复杂业务规则全塞进约束里。比如“非 VIP 用户的折扣不能大于 0.9”这种跨字段、跨状态的规则,写进 CHECK 里一方面可读性差,另一方面以后改规则要重建表结构,很折腾。这种场景用触发器或者应用层逻辑更合适。CHECK 最适合的是那些稳定不变的边界规则,比如金额不能为负、状态只能取枚举集合里的几个值。
2.6 DEFAULT:给字段一个“保底方案”
DEFAULT 约束在字段没有显式赋值时,自动写入一个默认值。它跟 NOT NULL 搭配使用非常舒服:既保证了字段不为空,又让业务代码不用每次插入都操心这个字段。
MySQL 8.0 的 DEFAULT 支持表达式,这是个非常实用的特性。比如create_time DATETIME DEFAULT CURRENT_TIMESTAMP,插入时自动填当前时间;update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,每次记录更新时自动刷新时间。有了这个,应用层完全不用维护时间字段了。
用 DEFAULT 有几个要注意的细节:BLOB、TEXT、GEOMETRY 这些类型不能直接给默认值,JSON 类型的默认值在 8.0.13 之前也不支持。另外,如果给一个字段设置了 DEFAULT,插入时显式写入 NULL,那默认值不会生效,写入的仍然是 NULL。只有在插入语句里“完全没写这个字段”或者“显式写成 DEFAULT”时,默认值才会起作用。
3. 实操过程与核心环节实现
3.1 一个完整的建表案例
理论讲再多不如直接上一个能跑的建表脚本。我拿一个最经典的“用户-订单”场景来演示,这是我在某模拟电商项目里实际用过的简化版结构。
CREATE TABLE `user` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '用户ID', `username` VARCHAR(50) NOT NULL COMMENT '用户名', `email` VARCHAR(100) NOT NULL COMMENT '邮箱', `phone` VARCHAR(20) DEFAULT NULL COMMENT '手机号,允许为空', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-正常 0-禁用', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` 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_status` CHECK (`status` IN (0, 1)) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';CREATE TABLE `order` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '订单ID', `user_id` INT UNSIGNED NOT NULL COMMENT '下单用户ID', `order_no` VARCHAR(32) NOT NULL COMMENT '订单编号', `amount` DECIMAL(10,2) NOT NULL COMMENT '订单金额', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '订单状态:0-待支付 1-已支付 2-已发货 3-已完成 4-已取消', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_id` (`user_id`), CONSTRAINT `fk_order_user` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE, CONSTRAINT `chk_order_amount` CHECK (`amount` >= 0), CONSTRAINT `chk_order_status` CHECK (`status` IN (0, 1, 2, 3, 4)) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';这个案例里有几个设计决策可以拿出来说说。用户表强制 username 和 email 唯一,因为业务上这两者都是登录凭证,不能重复;手机号没有设成 NOT NULL,因为老用户可能没绑定手机,用 NULL 表示“未绑定”,这里我没用空字符串,就是为了语义清晰。订单表的外键用了 ON DELETE RESTRICT,意思是用户有订单时不允许删用户,这比 CASCADE 安全得多,防止手滑删除用户时把整个订单历史全灭掉。amount 加了 CHECK 保证金额不为负,status 用 CHECK 限定在合法枚举里。
3.2 表建完之后怎么改约束
线上表结构变更时,学会用 ALTER TABLE 修改约束是一项基本技能。常用操作我都整理在下面。
添加约束:
-- 添加唯一约束 ALTER TABLE `user` ADD UNIQUE KEY `uk_idcard` (`idcard`); -- 添加外键约束 ALTER TABLE `order` ADD CONSTRAINT `fk_order_user` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`); -- 添加 CHECK 约束(MySQL 8.0.16+) ALTER TABLE `user` ADD CONSTRAINT `chk_age` CHECK (`age` >= 0); -- 修改字段为 NOT NULL ALTER TABLE `user` MODIFY COLUMN `phone` VARCHAR(20) NOT NULL DEFAULT '';删除约束:
-- 删除唯一约束(通过索引名) ALTER TABLE `user` DROP INDEX `uk_idcard`; -- 删除主键约束 ALTER TABLE `user` DROP PRIMARY KEY; -- 删除外键约束 ALTER TABLE `order` DROP FOREIGN KEY `fk_order_user`; -- 删除 CHECK 约束 ALTER TABLE `user` DROP CHECK `chk_age`;这里要特别提醒:删除或添加约束之前,一定先在生产环境的从库或者测试库跑一遍,确认执行时间可以接受。而且大表上加唯一约束、加主键这类操作会重建表,建议用在线变更工具分批处理,避免长时间锁表。我见过有人凌晨两点在生产库直接 ALTER 一张上亿的表加唯一索引,结果整张表锁了半小时,业务告警直接被打爆。
3.3 查看约束定义信息
排查约束问题时,第一步永远是确认表上现在到底有哪些约束。MySQL 提供了几种查看方式,各有适用场景。
最直接的是 SHOW CREATE TABLE,能看到一张表的完整生成语句,约束一目了然:
SHOW CREATE TABLE `order`\G但 SHOW CREATE TABLE 在约束非常多的时候输出很长,看起来费劲。这时候可以查系统表 information_schema:
-- 查看某张表上的所有约束类型 SELECT CONSTRAINT_NAME, CONSTRAINT_TYPE, TABLE_NAME FROM information_schema.TABLE_CONSTRAINTS WHERE TABLE_SCHEMA = 'mydb' AND TABLE_NAME = 'order'; -- 查看外键的具体引用关系 SELECT CONSTRAINT_NAME, TABLE_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = 'mydb' AND TABLE_NAME = 'order' AND REFERENCED_TABLE_NAME IS NOT NULL;我平时排查问题,习惯先 SHOW CREATE TABLE 看全貌,再用 information_schema 精准查某个约束,两个配合基本够用。注意别在生产环境的系统表上随手 SELECT *,数据量大的实例上这样查会拖慢整个库。
4. 常见问题与排查技巧实录
4.1 外键怎么都建不上
外键约束创建失败是新手问得最多的问题,报错信息五花八门,但根因就那么几个。我把这些年遇到的坑总结成一张速查表。
| 报错关键词 | 根本原因 | 解决办法 |
|---|---|---|
| Error 1215 Cannot add foreign key | 最通用的报错 | 逐个核对下面几项 |
| 引擎不一致 | 父表或子表不是 InnoDB | 统一改成 ENGINE=InnoDB |
| 字段类型不匹配 | 如父表 INT UNSIGNED,子表 INT | 改为完全相同类型,包括 UNSIGNED |
| 字符集不一致 | 两表默认字符集不同 | 统一为 utf8mb4 |
| 被引用字段无索引 | 关联的父表字段不是索引 | 父表字段必须是主键或有索引 |
| 外键字段自身无索引 | 子表外键列没有索引 | 子表外键列建议手动加索引 |
其中“字段类型不一致”这个坑最隐蔽。比如父表主键是 INT UNSIGNED,子表外键写成 INT,MySQL 认为类型不匹配,直接报 1215。还有字符集问题,两张表的表级字符集一样,但某个列的字符集被单独改过,也会建不上。遇到 1215 别慌,按表格逐项排查,五分钟之内基本能定位。
另外一个经常被忽略的点是:外键约束要求父表和子表的字段都加上索引。引用时如果父表字段是主键,这天然满足;子表的外键字段,MySQL 在创建外键时会自动帮你建索引,但如果是手工建表,建议显式写出来,命名也更规范。
4.2 唯一约束与 NULL 的“相爱相杀”
前面提过,UNIQUE 约束允许字段存在多个 NULL 值。这个特性在业务上会造成很诡异的数据:比如用户表的身份证号字段,如果允许 NULL 又加了 UNIQUE 约束,那么可以插入 10 条身份证号都是 NULL 的记录,业务上这些“未实名用户”实际上是重复的。
如果业务要求“未填写的值只能有一条”,标准解法是把字段改成 NOT NULL DEFAULT ''。但空字符串作为默认值也有问题,比如查询WHERE idcard = ''和WHERE idcard = NULL语义不同,很容易混。MySQL 8.0 里更优雅的解法是用生成列:
ALTER TABLE `user` ADD COLUMN `idcard_unique` VARCHAR(18) GENERATED ALWAYS AS (NULLIF(`idcard`, '')) STORED, ADD UNIQUE KEY `uk_idcard_unique` (`idcard_unique`);把空字符串统一转成 NULL,再对生成列加唯一索引,NULLIF 会把空字符串变成 NULL,这样空值仍然可以有多条,但实际业务上“多个空字符串”就被统一成一个 NULL 了,配合 NOT NULL 约束就能达成严格唯一。这个技巧比较进阶,但确实是处理“唯一但可空”需求的正规军。
4.3 约束与性能的平衡怎么拿捏
约束不是越多越好。每个唯一约束都对应一个索引,写操作要维护所有索引,索引太多会拖慢插入和更新速度;外键在每次子表写操作时都要对父表加锁检查,高并发下可能成为瓶颈。
我在实际项目里的取舍标准是三个维度:数据一致性要求、写入并发量、表的数据量级。核心交易链路,比如订单、支付流水,外键和 CHECK 该上就上,宁可损失一点写入性能也要保证数据不出错;日志、埋点、中间表这类海量写入且对一致性不敏感的表,尽量减少约束,最好只保留主键和必要的索引。
CHECK 约束对性能的影响很小,因为它只在写入时做一次条件判断,成本可以忽略。真正影响性能的是主键和唯一约束对应的索引,尤其是联合索引字段顺序选错,不仅约束本身失效一部分,查询也走不上索引。我见过一张表建了 (a, b, c) 联合唯一约束,业务上却大量按 b 字段查,结果每次查询都是全表扫描,这纯粹是设计失误。
4.4 线上改约束前的三个必做动作
线上改约束,尤其是给大表加唯一约束、加主键这类操作,我总结了一套固定流程,踩过几次坑之后就成了条件反射。
第一步,先在测试环境全量复现,记录执行时间。第二步,检查从库延迟,主从延迟大的时候先等等,否则操作过程中主库长时间持有 DDL 锁,从库追不上来会拖垮读流量。第三步,准备好回滚方案,约束变更不像数据更新能简单地反向操作,一旦执行完发现问题,恢复成本极高,所以操作前把原表结构完整备份,甚至直接用可扩展的在线改表工具,失败了能立刻回切。
还有一个心得:同一次变更里尽量少混合不同类型的约束修改。比如既要加唯一约束,又要改外键,还要调字段类型,分开几次执行。混在一起,出问题后很难定位到底是哪一步导致的。
5. 最后再分享几个约束设计的小技巧
在建表工具这一环上,我强烈建议团队把约束设计沉淀成模板。比如约定所有表必须有自增主键、创建时间和更新时间必须有 DEFAULT、金额字段必须有 CHECK 非负、状态字段必须有 CHECK 枚举,把这些规则固化到建表规范里,新表直接套模板,能少掉一大半低级错误。
另外,约束和索引的命名也要形成习惯。我个人的命名规则是:主键就叫 PRIMARY,唯一索引统一uk_前缀,普通索引idx_前缀,外键fk_前缀加引用表名,CHECK 约束chk_前缀加字段名。命名规范的好处是,将来查 information_schema 看约束列表的时候,一眼就能看出每个约束是干嘛的,不用逐个去对建表语句。
最后说一个很多人不知道的小细节:MySQL 8.0 里 ALTER TABLE 支持原子性了,一条 ALTER 语句要么全部成功要么全部回滚,不会出现执行到一半留下残缺结构的情况。这是 8.0 相比 5.7 的显著改进,如果你还在 5.7 上挣扎,又特别在意表结构变更的安全性,这会是一个升级的充分理由。
约束这个东西,看起来只是建表时的几行关键字,实际却是整个数据质量体系的基石。我在实际项目中见过太多因为一张表少了一个约束,导致后续无数补数据脚本、对账任务和线上事故的情况。花十分钟把约束设计好,后面能省下几百小时填坑的时间,这笔账怎么算都划算。