缘起:
最近碰到客户遇到事务号回卷问题,导致业务停摆。相关报错如下:
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 这行。