☰
Spark RDD持久化实战:从StorageLevel选型到作业调优
2026/10/18 15:06:46 网站建设 项目流程

做Spark作业调优这几年,我见过太多类似场面:一个数据量不算大的统计任务,日志清洗、词频统计、TopN排序,逻辑清清楚楚,跑一次却要二十多分钟,加资源也压不下去。后来翻代码一看,同一个RDD被调用了三四次,每次Action都重新读源数据、重新做shuffle,等于把同一条生产线反复开了好几遍。问题不在资源,而在于没人告诉Spark“这个结果先留着别扔”——这正是RDD持久化要解决的事。

RDD持久化(persist/cache)在Spark里是最基础也最容易用错的调优手段之一。它的本质很简单:把某个RDD的计算结果按你指定的存储级别保存在Executor的内存或磁盘上,让后续多个Action复用同一份数据,而不是每次依赖RDD的血统(lineage)从头算起。适合的人群也很明确:凡是写过Spark作业、遇到过“作业越跑越慢”“同一份数据算了好几遍”“迭代任务内存吃紧”的开发者,都能从这篇文章里找到解法。下面我会从“为什么要持久化”讲起,把StorageLevel的每个参数拆开说清楚,再结合真实场景给出选型思路和完整实操。

1. 为什么需要持久化:先看懂Spark的“惰性”和“血统”

1.1 同一个RDD被反复使用,浪费了多少计算

先看一个很常见的例子。假设有一份点击日志,你要统计词频、找TopN、再筛出长词数量,很自然的写法是这样:

val words = sc.textFile("hdfs://.../click_log") .flatMap(_.split(" ")) .map((_, 1)) .reduceByKey(_ + _) val top10 = words.sortBy(_._2, ascending = false).take(10) // 第一次Action val total = words.count() // 第二次Action val longWords = words.filter(_._1.length > 10).count() // 第三次Action

这段代码逻辑没错,但性能很有问题。words这个RDD被用到了三次,而Spark的Action每次都会从源头开始完整计算一遍:重新读HDFS文件、重新split、重新map、重新做reduceByKey的shuffle。文件大一点,比如几个GB的日志,这三次重复计算直接让作业时间翻三倍。我在实际项目里见过最夸张的例子,一个模拟项目X的日志分析任务,开发者在代码里写了七八个Action,每个Action都从原始文件算起,作业跑了两个多小时,去掉重复计算之后不到二十分钟就结束了。

这里要说明的是,重复计算的代价不只是CPU。读文件要花IO,shuffle要落盘、要网络传输,中间Stage的调度和序列化也都有开销。更重要的是,这些开销是成倍叠加的,数据量越大、血统越长,浪费越明显。所以判断要不要持久化,第一个信号就是:这个RDD是不是被多个Action复用了。

1.2 惰性求值与血统机制:容错设计的一体两面

要理解持久化为什么必要,得先理解Spark两个核心机制:惰性求值和血统。

Spark的transformation操作(map、flatMap、filter、reduceByKey这些)都是惰性的,调用它们只是记录下“我要做什么”,并不会真的去算。只有遇到Action(count、collect、take、saveAsTextFile这些)时,Spark才会把前面的transformation串成一个DAG,真正开始执行。这就解释了为什么上面那段代码里三个Action会引发三次完整计算——每个Action都会独立地从头触发一次DAG执行。

另外,每个RDD都保存着血统,也就是它是从哪些父RDD、经过哪些操作得来的“说明书”。一旦某个分区的数据在运行中丢失,Spark会沿着这条血统把丢失的分区重算出来。这设计很聪明,让Spark不需要对每一份中间结果做备份也能实现容错。但代价也很直接:血统越长,重算链条越深,一个分区的失败可能牵出一长串重新计算。尤其是涉及宽依赖(shuffle)的血统,重算一次几乎等于把之前的Stage全部重跑。

我把血统理解成Spark的“后悔药”,它不是“保险柜”。后悔药能让你在数据丢了之后重新拿回来,但每一次重新拿回来都要付出实打实的计算代价。如果一个RDD要被反复使用,每次都靠血统重算,那这份“后悔药”的代价就太高了。

1.3 持久化的本质:用存储空间换计算时间

持久化做的事情其实非常朴素:把一个RDD的计算结果按指定的存储级别存下来,放在Executor的内存或磁盘里。之后再有Action需要这个RDD,Spark直接读取缓存好的数据块(block),而不是从头执行一遍血统。

这里有个容易混淆的点:调用persist()本身是惰性的,它只是给RDD打上一个“需要存储”的标记,真正把数据写进内存或磁盘,要等到第一个Action触发计算完成之后。比如前面那段代码,如果你在words上调用persist(),三个Action中的第一个top10会触发生成并存储数据,后面的total和longWords就直接用缓存了。

持久化的核心权衡,是在“存储空间”和“计算时间”之间做取舍。内存空间有限,磁盘空间便宜但读取慢,序列化能省空间但要花CPU——这些矛盾组合出了Spark提供的各种存储级别。理解每一种级别背后的参数,才能选对策略。下一节我把StorageLevel从头到尾拆一遍。

2. StorageLevel全拆解:一张表看懂所有持久化策略

2.1 12种存储级别对比:内存、磁盘、序列化、副本

Spark内置的存储级别全都封装在org.apache.spark.storage.StorageLevel里,我把常用的整理成一张表:

存储级别使用内存使用磁盘序列化副本数一句话说明
NONE否否-1不持久化,等于没调用persist
DISK_ONLY否是是1只写磁盘,内存零占用
DISK_ONLY_2否是是2磁盘存两份,抗Executor故障
MEMORY_ONLY是否否1RDD默认cache()级别,内存放得下首选
MEMORY_ONLY_2是否否2内存双副本,可靠性优先
MEMORY_ONLY_SER是否是1序列化进内存,省空间费CPU
MEMORY_ONLY_SER_2是否是2序列化内存双副本
MEMORY_AND_DISK是溢出时否1先内存,放不下溢写磁盘
MEMORY_AND_DISK_2是溢出时否2内存+磁盘+双副本
MEMORY_AND_DISK_SER是溢出时是1序列化进内存,溢出写盘,最省心
MEMORY_AND_DISK_SER_2是溢出时是2上面配置再加双副本
OFF_HEAP堆外否是1存到JVM堆外内存,需单独配置

我实际使用中的频率排序大概是:MEMORY_ONLY > MEMORY_AND_DISK_SER > MEMORY_ONLY_SER > MEMORY_AND_DISK > DISK_ONLY。剩下的带_2和OFF_HEAP级别属于特定场景才会碰。别被这么多选项吓到,它们的差异说到底就是五件事:用不用内存、用不用磁盘、序列化不序列化、副本几个、用不用堆外。把这五个参数搞清楚,你甚至可以自定义一个存储级别。

2.2 StorageLevel的五个构造参数,逐个说明白

StorageLevel的完整构造函数是StorageLevel(useDisk: Boolean, useMemory: Boolean, useOffHeap: Boolean, deserialized: Boolean, replication: Int),我逐个解释:

  • useDisk:是否允许把数据写到Executor本地磁盘。注意是本地磁盘,不是HDFS。磁盘便宜但读取慢,通常作为内存放不下时的兜底。
  • useMemory:是否把数据放到Executor内存里。内存快但容量有限,而且受统一内存管理机制约束,后面第4.4节会细说。
  • useOffHeap:是否使用JVM堆外内存。需要先在配置里开启spark.memory.offHeap.enabled并设置大小,否则设了也没效果。
  • deserialized:是否以反序列化(Java对象)形式存储。false表示存序列化后的字节数组,true表示直接放对象。
  • replication:保存几份副本。默认1,设成2就是把同样的数据块复制到两个不同Executor上,丢失一个还能用另一个。

理解了这五个参数,内置级别的构成就一目了然了。比如MEMORY_AND_DISK_SER,实际上就是useDisk=true, useMemory=true, useOffHeap=false, deserialized=false, replication=1。你也可以自己定义:

import org.apache.spark.storage.StorageLevel val customLevel = new StorageLevel( useDisk = true, useMemory = true, useOffHeap = false, deserialized = false, replication = 1 ) rdd.persist(customLevel)

自定义级别在99%的情况下用不上,内置12种已经覆盖了主流场景。但知道参数含义之后,你在看Spark UI的Storage标签页时,就能直接读懂某个RDD当前到底是怎么存的,排查问题会快很多。

2.3 序列化与反序列化:省内存背后的CPU代价

MEMORY_ONLY和MEMORY_ONLY_SER的差别只在序列化这一项上,但实际影响非常大。

不序列化(反序列化存储)时,RDD的数据以Java对象的形式放在内存里。好处是后续Action读取时直接可用,几乎不需要额外处理;坏处是Java对象的内存开销很高。一个Integer对象在JVM里除了4字节的数据,还要带上对象头、引用等信息,算下来可能占到16字节甚至更多。对象之间还有引用关系,GC扫描时要遍历整张对象图,内存越大GC压力也越大。我自己测过一个模拟项目,一份约2GB的原始数据转成对象存进MEMORY_ONLY,实际占用了近5GB堆。

序列化存储则把整块数据变成紧凑的字节数组。同一份数据,用Java原生序列化通常能压到原来的三分之一,换成Kryo还能再小不少。代价是每次读这块数据都要反序列化,CPU开销上来了。所以选MEMORY_ONLY_SER还是MEMORY_ONLY,本质是CPU换内存。数据量不大、内存充足,直接用MEMORY_ONLY最省事;数据量大、内存紧张,或者要缓存的对象是个大集合,MEMORY_ONLY_SER通常更划算。

用Kryo序列化需要做两件事:在spark-defaults.conf里设置spark.serializer=org.apache.spark.serializer.KryoSerializer,然后把业务类注册进去,推荐用spark.kryo.registrator指向一个注册类,或者用spark.kryo.classesToRegister直接列类名。注册之后Kryo不需要为每个类生成映射信息,序列化结果会更小更快。这里有个容易被忽略的点:如果启用了Kryo但不注册类,虽然也能工作,但每条记录都会带一份类名信息,压缩效果大打折扣。

补充一个PySpark特有的细节:PySpark里的RDD数据本质上都是Python对象,存储前必须经过pickle序列化,所以在Python侧你选MEMORY_ONLY,实际落到JVM里的依然是序列化字节。PySpark的StorageLevel里_SER变体和普通变体的区别,在RDD场景下没有Scala那么明显。这个坑很多人不知道,容易在跨语言调优时做错误判断。

2.4 副本数怎么定:多存一份还是多算一次

存储级别的最后一个参数是副本数。默认1,就是把每个数据块存一份;设成2,则同一份数据块会在两个不同Executor上各存一份。

副本的作用是抗故障。Executor是可能挂的,进程OOM、节点宕机、网络抖动,都可能导致某个Executor上缓存的RDD数据块全部丢失。如果没有副本,这些数据块丢了就得按血统重算;如果有副本,另一个Executor上还有备份,直接读就好了。

那是不是副本越多越好?肯定不是。副本翻倍意味着存储空间翻倍、写入时的网络传输和序列化开销也翻倍。对一个本身就是内存瓶颈的作业来说,副本2可能直接把缓存容量砍一半。我的经验是,默认不要开副本,除非满足以下条件之一:集群节点稳定性差、Executor频繁被杀;RDD血统非常长、重算代价极高;或者这个RDD是整个作业的核心资产,丢了会导致作业整体雪崩。另一种更稳妥的替代方案是Checkpoint,它把数据写到HDFS这类可靠存储上,容错能力更强,第5.4节会对比。

3. 不同场景下的持久化选型思路

3.1 五分钟搞定选型:一个判断流程

面对一堆存储级别,很多人会纠结。我总结了套判断流程,按顺序走一遍基本不会错:

  1. 判断是否真的需要持久化。这个RDD只被一个Action消费?那别持久化,纯属浪费内存。被两个以上Action消费,或者要在多个Job之间复用,才继续往下走。
  2. 估算内存能不能放得下。先跑一遍看Spark UI的Storage页,或者用SizeEstimator粗略估一下,后面第4.3节会讲具体方法。能放下就直接用MEMORY_ONLY,访问速度最快。
  3. 内存放不下或不确定。用MEMORY_AND_DISK_SER兜底,放得下就放内存,放不下溢写磁盘,至少不会丢掉数据。
  4. 重算很便宜但数据很大。比如一个刚从HDFS读进来、还没经过复杂变换的原始数据,血统短、重算就是再读一次文件,这种情况直接用DISK_ONLY,把内存让给真正需要的地方。
  5. 可靠性要求高。加上_2副本,或者干脆用Checkpoint。

这套流程的核心思路就一句话:内存够用就图快,内存不够就图稳,重算便宜就图省。没有绝对最优的级别,只有和场景最匹配的方案。

3.2 迭代计算:机器学习与图算法怎么持久化

迭代计算是持久化的经典适用场景。K-Means、PageRank、ALS这类算法都有共同模式:一份基础数据在每一轮迭代里都要被反复读取,同时每轮迭代又会产出新的中间结果。

以K-Means为例,样本数据集会被多个迭代轮次反复使用,这个RDD一定要持久化。我会在加载并清洗完数据后立刻persist(StorageLevel.MEMORY_ONLY),如果样本数据特别大才降到MEMORY_ONLY_SER。至于每轮迭代的中间结果,如果下一轮还要用,也应该持久化,但务必注意释放上一轮的缓存。

var iterRdd = initialData.persist(StorageLevel.MEMORY_ONLY) for (i <- 0 until maxIterations) { iterRdd.count() // 触发持久化 val nextRdd = iterRdd.map(updateCluster).persist(StorageLevel.MEMORY_ONLY) nextRdd.count() // 关键:释放上一轮的缓存,避免内存被多轮中间结果吃光 iterRdd.unpersist() iterRdd = nextRdd }

这个循环里如果忘记unpersist,每轮迭代都会往内存里多存一份RDD,二十轮迭代下来,内存再大也会被撑爆。这是我见过迭代作业最常见的崩溃原因之一,后面第5.5节还会专门讲。

3.3 多Action复用:交互式查询与报表场景

回到开头那个日志分析的例子。词频、TopN、长词统计这三个Action共享同一个wordsRDD,这种“一次计算、多次消费”的模式在报表和交互式查询场景里太常见了。

对这种场景,我的默认选择是MEMORY_AND_DISK_SER。原因有三个:第一,词频统计的结果是(word, count)的键值对,结构简单,序列化代价小;第二,报表场景的并发任务多,统一内存池里执行内存和存储内存互相挤占,万一内存放不下还能溢写磁盘,不丢数据;第三,MEMORY_AND_DISK_SER不挑数据形状,不管后面接多少个Action都能稳定兜住。

如果你用的是DataFrame或Dataset而不是RDD,有个细节值得注意:Dataset.cache()的默认存储级别和RDD的cache()不一样,它默认是MEMORY_AND_DISK,配合Spark的列式存储格式,通常表现不错。所以Spark SQL开发者的直觉“cache一下就行”是能work的,但RDD开发者如果也这么想,默认只cache到内存、放不下就丢块重算,风险就大了。

3.4 什么时候可以大方地用DISK_ONLY

很多人一看到“磁盘”就嫌弃,觉得慢。但DISK_ONLY有它不可替代的位置。

一种是数据量极大、内存根本不可能放下全部缓存的情况。比如几十GB的清洗结果,硬塞进MEMORY_AND_DISK,大部分块还是会溢写到磁盘,序列化和内存管理反而成了负担。直接DISK_ONLY,让内存去做更重要的事,磁盘顺序读的速度也没你想象的那么惨。

另一种是血统很短、重算代价很低的情况。比如一个RDD就是从HDFS读文件做了个简单filter,它就算丢了,重算也就是再读一遍文件。与其占着宝贵的内存,不如放磁盘,或者干脆不持久化。

我踩过的坑是这样的:有一次处理一个很大的中间RDD,为了“稳妥”选了MEMORY_AND_DISK,结果大量数据溢写到磁盘,每次复用都要走磁盘IO,而且因为内存里也有一部分,还要多维护一份序列化状态,整体反而比直接DISK_ONLY慢。后来想明白了,这个RDD的生成逻辑特别简单,重算成本低于溢写成本,直接用DISK_ONLY,内存腾出来了,总作业时间还缩短了。记住一个原则:持久化不总是越快越好,选择什么级别,要看“重算代价”和“存储代价”谁更大。

3.5 可靠性优先:副本、Checkpoint怎么搭

当单个Executor不稳定,或者数据重算代价极高时,就需要可靠性更强的方案。这里的选项有两类:带_2副本的持久化,以及Checkpoint。

MEMORY_ONLY_2或MEMORY_AND_DISK_2适合数据量不大、集群节点偶尔出问题的场景。双副本的存在让单个Executor挂掉时,数据依然可用。缺点前面说过,存储成本翻倍。

Checkpoint则是把数据写到HDFS这样的可靠存储上,并且会截断RDD的血统。截断血统这个特性在很多场景比数据本身更值钱。想象一条长达几十个Stage的血统链,中间任何一个节点失败,Spark都要沿着血统从头重算,这种“雪崩式重算”有时比直接丢数据还可怕。对这种超长血统,用Checkpoint把中间结果固化到HDFS,血统被切断,后续重算的起点大幅缩短。代价是写HDFS的IO开销比较大,运行时会比普通持久化慢。

我的建议是,绝大多数的可靠性问题,优先用_2副本解决;只有血统过长、或者数据跨作业长期复用(比如从A任务产出给B任务用)时才上Checkpoint。两者的关系不是互斥的,某些核心中间数据可以“先persist加速,再Checkpoint兜底”,典型做法是先persist跑几轮,确认后再做Checkpoint并unpersist。

4. 实操落地:从代码到Spark UI的完整过程

4.1 persist、cache、unpersist:用法与坑

先明确三个方法的区别。cache()是persist(StorageLevel.MEMORY_ONLY)的简写,只适用于RDD,而且Lock死了存储级别。persist(StorageLevel.XXX)可以指定任意一个级别,也可以用自定义级别。unpersist()是手动释放缓存,它和persist不一样,调用后会立即生效,不需要等待下一个Action。

RDD的unpersist()默认是阻塞式的,会等数据块真正释放完才返回。如果你的作业对延迟敏感,可以用unpersist(false)做异步释放。

import org.apache.spark.storage.StorageLevel val baseRdd = sc.textFile("hdfs://.../click_log") .flatMap(_.split(" ")) .map((_, 1)) .reduceByKey(_ + _) .persist(StorageLevel.MEMORY_AND_DISK_SER) val top10 = baseRdd.sortBy(_._2, ascending = false).take(10) val total = baseRdd.count() val longWords = baseRdd.filter(_._1.length > 10).count() baseRdd.unpersist()

Python版本也一并给出:

from pyspark import StorageLevel base_rdd = (sc.textFile("hdfs://.../click_log") .flatMap(lambda line: line.split(" ")) .map(lambda word: (word, 1)) .reduceByKey(lambda a, b: a + b) .persist(StorageLevel.MEMORY_AND_DISK)) top10 = base_rdd.sortBy(lambda x: x[1], ascending=False).take(10) total = base_rdd.count() long_words = base_rdd.filter(lambda x: len(x[0]) > 10).count() base_rdd.unpersist()

有几个细节提醒一下。第一,persist()不会触发计算,上面代码里第一个take(10)才真正把数据算出来并缓存,所以持久化的效果是有延迟的。第二,cache()之后的级别是固定的,后续想改成MEMORY_AND_DISK,必须先unpersist()再重新persist(),直接重复调用一次persist()实际上不会改变已缓存RDD的存储级别。第三,判断一个RDD当前是什么级别,可以用rdd.getStorageLevel查看。

4.2 persist调用位置有讲究:放在宽依赖之后

很多初学者在RDD创建后立刻persist,这本身没错,但高手的习惯是在关键位置持久化,尤其是宽依赖之后。

宽依赖意味着shuffle,shuffle过程要把数据分区、落盘、网络传输,是整个作业里最贵的操作。如果把persist放在reduceByKey、groupBy、join这一类宽依赖之后,持久化的是already洗好的数据,后续复用完全绕开了shuffle。相反,如果放在宽依赖之前,虽然也缓存了中间结果,但每次复用还是得再跑一遍shuffle,省不了大头。

我比较推荐的做法:找出血统里最贵且被复用的那个点。比如日志清洗后的词频统计,reduceByKey之后的words就是理想的持久化点。如果这个RDD不仅被后续的Action复用,还被map到其他分支,那就更应该在它上面时候持久化,等于一刀切断了所有下游对上游重算的依赖。

另外要提醒,不要在map、filter这类算子内部调用persist。有些代码写成了rdd.map(x => { ...; someRdd.persist(); x }),这是没用的——Worker端的算子函数在Executor上执行,不可能去控制Driver端的缓存调度。持久化调用必须出现在Driver端的RDD转换链上。

4.3 用Spark UI验证持久化到底生效没有

写完代码不能只看结果对不对,还要确认持久化真的起了作用。Spark UI的Storage标签页就是最直接的验证工具。

打开Storage页,能看到所有被持久化的RDD列表,每项包含:存储级别、缓存的分区数、内存占用、磁盘占用、副本数。如果某个RDD的Memory列一直不涨,说明持久化可能压根没被触发,或者数据刚存就被清掉了。如果看到Evicted字样,说明数据块被统一内存管理器挤出了,后面我会讲怎么处理。

除了看存储,还要对比作业耗时。持久化之后跑一遍同样的作业,看Stage耗时是否下降。更细致的验证方式是在日志里搜BlockManager相关输出,命中缓存时会看到类似Found block rdd_xx_0 locally的记录,这说明任务直接在本地读取了缓存块,没有再走上游重算。

还有一种办法可以事前估算RDD大小,用Spark自带的org.apache.spark.util.SizeEstimator:

import org.apache.spark.util.SizeEstimator val sample = rdd.take(10000) val perRecord = SizeEstimator.estimate(sample) / sample.length.toDouble val estimatedTotal = perRecord * rdd.count()

注意这个估算有误差,尤其对嵌套对象和包含引用关系的对象图,偏差可能很大。我的习惯是把估算当作粗筛,真正决定存储级别还是以Spark UI里显示的实际占用为准。先跑一次小规模作业,看真实缓存占用再定级别,比拍脑袋选MEMORY_AND_DISK靠谱得多。

4.4 内存参数联动:spark.memory.fraction与storageFraction

存储级别的效果不是孤立的,它受Spark统一内存管理的制约。自Spark 1.6起,Executor的JVM堆被划分成执行内存(给shuffle、join、aggregation用)和存储内存(给缓存、广播变量用),两者共享一个统一内存池。

有两个关键参数要理解:

  • spark.memory.fraction:默认0.6,表示JVM堆(扣除300MB系统保留后)里有60%用于执行和存储,剩下40%留给用户代码、元数据和老对象,避免GC频繁触发Full GC。
  • spark.memory.storageFraction:默认0.5,表示统一内存池里有50%是存储内存的“保底份额”。执行内存不足时可以抢占存储内存中超出这部分份额的缓存块,但保底份额内的缓存块是安全的。

这套机制带来一个实际后果:你做了MEMORY_ONLY持久化,不代表数据一定稳稳在内存里待着。一旦后续来了大shuffle,执行内存不够用,就可能把存储内存里的缓存块挤出去。缓存块被挤出后,再访问这个RDD就得重算,作业反而变慢。

针对这个情况,调整参数的方向是:如果你的作业以小迭代、多复用为主,shuffle不多,可以把spark.memory.storageFraction调高到0.6或0.7,保护缓存不被挤掉;反过来,如果你的作业shuffle密集、缓存的数据又不大,调低到0.3或0.4,给执行内存更多空间。另外,spark.memory.fraction不建议乱动,默认0.6在大多数作业里是个均衡值,调太高会挤占User memory,调太低又浪费堆空间。

4.5 堆外内存OFF_HEAP:什么时候值得用

存储级别里还有个OFF_HEAP,对应StorageLevel.OFF_HEAP。它把缓存的数据放到JVM堆外内存,需要先开启配置:

spark.memory.offHeap.enabled true spark.memory.offHeap.size 4g

OFF_HEAP的优势主要有两个。第一个是减轻GC压力。缓存很大且长期存留时,堆内对象反复被GC扫描,会造成明显的停顿;堆外内存不受GC管理,这一块压力就没了。第二个是堆外内存可以在一定程度上绕过Executor的内存限制,适合那种缓存数据特别庞大、又不想频繁被驱逐的场景。

但它也有硬伤。一是堆外内存只能存序列化后的数据,所以使用时天然带序列化开销;二是配置不当容易把堆外内存吃光,导致后续任务分配不到空间;三是OFF_HEAP并不能提供磁盘那样的超大规模存储,大小依然受spark.memory.offHeap.size限制。我的总体建议是:先在堆内把MEMORY_AND_DISK_SER用熟练,能解决大部分问题,OFF_HEAP属于最后一层的优化手段。我见过不少团队一上来就切OFF_HEAP,结果收益不明显,反而因为堆外内存配置和堆内执行内存互相干扰,把作业的整体稳定性做差了。

5. 常见问题与排查技巧实录

5.1 明明persist了,为什么还是慢

这是被问得最多的一个问题。代码里写了persist,作业却还是像没缓存一样慢。原因通常出在以下几点:

第一,缓存块被驱逐了。前面说过,执行内存紧张时可以挤占存储内存,缓存块一旦被驱逐,后续访问就会走重算。检查方法很简单:看Spark UI的Storage页,如果Memory列显示的大小明显小于RDD本身,或者出现过Evicted标记,就是被驱逐了。解决方法是换MEMORY_AND_DISK、调高storageFraction或用Checkpoint。

第二,持久化的位置不对。很多人把persist放在一个很便宜的中间RDD上,真正昂贵的血统部分根本没被缓存,每次Action照样重算开销大的那段。排查方法是沿着血统挨个看,确认缓存点是不是在最贵的复用节点上。

第三,作业被并发任务干扰。同一个集群上多个作业同时跑,内存是共享的,你缓存的块可能被别的作业的任务挤掉。这种问题单看代码看不出来,要结合集群监控和UI时间线一起分析。

第四,缓存只在一个Job内生效就释放了。某些版本或配置下,缓存数据会在Job结束后被定时清理,尤其配合了动态资源分配时Executor被回收,缓存随Executor一起消失。解决思路是缩短作业内部多次Action之间的间隔,或者把多个复用逻辑放在同一个Application内。

5.2 persist之后出现OOM怎么处理

先说一个反直觉的事实:MEMORY_ONLY缓存的数据放不下时,Spark通常不会直接OOM,而是把放不下的数据块丢掉,等下次访问时再重算。真正出现Executor OOM,往往是两种情况:一是执行内存被shuffle和aggregation耗尽;二是反序列化存储的对象过大,导致GC持续高压,最终堆溢出。

如果你在持久化后遇到OOM,先从两个维度排查。一是检查是不是选择了MEMORY_ONLY且数据量远超内存,这种请立刻换成MEMORY_AND_DISK_SER。二是检查作业里有没有同时存在多个大RDD缓存,我见过一个作业在同一时期缓存了六七个大RDD,总占用远超堆内存,OOM自然不可避免。正确的做法是精细化管理生命周期,用完一个unpersist一个,保证同一时刻只有真正核心的RDD占据存储内存。

如果OOM来自堆的整体压力,还可以考虑降低spark.memory.fraction,比如从0.6降到0.5,给用户代码和GC留更多空间。另外,大RDD切勿同时开启副本2,副本会让存储占用翻倍,OOM风险成倍上升。

5.3 序列化策略引发CPU飙升

有次帮某开发者排查一个作业,改成MEMORY_ONLY_SER之后内存确实省了,但整个作业CPU使用率暴涨,最后比原来更慢。原因就是序列化开销盖过了省内存的收益。

序列化有两种常见的坑。一个是Java原生序列化效率太低,每条记录都带大量类信息,序列化和反序列化都耗CPU。另一个是Kryo开了但没注册类,导致序列化结果里反复出现完整类名,压缩效果很差。正确的做法是接入Kryo并完成注册。

判断序列化成本是否可接受,有个简单办法:对比切换前后同一个Stage的耗时和CPU时间。如果内存占用降了但Stage总时间涨了20%以上,说明序列化开销太大,要么优化注册、要么换回不序列化的级别。对结构简单的数据,序列化收益通常是正的;对嵌套很深、字段很多的对象,序列化成本会更明显。这种RDD可以考虑mapPartitions批量处理,减少每条记录单独反序列化的次数。

5.4 persist和checkpoint千万别混用

持久化和Checkpoint都和数据存储有关,但定位完全不同。我整理了个对比表:

对比项persist/cachecheckpoint
存储位置Executor内存/本地磁盘可靠存储(如HDFS)
生命周期Application运行期间Application结束后数据仍存在
血统保留完整血统截断血统,只留最新起点
容错Executor挂掉则缓存失效Executor挂掉数据仍可恢复
写入代价内存写快写HDFS代价高,运行较慢
典型用途加速复用截断超长血统、跨作业共享

混用的典型错误是拿Checkpoint当缓存加速器用,结果每个Action都触发一次HDFS写,作业慢了几倍。反过来,拿persist当容错方案也有问题:Executor一挂,缓存全丢,血统照样重算,根本兜不住。

另外,localCheckpoint是一个折中方案,它把数据写到Executor本地磁盘,可以截断血统,但数据不跨Executor容错。如果你的血统太长导致重算链路危险,但又不方便写HDFS,localCheckpoint可以作为应急手段。它能快速切断血统,减少重算范围,但别把它当可靠存储。

5.5 迭代循环里的缓存泄漏

第3.2节的例子里已经埋了个伏笔:迭代循环里只persist不unpersist,内存会逐轮耗尽。这在长迭代作业里太常见了,我甚至有次在别人的代码里看到每轮迭代persist了三个RDD,但全程只有最后一步unpersist过,跑了三十轮之后Executor集体OOM。

根治方法就是严格配对管理:每一个persist出来的缓存,都要在它退出使用范围时立即unpersist。在迭代场景里,我的习惯是定义一个辅助方法,专门负责“创建新缓存、释放旧缓存”的切换:

def rotateCache[T](oldRdd: RDD[T], newRdd: RDD[T]): RDD[T] = { newRdd.persist(StorageLevel.MEMORY_ONLY).count() oldRdd.unpersist() newRdd }

每轮迭代结束时调用rotateCache,上一轮缓存被释放,新一轮缓存接上,内存水位始终保持在低位。这个方法我在多个迭代项目里用过,效果很稳定。同样的思路也适用于Spark Streaming,某些状态数据如果每批都persist而不清理,长时间运行下来内存必然被拖垮。

最后再分享一点我的实际操作体会

调优做了这么多年,我对RDD持久化的态度是:不要一上来就到处加persist,更不要迷信“持久化就一定更快”。接到一个慢作业,我习惯先在Spark UI里把每个Stage的耗时和输入大小过一遍,理清哪些RDD被多个Action重复消费,再动手选级别。选的时候记住几个原则:能放内存就别碰磁盘,能序列化就尽量序列化,拿不准就用MEMORY_AND_DISK_SER兜底。还有一个小细节容易被忽略——用完的缓存要主动unpersist,尤其在跑长任务或迭代作业时,不然缓存不释放,内存越吃越紧,最后反而拖垮整个作业。RDD持久化不是什么高深技术,它就是把“少算一遍”这四个字落到每个RDD上,把细节做扎实了,作业时间会给你最直接的回报。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询