做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 | 是 | 否 | 否 | 1 | RDD默认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 五分钟搞定选型:一个判断流程
面对一堆存储级别,很多人会纠结。我总结了套判断流程,按顺序走一遍基本不会错:
- 判断是否真的需要持久化。这个RDD只被一个Action消费?那别持久化,纯属浪费内存。被两个以上Action消费,或者要在多个Job之间复用,才继续往下走。
- 估算内存能不能放得下。先跑一遍看Spark UI的Storage页,或者用
SizeEstimator粗略估一下,后面第4.3节会讲具体方法。能放下就直接用MEMORY_ONLY,访问速度最快。 - 内存放不下或不确定。用
MEMORY_AND_DISK_SER兜底,放得下就放内存,放不下溢写磁盘,至少不会丢掉数据。 - 重算很便宜但数据很大。比如一个刚从HDFS读进来、还没经过复杂变换的原始数据,血统短、重算就是再读一次文件,这种情况直接用
DISK_ONLY,把内存让给真正需要的地方。 - 可靠性要求高。加上
_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 4gOFF_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/cache | checkpoint |
|---|---|---|
| 存储位置 | 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上,把细节做扎实了,作业时间会给你最直接的回报。