Skip to content

11.9. 仅索引扫描和覆盖索引#

在PostgreSQL中的所有索引都是辅助索引,这意味着每个索引与表的(在PostgreSQL术语中称为表的)分开存储。这意味着在普通索引扫描中,每行检索都需要从索引和堆中获取数据。此外,虽然与给定可索引WHERE条件匹配的索引条目通常在索引中紧密相连,但它们引用的表行可能位于堆中的任何位置。因此,索引扫描的堆访问部分涉及大量对堆的随机访问,这可能会很慢,尤其是在传统的旋转介质上。(如第 11.5 节中所述,位图扫描尝试通过按排序顺序执行堆访问来缓解此成本,但这只能起到一定的作用。)

要解决此性能问题,PostgreSQL支持仅索引扫描,它可以仅从索引回答查询,而无需任何堆访问。基本思想是直接从每个索引条目返回值,而不是查阅关联的堆条目。对于何时可以使用此方法,有两个基本限制

  1. 索引类型必须支持仅索引扫描。B 树索引始终支持。GiST 和 SP-GiST 索引支持某些运算符类的仅索引扫描,但不支持其他运算符类。其他索引类型不支持。基本要求是索引必须物理存储或能够重建每个索引条目的原始数据值。作为反例,GIN 索引不支持仅索引扫描,因为每个索引条目通常仅包含原始数据值的一部分。

  2. 查询必须仅引用存储在索引中的列。例如,给定表中列 xy 的索引,该表还具有列 z,则这些查询可以使用仅索引扫描

    SELECT x, y FROM tab WHERE x = 'key';
    SELECT x FROM tab WHERE x = 'key' AND y < 42;
    

    但这些查询不能使用

    SELECT x, z FROM tab WHERE x = 'key';
    SELECT x FROM tab WHERE x = 'key' AND z < 42;
    

    (如以下所述,表达式索引和部分索引使此规则复杂化。)

如果满足这两个基本要求,则查询所需的所有数据值都可以从索引中获得,因此仅索引扫描在物理上是可能的。但PostgreSQL中的任何表扫描还有另一个要求:它必须验证每个检索到的行对查询的 MVCC 快照是“可见的”,如第 13 章中所述。可见性信息不存储在索引条目中,仅存储在堆条目中;因此乍一看,似乎每行检索都需要堆访问。如果表行最近已修改,情况确实如此。但是,对于很少更改的数据,有一种方法可以解决此问题。对于表堆中的每一页,PostgreSQL会跟踪存储在该页中的所有行是否足够旧,以对所有当前和未来事务可见。此信息存储在表的可见性映射中的一个位中。仅索引扫描在找到候选索引条目后,将检查相应堆页的可见性映射位。如果已设置,则该行已知可见,因此可以返回数据,而无需进一步处理。如果未设置,则必须访问堆条目以找出它是否可见,因此与标准索引扫描相比,不会获得性能优势。即使在成功的情况下,此方法也会将可见性映射访问换成堆访问;但由于可见性映射比它描述的堆小四个数量级,因此访问它所需的物理 I/O 要少得多。在大多数情况下,可见性映射始终缓存在内存中。

简而言之,虽然在满足两个基本要求的情况下,索引专用扫描是可行的,但只有当表中相当一部分堆页设置了其所有可见映射位时,它才会获胜。但是,其中很大一部分行保持不变的表足够常见,足以使这种类型的扫描在实践中非常有用。

为了有效利用索引专用扫描功能,你可以选择创建一个覆盖索引,这是一个专门设计为包含你经常运行的特定类型查询所需的列的索引。由于查询通常需要检索的列不仅仅是它们搜索的列,PostgreSQL允许你创建索引,其中一些列只是“有效负载”,并且不是搜索键的一部分。这是通过添加一个INCLUDE子句来完成的,该子句列出了额外的列。例如,如果你通常运行类似的查询

SELECT y FROM tab WHERE x = 'key';

加速此类查询的传统方法是在x上创建一个索引。但是,定义为

CREATE INDEX tab_x_y ON tab(x) INCLUDE (y);

的索引可以将这些查询作为索引专用扫描来处理,因为y可以从索引中获取,而无需访问堆。

由于列y不是索引搜索键的一部分,因此它不必是索引可以处理的数据类型;它只是存储在索引中,并且不会被索引机制解释。此外,如果索引是唯一索引,即

CREATE UNIQUE INDEX tab_x_y ON tab(x) INCLUDE (y);

唯一性条件仅适用于列x,而不适用于xy的组合。(INCLUDE子句也可以写在UNIQUEPRIMARY KEY约束中,为设置这样的索引提供备用语法。)

在索引中添加非键有效负载列时,最好保持保守,尤其是宽列。如果索引元组超过索引类型允许的最大大小,数据插入将失败。在任何情况下,非键列都会重复索引表中的数据,并增加索引的大小,从而可能减慢搜索速度。请记住,除非表更改得足够慢,以至于仅索引扫描可能不需要访问堆,否则将有效负载列包含在索引中几乎没有意义。如果无论如何都必须访问堆元组,那么从那里获取列的值不会花费更多。其他限制是当前不支持将表达式作为包含的列,并且当前只有 B 树、GiST 和 SP-GiST 索引支持包含的列。

在PostgreSQL拥有INCLUDE功能之前,人们有时会通过将有效负载列写为普通索引列来创建覆盖索引,即编写

CREATE INDEX tab_x_y ON tab(x, y);

即使他们从未打算将y用作WHERE子句的一部分。只要额外的列是尾随列,这种方法就很好用;使它们成为前导列是不明智的,原因在第 11.3 节中进行了说明。但是,此方法不支持您希望索引对键列强制唯一性的情况。

后缀截断始终从上层 B 树级别中删除非键列。作为有效负载列,它们永远不会用于指导索引扫描。当键列的剩余前缀恰好足以描述最低 B 树级别上的元组时,截断过程还会删除一个或多个尾随键列。在实践中,没有INCLUDE子句的覆盖索引通常避免在较高级别存储实际上是有效负载的列。但是,明确将有效负载列定义为非键列可靠地保持上层元组较小。

原则上,仅索引扫描可与表达式索引一起使用。例如,给定f(x)上的索引,其中x是表列,应该可以执行

SELECT f(x) FROM tab WHERE f(x) < 1;

作为仅索引扫描;如果f()是计算成本很高的函数,这是非常有吸引力的。但是,PostgreSQL的规划器目前对这种情况不太明智。它认为只有当查询所需的所有可从索引获得时,查询才可能通过仅索引扫描执行。在此示例中,除了在上下文f(x)中之外,不需要x,但规划器没有注意到这一点,并得出结论认为无法进行仅索引扫描。如果仅索引扫描似乎非常值得,可以通过将x添加为包含的列来解决此问题,例如

CREATE INDEX tab_f_x ON tab (f(x)) INCLUDE (x);

另一个需要注意的地方是,如果目标是避免重新计算f(x),那么规划器不一定将f(x)的使用(不在可索引的WHERE子句中)与索引列匹配。在上面所示的简单查询中,它通常会正确处理,但在涉及联接的查询中则不会。这些缺陷可能会在PostgreSQL的未来版本中得到解决。

部分索引与仅索引扫描也有着有趣的交互。考虑在示例 11.3中显示的部分索引

CREATE UNIQUE INDEX tests_success_constraint ON tests (subject, target)
    WHERE success;

原则上,我们可以对该索引执行仅索引扫描以满足类似这样的查询

SELECT target FROM tests WHERE subject = 'some-subject' AND success;

但存在一个问题:WHERE子句引用了success,而该子句不可用作索引的结果列。尽管如此,仅索引扫描还是可行的,因为该计划不需要在运行时重新检查WHERE子句的那一部分:在索引中找到的所有条目必定具有success = true,因此无需在计划中明确检查这一点。PostgreSQL9.6 及更高版本将识别此类情况并允许生成仅索引扫描,但较低版本则不行。