Spark入门实践:spark-shell到spark-submit避坑指南
简介:面向大数据技术原理与应用课程的学习者,这份 Spark 初级编程实践实验报告以单份文档承载完整上机流程,从环境搭建到独立应用开发均有记录,适合课程实验对照、上机复盘与提交前自查。文档基于 Hadoop 3.1.3、JDK 1.8 与 UbuntuKylin 环境,依次记录 spark-shell 启动、Linux 本地文件与 HDFS 文件行数统计、用 Scala 编写 SimpleApp 与 RemDup 程序并借助 sbt 打包、通过 spark-submit 提交运行,以及按学科成绩求学生平均分等实践环节,命令与步骤交代清楚。对读取路径书写不当引发的非法参数、无效输入与 URI 语法异常,文中也附上报错截图与修正思路,可减少初学者的调试弯路。压缩包共 1 个 docx 文件,约 1.9MB,内含实验环境表格、分步操作截图与关键代码片段,内容集中、便于检索。已有 8348 人学习下载。
1. 从 spark-shell 到 spark-submit:这套实验材料的真实门槛在哪
大多数人以为 Spark 入门最难的是 RDD 算子,真正跑过一遍就知道,卡住新手的九成不是map、reduceByKey这些 API,而是file:///少写一个斜杠、HDFS 路径写成~/test.txt这种一秒钟的事故。这份“Spark 初级编程实践”实验材料把大数据课程里最典型的五步串成了一条线:装 Hadoop 与 Spark、用 spark-shell 读本地文件和 HDFS、写 Scala 独立应用、用 sbt 打成 JAR、再用 spark-submit 提交。
它适合三类人:正在上大数据课、需要交一份能跑通的实验报告的学生;从 Python 脚本转过来、第一次接触 JVM 系大数据栈的开发者;以及想在自己的笔记本上用虚拟机把 Hadoop 3.1.3 加 Spark 这套组合完整走一遍的工程师。整套东西不依赖集群,一台 16GB 内存的机器就够,重点是路径、版本和打包这三件事。
2. 环境底座:Hadoop 3.1.3 与 Spark 的版本对齐、路径约定
虚拟机装 Hadoop 加 Spark 最容易翻车的地方不是命令记不住,而是版本对不上、环境变量漏了一条、HDFS 没格式化就急着启动 spark-shell。这一章按“先定版本、再定路径、最后交叉验证”的顺序把底座铺平,后面所有代码才有地方落脚。
2.1 版本矩阵:为什么 JDK 1.8 和 Scala 2.11 是硬约束
这套组合里最不能随便动的是 JDK。Hadoop 3.1.3 和多数 Spark 2.x 发行包都是按 JDK 1.8 编译的,你换成 JDK 11 或 17,常见结果不是编译报错,而是运行期抛UnsupportedClassVersionError或者 Hadoop 的Configuration类初始化失败,报错信息还指不到根因上。所以第一步先用一条命令确认默认 JDK:
java -version # 期望输出包含:java version "1.8.0_xxx" # 如果系统里有多个 JDK,用下面这条切换并写进 ~/.bashrc sudo update-alternatives --config javaScala 这边还有一个隐性约束。你在target/scala-2.11/下看到的目录名不是 Spark 随便起的,它由simple.sbt里的scalaVersion决定,而 Spark 发行包自带的spark-core又是针对某个 2.11.x 小版本编译的。两者大版本必须都是 2.11,小版本差一点通常没事,但如果你写成2.12.x,sbt package会顺利通过,spark-submit一跑就是NoSuchMethodError或者ClassNotFoundException,因为依赖根本没被正确解析。
Eclipse 在这套流程里的角色要说清楚:它只是个代码编辑器加语法提示,真正编译和打包交给 sbt。很多人习惯在 Eclipse 里点 Run 然后发现 Spark 的依赖全红,那是因为工程没有按 sbt 的结构建。想省事就直接用 vim 写.scala文件,sbt 命令行打包,反而少一层坑。
2.2 解压、环境变量与首次 spark-shell 启动
安装包统一解压到/usr/local下,路径里不要带空格和中文,这是后面所有脚本能写死路径的前提。环境变量建议直接追加到~/.bashrc,写完后source一次,再开新终端验证。
# 解压到固定路径,注意不要解压到带空格的目录 sudo tar -zxf hadoop-3.1.3.tar.gz -C /usr/local/ sudo tar -zxf spark-2.x-bin-hadoop3.tgz -C /usr/local/ sudo mv /usr/local/hadoop-3.1.3 /usr/local/hadoop sudo mv /usr/local/spark-2.x-bin-hadoop3 /usr/local/spark sudo chown -R $USER:$USER /usr/local/hadoop /usr/local/spark # 追加环境变量 cat >> ~/.bashrc <<'EOF' export HADOOP_HOME=/usr/local/hadoop export SPARK_HOME=/usr/local/spark export PATH=$PATH:$HADOOP_HOME/bin:$HADOOP_HOME/sbin:$SPARK_HOME/bin export CLASSPATH=$($HADOOP_HOME/bin/hadoop classpath) EOF source ~/.bashrc echo $HADOOP_HOME $SPARK_HOME这里逐条说明。tar -zxf ... -C是指定解压目标目录,解压后重命名成不带版本号的短路径,是为了让后面spark-submit的 jar 路径短一点、也方便以后换版本。chown这步别省,否则用普通用户跑 spark-shell 时会因为写不了日志目录而报权限错误,报错信息通常在logs/下,很容易被忽略。
CLASSPATH这行是给 sbt 用的。sbt 编译时需要能找到 Hadoop 的类,否则你在代码里写org.apache.hadoop.fs.Path会提示找不到符号。用hadoop classpath让 Hadoop 自己吐出一长串 jar 路径,比自己手工拼要靠谱得多。
core-site.xml必须配一次,否则后面所有写hdfs://localhost:9000/...的代码都得带全主机名和端口:
<!-- /usr/local/hadoop/etc/hadoop/core-site.xml --> <configuration> <property> <name>fs.defaultFS</name> <value>hdfs://localhost:9000</value> </property> </configuration>配完执行hdfs namenode -format只做一次,重复格式化会把 namenode 的元数据清掉,之前传上去的文件全部消失。然后start-dfs.sh启动,jps应该能看到NameNode、DataNode、SecondaryNameNode三个进程。少一个就去logs/里翻对应日志,通常是 ssh 免密没配好导致start-dfs.sh起不完整。
2.3 用 hdfs dfs 与 spark-shell 交叉验证连通性
启动完别急着写业务代码,先做一次双向验证:HDFS 能读写,Spark 能认出 HDFS。
# 建用户目录,HDFS 里没有"家目录"概念,必须手工建 hdfs dfs -mkdir -p /user/hadoop hdfs dfs -ls /user/ # 启动交互式 shell,看到 scala> 提示符即成功 $SPARK_HOME/bin/spark-shell进到scala>之后,先看 Spark 认到的默认文件系统:
// 打印当前上下文的默认文件系统,正常应输出 hdfs://localhost:9000 sc.hadoopConfiguration.get("fs.defaultFS") // 打印 Spark 版本与 Scala 版本,确认和 sbt 里写的一致 sc.version如果第一行返回null,说明 Spark 没读到 Hadoop 的配置文件。常见的做法是在spark-env.sh里显式指定HADOOP_CONF_DIR=/usr/local/hadoop/etc/hadoop,这一条加完重启 spark-shell 就正常了。确认没问题后:quit退出,进入下一章的文件读取。
3. spark-shell 读两类文件系统:file:/// 与 /user/hadoop 的路径语义
Spark 的textFile只接受一个字符串,但这个字符串的解析规则决定了你读的是本地磁盘还是 HDFS。实验里最先出问题的两个异常,追到根上都是路径语义没搞清:IllegalArgumentException对应 URI 写法不合法,InvalidInputException对应路径在目标文件系统里不存在。
3.1 textFile 的路径解析:为什么少个斜杠就抛 IllegalArgumentException
Spark 解析路径时走的是 Hadoop 的FileSystem.get(URI, conf),也就是说它期望一个合法的 URI。URI 的通用格式是scheme://authority/path,本地文件系统的 scheme 是file,authority 部分为空,所以完整的本地绝对路径必须写成file:///home/hadoop/test.txt,三个斜杠里前两个属于//,第三个才是根目录/。
写成file://home/hadoop/test.txt,Hadoop 会把home当成 authority(主机名),然后去找一台叫home的机器,解析阶段就抛IllegalArgumentException: Wrong FS或者java.net.UnknownHostException。写成file:/home/hadoop/test.txt(两个斜杠)多数情况下能跑,但不同 Hadoop 版本行为不一致,不建议赌。
| 写法 | 解析结果 | 是否可用 |
|---|---|---|
file:///home/hadoop/test.txt | 本地绝对路径 | 推荐 |
file://home/hadoop/test.txt | 主机名 home 上的路径 | 抛异常 |
file:/home/hadoop/test.txt | 部分版本可解析 | 不推荐 |
/user/hadoop/test.txt | 走fs.defaultFS,即 HDFS | 可用 |
hdfs://localhost:9000/user/hadoop/test.txt | 显式指定 HDFS | 可用但啰嗦 |
记住一条就够了:本地文件三斜杠打头,HDFS 用绝对路径或者显式hdfs://打头,相对路径和~/一律不要用。
3.2 本地文件统计行数的完整操作
先在 Linux 本地造一个测试文件,注意这个文件在虚拟机的本地磁盘上,和 HDFS 没有关系。
# 在本地家目录建测试文件,三行内容 cat > /home/hadoop/test.txt <<'EOF' hello spark hello hadoop hello hdfs EOF wc -l /home/hadoop/test.txt # 记录基准值,后面用来对拍然后在 spark-shell 里读:
// 三斜杠,绝对路径,读本地磁盘 val localFile = sc.textFile("file:///home/hadoop/test.txt") // count 是行动操作,会触发真正的读取 val localCount = localFile.count() println("本地文件行数: " + localCount) // 顺手把内容打出来,确认读到的不是空 RDD localFile.collect().foreach(println)count()是行动(action)算子,前面的textFile只是构造了一个 RDD 的血缘关系,不落盘也不读盘,真正触发读取的就是count。这一步最常见的翻车是路径写对了但文件权限不对,hadoop用户读不了 root 建的文件,报Permission denied。用ls -l看一眼属主,需要的话chmod 644一下。
collect()把小数据集的全部元素拉到 Driver 端变成数组,只适合几行到几万行的量级。如果你的文件是几百 MB,collect会直接把 Driver 的内存打爆,抛OutOfMemoryError,这是新手很典型的血泪经验。大文件用take(10)抽样看前几行就够了。
3.3 HDFS 文件的上传、读取与行数统计
HDFS 里的路径和 Linux 完全是两套命名空间。/user/hadoop只是约定俗成的用户工作目录,不是 Linux 的家目录,~在 HDFS 里没有任何特殊含义,写成~/test.txt会原样被当成根目录下一个叫~的目录,自然报InvalidInputException: Input path does not exist。
先把本地文件传上去:
# 本地 /home/hadoop/test.txt 上传到 HDFS 的 /user/hadoop/ hdfs dfs -put /home/hadoop/test.txt /user/hadoop/ # 确认文件在,注意看权限和副本数 hdfs dfs -ls /user/hadoop/ # 直接查看内容,验证上传完整 hdfs dfs -cat /user/hadoop/test.txt再回到 spark-shell:
// 方式一:依赖 core-site.xml 里的 fs.defaultFS,写绝对路径即可 val hdfsFile = sc.textFile("/user/hadoop/test.txt") // 方式二:显式写全,不依赖默认配置,迁移到别的环境时更稳 val hdfsFile2 = sc.textFile("hdfs://localhost:9000/user/hadoop/test.txt") println("HDFS 文件行数: " + hdfsFile.count())两种写法读的是同一份数据。显式写hdfs://的好处是你把 jar 拷到另一台机器上跑时,不会因为那台机器的core-site.xml配错而读到别的地方去;坏处是主机名和端口写死了,换集群要改代码。实验阶段两种都可以,工程里一般用第一种,靠配置文件管理环境差异。
还有一个小细节:sc.textFile的第二个参数可以指定分区数,不传时按 HDFS 的 block 数自动切。文件只有几行的时候就是一个分区,count的结果和hdfs dfs -cat | wc -l必须一致,这是我每次跑完都会做的对拍动作。
4. 独立应用:simple.sbt 工程骨架、sbt package 与 spark-submit
spark-shell 只能验证逻辑,真正交付的是能提交到集群的 JAR。这一章把 sbt 工程的目录结构、simple.sbt四个字段、三个作业的完整代码和提交参数一次讲透,写完这一章你手上应该有三个能直接跑的 JAR。
4.1 目录结构与 simple.sbt 的四个字段
sbt 对目录结构有硬性约定,源码必须放在src/main/scala下,否则sbt package会告诉你编译了 0 个文件,然后打出一个空 JAR,提交时报ClassNotFoundException,排查半天以为是 class 名写错了。
# 标准 sbt 工程骨架,以 SimpleApp 为例 mkdir -p /usr/local/spark/mycode/HDFStest/src/main/scala cd /usr/local/spark/mycode/HDFStest # 目录长这样: # HDFStest/ # ├── simple.sbt # └── src/main/scala/SimpleApp.scalasimple.sbt放在工程根目录,四个字段就够:
// 工程名决定产物 JAR 的前缀,空格会被替换成连字符 name := "a-simple-hdfs-test" version := "1.0" // 必须是 2.11 系列,与 Spark 发行包一致 scalaVersion := "2.11.8" // %% 会自动补上 _2.11 后缀;版本号要和 $SPARK_HOME/jars 里的 spark-core 对齐 libraryDependencies += "org.apache.spark" %% "spark-core" % "2.1.0"第三个字段和第四个字段是配套的。%%是 sbt 的语法糖,它会在 artifact 名后自动追加_2.11,最终去仓库找spark-core_2.11。如果你手写成%,找的就是spark-core这个不存在的坐标,编译期直接失败。
版本号这块不要凭记忆填,用命令查本机到底带的是哪个:
# 从 Spark 自带的 jars 目录反查版本,比自己记靠谱 ls $SPARK_HOME/jars | grep spark-core # 输出形如 spark-core_2.11-2.1.0.jar,版本号照抄进 simple.sbtsbt package第一次运行会去下载依赖,网络慢的时候会卡很久,看到[success]才算完。产物在target/scala-2.11/下,文件名是name字段加_2.11加version,空格变连字符。
4.2 SimpleApp.scala:读 HDFS 统计行数的最小闭环
import org.apache.spark.SparkContext import org.apache.spark.SparkConf // object 名就是后面 spark-submit --class 要填的名字 object SimpleApp { def main(args: Array[String]): Unit = { // setAppName 决定 Web UI 和日志里显示的应用名 val conf = new SparkConf().setAppName("SimpleApp") val sc = new SparkContext(conf) // HDFS 路径写全,避免依赖运行环境的 core-site.xml val textFile = sc.textFile("hdfs://localhost:9000/user/hadoop/test.txt") // count 是行动算子,触发作业执行 val count = textFile.count() println("Number of lines: " + count) // 显式关闭,避免 local 模式下进程不退出 sc.stop() } }逐行看几个点。SparkConf里没有调setMaster,这是故意的:master 由spark-submit的--master参数决定,写死在代码里的应用换到集群上就得改代码重打包。sc.stop()在 spark-shell 里不需要,但独立应用必须加,否则 local 模式下 JVM 可能挂着不退出,你以为是程序卡住,其实是 Driver 还在等。
打包和提交:
cd /usr/local/spark/mycode/HDFStest sbt package # 看到 [success] 后再提交,产物路径如下 /usr/local/spark/bin/spark-submit \ --class "SimpleApp" \ --master local[2] \ /usr/local/spark/mycode/HDFStest/target/scala-2.11/a-simple-hdfs-test_2.11-1.0.jar4.3 RemDup 与 AvgScore:两个能直接抄的作业
去重作业要求把 A、B 两个文件合并后剔除重复行。输入样例是按日期加字母的键值对,注意去重的粒度是整行而不是某几个字段,所以distinct直接作用在行上就对了。
import org.apache.spark.SparkContext import org.apache.spark.SparkConf object RemDup { def main(args: Array[String]): Unit = { val conf = new SparkConf().setAppName("RemDup") val sc = new SparkContext(conf) val fileA = sc.textFile("hdfs://localhost:9000/user/hadoop/A.txt") val fileB = sc.textFile("hdfs://localhost:9000/user/hadoop/B.txt") // union 只做分区拼接,不产生 shuffle val merged = fileA.union(fileB) // distinct 内部走 shuffle,把相同整行收敛到一条 // 再 sortBy 一次,保证输出顺序和样例一致,方便人工核对 val result = merged.distinct().sortBy(line => line) // 注意这里是目录,不是文件 result.saveAsTextFile("hdfs://localhost:9000/user/hadoop/output/remdup") sc.stop() } }distinct在 RDD 上的实现是map(x => (x, null)).reduceByKey(...)再取 key,本质是一次 shuffle。如果你只需要按日期去重、允许同一天保留多条,就不能用distinct,要改成map提取 key 之后reduceByKey自己选一条,这是实验和真实业务的分水岭。
求平均值作业的输入是三个文件,每行“姓名 空格 成绩”。关键点是不要把所有数据collect到 Driver 再算平均,那样就完全失去了分布式的意义,数据量一大直接 OOM。
import org.apache.spark.SparkContext import org.apache.spark.SparkConf object AvgScore { def main(args: Array[String]): Unit = { val conf = new SparkConf().setAppName("AvgScore") val sc = new SparkContext(conf) // 通配符读目录下所有文件,比一个个写路径省事 val lines = sc.textFile("hdfs://localhost:9000/user/hadoop/datas/*") // split("\\s+") 兼容多个空格和制表符 val pairs = lines.map { line => val arr = line.split("\\s+") (arr(0), arr(1).toDouble) } // 先把每条记录变成 (分数, 1),再按 key 聚合出 (总分, 门数) val sumCount = pairs .mapValues(v => (v, 1.0)) .reduceByKey((a, b) => (a._1 + b._1, a._2 + b._2)) // 除法在 Executor 侧完成,只把最终结果写出去 val avg = sumCount.mapValues { case (sum, cnt) => f"${sum / cnt}%.2f" } avg.saveAsTextFile("hdfs://localhost:9000/user/hadoop/output/avg") sc.stop() } }reduceByKey和groupByKey的区别在这里体现得很直接:groupByKey会把同一个 key 的所有 value 拉到一个节点上组成迭代器,数据倾斜时单个 task 能把内存撑爆;reduceByKey会先在各分区做本地预聚合(combiner),网络传输量小一个数量级。求平均这种可结合的场景,一律用reduceByKey或者aggregateByKey。
f"${...}%.2f"是 Scala 的字符串插值格式化,输出正好是样例里的两位小数。注意浮点除法的顺序不能反,cnt / sum会得到门数除以总分,结果全是零点零几。
4.4 spark-submit 的参数与 --class 对齐
提交命令里三个参数最容易错,逐个说:
| 参数 | 含义 | 常见错误 |
|---|---|---|
--class | 主类的全限定名 | object 放在 package 下时没写包名 |
--master | 运行模式,local[N] 表示本地 N 线程 | 不写时读 spark-defaults.conf,可能连到不存在的集群 |
| jar 路径 | 必须是打包产物的完整路径 | 写成源码目录或者 target 下的 classes 目录 |
--class填的是object的名字,不是文件名。如果你在SimpleApp.scala里写了package com.demo,那--class必须写com.demo.SimpleApp,少一段就是ClassNotFoundException: com.demo.SimpleApp,而这个报错会让人误以为是 JAR 没打进去。
--master local[2]表示用两个线程模拟分布式,实验环境单机跑这个就够,不要试图去连 standalone 集群,端口、worker 注册、资源分配每一个都能耗掉半天。真要用集群模式,先把 local 跑通再说。
5. 避坑排查:三个异常、一个空格与路径写法的血泪账
下面五条都是这套实验里真实会撞上的,按“现象 → 原因 → 解决”写,遇到报错直接对号入座。
5.1 IllegalArgumentException:本地路径的斜杠少了一个
现象:sc.textFile("file://home/hadoop/test.txt")执行后抛IllegalArgumentException,或者提示Wrong FS: file://home/..., expected: hdfs://localhost:9000。
原因:URI 里file://后面紧跟的home被当成了 authority(主机名),Hadoop 尝试解析一台叫home的机器,scheme 和实际文件系统对不上。少一个斜杠的事情,但报错信息完全不提斜杠。
解决:本地绝对路径统一写三个斜杠file:///home/hadoop/test.txt。养成习惯:只要看到file:开头,后面必须跟三个/。
5.2 InvalidInputException:HDFS 路径写成了家目录
现象:读/user/hadoop/test.txt报org.apache.hadoop.mapred.InvalidInputException: Input path does not exist,但你明明记得文件已经放进去了。
原因有两个。一是文件压根没上传,只在 Linux 本地存在;二是路径写成了~/test.txt或/home/hadoop/test.txt,HDFS 的命名空间里没有~展开,也没有/home这个约定目录,找不着就是找不着。
解决:先hdfs dfs -ls /user/hadoop/确认文件在,不在就hdfs dfs -put上传。路径一律用 HDFS 视角的绝对路径,不要用 Linux 的家目录思维。
5.3 URISyntaxException:字符串前面藏了一个空格
现象:程序运行报java.net.URISyntaxException: Illegal character in scheme name at index 0,看起来路径完全正确。
原因:路径字符串的第一个字符不是h而是空格或者制表符。常见于从文档里复制路径时带上了前导空白,或者用"""多行字符串拼接时缩进被带了进去。hdfs://的 scheme 名不允许出现空格,解析器在 index 0 就撞上了非法字符。
解决:删掉前导空格,或者用trim兜底:sc.textFile(rawPath.trim)。我现在的习惯是所有从配置文件、命令行参数读进来的路径,第一件事就是trim。
5.4 NoClassDefFoundError:simple.sbt 的 Scala 版本和 Spark 不匹配
现象:sbt package成功,spark-submit提交后抛java.lang.NoClassDefFoundError: org/apache/spark/SparkConf或者NoSuchMethodError。
原因:scalaVersion写成了 2.12,而 Spark 发行包是 2.11 编译的;或者libraryDependencies用了%而不是%%,依赖坐标不完整,sbt 没把 spark-core 打进 classpath。
解决:scalaVersion改成 2.11.x,依赖用%%,版本号从$SPARK_HOME/jars里反查后照抄。打完包用unzip -l xxx.jar | head看一眼里面有没有你的 class 文件。
5.5 ClassNotFoundException:--class 和 object 名对不上
现象:提交时报ClassNotFoundException: SimpleApp,但 JAR 明明就在路径下。
原因:三种可能。object 前面有 package 声明而--class没写包名;--class写成了文件名SimpleApp.scala;JAR 路径写错,实际读的是另一个旧包。
解决:--class填 object 的全限定名。用unzip -l确认 JAR 里的 class 路径,和--class逐字符比对。顺手把spark-submit的完整命令记在工程根目录的run.sh里,避免每次手敲出错。
6. 结果验证与两个让实验更稳的小技巧
跑出结果不等于跑对。我一般会做两道对拍:行数用hdfs dfs -cat ... | wc -l和count()比,去重结果用sort之后diff比。saveAsTextFile写出的是目录不是文件,里面有part-00000、_SUCCESS这些,直接hdfs dfs -cat 输出目录/part-*就能看内容;要合并成一个文件拿回本地,用hdfs dfs -getmerge 输出目录 /home/hadoop/result.txt,这一步在交实验报告时特别省事,省得截图截一堆 part 文件。
第二个技巧是压日志。spark-shell 默认 INFO 级别,一屏刷几十行,真正有用的输出被埋掉。把$SPARK_HOME/conf/log4j.properties.template复制成log4j.properties,把log4j.rootCategory=INFO, console改成WARN, console,再启动世界就清净了。独立应用想定向调级别,可以在SparkConf里set("spark.ui.showConsoleProgress", "false"),配合日志级别一起用。
还有个验证手段很多人不知道:--master local[2]启动后,浏览器打开http://localhost:4040能看到这次作业的 DAG、stage 划分和 shuffle 读写量。去重作业里你会看到distinct对应一个带 shuffle 的 stage,sortBy又是一个,两个 shuffle 连着来。看到这个图,你对“哪些算子会触发 shuffle”的理解会比背八股文扎实得多。
从那以后我每次提交 JAR 之前,都强制先走一遍hdfs dfs -ls确认输入路径存在、unzip -l确认 class 打进去了,两步加起来不到十秒,能省掉一晚上。希望帮到你。
本文还有配套的精品资源,点击获取