概述
并行查詢使用多個后臺進程,但后端進程基本上處理連接的客戶端發出的所有查詢。改后端有五個子系統組成。
子系統 | 功能 |
---|---|
解析器 | 解析器從純文本的 SQL 語句生成解析樹。 |
分析器 | 解行解析樹的語義分析并生成查詢樹。 |
重寫器 | 如果存在這樣的規則,則重寫器使用存儲在規則系統中的規則來轉換查詢樹。 |
計劃器 | 計劃器生成可以從查詢樹最有效地執行的計劃樹。 |
執行器 | 執行器通過按計劃樹創建的順序訪問表和索引來執行查詢。 |
解析器
解析器生成一個解析樹,后續子系統可以從純文本的 SQL 語句中讀取該解析樹。
如下面的查詢:
testdb =# SELECT id , data FROM tbl_a WHERE id < 300 ORDER BY data ;
解析樹是其根節點是定義在parsenodes.h中的[SelectStmt](javascript:void(0))結構的樹。
SELECT 查詢的元素和解析樹的相應元素編號相同。例如,(1) 是第一個目標列表的一個項目,它是表的“id”列,(4) 是 WHERE 子句,依此類推。
由于解析器在生成解析樹時只檢查輸入的語法,因此只有在查詢中出現語法錯誤時才會返回錯誤。
解析器不檢查輸入查詢的語義。例如,即使查詢包含不存在的表名,解析器也不會返回錯誤。語義檢查由分析器/分析器完成。
分析器
分析器運行由解析器生成的解析樹的語義分析并生成查詢樹。
查詢樹的根是定義在parsenodes.h中的[查詢](javascript:void(0))結構;此結構包含其相應查詢的元數據,例如此命令的類型(SELECT、INSERT 或其他)和幾個葉子;每個葉子形成一個列表或樹,并保存各個特定子句的數據。
述查詢樹簡述如下。
- targetlist 是作為此查詢結果的列的列表。在此示例中,此列表由兩列組成:'id'和'data'。如果輸入查詢樹使用 '*'(星號),分析器/分析器會將其顯式替換為所有列。
- 范圍表是在此查詢中使用的關系列表。在這個例子中,這個表保存了表' tbl_a '的信息,例如這個表的oid和這個表的名字。
- 連接樹存儲 FROM 子句和 WHERE 子句。
- sort 子句是 SortGroupClause 的列表。
重寫器
重寫器是實現規則系統的系統,必要時根據存儲在pg_rules系統目錄中的規則變換查詢樹。
PostgreSQL 中的視圖是使用規則系統實現的。當視圖由CREATE VIEW命令定義時,相應的規則會自動生成并存儲在目錄中。
假設已經定義了以下視圖,并且對應的規則存儲在pg_rules系統目錄中。
sampledb=# CREATE VIEW employees_list
sampledb-# AS SELECT e.id, e.name, d.name AS department
sampledb-# FROM employees AS e, departments AS d WHERE e.department_id = d.id;
當發出包含如下所示視圖的查詢時,解析器將創建解析樹,如圖所示。
sampledb=# SELECT * FROM employees_list;
在這個階段,重寫器將范圍表節點處理為子查詢的解析樹,即對應的視圖,存儲在pg_rules中。
計劃器和執行器
計劃器從重寫器接收查詢樹并生成可以由執行器最有效地處理的(查詢)計劃樹。
PostgreSQL 中的計劃器是基于純成本優化的;它不支持基于規則的優化和提示。這個規劃器是 RDBMS 中最復雜的子系統
與其他 RDBMS 一樣,PostgreSQL 中的EXPLAIN命令顯示計劃樹本身。 如下所示。
testdb=# EXPLAIN SELECT * FROM tbl_a WHERE id < 300 ORDER BY data;
QUERY PLAN
---------------------------------------------------------------
Sort (cost=182.34..183.09 rows=300 width=8)
Sort Key: data
-> Seq Scan on tbl_a (cost=0.00..170.00 rows=300 width=8)
Filter: (id < 300)
(4 rows)
他對應的計劃樹:
每個計劃節點都有執行器需要處理的信息,單表查詢的情況下,執行器從計劃樹的末端到根進行處理。
單表查詢的成本估計
PostgreSQL 的查詢優化是基于成本的。成本是無量綱值,它們不是絕對的績效指標,而是比較運營相對績效的指標。成本由costsize.c中定義的函數估算。執行器執行的所有操作都有相應的成本函數。例如,順序掃描和索引掃描的成本分別由 cost_seqscan() 和 cost_index() 估算。
有三種成本,啟動成本,執行成本以及總成本。其中總成本 = 啟動成本 + 執行成本。
啟動成本(start-up cost):從sql語句開始執行算子,到該算子輸出第一條元組為止,所需要的成本成為啟動成本。有的算子啟動成本很小,比如基本表上的掃描算子,一旦開始讀取數據也就可以輸出元組,因為啟動代價為0.有的算子的啟動成本相對較大,比如排序算子,他需要把所有下層算子的輸出全部讀取,并且把這些元組排序之后,才能輸出第一條元組,因此他的啟動成本比較大。
執行成本(run cost):從輸出第一條元組開始至查詢結束,所需要的成本部稱為執行成本。這個成本中又可以包含CPU成本,IO成本和通信成本。執行代價的大小與算子需要處理的數據量有關,與每個算子完成的功能有關。處理的數據量越大、算子需要完成的任務越重,則執行成本越大。
總成本(total cost):成本計算是一個自底向上的過程,首先計算掃描算子的代價,然后根據掃面算子的代價計算連接算子的代價,以及Non-SPJ算子的代價。
順序掃描
順序掃描的成本由 cost_seqscan() 函數估算。
其中 seq_page_cost、 cpu_tuple_cost 和 cpu_operator_cost在 postgresql.conf 文件中設置,默認值分別為1.0、0.01和0.0025,Ntuple和Npage分別是該表的所有元組和所有頁的編號。
從運行成本估算可以看出,PostgreSQL 假設所有頁面都將從存儲中讀取;也就是說,PostgreSQL 不考慮掃描的頁面是否在共享緩沖區中。
索引掃描
啟動成本
雖然 PostgreSQL 支持 一些索引方法,例如 BTree、 GiST、 GIN和 BRIN,但索引掃描的成本是使用常見的成本函數估算的:cost_index()。
索引掃描的啟動成本是讀取索引頁以訪問目標表中第一個元組的成本,它由以下等式定義:
Hindex是索引樹的高度。
運行成本
索引掃描的運行成本是表和索引的 cpu 成本和 IO(輸入/輸出)成本之和:
前三個成本定義如下:
其中 cpu_index_tuple_cost和random_page_cost 在 postgresql.conf 文件中設置(默認分別為 0.005 和 4.0); qual_op_cost粗略來說就是指數的評估成本,值為0.0025。選擇性選擇性是指定WHERE子句對索引的搜索范圍的比例;它是一個從 0 到 1 的浮點數
選擇率
查詢謂詞的選擇率是通過直方圖界值與高頻值估計的,這些信息都儲存在系統目錄pg_staticstics中,并可通過pg_stats視圖查詢。
表中的每一列的高頻值都在pg_stats視圖的most_common_vals和most_common_freqs中成對存儲。
- 高頻值:該列上最常出現的取值列表
- 高頻值頻率:高頻值相應出現頻率的列表
排序
排序路徑會在排序操作中被使用。排序操作包括order by、歸并連接的預處理操作,以及其他函數。函數cost_sort()用于估計排序操作的代價。如果能在工作內存中放下所有元組,那么排序操作會選用快速排序算法。否則就會創建臨時文件,使用文件歸并排序算法。
排序路徑的啟動代價就是對目標表的排序代價,因此代價就是O(Nsort) * Log2(Nsort),這里Nsort就是帶排序的元組數。排序路徑的運行代價就是讀取已經排序好的元組的代價,因此代價就是O(Nsort)。
創建單表查詢的計劃樹
PostgreSQL中的計劃器會執行三個步驟:
- 執行預處理
- 在所有可能的訪問路徑中,找出代價最小的訪問路徑
- 按照代價最小的路徑,創建計劃樹
訪問路徑是估算代價時的處理單元。比如順序掃描、索引掃描、排序,以及各種連接操作都有其對應的路徑。訪問路徑只在計劃器創建查詢計劃樹的時候使用。最忌本的訪問路徑數據結構就是relation.h中定義的path結構體,相當于順序掃描。所有其他的路徑訪問都基于該結構。
預處理
在創建計劃樹之前,計劃器將線對PlannerInfo中的查詢書進行一些預處理。預處理有很多步驟,本節值討論和單表查詢處理相關的主要步驟。
簡化目標列表,limit子句等
布爾表達式的規范化
-
壓平與/或表達式
SQL標準中的and/or是二元操作符,但他們在postgresql內部是多遠操作符。而計劃器總是會假設所有的嵌套and/or都應該被壓平。
找出代價最小的訪問路徑
計劃器對所有可能的訪問路徑進行代價估計,然后選擇代價最小的那個。
-
創建一個RelOptInfo數據結構,存儲訪問路徑及其代價。
RelOptInfo結構體是通過make_one_rel()函數創建的,并存儲于PlannerInfo持有者baserestrictinfo變量如果存在相應縮影,還會持有indexlist變量。baserestrictinfo存儲著查詢的where子句,而indexlist儲存著目標表上相關的索引。
-
估計所有可能訪問路徑的代價,并將訪問路徑添加至RelOptInfo結構中。這一處理過程的細節如下:
a. 創建一條路徑,估計該路徑中順序掃描的代價,并將其寫入路徑中。將該路徑添加到RelOptInfo結構的pathlist變量中。
b. 如果目標表上存在相關的索引,則為每個索引創建相應的索引訪問路徑。估計所有索引掃描的代價,并將代價寫入相應路徑中。然后將索引訪問路徑添加到pathlist變量中。
c. 如果可以進行位圖掃描,則創建一條位圖掃描訪問路徑。估計所有位圖掃描的代價,并將代價寫入路徑中,然后將位圖掃面路徑添加到pathlist變量中。
從RelOptInfo的pathlist中,找出代價最小的訪問路徑。
如果有必要,估計limit、order by和aggregate操作的代價。
創建計劃樹
在最后一步中,計劃器按照代價最小的路徑生成一顆計劃樹。
計劃樹的根節點是定義在plannodes.h中的Plannedstmt結構,包含19個字段,其中有4個代表性字段:
- commandType存儲操作的類型,注入select、update和insert。
- rtable儲存范圍表的列表(RangeTblEntry的列表).
- relationOids儲存與查詢相關表的oid。
- plantree存儲這一棵由計劃樹,每個計劃節點對應著一種特定操作,諸如順序掃描、排序和索引掃描。
計劃樹包含各式各樣的計劃節點。PlanNode是所有計劃節點的基類,其他計劃節點都會包含PlanNode結構。比如順序掃描節點SeqScanNode包含一個PlanNode和一個整型變量scanrelid。PlanNode包含14個字段,下面是7個代表性字段:
startup_cost和total_cost是該節點對應操作的預估代價。
rows是計劃器預計掃描的行數。
targetlist保存了該查詢樹中目標項的列表。
qual儲存了限定條件的列表。
lefttree和righttree用于添加子節點。
執行器是如何工作的
在單表查詢的例子中,執行器從計劃樹中取出計劃節點,按照自底向上的順序進行處理,并調用節點相應的處理函數。
每個計劃節點都有相應的函數,用于執行節點對應的操作。這些函數在src/backend/executor目錄中。
理解執行器如何工作的最好方式,就是閱讀explain命令的輸出。
我們可以自底向上閱讀explain的結果,來看一看執行器是如何工作的。
第六行:首先,執行器通過nodeSeqscan.c中定義的函數執行順序掃描操作。
第四行:然后,執行器通過nodeSort.c中定義的函數,對順序掃描的結果進行排序。
臨時文件
執行器在處理查詢時會使用工作內存和臨時緩沖區,兩者都在內存中分配。如果查詢無法在內存中完成,就會用到臨時文件。
使用帶有Analyze選項的explain,待解釋的命令會真正執行,并顯示實際結果行數、實際執行時間和實際內存使用量。
在第6行,explain命令顯示執行器使用了10000KB的臨時文件。臨時文件會被臨時創建在base/pg_tmp子目錄中,并遵循如下命令規則:{“pgsql_tmp”}+ {創建本文件的postgres進程pid}.{從0開始的序列號}
比如,臨時文件pgsql_tmp8903.5是pid為8903的postgres進程創建的第6個臨時文件。
連接
PostgreSQL中支持三種連接操作,分別是嵌套循環連接,歸并連接和散列連接。在pg中,嵌套循環連接和歸并連接有幾種變體。
這三種連接方式都支持pg中所有的連接操作,注入inner join、 left/right outer join、 full outer join等。
循環嵌套連接
循環嵌套連接不需要任何啟動代價,因此:start-up cost = 0
運行代價和內外表尺寸的乘積成比例,即run cost是O(Nouter * Ninner), Nouter和Ninner分別是外表和內表的元組條數。run cost的定義如下:
Couter和Cinner分別是內表和外表順序掃描的代價。
循環嵌套連接的代價總會被估計,但實際中很少會使用這種連接操作,因為它有幾種更高效的變體。
物化循環嵌套連接
在上面描述的循環嵌套連接中,每當讀取一條外表中的元組時,都需要掃描內標中的所有元組。位每條外表元組對內標做全表掃描,這一過程代價高昂,pg支持一種物化嵌套循環連接,可以減少內標全表掃描的代價。
在運行嵌套循環連接之前,執行器會使用臨時元組存儲模塊對內表進行一次掃描,將內表元組加載到工作或臨時文件中。在處理內表元組時,臨時元組存儲比緩沖區管理器更為高效,特別是當所有的元組都能放入工作內存中。
臨時元組存儲
qg內部提供了臨時元組存儲的模塊,可用于各種操作,如五花膘、創建混合散列連接的批次等。該模塊包含一系列函數,都在tuplestore.c中。這些函數用于從工作內存或臨時文件讀寫元組。該工作內存還是臨時文件取決于待存儲元組的總數。
上面顯示了執行器要進行的操作,執行器對這些計劃節點的處理過程如下:
第7行:執行器使用順序掃描,物化內部表tbl_b。
第4行:執行器執行嵌套循環連接操作,外表是tbl_a,內表是物化的tbl_b。
索引嵌套循環連接
如果內表上有索引,且該索引能用于搜索滿足連接條件的元組,那么計劃器在外外表的每條元組搜索內標中的匹配元組時,會考慮使用索引進行直接搜索,以替代順序掃描。這種變體叫做索引嵌套循環連接,如下圖所示。雖然這種變體叫做“索引嵌套循環連接”,但是誰該算法基本上只需要在外表上循環一次,因此連接操作的執行非常高效。
歸并連接
與嵌套循環連接不同的是,歸并連接只能用于自然連接與等值連接。
函數initial_cost_merge_join()和final_cost_merge_join()用于估計歸并連接的代價。
歸并連接的啟動成本是內表與外表排序成本之和,因此其啟動成本為:
這里Nouter和Ninner分別是外表和內標的元素條數,而運行代價是O(Nouter + Ninner)。
歸并連接
下圖是歸并連接的示意圖。
如果所有元組都可以存儲在內存中,那么排序操作就能在內存中進行,否則就是用臨時文件。
第9行:執行器對內表tbl_b進行排序,使用順序掃描(第11行)。
第6行:執行器對外表tbl_a進行排序,使用順序掃描(第8行)。
第4行:執行器執行歸并連接操作,外表是排序好的tbl_a,內表是排好序的tbl_b。
物化歸并連接
與嵌套循環連接類似,歸并連接還支持物化歸并連接,物化內表,使內表掃描更為高效。
下面是物化歸并連接的explain結果,很容易發現,與普通歸并連接的差異是第9行:Materialize。
其他變體
- 索引歸并連接
Hash連接
與歸并連接類似,hash連接只能用于自然連接與等值連接。
PostgreSQL中的散列連接的行為因表的大小而異。如果布標足夠小(確切的說,內表大小不超過工作內存的25%),那么hash連接就是簡單的兩階段內存hash連接,否則將會使用帶傾斜批次的混合hash連接。
內存Hash連接
內存中的hash連接是在work_mem中處理的,在pg中,散列表區域被稱作處理批次。一個批處理批次會有多個散列槽,內部稱其為桶,桶的數量由nodeHash.c中定義的ExecChooseHashTableSize()函數所確定。桶的數量是2的整數次冪。
內存散列連接有兩個階段,分別是構建階段和探測階段。在構建階段,內存表中的所有元組都會被插入到處理批次中;在探測階段每條腕表元組都會與處理批次中的內表元組比較,如果滿足連接條件,則將兩條元組連接起來。
帶傾斜的混合hash連接
當內表的元組無法全部存儲在工作內表中的單個處理批次時,pg使用帶傾斜批次的混合散列連接算法,該算法時混合散列連接誒的一種變體。
在第一個構建和探測階段postgresql準備多個批次,宇通的數目類似,處理批次的數據由函數ExecChooseHashTableSize()決定,也就是2的整數次冪。工作內存中智慧分配一個處理批次,而其他批次都以臨時文件的形式創建。屬于這些批次的元組將通過臨時元組存儲功能被寫入到相應的文件中。
連接訪問路徑與連接節點
連接訪問路徑
連接節點
- NestedLoopNode
- MergeJoinNode
- HashJoinNode
創建多表查詢和計劃樹
預處理
對CTE進行計劃與轉換。如果存在with列表,計劃器就會通過SS_process_ctes()函數對每個with查詢進行處理。
上拉子查詢。如果from子句帶有一個子查詢,且該表沒有用到group by,having、order by、limit和disinct、intersect或except,那么計劃器就會使用pull_up_subqueries()函數將其轉換為連接形式。
將外連接轉為內連接。如果可能的話,計劃器會將outer join查詢轉化為inner join查詢。
獲取代價最小的路徑
為了獲取最佳計劃樹,計劃器必須考慮各個索引與各種連接方法之間的所有可能組合。如果表的數量超過某個水平,該過程的代價就會因為組合爆炸而變得非常昂貴,以至于根本不可行。
如果表的數量小于12張,計劃器可以使用動態規劃來獲取最佳計劃。