深入淺出openGauss的執行器基礎
火山模型
執行器各個運算元解耦合的基礎。對於每個運算元來說,只有三步:
-
向自己的孩子拿一個 tuple。即呼叫孩子節點的 Next 函式;
-
執行計算;
-
向上層返回一個 tuple。即當前節點 Next 函式的返回結果。
所以整個執行器的核心可以用下面這個虛擬碼來表達。
ExecutePlan { for (;;) slot = ExecProcNode(planstate); ------->if (node->chgParam != NULL) ExecReScan(node); result= g_execProcFuncTable[index](node) // 表驅動,每個運算元不同的執行函式 return result; if (TupIsNull(slot)) { ExecEarlyFreeBody(planstate); break; } // 返回給前端 }
這種模型的好處是:
-
設計簡單,運算元解耦,互相不依賴;
-
記憶體使用量小,沒有物化的情況下,每次只消耗一個 tuple 的記憶體。
Tuple 資料結構設計
兩個運算元之間的傳遞的都是 tuple。所以 Tuple 資料結構是整個執行器的核心,也是執行器和儲存引擎互動的資料結構。
先看下幾個具體資料結構之間的關係(附上關鍵的變數,非全部)
// 存在磁碟上的資料結構,是 header + 資料 的一片連續記憶體。設計要求儘可能緊湊,節約儲存空間。 // 只有事務資訊等,其它比如長度、每一列起始地址等都不會直接存,而是算出來的。 HeapTupleHeaderData t_xmin; // 事務資訊 t_xmax; t_cid; t_ctid; ... // 磁碟上的 Tuple 資料結構過於緊湊,不好用。所以設計了記憶體中一個 tuple 的物件,會存一些額外(冗餘)的後設資料資訊方便處理。 // 資料和後設資料可以不連續。這個資料結構一般是儲存引擎內部用,比如可見性判斷等。 HeapTupleData t_len; // 資料長度 tupTableType;// store 型別 t_self; // ctid t_tableOid; ... HeapTupleHeader t_data; // tuple header data 的地址 // 可以理解為 Tuple 槽位,執行器用。儲存引擎不關心具體每一列的內容,只關心事務、長度等公共資訊。 // 而執行器可能需要訪問每一列,所以在這裡把 tuple 進一步拆解開。 TupleTableSlot Tuple tts_tuple; // 物理 tuple 位置(HeapTupleData);虛擬 tuple 的話為 null。 TupleDesc tts_tupleDescriptor; Buffer tts_buffer; Datum* tts_values; // 陣列,表示每一列的值(如果是 int 就是值,如果是 varchar 就是地址) bool* tts_isnull; // 陣列,表示每一列是否是 null TableAmType tts_tupslotTableAm // 處理物理 tuple 的函式(astore 和 ustore 不同)
以上圖為例,最下層的 seqscan,會呼叫儲存引擎的 heap_getnext 函式(astore)。
// 程式碼中用的函式指標,實際呼叫棧如下 ExecScanFetch --->SeqNext --------->seq_scan_getnext_template // 儲存引擎入口 tuple = (Tuple)heap_getnext // 拿到 HeapTuple // 組裝TupleTableSlot,但是 attr 的值還沒置上,得在 heap_slot_getattr 裡設定。 heap_slot_store_heap_tuple(htup, slot, scan->rs_cbuf, false, false); heap_getnext ---> heapgettup HeapTuple tuple = &(scan->rs_ctup); // tuple 是 scan 結構體中的變數。 LockBuffer(scan->rs_base.rs_cbuf, BUFFER_LOCK_SHARE); // 鎖住 buffer dp = (Page)BufferGetPage(scan->rs_base.rs_cbuf); lpp = HeapPageGetItemId(dp, line_off); tuple->t_data = (HeapTupleHeader)PageGetItem((Page)dp, lpp); // t_data 的地址就是 buffer 中的地址 ItemPointerSet(&(tuple->t_self), page, line_off); // 設定 tuple 中的一些變數 valid = HeapTupleSatisfiesVisibility(tuple, snapshot, scan->rs_base.rs_cbuf); // 用作可見性判斷 return &(scan->rs_ctup); // 把物理 tuple 中的資料,複製到 tupleTableSlot 中。這裡是 astore 的解析函式,ustore 不一樣。 heap_slot_getattr // 核心思想是利用 TupleDesc 知道表的定義,然後從0 開始一個 attr 一個 attr 地解析。 // 對於定長欄位,直接按地址取值;對於變長欄位,長度會存在原始的資料頭中。 // 因此,之前的物理 tuple 不需要存長度這種資訊;同時,也需要 tupleTableSlot 這樣的變數來快取具體資料的起始地址。
以上就是儲存引擎和執行引擎之間 tuple 怎麼互動的。總結下就是儲存引擎會把實際資料所在的地址傳給上層,最後執行引擎拿到的結構體為TupleTableSlot。
所以,執行引擎只關心 TupleTableSlot 這一個結構體即可。
一個很自然的問題就是,如果運算元之間的 Tuple 都是深複製傳遞,對於較大的 tuple 來說(包含 varchar 型別),效能很差。因此,PG 中的 tuple 分了4類(詳見標頭檔案tuptable.h):
-
第一類 tuple 是 Disk buffer page 上的 physical tuple,就是前文的 HeapTuple。Buffer 一定要 pin 住。這種 Tuple 可以直接根據頭地址進行訪問。
-
記憶體中的組裝的tuple,格式和檔案上 tuple 完全一樣,也是進行過壓縮。這種也算是 physical tuple,可以直接用地址。
-
Minimal physical tuple,也是記憶體中的,區別在於沒有系統列(xmin、xmax 等)。
-
virtual tuple,只記錄每一個屬性資料的地址,並沒有深複製,而是直接透過地址來訪問。現在約定的是,當一個運算元向上層吐一個 tuple,直到它下次被呼叫時,該tuple所在的記憶體不會被釋放。
對於查詢來說,第一類和第四類最為常見。2、3兩類會在物化的時候使用,比如 CTEScan、HashJoin 建立 hash 表的時候,相當於深複製。效能比較敏感的場合,儘量避免2、3類 tuple的使用。
slot 的建立一般透過 ExecAllocTableSlot、ExecSetSlotDescriptor 兩個函式來分配記憶體和初始化資訊。在執行器初始化階段,每個運算元會分配相應的 slot。
以上圖為例,
-
SeqScan 運算元有一個 tupleTableSlot,是一個 physical tuple,指向的是 Buffer 中的地址。
向上層返回自己的 tuple slot;
-
HashJoin 一個一個拿到 內表的 tuple slot,需要建立 hash 表,所以建立了一個 minimal physical tuple,複製內表的 tuple 內容;
-
Hash表建立後,HashJoin 運算元然後一個一個拿到外表的 tuple slot,做 join 計算。
HashJoin 自己有一個 tuple slot,如果碰到匹配,則把自己的 tuple slot 設定成 virtual tuple,其中的 tts_values 指向的是孩子節點的 tuple 中的地址。
再向上返回。
其中,外表的內容指向的是 Buffer 上的physical tuple, 內表的內容指向的是 hash 表中的 minimal physical tuple;
-
當 HashJoin 被再次呼叫時,它會重置 tuple slot。因為是 virtual tuple,所以沒做任何事情。然後 HashJoin 會呼叫 SeqScan 拿下一條tuple;
-
SeqScan 被再次呼叫時,也會重置 tuple slot。
因為是 physical tuple,它需要釋放之前的 Buffer。
(當然,如果一直是同一個 Buffer 不會反覆 pin/unpin,這是儲存引擎的最佳化)。
條件計算
Expr 和 Var
執行器每個運算元會對底下傳上來的 tuple 進行計算和過濾。比如 NestLoop 需要計算內外表傳上來的 tuple 是否滿足 join 條件。
這時候需要引出第二個重要的概念,表示式的抽象。個人理解,任何對資料的獲取操作都可以認為是表示式。
Var/Const 也是表示式的一種。Var 表示直接從 tuple 中獲取資料,Const 表示的是直接獲取一個常數。
每一個表示式會對應一個 ExprContext,ExprContext 中會記錄計算所需要的所有資料,一般是孩子節點返回的 tuple。
表示式本身,在執行器中用 ExprState 來表示。裡面重點是表示式的計算函式
// 因為當前執行模型中,每個運算元最多隻有兩個孩子節點,所以下面三個變數用的最多。 struct ExprContext { TupleTableSlot* ecxt_scantuple; // scan 運算元會用到 TupleTableSlot* ecxt_innertuple; // 非 scan 運算元如 join TupleTableSlot* ecxt_outertuple; ... } // 表示式計算的結構體 struct ExprState { Expr* expr; // 原始的表示式 ExprStateEvalFunc evalfunc; // 表示式對應的函式 } // 執行器開始階段,會透過最佳化器傳過來的 Expr,初始化 ExprState 結構 ExecInitExpr { case T_Var: ExecInitExprVar state = (ExprState *)makeNode(ExprState); state->evalfunc = ExecEvalScalarVar; // 內部實現是直接去取對應 tuple slot 上的 attr case T_OpExpr: FuncExprState* fstate = makeNode(FuncExprState); fstate->xprstate.evalfunc = (ExprStateEvalFunc)ExecEvalOper; // 內部實現是先遞迴呼叫 ExecEvalExpr, 獲取引數列表,再呼叫 function fstate->args = (List*)ExecInitExpr((Expr*)opexpr->args, parent); }
總結下,ExprState 結構體表示表示式計算的邏輯,ExprContext結構體表示的是表示式計算要用到的資料。
從 OpExpr 可以看出,ExprState 本身也是一棵樹。一直遞迴呼叫 ExecEvalExpr 來獲取最終的結果。
需要注意的是,執行樹中除了葉子節點上的掃描節點,其它節點的資料都來源於孩子節點。
所以,這些計算節點上的 Var,不能直接指向某個 table,而是需要指向的是內表還是外表的 tuple slot。
因此,在最佳化器最後的階段,set_plan_refs 函式中,會把中間節點的 Var 的 varno 改寫成特定的值。
而 Var 的表示式處理函式 ExecEvalScalarVar 也是根據這個資訊決定去找 ExprContext 中的 哪個 tuple slot。
表示式如下:
示例1 filter
以 SeqScan 為例,在最佳化器階段, SeqScan 上會有一個 qual,表示過濾條件。在執行器階段會生成一個對應的 ExprState,用於計算。
// 執行器初始化階段,會根據最佳化器裡的 Expr 構造 ExprState
ExecInitSeqScan
InitScanRelation
node->ps.qual = (List*)ExecInitQualWithTryCodeGen((Expr*)node->ps.plan->qual, (PlanState*)&node->ps, false);
// 執行階段
ExecSeqScan
ExecScan
qual = node->ps.qual;
slot = ExecScanFetch(node, access_mtd, recheck_mtd); // 從儲存引擎那裡拿到 slot
econtext->ecxt_scantuple = slot; // 設定好 ExprContext
ExecNewQual(qual, econtext) // 呼叫 ExprState 進行計算
ret = ExecEvalExpr((ExprState*)qual, econtext, &isNull, NULL);
return DatumGetBool(ret);
示例2 join
以 Nestloop 為例,最佳化器結束的時候,join 節點會有一個 joinqual 表示 join 條件。
ExecInitNestLoop
nlstate->js.joinqual = (List*)ExecInitQualWithTryCodeGen((Expr*)node->join.joinqual, (PlanState*)nlstate, false);
ExecNestLoop
// 拿到內外表的 tuple slot,設定在 ExprContext 上
econtext->ecxt_innertuple =ExecProcNode(inner_plan);
econtext->ecxt_outertuple = ExecProcNode(outer_plan);
List* joinqual = node->js.joinqual;
// 呼叫 ExprState 中的函式,如果符合 join 條件,則向上層返回一個 tuple。
if (ExecNewQual(joinqual, econtext)) {
result = ExecProject(node->js.ps.ps_ProjInfo, &is_done);
return result
}
示例3 index scan & index only scan
# Index on t(a,b,c) # select a,b,c from t where a = 1 and c = 1; Index Only Scan using t_a_b_c_idx on t (cost=0.15..8.26 rows=1 width=12) Index Cond: ((a = 1) AND (c = 1)) # select a,b,c from t where a = 1 and c <> 1; Index Only Scan using t_a_b_c_idx on t (cost=0.15..32.35 rows=10 width=12) Index Cond: (a = 1) Filter: (c <> 1) # select * from t where a = 1 and c <> 1 and d = 1; Index Scan using t_a_b_c_idx on t (cost=0.15..32.36 rows=1 width=16) Index Cond: (a = 1) Filter: ((c <> 1) AND (d = 1))
之前很多人搞不清楚這裡面 index cond/ filter 是什麼關係。但是,透過執行器原始碼很容易得知它們的用處。先看 IndexOnlyScan
## 首先,透過 Explain 可以看出來,Index Cond 顯示的是 plan->indexqual, Filter 顯示的是 plan->qual。
optutil_explain_proc_node:
caesT_IndexOnlyScan:
optutil_explain_show_scan_qual(((IndexOnlyScan *)plan)->indexqual, "Index Cond", planstate, ancestors, es);
optutil_explain_show_scan_qual(plan->qual, "Filter", planstate, ancestors, es);
## 透過 Init 函式,發現 這兩個 qual 分別被初始化為 Expr,賦給 ss.ps.qual 和 indexqual 上面。
ExecInitIndexOnlyScan
indexstate->ss.ps.qual = ExecInitExpr((Expr *) node->scan.plan.qual, (PlanState *) indexstate);
indexstate->indexqual = (List *) = ExecInitExpr((Expr *) node->indexqual,
// 用 indexqual 來做Scan 的key
ExecIndexBuildScanKeys((PlanState *) indexstate,
indexstate->ioss_RelationDesc,
node->indexqual....
## 在執行的時候發現,indexqual 在索引掃描內部使用, ps.qual 是用在掃描之後的 check 中。
ExecIndexOnlyScan
--->ExecScan
qual = node->ps.qual; // 注意,這裡用的是 qual。
slot = ExecScanFetch(node, accessMtd, recheckMtd);
----> IndexOnlyNext
tid = index_getnext_tid(scandesc, direction) // 呼叫 btree 相關函式
// 用 IndexTuple 來填充 scan 的 slot (IndexScan是根據 tid 回表去 heap 上讀取)
StoreIndexTuple(slot, scandesc->xs_itup, scandesc->xs_itupdesc);
// 用 qual 做過濾條件
if (!qual || ExecQual(qual, econtext, false))
返回 slot
btgettuple
_bt_steppage
_bt_readpage
_bt_checkkeys // 每個頁面會挨個檢查一下 ScanKey 的條件是否滿足
總結:
-
Index Cond 是用來做 btree 掃描的 key,定位到第一個 IndexTuple。儲存引擎中用
-
之後索引掃描會順著 btree 的連結串列掃描所有的葉子頁面,對葉子頁面上的每一個 tuple 用 ScanKey 檢查是否滿足條件,滿足再返回
-
Filter 是在 執行器層用,針對 HeapTuple 再做一次過濾
-
IndexOnlyScan 和 Index Scan的區別是,IndexOnlyScan 的HeapTuple是根據IndexTuple直接構建的,不需要回表,其它邏輯是一樣。
-
所以理論上講 IndexOnlyScan 不應該出現 filter,上述場景可能有改進空間。
來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70023856/viewspace-2946403/,如需轉載,請註明出處,否則將追究法律責任。
相關文章
- vuejs深入淺出—基礎篇VueJS
- 深入淺出RxJava(一:基礎篇)RxJava
- 深入淺出RxJava(1):基礎篇RxJava
- 深入淺出JavaScript執行機制JavaScript
- 深入淺出Java多執行緒Java執行緒
- 深入淺出 Java 執行緒池Java執行緒
- 深入淺出Java多執行緒(十二):執行緒池Java執行緒
- Java基礎 Java-IO流 深入淺出Java
- 深入淺出Java執行緒池:使用篇Java執行緒
- 深入淺出Java執行緒池ThreadPoolExecutorJava執行緒thread
- 深入淺出MyBatis:MyBatis解析和執行原理MyBatis
- 深入淺出Java多執行緒(十):CASJava執行緒
- 深入淺出Java多執行緒(十一):AQSJava執行緒AQS
- 深入淺出JVM(七)之執行引擎的解釋執行與編譯執行JVM編譯
- openGauss執行器技術
- 深入淺出Java執行緒池:原始碼篇Java執行緒原始碼
- 深入淺出執行緒池+高階選項的使用執行緒
- Linux下Shell基礎知識深入淺出(轉)Linux
- 深入淺出Java多執行緒程式設計(轉)Java執行緒程式設計
- 深入淺出Java多執行緒(十三):阻塞佇列Java執行緒佇列
- 深入淺出執行緒池 | 京東雲技術團隊執行緒
- 深入淺出 Java 同步器Java
- 深入淺出Win32多執行緒程式設計--之MFC的多執行緒Win32執行緒程式設計
- 深入淺出FE(十四)深入淺出websocketWeb
- 深入JavaScript基礎之深淺拷貝JavaScript
- 深入淺出瀏覽器渲染原理瀏覽器
- 深入淺出Lua虛擬機器虛擬機
- 反射的深入淺出反射
- 深入淺出this的理解
- 瀏覽器執行javaScript程式碼基礎瀏覽器JavaScript
- 執行緒基礎執行緒
- 【最新版】Java基礎視訊精華版深入淺出(有原始碼)Java原始碼
- 評侯捷的《深入淺出MFC》和李久進的《MFC深入淺出》
- 深入淺出java的MapJava
- 深入淺出Seata的AT模式模式
- 深入淺出 JavaScript 中的 thisJavaScript
- 從零開始瞭解多執行緒 之 深入淺出AQS -- 上執行緒AQS
- 深入淺出——MVCMVC