PostgreSQL中多版本并发控制详解


这篇博文讨论了 PostgreSQL 中多版本并发控制的基础知识。然后介绍快照以及它们如何控制元组的可见性。还讨论了与表扫描 API 的集成。

与许多关系数据库管理系统一样,PostgreSQL 使用多版本并发控制(MVCC)来支持并行运行的事务,并协调对图元的并行访问。

  • 快照用于确定图元的哪个版本在哪个事务中可见。
  • 每个修改数据的事务都有一个事务 ID(txid)。
  • 图元与两个属性(xmin、xmax)一起存储,这两个属性决定了图元在哪个快照(以及哪个事务)中可见。

本博文将讨论快照的一些实现细节。

元组可见性
本文使用下表来说明快照在 PostgreSQL 中的工作原理。

CREATE TABLE temperature (
  time timestamptz NOT NULL,
  value float
);

让我们在此表中插入第一条记录:这是通过创建一个新事务、获取当前事务 ID(如果可用)、插入新元组、再次获取事务 ID 并提交事务来完成的。

BEGIN;

SELECT * FROM txid_current_if_assigned();
 txid_current_if_assigned
--------------------------

(1 row)

INSERT INTO temperature VALUES(now(), 4);

SELECT * FROM txid_current_if_assigned();
 txid_current_if_assigned
--------------------------
                  5062286
(1 row)

COMMIT;

从这个示例中可以看出,PostgreSQL 只在数据被修改后立即为事务分配一个事务 ID,这一点很重要。这样做是为了防止不必要的工作,并防止事务 ID 耗尽。即使事务 ID 是一个 32 位整数,该值也会在某个时刻耗尽。PostgreSQL 可以处理这种溢出(即冻结元组,以正确处理事务 ID 包络)。

系统属性 xmin 和 xmax 决定了能看到某个元组的第一个事务和最后一个事务。此外,ctid 属性显示元组在相应页面上的编号。
当 SELECT 语句中明确提到这些属性时,就会显示它们的值:

SELECT xmin, xmax, ctid, * FROM temperature;
  xmin   | xmax |  ctid |             time              | value
---------+------+-------+-------------------------------+-------
 5062286 |    0 | (0,1) | 2024-04-02 22:06:03.035868+02 |     4
(1 row)

输出结果表示:

  • 所有事务 ID >= 5062286 的事务都能看到这个元组。
  • 删除元组时,xmax 值将填入能看到此元组的最大事务 ID。
  • ctid为 0,1 表示该图元是第 0 页的第一个图元。

现在我们删除该图元:

EGIN;

DELETE FROM temperature;

SELECT * FROM txid_current_if_assigned();
 txid_current_if_assigned
--------------------------
                  5062291
(1 row)

COMMIT;

但是,当执行 SELECT 语句时,什么也不会返回,而是返回一个带有 xmin 和 xmax 值的元组。

SELECT xmin, xmax, ctid, * FROM temperature;
 xmin | xmax | ctid | time | value
------+------+------+------+-------
(0 rows)

产生这种行为的原因是内部扫描器。如果一个元组在当前事务快照中不可见。要从元组中获取这些值,我们需要使用更底层的工具,而不是简单的 SELECT。

PostgreSQL 的  pageinspect 扩展允许我们获取存储在页面上的所有元组,并解码内部标志和属性。需要加载该扩展,然后就可以检查关系的页面了。

-- Load the extension
CREATE EXTENSION pageinspect;

-- Get the tuples of the first page of the relation 'temperature'
SELECT lp, t_xmin, t_xmax FROM heap_page_items(get_raw_page('temperature', 0));

 lp | t_xmin  | t_xmax
----+---------+---------
  1 | 5062286 | 5062291

输出结果显示,第 0 页的第一个元组(上述输出中的ctid 为 (0,1))的 t_max 值为 5062291,与删除该元组的事务 ID 相同。因此,每个事务 ID 大于 5062291 的事务都看不到这个元组。

快照
PostgreSQL 扫描表时,必须指定快照。请参见 table_beginscan 函数,该函数将快照数据作为第二个参数:

static inline TableScanDesc table_beginscan(Relation rel,
    Snapshot snapshot, int nkeys, struct ScanKeyData *key)

内部数据结构
通常,事务快照 transaction snapshot被用作该函数的参数。结构  SnapshotData 包含快照的所有信息。在本博文中,我们将重点讨论以下属性:

typedef struct SnapshotData
{
  [...]
    /*
     *MVCC 快照永远无法查看 XID >= xmax 的效果。xmin
     * 作为优化存储,以避免搜索大多数元组的 XID 数组
     *。
     */
    TransactionId xmin;            
/* all XID < xmin are visible to me */
    TransactionId xmax;            
/* all XID >= xmax are invisible to me */

    
/*
     *对于普通 MVCC 快照,它包含正在进行中的所有 xact ID,除非快照是在恢复期间拍摄的,在这种情况下
     * 它是空的。对于历史 MVCC 快照,其含义是相反的,即
     * 它包含 xmin 和 xmax 之间*已提交*的事务。
     *
     * note: all ids in xip[] satisfy xmin <= xip[i] < xmax
     */

    TransactionId *xip;
    uint32        xcnt;            
/* # of xact ids in xip[] */
  [...]
}

字段 xmin 定义了系统中最老的活动事务。所有 txid 小于此值的事务都已提交。xmax 包含快照已知的最新事务 ID。当前快照不可见 txid > xmax 的所有图元。

为什么需要 xip 和 xcnt 字段?
对于 xmin 和 xmax 之间的事务 ID,需要确定创建快照时事务是已提交还是正在进行中。

DBMS 处理多个用户的查询。他们可以随时启动事务。这些事务的开始时间和提交时间没有顺序。这意味着在创建快照时,可能有事务 ID 大于 xmin 的事务已经提交。然而,在 [xmin, xmax] 范围内的其他一些事务仍未提交。由于需要正确处理已提交和未提交事务的数据,因此定义了一个长度为 xcnt 的事务 ID 数组 xip。它包含所有大于 xmin 且小于 xmax 的事务,这些事务在快照拍摄时正在进行中。

示例
为了说明这种行为,让我们用三个事务做一个实际例子。

事务1

BEGIN;

INSERT INTO temperature VALUES(now(), 5);

SELECT * FROM txid_current_if_assigned();
 txid_current_if_assigned
--------------------------
                  5062310
(1 row)

第一个事务在表temperature 中插入新数据,但保持未提交状态。该事务的事务 ID 是 5062310。

事务2

BEGIN;

INSERT INTO temperature VALUES(now(), 5);

SELECT * FROM txid_current_if_assigned();
 txid_current_if_assigned
--------------------------
                  5062311
(1 row)

此外,第二个事务向同一个表插入数据,但也保持未提交状态。该事务的 ID 是 5062311。

事务3

SELECT * FROM pg_current_snapshot();
 pg_current_snapshot
---------------------
 5062310:5062310:
(1 row)

第三个事务使用函数 pg_current_snapshot 获取当前快照。函数的输出意味着,ID 小于 5062310 的事务的所有更改都是可见的。等于或大于事务 ID 5062310 的更改不可见,此时不存在未提交的事务。

那么,仍未提交的事务 5062310 和 5062311 又是怎么回事呢?由于在这个演示系统中到目前为止还没有提交更多事务,所以 PostgreSQL 没有更改当前事务 ID。不过,这是可以改的:

SELECT * FROM pg_current_xact_id_if_assigned();
 pg_current_xact_id_if_assigned
--------------------------------

(1 row)

SELECT * FROM pg_current_xact_id();
 pg_current_xact_id
--------------------
            5062312
(1 row)

SELECT * FROM pg_current_snapshot();
       pg_current_snapshot
---------------------------------
 5062310:5062313:5062310,5062311
(1 row)


与函数 pg_current_xact_id_if_assigned 不同,函数 pg_current_xact_id 强制为当前事务指定一个事务 ID。在我们的例子中,它是 5062312。使用该事务 ID 也会导致快照的更新。

第一个值保持不变。不过,ID 小于 5062310 的事务修改的所有图元在当前快照中都是可见的。但是,上限 (xmax) 发生了变化。现在,所有等于或大于 5062313 的更改在当前快照中都不可见。因为我们的事务 ID 是 5062312,所以这些更改不可见是合理的。那么新的 5062310,5062311 部分呢?这是快照的 xip 部分,表示在拍摄快照时,5062310 和 5062311 这两个事务尚未提交。因此,这些更改在当前快照中也不可见。一旦这些事务中的一个提交,并且我们拍摄了新快照,事务 ID 就会从 zip 中移除,因此这些更改在当前快照中就会可见。

导出快照
PostgreSQL 另一个有趣的功能是导出快照并将其加载到其他会话中。调用 pg_export_snapshot 函数可以导出快照。该函数会返回快照的 ID,并在数据目录的 pg_snapshots 文件夹中创建一个相应的文件。

BEGIN;

SELECT * FROM pg_export_snapshot();
 pg_export_snapshot
---------------------
 0000000C-000005F6-1
(1 row)

该文件包含的信息与上面讨论过的 pg_current_snapshot 所返回的信息相同。此外,它还包含有关所用隔离级别或所用数据库 ID 的更多信息。

$ cat ~/postgresql-sandbox/data/REL_15_1_DEBUG/pg_snapshots/0000000C-000005F6-1
vxid:12/1526
pid:1362769
dbid:706615
iso:1
ro:0
xmin:5062310
xmax:5062313
xcnt:2
xip:5062310
xip:5062311
sof:0
sxcnt:0
rec:0

可以通过调用 SET TRANSACTION SNAPSHOT 0000000C-000005F6-1 将导出的快照加载到另一个事务中,从而使用与创建快照的事务相同的快照运行。

快照和事务隔离级别

  • 根据隔离级别的不同,快照会在事务启动时(可重复读取)或为事务中的每条语句(已提交读取)创建。
  • 当为事务中的每条语句创建新快照时,其他事务中已提交的数据就会在当前事务中可见。
  • 如果只为整个事务创建一个快照,xmax 值就会保持不变,ID 更高的事务中的新数据就不会可见,读取也是可重复的。