PostgreSQL唯一约束与唯一索引:底层机制、操作差异与场景选择
发布时间:2026/10/19 5:02:21 锦皓数字建站

PostgreSQL 里有个特别常见的混淆点UNIQUE CONSTRAINT唯一约束和 UNIQUE INDEX唯一索引到底是不是同一个东西我见过太多人在这个问题上栽跟头。刚入行时在某个数据服务平台同事排查一个线上问题说我建了唯一索引为什么约束没生效隔了几天又有人问我加了 UNIQUE 约束为什么多出来一个索引是不是能删掉省点空间。这些问题看起来只是概念理解上的小偏差真到操作层面轻则报错、重则数据出重复——毕竟唯一性这个东西拦的是脏数据进门。先说结论在 PostgreSQL 里这两个概念紧密相关但绝对不是一回事。唯一约束是一个逻辑规则唯一索引是实现这条规则的物理结构。PostgreSQL 为了让你省心在创建唯一约束时会自动帮你建一个唯一索引但反过来你手动建一个唯一索引它却不一定是一个约束。这篇文章我想把这块掰开揉碎讲清楚看完你能明白它们的底层机制、操作差异、删除时的连带效应以及到底该在什么场景下用哪个。1. 名字几乎是同一个东西先理清逻辑层与物理层1.1 从一道日常送命题说起假设你有张用户表CREATE TABLE t_user ( id bigserial PRIMARY KEY, user_email text, user_name text );业务上要求user_email不能重复。大多数人会顺手这样写ALTER TABLE t_user ADD CONSTRAINT uk_user_email UNIQUE (user_email);然后你去看表结构\d t_user会发现 PostgreSQL 不仅加了约束还自动带了一个索引索引名和约束名一模一样都是uk_user_email。这时候问题来了这个自动生成的索引是多余的吗能删吗我直接告诉你答案不能删。你试图DROP INDEX uk_user_email的时候PostgreSQL 会给你一个索引上有依赖约束之类的报错根本删不掉。这就是逻辑规则和物理结构绑在一起的直接体现。为了理解这件事可以把数据库拆成两个层面看逻辑层约束是规则它声明了数据必须满足什么条件。唯一约束声明的是整张表里这一列或几列的值不能重复。这是关系模型层面的东西是业务规则的载体。物理层索引是实现它负责用具体的存储结构在 PG 里绝大多数是 B-tree去加速查询、去承载唯一性检查。没有这个索引数据库只能每次插入时全表扫描判断有没有重复效率低到没法用。所以 PostgreSQL 的设计非常直白约束定规则索引做执行。你只说了要唯一数据库就自动把能实现唯一的工具也给你造好这个工具就是唯一索引。1.2 约束和索引在元数据里就分家了哪怕它们长得像在系统表里两者的户口也是分开的唯一约束的元数据记录在pg_constraint索引的元数据记录在pg_class/pg_index里而它们之间的关系靠pg_depend来建立依赖关联。你可以在 psql 里执行SELECT conname, contype, conrelid::regclass FROM pg_constraint WHERE contype u;这里的contype u代表唯一约束。而想查索引则是看pg_indexes或者pg_classSELECT indexname, indexdef FROM pg_indexes WHERE tablename t_user;虽然查到的名字可能是同一个uk_user_email但它在元数据层面分别以约束和索引两种身份被登记。理解这一点后后面所有的差异都顺理成章了。2. 唯一约束为什么自动带出一把索引底层实现机制拆解2.1 唯一性检查到底是怎么跑的从执行路径看PostgreSQL 在插入或更新一行数据时如果目标表上有唯一约束它会走这样一个流程收到INSERT/UPDATE语句解析器生成执行计划执行器发现这些行涉及唯一约束在ExecInsert阶段用待插入的键值去唯一索引中查找是否存在相同键值如果 B-tree 索引中已经存在对应条目直接报唯一性冲突错误没有冲突则继续插入并同步维护这条索引。也就是说约束本身不参与运行时的数据扫描它真正干活的是它名下的那棵 B-tree 索引。唯一索引承担了查找重复键的重任效率是 O(log n) 级别的而约束的存在只是让 PostgreSQL 知道必须做这一步检查、并且带上这条索引一起维护。这就像你进一家只认预约的餐厅规则约束是没预约不能进但真正执行检查的是门口放了一个预约名单索引。你只关心能不能进但餐厅必须维护好这个名单否则规则形同虚设。2.2 创建约束时发生了什么索引是赠送的不是可选的你可能会想那如果我不想多建索引只想要唯一的规则行不行在 PostgreSQL 的标准用法里不行。看官方文档对UNIQUE约束的说明有一句很关键的话UNIQUE约束会自动创建一个 B-tree 索引这个索引覆盖了约束涉及的列。如果你再手工建一个一模一样的唯一索引那就是白白浪费写入开销。为什么会这样设计因为从实现层面讲PostgreSQL 的唯一性判断逻辑必须依赖索引。在heapam的插入路径里唯一性检查要走index_insert去探测是否冲突没有索引这个检查无从下手。与其让你的约束形同虚设不如替你把这个物理结构造好而且默认就用 B-tree因为它支持等值与范围查找通用性最强。2.3 主键约束其实也是特殊版的唯一约束顺带提一个容易混淆的点主键PRIMARY KEY就是唯一约束加上非空NOT NULL。在pg_constraint里主键的contype是p唯一约束是u但底层都是靠唯一索引来保证唯一性。所以前面讲的约束自动建索引逻辑对主键同样适用。这也是为什么很多人在设计表结构时看到主键字段下自动出现了一个名字类似表名_pkey的索引完全不用惊讶——它是主键约束的执行工具不能随手删。2.4 一个小实验先建索引再加约束 vs 直接建约束如果你动手试一下会发现还有个有趣的现象-- 方式A只建唯一索引 CREATE UNIQUE INDEX uk_user_email ON t_user (user_email); -- 方式B只建唯一约束 ALTER TABLE t_user ADD CONSTRAINT uk_user_email UNIQUE (user_email);方式A建完后\d t_user里会看到一个普通索引但它不叫约束。往表里插重复值时依然会报错——因为唯一索引同样能拦截重复键。但从元数据角度pg_constraint里查不到它外部工具比如某些 ORM 的 schema 同步逻辑也不会认为这张表存在唯一约束。方式B建完后约束和索引同时存在pg_constraint能查到约束pg_indexes能查到索引。这件事直接影响一个问题如果你只关心数据不能重复两种方案效果一样如果你还关心业务规则是否被显式表达只有约束能做到。从数据建模的角度看唯一约束是语义唯一索引是实现两者不能简单划等号。3. 操作层面的差异建、查、删三种场景下的行为对比3.1 建入口不同默认行为不同操作语法是否自动建索引元数据表现创建唯一约束ALTER TABLE ... ADD CONSTRAINT ... UNIQUE(...)或建表时指定自动创建同名 B-tree 唯一索引pg_constraint有记录创建主键约束ALTER TABLE ... ADD PRIMARY KEY(...)自动创建同名 B-tree 唯一索引pg_constraint有记录contypep创建唯一索引CREATE UNIQUE INDEX ...本身就是索引仅pg_indexes有记录pg_constraint无记录建的时候有个非常实用的差异唯一索引可以带 WHERE 条件变成部分唯一索引也可以对表达式建索引。而建表时的唯一约束语法本身不支持这些东西后面要讲怎么绕过。3.2 查想搞清楚表上到底有什么别只查一个地方很多人在排查问题时只盯着\d 表名的输出看。这有一定误导性因为 psql 会在输出里同时展示索引和约束两个列表同一把索引有可能以两种身份出现。推荐这套排查动作-- 1. 查看这张表上的所有索引 SELECT indexname, indexdef FROM pg_indexes WHERE tablename t_user; -- 2. 查看这张表上的所有唯一约束 SELECT conname, pg_get_constraintdef(oid) FROM pg_constraint WHERE conrelid t_user::regclass; -- 3. 如果想看索引和约束的对应关系 SELECT c.relname AS index_name, con.conname AS constraint_name FROM pg_index i JOIN pg_class c ON c.oid i.indexrelid LEFT JOIN pg_constraint con ON con.conindid i.indexrelid WHERE i.indrelid t_user::regclass;注意第三段 SQL 里的LEFT JOIN如果某个索引没有对应约束constraint_name会是 NULL。这就是判断某个唯一索引到底是不是某个约束的底层索引的最直接方法。3.3 删最容易踩坑的地方删除操作是最能体现约束依赖索引的地方我把实际碰到的几种情况列出来删除约束ALTER TABLE t_user DROP CONSTRAINT uk_user_email;这条命令会连坐删除它底下的那个同名索引。因为约束没了对应的物理工具也没有存在的必要了。直接删除由约束自动创建的索引DROP INDEX uk_user_email;大概率会失败提示存在依赖对象即使能删约束规则也就失去依靠了PostgreSQL 不允许你制造这种不一致状态。删除手动创建的唯一索引这个没有约束依赖可以正常删除。删除由主键约束创建的索引同样删不掉因为主键约束依赖它。我在维护某套订单系统时就遇到过同事想清理冗余索引把主键索引给DROP了结果 PG 直接悬了。后来恢复了备份才解决。从那以后我得到一个教训看到不认识的索引不要只看名字先用上一节那段 SQL 确认它有没有依赖自己的约束有依赖关系的索引只能通过删约束来间接处理。3.4 改名约束名和索引名一起变既然约束和索引总是成对出现很多人会好奇ALTER TABLE ... RENAME CONSTRAINT ... TO ...会不会把索引名也一起改实测结果是约束改名后由约束自动创建的索引名也会跟着变。因为 PostgreSQL 默认把约束绑定的索引命名与约束保持一致改名操作会同步处理内部依赖。如果你改的是单独创建的索引名用ALTER INDEX ... RENAME TO ...约束名不会受影响——毕竟根本没有约束。4. 特殊场景决定取舍多列唯一、NULL 值、部分唯一性与表达式索引4.1 多列唯一约束和索引都能做但语义要斟酌业务中经常有同一个用户不能对同一篇文章重复点赞这类需求。表结构可能是CREATE TABLE t_like ( user_id bigint, article_id bigint, created_at timestamptz DEFAULT now() );你可以用复合唯一约束ALTER TABLE t_like ADD CONSTRAINT uk_like_user_article UNIQUE (user_id, article_id);也可以建复合唯一索引CREATE UNIQUE INDEX uk_like_user_article ON t_like (user_id, article_id);两者在唯一性保证上等价。但从维护和语义表达的角度我更推荐约束因为约束会进pg_constraint建表语句导出后语义清晰ORM 和元数据工具也更容易识别。但要注意复合唯一约束的行顺序有讲究UNIQUE (user_id, article_id)和UNIQUE (article_id, user_id)底层的 B-tree 键顺序不同。如果后续有查询是按article_id过滤的前者可能用不上索引。所以设计时把最常用作查询条件的列放前面这和普通复合索引的优化原则一样。4.2 NULL 值的放行陷阱为什么唯一约束挡不住重复的 NULL这是很多人踩过的大坑PostgreSQL 遵循 SQL 标准UNIQUE 约束认为两个 NULL 彼此不相等。也就是说一张表里可以插入无数行同一列值为 NULL的记录唯一约束不拦。这个特性在某些场景很合理NULL 表示未知未知不等于未知但在硬性业务唯一的场景里就是灾难。比如用户名如果允许为空那你只能有一个非空用户名空用户名却能无限多——显然不合理。之前维护某用户资料库时就出过这种问题业务端把未填写邮箱的用户默认置 NULL结果数据库里出现了一大堆邮箱为 NULL 的重复记录。排查到最后发现唯一约束压根没有拦截。所以我才在后面加了一行NOT NULL或者用下面的新语法去覆盖。PostgreSQL 15 引入了UNIQUE NULLS NOT DISTINCT可以在约束里直接声明NULL 也参与唯一性比较ALTER TABLE t_user ADD CONSTRAINT uk_user_email UNIQUE NULLS NOT DISTINCT (user_email);如果是 15 之前的版本只能曲线救国要么加NOT NULL约束要么改用部分唯一索引下面这个场景会讲到。4.3 部分唯一索引软删除场景下的唯一性救命稻草考虑这样一个需求订单表里允许order_no重复但同一时刻只能有一个未删除的记录。这个用普通唯一约束没法做——因为唯一性必须作用于整张表。解决办法是部分唯一索引CREATE UNIQUE INDEX uk_order_no_active ON t_order (order_no) WHERE deleted_at IS NULL;这个索引只对deleted_at IS NULL的行生效也就是只保证未删除订单的 order_no 不重复。删除过的历史订单随便重现。这种灵活性是唯一约束完全不支持的也是很多 DBA 在复杂业务中偏爱手工唯一索引的根本原因。再说一个配套技巧如果既想要约束语义又想要部分唯一性可以创建普通约束再在旁边补充一个部分唯一索引。比如用约束保证整表唯一用部分唯一索引保证某状态下的行唯一。原则是不用一个方案硬扛所有场景而是让约束和索引各管一摊。4.4 表达式唯一索引对大小写不敏感的场景怎么做登录账号通常不希望Adminexample.com和adminexample.com被认为是两个账号。标准做法是建一个表达式唯一索引CREATE UNIQUE INDEX uk_user_email_lower ON t_user (lower(user_email));这个唯一索引能保证小写化后的邮箱不重复。同样这种需求用约束语法直接写是写不出来的除非你先把列值本身统一规范化。很多团队会直接约定所有邮箱写入前转小写然后配合普通唯一约束效果一样但应用层容易漏表达式唯一索引是数据库层面的兜底。4.5 条件约束 vs 部分索引名字相似用处不同顺带提一句CHECK约束带条件的用法比如ALTER TABLE t_order ADD CONSTRAINT ck_order_status CHECK (status IN (pending, paid, cancelled));这不是唯一性的范畴但可以看出约束擅长表达规则而不是存储结构。部分唯一索引擅长的是在一定范围内的唯一性两者在数据建模里往往是配合关系。5. 从索引变约束从约束变索引USING INDEX 的正确用法5.1 什么时候需要把索引转成约束有一种常见场景你为了支持部分唯一性或表达式唯一性先用CREATE UNIQUE INDEX建了索引。后来数据结构稳定了希望把这条规则明确成约束让pg_constraint里有记录、让工具和同事都能识别。这时不应该把索引删掉再建约束白做一次写操作、还要重新扫描全表可以直接把现有索引挂到约束上。PostgreSQL 支持这样的语法ALTER TABLE t_user ADD CONSTRAINT uk_user_email UNIQUE USING INDEX uk_user_email;执行后原有的唯一索引uk_user_email就会升级成约束uk_user_email的底层索引约束与索引之间形成依赖关系。之后删约束索引连坐删除。注意几个限制索引必须是唯一索引USING INDEX语法要求索引列顺序与约束列一致所以必须先想好如果索引带有 WHERE 条件PG 会把它当成一个部分唯一索引来绑约束约束的语义会沿用这个过滤条件索引不能同时被别的主键或外键依赖。5.2 什么时候约束转索引更合适反过来如果你建了一个UNIQUE约束又希望单独保留唯一索引而解除约束直接删约束会把索引一起带走。那能否让约束没了但索引留着按正常操作流程是做不到的因为约束依赖索引删除约束会自动级联删索引。解决思路是反着来先基于已有约束语义手动创建一个新的唯一索引再删除旧约束旧索引随之删除保留新索引作为唯一性兜底。但这样做让我非常谨慎无非是想保留唯一性的同时去掉约束实际价值很低还容易造成重复索引或短暂无保护窗口。我会在删除约束之前再次确认这张表确实不需要对外暴露约束语义否则宁可不折腾。5.3 空表迁移时的顺序选择如果是一张大表要加唯一约束很多人犹豫先建约束好还是先建索引好。我的建议是如果是全新空表直接ADD CONSTRAINT UNIQUE让 PG 自动处理索引最省心如果是大表且带历史脏数据先手工清理重复再ADD CONSTRAINT这期间 PG 会扫描全表构建索引耗时较长锁表风险也高要在维护窗口做如果你已经建了唯一索引做预清洗可以直接ADD CONSTRAINT ... USING INDEX跳过重建索引的步骤。6. 性能与维护锁、开销、键长限制这些副作用必须看6.1 加约束的锁比你想的要重ALTER TABLE ... ADD CONSTRAINT ... UNIQUE会请求ACCESS EXCLUSIVE锁也就是全表禁止读写。小表无所谓几千万行的表这个操作可能让你业务直接中断。我处理过的某支付流水表当时 8000 万行加唯一约束跑了将近 20 分钟。实际上索引构建本身只用了一部分时间锁等待和 WAL 日志同步也占了不小比例。所以大表加唯一约束必须安排在维护窗口并且在此之前先查重复键避免构建中途因数据冲突而全量回滚。如果不想长时间持锁可以考虑分成两个阶段先CREATE UNIQUE INDEX CONCURRENTLY这个命令不会长时间阻塞读写再ALTER TABLE ... ADD CONSTRAINT ... USING INDEX 索引名因为索引已经存在这一步只需做元数据关联锁的持有时间非常短。6.2 唯一索引的维护成本每次写操作的代价清单唯一索引不是免费送的。每插入一行、每更新一行的键值PostgreSQL 都需要维护 B-tree并且在发生页分裂时标记半页。尤其高并发写入场景唯一索引的竞争点会成为明显的热点两个并发事务同时插入相同键值其中一个要等另一个提交后才能判断冲突唯一索引本身也会带来死锁风险特别是在多行乱序插入时——两个事务分别插入不同键但在 B-tree 页的相邻位置互相等待对方的锁。这些不是约束特有的问题而是唯一索引自带的。但很多开发者把唯一约束当成规则看待忽略了底层同样是一个有锁竞争的索引导致生产环境出现性能洼地预算不足。6.3 键长限制一个容易忽略的边界PostgreSQL 的 B-tree 索引对单条索引项有约三分之一页面的长度限制默认 8KB 页面下大约是 2704 字节。如果你的唯一列是一个超长 varchar 或者 text直接建唯一约束可能会报错index row size 3216 exceeds btree version 4 maximum 2704 for index ...这时候约束语法就无能为力了。但唯一索引可以打辅助CREATE UNIQUE INDEX uk_user_token ON t_user (md5(user_token));用md5之类的定长哈希值做键绕开长度限制。代价是不能保证绝对防碰撞加密角度概率极低且列本身的值如果超过限制索引还是可能失败。这类原则上唯一的需求我自己更倾向于用哈希列加唯一索引同时在应用层做完整值校验。6.4 冗余索引的判断怎么知道约束索引和手建索引重复了很多巡检脚本会从pg_index里找看起来一模一样的索引。但如果你不看pg_constraint很容易把约束绑定的索引当成冗余索引进而出删除建议——这是很大的误区。我习惯用这套判断逻辑先给所有索引匹配上约束信息前面那段LEFT JOINSQL把有约束依赖的索引自动标记为受管索引不参与冗余判断只对比无约束依赖的索引之间是否有前缀重复或完全重复输出冗余建议后手工再确认一遍。这样做能少很多乌龙事件。毕竟约束删了索引也会跟着删但如果你误把约束的索引当冗余删了前面已经说过会直接报错甚至可能影响主键和外键。6.5 延迟约束约束和索引不同步的特殊窗口PostgreSQL 还支持DEFERRABLE约束ALTER TABLE t_user ADD CONSTRAINT uk_user_email UNIQUE (user_email) DEFERRABLE INITIALLY DEFERRED;这种约束的检查会被推迟到事务提交时执行。注意索引本身是同步维护的但约束的唯一性判断延迟了。也就是事务中途你可以插入多条相同键值只要提交前保证唯一即可。我用它解决过批量导入时需要打乱插入顺序但最终保证唯一的场景。代价是这个机制依赖索引来做最终检查性能开销并不低不要为了灵活滥用。另外一个避坑点如果配合ON CONFLICT使用延迟约束的冲突处理会变得非常隐晦我建议尽量别在这个组合下搞复杂逻辑。6.6 外键引用唯一约束约束名必须存在如果你的表要被其他表外键引用被引用的列必须有主键或唯一约束。光有唯一索引是不够的外键约束在pg_constraint里找不到被引用的约束时会直接报错。这是一个很多人忽略的点某业务表用CREATE UNIQUE INDEX保证了唯一性但另建附表时想REFERENCES它数据库拒绝原因就是没有可以作为引用目标的唯一约束。所以只要你的唯一性要被外键引用就一定要用约束语法而不是裸索引。这点在数据建模阶段就该定下来。总结实操建议到底该用约束还是索引说了这么多直接给出我的选择思路希望能给正在纠结的你一个参考最常规的需求比如用户邮箱、订单号唯一优先用UNIQUE约束。语义清晰、会被外键识别、删除时自动清理索引、ORM 也能识别需要部分唯一性如软删除场景用WHERE的部分唯一索引需要对表达式唯一如大小写不敏感用表达式唯一索引需要外键引用必须用约束大表加唯一约束优先CREATE UNIQUE INDEX CONCURRENTLY再用USING INDEX挂约束避免长时间锁表约束自动建的索引不要手动删要改就删约束重建而不是直接操作索引排查唯一性冲突不要只看\d把pg_constraint和pg_indexes都查一遍先搞清楚约束索引和裸索引到底是谁。我在实际维护中最大的体会是PostgreSQL 选择约束 索引这套组合本质是把语义和执行解耦。让你既能在建模时代表达规则又能在物理层灵活优化实现。如果你只把约束当索引容易忽略它的语义价值如果你只把索引当约束又会碰上外键、元数据识别这类麻烦。理解它们的绑定关系很多操作困惑自然就解开了。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。