PostgreSQL的MVCC(5)--In-page vacuum and HOT updates

in-page vacuum和HOT更新,两种技术都可以称为优化。它们很重要,但实际上并未包含在文档中。

在常规更新期间执行in-page vacuum

当因为读取或更新访问页面时,如果PostgreSQL知道该页面空间不足,则可以执行一次快速的in-page vacuum。发生在以下两种情况下:

1.此页面上之前的更新找不到足够的空间来在同一页面中分配新的行版本。在page header中会记录这种情况,下一次将会对页面vacuum。

2.该页面已超过fillfactor设定的百分比。在这种情况下,立即执行vacuum,而不会推迟到下一次。

fillfactor是可以为表(和索引)定义的存储参数。仅当页面小于fillfactor百分比时,PostgresSQL才会在页面中插入新行。剩余空间保留用于更新产生的新元组。表的默认值为100,即不保留任何空间(索引的默认值为90)。

in-page vacuum会删除在任何快照中都不可见的元组(那些元组超出了数据库的事务范围,这是上次讨论的),但是严格地在一个表页中执行此操作。由于可以从索引中引用被vacuum的元组的指针,因此不会释放指针,索引是在另一页中。in-page vacuum永远不会超过一个表页,但是工作非常迅速。

出于同样的原因,可用空间map也不会更新。这也为更新而不是插入保留了额外的空间。可见性map也不会更新。

页面在读取过程中可以被vacuum的事实意味着SELECT查询可能会更改页面。除了前面讨论的hint bits的延迟更改外,这是另外一种情况。

让我们看一个如何工作的例子。

=> CREATE TABLE hot(id integer, s char(2000)) WITH (fillfactor = 75);
=> CREATE INDEX hot_id ON hot(id);
=> CREATE INDEX hot_s ON hot(s);

如果s列仅存储拉丁字符,则每个行版本将占用2004字节加上header的24字节。 我们将fillfactor存储参数设置为75%,这将为三行保留足够的空间。

为了方便地查看表页面的内容,让我们通过在输出中添加另外两个字段来重新创建一个已经熟悉的函数:

=> CREATE FUNCTION heap_page(relname text, pageno integer)
RETURNS TABLE(ctid tid, state text, xmin text, xmax text, hhu text, hot text, t_ctid tid)
AS $$
SELECT (pageno,lp)::text::tid AS ctid,
       CASE lp_flags
         WHEN 0 THEN 'unused'
         WHEN 1 THEN 'normal'
         WHEN 2 THEN 'redirect to '||lp_off
         WHEN 3 THEN 'dead'
       END AS state,
       t_xmin || CASE
         WHEN (t_infomask & 256) > 0 THEN ' (c)'
         WHEN (t_infomask & 512) > 0 THEN ' (a)'
         ELSE ''
       END AS xmin,
       t_xmax || CASE
         WHEN (t_infomask & 1024) > 0 THEN ' (c)'
         WHEN (t_infomask & 2048) > 0 THEN ' (a)'
         ELSE ''
       END AS xmax,
       CASE WHEN (t_infomask2 & 16384) > 0 THEN 't' END AS hhu,
       CASE WHEN (t_infomask2 & 32768) > 0 THEN 't' END AS hot,
       t_ctid
FROM heap_page_items(get_raw_page(relname,pageno))
ORDER BY lp;
$$ LANGUAGE SQL;

让我们也创建一个函数来查看索引页:

=> CREATE FUNCTION index_page(relname text, pageno integer)
RETURNS TABLE(itemoffset smallint, ctid tid)
AS $$
SELECT itemoffset,
       ctid
FROM bt_page_items(relname,pageno);
$$ LANGUAGE SQL;

让我们来看看in-page vacuum是如何工作的。为此,我们插入一行并修改几次:

=> INSERT INTO hot VALUES (1, 'A');
=> UPDATE hot SET s = 'B';
=> UPDATE hot SET s = 'C';
=> UPDATE hot SET s = 'D';

现在页面中有四个元组:

=> SELECT * FROM heap_page('hot',0);
 ctid  | state  |   xmin   |   xmax   | hhu | hot | t_ctid 
-------+--------+----------+----------+-----+-----+--------
 (0,1) | normal | 3979 (c) | 3980 (c) |     |     | (0,2)
 (0,2) | normal | 3980 (c) | 3981 (c) |     |     | (0,3)
 (0,3) | normal | 3981 (c) | 3982     |     |     | (0,4)
 (0,4) | normal | 3982     | 0 (a)    |     |     | (0,4)
(4 rows)

正如预期的那样,我们刚刚超过了fillfactor阈值。从pagesize值和上限值之间的差异可以清楚地看出这一点:它超过了等于页面大小75%的阈值,即6144字节。

=> SELECT lower, upper, pagesize FROM page_header(get_raw_page('hot',0));
 lower | upper | pagesize 
-------+-------+----------
    40 |    64 |     8192
(1 row)

因此,当下一次访问该页时,一定发生in-page vacuum。让我们检查一下。

=> UPDATE hot SET s = 'E';
=> SELECT * FROM heap_page('hot',0);
 ctid  | state  |   xmin   | xmax  | hhu | hot | t_ctid 
-------+--------+----------+-------+-----+-----+--------
 (0,1) | dead   |          |       |     |     | 
 (0,2) | dead   |          |       |     |     | 
 (0,3) | dead   |          |       |     |     | 
 (0,4) | normal | 3982 (c) | 3983  |     |     | (0,5)
 (0,5) | normal | 3983     | 0 (a) |     |     | (0,5)
(5 rows)

清除所有死元组(0,1),(0,2)和(0,3); 之后,在释放的空间中添加一个新的元组(0,5)。

幸免于难的元组被物理地移向页面的高地址,因此所有可用空间都由一个连续区域表示。指针的值会相应更改。因此,页面中的可用空间碎片不会出现任何问题。

由于从索引页面引用了指向被vacuum的元组的指针,因此无法释放它们。 让我们看一下hot_s索引的第一页(因为第零页被元信息占据):

=> SELECT * FROM index_page('hot_s',1);
 itemoffset | ctid  
------------+-------
          1 | (0,1)
          2 | (0,2)
          3 | (0,3)
          4 | (0,4)
          5 | (0,5)
(5 rows)

我们在另一个指数中也看到了同样的结果:

=> SELECT * FROM index_page('hot_id',1);
 itemoffset | ctid  
------------+-------
          1 | (0,5)
          2 | (0,4)
          3 | (0,3)
          4 | (0,2)
          5 | (0,1)
(5 rows)

你可能会注意到,指向表行的指针以相反的顺序跟随在这里,但这没有什么区别,因为所有元组都具有相同的值:id = 1。 但是在上一个索引中,指针由s的值排序,这是必不可少的。

通过索引访问,PostgreSQL可以获取(0,1),(0,2)或(0,3)作为元组标识符。 然后它将尝试从表页面中获取适当的行版本,但是由于指针的“dead”状态,PostgreSQL将发现该版本不再存在并将忽略它。(实际上,一旦发现表行的版本不可用,PostgreSQL将更改索引页中的指针状态,以便不再访问表页)

重要的是,in-page vacuum只能在一个表页内工作,而不能vacuum索引页。

HOT更新

为什么在索引中存储对所有行版本的引用没有好处?

首先,对于该行的任何更改,都必须更新为该表创建的所有索引:创建新的行版本后,需要对其进行引用。而且,即使更改了未建立索引的字段,我们仍然需要这样做。这显然不是很有效。

其次,索引会累积对历史元组的引用,然后将其与元组本身一起清理掉(我们将在稍后讨论如何完成)。

此外,PostgreSQL中的B树具有实现细节。如果索引页没有足够的空间来插入新行,则该页将被分为两部分,并且所有数据都将在它们之间进行分配。这称为页拆分。但是,删除行时,两个索引页不会合并为一个。因此,即使删除了相当一部分数据,索引大小也可能无法减小。

自然地,在表上创建的索引越多,遇到的复杂度就越高。

但是,如果更改了一个根本没有索引的列中的值,则创建包含相同键值的额外B树行是没有意义的。这就是所谓的HOT更新(仅堆元组更新)优化的工作方式。

在此更新过程中,索引页仅包含一行,该行引用表页中该行的第一个版本。在表页面内部,已组织了一个元组链:

·链中被更新的行,用heap hot updated位标记。 ·未被索引引用的行,用heap only tupe位标记。 ·通常,行版本通过ctid字段链接。

如果在索引扫描期间,PostgreSQL访问表页并找到一个标记为Heap Hot Updated的元组,pg就知道不应该停止,而必须顺着HOT链,并考虑其中的每个元组。当然,对于所有以这种方式获取的元组,在将其返回给客户端之前都要检查可见性。

为了观察HOT更新的工作原理,让我们删除一个索引并清除表。

=> DROP INDEX hot_s;
=> TRUNCATE TABLE hot;

现在我们重做一行的插入和更新。

=> INSERT INTO hot VALUES (1, 'A');
=> UPDATE hot SET s = 'B';

表页内容如下:

=> SELECT * FROM heap_page('hot',0);
 ctid  | state  |   xmin   | xmax  | hhu | hot | t_ctid 
-------+--------+----------+-------+-----+-----+--------
 (0,1) | normal | 3986 (c) | 3987  | t   |     | (0,2)
 (0,2) | normal | 3987     | 0 (a) |     | t   | (0,2)
(2 rows)

页面中有一系列更改:

·Heap Hot Updated标志指示必须顺着ctid链。 ·Heap Only Tuple 标志指示改行未被索引引用。

链(chain)会随着页面的变化而增长(在页面内):

=> UPDATE hot SET s = 'C';
=> UPDATE hot SET s = 'D';
=> SELECT * FROM heap_page('hot',0);
 ctid  | state  |   xmin   |   xmax   | hhu | hot | t_ctid 
-------+--------+----------+----------+-----+-----+--------
 (0,1) | normal | 3986 (c) | 3987 (c) | t   |     | (0,2)
 (0,2) | normal | 3987 (c) | 3988 (c) | t   | t   | (0,3)
 (0,3) | normal | 3988 (c) | 3989     | t   | t   | (0,4)
 (0,4) | normal | 3989     | 0 (a)    |     | t   | (0,4)
(4 rows)

但是索引中只有一个指向链(chain)头的引用:

=> SELECT * FROM index_page('hot_id',1);
 itemoffset | ctid  
------------+-------
          1 | (0,1)
(1 row)

要强调的是,HOT更新在要更新的字段没有被索引的情况下起作用。 否则,某些索引将直接包含对新行版本的引用,这与此优化的概念不兼容。

该优化仅在一页内进行,因此,在链中进行额外的遍历不需要访问其他页面,并且不会影响性能。

HOT更新期间执行in-page vacuum

HOT更新期间的vacuum是in-page vacuum的一种特殊但重要的情况。

和上面一样,我们已经超出了fillfactor阈值,因此下一次更新必须引起in-page vacuum。但是这次页面中有一系列更新。 由于索引已引用该HOT链的头部,因此它必须始终保留在其位置,而其余的指针可以释放:已知它们没有外部引用。

为了不改变头指针,使用了间接寻址:在这个例子下,索引引用的是(0,1),该指针状态将重定向到适当的元组。

=> UPDATE hot SET s = 'E';
=> SELECT * FROM heap_page('hot',0);
 ctid  |     state     |   xmin   | xmax  | hhu | hot | t_ctid 
-------+---------------+----------+-------+-----+-----+--------
 (0,1) | redirect to 4 |          |       |     |     | 
 (0,2) | normal        | 3990     | 0 (a) |     | t   | (0,2)
 (0,3) | unused        |          |       |     |     | 
 (0,4) | normal        | 3989 (c) | 3990  | t   | t   | (0,2)
(4 rows)

注意:

·将元组(0,1),(0,2)和(0,3)清除。 ·头指针(0,1)保留,但是它获得了“重定向”状态。 ·由于肯定没有对该元组的引用,因此新的行版本已覆盖(0,2),并且指针已释放(“unused”状态)。

让我们再进行几次更新:

=> UPDATE hot SET s = 'F';
=> UPDATE hot SET s = 'G';
=> SELECT * FROM heap_page('hot',0);
 ctid  |     state     |   xmin   |   xmax   | hhu | hot | t_ctid 
-------+---------------+----------+----------+-----+-----+--------
 (0,1) | redirect to 4 |          |          |     |     | 
 (0,2) | normal        | 3990 (c) | 3991 (c) | t   | t   | (0,3)
 (0,3) | normal        | 3991 (c) | 3992     | t   | t   | (0,5)
 (0,4) | normal        | 3989 (c) | 3990 (c) | t   | t   | (0,2)
 (0,5) | normal        | 3992     | 0 (a)    |     | t   | (0,5)
(5 rows)

下次更新导致in-page vacuum再次执行:

=> UPDATE hot SET s = 'H';
=> SELECT * FROM heap_page('hot',0);
 ctid  |     state     |   xmin   | xmax  | hhu | hot | t_ctid 
-------+---------------+----------+-------+-----+-----+--------
 (0,1) | redirect to 5 |          |       |     |     | 
 (0,2) | normal        | 3993     | 0 (a) |     | t   | (0,2)
 (0,3) | unused        |          |       |     |     | 
 (0,4) | unused        |          |       |     |     | 
 (0,5) | normal        | 3992 (c) | 3993  | t   | t   | (0,2)
(5 rows)

同样,一些元组被清除,指向链头的指针相应地被移动。

结论:如果经常更新没有索引的列,那么减少fillfactor参数以便为更新保留一些页面空间可能是有意义的。但是,我们应该考虑到,填充因子越少,页面中剩余的空间就越多,因此表的物理大小就会增加。

HOT chain断裂

如果页面缺少可用空间来分配新的元组,则链将断裂。 而且,我们将为位于不同的页中的行创建单独的引用。

为了重现这种情况,让我们开始一个并发事务并在其中构建数据快照。

|  => BEGIN ISOLATION LEVEL REPEATABLE READ;
|  => SELECT count(*) FROM hot;
|   count 
|  -------
|       1
|  (1 row)

快照不允许将元组从页中清除掉。现在让我们在第一个会话中做一个更新:

=> UPDATE hot SET s = 'I';
=> UPDATE hot SET s = 'J';
=> UPDATE hot SET s = 'K';
=> SELECT * FROM heap_page('hot',0);
 ctid  |     state     |   xmin   |   xmax   | hhu | hot | t_ctid 
-------+---------------+----------+----------+-----+-----+--------
 (0,1) | redirect to 2 |          |          |     |     | 
 (0,2) | normal        | 3993 (c) | 3994 (c) | t   | t   | (0,3)
 (0,3) | normal        | 3994 (c) | 3995 (c) | t   | t   | (0,4)
 (0,4) | normal        | 3995 (c) | 3996     | t   | t   | (0,5)
 (0,5) | normal        | 3996     | 0 (a)    |     | t   | (0,5)
(5 rows)

在下一次更新时,页面将没有足够的空间,但in-page vacuum将无法清除任何东西:

=> UPDATE hot SET s = 'L';

|  => COMMIT; -- snapshot no longer needed

=> SELECT * FROM heap_page('hot',0);
 ctid  |     state     |   xmin   |   xmax   | hhu | hot | t_ctid 
-------+---------------+----------+----------+-----+-----+--------
 (0,1) | redirect to 2 |          |          |     |     | 
 (0,2) | normal        | 3993 (c) | 3994 (c) | t   | t   | (0,3)
 (0,3) | normal        | 3994 (c) | 3995 (c) | t   | t   | (0,4)
 (0,4) | normal        | 3995 (c) | 3996 (c) | t   | t   | (0,5)
 (0,5) | normal        | 3996 (c) | 3997     |     | t   | (1,1)
(5 rows)

在元组(0,5)中,有一个对第1页中的(1,1)的引用

=> SELECT * FROM heap_page('hot',1);
 ctid  | state  | xmin | xmax  | hhu | hot | t_ctid 
-------+--------+------+-------+-----+-----+--------
 (1,1) | normal | 3997 | 0 (a) |     |     | (1,1)
(1 row)

现在索引中有两行,每一行都指向其HOT链的开始:

=> SELECT * FROM index_page('hot_id',1);
 itemoffset | ctid  
------------+-------
          1 | (1,1)
          2 | (0,1)
(2 rows)

  

原文地址:

https://habr.com/en/company/postgrespro/blog/483768/

原文地址:https://www.cnblogs.com/abclife/p/13620700.html