本文面向正在学习 Doris 查询执行流程的读者,依据 Doris 2.1 分支整理。重点是 FE 如何把 SQL 变成可执行计划、组织分布式任务并返回结果;BE 内部算子如何读取数据和完成计算不在本文展开。
主线先记住一句话:Nereids 理解 SQL、改写并选择物理计划;Translator 生成 Fragment 和 Exchange;Coordinator 安排 BE 上的实例和扫描任务;BE 执行后由 FE 返回结果。
本文以普通 Doris OLAP 表的 SELECT 查询为主线,假设由 Nereids 完成规划。特殊查询快路径不在本文展开。所有计划图和部署图均为概念示意,不代表固定的实际执行计划。
| 阶段 | 输入 → 输出 | 这一阶段解决什么问题 |
|---|---|---|
| Parser / Builder | SQL → Parse Tree → 未绑定逻辑计划 | SQL 写了哪些操作? |
| Analyzer | 未绑定 → 已绑定逻辑计划 | 表、列、类型与表达式有什么含义? |
| Rewriter | 已绑定 → 等价改写后的逻辑计划 | 能否减少不必要的工作? |
| Optimizer | 逻辑计划 → 选定的物理计划 | 采用哪种执行方式和数据分布? |
| Translator | 物理计划 → 执行节点、Fragment 与交换关系 | 怎样表示可下发的分布式计划? |
| Coordinator | 执行计划 → 实例、位置与扫描参数 | 这次具体由谁执行? |
阅读路线:第 1 节看全貌 → 第 4–7 节理解计划如何演变 → 第 8–10 节理解 Fragment、调度与返回。规则细节和附录适合第二遍查阅。
图示约定:计划树看父子关系,数据流图沿箭头阅读;Fragment 图与部署图用相同颜色标识同一片段。
1. 先看一条查询经历了什么
用下面这个常见的分析查询贯穿全文:
SELECT
d.region,
COUNT(*) AS cnt
FROM
fact f
JOIN dim d ON f.dim_id = d.id
WHERE
f.event_date >= '2026-01-01'
GROUP BY
d.region
ORDER BY
cnt DESC
LIMIT
10;
这条查询先在 FE 中完成规划和调度,再由 BE 执行数据计算:
FE 负责理解查询、设计计划、组织任务和返回结果;BE 负责扫描数据并执行算子。Nereids 是 FE 内的规划器,不是执行数据计算的引擎。
把上面的查询再按数据处理过程想一遍:fact 的扫描先受到日期条件限制;符合条件的数据按 Join 条件与 dim 匹配,再按地区统计连接结果的行数。若 dim.id 不唯一,同一事实行可能匹配多行,因此这里统计的是 Join 结果行数,而非必然等于事实表行数。若使用两阶段聚合,各 BE 先产出本地的地区计数,再按地区通过 Exchange 汇集,让同一地区的计数进入同一个最终聚合位置。最后对聚合结果排序,取前 10 个地区。
这是帮助理解的常见执行形状,不承诺优化器一定生成完全相同的节点顺序或聚合阶段。比如 Join 可能使用 Broadcast 或 Shuffle,是否采用局部聚合也要看物理属性、统计与成本。后文会依次解释这些选择发生在哪里。
2. 先分清几个层次:计划、Fragment、Instance
看 Doris 执行逻辑时,最容易把“计划里的节点”“执行片段”“BE 上的任务实例”混为一谈。它们是不同层次:
- 逻辑计划描述“查询要做什么”,例如过滤、Join、聚合、排序。此时通常还没有确定具体使用哪种 Join 算法或在哪些节点间传数据。
- 物理计划在逻辑操作上增加了“如何执行”的选择,例如 Hash Join、数据按哪些列分布、聚合分几阶段。
- Fragment 是执行计划中一组连在一起的算子。若数据需要跨执行边界传递,计划中通常会有 Exchange,FE 会据此把计划划成不同 Fragment。
- Fragment Instance 是某个 Fragment 在某台 BE 上的一份运行实例。一个 Fragment 可以在多台 BE 上各跑一个或多个 Instance;所以 Fragment 数不是任务实例总数,也不是 BE 数。
- Tablet 是表内的数据分片;Replica 是同一个 Tablet 的副本;Scan Range 是 FE 为扫描工作准备的具体扫描范围。优化计划先决定“需要读哪些数据”;扫描准备阶段为这些数据收集副本候选;最后由 Coordinator 决定“用哪个副本、由哪台 BE 的哪个 Instance 来读”。
3. SQL 到达 FE 后,如何走到 Nereids
客户端通过 MySQL 协议把请求发给 FE。FE 收到数据包后,先解析 MySQL 命令:若是普通查询命令,就取出 SQL 文本;随后进入语句处理和规划流程。
StmtExecutor 组织语句的规划与执行。对于本文的主路径,SQL 经 Nereids Parser 和 Builder 进入规划流程,后续由 NereidsPlanner 串起分析、改写、优化和计划翻译。
Doris 2.1 分支还需要留意 fallback:尝试用 Nereids 解析,不代表最后一定由 Nereids 完成规划。 如果语法暂不受 Nereids 支持,FE 可能继续使用旧 Parser;即便 Nereids 成功解析,后续在 Planner 阶段发生的某些异常,也可能根据 fallback 配置决定是否回到 legacy planner。
所以追 SQL 路径时,不要看到 Parser 被调用就认定整条语句已经走 Nereids。应把“Parser 尝试解析”和“最终 Planner 决定使用哪套规划流程”当作两个判断点。
4. Parse Tree 和 LogicalPlan 是什么关系
4.1 Parser 先识别语法结构
ANTLR Parser 会把 SQL 文本变成一棵语法树(Parse Tree)。例如上面的查询会被分解成 SELECT 列表、FROM 表关系、Join 条件、WHERE 条件、GROUP BY、ORDER BY 和 LIMIT 等语法部分。
Parse Tree 的关注点是“这段文本符合什么语法、语法元素之间如何嵌套”。它还不是优化器可以直接比较和执行的计划。
4.2 Builder 把语法节点组装成逻辑操作
LogicalPlanBuilder 通过 Visitor 模式遍历 Parse Tree:遇到 SELECT 就组装投影和查询结构,遇到表名就产生关系节点,遇到 Join、Filter、Aggregate 等语法就建立相应的逻辑算子。
对于示例查询,初始逻辑结构可以粗略理解为:
图中先按 SQL 的操作关系展示 Filter 位于 Join 上方的形状。改写后,它可以移动到 Scan fact 上方,见第 6.1 节。
在这个阶段,表和列往往仍然是“按名字写下来的”。比如计划知道这里提到了 fact 和 d.region,但还需要后续步骤确认:当前 Catalog 中是否存在这张表、列是否存在、列的数据类型是什么。
这类暂未绑定 Catalog 对象的节点常带有 Unbound 名称。其意义不是计划缺失或错误,而是 Parser/Builder 还处在“把 SQL 写成结构”的阶段。
5. Analyzer:给名字和表达式补上实际含义
Analyzer 把刚才的名字连接到 Doris 的真实元数据和语义信息。它由一组按顺序执行的分析规则组成,常见工作包括:
- 绑定关系:根据表名、Database、Catalog 和当前会话上下文找到表。若表不存在,就在此阶段报错;若存在,则转成带表元数据的 Scan 关系。
- 绑定列引用:把
f.event_date、d.region等名称对应到具体列和内部 Slot(计划中表示输入或输出列的引用)。之后优化器比较的是这些已绑定的列身份,而不仅仅是字符串名称。 - 分析表达式:解析函数、运算符和比较两边的类型,判断表达式能否应用在当前位置。
- 检查查询语义:例如列是否歧义、聚合列是否使用正确、Join 条件是否有合法引用、子查询作用域是否正确。
- 规范化部分计划:将某些语法糖或不同写法改为后续规则更容易处理的统一形态。
完成 Analyzer 后,fact 不再只是一个未解析的表名字,而是绑定到 Catalog 里的表对象;event_date 也对应到表中的具体列、类型和内部标识。此时逻辑计划已经“知道自己在处理什么数据”。
但这仍不等于知道实际在哪个 BE 执行。Analyzer 获得的是元数据和语义;并行实例及副本位置要到后续的执行准备阶段才最终安排。
5.1 示例快照:名字已经有了含义
f.dim_id 与 d.id 已分别指向两张表的列;d.region 是分组键,cnt 对应计数表达式的输出。类型和引用合法后,计划才具备继续优化的语义基础。此时仍未安排执行 BE。
6. Rewriter:让逻辑计划做更少、更合适的工作
Analyzer 保证计划理解正确;Rewriter 则尝试在不改变 SQL 结果的情况下,让计划更有效率。Rewriter 会按批次运行许多规则,有些规则自顶向下,有些自底向上。
6.1 先看贯穿示例:让过滤靠近事实表
日期条件只依赖 fact,因此这个内连接示例可以将它放到事实表一侧。若 event_date 是分区列,还可能裁掉无关分区;示例未给出表定义,因此这里只展示过滤下推,不假定已完成分区裁剪。列裁剪需要保留连接键、分组键和过滤所需列,即使它们不全部出现在最终 SELECT 中。
6.2 Filter 穿过 Project:先解释表达式,再移动过滤
下面换一个小例子,说明过滤下推遇到别名时如何保持语义:
SELECT
*
FROM
(
SELECT
c + d AS a,
e AS b
FROM
fact
) t
WHERE
a > 2
AND b = 0;
Project 把 c + d 命名为 a,把 e 命名为 b。如果把过滤条件放到 Project 下方,就不能继续写 a > 2、b = 0,因为这些别名是在 Project 输出端才产生的。规则需要把别名替换回来源表达式:
这样做可能让更靠近 Scan 的节点更早过滤数据,减少上层要处理的行。若某个过滤条件引用了 Project 子计划无法提供的外部列、窗口表达式或不满足规则约束的表达式,该部分就不能照搬到下面;规则可以只下推安全的部分,把其余条件留在原处。
6.3 Filter 穿过 Aggregate:区分分组键条件与聚合结果条件
例如:
SELECT k, COUNT(*)
FROM fact
GROUP BY k
HAVING k = 10;
k 是分组键。对每条输入记录来说,k = 10 的真假已经确定;只保留 k = 10 的输入再做聚合,与先对所有 k 聚合、最后只保留组 k = 10,结果相同。因此优化器可以尝试将这个条件移到聚合之前。
但下面的条件依赖聚合结果:
SELECT k, COUNT(*)
FROM fact
GROUP BY k
HAVING COUNT(*) > 5;
每条输入记录还没有对应的 COUNT(*) 结果,不能在聚合之前判断 COUNT(*) > 5。这个条件必须保留在聚合之后。
因此规则判断的是 Filter 条件所引用的 Slot 是否属于可安全下推的分组键等集合,不是单纯看到 SQL 里出现 HAVING 就把条件往下搬。真实计划里 HAVING 也会变成逻辑 Filter 节点,规则看的是逻辑结构和表达式依赖。
6.4 三种裁剪:列、分区、Tablet
它们都叫“裁剪”,但目标不同:
- 列裁剪回答“后续还需要哪些列”。例如查询只输出
region并计算行数,可能不需要从表中读取其他业务列。删掉不必要的列能减少 Scan、网络传输和内存开销。 - 分区裁剪回答“哪些 Partition 有可能包含满足条件的数据”。例如表按
event_date分区,过滤日期只覆盖一个范围时,可以排除日期范围之外的分区。 - 分桶/Tablet 裁剪回答“在剩余分区中,Hash 分布列上的条件能否进一步排除一些桶/Tablet”。例如分布列是
customer_id,过滤条件能确定customer_id = 7时,Hash 规则可以计算可能命中的桶;如果分布列没有约束或谓词太复杂,就必须保留更多甚至全部桶。
示意:
这些优化确定的是计划需要的数据范围或列集合。**它们本身不会最终选中某一个副本,也不会决定哪台 BE 来执行。这点对理解 UnboundRelation、分区/Tablet 剪枝和 Coordinator 尤其重要。
7. Optimizer:比较等价的执行办法
7.1 RBO 和 CBO 如何理解
通常可以这样建立直觉:
- 规则驱动、根据局部语义改写计划,常被概括成 RBO 思路。
- 使用行数、列统计等信息估算不同方案的代价,再选择一个方案,常被概括成 CBO 思路。
但在 Nereids 中,不能机械地画线说“Rewriter 全是 RBO,Optimizer 才有规则”。Cascades 优化过程也会应用物理实现和属性相关规则;Rewriter 里也有不止一种逻辑变换。更实用的区别是:Rewriter 的主要目标是整理/简化逻辑结构,而 Optimizer 主要在等价候选和物理属性间搜索、比较成本。
7.2 Memo 是什么
如果 Join 有多个输入顺序、Join 算法或数据分布选择,单纯不断复制整棵计划树会产生很多重复结构。Memo 用 Group 和 GroupExpression 组织这些等价候选:
- Group 可以理解为“代表同一逻辑结果的计划集合”。
- GroupExpression 是这个集合中的一种具体表达方式,例如某种 Join 运算及其子 Group。
这里的物理属性包括数据如何分布、是否满足排序要求等。例如,按 region 完成最终聚合,需要让同一地区的数据进入同一个聚合实例;如果输入尚不满足,就需要考虑额外的数据交换。
优化器可以将不同实现放进 Memo,对每种实现考虑它需要的子节点属性和预计成本。最终不是把所有候选都执行,而是从根 Group 递归取出满足要求、估算成本合适的候选,重建成一个物理计划。
所以,物理执行计划不是 Parser 刚完成时就有的;它是在分析和逻辑改写之后,由优化器在 Memo 搜索空间中选出的。
7.3 为什么有时选择 Broadcast Join,有时选择 Shuffle Join
假设 Join 的右侧是维表,左侧是大事实表:
- Broadcast 把右侧数据复制到参与执行的 BE/实例,让左侧数据可以就地匹配。若右侧小,避免两侧大规模重分发通常划算;若右侧很大,复制到很多位置会放大网络和内存成本。
- Shuffle 把两侧数据都按 Join Key 重新分发,使相同 Key 汇合到同一个分区。它不需要复制完整右表到每个执行位置,但要付出两侧重分发的网络代价。
优化器先检查 Join 语义和物理属性是否允许某种分布,再估算可行方案的代价。统计信息用于估计过滤和 Join 后还剩多少行、需要传输多少数据;这些是规划时的估计,不是执行时已经读到的实际行数。
因此“右表小就必然 Broadcast”是过度简化。它可能是强有力的倾向,但统计是否可用、Hint、执行实例规模、Join 类型、其他算子的代价都会影响最终计划。Translator 随后落实选定的数据分布方式,不再重新做这轮成本比较。
7.4 局部聚合与多阶段聚合
对 GROUP BY,一个常见执行思路是先在数据所在位置做局部聚合,减少需要跨 BE 传输的中间行数,再按分组键交换到目标位置做合并或最终聚合。比如 100 万行若局部聚合后变成几千组,Exchange 需要传输的数据就可能明显减少。
但计划可以根据语义和分布采用不同聚合阶段/模式,不是所有查询都一律“先本地、再全局”。
8. PhysicalPlan 如何变成 Fragment
优化器选出 PhysicalPlan 后,FE 还要把它翻译成执行端能理解的描述。Translator 将物理算子转换为执行节点(PlanNode),并准备表达式与输入输出描述。其中 Tuple 描述一组输出字段,Slot 描述具体字段;需要重新分布数据的位置则形成 Fragment 间的发送端和接收端。
8.1 Scan:建立执行树的叶子
Scan 是读表的入口。Translator 为它准备输出列的 Tuple/Slot 描述,建立 OLAP Scan 节点,并传递已选的表、索引、分区等信息。它通常是一个 Fragment 的起点。
此时的 Scan 节点知道要扫描哪些表数据的逻辑范围,但真实副本地址和最终由哪个 BE 的哪个 Instance 执行,还要经过扫描范围准备和 Coordinator 调度。
8.2 Filter:尽量附加在已有节点
Filter 是一组需要检查的条件。Translator 会把它们翻成执行表达式,尽量附加到已有 PlanNode 上;如果当前根节点不适合承载条件,就加一个专门的 Select 节点。
所以普通 Filter 通常只是当前 Fragment 里的又一个算子,不会因为出现了 WHERE 就自动切出新 Fragment。紧邻 Join 的过滤条件也可能被 Join 节点统一处理。
8.3 Aggregate:在 Fragment 内添加聚合算子
Aggregate 会把分组表达式、聚合函数以及聚合输出列转换成执行描述,再作为当前 Fragment 的新根或上层节点。是否需要中间聚合结果、是否要做最终化等,取决于聚合阶段与模式。
局部聚合和全局聚合之间若需要不同实例交换数据,通常会有 Distribute/Exchange 来建立边界;不是每遇到一个 Aggregate 就新开一个 Fragment。
8.4 Join:把两棵子计划接起来
Join 需要两侧输入。Translator 先分别得到左、右子计划,再创建 Join 节点并将两侧接入。若两侧 Fragment 可以在当前结构下合并,就会合并为一个 Fragment 计划;若某一侧已有 Exchange,则保留相应的分布和边界关系。
Join 算子本身不必单独占用一个 Fragment,边界取决于两侧输入的数据分布与 Exchange。
8.5 Exchange / Distribute:Fragment 之间的数据传送站
PhysicalDistribute 可以理解成物理计划上的“数据要按某种方式送到下一步”的明确要求。翻译时通常会生成一对逻辑角色:
- 发送端属于子 Fragment,负责把数据送出。
- 接收端 Exchange属于父 Fragment,负责接收来自一个或多个发送实例的数据。
同时要写明传输规则:随机分发、按 Hash Key 分发、汇聚到单个实例,或特定的 Bucket 分发。这个规则在执行计划中表示为 DataPartition 等信息。
普通的 Filter、Project、Aggregate 等经常是在已有 Fragment 内添加算子;Exchange 才是理解“数据跨片段流动”的关键位置。一个查询可能包含多个 Fragment,但它们合在一起才是完整的执行计划。
8.6 在数据交换处画出 Fragment 边界
9. Coordinator:从计划变成真正的 BE 任务
到此为止,FE 已经知道有哪些 Fragment、算子和数据交换规则。Coordinator 接下来要结合集群当下状态,把抽象计划落实为具体执行实例和扫描任务。
9.1 Tablet 范围、副本和 BE 是逐步确定的
实际扫描准备可以这样理解:
- 前面的分析/改写阶段,根据表定义和过滤条件减少候选分区;某些 Hash 分布列条件还能进一步限制候选 Tablet。
- OLAP Scan 节点根据选中的分区和索引准备 Tablet 扫描范围,并收集这些 Tablet 可用的副本位置。一个 Tablet 通常有多个副本候选,且各副本分布在不同 BE 上。
- Coordinator 从候选位置里选出实际执行位置,并把 Scan Range 放进某个 Fragment Instance 的任务参数。
选择时可能考虑数据本地性、副本是否可用、资源标签、当前负载、Replica 平衡和特殊 Join 分布要求。Colocate Join 或 Bucket Shuffle 为了让对应数据尽量留在匹配的位置上,会使用专门的分配逻辑。
所以要把下面三句话严格区分开:
- “裁掉了某个 Partition/Tablet”是计划范围决定。
- “这个 Tablet 有哪些 Replica 可供扫描”是扫描准备信息。
- “这次查询由哪个 BE 上的 Instance 扫这个 Range”是 Coordinator 的执行调度结果。
它们相关,但不是同一时刻、同一个决策。
9.2 同一张计划怎样展开成 Instance
接着第 8.6 节,假设 F1、F2、F3 都在 A、B、C 三台 BE 上各运行一个 Instance,F0 只在 A 上运行:
| Fragment | 数据如何流动 |
|---|---|
| F1:维表扫描 | 各实例扫描各自分到的数据,将这些数据广播到 F2 接收实例 |
| F2:事实表扫描、Join、局部聚合 | 每个 Join 实例合起来收到完整维表输入;局部计数按 region 发往 F3 |
| F3:最终聚合 | 相同 region 的中间计数进入同一接收实例并合并 |
| F0:结果汇总 | 接收 F3 结果,完成最终排序和 LIMIT,向 FE 返回结果 |
这个示意共有 4 个 Fragment、10 个 Instance、3 台 BE。F0 放在 A 只是举例;最终位置、并行度及实例模型须以该分支的实际执行参数为准。Instance 也不等于一个线程,BE 内部还会进一步调度执行工作。
这不是固定的实例数量公式。同一个 Fragment 在一台 BE 上也可以有多个 Instance;实际数量受会话并行度、扫描范围、执行引擎模式及分布要求等因素影响。
9.3 Exchange 如何连接发送者和接收者
假设上游局部聚合后要按 region 做 Hash 分发,下游 Exchange 就要知道:
- 哪些上游 Instance 会向它发送数据;
- Hash Key 是什么;
- 哪些下游 Instance 接收相应数据;
- 每个接收实例对应的 BE 地址和 Fragment Instance ID。
Coordinator 会把这些关系整理进执行参数,再发送给 BE。这样 BE 的发送端不需要在运行时猜测接收者;接收端也知道有多少发送方,需要从哪里读取数据。
Gather 是一个直观的特殊情况:如果结果必须由单个实例汇总,Exchange 接收端通常只安排一个实例。Hash 分发则会按照分区规则连接到多个接收目标。
9.4 下发任务并跟踪执行
实例位置、扫描范围和片段连接关系齐备后,Coordinator 将执行参数下发给相应 BE。BE 据此启动实例,扫描输入数据并执行算子;不同 Fragment 的实例按既定分布关系交换中间结果。
FE 随后跟踪查询状态,并从结果端获取输出。执行跟踪与结果接收在查询运行期间进行,不是等全部任务完成后才开始读结果。
10. 执行结果如何回到客户端
BE 执行 Fragment 后,FE 的 Coordinator/ResultReceiver 按批次获取结果。FE 不是等所有结果都存到一个巨大结果集后再一次性处理;典型查询会以批次方式消费数据。
StmtExecutor 从 Coordinator 取下一批数据后,按查询结果定义发送列信息和行数据。客户端按查询结果集的形式消费这些列和数据行。
一条查询的返回路径可以概括为:
批次返回不等于立即返回首行。示例包含聚合和排序,结果产生时间仍取决于这些算子何时能输出。执行结束后,FE 完成结果响应并清理查询相关状态;若出现错误、取消或超时,则需要终止相关任务。
11. 可选的源码阅读地图
建议按下表追踪职责交接,先看入口,再进入具体规则。Java 文件均以 fe/fe-core/src/main/java/org/apache/doris/ 为路径前缀。
| 阶段 | 优先阅读的入口 |
|---|---|
| 请求与语句执行 | qe/MysqlConnectProcessor.java、qe/StmtExecutor.java |
| 规划主线 | nereids/NereidsPlanner.java |
| 解析与构建 | nereids/parser/NereidsParser.java、LogicalPlanBuilder.java |
| 分析与改写 | nereids/jobs/executor/Analyzer.java、Rewriter.java |
| 成本优化 | nereids/jobs/executor/Optimizer.java、nereids/memo/Memo.java |
| 计划翻译 | nereids/glue/translator/PhysicalPlanTranslator.java |
| 扫描准备与调度 | planner/OlapScanNode.java、qe/Coordinator.java |
| 结果接收 | qe/ResultReceiver.java、qe/StmtExecutor.java |
需要查看具体改写时,再从 nereids/rules/RuleSet.java 找到规则注册位置,进入 nereids/rules/rewrite/ 下对应实现;关系和表达式绑定从 nereids/rules/analysis/ 开始。这样可以把规则放回调用顺序中理解。
EXPLAIN PARSED PLAN、EXPLAIN ANALYZED PLAN、EXPLAIN REWRITTEN PLAN 和 EXPLAIN OPTIMIZED PLAN 可以用来比较 Nereids 的计划阶段;也可以结合 enable_nereids_trace 和 dump_nereids_memo 等 Session Variable。支持的 Explain 形式和会话变量以当前分支为准。