我们先参考其他 Connector 中的大概代码布局,发现大部分 Connector 中,都有一个 optimizer 的相关实现,参考发现,相关的查询优化逻辑大部分都是在这里面来实现。
这里需要明确,到这一步的时候 SQL 已经经过 Presto 核心的一些相关优化了,这里面属于对应 Connector 的专属优化。
以接下来要举的例子,比如我的查询在 Presto Coordinator 中已经完成了优化,但是谓词下推的优化还不能正常推到对应的引擎中(Redis、Clickhouse、以及 Paimon 等,简单举例,如果不下推下去的话,就相当于把所有的数据都查到 Presto 中,然后在 Presto 引擎中再去过滤、计算等等),此时需要就查询计划按照对应引擎允许下推的方式重新优化。
(注意!这里跟包名没关系,只是约定都使用 optimizer 等)
Presto 中,需要实现 ConnectorPlanOptimizerProvider 接口,即可完成对应 Connector 的查询计划的逻辑优化与物理优化。
我们实现对应的查询优化即完成前面说的下推,这里我们可以先思考一个问题,是在逻辑优化中实现,还是在物理优化中实现呢,而且看了 Presto 源码中其他引擎的 Connector 的优化时,在逻辑优化与物理优化的实现中都有,那么它们的区别是什么呢?
public interface ConnectorPlanOptimizerProvider
{
/**
* The plan optimizers to be applied before having the notion of distribution.
*/
Set<ConnectorPlanOptimizer> getLogicalPlanOptimizers();
/**
* The plan optimizers to be applied after having the notion of distribution.
* The plan will be only executed on a single node.
*/
Set<ConnectorPlanOptimizer> getPhysicalPlanOptimizers();
}
参考其他的优化实现中发现,大部分都是对入参的 PlanNode 进行 visit 操作,流程则用以下伪代码表示:
public class PrestoComputePushdown implements ConnectorPlanOptimizer {
...
...
@Override
public PlanNode optimize(...) {...}
private class Visitor extends PlanVisitor<PlanNode, Void> {
...
@Override
public PlanNode visitFilter(...) {...}
@Override
public PlanNode visitAggregation(...) {...}
...
}
}
关键的优化操作,就在比如 visitFilter、visitAggregation 等相关方法实现,根据方法名的不同我们可以发现是对不同的场景进行专属优化,比如有过滤、聚合、投影、TopK、去重等等。
了解更多的信息,可以参考相关接口源码 com/facebook/presto/spi/plan/PlanVisitor.java
。
以最近实现的 presto-paimon-connector 的例子举例,详情代码如下:
这里就不贴代码了,主要来讲几个实现中的关键点。
在构建优化时,会根据我们的需要初始化一个辅助工具。比如在我们这个优化中,就用到了 StandardFunctionResolution 和 RowExpressionService。
- StandardFunctionResolution 是一个服务接口,它在Presto中用于处理与函数解析相关的操作。在SQL查询和表达式处理中,经常需要对函数(如聚合函数、数学函数、字符串函数等)进行解析和处理。
- RowExpressionService 是另一个关键服务,它在处理行表达式(Row Expressions)时发挥作用。行表达式是Presto内部用于表示列、常量、函数调用等的一种结构。在查询计划中,几乎所有的操作(如过滤、投影、聚合等)都可以表示为对行表达式的操作。
谓词下推,也算是查询计划优化的一种,所以我们在继承 ConnectorPlanOptimizer 接口后,需要实现 optimize 方法它就是我们查询计划优化的入口。
通过也会进行参数相关的联动配置,比如我根据 Session 中的配置来随时控制是否开启这个优化等等,都可以在这个入口方法中进行,起到筛选,优化准备,的一些操作。
核心就在 optimize 方法的四个入参。
- PlanNode maxSubplan,代表当前正在优化的查询计划的一部分或整个查询计划;
- ConnectorSession session,当前查询会话的上下文信息。这包括用户身份、时区、语言设置、会话特定的配置和属性;
- VariableAllocator variableAllocator,负责分配和管理查询计划中使用的变量。在构建或修改查询计划时,可能需要创建新的变量;
- PlanNodeIdAllocator idAllocator,负责为查询计划中的每个节点分配唯一的标识符(ID)。每个 PlanNode 在查询计划中都有一个唯一的ID,以区分不同的节点;
优化主要是对 PlanNode 进行操作,所以优化的思路可以按照 Parser 的 Visitor 模型来理解,核心就是找到 Node 然后进行各种操作。
操作的方式一般分为两种,可能还有其他的技法,我目前只发现了两种。
在 Presto 中解析 SQL 后,执行计划会使用 PlanNode 抽象类来表示 SQL 中的各个节点。
先用我简单的理解描述一下这个方法,accept 方法就是访问者设计模式(Visitor Pattern)的一部分。这种模式在处理对象结构(如树或图)时非常有用,特别是当你需要对这些结构进行操作或遍历时。一般我们的查询计划都是一个树形结构,所以我们经常要在树中遍历,迭代,此时 accept 方法就特别管用,因为它每个节点都可以接受一个访问者并将其传递给其子节点。
同时,我们有时候可能只是想到达某个节点,因为树形结构的特性,我们没法一把子直接就能搂到某个节点(这里需要有点解析器,解析SQL到AST的知识,如果不理解可以先补补),此时我们也可以通过访问者模式,在不修改节点类的情况下,到达某个节点然后进行节点的新增、修改、改写等等操作。
更重要的一点,就是解耦了数据结构的操作,因为这样的方式允许将数据结构(如查询计划树)与对它们的操作(如优化、转换)解耦。这样,可以在不改变节点类的情况下添加新的操作,大部分 SQL 都会这样来做优化。
此时我们再看下面的方法源码,就好理解了。
@Override
public PlanNode optimize(
PlanNode maxSubplan,
ConnectorSession session,
VariableAllocator variableAllocator,
PlanNodeIdAllocator idAllocator) {
if (isPaimonPushdownEnabled(session)) {
return maxSubplan.accept(new Visitor(session, idAllocator), null);
}
return maxSubplan;
}
...
...
...
/**
* The basic component of a Presto IR (logic plan).
* An IR is a tree structure with each PlanNode performing a specific operation.
*/
public abstract class PlanNode {
...
...
/**
* A visitor pattern interface to operate on IR.
*/
public <R, C> R accept(PlanVisitor<R, C> visitor, C context) { return visitor.visitPlan(this, context);
...
...
}
经过前面的介绍,我们知道核心就是要遍历 PlanNode 然后进行优化。此时我们在看看另外一种实现:
public PlanNode optimize(
PlanNode maxSubplan,
ConnectorSession session,
VariableAllocator variableAllocator,
PlanNodeIdAllocator idAllocator)
{
return rewriteWith(new Rewriter(session, idAllocator), maxSubplan);
}
这个 rewriteWith 方法是什么呢?对应源码在这 src/main/java/com/facebook/presto/spi/ConnectorPlanRewriter.java
,我这里就不贴代码了。感兴趣的可以自己去看。
我讲一些个人的简单薄见:核心都是用 accept 方法去迭代,只是 ConnectorPlanRewriter.rewriteWith 计划重写方法提供了一些默认实现,相当于提供了一个通用的重写框架,简化了遍历和修改的操作,实现会削微简单一些。
比如我现在要优化 select * from where a=1;
这个 a=1 能下推到对应引擎或存储中去,而不是全搂到 Presto 进行,这算什么优化呢?对应 visit 方法的时候,是什么呢?
基于以上的疑问,