
如果让我挑一个数据库里看起来简单、实际上坑最多的问题“主键和唯一索引的区别”绝对能排进前三。面试里被问到开发里天天用但很多工作三五年的同学依然会在“能不能用唯一索引代替主键”“为什么主键不能是NULL”这类问题上卡壳。这篇文章我想把这两个概念的底层差异彻底拆开从声明方式、存储结构、行为习惯到设计选型都过一遍最后给出我平时建表时的一套固定做法。无论你用的是 MySQL、PostgreSQL 还是其他主流关系型数据库只要把底下这几层逻辑吃透遇到具体方言差异时自己就能判断。1. 声明方式的差异只是表象约束语义才是分水岭1.1 两种写法在 SQL 层面的不同表现先说最直观的语法。主键在表结构里通常这样声明CREATE TABLE user ( id INT PRIMARY KEY, email VARCHAR(100) UNIQUE );也可以写成表级约束的形式CREATE TABLE user ( id INT, email VARCHAR(100), PRIMARY KEY (id), UNIQUE KEY uk_email (email) );从索引的角度看主键会自动创建一个唯一索引所以很多人就得出结论主键就是加了非空限制的唯一索引。这个说法方向对但不够精确。主键本质上是实体完整性约束它保证每一行都能被唯一标识而唯一索引本质上是候选键约束它保证某个列或列组合的值不重复。约束语义不同数据库对它们的处理方式就不同。如果你去翻 MySQL 的官方文档会发现主键被描述为NOT NULL UNIQUE的特殊形式。这句话很容易让人产生“主键约等于唯一索引”的错觉但它只强调了约束组合忽略了主键在存储引擎层面的特殊地位。做开发尤其是做数据库设计时需要始终记住唯一索引只负责“不重复”主键除了“不重复”还负责“我是这行数据的身份标识”。1.2 非空约束为什么和主键绑定在一起唯一索引允许 NULL而且允许多个 NULL 共存。主键列则完全禁止 NULL。差异背后的逻辑很实在NULL 表示“未知”两条 NULL 到底相不相等数据库无法判断所以唯一索引对 NULL 是网开一面的。如果主键也允许 NULL就会出现一行数据没有身份标识的情况这违背了“每一行都必须能被唯一寻址”的基本假设。举个实际例子下面这张表是可以正常建出来的CREATE TABLE t ( id INT PRIMARY KEY, code VARCHAR(20) UNIQUE ); INSERT INTO t (id, code) VALUES (1, NULL); INSERT INTO t (id, code) VALUES (2, NULL);两条记录的 code 都是 NULL插入完全成功不会报重复键错误。如果业务语义要求“这个字段要么有值有值就唯一”那么唯一索引加 NULL 的特性恰好符合。但如果业务语义要求“这个字段绝对不能为空并且不能重复”那就必须在代码层面额外做空值校验或者直接改用主键。这也是一个容易被忽略的经验点线上排查“唯一索引怎么允许重复了”的工单十有八九是 NULL 造成的。等到数据污染已经发生再想去补一个非空约束就需要先清理历史数据操作窗口和成本都会放大。1.3 每张表只能有一个主键但唯一索引可以有很多个这个限制从关系模型的角度非常好理解身份标识只要一个就够但业务上的“不重复”需求可以有很多个。比如用户表的 id 是主键同时手机号不能重复身份证号不能重复这两个需求可以分别建唯一索引CREATE TABLE user ( id BIGINT PRIMARY KEY, mobile VARCHAR(20) UNIQUE, id_card VARCHAR(30) UNIQUE, ... );数据库只允许一个主键却允许在同一张表上创建多个唯一索引。这个差异在日常开发中最常引发的问题是建表时没想清楚把手机号列设成了主键后来发现业务还需要按 id 关联于是又给 id 列建唯一索引。结果是主键和唯一索引的角色颠倒了外键关系、ORM 映射全都要绕路。我之前接手过一个老系统业务表的主键就是手机号后来因为一个用户会换手机号每次换号都要 UPDATE 主键值。主键一旦变更所有引用它的外键关系都要级联合法性检查一行数据在聚簇索引里的物理位置也要挪动。那会儿才真正意识到主键不能由业务可变字段来承担这是一个设计原则问题而不是“能不能用”的问题。2. InnoDB 存储引擎下一个长在聚簇索引上一个只是二级索引2.1 聚簇索引主键决定了行的物理组织方式讨论 MySQL 时默认引擎基本都是 InnoDB这一点必须先说清楚。InnoDB 是索引组织表数据文件本身按照主键构建的 B 树来组织。主键索引的叶子节点存放的是完整的数据行也就是说整张表的数据就是按主键顺序排列的。主键在 InnoDB 里天然是聚簇索引而不只是一个“带了唯一约束的索引”。这意味着主键的选择直接决定数据行的物理存储方式。如果你用自增整数做主键新插入的行总是追加到 B 树的最右侧写入局部性好页分裂概率低。如果你用 UUID 字符串做主键每次插入都是随机位置B 树会频繁发生页分裂索引维护成本高缓冲池命中率也会下降。很多初学者不理解为什么 DBA 天天强调“主键要短、要有序”归根结底就是因为聚簇索引的物理特性。索引不只是逻辑结构它直接影响磁盘 IO 和内存缓存的效率。2.2 二级索引的叶子节点里装的是主键值唯一索引在 InnoDB 里是二级索引它的叶子节点不存数据行而是存主键值。查询时如果索引列不满足覆盖索引条件就需要根据主键值回到聚簇索引里再查一次这就是我们常说的“回表”。用查询举例SELECT * FROM user WHERE mobile 13800138000;如果表上有uk_mobile唯一索引这条语句会先走二级索引找到对应的主键 id再用这个 id 回聚簇索引找整行数据。而SELECT * FROM user WHERE id 10001;直接走聚簇索引一次定位就能拿到整行。这个差异在单行查询时几乎无感但在范围查询、批量扫描场景下回表次数会被放大。覆盖索引的优化思路也因此而来如果你只需要返回 mobile 和 id那么直接让查询走uk_mobile就够不需要回表。这里要破除一个常见误解“唯一索引一定比普通索引快”是不成立的。唯一索引由于需要校验唯一性插入时会多一次查找确认更新时锁竞争也更敏感。查询时它的 B 树结构和普通索引没有本质差别复杂度同样是 O(log n)唯一性并不会带来速度优势。真正影响查询性能的是索引能不能覆盖查询、列的顺序、过滤度这些因素。2.3 没有显式主键时InnoDB 会自己“造”这是很多人不知道的细节。如果一张表没有显式主键InnoDB 会先找一个“非空的唯一索引”作为聚簇索引如果连这个也没有就会生成一个隐藏的 6 字节 rowid 作为主键。这个行为带来的直接影响是你建一张表既没有主键也没有非空唯一索引依然可以读写但逻辑上少了可靠的身份标识。binlog 里的行定位、恢复工具的行匹配、Online DDL 的执行策略都会受影响。表面上表能跑底层其实已经开始积累技术债。另一个场景是给已有表加主键。如果原表恰好有一个非空唯一索引被 InnoDB 选成了聚簇索引后来你 ADD PRIMARY KEY 时InnoDB 可能会重建整张表因为聚簇索引变了。这个操作在大表上是灾难级的锁表时间可能以小时计。这也是为什么我强调建表时就显式定义主键而不是依赖 InnoDB 的“备用方案”。3. 空值、外键和更新代价日常开发中差异最明显的三个行为3.1 NULL 值处理唯一索引的“通融”和主键的“零容忍”上一节提过 NULL 差异但这里要展开说一个很容易踩坑的并发场景。唯一索引对 NULL 的“通融”不只是插入多条 NULL还会影响唯一性约束的冲突检测。假设业务表用 email 做唯一索引注册接口在并发下收到两个空邮箱的请求。如果不小心把空字符串传成了 NULL两条记录都能插入成功。等业务方发现邮箱字段大量是 NULL想要把 NULL 改成实际邮箱时才会体会到什么叫数据清洗的痛。这里我的建议是唯一索引列的默认值尽量设置成业务上不可能出现的值而不是 NULL如果一定要允许空值那就必须接受“空值不算重复”的约束语义。有些数据库方言提供了额外选项比如 PostgreSQL 可以用NULLS NOT DISTINCT让 NULL 也参与唯一性比较MySQL 8.0 目前没有这个便捷选项只能通过生成列或应用层兜底。如果你在设计阶段就明确“该字段必须唯一且不能为空”那最稳妥的方案就是做成主键或者加NOT NULL约束后再建唯一索引而不是把唯一性判断交给应用层。3.2 外键关联被引用列必须满足的限制外键约束要求在父表上引用一个“唯一标识列”。大多数数据库要求被引用的列要么是主键要么是受唯一约束保护的列。MySQL/InnoDB 的官方文档里写得很明确被引用列必须建有索引并且这个索引的列顺序和外键定义一致。这里出现一个很常见的误区有人觉得只要建了普通索引就能被外键引用。实际情况是被引用列的值必须唯一普通索引只保证“能找到”不保证“不重复”。如果父表的被引用列出现重复值外键约束就无法建立或者建立后产生不可预期的行为。所以在外键设计上主键是默认选项唯一索引是备选项。多对多关联的中间表通常就是用两个外键列组成联合主键或者用自增主键加联合唯一索引两种方案各有取舍。前者省去一个冗余 id 列行定位更直接后者更方便 ORM 映射也方便后续在中间表上增加更多业务字段。没有绝对的对错但你要清楚自己选择了哪种组合约束。另外特别提醒子表上创建外键时外键列本身也要建索引。否则父表发生 DELETE 或 UPDATE 时InnoDB 需要扫描子表来检查外键约束代价极高。这个问题和“主键 vs 唯一索引”的关系不直接但和外键设计高度相关顺手提一句。3.3 更新与删除的代价换主键值比换唯一索引值伤筋动骨得多主键值的变更会带来连锁反应聚簇索引中数据行的物理位置可能要移动所有二级索引叶子节点里存储的主键值都要更新外键引用关系要逐条校验。这还没算上并发场景下的锁竞争和复制延迟。唯一索引值的变更则温和一些它只涉及二级索引叶子节点的更新不需要移动数据行。比如用户换手机号UPDATE mobile 就可以了聚簇索引里只有主键 id 不变化。这又一次印证了那个设计原则能否被业务修改是判断一个字段适不适合当主键的重要标准。主键设计得好业务变更时你只是更新一个普通字段主键设计得不好一次业务变更就变成一次 DDL 级的数据重整。数据库的很多运维事故本质上都不是数据库不行而是最初建表时把不该当主键的字段设成了主键。4. 实战选型什么场景必须用主键什么场景只建唯一索引就够4.1 必须用主键实体表、关联表和 ORM 映射先给一个分类实体表、关联表、审计流水表默认都应该有主键。实体表指的是用户、订单、商品这类核心业务对象它们需要被外键引用需要被 ORM 识别必须有一个稳定且不可为空的唯一标识。关联表多对多中间表如果不用联合主键也可以用自增主键加联合唯一索引但至少要有一种方式能唯一定位一行否则后续删除、去重、排查数据都会很麻烦。ORM 框架对主键的依赖也值得注意。Hibernate、MyBatis-Plus、Sequelize 这些框架默认都要求实体类有主键字段否则批量更新、按主键查询、乐观锁版本控制等功能会受限。你当然可以绕过这些约定去写原生 SQL但那就等于放弃框架能力增加了维护成本。分区表场景更特殊。在 MySQL 里分区键必须包含在主键或唯一索引中。这个限制本质上是想保证跨分区唯一性可以被快速校验。很多人建分区表时忘了这一点导致后续想加分区却被提醒“分区列必须包含在主键中”只能推倒重建。这个坑在最初设计主键时就该避开。4.2 只建唯一索引就够业务幂等键与附属属性有些字段只承担“业务上不能重复”的职责不需要成为行身份标识。典型例子包括手机号、身份证号、邮箱、订单业务号、第三方回调的交易流水号。以订单表为例常见设计是CREATE TABLE orders ( id BIGINT PRIMARY KEY, order_no VARCHAR(32) UNIQUE, ... );这里的 order_no 是业务上需要展示、需要去重的单号但它不参与表之间的外键关联也不承担身份标识职责。让它成为唯一索引而不是主键可以保留 id 主键的高效聚簇存储同时让 order_no 的插入校验得到唯一性保障。幂等场景是唯一索引的高光时刻。支付回调、消息消费这类场景经常需要“同一条业务消息只能成功处理一次”。如果没有唯一索引兜底并发重复请求会两次插入同一条记录。建一个业务流水号唯一索引后第二次插入会直接报 Duplicate 错误应用层捕获这个错误做幂等处理即可比用分布式锁简单可靠得多。4.3 我的双键设计习惯代理主键加业务唯一键我个人建核心业务表时习惯采用“代理主键 业务唯一键”的组合。代理主键通常是一个自增 BIGINT 或分布式环境下生成的雪花 ID它没有任何业务含义只为保证行的稳定唯一。业务唯一键则是那些线上真正会拿来查询、校验、去重的字段。这样设计的好处是主键永远不需要被业务修改聚簇索引的物理稳定性有保障业务唯一键承担唯一性约束查询时走二级索引完全够用外键关联一律使用代理主键不会出现因为业务字段变更导致外键关系断裂的情况。需要说明的是这个设计不是唯一正确方案。比如某些场景下业务单号本身就具备全局唯一性也几乎不会变更直接拿它做主键完全可行。核心要思考的不只是“能不能”而是“后续能不能稳定”。建表时把主键当成不可变假设把唯一索引当成业务规则的落地大部分坑就能避开。5. 最容易踩的坑随机主键、超长主键、组合列顺序5.1 随机主键引发的页分裂与写放大UUID 字符串做主键是新手最常见的问题之一。Oracle 和 MySQL 早期教程里常见用 UUID 做主键导致很多人沿用了这个习惯。主键如果是随机字符串插入时新的主键值落在 B 树中随机位置InnoDB 不得不经常分裂页、重排数据。写入吞吐量在数据量上来后会明显下滑碎片率也会升高。如果确实需要全局唯一 ID优先级排序可以是自增整数 雪花 ID 有序 UUID 随机 UUID。MySQL 8.0 里可以用UUID_TO_BIN()函数把 UUID 转成 16 字节二进制存储能在一定程度上改善随机性带来的性能问题。如果项目已经上线且主键是随机 UUID重构成本会很高但至少要知道下一次设计时怎么避开。5.2 超长主键和组合主键二级索引的“寄生负担”二级索引叶子节点里存储的是主键值这意味着主键越长所有二级索引占用的空间就越大。比如用VARCHAR(64)做主键索引体积会比BIGINT大好几倍缓冲池能装下的索引页数量也相应减少。这个成本在主键上不明显但会通过二级索引传导到每一次查询和写入。组合主键的情况更复杂。组合主键的列顺序会影响聚簇索引的物理排序也影响二级索引中存储的“组合主键值”的长度。如果一张表的联合主键由三个 VARCHAR 字段组成任何一个二级索引里都要装这三个字段的值放大效应非常显著。我的经验是能不用组合主键就不用业务唯一性可以交给联合唯一索引行身份标识继续用单列主键。5.3 组合唯一索引的列顺序等值查询与范围查询的差别同样一组列UNIQUE KEY uq_a_b (a, b)和UNIQUE KEY uq_b_a (b, a)在唯一性语义上等价但在查询场景上是不同的。最左前缀原则决定了索引能帮到哪些查询模式前者适合WHERE a ?和WHERE a ? AND b ?后者适合WHERE b ?和WHERE b ? AND a ?。如果你在中间表上建联合唯一索引却没有仔细考虑两个外键列谁先谁后很容易出现“索引建了但实际查询没走索引”的情况。排查时用EXPLAIN看一眼就能发现。列顺序的选择依据是实际的查询比例优先把等值查询最频繁的列放前面。5.4 给已有表补主键或建唯一索引时的操作窗口如果建表时没设计好后面要补救一定要警惕锁表和复制延迟。直接执行ALTER TABLE ADD PRIMARY KEY在大表上会导致长时间元数据锁期间该表的读写都会被阻塞。 MySQL 8.0 的 Online DDL 支持部分主键操作但在修改聚簇索引时仍然需要重建表。比较稳妥的做法是使用 gh-ost 或 pt-online-schema-change 这类在线变更工具先在备库上完成结构变更再切换流量。虽然工具用起来多一步流程但相比凌晨三点被报警电话叫醒这个成本算很低了。如果只是想加唯一索引但数据里已经存在重复值直接 ALTER TABLE 会直接报错。必须先找出重复数据并清理SELECT mobile, COUNT(*) FROM t GROUP BY mobile HAVING COUNT(*) 1;清理时建议先备份、保留一条、再分批处理重复记录。直接在重复数据上改值或删除后才能成功创建唯一索引。6. 我保留下来的一套做法与建议6.1 建表模板一张业务表的常规配置下面是我平时写业务表结构常用的模板逻辑上兼顾了主键和唯一索引的角色划分CREATE TABLE user_account ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 代理主键, mobile VARCHAR(20) NOT NULL COMMENT 手机号, email VARCHAR(100) DEFAULT NULL COMMENT 邮箱, status TINYINT NOT NULL DEFAULT 0 COMMENT 状态, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_mobile (mobile), KEY idx_status_create_time (status, create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户账户表;几个细节说明一下。id 用BIGINT UNSIGNED而不是INT因为只要业务足够久INT 的上限很快会不够用。UNSIGNED可以让正数范围翻倍。mobile 设为 NOT NULL同时建唯一索引确保业务上“手机号不能为空也不能重复”。email 允许 NULL唯一索引对 NULL 放行这样未绑定邮箱的用户不会因为多个 NULL 而冲突。create_time 和 update_time 直接由数据库维护避免应用层时间不一致的问题。有人会问BIGINT不是比INT占空间更大、二级索引更肿吗确实更大但 8 字节和 4 字节的差距远小于业务字段中途穷尽 INT 后需要迁移主键的成本。数据库设计里最贵的操作是迁移而不是存储。6.2 关于主键生成策略的一点个人保留意见自增主键在单库单表时代最省心但分库分表后会遇到全局唯一 ID 的需求。这时我一般推荐雪花 ID 或类似思路的分布式 ID。雪花 ID 虽然是 64 位整数但它不像随机 UUID 那样完全无序只要保证同一毫秒内的序列有序整体上仍然具有“趋势递增”的特性对聚簇索引的性能影响很有限。如果团队不希望引入额外的 ID 生成中间件还有一种低成本方案用一台数据库维系一张发号表各业务系统批量获取 ID 段。这种方式在高并发下会带来对发号表的竞争但胜在实现简单、可控性好。选择哪种方案取决于业务规模而不是单纯的技术偏好。6.3 收个尾先想清楚“身份”和“规则”的分工主键解决的是“这行数据是谁”唯一索引解决的是“哪些字段不能重复”。这两句话几乎可以回答所有主键和唯一索引的选择问题。不管数据库方言怎么变、存储引擎怎么更新这个底层逻辑是稳定的。建表之前多花几分钟想一下“这个字段会被业务修改吗会不会被外键引用NULL 是否有业务含义”后面省下的运维时间会是这个几分钟的几十倍甚至上百倍。如果你正在维护一套历史系统里面确实存在把唯一索引当主键、或者没有主键的表也不用急着一次性重构。先搞清楚这些表的读写模式、外键依赖、数据量级再按优先级逐个优化。数据库最怕的不是设计有问题而是问题已经存在却没人愿意面对。