阿里明虛:RDS for PostgreSQL加入Plan Hint功能

jopen 8年前發布 | 8K 次閱讀 SQL PostgreSQL

有一個功能,是社區官方版"永遠"不考慮引入的(參見 PG TODO ,查找"Oracle-style"),即類似Oracle的Plan Hint。社區開發者的理念是,引入Hint功能,會掩蓋優化器本身的問題,導致缺陷不被暴露出來。但對于我們的使用者來講,遇到某些SQL的查詢計劃不好,性能出了問題,使用了其他方法又不奏效的情況下,首先的目標還是想盡快解決問題。而Hint就可以在這種時候幫助到我們。可喜的是,通過集成“民間”的pg_hint_plan插件( 文檔 ),RDS for PG已經支持了Hint功能(RDS for PPAS也是支持的)。現在我們來研究一下這個插件如何使用,又是如何做到改變優化器優化過程,讓優化器聽我們“指揮”的。

使用

Plan Hint在RDS for PG里面缺省是沒有打開的,可以LOAD命令啟用:

postgres=# LOAD 'pg_hint_plan';
LOAD

但注意這只在會話級別有效,重新連接后將失效。如果想要每次連接都自動啟用Hint,可以使用下面的命令(注意必須以RDS的根用戶執行,否則會遇到權限錯誤)。這樣,下次連接時,Hint就默認啟用了。

postgres=> alter role all set session_preload_libraries = 'pg_hint_plan';
ALTER ROLE

為了便于說明,我們使用下面的shell腳本來創建2張表:

for i in seq 2 ; do

psql -c "drop table t${i}" psql -c "create table t${i}(a int, b int);" psql -c "insert into t${i} select generate_series(1,1000), random() *1000+1" psql -c "create index t${i}_i_a on t${i}(a)" psql -c "create index t${i}_i_b on t${i}(b)"

done</code></pre>

然后在t1上進行查詢,不使用和使用Hint的查詢計劃分別如下:

postgres=> explain select * from t1 where a = 1;

                       QUERY PLAN

Index Scan using t1_i_a on t1 (cost=0.28..8.29 rows=1 width=8) Index Cond: (a = 1) (2 rows)

postgres=> /+ SeqScan(t1) / explain select * from t1 where a = 1;

                QUERY PLAN

Seq Scan on t1 (cost=0.00..17.50 rows=1 width=8) Filter: (a = 1) (2 rows)</code></pre>

可以看出,利用Hint后,我們成功強制使用了表掃描。Hint一般以SQL注釋的形式,出現在SQL的前面,并以/*+開頭,以*/結尾。注意/*和+之間不能有空格。

Hint的種類

pg_hint_plan插件支持的Hint有很多種,分成如下幾類(具體參見 pg_hint_plan文檔 ):

掃描類(Scan Method),指定表的訪問路徑,舉例如下:

--順序掃描,參數為表名,也可以帶模式名 SeqScan(t1) --索引掃描,參數為表名和索引名,注意兩者之間是空格,沒有逗號 IndexScan(t1 t1_i_a) --TID掃描 TidScan(t1) --禁止順序掃描 NoSeqScan(t1) --禁止索引掃描 NoIndexScan(t1)

連接類(Join Method),指定表連接的方法,舉例如下:

NestLoop(t1 t2) MergeJoin(t1 t2) HashJoin(t1 t2)

連接順序類(Join Order),指定連接的順序,舉例如下: --使t3和t1先連接,最后和t2連接 Leading(t2 (t3 t1))

SET類,即改變任意的GUC變量,舉例如下: --改變random_page_cost Set(random_page_cost 3.0)

ROW類型,改變表的連接結果集的估計大小,舉例如下: --將t1和t2的連接結果的估計大小擴大10倍 Rows(t1 t2 *10)</code></pre>

內核實現

看完了形形色色的Hint,我們會想,這些Hint是怎么改變復雜的優化器邏輯,使其生成我們需要的查詢計劃的呢?我們從其源碼看起(源碼可以從 這里 下載)。

插件主要的代碼集中在pg_hint_plan.c里面。從其中PG_init函數的代碼可以看出,它利用了planner_hook(優化器的函數鉤子,實際上是全局變量,存放函數地址,可以被插件更改,換成插件自定義函數的地址),用pg_hint_plan_planner取代了原來的優化器邏輯。這樣,PG在處理一個SQL時,將調用pg_hint_plan_planner來做優化。而pg_hint_plan_planner會調用get_hints_from_comment,來讀取Hint,并調用create_hintstate進行語法分析。這里要說明的是,create_hintstate遇到一張表上的多個同類型Hint(包括重復的Hint),只保留最后一個,前面的會忽略。

另外,還有兩個函數鉤子被利用:get_relation_info_hook和join_search_hook。這兩個鉤子分別被修改指向了pg_hint_plan_get_relation_info和pg_hint_plan_join_search。前者是在優化器處理基本表(非視圖、非函數的表)獲取表信息時被調用,調用棧如下:

query_planner -> add_base_rels_to_query -> build_simple_rel -> get_relation_info -> get_relation_info_hook(即pg_hint_plan_get_relation_info)

這個pg_hint_plan_get_relation_info做了什么呢?仔細看會驚訝的發現,它是用來刪除索引的!對,它在優化器獲取表的基本信息后被調用,然后其從基本信息刪除了那些在Hint中未使用的索引。例如,t1上有兩個索引t1_i_a和t1_i_b,如果指定了IndexScan(t1 t_i_b)這個Hint,那么t1_i_a的索引信息在這里被刪除,這樣在后續的優化中,就永遠不會考慮t1_i_a這個索引了!

再看pg_hint_plan_join_search,其被調用的位置如下:

query_planner -> make_one_rel -> make_rel_from_joinlist ->join_search_hook(即pg_hint_plan_join_search)

可見,它是在為一個SQL語句生成連接結果時被調用,其輸入為待連接的表,輸出為連接后生成的表及其最優的查詢計劃。它主要做了兩件事:

1)調用rebuild_scan_path重新生成基本表的訪問路徑。為什么要重新生成呢?因為在基本表的訪問計劃生成階段,掃描類的Hint并未實際起作用(只是對索引做過刪除處理)。例如,即使指定了IndexScan(t1 t1_i_a),但外部的GUC變量enable_indexscan被設置為了off,在這里也只會看到一個表掃描(SeqScan)的查詢計劃。因此這里需要重新設置好GUC變量(例如如果遇到IndexScan Hint,需要把GUC變量enable_indexscan重置為on),再做一遍訪問計劃。由于基本表一般數量較少,訪問計劃也只需再生成一次,所以此步開銷是可接受的。

2)調用pg_hint_plan_standard_join_search生成連接的計劃。這里是應用連接方法和連接順序Hint的地方。要想改變連接方法或順序,需要進一步修改優化器的整個邏輯,但優化器沒那么多的預定義鉤子可用了,采用函數鉤子的方法不可行。于是,插件便“自備”了優化器的主流程代碼(其實是從同版本的PG里面拷貝出來的),見插件代碼中的core.c和make_join_rel.c兩個文件。里面很多地方是被插件修改過的。其中核心的是修改對add_paths_to_joinrel的調用,使優化器實際調用add_paths_to_joinrel_wrapper。這個函數是用于為輸入的兩張表(可能是連接生成的中間表),生成一個連接計劃。可以看到add_paths_to_joinrel_wrapper會先去查找有沒有對應的Hint,如果有就直接利用,并舍棄掉不符合Hint的連接方法和順序(這是連接順序Hint其作用的地方)。

可以看到,此插件的實現并不復雜,它巧妙利用了優化器優化流程中的關鍵點,來應用Hint,達到固定查詢計劃的目的。

性能測試

從內核實現可以看出,指定Hint后會帶來如下開銷:基本表的訪問路徑要生成兩次;每次連接兩個中間表時,要檢查是否有對應的Hint;很多地方需要反復更新GUC變量來影響計劃生成。當然,由于直接指定了表的連接方法、順序等,減少了生成的中間計劃,這一點又節省了很多開銷。所以,對使用Hint后的編譯時間是否比原來長,不能一概而論。下面,我們對Hint造成的編譯開銷做一下粗略測試。測試用例如下:

\timing

DO $$DECLARE count int; BEGIN count := 1; LOOP count := count + 1;

    begin

        EXECUTE 'explain select * from t1,t2,t3,t4,t5,t6,t7,t8 where t1.a=t2.b and t2.a=t3.b and t3.a=t4.b and t4.a=t5.b and t5.a=t6.b and t6.a=t7.b and t7.a=t8.b';

        IF count > 10000 THEN
            EXIT;
        END IF;
    exception when others then

    end;
END LOOP;

END$$;

DO $$DECLARE count int; BEGIN count := 1; LOOP count := count + 1;

    begin

        EXECUTE '/*+  IndexScan(t1 t1_i_a) IndexScan(t2 t2_i_a) IndexScan(t3 t3_i_a) IndexScan(t4 t4_i_a) IndexScan(t5 t5_i_a) IndexScan(t6 t6_i_a) IndexScan(t7 t7_i_a) IndexScan(t8 t8_i_a)  Leading ( t1  t2  t3  t4  t5  t6  t7  t8 )  */ explain select * from t1,t2,t3,t4,t5,t6,t7,t8 where t1.a=t2.b and t2.a=t3.b and t3.a=t4.b and t4.a=t5.b and t5.a=t6.b and t6.a=t7.b and t7.a=t8.b' ;

        IF count > 10000 THEN
            EXIT;
        END IF;
    exception when others then

    end;
END LOOP;

END$$;</code></pre>

這里我們使用了8張表,每張表都只有a,b兩個int字段。用兩個DO語句,每個都執行同一SQL語句10000次。一個DO語句是不帶Hint的,另一個帶了較復雜的Hint。測試結果,不帶Hint的執行耗時17秒左右,帶Hint的14秒左右。即帶Hint的反而編譯時間更短(注意這里只執行了explain,未真正執行SQL語句)。

</div>

來自: http://yq.aliyun.com/articles/3072?spm=5176.100239.yqblog1.59.YItNDO

 本文由用戶 jopen 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!