你有没有过这种经历:在 IDE 里点一下运行,Spark 程序跑得顺顺利利,RDD 输出整整齐齐;可一旦打成 jar 包,用 spark-submit 提交到集群,立刻就抛出一堆 ClassNotFoundException,或者任务卡在 SUBMITTED 状态半天没动静。我最早接手 Spark 开发时,在这个环节上反复折腾了三四个版本,最后才发现问题根本不在于代码逻辑,而是打包和提交方式没弄对。
这篇内容解决的场景很具体:本地运行测试、jar 包本地模式提交、jar 包集群模式提交。无论刚入门 Spark 写 WordCount 的新手,还是已经写完一个分析任务、准备把它丢到测试环境的开发者,只要能耐心跟着走一遍,基本能把“代码能跑”和“真正能交付”之间的这段路彻底打通。我不会堆概念,只讲我在实际提交过程中用过的命令、踩过的坑,以及出问题后一套可复用的排查思路。
1. 打包这件事,先把“解决什么问题”想清楚
很多人一上来就敲mvn package,然后拿着生成的 jar 去提交,以为万事大吉。但打包这件事,本质上是在回答一个很现实的问题:你分发出去的代码,到底能不能在别人的 JVM 里跑起来?
1.1 从一段最常见的报错说起:ClassNotFoundException 的真相
我接手过好几个临时救火的任务,第一屏日志几乎都是同一个东西:
Exception in thread "main" java.lang.ClassNotFoundException: org.apache.spark.sql.SparkSession或者更隐蔽一点的:
java.lang.NoSuchMethodError: scala.collection.immutable.List.map很多同事看到这个第一反应是“集群环境坏了”“Spark 没装好”,其实 90% 的情况下都猜错了根源。
先理解 Spark 任务的运行模型:driver 负责解析代码、切分任务,executor 负责真正执行计算。这两个角色通常是完全独立的 JVM,甚至在集群模式下可能分布在不同的物理机器上。你在本地 IDE 里点运行,IDE 会把你整个项目的 classpath 全部塞进同一个 JVM,所以啥都能找到。但提交 jar 包时,Spark 只会把那个 jar 分发到各个节点,JVM 不会自动带上你 pom 文件里声明的那些第三方依赖。
我把这比作“把一份菜谱快递给厨师,但没把食材一起寄过去”。代码的类就是菜谱,依赖 jar 就是食材,缺了哪一样,菜都做不出来。
1.2 薄 jar 和 fat jar 的依赖边界
所以打包时,必须先搞清楚你到底要打哪种 jar:
| 类型 | 包含哪些内容 | 典型使用场景 | 主要缺点 |
|---|---|---|---|
| 薄 jar(thin jar) | 只有你自己的 class 文件 | 集群已内置 Spark 及第三方依赖 | 对你线上环境要求极高,几乎必然缺依赖 |
| 胖 jar(fat jar) | 自己代码 + 第三方依赖一并打包 | 独立提交到 YARN、Kubernetes 等集群 | 文件体积大,可能存在同名类冲突 |
新手最容易犯的错就是把 Spark 相关的依赖也打进 jar 里。这些依赖集群节点上本来就有,你硬塞进去,反而容易和集群内置版本冲突,搞出NoSuchMethodError这种连报错都找不准的问题。
正确做法是把 Spark 相关依赖标记为provided,只在编译、测试时用,打包时不打进去。第三方库比如连接池、JSON 解析库、业务依赖,才需要打进 jar 里。
1.3 判断依赖要不要打进去的三个标准
我不太喜欢背配置,所以习惯直接用三个问题来判断:
- 这个依赖是不是 Spark 自带的?如果是,说明集群会提供,别打进 jar。
- 这个依赖是不是代码运行所必需的、但集群相对不一定有?如果是,最好打进 jar。
- 项目里有没有同一个依赖的多个版本?如果有,打包时做版本统一或重定位,否则就算打进去也会互踩。
如果项目用的是 Maven,常见配置长这样,核心就是scope=provided:
<dependency> <groupId>org.apache.spark</groupId> <artifactId>spark-sql_2.12</artifactId> <version>3.3.1</version> <scope>provided</scope> </dependency>打包时用 shade 插件,把所有非 provided 依赖合并成最终的可执行 jar。这样产出的就是一个结构相对完整的 fat jar。
2. 第一关:本地运行测试的正确打开方式
本地测试看起来很基础,但我真见过不少人在这一步卡了一两天。核心原因是:他们把所有希望都押在了“在 IDE 里跑通”,而不是“用提交命令把 jar 跑通”。这两件事完全不一样。
2.1 本地模式帮你验证什么
Spark 的本地模式其实很强大。它在单个 JVM 里同时跑 driver 和 executor,线程模拟分布式执行。对于调试数据格式、SQL 结果、UDF 逻辑,效率极高。常用的写法是:
val spark = SparkSession.builder() .appName("LocalTest") .master("local[*]") .getOrCreate()local[*]表示用本机所有 CPU 核心来跑,如果想要固定并发度,也可以写成local[2]或local[4]。
但本地模式能验证的东西有一个边界:它验证的是算法和逻辑,不是部署和分发。所谓“本地能跑”只代表你的数据处理逻辑正确,不代表你的依赖边界正确、资源参数合理。
2.2 本地测试容易翻车的环境差异
本地测试最常见的坑在于路径体系。本地模式下spark.read.text("/home/user/input.txt")是直接读本机文件;但同样代码提交到集群,driver 和 executor 不一定在同一台机器,这个路径对 executor 来说很可能不存在。所以本地测试时最好刻意养成写 URL 前缀的习惯:本地用file://,集群内用hdfs://或对象存储路径。
还有个容易被忽略的点是文件权限。本地你用自己的账号跑,文件随便读写;到了集群,执行用户可能是另一个账号,目录权限不对,任务会在PartitionDiscovery或者FileSystem阶段卡很久,然后抛权限异常。所以本地测试时我一般会把输入输出目录权限设置成755/644这类宽松一点的,提前模拟出集群里的最低要求。
2.3 从“在 IDE 里跑”切换到“用打好的 jar 跑”
这里是我最想强调的:本地测试的最终形态,一定是在命令行里用 spark-submit 把打好的 jar 提交起来跑一遍,而不是在 IDE 里右键运行。
原因很简单:IDE 运行会把开发环境的整个 classpath 喂进去,掩盖掉很多打包问题。而命令行提交时,你只有一个 jar,系统读到多少就是多少,缺了依赖立刻现原形。这一步虽然看起来多此一举,但能早点暴露后面集群提交才可能出现的问题,代价极低。
3. 打好的 jar 怎么在本地模式下提交:一条命令背后发生了什么
本地模式下提交 jar,实际上是我在整个交付流程里最常用的快速验证手段。它比 IDE 运行更接近真实部署场景,又比集群提交快得多、好调试得多。
3.1 一行命令把 jar 提交到本地模式跑起来
假设你打好了user-job-1.0.jar,主类全名是com.example.demo.WordCountJob,典型的本地提交命令长这样:
spark-submit \ --class com.example.demo.WordCountJob \ --master local[4] \ --driver-memory 2g \ target/user-job-1.0.jar \ file:///home/user/data/input.txt \ file:///home/user/data/output这段命令的含义是:让 Spark 以本地 4 个并发线程的规模启动一个应用,driver 内存在当前 JVM 中最多分配 2G,然后把user-job-1.0.jar作为唯一应用包,传给主类的main方法。spark-submit后面的两个路径会被当作普通参数传给程序,而不是 Spark 的配置项,这一点很多人一开始会搞混。
这条命令背后,Spark 会在你本地启动一个 JVM 进程,driver 跑起来后,又用线程模拟出若干个 executor,所以stdout里能直接看到 executor 的日志。好处是出错了你直接看屏幕就行,不用去集群翻聚合日志。
3.2 本地提交能暴露哪些 IDE 里看不见的问题
我用这种方式,抓到过至少三类问题:
第一类是资源路径写死。比如代码里写死了/tmp/data.json,IDE 跑的时候因为当前工作目录是项目根目录,凑巧能读到;但命令行提交时,工作目录和 IDE 里的并不一定一致,路径解析直接失败。
第二类是序列化类没注册。本地模式下某些 RDD 操作内置的序列化器能兜底,命令行提交后虽然还在本地,但配置环境更干净,NotSerializableException反而更容易露出来。
第三类是fat jar 里的同名依赖冲突。IDE 启动时 classpath 顺序是 IDE 控制的,命令行提交时则是 jar 内部的加载顺序,可能之前一直被 IDE 藏住的重复类突然冒出来。
所以千万别小看这一步,它是所有“预演”里最接近真实部署、又最低成本的一环。
3.3 我常用的本地提交验证清单
每次在本地提交 jar 验证时,我都会过一个固定清单:
- 应用退出码是否为 0,日志中是否有 ERROR 或 Exception。
- 输出目录是否生成,文件条数是否符合预期。
- 是否出现依赖类相关报错,比如
ClassNotFound、NoSuchMethod。 - 性能是否明显异常,比如数据量很小却执行了很久。
- 连续跑第二遍是否仍然稳定,排除临时文件残留造成的偶发问题。
如果清单全绿,我才会放心把同一个 jar 提交到集群。
4. 集群模式提交:jar 包要过几道门槛
到了集群提交这一步,才是真正考验“工程交付”能力的地方。很多代码本地能跑,上集群就“薛定谔地跑”,主要原因是集群模式下的进程分布、资源约束、日志位置,和本地完全不是一个概念。
4.1 client 与 cluster 两种部署模式,到底是哪里不一样
集群提交的第一件事是选--deploy-mode。最常用的两种:client和cluster。
| 模式 | driver 运行在哪里 | 日志在哪里 | 适合场景 |
|---|---|---|---|
| client | 提交命令所在的那台机器 | 基本直接输出到提交终端 | 调试、小任务、交互式分析 |
| cluster | 集群内部(通常是 ApplicationMaster 容器) | 需要到 YARN 日志聚合或 Spark History Server 去翻 | 生产任务、定时调度、需要漂移的场景 |
我的经验是:调试用 client,生产用 cluster。client 模式下 driver 在你眼前跑,异常信息一目了然,适合调试;cluster 模式下 driver 搬进集群,崩溃了会自动根据策略尝试重试,适用生产。如果生产上用了 client 模式,提交任务的机器一断网或重启,driver 也跟着没了,任务直接中断,这在调度场景下极其危险。
4.2 一份可以直接抄作业的集群提交命令
以 YARN 集群为例,一个典型的 cluster 模式提交大概长这样:
spark-submit \ --class com.example.demo.WordCountJob \ --master yarn \ --deploy-mode cluster \ --driver-memory 2g \ --executor-memory 4g \ --executor-cores 2 \ --num-executors 20 \ --conf spark.yarn.queue=default \ --conf spark.serializer=org.apache.spark.serializer.KryoSerializer \ hdfs:///user/me/jars/user-job-1.0.jar \ hdfs:///user/me/data/input \ hdfs:///user/me/data/output我先解释几个容易被误解的参数:
--num-executors:申请多少个 executor 容器。别拍脑袋写,先看队列资源配额,比如队列能用的总内存是 100G,你每个 executor 4G,那最多约 25 个,但还要算上 driver 和系统 overhead。--executor-memory:单个 executor 的内存。Spark 实际向 YARN 申请的内存会在这个值之上额外加一部分 overhead,默认约为max(384MB, 10% × executor-memory)。如果你的值卡着资源上限,很容易提交后一直 ACK 但是起不来。--conf spark.yarn.queue:指定 YARN 队列,不同团队通常有不同的队列,乱提交可能会被权限拒绝或者抢不到资源。
另外注意,jar 包路径我写成了hdfs:///user/me/jars/user-job-1.0.jar。生产环境这样最稳,因为所有节点都能从 HDFS 读到该包;如果只写本地路径file:///...,只有提交机自己能读到,executor 在其他机器就会拿不到代码。实际原理是 YARN 会把 HDFS 上的 jar 分发到每个容器的工作目录,所以共享存储路径非常关键。
4.3 提交后怎么判断任务真的启动没
很多新手提交后盯着屏幕发呆,不知道任务到底有没有起来。我的通用判断步骤是:
- 先看命令行反馈。如果提交后打印了
applicationId,说明 YARN 已经接收了应用。 - 用
yarn application -status application_xxx查看状态,关键看tracking URL和state。 - 打开
tracking URL指向的 Spark Web UI,看有没有出现 job、stage、executor 信息。看到RUNNING状态但没有任何 job,说明应用还在初始化,不一定是卡住。 - 如果状态一直是
ACCEPTED,多半是资源没分配到,去查队列余量和各节点资源使用情况。
这一整套动作下来,基本能从黑暗的日志丛林里找到一条清晰的路径。
5. 我踩过的坑:打包与提交阶段最容易翻车的几个环节
最后这部分不写教程式流程,只记录我真实踩过、帮别人擦过屁股的几个高频坑,每个都附上排查链路。
5.1 本地能跑、集群一上就 ClassNotFound 的排查全链路
有一次某任务的 jar 在本地模式提交一切正常,cluster 模式一下去就报ClassNotFoundException: com.example.common.MyHelper。我当时没有直接去调配置,而是按这条链路排:
- 先确认出的 jar 里有没有这个类。命令:
jar tf user-job-1.0.jar | grep MyHelper。如果连包都没进,那就是打包时漏了依赖,或者该依赖被provided挡住了。 - 如果 jar 里确实有,再用
unzip -l或者解压出来看目录结构,检查是不是有多层目录嵌套问题,比如 class 路径从com/example变成BOOT-INF/classes/com/example。这种情况常见于用 Spring Boot 插件打的包,Spark 认不出来。 - 再查 classpath 差异。用
mvn dependency:tree看本地环境解析出来的依赖树,比照集群 Spark 内置版本。 - 最后尝试手动指定 classpath 打印,在驱动里临时加一行把
System.getProperty("java.class.path")输出到日志,对比节点上的实际内容。
排查的核心不是“网上搜个参数”,而是先搞清楚类是没进 jar,还是进了 jar 但没被加载。
5.2 依赖冲突的根源:一个大 jar 里的多份同名类
fat jar 也不是万能的。最大的坑是同一个类在 jar 内出现了多个版本,加载到哪个完全取决于 classpath 扫描顺序,因此在不同节点上表现还可能不一致。
我遇到过一次典型的 Scala 版本错位:项目里用的spark-sql_2.12打成 provided,但业务代码里有一个工具库依赖了 Scala 2.11 的某个类,最终 fat jar 里混进了两套 Scala 标准库的同名类。结果是本地跑没事,集群上部分 executor 一执行就挂,报错还是最阴间的NoSuchMethodError,因为编译器按 2.12 生成的调用签名,在 2.11 的类库里找不到对应方法。
解决思路是两件事同时做:第一,确认 Scala 版本和 Spark 发行版一致;第二,对真正冲突的第三方库做重定位。用 shade 插件时,可以把某个库挪到内部命名空间:
<relocation> <pattern>com.google.common</pattern> <shadedPattern>shaded.com.google.common</shadedPattern> </relocation>重定位之后,你的 jar 和集群内既有依赖就不会互相踩踏,但重定位只适合第三方库,绝不要对 Spark 自身做这种操作,否则你就是在制造一个新的坑。
5.3 提交参数里的隐性陷阱:内存、并行度与日志位置
很多任务起不来的原因不在代码,在参数。我最常见到的问题是内存估得太细,导致 executor 数量被压得很低。比如一个任务看起来只需要 2G 内存,但真实跑起来有大量落地中间结果,2G 根本不够,频繁 GC 导致任务跑了几个小时没结束。
我的应对套路是:先保守提--executor-memory 4g,跑一个数据子集验证时间,再按数据量线性放大。不要一开始就追求最优参数,先把任务“跑通”再“跑快”。
并行度方面,--num-executors和--executor-cores不是越大越好。executor 数量太多,每个 Executor 分到的数据块太碎,调度反而有额外开销;单 executor 内核数太多,又会把内存切分得乱七八糟。通常一个 executor 2-4 核比较实在,但具体数字,还是要以实测为准。
还有一个隐蔽问题:cluster 模式下日志不回到提交终端。你以为任务挂了,其实它可能在集群里正常跑,只是日志去了聚合目录或者 History Server。找日志第一站是 YARN 的log aggregation,不是提交命令的窗口。我见过有人为了“看日志”,硬把生产任务改成 client 模式,结果提交机一断网,整个线上任务就中止了,这个操作一定要避免。
写到这里,说点最真实的体会:从“在 IDE 里跑通”到“在集群上稳定跑完”,中间隔着的不是一步,而是一整套工程习惯。先把依赖边界划清楚,再老老实实把 jar 在本地模式提交一遍,最后才往集群送,是我这两年最想推荐给所有人的顺序。别怕慢,这一步省掉的时间,远比你线上踩坑之后再回头发狠要少得多。