ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spark初级编程实验:spark-shell到spark-submit路径避坑

Spark初级编程实验:spark-shell到spark-submit路径避坑

简介:《实验七:Spark初级编程实践》是一份面向大数据课程学习者与Spark入门者的实验报告类资料,围绕Hadoop与Spark环境搭建、spark-shell交互操作及Scala独立应用开发展开,适合需要完成课程实验或夯实分布式计算基础的高校学生。资源包内仅含1个docx文档,压缩后约1.9MB,以完整实验报告形式收录实验环境、操作截图、代码片段与结果记录,便于集中查阅和整理。该资料已有8351人学习,具备较高参考热度。内容覆盖Hadoop 3.1.3与Spark安装启动、读取本地及HDFS文件统计行数,以及SimpleApp、RemDup、AvgScore等独立应用通过sbt打包、spark-submit提交运行的完整流程;同时记录非法参数、路径不存在、URI非法字符等报错及解决思路,可帮助读者对照复现实验步骤、理解Scala应用开发与排错方法。

1. 从 spark-shell 到 spark-submit:这套 Spark 初级编程实验包到底能跑通什么

大数据 Spark Hadoop 课程实验最容易翻车的地方,往往不是算子写错,而是路径前缀、HDFS 目录和提交命令这三处细节。这套 Spark 初级编程实践实验包把安装配置、spark-shell 读本地与 HDFS 文件、SimpleApp/RemDup/AvgScore 三个 Scala 独立应用、sbt 打包和 spark-submit 提交串成一条线,适合刚接触分布式计算、需要把课程实验跑通的人。它的价值不在代码多复杂,而在每一步都有明确的输入输出:本地文件走 file:///,HDFS 文件走 /user/hadoop,独立应用打成 JAR 再提交。照着目录走,能少踩我当初那几个路径坑;边界也清楚,本地模式不覆盖集群调优。

2. 环境先立住:Hadoop 3.1.3 与 Spark 本地模式的最小闭环

2.1 虚拟机、JDK 与目录约定:先把路径统一

实验环境是宿主机 Windows 10、虚拟机 Ubuntu 16.04,JDK 1.8,Hadoop 3.1.3,Spark 解压到固定目录。很多人一上来就复制命令,结果 JDK、Hadoop、Spark 三个路径各写各的,后面 spark-shell 起不来、sbt 找不到 scala-library、spark-submit 找不到主类,全是路径不统一留下的账。我的习惯是先定一个安装基线:JDK 放/usr/local/jdk1.8.0_xxx,Hadoop 放/usr/local/hadoop,Spark 放/usr/local/spark,后面所有命令都围绕这三个前缀写,不在一条命令里混用两套路径。

先写环境变量,改~/.bashrc或/etc/profile都行,课程实验用当前用户生效就够:

# JDK 目录按实际解压后的目录名替换,不要指向 JRE export JAVA_HOME=/usr/local/jdk1.8.0_xxx export HADOOP_HOME=/usr/local/hadoop export SPARK_HOME=/usr/local/spark export PATH=$JAVA_HOME/bin:$HADOOP_HOME/bin:$HADOOP_HOME/sbin:$SPARK_HOME/bin:$PATH

这段配置的逻辑很直接:JAVA_HOME给 Hadoop 和 Spark 找 JDK,HADOOP_HOME让hdfs、hadoop命令可用,SPARK_HOME让spark-shell、spark-submit可用。PATH的顺序把 JDK 放前面,避免系统自带 Java 抢先命中。参数上唯一需要你改的是JAVA_HOME的最后一段目录名,用ls /usr/local看一眼就能确认。改完执行source ~/.bashrc,再用三条命令验收:

java -version hadoop version $SPARK_HOME/bin/spark-shell --version

java -version要看到 1.8;hadoop version要看到 3.1.3;spark-shell --version能打印版本并正常退出,说明 Spark 已经能找到 Scala 和 JDK。这里常见的误用是把JAVA_HOME指到/usr/lib/jvm/.../jre,Hadoop 启动时不会立刻报错,但 Spark 提交任务会以JAVA_HOME is not set或类加载异常的形式翻车。目录约定用一张表固定下来,后面排查时少猜:

目录用途注意点
/usr/local/jdk1.8.0_xxxJDK 1.8必须指向 JDK 根目录,不是 bin 或 jre
/usr/local/hadoopHadoop 3.1.3bin和sbin都要进 PATH
/usr/local/sparkSpark 安装目录用$SPARK_HOME/bin/spark-shell启动
/usr/local/sbtsbt 工具打包命令用绝对路径更稳
/home/hadoop本地实验数据本地路径读取时注意file:///

提示:目录名里不要带空格,课程实验的数据文件也别放桌面或中文目录,后面 URL 解析会省很多事。

2.2 解压安装与环境变量:spark-shell 能起来才算过关

安装动作本身不复杂,难在解压后的目录名和后续命令对不上。常见做法是先把压缩包放到/usr/local下解压,再把带版本号的目录改成短目录,这样SPARK_HOME不用跟着版本变。下面以 Spark 为例,Hadoop 同理,压缩包名按你手里的实际文件替换:

# 解压到 /usr/local,注意压缩包实际文件名 sudo tar -zxf spark-*.tgz -C /usr/local # 把带版本号的目录改成固定短目录,后续脚本不跟着版本漂 sudo mv /usr/local/spark-* /usr/local/spark # 当前实验用户要有读写权限,否则 spark-shell 写临时目录会失败 sudo chown -R hadoop:hadoop /usr/local/spark

tar -zxf负责解压.tgz,-C /usr/local指定落地目录;mv把版本目录改成/usr/local/spark;chown把属主交给实验用户hadoop。如果你的压缩包解压出来已经是spark,mv那步会提示目录已存在,跳过即可。Hadoop 也按同样方式落到/usr/local/hadoop,别把 Hadoop 和 Spark 解压到/home/hadoop下再混着写路径,后面 spark-submit 的 JAR 路径会变得很长,排查时容易看漏。

启动 spark-shell 用绝对路径最稳:

cd /usr/local/spark ./bin/spark-shell --master local[*]

--master local[*]表示用本机所有可用核跑本地模式,课程实验不依赖集群,先用本地模式把逻辑跑通。启动成功后会出现scala>提示符,同时有一个sc变量代表SparkContext。如果只写./bin/spark-shell,默认也会起本地模式,但显式写local[*]更利于后面解释并行度。启动失败时先看三处:JAVA_HOME是否指向 JDK、SPARK_HOME下是否有bin/spark-shell、当前用户对 Spark 目录是否有读权限。spark-shell 能起来,才算环境闭环过关。

2.3 HDFS 与本地文件系统:两条读取路径先分清

Spark 的textFile不是“看到路径就读”,它先看路径前缀。写file:///home/hadoop/test.txt读的是虚拟机本地文件系统;写/user/hadoop/test.txt默认走 HDFS,具体取决于 Hadoop 配置里的默认文件系统。课程实验里这两个路径经常被混用,于是出现“本地文件明明存在,Spark 却说路径不存在”。HDFS 的根目录不是用户主目录,~在 HDFS 命令里也不表示/user/hadoop,这一点要提前建立概念。

先把本地测试文件准备好,再上传到 HDFS:

# 本地准备一个测试文件,行数自己控制 printf "a\nb\nc\nd\n" > /home/hadoop/test.txt # HDFS 上创建用户目录,-p 表示父目录不存在也一起建 hdfs dfs -mkdir -p /user/hadoop # -f 覆盖已有文件,避免重复上传时报错 hdfs dfs -put -f /home/hadoop/test.txt /user/hadoop/test.txt # 确认 HDFS 文件真实存在 hdfs dfs -ls /user/hadoop

-mkdir -p解决的是/user/hadoop父目录不存在的问题;-put -f解决重复上传;-ls是排错时最该先跑的命令。HDFS 路径区分大小写,/user/hadoop/test.txt和/user/Hadoop/test.txt不是一回事。后面在 spark-shell 里读 HDFS 文件,直接写/user/hadoop/test.txt就行;如果你想写全路径,也可以用hdfs://开头,但课程实验里简写更常见。把本地路径和 HDFS 路径分开记:本地用file:///,HDFS 用/user/hadoop/...,这条规则能挡掉一半低级错误。

3. spark-shell 里的两条读取链路:本地文件与 HDFS 行数统计

3.1 本地文件读取:file:/// 前缀与 textFile 的边界

本地文件读取是这套实验的第一个动手点,也是路径问题最集中的地方。Spark 跑在 JVM 里,textFile收到的字符串会按 URI 解析,file:///home/hadoop/test.txt里的三个斜杠不是手误:第一个是协议分隔,第二个和第三个构成空 authority 加绝对路径。少一个斜杠,URI 解析就会把home当成主机名,抛IllegalArgumentException或找不到文件。正确写法在 spark-shell 里直接敲:

// 三斜杠:file:// + /home/hadoop/test.txt val localText = sc.textFile("file:///home/hadoop/test.txt") // count 是 action,会触发真正读取 val localCount = localText.count() println(s"本地文件行数: $localCount")

sc.textFile返回 RDD[String],每个元素是一行;count()返回 Long。textFile还支持目录和通配符,比如file:///home/hadoop/data/*.txt,但本地路径里不要出现空格,空格在 URI 里要转义,课程实验没必要给自己加难度。参数上还可以传第二个参数控制最小分区数,例如sc.textFile(path, 2),不传时 Spark 按文件块和默认并行度切分。常见误用是把file:///写成file://,或者在路径前多打空格,前者是 URI 解析问题,后者在 Scala 字符串里不一定立刻报错,但读到的路径会带空白。

提示:本地文件路径用file:///开头,不要用~,Spark 不保证替你展开用户主目录。

3.2 HDFS 文件准备:put 上传与 /user/hadoop 目录

HDFS 读取的前提是文件真的在 HDFS 上。实验里常见的动作是先在本地建test.txt,再用hdfs dfs -put上传到/user/hadoop/test.txt。如果你直接写sc.textFile("/user/hadoop/test.txt"),但 HDFS 上只有/user/hadoop/1.txt,就会看到InvalidInputException: Input path does not exist。排查顺序应该是先看 HDFS 目录,再看 Spark 代码:

# 先确认 HDFS 上到底有什么 hdfs dfs -ls /user/hadoop # 文件不在就补传,源文件用本地路径 hdfs dfs -put -f /home/hadoop/test.txt /user/hadoop/test.txt # 再确认一次 hdfs dfs -cat /user/hadoop/test.txt

hdfs dfs -ls看目录,-cat看内容,两个命令能把“文件在不在、内容对不对”一次确认。然后回 spark-shell:

// 不写 file:/// 时,默认按 Hadoop 配置走 HDFS val hdfsText = sc.textFile("/user/hadoop/test.txt") val hdfsCount = hdfsText.count() println(s"HDFS 文件行数: $hdfsCount")

这里的关键参数是路径:/user/hadoop/test.txt是 HDFS 路径,不是本地/home/hadoop/test.txt。HDFS 的根目录不是~,~也不会被展开成/user/hadoop。如果 Hadoop 默认文件系统没有配好,简写可能落到本地文件系统,表现成“文件明明上传了却读不到”。这时可以临时写全路径hdfs://主机:端口/user/hadoop/test.txt,但不建议在实验代码里硬编码主机名,优先把 Hadoop 配置改对。

3.3 行数统计的 count 动作与惰性求值

RDD 是惰性的,textFile只是记录“我要读这个路径”,并不会立刻把文件读进内存;count()是 action,才会触发 job。理解这一点,后面看 Spark 控制台输出的 stage 和 task 才不会懵。下面这段代码把 transformation 和 action 放在一起,方便观察:

// transformation:只建 RDD 血缘,不触发计算 val lines = sc.textFile("file:///home/hadoop/test.txt") // action:取第一行,触发一次 job val first = lines.first() // action:统计行数,再触发一次 job val total = lines.count() println(s"first=$first, total=$total")

first()和count()都是 action,分别触发 job;lines本身可以复用,Spark 会按血缘重算。统计行数用count()就够,不要写成lines.collect().length,后者会把全量数据拉到 Driver,文件一大就内存溢出。count()返回 Long,println里用$total直接输出。常见误用是只写val c = lines.count却忘了(),或者把map当 action 用,结果程序跑完没有任何输出。课程实验的数据量小,这些差异不明显,但习惯要一开始就养对。

4. 独立应用打包:SimpleApp、RemDup、AvgScore 的 Scala 骨架与 sbt 配置

4.1 SimpleApp:从 HDFS 读文件到 count 输出

SimpleApp 是把 spark-shell 里的交互代码搬进独立应用的最小样例。它需要自己创建SparkConf和SparkContext,读取 HDFS 文件,统计行数,最后stop()释放资源。代码放在SimpleApp.scala:

import org.apache.spark.SparkConf import org.apache.spark.SparkContext object SimpleApp { def main(args: Array[String]): Unit = { // setAppName 会显示在 Spark 控制台,便于区分任务 val conf = new SparkConf().setAppName("SimpleApp") val sc = new SparkContext(conf) // 路径按实际 HDFS 文件调整 val textFile = sc.textFile("/user/hadoop/test.txt") val count = textFile.count() println(s"文件行数: $count") sc.stop() } }

这段代码里没有写setMaster,因为提交时用spark-submit --master local[*]指定更灵活;如果直接在 IDE 里跑,才需要补setMaster("local[*]")。sc.stop()别省,否则本地模式下 JVM 可能不退出。编译打包后,用spark-submit提交:

$SPARK_HOME/bin/spark-submit \ --class SimpleApp \ --master local[*] \ /usr/local/spark/mycode/HDFStest/target/scala-2.11/a-simple-hdfs-test_2.11-1.0.jar

--class后面是对象名,不是文件名;JAR 路径里的scala-2.11说明 Scala 二进制版本是 2.11,JAR 名由 sbt 的name和version决定。提交后看到文件行数: 4这类输出,说明 HDFS 读取和 count 都通了。常见翻车是--class写成了SimpleApp.scala,或者 JAR 路径还停留在target/scala-2.11/下但文件名记错,提交时直接ClassNotFoundException。

4.2 RemDup:union + distinct 去重与输出目录处理

RemDup 的实验目标是把两个输入文件合并去重,得到新文件 C。核心算子是union和distinct:union只做合并,不去重;distinct才会触发 shuffle 去重。代码可以写成:

import org.apache.spark.SparkConf import org.apache.spark.SparkContext object RemDup { def main(args: Array[String]): Unit = { val conf = new SparkConf().setAppName("RemDup") val sc = new SparkContext(conf) val fileA = sc.textFile("file:///home/hadoop/sparkapp2/datas/A.txt") val fileB = sc.textFile("file:///home/hadoop/sparkapp2/datas/B.txt") // union 合并,distinct 去重,sortBy 让输出稳定可比对 val merged = fileA.union(fileB).distinct().sortBy(identity) // 输出目录必须不存在,否则 saveAsTextFile 会报错 merged.saveAsTextFile("file:///home/hadoop/sparkapp2/output") sc.stop() } }

union的参数是另一个 RDD,两个 RDD 元素类型要一致;distinct()不带参数时按元素本身去重;sortBy(identity)按字符串排序,方便和样例输出对照。saveAsTextFile写出的是目录,不是单个文件,目录里会有part-00000这类分片文件。参数上最需要注意的是输出路径:每次重跑前先删掉旧输出目录,或者换一个新目录名。常见误用是输出目录已存在还反复提交,Spark 直接抛FileAlreadyExistsException,看起来像代码错,其实是输出目录没清。

4.3 AvgScore:reduceByKey 求平均的成绩聚合

AvgScore 要求读多个成绩文件,每行两个字段:学生名和成绩,最后输出每个学生的平均分。思路是先把每行解析成(姓名, 成绩),再转成(姓名, (成绩, 1)),用reduceByKey同时累加总分和人数,最后相除。代码:

import org.apache.spark.SparkConf import org.apache.spark.SparkContext object AvgScore { def main(args: Array[String]): Unit = { val conf = new SparkConf().setAppName("AvgScore") val sc = new SparkContext(conf) // 通配符读取目录下多个成绩文件,空行先过滤 val lines = sc.textFile("file:///home/hadoop/sparkapp3/AvgScore/datas/*.txt") .filter(_.trim.nonEmpty) val scores = lines.map { line => val parts = line.trim.split("\\s+") (parts(0), parts(1).toDouble) } // (姓名, (成绩, 1)) -> 按姓名累加总分和人数 val total = scores.mapValues(v => (v, 1.0)).reduceByKey { (a, b) => (a._1 + b._1, a._2 + b._2) } val avg = total.mapValues { case (sum, cnt) => sum / cnt } // 输出格式对齐样例:(姓名,平均分) avg.map { case (name, value) => f"($name,$value%.2f)" } .saveAsTextFile("file:///home/hadoop/sparkapp3/AvgScore/output") sc.stop() } }

filter(_.trim.nonEmpty)挡掉空行,避免parts(1)越界;split("\\s+")兼容空格和制表符;mapValues只改 value 不触发 shuffle;reduceByKey按 key 聚合,参数是两个(总分, 人数)元组相加。最后f"($name,$value%.2f)"保留两位小数,输出形状和样例一致。常见误用是直接sum / count用整数除,成绩被截断;或者输入文件字段分隔符不统一,解析时ArrayIndexOutOfBoundsException。数据量小的时候这些错误不容易暴露,换一份带空行的数据就会现形。

4.4 simple.sbt:Scala 2.11 与 Spark 依赖版本对齐

sbt 打包最容易错在版本对齐:Spark 发行包用的是 Scala 2.11,你的scalaVersion却写成 2.12,编译能过、运行会报NoSuchMethodError或NoClassDefFoundError。更稳的做法是从$SPARK_HOME/jars里反查实际版本,再生成simple.sbt:

# 从 Spark 自带 jar 反查 spark-core 和 scala-library 版本 SPARK_CORE_JAR=$(basename $SPARK_HOME/jars/spark-core_2.11-*.jar) SPARK_CORE_VERSION=${SPARK_CORE_JAR#spark-core_2.11-} SPARK_CORE_VERSION=${SPARK_CORE_VERSION%.jar} SCALA_LIB_JAR=$(basename $SPARK_HOME/jars/scala-library-2.11.*.jar) SCALA_VERSION=${SCALA_LIB_JAR#scala-library-} SCALA_VERSION=${SCALA_VERSION%.jar} cat > simple.sbt <<EOF name := "a-simple-hdfs-test" version := "1.0" scalaVersion := "$SCALA_VERSION" libraryDependencies += "org.apache.spark" %% "spark-core" % "$SPARK_CORE_VERSION" % "provided" EOF

basename取出 jar 文件名,#前缀删除和%.jar后缀删除拼出版本号;%%让 sbt 自动拼上 Scala 二进制版本;provided表示 Spark 依赖由运行环境提供,不打进 JAR,避免和集群里的包冲突。name和version决定最终 JAR 文件名,所以提交时看到的a-simple-hdfs-test_2.11-1.0.jar不是手写的。打包命令用绝对路径:

cd /usr/local/spark/mycode/HDFStest /usr/local/sbt/sbt package

打包成功后去target/scala-2.11/下找 JAR。三个应用的目录可以分开建,主类名、输入路径、输出路径按表对照:

应用主类输入输出
SimpleAppSimpleApp/user/hadoop/test.txt控制台行数
RemDupRemDup两个本地文本文件去重后的输出目录
AvgScoreAvgScore多个成绩文本文件平均分输出目录

注意:spark-submit --class后面写主类名,JAR 路径写完整路径;两者任意一个写错,都会在提交阶段失败。

5. 避坑与排查:三个异常背后的路径与空格问题

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

现象:在 spark-shell 里执行sc.textFile("file://home/hadoop/test.txt").count(),抛IllegalArgumentException,提示非法参数或路径无法解析。原因不是文件没建,而是 URI 少了一个斜杠。file://后面直接跟home,解析器会把home当成 authority,后面的路径结构就乱了。解决方法是改成三斜杠,并且让路径以/开头:

// 错误:file://home/... 会被当成主机名 // 正确:file:///home/... 表示本地绝对路径 val text = sc.textFile("file:///home/hadoop/test.txt") text.count()

这类错误在复制粘贴时特别常见,尤其是从笔记里抄路径,少一个/肉眼很难发现。排查时先把路径单独打印出来,看斜杠数量,再用ls确认文件存在。file:///不是 Spark 特有语法,而是标准 URI 写法,记住“协议 + 空 authority + 绝对路径”就不会再写错。

5.2 InvalidInputException:HDFS 路径不是用户主目录

现象:sc.textFile("/user/hadoop/test.txt").count()报InvalidInputException,提示输入路径不存在。原因通常是文件只上传到了本地/home/hadoop/test.txt,HDFS 上并没有/user/hadoop/test.txt;或者上传到了/user/hadoop/1.txt,代码却还在读test.txt。HDFS 的根目录不是~,~也不会自动展开成/user/hadoop。解决方法是先查 HDFS 再改代码:

hdfs dfs -ls /user/hadoop hdfs dfs -put -f /home/hadoop/test.txt /user/hadoop/test.txt hdfs dfs -ls /user/hadoop/test.txt

-ls能直接看到文件是否存在,-put -f覆盖上传。代码里的路径要和 HDFS 上的路径逐字对应,大小写也要一致。如果 Hadoop 默认文件系统配置不完整,简写路径可能落到本地,这时写全hdfs://路径能临时定位问题,但长期还是把配置修对。

5.3 URISyntaxException:URL 前面的空格与不可见字符

现象:程序运行报java.net.URISyntaxException: Illegal character in scheme name at index 0。原因不是路径本身错,而是字符串前面多了空格或不可见字符,URI 解析器把空格当成了 scheme 的一部分。常见于从网页、聊天窗口复制代码,行首带了空白,或者字符串拼接时多了一个空格。解决方法是删掉空格,并用cat -A检查脚本里的不可见字符:

# 查看行首行尾是否有空格或制表符,$ 表示行尾 cat -A SimpleApp.scala | sed -n '1,20p'

代码里写路径时尽量让字符串紧贴引号,不要在sc.textFile(后面换行再缩进出空格。Scala 字符串里的空格是真实字符,编译器不会替你 trim。排查时把路径println出来,前后加标记,例如println(s"[${path}]"),一眼就能看出有没有空白。

5.4 打包与提交阶段的排查清单

除了上面三个异常,打包和提交阶段还有几类高频问题。输出目录已存在时,saveAsTextFile会直接失败,重跑前先hdfs dfs -rm -r或换目录;--class写错时,提交阶段报ClassNotFoundException,用jar tf看一眼 JAR 里有没有对应的 class;依赖版本不匹配时,编译能过但运行报NoSuchMethodError,回到 4.4 节用 Spark 自带 jar 反查版本;JAR 路径写错时,spark-submit直接提示文件不存在,先用ls确认;本地模式忘了--master local[*]时,可能去连不存在的集群,卡住或超时。把这几条按顺序过一遍,大部分提交失败都能定位:

现象先查什么处理
输出目录已存在输出路径是否重复删除旧目录或换新目录
ClassNotFoundException--class与 JAR 内容用jar tf核对主类
NoSuchMethodErrorScala/Spark 版本从$SPARK_HOME/jars反查版本
文件不存在JAR 绝对路径ls确认后重提交
任务卡住--master本地实验显式写local[*]

提示:每次重跑前先清输出目录,再确认 HDFS 输入文件,最后看--class和 JAR 路径,这三步能挡掉大多数“代码没改却突然跑不通”的情况。

6. 端到端回放验收:用固定输入和输出目录把 Spark 实验跑三遍

这套实验最值得养成的习惯,是每次改完代码不要只看控制台有没有报错,而是用固定输入、固定输出目录做一次端到端回放。我的做法是准备一份 4 行小文件、一份带重复行的 A/B 文件、一份四个学生三门课的成绩文件,然后把 SimpleApp、RemDup、AvgScore 各跑三遍:第一遍确认能出结果,第二遍删掉输出目录再跑确认可重复,第三遍换一个输入路径确认路径参数没有写死。下面这段脚本可以当作回放骨架:

# 清掉旧输出,避免 FileAlreadyExistsException rm -rf /home/hadoop/sparkapp2/output rm -rf /home/hadoop/sparkapp3/AvgScore/output # 重新打包并提交,JAR 名按实际 target 目录调整 cd /usr/local/spark/mycode/HDFStest && /usr/local/sbt/sbt package $SPARK_HOME/bin/spark-submit --class SimpleApp --master local[*] \ /usr/local/spark/mycode/HDFStest/target/scala-2.11/a-simple-hdfs-test_2.11-1.0.jar # 去重结果按行排序后和预期比对 sort /home/hadoop/sparkapp2/output/part-* > /tmp/remdup_actual.txt diff -u /home/hadoop/sparkapp2/expected.txt /tmp/remdup_actual.txt

rm -rf只删实验输出目录,不要写成更大范围;sort把分片输出合并排序,diff用预期文件做比对。AvgScore 的验收可以看输出里每个学生的平均分是否保留两位小数,再随机抽一个学生手算总分除以人数。回放时如果第二遍失败、第三遍成功,多半是输出目录或路径写死的问题;如果三遍都成功但结果偶尔不同,检查distinct后有没有依赖无序输出。从那以后我每次带课程实验都强制走一遍“清输出、重打包、再提交、做 diff”的流程,比盯着截图找问题稳得多。这套实验包的目录和三个 Scala 样例可以直接当基线,需要复现时按上面的路径约定取用,希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进