PostgreSQL 的并行框架
前言
2016年4月,PostgreSQL 社區(qū)發(fā)布了 PostgreSQL 9.6,并首次引入了并行查詢的能力,進(jìn)一步釋放了多核服務(wù)器的計(jì)算力。最近微擾醬則因?yàn)楣ぷ鞯脑蛐枰{(diào)研 PostgreSQL 對(duì)并行化算子的實(shí)現(xiàn),就隨手翻譯了 PostgreSQL 代碼中介紹 pg 所提供的并行查詢框架的一篇文檔,之后應(yīng)該會(huì)再陸續(xù)輸出幾篇調(diào)研結(jié)果;文檔在代碼中的路徑為 src/backend/access/transam/README.parallel,翻譯如有疏漏還請(qǐng)各位大佬多多指正。
那如果有讀者對(duì)并行算子本身沒(méi)有任何概念,微擾醬這邊給各位舉一個(gè)簡(jiǎn)單的例子。我們考慮一個(gè)簡(jiǎn)單的 agg 語(yǔ)句 explain select count(*) from bmscantest2 where a>1。如果一張表內(nèi)數(shù)據(jù)不多時(shí),pg 的優(yōu)化器是不會(huì)選擇采用并行化的,得到的查詢計(jì)劃如下所示。
而如果表中數(shù)據(jù)比較多,pg 可能就會(huì)開(kāi)始考慮并行化的查詢計(jì)劃,得到的查詢計(jì)劃如下,其中 Workers Planned: 4 就表示我們啟動(dòng)了4個(gè)工作進(jìn)程進(jìn)行agg的計(jì)算。
借一張 Thomas Munro 的圖,出自他18年做的 Parallelism in PostgreSQL 11 的演講的 slides。
而算子的并行化具體是如何實(shí)現(xiàn)的,又能帶來(lái)怎樣的性能提升則要因算子而異,且聽(tīng)下回分解。
以下為文檔翻譯:
概述
PostgreSQL 提供了一些簡(jiǎn)單的機(jī)制使得編寫并行算法更加簡(jiǎn)單。你可以通過(guò)使用 ParallelContext 數(shù)據(jù)結(jié)構(gòu)去喚起后臺(tái)工作進(jìn)程、初始化工作進(jìn)程的進(jìn)程狀態(tài)(以匹配喚起他們的后臺(tái)進(jìn)程),使進(jìn)程通過(guò)動(dòng)態(tài)共享內(nèi)存 (Dynamic Shared Memory) 進(jìn)行通信和寫并不復(fù)雜的邏輯且不用意識(shí)到并行的存在就可以讓代碼跑在用戶后臺(tái)進(jìn)程或者任一并行的工作進(jìn)程。
那個(gè)發(fā)起并行指令的進(jìn)程(我們此后稱為發(fā)起進(jìn)程)首先會(huì)創(chuàng)建一個(gè)動(dòng)態(tài)共享內(nèi)存區(qū),該區(qū)域在整個(gè)并行運(yùn)算的過(guò)程里都會(huì)存在。動(dòng)態(tài)共享內(nèi)存區(qū)會(huì)包含(1)用于傳遞錯(cuò)誤信息(和通過(guò) elog/ereport 上報(bào)的其他信息)的 shm_mq (2)用于同步工作進(jìn)程狀態(tài)的發(fā)起進(jìn)程私有狀態(tài)的序列化表示(3)任何其他 ParallelContext 使用者出于使用目的自定義的數(shù)據(jù)結(jié)構(gòu)。一旦發(fā)起進(jìn)程完成了動(dòng)態(tài)共享內(nèi)存區(qū)的初始化,它就會(huì)要求 postmaster 發(fā)起適當(dāng)數(shù)量的工作進(jìn)程。這些工作進(jìn)程隨后會(huì)連接上動(dòng)態(tài)共享內(nèi)存區(qū)、初始化他們的狀態(tài)然后喚起入口函數(shù),我們馬上會(huì)介紹這一部分內(nèi)容。
錯(cuò)誤上報(bào)
工作進(jìn)程被啟動(dòng)的時(shí)候,首先會(huì)綁定動(dòng)態(tài)共享內(nèi)存區(qū)并定位其中的 shm_mq,用于進(jìn)行錯(cuò)誤上報(bào);工作進(jìn)程會(huì)把所有的協(xié)議消息重定向給 shm_mq。而在此之前,所有后臺(tái)工作進(jìn)程發(fā)生的錯(cuò)誤并不會(huì)發(fā)送給發(fā)起進(jìn)程。從發(fā)起進(jìn)程的視角來(lái)看,這些工作進(jìn)程只不過(guò)是初始化失敗了。發(fā)起進(jìn)程也需要始終做好和比其發(fā)起的數(shù)量更少的工作進(jìn)程協(xié)同工作的準(zhǔn)備,所以即使出現(xiàn)這樣的情況也不會(huì)有什么額外的問(wèn)題。
當(dāng)有一條消息(在消息體很大被拆分的時(shí)候也可能是部分消息)被放入錯(cuò)誤上報(bào)隊(duì)列時(shí),PROCSIG_PARALLEL_MESSAGE 會(huì)被發(fā)送到發(fā)起進(jìn)程。而發(fā)起進(jìn)程的 CHECK_FOR_INTERRUPTS() 就會(huì)檢查到這一事件,從而讀取并重新在發(fā)起進(jìn)程上重新發(fā)出該消息。大多數(shù)情況下,這就足以使得錯(cuò)誤上報(bào)在并行的模式下可以工作了。當(dāng)然,為了正常運(yùn)行,發(fā)起進(jìn)程需要定期執(zhí)行 CHECK_FOR_INTERRUPTS() 并避免中斷長(zhǎng)時(shí)間阻塞進(jìn)程,但這些事情本就是應(yīng)該做的。
(目前仍有的一個(gè)懸而未決的問(wèn)題就是有時(shí)候一些消息會(huì)被寫到系統(tǒng)日志中兩次,一次是在上報(bào)發(fā)生的工作進(jìn)程寫入,一次是在發(fā)起進(jìn)程收到消息后重新拋出的消息。如果我們決定要避免其中一次的消息寫入,應(yīng)該想辦法避免發(fā)起進(jìn)程的重復(fù)寫。不然的話,如果工作進(jìn)程因?yàn)橐恍┰蛭茨軐⑾鬟f給發(fā)起進(jìn)程,則整個(gè)消息就會(huì)被丟失了。)
狀態(tài)共享
在單進(jìn)程狀態(tài)下可以工作的 C 代碼在并行模式下卻失敗了的情況是時(shí)有發(fā)生的。只要全局變量存在,就沒(méi)有并行的框架可以完全解決這個(gè)問(wèn)題。沒(méi)有通用的機(jī)制可以保證每個(gè)全局變量在工作進(jìn)程中可以和發(fā)起進(jìn)程有一樣的值。即使我們可以保證這一點(diǎn),只要我們調(diào)用了一些函數(shù)去改變這些變量,那么只有在這些改變發(fā)生的進(jìn)程才可以立刻看到更新后的新值。相似的問(wèn)題在任何一個(gè)我們使用的更復(fù)雜的數(shù)據(jù)結(jié)構(gòu)中都會(huì)出現(xiàn)。比如偽隨機(jī)數(shù)生成器在指定隨機(jī)種子的情況下,每次都應(yīng)該產(chǎn)生同樣的可預(yù)測(cè)的隨機(jī)序列。而這背后依賴的是執(zhí)行生成器的進(jìn)程內(nèi)部的私有狀態(tài),這本身不會(huì)跨進(jìn)程共享。所以一個(gè)并行安全的偽隨機(jī)器應(yīng)該要將其狀態(tài)存儲(chǔ)在動(dòng)態(tài)共享內(nèi)存中,并用鎖保證其安全性。而并行框架本身沒(méi)有辦法知道用戶所調(diào)用的代碼是否有這樣的問(wèn)題,也就沒(méi)有辦法對(duì)此做出什么措施。
取而代之的,我們采用了更加實(shí)用主義的策略。首先,我們?cè)囍尭嗟牟僮髟诓⑿心J较潞蛦芜M(jìn)程模式下工作的一樣正確。其次,我們?cè)囍ㄟ^(guò)錯(cuò)誤檢查禁止一些常見(jiàn)的不安全操作。這些機(jī)制可以 100% 保證 SQL 中的不安全行為被禁止,但是 C 代碼中的不安全行為卻可能并不會(huì)觸發(fā)這些檢查。這些檢查會(huì)通過(guò)調(diào)用 EnterParallelMode() 函數(shù)啟用。因而,在創(chuàng)建并行上下文的時(shí)候,我們就應(yīng)該調(diào)用這個(gè)函數(shù),并在 ExitParallelMode() 調(diào)用時(shí)解除這些檢查。最后,最重要的一個(gè)限制則是我們要求所有的操作在只讀的時(shí)候才可以使用并行模式,所有的寫操作和 DDL 都是不會(huì)被并行的。也許以后我們可以減少這樣的限制。
為了使得更多的操作可以在并行模式下安全執(zhí)行,我們會(huì)從發(fā)起進(jìn)程中拷貝出許多重要的狀態(tài)到工作進(jìn)程里,包括:
- dfmgr.c 動(dòng)態(tài)加載的一系列動(dòng)態(tài)庫(kù)。
- 被驗(yàn)證的用戶 ID 和當(dāng)前數(shù)據(jù)庫(kù)。每個(gè)工作進(jìn)程都會(huì)和發(fā)起進(jìn)程用同樣的 ID 連接同樣的數(shù)據(jù)庫(kù)。
- 所有 GUC 值。在并行模式下禁止任何 GUC 的永久改變;但暫時(shí)的變化,比如進(jìn)入一個(gè)帶有非空 proconfig 的函數(shù),則是可以的。
- 當(dāng)前子事務(wù)的 XID,最上層事務(wù)的 XID,以及當(dāng)前的 XID 列表(即正在進(jìn)行中或提交的事務(wù))。需要這些信息以確保元組可見(jiàn)性檢查在工作進(jìn)程中與在發(fā)起進(jìn)程中返回相同的結(jié)果。細(xì)節(jié)請(qǐng)參閱下面的事務(wù)集成部分。
- CID 映射。這也是為了保證一致的元組可見(jiàn)性檢查。需要同步這個(gè)數(shù)據(jù)結(jié)構(gòu)的是我們不能支持并行模式寫入的一個(gè)主要原因:因?yàn)閷懭肟赡軙?huì)創(chuàng)建新的 CID,而我們無(wú)法讓其他工作進(jìn)程了解它們。
- 事務(wù)快照。
- 活躍快照,可能和事務(wù)快照不同。
- 當(dāng)前活動(dòng)的用戶 ID 和安全上下文。
- 與阻塞的 REINDEX 操作相關(guān)的狀態(tài)。這能阻止訪問(wèn)正在被重建的索引。
- 活躍的 relmapper.c 的映射狀態(tài)。這是為了保證獲取映射的關(guān)系表 oid 對(duì)應(yīng)的 relfilenumber 一致所需要的。
為了防止在并行模式下運(yùn)行時(shí)出現(xiàn)死鎖,代碼中還引入了針對(duì)主進(jìn)程和工作進(jìn)程的分組鎖 (group locking)。具體可以參考 src/backend/storage/lmgr/README 。
事務(wù)集成
不管主進(jìn)程中的 TransactionState 棧是什么樣子,每個(gè)并行工作進(jìn)程最終都會(huì)得到一個(gè)深度為 1 的事務(wù)狀態(tài)棧。這個(gè)棧中唯一的記錄會(huì)被標(biāo)記為特殊的事務(wù)狀態(tài) TBLOCK_PARALLEL_INPROGRESS,這樣它就不會(huì)與普通的最上層事務(wù)混淆。這個(gè) TransactionState 的 XID 會(huì)被設(shè)置為發(fā)起進(jìn)程的當(dāng)前活動(dòng)子事務(wù)中最里的 XID。發(fā)起進(jìn)程的最上層 XID,以及所有當(dāng)前(進(jìn)行中或已提交)XID 與 TransactionState 堆棧分開(kāi)存儲(chǔ),但 GetTopTransactionId()、GetTopTransactionIdIfAny() 和 TransactionIdIsCurrentTransactionId() 調(diào)用時(shí)會(huì)返回和發(fā)起進(jìn)程相同的值。我們可以復(fù)制整個(gè)事務(wù)狀態(tài)堆棧,但其中大部分狀態(tài)是無(wú)用的:例如,你不能從工作進(jìn)程中回滾到保存點(diǎn),并且沒(méi)有與內(nèi)存上下文相關(guān)的資源或中間子事務(wù)的資源所有者。
在并行模式下不能對(duì)事務(wù)狀態(tài)進(jìn)行有意義的更改。既不能分配 XID,也不能發(fā)起或結(jié)束子事務(wù),因?yàn)槲覀儫o(wú)法將這些狀態(tài)更改傳達(dá)或同步給協(xié)作的其他進(jìn)程。在所有工作進(jìn)程退出之前,發(fā)起進(jìn)程想要退出正在進(jìn)行的任何事務(wù)或子事務(wù)顯然是不可行的;而對(duì)于工作進(jìn)程來(lái)說(shuō),嘗試提交子事務(wù)或中止當(dāng)前子事務(wù)并自行切換上下文執(zhí)行一些非當(dāng)前發(fā)起進(jìn)程正在處理的事務(wù),當(dāng)然是更不被允許的。允許以并行模式執(zhí)行內(nèi)部子事務(wù)(例如,實(shí)現(xiàn) PL/pgSQL EXCEPTION 塊)可能是可行的,只要它們不會(huì)產(chǎn)生 XID,因?yàn)槠渌M(jìn)程實(shí)際上不需要知道這些事務(wù)的發(fā)生,也不需要為此做任何事情。但現(xiàn)在,我們選擇直接禁用他們。
在并行操作結(jié)束時(shí),不管是得到了成功提交還是被錯(cuò)誤中斷,與該操作關(guān)聯(lián)的并行工作進(jìn)程都會(huì)退出。在錯(cuò)誤發(fā)生的情況下,發(fā)起進(jìn)程的終止事務(wù)處理模塊會(huì)發(fā)出終止所有剩余的工作進(jìn)程的信號(hào),然后等待他們退出。在并行操作成功的情況下,發(fā)起進(jìn)程不發(fā)送任何信號(hào),而是必須等待工作進(jìn)程完成并自行退出。無(wú)論在哪種情況下,在發(fā)起進(jìn)程清理被創(chuàng)建的(子)事務(wù)之前,都必須先等待工作進(jìn)程全部退出;否則,可能會(huì)出現(xiàn)混亂。例如,如果發(fā)起進(jìn)程正在回滾創(chuàng)建了某個(gè)正在被工作進(jìn)程掃描的表的事務(wù),則該表可能會(huì)在工作進(jìn)程掃描它的過(guò)程中消失。這顯然是不安全的。
通常,此時(shí)每個(gè)工作進(jìn)程執(zhí)行的清理操作類似于最頂層事務(wù)的提交或中止時(shí)發(fā)生的。每個(gè)進(jìn)程都有自己的資源所有者:buffer pins、catcache 或 relcache 的引用計(jì)數(shù)、元組描述符等由每個(gè)進(jìn)程獨(dú)立管理,并且必須在退出之前釋放它們。但是,工作進(jìn)程對(duì)事務(wù)的提交或中止與真正的最頂層事務(wù)的提交或中止之間仍存在一些重要區(qū)別,包括:
- 不會(huì)有任何提交或終止記錄被寫入系統(tǒng);發(fā)起進(jìn)程會(huì)處理這件事。
- pg_temp 命名空間的清理不會(huì)發(fā)生。并行進(jìn)程不能安全的訪問(wèn)發(fā)起進(jìn)程的 pg_temp 命名空間,也不應(yīng)該創(chuàng)建一個(gè)自己的副本。
編碼約定
在開(kāi)始任何并行操作之前,調(diào)用 EnterParallelMode();在所有并行操作完成后,調(diào)用 ExitParallelMode()。試圖并行化任何特定算子的時(shí)候,都請(qǐng)使用 ParallelContext。基本的編碼模式如下所示:
如果需要,在調(diào)用 WaitForParallelWorkersToFinish() 之后,可以重置上下文,以便可以使用相同的并行上下文重新啟動(dòng)新的工作進(jìn)程。為此,我們需要首先調(diào)用 ReinitializeParallelDSM() 以重新初始化由并行上下文機(jī)制本身管理的狀態(tài);然后重置任何所需要的狀態(tài);之后,你就可以再次調(diào)用 LaunchParallelWorkers 去喚起新的工作進(jìn)程了。
結(jié)語(yǔ)
PostgreSQL 確實(shí)是一個(gè)非常復(fù)雜的系統(tǒng),微擾醬已經(jīng)入職 Hashdata 半年,接觸到的代碼面積仍然是 PostgreSQL 中非常小的一部分;以至于翻譯這篇文章的時(shí)候?qū)锩婀蚕韮?nèi)存機(jī)制、鎖機(jī)制還有事務(wù)的機(jī)制都還仍有很多困惑,翻譯出來(lái)把握也不是很足,希望好朋友們多多交流。
本文轉(zhuǎn)載自微信公眾號(hào)??「微擾理論」??,作者微擾理論 。轉(zhuǎn)載本文請(qǐng)聯(lián)系微擾理論公眾號(hào)。