在数据库这行摸爬滚打久了,你会发现索引这个东西永远是“会了不难、难了不会”的分水岭。尤其到了PostgreSQL里,唯一索引和复合索引用好了,查询能快得飞起,用不好反而给写操作拖后腿。这篇文章不聊虚的,就从实际干活的角度,把PostgreSQL唯一索引和复合索引创建的那些事讲透——包括背后的原理、具体的SQL语法、复合索引里最容易踩的列顺序坑,以及我在生产环境里碰到过的典型问题和处理过程。
适合谁来读呢?已经会写SELECT,懂一点点B-Tree或普通索引的概念,但想进一步提升索引设计水平的开发者、DBA。需要掌握的三个核心关键词就是:PostgreSQL、唯一索引、复合索引。读完你至少能搞明白两件事:怎么用索引卡住业务数据不重复,怎么用复合索引把慢查询优化下来。
1. 内容整体设计与思路拆解
1.1 为什么要单独把这两个索引拎出来讲
先说个现象。很多人提到唯一索引,就只记得“加个UNIQUE”,提到复合索引,就只想着“多个列建个索引”。但这两类索引放在一起,能解决一类非常关键的病——关联关系重复。
举个例子,订单系统里的明细表。业务上不允许同一个订单里出现两行一模一样的商品,这种约束既涉及到多列组合,又涉及到唯一性。如果你只学单列唯一索引,不知道怎么做多列唯一校验;如果你只学复合索引,又不知道唯一性约束怎么加。所以这篇文章刻意把两者放一起,讲清楚它们的组合方式,也讲清楚它们之间那些容易混淆的边界。
1.2 唯一索引和复合索引的定位差异
在PostgreSQL里,最常规的索引底层是B-Tree,唯一索引和复合索引默认也都基于它。B-Tree的好处是既支持等值查询,又支持范围查询,还能顺便帮你把排序问题解决了。唯一索引就是在B-Tree的键上追加了唯一性校验,不允许出现重复值;复合索引则是把一个表的多个列作为组合键,让索引能支撑更复杂的查询条件。
这里要泼一盆冷水:复合索引是最容易被滥用的一类索引。很多人一上来就“哪个查询慢就建哪个复合索引”,完全不看列顺序,结果索引建了一堆,查询没快多少,INSERT和UPDATE反而被拖慢。真正要做的,是理解复合索引的匹配规则,让每个索引都能被充分用起来。
1.3 业务场景对照表:什么时候该用什么索引
| 场景 | 推荐索引类型 | 示例查询/约束 |
|---|---|---|
| 某字段必须全局唯一 | 单列唯一索引 | users(email) |
| 多列组合必须唯一 | 复合唯一索引 | order_items(order_id, product_id) |
| WHERE里多个等值过滤 | 普通复合索引 | WHERE user_id = ? AND status = ? |
| 一个等值加一个范围 | 复合索引,等值列在前 | WHERE user_id = ? AND created_at > ? |
| 过滤后还要排序 | 复合索引,排序字段放后面 | WHERE status = ? ORDER BY created_at |
| 查询只要少数几列 | 覆盖索引(INCLUDE) | SELECT email, nickname FROM users WHERE email = ? |
这个表我在设计索引的时候基本会先过一遍。搞清楚自己的查询属于哪一种,再决定索引怎么建,远比临时抱佛脚建一堆索引靠谱。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与实操要点
2.1 唯一索引:创建语法与NULL语义
先看最基础的创建语法。
sql复制CREATE UNIQUE INDEX idx_users_email ON users(email);
执行这条语句时,PostgreSQL会扫描整张表,检查email列是否有重复值。一旦发现重复,创建就会失败,报出“duplicate key value violates unique constraint”一类的错误。所以给已有数据加唯一索引之前,先确认一下数据是否干净,这一步非常重要,后面我会详细讲。
除了手动创建索引,PostgreSQL还支持通过约束来隐式创建唯一索引。建表时你可以写:
sql复制CREATE TABLE users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE,
phone VARCHAR(20)
);
这样系统会自动为一个名为users_email_key的唯一索引,用来支撑email的唯一约束。如果你用的是已经存在的表,也可以这样做:
sql复制ALTER TABLE users ADD CONSTRAINT users_email_key UNIQUE (email);
同样,这也会自动创建一个同名唯一索引。
还有一个细节经常被忽略:PostgreSQL唯一索引允许存在多个NULL值。因为在B-Tree的语义里,NULL被视为互不相等。也就是说,如果你的email列允许NULL,那么可以有无数行email是NULL,这不违反唯一约束。业务上如果要求“有邮箱时不能重复,没邮箱时也不限制”,那这种默认行为完全够用。
2.2 复合索引:列顺序为什么决定查询命运
复合索引的创建语法很简单:
sql复制CREATE INDEX idx_orders_user_created ON orders(user_id, created_at);
但真正难的是列顺序。你可以把复合索引想象成一本书的目录:最左边的列是“章节一级标题”,后面是“二级标题”。你想在目录里直接找到某个二级标题,就必须先确定一级标题落在哪。这就是数据库里常说的“最左前缀原则”。
在这个例子中,(user_id, created_at)这样的复合索引能服务这些查询:
- WHERE user_id = 100
- WHERE user_id = 100 AND created_at > '2024-01-01'
- WHERE user_id = 100 AND created_at = '2024-01-01'
但这些查询通常用不上它:
- WHERE created_at > '2024-01-01'
- WHERE created_at = '2024-01-01'
因为created_at是第一列的后续列,单独拿它出来找,没办法从索引根部开始检索。
所以,设计复合索引时有一个经验法则,等值条件的列放在前面,范围条件的列放在后面。这样PostgreSQL可以先利用等值列精确锁定一个较小的区间,再在区间内做范围扫描。如果用反了,范围条件先框住一大片数据,再回头过滤等值列,效率就差很多。
还有一个容易被忽略的细节:如果查询里同时有多个等值列,通常把区分度更高的列放前面。比如user_id可能有10万个不同值,status只有3个不同值,那么(user_id, status)通常优于(status, user_id)。但这不是绝对的,因为如果有单独按status查的高频查询,(status, user_id)就能命中,你需要结合业务查询频率来权衡。
2.3 唯一约束与唯一索引:什么时候该用谁
唯一约束和唯一索引在PostgreSQL里底层实现几乎一样——唯一约束会自动创建一个唯一索引。既然这么像,为什么还分成两个概念?它们的区别更多体现在语义和管理上。
创建唯一约束,是要在数据模型层面表达业务规则。比如用户邮箱不能重复,这是一条业务约束,应该在建表SQL里写清楚,这样后来接手的人看表结构就能知道。
而创建唯一索引,有时更侧重于性能目标。比如你给订单号建唯一索引,是为了让按订单号搜索时走索引,同时碰巧实现了唯一约束。
如果让我给建议,核心业务规则用唯一约束,纯性能优化加顺带防重可以用唯一索引。还有一种场景只能用唯一索引,那就是“部分唯一索引”。比如要求“未删除的订单号必须唯一”,但已删除的记录允许单号重复。用唯一约束表达不了这种有条件的唯一性,但可以这样建索引:
sql复制CREATE UNIQUE INDEX idx_orders_number_active
ON orders(order_no)
WHERE deleted_at IS NULL;
这是PostgreSQL特有的大杀器,后面实操环节我再细说。
2.4 复合唯一索引的业务价值
复合唯一索引听起来像是两个概念拼在一起,但它在业务中的价值是1+1>2的。最典型的场景就是我们开头说的订单明细表:
sql复制CREATE UNIQUE INDEX idx_order_items_order_product
ON order_items(order_id, product_id);
这个索引做的事是:order_id和product_id的组合不允许重复。也就是说,同一个订单里不能出现两个一模一样的商品。如果没有这个索引,应用层可能在插入前先查一遍,判断是否已经存在。但并发场景下,两个请求同时查到“不存在”,同时插入,就出现了重复数据。数据库唯一索引是最后一道防线,无论如何都能挡住。
这个场景其实很多表都该用。比如课表座位表,某个时间段、某个座位只能被一个人预定;比如风控系统的黑白名单,某个用户和某个规则只能绑定一次。几乎所有“两张表的关联关系不能重复”的场景,都是复合唯一索引发挥价值的地方。
3. 实操过程与核心环节实现
3.1 准备演示表和测试数据
为了让你能直接跟练,我先搭一个演示环境。你可以用Docker Compose快速起一个PostgreSQL实例,或者直接使用本地已有的服务。我用的是PostgreSQL 15,不过本文SQL在12以上都能执行。
假设已经有了一个可用的库,下面是建表和造数SQL。
sql复制CREATE TABLE users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL,
phone VARCHAR(20),
nickname VARCHAR(50),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
order_no VARCHAR(32) NOT NULL,
status VARCHAR(10) NOT NULL DEFAULT 'pending',
total_amount NUMERIC(10,2) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE order_items (
id SERIAL PRIMARY KEY,
order_id INTEGER NOT NULL REFERENCES orders(id),
product_id INTEGER NOT NULL,
product_name VARCHAR(100) NOT NULL,
quantity INTEGER NOT NULL,
price NUMERIC(10,2) NOT NULL
);
先给users表插1万条数据,orders表插10万条,order_items表插30万条。
sql复制INSERT INTO users (email, phone, nickname, created_at)
SELECT 'user' || g || '@example.com',
'1380000' || LPAD(g::text, 4, '0'),
'nick_' || g,
now() - (g || ' minutes')::interval
FROM generate_series(1, 10000) AS g;
INSERT INTO orders (user_id, order_no, status, total_amount, created_at)
SELECT (g % 10000) + 1,
'ORD' || LPAD(g::text, 8, '0'),
CASE WHEN g % 3 = 0 THEN 'paid'
WHEN g % 3 = 1 THEN 'pending'
ELSE 'cancelled' END,
(g % 1000)::numeric + 0.99,
now() - (g || ' hours')::interval
FROM generate_series(1, 100000) AS g;
INSERT INTO order_items (order_id, product_id, product_name, quantity, price)
SELECT (g % 100000) + 1,
(g % 500) + 1,
'product_' || (g % 500 + 1),
(g % 5) + 1,
((g % 100)::numeric + 0.99)
FROM generate_series(1, 300000) AS g;
数据准备好之后,后面每个例子都能在这个库上复现。
3.2 创建唯一索引并验证效果
给users表的email加唯一索引:
sql复制CREATE UNIQUE INDEX idx_users_email ON users(email);
执行成功后,查看一下索引是否建好:
sql复制SELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'users';
输出里会出现这样一行:
code复制idx_users_email | CREATE UNIQUE INDEX idx_users_email ON public.users USING btree (email)
接下来故意插入重复email,验证唯一索引的拦截能力:
sql复制INSERT INTO users (email, phone, nickname)
VALUES ('user1@example.com', '13900000000', 'duplicate');
执行后会报错:
code复制ERROR: duplicate key value violates unique constraint "idx_users_email"
DETAIL: Key (email)=(user1@example.com) already exists.
看到这条错误,说明唯一索引正在正常工作。别觉得这个例子简单,我见过太多人把唯一性校验完全放在应用层,数据库层面不设防,结果并发压测一上来,脏数据就像雨水一样漏进来。
3.3 创建复合索引并对比执行计划
再看复合索引的效果。假设有一个很常见的高频查询:查某个用户最近创建了哪些订单。
sql复制EXPLAIN ANALYZE
SELECT order_no, status, total_amount
FROM orders
WHERE user_id = 100 AND created_at > '2024-06-01'
ORDER BY created_at ASC;
在没有复合索引的情况下,执行计划大概率是:
code复制Seq Scan on orders
也就是全表扫描。哪怕最后只返回几行,它也得在一整张表里把数据翻一遍。在10万行的时候还好,到了千万行级别,这种查询直接让人头皮发麻。
现在建复合索引:
sql复制CREATE INDEX idx_orders_user_created ON orders(user_id, created_at);
再次执行EXPLAIN ANALYZE,你会看到执行计划变成了:
code复制Index Scan using idx_orders_user_created
扫描行数大幅缩减。而且因为创建了(user_id, created_at)的复合索引,且created_at正好是索引的第二列,PostgreSQL也能直接从索引里拿到有序的created_at,连额外的Sort步骤都省了。
这个例子已经足够说明列顺序的意义。如果索引建成了(created_at, user_id),同样一条查询,PostgreSQL会在索引里先扫描一大段created_at > '2024-06-01'的数据,再去过滤user_id = 100,效率通常差很多。所以多想想你的实际查询条件,才能决定列顺序。
3.4 使用部分唯一索引做有条件的唯一约束
再来一个实战里非常实用的玩法:部分唯一索引。
业务场景:orders表里,order_no在“待支付”状态下必须唯一,但是一旦订单被取消或支付完成,业务允许相同的单号再次出现。这种“有条件的唯一性”用常规UNIQUE约束是做不到的,但部分唯一索引可以精准实现:
sql复制CREATE UNIQUE INDEX idx_orders_order_no_pending
ON orders(order_no)
WHERE status = 'pending';
执行这条SQL后,PostgreSQL会为orders表创建一个部分唯一索引。它只对status='pending'的行做唯一性校验,其他状态的order_no完全不受约束。
类似的需求还出现在软删除场景。比如用户表里软删除了一个电话号码为13800000000的账号,业务允许之后重新注册一个相同号码的新账号。这时候可以在deleted_at上做条件:
sql复制CREATE UNIQUE INDEX idx_users_phone_active
ON users(phone)
WHERE deleted_at IS NULL;
这个索引能保证:所有未删除的用户里,phone不会重复;已删除的用户,phone可以重复。这种需求很常见,但很多人不知道PostgreSQL能有条件地建唯一索引,白白在应用层写了一堆复杂的查重逻辑。
3.5 覆盖索引:让查询不回表
PostgreSQL 11开始支持INCLUDE语法。它允许你在索引的叶子节点“捎带”存储一些额外的列,这些列不参与索引的排序和查询,但当你只需要返回这些列时,数据库可以从索引直接取数,不需要回表查堆表。
比如这条查询:
sql复制SELECT email, nickname FROM users WHERE email = 'user100@example.com';
如果只在email上建了唯一索引,查询过程是:先通过索引找到email对应的行指针,再回到users表读取nickname。如果让索引直接带上nickname:
sql复制DROP INDEX idx_users_email;
CREATE UNIQUE INDEX idx_users_email_cover
ON users(email) INCLUDE (nickname);
注意,这里是UNIQUE INDEX,所以唯一性依然只针对email列,INCLUDE的nickname不参与唯一约束。再次验证:
sql复制EXPLAIN ANALYZE
SELECT email, nickname FROM users WHERE email = 'user100@example.com';
执行计划里会多出“Index Only Scan using idx_users_email_cover”,意味着全程没有回表。
INCLUDE列的使用要节制。它适合高频查询、返回列数量不多、且你明确知道不会用它来过滤或排序的场景。因为INCLUDE列的加入会让索引文件变大,写入时也会更费一些资源。追求极致查询速度的同时,也要看写入和存储成本能不能接受。
4. 常见问题与排查技巧实录
4.1 创建唯一索引时发现已有重复数据怎么办
这是生产环境里最常见的问题,没有之一。给一张存量很大的表加唯一索引,PostgreSQL直接拒绝执行,提示存在重复键。此时不用慌,按下面步骤处理。
第一步,找到重复数据。
sql复制SELECT email, COUNT(*)
FROM users
GROUP BY email
HAVING COUNT(*) > 1;
第二步,确认保留规则。一般业务上会要求保留最早的一条,或者保留ID最小的一条。执行删除前,先和业务方确认清楚,别闷头删数据。
如果确认保留ID最小的那行,可以这样删:
sql复制DELETE FROM users a
USING users b
WHERE a.email = b.email
AND a.id > b.id;
这条SQL利用了自连接:对每个email分组,删除所有存在更小ID同邮箱的那些行,最终只保留ID最小的一条。执行后再次查询重复数据,确认结果为0。这时再创建唯一索引,就不会报错了。
这里我想额外提醒两点:第一,删除前务必备份。第二,如果表很大,这种DELETE会扫两遍表,耗时可能很长,建议在业务低峰期执行。
4.2 复合索引明明建了,查询还是慢
遇到这种情况,我第一反应是看向最左前缀。你建了(user_id, created_at)的索引,但查询条件里只写了created_at,不走索引是很正常的。
例如:
sql复制SELECT * FROM orders WHERE created_at > '2024-06-01';
这条查询的WHERE条件里没有user_id,而复合索引最左边一列就是user_id,所以PostgreSQL无法直接使用这个索引。这不是索引坏了,是索引和查询不匹配。
解决思路有两条:如果这条查询很频繁,应该考虑单独为created_at建一个单列索引;如果这条查询不频繁,那也没必要为了它增加额外索引。最理想的做法是,把业务里的高频查询全部收集起来,分析它们的WHERE条件、排序字段、返回字段,再来统一规划复合索引,尽量做到“一索引多用”。
4.3 索引膨胀导致查询变慢:REINDEX的正确姿势
PostgreSQL的MVCC机制导致一个特性:表里被UPDATE或DELETE的行,在事务提交后不会立即物理消失,而是留下死元组。索引里的对应条目也不会马上被清理。频繁更新过后,索引文件会不断膨胀,扫描成本随之上升。这就是为什么有些索引明明很合理,但跑久了还是越来越慢。
你可以用下面这个查询看看索引膨胀情况:
sql复制SELECT
pg_size_pretty(pg_total_relation_size('idx_orders_user_created')) AS index_size,
pg_size_pretty(pg_total_relation_size('orders')) AS table_size;
如果索引大小明显偏大,或者你确认表的数据更新频繁,就可以重建索引。
最简单的方式:
sql复制REINDEX INDEX idx_orders_user_created;
但REINDEX默认会锁表,在生产环境会阻塞读写。从PostgreSQL 12开始,推荐使用CONCURRENTLY:
sql复制REINDEX INDEX CONCURRENTLY idx_orders_user_created;
CONCURRENTLY方式不会长时间锁表,代价是它会多消耗一些资源,而且不能在一个事务块里执行。不过为了线上业务不断,这点代价完全值得。
4.4 查询计划不理想,如何用EXPLAIN定位
优化索引的过程,本质上就是看执行计划的过程。排查问题时,我喜欢用带ANALYZE和BUFFERS的方式:
sql复制EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM users WHERE email = 'user100@example.com';
执行计划里如果出现Index Scan,说明走了索引;如果出现Seq Scan,说明PostgreSQL选择了全表扫描。这时候要思考为什么。常见原因有:表太小,全表扫描比索引更快;统计信息过期;查询条件里用了函数,索引键被包裹后没法匹配。
举个例子,如果你对email做了lower函数转换查询:
sql复制SELECT * FROM users WHERE lower(email) = 'user100@example.com';
普通的email唯一索引就帮不上忙,因为索引里存的是原始值,不是lower之后的值。这种情况应该建函数索引:
sql复制CREATE UNIQUE INDEX idx_users_email_lower ON users(lower(email));
函数唯一索引在用户系统里很实用——既可以保证邮箱不区分大小写地不重复,又能让lower(email)的查询走索引。
4.5 常见问题速查表
| 问题 | 可能原因 | 解决方案 |
|---|---|---|
| 建唯一索引报duplicate key | 表里已有重复数据 | 先查重、去重,再建索引 |
| 复合索引查询没变快 | 没满足最左前缀原则 | 调整查询条件或重新设计列顺序 |
| 唯一约束自动建的索引名不可控 | 系统自动命名 | 建表时指定CONSTRAINT名称,或先建索引再添加约束 |
| INCLUDE列在PG11以下没用 | 版本不支持 | 升级到PG11+,或放弃索引覆盖选择回表 |
| 唯一索引允许重复NULL | NULL互不相等 | 需要业务规则时考虑部分唯一索引 |
| 索引膨胀导致查询慢 | UPDATE/DELETE频繁 | 定期REINDEX CONCURRENTLY |
| 查询走错索引 | 统计信息过旧 | 执行ANALYZE,或调整成本参数 |
4.6 综合案例:给千万级大表加复合唯一索引
最后分享一个生产环境的真实操作。有一张用户事件表,接近1200万行,业务要求同一个用户、同一个事件类型、同一天只能保留一条记录。这个约束必须靠数据库来保证,不能指望应用层判断。
第一步,查重复:
sql复制SELECT user_id, event_type, event_date, COUNT(*)
FROM user_events
GROUP BY user_id, event_type, event_date
HAVING COUNT(*) > 1
LIMIT 20;
第二步,确认保留策略。和业务方对齐后,决定保留ID最小的记录,删除其余记录:
sql复制DELETE FROM user_events a
USING user_events b
WHERE a.user_id = b.user_id
AND a.event_type = b.event_type
AND a.event_date = b.event_date
AND a.id > b.id;
第三步,创建复合唯一索引。这一步我用CONCURRENTLY,避免长锁阻塞线上写入:
sql复制CREATE UNIQUE INDEX CONCURRENTLY idx_user_events_unique
ON user_events(user_id, event_type, event_date);
这里要特别提醒:CONCURRENTLY创建唯一索引时,如果表里还有重复数据,索引会创建失败,并且会留下一个INVALID索引。这个无效索引不仅帮不上查询,还会持续占资源。所以建之前一定要确认重复数据已经清理干净。一旦发现创建失败,需要手动清理:
sql复制DROP INDEX CONCURRENTLY idx_user_events_unique;
然后处理数据后重试。
第四步,验证:
sql复制SELECT user_id, event_type, event_date, COUNT(*)
FROM user_events
GROUP BY user_id, event_type, event_date
HAVING COUNT(*) > 1;
返回0行,说明约束生效。从此以后,不管是应用层并发漏判,还是有人手工改数据,数据库都会挡住重复事件记录。
最后说点自己的体会。我给很多项目做过索引优化,踩过无数坑之后最大的心得是:别迷信“万能索引设计规则”。唯一索引和复合索引都不是万金油,它们必须服务于你真实的查询和真实的业务约束。唯一索引让我睡得踏实,因为重复数据这种最脏、最难查的问题被数据库兜底了;复合索引让我从全表扫描的泥潭里爬出来,把慢查询从秒级拉到毫秒级。但每一次加索引,我都要回头看一下执行计划,确认它真的被用上了,而不是让索引变成摆设。希望这篇文章能让你少踩几个我踩过的坑。
