MVCC(Multi-Version Concurrency Conctrol,多版本控制并发),是数据库中一种常见的存在并发时保证数据一致性的方法。包括Oracle、Mysql、Postgresql在内许多主流数据库都有一套自己的MVCC方法实现。从实现逻辑上来讲,大体分为两种:
第一种,就是Oracle和Mysql采用的,在更新数据时,把原来的数据放到回滚段中,如果有其它事务需要读取原来的数据,则需要从回滚段中进行读取。
第二种,是Postgresql采用的,在更新数据时,原来的数据不删除,而是把更新的数据作为新数据插进来。
本文主要介绍下,Postgresql这种MVCC方法的实现。在文章中,将对以下问题进行解答:
- Postgresql怎么标记新数据和旧的数据
- 旧的数据会怎么进行清理
- 如何结合CommitLog文件来判断事务的状态
- 什么是事务ID的回卷问题
一、Postgresql怎么标记新数据和旧的数据?
为解决这个问题,Postgresql引入了两个伪列(数据表上隐藏的、系统自动添加的列)xmin和xmax。这两个伪列记录的都是事务ID,但是在不同的场景之下,记录的位置和数值不同。
这里首先解释下什么是事务ID?Postgresql为每个事务都分配一个4个子节的数字作为这个事务的唯一性标识,这个标识就是事务ID。事务ID是全局的,起始值是3(稍后解释为什么起始值不是0或者1,而是3),每增加一个事务,它就会加1。当它达到最大值时,它又会回到3开始新一轮增长,这也叫做事务ID的回卷。
在上文中提到,事务ID是从3开始增长的,是因为0、1、2都用来做了特殊的标记:
0:表示无效的事务ID;
1:初始化数据库时(pg_ctl initdb),用来做系统表(pg_class、pg_type等等)的事务ID;
2:冻结事务的ID,就是在事务ID回卷后,就旧的事务ID进行回收,标记为2;
接下来,笔者以实例的方式,来介绍在不同场景下,数据表中xmin和xmax的配置原则。
- 在插入数据时,xmin存储当前事务的事务ID,而xmax为0.
为了验证这个问题,我们开启一个事务,向t1表中插入一行数据:
stock_analysis_data=# begin;
BEGIN
stock_analysis_data=# insert into t1 values (1,'tom',18);
INSERT 0 1
在本事务内,执行下面的命令查询该行数据的xmin和xmax:
stock_analysis_data=# select xmin,xmax,* from t1;
xmin | xmax | id | name | age
--------+------+----+------+-----
353401 | 0 | 1 | tom | 18
可以用下面的命令查看到当前事务的ID:
stock_analysis_data=# SELECT CAST(txid_current() AS text);
txid_current
--------------
353401
(1 row)
这样,就可以确定对于新插入的数据,xmin写的就是当前事务的ID。如果本事务提交之后,另外启动一个终端查询t1表,也可以看到xmin和xmax数据和上面查询到的一致。
stock_analysis_data=# select xmin,xmax,* from t1;
xmin | xmax | id | name | age
--------+------+----+------+-----
353401 | 0 | 1 | tom | 18
- 在更新数据时,新数据行的xmin存储当前事务的事务ID,而xmax为0;原数据行xmin保持不变,xmax存储的是新的事务ID。
开启一个事务,更新id为1的数据行:
stock_analysis_data=# begin;
BEGIN
stock_analysis_data=# update t1 set age=12 where id=1;
UPDATE 1
在当前事务中查询该行的xmin和xmax:
stock_analysis_data=# select xmin,xmax,ctid,* from t1;
xmin | xmax | ctid | id | name | age
--------+------+-------+----+------+-----
353403 | 0 | (0,2) | 1 | tom | 12
(1 row)
stock_analysis_data=# SELECT CAST(txid_current() AS text);
txid_current
--------------
353403
(1 row)
可以看到,我们在当前事务中查询到的xmin已经变成了当前事务的ID。接下来,在另外的事务中在进行上述查询:
stock_analysis_data=# select xmin,xmax,ctid,* from t1;
xmin | xmax | ctid | id | name | age
--------+--------+-------+----+------+-----
353401 | 353403 | (0,1) | 1 | tom | 18
(1 row)
细心的朋友可能已经发现,我们这两次的查询的列中包含了列的ctid,而且两次查询ctid的值不一致,这就说明了Postgresql在更新数据时是插入新的数据行,而非在原来的数据行上修改。再来看本次查询出来的xmin和xmax,xmin还是原来的值,而xmax由0变成了是正在修改改行数据的新的事务ID。
如果我们提交进行的修改,再进行查询,会发现已经查询不到ctid为(0,2)的数据行,而查询出来的数据行的ctid为(0,2),是最新的那一行。
- 在删除一行时,将原来行的xmax改成删除事务ID
还是开启一个事务,在事务中删除id为1的数据行。
stock_analysis_data=# begin;
BEGIN
stock_analysis_data=# delete from t1 where id=1;
DELETE 1
此时在当前事务中是无法查询到已经删除的改行数据了,因为没有提交,可以再开启一个事务进行查询:
stock_analysis_data=# select xmin,xmax,ctid,* from t1;
xmin | xmax | ctid | id | name | age
--------+--------+-------+----+------+-----
353403 | 353404 | (0,2) | 1 | tom | 12
(1 row)
发现数据行的xmax已经变成了353404,这正是删除事务的事务ID。
至此,我们可以得出结论:
(1)插入和更新数据时,数据行的xmin将最终存储相关事务的ID,而xmax将为0;
(2)当数据行的xmax不为0时,说明改行是要被删除的行。
二、Postgresql旧数据的删除?
在上文中,我们已经知道xmax被标记为事务ID的数据行是要被删除的行,由此带来了两个问题:
(1)一个是被标记为要删除的行怎么去删除,由谁去删除?
(2)在执行select查询语句时,如果表中存在xmax不为0的数据说明有事务在修改或者删除该表,那么select怎么知道事务有没有提交,从而确定该行数据显不显示?
现在,我们先来说明下第一个问题。Postgresql中使用autovacuum进程进行这些存储空间的回收,回收的触发时机是:当表的更新量到达一定的规模时触发。AutoVacuum是否打开在postgresql.conf文件中配置,默认是打开的,如果我们关闭了也可以使用VACUUM命令手动进行回收,如下所示:
stock_analysis_data=# VACUUM t1;
VACUUM
对表t1进行VACUUM时,只是单纯的回收空间已被重用,被回收的空间不会返还给操作系统,而是仍然保留在原来的表中以进行重用。
与VACUUM命令相近的命令是VACUUM FULL命令,它会将整个表的内容重新写到一个磁盘文件中,类似于Java的JVM垃圾回收的复制算法,此时被整理后的数据行的ctid都会改变,特别是,VACUUM FULL命令还会进行锁表。
接下来,我们演示下VACUUM FULL的用法。首先修改t1表里面的数据并进行提交:
stock_analysis_data=# begin;
BEGIN
stock_analysis_data=# update t1 set age=18;
UPDATE 2
stock_analysis_data=# commit;
COMMIT
然后查看t1表里面数据的ctid:
stock_analysis_data=# select xmin,xmax,ctid,* from t1;
xmin | xmax | ctid | id | name | age
--------+------+-------+----+-------+-----
353409 | 0 | (0,3) | 1 | tom | 18
353409 | 0 | (0,4) | 2 | jerry | 18
(2 rows)
可以看到,两条数据的ctid分别是(0,3)和(0,4),然后执行VACUUM FULL命令,再去查询,发现两条数据的ctid变成了(0,1)和(0,2):
stock_analysis_data=# VACUUM FULL t1;
VACUUM
stock_analysis_data=# select xmin,xmax,ctid,* from t1;
xmin | xmax | ctid | id | name | age
--------+------+-------+----+-------+-----
353409 | 0 | (0,1) | 1 | tom | 18
353409 | 0 | (0,2) | 2 | jerry | 18
(2 rows)
三、如何结合CommitLog文件来判断事务的状态
在上文中提到,使用多版本更新数据带来的第二个问题就是:怎么在select时,准确判断出数据行xmax记录的事务ID对应的事务的状态,是提交了,还是未提交?进而影响查询的结果集。实际上,Postgresql会将事务的状态写到CommitLog文件中,而执行select时,正是根据这个文件中记录的事务状态来判断数据行是否有效。
CommitLog文件,在Postgresql 9.X之前是存放到pg_clog目录下的,而在Postgresql10之后,该文件存放到了pg_xact目录下。
[root@VM-115-39-centos pg_xact]# pwd
/var/lib/pgsql/11/data/pg_xact
[root@VM-115-39-centos pg_xact]# ls -l
total 92
-rw------- 1 postgres postgres 90112 Jul 7 14:44 0000
我们看到的这个名为0000的文件就是Postgresql的CommitLog,它实际上是一个位图文件。用两位表示一个事务的状态:
- 0x00:表示事务正在进行中。
- 0x01:表示事务已经提交
- 0x02:表示事务已经回滚
- 0x03:表示子事务已经提交
上文中提到Postgresql的事务ID其实是一个连续增长的四个子节的数字,而CommitLog是一个位图,那么CommitLog每2位划分出来一个表示事务状态,事务ID就是这个位图的索引:
为了做到快速的通过判断出事务的状态,Postgresql也做了一系列的优化,这就包括将CommitLog全部缓存到内存中,在数据行上使用t_infomask标志字段表示事务提交状态(第一次查询数据行时,根据CommitLog记录的状态更新t_infomask标志)等等,总之Postgresql通过CommitLog日志和上述优化手段,保证了在多版本更新机制下,数据的查询效率。
四、事务ID的回卷
最后一个问题是事务ID回卷的问题,上文已经提到,Postgresql的事务ID是1个连续增长的四字节整数,那么它势必有一个最大值。当达到最大值之后,它又会回到初始值,重新开始增长,这就是事务ID的回卷。
在回卷的过程中,必然会出现事务ID重新利用的问题,Postgresql的方法就是对旧的事务ID一律设置成冻结ID2。比如说新开启一个事务,为它分配事务ID为1000,而原来的数据中有部分数据xmin的值是1000,此时会把这部分数据的xmin统一设置成2,这样就实现对事务ID 1000的回收和再利用。
实际上旧的数据ID的回收工作也是由VACUUM进程进行的,如果旧的事务ID没有来得及回收,当新事务ID和旧的事务ID距离还有1千万时,数据库会在日志中报警。如果这个距离还有1百万时,数据库会直接宕机,并再日志文件中报错。我们应该特别留意数据库事务ID回卷的问题,否则很可能引起数据库宕机。