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

PostgreSQL的MVCC机制

叶同学专栏 2022-10-13
517

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_xmin4bytes      保存插入元组的事务的txid
    t_xmax4bytes保存删除或更新此元组的事务的txid。如果尚未删除或更新,则设置为0,即无效
    t_field34bytes在当前事务里执行当前命令前执行了多少条sql命令。从0开始计数,比如一个事务依次执行:BEGIN; INSERT; INSERT; INSERT; COMMIT;那么第一个insert命令插入的元组其t_field3设为0,第二个insert命令插入的元组其t_field3设为1,以此类推。其它版本中名字是t_cid
    t_ctid6bytes用于标识表中的元组。在更新该元组时,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^32

epoch可以通过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
    会存在,字段类型为oid

  • tableoid:包含本行的表的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



文章转载自叶同学专栏,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论