0

[译文] PostgreSQL 中的 MVCC — Transaction ID环绕和冻结

344

我们从与隔离相关的问题开始,低级数据结构进行了题外话,详细讨论了行版本,并观察了如何从行版本中获取数据快照

然后,我们覆盖不同的吸尘技术:在页面的真空(热更新一起),真空自动清理

现在我们已经到达了本系列的最后一个主题。我们将讨论事务 ID 环绕和冻结。

交易 ID 环绕

PostgreSQL 使用 32 位事务 ID。这是一个相当大的数字(大约 40 亿),但随着服务器的密集工作,这个数字不太可能被耗尽。例如:以每秒 1000 笔交易的工作量,这种情况最早会在连续工作一个半月后发生。

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

但是为什么不使用 64 位事务 ID - 它不会完全消除这个问题吗?问题是每个元组的标头(如前所述)存储两个事务 ID:xminxmax标头非常大 - 至少 23 个字节,并且位大小的增加将导致标头增加额外的 8 个字节。而这完全是没有道理的。

64位的事务ID都在我们公司的Postgres的专业企业的产品实现,但他们都不太公平有:xminxmax仍然是32位,和页眉存储,这是很常见的整个页面“时代的开始” .

那该怎么办呢?不要按顺序(作为数字)对交易 ID 进行排序,而是想象一个圆圈或一个钟盘。事务 ID 的比较与比较时钟读数的意义相同。也就是说,对于每笔交易,“逆时针”一半的交易 ID 被认为是属于过去的,而“顺时针”部分被认为是属于未来的。

事务的年龄定义为自事务在系统中发生时运行的事务数(与事务 ID 环绕无关)。为了确定一个交易是否比另一个更老,我们比较它们的年龄而不是 ID。(顺便说一句,正是出于这个原因,没有为xid数据类型定义“更大”和“更少”操作。)

但是这种环状排列很麻烦。一个遥远的过去的交易(图中的交易1),一段时间后将进入与未来相关的半圈。这当然会破坏可见性规则并会导致问题:事务 1 所做的更改将不可见。

Tuple冻结和可见性规则

为了防止这种从过去到未来的“旅行”,吸尘还做了一项任务(除了释放页面空间)。它会找到非常旧的“冷”元组(在所有快照中都可见并且不太可能更改)并以特殊方式标记它们,即“冻结”它们。冻结的元组被认为比任何普通数据都旧,并且在所有快照中始终可见。并且不再需要查看xmin交易编号,并且可以安全地重复使用该编号。所以,冻结元组总是保留在过去。

为了跟踪xmin冻结事务,设置了两个提示位:committedaborted

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

让我们为实验创建一个表。让我们为它指定最小填充因子,使得每页上只有两行——这让我们更方便地观察正在发生的事情。让我们也关闭自动吸尘器以自行控制吸尘时间。

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

我们已经创建了一些使用“pageinspect”扩展来显示位于页面上的元组的函数的变体。我们现在将创建此函数的另一个变体:它将一次显示多个页面并输出xmin交易的年龄(使用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_ctidFROM generate_series(pageno_from, pageno_to) p(pageno),     heap_page_items(get_raw_page(relname, pageno))ORDER BY pageno, lp;$$ LANGUAGE SQL;

请注意,设置的提示位committedaborted提示位都表示冻结(我们用括号中的“f”表示)。多个来源(包括文档)提到了一个专门的 ID 来指示冻结的事务:FrozenTransactionId = 2。这个方法在早于 9.4 的 PostgreSQL 版本中就存在,现在它被提示位替换。这样可以在元组中保留初始交易号,便于维护和调试。但是,您仍然可以在旧系统中遇到 ID = 2 的事务,甚至升级到最新版本。

我们还需要“pg_visibility”扩展,它使我们能够查看可见性图:

=> CREATE EXTENSION pg_visibility;

在 9.6 之前的 PostgreSQL 版本中,可见性图每页包含一位;该地图仅跟踪具有“相当旧”行版本的页面,这些页面肯定在所有数据快照中都可见。这背后的想法是,如果在可见性映射中跟踪页面,则不需要检查其元组的可见性规则。

从 9.6 版开始,每个页面的全部冻结位被添加到可见性图中。all-frozen 位跟踪所有元组都被冻结的页面。

让我们在表中插入几行,并立即对要创建的可见性图进行清理:

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

我们可以看到,已知这两个页面都是可见的,但不是完全冻结的:

=> 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)

创建行 ( 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)

冷冻的最低年龄

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

让我们从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)

在零页上,一个版本被冻结,但吸尘根本没有查看第一页。因此,如果页面上只剩下活动元组,则吸尘将无法访问此页面,也不会冻结它们。

=> 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_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 之前,每次vacuuming 都会对一张表进行全盘扫描,以确保访问所有页面。对于大型表,此操作既漫长又令人伤心。情况更糟,因为如果吸尘没有完成(例如,一个不耐烦的管理员中断了命令),则该过程必须从一开始就开始。

从 9.6 版开始,由于全冻结位(我们可以all_frozenpg_visibility_map输出列中看到),抽真空仅通过尚未设置该位的页面。这不仅确保了相当少的工作量,而且确保了中断容限:如果一个真空进程停止并重新启动,它就不必再次查看上次已经设置了全部冻结位的页面。

总之,所有的表页面获得冻结一次(vacuum_freeze_table_age - vacuum_freeze_min_age)交易。使用默认值,这会在百万次交易中发生一次:

=> 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参数控制如果某个数据库中的表可能有一个比 age 参数中指定的年龄更旧的未冻结事务,则会启动强制自动清理(即使它已关闭)并且进程迟早会到达有问题的表(无论通常情况如何)标准)。

默认值非常保守:

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

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

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

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

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

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

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

不幸的是 - 因为我们将不得不执行 100 000 次交易来重现感兴趣的情况。但对于实际使用来说,这当然是一个极低的值。

由于我们要添加数据,让我们在表中插入 100 000 行,每行都在自己的事务中。再次请注意,您应该避免在真实情况下这样做。但我们只是在研究,所以我们是被允许的。

=> 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)

但是现在如果我们等待一段时间,消息 log on 中会出现一条记录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 冻结技术,但我们将在讨论锁定之前先讨论它,以避免走得太远。

手动冷冻

有时,手动控制冻结比依赖自动清扫更方便。

您可以通过 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	FOO2	BAR3	BAZ\.
=> COMMIT;

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

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

但由于此类数据加载不太可能经常发生,因此这几乎不是问题。

更糟糕的是,COPY WITH FREEZE 不适用于可见性映射 - 加载的页面不会被跟踪为仅包含对所有人可见的元组。因此,当一个vacuum 操作首先访问表时,它必须再次处理所有表并创建可见性图。更糟糕的是,数据页在它们自己的标题中有全可见的指示符,因此,vacuating 不仅读取整个表,而且还完全重写它以设置所需的位。

此功能最终登陆 PostgreSQL 14。

结论

PostgreSQL 中的隔离和多版本并发控制系列文章到此结束。感谢您的关注,尤其是评论。它们有助于改进内容,并经常发现需要我更加关注的领域。

和我们在一起,还有更多!

叶戈尔·罗戈夫



「喜欢文章,快来给作者赞赏墨值吧」
【版权声明】本文为墨天轮用户原创内容,转载时必须标注文章的来源(墨天轮),文章链接,文章作者等基本信息,否则作者和墨天轮有权追究责任。如果您发现墨天轮中有涉嫌抄袭或者侵权的内容,欢迎发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论