1. 先搞清楚一个老生常谈的问题:它俩到底是不是一回事
做了这么多年数据库相关工作,几乎每隔一段时间就能看到有人在论坛或者技术群里问:主键和唯一索引到底有什么区别?是不是只要建了唯一索引就能当成主键用?甚至有些刚入行的同学直接把这两个概念画上等号。
我先直接给结论:主键和唯一索引在底层存储上确实有不少交集,但在逻辑语义、约束力度、执行计划和数据组织方式上有本质区别。如果你只是背下来“主键不能为空、唯一索引可以有空值”这种表面答案,那遇到线上性能问题或者数据一致性事故的时候,还是会一头雾水。
这篇内容适合谁看?适合正在做表结构设计、写SQL优化、处理数据去重或者排查慢查询的同学。不管你是用MySQL、PostgreSQL还是Oracle,核心原理都相通,我会把通用逻辑讲清楚,同时针对主流数据库的差异做补充说明。
先说个生活化的类比帮你建立直觉。主键就像一个人的身份证号,全国唯一、一人一个、并且从出生起就固定不变,你用它来唯一定位一个人。唯一索引更像是工号或者学号,它也要求唯一,但你可以在不同公司用不同工号,甚至可以用手机号去关联一个人——它服务于某个具体业务场景,而不是从“身份本质”上去定义这个人。
这背后的差异,远比一句“唯一索引允许NULL”要深刻得多。
2. 概念与机制拆解:主键和唯一索引各自的底层逻辑
2.1 主键的本质:它不只是一个索引
主键从关系模型诞生的第一天起,就是一个逻辑概念。它表示“用哪一个或哪几个字段来唯一标识一条记录”。在数据库实现上,主键会伴随一个唯一索引,但这个唯一索引只是主键的“物理载体”,主键本身还带了更多约束:
- 非空约束(NOT NULL):主键字段不允许出现NULL值,因为NULL代表“未知”,不能用未知值去唯一标识任何东西。
- 唯一性约束(UNIQUE):表内任何两行的主键值不能相同。
- 每张表只能有一个主键:这是关系模型的硬性规定。当然主键可以由多个字段组成,叫复合主键,但它仍然只能存在一个。
- 默认作为聚簇索引的键:在InnoDB这类使用聚簇索引的存储引擎里,主键直接决定了数据在磁盘上的物理排列顺序。也就是说,表里的数据行是按照主键值排序存储的,这个特点对写入性能和范围查询有非常大的影响,后面会详细展开。
从用户角度来看,主键存在的意义是:保证每一行都是可区分的实体。如果你插入两条主键相同的数据,数据库会直接报错,这个约束严格到没有任何商量余地。
2.2 唯一索引的本质:它是为了业务规则服务的
唯一索引是一个物理对象。它存在的目的,是在某个或某几个字段上建立“唯一性规则”,防止业务数据出现重复。比如用户表里的手机号字段,你不想让两个人注册同一个手机号,那就建一个唯一索引。
与主键明显的几个不同点:
- 允许NULL值:在MySQL的InnoDB里,唯一索引允许存在多个NULL值,因为NULL和NULL之间并不被视为相等。在PostgreSQL里同样如此。这引发了一个值得注意的问题:如果业务上某字段“要么为空、要么全班唯一”,唯一索引是能直接满足的,但主键做不到。
- 一张表可以有多个唯一索引:你可以给手机号建一个,给身份证号再建一个,给邮箱再建一个,它们互不冲突,各自独立保证唯一性。
- 默认不改变数据物理排列:唯一索引是二级索引(Secondary Index),它单独维护一份索引结构,索引里存放的是“索引值+主键引用”或者说行指针,数据本身的物理存储顺序不受它控制。
所以从设计意图上看,唯一索引更像是一个业务规则校验器,专门用来回答“这个字段的值是否允许重复”这个问题。而主键回答的是“这一行是什么”。
2.3 一张表只能有一个主键,但主键不一定只有一个字段
这里有个细节很容易被忽略。主键可以是一个字段,也可以是多字段组合。比如订单明细表里,用“订单ID+商品ID”作为复合主键是常见做法。在复合主键的场景下,主键约束的“唯一性”是对组合值而言的,不是对单个字段而言的。
这带来一个连锁影响:主键越复杂,索引体积越大,写入时维护成本也越高。因为所有二级索引的叶子节点都要引用主键值,主键字段多、类型长,每个二级索引都会被迫变大。这也是为什么业内经验强烈推荐“单字段、自增、短类型”作为主键的原因——不是因为它难,而是因为它性价比最高。
3. 二者真正的核心差异:从约束语义到存储结构
3.1 约束语义的对比:身份证号 vs 业务编号
用表格来看会更直观:
| 对比维度 | 主键(Primary Key) | 唯一索引(Unique Index) |
|---|---|---|
| 逻辑定位 | 实体的唯一身份标识 | 业务字段的唯一性校验 |
| 是否允许NULL | 绝对不允许 | 一般允许(视数据库而定) |
| 单表数量 | 只能有一个 | 可以有多个 |
| 是否必然改变数据排列 | 是(聚簇索引场景下) | 否 |
| 能否被外键引用 | 可以 | 可以,但前提是它本身也是唯一约束 |
| 删除/修改难度 | 涉及面广,影响物理存储 | 相对独立,可在线处理 |
| 是否自动创建索引 | 是,而且是数据库自动完成 | 是,但你也可以显式创建 |
| 作用范围 | 整行唯一的保证 | 某个字段或字段组合唯一的保证 |
这里面最核心的一句话:主键是表结构设计的根基,唯一索引是业务规则的延伸。如果你的表结构设计得合理,主键就是一个你不会去动它的稳定锚点;而唯一索引则是可以随时根据新业务需求动态加减的弹性规则。
3.2 物理存储差异:聚簇索引和二级索引的分水岭
在MySQL的InnoDB存储引擎里,主键就是聚簇索引。什么叫聚簇索引?意思是数据行的物理存储顺序和索引顺序一致,表数据本身就存放在主键索引的B+树叶子节点上。你按照主键范围查数据,InnoDB只需要顺序扫描这一段物理区域,效率极高。而普通唯一索引是二级索引,索引的叶子节点只存放索引键值和对应的主键值。你要通过某个唯一索引字段查数据,流程是:先在二级索引的B+树里查到匹配的主键值,然后回表(根据主键再去聚簇索引里找完整行数据),这叫回表查询。
要不要回表,以及回表多少次,对查询性能影响极大。举个实际例子:
-- 假设用户表有主键 id,唯一索引 uk_mobile 在 mobile 字段上 -- 这条查询只需要 mobile 和 id 两个字段 SELECT id, mobile FROM user WHERE mobile = '13800138000';因为id和mobile都存在于唯一索引uk_mobile的索引树上,MySQL可以直接从二级索引里拿到结果,不需要回表,这叫做覆盖索引优化。
但如果这条查询还要查nickname字段:
SELECT id, mobile, nickname FROM user WHERE mobile = '13800138000';那二级索引里没有nickname,MySQL就必须拿着查到的id值,回到聚簇索引里再找一次完整行数据。数据量大时,大量的回表会拉高查询延迟。
理解了这一点,你也就明白了为什么主键选型会影响整张表的性能上限。如果用随机字符串(比如UUID)做主键,InnoDB在插入新行时,数据页会因为随机值导致频繁的页分裂和碎片化,写入性能会明显劣于自增整数主键。而如果用自增整数做主键,新行总是追加到B+树的末尾,页分裂几乎不会发生,写入性能非常稳定。
我实测过一张千万级数据量的表,把UUID主键换成自增整数主键后,纯插入吞吐提升了差不多3到4倍,同时表占用空间也减少了大概25%到30%。这就是聚簇索引选型对存储和性能造成的真实影响。
3.3 逻辑层面的影响:对数据完整性的控制力不同
主键约束是数据库对外承诺的“数据完整性基线”。你只要声明了主键,数据库就会自动做三件事:建唯一索引、加非空约束、指定聚簇索引键。这相当于替你做了全部决定,不给你偷懒的机会。
唯一索引则更像“局部规则”。你可以在一个已经有主键的表上,给身份证号、手机号、邮箱分别建唯一索引,保证各自的业务唯一性,而不影响整张表的数据组织方式。
举个例子。假设订单表结构如下:
CREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, user_id INT NOT NULL, status TINYINT NOT NULL, created_at DATETIME NOT NULL, UNIQUE KEY uk_order_no (order_no), KEY idx_user_created (user_id, created_at) );主键id保证每一行订单都有独立标识。唯一索引uk_order_no保证订单号不重复,这是业务刚需。索引idx_user_created用于加速用户维度的查询。你会发现这三件事的职责完全不同,互相不能替代。如果你试图用order_no做唯一索引来替代主键,虽然也能保证行不被重复插入,但整张表缺少了稳定的聚簇索引键,数据物理组织会变得混乱,外键、关联查询、备份恢复都会受影响。
生产环境里,“用业务字段当主键”带来的教训太多了。最典型的就是手机号做主键:手机号会变、可以注销、还能被运营商回收重新放号。一旦发生变更,你要面对的不只是UPDATE一行数据,而是所有引用这个主键的外键表、二级索引全部要跟着更新。而用自增id做主键,手机号就只是一个普通业务字段,随便改,数据库层面完全不受影响。
4. 实际工程场景里头怎么选,怎么用
4.1 什么时候必须用主键
只要一张表要存储业务实体数据,就应该有主键。下面这些场景尤其严格:
- 需要被其他表外键引用时,主键是外键的唯一合法参照(当然唯一索引也能被引用,但实践上几乎都是主键)。
- **需要精确数据同步、数据对比(如binlog、CDC工具)**时,主键是定位一条记录的关键依据。没有主键的表在同步时经常出现漏数据或者重复数据。
- 需要快速点查单行记录时,主键查询走聚簇索引,通常最快。
- 需要保证每一行都有稳定身份时,比如日志表,虽然你可能会疑惑日志表也需要主键吗?答案是建议有,哪怕它只是自增id。没有主键的日志表在后续做数据清理、去重分析、关联查询时,处处掣肘。
4.2 什么时候加唯一索引更合适
唯一索引的应用场景比很多人想得更宽,只要业务上有“唯一”诉求,但又不适合放在主键位置上的字段,都适合用唯一索引:
- 业务账号类字段:手机号、邮箱、身份证号、微信号、user_name等,它们唯一但可变更。
- 防重提交场景:比如支付表中用“订单号+操作类型”做唯一索引,防止重复支付回调处理时插入两条一样的记录。
- 冗余字段的唯一性兜底:比如在分库分表后,全局ID已经用分布式ID生成器保证了唯一,但为了安全起见,仍然会在分表里对全局ID字段建唯一索引,防止数据重复。
- 业务上不要求非空的唯一语义:比如一个“升级优惠券码”字段,用户可能没领过,那么字段为空;领过的用户每人只能领一个,这时候用普通字段搭配唯一索引,天然合适。
很多人忽略的一点是,唯一索引也是优化器的重要统计信息来源。MySQL优化器在执行SQL时,会利用唯一索引的高区分度来评估行数,从而选出更优的执行计划。如果一个字段有大量重复值但没建索引,优化器可能会误判行数,选了全表扫描。
4.3 复合唯一索引的实际用法
复合唯一索引不只是“多个字段一起唯一”这么简单,它实际上定义了一个去重规范。比如:
CREATE TABLE user_follow ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, follow_user_id INT NOT NULL, created_at DATETIME NOT NULL, UNIQUE KEY uk_user_follow (user_id, follow_user_id) );这张表记录用户关注关系,唯一索引保证了同一用户不能重复关注同一个人。这种写法比在代码里先SELECT再INSERT要可靠得多,因为它把并发场景下的重复问题交给数据库层解决,天然防重。
这里有个设计细节:id这个自增主键在user_follow表里其实是有点冗余的,因为唯一索引本身可以充当行标识。但保留一个主键仍然有价值——如果你后续要做数据修复、批量更新、或者需要按主键定位记录,会方便很多。这也是我在实际设计里倾向于保留主键加唯一索引组合的原因。
4.4 再说说MySQL和Oracle在实现上的差异
同一种概念在不同数据库里,实现细节其实有明显差异,踩过坑的人才知道滋味。
在MySQL的InnoDB中,主键强制非空且自动成为聚簇索引,唯一索引默认不改变数据物理排列。但MyISAM存储引擎不太一样:MyISAM不支持聚簇索引,无论主键还是唯一索引都只是独立的索引文件,数据存储是堆式(Heap)的,与索引顺序无关。这意味着在MyISAM表上,主键的“物理排列优势”是不存在的。好在现代MySQL默认使用InnoDB,MyISAM基本已经退出生产主流。
在Oracle中,主键和唯一约束的实现也很有意思。Oracle里主键实际上就是一个“非空唯一约束”,但它会连同唯一约束一起创建一个同名的唯一索引。在Oracle中,主键无效化(disable)之后,主键约束本身被禁用,但底层唯一索引可能保留也可能被删除,这取决于你禁用约束时是否选择保留索引。很多DBA在这上面吃过亏:禁用了主键约束但没让索引一起失效,结果数据插入了一些重复值之后,想重新启用主键约束时,发现已经无法启用了,因为底层数据已经不满足主键的唯一性要求了。
PostgreSQL的逻辑又略有不同。在PostgreSQL里,主键约束和唯一约束在功能上高度接近,但如果你在某个字段上建了主键,系统会自动创建唯一索引并强制非空;而普通唯一索引则没有非空要求。PostgreSQL允许对唯一索引做部分索引、表达式索引等高级操作,灵活性比MySQL更高一些。
所以在跨数据库迁移时,千万别以为主键和唯一索引的语义可以一比一平移。从Oracle迁到MySQL,如果你的原表里主键是允许NULL的(虽然Oracle里理论上不会出现这种情况),或者用了复合主键带漂移字段,迁移过去之后MySQL可能直接拒绝建表。
5. 索引失效与优化:真正影响线上的细节
5.1 哪些场景会导致索引用不上
这个问题几乎是所有数据库面试里必问的,但在实际工作中,真正理解它并能在写SQL时提前规避的人并不多。我梳理一下最常见的索引失效场景,并解释失效的底层原因:
- 对索引列使用函数或计算:比如
WHERE DATE(created_at) = '2024-01-01',这会让索引失效,因为你改变了索引列的值。正确做法是改写成范围查询:WHERE created_at >= '2024-01-01' AND created_at < '2024-01-02'。 - 隐式类型转换:索引字段是varchar类型,查询条件是整型数字,MySQL会在比较时把字段转成数字,一转换就索引失效。比如
WHERE mobile = 13800138000,当mobile是varchar时,这种写法很可能索引失效。 - 前导模糊匹配:
LIKE '%abc'肯定失效,因为B+树索引是按前缀排序的,无法从不确定的起点开始搜索。LIKE 'abc%'则可以使用索引。 - 联合索引未遵循最左前缀原则:比如索引是(a, b, c),你查询只用b和c,索引基本用不上。优化器的选择是,必须在a上有等值条件才可以顺利用上这个联合索引。
- 对索引列进行运算:
WHERE id + 1 = 10和WHERE id = 9看起来结果一样,但前者索引失效。 - NULL值的处理:有些数据库里,对允许NULL的列做
IS NULL查询是可以走索引的(比如MySQL 8.0.21之后对IS NULL有额外优化),但从经验上看,业务字段尽量设置NOT NULL更有利于索引选择,也能省去很多“NULL还是空字符串”的语义纠缠。
5.2 用EXPLAIN实操判断索引是否生效
纸上谈兵没用,我们直接看实际SQL的执行计划。下面是一个典型的场景:
-- 表结构 CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, mobile VARCHAR(20) NOT NULL, nickname VARCHAR(50), created_at DATETIME NOT NULL, UNIQUE KEY uk_mobile (mobile) ); -- 正常查询 EXPLAIN SELECT * FROM user WHERE mobile = '13800138000';执行计划里,key字段会显示uk_mobile,type为const或者ref,说明走了唯一索引且效率极高。再看另一个查询:
-- 对索引列做了函数操作 EXPLAIN SELECT * FROM user WHERE LEFT(mobile, 3) = '138';执行计划里key变成NULL,type为ALL,说明全表扫描已经发生。这就是函数操作导致索引失效的直接证据。真正排查慢查询时,EXPLAIN是我最常用的工具。遇到线上SQL变慢,第一步就是EXPLAIN,而不是瞎猜或重启。
5.3 死锁与唯一索引的隐蔽关系
唯一索引还有一个很少被提及但非常实际的隐患:并发插入时容易引发死锁。这在做订单、支付类高并发系统时尤其常见。
举个具体场景。支付回调接口同时收到两条针对同一订单的重复请求,都向支付流水表插入记录。表结构如下:
CREATE TABLE pay_log ( id INT PRIMARY KEY AUTO_INCREMENT, order_id VARCHAR(32) NOT NULL, amount DECIMAL(10, 2) NOT NULL, status TINYINT NOT NULL, UNIQUE KEY uk_order (order_id) );两个并发事务同时尝试插入相同order_id的记录。InnoDB在插入时会对唯一索引的gap加锁(间隙锁),用于检查唯一性冲突。如果两个事务在不同间隙插入,然后由于某种原因需要互相等待对方释放间隙锁,就会形成死锁。MySQL会检测到死锁并回滚其中一个事务,但高并发下频繁的死锁会让业务重试量剧增。
我处理过的真实案例里,这种死锁发生频率可能每小时几十次。排查方式是通过SHOW ENGINE INNODB STATUS查看最近的死锁信息,确认锁等待链。解决思路也不是去关闭唯一索引(它必要),而是调整业务逻辑:先对订单级别加分布式锁,或者对同一订单的检查-插入流程做串行化处理,减少锁竞争窗口。
5.4 数据迁移和归档时,主键的唯一性要格外盯紧
还有一个实操细节。你在做数据迁移、分表归档时,主键和唯一索引容易出现两类问题。
第一类:从旧表迁移到新表时,如果新旧主键生成策略不一致(比如旧表是业务编号做主键,新表改成自增id),那么迁移脚本里必须维护新旧主键的映射关系。很多人漏了这一步,导致关联数据全部串号。
第二类:分库分表后,唯一索引的分片键选择很关键。如果你把唯一索引建立在非分片键字段上,插入时无法在单个分片上完成唯一性校验,要么依赖分布式ID方案从源头保证唯一,要么就得接受“最终一致、允许短暂重复”的妥协。这在设计阶段就应该想清楚,不要等到上线后才发现唯一约束形同虚设。
6. 常见问题排雷与经验补丁
6.1 常见问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 主键是自增的,但中间跳号了 | 插入冲突或回滚导致自增值消耗 | 属于正常现象,不必修复;若业务要求主键严格连续,需改造生成策略 |
| 唯一索引建在可空字段上 | 多个NULL不冲突,业务期望“全表唯一”但没有实现 | 业务上若必须“非NULL时唯一”,可配合触发器或改用字段默认空串 |
| 线上误删了唯一索引 | 重复数据开始混入 | 先全表扫描定位重复行,清理后再重建带唯一约束的索引 |
| 主键改为唯一索引后查询变慢 | 数据由聚簇存储变为堆式存储 | 重新建立主键,让表回到聚簇索引组织模式 |
| 两张表join用唯一索引但速度慢 | 唯一索引类型不匹配(varchar vs int) | 统一字段类型和排序规则 |
| 修改主键字段时报错 | 外键或引用表阻止变更 | 按顺序:先移除关联约束,改完再重建 |
6.2 自增主键被删掉后,真的需要重建吗
有一种情况是:为了“优化写入”,直接把原有主键删掉,只保留唯一索引。这几乎是灾难性的操作。在InnoDB里,删掉主键意味着表退化成使用隐藏主键的堆表排列,二级索引全部要重建,曾经有序的数据物理组织被打乱,查询性能很可能不升反降。
我见到过一例,某团队为了“少写一点主键数据”把一张几千万行的流水表主键删了,只留唯一索引。上线没几天,线上高峰期经常出现CPU飙升。后来排查发现,所有关联表都要回表查,二级索引因为底层主键引用失效,聚簇能力荡然无存。最后花了两天时间重建表结构才恢复。这就是典型的只看表层语义、不理解物理结构而埋下的雷。
6.3 唯一索引用来实现“有则更新,无则插入”
MySQL的INSERT ... ON DUPLICATE KEY UPDATE和PostgreSQL的ON CONFLICT ... DO UPDATE都是依赖唯一索引或主键来实现upsert语义的。使用时要注意:触发更新的唯一键可以是主键,也可以是唯一索引。很多人在这个环节会踩坑,比如唯一索引包含了多个字段,ON DUPLICATE时只指定部分字段,可能因为组合唯一约束没匹配上而更新错行。
-- MySQL中通过唯一索引实现upsert INSERT INTO user (id, mobile, nickname) VALUES (1, '13800138000', '张三') ON DUPLICATE KEY UPDATE nickname = VALUES(nickname);这个SQL依赖的主键或 uk_mobile 只要有一个存在冲突,就会走更新路径。如果你的表里同时存在多个唯一索引,而你期望的冲突检测是针对某一个特定的唯一索引,那么用这种语法存在误触发的可能,务必要小心。
6.4 关于索引失效的老问题,再补一个容易被忽略的案例
联合索引最左前缀原则大家都很熟了,但有一个场景容易被忽略:范围查询字段在中间。比如联合索引是(a, b, c),查询条件是WHERE a = 1 AND b > 100 AND c = 5。此时b是范围条件,c的等值条件无法继续使用索引,因为在B+树中,范围查询一旦开始,后面的字段就已经无法继续按索引有序匹配了。所以设计联合索引时,要把等值条件字段放在前面,范围条件放在后面。
另一个容易被忽略的坑是:IN和OR也可能会让索引失效,但这不是绝对的。在MySQL 8.0里,优化器对IN列表做了不少优化,可以做到range扫描。真正需要警惕的是OR连接两个不同列的等值条件,尤其是其中一列有索引另一列没有时,MySQL可能选择全表扫描。
6.5 补充:主键和唯一索引在备份、恢复时的差异
备份恢复场景里,两者也有区别。全库备份的binlog回放是基于主键定位行的。如果没有主键,MySQL在回放UPDATE或DELETE语句时会退化成全表扫描逐行匹配,恢复速度慢得惊人。曾经遇到一个极端案例:一张几十万行的表没有主键,恢复时整整跑了几个小时,而同样数据量有主键的表只需几分钟。所以在做表结构设计时,哪怕是一张临时表,我也建议至少有个主键,这不是教条,而是经验的沉淀。
7. 实操中我个人常用的几个选型原则
多年做数据库设计和优化的经验,最后沉淀下来其实就是几条朴素的判断准则,分享给你参考。
第一条:凡是业务实体表,无条件要有主键。主键优选自增整数,其次雪花ID、分布式ID,最后才是业务字段。
第二条:业务上需要保证唯一但兼具可变更性的字段,用唯一索引而不是主键。
第三条:唯一索引的数量不要贪多。每个唯一索引都意味着写入时的额外唯一性检查开销,索引文件也占用磁盘和内存。一张表三个以内唯一索引是常见状态,再多就要反思业务设计是否合理。
第四条:创建唯一索引时一定要确定好字段是否允许NULL。如果业务含义是“未填写则为空”,允许NULL并用唯一索引没问题;如果是业务上必须存在,就加NOT NULL约束,避免应用层出现脏数据。
第五条:做SQL优化时,EXPLAIN看执行计划是最可靠的第一步。不要凭感觉判断索引是否生效,数据会告诉你答案。
最后再分享一个我在实际排查中养成的习惯:每次新SQL上线前,先扔到测试环境的慢查询日志或者EXPLAIN里看一眼,确认执行计划没有全表扫描、没有filesort、没有临时表,再允许发版。这种前置检查虽然多花几分钟,但换来的线上稳定性是实实在在的。数据无小事,索引选型和解法多一分理解,线上就能少一分折腾。