部分索引筛选的是行,不是裁剪列。给 id 建索引并加上 WHERE status = 'open',保存的是未关闭行的标识符;状态列决定哪些行进入索引,却不因此成为搜索键。估算维护成本时,这个区别很重要:即使某一列的值没有存进索引键,它仍可能影响更新需要付出的代价。
如果订单表大部分是已关闭的历史记录,把这些行排除在索引之外,确实可能省下大量索引空间。但这不代表历史行可以免费写入,也不保证查询一定更快,更不会消除数据表本身的清理工作。判断收益时,要同时看结构缩小了多少,以及维持正确状态仍需承担哪些写入。
区分成员资格与更新成本
新插入的行不满足谓词时,这一个部分索引不需要为它新增条目。谓词判断、表内存储、其他索引以及正常事务处理仍然存在。把一行从子集外改到子集内,需要新增条目;把它改出子集,则会改变当前查询能看见的成员。旧行版本与过时索引条目的物理回收可能发生在之后,不能把逻辑上退出子集理解成立即释放所有存储空间。
成员资格没有变化,也不等于可以跳过索引维护。未关闭订单的标识符改变了,索引键就需要更新。即使只修改一列与索引无关的备注,只要数据库无法采用堆内元组更新,也就是 HOT,新行版本仍可能需要新的索引条目。因此,“谓词结果不变”不足以证明这次更新很便宜。
对本文讨论的 PostgreSQL 18 B-tree 索引,HOT 要求所有索引定义引用的列值保持不变,包括表达式引用的列和部分索引谓词引用的列;原来那张堆页面还必须容得下新行版本。把状态从已关闭改成已归档,两种状态虽然都不满足谓词,仍然改变了索引依赖的状态列,会阻止 HOT。只修改没有被任何索引引用的备注列,则可能满足条件。
降低表的填充比例可以为页面内更新预留空间,但不是保证成功的开关。检查时必须看整张表的索引集合,另一个索引可能恰好引用备注列,独立阻止这次优化。只盯着准备新增的那个部分索引,会漏掉其他定义施加的限制。
规则还依赖版本和索引方法。PostgreSQL 16 开始允许只改变 BRIN 等汇总索引引用列的更新使用 HOT,汇总信息仍可能需要维护。这项例外不适用于这里的 B-tree 索引。把一个版本、某种索引上的测试结论搬到其他部署之前,应先核对对应版本的文档。
可以使用与值得使用是两回事
规划器必须能够证明,查询条件蕴含部分索引的谓词。只包含未关闭订单的索引,不能单独回答还可能需要已关闭订单的请求。通用计划里的 status = $1 无法提供这个证明;按具体参数生成的定制计划则可能知道传入值。若状态条件本来就是固定的 status = 'open',仅把其他查询值参数化,并不会抹掉这个事实。
有资格参与选择的索引,仍要与其他访问路径比较成本。表很小或者结果覆盖面很广时,顺序扫描可能更合适。看到计划没有采用部分索引,先检查估算行数、实际行数与缓冲区访问,再判断是不是谓词写法导致无法匹配。机械要求计划永远出现某个索引名,会把性能假设误当成正确性要求;结果是否完整正确与路径是否足够便宜,应该分别验证。
坏掉的样子
积压越来越多,原本很小的未关闭子集也可能占据大部分数据;频繁的状态切换,还可能抵消预期的写入节省。应使用有代表性的查询和状态转换比较,而不是只凭两个索引的体积推导吞吐量。大量重叠的部分索引会增加规划与维护工作,不能把每个报表的过滤条件都独立变成一个索引。
新增切片或删除看起来没人扫描的索引之前,要检查完整索引集合与业务规则。尤其是承担唯一性约束的索引,即使查询扫描次数很低,也可能一直在保护数据完整性。体积变大或扫描次数减少,意味着需要重新评估,不是可以直接删除的充分证据。
快速验证
在可丢弃的 PostgreSQL 18 数据库中,打开一个新的 psql -X -v ON_ERROR_STOP=1 会话,并确认已启用 track_counts,依次运行两个代码块。历史审阅记录报告,下面的 SQL 曾在 PostgreSQL 18.6 的无套接字单用户模式中执行,本次没有重跑;尚未验证客户端连接服务器的路径。临时表共有两万行,其中两百行未关闭。显式命名索引,避免依赖数据库自动生成的名字。
BEGIN;
CREATE TEMP TABLE partial_cost_demo (
id integer NOT NULL,
status text NOT NULL,
note integer NOT NULL
) WITH (fillfactor=50);
INSERT INTO partial_cost_demo
SELECT g, CASE WHEN g % 100 = 0 THEN 'open' ELSE 'closed' END, 0
FROM generate_series(1,20000) AS g;
CREATE INDEX cost_all_id ON partial_cost_demo (id);
CREATE INDEX cost_open_id ON partial_cost_demo (id) WHERE status='open';
ANALYZE partial_cost_demo;
SELECT pg_relation_size('cost_all_id') AS full_bytes,
pg_relation_size('cost_open_id') AS partial_bytes;
EXPLAIN (ANALYZE, BUFFERS, TIMING OFF)
SELECT id FROM partial_cost_demo WHERE status='open' ORDER BY id LIMIT 5;
SELECT id FROM partial_cost_demo WHERE status='open' ORDER BY id LIMIT 5;
查询结果应为一百、两百、三百、四百和五百。部分索引预计占用更少字节,但不要要求体积精确等于全量索引的百分之一,页面本身也有开销。记录实际选中的计划,不要断言某一种固定形状。全量索引与部分索引使用同样的键,提供体积对照,同时也会参与访问路径竞争。
UPDATE partial_cost_demo SET note=1 WHERE id=100;
SELECT n_tup_upd, n_tup_hot_upd FROM pg_stat_xact_user_tables
WHERE relid='partial_cost_demo'::regclass;
UPDATE partial_cost_demo SET status='archived' WHERE id=101;
SELECT n_tup_upd, n_tup_hot_upd FROM pg_stat_xact_user_tables
WHERE relid='partial_cost_demo'::regclass;
SELECT count(*) FROM partial_cost_demo WHERE status='open';
ROLLBACK;
这里使用当前事务的统计视图,计数会随本事务中的操作持续更新,不必等待累计统计刷新。更新总数应从一次增加到两次。备注更新可能增加 HOT 次数;状态改变不应增加 HOT 次数,即使它前后都不属于未关闭子集。隔离测试中,两次记录的更新总数与 HOT 次数分别为(一,一)和(二,一),未关闭行仍有两百行。全量和部分索引的大小分别为四十六万六千九百四十四与一万六千三百八十四字节,选中的仅索引扫描仍访问了五次堆。这里记录的是特定实验的观察值,不是所有环境都应相同的结果。回滚后实验对象和数据已被移除。
这个练习把索引子集大小与 HOT 条件分开观察,并没有测量长期运行的生产负载。临时表上的耗时不能证明生产 WAL 节省,也不能证明持续写入吞吐量。真正的上线判断还需要同等负载、同等索引集合,以及有代表性的页面空间和更新分布。
来源
- PostgreSQL 18 部分索引,成员资格、逻辑蕴含与过量部分索引,已核验 2026-10-15。
- PostgreSQL 18 HOT,更新条件与页面空间,已核验 2026-10-15。
- PostgreSQL 18 HOT 实现说明,谓词列依赖和元组链,已核验 2026-10-15。
- PostgreSQL 16 发行说明,第十六版引入的 BRIN 例外,已核验 2026-10-15。
- PostgreSQL 18 PREPARE,定制计划与通用计划,已核验 2026-10-15。
- PostgreSQL 18 统计系统,当前事务的统计计数,已核验 2026-10-15。
- PostgreSQL 18 EXPLAIN,实际执行与缓冲区测量,已核验 2026-10-15。
- PostgreSQL 18 体积函数,关系主数据分支的大小,已核验 2026-10-15。