1. 从SQL到逻辑执行计划:一场精密的“翻译”之旅
当你向Presto(现在叫Trino)提交一条SQL查询时,你可能觉得只是敲了一行命令,然后等着结果出来。但引擎内部,却像经历了一场精密而复杂的“翻译”和“重组”手术。这个过程,就是把人类能看懂的SQL语句,一步步转换成机器能高效执行的物理指令。今天,我就带你深入这个核心地带,看看逻辑执行计划是怎么被“优化”和“分段”的,这直接决定了你的查询是“秒回”还是“卡死”。
简单来说,逻辑执行计划就是SQL语句的第一次“结构化”表达。它描述了要“做什么”,比如从哪个表读数据、在哪里过滤、按什么分组、怎么排序,但还没决定“谁来做”以及“在哪台机器上做”。这就像一份菜谱,列出了需要的食材和烹饪步骤(洗菜、切块、翻炒),但还没分配哪个厨师负责洗,哪个厨师负责炒。而Fragment(片段)机制,就是把这本完整的菜谱,按照工序拆分成可以并行处理的小任务卡,分发给不同的厨师(计算节点)。理解这个过程,对于调优SQL、诊断慢查询至关重要。无论你是数据工程师、平台开发者,还是对分布式系统好奇的技术爱好者,搞懂这些底层机制,都能让你从“会用”升级到“懂为什么快”。
2. SQL的“解剖”与逻辑执行计划的诞生
2.1 语法解析:从字符串到抽象语法树
你的SQL语句首先会经过一个叫做“解析器”的模块,在Presto/Trino里,它用的是ANTLR这个强大的工具。这个过程,就像英语老师分析一个复杂的长句子,找出主语、谓语、宾语和各种从句。SELECT country, count(1) FROM hive.default.zipcodes_orc_flat GROUP BY country ORDER BY country ASC LIMIT 2; 这么一句SQL,会被拆解成一个结构化的对象(Statement)。
这个Statement对象里,SELECT后面的部分被包装成Select对象,FROM后面的表名是Relation,GROUP BY、ORDER BY、LIMIT都会变成对应的Java对象节点。这时候,引擎已经理解了查询的基本骨架,但还远远不够。它还不知道zipcodes_orc_flat这个表是否存在,country字段是什么类型,用户有没有权限查询。这第一步,我们称之为“词法语法解析”,只确保你写的SQL符合语法规范,不关心具体含义。
2.2 语义分析:赋予“名字”真正的意义
拿到结构化的Statement之后,下一个关键步骤就是“语义分析”。这一步由一个叫做StatementAnalyzer的组件完成,它本质上是一个访问者(Visitor),会遍历刚才生成的语法树,进行深入的“背景调查”。
我打个比方,语法解析只检查“我要去图书馆借《三体》”这句话语法对不对。而语义分析则会核实:图书馆是否存在?《三体》这本书在不在馆藏里?你的借书卡有没有过期?有没有借阅权限?在Presto/Trino里,这个过程会做大量琐碎但至关重要的检查:
- 权限校验:查询用户是否有对
hive.default.zipcodes_orc_flat表的读取权限? - 类型校验:
GROUP BY country里的country字段,它是不是一个可以分组的类型?ORDER BY的字段是不是可比较、可排序的? - 逻辑校验:比如你写了
GROUP BY 3,但SELECT列表里只有2个字段,这就会报错。或者FETCH FIRST ... WITH TIES必须和ORDER BY一起使用,否则语义就不完整。 - 表达式有效性:
WHERE子句里的条件,最终必须能计算出一个布尔值(真或假)。
经过这轮分析,引擎不仅知道了你要做什么,还确认了你有没有权利做,以及你要操作的对象(表、字段)到底是什么。它会为每个字段、表达式分配一个内部的符号(Symbol)和数据类型,为生成执行计划打下坚实基础。
2.3 生成“原始”逻辑执行计划树
语义分析通过后,引擎就可以生成最初的、未经任何优化的逻辑执行计划了。这个计划是一棵树,树上的每个节点都是一个PlanNode。我们以上面的SQL为例,生成的树可能长这样(自底向上看):
- 最底层的叶子节点是一个
TableScanNode,表示要从zipcodes_orc_flat表扫描数据。 - 往上一层,是一个
AggregationNode,表示要对数据按country分组,并计算count(1)。 - 再往上,是一个
SortNode,表示要按country升序排列。 - 最顶层,是一个
OutputNode,表示最终要输出country和count这两列。 - 在
SortNode和OutputNode之间,可能还会插入一个LimitNode,表示只取前2条结果。
这棵树非常“忠实”于你写的SQL,你写了什么步骤,它就生成什么节点,顺序都不带变的。但这份“忠实”往往意味着低效。比如,Limit 2这个操作是在最顶层(OutputNode之前)执行的,这意味着它需要等待下面所有分组、排序都完成,才从海量结果里截取2条。显然,如果能在扫描数据或聚合的早期就知道只要2条结果,就能省下大量计算。所以,这个“原始”计划只是个毛坯,接下来就要进入激动人心的“优化”装修阶段。
3. 基于Visitor模式的逻辑执行计划优化
3.1 Visitor设计模式:执行计划树的“外科医生”
Presto/Trino的优化器核心采用了Visitor(访问者)设计模式。这个模式特别适合处理像树这样复杂的对象结构。你可以把整棵逻辑执行计划树想象成一个病人,而一个个优化器(PlanOptimizer)就是拥有不同专长的外科医生。
每个PlanNode(树节点)都有一个accept()方法,意思是“接受访问”。当优化器开始工作时,它会从根节点(通常是OutputNode)调用accept()方法,并把自己(Visitor)传进去。节点收到Visitor后,会回调Visitor上对应的visitXXX()方法(比如visitAggregation()、visitLimit())。优化器的所有“手术逻辑”,就写在这些visitXXX()方法里。这样做的好处是,树结构(病人)和操作逻辑(手术方案)解耦了。我可以轻松地增加新的优化器(新的外科医生),而不用去修改成千上万个PlanNode类的代码。
在代码里,你会看到一个循环,依次应用所有已注册的优化器:
PlanNode root = planStatement(...); // 生成原始计划
for (PlanOptimizer optimizer : planOptimizers) {
root = optimizer.optimize(root, session, ...); // 每个优化器“手术”一次
}
每经过一个优化器,执行计划树就可能被改写一次,变得更高效。
3.2 常规优化器与迭代优化器
Presto/Trino的优化器主要有两种实现形式,它们目标一致,但设计哲学不同。
第一种是常规优化器,比如LimitPushDown(Limit下推)。它需要开发者自己完整地实现一个Visitor(通常是内部类Rewriter)。在这个Visitor里,你需要重写你关心的节点类型的访问方法。例如,LimitPushDown.Rewriter会重写visitAggregation()、visitUnion()等方法,检查是否能把Limit节点向下推过这些节点。这种方式直接,但每个优化器都要写一套完整的Visitor逻辑,有点重复劳动。
第二种更强大的是迭代优化器,它叫IterativeOptimizer。它引入了一个“规则(Rule)”的概念,把优化过程抽象成了“模式匹配”+“规则应用”两步。每个Rule只需要声明它对哪种模式的节点感兴趣(比如“一个Limit节点下面是一个Aggregation节点”),以及匹配后如何替换这棵子树。IterativeOptimizer内部维护了一个叫Memo的数据结构,它非常聪明,可以记住所有中间优化状态,支持对同一棵子树应用多个规则,并能避免重复工作。
我举个例子,有一个规则叫MergeLimits,它的模式是“一个Limit节点下面直接是另一个Limit节点”。如果匹配到,它的应用逻辑就是把两个Limit的数量合并(比如LIMIT 10下面有个LIMIT 5,最终就是LIMIT 5)。这种方式让编写优化规则变得像搭积木一样简单,扩展性极强。社区里大部分新的优化规则,比如连接重排序、谓词下推的复杂形式,都是用IterativeOptimizer+Rule的方式实现的。
3.3 优化器实战:都优化些什么?
那么,这些“外科医生”具体都做哪些“手术”呢?我挑几个最常见、效果最显著的说说:
- 谓词下推:这是王牌优化。把你
WHERE子句里的过滤条件,尽可能推到靠近数据源的TableScanNode那里。如果底层是Hive或Iceberg,甚至能直接下推成存储层的过滤谓词,极大减少IO。比如WHERE date = ‘2023-10-01’,如果date是分区字段,下推后可能直接跳过其他所有分区文件。 - Limit下推:就是我们前面提到的。当查询有
LIMIT N时,尝试把它往下推。如果推到了TopNNode(排序+Limit)或者AggregationNode附近,可以大幅减少需要处理的数据量。在某些情况下,甚至能下推到连接(Join)或扫描阶段。 - 列裁剪:你的
SELECT可能只选了3个列,但表有100个列。优化器会分析整个执行计划树,发现中间某些节点产生的列,在父节点中根本没用到,就会像剪树枝一样把它们剪掉,减少数据在内存和网络中的流动体积。 - 常量折叠:计算
WHERE salary > 1000*12这种表达式,优化器会直接帮你算成WHERE salary > 12000,避免运行时重复计算。 - 连接重排序:多表Join时,顺序不同性能天差地别。优化器会基于表的统计信息(大小、是否有过滤条件)和连接类型,尝试找出一个计算成本更低的连接顺序。
经过这一系列优化,最初的“毛坯”逻辑执行计划已经变成“精装修”了。但此时它仍然是一个整体,还无法分发到集群去并行执行。接下来,就需要一个关键角色登场,为分布式执行铺路。
4. ExchangeNode:分布式执行的“交通枢纽”
4.1 理解ExchangeNode的类型与作用
在单机数据库里,数据都在本地内存或磁盘,直接处理就行。但在Presto/Trino这样的分布式系统里,数据分散在不同机器,计算也需要多机协作。这就需要一个机制来规划数据如何在节点间移动,这个机制的核心就是ExchangeNode。你可以把它想象成物流系统中的“分拣中心”或“交通枢纽”。
ExchangeNode有两个非常重要的属性:Type和Scope。
Type决定了数据移动的方式:
- GATHER:收集。把所有数据汇聚到一个节点。比如最终的计算结果需要汇集到协调者(Coordinator)节点返回给用户,这个最后的汇聚操作就是一次GATHER交换。
- REPARTITION:重分区。这是最常用的类型,通常发生在
GROUP BY和JOIN之后。它根据分区键(如group by的key或join key)的哈希值,将数据重新分发到下游多个节点,保证相同key的数据去往同一个节点进行处理。这是实现并行聚合和分布式连接的基础。 - REPLICATE:广播。将一份完整的数据拷贝,发送到所有相关节点。这通常用于大小表关联且小表极小的场景(即广播连接)。把整个小表复制到每个参与大表计算的节点上,就可以在本地完成连接,避免了昂贵的大表数据重分区。
Scope则定义了交换的“距离”:
- LOCAL:本地交换。发生在同一个物理任务(Task)内部的不同执行线程(Driver)之间,或者同一个节点上不同处理阶段之间。它不跨网络,主要用于调整任务内的并行度或进行局部聚合。
- REMOTE:远程交换。这才是真正的跨网络数据移动,是分布式执行的标志。逻辑执行计划的分段(Fragment),正是以REMOTE类型的ExchangeNode作为切割点。
4.2 ExchangeNode的生成时机
你可能会问,这些ExchangeNode是什么时候被插入到逻辑执行计划树里的呢?它们并不是一开始就有的,而是在优化阶段,由特定的优化规则根据计算需求插入的。例如,当优化器识别出一个跨节点的GROUP BY操作时,它就会在AggregationNode的上游插入一个Type=REPARTITION、Scope=REMOTE的ExchangeNode,来规划数据如何按分组键分发。同样,识别出分布式JOIN时,也会在连接操作的上游插入对应的ExchangeNode。
5. Fragment分段机制:从逻辑计划到可调度单元
5.1 为什么需要分段?
优化后的逻辑执行计划虽然高效,但它还是一个连续的、庞大的整体。为了在成百上千台机器上并行执行,必须把它切成一块块可以独立调度和执行的“碎片”,这就是Fragment。每个Fragment最终会对应物理执行时的一个Stage。Stage之间通过我们刚才讲的REMOTE Exchange来传输数据。
分段过程同样是一个Visitor(PlanFragmenter.Fragmenter)遍历执行计划树的过程。它的核心逻辑非常简单:以每一个Scope = REMOTE的ExchangeNode为界,进行切割。
5.2 分段过程详解
当Fragmenter访问到一个REMOTE ExchangeNode时,它会做以下几件事:
- 切割:把这个
ExchangeNode作为当前Fragment的结束。ExchangeNode本身不会出现在当前Fragment中,而是被替换成一个RemoteSourceNode。这个RemoteSourceNode就像当前Fragment的一个“输入端口”,专门用来接收上游Fragment(即ExchangeNode的source端)发送过来的数据。 - 创建子Fragment:对于这个REMOTE
ExchangeNode的每一个source子树,递归地调用分段过程,为每一棵子树生成一个独立的子Fragment。 - 建立关系:通过
SubPlan对象,记录父Fragment和子Fragment之间的依赖关系。子Fragment必须先执行,其输出数据通过Exchange传输给父Fragment的RemoteSourceNode。
我们用一个简单的两阶段聚合例子来看:SELECT country, count(*) FROM t GROUP BY country。
- 优化后的计划可能是在
TableScan后,先做一个局部聚合(Partial Aggregation),然后经过一个REPARTITION REMOTE的Exchange,将数据按country哈希分发,最后再做最终聚合(Final Aggregation)。 - 分段时,会在那个REMOTE
ExchangeNode处切开。 ExchangeNode的source端(包含TableScan和Partial Aggregation)被切成Source Stage(Stage 1)。ExchangeNode本身被替换为RemoteSourceNode,它和Final Aggregation、OutputNode一起被切成Final Stage(Stage 0)。- Stage 1 并行执行,每个Task进行本地扫描和预聚合,然后将结果按
country哈希分发到Stage 0的各个Task。 - Stage 0的每个Task通过
RemoteSourceNode接收属于自己分区键的数据,进行最终聚合,然后由其中一个Task将结果GATHER到Coordinator。
5.3 分段后的视图
分段完成后,原先的一棵大树,就被转换成了一棵由多个PlanFragment(片段)组成的、层次化的“片段树”。Coordinator的调度器会根据这棵树,从叶子Fragment(没有依赖其他Fragment)开始,依次调度和执行。每个Fragment会根据数据源的位置和分区情况,实例化成若干个并行的Task,分发到对应的Worker节点上执行。
6. 总结与核心要点回顾
走完这一趟旅程,我们再回头梳理一下Presto/Trino从SQL到可调度Fragment的核心流程,它就像一条精密的流水线:
- 解析与验证:SQL字符串 -> 抽象语法树 -> 语义分析(权限、类型、存在性校验)。
- 生成原始逻辑计划:根据语义分析结果,生成忠实于SQL文本的初始逻辑执行计划树。
- 逻辑优化:基于Visitor模式,应用一系列优化器(常规的或迭代的),对计划树进行重写。目标是减少数据量、降低计算复杂度、优化连接顺序等。ExchangeNode也在此阶段被插入,以规划分布式数据流动。
- 物理分段:以
Scope=REMOTE的ExchangeNode为切割点,将优化后的逻辑计划树分割成多个Fragment。每个Fragment对应一个物理执行Stage,Fragment间通过RemoteSourceNode/Exchange连接。
理解这个过程,对你实际工作有巨大帮助。当你遇到一个慢查询时,不再盲目猜测,而是可以:
- 通过
EXPLAIN命令查看逻辑执行计划,看看优化器有没有成功下推谓词和Limit。 - 观察计划中
ExchangeNode的类型和数量,过多的网络传输(REPARTITION)或广播(REPLICATE)可能是瓶颈。 - 分析Fragment的划分,是否产生了不合理的数据倾斜?比如某个Fragment的计算量远大于其他。
Presto/Trino的这一套机制,在分布式SQL引擎中非常经典。它通过清晰的模块化设计,将复杂的查询处理分解为解析、优化、分段、调度、执行等阶段,每一阶段各司其职又紧密协作。虽然初次阅读其源码会感到挑战,尤其是大量泛型和函数式编程的运用,但一旦理清主线,你就会惊叹于其设计的巧妙与严谨。掌握这些原理,是进行深度性能调优和贡献社区的基础。我在实际排查一些极端性能问题时,正是通过深入分析特定优化规则的行为和Fragment的切分逻辑,才找到了问题的根因。希望这篇深入解析,能帮你打开这扇门,看到分布式查询引擎内部更精彩的风景。
88



被折叠的 条评论
为什么被折叠?



