不想看文章直接访问https://github.com/yuqi1129/schema/tree/master/mysql-protocol
(Java版本的Mysql)、https://github.com/yuqi1129/calcite-test,这里有关于Calcite RBO,CBO使用具体用例
CBO 概念
提到CBO(Cost based optimization) , 如果学习过Spark SQL, HSQ的读者应该对此不会陌生,CBO的主要思想是利用等价表式替换的方式加上代价计算框架和模型迭代来优化一个SQL执行计划, 也就是说
CBO = RBO + Cost Model + Model Iteration
即CBO 是以RBO为基础, 通过代价模型,在一定的时间空间范围内重复迭代代价模型来获得最终的执行计划
以下简单的列子
图中为SQL中常碰到的三张表Join问题,Calicte中内置的JoinAssocaiteRule 会将的LDT(Left deep tree) 转换成右边的执行计划, 从左图到右图这个转化在RBO就已经有,不同的是CBO会在转化过程中记录每次转化中总的执行代价,最终会选择代价最小执行计划为最终的的执行结果。本文的内容主要是细说Calcite代价计算模型与方法,关于CBO具体的执行优化步骤,会在后续的文章中慢慢说明.
Calcite 代价模型
Calcite 的代价计算模型为Volcano/Cascades模型, 关于Volcano/Cascades模型,大家可以参考一下The Volcano Optimizer Generator: Extensibility and Efficient Search、The Cascades Framework for Query Optimization
, 其主要思想是采用动态的规划中的贪心算法计算执行计划的代价。Cascades 相对于Volcano
的区别在于Volcano先枚举所有可能的执行计划后面再统一计算代价并最终选择最低的代价作为最后结果,而Cascades会在枚举的过程进行局部最优优化即修枝剪枝,使得需要迭代的执行计划空间不会像Vocano那么大的也可以获得相对较好的执行计划。Cascades 模型通过在一定的时间活代价范围内迭代RBO的Rule, 每次迭代时比较当前的计划与迭代前的计划的代价,选择代价较低的计划并保存,最终生成一个次优的执行计划,之所以称之为次优,Cascades 模型只会遍历部分计划空间寻找局部最优的执行计划,如果要遍历所有的执行,一来是所有执行计划空间非常庞大,实现难度较大,二来遍历所有执行计划空间得不偿失,可能一句SQL花费在执行计划优化的时候比整个SQL的执行时间还长,考虑到以上因素,Cascades模型通过贪心算法,每次迭代找到局部最优的执行计划,反复执行该步骤一定次数后,最终得到次优的执行计划。
Calcite 代价
Calcite中代价的接口为 RelOptCost
, Volcano(Calcite中最新实现的其实是Cascades, 但名称还是叫Volcano)模型中cost代价为 VolcanoCost
, 其中Cost主要有以下三个部分组成:
- RowCount, RelNode代表的数据行数
- CPU 所消耗的CPU
- IO 所需要的IO
目前Calcite中Cost模型主要是以上面三个方面的代价,那么问题来了,如何计划一个RelNode的代价呢?
Calcite 代价计算主要交给RelMetadataQuery
计算, 核心代码见下:
public final JaninoRelMetadataProvider metadataProvider;
//获取RelNode的数据的行数
public Double getRowCount(RelNode rel) {
for (;;) {
try {
Double result = rowCountHandler.getRowCount(rel, this);
return validateResult(result);
} catch (JaninoRelMetadataProvider.NoHandler e) {
rowCountHandler = revise(e.relClass, BuiltInMetadata.RowCount.DEF);
}
}
}
// 获取RelNode的累加代价,累加代价即该RelNode与其所有的input代价和
public RelOptCost getCumulativeCost(RelNode rel) {
for (;;) {
try {
return cumulativeCostHandler.getCumulativeCost(rel, this);
} catch (JaninoRelMetadataProvider.NoHandler e) {
cumulativeCostHandler =
revise(e.relClass, BuiltInMetadata.CumulativeCost.DEF);
}
}
}
现在细说一下RelMetadataQuery
中相关field和method的作用和工作原理
RelMetadataProvider
RelMetadataProvider
是为Relnode提供元数据的接口,默认实现是JaninoRelMetadataProvider
, 该方法是通过CodeGen的方式动态获取元数据据Handler
元数据
什么是元数据,准确来讲是指RelNode所代表的数据的元数据,Collation
, Distribution
, RowCount
等等, 这些元数据定义RelNode所代表数据的特征,根据这些特征进行后续的CBO和RBO优化(RBO也用到了元数据,比如relNode的Collation 是排序好的,那么可以消除显示的Sort)
元数据的原始接口为Metadata, 各种不同的元数据通过扩展该接口实现不同的定义。
元数据只是定义相应的接口,在哪里实现呢?每一个元数据接口中定义一个 获取该元数据值的方法,同时定义Hander接口,Handler接口中也定义了同名的方法,见:
public interface CumulativeCost extends Metadata {
MetadataDef<CumulativeCost> DEF = MetadataDef.of(CumulativeCost.class,
CumulativeCost.Handler.class, BuiltInMethod.CUMULATIVE_COST.method);
RelOptCost getCumulativeCost();
/** Handler API. */
interface Handler extends MetadataHandler<CumulativeCost> {
RelOptCost getCumulativeCost(RelNode r, RelMetadataQuery mq);
}
}
获取元数据值最终会调用Handler中的同名函数的。
Handler
看过Calcite源码的同学或许很其怪, Hanlder接口有默认实现,我刚开始接触这一块也没有弄明白怎么回事,实际上每个Handler是通过动态代码生成技术来实现相应的接口。
现在就以下代码为例:
// Volcano中获取一个RelNode的累加Cost方法,
public RelOptCost getCost(RelNode rel, RelMetadataQuery mq) {
...
//重点,通过RelMetadataQuery获取Cost
RelOptCost cost = mq.getNonCumulativeCost(rel);
for (RelNode input : rel.getInputs()) {
cost = cost.plus(getCost(input, mq));
}
...
return cost;
}
public RelOptCost getNonCumulativeCost(RelNode rel) {
for (;;) {
try {
//调定对应的handler来获取Cost
return nonCumulativeCostHandler.getNonCumulativeCost(rel, this);
} catch (JaninoRelMetadataProvider.NoHandler e) {
//注意这个Reivse方法, 具体内容在这里
nonCumulativeCostHandler =
revise(e.relClass, BuiltInMetadata.NonCumulativeCost.DEF);
}
}
}
//nonCumulativeCostHandler 初始逻辑
public RelMetadataQuery() {
...
// init Handler
this.nonCumulativeCostHandler = initialHandler(BuiltInMetadata.NonCumulativeCost.Handler.class);
...
}
// init handler 主要逻辑,能过Rroxy, 生成实例,该方法没做什么内容,直接抛出异常
protected static <H> H initialHandler(Class<H> handlerClass) {
return handlerClass.cast(
Proxy.newProxyInstance(RelMetadataQuery.class.getClassLoader(),
new Class[] {handlerClass}, (proxy, method, args) -> {
final RelNode r = (RelNode) args[0];
throw new JaninoRelMetadataProvider.NoHandler(r.getClass());
}));
}
以上逻辑总结来说:
在初始化时,能过代理实例出一个抛异常的handler, 而在获取对实的元数据时,如果抛了异常,就会调用revise方法来订正,直到得到结果。现在来看一下revise具体做了
protected <M extends Metadata, H extends MetadataHandler<M>> H
revise(Class<? extends RelNode> class_, MetadataDef<M> def) {
return metadataProvider.revise(class_, def);
}
最终调用HANDLERS.get()
//Google cache中获取
private static final LoadingCache<Key, MetadataHandler> HANDLERS =
maxSize(CacheBuilder.newBuilder(), CalciteSystemProperty.METADATA_HANDLER_CACHE_MAXIMUM_SIZE.value())
.build(
CacheLoader.from(key ->
load3(key.def, key.provider.handlers(key.def),
key.relClasses)));
load3
方法通过代码拼接、内存编译、实例化等过程,生所的类方法如下:
public final class GeneratedMetadataHandler_NonCumulativeCost implements org.apache.calcite.rel.metadata.BuiltInMetadata.NonCumulativeCost.Handler {
private final java.util.List relClasses;
public final org.apache.calcite.rel.metadata.RelMdPercentageOriginalRows provider0;
public GeneratedMetadataHandler_NonCumulativeCost(java.util.List relClasses,
org.apache.calcite.rel.metadata.RelMdPercentageOriginalRows provider0) {
this.relClasses = relClasses;
this.provider0 = provider0;
}
public org.apache.calcite.rel.metadata.MetadataDef getDef() {
return org.apache.calcite.rel.metadata.BuiltInMetadata$NonCumulativeCost.DEF;
}
public org.apache.calcite.plan.RelOptCost getNonCumulativeCost(
org.apache.calcite.rel.RelNode r,
org.apache.calcite.rel.metadata.RelMetadataQuery mq) {
final java.util.List key = org.apache.calcite.runtime.FlatLists.of(org.apache.calcite.rel.metadata.BuiltInMetadata$NonCumulativeCost.DEF, r);
final Object v = mq.map.get(key);
if (v != null) {
if (v == org.apache.calcite.rel.metadata.NullSentinel.ACTIVE) {
throw org.apache.calcite.rel.metadata.CyclicMetadataException.INSTANCE;
}
if (v == org.apache.calcite.rel.metadata.NullSentinel.INSTANCE) {
return null;
}
return (org.apache.calcite.plan.RelOptCost) v;
}
mq.map.put(key,org.apache.calcite.rel.metadata.NullSentinel.ACTIVE);
try {
final org.apache.calcite.plan.RelOptCost x = getNonCumulativeCost_(r, mq);
mq.map.put(key, org.apache.calcite.rel.metadata.NullSentinel.mask(x));
return x;
} catch (java.lang.Exception e) {
mq.map.remove(key);
throw e;
}
}
// 最终调用函数
private org.apache.calcite.plan.RelOptCost getNonCumulativeCost_(
org.apache.calcite.rel.RelNode r,
org.apache.calcite.rel.metadata.RelMetadataQuery mq) {
switch (relClasses.indexOf(r.getClass())) {
default:
/*走这里,provider0 指的是RelMdPercentageOriginalRows, getNonCumulativeCost 函数返回结果如下:
public RelOptCost getNonCumulativeCost(RelNode rel, RelMetadataQuery mq) {
return rel.computeSelfCost(rel.getCluster().getPlanner(), mq);
}
rel.computeSelfCost() 函数为RelNode中方法,也就是说用户需要自行定义
computeSelfCost()方法的实现逻辑,如可实现可以参考默认实现
*/
return provider0.getNonCumulativeCost((org.apache.calcite.rel.RelNode) r, mq);
case 3:
return getNonCumulativeCost(((org.apache.calcite.plan.hep.HepRelVertex) r).getCurrentRel(), mq);
case -1:
throw new org.apache.calcite.rel.metadata.JaninoRelMetadataProvider$NoHandler(r.getClass());
}
}
}
总结来说:
RelMetadataQuery
中实现了RelNode的各种元数据计算逻辑,计算逻辑是通过MetadataHandler
来实现的,而MetadataHandler
是通过CodeGen动态生成代码来实现的。
关于RelNode的computeSelfCost
的方法,可以参考TableScan
的computeSelfCost
方法,在构造CBO优化器时,需要重写当前Convention
各个RelNode的computeSelfCost
方法。TableScan
中可以通过查全表的形式获取RelOptCost
的rowcount属性,后续的代价计算可以通过TableScan
获取,比如说默认Filter的rowcount, 只是默认实现比较原始,统计结果无法直接用于实际场景,故用户需要自行实现computeSelfCost
方法
@Override public RelOptCost computeSelfCost(RelOptPlanner planner, RelMetadataQuery mq) {
//Filter 代价直接设置成其输入的0.1倍
return super.computeSelfCost(planner, mq).multiplyBy(0.1);
}
上述是以CumulativeCost
这个元数据为例,在RelMetadataQuery
中还有许多其它的元数据实现,如Distribution
、Collation
等,感兴趣的同学可以自行看一下。
总结
本文简要说明了一个Calcite CBO 代价计算的基本逻辑及流程,关于CBO中CBO的执行流程,请参考下后续文章。