提醒一下,我们已经讨论了与隔离相关的问题,对低级数据结构进行了题外话,然后探索了行版本并观察了如何从行版本中获取数据快照。
现在我们将着手解决两个密切相关的问题:页内vacuum和热点更新。这两种技术都可以称为优化;它们很重要,但实际上并未包含在文档中。
经常更新期间的页内vacuum
在访问页面以进行更新或读取时,如果 PostgreSQL 了解到页面空间不足,它可以执行快速的页内vacuum。这发生在以下任一情况下:
此页面中的先前更新找不到足够的空间来在同一页面中分配新的行版本。这样的情况在页头中被记住,下次该页被vacuum。
页面
fillfactor
已满。在这种情况下,立即进行vacuum,而不会推迟到下一次。
fillfactor
存储表定义(和索引)的参数。仅当页面小于fillfactor
(填充因子)百分比时,PostgresSQL 才会在页面中插入新行。剩余空间保留给由于更新而创建的新元组。表的默认值为 100,即不保留空间(索引的默认值为 90)。
页内vacuum删除在任何快照中都不可见的元组(那些超出数据库事务范围的元组,上次讨论过),但严格在一个表页内执行此操作。指向vacuum元组的指针不会被释放,因为它们可以从索引中引用,并且索引在另一个页面中。页内vacuum永远不会超过一个表页,但执行的非常快。
出于同样的原因,空闲空间区域不会更新;这也为更新而不是插入保留了额外的空间。可见性区域也不会更新。
在读取期间可以清空页面的事实意味着 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;
复制
让我们来看看页内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
和upper
值之间的差异可以清楚地看出这一点:它超过了等于页面大小 75% 的阈值,即 6144 字节。
=> SELECT lower, upper, pagesize FROM page_header(get_raw_page('hot',0));
lower | upper | pagesize
-------+-------+----------
40 | 64 | 8192
(1 row)
复制
因此,下次访问该页面时,必须发生页内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) 都被vacuum清空;之后,在释放的空间中添加一个新元组 (0,5)。
幸存下来的元组被物理地移向页面的高地址,这样所有的空闲空间都由一个连续的区域表示。指针的值相应地改变。多亏了这一点,页面中的可用空间碎片化不会出现问题。
无法释放指向vacuum元组的指针,因为它们是从索引页引用的。让我们看一下hot_s
索引的第一页(因为第 0 页被元信息占用):
=> 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 会更改索引页中的指针状态,以便不再访问表页。)
重要的是,页内vacuum只能在一个表页内工作,不能vacuum索引页。
热点updates
为什么在索引中存储对所有行版本的引用是不好的?
首先,对于行的任何更改,必须更新为表创建的所有索引:一旦创建了新版本,就需要引用它。在任何情况下我们都需要这样做,即使更改了未编入索引的字段。这显然不是很有效。
其次,索引积累了对历史元组的引用,然后需要将其与元组本身一起清除(稍后我们将讨论如何做到这一点)。
此外,PostgreSQL 中的 B-tree 具有实现细节。如果一个索引页没有足够的空间来插入新行,那么该页就会被一分为二,所有的数据都分布在它们之间。这称为页面的拆分。但是,删除行时,两个索引页不会合并为一个。因此,即使删除了相当一部分数据,索引大小也可能无法减小。
自然,在表上创建的索引越多,遇到的复杂性就越多。
但是,如果在根本没有索引的列中更改了值,则创建包含相同键值的额外 B 树行是没有意义的。这正是称为热点更新(仅堆元组更新)的优化的工作原理。
在此更新期间,索引页仅包含一行,它引用表页中该行的第一个版本。它已经在表格页面中,组织了一个元组链:
链中的更新行标有堆热更新位。
未从索引中引用的行标有 Heap Only Tuple 位。
像往常一样,行版本通过
ctid
字段链接。
如果在索引扫描期间,PostgreSQL 访问一个表页并找到一个标记为 Heap Hot Updated 的元组,它会理解它不应该停止,但必须遵循 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 标志表示这个元组没有被索引引用。
链将随着进一步变化而增长(在页面内):
=> 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)
复制
强调一下,热点更新在要更新的字段根本没有索引的情况下起作用。否则,某些索引将直接包含对新行版本的引用,这与此优化的概念不兼容。
优化只在一个页内起作用,因此,额外的遍历链不需要访问其他页,也不会影响性能。
页内vacuum期间的热点updates
在热点更新期间vacuum是页内vacuum的一个特殊但重要的情况。
和以前一样,我们已经超过了fillfactor
阈值,因此下一次更新必须引起页内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) 被vacuum。
头指针 (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)
复制
下一次更新再次导致页内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)
复制
同样,一些元组被vacuum,指向链头的指针也相应地移动。
结论:如果未编入索引的列经常更新,则减少fillfactor
参数以保留一些页面空间用于更新可能是有意义的。但是,我们应该考虑到fillfactor
越小,页中剩余的可用空间越多,表的物理大小就会增加。
热链中断
如果页面缺乏分配新元组的可用空间,则链将中断。我们将不得不从索引中单独引用位于不同页面中的行版本。
为了重现这种情况,让我们启动一个并发事务并在其中构建数据快照。
| => 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)
复制
在下一次更新时,页面将没有足够的空间,但页面内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)
复制
现在索引中有两行,每一行都指向其热链的开头:
=> SELECT * FROM index_page('hot_id',1);
itemoffset | ctid
------------+-------
1 | (1,1)
2 | (0,1)
(2 rows)
复制
不幸的是,该文档实际上缺乏有关页内vacuum和 热点更新的信息,您应该在源代码中寻找答案。我建议你从README.HOT开始。(https://git.postgresql.org/gitweb/?p=postgresql.git;a=blob;f=src/backend/access/heap/README.HOT;hb=HEAD)