事务ID(XID)基本概念
从Transactions and Identifiers可知:
事务 ID,例如 278394,会根据 PostgreSQL 集群内所有数据库使用的全局计数器按顺序分配给事务。此分配会在事务首次写入数据库时进行。这意味着编号较低的 xid 会先于编号较高的 xid 写入。
事务 ID 类型 xid 为 32 位宽,每 40 亿次事务绕回一次。每次绕回时,都会增加一个 32 位的纪元 (epoch)。此外,还有一个 64 位类型 xid8,它包含这个纪元,因此在安装的生命周期内不会绕回;它可以通过强制类型转换转换为 xid。xid 是 PostgreSQL MVCC 并发机制和流复制的基础。
交易ID和快照信息函数参见这里。
日常清理(VACUUM)中的冻结操作
PostgreSQL 数据库需要定期维护,称为清理(vacuum)。其中除更新统计信息,回收空间外,一项重要的任务就是防止由于事务 ID 回绕或多事务 ID 回绕而丢失非常旧的数据。
从Preventing Transaction ID Wraparound Failures可知:
PostgreSQL 的 MVCC 事务语义依赖于能够比较事务 ID (XID) 编号:如果行版本的插入 XID 大于当前事务的 XID,则该行版本“位于未来”,对当前事务不可见。但由于事务 ID 的大小有限(32 位),因此长期运行的集群(超过 40 亿个事务)将遭遇事务 ID 回绕:XID 计数器会回绕为零,过去的事务会突然变成未来的事务 — — 这意味着它们的输出变得不可见。简而言之,就是灾难性的数据丢失。(实际上数据仍然存在,但如果您无法获取数据,这也只是些安慰。)为了避免这种情况,有必要至少每 20 亿个事务清理一次每个数据库中的每个表。
为何是至少每 20 亿个事务
清理一次,这是由autovacuum_freeze_max_age参数控制的:
sampledb=> show autovacuum_freeze_max_age;autovacuum_freeze_max_age
---------------------------200000000
(1 row)
20 亿实际是XID取值范围的一半,即231。
定期清理能够解决这个问题的原因是,VACUUM 会将行标记为冻结,表明这些行是由一个提交时间足够久的事务插入的,因此插入事务的影响对所有当前和未来的事务都可见。普通 XID 使用模 232 算法进行比较。这意味着对于每个普通 XID,都有 20 亿个“更旧”的 XID 和 20 亿个“更新”的 XID;换句话说,普通 XID 空间是循环的,没有端点。因此,一旦使用特定的普通 XID 创建了行版本,那么在接下来的 20 亿个事务中,无论我们讨论的是哪个普通 XID,该行版本都会看起来像是“过去”的。如果在超过 20 亿个事务之后,该行版本仍然存在,它就会突然看起来像是未来。为了防止这种情况,PostgreSQL 保留了一个特殊的 XID,FrozenTransactionId,它不遵循普通 XID 比较规则,并且始终被认为比所有普通 XID 都旧。冻结行版本被视为插入 XID 是 FrozenTransactionId,因此无论环绕问题如何,它们对于所有正常事务都将显示为“过去”,因此此类行版本将一直有效,直到被删除,无论时间有多长。
所谓是循环的,没有端点
类似于下图:
0 → 1 → 2 → ... → 2^32-1 → 0 → 1 → ...
每一个表都有系统定义的隐含列,xmin和xmax:
sampledb=> select xmin, xmax from regions limit 1;xmin | xmax
------+------5190 | 0
(1 row)
所谓冻结就是将xmin的值设为FrozenTransactionId(实际值为2),设置后xmin的值不会再被修改。
vacuum_freeze_min_age 控制 XID 值的有效期,超过该 XID 值的行才会被冻结。如果原本会被冻结的行很快会被再次修改,则增加此设置可以避免不必要的工作;但降低此设置会增加在必须再次清理表之前可以处理的事务数。
表未清理的最长时间是20亿个事务数减去上次激进清理时的vacuum_freeze_min_age值。如果未清理的时间超过该时间,可能会导致数据丢失。为确保不会发生这种情况,任何可能包含XID大于配置参数autovacuum_freeze_max_age指定的未冻结行的表都会被调用自动清理。(即使禁用自动清理,也会发生这种情况。)
事务ID环绕问题是如何产生和解决的
前面已经谈到了普通 XID 使用模 232 算法进行比较。这个规则就是:
如果(NextXID - xmin) % 2^32 < 2^31,则 xmin 属于过去
PostgreSQL 将 “当前 XID ± 2^31 (≈ 20 亿)” 作为 可见窗口,因此总有约20亿属于过去,20亿属于未来(中间那个|
即NextXID):
<---------------- 2^31 = 2,147,483,648 ---------------->“过去” “未来”
-----------------------|------------------------------>可见 不可见
先看一个属于过去的例子。
假设xmin = 4,294,967,000,接近2^32。XID已经回绕,此时NextXID = 100。
根据算法(NextXID - xmin) % 2^32 < 2^31
。
NextXID = 100
xmin = 4,294,967,000
delta = (100 - 4,294,967,000) % 4,294,967,296= ( -4,294,966,900 ) % 4,294,967,296= 396
显然,396小于2^31,因此xmin虽然接近XID的最大值,但属于过去。
再看一个属于未来的例子,xmin和上例相同:
NextXID = 2,147,483,700
xmin = 4,294,967,000
delta = (2,147,483,700 - 4,294,967,000) % 4,294,967,296= (-2,147,483,300) % 4,294,967,296= 2,147,483,996
此时,delta大于2^31,因此xmin属于未来。
xmin并没有发生变化,而此时却被视为未来,这显然是错误的。通过冻结,即将xmin置为FrozenTransactionId,即可解决事务ID环绕问题。
在源码文件./backend/access/transam/transam.c中,可以找到此算法:
// git clone https://github.com/postgres/postgres.git 检出源码
/** TransactionIdPrecedes --- is id1 logically < id2?*/
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);
}
监控XID环绕
postgres=# SELECT datname,age(datfrozenxid) AS xid_age,2000000000 - age(datfrozenxid) AS remaining_before_wraparound
FROM pg_database;datname | xid_age | remaining_before_wraparound
--------------------+---------+-----------------------------postgres | 5375 | 1999994625template1 | 5375 | 1999994625template0 | 5375 | 1999994625world_temperatures | 5375 | 1999994625demo | 5375 | 1999994625sampledb | 5375 | 1999994625
(6 rows)
可以参照Transaction ID wraparound: a walk on the wild side,模拟事务ID环绕问题。