暂无图片
暂无图片
3
暂无图片
暂无图片
暂无图片

PostgreSQL细说freeze(二)

原创 yBmZlQzJ 2022-12-23
3666

冻结

当然前面我们讨论了vacuum正常工作的情况,如果vacuum没有正常运行,数据库在不断使用过程中就可能会出现的问题,那就是Transaction ID Wraparound(事务ID回卷)

原本的 TransactionId  是uint32_t 类型,也就是范围是2^32在 [0,4294967296] 区间。PG 这里认为 >= 3 的事务才是正常的事务id,0,1,2都有各自的用处。

对于正常的事务ID的处理,这里会根据两个事务之间的差值做一个强制类型转换,转为 int32,也就是有符号的整型了,在[-2147483648,2147483647] 之间,也就是让 id1 和 id2 的差值 减去 (1<<32),看返回的结果是否<0;是,则认为 id1 更旧,否 则认为id 1 更新。
整个事务ID 的回卷处理体系中,总会有20亿的事务ID 处于过去,20亿的事务ID处于未来

这个就是PostgreSQL为了保证MVCC的一致性,再加上自身的实现机制,而必须要做的一项维护性操作。为了预防事务回卷,然后引入了freeze冻结这个操作,在PostgreSQL里面是一个很繁忙并且消耗资源的事情,大量的读IO(DATAFILE)和写IO(DATAFILE以及XLOG),俗称“冻结炸弹”。

在PostgreSQL里面,一个基本原则是,当前事务只能看到比表上xmin事务号小的记录,也就是说每个事务只能看见xmin比自己XID小且没有被删除的元组,txid(事务id)的最大值为无符号整数,32位,即2^32为4294967296(约40亿),当数据库的事务号到达最大值后事务号就用尽了,此时需要重新使用,又从3(0、1、2为保留的事务id)开始。这就会导致任何原来表上的数据的xmin均大于当前事务号,造成看不到以前的数据现象,这就违背了mvcc的原则 (之前的事务就可以看到这个新事务创建的元组,而新事务不能看到之前事务创建的元组)。当然PostgreSQL不会让这种情况发生,一旦当数据库的年龄到达20亿时(为什么是20亿?)就会采取措施了,对数据库中的表进行清理,以此来降低数据库表的年龄。降低数据库的年龄是autovacuum进程在表的年龄到达阀值后自动进行的,也可以vacuum freeze命令手动执行。autovacuum 操作也有可能会进行部分行freeze而不是全表freeze。

其中,值得注意的是diff = (int32) (id1 - id2)。如果发生了XID 回卷后,即使id1=4294967290比id2=5(回卷后的XID)大,但因为相减后diff大于2^31(约等于20亿),结果值转成int32后会变成一个负数(最高位符号位为1,表示负数),从而让判断逻辑与事务回卷前都是一样的: (int32)(id1 - id2) < 0(即返回true),所以事务id1=4294967290比事务id2=5小。但是如果这里的事务id2是回卷前的XID,就是说数据库内真的有一个特别特别老且没有提交的事务,那么这里就会出现问题。所以,PostgreSQL 就要保证一个数据库中两个有效的事务之间的年龄最多是2^31,即20亿。
也就是说,同一个数据库中,存在的最旧和最新两个事务之间的年龄最多是2^31,即20亿。我们可以把PostgreSQL 中事务ID理解为一个循环可重用的序列串。对其中的任一普通XID(特殊XID 除外)来说,都有20亿个相对它来说过去的事务,都有20亿个未来的事务,事务ID 回卷的问题得到了解决。但是可以看出这个问题得到解决的前提在同一个数据库中存在的最旧和最新两个事务之间的年龄是最多是2^31。

事务ID的冻结

为了保证同一个数据库中的最新和最旧的两个事务之间的年龄不超过2^31,PostgreSQL引入了冻结(freeze)功能。txid=2的事务在参与事务id比较时总是比所有事务都旧,冻结的txid始终处于非活跃状态,并且始终对其他事务可见。
这里涉及到三个与冻结相关的参数:

  1. vacuum_freeze_min_age

  2. vacuum_freeze_table_age

  3. autovacuum_freeze_max_age
    还有涉及到的术语:

  4. 表年龄:当前事务号距上一次表执行freeze操作的事务id的差值

  5. 元组年龄:当前元组的xmin距上一次执行freeze操作的事务id的差值

9.4以后的版本,冻结后的元组是通过行上的infomask标志位实现的,
HEAP_XMIN_FROZEN

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

事务id的范围可以认为组成了一个圆
• 事务id从3开始,到达最大之后又重新从3开始
• 对一个指定的事务ID,当前位置之前有231个事务比其旧,位置之后有231个比其新
• 由vacuum保证整个数据库中的所有表中的xmin和xmax中事务ID的值的范围小于231,太旧的事务ID值会设置为一个特殊的事务ID:FrozenXID
• FrozeXID认为是比所有的事务ID都旧


  • 有三个特殊值的事务ID

  • 0: InvalidXID,无效事务ID

  • 1: BootstrapXID,表示系统表初使化时的事务 ID,比任务普通的事务ID都旧。

  • 2:FrozenXID,冻结的事务ID,比任务普通的 事务ID都旧。

  • 大于2的事务ID都是普通的事务ID。

  • commitlog的大小

  • 理论上,数据库中事务ID最多231个,每个事务 占用2bit,所以commitlog最大512M字节

  • autovacuum_freeze_max_age为2亿,

freeze-普通模式

https://www.interdb.jp/pg/pgsql06.html#_6.5.

  • 冻结过程有两种:一种是lazy 冻结,一种是 aggressive 冻结。

  • 在 lazy 模式下,冻结过程仅使用表对应的空闲空间映射文件中包含死元组的页面。

  • 在Aggressive 模式下,冻结过程会对表的整个页面进行扫描。无论该表页面中是否包含有死元组。并且在可能的情况下才会移除xact(clog)文件。

在冻结开始时,PostgreSQL会计算freezelimit_txid的值,并冻结xmin小于freezelimit_txid的元组,freezelimit_txid的计算前面也提到过,freezelimit_txid = oldestxmin-vacuum_freeze_min_age,vacuum_freeze_min_age可以理解为一个元组可以做freeze的最小间隔年龄,因为事务回卷的问题,这个值最大设置为20亿,oldestxmin代表当前活跃的所有事务中的最小的事务标识,假如有100、101和102三个事务,那么oldestxmin就是100;如果不存在其他事务,那oldestxmin就是当前执行vacuum命令的事务id。
普通vacuum进程会挨个扫描页面,同时配合vm可见性映射跳过不存在死元组的页面,将xmin小于freezelimit_txid的元组t_infomask置为xmin_frozen,清理完成之后,相关统计视图中如pg_stat_user_tables等,n_live_tuple、n_dead_tuple、vacuum_count、autovacuum_count、last_autovacuum、last_vacuum之类的统计信息会被更新。
假设当前的oldestxmin为50002500,那么freezelimit_txid就为50002500 - 50000000 = 2500,那么所有xmin小于2500的元组都会被冻结,如下图,可以看到因为vm文件的原因,跳过了第1个page,导致其中的元组没有被冻结:


freeze-急切冻结

事务ID的冻结——急切模式

普通的vacuum 使用visibility map来快速定位哪些数据页需要被扫描,只会扫描那些脏页,其他的数据页即使其中元组对应的xmin非常旧也不会被扫描。而在freeze的过程中,我们是需要对所有可见且未被all-frozen的数据页进行扫描,这个扫描过程PostgreSQL称为aggressive vacuum急切冻结。每次vacuum都去扫描每个表所有符合条件的数据页显然是不现实的,所以我们要选择合理的aggressive vacuum周期。PostgreSQL引入了参数vacuum_freeze_table_age来决定这个周期,同理该参数的最大值也只能是20亿,当表的年龄大于vacuum_freeze_table_age时,会执行急切冻结,表的年龄通过oldestxmin-pg_class.relfrozenxid计算得到,pg_class.relfrozenxid字段是在某个表被冻结后更新的,代表着某个表最近的冻结事务id。而pg_database.datfrozenxid代表着当前库所有表的最小冻结标识,所以只有当该库具有最小冻结标识的表被冻结时,pg_database.datfrozenxid字段才会被更新。如下:


事务ID的冻结——急切模式

急切冻结的触发条件是pg_database.datfrozenxid < oldestxmin - vacuum_freeze_table_age,这其实和上面的说法不冲突,因为某个数据库所有表中的最老的relfrozenxid就是数据库的datfrozenxid,所以冻结可以用一句话来理解:当数据库中存在某个表的年龄大于vacuum_freeze_table_age参数设定值,就会执行急切冻结过程,当表中元组年龄超过vacuum_freeze_min_age,就可以被冻结,这里其实是必须和可以的区别。
假设当前datfrozenxid为1821,当前事务ID是150002000,1821 < 150002000 - 150000000 = 2000(vacuum_freeze_table_age的默认值),所以会触发急切模式。那么freezelimit_txid = 150002000 - 50000000 = 100002000(oldestxmin - vacuum_freeze_min_age),所有小于freezelimit_txid的元组都会被冻结,并且扫描每一个数据页面,即使某个页面已经被冻结过,如下(其中Tuple1 和Tuple7是死元组,在vacuum的过程中被移除了),Tuple11没有被冻结:



数据库所有表中的最老的pg_class.relfrozenxid就是数据库的pg_database.datfrozenxid:

--获取一个数据库的datfrozenxid
db_sqlfx=#  SELECT datname,datfrozenxid, age(datfrozenxid) FROM pg_database where datname = 'db_sqlfx';
 datname  | datfrozenxid |   age    
----------+--------------+----------
 db_sqlfx |    166733105 | 38859740
(1 row)
--获取这个库中年龄最大的表
db_sqlfx=#   select relname,relfrozenxid, age(relfrozenxid) from pg_class where relfrozenxid !=0 order by age(relfrozenxid) desc limit 1;
   relname   | relfrozenxid |   age    
-------------+--------------+----------
 pg_inherits |    166733105 | 38859740
(1 row)

pg_database.datfrozenxid < oldestxmin - vacuum_freeze_table_age

当数据库中存在某个表的年龄大于vacuum_freeze_table_age参数设定值,就会执行急切冻结过程,当表中元组年龄超过vacuum_freeze_min_age,就可以被冻结

数据库和表的年龄计算

--库年龄
db_sqlfx=#  SELECT datname,datfrozenxid, age(datfrozenxid),txid_current()-datfrozenxid::varchar::numeric as age1 FROM pg_database where datname = 'db_sqlfx';
 datname  | datfrozenxid |   age    |   age1   
----------+--------------+----------+----------
 db_sqlfx |    166733105 | 38859763 | 38859763
(1 row)


--这里我们来计算表年龄使用age命令
db_sqlfx=# select relname,relfrozenxid,age(relfrozenxid) from pg_class limit 3;
       relname        | relfrozenxid |    age     
----------------------+--------------+------------
 testzdf              |    204924434 |     668422
 pg_toast_17501       |    166733114 |   38859742
 pg_toast_17501_index |            0 | 2147483647
(3 rows)
--可以看到表年龄在没有其他事务的情况下,就等于当前事务id-relfrozenxid
db_sqlfx=# select relname,relfrozenxid,age(relfrozenxid),txid_current()-relfrozenxid::varchar::numeric as age1 from pg_class limit 3;
       relname        | relfrozenxid |    age     |   age1    
----------------------+--------------+------------+-----------
 testzdf              |    204924434 |     668423 |    668423
 pg_toast_17501       |    166733114 |   38859743 |  38859743
 pg_toast_17501_index |            0 | 2147483647 | 205592857
(3 rows)
--数据库中不计算索引的年龄,索引的年龄一直都是2147483647,计算的时候要排除

根据前面的公式pg_database.datfrozenxid < oldestxmin - vacuum_freeze_table_age,等价得到vacuum_freeze_table_age < oldestxmin - datfrozenxid,其中oldestxmin-datfrozenxid = age(datfrozenxid)。也就是说当数据库的年龄大于vacuum_freeze_table_age就会执行急切冻结。

事务ID的冻结——急切模式

在PostgreSQL9.6之后,对freeze进行了优化,在vm文件中添加了一个标志位all_frozen。在9.6之前,假如某一个页面之前已经被冻结过,但执行急切模式的freeze依旧会扫描该页面,在9.6之后,通过判断vm文件中的all_frozen标志位,即可判断是否需要冻结该页面,如下,第一个页面的all_frozen的标志位为1,那么就可以跳过该页面,继续冻结第二个页面,冻结完之后再将vm文件的all_frozen标志位置1,可以大幅加速静态表的清理速度:


事务ID的冻结——急切模式

至于autovacuum_freeze_max_age的参数,是针对autovacuum的,如果当前最新的txid减去元组的t_xmin>=autovacuum_freeze_max_age,则元组对应的表会强制进行autovacuum(即使已经关闭了autovacuum),自动进行freeze。该参数最小值为2亿,最大值为20亿。
这里有疑问了,乍一看,有了vacuum_freeze_min_age和vacuum_freeze_table_age就可以解决了,为什么还需要autovacuum_freeze_max_age这个参数呢?举个例子,vacuum_freeze_min_age为2亿,vacuum_freeze_table_age为19亿,假设test表中的部分tuple的年龄达到了2亿,那么这个时候执行freeze的操作,表中部分tuple被冻结,部分没有被冻结,同时更新表的relfrozenxid为2亿。然后假设表的年龄从2亿又一直运行涨到了19亿,然后就需要去执行迫切模式的冻结,但此时某些元祖的年龄前后达到了21亿,超过了20亿的限制。这样就不能保证vacuum_freeze_table_age+vacuum_freeze_min_age<20亿,此时就需要单独弄一个参数来保证新老事务差不超过20亿,这个参数就是autovacuum_freeze_max_age。这个参数会强制限制元组的年龄(oldestxmin-xmin)如果超过该值就必须进行急切冻结操作,这个限制是个硬限制。当表的年龄大于autovacuum_freeze_max_age时(默认是2亿),autovacuum进程会自动对表进行freeze。freeze后,当更新pg_database.datfrozenxid时,PostgreSQL还可以清除掉比整个集群的最老事务号早的clog文件。因为表的最老事务号则是记录在pg_class.relfrozenxid里面的,之前的事务都已经可见了,那么就可以清理CLOG。


pg_visibility

1、创建扩展
create extension pg_visibility ;
2、查看可见性
db_sqlfx=# select  pg_visibility_map('test1');
 pg_visibility_map 
 (0,t,t)
 (1,t,t)
 (2,t,t)
 (3,t,t)
 (4,t,t)
 (5,t,t)
 (6,t,f)
 (7,t,t)
 (8,t,t)
 (9,t,t)
 (10,t,t)
 (11,t,t)
 (12,t,t)
 (13,t,t)
 (14,t,t)

第二列是全部可见all_visible,第三列是全部冻结all_frozen

当冻结的时候根据all_frozen判断这个页面是否有脏数据,1:true,0:false

如果为1也就是t那么就跳过该页面,继续冻结第二个页面,冻结完之后将vm文件的all_frozen标志为1


vacuum full

关于什么时候改vacuum full并没有一个最佳实践,但是可以根据pg_freespacemap来查看表中是否膨胀

create  extension pg_freespacemap;
select count(*) as "number of pages",pg_size_pretty(cast(avg(avail) as bigint)) as "Av. freespace size",round(100 * avg(avail)/8192,2)as "Av. freespace ratio"
from pg_freespace('test')

--查看上面的结果,可以看出没有多少空闲空间
db_sqlfx=# select count(*) as "number of pages",pg_size_pretty(cast(avg(avail) as bigint)) as "Av. freespace size",round(100 * avg(avail)/8192,2)as "Av. freespace ratio"
from pg_freespace('t');
 number of pages | Av. freespace size | Av. freespace ratio 
-----------------+--------------------+---------------------
             885 | 0 bytes            |                0.00
(1 row)
--如果我们删掉大部分数据
db_sqlfx=# delete from t where id >1000;
DELETE 198000
db_sqlfx=# select count(*) as "number of pages",pg_size_pretty(cast(avg(avail) as bigint)) as "Av. freespace size",round(100 * avg(avail)/8192,2)as "Av. freespace ratio"
from pg_freespace('t');
 number of pages | Av. freespace size | Av. freespace ratio 
-----------------+--------------------+---------------------
             885 | 0 bytes            |                0.00
(1 row)
--执行vacuum
db_sqlfx=# vacuum t;
VACUUM
--执行vacuum后,查看发现几乎所有页面都是空的
db_sqlfx=# select count(*) as "number of pages",pg_size_pretty(cast(avg(avail) as bigint)) as "Av. freespace size",round(100 * avg(avail)/8192,2)as "Av. freespace ratio"
from pg_freespace('t');
 number of pages | Av. freespace size | Av. freespace ratio 
-----------------+--------------------+---------------------
             885 | 7160 bytes         |               87.40
(1 row)
--检查表t中每个页面的自由空间占比
db_sqlfx=# select * ,round(100 * avail/8192,2)as "freespace ratio" from pg_freespace('t') limit 100;
blkno | avail | freespace ratio 
-------+-------+-----------------
     0 |     0 |            0.00
     1 |     0 |            0.00
     2 |     0 |            0.00
     3 |     0 |            0.00
     4 |  4160 |           50.00
     5 |  7232 |           88.00
     6 |  7232 |           88.00
     7 |  7232 |           88.00
     8 |  7232 |           88.00
     9 |  7232 |           88.00
    10 |  7232 |           88.00
    11 |  7232 |           88.00
    12 |  7232 |           88.00
    13 |  7232 |           88.00
    14 |  7232 |           88.00
--执行vacuum full以后表会被压实,pages=9
db_sqlfx=# vacuum full t;
VACUUM
db_sqlfx=# select count(*) as "number of pages",pg_size_pretty(cast(avg(avail) as bigint)) as "Av. freespace size",round(100 * avg(avail)/8192,2)as "Av. freespace ratio"
from pg_freespace('t');
 number of pages | Av. freespace size | Av. freespace ratio 
-----------------+--------------------+---------------------
               9 | 0 bytes            |                0.00
(1 row)

细心的同学会发现vacuum full会清理表,但是并不会更新pg_stat_all_tables中的n_dead_tup等统计字段,但是会更新pg_class中的统计信息数据

  • 如何高效的清理呢?

    vacuumdb -f -d postgres -v -e -j 8 --analyze

    -f vacuum full ,-v verbose, -e打印执行的命令,-j并行执行,analyze更新统计信息
    经过测试在加上-j参数以后,并且-f选项以后,某些系统目录被并行清理会死锁,但是用户建的普通表还是可以清理成功(pg12还有这个问题)

事务id回卷

PostgreSQL 在任何时候都有许多事务,这些事务由唯一 ID 跟踪。每隔一段时间,这个数字就会达到可以注册的上限,例如默认的 2 亿笔交易,然后重新编号。但是,如果唯一事务 ID 的数量达到其最大事务限制(称为 TXID Wraparound),Postgres 将强制关闭以保护数据。

它是这样工作的:

  • 40 亿个事务,2^32,是 Postgres 中使用的数据类型的整数上限。

  • 20 亿个事务,2^31,是 PostgreSQL 在强制关闭之前允许的上限。

  • 在达到上限之前的1000 万笔交易,将记录由倒计时组成的警告消息。

  • 在达到上限之前100 万个事务,PostgreSQL 进入 READ-ONLY 模式。

模拟事务id回卷

  • 事务ID耗尽,数据库便会发出如下告警:在4000w个事务的时候就会进行警告。

    database "postgres" must be vacuumed within 40000000 transactions       
"To avoid a database shutdown, execute a database-wide VACUUM in that database.
You might also need to commit or roll back old prepared transactions, 
or drop stale replication slots."    pgbench client backend  
  • 到剩余300w个事务的时候,就不会再被消耗了

could not open file "base/14892/16592": No such file or directory    
automatic vacuum of table "postgres.public.t_vacuum_a"    autovacuum worker   
database "postgres" must be vacuumed within 3000000 transactions        
"To avoid a database shutdown, execute a database-wide VACUUM in that database.
You might also need to commit or roll back old prepared transactions, 
or drop stale replication slots."    autovacuum worker   

总的事务id:

int 32的数据取值范围为[-2^(n-1),2^(n-1)-1],即[-2^31,2^31-1]
select 2^(32-1)-1=2147483647
减去表的年龄,差不多剩余300w个事务id
select 2147483647-2144483647 =3000000

这个时候建议停止数据库,避免数据丢失。进入单用户模式,执行vacuum清理命令

参考资料:

https://www.percona.com/blog/overcoming-vacuum-wraparound/

https://www.percona.com/blog/2018/08/10/tuning-autovacuum-in-postgresql-and-autovacuum-internals/

https://www.percona.com/blog/2018/08/10/tuning-autovacuum-in-postgresql-and-autovacuum-internals/

http://blog.itpub.net/30088583/viewspace-1615226/

pg14中新增:

还有vacuum_failsafe_age  和 vacuum_multixact_failsafe_age 两个参数,用来进来防止POSTGRESQL 数据库冻结炸弹产生的可能

正常情况下, 数据库的年龄应该小于vacuum_freeze_table_age,如果剩余年龄小于2亿, 建议人为干预,将LONG SQL或事务杀掉后,执行vacuum freeze

「喜欢这篇文章,您的关注和赞赏是给作者最好的鼓励」
关注作者
【版权声明】本文为墨天轮用户原创内容,转载时必须标注文章的来源(墨天轮),文章链接,文章作者等基本信息,否则作者和墨天轮有权追究责任。如果您发现墨天轮中有涉嫌抄袭或者侵权的内容,欢迎发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论