PostgreSQL 教程: 管理冻结

十二月 12, 2024

摘要:在本教程中,您将学习如何在 PostgreSQL 中管理冻结。

目录

冻结的最小年龄

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

让我们从vacuum_freeze_min_age开始,它定义了可以冻结元组的xmin事务的最小年龄。该值越小,额外的开销可能就越多:如果我们处理“热”的、经常更改的数据,新的和较新的元组的冻结工作将会付诸东流。在这种情况下,最好多等待一些时间。

该参数的默认值指定,一个事务发生以后,在经过 5000 万个其他事务后,事务会开始被冻结:

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

为了观察冻结,让我们将该参数的值减小到 1。

ALTER SYSTEM SET vacuum_freeze_min_age = 1;

SELECT pg_reload_conf();

让我们更新零号页面上的一行。由于页面填充因子较小,新的行版本会放入同一页面。

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)

现在,早于vacuum_freeze_min_age = 1 的行将会被冻结。但请注意,在可见性映射表中不会跟踪零行页面,更改页面的 UPDATE 命令会重置可见性标志位,而第一页还是被跟踪了:

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

清理过程只会查看可见性映射表中未跟踪的页面。情况就是这样:

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)

在零号页面上,一个行版本被冻结,但 VACUUM 根本没有查看第一页。因此,如果页面上只留下活动元组,则 VACUUM 操作将不会访问该页面,也不会冻结它们。

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 操作的页面上,冻结留下的元组,PostgreSQL 提供了第二个参数:vacuum_freeze_table_age。它定义了 VACUUM 会忽略可见性映射表,并查看所有表页面以进行冻结的事务年龄。

每个关系都存储了一个事务 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 以前,每次 VACUUM 都会对表进行全表扫描,以确定访问过所有的页面。对于大型表,该操作时间长且工作量大。更糟糕的情况是,如果 VACUUM 没有完成(例如,一个不耐烦的管理员中断了命令),还必须从头开始这个过程。

从 9.6 版本开始,引入了一个 “全都已冻结” 的标志位(我们可以在pg_visibility_map输出的all_frozen列中看到它),VACUUM 只会处理没有设置该标志位的页面。这不仅确保了工作量相当小,而且保证了中断的容忍度:如果 VACUUM 进程停止并重新启动,则它不必再次去查看上次已设置冻结标志位的页面。

无论如何,所有表的页面,都会在每次发生(vacuum_freeze_table_agevacuum_freeze_min_age)个事务后被冻结一次。使用默认值时,每 100 万个事务会冻结一次:

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

因此,很明显,将vacuum_freeze_min_age的值设置太大,并不是一个好的做法,因为这会增加清理的开销,而不是降低开销。

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

ALTER SYSTEM SET vacuum_freeze_table_age = 5;

SELECT pg_reload_conf();

让我们进行冻结:

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)

激进式冻结的年龄

元组的及时冻结是必不可少的。如果未冻结而面对进入到未来时间的风险,PostgreSQL 将会停机,以防止可能出现的问题。

为什么会这样?原因有很多。

  • autovacuum 是可以关闭的,这样 VACUUM 也就不会启动了。
  • 即使启用了 autovacuum,它也不会去访问未使用的数据库(请记住track_counts参数,还有 “template0” 数据库)。
  • 清理会跳过那些只添加数据,而不删除或更改数据的表。

为了应对这些问题,提供了激进式冻结,它由autovacuum_freeze_max_age参数控制。如果在某个数据库中的表,可能具有早于该参数指定的年龄的未冻结事务,则会启动强制自动清理(即使它已关闭),这样进程迟早会处理到有问题的表(无论通常的判据如何)。

默认的值非常保守:

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

参数autovacuum_freeze_max_age的阈值为 20 亿个事务,使用的值要小 10 倍。这是有意义的:如果将该值调大,我们也会增加 autovacuum 的风险,即在剩余的时间间隔内无法冻结所有必要的行。

此外,该参数的值决定了事务状态目录 pg_xact 的大小:由于系统不得保留可能需要找出其状态的旧事务,因此自动 autovacuum 会删除不需要的事务状态文件,来释放空间。

让我们通过 “tfreeze” 的例子来看看 VACUUM 如何处理仅追加的表。此表的 autovacuum 已关闭,但即使这样也不会妨碍。

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

因此,我们将在表级别设置autovacuum_freeze_max_age(并同时将填充因子恢复为正常的值)。遗憾的是,可能的最小值为 100000:

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

不幸的是,因为我们将不得不执行 100000 个事务,才能重现我们感兴趣的情况。但是对于实际使用来说,这肯定已是一个极低的值了。

由于我们要添加数据,让我们向表中插入 100000 行,每行的插入都是在自己的事务中进行。同样,请注意,在实际场景中应避免这样做。但我们只是在研究,所以我们可以这样做。

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)

手动冻结

有时,手动控制冻结似乎比依赖 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 操作首先访问该表时,它必须再次处理所有表页面,并创建可见性映射表。更糟糕的是,数据页在自己的头部中具有 “全都可见” 的标志符,因此,VACUUM 不仅会读取整个表,还会完全重写它以设置所需的标志位。

了解更多

PostgreSQL 管理