接手过几个历史遗留的MySQL业务库之后,我发现一个特别普遍的规律:凡是数据脏得没法看的表,十有八九在建表的时候就没好好用约束。手机号能存出三种格式,订单金额出现负数,状态字段躺着一个谁都没见过的取值——这些问题的根子,都在建表那一刻就埋下了。MySQL里的约束,就是数据库自带的那道质检线,它能在写入数据之前先做一轮“合规检查”,不合格的数据根本进不了表。
这篇文章我想把MySQL约束这件事从头到尾捋一遍。不管你是刚接触数据库的新人,还是已经写了好几年SQL但没系统看过约束的老手,读完应该都能对六类约束的用法、适用场景、坑点和底层逻辑有一个完整的把握。内容里所有的语句我都尽量用真实的业务场景来演示,不是那种“为了讲约束而造出来的玩具表”,你完全可以拿过去改改就用。
1. 约束到底在解决什么痛点
1.1 没有约束的数据表会变成什么样
先说一个我实际遇到过的场景。某套系统上线跑了一年多,用户表里有一个字段专门存手机号,类型是VARCHAR(20),建表时没加任何约束。结果库里的数据长什么样呢?有人存了11位纯数字,有人加了区号,有人中间带了空格,还有几个人干脆拿“无”字来占位。业务方每次做用户触达之前,都得写一堆正则表达式去清洗数据,清洗完还有漏网之鱼。
这还只是字段格式层面的混乱。更严重的是数据之间的“关系”找不到参照。订单表里的user_id填了一个根本不存在的用户编号,商品表里某个分类被删掉之后,关联的分类商品却还挂在那个废弃分类下面。这些情况在没有外键约束的表里太常见了。数据库本身不拦你,应用层如果没写校验,脏数据就这么大摇大摆地进了库。
约束的本质,是把本该由业务代码反复检查的那些规则,下沉到数据库引擎层面强制执行。它不关心你为什么写入一条负数金额,只要规则不允许,这条数据就会被直接拒绝。这个特性意味着:哪怕将来换了两个菜鸟开发者维护这个库,哪怕应用层漏掉了某些校验分支,数据的底线依然有人兜底。
1.2 约束的核心分类与作用总览
MySQL里的约束可以按照数据完整性这个大的框架来理解。四大完整性类型——实体完整性、域完整性、引用完整性、用户自定义完整性——落实到具体语法上,就是六类约束。
| 约束类型 | 关键语法 | 解决什么问题 |
|---|---|---|
| 非空约束 | NOT NULL | 字段不允许存入NULL,保证域完整性 |
| 唯一约束 | UNIQUE | 字段值不允许重复,可空但只能有一个NULL |
| 主键约束 | PRIMARY KEY | 唯一标识一行记录,等价于非空+唯一 |
| 默认值约束 | DEFAULT | 插入时不显式传值则使用预设值 |
| 外键约束 | FOREIGN KEY | 关联另一张表的有效记录,保证引用完整性 |
| 检查约束 | CHECK | 自定义取值范围或逻辑条件,保证域完整性 |
这里面最常被忽略的是CHECK约束。MySQL 8.0.16之前的版本虽然能解析CHECK关键字,但不会真正执行,很多老教程甚至直接告诉你“MySQL不支持CHECK”,导致很多开发者的习惯是彻底不用它。其实从8.0.16开始,CHECK约束已经正常生效,而且它的表达力比NOT NULL和DEFAULT强很多,后面我会专门展开讲。
1.3 约束与索引的关系:容易被忽略的底层联系
约束和索引在MySQL里并不是两个完全独立的概念,这一点很容易让人懵。PRIMARY KEY会自动创建一个聚集索引,UNIQUE约束会自动创建一个唯一索引,这两个约束天生自带索引属性。外键约束则正好相反:如果子表外键列上还没有索引,MySQL在建立外键的时候会自动创建一个,但这个自动创建的行为并不总是很理想,索引名往往不直观,后续维护时看着莫名其妙。
理解这个关系很重要,因为很多人学习约束的时候,只记了“约束是规则”,没有意识到“规则底下还有索引这层结构”。比如你想删除一个建立在唯一约束上的索引,直接用DROP INDEX是做不到的,必须先DROP掉这个约束本身;反过来,如果你建表的时候忘记给外键列手动建索引,查询里的JOIN性能通常会比预期差很多。这些坑在实操里非常常见,后面第5部分我会专门讲排查。
2. 建表阶段把约束一次配齐
2.1 一张业务表上的完整约束示例
建表时加约束,是成本最低、效果最好的方式。我建议不要先建一张空表再去修修补补,除非你表里确实还没有数据。下面这个例子是我在一个模拟电商项目里常用的用户表结构,它几乎覆盖了除了外键之外的所有约束类型。
CREATE TABLE users ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '用户ID', phone VARCHAR(20) NOT NULL COMMENT '手机号', email VARCHAR(100) NOT NULL COMMENT '邮箱', nickname VARCHAR(30) NOT NULL DEFAULT '新用户' COMMENT '昵称', status TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0-正常 1-禁用 2-注销', register_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '注册时间', is_deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', PRIMARY KEY (id), CONSTRAINT uk_users_phone UNIQUE (phone), CONSTRAINT uk_users_email UNIQUE (email), CONSTRAINT ck_users_status CHECK (status IN (0, 1, 2)), CONSTRAINT ck_users_is_deleted CHECK (is_deleted IN (0, 1)) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';这个表设计里有几个点值得说。id是主键且自增,这个不用多解释;phone和email都加了唯一约束,因为这两样业务上天然具备唯一性;status字段用CHECK约束把取值范围锁死在0、1、2三个值上;is_deleted同理,只允许0和1存在。这样设计之后,哪怕应用端有一段校验逻辑写错了,把status传成了5,MySQL这一层也会直接把数据弹回去。
2.2 主键、唯一、非空与默认值的选型逻辑
主键的选择,我个人的经验是以“单字段自增整数”为默认方案,不要去秀操作搞什么复合主键或者UUID主键,除非有十足的业务理由。自增主键的物理存储是紧凑有序的,B+树插入时基本是顺序写,性能稳定。唯一约束则要区分“业务主键”和“逻辑主键”这两个概念:id是逻辑主键,phone是业务主键,它们不冲突,可以同时存在。
NOT NULL和DEFAULT往往是搭配使用的。一个常见的误区是“不确定会不会填的就干脆允许为空”,结果NULL在查询里引发一堆NULL值比较的坑,COUNT(字段)也统计不到,WHERE条件容易漏数据。更合理的做法是:想清楚这个字段如果没有值应该代表什么语义,然后用DEFAULT给一个合理兜底。比如nickname默认给“新用户”,status默认给0,这样插入语句可以少传几个参数,业务代码也更好维护。
DEFAULT还可以配合表达式,MySQL 8.0支持带括号的函数表达式默认值。假如某张表需要一个“创建时间”字段,你可以写成create_time DATETIME DEFAULT (NOW()),相比老版本里常用的DEFAULT CURRENT_TIMESTAMP,这种写法更统一、更灵活。
2.3 联合约束:一对多关系的数据骨架
单字段约束解决单字段的问题,但业务里还有一种很常见的需求:记录之间的关系唯一。单独看某一列,重复是合法的,两列凑在一起却不能再重复。这就是联合约束的用武之地。
最典型的是联合主键。模拟项目里的订单明细表就是个好例子:一个订单可以有多件商品,一件商品也能出现在多个订单里,所以明细表的主键不能是order_id,也不能是goods_id,而应该是(order_id, goods_id)联合主键。
CREATE TABLE order_details ( order_id BIGINT UNSIGNED NOT NULL, goods_id BIGINT UNSIGNED NOT NULL, quantity INT NOT NULL DEFAULT 1, price DECIMAL(10, 2) NOT NULL, PRIMARY KEY (order_id, goods_id) );联合唯一约束同理。比如一张用户收藏商品表,要保证“同一个用户不能重复收藏同一个商品”,用(user_id, goods_id)建联合唯一约束再合适不过。这个约束的语义比在应用层加Redis锁、查重判断要可靠得多,因为数据库引擎天然保证并发安全性,两台机器同时提交重复数据也只有一条能活下来。
3. 用 ALTER TABLE 给老表补约束
3.1 添加约束的完整写法
建表时漏了约束也没关系,只要表里还没有脏数据,随时都能用ALTER TABLE补上。六类约束添加方式我整理如下。
-- 添加非空约束 ALTER TABLE users MODIFY COLUMN email VARCHAR(100) NOT NULL; -- 添加唯一约束(可指定约束名) ALTER TABLE users ADD CONSTRAINT uk_users_nickname UNIQUE (nickname); -- 添加主键约束 ALTER TABLE users ADD PRIMARY KEY (id); -- 添加默认值 ALTER TABLE users ALTER COLUMN status SET DEFAULT 0; -- 添加检查约束 ALTER TABLE users ADD CONSTRAINT ck_users_age CHECK (age >= 18 AND age <= 65); -- 添加外键约束 ALTER TABLE orders ADD CONSTRAINT fk_orders_user_id FOREIGN KEY (user_id) REFERENCES users(id);这里有个细节必须提醒:如果目标列上已经存在重复数据,UNIQUE和PRIMARY KEY会直接报错,NOT NULL也一样,只要存在NULL值就加不上。所以给老表补约束之前,第一步永远是清洗存量数据,而不是直接执行SQL。
3.2 删除约束的操作与常见坑
删除约束的操作相对容易,但坑也不少。主键、唯一、外键、检查这四类约束,它们的删除方式各不相同,写错关键字就会得到一篇报错。
-- 删除主键约束 ALTER TABLE users DROP PRIMARY KEY; -- 删除唯一约束(通过索引名删除) ALTER TABLE users DROP INDEX uk_users_phone; -- 删除外键约束(通过约束名删除) ALTER TABLE orders DROP FOREIGN KEY fk_orders_user_id; -- 删除默认值约束 ALTER TABLE users ALTER COLUMN status DROP DEFAULT; -- 删除检查约束 ALTER TABLE users DROP CHECK ck_users_status;有一个很经典的坑:如果表里存在AUTO_INCREMENT列,直接执行ALTER TABLE ... DROP PRIMARY KEY会报错,因为自增列依赖主键索引,必须先修改列属性去掉AUTO_INCREMENT,操作完再重新加主键、重新恢复自增。这个流程比较繁琐,所以在生产环境删除主键之前,一定要先在测试环境完整演练一遍。
3.3 给存量脏数据补约束的实操流程
我自己的习惯是三步走。第一步,备份整张表,这一步无论如何都不能省;第二步,写查询找出不符合目标的脏数据,比如NULL值、重复值、非法枚举值,根据业务规则决定是修复、删除还是归档;第三步,数据清洗干净后再执行ALTER TABLE加约束。
举个小例子。某张老表里的mobile字段有NULL值,要补NOT NULL约束。先跑一条UPDATE把NULL替换成有效默认值,再执行MODIFY COLUMN。如果这个字段同时还要加唯一约束,那就得先查重复值,把重复的合并或清理掉。整个过程看起来琐碎,但这是唯一稳妥的路径。直接硬加约束,报错是小事,更怕的是在线上库触发大表锁查询,把业务拖死。
4. 外键约束:理解引用完整性的钥匙
4.1 外键的底层语义与父子表关系
外键约束解决的是一类特殊问题:表与表之间的引用关系不能断裂。它要求“子表”里插入的数据,必须在“父表”中存在对应的记录。
CREATE TABLE orders ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL, order_no VARCHAR(32) NOT NULL, amount DECIMAL(10, 2) NOT NULL, PRIMARY KEY (id), CONSTRAINT uk_orders_order_no UNIQUE (order_no), CONSTRAINT fk_orders_user_id FOREIGN KEY (user_id) REFERENCES users(id) );在这个表结构里,orders就是子表,users是父表。往orders里插入一条user_id=9999的记录时,如果users表里根本没有9999这个用户,插入就会被拒绝。这是不是意味着你不需要在应用层校验用户是否存在了?严格来说,应用层依然可以保留这段校验逻辑,但外键约束的意义在于:它把这道防线变成了数据库的底线,即使应用层漏掉了校验,数据库也会出手拦住。
4.2 级联策略的选择:CASCADE、SET NULL、RESTRICT
外键约束真正让人纠结的部分,是当父表记录被更新或删除时,子表该怎么办。MySQL提供了四种策略,默认是RESTRICT。
| 策略 | 行为 | 适用场景 |
|---|---|---|
| RESTRICT | 父表有子表引用时禁止删除/更新 | 默认选项,最安全 |
| NO ACTION | 与RESTRICT行为相同 | 某些标准SQL兼容场景 |
| CASCADE | 父表删除/更新时,子表同步删除/更新 | 父子数据生命周期完全一致 |
| SET NULL | 父表删除/更新时,子表外键字段置为NULL | 外键列允许NULL,保留子表数据 |
我给一个常见的设计建议:涉及“主数据”和“明细流水”的关系,比如订单和订单明细,用RESTRICT,订单存在时不允许删用户,这是为了保护交易记录;涉及“可选关联”的关系,比如用户和优惠券领取记录,用户注销后希望保留优惠券历史但不再关联,可以设置ON DELETE SET NULL;只有当子表数据本身没有独立保留价值时,比如垃圾过滤规则和对应的日志明细,才用CASCADE。
ON UPDATE CASCADE则要谨慎用于主键场景。如果父表主键设计合理且永远不应该被修改,就不要加ON UPDATE的级联规则了,留着默认的RESTRICT反而能防止有人误改主键。
4.3 外键的局限:哪些场景不要用
外键不是银弹,有些场景下它反而是包袱。第一个典型场景就是对性能极端敏感的大流量核心表。外键在每次写入时会额外触发一次对父表的检查,写入并发一大,这种额外的索引查找就变成不可忽视的开销。很多一线互联网团队在核心交易链路直接放弃数据库外键,把引用完整性完全交给应用层事务和补偿机制。
第二个场景是分库分表。外键约束本身无法跨库工作,你做了分库之后,父表和子表可能落到不同的物理库上,外键约束自然就失效了。第三种场景是数据迁移和归档阶段。外键的存在会强制多张表的操作必须按特定顺序执行,删数据也可能被级联规则牵连,这些都让运维操作变得被动。
所以我会把外键当成一种“默认推荐但允许定制关闭”的能力。中小型系统、强一致要求较高的业务,放心用;超大规模、高强度写入、水平拆分明显的场景,再考虑用应用层逻辑替代。
5. 约束相关的常见报错排查实录
5.1 高频报错速查表
实际运维中,约束相关的报错来回就那么几个。我把常见情况整理成一张速查表,遇到问题直接对着查。
| 报错特征 | 错误码参考 | 常见原因 | 快速解法 |
|---|---|---|---|
| Duplicate entry 'xxx' for key | 1062 | 违反唯一约束或主键约束 | 查询重复数据并清理 |
| Column 'xxx' cannot be null | 1048 | 违反NOT NULL约束 | 补齐值或修改DEFAULT |
| Foreign key constraint fails | 1452 | 子表引用了父表不存在的记录 | 检查父表数据或清理子表孤儿数据 |
| Cannot delete or update a parent row | 1451 | 父表记录被子表引用,受RESTRICT限制 | 先处理子表引用,或考虑级联策略 |
| Cannot add foreign key constraint | 1215 | 字段类型/字符集不一致或父表列无索引 | 核对列属性、引擎、索引 |
| Check constraint 'xxx' is violated | 3819 | 写入值违反CHECK约束 | 修正业务数据或调整CHECK条件 |
5.2 从报错到定位的排查思路
遇到外键相关报错,我的排查顺序一般是这样的:先看完整的错误信息,MySQL的报错会直接给出约束名字,比如CONSTRAINT fk_orders_user_id FOREIGN KEY (user_id) REFERENCES users(id),这已经告诉你具体是哪个约束、哪两个字段出的问题。然后看父表那条记录是否存在、状态是否正常。接着看子表的外键字段有没有NULL值散布,很多时候报错不是误写主键,而是子表里躺着一批历史遗留的孤儿数据。
一个非常好用的排查语句是SHOW CREATE TABLE 表名\G,它能一次性展示当前表上所有约束的完整定义。排查外键关系时,我还会用information_schema的视图来捞全局信息:
SELECT CONSTRAINT_NAME, TABLE_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = 'test_db' AND REFERENCED_TABLE_NAME IS NOT NULL;这种方法在维护一个数据库里几十张表的时候特别省力,不用一张表一张表地翻建表语句。
5.3 两个容易被忽略的细节
第一个细节是字符集与排序规则不一致导致外键建不起来。父表字段是utf8mb4,子表字段是utf8mb3,或者排序规则一个是utf8mb4_unicode_ci,另一个是utf8mb4_0900_ai_ci,MySQL会直接抛出1215错误。解决方案是把两边的列类型、字符集、排序规则统一修改成一致。
第二个细节是外键字段的类型边界不一致。父表用BIGINT UNSIGNED,子表用BIGINT(有符号),看起来只差了一个UNSIGNED,实际上取值范围差了将近一倍。如果父表主键自增到了极大值,子表插入时就会发生越界异常,这种问题排查起来非常隐蔽。所以建立外键关系时,两边的数据类型定义要完全对齐,大小写、长度、符号属性一个都不能漏掉。
6. 约束命名规范与设计心得
6.1 一套实用的约束命名规则
约束名看起来无足轻重,但维护老库的时候恨不恨当初那个人,全看这里。默认情况下,如果不显式给约束起名,MySQL会自动生成一个名字,唯一索引很可能叫phone_2这种,外键可能是表名+字段名的组合,混乱程度全看脸。我建议在项目里统一约束命名前缀,形成惯例:
| 约束类型 | 前缀 | 示例 |
|---|---|---|
| 主键 | pk_ | pk_users_id |
| 唯一 | uk_ | uk_users_phone |
| 外键 | fk_ | fk_orders_user_id |
| 检查 | ck_ | ck_users_status |
| 默认值 | df_ | df_users_status |
命名后缀统一走“表名_字段名”的格式,一张表上多个同字段约束再加序号区分。这样做的好处很明显:看到约束名就知道它的类型、所属表、关联字段,排查问题和写删除语句时完全不需要再去查建表语句。
6.2 约束宁严勿松的项目复盘体会
我在实际项目里踩过最大的一个坑,是过度依赖应用层校验而放弃数据库约束。当初为了代码里少两句报错处理,签字表甚至没有CHECK约束,状态字段靠枚举类去约束,结果后来某个分支改代码时枚举判断漏了一个条件,库里直接塞进了脏数据。从那以后,凡是能落到数据库层面的校验,我一定会用约束兜底。
但也不是说约束越多越好。约束太碎太严,同样会带来问题:线上业务需求稍微一变,就要执行DDL改约束,而MySQL的DDL本身有锁表风险;每加一个CHECK,写入路径就多一道判断。我的倾向是,把“业务绝对不允许出现”的硬边界用约束守住,把那些“允许存在但需要告警”的软规则留在应用层。
最后分享一个我自己的操作习惯:所有跟约束相关的DDL,都不会直接在线上库执行,而是先在测试环境跑一遍,用EXPLAIN和SHOW CREATE TABLE检查影响,再挑业务低峰期执行。建表容易,改表难,这句话在约束这个主题上体现得淋漓尽致。