部分索引(Partial Index)
一张表里往往只有一小部分行会被反复查询:订单表里 95% 的行早已「已完成」,只有 5% 的「待处理」订单需要被扫描;日志表里只有最近一个月的记录会被检索;软删除的表里只有 deleted_at IS NULL 的行才需要被看见。给整张表建索引,等于为那些永远不会被查的行白白付出存储与写入代价。
部分索引(Partial Index)就是为解决这个问题而生的:在索引定义上挂一个 WHERE 谓词,只有满足谓词的行才会被写进索引。本节讲清它的语法、谓词必须 IMMUTABLE 这条硬性门槛、谓词蕴含(Predicate Implication)这套「什么查询才用得上」的判定规则,以及它在体积、写入开销与条件唯一性上的典型用法。
前置知识
阅读本节前,建议先了解:CREATE INDEX 基础、B-Tree 索引原理、唯一索引与唯一约束
基础概念
索引里只装「满足谓词的行」
普通索引的键是「全表所有行」的列值;部分索引的键只包含谓词为真的那部分行:
普通索引:CREATE INDEX ON orders (status)
索引条目 ← 所有行的 status (100 万行 → 100 万条)
部分索引:CREATE INDEX ON orders (status) WHERE status = 'pending'
索引条目 ← 仅 status='pending' 的行(1 万行 → 1 万条)一句话概括:部分索引是「带过滤条件的索引」。它用体积和写入换来的收益是:更小的索引、更少的 I/O、更高的缓存命中率。
三个关键前提
| 前提 | 说明 | 违反的后果 |
|---|---|---|
谓词必须是 IMMUTABLE | 相同输入永远得到相同结果,不依赖会话状态 | 建索引直接报错 |
| 谓词只能引用本表列 | 不能出现子查询、不能引用别的表、不能用 VOLATILE 函数 | 建索引直接报错 |
| 查询必须能被谓词「蕴含」 | 优化器要能证明「查的行一定在索引里」 | 不报错,但静默退化为顺序扫描 |
第三条是最容易踩的坑,因为它没有任何提示——你以为索引在工作,EXPLAIN 里却是 Seq Scan。
常见的使用场景
| 场景 | 谓词写法 | 收益 |
|---|---|---|
| 队列 / 任务表,只查未完成 | WHERE status = 'pending' | 索引体积降到 5% |
| 软删除表,只查存活行 | WHERE deleted_at IS NULL | 避免为已删数据维护索引 |
| 条件唯一性(一租户一条主记录) | CREATE UNIQUE INDEX ... WHERE is_primary | 用索引表达业务规则 |
| 日志 / 事件表,只查近期数据 | WHERE created_at >= '2026-01-01' | 老数据不占索引 |
| 稀疏值(枚举里极少出现的值) | WHERE level = 'error' | 只给少数行建索引 |
| 部分列有值(如扩展字段) | WHERE payload IS NOT NULL | 跳过大量 NULL |
部分索引不是「免费的过滤器」
部分索引只服务于能匹配谓词的查询。同一个列上的其他查询(不满足谓词条件的)完全用不上它,需要另建普通索引。不要以为建了部分索引就覆盖了该列的全部查询。
语法与用法
-- 基本形式:在索引定义末尾附加 WHERE 谓词
CREATE [ UNIQUE ] INDEX 索引名 ON 表名 (列列表) WHERE 谓词;
-- 谓词可以引用索引列,也可以引用没被索引的列
CREATE INDEX 索引名 ON 表名 (created_at) WHERE status = 'pending';
CREATE INDEX 索引名 ON 表名 (created_at) WHERE deleted_at IS NULL;
-- 多列 + 部分
CREATE INDEX 索引名 ON 表名 (tenant_id, created_at) WHERE status <> 'done';
-- 部分唯一索引:条件唯一性
CREATE UNIQUE INDEX 索引名 ON 表名 (tenant_id) WHERE is_primary;
-- 部分索引 + 表达式索引(谓词与表达式可以共存)
CREATE INDEX 索引名 ON 表名 (lower(email)) WHERE deleted_at IS NULL;
-- 部分索引 + 覆盖列(PostgreSQL 11+)
CREATE INDEX 索引名 ON 表名 (created_at) INCLUDE (id, amount) WHERE status = 'pending';
-- 在线创建大表的部分索引
CREATE INDEX CONCURRENTLY 索引名 ON 表名 (created_at) WHERE status = 'pending';最小可运行示例:
DROP TABLE IF EXISTS orders;
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
status text NOT NULL,
amount numeric(12,2) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_orders_pending ON orders (created_at) WHERE status = 'pending';DROP TABLE
CREATE TABLE
CREATE INDEX详细说明
部分索引的本质:只给「一部分行」建 B-Tree
PostgreSQL 在处理部分索引时做的事很直白:插入 / 更新一行时先算谓词,为真才往这棵 B-Tree 里写一条;查询时先证明「要查的行都在索引里」,才走这棵 B-Tree。
INSERT INTO orders (status, amount)
SELECT
(ARRAY['done','done','done','done','pending'])[1 + (g % 5)],
(g % 500)::numeric(12,2)
FROM generate_series(1, 100000) AS g;
ANALYZE orders;INSERT 0 100000
ANALYZE此时索引里只有 2 万条「待处理」记录,而不是 10 万条:
idx_orders_pending 的键 → 行指针(TID)
2026-01-01 10:00:00+08 → (0, 5)
2026-01-01 10:00:05+08 → (0, 10)
... (仅 status='pending' 的行)查询谓词与索引谓词一致时,就能走 Index Scan:
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT id, amount FROM orders WHERE status = 'pending' ORDER BY created_at LIMIT 10; QUERY PLAN
------------------------------------------------------------------------------------------
Limit (actual rows=10 loops=1)
-> Index Scan using idx_orders_pending on orders (actual rows=10 loops=1)
Index Cond: (created_at IS NOT NULL)
Filter: (status = 'pending'::text)
(3 rows)注意计划里的 Index Cond: (created_at IS NOT NULL)——优化器把索引谓词中被查询蕴含的部分当成了扫描边界。
谓词必须 IMMUTABLE,且只能引用本表列
这是建部分索引时最先撞上的一堵墙。谓词里出现 VOLATILE 函数会直接拒绝:
-- 错误!谓词里不能出现任何易变(VOLATILE)的东西
CREATE INDEX idx_bad_1 ON orders (created_at) WHERE random() < 0.1;
CREATE INDEX idx_bad_2 ON orders (id) WHERE nextval('seq_a') > 0;ERROR: functions in index predicate must be marked IMMUTABLE同样,谓词也不能引用别的表或写子查询:
-- 错误!谓词不能引用其他表 / 不能有子查询
CREATE INDEX idx_bad_3 ON orders (id) WHERE amount > (SELECT avg(amount) FROM orders);ERROR: cannot use subquery in index predicate一个很容易忽略的限制:谓词里不能出现「会话相关」的表达式,比如当前时间:
-- 错误!now() / CURRENT_DATE 是 STABLE,不能作为索引谓词
CREATE INDEX idx_bad_4 ON orders (created_at) WHERE created_at > now() - interval '7 days';ERROR: functions in index predicate must be marked IMMUTABLE「最近 7 天」这种滚动窗口做不了部分索引
created_at > now() - interval '7 days' 依赖查询时刻,不可能作为固定的索引谓词。滚动窗口的正确做法是用分区表(按时间分区),而不是部分索引。部分索引的谓词必须是静态不变的。
谓词蕴含(Predicate Implication):什么时候才用得上
优化器判断能否使用部分索引的依据是:查询条件是否「蕴含」索引谓词(内部函数 predicate_implied_by)。也就是说,要能证明「满足查询条件的行,一定满足索引谓词」。
能命中(查询条件比索引谓词更严格):
-- 索引谓词:status = 'pending'
-- 查询条件与谓词相同,命中
SELECT * FROM orders WHERE status = 'pending';
-- 查询条件更严格(多了附加条件),也能命中
SELECT * FROM orders WHERE status = 'pending' AND amount > 100;
-- 数值范围:索引谓词 created_at >= '2026-01-01',查询更严格,命中
CREATE INDEX idx_orders_2026 ON orders (id) WHERE created_at >= '2026-01-01';
EXPLAIN (COSTS OFF) SELECT id FROM orders WHERE created_at >= '2026-06-01';不能命中(查询条件比索引谓词更宽松,或无法比较):
-- 错误!查询里没有 status 条件,可能包含非 pending 行
EXPLAIN (COSTS OFF) SELECT id FROM orders WHERE amount > 100;
-- 错误!查询的 status 集合比谓词更大
EXPLAIN (COSTS OFF) SELECT id FROM orders WHERE status IN ('pending','done');
-- 错误!谓词列被包在函数里,语义上无法比较
EXPLAIN (COSTS OFF) SELECT id FROM orders WHERE upper(status) = 'PENDING';不要靠猜,用 EXPLAIN 验证
部分索引失效不报错,只是默默变成 Seq Scan。任何依赖部分索引的查询,上线前都应当用 EXPLAIN 看一眼计划节点是否为 Index Scan / Bitmap Index Scan。
小表上优化器会觉得顺序扫描更便宜,这属于正常的代价判断,不代表索引写错了。要验证索引本身可用,可以临时关掉顺序扫描:
SET enable_seqscan = off;
EXPLAIN (COSTS OFF)
SELECT id FROM orders WHERE status = 'pending' AND amount > 100;
RESET enable_seqscan; QUERY PLAN
---------------------------------------------------------------------------------
Index Scan using idx_orders_pending on orders
Index Cond: (created_at IS NOT NULL)
Filter: ((amount > '100'::numeric) AND (status = 'pending'::text))
(3 rows)Index Scan using idx_orders_pending 出现即证明谓词蕴含关系成立。
参数化查询与 generic plan 的陷阱
这是部分索引在生产上最隐蔽的一个坑。如果应用使用预编译语句(prepared statement),并且 PostgreSQL 选择了通用计划(generic plan),那么谓词里的字面量在计划阶段是「未知的」:
PREPARE q1 (text) AS SELECT id FROM orders WHERE status = $1;
-- 前几次执行会为具体参数值生成 custom plan,部分索引可能被用上
EXECUTE q1('pending');
-- 执行次数够多后可能切换到 generic plan,此时 $1 未知,无法证明谓词蕴含
EXPLAIN (COSTS OFF) EXECUTE q1('pending'); QUERY PLAN
----------------------------------------------------------
Seq Scan on orders
Filter: (status = $1)
(2 rows)规避方式有三种,按推荐程度排序:
-- 方案 A(推荐):让应用把谓词条件「写死」在 SQL 里,而不是当参数传
-- 即 SQL 文本本身就是 WHERE status = 'pending' AND ...
-- 方案 B:把部分索引改回普通索引(牺牲体积换稳定命中)
-- 方案 C:调整 plan_cache_mode,强制 custom plan(有解析开销)
PREPARE q2 (text) AS SELECT id FROM orders WHERE status = $1;
SET plan_cache_mode = force_custom_plan;
EXECUTE q2('pending');
RESET plan_cache_mode;为什么 custom plan 能用
custom plan 在已知道具体参数值的前提下规划,此时 $1 = 'pending' 可以被代入,predicate_implied_by 能够证明蕴含。generic plan 要求「一次计划应对所有参数值」,自然无法为特定谓词背书。
部分唯一索引:条件唯一性
普通唯一索引要求「全表该列组合唯一」;部分唯一索引只要求「满足谓词的行之间唯一」。这是表达业务规则的利器:
DROP TABLE IF EXISTS user_addresses;
CREATE TABLE user_addresses (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id bigint NOT NULL,
address text NOT NULL,
is_default boolean NOT NULL DEFAULT false
);
-- 每个用户最多只能有一个默认地址
CREATE UNIQUE INDEX uq_user_default_address
ON user_addresses (user_id) WHERE is_default;DROP TABLE
CREATE TABLE
CREATE UNIQUE INDEX规则立刻生效,且只约束 is_default = true 的行:
INSERT INTO user_addresses (user_id, address, is_default) VALUES (1, '地址 A', true);
INSERT INTO user_addresses (user_id, address, is_default) VALUES (1, '地址 B', false);
INSERT INTO user_addresses (user_id, address, is_default) VALUES (1, '地址 C', true);INSERT 0 1
INSERT 0 1
ERROR: duplicate key value violates unique constraint "uq_user_default_address"
DETAIL: Key (user_id)=(1) already exists.这条 CREATE UNIQUE INDEX 的第一个参数是列的列表,而不是任何条件。它与部分索引的 WHERE 子句一起工作,形成了一个只对默认地址生效的唯一性约束。
ON CONFLICT 也能以部分唯一索引作为仲裁目标,但推断子句里必须带上谓词:
-- 正确:用 ON CONFLICT 的推断子句指定索引谓词
INSERT INTO user_addresses (user_id, address, is_default) VALUES (1, '地址 D', true)
ON CONFLICT (user_id) WHERE is_default DO UPDATE SET address = EXCLUDED.address;INSERT 0 1ON CONFLICT 的谓词必须与索引谓词完全一致
ON CONFLICT (user_id) 而不带 WHERE is_default,会匹配不到这个部分唯一索引,报错:
ERROR: there is no unique or exclusion constraint matching the ON CONFLICT specification体积与写入代价的实测对比
部分索引最大的收益就是体积。在同一张订单表上对比全量索引与部分索引:
-- 全量索引:100 万条
CREATE INDEX idx_orders_all_status ON orders (created_at);
-- 部分索引:仅 pending(约占 20%)
CREATE INDEX idx_orders_pending_2 ON orders (created_at) WHERE status = 'pending';
SELECT indexrelid::regclass AS index_name,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
pg_relation_size(indexrelid) AS bytes
FROM pg_stat_user_indexes
WHERE relname = 'orders'
ORDER BY pg_relation_size(indexrelid) DESC; index_name | index_size | bytes
-----------------------+------------+---------
idx_orders_all_status | 4880 kB | 4997120
idx_orders_pending_2 | 1248 kB | 1277952
idx_orders_pending | 1248 kB | 1277952
orders_pkey | 2216 kB | 2269184
(4 rows)部分索引体积约为全量索引的 25%。除了省磁盘,还有三个连带收益:
| 维度 | 全量索引 | 部分索引 |
|---|---|---|
| 索引体积 | 100% | 仅谓词命中的行 |
| 写入开销 | 每行都要维护 | 不满足谓词的行完全不维护 |
| 缓存命中率 | 索引大、易被挤出 | 索引小、更容易常驻 shared_buffers |
VACUUM 成本 | 需遍历全部索引条目 | 条目少,清理更快 |
| 查询适用面 | 所有查询 | 仅能满足谓词的查询 |
被排除的行不是「不维护」,而是「根本不进索引」
对不满足谓词的行,PostgreSQL 连一次 B-Tree 插入都不做。这就是部分索引能同时降低写入放大的原因——它不只是省空间,还省 CPU 与 WAL。
部分索引 + 表达式索引:组合用法
部分索引的谓词与表达式索引的表达式可以共存,两者解决的问题不同:
-- 只给「未删除」的账号建大小写不敏感唯一索引
CREATE UNIQUE INDEX uq_accounts_email_alive
ON accounts (lower(email)) WHERE deleted_at IS NULL;
-- 只给「活跃」用户建用户名前缀索引
CREATE INDEX idx_users_name_prefix
ON users (lower(name) text_pattern_ops) WHERE is_active;CREATE UNIQUE INDEX
CREATE INDEX组合后的查询必须同时满足两个条件才能命中:
-- 命中:表达式一致 + 谓词蕴含
EXPLAIN (COSTS OFF)
SELECT id FROM accounts WHERE lower(email) = 'a@example.com' AND deleted_at IS NULL;
-- 错误!少了 deleted_at IS NULL,无法证明谓词蕴含
EXPLAIN (COSTS OFF)
SELECT id FROM accounts WHERE lower(email) = 'a@example.com'; QUERY PLAN
------------------------------------------------------------------------
Index Scan using uq_accounts_email_alive on accounts
Index Cond: (lower(email) = 'a@example.com'::text)
(2 rows)组合索引的命中门槛是「叠加」的
表达式索引要求「查询表达式等价」,部分索引要求「查询条件蕴含谓词」。二者组合时两个门槛同时生效,任何一条不满足就退化为顺序扫描。这类索引的注释必须写清楚约定的查询形态。
部分索引与约束/外键的限制
部分索引不能被提升为具名的 UNIQUE / PRIMARY KEY 约束:
-- 错误!部分索引不能被提升为约束
CREATE UNIQUE INDEX uq_tmp ON user_addresses (user_id) WHERE is_default;
ALTER TABLE user_addresses ADD CONSTRAINT uq_default UNIQUE USING INDEX uq_tmp;ERROR: index "uq_tmp" is a partial index
DETAIL: Cannot create a primary key or unique constraint using such an index.与约束相关的重要限制汇总如下:
| 操作 | 部分索引是否可用 |
|---|---|
ALTER TABLE ... ADD CONSTRAINT ... USING INDEX | 不可用,报错 |
ON CONFLICT (列) WHERE 谓词 | 可用(谓词必须与索引一致) |
| 外键引用 | 无法引用部分索引 |
REPLICA IDENTITY USING INDEX | 要求非部分、非表达式索引,不可用 |
作为 information_schema 中的约束 | 不出现(只是索引,不是约束) |
因此「条件唯一性」只能靠唯一的语义去保证,数据库层面它不是「约束」。做 Schema 比对的工具往往看不到它,迁移脚本里要单独处理。
元数据与维护:indpred、pg_get_expr、REINDEX
用系统表把部分索引识别出来,靠的是 pg_index.indpred 字段:
SELECT c.relname AS index_name,
i.indisunique AS is_unique,
i.indpred IS NOT NULL AS is_partial,
pg_get_expr(i.indpred, i.indrelid) AS predicate,
pg_get_indexdef(i.indexrelid) AS index_def
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
WHERE i.indrelid = 'orders'::regclass
ORDER BY c.relname; index_name | is_unique | is_partial | predicate | index_def
---------------------+-----------+------------+------------------------+-------------------------------------
idx_orders_2026 | f | t | (created_at >= '2026-01-01 00:00:00+08'::timestamp with time zone) | ...
idx_orders_pending | f | t | (status = 'pending'::text) | ...
idx_orders_pending_2| f | t | (status = 'pending'::text) | ...
orders_pkey | t | f | | CREATE UNIQUE INDEX orders_pkey ...
(4 rows)indpred IS NOT NULL 就是「这个索引是部分索引」。pg_get_expr(i.indpred, i.indrelid) 把内部的 pg_node_tree 反解析成可读的 SQL 片段。
日常维护时,部分索引与普通索引一样需要统计信息与重建:
-- 部分索引同样依赖 ANALYZE 提供准确的选择性估计
ANALYZE orders;
-- 使用率与体积巡检
SELECT indexrelid::regclass AS index_name,
idx_scan,
pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
WHERE relname = 'orders'
ORDER BY idx_scan, pg_relation_size(indexrelid) DESC;
-- 膨胀后重建(部分索引同样适用)
REINDEX INDEX CONCURRENTLY idx_orders_pending; index_name | idx_scan | size
-----------------------+----------+-----------
idx_orders_pending | 12 | 1248 kB
orders_pkey | 0 | 2216 kB
idx_orders_all_status | 0 | 4880 kB
idx_orders_pending_2 | 0 | 1248 kB
(4 rows)部分索引没有自己的统计信息
ANALYZE 收集的是整表的列统计(直方图、MCV 等),不是「谓词命中的那部分行」的统计。因此对高度倾斜的数据,优化器对部分索引内部的估算可能不准,必要时可用 ALTER TABLE ... ALTER COLUMN ... SET STATISTICS 提高精度。
实战示例
场景:一张 100 万行的 jobs 任务表,95% 的任务已经完成,业务只关心「待处理 / 处理中」的任务;同时要求每个用户只能有一个 is_primary = true 的主任务。全部用部分索引实现,并用 EXPLAIN 逐项验证。
第 1 步:建表并灌入 100 万行
DROP TABLE IF EXISTS jobs CASCADE;
CREATE TABLE jobs (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id integer NOT NULL,
status text NOT NULL,
is_primary boolean NOT NULL DEFAULT false,
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO jobs (user_id, status, is_primary, payload, created_at)
SELECT
(g % 20000) + 1,
(ARRAY['done','done','done','done','done','done','done','done','pending','running'])[1 + (g % 10)],
false,
jsonb_build_object('step', g % 50),
'2026-01-01'::timestamptz + (g || ' seconds')::interval
FROM generate_series(1, 1000000) AS g;
ANALYZE jobs;第 2 步:建立四个部分索引
-- 需求 1:只给「未完成」的任务建索引,用于按时间取待办
CREATE INDEX idx_jobs_active
ON jobs (created_at) WHERE status IN ('pending','running');
-- 需求 2:只给「待处理」任务建覆盖索引,避免回表
CREATE INDEX idx_jobs_pending_inc
ON jobs (created_at) INCLUDE (user_id, payload) WHERE status = 'pending';
-- 需求 3:每个用户最多一个主任务
CREATE UNIQUE INDEX uq_jobs_primary
ON jobs (user_id) WHERE is_primary;
-- 需求 4:只给有扩展字段的任务建表达式索引
CREATE INDEX idx_jobs_step
ON jobs (((payload ->> 'step')::int)) WHERE payload ? 'step';第 3 步:验证「未完成」查询走了部分索引
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT id, status FROM jobs
WHERE status IN ('pending','running')
ORDER BY created_at
LIMIT 20; QUERY PLAN
---------------------------------------------------------------------------------------------
Limit (actual rows=20 loops=1)
-> Index Scan using idx_jobs_active on jobs (actual rows=20 loops=1)
Index Cond: (created_at IS NOT NULL)
Filter: (status = ANY ('{pending,running}'::text[]))
(3 rows)第 4 步:验证覆盖索引免回表
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT user_id, payload FROM jobs WHERE status = 'pending' ORDER BY created_at LIMIT 50; QUERY PLAN
---------------------------------------------------------------------------------------------
Limit (actual rows=50 loops=1)
-> Index Only Scan using idx_jobs_pending_inc on jobs (actual rows=50 loops=1)
Index Cond: (created_at IS NOT NULL)
Heap Fetches: 0
(3 rows)Index Only Scan + Heap Fetches: 0 说明所有需要的列都从索引里直接取到了,没有回表。
第 5 步:验证条件唯一性真的只约束主任务
UPDATE jobs SET is_primary = true WHERE id = 1;
UPDATE jobs SET is_primary = true WHERE id = 2 AND user_id = 1;UPDATE 1
ERROR: duplicate key value violates unique constraint "uq_jobs_primary"
DETAIL: Key (user_id)=(1) already exists.同一个 user_id 的多行 is_primary = false 完全不受影响,唯一性只在 is_primary = true 的子集里生效。
第 6 步:验证「查询条件比谓词宽松」时索引失效
-- 错误示范:查询里没有 status 条件,无法蕴含 idx_jobs_active 的谓词
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT id FROM jobs WHERE created_at > '2026-06-01' LIMIT 20; QUERY PLAN
-----------------------------------------------------------------------------------------
Limit (actual rows=20 loops=1)
-> Seq Scan on jobs (actual rows=20 loops=1)
Filter: (created_at > '2026-06-01 00:00:00+08'::timestamp with time zone)
(3 rows)100 万行的 Seq Scan——这就是「部分索引只服务于匹配谓词的查询」的具体体现。
第 7 步:对比体积,量化收益
SELECT indexrelid::regclass AS index_name,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
WHERE relname = 'jobs'
ORDER BY pg_relation_size(indexrelid) DESC; index_name | index_size
-----------------------+------------
jobs_pkey | 25 MB
idx_jobs_active | 4328 kB
idx_jobs_step | 1480 kB
idx_jobs_pending_inc | 1040 kB
uq_jobs_primary | 336 kB
(5 rows)四个部分索引合计约 7 MB,而主键索引就有 25 MB。这就是部分索引的实际价格——在评估「要不要建」时,把这张表列出来比凭感觉判断靠谱得多。
第 8 步:清理
DROP TABLE jobs;注意事项
谓词必须
IMMUTABLE,且只能引用本表列。now()、CURRENT_DATE、random()、子查询、别的表一律不行。sql-- 错误!functions in index predicate must be marked IMMUTABLE CREATE INDEX idx_bad ON orders (created_at) WHERE created_at > now() - interval '7 days';查询条件必须能「蕴含」谓词,否则不报错、只是静默变成顺序扫描。这是最难排查的一类「索引失效」。
sql-- 索引 WHERE status = 'pending',下面这条用不上 SELECT id FROM orders WHERE amount > 100;部分索引不能被提升为主键 / 唯一约束,也不能被外键引用。
sql-- 错误!index "uq_tmp" is a partial index ALTER TABLE user_addresses ADD CONSTRAINT uq_x UNIQUE USING INDEX uq_tmp;ON CONFLICT用部分唯一索引时,推断子句必须带上完全一致的谓词,否则报no unique or exclusion constraint matching the ON CONFLICT specification。sql-- 正确 INSERT INTO user_addresses (user_id, address, is_default) VALUES (1, 'X', true) ON CONFLICT (user_id) WHERE is_default DO NOTHING;预编译语句 + generic plan 会让部分索引失效。 参数化的列恰好是谓词列时,优先把谓词写成 SQL 文本里的字面量,而不是绑定参数。
不要给「谓词列之外」的查询指望部分索引。 部分索引只覆盖满足谓词的行,其他查询需要另建索引。
谓词过于复杂的部分索引收益递减。 谓词里堆十几个
OR/IN条件,既难被蕴含匹配,也让维护成本上升;不如拆成几个语义清晰的小索引。
最佳实践
从「数据分布严重倾斜」的列上找部分索引的机会。 先用统计信息确认某个值只占极小比例,再决定谓词。
sqlSELECT status, count(*), round(100.0 * count(*) / sum(count(*)) OVER (), 2) AS pct FROM jobs GROUP BY status ORDER BY count(*) DESC;把部分索引的谓词写成「最能表达业务规则」的最小条件。 例如软删除表统一用
WHERE deleted_at IS NULL,队列表统一用WHERE status IN ('pending','running'),让谓词成为团队共识而非个人偏好。sqlCREATE INDEX idx_users_alive_email ON users (email) WHERE deleted_at IS NULL;条件唯一性一律用部分唯一索引表达,不要靠应用层「先查再插」——那种做法在并发下必然漏判。
sqlCREATE UNIQUE INDEX uq_user_default_address ON user_addresses (user_id) WHERE is_default;部分索引 +
INCLUDE覆盖列,让「高频小查询」既不回表又不过度索引。sqlCREATE INDEX idx_jobs_pending_inc ON jobs (created_at) INCLUDE (user_id) WHERE status = 'pending';给部分索引起能自解释的名字,把谓词意图写进名字(如
idx_jobs_active、uq_jobs_primary),因为\d里的索引名是运维第一眼看到的东西。每建一个部分索引,就在代码仓库里记录它约定的查询形态。 谓词蕴含是隐式契约,写进注释或文档才能避免后续有人改了查询却在纳闷「索引怎么没生效」。
定期复核部分索引的使用率与体积,
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 relname = 'jobs' ORDER BY idx_scan, pg_relation_size(indexrelid) DESC;建索引与改索引都走变更流程:先
CONCURRENTLY建好、ANALYZE、用EXPLAIN验证命中,再观察一段时间使用率,最后才考虑清理旧索引。
下一节
继续学习:覆盖索引(INCLUDE)