PG中的MVCC(多版本并发控制)
多版本并发控制(Multi-version Concurrency Control, MVCC)是每一个写操作都创建一个新版本数据,并保留旧版本数据。当事务读取数据时,系统会选择一个合适的版本呈现出来,通过这种方式实现各事务之间相互隔离。比如MySQL
、Oracle
,新版本数据写入时,将旧版本数据写入到回滚段,用新版本数据项覆盖原有数据区域。PostgreSQL
是将新旧版本数据都写入到数据文件中,当其他事物读取数据项时,根据可见性校验规则,选择合适的版本呈现出来。
一、事物ID
在PostgreSQL中,每一个事务都有一个唯一(并非真正唯一)标识符(txid
)。txid
是一个32位无符号整数,约42亿个,在事务启动后执行txid_current()
函数,即可获得当前事务ID。txid
可以比较大小,比当前txid
大的事务,认为是未来的事务,在当前事务中不可见。比当前txid
小的事务,认为是过去的事务,在当前的事务中可见。
1 2 3 4 5 6 7 8 9 | postgres=# begin ; BEGIN postgres=# select txid_current(); txid_current -------------- 650 (1 row) postgres=# |
PostgreSQL保留以下三个特殊txid
:
- 0表示无效的
txid
。 - 1表示初始启动的
txid
,仅用于数据库集群的初始化initdb
过程。 - 2表示冻结的
txid
二、事务ID大小比较
txid是理论上是无限大的,而实际上实现无限大不现实。PostgreSQL将txid
空间视为一个环,可以无限循环。对于某个特定的txid
,其环内前约21亿个txid
属于过去的,而其环内后约21亿个txid
属于未来的。txid在环内循环(txid随增长环内循环,至于如何保证过去事务不变成未来事务,这个是另一个冻结txid的话题,本文不做讨论)
三、隐藏列
在PostgreSQL中,表中每一行数据称为一个元组tuple
,每个tuple中除了用户自定义的数据外,还有其他隐藏列,本文只介绍用于并发控制的几个列t_ctid, t_xmin, t_xmax, t_cid
,用来展示如何实现新旧版本数据都写入到数据文件中。
在PG中,可以使用pageinspect这个外部扩展来观察数据库页面的内容。
在展示之前,需要安装插件pageinspect,如下:
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 | postgres=# create extension pageinspect ; CREATE EXTENSION postgres=# \dx List of installed extensions Name | Version | Schema | Description -----------------+---------+------------+---------------------------------------------- pageinspect | 1.7 | public | inspect database pages at a low level pg_freespacemap | 1.2 | public | examine the free space map (FSM) plpgsql | 1.0 | pg_catalog | PL/pgSQL procedural language (3 rows) postgres=# postgres=# \dx pageinspect List of installed extensions Name | Version | Schema | Description -------------+---------+--------+------------------------------------------------------- pageinspect | 1.8 | public | inspect the contents of database pages at a low level (1 row) postgres=# \dx+ pageinspect Objects in extension "pageinspect" Object description ------------------------------------------------------------------- function brin_metapage_info(bytea) function brin_page_items(bytea,regclass) function brin_page_type(bytea) function brin_revmap_data(bytea) function bt_metap(text) function bt_page_items(bytea) function bt_page_items(text,integer) function bt_page_stats(text,integer) function fsm_page_contents(bytea) function get_raw_page(text,integer) function get_raw_page(text,text,integer) function gin_leafpage_items(bytea) function gin_metapage_info(bytea) function gin_page_opaque_info(bytea) function hash_bitmap_info(regclass,bigint) function hash_metapage_info(bytea) function hash_page_items(bytea) function hash_page_stats(bytea) function hash_page_type(bytea) function heap_page_item_attrs(bytea,regclass) function heap_page_item_attrs(bytea,regclass,boolean) function heap_page_items(bytea) function heap_tuple_infomask_flags(integer,integer) function page_checksum(bytea,integer) function page_header(bytea) function tuple_data_split(oid,bytea,integer,integer,text) function tuple_data_split(oid,bytea,integer,integer,text,boolean) (27 rows) |
t_xmin:保存插入该元组事务txid。
t_xmax:保存删除或跟新该元组事务txid。
t_cid:保存事务内的第几条SQL命令,从0开始。如事务内第一条SQL记录为0,第二条SQL记录为1。
t_ctid:保存着指向自身或新元组的元组标识符tid
,tid
用于标识表中的元组。在更新该元组时,其t_ctid
会指向新版本的元组;否则t_ctid
会指向自己。
四、数据insert、update、delete
insert数据
insert数据的事务txid为3809,共insert两条数据。所以t_xmin为3809,t_ctid分别为(0,1),(0,2)代表0号数据块的第一条数据和第二条数据,t_cid代表事务内的第几条SQL命令。
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 | postgres=# create table a(id int primary key, v text); CREATE TABLE postgres=# begin ; BEGIN postgres=*# select txid_current(); txid_current -------------- 3809 (1 row) postgres=*# select txid_current(); txid_current -------------- 3809 (1 row) postgres=*# insert into a values (1, 'a'); INSERT 0 1 postgres=*# insert into a values (2, 'b'); INSERT 0 1 postgres=*# select txid_current(); txid_current -------------- 3809 (1 row) postgres=*# end; COMMIT postgres=# SELECT t_data,t_ctid,t_xmin,t_xmax,t_field3 as t_cid FROM heap_page_items(get_raw_page('a', 0)); t_data | t_ctid | t_xmin | t_xmax | t_cid ----------------+--------+--------+--------+------- \x010000000561 | (0,1) | 3809 | 0 | 0 \x020000000562 | (0,2) | 3809 | 0 | 1 (2 rows) postgres=# select xmin,xmax,cmin,cmax,id,v from a; xmin | xmax | cmin | cmax | id | v ------+------+------+------+----+--- 3809 | 0 | 0 | 0 | 1 | a 3809 | 0 | 1 | 1 | 2 | b (2 rows) |
update数据
id=2的数据将新旧版本数据都写入到数据文件中。旧版本数据t_xmax等于3810,表明3810号事务已将该数据更新或删除。新版本数据t_xmin等于3810,表明该条数据是3810号事务写入的。再次查询a表,只select到了新版本数据,旧版本数据在没有其他事务使用时,变成了dead tuple
(dead tuple
数据占用的磁盘空间会被vacuum进程回收再利用,本文不做讨论)
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 | postgres=# begin ; BEGIN postgres=*# select txid_current(); txid_current -------------- 3810 (1 row) postgres=*# update a set v = 'bb' where id = 2; UPDATE 1 postgres=*# end; COMMIT postgres=# SELECT t_data,t_ctid,t_xmin,t_xmax,t_field3 as t_cid FROM heap_page_items(get_raw_page('a', 0)); t_data | t_ctid | t_xmin | t_xmax | t_cid ------------------+--------+--------+--------+------- \x010000000561 | (0,1) | 3809 | 0 | 0 \x020000000562 | (0,3) | 3809 | 3810 | 0 \x02000000076262 | (0,3) | 3810 | 0 | 0 (3 rows) postgres=# select xmin,xmax,cmin,cmax,id,v from a; xmin | xmax | cmin | cmax | id | v ------+------+------+------+----+---- 3809 | 0 | 0 | 0 | 1 | a 3810 | 0 | 0 | 0 | 2 | bb (2 rows) |
delete数据
id=2的数据已经全部被删除。但是在数据文件中并没有被删除,只是在t_xmax上打了一个标志。
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 | postgres=# begin; BEGIN postgres=*# select txid_current(); txid_current -------------- 3811 (1 row) postgres=*# delete from a where id = 2; DELETE 1 postgres=*# end; COMMIT postgres=# SELECT t_data,t_ctid,t_xmin,t_xmax,t_field3 as t_cid FROM heap_page_items(get_raw_page('a', 0)); t_data | t_ctid | t_xmin | t_xmax | t_cid ------------------+--------+--------+--------+------- \x010000000561 | (0,1) | 3809 | 0 | 0 \x020000000562 | (0,3) | 3809 | 3810 | 0 \x02000000076262 | (0,3) | 3810 | 3811 | 0 (3 rows) postgres=# select * from a; id | v ----+--- 1 | a (1 row) postgres=# select xmin,xmax,cmin,cmax,id,v from a; xmin | xmax | cmin | cmax | id | v ------+------+------+------+----+--- 3809 | 0 | 0 | 0 | 1 | a (1 row) |
PostgreSQL多版本并发控制MVCC
(Multi-version Concurrency Control)是在写数据时创建一个新版本数据,并将新旧版本数据都保存在数据文件中。而并行的其他事务查询数据时,应该选择哪一个版本的数据来呈现呢?
接下来进行解释。
五、事务快照
事务快照是在某个时间点看到的事务状态信息,包括哪些事务已经完成,那些事务还未开始,哪些事务正在进行中。在PostgreSQL中,用txid_current_snapshot()
函数来获得当前的事务快照。
1 2 3 4 5 | postgres=# select txid_current_snapshot(); txid_current_snapshot ----------------------- 678:678: (1 row) |
事务快照的文本表现形式:xmin:xmax:xip_list
具体含义如下:
事务快照项 | 解释 | 说明 |
---|---|---|
xmin | Earliest transaction ID (txid) that is still active. All earlier transactions will either be committed and visible, or rolled back and dead. | 最早的活跃事务的txid,txid<xmin的事务要么提交,要么回滚 |
xmax | First as-yet-unassigned txid. All txids greater than or equal to this are not yet started as of the time of the snapshot, and thus invisible. | 第一个尚未分配的txid,txid>=xmin的事务没有开启,所以不可见。 |
xip_list | Active txids at the time of the snapshot. The list includes only those active txids between xmin and xmax ; there might be active txids higher than xmax . A txid that is xmin <= txid < xmax and not in this list was already completed at the time of the snapshot, and thus either visible or dead according to its commit status. The list does not include txids of subtransactions. | xmin<=txid<xmax中仍然活跃的事务。 |
例1: 100:100:
。 xmin
为100,因此txid < 100
的事务是非活跃的,要么提交,要么回滚。xmax
为100,因此txid ≥ 100
的事务尚未开始。
例2: 100:104:100,102
。xmin
为100,因此txid < 100
的事务是非活跃的,要么提交,要么回滚。xmax
为104,因此txid ≥ 104
的事务尚未开始。xip_list
为100,102,所以100,102号事务仍然活跃,101,103号事务不活跃,要么提交、要么回滚。
非活跃事务是回滚还是提交?需要查看PostgreSQL中clog记录,本文不做解释。
六、事务快照与事务隔离级别
PostgreSQL中事务隔离级别是通过事务快照来实现的,获取事务快照用来检查元组的可见性。对read committed
隔离级别,每执行一条语句都会获得事务快照;对repeatable read
,事务只会在执行第一条SQL时获取一次快照。注意,事务快照不是在事务开始时获取,而是在事务中执行语句时获取,看例子:
时间 | 事务一 | 事务二 | 事务三 | 说明 |
---|---|---|---|---|
Time_1 | begin transaction isolation level repeatable read; | 事务二首先开始,并设置为repeatable read,此时事务二并没有获取事务快照。 | ||
Time_2 | begin; | 事务一开始,默认read committed隔离级别 | ||
Time_3 | select count(1) from a;结果是0条insert into a values (1 , 'a'); | 事务一插入一条记录。 | ||
Time_4 | commit; | 事务一提交 | ||
Time_5 | select count(1) from a; 结果是1条 | 事务二获取事务快照,此时事务一已经提交,事务一的结果可以查询 | ||
Time_6 | begin;insert into a values (2 , 'b');commit ; | 事务三又插入一条记录,默认read committed隔离级别。 | ||
Time_7 | select count(1) from a; 结果是1条 | 事务二仍然使用Time_5获取是事务快照,只看到一条记录。 | ||
Time_8 | commit; | 事务二结束 |