1. Action算子的执行机制:先搞懂它,再聊具体API
1.1 Transformation是"记流水账",Action才是真正去干活
很多刚开始写Spark的朋友都会有一个困惑:为什么同一个RDD上调用map、filter这类算子之后,打印出来什么都没发生?而一旦在最后接上一个collect或者count,程序才真正开始跑?这是Spark最核心的设计之一:RDD上的Transformation算子(转换算子)是惰性(lazy)求值的,只有遇到Action算子(行动算子)时,整个计算链才会被真正执行。
打个比方,Transformation就像你在备忘录里写"今天要去买菜、做饭、洗碗",写得很详细,但没有动手。而Action就是那个把你从沙发上拽起来去执行的人。没有Action,前面所有的map、filter、reduceByKey都只是构建了一张计算流程图(DAG),不会产生任何实际计算。
所以对于初学者来说,理解Action算子的地位,比背住几个API更重要。它决定了你的Spark作业什么时候真正提交、什么时候产出结果、结果以什么形式返回给Driver。而本文要聊的saveAsTextFile、top(num)、takeOrdered(num),正是这三类最典型的Action算子场景:一个负责把结果落到分布式文件系统里,两个负责把全局排序后的TopN结果捞回Driver端。
提示:判断一个算子到底是Transformation还是Action,最简单的办法就是看它的返回值类型。返回RDD的是Transformation,返回非RDD(如Array、Long、Unit等)的就是Action。
saveAsTextFile返回Unit(无返回值),top返回Array[T],takeOrdered返回Array[T],三者都是Action。
1.2 runJob调度链路:从Action到DAGScheduler的完整触发过程
一个Action被调用的背后,Spark内部会发生一连串动作。以top(num)为例,当你调用了这个算子,SparkContext会调用runJob方法,把当前的RDD以及一个处理函数传给DAGScheduler。DAGScheduler会根据RDD的依赖关系把DAG切分成多个Stage,每个Stage内部包含一组可以并行执行的Task。然后TaskScheduler把这些Task分发到Executor上执行,每个Executor执行完自己的分区计算后,把结果返回给Driver,最终在Driver端汇聚成你看到的Array。
这里有个值得注意的细节:不同类型的Action,对结果的处理方式不同。有的算子只需要Executor返回局部结果(比如count返回分区内计数),有的算子则要求Executor把整个分区的数据都序列化回来(比如collect)。而top和takeOrdered则介于两者之间——它并不是把全量数据都拉回Driver再排序,而是在每个Executor内先取局部的TopN,再把这些局部TopN结果合并到Driver端做最终的全局TopN,这个机制我在第4章会详细拆解源码。
理解这个执行链路,你就能明白一个道理:为什么要尽量避免在数据量极大的RDD上直接调用collect()?因为collect()会把所有分区的全部数据通过网络传输到Driver端,一旦数据量超过Driver可用内存,直接OOM。而top和takeOrdered因为采用了局部裁剪,传输的数据量就小得多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. saveAsTextFile:把RDD落盘的细节与常见坑
2.1 基本用法与输出目录结构
saveAsTextFile是Spark中最常用的输出算子,作用是把RDD中的每个元素以文本形式写入到文件系统(本地文件系统或HDFS)。每行对应RDD中的一个元素,元素内容的字符串形式由toString方法决定。使用非常简单:
scala复制val rdd = sc.parallelize(Seq("apple", "banana", "orange"), 2)
rdd.saveAsTextFile("hdfs:///tmp/fruit_output")
执行完成后,你会发现目标路径下并不是一个单一的文件,而是一个目录,目录里面包含若干个part-00000之类的文件,以及一个_SUCCESS标记文件:
text复制/tmp/fruit_output/
_SUCCESS
part-00000
part-00001
每个part-*文件对应一个RDD分区的输出。这里提醒一下,_SUCCESS文件是Spark 1.x之后自动生成的,用来标识作业成功结束。很多工作流调度系统(比如Azkaban、Oozie)都会依赖这个文件来判断数据是否产出完整。如果你想关闭它,可以通过配置spark.hadoop.mapreduce.fileoutputcommitter.marksuccessfuljobs=false来实现,但生产环境我建议保留,因为下游任务校验时非常有用。
2.2 分区数量与输出文件数的对应关系
这个细节面试经常考,实际工作中也容易踩坑:输出文件的数量 = 父RDD的分区数。也就是说,如果你对一个有200个分区的RDD执行saveAsTextFile,那么输出目录里就会有200个part文件。有些同学希望最终只产出一个文件,就把数据源repartition(1)再写出,但这样做有严重的性能隐患——repartition(1)会把全量数据shuffle到一个分区,完全丧失了并行度,在大数据量下会非常慢,甚至OOM。
正确的做法是分场景处理:
- 如果数据量不大,且下游系统只支持单文件读取(比如某些传统数仓导入工具),你可以在写出前先进行文件合并,而不是在Spark作业里强行单分区。
- 如果数据量很大,下游是Hive或Spark SQL读取,那么多part文件完全不是问题,Spark和Hive读取目录时天然支持多文件并行。
另外补充一点:如果对输出文件的命名有要求,saveAsTextFile本身是没法自定义文件名的。但可以通过rdd.coalesce(n, shuffle = false)提前把分区数调整为期望的文件数量,这样既保留了并发,又能控制输出文件个数。具体对比我在第5章展开。
2.3 常见坑:目录已存在、自定义对象输出、压缩格式
第一坑:目标路径已存在时,Spark不会帮你覆盖删除,而是直接抛FileAlreadyExistsException。这个行为在实际开发中经常让人觉得困扰,因为HDFS上的临时目录如果没有清理干净,作业重跑时就会失败。我的习惯是在每次写出前,先调用HDFS的Shell命令或Spark的hadoop.fsAPI做一次递归删除:
scala复制val hadoopConf = sc.hadoopConfiguration
val fs = org.apache.hadoop.fs.FileSystem.get(hadoopConf)
val outputPath = new org.apache.hadoop.fs.Path("hdfs:///tmp/fruit_output")
if (fs.exists(outputPath)) {
fs.delete(outputPath, true)
}
rdd.saveAsTextFile("hdfs:///tmp/fruit_output")
第二坑:自定义对象输出的内容不是字段,而是调用了toString方法的默认输出。比如你有一个case class Student(name: String, score: Double),直接把RDD[Student]写出,得到的内容很可能是Student(Alice,98.5)这样的格式,下游解析起来非常别扭。解决方式有两种:一种是提前把RDD映射成String类型,比如rdd.map(s => s"${s.name},${s.score}");另一种是重写toString方法。实战中我更推荐前者,因为显式的map会让数据格式一目了然,也便于后续维护。
第三坑:输出格式默认是纯文本,不压缩。如果你的中间结果非常大,磁盘占用和网络传输开销会很高。可以通过设置压缩相关参数来产出压缩文件,比如:
scala复制sc.hadoopConfiguration.set("spark.hadoop.mapred.output.compress", "true")
sc.hadoopConfiguration.set("spark.hadoop.mapred.output.compression.codec", "org.apache.hadoop.io.compress.GzipCodec")
这样输出文件的扩展名会带上.gz,下游spark读取时自动识别。不过要注意,压缩文件的并行读能力取决于压缩格式,Gzip虽然压缩率高,但不可切分,如果下游做全量扫描,建议使用Snappy或LZO。关于这一点,我在生产环境里遇到过一次血泪教训:把一个大表输出成Gzip格式,下游跑一个聚合任务,因为Gzip不可切分,一个文件只能由一个Task读取,导致整个Stage的并行度下降到文件数量,作业跑了一个多小时,换成Snappy后十几分钟就结束了。
3. top和takeOrdered:TopN排序背后的优先队列设计
3.1 top与takeOrdered的语义差异
这两个算子从名字上看就很像,实际效果也确实相关,但有一个本质区别:
top(num):返回RDD中最大的num个元素,结果按降序排列(最大的排在最前面)。takeOrdered(num):返回RDD中最小的num个元素,结果按升序排列(最小的排在最前面)。
举一个最简单的例子,假设RDD中包含[1, 5, 9, 12, 21]:
scala复制val rdd = sc.parallelize(Seq(1, 5, 9, 12, 21))
rdd.top(2) // 返回 Array(21, 12)
rdd.takeOrdered(2) // 返回 Array(1, 5)
也就是说,top和takeOrdered其实是互补关系,一个取大,一个取小。那如果我想取最小的5个但希望降序排列怎么办?普通的takeOrdered做不到,因为它的返回结果固定是升序。但我们可以借助Ordering的反转来实现,这部分在第3.3节详述。
另外需要特别强调的是,这两个算子都不能处理num = 0,源码里直接返回空数组,这是合理的设计。而如果num大于RDD元素总数,它们会返回全部元素,但排列仍然符合各自语义。
3.2 源码拆解:为什么取TopN不用collect全量排序
很多人在拿到排序需求时,第一反应是rdd.collect().sortBy(...).take(n),这在数据量小的时候没什么问题,但数据量一旦上亿,collect会直接把Driver搞挂。Spark提供的top和takeOrdered之所以性能更好,核心在于内部使用了BoundedPriorityQueue(有界优先队列)做局部裁剪。
我们来看takeOrdered的源码逻辑:
scala复制def takeOrdered(num: Int)(implicit ord: Ordering[T]): Array[T] = withScope {
if (num == 0) {
Array.empty
} else {
val mapRDDs = mapPartitions { items =>
val queue = new BoundedPriorityQueue[T](num)(ord)
queue ++= collectLocalIterator(items, num)
Iterator.single(queue)
}
if (mapRDDs.partitions.length == 0) {
Array.empty
} else {
mapRDDs.reduce { (queue1, queue2) =>
queue1 ++= queue2
queue1
}.toArray.sorted(ord)
}
}
}
这个实现非常巧妙,分为两步:
第一步,对每个分区单独执行mapPartitions,在每个分区内用一个容量为num的有界优先队列维护局部TopN数据。比如你要全局Top10,每个分区只需保留本分区内最大的10个元素,其余全部丢掉。这一步相当于做了一次"分区级裁剪",大幅减少了需要shuffle到Driver的数据量。
第二步,把每个分区返回的局部优先队列在Driver端用reduce合并。由于每个队列最多只有num个元素,合并时队列大小始终不会超过num,最终得到一个全局的TopN,最后执行sorted保证输出按照ord顺序排列。
整体流程可以这样理解:不需要把成千上万个元素全部运到Driver,每个分区只交出"班级前十名",然后Driver把各班的"前十名"聚在一起,再精挑出"全校前十名"。数据量从N缩减到了num × 分区数,这个数字通常小到可以忽略不计。
再看top的源码,就简单得有点意外:
scala复制def top(num: Int)(implicit ord: Ordering[T]): Array[T] = withScope {
takeOrdered(num)(ord.reverse)
}
它其实就是把Ordering反转之后调用takeOrdered,再通过反转后的比较器得到的数组天然是降序的。所以top不是一套独立的实现,而在逻辑上完全复用takeOrdered。
3.3 自定义排序规则:从Ordering到reverse
两个算子都接受一个隐式参数Ordering[T],这意味着你可以完全控制"最大"或"最小"的判定标准。Scala的Ordering是一个相当灵活的类型类,通过Ordering.by可以方便地根据对象的某个字段来定义排序规则。
比如说,你有一个学生对象RDD,想按分数取前三名:
scala复制case class Student(name: String, score: Double)
val students = sc.parallelize(Seq(
Student("Alice", 98.5),
Student("Bob", 87.0),
Student("Cathy", 92.3),
Student("David", 76.8),
Student("Eve", 95.1)
))
// 默认按case class字段顺序比较,不适用;需要显式指定按score比较
implicit val studentOrdering: Ordering[Student] = Ordering.by[Student, Double](_.score)
students.top(3)
// Array(Student(Alice,98.5), Student(Eve,95.1), Student(Cathy,92.3))
students.takeOrdered(3)
// Array(Student(David,76.8), Student(Bob,87.0), Student(Cathy,92.3))
这里有一个非常重要的细节:如果RDD元素是自定义对象,且你没有提供隐式Ordering,编译器会尝试自动生成一个基于字段顺序的Ordering。对于case class Student(name: String, score: Double)来说,自动生成的Ordering先比较name再比较score,这通常不是你想要的结果。所以实际开发中,遇到自定义对象排序时,一定要显式声明排序规则,否则会出现排序结果完全不在预期中,且排查起来非常隐蔽。
再看如何实现"取倒数前3但按降序输出"这种反转需求,可以用以下方式:
scala复制// 取分数最低的3个,返回时从低到高排列(升序)
students.takeOrdered(3)(Ordering.by[Student, Double](_.score))
// 取分数最低的3个,但希望从高到低排列:先取升序,再.reverse
students.takeOrdered(3)(Ordering.by[Student, Double](_.score)).reverse
或者直接用Ordering.by(...).reverse控制比较器方向:
scala复制// 取分数最高的3个,但返回时按升序排列
students.top(3)(Ordering.by[Student, Double](_.score).reverse)
在实际项目中,Ordering.by这种写法非常常见,尤其是当你要在Scala代码中对DataFrame之外的非结构化数据做多级排序时,Ordering.by[Student, (Double, String)](s => (s.score, s.name))可以轻松实现"先按分数、再按名字"的多级比较。
4. 实战案例:销售TopN排行榜与结果落盘
4.1 模拟数据与计算目标
理论讲完,必须来点实操。假设我们有一份电商网站的订单数据,字段依次是商品分类、商品名称、销售额。目标是:
- 统计每个商品的销售总额,并找出销售额最高的5个商品,作为推荐榜。
- 找出销售额最低的5个商品,作为淘汰候选。
- 把结果按指定格式写入到HDFS目录中,供下游报表系统读取。
先构造测试数据并创建RDD:
scala复制import org.apache.spark.{SparkConf, SparkContext}
val conf = new SparkConf().setAppName("TopNActionDemo").setMaster("local[*]")
val sc = new SparkContext(conf)
val salesData = Seq(
("Electronics", "iPhone 15", 1200000),
("Electronics", "Samsung S24", 980000),
("Home", "Air Fryer", 230000),
("Home", "Robot Vacuum", 340000),
("Clothing", "Down Jacket", 150000),
("Clothing", "T-shirt", 45000),
("Sports", "Running Shoes", 310000),
("Sports", "Yoga Mat", 18000),
("Books", "Data Science Guide", 55000),
("Books", "Novel Set", 12000),
("Beauty", "Face Cream", 260000),
("Beauty", "Lipstick", 42000)
)
val salesRDD = sc.parallelize(salesData, 4)
4.2 用reduceByKey + top取销售额Top5
现在按商品名称汇总销售额,由于模拟数据没有重复商品名,为了演示reduceByKey的完整用法,我们把iPhone 15和Samsung S24的数据做一下拆分再汇总:
scala复制val detailedData = Seq(
("iPhone 15", 800000), ("iPhone 15", 400000),
("Samsung S24", 500000), ("Samsung S24", 480000),
("Air Fryer", 150000), ("Air Fryer", 80000),
("Robot Vacuum", 200000), ("Robot Vacuum", 140000),
("Down Jacket", 100000), ("Down Jacket", 50000),
("T-shirt", 25000), ("T-shirt", 20000),
("Running Shoes", 160000), ("Running Shoes", 150000),
("Yoga Mat", 10000), ("Yoga Mat", 8000),
("Data Science Guide", 30000), ("Data Science Guide", 25000),
("Novel Set", 7000), ("Novel Set", 5000),
("Face Cream", 140000), ("Face Cream", 120000),
("Lipstick", 22000), ("Lipstick", 20000)
)
val detailedRDD = sc.parallelize(detailedData, 4)
val totalSalesRDD = detailedRDD.reduceByKey(_ + _).cache()
totalSalesRDD.collect().foreach(println)
reduceByKey是一个Transformation算子,但这里调用了collect()来触发执行,方便我们确认中间结果是否正确。输出如下:
text复制(Robot Vacuum,340000)
(Face Cream,260000)
(Data Science Guide,55000)
...
得到totalSalesRDD后,使用top(5)取出销售额最高的5个商品:
scala复制implicit val salesOrdering: Ordering[(String, Double)] = Ordering.by[(String, Double), Double](_._2)
val top5 = totalSalesRDD.top(5)
top5.foreach(println)
结果按销售额降序排列,应该是类似:
text复制(iPhone 15,1200000.0)
(Samsung S24,980000.0)
(Robot Vacuum,340000.0)
(Running Shoes,310000.0)
(Face Cream,260000.0)
这里要注意,我把销售额定义成了Double类型(实际开发中金额字段建议用BigDecimal或Long,避免浮点精度问题),所以隐式Ordering用的是Double字段。
4.3 用takeOrdered取末位指标与阈值分析
如果业务方需要关注尾部商品,比如"哪些商品销售额最低,可能需要清仓处理",可以用takeOrdered(3):
scala复制val bottom3 = totalSalesRDD.takeOrdered(3)
bottom3.foreach(println)
结果按销售额升序排列:
text复制(Novel Set,12000.0)
(Yoga Mat,18000.0)
(Lipstick,42000.0)
这时候注意一个细节:takeOrdered使用的是同一个隐式Ordering,也就是按销售额升序排序。所以返回的第一个元素是销售额最低的商品。
如果我们希望"取最低的3个商品,但打印时从高到低排",只需要在takeOrdered的结果上调用reverse:
scala复制val bottom3Reversed = totalSalesRDD.takeOrdered(3).reverse
这种写法在写报表展示时很常见:报表表头需要先展示排名靠前的,但尾部排行榜反而希望最后一名在底部,所以常常需要配合reverse调整展示顺序。
4.4 用saveAsTextFile把结果写入HDFS
最后把汇总结果保存到HDFS。如果我们直接把totalSalesRDD保存,得到的是(商品名,销售额)的原始元组字符串,格式未必满足下游需求。所以我通常先做一个格式化map,转成CSV或者竖线分隔的文本,再写出:
scala复制val formattedRDD = totalSalesRDD.map { case (product, amount) =>
s"$product|$amount"
}
formattedRDD.saveAsTextFile("hdfs:///data/sales_total")
如果你在本地模式跑,路径可以写成file:///tmp/sales_total,Spark会写入本地文件系统。
写出之后,我们可以通过HDFS命令验证:
bash复制hdfs dfs -ls /data/sales_total
hdfs dfs -cat /data/sales_total/part-00000
因为totalSalesRDD有4个分区,所以输出目录会有4个part文件。如果你希望最终只有1个文件,可以在写出前调用coalesce(1),但注意它会触发一次shuffle,数据量大时需要权衡。具体怎么取舍,下一节细讲。
5. 生产环境下的优化建议与踩坑记录
5.1 输出文件数量控制:coalesce vs repartition
回到saveAsTextFile的输出文件数问题。很多人不知道coalesce和repartition的区别,简单来说:
repartition(n):一定会发生shuffle,重新把数据均匀分布到n个分区,适合让数据分布更均衡的场景。coalesce(n, shuffle = false):尽量避免shuffle,只做分区合并。如果n小于当前分区数,它可以高效地把相邻分区合并,避免全量shuffle。
所以在输出文件数量控制时,优先用coalesce:
scala复制totalSalesRDD.coalesce(1, shuffle = false).saveAsTextFile("hdfs:///data/sales_total_onefile")
但这里有一个隐藏的性能问题:coalesce(1)会把所有分区的数据拉到一个Task中执行,如果数据量大,单Task的写入会成为瓶颈。所以我的建议是:
- 数据量小于几GB时,
coalesce(1)可以接受。 - 数据量几十GB以上,建议保留多个输出文件,不要强行合并成1个。如果下游确实需要单文件,可以借助
hdfs dfs -getmerge命令在HDFS层合并:
bash复制hdfs dfs -getmerge /data/sales_total /local/sales_total_merge.csv
hdfs dfs -put /local/sales_total_merge.csv /data/sales_total_merge.csv
这是生产环境中比较常见的"两阶段落地"方案,既不影响Spark作业的并行写入性能,又满足了下游单文件消费需求。
5.2 TopN算子的性能边界与数据倾斜处理
top和takeOrdered虽然做了局部裁剪,但当分区数特别多时,Driver端汇聚的局部TopN队列数量是num × 分区数。如果一个RDD有10000个分区,取全局Top10,那么Driver端最多会收到10000个大小为10的队列,合计约10万条记录参与最终排序,这个量级对Driver来说完全可以接受。
但如果num值特别大,比如一次性取Top100万,那有界优先队列的大小本身就很大,Driver端合并和排序的开销就会显著上升。这时候你需要反思:业务真的需要Top100万吗?通常这种需求可以拆解为多个批次的TopN再二次合并。
数据倾斜是另一个常见问题。如果某些分区内的元素量远大于其他分区,局部取TopN时这些分区内的优先队列会先装满,但整体计算时间会被倾斜分区拉长。对于TopN场景,我遇到过一个有意思的优化技巧:如果数据倾斜严重,可以先用Sample算子或countByKey估算各key的分布,然后决定是全局做TopN,还是先做两级TopN。比如先按某个维度分组局部取TopN,再把所有局部TopN合并做全局TopN,这种"二次TopN"思路在实际业务中十分常用。
另外,top和takeOrdered要求num必须是非负整数,如果你传入负数,源码中的优先队列会直接报错。虽然这是很基础的约束,但我在Code Review时真的见过有人把方法参数传反了,导致生产作业失败。
5.3 更多Action算子对比与选型建议
这里顺便把几个常用的Action算子放在一起做一个对比,方便你选型:
| 算子 | 返回值 | 适用场景 | 数据量风险 |
|---|---|---|---|
collect() |
Array[T] |
小数据量预览、结果回传Driver | 全量数据拉回,大数据量必OOM |
count() |
Long |
统计记录数 | 无风险,每个分区返回计数即可 |
take(n) |
Array[T] |
无排序地取任意n条数据 | 每个分区取n条,不排序 |
top(n) |
Array[T] |
取最大的n个,降序返回 | 局部裁剪,较安全 |
takeOrdered(n) |
Array[T] |
取最小的n个,升序返回 | 局部裁剪,较安全 |
reduce(func) |
单值 | 对全部分区数据做聚合 | 聚合结果必须可合并 |
foreach(func) |
Unit |
对每个元素做外部系统写入 | 无回传Driver,但要注意连接复用 |
saveAsTextFile(path) |
Unit |
结果落盘到文件系统 | 输出文件数 = 分区数 |
从这个表可以清楚看出,top和takeOrdered在处理TopN问题时,比collect().sortBy().take()要安全得多。这也是为什么我要反复强调:人肉实现"先collect再排序"的做法,在大数据量下基本是给自己埋雷。
5.4 我的实操体会:算子选型要结合下游消费方式
最后说一点我自己在实际项目中沉淀下来的体会。很多人在用saveAsTextFile的时候,只把它当成"把结果存下来"的简单方法,却忽略了它与下游消费方式的强耦合。如果你输出的是CSV文本,下游却期望读取Parquet,那你的产出物对下游就是一个灾难。所以在设计作业的输出环节时,要先问清楚:
- 下游是通过Hive/Spark SQL读取,还是通过传统接口读文件?
- 每条记录的字段分隔符是什么?有没有字段本身包含分隔符的情况?
- 是否要求
_SUCCESS文件?是否要求特定压缩格式? - 数据量和分区数是否匹配?会不会产生大量小文件?
这些问题的答案直接决定你要不要用saveAsTextFile,还是换成saveAsParquetFile、saveAsSequenceFile,或者是把数据写入Hive表。反过来说,saveAsTextFile最适合的场景就是:下游是Shell脚本、传统ETL工具,或者需要人工查看的轻量数据场景。
另一个实操体会是关于top和takeOrdered的测试。由于这两个算子在本地模式和集群模式下的行为是一致的(都会走mapPartitions + reduce),所以我强烈建议你在本地用一个小数据集做功能验证,然后把num参数做成配置项放到提交命令里,方便上线时根据数据量动态调整。这样既保证了代码的可测试性,也避免了硬编码参数带来的维护负担。
这三个Action算子本身都不难,难的是把它们放到真实的数据链路里做出合理选择。你只要理解了Action触发作业的本质,掌握了saveAsTextFile的输出分区机制,再弄明白top和takeOrdered的优先队列裁剪原理,后续遇到任何排序落盘的场景,基本都能举一反三。
