一个任务队列可能只有一百条待处理记录,却保留了成千上万条已完成记录。如果主要请求都是领取待处理任务,只为这个子集建立索引就可能更合适。PostgreSQL 把它称为部分索引,索引谓词决定哪些表行拥有条目。收益仍取决于真实查询和状态变化,不能把“所有索引越小越好”当成脱离业务的优化目标。
坏掉的样子
条件之间的蕴含方向不能写反:查询条件必须蕴含索引谓词,也就是查询可能需要的每一行都在索引覆盖范围内。只收录待处理任务的索引,不能完整回答没有状态限制的全表查询。两个条件看上去不矛盾,也不代表规划器能证明它们之间的关系。PostgreSQL 不会识别所有数学上等价的写法,所以让谓词接近应用实际过滤表达式,也有助于人工审阅和计划识别。
哪些参数已经知道,同样由计划阶段决定。预备语句的定制计划,可以使用这次传入的状态值;通用计划却必须对所有可能的参数都成立,不能假定每次调用永远查询待处理状态。因此,手工测试常量查询时看到索引被使用,并不能直接证明应用长期执行的预备语句也会选择同一条访问路径。
快速验证
在 PostgreSQL 18 的可丢弃数据库中,使用一个全新的 psql -X -v ON_ERROR_STOP=1 会话按顺序执行。实验没有建立覆盖全表的其他索引,便于观察资格差异。先创建一万条任务,其中编号为一百整数倍的记录处于待处理状态:
CREATE SCHEMA partial_demo;
CREATE TABLE partial_demo.jobs (id integer,state text);
INSERT INTO partial_demo.jobs
SELECT g,CASE WHEN g%100=0 THEN 'ready' ELSE 'done' END
FROM generate_series(1,10000) AS g;
CREATE INDEX ready_jobs ON partial_demo.jobs(id) WHERE state='ready';
VACUUM (ANALYZE) partial_demo.jobs;
SELECT state,count(*) FROM partial_demo.jobs GROUP BY state ORDER BY state;
分组结果应是已完成九千九百条、待处理一百条。这里索引键是任务编号,谓词检查的是状态,两者不要求是同一个字段。先检查带常量条件的计划:
EXPLAIN (ANALYZE,COSTS OFF,TIMING OFF,SUMMARY OFF,FORMAT JSON)
SELECT id FROM partial_demo.jobs WHERE state='ready' ORDER BY id LIMIT 5;
本次得到的计划在限制节点之下使用 ready_jobs 的仅索引扫描,返回五行。前面的清理建立了实验所需的可见性信息,使这次扫描能够避免访问堆页。部分索引本身并不保证仅索引扫描;查询所需列和可见性状态仍然重要。首先应核对的是索引名字、实际行数与结果,而不是只盯着某个扫描类型。
接着,把状态改成参数,并为比较而明确选择定制计划:
PREPARE next_jobs(text) AS
SELECT id FROM partial_demo.jobs WHERE state=$1 ORDER BY id LIMIT 5;
SET plan_cache_mode=force_custom_plan;
EXPLAIN (ANALYZE,COSTS OFF,TIMING OFF,SUMMARY OFF,FORMAT JSON)
EXECUTE next_jobs('ready');
此时计划仍然使用部分索引,因为规划时已经知道参数是待处理状态。保留同一条预备语句,切换到通用计划:
SET plan_cache_mode=force_generic_plan;
EXPLAIN (ANALYZE,COSTS OFF,TIMING OFF,SUMMARY OFF,FORMAT JSON)
EXECUTE next_jobs('ready');
通用计划扫描表、滤掉九千九百行,再把匹配编号排序后取前五行。它保留状态参数,不能使用只覆盖某个固定状态的索引来服务所有调用。这里强制两种模式,是为了隔离并观察机制差异,不是建议对所有生产连接强制使用某一种模式。真实选择还要考虑规划开销、执行分布和调用次数。
执行两个不同状态的参数值,然后让一条任务跨越索引谓词的边界:
EXECUTE next_jobs('ready');
EXECUTE next_jobs('done');
DEALLOCATE next_jobs;
RESET plan_cache_mode;
UPDATE partial_demo.jobs SET state='done' WHERE id=100;
SELECT id FROM partial_demo.jobs WHERE state='ready' ORDER BY id LIMIT 5;
SELECT count(*) FROM partial_demo.jobs WHERE state='ready';
DO $$ BEGIN
IF (SELECT array_agg(id ORDER BY id) FROM
(SELECT id FROM partial_demo.jobs WHERE state='ready' ORDER BY id LIMIT 5) AS q)
IS DISTINCT FROM ARRAY[200,300,400,500,600] THEN
RAISE EXCEPTION 'partial-index membership result mismatch';
END IF;
END $$;
DROP SCHEMA partial_demo CASCADE;
待处理请求先返回一百、二百、三百、四百、五百;已完成请求返回一至五。把编号一百改成已完成后,前五条待处理任务变成二百至六百,间隔仍是一百,总数降到九十九。断言核对的是状态更新后索引成员关系对应的查询结果,不能只以更新语句没报错作为正确性证据。最后释放预备语句、恢复计划配置并删除实验数据。
结合完整工作负载评估
一行进入或离开谓词覆盖范围时,索引成员关系需要维护。未被收录的行也仍然需要普通表写入,不能宣称部分索引消除了所有写成本。应结合代表性的状态流转,观察索引体积、查询计划与维护代价。如果后来大多数任务都变成待处理,原先预期的优势可能减弱,顺序扫描也可能成为合理选择。
部分唯一索引只约束满足谓词的那些行,索引之外的记录不受这条唯一规则限制。这适合允许历史记录重复的某些模型,却不适合要求全局唯一的业务标识。另一个容易误用的边界是时间:谓词涉及的函数需要满足不可变要求,索引成员也不会仅因为时钟跨过午夜就自动重新分类。如果业务真正查询的是某个状态,应明确维护该状态,而不是用会随当前时间改变答案的条件来暗示索引会自行更新。
来源
- PostgreSQL 18 部分索引,谓词蕴含关系及子集唯一性,已核验 2026-10-15。
- PostgreSQL 18 CREATE INDEX,索引谓词及不可变表达式要求,已核验 2026-10-15。
- PostgreSQL 18 PREPARE,预备语句的通用及定制计划与控制方式,已核验 2026-10-15。