Postgresql Freezing 实现原理

本文深入探讨 PostgreSQL 中 32 位事务 ID 的回卷问题及解决方案,介绍了事务 ID 比较的新方法、TimeTravel 引入的可见性问题以及 Freezing 过程。并通过实例演示了如何通过配置参数控制事务 ID 的回收效率。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

背景

因为历史原因,PG 内部实现的事务ID 采用的32bits,在当下的TP 场景会被消耗得极快。比如 TPS 为1000 的应用负载,32bits的事务 id 也会在六周内被耗尽。PG 为了解决这个 事务ID 回卷的问题,通过一种让事务ID被耗尽之前能够重置的办法来解决这个问题,方法很简单,下文会简单介绍。

主要麻烦的地方在于 让事务ID 回卷之后需要保证一些数值较小的事务在事务隔离级别的需求要需要比数值较大的事务更新,引入了一系列本文要详细描述的问题:如何标识 存储一个tuple是过期的,如何回收事务ID,事务ID 的回收如何和vacuum结合 以及相关的一些配置如何影响回收的效率。

因为32 bits 而引入的一些额外的工作量还是不小的,如果改成64bits,可以说是一劳永逸了。但是PG官方也给出了较为明确的解释,主要还是考虑空间占用的问题,目前Tuple 的header数据其实已经有 24bytes 了,而且header中需要保存 t_xmint_xmax 两个事务id,如果改造了,那header部分 每一个tuple还需要额外增加 8bytes,这存储实成本其实很高。而且 PG 在处理 事务ID 的回卷问题上有足够的性能和稳定性,所以 改造64 bits 的吸引力也就没有那么大了。

本节涉及到的 PG 源码版本 REL_12_STABLE.

32bits 事务ID 回卷新旧比较问题的解决

如下一段简洁优雅的代码就把问题解决了:

typedef uint32 TransactionId;

bool
TransactionIdPrecedes(TransactionId id1, TransactionId id2)
{
	/*
	 * If either ID is a permanent XID then we can just do unsigned
	 * comparison.  If both are normal, do a modulo-2^32 comparison.
	 */
	int32		diff;

	if (!TransactionIdIsNormal(id1) || !TransactionIdIsNormal(id2))
		return (id1 < id2);

	diff = (int32) (id1 - id2);
	return (diff < 0);
}

原本的 TransactionId uint32_t 类型,也就是范围是在 [0,4294967296] 区间。PG 这里认为 >= 3 的事务才是正常的事务id,0,1,2都有各自的用处。
对于正常的事务ID的处理,这里会根据两个事务之间的差值做一个强制类型转换,转为 int32,也就是有符号的整型了,在[-2147483648,2147483647] 之间,也就是让 id1 和 id2 的差值 减去 (1<<32),看返回的结果是否<0;是,则认为 id1 更旧,否 则认为id 1 更新。
整个事务ID 的回卷处理体系中,总会有20亿的事务ID 处于过去,20亿的事务ID处于未来。

比如 id1 = 4294967295, id2 = 1, 则id1 比 id2 更旧,因为他们处于 均分2^32 的同一侧,这个边界是跟着最新的事务id 移动的。
在这里插入图片描述

Time Travel 引入的可见性问题

接下来就是正题了, 单纯得按照这样的事务ID 比较方式会出现 time travel,如下图对于事务 id1
在这里插入图片描述
因为要保证一般的事务处于past,一半的事务处于future,所以id1 在T1 时刻还是对所有的事务都可见,因为其处于过去;但是在T2 时刻又对所有的事务都不可见了,这是非常严重的可见性问题。

Freezing tuple 解决Time Travel

Freezing 的过程主要是在 vacuum 中实现的,处理的过程就像字面意思一样,通过对vacuum找到的过期的tuple进行冻结(主要是事务id的标识,打一个freezing 冻结标识)。在可见性检测的规则中,比较基础的一个规则是tuple的 xmin 是对于所有的snapshots 都可见,则 所有 小于<= frozen tuple xmin的transaction id都能够被复用,继续用做生成新的事务。

像前面ID1的情况,在T2之前,ID1的事务id 需要被freeze,然后ID1的事务id在 T2的时候就能够被复用了,用做future的活跃事务id了。

这里核心主要是两部分:

  1. 以哪一个transaction id 为分界线进行freeze?PG 里面提到的是 horizon,即 relation(表)级别所有tuple 的 t_xmin 的最小值,这块会在下文介绍。
  2. 以什么样的方式进行freeze。

怎样 freeze 的 过程的主要实现代码是在如下调用栈中:

vacuum
	vacuum_rel
		table_relation_vacuum
		heap_vacuum_rel
			lazy_scan_heap
				heap_prepare_freeze_tuple /* 标记t_infomast 为freeze ,即 HEAP_XMIN_FROZEN 标识 */
				heap_execute_freeze_tuple /* 标记 transactionid 为 FrozenTransactionId ,默认是2 */

关于 heap_prepare_freeze_tuple 函数,主要是对当前tuple 能否freeze 进行检测,在这个函数里面主要是根据传入的tuple 的xmin 以及 xmax 和 relfrozenxid 以及 relminmxid 等horizen边界 进行对比,看否能够进行freeze。
如果能的话则会打一个这样的标记:

		if (xmin_frozen)
		{
			if (!TransactionIdDidCommit(xid))
				ereport(ERROR,
						(errcode(ERRCODE_DATA_CORRUPTED),
						 errmsg_internal("uncommitted xmin %u from before xid cutoff %u needs to be frozen",
										 xid, cutoff_xid)));

			frz->t_infomask |= HEAP_XMIN_FROZEN;
			changed = true;
		}

这个 HEAP_XMIN_FROZEN 会在 HeapTupleSatisfiesMVCC 函数中判断一个 tuple 的可见性:

static bool
HeapTupleSatisfiesMVCC(HeapTuple htup, Snapshot snapshot,
					   Buffer buffer)
{
		...
		if (!HeapTupleHeaderXminFrozen(tuple) &&
			XidInMVCCSnapshot(HeapTupleHeaderGetRawXmin(tuple), snapshot))
			return false;		/* treat as still in progress */
		...
}

在函数 heap_execute_freeze_tuple 则会将 该tuple的 (tup)->t_choice.t_heap.t_field3.t_xvac 设置为FrozenTransactionId
这样,freeze 本身是如何实现的就清楚了

核心其实还是在如何 维护 relfrozenxid上面,而且在刚才提到的函数 heap_prepare_freeze_tuple 有一个参数是 cutoff_xid,这个也是控制freeze 的一个guc 选项,和 freeze 的性能相关。

Manual Freezing 控制freeze的触发

接下来看看 PG 如何决定一个tuple 是否应该freeze,有哪一些guc 参数能够控制这个过程?

先来做一些freeze 标识的测试,建表并关闭 autovacuum,测试过程不受其影响:

postgres=# CREATE TABLE tfreeze( id integer, s char(300) ) WITH (fillfactor = 10, autovacuum_enabled = off);
CREATE TABLE
postgres=# INSERT INTO tfreeze(id, s) SELECT id, 'FOO'||id FROM generate_series(1,100) id;
INSERT 0 100

其中 fillfactor 能够保证每一个page 只会有两个tuple,这样能够方便我们追踪整个过程。
这个时候,我们去查看部分page的 visible_map 和 frozen_map,会发现两个 map都是false,其中两个page中的tuple 都不可见的原因是 visibility_map 还没有刷盘,还没有生成 vm 文件,所以肯定读不到;关于 两个page 在 froze_map 中都不可见的原因后面会说。

postgres=# create extension pg_visibility ;
CREATE EXTENSION
postgres=# 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 | f           | f
(2 rows)

tfreeze 表经过vacuum 就能够生成 vm 文件,从而在 visible map中体现。

postgres=# vacuum ;
VACUUM
postgres=# 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)

可以通过如下function 查看两个page 中的元组信息,以及 xmin_age (表示数据库启动完成 到现在调度的第几个transaction,初始化系统表产生的transaction不算),这个age非常重要,能够清晰得标识我们是否应该按照 guc的一些freeze配置来 触发freeze了。

postgres=# 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 ) 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) AS xmin_age, t_xmax || CASE 
	 WHEN (t_infomask & 1024) > 0 THEN ' c' 
	 WHEN (t_infomask & 2048) > 0 THEN ' a' 
	 ELSE '' 
	END AS xmax 
FROM generate_series(pageno_from, pageno_to) p(pageno), 
     heap_page_items(get_raw_page(relname, pageno)) 
ORDER BY pageno, lp; 
$$ LANGUAGE sql;
postgres=# SELECT * FROM heap_page('tfreeze',0,1);
 ctid  | state  | xmin  | xmin_age | xmax
-------+--------+-------+----------+------
 (0,1) | normal | 534 c |        4 | 0 a
 (0,2) | normal | 534 c |        4 | 0 a
 (1,1) | normal | 534 c |        4 | 0 a
 (1,2) | normal | 534 c |        4 | 0 a
(4 rows)

能够看到 四个元组 所在的两个page信息,以及每一个元组对应的xmin_age

对 tuple freeze 的触发时机有关键影响的几个 guc 如下,其中提到的age 就是我们前面说到的xmin_age的含义。

  • vacuum_freeze_min_age 标识xmin_age 达到多少的时候就开始触发freeze。这个配置的目的主要是为了lazy freeze,因为对于一个热点行的频繁更新会产生非常多的tuple,而且freeze的过程会有较多的判断,将freeze的触发时机拖长一些,等到有足够的tuple产生了更新,再统一进行freeze 效率会更高一些。
  • vacuum_freeze_table_age 允许vacuum 忽略visibility_map 的约束,直接freeze 整个page.
  • autovacuum_freeze_max_age 在一个数据库内部某一个事务 age 超过这个阈值会强制触发autovacuum 进行freeze,防止autovacuum 被误关闭导致无法回收事务id。

下面会结合前面的测试方式,来从源码分析这个几个配置如何生效。

配置1 vacuum_freeze_min_age (default : 5000000) 实现过程

先来验证一下这个配置的作用,它作为阈值,在age 达到这个page的情况下会开始进行freeze
重新设置一下 vacuum_freeze_min_age来方便我们测试,同时更新tfreeze中的第一行:

postgres=# show vacuum_freeze_min_age;
 vacuum_freeze_min_age
-----------------------
 50000000
(1 row)

postgres=# ALTER SYSTEM SET vacuum_freeze_min_age = 1;
ALTER SYSTEM
postgres=# SELECT pg_reload_conf();
 pg_reload_conf
----------------
 t
(1 row)

postgres=# UPDATE tfreeze SET s = 'BAR' WHERE id = 1;
UPDATE 1
postgres=# SELECT * FROM heap_page('tfreeze',0,1);
 ctid  | state  | xmin  | xmin_age | xmax
-------+--------+-------+----------+------
 (0,1) | normal | 534 c |        5 | 538
 (0,2) | normal | 534 c |        5 | 0 a
 (0,3) | normal | 538   |        1 | 0 a
 (1,1) | normal | 534 c |        5 | 0 a
 (1,2) | normal | 534 c |        5 | 0 a
(5 rows)
postgres=# 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_freeze_min_age = 1 的语意,所有 比 vacuum_freeze_min_age = 1 对应的tuple 旧的tuple 都会被freeze。因为更新之后的tuple 还没有落盘,并没有写入到其vm文件中,所以其所在的 0号page是不可见的。接下来进行vacuum,预期行为是 0号 page中的 ctid=(0,2) 的tuple被标记为 freeze,但是page 1中所有的tuple并不会被标记为 freeze,因为vacuum 并不会对 page 1进行vacuum,其visible 是true。

postgres=# vacuum tfreeze ;
VACUUM
postgres=# SELECT * FROM heap_page('tfreeze',0,1);
 ctid  |     state     | xmin  | xmin_age | xmax
-------+---------------+-------+----------+------
 (0,1) | redirect to 3 |       |          |
 (0,2) | normal        | 534 f |        5 | 0 a
 (0,3) | normal        | 538 c |        1 | 0 a
 (1,1) | normal        | 534 c |        5 | 0 a
 (1,2) | normal        | 534 c |        5 | 0 a
(5 rows)

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

很明显, page 0 中的tuple 2 (xmin = 534) 比 vacuum_freeze_min_age = 1 对应的tuple (xmin = 538)旧,会被标记为freeze,但是page 1不会被 vacuum 调度,则其内部的两个tuple 即使 xmin 都小于 538,但并不会被freeze。

在源代码中,有一个全局变量 FreezeLimitvacuum_freeze_min_age 配置关系比较大,通过 vacuum_set_xid_limits 函数 计算出 后续freeze 时需要freeze的transaction id的下界,所有小于这个下界的transaction id都会被freeze。

void
vacuum_set_xid_limits(Relation rel,
					  int freeze_min_age,
					  int freeze_table_age,
					  int multixact_freeze_min_age,
					  int multixact_freeze_table_age,
					  TransactionId *oldestXmin,
					  TransactionId *freezeLimit,
					  TransactionId *xidFullScanLimit,
					  MultiXactId *multiXactCutoff,
					  MultiXactId *mxactFullScanLimit)
{
	...
	/* freeze_min_age 默认是-1 */
	freezemin = freeze_min_age;
	if (freezemin < 0)
		freezemin = vacuum_freeze_min_age; /* 填充 guc 配置 */
	freezemin = Min(freezemin, autovacuum_freeze_max_age / 2);
	Assert(freezemin >= 0);

	/*
	 * Compute the cutoff XID, being careful not to generate a "permanent" XID
	 * 计算当前数据库最小的xmin 事务id 和 freezemin 的差值,在vacuum的时候会将所有 <= freezemin
	 * 的事务id 都freeze掉。
	 */
	limit = *oldestXmin - freezemin;
	if (!TransactionIdIsNormal(limit))
		limit = FirstNormalTransactionId;

	/*
	 * If oldestXmin is very far back (in practice, more than
	 * autovacuum_freeze_max_age / 2 XIDs old), complain and force a minimum
	 * freeze age of zero.
	 */
	safeLimit = ReadNewTransactionId() - autovacuum_freeze_max_age;
	if (!TransactionIdIsNormal(safeLimit))
		safeLimit = FirstNormalTransactionId;

	if (TransactionIdPrecedes(limit, safeLimit))
	{
		ereport(WARNING,
				(errmsg("oldest xmin is far in the past"),
				 errhint("Close open transactions soon to avoid wraparound problems.\n"
						 "You might also need to commit or roll back old prepared transactions, or drop stale replication slots.")));
		limit = *oldestXmin;
	}
	*freezeLimit = limit;
	...
}

最后 拿到的freezeLimit 会回填给 全局变量 FreezeLimit
lazy_scan_heap --> heap_prepare_freeze_tuple的时候进行当前tuple的 freeze操作,只要当前tuple的xmin 小于这个 FreezeLimit,会打上 HEAP_XMIN_FROZEN 标记。

所以,vacuum_freeze_min_age 建议不要设置的过小,否则得到的 FreezeLimit 就会比较大(更接近新的transaction id),意味freeze会被更频繁的调度,对性能并不友好

配置2 vacuum_freeze_table_age (default : 150000000) 实现过程

在前面的vacuume_freeze_min_age 的测试中可以看到,page1 包含了两个xmin 小于 538 的tuple,但是整个page 并不会被frozen,这样回收事务id的时候就没有办法按照 page粒度进行了。为了让整个page都能快速得被frozen,vacuum 提供了另一个参数 vacuum_freeze_table_age 来避免这个限制。

PG 的系统表数据结构 FormData_pg_class 提供了每一个表的 relfrozenxid,即当前表中所有小于这个配置的 xid 的tuple 都会被标记为freeze。

postgres=# SELECT relfrozenxid, age(relfrozenxid) FROM pg_class WHERE relname = 'tfreeze';
 relfrozenxid | age
--------------+-----
          533 |   6

relfrozenxid的age 以及其他tuple的xmin_age 会和 vacuum_freeze_table_age 进行对比,如果达到了这个参数的阈值,那就会进行freeze。freeze map 能够非常方便得判断一个page 是否可见,不需要在vacuum的过程中 从磁盘逐个读取page了(freeze map的check 逻辑是在 lazy_scan_heap 函数中的前面部分,获取需要vacuum 的block number的时候会对着一些page 进行检测)。

接下来做一些 vacuum_freeze_table_age 的测试,为了能够freeze 整个table,需要设置 vacuum_freeze_table_age = 4 :

postgres=# alter system set  vacuum_freeze_table_age = 4;
ALTER SYSTEM
postgres=# select pg_reload_conf();
 pg_reload_conf
----------------
 t
(1 row)

再对 tfreeze 表进行一次 vacuum ,可以看到当前表的relfrozenxid 已经被设置为了 538,因为部分tuple的 age 已经满足vacuum_freeze_table_age = 4 的阈值从而被freeze了,这样 relfrozenxid 就可以变更为 oldest xmin - 1了。

postgres=# VACUUM VERBOSE tfreeze;
INFO:  aggressively vacuuming "public.tfreeze"
INFO:  "tfreeze": found 0 removable, 100 nonremovable row versions in 50 out of 50 pages
DETAIL:  0 dead row versions cannot be removed yet, oldest xmin: 539
There were 0 unused item identifiers.
Skipped 0 pages due to buffer pins, 0 frozen pages.
0 pages are entirely empty.
CPU: user: 0.00 s, system: 0.00 s, elapsed: 0.00 s.
VACUUM
postgres=# SELECT relfrozenxid, age(relfrozenxid) FROM pg_class WHERE relname = 'tfreeze';
 relfrozenxid | age
--------------+-----
          538 |   1
(1 row)

再看看page 1的 freeze map,就会发现整个 page 已经被标记为 freeze了。

postgres=# SELECT * FROM heap_page('tfreeze',0,1);
 ctid  |     state     | xmin  | xmin_age | xmax
-------+---------------+-------+----------+------
 (0,1) | redirect to 3 |       |          |
 (0,2) | normal        | 534 f |        5 | 0 a
 (0,3) | normal        | 538 c |        1 | 0 a
 (1,1) | normal        | 534 f |        5 | 0 a
 (1,2) | normal        | 534 f |        5 | 0 a
(5 rows)

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

关于 vacuum_freeze_table_age 的生效,和前面 vacuum_freeze_min_age一样 在函数中vacuum_set_xid_limits 会做一个计算,将结果填充到参数 xidFullScanLimit中,这个参数会用做是否应该进行agreessive vacuum的判断:

void
heap_vacuum_rel(Relation onerel, VacuumParams *params,
				BufferAccessStrategy bstrategy)
{
...
	vacuum_set_xid_limits(onerel,
						  params->freeze_min_age,
						  params->freeze_table_age,
						  params->multixact_freeze_min_age,
						  params->multixact_freeze_table_age,
						  &OldestXmin, &FreezeLimit, &xidFullScanLimit,
						  &MultiXactCutoff, &mxactFullScanLimit);
	aggressive = TransactionIdPrecedesOrEquals(onerel->rd_rel->relfrozenxid,
											   xidFullScanLimit);
...
	/* Do the vacuuming */
	lazy_scan_heap(onerel, params, vacrelstats, Irel, nindexes, aggressive);
...
}

lazy_scan_heap 函数中 如果 aggressive 为true,则判断一个block 是否应该进行vacuum 的过程不再是检测 visible_map,而是检测freeze_map,这个时候对于前面的 page1 来说,其本身在freeze_map 中为false,则会被调度参与vacuum;而不像设置vacuum_freeze_table_age 之前 因为visible_map 为true 并不会被调度参与到vacuum中。

对于 relfrozenxid 的更新在heap_vacuum_rel 中 完成其他的page的vacuum 操作之后调用 vac_update_relstats 函数 完成更新:

void
heap_vacuum_rel(Relation onerel, VacuumParams *params,
				BufferAccessStrategy bstrategy)
{
	...	
	/* Do the vacuuming */
	lazy_scan_heap(onerel, params, vacrelstats, Irel, nindexes, aggressive);
	...
	/* new_frozen_xid 会被设置为 当前rel 在pg_class 中的 relfrozenxid。 */
	new_frozen_xid = scanned_all_unfrozen ? FreezeLimit : InvalidTransactionId;
	new_min_multi = scanned_all_unfrozen ? MultiXactCutoff : InvalidMultiXactId;

	vac_update_relstats(onerel,
						new_rel_pages,
						new_live_tuples,
						new_rel_allvisible,
						nindexes > 0,
						new_frozen_xid,
						new_min_multi,
						false);
	...
}

可以看到设置的实际值是 FreezeLimit 全局变量,它的计算在前面的代码中展示过,就是 oldestxmin - freezemin,从上面跑的sql中 vacuum -v 的展示中能够看到,因为我们 oldestxmin 是 539,且vacuum_freeze_min_page = 1 所以new_frozen_xid肯定就是 538了。

配置3 autovacuum_freeze_max_age (default : 200000000) 实现过程

有一个问题是 前面两个配置不一定总能够保证事务id 被及时freeze,因为他们事务id的回收会依赖vacuum 命令 或者 autovacuum。而用户有可能把autovacuum 给关了,且针对像(template0)这样的用户极少操作的数据库,可能很长时间都不会调度一次手动 vacuum,就导致这一些数据库内部的 比较旧的事务id 不一定能够被及时回收,那就会影响用户其他数据库的事务id 的使用。

为了避免这样的问题,PG 提供了 autovacuum_freeze_max_age配置 来在 autovacuum 关闭的情况下强制触发 autovacuum,触发的条件就是当前PG 集群内部的unfrozen 事务id 的 age 达到 autovacuum_freeze_max_age配置的默认值 2亿,就会触发了。

pg_database 系统表中维护了每一个 database的最小frozen xid datfrozenxid

postgres=# SELECT datname, datfrozenxid, age(datfrozenxid) FROM pg_database;
  datname  | datfrozenxid | age
-----------+--------------+-----
 postgres  |          479 |  60
 testdb    |          479 |  60
 template1 |          479 |  60
 template0 |          479 |  60

所有数据库的 datfrozenxid 需要一样,这个值是取数据库内部所有表的 relfrozenxid 的最小值的到的。
关于 datfrozenxidrelfrozenxid 的关系下图就比较清晰了(图片来自于 参考文献1 ):
在这里插入图片描述
关于 autovacuum_freeze_max_age 帮助force 触发 autovacuum的逻辑如下 (do_autovacuum --> relation_needs_vacanalyze):

static void
relation_needs_vacanalyze(Oid relid,
						  AutoVacOpts *relopts,
						  Form_pg_class classForm,
						  PgStat_StatTabEntry *tabentry,
						  int effective_multixact_freeze_max_age,
 /* output params below */
						  bool *dovacuum,
						  bool *doanalyze,
						  bool *wraparound)
{
	...
	freeze_max_age = (relopts && relopts->freeze_max_age >= 0)
		? Min(relopts->freeze_max_age, autovacuum_freeze_max_age)
		: autovacuum_freeze_max_age;
	...
	/* Force vacuum if table is at risk of wraparound */
	/* recentXid 是取最新的transaction id : ReadNewTransactionId(),即unfrozen 的transactionid */
	xidForceLimit = recentXid - freeze_max_age;
	if (xidForceLimit < FirstNormalTransactionId)
		xidForceLimit -= FirstNormalTransactionId;
	force_vacuum = (TransactionIdIsNormal(classForm->relfrozenxid) &&
					TransactionIdPrecedes(classForm->relfrozenxid,
										  xidForceLimit));
	...
}

vacuum 结束的时候会对 datfrozenxid进行更新 (vacuum --> vac_update_datfrozenxid 或者 do_autovacuum --> vac_update_datfrozenxid),在 vac_update_datfrozenxid 函数中 会扫描当前database 中所有的relation,逐个和GetOldestXmin 获取到的 relfrozenxid比较,取最小的一个作为datfrozenxid,因为这个更新的是 pg_database 表中的一行,所以需要 通过heap_inplace_update 完成更新。

void
vac_update_datfrozenxid(void)
{
	...
	newFrozenXid = GetOldestXmin(NULL, PROCARRAY_FLAGS_VACUUM);
	...
	/* 打开pg_class 表,扫描所有的relation,拿到最小的relfrozenxi */
	relation = table_open(RelationRelationId, AccessShareLock);
	scan = systable_beginscan(relation, InvalidOid, false,
							  NULL, 0, NULL);
	while ((classTup = systable_getnext(scan)) != NULL)
	{
		...
		if (TransactionIdIsValid(classForm->relfrozenxid))
		{
			Assert(TransactionIdIsNormal(classForm->relfrozenxid));

			/* check for values in the future */
			if (TransactionIdPrecedes(lastSaneFrozenXid, classForm->relfrozenxid))
			{
				bogus = true;
				break;
			}

			/* determine new horizon */
			if (TransactionIdPrecedes(classForm->relfrozenxid, newFrozenXid))
				newFrozenXid = classForm->relfrozenxid;
		}
		...
	}
	...
	/* 打开pg_database 表 */
	relation = table_open(DatabaseRelationId, RowExclusiveLock);

	/* Fetch a copy of the tuple to scribble on */
	tuple = SearchSysCacheCopy1(DATABASEOID, ObjectIdGetDatum(MyDatabaseId));
	if (!HeapTupleIsValid(tuple))
		elog(ERROR, "could not find tuple for database %u", MyDatabaseId);
	dbform = (Form_pg_database) GETSTRUCT(tuple);

	/* 填充拿到的newFrozenXid 到pg_database 中的MyDatabaseId 对应的 tuple中,并标记为dirty */
	if (dbform->datfrozenxid != newFrozenXid &&
		(TransactionIdPrecedes(dbform->datfrozenxid, newFrozenXid) ||
		 TransactionIdPrecedes(lastSaneFrozenXid, dbform->datfrozenxid)))
	{
		dbform->datfrozenxid = newFrozenXid;
		dirty = true;
	}
	...
	/* update 这个tuple */
	if (dirty)
		heap_inplace_update(relation, tuple);
	...
	/* 对clog进行truncate */
	if (dirty || ForceTransactionIdLimitUpdate())
		vac_truncate_clog(newFrozenXid, newMinMulti,
						  lastSaneFrozenXid, lastSaneMinMulti);
}

从源代码中能看到在更新完 datfrozenxid 之后还会进行clog的truncate,因为clog 是保存执行过程中的事务状态的,用于可见性检查。当事务执行完车之后,尤其是对于能够frozen的事务,本身就都可见了,保留其clog 内容就没有必要了,所以需要进行truncate。关于clog的作用以及实现细节可以参考PostgreSQL 基于heap 表引擎的事务实现原理

如果整个PG 内部事务ID消耗过快,甚至超过了 通过autovacuum_freeze_max_age 让auto vacuum 触发freeze 的速度,那 PG 会立即不可用,并重启整个服务,为了防止这一点,PG 让 autovacuum_freeze_max_age 默认允许的值设置为了 2亿,其上限是20亿,这样就距离回卷有较长的缓冲区间,来保证不会出现事务 id 回卷且来不及freeze的问题。

这里的检测代码主要有两条告警路径

  1. vacuum 的时候,会进入如下检测以及警告逻辑
    void
    vacuum_set_xid_limits(Relation rel,
    					  int freeze_min_age,
    					  int freeze_table_age,
    					  int multixact_freeze_min_age,
    					  int multixact_freeze_table_age,
    					  TransactionId *oldestXmin,
    					  TransactionId *freezeLimit,
    					  TransactionId *xidFullScanLimit,
    					  MultiXactId *multiXactCutoff,
    					  MultiXactId *mxactFullScanLimit)
    {
    	...
    	/*
    	 * If oldestXmin is very far back (in practice, more than
    	 * autovacuum_freeze_max_age / 2 XIDs old), complain and force a minimum
    	 * freeze age of zero.
    	 */
    	safeLimit = ReadNewTransactionId() - autovacuum_freeze_max_age;
    	if (!TransactionIdIsNormal(safeLimit))
    		safeLimit = FirstNormalTransactionId;
    
    	if (TransactionIdPrecedes(limit, safeLimit))
    	{
    		ereport(WARNING,
    				(errmsg("oldest xmin is far in the past"),
    				 errhint("Close open transactions soon to avoid wraparound problems.\n"
    						 "You might also need to commit or roll back old prepared transactions, or drop stale replication slots.")));
    		limit = *oldestXmin;
    	}
    	...
    }
    
  2. 这条路径比较重要,因为这个配置本身就是防止vacuum没有被执行的情况,所以在auto_vacuum 被强制触发 的情况下:
    SetTransactionIdLimit
      └── vac_truncate_clog	[vim src/backend/commands/vacuum.c +1771]
      └── vac_update_datfrozenxid	[vim src/backend/commands/vacuum.c +1542]
          ├── do_autovacuum	[vim src/backend/postmaster/autovacuum.c +1978]
          │   └── AutoVacWorkerMain	[vim src/backend/postmaster/autovacuum.c +1532]
    
    SetTransactionIdLimit函数中会进行检测。

更多的代码细节处理大家可以跟着前面提到的流程链路来看,整体会更容易些。

总结

  1. freezing 是为了解决 PG 事务id 回卷问题,PG 不希望改造32bits的事务id 主要考虑的是存储问题(每一个tuple额外增加8bytes的存储)。
  2. freezing 过程就是给tuple 的header data打标记,标识这个tuple 被frozen,其事务id就可以被回收 且对所有的事务可见。
  3. freezing 的触发时机主要通过三个参数控制:vacuum_freeze_min_agevacuum_freeze_table_ageautovacuum_freeze_max_age,分别控制的是tuple 级别是否应该被freeze; page级别是否应该被freeze(通过freeze map检查page是否应该参与到vacuum的过程); 前两个参数的一个灾备,防止vacuum不被调用且autovacuum 关闭,能够强制触发autovacuum 进行freeze。

看下来整个实现,处理32bits的事务id回卷导致的问题都交给了vacuum 😐, 所以 vacuum 对PG 的性能影响极大,单纯事务id 这里的freeze 如果回收不及时,就可能导致非常严重的数据一致性问题(虽然增加了很多防御措施),当然其本身对整个事务id 的生产性能没有太大的影响,因为有足够的buffer空间,但vacuum 这个过程会读/写 一些page(虽然做了很多优化,比如检查visible_map 以及 freeze_map,但频繁的更新场景还是避免不了大量的读写),延时上可能并不好看。。。负载较高的时候,可能就类似 lsm-compaction 在高写入场景中 这样的问题了。

这可能也是zheap 引擎想要快速推出的原因了,利用undo来处理 事务,vacuum 本身的负担就小太多了。

参考

1. PostgreSQL 14 internals
2. PostgreSQL - REL_12_STABLE 源码

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值