文章目录
引言(Introduction)
索引是数据库快速查询满足要求的元组的结构,是每个数据库必备的结构。当然,使用索引虽然能提高查询速率,但也会带来内存以及维护上的开销,只不过这些开销对于索引带来的好处而言是可以忽略的。本章针对PG14中的索引及其源码进行学习。
概述
在数据库的使用中,常常需要查询某个字段为给定值的元组。由于PG在创建relation的时候并不会自动创建索引,这种查询常常需要遍历整个表格寻找所有匹配的元组。当表中含有的元组数据太多时,这种顺序遍历的方式不仅要扫描大量无关的元组,每次扫描都会带来大量的IO操作,搜索的效率也差强人意。而如果让数据库能够在该字段(或多个字段)维护一个索引用于快速定位目标元组,那么数据库就能够以很快的速度找到满足要求的元组,这会大大增加查询的效率。
PG14.0支持的索引结构有B-Tree,Hash,GIST,SP-GIST,GIN和BGIN。当前支持的四种索引方式为:
- 单一索引(Unique Indexes)。如果索引声明为唯一索引,那么就不允许出现多个索引值相同的元组。唯一索引可以迫使某个字段的值不重复(主键功能类似,但唯一索引允许NULL存在,NULL并不会被认为是相等的),也可以让联合字段组成的值唯一。在创建索引时添加UNIQUE关键字可以生成单一索引,目前只有B-tree索引支持单一索引的用法。
- 多字段索引(Multicolumn Indexes)。表格多个字段构成的组合键的索引为多字段索引。目前支持多字段索引的索引结构有B-tree,GIST,GIN以及BGIN,多字段索引的字段数量最多可以达到32个。
- 部分索引(Partial Indexes)。对表格的部分元组建立的索引是部分索引。引入部分索引最大的一个好处是可以避免对公共值进行索引,这样能够避免索引的浪费。这里需要避免的是不要使用部分索引代替group by或partition by的功能。
- 表达式索引(Indexes on Expressions)。当需要对表中多个字段在某个函数下的映射值构建索引时,可以构建表达式索引。但是这样构建的索引只能在查询时使用相同的表达式才能起作用。例如可以对字段的小写函数构建一个索引,那么该索引只有在查询上加入lower()才能起作用。
PG中所有的索引都是从属索引,即索引也是一种单独的数据结构,存在自己的物理数据。
但是要记住,数据库的索引是冗余的,删除索引并不会丢失数据,索引的作用仅仅是为了快速查询。
索引相关的系统表
索引也是一种表,因此也可以通过系统表进行管理。PG中与索引相关的系统表有:
- pg_am:PG支持的索引结构都存储在pg_am中;
- pg_class:每个创建的索引都会作为一个元组保存在pg_class中;
- pg_index:存储创建元组的部分信息;
- pg_opclass:索引结构可以运用在不同数据类型的字段上,因此索引结构并不直接管理目标字段的类型,这些信息由pg_opclass系统表维护。pg_opclass定义索引访问方法操作符类。 每个操作符类为一种特定数据类型和一种特定索引访问方法定义索引字段的语义。比如说在复数字段中使用B树索引,那么用户可以提供该索引的排序方式,并提供相应的运算符,例如’=‘,’>'。如果不提供则使用缺省的操作符类;
- pg_opfamily:管理不同索引下的不同数据库的操作符集合,比如说b树索引下的数组操作符集合的元组的opfmethod与opfname字段值为“403,array_ops”;而hash索引下的数组操作符集合的元组的对应字段值为“405,arr_ops”;
- pg_amop:存储具体操作符的关联信息。因为pg_opfamily只是提供了操作符集合,具体的操作符实现存储在这里;
- pg_amproc:存储具体操作符的过程函数信息,与pg_amop功能相似。
索引操作函数
在PG中,每种索引类型都在pg_am中注册了操作函数,不同索引支持的操作函数的数量并不相同,最多可以支持22个操作函数(PG14.0):
操作函数 | 描述 |
---|---|
ambuild | 构造新的索引 |
ambuildempty | 构造空索引 |
aminsert | 插入一个索引元组 |
ambulkdelete | 批量删除索引元组 |
amvacuumcleanup | 在VACUUM之后调用,主要完成一些额外的清理工作,比如说对索引的FSM文件进行清理 |
amcanreturn | 判断索引是否能够返回一个索引元组 |
amcostestimate | 估计索引查找花费的代价 |
amoptions | 解析索引的表达关系式 |
amproperty | 报告索引的字段属性 |
ambuildphasename | 返回amproperty所处阶段的名称? |
amvalidate | 验证该am的运算符类的定义 |
amadjustmember | 验证要添加的运算符操作函数 |
ambeginscan | 准备索引扫描 |
amrescan | 索引重新扫描 |
amgettuple | 返回一个索引元组 |
amgetbitmap | 返回查找位图,即所有合法的元组 |
amendscan | 终止索引扫描 |
ammarkpos | 标记当前扫描的位置 |
amrestrpos | 保存当前扫描的位置 |
amestimateparallelscan | 估计索引并行扫描的运算符大小 |
aminitparallelscan | 准备并行扫描 |
amparallelrescan | 重新开始并行扫描 |
为提供给上层模块一个使用索引的统一接口,以上操作函数都保存在结构体IndexAmRoutine中,该结构体又保存在索引扫描的描述信息IndexScanDesc中。上层模块的操作函数定义在文件"src/backend/access/index/indexam.c"与文件"src/backend/catalog/index.c"中。
B-Tree索引
页面结构
PG的B-Tree索引的实现是基于Lehman与Yao的高并发B树管理的算法实现的,他们在原有B-Tree的基础上,在每个非根结点中添加了指向兄弟的指针,还在每个索引页面中添加了一个"high key"的属性,该属性表示该页面关键字的上界。high key并不是一个真正的关键字,其只是起到快速扫描的作用,这种元组也被称为pivot tuple。图中linp0存储的是high key。
与CMU15-445相似,这里整个B-Tree由多个索引结点页面组成,每个页面表示单一结点。每个索引页面的数据存组织形式与表文件中元组的组织形式相同,也就是说一个页面(索引结点)最多只有4096个索引元组。为存放兄弟结点的指针,每个索引页面的末尾都会保存一个一个BTPageOpaqueData的数据结构:
typedef struct BTPageOpaqueData
{
BlockNumber btpo_prev; /* left sibling, or P_NONE if leftmost */
BlockNumber btpo_next; /* right sibling, or P_NONE if rightmost */
uint32 btpo_level; /* tree level --- zero for leaf pages */
uint16 btpo_flags; /* flag bits, see below */
BTCycleId btpo_cycleid; /* vacuum cycle ID of latest split */
} BTPageOpaqueData;
其中btpo_flags用于表示页面结点的类型:
/* Bits defined in btpo_flags */
#define BTP_LEAF (1 << 0) /* leaf page, i.e. not internal page */
#define BTP_ROOT (1 << 1) /* root page (has no parent) */
#define BTP_DELETED (1 << 2) /* page has been deleted from tree */
#define BTP_META (1 << 3) /* meta-page */
#define BTP_HALF_DEAD (1 << 4) /* empty, but still in tree */
#define BTP_SPLIT_END (1 << 5) /* rightmost page of split group */
#define BTP_HAS_GARBAGE (1 << 6) /* page has LP_DEAD tuples (deprecated) */
#define BTP_INCOMPLETE_SPLIT (1 << 7) /* right sibling's downlink is missing */
#define BTP_HAS_FULLXID (1 << 8) /* contains BTDeletedPageData */
PG的B-Tree存储数据的形式与B+Tree相似,都是在叶节点存储指向真实元组所在物理位置的指针,中间结点则只维护关键字的关系。
B-Tree索引的实现
索引创建
PG中B-Tree索引的创建流程以及涉及到的数据结构如下:
-
为每个需要索引的表元组都生成相应的索引元组。
索引元组是索引中的基本单位,由IndexTupleData表示:typedef struct IndexTupleData { ItemPointerData t_tid; /* reference TID to heap tuple */ unsigned short t_info; /* various info about tuple */ } IndexTupleData; typedef IndexTupleData *IndexTuple;
在将表元组封装成索引元组的过程中,会生成一个BTBuildState的对象,该对象存储了整个表生成的索引元组:
typedef struct BTBuildState { bool isunique; bool havedead; Relation heap; BTSpool *spool; BTSpool *spool2; double indtuples; BTLeader *btleader; } BTBuildState;
BTBuildState中的spool存储了生成的索引元组,spool2只有在唯一索引的情况下才会用来保存死亡元组。
-
对生成的索引元组在索引关键字上进行排序。
在生成索引元组之后,需要对索引元组进行排序,这样可以提高构造B-Tree的速度。 -
使用排序完成的索引元组生成B-Tree索引。
在正式构造B-Tree索引之前,还需要生成一个BTWriteState与一个元页信息BTMetaPageData的对象。其中BTWriteState用于记录整个索引创建过程中的信息:typedef struct BTWriteState { Relation heap; Relation index; BTScanInsert inskey; /* generic insertion scankey */ bool btws_use_wal; /* dump pages to WAL? */ BlockNumber btws_pages_alloced; /* # pages allocated */ BlockNumber btws_pages_written; /* # pages written out */ Page btws_zeropage; /* workspace for filling zeroes */ } BTWriteState;
而元页信息则是用于保存根节点以及有效结点的相关信息,主要包括当前树的版本号,根节点所在的页面块等
typedef struct BTMetaPageData { uint32 btm_magic; /* should contain BTREE_MAGIC */ uint32 btm_version; /* nbtree version (always <= BTREE_VERSION) */ BlockNumber btm_root; /* current root location */ uint32 btm_level; /* tree level of the root page */ BlockNumber btm_fastroot; /* current "fast" root location */ uint32 btm_fastlevel; /* tree level of the "fast" root page */ /* remaining fields only valid when btm_version >= BTREE_NOVAC_VERSION */ /* number of deleted, non-recyclable pages during last cleanup */ uint32 btm_last_cleanup_num_delpages; /* number of heap tuples during last cleanup (deprecated) */ float8 btm_last_cleanup_num_heap_tuples; bool btm_allequalimage; /* are all columns "equalimage"? */ } BTMetaPageData;
BTMetaPageData中的btm_fastroot表示快速根节点,用于快速访问叶节点。因为B-Tree索引结点可能会由于多次分裂与删除导致根节点与叶节点的距离相差较远,因此通过快速根结点可以用于快速定位结点位置。
另外,为保存B-Tree页面构造的状态信息,PG还会在每一层的树中都维护一个BTPageState结构:
typedef struct BTPageState { Page btps_page; /* workspace for page building */ BlockNumber btps_blkno; /* block # to write this page at */ IndexTuple btps_lowkey; /* page's strict lower bound pivot tuple */ OffsetNumber btps_lastoff; /* last item offset loaded */ Size btps_lastextra; /* last item's extra posting list space */ uint32 btps_level; /* tree level (0 = leaf) */ Size btps_full; /* "full" if less than this much free space */ struct BTPageState *btps_next; /* link to parent level, if any */ } BTPageState;
在创建索引的过程中,对于每一个层次的所有页面都只有一个BTPageState结构,当一个页面填充满了之后,会申请一个新的页面,这时BTPageState结构随即更新为新页面的信息。
B-Tree树的构造在函数btbuild中。以上流程对应的函数的调用过程如下:
btbuild
->_bt_spools_heapscan /* 扫描生成索引元组数组 */
->_bt_leafbuild /* 构建B+树叶节点 */
->tuplesort_performsort /* 对索引元组执行排序 */
->_bt_load
->_bt_buildadd /* 将排序成功的结点依次插入到B-Tree中 */
插入索引元组
若对表中的某个字段创建了索引,当有新的元组插入到表中时,索引也需要进行相应的更新,也就是将新插入的元组封装成索引元组并插入到索引中。B树的插入都需要先搜索到目标叶节点,再判断是否进行分裂,如果发生分裂则需要产生新的结点,并分配旧结点内的元素,同时还要判断是否触发递归分裂。B-Tree索引插入新元组的函数入口是btinsert,该函数的调用过程如下:
btinsert
->index_from_tuple /* 生成索引元组*/
->_bt_doinsert /* 执行插入 */
->_bt_mkscankey /* 根据属性计算关键字 */
->_bt_search_insert /* 根据关键字与插入模式找到目标叶节点(以栈的形式返回) */
->_bt_findinsertloc /* 搜索可以插入的位置 */
->_bt_check_third_page /* 判断页面中元素的数量是否超过1/3 */
->_bt_stepright /* 在并发访问的情况下,当前结点可能会分裂,因为PG中只允许向右分裂,所以需要将右边页面也加入到栈中 */
->_bt_insertonpg /* 将索引元组插入到目标页面 */
->_bt_split /* 分裂入口 */
->_bt_findsplitloc /* 搜索分裂的位置 */
->_bt_insert_parent /* 分裂时需要在父节点中插入信息,同时开始递归判断是否要继续分裂 */
在由根节点向下遍历时,需要存储遍历过的结点页面,用于递归分裂。PG的BTree中存储遍历的树节点的数据结构为BTStackData:
typedef struct BTStackData
{
BlockNumber bts_blkno;
OffsetNumber bts_offset;
struct BTStackData *bts_parent;
} BTStackData;
typedef BTStackData *BTStack;
当分裂结点时会生成FindSplitData结构,用来记录寻找结点分裂位置时的相关信息:
typedef struct
{
/* context data for _bt_recsplitloc */
Relation rel; /* index relation */
Page origpage; /* page undergoing split */
IndexTuple newitem; /* new item (cause of page split) */
Size newitemsz; /* size of newitem (includes line pointer) */
bool is_leaf; /* T if splitting a leaf page */
bool is_rightmost; /* T if splitting rightmost page on level */
OffsetNumber newitemoff; /* where the new item is to be inserted */
int leftspace; /* space available for items on left page */
int rightspace; /* space available for items on right page */
int olddataitemstotal; /* space taken by old items */
Size minfirstrightsz; /* smallest firstright size */
/* candidate split point data */
int maxsplits; /* maximum number of splits */
int nsplits; /* current number of splits */
SplitPoint *splits; /* all candidate split points for page */
int interval; /* current range of acceptable split points */
} FindSplitData;
其中成员变量splits记录了节点最好的分裂位置:
typedef struct
{
/* details of free space left by split */
int16 curdelta; /* current leftfree/rightfree delta */
int16 leftfree; /* space left on left page post-split */
int16 rightfree; /* space left on right page post-split */
/* split point identifying fields (returned by _bt_findsplitloc) */
OffsetNumber firstrightoff; /* first origpage item on rightpage */
bool newitemonleft; /* new item goes on left, or right? */
} SplitPoint;
另外还需要注意的是在多并发的插入场景中,需要使用到互斥锁(Crab lock in CMU15-445)。
扫描索引
利用索引进行查找时需要使用扫描函数。PG与B-Tree扫描相关的函数主要有以下几个:
-
btgettuple:得到扫描中下一个满足条件的索引元组。该函数在第一次调用时(执行第一次扫描时),需要初始化关键字数组,用于确定扫描顺序。如果是第一次扫描,那么调用_bt_frist()函数获取扫描顺序的第一个元组,否则调用_bt_next()函数获取下一个元组。该函数的调用栈如下:
btgettuple ->if not initialize the array before ->_bt_start_array_keys // 初始化查询数组 ->if first call for a scan ->_bt_first ->else ->_bt_next
-
btbeginscan:开始索引扫描。该函数根据需要扫描的索引的相关信息生成一个IndexScanDesc对象,IndexScanDesc结构中存储了扫描索引的相关信息,包括目标relation,键值的数量,当前扫描的位置等:
typedef struct IndexScanDescData { /* scan parameters */ Relation heapRelation; /* heap relation descriptor, or NULL */ Relation indexRelation; /* index relation descriptor */ struct SnapshotData *xs_snapshot; /* snapshot to see */ int numberOfKeys; /* number of index qualifier conditions */ ... } IndexScanDescData;
-
btrescan:重新开始索引扫描。
-
btendscan:与btbeginscan函数成对出现,释放一个索引扫描占用的系统资源。
-
btmarkpos:当一个正在进行的索引扫描由于某种原因需要停止时,需要保存当前扫描位置的相关信息。
-
btrestrpos:与btmarkpos相对应,将保存的扫描信息导入到当前扫描位置信息变量中。
删除索引元组
在PG中,删除B-Tree索引元组的函数主要有两个:
- btvacuumcleanup:寻找可以删除的页面;
- btbulkdelete:批量删除指向一个表元组集合所对应的所有索引元组。
出于效率考虑,PG中删除某个元组并不会立即删除对应的索引元组,而是会在VACUUM的时候进行删除。删除B-Tree索引元组也要像插入元组一样,要检查是否出现递归删除的现象。
Hash索引
PG实现了一种存储在硬盘上的哈希索引,这使得该索引支持故障恢复(crash recoverable)。任何数据结构的字段都支持哈希索引,因为哈希索引是根据字段的哈希值(4 bytes)进行存储的,因此对字段的长度并没有约束。但是哈希索引存在以下约束:
- 支持单一字段的索引,并且不支持唯一性检查(uniqueness checking)。
- 支持“=”操作,即仅支持单点查找,不支持范围查找。
从索引字段哈希值的分布而言,哈希索引能够完美地处理对称分布的数据,而对于非对称分布的数据而言,则有可能存在某一个桶页链接到多个溢出页的情况,这使得哈希索引的查找性能接近线性。
Hash索引的组织结构
PG的哈希索引含有四种类型的页面:元页(meta page, 序号为0),桶页(primary bucket pages),溢出页(overflow pages)与位图页(bitmap pages)。其中元页用于存储管理信息,桶页和溢出页都用于存储实际数据,而位图页则用于记录溢出页的可用状态,位图页属于一种特殊的溢出页。每个页面的存储结构与前面介绍的相似,都包含头部数据,中间数据与页尾的特殊空间。在哈希索引的上述4种页面尾部的特殊空间中,存储的都是HashPageOpaqueData结构:
typedef struct HashPageOpaqueData
{
BlockNumber hasho_prevblkno; /* 前一页(桶或溢出块)的块号 */
BlockNumber hasho_nextblkno; /* 后一页(桶或溢出块)的块号 */
Bucket hasho_bucket; /* 该页所属桶页的桶号 */
uint16 hasho_flag; /* 标识该页的类型 */
uint16 hasho_page_id; /* 哈希索引的标识 */
} HashPageOpaqueData;
下面将依次介绍哈希索引的四种页面:
元页
每个哈希索引都有一个元页,元页中记录了哈希索引的元信息,包括Hash的版本号、Hash索引记录的索引元组数量、桶的信息、位图等相关信息。通过元页可以了解该Hash索引在总体上的分配和使用信息,并且索引元组的插入,溢出页的分配与回收以及Hash表的扩展等过程中都需要使用元页。PG中元页的数据结构如下:
typedef struct HashMetaPageData
{
uint32 hashm_magic; /* magic no. for hash tables */
uint32 hashm_version; /* version ID */
double hashm_ntuples; /* number of tuples stored in the table */
uint16 hashm_ffactor; /* target fill factor (tuples/bucket) */
uint16 hashm_bsize; /* 索引页面中用于存储索引元组的大小:8KB-PageHeaderData-HashPageOpaqueData */
uint16 hashm_bmsize; /* bitmap array size (bytes) - must be a power
* of 2 */
uint16 hashm_bmshift; /* log2(bitmap array size in BITS) */
uint32 hashm_maxbucket; /* ID of maximum bucket in use */
uint32 hashm_highmask; /* 桶的高32位掩码 */
uint32 hashm_lowmask; /* 桶的低32位掩码 */
uint32 hashm_ovflpoint; /* splitpoint from which ovflpage being
* allocated */
uint32 hashm_firstfree; /* 可能空闲的最小溢出页号 */
uint32 hashm_nmaps; /* 位图页数量 */
RegProcedure hashm_procid; /* hash function id from pg_proc */
uint32 hashm_spares[HASH_MAX_SPLITPOINTS]; /* 记录在每个分裂点前整个Hash索引的溢出页数量 */
BlockNumber hashm_mapp[HASH_MAX_BITMAPS]; /* 记录所有位图页的块号 */
} HashMetaPageData;
扫描索引与插入索引元组都需要定位到目标的桶或溢出页,为准确定位该目标页面,需要在元页中获得桶的数量,高位掩码以及低位掩码。但每次进行扫描或插入都访问元页对于性能而言是不理想的,因为这会导致不停地对页面进行锁定。因此PG中在relcache中维护了一个元页的高速缓存,只要在上一次更新缓存到目前为止,目标桶页并没有发生分裂,那定位的目标页面就是准确的。
桶页
Hash表中桶是真正存储索引元组的数据结构,一个Hash表可以包含多个桶结构。每个桶由一个或多个页面组成,桶的第一页称为桶页,其余页被称为溢出页。桶页随着桶的建立而建立。当需要分配新的桶页时,桶页会在整个哈希表的随后区域直接分配,并且每次分配的桶页都是2的幂次方,且分配的桶页都是连续存储的。
新分配的桶页不会立即使用全部,而是将原桶页上的索引元组分割到旧桶页与一个新桶页之间。这个过程类似B-Tree的分割。
溢出页
当元组索引在它所属的桶中放不下的时候,需要为该桶添加一个溢出页。溢出页和桶页之间通过双向链表连接,该双向链表的数据结构存储在HashPageOpaqueData结构当中。与桶页一样,溢出页会直接配分到整个哈希表的随后区域。但是桶页与溢出页的分配是独立的,所以溢出页可以多于或少于桶页。
索引元组在同一个桶页或溢出页中可以按照哈希值大小的顺序进行排序,这可以通过二分查找快速定位到目标元组的地点。而在不同的桶页和溢出页之间则没有这种关系。
位图页
位图用于管理Hash索引中溢出页和位图页本身的使用情况。如果某个溢出页上的元组都被移除或删除,就要将该溢出页回收,但并不会把它还给操作系统,而是继续由该Hash索引进行管理,以便在下一次需要使用溢出页的时候继续使用。
由于在元页中记录了每个分裂点时溢出页的数量,并且每个分裂点分配的桶页都是之前的两倍(第一次除外),因此可以根据位图上某一个位的编号计算其对应的溢出页的块号。
Hash索引的各个页面都保存在一个连续的内存上,并且其块号表明了其创建的顺序。如下图所示。
Hash索引的实现
哈希索引的操作主要涉及以下几项任务:1. Hash表的创建、2. 索引元组的插入、3. 溢出页的分配与回收、4. Hash表的扩展。
索引创建
Hash索引的创建流程与B-Tree索引创建相似,其调用栈如下所示:
hashbuild
->_hash_init // 初始化哈希索引的元页与初始化桶
->table_index_build_scan // 扫描需要创建索引的表格,获取存活的元组数据
->_h_indexbuild // 将索引元组插入到Hash表中
->tuplesort_performsort // 排序元组
->_hash_hashkey2bucket // 计算元组的哈希值
->_hash_doinsert // 插入到Hash表中
插入元组
当在表格中插入元组时,需要同步更新其索引结构。Hash索引提供的插入元组的入口函数为hashinsert,其内部逻辑如下:
hashinsert
->index_form_tuple // construct a index tuple from tuple
->_hash_doinsert
->_hash_getbuf // 此次调用为获取Hash索引的元页
->_hash_getbucketbuf_from_hashkey // 锁定目标桶页
-->search for the page to insert the index tuple
// 此处在目标桶页及其溢出页处搜索具有空闲空间的页面,如果遇到死亡元组,则进行清理
->_hash_addovflpage // 如果到最后一页都没有找到空闲空间,那么分配新的溢出页
->LockBuffer // 此次调用为锁定元页
->_hash_pgaddtup // 插入元组到页面中
->if expanded // 检查是否需要分配新的桶页
->_hash_expandtable // 扩展哈希索引
溢出页的分配与回收
在元组插入时,可能会遇到目标桶所在的双向链表上的桶页与溢出页都不能装下新元组,此时需要分配一个新的页面到该目标桶中。此时会先分配已经存在的空闲溢出页,如果不存在这样的空闲溢出页,那么需要在磁盘上申请一块新的空闲溢出页。分配一块新溢出页的函数入口是_hash_addovflpage:
_hash_addovflpage
-->loop to find the current tail page
-->loop to find a free overflow page
-->if not found, then extend the relation to add an overflow page.
-->initialize new overflow page, and logically chain overflow page to previous page
而释放某个溢出页的入口函数是_hash_freeovflpage。因为位图页需要管理溢出页的状态,所以溢出页的创建与删除都需要访问到溢出页。
Hash表的扩展
在每次插入元组之后,都需要用当前元组数量的总数除于桶的数量,如果该值大于填充因子,那么需要对Hash表进行扩展。由此可以看出,Hash表的扩展与元组插入的目标桶并没有关系。
GiST索引
GiST(Generalized Search Tree, 通用搜索树)是一种平衡树状结构的访问方法,并且其是作为一个模板索引用于实现别的索引结构。比如B-Tree,R-Tree这些索引方法都可以通过GiST实现。GiST最大的一个优点是支持用户自定义的索引访问方法,在PG内部实现一个新的索引方法常常需要大量困难的工作,并且需要了解数据库的内部知识,比如说锁管理,预写日志。GiST则提供了索引的高层抽象,用户只需要提供索引对象的语义处理即可,GiST在内部已经实现了并发处理,日志以及搜索的功能。
使用基于GiST结构的索引支持一些额外的操作。比如原B-Tree索引仅支持范围查找操作"<, =, >“,Hash索引仅支持”="操作。而使用基于GiST结构的索引,则可以支持一些新建的操作。
比如说使用PG中原生的B-Tree对收集的图片进行索引,那么支持的查找命令包括“查找与图片x相等的图片y”,“查找比图片x小的图片y”,“查找比图片x大的图片y”。这些“等于,小于,大于”的操作取决于你如何定义这些操作的。然而,使用基于GiST的索引,则可以查找一些具有特殊含义的图片,比如“查找含有马的图片y”。
若使用自定义数据的GiST索引,那么必须要提供5种访问方法的实现与6种可选访问方法:
- same:判断两个索引元组是否相等;
- consistent:判断索引元组p是否与查询条件q匹配;
- union:合并索引条目的信息,给定一个索引元组的集合s,生成一个新的索引元组表示该集合的共性;
- penalty:计算某个索引条目插入到搜索树中某个部位的惩罚值,最终元组会插入到惩罚值最小的部位;
- picksplit:当索引页需要分裂时,判断哪些索引元组需要保留在当前页面,哪些需要迁移到目标页面;
- compress(optional):定义将索引元组存储到物理页面的格式,如果这项没有定义,那么该索引元组会直接存储到物理页中;
- decompress(optional):将物理格式的索引元组还原到原来的格式,与compress相对应;
- distance(optional):计算索引元组p与查询条件之间的距离,这在需要计算最近邻的场景中使用;
- fetch(optional):将索引元组还原到最先被索引的数据格式;
- option(optional):定义用户需要使用的参数;
- sortsupport(optional):一个索引元组排序需要使用的比较函数。在创建索引的过程中可能会使用到,如果没有定义,那么会使用penalty与picksplit进行插入,这样的插入效率会很低。
GiST索引的实现
PG中已经内置了GiST索引的几个方法,包括索引的创建,查找与删除,这里仅介绍其创建的流程。
索引创建
GiST索引创建的入口函数是gistbuild:
gistbuild
->initGISTstate // 初始化GIST的状态
->createTempGistContext // 创建临时上下文
->if (buildstate.buildMode == GIST_SORTED_BUILD) // 若支持排序建立索引
->tuplesort_performsort // 进行排序
->gist_indexsortbuild // 将排序好的索引元组插入到索引中
->gist_indexsortbuild_pagestate_add
->gistfillbuffer // 将排序好的索引添加的gist中
->tuplesort_end
->else // 不支持排序建立索引
->table_index_build_scan // 直接将扫描的元组插入到gist中
->gistBuildCallback
->if (buildstate->buildMode == GIST_BUFFERING_ACTIVE) // 支持使用缓冲区
->gistBufferingBuildInsert
->else
->gistdoinsert
GIN索引
GIN(Generalized Inverted Index, 通用倒排索引)用于对复合值进行索引的结构,并且通过GIN进行查询的操作需要查找含有复合值元素的元组。GIN索引存储的是键值对(key, posting list)的集合,这里posting list存储的是key出现的位置,比如说行数。GIN索引支持高度可扩展的全文搜索。
与GiST索引相似,GIN索引也支持自定义数据类型与访问方法。若使用GIN,则需要提供以下5种必选与两种可选的函数实现。
- extractValue:根据原始数据获取键值对的数组;
- extractQuery:返回满足要求的键值对数组;
- consistent:判断索引元组p是否满足查询条件q;
- triConsistent:与consistent功能相似,但是除了true与false之外,还支持GIN_MAYBE,maybe表示某个键的存在是不确定的;
- compare:比较两个原始数据的大小,用于排序索引元组;
- comparePartial(optional):比较索引元组与部分匹配的查询;
- options(optional):定义用户需要使用的参数。
GIN索引的组织结构
GIN索引结构内部维护了一个根据键值建立的B-Tree(“entry tree”),这里的键值就是原始数据中的一个元素。B-Tree中叶节点可以是一个指向含有Heap pointer的B-Tree(“posting tree”),或者是由heap pointer组成的列表(“posting list”),posting tree与posting list的区别是含有的heap pointers的数量不同。
GIN索引的实现
索引创建
GIN索引的创建过程需要从基表中依次取出基表元组,然后从基表元组构建出若干个Entry及其posting list,最后将这些Entry插入到GIN索引结构中。在这个过程中,不是生成一个Entry后就立即插入到GIN索引结构中,而是先将这些Entry都放入一个填充列表"pending list",等到该列表填充到一定程度的时候才将其中的Entry插入到GIN索引结构中。
GIN索引在创建的过程中使用GinBuildState结构管理整个创建流程:
typedef struct
{
GinState ginstate; // 存储创建过程中使用到的相关函数
double indtuples;
GinStatsData buildStats;
MemoryContext tmpCtx;
MemoryContext funcCtx;
BuildAccumulator accum; // 指向创建过程中用到的pending list
} GinBuildState;
其中accum(BuildAccumulator)中存储了管理pending list的参数:
typedef struct
{
GinState *ginstate;
Size allocatedMemory;
GinEntryAccumulator *entryallocator;
uint32 eas_used;
RBTree *tree;
RBTreeIterator tree_walk;
} BuildAccumulator;
typedef struct GinEntryAccumulator
{
RBTNode rbtnode;
Datum key;
GinNullCategory category;
OffsetNumber attnum;
bool shouldSort;
ItemPointerData *list;
uint32 maxcount; /* allocated size of list[] */
uint32 count; /* current number of list[] entries */
} GinEntryAccumulator;
GIN索引创建的入口函数是ginbuild:
ginbuild
->initGinState // 初始化Gin状态
->ginInitBA
->table_index_build_scan // 扫描基本表,并插入到pending list中
->ginBuildCallback
->ginHeapTupleBulkInsert
->ginInsertBAEntries
->ginInsertBAEntry
->rbt_insert
-->while ginGetBAEntry // 获取下一个posting list
->ginEntryInsert // 插入到gin索引结构当中
索引查询
GIN索引的查询通过关键字与Entry的匹配(consistent)操作来查找其所在的元组。GIN索引并没有提供返回单个元组的函数,但是提供了位图查询的方式,即对GIN索引的查询只能得到一个位图,该位图包含了复合查询条件的元组的物理位置。
总结
索引是提高数据库查询效率的常用方法,但是维护索引也会带来相应的开销,比如索引创建,保存与更新。但是大部分场景下,使用索引的利还是要远远大于弊。
参考资料(References)
Using Postgres CREATE INDEX: Understanding operator classes, index types & more