简介:本资源为大数据课程实验七「Spark初级编程实践」的完整实验报告文档,面向正在学习Spark与Hadoop的高校学生及大数据入门开发者,帮助其快速完成实验环境搭建、编程实践与问题排查。文档围绕Spark核心操作展开,涵盖Hadoop与Spark的安装配置、spark-shell启动、本地文件与HDFS文件的行数统计、使用Scala编写独立应用程序并通过sbt打包、spark-submit提交运行,以及数据去重和求平均值等典型场景,同时记录了实验过程中遇到的非法参数异常、无效输入异常、URISyntaxException等报错及其解决方案,便于读者对照复现与排错。资源包共1个docx文件,大小约1.9MB,内容为图文并茂的实验报告,结构完整、步骤清晰。目前已有8339人学习下载,适合需要完成Spark初级编程实验或巩固Scala应用开发流程的读者参考。
1. 实验七 Spark 初级编程实践:从零跑通第一个 RDD 作业
很多同学第一次打开 Spark 的编程实验,卡住的地方往往不是算子本身,而是环境。明明照着讲义敲了sc.textFile,结果要么报ClassNotFound,要么spark-shell起不来,要么跑完发现结果和预期对不上。实验七 Spark 初级编程实践这个标题,核心就是让你用最少的代码,把 RDD 的创建、转换、行动三类操作串起来,理解惰性求值到底是怎么回事。它适合刚接触分布式计算、能写一点 Scala 或 Python、但对“为什么我的代码不执行”感到困惑的开发者。这一章先把实验目标、环境边界和最小可跑通路径讲清楚,后面几章再拆算子、拆参数、拆踩坑点。
2. 实验环境搭建:三种启动方式与版本对齐
Spark 初级编程实践最容易翻车的地方,不是代码写错,而是环境没对齐。Spark 对 JDK、Scala、Python 的版本相当敏感,版本错一位,可能连spark-shell都进不去。这一章把本地模式、伪分布式、以及交互式 shell 三种启动方式讲透,并给出可复现的版本组合。
2.1 本地模式最小依赖:JDK、Scala、Spark 的版本三角
Spark 跑在 JVM 上,所以 JDK 是第一道门槛。常见做法是选 JDK 8 或 JDK 11,不要用 JDK 17 以上去配老版本 Spark,否则会碰到模块访问权限的报错。Scala 版本要和 Spark 发行版里自带的 Scala 二进制版本一致,比如 Spark 3.3.x 默认对应 Scala 2.12,你本地装 2.13 就可能出问题。
下面这段是本地模式的环境变量配置,写进~/.bashrc或~/.zshrc后source生效:
# Spark 本地模式环境变量,路径按自己解压位置改 export JAVA_HOME=/usr/lib/jvm/jdk-11 # JDK 11,兼容性最稳 export SCALA_HOME=/opt/scala-2.12.15 # 与 Spark 自带 Scala 大版本一致 export SPARK_HOME=/opt/spark-3.3.2 # Spark 解压目录 export PATH=$JAVA_HOME/bin:$SCALA_HOME/bin:$SPARK_HOME/bin:$PATH export PYSPARK_PYTHON=python3 # PySpark 用的 Python 解释器逻辑说明:JAVA_HOME决定 Spark 用哪个 JVM;SCALA_HOME只在你要用scalac编译独立程序时才需要,跑spark-shell其实用的是 Spark 自带的 Scala 库;SPARK_HOME/bin加进 PATH 后,才能直接敲spark-shell、pyspark、spark-submit。参数上,PYSPARK_PYTHON建议显式指定,否则集群里可能用到系统自带的旧 Python。
验证是否配好,执行:
spark-shell --version pyspark --version两条命令都能打印出 Spark 版本号,说明环境变量生效。如果只成功一条,检查对应发行包是否解压完整。
2.2 spark-shell 与 pyspark 的启动差异
spark-shell是 Scala 交互式环境,pyspark是 Python 交互式环境。两者底层都是同一个 Spark 内核,但初始化时创建的SparkContext变量名不同:Scala 里叫sc,Python 里也叫sc,但 Python 里还多一个spark变量指向SparkSession。
启动命令:
# Scala 交互式,指定本地 2 核 spark-shell --master local[2] # Python 交互式,同样本地 2 核 pyspark --master local[2]--master local[2]表示用本地模式、开 2 个工作线程。local不带方括号是单线程,local[*]是用满所有核。实验阶段建议固定local[2],方便观察并行度对结果的影响。启动后终端会打印一个 Web UI 地址,默认 4040 端口,浏览器打开能看到作业和 Stage 划分,这是排查“为什么没跑”的第一现场。
2.3 用 spark-submit 提交独立脚本
交互式适合调试,真正交作业一般用脚本。PySpark 脚本结构如下:
# wordcount.py from pyspark import SparkConf, SparkContext conf = SparkConf().setAppName("WordCount").setMaster("local[2]") sc = SparkContext(conf=conf) text = sc.textFile("input.txt") # 创建 RDD counts = text.flatMap(lambda line: line.split(" ")) \ .map(lambda word: (word, 1)) \ .reduceByKey(lambda a, b: a + b) counts.saveAsTextFile("output") # 行动操作,触发执行 sc.stop()提交命令:
spark-submit --master local[2] wordcount.py逻辑说明:SparkConf里setMaster会覆盖命令行--master,实验时建议只留一处,避免冲突。textFile读本地文件时,路径要能被所有 executor 访问,本地模式就是当前机器路径。saveAsTextFile输出目录必须不存在,否则报FileAlreadyExistsException,这是新手最常踩的坑之一。
3. RDD 核心算子:转换与行动的边界在哪
RDD 的算子分两类:转换(transformation)和行动(action)。转换是惰性的,只记录血缘,不真正计算;行动才会触发作业提交。实验七的代码量不大,但要把这条边界理解清楚,否则会出现“我明明写了 map,为什么什么都没发生”的困惑。
3.1 map、flatMap、filter 的输入输出对照
这三个是最基础的转换算子,区别在输出粒度。map是一进一出,flatMap是一进多出,filter是一进零或一出。用一张表对照更直观:
| 算子 | 输入 | 输出 | 典型场景 |
|---|---|---|---|
| map | 一行文本 | 一个对象 | 提取字段、类型转换 |
| flatMap | 一行文本 | 多个对象 | 分词、展开嵌套 |
| filter | 一个对象 | 保留或丢弃 | 过滤空行、异常值 |
代码示例:
rdd = sc.parallelize(["hello world", "spark test", ""]) print(rdd.map(lambda x: len(x)).collect()) # [11, 10, 0] print(rdd.flatMap(lambda x: x.split(" ")).collect()) # ['hello','world','spark','test',''] print(rdd.filter(lambda x: x != "").collect()) # ['hello world', 'spark test']逻辑说明:parallelize把本地集合转成 RDD,方便实验。collect是行动算子,把结果拉回 Driver 端打印。注意flatMap对空字符串split后会产生一个空字符串元素,如果后续要统计词频,得再filter一次。参数上,parallelize第二个参数可以指定分区数,默认按本地核数切,实验时显式写sc.parallelize(data, 2)更可控。
3.2 reduceByKey 与 groupByKey 的性能分水岭
词频统计绕不开按键聚合。reduceByKey会先在 map 端做本地合并,再 shuffle;groupByKey直接把所有同键值拉到一起,数据量大时容易 OOM。实验数据小,两者结果一样,但习惯要一开始就养对。
pairs = sc.parallelize([("a", 1), ("b", 1), ("a", 1)]) # 推荐:map 端预聚合 print(pairs.reduceByKey(lambda x, y: x + y).collect()) # [('a', 2), ('b', 1)] # 不推荐:全量 shuffle print(pairs.groupByKey().mapValues(sum).collect()) # [('a', 2), ('b', 1)]逻辑说明:reduceByKey的合并函数要求满足结合律和交换律,否则并行结果可能不一致。groupByKey返回的是(key, Iterable),后面接mapValues(sum)才能得到计数。参数上,reduceByKey可以传第二个参数指定 reduce 分区数,默认沿用父 RDD 分区数,实验里保持默认即可。
3.3 行动算子触发时机:collect、count、take 的取舍
行动算子决定作业什么时候真正跑。collect把全量结果拉回本地,数据大时会把 Driver 内存打爆;take(n)只取前 n 条,适合快速验证;count只返回数量,不拉数据。
rdd = sc.parallelize(range(1000000), 4) print(rdd.take(3)) # [0, 1, 2],只拉 3 条 print(rdd.count()) # 1000000,只算数量 # print(rdd.collect()) # 危险:百万级数据全拉回本地逻辑说明:take底层会先扫描一个分区,不够再扫下一个,所以不是严格全局有序,除非先sortBy。count会触发全量计算,但结果只是一个长整型。实验里验证结果优先用take和count,确认无误后再考虑collect小数据集。
4. 避坑与排查:实验七最常见的五类报错
这一章按“现象 → 原因 → 解决”整理五条血泪经验,都是实验七阶段高频出现的。
4.1 现象:spark-shell 启动报 UnsupportedClassVersionError
原因:JDK 版本高于 Spark 编译时使用的版本。比如 Spark 3.3 用 JDK 8 编译,你本地是 JDK 17,类文件版本不匹配。
解决:切到 JDK 8 或 JDK 11,用java -version确认当前生效版本,再检查JAVA_HOME是否指向正确目录。改完记得重新source环境变量,新开终端验证。
4.2 现象:saveAsTextFile 报 FileAlreadyExistsException
原因:输出目录已存在。Spark 不会覆盖已有目录,这是保护机制。
解决:提交前删掉输出目录,或代码里用os.system("rm -rf output")先清理。注意不要用overwrite模式去写 RDD,RDD 的saveAsTextFile没有这个选项,DataFrame 的write.mode("overwrite")才有。
4.3 现象:collect 结果顺序和输入不一致
原因:RDD 分区之间并行计算,collect按分区顺序拼接,但分区内部和分区之间的完成顺序不保证全局有序。
解决:需要有序结果时,先sortBy再collect,或者用takeOrdered(n)取排序后的前 n 条。实验里如果只是验证计数,顺序不影响正确性。
4.4 现象:PySpark 报 Python worker failed to connect
原因:PYSPARK_PYTHON指向的解释器在 executor 环境里不存在,或者版本不兼容。
解决:显式设置export PYSPARK_PYTHON=python3,并确认which python3路径在所有节点一致。本地模式一般不会出这个问题,集群模式要保证每个节点都有相同 Python 环境。
4.5 现象:作业卡在某个 Stage 不动
原因:数据倾斜,某个 key 的数据量远大于其他 key,导致单个 task 拖慢整个 Stage。
解决:实验数据小一般不会遇到,但如果模拟数据里某个 key 特别多,可以先sample看分布,再用reduceByKey加随机前缀打散,聚合后再去掉前缀。排查入口是 4040 端口的 Web UI,看 Stage 里各 task 的耗时分布。
5. 进阶技巧:用缓存和分区把实验代码跑得更稳
实验七的代码跑通只是起点,真正要理解 Spark,得知道什么时候该缓存、分区数怎么定。这一章给两个具体技巧,都是我在带实验时反复强调的。
5.1 persist 与 cache 的选择:什么时候值得缓存
如果一个 RDD 会被多个行动算子复用,缓存能避免重复计算。cache()是persist(StorageLevel.MEMORY_ONLY)的简写,内存不够时会丢分区重算;persist(StorageLevel.MEMORY_AND_DISK)会落盘,更稳但慢一点。
rdd = sc.textFile("big_input.txt").flatMap(lambda x: x.split(" ")).map(lambda w: (w, 1)) rdd.persist(StorageLevel.MEMORY_AND_DISK) # 标记缓存,第一次行动时生效 rdd.reduceByKey(lambda a, b: a + b).count() # 第一次触发,数据进缓存 rdd.filter(lambda x: x[1] > 100).count() # 第二次复用,不再重读文件 rdd.unpersist() # 用完释放,避免占内存逻辑说明:persist本身是转换,不会立即缓存,第一次行动算子执行时才把结果存下来。unpersist建议显式调用,尤其在交互式环境里反复实验时,不然内存会被旧 RDD 占满。参数上,MEMORY_AND_DISK适合数据量超过内存一半的场景,纯内存够用就用cache()。
5.2 分区数怎么定:并行度与开销的平衡
分区数决定 task 数量。分区太少,并行度不够;分区太多,task 调度开销大。经验值是分区数设为本地核数的 2 到 4 倍,或者按数据块大小反推,比如 128MB 一个分区。
rdd = sc.textFile("input.txt", minPartitions=4) # 读文件时指定最小分区数 print(rdd.getNumPartitions()) # 查看实际分区数 rdd2 = rdd.repartition(8) # 增加分区,会 shuffle rdd3 = rdd.coalesce(2) # 减少分区,默认不 shuffle逻辑说明:textFile的minPartitions是建议值,实际分区数还受文件块大小影响。repartition走全量 shuffle,适合增大并行度;coalesce默认不 shuffle,适合合并小分区。实验里可以用getNumPartitions观察不同设置下的分区变化,再结合 4040 页面的 task 数验证。
5.3 用 takeSample 快速验证数据分布
实验数据不大,但养成抽样验证的习惯很有用。takeSample可以在不拉全量的前提下看数据长什么样。
rdd = sc.parallelize(range(1000), 4) sample = rdd.takeSample(withReplacement=False, num=5, seed=42) print(sample) # 固定 seed,结果可复现逻辑说明:withReplacement=False表示不放回抽样,num是样本数,seed固定后每次结果一致,方便对比。这个技巧在排查数据倾斜时特别顺手,先抽样看 key 分布,再决定要不要打散。
我自己的习惯是,每次写完一个 RDD 作业,先take(5)看结构,再count()看规模,最后才决定要不要collect。这个顺序能挡掉八成以上的内存翻车。希望帮到你。
本文还有配套的精品资源,点击获取