1. 从一个"看起来简单"的WordCount说起
但凡接触过分布式计算的人,几乎都绕不开WordCount这个案例。它就像编程语言里的"Hello World",看着简单,但真正动手写的时候,你会发现里面藏着不少门道。我见过太多人第一次跑WordCount,代码照着示例敲完,提交上去,结果要么报错,要么输出一堆空文件,要么结果跟预期对不上。问题往往不在逻辑本身,而在于对Spark运行机制的理解不够深。
这篇内容我打算把WordCount和TopKey这两个经典案例彻底讲透。不只是给你一份能跑的Scala代码,而是把每个算子为什么这么选、参数为什么这么设、集群上跑的时候会遇到什么坑,全部掰开揉碎说清楚。适合已经了解Scala基础语法、想真正把Spark用起来的人。如果你还在纠结要不要学Spark,那这篇可能不太适合你;但如果你已经决定要上手,并且想少走弯路,那接下来的内容应该能帮你省下不少调试时间。
先明确一下这两个案例的核心价值。WordCount解决的是词频统计问题,本质上是把一堆文本拆成单词,然后按单词分组计数。TopKey则是在WordCount的基础上,找出出现频率最高的前N个词。这两个案例覆盖了Spark核心编程中最常用的算子组合:flatMap、map、reduceByKey、sortBy、take等。把它们吃透,后面写更复杂的业务逻辑就是搭积木的事。
2. 环境准备与项目骨架搭建
2.1 依赖版本的选择逻辑
在动手写代码之前,先把环境理清楚。Spark的版本迭代很快,不同版本之间API有差异,尤其是Spark 2.x和3.x之间。我个人的建议是,如果是新项目,直接上Spark 3.x,因为它在性能优化和API简洁性上都有明显提升。但如果你所在的环境还在用Spark 2.x,那也没关系,WordCount和TopKey这两个案例的核心API在两个版本里基本一致。
Scala版本方面,Spark 3.x默认用的是Scala 2.12,Spark 2.4之前用的是2.11。这个必须对齐,否则编译的时候会报NoSuchMethodError之类的错误,而且这种错误往往在运行时才暴露,排查起来很烦。我踩过这个坑,当时本地Scala是2.13,集群上是2.12,代码本地跑得好好的,一提交就挂,查了半天才发现是版本不匹配。
构建工具用Maven或者sbt都行。sbt对Scala项目更友好,依赖管理更自然;Maven的话,配置稍微繁琐一点,但团队协作时更通用。下面以Maven为例,给出核心依赖配置:
<dependencies> <dependency> <groupId>org.apache.spark</groupId> <artifactId>spark-core_2.12</artifactId> <version>3.3.0</version> </dependency> <dependency> <groupId>org.scala-lang</groupId> <artifactId>scala-library</artifactId> <version>2.12.15</version> </dependency> </dependencies>注意artifactId里的_2.12后缀,这个数字必须和你的Scala版本一致。很多人复制依赖的时候只看版本号,忽略了后缀,结果编译报错找不到类。
2.2 项目目录结构
目录结构不用太复杂,但建议按职责分一下。我习惯这样组织:
src/main/scala/ com/example/spark/ WordCount.scala TopKey.scala util/ SparkSessionBuilder.scala把创建SparkSession的逻辑单独抽出来,是因为实际项目中,几乎每个任务都需要初始化SparkSession,而且配置参数往往有共性。抽成一个工具类,后面写新任务的时候直接调用,省得重复写一堆配置。
package com.example.spark.util import org.apache.spark.sql.SparkSession object SparkSessionBuilder { def create(appName: String, master: String = "local[*]"): SparkSession = { SparkSession.builder() .appName(appName) .master(master) .config("spark.sql.shuffle.partitions", "10") .getOrCreate() } }这里我特意设了spark.sql.shuffle.partitions为10。默认值是200,对于小数据集来说,200个分区意味着200个任务,每个任务处理的数据量极小,调度开销反而成了瓶颈。本地测试的时候设小一点,能明显感觉到速度提升。当然,生产环境要根据数据量调整,这个后面会细说。
3. WordCount的Scala实现与算子拆解
3.1 完整代码与逐行解读
先上完整代码,然后逐段拆解:
package com.example.spark import com.example.spark.util.SparkSessionBuilder object WordCount { def main(args: Array[String]): Unit = { val spark = SparkSessionBuilder.create("WordCount") val sc = spark.sparkContext val inputPath = args(0) val outputPath = args(1) val textRDD = sc.textFile(inputPath) val wordCounts = textRDD .flatMap(line => line.split("\\s+")) .filter(_.nonEmpty) .map(word => (word, 1)) .reduceByKey(_ + _) wordCounts.saveAsTextFile(outputPath) spark.stop() } }这段代码看着简单,但每一行都有讲究。
sc.textFile(inputPath)读取文件,返回的是一个RDD[String],每个元素是文件中的一行。这里有个细节:如果输入路径是本地文件系统,需要确保集群上每个节点都能访问到;如果是HDFS路径,那就没这个问题。我见过有人在本地用file:///路径测试,提交到集群后报FileNotFoundException,就是因为路径没改成HDFS。
flatMap(line => line.split("\\s+"))把每行拆成单词。用\\s+而不是简单的空格,是因为实际文本里可能有多个连续空格、制表符、换行符等。flatMap和map的区别在于,flatMap会把拆分后的数组"拍平",返回一个包含所有单词的RDD,而不是RDD[Array[String]]。
filter(_.nonEmpty)过滤掉空字符串。这个步骤容易被忽略,但如果文本里有连续空格,split之后会产生空字符串,不过滤的话最后统计结果里会出现一个空key,看着很别扭。
map(word => (word, 1))把每个单词映射成键值对,这是为后面的reduceByKey做准备。Spark的reduceByKey要求输入是(key, value)形式的RDD。
reduceByKey(_ + _)按key分组,对value做累加。这里用reduceByKey而不是groupByKey,是因为reduceByKey会在map端做本地聚合,减少shuffle的数据量。对于词频统计这种场景,reduceByKey的性能优势非常明显。
3.2 reduceByKey和groupByKey的性能差异
很多人写WordCount的时候,习惯用groupByKey然后mapValues(_.sum),逻辑上没问题,但性能差很多。我做过一个测试,同样的数据集,reduceByKey比groupByKey快了将近三倍。
原因在于shuffle机制。groupByKey会把所有相同key的value拉到同一个分区,不做任何预聚合。如果某个key对应的数据量特别大,还会导致数据倾斜,某个task处理的数据量远超其他task,拖慢整个作业。reduceByKey则会在map端先做一次本地聚合,相当于把"局部求和"提前做了,shuffle的时候只传输聚合后的结果,数据量小很多。
注意:如果业务逻辑不是简单的求和,而是需要保留所有原始value(比如要计算中位数),那
groupByKey还是得用。但纯粹做计数、求和这类聚合操作,优先选reduceByKey。
3.3 输出结果的处理与常见问题
saveAsTextFile(outputPath)把结果写到指定路径。这里有几个坑:
第一,输出路径不能已经存在。Spark会检查路径,如果目录已存在,直接抛异常。调试的时候反复运行,每次都要手动删输出目录,很烦。可以在代码里加一段判断,或者用hadoop fs -rm -r先清理。
第二,输出是一个目录,里面包含多个part文件,数量等于最终RDD的分区数。如果分区数是200,就会生成200个文件,哪怕数据量很小。这时候可以用coalesce(1)把结果合并到一个文件里,但要注意,coalesce(1)会把所有数据拉到一个节点,数据量大时可能OOM。更稳妥的做法是repartition(1),但同样有性能问题。我的建议是,小结果集用coalesce(1),大结果集保持多文件输出,后续用其他工具合并。
第三,输出格式是(word, count)的字符串形式,因为saveAsTextFile调用的是元素的toString方法。如果后续要程序化读取,这种格式不方便解析。可以考虑用saveAsObjectFile或者转成DataFrame后写CSV/Parquet。
4. TopKey的实现思路与排序策略
4.1 从WordCount到TopKey的演进
TopKey的需求是:在词频统计的基础上,找出出现次数最多的前N个词。这个需求在实际场景中很常见,比如分析热搜词、统计高频错误码、找出访问量最大的URL等。
实现思路有两种。第一种是先做完整的WordCount,然后对结果按count降序排列,取前N个。第二种是用takeOrdered或者top算子,直接在RDD层面做。两种方式各有适用场景,下面分别说。
4.2 基于sortBy的全局排序方案
先看第一种,也是最直观的方案:
package com.example.spark import com.example.spark.util.SparkSessionBuilder object TopKey { def main(args: Array[String]): Unit = { val spark = SparkSessionBuilder.create("TopKey") val sc = spark.sparkContext val inputPath = args(0) val topN = args(1).toInt val textRDD = sc.textFile(inputPath) val topKeys = textRDD .flatMap(_.split("\\s+")) .filter(_.nonEmpty) .map(word => (word, 1)) .reduceByKey(_ + _) .sortBy(_._2, ascending = false) .take(topN) topKeys.foreach(println) spark.stop() } }这段代码的核心是sortBy(_._2, ascending = false)。sortBy会对整个RDD进行全局排序,底层通过rangePartitioner实现。排序完成后,take(topN)取前N个元素。
这里有个性能问题:sortBy是全局排序,会触发一次全量shuffle,把所有数据按照count重新分区。如果数据量很大,这一步开销不小。而且我们只需要前N个,却对所有数据做了排序,有点浪费。
4.3 takeOrdered与top的取舍
Spark提供了takeOrdered和top两个算子,专门用于取前N个元素,不需要全局排序。它们的区别在于:
top(n):取最大的N个元素,使用默认的降序排列。takeOrdered(n):取最小的N个元素,使用默认的升序排列。
对于TopKey场景,我们要取count最大的N个词,可以用top,但top比较的是整个元素,而我们的元素是(word, count)这样的元组。元组的默认排序是先比第一个元素,再比第二个。我们想按count排序,就需要提供一个自定义的Ordering。
val topKeys = textRDD .flatMap(_.split("\\s+")) .filter(_.nonEmpty) .map(word => (word, 1)) .reduceByKey(_ + _) .top(topN)(Ordering.by(_._2))Ordering.by(_._2)指定按照元组的第二个元素(也就是count)来比较。top内部会先在每个分区取前N个,然后汇总,效率比全局排序高很多。
但top有一个限制:它返回的是一个数组,数据量大的时候,这个数组会全部拉到Driver端。如果N很大,Driver端可能内存不够。所以top适合N比较小的场景,比如取前10、前100。如果N是几千甚至几万,还是用sortBy然后take更稳妥,或者用sortBy后limit再写文件。
4.4 处理数据倾斜的实战技巧
TopKey场景下,数据倾斜是一个绕不开的问题。如果某个词出现频率极高,比如"的"、"是"这类停用词,那么reduceByKey的时候,这个key对应的数据量会远超其他key,导致某个task成为瓶颈。
我遇到过一个案例,日志分析任务里,某个错误码占了总日志量的40%,reduce阶段直接卡住。解决办法有几个:
第一,预处理阶段过滤掉明显的停用词。如果是文本分析,维护一个停用词表,在flatMap之后加一个filter,把停用词去掉。这个最简单,效果也最直接。
第二,加盐打散。给key加上随机前缀,比如(word + "_" + Random.nextInt(10), 1),先做一次聚合,然后再去掉前缀做第二次聚合。这样能把一个热key拆成10个,分散到不同task上。代价是多了一次shuffle,但能解决倾斜问题。
第三,提高并行度。增加shuffle的分区数,让每个task处理的数据量小一点。可以通过spark.sql.shuffle.partitions或者reduceByKey(_ + _, numPartitions)来指定。
提示:加盐方案会增加代码复杂度,如果不是倾斜特别严重,优先考虑过滤停用词和调整并行度。加盐适合倾斜非常极端、且无法通过过滤解决的场景。
5. 集群提交与参数调优的实操细节
5.1 spark-submit的常用参数
代码写好了,本地跑通只是第一步,真正要上集群,得用spark-submit提交。下面是一个典型的提交命令:
spark-submit \ --class com.example.spark.WordCount \ --master yarn \ --deploy-mode cluster \ --executor-memory 4g \ --executor-cores 2 \ --num-executors 10 \ --driver-memory 2g \ wordcount.jar \ hdfs:///input/text.txt \ hdfs:///output/wordcount每个参数都有讲究。--executor-memory决定每个Executor的堆内存,--executor-cores决定每个Executor能并行跑几个task。--num-executors是Executor的数量。这三个参数共同决定了作业的总并行度:num-executors * executor-cores。
很多人调优的时候只盯着内存加,忽略了cores。实际上,如果cores太少,每个Executor只能串行跑task,内存再大也用不上。一般来说,每个Executor配2到5个cores比较合适,太少浪费内存,太多会导致GC压力大。
--driver-memory容易被忽略。如果代码里有collect、take这类把数据拉到Driver端的操作,Driver内存不够会直接OOM。TopKey场景下,如果用top取前N个,N很大时就要注意Driver内存。
5.2 内存配置的计算逻辑
Executor内存怎么定?一个粗略的估算方法是:总数据量除以总并行度,再乘以一个安全系数。
假设输入数据是10GB,num-executors=10,executor-cores=2,那么总并行度是20。每个task平均处理500MB数据。考虑到shuffle、缓存、对象开销,每个task至少需要1GB内存,那么每个Executor(跑2个task)需要2GB用于计算,再加上堆外内存和系统预留,executor-memory设4GB比较稳妥。
当然这只是估算,实际还要看数据特征。如果数据里字符串特别多,对象开销会更大;如果有大量shuffle,内存需求也会上升。我的经验是,先按估算值跑一次,看Spark UI里的GC时间和spill情况,再针对性调整。
5.3 提交后常见报错与排查
提交到集群后,最常见的报错有这么几类:
第一类:ClassNotFoundException。通常是依赖没打进去。用Maven的shade插件打一个fat jar,把所有依赖都包含进去。注意Spark本身的依赖要设成provided,因为集群上已经有了,打进去反而可能冲突。
第二类:Container killed by YARN for exceeding memory limits。这是Executor内存超了。可能是数据倾斜导致某个task处理的数据量特别大,也可能是内存配置本身就不够。先看Spark UI里各task的处理数据量,确认是不是倾斜,再决定是加内存还是解决倾斜。
第三类:Shuffle fetch failed。通常是Executor挂了导致shuffle数据丢失。可以开启spark.shuffle.service.enabled,让shuffle数据由外部服务管理,Executor挂了也不影响。另外,spark.network.timeout可以适当调大,避免网络抖动导致的fetch失败。
第四类:输出目录已存在。前面提过,Spark不允许输出到已存在的目录。可以在代码里加判断,或者每次提交前手动清理。
6. 从案例到生产的思维转变
6.1 本地模式与集群模式的差异
本地模式跑通,不代表集群上没问题。我总结了几点差异,都是实际踩过的:
第一,文件路径。本地用file:///,集群上要用HDFS路径。如果代码里写死了本地路径,提交上去必然报错。建议把路径作为参数传入,不要硬编码。
第二,序列化。本地模式下,很多对象不需要序列化就能传递;集群模式下,所有在Executor上执行的对象都必须可序列化。如果代码里用了自定义类,记得实现Serializable接口。否则会报NotSerializableException,而且报错信息往往指向一个看起来无关的地方,排查起来很费劲。
第三,日志级别。本地跑的时候,控制台输出很清晰;集群上跑,日志分散在各个Executor的container里,需要去YARN的日志聚合里看。建议在代码里把日志级别调成WARN,减少不必要的输出,方便定位问题。
6.2 代码健壮性的几个习惯
写Spark代码,有几个习惯能帮你省很多事:
习惯一:参数校验。输入路径、输出路径、TopN这些参数,在main函数开头就校验,不合法直接退出,别等到运行到一半才报错。
习惯二:资源关闭。spark.stop()要确保执行,最好放在finally块里。虽然SparkSession不关也不会导致严重问题,但长期运行的服务里,不关会占着资源。
习惯三:异常捕获。对于可能失败的操作,比如读写HDFS,加try-catch,把异常信息打出来,方便排查。但不要吞异常,捕获了要处理或者重新抛出。
习惯四:配置外置。把路径、并行度、内存这些配置抽到配置文件或者命令行参数里,不要硬编码。这样换环境的时候不用改代码,重新提交就行。
6.3 性能监控与调优的切入点
作业跑起来之后,怎么知道性能好不好?看Spark UI。重点看几个指标:
- Task的duration分布:如果大部分task很快,少数task特别慢,说明有数据倾斜。
- GC时间:如果GC时间占比超过10%,说明内存不够,需要加内存或者减少缓存。
- Shuffle read/write:如果shuffle数据量特别大,考虑优化算子,减少shuffle。
- Spill:如果出现spill,说明内存不足以容纳当前分区的数据,需要加内存或者增加分区数。
调优是一个迭代的过程,没有一劳永逸的配置。我的做法是,先保证功能正确,再根据监控数据逐步优化。每次只调一个参数,观察效果,避免同时改多个参数导致无法定位问题。
7. 一些容易被忽略的细节
7.1 文本编码问题
读取文本文件时,默认是UTF-8编码。如果文件是GBK编码,中文会出现乱码。解决办法是在读取时指定编码,或者先用工具转成UTF-8。sc.textFile本身不提供编码参数,可以用sparkContext.hadoopConfiguration设置,或者用wholeTextFiles读取后手动解码。
7.2 空文件与空分区的处理
如果输入目录里有空文件,textFile会正常读取,但对应的分区里没有数据。这本身不是问题,但如果后续有mapPartitions之类的操作,要处理空迭代器的情况,否则可能报错。
7.3 结果验证的方法
跑完WordCount,怎么验证结果对不对?我的做法是,先用一个小样本数据,手动算一遍预期结果,然后跟Spark的输出对比。确认逻辑没问题后,再上全量数据。全量数据跑完后,可以抽样检查几个key的count,跟原始数据里的出现次数对一下。
对于TopKey,验证方法是:先跑完整的WordCount,把结果按count排序,取前N个,跟TopKey的输出对比。如果一致,说明TopKey的逻辑没问题。
7.4 关于Scala的隐式转换
Scala的隐式转换在Spark代码里用得很多,比如reduceByKey需要(K, V)类型的RDD,而map返回的是普通RDD,中间就涉及隐式转换。如果import不对,编译会报错。常见的import包括org.apache.spark.SparkContext._,它包含了这些隐式转换。Spark 2.x之后,很多隐式转换移到了org.apache.spark.rdd.RDD的伴生对象里,import方式有变化,需要注意版本差异。
8. 从这两个案例延伸出去
WordCount和TopKey虽然简单,但它们覆盖的算子组合可以延伸到很多场景。比如,把单词换成URL,就是访问量统计;把单词换成错误码,就是错误分布分析;把count换成金额,就是销售额聚合。核心模式是一样的:拆分、映射、聚合、排序。
掌握了这个模式,再去看Spark的其他算子,比如aggregateByKey、combineByKey、foldByKey,会发现它们只是聚合方式不同,底层逻辑是相通的。reduceByKey要求聚合前后的类型一致,aggregateByKey允许聚合前后类型不同,combineByKey则更灵活,可以指定初始创建、分区内合并、分区间合并三个函数。
我在实际项目中,用得最多的是reduceByKey和aggregateByKey。前者适合简单的求和、计数,后者适合求平均值、求TopN这类需要中间状态的聚合。比如求每个用户的平均消费金额,就可以用aggregateByKey,初始值是(0.0, 0),分区内合并是累加金额和次数,分区间合并也是累加,最后再相除。
最后分享一个小技巧:写Spark代码的时候,尽量用reduceByKey替代groupByKey,用mapPartitions替代map(当需要在每个分区内做初始化操作时),用filter尽早过滤掉不需要的数据。这些习惯能显著提升作业性能,尤其是在数据量大的时候。