我经常在技术群里看到一类问题:有人发现生产环境里同一个用户居然有两条已激活的会员记录,或者同一笔订单的支付流水重复入库,于是问“我是不是应该加唯一索引?加唯一约束和直接建唯一索引有什么区别?”说实话,这类问题的答案并不复杂,但背后涉及的概念和 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 索引里并不是严格的普适法则。如果一个查询的多个列都是等值条件,那么只要它们同时出现,索引列内部谁前谁后并不会影响最终扫描到的行数;真正决定效率的是“这个索引能不能服务更多种查询形态”。
举个例子,假设有一张订单表:
sql复制CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
shop_id BIGINT NOT NULL,
status SMALLINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL
);
如果线上最核心的查询是:
sql复制SELECT *
FROM orders
WHERE shop_id = 147
AND status = 2
AND created_at >= '2024-06-01'
AND created_at < '2024-07-01'
ORDER BY created_at;
这时推荐索引是 (shop_id, status, created_at),不建议建成 (created_at, status, shop_id)。原因在于 created_at 是范围条件,如果把它放在最前面,数据库虽然能通过 B-tree 快速定位到起始时间,但在整个时间范围内,它必须拿“所有店铺、所有状态”的索引项做一遍大量过滤;而把 shop_id 和 status 这两个等值列放前面以后,B-tree 可以直接定位到“这个店铺这个状态的时间区间”,扫描的叶子节点数量会小一个数量级。
2.3 复合索引里的“排序红利”
PostgreSQL 的 B-tree 索引天然有序,所以在某些场景下,索引可以替掉一次显式排序。继续看上面的查询:如果索引是 (shop_id, status, created_at),在定位到 shop_id=147、status=2 之后,叶子节点里的 created_at 本身是有序的,数据库就能顺着这个顺序读取并反方向遍历倒序,ORDER BY created_at DESC 不需要额外排序。如果查询同时要 ORDER BY created_at 和 LIMIT 50,这种收益会非常明显。
但有一个细节容易被忽略:ORDER BY 的列必须与范围条件相邻才有机会生效。如果你建的是 (shop_id, status, created_at, amount),查询条件是 shop_id = 147 AND status = 2 AND amount > 100 ORDER BY created_at,那么 amount 是范围列,排在它后面的 created_at 就无法直接利用索引的有序性了。这也是为什么说“建复合索引之前,先完整列出查询条件与排序条件,不要只看 WHERE 里那几列”。
3. 创建语法与锁行为:在跑着的业务上加索引前必看
3.1 ALTER TABLE 加唯一约束,还是 CREATE UNIQUE INDEX
如果表是新建的,直接在建表语句里写约束最直接:
sql复制CREATE TABLE orders (
shop_id BIGINT NOT NULL,
order_no VARCHAR(32) NOT NULL,
CONSTRAINT uq_orders_shop_no UNIQUE (shop_id, order_no)
);
如果表已经存在,两种做法等价性不完全一样:
sql复制-- 方式一:加唯一约束,PostgreSQL 会自动创建一个同名唯一索引
ALTER TABLE orders
ADD CONSTRAINT uq_orders_shop_no UNIQUE (shop_id, order_no);
-- 方式二:先建唯一索引,后续再决定要不要挂成约束
CREATE UNIQUE INDEX uq_orders_shop_no_idx
ON orders (shop_id, order_no);
方式一最大的好处是语义清晰,迁移工具、ORM 都能识别。方式二最大的好处是灵活:它可以带 WHERE 条件做部分唯一索引,可以通过 INCLUDE 往索引里塞辅助列,也能用不同的 opclass。如果业务允许并且你有把握,我的习惯是“默认用 ALTER TABLE ... ADD CONSTRAINT,只有需要部分唯一索引或自定义索引选项时才用 CREATE UNIQUE INDEX”。
3.2 唯一索引和约束在创建过程中都会锁写吗?
普通 CREATE INDEX 在 PostgreSQL 里会在表上加锁,阻塞该表上的写操作。唯一索引要比普通索引更麻烦:数据库必须扫描全表检查是否已经有重复值,如果表很大,这个扫描过程会持续很久,期间业务写入会被锁住。唯一约束通过 ALTER TABLE 添加时也一样,甚至更隐蔽,因为很多人以为“只改一下约束,应该很快”。
生产环境上的标准做法是先用并发方式把唯一索引建好:
sql复制CREATE UNIQUE INDEX CONCURRENTLY uq_orders_shop_no_idx
ON orders (shop_id, order_no);
CONCURRENTLY 的意思是构建索引的过程中允许原有的读写继续,不长时间锁表。但它也有一些代价和限制:执行时间通常更长,CPU 与磁盘 IO 占用更高,并且不允许放在事务块里执行。一旦执行失败,PostgreSQL 可能留下一个 INVALID 状态的索引,后续需要手动 DROP INDEX 再重建。另一个常见坑是:CONCURRENTLY 和某些其他索引选项的组合并不一定被当前版本支持,所以在大表上动手前,先在一个测试库小表上把语法验证一遍。
等唯一索引并发建好之后,如果还是希望有一个约束对象,可以用一个冷门语法把索引“挂”成约束:
sql复制ALTER TABLE orders
ADD CONSTRAINT uq_orders_shop_no UNIQUE USING INDEX uq_orders_shop_no_idx;
这是很多 DBA 喜欢的两段式做法,既避免了长时间锁表,又保留了约束语义。要注意:能这样挂载的索引必须是唯一索引,而且不能是部分索引、表达式索引;子句里会对列集合做校验,不匹配会直接报错。
3.3 PostgreSQL 15 之后的 NULLS NOT DISTINCT
默认情况下,PostgreSQL 唯一索引把多个 NULL 视为互不相同,所以下面的表可以同时存在两条 phone = NULL 的记录:
sql复制CREATE TABLE contacts (
id BIGSERIAL PRIMARY KEY,
phone VARCHAR(20),
UNIQUE (phone)
);
如果你的业务规则要求“该列最多只能有一条记录是空值”,PostgreSQL 15 开始可以这么写:
sql复制CREATE UNIQUE INDEX uq_contacts_phone
ON contacts (phone) NULLS NOT DISTINCT;
约束写法同样支持:
sql复制ALTER TABLE contacts
ADD CONSTRAINT uq_contacts_phone UNIQUE NULLS NOT DISTINCT (phone);
15 之前怎么处理?常用的变通方式是借助 COALESCE 造一个表达式唯一索引,比如:
sql复制CREATE UNIQUE INDEX uq_contacts_phone_fix
ON contacts (COALESCE(phone, ''));
但这有个副作用:空字符串和空值会被当成同一回事,真实业务里空字符串往往也是非法数据,所以倒也可以接受。这类细节如果没有提前想清楚,唯一索引加上去之后反而会变成业务规则的“绊脚石”。
4. 把唯一和复合组合起来:三个值得抄走的真实建索引案例
4.1 订单号和店铺组合唯一:约束 + 幂等写入
假设订单号只在店铺内唯一,也就是说不同店铺可以各有一个 A1001,同一个店铺内不允许重复。这时候表结构里最该做的不是全表唯一约束,而是店铺维度复合唯一:
sql复制ALTER TABLE orders
ADD CONSTRAINT uq_orders_shop_order_no UNIQUE (shop_id, order_no);
这个约束不仅能挡掉重复订单号,还顺便给“店铺 + 订单号”的精确查询提供了索引。配合 PostgreSQL 的 ON CONFLICT,还能把重复插入变成幂等操作:
sql复制INSERT INTO orders (shop_id, order_no, amount, status)
VALUES (147, 'A1001', 99.00, 1)
ON CONFLICT (shop_id, order_no)
DO UPDATE SET amount = EXCLUDED.amount;
在订单回调、消息重试、接口重放这类场景中,这个组合非常实用。不过我只建议在确认业务允许覆盖旧值时用 DO UPDATE;如果只是想忽略本次重复,DO NOTHING 更安全。
有一点要提醒:复合唯一约束虽然能保证 (shop_id, order_no) 不重复,但如果另一个接口拿 order_no 全局精确查询,这个复合索引通常帮不上什么忙。也就是说,“唯一约束”解决的是数据完整性问题,不等于解决了所有查询性能问题。如果确实存在大量按订单号全局搜索的场景,业务上应确认订单号本身就应该全局唯一,此时直接给 order_no 单独建唯一约束更符合真实规则。
4.2 好友关系表:无向关系的唯一性设计
好友关系表是复合唯一索引的经典场景。最容易写错的结构是只给 (user_id, friend_id) 建唯一约束:
sql复制ALTER TABLE friendships
ADD CONSTRAINT uq_friendships_pair UNIQUE (user_id, friend_id);
这样能防住“A 加 B 两次”,但防不住“A 加 B”和“B 加 A”同时存在。如果业务把好友关系视为无向的,就需要在写入时强制规范化:要么始终保证 user_id < friend_id,并配合一个 CHECK 约束:
sql复制CREATE TABLE friendships (
user_id BIGINT NOT NULL,
friend_id BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT chk_friendships_direction CHECK (user_id < friend_id),
CONSTRAINT uq_friendships_direction UNIQUE (user_id, friend_id)
);
这样建出来的复合唯一约束,才能同时挡住重复请求和反向重复。查询我的好友列表时,SQL 会比较别扭,需要两个条件一起查:
sql复制SELECT *
FROM friendships
WHERE user_id = 1024 OR friend_id = 1024;
这种查询很难真正高效利用 (user_id, friend_id) 的唯一索引,所以在数据量上来以后,通常还要再单独建一个反向单列索引。这里我想表达的是:真正好的索引设计,不是把所有列塞进一个唯一约束了事,而是先想清楚这份数据关系的业务方向,再去决定约束和索引。
4.3 软删除场景中的部分唯一索引
很多业务表做的是“软删除”。比如用户表有一条历史逻辑禁止复用某个邮箱,但现在很多产品是允许“删除账号后重新注册同一个邮箱”的。如果坚持用普通唯一约束:
sql复制ALTER TABLE users ADD CONSTRAINT uq_users_email UNIQUE (email);
那条被软删除的旧用户记录会一直占着坑,新用户永远无法用同邮箱重新注册。正确做法是只对 deleted_at IS NULL 的活跃数据做唯一性控制:
sql复制CREATE UNIQUE INDEX uq_users_active_email
ON users (email)
WHERE deleted_at IS NULL;
这个部分唯一索引的含义是:活跃用户中 email 不能重复,历史软删除数据不参与唯一判断。它是用唯一索引做业务规则建模的典型场景,用普通 UNIQUE 约束无法表达。
如果还要兼顾多租户,只需要把租户字段加进索引,例如:
sql复制CREATE UNIQUE INDEX uq_tenant_users_active_email
ON users (tenant_id, email)
WHERE deleted_at IS NULL;
需要注意,部分唯一索引存在时,ON CONFLICT 的写法会变得复杂,PostgreSQL 的冲突仲裁机制需要能匹配到对应索引。因此我在接入层会尽量少依赖部分唯一索引做 upsert,宁可先 SELECT 或直接让异常冒泡,反而容易维护。
5. 排查实录:“建了 (shop_id, created_at) 为什么还是慢”
5.1 现场问题与第一版索引
某次线上系统反馈,订单表的统计查询越来越慢。简化后的表结构是这样:
sql复制CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
shop_id BIGINT NOT NULL,
order_no VARCHAR(32) NOT NULL,
amount NUMERIC(12,2) NOT NULL,
status SMALLINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
慢查询大致是:
sql复制SELECT order_no, amount, created_at
FROM orders
WHERE shop_id = 1024
AND status = 2
AND created_at >= '2024-06-01'
AND created_at < '2024-07-01'
ORDER BY created_at DESC
LIMIT 100;
当时的索引是两周前加的:
sql复制CREATE INDEX idx_orders_created_at_shop
ON orders (created_at, shop_id);
执行计划里确实能看到 Index Scan Using idx_orders_created_at_shop,但每条 SQL 平均要读上千个数据页,响应时间像坐过山车。负责的同学很困惑:明明已经在用索引了,为什么还是慢?
5.2 执行计划暴露的第一层问题:前导列是范围条件,右侧等值列被浪费
我用一条命令把执行计划跑出来:
sql复制EXPLAIN (ANALYZE, BUFFERS, TIMING OFF)
SELECT order_no, amount, created_at
FROM orders
WHERE shop_id = 1024
AND status = 2
AND created_at >= '2024-06-01'
AND created_at < '2024-07-01'
ORDER BY created_at DESC
LIMIT 100;
很多人一看到 Index Scan 就觉得索引已经生效,但关键在于 filter 部分。当时执行计划里体现了大量“按时间范围拿到索引项,再用 shop_id、status 去过滤”的工作。
问题就出在索引列顺序上:(created_at, shop_id) 把范围条件 created_at 放在了最左。B-tree 能优雅利用的只有第一列的条件;shop_id = 1024 这个选择性极高的等值条件,被降级成了索引范围扫描之后的额外过滤条件。更糟的是,时间范围覆盖了全平台所有店铺的数据,如果这个时间段内整体订单量大,哪怕最后只返回 100 条,扫描的索引区间也非常宽。
5.3 第二层问题:还有一张单列索引在兜底,但没有覆盖 status
查看该表所有索引后发现,线上还有一个很久以前建的单列索引 idx_orders_shop_id(shop_id)。它能让数据库先定位到店铺,但后续还要在堆表里筛 status 和时间范围。现实情况往往是:优化器在两个索引之间来回纠结,最后选出来的路线不一定是开发者心里那条。
我当时的建议是先重建复合索引,把等值条件放前,范围条件放后:
sql复制DROP INDEX idx_orders_created_at_shop;
DROP INDEX idx_orders_shop_id;
CREATE INDEX CONCURRENTLY idx_orders_shop_status_created
ON orders (shop_id, status, created_at);
为什么敢把 idx_orders_shop_id 直接删掉?因为新的复合索引 (shop_id, status, created_at) 已经把 shop_id 作为最左前缀,任何只要 shop_id = ? 的查询都能使用这个索引,单独的单列索引在功能上是重复的,留着只会增加写入开销和占用磁盘。
5.4 重建后的验证和进一步优化:减少回表
重建索引后,同样一条 SQL 的执行计划从“先按时间范围摸一大片、再过滤”变成“直接沿着 B-tree 走到这个店铺、这个状态,再按时间范围扫”。LIMIT 100 只需要扫描少量索引项,响应时长降了一个量级。
不过查询里还有 amount 需要回表,如果这张表高频执行这类统计,可以再考虑把查询结果列塞进索引,实现 Index-Only Scan:
sql复制CREATE UNIQUE INDEX uq_orders_shop_no
ON orders (shop_id, order_no)
INCLUDE (amount, status, created_at);
但注意这个索引和查询 WHERE 的条件不完全匹配,如果统计查询并不按 order_no 过滤,那它走不了这个复合结构。更合理的做法是为慢查询专门设计一个覆盖索引:
sql复制CREATE INDEX idx_orders_cover_stat
ON orders (shop_id, status, created_at DESC)
INCLUDE (order_no, amount);
这样查询只需要读索引页,不需要回表读堆数据,性能和稳定性都会更好。这段排查还说明了一个道理:复合索引不是“只要建了就一定被用上”,列顺序和业务查询条件必须形成精确匹配,才算真正建对了。
6. 从加索引到长期维护:我常用的几个体检动作
6.1 给大表加唯一索引前,先把重复数据揪出来
唯一索引不像普通索引,创建失败不会只是“变慢一点点”,而是整个 CREATE 语句直接报错。你如果想往一张千万行表上加一个唯一约束,最尴尬的不是创建过程被人投诉,而是创建到一半发现历史数据里早就有重复值,只能回滚重来。
安全流程是:先在测试环境或者只读从库上跑一遍重复检查:
sql复制SELECT order_no, COUNT(*)
FROM orders
GROUP BY order_no
HAVING COUNT(*) > 1;
确认没有重复,或者清理完重复数据之后,再去生产环境并发建索引。如果表真的很大,我的习惯是先建一个普通索引观察写入负载,再把它升级成唯一索引,甚至直接考虑用分区表配合唯一索引,避免单索引膨胀失控。
6.2 根据统计视图判断复合索引是否被高频使用
索引建完不是终点。PostgreSQL 里有几个很有用的视图,我每次调完索引后都会顺手查一下:
sql复制SELECT
schemaname,
relname,
indexrelname,
idx_scan,
idx_tup_read,
idx_tup_fetch
FROM pg_stat_user_indexes
WHERE relname = 'orders'
ORDER BY idx_scan DESC;
idx_scan 为 0 的索引需要重点审视,但不要一看到 0 就删。尤其对于唯一索引,它即使从来没被查询使用过,也承担着唯一性约束的职责,不能因为扫描次数低就删。对于普通复合索引,如果连续一两周都没有任何扫描,说明它和真实查询形态不匹配,可以考虑删除或调整列顺序。
复合索引也容易和单列索引形成“前缀冗余”。例如已经有了 (shop_id, status, created_at),再保留单独的 (shop_id) 索引意义不大,后者基本是前者的前缀;保留单独的 (status) 倒还有可能有用,因为 status 不是新索引的最左列。这类冗余识别没有统一脚本能全自动判断,必须结合业务查询去挨个看。
6.3 索引膨胀:唯一索引也会变大,也需要 REINDEX
很多运维只看表膨胀,忽略了索引膨胀。对于频繁 UPDATE 的表,复合索引的叶子节点会被不断拆页、标记死版本,时间一长索引体积会比实际数据大很多,扫描效率也就跟着下降。
处理手段是重建索引。PostgreSQL 12 以前只能离线重建 REINDEX INDEX,会阻塞写;12 以后支持 REINDEX INDEX CONCURRENTLY:
sql复制REINDEX INDEX CONCURRENTLY idx_orders_shop_status_created;
但我不会没事就全表 REINDEX,而是先看索引大小和膨胀率。可以用索引大小除以表大小做个粗估;如果某些复合索引
