资讯详情

资讯详情

Spark Action算子深度解析:first、collect、collectAsMap用法与避坑指南

我平时写Spark作业尤其是调RDD的transformations时最常干的一件事就是跑到Driver端把数据拉回来亲眼看一看。你想想一整套数据处理逻辑跑通了光靠日志里的统计数字肯定不够最直接的办法就是调用Action算子把结果拿到Driver端看一眼。Spark里的Action算子不少但实际项目里用最多的其实就是first、collect、collectAsMap这三个。这篇是Spark系列的第七十七篇我就围绕这三个行动算子把原理、用法、典型坑一次讲透。适合刚开始学RDD算子的人也适合写过不少Spark、但没细究过Action语义的开发者。first等于只看一眼数据取第一条collect等于把整个RDD的数据都搬到Driver内存里collectAsMap则是把PairRDD变成Map。名字都很好记但真正在工程里用起来三者背后的执行链路、内存开销、顺序语义和失败模式完全不同。如果只记住“都能拿数据”就上手写很容易在数据量稍微大一点的时候就翻车。我见过不少同学在集群上跑一个几百万条的RDD完了直接collect到一个列表里最后Driver端直接OOM整个应用挂掉。也见过有人用first想拿“排序后的第一条”结果因为忘了做全局排序拿到的只是某个分区里第一个元素。这些都不是算子本身的问题而是对Action算子执行细节理解不到位。1. 一次Action触发的完整链路从Driver到Executor1.1 Transformation懒执行与Action触发的意义要理解first、collect、collectAsMap先得理解Spark里“懒执行”这件事。平时写的map、filter、flatMap这类转换算子并不会立刻干活它们只是在构建一张“计算蓝图”记录数据从哪来、要经过哪些步骤。真正让Spark开始跑作业的只有Action算子。first、collect、collectAsMap都是Action所以调用它们的那一瞬间Spark才会把Job提交给集群在Executor上真正执行之前规划好的所有转换逻辑。我用一个生活化的类比Transformation阶段好比你跟餐厅点菜把想吃什么、什么忌口都告诉服务员单子写好了但厨房还没动火。Action就是那句“下单”你拍板之后厨房才开始切菜、下锅、把菜端到你桌上。first是让厨房先上一道菜你尝尝味道collect是让厨房把所有菜全端上来摆满一桌collectAsMap则是把所有菜按菜单编号整理成一份清单再端上来。1.2 Driver与Executor之间实际发生了什么当你在代码里调用某个Action算子时Spark会做这样几件事Driver端将整条链路里的RDD依赖关系提交给调度器构建出Job。Job按照宽窄依赖切分成StageStage内部再拆成按分区运行的Task分发到Executor上并行执行。Executor跑完各自负责的数据分片之后把计算结果往Driver端回传。Driver端汇总所有Executor的结果组装成你调用的算子想要的数据结构返回给用户代码。这里最需要注意的是“结果回传”这一步的差异。三个算子都触发了完整的计算链路但回传给Driver的数据量天差地别。我整理了一张对比表能比较直观地看出区别算子返回给Driver的数据量是否等待所有分区计算完成主要开销来源first1条记录通常只需前几个分区启动Job的调度开销collect全量记录组装成Array/List是全部完成Driver内存 网络传输 序列化collectAsMap全量键值对组装成Map是全部完成Driver内存 Map结构额外开销这张表基本上说出了全部问题first几乎不耗Driver内存collect和collectAsMap都是“全量拉回”区别只是组装形式不同。1.3 为什么说“拿数据”这个动作也要分场景很多读者第一次接触这三个算子时会觉得它们只是“换着姿势拿数据”。但本质上凡是全量拉回的Action都意味着集群的计算结果最终要汇聚到一个单点上。这个单点就是Driver进程。Driver的内存是独立分配给驱动程序进程的一般不会太大如果你把几个GB的数据全部collect回来结果就是内存溢出。所以真正专业的做法是在调用这三个算子之前先想清楚两个问题——结果集到底有多大以及我真的需要全量数据吗带着这两个问题去选算子才能避免踩坑。2. first算子只取一条记录背后的分区细节2.1 first的语义等价与源码实现first从名字上很好理解就是返回RDD里的第一个元素。但“第一个”三个字在分布式系统里并不是天然存在的概念。RDD的数据分散在多个分区里分区之间本身没有顺序每个分区内部只有在读取数据源或经过某些转换时才会有顺序。Spark的first在源码上的实现其实非常简单本质上等价于take(1)之后取结果数组的第一个元素。正因为first等价于take(1)它只会启动一个Job并且在拿到第一个结果之后Spark就可能提前结束后续分区的计算所以它的开销通常非常小。这一点和collect有本质区别collect必须等所有分区都把数据交上来first则不需要。2.2 分区顺序与全局顺序一个非常容易踩的坑很多人以为RDD.first()返回的就是“整个数据集的第一条”这在无顺序要求的场景下没有大问题但一旦RDD经历了shuffle操作比如groupByKey、reduceByKey、join那么分区的顺序和数据在分区内的排序都没有严格保证first返回的只是“某个分区的第一条”不一定是全局逻辑上的第一条。如果要拿真正的“第一条”必须显式先排序。例如在Scala的RDD API里// 拿金额最大的那条记录 val maxRecord pairRDD.sortBy(_._2, ascending false).first() // 拿金额最小的那条记录 val minRecord pairRDD.sortBy(_._2, ascending true).first()这里sortBy会产生一个shuffle过程把数据按全局顺序重新分区然后再执行first。虽然多了shuffle的开销但只有这样才能保证语义正确。很多人踩过的坑就是“以为first是全局第一条结果拿到的数据不对”实际上不是算子算错了而是没理解RDD的顺序性。2.3 first的边界条件空RDD与COALESCE的影响first还有一个容易忽视的边界条件如果RDD是空的调用first会直接抛异常具体错误类型要看Spark版本通常是一个类似“first on empty collection”的异常。这种场景下用take(1)更安全因为take(1)在空RDD上返回的是空数组不会抛异常val sample rdd.take(1) if (sample.nonEmpty) { println(sample.head) }另外first到底扫描哪些分区是可以受到分区数影响的。默认情况下first会从第一个分区开始拿只要第一个分区有数据就直接返回。但如果你的数据分布很倾斜或者你在first之前做了一些转换导致前面的分区里没有数据那么它可能要往后多看几个分区。还有一种经典情况是RDD在调用first之前被coalesce过。coalesce会重新调整分区数如果coalesce(1)的操作没有产生shuffle那前面几个分区的数据合并顺序与原来大致保持一致但如果你想通过first拿一个全数据集里的最小值而不是碰巧第一个分区的值那还得配合排序。2.4 first在实战中怎么用最有价值从我自己的使用习惯来说first最大的价值不是“取数据”而是“验证链路”。一个复杂的ETL流程经过解析、清洗、过滤、聚合之后你很难确定每条记录的字段到底是什么样子。这时候先用first拿一条样例打出来能立刻发现字段错位、空值、格式不对等问题。比如val raw sc.textFile(hdfs://.../orders.csv) val firstLine raw.first() println(s样例数据: $firstLine)这条日志如果显示解析后的字段长度不对那就说明上游数据格式和你预期的不一致后续的map逻辑必然有问题早发现早止损。这个动作成本极低几乎不占用额外资源但能省掉大量排查时间。此外first也常用于和sortBy配合取极值以及用take(n)抽样查看一小批数据。总之first是一个“小口径”算子适合验证、抽样、快速探索而不是用于完整结果的展示。3. collect算子把全量数据拉回Driver的收益与代价3.1 collect的执行过程从分区到Driver端的组装collect是把整个RDD的所有元素都拉到Driver端以数组或列表的形式返回给用户。它的执行过程可以理解为先让所有分区把计算结果准备好然后通过网络传输到Driver端Driver把所有分区的数据拼成一个完整的数组最后返回。在Scala的RDD API中collect返回的是Array[T]在Java的RDD API中collect返回的是java.util.List[T]在PySpark中返回的是Python列表。无论哪种形式本质都一样全量数据按分区合并组装到Driver进程的内存里。3.2 数据量估算怎样判断该不该collect很多OOM事故的根本原因是盲目collect不知道结果集到底有多大。其实在写代码前你可以做一个非常粗略但实用的估算。先在集群上抽样用take(100)拿一小批数据算出每条记录的平均大小再乘以总记录数就能估算出最终结果集的内存占用。举一个具体例子假设你有一个RDD[Row]每条Row平均有10个字段大部分是字符串和数字通过序列化后平均约200字节。如果总记录数是100万条那么200字节乘以100万大约是200MB。再加上Spark结果传输、组装过程中可能存在的内存副本如果Driver端内存是1GB虽然看起来还够但已经有一定风险了因为Driver端还承担着调度、广播变量、累加器等额外内存开销。更稳妥的估算方法是在collect之前先用一个Action算子把大小算出来val totalSizeMB rdd.map { record // 这里的SizeEstimator是Spark提供的公共API可以粗略估算对象大小 org.apache.spark.util.SizeEstimator.estimate(record) }.sum() / (1024 * 1024)当然这一行本身也是一次Action会重新触发一次计算所以更适合放在调试阶段临时使用。生产环境里我通常用另一种方式观察Spark UI里各Task的Shuffle Read/Write大小或者直接在collect前加一个take抽样打印再凭经验判断。3.3 collect的三大风险点第一是Driver OOM这是最直接的风险。Executors可能每个都有几个GB内存但Driver默认配置通常小得多一旦collect回来的数据超过Driver可用内存就会直接报OutOfMemoryError。更头疼的是OOM有时候不会马上让应用失败而是进入漫长的Full GC状态表现为作业卡死、心跳超时这种问题定位起来非常痛苦。第二是网络传输压力。collect不是魔术所有数据都要从各Executor通过网络回传到Driver。数据量大时不光Driver扛不住还会占用集群内部大量带宽影响同集群其他作业。我在调度资源时经常遇到一个作业collect了几百MB数据把网络打满导致其他Spark流任务延迟飙升的情况。第三是序列化与反序列化的开销。Executor端要把结果序列化后通过网络传输Driver端接收后还要反序列化并组装成你想要的Java/Scala/Python对象。这个过程中会创建大量临时对象进一步加剧Driver端的GC压力。3.4 什么场景适合collect什么场景绝对不该collect适合collect的场景有几个共同特征结果集小、需要完整在Driver端做后续处理、或者结果本身是最终展示给用户看的。典型的例子包括对去重后的枚举值进行统计展示比如所有商品类目列表。聚合计算后的最终指标比如每个分区的计数汇总。调试阶段需要把完整结果打出来核对。绝对不适合collect的场景也有很明显的特征数据量大、数据明细化、或者collect被放在高频循环里。举个反模式例子// 反模式示例在循环里反复collect val filteredRDD initialRDD.filter(...) for (i - 0 until 100) { val result filteredRDD.collect() // 每次collect都触发完整Job且全量传输 // 对result做各种判断 }这段代码极其危险每一次循环都产生一次全量计算结果回传100次循环就是100次全量传输Driver内存和网络都会被瞬间打满。正确做法应该是先把计算好的RDD缓存住cache只collect一次然后在Driver端对数组做循环逻辑或者改用一个更合理的分布式聚合方式。如果结果集很大又想把它保存下来正确选择是直接用saveAsTextFile、写入HDFS、写入外部数据库或数据仓库而不是collect后自己写文件。能分布式落地存储的就别拉到Driver端做单点处理。4. collectAsMapPairRDD转Map的便捷与隐藏约束4.1 collectAsMap本质上是collect和toMap的复合操作collectAsMap只看名字也能猜出个大概它是专门为PairRDD设计的返回一个Map结构。在Scala的Spark API中collectAsMap的返回值是scala.collection.Map[K, V]。从实现逻辑上看它基本等价于先collect成一个数组然后调用toMap方法转成Map。和collect一样collectAsMap也是全量拉回所以所有collect会遇到的内存问题、网络问题、序列化问题它一个都不少。但除此之外它还有两个自己的独特陷阱。4.2 重复Key覆盖问题一个随时可能引爆的陷阱第一个陷阱是重复键的覆盖。RDD本质上是分布式的数据集分区之间没有严格的顺序Executor把各自的结果回传给Driver时Driver端会把这些结果组装成数组然后依次放入Map。如果同一个Key在多个分区里都存在那么后放入Map的值会覆盖先放入的。但因为分区回传顺序在分布式环境下是不可控的你根本不知道最终保留的是哪一个值。这里用一个简单例子来说明假设RDD里有两条记录分别是(cityA, 100)和(cityA, 200)它们分布在两个不同的分区里。调用collectAsMap之后Map里最终是100还是200完全取决于两个分区谁的值先到达Driver端以及组装时谁先被处理。这个结果不确定所以千万不要用collectAsMap去处理存在重复Key的原始数据。正确姿势是先把Key聚合去重再collectAsMap。最常用的手段是reduceByKey或groupByKeyval cityAmount pairRDD.reduceByKey(_ _) val amountMap cityAmount.collectAsMap()这样每个Key就只会保留聚合后的唯一值再转成Map就安全了。在很多真实业务里PairRDD的Key都是唯一的比如城市编码、商品ID、用户ID这时候直接collectAsMap一点问题没有但只要Key有重复可能就必须在前面加聚合步骤。4.3 内存再翻倍Map结构比数组更“吃”内存第二个陷阱是内存开销比collect还要大。collect返回的是一个数组数据在内存里是紧凑排列的collectAsMap返回的是一个Map底层通常是Hash表结构。Hash表为了减少冲突会预留不少空桶再加上每个条目还要保存Key和Value两个引用以及对象头的开销整体内存占用往往比同样条数的数组高出0.5到1倍。如果你考虑极限情况100MB紧凑数组的数据转成Map之后可能要占用150MB到200MB以上。所以在做collectAsMap之前一定把Driver端内存余量留得比collect更宽裕。另外还有一个容易被忽略的问题PairRDD的Key和Value类型。如果Key是自定义对象且没有正确实现hashCode和equals那么Map的查找效率会大幅下降还可能踩到一致性坑。工程上建议Key尽量使用原生类型字符串、整数、Long这样既快速又安全。4.4 collectAsMap真正有价值的应用场景虽然collectAsMap有种种约束但在合适的场景下它非常顺手。我见过最有代表性的用法有两个。第一个是“小表广播”。当一个大表和一个小表做join时直接join会触发shuffle成本很高。更聪明的做法是把小表的PairRDD用collectAsMap拉回Driver生成一个本地Map然后用广播变量广播到每个Executor在map阶段直接查Map完成关联。这个场景下小表数据量通常只有几万到几十万条完全在Driver内存可接受范围内collectAsMap的性能收益非常明显。第二个是“结果集查询”。统计任务算完每个城市、每个类目的指标之后把结果转成MapDriver端就可以按Key快速获取某个具体值而不需要一个数组反复查找val amountMap cityAmount.collectAsMap() val salesOfCity amountMap.getOrElse(beijing, 0.0)这种“先分布式聚合再拉取查询”的模式在数据量可控、查询需求明确的场景下非常舒服。4.5 与reduceByKey组合压数据量的技巧如果PairRDD里有大量重复Key想用collectAsMap但担心数据量太大可以通过reduceByKey预先合并让每个Key在网络上传输的次数降到最低。比如一个日志型PairRDD每个用户ID出现几十次事件想拿到用户最后状态可以这样val latestStatus userEvents .reduceByKey((a, b) if (a.timestamp b.timestamp) a else b) .collectAsMap()这样传输到Driver端的数据量就等于用户数而不是事件总数内存压力会小一个数量级。这个技巧在处理“按维度去重取快照”类需求时非常实用。5. 三个算子怎么选一个可落地的判断流程5.1 选择算子前的两个先决问题不是拿到数据需求就立刻选算子。我在实际编码之前一定会先问自己两个问题这个结果集大概有多大我真的需要在Driver端拿到完整数据吗很多人觉得这两个问题不好回答其实有一些很直接的估算方法。如果是中间结果可以用take抽样估算单条大小如果是聚合后结果通常可以按Key数量来估算如果是从数据源读取裸文件可以先看文件大小。估算出来之后心里就有一条很清晰的线了。为方便理解和记忆我把常见场景、推荐算子和原因整理成了一张表场景推荐Action原因查看一条样例验证管道first开销最小只跑少量分区抽样看一小批数据take(10) 或 takeSample数据量小且可控制结果集小需要在Driver端做后续遍历collect数组紧凑遍历方便结果集是PairRDD且Key唯一需按Key查值collectAsMap便于快速查找和广播结果集是PairRDD但Key有重复reduceByKey collectAsMap先聚合去重避免覆盖问题结果集大需要保留数据saveAsTextFile/写入存储避免Driver单点瓶颈需要全局第一条或极值sortBy first显式排序保证语义5.2 错误用法和反面教材判断流程之外更值得记住的是那些典型错误。第一盲目全量collect。只要数据量超过Driver内存这就不是“可能出问题”而是“必然出问题”只是时间早晚。我遇到过好几起线上事故都是同事为了赶工临时找了一个结果集直接collect结果Driver被打爆整个作业失败。事后复盘时只需要在collect前加一行take估算就能完全避免。第二应该用reduceByKey在先却直接用collectAsMap。这会导致两个问题重复Key覆盖不确定以及传输数据量大。其实reduceByKey本身是在Executor端先做局部聚合是一个“分布式先行压缩”的好机会白白浪费掉太可惜。第三在Driver端对collect回来的大数据量做复杂循环处理。collect已经把分布式计算退化成单机数组了如果你再在这个数组上跑一个多重循环计算性能会急剧下降。正确的思路是能用分布式算子解决的在集群里解决collect只承担最终展示或小规模后处理的责任。5.3 我的个人决策习惯我自己的习惯是在做任何“看数据”类操作之前先用first确认字段对不对再用take(10)看一小批数据有没有明显异常等结果集经过充分转换和聚合之后如果结果集小且确实需要完整展示才考虑collect如果结果集是PairRDD优先把聚合逻辑做完再考虑collectAsMap。最后还有一个兜底动作在collect或collectAsMap之前临时打印一下预估数据条数。如果条数动辄百万以上我基本不会再去挑战Driver内存而是改写存储逻辑。这套流程看起来多花了几步实际上能把很多潜在事故提前拦截掉。6. 综合实战一次订单数据统计中的三个算子配合6.1 场景设计为了把前面讲的内容串起来我设计一个非常典型的业务场景。假设有一份订单数据文件每一行是两个字段城市名和订单金额格式类似“beijing,258.50”。现在需要统计每个城市的订单总金额同时在Driver端查看第一条原始数据长什么样用来确认数据格式每个城市的汇总结果方便核对后续按城市快速查询某个城市的销售额。这个需求很常见三个目标分别对应first、collect/collectAsMap正好能把三个算子都串起来。6.2 完整代码示例与逐步解释我以Scala的RDD API为例写一个完整可跑的骨架import org.apache.spark.{SparkConf, SparkContext} val conf new SparkConf().setAppName(AmountByCity) val sc new SparkContext(conf) // 1. 读取数据first验证原始格式 val lines sc.textFile(hdfs://.../orders.csv) val firstLine lines.first() println(s样例原始数据: $firstLine) // 2. 解析成PairRDD[(city, amount)] val pairRDD lines.map { line val arr line.split(,) // 这里假设文件没有表头有表头的话注意跳过 (arr(0).trim, arr(1).trim.toDouble) } // 3. reduceByKey分布式聚合压缩数据量 val cityAmount pairRDD.reduceByKey(_ _) // 4. 先抽样看一下聚合后的记录 val sampleAgg cityAmount.take(5) sampleAgg.foreach(println) // 5. 结果集较小collectAsMap生成Map方便按城市查值 val amountMap cityAmount.collectAsMap() println(s北京市销售额: ${amountMap.getOrElse(beijing, 0.0)}) // 6. 如果需要按金额排个序看看Top城市 val top5 cityAmount.sortBy(_._2, ascending false).take(5) top5.foreach(println) sc.stop()6.3 每个算子在作业中承担的角色这里的first承担的是“数据格式验证”角色。如果原始文件里混入了一些脏数据比如字段数量不对解析行在first阶段就能发现异常即使解析行没触发异常打印出来的样例也能让人直观判断后续split、toDouble的逻辑是否合理。取样阶段我用take(5)而不是collect是为了在聚合结果上“看一眼”而不承担全量拉回的开销。聚合之后的cityAmount记录条数等于城市数量正常情况下一个国家的城市也就几百个这时候调用collectAsMap就非常安全了。这里用collectAsMap而不是collect是因为后续我明确有“按城市查销售额”的需求Map结构天然支持这种查询。假如后续没有查询需求只是想把所有城市和销售额打印出来那用collect也是可以的因为城市数量很小。这两个算子在结果集很小的时候都能用区别只取决于你想用数组遍历还是Map查询。末尾的sortBy take(5)是另一种常见组合先全局排序再拿前5条做TopN展示。注意sortBy会产生shuffle但这里只拿5条shuffle后的数据量只是中间排序的成本最终回传Driver的数据量很小所以开销完全可以接受。6.4 运行注意事项这个作业如果用spark-submit提交到集群有一点必须提醒println输出的内容在Driver端日志里而不是在Executor日志里。所以如果想看collectAsMap的结果要去Driver日志里找别在Executor日志里翻半天。另外如果城市数量特别多比如有几百万个Key那么collectAsMap仍然可能撑爆Driver内存这种场景建议把结果写入文件或数据库而不是拉回Driver。还有一个小细节当在本地IDE里跑这个例子时collectAsMap的结果可以直接打印非常方便调试但一旦换成真实集群输出量就要克制可以用take(10)代替完整打印。我自己做这类统计时通常会先打印条数、总金额再抽样几条明细而不是把所有结果都刷在日志里。写在最后的一点经验我刚开始用Spark时也习惯到处collect觉得这样最方便。后来在一次数据量猛增的作业里Driver端直接OutOfMemory整个任务挂了快两个小时才恢复过来从那以后我才真正意识到Action算子不是“随便拿数据”的工具而是涉及单点内存和网络传输的资源操作。现在我写代码的习惯是先first验证再take抽样最后才根据结果集大小决定是collect还是collectAsMap而且collect之前一定会做一次“预估数据量”的确认。最后再分享一个小技巧如果拿不准一个PairRDD的Key是否唯一就先执行reduceByKey再去collectAsMap这个动作既能规避覆盖问题又能压缩传输量性价比极高。希望这篇Spark行动算子解析能让你少踩一些坑。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →