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
当发生数据变化的事务开始时,事务管理器会为事务分配一个唯一标识TransactionId
。TransactionId
是32位无符号整型,总共可以存储232=4294967296,42亿多个事务。32位无符号整型能存储的数据范围为:0~232-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=231+99,id2=100,id1-id2=231-1。这没问题,int32刚好可以存放 =>大txid较新
当id1=231+100,id2=100,id1-id2=231。这有问题,刚好超出int32存储范围,此时的值为231-232=-2^31<0 =>小txid较新
当id1=100,id2=231+100,id1-id2=-231。这没问题,int32刚好可以存放 =>大txid较新
当id1=100,id2=231+101,id1-id2=-231-1。这有问题,刚好超出int32存储范围,此时的值为-231-1+232=2^31-1>0 =>小txid较新
以上分析可以看出,当发生数值溢出时,txid大的事务看不见更小的txid事务,本身数值溢出是一个异常事件,这无可厚非。为了解决这个问题,pg将40亿事务id分成两半,一半事务是可见的,另一半事务是不可见的。
比如,txid 100的事务,它过去的20亿事务是它可见的,它未来的20亿事务是它不可见的。所以,在pg数据库中最大事务和最小事务(数据库年龄)之差最大为|-231|=231,20亿左右
事务ID回卷
什么是事务ID回卷?
理解事务ID回卷本身不难,但是刚开始了解回卷时,发现了事务ID回卷有两种定义:
pg官方定义:
由于事务ID的大小有限(32位),一个长时间运行的集群(超过40亿个事务)将遭遇事务ID的回卷:XID计数器回卷到零,突然之间,过去的事务似乎在未来,这意味着它们变得不可见。简而言之,就是灾难性的数据丢失。(事实上,数据仍然存在,但如果你无法获得数据。)
interdb解释:
元组中t_xmin记录了当前元组的最小事务,如果这个元组一直没有变化,这个t_xmin不会变。假如一个元组tuple_1由txid=100事务创建,它的t_xmin=100。如果数据库事务向前推进了231个,到了231+100,此时tuple_1是可见的。此时再启动一个事务,txid推进至2^31+101,txid=100的事务属于未来,tuple_1是不可见的,此时便发生了严重的数据丢失问题,这就是事务回卷。
是的,对事物回卷的定义,官方文档与有些经典文章不太一样,他俩确实是在说两个事情。我把这个当成是翻译问题:他俩的行为在英语语义里面都是wraparound。如果重新思考“回卷”(wraparound)的含义,其实它俩都是回卷。
不过回卷形式还是有些区别:前者是事务ID(232)全部用完,回卷到0重新计数;后者是把事务ID分成两半,“最老的事务ID“与”最新的事务ID“只差大于231。
- 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有232个,64位事务id有264个。即使每秒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/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