跟我一起讀postgresql源碼(五)——Planer(查詢規划模塊)(下)


上一篇我們介紹了查詢規划模塊的總體流程和預處理部分的源碼。查詢規划模塊再執行完預處理之后,可以進入正式的查詢規划處理流程了。

查詢規划的主要工作由grouping_planner函數完成。在具體實現的時候,針對postgresql中獨有的繼承表,程序使用inheritance_planner函數來解決,該函數主要是先將繼承表的繼承關系變換為非繼承表來處理,然后仍然調用的是grouping_planner函數來完成查詢規划的工作。

因此,我們說查詢規划的主要工作在於grouping_planner函數。本篇的重點也是來解析該函數內部的調用關系和處理流程。



3.查詢規划處理

這里大家真的要做好准備,因為grouping_planner函數本身就有將近1000行~

那什么,我們還是先上圖吧。有圖更清楚,文字太多大家也會暈的。以下是grouping_planner函數的流程圖。

grouping_planner函數是生成查詢計划樹的主要函數。該函數首先要考慮查詢計划中是否有集合操作(可通過查詢樹的setOperation變量來判斷)。如果有則需要進行集合操作:遍歷setOperation,為其中的每一個子查詢生成計划。而對於非集合操作,計划的生成過程如下:

  1. 如果查詢含有GROUP BY子句,那么就調整其GROUP BY屬性的順序以匹配ORDER BY子句中的屬性順序(如果使用了grouping sets,則需要做進一步的變換,對於grouping sets,可以看這篇文章),這樣就可以使用一次排序操作同時實現排序和分組;

  2. 調用preprocess_targetlist預處理INSERT、UPDATE、DELETE和FOR UPDATE情況下的目標屬性;

  3. 計算並確定代表排序需求的路徑關鍵字,按優先級遞減排序主要有groupClause、WindowClause,distinctClause和sortClause;

  4. 調用query_planner函數為一個基本查詢創建路徑並獲得cheapest_path(代價最低執行路徑),再調用get_cheapest_fractional_path_for_pathkeys函數獲得sorted_path(對排序最優的路徑);

  5. 依據是否存在Hash等條件,確定最優路徑best_path;

  6. 生成可優化的MIN/MAX聚集計划;

  7. 如果6)中沒有生成聚集計划,則調用create_plan函數生成普通計划;

  8. 根據是否有GROUP BY子句、聚集操作、ORDER BY子句、DISTINCT子句和LIMIT子句等添加相應的計划節點.


3.1 生成路徑

SQL語句說白了就是從數據庫中獲取元組,之后再進行增刪改查的操作。因此對於一個查詢計划來說,重要的是告訴查詢執行模塊如何獲取到要操作的元組。而這些元組要么來自於某張表,要么來自於一些基本表連接而成的"連接表"。對於這些連接表來說,會存在多種不同的連接方式,從而形成多種連接樹(邏輯結構)。這樣的每一棵樹在postgresql中都成為一條路徑。查詢規划的目的莫過於從這些路徑中選取一條最優的路徑並生成對應的查詢計划。

而生成路徑的工作就是query_planner函數來完成的。不廢話,上圖。

在query_planner函數內部,首先我們要判斷一下由PlannerInfo封裝的查詢樹的jointree是否存在。為什么要做這樣的判斷呢?因為那些沒有jointree的查詢可能是"SELECT 2+2;"或者"INSERT ... VALUES()"這樣的形式。對於他們的處理和對於有jointree的處理是不一樣的。

對於沒有jointree的查詢樹,為了和有jointree的查詢樹在返回的路徑形式上保持一致,我們調用build_empty_join_rel函數來為它們構建一個空的join。這樣以后我們就直接調用create_result_path函數和add_path函數為其創建了一條"冗余"(因為這條路徑本來就不存在,數據並不在數據庫里面)的路徑。值得注意的是,我們仍然要為這樣的路徑做排序操作,因為我們可能會寫出這樣的查詢語句:"SELECT 2+2 ORDER BY 1" 。雖然是毫無意義的排序,但是我們還是要處理的不是么。至此,返回查詢路徑,函數退出。

而對於有jointree的查詢樹,情況一定是更復雜了。首先要為PlannerInfo里面各種參數以及各種JOIN(left_join,right_join,full_join等等)語句做初始化。然后我們再為查詢中的基本關系創建RelOptInfo節點(調用setup_simple_rel_arrays函數和add_base_rels_to_query函數)。然后,我們檢查targetlist和jointree,對於所有引用的變量,將其增加到相對應的"基本關系"的targetlist條目中(調用build_base_rel_tlists函數)。然后再在jointree中搜尋PlaceHolderVars並為它們創建PlaceHolderInfos(調用find_placeholders_in_jointree函數)。再將join語句對應到相關的關系表中。以上的這些是為了處理目標鏈表,方便后面的創建路徑。然后在處理好條件表達式和隱含條件,同時規范化好pathkeys的基礎上,我們再調用make_one_rel函數來創建並返回路徑,函數退出。(老實說,寫完以上這兩段,樓主的內心是崩潰的)

要理解該函數的操作,有一個數據結構是無論如何也無法回避的: RelOptInfo,定義如下:

typedef struct RelOptInfo
{
    NodeTag        type;
    RelOptKind    reloptkind;
	/* all relations included in this RelOptInfo */
	Relids		relids;			/* set of base relids (rangetable indexes) */
	/* size estimates generated by planner */
	double		rows;			/* estimated number of result tuples */
	int			width;			/* estimated avg width of result tuples */
	/* per-relation planner control flags */
	bool		consider_startup;		/* keep cheap-startup-cost paths? */
	bool		consider_param_startup; /* ditto, for parameterized paths? */
	/* materialization information */
	List	   *reltargetlist;	/* Vars to be output by scan of relation */
	List	   *pathlist;		/* Path structures */
	List	   *ppilist;		/* ParamPathInfos used in pathlist */
	struct Path *cheapest_startup_path;
	struct Path *cheapest_total_path;
	struct Path *cheapest_unique_path;
	List	   *cheapest_parameterized_paths;
	/* parameterization information needed for both base rels and join rels */
	/* (see also lateral_vars and lateral_referencers) */
	Relids		direct_lateral_relids;	/* rels directly laterally referenced */
	Relids		lateral_relids; /* minimum parameterization of rel */
	/* information about a base rel (not set for join rels!) */
	Index		relid;
	Oid			reltablespace;	/* containing tablespace */
	RTEKind		rtekind;		/* RELATION, SUBQUERY, or FUNCTION */
	AttrNumber	min_attr;		/* smallest attrno of rel (often <0) */
	AttrNumber	max_attr;		/* largest attrno of rel */
	Relids	   *attr_needed;	/* array indexed [min_attr .. max_attr] */
	int32	   *attr_widths;	/* array indexed [min_attr .. max_attr] */
	List	   *lateral_vars;	/* LATERAL Vars and PHVs referenced by rel */
	Relids		lateral_referencers;	/* rels that reference me laterally */
	List	   *indexlist;		/* list of IndexOptInfo */
	BlockNumber pages;			/* size estimates derived from pg_class */
	double		tuples;
	double		allvisfrac;
	/* use "struct Plan" to avoid including plannodes.h here */
	struct Plan *subplan;		/* if subquery */
	PlannerInfo *subroot;		/* if subquery */
	List	   *subplan_params; /* if subquery */
	/* Information about foreign tables and foreign joins */
	Oid			serverid;		/* identifies server for the table or join */
	/* use "struct FdwRoutine" to avoid including fdwapi.h here */
	struct FdwRoutine *fdwroutine;
	void	   *fdw_private;
	/* used by various scans and joins: */
	List	   *baserestrictinfo;		/* RestrictInfo structures (if baserel) */							
	QualCost	baserestrictcost;		/* cost of evaluating the above */
	List	   *joininfo;		/* RestrictInfo structures for join clauses involving this rel */
	bool		has_eclass_joins;		/* T means joininfo is incomplete */
} RelOptInfo;

該結構涉及baserel(基本關系)和joinrel(連接關系)的概念。這里baserel指的是一個普通表、子查詢或者范圍表中出現的函數。而joinrel是指兩個或者兩個以上的baserel的簡單合並。值得一提的是任何一組baserel只有一個joinrel,即只有一個RelOptInfo結構。對於joinrel本身來說,它並不關心baserel的連接順序,baserel的連接順序記錄在RelOptInfo結構的pathlist字段里面。

而對於pathlist字段,它是一個List類型,其中每一個節點都是一個Path類型的指針。一個Path就記錄了一條路徑。每條路徑描述了掃描表的不同方法(pathtype字段,比如有順序掃描T_SeqScan,索引掃描T_IndexScan等等)和元組排序的不同結果(pathkeys)。需要注意的是:

  • a) Path類型是一個"超類型",也就是說,它不是一個具體的路徑類型,他根據pathtype字段可以轉換為具體的路徑節點,例如T_IndexScan類型就對應着IndexPath數據結構。說白了,IndexPath這樣的數據結構封裝了Path類型,根據不同的pathype解析為不同的路徑類型,這也算是一種抽象吧,后面如果新增新的路徑類型,可以很方便的擴展。值得我們學習。

  • b) pathkeys是一個Pathkeys類型的鏈表,它描述了一個路徑下的排序信息。比如對於順序掃描來說,就沒有可用的排序信息,該值就為NIL,而對於索引掃描來說,我們顯然是按照索引的鍵值來按序訪問的,該值即記錄了索引的排序鍵值(好的代碼看起來就是讓人舒服)。

這樣看來,我們大概可以想到,路徑的邏輯結構是類似樹形的組織結構。上例子:

SELECT * FROM A ,B ,C
    WHERE A.a = B.a AND B.a = C.a AND A.b = 'XXX'

那么他的路徑很可能長這個樣子:

然而只是可能長這個樣子,具體長什么樣子,取決於很多因素,這就涉及到路徑的生成算法了。

生成路徑的工作由make_one_rel函數完成。make_one_rel函數非常簡短。主要的就兩步:

Step1:調用set_base_rel_sizes函數估算每個訪問路徑鎖涉及的記錄行數和屬性數,然后再調用set_base_rel_pathlists函數為每個基本關系生成一個RelOptInfo結構並生成路徑,該路徑放在RelOptInfo結構的pathlist字段里;

Step2:調用make_rel_from_joinlist函數生成最終的路徑。該函數生成一個RelOptInfo結構,它連接了上面提到的所有基本關系,並將最終路徑組成的鏈表寫入到pathlist字段里。說白了,這里就是把上面的基本關系的路徑串起來,獲得完整的路徑。

函數的調用很清晰,但是處理機制還是比較復雜的。你看首先每個表的訪問方式有順序、索引和TID,表間的連接方式也有嵌套循環連接、歸並連接和Hash連接等,多個表間的連接順序也有左連接、右連接和bushy連接。這樣一排列組合就有很多種路徑可以選擇。可以說當涉及的表數目較多時,這種排列組合的增長是爆炸式的!因此,如何選取一條執行效率最高,也即最優路徑就是路徑生成算法的核心問題了。對於這種可能解空間巨大的優化問題,我們想想也知道要用優化算法了。這里postgresql使用的是動態規划和遺傳算法這兩種策略。

(1)動態規划算法

在postgresql里,默認的是使用動態規划算法來獲得最優路徑的。關於動態規划方法,網上的介紹很多,原理我就不介紹了。在postgresql里使用的步驟如下:

1)初始狀態。在初始狀態下,為每一個待連接的baserel生成基本關系訪問路徑,選出最優路徑。這些關系成為第一層中間關系。把所有n個中間關系連接生成的的中間關系成為第n層關系;

2)歸納階段。已知第1~n-1層關系,用下列方法生成第n層關系:

  • a.將第n-1層關系與每個第i層的關系連接,計算當前的最優連接方法(1 <= i <= n/2);

  • b.從以上結果中選擇代價最小的連接順序作為第n層的路徑.

3)在生成的第n層關系里,選取最優的連接方法輸出。

在計算時,由於連接的順序和連接的方法都會直接或間接的影響查詢執行時的磁盤I/O和CPU時間。因此在生成路徑時需要同時考慮這兩個因素,計算總代價。在postgresql中,代價的計算是自底向上的。因此在具體實現時保留當前的最優路徑供上層節點使用。而最優路徑無法精確判斷,那么在每層節點中的RelOptInfo結構中都有cheapest_path、cheapest_total_path、cheapest_unique_path。分別代表啟動代價最優路徑、總代價最優路徑和最優唯一路徑。這些路徑供上層比較選用。

(2)遺傳算法

遺傳算法是一種啟發式的搜索算法,在解空間很大的情況下,能夠在一個合理的時間里獲得一個"較優"的解。關於遺傳算法的具體實現這里就不多說了,網上大把的例子和教程。這里提到遺傳算法是因為它是作為對動態規划算法的補充。動態規划算法很好,能總是獲得最優解。但是其算法時間復雜度比較高。尤其在基本關系數比較多的情況下,計算量會非常大,無法在合理的時間里獲取查詢規划。這里說極端點,假如我為了使查詢時間節省了1秒而在獲取最優查詢規划時多花了十幾秒這顯然是得不償失的。因此,這里有一個效率和准確度上的權衡。一般在基本關系超過閾值(默認值12)時,系統就開始使用遺傳算法來生成路徑。在配置文件里有兩個參數來控制。一個是enable_geqo,用來設置是否允許使用遺傳算法;另一個是geqo_threshold,用來控制閾值的大小。

以上是獲取最優路徑的算法。


3.2 生成可優化的MIN/MAX聚集計划

在上一小節我們完成了路徑的生成,接下來就可以進行計划的生成了。在這里,程序會處理一種比較特殊的查詢:查詢里含有MAX/MIN聚集函數並且聚集函數使用了建有索引的屬性或者該屬性在ORDER BY中指定了。在這種情況下,程序可以直接在索引或者排序好的元組上直接獲取MAX/MIN值對應的元組,否則可能就要掃描全表了。因此,這里程序就會判別一個查詢是否滿足以上條件,即可以直接讀取元組。如果回答是肯定的,則生成可優化的MIN/MAX聚集計划,否則轉到下一小節,生成普通的查詢計划。

這里,執行這一操作的函數是optimaze_maxmin_aggregates函數。該函數對於給定的路徑,判斷如果可以生成可優化的MIN/MAX聚集計划,則返回一個查詢計划並跳轉至distinct子句節點的計划處理,否則返回空值。這個函數主要做了這些事:

  1. 掃描查詢樹結構體(Query)中的hasAggs字段,如果存在聚集函數則進行下一步,否則跳至9);

  2. 繼續掃描查詢樹中的groupClause字段和hasWindowFuns字段分別判斷查詢中是否有GROUP BY和窗口函數,如果有則說明必須對表中的數據進行全表掃描,無法優化到直接獲取單個元組,程序跳至9),否則繼續下一步;

  3. 掃描查詢中的范圍表的個數,判斷是否為單表查詢,如果是則調用planner_rt_fetch函數讀取該范圍表進行下一步,否則跳至9);

  4. 判斷上一步的范圍表是否為非繼承的普通表,如果是則調用find_base_rel函數訪問該關系,否則跳至9);

  5. 調用find_minmax_aggs_walker函數在目標屬性和HAVING子句中查找聚集函數,如果不都是MIN/MAX聚集函數則跳至9);否則調用build_minmax_path函數查找是否可以使用索引對其優化並計算優化代價,此時如果不是所有的MIN/MAX聚集函數都可以被優化則也跳至9);

  6. 通過調用函數cost_agg評估不優化MIN/MAX聚集函數時的代價,如果優化后的代價比優化前要好則調用make_agg_subplan函數為每個MIN/MAX聚集函數生成優化后的子計划,否則跳至9);

  7. 分別調用函數replace_aggs_with_params_mutator函數將目標屬性和HAVING子句中的MIN/MAX聚集函數替換成對應的子計划,調用函數mutate_eclass_expressions將等價類中的MIN/MAX聚集函數替換為對應的子計划;

  8. 調用函數make_result生成最終的輸出計划節點並調用cost_qual_eval函數對目標屬性進行代價評估和更新計划節點中的代價評估值;

  9. 程序結束並返回空值,退出。


3.3 生成普通計划

如果optimize_minmax_aggregates函數返回值為空值,那么就要繼續生成普通執行計划了。grouping_planner函數調用create_plan函數來完成這一工作。同樣,該函數使用3.1節生成的最優路徑作為輸入,並根據路徑節點的不同,調用不同的函數完成計划的生成。要說明的是create_plan函數只是一個殼子,主處理交給了create_plan_recurse函數來遞歸地處理。而create_plan_recurse函數就是一個大的swith語句,負責判斷節點類型然后分發給不同的函數去處理。
主要地,普通計划分為三種大的類型,分別介紹如下:

(1)掃描計划。也就是訪問表的方式,種類如下:

T_SeqScan:
T_SampleScan:
T_IndexScan:
T_IndexOnlyScan:
T_BitmapHeapScan:
T_TidScan:
T_SubqueryScan:
T_FunctionScan:
T_ValuesScan:
T_CteScan:
T_WorkTableScan:
T_ForeignScan:
T_CustomScan:

這些交給create_scan_plan函數去生成掃描表的計划;

(2)連接計划。也就是表之間連接的方式,種類如下:

T_HashJoin
T_MergeJoin
T_NestLoop

這些交給create_join_plan函數去生成表間連接的計划;

(3)其它計划。比如Append計划、Result計划和Material計划等。這些分別使用create_xxx_plan函數去處理。


3.4 生成完整計划

總之,通過上面的內容我們總算根據best_path路徑獲得了計划樹。但是,我們需要注意的是,生成的路徑只考慮了基本查詢語句的信息(即主干信息),並沒有保留GROUP BY、ORDER BY這些信息。因此。在獲得基本的查詢計划樹之后,我們還需要添加相應的查詢計划樹節點使之完整。

我們回到最開頭的那張圖,我們看到主要要處理的是Agg、Group計划節點;Distinct計划節點;Sort計划節點和Limit計划節點。

對於Agg節點,調用make_agg函數來生成Agg類型的結構並連接到計划樹;來個圖吧:

其他的節點也類似的調用make_xxx函數來生成對應的節點結構來進行處理。
值得注意的是,在生成節點結構的時候也同時計算該節點的代價。



4.整理計划樹

生成完整計划之后,還不能直接交給查詢執行器去執行,還需要對計划樹進行整理。這樣做主要是為了方便執行模塊的執行,對查詢計划樹上的一些細節進行整理加工。負責該項工作的主要函數是set_plan_references。

主要做了這幾件事:

  • 1.把子查詢里面所涉及到的相關關系表放進一個單獨的list里,並清空那些在查詢規划樹里對查詢執行模塊無用的RTE域的值;

  • 2.調節掃描節點中的Var結構,使得其與范圍表一致;

  • 3.調節上層計划節點中的Var結構,令其成為對子計划節點輸出的引用;

  • 4.用PARAM_EXEC參數來替換PARAM_MULTIEXPR,這樣就完成了所有MULTIEXPR子計划;

  • 5.計算操作符的OID;

  • 6.創建一個列表,用來存放查詢計划所依賴的特殊對象(目前主要存儲關系和用戶定義函數);

  • 7.刪除無用的子查詢計划節點.



5.總結

是的,這篇廢話很多的文章到這就結束了,很不甘心,查詢規划部分有很多內容值得細講,講的時候有的講的比較多,有的講的很少。而且關鍵是自己的理解很不是特別清晰。查詢規划過程中涉及到了很多的數據結構和方法。樓主我也沒有一一的細講。總之這篇趕得比較緊,有很多圖表到后來都沒有細細的畫了,作為強迫症真有點不舒服。奈何!先這樣吧,后續再對許多為完全理解的地方再一一解釋吧,也是給自己一個交代。

也是第一次寫這樣長的文章,大家多擔待了。

祝大家周末愉快~


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM