calcite简介和使用

3,288 阅读9分钟

一、calcite介绍

calcite是一个查询引擎,提供标准的SQL语言,查询优化,连接数据源的能力;calcite的设计目标是为不同计算平台和数据源提供统一的查询引擎;特性包括:

  • 支持标准 SQL 语言;
  • 独立于编程语言和数据源,可以支持不同的前端和后端;
  • 支持关系代数、可定制的逻辑规划规则和基于成本模型优化的查询引擎;
  • 支持物化视图( materialized view)的管理(创建、丢弃、持久化和自动识别);
  • 基于物化视图的 Lattice 和 Tile 机制,应用于 OLAP 分析;
  • 支持对流数据的查询。 它是一个基础的查询模块,被很多的项目引入,比如Hive,Flink,Kylin,Spark等;其中更是支持基于物化视图对于查询SQL的重写,在某些情况下,极大的提高查询效率;

二、处理流程

本小节参考博客

2.1 基本概念

  • RelNode
    • 代表了对数据的一个处理操作,常见的操作有 Sort、Join、Project、Filter、Scan 等。它蕴含的是对整个 Relation 的操作,而不是对具体数据的处理逻辑。
  • RexNode
    • Row-level expression;对一行数据的处理逻辑;常见的行表达式包括字面量 RexLiteral, 变量 RexVariable, 函数或操作符调用 RexCall 等。 RexNode 通过 RexBuilder 进行构建。
  • RelOptRule
    • transforms an expression into another。根据 RelOptRuleOperand 来对目标 RelNode 树进行规则匹配,匹配成功后,则调用 onMatch() 进行转换。其中ConvertRule是RelOptRule的子类,专门用来做数据源之间的转换,比如说:JdbcToSparkConverterRule 调用 JdbcToSparkConverter 来完成对 JDBC Table 到 Spark RDD 的转换
  • Converter
    • 用来把一种 RelTrait 转换为另一种 RelTrait 的 RelNode。如 JdbcToSparkConverter 可以把 JDBC 里的 table 转换为 Spark RDD。如果需要在一个 RelNode 中处理来源于异构系统的逻辑表,Calcite 要求先用 Converter 把异构系统的逻辑表转换为同一种 Convention。
  • RelTrait
    • 用来定义逻辑表的物理相关属性(physical property),三种主要的 trait 类型是:Convention、RelCollation、RelDistribution;其中Convention代表是某一种特定数据源,因为不同数据源的算子计算需要转换;
  • RelOptCluster
    • palnner 运行时的环境,保存上下文信息;
  • RelOptPlanner
    • 优化器,Calcite 支持RBO(Rule-Based Optimizer) 和 CBO(Cost-Based Optimizer)。Calcite 的 RBO (HepPlanner)称为启发式优化器(heuristic implementation ),它简单地按 AST 树结构匹配所有已知规则,直到没有规则能够匹配为止;Calcite 的 CBO 称为火山式优化器(VolcanoPlanner)成本优化器也会匹配并应用规则,当整棵树的成本降低趋于稳定后,优化完成,成本优化器依赖于比较准确的成本估算。RelOptCost 和 Statistic 与成本估算相关;

2.2 calcite内部组件关系

calcite组件交互图.png 以上这张图来源于论文中,我重新画过,交互流程比较详细;其中:

  • metadata providers:向优化器提供数据的信息,用于优化器优化AST;比如,数据的基数,那些列有索引等
  • 外部数据处理系统与calcite通过Operator Ast进行交互;比如,如果外部系统支持SQL,则可以通过Operator Ast生成SQL与外部系统交互,如果不支持需要实现Express Builder,生成外部系统可以执行的表达式;

2.3 处理流程

calcite处理流程.png

  1. SQL解析: calcite使用java cc解析SQL,解析成语法树,解析模板定义在Parser.jj中,生成Ast由SqlNode表示;
  2. Validate SQL:检查SQL语法是否正确,语法检查需要知道元数据信息,元数据信息由外部系统提供;检查的范围包括表名,字段名,函数名,数据类型;
  3. SqlToRel: 将SqlNode转化成RelNode/RexNode,也就是LogicalPlan;使用SqlToRelConverter转换;
  4. Optimizer:查询优化器,calcite的查询优化分为RBO和CBO,RBO基于规则的优化,CBO是基于统计信息代价的优化;本质上都是参考火山模型,在查询空间中寻找出best plan;算法参考:查询优化器

三、使用示例

根据第二章节的图,calcite的使用主要分成以下三个方面:

  1. 向calcite提供meta数据,calcite用于验证,解析,优化AST
  2. 给calcite增加Plugable Rule,新增自己的优化规则
  3. 将calcite的Ast转化成外部系统需要形式,供外部系统执行查询

3.1 向calcite提供meta数据

3.1.1 calcite 注册模型

calcite的模型可以通过SPI机制自动注入;格式如下:

{
  version: '1.0',
  defaultSchema: 'mongo',
  schemas: [ Schema... ]
}

其中custom schema示例结构如下:

{
  name: 'mongo',
  type: 'custom',
  factory: 'calcite.csv.read.factory.FileSchemaFactory',
  operand: {
    "schemaType": "csv"
  }
}

jdbc schema示例如下:

{
  name: 'foodmart',
  type: 'jdbc',
  jdbcDriver: TODO,
  jdbcUrl: TODO,
  jdbcUser: TODO,
  jdbcPassword: TODO,
  jdbcCatalog: TODO,
  jdbcSchema: TODO
}

3.1.2 定义SchemaFactorySchema、...

继承并实现接口SchemaFactory,接口需要返回一个Schema

public interface SchemaFactory {
  Schema create(
      SchemaPlus parentSchema,
      String name,
      Map<String, Object> operand);
}

定义Schema返回,接口Schema有一个AbstractSchema子类,我们可以继承该类,需要实现方法getTableMap;Map中key是schema的名字,value是schema中包含的Table

  protected Map<String, Table> getTableMap() {
    return ImmutableMap.of();
  }

定义Table接口的子类FileTable,需要实现接口TranslatableTable和AbstractQueryableTable;实现方法:asQueryable(标志该Table可执行),getRowType(返回该Table的列类型),toRel(将该Table转换成关系表达式);

<T> Queryable<T> asQueryable(QueryProvider queryProvider, SchemaPlus schema,
      String tableName);

RelDataType getRowType(RelDataTypeFactory typeFactory);

RelNode toRel(
    RelOptTable.ToRelContext context,
    RelOptTable relOptTable);

向calcite提供信息需要提供Schema,Schema包含一个dbName->Table的Map结构, 每一个Table能够转换成关系表达式,能够提供该Table的列信息、列类型,可以获取一个Queryable对象,用于执行;

3.1.3 直接创建FrameworkConfig注册schema

    SchemaPlus rootSchema = Frameworks.createRootSchema(true);
    // 添加表,这里只实现了getRowType,所以不能执行;
    rootSchema.add("USERS", new AbstractTable() {
    public RelDataType getRowType(final RelDataTypeFactory typeFactory) {
            RelDataTypeFactory.FieldInfoBuilder builder = typeFactory.builder();
            RelDataType t1 = typeFactory.createTypeWithNullability(typeFactory.createSqlType(SqlTypeName.INTEGER), true);
            RelDataType t2 = typeFactory.createTypeWithNullability(typeFactory.createSqlType(SqlTypeName.CHAR), true);
            builder.add("ID", t1);
            builder.add("NAME", t2);
            return builder.build();
            }
    });
    final FrameworkConfig config = Frameworks.newConfigBuilder()
        .parserConfig(SqlParser.Config.DEFAULT)
        .defaultSchema(rootSchema)
        .build();
    // 创建FrameworkConfig,并且注册schema
    Planner planner = Frameworks.getPlanner(config);
    String sql2 = "select id, name from users where id = 10";
    SqlNode parse1 = planner.parse(sql2);
    SqlNode validate = planner.validate(parse1);
    RelRoot root = planner.rel(validate);
    System.out.println(RelOptUtil.toString(root.rel));

3.2 自定义规则

3.2.1 定义规则类

public class DogProjectConverterRule extends ConverterRule {
   public static final DogProjectConverterRule INSTANCE = new DogProjectConverterRule(
         LogicalProject.class,
         Convention.NONE,
         DogRel.CONVENTION,
         "DogProjectConverter"
   );

   public DogProjectConverterRule(Class<? extends RelNode> clazz, RelTrait in, RelTrait out, String description) {
      super(clazz, in, out, description);
   }

   @Override
   public boolean matches(RelOptRuleCall call) {
      return super.matches(call);
   }

   @Override
   public RelNode convert(RelNode rel) {
      LogicalProject logicalProject = (LogicalProject) rel;
      RelNode input = convert(logicalProject.getInput(), logicalProject.getInput().getTraitSet().replace(DogRel.CONVENTION).simplify());
      return new DogProject(
            logicalProject.getCluster(),
            RelTraitSet.createEmpty().plus(DogRel.CONVENTION).plus(RelDistributionTraitDef.INSTANCE.getDefault()),
            input,
            logicalProject.getProjects(),
            logicalProject.getRowType()
      );
   }
}

说明:

  • 通常规则类都会继承ConverterRule类,ConverterRule是一个Abstract类,使用者必须实现方法RelNode convert(RelNode rel), 用于将匹配的模式节点转换成新的节点; matches(RelOptRuleCall call)方法在ConverterRule中默认返回true;
  • 同时一般还会初始化一个静态的INSTACE,用于Planner加入规则队列;初始化的参数比较重要,分别为:
    • Class<? extends RelNode> clazz: 要转换的节点的类型
    • RelTrait in: 要转换节点的特征,Trait主要有三种,关于数据源类型的、数据分布、排序
    • RelTrait out: 转换结果节点的特征
    • String description:当前规则的描述
  • 如上面的规则DogProjectConverterRule匹配类型为LogicalProject.class的节点,不要求输出节点的特征;将其输入节点和当前节点的Trait修改成DogRel.CONVENTION;

规则匹配过程

  1. 根据规则的operand类型和节点类型匹配,过滤规则能用到节点
  2. 调用规则的matches方法判断规则能否应用到节点
  3. 调用的规则的convert方法将节点转换成替换的节点

3.2.2 将规则添加到planner

    VolcanoPlanner volcanoPlanner = new VolcanoPlanner();
    volcanoPlanner.addRule(DogFilterConverterRule.INSTANCE);
    volcanoPlanner.addRule(DogProjectConverterRule.INSTANCE);
    volcanoPlanner.addRule(DogTableScanConverterRule.INSTANCE);

四、应用

calicte的应用有很多,比如Spark用来做查询计划的优化(calcite目前内置的优化规则是比较丰富,其大部分是经过前辈们的验证),flink使用calicte作为SQL API的入口,使用calcite的解析,验证,优化等功能;这里介绍一下360-quicksql,github地址,它的思想还是比较有意思的,使用calcite解析用户的SQL,根据不同的数据源,切分不同的查询计划;将最后将不同部分的查询计划通过代码生成的方式,生成代码到Spark/Flink平台跑,起到了跨源SQL计算的作用;

4.1 quick-sql的总体设计图

image.png 如上图所示(参考官方文档),quick-sql的sql解析,验证,优化部分都是用calcite的代码;如果是单一源查询,那么则直接提交到Spark/Flink直接根据SQL生成代码即可;如果是跨源查询,quick-sql使用visitor模式访问逻辑计划树,将访问数据源的部分直接拆分成单独的逻辑计划,并使用代码生成将单独逻辑计划的结果生成一个Spark/Flink的temp表,最后将原始的计划直接修改成查询零时表;
本文主要着力于介绍calcite的使用,所以我们重点介绍一下如何使用calcite拆分Mixed Query

4.2 拆分Mixed Query

假设查询输入的SQL是:

SELECT id, name
FROM depts
         INNER JOIN (
    SELECT id, name
    FROM student
    WHERE city in ('FRAMINGHAM')
) filtered
                    ON depts.name = filtered.type 

解析、验证、优化后得到的LogicalPlan如下图所示:

graph TD 
0("LogicalProject(detno,name,city,type)") --> 1("LogicalJoin(inner)")
1("LogicalJoin(inner)") --> 2("LogicalTableScan(table=depts)")
1("LogicalJoin(inner)") --> 3("LogicalProject(city,type)") 
3("LogicalProject(city,type)") --> 4("LogicalFilter(condition=[$0='FRAMINGHAM'])") 
--> 5("ElasticsearchTableScan(table=student)")

360quicksql使用visitor模式实现算子切分器,将逻辑以上逻辑计划在join节点处拆分成三个部分,如下图所示:

graph TD 
0("LogicalProject(detno,name,city,type)") --> 1("LogicalJoin(inner)")
1("LogicalJoin(inner)") --> 2("TemporaryTableScan(table=custom_name_depts_0)")
1("LogicalJoin(inner)") --> 3("TemporaryTableScan(table=student_profile_student_1)") 

graph TD
0("LogicalProject(detno,name)") --> 1("LogicalTableScan(depts)")
2("LogicalProject(city,type)") --> 3("LogicalProject(city,type)")
--> 4("LogicalFilter(condition=[$0='FRAMINGHAM'])") 
--> 5("ElasticsearchTableScan(table=student)")

4.3 代码生成结果

最后根据4.2小节拆分的结果,将各个部分生成对应系统的代码,生成逻辑比较简单,类似模板替换;感兴趣的同学可以查看工程代码的类SparkProcedureVisitor#visit方法;生成结果如下:

public class Requirement339573 extends SparkRequirement {
    public Requirement339573(SparkSession spark){
        super(spark);
    }

    public Object execute() throws Exception {
        Dataset<Row> tmp;
        { // 根据depts.csv 创建零时表 custom_name_depts_0
            spark.read()
                    .option("header", "true")
                    .option("inferSchema", "true")
                    .option("delimiter", ",")
                    .csv("/Users/mac/Desktop/workspace/open_source/Quicksql-0.7.0/data/sales/DEPTS.csv")
                    .toDF()
                    .createOrReplaceTempView("custom_name_depts");

            tmp = spark.sql("SELECT deptno, name FROM custom_name_depts");
            tmp.createOrReplaceTempView("custom_name_depts_0");
        }
        {   // 读取ES创建零时表 student_profile_student_1
            Map<String, String> config = SparkElasticsearchGenerator.config("localhost", "9025", "username", "password", "student", "{"query":{"constant_score":{"filter":{"term":{"city":"FRAMINGHAM"}}}},"_source":["city","type"]}", "246");
            tmp = JavaEsSparkSQL.esDF(spark, config);
            tmp.createOrReplaceTempView("student_profile_student_1");
        }
        // 使用查询零时表,实现查询逻辑
        String sql = "SELECT custom_name_depts_0.deptno, custom_name_depts_0.name, student_profile_student_1.city, student_profile_student_1.type FROM custom_name_depts_0 INNER JOIN student_profile_student_1 ON custom_name_depts_0.name = student_profile_student_1.type";
        tmp = spark.sql(sql);
        tmp.show();

        return null;

    }
}

如上方法,360quicksql最终实现了跨源的查询;

4.4 思考

当前实现跨源查询的一个比较流行的方案是Presto,Presto通过Connector连接不同的数据源,计算并返回查询结果;相较于本文介绍的方式,Presto查询更适合于数据量适中,不超过TB级别的查询,当数据量过大时Presto比较容易OOM,或者说对机器资源的要求更高;然后本文的方式可以根据各个查询engine的优点,选中Spark、Flink、本地计算;