☰
IDEA 中 Spark 本地开发环境配置与报错排查实战
2026/10/27 7:56:35 网站建设 项目流程

在 IDEA 里把 Spark 跑起来,难点从来不是写那几行WordCount,而是把一堆看不见的配置调成一个能稳定复现的环境。我这两年在几个离线计算项目里来回折腾,最常被同事问到的问题集中在一句话上:代码在命令行spark-submit提交上去没问题,在 IDEA 里点绿三角却各种报错。这里面涉及的东西其实很碎——工程骨架怎么搭、依赖 scope 怎么写、Scala 和 JDK 版本怎么对齐、VM options 里塞什么、日志怎么压下去、序列化器什么时候换。把这些配置项一个个捋清楚,比背 API 有用得多。下面这篇就把我在 IDEA 里做 Spark 本地开发时的那套配置习惯完整摊开,从工程搭建到参数调优再到报错排查,全部给到可以照抄的程度。刚入门的朋友可以当成一份环境搭建清单,已经在做项目的同行可以重点看依赖冲突和 JDK 模块开放那两节,那是最容易卡住人的地方。

1. 先把思路理顺:IDEA 里的 Spark 到底在配什么

1.1 一次“能打包、不能本地跑”的典型排查

前阵子帮一个刚转过来的同学看环境,他的工程是直接从老项目复制过来的,mvn package一路绿灯,打出来的 jar 扔到集群上跑得好好的,唯独在 IDEA 里右键运行main方法就抛NoClassDefFoundError。翻他 pom 一看,spark-core和spark-sql全都标了<scope>provided</scope>——这是给集群打包用的写法,本地运行的时候这些类根本不在 classpath 里,当然找不到。这类问题占了我在 IDEA 里遇到的 Spark 故障的一大半,本质上不是 Spark 的问题,是开发期运行形态和交付期运行形态的差异没被意识到。

把这个差异想明白,后面的配置就都有逻辑可循了。Spark 应用在生命周期的不同阶段,classpath 的构成是完全不同的:交付到集群时,Spark 的运行时由集群节点自己提供,你的 jar 只需要带上业务代码和第三方依赖,所以 Spark 相关的包必须provided;而在 IDEA 里本地调试时,没有任何外部环境给你提供这些类,你必须自己把它们拉进 classpath。这两种诉求天然冲突,所以配置方案的核心思路就是:默认按交付形态写,本地调试时用 profile 或者临时改 scope 的方式切换到开发形态。想通这一层,pom 里那些看似矛盾的东西就都解释得通了。

再往上一层看,IDEA 只是个壳,它做的事情无非三件:组织编译期依赖、生成 JVM 启动命令、把启动参数传进去。Spark 真正关心的只有最后那串 JVM 参数和 classpath。所以你在 IDEA 里配的每一项,几乎都能在spark-submit的参数列表里找到对应物,只是换了种表达方式。理解这个映射关系,排查问题的时候就能快速判断“这是 IDE 的问题还是 Spark 的问题”。

1.2 三种运行形态与配置的分层

我把日常接触的运行形态分成三层,每层关心的配置重点不一样,混在一起想就容易乱。

形态master 取值Spark 依赖来源调试能力典型用途
纯本地单机local/local[*]工程自己引入断点全通逻辑验证、单元测试
本地伪集群local[N]或local[*]工程自己引入断点全通并发行为验证、shuffle 调试
远程 client 模式spark://host:7077或yarn集群提供仅 driver 可断点联调、验证集群依赖

绝大多数人在 IDEA 里做的其实是第一层,也就是local[*]。这里的星号表示“用满当前机器的可用核数”,写成local[4]就是限定 4 个线程。别小看这一个字符的差别,它直接决定了你本地跑出来的并发度和分区行为。我见过有人在 16 核机器上用local[*]跑一个原本在小机器上验证过的任务,结果 shuffle 分区数和并行度全变了,数据倾斜的表现完全不一样,排查了半天以为是代码问题。验证逻辑用local[2]或local[4]固定住,比local[*]更可控,这是我踩过坑之后固定下来的习惯。

第三层的远程 client 模式值得单独说一句。有些团队会把 driver 放在本地、executor 放在集群,这样断点能打在 driver 上,又不用把整个数据搬到本地。这种模式的配置重点不在 IDEA 的 Run Configuration,而在--conf参数和网络连通性上,本地依赖依然要用provided。我一般不建议新手一上来就搞这个,先用local[*]把逻辑跑通,再去碰远程模式。

1.3 版本三件套:Spark、Scala、JDK 的对应关系

这是最容易埋雷的地方,而且雷往往在编译期不炸,运行期才炸。Scala 的二进制兼容性是有严格边界的,2.12 编译出来的包拿到 2.13 环境上跑,轻则NoSuchMethodError,重则ClassNotFoundException,报错信息还完全不指向根因。Spark 官方发布的二进制包是绑定 Scala 版本的,artifact 名字里的_2.12后缀就是这个意思,spark-core_2.12和spark-core_2.13是两个完全不同的包,不能混用。

JDK 这边的情况类似但更隐蔽。Spark 3.3 之前官方对 JDK 17 的支持是很勉强的,3.3 开始才有比较正式的支持(内部通过一堆--add-opens绕开模块化限制)。如果你用的是 JDK 17 配 Spark 3.1,那基本是在给自己找麻烦。

Spark 版本官方二进制绑定的 Scala建议 JDK注意事项
2.4.x2.11 / 2.128老项目居多,不要动 JDK
3.0 - 3.22.128 / 11JDK 17 不建议
3.3 - 3.42.12(另有 2.13 构建)8 / 11 / 17JDK 17 必须加模块开放参数
3.5.x2.128 / 11 / 17社区生态最完整

选型的建议很朴素:跟着团队现有集群走,别自己升级。本地开发环境的价值在于“和线上一致”,你本地用 3.5 写出来的代码,集群上是 3.2,中间那些 API 变更足够让你返工。定下来版本之后,在 pom 里用一个spark.version属性统一管理,别在多个 dependency 里各写各的版本号,那是依赖冲突的头号来源。

2. 工程骨架与依赖配置的关键细节

2.1 Maven 还是 SBT:一个务实的取舍

这个问题每年都有人问。我的答案一直是:团队用什么你就用什么,没得选的时候选 Maven。理由不是 Maven 更优秀,而是它在国内的工程化配套更成熟——私服配置、CI 流水线、多模块聚合,这些现成的东西多。SBT 的增量编译确实快,Scala 原生支持也更顺滑,但它的依赖解析逻辑和 Maven 差别不小,遇到冲突时排查路径完全不一样,团队里没人熟悉的话,出问题就是一锅粥。

另外一个实际考量是打包。Maven 有maven-shade-plugin和maven-assembly-plugin这两个成熟方案,处理合并META-INF/services这类问题时有现成的ServicesResourceTransformer可用。SBT 那边的assembly插件也能做,但配置写法对不熟悉的人不太友好。我在几个项目里都用 Maven,从来没因为构建工具本身卡住过。

有一点需要提醒:别在同一个工程里混用 Maven 和 IDEA 自带的 Scala 编译。IDEA 2019 之后的版本默认用自带的构建服务处理 Scala 文件,如果和 Maven 的scala-maven-plugin同时生效,会出现“命令行编译通过、IDEA 里飘红”或者反过来的情况。稳妥的做法是在 Settings 里把 Scala Compiler Server 的行为统一掉,这一点在 3.2 节细说。

2.2 一份可以直接抄的 pom 骨架

下面这份 pom 是我在多个项目里迭代出来的,剔掉了业务相关的东西,只保留 Spark 开发必需的部分。关键位置我都加了注释说明意图。

<properties> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <maven.compiler.source>1.8</maven.compiler.source> <maven.compiler.target>1.8</maven.compiler.target> <!-- 三件套版本统一在这里管,改一处全局生效 --> <spark.version>3.3.2</spark.version> <scala.version>2.12.15</scala.version> <scala.binary.version>2.12</scala.binary.version> <hadoop.version>3.3.4</hadoop.version> </properties> <dependencies> <!-- spark-sql 已经传递依赖了 spark-core,不用重复引 --> <dependency> <groupId>org.apache.spark</groupId> <artifactId>spark-sql_${scala.binary.version}</artifactId> <version>${spark.version}</version> <scope>provided</scope> </dependency> <!-- 读写外部存储时才需要,按需引入 --> <dependency> <groupId>org.apache.hadoop</groupId> <artifactId>hadoop-client</artifactId> <version>${hadoop.version}</version> <scope>provided</scope> </dependency> <!-- 日志门面,注意版本要和 Spark 内部用的对齐 --> <dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-api</artifactId> <version>1.7.36</version> <scope>provided</scope> </dependency> </dependencies>

这里有个细节值得展开:spark-sql的传递依赖里已经包含了spark-core,所以只引spark-sql一个就够了。我见过不少工程两个都写,版本号还写得不一样,结果 classpath 上同时存在两个版本的spark-core,NoSuchMethodError就此埋下。同理,spark-hive会带上spark-sql,也不要重复引。

scala.version和scala.binary.version分开两个属性管理也是个经验做法。前者是完整版本号,给scala-maven-plugin用;后者是主次版本号,给 artifact 后缀用。两者必须对应,比如2.12.15对应2.12。有次我把 binary 写成2.12.15,Maven 直接找不到spark-sql_2.12.15这个 artifact,报错信息里那一长串看完才反应过来。

2.3 provided 还是 compile:本地调试的取舍方案

前面说了集群交付要用provided,本地调试需要compile,这两个需求必须调和。我试过三种方案,最后固定用第二种。

方案一:改 scope。调试的时候把provided手动改成compile,提交前再改回来。优点是零成本,缺点是人一定会忘,我至少有过三次把带着全量 Spark 依赖的胖包传到集群,被运维同事提醒。

方案二:用 Maven profile 切换。定义两个 profile,本地调试激活dev把 scope 覆盖成compile,打包时激活prod保持provided。这个方案成本稍高,但一劳永逸,也是我现在用的。

<profiles> <profile> <id>local-dev</id> <dependencies> <dependency> <groupId>org.apache.spark</groupId> <artifactId>spark-sql_${scala.binary.version}</artifactId> <version>${spark.version}</version> <scope>compile</scope> </dependency> </dependencies> </profile> </profiles>

在 IDEA 右侧的 Maven 面板里勾选local-dev就能激活,取消勾选就回到交付状态。注意 profile 里的依赖声明会覆盖同坐标的默认声明,这是 Maven 的既定行为,利用的就是这一点。

方案三:干脆全用 compile,打包时用 shade 插件排除。这个方案看着优雅,实际很坑,因为 shade 的artifactSet排除规则要跟着 Spark 的传递依赖树走,漏一个就会打到包里。不推荐。

提醒一句:无论用哪种方案,提交前都建议跑一次mvn dependency:tree,确认 Spark 相关包的状态是provided。养成这个习惯能省掉很多“提交后被退回”的往返。

2.4 依赖冲突:guava、jackson、log4j 这三个老熟人

Spark 生态的依赖冲突,十次里有八次集中在这三个包上。它们的共同特点是:Spark 用它、Hadoop 用它、你自己的业务依赖也在用它,三方版本各不相同。

冲突包常见症状排查方式处理思路
guavaNoSuchMethodError: com.google.common...mvn dependency:tree -Dincludes=com.google.guava排除低版本,显式引高版本
jacksonJsonMappingException、反序列化字段丢失mvn dependency:tree -Dincludes=com.fasterxml.jackson.core统一jackson.version属性
log4j / log4j2日志不输出、启动时告警看 classpath 上是否同时存在 log4j 1.x 和 2.x只留一套,优先 log4j2

guava 的坑最经典。Hadoop 的某几个子模块依赖 guava 的某一版,Spark 又依赖另一版,Maven 的“最近优先”原则选出来的往往不是你想要的那个,表现出来的就是运行到某一行突然NoSuchMethodError。排查命令很直接:

mvn dependency:tree -Dincludes=com.google.guava:guava -Dverbose

输出里会标出被忽略的版本和选择原因。看到某个依赖把 guava 降级了,就在那个依赖上加<exclusions>排掉,然后在顶层显式声明一个统一版本。这个动作做完,问题基本就消了。

log4j 的情况稍微特殊一些。Spark 3.3 开始默认用 log4j2,但生态里很多老组件还在引 log4j 1.x,两套同时存在时会出现“日志全跑到 stderr 或者干脆不打印”的现象。判断方法是看启动日志里有没有那行经典的WARN Please initialize the log4j system properly,有的话就是配置文件没被识别。第三节会具体讲怎么配。

3. IDEA 编译器与运行环境的配置实操

3.1 Scala SDK 与项目结构的对齐

IDEA 里最容易出问题的一步是 Scala SDK 的设置。打开File - Project Structure - Global Libraries,先确认全局有没有装 Scala SDK,没有就创建一个,版本必须和 pom 里的scala.version一致。然后在Modules里给当前模块勾上这个 SDK,选的是Scala那一栏,不是Dependencies里的 jar。

这一步做错的表现很有意思:代码里import org.apache.spark._全是红的,但mvn compile完全正常。说明 Maven 的依赖没问题,是 IDEA 自己的 Scala 插件没认出依赖。解决方法是在 Maven 面板里点Reload All Maven Projects,让 IDEA 重新解析一次;如果还不行,检查模块的 Scala facet 配置有没有指向正确的 SDK。

还有一个常被忽略的点:File - Settings - Build, Execution, Deployment - Compiler - Scala Compiler里的 “Compile with” 选项。默认是 IDEA 自带的编译器,速度慢但稳定;切成 Maven 的话编译行为完全由 pom 决定,一致性好。我一般选 IDEA 自带加增量编译,日常改代码的反馈更快,但会在提交前用一次mvn clean compile做最终确认,两边都过一遍心里才踏实。

3.2 编译插件配置与加速技巧

scala-maven-plugin有几个参数值得单独调。默认配置下它会在编译时输出大量[INFO]级别日志,还要重新解析所有 Scala 源文件的依赖关系,大工程一次全量编译能花掉几分钟。

<plugin> <groupId>net.alchim31.maven</groupId> <artifactId>scala-maven-plugin</artifactId> <version>4.8.1</version> <executions> <execution> <id>scala-compile</id> <phase>process-resources</phase> <goals> <goal>compile</goal> <goal>testCompile</goal> </goals> </execution> </executions> <configuration> <scalaVersion>${scala.version}</scalaVersion> <recompileMode>incremental</recompileMode> <args> <arg>-deprecation</arg> <arg>-feature</arg> <arg>-unchecked</arg> <arg>-encoding</arg> <arg>UTF-8</arg> <arg>-target:jvm-1.8</arg> </args> </configuration> </plugin>

recompileMode设成incremental是提速的关键,改动一个文件只重编相关的。-target:jvm-1.8这个参数在 JDK 11 以上编译时特别重要,不写的话可能生成高于 1.8 的字节码,扔到 JDK 8 的集群上直接报UnsupportedClassVersionError。这一条我在两个项目里都栽过。

另外,如果你的工程里有 Java 和 Scala 混编(比如工具类用 Java 写、主逻辑用 Scala),scala-maven-plugin要放在maven-compiler-plugin之前执行,否则 Java 类引用 Scala 类时会找不到。这个顺序在 pom 里就是插件声明的先后,很容易被忽略。

3.3 Run Configuration:参数到底往哪填

IDEA 的 Run Configuration 对话框里有一堆选项,最容易填错位置的是Program arguments和VM options。判断标准很简单:要传给SparkConf的写 Program arguments,要传给 JVM 的写 VM options。

在 Spark 3.x 里,--master和--conf这些其实是spark-submit的语法,直接扔进 IDEA 的 main 方法不会自动生效。本地运行时master通常是在代码里硬编码或者通过System.setProperty设置的:

val conf = new SparkConf() .setAppName("local-test") .setMaster("local[4]")

这种写法适合快速验证,但我不建议长期留着,因为它会把本地配置写死到代码里,不利于后续切换到集群。更好的做法是抽一个配置入口:

val conf = new SparkConf().setAppName("local-test") if (!conf.contains("spark.master")) { conf.setMaster("local[4]") }

这样集群提交时传进来的--master会优先生效,本地跑也不会因为缺 master 而报错。这个小技巧在多个环境之间来回切换时特别省事。

VM options 那一栏填的是真 JVM 参数,最常用的是内存和编码:

-Xmx2g -Xms2g -XX:+UseG1GC -Dfile.encoding=UTF-8

-Xmx2g的取值要看你机器的物理内存,本地调试给 driver 2G 到 4G 是常态。如果你在代码里会用collect()把结果拉回本地,更要留足空间,不然就是OutOfMemoryError: Java heap space等着你。-Dfile.encoding=UTF-8在 Windows 上尤其要加,不加的话中文乱码是大概率事件,而且现象很诡异——日志里是乱码,写入文件后又是好的,排查方向容易跑偏。

3.4 Windows 环境下的那点额外配置

在 Windows 上跑 Spark 需要额外补两个东西:winutils.exe和hadoop.dll。这不是 Spark 的问题,是 Hadoop 的一些文件操作依赖平台原生实现。缺了它们最典型的报错是:

java.io.IOException: Could not locate executable null\bin\winutils.exe in the Hadoop binaries

处理步骤不复杂,但每一步都不能少:

  1. 找到与你的 Hadoop 版本匹配的 winutils 二进制包(社区有编译好的版本,注意版本对应)。
  2. 解压到某个目录,比如D:\hadoop-3.3.4,确保bin子目录下有winutils.exe。
  3. 配置环境变量HADOOP_HOME指向该目录,并把%HADOOP_HOME%\bin加进PATH。
  4. 把hadoop.dll复制到系统目录或者%HADOOP_HOME%\bin。
  5. 重启 IDEA——这一步别忘,环境变量在已启动的进程里不会生效。

一个省事的替代方案:在代码第一行加System.setProperty("hadoop.home.dir", "D:/hadoop-3.3.4")。适合临时跑一次的场景,但长期开发还是配环境变量更稳。

另外顺手检查一下 IDEA 的 Run Configuration 里有没有Working directory设置,默认是模块根目录,没问题。如果你的程序里有相对路径读写文件,这个值会影响结果,调试时莫名其妙“找不到文件”往往就是这个原因。

3.5 JDK 17 的模块开放参数清单

JDK 9 引入模块化之后,反射访问 JDK 内部包会被拦截,而 Spark 内部有大量这类操作。所以在 JDK 17 上跑 Spark 3.3+,VM options 里必须补上一组--add-opens参数,否则启动就抛InaccessibleObjectException。Spark 自己的JavaModuleOptions类里维护了这个清单,抄过来即可:

--add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.lang.invoke=ALL-UNNAMED --add-opens=java.base/java.lang.reflect=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED --add-opens=java.base/java.net=ALL-UNNAMED --add-opens=java.base/java.nio=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/java.util.concurrent=ALL-UNNAMED --add-opens=java.base/java.util.concurrent.atomic=ALL-UNNAMED --add-opens=java.base/sun.nio.ch=ALL-UNNAMED --add-opens=java.base/sun.nio.cs=ALL-UNNAMED --add-opens=java.base/sun.security.action=ALL-UNNAMED --add-opens=java.base/sun.util.calendar=ALL-UNNAMED

这些参数粘到 Run Configuration 的 VM options 里就行。如果团队里多个工程都要用,可以在 IDEA 的模板里配一次,新建的配置会自动带上。我一般还会顺手加一句-Djdk.reflect.useDirectMethodHandle=false,在部分 JDK 17 的补丁版本上能规避一些反射相关的警告。

4. 让 Spark 跑得像个工程师:日志、序列化与内存

4.1 log4j2 配置:把噪音压下去

Spark 启动时刷屏的INFO日志是劝退新人的一大因素,一个简单的WordCount能打出几十行。控制日志级别的正确做法是在src/main/resources下放一个log4j2.properties(Spark 3.3+ 用 log4j2,老版本用log4j.properties)。

rootLogger.level = warn rootLogger.appenderRef.stdout.ref = console appender.console.type = Console appender.console.name = console appender.console.target = SYSTEM_ERR appender.console.layout.type = PatternLayout appender.console.layout.pattern = %d{yy/MM/dd HH:mm:ss} %p %c{1}: %m%n # 需要看 Spark 内部细节时单独打开 logger.spark.name = org.apache.spark logger.spark.level = warn # 排查 SQL 执行计划时临时开到 info logger.sql.name = org.apache.spark.sql logger.sql.level = warn

rootLogger.level = warn是关键,一下子就把绝大部分噪音干掉了。appender.console.target = SYSTEM_ERR让日志走错误输出流,这样你在控制台里用println打的调试信息会和 Spark 日志分开显示,找起来方便很多。

有时候需要看Physical Plan来排查性能,那就把org.apache.spark.sql开到info,再配合df.explain(true)用。用完记得改回warn,否则日志文件能涨到几百兆。这个配置文件的优先级是 classpath 上的先出现者生效,所以在 IDEA 里跑的时候,工程里的这份会覆盖 Spark jar 里带的默认配置——这也是为什么明明没配置却能看到 Spark 输出日志的原因。

4.2 Kryo 序列化:什么时候换,怎么注册

Spark 默认用 Java 序列化,好处是省心,缺点是体积大、速度慢。改成 Kryo 之后,序列化后的数据量通常能降到原来的三分之一到一半,shuffle 和缓存的开销随之下降。配置很简单:

val conf = new SparkConf() .set("spark.serializer", "org.apache.spark.serializer.KryoSerializer") .set("spark.kryo.registrationRequired", "false") .registerKryoClasses(Array( classOf[UserRecord], classOf[Array[UserRecord]] ))

这里有两个点值得说清楚。registrationRequired设成true能让 Kryo 用更紧凑的编码,但代价是所有没注册的类都会抛异常,配置成本高。我一般设false先跑通,等确认序列化是瓶颈了再逐步收紧。

registerKryoClasses这一步很多人嫌麻烦不写,确实不写也能跑,但会有性能损失——Kryo 需要把类名完整写进序列化结果里,长类名会造成明显冗余。我的做法是只注册频繁序列化的那几个业务 bean,不追求全覆盖,收益和投入比最好。

有个坑要提一下:如果自定义 bean 里有个字段是java.util.Date或者某些第三方类型,Kryo 处理这些类型的默认序列化器可能和 Java 序列化行为不一致,出现反序列化后字段为 null 的情况。遇到这种诡异问题,先试试换回 Java 序列化确认是不是 Kryo 引起的,能省下大量排查时间。

4.3 内存与 shuffle 参数:从默认 200 说起

spark.sql.shuffle.partitions的默认值是 200,这是个 2015 年定下来的数字,放到今天的设备上明显偏大。本地跑小数据集时,200 个分区意味着 200 次任务调度开销,本来一秒钟能跑完的活儿要花十几秒。本地调试阶段我一般直接设成local[*]的核数或者干脆4:

.set("spark.sql.shuffle.partitions", "4")

反过来,处理大数据集时 200 又可能太小,导致单个任务要处理的数据量过大,容易 OOM。经验值是按数据量 / 单分区目标大小来估算,单分区处理 128MB 到 256MB 是比较舒服的区间。一个 100GB 的表做 groupBy,理论分区数在 400 到 800 之间,这时候 200 个分区每个要处理 500MB,压力就上来了。

另一个常被忽略的参数是广播阈值:

.set("spark.sql.autoBroadcastJoinThreshold", "10m")

默认值就是 10MB,含义是小表小于这个尺寸时会自动广播到每个 executor,避免 shuffle。但如果你明确知道某张维表只有几百 KB,可以把阈值调大一点确保触发;如果集群 executor 内存紧张,反而要调小甚至设为-1关闭,否则广播本身可能把 executor 撑爆。这个参数没有普适值,得看具体场景。

4.4 Spark UI 与 4040 端口那些事

本地跑起来后,Spark 会在 4040 端口起一个 Web UI,这是排查性能问题最有价值的入口。但本地开发时经常碰到端口被占的情况:

Exception: Could not start the Spark UI, port 4040 already in use

原因通常是上一个任务没完全退出,或者同时跑了两个 Spark 应用。处理方式有两种,一种是改端口:

.set("spark.ui.port", "4041")

另一种是找到占用的进程干掉。我倾向于改端口,因为开发时同时开两三个应用对比结果很正常。

UI 里最值得看的是SQL标签页里的执行计划图和Stages里的 shuffle 读写量。前者能直观看到有没有发生BroadcastHashJoin还是退化成SortMergeJoin,后者能看出数据倾斜——某个 stage 里个别 task 的输入数据量远高于其他 task,那就是倾斜的典型信号。这两块信息在 IDEA 的控制台日志里是看不到的,一定要习惯打开 UI 看。

5. 常见问题与排查技巧实录

5.1 报错速查表

这几年在 IDEA 里跑 Spark 遇到的报错,翻来覆去就那么几类。整理成一张表,出问题的时候先对号入座,能省掉一半的排查时间。

报错关键字大概率根因处理动作
NoClassDefFoundError: org/apache/spark/...依赖 scope 是 provided,本地 classpath 缺包激活 dev profile 或临时改 compile
NoSuchMethodError依赖冲突,classpath 上有两个版本mvn dependency:tree -Dverbose定位
InaccessibleObjectExceptionJDK 17 未加--add-opens补齐 3.5 节的参数清单
Could not locate executable null\bin\winutils.exeWindows 缺 Hadoop 原生库配HADOOP_HOME,见 3.4 节
UnsupportedClassVersionError编译目标版本高于运行 JDK加-target:jvm-1.8
OutOfMemoryError: Java heap spacedriver 内存不足或 collect 数据过大调-Xmx,避免大结果 collect
Port 4040 already in useUI 端口冲突改spark.ui.port
Task not serializable闭包里捕获了不可序列化对象检查 lambda 里引用的外部变量
java.lang.ClassCastException: ... cannot be cast to ...Kryo 与 Java 序列化混用统一序列化器,清理 checkpoint

Task not serializable这个值得单独展开一句,因为它的报错信息往往指向一个你根本没注意的地方,比如在map里引用了外层的SparkSession或者某个连接对象。判断方法是在报错栈里找Serialization stack那一段,它会明确告诉你哪个字段不可序列化。常见修复是把需要的值提取成局部变量再传进去,而不是整个对象塞进闭包。

5.2 断点调试 Spark 源码的配置

想在 IDEA 里单步进 Spark 源码,需要两步:下载 sources 和配置断点类型。

第一步,在 Maven 面板里点Download Sources,或者直接执行:

mvn dependency:sources -DincludeGroupIds=org.apache.spark

第二步,在 Run Configuration 里确认Debugger的Transport是Socket,默认就行,不用改。然后在Run - View Breakpoints里把Any exception关掉,否则 Spark 内部的各种受控异常会把调试器打断无数次。

调试 Spark 源码时有个技巧:别在SparkContext的初始化流程里下断点,那个流程太长,一路走下去半天出不来。要定位具体问题,直接在你自己调用的 API 上打断点,比如Dataset.collect或者RDD.reduce,进去之后再顺着栈往下看,效率高得多。

如果断点怎么都进不去,多半是 IDEA 用的编译产物和 Maven 构建的 class 文件不一致。执行一次mvn clean再Build - Rebuild Project,问题基本解决。

5.3 几个只有踩过才知道的细节

说几个文档里不会写、但实际很影响效率的点。

第一个是 checkpoint 目录。本地跑带 checkpoint 的逻辑时,如果不设置目录会抛异常,或者默认写到临时目录,下次启动就找不到了。我一般在初始化时明确指定:

spark.sparkContext.setCheckpointDir("D:/tmp/spark-checkpoint")

并且每次跑之前手动清一下,否则残留的 checkpoint 数据可能让结果看起来“没更新”,实际是读到了旧数据。这个坑我踩过一次,排查了两小时才发现是缓存问题。

第二个是spark.local.dir。Spark 会把 shuffle 的中间文件写到这个目录,默认是系统临时目录。Windows 上系统盘空间经常不够,几个 GB 的 shuffle 数据就能把 C 盘塞满。设到容量大一点的盘上,能避免很多莫名其妙的No space left on device。

第三个是SparkSession的复用问题。IDEA 里连续跑同一个 main 方法时,如果不开新进程,SparkSession可能会复用之前的,导致配置不生效。Run Configuration 里勾上Allow multiple instances或者每次手动停止上一次的运行,能避开这个问题。表现是“改了配置但行为没变”,很容易让人怀疑人生。

第四个是并行度验证。写完代码后别只看结果对不对,顺手打印一下df.rdd.getNumPartitions看看分区数是否符合预期。我在一个项目里发现 join 之后分区数从 8 变成了 200,原因是中间触发了默认的 shuffle 分区数,导致后续每一步都是 200 个小任务在跑,整体慢了一大截。改了spark.sql.shuffle.partitions之后性能直接翻倍。这种问题在本地小数据量上不明显,但逻辑一旦搬到线上就是灾难。

最后分享一个我自己的习惯:给每个调试用的 Run Configuration 起一个有意义的名字,比如local-4cores-2g、local-debug-kryo,把不同的参数组合存成不同的配置。换来换去比每次改参数快得多,而且不容易记混哪个配置对应哪种场景。这个习惯看着不起眼,但在需要反复对比两种序列化器或者两种分区数效果的时候,能省下大量重复操作。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询