这些年调试Spark任务,我听得最多的话就是“代码在本地跑得好好的,一上集群就崩”。屏幕上的英文报错一长串,有的是Timeout,有的是OOM,有的是压根不知道哪里来的Exception。大多数时候,问题不在业务逻辑,而在你对Spark运行机制的理解,以及提交作业时那些看一眼就跳过的参数。这篇东西不是从官方文档抄出来的说明书,是我在真实集群环境里,把Spark程序从“能跑”调到“跑得顺畅”的过程梳理,尽量把报错背后的原因讲透,也把优化该从哪里下手讲明白。适合刚接触Spark的人、被任务杀得焦头烂活的运维开发,以及想在现有资源下把任务提速的人。
1. 先搞清楚错误从哪里来:排查前的整体思路
1.1 错误不是孤立的:先分清楚三类源头
我见过太多人拿到报错就复制第一行去搜索引擎,结果对着一个无关痛痒的提示折腾半天。报错的位置往往不是病根,就像发烧的病因不一定是额头。根据常年跟Spark打交道的经验,运行期的错误基本逃不出三类:环境资源类、代码逻辑类、数据分布类。
环境资源类最典型的就是OOM、堆外内存超限、容器被强杀、连接超时、节点失联。这类问题多数发生在集群压力大的时候,或者资源参数配置不匹配的时候。特征是报错里带“Container killed by YARN”“Lost executor”“Connection refused”。
代码逻辑类是序列化错误、依赖冲突、算子使用不当、闭包捕获问题。比如“Task not serializable”“NoClassDefFoundError”“Caused by: java.lang.IllegalStateException”之类。这类问题最明显的特征是:换个小数据集就不报错,一上全量数据就翻车。
第三类数据分布问题,最典型的是数据倾斜。报错不一定直接写“skew”,但你会看到某个Stage里99个task秒完,一个task跑两小时还卡着,最后OOM或被杀死。遇到这种情况,先别急着调参,先看数据键的分布。
把报错归入哪一类,决定你的排查路线。我一般拿到报错后先看三样东西:日志位置、任务阶段、失败的是Driver还是Executor。不要一上来就改配置,配置只是给错误兜底,不是给设计缺陷买单。
1.2 日志是唯一的真相:按顺序看,别抓瞎
Spark的日志分散在好几个地方,看错地方等于近视眼找针。完整的日志链是:提交命令的控制台 -> Driver日志 -> Executor日志 -> 资源调度框架的日志。有个规律得记清楚:Executor日志才是多数执行期错误的现场,Driver日志只反映最终结果。
如果你用命令行提交,控制台会打出“ERROR SparkUncaughtExceptionHandler”,这类通常是Driver端的问题。如果是某个Executor报错,控制台只显示“Lost executor X on host Y”,具体堆栈要去对应节点的日志目录找。这就是为什么我建议从一开始就把日志都落在统一路径下,否则分布式环境里找日志就跟在迷宫里找洗手间一样。
看日志顺序是:先定位失败在哪个Stage,比如报错信息里会写“at Stage 3.0 (xxxx)”,再去识别是哪个Task失败,对应日志里的“Task 4 in stage 3.0 failed”,最后看完整堆栈的首个Caused by。很多初学者只看ERROR行的第一句话,那是Spark的二次封装,真正的原始异常往往在“Caused by:”后面。我习惯直接grep到第一个“Caused by”,然后往上翻十个上下文行,基本就能锁定是代码哪个位置的调用链。
1.3 复现与最小化:把异常缩小到一段代码
拿到一个可疑的报错,先在本地或者单机模式做最小化复现,是排查效率最高的一招。怎么个最小化法?把数据切到只有几MB,算子链压缩到触发报错的那几步,其余逻辑全部注释掉,跑通了再逐段放开。这样做的本质是减少变量,你能确定某个Exception一定由某段逻辑引发,而不是多种因素叠加造成的假象。
有次某开发说任务在集群上跑了两小时崩了,本地小数据一直不崩。我让他抓取的是输入文件里某个字段的脏数据,把那条数据单独拎出来,写了二十行的程序一跑,问题立刻现出原形:Parquet表里某列在部分文件中的schema类型不一致,转换成DataFrame时直接抛出异常。这属于“数据形态触发代码缺陷”,并不需要把整个生产任务跑完才能发现。
2. 高频报错拆解:现象、原因与修复动作
2.1 内存类:容器被杀与OOM,别混为一谈
“Container killed by YARN for exceeding memory limits”是Spark作业最常见的死法,可是很多人会误认为是Java Heap OOM。其实这是两个完全不同的内存问题。
Java Heap OOM是JVM堆内存不够,报错通常是“java.lang.OutOfMemoryError: Java heap space”,GC日志里能看到堆占用接近上限,Full GC非常频繁。这种情况是代码里加载了过多数据,或者某个RDD/DataFrame没有及时释放,也可能是executor内存本身就设小了。
而“Container killed by YARN”是物理内存超限,问题出在JVM堆外的内存开销。Spark的Execuor进程不仅吃heapsize,还要吃堆外内存、网络缓冲、直接内存、线程栈和内部数据结构。默认情况下这些算在spark.executor.memoryOverhead里,当它设置太小,整个容器就会超过资源调度框架给它的物理内存限制,直接被判死刑杀掉。
一个经常被忽略的细节是:默认的memoryOverhead是executor内存的10%,对于某些需要大量堆外空间的场景根本不够。比如你用内存列式缓存、网络传输量大、开启Kryo、使用堆外存储时,压力全在堆外。我实际遇到过一个Python UDF场景,把memoryOverhead从默认值抬升到2g才消停下来。
调法没有秘密,先算清楚总账:
spark.executor.memory=8g spark.executor.memoryOverhead=2g这里的8g + 2g 基本等于进程最大可用物理内存,如果还包含pyspark产生的Python子进程,那还得在spark.python.worker.memory里留出空间。很多平台把executor物理限制设置为memory+overhead,超一点就杀容器。宁可把executor内存设小一点、留足overhead,也别把堆数撑满,否则随机波动一下就会被杀。
再说怎么判断当前到底是不是内存问题:去看Spark UI里Executor页签的GC时间,如果GC时间占比超过10%,说明JVM堆已到极限;如果直接显示“ExecutorLostFailure”,再去节点上查dmesg,看是不是被操作系统OOM Killer点名了。
2.2 序列化类:Task not serializable,闭包是罪魁祸首
“org.apache.spark.SparkException: Task not serializable”是初学者遇到最多的堆栈之一。第一次见可能觉得莫名其妙,但原理其实简单:Spark会把闭包(闭包)从Driver发送到Executor,闭包里引用的所有对象都要能序列化。如果引用了某个不可序列化的类,分布式执行就玩不转。
最常见的坑是在一个普通类里面用this调用类成员方法。比如写:
class DataProcessor(val dbConn: Connection) { def run(df: DataFrame): DataFrame = { df.map(row => process(row)) // 这里隐含了this引用 } }这个map算子里的函数process其实是this.process,闭包会带上整个DataProcessor实例,而这个实例里有不可序列化的dbConn,Task不炸才有鬼。
解决办法有几个方向。第一,把处理逻辑放到object静态方法里,闭包里只引用可序列化的结果,不让this出现;第二,把不可序列化的字段声明为@transient,并确保真正使用它的地方是在Executor端重新创建,而不是从Driver带过去;第三,只把函数中需要的一部分参数提取成局部变量传入,不要整个对象进闭包。
另外,配置Kryo序列化能缓解很多序列化问题,但不是万能药,也无法解决不可序列化对象本身的问题。Kryo只是效率更高,不注册类的情况下性能优势有限。正确姿势是:
val conf = new SparkConf() conf.set("spark.serializer", "org.apache.spark.serializer.KryoSerializer") conf.registerKryoClasses(Array(classOf[MyClass], classOf[YourClass]))我的经验是,凡是网上搜到“Task not serializable”第一屏回答说不清楚时,先检查是不是闭包捕获了外部类实例,八成命中的是这一条。
2.3 Shuffle与网络类:FetchFailed、连接超时、Executor丢失
Shuffle是Spark任务里最容易出幺蛾子的环节。一个典型报错长这样:
org.apache.spark.shuffle.FetchFailedException: Failed to connect to host ...名字里带Fetch,说的是下游Reducer从上游Map任务的输出文件拉取数据时失败。原因通常是三种:上游节点机器故障、网络临时抖动、分区文件被清理或损坏。遇到这种错误,不要一拍脑袋觉得是网络问题。如果连续多次FetchFailed,且集中在一个节点上,那大概率那台机器上有状况,比如磁盘空间满了、IO异常、节点进程僵死。
更隐蔽的是“Premature EOF”或“Connection reset”。这类错误在Shuffle读取量大的任务里很常见,跟GC时间长导致的心跳超时也有关系。Executor忙于Full GC,没时间上报心跳,调度方以为节点死了,就把它标记为丢失,重新调度Task,结果任务反复尝试又反复失败。排查这类问题,先看GC时间,再检查网络,顺序不能反。
针对不稳定的网络环境,有几个参数可以兜底。下面这个组合是我在某个集群上验证过比较稳妥的:
spark.shuffle.io.retryWait=60s spark.shuffle.io.maxRetries=10 spark.network.timeout=600s spark.executor.heartbeatInterval=60s这三个参数给的是“重试机会”,不是逃避问题的解药。如果每小时出现一次FetchFailed,调参会掩盖硬件或网络故障,还是该找运维彻查节点。
2.4 依赖与类加载类:ClassNotFound、NoSuchMethodError
提交任务时最常见的低级错误,是把一大堆依赖用fat jar打包,结果带上来的jar里含有和集群Spark版本冲突的旧类,引发NoSuchMethodError;又或者反过来,依赖没打包,Executor端反序列化某个类时ClassNotFound。
我见过最典型的坑:项目里引了一个比集群Spark版本新得多的Hive依赖,覆盖了集群自带的类,导致任务一启动就报各种看不懂的AbstractMethodError。排查这类问题,别在代码里找,先去撞一下依赖树。用--packages或--jars指定外部依赖时,注意排除集群已有的传递依赖。Maven里写好:
<exclusions> <exclusion> <groupId>org.apache.hive</groupId> <artifactId>hive-exec</artifactId> </exclusion> </exclusions>Spark版本相关的核心依赖,尽量以provided方式存在于打包中,让集群在运行时用自己的版本。凡是涉及Spark内部API的类,别自己“手动管理”版本,这个坑填不干净。报错出现NoSuchMethodError时,第一反应不是改代码,而是确认编译期的Spark版本和集群运行时版本是否一致。
3. 优化:让同样的资源跑出更快的任务
3.1 先从数据形态入手:倾斜才是性能杀手
我做过不少“优化”咨询,大多数任务是性能不理想,翻来覆去调参,最后发现根源就是数据倾斜。症状很典型:某个Stage里一个task的Shuffle Read量是其他task的几十倍,运行时间被它一人拖到常态的十倍以上,后续Stage全程等它。
为什么倾斜危害这么大?因为在同一个Stage的task是并行跑的,最终执行时间取决于最慢的那个task。而数据倾斜意味着并行计算中有一个“胖子”扛了绝大部分活,其他“瘦子”干完了只能干等。更糟糕的是,倾斜分区还可能直接把内存击穿。
倾斜的解法必须根据业务灵活选,我按命中率排一下:
第一种,两阶段聚合,俗称加盐。对热点key先打一个随机前缀,让它分散到多个临时分区做局部聚合,去掉前缀后再做一次全局聚合。这种方法适合聚合类算子,比如reduceByKey、agg。
df.withColumn("salt", (rand * 3).cast("int")) .groupBy("key", "salt") .agg(sum("value") as "partial") .groupBy("key") .agg(sum("partial") as "total")第二种,过滤掉纯噪音数据。有些热点key的分区数据量大,但有效信息极少,直接在前期过滤掉,省得白白浪费计算内存。第三种是广播小表,把倾斜的大表Join小表变成广播Join,让每个task在本地完成连接,避免Shuffle。如果你用的是Spark 3以上版本,直接开AQE的倾斜Join优化:
spark.sql.adaptive.enabled=true spark.sql.adaptive.skewJoin.enabled=true它会动态感知到倾斜分区并把它拆成多个task,这个特性实测有效,但它只对Join有效,聚合类的倾斜还得靠代码解决。
3.2 序列化与存储:Kryo、列式压缩、缓存策略
很多任务时间都耗在序列化上,尤其是Shuffle量大和频繁的算子操作场景。Java默认的ObjectOutputStream是极其低效的,换成Kryo通常能减少3到5倍的序列化体积,节省真正传输的数据量,GC压力也会跟着降。前提是注册类,否则Kryo会走反射路径,效果大打折扣。上面讲过注册方式,这里再补充一个经验:注册类列表写全但别写超过一千个类,注册太多类本身也会膨胀初始化的开销。
存储层面的优化常常被忽略。Spark在处理Parquet/ORC这类列式文件时有天然的剪枝能力,只读取所需列,所以强烈建议把输入数据转成列式格式;配合压缩算法,传输和Scan速度都会改善。比如Parquet配合Zstandard,压缩率高,解压速度快。而存成JSON或CSV,加上Gzip也算能用,但读Path时无数倍的性能劣化是肉眼可见的。
Cache和Persist这两个操作,很多人用得毫无节制。DataFrame或RDD用cache()之后如果没unpersist(),会在内存里一直占位,到后面反而拖累整个任务。我的原则是:只缓存那些会被多次使用的中间结果;如果中间结果只需要跑一次,别缓存;如果缓存链太长,可以改为用checkpoint()切掉血缘。checkpoint会把数据落到存储上,能截断计算链,但代价是额外的一轮IO,在血统特别长、恢复成本高的情况下才值得用。
3.3 执行参数与动态资源:把资源预算算清楚
很多人设执行参数全凭感觉,今天调个executor内存,明天加个并行度。这里给一套公式性的思路。
先定executor数量。假设你申请了120个vcore,每个executor设4核。理想executor数就是120/4=30个。但真正能同时运行的executor上限依然受内存配额制约,核心数定出来后看内存,每个executor给8g,总内存就是30*8=240g,超了就砍executor数或降核数。记住,executor核心数设得太大,比如8核一个,会加剧Shuffle的竞争;设得太小,比如1核一个,task数多了调度开销又大。实践上4到5核是最稳的区间。
再定并行度。Spark的并行度由分区数决定,合理值大约是总核心数的2到3倍。核心数120乘以3是360,那spark.sql.shuffle.partitions设为360或略高。设得太少,浪费核心资源;设得太多,task碎片化严重,调度和序列化开销反而拖慢速度。我的经验是宁可略高,也不能太低,最低不要低于总核心数的1.5倍。
如果集群使用率波动大,建议开启动态资源分配:
spark.dynamicAllocation.enabled=true spark.dynamicAllocation.minExecutors=2 spark.dynamicAllocation.maxExecutors=20它能在任务排队积压时自动增加executor,在空转时自动回收。不过要注意,动态分配和某些固定队列的配额策略有冲突,启用前先确认平台的资源调度策略。
AQE是我强烈建议默认开启的开关。它能在运行时改变物理执行计划,比如自动合并小分区、自动优化Join策略、自动处理倾斜。配置起来也不难:
spark.sql.adaptive.enabled=true spark.sql.adaptive.coalescePartitions.enabled=true spark.sql.adaptive.coalescePartitions.parallelismFirst=false spark.sql.adaptive.advisoryPartitionSizeInBytes=64MB开启后,Shuffle之后的输出分区会按实际数据量动态调整,而不是机械地按固定分区数切分,小任务不再手捧着几十个小文件,IO效率高不少。
3.4 算子层面:改写代码,比调参更快
与其靠调参让烂代码跑顺,不如把代码本身写得符合Spark脾气。算子里最经典的教训是groupByKey和reduceByKey的区别:前者把所有原值都传到一个Reducer里再聚合,传输量巨大;后者先在Map端做一次合并,Shuffle的数据量小一个量级。实际测试中,同样逻辑用reduceByKey比groupByKey可以减少60%以上Shuffle数据。
自定义UDF能避免就避免。Spark内置函数是优化器认识的,能下推到数据源、能参与列剪枝,而UDF是一团黑盒,必须完整拉进JVM。很多Python UDF更是噩梦,因为数据和函数要在JVM和Python进程间来回拷贝,开销极大。能用when、concat、date_format等函数解决的问题,千万不要造一个UDF。
collect()这个动作也要小心。把全量数据拉到Driver端,动辄几GB,轻则OOM重则整个Driver挂掉。你需要的是抽样看数据,那就用take(20)或limit(10);你需要输出统计值,那就用agg;你需要写文件,那就用write。凡是涉及全表数据回来再处理的模式,建议先停下来问自己:这真的必须吗?
还有个经常踩的坑:在循环里反复创建DataFrame或SparkSession。有的业务是按天跑一批数据,循环里每次new一个SparkSession来读文件,结果任务越跑越慢,GC也止不住。SparkSession是进程级的资源,复用同一个实例,数据读取和注册表操作都是廉价的,没必要反复初始化。
4. 实战排查技巧与速查表
4.1 Spark UI 阅读路径:先看哪几个页面
遇到问题不要急着翻日志文件,Spark自带UI的信息密度比日志高得多。打开Web界面后,我一般按这个顺序走:
先看Jobs页,定位失败的Job和耗时最长Job。点进去看Stage列表,重点找Shuffle Read大、运行时间长或失败的Stage。再进入Details,可以看每个task的运行时间分布,如果某个task的时间和Shuffle数据量比同类高一个数量级,那就是倾斜的直接证据。然后看Executors页,如果某个executor的GC时间很高、磁盘或内存用量明显异常,说明任务或资源在这台机器上出了状况。最后看SQL页面,查看物理计划。有时候你能看到优化器生成的计划里有BroadcastNestedLoopJoin,而不是BroadcastHashJoin,这往往意味着表太小没触发自动广播,或者代码缺失了某种必要优化条件。
不要觉得UI是给运维看的东西,开发也必须掌握。我自己有一半的性能问题是通过UI页面定位的,尤其是Stages里的Shuffle Read/Write量,它直接告诉你数据传输的负担在哪里。
4.2 常见报错速查表
下面是我整理的一份高频报错速查表,可以贴到自己的备忘里:
| 报错信息片段 | 可能位置 | 优先排查思路 | 常用处理动作 |
|---|---|---|---|
| Container killed by YARN / exceeding memory limits | 资源层 | Executor物理内存超限 | 调大memoryOverhead,降低executor内存,检查堆外空间 |
| OutOfMemoryError: Java heap space | 执行层 | 堆内空间不足 | 增加executor内存,检查collect/cache,减少单task数据量 |
| Task not serializable | 代码层 | 闭包捕获不可序列化对象 | 消除this引用,用@transient,改用静态方法 |
| NoSuchMethodError / NoClassDefFoundError | 依赖层 | Spark版本与依赖冲突 | 统一版本,提供provided依赖,排除传递冲突 |
| FetchFailedException / Connection reset | 网络/Shuffle层 | 节点故障或GC抖动 | 检查节点状态,调大重试和网络超时,排查GC |
| ExecutorLostFailure / Lost executor | 资源/节点层 | 节点被杀或失联 | 看dmesg,检查机器负载,确认GC与HTTP起搏 |
| FileAlreadyExistsException | 输出层 | 写文件路径已存在 | 设置SaveMode.Overwrite或重新确认输出目录 |
| AnalysisException: Table or view not found | SQL/元数据层 | 表名或库名不存在 | 检查catalog和表空间,确认SQL命名规范 |
| SparkException: Could not serialize object | 序列化层 | 闭包/状态对象不可序列化 | 同Task not serializable,且检查Kryo注册 |
| java.lang.StackOverflowError | 代码层 | 深血统或递归过长 | 算子过多用checkpoint切断血统,减少递归 |
表格可以当速查,但每个条目背后都有不同的触发条件,不要把表格当成万能解。
4.3 一次真实排查记录:像侦探一样定位问题
最后分享一个脱敏的案例,这种过程在每个Spark使用者身边都可能发生。
某项目X的处理任务是每天凌晨跑批,某天开始频繁失败,报错是“Lost executor 1 on host-xyz”。一开始大家以为是偶发,重启后白天还能跑,但第二晚又出现在同一时间段。我接手后没有直接调整Executor数量,先去翻当天所有executor的日志,发现一个共性:GC日志中Full GC频繁,而且Executor的心跳间隔远低于网络超时的设置。也就是说,Executor忙着做GC,心跳没来得及发,调度方以为它没了。
继续看GC为什么这么夸张,发现数据读入量比前几天涨了近一倍,某些Java对象存储了大量字符串,堆内存已经压到9成以上。我先把spark.executor.memory从6g调到10g,GC降到5%以内,任务能跑完了。但回头分析,业务侧的数据量上涨是长期趋势,光加内存解决不了问题。于是让业务方清理了数据源里大量无效字段,又改用spark.sql.adaptive.enabled=true让Spark自己合并小分区,最终在8g内存下稳定运行,比加内存的方案更省资源。
这个案例想表达的不是参数调优有多神奇,而是说:任何报错都有一个完整的因果链,环境变化导致数据量变化,数据量变化压垮GC,GC压垮心跳,心跳超时导致executor被误杀。把链条的每一环都打开,修复才真正可持续。
5. 我长期踩坑后的几点心得
调Spark的时候,我习惯把每次报错和修复记录成一个“case”,用一句话概括现象,再补一句根因判断。时间久了,你会发现自己处理同一类错误的速度越来越快。可我要提醒的是,不要过度依赖参数。有一次某开发把executor内存加了4倍,任务依然超时,我看了代码后只改了一行,把groupByKey换成reduceByKey,执行时间从50分钟降到11分钟。调参数是给系统化妆,改代码才是治疗根源。
最后分享一个大家最容易忽略的小技巧:提交任务时先把spark.sql.shuffle.partitions和spark.default.parallelism这两个值打印到日志里,和任务实际的数据量对比一下。你会发现很多性能问题,其实是在集群层面被一个小数字和一个小数据不匹配放大的。无论你的任务跑的是批处理还是实时,是数据清洗还是模型训练,先把数据分布看明白、把日志链条看清、把算子选型选对,你的Spark程序就已经比大多数人稳了。