☰
Spark初级编程实践:文件路径与HDFS读取避坑指南
2026/10/22 1:09:21 网站建设 项目流程

简介:面向大数据课程实验七的 Spark 初级编程实践完整报告,适合正在学习 Hadoop/Spark 环境搭建与 Scala 独立应用开发的本科学生。文档基于 Ubuntu Kylin 16.04 虚拟机、Hadoop 3.1.3、JDK 1.8 和 Spark 环境,完整记录从安装启动到编程验证的过程,包括通过 spark-shell 读取本地文件与 HDFS 文件并统计行数,以及实现 SimpleApp、RemDup、AvgScore 三个 Scala 独立应用,覆盖 sbt 打包、spark-submit 提交运行和结果输出。资源包为 1 个 docx 文档,大小约 1.9MB,正文包含实验环境、代码清单、运行截图及常见问题排查,对提交课程实验报告或复习 Spark 入门操作有直接参考价值。已有 8345 人浏览学习,适合按步骤对照复现。

1. Spark初级编程实践:最值钱的不是代码,是三条文件路径

一份Spark初级编程实践的实验记录,通常被当成课程作业看待:装好Hadoop和Spark,启动spark-shell,写几个Scala独立应用,跑完交差。但这份实验记录真正值钱的部分,藏在"出现的问题"那一节里——三个报错全部指向同一个大坑:文件路径写错。file:///少打一个斜杠、把HDFS根目录当成~、URL前面多了个空格,每一个都能让新手卡半小时以上。这篇文章把环境搭建、Spark读取本地与HDFS文件、数据去重、求平均值整条链路拆开讲,保留完整可复现的代码和踩坑记录,适合正在做类似课程实验、或者刚接触Spark想独立跑通一个Scala项目的开发者。

2. 环境安装与Scala项目骨架:sbt + spark-submit 这套组合为什么绕不开

2.1 版本搭配:Hadoop 3.1.3 配 JDK 1.8 是最省事的组合

先看清楚这份实验的运行底座:主机Windows 10,虚拟机Ubuntu 16.04(ubuntukylin),上面装Hadoop 3.1.3和Spark,JDK用1.8。这套组合是很多高校大数据课程的标准配置。JDK必须用1.8,因为Spark 2.x依赖Java 8的运行时特性,装更高版本反而会碰到模块化访问限制之类的兼容问题。

装Hadoop和Spark的路径要固定下来,后续所有命令都依赖这两个位置。常见布局是:

# 解压到固定路径 tar -zxvf hadoop-3.1.3.tar.gz -C /usr/local tar -zxvf spark-*.tgz -C /usr/local # 重命名方便管理 mv /usr/local/hadoop-3.1.3 /usr/local/hadoop mv /usr/local/spark-* /usr/local/spark # 配置环境变量,写入 ~/.bashrc export JAVA_HOME=/usr/lib/jvm/java-8-openjdk-amd64 export HADOOP_HOME=/usr/local/hadoop export SPARK_HOME=/usr/local/spark export PATH=$PATH:$HADOOP_HOME/bin:$HADOOP_HOME/sbin:$SPARK_HOME/bin

source ~/.bashrc之后,用hadoop version和spark-shell --version验证变量是否生效。

注意:Spark本身不强制依赖Hadoop启动,但读写HDFS时必须能找到Hadoop配置。所以实验里Spark的HDFS访问依赖Hadoop的core-site.xml和hdfs-site.xml,这两个文件不配好,后续sc.textFile("hdfs://...")会报错找不到文件系统。

2.2 用 ./bin/spark-shell 验证安装,但别只把它当验证工具

安装完成后,实验记录里的第一个命令是:

cd /usr/local/spark ./bin/spark-shell

看到一堆日志后进入scala>提示符,就算成功了。spark-shell本质是Spark的交互式客户端,它启动的时候会自动创建SparkContext对象sc,所以你不需要自己写new SparkContext(conf)就能直接调sc.textFile。

新手很容易把spark-shell当成"命令行工具"来用,但它真正的价值是快速验证一段数据处理的逻辑对不对——比如你想确认某个文件路径写法是否合法、某种RDD转换是否报错,先在spark-shell里敲一遍,比写了完整应用再打包提交快得多。我自己做开发时,凡是涉及新路径、新数据源,一定先在spark-shell里做冒烟测试。

2.3 sbt 最小配置:三行必填项之外还有一个关键参数

独立应用必须用sbt打包,这是Spark官方推荐的Scala构建方式。实验里simple.sbt的内容是最小配置,我把它拆开解释:

name := "SimpleProject" version := "1.0" scalaVersion := "2.11.8" libraryDependencies += "org.apache.spark" %% "spark-core" % "2.1.0"

name决定JAR包文件名前缀;scalaVersion必须和Spark编译时用的Scala版本一致,比如Spark 2.x通常配Scala 2.11,版本不一致会出现NoSuchMethodError;libraryDependencies里的%%表示sbt会自动追加Scala版本号到依赖坐标,比如实际解析成spark-core_2.11。

提示:spark-core的版本号应该和你安装的Spark版本严格对应。实验用的Spark如果解压后目录名里带版本号(比如spark-2.1.0-bin-hadoop2.7),那么这里就写2.1.0。版本对不上,打包能过,运行时会抛ClassNotFoundException。

代码目录结构必须是sbt标准布局:

项目根目录/ ├── simple.sbt └── src/ └── main/ └── scala/ └── SimpleApp.scala

在项目根目录执行:

/usr/local/sbt/sbt package

执行完会在target/scala-2.11/下生成JAR包。sbt首次运行会下载依赖,慢是正常的,耐心等。如果卡住,检查网络或者换成国内镜像仓库。

2.4 spark-submit 提交参数:--class 后面跟的是全限定类名

打包之后用spark-submit提交:

/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

--class后面是Scala对象名,对应代码里的object SimpleApp,注意不带.scala后缀,也不带包名时直接写类名。JAR包路径要写成实际生成的文件名,如果simple.sbt里的name定义成了a-simple-hdfs-test,那么JAR就是a-simple-hdfs-test_2.11-1.0.jar。

这里有个容易翻车的地方:spark-submit执行时,当前Shell会把命令行里的路径做一次转义,路径里如果混入空格或者引号没配对,提交后报的错误会非常难排查。实验记录里URISyntaxException那条就是这么来的,后面避坑章节细说。

3. 数据读取与行数统计:本地文件和HDFS的两条路径差异

3.1 读取本地文件:file:/// 三个斜杠一个都不能少

打开spark-shell,读取Linux本地文件并统计行数,正确写法如下:

val textFile = sc.textFile("file:///home/hadoop/test.txt") textFile.count()

这里file:///是三个斜杠,协议是file,后面跟绝对路径。很多人写成file://home/hadoop/test.txt(两个斜杠)或直接写/home/hadoop/test.txt,都会出问题。直接写绝对路径时,Spark默认把它当HDFS路径处理,本地模式下有时能蒙混过关,但如果你同时配了Hadoop集群配置,就会被解析成hdfs://localhost:9000/home/hadoop/test.txt,文件不存在就报错。

实验里还出现了一个特例:sc.textFile("file:///usr/local/hadoop/1.txt"),这个写法和你当前登录用户无关,只要这个文件对当前进程有读权限就能读。用户是hadoop,文件在hadoop用户目录下,权限天然满足。

3.2 读取HDFS文件:根目录不是 ~,而是 /user/hadoop

HDFS的读取先要保证文件已经上传。先在Linux终端准备数据:

echo "这是第一行" >> test.txt echo "这是第二行" >> test.txt hdfs dfs -mkdir -p /user/hadoop hdfs dfs -put test.txt /user/hadoop/test.txt

上传后建议先用hdfs dfs -ls /user/hadoop确认文件真的存在,再进spark-shell:

val hdfsFile = sc.textFile("/user/hadoop/test.txt") hdfsFile.count()

这里的路径为什么不是~?因为~是Shell层面的概念,展开后是/home/hadoop,而HDFS是独立文件系统,它的根目录是/。对于Hadoop用户名hadoop,HDFS默认用户目录是/user/hadoop。如果直接用sc.textFile("~/test.txt"),Spark根本不会做Shell式展开,它会原样把~/test.txt当成路径去NameNode查询,结果自然是InvalidInputException: Input path does not exist。

如果HDFS的默认块大小、副本数都配好了,也可以写完整HDFS协议路径:

val hdfsFile = sc.textFile("hdfs://localhost:9000/user/hadoop/test.txt")

端口号要和core-site.xml里fs.defaultFS的一致,默认单机伪分布式是hdfs://localhost:9000。

3.3 独立应用读取HDFS:SimpleApp 从源码到运行全流程

spark-shell里适合做验证,但正式交付要用独立应用。SimpleApp.scala的完整代码如下:

import org.apache.spark.SparkContext import org.apache.spark.SparkContext._ import org.apache.spark.SparkConf object SimpleApp { def main(args: Array[String]) { val conf = new SparkConf().setAppName("Simple Application") val sc = new SparkContext(conf) val textFile = sc.textFile("hdfs://localhost:9000/user/hadoop/test.txt") println("文件行数:" + textFile.count()) sc.stop() } }

代码逻辑拆解:第1步创建SparkConf并设置应用名,这个名字会显示在Spark Web UI的Application列表里,方便查看运行状态和日志;第2步基于conf创建SparkContext,它是所有RDD操作的入口;第3步用hdfs://localhost:9000前缀显式指定文件系统,避免依赖默认配置;第4步count()是一个行动算子,触发真正的分布式计算。

注意:sc.stop()必须写在最后。不释放SparkContext,任务结束后JVM不会退出,spark-submit会一直挂在那里。

配套的simple.sbt如下:

name := "a-simple-hdfs-test" version := "1.0" scalaVersion := "2.11.8" libraryDependencies += "org.apache.spark" %% "spark-core" % "2.1.0"

打包提交命令和上一章一致。运行成功后终端会输出文件行数:4,这里的4是实验里test.txt的实际行数。

3.4 三种路径写法对比:到底什么时候用哪种

整理成一张表,遇到路径问题先查这张表:

写法适用场景常见错误
file:///home/hadoop/test.txt读取Linux本地文件少写一个/报IllegalArgumentException
/user/hadoop/test.txt读取HDFS文件,走默认配置误用~/报InvalidInputException
hdfs://localhost:9000/user/hadoop/test.txt显式指定HDFS协议和端口端口号不匹配报连接失败
/home/hadoop/test.txtSpark可同时访问本地和HDFS,默认解析逻辑取决于配置配置了HDFS后会被解析到HDFS路径

最后一种写法最迷惑人:sc.textFile("/home/hadoop/test.txt")在没装Hadoop的机器上读本地文件没问题,但一旦Spark知道HDFS地址,同样的代码就会去HDFS找路径,行为完全变了。所以我的习惯是:本地文件永远带file:///前缀,HDFS文件永远写/user/开头的完整路径,不做任何省略。

4. 数据去重与平均值计算:两个独立应用带你走通完整开发流程

4.1 文件合并去重:union 和 distinct 的配合

数据去重的需求很典型:文件A和文件B各有若干行日期 事件记录,合并后去掉完全重复的行。输入样例里A有6行、B有5行,合并后共11行,其中20170102 y和20170103 x在两文件里都有,去重后剩9行。

RemDup.scala的完整实现:

import org.apache.spark.SparkContext import org.apache.spark.SparkContext._ import org.apache.spark.SparkConf object RemDup { def main(args: Array[String]) { val conf = new SparkConf().setAppName("Remove Duplication") val sc = new SparkContext(conf) val fileA = sc.textFile("file:///home/hadoop/A.txt") val fileB = sc.textFile("file:///home/hadoop/B.txt") val lines = fileA.union(fileB) val distinctLines = lines.distinct() distinctLines.saveAsTextFile("file:///home/hadoop/C_result") sc.stop() } }

逐行说逻辑:textFile读入后,每个RDD的元素就是一行字符串;union把两个RDD纵向拼接成10个元素的RDD,只做合并不做任何去重;distinct()对全部行做shuffle去重,把完全相同的文本行去掉;saveAsTextFile把结果写入目标目录。

saveAsTextFile有一个非常隐蔽的坑:它要求目标目录在HDFS上不存在(或者本地文件系统目录不存在)。如果C_result已经存在,Spark会直接抛FileAlreadyExistsException。所以每次重跑之前,必须先手工删除旧输出目录:

rm -rf /home/hadoop/C_result

如果输出是写到HDFS的/user/hadoop/C_result,删除命令就换成hdfs dfs -rm -r /user/hadoop/C_result。

提到输出结果的排序问题:使用distinct()之后数据的顺序是无保证的。实验样例里的输出看起来是按日期排列的,那是因为输入文件本身有序,union之后shuffle量小,碰巧保留了大致顺序。如果你需要精确的排序结果,必须在distinct()之后加一次sortBy:

val sortedLines = distinctLines.sortBy(line => line.split(" ")(0))

4.2 求平均值:groupByKey 和 mapValues 的分组聚合写法

平均值案例是三个文件,Algorithm、Database、Python,每行格式都是姓名 成绩。要求输出每个学生的平均成绩并保留两位小数。先把数据文件放进同一个目录:

mkdir -p /home/hadoop/datas echo "小明 92" >> /home/hadoop/datas/Algorithm.txt echo "小红 87" >> /home/hadoop/datas/Algorithm.txt # 其他成绩行省略

AvgScore.scala完整代码:

import org.apache.spark.SparkContext import org.apache.spark.SparkContext._ import org.apache.spark.SparkConf object AvgScore { def main(args: Array[String]) { val conf = new SparkConf().setAppName("Average Score") val sc = new SparkContext(conf) val data = sc.textFile("file:///home/hadoop/datas") val lines = data.map(line => line.split(" ")) val scores = lines.map(arr => (arr(0), arr(1).toInt)) val avgScores = scores.groupByKey().mapValues(iter => { val sum = iter.sum val count = iter.size math.round(sum.toDouble / count * 100.0) / 100.0 }) val result = avgScores.map(x => "(" + x._1 + "," + x._2 + ")") result.saveAsTextFile("file:///home/hadoop/avg_result") sc.stop() } }

数据处理链路拆开讲:textFile读取目录时,Spark会把datas目录下的所有文件作为输入,每个文件的行都会进入同一个RDD,所以不需要手动union三个文件;第一段map用空格split切出两个字段;第二段map把成绩字段从字符串转成Int,同时构造出(name, score)键值对;groupByKey()把相同姓名的所有成绩归并到一个迭代器;mapValues对整个成绩序列求和、计数并计算平均值。

保留两位小数的写法有讲究。math.round(x * 100.0) / 100.0在Scala里返回的是Double,比如83.6666会变成83.67,但要注意它做的是四舍五入而不是截断。如果想严格用截断模式,可以用BigDecimal的setScale(2, BigDecimal.RoundingMode.HALF_UP)。

另一个细节:sc.textFile读目录而不指定具体文件时,Spark只认这个目录下的文件,不递归读子目录。如果你的成绩数据分散在嵌套目录里,要用data.map配合通配符或者改用wholeTextFiles。

4.3 两个独立应用的共性套路:读-转-聚合-写

走到这一步能发现,Spark独立应用的骨架高度统一:创建上下文,读入数据,用map逐行解析成结构化键值对,再用groupByKey或reduceByKey这类聚合算子完成核心逻辑,最后saveAsTextFile落盘。这个套路覆盖了大部分课程实验和入门项目。

reduceByKey和groupByKey的选择值得多说一句。实验里用groupByKey实现平均值,性能上有隐患:同一个key的所有value会被全部拉到同一个节点,如果某位学生的成绩记录特别多,可能出现数据倾斜。对平均值这种可以先局部求和再全局合并的场景,combineByKey和reduceByKey是更好的选择。不过课程实验的数据量小,groupByKey逻辑直观,优先保证跑通,优化留到真实场景。

5. 避坑手册:三个真实异常的排查记录

5.1 IllegalArgumentException:file:/// 少打了一个斜杠

现象:在spark-shell里用sc.textFile("file:///home/hadoop/test.txt")统计行数,结果报IllegalArgumentException,异常信息指向URI解析失败。

原因:把file:///写成了file://。file://这种写法在URI标准里表示"协议后跟一个空authority",Spark解析时会认为URI格式不合法。实验记录里当事人写的错误路径是file:///usr/local/hadoop/1.txt少了一个斜杠,实际正确写法是file:///usr/local/hadoop/1.txt,三个斜杠里第三个是根目录/的开始。

解决:数清楚斜杠数量。file:///home/hadoop/test.txt从右往左拆解,file:是协议,//是分隔符,/home/hadoop/test.txt是绝对路径,所以协议后一共是三个斜杠。不放心的话,在Spark Shell里先打印路径确认:

val p = new java.net.URI("file:///home/hadoop/test.txt") println(p.getPath)

如果输出的是/home/hadoop/test.txt,说明解析正确;如果报错或者getPath和预期不符,就是斜杠数量有问题。

5.2 InvalidInputException:HDFS 根目录不是 ~

现象:读取HDFS文件时执行sc.textFile("~/test.txt")或sc.textFile("hdfs://localhost:9000/~/test.txt"),报InvalidInputException,提示Input path does not exist。

原因:把Linux Shell里的~符号直接套在了HDFS路径上。~是Bash的展开语法,HDFS的路径解析器不认识它。正确路径应该是/user/hadoop/test.txt,因为Hadoop用户hadoop在HDFS上的home目录就是/user/hadoop,而这个/user/hadoop和Linux的/home/hadoop完全无关,它是HDFS命名空间里的一个逻辑目录。

解决:先确认文件在HDFS的真实位置,再写路径。

hdfs dfs -ls /user/hadoop/

看输出结果里有没有test.txt。同时可以用hdfs dfs -pwd查看当前HDFS路径,不过新版Hadoop里这个命令改名了,最保险的做法是hdfs dfs -ls /一级一级往里找。找到真实路径后,改写Spark代码里的路径参数。

5.3 URISyntaxException:路径里藏了一个空格

现象:运行RemDup或AvgScore的JAR包时,报java.net.URISyntaxException: Illegal character in scheme name at index 0,然后定位到textFile调用那一行。

原因:这一条最隐蔽。异常信息里的at index 0说明URI字符串的第一个字符就非法——实际原因是路径字符串最前面有一个不可见或可见的空格,导致Spark把hdfs://...整体当成一个scheme去解析。这通常发生在复制粘贴代码时,引号前不小心带了空格,或者命令行传参时URL前多敲了一个空格。

解决:肉眼检查代码里所有字符串常量前后有没有多余空白,特别是在textFile("...")的引号内。用编辑器开启"显示空白字符"功能最直观。我在自己的开发环境里配置了保存时自动去掉行尾空格,这类问题就被直接消灭了。

5.4 三类问题背后的通用排查规律

这三个异常放在一起看,本质都是"URI/路径字符串不符合Spark解析器的预期格式"。排查顺序总结如下:第1步,用println把传给textFile的完整字符串打出来,肉眼核对有没有空格、斜杠数量、协议前缀;第2步,确认目标文件在对应文件系统里真实存在,本地用ls -l、HDFS用hdfs dfs -ls;第3步,如果报错信息里有at index N的数字,直接数那一位是什么字符,往往就是答案。

6. 验证结果与提交习惯:每次跑完Spark作业后我必做的三件事

6.1 用系统命令交叉验证Spark输出

Spark的count()结果到底准不准?我从第1次实验开始就不敢全信,每次都用Linux原生命令交叉验证。本地文件用wc -l /home/hadoop/test.txt,HDFS文件用hdfs dfs -cat /user/hadoop/test.txt | wc -l,两个数字对得上才敢继续往下走。去重和平均值任务,直接查看输出目录里的part文件:

cat /home/hadoop/C_result/part-00000 cat /home/hadoop/avg_result/part-00000

看到的结果如果和预期一致,这个作业才算真正跑通。

6.2 输出目录的"先删后跑"习惯

saveAsTextFile不能覆盖已存在目录,这个限制让很多人第二次运行同一个JAR包时直接翻车。我养成的习惯是在每条spark-submit命令前加一个删除命令:

rm -rf /home/hadoop/C_result /usr/local/spark/bin/spark-submit --class "RemDup" \ /home/hadoop/sparkapp2/RemDup/target/scala-2.11/remove-duplication_2.11-1.0.jar

这样保证了无论上一次运行结果是成功还是失败,输出目录一定是干净的。

6.3 提交前先走一遍"路径三连查"

写这份实验记录的人踩了三次路径坑,而真实的项目环境里路径问题出现的概率比这还高。从那以后,我每次执行spark-submit之前都会强制自己走一遍三连查:第一步查输入路径,本地文件用ls、HDFS文件用hdfs dfs -ls,确认文件在;第二步查输出路径,确认目录不存在或者已删除;第三步查提交命令里的JAR包路径,直接复制target/scala-2.11/目录下的实际文件名,不手敲。三连查全程不超过30秒,但帮我省下过无数次定位"为什么找不到文件"的远程调试。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询