openGauss

开源数据库

openGauss社区官网

开源社区

openGauss gist 索引

吴松2021-10-26openGauss gist 索引

openGauss gist 索引

概述

自 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

  • 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 适用于一个特定的可索引的字段数据类型。

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

    可以看到 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 中。

    以上是 Gist 索引中插入流程的实现,可以看到 penalty 接口在插入流程用于选择插入的 page 。

    对于 Gist 索引而言,其叶子节点每一条数据包含一个谓词(bool 类型的表达式)和一个指向基表的 TID,索引的 key 必须满足这个谓词。非叶子节点也包含一个谓词和指向子节点的指针,非叶子节点的所有子节点都必须满足这个谓词。

    以在二维空间插入一个 Point 为例,父节点的谓词可能是“所有子节点包含的矩形和点都在某个大的矩形内”,如果新插入的点完全落在父节点所表示的矩形内,则父节点不需要更新;相反,如果不在父节点所在的矩形内,则需要更新父节点所表示的矩形空间;更新完成后,继续向下完成插入动作。如下图所示,左边是插入需要更新父节点的矩形范围的情况(节点 B 表示父节点);右边是插入不需要更新父节点矩形范围的情况。如果父节点范围更新完后,最终插入失败,不影响 Gist 索引的使用。

  • 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 的左边界比较。

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

总结

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

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