PostgreSQL-8中的MVCC。 冻结

我们从与隔离有关的问题开始,对低级组织数据进行了讨论,并详细讨论了行版本以及如何从版本中获取快照

然后,我们检查了不同类型的清洁: 页内 (以及HOT更新), 常规自动

到了这个周期的最后一个话题。 今天我们将讨论事务ID环绕和冻结的问题。

交易计数器溢出


PostgreSQL为事务编号分配了32位。 这是一个相当大的数字(约40亿),但是随着服务器的活跃运行,它很可能会用尽。 例如,在每秒1000个事务的负载下,这种情况只会在连续运行一个半月之后发生。

但是我们谈到了以下事实:多版本控制机制依赖于编号顺序-然后在两个事务中,可以认为编号较小的事务较早开始。 因此,很明显,您不能仅重置计数器并再次继续编号。



为什么没有为事务号分配64位-因为这样可以完全消除问题? 事实是( 如前所述 )在该行的每个版本的标题中都存储了两个事务编号-xmin和xmax。 标头已经很大,至少23个字节,并且位深度的增加将导致其头再增加8个字节。 绝对没有办法。

我们公司的产品Postgres Pro Enterprise中实现了64位交易号,但那里也不完全诚实:xmin和xmax仍然是32位,并且页面标题包含整个页面的通用“时代开始”。

怎么办 代替线性图,所有交易号都是循环的。 对于任何交易,“逆时针”数字的一半都属于过去,而“顺时针”数字的一半则属于未来。

交易的期限是自交易出现在系统中以来经过的交易数量(无论计数器是否通过零)。 当我们想了解某笔交易是否早于另一笔交易时,我们比较它们的年龄而不是数字。 (因此,顺便说一下,没有为xid数据类型定义“更大”和“更少”操作。)



但是在这样的环路中,出现了不愉快的情况。 一段时间后,过去的交易(图中的交易1)将处于与未来有关的那半圈。 当然,这违反了可见性规则,并会导致问题-事务1所做的更改只会从视图中消失。



版本冻结和可见性规则


为了防止这种“旅行”过去和将来,清洁过程(除了释放页面中的空间之外)还执行另一项任务。 他发现线条的过旧和“冷”版本(在所有图片中都可见,并且已经不太可能更改),并以特殊方式标记它们-“冻结”它们。 该行的冻结版本被认为比任何常规数据都旧,并且在所有数据快照中始终可见。 而且,不再需要查看事务编号xmin,并且可以安全地重用该编号。 因此,字符串的冻结版本始终保留在过去。



为了将事务编号xmin标记为冻结,两个提示位(提交位和取消位)同时设置。

请注意,不需要冻结xmax事务。 它的存在意味着该字符串版本不再相关。 在数据快照中不再显示该行之后,将清除该版本的行。

对于实验,创建一个表。 我们为其设置了最小填充因子,以便每页仅容纳两行-这样对于我们观察情况将会更加方便。 并关闭自动控制以自行控制清洁时间。

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

我们已经创建了该函数的多个变体,这些变体使用pageinspect扩展名显示了页面上各行的版本。 现在,我们将创建相同功能的另一个变体:现在它将一次显示多个页面并显示xmin事务的使用期限(为此使用系统功能使用年龄):

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

请注意,冻结的迹象(我们在括号中以字母f表示)由同时安装已提交和已中止的提示确定。 许多来源(包括文档)都提到了特殊数字FrozenTransactionId = 2,该数字表示冻结的交易。 这样的系统在9.4版之前一直运行,但是现在已由工具提示位代替-这使您可以将原始交易号保存在线路版本中,这方便了支持和调试。 但是,编号为2的事务仍然可以在较旧的系统中进行,甚至可以升级到最新版本。

我们还需要pg_visibility扩展,它允许您查看可见性图:

 => CREATE EXTENSION pg_visibility; 

在PostgreSQL 9.6之前,可见性映射每页只包含一位。 它标记了仅包含“相当古老”版本的字符串的页面,这些版本已经保证在所有图片中都可见。 这里的想法是,如果页面在可见性图中标记,那么对于其行的版本,您无需检查可见性规则。

从9.6版开始,将冻结图添加到同一层-每页增加一位。 冻结映射标记所有行版本都被冻结的页面。

我们将几行插入表中,并立即执行清理以创建可见性图:

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

并且我们看到两个页面现在都已在可见性地图中标记为(all_visible),但尚未冻结(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) 

创建行(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。 该值越小,可能导致不必要的间接费用就越多:如果我们处理的是“热”数据,主动更改数据,那么冻结越来越多的新版本将毫无益处。 在这种情况下,最好等待。

此参数的默认值设置为,自从出现其他五千万笔交易以来,交易开始冻结:

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

然后,我们将在零页上更新一行。 由于fillfactor值较小,因此新版本将进入同一页面。

 => 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 。 它确定事务的期限,在该期限内,清理将忽略可见性图,并遍历表的所有页面以进行冻结。

每个表都存储一个事务编号,已知所有冻结的旧事务都将被冻结(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版开始,多亏了冻结映射(我们在pg_visibility_map输出的all_frozen列中看到了),清除仅绕过那些尚未在映射中标记的页面。 这不仅减少了工作量,而且还耐中断:如果清洗过程停止并再次开始,他将不必再次查看他上次已在冻结图中标记的页面。

一种或另一种方式是,表中的所有页面在( 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; 

现在,由于已保证可以验证整个表,因此可以增加冻结事务的数量-我们确信页面没有较旧的未冻结事务。

 => 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将崩溃以防止潜在的问题。

这可能是什么原因? 原因有很多。

  • 可以关闭自动清洁,并且常规清洁也不会开始。 我们已经说过这不是必须的,但从技术上讲是可能的。
  • 甚至包括的自动清除功能也不会出现在未使用的数据库中(请记住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倍。 这是有道理的:增加价值会增加以下风险:在剩余时间内,自动清洗只是没有时间冻结所有必要的生产线版本。

另外,此参数的值确定XACT结构的大小:由于系统中应该没有任何较旧的事务可能需要了解其状态,因此自动清理将删除不必要的XACT段文件,从而释放空间。

让我们以tfreeze为例,看看清理如何处理仅追加表。 对于此表,通常禁用自动清洁功能,但这不会成为障碍。

更改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) 

但是,如果您稍等片刻,则服务器的消息日志中将有一个有关表“ test.public.tfreeze”的自动主动清除的条目,冻结的事务数将发生变化,并且其使用期限将恢复为正态:

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

还有冻结多事务之类的事情,但是我们暂时不会谈论它-我们将推迟到讨论锁之前,以免超越自己。

手动冻结


有时,手动控制冻结比等待自动清洁的到来更方便。

您可以使用VACUUM FREEZE命令手动冻结命令-所有行版本都将被冻结,而与事务的期限无关(就像autovacuum_freeze_min_age = 0参数一样)。 使用VACUUM FULL或CLUSTER命令重建表时,所有行也会被冻结。

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

 vacuumdb --all --freeze 

通过指定FREEZE参数,还可以使用COPY命令在初始加载期间冻结数据。 为此,必须在同一位置创建表(或用TRUNCATE命令清空)。
交易为COPY。

由于冻结行具有单独的可见性规则,因此这些行将在违反常规隔离规则的其他事务的数据快照中可见(这适用于具有“可重复读取”或“可序列化”级别的事务)。

要验证这一点,请在另一个会话中以“可重复读取”隔离级别启动事务:

 | => 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无法与可见性图一起使用-加载的页面未标记为仅包含所有人可见的行的版本。 因此,当您第一次访问该表时,强制执行清理以重新处理所有表并创建可见性图。 更糟糕的是,数据页在其自己的标头中具有完全可见性的迹象,因此清洗不仅读取整个表,而且还完全重写了表,从而放下了所需的位。 不幸的是,解决此问题的方法不必早于版本13( 讨论 )。

结论


总结了我有关PostgreSQL隔离和多版本的系列文章。 感谢您的关注,尤其是您的评论-它们会改善材料,并经常指出需要我特别注意的方面。

和我们在一起,继续!

Source: https://habr.com/ru/post/zh-CN455590/


All Articles