lazy Vacuum
死元组和表空间膨胀
在 PG 中,update/delete 语句的实现通过 MVCC 机制的多版本链实现。如下图所示,更新一条元组时,会将原来的元组标记,并新增一条元组。后续的事物通过快照来判断元组的可见性。
对于一条已经被更新/删除的元组来说,当这条元组对所有事物都不可见后,它的存在就没有意义了,理应被删除,对于这种元组,我们称之为“死元组”。当一张表有大量更新/删除时,如果不做清理的话,表里面就会积攒很多这样的“死元组”,占用大量的空间,造成表空间膨胀。
事务号回卷
PG 的事物号采用 32 位无符号整数 xid 来表示,约能表示 40 亿数字。借用下图中的图 b 来说,对于任意一个 xid ,它在环中的前 20 亿对它来说是过去,后 20 亿对它来说是未来。
以上图为例,对于 xid 为 100 的事物来说,本来 99 是它的过去,当事物号耗尽的时候,99 会回卷重新使用。这时候就无法判断一条 xmin 或 xmax 为 99 的元组到底是过去还是未来。
Vacuum功能概述
在提出上两个问题之后,就可以简单的介绍一下 vacuum 的功能:
-
清理死元组:删除死元组和其索引,回收表空间;
-
冻结元组的事物号:如果一个元组的事物号已经很老了,对于当前所有的事物都可见,那么就直接将它的事物号改为 FrozenTransactionId ;(注:FrozenTransactionId 对于所有事物号都属于过去)
-
更新表的统计信息,方便优化器工作。
Vacuum总体结构
PG 8.4.1版本后增加了VM即可见性映射表文件,为每一个数据块设置了一个标志位,用来标记数据块中是否存在需要清理的行vacuum只需要扫描vm文件即可知道数据块是否有需要清理的行,提高了vacuum效率清理方式:
Lazy vacuum:可以使用vm文件 full VACUUM:需要扫描整个数据文件
在 lazy_vacuum_rel 函数中,主要完成以下几件事:
-
调用 vacuum_set_xid_limits 设置一些阈值,用于后续判断是否采取迫切清理的策略(aggressive);
-
调用 lazy_scan_heap 做 vacuum 操作:删除元组和索引,清理表空间;
-
调用 lazy_truncate_heap 做截断操作,将表最后的空白页面截断;
-
调用 FreeSpaceMapVacuum 对 fsm 做 vacuum;
-
更新系统表中的统计信息
lazy_scan_heap
lazy_scan_heap 是做 vacuum 的主要函数。在介绍该函数之前,有几个术语/变量先要讲解一下含义:
-
死元组列表:vacuum 会维护一个数组,将当前 page 的所有死元组的头指针存在这个数组里;
-
relfrozenxid:标记一张表中所有 <relfrozenxid 的元组均被冻结;
-
迫切模式:这里直接引用 [2] 中的原文。当 relfrozenxid 满足特定条件时,将触发迫切模式;
冻结的过程有两种模式,依特定条件而择其一执行。一种为惰性模式另一种为迫切模式。惰性模式下,冻结过程仅适用目标表对应的VM扫描包含死元组的页面。迫切模式会扫描所有的页面,无论其是否包含死元组,都会更新与冻结过程相关的系统视图,并在可能的情况下删除不必要的 CLOG 文件。
-
过期元组:对所有事物均不可见的元组
该函数主要有以下几个步骤:
-
初始化若干变量,给死元组列表分配空间;
-
计算 next_unskippable_block,用于跳页逻辑判断;
-
循环遍历当前表的所有页面,进行 vacuum 处理。
-
如果有索引的话,需要先清理索引,再次访问表中的所有页面,进行清理操作;
计算 next_unskippable_block
如果出现很多页面都无需做 vacuum 的话,那么这些页面可以直接被跳过,提高 vacuum 效率。
vacuum 可以通过 vm (visbility map) 的状态来快速判断该页是否 全部可见/全部冻结,如果满足以下两个条件,那么当前页就可以跳过 vacuum。
-
处于迫切模式,且当前页面所有元组被冻结;
-
处于非迫切模式,且当前页面所有元组全可见;
如果发现超过连续 SKIP_PAGES_THRESHOLD (一般为32)个页面可跳,则后续处理到该页面时可以跳页。
之所以设置这个阈值有以下几个原因:
-
OS 本身有页面预读算法,在读一个页的时候,OS 通常已经将该页后面的页面读进了操作系统的缓存,这时候只跳一个页并不会增加很高的性能;
-
如果跳页的话,整张表的
relfrozenxid
无法被更新,会对后续 vacuum 执行的策略造成影响;
遍历所有页面
循环遍历当前表的所有页面,对于每个页面,都进行如下操作:
-
计算 next_unskippable_block,按照上图的逻辑判断是否需要跳页;
-
如果当前死元组列表快满了,调用 lazy_vacuum_index 和 lazy_vacuum_heap 清理索引和元组;
-
调用 ReadBufferExtended函数读取当前页面;
-
获取当前页面的 clean up 锁,即排它锁。因为要修改页面,所以拿写锁。注意,这里只获取一次,如果处于非迫切模式且并不是最后一页的话,没获取到就算了,直接跳过当前页面,等下一次 vacuum 来处理。其他情况请参考代码里的注释;
-
调用 heap_page_prune 函数,将当前页面上的 HOT 链进行修剪,将过期元组标记为死元组,对并调用 PageRepairFragmentation 进行页面空间整理 --*--
-
遍历当前页面的所有元组,清理之前因为并发没有清理掉的过期元组和其他种类的元组,记录需要冻结的元组,并将所有死元组记录到死元组列表中;
-
如果有可冻结的元组,那么执行冻结操作;
-
如果当前表没有索引,那么直接调用 lazy_vacuum_page 清理该页面:将死元组标记为 LP_UNUSED ,其他事物放元组的时候可以直接重用标记为 LP_UNUSED 的元组;
清理索引
如果当前表有索引的话,那么需要先清理索引。然后根据死元组列表,挨个访问所有存在死元组的 page (这里还有一次 IO),调用 lazy_vacuum_page 进行清理。
为什么在有索引的情况下不能进行上一部分的第 8 步-->没法保证原子性。如果索引没删除,而其指向的元组被删除甚至被重用,这会导致下一次按照这个索引找到的元组和这个索引没有任何关系,造成数据不一致。
元祖删除
版本链和HOT机制
在PostgreSQL中,MVCC并不会使用回滚区,Update的实现是先删除旧元组,再插入一条新元组。
原始元组使用t_ctid指向新元组,于是通过t_ctid,新旧多个版本的元组就组成了一条版本链。
如果表上创建了索引,由于元组的每次更新都会插入一条新元组,所以不论索引列是否更新都会向索引插入一条索引元组,这样会极大的降低插入性能。可以用一个简单的思路来优化这个问题:如果更新操作不涉及索引列,那么就无需向索引插入索引元组。当元组发生多次更新后(多次更新均不涉及索引列),元组的版本链和索引情况如下图所示:
从上图中我们可以得到两个信息:
-
元组一共有ver1-ver4四个版本,通过t_cid串联成一条版本链,其中ver1-ver3在block1中,ver4在block2中。
-
索引元组idx1指向ver1。
所以在查询时,通过idx1可以找到ver1,而ver1就是这条版本链的根,所以通过t_ctid就可以遍历所有四个版本。通过这样的方式可以很好的解决每做一次更新就需要插入一条索引元组的问题。但这种方式依然有一个问题。在ver1-ver4这4个版本中,很显然ver4是最新版本,而ver1-ver3都是历史版本,对于大多数事务来讲ver4才是唯一可见的版本。但是,如果通过索引来获取ver4,总是需要先访问ver1才能定位到ver4,这需要加载block1和bloc2两个块,如果版本链更长一点则需要加载更多的块。所以PostgreSQL采用了HOT机制,如果更新操作满足下面两个条件:
-
更新操作不涉及索引列。
-
更新后,插入的新元组和老元组在同一个块。
那么对于上图来说,由于ver4与ver3不在同一个块中,所以当ver3更新为ver4时,需要向索引插入一条新的索引元组,如下图所示:
从上图中可以看出,索引元组idx2指向ver4,版本链并没有发生改变,在PostgreSQL中将位于同一个块中的版本链称为HOT链,在图2中,ver1-ver3就是一条HOT链。所以在PostgreSQL中一个索引元组对应一条HOT链。那么HOT链有什么用呢?
-
第一个作用,PostgreSQL在查询时,访问多版本不会跨块访问,即只会访问HOT链上的版本。假设我们要通过索引查询图2中的元组,那么通过idx1只会访问ver1-ver3(虽然ver3的t_ctid会指向ver4),通过idx2才会访问ver4。
-
ver1-ver3是历史版本,历史版本最终是需要被删除的,在上图中,当ver1-ver3过期后,我们可以将ver1-ver3以及idx1全部删除。而在图1中,除非ver1-ver4全部过期,否则我们总是需要保留idx1和ver1。
HOT链标识
HOT链这么有用,我们要如何判断HOT链呢?通过标记,元组第一次插入时没有特别标记,当发生更新时,如果满足HOT条件,那么旧元组的t_infomask2会被标记为HEAP_HOT_UPDATED,新元组会标记为HEAP_ONLY_TUPLE。所以图2中ver1-ver4的标记如下:
其中,ver1-ver3在HOT链上,ver1是链头所以只有HEAP_HOT_UPDATED标记,ver3是链尾所以只有HEAP_ONLY_TUPLE标记,ver2在链表中间所以既有HEAP_HOT_UPDATED又有HEAP_ONLY_TUPLE,ver4不在HOT链上所以没有标记。
删除流程
通过前面的阐述,我们了解到了一个重要信息:元组的删除是基于HOT链进行的,只会删除HOT链上的一条或多条甚至全部元组,下面我们具体来看看删除如何进行。
是由ItemIdData+元组实体两部分组成,ItemIdData主要用于记录元组长度、元组状态,然后指向元组实体。如果通过Lazy Vacuum来删除元组,那么元组的ItemData是不会被真正删除的,只会修改其状态,而元组的实际内容会被删除,从而释放出空闲空间。
情况1
假设现在元组及HOT链的情况如上图所示,现在ver3为当前版本,ver1和ver2已经过期,我们来看看PostgreSQL是如何删除ver1和ver2的。首先,ver2位于HOT链的中间,且已经过期,所以ver2的元组实体可以被删除,ver2的ItemIdData中的状态会被设置为LP_UNUSED,表示这个ItemIdData在插入时可以被新元组复用。
关于ItemIdData复用在向数据库中插入新元组时,需要为元组分配ItemIdData,而分配ItemIdData时总是优先考虑复用被标记为LP_UNUSED的元组。具体代码如下://bufpage.c line 237 /* offsetNumber was not passed in, so find a free slot */ /* if no free slot, we'll put it at limit (1st open slot) */ if (PageHasFreeLinePointers(phdr)) { /* * Look for "recyclable" (unused) ItemId. We check for no storage * as well, just to be paranoid --- unused items should never have * storage. */ for (offsetNumber = 1; offsetNumber < limit; offsetNumber++) { itemId = PageGetItemId(phdr, offsetNumber); if (!ItemIdIsUsed(itemId) && !ItemIdHasStorage(itemId)) break; } if (offsetNumber >= limit) { /* the hint is wrong, so reset it */ PageClearHasFreeLinePointers(phdr); } } else { /* don't bother searching if hint says there's no free slot */ offsetNumber = limit; }
同时由于ver3为元组的当前版本,所以在ver2删除之后,按理说应该将ver1的t_ctid指向ver3。但是ver1也是一条过期元组,所以没必要保留ver1的元组实体,由于t_ctid存放在元组实体中(HeapTupleHeader结构体),所以如果要删除ver1的元组实体就没办法使用t_ctid指向ver3。于是PostgreSQL使用ver1的ItemIdData来重定位ver3,所谓重定位实际是两个步骤:
-
将ver1的ItemIdData的状态修改为LP_REDIRECT。
-
将ver3的ItemIdData的位置,存放到ver1的ItemIdData中的lp_off成员中。
这样,通过ver1的ItemIdData就可以找到ver3的ItemIdData,从而找到ver3的元组实体,重定位操作的代码如下
#define ItemIdSetRedirect(itemId, link) \
( \
(itemId)->lp_flags = LP_REDIRECT, \
(itemId)->lp_off = (link), \
(itemId)->lp_len = 0 \
)
情况2
情况2依然如图所示,但现在元组当前版本ver3被删除了,且ver1-ver3都已经过期,现在需要将其全部删除。
对于ver2和ver3可以直接删除元组实体,然后将他们ItemIdData的状态改为LP_UNUSED。但对于ver1却不能这么做,因为ver1还关联着索引idx1,在索引没有删除之前,关联元组的ItemIdData不能删除,也不能重用!(因为一旦重用那么idx1将会指向一条和它没有任何关系的新元组)所以在删除ver1之前,必须先将idx1删除。
当然,我们可以从ver1中获取索引列的值,然后在B+树中进行查询,从而定位到idx1的位置,将其删除。但是这个流程显然非常冗长,如果每删除一个位于HOT链头的元组都要经历这样的操作,那必然十分低效,我们更希望的是一种批量执行的策略。现在我们只需要知道,当前不会试图删除idx1,在删除了ver1的元组实体后,不会将ItemIdData标记为LP_UNUSED,而是标记为LP_DEAD。
再后面批量的删除索引后,再来将标记为LP_DEAD的元组修改为LP_UNUSED。
heap_page_prune
heap_page_prune用于删除指定页面中的所有过期元组。
heap_page_prune会遍历指定页面的所有元组,对每个元组调用heap_prune_chain来遍历其HOT链,判断元组是否过期,记录需要设置为LP_UNUSED、LP_DEAD、LP_REDIRECT的元组。
然后调用heap_page_prune_execute批量处理这些元组,删除他们的元组实体,并将其ItemIdData设置为对应的标记。下面,我们分别来看看这两个函数。
heap_prune_chain
heap_prune_chain包含三个步骤:
-
步骤1:遍历元组的HOT链
-
步骤2:判断元组是否过期需要注意的是,HOT链上的元组是按照版本的先后顺序排列,所以只需要找到最后一条过期的原数,它之前的所有元组都过期了。
-
步骤3:记录需要设置为LP_UNUSED、LP_DEAD、LP_REDIRECT的元组
heap_page_prune_execute
heap_page_prune_execute主要包含两个步骤:
-
步骤1:依据heap_prune_chain的处理结果,将相关元组设置为LP_UNUSED、LP_DEAD、LP_REDIRECT。这三种类型的元组,他们的元组实体部分都没有意义了,所以需要将他们ItemIdData中的lp_len设置为0,以此表示他们已经没有元组实体了。对于LP_REDIRECT元组,需要将其lp_off设置为重定位的ItemIdData位置,对于LP_UNUSED、LP_DEAD直接将其lp_off设置为0。
-
步骤2:调用PageRepairFragmentation对页面内进行空间整理,即删除过期元组的元组实体部分。
PageRepairFragmentation
下面我们来看看PageRepairFragmentation的实现细节,PageRepairFragmentation的实现:
在上图中,红色表示需要删除的元组,绿色表示需要保留的元组,那么回收的最便捷方式就是将TUPLE3-TUPLE1依次移动到块末尾,如下图所示:
PageRepairFragmentation主要包含三个步骤:
-
步骤1:统计块内需要保留的元组数量。
如果块内没有元组需要保留,则直接修改块的pd_upper指针让其指向块末尾。
-
步骤2:记录块内需要保留的元组。
-
步骤3:调用compactify_tuples实现实际的元组删除。
这个算法类似于操作系统中的存储管理中的“紧凑”操作,将分散的元组项地址连续起来,消除页面中的内部碎片。
compactify_tuples
思考:关于元组实体的顺序问题
向一个新分配的块中插入元组,元组ItemData的顺序和元组实体offset之前应该是逆序关系,如上图所示。
然而,随着元组的删除,元组ItemData的重用,会导致元组ItemData和元组实体offset之间完全乱序,这就是为什么需要compactify_tuples的原因。
未优化前
static void
compactify_tuples(itemIdSort itemidbase, int nitems, Page page)
{
PageHeader phdr = (PageHeader) page;
Offset upper;
int i;
/* sort itemIdSortData array into decreasing itemoff order */
qsort((char *) itemidbase, nitems, sizeof(itemIdSortData),
itemoffcompare);
upper = phdr->pd_special;
for (i = 0; i < nitems; i++)
{
itemIdSort itemidptr = &itemidbase[i];
ItemId lp;
lp = PageGetItemId(page, itemidptr->offsetindex + 1);
upper -= itemidptr->alignedlen;
memmove((char *) page + upper,
(char *) page + itemidptr->itemoff,
itemidptr->alignedlen);
lp->lp_off = upper;
}
phdr->pd_upper = upper;
}
为什么要确认item是不是顺序排列?
这里讨论为什么要知道他是不是顺序排列的原因:
首先先明确一点,整个过程是为了清理碎片,tuple的碎片,但我们不是直接控制tuple,而是通过控制linp这个指针内容来控制,而linp会保存他指向的元祖的信息。
通过它的内容我们可以知道这个元祖是活的还是死的等等,我们要处理的就是这个内容。
那我们会按照linp这个数组的遍历顺序从头到尾进行紧凑排列从而占用掉死元组等内容,也就是,后面会有一个按照item顺序遍历的将tuple推到底部的操作,从而来清除死元组等无效内容空间
还是如上图所示,如果不排序,第二个指向倒数第三,第三个指向倒数第二,这样的话,移动第二个的时候就会把第三个的位置占用,发生错误。
从中我们可以看到:这个函数宏观上的整体过程是:统计一个页面中的“有内容”的元组项,接着把这些元组项“装入”到一个itemidbase的数组,借助这个辅助空间,移动这些元组,使得他们的页面偏移量连续起来,消除页面中的碎片,得到更多的剩余空间。
关于旧代码排序的思考
这个过程真的需要排序吗?
-
我们可不可以与判断一下itembase 如果他已经按照顺序排列了就直接执行紧凑操作?
-
如果乱序,我们真的需要排序吗?我们的终极目的是为了让元祖紧凑!那我们如果就按照item的顺序将元祖进行排序。那在之前说到如果按照他本来的顺序进行排序他会覆盖,有没有一种办法不让他覆盖?
-
空间换时间,我们能不能先把需要排序的item所指向的元祖保存下来?
优化后
static void
compactify_tuples(itemIdCompact itemidbase, int nitems, Page page, bool presorted)
{
PageHeader phdr = (PageHeader) page;
Offset upper;
Offset copy_tail;
Offset copy_head;
itemIdCompact itemidptr;
int i;
/* Code within will not work correctly if nitems == 0 */
Assert(nitems > 0);
if (presorted)
{
/*
* 'itemidbase' is already in the optimal order, i.e, lower item
* pointers have a higher offset. This allows us to memmove() the
* tuples up to the end of the page without having to worry about
* overwriting other tuples that have not been moved yet.
*
* There's a good chance that there are tuples already right at the
* end of the page that we can simply skip over because they're
* already in the correct location within the page. We'll do that
* first...
*/
upper = phdr->pd_special;
i = 0;
do
{
itemidptr = &itemidbase[i];
if (upper != itemidptr->itemoff + itemidptr->alignedlen)
break;
upper -= itemidptr->alignedlen;
i++;
} while (i < nitems);
/*
* Now that we've found the first tuple that needs to be moved, we can
* do the tuple compactification. We try and make the least number of
* memmove() calls and only call memmove() when there's a gap. When
* we see a gap we just move all tuples after the gap up until the
* point of the last move operation.
*/
copy_tail = copy_head = itemidptr->itemoff + itemidptr->alignedlen;
for (; i < nitems; i++)
{
ItemId lp;
itemidptr = &itemidbase[i];
lp = PageGetItemId(page, itemidptr->offsetindex + 1);
if (copy_head != itemidptr->itemoff + itemidptr->alignedlen)
{
memmove((char *) page + upper,
page + copy_head,
copy_tail - copy_head);
/*
* We've now moved all tuples already seen, but not the
* current tuple, so we set the copy_tail to the end of this
* tuple so it can be moved in another iteration of the loop.
*/
copy_tail = itemidptr->itemoff + itemidptr->alignedlen;
}
/* shift the target offset down by the length of this tuple */
upper -= itemidptr->alignedlen;
/* point the copy_head to the start of this tuple */
copy_head = itemidptr->itemoff;
/* update the line pointer to reference the new offset */
lp->lp_off = upper;
}
/* move the remaining tuples. */
memmove((char *) page + upper,
page + copy_head,
copy_tail - copy_head);
}
else
{
PGAlignedBlock scratch;
char *scratchptr = scratch.data;
/*
* Non-presorted case: The tuples in the itemidbase array may be in
* any order. So, in order to move these to the end of the page we
* must make a temp copy of each tuple that needs to be moved before
* we copy them back into the page at the new offset.
*
* If a large percentage of tuples have been pruned (>75%) then we'll
* copy these into the temp buffer tuple-by-tuple, otherwise, we'll
* just do a single memcpy() for all tuples that need to be moved.
* When so many tuples have been removed there's likely to be a lot of
* gaps and it's unlikely that many non-movable tuples remain at the
* end of the page.
*/
if (nitems < PageGetMaxOffsetNumber(page) / 4)
{
i = 0;
do
{
itemidptr = &itemidbase[i];
memcpy(scratchptr + itemidptr->itemoff, page + itemidptr->itemoff,
itemidptr->alignedlen);
i++;
} while (i < nitems);
/* Set things up for the compactification code below */
i = 0;
itemidptr = &itemidbase[0];
upper = phdr->pd_special;
}
else
{
upper = phdr->pd_special;
/*
* Many tuples are likely to already be in the correct location.
* There's no need to copy these into the temp buffer. Instead
* we'll just skip forward in the itemidbase array to the position
* that we do need to move tuples from so that the code below just
* leaves these ones alone.
*/
i = 0;
do
{
itemidptr = &itemidbase[i];
if (upper != itemidptr->itemoff + itemidptr->alignedlen)
break;
upper -= itemidptr->alignedlen;
i++;
} while (i < nitems);
/* Copy all tuples that need to be moved into the temp buffer */
memcpy(scratchptr + phdr->pd_upper,
page + phdr->pd_upper,
upper - phdr->pd_upper);
}
/*
* Do the tuple compactification. itemidptr is already pointing to
* the first tuple that we're going to move. Here we collapse the
* memcpy calls for adjacent tuples into a single call. This is done
* by delaying the memcpy call until we find a gap that needs to be
* closed.
*/
copy_tail = copy_head = itemidptr->itemoff + itemidptr->alignedlen;
for (; i < nitems; i++)
{
ItemId lp;
itemidptr = &itemidbase[i];
lp = PageGetItemId(page, itemidptr->offsetindex + 1);
/* copy pending tuples when we detect a gap */
if (copy_head != itemidptr->itemoff + itemidptr->alignedlen)
{
memcpy((char *) page + upper,
scratchptr + copy_head,
copy_tail - copy_head);
/*
* We've now copied all tuples already seen, but not the
* current tuple, so we set the copy_tail to the end of this
* tuple.
*/
copy_tail = itemidptr->itemoff + itemidptr->alignedlen;
}
/* shift the target offset down by the length of this tuple */
upper -= itemidptr->alignedlen;
/* point the copy_head to the start of this tuple */
copy_head = itemidptr->itemoff;
/* update the line pointer to reference the new offset */
lp->lp_off = upper;
}
/* Copy the remaining chunk */
memcpy((char *) page + upper,
scratchptr + copy_head,
copy_tail - copy_head);
}
phdr->pd_upper = upper;
}
-
已经排好序了
'itemidbase'已经处于最优的顺序。这意味着较低的元指针(item pointers)具有较高的偏移量。这样可以使用memmove()函数将元组移动到页面的末尾,而无需担心覆盖尚未移动的其他元组。此外,很可能存在一些已经位于页面末尾的元组,我们可以简单地跳过它们,因为它们在页面中已经处于正确的位置。
当发现有gap--空隙的时候
已经找到了需要移动的第一个元组,可以进行元组压缩。我们尽量使memmove()调用的次数最少,并且只在有间隔时调用memmove()。当我们发现间隔时,我们只需将所有位于间隔之后的元组移动到最后一次移动操作之后的位置。
-
非排序
非排序情况:itemidbase数组中的元组可以是任意顺序。因此,为了将这些元组移动到页面的末尾,我们必须在对它们进行复制并移动到新偏移量之前,将每个需要移动的元组临时复制一份。如果被修剪的元组占很大比例(>75%),那么我们将逐个将这些元组复制到临时缓冲区中,否则,我们将仅为需要移动的所有元组执行单个memmove()操作。当删除如此多的元组时,很可能存在许多间隙,并且很可能不再有大量不可移动的元组留在页面的末尾。
很可能有许多元组已经位于正确的位置。没有必要将这些元组复制到临时缓冲区中,而是直接在itemidbase数组中跳到需要从其中移动元组的位置,
换句话说,这个函数的目的就是把tuple底部的空隙去掉,将tuple紧凑排列,而我们不能直接控制tuple,通过控制item的方式去控制他。
之前通过排序解决顺序错乱的问题,但现在通过缓存区,空间换时间来优化。
效果
社区实验:对比pg13提升了75%
Wal --100GB
Teledbx 提高15s左右