PG 中的事务: 事务 id

Posted by w@hidva.com on December 1, 2019

本文章内容根据 PG9.6 代码而来.

PG 之中, 使用 uint32 来表示存放事务 id, 因此如不作任何处理的情况下, 最多只能运行 2 ** 32 个事务, 很显然这是不合理的. 为了解决这个问题, PG 使用了很巧妙的一个法子: 将 uint32 取值空间作为一个环来看待, 定义对于环中任一一点, 该点向前(顺时针方向) 2 ** 31 范围内的事务都认为发生在该点之后, 该点向后(逆时针方向) 2 ** 31 范围内的事务都认为发生在该点之前. 那么假设事务起始 id1 为 3, 那么当事务 id 到达 3 + (2 ** 31) 时, 我们就需要对数据库执行一次 freeze 操作. 如果这时不进行任何操作, 那么事务 id2 取到了 4 + (2 ** 31), 此时预期情况我们认为 id2 发生在 id1 之后, 但按照上面的规则则是 id1 发生在 id2 之后, 很显然 freeze 操作必不可少. 简单来说, freeze 操作就是丢弃 tuple 中原 xmin 信息, 将其置为一个特殊的事务 id: FREEZE_XID. 在具体实现上, PG 会保存 tuple xmin 信息, 而是加入一个额外的 flag 来表明 xmin 该行已经 freeze 了. PG 同时规定 FREEZE_XID 发生在任何事务之前, 即对任何事务可见. 回到上面情况, 当 id2 到达 3 + (2 ** 31) 时, 通过 freeze 操作将 id1 置为 FREEZE_XID, 此时起始事务 id 变为了 4, 事务 id 的上限变为了 4 ++ (2 ** 31), 又可以坚持一会了….

在 PG 实现中, 其使用位于共享内存 ShmemVariableCache 变量来保存与事务 id 相关信息, 这些信息相互之间的关系:

ShmemVariableCache

简单来说 oldestXid 是当前起始事务 id(也就是上文 id1), xidWrapLimit 是事务 id 上限. 当 backend 在分配事务 id 时发现事务 id 超过 xidVacLimit 之后, 就会通过 pmsignal 机制向 postmaster 发送信号告知其需要一次 AUTO VACUUM. 当事务 id 超过 xidWarnLimit 后, 之后每次事务 id 分配都会触发一条 warning 日志发送给客户端. 话外一下, 这里 warning 日志是通过 PG notice 机制实现. 在 libpq 中, 默认行为是将 warnning 日志输出到 stderr 中. 但 JDBC 默认行为下是会把 warning 日志放在内存中, 所以这时可能会导致客户端 OOM. 最后当事务 id 即将超过 xidStopLimit 之后, 就完全禁止掉事务 id 的分配.

SetTransactionIdLimit(oldest_datfrozenxid, oldest_datoid); PG 会在合适的时机调用 SetTransactionIdLimit() 来更新 xidVacLimit 等变量. 目前来看, PG 会在 vacuum 链路中, 在调用 vac_truncate_clog() 时, 调用 SetTransactionIdLimit(). 根据 vac_truncate_clog() 的实现可以看到, 这里 oldest_datfrozenxid 是 pg_database 表中所有行的最小值, 包括哪些不允许连接的数据库, 如 template0. 在 PG8.x 版本中, vac_truncate_clog 在 oldest_datfrozenxid 时会忽略不可连接的数据库. 因此在 PG 中, autovacuum 必须要开启, 因为只有 autovacuum 才能在那些不可连接数据库上执行 vacuum 操作.

事务 id 的先后顺序比较, 根据上文介绍的规则, 对于待比较的事务 id1, id2, 只需要将 id2 减去 id1 计算它们之前的间隔, 若间隔大于 2 ** 31, 那么表明 id2 已经超过 id1 所对应的 xidWrapLimit, 即 id2 发生在 id1 之前. 对应到 PG 中实现如下:

bool
TransactionIdPrecedes(TransactionId id1, TransactionId id2)
{
    // 等同于 return (id1 - id2) & 0x80000000
	return ((int32) (id1 - id2)) < 0;
}

这里主要就是判断最高位是否为 1, PG 巧妙地使用了 uint32 -> int32 转换规则来判断, 若最高位为 1, 那么转换为 int32 一定为负数. 还有种做法是使用 & 运算符等. 我个人觉得 PG 使用的 uint32 -> int32 转换规则有点不妥, 因为按照 C 标准规定:

A value of any integer type can be implicitly converted to any other integer type. Except where covered by promotions and boolean conversions above, the rules are:

  • if the target type can represent the value, the value is unchanged
  • otherwise, if the target type is unsigned, the value 2^b , where b is the number of bits in the target type, is repeatedly subtracted or added to the source value until the result fits in the target type. In other words, unsigned integers implement modulo arithmetic.
  • otherwise, if the target type is signed, the behavior is implementation-defined (which may include raising a signal)

即这里 uint32 -> int32 转换规则是取决于实现的, 较真来说的话不那么科学. 而且我之前对 GCC 中的整数转换一直云里雾里的, 为此还深入了 GCC 背后探究了一番 GCC 中的整数转换规则, 见 GCC 中的整数转换.

另外严格来说 TransactionIdPrecedes() 这里实现并不正确, 主要是 PG 将 0, 1, 2 作为特殊 xid 来用, 在 SetTransactionIdLimit() 实现中计算 xidWrapLimit 时也会跳过 0, 1, 2:

xidWrapLimit = oldestXid + (MaxTransactionId >> 1);
if (xidWrapLimit < FirstNormalTransactionId /* 取值为 3 */)
    xidWrapLimit += FirstNormalTransactionId;

所以假设 oldestXid 取值为 2147483650, 那么计算的 xidWrapLimit 也会从 1 调整到 4, 那么存在合法的两个事物 id: id1 = 2147483650, id2 = 3, 并且 id2 发生在 id1 之后. 但是这时采用 TransactionIdPrecedes() 中规则计算确是 id1 > id2. 万幸的是, PG 连带引入的 xidStopLimit 避免了这种情况. 在 PG 中 xidStopLimit 与 xidWrapLimit 之间间隔固定为 1000000, 完全杜绝了此类情况发生. 在 Greenplum 中, GP 引入了 xid_stop_limit 参数来灵活地调整 xidStopLimit 与 xidWrapLimit 之间间隔, 所以 xid_stop_limit 值是有底线的, 必须要 >= 2.