ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spark 3.2.0预编译包部署与Standalone/YARN提交调优实战

Spark 3.2.0预编译包部署与Standalone/YARN提交调优实战

简介:spark-3.2.0-bin-hadoop3.2.tgz 是面向大数据开发、数据分析与机器学习实践者的 Spark 3.2.0 预编译发行包,适配 Hadoop 3.2 环境,可用于快速搭建本地或集群实验环境、运行官方示例并查阅源码与配置。压缩包共 1476 个文件,约 287.02MB,包含大量 py、jar、scala、java 文件,分别对应 PySpark 接口、运行依赖库、Scala/Java 源码与示例;同时配有 txt、rst 文档、sh/cmd/bat 启动脚本及 parquet、avro、orc 等样例数据,便于按模块检索。已有 1126 人学习下载,适合需要理解 Spark Core、SQL、Streaming、MLlib 与 GraphX 组件,或进行性能调优、Kubernetes 部署验证的开发者参考。解压后可直接体验完整目录结构与提交、Shell、SQL 等命令行工具,为环境搭建和二次开发提供较完整的基线材料。

1. spark-3.2.0-bin-hadoop3.2.tgz 这个文件名其实说明了全部信息

spark-3.2.0-bin-hadoop3.2.tgz 这串字符拆开看就三件事:Spark 3.2.0 的发行版、Scala 2.12 编译的二进制、客户端依赖锁定在 Hadoop 3.2 这条线上。它是官方打好的预编译包,解压、配两个环境变量、敲一条 spark-submit 就能跑,不需要装 Maven、不需要拉 Scala 编译器、不需要对着源码构建脚本折腾。

真正需要它的人,多半不是来学 Spark 语法的,而是要在还能连上老集群的机器上,快速立起一套既能跑 Standalone 又能提交 YARN 的运行时:做数据清洗、跑批、验证一段 SQL 逻辑,或者在离线环境里给团队复现某个报错。它省掉的是编译和版本对齐这两道工序,代价是你得接受它锁死的版本范围——JDK 8 或 11、Scala 2.12、Hadoop 3.2 客户端,越过这条线一步,后面全是需要处理的兼容问题。

2. 从解包到第一条命令:把 spark-shell 在 local[4] 上跑通

2.1 解包后先认清这几个目录各自管什么

很多人解压完直接bin/spark-submit一把梭,出了错才回头翻目录,效率很低。这套包的结构其实非常规整,几分钟理清能省掉后面几小时的瞎猜:

目录里面装的是什么什么时候会碰它
bin/spark-submit、spark-shell、pyspark、spark-sql、spark-class每次提交作业
sbin/start-master.sh、start-worker.sh、start-history-server.sh、start-thriftserver.sh起 Standalone、起 History Server
conf/一堆.template模板文件第一次部署
jars/Spark 自身 jar + Hadoop 3.2 客户端 jar排 jar 冲突、查依赖
examples/jars/官方示例 jar冒烟测试
python/pyspark 的 Python 侧代码和 zip 包指定 PYSPARK_PYTHON 时
data/几份小样本数据(含 README.md)写第一条测试作业

关键点在于conf/里全是.template后缀的模板,把spark-env.sh.template改名为spark-env.sh、把spark-defaults.conf.template改名为spark-defaults.conf,才会被启动脚本读到。没有后者的文件名就是不会生效,不报任何错。

解包本身没什么技巧,但建议养成用软链接指向版本目录的习惯,将来升级到 3.3、3.4 时只换软链,集群上写死的路径不用动:

# 解包到统一目录;tgz 顶层目录本身就是 spark-3.2.0-bin-hadoop3.2 mkdir -p /opt/spark tar -xzf spark-3.2.0-bin-hadoop3.2.tgz -C /opt/spark # 建软链,后续所有配置只认 current ln -sfn /opt/spark/spark-3.2.0-bin-hadoop3.2 /opt/spark/current ls -l /opt/spark/current/bin/spark-submit

上面这段里-C是指定解压目标目录,-sfn中的n表示把已有软链当普通文件覆盖,避免重复执行时报“File exists”。解完之后ls一下bin/,确认spark-submit、spark-shell、spark-class都在,包就是完整的。

2.2 conf 目录下两个模板必须复制,环境变量只认 spark-env.sh

spark-env.sh本质是一个被sbin/*.sh用source加载的 bash 脚本,它只负责“进程启动时的环境”,不负责 Spark 运行时参数;而spark-defaults.conf是 SparkConf 读的配置。这两类东西混写是新手最常见的翻车点:把spark.executor.memory写进spark-env.sh会静默失效,日志里一句提示都没有。

#!/usr/bin/env bash # conf/spark-env.sh —— 只放环境变量,别写 spark.xxx 配置项 export JAVA_HOME=/usr/lib/jvm/java-8-openjdk-amd64 export SPARK_HOME=/opt/spark/current export SPARK_CONF_DIR=/opt/spark/current/conf export HADOOP_CONF_DIR=/etc/hadoop/conf # 多网卡机器必须显式指定,否则可能注册到 docker0 或虚拟网卡地址 export SPARK_LOCAL_IP=192.168.10.11 export SPARK_LOG_DIR=/var/log/spark export SPARK_PID_DIR=/var/run/spark export PYSPARK_PYTHON=/usr/bin/python3.8

这段里最值得说的是两个变量。SPARK_LOCAL_IP在多网卡环境下如果不定,driver 向 master 注册的地址可能是虚拟网桥的地址,表现就是 worker 一直在Launching executor然后超时,UI 上 executor 数量永远是 0,翻 executor 日志能看到Connection refused连的是个莫名其妙的网段。PYSPARK_PYTHON则是给 pyspark 用的,集群上系统自带 Python 版本五花八门,不显式钉住,driver 和 executor 用的解释器版本不一致,pandas_udf 那一层会直接抛类型不匹配。

另一个隐蔽的细节:spark-env.sh里的变量必须带export。启动脚本source完它之后是用 fork 的方式拉起 JVM 的,没 export 的变量只存在于当前 shell,子进程读不到,表现为“我明明写了 JAVA_HOME,还是报找不到 Java”。

spark-defaults.conf我一般只放全局默认值,作业级参数一律走命令行,方便回溯:

# conf/spark-defaults.conf spark.master spark://192.168.10.11:7077 spark.serializer org.apache.spark.serializer.KryoSerializer spark.eventLog.enabled true spark.eventLog.dir hdfs://ns1/spark/eventlog spark.sql.adaptive.enabled true spark.sql.adaptive.coalescePartitions.enabled true spark.sql.shuffle.partitions 400 spark.yarn.jars hdfs://ns1/spark/jars/*

spark.eventLog.enabled建议一上来就开,后面所有“当时到底跑了什么”的疑问,都靠 event log 加 History Server 来回答,这是唯一的追溯手段。spark.yarn.jars指到 HDFS 上,是为了避免每次提交都把几百兆 jar 重新上传一遍——第一次跑的时候把jars/*传上去即可。

2.3 用 spark-submit 跑通一个能验证链路的 WordCount

验证包是否可用,不要用spark-shell敲两行sc.parallelize(1 to 10).count()就完事,那只验证了 JVM 起来了。要验证的是读文件、shuffle、写结果整条链路。写个最小脚本:

# wordcount.py —— 放在 $SPARK_HOME 下的任意目录都行 import sys from pyspark.sql import SparkSession from pyspark.sql import functions as F def main(src: str, dst: str) -> None: spark = (SparkSession.builder .appName("wordcount-smoke") # 本地模式也要限制 shuffle 分区,否则 200 个空分区拖慢启动 .config("spark.sql.shuffle.partitions", "8") .getOrCreate()) df = spark.read.text(src) out = (df.select(F.explode(F.split(F.col("value"), r"\s+")).alias("word")) .where(F.col("word") != "") .groupBy("word") .count() .orderBy(F.col("count").desc())) # coalesce(1) 只为方便 cat 结果,生产上不要这么干 out.coalesce(1).write.mode("overwrite").csv(dst, header=True) spark.stop() if __name__ == "__main__": main(sys.argv[1], sys.argv[2])

提交命令用local[4],四个线程模拟四个 slot,能真实触发 shuffle 和并发调度:

cd /opt/spark/current ./bin/spark-submit \ --master local[4] \ --driver-memory 2g \ --conf spark.ui.port=4040 \ --conf spark.sql.shuffle.partitions=8 \ wordcount.py \ data/README.md \ /tmp/wc_out

参数逐个说清楚:--master local[4]是本地四线程,不用起任何 daemon;--driver-memory在 local 模式下就是这唯一的 JVM 堆大小,给 2g 足够;spark.ui.port=4040是 Web UI 端口,被占用时 Spark 会自动往后找 4041,但如果你在多台机器上同时跑,端口漂移会让 History Server 里的链接对不上,所以固定下来更省心;最后两个位置参数分别传给脚本的src和dst。

跑完之后ls /tmp/wc_out应该看到_SUCCESS和一个part-00000-*.csv,head一下能看到词频最高的几行。如果这一步卡住不动、UI 上 stage 一直停在running而 task 数为 0,八成是data/README.md的相对路径没对上——spark-submit的工作目录是提交时所在的目录,不是$SPARK_HOME,用绝对路径最稳。

再用--version确认这套包自报的身份,顺手把 Java 和 Scala 版本记下来,后面排查依赖冲突时全靠它:

./bin/spark-submit --version # 输出里重点看三行:Spark version 3.2.0 / Scala version 2.12.x / Java version 1.8.x

3. Standalone 与 on YARN:两种 master 的资源配置与提交参数

3.1 Standalone 的 master/worker 拆分与核数内存怎么切

Standalone 是这套包里最轻的集群形态,master 只做资源调度和状态维护,几乎不吃资源;worker 才是真正跑 executor 的进程。起法就三条命令,但要起对,得先把conf/workers和spark-env.sh里的资源值捋清楚。

# master 节点 ./sbin/start-master.sh # 会监听 7077(RPC)和 8080(Master UI) # worker 节点:指向 master 的 RPC 地址 ./sbin/start-worker.sh spark://192.168.10.11:7077 # 多台 worker 时,把主机名写进 conf/workers,然后在 master 上一次性拉起 cat conf/workers # node02 # node03 # node04 ./sbin/start-workers.sh

3.2 的sbin/下已经是start-worker.sh,老的start-slave.sh还在但已经标了弃用,脚本里会打一行 warning,不影响运行但别在新脚本里继续用。

资源切分的关键在spark-env.sh里的三个变量:

export SPARK_WORKER_CORES=28 # 32 核机器留 4 核给系统和其它服务 export SPARK_WORKER_MEMORY=110g # 128G 机器留 18G,别顶满 export SPARK_DAEMON_MEMORY=2g # master/worker 自身 JVM 的堆

SPARK_WORKER_MEMORY上报的是“可分配给 executor 的总内存”,worker 自己还要活,OS 还要 page cache 给 shuffle 文件读写,顶满上报的结果是 executor 分到了内存但机器开始 swap,表现是 task 跑得极慢、GC 时间占比飙到 40% 以上。血泪经验是留 15%~20% 的余量。

Standalone 下提交作业,cluster 模式需要 driver 也跑在 worker 上,参数和 client 模式差别不大,但--supervise值得加:

./bin/spark-submit \ --master spark://192.168.10.11:7077 \ --deploy-mode cluster \ --supervise \ --name wc-standalone \ --driver-memory 2g \ --driver-cores 1 \ --executor-memory 8g \ --executor-cores 4 \ --num-executors 6 \ --conf spark.executor.memoryOverhead=1g \ wordcount.py hdfs://ns1/input/wc /tmp/wc_out

--supervise的作用是 driver 异常退出后由 worker 自动重启,适合长跑作业;一次性跑批反而不要加,因为失败重试会把真正的错误淹没在重启日志里。--num-executors 6在 Standalone 下是硬上限,worker 资源不够时会排队等,UI 上表现为WAITING状态的 app 一直挂着,不是卡死。

3.2 提交到 YARN:hadoop3.2 预编译包替你省掉的兼容工作

bin-hadoop3.2这个后缀的含义是:包里那批 Hadoop 客户端 jar 是 3.2 版本的,和 Hadoop 3.x 集群的 RPC 协议、文件系统接口对得上。你不需要自己往jars/里塞集群的 hadoop-client,也不用操心 Guava、Netty 的版本打架——官方在打包时已经对齐过一轮。这是这个包最大的实用价值。

如果集群是 Hadoop 2.7 那条线,这个包别硬上,会以NoSuchMethodError、NoClassDefFoundError或者文件系统初始化失败的形式还债,正确做法是换bin-hadoop2.7那个包。

提交到 YARN 之前,HADOOP_CONF_DIR必须指到集群配置目录,让 Spark 能读到core-site.xml、hdfs-site.xml、yarn-site.xml:

export HADOOP_CONF_DIR=/etc/hadoop/conf ./bin/spark-submit \ --master yarn \ --deploy-mode cluster \ --name etl-orders-daily \ --queue root.etl \ --driver-memory 2g \ --driver-cores 1 \ --executor-memory 8g \ --executor-cores 4 \ --conf spark.executor.memoryOverhead=2g \ --conf spark.dynamicAllocation.enabled=true \ --conf spark.dynamicAllocation.initialExecutors=6 \ --conf spark.dynamicAllocation.minExecutors=4 \ --conf spark.dynamicAllocation.maxExecutors=40 \ --conf spark.dynamicAllocation.executorIdleTimeout=90s \ --conf spark.shuffle.service.enabled=true \ --conf spark.eventLog.enabled=true \ --conf spark.eventLog.dir=hdfs://ns1/spark/eventlog \ --conf spark.yarn.historyServer.address=http://192.168.10.11:18080 \ hdfs://ns1/jobs/etl.jar \ hdfs://ns1/ods/orders \ hdfs://ns1/dwd/orders

几个参数值得单独解释。--queue要和 YARN 调度器里真实存在的队列名一致,写错的报错是Queue root.xxx not found,但常常被后面的Application submission failed盖住,要往上翻。spark.dynamicAllocation.enabled=true打开之后,--num-executors会被忽略,实际并发由initialExecutors起步、按 backlog 伸缩,这是最容易误解的一点——很多人以为两个都写会取较小值。

spark.shuffle.service.enabled=true是动态分配的前提:executor 被回收后,它写过的 shuffle 数据如果不在外部 shuffle 服务里,下游 stage 重算时会抛FetchFailedException然后整段 lineage 重跑。这个开关要求 NodeManager 上已经配好对应 aux-service,集群侧没开的话,这个参数就是自欺欺人。

3.3 内存模型与参数表:容器真实占用是 executor + overhead

YARN 调度看的是容器总内存,等于spark.executor.memory加spark.executor.memoryOverhead,不是前者。这个认知差是绝大多数“物理内存没超但容器被杀”的根源。

参数默认值常见改法影响面
spark.executor.memory1g6g~12gJVM 堆,执行内存 + 缓存内存都从这里切
spark.executor.memoryOverheadmax(384m, 10%)堆的 15%~20%堆外:Netty direct buffer、Python worker、native 库
spark.executor.cores14~5每个 core 一个并发 task;超过 5 核写 HDFS 吞吐反降
spark.memory.fraction0.60.6执行+存储占堆比例,调大挤掉用户数据结构
spark.memory.storageFraction0.50.4~0.5缓存区不被执行内存挤出的比例
spark.sql.shuffle.partitions200总核数 × 2~3shuffle 分区数,AQE 开启后会二次合并
spark.sql.adaptive.advisoryPartitionSizeInBytes64m64m~128mAQE 合并分区的目标大小

算一笔账就明白为什么“核越多越好”不成立。一台 64G 内存、32 核的节点,NodeManager 分配 56G 内存、28 核。如果按 8g + 2g = 10g 一个容器,最多 5 个 executor,每个 4 核,用掉 20 核,还剩 8 核闲着——内存先到瓶颈,核被浪费。反过来按 4g + 1g = 5g 一个容器,能起 11 个 executor,但每个只有 4 核就超了;实际要按min(内存能塞几个, 核数/每executor核数)去配,让两边同时打满才是合适的。

Executor 核数我一般建议 4~5。超过 5 核的单个 executor,HDFS 写路径上的并发连接数和 GC 压力都会上升,实际吞吐不一定比两个 4 核 executor 好,而且一个 executor 挂了损失的 task 更多,重算代价更大。

4. 避坑清单:五个报错从现象到根因的排查路径

4.1 报 Unsupported class file major version

现象:spark-submit起来几秒就退出,栈里出现java.lang.UnsupportedClassVersionError或Unsupported class file major version 61。

原因:系统 PATH 上默认的java是 JDK 17,而 3.2 这条线支持的是 JDK 8 和 11。spark-env.sh里的JAVA_HOME没写,或者写了但被 shell 里更靠前的 PATH 覆盖。用./bin/spark-submit --version一眼就能看到实际用的是哪个 Java。

解决:显式钉住JAVA_HOME,并且确认--version输出对得上;如果用 JDK 11,务必通过bin/下的启动脚本启动,脚本会自动补上--add-opens那一串参数,绕开脚本自己用java -cp拼命令行的,会在反射访问上直接失败。

4.2 报 Wrong FS,路径被当成本地文件

现象:java.lang.IllegalArgumentException: Wrong FS: hdfs://ns1/input, expected: file:///。

原因:Spark 进程没读到集群的core-site.xml,文件系统实例还是本地默认的file:///。这通常发生在HADOOP_CONF_DIR只在当前 shellexport了,但通过sbin/start-*.sh拉起的 daemon 没继承到;或者这个变量压根没写进spark-env.sh。

解决:两条路,任选其一并确认生效——把export HADOOP_CONF_DIR=...写进spark-env.sh(daemon 也能继承),或者把core-site.xml、hdfs-site.xml软链到$SPARK_HOME/conf下。改完用spark-shell里spark.read.text("hdfs://...").count()验证,别直接又提大作业。

4.3 报 Task not serializable

现象:作业在第一个map/filter就炸,org.apache.spark.SparkException: Task not serializable,栈顶指向你写的那个匿名函数。

原因:闭包捕获了 Driver 端不可序列化的对象——最常见的是把SparkSession、数据库连接池、日志实例、外部类的成员变量带进了 lambda。Scala 的闭包是整体序列化的,只要引用到就一起打包。

// 反例:闭包里直接用了外层的 spark 和成员变量 conn class BadJob(spark: SparkSession) { private val conn = DriverManager.getConnection("jdbc:...") // 不可序列化 def run(ds: Dataset[String]) = ds.map(s => conn.query(s).toString) // 提交即炸 } // 正例:只捕获可序列化的值,重资源在 executor 侧按分区创建 val threshold = 100 ds.mapPartitions { it => val client = new HeavyClient() // 每个分区建一次,分区内复用 try it.map(x => client.score(x) > threshold) finally client.close() }

排查手法:看异常栈里Serialization trace那一段,它会直接列出是哪个字段不可序列化;实在找不到,就给可疑成员加@transient lazy val,让它不参与序列化。

4.4 容器被杀,但 JVM 堆明明没满

现象:YARN 日志里Container killed by YARN for exceeding memory limits. 12.3 GB of 12 GB physical memory used,退出码 143。

原因:容器上限是executor.memory + memoryOverhead,而 overhead 默认只有堆的 10%(下限 384m)。PySpark 的 Python worker 进程、Netty 的 direct buffer、Parquet 解压用的 native 内存,全都从这 10% 里出。写 Parquet、跑 pandas_udf 的作业特别容易撞这条线。

解决:把spark.executor.memoryOverhead提到堆的 15%~20%;PySpark 再单独设spark.executor.pyspark.memory给 Python worker 划一块。另外注意spark.memory.offHeap.enabled=true时,off-heap 内存也是从 overhead 里扣的,别把 overhead 又按 10% 算。

4.5 shuffle 阶段 FetchFailed 或磁盘写满

现象:org.apache.spark.shuffle.FetchFailedException: Failed to connect to node02:7337,或者 executor 日志里java.io.IOException: No space left on device。

原因:三种情况最常见——shuffle 分区数太多导致每个分区文件都小但文件数爆炸;spark.local.dir只挂了一块盘,shuffle 写满;动态分配把 executor 回收了,下游取不到中间数据又没有外部 shuffle 服务兜底。

解决:先开 AQE 让分区自动合并(3.2 里默认已经开,别手动关);spark.local.dir指向多块盘,用逗号分隔,Spark 会轮询写;确认spark.shuffle.service.enabled与集群 aux-service 都已配置,两者必须同时打开才有意义。

5. 读写与调优:AQE、Kryo 与小文件合并的落地参数

5.1 内置 Hive 支持与分区覆盖的正确写法

预编译包自带 Hive 支持,enableHiveSupport()之后能直接读写 Hive 表,元数据默认用内置的 Derby。但要落地到集群,必须先把 warehouse 目录指到 HDFS 上,否则它会老老实实往提交机器上的file:/user/hive/warehouse写,数据散落一地,第二天才发现。

from pyspark.sql import SparkSession spark = (SparkSession.builder .appName("orders-dwd") .config("spark.sql.warehouse.dir", "hdfs://ns1/warehouse") # 关键:动态分区覆盖,只覆盖写入涉及的分区,而不是清空整表 .config("spark.sql.sources.partitionOverwriteMode", "dynamic") .config("spark.sql.hive.metastore.version", "2.3.9") .config("spark.sql.hive.metastore.jars", "maven") .enableHiveSupport() .getOrCreate()) (spark.read.parquet("hdfs://ns1/ods/orders") .where("dt between '20240101' and '20240131'") .write.mode("overwrite") .partitionBy("dt") .parquet("hdfs://ns1/dwd/orders"))

partitionOverwriteMode=dynamic是这段里最重要的一行。默认的static模式下,mode("overwrite")会把整张表的数据清掉再写,一旦上游只补了一天数据,历史分区全没了,这种事故我见过不止一次。改成dynamic后,只有本次写入涉及的分区被替换。

where("dt between ...")这种字符串条件能触发分区裁剪,前提是分区列的类型和字面量对得上;如果dt是 int 而写成字符串,裁剪会失效,plan 里会看到PartitionFilters: [],全表扫描。

连远端 Hive metastore 时,spark.sql.hive.metastore.version要和远端一致,metastore.jars=maven会让 Spark 去拉对应版本的 metastore 客户端 jar,离线环境要改成builtin或指向本地 jar 目录,否则启动时会卡在下载上,报连接超时。

5.2 AQE 的三个开关:合并分区、倾斜 Join、本地 Shuffle 读取

3.2 这条线里spark.sql.adaptive.enabled默认已经是 true,很多人以为自己没开 AQE,其实一直在

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进