简介:这份Spark初级编程实践实验报告,面向大数据方向学生与Spark入门者,完整覆盖Hadoop/Spark环境搭建、spark-shell读取本地与HDFS文件统计行数、Scala独立应用程序开发等核心环节,并针对文件URL路径、非法字符等常见报错给出排查思路。报告基于Windows 10主机+Ubuntu Kylin 16.04虚拟机环境,Hadoop 3.1.3、JDK1.8,步骤清晰,便于对照实操。
资源为单个docx文档,大小1.9MB,内含代码示例(SimpleApp、RemDup、AvgScore)、sbt打包与spark-submit提交命令、运行截图及问题解决方案,可作为课程实验报告参考或自学Spark入门指南。目前已有8345人浏览/学习,适合需要完成同类实验或快速上手Spark编程实践的读者。
1. 别急着写代码:先看一份能把 Spark 跑通的完整作业
如果你的大数据课程作业刚好卡在 Spark 初级编程实践上,这份实验记录能让你少走两天的弯路。它不只是一份跑通 wordcount 的笔记,而是一个从 Ubuntu 虚拟机里安装 Hadoop 3.1.3 和 Spark,到写出三个独立 Scala 应用、用 sbt 打包、再用 spark-submit 提交的完整闭环。网上教程大多停在 spark-shell 做 demo,真到独立应用打包提交就断档;这份作业把这条路完整趟了一遍,连 file:/// 少打一个斜杠的现场都留下了记录。适合正要交类似实验的新手,也适合想快速复习 Spark 提交流程的开发者。
2. 环境与技术栈:Ubuntu 虚拟机里 Hadoop 3.1.3 与 Spark 的搭建细节
2.1 选型清单:JDK 1.8、Hadoop 3.1.3、Scala 2.11 的版本默契
这套实验跑在一台 Windows 10 主机上,主机是 i5-10300H 处理器加 16 GB 内存,虚拟机装 Ubuntu 16.04 64 位,Hadoop、Spark、JDK 全部放在虚拟机内部。主机只负责调度,真正的数据处理都在 Linux 环境里,所以实验报告里记录的路径、权限、启动命令全部是虚拟机视角,这一点先对齐,后面才不会把 Windows 路径带进 Spark 代码。
| 组件 | 实验用到的版本 | 为什么这样选 |
|---|---|---|
| 虚拟机系统 | Ubuntu 16.04(64 位) | 课程镜像最常用,命令行为传统 init 风格,不容易踩 systemd 的新坑 |
| JDK | 1.8 | Hadoop 3.x 与 Spark 2.x 官方兼容列表里最稳的一个大版本 |
| Hadoop | 3.1.3 | 提供 HDFS 存储,是 Spark 读数据的底座 |
| Spark | 2.x(编译产物为 scala-2.11) | 实验核心计算框架,2.x 默认用 Scala 2.11 编译 |
| Scala | 2.11 | 和 Spark 二进制保持同一大版本,sbt 打包才不会出现二进制不兼容 |
| 构建工具 | sbt | Scala 社区主流构建工具,原实验也用它做 JAR 打包 |
| Java IDE | Eclipse | 只用来写 Scala 源码,真正编译和提交完全交给 sbt 和 spark-submit |
JDK 版本在这是最容易翻车的第一关。JDK 1.8 看起来老,但 Hadoop 的 namenode 和 Spark 的 executor 在 JDK 9 以上会遇到模块化限制,很多反射调用直接报 IllegalAccess;回到 1.8 就安静了。Scala 2.11 同理,Spark 2.x 的 spark-core 是针对 2.11 编译的,如果 sbt 里写成 2.12,运行时可能报 NoSuchMethodError,这种错误比语法错误难查得多。这台机器整体配置属于主流笔记本水平,虚拟机分 4~8 GB 给它就够用,分太多反而让宿主机发卡。
2.2 安装顺序:先 Hadoop 再 Spark,环境变量别拼错
下载安装包之后不要直接双击解压到桌面,我一般会统一放到 /usr/local 下,然后用 mv 改名缩短路径,Spark 和 Hadoop 的路径越短,后面写 spark-submit 命令就越省事。
# 解压安装包,-C 指定目标目录 sudo tar -zxvf hadoop-3.1.3.tar.gz -C /usr/local sudo tar -zxvf spark-2.x-bin-hadoop2.7.tgz -C /usr/local # 把解压出的目录改成短名,后续配置少敲很多字 sudo mv /usr/local/spark-2.x-bin-hadoop2.7 /usr/local/spark参数说明:tar.gz 用 -zxvf 解压;-C 决定解压到哪,习惯上 Hadoop 固定叫 /usr/local/hadoop,Spark 固定叫 /usr/local/spark。如果解压后目录名带了版本号,建议用 mv 改成不带版本号的名字,环境变量里少写一段版本串,这是省眼泪的经验。
然后配置环境变量:
# 编辑 ~/.bashrc 追加以下行 export HADOOP_HOME=/usr/local/hadoop export PATH=$PATH:$HADOOP_HOME/bin:$HADOOP_HOME/sbin export SPARK_HOME=/usr/local/spark export PATH=$PATH:$SPARK_HOME/bin编辑后 source ~/.bashrc。PATH 顺序没有严格要求,但不要覆盖已有的 java 命令位置,否则 java -version 会突然失效。验证环境是否就位,依次跑:
java -version hadoop version /usr/local/spark/bin/spark-shell说明:spark-shell 是 Spark 的交互式 REPL,启动时日志里会出现 master 为 local[*] 的提示,表示用本机全部可用核心跑,不需要额外启动 YARN。等到出现 scala> 提示符就算成功,而且 sc 这个 SparkContext 已经被 Spark 帮你创建好了,你不需要 new 它。
2.3 第一个动作:spark-shell 里统计文本行数
环境就绪后别急着写应用,先在 spark-shell 里验证文件读取。准备一个小文件:
echo -e "hello spark\nhadoop data\nspark line" > /home/hadoop/test.txt然后在 scala> 提示符下输入:
val textFile = sc.textFile("file:///home/hadoop/test.txt") textFile.count()逻辑说明:textFile 只是定义一个 RDD,属于 transformation,不会真的读文件;count 是 action,会触发 Spark 启动一个 job 去扫描文件并返回行数。这条链路是后面所有 Spark 程序的最小原型。
参数说明:file:/// 是本地文件 URI 的标准写法,三个斜杠一个都不能少。第一次执行 count 会看到一堆 INFO 日志,里面有 Starting task、Finished task 之类的信息,属正常现象,不是报错。如果在这步就抛 IllegalArgumentException,问题几乎都出在路径上,先翻第 5 章。
3. 核心模块拆解:同一个文本,三种读取方式背后的路径语义
3.1 本地文件读取:file:/// 为什么是三个斜杠
我在很多作业里见过同一个错误:把 file:///home/hadoop/test.txt 写成了 file://home/hadoop/test.txt,然后对着报错发呆。拆开看,file 是 URI 的 scheme,// 是 scheme 和 authority 的分隔,最后一个 / 才是根路径的起点。所以 file:///home... 表示“本地文件系统根目录下的 home/...”,而 file://home 会把 home 当成主机名,Spark 根本找不到这台主机。
val textFile = sc.textFile("file:///home/hadoop/test.txt") println("本地文件行数: " + textFile.count())逻辑说明:sc.textFile 的参数是 URI,不是普通路径。省略 scheme 时,Spark 在集群模式下默认丢给 HDFS,本地文件必须显式写 file:///。
参数说明:如果你所在目录就是 /home/hadoop,也别想当然写成 test.txt,spark-shell 的工作目录未必是家目录,最保险的写法永远是绝对路径加 file:/// 前缀。本地文件场景相对简单,但它是理解后面 HDFS 路径的基础。
3.2 HDFS 文件读取:/user/hadoop 才是根目录
本地文件读通了,下一步把同一个 test.txt 放进 HDFS。这一步要先把文件传上去:
# 建目录并上传 hadoop fs -mkdir -p /user/hadoop hadoop fs -put /home/hadoop/test.txt /user/hadoop/test.txt hadoop fs -ls /user/hadoop/test.txt参数说明:hadoop fs 是 HDFS 的命令行客户端;-mkdir -p 会自动创建中间目录;-put 是上传;-ls 用来确认文件真实存在。很多新人会在这里漏掉 -mkdir,直接 put 到一个不存在的目录,然后报错路径不存在。
上传完成后再进 spark-shell:
val hdfsFile = sc.textFile("/user/hadoop/test.txt") hdfsFile.count()逻辑说明:这里没有写 scheme,Spark 会从 core-site.xml 里找到 fs.defaultFS,把它解释成 hdfs://localhost:9000/user/hadoop/test.txt。所以 HDFS 的根不是家目录 ~,而是 /,hadoop 用户对应的个人目录是 /user/hadoop,这是 HDFS 的用户目录约定。
参数说明:如果 HDFS 里还没有这个文件,Spark 会抛 InvalidInputException。注意,本地文件系统和 HDFS 各自有各自的根,/home/hadoop/test.txt 看得见,不代表 /user/hadoop/test.txt 一定存在,两个根不互通,这是整套实验里最容易混淆的一层。
3.3 独立应用链路:SimpleApp、sbt、spark-submit 三段式
spark-shell 适合验证,但作业要求的是独立应用程序。SimpleApp 做的事情和刚才一模一样,只是换成了完整应用的形式,用 sbt 打成 JAR 再交给 spark-submit 运行。
import org.apache.spark.{SparkConf, SparkContext} object SimpleApp { def main(args: Array[String]): Unit = { val conf = new SparkConf().setAppName("SimpleApp") val sc = new SparkContext(conf) val textFile = sc.textFile("/user/hadoop/test.txt") println("文件中行数 " + textFile.count()) sc.stop() } }逻辑说明:和 spark-shell 最大的区别是,这里必须自己 new SparkContext。setAppName 只是给应用起名,会显示在 Spark UI 上;count 返回 Long,println 拼接成输出。最后 sc.stop() 很重要,不写的话 JVM 可能不会干净退出。
参数说明:SimpleApp 没有写 package,所以 --class 后面只写 SimpleApp;一旦你在源码顶部加了 package com.demo,--class 就必须写成 com.demo.SimpleApp,这是提交阶段最常见的翻车点之一。
name := "Simple Project" version := "1.0" scalaVersion := "2.11.8" libraryDependencies += "org.apache.spark" %% "spark-core" % "2.4.0"参数说明:scalaVersion 要和 Spark 的编译版本一致,这里演示用 2.11.8;spark-core 的 % "2.4.0" 要替换成你实际安装的 Spark 版本,2.x 都可以。如果版本不一致,打包能过但运行时会报二进制不兼容的错。
打包和提交:
cd /usr/local/spark/mycode/HDFStest /usr/local/sbt/sbt package /usr/local/spark/bin/spark-submit --class "SimpleApp" \ /usr/local/spark/mycode/HDFStest/target/scala-2.11/a-simple-hdfs-test_2.11-1.0.jar逻辑说明:sbt package 会在 target/scala-2.11/ 下生成 JAR,文件名由 simple.sbt 的 name 和 version 拼出来;spark-submit 的 --class 告诉 Spark 入口对象是谁,后面跟 JAR 路径。
参数说明:命令里的 /usr/local/spark/bin/spark-submit 是完整路径,如果把 SPARK_HOME/bin 加进了 PATH,也可以直接写 spark-submit。JAR 路径一定要指向 sbt 实际输出的文件,很多人不看打包日志,凭感觉自己拼路径,结果 No such file。
| 方式 | 文件位置 | 关键步骤 | 典型使用场景 |
|---|---|---|---|
| spark-shell | 本地文件 | sc.textFile("file:///...").count() | 最快验证环境、路径 |
| spark-shell | HDFS | hadoop fs -put 后 textFile("/user/hadoop/...") | 验证 HDFS 连通与文件存在 |
| 独立应用 | HDFS | sbt package + spark-submit --class | 交作业、跑定时脚本、生产任务 |
4. 进阶作业拆解:数据去重与平均成绩是两个典型 Transform 组合
4.1 数据去重:union 之后接 distinct,整行去重就够了
第二个独立应用叫 RemDup,输入是两个文件 A 和 B,每行都是“日期 用户名”的格式,要求合并后去除完全相同的行,输出到新文件 C。A 里有一行 20170102 y,B 里也有 20170102 y,这就是要剔除的重复项;A 里的 20170101 x 和 B 里的 20170101 y 虽然日期相同但用户不同,属于不同行,要保留。
import org.apache.spark.{SparkConf, SparkContext} object RemDup { def main(args: Array[String]): Unit = { val conf = new SparkConf().setAppName("RemDup") val sc = new SparkContext(conf) val dataA = sc.textFile("file:///home/hadoop/input/A.txt") val dataB = sc.textFile("file:///home/hadoop/input/B.txt") val result = dataA.union(dataB).distinct().sortBy(line => line) result.saveAsTextFile("file:///home/hadoop/output/C.txt") sc.stop() } }逻辑说明:union 把两个 RDD 拼成一个,但不做任何去重;distinct 对整行数据做全局去重,因为重复行的定义就是内容完全一致,不需要 split 解析字段;sortBy(line => line) 按字典序排序,是为了让输出和作业样例好对比。
参数说明:saveAsTextFile 的目标目录在运行前必须不存在,否则直接 FileAlreadyExistsException。输出不是单文件,而是一个目录加 part-00000 这类分片文件,这是分布式输出的正常样子,别以为程序出错。
提交命令:
/usr/local/sbt/sbt package /usr/local/spark/bin/spark-submit --class "RemDup" \ /home/hadoop/sparkapp2/RemDup/target/scala-2.11/remove-duplication_2.11-1.0.jar参数说明:每次想重新跑之前,先删掉上次的 C 目录,本地用 rm -rf,HDFS 用 hadoop fs -rm -r。我习惯把删除命令和提交命令写在一起,省得第二次运行被同一个坑绊倒。
4.2 平均成绩:groupByKey 与 reduceByKey 的取舍
第三个应用按学科存放成绩文件,Algorithm、Database、Python 三个文件,每行是“名字 成绩”,要求算出每个学生的平均分。这个场景比去重多了一个 step:先按名字分组,再对组内成绩求平均。
import org.apache.spark.{SparkConf, SparkContext} object AvgScore { def main(args: Array[String]): Unit = { val conf = new SparkConf().setAppName("AvgScore") val sc = new SparkContext(conf) val paths = Seq( "file:///home/hadoop/sparkapp3/datas/Algorithm.txt", "file:///home/hadoop/sparkapp3/datas/Database.txt", "file:///home/hadoop/sparkapp3/datas/Python.txt" ).mkString(",") val scores = sc.textFile(paths).map { line => val parts = line.split(" ") (parts(0), (parts(1).toDouble, 1)) }.reduceByKey { case ((sum1, n1), (sum2, n2)) => (sum1 + sum2, n1 + n2) } val avg = scores.mapValues { case (sum, n) => f"${sum / n}%.2f" } avg.saveAsTextFile("file:///home/hadoop/sparkapp3/output/avg") sc.stop() } }逻辑说明:sc.textFile 的路径用逗号拼成一个字符串,Spark 会自动把多个文件当成一个输入集合;map 阶段把“小明 92”拆成 (小明, (92.0, 1)),第二个字段的 1 是计数器;reduceByKey 把同一个学生的 (成绩和, 次数) 两两合并,最后 mapValues 里 sum 除以 n 就是平均分。
参数说明:score 是 Double 不是 Int,因为平均分要带小数;f"${sum / n}%.2f" 是 Scala 的格式化字符串,83.6666 会被输出成 83.67,和作业样例一致。如果只关心正确性,不关心小数位,用 sum / n 也行,但会被截断成整数。
对比一下 groupByKey 的写法:
val grouped = scores.map { line => val parts = line.split(" ") (parts(0), parts(1).toDouble) }.groupByKey() val avg2 = grouped.mapValues { iter => val list = iter.toList list.sum / list.size }逻辑说明:groupByKey 会把同一个 key 的所有成绩通过网络洗牌到同一个分区,再统一 sum;reduceByKey 先在每个分区内部做局部合并,再洗牌少数中间结果。数据量小的时候两者跑起来差不多,数据量一大,reduceByKey 的 Shuffle 数据量会明显小,这也是官方文档建议优先用 reduceByKey 的原因。
参数说明:groupByKey 的 iter 是 Iterable[Double],不能遍历两次,所以先 toList 再 sum,直接 iter.sum 再 iter.size 会得到 0 或者其他奇怪值,这是 Scala Iterator 只能消费一次的经典坑。
提交命令:
/usr/local/spark/bin/spark-submit --class "AvgScore" \ /home/hadoop/sparkapp3/AvgScore/target/scala-2.11/average-score_2.11-1.0.jar运行后输出目录里就是一行一个学生成绩,例如 (小红,83.67)。
4.3 sbt 目录约定:为什么每个作业都要重建一个工程
三个作业各自有独立文件夹,不是随手乱放,而是 sbt 工程结构的硬性要求。以 AvgScore 为例:
sparkapp3/ ├── simple.sbt ├── src │ └── main │ └── scala │ └── AvgScore.scala └── datas/ ├── Algorithm.txt ├── Database.txt └── Python.txt逻辑说明:sbt 规定源码必须放在 src/main/scala 下面,simple.sbt 和 src 平级;datas 目录只是本地输入数据,不参与打包。如果你把 AvgScore.scala 直接放到 sparkapp3 根目录再执行 sbt package,sbt 会告诉你没有源文件,或者打出一个空 JAR。
参数说明:target/scala-2.11 是编译输出目录,里面的 JAR 名字来自 simple.sbt 的 name 和 version,例如 name := "AvgScore"、version := "1.0" 就会生成 average-score_2.11-1.0.jar。提交的时候 --class 跟的是 Scala object 的名字,不是 JAR 文件名,很多初学把两者搞混,报了 ClassNotFoundException 还在反复检查 JAR 路径。
5. 避坑记录:三次路径错误,全是被细节卡住的
5.1 IllegalArgumentException:file:/// 少打一个斜杠
现象:spark-shell 里读取本地文件,一行命令刚回车,日志直接抛 IllegalArgumentException,后面跟着一串路径解析异常。
原因:路径写成 file://home/hadoop/test.txt 或者 file:/home/hadoop/test.txt。Spark 的 textFile 把输入当成 URI 解析,file 是协议名,// 是协议和主机名之间的分隔,最后一个 / 才指向文件系统根路径。少一个斜杠,home 就被当成主机名。
解决:统一写成 file:///home/hadoop/test.txt,三个斜杠。我不太信自己的记忆,所以每次写完先复制到浏览器地址栏看一眼,浏览器能打开的本地路径格式,Spark 通常也认。
5.2 InvalidInputException:HDFS 路径不是家目录
现象:用 spark-shell 读 HDFS 文件,报 InvalidInputException,文字提示 Input path does not exist,有时还带着 hdfs://localhost:9000/user/root/... 这样的路径。
原因:把 Linux 的家目录概念带到了 HDFS。HDFS 的根就是 /,hadoop 用户有自己的 home 目录约定 /user/hadoop,但这不是 shell 里的 ~,更不是 /home/hadoop。test.txt 必须先放到 HDFS 上,代码里的路径也必须写 HDFS 视角的路径。
解决:先 hadoop fs -mkdir -p /user/hadoop,再 hadoop fs -put /home/hadoop/test.txt /user/hadoop/test.txt,确认 put 成功后再跑 Spark。把“看得到本地文件”和“HDFS 里有文件”当成两件没有必然联系的事,这个坑基本就避开了。
5.3 URISyntaxException:字符串开头多了个空格
现象:独立应用提交后,报 java.net.URISyntaxException: Illegal character in scheme name at index 0,看着像路径问题,但错误信息完全不提文件名。
原因:源码里的路径字符串是 " /user/hadoop/test.txt",开头多了一个空格。URI 解析器要求 scheme 名以字母开头,空格出现在 index 0 直接非法,所以报的是 scheme name 而不是 file not found。
解决:把空格删掉,或者写统一入口时对参数做 trim()。这个报错非常容易让人以为是 Spark 版本问题,我建议一旦看到 scheme name at index 0,先 println("[" + path + "]") 看路径两边的空白字符。
5.4 saveAsTextFile 目录已存在与 --class 全类名
现象:同一份 JAR 第二次运行,报 FileAlreadyExistsException;或者把代码加了 package 之后,提交报 ClassNotFoundException。
原因:saveAsTextFile 要求输出路径运行前不存在,Spark 不会像普通文件系统那样自动覆盖上次结果;--class 必须写全类名,一旦 object 前面加了 package 声明,只写类名就找不到入口。
解决:每次提交前先删输出目录,本地用 rm -rf,HDFS 用 hadoop fs -rm -r;提交命令里的 --class 也同步改成完整全类名。再补一句,如果提交时 master 没指定,spark-submit 默认会按配置文件找 YARN,本地没起 YARN 就加上 --master local[*] 让它在本地直接跑。
6. 二次开发:把三份作业改成你自己的数据处理工具
前面三个应用如果把文件路径全改成 args 参数,这套代码立刻能从“作业”变成“工具”。最简单的改造是提交时传路径:spark-submit --class "SimpleApp" xxx.jar /user/hadoop/test.txt /user/hadoop/output,在 main 里读取 args 就行。
val input = if (args.length > 0) args(0) else "file:///home/hadoop/test.txt" val output = if (args.length > 1) args(1) else "file:///home/hadoop/output/result"参数说明:args 会被 spark-submit 自动传到目标 JAR 的 main 方法,注意要放在 JAR 路径后面,不是放在 --class 前面,顺序写反 Spark 会把路径当成自己的参数吃掉。
再进一步,AvgScore 可以直接换成 DataFrame 写法,少写一半样板代码:
val spark = SparkSession.builder().appName("AvgScoreSQL").master("local[*]").getOrCreate() import spark.implicits._ val df = spark.read.text("file:///home/hadoop/input").toDF("line")验证方法也很简单:准备一个三行小文件,跑完 cat 输出目录里的 part 文件,对比手工算的平均值,正确后再换成完整数据。我从那以后每次敲 textFile 都会默念一遍:file:/// 是三个斜杠,HDFS 根不是 ~,路径前不能有空格,输出目录要先删。这些坑没有一个是分布式原理太难,全是字符串细节,但每一个都足够让人怀疑人生。希望帮到你。
本文还有配套的精品资源,点击获取