1. 程式人生 > 實用技巧 >Apache Calcite 優化器詳解(二)

Apache Calcite 優化器詳解(二)

什麼是查詢優化器

查詢優化器是傳統資料庫的核心模組,也是大資料計算引擎的核心模組,開源大資料引擎如 Impala、Presto、Drill、HAWQ、 Spark、Hive 等都有自己的查詢優化器。Calcite 就是從 Hive 的優化器演化而來的。

優化器的作用:將解析器生成的關係代數表示式轉換成執行計劃,供執行引擎執行,在這個過程中,會應用一些規則優化,以幫助生成更高效的執行計劃。

關於 Volcano 模型和 Cascades 模型的內容,建議看下相關的論文,這個是 Calcite 優化器的理論基礎,程式碼只是把這個模型落地實現而已。

基於規則優化(RBO)

基於規則的優化器(Rule-Based Optimizer,RBO):根據優化規則對關係表示式進行轉換,這裡的轉換是說一個關係表示式經過優化規則後會變成另外一個關係表示式,同時原有表示式會被裁剪掉,經過一系列轉換後生成最終的執行計劃。

RBO 中包含了一套有著嚴格順序的優化規則,同樣一條 SQL,無論讀取的表中資料是怎麼樣的,最後生成的執行計劃都是一樣的。同時,在 RBO 中 SQL 寫法的不同很有可能影響最終的執行計劃,從而影響執行計劃的效能。

基於成本優化(CBO)

基於代價的優化器(Cost-Based Optimizer,CBO):根據優化規則對關係表示式進行轉換,這裡的轉換是說一個關係表示式經過優化規則後會生成另外一個關係表示式,同時原有表示式也會保留,經過一系列轉換後會生成多個執行計劃,然後 CBO 會根據統計資訊和代價模型 (Cost Model) 計算每個執行計劃的 Cost,從中挑選 Cost 最小的執行計劃。

由上可知,CBO 中有兩個依賴:統計資訊和代價模型。統計資訊的準確與否、代價模型的合理與否都會影響 CBO 選擇最優計劃。 從上述描述可知,CBO 是優於 RBO 的,原因是 RBO 是一種只認規則,對資料不敏感的呆板的優化器,而在實際過程中,資料往往是有變化的,通過 RBO 生成的執行計劃很有可能不是最優的。事實上目前各大資料庫和大資料計算引擎都傾向於使用 CBO,但是對於流式計算引擎來說,使用 CBO 還是有很大難度的,因為並不能提前預知資料量等資訊,這會極大地影響優化效果,CBO 主要還是應用在離線的場景。

優化規則

無論是 RBO,還是 CBO 都包含了一系列優化規則,這些優化規則可以對關係表示式進行等價轉換,常見的優化規則包含:

  1. 謂詞下推 Predicate Pushdown

  2. 常量摺疊 Constant Folding

  3. 列裁剪 Column Pruning

  4. 其他

在 Calcite 的程式碼裡,有一個測試類(org.apache.calcite.test.RelOptRulesTest)彙集了對目前內建所有 Rules 的測試 case,這個測試類可以方便我們瞭解各個 Rule 的作用。在這裡有下面一條 SQL,通過這條語句來說明一下上面介紹的這三種規則。

1
2
3
select 10 + 30, users.name, users.age
from users join jobs on users.id= user.id
where users.age > 30 and jobs.id>10

謂詞下推(Predicate Pushdown)

關於謂詞下推,它主要還是從關係型資料庫借鑑而來,關係型資料中將謂詞下推到外部資料庫用以減少資料傳輸;屬於邏輯優化,優化器將謂詞過濾下推到資料來源,使物理執行跳過無關資料。最常見的例子就是 join 與 filter 操作一起出現時,提前執行 filter 操作以減少處理的資料量,將 filter 操作下推,以上面例子為例,示意圖如下(對應 Calcite 中的FilterJoinRule.FilterIntoJoinRule.FILTER_ON_JOINRule):

640?wx_fmt=png


Filter操作下推前後的對比

在進行 join 前進行相應的過濾操作,可以極大地減少參加 join 的資料量。

常量摺疊(Constant Folding)

常量摺疊也是常見的優化策略,這個比較簡單、也很好理解,可以看下編譯器優化 – 常量摺疊這篇文章,基本不用動腦筋就能理解,對於我們這裡的示例,有一個常量表達式10 + 30,如果不進行常量摺疊,那麼每行資料都需要進行計算,進行常量摺疊後的結果如下圖所示( 對應 Calcite 中的ReduceExpressionsRule.PROJECT_INSTANCERule):

640?wx_fmt=png常量摺疊前後的對比

列裁剪(Column Pruning)

列裁剪也是一個經典的優化規則,在本示例中對於jobs 表來說,並不需要掃描它的所有列值,而只需要列值 id,所以在掃描 jobs 之後需要將其他列進行裁剪,只留下列 id。這個優化帶來的好處很明顯,大幅度減少了網路 IO、記憶體資料量的消耗。裁剪前後的示意圖如下(不過並沒有找到 Calcite 對應的 Rule):

640?wx_fmt=png


列裁剪前後的對比

Calcite 中的優化器實現

有了前面的基礎後,這裡來看下 Calcite 中優化器的實現,RelOptPlanner 是 Calcite 中優化器的基類,其子類實現如下圖所示:

640?wx_fmt=png


RelOptPlanner

Calcite 中關於優化器提供了兩種實現:

  1. HepPlanner:就是前面 RBO 的實現,它是一個啟發式的優化器,按照規則進行匹配,直到達到次數限制(match 次數限制)或者遍歷一遍後不再出現 rule match 的情況才算完成;

  2. VolcanoPlanner:就是前面 CBO 的實現,它會一直迭代 rules,直到找到 cost 最小的 paln。

前面提到過像calcite這類查詢優化器最核心的兩個問題之一是怎麼把優化規則應用到關係代數相關的RelNode Tree上。所以在閱讀calicite的程式碼時就得帶著這個問題去看看它的實現過程,然後才能判斷它的程式碼實現得是否優雅。
calcite的每種規則實現類(RelOptRule的子類)都會宣告自己應用在哪種RelNode子類上,每個RelNode子類其實都可以看成是一種operator(中文常翻譯成運算元)。
VolcanoPlanner就是優化器,用的是動態規劃演算法,在建立VolcanoPlanner的例項後,通過calcite的標準jdbc介面執行sql時,預設會給這個VolcanoPlanner的例項註冊將近90條優化規則(還不算常量摺疊這種最常見的優化),所以看程式碼時,知道什麼時候註冊可用的優化規則是第一步(呼叫VolcanoPlanner.addRule實現),這一步比較簡單。
接下來就是如何篩選規則了,當把語法樹轉成RelNode Tree後是沒有必要把前面註冊的90條優化規則都用上的,所以需要有個篩選的過程,因為每種規則是有應用範圍的,按RelNode Tree的不同節點型別就可以篩選出實際需要用到的優化規則了。這一步說起來很簡單,但在calcite的程式碼實現裡是相當複雜的,也是非常關鍵的一步,是從呼叫VolcanoPlanner.setRoot方法開始間接觸發的,如果只是靜態的看程式碼不跑起來跟蹤除錯多半摸不清它的核心流程的。篩選出來的優化規則會封裝成VolcanoRuleMatch,然後扔到RuleQueue裡,而這個RuleQueue正是接下來執行動態規劃演算法要用到的核心類。篩選規則這一步的程式碼實現很晦澀。
第三步才到VolcanoPlanner.findBestExp,本質上就是一個動態規劃演算法的實現,但是最值得關注的還是怎麼用第二步篩選出來的規則對RelNode Tree進行變換,變換後的形式還是一棵RelNode Tree,最常見的是把LogicalXXX開頭的RelNode子類換成了EnumerableXXX或BindableXXX,總而言之,看看具體優化規則的實現就對了,都是繁瑣的體力活。
一個優化器,理解了上面所說的三步基本上就抓住重點了。
—— 來自【zhh-4096 】的微博

下面詳細講述一下這兩種 planner 在 Calcite 內部的具體實現。

HepPlanner

使用 HepPlanner 實現的完整程式碼見SqlHepTest。

HepPlanner 中的基本概念

這裡先看下 HepPlanner 的一些基本概念,對於後面的理解很有幫助。

HepRelVertex

HepRelVertex 是對 RelNode 進行了簡單封裝。HepPlanner 中的所有節點都是 HepRelVertex,每個 HepRelVertex 都指向了一個真正的 RelNode 節點。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// org.apache.calcite.plan.hep.HepRelVertex
/**
 * note:HepRelVertex 將一個 RelNode 封裝為一個 DAG 中的 vertex(DAG 代表整個 query expression)
 */
public class HepRelVertex extends AbstractRelNode {
  //~ Instance fields --------------------------------------------------------

  /**
   * Wrapped rel currently chosen for implementation of expression.
   */
  private RelNode currentRel;
}

HepInstruction

HepInstruction 是 HepPlanner 對一些內容的封裝,具體的子類實現比較多,其中 RuleInstance 是 HepPlanner 中對 Rule 的一個封裝,註冊的 Rule 最後都會轉換為這種形式。

HepInstruction represents one instruction in a HepProgram.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
//org.apache.calcite.plan.hep.HepInstruction
/** Instruction that executes a given rule. */
//note: 執行指定 rule 的 Instruction
static class RuleInstance extends HepInstruction {
  /**
   * Description to look for, or null if rule specified explicitly.
   */
  String ruleDescription;

  /**
   * Explicitly specified rule, or rule looked up by planner from
   * description.
   * note:設定其 Rule
   */
  RelOptRule rule;

  void initialize(boolean clearCache) {
    if (!clearCache) {
      return;
    }

    if (ruleDescription != null) {
      // Look up anew each run.
      rule = null;
    }
  }

  void execute(HepPlanner planner) {
    planner.executeInstruction(this);
  }
}

HepPlanner 處理流程

下面這個示例是上篇文章(Apache Calcite 處理流程詳解(一))的示例,通過這段程式碼來看下 HepPlanner 的內部實現機制。

1
2
3
4
5
HepProgramBuilder builder = new HepProgramBuilder();
builder.addRuleInstance(FilterJoinRule.FilterIntoJoinRule.FILTER_ON_JOIN); //note: 新增 rule
HepPlanner hepPlanner = new HepPlanner(builder.build());
hepPlanner.setRoot(relNode);
relNode = hepPlanner.findBestExp();

上面的程式碼總共分為三步:

  1. 初始化 HepProgram 物件;

  2. 初始化 HepPlanner 物件,並通過setRoot()方法將 RelNode 樹轉換成 HepPlanner 內部使用的 Graph;

  3. 通過findBestExp()找到最優的 plan,規則的匹配都是在這裡進行。

1. 初始化 HepProgram

這幾步程式碼實現沒有太多需要介紹的地方,先初始化 HepProgramBuilder 也是為了後面初始化 HepProgram 做準備,HepProgramBuilder 主要也就是提供了一些配置設定和新增規則的方法等,常用的方法如下:

  1. addRuleInstance():註冊相應的規則;

  2. addRuleCollection():這裡是註冊一個規則集合,先把規則放在一個集合裡,再註冊整個集合,如果規則多的話,一般是這種方式;

  3. addMatchLimit():設定 MatchLimit,這個 rule match 次數的最大限制;

HepProgram 這個類對於後面 HepPlanner 的優化很重要,它定義 Rule 匹配的順序,預設按【深度優先】順序,它可以提供以下幾種(見 HepMatchOrder 類):

  1. ARBITRARY:按任意順序匹配(因為它是有效的,而且大部分的 Rule 並不關心匹配順序);

  2. BOTTOM_UP:自下而上,先從子節點開始匹配;

  3. TOP_DOWN:自上而下,先從父節點開始匹配;

  4. DEPTH_FIRST:深度優先匹配,某些情況下比 ARBITRARY 高效(為了避免新的 vertex 產生後又從 root 節點開始匹配)。

這個匹配順序到底是什麼呢?對於規則集合 rules,HepPlanner 的演算法是:從一個節點開始,跟 rules 的所有 Rule 進行匹配,匹配上就進行轉換操作,這個節點操作完,再進行下一個節點,這裡的匹配順序就是指的節點遍歷順序(這種方式的優劣,我們下面再說)。

2. HepPlanner.setRoot(RelNode –> Graph)

先看下setRoot()方法的實現:

1
2
3
4
5
6
7
// org.apache.calcite.plan.hep.HepPlanner
public void setRoot(RelNode rel) {
  //note: 將 RelNode 轉換為 DAG 表示
  root = addRelToGraph(rel);
  //note: 僅僅是在 trace 日誌中輸出 Graph 資訊
  dumpGraph();
}

HepPlanner 會先將所有 relNode tree 轉化為 HepRelVertex,這時就構建了一個 Graph:將所有的 elNode 節點使用 Vertex 表示,Gragh 會記錄每個 HepRelVertex 的 input 資訊,這樣就是構成了一張 graph。

在真正的實現時,遞迴逐漸將每個 relNode 轉換為 HepRelVertex,並在graph中記錄相關的資訊,實現如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
//org.apache.calcite.plan.hep.HepPlanner
//note: 根據 RelNode 構建一個 Graph
private HepRelVertex addRelToGraph(
    RelNode rel) {
  // Check if a transformation already produced a reference
  // to an existing vertex.
  //note: 檢查這個 rel 是否在 graph 中轉換了
  if (graph.vertexSet().contains(rel)) {
    return (HepRelVertex) rel;
  }

  // Recursively add children, replacing this rel's inputs
  // with corresponding child vertices.
  //note: 遞迴地增加子節點,使用子節點相關的 vertices 代替 rel 的 input
  final List<RelNode> inputs = rel.getInputs();
  final List<RelNode> newInputs = new ArrayList<>();
  for (RelNode input1 : inputs) {
    HepRelVertex childVertex = addRelToGraph(input1); //note: 遞迴進行轉換
    newInputs.add(childVertex); //note: 每個 HepRelVertex 只記錄其 Input
  }

  if (!Util.equalShallow(inputs, newInputs)) { //note: 不相等的情況下
    RelNode oldRel = rel;
    rel = rel.copy(rel.getTraitSet(), newInputs);
    onCopy(oldRel, rel);
  }
  // Compute digest first time we add to DAG,
  // otherwise can't get equivVertex for common sub-expression
  //note: 計算 relNode 的 digest
  //note: Digest 的意思是:
  //note: A short description of this relational expression's type, inputs, and
  //note: other properties. The string uniquely identifies the node; another node
  //note: is equivalent if and only if it has the same value.
  rel.recomputeDigest();

  // try to find equivalent rel only if DAG is allowed
  //note: 如果允許 DAG 的話,檢查是否有一個等價的 HepRelVertex,有的話直接返回
  if (!noDag) {
    // Now, check if an equivalent vertex already exists in graph.
    String digest = rel.getDigest();
    HepRelVertex equivVertex = mapDigestToVertex.get(digest);
    if (equivVertex != null) { //note: 已經存在
      // Use existing vertex.
      return equivVertex;
    }
  }

  // No equivalence:  create a new vertex to represent this rel.
  //note: 建立一個 vertex 代替 rel
  HepRelVertex newVertex = new HepRelVertex(rel);
  graph.addVertex(newVertex); //note: 記錄 Vertex
  updateVertex(newVertex, rel);//note: 更新相關的快取,比如 mapDigestToVertex map

  for (RelNode input : rel.getInputs()) { //note: 設定 Edge
    graph.addEdge(newVertex, (HepRelVertex) input);//note: 記錄與整個 Vertex 先關的 input
  }

  nTransformations++;
  return newVertex;
}

到這裡 HepPlanner 需要的 gragh 已經構建完成,通過 DEBUG 方式也能看到此時 HepPlanner root 變數的內容:

640?wx_fmt=png


Root 轉換之後的內容

3. HepPlanner findBestExp 規則優化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//org.apache.calcite.plan.hep.HepPlanner
// implement RelOptPlanner
//note: 優化器的核心,匹配規則進行優化
public RelNode findBestExp() {
  assert root != null;

  //note: 執行 HepProgram 演算法(按 HepProgram 中的 instructions 進行相應的優化)
  executeProgram(mainProgram);

  // Get rid of everything except what's in the final plan.
  //note: 垃圾收集
  collectGarbage();

  return buildFinalPlan(root); //note: 返回最後的結果,還是以 RelNode 表示
}

主要的實現是在executeProgram()方法中,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//org.apache.calcite.plan.hep.HepPlanner
private void executeProgram(HepProgram program) {
  HepProgram savedProgram = currentProgram; //note: 保留當前的 Program
  currentProgram = program;
  currentProgram.initialize(program == mainProgram);//note: 如果是在同一個 Program 的話,保留上次 cache
  for (HepInstruction instruction : currentProgram.instructions) {
    instruction.execute(this); //note: 按 Rule 進行優化(會呼叫 executeInstruction 方法)
    int delta = nTransformations - nTransformationsLastGC;
    if (delta > graphSizeLastGC) {
      // The number of transformations performed since the last
      // garbage collection is greater than the number of vertices in
      // the graph at that time.  That means there should be a
      // reasonable amount of garbage to collect now.  We do it this
      // way to amortize garbage collection cost over multiple
      // instructions, while keeping the highwater memory usage
      // proportional to the graph size.
      //note: 進行轉換的次數已經大於 DAG Graph 中的頂點數,這就意味著已經產生大量垃圾需要進行清理
      collectGarbage();
    }
  }
  currentProgram = savedProgram;
}

這裡會遍歷 HepProgram 中 instructions(記錄註冊的所有 HepInstruction),然後根據 instruction 的型別執行相應的executeInstruction()方法,如果instruction 是HepInstruction.MatchLimit型別,會執行executeInstruction(HepInstruction.MatchLimit instruction)方法,這個方法就是初始化 matchLimit 變數。對於HepInstruction.RuleInstance型別的 instruction 會執行下面的方法(前面的示例註冊規則使用的是addRuleInstance()方法,所以返回的 rules 只有一個規則,如果註冊規則的時候使用的是addRuleCollection()方法註冊一個規則集合的話,這裡會返回的 rules 就是那個規則集合):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//org.apache.calcite.plan.hep.HepPlanner
//note: 執行相應的 RuleInstance
void executeInstruction(
    HepInstruction.RuleInstance instruction) {
  if (skippingGroup()) {
    return;
  }
  if (instruction.rule == null) {//note: 如果 rule 為 null,那麼就按照 description 查詢具體的 rule
    assert instruction.ruleDescription != null;
    instruction.rule =
        getRuleByDescription(instruction.ruleDescription);
    LOGGER.trace("Looking up rule with description {}, found {}",
        instruction.ruleDescription, instruction.rule);
  }
  //note: 執行相應的 rule
  if (instruction.rule != null) {
    applyRules(
        Collections.singleton(instruction.rule),
        true);
  }
}

接下來看applyRules()的實現:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
//org.apache.calcite.plan.hep.HepPlanner
//note: 執行 rule(forceConversions 預設 true)
private void applyRules(
    Collection<RelOptRule> rules,
    boolean forceConversions) {
  if (currentProgram.group != null) {
    assert currentProgram.group.collecting;
    currentProgram.group.ruleSet.addAll(rules);
    return;
  }

  LOGGER.trace("Applying rule set {}", rules);

  //note: 當遍歷規則是 ARBITRARY 或 DEPTH_FIRST 時,設定為 false,此時不會從 root 節點開始,否則每次 restart 都從 root 節點開始
  boolean fullRestartAfterTransformation =
      currentProgram.matchOrder != HepMatchOrder.ARBITRARY
      && currentProgram.matchOrder != HepMatchOrder.DEPTH_FIRST;

  int nMatches = 0;

  boolean fixedPoint;
  //note: 兩種情況會跳出迴圈,一種是達到 matchLimit 限制,一種是遍歷一遍不會再有新的 transform 產生
  do {
    //note: 按照遍歷規則獲取迭代器
    Iterator<HepRelVertex> iter = getGraphIterator(root);
    fixedPoint = true;
    while (iter.hasNext()) {
      HepRelVertex vertex = iter.next();//note: 遍歷每個 HepRelVertex
      for (RelOptRule rule : rules) {//note: 遍歷每個 rules
        //note: 進行規制匹配,也是真正進行相關操作的地方
        HepRelVertex newVertex =
            applyRule(rule, vertex, forceConversions);
        if (newVertex == null || newVertex == vertex) {
          continue;
        }
        ++nMatches;
        //note: 超過 MatchLimit 的限制
        if (nMatches >= currentProgram.matchLimit) {
          return;
        }
        if (fullRestartAfterTransformation) {
          //note: 發生 transformation 後,從 root 節點再次開始
          iter = getGraphIterator(root);
        } else {
          // To the extent possible, pick up where we left
          // off; have to create a new iterator because old
          // one was invalidated by transformation.
          //note: 儘可能從上次進行後的節點開始
          iter = getGraphIterator(newVertex);
          if (currentProgram.matchOrder == HepMatchOrder.DEPTH_FIRST) {
            //note: 這樣做的原因就是為了防止有些 HepRelVertex 遺漏了 rule 的匹配(每次從 root 開始是最簡單的演算法),因為可能出現下推
            nMatches =
                depthFirstApply(iter, rules, forceConversions, nMatches);
            if (nMatches >= currentProgram.matchLimit) {
              return;
            }
          }
          // Remember to go around again since we're
          // skipping some stuff.
          //note: 再來一遍,因為前面有跳過一些節點
          fixedPoint = false;
        }
        break;
      }
    }
  } while (!fixedPoint);
}

在這裡會呼叫getGraphIterator()方法獲取 HepRelVertex 的迭代器,迭代的策略(遍歷的策略)跟前面說的順序有關,預設使用的是【深度優先】,這段程式碼比較簡單,就是遍歷規則+遍歷節點進行匹配轉換,直到滿足條件再退出,從這裡也能看到 HepPlanner 的實現效率不是很高,它也無法保證能找出最優的結果。

總結一下,HepPlanner 在優化過程中,是先遍歷規則,然後再對每個節點進行匹配轉換,直到滿足條件(超過限制次數或者規則遍歷完一遍不會再有新的變化),其方法呼叫流程如下:

640?wx_fmt=png

HepPlanner 處理流程

思考

1. 為什麼要把 RelNode 轉換 HepRelVertex 進行優化?帶來的收益在哪裡?

關於這個,能想到的就是:RelNode 是底層提供的抽象、偏底層一些,在優化器這一層,需要記錄更多的資訊,所以又做了一層封裝。