数据库内核月报 - 2018 / 03

PgSQL · 特性分析 · 事务ID回卷问题

背景

在之前的月报 PgSQL · 特性分析 · MVCC机制浅析中,我们了解到了:

  • 事务ID(XID)使用32位无符号数来表示,顺序产生,依次递增
  • 每个元组会来用(t_xmin, t_xmax)来标示自己的可用性
  • t_xmin 存储的是产生这个元组的事务ID,可能是insert或者update语句
  • t_xmax 存储的是删除或者锁定这个元组的XID
  • 每个事务只能看见t_xmin比自己XID 小且没有被删除的元组

其中需要注意的是,XID 是用32位无符号数来表示的,也就是说如果不引入特殊的处理,当PostgreSQL的XID 到达40亿,会造成溢出,从而新的XID 为0。而按照PostgreSQL的MVCC 机制实现,之前的事务就可以看到这个新事务创建的元组,而新事务不能看到之前事务创建的元组,这违反了事务的可见性。本文将这种现象称为XID 的回卷问题。

使用64位无符号数表示XID 可以缓解甚至解决这个问题。但是因为数据页中每个元组都会存储(xmin,xmax),这样势必会造成元组头部信息继续扩大,至少扩大2个字节。两者权衡,PostgreSQL 社区更加期望将这些内容放在缓存中,而只用数据页的一个bit 位来达到64位XID 的效果(详见 邮件列表)。目前来看,PostgreSQL 社区也是逐渐在实现这点。我们接下来主要来分析PostgreSQL 是如何解决这个问题的。

两个事务ID的比较方法

在详细讲解PostgreSQL 解决这个问题的方法之前,我们需要先了解下两个XID 是如何进行比较的。这部分代码非常的简单易懂,具体如下:

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

其中,值得注意的是diff = (int32) (id1 - id2)。如果发生了XID 回卷后,即使id1=4294967290比id2=5(回卷后的XID)大,但因为相减后diff大于2^31,结果值转成int32后会变成一个负数,从而让判断逻辑与事务回卷前都是一样的: (int32)(id1 - id2) < 0。但是如果这里的id2 是回卷前的XID,则这里就会出现问题。所以,PostgreSQL 就要保证一个数据库中两个有效的事务之间的年龄最多是2^31,即20亿。

也就是说:

  • PostgreSQL中是使用2^31取模的方法来进行事务的比较
  • 同一个数据库中,存在的最旧和最新两个事务之间的年龄最多是2^31,即20亿

这时,我们可以把PostgreSQL 中事务ID理解为一个循环可重用的序列串。对其中的任一普通XID(特殊XID 除外)来说,都有20亿个相对它来说过去的事务,都有20亿个未来的事务,事务ID 回卷的问题得到了解决。但是可以看出这个问题得到解决的前提在同一个数据库中存在的最旧和最新两个事务之间的年龄是最多是2^31,接下来我们将分析PostgreSQL 如何做到的这一点。

冻结清理

为了实现同一个数据库中的最新和最旧的两个事务之间的年龄不超过2^31,PostgreSQL 引入了冻结清理(freeze)的功能。通过之前的月报 PgSQL · 源码分析 · AutoVacuum机制之autovacuum worker 可知,在autovacuum过程中,会自动对符合条件的元组进行freeze。为了不让问题扩散,我们会在下文具体分析符合什么条件的元组才需要freeze,这里会先分析不同的PostgreSQL版本freeze具体的实现。

9.4之前冻结清理实现

在9.4之前的版本中,freeze实现的方法很简单。就是对符合条件的元组直接更新元组信息(HeapTupleFields结构体)中的t_xmin 属性为一个特殊的XID,FrozenTransactionId(FrozenTransactionId 为2,initdb产生的catalog所对应的XID 为1,0代表无效的XID)。

以FrozenTransactionId为t_xmin的元组将会被其他所有的事务可见,这样该元组原来对应的XID 相当于被回收了,经过不断的处理,就可以控制一个数据库的最老的事务和最新的事务的年龄不超过20亿。

但是这样的实现有很多问题:

  • 当前可见的数据页(通过visibility map可以快速定位)需要全部扫描,带来大量的IO扫描
  • 符合条件的元组需要更新xmin,造成大量的脏页,带来大量的IO

9.4之后冻结清理实现

为了解决之前老版本存在的问题,9.4之后(包含9.4)不直接修改HeapTupleFields中的t_xmin,而是:

  • 只更新元组头部信息(HeapTupleHeaderData结构体)的t_infomask为HEAP_XMIN_FROZEN,表示该元组已经被冻结清理过(frozen)
  • 有些插入操作,也可以直接将记录置为frozen,例如大批量的COPY数据,insert into等
  • 整个page 如果所有记录已经frozen,则再vm文件中标记为FROZEN,冻结清理会跳过该页,减少了IO扫描

其中值得注意的是,如果vm页损坏了,可以通过vacuum DISABLE_PAGE_SKIPPING强制扫描所有的数据页。

可以看出,9.4之后对freeze的实现进行了很多方面的优化,提高了其性能。不过如果是9.4之前的数据通过pg_upgrade的脚本导入的数据,仍然会发现有t_xmin 为2的元组。当然除了上文讲到的autovaccum可以周期性地进行freeze之外,我们还可以执行VACUUM FREEZE命令来强制freeze。

至此,我们弄清楚了freeze是怎么实现的,接下来会去分析元组满足什么样的条件才会触发周期性的freeze。在PostgreSQL,这个条件是由一系列的参数设置来实现的,研究好这些参数的含义,将会更加有利于我们的日常运维。

涉及到的参数

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

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

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

  • 当表的年龄超过vacuum_freeze_table_age则会aggressive vacuum
  • 当元组的年龄超过vacuum_freeze_min_age后可以进行freeze

为了保证上文中整个数据库的最老最新事务差不能超过20亿的原则,两次aggressive vacuum之间的新老事务差不能超过20亿,即两次aggressive vacuum之间表的年龄增长(vacuum_freeze_table_age)不能超过20亿减去vacuum_freeze_min_age(只有元组年龄超过vacuum_freeze_min_age才会被freeze)。但是看上面的参数,很明显不能绝对保证这个约束,为了解决这个问题,PostgreSQL 引入了autovacuum_freeze_max_age 参数。

autovacuum_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。

freeze 操作会消耗大量的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 这行。

但是遗憾的是,无论参数怎么调优,都存在一个问题,freeze是不能主动预测的,只能被动触发,所以更提倡用户进行主动预测需要freeze 的时机,选择合适的时间(比如说应用负载较低的时间)主动执行vacuum freeze命令。接下来我们会具体讨论如何去做关于vacuum freeze 的运维。

运维建议

由于参数设置问题或者其他问题,造成freeze 失败,导致数据库最老的表年龄达到了1000万的时候,数据库会打印如下的warning:

    WARNING:  database "mydb" must be vacuumed within 177009986 transactions
HINT:  To avoid a database shutdown, execute a database-wide VACUUM in "mydb".

根据提示,对该数据库执行vacuum free命令,可以解决这个潜在的问题。注意因为非超级用户没有权限更新database的datfrozenxid,只能使用超级用户执行acuum free database_name。

如果数据库可用的XID 空间还有100万的时候,即当前最新XID 与数据库最老的XID 的差值还差100万达到20亿,则PostgreSQL 会变为只读并拒绝开启任何新的事务,同时在日志中打印如下错误信息:

ERROR:  database is not accepting commands to avoid wraparound data loss in database "mydb"
HINT:  Stop the postmaster and vacuum that database in single-user mode.

如果出现了这种情况,根据提示,用户可以以单用户模式(single-user mode,详见链接)的方法启动PostgreSQL并执行vacuum freeze命令。

可以看出,参数的正确设置是非常重要的。但是上文说过即使参数设置的比较合适,因为不能预测freeze 发生的时间,如果freeze发生的时间正好是数据库比较繁忙的时间,这就会造成IO资源争抢,导致正常的业务受损。用户可以自己监控数据库和表的年龄,在业务比较空闲的时间主动执行以下操作:

  • 查询当前所有表的年龄,SQL 语句如下:
    SELECT c.oid::regclass as table_name,
         greatest(age(c.relfrozenxid),age(t.relfrozenxid)) as age
    FROM pg_class c
    LEFT JOIN pg_class t ON c.reltoastrelid = t.oid
    WHERE c.relkind IN ('r', 'm');
    
  • 查询所有数据库的年龄,SQL 语句如下:
    SELECT datname, age(datfrozenxid) FROM pg_database;
    
  • 设置vacuum_cost_delay为一个比较高的数值(例如50ms),这样可以减少普通vacuum对正常数据查询的影响
  • 设置vacuum_freeze_table_age=0.5 * autovacuum_freeze_max_age,vacuum_freeze_min_age为原来值的0.1倍
  • 对上面查询的表依次执行vacuum freeze,注意要预估好时间。

目前已经有很多实现好的开源PostgreSQL vacuum freeze监控管理工具,比如说flexible-freeze),它能够:

  • 确定数据库的高峰和低峰期
  • 在数据库低峰期创建一个cron job 去执行flexible_freeze.py
  • flexible_freeze.py 会自动对具有最老XID的表进行vacuum freeze

总结

至此,我们已经从各个角度分析了PostgreSQL 中出现的事务ID 回卷问题的解决方法。总结起来就是:

  1. XID 可循环利用
  2. XID 比较实用mod 2^31的方法
  3. 同一个数据库中,存在的最旧和最新两个事务之间的年龄最大为2^31,即20亿
  4. 当元组满足一定条件时,将其freeze,从而实现了将其对应的XID回收的操作
  5. 通过vacuum_freeze_min_age,vacuum_freeze_table_age,autovacuum_freeze_max_age参数配合,让freeze 操作更平滑,更高效

不过,上文中我们并没有涉及Multixacts ID 的回卷问题。Multixacts ID 的回卷和XID 的回卷问题大体相似,我们这里不再过多赘述,有兴趣的同学可以去查找下相关资料。