让我提醒您,我们研究了与
隔离有关的问题,对
低级组织数据进行了讨论,然后详细
讨论了行版本以及如何从版本中获取
快照 。
今天,我们将处理两个密切相关的问题:
页内清理和
HOT更新 。 两种机制都可以归类为优化。 它们很重要,但用户文档中几乎没有涉及它们。
定期更新页内清洁
在访问页面时(在更新期间和在读取过程中),如果PostgreSQL知道页面空间不足,则可以快速进行页面内清理。 在两种情况下会发生这种情况。
- 先前在此页面上执行的更新(UPDATE)找不到足够的空间来在同一页面上放置新版本的行。 在页面标题中会记住这种情况,在下次清除该页面时。
- 该页面的填充量大于fillfactor上的填充量。 在这种情况下,清洁将立即进行,而不会延迟下一次。
Fillfactor是可以为表(和索引)定义的存储参数。 PostgreSQL仅在该页面小于填充百分比或填充百分比时才在该页面上插入新行(INSERT)。 剩余空间保留用于更新(UPDATE)产生的字符串的新版本。 表的默认值为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)
不出所料,我们刚刚超过了填充因子阈值。 页面大小和上限值之间的差异表明了这一点:它超过了页面大小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中还有一个实现B树的功能。 如果索引页上没有足够的空间来插入新行,则将该页分为两部分,并在它们之间重新分配所有数据。 这称为拆分页面。 但是,删除行时,两个索引页不再“粘在一起”。 因此,即使删除了大部分数据,索引的大小也可能不会减小。
自然,在表上创建的索引越多,您面临的困难就越大。
但是,如果不属于任何索引的列的值发生更改,则没有必要在B树中创建包含相同键值的其他记录。 这就是优化的工作方式,称为HOT更新-纯堆元组更新。
通过此更新,索引页中只有一个条目引用表页中该行的第一个版本。 并且已经在此表格页面内组织了一系列版本:
- 更改并包含在链中的字符串标有“堆热更新”位;
- 未从索引引用的行标有“仅堆元组”位(即,“仅行的表格形式”);
- 支持通过ctid字段常规链接字符串版本。
如果在扫描索引时PostgreSQL进入一个表格页面并发现标记为“堆热更新”的版本,则说明它不需要停止并在整个更新链中走得更远。 当然,对于以这种方式获得的所有版本的字符串,在将其返回给客户端之前都要检查可见性。
要查看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更新期间进行清洁。
与上次一样,我们已经超过了填充因子阈值,因此下一次更新将导致页内清理。 但是页面中的这次是一系列更新。 该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参数以在页面上保留一些空间来进行更新可能是有意义的。 当然,我们必须考虑到填充因子越低,页面上剩余的未分配空间就越大,因此表的物理大小会增加。
热断链
如果页面上没有足够的可用空间来发布新版本的行,则链条将断开。 在另一页上发布的行的版本必须与索引建立单独的链接。
为了解决这种情况,我们开始并行事务并在其中构建数据快照。
| => 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;
=> 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开始。
待续 。