
简介面向大数据课程学习者与 Spark 初学者的实验报告文档围绕 Spark 初级编程实践展开适合配合 Hadoop 生态入门、课程实验复盘和动手练习使用。压缩包内含 1 个 docx 文件整体约 1.9MB以实验报告形式组织内容便于直接查阅实验环境、过程记录与运行结果。文档覆盖 Hadoop 与 Spark 的安装启动、spark-shell 交互式读取本地及 HDFS 文件并统计行数、Scala 独立应用打包提交、两文件合并去重以及学生平均成绩计算等典型任务并记录了文件路径参数异常等常见问题的排查思路与解决方式。已有 8351 人学习适合需要一份可对照实验过程与排错参考的读者用于理解 Spark 基本数据读取、RDD 操作和独立应用开发流程。1. 从一条 spark-submit 命令说起这套实验要打通什么「实验七Spark 初级编程实践」在课程表上只有一行字落到手上却是一整条链路Ubuntu 虚拟机里装好 Hadoop 3.1.3、解压 Spark、在 spark-shell 里读本地文件和 HDFS 文件、再写 Scala 独立应用用 sbt 打成 JAR最后 spark-submit 提交跑出结果。大数据方向的学生最容易翻车的地方不是算子写不出来而是环境与路径——少写一个斜杠程序就甩一个 IllegalArgumentException 给你屏幕上连行号都没有。这套方案面向三类人要交大数据课程实验报告的学生、要做 Spark 入门毕设开题的同学、以及需要给团队搭一套最小可跑 Spark 开发环境的从业者。下面按「环境 → 交互式读取 → 独立应用工程化 → 三个算子练习 → 报错排查 → 验收答辩」的顺序拆开每一步都给到能直接照抄的命令、配置和代码。2. 环境搭建Windows 主机加 Ubuntu 虚拟机的四层版本对齐2.1 宿主机与虚拟机的分工为什么不直接在 Windows 上跑实验环境是 Windows 10 家庭中文版作为宿主机处理器 4 核、内存 16GBSpark 和 Hadoop 全部装在虚拟机里的 Ubuntu 16.04 上。这个分工不是随便定的Hadoop 的启动脚本是 shell 脚本HDFS 的权限模型、路径分隔符、文件换行符都按类 Unix 系统设计在 Windows 上跑伪分布式要额外处理 winutils 和权限兼容一个实验课的时间根本不够折腾。虚拟机的好处是快照——环境搞崩了回滚一次比重新装一遍省两小时。版本这块有一条硬约束容易被忽略Hadoop 3.1.3 依赖 JDK 1.8Spark 的 Scala 二进制版本必须和编译时用的 Scala 版本一致。原文里打出来的 jar 落在target/scala-2.11/目录下说明整个工具链锁在 Scala 2.11 系列。如果你后面换了 Scala 2.12 去编译jar 能打出来提交时会直接甩 NoSuchMethodError 或者 ClassNotFoundException排查起来非常难受。我一般的做法是环境装完先把四个版本号写进实验报告第一页后面出任何问题先回头看这张表。组件版本作用对齐要点虚拟机系统Ubuntu 16.04运行 Hadoop 与 Spark64 位磁盘不小于 30GBJDK1.8Hadoop 与 Spark 的运行时只能装一个版本避免多 JDK 冲突Hadoop3.1.3提供 HDFS 存储与 Spark 编译版本匹配Scala2.11 系列编写 Spark 应用与target/scala-2.11一致2.2 JDK 与 Hadoop 的解压式安装环境变量是第一步Hadoop 和 Spark 都不需要 apt 安装下载 tar.gz 解压到固定目录即可。约定俗成的路径是/usr/local/hadoop和/usr/local/sparkJDK 放/usr/local/jdk1.8。这三个路径一旦定下来就不要改因为后面 sbt 配置、spark-submit 命令、实验报告截图里都会出现它们中途改路径等于把前面所有记录推翻。环境变量统一写进~/.bashrc改完source ~/.bashrc生效。这里有个细节JAVA_HOME必须导出Hadoop 的启动脚本会去读它缺了它会报 JAVA_HOME is not set 然后静默退出看起来像是脚本没执行。# ~/.bashrc 末尾追加路径按自己解压位置改 export JAVA_HOME/usr/local/jdk1.8 export HADOOP_HOME/usr/local/hadoop export HADOOP_CONF_DIR$HADOOP_HOME/etc/hadoop export SPARK_HOME/usr/local/spark export PATH$PATH:$JAVA_HOME/bin:$HADOOP_HOME/bin:$HADOOP_HOME/sbin:$SPARK_HOME/bin export CLASSPATH.:$JAVA_HOME/lib/dt.jar:$JAVA_HOME/lib/tools.jarHADOOP_CONF_DIR这一行是给 Spark 用的Spark 启动时会去这个目录找core-site.xml和hdfs-site.xml从而知道 HDFS 的默认访问地址。没有它你在代码里写相对路径时 Spark 根本不知道去哪儿找文件。CLASSPATH里的dt.jar和tools.jar是 JDK 自带的工具类Eclipse 编译老项目时会用到顺手加上不吃亏。2.3 伪分布式最小配置与 HDFS 自检实验只要求单机跑通所以 Hadoop 用伪分布式模式两个配置文件就够。core-site.xml指定 HDFS 的默认地址hdfs-site.xml把副本数降到 1——伪分布式只有一个 DataNode写 3 副本会一直停留在副本不足状态虽然不影响读写但hdfs dfsadmin -report的输出会一直显示 under-replicated答辩时被问到不好解释。!-- $HADOOP_HOME/etc/hadoop/core-site.xml -- configuration property namefs.defaultFS/name valuehdfs://localhost:9000/value /property /configuration !-- $HADOOP_HOME/etc/hadoop/hdfs-site.xml -- configuration property namedfs.replication/name value1/value /property /configuration配置完成后是格式化 NameNode 和启动。这里有一个血泪经验hdfs namenode -format一辈子只对同一个数据目录执行一次。第二次格式化会生成新的 clusterID而 DataNode 里存的还是旧 ID结果是 DataNode 启动后秒退日志里写 Incompatible clusterIDs。解决办法是删掉tmp和dfs/data目录再格式化或者干脆回滚虚拟机快照。hdfs namenode -format # 只执行一次重复执行会导致 DataNode 起不来 start-dfs.sh # 启动 NameNode、DataNode、SecondaryNameNode jps # 应看到 NameNode、DataNode、SecondaryNameNode 三个进程 hdfs dfs -ls / # 能列目录就说明 HDFS 起来了jps是验证 Java 进程最直接的工具比看日志快。三个进程里少一个先去$HADOOP_HOME/logs下看对应角色的.log文件最后几十行基本就写着原因。2.4 Spark 解压与 spark-shell 首次启动Spark 的解压和 Hadoop 一样改个目录名就行。启动交互式 Shell 用./bin/spark-shell第一次启动会输出一大段日志最后出现scala提示符就算成功。日志里那一堆 INFO 是 log4j 的默认级别做实验时看着乱可以改$SPARK_HOME/conf/log4j.properties把 rootLogger 级别从 INFO 调到 WARN屏幕立刻清爽这个设置建议在截图前完成。cd /usr/local/spark ./bin/spark-shell # 启动交互式 Scala 环境出现 scala 提示符即成功Shell 启动时会自动创建一个名为sc的 SparkContext 对象后面所有sc.textFile都依赖它。如果启动日志里出现 Failed to get broadcast 或者内存相关的告警去conf/spark-env.sh里显式指定SPARK_LOCAL_IP127.0.0.1虚拟机的多网卡环境经常让 Spark 选错网卡地址。启动成功后先别急着写业务代码用sc.master看一眼运行模式确认是local[*]说明当前是本地模式不存在集群资源申请的问题这对实验环境来说是正常且够用的。3. spark-shell 的双路径读取本地文件与 HDFS 文件的行数统计3.1 本地文件file:/// 三斜杠不是笔误第一个练习是在 spark-shell 里读取 Linux 本地文件并统计行数。命令看着简单但file:///usr/local/hadoop/1.txt里为什么是三个斜杠绝大多数人第一次写都会写成两个然后收获一个 IllegalArgumentException。URI 的通用格式是scheme://authority/path两个斜杠后面跟的是主机名。对本地文件来说没有主机名authority 部分是空的所以协议头file://加上绝对路径开头的/拼起来就是三个斜杠。写成file://usr/local/hadoop/1.txtSpark 会把usr当成主机名去解析路径里自然找不到文件。// 正确写法file:// 是协议头第三个斜杠是根目录 val localFile sc.textFile(file:///usr/local/hadoop/1.txt) localFile.count() // 返回文件总行数比如 4 // 也可以省略协议头直接给绝对路径Spark 会按本地文件处理 val sameFile sc.textFile(/usr/local/hadoop/1.txt)两种写法在本地模式下等价但在提交到集群或者配置了fs.defaultFS的环境里/usr/local/hadoop/1.txt会被当成 HDFS 路径这时候就会报路径不存在。所以写实验报告时我建议统一带file:///前缀语义明确换环境也不会出问题。另外注意textFile是按行读的返回的是 RDD[String]每个元素是一行文本count()统计的是元素个数也就是行数不是字节数。3.2 HDFS 文件/user/hadoop 才是默认落脚点第二个练习读 HDFS 上的文件路径写成/user/hadoop/test.txt。这里最常见的误解是把 HDFS 的根目录当成/home/hadoop——那是 Linux 的用户家目录HDFS 是另一套独立的文件系统它的根目录就是/而用户的默认工作目录是/user/用户名。如果 HDFS 里没有这个文件得先上传。# 在 HDFS 里创建用户目录并上传文件源文件是 Linux 本地文件 hdfs dfs -mkdir -p /user/hadoop hdfs dfs -put /usr/local/hadoop/test.txt /user/hadoop/ hdfs dfs -ls /user/hadoop # 确认真的是 /user/hadoop/test.txt 而不是别的上传完成后回到 spark-shell两种写法都能读到一是带完整协议头hdfs://localhost:9000/user/hadoop/test.txt二是只写/user/hadoop/test.txt后者依赖core-site.xml里配的fs.defaultFS。实验报告里截图用的是完整路径更保险因为一旦换了环境core-site.xml不一定还在。// 完整协议头写法主机和端口与 core-site.xml 保持一致 val hdfsFile sc.textFile(hdfs://localhost:9000/user/hadoop/test.txt) hdfsFile.count() // 依赖 fs.defaultFS 的简写 val hdfsFile2 sc.textFile(/user/hadoop/test.txt)3.3 只统计行数不够用 take 和 first 做交叉验证count()返回一个数字但数字对不对你没法判断——文件里本来就有 4 行统计出 4 是应该的统计出 0 你也看不出是空文件还是路径错了。所以我在实验里会补两个动作first()看第一条内容take(5)看前五条。如果first()抛java.util.NoSuchElementException: next on empty iterator说明文件读到了但是空的或者路径指向了一个空目录如果count()是 0 而first()也报错大概率路径本身就错了。val rdd sc.textFile(file:///usr/local/hadoop/test.txt) println(行数 rdd.count()) println(第一条 rdd.first()) rdd.take(5).foreach(println) // 打印前 5 行肉眼核对内容这三个动作加起来不到十秒但能省掉后面独立应用调试时的一大半怀疑时间。做实验报告时把这三个输出都截图上比只有一个数字有说服力得多。3.4 spark-shell 的退出与二次启动注意用:quit退出 Shell别直接 CtrlZ 挂起挂起后 JVM 还占着内存再启动一次容易 OOM。另外 Shell 每次启动都是全新的 SparkContext上一次定义的 RDD 和变量不会保留所以实验过程中要么一次把命令敲完要么把代码存成.scala文件用:load载入。我一般会准备一个test.scala文件写好命令序列需要演示时:load /home/hadoop/test.scala一次性跑完答辩现场不会因为手抖敲错命令翻车。4. 独立应用工程化sbt 工程、jar 打包与 spark-submit 的三段式4.1 工程骨架src/main/scala 与 simple.sbt从 spark-shell 转到独立应用最大的变化是代码要按 Maven 目录规范摆放否则 sbt 找不到源文件。标准骨架是工程根目录下放simple.sbt源码放src/main/scala/编译产物落在target/scala-2.11/。目录名一个字母都不能错写成src/main/Scala在 Linux 下是另一个目录sbt 会编译出零个 class打包时得到一个没有主类的空 jar。# 以 SimpleApp 工程为例的目录结构 HDFStest/ ├── simple.sbt └── src/ └── main/ └── scala/ └── SimpleApp.scalasimple.sbt里声明的三件事工程名决定 jar 包名、Scala 版本决定 target 下的子目录名、依赖。spark-core建议加provided作用域因为集群上已经有这个包打进去只会让 jar 变大还容易版本冲突本地spark-submit时会从$SPARK_HOME/jars里加载不影响运行。// simple.sbt工程名决定最终 jar 文件名 name : a-simple-hdfs-test version : 1.0 scalaVersion : 2.11.8 // 与 Spark 自带的 Scala 二进制版本保持一致 // 版本号以 $SPARK_HOME/jars 下的 spark-core-*.jar 实际版本为准不要照抄 libraryDependencies org.apache.spark %% spark-core % 2.4.0 % provided%%是 sbt 的语法糖会自动在依赖名后追加当前 Scala 主版本号写成spark-core_2.11也可以但换 Scala 版本时要手动改不如%%省事。4.2 SimpleAppHDFS 行数统计的完整写法独立应用和 Shell 的区别是要自己创建SparkContext并显式关闭。不关闭的话程序跑完不退出spark-submit会一直挂着看起来像是卡死了。import org.apache.spark.SparkContext import org.apache.spark.SparkConf object SimpleApp { def main(args: Array[String]): Unit { // setMaster 在提交到集群时可以不写本地调试建议保留 local[*] val conf new SparkConf().setAppName(SimpleApp).setMaster(local[*]) val sc new SparkContext(conf) // 读 HDFS 上的文件路径规则和 Shell 里完全一致 val textFile sc.textFile(hdfs://localhost:9000/user/hadoop/test.txt) val count textFile.count() println(文件中行数 count) // 预期输出 4 sc.stop() // 不调用会残留线程进程不退出 } }object而不是class因为 Scala 的入口方法必须定义在单例对象里main方法签名固定为Array[String] Unit。类名SimpleApp要和后面--class参数完全一致包括大小写——Spark 用反射加载主类名字差一个字母就是 ClassNotFoundException。4.3 RemDupunion 之后 distinct顺序不能反数据去重这个练习给的是两个日期加字母的样本文件A 和 B 里有大量重复的 (日期, 字母) 组合要求合并后去重输出到 C。思路很直接两个 RDD 做union再对结果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).setMaster(local[*]) 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 是合并distinct 是全局去重顺序反过来结果一样但语义不同 val result fileA.union(fileB).distinct() result.saveAsTextFile(hdfs://localhost:9000/user/hadoop/output_c) sc.stop() } }union只做分区拼接不产生 shuffle也不去重distinct内部会走一次 shuffle 把相同元素聚到一起。所以union在前distinct在后是唯一合理的顺序反过来distinct完了再union等于去重两次结果一样但多做一次 shuffle在数据量大的时候代价翻倍。输出用saveAsTextFile它会生成一个目录里面是part-00000这样的分片文件同时伴随_SUCCESS标记文件看到_SUCCESS就说明写成功了这个文件在验收时很有用。4.4 AvgScorecombineByKey 求平均别用 groupByKey 硬扛求平均分是三个练习里唯一需要按键聚合的。输入是三个课程文件每行「姓名 成绩」输出每个学生的平均分。最直观的写法是groupByKey把同一个人的所有成绩收成一个迭代器再求平均代码能跑但groupByKey会把所有数据拉到同一个分区数据量一大就 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).setMaster(local[*]) val sc new SparkContext(conf) // 读取 datas 目录目录下所有文件都会被当作输入 val lines sc.textFile(hdfs://localhost:9000/user/hadoop/datas) // 解析成 (姓名, 成绩) 的键值对split(\\s) 兼容多个空格和制表符 val pairs lines.map { line val arr line.trim.split(\\s) (arr(0), arr(1).toDouble) } // combineByKey 分三步初始值、分区内合并、跨分区合并 val sumCount pairs.combineByKey( (score: Double) (score, 1), (acc: (Double, Int), score: Double) (acc._1 score, acc._2 1), (a: (Double, Int), b: (Double, Int)) (a._1 b._1, a._2 b._2) ) // 元组里存的是 (总分, 门数)相除得到平均分保留两位小数 val avg sumCount.mapValues { case (sum, cnt) f${sum / cnt}%.2f } avg.saveAsTextFile(hdfs://localhost:9000/user/hadoop/avg_output) sc.stop() } }三个函数参数的含义要讲清楚第一个是每个键的初始累加器这里用(成绩, 1)表示「这一门成绩 计 1 次」第二个是分区内遇到同键新值时的合并逻辑把成绩相加、计数加一第三个是两个分区的累加器合并同样是对应位置相加。这样全程只在 shuffle 阶段传输二元组不会把原始成绩全拉到一个节点。mapValues只对 value 做变换不触发 shuffle所以最后的格式化几乎不花钱。样本跑出来是 (小红,83.67)、(小新,88.33)、(小明,89.67)、(小丽,88.67) 这一组数可以拿来对照验证。4.5 sbt package 与 spark-submit把命令拆开看每一个参数打包命令是/usr/local/sbt/sbt package第一次执行会去下载依赖网速慢的话等几分钟之后有缓存就快了。成功时输出里会出现[success]和 jar 的绝对路径路径形如target/scala-2.11/a-simple-hdfs-test_2.11-1.0.jar中间的_2.11是 sbt 自动加的 Scala 版本后缀。提交命令看着长拆开只有四段--class指定主类全名后面跟 jar 的绝对路径。主类名不带包名是因为代码没写package声明如果写了package com.demo--class就必须写成com.demo.SimpleApp这是新手第二大坑。# 打包在工程根目录有 simple.sbt 的那一层执行 /usr/local/sbt/sbt package # 提交运行--class 后面跟主类名最后是 jar 的完整路径 /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去重和求平均两个工程的提交命令同理只是主类和 jar 名换成RemDup配remove-duplication_2.11-1.0.jar、AvgScore配average-score_2.11-1.0.jar。三个工程建议分目录放共用一份 sbt 配置模板改name和类名即可比每次从零建目录可靠。5. 常见问题与排查三条报错背后的路径与语法规则5.1 现象读本地文件抛 IllegalArgumentException现象spark-shell 里执行读取本地文件的命令抛出java.lang.IllegalArgumentException堆栈里能看到 Hadoop 的Path相关类但看不出具体是哪个参数不合法。原因路径写成了file://usr/local/hadoop/1.txt只有两个斜杠。URI 规范里//之后是 authority主机名Spark 把usr当成主机名后面再拼/local/hadoop/1.txt构造出来的路径不合法。解决改成file:///usr/local/hadoop/1.txt三个斜杠。判断方法是数斜杠协议头两个路径开头的根目录一个合起来三个。我习惯在代码里把这行单独拎出来加注释写清楚为什么是三个免得下次复制粘贴又改回去。5.2 现象读 HDFS 文件抛 InvalidInputException提示输入路径不存在现象org.apache.hadoop.mapred.InvalidInputException: Input path does not exist后面跟着一串路径。原因把 HDFS 当成了 Linux。HDFS 里没有~这个家目录也没有/home/hadoop用户的默认工作目录是/user/hadoop。另一个变体是文件确实上传了但传到了/user/root/下面因为当时用的是 root 用户执行hdfs dfs -put。解决先用hdfs dfs -ls /user/hadoop确认文件到底在不在、叫什么名字再写路径。上传时统一用hdfs dfs -put 本地文件 /user/hadoop/并且养成上传完立刻ls一遍的习惯。路径确认无误后再回 spark-shell 执行count()别在没确认的情况下反复改代码。5.3 现象URISyntaxException: Illegal character in scheme name at index 0现象程序运行直接抛java.net.URISyntaxException: Illegal character in scheme name at index 0连 Spark 的作业都没提交上去。原因路径字符串最前面有一个空格或者全角字符。做实验时经常是从文档里复制路径粘贴到代码里带上了看不见的空白URI 解析器读到的第一个字符不是字母判断不出协议名。解决把路径字符串删掉重打一遍别复制。或者在代码里对路径做.trim处理但根本办法是别让脏字符进代码。这个错误定位很快——index 0明确告诉你问题在第一个字符看到这个报错直接去看字符串开头就行。5.4 现象提交后抛 ClassNotFoundException 或 NoSuchMethodError现象jar 打包显示 successspark-submit一执行就报ClassNotFoundException: SimpleApp或者跑起来报NoSuchMethodError。原因两种可能。一是--class后面写的类名和代码里的object名不一致或者代码里写了package但--class没带包名。二是 Scala 版本不匹配用 2.12 编译的 jar 提交到自带 2.11 的 Spark 上类加载时找不到对应的方法签名。解决先jar tf xxx.jar | grep class看 jar 里到底有哪些 class、在哪个包路径下再核对--class。版本问题看target/下面的目录名是scala-2.11就对是scala-2.12就把simple.sbt里的scalaVersion改回 2.11 系列并sbt clean package重新打。5.5 现象输出目录已存在导致作业失败现象saveAsTextFile那一步报org.apache.hadoop.mapred.FileAlreadyExistsException或者提示 output directory already exists。原因Spark 的输出语义是「不允许覆盖」目标目录存在就直接失败这是为了防止误删数据。第二次跑同一个程序必然撞上。解决每次运行前删掉输出目录hdfs dfs -rm -r /user/hadoop/output_c。写实验报告时把这个清理命令也写进去说明自己知道这个机制比答辩时被问住强。本地路径输出同理rm -rf一下。排查顺序上我一般按「报错关键字 → 定位类型 → 缩小范围」三步走看到 IllegalArgumentException 先看路径格式看到 InvalidInputException 先hdfs dfs -ls看到 URISyntaxException 先看字符串开头看到 ClassNotFound 先jar tf看包结构。这四类覆盖了这套实验里九成以上的失败场景。6. 验收与答辩怎么证明这条链路真的跑通了验收不是把截图堆上去就完事评审看的是「你能不能复现」。我建议按下面的清单逐项过一遍每一项都有明确的命令和预期输出任何一项对不上就说明链路有断点。验收项执行命令预期结果HDFS 服务正常jps出现 NameNode、DataNode、SecondaryNameNodeShell 可启动./bin/spark-shell出现scala提示符本地文件读取sc.textFile(file:///usr/local/hadoop/1.txt).count()返回具体行数非 0HDFS 文件读取sc.textFile(hdfs://localhost:9000/user/hadoop/test.txt).count()返回 4独立应用打包sbt package输出[success]target 下有 jar独立应用提交spark-submit --class ...控制台打印行数或平均分结果输出落盘hdfs dfs -ls /user/hadoop/output_c存在 part-00000 与 _SUCCESS答辩时被问得最多的三个问题提前想好答案。第一个是「为什么用 Scala 不用 Java」答Spark 本身用 Scala 写RDD 的 API 在 Scala 里最自然函数式写法代码量只有 Java 的三分之一而且 sbt 打包比 Maven 配置简单。第二个是「distinct 和 groupByKey 有什么区别」答distinct 是全局去重内部走 shufflegroupByKey 是按 key 聚合但不做计算容易把数据全拉到一台机器上求平均应该用 combineByKey 在分区内先算。第三个是「本地模式和集群模式的区别」答本地模式用一个 JVM 模拟 Executorlocal[*]的星号表示用满所有核提交到集群时要把setMaster去掉由spark-submit --master指定。演示环节有个小技巧提前把所有命令写进一个 shell 脚本答辩时按顺序执行中间不手动敲。我当时第一次答辩就是现场手敲路径少打一个斜杠屏幕上直接一片红后面解释了三分钟才圆回来。从那以后我每次做这类演示都强制走一遍「脚本预演 快照回滚」跑之前先在快照上完整走一遍流程确认没有遗漏再上台。希望这套结构能帮到你把实验报告写扎实也把这条 Spark 开发链路真正跑通一次。本文还有配套的精品资源点击获取