深入浅出Greenplum Bitmap Index(下篇)
续:深入浅出Greenplum Bitmap Index(上篇)
3.1
Index Scan
首先来看下 Index Scan 的执行流程以及大致的函数调用栈,如下图所示:
整体可以分为 4 大部分:
- 最外层自然是 AM,即 Access Method 部分,是 PostgreSQL 对数据访问方式的一种抽象,我们可以简单地理解为一种多态。Bitmap Index 使用 bmgettuple() 每次向上层调用返回一个 tuple,在第一次调用时将调用 _bitmap_first() 完成初始化工作,而后不断调用 _bitmap_next() 返回结果;
- 第二部分则是为索引扫描进行的初始化工作,例如打开 B-Tree 索引和 LOV Heap Table,根据扫描 Key 找到所有的 LOV Item,以及为在扫描过程中需要的结构体分配内存空间等。在所有事情准备完毕后,即可开始调用 _bitmap_next 开始扫描 Bitmap Words 了。
- 当我们开始扫描 Bitmap Words 时,将对应的 Page 读取到 Buffer Pool 中,并将对应的 Header Section 和 Content Section 赋值给结构体 BMBatchWords。同时,如果扫描 Key 对应了多条 Bitmap List 的话,还需要将所有的 bitmap 进行 OR 操作,即 _bitmap_union。
- 最后,由 BMIterateResult 对 BMBatchWords 中的压缩字和非压缩字进行读取,将结果保存在 BMIterateResult->nextTids 数组中,提供给上层调用下一条 tuple。我们可以将 BMBatchWords 看做是一个巨大的,带有压缩的沙场,BMIterateResult 则是小型运沙卡车,每次从沙场铲一铲子被压缩过的沙子,并进行解压缩放在车斗中,然后再运输到其它地方。
接下来就是非常细致的代码流程了,不感兴趣的读者可以直接跳过,由于 Bitmap Index 引入了 heap table 和与之对应的 B-Tree 索引,同时还对 bitmap 进行了压缩,这使得读取 TID 的过程异常复杂。
首先来看 bmgettuple(),这是 Bitmap Index 对外提供的 Access Mothod,该方法需要返回是否还存在下一个 tuple,并将扫描到的 tuple tid 放到 scan->xs_heaptid 中:
bool bmgettuple(IndexScanDesc scan, ScanDirection dir) {
BMScanOpaque so = (BMScanOpaque) scan->opaque;
bool res;
/* This implementation of a bitmap index is never lossy */
scan->xs_recheck = false;
if (so->bm_currPos && so->cur_pos_valid)
res = _bitmap_next(scan, dir);
else
res = _bitmap_first(scan, dir);
return res;
}
当我们第一次调用 bmgettuple 时,BMScanOpaque 并没有进行初始化,因此会调用 _bitmap_first 进行初始化:
bool _bitmap_first(IndexScanDesc scan, ScanDirection dir) {
BMScanOpaque so;
BMScanPosition scanpos;
/* 使用 _bitmap_findbitmaps 找到所有满足查询 Key 的 Bitmap Vector */
_bitmap_findbitmaps(scan, dir);
so = (BMScanOpaque) scan->opaque;
scanpos = (BMScanPosition) so->bm_currPos;
if (scanpos->done)
return false;
return _bitmap_next(scan, dir);
}
而对于 _bitmap_findbitmaps 函数来说,其主要工作就是根据 Scan Key 从 B-Tree 索引中找出所有符合条件的 tuple,然后根据对应的 Block Number 和 Offset 取出所有的 LOV 项,并构建 BMVector 数组:
void _bitmap_findbitmaps(IndexScanDesc scan, ScanDirection dir) {
BMScanOpaque so;
BMScanPosition scanPos;
Buffer metabuf;
BMMetaPage metapage;
BlockNumber lovBlock;
OffsetNumber lovOffset;
bool blockNull, offsetNull;
int vectorNo, keyNo;
so = (BMScanOpaque) scan->opaque;
if (so->bm_currPos == NULL)
so->bm_currPos = (BMScanPosition) palloc0(sizeof(BMScanPositionData));
scanPos = so->bm_currPos;
/* 初始化扫描结果的 nextTid 为 1 */
scanPos->bm_result.nextTid = 1;
/* 获取 Bitmap Index 的 Meta Page */
metabuf = _bitmap_getbuf(scan->indexRelation, BM_METAPAGE, BM_READ);
metapage = _bitmap_get_metapage_data(scan->indexRelation, metabuf);
Relation lovHeap, lovIndex;
ScanKey scanKeys;
IndexScanDesc scanDesc;
List* lovItemPoss = NIL;
ListCell *cell;
/* 打开 B-Tree Index 以及 LOV heap table */
_bitmap_open_lov_heapandindex(scan->indexRelation, metapage, &lovHeap, &lovIndex, AccessShareLock);
scanKeys = palloc0(scan->numberOfKeys * sizeof(ScanKeyData));
/* 开始扫描 B-Tree 索引 */
scanDesc = index_beginscan(lovHeap, lovIndex, GetActiveSnapshot(), scan->numberOfKeys, 0);
index_rescan(scanDesc, scanKeys, scan->numberOfKeys, NULL, 0);
/* 找到所有符合过滤条件的 LOV 项 */
while (true) {
ItemPos *itemPos;
/* 通过 lovHeap 和 lovIndex 获取到 LOV 的 BlockNumber 和 Offset,并保存到
* lovBlock 和 lovOffset 中 */
bool res = _bitmap_findvalue(lovHeap, lovIndex, scanKeys, scanDesc,
&lovBlock, &blockNull, &lovOffset,
&offsetNull);
if (!res) return;
itemPos = (ItemPos*)palloc0(sizeof(ItemPos));
itemPos->blockNo = lovBlock;
itemPos->offset = lovOffset;
lovItemPoss = lappend(lovItemPoss, itemPos);
scanPos->nvec++;
}
if (scanPos->nvec)
scanPos->posvecs = (BMVector)palloc0(sizeof(BMVectorData) * scanPos->nvec);
vectorNo = 0;
/* 逐一遍历所有的 lovItemPoss 项,取出每一个 LOV Item */
foreach(cell, lovItemPoss) {
ItemPos *itemPos = (ItemPos*)lfirst(cell);
BMVector bmScanPos = &(scanPos->posvecs[vectorNo]);
init_scanpos(scan, bmScanPos, itemPos->blockNo, itemPos->offset);
vectorNo++;
}
list_free_deep(lovItemPoss);
index_endscan(scanDesc);
_bitmap_close_lov_heapandindex(lovHeap, lovIndex, AccessShareLock);
pfree(scanKeys);
_bitmap_relbuf(metabuf);
/*
* 当只有一个 bitmap vector 时,scanPos->bm_batchWords 和 posvecs->bm_batchWords 数组头指针相等;
* 当存在多个 bitmap vector 时,为 scanPos->bm_batchWords 分配内存空间并初始化,这里的 bm_batchWords
* 就是前面我们所提到的沙场。
*/
if (scanPos->nvec == 1)
scanPos->bm_batchWords = scanPos->posvecs->bm_batchWords;
else {
scanPos->bm_batchWords = (BMBatchWords *) palloc0(sizeof(BMBatchWords));
_bitmap_init_batchwords(scanPos->bm_batchWords, BM_NUM_OF_HRL_WORDS_PER_PAGE, CurrentMemoryContext);
}
}
_bitmap_findbitmaps 函数比较复杂,首先是打开 B-Tree Index 和 heap table,然后根据 Scan Key 从 B-Tree 索引中找出所有符合条件的 tuple,然后根据对应的 Block Number 和 Offset 从 heap table 取出所有的 LOV 项,并构建 BMVector 数组:
typedef struct BMVectorData {
Buffer bm_lovBuffer; /* BufferPool 中的 Buffer No */
OffsetNumber bm_lovOffset; /* Buffer Page 的偏移量 */
/* 记录下一个要扫描的 Bitmap Page Block Number */
BlockNumber bm_nextBlockNo; /* the next bitmap page block */
bool bm_readLastWords;
BMBatchWords *bm_batchWords; /* 该字段将记录从硬盘中扫描上来的 Bitmap Words Page */
} BMVectorData;
typedef BMVectorData *BMVector;
首先来看为什么我们需要一个 BMVector 数组,当我们使用 create index on t using bitmap(b); 创建索引时,不是根据列 b 的唯一值来创建 LOV 项的吗? 例如当我们向表 t 中插入一些数据之后:
create table t (a int, b int, c int) distributed by (a);
create index idx1 on t using bitmap(b);
insert into t values (1, 1, 1);
insert into t values (1, 2, 1);
insert into t values (1, 3, 4);
此时列 b 只有 “1, 2, 3” 这 3 个唯一值,那么当我们查询 select * from t where b = 2; 时,只会从 B-Tree 中找到一个结果,也就是说 LOV 项只有 1 个,那么为什么我们还需要一个数组? 原因就在于当 bitmap index 中包含多列时,查询单列时就会返回不止一个结果:
create table t (a int, b int, c int) distributed by (a);
create index idx1 on t using bitmap(b, c);
insert into t values (1, 1, 1);
insert into t values (1, 2, 3);
insert into t values (1, 2, 4);
insert into t values (1, 3, 5);
此时,我们再使用 select * from t where b = 2; 进行查询时,B-Tree 就会返回 2 个结果,分别为 “(2, 3)” 和 “(2, 4)”,而这对应了两个 LOV 项,也就是两个 Bitmap Vector,所以我们才需要使用一个数组来保存 LOV 数据以及对应的 Bitmap Batch Words 数据,如下图所示:
这种情况只会出现在多列的 Bitmap 索引上,对于单列索引来说,永远只会扫描一条 Bitmap Vector。
回归正题,当初始化准备工作做完之后,就可以开始正式的 Bitmap Words 扫描了,即调用 _bitmap_next:
bool _bitmap_next(IndexScanDesc scan, ScanDirection dir) {
BMScanOpaque so;
BMScanPosition scanPos;
uint64 nextTid;
so = (BMScanOpaque) scan->opaque;
scanPos = so->bm_currPos;
/* 若扫描已经结束,则直接返回 false,表示没有下一条 tuple 了 */
if (scanPos->done) return false;
for (;;) {
/* 当 Batch Words 中没有多余的 Word 可读时,就需要获取新的 Batch Words */
if (scanPos->bm_batchWords->nwords == 0 &&
scanPos->bm_result.nextTidLoc >= scanPos->bm_result.numOfTids) {
_bitmap_reset_batchwords(scanPos->bm_batchWords);
scanPos->bm_batchWords->firstTid = scanPos->bm_result.nextTid;
/* 获取下一批数据,不一定需要读取新的 bitmap page (在有多个 bitmap vector 的情况下) */
next_batch_words(scan);
_bitmap_begin_iterate(scanPos->bm_batchWords, &(scanPos->bm_result));
}
/* 同上 */
if (scanPos->done) return false;
/* 获取下一个 TID */
nextTid = _bitmap_findnexttid(scanPos->bm_batchWords, &(scanPos->bm_result));
if (nextTid == 0) continue;
else break;
}
/* 将结果写入至 scan->xs_heaptid 供上层调用使用 */
ItemPointerSet(&(scan->xs_heaptid), BM_INT_GET_BLOCKNO(nextTid), BM_INT_GET_OFFSET(nextTid));
so->cur_pos_valid = true;
return true;
}
现在,我们就需要揭开 BMBatchWords 和 BMIterateResult 这两个在 Bitmap Index 扫描中最为重要的两个数据结构了。对于一般的索引扫描来说,我们只需要记录下当前扫描到的位置即可,下一次扫描时从记录的位置开始继续扫描即可。但是对于 Bitmap Index 来说,情况要复杂许多,原因就在于 HRL 压缩编码。
首先来看下 BMBatchWords 和 BMIterateResult 这两个结构之间的大体关系:
如上图所示,在 BMBatchWords 中,最为重要的数据就是经过 HRL 压缩编码之后的 Bitmap Word,这些 bitmap 将会由 Iterate Reuslts 进行解压缩,而后将 TID 保存在 nextTID 数组中,这个数组的长度固定,默认为 16 * 1024 = 16384。而对于 Batch Words 来说,默认可存储 3968 个字,由于一个字的长度为 64 bit,那么所有的 Word 都是未压缩的情况下,可存储 3968 * 64 = 253952 个 TID,当所有的字都是压缩字的话,一个 Batch Word 就可以保存 3968 * (2 ^ 63) 个 TID,而这将是一个天文数字。因此,在最好的情况下,Iterate Restult 至少需要扫描同一个 Batch Word 两次,才能取得所有的 TID。
/* BMIterateResult 用于保存扫描 BMBatchWords 的结果,BMBatchWords 其实就是 Bitmap Pages 的内存存储形式 */
typedef struct BMIterateResult {
/* result->nextTid 中保存了下一个需要从 Batch Words 中取出的 TID */
uint64 nextTid;
/*
* lastScanWordNo 表示在当前扫描中,需要读取 Batch Words 中的第几个字,从 0 开始;
* lastScanPos 则表示需要读取 cwords[lastScanWordNo] 的第几个 bit 位,从 0 开始。
*/
uint32 lastScanWordNo;
uint32 lastScanPos;
/* TID 结果数组,用于向上层调用返回 TID,最多可保存 16*1024 个结果 TID */
uint64 nextTids[BM_BATCH_TIDS];
/* 当前已经保存的 TID 数量,其值必然小于 16384 */
uint32 numOfTids;
/* 调用方需要读取的 TID 在 nextTids 数组中的位置,从 0 开始 */
uint32 nextTidLoc; /* the next position in 'nextTids' to be read. */
uint64 markedTid;
} BMIterateResult;
由于 nextTids 只能装下 16384 个 TID,因此我们需要记录下在当前扫描中 Result 在当前扫描中扫到了 Batch Words 的第几个 Word 第几个 Bit,下一次重新扫描同一个 Batch Words 时就需要从此处重新开始。
typedef struct BMBatchWords {
uint32 maxNumOfWords; /* 当前 Batch Words 所能保存的最大 Word 数量 */
/* nwordsread 和 nextread 用于 _bitmap_union 函数中 */
uint64 nwordsread;
uint64 nextread;
/* 下面 3 个参数与扫描相关 */
uint64 firstTid;
uint32 startNo;
uint32 nwords;
/* BMBatchWords 中存储的 HRL Bitmap 数据,hwords 为 header words,cwords 为 content words */
BM_HRL_WORD *hwords;
BM_HRL_WORD *cwords;
} BMBatchWords;
当 Batch Word 中不存在剩余的字或者是 Result 已经装满时,就可以停止装填数据的过程:
uint64 _bitmap_findnexttid(BMBatchWords *words, BMIterateResult *result) {
/* 当 result 中没有数据可读时,从 Batch Words 中重新装填数据 */
if (result->nextTidLoc >= result->numOfTids)
_bitmap_findnexttids(words, result, BM_BATCH_TIDS);
/* 当 result 中还存在数据时,继续从 result->nextTids 数组中取数据 */
if (result->nextTidLoc < result->numOfTids) {
result->nextTidLoc++;
return (result->nextTids[result->nextTidLoc-1]);
}
/* no more tids */
return 0;
}
所以,_bitmap_findnexttids 其实就是从 Batch Words 中读取数据到 Iterate Result 中,其中最为重要的步骤就是将压缩过的字进行解压缩:
void _bitmap_findnexttids(BMBatchWords *words, BMIterateResult *result, uint32 maxTids) {
bool done = false;
/*
* 初始化扫描结果,开始一次新的扫描。
* 此时 result->nextTid 中已经没有数据,因此调用方下一次获取数据时,应当从数组的起始位置开始。
*/
result->nextTidLoc = result->numOfTids = 0;
/* 当 result->lastScanWordNo 和 words->startNo 的值相等时,表示我们读取了一个新的 Batch Words,
* 此时我们需要确定该 Words 的 firstTid 和 result->nextTid 是否相同。如果不相同,说明存在并发
* 插入,并且当前 Batch Words 中存在上一页的数据,我们需要调整 lastScanWordNo。
*/
if (result->lastScanWordNo == words->startNo && words->firstTid < result->nextTid)
_bitmap_catchup_to_next_tid(words, result);
/* 当 words 中还有剩余的字可读,同时扫描的 TID 数量未达到 maxTids 时,不断扫描 Batch Words */
while (words->nwords > 0 && result->numOfTids < maxTids && !done) {
uint8 oldScanPos = result->lastScanPos;
/* 获取当前要读取的字 */
BM_HRL_WORD word = words->cwords[result->lastScanWordNo];
/*
* if we begin to read a new word, and the word is compressed with zero or the word itself is zero,
* we can skip over one or more words directly.
* 如果当前读取的位置是一个新的 word 的起始位置,并且该 word 为 0,那么我们就可以直接跳过这部分数据
*/
if (oldScanPos == 0 && ((IS_FILL_WORD(words->hwords, result->lastScanWordNo) &&
GET_FILL_BIT(word) == 0) || word == 0)) {
BM_HRL_WORD fillLength;
if (word == 0) /* 非压缩字的全 0,只需要跳过一个 word */
fillLength = 1;
else
fillLength = FILL_LENGTH(word);
/* skip over non-matches */
result->nextTid += fillLength * BM_HRL_WORD_SIZE;
result->lastScanWordNo++;
words->nwords--;
result->lastScanPos = 0;
continue;
}
/* 压缩字,并且是对 1 进行的压缩 */
else if (IS_FILL_WORD(words->hwords, result->lastScanWordNo) && GET_FILL_BIT(word) == 1) {
uint64 nfillwords = FILL_LENGTH(word);
uint8 bitNo;
while (result->numOfTids + BM_HRL_WORD_SIZE <= maxTids && nfillwords > 0) {
/* 解压缩,result->nextTid 中保存了下一个需要访问的 TID,因为一个字中的 TID
* 肯定是连续的,所以可以直接使用 result->nextTid++ 的方式更新结果 */
for (bitNo = 0; bitNo < BM_HRL_WORD_SIZE; bitNo++)
result->nextTids[result->numOfTids++] = result->nextTid++;
nfillwords--;
/* 更新 lastScanWordNo 对应 word 的压缩字数量,可能这一次的 Scan 不足以扫描完
* 所有的压缩字,因此需要扣除掉当前压缩字已经扫描的数量 */
words->cwords[result->lastScanWordNo]--;
}
/* 完整的获取了所有(nfillwords)的压缩字的 TID */
if (nfillwords == 0) {
/* 压缩字的所有 TID 都被保存在了 result->nextTids 中,更新下一个要访问的字 */
result->lastScanWordNo++;
words->nwords--;
result->lastScanPos = 0;
continue;
}
else {
/* result 空间不够了,直接返回,等待下一个的扫描 */
done = true;
break;
}
}
else {
/* 非压缩字的情况 */
if(oldScanPos == 0) oldScanPos = BM_HRL_WORD_SIZE + 1;
while (oldScanPos != 0 && result->numOfTids < maxTids) {
BM_HRL_WORD w;
if (oldScanPos == BM_HRL_WORD_SIZE + 1)
oldScanPos = 0;
w = words->cwords[result->lastScanWordNo];
result->lastScanPos = _bitmap_find_bitset(w, oldScanPos);
/* did we find a bit set in this word? */
if (result->lastScanPos != 0) {
uint64 tid = result->nextTid + result->lastScanPos -1;
result->nextTids[result->numOfTids++] = tid;
}
else {
result->nextTid += BM_HRL_WORD_SIZE;
/* start scanning a new word */
words->nwords--;
result->lastScanWordNo++;
result->lastScanPos = 0;
}
oldScanPos = result->lastScanPos;
}
}
}
}
到这里,扫描 Bitmap Index 的函数调用栈就到头了,数据已经被保存在了 result->nextTids 数组中,上层函数只需要不断从数组中依次取出其中的数据即可。当 result 被读完就从 Batch Words 中解压缩新的 TID,如果 Batch Words 也读完了,那么就获取下一页的 Bitmap Words,然后继续上述流程,直到将全部 Bitmap 数据读取完毕。
3.2
Bitmap Index Scan
接下来我们来看 Bitmap Index Scan 的过程,它和 Index Scan 最大的不同就是 Bitmap Index Scan 返回的是 Bitmap Stream,而 Index Scan 返回的是 TID。Bitmap Index Scan 通常会和 Bitmap Heap Scan 成对出现。
Bitmap Index Scan 节点会在第一次被执行时就将获取所有满足条件的元组并在位图中标记它们,而其上层节点中都会有特殊的扫描节点(如 Bitmap Heap Scan) 使用该位图来获取实际的元组。
其入口为 pull_stream,首先获取全部的 Bitmap Pages,然后对其进行扫描,过程和 Index Scan 基本类似。
4
Bitmap Index 删除过程
和 B-Tree 索引类似,Bitmap 索引的删除操作并不由 delete 语句触发,而是放在了 vacuum 的过程当中。在实现上,采用了重建 Bitmap 索引的方式来实现,由于存在 HRL 编码,如果在原有索引上进行删除,可能会引发连锁反应。因此,重建索引是最佳选择,同时也简化了并发控制和实现逻辑。
5
Greenplum Bitmap Index 曾经出现过的问题
并发读写导致 Iterate Result 读取到上一页的数据
首先,当我们向 Bitmap Index 中写入一个数据时,在底层的索引文件中只会更新对应 TID 的 bit 位,而由于 HRL 压缩编码的存在,当我们更新时可能会导致原有的字扩展成两个或者三个字,以下面的数据为例:
当我们存在一个压缩字,并且其 Content 为 00001011,表示 11 个全 0 的 Word,并且假设该 Word 的起始 TID 为 1。那么假设我们现在需要更新 TID = 51 的 bit 位为 1,那么就需要将压缩字展开,然后找到对应的 bit 位进行更新,然后再将更新后的 Words 使用 HRL 编码再次进行压缩。这里我们假设一个 Word 是 8 位的,便于演示:
# 对 00001011 压缩字进行解压缩,得到 11 个全 0
00000000 00000000 00000000 00000000 00000000 00000000 00000000 ...... 00000000
# 找到 TID = 51 的 bit 位进行更新
00000000 00000000 00000000 00000000 00000000 00000000 00100000 ...... 00000000
# 对结果重新进行 HRL 编码的压缩
00000110(c) 00100000 00000100(c)
可以看到,当我们对压缩字 00001011(c) 进行更新时,结果变成了 00000110(c) 00100000 00000100(c) (“c” 表示 compressed,表示该 word 为压缩字),新增了两个新的 Words。那么正因为这个特性,当我们对某一个 Bitmap Words Page 进行更新时,可能导致新增 Words,而如果此时该页已经没有多余的空间存放新增的字时,就需要把这部分新增的字存放在下一个页面中,那么下一页的数据就需要保持有序的向后移动。
此时,如果有并发读写的话,就会出现问题,如下图所示:
- 在 T1 时刻,TRX-1 正在读取 FULL PAGE,即将文件中的数据页读取到 Batch Words 中,然后由 Iterate Result 进行数据的搬运;
- 在 T2 时刻,TRX-1 已经读取完 FULL_PAGE 并释放了该页的排它锁。此时 TRX-2 更新 FULL PAGE 中的某一位,导致新增了字,这些字被顺序写入到 NEXT PAGE 的起始位置中,如上图中 NEXT PAGE 的橘色部分。
- 在 T3 时刻,TRX-1 对 NEXT PAGE 进行读取,而此时 NEXT PAGE 的起始部分其实已经被上一次的扫描读取过了(这部分数据实际上是 FULL PAGE 的末尾部分),因此如果还是从 NEXT PAGE 的起始位置开始读取的话,就会导致同一份数据被读取两次,导致数据错乱。
这个问题的具体复现过程可见: Data corruption during a bitmap scan(https://github.com/greenplum-db/gpdb/issues/11308),修复可见 #11377 (https://github.com/greenplum-db/gpdb/pull/11377)。
对这个问题的修复方式也比较简单,result->nextTid 记录了我们下一次需要从 Batch Words 中取出的 TID,而 words->firstTid 则记录了 Batch Words 的第一个 bit 代表的 TID。当我们读取新的 Batch Words 时,如果 result->nextTid 和 words->firstTid 的值不相等的话,就说明存在并发读写,并且上一页的数据扩展到了当前读取的页,那么就需要把这部分数据跳过。
#12709(https://github.com/greenplum-db/gpdb/pull/12709)和 #13479 (https://github.com/greenplum-db/gpdb/pull/13479)这个 PR 则是修复 #11377 (https://github.com/greenplum-db/gpdb/pull/11377)所带来的额外问题,感兴趣的小伙伴可以进行拓展阅读。
文章转载自公众号:Greenplum中文社区