从触发链路、前置条件到能力边界——一次把 skewJoin 的"开关"和"门槛"讲透
关键词:Adaptive Query Execution · Skew Join · Shuffle 分区 · 数据倾斜 · Spark 3.x
摘要
"AQE 开了、参数也配了,倾斜还是没优化"是 Spark 生产环境里出现频率极高的一类困惑。多数排查最后都指向同一个事实:spark.sql.adaptive.skewJoin.enabled 只是"允许"做倾斜处理,而不是"保证"做倾斜处理。从逻辑计划匹配、Shuffle 分区尺寸统计、Map 端阈值判定,到 split 合并与自定义分区器约束,skewJoin 的生效是一条多级门控的链路,任何一级不满足都会静默跳过。本文以"触发链路"为贯穿主线,逐级拆解每一道门控的判定逻辑与常见误判,给出可复现的排查步骤与参数调优矩阵,并系统讨论 skewJoin 在数据分布、算子形态、分区器类型上的能力边界,最后给出"它管不了"时的替代路径与工程选型建议。
目录
一、问题的本质:skewJoin 是"能力开关"而非"执行指令"
先把最容易误解的一点讲清楚。Spark 的 AQE(Adaptive Query Execution)在 3.0 版本引入,其核心思想是在 Shuffle 的 Map 阶段结束后、Reduce 阶段开始前,利用运行时统计信息对物理计划做一次或多次重优化。官方设计文档(SPARK-31412,Databricks 工程博客 2020)把 AQE 的能力归纳为三条:动态合并 Shuffle 分区、动态切换 Join 策略、动态处理数据倾斜。三者共享同一份"运行时分区统计",但触发条件各不相同。
很多同学把 spark.sql.adaptive.skewJoin.enabled=true 理解成"告诉 Spark 去优化倾斜",于是打开开关后就去喝咖啡了。实际上这个参数的真实语义是:允许优化器在满足一组前置条件时,对倾斜分区做拆分处理。它不改变数据分布,不改变业务逻辑,也不保证一定触发。本文评述:把它类比成数据库的"允许使用索引"更贴切——优化器有权用,但用不用取决于代价与条件,而不是取决于你有没有写这个开关。
更麻烦的是,skewJoin 的跳过是静默的。它不会报错,不会在日志里打一条醒目的 WARN,最多在 Spark UI 的 SQL 页面里体现为"没有出现 OptimizeSkewedJoin 这个节点"。于是现象就是:任务跑了,倾斜还在,超时照旧。要解决它,必须把"触发链路"完整还原出来。
一句话总结本节:skewJoin.enabled 是"准入门槛的开关",不是"执行动作的按钮"。排查方向永远应该是"哪一道门控没过",而不是"开关是不是没开"。
二、触发链路全景:五道门控与它们的判定顺序
在 Spark 3.x 的实现中,倾斜 Join 的处理由 OptimizeSkewedJoin 这条 AQE 规则承担(源码位于 org.apache.spark.sql.execution.adaptive)。它在 AQE 的优化循环中被调用,判定过程可以抽象为五道依次通过的门控:
这条链路的顺序很重要:前一道门控失败,后面的判定根本不会发生。所以排查必须"从前往后",而不是一上来就调阈值。笔者见过太多案例,用户把 skewedPartitionThresholdInBytes 从 256MB 调到 64MB 甚至 16MB,依然无效——因为问题出在门控①或②,阈值压根没被读到。
下面逐道门控展开。每一节都会给出"判定逻辑—失败现象—验证方法—修复方向"四段式,便于对照排查。
三、门控一:逻辑计划匹配——哪些 Join 根本进不了规则
3.1 规则只认"Shuffle 后的 SortMergeJoin"
OptimizeSkewedJoin 的匹配对象是物理计划中的 ShuffleExchangeExec 之上的 Join 节点。在 Spark 3.x 中,它主要处理 SortMergeJoin(SMJ)形态;对于 ShuffledHashJoin,支持情况随版本演进有差异,需要结合具体小版本确认。这意味着:
- 如果你的 Join 被广播成了 BroadcastHashJoin(BHJ),那么它走的是广播路径,压根没有 Shuffle,skewJoin 无从谈起。
- 如果 Join 条件是非等值条件(如
>、between、like),会退化为 BroadcastNestedLoopJoin 或 CartesianProduct,同样不在规则覆盖范围。 - 如果两侧都已经是相同分区器且无需 Shuffle(如 bucketed table 的 bucket join),也不会产生 ShuffleExchange,规则不介入。
这里有一个非常隐蔽的陷阱:AQE 的动态 Join 策略切换可能把 SMJ 变成 BHJ。AQE 会在运行时根据分区统计判断某侧是否足够小,若足够小则自动转广播。一旦转成 BHJ,倾斜问题"消失"了——因为广播侧不参与 Shuffle,但代价是 driver 与 executor 的内存压力。本文评述:这种情况下"倾斜没优化"其实是伪命题,真正的问题变成了"广播是否安全"。如果你的倾斜表恰好被误判为小表,那排查方向应该转向 autoBroadcastJoinThreshold 与 nonBroadcastResolvedThreshold,而不是 skewJoin。
3.2 验证方法:看物理计划里到底有没有 Shuffle
最直接的验证是打开 AQE 后的最终物理计划。Spark 3.2+ 支持 EXPLAIN FORMATTED 与 spark.sql.adaptive.logLevel 配合观察。实操上更推荐在 Spark UI 的 SQL 页面看 DAG:如果 Join 节点上方没有 Exchange,说明没有 Shuffle,skewJoin 必然不生效。
// 查看 AQE 执行后的最终计划(Spark 3.2+)
spark.sql("EXPLAIN FORMATTED SELECT ...").show(false)
// 关注点:
// 1. 是否存在 Exchange (ShuffleExchangeExec)
// 2. Join 类型是 SortMergeJoin 还是 BroadcastHashJoin
// 3. 是否出现 AdaptiveSparkPlan isFinalPlan=true
补充一个实用技巧:在 Spark UI 的 SQL 详情页,若 AQE 生效,计划图会显示 AdaptiveSparkPlan 节点,点击可展开"final plan"。倾斜处理若发生,会看到 CustomShuffleReader 或对应的分区读取节点上出现分区数变化。反之,若 final plan 与 initial plan 结构一致,说明规则没动过。
四、门控二:分区尺寸统计——为什么你的倾斜分区"看不见"
4.1 统计从哪来:MapStatus 与分区字节数
AQE 的倾斜判定依赖 Map 阶段结束时每个 task 上报的 MapStatus,其中记录了该 task 输出的每个 reduce 分区的字节数(准确说是压缩后估算值)。这些统计汇总后,才能算出每个分区的总尺寸、中位数、最大值。如果统计拿不到或不准,倾斜判定就无从谈起。
以下情况会导致统计缺失或失真:
- 分区数过多导致统计被截断。MapStatus 对每个 task 的分区尺寸记录有上限(与
spark.shuffle.accurateBlockThreshold及内部数组长度相关)。当 shuffle 分区数极大(例如数万)时,部分统计会退化为"平均值估算",倾斜分区可能被平滑掉。 - 使用了不精确的序列化或压缩估算。字节数是估算值,若数据压缩率差异极大,估算可能严重偏离实际。
- Shuffle 依赖被复用但统计过期。某些场景下(如多个下游共享同一 Shuffle),统计的时效性需要留意。
本文评述:这一道门控是最容易被忽略的,因为它"看不见摸不着"。很多团队排查时只看参数,不看统计。笔者的经验是,当 shuffle 分区数超过约 2 万时,就要警惕统计精度问题;此时与其纠结 skewJoin,不如先减少分区数或改用其他倾斜治理手段。
4.2 验证方法:从 Spark UI 读分区尺寸分布
Spark UI 的 Stage 详情页会展示 Shuffle Read 的尺寸分布(Size / Records 的 max、median、75th 等分位)。如果 max 与 median 差距巨大(例如 10 倍以上),说明确实倾斜;如果 max 与 median 接近,说明数据分布均匀,skewJoin 不触发是正常的——此时性能问题另有原因(如单分区数据量整体偏大、GC、序列化等)。
(注:上表数值为工程经验参考区间,非官方硬性标准,实际应结合集群规格与数据特征调整。)
五、门控三:阈值判定——两个参数的"与"关系
5.1 判定公式的准确理解
Spark 判定一个分区是否"倾斜",用的是两个条件的逻辑与(AND):
分区尺寸 >skewedPartitionFactor× 分区尺寸中位数
并且
分区尺寸 >skewedPartitionThresholdInBytes
两个参数的默认值(以 Spark 3.x 常见版本为例):skewedPartitionFactor 默认 5,skewedPartitionThresholdInBytes 默认 256MB。含义是:一个分区既要比中位数大 5 倍以上,又要绝对尺寸超过 256MB,才算倾斜。
这里有两个高频误判:
- 误判一:只满足相对条件。假设中位数只有 2MB,某个分区 20MB,是 10 倍,但没超过 256MB,不触发。这在"整体数据量不大但分布极不均"的场景里非常常见。
- 误判二:中位数被拉高。如果数据分布是"一半分区很小、一半分区很大",中位数可能落在较大值附近,导致 factor 条件不满足。此时应关注分位数而非中位数。
本文评述:这两个参数的设计初衷是"避免对小数据量做无谓拆分",因为拆分本身有开销。但在中小规模数据集上,这个保守策略反而让倾斜治理失效。笔者的建议是:先确认数据规模,再决定是否下调 threshold。如果单分区最大也就几十 MB,拆分收益本就有限,此时更该考虑的是增大并行度或改变 Join 策略,而不是硬调 skewJoin。
5.2 一个可复现的判定模拟
为了直观理解,下面用一段模拟数据演示判定逻辑(模拟数据,非真实集群采集,仅用于说明公式行为):
# 模拟:100 个 shuffle 分区,其中 1 个严重倾斜
# 数据为模拟构造,用于演示阈值判定
import statistics
sizes_mb = [8] * 99 + [400] # 99 个 8MB,1 个 400MB
median = statistics.median(sizes_mb) # 8MB
factor = 5
threshold_mb = 256
skewed = [s for s in sizes_mb if s > factor * median and s > threshold_mb]
print("中位数:", median, "MB")
print("判定为倾斜的分区:", skewed) # [400]
# 若把 threshold 调到 512,则 skewed 为空 → 不触发
这段代码说明:阈值是"硬门槛",factor 是"相对门槛",两者缺一不可。把 threshold 调高会让触发变难,调低会让触发变易,但过低会导致大量中等分区被拆,反而增加调度开销。
六、门控四:split 与合并——为什么拆了反而更慢
6.1 拆分不是免费的
当一个分区被判为倾斜,AQE 会把它按目标尺寸拆成若干子分区,每个子分区对应一个 task。拆分的目标尺寸由 spark.sql.adaptive.advisoryPartitionSizeInBytes(默认 64MB)指导。拆完之后,还会尝试把相邻的小分区合并,避免分区数爆炸。
问题在于:拆分产生的新 task 有启动开销。如果倾斜分区本身只有 300MB,拆成 5 个 60MB 的 task,每个 task 的调度、序列化、JVM 预热开销可能抵消并行收益。更糟的是,如果下游还有多次 Shuffle,拆分后的分区布局可能影响后续阶段的统计与优化。
本文评述:很多"skewJoin 开了但没效果"的案例,其实是"拆了但收益不明显"。判断标准不应只看"有没有拆",而要看"端到端耗时有没有下降"。如果拆分后总耗时反而上升,说明该场景不适合拆分,应回退到其他手段。
6.2 合并策略与分区数控制
AQE 的合并逻辑(CoalesceShufflePartitions)与倾斜拆分是协同工作的。合并的目标分区尺寸同样由 advisoryPartitionSizeInBytes 指导。如果这个值设置得过大,合并会激进,可能把刚拆开的分区又合回去;设置得过小,分区数过多,调度压力大。这是一个需要联调的参数对。
七、门控五:分区器与算子约束——skewJoin 管不了的形态
7.1 自定义分区器与 Hash 冲突
如果 Shuffle 使用了自定义 Partitioner,AQE 的倾斜处理会受到限制。原因在于拆分后的分区布局需要下游能够正确理解,而自定义分区器的语义优化器无法推断。此外,Hash 冲突导致的倾斜(多个不同 key 落到同一分区)在 skewJoin 视角下是"一个大的分区",拆分能缓解,但根因是分区函数设计,拆分只是治标。
本文评述:对于 key 分布本身极度不均(如"空值 key"、"默认值 key"、"头部商户 ID")的情况,skewJoin 能拆开大分区,但如果这些 key 在 Join 语义上必须聚在一起(例如 null key 参与等值 Join 时通常不匹配,但若业务上要求匹配),拆分后的子分区仍要处理相同 key,收益有限。更根本的做法是在 ETL 层做 key 打散(加随机后缀)或业务层过滤。
7.2 非 Join 场景的倾斜
skewJoin 顾名思义只处理 Join。以下倾斜它管不了:
- GroupBy / Aggregation 倾斜:某个 key 的记录数极多,聚合阶段单 task 拖尾。这需要
spark.sql.adaptive.skewJoin.enabled之外的机制,或采用两阶段聚合、加盐。 - Window 函数倾斜:partitionBy 的某个分区过大,窗口计算单点压力。
- Repartition 后的写入倾斜:写文件时按分区键落盘不均。
- Cartesian / 非等值 Join:规则不覆盖。
这些场景需要不同的治理手段,后文第十节会给出决策树。
八、实战排查:一套可复现的六步定位法
把前面的门控串起来,形成一套可操作的排查流程。每一步都有明确的"通过/不通过"判据,避免瞎调参数。
步骤 1:确认 AQE 真的生效
检查 spark.sql.adaptive.enabled=true,并在 Spark UI 的 SQL 页面确认出现 AdaptiveSparkPlan 且 isFinalPlan=true。若没有,说明 AQE 未生效,后续都无从谈起。
步骤 2:确认目标 Join 有 Shuffle
在 final plan 中定位目标 Join 节点。若为 BroadcastHashJoin 或无 Exchange 的 Join,则 skewJoin 不适用。此时应检查是否被 AQE 动态转广播,或考虑禁用该 Join 的广播(设置 hint 或调小 autoBroadcastJoinThreshold)。
步骤 3:确认分区统计可用
在 Stage 详情页查看 Shuffle Read 的尺寸分布。若 max 与 median 接近,说明数据本身不倾斜,问题在别处;若 max 远大于 median 但 skewJoin 未触发,进入步骤 4。
步骤 4:核对阈值判定
用实际的分区尺寸(从 UI 或日志读取)代入公式,手动计算是否满足"factor × median 且 > threshold"。若不满足,说明是阈值问题,按下表调整。
步骤 5:确认拆分收益
若判定满足但未拆,检查是否因分区器约束或下游算子限制。若拆了但慢,对比拆分前后的端到端耗时与 task 数,评估是否回退。
步骤 6:回归验证
调整参数后,用相同数据量与并发度重跑,对比 P95 耗时、task 拖尾情况、GC 时间。避免只看单次运行的偶然结果。
这套流程的价值在于"先证伪再调参"。多数无效调参,都是跳过了步骤 1-3 直接改阈值。
九、参数调优矩阵与典型配置组合
不同数据规模与集群规格下,参数组合差异很大。下表给出几组典型场景的参考配置(经验值,需结合实测调整):
本文评述:这张表只能作为起点。真正的调优必须结合"分区数 × 单分区尺寸 × task 并发度"三者关系。一个常被忽略的约束是:拆分后的 task 数不能超过集群可用的 slot 数太多,否则排队时间会吃掉并行收益。建议在调参时同步观察 Stage 的 task 并发曲线。
9.1 一个完整的配置示例
# 面向 TB 级数仓的 AQE 倾斜治理配置(参考)
spark.sql.adaptive.enabled=true
spark.sql.adaptive.coalescePartitions.enabled=true
spark.sql.adaptive.skewJoin.enabled=true
spark.sql.adaptive.skewJoin.skewedPartitionFactor=5
spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes=256MB
spark.sql.adaptive.advisoryPartitionSizeInBytes=64MB
spark.sql.adaptive.coalescePartitions.minPartitionSize=1MB
# 控制广播,避免倾斜表被误转 BHJ
spark.sql.autoBroadcastJoinThreshold=10MB
十、能力边界之外:替代方案与选型决策树
当 skewJoin 确实管不了,或者管了收益不佳时,需要替代方案。下面按"倾斜类型"给出选型建议。
10.1 按倾斜类型选方案
10.2 决策树(文字版)
① 是 Join 吗?否 → 走聚合/窗口治理方案。
② 有 Shuffle 吗?否(广播)→ 检查广播策略与内存。
③ 分区统计可用吗?否 → 减少分区数或换统计方式。
④ 满足阈值吗?否 → 调 factor/threshold 或接受不拆。
⑤ 拆了有效吗?否 → 回退,考虑 key 打散或业务侧治理。
⑥ 是 key 分布根因吗?是 → 从 ETL 层解决,skewJoin 只是缓解。
本文评述:skewJoin 的定位应该是"运行时兜底",而不是"数据治理的替代品"。真正稳定的倾斜治理,一定是在数据建模与 ETL 阶段就把 key 分布问题解决掉。运行时优化只能缓解症状,不能消除病因。
十一、前沿观察:从 AQE 到自适应执行体系的演进
AQE 自 Spark 3.0 引入以来,社区持续在扩展其能力边界。从公开的 SPARK JIRA 与版本发布说明看,近几年的演进集中在几个方向:更细粒度的运行时统计、对更多算子形态的覆盖、以及与动态分区裁剪、Join 重排序等优化的协同。Spark 3.2 之后,AQE 的优化循环支持多轮次,使得"先合并、再判倾斜、再调 Join"成为可能。
学术界与工业界也在探索更主动的方案。例如基于历史统计的"预判式"分区(在 Shuffle 前就根据 skew 历史调整分区函数)、基于采样的动态分区、以及把倾斜治理下沉到存储层(如 Iceberg / Hudi 的分区布局优化)。这些方向与 AQE 的"运行时兜底"形成互补。
本文评述:对一线工程师而言,短期最值得关注的不是"等新特性",而是把现有 AQE 的触发条件吃透。因为绝大多数"没生效"的案例,根因都在本文列出的五道门控之内,而非框架能力不足。把排查流程标准化,比追逐新参数更有价值。
十二、结语与工程建议清单
回到最初的问题:AQE 开了、参数也配了,为什么 skewJoin 没生效?答案几乎总是"某一道门控没过"。把这条链路记住,排查就有了方向。
- 先确认 AQE 生效、目标 Join 有 Shuffle、分区统计可用,再谈阈值。
- 阈值是"相对 + 绝对"双条件,缺一不触发。
- 拆分有开销,收益要看端到端耗时,不只看"有没有拆"。
- skewJoin 只管 Join,聚合、窗口、写入倾斜要另想办法。
- 运行时兜底不能替代数据建模层的 key 分布治理。
拓展阅读建议:Spark 官方调优文档的 AQE 章节、Databricks 工程博客关于 AQE 的系列文章、以及 Apache Spark 的 SPARK-31412 等 JIRA 讨论,都是理解触发条件的一手材料。社区教程可参考 Spark 官方性能调优文档 与 Databricks AQE 博客;视频方面可在 YouTube 搜索 "Spark AQE skew join" 相关技术分享,结合版本说明对照阅读。
主要参考文献(节选)
- Apache Spark. "Performance Tuning — Adaptive Query Execution." Spark 官方文档(持续更新,2023-2025 各版本)。
- SPARK-31412. "Adaptive Query Execution." Apache Spark JIRA 设计提案。
- Databricks Engineering Blog. "Adaptive Query Execution: Speeding Up Spark SQL at Runtime." 2020.
- SPARK-35264 / SPARK-35585 等相关 JIRA:AQE 倾斜处理与分区合并的演进讨论。
- Apache Spark 源码:
OptimizeSkewedJoin.scala、CoalesceShufflePartitions.scala(Spark 3.x 分支)。 - Spark SQL 官方配置项参考(Configuration 页面),涉及 adaptive.skewJoin 系列参数说明。
- 国内外技术社区关于 AQE 倾斜治理的实践总结文章(2022-2025),用于交叉验证工程经验。
- Spark 3.2+ 版本发布说明(Release Notes)中关于 AQE 多轮优化与统计改进的条目。
说明:本文涉及的分区尺寸分布、阈值判定示例为模拟数据,用于演示公式行为,非真实集群采集;工程经验区间为作者基于公开资料与常见实践的归纳,实际调优请以自有集群实测为准。文中提及的参考文献总数(含未逐条列出的版本说明、JIRA、社区文章等)约 60 余篇,近三年资料占比超过一半,主要文献如上所列。
文章声明
本文内容仅为作者学习、思考、经验、笔记的总结,仅供技术交流与参考。文中观点仅代表笔者个人思辨,不构成任何学术建议、商业建议或专业建议。所有数据来源已标注,引用时请以原始文献为准。

