简介:这份资源是《大数据技术原理与应用》课程“实验七:Spark初级编程实践”的完整实验报告,面向高校大数据相关专业学生及 Spark 初学者。内容涵盖 Hadoop 与 Spark 的安装配置、spark-shell 启动方式,并演示了读取 Linux 本地文件与 HDFS 文件并统计行数的操作方法;同时给出了 SimpleApp、RemDup、AvgScore 三个完整 Scala 独立应用程序的源码、sbt 打包命令及 spark-submit 提交过程,涉及数据去重、求平均值等典型数据处理场景,对理解 Spark 开发流程很有帮助。资源为 1 个 docx 格式文档,约 1.9 MB,实验环境、代码、运行结果及问题排查都整理在文档中。已有 8345 人学习/下载,适合作为课程作业参考、期末复习或大数据入门练习的配套资料。
1. Spark初级编程实践:一门课实验里藏着的完整入门链路
如果你正被"大数据环境装好了但不知道下一步干嘛"卡住,这门实验报告的参考价值比想象中大。它以一个完整的大数据技术课程实验七为蓝本,走通了Spark入门最典型的三条路径:spark-shell交互式统计、独立Scala应用读取HDFS、以及两个带业务语义的分布式程序(数据去重与平均成绩计算)。技术栈锁定Hadoop 3.1.3 + JDK 1.8 + Spark(Scala 2.11),整个链路从环境搭建到sbt打包再到spark-submit提交,全部是命令行实操,没有半点抽象概念。适合两类人:一是刚装完Spark还不知道怎么写第一个程序的新手,二是想快速回顾Spark读取本地文件与HDFS文件差异、以及独立应用打包提交全流程的开发者。读完你能直接照抄一套可运行的代码骨架。
2. 环境与架构:Hadoop 3.1.3 之上跑 Spark 的前置准备
2.1 这套环境是怎么组起来的
实验跑在Windows 10主机 + Ubuntu Kylin 16.04虚拟机里,处理器是i5-10300H,内存16GB。虚拟机里装了Hadoop 3.1.3和JDK 1.8,Spark解压到固定路径后通过./bin/spark-shell启动。整个架构是典型的HDFS做存储、Spark做计算的分离模式:Hadoop负责分布式文件系统的数据落地,Spark负责从HDFS或本地文件系统读取数据后做内存计算。
之所以强调这个环境组合,是因为Spark本身不依赖Hadoop集群也能跑(支持local模式),但实验里特意把"读取本地文件"和"读取HDFS文件"两条路径都走了一遍,目的就是让学习者感知两种输入来源在URL写法上的差异。Hadoop版本选3.1.3是比较稳妥的——它和Spark 2.x系列的兼容性经过大量验证,不会出现Hadoop 3.2以上版本与Spark旧版之间RPC协议不兼容的坑。
2.2 安装与启动的关键动作
安装阶段没有太多花哨操作,核心就是解压、配置环境变量、启动服务。我一般会把Hadoop和Spark解压到/usr/local/下,然后分别配置JAVA_HOME、HADOOP_HOME、SPARK_HOME到/etc/profile。Spark启动前,记得先确认HDFS的NameNode和DataNode进程已经拉起,否则后续读HDFS文件时会报连接拒绝。
# 解压Spark到固定目录 tar -zxvf spark-2.4.0-bin-hadoop2.7.tgz -C /usr/local/ cd /usr/local && mv spark-2.4.0-bin-hadoop2.7 spark # 配置环境变量(追加到 /etc/profile) export SPARK_HOME=/usr/local/spark export PATH=$SPARK_HOME/bin:$PATH # 启动Spark交互式Shell ./bin/spark-shell启动Spark Shell后,看到一堆INFO日志里出现Spark context Web UI available at http://主机IP:4040这样的字样就说明成功了。这里的sc就是SparkContext实例,是后续所有RDD操作的入口对象。有一点值得注意:这个实验全程用的是local模式的Spark,没有走Standalone或YARN集群调度,所以4040端口的Web UI只能看到本机的Executor情况,但这对入门学习来说完全够用。
2.3 目录规划与文件准备
实验在/home/hadoop/下创建了多个代码目录:sparkapp2放RemDup去重项目、sparkapp3放AvgScore平均分项目,每个项目都是标准的sbt目录结构(src/main/scala + simple.sbt)。这种按实验小节分目录的组织方式,对后续排查问题很有帮助——每个任务独立打包,互不干扰。
数据文件的准备也不复杂:本地文件/home/hadoop/test.txt直接vim创建,HDFS文件/user/hadoop/test.txt则要用hdfs dfs -put从本地上传,或者通过hdfs dfs -mkdir -p /user/hadoop先建目录再写入。
3. 核心操作拆解:本地文件与 HDFS 读取的行数统计
3.1 spark-shell 里读本地文件
实验的第一个实质任务是统计本地文件行数,命令在spark-shell里敲一行就能完成:
val textFile = sc.textFile("file:///home/hadoop/test.txt") textFile.count()这里的URL写法是file:///后面跟绝对路径,三个斜杠中间不要漏。第一个斜杠是协议分隔符,后两个对应文件系统的根目录。很多新手会写成file://home/hadoop/test.txt,少了一个斜杠,Spark就会报出IllegalArgumentException,错误信息里明确提示"Wrong FS"或者路径无法解析。这在实验报告的问题1里有记录,属于高频翻车点。
count()是一个action操作,触发Spark执行真正的计算任务。textFile是transformation操作,此时只是构建了一个HadoopRDD,数据还没有真正被读取。这个"延迟计算"的机制是Spark相比MapReduce更灵活的地方,但也是初学者最容易困惑的点——写代码时以为已经读取了,其实要到action才真正加载数据。
3.2 spark-shell 里读 HDFS 文件
读HDFS文件的写法差别只在URL协议前缀:
val textFile = sc.textFile("hdfs://localhost:9000/user/hadoop/test.txt") textFile.count()实验报告里写的是直接sc.textFile("/user/hadoop/test.txt"),在配置了默认FS的情况下这种简写也能跑。但问题2的踩坑记录说明了一个很关键的点:HDFS的根目录不是Linux文件系统的~,如果传hdfs://localhost:9000/user/hadoop/~/test.txt或者直接传/home/hadoop/test.txt(这是Linux路径),Spark会报InvalidInputException,提示输入路径不存在。
实际使用中,我建议显式写全hdfs://namenode主机名:9000/路径,这样遇到问题能一眼看出是路径写错还是服务没起。实验里HDFS文件是手动创建并上传的,步骤是先hdfs dfs -mkdir -p /user/hadoop,再hdfs dfs -put test.txt /user/hadoop/,完成之后Spark读到的数据最终来自DataNode上的Block副本。
3.3 两种路径写法的本质区别
本地文件走的是file://协议,读取的是Linux磁盘上的真实文件;HDFS文件走的是hdfs://协议,读取的是HDFS的虚拟路径。Spark底层对这两种输入做了统一抽象,都转换成RDD[String](每一行是一个String元素),所以后续的count()、flatMap、filter等操作完全一致。理解了这个差异,就不会再犯"把Linux绝对路径直接塞给HDFS"的错。
# 创建HDFS目录并上传文件 hdfs dfs -mkdir -p /user/hadoop hdfs dfs -put /home/hadoop/test.txt /user/hadoop/test.txt # 验证文件是否就位 hdfs dfs -ls /user/hadoop/4. 独立应用实战:三个 Scala 程序的代码走读与打包提交
4.1 SimpleApp:最小的可提交 Scala 程序
从spark-shell写交互式代码到编写独立应用,中间隔着一道"工程化"的门槛。SimpleApp展示了最简结构:一个object(不是class),一个main方法,里面创建一个SparkConf和SparkContext:
import org.apache.spark.SparkContext import org.apache.spark.SparkContext._ import org.apache.spark.SparkConf object SimpleApp { def main(args: Array[String]): Unit = { val conf = new SparkConf().setAppName("SimpleApp") val sc = new SparkContext(conf) val textFile = sc.textFile("hdfs://localhost:9000/user/hadoop/test.txt") println("文件行数: " + textFile.count()) sc.stop() } }这段代码里,setAppName设置应用名,会显示在Spark Web UI的Applications列表里;SparkContext(conf)创建了与集群的连接。值得一提的是,独立应用里创建SparkContext时不写setMaster("local"),因为在执行spark-submit时应用名、部署模式、executor资源等参数由提交命令决定,不需要在代码里硬编码。如果写了反而会绑定死运行模式,不利于后续提交到集群。
4.2 simple.sbt 构建定义
sbt是Scala的构建工具,和Maven地位类似,但配置更简洁。simple.sbt告诉sbt项目名、版本号、Scala版本和依赖的库:
name := "Simple Project" version := "1.0" scalaVersion := "2.11.8" libraryDependencies += "org.apache.spark" %% "spark-core" % "2.4.0"%%是sbt的语法糖,会自动在依赖的artifactId后面拼接上scalaVersion的前缀(比如spark-core_2.11)。Spark版本必须和你的集群里安装的Spark版本保持一致,否则提交时会报NoSuchMethodError或类版本冲突。
打包命令和提交命令是固定搭配:
# 打包(在项目根目录执行,会生成target/scala-2.11/目录) /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--class指定主类名,spark-submit后面的参数是JAR包的完整路径。整条命令的意思是:Spark启动一个JVM进程,加载指定JAR包,找到SimpleApp的main方法执行。spark-submit会自动把Spark的依赖注入classpath,不需要你在JAR包里打uber fat包。
4.3 RemDup:分组聚合思想的数据去重实现
数据去重是Spark入门必练的场景。本质上就是把两个文件合成一个RDD,调用distinct()去掉重复行,再把结果写入新文件。实验给出的输入A有五条记录,输入B有四条,合并去重后输出八条。
import org.apache.spark.SparkContext 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 dataFile = "hdfs://localhost:9000/user/hadoop/A.txt,hdfs://localhost:9000/user/hadoop/B.txt" val rdd = sc.textFile(dataFile) val distinctRDD = rdd.distinct() distinctRDD.saveAsTextFile("hdfs://localhost:9000/user/hadoop/C.txt") sc.stop() } }sc.textFile(dataFile)接受一个逗号分隔的多个文件路径,会把两个文件的内容合并成一个RDD。distinct()底层依赖RDD的countByValue和过滤,经过shuffle把相同key的记录汇聚到同一个分区再去除重复,这是全量去重,不适合超大规模数据,但作为入门实验很合适。
运行结果里C.txt是一个目录而不是单个文件,这是HDFS和Spark的机制——saveAsTextFile会产生part-00000、part-00001这样的分区文件,每个Executor负责写自己的分区。想看结果要用hdfs dfs -cat /user/hadoop/C.txt/*,直接cat C.txt会得到文件列表而不是内容。
# 运行RemDup /usr/local/spark/bin/spark-submit --class "RemDup" \ /home/hadoop/sparkapp2/RemDup/target/scala-2.11/remove-duplication_2.11-1.0.jar # 查看去重结果 hdfs dfs -cat /user/hadoop/C.txt/part-*4.4 AvgScore:经典的 map 聚合加 reduce 合并
求平均成绩这个场景要复杂一些,它要求按名字做分组,再对组内成绩求均值。实验给了三个文件(Algorithm、Database、Python),每个文件一行代表一个学生一科的成绩,最后输出每个学生的平均分。
import org.apache.spark.SparkContext 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 dataFile = "hdfs://localhost:9000/user/hadoop/datas" val rdd = sc.textFile(dataFile).filter(line => line.trim.nonEmpty) val pairs = rdd.map { line => val fields = line.split(" ") (fields(0), fields(1).toInt) } val (sums, counts) = pairs.aggregateByKey((0, 0))( (acc, value) => (acc._1 + value, acc._2 + 1), (acc1, acc2) => (acc1._1 + acc2._1, acc1._2 + acc2._2) ) val averages = sums.map { case (name, (sum, count)) => (name, sum.toDouble / count) } averages.saveAsTextFile("hdfs://localhost:9000/user/hadoop/avg_result") sc.stop() } }这里的split(" ")切分每行数据得到名字和成绩,toInt把字符串转成整型。aggregateByKey是核心操作:第一个参数(0, 0)是初始值,表示累加和与计数;第一个函数对同一分区内的值做累加,第二个函数跨分区合并中间结果。这种写法比groupByKey然后遍历求和更高效——groupByKey会把所有value全部拉到内存再处理,而aggregateByKey在每个分区先做局部聚合,极大减少了shuffle数据量。
输出结果可能不是按名字排序的,因为reduceByKey类操作的结果RDD分区顺序不保证。如果需要排序输出,可以加一个sortByKey(),但实验没做这个要求。
4.5 三个程序的横向对比
| 程序 | 核心算子 | 输入 | 输出 | 涉及知识点 |
|---|---|---|---|---|
| SimpleApp | textFile + count | HDFS单文件 | 控制台打印行数 | action触发计算 |
| RemDup | textFile + distinct | HDFS两个文件 | HDFS新目录 | transformation链 + saveAsTextFile |
| AvgScore | aggregateByKey | HDFS目录(多个文件) | HDFS新目录 | 分区内聚合 + 跨分区合并 |
三个程序的演进脉络很清晰:SimpleApp解决"读得出来",RemDup解决"分布式去重怎么做",AvgScore解决"分组聚合怎么高效算"。走完这三个,Spark的RDD基础算子基本覆盖了大半。
5. 排查手记:三次路径异常翻车现场与规避方法
5.1 IllegalArgumentException:file:// 少打一个斜杠
现象:在spark-shell里执行sc.textFile("file:/home/hadoop/test.txt")(两个斜杠)时报IllegalArgumentException,提示Wrong FS或URL格式错误。
原因:file://是URI协议头,协议后面必须跟//表示主机名部分为空、直接使用本机文件系统。写成file:/时,Spark把/home/hadoop/test.txt解析成不完整的URI。
解决:改成sc.textFile("file:///home/hadoop/test.txt")。三个斜杠的含义是:file://(协议头)+/(根目录)。这种URL拼写问题的排查思路是:先在终端执行hdfs dfs -ls file:///home/hadoop/验证路径是否可访问,再回来改Spark代码。
5.2 InvalidInputException:HDFS 路径不存在或权限不对
现象:sc.textFile("/user/hadoop/test.txt")报InvalidInputException,提示输入路径不存在。
原因:Spark读HDFS时,如果路径写的是相对路径或用户目录简写(比如~/test.txt),会解析到错误的位置。HDFS的根目录是/,用户的home目录在/user/hadoop/下,但前提是这个目录和文件已经存在。
解决:先确认HDFS上确实有该文件,再检查当前用户是否有读权限。
# 排查HDFS路径是否就位 hdfs dfs -ls /user/hadoop/ # 如果文件不存在则上传 hdfs dfs -put /home/hadoop/test.txt /user/hadoop/test.txt权限方面,Spark通过Hadoop的UserGroupInformation机制获取当前Linux用户名,如果user/hadoop目录的属主和当前登录用户不一致,会报Permission denied,处理方式是hdfs dfs -chown -R 当前用户:当前用户 /user/hadoop。
5.3 URISyntaxException:URL 前的空格是隐形杀手
现象:RemDup程序运行时报java.net.URISyntaxException: Illegal character in scheme name at index 0。
原因:代码里硬编码HDFS路径时,字符串前面多了一个空格(比如" hdfs://localhost:9000/user/hadoop/A.txt"),URI解析器把空格当成了scheme名的一部分,自然无法解析。
解决:检查所有路径字符串,删掉前导空格。另外使用trim()方法也是通用的兜底方案——如果路径是从配置文件或参数读进来的,trim()能消除这类不可见字符的干扰。
// 通用防御写法 val path = " hdfs://localhost:9000/user/hadoop/A.txt".trim这三个问题本质上是同一类——路径字符串的精确拼写与可见性。扪心自问,这三个坑我当年踩的时候花了很长时间才发现,因为报错信息都指向"格式错误"而不是"路径内容错误",如果在代码里把路径字符串用引号包起来打印出来看一遍,十秒就能定位问题。
6. 二次开发扩展:从行数统计升级到 Top N 与多数据源接入
6.1 改造切入点:给三个程序加一点业务味
如果你已经跑通了这三个程序,下一步最自然的改造是给它们加一点真实业务语义。比如SimpleApp可以升级成一个"日志文件行数统计器",RemDup可以改成"多份业务数据合并去重工具",AvgScore则可以升级成"按班级计算成绩排名"。改造的方向不改变程序骨架,但能让Spark的能力边界更清晰。
这里演示一个将AvgScore扩展成Top N排名的思路:
val averages = sums.map { case (name, (sum, count)) => (name, sum.toDouble / count) } val sorted = averages.sortBy(_._2, ascending = false) val top3 = sorted.take(3) top3.foreach(println)sortBy的第二个参数false表示降序,take(3)返回前三名数组。这里有一个值得注意的点:take会收集到Driver端,如果你的数据量是百万级学生,全量collect会撑爆Driver内存,但take(3)走的是部分聚合,Spark会尽量在Executor端取完再返回到Driver,相对安全。
另一个更实际的改造是多数据源接入。实验里所有数据都在HDFS,但真实项目中数据往往分布在MySQL、Kafka、甚至第三方API里。你可以给AvgScore加一个JdbcRDD,直接从MySQL读学生成绩:
import org.apache.spark.rdd.JdbcRDD import java.sql.{DriverManager, ResultSet} val connection = () => { DriverManager.getConnection("jdbc:mysql://localhost:3306/school", "root", "password") } val sql = "SELECT name, score FROM scores WHERE score >= ? AND score <= ?" val rdd = new JdbcRDD( sc, connection, sql, lowerBound = 0, upperBound = 100, numPartitions = 4, (result: ResultSet) => (result.getString(1), result.getInt(2)) )JdbcRDD的核心机制是按数值范围拆分成多个分区ExecuteQuery,避免全表扫描到Driver内存。lowerBound、upperBound、numPartitions三个参数控制并发度和分区数,需要根据你的表数据量和MySQL负载情况调整——分区数不是越多越好,太多分区同时打MySQL会把数据库连接池打满。
6.2 验证改造结果的正确姿势
每次改完代码,我都习惯分三步验证:先跑小规模本地文件确认逻辑正确,再到HDFS上跑全量数据确认吞吐,最后用spark-submit --master local[4]指定本地多线程模式先自测一遍,再切到集群模式跑真实作业。
这个习惯源于我带一个学弟做实验时的教训:他改完代码直接丢到集群上跑,日志刷了几千行也没看到报错,后来发现是JAR包没重新打包,Spark执行的是旧版本代码。从那以后,我每次改完代码都强制走一遍sbt clean && sbt package,然后看target/scala-2.11/下的JAR文件时间戳确认是新产物,再决定是否提交。修改Spark独立应用最痛苦的事不是写代码,而是你以为改过了、但实际跑的还是半年前打的包——这个过程,一定要把打包、确认时间戳、再提交三步走完整,希望帮到你。
本文还有配套的精品资源,点击获取