从源码角度,分析PG事务号分配机制和防止事务号回卷

本文涉及的产品
云原生数据库 PolarDB MySQL 版,通用型 2核4GB 50GB
云原生数据库 PolarDB PostgreSQL 版,标准版 2核4GB 50GB
简介: 从源码角度,分析PG事务号分配机制和防止事务号回卷

缘起:

最近碰到客户遇到事务号回卷问题,导致业务停摆。相关报错如下:

ERROR: database is not accepting commands to avoid wraparound data loss in database "xxx"
HINT: Stop the postmaster and vacuum that database in single-user mode. You might also need to commit or roll back old prepared transactions, or drop stale replication slots.

之前虽然也碰到过类似问题,但没有真正深入了解,这次趁机会能够尽可能了解详细。

PG为什么要有事务号?

接触数据库的可能都听说过逻辑时钟,像Oracle的SCN(system change number),MySQL的lsn(log sequence number),包括PG的xid,其实都是为了比较事务的先后,实现MVCC(Multi-version Concurrency Control)中SI(snapshot isolation)。

PG的事务号是32位的无符号整形,定义如下,所以可用的事务号大概在42亿:

typedef uint32 TransactionId;

那么就要引申出下一个问题?用光了怎么办?

其实在PG内部,把事务号看做一个圆⭕️,循环的去使用事务号。这样就不会出现用光的情况了。

那么就要引申出下一个问题?既然是循环使用,怎么比较大小?怎么保证逻辑上是晚生成的事务号,但却比之前的事务号小?

提示:PG中有2类事务号,>=3的为正常事务号;0 1 2事务号有特殊含义,并且永远比正常事务号小
PG源码中事务号定义如下:
#define InvalidTransactionId    ((TransactionId) 0)  不可用的事务id 
#define BootstrapTransactionId    ((TransactionId) 1)  pg初始化cluster时用的事务id
#define FrozenTransactionId     ((TransactionId) 2)  frozen的事务id

PG使用了比较讨巧的方式,看下面比较事务号的代码:

/*
 * TransactionIdFollowsOrEquals --- is id1 logically >= id2?
 */
bool
TransactionIdFollowsOrEquals(TransactionId id1, TransactionId id2)
{
  int32   diff;
  if (!TransactionIdIsNormal(id1) || !TransactionIdIsNormal(id2))
    return (id1 >= id2); //如果有非正常事务号,永远是非正常事务号小
  diff = (int32) (id1 - id2); //如果
  return (diff >= 0);  
}

通过定义了frozen事务id来保证,所有足够久的事务都对所有事务可见。通过autovaccum 进程定期会扫描page中已经足够久行,把事务号标记成frozen事务号。(这里用“足够久”来解释,其实PG中有对应参数可以设置)。

如何保证并发获取

因为事务号是全局共享的,所以存储在共享内存中,在获取事务号之前,会去持有全局唯一独占锁XidGenLock的lwlock:LWLockAcquire(XidGenLock, LW_EXCLUSIVE)。

define XidGenLock (&MainLWLockArray[3].lock)

MainLWLockArray是一个lwlock的数组,XidGenLock是数组中的第三个元素。LWLock.state 是locker的状态。

*   lwlock.h
*   Lightweight lock manager
typedef struct LWLock
{
  uint16    tranche;    /* tranche ID */
  pg_atomic_uint32 state;   /* state of exclusive/nonexclusive lockers */
  proclist_head waiters;    /* list of waiting PGPROCs */
#ifdef LOCK_DEBUG
  pg_atomic_uint32 nwaiters;  /* number of waiters */
  struct PGPROC *owner;   /* last exclusive owner of the lock */
#endif
} LWLock;
* src/include/port/atomics/fallback.h
typedef struct pg_atomic_uint32
{
  /* Check pg_atomic_flag's definition above for an explanation */
#if defined(__hppa) || defined(__hppa__)  /* HP PA-RISC, GCC and HP compilers */
  int     sema[4];
#else
  int     sema;
#endif
  volatile uint32 value;
} pg_atomic_uint32;
proc是每个进程共享内存的结构
/*
 * src/include/storage/proclist_types.h
 * Header of a doubly-linked list of PGPROCs, identified by pgprocno.
 * An empty list is represented by head == tail == INVALID_PGPROCNO.
 */
typedef struct proclist_head
{
  int     head;     /* pgprocno of the head PGPROC */
  int     tail;     /* pgprocno of the tail PGPROC */
} proclist_head;

LWLockAcquire是获取轻量级锁的函数,如果锁不可用,则sleep到可用为止。如果能马上获取则返回true,返回flase则只能sleep。

函数主调用栈:LWLockAcquire--> LWLockAttemptLock --> pg_atomic_compare_exchange_u32--> pg_atomic_compare_exchange_u32_impl(这个就是所谓的CAS原子操作:compare and swap)

static bool
LWLockAttemptLock(LWLock *lock, LWLockMode mode)
{
/* loop until we've determined whether we could acquire the lock or not */
  while (true)
  {
    uint32    desired_state;
    bool    lock_free;
    desired_state = old_state;
    if (mode == LW_EXCLUSIVE)
    {
      lock_free = (old_state & LW_LOCK_MASK) == 0;
      if (lock_free)
        desired_state += LW_VAL_EXCLUSIVE;
    }
    else
    {
      lock_free = (old_state & LW_VAL_EXCLUSIVE) == 0;
      if (lock_free)
        desired_state += LW_VAL_SHARED;
    }
    ...
  }
  ...
}

old_state用来保存locker的旧状态值。LW_LOCK_MASK =  00000001 11111111 11111111 11111111

(gdb)p old_state
$5 = 536870912
(gdb) p /x old_state
$6 = 0x20000000
(gdb) p /t old_state
$7 = 100000 00000000 00000000 00000000
 old_state & LW_LOCK_MASK =  0 所以 lock_free 是true
desired_state += LW_VAL_EXCLUSIVE;  (LW_VAL_EXCLUSIVE =1 00000000 00000000 00000000)
(gdb) p desired_state
$9 = 553648128
(gdb) p /x desired_state
$10 = 0x21000000
(gdb) p /t desired_state
$11 = 100001 00000000 00000000 00000000

可以看到其实__asm__用的就是内嵌汇编,核心就是cmpxchgl这个汇编指令。比较newval和*expected(也就是old_state) 是否相等,如果相等的话,就 setz就是取ZF(zero flag)的值,设置到ret。

if (pg_atomic_compare_exchange_u32(&lock->state,
                       &old_state, desired_state))
#define PG_HAVE_ATOMIC_COMPARE_EXCHANGE_U32
static inline bool
pg_atomic_compare_exchange_u32_impl(volatile pg_atomic_uint32 *ptr,
                  uint32 *expected, uint32 newval)
{
  char  ret;
  /*
   * Perform cmpxchg and use the zero flag which it implicitly sets when
   * equal to measure the success.
   */
  __asm__ __volatile__(
    " lock        \n"
    " cmpxchgl  %4,%5 \n"
    "   setz    %2    \n"
:   "=a" (*expected), "=m"(ptr->value), "=q" (ret)
:   "a" (*expected), "r" (newval), "m"(ptr->value)
:   "memory", "cc");
  return (bool) ret;
}
伪代码:
%rax = *expected;
if ( *expected ==ptr->value) {
    ptr->value = newval;
    ZF = 1;
} else {
    %rax = ptr->value
    ZF = 0;
}
*expected = %rax
ret = ZF;
return ret;
 
         

相关参数优化

与 vacuum freeze 相关的参数主要有三个:

1. vacuum_freeze_min_age

2. vacuum_freeze_table_age

3. autovacuum_freeze_max_age

vacuum_freeze_min_age

表示表中每个元组需要 freeze 的最小年龄。这里值得一提的是每次表被 freeze 之后,会更新 pg_class 中的 relfrozenxid 列为本次freeze 的 XID。 表年龄就是当前的最新的 XID 与 relfrozenxid 的差值,而元组年龄可以理解为每个元组的t_xmin 与 relfrozenxid 的差值。所以,这个参数也可以被简单理解为每个元组两次被 freeze 之间的 XID 差值的一个最小值。 增大该参数可以避免一些无用的 freeze 操作,减小该参数可以使得在表必须被强制清理之前保留更多的 XID 空间。该参数最大值为 20 亿,最小值为 2 亿。

普通的 vacuum 使用 visibility map 来快速定位哪些数据页需要被扫描,只会扫描那些脏页,其他的数据页即使其中元组对应的 xmin非常旧也不会被扫描。而在 freeze 的过程中,我们是需要对所有可见且未被 all-frozen 的数据页进行扫描,这个扫描过程PostgreSQL称为 aggressive vacuum。每次vacuum 都去扫描每个表所有符合条件的数据页显然是不现实的, 所以我们要选择合理的 aggressive vacuum 周期。 PostgreSQL 引入了参数 vacuum_freeze_table_age 来决定这个周期。

vacuum_freeze_table_age

表示表的年龄大于该值时,会进行 aggressivevacuum,即扫描表中可见且未被 all-frozen 的数据页。该参数最大值为 20 亿,最小值为 1.5 亿。 如果该值为 0,则每次扫描表都进行 aggressive vacuum。

直到这里,我们可以看出:

当表的年龄超过 vacuum_freeze_table_age 则会 aggressive vacuum。当元组的年龄超过 vacuum_freeze_min_age 后可以进行 freeze。为了保证整个数据库的最老最新事务差不能超过 20 亿的原则,两次 aggressivevacuum 之间的新老事务差不能超过 20 亿,即两次 aggressive vacuum 之间表的年龄增长( vacuum_freeze_table_age)不能超过 20 亿减去vacuum_freeze_min_age(只有元组年龄超过 vacuum_freeze_min_age 才会被freeze)。 但是看上面的参数,很明显不能绝对保证这个约束,为了解决这个问题, PostgreSQL 引入了 autovacuum_freeze_max_age 参数。22autovacuum_freeze_max_age 表示如果当前最新的 XID 减去元组的t_xmin 大于等于 autovacuum_freeze_max_age,则元组对应的表会强制进行autovacuum,即使 PostgreSQL 已经关闭了 autovacuum。该参数最小值为 2亿, 最大值为 20 亿。

也就是说, 在经过 autovacuum_freeze_max_age - vacuum_freeze_min_age 的XID 增长之后,这个表肯定会被强制地进行 一次 freeze。因为autovacuum_freeze_max_age 最大值为 20 亿, 所以说在两次 freeze 之间, XID 的增长肯定不会超过 20 亿,这就保证了上文中整个数据库的最老最新事务差不能超过 20 亿的原则。

值得一提的是, vacuum_freeze_table_age 设置的值如果比autovacuum_freeze_max_age 要高,则每次 vacuum_freeze_table_age 生效地时候, autovacuum_freeze_max_age 已经生效,起不到过滤减少数据页扫描的

作用。所以默认的规则, vacuum_freeze_table_age 要设置的比autovacuum_freeze_max_age 小。 但是也不能太小,太小的话会造成频繁的aggressive vacuum。

另外我们通过分析源码可知, vacuum_freeze_table_age 在最后应用时,会去取min(vacuum_freeze_table_age,0.95 autovacuum_freeze_max_age)。所以官方文档推荐 vacuum_freeze_table_age=0.95 autovacuum_freeze_max_age。

23freeze 操作会消耗大量的 IO,对于不经常更新的表,可以合理地增大autovacuum_freeze_max_age 和 vacuum_freeze_min_age 的差值。

但是如果设置 autovacuum_freeze_max_age 和 vacuum_freeze_table_age 过大, 因为需要存储更多的事务提交信息,会造成 pg_xact 和 pg_commit 目录占用更多的空间。例如,我们把 autovacuum_freeze_max_age 设置为最大值

20 亿, pg_xact 大约占 500MB, pg_commit_ts 大约是 20GB(一个事务的提交状态占 2 位) 。 如果是对存储比较敏感的用户,也要考虑这点影响。

而减小 vacuum_freeze_min_age 则会造成 vacuum 做很多无用的工作, 因为当数据库 freeze 了符合条件的 row 后,这个 row 很可能接着会被改变。 理想的状态就是,当该行不会被改变,才去 freeze 这行。

相关实践学习
阿里云图数据库GDB入门与应用
图数据库(Graph Database,简称GDB)是一种支持Property Graph图模型、用于处理高度连接数据查询与存储的实时、可靠的在线数据库服务。它支持Apache TinkerPop Gremlin查询语言,可以帮您快速构建基于高度连接的数据集的应用程序。GDB非常适合社交网络、欺诈检测、推荐引擎、实时图谱、网络/IT运营这类高度互连数据集的场景。 GDB由阿里云自主研发,具备如下优势: 标准图查询语言:支持属性图,高度兼容Gremlin图查询语言。 高度优化的自研引擎:高度优化的自研图计算层和存储层,云盘多副本保障数据超高可靠,支持ACID事务。 服务高可用:支持高可用实例,节点故障迅速转移,保障业务连续性。 易运维:提供备份恢复、自动升级、监控告警、故障切换等丰富的运维功能,大幅降低运维成本。 产品主页:https://www.aliyun.com/product/gdb
相关文章
|
6月前
|
SQL 安全 关系型数据库
【Mysql-12】一文解读【事务】-【基本操作/四大特性/并发事务问题/事务隔离级别】
【Mysql-12】一文解读【事务】-【基本操作/四大特性/并发事务问题/事务隔离级别】
|
供应链 关系型数据库 MySQL
数据隔离级别的隐患:深入探讨MySQL重复读问题
在多用户并发访问数据库时,事务隔离级别是确保数据一致性的重要因素。然而,在某些情况下,事务隔离级别可能会导致"重复读"问题。重复读是指在一个事务内多次读取同一数据,但在读取过程中其他事务对数据进行了修改,导致事务多次读取到不同的数据版本。在MySQL数据库中,重复读问题是需要引起关注的。
112 0
|
SQL Oracle 关系型数据库
深度解析 MySQL 事务、隔离级别和 MVCC 机制:构建高效并发的数据交响乐(一)
深度解析 MySQL 事务、隔离级别和 MVCC 机制:构建高效并发的数据交响乐
399 0
|
6月前
|
关系型数据库 MySQL
mysql事务(开启,回滚,提交,四大特征以及隔离级别)
mysql事务(开启,回滚,提交,四大特征以及隔离级别)
|
SQL 存储 NoSQL
深度解析 MySQL 事务、隔离级别和 MVCC 机制:构建高效并发的数据交响乐(二)
深度解析 MySQL 事务、隔离级别和 MVCC 机制:构建高效并发的数据交响乐(二)
554 0
|
SQL NoSQL 关系型数据库
深度解析 MySQL 事务、隔离级别和 MVCC 机制:构建高效并发的数据交响乐(三)
深度解析 MySQL 事务、隔离级别和 MVCC 机制:构建高效并发的数据交响乐(三)
507 0
|
大数据 数据库 开发者
大数据开发基础的数据库基础的事务/隔离级别/并发/索引等重要机制
在大数据开发中,数据库的事务、隔离级别、并发和索引等机制是非常重要的。这些机制可以帮助我们更好地管理和处理大量的数据,提高数据库的性能和可靠性。以下是这些机制的简要介绍。
101 0
|
SQL 关系型数据库 MySQL
【MySQL】事务基础知识(重点:事务的隔离级别)
本文重点介绍MySQL的数据库事务概述、如何使用事务、事务的四种隔离级别。
172 0
|
SQL 安全 Oracle
mysql事务的理解学习, 面试不问索引原理就是事务原理
mysql事务的理解学习, 面试不问索引原理就是事务原理
mysql事务的理解学习, 面试不问索引原理就是事务原理
|
SQL 关系型数据库 MySQL
MySQL事务并发带来的问题以及其解决方案分析
MySQL事务并发带来的问题以及其解决方案分析
MySQL事务并发带来的问题以及其解决方案分析