PostgreSQL主键与唯一约束怎么选?从原理到实战彻底搞懂
身边经常有搞后端的朋友问我主键和唯一约束到底有什么区别网上的说法看着都差不多要么说“主键就是非空且唯一”要么说“唯一约束允许NULL”可落到实际项目里总感觉还是拿不准。这个问题的确值得好好掰扯。把这个问题丢给DeepSeek它能给你背出标准答案但标准答案和能用、会用、避坑中间还隔着大量从真实业务场景里踩出来的经验。我最近在重构一个订单系统时又和这两类数据完整性约束打了一轮交道今天干脆把它们的原理、差异、底层实现和实操细节一次性讲清楚。先给一个结论性认知在PostgreSQL中主键和唯一约束看起来是一对孪生兄弟但它们承担的责任完全不同。主键是一个表的“实体身份”唯一约束则更像一条“业务规则护栏”。理解这个本质区别比死记一堆参数有用得多。这篇文章适合正在做表结构设计、写后端CRUD、处理数据迁移的人也适合准备数据库面试的同学。1. 主键和唯一约束别再混为一谈了1.1 数据完整性约束是什么先把地基打牢PostgreSQL里的数据完整性约束不是某个高级功能而是数据库替你守住的数据底线。往小了说它防止你写入重复的客户编号往大了说它让一张表里的数据在业务层面保持自洽让下游的分析、报表、同步不至于被脏数据带偏。常见的约束类型一共五种NOT NULL 约束保证字段不空UNIQUE 约束保证字段或字段组合不重复PRIMARY KEY 是主键它等于“非空 唯一”CHECK 约束负责检查取值范围FOREIGN KEY 外键约束管理表与表之间的引用关系。这里面主键和唯一约束经常被拿出来比较是因为它们在“唯一性”这一点上表现很像但设计意图和限制条件差得很远。我习惯把一个表理解成一个实体集合。比如一张用户表每一行代表一个真实的用户。如果这张表连“每行是谁”都说不清楚那后续任何关联、更新、去重都会变成噩梦。主键解决的就是这个问题它给每一行一个不可重复、不可为空的标识这就是关系数据库里说的“实体完整性”。而唯一约束更多是在回答业务问题“这个自然键比如身份证号、邮箱、手机号在业务规则上是否允许重复”所以主键往往和表结构设计强相关唯一约束则和业务流程、需求规则强相关。记住这个区分之后你再去看各种参数和语法思路会顺很多。网上那些“主键就是非空唯一”的速食答案只是把表象描述了一遍没有回答最关键的为什么。1.2 主键的语义一张表的“身份证”主键最核心的三个特性非空、唯一、一张表只能有一个。第三个特性很关键它意味着主键不是随便挑一列就能加的你选它就等于在告诉数据库和所有人“这列就是这张表每行数据的身份标识。”在实际设计里主键的选择经常陷入两个极端一种人无脑用自增整数另一种人坚持用业务自然键比如身份证号、订单号。我个人的经验是主键最好是和业务解耦的代理键哪怕是自增bigint或UUID都行。原因很现实自然键存在变更的可能身份证号有历史升位的情况邮箱可能改手机号可能换运营商这些字段一旦作为主键被几十张表的外键引用变更时就是一场灾难。用代理键做主键业务字段想怎么改就怎么改外键关系完全不受影响。至于自然键的“唯一性”需求完全交给唯一约束去管。这其实是关系模型里的标准做法一个表可以先找出所有“候选键”然后挑一个作为主键剩下的候选键全部变成唯一约束。1.3 唯一约束的语义业务规则守卫者唯一约束和主键最大的不同在于它允许NULL而且一张表可以有多个唯一约束。它存在的意义不是标识行而是守住业务规则。举个例子用户表里有一个“绑定手机号”字段业务上要求“已经绑定的手机号不能重复”但允许有人没绑——此时唯一约束是正解因为NULL可以出现多次未绑定用户不会被误伤。再比如订单系统里要保证“同一笔订单号不能出现两次”这通常用唯一约束实现。如果还要升级一点订单可以软删除且删除后订单号允许重新使用那就得用部分唯一索引来实现了这是3.3要展开的内容。所以你可以把唯一约束理解成一个可插拔的规则开关。它不承担实体标识的责任只负责拦截违反业务唯一性的数据。这种职责分离在表结构设计里非常重要。2. 一张表里的细节主键与唯一约束的本质差异2.1 差异对照规则、数量、索引、外键为了让你一眼看清差异我先把对比表放出来。这张表里的每一行都是我实际踩过或者见别人踩过的点值得收藏。对比项主键 PRIMARY KEY唯一约束 UNIQUE语义定位表的实体身份标识业务规则唯一性单表数量只能有一个可以有多个是否允许NULL不允许允许PostgreSQL 15之前默认是否自动创建索引自动创建唯一索引自动创建唯一索引默认索引名表名_pkey表名_列名_key能否被外键引用能能是否属于标准SQL约束是是能否做表达式/部分索引不能直接做约束本身也不能但可用唯一索引绕过这个表格里最容易翻车的就是NULL那一行。很多从MySQL转到PostgreSQL的同学会下意识认为唯一约束和主键一样不允许NULL结果线上数据出现多条NULL记录业务去重时直接炸。这个坑我放到2.2重点讲。还有一个容易混淆的点主键和唯一约束都会自动创建唯一索引所以它们的“检查速度”都很快不是靠全表扫描来判重的。如果你在建表时又手动给同一列加了一个普通索引那就纯粹是重复劳动白白多占磁盘和写入开销。2.2 唯一约束里的NULL比你想象的更“狡猾”为什么唯一约束允许NULL因为SQL标准里NULL表示“未知”而两个“未知”并不相等。也就是说唯一约束在判断冲突时只会对比那些非NULL的具体值NULL之间互不冲突。这个特性大多数时候是好事比如用户邮箱字段只有填写了邮箱的人需要保证邮箱唯一没填邮箱的NULL行可以随便存在。但反过来想如果业务要求“未填写的空值也同一个标识只能出现一次”那唯一约束就直接失效了。比如一个问卷表参与人字段为空时你希望空记录也不能超过一条怎么办办法有三条。第一把空值统一转换成默认值比如用空字符串然后建唯一约束——但这样会牺牲可读性而且如果真实数据里本来就存在空字符串就会误伤。第二用表达式唯一索引比如CREATE UNIQUE INDEX ON survey(COALESCE(user_id, -1))空值归一化之后再查重。第三PostgreSQL 15及以上版本可以直接写UNIQUE NULLS NOT DISTINCT让唯一约束把NULL当成可比较的普通值从而禁止重复NULL。ALTER TABLE survey ADD CONSTRAINT survey_user_unique UNIQUE NULLS NOT DISTINCT (user_id);这个语法我实际用下来体验很好语义清楚也符合直觉。如果你还在用旧版本建议老老实实做表达式唯一索引。2.3 主键怎么选代理键还是自然键从差异表里延伸出来的一个实际问题主键到底选代理键还是自然键这不是纯理论之争它直接影响你未来改表结构的痛苦程度。自然键就是业务里本来就有唯一标识意义的字段比如身份证号、税号、订单号。优点是无新增列读取时有业务语义缺点是会变、可能包含敏感信息、类型往往不是紧凑的整数索引存储和比较会慢一些。更关键的是自然键一旦被外键引用你要改它的值就必须级联修改所有子表里的外键列这在生产环境几乎等于做一次手术。代理键是为了“当主键”而造出来的列典型是bigint自增、UUID。它没有业务含义所以不会因为业务调整而变化占空间小bigint 8字节B-Tree索引比较快写入有序性能上很友好。UUID虽然体积大一点但在分布式合并、多系统同步的场景下很有优势不用担心撞ID。我对大多数业务表的建议主键一律用代理键业务自然键放进唯一约束。这套组合既能满足表结构稳定又能满足业务流程的唯一性校验是成本最低、扩展性最好的方案。3. 底层实现为什么这两类约束都自带索引3.1 约束背后是B-Tree唯一索引很多人问为什么我的表没建索引查询计划里却出现了Index Scan答案往往就是主键或唯一约束在背后撑腰。PostgreSQL在创建主键时会自动建一个B-Tree唯一索引索引名默认是“表名_pkey”。创建唯一约束时同理自动建一个B-Tree唯一索引默认名“表名_列名_key”。这个自动索引的存在是有必然性的数据库判断一行数据是否重复最可靠高效的方式就是查索引而不是每插入一行就扫全表。所以主键和唯一约束没法“省掉索引”省掉索引等于让约束变成摆设。这也解释了一个常见现象有时候你明明没建索引却觉得某些查询很快。如果那个查询条件恰好命中主键或唯一约束列那数据库已经在利用这个自动索引加速查找了。反过来如果你给主键列又手动加了一个普通索引数据库执行计划里可能用不到它反而每个写事务都要多维护一份索引数据纯亏。3.2 主键、唯一约束、唯一索引三者关系在PostgreSQL里主键、唯一约束、唯一索引这三者很容易被搞混因为它们最终都是基于唯一索引实现的。但严格来说他们属于两层东西主键和唯一约束是“约束”由系统目录记录可以被外键引用唯一索引则是“索引对象”提供唯一性检查能力但它不被系统认为是约束外键无法直接引用它。如果你需要对外提供外键目标那就必须用主键或唯一约束不能只建一个唯一索引。反过来如果只是某些数据需要过滤式唯一比如软删除场景下只想对有效记录做唯一唯一索引才是正解约束做不到。这个关系可以这样记约束是业务层的声明索引是实现层的工具约束需要索引但索引不一定是约束。有一个从索引“升级”成约束的捷径我经常在大表改造时用。先并发创建一个唯一索引不锁表CREATE UNIQUE INDEX CONCURRENTLY idx_users_email_unique ON users (email);索引建完且数据校验通过后再把它挂成约束ALTER TABLE users ADD CONSTRAINT users_email_key UNIQUE USING INDEX idx_users_email_unique;这样就能避免直接ADD CONSTRAINT时长时间锁表。注意用这种方式挂约束时索引名称会被约束名接管所以两个名字建议保持一致避免后续排查时对不上。3.3 唯一索引的隐藏优势部分索引与表达式索引唯一索引比唯一约束更强的地方在于它能做部分索引和表达式索引这两招可以精准处理业务规则。部分唯一索引适合“有效范围内唯一”的场景。比如订单表里有一个status字段业务要求同一个订单组内只能有一条状态为ACTIVE的记录但历史订单、被取消的订单可以重复。你只需要给有效记录加一个唯一性护栏CREATE UNIQUE INDEX idx_order_active_unique ON orders (group_id) WHERE status ACTIVE;这样索引体积小而且约束范围精确不会误伤历史数据。表达式唯一索引适合数据源比较脏的场景。比如邮箱注册时用户可能输入大写、小写、或者带空格的邮箱你希望在归一化之后保证唯一CREATE UNIQUE INDEX idx_users_lower_email ON users (lower(email));同理手机号可以去掉空格、数字键可以统一位数后再唯一。这类需求用约束语法写不出来只有唯一索引能做到。所以遇到“特殊唯一性”需求时不要死磕约束直接上唯一索引。4. 实操建表、加约束、迁移这几件事怎么做4.1 建表时推荐的主键与唯一约束写法先看我比较推荐的一种基础建表方式。假设我们要建一张用户表主键用自增bigint业务上要求邮箱和手机号各自唯一CREATE TABLE users ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, email text, phone text, CONSTRAINT users_email_unique UNIQUE (email), CONSTRAINT users_phone_unique UNIQUE (phone) );解释几个选择。GENERATED ALWAYS AS IDENTITY是SQL标准里的自增写法比老的serial更规范它不允许你手动插入id值能防止一些奇怪的写入操作。如果项目里有合并数据库的场景建议改用UUID主键避免不同库的自增id撞车。但纯业务单库系统bigint自增体积小、查询快、写入性能稳定是我最常用的选择。表级约束写法CONSTRAINT users_email_unique UNIQUE (email)比直接在列上写email text UNIQUE可读性好很多。一旦后续要做外键引用、排查约束问题你能从约束名一眼看出它管的是哪列。用Navicat设置时右键表进入设计表切到“约束/索引”标签添加唯一约束并选字段即可思路完全一致。4.2 什么时候该用唯一约束而不是主键建表阶段最纠结的问题通常是这个业务字段唯一性这么强为什么不直接设成主键答案回到2.3如果这个字段存在变化可能、或者本身有更合适的代理键就只做唯一约束。举一个典型例子。课程报名表里有一个报名编码reg_code业务上保证唯一但它的值可能在运营流程中因为系统纠错而被修改过。如果把它设为主键所有子表的外键、所有历史日志里的关联记录都得跟着改如果它只是唯一约束那改的时候只需UPDATE这一行字段外键关系完全不受影响。这就是唯一约束灵活性的最大价值。另外多列唯一约束也是主键覆盖不了的场景。比如要保证“同一个用户在同一天只能领取一次优惠券”单纯单列无法表达要用联合唯一CREATE TABLE coupon_log ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, user_id bigint NOT NULL, coupon_day date NOT NULL, CONSTRAINT uq_coupon_user_day UNIQUE (user_id, coupon_day) );这种组合约束表达的是“多个列合在一起的唯一性”是业务规则里最常见的形态。反过来如果设计成复合主键外键要引用两列麻烦程度直接翻倍我一直不推荐没有足够理由时使用复合主键。4.3 存量表加约束先查重少锁表给一张已经跑了一年、数据几十万行的表添加主键或唯一约束千万不能直接抄建表语法就跑。存量数据里隐藏的重复值会直接让约束创建失败错误信息虽然清晰但你已经浪费了一次全表扫描和一次锁表时间。我的标准流程分三步。第一步查重定位候选键列里有没有重复值。SELECT email, COUNT(*) FROM users GROUP BY email HAVING COUNT(*) 1;如果查出来有重复先清洗数据该合并合并、该失效失效。第二步根据数据量和业务容忍度决定用普通方式还是并发方式。数据量小、低峰期可以接受短暂锁表直接执行ALTER TABLE ... ADD CONSTRAINT ... UNIQUE (email)。数据量大就用3.2提到的CREATE UNIQUE INDEX CONCURRENTLY加ADD CONSTRAINT ... USING INDEX两步走。第三步加完约束后立刻验证。验证方法很简单正常插入一条重复数据预期收到duplicate key value violates unique constraint的报错再查一下约束是否出现在系统目录里SELECT conname FROM pg_constraint WHERE conrelid users::regclass AND contype IN (p, u);这个过程说起来快但每一步都有坑。比如清洗重复数据的UPDATE语句本身可能又触发其他约束用CONCURRENTLY建索引时如果数据里有重复值索引会创建失败并留下一个无效索引需要手动删除。第二次遇到这些情况你会淡定很多但第一次最好在测试环境完整演练一遍。5. 常见问题与排查实录5.1 我的表没有建索引为什么查询计划出现了Index Scan这个问题我回答了不止三遍。核心原因就是主键或唯一约束自动创建的唯一索引在起作用。比如你对主键id做一个等值查询规划器发现唯一索引能直接定位到行自然就走Index Scan或Index Only Scan。这对性能通常是个好消息不用额外建索引。但要注意自动索引只会建立在约束列上。如果你的查询条件用的是其它非约束列哪怕那列业务上经常查也不会被自动覆盖该建索引还是得建。不要把主键自动索引当成所有查询的万能药。5.2 外键引用唯一索引为什么报错PostgreSQL里想让一个字段做外键引用目标目标必须是主键或唯一约束不能是普通唯一索引。很多人在多列场景里建了一个唯一索引然后去做外键引用报错之后一脸懵。解决办法很直接把唯一索引升级成唯一约束。如果只是普通唯一索引先ALTER TABLE ... ADD CONSTRAINT ... UNIQUE USING INDEX 索引名把它变成约束再创建外键。如果你根本不需要外键只是单纯想在子表写入时做关联校验那也可以靠应用层逻辑解决不一定要动数据库约束。这里插一句题外话有些人从Oracle切过来后习惯找“禁用主键”的操作。Oracle里约束可以DISABLEPG里没有这个语法你只能DROP约束再ADD回来。所以如果听到“让主键失效”这种说法换成PG语境就是删掉主键再重建代价通常比Oracle大线上操作更要谨慎。5.3 主键和唯一约束重复建白白浪费一个索引有些表里同一个字段既是主键又额外加了一个唯一约束理由是“更保险”。实际上主键本身已经是非空唯一约束再加一个唯一约束等于同样的唯一性逻辑维护了两份索引每个写入事务都要更新两棵B-Tree纯属浪费。正确的做法是只保留主键。如果你想要一个更友好的约束名可以给主键约束显式命名id bigint GENERATED ALWAYS AS IDENTITY, CONSTRAINT pk_users PRIMARY KEY (id)多余的唯一约束尽早清理。用上pg_constraint查一下约束列表找出名称和列重复的约束确认没被外键引用后DROP掉。5.4 批量导入时如何避开唯一约束的“拦截”导入十万条数据中间夹着几条重复记录或脏数据唯一约束会立刻报错中断整个事务这是数据清洗工作者最常见的痛苦。处理原则是“提前消化不要硬闯”。先把数据放到 staging 导入表用分组统计找出重复项清洗后再往正式表插入。如果你用的是PG原生命令或ETL工具可以分批提交把错误范围控制在单批内部。还要提醒一个细节SET CONSTRAINTS ALL DEFERRED只对声明为DEFERRABLE的约束生效。默认的唯一约束、主键都是立即检查的临时执行这个命令不会有任何效果。真要延迟检查建约束时就必须加DEFERRABLE INITIALLY DEFERRED子句。延迟约束适合会循环引用的复杂写库场景日常批量导入不要指望它救急老老实实清洗数据才是正道。写在最后的实际操作体会这几轮项目做下来我最大的感受是主键和唯一约束的选择不是一个语法问题而是一个设计取舍问题。主键负责稳定地标识每一行所以越无意义越好唯一约束负责拦截业务上的重复所以越贴近规则越好。两者配合才能既保证表结构不频繁变动又让数据质量长期可控。最后再分享一个小技巧给每个唯一约束命名时建议统一用约束名_表名_列名的格式比如uq_users_email。时间久了一张表上会有主键、外键、多列唯一约束、表达式唯一索引没有一套命名规范排查数据导入失败的问题会非常痛苦。这个习惯让我少加了好几个小时的班。