Spark性能优化实战:代码规范、Shuffle治理与数据倾斜调优
这些年在数据开发一线经手过的Spark任务不少从最初几百行的ETL脚本到后面跑批链路里动辄上千行的复杂作业我最大的体会是同一个逻辑不同的写法性能差距可以达到数倍甚至一个数量级。很多人遇到Spark任务慢第一反应是加资源、调并行度但真正根子上的问题往往藏在代码本身的写法里。Spark代码规范这件事看起来是风格问题实际上直接决定了你的应用能不能把集群资源用在刀刃上。这篇内容不是讲语法规范也不是列一堆应该这样写的教条而是从我实际踩坑和调优的经历出发把影响Spark性能的代码习惯、算子选择、shuffle治理、参数配置这些硬核内容拆开讲清楚。适合正在用Spark做离线数仓、实时链路或数据平台开发的工程师尤其是那些被任务慢、数据倾斜、OOM反复折磨过的同学。1. 高性能Spark应用的总体设计思路1.1 代码规范为什么直接影响性能很多人有个误解觉得代码规范就是命名、缩进、注释这些表面功夫。但在Spark里代码规范的核心是你有没有在正确的地方做正确的事。Spark应用跑得慢绝大多数时候不是集群不够大而是你的代码触发了本不该有的shuffle、做了本可以避免的重复计算、用了开销极大的序列化方式或者资源参数和作业特征完全不匹配。举一个最简单的例子统计一份日志里每个用户的访问次数。新手写法可能是先读全量数据接着用groupByKey再map求和。懂行的人会直接写成reduceByKey。这两者在语法上都能跑通结果也一样但性能上完全是两个世界。原因在于groupByKey会把每个key对应的所有value原封不动地传到reduce端再聚合而reduceByKey会在map端先做一轮本地预聚合传输的数据量可能少几十倍。这种问题你在本地小数据量测试时根本感觉不到一旦上了生产环境几亿条数据差别直接拉满。所以我理解的Spark代码规范实际上是围绕如何减少数据移动、如何减少计算浪费、如何让资源利用最大化这三个目标形成的一系列编码约定。它不是约束你写不写得好看而是约束你写不写得高效。1.2 性能优化的三个核心层次把Spark应用的性能优化拆开来看其实是三个层次的问题任何一个层次出了问题都会让应用整体变慢。第一层是应用架构层你的任务划分是否合理数据读取方式是否高效是否有不必要的落盘和重复读取。比如同样一份数据是每天全量读一遍还是增量合并是直接用列式存储还是文本格式这层的问题决定了你的IO开销下限。第二层是算子逻辑层你用什么样的算子组合完成业务计算是否避免了笛卡尔积、是否存在数据倾斜风险、是否有过多的shuffle节点。这一层是代码规范的主体也是我后面要花大篇幅讲的内容。第三层是资源参数层executor数量、内存配比、并行度设置、动态分配策略等。这层配置必须跟前两层匹配否则即使代码写得再好资源给得不对任务一样跑不动。我看到的很多调优文章一上来就讲参数但我想强调的是参数只是放大器代码本身才是源头。你代码里有一个注定倾斜的groupByKey再怎么调spark.sql.shuffle.partitions也只是缓解不可能根治。所以这篇内容的顺序也是按这个逻辑来的先讲代码怎么写再讲参数怎么配。2. 核心写法规避最容易拖垮性能的几种代码习惯2.1 算子选择不当无效shuffle是怎么产生的Spark里的算子供你选择时同一个目的往往有多个实现路径但代价完全不同。最典型的就是聚合类算子。groupByKey vs reduceByKey vs aggregateByKey这三者做同样的事情性能差距可以非常悬殊。groupByKey是纯shuffle所有数据都通过网络传输到reduce端再做聚合。reduceByKey会在map端做combiner先本地合并再传输。aggregateByKey则允许你同时指定seqOp和combOp更细粒度地控制map端和reduce端的聚合逻辑。我实际处理过一个案例某离线报表任务使用groupByKey对用户行为数据做聚合数据量在2亿条左右整个stage跑了40分钟executor的网络IO居高不下。改成reduceByKey后同样的逻辑只跑了7分钟。没有加任何资源纯粹是shuffle数据量大幅度下降了。除了聚合join算子也有讲究。Spark SQL中的join会被优化器自动处理但如果你写的是RDD代码或DataFrame APIjoin的写法直接影响执行计划。broadcast join、sort merge join、shuffle hash join的开销完全不同。小表join大表明明可以广播你如果没写hint或没配阈值Spark就会傻乎乎地走sort merge join多出一次全量shuffle。还有个容易被忽略的场景是使用RDD的mapPartitions替代map。如果你在每条record级别都需要创建一次连接池或打开一次外部资源比如查Redis、访问数据库用map就意味着每个元素都要建连性能直接没法看。mapPartitions让你在分区级别只建一次连接复用整个分区的数据处理这个差异在天量数据下是质变。规范建议能用SQL表达的逻辑优先用Spark SQL让优化器帮你做谓词下推、列剪枝、分区剪枝。必须用RDD API时聚合优先选reduceByKey或aggregateByKey避免groupByKey需要访问外部存储时用mapPartitions批量处理小表join大表时主动评估使用broadcast。2.2 序列化方式与数据结构选择被低估的CPU开销序列化在Spark里是把烫手山芋藏着的话题因为它在Spark UI里不会单独列一行但它的开销渗透在每一次shuffle写数据、每一次RDD跨节点传输、每一次落盘溢写里。默认的Java序列化器慢、占空间、容易踩坑尤其是当你序列化的是复杂嵌套对象时CPU开销直接爆炸。我建议所有生产环境Spark应用统一用Kryo序列化器并在SparkConf里提前注册要用到的类。注册不仅是为了避免class not registered的报错更关键的是注册后Kryo不需要在每次写对象时附带完整的类名信息序列化后的字节数可以再压缩不少。举个例子我用默认Java序列化和Kryo分别跑同一个join任务序列化后的shuffle数据量从2.1GB降到了1.2GB任务总时长缩短了约35%。没换代码逻辑只是配了一行spark.serializer和几行registerKryoClasses这性价比高得离谱。数据结构的选择同样重要。Spark SQL里定义schema时能定成Long别用BigInt能定成String别用复杂JSON。DataFrame底层是Tungsten的二进制存储字段类型定得越紧凑内存占用越低处理速度越快。还有个细节是尽量避免在关键链路里用UDF。UDF意味着Spark无法对函数内部做任何优化Tungsten的code generation在这种场景下直接失效每行数据都要经过Scala函数调用的序列化和反序列化。同一份数据用内置函数和用UDF处理性能差距经常是2到3倍。能拼内置函数解决的别写UDF。2.3 缓存与复用没有章法的cache比不cache更糟缓存是Spark里被滥用得最厉害的功能之一。很多人一上来就对DataFrame调用.cache()认为这样后面用着快完全没有考虑这个缓存到底被谁复用了、存到磁盘还是内存、失效条件是什么。我见过最典型的场景一个数据处理流程中间有个中间结果被下游三个不同逻辑各自引用了一遍代码确实需要这个中间结果的复用。但问题在于开发者只是调了.cache()后面所有下游任务全部触发了一次从头到尾的计算而Spark的cache是惰性的如果你不在某个cached DataFrame上触发一个action缓存根本不会建立后续引用反而是每个下游各自重新算了一遍。结果就是任务时长增加了一倍缓存没起到任何作用。正确做法是在需要复用的中间结果上调用.cache()或.persist(StorageLevel.MEMORY_AND_DISK)之后立刻紧跟一个count()或其他action把缓存真正物化然后再给下游使用。同时要考虑这个中间结果是否真的会被复用多次如果只有一次使用cache纯属浪费还要承担spill的风险。我的一个习惯是缓存的关键DataFrame存储级别优先选择MEMORY_AND_DISK而不是纯MEMORY。虽然看起来多占了一点磁盘但它的好处是内存放不下的时候不会直接OOM或重新计算而是把溢出的部分落盘。在大数据量场景下这个选择的稳定性收益远高于那一点磁盘开销。3. shuffle与数据倾斜必须遵守的专项规范3.1 减少shuffle的代码级手段shuffle是Spark应用里最昂贵的操作也是最值得在代码层面做文章的地方。每一次shuffle都意味着数据要落盘、序列化、网络传输、反序列化这中间任何一环都可能成为瓶颈。减少shuffle的第一个代码级手段是尽量使用宽依赖的替代方案。举个例子如果你需要对数据按照某个字段去重很多人会写groupByKey然后取第一条。但如果这个字段的唯一性可以通过distinct或dropDuplicates实现那shuffle的机制就完全不同前者是全量聚合后者有更高效的hash分区去重逻辑。第二个手段是在shuffle前先做过滤和裁剪。这个看起来像废话但实际代码里我经常看到有人先join再where或者先groupBy再filter。你要知道join和groupBy都是触发shuffle的算子如果能提前把不必要的行过滤掉shuffle的数据量就直接降下来了。谓词下推在Spark SQL里由优化器自动做但在RDD代码里你必须自己动手把filter往前挪。第三个手段是两阶段聚合。针对数据倾斜的场景在真正groupBy的key前面加上一个随机前缀做一次局部聚合再把前缀去掉做第二次聚合。这样原本倾斜到单个task的数据被分摊到了多个task上shuffle不再是单点压力。还有一个很多人忽略的能用reduceByKey的增量聚合就不要先collect再统一聚合。我在一些代码里看到有人用map把数据拉成Map然后collectAsMap再reduce这在数据量大时直接就把driver搞挂。规范要求是所有聚合尽量在分布式阶段完成driver只做最终的结果汇总。3.2 数据倾斜的定位与常见处理规范数据倾斜是Spark性能问题里出现频率最高、最让人头疼的一种。不是说数据量大就一定倾斜而是某些key的数据量远大于其他key导致某几个task计算时间极长其他task空转等待。定位倾斜的第一步不是猜而是看Spark UI。在Stage页面上如果某个stage里的task完成时间呈现明显的长尾分布比如大多数task几秒完成个别task要十几分钟那就是典型的倾斜。进一步看如果你启用了accumulator或做了key级别采样可以直接看是不是某个key的值特别多。解决倾斜的常见手段分为三类第一类是过滤异常key。如果倾斜是因为个别key不合理比如null值或某个默认占位符先把这些key单独处理掉再走正常逻辑。第二类是提高shuffle并行度。调整spark.sql.shuffle.partitions或者在RDD的reduceByKey里显式传入分区数让倾斜的key分散到更多task上。这个方法简单但对极端倾斜场景效果有限。第三类是加盐salting两阶段聚合这个前面提过。在key上拼随机数先局部聚合再去掉盐做全局聚合。注意这个方法只适用于聚合类的场景如果是join场景需要把小表也做同样的膨胀处理小心小表膨胀后变得不再小。我在一个真实任务里用加盐方式解决过一次严重倾斜某营销场景的曝光数据按用户id做聚合头部用户占比极高导致跑批任务总是有一个task跑不完。加盐后分为两步聚合整个stage从原来50分钟降到8分钟。3.3 动态分区写入与小文件治理很多人的Spark任务慢不在计算而在写入阶段。尤其是离线数仓里按天或按小时分区写入时如果不控制最终落地的分区数量就会产生大量小文件。小文件的问题不只是写入慢还会在下游读取时造成巨大的元数据开销。我在处理一个项目时遇到的情况很典型某个任务在写Hive分区表时每个分区写入了上百个几十KB的小文件导致后续查询这个分区的时候Hive的namenode要处理海量文件列表查询响应直接退化成卡死状态。规范做法是在写入前用repartition或coalesce控制最终的输出分区数。repartition适合需要增大分区数提升并行度的场景coalesce适合减少分区数合并小文件的场景。注意coalesce默认不触发shuffle它是把现有分区合并但如果你需要均匀分布数据反而要用repartition。另外Spark 3.x引入的动态分区写入dynamic partition overwrite模式也值得注意。开启spark.sql.sources.partitionOverwriteModedynamic后只有本次写入涉及的分区会被覆盖而不是整个表或全部分区重建。这个配置能显著减少不必要的IO同时避免产生重复的小文件。我个人的习惯是写入前先预估一下目标分区的数据量按单个文件128MB到256MB的目标去计算需要的分区数。比如一个分区有10GB数据我期望每个文件256MB那就设置40个分区左右。不要偷懒直接沿用了shuffle分区数那是两码事。4. Spark任务参数与资源规范4.1 核心参数配置的最佳实践代码写完之后参数配置就是最后一道放大器。配置不合适代码再高效也白搭。先说executor内存。很多人习惯把executor内存堆到很大比如20GB甚至30GB但忽略了JVM的GC开销。executor内存过大的时候Full GC的停顿时间会非常夸张反而拖慢整个任务。我的经验是单个executor的内存设置在8GB到16GB之间比较合适同时要预留spark.memory.offHeap.enabled和spark.memory.offHeap.size的空间让部分数据走堆外内存减少GC压力。再说并行度。一个经典的经验公式是集群总CPU核数乘以2到3倍作为task目标数。比如你有100个vcore那目标task数可以设在200到300之间。task数太少每个task处理的数据太多容易倾斜和OOMtask数太多调度开销变大每个task又太小吃不满资源。spark.sql.shuffle.partitions这个参数要重点说。默认值是200但这个默认值在大多数场景下都不适合你。如果数据量小200个分区多余如果数据量大200个分区不够用会导致每个task处理的数据量过大。正确的做法是动态评估我给团队定的规范是shuffle数据量在几百MB级别时分区数设为100左右几GB级别时设为300到500几十GB级别时建议500以上。宁可多配也别少配因为多配只是调度开销变大少配会导致OOM和严重耗时。4.2 动态资源分配的使用细节Spark的动态资源分配dynamic allocation在YARN模式下非常好用它能让应用根据实际负载动态增减executor避免资源浪费和等待排队。但动态资源分配不是开了就万事大吉。我见过很多团队开了动态分配后任务反而变慢原因是executor的回收策略和预期的任务负载不匹配。你需要关注两个参数spark.dynamicAllocation.minExecutors和spark.dynamicAllocation.maxExecutors。如果min设得太大空闲时资源无法释放如果max设得太小高峰期资源不够用。另外还有spark.dynamicAllocation.executorIdleTimeout默认值是60秒。如果任务本身有周期性波动比如每10分钟有一次重计算的高峰executor闲了60秒就被回收下次高峰又要重新申请和拉起反而增加了启动开销。我建议根据任务的实际节奏把IdleTimeout调到5到10分钟之间。还有个容易踩坑的地方如果你的作业里有大范围的cache或persist动态分配回收executor时会把缓存一起丢掉后续用的时候又要重新计算。所以在使用动态分配的同时配合spark.memory.storageFraction设置合理的存储比例保证缓存的数据尽量驻留在内存。4.3 分区数设定的黄金法则分区的设定是Spark参数设置里最容易被拍脑袋决定的一项。我见过有人写repartition(1000)是拍脑袋也见过repartition(10)也是拍脑袋。正确的分区数设定要考虑两个维度数据量和单分区处理能力。先说一个基础底线单分区数据量尽量控制在200MB到1GB之间。小于200MB时处理这个分区的task太轻浪费调度开销大于1GB时task太重内存容易吃不消shuffle落盘压力也大。这个范围可以通过实验微调但作为起步的参考值非常实用。再结合分区数的实际计算公式目标分区数 预估数据总量 / 目标单分区大小。比如你预估要处理50GB的数据目标单分区500MB那就是100个分区。如果你用的不是RDD而是DataFrame写shuffle类算子时也可以参考这个公式显式传入分区数而不是依赖默认值。我常用的一个经验是源表读取时尽量保留文件本身的split数。Spark读取HDFS或S3文件时分区数会根据文件大小和块大小自动生成如果条件允许不要随便repartition。读取阶段的分区数往往是当前可用的最优并行度强行改变只会增加一次不必要的shuffle。5. 问题定位与代码审查经验5.1 从Spark UI快速定位性能瓶颈Spark UI是定位性能问题的第一入口但我发现很多开发者不会系统性看它。上来就看某个stage的耗时忽略了整体链路里哪些stage是真正的瓶颈这种做法容易误判。我的习惯是先看Job列表整体耗时分布确定耗时的stage集中在哪一步。然后进入这个Stage页面重点看两个指标第一个是Shuffle Read Size和Shuffle Write Size如果某个stage的shuffle read数据量异常大说明上一步产生了过多的shuffle数据要去上游算子找问题第二个是task的完成时间分布如果存在长尾基本可以断定是数据倾斜或某个task上的计算太重。还有一个隐藏技巧是看Event Timeline里每个task的GC时间占比。如果GC时间超过task总时间的20%说明内存分配或对象创建上出了问题可能需要在代码里减少大对象的创建或者优化序列化方式。这属于UI上很容易忽略但信息量很大的细节。5.2 常用日志分析与异常排查手段遇到Spark任务跑挂或者跑得异常慢先看日志不要慌日志里面藏着大量线索。shuffle阶段报错最常见的是Java heap space或Direct buffer memory前者说明executor堆内存不够后者说明堆外内存或网络缓冲设置有问题。对于OOM问题我的排查顺序是先确认是否数据倾斜导致单个task数据量异常再检查代码里是否存在一次性collect全部数据的行为最后检查参数配置是否给了过大的executor内存和过小的并行度。另外我强烈建议在开发阶段就开启spark.driver.extraJavaOptions和spark.executor.extraJavaOptions里的GC日志参数。这样任务失败时你可以直接从GC日志里看到是老年代占满还是新生代频繁回收这能直接指导你是加内存还是改代码逻辑。还有一个细节如果你用DataFrame API报错信息里往往带着执行计划的片段。很多人在日志里看到那一大段物理计划就跳过其实那是定位谓词下推失没失效join类型变成了什么的最好材料。拿它跟预期的执行计划比对往往能找出问题所在。5.3 代码评审时的Spark性能检查清单我在团队里做了很长时间的Spark代码审查逐渐总结出一张检查清单。每次评审新任务或新同学的代码我就按这张清单逐项核对对发现问题非常高效。这里分享出来你可以直接拿去用。是否存在groupByKey等纯shuffle聚合算子能否改成reduceByKey或aggregateByKey。是否存在不必要的collect或collectAsMap数据量级是否可能过大导致driver OOM。join时是否考虑过broadcast小表是否被正确广播是否有join倾斜风险。是否有中间结果被多次使用却未正确cache或者cache后未触发action导致缓存失效。过滤条件是否尽量前移是否进行了早期行裁剪和列裁剪。是否使用了全局UDF替代了本可以拆解的内置函数。写入阶段是否控制了输出分区数是否可能产生大量小文件。动态分区写入时partitionOverwriteMode是否为dynamic避免整表覆盖。这份清单不是僵硬的必须满足而是用来激发讨论的锚点。比如broadcast在某些场景会因为广播表太大反而更慢那就要结合数据量评估再决定。5.4 一个完整的排查案例复盘最后分享一个实际的排查案例把这个过程串起来。某项目组的一个离线报表任务每天跑的时长从正常的20分钟突然涨到90分钟而且连续几天只增不减。我接手后用UI看了一下发现大部分时间集中在某一个join stage上而且这个stage的shuffle read数据量异常大是其他stage的好几倍。看进度条时发现有一个task的执行时间远超其他task典型的倾斜特征。于是我用sample算子对join key做了个采样发现有一个key占了全部数据的近30%这是一个异常的时间戳默认值。处理方式分两步第一步在join之前过滤掉这个异常key单独用filter走出来走旁路逻辑第二步对剩下的数据用加盐两阶段join把小表膨胀成原表若干份再join。改动后的效果是任务回到25分钟左右而且稳定性明显提升了。这个案例里没有用到任何复杂的技巧核心就是从UI确认瓶颈用采样确认原因再用合理的代码规范去修复。这套流程适用于绝大多数Spark性能问题。我个人在实际排查中还有一个体会性能和稳定性往往是同一回事一个频繁OOM或倾斜的任务就算偶尔跑得快也会在数据量波动的某一天突然崩掉。所以写Spark代码时不要去赌今天数据量小没出问题而是要在写的每一行代码里都问自己一句如果数据量变成10倍这段逻辑还会不会稳。