PostgreSQL多版本并发控制简析

本篇基于PostgreSQL9.6版本。

并发引出的问题

在没有任何限制且数据只有一个版本的情况下,多个事务同时执行,可能出现以下问题:

  • 脏读:一个事务读到了其他事务还未提交的数据。
  • 不可重复读:一个事务重复读取同一记录,期间由于其他事务修改该记录并提交,导致读取的内容与之前不同。
  • 幻读:一个事务重复读取符合条件的所有记录,期间由于其他事务新增或删除符合条件的记录,导致读取的结果与之前不同。

pgxl-mvcc-001

如果结合业务场景,可以衍生出更多具体的可讨论的现象。问题的根源在于,事务之间数据的隔离程度的高低。通常,脏读是不能容忍的,因为他操作了不确定的数据。在PostgreSQL中虽然支持多种事务隔离级别,但都不允许出现脏读。

PostgreSQL的事务隔离级别如下图,

pgxl-mvcc-002

并发控制的策略

# 锁

最直接的方式是用锁。锁是加在资源上的,对资源的操作无非就是读和写,对资源的所有权无非就是独占和共享。如果一个事务独占一个资源,那在提交前只有他可以读写该资源,这样的锁称为排它锁;如果多个事务共享一个资源,则表示这些事务对该资源都可以读,但是都不能写,这样的锁称为共享锁。

所以,当一个事务对某资源加了排它锁,那其他事务不能再对其加排它锁或共享锁;当一个事务对其加了共享锁,那其他事务也可以继续加共享锁,但是不能加排它锁。可以形象地认为每个资源上都有一个盒子,盒子里面有很多珠子,盒子是排它锁,珠子是共享锁,事务需要共享锁时就拿走一颗珠子,需要排它锁时就拿走盒子,但必须全部珠子都在盒子里才行,如果有人拿走了珠子尚未归还,盒子就不能被拿走了,但是别人仍然可以拿珠子;当珠子都在盒子里的时候,有人把盒子拿走了,那其他人来也就拿不到珠子了。

# 多版本并发控制(MVCC)

另一种方式是MVCC。这种方式对数据引入了版本特性,每次更新其实都是一次插入,将变更后结果以新版本的方式保存,而旧版本仍然存在,只是打上一些标记。从这个角度看,每个版本的与业务相关数据内容其实都是不可变的,这样就自然避开了脏读的可能。尽管一条记录可以有多个版本,但对于一个事务最多只有一个版本可见,并且修改记录时仍然需要加锁控制,因为同一个版本的写操作只能顺序进行。

这种方式相比锁的优势在于,读写互不冲突、互不阻塞,这带来了更好的并发性能。代价是需要额外的存储开销,并为回收垃圾版本做额外的工作。

PostgreSQL的MVCC实现方式

在PostgreSQL里,一条记录(比如users表中的一行)可能存在多个的版本,一个版本就是一个tuple,我们在SQL层面看到的只是其中一个版本。每个tuple除了用户自定义的table column外,还包含一些自带的系统字段,如ctid、xmin和xmax,这些系统字段需要在select语句中特别指明才会显示。另外还有一些重要的隐藏属性保存在tuple的header中,如t_infomask、t_ctid。以下是这里需要关注的字段和属性的介绍,以及部分源码(src/include/access/htup_details.h)截图。

typedef struct HeapTupleFields
{
        TransactionId t_xmin;           /* inserting xact ID */
        TransactionId t_xmax;           /* deleting or locking xact ID */
        union
        {
                CommandId       t_cid;  /* inserting or deleting command ID, or both */
                TransactionId   t_xvac; /* old-style VACUUM FULL xact ID */
        }                       t_field3;
} HeapTupleFields;

typedef struct DatumTupleFields
{
        int32           datum_len_;     /* varlena header (do not touch directly!) */
        int32           datum_typmod;   /* -1, or identifier of a record type */
        Oid             datum_typeid;   /* composite type OID, or RECORDOID */
} DatumTupleFields;

struct HeapTupleHeaderData
{
        union
        {
                HeapTupleFields  t_heap;
                DatumTupleFields t_datum;
        }                        t_choice;
        ItemPointerData t_ctid;         /* current TID of this or newer tuple (or a speculative insertion token) */
        /* Fields below here must match MinimalTupleData! */
        uint16          t_infomask2;    /* number of attributes + various flags */
        uint16          t_infomask;             /* various flag bits, see below */
        uint8           t_hoff;                 /* sizeof header incl. bitmap, padding */
        bits8           t_bits[FLEXIBLE_ARRAY_MEMBER];  /* bitmap of NULLs */
};
  • ctid:保存tuple的索引信息(page_idx, item_idx)。
  • xmin:保存插入操作时的事务ID。
  • xmax:保存删除或锁定操作时的事务ID,初始是0。
  • t_infomask:保存多种状态标记位的十进制整数。例如HEAP_XMIN_COMMITTED是十六进制0x0100(二进制100000000),HEAP_XMIN_INVALID是十六进制0x0200(二进制1000000000),HEAP_XMAX_INVALID是十六进制0x0800(二进制100000000000),如果tuple同时具备这三个状态,则t_infomask的值为十进制2816(二进制101100000000)。
    /*
     * information stored in t_infomask:
     */
    #define HEAP_HASNULL                    0x0001  /* has null attribute(s) */
    #define HEAP_HASVARWIDTH                0x0002  /* has variable-width attribute(s) */
    #define HEAP_HASEXTERNAL                0x0004  /* has external stored attribute(s) */
    #define HEAP_HASOID                     0x0008  /* has an object-id field */
    #define HEAP_XMAX_KEYSHR_LOCK           0x0010  /* xmax is a key-shared locker */
    #define HEAP_COMBOCID                   0x0020  /* t_cid is a combo cid */
    #define HEAP_XMAX_EXCL_LOCK             0x0040  /* xmax is exclusive locker */
    #define HEAP_XMAX_LOCK_ONLY             0x0080  /* xmax, if valid, is only a locker */
    /* xmax is a shared locker */
    #define HEAP_XMAX_SHR_LOCK      (HEAP_XMAX_EXCL_LOCK | HEAP_XMAX_KEYSHR_LOCK)
    #define HEAP_LOCK_MASK          (HEAP_XMAX_SHR_LOCK | HEAP_XMAX_EXCL_LOCK | HEAP_XMAX_KEYSHR_LOCK)
    #define HEAP_XMIN_COMMITTED             0x0100  /* t_xmin committed */
    #define HEAP_XMIN_INVALID               0x0200  /* t_xmin invalid/aborted */
    #define HEAP_XMIN_FROZEN        (HEAP_XMIN_COMMITTED|HEAP_XMIN_INVALID)
    #define HEAP_XMAX_COMMITTED             0x0400  /* t_xmax committed */
    #define HEAP_XMAX_INVALID               0x0800  /* t_xmax invalid/aborted */
    #define HEAP_XMAX_IS_MULTI              0x1000  /* t_xmax is a MultiXactId */
    #define HEAP_UPDATED                    0x2000  /* this is UPDATEd version of row */
    #define HEAP_MOVED_OFF                  0x4000  /* moved to another place by pre-9.0 VACUUM FULL; kept for binary upgrade support */
    #define HEAP_MOVED_IN                   0x8000  /* moved from another place by pre-9.0 VACUUM FULL; kept for binary upgrade support */
    #define HEAP_MOVED              (HEAP_MOVED_OFF | HEAP_MOVED_IN)
    #define HEAP_XACT_MASK                  0xFFF0  /* visibility-related bits */
    
  • t_ctid:与自身的ctid值相同,或保存下一个新版本tuple的ctid。

下面通过举例来一步步阐述上面五个属性与tuple可见性的关系。

开始前先准备users表和数据,并创建用于查看page和tuple的扩展模块pageinspect。与查询分析相关的语句如下,

postgres=# create table users ( id integer primary key, points integer);
CREATE TABLE
postgres=# insert into users (id, points) values (1, 200), (2, 500), (3, 1000);
INSERT 0 3
postgres=# select ctid, xmin, xmax, * from users;
 ctid  | xmin | xmax | id | points
-------+------+------+----+--------
 (0,1) | 2172 |    0 |  1 |    200
 (0,2) | 2172 |    0 |  2 |    500
 (0,3) | 2172 |    0 |  3 |   1000
(3 rows)

postgres=# create extension pageinspect;
CREATE EXTENSION
postgres=# -- 查看users表的主数据文件第0页的header信息
postgres=# select * from page_header(get_raw_page('users', 'main', 0));
    lsn    | checksum | flags | lower | upper | special | pagesize | version | prune_xid
-----------+----------+-------+-------+-------+---------+----------+---------+-----------
 0/1CCA210 |        0 |     0 |    36 |  8096 |    8192 |     8192 |       4 |         0
(1 row)

postgres=# -- 查看users表的主数据文件第0页的所有tuple信息
postgres=# select * from heap_page_items(get_raw_page('users', 'main', 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 |   8160 |        1 |     32 |   2172 |      0 |        0 | (0,1)  |           2 |       2304 |     24 |        |       | \x01000000c8000000
  2 |   8128 |        1 |     32 |   2172 |      0 |        0 | (0,2)  |           2 |       2304 |     24 |        |       | \x02000000f4010000
  3 |   8096 |        1 |     32 |   2172 |      0 |        0 | (0,3)  |           2 |       2304 |     24 |        |       | \x03000000e8030000
(3 rows)

postgres=# -- 查看索引users_pkey的元数据信息
postgres=# select * from bt_metap('users_pkey');
 magic  | version | root | level | fastroot | fastlevel
--------+---------+------+-------+----------+-----------
 340322 |       2 |    1 |     0 |        1 |         0
(1 row)

postgres=# -- 查看索引users_pkey第1页的数据
postgres=# select * from bt_page_items('users_pkey', 1);
 itemoffset | ctid  | itemlen | nulls | vars |          data
------------+-------+---------+-------+------+-------------------------
          1 | (0,1) |      16 | f     | f    | 01 00 00 00 00 00 00 00
          2 | (0,2) |      16 | f     | f    | 02 00 00 00 00 00 00 00
          3 | (0,3) |      16 | f     | f    | 03 00 00 00 00 00 00 00
(3 rows)

postgres=#

首先看下insert、update、delete操作在tuple层面都做了哪些事情。

postgres=# insert into users (id, points) values (4, 200);
INSERT 0 1
postgres=# select ctid, xmin, xmax, * from users where id = 4;
 ctid  | xmin | xmax | id | points
-------+------+------+----+--------
 (0,4) | 2244 |    0 |  4 |    200
(1 row)

postgres=# select t_xmin, t_xmax, t_ctid, t_infomask from heap_page_items(get_raw_page('users', 'main', 0)) where t_xmin::text::bigint >= 2244::bigint;
 t_xmin | t_xmax | t_ctid | t_infomask
--------+--------+--------+------------
   2244 |      0 | (0,4)  |       2304
(2 rows)

postgres=# update users set points = 100 where id = 4;
UPDATE 1
postgres=# select ctid, xmin, xmax, * from users where id = 4;
 ctid  | xmin | xmax | id | points
-------+------+------+----+--------
 (0,5) | 2245 |    0 |  4 |    100
(1 row)

postgres=# select t_xmin, t_xmax, t_ctid, t_infomask from heap_page_items(get_raw_page('users', 'main', 0)) where t_xmin::text::bigint >= 2244::bigint;
 t_xmin | t_xmax | t_ctid | t_infomask
--------+--------+--------+------------
   2244 |   2245 | (0,5)  |       1280
   2245 |      0 | (0,5)  |      10496
(3 rows)

postgres=# delete from users where id = 4;
DELETE 1
postgres=# select ctid, xmin, xmax, * from users where id = 4;
 ctid | xmin | xmax | id | points
------+------+------+----+--------
(0 rows)

postgres=# select t_xmin, t_xmax, t_ctid, t_infomask from heap_page_items(get_raw_page('users', 'main', 0)) where t_xmin::text::bigint >= 2244::bigint;
 t_xmin | t_xmax | t_ctid | t_infomask
--------+--------+--------+------------
   2244 |   2245 | (0,5)  |       1280
   2245 |   2246 | (0,5)  |       9472
(3 rows)

由上图可见,

  1. insert一条记录会产生一个tuple,它的xmin等于操作时的事务ID,xmax初始为0,t_ctid保存当前tuple的ctid,t_infomask转换成十六进制是0x0900,对照源码解析为HEAP_XMIN_COMMITTED & HEAP_XMAX_INVALID状态。
  2. update一条记录会产生一个新的tuple,同时把那个旧tuple的xmax改为当前操作的事务ID,t_ctid改为新tuple的ctid,t_infomask为HEAP_XMIN_COMMITTED & HEAP_XMAX_COMMITTED。新tuple类似insert的结果,不同的是t_infomask多了个HEAP_UPDATED状态,它的含义是这个版本是由update产生的,这里可以无视它。
  3. delete一条记录会直接把tuple的xmax改为当前操作的事务ID,t_infomask为HEAP_XMIN_COMMITTED & HEAP_XMAX_COMMITTED & HEAP_UPDATED。

通过对t_infomask分析,得出初步判断,

  • 【1.1】同时具备HEAP_XMIN_COMMITTED(意为xmin对应的事务已提交)和HEAP_XMAX_INVALID(意为xmax为初始的0或对应的事务已中止,此处为0的情况)的tuple可见。即生成它的事务已提交,且尚未被其他事务修改或锁定的tuple可见
  • 【1.2】具备HEAP_XMAX_COMMITTED(xmax对应的事务已提交)的tuple不可见。即已经被修改,且该事务已提交的tuple不可见

然后看下READ COMMITTED隔离级别下,事务间tuple的可见性,以及tuple状态变化的规律。此处缺省为READ COMMITTED。

postgres=# begin;
BEGIN
postgres=# update users set points = 100 where id = 1;
UPDATE 1
postgres=# select ctid, xmin, xmax, * from users where id = 1;  --看到的是当前事务刚更新的版本
 ctid  | xmin | xmax | id | points
-------+------+------+----+--------
 (0,4) | 2477 |    0 |  1 |    100
(1 row)

postgres=# select t_xmin, t_xmax, t_ctid, t_infomask from heap_page_items(get_raw_page('users', 'main', 0)) where t_xmin::text::bigint >= 2475::bigint;
 t_xmin | t_xmax | t_ctid | t_infomask
--------+--------+--------+------------
   2475 |   2477 | (0,4)  |       8960
   2477 |      0 | (0,4)  |      10240
(2 rows)

postgres=# commit;
COMMIT
postgres=# select t_xmin, t_xmax, t_ctid, t_infomask from heap_page_items(get_raw_page('users', 'main', 0)) where t_xmin::text::bigint >= 2475::bigint;
 t_xmin | t_xmax | t_ctid | t_infomask
--------+--------+--------+------------
   2475 |   2477 | (0,4)  |       8960
   2477 |      0 | (0,4)  |      10240
(2 rows)

postgres=#
postgres=#
postgres=#
postgres=# select t_xmin, t_xmax, t_ctid, t_infomask from heap_page_items(get_raw_page('users', 'main', 0)) where t_xmin::text::bigint >= 2475::bigint;
 t_xmin | t_xmax | t_ctid | t_infomask
--------+--------+--------+------------
   2475 |   2477 | (0,4)  |       9984
   2477 |      0 | (0,4)  |      10496
(2 rows)

postgres=#
postgres=#
postgres=#
postgres=# begin;
BEGIN
postgres=# select txid_current(), txid_current_snapshot();  -- 为当前事务获取事务ID,并查看快照
 txid_current | txid_current_snapshot
--------------+-----------------------
         2478 | 2477:2477:
(1 row)

postgres=# select ctid, xmin, xmax, * from users where id = 1;  --看到的是旧版本,避免脏读
 ctid  | xmin | xmax | id | points
-------+------+------+----+--------
 (0,3) | 2475 | 2477 |  1 |    200
(1 row)

postgres=#
postgres=#
postgres=#
postgres=#
postgres=#
postgres=#
postgres=#
postgres=#
postgres=#
postgres=#
postgres=#
postgres=# select ctid, xmin, xmax, * from users where id = 1;  --看到新版本,出现不可重复读
 ctid  | xmin | xmax | id | points
-------+------+------+----+--------
 (0,4) | 2477 |    0 |  1 |    100
(1 row)

postgres=# select txid_current(), txid_current_snapshot();  -- 再次查看事务ID和快照
 txid_current | txid_current_snapshot
--------------+-----------------------
         2478 | 2478:2478:
(1 row)

postgres=# commit;
COMMIT

上图中,左侧是事务A,xid为2477;右侧是事务B(READ COMMITTED),xid为2478。由上可见,

  1. 事务A更新id = 1的记录后,A看到的是新tuple(t_xmin = 2477),事务B看到的仍然是旧tuple(t_xmin = 2475),不会产生脏读。此时新tuple的t_infomask是HEAP_XMAX_INVALID|HEAP_UPDATED,旧tuple的t_infomask是HEAP_XMIN_FROZEN|HEAP_UPDATED。
    值得注意的是,当事务A提交后,再次查询记录前,新旧tuple的t_infomask没有发生改变,直到事务B查询了一次该记录。此时新tuple的t_infomask比之前多了HEAP_XMIN_COMMITTED;旧tuple的t_infomask比之前多了HEAP_XMAX_COMMITTED。这是因为在insert、update、delete操作发生后,相关事务的状态保存在clog中,不会立即同步到tuple的t_infomask里。此时对于tuple而言,与xmin或xmax相关的事务的状态是未知的。再次查询时,如果相关事务状态未知,则去clog读取事务状态并更新到tuple中。
  2. 事务A提交后,事务B再次查询id = 1的记录,看到的是A更新后的新tuple,产生不可重复读
  3. 同样也会产生幻读,此处略。

通过对t_infomask分析,得出初步判断,

  • 【2.1】xmin为当前事务ID,且具备HEAP_XMAX_INVALID(此处xmax为0的情况)的tuple可见(事务内多次更新一条记录涉及到cid状态,这里不作描述)。即由当前事务生成的,且尚未被当前事务修改的tuple可见。被当前事务修改的tuple不可见
  • 【2.2】具备HEAP_XMIN_FROZEN(HEAP_XMIN_COMMITTED|HEAP_XMIN_INVALID,表示此tuple被冻结),且xmax对应的事务不是当前事务,且xmax对应的事务状态为进行中的tuple可见。即被其他事务正在修改的已冻结的tuple可见

再来看下事务回滚对tuple可见性的影响,以及MVCC中行锁的作用。

postgres=# begin;
BEGIN
postgres=# update users set points = 888 where id = 1;
UPDATE 1
postgres=# select t_xmin, t_xmax, t_ctid, t_infomask from heap_page_items(get_raw_page('users', 'main', 0)) where t_xmin::text::bigint >= 2432::bigint;
 t_xmin | t_xmax | t_ctid | t_infomask
--------+--------+--------+------------
   2432 |   2435 | (0,4)  |       8960
   2435 |      0 | (0,4)  |      10240
(2 rows)

postgres=# commit;
COMMIT
postgres=#
postgres=#
postgres=#
postgres=# select t_xmin, t_xmax, t_ctid, t_infomask from heap_page_items(get_raw_page('users', 'main', 0)) where t_xmin::text::bigint >= 2432::bigint;
 t_xmin | t_xmax | t_ctid | t_infomask
--------+--------+--------+------------
   2432 |   2435 | (0,4)  |       9984
   2435 |   2436 | (0,5)  |       8448
   2436 |   2436 | (0,5)  |       8336
(3 rows)

postgres=#
postgres=#
postgres=# select t_xmin, t_xmax, t_ctid, t_infomask from heap_page_items(get_raw_page('users', 'main', 0)) where t_xmin::text::bigint >= 2432::bigint;
 t_xmin | t_xmax | t_ctid | t_infomask
--------+--------+--------+------------
   2432 |   2435 | (0,4)  |       9984
   2435 |   2436 | (0,5)  |      10496
   2436 |   2436 | (0,5)  |       8848
(3 rows)
postgres=# begin;
BEGIN
postgres=#
postgres=#
postgres=# -- 阻塞,等待获得行锁
postgres=# update users set points = 80 where id = 1;






UPDATE 1
postgres=#
postgres=# -- 回滚
postgres=# rollback;
ROLLBACK
postgres=#
postgres=#
postgres=#
postgres=#
postgres=#
postgres=# select ctid, xmin, xmax, * from users where id = 1;
 ctid  | xmin | xmax | id | points
-------+------+------+----+--------
 (0,4) | 2435 | 2436 |  1 |    888
(1 row)

postgres=#
postgres=#
postgres=#
postgres=#
postgres=#

上图中,左侧是事务A,xid为2435;右侧是事务B(READ COMMITTED),xid为2436。由上可见,

  1. 事务A更新id = 1的记录尚未提交时,事务B也更新该记录,发现tuple被A锁定,于是B被阻塞,等待锁释放。A提交后,B的update得以继续执行。说明不同事务操作同一个tuple时,通过锁来控制并发。从tuple的变化看到,事务B最终修改的是该记录的最新版,也就是事务A更新后产生的新tuple(t_xmin = 2435,因为当A提交后,该记录的这个tuple对B可见)。同时B又产生了更加新的tuple(t_xmin = 2436)。
    需要注意的是,如果事务B的隔离级别是REPEATABLE READ,则A提交后,B的update操作会失败(ERROR: could not serialize access due to concurrent update),因为B可见的旧tuple已经发生改变。
  2. 事务B回滚后,由B产生的新tuple(t_xmin = 2436)的t_infomask比之前多了HEAP_XMIN_INVALID,旧tuple(t_xmin = 2435)的t_infomask比之前多了HEAP_XMAX_INVALID。该记录对新事务的可见tuple仍然是t_xmin = 2435的tuple。

通过分析,得出初步判断,

  • 【3.1】具备HEAP_XMIN_INVALID,且不具备HEAP_XMIN_COMMITTED的tuple不可见。即生成它的事务已中止的tuple不可见
  • 【3.2】同时具备HEAP_XMIN_COMMITTED和HEAP_XMAX_INVALID(此处为对应的事务已中止)的tuple可见。即修改它的事务已中止的tuple可见

再来看下REPEATABLE READ隔离级别下,事务间tuple的可见性。

postgres=# begin;
BEGIN
postgres=# update users set points = 111 where id = 1;
UPDATE 1
postgres=#
postgres=# select ctid, xmin, xmax, * from users where id = 1;  --看到的是当前事务刚更新的版本
 ctid  | xmin | xmax | id | points
-------+------+------+----+--------
 (0,4) | 2483 |    0 |  1 |    111
(1 row)

postgres=#
postgres=# commit;
COMMIT
postgres=#
postgres=#
postgres=#
postgres=#
postgres=#
postgres=#
postgres=# select t_xmin, t_xmax, t_ctid, t_infomask from heap_page_items(get_raw_page('users', 'main', 0)) where t_xmin::text::bigint >= 2477::bigint;
 t_xmin | t_xmax | t_ctid | t_infomask
--------+--------+--------+------------
   2477 |   2483 | (0,4)  |       9984
   2483 |      0 | (0,4)  |      10496
(2 rows)

postgres=#
postgres=#
postgres=# begin transaction isolation level repeatable read;
BEGIN
postgres=# select txid_current(), txid_current_snapshot();  -- 为当前事务获取事务ID,并查看快照
 txid_current | txid_current_snapshot
--------------+-----------------------
         2484 | 2483:2483:
(1 row)

postgres=# select ctid, xmin, xmax, * from users where id = 1;  -- 看到的是旧版本,避免脏读
 ctid  | xmin | xmax | id | points
-------+------+------+----+--------
 (0,3) | 2477 | 2483 |  1 |    888
(1 row)

postgres=#
postgres=# select ctid, xmin, xmax, * from users where id = 1;  -- 看到的是旧版本,避免不可重复读
 ctid  | xmin | xmax | id | points
-------+------+------+----+--------
 (0,3) | 2477 | 2483 |  1 |    888
(1 row)

postgres=# select txid_current(), txid_current_snapshot();  -- 再次查看事务ID和快照
 txid_current | txid_current_snapshot
--------------+-----------------------
         2484 | 2483:2483:
(1 row)

postgres=# commit;
COMMIT

由上图可见,REPEATABLE READ隔离级别的事务不会产生脏读、不可重复读、幻读。与前面的READ COMMITTED事务不同,事务A提交后,事务B看到的仍然是旧tuple,看不到新tuple,这涉及到另外一个重要概念,快照,即txid_current_snapshot()显示的内容。快照可以理解为某一时刻一组事务的状态集合,不同隔离级别、不同时刻的快照都可能不同。对比READ COMMITTED事务发现,已经开始的REPEATABLE READ事务的快照不会因为其他事务的提交而改变,这直接影响tuple在事务中的可见性。下面举例说明,

postgres=# select txid_current_snapshot();
        txid_current_snapshot
------------------------------------------
 9592:9601:9592,9593,9594,9596,9598,9599
(1 row)

快照的结构为xmin:xmax:xip_list,各自含义如下,

  • xmin:仍然处于进行中状态的事务里最早开始的那个事务的ID。(之前的事务要么已提交,要么已回滚)
  • xmax:尚未分配的事务ID里的第一个ID。(大于等于它的事务都被认为是尚未开始的)
  • xip_list:在xmin和xmax之间的仍然处于进行中状态的事务的ID。

通过分析,得出初步判断,

  • 【4.1】之前的判断并不充分,tuple是否可见需要附加一个与时序相关必要条件:可见的tuple至少要xmin小于当前快照的xmax,或者等于当前事务的ID;xmin大于等于当前快照的xmax,且不等于当前事务ID的tuple一定不可见。即以快照的xmax为分界线,之前生成的tuple为历史数据,根据其状态判断是否可见;之后生成的tuple为未来数据,不可见。特别的,正在进行的事务把自己的XID看作已提交,把自己生成的tuple看作历史数据

最后看下vacuum对版本的影响,以及索引与版本的关系。

postgres=# update users set points = 2001 where id = 1;
UPDATE 1
postgres=# update users set points = 2011 where id = 1;
UPDATE 1
postgres=# update users set points = 2111 where id = 1;
UPDATE 1
postgres=# select * from heap_page_items(get_raw_page('users', 'main', 0));  -- 查看users表第0页的tuples
 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 |   8160 |        1 |     32 |   2172 |      0 |        0 | (0,1)  |           2 |       2816 |     24 |        |       | \x02000000f4010000
  2 |   8128 |        1 |     32 |   2172 |      0 |        0 | (0,2)  |           2 |       2816 |     24 |        |       | \x03000000e8030000
  3 |   8096 |        1 |     32 |   2557 |   2559 |        0 | (0,4)  |       16386 |       9984 |     24 |        |       | \x0100000078030000
  4 |   8064 |        1 |     32 |   2559 |   2560 |        0 | (0,5)  |       49154 |       9472 |     24 |        |       | \x01000000d1070000
  5 |   8032 |        1 |     32 |   2560 |   2561 |        0 | (0,6)  |       49154 |       8448 |     24 |        |       | \x01000000db070000
  6 |   8000 |        1 |     32 |   2561 |      0 |        0 | (0,6)  |       32770 |      10240 |     24 |        |       | \x010000003f080000
(6 rows)

postgres=# vacuum users;  -- vacuum without full
VACUUM
postgres=# select * from heap_page_items(get_raw_page('users', 'main', 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 |   8160 |        1 |     32 |   2172 |      0 |        0 | (0,1)  |           2 |       2816 |     24 |        |       | \x02000000f4010000
  2 |   8128 |        1 |     32 |   2172 |      0 |        0 | (0,2)  |           2 |       2816 |     24 |        |       | \x03000000e8030000
  3 |      6 |        2 |      0 |        |        |          |        |             |            |        |        |       |
  4 |      0 |        0 |      0 |        |        |          |        |             |            |        |        |       |
  5 |      0 |        0 |      0 |        |        |          |        |             |            |        |        |       |
  6 |   8096 |        1 |     32 |   2561 |      0 |        0 | (0,6)  |       32770 |      10496 |     24 |        |       | \x010000003f080000
(6 rows)

postgres=# select * from bt_page_items('users_pkey', 1);
 itemoffset | ctid  | itemlen | nulls | vars |          data
------------+-------+---------+-------+------+-------------------------
          1 | (0,3) |      16 | f     | f    | 01 00 00 00 00 00 00 00
          2 | (0,1) |      16 | f     | f    | 02 00 00 00 00 00 00 00
          3 | (0,2) |      16 | f     | f    | 03 00 00 00 00 00 00 00
(3 rows)

postgres=# delete from users where id = 1;
DELETE 1
postgres=# insert into users values (1, 200);
INSERT 0 1
postgres=# select * from heap_page_items(get_raw_page('users', 'main', 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 |   8160 |        1 |     32 |   2172 |      0 |        0 | (0,1)  |           2 |       2816 |     24 |        |       | \x02000000f4010000
  2 |   8128 |        1 |     32 |   2172 |      0 |        0 | (0,2)  |           2 |       2816 |     24 |        |       | \x03000000e8030000
  3 |      6 |        2 |      0 |        |        |          |        |             |            |        |        |       |
  4 |   8064 |        1 |     32 |   2563 |      0 |        0 | (0,4)  |           2 |       2048 |     24 |        |       | \x01000000c8000000
  5 |      0 |        0 |      0 |        |        |          |        |             |            |        |        |       |
  6 |   8096 |        1 |     32 |   2561 |   2562 |        0 | (0,6)  |       40962 |       9472 |     24 |        |       | \x010000003f080000
(6 rows)

postgres=# select * from bt_page_items('users_pkey', 1);
 itemoffset | ctid  | itemlen | nulls | vars |          data
------------+-------+---------+-------+------+-------------------------
          1 | (0,4) |      16 | f     | f    | 01 00 00 00 00 00 00 00
          2 | (0,3) |      16 | f     | f    | 01 00 00 00 00 00 00 00
          3 | (0,1) |      16 | f     | f    | 02 00 00 00 00 00 00 00
          4 | (0,2) |      16 | f     | f    | 03 00 00 00 00 00 00 00
(4 rows)

postgres=# select ctid, xmin, xmax, * from users;
 ctid  | xmin | xmax | id | points
-------+------+------+----+--------
 (0,1) | 2172 |    0 |  2 |    500
 (0,2) | 2172 |    0 |  3 |   1000
 (0,4) | 2563 |    0 |  1 |    200
(3 rows)

postgres=# vacuum freeze users;  -- vacuum freeze without full
VACUUM
postgres=# select * from heap_page_items(get_raw_page('users', 'main', 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 |   8160 |        1 |     32 |   2172 |      0 |        0 | (0,1)  |           2 |       2816 |     24 |        |       | \x02000000f4010000
  2 |   8128 |        1 |     32 |   2172 |      0 |        0 | (0,2)  |           2 |       2816 |     24 |        |       | \x03000000e8030000
  3 |      0 |        0 |      0 |        |        |          |        |             |            |        |        |       |
  4 |   8064 |        1 |     32 |   2563 |      0 |        0 | (0,4)  |           2 |       2816 |     24 |        |       | \x01000000c8000000
  5 |      0 |        0 |      0 |        |        |          |        |             |            |        |        |       |
  6 |      0 |        0 |      0 |        |        |          |        |             |            |        |        |       | 
(6 rows)

postgres=# select * from bt_page_items('users_pkey', 1);
 itemoffset | ctid  | itemlen | nulls | vars |          data
------------+-------+---------+-------+------+-------------------------
          1 | (0,4) |      16 | f     | f    | 01 00 00 00 00 00 00 00
          2 | (0,1) |      16 | f     | f    | 02 00 00 00 00 00 00 00
          3 | (0,2) |      16 | f     | f    | 03 00 00 00 00 00 00 00
(4 rows)

postgres=# vacuum full users;  -- vacuum full
VACUUM
postgres=# select * from heap_page_items(get_raw_page('users', 'main', 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 |   8160 |        1 |     32 |   2172 |      0 |        0 | (0,1)  |           2 |       2816 |     24 |        |       | \x02000000f4010000
  2 |   8128 |        1 |     32 |   2172 |      0 |        0 | (0,2)  |           2 |       2816 |     24 |        |       | \x03000000e8030000
  3 |   8064 |        1 |     32 |   2563 |      0 |        0 | (0,3)  |           2 |       2816 |     24 |        |       | \x01000000c8000000
(6 rows)

postgres=# select * from bt_page_items('users_pkey', 1);
 itemoffset | ctid  | itemlen | nulls | vars |          data
------------+-------+---------+-------+------+-------------------------
          1 | (0,3) |      16 | f     | f    | 01 00 00 00 00 00 00 00
          2 | (0,1) |      16 | f     | f    | 02 00 00 00 00 00 00 00
          3 | (0,2) |      16 | f     | f    | 03 00 00 00 00 00 00 00
(4 rows)

分别进行了vacuum、vacuum freeze、vacuum full操作,可以看到,

  1. vacuum(without full)会回收dead tuple,但不会改变表索引page中的item。由于索引未改变,所以被索引引用了ctid的dead tuple不会被回收,而是通过改变其lp_off(tuple在page中的位置或重定向偏移量)和lp_flags(tuple标记,0未使用,1已使用,2重定向)来重定向到可见的tuple。被回收的tuple可以被重新利用。
  2. vacuum freeze比vacuum(without full)多了一个作用,会冻结可见的tuple(即设置t_infomask增加HEAP_XMIN_FROZEN)。
  3. vacuum full实质是新建数据文件,copy旧数据到新位置,所以原本可见的tuple的ctid也发生变化,回收的空间被释放给磁盘空间。

# 总结

## 在PostgreSQL中,每次单独执行insert、update、delete语句都会开启一次事务,系统会分配一个名为XID的事务ID,这个XID会被保存到SQL语句生成的tuple的xmin和更新的tuple的xmax。另一种情况是显式使用BEGIN…COMMIT语句块开启一个事务,不同的是,如果这个事务中没有使用insert、update、delete或txid_current()等主动申请XID的语句,那么将不会被分配XID,并且只有在第一个此类语句被调用时申请一次XID,供整个事务使用。事务ID实际上是一个计数器,递增分配。

tuple对于当前事务的可见性受生成它的事务的状态、更新它的事务的状态、当前事务的隔离级别共同影响。事务状态会先保存在clog,后更新到t_infomask,最终clog会被清理,而t_infomask的生命周期与tuple一致。事务的隔离级别决定快照的xmax,例如READ COMMITTED事务中,快照的xmax会随事务ID的递增而变化,使其可以把不断开始的新事务当作历史,看到那些新生成的已提交状态的tuple。而REPEATABLE READ事务中,快照的xmax为获取事务ID时尚未开始的事务ID保持不变,因此即使新事务生成了新的tuple并已提交,它也看不到。

综上所述,当前事务能够看到哪些tuple首先取决于快照,那些大于等于快照xmax的被认为是未发生的事务,因此由他们生成的tuple不可见。小于快照xmin的为已提交或已中止的事务,在快照xmin和xmax之间的事务除了已提交和已中止外,还可能包括当前事务和其他正在进行中的事务。这两个范围的tuple的可见性规则是:生成它的事务已提交,且尚未被其他事务修改或锁定的tuple可见;修改它的事务已中止的tuple可见;已经被修改,且该事务已提交的tuple不可见;生成它的事务已中止的tuple不可见;被其他事务正在修改的已冻结的tuple可见;由当前事务生成的,且尚未被当前事务修改的tuple可见;被当前事务修改的tuple不可见。

## 事情到此似乎已经结束,然而还有一个重要的疑问没有回答:何为历史,何为未来?如果事务ID一直递增下去,后面的数字永远比前面的大,那刚才的直接比较大小的理解方式没有问题。但事务ID并不是可以无限增大的,它的尺寸有限(32位),最多支撑40亿多个事务。当递增超过最大值时会遇到事务ID回卷(wraparound),即XID计数器会回到0的情况。这意味着本来属于历史的tuple一下子变成了未来,依据可见性规则他们是不能被看到的,这导致了“数据丢失”的发生。所以简单地用“比当前XID小的是历史,比当前XID大的是未来”的逻辑来判断tuple生于何时是行不通的,因为它不是一条线,而是一个环。

于是,开发者首先采用mod-2^32算法来解决XID比较大小的问题。举例说明,假如当前即将分配的XID是1024,在环上是一个点,它与对端的点把环上的其他XID分为两半,对于1024来说前面的一半都是未来,后面的一半都是历史。对端的点可以理解为历史与未来的鸿沟,当XID向前移动,这个鸿沟也在向前移动,对于一个XID来说始终有20亿多个历史和一样多的未来。算法有了,但没有解决tuple从历史变成未来的问题。举例说明,当XID从1024移动到其对端时,那正好颠倒过来,1024这个点成为了鸿沟,如果继续向前移动,则1024生成的tuple将从历史半圈进入未来半圈,它将从此消失不见。为了解决这个问题,开发者采用了一个新的概念,即前面提到的冻结,来标记tuple,使其可见性不受XID大小关系的影响,也可以理解为让其从环上脱离,进入足够久远的历史,这样“未来”再也覆盖不到它。

因此,PostgreSQL需要定期定量把以当前事务ID为参照点的历史进行冻结,来避免出现“数据丢失”。这个过程包含在autovacuum机制中,也可以调用vacuum freeze命令进行。

至于为什么事务ID没有被设计为64位,开发者的解释是每个tuple都会存储xmin和xmax,扩展到64位势必会占用更多空间,而tuple的header已经相对较大,他们不想这么做。

Creative Commons License

本文基于署名-非商业性使用-相同方式共享 4.0许可协议发布,欢迎转载、使用、重新发布,但请保留文章署名wanghengbin(包含链接:https://wanghengbin.com),不得用于商业目的,基于本文修改后的作品请以相同的许可发布。

发表评论