当面对一个运行缓慢的Spark作业时,大多数工程师的第一反应是查看explain()输出的物理执行计划(Physical Plan),这就像医生只看X光片而忽略血液检测报告一样片面。Spark 3.x引入了更强大的执行计划分析工具——explain('cost')和explain('formatted'),它们能揭示优化器决策背后的关键因素,本文将带您解锁这些高阶用法。
物理执行计划展示的是Spark最终选择的执行路径,但它无法回答两个核心问题:
典型误区案例:某电商平台发现以下两个SQL查询物理执行计划几乎相同,但性能差异达5倍:
sql复制-- 查询A
SELECT o.order_id, u.user_name
FROM orders o JOIN users u ON o.user_id = u.user_id
WHERE o.create_date > '2023-01-01'
-- 查询B
SELECT o.order_id, u.user_name
FROM users u JOIN orders o ON u.user_id = o.user_id
WHERE o.create_date > '2023-01-01'
通过explain('cost')分析发现:优化器错误估计了2023年订单表的大小(实际500GB,估计仅50GB),导致错误选择了非广播连接。这种代价模型偏差在纯物理计划中完全不可见。
Spark的基于代价的优化器(Cost-Based Optimizer, CBO)依赖统计信息进行决策,explain('cost')能展示三个关键维度:
| 统计指标 | 说明 | 优化影响案例 |
|---|---|---|
| sizeInBytes | 表/列的预估大小 | 决定是否启用广播连接 |
| rowCount | 行数估计 | 影响join顺序选择 |
| attributeStats | 列值分布(NDV, nullCount等) | 决定过滤条件应用顺序 |
实操示例:对比两个逻辑等价的查询计划
python复制# 启用CBO和统计信息收集
spark.conf.set("spark.sql.cbo.enabled", "true")
spark.conf.set("spark.sql.statistics.histogram.enabled", "true")
# 对关键表收集统计信息
spark.sql("ANALYZE TABLE orders COMPUTE STATISTICS FOR COLUMNS user_id, create_date")
# 查看带代价的执行计划
spark.sql("""
SELECT o.order_id, u.user_name
FROM orders o JOIN users u ON o.user_id = u.user_id
WHERE o.create_date > '2023-01-01'
""").explain('cost')
输出中将包含如下关键信息:
code复制== Optimized Logical Plan ==
Join Inner, cost=12345, size=987654, rows=67890
:- Filter (create_date#32 > 2023-01-01), cost=2345, size=543210, rows=45678
: +- Relation[order_id#31,user_id#33,create_date#32] parquet
+- Relation[user_id#10,user_name#11] parquet
CBO计算代价的公式通常考虑:
注意:当发现
explain('cost')中的估算值与实际运行情况差异较大时,应立即检查统计信息是否过期,通过ANALYZE TABLE重新收集。
explain('formatted')以结构化方式展示物理计划,特别适合诊断复杂查询问题。其输出分为四个部分:
code复制== Physical Plan ==
*(5) Project [order_id#31, user_name#11]
+- *(5) SortMergeJoin [user_id#33], [user_id#10], Inner
:- *(2) Sort [user_id#33 ASC NULLS FIRST], false, 0
: +- Exchange hashpartitioning(user_id#33, 200), true, [id=#20]
: +- *(1) Filter (create_date#32 > 2023-01-01)
: +- *(1) ColumnarToRow
: +- FileScan parquet [order_id#31,user_id#33,create_date#32]
+- *(4) Sort [user_id#10 ASC NULLS FIRST], false, 0
+- Exchange hashpartitioning(user_id#10, 200), true, [id=#26]
+- *(3) ColumnarToRow
+- FileScan parquet [user_id#10,user_name#11]
通过以下标记快速定位问题:
hashpartitioning(user_id#33, 200))在formatted输出中搜索skew关键词:
code复制(5) SortMergeJoin [user_id#33], [user_id#10], Inner
:- SkewedJoin: true
: +- SkewedPartition: [user_id#33 = 100001], size=1.2GB
: +- SkewedPartition: [user_id#33 = 100002], size=1.5GB
问题场景:某用户画像作业运行缓慢,formatted计划显示:
code复制Exchange hashpartitioning(user_id#33, 200), [id=#20]
+- FileScan parquet [user_id#33]
Size: 500GB, RowCount: 1B, Partitions: 1000
诊断发现:
优化方案:
python复制# 调整分区数并处理倾斜
spark.conf.set("spark.sql.shuffle.partitions", "1000")
df = df.withColumn("user_id",
when(col("user_id").isin(skewed_ids), lit("SKEW_GROUP"))
.otherwise(col("user_id")))
结合两种explain模式,建立四步优化流程:
基线分析
explain('formatted')定位明显瓶颈代价验证
explain('cost')检查统计信息准确性干预手段
ANALYZE TABLE/*+ BROADCAST */等hint效果验证
高级技巧:创建执行计划差异报告
python复制def compare_plans(plan1, plan2):
from difflib import unified_diff
return '\n'.join(unified_diff(
plan1.splitlines(),
plan2.splitlines(),
fromfile='original',
tofile='optimized'
))
original_plan = spark.sql("SELECT ...").explain('formatted')
optimized_plan = spark.sql("SELECT /*+ BROADCAST */ ...").explain('formatted')
print(compare_plans(original_plan, optimized_plan))
执行计划分析应直接影响集群资源配置,这里给出对应关系表:
| 执行计划观察点 | 资源配置调整建议 | 参数示例 |
|---|---|---|
| 大量Exchange操作 | 增加shuffle分区数 | spark.sql.shuffle.partitions=2000 |
| SortMergeJoin占比高 | 提高执行内存 | spark.executor.memoryOverhead=2g |
| BroadcastJoin失败回退 | 增大广播阈值 | spark.sql.autoBroadcastJoinThreshold=100MB |
| 单个Task处理数据量过大 | 减小输入分区大小 | spark.sql.files.maxPartitionBytes=128MB |
内存配置黄金法则:
formatted计划显示多个HashAggregate时,确保:bash复制spark.executor.memory ≥ (sort_buffer_size * concurrent_tasks) + overhead
java.lang.OutOfMemoryError错误时,优先检查执行计划中的排序和聚合节点通过将执行计划分析与资源配置结合,我们在实际项目中成功将ETL作业从4小时缩短到25分钟,关键是通过explain('cost')发现优化器低估了维度表关联后的数据膨胀效应,调整统计信息后优化器选择了更优的join策略。