Hive使用Calcite CBO優化流程及SQL優化實戰

zzzzMing 發表於 2020-09-22
SQL


上一篇主要對Calcite的背景,技術特點,SQL的RBO和CBO等做了一個初步的介紹。深入淺出Calcite與SQL CBO(Cost-Based Optimizer)優化

這一篇會從Hive入手,介紹Hive如何使用Calcite來優化自己的SQL,主要從原始碼的角度進行介紹。文末附有一篇其他博主的文章,從其他角度闡述Hive CBO的,可供參考。

另外,上一篇中有提到我整理了Calcite的各種樣例,Calcite的一些使用樣例整理成到github,https://github.com/shezhiming/calcite-demo。其中自定義rule,Relnode等內容有部分參照自Hive。在介紹的時候可能也會稍微講到。

最後會從Hive這個例子延伸,看看自己可以怎麼藉助Calcite來優化SQL。

Hive SQL執行流程

Hive debug簡單介紹

在開始介紹之前,本著授人以漁的精深,先說下如何使用Hive debug檢視原始碼執行流程。具體流程可以參照這篇:

簡單說就是搭建個hive環境,通過 hive --debug -hiveconf hive.root.logger=DEBUG,console語句開啟 debug 模式,開啟後 hive 會監聽 8000 埠並等待輸入,此時從本地的 hive 原始碼專案中配置遠端 debug 就可以通過 debug 的方式追蹤 hive 執行流程。

debug過程中,執行SQL的入口是在CliDriver.executeDriver()這個方法,可以在這個地方打一個斷點,然後就可以除錯跟蹤了。如下圖:

hive原始碼入口

搭建hive服務的話,建議使用docker,搭建起來會比較方便一些。

PS:這裡介紹用的Hive的版本是2.3.x。

Hive SQL執行流程

前面說到,debug輸入語句的入口的類是org.apache.hadoop.hive.cli.CliDriver。而實際執行SQL語句邏輯的主要模組是ql(Query Language) 模組的Driver類(org.apache.hadoop.hive.ql.Driver)。Driver主要邏輯,是先呼叫compile(String command, boolean resetTaskIds, boolean deferClose)方法,對 SQL 進行編譯,然後Driver呼叫execute()方法,執行對應的MR任務。我們的關注點主要放在compile()方法的執行過程。

compile()方法中,整個SQL執行流程如下圖:
Hive SQL執行流程

即先將SQL解析成AST Node,然後轉換成QB,再轉換成Operator tree,最後進行邏輯優化和物理優化後,就程式設計一個可執行的MR任務了。對應階段的入口,我也在上面的圖中標註出來了。

其中較為核心的,從AST Node到Phsical Optimize這幾個階段,都是在SemanticAnalyzer.analyzeInternal()方法中進行的。這個方法中的註釋已經跟我們說明了SQL執行的主要流程,我這裡貼一下:

  1. Generate Resolved Parse tree from syntax tree
  2. Gen OP Tree from resolved Parse Tree
  3. Deduce Resultset Schema
  4. Generate Parse Context for Optimizer & Physical compiler
  5. Take care of view creation
  6. Generate table access stats if required
  7. Perform Logical optimization
  8. Generate column access stats if required - wait until column pruning takes place during optimization
  9. Optimize Physical op tree & Translate to target execution engine (MR, TEZ..)
  10. put accessed columns to readEntity
  11. if desired check we're not going over partition scan limits

大致的流程和圖裡面介紹的差不多,不過會多一些細節上的補充,感興趣的童鞋可以實際執行一下看看執行流程。我這裡簡單介紹下,前幾個步驟就是根據AST Node生成QB,然後再轉換成Operator Tree,然後處理檢視和生成統計資訊。最後執行邏輯優化和物理優化並生成MapReduce Task。

上述流程有一個比較容易讓人疑惑的點,無論是AST Node,Operator Tree都比較好理解,後面的邏輯優化和物理優化也都是SQL解析的常規套路,但為什麼中間會插入一個QB的階段?

其實這裡插入一個QB,一個主要的目的,是為了讓Calcite來進行優化。

Hive 使用Calcite優化

Hive Calcite優化流程

在Hive中,使用Calcite來進行核心優化,它將AST Node轉換成QB,又將QB轉換成Calcite的RelNode,在Calcite優化完成後,又會將RelNode轉換成Operator Tree,說起來很簡單,但這又是一條很長的呼叫鏈。

Calcite優化的主要類是CalcitePlanner,更加細節點,是在CalcitePlannerAction.apply()這個方法,CalcitePlannerAction是一個內部類,包括將QB轉換成RelNode,優化具體操作都是在這個方法中進行的。

這個方法的註釋也給出了主要操作步驟,這裡也貼一下流程:

  1. Gen Calcite Plan
  2. Apply pre-join order optimizations
  3. Apply join order optimizations: reordering MST algorithm
    If join optimizations failed because of missing stats, we continue with the rest of optimizations
  4. Run other optimizations that do not need stats
  5. Materialized view based rewriting
    We disable it for CTAS and MV creation queries (trying to avoid any problem due to data freshness)
  6. Run aggregate-join transpose (cost based)
    If it failed because of missing stats, we continue with the rest of optimizations
    7.convert Join + GBy to semijoin
  7. Run rule to fix windowing issue when it is done over aggregation columns
  8. Apply Druid transformation rules
  9. Run rules to aid in translation from Calcite tree to Hive tree
    10.1. Merge join into multijoin operators (if possible)
    10.2. Introduce exchange operators below join/multijoin operators

簡單說下,就是先生成RelNode(根據QB),然後進行一系列的優化。這裡的優化最主要的還是跟join有關的優化,上面流程步驟中的2~7步都是join相關的優化。然後才是根據各個rule進行優化。最後再轉換成Operator Tree,這就是最上面圖片中QB->Operator Tree的流程。

接下來我們就深入這個流程,看看Hive是如何使用Calcite做SQL優化的。

Hive Calcite使用細則

要介紹Hive如何利用Calcite做優化,我們還是先轉頭看看Calcite優化需要哪些東西。先貼一下上一篇中介紹到的,Calcite的架構圖:
Calcite架構

從圖中可以明顯發現,跟QUery Optimizer(優化器)有關的模組有三個,Operator ExpressionsMetadata ProvidersPluggable Rules,三者分別是關係表達樹(由RelNode節點組成),後設資料提供器,還有Rule。

其中關係表達樹是Calcite將SQL解析校驗後產生的一種關係樹,樹的節點即是RelNode(關係代數節點),RelNode又有多種型別,比如TableScan代表最底層的表輸入,Filter表示Where(關係代數的過濾),Project表示select(關係代數的投影),即大部分的RelNode都會和關係代數中的操作對應。以一條SQL為例,一條簡單的SQL程式設計RelNode就會是下面這個樣子:

select * from TEST_CSV.TEST01 where TEST01.NAME1='hello';

//RelNode關係樹
Project(ID=[$0], NAME1=[$1], NAME2=[$2])
  Filter(condition=[=($1, 'hello')])
    TableScan(table=[[TEST_CSV, TEST01]])

再來說說後設資料提供器,所謂後設資料,就是跟表有關的那些資訊,rowcount,表欄位等資訊。其中rowcount這類資訊跟計算cost有關,Calcite有自己的預設的後設資料提供器,但做的比較粗糙,如果有需要應該自己提供一個後設資料提供器提供自己的後設資料資訊。

最後就是Rules,這塊Calcite預設已經有非常多的Rules,當然我們也可以定義自己的Rule再新增進去。不過通常基本的SQL優化使用Calcite的Rule就足夠。這裡說下怎麼在idea裡面檢視Calcite提供的Rule,先找到RelOptRule這個類,然後按下檢視類繼承關係的快捷鍵(Mac上是Ctrl+h),就能看到多條Rule,如果要自己實現也可以照著其中實現。

稍微總結一下,Calcite已經基本提供了所需要的Rule,所以要使用Calcite優化SQL,我們需要的,是提供SQL對應的RelNode,以及通過後設資料提供器提供自身的後設資料。

Hive要使用Calcite優化,也無外乎就是提供上述的兩部分內容。

用過Hive的童鞋應該知道,Hive可以通過外部儲存元件儲存資料庫和表後設資料資訊,包括rowcount,input size等(需要執行Analyze語句或DML才會計算並後設資料到Mysql)。Hive要做的就是將這些資訊,提供給Calcite。

Hive向Calcite提供後設資料

需要先明確的一點是,後設資料提供器需要提供的一個比較重要的資料,是rowcount,在進行CBO計算Cost的過程中,CPU,IO等資訊也基本都是從rowcount加工而來的。且後設資料重要的一個用途,也是進行CBO優化,輸入的後設資料可以等價於CBO要用到的Cost資料。

繼續深入CBO的Cost,通過前面的例子,可以知道SQL在Calcite會被解析成RelNode樹,RelNode樹上層節點(Project等)的Cost資訊,是由下層的資訊計算而得到的。我們的目標是要自定義Cost資訊,那麼就需要將Hive的後設資料注入最底層的TableScan的Cost資訊,同時要能夠自定義每個節點的Cost計算方式

還記得前面說到Calcite預設的後設資料提供器比較粗糙嗎,就是體現在它的TableScan的rowcount預設是100,而每個節點的計算邏輯也比較簡單。

所以重點有兩個,一個是最底層TableScan的cost資訊注入方式,另一個是如何每種RelNode型別定義計算邏輯的方式

辦法有兩種,一種是比較上層的,通過自定義RelNode,修改其中的computeSelfCost()方法和estimateRowCount方法,這兩個方法,一個是計算Cost資訊,另一個是計算行數。這種辦法可以直接解決TableScan的cost注入,和自定義每種RelNode型別的計算邏輯。但這種辦法忽了後設資料提供器,算是比較簡單粗暴的方法。

就像這樣:

程式碼見:https://github.com/shezhiming/calcite-demo/blob/master/src/main/java/pers/shezm/calcite/optimizer/reloperators/CSVTableScan.java

public class CSVTableScan extends TableScan implements CSVRel {
    private RelOptCost cost;
    public CSVTableScan(RelOptCluster cluster, RelTraitSet traitSet, RelOptTable table) {
        super(cluster, traitSet, table);
    }

    @Override public double estimateRowCount(RelMetadataQuery mq) {
        return 50;
    }

    @Override
    public RelOptCost computeSelfCost(RelOptPlanner planner, RelMetadataQuery mq) {
        //return super.computeSelfCo(planner, mq);

        if (cost != null) {
            return cost;
        }
        //通過工廠生成 RelOptCost ,注入自定義 cost 值並返回
        cost = planner.getCostFactory().makeCost(1, 1, 0);
        return cost;
    }
}

另一種方法則更加底層一些,TableScan的後設資料資訊,是通過內部變數RelOptTable獲取,那麼就自定義RelOptTable實現後設資料注入。然後通過實現MetadataDef<BuiltInMetadata.RowCount>系列的介面,在其中新增自己的計算邏輯,將這些自定義的類都載入到RelMetadataProvider中(後設資料提供器,可以在其中提供自定義的後設資料和計算邏輯),再注入到Calcite中就可以實現自己的Cost計算邏輯。這也是Hive的實現方式。

我們從TableScan注入,和RelMetadataProvider這兩方面看看Hive是怎麼做。

TableScan的注入後設資料

首先,Hive自定義了Calcite的TableScan,在org.apache.hadoop.hive.ql.optimizer.calcite.reloperators.HiveTableScan。但這裡並不涉及後設資料,我們觀察下TableScan的原始碼,

public abstract class TableScan extends AbstractRelNode {
  //~ Instance fields --------------------------------------------------------

  /**
   * The table definition.
   */
  protected final RelOptTable table;

  //生成 cost 資訊
  @Override public RelOptCost computeSelfCost(RelOptPlanner planner,
      RelMetadataQuery mq) {
    double dRows = table.getRowCount();
    double dCpu = dRows + 1; // ensure non-zero cost
    double dIo = 0;
    return planner.getCostFactory().makeCost(dRows, dCpu, dIo);
  }

  //生成 rowcount 資訊
  @Override public double estimateRowCount(RelMetadataQuery mq) {
    return table.getRowCount();
  }
}

順便說下,上面說過,Cost資訊和rowcount息息相關,這裡就可以看出來了,Cpu直接就用rowcount加一。並且這裡也可以看出預設的後設資料提供器比較粗糙。

不過我們重點不在這,通過程式碼可以發現它主要是通過table這個變數獲取表後設資料資訊。而hive也自定義了相關的類,就是繼承自RelOptTableRelOptHiveTable。這個類在HiveTableScan初始化的時候,會作為引數傳遞進去。而它的後設資料則是通過QB獲取,這個過程也是在CalcitePlannerAction.apply()中完成的,至於QB的後設資料,則是在初始化的時候通過Mysql獲取到的。聽起來挺繞,稍微按順序整理下:

  1. QB初始化的時候,通過Mysql獲取後設資料資訊並注入
  2. QB轉成RelNode的時候,將後設資料傳遞到RelOptHiveTable
  3. RelOptHiveTable作為引數新建HiveTableScan

以上就是Hive完成TableScan後設資料注入的過程。
自定義RelMetadataProvider
再來說說如何提供RelMetadataProvider。這個主要是通過繼承MetadataHandler實現的,這裡貼一下就能清楚metadata有哪些型別,以及Hive實現了哪些:

Hive metadata

這裡可以清楚看到,metadata除了之前提到的rowcount,cost,還有size,Distribution等等,其中白色的就是Hive實現的。

而之前一直提到的rowcount和cost,對應的就是HiveRelMdRowCountHiveRelMdCost(這個真正的cost模型實現,是在HiveCostModel)。這裡貼一下HiveCostModel中Join的Cost自定義計算邏輯,因為join優化是一個重點,所以這裡會根據不同實現類去計算cost,相比Calcite預設實現,精細很多了。

public abstract class HiveCostModel {
  ......其他程式碼
  public RelOptCost getJoinCost(HiveJoin join) {
    // Select algorithm with min cost
    JoinAlgorithm joinAlgorithm = null;
    RelOptCost minJoinCost = null;

    if (LOG.isTraceEnabled()) {
      LOG.trace("Join algorithm selection for:\n" + RelOptUtil.toString(join));
    }

    for (JoinAlgorithm possibleAlgorithm : this.joinAlgorithms) {
      if (!possibleAlgorithm.isExecutable(join)) {
        continue;
      }
      RelOptCost joinCost = possibleAlgorithm.getCost(join);
      if (LOG.isTraceEnabled()) {
        LOG.trace(possibleAlgorithm + " cost: " + joinCost);
      }
      if (minJoinCost == null || joinCost.isLt(minJoinCost) ) {
        joinAlgorithm = possibleAlgorithm;
        minJoinCost = joinCost;
      }
    }

    if (LOG.isTraceEnabled()) {
      LOG.trace(joinAlgorithm + " selected");
    }

    join.setJoinAlgorithm(joinAlgorithm);
    join.setJoinCost(minJoinCost);

    return minJoinCost;
  }
  ......其他程式碼
}

其他的也和這個差不多,就是更加精細的自定義Cost計算,就不多展示了。

OK,說完上面這些,Hive的優化也就差不多介紹完了,這裡重點還是介紹了Hive如何向Calcite中注入後設資料資訊以及實現自定義的RelNode計算邏輯。至於Calcite進行RBO和CBO優化的更多細節,我上一篇有提到,也有給出相關資料,這裡就不多介紹。

深入淺出Calcite與SQL CBO(Cost-Based Optimizer)優化

還有另一個點是編寫自定義的rule實現自定義優化,這一點以後與機會再說。

另外我最上方的github中,也有簡單照著hive,實現了自己注入後設資料和自定義RelNode的計算方式,基本都是從最簡單的CSV的例子延伸而言,方便理解,有興趣的朋友可以看看,如果有幫助不妨點個star。

以上~

參考文章:
Apache Hive 是怎樣做基於代價的優化的?