【Flink SQL】Apache Calcite 架構剖析

1.簡介和架構

Apache Calcite 是一個動態的數據管理框架, 可以實現 SQL 的解析、驗證、優化和執行。Calcite 是模塊化插件式的, 解析、驗證、優化和執行的步驟都對應著一個相對獨立的模塊。用戶可以選擇使用其中的一個或多個模塊,也可以對任意模型進行定制化擴展。

Calcite 的架構如下圖所示,
JDBC:構建了一個獨立的 Avatica 框架,可以通過標準的 JDBC 接口訪問 Calcite 獲取數據。
SQL ParserSQL Validator:可以進行 SQL 的解析和驗證,,并將原始的 SQL 字符串解析并轉化為內部的 SqlNode 樹來表示。
Query Optimizer:進行查詢優化,,基于在關系代數在 Calcite 內部有一種關系代數表示方法,將關系代數表示為 RelNode 樹。RelNode 樹不只是由 SqlNode 樹轉化而來,也可以通過Calcite 提供的 Expressions Builder 接口構建。

image.png

說明:Calcite 包含許多組成典型數據庫管理系統的部件,但是省略了一些關鍵的組成部分,例如數據的存儲、處理數據的算法和存儲元數據的存儲庫等。因為對不同的數據類型有不同的存儲和計算引擎,是不可能將它們統一到一個框架的,所以 Calcite 是一個統一的 SQL 接口實現數據訪問框架

2.SQL 處理流程

如下圖所示,Calcite 的處理流程實際上就是 SQL 的解析、校驗、優化和執行。
Parser:解析 SQL,將輸入的 SQL 字符串轉化為抽象語法樹(AST),即 SqlNode 樹表示
Validator:根據元數據信息對 SqlNode 樹進行驗證, 其輸出仍是 SqlNode
Converter:將 SqlNode 樹轉化為關系代數,其中 RelNode 樹表示關系代數
Optimizer:對輸入的關系代數進行優化,并輸出優化后的 RelNode
Execute:根據優化后的 RelNode 生成執行計劃

image.png

demo利用 Calcite 實現使用 SQL 訪問 CSV 文件

3.案例分析

users 表的內容:

id:string,name:string,age:int
1,Jack,28
2,John,21
3,Tom,32
4,Peter,24

orders 表內容:

id:string,user_id:string,goods:string,price:double
001,1,Cola,3.5
002,1,Hat,38.9
003,2,Shoes,199.9
004,3,Book,39.9
005,4,Phone,2499.9

查詢的 SQL 語句:

SELECT u.id, name, age, sum(price)
FROM users AS u join orders AS o ON u.id = o.user_id
WHERE age >= 20 AND age <= 30
GROUP BY u.id, name, age
ORDER BY u.id

3.1 SQL 解析

通過詞法分析和語法分析將 SQL 字符串轉化為 AST。在Calcite中, 借助 JavaCC 實現了 SQL 的解析, 并轉化為 SqlNode 表示。
SqlNode 是 AST 的抽象基類,不同類型的節點有對應的實現類。下面的 SQL 語句會生成 SqlSelectSqlOrderBy 兩個主要的節點。

String sql = "SELECT u.id, name, age, sum(price) " +
    "FROM users AS u join orders AS o ON u.id = o.user_id " +
    "WHERE age >= 20 AND age <= 30 " +
    "GROUP BY u.id, name, age " +
    "ORDER BY u.id";
// 創建SqlParser, 用于解析SQL字符串
SqlParser parser = SqlParser.create(sql, SqlParser.Config.DEFAULT);
// 解析SQL字符串, 生成SqlNode樹
SqlNode rootSqlNode = parser.parseStmt();

上述代碼中的 rootSqlNode 是 AST 的根節點。如下圖所示,可以看到 rootSqlNodeSqlOrderBy 類型,其中 query 字段是一個 SqlSelect 類型,即代表原始的 SQL 語句去掉ORDER BY 部分。

image.png

3.2 SQL 校驗

SQL 校驗階段一方面會借助元數據信息執行上述驗證,另一方面會對 SqlNode 樹進行一些改寫,以轉化為統一的格式。

// 創建Schema, 一個Schema中包含多個表
SimpleTable userTable = SimpleTable.newBuilder("users")
    .addField("id", SqlTypeName.VARCHAR)
    .addField("name", SqlTypeName.VARCHAR)
    .addField("age", SqlTypeName.INTEGER)
    .withFilePath("/path/to/user.csv")
    .withRowCount(10)
    .build();
SimpleTable orderTable = SimpleTable.newBuilder("orders")
    .addField("id", SqlTypeName.VARCHAR)
    .addField("user_id", SqlTypeName.VARCHAR)
    .addField("goods", SqlTypeName.VARCHAR)
    .addField("price", SqlTypeName.DECIMAL)
    .withFilePath("/path/to/order.csv")
    .withRowCount(10)
    .build();
SimpleSchema schema = SimpleSchema.newBuilder("s")
    .addTable(userTable)
    .addTable(orderTable)
    .build();    
CalciteSchema rootSchema = CalciteSchema.createRootSchema(false, false);
rootSchema.add(schema.getSchemaName(), schema);

RelDataTypeFactory typeFactory = new JavaTypeFactoryImpl();

// 創建CatalogReader, 用于指示如何讀取Schema信息
Prepare.CatalogReader catalogReader = new CalciteCatalogReader(
    rootSchema,
    Collections.singletonList(schema.getSchemaName()),
    typeFactory,
    config);
// 創建SqlValidator, 用于執行SQL驗證
SqlValidator.Config validatorConfig = SqlValidator.Config.DEFAULT
    .withLenientOperatorLookup(config.lenientOperatorLookup())
    .withSqlConformance(config.conformance())
    .withDefaultNullCollation(config.defaultNullCollation())
    .withIdentifierExpansion(true);
SqlValidator validator = SqlValidatorUtil.newValidator(
    SqlStdOperatorTable.instance(), catalogReader, typeFactory, validatorConfig);
// 執行SQL驗證
SqlNode validateSqlNode = validator.validate(node);

如下圖可知,SQL 驗證后的輸出結果仍是 SqlNode 樹。不過其內部結構發生了改變,一個明顯的變化是驗證后的 SqlOrderBy 節點被改寫為了 SqlSelect 節點,并在其 orderBy 變量中記錄了排序字段。

image.png

如果把表名或者字段寫錯,validator.validate(node) 運行時在就會報錯。如果把驗證前后的SqlNode 完全打印出來,可以發現在校驗時會為每個字段加上表名限定。

-- 驗證前的SqlNode樹打印結果
SELECT `u`.`id`, `name`, `age`, SUM(`price`)
FROM `users` AS `u`
INNER JOIN `orders` AS `o` ON `u`.`id` = `o`.`user_id`
WHERE `age` >= 20 AND `age` <= 30
GROUP BY `u`.`id`, `name`, `age`
ORDER BY `u`.`id`

-- 驗證后的SqlNode樹打印結果
SELECT `u`.`id`, `u`.`name`, `u`.`age`, SUM(`o`.`price`)
FROM `s`.`users` AS `u`
INNER JOIN `s`.`orders` AS `o` ON `u`.`id` = `o`.`user_id`
WHERE `u`.`age` >= 20 AND `u`.`age` <= 30
GROUP BY `u`.`id`, `u`.`name`, `u`.`age`
ORDER BY `u`.`id`

3.3 轉換為關系代數 RelNode

關系代數是 SQL 的理論基礎,可以閱讀 Introduction of Relational Algebra in DBMS簡單了解,其中“數據庫系統概念“中對關系代數有更深入的介紹。

在 Calcite 中, 關系代數由 RelNode 表示。如下代碼所示,將校驗后的 SqlNode 樹轉化為RelNode樹。

// 創建VolcanoPlanner, VolcanoPlanner在后面的優化中還需要用到
VolcanoPlanner planner = new VolcanoPlanner(RelOptCostImpl.FACTORY, Contexts.of(config));
planner.addRelTraitDef(ConventionTraitDef.INSTANCE);
// 創建SqlToRelConverter
RelOptCluster cluster = RelOptCluster.create(planner, new RexBuilder(typeFactory));
SqlToRelConverter.Config converterConfig = SqlToRelConverter.config()
    .withTrimUnusedFields(true)
    .withExpand(false);
SqlToRelConverter converter = new SqlToRelConverter(
    null,
    validator,
    catalogReader,
    cluster,
    StandardConvertletTable.INSTANCE,
    converterConfig);
// 將SqlNode樹轉化為RelNode樹
RelNode relNode = converter.convertQuery(validateSqlNode, false, true);

RelNode 樹實質上是一個邏輯執行計劃,上述 SQL 對應的邏輯執行計劃如下,其中每一行都表示一個節點,即 RelNode 的實現類。

LogicalSort(sort0=[$0], dir0=[ASC])
  LogicalAggregate(group=[{0, 1, 2}], EXPR$3=[SUM($3)])
    LogicalProject(id=[$0], name=[$1], age=[$2], price=[$6])
      LogicalFilter(condition=[AND(>=($2, 20), <=($2, 30))])
        LogicalJoin(condition=[=($0, $4)], joinType=[inner])
          LogicalTableScan(table=[[s, users]])
          LogicalTableScan(table=[[s, orders]])

3.4 查詢優化

查詢優化是 Calcite 的核心模塊,主要有三部分組成:
Planner rules:優化規則,例如內置優化規則有謂詞下推、投影下推等。用戶也可定義自己的優化規則。
Metadata providers:元數據,主要用于基于成本的優化(Cost-based Optimize 即 CBO),包括表的行數、表的大小、給定列的值是否唯一等信息。
Planner engines:優化器實現,HepPlanner 用于實現基于規則的優化(Rule-based Optimize 即 RBO),VolcanoPlanner 用于實現基于成本的優化。

// 優化規則
RuleSet rules = RuleSets.ofList(
    CoreRules.FILTER_TO_CALC,
    CoreRules.PROJECT_TO_CALC,
    CoreRules.FILTER_CALC_MERGE,
    CoreRules.PROJECT_CALC_MERGE,
    CoreRules.FILTER_INTO_JOIN,     // 過濾謂詞下推到Join之前
    EnumerableRules.ENUMERABLE_TABLE_SCAN_RULE,
    EnumerableRules.ENUMERABLE_PROJECT_TO_CALC_RULE,
    EnumerableRules.ENUMERABLE_FILTER_TO_CALC_RULE,
    EnumerableRules.ENUMERABLE_JOIN_RULE,
    EnumerableRules.ENUMERABLE_SORT_RULE,
    EnumerableRules.ENUMERABLE_CALC_RULE,
    EnumerableRules.ENUMERABLE_AGGREGATE_RULE);
Program program = Programs.of(RuleSets.ofList(rules));
RelNode optimizerRelTree = program.run(
    planner,
    relNode,
    relNode.getTraitSet().plus(EnumerableConvention.INSTANCE),
    Collections.emptyList(),
    Collections.emptyList());

經過優化后的輸出如下,可知所有的節點都變成了 Enumerable 開頭的物理節點,其基類是EnumerableRel

EnumerableSort(sort0=[$0], dir0=[ASC])
  EnumerableAggregate(group=[{0, 1, 2}], EXPR$3=[SUM($3)])
    EnumerableCalc(expr#0..6=[{inputs}], proj#0..2=[{exprs}], price=[$t6])
      EnumerableHashJoin(condition=[=($0, $4)], joinType=[inner])
        EnumerableCalc(expr#0..2=[{inputs}], expr#3=[Sarg[[20..30]]], expr#4=[SEARCH($t2, $t3)], proj#0..2=[{exprs}], $condition=[$t4])
          EnumerableTableScan(table=[[s, users]])
        EnumerableTableScan(table=[[s, orders]])

優化前后的計劃:users 表的過濾位置發生了變動,從先 Join 后過濾,變成了先過濾后 Join,如下圖所示。

image.png

3.5 執行計劃

將物理計劃轉化為執行計劃通常需要自定義代碼。Calcite 提供了一種執行計劃生成方法,如下所示,可以生成執行計劃并讀取CSV文件中的數據。

EnumerableRel enumerable = (EnumerableRel) optimizerRelTree;
Map<String, Object> internalParameters = new LinkedHashMap<>();
EnumerableRel.Prefer prefer = EnumerableRel.Prefer.ARRAY;
Bindable bindable = EnumerableInterpretable.toBindable(internalParameters,
                                                       null, enumerable, prefer);
Enumerable bind = bindable.bind(new SimpleDataContext(rootSchema.plus()));
Enumerator enumerator = bind.enumerator();
while (enumerator.moveNext()) {
    Object current = enumerator.current();
    Object[] values = (Object[]) current;
    StringBuilder sb = new StringBuilder();
    for (Object v : values) {
        sb.append(v).append(",");
    }
    sb.setLength(sb.length() - 1);
    System.out.println(sb);
}

執行結果:

1,Jack,28,42.40
2,John,21,199.90
4,Peter,24,2499.90

參考
[1] SQL over anything with an Optiq Adapter
[2] Apache Calcite 處理流程詳解(一)
[3] 編譯原理實踐 - JavaCC 解析表達式并生成抽象語法樹.

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,739評論 6 534
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,634評論 3 419
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,653評論 0 377
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,063評論 1 314
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,835評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,235評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,315評論 3 442
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,459評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,000評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,819評論 3 355
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,004評論 1 370
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,560評論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,257評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,676評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,937評論 1 288
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,717評論 3 393
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,003評論 2 374

推薦閱讀更多精彩內容