PostgreSQL 的并行框架
前言
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 的優化器是不會選擇采用并行化的,得到的查詢計劃如下所示。
而如果表中數據比較多,pg 可能就會開始考慮并行化的查詢計劃,得到的查詢計劃如下,其中 Workers Planned: 4 就表示我們啟動了4個工作進程進行agg的計算。
借一張 Thomas Munro 的圖,出自他18年做的 Parallelism in PostgreSQL 11 的演講的 slides。
而算子的并行化具體是如何實現的,又能帶來怎樣的性能提升則要因算子而異,且聽下回分解。
以下為文檔翻譯:
概述
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。基本的編碼模式如下所示:
如果需要,在調用 WaitForParallelWorkersToFinish() 之后,可以重置上下文,以便可以使用相同的并行上下文重新啟動新的工作進程。為此,我們需要首先調用 ReinitializeParallelDSM() 以重新初始化由并行上下文機制本身管理的狀態;然后重置任何所需要的狀態;之后,你就可以再次調用 LaunchParallelWorkers 去喚起新的工作進程了。
結語
PostgreSQL 確實是一個非常復雜的系統,微擾醬已經入職 Hashdata 半年,接觸到的代碼面積仍然是 PostgreSQL 中非常小的一部分;以至于翻譯這篇文章的時候對里面共享內存機制、鎖機制還有事務的機制都還仍有很多困惑,翻譯出來把握也不是很足,希望好朋友們多多交流。
本文轉載自微信公眾號??「微擾理論」??,作者微擾理論 。轉載本文請聯系微擾理論公眾號。