PostgreSQL的MVCC(8)--Freezing

事务ID包装

PostgreSQL使用32位事务ID。 这是一个相当大的数字(大约40亿),但是随着服务器的大量工作,这个数字并不是不可能被耗尽。例如:每秒处理1000次事务,这种情况最少要在一个半月的连续工作中发生。

但是我们已经提到,多版本并发控制依赖于顺序编号,这意味着在两个事务中,数值较小的事务可以被认为是较早开始的。 因此,很明显,重置计数器并从头开始编号不是一个选择。

PostgreSQL的MVCC(8)--Freezing

 

但是为什么不使用64位的事务id——它不会完全消除这个问题吗?问题是每个元组的header(如前所述)存储两个事务id: xmin和xmax。header部分相当大,因为它至少23个字节,扩大事务id将导致header增加额外的8个字节,完全没有理由这么做。

但是,为什么不使用64位事务处理ID-不就能完全消除问题?问题是每个元组的标头(如前所述)存储两个事务ID:xmin和xmax。标头相当大-至少23个字节,并且位大小的增加将使标头增加额外的8个字节。这是完全没有道理的。

那该怎么办呢?与其按顺序(按数字)顺序排列事务标id,不如想象一个圆圈或一个钟盘。以与比较时钟读数相同的方式比较事务ID。也就是说,对于每个事务,事务ID的“逆时针”部分被认为与过去有关,而“顺时针”部分被视为与未来有关。

事务的age定义为自系统中发生事务以来(不考虑事务ID绕行)开始运行的事务数。为了确定一个事务是否比另一个事务更早,我们比较了它们的age而不是ID。 (顺便说一下,正是由于这个原因,没有为xid数据类型定义 «greater»和«less»操作)

PostgreSQL的MVCC(8)--Freezing

但是这种循环的安排很麻烦。一个在past,距离很远的事务(图中的事务1),一段时间后将进入与未来相关的圆的一半。这当然会破坏可见性规则并导致问题:事务1所做的更改将会消失在视线之外。

PostgreSQL的MVCC(8)--Freezing

 

元组的freeze和可见性原则

为了防止这种从past到future的“旅行”发生,vacuum操作还需要执行另一项任务(除了释放页面空间之外)。它会找到相当old和“cold”元组(在所有快照中可见,并且不太可能更改),并以特殊的方式标记它们,即“freeze”它们。冻结的元组被认为比任何普通数据都旧,并且在所有快照中始终可见。并且不再需要查看xmin事务编号,并且可以安全地重用该编号。 因此冻结的元组始终保留在过去。

PostgreSQL的MVCC(8)--Freezing

 

为了跟踪冻结的xmin事务,两个提示位都设置为:commited和aborted。

请注意,xmax事务不需要冻结。它的存在表明该元组不再是live的。 当它不再在数据快照中可见时,该元组将被清除。

让我们为实验创建一个表。为其指定最小填充因子(fillfactor),以便每页仅容纳两行-这使我们更方便地观察正在发生的事情。 让我们也关闭autovacuum以自行控制vacumm时间。

=> CREATE TABLE tfreeze(
  id integer,
  s char(300)
) WITH (fillfactor = 10, autovacuum_enabled = off);

我们已经创建了使用«pageinspect»扩展来显示位于页面上的元组的函数的几个变体。现在我们将创建这个函数的另一个变体:它将一次显示多个页面并输出xmin事务的age(使用age系统函数):

=> CREATE FUNCTION heap_page(relname text, pageno_from integer, pageno_to integer)
RETURNS TABLE(ctid tid, state text, xmin text, xmin_age integer, xmax 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+512) = 256+512 THEN ' (f)'
         WHEN (t_infomask & 256) > 0 THEN ' (c)'
         WHEN (t_infomask & 512) > 0 THEN ' (a)'
         ELSE ''
       END AS xmin,
      age(t_xmin) xmin_age,
       t_xmax || CASE
         WHEN (t_infomask & 1024) > 0 THEN ' (c)'
         WHEN (t_infomask & 2048) > 0 THEN ' (a)'
         ELSE ''
       END AS xmax,
       t_ctid
FROM generate_series(pageno_from, pageno_to) p(pageno),
     heap_page_items(get_raw_page(relname, pageno))
ORDER BY pageno, lp;
$$ LANGUAGE SQL;

注意,committed和aborted的提示位集都表示冻结(我们用圆括号«f»表示)。多个来源(包括文档)提到了一个专门的ID来表示冻结的事务:FrozenTransactionId = 2。这个方法在9.4之前的PostgreSQL版本中就已经存在了,现在它被提示位所取代。这允许在元组中保留初始事务号,这便于维护和调试。但是,在旧系统中仍然可以遇到ID = 2的事务,甚至被升级到最新版本也可能遇到。

我们还需要«pg_visibility»扩展,它使我们能够查看visibility map:

=> CREATE EXTENSION pg_visibility;

  

在9.6前的PostgreSQL版本中,visibility map每页只包含一位;这个map只跟踪那些有«pretty old»行版本的页面,这在所有数据快照中都是可见的。这背后的思想是,如果页面在可见性映射中被跟踪,则不需要检查其元组的可见性规则。

从9.6版本开始,每个页面的all-frozen bit被添加到visibility map中。all-frozen bit跟踪所有元组都被冻结的页面。

让我们在表中插入几行,然后立即对要创建的可见性映射进行vacuum处理:

=> INSERT INTO tfreeze(id, s)
  SELECT g.id, 'FOO' FROM generate_series(1,100) g(id);
=> VACUUM tfreeze;

  

我们可以看到,这两个页面都是可见的,但不是all-frozen:

=> SELECT * FROM generate_series(0,1) g(blkno), pg_visibility_map('tfreeze',g.blkno)
ORDER BY g.blkno;
 blkno | all_visible | all_frozen 
-------+-------------+------------
     0 | t           | f
     1 | t           | f
(2 rows)

  

创建行的事务的age(xmin_age)等于1——这是系统中执行的上一个事务:

=> SELECT * FROM heap_page('tfreeze',0,1);
 ctid  | state  |  xmin   | xmin_age | xmax  | t_ctid 
-------+--------+---------+----------+-------+--------
 (0,1) | normal | 697 (c) |        1 | 0 (a) | (0,1)
 (0,2) | normal | 697 (c) |        1 | 0 (a) | (0,2)
 (1,1) | normal | 697 (c) |        1 | 0 (a) | (1,1)
 (1,2) | normal | 697 (c) |        1 | 0 (a) | (1,2)
(4 rows)

  

Minimum age for freezing

控制freeze的主要参数有三个,我们将逐一讨论。

让我们从vacuum_freeze_min_age开始,它定义了元组可以被冻结的xmin事务的最小age。这个值越小,额外的开销可能就越多:如果我们处理热数据,密集更新,冻结新的和更新的元组将会很难。在这种情况下最好等一等。

这个参数的默认值指定了一个事务开始被冻结时,自它发生以来有5000万个其他事务在运行:

=> SHOW vacuum_freeze_min_age;
 vacuum_freeze_min_age 
-----------------------
 50000000
(1 row)

  

为了观察freeze,让我们将该参数的值降低为1。

=> ALTER SYSTEM SET vacuum_freeze_min_age = 1;
=> SELECT pg_reload_conf();

  

更新第0页上的一行。新版本将在相同的页面,因为小的填充因子。

=> UPDATE tfreeze SET s = 'BAR' WHERE id = 1;

  

这是我们现在在数据页上看到的:

=> SELECT * FROM heap_page('tfreeze',0,1);
 ctid  | state  |  xmin   | xmin_age | xmax  | t_ctid 
-------+--------+---------+----------+-------+--------
 (0,1) | normal | 697 (c) |        2 | 698   | (0,3)
 (0,2) | normal | 697 (c) |        2 | 0 (a) | (0,2)
 (0,3) | normal | 698     |        1 | 0 (a) | (0,3)
 (1,1) | normal | 697 (c) |        2 | 0 (a) | (1,1)
 (1,2) | normal | 697 (c) |        2 | 0 (a) | (1,2)
(5 rows)

  

在0页面上,有一个版本被冻结,但是vacuum操作根本没有查看第1页。因此,如果页面上只剩下活动元组,那么清理将不会访问此页面,也不会冻结它们。

=> SELECT * FROM generate_series(0,1) g(blkno), pg_visibility_map('tfreeze',g.blkno)
ORDER BY g.blkno;
 blkno | all_visible | all_frozen 
-------+-------------+------------
     0 | t           | f
     1 | t           | f
(2 rows)

  

我们已经讨论过,vacuum只查看visibility map中没有跟踪的页面。

=> VACUUM tfreeze;
=> SELECT * FROM heap_page('tfreeze',0,1);
 ctid  |     state     |  xmin   | xmin_age | xmax  | t_ctid 
-------+---------------+---------+----------+-------+--------
 (0,1) | redirect to 3 |         |          |       | 
 (0,2) | normal        | 697 (f) |        2 | 0 (a) | (0,2)
 (0,3) | normal        | 698 (c) |        1 | 0 (a) | (0,3)
 (1,1) | normal        | 697 (c) |        2 | 0 (a) | (1,1)
 (1,2) | normal        | 697 (c) |        2 | 0 (a) | (1,2)
(5 rows)

  

在0页面上,有一个版本被冻结,但是vacuum操作根本没有查看第一页。因此,如果页面上只剩下活动元组(live tuples),那么清理将不会访问此页面,也不会冻结它们。

=> SELECT * FROM generate_series(0,1) g(blkno), pg_visibility_map('tfreeze',g.blkno)
ORDER BY g.blkno;
 blkno | all_visible | all_frozen 
-------+-------------+------------
     0 | t           | f
     1 | t           | f
(2 rows)

  

Age to freeze the entire table

为了冻结那些留在页面内,常规vacuum不会扫描的元组,提供了第二个参数:vacuum_freeze_table_age。

每个页面都会存储一个事务id,比该事务老的事务都被认为是冻结的(pg_class.relfrozenxid)。这就是与vacuum_freeze_table_age参数的值进行比较的存储事务的年龄。

=> SELECT relfrozenxid, age(relfrozenxid) FROM pg_class WHERE relname = 'tfreeze';
 relfrozenxid | age 
--------------+-----
          694 |   5
(1 row)

  

在PostgreSQL 9.6之前,每次清理都会对表进行完全扫描,以确保能够访问所有页面。 对于大表,此操作很长。 更糟的是,因为如果无法完成清理工作(例如,急躁的管理员打断了该命令),则该过程必须从头开始。

从9.6版开始,由于使用了all-frozen bit (我们可以在pg_visibility_map输出的all_frozen列中看到),清理仅对尚未设置该位的页面进行。这样不仅可以确保工作量少得多,而且可以确保中断可忍受:如果清理过程停止并重新启动,则不必再次查看上次已将其设置为all-frozen bit的页面。

无论如何,所有表页面每相隔(vacuum_freeze_table_age − vacuum_freeze_min_age)个事务都会冻结一次。 使用默认值时,这种情况每一百万个事务发生一次:

=> SHOW vacuum_freeze_table_age;
 vacuum_freeze_table_age 
-------------------------
 150000000
(1 row)

  

很明显,vacumm_freeze_min_age设置太大不是一个好的选择,因为这将增加开销而不是减少开销。

让我们看看如何冻结整个表,为此,我们将vacuum_freeze_table_age减小为5,以便满足冻结条件。

=> ALTER SYSTEM SET vacuum_freeze_table_age = 5;
=> SELECT pg_reload_conf();

  

开始做freeze:

=> VACUUM tfreeze;

  

现在,由于已经确定检查了整个表,冻结的事务的ID可以增加,因为我们确信页面上没有旧的未冻结的事务。

=> SELECT relfrozenxid, age(relfrozenxid) FROM pg_class WHERE relname = 'tfreeze';
 relfrozenxid | age 
--------------+-----
          698 |   1
(1 row)

现在,第一页上的所有元组都被冻结:

=> SELECT * FROM heap_page('tfreeze',0,1);
 ctid  |     state     |  xmin   | xmin_age | xmax  | t_ctid 
-------+---------------+---------+----------+-------+--------
 (0,1) | redirect to 3 |         |          |       | 
 (0,2) | normal        | 697 (f) |        2 | 0 (a) | (0,2)
 (0,3) | normal        | 698 (c) |        1 | 0 (a) | (0,3)
 (1,1) | normal        | 697 (f) |        2 | 0 (a) | (1,1)
 (1,2) | normal        | 697 (f) |        2 | 0 (a) | (1,2)
(5 rows)

此外,第一页已知是全冻结的:

=> SELECT * FROM generate_series(0,1) g(blkno), pg_visibility_map('tfreeze',g.blkno)
ORDER BY g.blkno;
 blkno | all_visible | all_frozen 
-------+-------------+------------
     0 | t           | f
     1 | t           | t
(2 rows)

  

Age for «aggressive» freezing

及时冻结元组至关重要。如果未冻结的事务可能会遇到一些风险,为防止可能出现的问题PostgreSQL将关闭。

为什么会发生这种情况?原因有很多。

·autovacum可能已关闭,VACUUM也未启动。我们已经提到不应该这样做,但这在技术上是可行的。 ·即使打开了autovacuum,也不会对有些数据库执行(请记住track_counts参数和«template0»数据库)。 ·正如我们上次观察到的,清理操作会跳过仅添加数据但不删除或更改数据的表。

为了响应这些问题,提供了«aggressive» freezing,这由autovacuum_freeze_max_age参数控制。如果某个数据库中的表的未冻结事务可能早于age参数中指定的年龄,则将启动强制autovacuum(即使已关闭),并且迟早会处理到有问题的表。

默认值非常保守:

=> SHOW autovacuum_freeze_max_age;
 autovacuum_freeze_max_age 
---------------------------
 200000000
(1 row)

autovacuum_freeze_max_age的限制为20亿个事务,但使用的值小10倍。这是有道理的:通过增加该值,我们还增加了autovacuum的风险,因为在剩余时间间隔内无法冻结所有必要的行。

此外,此参数的值确定XACT结构的大小:由于系统不得保留可能需要了解其状态的较旧事务,因此,自动清空将通过删除不需要的XACT段文件来释放空间。

让我们看一下vacuum是如何处理只是append-only的表的。该表的autovacuum功能已关闭,但即使这样也不会受到阻碍。

更改autovacuum_freeze_max_age参数要求服务器重新启动。但是,您也可以通过存储参数在单独的表级别设置所有上述参数。通常只有在表确实需要特殊处理的情况下,才有意义。

因此,我们将在表级别设置autovacuum_freeze_max_age(并同时恢复为正常的fillfactor)。不幸的是,最小可能值为100 000:

=> ALTER TABLE tfreeze SET (autovacuum_freeze_max_age = 100000, fillfactor = 100);

不幸的是,因为我们必须执行10万个事务才能重现你所关注的情况。 但是,对于实际使用,这无疑是一个极低的值。

由于我们要添加数据,因此让我们将10万行插入到表中,每个行都有自己的事务。 再次提醒,在实际情况下应避免这样做。 但是我们只是在研究,所以我们被允许。

=> CREATE PROCEDURE foo(id integer) AS $$
BEGIN
  INSERT INTO tfreeze VALUES (id, 'FOO');
  COMMIT;
END;
$$ LANGUAGE plpgsql;

=> DO $$
BEGIN
  FOR i IN 101 .. 100100 LOOP
    CALL foo(i);
  END LOOP;
END;
$$;

我们可以看到,表中最后一个被冻结的事务的年龄超过了阈值:

=> SELECT relfrozenxid, age(relfrozenxid) FROM pg_class WHERE relname = 'tfreeze';
 relfrozenxid |  age   
--------------+--------
          698 | 100006
(1 row)

但是现在如果我们等待一段时间,一个记录将出现在`automatic aggressive vacuum of table "test.public.tfreeze"的消息日志中。冻结事务的数量将会改变,其年龄将不再超出界限:

=> SELECT relfrozenxid, age(relfrozenxid) FROM pg_class WHERE relname = 'tfreeze';
 relfrozenxid | age 
--------------+-----
       100703 |   3
(1 row)

  

还有multixact冻结技术,但我们将在讨论锁之前暂停讨论,以避免讨论得太多。

Freezing manually

有时,手动控制冻结而不是依靠autovacuum似乎很方便。

您可以通过VACUUM FREEZE命令手动启动冻结。它将冻结所有元组,而不理会事务的年龄(就像autovacuum_freeze_min_age参数等于零)。 使用VACUUM FULL或CLUSTER命令重写表时,所有行也将被冻结。

要冻结所有数据库,可以使用该实用程序:

vacuumdb --all --freeze

如果指定了FREEZE参数,则在最初由COPY命令加载数据时也可以将其冻结。 为此,必须在与COPY相同的事务中创建表(或用TRUNCATE命令清空)。

由于可见性规则中冻结的行存在例外,因此这些行将在其他事务的快照中可见,这违反了正常的隔离规则(这与具有“可重复读”或“可序列化”级别的事务有关)。

为确保这一点,在另一个会话中,让我们以“可重复读”隔离级别启动事务:

|  => BEGIN ISOLATION LEVEL REPEATABLE READ;
|  => SELECT txid_current();

注意,该事务创建了数据快照,但没有访问“ tfreeze”表。 现在,我们将截断«tfreeze»表,并在一个事务中将新行加载到该表中。 如果并行事务读取《 tfreeze》的内容,则TRUNCATE命令将被锁定到该事务的末尾。

=> BEGIN;
=> TRUNCATE tfreeze;
=> COPY tfreeze FROM stdin WITH FREEZE;
1  FOO
2  BAR
3  BAZ
\.
=> COMMIT;

现在并发事务看到了新的数据,尽管这违反了隔离:

|  => SELECT count(*) FROM tfreeze;
|   count 
|  -------
|       3
|  (1 row)
|  => COMMIT;

但是,由于这种数据加载不太可能定期发生,因此这几乎不是问题。

更糟糕的是,COPY WITH FREEZE无法与可见性图一起使用-加载的页面没有被跟踪为仅包含所有人可见的元组。 因此,当vacuum操作首先访问该表时,它必须再次处理所有表并创建可见性图。 更糟糕的是,数据页在其自己的header中具有全可见的指示器,因此,清理不仅读取整个表,还完全重写它以设置所需的位。 不幸的是,可以期望不早于版本13(讨论)解决该问题。

 

 

 

 

原文地址:

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

 

 

 

 

  

 

上一篇:调用函数,是否需要加锁


下一篇:ADVANCE.AI人工智能产品助力中国企业出海墨西哥,开启美洲站