深入淺出openGauss的執行器基礎

小侃資料庫發表於2023-04-18

火山模型

執行器各個運算元解耦合的基礎。對於每個運算元來說,只有三步:

  1. 向自己的孩子拿一個 tuple。即呼叫孩子節點的 Next 函式;

  2. 執行計算;

  3. 向上層返回一個 tuple。即當前節點 Next 函式的返回結果。

深入淺出openGauss的執行器基礎

所以整個執行器的核心可以用下面這個虛擬碼來表達。

ExecutePlan
{
for (;;)
slot = ExecProcNode(planstate);
------->if (node->chgParam != NULL)
           ExecReScan(node);
        result= g_execProcFuncTable[index](node)  // 表驅動,每個運算元不同的執行函式
        return result;
if (TupIsNull(slot)) {
   ExecEarlyFreeBody(planstate);
   break;
}
// 返回給前端
}


這種模型的好處是:

  1. 設計簡單,運算元解耦,互相不依賴;

  2. 記憶體使用量小,沒有物化的情況下,每次只消耗一個 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):

  1. 第一類 tuple 是 Disk buffer page 上的 physical tuple,就是前文的 HeapTuple。Buffer 一定要 pin 住。這種 Tuple 可以直接根據頭地址進行訪問。

  2. 記憶體中的組裝的tuple,格式和檔案上 tuple 完全一樣,也是進行過壓縮。這種也算是 physical tuple,可以直接用地址。 

  3. Minimal physical tuple,也是記憶體中的,區別在於沒有系統列(xmin、xmax 等)。

  4.  virtual tuple,只記錄每一個屬性資料的地址,並沒有深複製,而是直接透過地址來訪問。現在約定的是,當一個運算元向上層吐一個 tuple,直到它下次被呼叫時,該tuple所在的記憶體不會被釋放。

對於查詢來說,第一類和第四類最為常見。2、3兩類會在物化的時候使用,比如 CTEScan、HashJoin 建立 hash 表的時候,相當於深複製。效能比較敏感的場合,儘量避免2、3類 tuple的使用。

slot 的建立一般透過 ExecAllocTableSlot、ExecSetSlotDescriptor 兩個函式來分配記憶體和初始化資訊。在執行器初始化階段,每個運算元會分配相應的 slot。

以上圖為例,

  1. SeqScan 運算元有一個 tupleTableSlot,是一個 physical tuple,指向的是 Buffer 中的地址。

    向上層返回自己的 tuple slot;

  2. HashJoin 一個一個拿到 內表的 tuple slot,需要建立 hash 表,所以建立了一個 minimal physical tuple,複製內表的 tuple 內容;

  3. Hash表建立後,HashJoin 運算元然後一個一個拿到外表的 tuple slot,做 join 計算。

    HashJoin 自己有一個 tuple slot,如果碰到匹配,則把自己的 tuple slot 設定成 virtual tuple,其中的 tts_values 指向的是孩子節點的 tuple 中的地址。

    再向上返回。

    其中,外表的內容指向的是 Buffer 上的physical tuple, 內表的內容指向的是 hash 表中的 minimal physical tuple;

  4. 當 HashJoin 被再次呼叫時,它會重置 tuple slot。因為是 virtual tuple,所以沒做任何事情。然後 HashJoin 會呼叫 SeqScan 拿下一條tuple;

  5. 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。

深入淺出openGauss的執行器基礎

表示式如下:

深入淺出openGauss的執行器基礎


示例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/,如需轉載,請註明出處,否則將追究法律責任。

相關文章