PgSQL·特性分析·事务ID回卷问题
PgSQL · 特性分析 · 事务ID回卷问题
author: 卓⼑
背景
在之前的中,我们了解到了:
事务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)的功能。通过之前的可知,在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(只有元组年龄超过vacu
um_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(lfrozenxid),lfrozenxid)) as age
FROM pg_class c
LEFT JOIN pg_class t ltoastrelid = t.oid
批量更新sql语句lkind 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监控管理⼯具,⽐如说),它能够:
确定数据库的⾼峰和低峰期
在数据库低峰期创建⼀个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 的回卷问题⼤体相似,我们这⾥不再过多赘述,有兴趣的同学可以去查下相关资料。