PostgreSQL事务ID回卷

1. 事务id回卷

在postgresql中,由于没有像oracle、mysql那样的undo来实现多版本并发控制,而是当执行dml操作时在表上创建新行,并在每行中用额外的列(xmin,xmax)来记录事务号(xmin为insert或回滚时的事务号、xmax为update或delete的事务号,注意xmin还会记录回滚时的事务号),以此实现多版本并发控制,当然基于此也会导致postgresql中一个比较常见的问题——表膨胀。
当前事务只能看到比表上xmin事务号小的记录,txid(事务id)的最大值为32位,即2^32为4294967296(约40亿),当数据库的事务号到达最大值后事务号就用尽了,此时需要重新使用,又从3(0、1、2为保留的事务id,后面会讲)开始。
这就会导致任何原来表上的数据的xmin均大于当前事务号,造成看不到以前的数据现象,这就违背了mvcc的原则。当然postgresql数据库系统不会让这种情况发生,当数据库的年龄到达20亿(后面会讲为什么是20亿)时就要采取措施了,数据库中的表就需要清理事务号(使用vacuum freeze),以此来降低数据库表的年龄。降低数据库的年龄是autovacuum 进程在表的年龄到达阀值后自动进行的,也可以vacuum freeze命令手动执行。autovacuum 操作也有可能会进行部分行freeze而不是全表freeze。

2. 事务id的分类

关于事务id的源码在src/include/access/transam.h中:

\#define invalidtransactionid ((transactionid) 0)
\#define bootstraptransactionid ((transactionid) 1)
\#define frozentransactionid ((transactionid) 2)
\#define firstnormaltransactionid ((transactionid) 3)
\#define maxtransactionid ((transactionid) 0xffffffff)

0-2都是保留的txid,它们比任何普通txid都要旧。
0:invalidtransactionid,表示无效的事务id
1:bootstraptransactionid,表示系统表初始化时的事务id,比任何普通的事务id都旧。
2:frozentransactionid,冻结的事务id,比任何普通的事务id都旧。
大于2的事务id都是普通的事务id,即从3开始就是普通的事务id。

3. 事务id的分配

postgresql中事务号有两个概念,一个就是通常意义上的事务id,即transaction id,如tuple中的xmin,xmax等。另外一个是虚拟事务id,即virtual transaction id。我们知道,像类似于select这些只读语句,并不会改变数据库;而dml语句会对数据库状态产生影响。transaction id属于永久id。它的意义是指对数据库的更改序列,使得数据库从一种状态变成另外一种状态,而且状态的改变是持久、可恢复的,是一致性的。而查询,实际上并不需要这种永久事务id,只需要处理好mvcc,锁的获取和释放即可,因此virtual transaction id也就足够了。不需要去获取xidgenlock锁而产生transaction id,从而提高数据库性能。另外,数据库也不会因为查询而导致transaction id快速wrap around(回卷),关于transaction id和virtual transaction id可以从pg_locks系统表中查看到相关信息。

4. 事务id的比较

\src\backend\access\transam\transam.c

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

虽然txid空间有42亿,却并非按实际数字大小来判断可见性。postgresql将txid空间一分为二,对于某个特定的txid,其后约21亿个txid属于未来,均不可见;其前约21亿个txid属于过去,均可见。
PostgreSQL事务ID回卷_第1张图片
例如对于txid=100的事务,从101到2^31 +100均为不可见事务(即n+1到n+2^31 );从2^31 +101到99均为可见事务(即n+2^31+1到n-1)。

4.1. 特殊事务和普通事务的比较

首先利用transactionidisnormal判断当前txid是不是普通的txid(即txid>3),前面说过0-2都是保留的txid,它们比任何普通txid都要旧。
比较方法非常简单,就通过
if (!transactionidisnormal(id1) || !transactionidisnormal(id2))
return (id1 < id2);
可以代入值实验一下:
若id1=10,id2=2,return(10<2)。明显10<2为假,所以10比2大,普通事务较新;
若id1=2,id2=10,return(2<10)。2<10为真,所以10比2大,还是普通事务较新。

4.2. 普通事务之间的比较

diff = (int32) (id1 - id2);
return (diff < 0);

由于int 32带符号,需要用最高位表示符号位,所以它能表示的整数比unsigned int 32类型少一半,int 32的数据取值范围为[-2(n-1),2(n-1)-1],即[-231,231-1]。当两个txid相减结果>2^31时,转为int 32后其实是个负数(符号位从0变成了1)。
比如id1=231+101,id2=100。id1-id2=231+1,用二进制表示即:100…(中间30个0)…001。当转为int 32后,由于第一位为符号位,而1表示负数,所以转换后这个值其实就是-1,小于0,因此txid=2^31+101的事务反而要旧。
但是如果图中的100真的是非常非常旧的事务(而非回卷后的id),那它确实应该被2^31+101这个事务看见,此时上面的判断就是错的。
也就是说如果id2确实是回卷前的txid,上面的判断方法就会出现问题。所以为了避免这种问题,postgresql必须保证一个数据库中两个有效的事务之间的年龄最多是231,即20亿(同一个数据库中,存在的最旧和最新两个事务txid相差不得超过231,但是同一个实例下的数据库获取的事务id是线性增长的,假如一个数据库实例下,有a、b两个数据库,a数据库当前事务id是n,那么b数据库获取的下一个事务id就是n+1,a数据库再获取的事务id就是n+2,以此类推)。

5. 事务id冻结

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

这里涉及到三个与冻结相关的参数:

1、vacuum_freeze_min_age

2、vacuum_freeze_table_age

3、autovacuum_freeze_max_age

还有涉及到的术语:

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

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

xcc_test=# select txid_current();

 txid_current

--------------

         6265

xcc_test=# create table test(id int);                            

create table

xcc_test=# insert into test values(1);                            

insert 0 1

xcc_test=# insert into test values(2);

insert 0 1

xcc_test=# insert into test values(3);

insert 0 1

xcc_test=# select relfrozenxid,age(relfrozenxid) from sys_class where relname = 'test';                          

 relfrozenxid | age

--------------+-----

         6266 |   4

(1 row)

xcc_test=# select t_xmin,t_xmax,t_infomask,t_infomask2,age(t_xmin) from heap_page_items(get_raw_page('test', 0));

 t_xmin | t_xmax | t_infomask | t_infomask2 | age

--------+--------+------------+-------------+-----

   6267 |      0 |       2048 |           1 |   3

   6268 |      0 |       2048 |           1 |   2

   6269 |      0 |       2048 |           1 |   1

可以看到,表年龄此处为4,最开始的relfrozenxid为6266(即最开始创建表的事务,因为还没有执行过冻结操作)

执行一次冻结操作:

xcc_test=# vacuum freeze test ;

vacuum

xcc_test=# select relfrozenxid,age(relfrozenxid) from sys_class where relname = 'test';                                     

 relfrozenxid | age

--------------+-----

         6270 |   0

(1 row)

xcc_test=# select t_xmin,t_xmax,t_infomask,t_infomask2,age(t_xmin) from heap_page_items(get_raw_page('test', 0));           

 t_xmin | t_xmax | t_infomask | t_infomask2 | age

--------+--------+------------+-------------+-----

   6267 |      0 |       2816 |           1 |   3

   6268 |      0 |       2816 |           1 |   2

   6269 |      0 |       2816 |           1 |   1

可以看到,表年龄变为了0,因为执行冻结操作的事务是6270,年龄为0;注意观察tuple的t_infomask变成了2816,因为冻结操作设置了该位为txmin_frozen(postgresql9.4之后的版本)。

src/include/access/htup_details.h
/*
* information stored in t_infomask:
*/

#define heap_xmin_committed 0x0100 /* t_xmin committed */
#define heap_xmin_invalid 0x0200/* t_xmin invalid/aborted */
#define heap_xmin_frozen (heap_xmin_committed|heap_xmin_invalid)

另外需要注意的是,带有freeze选项的vacuum命令会强制冻结指定表中的所有事务标识。虽然这是在迫切模式下执行的,但是这里的freezelimit(后面会讲,pg_class.relfrozenxid会被设置为freezelimit的值,freezelimit = oldestxmin - vacuum_freeze_min_age)会被设置为oldestxmin而不是oldestxmin - vacuum_freeze_min_age。例如,当txid=5000的事务执行vacuum full命令,且没有其他正在运行的事务时,oldestxmin会被设置为5000,而t_xmin小于5000的元组将会被冻结。

如果发生当新老事务id差超过21亿的时候,事务号会发生回卷,此时数据库会报出如下错误并且拒绝接受所有连接,必须进入单用户模式执行vacuum freeze操作。

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

所以冻结过程应该在平时不断地自动做而不是等到事务号需要回卷的时候才去做。这时就需要引入一个参数:vacuum_freeze_min_age(默认5000万),当冻结过程在扫描表页面元组的时候发现元组xmin比当前事务号current_txid-vacuum_freeze_min_age更小时,就可以将该元组事务id置为2,换个角度理解,也就是对于当前事务来说,如果存在某个元组的年龄超过vacuum_freeze_min_age参数值时(这里可以这么理解,假如元组的xmin

其中冻结又分为“惰性模式”和“急切模式”。惰性模式在扫描过程中仅使用vm文件,而急切模式会扫描的所有数据文件,并在可能的时候清理无用的clog文件。其中,vm文件如下:

PostgreSQL事务ID回卷_第2张图片

5.1. 惰性模式

在冻结开始时,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 - 5000000 = 2500,那么所有xmin小于2500的元组都会被冻结,如下图,可以看到因为vm文件的原因,跳过了第1个page,导致其中的元组没有被冻结。
PostgreSQL事务ID回卷_第3张图片
注意:在9.4之前的postgresql版本中,实际上会通过将一行的xmin替换为 frozentransactionid来实现冻结,这种frozentransactionid在行的 xmin系统列中是可见的。较新的版本只是设置一个标志位,保留行的原始xmin用于可能发生的鉴别用途。不过, 在9.4之前版本的数据库pg_upgrade中可能仍会找到 xmin等于frozentransactionid (2)的行。

此外,系统目录可能会包含xmin等于bootstraptransactionid (1) 的行,这表示它们是在initdb的第一个阶段被插入的。和frozentransactionid相似,这个特殊的xid被认为比所有正常xid的年龄都要老。

5.2. 急切模式

普通的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字段才会被更新。急切冻结的触发条件是pg_database.datfrozenxid 假设oldestxmin为150002000,那么freezelimit_txid = 150002000 - 50000000 = 100002000,所有小于freezelimit_txid的元组都会被冻结,并且扫描每一个数据页面,即使某个页面已经被冻结过,如下:

PostgreSQL事务ID回卷_第4张图片
在postgresql9.6之后,对freeze进行了优化,在vm文件中添加了一个标志位all_frozen。在9.6之前,假如某一个页面之前已经被冻结过,但执行急切模式的freeze依旧会扫描该页面,在9.6之后,通过判断vm文件中的all_frozen标志位,即可判断是否需要冻结该页面,如下,第一个页面的all_frozen的标志位为1,那么就可以跳过该页面,继续冻结第二个页面,冻结完之后再将vm文件的all_frozen标志位置1:

PostgreSQL事务ID回卷_第5张图片
至于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)如果超过该值就必须进行急切冻结操作,这个限制是个硬限制,即使PostgreSQL已经关闭了autovacuum。

当表的年龄大于autovacuum_freeze_max_age时(默认是2亿),autovacuum进程会自动对表进行freeze。

freeze后,当更新pg_database.datfrozenxid时,postgresql还可以清除掉比整个集群的最老事务号早的clog文件。表的最老事务号则是记录在pg_class.relfrozenxid里面的。

6. 运维

1.查询所有表的年龄: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’);

2.查询所有数据库的年龄:select datname, age(datfrozenxid) from pg_database;

3.监控工具:flexible-freeze,链接:https://github.com/pgexperts/flexible-freeze,它能够:1、会自动对具有最老xid的表进行vacuum freeze;2、确定数据库的高峰和低峰期等等

4.推荐德哥的一篇博文:PostgreSQL Freeze 风暴预测续 - 珍藏级SQL

7. 最佳实践

在postgresql中,vacuum是一个比较耗费io的过程,而vacuum freeze更是被称为“冻结炸弹”,因为涉及到了大量的读写io,读io(datafile)和写io(datafile以及写xlog)。对于业务繁忙的库,可能会出现如下情况:

可能有很多大表的年龄会先后到达2亿,数据库的autovacuum会开始对这些表依次进行vacuum freeze,从而集中式的爆发大量的读写io,数据库和操作系统响应迟缓,如果又碰上业务高峰,会出现很不好的影响。所以设置好参数尤为重要:

1.设置vacuum_cost_delay为一个比较高的数值(例如50ms),这样可以减少普通vacuum对正常数据查询的影响

2.autovacuum_freeze_max_age的值应该大于vacuum_freeze_table_age的值,因为如果反过来设置,那么每次当表年龄vacuum_freeze_table_age达到时,autovacuum_freeze_max_age也达到了,那么刚刚做的freeze操作又会去扫描一遍,造成浪费。但是vacuum_freeze_table_age的值也不能太小,太小的话会造成频繁的急切冻结,官方文档建议为95%。

3.执行急切冻结时,vacuum_freeze_table_age真正的值会去取vacuum_freeze_table_age和0.95 * autovacuum_freeze_max_age中的较小值,所以建议将vacuum_freeze_table_age设置为0.95 * autovacuum_freeze_max_age。

4.autovacuum_freeze_max_age和vacuum_freeze_table_age的值也不适合设置过大,因为过大会造成pg_clog中的日志文件堆积,来不及清理(执行迫切模式的冻结还会清理掉无用的clog文件)。如果设置过大,因为需要存储更多的事务提交信息,会造成pg_xact 和 pg_commit目录占用更多的空间。例如,我们把autovacuum_freeze_max_age设置为最大值20亿,pg_xact大约占500mb,pg_commit_ts大约是20gb(一个事务的提交状态占2位)。如果是对存储比较敏感的用户,也要考虑这点影响。

5.vacuum_freeze_min_age不易设置过小,比如我们freeze某个元组后,这个元组马上又被更新,那么之前的freeze操作其实是无用功,freeze真正应该针对的是那些长时间不被更新的元组。

6.生产环境中做好pg_database.frozenxid的监控,当快达到触发值时,我们应该选择一个业务低峰期窗口主动执行vacuum freeze操作,而不是等待数据库被动触发。

7.分区,把大表分成小表。每个表的数据量取决于系统的io能力,前面说了vacuum freeze是扫全表的,现代的硬件每个表建议不超过32gb,单表数据不要超过3000w。

8.对大表设置不同的vacuum年龄

9.table t set (autovacuum_freeze_max_age=xxxx);

10.用户自己调度 freeze,如在业务低谷的时间窗口,对年龄较大,数据量较大的表进行vacuum freeze。

11.年龄只能降到系统存在的最早的长事务即 min(pg_stat_activity.(backend_xid, backend_xmin))。因此也需要密切关注长事务。


转自:https://blog.csdn.net/ctypyb2002/article/details/119739578

你可能感兴趣的:(PostgreSQL,postgresql,数据库)