PostgreSQL 之並行框架

roc_guo發表於2022-11-08
前言

2016年4月,PostgreSQL 社群釋出了 PostgreSQL 9.6,並首次引入了並行查詢的能力,進一步釋放了多核伺服器的計算力。最近微擾醬則因為工作的原因需要調研 PostgreSQL 對並行化運算元的實現,就隨手翻譯了 PostgreSQL 程式碼中介紹 pg 所提供的並行查詢框架的一篇文件,之後應該會再陸續輸出幾篇調研結果;文件在程式碼中的路徑為 src/backend/access/transam/README.parallel,翻譯如有疏漏還請各位大佬多多指正。

那如果有讀者對並行運算元本身沒有任何概念,微擾醬這邊給各位舉一個簡單的例子。我們考慮一個簡單的 agg 語句 explain select count(*) from bmscantest2 where a>1。如果一張表內資料不多時,pg 的最佳化器是不會選擇採用並行化的,得到的查詢計劃如下所示。

postgres=# explain select count(*) from bmscantest2 where a>1;
                           QUERY PLAN
-----------------------------------------------------------------
 Aggregate  (cost=1.13..1.14 rows=1 width=8)
   ->  Seq Scan on bmscantest2  (cost=0.00..1.12 rows=3 width=0)
         Filter: (a > 1)
(3 rows)

而如果表中資料比較多,pg 可能就會開始考慮並行化的查詢計劃,得到的查詢計劃如下,其中 Workers Planned: 4 就表示我們啟動了4個工作程式進行agg的計算。

postgres=*# explain select count(*) from bmscantest where a>1;
                                        QUERY PLAN
-------------------------------------------------------------------------------------------
 Finalize Aggregate  (cost=1968.35..1968.36 rows=1 width=8)
   ->  Gather  (cost=1568.33..1968.34 rows=4 width=8)
         Workers Planned: 4
         ->  Partial Aggregate  (cost=1568.33..1568.34 rows=1 width=8)
               ->  Parallel Seq Scan on bmscantest  (cost=0.00..1547.50 rows=8333 width=0)
                     Filter: (a > 1)
(6 rows)

借一張 Thomas Munro 的圖,出自他18年做的 Parallelism in PostgreSQL 11 的演講的 slides。

PostgreSQL 之並行框架PostgreSQL 之並行框架

而運算元的並行化具體是如何實現的,又能帶來怎樣的效能提升則要因運算元而異,且聽下回分解。

以下為文件翻譯:

概述

PostgreSQL 提供了一些簡單的機制使得編寫並行演算法更加簡單。你可以透過使用 ParallelContext 資料結構去喚起後臺工作程式、初始化工作程式的程式狀態(以匹配喚起他們的後臺程式),使程式透過動態共享記憶體 (Dynamic Shared Memory) 進行通訊和寫並不複雜的邏輯且不用意識到並行的存在就可以讓程式碼跑在使用者後臺程式或者任一併行的工作程式。

那個發起並行指令的程式(我們此後稱為發起程式)首先會建立一個動態共享記憶體區,該區域在整個並行運算的過程裡都會存在。動態共享記憶體區會包含(1)用於傳遞錯誤資訊(和透過 elog/ereport 上報的其他資訊)的 shm_mq (2)用於同步工作程式狀態的發起程式私有狀態的序列化表示(3)任何其他 ParallelContext 使用者出於使用目的自定義的資料結構。一旦發起程式完成了動態共享記憶體區的初始化,它就會要求 postmaster 發起適當數量的工作程式。這些工作程式隨後會連線上動態共享記憶體區、初始化他們的狀態然後喚起入口函式,我們馬上會介紹這一部分內容。

錯誤上報

工作程式被啟動的時候,首先會繫結動態共享記憶體區並定位其中的 shm_mq,用於進行錯誤上報;工作程式會把所有的協議訊息重定向給 shm_mq。而在此之前,所有後臺工作程式發生的錯誤並不會傳送給發起程式。從發起程式的視角來看,這些工作程式只不過是初始化失敗了。發起程式也需要始終做好和比其發起的數量更少的工作程式協同工作的準備,所以即使出現這樣的情況也不會有什麼額外的問題。

當有一條訊息(在訊息體很大被拆分的時候也可能是部分訊息)被放入錯誤上報佇列時,PROCSIG_PARALLEL_MESSAGE 會被髮送到發起程式。而發起程式的 CHECK_FOR_INTERRUPTS() 就會檢查到這一事件,從而讀取並重新在發起程式上重新發出該訊息。大多數情況下,這就足以使得錯誤上報在並行的模式下可以工作了。當然,為了正常執行,發起程式需要定期執行 CHECK_FOR_INTERRUPTS() 並避免中斷長時間阻塞程式,但這些事情本就是應該做的。

(目前仍有的一個懸而未決的問題就是有時候一些訊息會被寫到系統日誌中兩次,一次是在上報發生的工作程式寫入,一次是在發起程式收到訊息後重新丟擲的訊息。如果我們決定要避免其中一次的訊息寫入,應該想辦法避免發起程式的重複寫。不然的話,如果工作程式因為一些原因未能將訊息傳遞給發起程式,則整個訊息就會被丟失了。)

狀態共享

在單程式狀態下可以工作的 C 程式碼在並行模式下卻失敗了的情況是時有發生的。只要全域性變數存在,就沒有並行的框架可以完全解決這個問題。沒有通用的機制可以保證每個全域性變數在工作程式中可以和發起程式有一樣的值。即使我們可以保證這一點,只要我們呼叫了一些函式去改變這些變數,那麼只有在這些改變發生的程式才可以立刻看到更新後的新值。相似的問題在任何一個我們使用的更復雜的資料結構中都會出現。比如偽隨機數生成器在指定隨機種子的情況下,每次都應該產生同樣的可預測的隨機序列。而這背後依賴的是執行生成器的程式內部的私有狀態,這本身不會跨程式共享。所以一個並行安全的偽隨機器應該要將其狀態儲存在動態共享記憶體中,並用鎖保證其安全性。而並行框架本身沒有辦法知道使用者所呼叫的程式碼是否有這樣的問題,也就沒有辦法對此做出什麼措施。

取而代之的,我們採用了更加實用主義的策略。首先,我們試著讓更多的操作在並行模式下和單程式模式下工作的一樣正確。其次,我們試著透過錯誤檢查禁止一些常見的不安全操作。這些機制可以 100% 保證 SQL 中的不安全行為被禁止,但是 C 程式碼中的不安全行為卻可能並不會觸發這些檢查。這些檢查會透過呼叫 EnterParallelMode() 函式啟用。因而,在建立並行上下文的時候,我們就應該呼叫這個函式,並在 ExitParallelMode() 呼叫時解除這些檢查。最後,最重要的一個限制則是我們要求所有的操作在只讀的時候才可以使用並行模式,所有的寫操作和 DDL 都是不會被並行的。也許以後我們可以減少這樣的限制。

為了使得更多的操作可以在並行模式下安全執行,我們會從發起程式中複製出許多重要的狀態到工作程式裡,包括:

  • dfmgr.c 動態載入的一系列動態庫。
  • 被驗證的使用者 ID 和當前資料庫。每個工作程式都會和發起程式用同樣的 ID 連線同樣的資料庫。
  • 所有 GUC 值。在並行模式下禁止任何 GUC 的永久改變;但暫時的變化,比如進入一個帶有非空 proconfig 的函式,則是可以的。
  • 當前子事務的 XID,最上層事務的 XID,以及當前的 XID 列表(即正在進行中或提交的事務)。需要這些資訊以確保元組可見性檢查在工作程式中與在發起程式中返回相同的結果。細節請參閱下面的事務整合部分。
  • CID 對映。這也是為了保證一致的元組可見性檢查。需要同步這個資料結構的是我們不能支援並行模式寫入的一個主要原因:因為寫入可能會建立新的 CID,而我們無法讓其他工作程式瞭解它們。
  • 事務快照。
  • 活躍快照,可能和事務快照不同。
  • 當前活動的使用者 ID 和安全上下文。
  • 與阻塞的 REINDEX 操作相關的狀態。這能阻止訪問正在被重建的索引。
  • 活躍的 relmapper.c 的對映狀態。這是為了保證獲取對映的關係表 oid 對應的 relfilenumber 一致所需要的。
  • 為了防止在並行模式下執行時出現死鎖,程式碼中還引入了針對主程式和工作程式的分組鎖 (group locking)。具體可以參考 src/backend/storage/lmgr/README 。

    事務整合

    不管主程式中的 TransactionState 棧是什麼樣子,每個並行工作程式最終都會得到一個深度為 1 的事務狀態棧。這個棧中唯一的記錄會被標記為特殊的事務狀態 TBLOCK_PARALLEL_INPROGRESS,這樣它就不會與普通的最上層事務混淆。這個 TransactionState 的 XID 會被設定為發起程式的當前活動子事務中最裡的 XID。發起程式的最上層 XID,以及所有當前(進行中或已提交)XID 與 TransactionState 堆疊分開儲存,但 GetTopTransactionId()、GetTopTransactionIdIfAny() 和 TransactionIdIsCurrentTransactionId() 呼叫時會返回和發起程式相同的值。我們可以複製整個事務狀態堆疊,但其中大部分狀態是無用的:例如,你不能從工作程式中回滾到儲存點,並且沒有與記憶體上下文相關的資源或中間子事務的資源所有者。

    在並行模式下不能對事務狀態進行有意義的更改。既不能分配 XID,也不能發起或結束子事務,因為我們無法將這些狀態更改傳達或同步給協作的其他程式。在所有工作程式退出之前,發起程式想要退出正在進行的任何事務或子事務顯然是不可行的;而對於工作程式來說,嘗試提交子事務或中止當前子事務並自行切換上下文執行一些非當前發起程式正在處理的事務,當然是更不被允許的。允許以並行模式執行內部子事務(例如,實現 PL/pgSQL EXCEPTION 塊)可能是可行的,只要它們不會產生 XID,因為其他程式實際上不需要知道這些事務的發生,也不需要為此做任何事情。但現在,我們選擇直接禁用他們。

    在並行操作結束時,不管是得到了成功提交還是被錯誤中斷,與該操作關聯的並行工作程式都會退出。在錯誤發生的情況下,發起程式的終止事務處理模組會發出終止所有剩餘的工作程式的訊號,然後等待他們退出。在並行操作成功的情況下,發起程式不傳送任何訊號,而是必須等待工作程式完成並自行退出。無論在哪種情況下,在發起程式清理被建立的(子)事務之前,都必須先等待工作程式全部退出;否則,可能會出現混亂。例如,如果發起程式正在回滾建立了某個正在被工作程式掃描的表的事務,則該表可能會在工作程式掃描它的過程中消失。這顯然是不安全的。

    通常,此時每個工作程式執行的清理操作類似於最頂層事務的提交或中止時發生的。每個程式都有自己的資源所有者:buffer pins、catcache 或 relcache 的引用計數、元組描述符等由每個程式獨立管理,並且必須在退出之前釋放它們。但是,工作程式對事務的提交或中止與真正的最頂層事務的提交或中止之間仍存在一些重要區別,包括:

  • 不會有任何提交或終止記錄被寫入系統;發起程式會處理這件事。
  • pg_temp 名稱空間的清理不會發生。並行程式不能安全的訪問發起程式的 pg_temp 名稱空間,也不應該建立一個自己的副本。
  • 編碼約定

    在開始任何並行操作之前,呼叫 EnterParallelMode();在所有並行操作完成後,呼叫 ExitParallelMode()。試圖並行化任何特定運算元的時候,都請使用 ParallelContext。基本的編碼模式如下所示:

EnterParallelMode();  /* prohibit unsafe state changes */
 pcxt = CreateParallelContext("library_name", "function_name", nworkers);
 /* Allow space for application-specific data here. */
 shm_toc_estimate_chunk(&pcxt->estimator, size);
 shm_toc_estimate_keys(&pcxt->estimator, keys);
 InitializeParallelDSM(pcxt); /* create DSM and copy state to it */
 /* Store the data for which we reserved space. */
 space = shm_toc_allocate(pcxt->toc, size);
 shm_toc_insert(pcxt->toc, key, space);
 LaunchParallelWorkers(pcxt);
 /* do parallel stuff */
 WaitForParallelWorkersToFinish(pcxt);
 /* read any final results from dynamic shared memory */
 DestroyParallelContext(pcxt);
 ExitParallelMode();

如果需要,在呼叫 WaitForParallelWorkersToFinish() 之後,可以重置上下文,以便可以使用相同的並行上下文重新啟動新的工作程式。為此,我們需要首先呼叫 ReinitializeParallelDSM() 以重新初始化由並行上下文機制本身管理的狀態;然後重置任何所需要的狀態;之後,你就可以再次呼叫 LaunchParallelWorkers 去喚起新的工作程式了。

結語

PostgreSQL 確實是一個非常複雜的系統,微擾醬已經入職 Hashdata 半年,接觸到的程式碼面積仍然是 PostgreSQL 中非常小的一部分;以至於翻譯這篇文章的時候對裡面共享記憶體機制、鎖機制還有事務的機制都還仍有很多困惑,翻譯出來把握也不是很足,希望好朋友們多多交流。

本文轉載自微信公眾號「微擾理論」,作者微擾理論 。轉載本文請聯絡微擾理論公眾號。


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69901823/viewspace-2922281/,如需轉載,請註明出處,否則將追究法律責任。

相關文章