MVCC-3。 字串版本

所以,我們考慮了相關問題 絕緣,並撤退了約 在低層次上組織數據。 最後我們到達了最有趣的部分 - 字串版本。

標題

正如我們已經說過的,每一行可以同時存在於資料庫中的多個版本中。 一個版本必須以某種方式與另一個版本區分開來。為此,每個版本都有兩個標記來確定該版本的作用「時間」(xmin 和 xmax)。 用引號引起來 - 因為使用的不是時間本身,而是一個特殊的遞增計數器。 而這個計數器就是交易號。

(像往常一樣,現實更加複雜:由於計數器的位元容量有限,交易數量不可能一直增加。但是當我們凍結時,我們會詳細查看這些細節。)

建立行時,xmin 設定為發出 INSERT 指令的交易號,xmax 留空。

當刪除一行時,目前版本的xmax值被標記為執行DELETE的交易的編號。

當透過 UPDATE 指令修改一行時,實際上執行了兩個動作:DELETE 和 INSERT。 行集 xmax 的目前版本等於執行 UPDATE 的交易的數量。 然後創建同一字串的新版本; 其xmin值與先前版本的xmax值一致。

xmin 和 xmax 欄位包含在行版本標頭中。 除了這些字段之外,標頭還包含其他字段,例如:

  • infomask 是定義該版本屬性的一系列位元。 它們的數量相當多; 我們將逐漸考慮主要的。
  • ctid 是指向同一行的下一個較新版本的連結。 對於某行的最新、最新版本,ctid 指的是該版本本身。 此數字的形式為 (x,y),其中 x 是頁碼,y 是數組中的索引號。
  • 空位圖 - 標記給定版本中包含空值 (NULL) 的那些欄位。 NULL 不是普通資料類型值之一,因此該屬性必須單獨儲存。

因此,標頭相當大 - 每個版本的行至少有 23 個位元組,而且由於 NULL 位圖,通常更多。 如果表很「窄」(即包含很少的欄位),則開銷可能會佔用比有用資訊更多的資訊。

插入

讓我們仔細看看低階字串操作是如何執行的,從插入開始。

為了進行實驗,我們建立一個包含兩個欄位並在其中一個欄位上建立索引的新資料表:

=> CREATE TABLE t(
  id serial,
  s text
);
=> CREATE INDEX ON t(s);

讓我們在開始事務後插入一行。

=> BEGIN;
=> INSERT INTO t(s) VALUES ('FOO');

這是我們目前的交易編號:

=> SELECT txid_current();
 txid_current 
--------------
         3664
(1 row)

讓我們來看看頁面的內容。 pageinspect 擴充功能的 heap_page_items 函數可讓您取得指標和行版本的資訊:

=> SELECT * FROM heap_page_items(get_raw_page('t',0)) gx
-[ RECORD 1 ]-------------------
lp          | 1
lp_off      | 8160
lp_flags    | 1
lp_len      | 32
t_xmin      | 3664
t_xmax      | 0
t_field3    | 0
t_ctid      | (0,1)
t_infomask2 | 2
t_infomask  | 2050
t_hoff      | 24
t_bits      | 
t_oid       | 
t_data      | x0100000009464f4f

請注意,PostgreSQL 中的“堆”一詞指的是表。 這是該術語的另一個奇怪的用法 - 堆是已知的 資料結構,它與表沒有任何共同點。 這裡這個詞的意思是“所有東西都放在一起”,而不是有序索引。

該函數以難以理解的格式「按原樣」顯示資料。 為了弄清楚,我們只留下部分訊息並破解它:

=> SELECT '(0,'||lp||')' AS ctid,
       CASE lp_flags
         WHEN 0 THEN 'unused'
         WHEN 1 THEN 'normal'
         WHEN 2 THEN 'redirect to '||lp_off
         WHEN 3 THEN 'dead'
       END AS state,
       t_xmin as xmin,
       t_xmax as xmax,
       (t_infomask & 256) > 0  AS xmin_commited,
       (t_infomask & 512) > 0  AS xmin_aborted,
       (t_infomask & 1024) > 0 AS xmax_commited,
       (t_infomask & 2048) > 0 AS xmax_aborted,
       t_ctid
FROM heap_page_items(get_raw_page('t',0)) gx
-[ RECORD 1 ]-+-------
ctid          | (0,1)
state         | normal
xmin          | 3664
xmax          | 0
xmin_commited | f
xmin_aborted  | f
xmax_commited | f
xmax_aborted  | t
t_ctid        | (0,1)

這是我們所做的:

  • 在索引號中新增一個零,使其看起來與 t_ctid 相同:(頁碼,索引號)。
  • 破解了 lp_flags 指針的狀態。 這裡是「正常」——這意味著指標實際上指的是字串的版本。 稍後我們會看看其他含義。
  • 在所有資訊位中,迄今僅識別出兩對。 xmin_commited 和 xmin_aborted 位元指示交易編號 xmin 是否已提交(中止)。 兩個相似的位指的是交易號xmax。

我們看到了什麼? 當您插入一行時,表頁中將出現一個索引號 1,指向該行的第一個也是唯一的版本。

在字串版本中,xmin 欄位填入目前交易編號。 交易仍然處於活動狀態,因此 xmin_comfilled 和 xmin_aborted 位元均未設定。

行版本 ctid 欄位引用同一行。 這意味著更新的版本不存在。

xmax 欄位填入了虛擬數字 0,因為該版本的行尚未被刪除且是最新的。 事務不會關注這個數字,因為 xmax_aborted 位元被設定。

讓我們透過在交易編號新增資訊位元來進一步提高可讀性。 讓我們建立一個函數,因為我們將多次需要該請求:

=> CREATE FUNCTION heap_page(relname text, pageno integer)
RETURNS TABLE(ctid tid, state text, xmin text, xmax text, t_ctid tid)
AS $$
SELECT (pageno,lp)::text::tid AS ctid,
       CASE lp_flags
         WHEN 0 THEN 'unused'
         WHEN 1 THEN 'normal'
         WHEN 2 THEN 'redirect to '||lp_off
         WHEN 3 THEN 'dead'
       END AS state,
       t_xmin || CASE
         WHEN (t_infomask & 256) > 0 THEN ' (c)'
         WHEN (t_infomask & 512) > 0 THEN ' (a)'
         ELSE ''
       END AS xmin,
       t_xmax || CASE
         WHEN (t_infomask & 1024) > 0 THEN ' (c)'
         WHEN (t_infomask & 2048) > 0 THEN ' (a)'
         ELSE ''
       END AS xmax,
       t_ctid
FROM heap_page_items(get_raw_page(relname,pageno))
ORDER BY lp;
$$ LANGUAGE SQL;

在這種形式中,行版本的標題中發生的情況更加清晰:

=> SELECT * FROM heap_page('t',0);
 ctid  | state  | xmin | xmax  | t_ctid 
-------+--------+------+-------+--------
 (0,1) | normal | 3664 | 0 (a) | (0,1)
(1 row)

類似,但明顯不太詳細,可以使用偽列 xmin 和 xmax 從表本身獲取資訊:

=> SELECT xmin, xmax, * FROM t;
 xmin | xmax | id |  s  
------+------+----+-----
 3664 |    0 |  1 | FOO
(1 row)

固定

如果事務成功完成,您需要記住它的狀態 - 請注意它已提交。 為此,使用了一個名為 XACT 的結構(在版本 10 之前,它被稱為 CLOG(提交日誌),這個名稱仍然可以在不同的地方找到)。

XACT 不是系統目錄表; 這些是 PGDATA/pg_xact 目錄中的檔案。 每個事務都有兩個位元:已提交和已中止 - 就像行版本標頭中一樣。 這些資訊被分成幾個文件只是為了方便;當我們考慮凍結時,我們將回到這個問題。 與所有其他文件一樣,這些文件的處理是逐頁進行的。

因此,在 XACT 中提交交易時,會設定該交易的已提交位。 這就是提交期間發生的所有事情(儘管我們還沒有討論預記錄日誌)。

當另一個事務訪問我們剛剛查看的表頁時,它將必須回答幾個問題。

  1. xmin交易完成了嗎? 如果不是,那麼創建的字串版本不應該是可見的。
    此檢查是透過查看另一個結構來執行的,該結構位於實例的共享記憶體中,稱為 ProcArray。 它包含所有活動進程的列表,並為每個進程指示其當前(活動)事務的數量。
  2. 如果完成了,那麼如何——提交或取消? 如果取消,則行版本也不應該可見。
    這正是 XACT 的用途。 但是,儘管 XACT 的最後一頁儲存在 RAM 的緩衝區中,但每次檢查 XACT 的成本仍然很高。 因此,一旦確定了事務狀態,就會將其寫入字串版本的 xmin_comfilled 和 xmin_aborted 位元。 如果設定了其中一個位,則事務 xmin 的狀態被視為已知,並且下一個事務將不必存取 XACT。

為什麼事務本身不設定這些位元來執行插入? 當插入發生時,事務仍不知是否會成功。 並且在提交時,不再清楚哪些頁面中的哪些行被更改了。 這樣的頁面可能有很多,記住它們是沒有好處的。 此外,某些頁面可以從緩衝區快取移出到磁碟; 再次讀取它們以更改位元會顯著減慢提交速度。

節省的缺點是,更改後,任何事務(即使是執行簡單的讀取 - SELECT)都可以開始更改緩衝區高速緩存中的資料頁。

那麼,讓我們修復更改。

=> COMMIT;

頁面上沒有任何變化(但我們知道交易狀態已經記錄在XACT中):

=> SELECT * FROM heap_page('t',0);
 ctid  | state  | xmin | xmax  | t_ctid 
-------+--------+------+-------+--------
 (0,1) | normal | 3664 | 0 (a) | (0,1)
(1 row)

現在,首先訪問該頁面的事務必須確定 xmin 事務狀態並將其寫入資訊位元:

=> SELECT * FROM t;
 id |  s  
----+-----
  1 | FOO
(1 row)

=> SELECT * FROM heap_page('t',0);
 ctid  | state  |   xmin   | xmax  | t_ctid 
-------+--------+----------+-------+--------
 (0,1) | normal | 3664 (c) | 0 (a) | (0,1)
(1 row)

切除

當刪除一行時,目前刪除交易的編號會寫入目前版本的xmax字段,並且xmax_aborted位元被清除。

注意,活動事務對應的xmax設定值起到了行鎖的作用。 如果另一個事務想要更新或刪除該行,它將被迫等待事務xmax完成。 稍後我們將詳細討論阻塞。 現在,我們只注意到行鎖的數量是無限的。 它們不佔用 RAM 空間,系統效能不會因其數量而受到影響。 誠然,「長」交易還有其他缺點,稍後會詳細介紹。

讓我們刪除該行。

=> BEGIN;
=> DELETE FROM t;
=> SELECT txid_current();
 txid_current 
--------------
         3665
(1 row)

我們看到xmax欄位中寫入了交易編號,但未設定訊息位元:

=> SELECT * FROM heap_page('t',0);
 ctid  | state  |   xmin   | xmax | t_ctid 
-------+--------+----------+------+--------
 (0,1) | normal | 3664 (c) | 3665 | (0,1)
(1 row)

消除

中止更改的工作方式與提交類似,只是在 XACT 中為事務設定了中止位。 撤銷與提交一樣快。 儘管該命令稱為 ROLLBACK,但更改不會回滾:交易設法在資料頁中更改的所有內容都保持不變。

=> ROLLBACK;
=> SELECT * FROM heap_page('t',0);
 ctid  | state  |   xmin   | xmax | t_ctid 
-------+--------+----------+------+--------
 (0,1) | normal | 3664 (c) | 3665 | (0,1)
(1 row)

當存取頁面時,將檢查狀態並將 xmax_aborted 提示位元設定為行版本。 xmax 數字本身保留在頁面上,但沒有人會看它。

=> SELECT * FROM t;
 id |  s  
----+-----
  1 | FOO
(1 row)

=> SELECT * FROM heap_page('t',0);
 ctid  | state  |   xmin   |   xmax   | t_ctid 
-------+--------+----------+----------+--------
 (0,1) | normal | 3664 (c) | 3665 (a) | (0,1)
(1 row)

更新

更新的工作方式就好像它首先刪除了行的當前版本,然後插入了新版本。

=> BEGIN;
=> UPDATE t SET s = 'BAR';
=> SELECT txid_current();
 txid_current 
--------------
         3666
(1 row)

該查詢產生一行(新版本):

=> SELECT * FROM t;
 id |  s  
----+-----
  1 | BAR
(1 row)

但在頁面上我們看到兩個版本:

=> SELECT * FROM heap_page('t',0);
 ctid  | state  |   xmin   | xmax  | t_ctid 
-------+--------+----------+-------+--------
 (0,1) | normal | 3664 (c) | 3666  | (0,2)
 (0,2) | normal | 3666     | 0 (a) | (0,2)
(2 rows)

刪除的版本在 xmax 欄位中標示目前交易編號。 此外,由於前一筆交易已取消,因此該值會覆蓋舊值。 並且 xmax_aborted 位元被清除,因為目前交易的狀態尚不清楚。

該行的第一個版本現在將第二個(t_ctid 欄位)稱為較新的版本。

第二個索引出現在索引頁中,第二行引用表頁中的第二個版本。

與刪除一樣,該行的第一個版本中的 xmax 值表示該行已被鎖定。

好吧,讓我們完成交易。

=> COMMIT;

指數

到目前為止我們只討論了表頁。 索引內部會發生什麼事?

索引頁中的資訊根據索引的具體類型而有很大差異。 即使一種類型的索引也有不同類型的頁面。 例如,B 樹具有元資料頁面和「常規」頁面。

然而,頁面通常有一個指向行和行本身的指標數組(就像表頁面一樣)。 此外,在頁面末端還有特殊資料的空間。

根據索引的類型,索引中的行也可以有非常不同的結構。 例如,對於 B 樹,與葉頁相關的行包含索引鍵值和對應表行的參考 (ctid)。 一般來說,索引可以以完全不同的方式建構。

最重要的一點是任何類型的索引中都沒有行版本。 好吧,或者我們可以假設每一行都由一個版本表示。 換句話說,索引行標題中沒有 xmin 和 xmax 欄位。 我們可以假設索引中的連結指向行的所有表版本 - 因此您可以僅透過查看表來確定交易將看到哪個版本。 (一如既往,這並不是全部事實。在某些情況下,可見性圖可以優化流程,但我們稍後會更詳細地討論這一點。)

同時,在索引頁中我們找到了指向兩個版本的指針,包括當前版本和舊版本:

=> SELECT itemoffset, ctid FROM bt_page_items('t_s_idx',1);
 itemoffset | ctid  
------------+-------
          1 | (0,2)
          2 | (0,1)
(2 rows)

虛擬交易

在實務中,PostgreSQL 使用最佳化來「保存」事務數。

如果事務僅讀取數據,則對行版本的可見性沒有影響。 因此,服務程序先給事務下發一個虛擬xid。 此編號由進程 ID 和序號組成。

發出這個號碼不需要所有進程之間的同步,因此速度非常快。 當我們談論凍結時,我們將了解使用虛擬號碼的另一個原因。

資料快照中不會以任何方式考慮虛擬號碼。

在不同的時間點,系統中很可能存在已經使用過的號碼的虛擬交易,這是正常的。 但這樣的數字不能寫入資料頁,因為下次造訪該頁時它可能會失去所有意義。

=> BEGIN;
=> SELECT txid_current_if_assigned();
 txid_current_if_assigned 
--------------------------
                         
(1 row)

如果一筆交易開始改變數據,它就會被賦予一個真實的、唯一的交易號碼。

=> UPDATE accounts SET amount = amount - 1.00;
=> SELECT txid_current_if_assigned();
 txid_current_if_assigned 
--------------------------
                     3667
(1 row)

=> COMMIT;

嵌套事務

保存積分

在 SQL 中定義 保存積分 (保存點),它允許您取消部分事務而不完全中斷它。 但這並不符合上圖,因為交易的所有變更都具有相同的狀態,並且物理上沒有資料回滾。

為了實現此功能,帶有保存點的事務被分成多個單獨的事務 嵌套事務 (子事務),其狀態可以單獨管理。

嵌套事務有自己的編號(高於主事務的編號)。 嵌套事務的狀態在XACT中以通常的方式記錄,但最終狀態取決於主事務的狀態:如果它被取消,那麼所有巢狀事務也被取消。

有關事務嵌套的資訊儲存在 PGDATA/pg_subtrans 目錄中的檔案中。 檔案透過實例共享記憶體中的緩衝區進行訪問,其組織方式與 XACT 緩衝區相同。

不要將巢狀事務與自治事務混淆。 自治事務不以任何方式相互依賴,但嵌套事務卻相互依賴。 常規 PostgreSQL 中沒有自主事務,也許這是最好的結果:它們的需要非常非常少,而且它們在其他 DBMS 中的存在會引發濫用,然後每個人都會遭受這種濫用。

讓我們清除表,啟動事務並插入行:

=> TRUNCATE TABLE t;
=> BEGIN;
=> INSERT INTO t(s) VALUES ('FOO');
=> SELECT txid_current();
 txid_current 
--------------
         3669
(1 row)

=> SELECT xmin, xmax, * FROM t;
 xmin | xmax | id |  s  
------+------+----+-----
 3669 |    0 |  2 | FOO
(1 row)

=> SELECT * FROM heap_page('t',0);
 ctid  | state  | xmin | xmax  | t_ctid 
-------+--------+------+-------+--------
 (0,1) | normal | 3669 | 0 (a) | (0,1)
(1 row)

現在讓我們放置一個保存點並插入另一行。

=> SAVEPOINT sp;
=> INSERT INTO t(s) VALUES ('XYZ');
=> SELECT txid_current();
 txid_current 
--------------
         3669
(1 row)

請注意,txid_current() 函數傳回主交易編號,而不是巢狀交易編號。

=> SELECT xmin, xmax, * FROM t;
 xmin | xmax | id |  s  
------+------+----+-----
 3669 |    0 |  2 | FOO
 3670 |    0 |  3 | XYZ
(2 rows)

=> SELECT * FROM heap_page('t',0);
 ctid  | state  | xmin | xmax  | t_ctid 
-------+--------+------+-------+--------
 (0,1) | normal | 3669 | 0 (a) | (0,1)
 (0,2) | normal | 3670 | 0 (a) | (0,2)
(2 rows)

讓我們回滾到保存點並插入第三行。

=> ROLLBACK TO sp;
=> INSERT INTO t(s) VALUES ('BAR');
=> SELECT xmin, xmax, * FROM t;
 xmin | xmax | id |  s  
------+------+----+-----
 3669 |    0 |  2 | FOO
 3671 |    0 |  4 | BAR
(2 rows)

=> SELECT * FROM heap_page('t',0);
 ctid  | state  |   xmin   | xmax  | t_ctid 
-------+--------+----------+-------+--------
 (0,1) | normal | 3669     | 0 (a) | (0,1)
 (0,2) | normal | 3670 (a) | 0 (a) | (0,2)
 (0,3) | normal | 3671     | 0 (a) | (0,3)
(3 rows)

在頁面中我們繼續看到取消的嵌套事務新增的行。

我們修復更改。

=> COMMIT;
=> SELECT xmin, xmax, * FROM t;
 xmin | xmax | id |  s  
------+------+----+-----
 3669 |    0 |  2 | FOO
 3671 |    0 |  4 | BAR
(2 rows)

=> SELECT * FROM heap_page('t',0);
 ctid  | state  |   xmin   | xmax  | t_ctid 
-------+--------+----------+-------+--------
 (0,1) | normal | 3669 (c) | 0 (a) | (0,1)
 (0,2) | normal | 3670 (a) | 0 (a) | (0,2)
 (0,3) | normal | 3671 (c) | 0 (a) | (0,3)
(3 rows)

現在您可以清楚地看到每個巢狀事務都有自己的狀態。

請注意,巢狀事務不能在 SQL 中明確使用,即在未完成目前事務的情況下無法啟動新事務。 當使用保存點、處理 PL/pgSQL 異常以及許多其他更奇特的情況時,該機制會被隱式激活。

=> BEGIN;
BEGIN
=> BEGIN;
WARNING:  there is already a transaction in progress
BEGIN
=> COMMIT;
COMMIT
=> COMMIT;
WARNING:  there is no transaction in progress
COMMIT

操作的錯誤和原子性

如果執行操作時發生錯誤,會發生什麼情況? 例如,像這樣:

=> BEGIN;
=> SELECT * FROM t;
 id |  s  
----+-----
  2 | FOO
  4 | BAR
(2 rows)

=> UPDATE t SET s = repeat('X', 1/(id-4));
ERROR:  division by zero

發生了錯誤。 現在交易被認為已中止並且不允許在其中進行任何操作:

=> SELECT * FROM t;
ERROR:  current transaction is aborted, commands ignored until end of transaction block

即使您嘗試提交更改,PostgreSQL 也會報告中止:

=> COMMIT;
ROLLBACK

為什麼交易失敗後無法繼續? 事實是,錯誤可能會以這樣的方式出現,即我們可以存取部分變更 - 甚至不是事務的原子性,而是違反操作員。 正如在我們的範例中,操作員設法更新了錯誤發生前的一行:

=> SELECT * FROM heap_page('t',0);
 ctid  | state  |   xmin   | xmax  | t_ctid 
-------+--------+----------+-------+--------
 (0,1) | normal | 3669 (c) | 3672  | (0,4)
 (0,2) | normal | 3670 (a) | 0 (a) | (0,2)
 (0,3) | normal | 3671 (c) | 0 (a) | (0,3)
 (0,4) | normal | 3672     | 0 (a) | (0,4)
(4 rows)

必須要說的是,psql有一種模式,在失敗後仍然允許事務繼續進行,就好像錯誤操作員的操作被回滾一樣。

=> set ON_ERROR_ROLLBACK on
=> BEGIN;
=> SELECT * FROM t;
 id |  s  
----+-----
  2 | FOO
  4 | BAR
(2 rows)

=> UPDATE t SET s = repeat('X', 1/(id-4));
ERROR:  division by zero

=> SELECT * FROM t;
 id |  s  
----+-----
  2 | FOO
  4 | BAR
(2 rows)

=> COMMIT;

不難猜測,在這種模式下,psql 實際上會在每個指令之前放置一個隱式保存點,並在失敗時啟動回滾到它。 預設不使用此模式,因為設定保存點(即使不回滾到它們)會涉及大量開銷。

繼續。

來源: www.habr.com

添加評論