1. 从SQL到逻辑执行计划:一场“翻译”与“重组”的旅程

当你向Presto(现在叫Trino)提交一条SQL查询时,它就像给一个精通多国语言但思维严谨的“翻译官”下达了一道指令。这个翻译官的任务,不是逐字翻译,而是深刻理解你的意图,然后规划出最高效的执行路径。这个过程的核心产出,就是逻辑执行计划。简单来说,逻辑执行计划就是SQL语句被“翻译”成的一系列抽象计算步骤,它描述了“要做什么”,但还没决定“具体由谁、在哪台机器上做”。

我刚开始接触Presto时,总觉得逻辑执行计划很神秘。后来发现,你可以把它想象成一道复杂菜品的“标准操作程序”。比如,你的SQL是“给我来一份宫保鸡丁,要微辣,最后撒点葱花”。逻辑执行计划就是后厨的标准化流程:1. 从冰箱(数据源)取出鸡肉和花生;2. 将鸡肉切丁(数据过滤和投影);3. 将鸡肉和花生一起爆炒(Join操作);4. 加入微辣酱料(聚合计算);5. 装盘并撒上葱花(结果排序和输出)。这个流程是逻辑上的,它不关心具体是张师傅还是李师傅来炒,也不关心用的是灶台A还是灶台B。

那么,Presto是如何生成这份“标准操作程序”的呢?整个过程可以拆解为几个关键阶段。首先,SQL字符串会通过语法解析器(比如Antlr)被转换成一颗结构化的抽象语法树。这步就像把一句中文句子拆分成主谓宾定状补。接着,系统会对这棵树进行语义分析,检查你的“菜谱”是否合理:你要的“鸡肉”冰箱里有没有(表是否存在)?你有权限取用吗?你说“微辣”,但厨房的辣度标准是什么(类型检查)?这些检查确保你的查询是合法且可执行的。

通过检查后,Presto会生成最原始、最“笨”的逻辑执行计划。这个计划完全忠实于你SQL的书写顺序,几乎没有做任何优化。比如,如果你写了SELECT * FROM A JOIN B ON A.id=B.id WHERE A.value > 10,原始计划可能会先不管三七二十一地把A表和B表的所有数据都拼起来(笛卡尔积),然后再过滤出A.value > 10的行。显然,这非常低效。所以,接下来的重头戏,就是对这份原始计划进行深度“优化重组”。

2. 逻辑执行计划的优化策略:让查询“飞起来”的魔法

拿到原始的逻辑执行计划后,Presto的优化器就开始大显身手了。优化器的目标很明确:在保证结果正确的前提下,用更少的资源、更短的时间完成计算。这就像一位经验丰富的厨师长,看到一份标准的宫保鸡丁流程后,会进行优化:“花生可以提前油炸好备用(谓词下推)”,“炒鸡肉和调酱汁可以同时进行(并行计算)”,“如果客人只要一小份,那就只取半块鸡肉(Limit下推)”。Presto内置了数十种优化规则,我这里挑几个最核心、效果最显著的策略,结合我的踩坑经验,给你掰开揉碎了讲。

2.1 下推优化:把活儿往前赶

下推优化 是Presto优化器中最基础也最有效的一类策略。核心思想是:尽可能将计算操作(如过滤、聚合、Limit)从引擎层“推”到离数据源更近的地方,最好是能直接推给存储系统(如Hive、MySQL)去执行。这样做能极大减少需要通过网络传输和在中层处理的数据量。

  • 谓词下推:这是最常见的。比如你的查询是SELECT * FROM logs WHERE date='2023-10-01' AND level='ERROR'。优化器会识别出WHERE条件中的过滤条件,并尝试将它们“推”到扫描logs表的节点(TableScanNode)上。如果数据源是Hive,且date是分区字段,那么Presto就会直接告诉Hive:“我只读取date='2023-10-01'这个分区的数据”。如果level字段上有统计信息或索引,数据源还能利用这些信息进一步过滤。我曾在一次排查慢查询时发现,一个全表扫描的查询在启用谓词下推后,读取的数据量从TB级降到了GB级,速度提升了上百倍。
  • Limit下推:当你使用LIMIT N时,优化器会努力把这个限制往下推。例如,SELECT * FROM table ORDER BY time DESC LIMIT 10。优化后的计划可能不是在所有数据排序后再取前10条,而是将LIMIT 10下推到各个数据读取节点,让每个节点先本地排序并取前10条,汇总到协调节点后再做一次全局排序取前10。这避免了海量数据的全量排序。代码层面,LimitPushDown优化器会遍历计划树,在遇到Aggregation、Join等节点时,判断是否可以将Limit穿透下去。
  • 投影下推:如果你只查询部分列(SELECT col1, col2 FROM ...),优化器会只读取col1和col2的数据,忽略其他列。对于列式存储(如ORC、Parquet)效果极佳,能大幅减少I/O。

一个实战踩坑案例:我们有一次查询一个非常大的事件表,用了LIKE ‘%keyword%’进行模糊匹配,结果巨慢。检查执行计划发现,谓词下推失效了!因为LIKE ‘%...%’这种前缀模糊匹配,很多数据源无法高效处理,Presto的优化器出于保守考虑,可能不会下推。后来我们调整了数据模型,增加了倒排索引,改为等值查询,性能立刻上来了。所以,下推虽好,但也依赖数据源的能力和查询本身的形式。

2.2 Join重排序与优化:告别“笛卡尔积”噩梦

JOIN操作是数据分析中最耗时也最容易出问题的环节。Presto的优化器会尝试对Join的顺序和算法进行优化。

  • Join重排序:当你的SQL中涉及多表Join时,它们的连接顺序对性能影响巨大。Presto的ReorderJoins优化器(或基于成本的优化器CBO)会尝试估算不同连接顺序的成本。它的目标是优先进行能最大程度过滤数据的Join,把小结果集的表放在前面。例如,A JOIN B ON A.id=B.id JOIN C ON B.key=C.key,如果B表很小,而A和C很大,优化器可能会尝试先计算B JOIN C得到一个中间小表,再去和A表Join。这背后依赖表的统计信息(行数、列基数、数据分布),统计信息越准确,优化器的决策就越靠谱。
  • Join算法选择:Presto主要支持两种分布式Join算法。哈希Join是默认且最常用的,它会将小表(或大表的一侧)构建为哈希表广播到所有参与Join的节点上。排序合并Join则要求两侧数据按Join键排序,然后在各节点进行归并。优化器会根据表的大小、内存预算等因素来选择。当识别到有一张表非常小(可以通过join_broadcast相关配置设定阈值)时,优化器可能会选择广播Join,即将小表的全量数据复制到所有包含大表数据的节点上,实现本地哈希Join,避免了Shuffle网络传输。
  • 消除交叉连接:有时SQL中可能隐含了笛卡尔积(Cross Join),这会产生爆炸级的数据量。EliminateCrossJoins优化器会尝试通过寻找等值连接条件来将隐式的交叉连接转化为等值连接,如果找不到,它甚至会报错或警告,避免你无意中触发一个“性能炸弹”。

这里有个关键点:Presto的优化器分为两类。一类是基于规则的优化器,比如LimitPushDown,它不关心数据量大小,只要模式匹配就执行规则。另一类是基于成本的优化器,主要在Join重排序等场景使用,它需要利用ANALYZE命令收集的统计信息来估算不同执行路径的代价。在实际运维中,确保定期收集关键表的统计信息,是保证CBO生效、Join高效的前提。

2.3 局部聚合与子查询优化:化整为零,分而治之

对于聚合查询(GROUP BY)和包含子查询的复杂SQL,Presto也有相应的优化手段。

  • 局部聚合:在执行SELECT region, SUM(sales) FROM orders GROUP BY region这样的全局聚合时,优化器可能会在数据扫描节点(TableScanNode)之后,立即插入一个局部聚合节点。这个节点先在每个数据所在的Worker节点上,对本地的数据进行一次预聚合(例如,先按region本地求和),然后将局部聚合的结果(数据量已经大大减少)通过网络Shuffle到下一个阶段进行最终聚合。这显著减少了网络传输的数据量。你可以在优化后的逻辑计划中看到AggregationNode的step属性是PARTIAL还是FINAL来识别。
  • 子查询解关联:许多数据库系统会将相关子查询(子查询依赖外层查询的值)处理成低效的嵌套循环。Presto的优化器会尝试将一些常见的相关子查询“扁平化”,转化为等价的JOIN操作。例如,SELECT * FROM A WHERE EXISTS (SELECT 1 FROM B WHERE A.id = B.id),优化器会将其重写为A SEMI JOIN B ON A.id = B.id。这种转换能利用高效的Join算法,性能提升往往是数量级的。

这些优化都是通过前文提到的IterativeOptimizer及其包含的众多Rule来实现的。每个Rule像是一个独立的“优化小插件”,不断对计划树进行模式匹配和局部改写,经过多轮迭代,最终得到一个高度优化的逻辑执行计划。

3. Fragment分段机制:分布式执行的蓝图

优化后的逻辑执行计划仍然是一个整体,它还没有和具体的硬件、网络联系起来。接下来,Presto需要将这个逻辑计划切割成一个个可以并行执行的独立单元,这就是Fragment分段。生成的每一个Fragment,未来就会对应一个物理执行的Stage。

那么,切割点在哪里呢?核心就在于ExchangeNode。在逻辑执行计划中,ExchangeNode代表了数据需要在不同执行节点间进行交换(Shuffle)的位置。它有两种作用域:

  • LOCAL Exchange:数据在同一台机器上的不同线程或进程间交换。这通常用于同一Stage内的并行流水线操作,比如合并多个本地聚合结果。
  • REMOTE Exchange:数据需要跨网络在不同机器(Worker节点)间进行交换。这正是分布式执行的标志,也是Fragment的切割点。

分段过程可以这样理解:Presto的PlanFragmenter组件(也是一个Visitor)开始遍历优化后的逻辑计划树。它从根节点(通常是OutputNode)出发,一路向下,直到遇到一个Scope为REMOTE的ExchangeNode。好了,就在这里“切一刀”!这个ExchangeNode将成为当前Fragment的边界。ExchangeNode本身会被替换成一个RemoteSourceNode,表示这个Fragment需要从上游Fragment(通过这个Exchange)接收数据。而ExchangeNode的所有源(sources)则会被递归地处理,各自形成独立的子Fragment。

举个例子,假设我们有一个简单的两表Join查询,经过优化后,计划可能是:TableScan A -> Local Exchange -> (REMOTE Exchange) -> Join -> Output。这里,REMOTE Exchange就是切割点。它会被切分成两个Fragment:

  • Fragment 1:TableScan A -> Local Exchange。这个Fragment负责扫描A表数据,并进行可能的本地预处理,然后通过REMOTE Exchange将数据Shuffle出去。
  • Fragment 2:RemoteSourceNode (接收来自Fragment1的数据) -> Join -> Output。这个Fragment接收来自A表的数据,与本地扫描的B表数据(如果B表数据也在本Fragment)进行Join,并输出结果。

ExchangeNode的类型(Type)决定了数据交换的方式:

  • GATHER:将多个源的数据汇集到一个目标节点。比如最终结果需要汇总到Coordinator返回给客户端。
  • REPARTITION:根据分区键(如Group By的键或Join的键)的哈希值,将数据重新分布到所有下游节点。这是最常用的Shuffle方式。
  • REPLICATE:将一份数据完整地广播到所有下游节点。常用于广播小表进行Broadcast Join。

通过这种以REMOTE Exchange为界的切割方式,Presto将一个庞大的逻辑计算图,分解成了多个层次清晰的Stage(Fragment),每个Stage内部可以高度并行,Stage之间通过定义好的Exchange通道传递数据,从而构建出一张高效的分布式执行流水线图。

4. 深入核心:从代码看优化与分段如何发生

光讲原理可能还有点抽象,我们结合Presto(Trino)源码中的关键片段,看看这些过程是如何具体实现的。这能帮你更好地理解执行计划,甚至在排查问题时能有的放矢。

4.1 Visitor模式与优化器执行框架

Presto中,无论是优化还是分段,都广泛使用了访问者模式。逻辑执行计划树中的每个PlanNode(如TableScanNode、JoinNode、AggregationNode)都是“被访问者”。而各种PlanOptimizer则是“访问者”。

以基于规则的LimitPushDown优化器为例,它的核心是一个内部类Rewriter,它继承了PlanVisitor。这个Rewriter只重写了它关心的节点类型的访问方法,比如visitAggregation、visitJoin。当它遍历到AggregationNode时,会检查其子节点是否包含Limit,并判断能否将Limit下推到聚合之前执行。如果可以,它就“改写”这个节点及其子树,返回一个新的PlanNode子树。

更现代、更模块化的方式是使用IterativeOptimizer。它内部维护一个Rule的集合。每个Rule定义了两个东西:一个Pattern(匹配什么样的计划节点模式)和一个apply方法(匹配后如何改写)。优化时,IterativeOptimizer会从计划树的根节点开始,用所有Rule去尝试匹配和改写当前节点,然后递归地应用到子节点。这个过程是迭代的,因为一个节点的改写可能会为它的父节点或子节点创造新的优化机会。

// 简化伪代码,展示IterativeOptimizer的核心循环
public PlanNode optimize(PlanNode plan, ...) {
    Memo memo = new Memo(plan); // 将计划树存入Memo结构,便于改写
    boolean progress;
    do {
        progress = false;
        for (Rule rule : allRules) {
            // 在memo中寻找匹配该rule模式的节点
            PlanNode matchingNode = findMatch(memo, rule.getPattern());
            if (matchingNode != null && rule.isEnabled(session)) {
                // 应用规则,得到可能改写后的新节点
                Result result = rule.apply(matchingNode, ...);
                if (result.isChanged()) {
                    memo.replace(matchingNode, result.getTransformedPlan());
                    progress = true; // 标记有改动,需要继续迭代
                }
            }
        }
    } while (progress); // 直到没有规则能再产生改动
    return memo.extract(); // 从Memo中提取最终优化后的计划树
}

这种架构使得增加新的优化规则变得非常容易,开发者只需要关注规则本身的逻辑即可。

4.2 Fragmenter如何识别与切割

分段的核心类是PlanFragmenter,它的核心也是一个Visitor——Fragmenter。我们来看它处理ExchangeNode的关键代码逻辑:

@Override
public PlanNode visitExchange(ExchangeNode exchange, RewriteContext<FragmentProperties> context) {
    // 1. 判断是否为REMOTE Exchange,只有REMOTE的才是Stage切分点
    if (exchange.getScope() != REMOTE) {
        // LOCAL Exchange不切分,继续递归处理其子节点
        return context.defaultRewrite(exchange, context.get());
    }

    // 2. 根据Exchange类型设置当前Fragment的数据分布要求(如GATHER到单机,REPARTITION哈希分布)
    PartitioningScheme partitioningScheme = exchange.getPartitioningScheme();
    if (exchange.getType() == ExchangeNode.Type.GATHER) {
        context.get().setSingleNodeDistribution();
    } else if (exchange.getType() == ExchangeNode.Type.REPARTITION) {
        context.get().setDistribution(partitioningScheme.getPartitioning().getHandle(), metadata, session);
    }

    // 3. 核心切割逻辑:为Exchange的每一个source子计划递归创建独立的子Fragment
    ImmutableList.Builder<SubPlan> childSubPlansBuilder = ImmutableList.builder();
    for (int i = 0; i < exchange.getSources().size(); i++) {
        FragmentProperties childProperties = new FragmentProperties(...);
        // buildSubPlan会递归调用visit方法,为子计划生成Fragment
        SubPlan childSubPlan = buildSubPlan(exchange.getSources().get(i), childProperties, context);
        childSubPlansBuilder.add(childSubPlan);
    }
    List<SubPlan> children = childSubPlansBuilder.build();
    context.get().addChildren(children); // 建立父子Fragment关联

    // 4. 将当前的ExchangeNode替换为RemoteSourceNode
    // RemoteSourceNode代表此Fragment的数据来源是上游Fragment(通过Exchange)
    return new RemoteSourceNode(
        exchange.getId(),
        children.stream().map(subPlan -> subPlan.getFragment().getId()).collect(toImmutableList()),
        exchange.getOutputSymbols(),
        exchange.getOrderingScheme(),
        exchange.getType()
    );
}

这段代码清晰地展示了分段的过程:遇到REMOTE Exchange就切割,为其每个上游分支创建子Fragment,然后把ExchangeNode本身替换成一个“占位符”——RemoteSourceNode。这个RemoteSourceNode里记录了它依赖的上游Fragment的ID。最终,整个逻辑计划树被转化为一个由SubPlan(包含PlanFragment)组成的树形结构,这就是物理调度和执行的基本单位。

理解了这个过程,当你在查看Presto的Web UI或EXPLAIN输出时,看到那些RemoteSource操作符,就能立刻明白它对应了一次跨节点的数据交换,也是不同Stage之间的边界。通过分析每个Fragment包含的操作符和Exchange的类型,你就能精准定位数据倾斜、网络传输过大等分布式查询的典型性能瓶颈所在。

更多推荐