MySQL 和约束这两个词,对新手来说往往听着很玄乎。很多刚学 MySQL 的朋友,建表时随手写几行字段就完事,等到数据乱掉、重复记录满天飞,或者 Join 查不到数据时,才回头补课,才发现当初没建约束是给自己挖了个大坑。我自己最初也是这样,被重复数据教做人之后,才老老实实把约束吃透。这篇要讲的就是 MySQL 里最常用的两个约束,主键约束和唯一约束。搞懂它们,你的表结构基本就稳了一大半,后续踩坑的概率也会直线下降。
提示:如果你连 MySQL 环境都还没装好,建议先把数据库装好、能用命令行或客户端连上,再对照本文动手敲。约束是建表时定义的规则,纸上谈兵远不如亲手试一遍。
1. 约束到底在约束什么
1.1 约束的本质是给数据立规矩
MySQL 里的约束,本质上就是数据库在写入数据前帮你做的一道检查。你规定这个字段不能为空、不能重复、必须是某个范围,数据库就会在每次 INSERT 或 UPDATE 时自动校验,不符合规矩的操作直接拒绝执行。
打个比方,约束就好比单位门口的闸机,只有刷了门禁卡的人才能进,没卡或者卡过期的人进不去。没有这道闸机,谁都能往你表里塞数据,脏数据、重复数据、空数据一股脑全进来,后面你清洗数据时能累到怀疑人生。
MySQL 中常见的约束一共五类:NOT NULL(非空)、UNIQUE(唯一)、PRIMARY KEY(主键)、DEFAULT(默认值)、CHECK(检查),再加上外键 FOREIGN KEY,大致就齐了。六类里面,主键约束和唯一约束是出场频率最高、也最容易让新手犯迷糊的两个。因为这两个都跟“唯一性”有关,一个表里既可以有主键,也可以有唯一约束,那它们到底有什么区别?这就是本文要解决的核心问题。
1.2 为什么主键和唯一约束被称为“最常用”
原因很简单。任何一个正经业务表,都会有一列或者几列用来区分“这是哪条记录”。比如用户表里的用户ID,订单表里的订单号,商品表里的商品编码。这些字段天然要求“不能重复”,否则数据就乱了套。
唯一约束解决的是“业务上不允许重复”的场景,比如身份证号、手机号、邮箱,这些在业务逻辑上必须是唯一值。主键约束则更严格一些,它不仅是唯一值,还承担着“记录身份标识”的职责,是整张表的数据定位基准,也是其他表通过外键关联你的参照物。
换句话说:唯一约束是“你的身份证号不能和别人重复”,主键是“你是你,不是别人”。这俩字段在使用频率上碾压其他约束,所以新手入门,先吃透这两个就够用了。
2. 主键约束:数据表的身份证
2.1 主键到底是什么规则
主键约束在 MySQL 里同时具备三个属性:值非空、值唯一、一个表只能有一个主键。前两个好理解,最后一个属性其实很讲究。一个表只能有一个主键,这个“一个”指的是主键只能定义一组,但这一组里可以包含多个列,这就是“联合主键”。
大部分新手接触到的单列主键,通常是配着 AUTO_INCREMENT 自增整数来用的。这样做的目的是让数据库自动帮你分配一个递增序号,你完全不用关心主键值应该是多少。反正每插入一条记录,MySQL 就自动给它编号。
CREATE TABLE user ( id INT NOT NULL AUTO_INCREMENT, username VARCHAR(50) NOT NULL, email VARCHAR(100), PRIMARY KEY (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这段建表语句应该是 MySQL 新手最早学会的模板之一。id 列定义成 INT,加了 NOT NULL 和 AUTO_INCREMENT,最后在表级用 PRIMARY KEY (id) 把主键落在 id 列上。以后每插一条记录,id 自动往上加,从 1、2、3……一直排下去。
2.2 主键为什么必须存在
主键的意义不只在“保证唯一”。更深层的作用是,InnoDB 存储引擎里,数据实际上是以主键为逻辑顺序组织的。InnoDB 的索引结构是聚簇索引(Clustered Index),整张表的行数据直接挂在主键索引的叶子节点上。你没有主键,InnoDB 就会在后台偷偷找一列不重复的字段当主键;找不到,就生成一个隐藏列当主键。
所以哪怕你建表时不写 PRIMARY KEY,InnoDB 也会给你弄一个隐式主键。与其让数据库猜,不如自己明明白白定义一个。主键一旦建立,MySQL 会自动为主键列创建一个唯一索引,查询时走索引扫描,效率远高于全表扫。
这里有个很容易被忽略的细节:主键列的长度直接影响到普通索引的大小。因为 InnoDB 的二级索引叶子节点存储的是主键值,主键越长,其他索引的存储空间就越大。所以推荐用自增整数做主键,而不是很长的字符串,更不能用那种超长的业务单号。这也是很多公司建表规范里明确写的:主键尽量用 INT 或 BIGINT 自增。主键太长的后果短期内看不出来,等数据量大到千万级,索引体积的差异会非常明显。
2.3 新手最容易犯的两个主键错误
第一个错误是好心给主键加上了业务含义。比如用手机号做主键,当时觉得反正是唯一的,还省事。结果后来产品要支持用户改绑手机号,你发现想改主键值,牵一发动全身,所有关联表都要跟着改。如果你是业务字段的主键,一旦业务规则调整,你的表结构就得跟着重构。
第二个错误是使用 UUID 或其他随机字符串做主键。UUID 不用自增,插入时值完全是随机的。InnoDB 聚簇索引喜欢顺序插入,随机值会导致页分裂、频繁移动行记录,出现大量碎片。表现在系统上就是插入变慢、磁盘占用增多。不是说 UUID 绝不能用,但在单库单表的主键场景下,自增整数几乎是所有教科书和公司规范的一致答案。
注意:主键是一个表的身份标识,它不承担业务判断。哪怕用户名可以唯一标识一个人,也不建议把 username 当主键,真正的主键应该是那个永不变更、自增递增的 id。
3. 唯一约束:主键的“同胞兄弟”
3.1 唯一约束解决的场景
唯一约束的关键词是 UNIQUE KEY。它和主键最大的相同点是:字段值不能重复。不同点是:唯一约束允许 NULL;唯一约束可以有多个;创建时也会自动生成索引,但构建的索引是普通唯一索引,不是聚簇索引。
举例来说,用户表里主键是 id,但 username 或 email 通常也要加唯一约束。因为在业务上,两个用户的用户名或邮箱不能相同。这属于业务规则,不属于身份标识,用唯一约束再合适不过。
CREATE TABLE user ( id INT NOT NULL AUTO_INCREMENT, username VARCHAR(50) NOT NULL, email VARCHAR(100), PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这样写之后,当你想插一个已存在的 username 时,MySQL 会直接报错:Duplicate entry 'xxx' for key 'user.uk_username'。这个报错信息对新手很常见,看到时第一反应不应该是“为什么插不进去”,而应该是“哪个唯一约束拦住了我”。
3.2 唯一约束与主键的四个区别
新手混乱的根源,往往在于没把这两个约束放在同一个维度对比。我把核心区别拆成一张表。
| 对比维度 | 主键约束 | 唯一约束 |
|---|---|---|
| 数量限制 | 一个表只能有一个主键 | 一个表可以有多个唯一约束 |
| NULL 值 | 不允许 NULL | 允许 NULL,且多个 NULL 不算重复 |
| 索引类型 | 聚簇索引(InnoDB) | 普通唯一索引 |
| 业务定位 | 记录的身份标识 | 业务上的防重复规则 |
这里面最反直觉的,是 NULL 值那一条:唯一约束允许多行 NULL。也就是说,如果 email 列加了唯一约束,表里可以存在多条 email 为 NULL 的记录。很多新手以为唯一约束会让 NULL 也变成唯一,结果发现 NULL 可以插入多条,一脸懵。这是数据库标准行为:NULL 表示“未知”,数据库认为多个未知不相等,所以互不冲突。
另外,唯一约束可以和主键同时存在,也可以建在多个列上形成联合唯一。比如订单明细表里,同一张订单不能出现两次同一个商品,那就用 (order_id, product_id) 做联合唯一约束。这类组合,用主键做会很别扭,但用唯一约束做很自然。
3.3 唯一约束的隐藏价值:顺带给你一个索引
建立唯一约束的同时,MySQL 会自动为这些列创建唯一索引。这就意味着,你在写 WHERE 条件时如果命中了这些唯一字段,查询就能直接走索引。
举个例子,用户登录靠 username 查库,username 上有唯一索引的话,这条查询就会非常快。相当于你给字段加了防重复规则,还免费得到一个索引。相比之下,如果你不建唯一约束,光靠应用层去查一遍再插入,既慢又容易出并发问题,不如数据库层面一刀切。
但有利就有弊。每个唯一索引都会增加写入成本,因为每次插入时数据库都要检查唯一性。所以不要给表里每个字段都加上唯一约束,只在业务上真正需要唯一的字段上加。否则索引膨胀,插入效率下降,得不偿失。
注意:唯一约束和唯一索引叫法不同,本质上是同一个东西。你在建表语句里写 UNIQUE KEY (col),或者单独用 CREATE UNIQUE INDEX 创建,效果是一样的。区别只是一个是建表时用,一个是已存在的表上补加。
4. 实操演练:建表时把约束用对
4.1 一个完整的用户表建表案例
讲了这么多,不如直接上手敲一遍。这里我拿一个稍微真实一点的场景:用户注册表。需求是:用户ID自增、用户名不能重复、邮箱不能重复但允许为空、状态字段有默认值。表结构可以这样建。
CREATE TABLE `t_user` ( `id` INT NOT NULL AUTO_INCREMENT COMMENT '用户ID', `username` VARCHAR(50) NOT NULL COMMENT '用户名', `email` VARCHAR(100) DEFAULT NULL COMMENT '邮箱', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态,1正常,0禁用', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`), UNIQUE KEY `uk_email` (`email`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';这段语句里,主键落在 id 上,username 和 email 各有一条唯一约束。注意 email 的 DEFAULT NULL,就是前面说的“唯一约束允许多条 NULL”的经典场景。注册时可以允许用户不填邮箱,提交时数据库不会拦。
现在试着插入几条数据验证约束效果:
-- 正常插入 INSERT INTO t_user (username, email) VALUES ('zhangsan', 'zs@example.com'); -- 用户名重复,会报错 INSERT INTO t_user (username, email) VALUES ('zhangsan', 'other@example.com'); -- 邮箱重复,也会报错 INSERT INTO t_user (username, email) VALUES ('lisi', 'zs@example.com'); -- 邮箱为空,可以插多次,不会报错 INSERT INTO t_user (username, email) VALUES ('wangwu', NULL); INSERT INTO t_user (username, email) VALUES ('zhaoliu', NULL);自己敲一遍这几条,你对约束的理解会比看十篇文章都深。我特别推荐你去故意试错一下,插重复值看数据库到底报什么错。把错误提示看熟了,以后线上遇到类似说辞的报错,一眼就能定位。
4.2 表已建好,怎么补救约束
如果表已经建好了,当初没加约束,不用推倒重建。可以用 ALTER TABLE 语句补加。这是工作中非常常见的操作。
-- 给已有表添加主键 ALTER TABLE t_user ADD PRIMARY KEY (id); -- 添加唯一约束 ALTER TABLE t_user ADD UNIQUE KEY uk_username (username); -- 删除唯一约束 ALTER TABLE t_user DROP INDEX uk_username; -- 删除主键 ALTER TABLE t_user DROP PRIMARY KEY;这里有个操作细节:给已有字段添加唯一约束之前,一定要先确认现有数据是否满足唯一性。如果有两条记录 username 是相同的,ALTER 直接执行会失败。正确的姿势是先用 GROUP BY 查一遍重复项,清理干净后再加约束。
-- 找出重复的 username SELECT username, COUNT(*) FROM t_user GROUP BY username HAVING COUNT(*) > 1;我见过不少人在生产环境直接执行加唯一约束,结果数据库报错:Duplicate entry。这不是数据库的问题,是存量脏数据的问题。所以补约束前,先做数据清理,这是流程问题,不是语法问题。
4.3 约束加完之后,业务层别忘了处理报错
数据库约束能拦住脏数据,但它的报错信息对前端用户并不友好。你在插入重复用户名时,MySQL 会给一个生硬的 Duplicate entry 错误。如果你的程序不做处理,用户看到的就是“500 内部错误”。
正确的做法是在代码里捕获这个数据库异常,转成业务提示。以 Java 为例,插入时捕捉 DuplicateKeyException,返回“用户名已被注册”;如果是 MySQL 原生错误码,通常关注 1062 这个值,它代表唯一约束冲突。
约束是后端最后一道防线,但不是用户体验的全部。数据库帮你拦住了坏数据,应用层再把拦截结果翻译成人话,这套搭配才算完整。很多新手只注重数据库能不能建出来,忽略了对外表现的细节,这一点容易被忽视。
5. 新手踩坑实录:这些问题我也犯过
5.1 典型问题速查表
我把新手在约束上常踩的坑汇总一下,你在自查时可以直接对应。
| 问题 | 报错或现象 | 解决方案 |
|---|---|---|
| 主键重复插入 | Duplicate entry '1' for key 'PRIMARY' | 检查插入逻辑,确认 id 值来源 |
| 唯一约束冲突 | Duplicate entry 'xxx' for key 'uk_username' | 检查业务字段重复,提示用户更换 |
| 给已有脏数据表加唯一约束 | ALTER 执行失败,提示 Duplicate | 先清理重复数据,再加约束 |
| 主键用业务字段 | 业务修改时牵一发动全身 | 设计阶段用自增 id 做主键 |
| 忘记加索引直接查 | 查询全表扫描,速度慢 | 考虑在 WHERE 高频字段建索引 |
| 唯一约束字段一会 NULL 一会儿有值 | 多次 NULL 插入成功,以为出 bug | 理解 NULL 语义,需求上用空字符串替代 |
5.2 场景一:重复提交导致唯一约束爆掉
做活动页面时,用户可能双击提交按钮,前端没做防抖,后端也没做幂等。同一个 username 被提交两次,第二次插入时唯一约束就炸了。这个场景非常典型。
处理办法有两层。第一层,前端按钮置灰,防止双击。第二层,后端捕获重复键异常,返回友好提示。数据库的唯一约束不是帮你防误触的,但它能保证即便出现误触,也不会真的写入两条脏数据。所以唯一约束存在的意义,不光是“业务上不允许重复”,还是“数据异常时的兜底保险”。
5.3 场景二:NULL 导致唯一约束没生效
另一个常见的困惑是:email 加了唯一约束,为什么还能插两条 NULL?前面解释过,这是 MySQL 的标准行为,多个 NULL 之间不彼此冲突。如果你的业务要求“要么都填邮箱,要么都为空,但不能一部分有值一部分为空”,这种逻辑已经超出唯一约束的能力范围了,放在应用层处理,或者用空字符串代替 NULL 再配合唯一约束使用。
用空字符串代替 NULL 是一种工程技巧。把 email 字段设置成 NOT NULL DEFAULT '',然后在唯一约束下,空字符串只能存一条,第二条会报错。这种设计适合“每个用户必须有邮箱,但允许为空字符串占位”的场景。但要注意,它改变了字段语义:NULL 表示不存在,空字符串表示没有填写,两个含义要想清楚再选。
5.4 场景三:规范化命名约束,否则后期调试想骂人
约束命名是个容易被忽略的点。MySQL 默认会给约束自动命名,比如唯一约束默认名和被约束字段名一致,删起来还算好认。但经常有多个唯一约束落在相似字段上,比如 uk_name_phone,你看到名字就知道是 name + phone 的联合唯一约束。如果你什么都不写,全靠默认,后期维护时想 DROP 一个约束,得先去 information_schema 里翻表,非常痛苦。
我自己的习惯是统一前缀:主键约束一般就叫 PRIMARY,不用管;唯一约束用 uk_ 开头,后面跟字段名,多个字段用下划线连接;普通索引用 idx_ 开头;外键用 fk_ 开头。这套规则在团队里推行之后,维护表结构的人都会轻松很多。
5.5 排查约束问题的一些实用命令
实际排查时,如果你不确定表上有哪些约束、哪些索引,用 SHOW 命令能看到全貌。这是我在工作中使用频率相当高的排查手段。
-- 查看表结构,能看到约束和索引 SHOW CREATE TABLE t_user; -- 查看已有索引 SHOW INDEX FROM t_user;SHOW CREATE TABLE 的结果里,你会看到 PRIMARY KEY 和 UNIQUE KEY 等字样,非常直观。当一个 SQL 执行很慢时,我也会先用 SHOW INDEX 看看查询条件里的字段有没有索引。很多时候,慢查询的解决办法是补一个索引,而不是去优化那几条 SQL 逻辑。
提示:约束和索引是两套概念但紧密关联。主键约束和唯一约束会自动创建索引,这一点既是福利也是成本。你在设计字段时,一定要想清楚一个字段是否真的需要唯一性,因为它的代价是每次写入时多一次唯一性检查。
6. 最后再分享一点个人体会
MySQL 的约束看着简单,实际用起来学问不少。我自己刚入门时,也干过用手机号做主键、给每个字段都加唯一约束的“蠢事”,被数据量和业务变更狠狠教育过。后来逐步养成一套规矩:先想清楚每个字段的业务含义,再看它需不需要唯一性、允不允许为空,最后才是动手建表。
如果你正在学 MySQL,我建议你把主键约束和唯一约束吃透之后再往下学索引、事务、存储过程,因为它们本质上是后续所有数据库设计的基础。约束不牢,后面的优化再多,也填不上数据结构设计时的坑。这篇文章能帮你减少的,正是那些我当年踩过之后才明白的入门坑。