Postgres:膨脹、pg_repack 和延遲約束

Postgres:膨脹、pg_repack 和延遲約束

膨脹對錶和索引的影響是眾所周知的,並且不僅存在於 Postgres 中。 有一些現成的方法可以處理它,例如 VACUUM FULL 或 CLUSTER,但它們在操作期間鎖定表,因此不能總是使用。

本文將包含一些關於膨脹如何發生、如何對抗它、延遲約束以及它們給使用 pg_repack 擴展帶來的問題的一些理論。

本文是根據 我的演講 在 PgConf.Russia 2020 上。

為什麼會出現浮腫呢?

Postgres 基於多版本模型(MVCC)。 其本質是表中的每一行可以有多個版本,而事務看到的這些版本不超過一個,但不一定是相同的。 這允許多個事務同時進行,並且彼此之間幾乎沒有影響。

顯然,所有這些版本都需要儲存。 Postgres 逐頁處理內存,頁是可以從磁碟讀取或寫入的最小資料量。 讓我們看一個小例子來了解這是如何發生的。

假設我們有一個表,其中添加了幾筆記錄。 新資料已出現在儲存表的檔案的第一頁。 這些是提交後可供其他交易使用的行的即時版本(為簡單起見,我們假設隔離等級為已提交讀取)。

Postgres:膨脹、pg_repack 和延遲約束

然後我們更新了其中一個條目,從而將舊版本標記為不再相關。

Postgres:膨脹、pg_repack 和延遲約束

一步步更新和刪除行版本,我們最終得到一個頁面,其中大約一半的資料是「垃圾」。 該數據對於任何交易都是不可見的。

Postgres:膨脹、pg_repack 和延遲約束

Postgres有一個機制 真空,它會清除過時的版本並為新資料騰出空間。 但是,如果它的配置不夠積極,或者正忙於在其他表中工作,那麼「垃圾資料」仍然存在,我們必須使用額外的頁面來儲存新資料。

因此,在我們的範例中,在某個時間點,表將由四頁組成,但其中只有一半包含即時資料。 結果,當存取表時,我們將讀取比需要的更多的資料。

Postgres:膨脹、pg_repack 和延遲約束

即使 VACUUM 現在刪除所有不相關的行版本,情況也不會顯著改善。 我們將在頁面甚至整個頁面中為新行提供可用空間,但我們仍然會讀取超出必要的資料。
順便說一句,如果文件末尾有一個完全空白的頁面(我們範例中的第二個頁面),那麼 VACUUM 將能夠修剪它。 但現在她夾在中間,也拿她沒有辦法。

Postgres:膨脹、pg_repack 和延遲約束

當此類空頁或高度稀疏頁的數量變大(稱為膨脹)時,它就會開始影響效能。

上面描述的一切都是表中發生膨脹的機制。 在索引中,這種情況的發生方式大致相同。

我有浮腫嗎?

有多種方法可以確定您是否有浮腫。 第一個的想法是使用 Postgres 內部統計信息,其中包含有關表中行數、“活動”行數等的大概信息。您可以在互聯網上找到許多現成腳本的變體。 我們以 腳本 來自 PostgreSQL Experts,它可以評估膨脹表以及 toast 和膨脹 btree 索引。 根據我們的經驗,其誤差為 10-20%。

另一種方法是使用擴展 pgstattuple,它允許您查看頁面內部並獲得估計的和準確的膨脹值。 但在第二種情況下,您將必須掃描整個表。

我們認為較小的膨脹值(最多 20%)是可以接受的。 它可以被視為填充因子的類似物 製表 и 因德克斯科夫。 在 50% 及以上,可能會出現效能問題。

對抗腫脹的方法

Postgres 有幾種開箱即用的方法來處理膨脹,但它們並不總是適合每個人。

設定 AUTOVACUUM 以免發生膨脹。 或者更準確地說,將其保持在您可以接受的水平。 這看起來像是「隊長」的建議,但實際上這並不總是那麼容易實現。 例如,您正在進行積極的開發,定期變更資料模式,或正在進行某種資料遷移。 因此,您的負載曲線可能會頻繁變化,並且通常會因表而異。 這意味著您需要不斷提前工作並調整 AUTOVACUUM 以適應每個表不斷變化的配置。 但顯然這並不容易做到。

AUTOVACUUM 無法跟上表格的另一個常見原因是,存在長時間運行的事務,導致它無法清理這些事務可用的資料。 這裡的建議也很明顯——擺脫「懸空」交易並最大限度地減少活躍交易的時間。 但是,如果應用程式的負載是 OLAP 和 OLTP 的混合體,那麼您可以同時進行許多頻繁更新和簡短查詢,以及長期操作 - 例如,建立報告。 在這種情況下,值得考慮將負載分散到不同的基地,這將允許對每個基地進行更多的微調。

另一個例子 - 即使配置文件是同質的,但資料庫處於非常高的負載下,那麼即使是最激進的 AUTOVACUUM 也可能無法應對,並且會發生膨脹。 縮放(垂直或水平)是唯一的解決方案。

如果您已設定 AUTOVACUUM,但膨脹仍在繼續,該怎麼辦?

團隊 真空充滿 重建表和索引的內容並僅在其中保留相關資料。 為了消除膨脹,它運作得很好,但在執行過程中會捕獲表上的排它鎖定(AccessExclusiveLock),這將不允許在此表上執行查詢,甚至選擇。 如果您有能力停止服務或部分服務一段時間(從幾十分鐘到幾個小時,取決於資料庫和硬體的大小),那麼此選項是最好的。 不幸的是,我們在計劃維護期間沒有時間運行 VACUUM FULL,因此這種方法不適合我們。

團隊 CLUSTER 以與 VACUUM FULL 相同的方式重建表的內容,但允許您指定索引,資料將根據該索引在磁碟上進行物理排序(但將來不保證新行的順序)。 在某些情況下,這對於許多查詢來說是一個很好的最佳化 - 透過索引讀取多個記錄。 該命令的缺點與 VACUUM FULL 相同 - 它在操作期間鎖定表。

團隊 重新索引 與前兩者類似,但是重建表的特定索引或所有索引。 鎖定稍微弱一些:表上的 ShareLock(防止修改,但允許選擇)和正在重建的索引上的 AccessExclusiveLock(阻止使用此索引的查詢)。 然而,在Postgres的第12版出現了一個參數 同時,它允許您重建索引,而不會阻止並發添加、修改或刪除記錄。

在 Postgres 的早期版本中,您可以使用以下命令來獲得類似 REINDEX CONCURRENTLY 的結果 同時建立索引。 它允許您建立一個沒有嚴格鎖定的索引(ShareUpdateExclusiveLock,不會幹擾並行查詢),然後用新索引取代舊索引並刪除舊索引。 這使您可以消除索引膨脹而不干擾您的應用程式。 重要的是要考慮到重建索引時磁碟子系統上會有額外的負載。

因此,如果索引有辦法「即時」消除膨脹,那麼表就沒有辦法。 這是各種外部擴展發揮作用的地方: pg_repack (以前的 pg_reorg), PG緊湊型, pg緊湊表 和別的。 在這篇文章中,我不會對它們進行比較,只會談論 pg_repack,它經過一些修改,我們自己使用。

pg_repack 是如何運作的

Postgres:膨脹、pg_repack 和延遲約束
假設我們有一個完全普通的表 - 有索引、限制,不幸的是,還有膨脹。 pg_repack 的第一步是建立一個日誌表來儲存執行時間所有變更的資料。 觸發器將為每次插入、更新和刪除複製這些變更。 然後建立一張表,結構上與原來類似,但沒有索引和限制,以免拖慢插入資料的過程。

接下來,pg_repack將資料從舊表傳輸到新表,自動過濾掉所有不相關的行,然後為新表建立索引。 在執行所有這些操作期間,變更會累積在日誌表中。

下一步是將變更傳輸到新表。 遷移會進行多次迭代,當日誌表中的條目少於 20 條時,pg_repack 會取得強鎖,遷移最新數據,並用 Postgres 系統表中的新表替換舊表。 這是您無法使用桌子工作的唯一且非常短的時間。 此後,舊表和帶有日誌的表將被刪除,並釋放檔案系統中的空間。 該過程已完成。

理論上一切看起來都很棒,但實踐中會發生什麼? 我們在無負載和負載下測試了 pg_repack,並檢查了其在過早停止的情況下的操作(換句話說,使用 Ctrl+C)。 所有測試均呈陽性。

我們去了食品店——然後一切都沒有按照我們的預期進行。

第一個煎餅發售

在第一個叢集上,我們收到了有關違反唯一約束的錯誤:

$ ./pg_repack -t tablename -o id
INFO: repacking table "tablename"
ERROR: query failed: 
    ERROR: duplicate key value violates unique constraint "index_16508"
DETAIL:  Key (id, index)=(100500, 42) already exists.

這個限制有一個自動產生的名稱index_16508 - 它是由pg_repack創建的。 根據其組成中包含的屬性,我們確定了與其相對應的「我們的」約束。 問題是,這不是一個完全普通的限制,而是延遲的限制(延遲約束), IE。 它的驗證晚於sql指令執行,這會導致意想不到的後果。

延遲約束:為什麼需要它們以及它們如何運作

關於延遲限制的一些理論。
讓我們考慮一個簡單的例子:我們有一本汽車表參考書,它有兩個屬性 - 目錄中汽車的名稱和順序。
Postgres:膨脹、pg_repack 和延遲約束

create table cars
(
  name text constraint pk_cars primary key,
  ord integer not null constraint uk_cars unique
);



假設我們需要交換第一輛車和第二輛車。 最簡單的解決方案是將第一個值更新為第二個值,將第二個值更新為第一個值:

begin;
  update cars set ord = 2 where name = 'audi';
  update cars set ord = 1 where name = 'bmw';
commit;

但是當我們運行這段程式碼時,我們預期會發生約束衝突,因為表中值的順序是唯一的:

[23305] ERROR: duplicate key value violates unique constraint “uk_cars”
Detail: Key (ord)=(2) already exists.

我怎樣才能做到不同呢? 選項一:為表中保證不存在的訂單添加附加價值替換,例如「-1」。 在程式設計中,這稱為「透過第三個變數交換兩個變數的值」。 此方法的唯一缺點是需要額外更新。

選項二:重新設計表以使用浮點資料類型而不是整數作為訂單值。 然後,當將值從 1(例如)更新為 2.5 時,第一個條目將自動「站立」在第二個和第三個條目之間。 解決方案有效,但有兩個限制。 首先,如果該值在介面中的某個地方使用,它對您不起作用。 其次,根據資料類型的精確度,在重新計算所有記錄的值之前,可能的插入次數有限。

選項三:推遲約束,以便僅在提交時檢查它:

create table cars
(
  name text constraint pk_cars primary key,
  ord integer not null constraint uk_cars unique deferrable initially deferred
);

由於我們初始請求的邏輯確保所有值在提交時都是唯一的,因此它會成功。

當然,上面討論的例子是非常綜合的,但它揭示了這個想法。 在我們的應用程式中,我們使用延遲約束來實現負責解決使用者同時使用板上共享小部件物件時的衝突的邏輯。 使用這樣的限制可以使我們的應用程式程式碼更簡單一些。

一般來說,根據約束的類型,Postgres 有三個層級的粒度來檢查它們:行層級、事務層級和表達式層級。
Postgres:膨脹、pg_repack 和延遲約束
來源: 貝格里夫斯

CHECK 和 NOT NULL 總是會在行層級進行檢查;對於其他限制,從表中可以看出,有不同的選項。 您可以閱讀更多內容 這裡.

簡而言之,在許多情況下,延遲約束提供了更易讀的程式碼和更少的命令。 但是,您必須為此付出代價,使偵錯過程變得複雜,因為錯誤發生的那一刻和您發現錯誤的那一刻在時間上是分開的。 另一個可能的問題是,如果請求涉及延遲約束,則調度程序可能無法總是建立最佳計劃。

pg_repack 的改進

我們已經介紹了什麼是延遲約束,但它們與我們的問題有何關係? 讓我們記住之前收到的錯誤:

$ ./pg_repack -t tablename -o id
INFO: repacking table "tablename"
ERROR: query failed: 
    ERROR: duplicate key value violates unique constraint "index_16508"
DETAIL:  Key (id, index)=(100500, 42) already exists.

當資料從日誌表複製到新表時會發生這種情況。 這看起來很奇怪,因為... 日誌表中的資料與來源表中的資料一起提交。 如果它們滿足原始表的約束,它們怎麼會違反新表中的相同約束?

事實證明,問題的根源在於pg_repack的上一步,它只創建了索引,但沒有創建約束:舊表有唯一約束,而新表卻創建了唯一索引。

Postgres:膨脹、pg_repack 和延遲約束

這裡要注意的是,如果約束是普通的並且沒有延遲,那麼改為創建的唯一索引就相當於這個約束,因為Postgres 中的唯一約束是透過建立唯一索引來實現的。 但在延遲約束的情況下,行為並不相同,因為索引不能延遲,而且總是在執行 sql 指令時檢查索引。

由此可見,問題的本質就在於檢查的「延遲」:在原始表中是在提交時發生的,而在新表中是在執行sql指令時發生的。 這意味著我們需要確保在兩種情況下執行相同的檢查:要么總是延遲,要么總是立即。

那我們有什麼想法呢?

建立類似於deferred的索引

第一個想法是在立即模式下執行這兩項檢查。 這可能會產生一些誤報限制,但如果誤報限制很少,這應該不會影響使用者的工作,因為這種衝突對他們來說是正常情況。 例如,當兩個使用者同時開始編輯同一個小部件,而第二個使用者的客戶端沒有時間接收該小部件已被第一個使用者阻止編輯的資訊時,就會發生這種情況。 在這種情況下,伺服器會拒絕第二個用戶,其用戶端會回滾變更並封鎖該小工具。 稍後,當第一個使用者完成編輯時,第二個使用者將收到該小部件不再被阻止並且能夠重複其操作的資訊。

Postgres:膨脹、pg_repack 和延遲約束

為了確保檢查始終處於非延遲模式,我們建立了一個類似於原始延遲約束的新索引:

CREATE UNIQUE INDEX CONCURRENTLY uk_tablename__immediate ON tablename (id, index);
-- run pg_repack
DROP INDEX CONCURRENTLY uk_tablename__immediate;

在測試環境中,我們只收到了一些預期的錯誤。 成功! 我們在生產環境中再次運行 pg_repack,在一小時的工作時間內在第一個叢集上出現了 5 個錯誤。 這是一個可以接受的結果。 然而,在第二個群集上,錯誤數量顯著增加,我們不得不停止 pg_repack。

為什麼會發生這樣的事? 發生錯誤的可能性取決於有多少用戶同時使用相同的小部件。 顯然,此時第一個叢集上儲存的資料的競爭性變化比其他叢集上儲存的資料要少得多,即我們只是「幸運」。

這個想法行不通。 此時,我們看到了另外兩個解決方案:重寫我們的應用程式程式碼以消除延遲約束,或「教導」pg_repack 使用它們。 我們選擇了第二個。

將新表中的索引替換為原始表中的延遲約束

修訂的目的很明顯 - 如果原始表有延遲約束,那麼對於新表,您需要建立這樣的約束,而不是索引。

為了測試我們的更改,我們編寫了一個簡單的測試:

  • 具有延遲約束和一筆記錄的表;
  • 在與現有記錄衝突的循環中插入資料;
  • 進行更新-資料不再衝突;
  • 提交更改。

create table test_table
(
  id serial,
  val int,
  constraint uk_test_table__val unique (val) deferrable initially deferred 
);

INSERT INTO test_table (val) VALUES (0);
FOR i IN 1..10000 LOOP
  BEGIN
    INSERT INTO test_table VALUES (0) RETURNING id INTO v_id;
    UPDATE test_table set val = i where id = v_id;
    COMMIT;
  END;
END LOOP;

pg_repack 的原始版本總是在第一次插入時崩潰,修改後的版本可以正常工作。 偉大的。

我們進入生產環境,在將資料從日誌表複製到新表的同一階段再次出現錯誤:

$ ./pg_repack -t tablename -o id
INFO: repacking table "tablename"
ERROR: query failed: 
    ERROR: duplicate key value violates unique constraint "index_16508"
DETAIL:  Key (id, index)=(100500, 42) already exists.

經典情況:在測試環境中一切正常,但在生產環境中卻不行?!

APPLY_COUNT 和兩個批次的交界處

我們開始逐行分析程式碼,發現了一個重要的點:資料從日誌表批次轉移到新的日誌表中,APPLY_COUNT常數表示批次的大小:

for (;;)
{
num = apply_log(connection, table, APPLY_COUNT);

if (num > MIN_TUPLES_BEFORE_SWITCH)
     continue;  /* there might be still some tuples, repeat. */
...
}

問題在於,來自原始事務的資料(其中多個操作可能會違反約束)在傳輸時可能會出現在兩個批次的交界處- 一半的命令將在第一批中提交,另一半將在第一批中提交在第二。 在這裡,取決於你的運氣:如果團隊在第一批中沒有違反任何規定,那麼一切都很好,但如果他們這樣做了,就會出現錯誤。

APPLY_COUNT 等於 1000 筆記錄,這解釋了為什麼我們的測試成功 - 它們沒有涵蓋「大量連接」的情況。 我們使用了兩個命令 - 插入和更新,因此兩個命令的 500 個事務總是放置在一個批次中,並且我們沒有遇到任何問題。 新增第二個更新後,我們的編輯停止工作:

FOR i IN 1..10000 LOOP
  BEGIN
    INSERT INTO test_table VALUES (1) RETURNING id INTO v_id;
    UPDATE test_table set val = i where id = v_id;
    UPDATE test_table set val = i where id = v_id; -- one more update
    COMMIT;
  END;
END LOOP;

因此,下一個任務是確保在一個交易中更改的原始表中的資料最終也會在一個事務中出現在新表中。

拒絕批次

我們再次有兩個解決方案。 第一:讓我們完全放棄批量分區並在一個事務中傳輸資料。 這個解決方案的優點是它的簡單性 - 所需的程式碼變更很少(順便說一下,在舊版本中 pg_reorg 的工作方式完全一樣)。 但有一個問題——我們正在創建一個長期運行的交易,正如之前所說,這對出現新的膨脹構成威脅。

第二種解決方案更複雜,但可能更正確:在日誌表中建立一個列,其中包含向表中新增資料的交易的識別碼。 然後,當我們複製資料時,我們可以透過這個屬性對其進行分組,並確保相關的變更一起傳輸。 該批次將由多個交易(或一個大事務)組成,其大小將根據這些事務中更改的資料量而變化。 需要注意的是,由於來自不同交易的資料以隨機順序進入日誌表,因此將不再可能像以前那樣順序讀取它。 透過 tx_id 過濾的每個請求的 seqscan 成本太高,需要索引,但由於更新它的開銷,它也會減慢該方法。 一般來說,一如既往,你需要犧牲一些東西。

因此,我們決定從第一個選項開始,因為它更簡單。 首先,有必要了解長交易是否會成為一個真正的問題。 由於從舊表到新表的主要資料傳輸也發生在一個長事務中,因此問題轉化為“我們將增加該事務多少?” 第一個事務的持續時間主要取決於表格的大小。 新的持續時間取決於資料傳輸期間表中累積的變更數量,即關於負載的強度。 pg_repack 運行發生在服務負載最小的時期,與表的原始大小相比,更改量小得不成比例。 我們決定忽略新交易的時間(作為比較,平均為 1 小時 2-3 分鐘)。

實驗結果是正面的。 也開始生產。 為了清楚起見,以下是運行後其中一個資料庫的大小的圖片:

Postgres:膨脹、pg_repack 和延遲約束

由於我們對這個解決方案完全滿意,因此我們沒有嘗試實現第二個解決方案,但我們正在考慮與擴展開發人員討論它的可能性。 不幸的是,我們目前的修訂版尚未準備好發布,因為我們僅透過獨特的延遲限制解決了問題,並且對於成熟的補丁,有必要為其他類型提供支援。 我們希望將來能夠做到這一點。

也許你有一個問題,為什麼我們甚至透過修改 pg_repack 來參與這個故事,而不是使用它的類似物? 在某些時候我們也考慮過這個問題,但是早期在沒有延遲約束的表上使用它的積極經驗激勵我們嘗試理解問題的本質並解決它。 此外,使用其他解決方案也需要時間進行測試,因此我們決定先嘗試修復其中的問題,如果我們意識到無法在合理的時間內做到這一點,那麼我們將開始尋找類似物。

發現

根據我們自己的經驗,我們可以推薦:

  1. 監測你的腫脹情況。 根據監控數據,您可以了解autovacuum的配置。
  2. 調整 AUTOVACUUM 以使膨脹保持在可接受的水平。
  3. 如果膨脹仍在增長,並且您無法使用開箱即用的工具來克服它,請不要害怕使用外部擴展。 最主要的是測試好一切。
  4. 不要害怕修改外部解決方案來滿足您的需求 - 有時這可能比更改您自己的程式碼更有效,甚至更容易。

來源: www.habr.com

添加評論