深入解析 PostgreSQL 系列之併發控制與事務機制

王下邀月熊_Chevalier發表於2019-01-31

深入解析 PostgreSQL 系列整理自 The Internals of PostgreSQL 等系列文章,從碎片化地閱讀到體系化地學習,感覺對資料庫有了更深入地瞭解;觸類旁通,相互印證,也是有利於掌握 MySQL 等其他的關係型資料庫或者 NoSQL 資料庫。

深入解析 PostgreSQL 系列之併發控制與事務機制

併發控制旨在針對資料庫中對事務並行的場景,保證 ACID 中的一致性(Consistency)與隔離(Isolation)。資料庫技術中主流的三種併發控制技術分別是: Multi-version Concurrency Control (MVCC), Strict Two-Phase Locking (S2PL), 以及 Optimistic Concurrency Control (OCC),每種技術也都有很多的變種。在 MVCC 中,每次寫操作都會在舊的版本之上建立新的版本,並且會保留舊的版本。當某個事務需要讀取資料時,資料庫系統會從所有的版本中選取出符合該事務隔離級別要求的版本。MVCC 的最大優勢在於讀並不會阻塞寫,寫也不會阻塞讀;而像 S2PL 這樣的系統,寫事務會事先獲取到排他鎖,從而會阻塞讀事務。

PostgreSQL 以及 Oracle 等 RDBMS 實際使用了所謂的 Snapshot Isolation(SI)這個 MVCC 技術的變種。Oracle 引入了額外的 Rollback Segments,當寫入新的資料時,老版本的資料會被寫入到 Rollback Segment 中,隨後再被覆寫到實際的資料塊。PostgreSQL 則是使用了相對簡單的實現方式,新的資料物件會被直接插入到關聯的 Table Page 中;而在讀取表資料的時候,PostgreSQL 會通過可見性檢測規則(Visibility Check Rules)來選擇合適的版本。

SI 能夠避免 ANSI SQL-92 標準中定義的三個反常現象:髒讀(Dirty Reads),不可重複讀(Non-Repeatable Reads)以及幻讀(Phantom Reads);在 9.1 版本後引入的 Serializable Snapshot Isolation(SSI)則能夠提供真正的順序讀寫的能力。

Isolation Level Dirty Reads Non-repeatable Read Phantom Read Serialization Anomaly
READ COMMITTED Not possible Possible Possible Possible
REPEATABLE READ Not possible Not possible Not possible in PG; See Section 5.7.2. (Possible in ANSI SQL) Possible
SERIALIZABLE Not possible Not possible Not possible Not possible

Tuple 結構

Transaction ID

當某個事務開啟時,PostgreSQL 內建的 Transaction Manager 會為它分配唯一的 Transaction ID(txid);txid 是 32 位無型別整型值,可以通過 txid_current() 函式來獲取當前的 txid:

testdb=# BEGIN;
BEGIN
testdb=# SELECT txid_current();
 txid_current
--------------
          100
(1 row)

PostgreSQL 還保留了三個關鍵 txid 值作特殊標記:0 表示無效的 txid,1 表示啟動時的 txid,僅在 Database Cluster 啟動時使用;2 代表了被凍結的(Frozen)txid,用於在序列化事務時候使用。PostgreSQL 選擇數值型別作為 txid,也是為了方便進行比較;對於 txid 值為 100 的事務而言,所有小於 100 的事務是發生在過去的,可見的;而所有大於 100 的事務,是發生在未來,即不可見的。

image

鑑於實際系統中的 txid 數目的需要可能會超過最大值,PostgreSQL 實際是將這些 txid 作為環來看待。

HeapTupleHeaderData

Table Pages 中的 Heap Tuples 往往包含三個部分:HeapTupleHeaderData 結構,NULL bitmap 以及使用者資料。

image

其中 HeapTupleHeaderData 與事物處理強相關的屬性有:

  • (TransactionId)t_xmin: 存放插入該 Tuple 時的 txid
  • (TransactionId)t_xmax: 存放刪除或者更新該 Tuple 時的 txid,如果還沒更新或者刪除,那麼置 0,表示無效
  • (CommandId)t_cid: 存放 Command ID,即 建立該 Tuple 的命令在該事務內執行的所有 SQL 命令中的編號;譬如 BEGIN; INSERT; INSERT; INSERT; COMMIT; 這個事務,如果是首個 INSERT 命令建立的 Tuple,那麼其 t_cid 值為 0,第二個就是 1
  • (ItemPointerData)t_ctid: 當某個 Tuple 更新時,該值就指向新建立的 Tuple,否則指向自己

Tuple 的插入、刪除與更新

如上所述,Table Pages 中的 Tuples 呈如下佈局:

image

插入

在執行插入操作時,PostgreSQL 會直接將某個新的 Tuple 插入到目標表的某個頁中:

image

假如某個 txid 為 99 的事務插入了新的 Tuple,那麼該 Tuple 的頭域會被設定為如下值:

  • t_xmin 與建立該 Tuple 的事務的 txid 保持一致,即 99
  • t_xmax 被設定為 0,因為其還未被刪除或者更新
  • t_cid 被設定為 0,因為該 Tuple 是由事務中的首個 Insert 命令建立的
  • t_ctid 被設定為了 (0, 1),即指向了自己
testdb=# CREATE EXTENSION pageinspect;
CREATE EXTENSION
testdb=# CREATE TABLE tbl (data text);
CREATE TABLE
testdb=# INSERT INTO tbl VALUES(`A`);
INSERT 0 1
testdb=# SELECT lp as tuple, t_xmin, t_xmax, t_field3 as t_cid, t_ctid
                FROM heap_page_items(get_raw_page(`tbl`, 0));
 tuple | t_xmin | t_xmax | t_cid | t_ctid
-------+--------+--------+-------+--------
     1 |     99 |      0 |     0 | (0,1)

刪除

在刪除操作中,目標 Tuple 會被先邏輯刪除,即將 t_xmax 的值設定為當前刪除該 Tuple 的事務的 txid 值。

image

當該事務被提交之後,PostgreSQL 會將該 Tuple 標記為 Dead Tuple,並隨後在 VACUUM 處理過程中被徹底清除。

更新

在更新操作時,PostgreSQL 會首先邏輯刪除最新的 Tuple,然後插入新的 Tuple:

image

上圖所示的行被 txid 為 99 的事務插入,被 txid 為 100 的事務連續更新兩次;在該事務提交之後,Tuple_2 與 Tuple_3 就會被標記為 Dead Tuples。

Free Space Map

當插入某個 Heap Tuple 或者 Index Tuple 時,PostgreSQL 使用相關表的 FSM 來決定應該選擇哪個 Page 來進行具體的插入操作。每個 FSM 都存放著表或者索引檔案相關的剩餘空間容量的資訊,可以使用如下方式檢視:

testdb=# CREATE EXTENSION pg_freespacemap;
CREATE EXTENSION

testdb=# SELECT *, round(100 * avail/8192 ,2) as "freespace ratio"
                FROM pg_freespace(`accounts`);
 blkno | avail | freespace ratio
-------+-------+-----------------
     0 |  7904 |           96.00
     1 |  7520 |           91.00
     2 |  7136 |           87.00
     3 |  7136 |           87.00
     4 |  7136 |           87.00
     5 |  7136 |           87.00
....

Commit Log

PostgreSQL 使用 Commit Log,亦稱 clog 來存放事務的狀態;clog 存放於 Shared Memory 中,在整個事務處理的生命週期中都起到了重要的作用。PostgreSQL 定義了四種不同的事務狀態:IN_PROGRESS, COMMITTED, ABORTED, 以及 SUB_COMMITTED。

Clog 有 Shared Memory 中多個 8KB 大小的頁構成,其邏輯上表現為類陣列結構,陣列下標即是關聯的事務的 txid,而值就是當前事務的狀態:

image

如果當前的 txid 超過了當前 clog 頁可承載的最大範圍,那麼 PostgreSQL 會自動建立新頁。而在 PostgreSQL 停止或者 Checkpoint 程式執行的時候,clog 的資料會被持久化儲存到 pg_xact 子目錄下,以 0000,0001 依次順序命名,單個檔案的最大尺寸為 256KB。而當 PostgreSQL 重啟的時候,存放在 pg_xact 目錄下的檔案會被重新載入到記憶體中。而隨著 PostgreSQL 的持續執行,clog 中勢必會累計很多的過時或者無用的資料,Vacuum 處理過程中同樣會清除這些無用的資料。

Transaction Snapshot | 事務快照

事務快照即是存放了當前全部事務是否為啟用狀態資訊的資料結構,PostgreSQL 內部將快照表示為簡單的文字結構,xmin:xmax:xip_list’;譬如 “100“,其意味著所有 txid 小於或者等於 99 的事務是非啟用狀態,而大於等於 100 的事務是處在了啟用狀態。

testdb=# SELECT txid_current_snapshot();
 txid_current_snapshot
-----------------------
 100:104:100,102
(1 row)
  • xmin: 最早的仍處在啟用狀態的 txid,所有更早之前的事務要麼處於被提交之後的可見態,要麼就是被回滾之後的假死態。
  • xmax: 首個至今仍未分配的事務編號,所有 txid 大於或者等於該值的事務,相對於該快照歸屬的事務都是尚未發生的,因此是不可見的。
  • xip_list: 快照時候處於啟用狀態的 txids,僅會包含在 xmin 與 xmax 之間的 txids。

100:104:100,102 為例,其示意圖如下所示:

image

事務快照主要由事務管理器(Transaction Manager)提供,在 READ COMMITTED 這個隔離級別,無論是否有 SQL 命令執行,該事務都會被分配到某個快照;而對於 REPEATABLE READ 或者 SERIALIZABLE 隔離級別的事務而言,僅當首個 SQL 語句被執行的時候,才會被分配到某個事務快照用於進行可見性檢測。事務快照的意義在於,當某個快照進行可見性判斷時,無論目標事務是否已經被提交或者放棄,只要他在快照中被標記為 Active,那麼其就會被當做 IN_PROGRESS 狀態的事務來處理。

image

事務管理器始終儲存有關當前執行的事務的資訊。假設三個事務一個接一個地開始,並且 Transaction_A 和 Transaction_B 的隔離級別是 READ COMMITTED,Transaction_C 的隔離級別是 REPEATABLE READ。

  • T1:

    • Transaction_A 啟動並執行第一個 SELECT 命令。執行第一個命令時,Transaction_A 請求此刻的 txid 和快照。在這種情況下,事務管理器分配 txid 200,並返回事務快照`200:200:`。
  • T2:

    • Transaction_B 啟動並執行第一個 SELECT 命令。事務管理器分配 txid 201,並返回事務快照`200:200:`,因為 Transaction_A(txid 200)正在進行中。因此,無法從 Transaction_B 中看到 Transaction_A。
  • T3:

    • Transaction_C 啟動並執行第一個 SELECT 命令。事務管理器分配 txid 202,並返回事務快照`200:200:`,因此,Transaction_A 和 Transaction_B 不能從 Transaction_C 中看到。
  • T4:

    • Transaction_A 已提交。事務管理器刪除有關此事務的資訊。
  • T5:

    • Transaction_B 和 Transaction_C 執行各自的 SELECT 命令。
    • Transaction_B 需要事務快照,因為它處於 READ COMMITTED 級別。在這種情況下,Transaction_B 獲取新快照`201:201:`,因為 Transaction_A(txid 200)已提交。因此,Transaction_B 不再是 Transaction_B 中不可見的。
    • Transaction_C 不需要事務快照,因為它處於 REPEATABLE READ 級別並使用獲得的快照,即`200:200:`。因此,Transaction_A 仍然是 Transaction_C 不可見的。

Visibility Check | 可見性檢測

Rules | 可見性檢測規則

可見性檢測的規則用於根據 Tuple 的 t_xmin 與 t_xmax,clog 以及自身分配到的事務快照來決定某個 Tuple 相對於某個事務是否可見。

t_xmin 對應事務的狀態為 ABORTED

當某個 Tuple 的 t_xmin 值對應的事務的狀態為 ABORTED 時候,該 Tuple 永遠是不可見的:

/* t_xmin status = ABORTED */
// Rule 1: If Status(t_xmin) = ABORTED ⇒ Invisible
Rule 1: IF t_xmin status is `ABORTED` THEN
                  RETURN `Invisible`
            END IF

t_xmin 對應事務的狀態為 IN_PROGRESS

對於非插入該 Tuple 的事務之外的其他事務關聯的 Tuple 而言,該 Tuple 永遠是不可見的;僅對於與該 Tuple 同屬一事務的 Tuple 可見(此時該 Tuple 未被刪除或者更新的)。

 /* t_xmin status = IN_PROGRESS */
              IF t_xmin status is `IN_PROGRESS` THEN
                   IF t_xmin = current_txid THEN
// Rule 2: If Status(t_xmin) = IN_PROGRESS ∧ t_xmin = current_txid ∧ t_xmax = INVAILD ⇒ Visible
Rule 2:              IF t_xmax = INVALID THEN
                  RETURN `Visible`
// Rule 3: If Status(t_xmin) = IN_PROGRESS ∧ t_xmin = current_txid ∧ t_xmax ≠ INVAILD ⇒ Invisible

Rule 3:              ELSE  /* this tuple has been deleted or updated by the current transaction itself. */
                  RETURN `Invisible`
                         END IF
// Rule 4: If Status(t_xmin) = IN_PROGRESS ∧ t_xmin ≠ current_txid ⇒ Invisible
Rule 4:        ELSE   /* t_xmin ≠ current_txid */
                  RETURN `Invisible`
                   END IF
             END IF

t_xmin 對應事務的狀態為 COMMITTED

此時該 Tuple 在大部分情況下都是可見的,除了該 Tuple 被更新或者刪除。

 /* t_xmin status = COMMITTED */
            IF t_xmin status is `COMMITTED` THEN
//  If Status(t_xmin) = COMMITTED ∧ Snapshot(t_xmin) = active ⇒ Invisible
Rule 5:      IF t_xmin is active in the obtained transaction snapshot THEN
                      RETURN `Invisible`
// If Status(t_xmin) = COMMITTED ∧ (t_xmax = INVALID ∨ Status(t_xmax) = ABORTED) ⇒ Visible
Rule 6:      ELSE IF t_xmax = INVALID OR status of t_xmax is `ABORTED` THEN
                      RETURN `Visible`
                 ELSE IF t_xmax status is `IN_PROGRESS` THEN
// If Status(t_xmin) = COMMITTED ∧ Status(t_xmax) = IN_PROGRESS ∧ t_xmax = current_txid ⇒ Invisible
Rule 7:           IF t_xmax =  current_txid THEN
                            RETURN `Invisible`
// If Status(t_xmin) = COMMITTED ∧ Status(t_xmax) = IN_PROGRESS ∧ t_xmax ≠ current_txid ⇒ Visible
Rule 8:           ELSE  /* t_xmax ≠ current_txid */
                            RETURN `Visible`
                      END IF
                 ELSE IF t_xmax status is `COMMITTED` THEN
// If Status(t_xmin) = COMMITTED ∧ Status(t_xmax) = COMMITTED ∧ Snapshot(t_xmax) = active ⇒ Visible
Rule 9:           IF t_xmax is active in the obtained transaction snapshot THEN
                            RETURN `Visible`
// If Status(t_xmin) = COMMITTED ∧ Status(t_xmax) = COMMITTED ∧ Snapshot(t_xmax) ≠ active ⇒ Invisible
Rule 10:         ELSE
                            RETURN `Invisible`
                      END IF
                 END IF
            END IF

可見性檢測流程

以簡單的雙事務更新與查詢為例:

image

上圖中 txid 200 的事務的隔離級別是 READ COMMITED,txid 201 的隔離級別為 READ COMMITED 或者 REPEATABLE READ。

  • 當在 T3 時刻執行 SELECT 命令時:

根據 Rule 6,此時僅有 Tuple_1 是處於可見狀態:

# Rule6(Tuple_1) ⇒ Status(t_xmin:199) = COMMITTED ∧ t_xmax = INVALID ⇒ Visible

testdb=# -- txid 200
testdb=# SELECT * FROM tbl;
  name
--------
 Jekyll
(1 row)

testdb=# -- txid 201
testdb=# SELECT * FROM tbl;
  name
--------
 Jekyll
(1 row)
  • 當在 T5 時刻執行 SELECT 命令時:

對於 txid 200 的事務而言,根據 Rule 7 與 Rule 2 可知,Tuple_1 可見而 Tuple_2 不可見:

# Rule7(Tuple_1): Status(t_xmin:199) = COMMITTED ∧ Status(t_xmax:200) = IN_PROGRESS ∧ t_xmax:200 = current_txid:200 ⇒ Invisible
# Rule2(Tuple_2): Status(t_xmin:200) = IN_PROGRESS ∧ t_xmin:200 = current_txid:200 ∧ t_xmax = INVAILD ⇒ Visible

testdb=# -- txid 200
testdb=# SELECT * FROM tbl;
 name
------
 Hyde
(1 row)

而對於 txid 201 的事務而言,Tuple_1 是可見的,Tuple_2 是不可見的:

# Rule8(Tuple_1): Status(t_xmin:199) = COMMITTED ∧ Status(t_xmax:200) = IN_PROGRESS ∧ t_xmax:200 ≠ current_txid:201 ⇒ Visible
# Rule4(Tuple_2): Status(t_xmin:200) = IN_PROGRESS ∧ t_xmin:200 ≠ current_txid:201 ⇒ Invisible

testdb=# -- txid 201
testdb=# SELECT * FROM tbl;
  name
--------
 Jekyll
(1 row)
  • 當在 T7 時刻執行 SELECT 命令時:

如果此時 txid 201 的事務處於 READ COMMITED 的隔離級別,那麼 txid 200 會被當做 COMMITTED 來處理,因為此時獲取到的事務快照是 201:201:,因此 Tuple_1 是不可見的,而 Tuple_2 是可見的:

# Rule10(Tuple_1): Status(t_xmin:199) = COMMITTED ∧ Status(t_xmax:200) = COMMITTED ∧ Snapshot(t_xmax:200) ≠ active ⇒ Invisible
# Rule6(Tuple_2): Status(t_xmin:200) = COMMITTED ∧ t_xmax = INVALID ⇒ Visible

testdb=# -- txid 201 (READ COMMITTED)
testdb=# SELECT * FROM tbl;
 name
------
 Hyde
(1 row)

如果此時 txid 201 的事務處於 REPEATABLE READ 的隔離級別,此時獲取到的事務快照還是 200:200:,那麼 txid 200 的事務必須被當做 IN_PROGRESS 狀態來處理;因此此時 Tuple_1 是可見的,而 Tuple_2 是不可見的:

# Rule9(Tuple_1): Status(t_xmin:199) = COMMITTED ∧ Status(t_xmax:200) = COMMITTED ∧ Snapshot(t_xmax:200) = active ⇒ Visible
# Rule5(Tuple_2): Status(t_xmin:200) = COMMITTED ∧ Snapshot(t_xmin:200) = active ⇒ Invisible

testdb=# -- txid 201 (REPEATABLE READ)
testdb=# SELECT * FROM tbl;
  name
--------
 Jekyll
(1 row)

Preventing Lost Updates | 避免更新丟失

所謂的 更新丟失(Lost Update),也就是寫衝突(ww-conflict),其出現在兩個事務同時更新相同的行;在 PostgreSQL 中,REPEATABLE READ 與 SERIALIZABLE 這兩個級別都需要規避這種異常現象。

(1)  FOR each row that will be updated by this UPDATE command
(2)       WHILE true

               /* The First Block */
(3)            IF the target row is being updated THEN
(4)                  WAIT for the termination of the transaction that updated the target row

(5)                  IF (the status of the terminated transaction is COMMITTED)
                          AND (the isolation level of this transaction is REPEATABLE READ or SERIALIZABLE) THEN
(6)                           ABORT this transaction  /* First-Updater-Win */
                  ELSE
(7)                           GOTO step (2)
                  END IF

               /* The Second Block */
(8)            ELSE IF the target row has been updated by another concurrent transaction THEN
(9)                  IF (the isolation level of this transaction is READ COMMITTED THEN
(10)                           UPDATE the target row
                  ELSE
(11)                           ABORT this transaction  /* First-Updater-Win */
                  END IF

               /* The Third Block */
                ELSE  /* The target row is not yet modified or has been updated by a terminated transaction. */
(12)                  UPDATE the target row
                END IF
           END WHILE
      END FOR

在上述流程中,UPDATE 命令會遍歷每個待更新行,當發現該行正在被其他事務更新時進入等待狀態直到該行被解除鎖定。如果該行已經被更新,並且隔離級別為 REPEATABLE 或者 SERIALIZABLE,則放棄更新。

image

Being updated 意味著該行由另一個併發事務更新,並且其事務尚未終止。因為 PostgreSQL 的 SI 使用 first-updater-win 方案, 在這種情況下,當前事務必須等待更新目標行的事務的終止。假設事務 Tx_A 和 Tx_B 同時執行,並且 Tx_B 嘗試更新行;但是 Tx_A 已更新它並且仍在進行中,Tx_B 等待 Tx_A 的終止。在更新目標行提交的事務之後,繼續當前事務的更新操作。 如果當前事務處於 READ COMMITTED 級別,則將更新目標行; 否則 REPEATABLE READ 或 SERIALIZABLE,當前事務立即中止以防止丟失更新。

空間整理

PostgreSQL 的併發控制機制還依賴於以下的維護流程:

  • 移除那些被標記為 Dead 的 Tuples 與 Index Tuples
  • 移除 clog 中過時的部分
  • 凍結舊的 txids
  • 更新 FSM,VM 以及其他統計資訊

首先討論下 txid 環繞式處理的問題,假設 txid 100 的事務插入了某個 Tuple_1,則該 Tuple 對應的 t_xmin 值為 100;而後伺服器又執行了許久,Tuple_1 期間並未被改變。直到 txid 為 2^31 + 101 時,對於該事務而言,其執行 SELECT 命令時,是無法看到 Tuple_1 的 ,因為 txid 為 100 的事務相對於其是發生在未來的,由其建立的 Tuple 自然也就是不可見的。

為了解決這個問題,PostgreSQL 引入了所謂的 frozen txid(被凍結的 txid),並且設定了 FREEZE 程式來具體處理該問題。前文提及到 txid 2 是保留值,專門表徵那些被凍結的 Tuple,這些 Tuple 永遠是非啟用的、可見的。FREEZE 程式同樣由 Vacuum 程式統一呼叫,它會掃描所有的表檔案,將那些與當前 txid 差值超過 vacuum_freeze_min_age 定義的 Tuple 的 t_xmin 域設定為 2。在 9.4 版本之後,則是將 t_infomask 域中的 XMIN_FROZEN 位設定來表徵該 Tuple 為凍結狀態。

延伸閱讀

相關文章