https://habr.com/en/company/postgrespro/blog/479512/
我们已经介绍了隔离性,行级别的数据结构,行版本以及如何基于行版本获取数据快照。本文我们将介绍两个密切相关的话题:页面内 vacuum 和 HOT 更新。这两项技术均与优化相关,它们很重要,但在官方文档中却鲜有提及。
常规更新时的页内 vacuum
当访问或者更新一个页面时,如果 PostgreSQL 意识到页面空间不足,会对其进行快速的页面内 vacuum,这通常发生在以下两种场景:
- 页面的上一次更新未能在该页面分配新的行。此种情形会被记录在页面头信息中,下次该页面将会被 vacuum。
- 页面的使用率已经超过了
fillfactor
设置的填充率,则会立即进行 vacuum,而不会推迟到下一次。
fillfactor
是在创建表或索引时可以指定的一个存储属性,PostgreSQL 仅在页面空间使用率小于 fillfactor
时才会在该页面中插入一行新数据,剩余空间用于更新时产生的新元组。对于表而言,默认的 fillfactor
是 100,即没有预留空间,索引的默认值是 90。
页内 vacuum 会删除表的一个数据页中任何快照均不可见的元组,索引指向被清理的元组的指针不会被释放。页内 vacuum 永远在一个页面内工作,因此其效率很高。同样的原因,FSM 页和 VM 页均不会更新,
在读取页面时可能会对其进行清理,这意味着在执行 SELECT 查询时可能会修改数据页。除了前文提到的 Hint Bit 的延迟更改之外,这是另外一例会在查询时修改数据页的情况。
让我们举个例子来看看它是如何运行的。建表和索引,如下:
=> 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%,这样页面中预留的空间足够存放 3 行数据。
为了方便查看数据页内容,创建如下函数:
=> 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) 都被清理,新元组 (0,5) 插入被释放的空间。
未被清理的元组被移动至页面的高地址区域,这使得页面中所有空闲空间在一个连续的区域。
HOT 更新
HOT 更新时的页内 vacuum
HOT 链断裂
如果页面空间不足以分配一个新元组,则 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)
下次更新时,页面空间不足,但页面内的 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)
PostgreSQL 官方文档中并没有页面内 vacuum 和 HOT 更新的信息,更多相关信息需要从源码中获取。建议从 README.HOT 开始。