暂无图片
暂无图片
2
暂无图片
暂无图片
暂无图片

OpenGauss gist 索引

原创 吴松 云和恩墨 2021-10-08
1444

概述

自 B-tree 提出以来,衍生出很多不同类型的搜索树,GiST(Generalized Search Tree)广义搜索树是一种新型的索引结构,它可以在一种实现中提供很多不同树形结构的功能。GiST是一种可扩展的数据结构,允许用户针对不同的数据类型开发索引,支持对支持的数据类型的各种方式的查找。GiST可以统一许多流程的搜索树(如 R-tree、B±tree、hB-tree、TV-tree、CH-tree等等),而无需构建多个搜索树。准确地说 Gist 并不是一种具体的索引类型,而是 tree 结构的索引模板,PG 和 OpenGauss 中有基于 Gist 实现的 R-tree 索引。
除了统一这些搜索树外,GiST还具有以前的树所没有的特性:数据和查询可扩展性。

查询扩展性

以前的搜索树在处理数据方面是可扩展的。例如,PG 支持可扩展的 B±tree 和 R-tree,这意味着你可以在不同的数据类型上建立 B±tree 或 R-tree ,例如 int 类型,float 类型等。但是 B±tree只支持(>=,<=,>,<,=)这几个谓词,而 R-tree 只支持(contains, contained, equals)。因此,PG 中如果你想用 B±tree 索引支持如查找“所有带炫酷爆炸的电影” 这样的查询可能很难实现。
而 GiST 可以被编程以支持任何查询谓词。运行 GiST 所需要的只是实现 4 个由用户定义的接口,这些接口中定义了树中 key 的行为。当然,要支持一些看起来很花哨的查询,这些接口必须实现得非常漂亮,但对于标准的一些查询(如 B-tree、R-tree等),实现起来非常简单。

GiST的关键

GiST 本身的结构是一种类似与 B-tree 的平衡树结构,包含 <key, pointer> 对。但 GisT 中的 key 和 B-tree 中的可能不一样,GiST 中的 key 可以是用户自定义的类型,用于表示通过关联的 pointer 可以访问到的一些属性。例如, B±tree 的 Gist 中 key 是数字范围(所有的指针指向的内容的范围都在4~6之间);T-tree 的 GiST 中 key 是边界框(指针指向的内容都在California);RD-tree 的 key 是集合(指针指向的内容都是key中表示的集合的子集)…
要让 Gist 正确工作,需要弄清楚 key 表示的是什么,然后编写 4 个接口来实现对树的插入、删除和搜索。

4种接口

以下是 GiST 工作需要实现的 4 个接口。

Consistent: 让树可以正确执行搜索。给定树种 page 上的 key p,以及用户查询 q,Consistent 方法应该返回 NO,如果对于一个给定的数据项 p 和 q 都不为真,否则应该返回 MAYBE。
举例: 下图 B-tree 内部节点中的 10,表示的含义是其最终指向的数据范围是 [10, 70)

p : [10, 70)
假设 q : select xx from table where key < 80
那么对于
item(key = 90,…),Consistent 方法返回 NO
item(key = 60,…),Consistent 方法返回 MAYBE

image.png

Union: 用于合并树中的信息。给定一组条目 S,该方法返回一个新的键 p,它 对于 S 下的所有数据项都为真。实现 Union 的一个简单的方法是返回一个等价于 S 中 keys 的析取的谓词,即 如果 S {P1,P2,…Pn} 返回 P1 or P2 or … Pn

Penalty: 如果在以 <p, ptr> 为根的子树中插入新的数据项,则返回一个数字,表示这样做的代价有多大。插入的项,将沿着代价最小的路径插入。

PickSplit: 和 B-tree 一样,GiST中的页面有时也需要在插入新数据时进行分裂,PickSplit 决定哪些数据属于新页面,哪些数据留在老页面。

更多关于 Gist 的细节,可以参考原始论文:Generalized Search Trees for Database Systems

Gist 索引实现

构建 gist 索引

gistbuild
{
    ...
    // 构建 GISTBuildState 对象
    GISTBuildState buildstate;
    ...
    buildstate.giststate = initGISTstate(index);
    ...
    // 初始化 gist 根节点
     /* initialize the root page */
    buffer = gistNewBuffer(index);
    Assert(BufferGetBlockNumber(buffer) == GIST_ROOT_BLKNO);
    page = BufferGetPage(buffer);

    START_CRIT_SECTION();

    GISTInitBuffer(buffer, F_LEAF);

    MarkBufferDirty(buffer);

    if (RelationNeedsWAL(index)) {
        XLogRecPtr recptr;

        XLogBeginInsert();
        XLogRegisterBuffer(0, buffer, REGBUF_WILL_INIT);

        recptr = XLogInsert(RM_GIST_ID, XLOG_GIST_CREATE_INDEX);
        PageSetLSN(page, recptr);
    } else
        PageSetLSN(page, GetXLogRecPtrForTemp());

    UnlockReleaseBuffer(buffer);

    END_CRIT_SECTION();
    ...
    // 表扫描,构造 index tuple,将 index tuple 插入 gist 索引中
    reltuples = tableam_index_build_scan(heap, index, indexInfo, true, gistBuildCallback, (void *)&buildstate);
    ... 
}

重点关注 gist 索引中 index tuple 结构 和 索引构造实现

这部分的主要实现在 gistBuildCallback 中,对于每个需要被索引的 heap tuple,都需要调用 gistBuildCallback 进行处理。

gistBuildCallback
{
    // 组装 gist index tuple 
    itup = gistFormTuple(buildstate->giststate, index, values, isnull, true);
    // 调用 gistdoinsert,将 index tuple 插入 gist 索引
    gistdoinsert(index, itup, buildstate->freespace, buildstate->giststate);
    ...
}

gistdoinsert
{
    // 从树的 root 节点开始以最小 penalty 向下查找,用插入的 key 更新父节点向下的指针;
    for( ; ; ) {
        // 如果在中间 crash 了,树仍然是一致的,更新父节点有时候可能不是很必要
        // 对当前访问的节点加锁时,首先加 ShareLock,如果需要更新节点,先放锁
        // 再将节点的锁换成 ExclusiveLock
    
        // 如果有未完成的分裂,或者并发执行的分裂任务需要处理
    
        // 如果不是叶子节点
        if (!GistPageIsLeaf(stack->page)) {
             // 找到插入代价(penalty)最小的子节点
             downlinkoffnum = gistchoose(state.r, stack->page, itup, giststate);
             // 和 B-tree 索引不同的是,B-tree 索引是先找到叶子节点,在叶子节点执行插入,再向上回溯更新父节点
             // 而 gist 索引是在向下查找的过程中先更新父节点,然后再向下查找子节点直到叶子节点;
             // 因此,最后更新完子节点不需要再向上回溯更新,因为父节点全部已经再查找过程中更新完毕
             // 更新非叶子节点
             newtup = gistgetadjusted(state.r, idxtuple, itup, giststate);
             if (newtup) {
                 ...
             }
        }
        else { // 如果是叶子节点
            // 插入新 key 
            ...
            (void)gistinserttuple(&state, stack, giststate, itup, InvalidOffsetNumber);
        }
    }
}

// 查找对插入 index tuple 中包含的 key 代价最小的子节点,返回指向子节点的指针 item 在当前节点的 offsetnumber
gistchoose
{
    // index tuple 中包含压缩的属性,先解压缩
    gistDeCompressAtt(giststate, r, it, NULL, (OffsetNumber)0, identry, isnull);
    // 索引可能包含多个列,每个列都有一个代价值
    // 索引定义中先出现的列的代价比后出现的列的代价权重更大
    // best_penalty[j] 是目前我们看到的对列 j 的最小代价,或者是 -1 如果还没有检查到列 j ; 第一个 -1 右侧的代价值都是未定义的
    best_penalty[0] = -1;
    // 当前 page 上所有 tuple
    for(i = FirstOffsetNumber; i <= maxoff; i = OffsetNumberNext(i)) {
        // 获取一个 index tuple
        IndexTuple itup = (IndexTuple)PageGetItem(p, PageGetItemId(p, i));
        // 对 index tuple 的每个属性
        for (j = 0; j < r->rd_att->natts; j++) {
            // 计算每个列的代价
            datum = index_getattr(itup, j + 1, giststate->tupdesc, &IsNull);
            gistdentryinit(giststate, j, &entry, datum, r, p, i, FALSE, IsNull);
            usize = gistpenalty(giststate, j, &entry, IsNull, &identry[j], isnull[j]);
            // 由于前面的列的权重更大,因此如果计算出来的前面的列的代价值比之前最小的代价值更大,则没有必要继续检查后面的列,可以直接检查下一个 tuple 了
            // 如果前面列比之前最小的更小,或者相等则继续检查后面的列
            if (best_penalty[j] < 0 || usize < best_penalty[j]) {
                result = i;
                best_penalty[j] = usize;

                if (j < r->rd_att->natts - 1)
                    best_penalty[j + 1] = -1;
            ...
        }
        // 如果当前 tuple 所有列的代价都是 0 ,则没有必要检查后面的 tuple 了,跳出循环
    }

    // 返回指向代价值最小的 index tuple line-pointer 在 page 中的 offsetnumber
    return result;
}

// 每一个 gist 索引支持的类型,都实现了索引相关的 proc

gistpenalty
{
    float penalty = 0.0;
    // 找到对应的 penalty 函数,计算 penelty 值
    if (giststate->penaltyFn[attno].fn_strict == FALSE || (isNullOrig == FALSE && isNullAdd == FALSE)) {
        FunctionCall3Coll(&giststate->penaltyFn[attno], giststate->supportCollation[attno], PointerGetDatum(orig),
                          PointerGetDatum(add), PointerGetDatum(&penalty));
        /* disallow negative or NaN penalty */
        if (isnan(penalty) || penalty < 0.0) {
            penalty = 0.0;
        }
    } else if (isNullOrig && isNullAdd) {
        penalty = 0.0;
    } else {
        /* try to prevent mixing null and non-null values */
        penalty = get_float4_infinity();
    }

    return penalty;
}

例如:R-tree 中新插入一条数据的逻辑如下

插入节点时,算法从树的根节点开始递归地向下遍历。检查当前节点的所有外接矩形,并启发式地选择在哪个子节点中插入(例如选择插入后外接矩形扩张最小的那个子节点),然后进入选择的那个子节点继续检查,直到到达叶子节点。满的叶子节点应该在插入之前分裂,所以插入时到达的叶子节点一定有空位来写数据。

查找对应的处理函数:
pg_opclass 定义索引的 operator class。 每个 operator class 为一种特定的 数据类型 和 特定索引 访问方法定义字段的语义。 一个操作符类本质上指定一个特定的 operator class 适用于一个特定的可索引的字段数据类型。
image.png

通过查询 pg_opclass 可以查到 gist 索引支持的操作符类,以及索引的类型、索引数据的类型等

image.png

可以看到 gist 支持的操作符类 包括
box_ops、point_ops、poly_ops、circle_ops、tsvector_ops、tsquery_ops 及 range_ops
以 box_ops 为例,查询到关联的 am_proc 有
gist_box_consistent、gist_box_union、gist_box_compress、gist_box_decompress、gist_box_penalty、gist_box_picksplit、gist_box_same
其中包含了 consistent、union、penalty 和 picksplit 这四个要实现的接口

查询 pg_amproc 获取索引关联的 opfamily 的支持的 proc
postgres=# select * from pg_amproc where amprocfamily = 2593;
 amprocfamily | amproclefttype | amprocrighttype | amprocnum |       amproc        
--------------+----------------+-----------------+-----------+---------------------
         2593 |            603 |             603 |         1 | gist_box_consistent
         2593 |            603 |             603 |         2 | gist_box_union
         2593 |            603 |             603 |         3 | gist_box_compress
         2593 |            603 |             603 |         4 | gist_box_decompress
         2593 |            603 |             603 |         5 | gist_box_penalty
         2593 |            603 |             603 |         6 | gist_box_picksplit
         2593 |            603 |             603 |         7 | gist_box_same
(7 rows)

查找到 box 类型的 penalty 函数为 gist_box_penalty
查看实现

Datum gist_box_penalty(PG_FUNCTION_ARGS)
{
    GISTENTRY *origentry = (GISTENTRY *)PG_GETARG_POINTER(0);
    GISTENTRY *newentry = (GISTENTRY *)PG_GETARG_POINTER(1);
    float *result = (float *)PG_GETARG_POINTER(2);
    BOX *origbox = DatumGetBoxP(origentry->key);
    BOX *newbox = DatumGetBoxP(newentry->key);
    BOX unionbox;

    rt_box_union(&unionbox, origbox, newbox);
    *result = (float)(size_box(&unionbox) - size_box(origbox));
    PG_RETURN_POINTER(result);
}

可以看出计算出来的代价值(penalty)是两个 box 做 union 操作后的矩阵面积减去原始矩阵的面积。

如下图所示,根据代价函数计算规则,假设原来有 A 和 C 两个 box,在两个不同的 page page1 和 page2 中, 则根据代价计算要插入的 box B 插入 page2 的代价更小,所以 box 应该插入 page2 中。

image.png

以上是 Gist 索引中插入流程的实现,可以看到 penalty 接口在插入流程用于选择插入的 page 。
对于 Gist 索引而言,其叶子节点每一条数据包含一个谓词(bool 类型的表达式)和一个指向基表的 TID,索引的 key 必须满足这个谓词。非叶子节点也包含一个谓词和指向子节点的指针,非叶子节点的所有子节点都必须满足这个谓词。
以在二维空间插入一个 Point 为例,父节点的谓词可能是“所有子节点包含的矩形和点都在某个大的矩形内”,如果新插入的点完全落在父节点所表示的矩形内,则父节点不需要更新;相反,如果不在父节点所在的矩形内,则需要更新父节点所表示的矩形空间;更新完成后,继续向下完成插入动作。如下图所示,左边是插入需要更新父节点的矩形范围的情况(节点 B 表示父节点);右边是插入不需要更新父节点矩形范围的情况。如果父节点范围更新完后,最终插入失败,不影响 Gist 索引的使用。

image.png

Gist 索引查找

搜索 gist 需要用到 consistent 接口,在此之前需要了解 gist 索引中支持的数据类型都支持哪些操作符(operator)。

postgres=# select * from pg_amop where amopfamily = 2593;
 amopfamily | amoplefttype | amoprighttype | amopstrategy | amoppurpose | amopopr | amopmethod | amopsortfa
mily 
------------+--------------+---------------+--------------+-------------+---------+------------+-----------
-----
       2593 |          603 |           603 |            1 | s           |     493 |        783 |           
   0
       2593 |          603 |           603 |            2 | s           |     494 |        783 |           
   0
       2593 |          603 |           603 |            3 | s           |     500 |        783 |           
   0
       2593 |          603 |           603 |            4 | s           |     495 |        783 |           
   0
       2593 |          603 |           603 |            5 | s           |     496 |        783 |           
   0
       2593 |          603 |           603 |            6 | s           |     499 |        783 |           
   0
       2593 |          603 |           603 |            7 | s           |     498 |        783 |           
   0
       2593 |          603 |           603 |            8 | s           |     497 |        783 |           
   0
       2593 |          603 |           603 |            9 | s           |    2571 |        783 |           
   0
       2593 |          603 |           603 |           10 | s           |    2570 |        783 |           
   0
       2593 |          603 |           603 |           11 | s           |    2573 |        783 |           
   0
       2593 |          603 |           603 |           12 | s           |    2572 |        783 |           
   0
       2593 |          603 |           603 |           13 | s           |    2863 |        783 |           
   0
       2593 |          603 |           603 |           14 | s           |    2862 |        783 |           
   0
(14 rows)

例如 box_ops 共对应 14 个 strategy,查询 pg_operator 获得这 14 个策略对应的 operator 分别为

postgres=# select oid,oprname,oprleft,oprright,oprresult from pg_operator where oid >= 493 and oid <= 500 or oid >= 2570 and oid <= 2573 or oid = 2862 or oid = 2863;
 oid  | oprname | oprleft | oprright | oprresult 
------+---------+---------+----------+-----------
  493 | <<      |     603 |      603 |        16
  494 | &<      |     603 |      603 |        16
  495 | &>      |     603 |      603 |        16
  496 | >>      |     603 |      603 |        16
  497 | <@      |     603 |      603 |        16
  498 | @>      |     603 |      603 |        16
  499 | ~=      |     603 |      603 |        16
  500 | &&      |     603 |      603 |        16
 2570 | <<|     |     603 |      603 |        16
 2571 | &<|     |     603 |      603 |        16
 2572 | |&>     |     603 |      603 |        16
 2573 | |>>     |     603 |      603 |        16
 2862 | @       |     603 |      603 |        16
 2863 | ~       |     603 |      603 |        16
(14 rows)

对于 index entry 下的所有数据项 x,谓词 (x op query) 必须是 FALSE,则应该返回 false, 其中 op 是与 pg_amop 表中策略对应的 operator。参考上面 consistent 的定义。

gist_box_consistent
{
    ...
    // 获取 strategy number
    StrategyNumber strategy = (StrategyNumber)PG_GETARG_UINT16(2);
    ...
    if (GIST_LEAF(entry)) {
        // 叶子节点,调用 gist_box_leaf_consistent
        PG_RETURN_BOOL(gist_box_leaf_consistent(DatumGetBoxP(entry->key), query, strategy));
    } else {
        // 非叶子节点,调用 rtree_internal_consistent
        PG_RETURN_BOOL(rtree_internal_consistent(DatumGetBoxP(entry->key), query, strategy));
    }
}

看一个具体的例子,对于 box 类型,查询条件为 x << y (语义是 x 在 y 的左侧),其中 x 是表中的索引列, y 是一个具体的 box,例如 (Point(5,5), Point(7,6))

查看实现代码:

// 对于 internal 节点,返回的是 !box_overright,即 x 的左边界不在 y 的左边界的右侧
// 或者 与 y 的左边界相等,即 x 的 左边界 严格小于 y 的左边界
// 对于非叶子节点不能进行准确判断,排除一定不符合条件的情况,其他情况都可能出现满足条件的结果
static bool rtree_internal_consistent(BOX *key, BOX *query, StrategyNumber strategy)
{
    bool retval = false;

    switch (strategy) {
        case RTLeftStrategyNumber:
            retval = !DatumGetBool(DirectFunctionCall2(box_overright, PointerGetDatum(key), PointerGetDatum(query)));
            break;
    ...

    return retval;
}

// 对于 叶子节点,由于叶子节点精确地指向 box,可以进行精确判断
static bool gist_box_leaf_consistent(BOX *key, BOX *query, StrategyNumber strategy)
{
    bool retval = false;

    switch (strategy) {
        case RTLeftStrategyNumber:
            retval = DatumGetBool(DirectFunctionCall2(box_left, PointerGetDatum(key), PointerGetDatum(query)));
            break;
    ...
    return retval;
}

如下图所示,查找出现在 box D 左侧的 box,可以看出其中 box A 和 B 符合条件;
蓝色区域为 A B C 的父节点,父节点判断时只需要排除一定不符合条件的情况(父节点的左边界在 D 的右侧,右侧灰色矩形);而到叶子节点判断时,需要用 box 的右边界去和 D 的左边界比较。

image.png

整体的查询流程和其他 Tree 结构的索引比较类似,这里不细述了。

总结

本文主要介绍了 Gist 索引的原理和其中 插入 、查询的实现,其他相关内容在下一篇介绍。

参考文档:https://postgrespro.com/blog/pgsql/4175817

最后修改时间:2021-10-08 21:20:52
「喜欢这篇文章,您的关注和赞赏是给作者最好的鼓励」
关注作者
【版权声明】本文为墨天轮用户原创内容,转载时必须标注文章的来源(墨天轮),文章链接,文章作者等基本信息,否则作者和墨天轮有权追究责任。如果您发现墨天轮中有涉嫌抄袭或者侵权的内容,欢迎发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

文章被以下合辑收录

评论