PostgreSQL-5中的MVCC。 页内真空和HOT更新

提醒您,我们已经讨论了与隔离相关的问题,对低级数据结构进行了论述,然后探讨了行版本,并观察了如何从行版本中获取数据快照

现在,我们将继续处理两个紧密相关的问题: 页内真空HOT更新 。 两种技术都可以称为优化。 它们很重要,但实际上并未在文档中进行介绍。

定期更新期间的页内空白


当访问页面进行更新或读取时,如果PostgreSQL知道该页面空间不足,则可以进行快速页面内清理。 在以下两种情况下都会发生这种情况:

  1. 此页面上的先前更新找不到足够的空间来在同一页面中分配新的行版本。 在页眉中会记住这种情况,下一次将页面清理时。
  2. 该页面已超过fillfactor已满百分比。 在这种情况下,将立即执行真空,而不会推迟到下一个。

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

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

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

页面在读取期间可以被清理的事实意味着SELECT查询可能需要更改页面。 除了前面讨论的提示位的延迟更改外,这是另外一种情况。

让我们考虑一个如何工作的例子。 让我们在两个列上创建一个表和索引。

 => 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字节加上标题的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; 

让我们检查页内真空的工作原理。 为此,我们插入一行并对其进行多次更改:

 => 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阈值。 从pagesizeupper限值之间的差异可以清楚地看出:它超过了等于页面大小75%的阈值,即6144个字节。

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

因此,下次访问该页面时,必须发生页面内清理。 让我们检查一下。

 => 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)。

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

由于从索引页引用了指向真空元组的指针,因此无法释放它们。 让我们看一下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)作为元组标识符。 然后它将尝试从表页面获取适当的行版本,但是由于指针的“死”状态,PostgreSQL将发现该版本不再存在并将忽略它。 (实际上,一旦发现表行的版本不可用,PostgreSQL将更改索引页中的指针状态,以便不再访问表页。)

重要的是,页内真空只能在一个表页内工作,而不能真空索引页。

热门更新


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

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

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

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

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

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

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

  • 链中的已更新行标有“堆热更新”位。
  • 未从索引引用的行标记为“仅堆元组”位。
  • 通常,行版本通过ctid字段链接。

如果在索引扫描期间PostgreSQL访问表页面并找到一个标记为Heap Hot Updated的元组,则它理解它不应停止,而必须遵循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) 

页面中有一系列更改:

  • 堆热更新标志指示必须遵循ctid链。
  • 仅堆元组标志指示未从索引引用该元组。

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

 => 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) 

但是索引中只有一个引用链的头:

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

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

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

HOT更新期间的页内真空


HOT更新期间的清理是页面内清理的一种特殊但重要的情况。

和以前一样,我们已经超过了fillfactor阈值,因此下一次更新必须引起页内真空。 但是这次页面中有一系列更新。 由于索引已引用该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),因为肯定没有对该元组的引用,并且指针已释放(“未使用”状态)。

让我们再进行几次更新:

 => 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) 

下次更新会再次导致页内清理:

 => 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参数可能会有意义,以便为更新保留一些页面空间。 但是,我们应该考虑到fillfactor越小,页面中剩余的可用空间就越大,因此表的物理大小会增加。

HOT链断裂


如果页面缺少可用空间来分配新的元组,则链将中断。 而且,我们将不得不从索引到位于不同页面的行版本进行单独的引用。

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

 | => 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) 

在下一次更新时,页面将没有足够的空间,但是页面内清理将无法清理任何东西:

 => 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) 

不幸的是,该文档实际上缺少有关页内真空和HOT更新的信息,因此您应该在源代码中寻找答案。 我建议您从README.HOT开始。

继续阅读

Source: https://habr.com/ru/post/zh-CN483768/


All Articles