受支持版本: 当前版本 (18) / 17 / 16 / 15 / 14
开发版本: devel

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

PostgreSQL中的所有索引都是二级索引,也就是说,每个索引都与表的主数据区分开存储(在PostgreSQL术语中,这个主数据区称为表的)。这意味着,在普通索引扫描中,每次取回一行都需要同时从索引和堆中取数据。此外,尽管满足某个可索引WHERE条件的索引项通常在索引中彼此接近,但它们引用的表行却可能分布在堆中的任何位置。因此,索引扫描的堆访问部分会涉及大量对堆的随机访问,这可能很慢,尤其是在传统旋转介质上。(正如Section 11.5中所述,位图扫描试图通过按排序顺序进行堆访问来缓解这项成本,但那也只能缓解到一定程度。)

为了解决这个性能问题,PostgreSQL支持仅索引扫描,它可以仅凭索引而不访问堆来回答查询。基本思路是直接从每个索引项中返回值,而不是再去查对应的堆项。要使用这种方法,有两个根本限制:

  1. 索引类型必须支持仅索引扫描。B-树索引总是支持。GiST 和 SP-GiST 索引对某些操作符类支持仅索引扫描,但对另一些则不支持。其他索引类型则完全不支持。底层要求是,索引必须实际存储原始数据值,或者至少能够重建出每个索引项对应的原始数据值。反例是 GIN 索引,它不能支持仅索引扫描,因为每个索引项通常只保存原始数据值的一部分。

  2. 查询只能引用存储在索引中的列。例如,给定一个表,它有列xyz,而索引建立在xy上,那么下面这些查询可以使用仅索引扫描:

    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 快照是否可见,如Chapter 13所述。可见性信息并不保存在索引项中,而只保存在堆项中;因此乍看之下,似乎每次取回行无论如何都要访问堆。这在表行最近被修改过时的确如此。然而,对于很少变化的数据,这个问题有办法绕开。PostgreSQL会跟踪表堆中每个页面是否其中所有行都已经足够老,以至于对当前和未来所有事务都可见。这个信息保存在该表的可见性映射中的一个位里。仅索引扫描在找到候选索引项后,会检查对应堆页面的可见性映射位。如果该位已设置,那么这行就已知可见,数据可以直接返回而无需进一步工作。如果没有设置,就必须访问堆项来判断该行是否可见,这样相对标准索引扫描就没有性能优势。即使在成功的情况下,这种做法也是用访问可见性映射来替代访问堆;但由于可见性映射比它描述的堆小四个数量级,访问它所需的物理 I/O 要少得多。在大多数场景下,可见性映射始终都会缓存于内存中。

简而言之,尽管满足那两个基本要求时就有可能使用仅索引扫描,但只有当表中相当一部分堆页的全部可见(all-visible)映射位已被设置时,它才会带来收益。不过,很多表都会有相当大一部分行长期不变,因此这种扫描方式在实践中非常有用。

为了有效利用仅索引扫描特性,你可以选择创建一个覆盖索引,也就是专门设计来包含某一类高频查询所需列的索引。由于查询通常需要取回的列不止是用于搜索的那些列,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子句的一部分来使用。只要这些额外列位于尾部,这样做就没问题;如果把它们放到前导位置,则出于Section 11.3中解释的原因,是不明智的。不过,这种方法无法支持你希望索引对键列强制唯一性的场景。

后缀截断总会从 B-Tree 上层移除非键列。作为负载列,它们从不会被用于指导索引扫描。截断过程还会在剩余键列前缀已经足以描述最低 B-Tree 层上的元组时,移除一个或多个尾随键列。实践中,不带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),那么规划器不一定会把那些不在可索引WHERE子句中的f(x)用法与索引列匹配起来。对于上面展示的简单查询,它通常能做对,但对于涉及连接的查询则未必。未来版本的PostgreSQL可能会修复这些不足。

部分索引与仅索引扫描之间也有有趣的相互作用。考虑Example 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,因此计划里无需显式检查它。PostgreSQL 9.6 及更高版本能够识别这种情况,并允许生成仅索引扫描;更早的版本则不能。

提交更正

如果您发现文档中有不正确的内容、与您使用特定功能的经验不符或需要进一步说明,请使用此表单来报告文档问题。