覆盖索引包含某一条查询需要的值,PostgreSQL 有时可以通过 index-only scan 直接返回它们,避免访问对应的堆元组。这里的“有时”很关键:列是否齐全属于结构条件,执行时能否真正跳过堆访问,还取决于可见性信息。计划里出现 Index Only Scan,并不能单独证明没有访问表数据,更不能据此宣称任何写入状态下都能省掉堆读取。

覆盖实际返回的列

按 sku 查价格时,可以建立 sku 的 B-tree,并用 INCLUDE (price) 把价格作为附加值存进去。搜索由 sku 键引导,附加的 price 不会自动成为第二个搜索键,也不提供按价格排序的顺序。如果这是唯一索引,唯一性约束针对键列,而不是键与附加列的整体组合。这些区别决定它究竟能回答哪些查询。

查询后来增加返回列,就可能破坏覆盖前提。例如 description 不在索引中,执行器需要从其他位置取得它,通常就是堆表。随手改成 SELECT 星号,也可能把原本专门优化的查找变成另一种访问模式。应从实际过滤条件和投影列出发,结合表达式与可见性需求设计,而不是把全部字段都塞进一个预想中的万能覆盖索引。

并非所有索引方法都能还原 index-only 执行所需的完整值。B-tree 支持这种扫描,GIN 通常保存的是从原值提取的键,而不是完整原值,因此不支持 index-only scan。索引不仅要包含或能够还原所需数据,也要支持对应操作。其他数据库也使用“覆盖索引”这个名称,但不能据此把 PostgreSQL 的可见性机制直接套过去。

可见性映射决定是否访问堆

PostgreSQL 必须判断候选元组是否对当前 MVCC 快照可见,索引项没有保存全部所需可见性信息。Index-only scan 会检查堆页在 visibility map 中的 all-visible 位;该位已设置时,才能直接返回索引中的值,而不读取堆元组。这样省下的是堆访问,仍然需要读取索引和可见性映射,并不等于完全没有 I/O。

写入可能清除页的 all-visible 状态,导致之后的查询重新访问堆,即使计划名称没有改变。VACUUM 可以在页内元组满足可见性条件时设置该状态,长事务和持续更新都会影响这一过程。它是页级属性,不是某个单独字段的属性;同一页上其他行被修改,也可能影响当前行的可见性检查路径。因此“这一行没改过”也不能直接推出零堆访问。

坏掉的样子

最常见的误判是只看 EXPLAIN 的计划标签就宣布优化成功。应在 EXPLAIN ANALYZE 中同时查看 Heap Fetches、相关缓冲区和实际返回值。频繁写入的表可能不断需要堆检查,读取收益下降,却仍承担附加值的复制与维护成本。每次业务写入后都运行 VACUUM,并不能代替对真实读写比例和维护成本的评估。

宽大的附加列还会占用磁盘和缓存,增加索引维护工作,甚至超过索引元组尺寸限制。更新 INCLUDE 列需要维护索引,也可能阻止原本可行的 heap-only update。应只覆盖读收益足以抵偿写入和存储成本的字段;覆盖索引不是免费物化的整表副本,也不能只用一次静态查询的耗时决定是否值得长期保留。

快速验证

把 SQL 保存为 covering.sql,运行 psql -X -v ON_ERROR_STOP=1 -f covering.sql,连接可丢弃的 PostgreSQL 18 数据库。保持 psql 默认自动提交,不要在外面加 BEGIN,因为 VACUUM 不能在显式事务块中运行。实验表是会话临时表,不会修改其他连接的数据,断言失败退出后临时表也会随会话消失。

\set ON_ERROR_STOP on
CREATE TEMP TABLE products (sku integer, price integer);
INSERT INTO products SELECT g, g*10 FROM generate_series(1,10000) AS g;
CREATE INDEX products_price_lookup ON products (sku) INCLUDE (price);
VACUUM (ANALYZE) products;
EXPLAIN (ANALYZE, BUFFERS, TIMING OFF)
SELECT price FROM products WHERE sku=4321;
UPDATE products SET price=price+5 WHERE sku=4321;
EXPLAIN (ANALYZE, BUFFERS, TIMING OFF)
SELECT price FROM products WHERE sku=4321;
VACUUM (ANALYZE) products;
EXPLAIN (ANALYZE, BUFFERS, TIMING OFF)
SELECT price FROM products WHERE sku=4321;
DO $$ BEGIN
  IF (SELECT price FROM products WHERE sku=4321) IS DISTINCT FROM 43215 THEN
    RAISE EXCEPTION 'covering lookup returned an obsolete price';
  END IF;
END $$;
DROP TABLE products;

第一次 VACUUM 让静止夹具满足无堆读取条件。在 PostgreSQL 18.6 上,三个计划都显示 Index Only Scan,但 Heap Fetches 依次是零、价格更新后的二、再次 VACUUM 后的零。断言同时确认返回的新价格是 43215,而不是旧索引值。实验最后显式删除临时表,避免把清理当成读者需要猜测的步骤。

中间的具体访问次数可以随实现和页状态变化,关键是相同计划标签下仍可能发生堆访问。Heap Fetches 统计堆访问,并不等于物理磁盘读取次数,所需页仍可能已经在缓存中。这个受控实验也不承诺生产中一次 VACUUM 就让所有页立刻 all-visible。真正评估覆盖收益时,应观察代表性查询在实际更新活动前后的表现,同时计算附加数据带来的维护成本,而不是只挑清理刚完成时的最好结果。

来源