主键和唯一索引,到底有什么区别?这个问题做过两三年开发的人基本都撞上过:面试被问一次,线上排查重复数据又被绕晕一次。网上讲两者区别的文章不少,但多数停在"一张表只能有一个主键、唯一索引可以有多个、主键不能为空"这种表面答案。这些当然没错,可真正落地到建表、改表、排查慢查询和生产事故时,光靠这几句话根本撑不住。
这篇文章我用自己的实际经验来讲,以 MySQL InnoDB 为主线,穿插 Oracle 和 PostgreSQL 的具体行为,最后给一套平时建表、加索引、清理重复数据时可以直接抄走的方案。顺便把踩过的坑一并倒出来:大表改主键导致业务中断、软删除后唯一索引冲突、精心建好的索引因为一个函数查询直接失效。把这些场景走完一遍,你对主键和唯一索引的理解会扎实很多。
1. 先从本质上分清:约束和索引不是一回事
1.1 主键是一个约束
很多文章一上来就对比"主键 VS 唯一索引",把两者放在同一层,这本身就容易误导。主键在 SQL 标准里的定义非常明确:主键约束(PRIMARY KEY) = 非空约束 + 唯一约束,它是一组完整性规则,用来保证每一行都能被唯一标识。
约束是逻辑概念,它回答的是"什么数据可以进入这张表"的问题。主键约束决定了:这一列(或这几列)的值不允许为空,且不允许重复。至于数据库底层拿什么数据结构去实现这个约束,主键约束本身并不关心。
打个比方,主键约束像是公司规定"每个员工必须有唯一的工号,且工号不能为空",这是一个制度层面的规则。
1.2 唯一索引是一个物理结构
索引就完全是另一层的东西了。索引是一种独立的存储结构,它的存在是为了加速查询、维护数据的有序性。唯一索引(UNIQUE INDEX)只是在这个物理结构上加了一层"值不能重复"的限定。
索引是物理概念,它回答的是"数据怎么被快速找到"的问题。唯不唯一只是它的一个属性,真正干活的还是那颗 B+ 树。
还是拿工号举例,唯一索引相当于"给工号字段建了一本专门的查找目录,并且这本目录规定每个工号只能出现一次"。它是物理存在的树结构,会占磁盘空间,会随数据写入而更新,会出现在 EXPLAIN 的执行计划里。
1.3 两者靠什么搭上关系
正因为主键约束需要一种机制来快速检查唯一性,几乎所有主流数据库在创建主键约束时,都会自动在对应列上创建一个唯一索引来支撑它。MySQL 的 InnoDB 里,主键直接就是聚簇索引;Oracle 创建主键约束时会隐式创建一个唯一索引;PostgreSQL 同样会自动为 PRIMARY KEY 建一个唯一 B-tree 索引。
所以你会看到一些人说"主键就是唯一索引",这是从物理实现角度说的,没错,但不完整。主键约束 = 非空 + 唯一 + (通常)一个底层唯一索引支撑;唯一索引 = 一个带唯一属性的索引结构,仅此而已。
想清楚这一层,后面所有区别都能顺下来。
2. 六大核心区别逐项拆解
2.1 数量限制:一个主键还是多个唯一索引
一张表只能有一个主键,但可以有多个唯一索引。这个限制的根源在于主键的语义:一张表的记录只能有一种"身份标识"。
不过很多人不知道的是,InnoDB 里"只能有一个主键"还叠了一层物理原因:聚簇索引只能有一个。聚簇索引决定了数据行在磁盘上的物理排列顺序,一个表的数据只能按照一种顺序存放,所以天然只能有一个聚簇索引,也就只能有一个主键。
唯一索引就没有这个限制。真实业务里你经常能看到一张表同时挂着好几个唯一索引:
CREATE TABLE user_account ( id BIGINT PRIMARY KEY AUTO_INCREMENT, email VARCHAR(64), mobile VARCHAR(20), identity_no VARCHAR(18), UNIQUE KEY uk_email (email), UNIQUE KEY uk_mobile (mobile), UNIQUE KEY uk_identity (identity_no) );邮箱、手机号、身份证号都承担着各自的"业务唯一性",但它们都不是记录的身份标识。这种场景下多个唯一索引并行,非常常见。
2.2 NULL 值处理:能不能为空是分水岭
主键列绝对不允许 NULL,哪怕你只在复合主键的一列上插入 NULL,整行都会插入失败。这是主键约束的一部分,SQL 标准写死了。
唯一索引就灵活得多。在 MySQL、PostgreSQL、Oracle 里,唯一索引列允许出现多个 NULL 值。原因在于唯一性判断的逻辑:NULL 不等于任何值,包括 NULL 本身,所以每一行 NULL 都被视为互不相同的值,不违反唯一约束。
只有 SQL Server 比较特殊,它的唯一索引默认只允许一个 NULL(除非使用过滤索引)。如果你在 SQL Server 上给可空列建唯一索引,插入第二个 NULL 会直接报冲突,这是个容易踩的坑。
这个差异在实际设计里非常有用。比如"用户选填手机号,没填的存 NULL",那么:
CREATE TABLE customer ( id BIGINT PRIMARY KEY, name VARCHAR(32) NOT NULL, mobile VARCHAR(20), UNIQUE KEY uk_mobile (mobile) );多个用户不填手机号时可以共存,填了手机号的用户之间不允许重复。这个效果用主键是做不到的,因为主键不允许 NULL。
2.3 聚簇索引:InnoDB 下的物理排序差异
这一条是 MySQL 使用者必须理解的核心。InnoDB 表默认是聚簇索引组织表,数据行的主键值就是聚簇索引的键值,叶子节点直接存放整行数据。这意味着:表数据本身按照主键顺序物理排列(逻辑意义上的顺序)。
主键查询为什么快?因为走聚簇索引直接定位到数据行,一次索引查找就能拿到整行,不需要回表。
而普通唯一索引(二级索引)的叶子节点存的是主键值,而不是整行数据。通过唯一索引查询时,先在一棵二级索引 B+ 树里找到主键值,再回到聚簇索引里捞整行,这个过程叫回表。多一次 IO。
这里有个隐藏知识点:如果你建表时没有定义主键,InnoDB 会按顺序检查是否有非空的唯一索引,如果有,就拿它当聚簇索引;如果连这都没有,就生成一个隐藏的 6 字节 rowid 列作为聚簇索引。换句话说,没有主键的表,InnoDB 会自己偷偷补一个。很多人以为"没主键就只是没主键",实际上物理存储层面它从来没缺席过。
2.4 外键引用:谁有资格被"认亲"
外键约束要求被引用的列必须是一个主键或唯一键。MySQL 里外键必须引用主键或唯一键,Oracle、PostgreSQL 也类似——被引用列需要具备唯一性。
但实践中几乎没人用唯一索引当外键的参照目标。原因很实际:唯一索引列可能为空,空值意味着"没有参照对象",这种语义在业务上很容易产生二义性;另外唯一索引列通常承载业务属性,业务属性恰恰是最容易变化的。比如用手机号做外键参照,哪天用户注销手机号,号码被运营商回收再分配给另一个人,你的历史数据关联就全乱了。
主键因为不承载业务含义(尤其是代理主键),天然稳定,所以外键默认指向主键,这是行业默认的最佳实践。
2.5 变更代价:删主键和删唯一索引完全不是一个量级
给大表加一个唯一索引,在 MySQL 8.0 里可以用 INPLACE 算法在线执行,虽然会占用额外空间、增加 IO 压力,但业务基本可以继续写入。给大表删一个唯一索引,代价更小,二级索引重建即可。
改主键就是另一个故事了。修改主键意味着重建聚簇索引,而聚簇索引的叶子节点是整行数据,重建它等于把整张表的行数据全部重排一遍。对千万行以上的表,这可能意味着几分钟到几十分钟的锁表窗口,很多线上事故就是这么来的。
Oracle 里同样有坑:禁用主键约束(DISABLE CONSTRAINT)不等于删除索引,约束停用后,底层唯一索引可能还在,但唯一性校验已经停止,这时候插入重复值约束不会拦你,但索引本身还在正常工作,可以加速查询。很多人误以为禁用约束后索引也没了,排查问题时会看走眼。
2.6 语义差异:标识记录还是保证不重复
说了这么多实现层面的东西,回到最朴素的问题:两者到底各自解决什么问题?
主键解决的是"记录身份"问题。它的存在是为了让每一行都有一张独一无二的身份证,哪怕这个身份证号本身毫无业务意义(比如自增 ID)。主键甚至不关心业务,只关心"我能不能在一堆记录里准确区分这一条"。
唯一索引解决的是"业务值不重复"问题。它保护的是某个业务字段的取值唯一性,比如订单号不能重复、邮箱不能重复注册、同一租户下的编码不能重复。
判断标准很简单:问自己一句,这一列如果未来业务上允许变化,变了之后表的关联和标识还成立吗?如果成立,它可能是唯一索引;如果不成立,那它是主键。
3. 不同数据库里的具体行为表现
3.1 MySQL InnoDB:主键直接决定存储形态
InnoDB 是最典型的主键驱动型引擎。建表时指定主键,数据就按主键聚簇。这里有几个实际影响:
第一,主键值越小,二级索引(包括唯一索引)的叶子节点就越小,因为叶子节点存主键值。用自增 BIGINT 做主键,二级索引占空间最小;用 VARCHAR(64) 的 UUID 做主键,每个二级索引的叶子节点都要多存一行 64 字符,索引体积翻倍甚至更多。
第二,自增主键写入时是顺序追加,新行落在聚簇索引的最后面,不会频繁触发页分裂。UUID 主键是随机值,每插入一行都可能插到现有数据中间,导致频繁页分裂和随机 IO,写入性能肉眼可见地下降。这个差异在数据量上千万后极其明显。
第三,如果你真的不建主键,InnoDB 会挑一个非空唯一索引当聚簇索引。这会导致一个有意思的现象:你自己建的唯一索引,在物理层面悄悄变成了"主键",后续再想加真正的主键,就要重建整张表。所以建表时老老实实给个代理主键,别指望 InnoDB 兜底。
3.2 Oracle:主键约束默认自带唯一索引
Oracle 里执行:
CREATE TABLE students ( student_id NUMBER PRIMARY KEY, name VARCHAR2(50) );Oracle 会自动为 STUDENT_ID 创建一个唯一索引,索引名和主键约束名一致。你可以通过USER_INDEXES看到这个索引。如果想复用已有索引来支撑新主键,可以:
ALTER TABLE students ADD CONSTRAINT pk_students PRIMARY KEY (student_id) USING INDEX idx_students_existing;热词里提到"Oracle 主键无效化后会怎样",展开讲一下。执行:
ALTER TABLE students DISABLE CONSTRAINT pk_students;后果有三点:约束的唯一性校验停了,你能插入重复的 STUDENT_ID;底层索引并没有被自动删除,它还在,只是不再作为唯一性约束的检查工具;索引依然能用来查询加速。如果你想启用约束但数据已经出现了重复,ENABLE的时候会直接报 ORA-02437,必须先清理重复数据。
如果你想彻底删掉主键约束,默认情况下 Oracle 会连带把支撑它的索引也删掉。不想删索引,必须显式写:
ALTER TABLE students DROP CONSTRAINT pk_students KEEP INDEX;这个细节很多人不知道,删完约束发现查询开始走全表扫描才反应过来。
3.3 PostgreSQL 和 SQLite:大同小异但各有脾气
PostgreSQL 里PRIMARY KEY会自动创建一个 B-tree 唯一索引,NULL 规则和 MySQL 一致,唯一索引允许多个 NULL。它和 MySQL 最大的区别是索引类型更丰富,你甚至可以让主键走 Hash 索引(虽然一般不建议)。PostgreSQL 还有一个细节:外键如果引用的是唯一约束,在更新被引用列时也会做额外的检查,约束的级联行为要仔细设计。
SQLite 比较特殊。它的INTEGER PRIMARY KEY在大多数表里会变成 rowid 的别名,这时候主键不仅唯一,还直接对应物理行号,查询效率最高。但如果你用一个非 INTEGER 类型做主键,SQLite 并不会自动把它变成 rowid 别名,本质上只是建了一个带唯一约束的索引,物理排列还是按 rowid 来的。这个差异经常导致同样的 SQL 在 SQLite 里聊性能时结论完全不一样。
4. 实操选型:建表时到底该用哪个
4.1 代理主键与自然主键之争
主键到底用自增 ID、UUID,还是业务自然键?我直接给结论:绝大多数业务表用自增 BIGINT 或雪花 ID 这类代理主键,不要用业务字段做主键。
自然主键的典型反面教材是用身份证号、手机号、订单号做主键。问题在于这些业务字段都可能变化:手机号可以换、身份证号存在极少数重号的历史问题、订单号在不同系统合并时可能格式冲突。主键一旦要改,聚簇索引重建,关联外键全部要动,代价极高。
代理主键唯一的缺点是需要额外维护一套生成规则,但对单机自增和分布式雪花 ID 来说,这个成本已经低到可以忽略。不要因为"少一列"就觉得省事,用自然主键的后续维护成本远比多一列高。
如果纠结 UUID 和自增:单库单表、写入以顺序追加为主,选自增;分布式环境、需要全局唯一且无法依赖单点序号,选雪花 ID 或带时间的 UUID 变体。纯粹随机 UUID 做主键我是劝退的,页分裂和索引膨胀会在高并发写入下教做人。
4.2 联合主键与联合唯一索引
复合主键出现在明细表这类场景里,比如订单明细表:
CREATE TABLE order_item ( order_id BIGINT NOT NULL, line_no INT NOT NULL, sku_id BIGINT NOT NULL, quantity INT NOT NULL, PRIMARY KEY (order_id, line_no) );(order_id, line_no) 联合主键保证同一个订单内部行号不重复,同时天然承担了"按订单查明细"的聚簇加速。这个设计是合理的,因为 order_id 已经有独立的订单主表做关联,明细表的主键用于标识明细内的一条记录。
但要注意联合主键的列顺序对查询影响很大。联合索引走的是最左前缀原则,(order_id, line_no) 能加速 WHERE order_id=xxx 和 WHERE order_id=xxx AND line_no=yyy,但单独用 line_no 查就是全索引扫描。如果业务里经常单独用 line_no 过滤,就得额外建一个 line_no 的二级索引,代价和收益要想清楚。
另一种情况是联合唯一索引。比如多租户系统里,每个租户内部的自定义编码不能重复:
CREATE TABLE tenant_code ( id BIGINT PRIMARY KEY AUTO_INCREMENT, tenant_id BIGINT NOT NULL, code VARCHAR(32) NOT NULL, UNIQUE KEY uk_tenant_code (tenant_id, code) );tenant_id + code 联合唯一索引保证了租户内唯一,但跨租户允许相同 code。这种业务约束用主键根本不合适,因为没有租户 ID 时这个编码没有全局标识意义。判断联合主键和联合唯一索引的标准还是那句:这一组列是不是用来唯一标识"记录本身的身份"。
4.3 哪些场景用唯一索引比主键更合适
第一,字段允许为 NULL。主键强制非空,但很多业务字段确实允许空。多个空值要共存,唯一索引是唯一解法。
第二,软删除场景。表结构里加了 is_deleted 做逻辑删除,业务上希望"未删除的数据里,业务编码唯一",但删除后允许复用编码。这时候直接给业务编码加唯一索引是不行的,因为软删的记录还在表里。常见解法是把 deleted 状态做成生成列或把唯一索引改成包含删除标记的复合索引。MySQL 8.0.13 之后可以用函数索引,或者维护一个 deleted_at,配合 NULL 不参与唯一判断的特性:
CREATE TABLE coupon ( id BIGINT PRIMARY KEY, coupon_no VARCHAR(32) NOT NULL, deleted_at DATETIME NULL, UNIQUE KEY uk_coupon_no (coupon_no, deleted_at) );同一 coupon_no 插入第二行时,如果 deleted_at 都是 NULL,会被拦住;软删除时把 deleted_at 写成时间值,该行不再参与冲突判断,因此允许再次插入相同 coupon_no 的新数据。这个技巧非常实用。
第三,全局唯一但非标识的字段。比如支付流水号、对账批次号、单号快照。它们是强业务唯一约束,但表的记录身份已经有自增主键了,这里用唯一索引精准表达。
5. 性能与索引失效:那些坑怎么绕
5.1 哪些写法和场景会导致索引失效
热词里有"哪些场景用会导致索引失效",这个必须展开。先给一个容易误解的前提:所谓"索引失效",很多时候不是索引真的坏了,而是优化器觉得走这个索引不如全表扫描划算,或者你的写法让索引本身无法被高效利用。
对索引列使用函数。最常见:
WHERE UPPER(email) = 'A@QQ.COM' WHERE DATE(create_time) = '2025-01-01'这类写法会破坏索引的排序结构,B+ 树没法按原始值快速定位,MySQL 里基本只能放弃索引。避免方式是把函数移到等号右侧,或者对查询列预先处理。MySQL 8.0.13 起也可以建函数索引,但要小心维护成本。
隐式类型转换。字符串列和数字比较时,MySQL 会把字符串转成数字再比较,这一转,索引就废了。经典案例:手机号列是 VARCHAR,查询写成WHERE mobile = 13800138000,优化器对 mobile 做数值转换,索引失效。正确写法是WHERE mobile = '13800138000'。
LIKE 前置通配符。LIKE '%abc%'没法走索引,因为不知道从哪个前缀开始扫。但LIKE 'abc%'是可以用索引的,很多人一棍子打死说 LIKE 全部失效,这是不对的。
OR 连接非索引列。WHERE id = 1 OR name = 'xx',如果 name 没有索引,优化器很可能干脆全表扫。用 UNION ALL 拆分,或者给 name 也建索引才是正解。
联合索引不满足最左前缀。前面已经讲过,(tenant_id, code) 联合索引,只查 code 时不走索引,只查 tenant_id 时走。
对索引列做计算。WHERE age + 1 > 30这种写法也会让索引失效,把计算拆出去变成WHERE age > 29。
还有一种容易被忽略的情况:优化器主动放弃索引。当索引列区分度太低(比如 status 只有三个值,三分之一的行都是同一个值),优化器算一下发现全表扫反而更快,就会忽略索引。这不是失效,是优化器的理性选择,你要做的是把这类低区分度过滤条件和其他高区分度条件组合,而不是干瞪眼。
5.2 主键查询与唯一索引查询的性能差异
同样是精确查一行:
SELECT * FROM t WHERE id = 100; SELECT * FROM t WHERE unique_col = 'abc';第一条走聚簇索引,一次 B+ 树定位,直接拿整行,没有回表。第二条走二级唯一索引,先在一棵二级索引树里定位到主键值,再回聚簇索引找整行,多一次回表。如果唯一索引是覆盖的(查询列都在索引里),第二条也能免回表。比如SELECT unique_col FROM t WHERE unique_col = 'abc',索引本身就够了。
写入方面,主键和唯一索引都要做唯一性检查。主键的检查在聚簇索引上做,唯一索引的检查在二级索引上做,两块索引都要更新。所以一张表每多一个唯一索引,写入成本都会增加。这也是为什么我不建议无脑给所有字段加唯一索引——写入频繁的表,每多一个唯一索引都是肉眼可见的性能损耗。
另外说一个高并发下的隐形问题:热点行。大量并发同时插入同一个唯一索引值(比如抢同一个手机号注册),唯一性检查会对该索引键加锁,容易引发锁等待或死锁。数据库死锁的热搜词背后,很多就是这么来的。解决方案是控制并发、缩短事务、必要时用插入前先查的幂等逻辑减轻索引冲突。
5.3 大表变更主键的线上处理经验
我接过一个线上事故:某订单表接近 2 亿行,原主键是订单号字符串,业务要改成自增代理主键。当时如果直接在源表上 ALTER,锁表窗口保守估计半小时起步,业务根本扛不住。
最终方案是新建表 + 双写 + 切换:
- 新建订单表,主键改为自增 BIGINT,原订单号列设为唯一索引保证业务唯一性;
- 开启双写,新数据同时写旧表和新表,离线任务把历史数据分批迁入新表,每批几千行,控制 binlog 和主从延迟;
- 校验两表数据量和关键字段一致性,跑对比 SQL,不一致的重跑对应批次;
- 确定一致后,在低峰期做读写切换,切换前短暂停写,确认切换成功后再放开写入。
整个过程花了两个晚上,但业务几乎无感。这个案例核心想说的是:主键是存储结构的骨架,动它等于给整栋楼重新换承重墙,宁可麻烦一点拆墙重砌,也不要在承重墙上直接用电钻。
如果只是加唯一索引,没必要这么兴师动众。MySQL 8.0 的 INPLACE、或者用 pt-osc / gh-ost 这类工具,都可以在线加,但要注意大表加唯一索引如果碰上已有重复数据,过程会失败。所以加之前必须先把重复数据排查并处理掉,这条下面详细说。
6. 常见问题排查与避坑速查
6.1 高频问题与解决思路
| 常见问题 | 原因与结论 | 处理方式 |
|---|---|---|
| 一张表可以有两个主键吗 | 不可以,主键唯一标识 + 聚簇索引唯一 | 需要多组唯一性时用多个唯一索引 |
| 主键列能存 NULL 吗 | 不能,主键约束强制非空 | 可空字段要用唯一索引而不是主键 |
| 唯一索引列能存多个 NULL 吗 | MySQL/PostgreSQL/Oracle 可以,SQL Server 只能一个 | 按数据库类型设计空值策略 |
| 表里已有重复数据,加唯一索引报错 | 唯一索引要求现有数据不重复 | 先清理或合并重复行,再建索引 |
| 删除主键有什么连带影响 | 关联外键失效、Oracle 默认连索引一起删 | 用 KEEP INDEX 保留索引,评估外键影响 |
| 禁用 Oracle 主键约束后还能插入重复吗 | 能,约束停用后唯一性校验停止 | 注意 ENABLE 前要清理重复数据 |
| 唯一索引和唯一约束有区别吗 | 逻辑层面不同,物理上通常由同一索引支撑 | 二者可互换,但唯一约束语义更清晰 |
| 为什么唯一索引查询还是慢 | 可能是回表、索引区分度低或写法导致失效 | EXPLAIN 分析,检查是否覆盖索引 |
关于"表里已有重复数据怎么加唯一索引",我给一个最常用的排查脚本:
-- 找重复 SELECT mobile, COUNT(*) AS cnt FROM customer GROUP BY mobile HAVING COUNT(*) > 1; -- 保留最小 id,删除其余重复项 DELETE FROM customer WHERE id NOT IN ( SELECT MIN(id) FROM customer GROUP BY mobile );注意大表 DELETE 要分批,直接一条大事务删几百万行容易撑爆 undo 和 binlog。建议按主键范围分段删除,每段删完提交一次。
6.2 我的几条实操心得
第一,建表时先把"业务唯一性"和"记录身份"分开列出来。不要等业务上线后才发现邮箱本来不该为空却被设成了主键,也不要为了省事把 varchar 订单号直接当主键。
第二,加唯一索引之前永远先跑一遍重复数据检查。我见过两次事故,都是 DBA 在大表上加唯一索引,跑到一半报 Duplicate entry,回滚特别痛苦。先查 10 分钟,后面省一晚上。
第三,唯一索引的命名规范要立起来。常见习惯是 uk_ 前缀,后面接列名,比如 uk_mobile、uk_tenant_code。主键约束用 pk_ 前缀。线上排查时看到一眼能懂的名字,比什么都强。
第四,MySQL 里给大表加唯一索引,最好用在线工具或者低峰期执行。虽然 8.0 支持 INPLACE,但加唯一索引要扫描全表校验唯一性,期间还是有 IO 压力和锁竞争,监控要盯住。
第五,千万记得 EXPLAIN 验证。很多时候你以为走的索引,实际执行计划里显示的是 ALL 全表扫描。SQL 优化不是靠猜,EXPLAIN 里看到 key 列用的是哪个索引,rows 估算多少,一目了然。我每写一条复杂查询,都会养成先 EXPLAIN 再放行的习惯。
踩过几次坑之后,我现在设计表结构时的默认套路是:任何表先给自增 BIGINT 主键;业务字段里凡是不允许重复的,逐个评估用唯一索引;凡是允许空或者需要软删除复用的,一定用唯一索引而不是主键。这套规则简简单单,但帮我挡住了绝大多数线上数据问题。希望这篇把主键和唯一索引的区别讲透的文章,也能让你的设计少走几个弯。