Presto 查询引擎内核详解:分布式调度模型

从 Distributed Plan 到 RemoteTask:SqlQueryScheduler 的构建与调度

引言:SqlQueryScheduler 到底负责什么?

在 Presto 的执行过程中,Planner (LogicalPlanner/Optimizer/PlanFragmenter) 最终生成的是一个 Distributed Plan,而真正把这个计划变成 Worker 上运行的 Task,则需要经过 Query Scheduler。

SqlQueryScheduler 是这个过程中的核心组件。从对象作用域来看,SqlQueryScheduler 是一个 Query 级别的调度对象:每个 Query 的执行过程都对应一个独立的 SqlQueryScheduler 实例,它负责维护和推进当前 Query 的 Section、Stage、Task 以及整体执行状态。

如果只从源码方法来看,SqlQueryScheduler 会涉及大量类和方法:StreamingPlanSection、StageExecution、StageScheduler、StageLinkage、RemoteTask、ScheduledSplit、OutputBuffer 等,很容易陷入"这个方法调用了那个方法"的细节,却很难建立整体认识。

实际上,可以把 SqlQueryScheduler 的职责概括成两件事情:

  • 构建调度执行实体:将已经生成的 Distributed Plan 转换为可调度的 StageScheduler,并为 Stage 创建相应的 StageExecution 等运行时调度对象。
  • 推进调度执行过程:不断调度 Stage、创建 RemoteTask、将 Splits 调度到 Task,并建立上下游 Stage 之间的数据流连接。

理解 SqlQueryScheduler 的关键,不是逐个理解这些类和方法,而是从"构建"和"调度"两个角度,理解它如何将 Distributed Plan 划分为若干 Section,并以 Section 为组织单位构建 Stage 和 Task 的调度实体,再通过持续推进调度状态驱动整个 Query 执行。

复制代码
             Distributed Plan (SubPlan)
                       │  划分为 Section
                       ▼
             StreamingPlanSection
                       │  构建调度执行实体
                       ▼
              SqlQueryScheduler
                       ├── StageId1 -> StageExecutionAndScheduler 1
                       ├── StageId2 -> StageExecutionAndScheduler 2
                       └── ......             │
                                    ┌─────────┼─────────┐
                                    ▼         ▼         ▼
                         StageExecution StageScheduler StageLinkage
                                              │ stage 调度
                                              ▼
                                          RemoteTask

此外,要理解整个调度过程,还需要进一步关注 Stage 之间的运行时数据流关系:一个 Stage 的输出如何连接到下游 Stage,以及这种上下游关系如何影响 Task 的创建和数据交换。StageLinkage 正是连接这一静态调度结构与运行时数据流的关键部分。Stage 之间的数据流连接关系如下图所示:

下面将从两个方面展开:首先分析 SqlQueryScheduler 如何将 Distributed Plan 划分为 Section,并进一步构建 StageExecution 和 StageScheduler;然后分析 schedule() 如何持续推进 Stage 调度、创建 RemoteTask 并调度 Splits,最终驱动整个查询执行。

第一部分:SqlQueryScheduler 的构建------从 SubPlan 到 Stage 调度实体

1.1 从 SubPlan 到 StreamingPlanSection

如前面的文章《Presto 查询引擎内核详解:分布式规划机制》所述,在构建 SqlQueryScheduler 之前,Presto 已经完成了分布式计划的生成,并得到一个 SubPlan。

而在构建 SqlQueryScheduler 时,会通过:

复制代码
extractStreamingSections(SubPlan plan)

将整个 SubPlan 按照物化依赖关系划分为若干个 Section,并通过 StreamingPlanSection 表达这些 Section 级的组织结构。

这里首先需要理解一个问题:

为什么已经有 Stage 了,还需要 Section?

因为 Stage 描述的是一个具体的执行阶段,而 Section 则用于组织一组具有流式数据依赖的 Stage,并划分它们的流式执行范围。

一个 Section 内部通常存在这样的结构:

复制代码
         Stage 1
         /     \
    Stage 2   Stage 3

这些 Stage 之间通过 Streaming Exchange 建立流式数据依赖,因此可以被组织在同一个 Section 中协同推进。

而不同 Section 之间,则通过物化的数据交换形成依赖。例如:

复制代码
    Section A
        │
        │ materialized result
        ▼
    Section B

因此,可以把 Section 理解为 Scheduler 划分流式执行范围的组织单位,Stage 是实际进行 Task 调度和执行状态管理的基本执行单元。

经过 extractStreamingSections() 后,原来的一个完整 SubPlan 就被组织成了一个 Section Tree。

复制代码
    SubPlan
       │ extractStreamingSections()
       ▼
    StreamingPlanSection
       ├── StreamingSubPlan
       │      ├── PlanFragment
       │      └── Child Stage
       │              ├── StreamingSubPlan
       │              ├── StreamingSubPlan
       │              └── ...
       └── Child Section
              ├── StreamingPlanSection
              ├── StreamingPlanSection
              └── ...

从这个角度看:

Section 提供 Stage 之间的组织和依赖边界,而 Stage 则是实际进行 Task 创建、Split 调度和运行状态推进的执行单元。

1.2 从 StreamingPlanSection 到 StageExecutionAndScheduler

有了 Section Tree 之后,SqlQueryScheduler 会通过 createStageExecutions() 为其中的 Stage 创建运行时调度实体。

对于stage这一真正可被调度执行的实体,最重要的抽象是:

复制代码
StageExecutionAndScheduler
        │
        ├── StageExecution
        ├── StageLinkage
        └── StageScheduler

可以把这三个对象理解成一个 Stage 的三个不同侧面。

StageExecution:Stage"现在运行得怎么样"

SqlStageExecution 负责描述 Stage 的运行时状态。例如:

  • Stage 当前是什么状态;
  • 创建了哪些 Task;
  • 哪些 Task 已经完成;
  • 哪些 lifespan 已经完成;
  • Stage 是否已经完成。

因此:

StageExecution 是 Stage 的运行时执行对象。

StageScheduler:Stage"应该怎么运行"

StageScheduler 负责真正推进 Stage 的调度。例如:

  • 选择 Worker;
  • 创建 RemoteTask;
  • 给 Task 分配 Split;
  • 判断 Stage 调度是否已经完成。

不同 Stage 的输入和并行度来源不同,因此需要不同的调度执行逻辑。例如 Source Stage 通常需要根据 Split 推进 Task,而某些非 Source Stage 的 Task 数量可以直接由上游或固定并行度决定。

复制代码
StageScheduler
   ├── SourcePartitionedScheduler
   ├── FixedSourcePartitionedScheduler
   ├── FixedCountScheduler
   └── ScaledWriterScheduler

所以:

StageExecution 负责维护 Stage 的运行时执行状态;StageScheduler 负责根据当前状态决定"下一步如何推进调度"。

StageLinkage:Stage"和上下游怎么连接"

Stage 并不是孤立运行的。

例如,在如下的分布式执行计划中:

复制代码
Stage 2
   │
   │ Exchange
   ▼
Stage 1

Stage 2 产生的数据需要被 Stage 1 消费。

因此,当 Stage 2 创建任务时,它需要知道:

"该任务的输出会被下游多少任务并发读取?数据数据应该被广播还是被切分?如果广播应该广播多少份?"

而当 Stage 2 创建新的 Task 后,Stage 1 必须知道:

"Stage 2 的这个 Task 在哪个 Worker?它的输出数据从哪里获取?"

这就是 StageLinkage 所负责的核心问题。

因此可以简单记成:

  • StageExecution → 我现在执行到哪里了?
  • StageScheduler → 下一步怎么执行?
  • StageLinkage → 上下游怎么连接?

这也是 StageExecutionAndScheduler 这个抽象存在的意义。

createStageExecutions() 的整体过程

createStageExecutions() 本身是一个递归过程。其会遍历整个 Query 对应的 Section Tree,并在遍历过程中逐层建立 Section 和 Stage 对应的运行时执行实体。

  • 递归处理 Child Section;
    • 递归构建 Child Section 中的 StageExecutionAndScheduler;
  • 创建当前 Section 的 SectionExecution;
    • 遍历当前 Section 中的所有 Stage;
    • 为每个 Stage 创建 StageExecution、StageLinkage 和 StageScheduler;
    • 将三者组合成 StageExecutionAndScheduler;
    • 完成当前 Section 的 Root Stage 和 OutputBuffers 配置;
  • 汇总当前 Section 及其 Child Sections 中构建出的 StageExecutionAndScheduler;
  • 返回整个 Section Tree 对应的 StageExecutionAndScheduler 列表。

最终形成:

复制代码
StreamingPlanSection
        │
        ▼
SectionExecution
        │
        ├── StageExecutionAndScheduler
        │       ├── StageExecution
        │       ├── StageLinkage
        │       └── StageScheduler
        │
        ├── StageExecutionAndScheduler
        │       ├── StageExecution
        │       ├── StageLinkage
        │       └── StageScheduler
        │
        └── ...

这里有一个很重要的特点:

在当前实现中,SqlQueryScheduler 会在初始化阶段遍历整个 Section Tree,为其中的 Stage 建立对应的 StageExecutionAndScheduler,从而提前构建完整的运行时执行结构。

需要注意的是:这里完成的是"运行时执行结构的构建",而不是立即执行所有 Stage。

1.3 Query 最终输出:Root Stage、OutputBuffer 与 locationConsumer

Stage 之间的数据连接我们在上面已经介绍过了,但是还有一个特殊的 Stage:Root Stage

Root Stage 没有下游 Stage,它产生的数据最终会成为整个 Query 的结果。

SqlQueryScheduler 在为 Root Stage 创建调度实体时,会把 Query 层面的 queryStateMachine.updateOutputLocations(...) 方法封装为一个 locationConsumer 传进去。

如此一来,在后续 Root Stage 调度并创建 tasks 时,就可以根据实际输出 buffers 的位置信息更新整个 Query 维护的最终输出位置信息,其核心逻辑可以理解为:

复制代码
    Root Stage 创建 Task
            │
            ▼
    得到 Task 的 OutputBuffer location
            │
            ▼
    通知 locationConsumer
            │
            ▼
    queryStateMachine.updateOutputLocations()
            │
            ▼
    Query 维护最终输出位置

因此,整个 Query 最终的 locationConsumer 实际上建立了一个非常重要的连接:

Stage 层的输出位置 → Query 层的最终结果。

此外,Root Stage 还有一个特殊的 bucketToPartition:0

此处可以简单理解为:Query 的最终输出被统一看作一个 bucket / partition。Root Stage 的每个 Task 所持有的 OutputBuffer 都只包含这一个分区,Task 不会再根据某种分区规则将输出数据划分到多个 partition 中。

因此,不论 Root Stage 最终创建多少 Task,这些 Task 都只是分别将自己产生的数据写入各自持有的这个唯一 OutputBuffer 分区中,而不会在 Root Stage 的输出侧继续进行数据分区。Query 层最终会根据这些 OutputBuffer 的 locations,通过一个 ExchangeClient 分别拉取 Root Stage 各 Task 产生的结果,并将这些结果作为整个 Query 的最终输出。

这里暂时先不把 bucketToPartition / OutputBuffer 的具体实现展开太多,只需要记住:

Root Stage 是 Stage 层和 Query Result 之间的桥梁。

1.4 Stage 之间的数据连接模型:StageLinkage、bucketToPartition 与 OutputBuffer

理解 StageLinkage 最容易的方法,是先理解 Presto 中的三个概念:

复制代码
bucket → partition → node(task)
Bucket

Bucket 是数据分布层面的逻辑概念。

例如一个 Hash Partitioning:

复制代码
hash(key) % N

可以把数据划分到多个 bucket。

在 Presto 中,Bucket 更多用来描述:

某条数据逻辑上属于哪个数据分桶(数据分片)。

Partition

Partition 则更接近 Stage 实际执行时的任务分区。

在本文的调度模型中,可以近似理解为一个 partition 通常对应一个 Task 的执行分区。而 Task 最终会运行在某个 Worker Node 上。

一个需要强调的地方是:多个 bucket 可以映射到同一个 partition。因此不能简单认为:

复制代码
bucket == partition

而是可能存在:

复制代码
bucket 0 ─┐
bucket 1 ─┼──→ partition 0
bucket 2 ─┘

bucket 3 ───→ partition 1
bucketToPartition

bucketToPartition 就是负责描述:

复制代码
bucket → partition

的映射。例如:

复制代码
bucketToPartition = [0, 0, 1, 1]

bucket 0 → partition 0
bucket 1 → partition 0
bucket 2 → partition 1
bucket 3 → partition 1

为什么需要这个映射?因为上游 Stage 产生数据时,需要知道:

某个 bucket 的数据最终应该进入哪个 OutputBuffer partition 才可以被下游 stage 正确的消费。

因此数据路径可以理解成:

复制代码
        Page
         │  BucketFunction
         ▼
       bucket
         │  bucketToPartition
         ▼
      partition
         │
         ▼
    对应 OutputBuffer partition

这也是为什么在构建 StageExecutionAndScheduler 时,bucketToPartition 信息会从下游 parent stage 逐步传递给上游 child stage。

StageLinkage 把这些东西连接起来

例如,对于如下的分布式执行计划:

复制代码
       Stage 1
          ▲
          │ Exchange
          │
       Stage 2

Stage 2 是生产者,Stage 1 是消费者。

Stage 1 创建 Task:

复制代码
    Stage 1
       │
       ├── Task 1-0
       └── Task 1-1

同时 Stage 2 创建 Task:

复制代码
    Stage 2
       │
       ├── Task 2-0
       └── Task 2-1

Stage2 的 tasks 会拥有自己的 OutputBuffer,而 Stage1 的 tasks 会通过各自的 ExchangeClient 去拉取上游数据。

因此,Stage1 需要知道:

  • Task 2-0 的输出在哪里?
  • Task 2-1 的输出在哪里?

而 Stage2 在创建 Task 和建立输出连接时,同样也需要结合下游 Stage 的并行度以及 partition 映射信息,使自己的 OutputBuffer 能够按照既定的数据分区方式向下游提供数据。

因此整体连接关系就是:

复制代码
             Stage 2
                │
         ┌──────┴──────┐
         ▼             ▼
      Task 2-0       Task 2-1
         │             │
         ▼             ▼
   OutputBuffer    OutputBuffer
         │             │
         └──────┬──────┘
                │
         Output locations
                │
                │  StageLinkage
                ▼
    Stage 1 Task(s) ExchangeClient

当 Stage 在调度过程中创建新的 RemoteTask 时,StageLinkage.processScheduleResults() 会利用新产生的 Task location 更新上下游 Stage 的连接关系。

所以可以把 StageLinkage 理解为:

Stage 树在运行时的"连接管理器"。

它不是简单保存父子 Stage 关系,而是负责在 Stage 的 Task 动态创建过程中,维护上下游 Task 之间的运行时关联,并将新产生的 Task location 等信息传递给相关的执行实体。真正的数据传输则由 OutputBuffer 和 ExchangeClient 等组件完成。

第二部分:SqlQueryScheduler 的调度------从 Ready Section 到 RemoteTask

2.1 一次 schedule() 并不意味着整个 Query 调度完成

理解 SqlQueryScheduler 的调度流程时,最容易产生的一个误区是:

schedule() 是不是负责把整个 Query 一次性调度完?

答案是否定的。更准确地说,一次 schedule() 会持续推进当前已经 Ready 的 Sections,直到当前不存在仍可继续调度的 Ready Section,然后返回。

可以用下面的模型理解:

复制代码
      schedule()
          │
          ▼
 找到当前 Ready 的 Sections
          │
          ▼
    调度其中的 Stage
          │
          ├── Stage 尚未完成调度
          │       ↓
          │   继续调用 StageScheduler.schedule()
          │       ↓
          │   创建更多 Task / 调度更多 Splits
          │
          └── 当前 Ready Sections 中的 Stages 全部完成调度
                 │
                 ▼
          重新检查 Ready Sections
                 │
            ┌────┴────┐
            │         │
           有         无
            │         │
            ▼         ▼
         继续调度   schedule() 返回
                      │
                      ▼
               等待后续 Stage 执行状态变化
                      │
                      ▼
                 再次 schedule()

因此,schedule() 返回时,并不意味着整个 Query 的所有 Sections 都已经完成调度。它严格表示当前已经 Ready 的调度工作已经被消耗完;但仍可能存在尚未 Ready 的 Sections,它们需要等待上游 Section 执行完成后才能进入下一轮调度。

例如:

复制代码
Section A ──→ Section B ──→ Section C

第一次 schedule() 可能完成对 Section A 的调度:

复制代码
        Section A
           ↓
 其中所有 Stage 完成 scheduling
           ↓
   当前没有新的 Ready Section
           ↓
      schedule() 返回

此时 B 可能仍未 Ready,因为它依赖 A 的执行完成,而不仅仅是 A 的 scheduling 完成。待 A 执行完成后,B 才会变为 Ready,此时触发的 schedule() 才会启动对 Section B 的调度。

因此,一次 schedule() 的边界是"当前 Ready 的调度工作已经全部推进完成",而不是"整个 Query 已经完成调度"。Presto 的调度因此更适合被理解为一个持续推进 Query 执行状态机的过程,而不是一次性完成全部调度工作的函数调用。

2.2 schedule() 的核心:Ready Section 循环 + Stage 调度循环

从整体结构来看,SqlQueryScheduler.schedule() 可以抽象成两层循环,分别负责 Section 级别的调度推进 和 Stage 级别的实际调度。

两层循环解决的是不同层次的问题。

Outer Loop:决定"哪些 Section 可以进入本轮调度"

首先通过 getSectionsReadyForExecution() 找到当前已经满足依赖条件的 Section,并选取本轮需要处理的 Section。

例如:

复制代码
    Section A
       │
       ▼
    Finished

    Section B
       │
       ▼
     Ready

此时 Scheduler 可以将 Section B 加入本轮调度,并为其创建对应的 ExecutionSchedule。如果运行时 CBO 已启用,还会在此阶段对相关计划片段进行 Runtime Optimization。

需要注意,一次 Outer Loop 不一定会处理所有 Section:一方面本轮可同时调度的 Section 数量存在限制;另一方面,Query 中还可能存在尚未 Ready 的 Section。后者需要等待上游执行状态发生变化后,才能在后续循环中进入调度。

Inner Loop:持续完成当前选中 Section 中 Stage 的调度

对于 Outer Loop 选中的 Section,会根据配置的 executionPolicy(如 all-at-once 或 phased)将其构建为 ExecutionSchedule,通过 ExecutionSchedule 决定当前需要调度哪些 Stage。

随后对这些 StageExecutionAndScheduler 逐个执行:

复制代码
        StageScheduler.schedule()
                  ↓
        创建 RemoteTask / 调度 Splits
                  ↓
    StageLinkage.processScheduleResults()
                  ↓
        更新上下游 Stage 的运行时连接

如果当前 Stage 的调度尚未完成,Inner Loop 会继续循环推进当前 Section 的 Stage 调度;只有当相关 Stage 都完成 scheduling,当前 ExecutionSchedule 调度才算完成。

综上所述:真正创建 RemoteTask、调度 Split 的动作发生在 Inner Loop 的 StageScheduler.schedule() 中,而 Outer Loop 则负责不断寻找新的 Ready Section,并推动整个 Query 的调度向前发展。

2.3 Section 级别的调度实体:ExecutionSchedule

Section Ready 以后,并不是简单地:

复制代码
for each stage:
    schedule()

而是首先通过:

复制代码
executionPolicy.createExecutionSchedule(...)

创建一个 ExecutionSchedule。它可以理解为附加在 Section 层面上的 Stage 调度策略实体,负责决定当前应该启动哪些 Stage 进行调度,以及整个 Section 的调度是否完成。

目前主要有两种策略:

复制代码
ExecutionPolicy
    ├── AllAtOnceExecutionPolicy
    └── PhasedExecutionPolicy

对应创建:

复制代码
ExecutionSchedule
    ├── AllAtOnceExecutionSchedule
    └── PhasedExecutionSchedule

ExecutionSchedule 的核心接口非常简单:

复制代码
Set<StageExecutionAndScheduler> getStagesToSchedule();
boolean isFinished();

也就是说,ExecutionSchedule 并不负责真正创建 Task 或分配 Split,而是负责回答:

当前这个 Section,哪些 Stage 应该被交给 StageScheduler 推进?

AllAtOnceExecutionSchedule

AllAtOnceExecutionSchedule 不会把 Section 中的 Stage 划分成多个阶段,而是将所有 Stage 放入 schedulingStages 中,同时计算一个 preferredScheduleOrder,用于确定推荐的调度顺序。

其调度过程可以概括为:

复制代码
    Section 中所有 Stage
           ↓
根据 PlanNode 拓扑结构计算 preferredScheduleOrder
           ↓
    按照该顺序排列 Stage
           ↓
    getStagesToSchedule()
           ↓
返回尚未 SCHEDULED / RUNNING / done 的 Stage

这里的"AllAtOnce"并不是说所有 Stage 严格同时执行,而是:

所有 Stage 都属于当前可调度集合,不存在必须等待前一阶段完成的 Section 内部调度屏障。

同时,源码会通过 Visitor 遍历 PlanNode,为 Stage 计算一个合适的 preferredScheduleOrder。例如:

  • Join 优先访问 build/right side,再访问 probe/left side;
  • Union、Exchange 的多个 source 按从左到右访问;
  • 遇到 RemoteSourceNode 时,会递归处理其对应的下游 PlanFragment。

由于 Visitor 是自顶向下遍历、在 fragment 访问结束后再将自身加入结果,最终形成的整体顺序是偏向自下而上的调度顺序。

因此,AllAtOnce 的核心可以概括为:

全量开放 Stage + 提供一个 preferred schedule order。

PhasedExecutionSchedule

PhasedExecutionSchedule 则会进一步对 Section 中的 Stage 进行阶段划分。

它首先通过 extractPhases() 构建 Stage 之间的调度依赖图:

复制代码
    Stage A ──→ Stage B
        │
        └──────→ Stage C

图中的边表示调度上的先后约束,例如 Join 的 build side 和 probe side 之间可能存在相互制约。

随后通过 Strongly Connected Components(SCC) 找出存在循环依赖的 Stage 集合。处于同一个强连通分量中的 Stage 不能简单地按照先后顺序调度,否则可能因为一方等待另一方而形成调度死锁,因此需要放入同一个 phase。

例如:

复制代码
        S1 ──→ S2
        ↑       │
        └───────┘
            ↓ SCC
    Phase 1 = {S1, S2}

将 SCC 压缩后,再得到一个 DAG,并通过拓扑排序得到最终的:

复制代码
    Phase 1
      ↓
    Phase 2
      ↓
    Phase 3

运行时,activeSources 表示当前已经开放调度的 Stage 集合。getStagesToSchedule() 会首先清理已经完成或已经进入 SCHEDULED/RUNNING 状态的 Stage,然后根据需要从 schedulePhases 中加入新的 phase。

不过它也不会机械地一次只开放一个 phase:addPhaseIfNecessary() 会尽量保证当前 active set 中包含可以直接提供数据的 source/table-scan Stage,从而避免调度过度串行化。

因此,Phased 调度策略的核心可以概括为:

通过依赖图划分调度阶段,将必须同时推进的 Stage 放入同一个 phase,并在阶段之间建立明确的调度屏障。

最终,两种策略的区别可以简单记为:

复制代码
AllAtOnce
    = 所有 Stage 都开放
    + preferred schedule order

Phased
    = Stage 依赖分析
    + SCC 合并
    + Phase 顺序
    + 当前 Phase 调度完成后再开放后续 Phase

所以,Section 层的 ExecutionSchedule 解决的是:

一个 Section 内部,哪些 Stage 现在应该被推进,以及这些 Stage 应该以什么节奏被开放。

至于 Stage 被开放之后如何创建 Task、如何分配 Split、如何跟踪远程任务状态,则继续由 StageScheduler 以及其下层的调度执行逻辑负责。

2.4 Section 开始调度前的 Runtime CBO

SqlQueryScheduler 中还有一个比较有意思的设计:

Section 真正开始调度之前,执行计划仍然可能根据运行时信息发生调整。

流程可以简化为:

复制代码
        Section Ready
              │
              ▼
      tryCostBasedOptimize()
              │
              ▼
    performRuntimeOptimizations()
              │
              ▼
      Runtime Plan Optimizer
              │
              ▼
          新的 Plan
              │
              ▼
        updatePlan()
              │
              ▼
     updateStageExecutions()
              │
              ▼
        真正开始调度

为什么已经完成 Planner 阶段了,调度阶段还要修改 Plan?

因为某些优化依赖运行时才能获得的信息。例如:

  • 运行时统计信息;
  • 已经完成的 Child Stage 信息;
  • 中间结果规模等。

因此 SqlQueryScheduler 并不是:

复制代码
    Planner 生成 Distributed Plan
            ↓
    Scheduler 原封不动执行 Plan

而更接近:

复制代码
      Planner
         ↓
    Distributed Plan
         ↓
      Scheduler
         ↓
     Section 级
  Runtime Optimization
         ↓
  调度并执行最终 Plan

因此,SqlQueryScheduler 构建完毕并不意味着整个 Query 的执行结构已经完全固定。Runtime Optimization 发生后,Scheduler 需要通过 updatePlan() 和 updateStageExecutions() 将新的 Plan 同步到已有的执行实体,然后才进入后续的 Stage 调度。

2.5 Stage 真正开始调度:StageScheduler、RemoteTask 与 Exchange

前面 ExecutionSchedule 决定了当前哪些 Stage 可以开始调度。接下来,真正负责将 Stage 调度为实际运行在 Worker 上的 Task 的,就是 StageScheduler。

整体链路可以概括为:

复制代码
    SqlQueryScheduler
            ↓
    ExecutionSchedule
            ↓
 StageExecutionAndScheduler
            ↓
      StageScheduler
            ↓
    RemoteTask (Split)
            ↓
        Worker 执行

不同类型 Stage 的数据来源和执行方式不同,因此 StageScheduler 有多种实现:

复制代码
StageScheduler
    ├── SourcePartitionedScheduler
    ├── FixedSourcePartitionedScheduler
    ├── FixedCountScheduler
    └── ScaledWriterScheduler

它们解决的是不同类型的 Task (Split) 调度场景。

SourcePartitionedScheduler

用于存在本地数据源(Source)的 Stage。它的核心任务是动态决定:

复制代码
Source Split → Worker

通过 DynamicSplitPlacementPolicy 等机制,可以将节点选择延迟到实际调度 Source Split 时进行。

FixedSourcePartitionedScheduler

同样用于包含 Local Source 的 Stage,但与 SourcePartitionedScheduler 的关键区别在于:Local Source 本身能够提供确定的 partitioning 信息,因此 Scheduler 可以基于这些 partition 预先确定 Task 的执行分布,而不需要完全依赖运行时动态选择 Worker。

这种调度方式同时还需要处理 grouped execution、lifespan、task recovery 等执行特性。

FixedCountScheduler

用于 Task 数量和 partition 分布已经基本确定的 Stage。例如:

复制代码
partition 0 → Task 0 → Worker A
partition 1 → Task 1 → Worker B
partition 2 → Task 2 → Worker C

构建 Scheduler 时,会通过 NodePartitionMap 得到 partition → node 的映射,调度时主要就是根据 partition 找到对应 node,然后创建 Task。

ScaledWriterScheduler

用于 Writer Stage。Writer 的并行度可能随着执行过程动态调整,因此 Task 数量并不是一开始就完全固定的。


因此,从 SqlQueryScheduler 的角度,并不需要关心每种 Scheduler 的具体实现细节。而是把它们统一理解为:

StageScheduler 负责把一个已经确定可以调度的 Stage,进一步转换成实际运行在 Worker 上的 RemoteTask,并为其提供必要的数据源和上下游输入输出的连接信息。

Exchange:Stage 之间如何建立数据传输

当 StageScheduler 开始创建 Task 后,Stage 才真正进入运行时执行。例如:

复制代码
    Stage 2
      ├── Task-2-0 → Task URI A
      └── Task-2-1 → Task URI B

这些 Task 在 Worker 上执行,并通过自己的 OutputBuffer 产生输出数据。

假设执行计划中存在:

复制代码
Stage 2 → Exchange → Stage 1

那么运行时的数据通路可以理解为:

复制代码
Stage 2
  ├── Task-2-0 → OutputBuffer → URI A ─┐
  └── Task-2-1 → OutputBuffer → URI B ─┤
                                       ↓  StageLinkage
                                 ExchangeClient
                                       ↓
                                    Stage 1

这里需要注意,Exchange 在 Plan 中表现为 Stage 之间的逻辑数据连接;到了运行时,则通过上游 Task 的 OutputBuffer、Task URI,以及下游的 ExchangeClient 等组件,具体建立数据传输路径。

StageLinkage 则位于两者之间:它描述当前 Stage 与上下游 Stage 的运行时连接关系,并提供下游建立 Exchange 所需要的 Task URI、partition 等运行时信息。

因此,从整个调度的执行过程来看,仅仅理解 StageScheduler → RemoteTask 还不够:Task 被创建出来之后,还需要通过 Exchange 机制与其他 Stage 建立实际的数据传输关系。

复制代码
    Plan 层:
    Stage 2 ── Exchange ──> Stage 1

    Runtime:
    Task-2-*
       ↓
    OutputBuffer
       ↓
Task URI / partition 信息
       ↓  StageLinkage
    ExchangeClient
       ↓
    Stage 1

可以把这一过程记成一句话:

ExecutionSchedule 决定"哪些 Stage 现在可以调度",StageScheduler 决定"这个 Stage 怎么创建和推进 Task",而 RemoteTask、OutputBuffer、Task URI 与 ExchangeClient 等组件,则共同完成 Task 的实际执行以及 Stage 之间的数据传递。

总结:建立 SqlQueryScheduler 的整体心智模型

经过前面的分析,可以把 SqlQueryScheduler 的职责概括成三个层次。

第一层是构建执行结构

SqlQueryScheduler 将 SubPlan 转换为 StreamingPlanSection,再进一步构建 SectionExecution、StageExecutionAndScheduler 等运行时执行实体。这个过程解决的是:

Query 应该由哪些可以被调度和执行的实体组成?

第二层是推进执行

Section Ready 之后,ExecutionSchedule 决定当前哪些 Stage 可以开始调度,StageScheduler 再负责具体推进 Stage,最终创建并调度 RemoteTask 到 Worker 上执行。这个过程解决的是:

什么时候调度哪个 Stage,以及如何把 Stage 推进成实际运行的 Task?

而 schedule() 并不是一次性完成整个 Query,而是随着执行状态不断变化,持续推进各个 Section 和 Stage。

第三层是建立 Stage 之间的数据通路

上游 Task 通过 OutputBuffer 产生数据,下游通过 ExchangeClient 获取数据,而 Task URI、partition 等运行时信息则将两者连接起来。这个过程解决的是:

一个 Stage 产生的数据,如何被下游 Stage 找到并消费?

因此,如果只保留一张图,可以用下面这张图建立 SqlQueryScheduler 的整体心智模型:

复制代码
                         SqlQueryScheduler
                                │
                  ┌─────────────┴─────────────┐
                  │                           │
              构建执行结构                    推进执行
                  │                           │
                  ▼                           ▼
        StreamingPlanSection          ExecutionSchedule
                  │                           │
                  ▼                           ▼
       StageExecutionAndScheduler       StageScheduler
          ┌───────┼───────┐                   │
          │       │       │                   ▼
          ▼       ▼       ▼               RemoteTask
    Execution   Linkage  Scheduler            │
                                              ▼
                                         OutputBuffer
                                              │
                                              ▼
                                         ExchangeClient
                                              │
                                              ▼
                                          下游 Stage

从这个角度看,SqlQueryScheduler 并不只是一个"调度 Task 的类",而是连接 Distributed Plan 与实际运行时执行的桥梁:

它把静态的 Distributed Plan 转换成动态调度及运行时的 Section、Stage 和 Task,并建立 Stage 之间真实的数据执行关系。

所以,理解 SqlQueryScheduler 的关键,并不是记住每个方法调用了什么,而是抓住三个核心问题:

  • Distributed Plan 如何转换成 StageExecutionAndScheduler?
  • 哪些 Stage 可以调度,以及这些 Stage 如何一步步变成 RemoteTask?
  • Stage 之间如何通过 Exchange 建立真实的数据通路?

掌握这三个问题,SqlQueryScheduler 的主要代码结构也就基本串起来了。

至于 NodePartitioningManager、BucketNodeMap、具体的 StageScheduler 实现,以及 RemoteTask 的通信及监控管理机制,则属于下一层实现细节,可以在后续再深入。


作者:王冬

PrestoDB Committer | Presto Iceberg Code Owner

本文章同步发表于:

https://hantangwangd.github.io/zh/posts/2026-09-05-sql-query-scheduler.html

相关推荐
不动明王19841 个月前
Presto 查询引擎内核详解:分布式规划机制
pipeline·mpp·presto·查询引擎内核·分布式规划
不动明王19841 个月前
Presto 查询引擎内核详解:整体架构与查询执行流程
架构·presto·分布式调度·pipeline执行·计算引擎内核
不动明王19841 个月前
Presto 查询引擎内核详解:基于多级反馈队列思想的 Worker 调度模型
presto·调度执行·多级反馈队列·交互式查询引擎
不动明王19841 个月前
Presto 查询引擎内核详解:Worker 本地执行模型——从物理计划到可调度执行单元
大数据·presto·湖仓·查询引擎内核·pipeline执行
不动明王19841 个月前
Presto 查询引擎内核详解:集群资源管理机制解析
内存管理·计算引擎·presto·revoking·memorypool
不动明王19841 个月前
能力联邦:Presto 的一种可能演进方向
大数据·iceberg·presto·湖仓·lance·联邦查询引擎
qq_435287924 个月前
第11章 不周山倒 = 内核恐慌!女娲补天是在线热修复的教科书?共工撞断关键指针
锁机制·分布式调度·洪荒神话·硬同步·周天星斗·都天神煞·工程师视角