pg事务:事务ID

pg中每个事务都会分配事务ID,事务ID分为虚拟事务ID和持久化事务ID。pg的事务ID非常重要,是理解事务、数据可见性、事务ID回卷等等的重要知识点。

虚拟事务ID

只读事务不会分配事务ID,事务ID是很宝贵的资源,比如简单的select语句不会申请事务ID。本身不需要把事务ID持久化到磁盘,但是为了在共享锁等情况下对事务进行标识,需要一种非持久化的事务ID,这个就是虚拟事务ID(vxid)
VXID由两部分组成:backendID 和backend本地计数器。
源码:src/include/storage/lock.h

 typedef struct
{
BackendId    backendId;        /* backendId from PGPROC */
LocalTransactionId localTransactionId;    /* lxid from PGPROC */
} VirtualTransactionId;

(PGPROC是一种存储进程信息的结构体,后面会介绍)

pg_locks可以看到vxid,查询pg_locks本身就是一个sql,会产生vxid

lzldb=# begin;
BEGIN
lzldb=*# select locktype,virtualxid,virtualtransaction,mode from pg_locks;
 locktype | virtualxid | virtualtransaction |   mode    
------------+------------+--------------------+-----------------
 relation		|     		 | 4/16        | AccessShareLock
 virtualxid | 4/16   	 | 4/16        | ExclusiveLock
(2 rows)
 
lzldb=*# savepoint p1;
SAVEPOINT
lzldb=*# select locktype,virtualxid,virtualtransaction,mode from pg_locks;
 locktype 	| virtualxid | virtualtransaction |   mode    
------------+------------+--------------------+-----------------
 relation  	|    		  | 4/16        | AccessShareLock
 virtualxid | 4/16    | 4/16        | ExclusiveLock
lzldb=*# rollback;
ROLLBACK
lzldb=# select locktype,virtualxid,virtualtransaction,mode from pg_locks;
 locktype 	| virtualxid | virtualtransaction |   mode    
------------+------------+--------------------+-----------------
 relation  	|     	  | 4/17        | AccessShareLock
 virtualxid | 4/17    | 4/17        | ExclusiveLock

此时\q退出会话再立即登录,计数仍然继续4/19

另开一个窗口,backendID+1

lzldb=# select locktype,virtualxid,virtualtransaction,mode from pg_locks;
 locktype   | virtualxid | virtualtransaction |   mode    
------------+------------+--------------------+-----------------
 relation   |        | 5/3        | AccessShareLock
 virtualxid | 5/3    | 5/3        | ExclusiveLock

从以上测试能看出:

  • VXID的backendID不是真正的进程号PID,也只是一个简单的递增的编号
  • VXID的bakendID和命令编号都是递增的
  • 子事务没有自己的VXID,他们用父事务的VXID
  • VXID也有回卷,不过问题不严重,因为没有持久化,实例重启后VXID从头开始计数

永久事务ID

32位的TransactionId

当发生数据变化的事务开始时,事务管理器会为事务分配一个唯一标识TransactionIdTransactionId是32位无符号整型,总共可以存储 2^32=4294967296,42亿多个事务。32位无符号整型能存储的数据范围为:0~2^32-1

3个特殊的事务ID

src/include/access/transam.h中宏定义几个事务ID

#define InvalidTransactionId        ((TransactionId) 0)
#define BootstrapTransactionId        ((TransactionId) 1)
#define FrozenTransactionId            ((TransactionId) 2)
#define FirstNormalTransactionId    ((TransactionId) 3)
#define MaxTransactionId            ((TransactionId) 0xFFFFFFFF)

0 代表无效TransactionID

1 代表启动事务ID,只在初始化数据库时才会使用。比所有正常事务都旧

2 代表冻结事务ID。比所有正常事务都旧

#define TransactionIdIsNormal(xid)        ((xid) >= FirstNormalTransactionId)

事务ID>=3时是正常事务id。

最大事务ID MaxTransactionId是0xFFFFFFFF=4294967295=2^32-1
所以正常事务id能分配到的范围为:3~2^32-1

64位的FullTransactionId

事务ID是顺序递增的,PostgreSQL一直使用32位事务ID。在PostgreSQL 7.2之前,当32位事务ID用完时,必须dump然后恢复数据库。而64位的事务ID几乎是用不完的。源码中定义64位FullTransactionId为结构体

/*
 *一个64位的值,包含一个epoch和一个TransactionId。它被封装在一个结构中,以防止隐式转换为TransactionId。
 *并非所有值都表示有效的正常XID。
 */
typedef struct FullTransactionId
{
uint64        value;
} FullTransactionId;

由上面的源码可知,64位的由epoch和32位的TransactionId组成,通过以下函数转化

#define EpochFromFullTransactionId(x)	((uint32) ((x).value >> 32))
#define XidFromFullTransactionId(x)		((uint32) (x).value)

epoch是FullTransactionId右移32位,xid(TransactionId)是FullTransactionId取模。这相当于把32位的TransactionId看成“环”,循环重复使用;64位的FullTransactionId是一直递增的“线”,几乎取不完。

full的事务id可以超过2^32:

在这里插入图片描述

事务ID分配

做几个小实验来看下事务id是怎么分配的。其中用到两个返回事务id的function

pg_current_xact_id ():返回当前事务id,如果当前事务还没有分配事务id,那么分配一个事务id。pg12及以前用txid_current ()

pg_current_xact_id_if_assigned () :返回当前事务id,如果当前事务还没有分配事务id,那么返回NULL。pg12及以前用txid_current_if_assigned ()

事务id顺序分配

lzldb=# select pg_current_xact_id();
 pg_current_xact_id 
--------------------
        612
lzldb=# select pg_current_xact_id();
 pg_current_xact_id 
--------------------
        613
lzldb=# select pg_current_xact_id();
 pg_current_xact_id 
--------------------
        614

begin不会立即分配事务id

lzldb=# begin; --显示开启事务
BEGIN
lzldb=*# select pg_current_xact_id_if_assigned () ; --begin不会立即分配事务id
 pg_current_xact_id_if_assigned 
--------------------------------           

(1 row)
lzldb=*# select * from lzl1; --begin后立即查询
 a 
---

(0 rows)
lzldb=*# select pg_current_xact_id_if_assigned () ; --查询不会分配事务id
 pg_current_xact_id_if_assigned 
--------------------------------               

(1 row)
lzldb=*# insert into lzl1 values(1); --插入数据,做一个数据变更
INSERT 0 1
lzldb=*# select pg_current_xact_id_if_assigned () ; --begin后的第一个非查询语句分配事务id
 pg_current_xact_id_if_assigned 
--------------------------------
             611
lzldb=*# commit;
COMMIT
lzldb=# select xmin, pg_current_xact_id_if_assigned () from lzl1; --insert事务写入到xmin
 xmin | pg_current_xact_id_if_assigned 
------+--------------------------------
 611 

系统表中的有些记录,在数据库初始化时分配了BootstrapTransactionId=1

postgres=# select xmin,count(*) from pg_class where xmin=1 group by xmin;
 xmin | count 
------+-------
  1 |  184

以上实验得出以下结论

  • 数据库初始化时分配特殊事务id 1,可以在系统表中看到
  • 事务id是递增分配的
  • begin不会立即分配事务id,begin后的第一个非查询语句分配事务id
  • 当一个事务插入了一tuple后,会将事务的txid写入这个tuple的xmin。

事务ID对比

pg事务新旧通过事务ID来对比。在src/backend/access/transam/transam.c定义了4种事务ID对比函数,分别是<,<=,>,>=

bool TransactionIdPrecedes()
bool TransactionIdPrecedesOrEquals()
bool TransactionIdFollows()
bool TransactionIdFollowsOrEquals()

内容都差不多,拿TransactionIdPrecedes()代表来看

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

该段源码的知识点

  • TransactionIdIsNormal()是已经在header中宏定义了的判断正常事务的函数,FirstNormalTransactionId是常量3。也就是说正常事务ID是>=3的
#define TransactionIdIsNormal(xid)        ((xid) >= FirstNormalTransactionId)
  • int32是有符号的整型,第一位0表示正数,第一位-1表示负数,取值范围-2*31~2^31-1
  • 数值溢出,意思数值超过数据存储范围,比如2^31对于int32是刚好数值溢出的。为了保证数据在范围内,对数值加减模长

对比事务ID源码分为2段理解

非正常事务ID对比:

if (!TransactionIdIsNormal(id1) || !TransactionIdIsNormal(id2))
return (id1 < id2);

当id1=2,id2=100时,return(2<100),precede为真,正常事务较新

当id1=100,id2=2时,return (100<2),precede为假,正常事务较新

所以,txid为1、2时比正常事务要旧

正常事务ID对比:

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

id1-id2可以是负数,所以diff不能是unsign int,转换有符号型的int。然后最关键的来了

由于int32是-2*31~2^31-1,

当id1=2^31+99,id2=100,id1-id2=2^31-1。这没问题,int32刚好可以存放 =>大txid较新

当id1=2^31+100,id2=100,id1-id2=2^31。这有问题,刚好超出int32存储范围,此时的值为2^31-2^32=-2^31<0 =>小txid较新

当id1=100,id2=2^31+100,id1-id2=-2^31。这没问题,int32刚好可以存放 =>大txid较新

当id1=100,id2=2^31+101,id1-id2=-2^31-1。这有问题,刚好超出int32存储范围,此时的值为-2^31-1+2^32=2^31-1>0 =>小txid较新

以上分析可以看出,当发生数值溢出时,txid大的事务看不见更小的txid事务,本身数值溢出是一个异常事件,这无可厚非。为了解决这个问题,pg将40亿事务id分成两半,一半事务是可见的,另一半事务是不可见的。

比如,txid 100的事务,它过去的20亿事务是它可见的,它未来的20亿事务是它不可见的。所以,在pg数据库中最大事务和最小事务(数据库年龄)之差最大为|-2^31|=2^31,20亿左右

在这里插入图片描述

事务ID回卷

什么是事务ID回卷

理解事务ID回卷本身不难,但是刚开始了解回卷时,发现了事务ID回卷有两种定义:

pg官方定义:

由于事务ID的大小有限(32位),一个长时间运行的集群(超过40亿个事务)将遭遇事务ID的回卷:XID计数器回卷到零,突然之间,过去的事务似乎在未来,这意味着它们变得不可见。简而言之,就是灾难性的数据丢失。(事实上,数据仍然存在,但如果你无法获得数据。)

interdb解释:

元组中t_xmin记录了当前元组的最小事务,如果这个元组一直没有变化,这个t_xmin不会变。假如一个元组tuple_1由txid=100事务创建,它的t_xmin=100。如果数据库事务向前推进了2^31个,到了2^31+100,此时tuple_1是可见的。此时再启动一个事务,txid推进至2^31+101,txid=100的事务属于未来,tuple_1是不可见的,此时便发生了严重的数据丢失问题,这就是事务回卷。

是的,对事物回卷的定义,官方文档与有些经典文章不太一样,他俩确实是在说两个事情。我把这个当成是翻译问题:他俩的行为在英语语义里面都是wraparound。如果重新思考“回卷”(wraparound)的含义,其实它俩都是回卷。

不过回卷形式还是有些区别:前者是事务ID(2^32)全部用完,回卷到0重新计数;后者是把事务ID分成两半,“最老的事务ID“与”最新的事务ID“只差大于2^31

  • pg官方定义的事务id回卷是为了引出“事务ID是一个环”这个概念
  • 一般认为的事务id回卷问题 ,是“把环分成两半,一半为可见,一半为不可见”这个概念,出现“超过一半”的事务id就是事务id回卷

实际上真正需要关心的回卷问题是后者:最新和最旧的事务id相差不能超过21亿(2^31)。

21亿事务到底要跑多久

21亿个事务看上去是挺多,但是仍然可能用完。

比如一个tps为100的pg库(不算select语句,因为单纯的select不会分配事务id),1天会使用8640000个事务,只需要历时2147483648/8640000≈248天就可以把21亿个事务id耗尽发生事务回卷;如果每秒1000个事务,不到1个月时间就可以把21亿事务id用完。所以事务回卷问题是pg数据库中必须要关注的。

事务id冻结

为了解决事务回卷引起严重的数据丢失问题,pg引入事务冻结的概念。

在这里插入图片描述

xid会循环使用,并分成2半,一半可见一半不可见。如xid=100的元组,如果不经过任何操作,事务id一直往前推进,那么这个可见的元组最终将不可见。

在这里插入图片描述

之前介绍有个冻结事务id,此时给xid=100的元组标记为冻结事务id,那么他将仍然可见。

这个就是事务冻结的作用。

事务id FrozenTransactionIdn=2,并且比所有正常事务都旧。也就是说txid=2对于所有正常事务(txid>=3)都是可见的。当t_xmin比当前txid-vacuum_freeze_min_age(默认5000w)更旧时,该元组将重写为冻结事务id 2。在9.4及以后的版本,用t_infomask中的xmin_frozen来表示冻结元组,而不是重写t_xmin为2。

在这里插入图片描述

事务ID回卷问题有许多优化方案,不过都绕不过事务冻结处理回卷问题,而事务冻结这个操作,会有非常大的IO消耗以及cpu消耗(所有表的所有行读一遍,重置标记)无从避免回卷,甚至数据库会拒绝所有操作,直至冻结操作结束,这也是俗称的“冻结炸弹”。业务系统越繁忙,事务越多的库,越容易触发。(后面再开章节展开事务冻结优化)

64位的事务id

事务id耗尽回卷问题终极解决方案就是使用64位的事务ID。32位事务id有2^32个,64位事务id有2^64个。即使每秒10000个事务,每天864000000个事务,也要5849万年才能把事务id消耗光。如果拥有64位事务id,事务id几乎是取之不尽用之不竭,就不需要考虑事务id回卷问题,也不需要事务冻结操作,也就没有“冻结炸弹”的概念…

为什么还没有实现64位事务id?

请注意,64为事务id已经在pg库中了(就像前面介绍的FullTransactionId)。但因为元组存储结构有限,元组中的xmin、xmax等等仍然用的是32位的XID,事务id对比大小仍然依赖32位的XID。xmin,xmax可以简单理解为插入事务和删除事务的事务id,保存在每个元组的header中(元组结构章节将介绍该部分内容),而header空间是有限的。32位事务id有8个字节,64位事务有16个字节,存储xmin、xmax两个事务id将需要额外的16字节空间,目前header无法保存这么大的数据。社区讨论过两种实现方案

1.扩展header。直接将64位事务id存储进去

2.header大小不变。内存中保留64位事务id,增加epoch概念来位移转换两者的关系。

第一种方案已基本放弃,对比其他系统,pg的tuple header已经够大了。

第二种方案epoch已经有了,fulltransactionid转换transactionid已经有了,怎么把元组中的transactionid转换为fulltransactionid是关键(不过怎么也得多一些存储来保存epoch吧,不然怎么实现?)

参考社区邮件

https://www.postgresql.org/message-id/CAEYLb_UfC+HZ4RAP7XuoFZr+2_ktQmS9xqcQgE-rNf5UCqEt5A@mail.gmail.com

https://www.postgresql.org/message-id/flat/DA1E65A4-7C5A-461D-B211-2AD5F9A6F2FD%40gmail.com

2014年社区就提出了64位事务永久解决freeze问题,并于2017年开始讨论如何实践64位事务id,不过经过了多个pg版本也只是只闻其声不见其人。由于数据库对于数据的敏感性和重要性,而事务id的改造对于数据库来说牵扯的东西太多,稍微不注意可能导致数据丢失或者触发未知bug,64位事务id改造的问题pg走的很谨慎。不过社区还是在考虑这个问题,期待有一天在某个pg版本中事务id回卷问题彻底解决。

事务id参考

《Postgresql指南 内幕探索》
https://www.interdb.jp/pg/pgsql05.html
https://www.interdb.jp/pg/pgsql06.html
https://www.slideshare.net/masahikosawada98/introduction-vauum-freezing-xid-wraparound?from_action=save
https://www.modb.pro/db/427012
https://www.modb.pro/db/377530
https://www.postgresql.org/docs/13/routine-vacuuming.html
https://blog.csdn.net/weixin_30916255/article/details/112365965
https://wiki.postgresql.org/wiki/FullTransactionId
https://www.bookstack.cn/read/aliyun-rds-core/bd7e1c1955b35f7d.md
https://github.com/digoal/blog/blob/master/201605/20160520_01.md

  • 1
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

liuzhilongDBA

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值