
简介面向大数据技术原理与应用课程的学习者这份 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类初始化失败报错信息还指不到根因上。所以第一步先用一条命令确认默认 JDKjava -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.xsbt 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 namefs.defaultFS/name valuehdfs://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 的路径解析为什么少个斜杠就抛 IllegalArgumentExceptionSpark 解析路径时走的是 Hadoop 的FileSystem.get(URI, conf)也就是说它期望一个合法的 URI。URI 的通用格式是scheme://authority/path本地文件系统的 scheme 是fileauthority 部分为空所以完整的本地绝对路径必须写成file:///home/hadoop/test.txt三个斜杠里前两个属于//第三个才是根目录/。写成file://home/hadoop/test.txtHadoop 会把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 端变成数组只适合几行到几万行的量级。如果你的文件是几百 MBcollect会直接把 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-submitspark-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 InvalidInputExceptionHDFS 路径写成了家目录现象读/user/hadoop/test.txt报org.apache.hadoop.mapred.InvalidInputException: Input path does not exist但你明明记得文件已经放进去了。原因有两个。一是文件压根没上传只在 Linux 本地存在二是路径写成了~/test.txt或/home/hadoop/test.txtHDFS 的命名空间里没有~展开也没有/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 NoClassDefFoundErrorsimple.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.scalaJAR 路径写错实际读的是另一个旧包。解决--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.rootCategoryINFO, console改成WARN, console再启动世界就清净了。独立应用想定向调级别可以在SparkConf里set(spark.ui.showConsoleProgress, false)配合日志级别一起用。还有个验证手段很多人不知道--master local[2]启动后浏览器打开http://localhost:4040能看到这次作业的 DAG、stage 划分和 shuffle 读写量。去重作业里你会看到distinct对应一个带 shuffle 的 stagesortBy又是一个两个 shuffle 连着来。看到这个图你对“哪些算子会触发 shuffle”的理解会比背八股文扎实得多。从那以后我每次提交 JAR 之前都强制先走一遍hdfs dfs -ls确认输入路径存在、unzip -l确认 class 打进去了两步加起来不到十秒能省掉一晚上。希望帮到你。本文还有配套的精品资源点击获取