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

我们已经介绍了隔离性,行级别的数据结构,行版本以及如何基于行版本获取数据快照。本文我们将介绍两个密切相关的话题:页面内 vacuum 和 HOT 更新。这两项技术均与优化相关,它们很重要,但在官方文档中却鲜有提及。

常规更新时的页内 vacuum

当访问或者更新一个页面时,如果 PostgreSQL 意识到页面空间不足,会对其进行快速的页面内 vacuum,这通常发生在以下两种场景:

  1. 页面的上一次更新未能在该页面分配新的行。此种情形会被记录在页面头信息中,下次该页面将会被 vacuum。
  2. 页面的使用率已经超过了 fillfactor 设置的填充率,则会立即进行 vacuum,而不会推迟到下一次。

fillfactor 是在创建表或索引时可以指定的一个存储属性,PostgreSQL 仅在页面空间使用率小于 fillfactor 时才会在该页面中插入一行新数据,剩余空间用于更新时产生的新元组。对于表而言,默认的 fillfactor 是 100,即没有预留空间,索引的默认值是 90。

页内 vacuum 会删除表的一个数据页中任何快照均不可见的元组,索引指向被清理的元组的指针不会被释放。页内 vacuum 永远在一个页面内工作,因此其效率很高。同样的原因,FSM 页和 VM 页均不会更新,

在读取页面时可能会对其进行清理,这意味着在执行 SELECT 查询时可能会修改数据页。除了前文提到的 Hint Bit 的延迟更改之外,这是另外一例会在查询时修改数据页的情况。

让我们举个例子来看看它是如何运行的。建表和索引,如下:

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

如果 s 列仅存储拉丁字符,则每行将占用 2004 字节,每行的头信息占用 24 字节。我们设置 fillfactor 为 75%,这样页面中预留的空间足够存放 3 行数据。

为了方便查看数据页内容,创建如下函数:

  1. => CREATE FUNCTION heap_page(relname text, pageno integer)
  2. RETURNS TABLE(ctid tid, state text, xmin text, xmax text, hhu text, hot text, t_ctid tid)
  3. AS $$
  4. SELECT (pageno,lp)::text::tid AS ctid,
  5. CASE lp_flags
  6. WHEN 0 THEN 'unused'
  7. WHEN 1 THEN 'normal'
  8. WHEN 2 THEN 'redirect to '||lp_off
  9. WHEN 3 THEN 'dead'
  10. END AS state,
  11. t_xmin || CASE
  12. WHEN (t_infomask & 256) > 0 THEN ' (c)'
  13. WHEN (t_infomask & 512) > 0 THEN ' (a)'
  14. ELSE ''
  15. END AS xmin,
  16. t_xmax || CASE
  17. WHEN (t_infomask & 1024) > 0 THEN ' (c)'
  18. WHEN (t_infomask & 2048) > 0 THEN ' (a)'
  19. ELSE ''
  20. END AS xmax,
  21. CASE WHEN (t_infomask2 & 16384) > 0 THEN 't' END AS hhu,
  22. CASE WHEN (t_infomask2 & 32768) > 0 THEN 't' END AS hot,
  23. t_ctid
  24. FROM heap_page_items(get_raw_page(relname,pageno))
  25. ORDER BY lp;
  26. $$ LANGUAGE SQL;

同样,创建如下查看索引页的函数:

  1. => CREATE FUNCTION index_page(relname text, pageno integer)
  2. RETURNS TABLE(itemoffset smallint, ctid tid)
  3. AS $$
  4. SELECT itemoffset,
  5. ctid
  6. FROM bt_page_items(relname,pageno);
  7. $$ LANGUAGE SQL;

现在来看看页内 vacuum 是如何运行的。我们插入一行数据,然后多次修改它:

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

现在,在数据页中有四个元组:

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

不出所料,我们恰好超过了 fillfactor 设置的阈值。从如下 pagesizeupper 的值可以看出,以上插入和更新的数据已经超过了数据页的 75%(6144 字节)。

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

因此,再次访问该页面时必然会触发页内 vacuum,如下:

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

所有死元组 (0,1), (0,2) 和 (0,3) 都被清理,新元组 (0,5) 插入被释放的空间。

未被清理的元组被移动至页面的高地址区域,这使得页面中所有空闲空间在一个连续的区域。

HOT 更新

HOT 更新时的页内 vacuum

HOT 链断裂

如果页面空间不足以分配一个新元组,则 HOT 链将会断裂。我们将不得不在索引中创建一个指向不同页上的行版本的索引项。

为复现这种场景,我们开启一个并发事务,在其中构建一个数据快照:

  1. | => BEGIN ISOLATION LEVEL REPEATABLE READ;
  2. | => SELECT count(*) FROM hot;
  3. | count
  4. | -------
  5. | 1
  6. | (1 row)

该快照将阻止页面中的元组被清理。现在,我们在第一个会话中做一些更新操作:

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

下次更新时,页面空间不足,但页面内的 vacuum 清理不掉任何内容:

  1. => UPDATE hot SET s = 'L';
  1. | => COMMIT; -- snapshot no longer needed
  1. => SELECT * FROM heap_page('hot',0);
  2. ctid | state | xmin | xmax | hhu | hot | t_ctid
  3. -------+---------------+----------+----------+-----+-----+--------
  4. (0,1) | redirect to 2 | | | | |
  5. (0,2) | normal | 3993 (c) | 3994 (c) | t | t | (0,3)
  6. (0,3) | normal | 3994 (c) | 3995 (c) | t | t | (0,4)
  7. (0,4) | normal | 3995 (c) | 3996 (c) | t | t | (0,5)
  8. (0,5) | normal | 3996 (c) | 3997 | | t | (1,1)
  9. (5 rows)

元组 (0, 5) 有指向页面 1 中的 (1, 1) 。

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

现在,索引中有两行数据,分别指向各自 HOT 链的起始点:

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

PostgreSQL 官方文档中并没有页面内 vacuum 和 HOT 更新的信息,更多相关信息需要从源码中获取。建议从 README.HOT 开始。