Postgresql Mvcc

Pg 锁 与 Mvcc

Mvcc- 事务 多版本并发控制

Postgresql也有表和行级别的锁功能,通常用于不需要完整事务隔离 并且想要显示管理特定冲突点的应用。

MVCC并发控制模型主要优点在MVCC中,对查询(读)数据的锁请求与写数据的锁请求不冲突,所以读不会堵塞写,而写也不堵塞读。

ACID在PostgreSQL中的实现原理

事务的实现原理可以解读为RDBMS采取何种技术确保事务的ACID事务的ACID特性,PostgreSQL针对ACID的实现技术如下表所示:

ACID 实现技术
原子性(Atomicity) MVCC
一致性(Consistency) 约束(主键、外键等)
隔离性(Isolation) MVCC
持久性(Durability) WAL
Postgresql中的MVCC原理
事务ID

在PostgreSQL中,每个事务都有一个唯一的事务ID,被称为XID, 除了被Begin - Commit/RollBack包裹的一组语句会被当做一个事务来对待外,不显示指定BEGIN - COMMIT/RollBack的单条语句也是一个事务。

数据库中的事务ID递增,可通过txid_current() 函数获取当前事务的ID。

隐藏多版本标记字段

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

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

测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
postgresql=# create table test ( id int , value text ); -- create
CREATE TABLE
postgresql=#
postgresql=# begin ; -- 开启事务
BEGIN
postgresql=# select txid_current(); -- 当前事务ID;
txid_current
--------------
1851
(1 row)

postgresql=# insert into test values ( 1, 'aaaaa'); -- insert
INSERT 0 1
postgresql=#
postgresql=# select *, xmin, xmax, cmin, cmax from test; -- cmin, cmax从0开始; xmin 为当前事务ID
id | value | xmin | xmax | cmin | cmax
----+-------+------+------+------+------
1 | aaaaa | 1851 | 0 | 0 | 0
(1 row)

postgresql=# insert into test values ( 2, 'bbbb'), (3, 'cccc'); -- insert
INSERT 0 2
postgresql=#
postgresql=# select *, xmin, xmax, cmin, cmax from test; -- cmin, cmax 递增
id | value | xmin | xmax | cmin | cmax
----+-------+------+------+------+------
1 | aaaaa | 1851 | 0 | 0 | 0
2 | bbbb | 1851 | 0 | 1 | 1 -- [1]
3 | cccc | 1851 | 0 | 1 | 1 -- [1] 同一条insert cmax 与 cmix相同;
(3 rows)
postgresql=# insert into test values ( 4, 'dddd'); -- insert
INSERT 0 1
postgresql=#
postgresql=# select *, xmin, xmax, cmin, cmax from test; -- cmin, cmax 递增
id | value | xmin | xmax | cmin | cmax
----+-------+------+------+------+------
1 | aaaaa | 1851 | 0 | 0 | 0
2 | bbbb | 1851 | 0 | 1 | 1
3 | cccc | 1851 | 0 | 1 | 1
4 | dddd | 1851 | 0 | 2 | 2
(4 rows)

postgresql=# update test set value = 'update' where id = 2 ; -- update
UPDATE 1
postgresql=#
postgresql=# select *, xmin, xmax, cmin, cmax from test; -- cmin, cmax -- 递增
id | value | xmin | xmax | cmin | cmax
----+--------+------+------+------+------
3 | cccc | 1851 | 0 | 1 | 1
4 | dddd | 1851 | 0 | 2 | 2
2 | update | 1851 | 0 | 4 | 4
(3 rows)
postgresql=# commit ; -- 提交事务
COMMIT

新开启事务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
postgresql=# begin ;  -- 开始事务
BEGIN
postgresql=#
postgresql=# select txid_current(); -- 当前XID
txid_current
--------------
1852
(1 row)

postgresql=# insert into test values ( 5, 'e'), (6, 'f'); -- insert
INSERT 0 2
postgresql=#
postgresql=# select *, xmin, xmax, cmin, cmax from test; -- xmin 改变,同时cmin,cmax修改为0;
id | value | xmin | xmax | cmin | cmax
----+--------+------+------+------+------
3 | cccc | 1851 | 0 | 1 | 1
4 | dddd | 1851 | 0 | 2 | 2 --- 对比
2 | update | 1851 | 0 | 4 | 4
5 | e | 1852 | 0 | 0 | 0 --- [1]
6 | f | 1852 | 0 | 0 | 0 --- [1] 同时我们发现 insert 一条, cmin, cmax相同;
(5 rows)

postgresql=# update test set value = '1852-update-sec' where id = 4; -- 本事务第二次修改id=4
postgresql=# select *, xmin, xmax, cmin, cmax from test;
id | value | xmin | xmax | cmin | cmax
----+-----------------+------+------+------+------
3 | cccc | 1851 | 0 | 1 | 1
2 | update | 1851 | 0 | 4 | 4
5 | e | 1852 | 0 | 0 | 0
6 | 1852-update | 1852 | 0 | 1 | 1
4 | 1852-update-sec | 1852 | 0 | 3 | 3 -- 对比

同一个事务中发现,修改之前事务数据, xmin 会被修改为当前事务ID, cmincmax 同时在本事务ID下递增。

xmax 验证

psql-1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
postgresql=# begin ;
BEGIN
postgresql=# select *, xmin, xmax, cmin, cmax from test;
id | value | xmin | xmax | cmin | cmax
----+-----------------+------+------+------+------
3 | cccc | 1851 | 0 | 1 | 1
2 | update | 1851 | 0 | 4 | 4 ---[2-1]
6 | 1852-update | 1852 | 0 | 1 | 1
4 | 1852-update-sec | 1852 | 0 | 3 | 3
(5 rows)

postgresql=#
postgresql=# update test set value = '1855-change-value' where id = 2; -- update
UPDATE 1
postgresql=# select *, xmin, xmax, cmin, cmax from test;
id | value | xmin | xmax | cmin | cmax
----+-------------------+------+------+------+------
3 | cccc | 1851 | 0 | 1 | 1
6 | 1852-update | 1852 | 0 | 1 | 1
4 | 1852-update-sec | 1852 | 0 | 3 | 3
2 | 1855-change-value | 1855 | 0 | 0 | 0 -- [2-2]
(5 rows)

-- 通过[2]对比发现 在本事务1855中已经修改tuple的参数. xmin = 1855, cmin =0, cmax = 0;

psql-2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
postgresql=# begin ;
BEGIN
postgresql=# select *, xmin, xmax, cmin, cmax from test;
id | value | xmin | xmax | cmin | cmax
----+-----------------+------+------+------+------
3 | cccc | 1851 | 0 | 1 | 1
2 | update | 1851 | 0 | 4 | 4 ---[3-1]
6 | 1852-update | 1852 | 0 | 1 | 1
4 | 1852-update-sec | 1852 | 0 | 3 | 3
(5 rows)
postgresql=# select txid_current();
txid_current
--------------
1856
(1 row)
postgresql=#
postgresql=# select *, xmin, xmax, cmin, cmax from test;
id | value | xmin | xmax | cmin | cmax
----+-----------------+------+------+------+------
3 | cccc | 1851 | 0 | 1 | 1
2 | update | 1851 | 1855 | 0 | 0 ---[3-2]
6 | 1852-update | 1852 | 0 | 1 | 1
4 | 1852-update-sec | 1852 | 0 | 3 | 3
(5 rows)
---[3] 对比发现 xmin 当前事务ID没变化, xmax 值被修改, cmin cmax值也被修改。
---[2-2] 当前数据Id, cmin, cmax 存于新数据记录中
---[3-2] 新事务ID存于旧数据的cmax中, 保持之前旧数据信息.

在事务1856中再次修改已被1855修改记录

通过[2-2] 与 [3-2] 对比发现,在事务 1855中数据已经被修改,为了保持事务一致性,在事务1856中再次查看时发此前id=2实际已经被删除(被修改). 此时执行sql操作将会被堵塞,直到之前事务被commit 或者 rollback

注意:

  • 不同事务ID, cmix 与 cmax 序号都是从0开始递增。
  • 更新数据实际是将旧的tuple标记为删除,并插入更新后的数据,所以每次执行update之后,从ID可以看出select出的数据顺序并不相同。
  • 事务查看数据时数据所处的状态,要么是另一并发事务修改它之前的状态,要么是之后的状态。事务进行处理时,必须加锁保持原子操作; 所以会堵塞,而且begin时,数据保持在未操作之前, 若有语句/事务 修改,则无法看到。
MVCC原子性
  • 插入操作: Postgresql 会将当前事务ID存于xmin中。
  • 删除操作: 其事务ID会存于xmax中
  • 更新操作: Postgresql 将当前事务ID存于旧数据的xmax中,并存于新数据的xmin中。[2-2] 与 [3-2] 对比

事务对增、删、改所操作的数据上都留有其事务ID, 可以很方便的提交或者撤销。从而实现事务的原子性。

不希望的异常现象
  • 脏读(dirty reads)

一个事务读取了另一个未提交的并行事务写的数据

时间 事务A 事务B
T1 开始事务
T2 开始事务
T3 查询账户余额1000
T4 去除500元,余额500
T5 未提交
T6 查询余额为500(脏读)
  • 不可重复读(non-repeatable reads)

一个事务重新读取前面读取过的数据,发现该数据已经被另一个已提交的事务修改过。

时间 事务A 事务B
T1 开始事务
T2 查询余额为1000 开始事务
T3 查询账户余额1000
T4 去除500元,余额500
T5 提交事务
T6 查询余额为500
T7 提交事务
  • 幻读(phantom read)

当前事务中重复执行相同的查询, 返回的记录数因另一个事务的插入或删除而得到不同的结果

时间 事务A 事务B
T1 开始事务
T2 select count(*) from Foos where flag1=1 //(10条) 开始事务
T3 update Foos set flag2=2 where flag1=1 //(10条)
T4 insert into Foos (..,flag1,…) values (.., 1 ,..)
T5 提交事务
T6 select count(*) from Foos where flag1=1 //(11条)
T7 update Foos set flag2=2 where flag1=1 //(更新11条)
T8 提交事务

会看到新插入的那条数据会被更新。

MVCC保证事务的隔离性
隔离级别 脏读 不可重复读 幻读
读未提交 可能 可能 可能
读已提交 不可能 可能 可能
可重复读 不可能 不可能 Allowed, but not in PG
可串行化 不可能 不可能 不可能

Postgresql 可以请求四种事务隔离级别中的任意一种。

但实际在内部实现过程中,实际只有三种独立的隔离级别, 分别对应读已提交 可重复读可串行化

  • 读未提交 Read Uncommitted

    另一个事务中只要更新的记录(不需要等到提交[commit]),当前事务就会读取到更新的数据(脏读)

  • 读已提交 Read Committed

    读已提交是Postgresql里的缺省隔离级别.

    当一个事务运行在这个隔离级别时,SELECT查询 (没有for update/share子句)只能看到其他事务已提交的数据.

    实际上,SELECT查询看到一个在查询开始运行的瞬间该数据库的一个快照。 不过SELECT看得见其自身所在事务中之前的更新的执行结果,即使他们尚未提交。

    请注意,在同一个事务里两个相邻的SELECT命令可能看到不同的快照,因为其他事务可能会在两个SELECT执行期间提交。

  • 可重复读 Repeatable Read

    即使数据被其他事务修改,当前事务也不会读取到新的数据。

    重复读事务中的查询看到的是事务开始时的快照[基于xmin=X, cmin=0],而不是该事务内部当前查询[基于xmin=X, cmin=Y]开始时的快照,这样同一个事务后边的SELECT语句[保持xmin相同,cmin无论怎么变化]总是看到同样的数据。

    也就是说, 它们看不到自身事务开始之后提交的其它事务所作出的改变。

    不会出现可脏读,可重读读,可以幻读。

  • 可串行化 Serializable

    可串行化级别提供最严格的事务隔离。这个级别为所有已提交事务模拟串行的事务执行,就好像事务将被一个接着一个那样串行的执行。

    不过,正如可重复读隔离级别一样,使用这个级别的应用必须准备在串行化失败的时候重新启动事务。

    事实上,该隔离级别和重复性读希望,它只是监视这些条件,以所有事务的可能的序列不一致的方式执行并行的可串行化事务执行的行为。

    这种检测不引入任何阻止可重复读出现的行为,但有一些开销的监测,检测条件这可能会导致串行化异常,将触发串行化失败。

PostgreSQL 中的MVCC优势
  • 使用mvcc,读操作不会堵塞写,写操作不会堵塞读,提高了并发访问下的性能
  • 事务的回滚可立即完成,无论事务进行了多少操作
  • 数据可以大量更新,不需要像Mysql和Innodb引擎和Oracle那样需要保障回滚段不会被耗尽。
PostgreSQL 缺陷
事务ID个数限制

事务ID由32位数保存,而事务ID递增,当事务ID用完时,将会出现wraparound问题。

PostgreSQL通过 VACUUM机制来解决该问题。对于事务ID,PostgreSQL有三个事务ID有特殊意义:

  • 0代表invalid事务号
  • 1代表bootstrap事务号
  • 2代表frozon事务,frozon transaction id 比任何事务都要老。

可用的最小事务ID为3, VACCUM将所有已提交的事务ID均设置为2,即frozon。之后所有事务都比frozon事务新,因此VACUUM之前的所有已提交的数据都对之后的事务可见。PostgreSQL通过这种方式实现了事务ID的循环利用。

大量过期数据占用磁盘并降低查询性能

PostgreSQL更新数据并非真正更改记录值,而是通过将旧数据标记为删除。在插入新的数据来实现。对于更新或者删除频繁的表,会积累大量过期的数据,占用大量磁盘,并且由于需要扫描更多的数据,使得查询性能降低。

PostgreSQL解决该问题的方式也是使用 VACUUM机制。从释放磁盘的角度:

  • VACUUM该操作并不要求获得排它锁,因此它可以和其他的读写表操作并行进行。同时它只是简单的将dead tuple对应的磁盘空间标记为可用状态,新的数据可以重用这部分磁盘空间。但是这部分磁盘并不会被真正释放,也即不会被交还给操作系统。因此不能被其他程序复用,并且可能会产生磁盘碎片。
  • VACUUM FULL 需要获得排它锁,它通过”标记-复制”的方式将所有有效数据(非dead tuple)复制到新的磁盘文件中,并将原数据文件全部删除,并将未使用的磁盘空间交还操作系统,因此系统中其他进程可使用空间,并且不会因此产生磁盘碎片。
欣赏此文? 求鼓励,求支持!