十二月 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_age
− vacuum_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 不仅会读取整个表,还会完全重写它以设置所需的标志位。