☰
Standalone集群实战:PySpark作业提交与spark-submit调度
2026/10/23 13:13:48 网站建设 项目流程

这篇日志写在2022年2月初,离我把Python和数据分析基础过完大概一个月。当时想往大数据方向走,身边所有人的建议几乎一致:先学PySpark。于是春节假期后的第一件事,就是把Spark环境和Standalone集群跑起来。真正动手之后才发现,"Spark快速入门"这几个字网上到处都是,但能把Standalone集群从零搭好、提交第一个作业、再搞清楚作业在集群里到底怎么被调度执行的,中间隔着一大堆教程不会明说的细节。这篇文章就把我这一天的学习过程完整复刻出来:环境版本怎么搭配、集群参数怎么算、第一个PySpark作业怎么写、spark-submit怎么提交,以及我在排查"任务一直Pending"时走过的弯路。适合刚学完Python准备入门大数据的人,也适合已经在用local模式但一直没上过集群的读者。

1. 学Spark之前,先把Standalone模式的位置想清楚

1.1 为什么我的第一个入口是PySpark而不是Scala

在决定入门框架时,我其实纠结过要不要直接学Scala。后来权衡下来选了PySpark,核心原因很现实:我日常写逻辑用的都是Python,换到PySpark之后,RDD、DataFrame这些数据结构对我来说只是换了一批新API,而不是换一门语言。这里要说明一下,Spark的核心执行引擎是JVM上的那一套,PySpark只是借助一套进程间通信机制,把Python写的算子翻译给JVM去执行,所以底层调度、容错、内存管理这些概念,用PySpark学习和用Scala学习是完全等价的。

另外一点是,PySpark本身在工业界早就不是"简化玩具"了。很多团队做离线特征、数据加工,就是直接拿PySpark跑生产任务。也就是说,我花时间学的这套东西,后面真正工作也用得上,不存在"先学个简单的、以后还得推翻重学"的问题。对当时只有Python基础、首门编译型语言都还没啃的我来说,PySpark明显是投入产出比最高的选择。

1.2 Local、Standalone、YARN、K8s四个模式要按这个顺序选

Spark启动时有个--master参数,它的值决定了你的任务跑在哪种资源调度环境里。最常见的四个值:local[*](本地线程)、spark://master:7077(Standalone集群)、yarn(YARN资源管理器)、k8s://(Kubernetes集群)。这四个不是平级的替代品,学习顺序也很有讲究。

local[*]只是单个JVM进程里用多线程模拟并行,适合写代码和单机调试,但它完全不涉及跨进程、跨机器的资源调度。Standalone是Spark自带的集群管理器,不需要预先装Hadoop,启动一个Master进程和若干Worker进程就能组成一个最小集群,分布式调度、Executor分配这些核心概念全都有,入门阶段用它学成本最低。YARN是工业界最常见的生产环境之一,但它要依赖Hadoop生态,组件多、排错链路长,不适合拿来当第一个实验环境。Kubernetes更不用说,容器编排那一套还没搞明白就贸然往上叠加,很容易劝退。

我的建议很明确:先local模式把API写熟,再上单机版Standalone集群把主从关系搞懂,然后扩展到多节点,最后有必要再接触YARN。标题里这个Standalone集群,恰恰就是这条路上最值得认真走的一步。

2. 环境准备:Java、Python与Spark的版本三角关系

2.1 版本组合怎么定,为什么要用这个组合

先说结论,我2022年2月选定的组合是:JDK 8、Python 3.8、Spark 3.2.1。这个组合不是随便拼的,每个选择都有原因。

组件版本选择原因
JDK8Spark 3.2支持Java 8/11,JDK 8生态资料最全、运行期坑最少
Python3.8PySpark官方支持3.6-3.9,3.10刚发布不久,第三方库兼容性还不稳
Spark3.2.12022年1月发布的稳定版,修掉了3.2.0的一些已知问题

有个新手很容易忽略的事实:即使你全程写Python,机器上依然必须装JDK。原因是Spark的Driver和Executor本质上是JVM进程,PySpark进程和JVM进程之间要通过通信机制交换数据和指令,没有JVM,PySpark根本跑不起来。所以"我会Python就可以不用装Java"这个想法趁早丢掉。选版本时也别一味的"追新",大数据生态里版本兼容性比新特性重要得多,稳定组合才能少踩莫名其妙的坑。

2.2 解压即用:目录结构和两套命令的分工

Spark的发行版是编译好的二进制包,下载解压之后不需要额外编译就能用。但它的目录结构里有两套命令,很多人一开始分不清:bin目录下的spark-submit、pyspark、spark-shell是面向"应用程序"的,sbin目录下的start-master.sh、start-workers.sh是面向"集群本身"的。这两套东西各自独立,别搞混。jars目录是Spark运行需要的全部依赖,配置都在conf目录下,python目录里是PySpark的源码和打包好的Python包。

我装好之后做的第一件事不是急着启动集群,而是先看一眼spark-submit --version的输出。这一条命令能同时确认Spark版本和JVM版本是否正常,如果这里都报错,后面集群再折腾也没意义。很多入门教程第一步就让你直接start-master.sh,其实跳过环境验证,后面出了问题反而更难定位。

2.3 环境变量与第一次启动验证

环境变量方面,核心是四件事:JAVA_HOME、SPARK_HOME、PATH,以及一个很多教程不强调的PYSPARK_PYTHON。前三个好理解,最后一个是在集群模式下指定"Worker节点用哪个Python解释器去执行Python代码"的。如果你的Master和Worker不在同一台机器上,几台机器上的Python路径不一致,执行任务时就会莫名报错,而报错信息往往还很隐蔽。所以从第一天起,我就在所有节点的配置里统一写死了同一个Python路径。

全部配好后,我先启动pyspark,用默认的local模式跑了一个最简单的RDD求和,确认解释器、JVM、PySpark三方通信没问题。这一步的价值是建立"基线":如果你知道local模式是好的,那之后再出问题,就能确定问题出在集群配置上,而不是环境本身。环境这关慢慢来,急躁跳过验证,后面排错的时间一定加倍还回来。

3. Standalone集群搭建:Master、Worker与资源数字

3.1 先看懂spark://master:7077背后发生了什么

Standalone模式下,--master参数长得像spark://192.168.1.10:7077。这里面的7077是Master进程对外提供RPC服务的端口,Driver通过它向Master注册Application、申请资源。注意别把它和8080混淆,8080是Master的Web UI端口,专门给人看的,7077才是机器和机器之间通信用的。

整个集群的工作流程大致是:Worker进程启动后主动向Master注册自己当前有多少核、多少内存;当Driver提交一个Application过来,Master就根据这个Application申请的资源数,在注册的Worker里挑出合适的节点,为它在Worker上拉起Executor进程;之后真正的计算在Executor里完成,数据分片和任务分配则由Driver里的调度器负责。也就是说,Master管"谁有资源和怎么分配",Driver管"具体每个任务怎么切分"。刚开始不用把每个概念都背下来,只要知道这个主从关系和资源申请链路,后面看日志和Web UI就顺了。

3.2 配置文件逐行拆解:spark-env.sh和workers

集群配置都在conf目录下。首次使用要把spark-env.sh.template复制成spark-env.sh,我在这份文件里写了四行核心配置:

SPARK_MASTER_HOST=192.168.1.10 SPARK_WORKER_CORES=3 SPARK_WORKER_MEMORY=6g SPARK_WORKER_INSTANCES=1

SPARK_MASTER_HOST指定Master绑定到哪个IP,多网卡机器一定要显式写,否则可能绑定到奇怪地址上,从别的机器就根本连不上。SPARK_WORKER_CORES和SPARK_WORKER_MEMORY表示每个Worker愿意贡献给Spark的核数和内存量,这是集群资源的总池子。SPARK_WORKER_INSTANCES一般保持1就行,只有你想在一台机器上跑多个Worker进程时才需要改。

另外一个文件是conf目录下的workers文件。需要说明的是,Spark 3.x里这个文件叫workers,老版本叫slaves,网上老教程混着叫,别被搞晕。里面一行一个Worker节点的主机名或IP。我一开始就漏了维护这个文件,结果start-workers.sh启动了个寂寞,之后立刻补齐就好了。还有一个容易踩的配置是spark.local.dir,它决定Executor写临时数据和shuffle文件的磁盘位置,默认在/tmp下。如果/tmp空间很小,大作业跑到一半会出现Task Lost,我后来直接把它指到了数据盘上一个专门的目录,一次就清净了。

3.3 用一台4核8G机器示范资源怎么算

资源参数不是拍脑袋填的,我用一台4核8G的虚拟机演示一下计算逻辑。

首先,操作系统和系统进程本身要占资源,不能把整台机器的CPU和内存全交给Spark。我的规矩是至少留1个核和大约2G内存给系统用。于是SPARK_WORKER_CORES填3,SPARK_WORKER_MEMORY填6g。

接着算应用侧的额度。当你用spark-submit提交作业时,会指定--executor-memory,假设我填5g。Executor在实际运行前,除了堆内存本身,Spark还要计算一部分额外开销,默认大约是max(384MB, executor内存的10%),所以5g的Executor真实占用是5g加约0.5g,合计约5.5g。这个数字必须小于Worker上报的6g,Executor才放得进去。如果我贪心填6g,那真实占用就是6.6g,超过Worker的6g容量,应用就会永远停在等待资源的状态,后面我会专门讲这个坑。

所以一套合理的换算公式是:executor-memory加max(384MB, 10% × executor-memory) 必须小于等于SPARK_WORKER_MEMORY。学习阶段我通常把Executor内存设为Worker内存的一半左右,既留足余量,又能一次多放几个Executor观察调度效果。这里给个具体例子:Executor申请4g时,开销约410MB,总占用约4.4g,放进6g的Worker绰绰有余;如果Executor申请6g,总占用6.6g,放不进去。

3.4 启动顺序、Web UI和进程检查

启动顺序是先Master后Worker。第一台机器上执行sbin/start-master.sh,然后执行sbin/start-workers.sh spark://192.168.1.10:7077。这里补充一句,老教程里你可能还会看到start-slaves.sh和start-all.sh,前者是Spark 2.x时代的叫法,后者会把Master和Worker一次全启动,但Hadoop也有一个同名的start-all.sh,两套东西混在一起特别容易误操作,所以我学习阶段一直用分开的脚本,宁可多敲一条命令也不冒这个险。

启动完之后的检查我固定做三件事:一是用jps看进程,正常应该看到Master和Worker两个Java进程;二是访问http://192.168.1.10:8080,Web UI里Workers表格会显示每个Worker的地址、核数、内存和状态;三是打开logs目录下对应的.out日志文件,确认没有异常堆栈。这三关都过了,集群才算真正立起来了。

4. 第一个PySpark作业:词频统计的执行链

4.1 RDD还是DataFrame?学习期我建议先写一遍RDD

现在PySpark教程很多一上来就教DataFrame,这没错,DataFrame有Schema、有优化器,生产上基本都用它。但我还是建议学习期的第一个作业用RDD写一遍。

原因是RDD把Spark的底层执行模型暴露得最直接:惰性求值、窄依赖、宽依赖、shuffle、Stage划分,这些概念靠DataFrame是看不出来的。而排查线上作业慢、任务卡住的时候,你看Web UI里的Stage和Task粒度,本质上还是在看RDD的执行模型。先把底层逻辑跑通,再去看DataFrame的优化,理解会扎实很多。等了解了RDD,用DataFrame重写同一个作业作为对比,这一步非常值得做,两种API各写一遍,对Spark的认知会立体很多。

4.2 代码逐行拆解:flatMap、map、reduceByKey

词频统计是Spark版的Hello World,完整代码长这样:

import sys from pyspark.sql import SparkSession spark = SparkSession.builder.appName("WordCountLearn").getOrCreate() sc = spark.sparkContext input_rdd = sc.textFile(sys.argv[1]) words = input_rdd.flatMap(lambda line: line.split()) pairs = words.map(lambda word: (word, 1)) counts = pairs.reduceByKey(lambda a, b: a + b) counts.saveAsTextFile(sys.argv[2]) spark.stop()

sc.textFile读入文件后得到一个RDD,这时候数据还没真正读,所有转换操作都是惰性的。flatMap把每一行按空格切词,并把多行铺平成一个个单词;map把每个单词变成(word, 1)这样的键值对;reduceByKey把所有相同单词的计数两两相加。而真正触发计算的是最后一行saveAsTextFile,它是个Action,只有Action才会让Spark真正启动一个Job去执行前面所有惰性算子。

执行过程中,Spark会把整个Job按shuffle边界切成Stage。词频统计里reduceByKey触发了shuffle,所以在这个作业里,flatMap和map属于同一个Stage,reduceByKey和saveAsTextFile属于下一个Stage。每个Stage会被继续切成若干个Task,分发给不同Executor并行执行。从Web UI的Stage页里,你能清楚看到这两个Stage以及Task的数量,这就是我前面说"值得用RDD写一遍"的原因。

作为对比,DataFrame版本连写起来都更接近SQL:

from pyspark.sql.functions import split, explode, lower lines_df = spark.read.text(sys.argv[1]) words_df = lines_df.select(explode(split(lower(lines_df["value"]), r"\W+")).alias("word")) result = words_df.groupBy("word").count() result.write.csv(sys.argv[2])

同样一个统计逻辑,DataFrame的写法更简洁、更结构化,但底层优化细节被掩盖了。两种都写一遍,你会明显感觉到Spark两个API家族的风格差异,也更容易理解为什么现在团队里更偏爱DataFrame。

4.3 从local[*]切到Standalone后发生了什么

我先用spark-submit --master local[*]把词频统计跑通,确认代码没问题,再切到--master spark://192.168.1.10:7077。切换后最大的区别在于:local模式下Driver和Executor都在同一个JVM进程里,而Standalone client模式下Driver还在提交命令的这台机器上,但Executor被分配到了Worker节点上。

这个区别引发了一个我差点没发现的经典误区:sc.textFile里如果写的是本地文件路径,比如/data/input.txt,在local模式没问题,但在多节点集群里,每个Executor会到自己所在机器上找这个路径。文件在Driver机器上并不代表它在所有Worker机器上都在。解决思路是让所有节点共享同一份输入数据,要么用分布式存储,要么放大家一起挂载的共享目录,临时学习场景也可以在每台Worker上各放一份。

输出路径也有同样的坑。saveAsTextFile如果写本地路径,part文件会落在各Executor所在机器的本地磁盘上,而不是回到Driver机器。我第一次跑完,在Driver机器上找输出目录怎么都找不到,后来才反应过来文件是被每个Executor分别写在各自节点上的。这个认知对理解分布式存储很重要:凡是涉及"文件路径"的东西,在集群模式下都默认指向Executor所在的机器,而不是你敲命令的那台。

5. spark-submit提交姿势与资源参数生效规则

5.1 一条完整的提交命令拆给你看

在掌握集群搭建和作业逻辑之后,spark-submit就成了每天都要用的命令。我最常用的一条提交命令长这样:

spark-submit \ --master spark://192.168.1.10:7077 \ --deploy-mode client \ --name wordcount \ --executor-memory 4g \ --executor-cores 2 \ --total-executor-cores 4 \ --conf spark.sql.shuffle.partitions=8 \ word_count.py /data/input.txt /data/output

--executor-memory给每个Executor分配多少内存,--executor-cores给每个Executor分配几个CPU核,--total-executor-cores限制整个应用最多能用多少个核。最后那个--conf是给配置追加任意参数,比如把shuffle的默认分区数从200改成8,这个在做小数据实验时能省不少调度开销。学习阶段我的习惯是:先在本地用local[*]跑一遍确认脚本正确,再带着这份spark-submit命令去集群执行,两边分开排查,定位速度会快很多。

5.2 Standalone模式下Executor是怎么排布的

很多从YARN教程转过来的人,第一反应是提交时写--num-executors来指定Executor数量。这里有个大坑:在Standalone模式下,--num-executors基本不生效,真正决定Executor数量和排布的是cores和memory的组合。

举个具体例子:Worker上报的是3核6g,如果我提交时指定--executor-cores 2、--executor-memory 4g,那么这台Worker能放得下的Executor,最多是1个,因为2核的Executor已经占据了Worker的大部分核数,虽然还剩下1核,但内存已经被4g加开销占了大半。反过来如果我指定--executor-cores 1、--executor-memory 2g,那这台上报3核6g的Worker理论上可以塞下两个Executor,因为核和内存都够。Standalone调度器就是这么按Worker的空闲核数和内存逐台放置Executor的,理解了这个逻辑,你对"为什么任务等了半天没跑起来"的排查方向就会清晰很多。

命令里还有一个--total-executor-cores,它相当于给整个应用设了核数上限。我跑单机Standalone时习惯把它设成Worker核数的一半到三分之二,既能看到多Executor并行,又不会把整个Worker占死,给后面的实验留出空间。

5.3 client与cluster模式,以及PySpark的特别限制

--deploy-mode有两个选择:client和cluster。client模式下Driver跑在提交命令的那台机器上,日志直接打印在终端,调试最方便,这也是我学习阶段一直用的模式。cluster模式则是让集群自己拉一个进程当Driver,日志不直接出现在你的终端。

这里有一个PySpark初学者容易踩的专门限制:在Standalone模式下,cluster模式只对Java/Scala应用开放,Python应用是不支持的。也就是说,你拿PySpark脚本配合Standalone集群,只能用client模式。不是命令敲错了,而是这个组合在架构上就没开放那条路。如果真要用PySpark的cluster模式,就得切换到YARN或者Kubernetes环境。知道这个限制能省下不少查文档的时间。

6. 踩坑复盘:三次现场还原与排查链路

6.1 Worker反复注册失败:hostname引发的连锁反应

我第一天搭建多节点Standalone集群时,出现过一个很典型的连环问题:Master进程起来了,Web UI也能访问,但第二台机器上的Worker一遍遍尝试连接Master,日志里反复出现连接失败,Web UI里死活看不见这个Worker。

我的排查链路是这样的:先在第二台机器上ping Master的IP,网络通;再用nc或者类似工具测试7077端口,端口也能通;问题就不在网络不通,而在主机名解析。原来我在Master机器上没配好主机名映射,导致Worker拿着spark://某个hostname这个地址去连,解析出来的却是回环地址,自然连不上真正的Master。最后我把各机器的主机名和IP写进/etc/hosts,确保所有节点都能通过hostname互相解析,问题才彻底解决。那次之后我记住了:集群里一切连接问题,先查网络连通性,再查端口,最后一定记得查主机名解析,顺序不能乱。

6.2 应用一直Pending:Executor内存塞不进去

另一个差点让我怀疑人生的现象是:spark-submit提交后,终端显示Application已经注册成功,但接下来就是一直等在那里,状态始终是等待资源。打开Master的Web UI,能看到Application,也能看到Worker就老老实实挂在那个集群里,核数和内存都有剩余,但Executor就是起不来。

翻Master和Driver日志后我找到了真正原因:Executor申请5g内存,实际需要5.5g左右,而Worker上报的内存只有6g,扣除系统开销后其实已经不太够,调度器一直在尝试放置却放不下。我用前面那套公式反复核算后,把--executor-memory降到3g,重新提交,Application立刻进入Running状态。这个坑的通用排查思路是:看到"registered但pending",优先怀疑资源放不进Worker,用内存开销公式重新算一遍,别急着怀疑代码逻辑。

6.3 文件路径"跑哪去了":本地文件与输出目录的误区

这个问题我前面提过,但值得单独再说一次完整现象:在单机local模式写完的word_count.py,切到集群提交后,输入文件明明还在Driver机器上放着,任务却报文件找不到;好不容易把输入文件复制到各Worker节点跑通了,输出目录又在Driver机器上找不到。两个现象本质是同一件事:集群模式下,Executor所在的节点才是读写文件的主场。

理解了这一点,学习阶段的处理就很朴素了:多节点集群实验,优先用所有人都能访问的共享路径,或者干脆在每个Worker节点都放一份输入文件;输出直接写分布式存储或共享目录。如果你的集群只有一台机器,Master和Worker同机,那本地路径暂时还能凑合,但心里必须清楚这只是"碰巧在同一台机器上"而已。集群从单节点扩展到多节点的那一刻,这个坑一定会重新出现,早理解早受益。

6.4 学习阶段值得长期保留的检查命令清单

经过这一天的折腾,我给自己固定了一套"下判断前先检查"的命令清单,每次集群状态不对,就按这个顺序过一遍。

首先是看进程,jps能列出的Java进程,Master、Worker、SparkSubmit都在就有基本的健康度;然后是看端口,7077是Master的RPC端口,8080是Master Web UI,4040是运行中应用自己的UI,哪个端口不通就往哪个方向查;接着是看资源,free -g看系统内存还剩多少,配合Master Web UI里Worker上报的资源数做对比;最后是看日志,Spark的日志都集中在logs目录,Master、Worker、应用各自有独立的输出文件,里面通常把根因写得比Web UI清楚得多。

这套习惯坚持下来,后面遇到的绝大多数集群问题,都能在十分钟内定位到方向。对了,我还养成了一个习惯:每调通一个环境,就把当时能正常运行的spark-submit完整命令连同配置文件一起复制进我的学习日志里,下次重建环境直接照着抄。我自己的体会是,大数据学习里最难的其实不是API用法,而是出了问题之后,能不能有一条清晰的排查路径可走。

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

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

立即咨询