PostgreSQL中的MVCC机制

MVCC,Multi-Version Concurrency Control,多版本并发控制。
一句话讲,MVCC就是用同一份数据临时保留多版本的方式,实现并发控制。它可以避免读写事务之间的互相阻塞,相比通常的封锁技术可极大的提高业务的并发性能。

为何需要MVCC

如果有人从数据库中读数据的同时,有另外的人写入数据,有可能读数据的人会看到『半写』或者不一致的数据。有很多种方法来解决这个问题,叫做并发控制方法。最简单的方法,通过加锁,让所有的读者等待写者工作完成,但是这样效率会很差。MVCC 使用了一种不同的手段,每个连接到数据库的读者,在某个瞬间看到的是数据库的一个快照,写者写操作造成的变化在写操作完成之前(或者数据库事务提交之前)对于其他的读者来说是不可见的。

当一个 MVCC 数据库需要更一个一条数据记录的时候,它不会直接用新数据覆盖旧数据,而是将旧数据标记为过时(obsolete)并在别处增加新版本的数据。这样就会有存储多个版本的数据,但是只有一个是最新的。这种方式允许读者读取在他读之前已经存在的数据,即使这些在读的过程中半路被别人修改、删除了,也对先前正在读的用户没有影响。这种多版本的方式避免了填充删除操作在内存和磁盘存储结构造成的空洞的开销,但是需要系统周期性整理(sweep through)以真实删除老的、过时的数据。

PG中的MVCC

PostgreSQL中的MVCC实现原理可简单概括如下:
1)数据文件中存放同一逻辑行的多个行版本(称为Tuple);
2)每个行版本的头部记录创建该版本的事务ID以及删除该行版本的事务的ID(分别称为xmin和xmax);
3)每个事务的状态(运行中,中止或提交)记录在pg_clog文件中;
4)根据上面的数据并运用一定的规则每个事务只会看到一个特定的行版本。

通过MVCC,读写事务可以分别在不同的行版本上工作,因此能够在互不冲突的情况下并发执行。

举例说明MVCC工作机制

事务ID:在PostgreSQL中,每个事务都有一个唯一的事务ID,被称为XID。注意:除了被BEGIN - COMMIT/ROLLBACK包裹的一组语句会被当作一个事务对待外,不显示指定BEGIN - COMMIT/ROLLBACK的单条语句也是一个事务。
数据库中的事务ID递增。可通过txid_current()函数获取当前事务的ID。

隐藏多版本标记字段

PostgreSQL中,对于每一行数据(称为一个tuple),包含有4个隐藏字段。这四个字段是隐藏的,但可直接访问。

xmin :在创建(insert)记录(tuple)时,记录此值为插入tuple的事务ID。
xmax :默认值为0.在删除tuple时,记录此值。
cmin和cmax :标识在同一个事务中多个语句命令的序列值,从0开始,用于同一个事务中实现版本可见性判断。

下面通过实验具体看看这些标记如何工作。在此之前,先创建测试表

CREATE TABLE test 
(
  id INTEGER,
  value TEXT
);

开启一个事务,查询当前事务ID(值为3277),并插入一条数据,xmin为3277,与当前事务ID相等。符合上文所述——插入tuple时记录xmin,记录未被删除时xmax为0。

postgres=> BEGIN;
BEGIN
postgres=> SELECT TXID_CURRENT();
 txid_current 
--------------
         3277
(1 row)
postgres=> INSERT INTO test VALUES(1, 'a');
INSERT 0 1
postgres=> SELECT *, xmin, xmax, cmin, cmax FROM test;
 id | value | xmin | xmax | cmin | cmax 
----+-------+------+------+------+------
  1 | a     | 3277 |    0 |    0 |    0
(1 row)

继续通过一条语句插入2条记录,xmin仍然为当前事务ID,即3277,xmax仍然为0,同时cmin和cmax为1,符合上文所述cmin/cmax在事务内随着所执行的语句递增。虽然此步骤插入了两条数据,但因为是在同一条语句中插入,故其cmin/cmax都为1,在上一条语句的基础上加一。

INSERT INTO test VALUES(2, 'b'), (3, 'c');
INSERT 0 2
postgres=> SELECT *, xmin, xmax, cmin, cmax FROM test;
 id | value | xmin | xmax | cmin | cmax 
----+-------+------+------+------+------
  1 | a     | 3277 |    0 |    0 |    0
  2 | b     | 3277 |    0 |    1 |    1
  3 | c     | 3277 |    0 |    1 |    1
(3 rows)

将id为1的记录的value字段更新为’d’,其xmin和xmax均未变,而cmin和cmax变为2,在上一条语句的基础之上增加一。此时提交事务。

UPDATE test SET value = 'd' WHERE id = 1;
UPDATE 1
postgres=> SELECT *, xmin, xmax, cmin, cmax FROM test;
 id | value | xmin | xmax | cmin | cmax 
----+-------+------+------+------+------
  2 | b     | 3277 |    0 |    1 |    1
  3 | c     | 3277 |    0 |    1 |    1
  1 | d     | 3277 |    0 |    2 |    2
(3 rows)
postgres=> COMMIT;
COMMIT

开启一个新事务,通过2条语句分别插入2条id为4和5的tuple。

BEGIN;
BEGIN
postgres=> INSERT INTO test VALUES (4, 'x');
INSERT 0 1
postgres=> INSERT INTO test VALUES (5, 'y'); 
INSERT 0 1
postgres=> SELECT *, xmin, xmax, cmin, cmax FROM test;
 id | value | xmin | xmax | cmin | cmax 
----+-------+------+------+------+------
  2 | b     | 3277 |    0 |    1 |    1
  3 | c     | 3277 |    0 |    1 |    1
  1 | d     | 3277 |    0 |    2 |    2
  4 | x     | 3278 |    0 |    0 |    0
  5 | y     | 3278 |    0 |    1 |    1
(5 rows)

此时,将id为2的tuple的value更新为’e’,其对应的cmin/cmax被设置为2,且其xmin被设置为当前事务ID,即3278。

UPDATE test SET value = 'e' WHERE id = 2;
UPDATE 1
postgres=> SELECT *, xmin, xmax, cmin, cmax FROM test;
 id | value | xmin | xmax | cmin | cmax 
----+-------+------+------+------+------
  3 | c     | 3277 |    0 |    1 |    1
  1 | d     | 3277 |    0 |    2 |    2
  4 | x     | 3278 |    0 |    0 |    0
  5 | y     | 3278 |    0 |    1 |    1
  2 | e     | 3278 |    0 |    2 |    2

在另外一个窗口中开启一个事务,可以发现id为2的tuple,xin仍然为3277,但其xmax被设置为3278,而cmin和cmax均为2。符合上文所述——若tuple被删除,则xmax被设置为删除tuple的事务的ID。

BEGIN;
BEGIN
postgres=> SELECT *, xmin, xmax, cmin, cmax FROM test;
 id | value | xmin | xmax | cmin | cmax 
----+-------+------+------+------+------
  2 | b     | 3277 | 3278 |    2 |    2
  3 | c     | 3277 |    0 |    1 |    1
  1 | d     | 3277 |    0 |    2 |    2
(3 rows)

这里有几点要注意
1.新旧窗口中id为2的tuple对应的value和xmin、xmax、cmin/cmax均不相同,实际上它们是该tuple的2个不同版。

2.在旧窗口中,更新之前,数据的顺序是2,3,1,4,5,更新后变为3,1,4,5,2。因为在PostgreSQL中更新实际上是将旧tuple标记为删除,并插入更新后的新数据,所以更新后id为2的tuple从原来最前面变成了最后。

3.在新窗口中,id为2的tuple仍然如旧窗口中更新之前一样,排在最前面。这是因为旧窗口中的事务未提交,更新对新窗口不可见,新窗口看到的仍然是旧版本的数据。

提交旧窗口中的事务后,新旧窗口中看到数据完全一致——id为2的tuple排在了最后,xmin变为3278,xmax为0,cmin/cmax为2。前文定义中,xmin是tuple创建时的事务ID,并没有提及更新的事务ID,但因为PostgreSQL的更新操作并非真正更新数据,而是将旧数据标记为删除,并插入新数据,所以“更新的事务ID”也就是“创建记录的事务ID”。

 SELECT *, xmin, xmax, cmin, cmax FROM test;
 id | value | xmin | xmax | cmin | cmax 
----+-------+------+------+------+------
  3 | c     | 3277 |    0 |    1 |    1
  1 | d     | 3277 |    0 |    2 |    2
  4 | x     | 3278 |    0 |    0 |    0
  5 | y     | 3278 |    0 |    1 |    1
  2 | e     | 3278 |    0 |    2 |    2
(5 rows)
MVCC在事务隔离性中的使用

PG中的MVCC主要应用于事务中的原子性和隔离性两方面,下面针对隔离的4种级别做说明介绍。
1.读未提交
略。因为PG的默认的隔离为读已提交。

2.读已提交
机制:事务中的查询看到的是当前查询开始时的快照。
当有两个事务同时修改同一行数据时,后发生的事务在初始事务提交前就可以进行查找,然后不执行进入等待,待初始事务提交后retry,检验查找条件是否仍然满足,如果满足,后续操作才会被执行。

例子:
在事务1提交之前,通常LOCK机制会让事务2进入等待,到事务1提交后才可以扫描查找;
而MVCC允许事务2在事务1提交之前就可以进行扫描查找工作,当事务1提交之后,事务2retry,检验where条件是否仍然满足;
若不满足,则不会执行任何操作(保证了一致性);
若满足,则相比LOCK机制节省了扫描查找所消耗的时间(在LOCK机制等待commit时MVCC就开始扫描查找了)。
在这里插入图片描述

3.可重复读
看到的是当前事务开始时的快照(注意和读已提交的差别,可重复度是事务开始时的快照,一个是已经进入事务,在进行查询时的快照)。
使用这个级别需要准备好重试事务,因为串行化可能失败。

例子:
在第二张图的例子中,按照一般的LOCK机制,在可重复读的级别下,在事务B提交后,查询结果应该不同(即幻读),但是在MVCC机制中,查询结果是相同的(幻读也被避免了)。
图1:可重复读
该图中产生了幻读现象,事务A在事务中进行了2次查询,由于事务A看到的是其事务开始前的快照,所以两次查询结果是一致的,但是第二次查询时事务B已提交,所以查询到的结果与实际不符。
可重复读
图2:避免幻读
在这里插入图片描述
4.可串行化
是严格意义上的可串行化。
可重复读级别已经避免了幻读,达到了SQL标准约定的“可串行化标准”,但能避免幻读并不等于严格意义上的可串行化。
在这种策略下,不同事务同时修改同一数据的行为会直接失败,并返回错误信息,因此需要准备好重启事务。
在这里插入图片描述

MVCC实现方法

MVCC的实现方法有两种:
1.写新数据时,把旧数据移到一个专门的地方(如回滚段),其他人读数据时,从回滚段中把旧数据读出来。
2.写数据时,旧数据不删除,把新数据插入。

PostgreSQL使用的是第二种方法,Oracle数据库和MySQL innodb引擎使用一种。

比较:
优点:
回滚可以立刻完成,无论进行了多少操作
数据可以进行逆很多更新,不必担心需要保证回滚段不被用完

缺点:
旧版本数据需要清理
旧版本数据过多导致查询变慢

存在的问题及解决方法

MVCC实现了一种期待:读永远不堵塞写。但是也带来了一些问题:

1.因为不同的事务会看到不同版本的记录,所以PostgreSQL连那些可能过期的数据也要保留着;
当UPDATA时,真正地创建了一行新记录,而DELETE时,并不会真正地删除一行旧记录;
最终数据库中会存在一些对有事务永远不可见的记录,称作dead rows。
2.事务ID只能增加,它是个32bit,支持大约40亿个事务,达到最大值会从0重新开始;
这样带来一个逻辑问题:突然所有记录都变成了发生在将来的事务所产生的,而所有新事物也都没有办法访问这些旧记录了。
3.解决方法:VACUUM
PostgreSQL自带了auto_vacuum守护进程会在一个可配置的周期内自动执行清理,解决了这两个问题。
使用者需要留意这个auto_vacuum,以免发生不想要的结果。
vacuum命令也可以手动执行。

转载自:
1.https://www.jianshu.com/p/04b542aeebac
2.http://blog.chinaunix.net/uid-20726500-id-4040024.html
3.http://www.jasongj.com/sql/mvcc/

猜你喜欢

转载自blog.csdn.net/hmxz2nn/article/details/82811404