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

深入解析 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 爲凍結狀態。

延伸閱讀

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章