CREATE INDEX 基础
上一节讲清了 B-Tree 能做什么,但要真正用起来,绕不开 CREATE INDEX 这条命令。它看起来简单,参数却不少:唯一性、并发、覆盖列、部分索引、存储参数、表空间……每一个都对应一种生产环境的真实需求。
本节把 CREATE INDEX 的完整语法拆开逐个讲透,重点说明 CONCURRENTLY 在线建索引的原理与限制、CREATE INDEX 与 ALTER TABLE ADD CONSTRAINT 的区别、索引对写入的影响,以及如何用 EXPLAIN、系统表验证索引是否真的被用上。学完这一节,你能在生产库上安全地加索引、删索引,并判断一次索引变更是否成功。
前置知识
阅读本节前,建议先了解:B-Tree 索引原理
基础概念
CREATE INDEX 做了什么
一条 CREATE INDEX 在 PostgreSQL 内部大致经历三个阶段:
1. 解析与检查
├── 校验表存在、列存在、表达式是否 IMMUTABLE
├── 校验操作符类是否存在(如 text_pattern_ops)
└── 检查同名关系是否已存在
2. 构建索引
├── 扫描全表,把每一行转成索引条目
├── 排序后批量写入索引页(不再是逐行插入,速度快得多)
└── 在系统目录 pg_class / pg_index 中登记
3. 收尾
├── 标记索引为 valid
├── 更新 relcache / 失效旧计划缓存
└── 通知统计收集器关键一点:建索引走的是「排序 + 批量写」而非「逐行插入」。这也是为什么「批量导入后一次建索引」远比「先建索引再逐行导入」快。
索引命名规则
索引名在同一 schema 内必须唯一,且不能与表、序列、视图、物化视图、外部表重名——因为它们共用 pg_class 一个命名空间。
| 情况 | 行为 |
|---|---|
| 显式指定名字 | 使用该名字,冲突则报错 |
| 省略名字 | 自动生成,通常是 表名_列名_idx(冲突时加数字后缀) |
| 名字含 schema | 不允许,索引总与父表同 schema |
-- 自动生成的典型名字
CREATE INDEX ON users (email); → users_email_idx
CREATE INDEX ON orders (customer_id); → orders_customer_id_idx
CREATE UNIQUE INDEX ON t (a, b); → t_a_b_key (唯一/主键用 _key / _pkey)命名规范建议
生产环境强烈建议显式命名。自动名字在加列、改列后含义会漂移,而显式命名如 idx_orders_customer_created 能自我说明用途,便于监控与运维。
CREATE INDEX vs ALTER TABLE ADD CONSTRAINT
两者都能产生索引,但目的不同:
| 维度 | CREATE UNIQUE INDEX | ALTER TABLE ... ADD CONSTRAINT |
|---|---|---|
| 目的 | 性能 / 唯一性都行 | 声明业务约束 |
| 是否可延迟(DEFERRABLE) | 不能 | 可以 |
| 是否可被外键引用 | 不能 | 可以 |
是否出现在 \d 的 Constraints 段 | 否 | 是 |
是否可 ON CONFLICT ON CONSTRAINT | 否 | 是 |
是否可被 pg_dump 还原为约束 | 否(还原为索引) | 是 |
一句话:要表达「这是业务规则」用约束,要表达「这是性能优化」用索引。 唯一索引只在「约束语义」上等价于 UNIQUE 约束,但缺少可延迟性与外键可引用性。
在线建索引(CONCURRENTLY)的原理
普通 CREATE INDEX 会持有 SHARE 锁,阻塞所有写操作(读不受影响)。对大表来说,这个阻塞可能长达数小时。CONCURRENTLY 通过三阶段避免长时间阻塞:
阶段 1(事务 1):在系统目录登记一个 INVALID 索引
│
▼
阶段 2(事务 2):第一次全表扫描,构建索引内容
│ 等待所有「能修改该表」的事务结束
▼
阶段 3(事务 3):第二次扫描,补齐阶段 2 期间产生的新行
│ 等待所有「快照早于阶段 3」的事务结束
▼
标记 VALID,可用代价与限制:
| 項 | 说明 |
|---|---|
| 慢 | 要扫两遍表,且要等事务;总耗时明显更长 |
| 占资源 | 构建期间额外 CPU / I/O 会拖慢其他查询 |
| 不能包在事务块里 | BEGIN; CREATE INDEX CONCURRENTLY ...; COMMIT; 会报错 |
| 失败会留残骸 | 失败后留下 INVALID 索引,会拖慢写入却不被查询使用 |
| 分区表 | 不能在分区表上直接 CONCURRENTLY(可对每个分区单独执行,或 PG 支持的方式) |
失败的 INVALID 索引长这样:
postgres=# \d tab
Table "public.tab"
Column | Type | Collation | Nullable | Default
--------+---------+-----------+----------+---------
col | integer | | |
Indexes:
"idx" btree (col) INVALID索引对写操作的影响
每个索引都是写放大(write amplification)的来源:
| 操作 | 需要维护的索引动作 |
|---|---|
INSERT | 每个索引插入 1 个条目 |
UPDATE(改索引列) | 每个索引删除旧条目 + 插入新条目 |
UPDATE(不改索引列) | 若新版本不在原页(HOT 更新失败),仍需更新索引指向 |
DELETE | 每个索引标记删除(真正回收靠 VACUUM) |
所以「表上有 N 个索引,一次插入就是 N+1 次写入」。索引不是越多越好。
HOT 更新
当 UPDATE 只改非索引列、且新页与原页在同一页内时,PostgreSQL 使用 HOT(Heap-Only Tuple)更新,可以完全跳过索引维护。一旦改动了建了索引的列,HOT 就失效,所有索引都要更新。
索引类型与默认操作符类
| 索引方法 | 关键词 | 适用 |
|---|---|---|
| B-Tree | btree(默认) | 等值、范围、排序、唯一 |
| Hash | hash | 仅等值 |
| GiST | gist | 几何、范围、KNN |
| SP-GiST | spgist | 非平衡空间划分 |
| GIN | gin | 数组、JSONB、全文 |
| BRIN | brin | 超大表、物理相关的列 |
验证索引是否被使用
光「建了索引」不等于「用上了索引」。验证的三个层次:
| 层次 | 手段 | 回答的问题 |
|---|---|---|
| 单条语句 | EXPLAIN (ANALYZE, BUFFERS) | 这条 SQL 用没用索引 |
| 累计统计 | pg_stat_user_indexes.idx_scan | 这个索引长期有没有被用 |
| 物理健康 | pgstatindex() / pg_relation_size() | 索引体积、膨胀、层数是否合理 |
语法与用法
CREATE [ UNIQUE ] INDEX [ CONCURRENTLY ] [ [ IF NOT EXISTS ] 索引名 ]
ON [ ONLY ] 表名 [ USING 方法 ]
( { 列名 | ( 表达式 ) }
[ COLLATE 排序规则 ]
[ 操作符类 [ ( 操作符类参数 = 值 [, ...] ) ] ]
[ ASC | DESC ]
[ NULLS { FIRST | LAST } ] [, ...] )
[ INCLUDE ( 列名 [, ...] ) ]
[ NULLS [ NOT ] DISTINCT ]
[ WITH ( 存储参数 [= 值] [, ... ] ) ]
[ TABLESPACE 表空间名 ]
[ WHERE 谓词 ]最小可运行示例:
DROP TABLE IF EXISTS demo_index;
CREATE TABLE demo_index (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL
);
CREATE INDEX idx_demo_index_email ON demo_index (email);DROP TABLE
CREATE TABLE
CREATE INDEX详细说明
完整语法逐参数拆解
把语法槽位一个个填满,看看每个位置能写什么:
DROP TABLE IF EXISTS idx_demo;
CREATE TABLE idx_demo (
id bigint GENERATED ALWAYS AS IDENTITY,
email text NOT NULL,
status text NOT NULL DEFAULT 'active',
created_at timestamptz NOT NULL DEFAULT now()
);
-- 唯一 + 并发 + 不存在才建 + 指定方法 + 操作符类 + 排序 + NULL 位置
CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS idx_idx_demo_email
ON idx_demo USING btree (email text_pattern_ops DESC NULLS LAST);DROP TABLE
CREATE TABLE
CREATE INDEX各槽位的取值约束:
| 槽位 | 可选值 | 备注 |
|---|---|---|
UNIQUE | 有 / 无 | 有则强制唯一 |
CONCURRENTLY | 有 / 无 | 不能用于临时表、不能用于事务块 |
IF NOT EXISTS | 有 / 无 | 单独用无效,必须写索引名 |
ONLY | 有 / 无 | 分区表不递归到子分区 |
USING | btree/hash/gist/spgist/gin/brin | 默认 btree |
COLLATE | 排序规则名 | 影响比较与排序 |
opclass | 操作符类名 | 如 text_pattern_ops |
ASC/DESC | 排序方向 | 默认 ASC |
NULLS FIRST/LAST | NULL 位置 | ASC 默认 LAST,DESC 默认 FIRST |
INCLUDE | 列名列表 | 非键列,B-Tree/GiST/SP-GiST 支持 |
NULLS NOT DISTINCT | 有 / 无 | 唯一索引下 NULL 视为相等(PG 15+) |
WITH | 存储参数 | fillfactor、deduplicate_items 等 |
TABLESPACE | 表空间名 | 把索引放独立磁盘 |
WHERE | 布尔谓词 | 生成部分索引 |
IF NOT EXISTS 与幂等部署
在自动化脚本里,重复执行建索引会报错并中断流程。IF NOT EXISTS 解决这个问题:
CREATE INDEX IF NOT EXISTS idx_idx_demo_email ON idx_demo (email);NOTICE: relation "idx_idx_demo_email" already exists, skipping
CREATE INDEX注意输出是 NOTICE 而不是报错——脚本可以继续执行。
IF NOT EXISTS 不校验定义
它只检查名字。如果已存在一个同名但结构完全不同的索引(比如建在别的列上),它依然跳过,不会报错也不会修正。所以只在「确定名字唯一对应一种定义」时使用。
ALTER TABLE ADD CONSTRAINT 与 USING INDEX
给已存在的表加主键/唯一约束时,PostgreSQL 有两种路径,性能差异巨大:
路径一:直接加约束(会隐式建索引并全程持锁)
ALTER TABLE idx_demo ADD CONSTRAINT idx_demo_email_key UNIQUE (email);ALTER TABLE路径二:先并发建唯一索引,再把索引「提升」为约束(生产推荐)
-- 第 1 步:并发建唯一索引(此时已经保证唯一性)
CREATE UNIQUE INDEX CONCURRENTLY idx_demo_email_uq ON idx_demo (email);
-- 第 2 步:把索引挂成约束(瞬间完成,不重新扫描)
ALTER TABLE idx_demo
ADD CONSTRAINT idx_demo_email_key UNIQUE USING INDEX idx_demo_email_uq;CREATE INDEX
ALTER TABLE对比:
| 方式 | 锁 | 耗时 | 失败残留 |
|---|---|---|---|
ADD CONSTRAINT UNIQUE | 全程 SHARE 阻塞写 | 一次扫描 | 无(回滚) |
CREATE UNIQUE INDEX CONCURRENTLY + USING INDEX | 建索引不阻塞 | 两次扫描 + 瞬时挂接 | 可能留 INVALID 索引 |
用 USING INDEX 还可以给主键「换索引」:
-- 把已有唯一索引变成主键
ALTER TABLE idx_demo
ADD CONSTRAINT idx_demo_pkey PRIMARY KEY USING INDEX idx_demo_email_uq;ALTER TABLE覆盖列 INCLUDE 与仅索引扫描
INCLUDE 把列作为非键列挂在叶子条目上,不参与排序、不参与唯一性判断,但能被仅索引扫描直接读出:
DROP TABLE IF EXISTS inc_demo;
CREATE TABLE inc_demo (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
sku text NOT NULL,
price numeric(10,2) NOT NULL,
stock integer NOT NULL
);
INSERT INTO inc_demo (sku, price, stock)
SELECT 'SKU-' || lpad(g::text, 6, '0'), (random()*100)::numeric(10,2), (random()*1000)::int
FROM generate_series(1, 50000) AS g;
CREATE INDEX idx_inc_demo_sku ON inc_demo (sku) INCLUDE (price, stock);
VACUUM ANALYZE inc_demo;DROP TABLE
CREATE TABLE
INSERT 0 50000
CREATE INDEX
VACUUMEXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT sku, price, stock FROM inc_demo WHERE sku = 'SKU-000777'; QUERY PLAN
------------------------------------------------------------------------------------
Index Only Scan using idx_inc_demo_sku on inc_demo (actual rows=1 loops=1)
Index Cond: (sku = 'SKU-000777'::text)
Heap Fetches: 0
Buffers: shared hit=4
(4 rows)Heap Fetches: 0 说明整条查询没回表。
INCLUDE 的三条硬限制
- 不能是表达式(因为无法用于仅索引扫描);
- 索引条目超限会导致插入失败,别放宽列;
- 有 INCLUDE 列的 B-Tree 不使用去重,体积会更大。
部分索引 WHERE
部分索引只索引满足条件的行子集,体积小、维护便宜:
DROP TABLE IF EXISTS task_demo;
CREATE TABLE task_demo (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
status text NOT NULL,
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO task_demo (status, payload)
SELECT
CASE WHEN g % 100 = 0 THEN 'pending' ELSE 'done' END,
jsonb_build_object('n', g)
FROM generate_series(1, 100000) AS g;
-- 只索引 1% 的待处理任务
CREATE INDEX idx_task_demo_pending
ON task_demo (created_at)
WHERE status = 'pending';
VACUUM ANALYZE task_demo;DROP TABLE
CREATE TABLE
INSERT 0 100000
CREATE INDEX
VACUUMSELECT
pg_size_pretty(pg_relation_size('idx_task_demo_pending')) AS partial_size,
pg_size_pretty(pg_relation_size('task_demo_pkey')) AS pkey_size; partial_size | pkey_size
--------------+-----------
16 kB | 3544 kB仅索引 1000 行,索引只有 16 kB,而全表主键索引 3.5 MB。
部分索引要生效,查询条件必须逻辑上蕴含索引的 WHERE:
-- 会走部分索引:条件里明确写了 status = 'pending'
EXPLAIN (COSTS OFF)
SELECT id FROM task_demo WHERE status = 'pending' AND created_at > now() - interval '1 day'; QUERY PLAN
------------------------------------------------------------------------------------------
Index Scan using idx_task_demo_pending on task_demo
Index Cond: (created_at > (now() - '1 day'::interval))
Filter: (status = 'pending'::text)
(3 rows)存储参数:fillfactor 与 deduplicate_items
CREATE INDEX idx_inc_demo_sku_hot
ON inc_demo (sku)
WITH (fillfactor = 70, deduplicate_items = on);CREATE INDEX| 参数 | 适用方法 | 默认 | 含义 |
|---|---|---|---|
fillfactor | btree/hash/gist/spgist | 90(B-Tree) | 页填充率,低则留白多、分裂少 |
deduplicate_items | btree | on | 合并重复键为 posting list |
调整已有索引的参数:
ALTER INDEX idx_inc_demo_sku_hot SET (fillfactor = 60);ALTER INDEX为什么 fillfactor = 100 往往有害
页填满后任何一次插入都可能触发分裂,甚至引发「分裂洪水」。只有完全静态(永不增删改)的表才适合 100。
指定表空间
把索引放到独立磁盘(如 SSD)能显著改善随机 I/O:
-- 先确保表空间存在
CREATE TABLESPACE fast_ssd LOCATION '/mnt/ssd/pg_tblspc';
CREATE INDEX idx_inc_demo_sku_ts ON inc_demo (sku) TABLESPACE fast_ssd;CREATE TABLESPACE
CREATE INDEX检查索引所在表空间:
SELECT c.relname AS index_name, ts.spcname AS tablespace
FROM pg_class c
JOIN pg_tablespace ts ON ts.oid = c.reltablespace
WHERE c.relname LIKE 'idx_inc_demo%'; index_name | tablespace
-----------------------------+------------
idx_inc_demo_sku_ts | fast_ssd
(1 row)CONCURRENTLY 的失败与恢复
并发建索引一旦失败(死锁、唯一冲突、磁盘满、被取消),会留下 INVALID 索引。它不被查询使用,但仍会拖慢写入,必须清理:
-- 找出所有无效索引
SELECT
c.relname AS index_name,
n.nspname AS schema_name,
t.relname AS table_name
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
JOIN pg_class t ON t.oid = i.indrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE NOT i.indisvalid; index_name | schema_name | table_name
------------+-------------+------------
(0 rows)两种恢复方式:
-- 方式一:删掉重建(推荐,语义最清晰)
DROP INDEX CONCURRENTLY idx_bad_index;
CREATE INDEX CONCURRENTLY idx_bad_index ON big_table (col);
-- 方式二:直接重建(保留原名)
REINDEX INDEX CONCURRENTLY idx_bad_index;DROP INDEX
CREATE INDEX
REINDEXDROP INDEX 与 CONCURRENTLY
DROP INDEX [ CONCURRENTLY ] [ IF EXISTS ] 索引名 [, ...] [ CASCADE | RESTRICT ]| 选项 | 说明 |
|---|---|
CONCURRENTLY | 不阻塞读写,但只能指定一个索引名 |
IF EXISTS | 不存在不报错 |
CASCADE | 连带删除依赖对象(如外键) |
RESTRICT | 有依赖则拒绝(默认) |
DROP INDEX CONCURRENTLY 的限制比 CREATE 更多:
-- 合法
DROP INDEX CONCURRENTLY idx_inc_demo_sku_hot;DROP INDEX以下都不能用、或会报错:
| 操作 | 是否允许 |
|---|---|
DROP INDEX CONCURRENTLY a, b; | 不允许,只能一个 |
DROP INDEX CONCURRENTLY ... CASCADE; | 不允许 |
在事务块内 DROP INDEX CONCURRENTLY | 不允许 |
| 用于分区表索引 | 不允许 |
删除支持 UNIQUE / PRIMARY KEY 约束的索引 | 不允许(要用 ALTER TABLE DROP CONSTRAINT) |
-- 删除约束索引的正确方式
ALTER TABLE idx_demo DROP CONSTRAINT idx_demo_email_key;ALTER TABLE验证索引是否被使用
第一步:强制 ANALYZE,让统计信息反映真实数据分布:
ANALYZE inc_demo;ANALYZE第二步:EXPLAIN 看计划里有没有 Index Cond:
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT id FROM inc_demo WHERE sku = 'SKU-000777'; QUERY PLAN
---------------------------------------------------------------------------------
Index Only Scan using idx_inc_demo_sku on inc_demo (actual rows=1 loops=1)
Index Cond: (sku = 'SKU-000777'::text)
Heap Fetches: 0
(3 rows)第三步:必要时临时关掉顺序扫描以确认索引可用(仅用于排查,别在生产长期设置):
SET enable_seqscan = off;
EXPLAIN (COSTS OFF)
SELECT id FROM inc_demo WHERE sku = 'SKU-000777';
RESET enable_seqscan;SET
QUERY PLAN
---------------------------------------------------------------------------------
Index Only Scan using idx_inc_demo_sku on inc_demo
Index Cond: (sku = 'SKU-000777'::text)
(2 rows)
RESET第四步:长期视角看 idx_scan:
SELECT
indexrelid::regclass AS index_name,
idx_scan,
idx_tup_read,
idx_tup_fetch
FROM pg_stat_user_indexes
WHERE relname = 'inc_demo'; index_name | idx_scan | idx_tup_read | idx_tup_fetch
----------------------+----------+--------------+---------------
inc_demo_pkey | 0 | 0 | 0
idx_inc_demo_sku | 3 | 3 | 0
(2 rows)idx_tup_fetch = 0 印证了仅索引扫描(不需要从堆表取行)。
用系统表读取索引定义
-- 单索引定义
SELECT indexrelid::regclass AS index_name, indexdef
FROM pg_indexes
WHERE indexname = 'idx_inc_demo_sku'; index_name | indexdef
-----------------------+------------------------------------------------------------------------
idx_inc_demo_sku | CREATE INDEX idx_inc_demo_sku ON public.inc_demo USING btree (sku)
(1 row)-- 更精确的还原(含存储参数)
SELECT pg_get_indexdef('idx_inc_demo_sku_hot'::regclass); pg_get_indexdef
----------------------------------------------------------------------------------------------------------------------------------
CREATE INDEX idx_inc_demo_sku_hot ON public.inc_demo USING btree (sku) WITH (fillfactor='70', deduplicate_items='true')
(1 row)索引的锁行为对照
不同 CREATE INDEX 变体持有的锁不同,直接决定生产可用性:
| 语句 | 对写操作的阻塞 | 对读操作 |
|---|---|---|
CREATE INDEX | 阻塞(SHARE 锁) | 不阻塞 |
CREATE INDEX CONCURRENTLY | 不阻塞 | 不阻塞 |
REINDEX INDEX | 阻塞 | 阻塞(ACCESS EXCLUSIVE) |
REINDEX INDEX CONCURRENTLY | 不阻塞 | 不阻塞 |
DROP INDEX | 阻塞(ACCESS EXCLUSIVE) | 阻塞 |
DROP INDEX CONCURRENTLY | 不阻塞 | 不阻塞 |
实战示例
场景:一张 200 万行的订单表,需要在业务不停机的前提下,从「无索引」演进到「主键 + 复合索引 + 部分索引」,并全程验证。
第 1 步:建表(先不建主键)并灌入 200 万行
DROP TABLE IF EXISTS prod_orders CASCADE;
CREATE TABLE prod_orders (
id bigint,
customer_id integer NOT NULL,
status text NOT NULL,
amount numeric(12,2) NOT NULL,
created_at timestamptz NOT NULL
);
INSERT INTO prod_orders (id, customer_id, status, amount, created_at)
SELECT
g,
(random() * 200000)::int,
CASE WHEN g % 50 = 0 THEN 'pending' ELSE 'done' END,
(random() * 9000)::numeric(12,2),
now() - (g || ' seconds')::interval
FROM generate_series(1, 2000000) AS g;
ANALYZE prod_orders;DROP TABLE
CREATE TABLE
INSERT 0 2000000
ANALYZE第 2 步:并发建立唯一索引(为后面挂主键做准备)
CREATE UNIQUE INDEX CONCURRENTLY uq_prod_orders_id ON prod_orders (id);CREATE INDEX第 3 步:把唯一索引提升为主键(瞬时完成)
ALTER TABLE prod_orders ADD CONSTRAINT prod_orders_pkey PRIMARY KEY USING INDEX uq_prod_orders_id;ALTER TABLE验证主键已生效:
SELECT conname, contype, pg_get_constraintdef(oid) AS definition
FROM pg_constraint
WHERE conrelid = 'prod_orders'::regclass; conname | contype | definition
------------------+---------+----------------------
prod_orders_pkey | p | PRIMARY KEY (id)
(1 row)第 4 步:为高频查询建立复合索引
业务查询是「查某客户最近的订单,按时间倒序取前 20 条」。
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT id, amount, created_at
FROM prod_orders
WHERE customer_id = 88888
ORDER BY created_at DESC
LIMIT 20; QUERY PLAN
---------------------------------------------------------------------------------------------------------
Limit (actual rows=10 loops=1)
Buffers: shared hit=6483
-> Sort (actual rows=10 loops=1)
Sort Key: created_at DESC
Sort Method: top-N heapsort Memory: 25kB
Buffers: shared hit=6483
-> Seq Scan on prod_orders (actual rows=10 loops=1)
Filter: (customer_id = 88888)
Rows Removed by Filter: 1999990
(8 rows)顺序扫描 200 万行。建立复合索引:
CREATE INDEX CONCURRENTLY idx_prod_orders_cust_created
ON prod_orders (customer_id, created_at DESC);
ANALYZE prod_orders;CREATE INDEX
ANALYZEEXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT id, amount, created_at
FROM prod_orders
WHERE customer_id = 88888
ORDER BY created_at DESC
LIMIT 20; QUERY PLAN
--------------------------------------------------------------------------------------------------------------
Limit (actual rows=10 loops=1)
Buffers: shared hit=8
-> Index Scan Backward using idx_prod_orders_cust_created on prod_orders (actual rows=10 loops=1)
Index Cond: (customer_id = 88888)
Buffers: shared hit=7
(5 rows)两个变化:Sort 节点消失(索引本身有序),缓冲区从 6483 降到 8。
第 5 步:为「待处理任务」建立部分索引
CREATE INDEX CONCURRENTLY idx_prod_orders_pending
ON prod_orders (created_at)
WHERE status = 'pending';
ANALYZE prod_orders;CREATE INDEX
ANALYZEEXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT count(*) FROM prod_orders WHERE status = 'pending'; QUERY PLAN
-------------------------------------------------------------------------------------------
Aggregate (actual rows=1 loops=1)
Buffers: shared hit=5
-> Index Only Scan using idx_prod_orders_pending on prod_orders (actual rows=40000 loops=1)
Heap Fetches: 0
Buffers: shared hit=4
(5 rows)第 6 步:对比三个索引的体积
SELECT
indexrelid::regclass AS index_name,
idx_scan,
pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
WHERE relname = 'prod_orders'
ORDER BY pg_relation_size(indexrelid) DESC; index_name | idx_scan | size
-----------------------------------+----------+---------
prod_orders_pkey | 0 | 47 MB
idx_prod_orders_cust_created | 1 | 101 MB
idx_prod_orders_pending | 1 | 1304 kB
(3 rows)部分索引只占 1.3 MB,因为只索引了 2% 的行。
第 7 步:检查有没有残留的无效索引
SELECT c.relname AS index_name
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
WHERE i.indrelid = 'prod_orders'::regclass AND NOT i.indisvalid; index_name
------------
(0 rows)第 8 步:清理
DROP TABLE prod_orders;
DROP TABLE inc_demo;
DROP TABLE task_demo;
DROP TABLE idx_demo;
DROP TABLE demo_index;注意事项
CREATE INDEX CONCURRENTLY不能放在事务块中,否则直接报错。sql-- 错误! BEGIN; CREATE INDEX CONCURRENTLY idx_x ON t (col); COMMIT;并发建索引失败会留下
INVALID索引,它不服务查询却拖慢写入,必须清理。sqlSELECT c.relname FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid WHERE NOT i.indisvalid;IF NOT EXISTS只比名字不比定义,同名不同定义会被静默跳过。sql-- 危险:如果已存在同名索引,这句什么也不做 CREATE INDEX IF NOT EXISTS idx_x ON t (other_col);DROP INDEX CONCURRENTLY一次只能删一个索引,且不支持CASCADE。sql-- 错误!只能指定一个索引 DROP INDEX CONCURRENTLY a, b;不能直接用
DROP INDEX删除约束背后的索引,要用ALTER TABLE DROP CONSTRAINT。sql-- 错误!会报 "cannot drop index ... because constraint ... requires it" DROP INDEX prod_orders_pkey;表达式索引里的函数必须是
IMMUTABLE,now()、random()这类会报错。sql-- 错误!now() 不是 IMMUTABLE CREATE INDEX idx_bad ON t ((now()::date));索引不是越多越好。每个索引都会拖慢所有写操作,还会占用缓存。
sql-- 不推荐:一张表上挂十几个索引并发建索引期间不要同时跑其他并发建索引,它们的快照互相等待,会显著延长耗时。
建索引别忘
ANALYZE,否则优化器可能因为统计过期而不使用新索引。sqlCREATE INDEX CONCURRENTLY idx_x ON t (col); ANALYZE t; -- 关键的一步fillfactor = 100在高更新表上是有害的,会导致大量页分裂。sql-- 不推荐(除非表完全静态) CREATE INDEX idx_bad ON hot_table (col) WITH (fillfactor = 100);INCLUDE列不能是表达式,也不能用于唯一性判断。sql-- 错误!表达式不能作为 INCLUDE 列 CREATE INDEX idx_bad ON t (a) INCLUDE ((b + 1));临时表的
CREATE INDEX始终是非并发的,CONCURRENTLY对临时表无意义(也更快)。索引名冲突发生在整个 schema 级,索引名不能与同 schema 的表名重复。
最佳实践
大表建索引一律用
CONCURRENTLY,并在失败后检查残留:sqlCREATE INDEX CONCURRENTLY idx_big ON big_table (col); SELECT c.relname FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid WHERE i.indrelid = 'big_table'::regclass AND NOT i.indisvalid;给已有表加主键/唯一约束时,先并发建索引再
USING INDEX挂接:sqlCREATE UNIQUE INDEX CONCURRENTLY uq_t_id ON t (id); ALTER TABLE t ADD CONSTRAINT t_pkey PRIMARY KEY USING INDEX uq_t_id;明确命名所有索引,用
idx_表_列的约定,便于监控和排查:sqlCREATE INDEX idx_orders_customer_created ON orders (customer_id, created_at DESC);建完索引立即
ANALYZE:sqlCREATE INDEX CONCURRENTLY idx_x ON t (col); ANALYZE t;小结果集的过滤条件用部分索引,能大幅降低索引体积与维护成本:
sqlCREATE INDEX idx_orders_pending ON orders (created_at) WHERE status = 'pending';批量导入采用「先删索引 → 导入 → 重建索引」策略,比逐行维护快得多:
sqlDROP INDEX idx_orders_customer_created; -- COPY / INSERT 大批量数据 CREATE INDEX idx_orders_customer_created ON orders (customer_id, created_at DESC);需要覆盖查询时用
INCLUDE,但只放窄列:sqlCREATE INDEX idx_orders_cover ON orders (customer_id) INCLUDE (amount);高频更新的索引调低
fillfactor:sqlCREATE INDEX idx_hot ON hot_table (col) WITH (fillfactor = 70);定期清理无用索引,
idx_scan = 0且非约束索引的都应审查:sqlSELECT indexrelid::regclass AS index_name, idx_scan, pg_size_pretty(pg_relation_size(indexrelid)) AS size FROM pg_stat_user_indexes WHERE schemaname = 'public' AND idx_scan = 0 ORDER BY pg_relation_size(indexrelid) DESC;用
EXPLAIN (ANALYZE, BUFFERS)而非仅EXPLAIN验证效果,前者才有真实行数与 I/O:sqlEXPLAIN (ANALYZE, COSTS OFF, BUFFERS) SELECT ... ;
下一节
继续学习:多列索引