Spark性能调优实战:从资源、并行度到数据倾斜与Shuffle的深度解析 性能调优这件事在Spark里从来不是调几个参数就完事的活。我见过太多团队把任务跑通了就撒手等到数据量从GB级涨到TB级作业开始频繁OOM、GC时间比计算时间还长、同一个逻辑跑出三种不同的耗时才回头翻文档找参数。这篇内容就是把这些年在大规模批处理和迭代计算场景里踩过的坑、验证过的调优路径整理出来从资源层、并行度、内存模型、数据倾斜、序列化与Shuffle几个维度拆开讲每个结论都尽量说清楚为什么这么调而不是照着配就行。适合已经能写出可运行Spark作业、但被性能问题卡住的中高级开发者也适合刚接手存量作业、需要快速定位瓶颈的维护者。文中涉及的所有配置和代码都基于常见生产实践给出具体数值需要结合你的集群规格和数据特征做二次验证。1. 先搞清楚瓶颈在哪别急着改参数调优最忌讳的动作是看到作业慢先把executor数量翻倍再把内存加到最大最后发现还是慢人已经懵了。Spark的性能问题大致分四类——资源不足、并行度不合理、数据分布不均、序列化与Shuffle开销过大。这四类的表现和修复手段完全不同搞错方向就是白费力气。所以第一步永远是定位而不是动手。1.1 用Spark UI把时间花在哪看清楚Spark UI是调优的第一现场但很多人只看Stages页面的耗时条这远远不够。我通常按这个顺序看先看Jobs页面确认哪个Job最耗时再进对应Stage看Shuffle Read/Write的量和Spill磁盘溢写情况最后进Stage详情看每个Task的耗时分布。Task耗时如果呈现大部分很快、少数极慢的长尾分布基本可以锁定数据倾斜如果所有Task都慢且均匀那更可能是并行度不够或者单Task处理的数据量过大。有一个容易被忽略的指标是GC Time。在Stage详情页的Task Metrics里如果GC时间占Task执行时间的比例超过10%说明内存压力已经很明显了这时候加内存或者调整内存比例往往比调并行度更有效。我遇到过一个作业Task耗时均匀但整体极慢最后发现是executor内存给得太大导致GC停顿长把内存从32G降到16G、同时提高并行度总耗时反而降了四成。这个反直觉的结论说明内存不是越大越好要看GC的实际表现。提示Spark UI的Event Timeline能直观看到executor的空闲与忙碌区间。如果大量时间executor处于空闲等待说明任务调度或数据本地性有问题而不是计算能力不足。1.2 从日志里抓Spill和OOM的早期信号除了UIexecutor日志里的关键字是更早的预警。spilling、Spill、OutOfMemoryError、GC overhead limit exceeded这几个词一旦频繁出现说明内存模型已经撑不住了。Spill的本质是executor内存放不下当前操作的数据被迫写到磁盘磁盘IO比内存慢一到两个数量级一旦大量Spill性能断崖式下跌。定位Spill要看是哪个操作触发的。groupByKey、reduceByKey、join、sortByKey这些宽依赖操作都会产生ShuffleShuffle的读端需要把数据拉过来做聚合或排序内存不够就Spill。如果是聚合类操作Spill优先考虑提高并行度让每个Task处理更少数据如果是join类Spill优先考虑数据倾斜或者join策略选择不当比如该用broadcast的没用。这个判断逻辑后面会展开。1.3 一个可复用的定位清单我把日常排查整理成一张表按现象对号入座能省掉大量试错时间现象最可能的原因优先验证手段Task耗时严重长尾数据倾斜看Task的Shuffle Read Size分布所有Task均匀慢并行度不足或单Task数据量大看Stage的Task总数与输入数据量比值GC Time占比高内存压力大或对象过多看Task Metrics的GC时间占比大量Spill聚合/排序内存不足看Spill (memory)与Spill (disk)指标executor大量空闲调度延迟或数据本地性差看Event Timeline与locality levelShuffle Write巨大分区数过多或序列化低效看Shuffle Write Size与分区数这张表不是万能钥匙但它能帮你在一开始就把搜索范围缩小到一两个方向避免盲目调参。2. 资源与并行度调优的地基资源和并行度是Spark调优里最基础也最容易被误解的部分。很多人以为资源给够就行实际上资源怎么分配、并行度怎么设直接决定了后面所有优化的上限。这一节把executor的配置逻辑和并行度的计算方式讲透。2.1 executor数量、核数与内存的三角关系Spark on YARN的经典配置模型里有三个量需要一起考虑executor数量、每个executor的核数cores、每个executor的内存。它们不是独立的受集群总资源和单节点规格约束。一个常见的错误是给每个executor配很多核比如16核看起来一个executor干很多活实际上每个核在同一时刻只能跑一个Task核数多意味着单个executor内并发Task多但共享同一份内存内存竞争会更激烈GC压力更大。我的经验值是每个executor配4到5个核比较均衡。核太少executor数量多调度和通信开销上升核太多单executor内存压力大且一个executor挂掉影响面更大。内存方面要区分executor总内存和可用于计算的内存后者受spark.memory.fraction和spark.memory.storageFraction控制默认计算与存储共享的部分占executor内存的60%这个比例在内存紧张时可以适当上调但要留足给JVM本身和系统页缓存的空间。举个具体例子集群单节点128G内存、32核假设要跑一个重Shuffle的聚合作业。我会这样配每个executor 5核、20G内存那么单节点最多放6个executor30核、120G留一点给系统。executor总数按数据量估算通常让总核数达到集群可用核数的70%到80%即可不必打满留出余量应对峰值。2.2 并行度到底怎么算才不拍脑袋并行度的核心是分区数它决定了有多少个Task并行处理。分区数太少资源用不满分区数太多Task调度开销和Shuffle开销上升。一个被广泛引用的经验公式是分区数 ≈ 总核数 × 2 到 3。但这个公式只适用于纯CPU密集的简单转换对于重Shuffle的作业分区数还要结合数据量来定。更实用的算法是单个分区处理的数据量控制在128MB到256MB之间。假设输入数据是100GB那么分区数大约在400到800之间。这个区间能保证每个Task处理的数据量适中既不会因为数据太少导致调度开销占比过高也不会因为数据太大导致单Task内存吃紧。对于Shuffle阶段spark.sql.shuffle.partitionsSpark SQL场景或spark.default.parallelismRDD场景要按Shuffle后的数据量重新估算而不是沿用输入分区数。我踩过的一个坑一个作业输入分区设了2000但Shuffle后数据量只有输入的三分之一结果Shuffle阶段2000个分区里大量是空分区或极小分区Task调度开销反而成了瓶颈。后来把Shuffle分区数降到600耗时降了将近一半。所以输入分区数和Shuffle分区数要分开算这是很多人忽略的点。2.3 动态资源分配该不该开spark.dynamicAllocation.enabled这个开关我的建议是长时批处理作业开短时交互式作业谨慎开。动态分配的好处是按需伸缩executor避免资源空转坏处是伸缩有延迟短作业可能还没扩起来就结束了反而增加调度抖动。开启时要注意配合spark.dynamicAllocation.minExecutors和maxExecutors设好上下界并且确保集群有足够的资源池支撑弹性伸缩否则扩不出来等于没开。还有一个细节动态分配依赖Shuffle服务的external shuffle service如果集群没部署这个服务开启动态分配后executor被回收会导致Shuffle数据丢失作业直接失败。这个前置条件一定要确认我见过不止一个团队在这上面栽跟头。3. 内存模型把每一块内存用在刀刃上Spark的内存模型是调优里最玄学的部分因为它涉及JVM堆内堆外、执行与存储的共享与抢占。但把机制理清楚之后很多玄学问题都能解释。这一节拆解executor内存的组成以及不同场景下怎么调比例。3.1 executor内存的几块拼图一个executor的JVM堆内存大致分成这几块Reserved Memory固定保留约300MB、User Memory用户代码和数据结构用默认占剩余部分的40%、Spark Memory执行和存储共享默认占60%。Spark Memory内部又分Execution MemoryShuffle、join、sort、aggregation用和Storage Memory缓存RDD/DataFrame用两者默认各占一半但可以互相借用——Execution需要时能抢占Storage的空闲部分Storage需要时也能借用Execution的空闲部分但Execution的抢占优先级更高。理解这个可借用机制很关键。很多作业缓存了大量DataFrame看起来Storage占满了但一旦有重Shuffle操作Execution会把Storage借走导致缓存被驱逐下次访问又要重算。如果你的作业既缓存数据又做重Shuffle就要考虑是不是缓存策略有问题或者干脆不缓存、靠重算换内存。3.2 什么情况下调memoryFractionspark.memory.fraction默认0.6控制Spark Memory占堆内存 - Reserved的比例。调高它Execution和Storage可用内存变多但User Memory变少。什么时候该调高当作业有大量Shuffle、聚合、排序且GC时间占比高、Spill频繁时适当调高到0.7甚至0.75能缓解内存压力。什么时候该调低当用户代码里有大量自定义对象、UDF里创建大对象时User Memory不够会直接OOM这时候反而要调低memoryFraction给User Memory让路。这里有个反直觉的经验调高memoryFraction不一定减少Spill。因为Execution Memory虽然变多但如果数据倾斜导致单个Task的数据量远超平均值再多的内存也不够。所以调memoryFraction之前先确认不是倾斜问题否则就是治标不治本。3.3 堆外内存与序列化格式的联动spark.memory.offHeap.enabled开启堆外内存后Execution和Storage可以使用JVM堆外的内存减少GC压力。这个特性配合spark.sql.unsafe.enabledTungsten引擎效果更明显因为Tungsten用二进制格式管理数据堆外内存能避免对象头开销和GC扫描。但堆外内存的坑在于它不受JVM堆大小限制配置不当会触发操作系统级别的OOM Killer进程直接被系统杀掉日志里看不到Java异常只有系统日志有记录。所以开堆外内存一定要设好spark.memory.offHeap.size并且监控节点的物理内存使用。序列化格式方面Spark SQL默认用Tungsten的二进制格式RDD场景默认用Java序列化慢且占空间大强烈建议切到Kryospark.serializerorg.apache.spark.serializer.KryoSerializer。Kryo比Java序列化快数倍、体积小很多对Shuffle和缓存都有直接收益。但Kryo需要注册自定义类否则性能打折扣这一点在自定义对象多的作业里要特别注意。4. 数据倾斜最难缠也最值得啃的骨头数据倾斜是Spark性能问题的头号杀手也是调优里最考验经验的部分。它的本质是某个或某几个key的数据量远超其他key导致处理这些key的Task成为长尾拖慢整个Stage。倾斜的识别不难难的是选对解法——不同成因的倾斜要用不同手段用错了不仅无效还可能引入新问题。4.1 倾斜的三种典型成因第一种是key分布天然不均比如按用户ID聚合少数大V用户的数据量是普通用户的上万倍。第二种是join时小表key重复比如用一张维度表join事实表维度表里某个key对应大量事实数据。第三种是分区键选择不当比如用时间戳的高位做分区导致某些分区数据量爆炸。这三种成因对应的解法不同第一种适合加盐打散第二种适合broadcast join第三种适合换分区键或加随机前缀。识别倾斜要看Task的Shuffle Read Size分布而不是只看耗时。耗时受机器负载影响有波动但数据量分布是客观的。如果最大Task的Shuffle Read Size是最小Task的几十倍甚至上百倍倾斜就坐实了。4.2 加盐打散适用边界与代价加盐salting的思路是给倾斜的key加上随机前缀把它拆成多个子key分散到不同Task聚合完再去掉前缀做二次聚合。这个方法对groupByKey、reduceByKey这类聚合操作有效但代价是多了一次Shuffle二次聚合而且加盐的随机范围要选好——范围太小打散不够范围太大导致分区数暴涨、小分区过多。具体操作先采样找出倾斜的key比如取Top 10数据量最大的key对这些key加0到N的随机前缀N一般取分区数的十分之一到五分之一非倾斜key不加前缀。第一次聚合按前缀原key分组第二次聚合去掉前缀按原key分组。这个方案我在多个作业里用过对天然不均的聚合场景效果稳定但要注意二次聚合的分区数要重新估算不能沿用第一次的。注意加盐会改变数据分布如果下游逻辑依赖原始key的顺序或分区要评估影响。另外加盐只对聚合类有效对join类倾斜要用下面的方案。4.3 broadcast join与倾斜join的分而治之join倾斜的经典解法是broadcast join如果参与join的一张表足够小默认阈值spark.sql.autoBroadcastJoinThreshold是10MB可调大到几十MB把它广播到每个executor避免Shuffle。这样大表的每个分区独立与小表做map-side join没有Shuffle就没有倾斜。判断标准是小表能被单个executor内存容纳且广播的网络传输开销可接受。如果两张表都很大、无法broadcast就要用分而治之把倾斜的key单独抽出来处理非倾斜的key正常join最后union结果。倾斜key的处理可以进一步用加盐或者扩大并行度。这个方案实现起来代码量不小但对无法broadcast的大表join倾斜是有效的兜底手段。我一般会先尝试调大broadcast阈值看能不能覆盖覆盖不了再上分而治之因为后者维护成本高。4.4 一个真实的倾斜排查链路说个我印象深的案例。一个日活统计作业平时跑20分钟某天突然变成2小时。看UI发现某个Stage的Task耗时从均匀变成极端长尾最大Task的Shuffle Read是平均值的80倍。先怀疑是当天有异常大客户的数据涌入采样确认确实有几个key的数据量暴增。第一反应是加盐但加盐后耗时只降到1小时还是慢。进一步看发现这几个大key在join阶段又产生了二次倾斜于是改成聚合阶段加盐join阶段把大key单独抽出来broadcast小表做map-side join。两步组合后耗时回到25分钟。这个案例的教训是倾斜可能在不同阶段反复出现要逐阶段看不能只解决第一个倾斜点就收工。而且解法要组合使用单一手段往往不够。5. Shuffle与序列化看不见的性能黑洞Shuffle是Spark里最昂贵的操作它涉及磁盘IO、网络传输、序列化反序列化任何一个环节低效都会放大成整体性能问题。这一节讲Shuffle的调优手段和序列化的选择逻辑。5.1 Shuffle Manager的选择与Shuffle文件管理Spark的Shuffle Manager主要有两种SortShuffleManager默认和HashShuffleManager已基本淘汰。SortShuffleManager会把每个Task的输出按分区排序后写成一个文件加一个索引文件减少文件数量适合大规模Shuffle。它内部还有bypass模式当分区数小于spark.shuffle.sort.bypassMergeThreshold默认200时跳过排序直接写多个小文件再合并省掉排序开销。这个阈值在分区数少的场景下可以适当调大但分区数多时不要开bypass否则文件数爆炸。Shuffle文件的管理要注意spark.shuffle.file.buffer写缓冲区默认32KB可调到64KB或128KB减少磁盘IO次数和spark.reducer.maxSizeInFlight读端每次拉取的数据量默认48MB网络好时可调大减少拉取次数。这两个参数是Shuffle IO调优的常用抓手但调整幅度要结合磁盘和网络的实际能力盲目调大可能适得其反。5.2 序列化Kryo不是万能但默认该用前面提过Kryo比Java序列化快且省空间这里展开说适用边界。Kryo对基本类型和常见集合支持很好但对某些复杂对象比如含循环引用、需要特殊构造器的类需要自定义Serializer否则可能序列化失败或结果错误。所以切Kryo时要先做全量回归测试确认所有自定义类都能正确序列化。另一个容易被忽略的点是数据格式的选择。如果数据源是文本格式CSV、JSON读进来再序列化开销很大。改用列式格式Parquet、ORC能大幅减少IO和反序列化开销因为列式格式支持按列读取、谓词下推、压缩。我经手的一个作业把输入从CSV换成Parquet后读取阶段耗时降了70%这个收益比调任何Shuffle参数都直接。5.3 压缩编码的取舍Shuffle数据压缩默认用spark.io.compression.codeclz4lz4压缩解压速度快、压缩比适中是Shuffle场景的合理默认。如果网络带宽是瓶颈可以换zstd压缩比更高但CPU开销略大如果CPU是瓶颈保持lz4甚至用snappy。这个选择没有绝对优劣要看集群是IO bound还是CPU bound。我一般先用lz4跑基线如果发现网络传输是瓶颈再换zstd对比。6. 那些文档里不会写的实操心得调优的很多经验是文档里找不到的都是实际跑出来的。这一节分享几条我认为最有价值的。第一条改一个参数跑一次记录一次。调优是实验科学同时改五个参数然后看总耗时你根本不知道哪个起了作用、哪个起了反作用。我习惯用配置版本管理每次只动一到两个参数记录耗时和关键指标形成自己的调优日志。时间长了你会发现某些参数在你的数据特征下有稳定的最优区间。第二条小数据量验证逻辑大数据量验证性能。很多性能问题在小数据上根本暴露不出来因为数据量小的时候Spill不发生、倾斜不明显。所以性能调优一定要用接近生产规模的数据做验证否则调出来的参数上线就翻车。第三条别迷信最佳实践的固定数值。网上流传的executor内存设8G分区数设200这类固定值脱离了具体集群和数据就是误导。所有数值都要结合你的核数、内存、数据量、Shuffle比例重新算本文给的数值也只是起点不是终点。第四条监控比调优更重要。上线前把关键指标GC时间、Spill量、Shuffle读写量、Task耗时分布接入监控设置告警阈值。性能退化往往是渐进的等用户反馈慢的时候问题可能已经积累很久了。我现在的习惯是每个核心作业都配一套指标看板数据量增长或代码变更后第一时间能看出异常。第五条优先优化算法和数据结构再调参数。参数调优的天花板很低一个O(n²)的UDF或者一次不必要的全表扫描调多少参数都救不回来。我见过一个作业调了两周参数只降了20%最后发现是UDF里每次调用都重建了一个大Map改成广播变量后耗时直接降了80%。所以调参之前先审视代码逻辑本身。最后分享一个我常用的快速验证套路拿一个慢作业先不动任何参数用spark-submit加--conf spark.eventLog.enabledtrue把事件日志存下来用Spark History Server回放把每个Stage的Task耗时、Shuffle量、Spill量导出来做成对比表。然后针对最突出的那一项做单点优化跑完再导一次对比。这个流程看起来笨但比凭感觉调参靠谱得多而且每次优化的收益都能量化方便向团队说明调优的价值。