做Spark编程实战,十个人里有九个是从词频统计(WordCount)开始的。我第一次在本地跑通这个例子只花了十几分钟,代码短,结果也正确,但打开Spark Web UI看到那些Stage和Task时,我承认自己其实没完全搞懂发生了什么。后来在几个真实的文本处理需求里反复写这个词频统计,才慢慢把flatMap、map、reduceByKey这些算子背后的执行逻辑、Shuffle的产生机制、分区和数据倾斜的坑摸清楚。这篇博文不打算再贴一份“从入门到精通”式的标准答案,而是把Spark编程实战中词频统计这一关拆开揉碎——环境怎么准备、代码为什么这么写、跑慢了怎么查、Demo怎么变成能用的工具,这些都会聊到。不管你是刚装好Spark还没跑通第一个例子,还是已经能出结果但想看明白内部机制,这篇内容都应该对你有用。
1. 为什么偏偏是词频统计:从单机到分布式的第一道坎
1.1 词频统计覆盖了Spark的核心概念
词频统计的问题描述一句话就能说清:统计一段文本里每个单词出现了多少次。它看起来太简单了,简单到很多教程把它当成“环境装好没”的验证脚本。但真正写过的人会意识到,这个例子恰好是理解Spark的完美载体。
从数据形态看,输入是一行一行的文本,输出是(单词,次数)的二元组,天然适合用RDD建模。从算子类型看,它同时用到了三类操作:flatMap做“一行变多行”的转换,map做逐条映射,reduceByKey做分组聚合。其中flatMap和map是窄依赖,reduceByKey是宽依赖,于是Shuffle、分区、延迟计算、结果落地这些Spark核心概念全部被覆盖了。
更重要的是,词频统计体现了单机编程和分布式编程的思维差异。单机写一个HashMap循环累加很容易,但数据一多,内存装不下,就要考虑分片并行计算、跨节点数据交换、节点故障恢复。词频统计的小代码块里实际上包含了“先局部计算、再全局合并”的分治思想。把这个逻辑吃透,后面理解Join优化、数据倾斜、流式聚合都顺很多。
1.2 Spark分布式执行到底做了什么
很多初学者写代码时容易把Spark当成“能跑大数据版本的Scala/Python集合框架”,但如果不知道执行引擎做了什么,遇到问题只会束手无策。
Spark程序跑起来后,Driver进程会把你写的变换逻辑构建成一张DAG(有向无环图)。DAGScheduler分析这张图,遇到宽依赖就在那里切分Stage。词频统计中,flatMap、map、filter这些操作在同一个Stage内串成流水线,而reduceByKey需要按key把数据重新分配到不同节点,因此会切开一个独立的Shuffle Stage。
Stage内部由多个Task组成,并行度取决于分区数。每个Executor上可以同时跑多个Task,每个Task处理一个分区的数据,处理完把结果写回或发给下游Stage。这就是为什么词频统计跑起来后,Web UI上能看到多个任务并行执行。
这里有一个很多人没注意到的关键点:reduceByKey比groupByKey + map + sum快很多。原因在于reduceByKey支持map端本地聚合,每个Task在shuffle之前先把同一个key的多个计数合并成一个(或少数几个),再通过网络发送给下游。groupByKey则不做任何本地合并,把该分区所有数据原样传输,网络IO和内存压力都会成倍增加。用生活里的例子类比:你所在部门要统计全年报销总额,如果每个人把每一笔发票原件都寄给财务部,财务部会崩溃;更合理的做法是大家先在部门内自己汇总成一张表,再寄汇总结果。reduceByKey就是那个“部门先汇总”的过程。
2. 动手前的准备:环境、数据与语言选型
2.1 本地开发环境与集群环境到底差在哪
很多Spark实战的文章会默认你已经搭好了环境,但实际教室里、办公室里最常出现的报错,恰恰是环境准备阶段埋下的。
本地开发时,最简单的做法是下载一份Spark发行包,用spark-shell或pyspark进入交互式环境。启动时指定--master local[4],意思是在本机用4个线程模拟分布式执行。本地模式的好处是零成本、方便调试、可以在日志里直接看到输出。但它的局限也很明显:数据量大到超过本机内存就崩,默认分区数和集群模式不同,很多分布式场景下的问题在本地根本复现不出来。
真正提交到集群时,通常用spark-submit --master yarn --deploy-mode cluster。这时候代码会被打成JAR包(Python则是带上依赖的压缩包),由ResourceManager分配到各NodeManager上的Executor执行。最容易踩的坑是路径问题:本地模式能读file:///home/user/input.txt,集群模式下每个Executor跑在不同机器上,共享的输入输出必须放到HDFS、对象存储这类分布式文件系统里。我第一次带新人做词频统计时,看到报错FileNotFoundException,第一反应就是问一句:“你是不是把本地路径直接搬到集群了?”
2.2 输入数据怎么构造才算贴近真实
演示用的文本集可以很干净,但真实世界的数据不会。如果你只按空格切分单词,会遇到大写、小写、标点、URL、空行、中文、表情符号等各种干扰项。
我在做词频统计实战时,建议不要用“hello world”这种数据,而是准备一个几百MB级别、包含噪声的真实语料,比如一份网页爬取结果或者一批客服对话记录。文件里既有正常英文句子,也有<div>标签、http://...链接、多处空行、重复标点,这样写出的代码才真正有通用性。
测试阶段的快速验证可以用sc.parallelize(Seq("hello world", "Hello Spark")),直接从内存创建RDD,不需要文件系统。但最终还是要落到sc.textFile("hdfs://.../input")的完整流程。注意textFile背后会按文件块切分分区,HDFS上一个128MB块对应一个分区,这意味着输入文件有几个块,初始就有多少个分区,这个数量直接影响后续并行度。
2.3 Scala还是Python,怎么选
这是个老话题。Spark的原生语言是Scala,API最全,性能和类型安全更可控,适合正式工程。PySpark则是Python包装层,上手快,适合数据分析和快速实验。在实际项目里,我的选择标准很朴素:做正式数据管道和性能调优,用Scala;做分析类和算法原型,用PySpark。
两种语言的词频统计核心算子几乎一一对应,但细节有差别。Scala的flatMap(_.split(...))用的是函数式风格,PySpark的flatMap(lambda line: line.split(...))同样类似。Python这边要注意的是,自定义lambda里如果引用了外部变量,序列化和闭包问题会比Scala更麻烦一些。下面的实现以Scala为主,PySpark的差异我会在关键处指出来。
3. 三种实现方式拆解:RDD、DataFrame、SQL的差异与取舍
3.1 RDD版本:最直觉也最能讲清原理的写法
先看一个基准版本:
import org.apache.spark.{SparkConf, SparkContext} object WordCount { def main(args: Array[String]): Unit = { val conf = new SparkConf().setAppName("WordCount") val sc = new SparkContext(conf) val textFile = sc.textFile(args(0)) val counts = textFile .flatMap(_.toLowerCase.split("\\W+")) .filter(_.nonEmpty) .map(word => (word, 1)) .reduceByKey(_ + _) counts.saveAsTextFile(args(1)) sc.stop() } }这段代码比教程里常见的版本多做了两件事:统一转小写和过滤空字符串。toLowerCase是为了让“Spark”和“spark”归并成一个词,split("\\W+")按非单词字符切分,过滤掉标点留下的空串。别小看这两步,真实语料里空字符串会成为一个巨大的热点key,直接导致数据倾斜。
逐行看执行逻辑:textFile返回RDD[String],每个元素是一行文本;flatMap把一行拆成多个单词,同时把一个RDD“扁平化”成更大的集合;map把每个单词变成(word, 1)这样的键值对;reduceByKey把相同key的value相加。整个链条里,flatMap和map是窄依赖,可以直接在当前节点数据所在的executor上完成,reduceByKey则触发一次Shuffle。
这里实际开发时我建议多考虑一步:如果文本中包含中文,只按\\W+切分会把汉字整体切掉,因为中文字符不属于\\W的“非单词”范围。中文文本的切分方案在后面的进阶部分再展开。
3.2 DataFrame与SQL版本:让Catalyst优化器帮忙
RDD版本适合讲原理,但生产环境我通常优先考虑DataFrame或SQL。原因很简单:Spark SQL底层的Catalyst优化器会在执行前对你的操作做逻辑和物理优化,比如谓词下推、列裁剪、甚至自动调整Shuffle方式。这些优化如果用手写RDD实现,需要自己做很多额外工作。
DataFrame版本:
import org.apache.spark.sql.SparkSession import org.apache.spark.sql.functions._ object WordCountDF { def main(args: Array[String]): Unit = { val spark = SparkSession.builder() .appName("WordCountDF") .getOrCreate() import spark.implicits._ val result = spark.read .text(args(0)) .select(explode(split(lower(col("value")), "\\W+")).alias("word")) .filter(length(col("word")) > 0) .groupBy("word") .count() .orderBy(desc("count")) result.write.parquet(args(1)) spark.stop() } }注意几个和RDD版完全不同的细节。spark.read.text读出来的DataFrame默认只有一列,列名是value。split函数把value字段按正则条件拆成数组,explode函数则把这个数组展开成多行。这两个都是Spark SQL内置函数,不需要自定义实现。groupBy("word").count()的分组计数逻辑一看就懂,orderBy(desc("count"))做全局排序。输出用write.parquet写列式存储,字段语义更清晰,下游也好消费。
当然也可以用纯SQL风格:
df.createOrReplaceTempView("docs") spark.sql(""" SELECT word, COUNT(*) AS cnt FROM ( SELECT explode(split(lower(value), '\\\\W+')) AS word FROM docs ) t WHERE word != '' GROUP BY word ORDER BY cnt DESC """).show()SQL写法的好处是分析人员和开发人员都能看懂,且Spark SQL的优化器依然生效。但它也有局限,复杂业务逻辑全塞进SQL字符串会让维护变得头疼。我的习惯是:快速验证用SQL,正式落库用DataFrame API。
3.3 三种写法怎么选:一张表说清楚
| 对比项 | RDD | DataFrame | SQL |
|---|---|---|---|
| 学习成本 | 较高,需理解函数式编程思维 | 中等,面向结构化数据 | 低,熟悉SQL即可上手 |
| 执行性能 | 手动控制空间大但默认优化少 | Catalyst自动优化,通常更优 | 同DataFrame,优化器统一处理 |
| 代码可读性 | 一般,逻辑复杂后难读 | 较好,链式调用清晰 | 最好,直观表达意图 |
| 适用场景 | 非结构化数据、自定义复杂逻辑 | 结构化数据管道、生产环境 | 即席查询、分析师自服务 |
词频统计本身的逻辑并不复杂,但如果把它放到更大的数据管道里看,DataFrame和SQL在血缘关系、分区裁剪、自动优化上的优势会越来越明显。RDD则适合作为“最后的手段”,当DataFrame的内置函数覆盖不了你的自定义业务逻辑时,再退回RDD自己实现。
4. 踩坑实录:一次词频统计任务的完整排查链路
4.1 现象:任务卡在Reduce阶段,部分Task反复重试
这里分享一个基于真实情况提炼的案例。A同学负责对一批网页文本做词频统计,数据量大概几十GB,用RDD版本跑。任务提交后,前面读文件和切分的阶段很快结束,但一到包含reduceByKey的Stage,整个任务就变得极慢。日志里先是出现ExecutorLostFailure,接着某个Task重试了两三次,最后整个Job以OOM失败收场。
先判断方向:这种“慢点集中在某个特定Stage”的现象,优先级最高的怀疑对象是数据倾斜,其次才是资源不足。注意看UI上那个Stage的Shuffle Write和Shuffle Read字节数,如果总量并不大但某个Task的时长明显高于其他Task,倾斜的概率非常高。
4.2 排查链路:从Web UI到数据采样
第一步,打开Spark Web UI,看Stage列表。确认那个最慢的Stage是不是包含reduceByKey的Shuffle Stage。
第二步,点进该Stage查看Task耗时分布。正常情况各Task耗时应该比较接近,如果出现一条特别长的长尾——比如99%的Task在1秒内结束,某一个Task跑了10分钟还没完——基本锁定倾斜。
第三步,采样确认key分布。在代码里临时加一段逻辑:
val sample = counts.sample(false, 0.01).map(_._1).countByValue() val topKeys = sample.toSeq.sortBy(-_._2).take(10) topKeys.foreach(println)跑完后打印出来的Top key让你能直观看到哪些key占了最大比例。A同学这个案例里,排在第一位的是空字符串,第二位是“the”这类停用词。
第四步,检查并行度。词频统计的reduceByKey默认Shuffle分区数是200,和输入文件分区数没有直接关系。如果data量很小但默认分区太多,会拖慢整体效率;如果数据量很大而分区太少,每个Task处理的数据量就会失衡。A同学当时用的默认配置,而空字符串这个key把某几个分区撑爆了。
4.3 修复方案与验证结果
根因找到后,修复分两步。第一步是清洗逻辑:把正则从split("\\W+")改成更严格的规则,过滤掉长度小于1的单词,同时把停用词也放进过滤条件。第二步是并行度调整:给reduceByKey显式指定分区数,比如reduceByKey(_ + _, numPartitions),numPartitions可以根据集群内核数乘以3到4来设置。
装载这个思路后的代码大致是这样:
val counts = textFile .flatMap(_.toLowerCase.split("\\W+")) .filter(word => word.nonEmpty && !stopWords.contains(word)) .map(word => (word, 1)) .reduceByKey(_ + _, numPartitions)同样一份数据,优化前任务Total Time约23分钟,中间还有Executors挂掉重试;优化后总耗时降到6分钟左右,没有任务重试,各Task耗时长尾消失了。这个对比不是玄学,根因就是空字符串倾斜加分区数不合理。
这里提醒一句:排查倾斜不要靠猜,一定要看Web UI和采样结果。另一种常见策略是Salting加盐,把热点key加随机前缀打散,再做二次聚合。但词频统计这种场景,碰到空字符串和停用词,优先清洗,不需要为了炫技引入复杂方案。
5. 词频统计的进阶:从Demo变成能用的统计工具
5.1 停用词过滤与中文分词:从英文到中文的进阶
停在“能跑通”是不够的。真实项目里,用户给你的往往是一批中文评论、客服工单或者新闻标题。中文文本没有空格边界,直接按正则切分没有任何意义,这时候必须引入分词器。
做法不复杂。选一个合适的分词器(我常用某种支持中文和英文混合的分词库),在每个分区内对文本调用分词逻辑,输出词序列,后面的流程依然走flatMap、map、reduceByKey。由于分词通常是个相对重的操作,最好用广播变量把分词所需的词库和停用词表发到每个Executor上,避免每处理一条数据都从Driver拉一次。
停用词表本身也是一个好例子。比如统计某段时间的热门词,如果不把“的”“了”“是”这些词过滤掉,Top N的结果基本没法看。实现方式是在filter阶段直接判断,如果当前单词在广播的停用词集合里就丢弃。注意广播变量在driver端只存一份,每个Executor本地缓存一份,所以即使停用词表有几千个词,内存开销也可控。
5.2 结果落地:输出文件、结果表与下游衔接
saveAsTextFile直接写出一堆part-00000这样的文件,每个分区一个文件,如果结果有200个分区,就会产生200个小文件。小文件会让下游在读取时吃大亏,HDFS的NameNode压力也跟着上来。
常用做法是写Parquet或Hive表。Parquet是列式存储,压缩率好、查询高效,而且保留了Schema。如果数据量不大,也可以.coalesce(1)合并成一个文件再写,但coalesce只在减少分区且不触发Shuffle时安全,数据量太大时一个分区内存会爆。需要从200个分区合并到10个分区时,用repartition(10),它会触发一次Shuffle,但数据分布更均匀。
实际工程里结果表我一般这样设计:至少包含word、cnt、stat_date三个字段,再加一个source_ds标识数据来源。这样下游做趋势分析时可以直接按日期过滤,也能区分统计范围。
5.3 从批处理到流式统计:Structured Streaming怎么做词频
很多场景下词频统计不是一天跑一次批任务,而是要实时统计“最近10分钟讨论最多的词”。Structured Streaming写出来的代码和批处理惊人地相似。
val lines = spark.readStream .format("kafka") .option("kafka.bootstrap.servers", "...") .option("subscribe", "topic_name") .load() .selectExpr("CAST(value AS STRING) AS line") val words = lines .select(explode(split(lower(col("line")), "\\W+")).alias("word")) .filter(length(col("word")) > 0) val windowedCounts = words .withWatermark("timestamp", "10 minutes") .groupBy(window(col("timestamp"), "10 minutes"), col("word")) .count()这里withWatermark用于处理延迟到达的数据,window函数定义统计窗口。流式任务State存储会持续保留中间聚合结果,如果窗口设置不合理或者状态清理策略不当,会出现结果延迟甚至内存上涨。从批处理切到流式,最大的思维转变在于:数据不再是一份固定文件,而是一条无限的流,逻辑相同,运行机制完全不同。
6. 运行验证与资源参数:本地能跑通不等于集群能跑通
6.1 提交方式的差异与常见坑
本地模式和集群模式的差别不止是路径。哪怕代码逻辑完全正确,也可能踩到几个隐蔽问题。
第一个问题是对分区顺序的依赖。RDD的分区顺序在集群模式下不保证稳定,如果代码里用了依赖partition顺序的逻辑,比如zipWithIndex后再假定某分区是前几条数据,结果就会不稳定。第二个问题是外部依赖。本地环境在Driver里运行,很多东西能读能写,集群模式每个Executor都是独立进程,算子内部如果引用了未被序列化的第三方连接器、本地文件路径,就会在运行时抛出NotSerializableException或FileNotFoundException。
提交命令通常长这样:
spark-submit \ --class com.example.WordCount \ --master yarn \ --deploy-mode cluster \ --num-executors 10 \ --executor-memory 8g \ --executor-cores 4 \ wordcount.jar \ hdfs:///input/path \ hdfs:///output/path注意JAR包里的主类名要和代码一致,参数顺序要严格按照代码里args的读取顺序来。如果要在算子内部写外部存储,用foreachPartition而不是foreach,前者可以针对每个分区批量创建连接、批量提交,后者每一条数据都创建一次连接,性能和稳定性都差很多。
6.2 executor内存、分区数与并行度的关系:一组起步配置
词频统计这类任务,资源配置不需要一开始就拉满,但大多数人不知道从哪里下手。一个常见的起步配置参考如下:
| 参数 | 建议值 | 说明 |
|---|---|---|
--num-executors | 5~20 | 视数据量和队列资源而定,不是越多越好 |
--executor-memory | 4g~8g | 给每个Executor进程的堆内存 |
--executor-cores | 2~4 | 每个Executor的CPU核数,2到4最稳 |
--driver-memory | 2g | Driver端做汇总和调度所需内存 |
spark.default.parallelism | executor核数 × 3~4 | 用于控制默认分区数 |
spark.sql.shuffle.partitions | 200或更高 | 控制Shuffle后数据分区数量 |
这些参数相互制衡。executor内存给得太大,JVM GC频率反而可能变高;executor核数给太多,和同事的队列资源容易冲突;默认并行度太低,大量数据挤在少数分区;并行度太高,调度开销和服务端IO都会上来。词频统计这种内存密集型任务,我会先给每个executor配4个核和6g内存跑一遍,看UI上Task执行时间和GC时间占比,再微调。
6.3 我的经验:如何验证并行执行真正生效
最后分享一个我用来验证代码是否真在并行的办法。本地模式时,故意在算子里加一行println(s"task on partition ${TaskContext.getPartitionId()}"),然后观察输出里是否出现了多个不同的partition编号。如果自始至终只有一个编号,那说明分区数不够或者数据量太小,并行效果根本没体现出来。这段测试日志跑通后记得删掉,集群模式的日志量会很大,而且普通的println不会出现在Driver侧,找起来很费劲。
另一个更实用的小技巧是把输入数据放大10倍再做一次耗时对比。如果并行机制正常工作,耗时不会严格等比放大,而是呈现一个相对平滑的曲线。如果任务耗时从5分钟直接跳到50分钟,或者中途出现OOM,那就不只是资源不足,而是代码里存在串行瓶颈或倾斜问题。
我最后想说的是,词频统计看起来简单,却值得反复写。每一次重写这个例子,你对Shuffle、分区、Catalyst优化器这些概念的理解都会深一层。把它当成解剖Spark内部机制的标本,而不是一个交差用的作业,收获会完全不一样。如果你正在准备一次Spark编程实战,建议从今天这个思路出发,先跑通最简版本,再故意制造一个数据倾斜,最后把它调优成一个能处理真实文本的工具,这样整条链路才算是真正走通了。