Doris源码——查询源码解析

目录

一、概述

 二、查询规划器

2.1 生成单节点查询计划

2.2 单节点的查询计划分布式化

2.2.1 词法语法解析

2.2.2 语义解析

2.2.3 query改写

2.2.4 单机执行计划

Join Reorder

谓词下推

2.2.5 分布式执行计划

Broadcast Join

Shuffle Join

Bucket shuffle join

Colocate join

源码分析

三、查询调度器

3.1 流程分析

3.2 源码分析

         3.2.1 prepare阶段

3.2.2 scheduler阶段

3.2.3 send阶段

四、查询执行器

4.1 单个Fragment执行流程

4.2 Fragment之间的数据交互

4.3 FE和Top Fragment数据交互

4.4 FE将数据返回给前端展示

五、总结

一、概述

   原文大佬的这篇查询源码解析写的很全面,主要是从一个sql语句出发,从使用层面和源码层面解析sql的底层执行过程。这里摘抄下来为后续的Doris慢查询分析做好技术储备。

   在Doris引擎中,FE根据用户的查询生成一个完整的逻辑规划,然后基于这个逻辑规划生成一个分布式的逻辑规划,当FE生成好查询计划树后,BE对应的各种Plan Node(Scan,Join,Union,Aggregation, Sort等)执行自己负责的操作即可。

   整个查询过程可以分为3个模块:

  • 查询优化器:将sql文本转换成一个“最佳的”分布式物理执行计划(FE生成查询计划)
  • 查询调度器:将执行计划调度到计算节点(FE分发分配PlanFragment)
  • 查询执行器:计算节点执行具体的物理执行计划(BE执行查询)

  接下来依次介绍这三个模块的执行流程及原理。

 二、查询规划器

   一条sql进入Doris后,有多种可以执行的方式。查询规划器的目的是为了生成一个BE能够识别的 “最佳的” 分布式物理执行计划,主要分为两个步骤:SQL --> PlanNodeTree 和PlanNodeTree -->PlanFragmentTree。   

相关概念:

  • PlanNode: 逻辑算子
  • PlanNodeTree :逻辑查询计划 
  • PlanFragmentTree:分布式查询计划
  • Plan Fragment:Plan NodeTree的子树 + Data Sink节点

 完整的流程图如下:

2.1 生成单节点查询计划

    即SQL --> PlanNodeTree的过程,其中Plan Node代表逻辑算子。该步骤包括Plan Tree的生成,谓词下推,Table Partitions pruning,Column projections,Cost-based优化等。

2.2 单节点的查询计划分布式化

    将单节点的查询计划分布式化,即PlanNodeTree --->PlanFragmentTree,其中FragmentTree是BE执行查询的最小单位,至少包含一个算子。分布式化的目标最小化数据移动和最大化本地Scan,分布式的方法是增加ExchangeNode以及DataSink,执行计划树会以ExchangeNode为边界拆分为PlanFragment。

    分布式查询计划是由多个Plan Fragment 组成的,例如:Plan Fragment 0,Plan Fragment 1,Plan Fragment 2等。每个 Fragment 负责查询计划的一部分,各个Fragment之间会通过 DataStreamSink和ExchangeNode 算子进行数据的传输。

  •  拆分PlanNodeTree

  分布式查询计划是由多个Plan Fragment 组成的,例如:Plan Fragment 0,Plan Fragment 1,Plan Fragment 2等

  • 数据传输

     各个Fragment之间会通过 DataStreamSink和ExchangeNode 算子进行数据的传输。

 完整的流程图如下

  查询优化器在运行过程中,又可以细分成很多个执行阶段:

  • 词法语法解析
  • 语义解析
  • query改写
  • 单机执行计划
  • 分布式执行计划

2.2.1 词法语法解析

  •  首先会进入词法解析,会将一个SQL中的关键词解析出来,如:select、from、join、group by等(如下图的蓝色部分)。

  • 然后会进入语法解析,该阶段会判断语法是否正确

     经过词法语法解析后,最终一条sql语句被转成抽象语法树(AST Tree)

2.2.2 语义解析

   语义解析的目的是为了保证查询的列名和表名是正确的,包括如下部分:

  • 元数据的识别和解析(Binder):检查表权限,列是否存在类型是否支持等,然后解析(Binder)出来

  • SQL合法性检查

  • SQL重写:如select * 重写为 select col1,col2...

  • 函数检查

  •  别名处理

2.2.3 query改写

    该阶段会按照指定的规则给之前生成的抽象语法树(AST Tree)做一些变化。query改写又分为表达式改写子查询解嵌套

2.2.4 单机执行计划

     经过前3步的处理,Doris对于一些基于规则的转换已经做完了,这时候SQL在内核中还是一颗抽象语法树(AST Tree)。此时Doris需要将抽象语法树转换成一个单机的执行规划。 比如会将下图中的SQL转成一个逻辑计划树PlanNodeTree,在转换期间会做一些优化,如 Join Reorder以及谓词下推。

  • Join Reorder

     上图中的sql语句如果不做优化,会按照默认的顺序进行join,即t1跟t2先进行关联,会产生一个笛卡尔积(因为t1和t2没有关联条件),这很有可能导致OOM。因此,Doris会通过Join Reorder对这个join重新排序,使得中间join的结果集变小,这样t1与t2产生的笛卡尔积也就消失了。

   那么【Join Reorder推导多表join的顺序】是怎么实现的呢?

     Doris使用贪婪算法进行Reorder,每次都是找中间结果最小的表进行合并。原理如下图:

  •  ①,②,③,④这四个点表示4个表

  • 表之间的线表示:表之间是有关联条件的,线上数字表示的是两个表关联以后的结果集,数字越小表示结果集越小

  • 下图中的第一步:选择了1和3之间的关联,因为结果集最小;后续依次选择2和4

  • 最后生成的执行计划树就是1-->3-->2-->4这种的执行顺序

  • 谓词下推

      单机执行规划还有谓词下推的优化,以下面的案例说明:

    当用户执行SQL:select * from a join b on a.k1=1;  其中  a.k1=1只涉及到表a,此时谓词下推能生效,下推流程如下:

  • 将 a.k1=1 放在谓词的链表Conjuncts里面

  • 当生成 Scan a 的时候会去list里面拿相关的谓词

  • 然后再生成 scan b 和 JoinNode

   即生成了如下的执行计划树:

2.2.5 分布式执行计划

  基于单机执行计划生成分布式执行计划,示例图如下:

下面以join算子为例,阐述由单机执行计划怎么生成分布式执行计划的

  • Broadcast Join
SELECT * FROM A,B WHERE A.column=B.column

    通过将B表的数据全量广播到A表的机器上,在A表的机器上进行Join操作,相比较于Shuffle join 节省了A表数据Shuffle,但是B表的数据是全量广播,适合B表是个小表的场景。

    如下图,根据数据分布,查询规划出A表有3个执行的HashJoinNode,那么需要将B表全量的发送到3个HashJoinNode,那么它的网络开销是3B,它的内存开销也是3B

优点:网络开销:3B

缺点:内存占用多(每个BE都需要把表B放在内存里面),内存开销:3B

    由于内存占用较多,如果表B的数据量够大,那就做不了Broadcast Join(适用于大表join小表)了,所以Doris引入了Shuffle Join

  • Shuffle Join
SELECT * FROM A,B WHERE A.column=B.column

     Shuffle Join是根据表A和表B执行join的列进行hash,相同hash值的数据分发到同一个节点上。它的网络开销是A+B,内存开销是B。

优点:常规join,常用于大数据量的场景

缺点:网络开销多(需要将表A和B进行网络传输),网络开销:A+B

  • Bucket shuffle join
SELECT * FROM A,B WHERE A.distributekey=B.anycolumn

     基于上面Broadcast Join、 Shuffle Join网络传输开销的痛点,Doris引入了更好的操作:Bucket shuffle join。

    Doris 的表数据本身是通过 Hash 计算分桶的,假如两张表需要做 Join,并且 Join 列是左表的分桶列,那么左表的数据不用移动,B表按照A表的分布方式,Shuffle到A表机器上进行Join操作,B表Shuffle的数据量全局只有一份,比Broadcast少传输了很多倍数量。它的网络开销是B,内存开销是B。

优点:性能好

缺点:场景有限,需要使用 A.distributekey

 既然表A可以不移动数据,那么表B是不是也能够这样?所以Doris又引入了Colocate join

  • Colocate join

     它与Bucket Shuffle Join相似,通过建表时指定 A 表和B表是同一个 Colocate Group,确保 A、B 表的数据分布完全一致,那么,计算节点只需做本地 Join,减少跨节点的数据移动和网络传输开销,提高查询性能。Colocate Join 十分适合几张大表按照相同字段分桶的场景,这样可以将数据预先存储到相同的分桶中,实现本地计算。所以它的网络开销是0,数据已经预先分区,直接在本地进行Join 计算

SELECT* FROM A,B WHERE A.colocatecolumn=B.collocatecolumn

优点:性能最好

缺点:使用场景有限,要求表A和表B的分布是在一个group的,且join的条件跟group是match的。这样的话表A和表B都不需要shuffle了,所以性能也是最好的。

  • 源码分析

     接下来从代码层面分析Doris是选择哪种join方式的,大概步骤如下

(1)首先判断leftChildFragment跟rightChildFragment能否做Colocate

  • 如果可以,则直接采用Colocate join
  •  如果不可以,则继续判断

(2)继续判断能否做Bucket Shuffle

  • 如果可以,则直接采用Bucket Shuffle Join

  • 如果不可以,则继续判断Broadcast join 和Shuffle join选择哪一个

(3)引入joinCostEvaluation,用来对比Broadcast和Shuffle的执行代价,并选择代价小的来执行。

     精简的代码如下:

private PlanFragment createHashJoinFragment(
        HashJoinNode node, PlanFragment rightChildFragment,
        PlanFragment leftChildFragment, ArrayList<PlanFragment> fragments)
        throws UserException {
    List<String> reason = Lists.newArrayList();
    //1. 首先判断leftChildFragment跟rightChildFragment能否做Colocate
    if (canColocateJoin(node, leftChildFragment, rightChildFragment, reason)) {
        ......
        //1.1 如果可以,则直接采用Colocate join
        return leftChildFragment;
    }

    //2. 如果不能,则判断能否做BucketShuffle
    List<Expr> rhsPartitionExprs = Lists.newArrayList();
    if (canBucketShuffleJoin(node, leftChildFragment, rhsPartitionExprs)) {
        ......
        //2.1 如果可以,则直接采用BucketShuffleJoin
        return leftChildFragment;
    }

    //3. 引入joinCostEvaluation,用来对比Broadcast和Shuffle的执行代价
    JoinCostEvaluation joinCostEvaluation = new JoinCostEvaluation(node, rightChildFragment, leftChildFragment);
    boolean doBroadcast;
    //4. 根据以下策略来进行选择其中一个来执行
    if (node.getJoinOp() == JoinOperator.NULL_AWARE_LEFT_ANTI_JOIN) {
        doBroadcast = true;
    } else if (node.getJoinOp() != JoinOperator.RIGHT_OUTER_JOIN
            && node.getJoinOp() != JoinOperator.FULL_OUTER_JOIN) {
        if (node.getInnerRef().isBroadcastJoin()) {
            // respect user join hint
            doBroadcast = true;
        } else if (!node.getInnerRef().isPartitionJoin() && joinCostEvaluation.isBroadcastCostSmaller()
                && joinCostEvaluation.constructHashTableSpace()
                <= ctx.getRootAnalyzer().getAutoBroadcastJoinThreshold()) {
            doBroadcast = true;
        } else {
            doBroadcast = false;
        }
    } else {
        doBroadcast = false;
    }
    //5. 如果Broadcast代价小,则选择Broadcast
    if (doBroadcast) {
        node.setDistributionMode(HashJoinNode.DistributionMode.BROADCAST);
        ........
        ((ExchangeNode) node.getChild(1)).setRightChildOfBroadcastHashJoin(true);
        return leftChildFragment;
    } else {
        //6. 否则选择Shuffle
        ........
        return joinFragment;
    }
}

三、查询调度器

3.1 流程分析

     在生成查询的分布式Plan之后,FE调度模块会负责 PlanFragment 的执行实例生成,PlanFragment的调度,每个BE执行状态的管理,查询结果的接受。有了分布式执行计划之后,Doris需要解决下面的问题:

  • 哪个BE执行哪个PlanFragment
  • 每个Tablet选择哪个副本去查询
  • 多个PlanFragment如何调度

     Doris会首先确认Scan Operator所在的Fragment在哪些BE节点执行,每个 Scan Operator 有需要访问的 Tablet 列表。然后对于每个 Tablet,Doris 会先选择版本匹配的、健康的、所在的BE状态正常的副本进行查询。在最终决定每个 Tablet 选择哪个副本查询时,采用的是随机方式,不过 Doris 会尽可能保证每个 BE 的请求均衡。假如有 0个BE、10个Tablet,最终调度的结果理论上就是每个 BE 负责1个Tablet的Scan。

   当确定包含 Scan的PlanFragment 由哪些BE节点执行后,其他的 PlanFragment 实例也会在 Scan的BE 节点上执行 (也可以通过参数选择其他 BE 节点 ),不过具体选择哪个BE是随机选取的。当FE确定每个 PlanFragment 由哪个BE执行,每个Tablet 查询哪个副本后,FE 就会将 PlanFragment 执行相关的参数通过 Thrift 的方式发送给 BE。

   如下图是一个简单示例,图中的PlanFrament包含了一个ScanNode,ScanNode扫描3个tablet,每个tablet有2副本,集群假设有2台host。
     computeScanRangeAssignment阶段确定了需要扫描replica 1,3,5,8,10,12,其中replica 1,3,5位于host1上,replica 8,10,12位于host2上。
• 如果全局并发度设置为1时,则创建2个实例FInstanceExecParam,下发到host1和host2上去执行
• 如果全局并发度设置为3,这个host1上创建3个实例FInstanceExecParam,host2上创建3个实例FInstanceExecParam,每个实例扫描一个replica,相当于发起6个rpc请求。

3.2 源码分析

     接下来从源码层面再进行分析,上述提到的PlanFragment的分配和分发的逻辑是在FE的Coordinator中实现的。精简代码如下:

public class Coordinator {
    //最重要的数据结构,该链表保存了前面执行计划里面包含的fragment
    private final List<PlanFragment> fragments;
    
    public void exec() throws Exception {
        // 1. prepare information:做一些初始化的工作
        prepare();
        
        // 2. scheduler:分配的过程,给fragmen分配BE
        //将scan node分配到真正存放数据的BE上
        computeScanRangeAssignment();
        //将上层的fragmen分配给BE
        computeFragmentExecParams();
        
        // 3. send:将fragmen分发到BE
        sendFragment();
    }
}

private void sendFragment() throws TException, RpcException, UserException {
     for (PlanFragment fragment : fragments) {
       ..........
     }
     BackendServiceProxy proxy = BackendServiceProxy.getInstance();
    futures.add(ImmutableTriple.of(states, proxy, states.execRemoteFragmentsAsync(proxy)));
}

    PlanFragment分配和分发的具体流程如下:

 3.2.1 prepare阶段

    给每个PlanFragment创建一个FragmentExecParams结构,用来表示PlanFragment执行时所需的所有参数;如果一个PlanFragment包含有DataSinkNode,则找到数据发送的目的PlanFragment,然后指定目的PlanFragment的FragmentExecParams的输入为该PlanFragment的FragmentExecParams。

private void prepare() {
    //给每个PlanFragment创建一个FragmentExecParams结构,用来表示PlanFragment执行时所需的所有参数;
    for (PlanFragment fragment : fragments) {
        fragmentExecParamsMap.put(fragment.getFragmentId(), new FragmentExecParams(fragment));
    }

    //如果一个PlanFragment包含有DataSinkNode,则找到数据发送的目的PlanFragment
    //然后指定目的PlanFragment的FragmentExecParams的输入为该PlanFragment的FragmentExecParams。
    for (PlanFragment fragment : fragments) {
        if (!(fragment.getSink() instanceof DataStreamSink)) {
            continue;
        }
        FragmentExecParams params = fragmentExecParamsMap.get(fragment.getDestFragment().getFragmentId());
        params.inputFragments.add(fragment.getFragmentId());
    }
}

3.2.2 scheduler阶段

  • computeScanRangeAssignmentByColocate:针对colocate join进行处理,由于join的两个表桶中的数据分布都是一样的,他们是基于桶的join操作,所以在这里是确定每个桶选择哪个host。在给host分配桶时,尽量保证每个host分配到的桶基本平均。

  • computeScanRangeAssignmentByBucket:针对bucket shuffle join进行处理,也只是基于桶的操作,所以在这里是确定每个桶选择哪个host。在给host分配桶时,同样需要尽量保证每个host分配到的桶基本平均。

  • computeScanRangeAssignmentByScheduler:针对其他类型的join进行处理。确定每个scanNode读取tablet哪个副本。一个scanNode会读取多个tablet,每个tablet有多个副本。为了使scan操作尽可能分散到多台机器上执行,提高并发性能,减少IO压力,Doris采用了Round-Robin算法,使tablet的扫描尽可能地分散到多台机器上去。例如100个tablet需要扫描,每个tablet 3个副本,一共10台机器,在分配时,保障每台机器扫描10个tablet。

(1)computeScanRangeAssignment阶段:针对不同类型的join进行不同的处理,主要逻辑是对fragment合理分配,尽可能保证每个BE节点的请求都是平均。

(2)computeFragmentExecParams:这个阶段解决PlanFragment下发到哪个BE上执行,以及如何处理实例并发问题。确定了每个tablet的扫描地址之后,就可以以地址为维度,将FragmentExecParams生成多个实例,也就是FragmentExecParams中包含的地址有多个,就生成多个实例FInstanceExecParam。如果设置了并发度,那么一个地址的执行实例再进一步的拆成多个FInstanceExecParam。针对bucket shuffle join和colocate join会有一些特殊处理,但是基本思想一样。FInstanceExecParam创建完成后,会分配一个唯一的ID,方便追踪信息。如果FragmentExecParams中包含有ExchangeNode,需要计算有多少senders,以便知道需要接受多少个发送方的数据。最后FragmentExecParams确定destinations,并把目的地址填充上去。

private void computeScanRangeAssignment() throws Exception {
    //针对colocate join进行处理
   if (fragmentContainsColocateJoin) {
       computeScanRangeAssignmentByColocate((OlapScanNode) scanNode, assignedBytesPerHost, replicaNumPerHost);
   }
   //针对colocate join进行处理
   if (fragmentContainsBucketShuffleJoin) {
       bucketShuffleJoinController.computeScanRangeAssignmentByBucket((OlapScanNode) scanNode,
               idToBackend, addressToBackendID, replicaNumPerHost);
   }
   //针对其他类型的join进行处理
   if (!(fragmentContainsColocateJoin || fragmentContainsBucketShuffleJoin)) {
       computeScanRangeAssignmentByScheduler(scanNode, locations, assignment, assignedBytesPerHost,
               replicaNumPerHost);
   }
}

private void computeScanRangeAssignmentByScheduler() throws Exception {
    for (TScanRangeLocations scanRangeLocations : locations) {
        Reference<Long> backendIdRef = new Reference<Long>();
        //Doris采用了Round-Robin算法,使tablet的扫描尽可能地分散到多台机器上去
        TScanRangeLocation minLocation = selectBackendsByRoundRobin(scanRangeLocations,
                assignedBytesPerHost, replicaNumPerHost, backendIdRef);
        updateScanRangeNumByScanRange(scanRangeParams);
    }
}

3.2.3 send阶段

   将fragment分发到BE。该阶段会调用BE的Proxy,这是一个异步的行为,Coordinator BE只负责分发fragment,但是不负责等待执行结果。

private void sendFragment() throws TException, RpcException, UserException {
     for (PlanFragment fragment : fragments) {
       ..........
     }
     BackendServiceProxy proxy = BackendServiceProxy.getInstance();
    futures.add(ImmutableTriple.of(states, proxy, states.execRemoteFragmentsAsync(proxy)));
}

public class BackendServiceProxy {
     public Future<InternalService.PExecPlanFragmentResult> execPlanFragmentsAsync(TNetworkAddress address,
          TExecPlanFragmentParamsList paramsList, boolean twoPhaseExecution) throws TException, RpcException {
            final BackendServiceClient client = getProxy(address);
            return client.execPlanFragmentAsync(pRequest);
    }
}

四、查询执行器

   为了简单起见,下面介绍的是非pipeline执行模式。整个执行的数据流是由底层的fragment叶子节点先去读磁盘数据,然后一步一步的返还给最top层的fragment。然后最top层的数据被FE拉取算好的数据,再返还给客户端。

   从以上逻辑可以看到几个关键的节点:

  •  单个Fragment执行流程

  •  Fragment之间的数据交互

  •  FE 和Top Fragment

  • FE将数据返回给前端展示

4.1 单个Fragment执行流程

   单个Fragment 是由一个DataSink+一个子树构成的。be里面负责执行单个 Fragment 执行流程的类:PlanFragmentExecutor

4.2 Fragment之间的数据交互

      Fragment 之间的数据交互是存在不同的分发策略的。

  • HASH_PARTITIONED:有些场景,需要对下层的数据进行hash,然后发送给不同的上层节点,如下图所示

  • UNPARTITIONED:有些场景,需要将数据全部发送给同一个上层节点

4.3 FE和Top Fragment数据交互

    数据会一层一层的传输到top Fragment,这涉及到了FE 和Top Fragment数据交互,该步骤的目的就是让FE获取top Fragment计算之后的结果。这个流程是FE主动向 Top Fragment去拉取的。

4.4 FE将数据返回给前端展示

   上文提到FE拉取Top Fragment数据以后,会将数据放在自己的channel中,即MysqlChannel类,这个MysqlChannel即是负责FE的数据返回给前端展示的。

五、总结

    一条sql的执行计划主要包含以下3个阶段:

(1)FE生成查询计划

  •  逻辑查询计划 PlanNodeTree,每个 PlanNode 代表一种运算。

  • 分布式查询计划PlanFragmentTree,每个 PlanFragment 是由 PlanNodeTree 的子树 和 Sink 节点组成的。

(2)FE进行PlanFragment的分配和分发

(3)BE执行查询

  •  Plan Fragment Tree 一层层处理数据,FE 获取后,最终返回给用户

  • 单个 Fragment 执行,递归调用 get_next 计算结果

  • Fragment 和Fragment 之间, sink 通过 channel 分发数据给上层Exchange Node

  • FE coordinator 不断获取 Top Fragment 的 row buffer 中的数据
  • 通过 Mysql Channel 将数据返回给Client

参考文章:

第四讲-一条SQL的执行过程

【源码解析系列】 Apache Doris 查询源码解析

  • 36
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值