事务ID包装
PostgreSQL使用32位事务ID。 这是一个相当大的数字(大约40亿),但是随着服务器的大量工作,这个数字并不是不可能被耗尽。例如:每秒处理1000次事务,这种情况最少要在一个半月的连续工作中发生。
但是我们已经提到,多版本并发控制依赖于顺序编号,这意味着在两个事务中,数值较小的事务可以被认为是较早开始的。 因此,很明显,重置计数器并从头开始编号不是一个选择。
但是为什么不使用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»操作)
但是这种循环的安排很麻烦。一个在past,距离很远的事务(图中的事务1),一段时间后将进入与未来相关的圆的一半。这当然会破坏可见性规则并导致问题:事务1所做的更改将会消失在视线之外。
元组的freeze和可见性原则
为了防止这种从past到future的“旅行”发生,vacuum操作还需要执行另一项任务(除了释放页面空间之外)。它会找到相当old和“cold”元组(在所有快照中可见,并且不太可能更改),并以特殊的方式标记它们,即“freeze”它们。冻结的元组被认为比任何普通数据都旧,并且在所有快照中始终可见。并且不再需要查看xmin事务编号,并且可以安全地重用该编号。 因此冻结的元组始终保留在过去。
为了跟踪冻结的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/