Postgresql 事务回卷

568 阅读5分钟

XID 定义

XID 是 PostgreSQL 里面的事务号,每个事物都会分配一个 xid。PostgreSQL 数据中每个元组头部都会保存着插入或者删除这条元组的事务号,即xid,然后通过这个xid进行元组的可见性判断。简单理解,在xid之前的元组是可见的,xid之后的元组是不可见的,比如有两个事务,xid_1=504,xid2=506,那么 xid_1中只能看到t_xmin <=504的元组,看不到t_xmin > 504的元组。(下图中的t_min是插入数据的xid,t_max表示数据更新或删除的xid)

image.png

/* tuple 头部有事务xid的信息,根据判断元组在当前事务下是否可见 */
typedef struct HeapTupleFields
{
   TransactionId t_xmin;     /* inserting xact ID */
   TransactionId t_xmax;     /* deleting or locking xact ID */

   union
   {
      CommandId  t_cid;    /* inserting or deleting command ID, or both */
      TransactionId t_xvac;  /* old-style VACUUM FULL xact ID */
   }        t_field3;
} HeapTupleFields;

XID 实现机制

PostgreSQL使用 uint32 来表示存放事务xid,即 0 到 23212^{32}-1,那么超过了23212^{32}-1 的事务怎么办呢?其实 xid 是一个环,超过了 2^32-1 之后又会从头开始分配. 因此如不作任何处理的情况下, 最多只能处理2322^{32}个事务, 很显然这是不合理的. 为了解决这个问题, PostgreSQL使用了很巧妙的一个方法: 将 uint32 取值空间作为一个环来看待, 定义对于环中任一一点, 该点向前(顺时针方向) 2322^{32} 范围内的事务都认为发生在该点之后, 该点向后(逆时针方向) 2322^{32} 范围内的事务都认为发生在该点之前.

image.png

那么假设事务起始id1为 100, 那么当事务 id 到达 100 + 2312^{31} 时, 我们就需要对数据库执行一次 freeze 操作. 如果这时不进行任何操作, 那么事务 id2 取到了 101 + 2312^{31}, 此时预期情况我们认为 id2 发生在 id1 之后, 但按照上面的规则则是 id1 发生在 id2 之后, 很显然 freeze 操作必不可少. 简单来说, freeze 操作就是丢弃 tuple 中原 xmin 信息, 将其置为一个特殊的事务 id: FREEZE_XID. 在具体实现上, PostgreSQL 会保存 tuple xmin 信息, 而是加入一个额外的 flag 来表明 xmin 该行已经 freeze 了. PostgreSQL 同时规定 FREEZE_XID 发生在任何事务之前, 即对任何事务可见. 回到上面情况, 当 id2 到达 100 + 2312^{31} 时, 通过 freeze 操作将 id1 置为 FREEZE_XID, 此时起始事务 id 变为了 101, 事务 id 的上限变为了 101 + 2312^{31}, 如果再次到达了上限还是需要继续执行 freeze 操作。

/* 无效事务号 */
#define InvalidTransactionId       ((TransactionId) 0)
/* 在数据库初始化过程(BKI执行)中使用 */
#define BootstrapTransactionId    ((TransactionId) 1)
/* 冻结事务号用于表示非常陈旧的元组,它们比所有正常事务号都要早(也就是可见) */
#define FrozenTransactionId          ((TransactionId) 2)
/* 第一个正常事务号 */
#define FirstNormalTransactionId   ((TransactionId) 3)
#define MaxTransactionId         ((TransactionId) 0xFFFFFFFF)

FullTransactionId
GetNewTransactionId(bool isSubXact)
{
    ...
    full_xid = ShmemVariableCache->nextXid;
    xid = XidFromFullTransactionId(full_xid);
    ...

    /*
     * Now advance the nextXid counter.  This must not happen until after we
     * have successfully completed ExtendCLOG() --- if that routine fails, we
     * want the next incoming transaction to try it again.  We cannot assign
     * more XIDs until there is CLOG space for them.
     */
    FullTransactionIdAdvance(&ShmemVariableCache->nextXid);
    ...
}

static inline void
FullTransactionIdAdvance(FullTransactionId *dest)
{
   dest->value++;

   /* see FullTransactionIdAdvance() */
   if (FullTransactionIdPrecedes(*dest, FirstNormalFullTransactionId))
      return;

   /* 跳过特殊的 xid */
   while (XidFromFullTransactionId(*dest) < FirstNormalTransactionId)
      dest->value++;
}

static void
AssignTransactionId(TransactionState s)
{
    ...
    s->fullTransactionId = GetNewTransactionId(isSubXact);
    if (!isSubXact)
        XactTopFullTransactionId = s->fullTransactionId;
}

TransactionId
GetTopTransactionId(void)
{
    if (!FullTransactionIdIsValid(XactTopFullTransactionId))
        AssignTransactionId(&TopTransactionStateData);
    return XidFromFullTransactionId(XactTopFullTransactionId);
}

在 PostgreSQL 使用位于共享内存 ShmemVariableCache 变量来保存与事务 id 相关信息,并且 0,1,2作为特殊的xid,正常的xid 从3开始。

XID 回卷

上面介绍了 xid 的实现是一个环,此时 xid 从3 开始分配,在数据库长时间的运行可能会遇到 |最新事务号-最老事务号|大于 2312^31 的情况,一旦大于就会出现回卷,导致老事务产生的数据对新事务不可见的情况(参见XID的实现机制)。比如 xid 经过下面的变化: 3 --> 23212^{32} -1 --> 6,如何判断出来 xid 为 6 发生在 xid 为 23212^{32}-1 之后呢?那么 pg 将采用下面的方式进行判断:

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);
}

例如 xid1 和 xid2 两个事务,如果这两个事务之间的间隔大于 2312^{31}时那么说明 xid2 超过了 xid1 对应的上限(xidWrapLimit),xid2 发生在 xid1 之前.这里主要就是判断最高位是否为 1, pg 巧妙地使用了 uint32 -> int32 转换规则来判断, 若最高位为 1, 那么转换为 int32 一定为负数。32位有符号整数的取值范围是 231-2^{31}23112^{31}-1,5-23212^{32}-1 得到的值比 23112^{31}-1 大,所以转换成 int32 会变成负数,xid=6 对于 xid=23212^{32} -1的事务是不可见的。

XID 回卷预防

上面介绍了,当发生事务的回卷时将会导致旧的事务对于新的事务不可见,所以 pg 通过定期把老事务产生的元组的 xid 更新为 FrozenTransactionId,即更新为2,来回收 xid,而 xid 为2 的元组对所有的事务可见,这个过程称为 xid 冻结,通过这个方式可以回收 xid 来保证 |最新事务号-最老事务号| < 2312^{31}。pg 使用了一些相关的变量实现预防事务回卷:

/* 	 
 * These fields are protected by XidGenLock.
 */
FullTransactionId nextXid;	/* next XID to assign */  	
TransactionId oldestXid;	/* cluster-wide minimum datfrozenxid */
TransactionId xidVacLimit;	/* start forcing autovacuums here */ 	
TransactionId xidWarnLimit;     /* start complaining here */ 	
TransactionId xidStopLimit;     /* refuse to advance nextXid beyond here */
TransactionId xidWrapLimit;     /* where the world ends */ 	
Oid    oldestXidDB;	        /* database with minimum datfrozenxid */

image.png

oldestXid 是当前起始事务 id, xidWrapLimit 是事务 id 上限,当前事务号一旦到达xidWrapLimit将发生回卷. 当 backend 在分配事务 id 时发现事务 id 超过 xidVacLimit 之后, 就会通过 pmsignal 机制向 postmaster 发送信号告知其需要一次 AUTO VACUUM. 当事务 id 超过 xidWarnLimit 后, 之后每次事务 id 分配都会触发一条 warning 日志发送给客户端. 一旦当前事务号到达xidStopLimit,实例将不可写入,保留 1000000 的xid用于vacuum每 vacuum 一张表需要占用一个xid. SetTransactionIdLimit函数是主要的处理逻辑。

这里 warning 日志是通过 PG notice 机制实现. 在 libpq 中, 默认行为是将 warnning 日志输出到 stderr 中. 但 JDBC 默认行为下是会把 warning 日志放在内存中, 所以这时可能会导致客户端 OOM. 最后当事务 id 即将超过 xidStopLimit 之后, 就完全禁止掉事务 id 的分配.

除了内核自动冻结回收 xid,也可以通过命令或者 sql 的方式手动进行 xid 冻结回收,具体的命令可以参考 VACCUM命令,例如 手动冻结回收一张表的元组的 xid:vacuum freeze 表名;

查询数据库或表的年龄,数据库年龄指的是:|最新事务号-数据库中最老事务号|,表年龄指的是: |最新事务号-表中最老事务号|

  • 查看每个库的年龄:
SELECT datname, age(datfrozenxid) FROM pg_database;  

image.png

  • 查看1个表的年龄:
select oid::regclass,age(relfrozenxid) from pg_class where oid=tableOid;

image.png

vacuum freeze 会扫描表的所有页面并更新,是一个重 IO 的操作,操作过程中一定要控制好并发数,否则非常影响正常的业务。