深刻解析 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 這樣的系統,寫事務會事先獲取到排他鎖,從而會阻塞讀事務。html

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

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

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:sql

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 爲凍結狀態。

延伸閱讀

相關文章
相關標籤/搜索