12.9. 只用索引的扫描和覆盖索引

LightDB中的所有索引是二级索引,这意味着每个索引都是与表的主数据区(在LightDB术语称为表的中)分开存储。这意味着在普通索引扫描中,每行检索都需要从索引和堆中取数据。 此外,虽然匹配给定的可索引WHERE条件的索引条目通常在一起靠近存储,但它们引用的表行可能在堆中的任何地方。 因此索引扫描的堆访问部分涉及到对堆的大量随机访问,这可能很慢,特别是在传统旋转媒介上。如 Section 12.5 中所述,位图扫描尝试通过按排序的顺序进行堆访问来减少成本,但这远远不够)。

为了解决这种性能问题,LightDB支持只用索引的扫描,这类扫描可以仅用一个索引来回答查询而不产生任何堆访问。其基本思想是直接从每一个索引项中直接返回值,而不是去参考相关的堆项。在使用这种方法时有两个根本的限制:

  1. 索引类型必须支持只用索引的扫描。B-树索引总是支持只用索引的扫描。其他索引类型不支持这种扫描。底层的要求是索引必须在物理上存储或者可以重构出每一个索引项对应的原始数据值。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;
    

    (如下面所讨论的,表达式索引和部分索引会让这条规则更加复杂)。

如果符合这两个根本要求,那么该查询所要求的所有数据值都可以从索引得到,因此才可能使用只用索引的扫描。但是对LightDB中的任何表扫描还有一个额外的要求:必须验证每一个检索到的行对该查询的 MVCC 快照是可见的,如Chapter 14中讨论的那样。可见性信息并不存储在索引项中,只存储在堆项中。因此,乍一看似乎每一次行检索无论如何都会要求一次堆访问。如果表行最近被修改过,确实是这样。但是,对于很少更改的数据有一种方法可以解决这个问题。LightDB为表堆中的每一个页面跟踪是否其中所有的行的年龄都足够大,以至于对所有当前以及未来的事务都可见。这个信息存储在该表的可见性映射的一个位中。在找到一个候选索引项后,只用索引的扫描会检查对应堆页面的可见性映射位。如果该位被设置,那么这一行就是可见的并且该数据库可以直接被返回。如果该位没有被设置,那么就必须访问堆项以确定这一行是否可见,这种情况下相对于标准索引扫描就没有性能优势。即便是在成功的情况下,这种方法也是把对堆的访问换成了对可见性映射的访问。不过由于可见性映射比它所描述的堆要小四个数量级,所以访问可见性映射所需的物理 I/O 要少很多。在大部分情况下,可见性映射总是会被保留在内存中的缓冲中。

总之,虽然当两个根本要求满足时可以使用只用索引的扫描,但是只有该表的堆页面中有很大一部分的“所有都可见”映射位被设置时这种索引才有优势。不过,有很大一部分行不被更改的表是很常见的,这也让这一类扫描在实际中非常有用。

为了有效利用仅索引扫描功能,您可以选择创建一个覆盖索引,它是一个特别设计的索引,包含经常运行的特殊类型查询所需要的列。由于查询通常需要检索的列不仅仅是他们搜索的列,LightDB允许您创建索引,这个索引中有些列只是负荷而不是搜索键的一部分。这可以通过添加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树索引当前支持包含的列。

LightDBINCLUDE特性之前,人们有时会通过写负载列作为普通索引列来制作覆盖索引。它这样写:

      CREATE INDEX tab_x_y ON tab(x, y);
   

即使他们无意将y用作WHER子句的一部分,只要额外的列是尾列就可以很好的工作。 让它们成为前导字段是不明智的,原因在Section 12.3中有说明。但是,此方法不支持您希望索引在键列上实施唯一性。

Suffix truncation总是从B-Tree的上层移除非键列。作为有效负载列,它们从不用于指导索引扫描。 当键列的其余前缀恰好足以描述最低B-Tree级别上的元组时,截断过程还会删除一个或多个尾随的键列。 实际中,不使用INCLUDE子句覆盖索引通常会避免存储在上层有效负载的列。 然而,显式地将有效负载列定义为非键列reliably使上层元组保持较小。

原则上,只用索引的扫描可以被用于表达式索引。例如,给定一个f(x)上的索引(x是一个表列),可以把

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

作为索引扫描的唯一扫描进行查询是非常有吸引力的,特别是当f()是一个计算开销很大的函数时。然而,LightDB的优化器目前对这种情况并不是很聪明。只有当查询所需的所有都可以从索引中获取时,它才认为可以通过索引扫描进行查询。在这个例子中,除了在上下文f(x)中需要x之外,它不需要x,但优化器没有注意到这一点,并得出不能使用索引扫描的结论。如果索引扫描看起来非常值得,可以通过将x作为包含列添加来解决这个问题,例如:

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

另一个需要注意的问题是,如果目标是避免重新计算f(x),那么优化器不一定会将不在可索引的WHERE子句中使用f(x)与索引列匹配。在上面显示的简单查询中,它通常会得出正确的结果,但在涉及连接的查询中不会。这些不足可能在未来版本的LightDB中得到解决。

部分索引也和只用索引的扫描之间有着有趣的关系。考虑Example 12.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,因此在计划中检查这个部分的需要并不明显。LightDB 9.6 和以后的版本将会识别这种情况,并且允许生成只用索引的扫描,但是旧版本无法这样做。