开篇:约束这东西,到底是给谁上的
如果你已经学了一段时间 MySQL,能写基本的增删改查,也搞懂了 where、join、group by 这些常规操作,那我猜你迟早会撞上一堵墙,就是"表结构到底怎么设计才不容易出脏数据"。这堵墙的正中央,写着两个字:约束。
约束(CONSTRAINT)在 MySQL 里的角色,有点像公司里的流程审批制度。没有它,谁都能往表里塞一条乱七八糟的数据,今天少个字段,明天多个重复值,后天把订单挂在了一个不存在的用户身上。等数据量上了几十万、上百万,你想靠代码一层层去兜底,那基本是给自己挖坑。我在实际项目里见过太多"业务逻辑里判断了非空、判断了唯一,但数据库层完全裸奔"的表,最后全是被脏数据教做人的。
这篇是"约束实战全攻略",定位很明确:不讲虚头巴脑的理论,直接面向真正要写建表语句、要改表结构、要排查约束报错的人。不管你是刚学完 SQL 基础想进阶的学生,还是工作中要设计表结构的开发,这篇都能让你把 MySQL 里六大约束(主键、唯一、非空、默认、外键、检查)一次吃透,并且能直接抄作业。我写的每一条语法、每一个排查思路,都是从实操里打磨出来的,看到最后你会觉得,原来建表时多写几行约束,比后补一百行异常处理代码都值。
1. 先搞清楚约束在整张表里到底扮演什么角色
1.1 约束的本质:把数据完整性这件事前置到数据库层
很多初学者容易陷入一个误区:觉得约束就是"加上去看着专业",或者"反正代码里都校验了,数据库加不加无所谓"。这种想法非常危险。数据库里的数据一旦被写入,它就会参与后续所有的查询、统计、关联,如果入口处不设卡,脏数据就像混进羊群的狼,后面你所有基于这张表的分析结果,全都站不住脚。
约束解决的,是数据完整性(Data Integrity)的问题。这个概念可以拆成四个维度:
- 实体完整性:每一行数据都得能唯一标识。对应到 MySQL 里就是主键约束。
- 域完整性:某一列的值必须满足特定的范围或格式。对应非空、默认值、检查约束。
- 引用完整性:某张表的列值必须在另一张表中真实存在。对应外键约束。
- 用户自定义完整性:根据业务需要自己定义的规则,比如库存不能为负数、年龄必须在 0 到 150 之间。
你可能会觉得这些概念听着抽象。我用一个生活化的类比解释一下:把表结构想象成小区门口的物业登记系统——主键就是每户人家的房号,绝对不能重复,否则快递都不知道送给谁;非空约束就是"访客必须登记姓名",空着不让进;默认值约束就是"没填楼栋时默认算 1 栋";检查约束就是"年龄不允许填 200 岁这种离谱数据";外键约束就是"访客拜访的对象必须是真实入住的业主,不能随便编一个房号"。
有了这层理解,你就会明白约束不是"可有可无的附加题",而是表结构设计的骨架。业务代码里的校验当然是第一道防线,但数据库层的约束是最后一道防线,两道防线都在,你晚上才睡得着觉。
1.2 约束和数据类型的分工:一个管格式,一个管规则
新人在设计表时经常把数据类型和约束混为一谈。比如有的人问:"我建一个性别列,用 char(1),是不是就不用加约束了?"这个问题的答案是:数据类型只解决"存什么格式"的问题,不解决"存什么值才合法"的问题。
举个例子,int(11)保证了这列存的肯定是整数,但它管不住你存 -999999 还是 999999;varchar(20)保证了字符串最多 20 个字符,但它管不住你存 "abc" 还是 "XYZ"。约束做的事情是在数据类型之上再做一层规则校验:非空约束规定"这个位置必须给值";默认值规定"不给值就自动填什么";检查约束规定"给的值必须在哪个范围内";外键约束规定"这个值必须在另一个表的某个范围内"。
所以,数据类型的任务是缩小合法数据的格式范围,约束的任务是掐掉业务上不合法的数据。两者是分工协作关系,谁也替代不了谁。我见过有人为了省事,把所有列都设计成varchar(255),然后全靠代码判断,这种设计在初期很爽,后期想哭都哭不出来——查询效率差、没法用数值函数、数据格式乱得跟菜市场一样。
1.3 约束的分类全景图
MySQL 里的约束主要分六类。我整理了一张总览表,建议你直接收藏,后面每一条我都会单独展开讲语法和踩坑点。
| 约束类型 | 关键字 | 作用 | 表级/列级都支持吗 |
|---|---|---|---|
| 主键约束 | PRIMARY KEY | 唯一标识一行,且不能为空 | 都支持 |
| 唯一约束 | UNIQUE | 保证某列或某组列的值不重复 | 都支持 |
| 非空约束 | NOT NULL | 不允许插入 NULL 值 | 仅列级 |
| 默认值约束 | DEFAULT | 未插入值时自动填充 | 仅列级 |
| 外键约束 | FOREIGN KEY | 保证子表列值必须在主表列中存在 | 都支持 |
| 检查约束 | CHECK | 校验列值必须满足表达式条件 | 都支持 |
这里要特别强调一点:在数据库规范里,还有一类约束叫 "主键自增",但严格来说它不算独立的约束类型,它是主键约束的一个属性选项,所以我不把它单拎出来分类。另外,UNSIGNED、ZEROFILL这些属于列属性,也不是约束,别搞混了。
2. 六大约束逐一拆解:语法、场景、底层逻辑、坑
2.1 PRIMARY KEY 主键约束:一张表只能有一个,但能有多列
主键约束是六大约束里最"强硬"的一个。一张表最多只能有一个主键,它的核心特征是两个:值必须唯一,且不能为 NULL。MySQL 的 InnoDB 存储引擎里,主键还直接决定了数据的物理存储顺序(聚簇索引),所以主键选得好不好,直接影响整张表的查询效率。
建表时最常规的写法是:
CREATE TABLE student ( id INT PRIMARY KEY AUTO_INCREMENT, student_no VARCHAR(20) NOT NULL, name VARCHAR(50) NOT NULL );这里id列既有主键约束又有自增属性。自增(AUTO_INCREMENT)让插入数据时不用手动给 id,MySQL 会自动生成 1、2、3 这样的递增序列。注意,自增属性必须加在"被索引的列上",最典型的就是主键列。如果一张表没有主键但有自增列,InnoDB 会隐式地把它当作聚簇索引来用,这种情况最好避免,因为说明你根本没想清楚谁是这行数据的"身份标识"。
主键还有一种高级用法——复合主键。比如选课关系表,同一门课一个学生只能选一次,那成绩表的逻辑主键是 (student_id, course_id) 两个列的组合:
CREATE TABLE course_selection ( student_id INT NOT NULL, course_id INT NOT NULL, score DECIMAL(5,2), PRIMARY KEY (student_id, course_id) );复合主键强调的是"组合后唯一"。在业务上,这种设计能直接从数据库层挡住"同一个学生重复选同一门课"的脏数据。但复合主键也有个代价:它底层会生成一个跨多列的联合索引,查询时如果只带上其中一部分列作为条件,往往无法充分利用索引。所以选复合主键时要想清楚业务真的需要"多列组合唯一",而不是单纯想省一张自增主键表。
关于主键,我必须提一个非常有争议的话题:到底该用自增整数主键还是业务字段主键(比如身份证号、学号)?我的个人经验是:优先用自增整数主键,也就是常说的"代理主键"。业务字段作为主键的风险在于,业务是会变的。身份证号虽然当前唯一,但它涉及隐私、格式调整、极端情况下还可能变更;而自增整数完全跟业务解耦,稳定、占空间小、索引效率高。业务字段的唯一性,交给唯一约束去管,不要让一张表的物理存储顺序绑在一个随时可能变化的业务属性上。这是我处理过很多项目之后最深的体会之一。
2.2 UNIQUE 唯一约束:允许 NULL 但 NULL 之间彼此不算重复
唯一约束保证一列(或一组列)的值在表里不重复。在主键之外,业务上最常见的唯一约束就是手机号、邮箱、身份证号这类字段——它们虽然不能当主键(因为只是"业务上唯一"而不是"物理标识"),但一定不允许重复注册。
单列唯一约束很直接:
CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, phone VARCHAR(20) UNIQUE, email VARCHAR(100) );多列联合唯一则用于"组合不重复"的场景。比如一个点赞表,同一用户对同一篇文章只能点一次赞:
CREATE TABLE article_like ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, article_id INT NOT NULL, like_time DATETIME, UNIQUE KEY uk_user_article (user_id, article_id) );这里UNIQUE KEY uk_user_article (user_id, article_id)表示 (user_id, article_id) 二元组在表中只能出现一次。
唯一约束最容易被坑的点,是关于 NULL 的处理。MySQL 对唯一约束有一个默认行为:唯一约束的列允许有多个 NULL 值。也就是说,phone列加了唯一约束之后,你可以插入 10 条 phone 为 NULL 的记录,MySQL 不会报错。这在业务上是合理的,因为 NULL 表示"未知",两个"未知"本来就没法确认是否相等。但如果你在业务上要求"未填手机号的只能有一条记录",唯一的办法是别让该列允许为空(加 NOT NULL),否则你需要在应用层做额外判断。
唯一约束还有一个隐藏的"副产品":它一定会自动创建一个唯一索引。所以唯一约束不仅校验重复,还能加速对该列的查询。如果在WHERE条件里经常用到某个业务唯一字段,给它加唯一约束,查询性能通常会有明显提升。
2.3 NOT NULL 非空约束与 DEFAULT 默认值约束:一对天然的搭档
非空约束说起来最简单:这一列的值不允许为 NULL。但越简单的东西越容易被忽视,实战里最常见的表结构问题之一,就是"该有值的列设计成了允许为空"。
哪些列应该加 NOT NULL?我总结了几个场景:
- 业务上必须有的数据:用户姓名、订单金额、创建时间。
- 参与计算的列:库存数量、价格等,如果允许为空,
SUM、AVG聚合时会直接忽略 NULL,算出来的结果容易让人疑惑。 - 会被用作关联条件的列:外键列,比如订单表的 user_id,绝不能为空,否则这条订单到底算谁的?
非空约束只有列级写法:
CREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, order_amount DECIMAL(10,2) NOT NULL, created_at DATETIME NOT NULL );默认值约束是这么设计的:插入数据时如果没显式给这一列赋值,MySQL 会自动填入默认值。注意,它和 NOT NULL 是配合使用的,不是互斥关系。一个很经典的组合是:
status TINYINT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMPstatus的默认值 0 表示"待处理",created_at默认取当前时间。这样的设计让业务代码插入数据时特别省心,只需要写核心业务字段,状态和时间都由数据库自动维护。在 MySQL 8.0.13 及以上版本,DEFAULT 还支持表达式,比如DEFAULT (UUID())可以给某列自动生成 UUID。不过表达式默认值有个前提:表达式必须是被括号包起来的,写DEFAULT UUID()会报语法错误,这一点很多人不知道。
说一个实战细节:如果一个列同时加了 NOT NULL 和 DEFAULT,那么插入时缺省该列,MySQL 会自动填默认值,不会报错。但如果只加 NOT NULL 不加 DEFAULT,插入时缺省该列就会直接报错,错误码一般是 1364(Field doesn't have a default value)。所以"NOT NULL + DEFAULT"组合其实是给插入操作提供了一层兜底,让调用方不用每列都显式给值。
2.4 FOREIGN KEY 外键约束:最强有力的完整性保障,也是性能与灵活性的博弈点
外键约束是六大约束里最复杂、最需要权衡的一个。它解决的是引用完整性问题:子表的某列值,必须是主表中已经存在的值。比如订单表的 user_id,必须在用户表的 id 里找得到。
建表时声明外键的语法:
CREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, order_amount DECIMAL(10,2) NOT NULL, CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES user(id) );CONSTRAINT fk_orders_user是给外键约束起名字。起名字这个习惯非常重要,后面删除外键、排查报错信息时,有名字的成功率远高于系统自动生成的那一串随机名称。
外键约束有四个级联规则,这是最核心的知识点:
| 规则 | 含义 | 适用场景 |
|---|---|---|
| CASCADE | 父表更新/删除时,子表同步更新/删除 | 比如删除用户时,连带删除他的草稿记录 |
| SET NULL | 父表更新/删除时,子表对应列设为 NULL | 比如删除商品时,订单里的商品 ID 置空,保留订单 |
| RESTRICT(默认) | 只要子表有引用,父表就不允许删除/更新 | 最安全,防止误删核心数据 |
| NO ACTION | 跟 RESTRICT 类似,在 MySQL 中表现一致 | 兼容其他数据库写法 |
级联规则写在 REFERENCES 后面:
CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE ON UPDATE CASCADE这里要解释一个底层原理:外键约束并不是只做"值比较",MySQL 还要求外键列上必须有索引。如果外键列本身没有索引,MySQL 会自动创建一个索引,因为外键的检查本质上是"子表的值在主表里查一下存不存在",没索引的话,每次插入都会全表扫主表,性能会非常难看。
但外键的争议也恰恰在这里。在高并发、大数据量的互联网业务里,很多人刻意不用外键,把引用完整性交给应用层来保证,理由是外键在每次插入、删除、更新时都会产生额外的检查开销,还会让复杂的级联操作变成数据库侧的"隐藏逻辑",出问题时不好排查。这是真实存在的顾虑,我可以理解。但如果你的项目是管理系统、内部系统、中小规模业务,数据一致性要求高、并发量没那么夸张,那数据库外键带来的保障价值远大于它那点性能开销。我自己的判断标准非常简单:金额、账户、订单这种错一个就要出事的核心链路,外键必须加;日志、流水、中间表这种量大、只进不出的数据,外键往往可以不加,因为它只会拖慢写入速度。
2.5 CHECK 检查约束:MySQL 8.0.16 之后才真正"硬起来"
检查约束在 MySQL 里的历史很曲折。在 8.0.16 之前的版本,MySQL 会解析 CHECK 语法但并不真正执行,也就是"形同虚设"。从 8.0.16 开始,MySQL 终于像其他主流数据库一样,让 CHECK 约束真正生效了。如果你还在用 5.7 版本,又希望数据库层校验值范围,那你只能退而求其次,靠 ENUM 类型或者触发器来做,但这两者都有各自的坑。ENUM 的问题是改枚举值很麻烦,触发器的问题是性能和维护成本高。所以如果你有"值域校验"的刚需,升级到 8.0 是最省心的路。
CHECK 约束的典型写法:
CREATE TABLE product ( id INT PRIMARY KEY AUTO_INCREMENT, price DECIMAL(10,2), stock INT, CONSTRAINT chk_price CHECK (price >= 0), CONSTRAINT chk_stock CHECK (stock >= 0 AND stock < 100000) );这个建表语句里,价格和库存都做了非负校验,库存还有一个上限。执行非法插入时,比如INSERT INTO product (price, stock) VALUES (-5, 10),MySQL 会直接报错:ERROR 3819 (HY000): Check constraint 'chk_price' is violated.。
CHECK 约束的表达式支持范围比较广,可以用 AND、OR、IN、BETWEEN、LIKE 等,甚至可以用多个列做复合校验。比如"促销价必须低于原价"这种跨列校验也能做:
CONSTRAINT chk_promo CHECK (promo_price < price)但需要注意几个限制:CHECK 表达式中不允许使用子查询、不允许使用存储函数、不允许使用 AUTO_INCREMENT 列以外的自增列。另外,如果一张表已经有大量数据,你再通过 ALTER TABLE 加 CHECK 约束时,MySQL 会默认校验已有数据。如果历史数据本身就非法,加约束会直接失败,这时候需要想清楚是修数据,还是用WITHOUT VALIDATION跳过校验(不过这个关键字从 8.0.16 开始可用,但确实要慎用,跳过校验等于埋雷)。
2.6 约束的命名规范:给约束起名是专业和野路子的分水岭
很多教程不会专门讲约束命名,但我必须说,这是实战里特别能体现基本功的细节。你建约束时不指定名字,MySQL 会自动生成一个随机约束名,主键通常是 PRIMARY,唯一约束通常叫 列名,但外键和 CHECK 约束会生成类似orders_ibfk_1这种数字命名。问题来了:当你要删除这个外键时,必须先查到它的名字,而随机名如果表多了,光靠猜根本对不上号。
我建议的命名规范是:
- 主键:直接用 PRIMARY KEY,系统中叫 PRIMARY,不用额外命名。
- 唯一约束:uk_表名_列名,比如 uk_user_phone。
- 外键约束:fk_表名_主表名_列名,比如 fk_orders_user_user_id。
- 检查约束:chk_表名_列名,比如 chk_product_price。
- 默认值和非空约束不用命名。
所有自定义约束都用CONSTRAINT 名字来显式声明。这样后面维护时,看名字就知道约束属于哪张表、哪个列、什么类型,排查报错信息时一眼就能定位问题。这个习惯养成得越早,你处理复杂项目的效率就越高。
3. 实操全流程:从一个图书管理系统案例学透约束
3.1 业务场景设定
直接讲语法太碎片了,我拿一个完整的业务场景串一遍。假设我们要做一个图书管理系统,核心有三张表:图书表(book)、读者表(reader)、借阅表(borrow)。
业务规则如下:
- 图书的 ISBN 必须唯一,书名为必填,库存必须大于等于 0。
- 读者的手机号必须唯一,姓名为必填,注册时间默认当前时间,会员等级默认 'normal',且只允许 normal、vip、gold 三种。
- 借阅表记录谁借了哪本书,同一读者同一本书不能重复借(还没还时),外键关联到读者表和图书表,读者被删时,借阅记录同步删除(级联),图书被删时,借阅记录里的图书信息置空(SET NULL),借出时间默认当前时间,归还时间必须晚于借出时间。
这个场景把六大约束全用上了。咱们来一步步写建表语句。
3.2 建表语句逐行解读
先建图书表:
CREATE TABLE book ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT '图书内部主键', isbn VARCHAR(20) NOT NULL COMMENT 'ISBN 编号', name VARCHAR(200) NOT NULL COMMENT '书名', author VARCHAR(100) COMMENT '作者', stock INT NOT NULL DEFAULT 0 COMMENT '库存', CONSTRAINT uk_book_isbn UNIQUE (isbn), CONSTRAINT chk_book_stock CHECK (stock >= 0) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='图书表';细节说几个:id是自增主键,负责物理标识;isbn虽然有唯一约束,但它更适合做业务唯一查找条件,而不适合做物理主键,因为万一以后系统要支持同一 ISBN 多册管理,主键是 id 会更灵活;name加了 NOT NULL,书名不能缺;stock有 DEFAULT 0 和 CHECK 双重保护,防止负库存。
接着建读者表:
CREATE TABLE reader ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT '读者内部主键', name VARCHAR(50) NOT NULL COMMENT '姓名', phone VARCHAR(20) NOT NULL COMMENT '手机号', membership_level VARCHAR(10) NOT NULL DEFAULT 'normal' COMMENT '会员等级', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '注册时间', CONSTRAINT uk_reader_phone UNIQUE (phone), CONSTRAINT chk_reader_level CHECK (membership_level IN ('normal', 'vip', 'gold')) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='读者表';这里membership_level用 NOT NULL + DEFAULT + CHECK 三件套,把"值不能为空、不填默认普通、填只能填三种"全部锁死。created_at用 DEFAULT CURRENT_TIMESTAMP,注册时间由数据库自动生成。
最后建借阅表:
CREATE TABLE borrow ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT '借阅记录主键', reader_id INT NOT NULL COMMENT '读者 ID', book_id INT NOT NULL COMMENT '图书 ID', borrow_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '借出时间', return_time DATETIME COMMENT '归还时间', CONSTRAINT fk_borrow_reader FOREIGN KEY (reader_id) REFERENCES reader(id) ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT fk_borrow_book FOREIGN KEY (book_id) REFERENCES book(id) ON DELETE SET NULL ON UPDATE CASCADE, CONSTRAINT uk_borrow_reader_book UNIQUE (reader_id, book_id), CONSTRAINT chk_borrow_return_time CHECK (return_time IS NULL OR return_time > borrow_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='借阅表';借阅表是外键和唯一约束的重灾区,我一条条讲:
fk_borrow_reader指向 reader 表,ON DELETE CASCADE表示删除读者时,他名下的借阅记录会自动删掉,不然读者都没了,借阅记录还悬着没意义。fk_borrow_book指向 book 表,这里我用ON DELETE SET NULL,因为图书如果从系统里下架删除,借阅记录里的 book_id 会被置成 NULL,历史记录还在,起码能查出来"曾经借过一本书,但那本书已经被清掉了",如果也用 CASCADE,那图书一删,所有借阅史也跟着没了,做统计时数据就断了。
uk_borrow_reader_book是联合唯一约束。它能保证同一读者对同一本书同一时刻只能有一条借阅记录吗?严格来说不能,因为读者还书之后再去借同一本书,会插入一条新的记录,此时 (reader_id, book_id) 组合又出现了,唯一约束会拦下来。所以如果你要做"同一读者同一本书同时只能有一条未还记录",还得依赖业务查询条件或者再加一个is_returned状态列配合处理。唯一约束只能解决"不允许出现第二条一模一样组合"的问题,解决不了"状态判断"的问题。这个边界一定要清楚。
chk_borrow_return_time这个 CHECK 约束有意思了,它允许 return_time 为 NULL(说明还没归还),但一旦填了归还时间,就必须大于借出时间,否则你会在系统里看到"还没借就先还了"这种笑话。
3.3 用 ALTER TABLE 增删约束的完整语法
实际项目里,约束的修改比比皆是。最典型的是:表上线跑了几个月,发现某列有重复数据,你才想起要加唯一约束;或者发现某列经常漏填,你才想改成 NOT NULL。这些操作全都用 ALTER TABLE。
添加约束语法:
-- 添加唯一约束 ALTER TABLE book ADD CONSTRAINT uk_book_isbn UNIQUE (isbn); -- 添加检查约束 ALTER TABLE product ADD CONSTRAINT chk_product_stock CHECK (stock >= 0); -- 添加外键约束 ALTER TABLE orders ADD CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES user(id); -- 修改列属性加非空约束 ALTER TABLE reader MODIFY COLUMN email VARCHAR(100) NOT NULL DEFAULT '';删除约束语法:
-- 删除主键约束 ALTER TABLE book DROP PRIMARY KEY; -- 删除唯一约束,注意用的是索引名(一般和约束名一致) ALTER TABLE book DROP INDEX uk_book_isbn; -- 删除外键约束,注意用的是约束名不是列名 ALTER TABLE borrow DROP FOREIGN KEY fk_borrow_reader; -- 删除检查约束 ALTER TABLE product DROP CHECK chk_product_stock;这里有几个特别容易踩的坑,我必须重点提醒。
第一,删除唯一约束用的是DROP INDEX 约束名,不是DROP CONSTRAINT。MySQL 里唯一约束和唯一索引是绑定的,索引名和我们定义的约束名一致,但你如果当时没显式命名,系统生成的索引名可能和列名有关。这时候可以靠SHOW INDEX FROM 表名;来查实际索引名。
第二,删除外键用DROP FOREIGN KEY 约束名。但如果这个外键列上还有索引,这个索引不会自动删除,需要手动DROP INDEX,否则你会发现删了外键但表上还留着一个多余的普通索引。
第三,修改非空约束时,反复 MODIFY COLUMN 是最常见的做法,但每次 MODIFY 都可能影响已有数据。如果列里已经有 NULL,直接加 NOT NULL 会报错(通常报 1265 或 1138),你要先执行 UPDATE 把 NULL 统一刷成一个默认值,再执行 MODIFY。
第四,往已有大量数据的表上加唯一约束,如果数据本身就存在重复,同样会失败。这时候得用分组查询先找出重复项,清理后再加。
3.4 验证约束有没有生效
建完表之后,我习惯立刻用几条 SQL 把每个约束都"打一遍",确认边界符合预期。这一步很多人觉得没必要,但实际上它最能暴露设计漏洞。
先把基础数据插进去:
INSERT INTO reader (name, phone, membership_level) VALUES ('张三', '13800138000', 'normal'); INSERT INTO reader (name, phone, membership_level) VALUES ('李四', '13800138001', 'vip'); INSERT INTO book (isbn, name, author, stock) VALUES ('9787111213826', 'MySQL实战', '张三', 10); INSERT INTO book (isbn, name, author, stock) VALUES ('9787111213827', '数据库原理', '李四', 5);然后故意制造非法数据,验证约束:
- 插入重复手机号:
INSERT INTO reader (name, phone) VALUES ('王五', '13800138000');预期结果:报错ERROR 1062 (23000): Duplicate entry '13800138000' for key 'reader.uk_reader_phone'。这就是唯一约束拦住了重复数据。
- 插入非法会员等级:
INSERT INTO reader (name, phone, membership_level) VALUES ('王五', '13800138002', 'super');预期结果:报错ERROR 3819 (HY000): Check constraint 'chk_reader_level' is violated.。
- 插入负库存图书:
INSERT INTO book (isbn, name, stock) VALUES ('9787111213828', '算法导论', -1);预期结果:被chk_book_stock拦住。
- 插入借阅记录时引用不存在的读者:
INSERT INTO borrow (reader_id, book_id) VALUES (999, 1);预期结果:报错ERROR 1452 (23000): Cannot add or update a child row: a foreign key constraint fails。这是外键最基本的保护。
- 插入归还时间早于借出时间的记录(先正常插入一条,再故意更新):
INSERT INTO borrow (reader_id, book_id) VALUES (1, 1); UPDATE borrow SET return_time = '2020-01-01 00:00:00' WHERE id = 1;预期结果:被chk_borrow_return_time拦住。这样"还书时间早于借书时间"的脏数据就进不来了。
- 验证级联操作:
DELETE FROM reader WHERE id = 1; SELECT * FROM borrow;预期结果:reader 表里的张三被删除后,所有借阅记录里 reader_id = 1 的行也会被自动删除。这就是ON DELETE CASCADE的实际效果。
这种"打约束"的验证方式,建议你在每次建表之后都做一遍。确认约束的行为和预期完全一致,再交付给业务使用,比后面靠运维和测试去撞运气强太多了。
3.5 大批量导入数据时,约束怎么处理最稳
实战中还有一个高频场景:从 Excel、CSV 或者其他数据库导入大量数据。这时候约束经常成为瓶颈——数据本身有少量脏值,导入时一遇到约束就直接中断整批失败。
最常用的做法是临时关闭外键检查。MySQL 有两个特殊变量:FOREIGN_KEY_CHECKS和UNIQUE_CHECKS。导入前执行:
SET FOREIGN_KEY_CHECKS = 0; SET UNIQUE_CHECKS = 0;导入后再恢复:
SET FOREIGN_KEY_CHECKS = 1; SET UNIQUE_CHECKS = 1;注意几个细节。第一,SET FOREIGN_KEY_CHECKS = 0只对当前会话生效,不影响其他连接,所以不用怕影响线上业务。第二,关闭外键检查只是跳过了"引用完整性校验",你导入的数据如果真的存在"孤儿数据"(引用了不存在的主表记录),后面做 join 时会查不出来,这个隐患要心里有数。第三,UNIQUE_CHECKS = 0可以加速唯一索引的构建,但同样是因为跳过了重复校验,导入完成后一定要用SELECT ... GROUP BY ... HAVING COUNT(*) > 1自查一遍。第四,我自己实际测下来,批量导入时同时关掉外键检查和唯一检查,速度能提升不少,但事后校验这一步绝对省不得,否则导入的"高速"最终都会变成"返工"的代价。
4. 常见问题与排查技巧实录
4.1 约束报错速查表
我把自己这些年攒下来的约束相关报错整理成了速查表,遇到问题别慌,先对号入座:
| 报错信息(片断) | 错误码 | 含义 | 解决方向 |
|---|---|---|---|
| Duplicate entry ... for key ... | 1062 | 违反唯一约束或主键 | 查重复数据,或确认业务上是否真允许重复 |
| Cannot add or update a child row: a foreign key constraint fails | 1452 | 子表插入的值在主表不存在 | 检查关联列的值是否真实存在于主表 |
| Cannot delete or update a parent row: a foreign key constraint fails | 1217/1451 | 删除父表记录时被子表引用 | 调整级联规则,或先清理子表数据 |
| Field 'xxx' doesn't have a default value | 1364 | 非空且无默认值,插入时缺省该列 | 补值,或调整列定义加 DEFAULT |
| Data truncated for column 'xxx' | 1265 | 插入值超过列长度/类型范围 | 检查业务数据格式,或调整列类型 |
| Check constraint 'xxx' is violated | 3819 | 违反 CHECK 约束 | 检查插入/更新的值是否满足表达式 |
| Cannot delete or update a parent row | 1451 | 父表更新/删除被外键拦截 | 确认级联规则是否符合预期 |
这个表建议收藏,遇到报错先把错误码定位出来,再去看具体是哪张表、哪个约束,问题就解决了一半。
4.2 外键创建失败的三个隐藏原因
外键约束创建失败是高频问题。明明语法看起来没问题,execute 就是报错。根据我的排障经验,九成是下面三个原因之一。
第一,两张表的关联列类型不一致。MySQL 要求外键列和引用列的数据类型必须一致(或者至少兼容)。最常见的是:主表 id 是BIGINT,子表 user_id 是INT,这样建外键直接失败。解决办法是统一两边列的类型,建议主外键列都用同一个整数类型。
第二,被引用的列不是索引列或不是主键。外键要求被引用的列必须有索引(其实主键必然带索引),如果引用的是一个没有索引的普通列,MySQL 会报错 1215(Cannot add foreign key constraint)。解决方法是先给被引用列创建索引。
第三,表引擎不一致。InnoDB 支持外键,但 MyISAM 不支持外键。你要检查一下两张表的 ENGINE 是否都是 InnoDB。这个坑在从旧库导入数据时特别常见,因为旧库可能有混合引擎。
判断到底是什么原因,最快的方式是执行:
SHOW ENGINE INNODB STATUS\G看 LAST FOREIGN KEY ERROR 那段信息,里面会写清楚失败的具体原因。这条命令是我排查外键问题时的第一招。
4.3 删除父表数据时,卡在 RESTRICT 上怎么办
默认的外键级联规则是 RESTRICT,意思是"只要子表还有引用,父表就不许删/改"。这是最安全但也最常让人摸不着头脑的规则。比如我想删除一个用户,报错说Cannot delete or update a parent row,我一看子表里的记录确实还引用着这个用户。
这时候的思路要分情况。如果业务上确实要删除这个用户及其关联数据,那有几种处理方式:
- 先手动清理子表引用数据,再删父表。
- 把级联规则改成
ON DELETE CASCADE,让数据库自动处理。 - 如果不想真删数据,加一个
status字段做逻辑删除(比如 status = 0 表示禁用),这样既保留历史关联,又不在物理上破坏数据。
我的实际经验是,核心业务表尽量用逻辑删除,外键级联用 CASCADE 时要非常慎重。因为 CASCADE 是"隐藏式连锁反应":你删一个用户,数据库可能悄悄帮你删了几百条关联记录,这些记录里也许还含有需要用到的审计数据。真要用 CASCADE,我强烈建议先做一遍模拟测试,看清级联范围再去动生产。
4.4 CHECK 约束不生效,先查版本和 SQL Mode
如果你明明写了 CHECK 约束,插入非法数据却没有任何报错,优先级最高的事情是查版本。执行SELECT VERSION();,如果你用的是 5.7 或更早版本,那 CHECK 就是"只解析不执行"的空壳。这种版本下,解决办法是升级到 8.0.16+,或者改用后端代码校验加触发器方案。
还有一个隐蔽的问题:MySQL 的sql_mode中如果有STRICT_TRANS_TABLES,则很多数据写入的异常会被严格模式拦截并报错;如果没有这个模式,一部分类型转换错误可能只给警告而不报错。虽然 CHECK 约束的实现不直接依赖 sql_mode,但如果你关掉了严格模式,某些边缘数据的行为会变得"宽容"到让你误以为约束没写对。排查约束问题时,也顺手看一眼 sql_mode:
SELECT @@sql_mode;4.5 唯一约束的 NULL 值陷阱
前面讲过,唯一约束允许有多条 NULL。这个特性在实战中会导致一个很常见的"数据明明重复但约束没拦住"的困惑。比如商家表里每个商家都有一个"推荐码",字段加了唯一约束,但很多商家创建时没有填写推荐码。那结果就是:表里有几十条推荐码为 NULL 的记录,MySQL 全都放行了。这不能怪约束,NULL 在 SQL 里代表"未知",两个未知值不相等,所以唯一约束不认为它们重复。
那如果你就是希望"没填写推荐码的只能有一条记录"呢?一个常见的办法是把空字符串 '' 作为默认值而不是 NULL,比如recommend_code VARCHAR(20) NOT NULL DEFAULT '',因为空字符串是实际值,唯一约束会对它去重,第二个空字符串就会报错。这个方案能把"未填写"和"填写相同值"都拦在门外,代价是数据库里空值会用 '' 表示,查询时要注意区分。这是一个非常典型的业务取舍,没有绝对的对错,关键是你得知道有这两种选择。
4.6 约束加错了,怎么无损回退
改表结构这种事,谁都有手滑的时候。比如你把某列加了 NOT NULL,结果后来业务反馈说这列确实有可能为空,不能禁;或者你把一个外键加成了 CASCADE,回头发现级联删数据太猛,想改成 SET NULL。这些都需要无损回退操作。
回退的核心原则是先看清楚当前定义再动手。我一般用三条命令摸清底细:
SHOW CREATE TABLE 表名; SHOW INDEX FROM 表名; SELECT TABLE_NAME, CONSTRAINT_NAME, CONSTRAINT_TYPE FROM information_schema.TABLE_CONSTRAINTS WHERE TABLE_NAME = '表名';第一条能看完整建表语句,包括所有约束定义;第二条能看清所有索引(唯一约束的索引名在这里查);第三条能从元数据层面看到约束类型。
然后按需执行回退:
-- 回退外键级联方式:先删外键,再按正确语义重新加 ALTER TABLE borrow DROP FOREIGN KEY fk_borrow_book; ALTER TABLE borrow ADD CONSTRAINT fk_borrow_book FOREIGN KEY (book_id) REFERENCES book(id) ON DELETE SET NULL ON UPDATE CASCADE; -- 回退 NOT NULL:用 MODIFY 去掉 NOT NULL ALTER TABLE reader MODIFY COLUMN email VARCHAR(100) NULL; -- 回退唯一约束:先删索引 ALTER TABLE book DROP INDEX uk_book_isbn;这里我最想强调的一点是:回退之前,务必先确认没有新的业务代码依赖这个约束。比如你删了 NOT NULL,那应用层如果写死了这个字段一定非空,后续插入 NULL 成功但代码里直接 NPE,那就是另一种灾难。所以回退约束前,和业务方/调用方对齐一下,永远是第一优先级。
写在最后的体会
我做了这么多年数据库相关的工作,最深的感受就是:约束不是束缚,而是保险。它在一开始给你添了一点点"麻烦"——建表时多写几行、插入数据时偶尔报个错——但换来的,是数据在源头就不会烂掉。大部分脏数据问题,靠代码排查要花几个小时甚至几天,而在数据库层加一条约束,只需要几秒钟。这笔账,怎么算都划算。
最后再分享两个小技巧。第一个,建任何表之前,先把自己代入"恶意用户"的角色,想一遍哪些数据一旦出错会造成严重后果,然后针对这些列加上对应约束,这比直接照抄模板强得多。第二个,每次改完约束后,养成跑一遍SHOW CREATE TABLE看终态的习惯,确认改动真正生效,不要想当然。如果你能把这两件事变成肌肉记忆,我相信后续所有 MySQL 相关的工作,你都会省下大量本该用来填坑的时间。