☰
MySQL约束实战指南:主键、唯一、外键与CHECK的用法和避坑
2026/9/29 16:30:40 网站建设 项目流程

1. 约束到底是干嘛的:先从一个血泪事故说起

去年年中,我们线上有个订单系统的报表数据突然对不上账。排查到最后,问题出在一张运营临时用的活动记录表上:因为没有唯一约束,同一个活动、同一个用户被重复写入了三次,后边统计营收的 SQL 一关联就把金额翻了三倍。更离谱的是,有一行关键记录的业务状态字段是 NULL,前端拿到直接判空出 bug,给用户发了错误优惠券。

那段时间我反复跟团队强调一句话:能在数据库层解决的数据质量问题,不要指望应用层代码兜底。这就是"表的约束"存在的根本意义——它是一套由数据库强制执行的数据校验规则,在数据落盘之前就把不合法、不完整、有歧义、相互矛盾的数据拦截下来。哪怕代码写得再烂、接口到处漏,只要约束建得够严,数据就不会坏。

这篇内容适合谁?正在学 MySQL 的新人,写了几年 SQL 但没系统整理过约束的老手,以及想把手头项目的表结构底子打得更扎实的开发者。我会把 MySQL 里常见的几类约束讲透:每一种约束解决什么问题、怎么写、藏在底下容易踩的坑,以及实战中怎么设计才不会把自己锁死。

先建立一个大概念:约束不是"数据库的附加功能",它本身就是表结构设计的一部分。你建表时写的每一列类型、每一个约束条件,都在定义"什么样的数据才配进入这张表"。想通了这一点,很多设计上的纠结就会变得明朗。

2. 六类约束逐个拆解:怎么用、为什么这么用、坑在哪

2.1 主键约束 PRIMARY KEY:一张表的"身份证"

主键约束是最基础也最不能少的约束。它的作用有两层:唯一标识一行记录+作为 InnoDB 聚簇索引的入口。在 MySQL 的 InnoDB 引擎里,表数据本身就按主键顺序物理存储,所以主键选得对不对,直接决定这张表的读写性能。

建主键有几种方式:

-- 方式一:建表时直接指定 CREATE TABLE user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL ); -- 方式二:表定义最后追加 CREATE TABLE user ( id BIGINT, name VARCHAR(50) NOT NULL, PRIMARY KEY (id) ); -- 方式三:复合主键,多列联合唯一 CREATE TABLE order_item ( order_id BIGINT NOT NULL, item_id BIGINT NOT NULL, quantity INT, PRIMARY KEY (order_id, item_id) );

这里最容易忽略的细节是:主键自带"非空 + 唯一 + 索引"三重属性。你不需要再额外给主键列加 NOT NULL 或者 UNIQUE,加了也白加,反而让定义冗余。

还有两个关于主键的经典经验:

第一,建议使用自增整数或雪花算法生成的分布式 ID 作为主键,而不要用业务字段当主键。比如有人拿身份证号当用户表主键,一旦业务规则调整(比如允许匿名用户注册、身份证校验规则变化),改主键会牵一发动全身。业务字段可以加唯一约束,但主键最好是跟业务解耦的纯标识符。

第二,自增主键在 InnoDB 中有个"不连续"特性:事务回滚后,自增计数不会回退。别误以为这是 bug,这是 InnoDB 为了保证并发插入性能做出的设计取舍。

提示:如果拿 UUID 字符串当主键,会带来两个问题——存储空间大、随机写入导致页分裂严重。在单表数据量大的场景下,性能差距会非常明显。优先考虑 BIGINT 自增,或者使用雪花算法。

2.2 非空约束与默认值约束:NULL 是万恶之源

我见过太多线上事故,最后都能追溯到"某个字段不该是 NULL 但却是 NULL"。NULL 在 SQL 里的语义非常特殊:它与任意值比较(包括 NULL = NULL)结果都是"未知",在 WHERE 条件、索引使用、聚合函数里都会产生微妙且隐蔽的行为差异。很多人写的 bug,本质上是没想清楚 NULL 和空字符串的区别。

CREATE TABLE customer ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL COMMENT '用户名必填', phone VARCHAR(20) NOT NULL DEFAULT '' COMMENT '手机号,默认空串', status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1启用 0禁用', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP );

几个实战判断标准:

  • 业务上"必须有值"的字段,直接 NOT NULL。例子:用户名、订单金额、创建时间。
  • 业务上"可能没有值"的字段,用空字符串或 0 表达"无",而不是放任为 NULL。例子:手机号可以允许注册时不填,那就存 '',查询时WHERE phone != ''清晰明了。
  • 时间字段必须 NOT NULL + DEFAULT CURRENT_TIMESTAMP,否则后续统计经常会遇到时间轴断裂的情况。

为什么我这么排斥 NULL?举个具体例子:统计用户总数时,COUNT(*) 和 COUNT(phone) 结果可能不一样,因为 COUNT(字段) 会自动忽略 NULL 值。如果你没意识到某列存在 NULL,你写统计 SQL 时会得到直觉之外的结果,而且排查起来特别难受。能用默认值表达"没有",就绝不放 NULL。

2.3 唯一约束 UNIQUE:防重复数据的主力

回到开头的活动记录表事故,解决方案就是加上唯一约束:

CREATE TABLE activity_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, activity_id BIGINT NOT NULL, user_id BIGINT NOT NULL, participate_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_activity_user (activity_id, user_id) ) COMMENT '活动参与记录';

加了uk_activity_user这个联合唯一索引后,同一个用户参与同一个活动就只能有一条记录,重复插入直接报错 Duplicate entry,彻底从源头堵住脏数据。

唯一约束和主键约束的区别,很多人说不清楚。整理一张对比表:

维度主键约束唯一约束
字段数量一张表只能一个主键(可多列组成)一张表可有多个唯一约束
是否允许 NULL不允许允许,且多个 NULL 可以共存
自动建索引是(聚簇索引或辅助索引)是(辅助索引)
语义定位行的唯一标识业务规则上的"不得重复"

那个"多个 NULL 共存"值得单独划重点:MySQL 认为 NULL 是"未知值",每个 NULL 都不相等,所以唯一约束不会阻止多行 NULL。这既是坑也是特性。如果业务要求"除了 NULL 之外不能重复",MySQL 原生唯一约束做不到,需要配合函数索引或者生成列来实现。

-- 利用生成列,把 NULL 转为唯一值,实现"非 NULL 字段唯一" ALTER TABLE customer ADD COLUMN phone_unique VARCHAR(20) GENERATED ALWAYS AS (IFNULL(phone, UUID())) VIRTUAL, ADD UNIQUE KEY uk_phone_unique (phone_unique);

当然,绝大多数业务根本不需要这么复杂,先把普通唯一约束用好再说。

2.4 CHECK 约束:被忽略多年的把关者

很多 MySQL 老用户对 CHECK 约束完全无感,因为在 8.0.16 版本之前,MySQL 虽然支持 CHECK 语法,但只会解析、不会真正执行。你建了也白建,数据照样能非法写入。这个历史包袱让很多人养成了"写完 CHECK 就不管"的坏习惯。从 8.0.16 开始,MySQL 终于让 CHECK 真正生效了。

CREATE TABLE product ( id BIGINT PRIMARY KEY AUTO_INCREMENT, price DECIMAL(10,2) NOT NULL, stock INT NOT NULL, status TINYINT NOT NULL DEFAULT 1, CONSTRAINT chk_price_positive CHECK (price >= 0), CONSTRAINT chk_stock_non_negative CHECK (stock >= 0), CONSTRAINT chk_status_valid CHECK (status IN (0, 1)) );

有了 CHECK 约束,应用层少写很多判断逻辑,比如库存不能为负数、价格不能低于 0、状态只能在枚举范围内。虽然这些校验在业务代码里也能做,但数据库层兜底的价值在于:任何入口写入的数据都必须通过校验,包括临时 SQL、数据订正脚本、运维手工改数据。

CHECK 的两个小坑:第一,老项目升级到 8.0.16 之前建的表,如果有"假 CHECK",需要手动重建才会真正生效;第二,CHECK 里不要写复杂的子查询或存储过程调用,一方面是性能问题,另一方面是维护难度直线上升。简单、直接、可枚举的条件,才适合做 CHECK。

2.5 外键约束 FOREIGN KEY:关系型数据库的灵魂,也是争议的起点

外键约束保证的是引用完整性:子表里的某个字段,必须是父表主键或唯一键里真实存在的值。它解决的是"订单表里的 user_id 在用户表里根本查不到这个人"这类关系错乱问题。

CREATE TABLE `order` ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, amount DECIMAL(10,2) NOT NULL, CONSTRAINT fk_order_user FOREIGN KEY (user_id) REFERENCES user(id) );

外键有三个核心规则需要理解透彻:

  • 约束双方的类型必须完全匹配。user_id 在父表是 BIGINT,子表也必须是 BIGINT,且字符集、排序规则一致。否则建表直接报错。
  • 外键列必须加索引。MySQL 会自动为外键列创建索引,但如果父表被引用的列本身就是主键或唯一键,这里通常没有问题;要注意的是,如果子表外键列在多列联合索引的后面位置,性能会有隐患,此时建议单独加索引。
  • 级联行为由 ON DELETE / ON UPDATE 指定,这个我在下一章单独展开。

外键的使用争议非常大,背后的权衡我在第 3 节详细说。先只放一句话:小团队、小项目、数据一致性要求高,放心用外键;大并发、分布式分库分表,基本都不再用外键,改为应用层保证。

3. 外键约束是把双刃剑:什么时候该用它、什么时候该果断放弃

3.1 为什么很多大厂都在"禁用外键"

在阿里、《Java 开发手册》这类业界规范里,有一句广为流传的规矩:禁止使用外键约束,一切外键关联必须在应用层解决。很多新人看了不理解:外键不是数据库的基本能力吗,为什么不用?理由其实很现实。

第一,外键约束会降低写入性能。每一次 INSERT 或 UPDATE 触发外键校验时,数据库都要去父表做一次加锁查询,这在批量导数据、高并发写入时是明显的瓶颈。第二,外键让表之间的耦合变得很紧。做分库分表时,跨数据库实例的外键根本没法生效,等于白建。第三,外键的级联删除风险极高。一条 DELETE 语句可能因为 CASCADE 把关联表里几十万条记录一并删掉,DBA 看报警都来不及拦。

所以技术圈逐渐形成了一种默契:互联网高并发场景下,让应用层做一致性校验,数据库只负责存储和最简单的约束。而传统企业应用、后台管理系统、ERP 这类数据量不大但正确性要求极高的场景,外键依然是强烈推荐的选择。它的存在能让数据关系长期保持稳定,减少应用层逻辑出漏子的概率。

3.2 外键级联操作的四个选项

写外键时,ON DELETE 和 ON UPDATE 有四类动作,选择不同,行为完全不同:

动作含义适用场景
CASCADE父表删除/更新,子表自动同步删除/更新子表记录是父表的附属物,没有独立存在价值
SET NULL父表删除/更新,子表外键列改为 NULL子表记录需要保留,但父表不在时语义为"无归属"
RESTRICT / NO ACTION存在子表引用时,禁止父表删除/更新默认行为,最安全,防误删
SET DEFAULT父表删除/更新,子表列改为默认值需要子表字段有默认值,实际用得少

我的推荐是:默认用 RESTRICT / NO ACTION,除非你真的明确知道级联是想要的。像"用户删了,他所有订单也删掉"这种需求,用 CASCADE 其实很危险——订单可能关联着发票、物流、退款等更深一层的数据,级联会引发连锁反应。

举个实际踩坑案例。之前做过一个 CRM 系统,联系人表外键关联客户表,删客户时联系人跟着 CASCADE 删了。后来业务方提出"删除客户要保留联系记录用于审计",那批历史数据已经被级联删得干干净净,恢复成本极高。从那以后,凡是涉及金融、日志、审计、订单这类"不能丢"的数据,我全部用软删除标记 + 外键 RESTRICT。

3.3 外键当成"最后一道防线"来用

现在我的实践策略非常明确:能建外键就建外键,但绝不依赖外键承担核心的正确性校验。核心业务的不变量由应用层事务和显式查询来保证,外键只是那个"万一应用层漏了"的时候兜底的存在。

比如创建订单时,应用层先查用户表确认 user_id 存在,再插入订单;如果代码出现并发情况或者查询逻辑漏了,外键在最后一刻拦截住非法数据,让写入报错而不是让脏数据落库。这个策略兼顾了性能和安全的性价比:正常链路没有任何额外查询开销,异常时数据库还能守住底线。

4. 约束相关的经典报错与完整排查路线

4.1 Duplicate entry:唯一键冲突的几种隐蔽场景

最常见的约束报错就是Duplicate entry 'xxx' for key 'uk_xxx'。

初级情况是重复插入同一条记录,这个大家都会看。麻烦的是这两种隐蔽场景:

  • 业务键确实发生了变化。比如用户手机号换绑,A 用户的手机号替换成了原来 B 用户正在用的号,此时更新就需要事务内先解绑再绑定,否则必然撞唯一约束。
  • 幂等逻辑没做好。订单回调、消息重试时忘记先查询是否已处理,导致重复写入。

排查思路很直接:把报错信息里的重复值拿出来,到表里查一遍,看已有数据的产生时间和来源。如果是历史脏数据导致的唯一索引新增失败,先清洗数据再建索引。我用过最快的清洗 SQL 长这样:

-- 保留每组重复中 id 最小的,删掉其他 DELETE c FROM customer c JOIN ( SELECT phone, MIN(id) AS keep_id FROM customer WHERE phone != '' AND phone IS NOT NULL GROUP BY phone HAVING COUNT(*) > 1 ) t ON c.phone = t.phone AND c.id != t.keep_id;

注意删完再做ALTER TABLE ... ADD UNIQUE KEY,顺序不能反,否则索引建一半报错很尴尬。

4.2 Cannot add foreign key constraint:外键建不上的五个检查点

这个报错的外键新手成功率极低,经常是一顿操作报错后不知所措。按我的经验,按顺序查下面五个点,九成问题出在这里:

  1. 父表和子表字段的数据类型是否完全一致。BIGINT 和 INT 就不行,VARCHAR(50) 和 VARCHAR(64) 也不行。
  2. 字段的字符集与排序规则是否一致。最常见的是父表用 utf8mb4_general_ci,子表用 utf8mb4_unicode_ci,或者一张表 utf8、一张表 utf8mb4。
  3. 父表被引用字段是否为主键或唯一索引。外键必须引用父表的唯一键。
  4. 存储引擎是否都是 InnoDB。MyISAM 不支持真正的外键约束,一个 MyISAM 表上建外键虽然不报错但实际不生效,混合在一起就容易出幻觉。
  5. 子表外键列与父表字段的默认值是否冲突。比如父表列是 NOT NULL DEFAULT 0,子表外键列是 NOT NULL 无默认值,在外键校验时会表现得很诡异。

排查时直接执行:

SHOW CREATE TABLE 表名;

把父表、子表的完整建表语句并排对比,一眼就能看出类型、字符集、引擎是否匹配。这个习惯能帮你省掉大量试错时间。

4.3 Data too long / Incorrect integer value:字段约束与写入数据的拉扯

Data too long for column 'name' at row 1这类报错,本质上是字符串长度超出 VARCHAR 或字符集限制。很多人第一反应是把 VARCHAR 长度加大,但深一层的问题是:设计时有没有预估字段的最长长度?

以手机号为例,为什么要留 VARCHAR(20) 而不是 VARCHAR(11)?因为手机号规则会变,历史上还出现过加 86 前缀的写法,你根本不知道上游系统会传什么格式进来。留 20 不是浪费存储,是给数据格式变化留缓冲。而状态码、年龄这类字段,用 TINYINT 就够了,没必要给 VARCHAR(10)。字段类型的挑选本身就是一种隐式约束:类型选大了,约束就松,垃圾数据更容易混进来;类型选小了,正常数据都可能被拒,误伤业务。平衡点是预留合理余量但不过度。

另一个常见场景是Incorrect integer value: 'abc' for column 'age'。这往往不是数据库的锅,而是应用层没做类型校验。在严格 SQL 模式下,MySQL 会直接拒绝非法类型转换;在非严格模式下,它只会把 'abc' 转成 0 然后存进去。所以项目一定要开启严格模式,确保类型错误直接暴露而不是悄悄被修正。

SET sql_mode = 'STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION';

4.4 ALTER TABLE 添加约束失败的迁移思路

给线上大表加约束是 DBA 和开发都怕的操作。直接ALTER TABLE ... ADD UNIQUE KEY在大数据量下会锁住整表写入,业务直接卡死。我的经验是按这个节奏来:

  1. 先在从库或者低峰期执行一遍,记录耗时和锁表时长。
  2. 如果表数据量不大(百万级以内),业务低峰期直接加,问题不大。
  3. 超过千万级,建议用在线 DDL 工具,比如gh-ost或pt-online-schema-change,发布后再做一次数据校验,确认新旧表数据一致。

还有一个经常被忽略的问题:给已有大量数据的表加新约束之前,先做一遍数据体检。逻辑很简单,约束是对所有历史数据生效的,如果表里已经存在重复数据,加唯一索引必然失败。所以提前跑一遍 SQL 查重复、查非法值、查 NULL 分布,是大表加约束前的标准动作。

5. 约束设计的系统方法论:别把约束当成建表的"附加题"

5.1 先梳理业务规则,再动手建表

很多人一拿到需求就打开 Navicat 开始点选字段,建表全靠手感和经验,这其实是把顺序搞反了。表结构设计的输入应该是一份完整的业务规则清单,约束的选择完全由规则推导而来。我通常按下面这个思路走:

  • 一条数据靠什么唯一标识?—— 主键约束
  • 哪些字段在业务上不允许重复?—— 唯一约束
  • 哪些字段必须有值?—— 非空约束
  • 哪些字段不填时该有什么默认值?—— 默认值约束
  • 哪些字段的取值范围需要限定?—— CHECK 约束
  • 哪些字段引用其他表?—— 外键约束

这套问题问完,表和约束的草图基本就出来了。很多时候,约束设计就是在帮业务方理清自己到底想要什么。别人来问"这个字段要不要允许为空"时,我的标准答案是:如果连业务方都说不清空值代表什么语义,就坚决 NOT NULL + DEFAULT。

5.2 软删除场景下的唯一约束难题

业务上做了逻辑删除(is_deleted=1)后,唯一约束会变得特别棘手:假设用户手机号唯一,用户删了之后想要重新注册同一个手机号,但表里还留着那条软删除记录,唯一索引直接拦住新注册。

我的解决方案是给唯一索引加上删除标记列,形成联合唯一:

-- 把删除标记设计为 0 或 1,唯一索引 (phone, is_deleted) 无法满足上面的需求(一个用户只能一个手机号,但删除后可以复用) -- 推荐方案:唯一索引 (phone),软删除时把 phone 改写为 原值 + 随机后缀

更通用的做法是在业务代码里,软删除时把 phone 置为旧值#deleted#时间戳,保证物理上不再与现存值冲突。这个技巧看着简单,但在很多系统里能避免一整套复杂的替代方案。

5.3 约束命名规范,别让 DBA 骂你

约束命名在国内团队里普遍不受重视,默认名称要么是 PRIMARY、要么是字段名,要么是 MySQL 自动生成的随机名。一旦线上出现问题,想精准定位是哪个约束在拦截,往往要一条条去查。我建议的规范如下:

  • 主键:pk_表名缩写_字段名,比如pk_usr_id
  • 唯一:uk_表名缩写_字段名组合,字段多时用下划线连,比如uk_usr_phone
  • 普通索引:idx_表名缩写_字段名
  • 外键:fk_子表名缩写_父表名缩写_字段名
  • CHECK:chk_表名缩写_规则描述

命名清晰的价值在排查问题时才会体现:看到uk_activity_user,立刻知道是活动用户唯一约束,连查都不用查。

提示:MySQL 的 INFORMATION_SCHEMA 提供了全部约束元数据,排查约束问题时的标准查询是:

SELECT * FROM information_schema.TABLE_CONSTRAINTS WHERE table_name = 'order'; SELECT * FROM information_schema.KEY_COLUMN_USAGE WHERE table_name = 'order';

5.4 一份可以直接抄的建表模板

结合前面所有讲到的点,我给出一个综合建表实例:

CREATE TABLE `user_account` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', `username` VARCHAR(50) NOT NULL COMMENT '用户名', `phone` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '手机号,未绑定为空串', `email` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '邮箱', `age` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '年龄,未知为 0', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态 1正常 0禁用', `register_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '注册时间', `is_deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除,1为已删除', PRIMARY KEY (`id`), UNIQUE KEY `uk_ua_username` (`username`), UNIQUE KEY `uk_ua_phone` (`phone`), KEY `idx_ua_register_time` (`register_time`), CONSTRAINT `chk_ua_status_valid` CHECK (`status` IN (0, 1)), CONSTRAINT `chk_ua_age_valid` CHECK (`age` >= 0 AND `age` <= 150) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT ='用户账户表';

这个模板覆盖了主键、唯一、非空、默认值、CHECK 和索引几大类。实际业务可以按需裁剪,但每条约束背后的设计理由要能说出来。如果别人问"为什么 phone 不用 NOT NULL",你说不上来,那大概率这个约束就设计得有问题。

5.5 最后的实战心得

跟 MySQL 打了这么多年交道,我对约束的态度经历了三个阶段:刚工作时嫌弃约束麻烦、能不加就不加;后来被线上脏数据教育过后,开始疯狂加约束;再到如今,会带着"分析每个约束的真实成本和收益"的心态去做设计。

约束不是越严越好。你给表加了太多 CHECK 和唯一约束,应用层每次写入都要多扛一层校验,出错的概率也更高。好的约束设计是"克制但关键"——核心的不变量一条不少,边缘的业务规则留给应用层灵活处理。

比如状态字段,如果业务方每个月都可能加新状态,「状态只能属于枚举值」这种 CHECK 尽量别写。你可能只加三个月报表,第四个月业务一改,开发就得来 DBA 这边联调改约束,这个成本很高。反过来,价格不能为负数、用户名不能重复、外键必须存在——这些长期不变量,一定要在数据库层锁死。

把约束当成一张表质量的生命线,它自然会成为你系统设计里最稳健的那道防线。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询