PostgreSQL中的索引—5(GiST)上

在之前的文章中,我们讨论了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")).

(未完待续)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值