一、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内部组件关系
以上这张图来源于论文中,我重新画过,交互流程比较详细;其中:
- metadata providers:向优化器提供数据的信息,用于优化器优化AST;比如,数据的基数,那些列有索引等
- 外部数据处理系统与calcite通过Operator Ast进行交互;比如,如果外部系统支持SQL,则可以通过Operator Ast生成SQL与外部系统交互,如果不支持需要实现Express Builder,生成外部系统可以执行的表达式;
2.3 处理流程
- SQL解析: calcite使用java cc解析SQL,解析成语法树,解析模板定义在Parser.jj中,生成Ast由SqlNode表示;
- Validate SQL:检查SQL语法是否正确,语法检查需要知道元数据信息,元数据信息由外部系统提供;检查的范围包括表名,字段名,函数名,数据类型;
- SqlToRel: 将SqlNode转化成RelNode/RexNode,也就是LogicalPlan;使用SqlToRelConverter转换;
- Optimizer:查询优化器,calcite的查询优化分为RBO和CBO,RBO基于规则的优化,CBO是基于统计信息代价的优化;本质上都是参考火山模型,在查询空间中寻找出best plan;算法参考:查询优化器
三、使用示例
根据第二章节的图,calcite的使用主要分成以下三个方面:
- 向calcite提供meta数据,calcite用于验证,解析,优化AST
- 给calcite增加Plugable Rule,新增自己的优化规则
- 将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 定义SchemaFactory、Schema、...
继承并实现接口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;
规则匹配过程
- 根据规则的operand类型和节点类型匹配,过滤规则能用到节点
- 调用规则的matches方法判断规则能否应用到节点
- 调用的规则的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的总体设计图
如上图所示(参考官方文档),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、本地计算;