MVCC,Multi-Version Concurrency Control,多版本并发控制。
PostgreSQL数据库中MVCC是通过多版本来实现的,在PostgreSQL中,对表数据进行delete删除, 它没有在表数据文件上真正的删除,而是标记为无效状态,对表数据进行update更新操作,它对旧的数据进行标记无效以及插入新的行数据。对于delete和update操作产生的死元组现象叫为数据膨胀,PostgreSQL默认开启autovacuum来处理膨胀或者使用vacuum full来回收空间,但今天主要学习的是PostgreSQL数据库中MVCC实现的原理。
在了解MVCC之前,先了解一下pageinspect
、事务ID和表的隐藏字段。
pageinspect
是PG中的模块插件,它提供的函数让你从低层次观察数据库页面的内容。主要有以下3个函数:
get_raw_page(relname text, fork text, blkno int)
读取提及的关系中的指定块并且以一个
bytea
值的形式返回一个拷贝。这允许得到该块的一个单一的时间一致的拷贝。对于主数据分叉,fork
应该是'main'
,对于空闲空间映射应该是'fsm'
,对于可见性映射应该是'vm'
,对于初始化分叉应该是'init'
。get_raw_page(relname text, blkno int) , 用于读取主分叉。等效于get_raw_page(relname, 'main', blkno)
page_header(page bytea)
显示所有PostgreSQL堆和索引页面的公共域 ,并 用
get_raw_page
获得的一个页面映像应该作为参数传递test=# SELECT * FROM page_header(get_raw_page('pg_class', 0));
lsn | checksum | flags | lower | upper | special | pagesize | version | prune_xid
-----------+----------+--------+-------+-------+---------+----------+---------+-----------
0/24A1B50 | 0 | 1 | 232 | 368 | 8192 | 8192 | 4 | 0
heap_page_items(page bytea)
显示一个堆页面上所有的行指针。对那些使用中的行指针,元组头部和元组原始数据也会被显示。不管元组对于拷贝原始页面时的 MVCC 快照是否可见,它们都会被显示
temp=# select * from heap_page_items(get_raw_page('tmp', 0));
lp | lp_off | lp_flags | lp_len | t_xmin | t_xmax | t_field3 | t_ctid | t_infomask2 | t_infomask | t_hoff | t_bits | t_oid | t_data
----+--------+----------+--------+---------+--------+----------+--------+-------------+------------+--------+--------+-------+------------------------
1 | 8152 | 1 | 34 | 8536658 | 0 | 0 | (0,1) | 2 | 2050 | 24 | | | \x010000000d7465737431
2 | 8112 | 1 | 34 | 8536658 | 0 | 0 | (0,2) | 2 | 2050 | 24 | | | \x020000000d7465737431主要关注下面4个字段
字段名称 字段长度 字段描述 t_xmin 4bytes 保存插入元组的事务的txid t_xmax 4bytes 保存删除或更新此元组的事务的txid。如果尚未删除或更新,则设置为0,即无效 t_field3 4bytes 在当前事务里执行当前命令前执行了多少条sql命令。从0开始计数,比如一个事务依次执行:BEGIN; INSERT; INSERT; INSERT; COMMIT;那么第一个insert命令插入的元组其t_field3设为0,第二个insert命令插入的元组其t_field3设为1,以此类推。其它版本中名字是t_cid t_ctid 6bytes 用于标识表中的元组。在更新该元组时,t_ctid会指向新版本的元组,否则指向自身。
事务ID
在PostgreSQL中,每个事务都有一个唯一的事务ID,被称为XID。
事务id类型是int32, 当事务id超过40亿( 2^32 = 4294967296 )以后就会有溢出的风险,为了防止事务回卷,PostgreSQL通过vacuum freeze的方式来循环利用这些事务号,如果vacuum freeze 不及时或者遇到故障,数据库会给相关报警提示,甚至关闭数据库。
--查看当前全局事务ID
temp=# select txid_current_snapshot();
txid_current_snapshot
-----------------------
8536660:8536660:
(1 row)
--获取当前事务的ID
temp=# select txid_current();
txid_current
--------------
8536660
(1 row)
通过txid_current()函数来有时会发现当前的事务号已经远远大于2^32。因为我们这时查询事务id用的是txid而不是xid,txid是不会被循环的64位整形数值,需要通过epoch来实现txid和xid转换。
转换公式:
txid = xid + epoch \* 2^32epoch可以通过pg_controldata命令获取到,即NextXID中分号前面的数值 ,
NextXID: 0:8536683,epoch为0
[postgres@yejf ~]$ pg_controldata
pg_control version number: 1201
Catalog version number: 201909212
Database system identifier: 7114463676545673794
Database cluster state: in production
...
Latest checkpoint's NextXID: 0:8536683
Latest checkpoint's NextOID: 25510
...
表中系统字段
PostgreSQL中,对于每一行数据都有几个隐藏字段。这些字段是隐藏的,但可直接访问。
oid:行对象标识符(对象id)。该字段在建表时指定
with oids
或者配置参数defaul_with_oids
会存在,字段类型为oidtableoid:包含本行的表的oid。对父表进行查询时,可以得知该行时来自父表还是子表以及是那张表表;tableoid和pg_class 表的oid关联获取表明
xmin:插入该行版本的事务id
xmax:删除此行时的事务id,第一次插入值为0,如不为0:删除该行事务未提交或删除该行事务已回滚;对于未删除的行版本为0。对于一个可见的行版本,该列值也可能为非零。这通常表示删除事务还没有提交,或者一个删除尝试被回滚。
cmin:事务内部插入类操作的命令id,次标识从0开始
cmax:事务删除此操纵的命令id,如果不是删除命令,值为0
ctid:一个行版本在它所处表内的物理位置
temp=# SELECT *, xmin, xmax, cmin, cmax,ctid,tableoid from tmp;
id | info | xmin | xmax | cmin | cmax | ctid | tableoid
----+-------+---------+---------+------+------+-------+----------
1 | test1 | 8536679 | 0 | 0 | 0 | (0,1) | 17396
2 | test1 | 8536679 | 8536681 | 0 | 0 | (0,2) | 17396
其中xmin, xmax, cmin, cmax 在MVCC实现中用于控制数据行是否对用户可见 。
新插入一行,新插入的行xmin填写当前事务ID,xmax为 0
修改某一行,实际上是插入新一行,旧行xmin不变,旧行xmax变更为当前事务id,新行xmin为当前事务id,新行xmax为0
删除一行时,被删除行xmax改为当前事务ID
cmin和cmax用于判断同一事务内的不同命令导致的行版本变化是否可见
实例分析
一个事务执行一条SQL
下面通过pageinspect
模块的函数和多版本的标记字段来进行实例分析。
首先确定当前的事务隔离级别是读已提交,这也是pg默认的隔离级别
temp=# show transaction_isolation ;
transaction_isolation
-----------------------
read committed
新创建一张表
drop table tmp;
create table tmp(id int,info varchar(100));
插入数据,如果当前库没有其它提交的事务,插入后表隐藏字段记录的事务ID应该是上面的前面全局事务ID值
temp=# select txid_current_snapshot();
txid_current_snapshot
-----------------------
8536685:8536685:
(1 row)
temp=# insert into tmp select generate_series(1,2),'test1';
INSERT 0 2
temp=# SELECT *, xmin, xmax, cmin, cmax,ctid from tmp;
id | info | xmin | xmax | cmin | cmax | ctid
----+-------+---------+------+------+------+-------
1 | test1 | 8536685 | 0 | 0 | 0 | (0,1)
2 | test1 | 8536685 | 0 | 0 | 0 | (0,2)
(2 rows)
通过函数heap_page_items和get_raw_page查看表数据原始页面信息,t_xmin同样是8536685
temp=# select t_xmin,t_xmax,t_field3,t_ctid from heap_page_items(get_raw_page('tmp', 0));
t_xmin | t_xmax | t_field3 | t_ctid
---------+--------+----------+--------
8536685 | 0 | 0 | (0,1)
8536685 | 0 | 0 | (0,2)
(2 rows)
新插入一行时,将新插入行的xmin填写为当前的事务ID,xmax填0。
temp=# select txid_current_snapshot();
txid_current_snapshot
-----------------------
8536696:8536696:
(1 row)
temp=# insert into tmp values(3,'3');
INSERT 0 1
temp=# SELECT xmin, xmax, cmin, cmax,ctid,* from tmp;
xmin | xmax | cmin | cmax | ctid | id | info
---------+------+------+------+-------+----+-------
8536685 | 0 | 0 | 0 | (0,1) | 1 | test1
8536685 | 0 | 0 | 0 | (0,2) | 2 | test1
8536696 | 0 | 0 | 0 | (0,3) | 3 | 3
(3 rows)
temp=# select t_xmin,t_xmax,t_field3,t_ctid from heap_page_items(get_raw_page('tmp', 0));
t_xmin | t_xmax | t_field3 | t_ctid
---------+--------+----------+--------
8536685 | 0 | 0 | (0,1)
8536685 | 0 | 0 | (0,2)
8536696 | 0 | 0 | (0,3)
(3 rows)
修改某行时,实际上操作是插入一行,旧行上的xmin不变,旧行上的xmax改为当前的事务ID,新行上的xmin填为当前事务ID,新行上的xmax填为0。
temp=# select txid_current_snapshot();
txid_current_snapshot
-----------------------
8536700:8536700:
(1 row)
temp=# update tmp set info='a' where id =1;
UPDATE 1
temp=# SELECT xmin, xmax, cmin, cmax,ctid,* from tmp;
xmin | xmax | cmin | cmax | ctid | id | info
---------+------+------+------+-------+----+-------
8536685 | 0 | 0 | 0 | (0,2) | 2 | test1
8536696 | 0 | 0 | 0 | (0,3) | 3 | 3
8536700 | 0 | 0 | 0 | (0,4) | 1 | a
(3 rows)
temp=# select t_xmin,t_xmax,t_field3,t_ctid from heap_page_items(get_raw_page('tmp', 0));
t_xmin | t_xmax | t_field3 | t_ctid
---------+---------+----------+--------
8536685 | 8536700 | 0 | (0,4)
8536685 | 0 | 0 | (0,2)
8536696 | 0 | 0 | (0,3)
8536700 | 0 | 0 | (0,4)
(4 rows)
删除一行时,把被删除行上的xmax填为当前的事务ID。
temp=# select txid_current_snapshot();
txid_current_snapshot
-----------------------
8536701:8536701:
(1 row)
temp=# delete from tmp where id=1;
DELETE 1
temp=# select t_xmin,t_xmax,t_field3,t_ctid from heap_page_items(get_raw_page('tmp', 0));
t_xmin | t_xmax | t_field3 | t_ctid
---------+---------+----------+--------
8536685 | 8536700 | 0 | (0,4)
8536685 | 0 | 0 | (0,2)
8536696 | 0 | 0 | (0,3)
8536700 | 8536701 | 0 | (0,4)
(4 rows)
temp=# SELECT xmin, xmax, cmin, cmax,ctid,* from tmp;
xmin | xmax | cmin | cmax | ctid | id | info
---------+------+------+------+-------+----+-------
8536685 | 0 | 0 | 0 | (0,2) | 2 | test1
8536696 | 0 | 0 | 0 | (0,3) | 3 | 3
(2 rows)
从上面的结果可以看到,表有两行有效记录,两行死元组。我们可以通过vacuum可以把死元组变成再可用,但不会回收空间。
temp=# vacuum tmp;
VACUUM
temp=# select t_xmin,t_xmax,t_field3,t_ctid from heap_page_items(get_raw_page('tmp', 0));
t_xmin | t_xmax | t_field3 | t_ctid
---------+--------+----------+--------
| | |
8536685 | 0 | 0 | (0,2)
8536696 | 0 | 0 | (0,3)
| | |
(4 rows)
上面对表进行vacuum后,两条死元组的t_xmin变成空,再往表插入一条数据
temp=# insert into tmp values(4,'4');
INSERT 0 1
temp=# select t_xmin,t_xmax,t_field3,t_ctid from heap_page_items(get_raw_page('tmp', 0));
t_xmin | t_xmax | t_field3 | t_ctid
---------+--------+----------+--------
8536702 | 0 | 0 | (0,1)
8536685 | 0 | 0 | (0,2)
8536696 | 0 | 0 | (0,3)
| | |
(4 rows)
这里没有新增一行而是复用原来的空闲行。
使用vacuum full可以回收空间
temp=# vacuum full tmp;
VACUUM
temp=# select t_xmin,t_xmax,t_field3,t_ctid from heap_page_items(get_raw_page('tmp', 0));
t_xmin | t_xmax | t_field3 | t_ctid
---------+--------+----------+--------
8536702 | 0 | 0 | (0,1)
8536685 | 0 | 0 | (0,2)
8536696 | 0 | 0 | (0,3)
(3 rows)
回收了t_xmin为空的死元组,释放了空间。
另外,对于fsm和vm的空间,在vacuum后才有:
select * from heap_page_items(get_raw_page('tmp', 'fsm',0));
ERROR: could not open file "base/16387/17369_fsm": 没有那个文件或目录
temp=# vacuum tmp;
VACUUM
temp=#select * from heap_page_items(get_raw_page('tmp', 'fsm',0));
temp=#select * from heap_page_items(get_raw_page('tmp', 'vm',0));
一个事物执行多条SQL
temp=# begin;
BEGIN
temp=# select txid_current_snapshot();
txid_current_snapshot
-----------------------
8536704:8536704:
(1 row)
temp=# update tmp set info='a' where id=2;
UPDATE 1
temp=# select t_xmin,t_xmax,t_field3,t_ctid from heap_page_items(get_raw_page('tmp',0));
t_xmin | t_xmax | t_field3 | t_ctid
---------+---------+----------+--------
8536702 | 0 | 0 | (0,1)
8536685 | 8536704 | 0 | (0,4)
8536696 | 0 | 0 | (0,3)
8536704 | 0 | 0 | (0,4)
(4 rows)
temp=# update tmp set info='b' where id=2;
UPDATE 1
temp=# select t_xmin,t_xmax,t_field3,t_ctid from heap_page_items(get_raw_page('tmp',0));
t_xmin | t_xmax | t_field3 | t_ctid
---------+---------+----------+--------
8536702 | 0 | 0 | (0,1)
8536685 | 8536704 | 0 | (0,4)
8536696 | 0 | 0 | (0,3)
8536704 | 8536704 | 0 | (0,5)
8536704 | 0 | 1 | (0,5)
(5 rows)
temp=# update tmp set info='c' where id=2;
UPDATE 1
temp=# select t_xmin,t_xmax,t_field3,t_ctid from heap_page_items(get_raw_page('tmp',0));
t_xmin | t_xmax | t_field3 | t_ctid
---------+---------+----------+--------
8536702 | 0 | 0 | (0,1)
8536685 | 8536704 | 0 | (0,4)
8536696 | 0 | 0 | (0,3)
8536704 | 8536704 | 0 | (0,5)
8536704 | 8536704 | 1 | (0,6)
8536704 | 0 | 2 | (0,6)
(6 rows)
temp=# commit;
COMMIT
temp=# SELECT xmin, xmax, cmin, cmax,ctid,* from tmp;
xmin | xmax | cmin | cmax | ctid | id | info
---------+------+------+------+-------+----+------
8536702 | 0 | 0 | 0 | (0,1) | 4 | 4
8536696 | 0 | 0 | 0 | (0,3) | 3 | 3
8536704 | 0 | 2 | 2 | (0,6) | 2 | c
(3 rows)
在上面的事务中,可以看到t_field3在变化,从0开始计数,随着事务执行依次累加。事物提交后cmin和cmax记录事务操作的情况。
事物回滚情况
执行前的数据
temp=# vacuum full tmp;
VACUUM
temp=# select t_xmin,t_xmax,t_field3,t_ctid from heap_page_items(get_raw_page('tmp',0));
t_xmin | t_xmax | t_field3 | t_ctid
---------+--------+----------+--------
8536702 | 0 | 0 | (0,1)
8536696 | 0 | 0 | (0,2)
8536704 | 0 | 0 | (0,3)
(3 rows)
开启事务执行,最后进行回滚操作
temp=# begin;
BEGIN
temp=# select txid_current_snapshot();
txid_current_snapshot
-----------------------
8536708:8536708:
(1 row)
temp=# update tmp set info='aa' where id=2;
UPDATE 1
temp=# select t_xmin,t_xmax,t_field3,t_ctid from heap_page_items(get_raw_page('tmp',0));
t_xmin | t_xmax | t_field3 | t_ctid
---------+---------+----------+--------
8536702 | 0 | 0 | (0,1)
8536696 | 0 | 0 | (0,2)
8536704 | 8536708 | 0 | (0,4)
8536708 | 0 | 0 | (0,4)
(4 rows)
temp=# update tmp set info='bb' where id=2;
UPDATE 1
temp=# select t_xmin,t_xmax,t_field3,t_ctid from heap_page_items(get_raw_page('tmp',0));
t_xmin | t_xmax | t_field3 | t_ctid
---------+---------+----------+--------
8536702 | 0 | 0 | (0,1)
8536696 | 0 | 0 | (0,2)
8536704 | 8536708 | 0 | (0,4)
8536708 | 8536708 | 0 | (0,5)
8536708 | 0 | 1 | (0,5)
(5 rows)
temp=# rollback;
ROLLBACK
temp=# select t_xmin,t_xmax,t_field3,t_ctid from heap_page_items(get_raw_page('tmp',0));
t_xmin | t_xmax | t_field3 | t_ctid
---------+---------+----------+--------
8536702 | 0 | 0 | (0,1)
8536696 | 0 | 0 | (0,2)
8536704 | 8536708 | 0 | (0,4)
8536708 | 8536708 | 0 | (0,5)
8536708 | 0 | 1 | (0,5)
(5 rows)
temp=# SELECT xmin, xmax, cmin, cmax,ctid,* from tmp;
xmin | xmax | cmin | cmax | ctid | id | info
---------+---------+------+------+-------+----+------
8536702 | 0 | 0 | 0 | (0,1) | 4 | 4
8536696 | 0 | 0 | 0 | (0,2) | 3 | 3
8536704 | 8536708 | 0 | 0 | (0,3) | 2 | c
(3 rows)
在回滚的事务中,用xmax记录事务ID。ctid内容是(0,3),还是之前的数据。但这时t_ctid是(0,5),在更新它会指向新版本的元组,即使是回滚了,再进行一次成功的更新操作的事务后,ctid和t_ctid就会一致了
temp=# begin;
BEGIN
temp=# select txid_current_snapshot();
txid_current_snapshot
-----------------------
8536709:8536709:
(1 row)
temp=# update tmp set info='aaa' where id=2;
UPDATE 1
temp=# commit;
COMMIT
temp=# select t_xmin,t_xmax,t_field3,t_ctid from heap_page_items(get_raw_page('tmp',0));
t_xmin | t_xmax | t_field3 | t_ctid
---------+---------+----------+--------
8536702 | 0 | 0 | (0,1)
8536696 | 0 | 0 | (0,2)
8536704 | 8536709 | 0 | (0,6)
8536708 | 8536708 | 0 | (0,5)
8536708 | 0 | 1 | (0,5)
8536709 | 0 | 0 | (0,6)
(6 rows)
temp=# SELECT xmin, xmax, cmin, cmax,ctid,* from tmp;
xmin | xmax | cmin | cmax | ctid | id | info
---------+------+------+------+-------+----+------
8536702 | 0 | 0 | 0 | (0,1) | 4 | 4
8536696 | 0 | 0 | 0 | (0,2) | 3 | 3
8536709 | 0 | 0 | 0 | (0,6) | 2 | aaa
(3 rows)
参考文档
https://www.modb.pro/db/214813
https://www.modb.pro/db/377530
https://zhuanlan.zhihu.com/p/409723642
http://postgres.cn/docs/12/pageinspect.html




