從一道PG知識的選擇題談起,你悟到了些什么?
昨天一個網友問我一道關于PG的選擇題:Postgresql數據庫中哪些進程可以將shared buffers中的臟數據回寫到數據文件?A) BACKEND B) BGWRITER C)CHECKPOINTER D) WALWRITER。
稍微懂點PG數據庫的人不難回答,答案是A、B、C。一些Oracle DBA可能會覺得這個答案有點出乎意料。因為在Oracle數據庫中,回寫DB CACHE臟數據的只有DBWR。可能這些人不太清楚的CKPT負責回寫部分臟數據是80年代早期關系型數據庫的共同特點,Oracle數據庫中,CKPT也曾經負責過寫臟塊。后來隨著數據庫規模的增大,CKPT的功能被獨立出來了,只負責CKPT的推進工作,不再負責寫臟塊了。在Oracle數據庫中,為了更快的將臟塊回寫,通過將DB CACHE分區,采用多個DBWR進程可以并發寫臟塊,從而滿足大型數據庫的需要。
PG在刷臟塊的算法方面的設計比較傳統,這種設計是從90年代一脈相承的,改起來成本較大,所以就一直沿用下來了。只不過讓backend也去寫臟塊,這未免也有點太出乎一般人的想象了。這會產生幾個比較奇怪的現象,一個是某條SQL語句,可能在執行計劃相同、數據量相同的情況下,不同的時間執行,其執行效率有較大的不同。另外一個奇怪現象是某條SELECT語句可能會產生較多的寫操作。實際上Oracle數據庫中也會存在類似的現象,這種情況是由延遲塊清理產生的。當一個大型事務結束后,ITL的狀態清理可能并沒有完成,如果馬上有SELECT操作訪問這些數據,那么執行SELECT的前臺進程將負責完成這些塊的清理,這時候select操作也會產生大量的寫操作,產生大量的REDO,同時這條SELECT比起平時會慢很多。
圖片
通過pg_stat_bgwriter系統表中的buffers_backend和buffers_backend_fsync可以查詢到系統中的backend寫臟塊和fsync的計數。
圖片
從我們的一個D-SMART的PG數據庫中看到一個十分神奇的情況,按理說被用來寫臟塊的bgwriter居然很小(buffers_clean),大多數臟塊都是checkpointer和backend寫的 。如果你去檢查一下你們的PG數據庫,可能bgwriter寫臟塊的比例也不是很高。
為什么會出現這種情況,為什么大量的寫臟塊工作不由本應該處理臟塊的 bgwriter去做,而讓Backend去寫臟塊呢?這方面的資料很少,我們只能從 PG的代碼上去找找答案。首先我們看一下src/backend/storage/bufmgr.c,在這里可以看到backend從shared buffers中分配空閑buffer的算法。一般我們都能理解從shared buffers中查找空閑的buffer,如果找不到非臟的空閑塊,那么就有可能找到一個存儲了臟數據的數據塊,這時候才需要backend去寫臟塊。在Oracle數據庫中,前臺進程是順著lru鏈的冷端去查找空閑緩沖塊的,如果前臺進程發現了某個沒有被Pin住的塊是臟塊,就會把這個數據塊移到lru-w中,然后繼續往下搜索,如果連續搜素到了N個臟塊,無法獲得所需要的空閑塊的時候,就會發出一個free buffer requests的事件,讓DBWR加快刷臟塊,然后再去重試。從PG的代碼上看,PG的大體思想類似,不過策略要復雜得多。
圖片
BufferAlloc里包含了backend查找某個buffer的頂層邏輯。不閱讀一下還真沒發現PG這方面的代碼邏輯會搞得如此復雜。要想訪問某個buffer,先要生成一個BUFFER TAG(關于這方面的詳細算法請參考我2021年寫過的一篇8000多字的長篇《PG SHARED BUFFER POOL的優化》)。然后查找這個BUFFER。
圖片
如果BUFFER存在,還有兩種可能性,一種是成功的PIN住了這個BUFFER,那么就可以返回這個BUFFER了。不過BUFFER存在還有一種可能性是無法PIN住,無法PIN住的原因是可能被其他的會話PIN住了,也可能是一些其他的原因。這種情況,Oracle被稱為read by other session或者buffer busy waits。這個部分不是我們今天分析的重點,我們繼續往下看代碼。
圖片
GetVictimBuffer函數通過時鐘掃描算法去找一個空閑的buffer。這里就涉及到查找空閑shared buffers了。我們下鉆到這個函數的代碼中去繼續分析。
圖片
首先調用StrategyGetBuffer去找一個BUFFER。
圖片
如果發現找到的是一個臟塊,那么就把臟塊刷盤,這就是BACKEND也需要刷臟塊的原因之一。作為數據庫緩沖的算法,我們肯定應該盡可能的找到非臟塊來復用,總是讓BACKEND寫臟塊肯定會降低數據庫的整體性能。
StrategyGetBuffer函數在src/backend/buffer/freelist.c中定義。首先,它會檢查是否有一個策略對象(strategy),如果有,就調用GetBufferFromRing函數,從策略對象的環形緩沖區(ring buffer)中獲取一個緩沖區。如果獲取成功,就返回這個緩沖區,并設置from_ring標志為true。如果沒能正常找到free buffer,它會嘗試喚醒bgwriter,讓它刷新臟的緩沖區到磁盤,以便釋放一些空間。接下來,backend會檢查StrategyControl->firstFreeBuffer變量,如果大于等于0,就表示有空閑的緩沖區,那么就通過一個循環從空閑鏈表中獲取一個緩沖區。這部分算法與Oracle的free buffer requests十分類似。
此時如果空閑鏈表為空,backend會進入另一個循環,嘗試從victim pool中選擇一個緩沖區,victim pool是一個循環隊列,存儲了最近被訪問過的緩沖區的編號。從nextVictimBuffer的當前位置開始,順時針掃描victim pool,尋找一個既不被鎖定,也沒有被引用,也不是臟的緩沖區。如果找到了,就返回這個緩沖區,并將其從victim pool中移除。如果沒有找到合適的緩沖區,它會繼續掃描victim pool,尋找一個既不被鎖定,也沒有被引用,但是是臟塊的緩沖區。如果找到了,就將這個緩沖區的內容寫入磁盤,然后返回這個緩沖區(這是代碼中backend中另外一個寫臟塊的地方),并將其從victim pool中移除,并添加到策略對象的環形緩沖區中。
我們先不去吐槽PG在這塊代碼的質量問題,僅僅從算法來看,backend直接刷臟塊的機會也應該是比較小的。那么為什么我們會在pg_stat_bgwriter中看到如此奇葩的數據呢?這里面其實也有一個十分有意思的邏輯。
首先是關于buffers_backend這個指標,本身這個指標就有一定的誤導性,我們今天看的代碼上包含了處寫臟塊的地方,其實不用看代碼我們都能想到第三處,那就是VACUUM。因為backend中還包含了auto vacuum,vacuum操作等寫臟塊的統計數據,因此我們可能會被這個指標誤導。PG社區中十多年前就有人希望PG代碼中把這些情況區分開來,從而讓buffers_clean更有指向性,不過沒有獲得PG社區核心研發的認同。
另外一點是PG數據庫的策略是盡可能讓數據在內存中多存放一段時間,而不急著把臟數據寫盤。因此在PG數據庫中,還是將檢查點進程作為寫臟塊的主力,如果你的系統中的buffers_alloc增長很緩慢的話,那么只要按照checkpointer的節奏慢慢寫臟塊就可以了,backend總是能夠找到所需要的buffer,因此也就沒必要讓bgwriter去寫臟塊了。這種算法對于早些年比較緩慢的IO子系統來說是十分友好的,不過對于當今高性能的IO系統來說,不夠高效,比較適合目前IO性能一般的云上小型數據庫,而對于采用高性能IO設備的大型數據庫來說,并不一定是很優化的。
基于上面的分析我們可以了解到,如果你看到你的系統中的buffers_clean總是為0或者總是慢速增長,那么并不說明系統存在問題,而是說明你的系統寫負載還不算太高,bgwriter還犯不著去幫你刷盤而已。對于IO性能還不錯的系統,或者說規模不算太大的數據庫來說,PG的這種刷臟塊的方法還是可以勝任的,在一些超大型系統中,可能這方面會成為瓶頸。我看到Polardb-PG、openGauss等基于PG代碼的數據庫產品中,對這方面都做了一些優化,引入了專門的機制來替換BGWRITER。目前還沒有對這些代碼進行分析,因此不知道這方面的改善如何。
今天分析PG這方面源代碼的另外一個收獲是從中學到一些PG的SHARED BUFFERS相關的優化策略的。首先shared buffers不能設置得太少,否則backend真正開始大量刷臟塊了,那么SQL的性能是會受到很大的影響的。其次是CHECKPOINTER的相關參數設置要合理,根據底層IO的能力配置合適的參數,讓CHECKPOINTER刷盤的速度能夠跟得上buffers_alloc的速度。如果我們發現buffers_clean的增長比較快了,那么說明目前系統的負載對shared buffers 有一定的壓力了,那么我們就需要考慮調整bgwriter相關的參數了。
最后的源碼鏈接是我兩年多前寫的一篇關于PG SHARED BUFFERS的內部結構的分析文章,文章很長,有8000多字,有興趣的朋友可以閱讀一下。文中有些觀點可能和今天的文章有些不大一致了,如果存在這方面的觀點,那么就以今天的文章為準吧。對PG數據庫的理解都是一點一點的從模糊到清晰,從不大準確到相對準確的。認知的提升是從一個個案例,一段段源碼的分析中逐漸完成的。