在之前的文章中,我们讨论了PostgreSQL索引引擎、访问方法的接口,以及两种访问方法:哈希索引和B树。在本文中,我们将描述GiST索引。
GiST
GiST是“广义搜索树”的缩写,这是一个平衡搜索树,就像之前讨论的B树一样。
有什么区别?“btree”索引与比较语义严格相关:它只支持“大于”、“小于”和“相等”运算符(但功能非常强大!)然而,现代数据库存储的数据类型,如地理数据、文本文档、图像等等,对于这些操作符来说毫无意义。
但GiST索引方法对这些数据类型有帮助,它允许定义一条规则,将任意类型的数据分布在平衡树上,并允许定义一种方法,使用这种表示方式通过某些操作符进行访问。例如,GiST索引可以“容纳”支持相对位置运算符(位于左侧、右侧、包含等)的空间数据的R树,或支持交叉或包含运算符集合的RD树。
由于可扩展性,可以在PostgreSQL中从头创建一个全新的方法:为此,必须实现一个包含索引引擎的接口。但这不仅需要预先规划索引逻辑,还需要将数据结构映射到页面,高效地实现锁,并支持预写日志。所有这些都需要高开发技能和大量人力。GiST通过接管低级问题并提供自己的接口简化了任务:几个功能不是与技术有关,而是与应用领域有关。从这个意义上讲,我们可以将GiST视为构建新访问方法的框架。
结构
GiST是一个高度平衡的树,由节点页组成。节点由索引行组成。
叶节点的每一行(叶行)通常包含一些谓词(布尔表达式)和一个对表行的引用(TID)。索引的数据(键)必须满足此谓词。
内部节点的每一行(内部行)包含一个谓词和对子节点的引用,子树的所有索引数据都必须满足该谓词。换句话说,内部行的谓词包含所有子行的谓词。GiST索引的这一重要特性取代了B-树的简单排序。
(这张图是根据上面文字自己画的,有错的话欢迎指正)
GiST树中的搜索使用一个专门的一致性函数(“consistent”)——接口定义的函数之一,并为每个支持的运算符族以其自己的方式实现。
一致性函数为索引行调用,并确定该行的谓词是否与搜索谓词一致(指定为"indexed-field operator expression")。对于内部行,此函数实际上确定是否需要下降到相应的子树,对于叶行,此函数确定索引数据是否满足谓词。
搜索从根节点开始,就像普通的树搜索一样。一致性函数允许找出哪些子节点可以进入(可能有几个)哪些子节点不可以。然后对找到的每个子节点重复该算法。如果节点是叶子,则一致性函数选择的行将作为结果之一返回。
搜索是深度优先的:算法首先尝试到达叶节点。这允许尽可能快地返回第一个结果(如果用户只对几个结果感兴趣,而不是所有结果感兴趣,这可能很重要)。
让我们再次注意,一致性函数不需要与“大于”、“小于”或“等于”运算符有任何关系。一致性函数的语义可能非常不同,因此,不能期望索引以特定顺序返回值。
我们不会在GiST中讨论插入和删除值的算法(有几个接口函数执行了这些操作)。然而,有一点很重要。在索引中插入新值时,将选择该值在树中的位置,以便尽可能少地扩展其父行的谓词(理想情况下,根本不扩展)。但是当一个值被删除时,父行的谓词不再减少。父行谓词减少只会发生在这样的情况下:一个页面被拆分为两个页面(当页面没有足够的空间插入新的索引行时),或者从头开始重新创建索引(使用REINDEX或VACUUM FULL命令)。因此,GiST索引对于频繁变化的数据的效率可能会随着时间的推移而降低。(就是插入基本不更新父节点,删除肯定不更新父节点)
此外,我们将考虑几个数据类型的索引示例和GiST的有用属性:
- 点(和其他几何实体)和最邻近搜索。
- 间隔和排除约束。
- 全文搜索。
点的R-树
我们将通过平面中点的索引示例来说明上述内容(我们也可以为其他几何实体构建类似的索引)。常规B-树不适合这种数据类型的数据,因为没有为点定义比较运算符。
R-树的思想是将平面分割成矩形,这些矩形总共覆盖所有被索引的点。索引行存储一个矩形,谓词可以这样定义:“寻找的点位于给定的矩形内”。
R树的根将包含几个最大的矩形(可能相交)。子节点将包含嵌入父节点中的较小的矩形,并且总体覆盖所有基础点。
理论上,叶节点必须包含被索引的点,但所有索引行中的数据类型必须相同,因此,再次存储矩形,但“折叠”为点(叶节点的每一行中实际上存的是点,只不过这个点可以看做是一个矩形的对角坐标)。
为了可视化这样的结构,我们提供了R树的三个级别的图像。点是机场的坐标(类似于演示数据库“airports”表中的坐标,但提供了更多openflights.org中的数据)。
第一级:两个可见的大的相交矩形。
第二级:大矩形被分割成更小的区域
第三级:每个矩形包含尽可能多的点,以匹配一个索引页。
现在让我们来考虑一个非常简单的“一级”例子:
postgres=# create table points(p point);
postgres=# insert into points(p) values
(point '(1,1)'), (point '(3,2)'), (point '(6,3)'),
(point '(5,5)'), (point '(7,8)'), (point '(8,6)');
postgres=# create index on points using gist(p);
通过这种拆分,索引结构将如下所示:
创建的索引可用于加速以下查询,例如:“查找给定矩形中包含的所有点”。这种情况可以形式化为:p<@box‘(2,1),(6,3)’;(运算符<@出自“points_ops”族,表示“被包含在……中”):
postgres=# set enable_seqscan = off;
postgres=# explain(costs off) select * from points where p <@ box '(2,1),(7,4)';
QUERY PLAN
----------------------------------------------
Index Only Scan using points_p_idx on points
Index Cond: (p <@ '(7,4),(2,1)'::box)
(2 rows)
运算符的一致性函数(“indexed-field <@expression”,其中indexed-field 是一个点,expression是一个矩形)定义如下。对于内部行,如果其矩形与表达式定义的矩形相交,则返回“是”。对于叶行,如果其点(“折叠”矩形)包含在表达式定义的矩形中,则函数返回“是”。
搜索从根节点开始。矩形(2,1)-(7,4)与(1,1)-(6,3)相交,但不与(5,5)-(8,8)相交,因此无需下降到第二个子树。
当到达一个叶节点时,我们遍历其中包含的三个点,并返回其中两个点作为结果:(3,2)与(6,3)。
postgres=# select * from points where p <@ box '(2,1),(7,4)';
p
-------
(3,2)
(6,3)
(2 rows)
内部构件
不幸的是,传统的“pageinspect”不允许查看GiST索引内部。但还有另一种方法可用:“gevel”扩展。它不包括在标准交付中,因此请参阅安装说明。
如果一切顺利,你将可以使用三个功能。首先,我们可以得到一些统计数据:
postgres=# select * from gist_stat('airports_coordinates_idx');
gist_stat
------------------------------------------
Number of levels: 4 +
Number of pages: 690 +
Number of leaf pages: 625 +
Number of tuples: 7873 +
Number of invalid tuples: 0 +
Number of leaf tuples: 7184 +
Total size of tuples: 354692 bytes +
Total size of leaf tuples: 323596 bytes +
Total size of index: 5652480 bytes+
(1 row)
显然,机场坐标上的索引大小为690页,索引由四个级别组成:上图(就是上面那三幅包含很多点的大图)显示了根级别和两个内部级别,第四个级别是叶级别。
实际上,8000个点的索引会小得多(意思是索引比点本身小得多?):为了清晰起见,它是用10%的填充因子创建的(?)。
其次,我们可以输出索引树:
postgres=# select * from gist_tree('airports_coordinates_idx');
gist_tree
-----------------------------------------------------------------------------------------
0(l:0) blk: 0 numTuple: 5 free: 7928b(2.84%) rightlink:4294967295 (InvalidBlockNumber) +
1(l:1) blk: 335 numTuple: 15 free: 7488b(8.24%) rightlink:220 (OK) +
1(l:2) blk: 128 numTuple: 9 free: 7752b(5.00%) rightlink:49 (OK) +
1(l:3) blk: 57 numTuple: 12 free: 7620b(6.62%) rightlink:35 (OK) +
2(l:3) blk: 62 numTuple: 9 free: 7752b(5.00%) rightlink:57 (OK) +
3(l:3) blk: 72 numTuple: 7 free: 7840b(3.92%) rightlink:23 (OK) +
4(l:3) blk: 115 numTuple: 17 free: 7400b(9.31%) rightlink:33 (OK) +
...
第三,我们可以输出存储在索引行中的数据。注意以下细微差别:函数的结果必须转换为所需的数据类型。在我们的情况下,这种类型是“box”(一个矩形框)。例如,请注意顶层的五行:
postgres=# select level, a from gist_print('airports_coordinates_idx')
as t(level int, valid bool, a box) where level = 1;
level | a
-------+-----------------------------------------------------------------------
1 | (47.663586,80.803207),(-39.2938003540039,-90)
1 | (179.951004028,15.6700000762939),(15.2428998947144,-77.9634017944336)
1 | (177.740997314453,73.5178070068359),(15.0664,10.57970047)
1 | (-77.3191986083984,79.9946975708),(-179.876998901,-43.810001373291)
1 | (-39.864200592041,82.5177993774),(-81.254096984863,-64.2382965088)
(5 rows)
(level=1其实是第二层)
实际上,上面提供的数字就是根据这些数据创建的。
搜索与排序操作符
到目前为止讨论的运算符(例如p<@box'(2,1), (7,4)'中的谓词<@)可以称为搜索运算符,因为它们在查询中指定了搜索条件。
还有另一种操作符类型:排序操作符。它们用于order by子句中排列顺序的规范,而不是列名的常规规范。以下是此类查询的示例:
postgres=# select * from points order by p point <-> '(4,7)' limit 2;
p
-------
(5,5)
(7,8)
(2 rows)
这里的p<->point '(4,7)'是一个使用<->排序运算符的表达式,它表示从一个参数(这里的参数是点)到另一个参数的距离。查询的意思是返回离点(4,7)最近的两个点。这样的搜索称为k-NN,即k-最近邻搜索。
为了支持此类查询,访问方法必须定义额外的距离函数,并且排序运算符必须包含在适当的运算符类中(例如,点的运算符包含在“points_ops”类中)。下面的查询显示了运算符及其类型(“s”-搜索和“o”-排序):
postgres=# select amop.amopopr::regoperator, amop.amoppurpose, amop.amopstrategy
from pg_opclass opc, pg_opfamily opf, pg_am am, pg_amop amop
where opc.opcname = 'point_ops'
and opf.oid = opc.opcfamily
and am.oid = opf.opfmethod
and amop.amopfamily = opc.opcfamily
and am.amname = 'gist'
and amop.amoplefttype = opc.opcintype;
amopopr | amoppurpose | amopstrategy
-------------------+-------------+--------------
<<(point,point) | s | 1 strictly left
>>(point,point) | s | 5 strictly right
~=(point,point) | s | 6 coincides
<^(point,point) | s | 10 strictly below
>^(point,point) | s | 11 strictly above
(point,point) | o | 15 distance
<@(point,box) | s | 28 contained in rectangle
<@(point,polygon) | s | 48 contained in polygon
<@(point,circle) | s | 68 contained in circle
(9 rows)
(结果中)还显示了策略的数量,并解释了它们的含义。很明显,有比“btree”多得多的策略,只有一些策略得到了分数的支持。可以为其他数据类型定义不同的策略。
为索引元素调用距离函数,它必须通过表达式(“indexed-field ordering-operator expression")计算定义的值到给定元素的距离(考虑运算符语义)。对于叶元素,这只是到索引值的距离。对于内部元素,函数必须返回到子叶元素的最小距离。由于遍历所有子行的成本非常高,因此允许函数乐观地低估距离,但代价是降低搜索效率。但是,决不允许函数高估距离,因为这会干扰索引的工作。(下面说明了这段话的含义)
distance函数可以返回任何可排序类型的值(为了排序值,PostgreSQL将使用“btree”访问方法的相应运算符族中的比较语义,如前所述)。
对于平面中的点,距离的解释通常是这样的:(x1,y1)<->(x2,y2)的值等于横坐标和纵坐标差平方和的平方根。从一个点到一个边界矩形的距离被视为从该点到该矩形的最小距离,如果该点位于该矩形内,则为零。无需遍历子点即可轻松计算该值,并且该值肯定不大于到任何子点的距离。
让我们考虑上述查询的搜索算法。
搜索从根节点开始。该节点包含两个矩形框。到(1,1)-(6,3)的距离是4.0,到(5,5)-(8,8)的距离是1.0。
子节点按增加距离的顺序遍历。这样,我们首先下降到最近的子节点,并计算到这些点的距离(我们将在图中显示数字以显示可见性):
此信息足以返回前两点(5,5)和(7,8)。因为我们知道到矩形(1,1)-(6,3)内的点的距离是4.0或更大,所以我们不需要下降到第一个子节点。
但如果我们需要找到前三点呢?
postgres=# select * from points order by p point '(4,7)' limit 3;
p
-------
(5,5)
(7,8)
(8,6)
(3 rows)
尽管第二个子节点包含所有这些点,但我们不能在不查看第一个子节点的情况下返回(8,6),因为该节点可以包含更接近的点(因为4.0<4.1)。
对于内部行,此示例阐明了距离函数的要求。通过为第二行选择较小的距离(4.0而不是实际的4.5),我们降低了效率(算法不必要地开始检查额外的节点),但没有破坏算法的正确性。
直到最近,GiST还是唯一能够处理排序运算符的访问方法(即自定义排序运算符)。但情况已经发生了变化:RUM访问方法(有待进一步讨论)已经纳入了这组方法,而且B树也不是不可能加上它们:社区正在讨论我们的同事尼基塔·格鲁霍夫开发的补丁。
【截至2019年3月,即将发布的PostgreSQL 12为SP-GiST添加了k-NN支持(也是尼基塔作为作者),B-tree的补丁仍在进行中。】
用于区间数据的R-树
使用GiST访问方法的另一个例子是区间索引,例如,时间间隔(“tsrange”类型)。不同之处在于,内部节点将包含间隔边界,而不是矩形框。
让我们考虑一个简单的例子。我们将出租一间小屋,并将出租时间存储在一张表上:
postgres=# create table reservations(during tsrange);
postgres=# insert into reservations(during) values
('[2016-12-30, 2017-01-09)'),
('[2017-02-23, 2017-02-27)'),
('[2017-04-29, 2017-05-02)');
postgres=# create index on reservations using gist(during);
该索引可用于加速以下查询,例如:
postgres=# select * from reservations where during && '[2017-01-01, 2017-04-01)';
during
-----------------------------------------------
["2016-12-30 00:00:00","2017-01-08 00:00:00")
["2017-02-23 00:00:00","2017-02-26 00:00:00")
(2 rows)
postgres=# explain (costs off) select * from reservations where during && '[2017-01-01, 2017-04-01)';
QUERY PLAN
------------------------------------------------------------------------------------
Index Only Scan using reservations_during_idx on reservations
Index Cond: (during && '["2017-01-01 00:00:00","2017-04-01 00:00:00")'::tsrange)
(2 rows)
&&区间运算符表示相交;因此,查询必须返回与给定间隔相交的所有间隔。对于这样的运算符,一致性函数确定给定的间隔是否与内部行或叶行中的值相交。
请注意,这不是关于按特定顺序获取间隔,尽管为间隔定义了比较运算符。我们可以使用“btree”索引来表示时间间隔,但如果使用btree,我们将在没有下面这些运算符的支持下工作:
postgres=# select amop.amopopr::regoperator, amop.amoppurpose, amop.amopstrategy
from pg_opclass opc, pg_opfamily opf, pg_am am, pg_amop amop
where opc.opcname = 'range_ops'
and opf.oid = opc.opcfamily
and am.oid = opf.opfmethod
and amop.amopfamily = opc.opcfamily
and am.amname = 'gist'
and amop.amoplefttype = opc.opcintype;
amopopr | amoppurpose | amopstrategy
-------------------------+-------------+--------------
@>(anyrange,anyelement) | s | 16 contains element
<<(anyrange,anyrange) | s | 1 strictly left
&<(anyrange,anyrange) | s | 2 not beyond right boundary
&&(anyrange,anyrange) | s | 3 intersects
&>(anyrange,anyrange) | s | 4 not beyond left boundary
>>(anyrange,anyrange) | s | 5 strictly right
-|-(anyrange,anyrange) | s | 6 adjacent
@>(anyrange,anyrange) | s | 7 contains interval
<@(anyrange,anyrange) | s | 8 contained in interval
=(anyrange,anyrange) | s | 18 equals
(10 rows)
(等式除外,因为它包含在“btree”访问方法的运算符类中。)
内部构件
我们可以使用相同的“gevel”扩展来查看内部。只需在调用gist_print时更改数据类型:
postgres=# select level, a from gist_print('reservations_during_idx')
as t(level int, valid bool, a tsrange);
level | a
-------+-----------------------------------------------
1 | ["2016-12-30 00:00:00","2017-01-09 00:00:00")
1 | ["2017-02-23 00:00:00","2017-02-27 00:00:00")
1 | ["2017-04-29 00:00:00","2017-05-02 00:00:00")
(3 rows)
排除约束
GiST索引可用于支持排除约束(EXCLUDE)。
排除约束确保任意两个表行的给定字段在某些运算符方面不“对应”。如果选择“等于”运算符,我们就得到了唯一的约束:任意两行的给定字段彼此不相等。(如,A与B间隔不相交)
索引和唯一约束都支持排除约束。我们可以选择任何操作符,只要:
1. 它由索引方法(例如,“btree”、GiST或SP GiST,但不包括GIN)的“can_exclude”属性支持。
2. 它是可交换的,即满足条件:a操作符b=b操作符a。
以下是一个合适的策略列表和运算符示例(运算符可以有不同的名称,并且并非适用于所有数据类型):
对于btree:
- “equal” =
对于GiST与SP-GiST:
- "相交"&&
- “一致”~=
- “相邻”-|-
请注意,我们可以在排除约束中使用相等运算符,但这是不符合实际的:唯一约束将更有效。这就是为什么我们在讨论B-树时没有提到排除约束的原因。(即,对于相等运算符、使用唯一约束更有效,使用排除约束效率更低)
让我们提供一个使用排除约束的示例。这个例子的区间数据不允许存在相交区间。
postgres=# alter table reservations add exclude using gist(during with &&);
一旦我们创建了排他约束,我们就可以添加行:
postgres=# insert into reservations(during) values ('[2017-06-10, 2017-06-13)');
但尝试在表中插入相交区间将导致错误:
postgres=# insert into reservations(during) values ('[2017-05-15, 2017-06-15)');
ERROR: conflicting key value violates exclusion constraint "reservations_during_excl"
DETAIL: Key (during)=(["2017-05-15 00:00:00","2017-06-15 00:00:00")) conflicts with existing key (during)=(["2017-06-10 00:00:00","2017-06-13 00:00:00")).
(未完待续)