深入 Impala 查询引擎:一条 SQL 如何变成分布式流水线
Impala 查询快,不是因为它省略了 SQL 优化,也不是简单地“绕过 MapReduce”。真正的区别在执行形态:Daemon 常驻、元数据常驻、查询被切成并行 Fragment,数据通过 Exchange 在节点间流动,Scan、Join 和 Aggregate 尽可能组成流水线,并由 Runtime Filter 在运行期缩小扫描范围。
本文以 Impala 4.5.0 为基线,从一条 SQL 进入 impalad 开始,追到 RowBatch 穿过执行树并回到客户端。源码路径均相对于 Impala 仓库。
先建立进程视图
Impala 集群的核心角色不是传统意义上的“Master + Worker”。每个 impalad 都能接收查询,其中接收客户端请求的节点在本次查询中成为 Coordinator,其他节点执行它分配的 Fragment Instance。
flowchart LR
C[客户端] --> Q[Coordinator impalad]
CAT[Catalogd] -->|元数据更新| Q
ST[Statestored] -->|成员与 Topic| Q
Q -->|ExecQueryFInstances| E1[Executor 1]
Q -->|ExecQueryFInstances| E2[Executor 2]
Q -->|ExecQueryFInstances| E3[Executor 3]
E1 -->|状态与 Profile| Q
E2 -->|状态与 Profile| Q
E3 -->|状态与 Profile| Q
Q -->|FetchResults| C
客户端
→ Coordinator impalad
├── Frontend:解析、分析、计划
├── Scheduler:选择执行位置
├── Coordinator:启动并跟踪后端实例
└── Result Sink:向客户端返回结果
Executor impalad × N
├── Scan / Join / Aggregate / Sort
├── DataStreamSender
└── Runtime Profile
Catalogd
└── 表、分区、文件、统计信息与元数据版本
Statestored
└── 成员状态、心跳和主题更新
Coordinator 与 Executor 是每条查询里的职责,不一定对应两套不同二进制。生产环境可以通过角色配置让部分节点主要承担协调工作。
第一步:SQL 进入 Frontend
客户端通过 Beeswax 或 HiveServer2 协议提交 SQL。服务层建立 QueryContext,随后进入 Java Frontend。主要入口可以从:
fe/src/main/java/org/apache/impala/service/Frontend.javafe/src/main/java/org/apache/impala/analysis/fe/src/main/java/org/apache/impala/planner/Planner.javafe/src/main/java/org/apache/impala/planner/DistributedPlanner.java
继续阅读。
Frontend 并不是只调用一次 Parser。它需要完成:
- 语法解析,生成 Statement AST;
- 名称解析,绑定数据库、表、列和函数;
- 类型推导与隐式转换;
- 权限检查;
- 常量折叠与表达式改写;
- 子查询改写、谓词传播和等价类分析;
- 生成单机逻辑计划;
- 依据数据位置切成分布式 Fragment;
- 估算资源并序列化为 Thrift 请求。
分析阶段失败的 SQL 不会进入后端执行。诸如列歧义、类型不兼容、聚合语义错误和权限拒绝,都在这里尽早终止。
Planner 的输入并不只是 SQL
优化器需要 Catalog 中的表结构、分区、文件块位置、列统计和行数估计。SQL 文本相同,元数据不同,计划也会不同。
以 Join 为例,Planner 要判断:
- 哪一侧作为 Build,哪一侧作为 Probe;
- 小表是否广播到每个执行节点;
- 大表是否按 Join Key 重新分区;
- Join 顺序怎样降低中间结果;
- 是否能生成 Runtime Filter;
- 每个节点和 Fragment 需要多少内存。
代价估算依赖统计信息。缺少 NDV、行数或列宽时,优化器只能使用默认估计,广播本不该广播的大表,或者采用代价更高的 Join 顺序。很多“Impala 突然很慢”的根因不在执行器,而在过期统计信息让 Planner 看错了数据规模。
从 PlanNode 到 PlanFragment
单机计划是一棵 PlanNode 树,例如:
TOP-N
└── AGGREGATE (merge)
└── EXCHANGE
└── AGGREGATE (partial)
└── HASH JOIN
├── HDFS SCAN fact
└── HDFS SCAN dim
分布式规划器在需要改变数据分布的地方插入 Exchange,并把树切成多个 PlanFragment。
Fragment 不是一台机器上的完整查询,而是一段可以并行实例化的执行子图。一个 Fragment 可以在多台 Executor 上各运行多个 Instance。ExchangeNode 是接收端,子 Fragment 末端的 DataStreamSender 是发送端。
常见数据分区方式包括:
UNPARTITIONED:单实例执行,常用于最终汇总;HASH_PARTITIONED:按表达式哈希,把相同 Key 送到同一接收端;RANDOM:均匀分散数据;BROADCAST:把 Build Side 复制到多个接收端;DIRECTED:按指定目标路由。
源码中 DistributedPlanner 的 create*Fragment() 方法和 ExchangeNode 展示了这些边界如何形成。
Scheduler:把逻辑实例落到具体主机
Fragment 只说明并行关系,还没有决定在哪运行。调度阶段会结合:
- HDFS 文件块或数据源位置;
- 当前可用 Executor;
- Scan Range 数量;
- 节点负载与资源池;
- Fragment 的并行度配置;
- Coordinator 与数据节点距离。
Scan 尽量靠近数据,但本地性不是唯一目标。如果一个节点拥有大量数据块,把所有 Scan 都压到它上面会失去并行收益。调度器要在本地读取和负载均衡之间取舍。
生成的 Query Schedule 包含每个 Backend 要运行的 Fragment Instance、Scan Range、数据流目标和 Runtime Filter 路由。
Coordinator 怎样启动整个查询
C++ 后端中的关键入口包括:
be/src/runtime/coordinator.ccbe/src/runtime/coordinator-backend-state.ccbe/src/runtime/query-exec-mgr.ccbe/src/runtime/query-state.ccbe/src/runtime/fragment-instance-state.cc
Coordinator::Exec() 建立查询级状态和后端状态,随后 StartBackendExec() 为每个 Backend 组织 ExecQueryFInstances RPC。远端 QueryExecMgr::StartQuery() 创建或取得 QueryState,再为本机每个 Fragment Instance 建立 FragmentInstanceState。
sequenceDiagram
participant C as Coordinator
participant B as BackendState
participant Q as QueryExecMgr
participant S as QueryState
participant F as FragmentInstanceState
participant E as ExecNode Tree
C->>B: StartBackendExec
B->>Q: ExecQueryFInstances RPC
Q->>S: 创建或取得查询状态
S->>F: 创建 Fragment Instance
F->>E: Prepare / Open
E-->>F: RowBatch 流
F-->>C: 状态与 Runtime Profile
Coordinator::Exec
→ StartBackendExec
→ BackendState::Exec
→ ExecQueryFInstances RPC
→ QueryExecMgr::StartQuery
→ QueryState
→ FragmentState
→ FragmentInstanceState
→ ExecNode / DataSink
启动不是完全串行的。Coordinator 会并行向后端发送 RPC,并在确认关键启动阶段后进入运行状态。任何 Backend 返回错误,都可能触发查询取消并向其他实例传播 Cancel。
ExecNode 生命周期:Prepare、Open、GetNext、Close
执行树上的节点共享统一接口,定义在 be/src/exec/exec-node.h:
Prepare() 建立描述符、表达式、计数器和内存资源
Open() 打开子节点,完成阻塞型准备
GetNext() 生产下一批 RowBatch
Reset() 在子计划等场景中重新执行
Close() 释放资源,必须能处理部分初始化和失败路径
GetNext() 体现了 Pull 模型:父节点向子节点要数据。ScanNode 从 Scanner 取得 RowBatch,Filter 丢弃不匹配行,Aggregate 更新哈希表,Exchange 从网络接收批次。
但不能把整个引擎理解成纯同步迭代器。Scan Range 读取、数据流 RPC、Runtime Filter 和后端状态上报都大量异步执行;RowBatch 只是执行算子之间的主要数据载体。
Scan:真正决定查询下限的地方
没有任何 Join 优化能挽救一次不必要的全表读取。Impala 的 Scan 性能来自多层裁剪:
Catalog 分区裁剪
→ 文件与 Scan Range 裁剪
→ Parquet Row Group 统计裁剪
→ Page Index / Dictionary 过滤
→ Runtime Filter
→ 解码后的谓词判断
Scan Range
文件被拆为多个 Scan Range,分配给 Scanner 线程。I/O Manager 负责异步读取、缓存与缓冲区管理,使磁盘或远端存储 IO 能与解码计算重叠。
Parquet 列裁剪
只读取查询需要的列。列式存储把同列值放在一起,使压缩和批量解码更高效。嵌套类型还需要根据 Definition/Repetition Level 重建逻辑结构。
Predicate Pushdown
谓词不一定等数据完全解码后才执行。Min/Max、字典和 Page Index 可以提前证明某个数据块不可能命中,从而跳过读取或解码。
be/src/exec/hdfs-scan-node*、hdfs-scanner.h 和 parquet/ 目录是理解扫描路径的主要入口。
Runtime Filter:用 Join 的结果反过来过滤 Scan
静态优化发生在执行前,Runtime Filter 则在查询运行时生成。Hash Join Build Side 完成后,可以从 Join Key 构造 Bloom Filter、Min/Max Filter 或 In-List Filter,再把它发布给 Probe Side 的 Scan。
Build Side: dim.region_id = {3, 7, 9}
↓ 生成 Runtime Filter
Probe Scan: fact.region_id
↓ 提前排除绝大多数不可能匹配的数据
Hash Join: 只处理剩余行
过滤器可能是本地的,也可能跨节点聚合并广播。过滤器到得太晚时,Scan 已经读完,收益为零;等得太久又会延迟首批数据。因此执行器允许 Scan 等待有限时间,并在过滤器到达后应用到后续 Range。
Runtime Filter 的收益取决于 Build Side 基数、过滤选择性、发布时间和数据源是否支持下推。不是 Filter 数量越多越好,大 Filter 的构建、合并和网络传输同样有成本。
Hash Join:广播还是重新分区
假设事实表很大,维表较小:
Broadcast Join
维表在 Build Fragment 中生成哈希表或 Build Rows,并发送到每个 Probe 节点。
优点是事实表不用跨网络重新分区;缺点是 Build 数据要复制 N 份,内存成本随 Executor 数量增加。
Partitioned Join
两侧都按 Join Key 哈希分区,相同 Key 被路由到同一个接收实例。
优点是 Build 内存分散;缺点是两侧可能发生大量网络 Shuffle。
优化器的选择可以粗略写成:
Broadcast 成本 ≈ BuildSize × ExecutorCount
Partition 成本 ≈ BuildSize + ProbeSize 的网络重分布
实际模型还要考虑并行度、已有分区、基数和内存上限。统计信息错误时,这个选择很容易出错。
Exchange:分布式流水线的关节
发送端实现位于 be/src/runtime/krpc-data-stream-sender.cc。KrpcDataStreamSender 根据分区策略把 RowBatch 分到 Channel,序列化后通过 KRPC 发送。接收端由 DataStreamMgr 与 ExchangeNode 管理。
flowchart LR
P[上游 RowBatch] --> H{分区策略}
H -->|Hash Key A| C1[Channel 1]
H -->|Hash Key B| C2[Channel 2]
H -->|Hash Key C| C3[Channel 3]
C1 -->|KRPC| X1[ExchangeNode 1]
C2 -->|KRPC| X2[ExchangeNode 2]
C3 -->|KRPC| X3[ExchangeNode 3]
X1 --> N1[下游 Pipeline]
X2 --> N2[下游 Pipeline]
X3 --> N3[下游 Pipeline]
为了避免无限堆积,Channel 需要处理背压:前一批 RPC 未完成时,发送端不能无界地产生缓冲区。发送、序列化和网络延迟会体现在 Profile 的 DataStreamSender 指标中。
Exchange 也是性能分界线:它切断单机 Pipeline,引入序列化、网络和接收端排队。看执行计划时,不能只数 Scan 和 Join;每个 Exchange 都意味着一次数据分布改变。
Aggregate 为什么通常分两阶段
GROUP BY 常被拆成局部聚合和全局合并:
每个 Scanner/Executor
→ Partial Aggregate
→ 按 Group Key Exchange
→ Merge/Final Aggregate
局部聚合先把大量明细压缩成较少的分组状态,减少网络数据量。若 Group Key 基数接近输入行数,压缩效果会很差,哈希表还会占据大量内存。
聚合器会监控哈希表和内存使用,必要时采用分区与 Spill。Spill 保住查询正确性,却把内存计算变成磁盘 IO,尾延迟通常显著上升。
Sort 与 Top-N 的阻塞边界
有些节点可以边读边产出,有些必须看到大量输入才能返回结果。
- Filter、Project 通常是流式节点;
- Hash Join 的 Build Side 是阻塞的;
- 完整 Sort 需要生成 Run 并归并;
- Top-N 只保留候选集合,通常比完整排序省内存;
- Final Aggregate 是否阻塞取决于执行形态。
SortNode::Open() 读取输入并驱动 Sorter,最终归并则可以在 GetNext() 中按需产生结果。外部排序在内存不足时写临时 Run,再执行多路归并。
Codegen 为什么能加速表达式执行
通用执行器需要处理类型分派、虚函数、Nullable 判断和表达式树调用。Impala 使用 LLVM 将热点表达式和部分循环编译为查询专用机器码,例如:
- Join Key 哈希;
- 谓词计算;
- Tuple Materialization;
- 聚合更新;
- DataStream 分区哈希。
Codegen 消除了部分解释开销,但编译本身有成本。短查询可能在代码生成完成前就能执行结束,因此 Impala 会区分 Codegen 准备和实际执行,并在 Profile 中记录编译时间。
RowBatch、Tuple 与内存所有权
Impala 4.5 的经典执行路径仍大量使用 RowBatch。一个 RowBatch 保存 TupleRow 指针,Tuple 中按 SlotDescriptor 布局字段,变长数据由关联内存持有。
GetNext() 的所有权规则很重要:父节点取得的行可能仍引用子节点或 RowBatch 的内存,不能过早释放。RowBatch::TransferResourceOwnership() 等机制让缓冲区在算子间转移,而不是复制每个字符串。
减少复制提升速度,也让生命周期错误更危险。源码中 ExecNode::GetNext() 和 Close() 的注释明确规定了资源在成功、EOS、取消和错误路径上的处理方式。
内存不是一个全局数字
查询内存由多层对象共同约束:
进程 MemTracker
└── Query MemTracker
├── Fragment / Instance
├── Hash Join Build
├── Aggregator
├── Sorter
├── Scan Buffers
└── Data Stream Buffers
Buffer Pool 管理可回收页,Reservation 机制避免多个算子都假设自己能拿到剩余内存。算子在 Prepare/Open 阶段声明最低和理想资源,Scheduler 据此做 Admission Control。
超过内存限制时,支持 Spill 的算子尝试落盘;不支持或无法继续 Spill 时查询失败。把 MEM_LIMIT 调大只能改变边界,不能修复错误 Join 顺序、数据倾斜或失控基数。
Admission Control:查询开始前的背压
如果所有查询都立即启动,节点会在内存和 CPU 已经耗尽后才失败。Admission Control 在运行前依据资源池配置、并发查询、内存估算和队列状态决定:
- 立即准入;
- 排队等待;
- 超时拒绝;
- 因资源请求不可能满足而直接拒绝。
这不是简单限流。一个大查询可能占用多个 Backend 的资源,准入判断必须考虑整个调度集合,而不是只看 Coordinator。
Catalog 与 Statestore 为什么会影响查询
Catalogd 管理元数据更新并通过 Topic 分发。Impalad 本地维护 Catalog Cache,因此规划查询时不必每次同步访问 Hive Metastore。
Statestored 负责成员信息和 Topic 更新传播。它不在 RowBatch 数据路径里,但 Executor 成员变化、Catalog 版本传播和节点故障发现都依赖这套机制。
元数据不一致通常表现为“文件明明存在却查不到”或分区版本落后。INVALIDATE METADATA 和 REFRESH 影响范围不同,不应当作无差别修复按钮。
查询结果怎样返回
根 Fragment 通常运行在 Coordinator,PlanRootSink 或 BufferedPlanRootSink 收集最终 RowBatch。客户端调用 FetchResults 时,Coordinator::GetNext() 从结果 Sink 取数据,转换成协议结果集。
这意味着客户端消费速度也会影响查询尾部。如果客户端迟迟不 Fetch,结果缓冲区和查询状态不能立刻释放。大型导出应控制 Fetch Size,并避免让 Coordinator 成为单点序列化瓶颈。
Profile:从哪里判断查询慢在何处
Impala Runtime Profile 是执行器真实发生过什么的证据。阅读时建议按边界排查:
计划是否错了
- Estimated Cardinality 与实际 RowsReturned 差多少;
- Broadcast Build 是否过大;
- Exchange 前后的数据量是否异常;
- 是否缺少 Runtime Filter 或分区裁剪。
Scan 是否慢
- BytesRead、ScanRangesComplete、RowsRead;
- IO Wait 与 Scanner Threads;
- Parquet 解码时间;
- Runtime Filter 到达时间和过滤比例;
- 本地、缓存、远程读取比例。
计算是否慢
- Hash Join Build/Probe 时间;
- 聚合哈希表基数;
- Sort Run 和 Spill 数据量;
- Codegen 时间;
- 单个 Instance 是否远慢于其他实例。
是否存在倾斜
平均值会隐藏倾斜。应比较各 Fragment Instance 的最大值和分布:某个分区承载大量相同 Join Key 时,一个实例会拖住整个查询。
一条 Join SQL 的完整路径
把前面的组件串起来:
SELECT d.region, SUM(f.amount)
FROM fact_orders f
JOIN dim_store d ON f.store_id = d.store_id
WHERE f.order_date >= '2026-09-01'
GROUP BY d.region;
执行过程大致是:
- Analyzer 解析表、列、类型与权限;
- 分区裁剪只保留目标日期范围;
- Planner 根据统计选择维表为 Build Side;
- 维表较小时选择 Broadcast Join;
- Build Side 生成 Store ID Runtime Filter;
- Filter 发布到事实表 Scan,提前跳过不命中数据;
- 每个 Executor 执行本地 Hash Join 和 Partial Aggregate;
- 结果按 Region 经 Exchange 重分区;
- 上层 Fragment 合并聚合;
- 根 Fragment 把结果交给客户端。
任何一步都可能成为瓶颈。维表统计过期会改变 Join 策略,Runtime Filter 太晚会失效,Region 极度倾斜会拖慢最终聚合,客户端读取缓慢则延长结果阶段。
Impala 查询快的真实原因
可以把它归结为几件相互配合的事:
- Daemon 和元数据常驻,避免每次查询启动完整计算框架;
- Fragment Instance 将 Scan 和计算分散到数据附近;
- Exchange 明确控制数据分布;
- 列裁剪与多层谓词下推减少读取;
- Runtime Filter 把 Join 信息推回 Scan;
- Pipeline 让 IO、解码、计算和网络重叠;
- LLVM Codegen 降低热点表达式开销;
- Buffer Pool、Spill 和 Admission Control 给资源使用设边界。
没有哪一项能单独保证性能。优化器估错基数,执行器再快也会做错工作;数据倾斜严重,并行度越高也无法均匀;内存设置不合理,Pipeline 最终仍会被 Spill 打断。
结语
理解 Impala 最有效的方式不是背组件名,而是跟踪两个东西:数据在哪里改变分布,资源在哪里形成阻塞。
Exchange 标记分布变化,Scan、Join Build、Aggregate、Sort 和 Result Sink 标记主要资源边界。Runtime Profile 则把计划中的估计与执行中的事实放在一起。
一条 SQL 从文本变成结果,经历的是 Frontend 语义分析、Planner 代价选择、Scheduler 实例放置、Coordinator RPC 启动、Executor 流水线运行和结果回收。能沿这条链找到具体 Fragment、Instance 和算子,才算真正开始理解 Impala 查询引擎。