MVCC PostgreSQL實現事務和多版本並發控制的精華


原創文章,同步發自作者個人博客http://www.jasongj.com/sql/mvcc/

PostgreSQL針對ACID的實現機制

事務的實現原理可以解讀為RDBMS采取何種技術確保事務的ACID特性。PostgreSQL針對ACID的實現技術如下表所示。

ACID 實現技術
原子性(Atomicity) MVCC
一致性(Consistency) 約束(主鍵、外鍵等)
隔離性 MVCC
持久性 WAL

從上表可以看到,PostgreSQL主要使用MVCC和WAL兩項技術實現ACID特性。實際上,MVCC和WAL這兩項技術都比較成熟,主流關系型數據庫中都有相應的實現,但每個數據庫中具體的實現方式往往存在較大的差異。本文將介紹PostgreSQL中的MVCC實現原理。

PostgreSQL中的MVCC原理

事務ID

在PostgreSQL中,每個事務都有一個唯一的事務ID,被稱為XID。注意:除了被BEGIN - COMMIT/ROLLBACK包裹的一組語句會被當作一個事務對待外,不顯示指定BEGIN - COMMIT/ROLLBACK的單條語句也是一個事務。

數據庫中的事務ID遞增。可通過txid_current()函數獲取當前事務的ID。

隱藏多版本標記字段

PostgreSQL中,對於每一行數據(稱為一個tuple),包含有4個隱藏字段。這四個字段是隱藏的,但可直接訪問。

  • xmin 在創建(insert)記錄(tuple)時,記錄此值為插入tuple的事務ID
  • xmax 默認值為0.在刪除tuple時,記錄此值
  • cmin和cmax 標識在同一個事務中多個語句命令的序列值,從0開始,用於同一個事務中實現版本可見性判斷

下面通過實驗具體看看這些標記如何工作。在此之前,先創建測試表

CREATE TABLE test 
(
  id INTEGER,
  value TEXT
);

開啟一個事務,查詢當前事務ID(值為3277),並插入一條數據,xmin為3277,與當前事務ID相等。符合上文所述——插入tuple時記錄xmin,記錄未被刪除時xmax為0

postgres=> BEGIN;
BEGIN
postgres=> SELECT TXID_CURRENT();
 txid_current 
--------------
         3277
(1 row)

postgres=> INSERT INTO test VALUES(1, 'a');
INSERT 0 1
postgres=> SELECT *, xmin, xmax, cmin, cmax FROM test;
 id | value | xmin | xmax | cmin | cmax 
----+-------+------+------+------+------
  1 | a     | 3277 |    0 |    0 |    0
(1 row)

繼續通過一條語句插入2條記錄,xmin仍然為當前事務ID,即3277,xmax仍然為0,同時cmin和cmax為1,符合上文所述cmin/cmax在事務內隨着所執行的語句遞增。雖然此步驟插入了兩條數據,但因為是在同一條語句中插入,故其cmin/cmax都為1,在上一條語句的基礎上加一。

INSERT INTO test VALUES(2, 'b'), (3, 'c');
INSERT 0 2
postgres=> SELECT *, xmin, xmax, cmin, cmax FROM test;
 id | value | xmin | xmax | cmin | cmax 
----+-------+------+------+------+------
  1 | a     | 3277 |    0 |    0 |    0
  2 | b     | 3277 |    0 |    1 |    1
  3 | c     | 3277 |    0 |    1 |    1
(3 rows)

將id為1的記錄的value字段更新為'd',其xmin和xmax均未變,而cmin和cmax變為2,在上一條語句的基礎之上增加一。此時提交事務。

UPDATE test SET value = 'd' WHERE id = 1;
UPDATE 1
postgres=> SELECT *, xmin, xmax, cmin, cmax FROM test;
 id | value | xmin | xmax | cmin | cmax 
----+-------+------+------+------+------
  2 | b     | 3277 |    0 |    1 |    1
  3 | c     | 3277 |    0 |    1 |    1
  1 | d     | 3277 |    0 |    2 |    2
(3 rows)

postgres=> COMMIT;
COMMIT

開啟一個新事務,通過2條語句分別插入2條id為4和5的tuple。

BEGIN;
BEGIN
postgres=> INSERT INTO test VALUES (4, 'x');
INSERT 0 1
postgres=> INSERT INTO test VALUES (5, 'y'); 
INSERT 0 1
postgres=> SELECT *, xmin, xmax, cmin, cmax FROM test;
 id | value | xmin | xmax | cmin | cmax 
----+-------+------+------+------+------
  2 | b     | 3277 |    0 |    1 |    1
  3 | c     | 3277 |    0 |    1 |    1
  1 | d     | 3277 |    0 |    2 |    2
  4 | x     | 3278 |    0 |    0 |    0
  5 | y     | 3278 |    0 |    1 |    1
(5 rows)

此時,將id為2的tuple的value更新為'e',其對應的cmin/cmax被設置為2,且其xmin被設置為當前事務ID,即3278

UPDATE test SET value = 'e' WHERE id = 2;
UPDATE 1
postgres=> SELECT *, xmin, xmax, cmin, cmax FROM test;
 id | value | xmin | xmax | cmin | cmax 
----+-------+------+------+------+------
  3 | c     | 3277 |    0 |    1 |    1
  1 | d     | 3277 |    0 |    2 |    2
  4 | x     | 3278 |    0 |    0 |    0
  5 | y     | 3278 |    0 |    1 |    1
  2 | e     | 3278 |    0 |    2 |    2

在另外一個窗口中開啟一個事務,可以發現id為2的tuple,xin仍然為3277,但其xmax被設置為3278,而cmin和cmax均為2。符合上文所述——若tuple被刪除,則xmax被設置為刪除tuple的事務的ID。

BEGIN;
BEGIN
postgres=> SELECT *, xmin, xmax, cmin, cmax FROM test;
 id | value | xmin | xmax | cmin | cmax 
----+-------+------+------+------+------
  2 | b     | 3277 | 3278 |    2 |    2
  3 | c     | 3277 |    0 |    1 |    1
  1 | d     | 3277 |    0 |    2 |    2
(3 rows)

這里有幾點要注意

  • 新舊窗口中id為2的tuple對應的value和xmin、xmax、cmin/cmax均不相同,實際上它們是該tuple的2個不同版本
  • 在舊窗口中,更新之前,數據的順序是2,3,1,4,5,更新后變為3,1,4,5,2。因為在PostgreSQL中更新實際上是將舊tuple標記為刪除,並插入更新后的新數據,所以更新后id為2的tuple從原來最前面變成了最后面
  • 在新窗口中,id為2的tuple仍然如舊窗口中更新之前一樣,排在最前面。這是因為舊窗口中的事務未提交,更新對新窗口不可見,新窗口看到的仍然是舊版本的數據

提交舊窗口中的事務后,新舊窗口中看到數據完全一致——id為2的tuple排在了最后,xmin變為3278,xmax為0,cmin/cmax為2。前文定義中,xmin是tuple創建時的事務ID,並沒有提及更新的事務ID,但因為PostgreSQL的更新操作並非真正更新數據,而是將舊數據標記為刪除,並插入新數據,所以“更新的事務ID”也就是“創建記錄的事務ID”。

 SELECT *, xmin, xmax, cmin, cmax FROM test;
 id | value | xmin | xmax | cmin | cmax 
----+-------+------+------+------+------
  3 | c     | 3277 |    0 |    1 |    1
  1 | d     | 3277 |    0 |    2 |    2
  4 | x     | 3278 |    0 |    0 |    0
  5 | y     | 3278 |    0 |    1 |    1
  2 | e     | 3278 |    0 |    2 |    2
(5 rows)

MVCC保證原子性

原子性(Atomicity)指得是一個事務是一個不可分割的工作單位,事務中包括的所有操作要么都做,要么都不做。

對於插入操作,PostgreSQL會將當前事務ID存於xmin中。對於刪除操作,其事務ID會存於xmax中。對於更新操作,PostgreSQL會將當前事務ID存於舊數據的xmax中,並存於新數據的xin中。換句話說,事務對增、刪和改所操作的數據上都留有其事務ID,可以很方便的提交該批操作或者完全撤銷操作,從而實現了事務的原子性。

MVCC保證事物的隔離性

隔離性(Isolation)指一個事務的執行不能被其他事務干擾。即一個事務內部的操作及使用的數據對並發的其他事務是隔離的,並發執行的各個事務之間不能互相干擾。

標准SQL的事務隔離級別分為如下四個級別

隔離級別 臟讀 不可重復讀 幻讀
未提交讀(read uncommitted) 可能 可能 可能
提交讀(read committed) 不可能 可能 可能
可重復讀(repeatable read) 不可能 不可能 可能
串行讀(serializable) 不可能 不可能 不可能

從上表中可以看出,從未提交讀到串行讀,要求越來越嚴格。

注意,SQL標准規定,具體數據庫實現時,對於標准規定不允許發生的,絕不可發生;對於可能發生的,並要不求一定能發生。換句話說,具體數據庫實現時,對應的隔離級別只可更嚴格,不可更寬松。

事實中,PostgreSQL可實現了三種隔離級別——未提交讀和提交讀實際上都被實現為提交讀。

下面將討論提交讀和可重復讀的實現方式

MVCC提交讀

提交讀只可讀取其它已提交事務的結果。PostgreSQL中通過pg_clog來記錄哪些事務已經被提交,哪些未被提交。具體實現方式將在下一篇文章《SQL優化(七) WAL PostgreSQL實現事務和高並發的重要技術》中講述。

MVCC可重復讀

相對於提交讀,重復讀要求在同一事務中,前后兩次帶條件查詢所得到的結果集相同。實際中,PostgreSQL的實現更嚴格,不緊要求可重復讀,還不允許出現幻讀。它是通過只讀取在當前事務開啟之前已經提交的數據實現的。結合上文的四個隱藏系統字段來講,PostgreSQL的可重復讀是通過只讀取xmin小於當前事務ID且已提交的事務的結果來實現的。

PostgreSQL中的MVCC優勢

  • 使用MVCC,讀操作不會阻塞寫,寫操作也不會阻塞讀,提高了並發訪問下的性能
  • 事務的回滾可立即完成,無論事務進行了多少操作
  • 數據可以進行大量更新,不段像MySQL和Innodb引擎和Oracle那樣需要保證回滾段不會被耗盡

PostgreSQL中的MVCC缺點

事務ID個數有限制

事務ID由32位數保存,而事務ID遞增,當事務ID用完時,會出現wraparound問題。

PostgreSQL通過VACUUM機制來解決該問題。對於事務ID,PostgreSQL有三個事務ID有特殊意義:

  • 0代表invalid事務號
  • 1代表bootstrap事務號
  • 2代表frozon事務。frozon transaction id比任何事務都要老

可用的有效最小事務ID為3。VACUUM時將所有已提交的事務ID均設置為2,即frozon。之后所有的事務都比frozon事務新,因此VACUUM之前的所有已提交的數據都對之后的事務可見。PostgreSQL通過這種方式實現了事務ID的循環利用。

大量過期數據占用磁盤並降低查詢性能

由於上文提到的,PostgreSQL更新數據並非真正更改記錄值,而是通過將舊數據標記為刪除,再插入新的數據來實現。對於更新或刪除頻繁的表,會累積大量過期數據,占用大量磁盤,並且由於需要掃描更多數據,使得查詢性能降低。

PostgreSQL解決該問題的方式也是VACUUM機制。從釋放磁盤的角度,VACUUM分為兩種

  • VACUUM 該操作並不要求獲得排它鎖,因此它可以和其它的讀寫表操作並行進行。同時它只是簡單的將dead tuple對應的磁盤空間標記為可用狀態,新的數據可以重用這部分磁盤空間。但是這部分磁盤並不會被真正釋放,也即不會被交還給操作系統,因此不能被系統中其它程序所使用,並且可能會產生磁盤碎片。
  • VACUUM FULL 需要獲得排它鎖,它通過“標記-復制”的方式將所有有效數據(非dead tuple)復制到新的磁盤文件中,並將原數據文件全部刪除,並將未使用的磁盤空間還給操作系統,因此系統中其它進程可使用該空間,並且不會因此產生磁盤碎片。

SQL優化系列


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM