我经常在技术群里看到一类问题:有人发现生产环境里同一个用户居然有两条已激活的会员记录,或者同一笔订单的支付流水重复入库,于是问“我是不是应该加唯一索引?加唯一约束和直接建唯一索引有什么区别?”说实话,这类问题的答案并不复杂,但背后涉及的概念和 PostgreSQL 的实现细节,会让不少人在真实环境中栽跟头。这篇文章就以 PostgreSQL 为主线,把唯一索引、复合索引的创建逻辑、底层行为和实际坑位系统梳理一遍。文章偏实战,默认你已经知道 B-tree 索引大概是什么;如果你是刚接触数据库索引的读者,我也尽量用生活化的方式去讲原理,不用太担心跟不上。
1. 唯一索引救场前:先想清楚“唯一性”从哪一层保证
1.1 数据完整性的最终兜底不是应用校验,而是数据库约束
很多人习惯在应用层做重复校验:插入前先 select count(*),查得到就拒绝。这个做法在并发量低的时候凑合能用,但一旦有两个请求同时进来,都查到“不存在”,然后同时插入,重复数据就这么产生了。真正可靠的方案是把唯一性下沉到数据库,让数据库在索引层面就拒绝重复值。
在 PostgreSQL 里,保证唯一性有两条路:
- 直接创建唯一约束:
ALTER TABLE ... ADD CONSTRAINT ... UNIQUE (col); - 直接创建唯一索引:
CREATE UNIQUE INDEX ... ON ... (col);
这两者最终都会生成一个唯一索引,但语义上有一个容易忽略的差别:唯一约束是数据库的“规则”,它会被 PostgreSQL 的元数据、information_schema 和 ORM/迁移工具识别;唯一索引只是一个物理索引对象,数据库理论上并不知道这个索引是为了“挡住重复数据”还是为了“加速查询”。所以如果你的目标是表达业务规则,优先用 UNIQUE 约束;如果目标是让一段查询走索引,建普通索引就够了。用唯一索引去加速查询并不是不行,只是会让业务含义变得含混,后面接手的人容易懵。
1.2 唯一约束、唯一索引、主键索引的关系
主键索引本质上就是一种唯一索引加上“非空”限制。PostgreSQL 里一个主键会自动创建一个唯一索引。例如:
sql复制CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE
);
上面这行建表语句实际上生成了一个主键唯一索引和一个 email 的唯一约束。
不过在动手加唯一索引之前,一定要先回答一个问题:这个字段的 NULL 怎么处理?PostgreSQL 的默认行为是:唯一索引允许多个 NULL,多个 NULL 之间不会被认为是重复值。这在很多业务场景下是符合预期的——比如用户的手机号字段可空,空代表“还没绑定”,那既然什么都没绑,多个人没绑也是合理的;但如果你的业务规则是“这类记录只能存在一条,不能允许第二个 NULL”,默认行为就会出问题。PostgreSQL 15 开始提供了 NULLS NOT DISTINCT 选项,这我在第 3 节会展开讲。
我自己处理过的一个真实案例是用户签名档表。业务要求“每个用户只能有一个主签名档”,所以我在 signatures(user_id, is_primary) 上建了部分唯一索引:
sql复制CREATE UNIQUE INDEX uq_signatures_user_primary
ON signatures(user_id)
WHERE is_primary;
这个需求如果用 NULLS NOT DISTINCT 不解决,因为软性标记不是 NULL,而是布尔的 true 和 false。此时普通唯一约束完全不适用,只能靠带 WHERE 的部分唯一索引。这一类“只对部分行生效的唯一性”是 PostgreSQL 相比很多数据库更灵活的地方,也是从约束思维切换成索引思维的关键场景。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 复合索引为什么有时候像“不存在”:B-tree 排序规则决定一切
2.1 复合索引是一本先按首字母排、再按第二字母排的字典
单列 B-tree 索引的逻辑简单,可复合索引(也叫联合索引)就没那么好理解了。以一个 (a, b, c) 索引为例,PostgreSQL 建立的是一棵按所有列一起排序的 B-tree:先按第一列 a 的大小排;当两个索引项的 a 值相同时,再按第二列 b 排;当 a、b 都相同时,再按第三列 c 排。
这时候用生活中的例子类比最清楚:复合索引就像一本“先按拼音首字母,再按声母,再按韵母”排的字典。你要查“b + 某个音”,可以很快翻到 B 区;但如果一上来就说“要找所有韵母是 ai 的字”,这本字典就没法帮你,因为韵母是第三排序关键字,它没有在全书的顶层组织起来。
这个特性引出了两个核心规则:
- 查询条件里没有复合索引的最左列时,索引通常不会发挥正常效率。
- 最左列用了范围条件后,右侧的列基本派不上用场。
很多“我明明建了复合索引,为什么还是慢”的问题,根源都在这里。
2.2 范围列到底该放前面还是后面:别迷信“选择性最高放最前”
在决定复合索引的列顺序前,可以先把查询条件拆成两类:等值条件(=、IN)和范围条件(>、<、BETWEEN、ORDER BY)。一句话建议是:
把最常用、最稳定的等值条件列放到最前面,范围列放到等值列之后,排序列放到更后面。
这里有个常见误区:不少人认为“选择性最高的列放最前”,这其实是 MySQL 时代传来的经验,它在 PostgreSQL 的 B-tree 索引里并不是严格的普适法
