Adaptive Query Execution:运行时改写执行计划
上一篇解决了 DataFrame 和 Dataset 的 API 演进。这一篇进入运行时优化——Adaptive Query Execution(AQE)。
传统查询优化器的一个根本问题是:优化发生在执行之前,依赖的统计信息可能不准确或完全缺失。AQE 把优化推迟到执行过程中——在每个 Shuffle 边界,收集实际的数据统计信息,然后用这些精确的统计重新优化后续 Stage 的执行计划。
本文只抓一个问题:AQE 的三个核心优化分别解决什么问题,以及运行时优化的反馈循环如何工作。
运行时反馈循环
AQE 的工作方式可以用一句话概括:执行一个 Stage → 收集 Shuffle 输出的统计信息 → 用统计信息重新优化下一个 Stage 的计划 → 执行下一个 Stage。
1 | |
Shuffle 是天然的观测点:上游 Stage 的所有 Task 必须全部完成后,下游 Stage 才能开始。在这个等待窗口中,AQE 可以统计每个 Shuffle 分区的实际数据量,然后据此调整计划。
优化一:合并小分区
spark.sql.shuffle.partitions 的默认值是 200。对于小数据量的查询,200 个分区可能导致大量的微小 Task,每个 Task 只处理几 KB 数据,调度开销远超计算开销。
AQE 的 Coalescing Post-Shuffle Partitions 优化在 Shuffle 完成后检查每个分区的实际数据量,把相邻的小分区合并成更大的分区:
1 | |
合并的目标大小由 spark.sql.adaptive.advisoryPartitionSizeInBytes(默认 64MB)控制。合并后 Task 数量减少,每个 Task 处理更多数据,减少了调度开销。
这个优化让用户不再需要精确调整 spark.sql.shuffle.partitions——设一个较大的值(如 200),AQE 会自动把多余的小分区合并掉。
优化二:动态切换 Join 策略
编译期选择 Join 策略依赖表的大小统计。如果统计信息缺失或不准确(比如经过过滤后表变小了),可能选择了 SortMergeJoin 而实际上 BroadcastHashJoin 更优。
AQE 在 Shuffle 后知道了 Join 输入的实际大小。如果某一侧的数据量小于广播阈值,AQE 把 SortMergeJoin 动态切换为 BroadcastHashJoin:
1 | |
BroadcastHashJoin 比 SortMergeJoin 快得多——它避免了大表的排序和归并,直接用小表构建 hash 表在大表端做探测。
优化三:处理倾斜 Join
数据倾斜是分布式 Join 的经典难题:某些 key 的数据量远超平均值,导致处理这些 key 的 Task 耗时远超其他 Task。
AQE 的 Skew Join Optimization 在 Shuffle 后检测倾斜分区(数据量超过中位数的 N 倍,默认 spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes=256MB),然后把倾斜分区拆成多个子分区,每个子分区与另一侧的对应分区做 Join:
1 | |
拆分后,原来一个 Task 处理 500MB 数据,变成三个 Task 各处理约 167MB + 5MB。总数据量略有增加(table_b 的 P2 被复制了),但并行度提升,消除了长尾 Task。
实验:观察 AQE 效果
1 | |
AQE 开启时,Spark UI 的 SQL 页面会显示 “AdaptiveSparkPlan” 节点,表示该查询使用了自适应优化。点击进入可以看到编译期计划和实际执行计划的差异。
模式提炼
AQE 体现了运行时自适应优化的通用模式:用执行过程中的实际数据替代编译期的预估数据来做决策。
这个模式在多个领域出现。JIT 编译器在运行时收集分支预测和热点方法信息,据此做内联和去虚化优化。TCP 的拥塞控制在传输过程中测量 RTT 和丢包率,动态调整窗口大小。自适应负载均衡器在请求过程中测量后端响应时间,动态调整流量分配。
核心思路一致:编译期/配置期的信息是静态的、可能不准确的;运行时的观测数据是动态的、精确的。在系统有能力在运行时做出调整的地方,用动态数据替代静态预估。
工程迁移表
| Spark/AQE 概念 | 传统数据库对应 | Flink 对应 | 通用系统对应 |
|---|---|---|---|
| 运行时重新优化 | 自适应查询处理(AQP) | 无直接对应 | JIT 编译的 profile-guided optimization |
| 合并小分区 | 无(分区数静态确定) | 无(并行度静态确定) | 自动 auto-scaling |
| 动态切换 Join 策略 | 运行时 Join 方法切换(如 Oracle AQP) | 无 | A/B 测试中的多臂老虎机 |
| 倾斜 Join 处理 | 倾斜处理 hint(手动) | 无内置自动处理 | 负载均衡的热点分散 |
| Shuffle 边界统计收集 | 执行期间的基数估算 | Operator 的 watermark/metrics | 监控系统的实时指标采集 |
常见误解
误解一:“AQE 可以修复任意的查询性能问题”。AQE 只在 Shuffle 边界做优化。如果性能瓶颈在 Scan 阶段(读取大量不需要的数据)或单个 Task 内部(复杂的 UDF 计算),AQE 帮不上忙。
误解二:“开启 AQE 后不再需要调整 spark.sql.shuffle.partitions”。AQE 的合并小分区确实减轻了对这个参数的敏感度,但过小的初始值(比如 1)仍然会导致 Shuffle 阶段只有一个 Task——AQE 只能合并,不能拆分已有的分区(拆分只发生在 Skew Join 场景)。
误解三:“AQE 没有开销”。AQE 在每个 Shuffle 边界需要收集统计信息并重新运行优化器,这带来少量的延迟(通常几十毫秒)。对于亚秒级的微小查询,这个延迟可能不可忽略。
练习
-
执行一条涉及两表 Join 的 SQL,在 Spark UI 的 SQL 页面查看 AdaptiveSparkPlan 节点。对比 “Initial Plan” 和 “Final Plan” 的差异。Join 策略是否发生了变化?
-
设置 spark.sql.shuffle.partitions=1000,执行一个小数据量的聚合查询(如 1000 行数据的 groupBy)。在 Spark UI 中观察 AQE 合并后的实际 Task 数量。
-
构造一个极度倾斜的数据集(90% 的数据 key 相同),做 Join 并对比 AQE 开启/关闭时最长 Task 和最短 Task 的耗时比。
系列导航
| 序号 | 主题 | 状态 |
|---|---|---|
| 00 | 导读:为什么 Spark 的核心是一张 DAG | 已完成 |
| 01 | RDD:弹性分布式数据集的五大属性 | 已完成 |
| 02 | 宽依赖与窄依赖:Stage 是怎样划分出来的 | 已完成 |
| 03 | DAG Scheduler 与 Task Scheduler:从逻辑计划到物理执行 | 已完成 |
| 04 | Shuffle 机制:数据跨分区交换的代价与优化 | 已完成 |
| 05 | 内存管理与 Tungsten:堆外内存、序列化与代码生成 | 已完成 |
| 06 | 存储体系:Block Manager、广播变量与累加器 | 已完成 |
| 07 | 容错机制:Lineage、Checkpoint 与推测执行 | 已完成 |
| 08 | Spark SQL 与 Catalyst:从 SQL 文本到物理算子树 | 已完成 |
| 09 | DataFrame 与 Dataset:类型安全与性能的折中 | 已完成 |
| 10 | Adaptive Query Execution:运行时改写执行计划 | 本篇 |
| 11 | 数据源 API 与谓词下推:让存储层帮忙过滤 | 下一篇 |
| 12 | Structured Streaming:把流当成无界表 | |
| 13 | 水位线与状态管理:流处理的时间语义 | |
| 14 | Spark on YARN / K8s:资源管理与部署模式 | |
| 15 | 动态资源分配与作业调度:多租户集群的资源博弈 | |
| 16 | 性能诊断:Spark UI、Event Log 与调优思路 | |
| 17 | Spark 与 Flink:两种计算引擎的设计选择 |
参考资料
- Apache Spark 官方文档:Adaptive Query Execution. https://spark.apache.org/docs/latest/sql-performance-tuning.html#adaptive-query-execution
- Databricks Blog: Adaptive Query Execution: Speeding Up Spark SQL at Runtime. https://www.databricks.com/blog/2020-05-29/adaptive-query-execution-speeding-up-spark-sql-at-runtime.html
- Apache Spark 源码:
sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/https://github.com/apache/spark - SPARK-31412: AQE Skew Join. https://issues.apache.org/jira/browse/SPARK-31412
