SQL優化(六) MVCC PostgreSQL實現事務和多版本併發控制的精華

  原創文章,同步發自作者個人博客。轉載請務必將下面這段話置於文章開頭處。
  本文轉發自Jason’s Blog原文鏈接 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優化系列

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