PostgreSQL的MVCC(4)--Snapshots


在討論了隔離問題並離題討論了底層數據結構之后,上次我們研究了行版本,並觀察了不同的操作如何改變元組頭字段。

現在我們來看看如何從元組中獲得一致性數據快照。

什么是數據快照

數據頁實際上可以包含同一行的多個版本。但是每個事務只能看到每一行的一個(或沒有)版本,以便它們在特定時間點上構成數據的一致視圖(按照ACID的意義)。

PosgreSQL中的隔離基於快照:每個事務都使用其自己的數據快照,該快照包含在創建快照之前提交的數據,並且不包含在該時刻尚未提交的數據。我們已經看到,盡管最終的隔離看起來比標准要求的嚴格,但仍然存在異常。

在“讀提交”隔離級別,將在事務的每個語句的開頭創建一個快照。在執行語句時,此快照處於活動狀態。在該圖中,快照創建的時刻(我們記得它由事務ID決定)以藍色顯示。

在可重復讀和可串行化級別上,快照只在事務第一個語句的開頭創建一次。這樣的快照在事務結束之前一直保持活動狀態。

 

快照中元組的可見性

可見性原則

快照當然不是所有必要元組的物理副本。快照實際上由多個數字指定,並且快照中元組的可見性由規則確定。

元組在快照中是否可見取決於header中的兩個字段,即xmin和xmax,即創建和刪除該元組的事務的ID。這樣的間隔不會重疊,因此,每個快照中表示一行的版本不超過一個。

確切的可見性規則非常復雜,並考慮了許多不同的情況和極端情況。

你可以查看src/backend/utils/time/tqual.c(在版本12中,將檢查移至src/backend/access/heap/heapam_visibility.c)。

為簡化起見,在快照中,我們可以說,由xmin事務進行的更改是可見的,而由xmax事務進行的更改則不可見(換句話說,已經創建了元組,但是尚不清楚是否已刪除它)。

關於事務,無論是創建快照的事務(它確實看到自己尚未提交的更改)還是創建快照之前提交的事務,更改在快照中都是可見的。

我們可以按段(從開始時間到提交時間)以圖形方式表示事務:

 

這里:

·事務2的更改是可見的,因為它是在創建快照之前完成的。 ·事務1的更改將不可見,因為它在創建快照時處於活動狀態。 ·事務3的更改將不可見,因為它是在創建快照后開始的(無論它是否完成)。

不幸的是,系統並不知曉事務的提交時間。僅知道其開始時間(由事務ID確定並在上圖中用虛線標記),但是事務完成的事件未寫入任何地方。

我們所能做的就是在創建快照時找出事務的當前狀態。該信息在服務器的共享內存中的ProcArray結構中可用,該結構包含所有活動會話及其事務的列表。

但是我們將無法在事后確定快照創建時某個事務是否處於活動狀態。因此,快照必須存儲所有當前活動事務的列表。

從上面可以看出,在PostgreSQL中,即使表頁中所有必需的元組都可用,也無法創建快照來顯示在特定時間后向一致的數據。經常會產生一個問題,為什么PostgreSQL缺乏追溯性(或時間性;或閃回,如Oracle),這是原因之一。

因此,快照由幾個參數確定:

·創建快照后,更確切地說,是下一個事務的ID,但在系統中不可用(snapshot.xmax)。 ·創建快照時的活動(進行中)事務列表(snapshot.xip)。

為了方便和優化,還存儲了最早活動事務的ID(snapshot.xmin)。 該值具有重要意義,下面將進行討論。

快照還存儲了一些其他參數,但是這些參數對我們來說並不重要。

示例

為了了解快照如何確定可見性,讓我們通過三個事務重現上面的示例。 該表將具有三行,其中:

·第一個是由在快照創建之前開始但在快照創建之后完成的事務添加的。
·第二個是通過在快照創建之前開始並完成的事務添加的。
·第三個是在創建快照后添加的。

=> TRUNCATE TABLE accounts;

第一個事務(尚未完成):

=> BEGIN;
=> INSERT INTO accounts VALUES (1, '1001', 'alice', 1000.00);
=> SELECT txid_current();
=> SELECT txid_current();
 txid_current 
--------------
         3695
(1 row)

第二個事務(在創建快照之前完成):

|  => BEGIN;
|  => INSERT INTO accounts VALUES (2, '2001', 'bob', 100.00);
|  => SELECT txid_current();
|   txid_current 
|  --------------
|           3696
|  (1 row)
|  => COMMIT;

在另一個會話的事務中創建快照:

||    => BEGIN ISOLATION LEVEL REPEATABLE READ;
||    => SELECT xmin, xmax, * FROM accounts;
||     xmin | xmax | id | number | client | amount 
||    ------+------+----+--------+--------+--------
||     3696 |    0 |  2 | 2001   | bob    | 100.00
||    (1 row)

創建快照后提交第一個事務:

=> COMMIT;

第三個事務(在創建快照后出現):

|  => BEGIN;
|  => INSERT INTO accounts VALUES (3, '2002', 'bob', 900.00);
|  => SELECT txid_current();
|   txid_current 
|  --------------
|           3697
|  (1 row)
|  => COMMIT;

顯然,在我們的快照中仍然僅可見一行:

||    => SELECT xmin, xmax, * FROM accounts;
||     xmin | xmax | id | number | client | amount 
||    ------+------+----+--------+--------+--------
||     3696 |    0 |  2 | 2001   | bob    | 100.00
||    (1 row)

問題是Postgres如何理解這一點的。

全部都是由快照確定。 讓我們看一下:

||    => SELECT txid_current_snapshot();
||     txid_current_snapshot 
||    -----------------------
||     3695:3697:3695
||    (1 row)

這里列出了snapshot.xmin,snapshot.xmax和snapshot.xip,以冒號分隔(在這種情況下,snapshot.xip是一個數字,但通常是一個列表)。

根據上述規則,在快照中,那些ID為xid的事務所做的更改必須是可見的,使得快照.xmin <= xid <快照.xmax,而那些在快照.xip列表中的更改除外。 讓我們看一下所有表行(在新快照中):

=> SELECT xmin, xmax, * FROM accounts ORDER BY id;
 xmin | xmax | id | number | client | amount  
------+------+----+--------+--------+---------
 3695 |    0 |  1 | 1001   | alice  | 1000.00
 3696 |    0 |  2 | 2001   | bob    |  100.00
 3697 |    0 |  3 | 2002   | bob    |  900.00
(3 rows)

第一行不可見:它是由活動事務(xip)列表上的事務創建的。
第二行可見:它是由快照范圍內的事務創建的。
第三行不可見:它是由快照范圍之外的事務創建的。

||    => COMMIT;

事務自身的更新

確定事務本身更改的可見性會使情況復雜化。在這種情況下,可能僅需要查看部分此類更改。例如:在任何隔離級別,在某個時間點打開的游標一定不能看到以后所做的更改。

為此,元組header具有一個特殊字段(在cmin和cmax偽列中表示),該字段顯示事務內的順序號。cmin是要插入的數字,cmax是要刪除的數字,但是為了節省元組header中的空間,這實際上是一個字段,而不是兩個不同的字段。它假定事務很少插入和刪除同一行。

但是,如果確實發生這種情況,則會在同一字段中插入一個特殊的組合命令ID(combocid),后端進程會記住該組合的實際cmin和cmax。 但這是完全異國情調的。

這是一個簡單的例子。讓我們開始一個事務並在表中添加一行:

=> BEGIN;
=> SELECT txid_current();
 txid_current 
--------------
         3698
(1 row)
INSERT INTO accounts(id, number, client, amount) VALUES (4, 3001, 'charlie', 100.00);

讓我們輸出表的內容以及cmin字段(但僅適用於事務添加的行,對於其他行則沒有意義):

=> SELECT xmin, CASE WHEN xmin = 3698 THEN cmin END cmin, * FROM accounts;
 xmin | cmin | id | number | client  | amount  
------+------+----+--------+---------+---------
 3695 |      |  1 | 1001   | alice   | 1000.00
 3696 |      |  2 | 2001   | bob     |  100.00
 3697 |      |  3 | 2002   | bob     |  900.00
 3698 |    0 |  4 | 3001   | charlie |  100.00
(4 rows)

現在,我們為查詢打開一個游標,該查詢返回表中的行數。

=> DECLARE c CURSOR FOR SELECT count(*) FROM accounts;

然后,我們添加另一行:

=> INSERT INTO accounts(id, number, client, amount) VALUES (5, 3002, 'charlie', 200.00);

查詢返回4-打開游標后添加的行不會進入數據快照:

=> FETCH c;
 count 
-------
     4
(1 row)

為什么? 因為快照僅考慮cmin <1的元組。

=> SELECT xmin, CASE WHEN xmin = 3698 THEN cmin END cmin, * FROM accounts;
 xmin | cmin | id | number | client  | amount  
------+------+----+--------+---------+---------
 3695 |      |  1 | 1001   | alice   | 1000.00
 3696 |      |  2 | 2001   | bob     |  100.00
 3697 |      |  3 | 2002   | bob     |  900.00
 3698 |    0 |  4 | 3001   | charlie |  100.00
 3698 |    1 |  5 | 3002   | charlie |  200.00
(5 rows)
=> ROLLBACK;

事件范圍(Event horizon)

最早的活動事務的ID(snapshot.xmin)具有重要意義:它確定事務的“事件范圍”。 也就是說,超出其范圍,該事務始終只能看到最新的行版本。

實際上,僅當尚未完成的事務創建了最新的(dead)行版本時,才需要看到該行版本,因此尚不可見。 但是,所有“事件范圍”的事務肯定都已完成。

你可以在系統目錄中看到事務范圍:

=> BEGIN;
=> SELECT backend_xmin FROM pg_stat_activity WHERE pid = pg_backend_pid();
 backend_xmin 
--------------
         3699
(1 row)

我們還可以在數據庫級別定義范圍。為此,我們需要獲取所有活動快照並在其中找到最舊的xmin。它將定義范圍,超過該范圍,數據庫中的死元組將永遠對任何事務都不可見。這樣的元組可以被清除掉-這就是為什么從實際的角度來看,范圍的概念如此重要的原因。

如果某個事務長時間保存快照,那么它也將保存數據庫范圍。此外,即使事務本身不保存快照,已存在未完成的事務也將保留范圍。

這意味着無法清除數據庫中的死元組。另外,“長期”事務可能不會與其他事務完全交叉,但是這並不重要,因為所有事務共享一個數據庫范圍。

如果現在我們使一個段代表快照(從snapshot.xmin到snapshot.xmax)而不是事務,則可以將情況可視化如下:

在此圖中,最低的快照與未完成的事務有關,在其他快照中,snapshot.xmin不能大於事務ID。

在我們的示例中,事務是從“讀提交”隔離級別開始的。 即使它沒有任何活動的數據快照,它也會繼續保持這事件范圍:

|  => BEGIN;
|  => UPDATE accounts SET amount = amount + 1.00;
|  => COMMIT;

=> SELECT backend_xmin FROM pg_stat_activity WHERE pid = pg_backend_pid();
 backend_xmin 
--------------
         3699
(1 row)

並且僅在事務完成之后,范圍才向前移動,從而可以清除死元組:

=> COMMIT;
=> SELECT backend_xmin FROM pg_stat_activity WHERE pid = pg_backend_pid();
 backend_xmin 
--------------
         3700
(1 row)

如果所描述的情況確實導致問題,無法在應用程序級別解決,從9.6版開始可以使用兩個參數:

·old_snapshot_threshold確定快照的最大生存期。 這段時間過后,服務器將有資格清理死元組,並且如果“長”事務仍然需要它們,則會出現“快照太舊”錯誤。
·idle_in_transaction_session_timeout確定空閑事務的最大生存期。 經過這段時間后,事務中止。

快照導出(Snapshot export)

有時會出現必須保證多個並發事務能看到相同數據的情況。一個示例是pg_dump實用程序,它可以在並行模式下工作:所有工作進程都必須以相同狀態查看數據庫,以使備份副本保持一致。

當然,我們不能依靠這樣的信念,即僅僅因為事務是“同時”開始的,事務就會看到相同的數據。 為此,可以導出和導入快照。

pg_export_snapshot函數返回快照ID,可以將其傳遞給另一個事務(使用DBMS外部的工具)。

=> BEGIN ISOLATION LEVEL REPEATABLE READ;
=> SELECT count(*) FROM accounts; -- any query
 count 
-------
     3
(1 row)
=> SELECT pg_export_snapshot();
 pg_export_snapshot  
---------------------
 00000004-00000E7B-1
(1 row)

另一個事務可以在執行自己的第一個查詢之前使用SET TRANSACTION SNAPSHOT命令導入快照。還應該在此之前指定可重復讀或可序列化隔離級別,因為在“讀已提交”級別,每個語句將使用其自己的快照。

|  => DELETE FROM accounts;
|  => BEGIN ISOLATION LEVEL REPEATABLE READ;
|  => SET TRANSACTION SNAPSHOT '00000004-00000E7B-1';

現在,第二個事務將與第一個事務的快照配合使用,因此,看到三行(而不是零行):

|  => SELECT count(*) FROM accounts;
|   count 
|  -------
|       3
|  (1 row)

導出快照的生命周期與導出事務的生命周期相同。

|    => COMMIT;
=> COMMIT;

  

 

原文地址:

https://habr.com/en/company/postgrespro/blog/479512/


免責聲明!

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



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