PostgreSQL Vacuum—元组删除
预备知识
概述
在《PostgreSQL思考—元组何时可以被删除?》中,我们阐述了关于元组过期的问题,在本文中我将具体阐述,对于过期元组应该如何删除。
版本链与HOT机制
基本概念
在PostgreSQL中,MVCC并不会使用回滚区,Update的实现是先删除旧元组,再插入一条新元组。原始元组使用t_ctid指向新元组,于是通过t_ctid,新旧多个版本的元组就组成了一条版本链。如果表上创建了索引,由于元组的每次更新都会插入一条新元组,所以不论索引列是否更新都会向索引插入一条索引元组,这样会极大的降低插入性能。可以用一个简单的思路来优化这个问题:如果更新操作不涉及索引列,那么就无需向索引插入索引元组。当元组发生多次更新后(多次更新均不涉及索引列),元组的版本链和索引情况如图1所示:

从图1中我们可以得到两个信息:
- 元组一共有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机制,如果更新操作满足下面两个条件:
- 更新操作不涉及索引列。
- 更新后,插入的新元组和老元组在同一个块。
那么这样的更新将不会向索引插入新的索引元组,反之则需要插入。
那么对于图1来说,由于ver4与ver3不在同一个块中,所以当ver3更新为ver4时,需要向索引插入一条新的索引元组,如图2所示:

从图2中可以看出,索引元组idx2指向ver4,版本链并没有发生改变,在PostgreSQL中将位于同一个块中的版本链称为HOT链,在图2中,ver1-ver3就是一条HOT链。所以在PostgreSQL中一个索引元组对应一条HOT链。那么HOT链有什么用呢?
HOT链作用1
第一个作用,PostgreSQL在查询时,访问多版本不会跨块访问,即只会访问HOT链上的版本。假设我们要通过索引查询图2中的元组,那么通过idx1只会访问ver1-ver3(虽然ver3的t_ctid会指向ver4),通过idx2才会访问ver4。
HOT链作用2
ver1-ver3是历史版本,历史版本最终是需要被删除的,在图2中,当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链上的一条或多条甚至全部元组,下面我们具体来看看删除如何进行。

通过《PostgreSQL 基础模块—表和元组组织方式》我们可以知道,是由ItemIdData+元组实体两部分组成,ItemIdData主要用于记录元组长度、元组状态,然后指向元组实体。如果通过Lazy Vacuum来删除元组,那么元组的ItemData是不会被真正删除的,只会修改其状态,而元组的实际内容会被删除,从而释放出空闲空间。下面我们来分析几种不同情况,看看不同情况下元组如何被删除。
情况1
假设现在元组及HOT链的情况如图4所示,现在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 \
)
对于发生重定位的元组,需要通过while循环来实现重定位:
while (ItemIdIsRedirected(hitemid))
{
hoffnum = ItemIdGetRedirect(hitemid);
hitemid = PageGetItemId(hpage, hoffnum);
}
情况2
情况2依然如图4所示,但现在元组当前版本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函数中,代码如下:
//pruneheap.c line 182
int
heap_page_prune(Relation relation, Buffer buffer, TransactionId OldestXmin,
bool report_stats, TransactionId *latestRemovedXid)
{
int ndeleted = 0;
Page page = BufferGetPage(buffer);
OffsetNumber offnum,
maxoff;
PruneState prstate;
//省略...
/* Scan the page */
maxoff = PageGetMaxOffsetNumber(page);
for (offnum = FirstOffsetNumber;
offnum <= maxoff;
offnum = OffsetNumberNext(offnum))
{
ItemId itemid;
/* Ignore items already processed as part of an earlier chain */
if (prstate

本文深入探讨了PostgreSQL中元组删除的机制,特别是如何利用HOT(Heap Only Tuple)链来优化删除过程。在PostgreSQL中,更新不涉及索引列的元组会形成HOT链,通过HOT链可以减少索引维护和提高查询效率。文章详细解释了HOT链的标识、作用以及如何通过HOT链进行元组删除,包括删除过程中的重定位和空间回收。此外,还介绍了`heap_page_prune`函数及其内部的`heap_prune_chain`和`heap_page_prune_execute`等关键步骤,以及`PageRepairFragmentation`的空间整理过程。
最低0.47元/天 解锁文章
1371

被折叠的 条评论
为什么被折叠?



