做过后端开发或者接触过数据库运维的同学,大概率都遇到过这种场景:业务跑着跑着,突然发现某张表里出现了重复数据,这时候第一个念头就是"赶紧加个唯一索引"。但真正下手的时候,问题就来了——要么加索引的SQL一直转圈不结束,要么直接报错 Duplicate entry,要么好不容易加上去了,线上业务又开始连环报唯一冲突,搞得手忙脚乱。这篇文章我就把 MySQL 加唯一索引这件事从头到尾拆一遍,从最基础的三种语法讲起,再到重复数据的预处理、大表的在线 DDL 方案、NULL 值和字符集这些容易翻车的细节,最后把我这些年踩过的坑和排查思路一并整理出来,希望对正在跟数据库搏斗的你有帮助。
1. 唯一索引是什么:先搞清楚它的边界
1.1 唯一索引、普通索引和主键到底差在哪
先说最基础的概念。唯一索引在 MySQL 里的核心作用就两个字:约束。它不允许索引列上出现重复值。你用一个普通索引能加速查询,但它不阻止重复;主键虽然也不允许重复,可一张表只能有一个主键,而且主键在 InnoDB 里还承担着聚簇索引的组织职责,往往和业务无关,就是一个自增 id。唯一索引刚好卡在两者中间——它既能约束业务字段的唯一性,又能在需要多列联合唯一的时候自由组合。
这里有一个很多人容易忽略的点:唯一约束本质上就是唯一索引。你在建表时写 UNIQUE KEY,MySQL 会物理地创建一棵唯一索引;你在字段上写 UNIQUE,结果也一样。有些同学以为"唯一约束"和"唯一索引"是两个东西,其实在 MySQL 里就是同一个实现,只是一个从约束角度命名,一个从数据结构角度命名。把这个关系理顺了,后面看任何资料都不容易被绕晕。
还有一个小细节值得记住:唯一索引允许 NULL。也就是说一列里如果有多行是 NULL,MySQL 会认为它们互不重复。这个特性有时候很讨厌,你想约束"手机号不能重复",但业务里有大量用户没有手机号,字段全是 NULL,唯一索引对这部分数据根本不生效。这个我后面会专门展开,但你现在先记住:唯一索引不是非空约束,它只管"非 NULL 值不重复",不管值是否存在。
1.2 哪些业务场景必须上唯一索引
工作中最常见的场景,基本都跟"防止重复写入"有关。最典型的是幂等设计:支付回调、消息消费、扣减库存,这些操作天然可能被重试,如果不用唯一索引兜底,同一笔订单被处理两次,损失就大了。业界通行的做法是用一个业务单据号,比如订单号、事务编号,对这个字段加唯一索引,重复请求进来时插入直接被拒绝,程序捕获到唯一冲突异常后返回"已处理",这比先查后插再加分布式锁要简单可靠得多。
另一个高频场景是用户类数据的唯一标识,比如用户名、邮箱、身份证号、手机号,这些字段业务上天然要求唯一。这里有个特别现实的坑:很多系统上线初期没加唯一索引,数据一多,注册接口的冲突校验就出现并发问题,两个请求同时发现"这个用户名没人用",然后都插入成功,数据就脏了。这种问题靠业务代码的先查后插根本没法根治,唯一索引才是真正能兜底的方案。
还有一些弱一点的场景,比如分页统计时靠唯一索引做去重,或者给外部系统暴露的标识符加约束防止脏数据回流。不管哪种场景,原则都一样:唯一索引保护的,是业务上绝对不能重复的那条红线。
1.3 动手之前想清楚:你的列真的适合做唯一吗
写这段是为了提醒你,别看到"唯一索引"三个字就滥用。有些字段业务上允许重复,比如用户昵称,很多平台允许重名;或者一个商品可以有多个相同的入库批次号,字段值本来就允许重复。如果一个字段在业务代码里就经常会插入相同值,你强行加唯一索引,线上立刻爆发大量写入失败,这就把业务规则硬生生塞给了数据库,代价非常大。
另一个需要判断的是:唯一是作用在单列上,还是作用在字段组合上。比如"同一个用户不能对同一件商品重复评价",你要加的唯一索引不是 user_id 一列,而是 (user_id, product_id) 这两列的复合唯一索引。这个组合能精确表达业务语义;如果你单独给 user_id 加唯一索引,反而会导致一个用户只能评价一次,那跟业务完全南辕北辙。所以在动手之前,先把自己的业务约束写清楚:到底是哪个字段,或者哪几个字段组合,在业务维度上必须唯一。
2. 加唯一索引的三种标准写法
2.1 建表阶段直接声明唯一约束
如果你还在表设计阶段,最省事的方式就是在 CREATE TABLE 里直接写清楚。比如订单表需要确保 order_no 唯一:
CREATE TABLE `order` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(64) NOT NULL COMMENT '订单号', `user_id` BIGINT UNSIGNED NOT NULL, `amount` DECIMAL(10,2) NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里uk_order_no是唯一索引的名字,后面报错、删除、查看索引时都要用到它。如果你不显式命名,MySQL 会默认用第一个列名当索引名,比如order_no,这个默认命名在复合索引里很容易撞车。所以我建议始终显式命名,用uk_开头代表唯一索引,普通索引用idx_开头,将来 DBA 一眼就能分清索引类型,排查问题也方便。
建表时还可以对联合字段做约束,语法也很简单,在表定义的最后加一行就行:
UNIQUE KEY `uk_user_product` (`user_id`, `product_id`)2.2 对已有表用 ALTER TABLE 追加
大多数时候业务已经上线很久,表里也有了数据,这时用 ALTER TABLE 命令,这是最常用的姿势:
ALTER TABLE `order` ADD UNIQUE KEY `uk_order_no` (`order_no`);这个命令执行时会把整张表扫一遍,逐行检查order_no是否有重复值。如果表里已经有重复数据,命令会直接报错:
ERROR 1062 (23000): Duplicate entry 'xxx' for key 'uk_order_no'
如果你加的是复合唯一索引:
ALTER TABLE `order` ADD UNIQUE KEY `uk_user_product` (`user_id`, `product_id`);MySQL 会按两个列的组合去判断重复,也就是 (user_id, product_id) 完全相同的两行才会冲突。这里要特别注意:复合唯一索引不限制单列自身的重复,同一个 user_id 可以有多条记录,只要 product_id 不同就行。
2.3 用 CREATE INDEX 独立创建
第三种写法是用 CREATE INDEX,作用完全相同:
CREATE UNIQUE INDEX `uk_order_no` ON `order` (`order_no`);在 MySQL 里,CREATE UNIQUE INDEX 和 ALTER TABLE ADD UNIQUE KEY 是等价的,底层走的是同一条 DDL 路径。唯一的区别是 ALTER TABLE 可以一条命令同时加多个索引:
ALTER TABLE `order` ADD UNIQUE KEY `uk_order_no` (`order_no`), ADD KEY `idx_user_id` (`user_id`);CREATE INDEX 一次只能建一个索引,所以在需要批量操作时,ALTER TABLE 的语法更紧凑、更好维护。
2.4 三种写法怎么选
我的使用习惯是这样:表还在设计阶段,直接在 CREATE TABLE 里写清楚,避免后期做迁移;已经上线的表,优先用 ALTER TABLE 一条命令搞定,因为后续如果还要加普通索引,也能统一从这个入口走;CREATE UNIQUE INDEX 我反而用得少,它更多出现在自动化迁移脚本里,视觉语义更明确。但不管用哪种写法,最后物理效果完全一致——information_schema.STATISTICS 里看到的都是一个唯一索引。所以不用纠结语法差异,选一种自己顺手的就行。
3. 加索引前的数据体检:别让重复数据坑了你
3.1 先定位重复数据,再谈加索引
我见过太多人第一次加唯一索引就是直接执行 ALTER TABLE,然后干等很久,最后收到一个 Duplicate entry 报错。说实话,这个报错本身还算好的,至少没有误删数据。最惨的情况是表里已经有上千万行,ALTER TABLE 执行了半个小时之后报错,索引建了一半还要回滚,业务查询性能被拖累,运维同学也跟着折腾半天。
所以加唯一索引的第一步,永远是查重复。最简单的方法:
SELECT `order_no`, COUNT(*) AS cnt FROM `order` GROUP BY `order_no` HAVING cnt > 1;如果返回空结果,说明数据是干净的,可以直接执行第二步加索引。如果非空,你还需要知道重复的严重程度,比如一共有多少组重复数据:
SELECT COUNT(*) AS duplicate_group_count FROM ( SELECT `order_no` FROM `order` GROUP BY `order_no` HAVING COUNT(*) > 1 ) t;这个信息会直接影响你去重方案的选择,重复组数少、每组重复两三条,跟组数多、每组几十条,处理思路完全不一样。
3.2 面对重复数据,保留哪条是个业务问题
数据里有重复,接下来最重要的是跟业务方确认保留规则,而不是自己拍脑袋。常见策略大概有这几种:保留 id 最小的那条;保留 id 最大的那条,因为往往是最后写入、状态最新的;保留某个时间字段最新的一条;保留特定状态字段为"有效"的那条。每种策略都没有标准答案,取决于业务怎么定义"正确数据"。
拿订单号重复来说,通常意味着同一笔订单被插入了两次,保留哪条其实差别不大,倾向于留着 id 最小的历史留底;如果是用户上传的附件记录重复,那可能应该保留最新的文件路径和上传时间。这里多说一句我的真实经历:有一次处理重复的用户绑定关系,我直接用"保留 id 最小"的规则跑完了,结果业务方告诉我应该保留的是最新绑定那条,因为老的绑定是测试数据。虽然数据最后通过备份恢复回来了,但也折腾了整整一天。从那以后,我养成了习惯——任何去重操作之前,先花十分钟把保留规则和业务确认一遍,不值为图快给自己挖坑。
3.3 大数据量去重的临时表方案
确认保留规则后,怎么执行去重?小表可以直接 DELETE,用 WHERE 子句限定范围就好;但大表不要直接在原表上做关联删除,尤其是多表 JOIN 的 DELETE,锁的范围大、IO 压力也大,很容易把库拖垮。我常用的方案是:先把要保留的 id 抓出来放到临时表里,再删除不在这个保留集合中的重复行。
第一步,把每组重复数据中要保留的 id 拿出来:
CREATE TABLE tmp_keep_id AS SELECT MIN(`id`) AS id FROM `order` GROUP BY `order_no` HAVING COUNT(*) > 1;如果业务要求保留最新一条,把 MIN 换成 MAX 就行。
第二步,删除重复行。最简洁的做法是用同表自连接:
DELETE t1 FROM `order` t1 INNER JOIN `order` t2 ON t1.`order_no` = t2.`order_no` AND t1.id > t2.id;这条 SQL 会把同组重复数据中非保留行的记录全部删掉,保留的是 id 最小那条。如果保留规则是"最新",把大于号改成小于号即可。DELETE 之前一定先跑一遍 SELECT 确认影响行数:
SELECT COUNT(*) FROM ( SELECT t1.id FROM `order` t1 INNER JOIN `order` t2 ON t1.`order_no` = t2.`order_no` AND t1.id > t2.id ) t;原则就是一个字:先查后删,多确认两遍不丢人。
3.4 去重之后,别忽略写入链路的新重复
数据清理完之后,按照第 2 部分的语法正式加索引。这里有个小提醒:如果这张表写入频繁,最好在业务低峰期操作。虽然去重已经完成,但加索引期间如果还有并发写入,新的重复数据可能又冒出来,下一次 ALTER TABLE 照样失败。遇到这种情况,你可能需要让业务短暂停写一会儿,或者配合业务方的幂等逻辑保证不产生新的脏数据。
4. 大表加唯一索引:不能硬刚,要讲究策略
4.1 直接加索引会锁多久
MySQL 的 DDL 执行时间,说白了取决于表量级、磁盘速度和当前负载。小表上 ALTER TABLE 几乎瞬间完成,千万行级别的表,一条普通的 ADD UNIQUE KEY 可能跑几分钟甚至几十分钟。从 MySQL 5.6 开始 InnoDB 支持了 Online DDL,很多 DDL 可以不再完全阻塞读写,但这不代表你可以随意在大表上执行 DDL,因为在线执行依然会占用大量 CPU 和 IO 资源,业务高峰跑同样会影响整体性能。
唯一索引的添加通常涉及索引数据重建,5.6 之后 InnoDB 在执行时大多采用 INPLACE 算法。但 INPLACE 期间仍然会申请额外空间用于日志和临时排序。换句话说,大表加唯一索引,往往不是"加一个索引",而是"在磁盘上重建表结构",你需要做好心理和运维准备。
4.2 Online DDL 的参数怎么写
MySQL 8.0 里,大多数 ALTER TABLE 默认就走 INPLACE 算法。你可以显式指定参数,把意图写得明明白白:
ALTER TABLE `order` ADD UNIQUE KEY `uk_order_no` (`order_no`), ALGORITHM=INPLACE, LOCK=NONE;这个写法的含义是:尽量不锁表,允许读写继续。但注意,LOCK=NONE 不保证一定生效,如果涉及全文索引、临时表等特殊情况,MySQL 会退回到更严格的锁级别。我自己的做法是,即使在 MySQL 8.0 里也会显式把 ALGORITHM 和 LOCK 写上,一方面让执行意图一目了然,另一方面万一执行失败,错误信息能更清楚地提示是哪一步不兼容。
4.3 大表加索引的分阶段落地流程
如果是几十万行以内的表,一次性 ALTER TABLE 通常没压力;到了千万甚至上亿行,我建议按这套流程走:
第一步,选业务低峰期操作,最好是凌晨或者写入量最低的时间段,降低数据冲突概率。第二步,先做数据体检,确认没有重复值,同时排查上游写入链路会不会产生新的重复。第三步,备份,至少要做一个逻辑备份或者物理快照,哪怕你自己很有把握,这个保险也一定不能省。大 DDL 失败不可怕,可怕的是回滚期间业务没法恢复。第四步,执行 Online DDL 并监控,盯住 CPU 使用率、IO util、临时空间占用三个指标,如果持续攀升,要考虑放慢速度或暂停。第五步,完成后用 SHOW INDEX 验证,再跑一下关键查询,确认执行计划真的用上了新索引。
4.4 主从环境下的操作注意点
主从复制环境下加唯一索引,有个非常现实的坑:你在主库执行 ALTER TABLE,如果从库延迟本身就高,DDL 会在从库排队很久,期间从库无法继续消费 binlog,延迟会被进一步放大。更麻烦的是,如果主库的 ALTER 中途失败,binlog 已经写入了一部分 DDL 事件,从库执行结果可能跟主库不一致。
我的建议是:操作前先看从库延迟,等延迟归零或者很低了再在主库执行;执行完成后,从库也要用 SHOW INDEX 验证。如果有多台从库,不要同时在所有从库上执行 DDL,而是让它们按顺序依赖主库的 binlog 自然同步,你只要监控延迟确认最终一致就行。
如果表实在太大,还可以考虑第三方在线改表工具,这类开源工具的通用思路是:创建一张影子表,把目标 DDL 应用到影子表,再通过触发器或日志回放的方式把增量数据同步过去,最后用原子替换的方式切换表名。它在线影响更小,但也不是银弹,要求表必须有主键、触发器会带来额外开销,使用前要评估清楚。
5. 细节决定成败:NULL 值、字符集和复合索引
5.1 NULL 值是唯一索引最隐蔽的漏洞
前面提到过,MySQL 的唯一索引允许多个 NULL。这个特性是很多线上事故的根源,因为开发者直觉认为 NULL 也是一种值,应该也被唯一约束管住,但 MySQL 不这么想。如果你的字段是"可选的手机号",一部分用户没填写导致字段为 NULL,那么这些 NULL 记录之间不会互相冲突,唯一索引等于对这部分数据失效。
遇到这种业务,你有几个选择:一是把字段改成 NOT NULL,用默认的空字符串表示"无";二是用生成列把 NULL 统一映射成非 NULL 再建索引;三是在应用层保证永远不插入 NULL。说到底,你得接受 MySQL 的语义:唯一索引只管"非 NULL 值不重复",所有涉及 NULL 的处理都要围绕这一点去设计,而不是指望数据库自动给你挡脏数据。
5.2 字符集和排序规则决定了什么才算重复
这个问题特别容易在英文和中文混合数据上翻车。举个例子,字段字符集是 utf8mb4,排序规则是 utf8mb4_general_ci。ci表示 case insensitive,也就是不区分大小写,所以a和A会被唯一索引判定为重复,abc和ABC也会冲突。如果你业务上允许大小写不同的昵称并存,这个唯一索引就会把合法数据挡住。
反过来,如果使用排序规则 utf8mb4_bin,a和A就是完全不同的值,可以共存。中文虽然大小写问题不大,但某些全角半角符号、特殊字符在部分排序规则下也可能出现"你以为不同、数据库认为是同一个"的情况。所以在加唯一索引前,先看看目标列的 collation 到底是不是不区分大小写,这决定了业务会不会被误伤。如果发现不合适,可以先调整字段排序规则,或者在写入阶段做统一的大小写归一化。
5.3 复合唯一索引的列顺序,不只是性能问题
唯一索引的列顺序如果理解错,唯一的语义也可能跟着错。比如需求是"同一个用户对同一个手机号只能绑定一次",正确写法是 (user_id, phone)。如果你颠倒写成 (phone, user_id),组合唯一的语义其实没变,仍然表示"用户+手机号"组合不重复。那顺序影响的是什么呢?
影响有两个维度。第一个是索引最左前缀原则:如果业务查询经常只用 user_id 过滤,那么唯一索引 (user_id, phone) 能支撑这类查询,而 (phone, user_id) 在单独按 user_id 查询时,用不上这个索引。第二个是 B+ 树的组织方式:组合顺序会影响插入时冲突检测的查找路径,一般把等值查询频率高、区分度高的列放前面,收益更大。所以设计复合唯一索引时,先想清楚业务查询习惯,再决定列顺序,不要随手一写。
5.4 前缀唯一索引:能不用就不用
MySQL 支持给字符串列的前缀建索引,比如:
ALTER TABLE `user` ADD UNIQUE KEY `uk_email_prefix` (`email`(20));这个索引只取 email 的前 20 个字符做唯一判断。这意味着,两个 email 前 20 个字符相同、后面不同,会被判定为重复。如果你的业务根本不允许这种"前缀相同"的场景,那没问题;但如果业务允许,这个索引会开始误杀合法数据。
我很少推荐用前缀唯一索引,因为它很容易造成语义混乱。真正的业务唯一性应该建立在完整字段上;前缀索引更多是普通索引里为了节省空间而用的手段,用在唯一约束上,除非有非常特殊的理由——比如字段长度极大且业务上明确要求前 N 个字符唯一——否则尽量避开。
6. 常见报错、排查思路与实测心得
6.1 加索引报错速查表
我把实际工作中比较高频的错误整理成一张表,方便直接对照:
| 报错信息 | 原因 | 解决方案 |
|---|---|---|
| Duplicate entry 'xxx' for key 'uk_xx' | 目标列或组合列存在重复 | 先查重复、按业务规则去重后再加 |
| Key 'uk_xx' doesn't exist in table | 索引名写错,或索引属于另一张表 | 用 SHOW INDEX 确认索引名 |
| Duplicate key name 'uk_xx' | 表里已有同名索引 | 换一个索引名,或先删掉旧索引 |
| Specified key was too long | 索引列过长,超过最大限制 | 考虑前缀索引,或改小字段长度/字符集 |
| LOCK=NONE is not supported | 当前存储引擎或操作不支持在线 DDL | 调整 ALGORITHM/LOCK 参数,必要时使用第三方工具 |
最容易被忽视的是 Duplicate key name。一张表里所有索引名必须全局唯一,普通索引和唯一索引也不能重名。我见过不少人在同一张表上加普通索引和唯一索引时抄了同一个名字,直接报错。
6.2 加了唯一索引后,业务偶发 Duplicate 怎么办
加完唯一索引,线上最典型的问题就是 Duplicate entry 异常突然增多。这种情况往往不是索引加错了,而是业务之前一直在"裸奔",很多重复数据以前能正常插入,现在被拦住了。你需要分两步处理:
先确认是不是正常的幂等保护:如果是重复上报的请求被拦截,那是预期行为,应用层应该捕获异常并返回友好提示,不能让错误直接暴露给用户。如果是不该重复的数据也被拦截,那就说明当前唯一索引的列表达不了真正的业务语义,比如你需要的是 (user_id, product_id) 组合唯一,结果却只对 user_id 加唯一,导致一个用户只能有一条记录,这时候得重新设计索引。
另外还要考虑历史代码的兼容性。有些老代码没捕获唯一冲突异常,会把堆栈打满日志,甚至影响接口响应。遇到这种项目,需要顺手在应用层补一个针对重复键冲突的兜底处理,插入失败时走查询更新流程,保证接口语义稳定。
6.3 最后分享几条自己的实测经验
第一个,加唯一索引之前,永远先跑一遍重复数据检查。这个动作成本极低、收益极高。哪怕是刚建好、看起来干干净净的表,也建议跑一下,因为灰度阶段很可能有手工测试数据混进去。
第二个,大表 Online DDL 执行前,把临时空间留足。InnoDB 建唯一索引时会用到临时排序文件,如果目录空间不够,DDL 会直接失败。检查磁盘空间应该成为大 DDL 之前的固定动作,我吃过一次亏之后就再也没忘过。
第三个,如果线上真的经常需要加各种索引,建议把索引管理纳入统一的发布流程,而不是每次手敲 ALTER TABLE。我之前在一个团队里做过简单的迁移脚本,每个 DDL 都带唯一前缀,配合记录表维护执行历史,后来排查问题方便很多。
第四个,如果加唯一索引的 SQL 执行时间远超预期,不要急着 KILL,先看一眼是不是正在等待元数据锁。有时候是前端有长事务一直占着表锁,ALTER TABLE 在排队而已。你把长事务找出来结束掉,索引加完可能只花几秒。
MySQL 加唯一索引这事,看着只是一条 SQL,背后牵涉的却是数据质量、业务语义、DDL 策略和上线规范。把这些细节一项项理顺,比背一百条命令都管用。希望这篇文章能帮你少踩几个坑,把唯一索引真正用到该用的地方去。