Kylin執行查詢流程分析

  Kylin基於MOLAP實現,查詢的時候利用Calcite框架,從存儲在Hbase的segment表(每一個segment對應着一個htable)獲取數據,其實理論上就相當於使用Calcite支持SQL解析,數據從Hbase中讀取,中間Kylin主要完成如何確定從Hbase中的哪些表讀數據,如何讀取數據,以及解析數據的格式。

場景設置

首先設想一種cube的場景:

維度:A(cardinality=10)、B(cardinality=20)、C(cardinality=30)、D(cardinality=40),其中A爲mandatory維度,rowkey順序爲A、B、C、D,只有一個分組。

度量:COUNT(1), SUM(X)

  在這種情況下,這個cube包含如下的cuboid:ABCD、ABC、ABD、ACD、AB、AC、AD、A。目前Kylin在執行查詢的時候只能通過查找cube進行匹配,如果能夠找到一個匹配的cube則讀取通過掃描該cube的所有segment處理該請求,首先先看一下kylin是如何處理一個SQL查詢的。

執行查詢

  Kylin提供了兩種執行SQL查詢的方式:jdbc訪問和http api的訪問,前者的實現實際上是在客戶端封裝了http api請求,然後獲取結果再轉換成ResultSet對象,在執行查詢之前Kylin服務端會對查詢的SQL做緩存,尤其是執行時間比較久的查詢,緩存是基於SQL的內容作爲key,結果作爲value的,所以重複執行一個查詢會很快返回的(這是因爲Kylin假設數據是隻讀的,不會被修改)。如果緩存不命中則使用服務器內嵌的Calcite創建一個向Calcite的jdbc connection,然後使用jdbc的方式獲取執行結果,在使用Calcite的時候用戶只需要給Calcite提供數據,Calcite能夠完成其他物理算子的優化和執行,但是對於Kylin來說,它深度定製了Calcite,增加了一些優化的策略,所以總的來說查詢可以分成兩部分:1、kylin是如何使用calcite完成SQL的解析,獲取SQL的上下文;2、kylin如何從預計算的數據中獲取數據並進行計算的。

使用Calcite完成SQL解析,獲取查詢上下文

這裏寫圖片描述

  當在Calcite中執行一個SQL時,Calcite會解析得到AST樹,然後再對邏輯執行計劃進行優化,Calcite的優化規則是基於規則的,在Calcite中註冊了一些新的Rule,在優化的過程中會根據這些規則對算子進行轉換爲對應的物理執行算子,接下來Calcite從上到下一次執行這些算子。這些算子都實現了EnumerableRel接口,在執行的時候調用implement函數:

public interface EnumerableRel
    extends RelNode {
  /**
   * Creates a plan for this expression according to a calling convention.
   *
   * @param implementor Implementor
   * @param pref Preferred representation for rows in result expression
   * @return Plan for this expression according to a calling convention
   */
  Result implement (EnumerableRelImplementor implementor , Prefer pref);
}

這裏寫圖片描述

  在所有Kylin優化之後的查詢樹中,根節點都是OLAPToEnumerableConverter,在它的implement函數中首先根據每一個算子中保持的信息構造本次查詢的上下文OLAPContext,例如根據OLAPAggregateRel算子獲取groupByColumns,根據OLAPFilterRel算子將每次查詢的過濾條件轉換成TupleFilter。然後根據本次查詢中使用的維度列(出現在groupByColumns和filterColumns中)、度量信息(aggregations)查詢是否有滿足本次查詢的Cube,如果有則將其保存在OLAPContext的realization中,獲取數據時需要依賴於它。然後再rewrite回調函數中遞歸調用每一個算子的implementRewrite函數重新構造每一個算子的參數,最後再調用每一個算子的implementEnumerable函數將其轉換成EnumerableRel對象,這一步相當於將上面生成的物理執行計劃再次轉換生成一個新的物理執行計劃。

  Calcite會根據這個執行計劃動態生成執行代碼,其中代碼的生成根據每一個算子的implement函數構造,並且Calcite根據算子之間的依賴關係生成在新生成的類中構造bind函數,在bind函數中首先會執行TableScan獲取數據,數據是通過一個Enumerable對象返回的,所以OLAPTableScan需要負責產生一個該對象獲取原始數據,在執行moveNext獲取下一條記錄的時候通過filter中指定的條件對原始數據進行過濾,在current函數中執行映射返回select中指定的列數據,接着對這個Enumerable依次執行groupBy和orderBy函數,將結果返回。本次查詢的statement會根據bind函數返回的Enumerable對象構造ResultSet對象。

  上面大致上介紹了Kylin利用Calcite框架執行查詢的流程,Kylin主要註冊了幾個優化規則,在每一個優化規則中將對應的物理算子轉換成Kylin自己的OLAPxxxRel算子,然後再將每一個算子根據本次查詢的參數生成Calcite自身的EnumerableXXX算子執行,比較特殊的是OLAPTableScan並不會轉換成其他的算子,同樣的還有OLAPJoinRel(當執行的sql有JOIN是會產生該算子),這OLAPTableScan算子的implement函數實現如下:

@Override
public Result implement(EnumerableRelImplementor implementor, Prefer pref ) {
    PhysType physType = PhysTypeImpl. of(implementor.getTypeFactory(), this.rowType , pref .preferArray());

    String execFunction = genExecFunc();

    MethodCallExpression exprCall = Expressions.call(table.getExpression(OLAPTable. class), execFunction , implementor.getRootExpression(), Expressions.constant( context. id));
    return implementor .result(physType , Blocks.toBlock( exprCall));
}

private String genExecFunc() {
    // if the table to scan is not the fact table of cube, then it's a lookup table
    if (context .hasJoin == false && tableName.equalsIgnoreCase(context .realization .getFactTable()) == false) {
        return "executeLookupTableQuery" ;
    } else {
        return "executeIndexQuery" ;
    }
}

  可以看出它根據MethodCallExpression對象exprCall執行Blocks.toBlock生成對應的代碼段(在bind函數中調用),例如本例中生成的代碼段如下:

final org.apache.calcite.linq4j.Enumerable _inputEnumerable = ((org.apache.kylin.query.schema.OLAPTable)
     root.getRootSchema().getSubSchema("databaseName").getTable("tableName")).executeIndexQuery(root, 0);

  返回的Enumerable是由executeIndexQuery函數返回的,在genExecFunc函數中會判斷是根據之前生成的查詢上下文OLAPContext,如果本次查詢沒有join並且查詢的表不是當前使用的Cube的事實表,則使用executeLookupTableQuery函數,否則(有join或者查詢事實表)則使用executeIndexQuery函數。

  而在OLAPJoinRel的implement函數的實現則是直接使用executeIndexQuery函數。

@Override
public Result implement(EnumerableRelImplementor implementor, Prefer pref ) {
    PhysType physType = PhysTypeImpl. of(implementor.getTypeFactory(), getRowType(), pref.preferArray());
    RelOptTable factTable = context .firstTableScan .getTable();
    MethodCallExpression exprCall = Expressions.call(factTable.getExpression(OLAPTable. class), "executeIndexQuery" , implementor.getRootExpression(), Expressions.constant( context. id));
    return implementor .result(physType , Blocks.toBlock( exprCall));
}

  爲什麼是這兩個不同的函數呢?這是由於在Kylin中預計算了所有可能的組合值保存在hbase中,rowkey爲值的組合,例如A=”abc”,B=”xyz”就對應着一條記錄,value爲select count(1), sum(X) from table where A=”abc” and B=”xyz”的返回值,所以對於事實表中的數據都是需要進行計算的,保存在hbase中,只能通過訪問hbase獲取,而Kylin會保存所有維度表的信息,在內存中生成SnapshotTable,這樣對維度表的查詢則不需要掃描hbase了。

Kylin從Hbase中獲取數據

  上面吧Calcite解析和執行部分介紹完了,在bind函數中需要返回一個Enumerable對象給Calcite執行接下來的過濾、Project、groupBy、orderBy、limit等操作,這裏不關注只對維度表的查詢,而是看一下Kylin如何從Hbase中獲取數據的。首先這個Enumerable對象時OLAPTable的executeIndexQuery函數返回的。

public Enumerable<Object[]> executeIndexQuery(DataContext optiqContext, int ctxSeq) {
    return new OLAPQuery(optiqContext, EnumeratorTypeEnum. INDEX, ctxSeq );
 }

  它的enumerator函數如下:

 public Enumerator<Object[]> enumerator() {
    OLAPContext olapContext = OLAPContext.getThreadLocalContextById( contextId);
    switch (type ) {
    case INDEX :
        return new CubeEnumerator(olapContext, optiqContext);
    case LOOKUP_TABLE :
        return new LookupTableEnumerator(olapContext);
    case HIVE :
        return new HiveEnumerator(olapContext);
    default:
        throw new IllegalArgumentException("Wrong type " + type + "!");
    }
}

  在CubeEnumerator中主要由current返回當前的數據,moveNext查看是否還有數據,它們完成了一個迭代器的功能:

@Override
public Object[] current() {
    return current ;
}

@Override
public boolean moveNext() {
    if (cursor == null) {
        cursor = queryStorage();
    }

    if (!cursor .hasNext()) {
        return false ;
    }

    ITuple tuple = cursor.next();
    if (tuple == null) {
        return false ;
    }
    convertCurrentRow (tuple );
    return true ;
}

  queryStorage函數返回一個迭代器,所有的數據都是通過這個迭代器獲得,其中current變量是在covertCurrentRow函數中根據hbase中的數據解碼之後的值,爲什麼需要解碼呢?首先hbase中存儲的都是二進制的數據,然後由於維度的成員的值可能會佔用很大的空間,如果存儲原始值的話會造成:1、hbase存儲空間增大,2、相同cuboid的rowkey的長度不一樣,所以Kylin在構建Cube的時候會將每一個維度下的成員進行編碼,每一個維度中的每一個成員編碼程一個從0開始的整數值,存儲在hbase中的數據是這些編碼值的二進制組合,因此讀取到這些值之後需要解碼獲取原始的維度值。

  querySorage函數主要執行邏輯:

    IStorageEngine storageEngine = StorageEngineFactory.getStorageEngine( olapContext.realization );
    ITupleIterator iterator = storageEngine.search(olapContext .storageContext , olapContext.getSQLDigest());

  首先根據本次查詢選中的Cube生成storageEngine對象,然後通過search方法返回一個迭代器,從其中獲取全部數據。CubeStorageEngine是在Cube中獲取數據使用的engine,它的search方法執行邏輯如下:

這裏寫圖片描述

  由於在線程局部變量中保存了本次查詢的OLAPContext,可以根據它保存的信息獲取根據哪些列group by和filter,以及對哪些度量進行計算,此時需要考慮derived維度,這種維度實際上會被它所在的維度表的主鍵代替,所以需要將這些列轉換爲主鍵列,並根據snapshotTable修改filter對象,然後判斷本次查詢是否需要啓動hbase的coprocessor,Kylin對於每一個htable都設置了一個observer類型的coprocessor,當執行scan操作之前會回調這個類的doPostScannerObserver函數,執行對錶中的原始記錄執行一些過濾和聚合運算,這樣可以減小每一個scan返回的記錄數,例如執行select A,count(1) from table where B > 1 and C not in (”) group by A,這樣的查詢可以根據B>1計算出本次查詢需要掃描的rowkey範圍,而C not in (”)則需要在coprocessor對掃描獲得的每一條記錄執行判斷,如果滿足纔可以從hbase中返回。例如上例中查詢出現了A/B/C維度,但是這個cuboid並沒有預計算,此時只能定位到A/B/C/D這個cuboid,在coprocessor中需要再根據D這一列執行聚合,進一步減小返回記錄數。

關於內存

  • 1、首先在coprocessor中,它是在hbase的regionServer中執行的,所以不能佔用hbase太多的內存,Kylin在這裏做的內存限制是500MB,因爲需要執行額外的聚合運算,因此在coprocessor中維護了一個map保存每一個需要返回的記錄並且持續的執行聚合運算,但是如果查詢中帶有distinct count的聚合運算,Kylin使用HLL實現的,每一個聚合值大概佔用32KB大小(根據精確度),所以如果查詢中有這樣的聚合函數會很快消耗完這些內存,所以這種聚合的查詢不會啓動coprocessor。

  • 2、對於返回的記錄,只是原始的數據,需要再交給calcite完成下面的聚合、過濾和排序等操作,但是既然coprocessor中都已經把過濾和聚合做完了,爲什麼還要在coprocessor中做呢?filter的確是在Kylin中已經完成的了,再使用Calcite執行過濾是爲了正確性的保證,但是這樣也限制的Kylin不能支持全部的Calcite的過濾(這裏可以擴展,Kylin只處理自己能處理的,剩餘由Calcite處理),至於還需要聚合運算是因爲一個Cube查詢可能涉及到多個segment,因此這些segment可能返回相同的key,此時就需要Calcite執行聚合運算,運算函數是由Kylin指定的,但是需要將所有從hbase中返回的記錄保存在內存中,Kylin爲每一個查詢設置了最大內存內存上線爲3GB,根據每一個key-value的大小計算出hbase最多返回的記錄數,如果超出這個數則根據配置是否接受部分結果,如果不接受則返回查詢失敗,如果接受則指根據已返回的記錄進行Calcite的運算,可能出現錯誤。

獲取數據

  將filter轉換爲扁平式的使用AND連接filter,然後每一個childFilter可以根據不同的segment生成一個keyRange,這裏成爲ColumnKeyRange,每一個segment中有多個ColumnKeyRange,由於每一個segment對應着一個htable,所以首先會嘗試merge每一個segment下的ColumnKeyRange(根據是否有重合的範圍),生成多個HBaseKeyRange(merge之後的多個範圍,直接對應着hbase中rowkey的範圍),根據這些HBaseKeyRange生成SerializedHBaseTupleIterator。

  在這個SerializedHBaseTupleIterator迭代器中按照每一個segment下的HbaseKeyRange創建一個map,segment爲key,這個segment下需要掃面的HbaseKeyRange數組作爲value,然後爲每一個Segment創建一個CubeSegmentTupleIterator對象,它中保持了多個HbaseKeyRange,然後對每一個HbaseKeyRange創建Scan對象,接着使用該對象向Hbase發起一個scan請求,上面每一個迭代器都是對它包含的迭代器數組的封裝。

總結

  本文介紹了Kylin如何處理Sql的查詢,充分利用了Calcite的sql解析和優化的功能,可以看到Calcite是一個非常強大的SQL引擎框架,Kylin較深入的定製了Calcite的功能,對於Calcite的初級使用可以參考:http://blog.csdn.net/yu616568/article/details/49915577,而Kylin提供從Hbase中讀取數據返回前端又有點類似於phoenix的做法(它也是通過Calcite完成解析和優化的),但是後者更加通用一些。Kylin 2.0中把存儲做成插件式的,理論上可以支持更多的存儲組件(需要支持scan和類似coprocessor的功能啊),但是基本上查詢流程是類似的。本文如果有什麼錯誤,還請多多指正,謝謝~

發佈了70 篇原創文章 · 獲贊 131 · 訪問量 55萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章