PostgreSQL中多版本併發控制詳解

banq發表於2024-04-05


這篇博文討論了 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
{
  [...]
    <font>/*
     *MVCC 快照永遠無法檢視 XID >= xmax 的效果。xmin
     * 作為最佳化儲存,以避免搜尋大多數元組的 XID 陣列
     *。
     */
<i>
    TransactionId xmin;            
/* all XID < xmin are visible to me */<i>
    TransactionId xmax;            
/* all XID >= xmax are invisible to me */<i>

    
/*
     *對於普通 MVCC 快照,它包含正在進行中的所有 xact ID,除非快照是在恢復期間拍攝的,在這種情況下
     * 它是空的。對於歷史 MVCC 快照,其含義是相反的,即
     * 它包含 xmin 和 xmax 之間*已提交*的事務。
     *
     * note: all ids in xip[] satisfy xmin <= xip[i] < xmax
     */
<i>
    TransactionId *xip;
    uint32        xcnt;            
/* # of xact ids in xip[] */<i>
  [...]
}

欄位 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 更高的事務中的新資料就不會可見,讀取也是可重複的。

相關文章