1. 先弄明白RDD为什么要持久化:血统重算的代价
很多人在第一次接触Spark的时候,都会被它的“RDD血缘机制”这个概念绕晕。官方文档里说得很好听——RDD通过lineage记录下每一步的转换关系,一旦某个分区数据丢失,可以沿着血缘关系重新计算出来。听起来像是一个完美的容错设计,但代价是什么?代价是“每一次Action操作都可能把整条血缘链从头到尾重算一遍”。
这里我先把Spark的懒执行机制说透。你在代码里写了一长串map、filter、join,它们只是构建了一张计算依赖图,Spark并不会立刻执行。只有当你调用count()、collect()、saveAsTextFile()这类Action时,Spark才会真正把任务调度到Executor上去跑。问题来了:如果同一个RDD被多个Action使用,而你又没有对它做过任何持久化处理,那么每一个Action都会把这个RDD的整个血缘链重新执行一次。换句话说,你写了一个filter之后又分别做了三次count,这个filter实际会被执行三次,前面所有的父RDD计算也全部重来一遍。
这还不是最要命的。真正要命的是宽依赖(wide dependency)。比如一个groupByKey操作,它的父RDD的分区数据会被打散重排到下游的不同分区。如果下游某个分区在执行过程中丢失了(Executor宕机、网络抖动导致任务重试),Spark需要重新计算上游所有相关分区,再重新做一次shuffle。在这种场景里,血缘重算不再是“多跑几个地图任务”这么简单,而是牵扯到整条shuffle链路的重新构建。
持久化策略解决的就是这个问题:把某个中间结果,把这个已经算出来的数据,按照你指定的方式在集群里缓存起来。等下一个Action再用到它的时候,直接读取缓存,绕开血缘重算。这是整个Spark性能调优里投入产出比最高的一步,没有之一。
我之前调过一个跑批任务,逻辑不复杂:读HDFS原始日志,清洗过滤,然后分别产出三张统计报表。没加缓存之前,整个任务耗时43分钟,加了cache()之后直接降到19分钟,而且还是在没有调任何执行参数的情况下。这就是因为那三张报表各自触发了Action,每次都把清洗逻辑从头跑了一遍。加了缓存,第二次、第三次Action直接从内存里取数。
那有人会问:既然缓存这么有用,为什么不把所有中间结果都缓存起来?这个问题问到点子上了。因为缓存也是有成本的,内存资源是有限的,盲目缓存大对象反而会把Executor的内存挤爆,引发频繁GC甚至是OOM。所以我们需要真正理解Spark提供的每一种持久化策略,知道它们在什么场景下划算,什么场景下是负优化。
2. StorageLevel存储级别逐个拆解:内存、磁盘、序列化与副本
先明确一个概念:我们平时说的“缓存”,在Spark里对应的是persist()方法,cache()只是persist()的一个特例——它等价于persist(StorageLevel.MEMORY_ONLY)。而persist()可以接收一个StorageLevel参数,这个参数决定了数据缓存在哪里、以什么形式缓存、存几份。
StorageLevel本质上由四个维度组合出来:是否使用内存(useMemory)、是否使用磁盘(useDisk)、是否使用堆外内存(useOffHeap)、是否序列化(deserialized),外加一个副本数(replication)。Spark内置了若干种预置的StorageLevel,我先把它们完整列出来,后面逐个说明适用场景。
| 存储级别 | 使用内存 | 使用磁盘 | 是否序列化 | 副本数 | 说明 |
|---|---|---|---|---|---|
MEMORY_ONLY | 是 | 否 | 否 | 1 | 直接存Java对象,不序列化 |
MEMORY_ONLY_SER | 是 | 否 | 是 | 1 | 存序列化后的字节数组 |
MEMORY_AND_DISK | 是 | 是 | 否 | 1 | 内存放不下时溢写到磁盘 |
MEMORY_AND_DISK_SER | 是 | 是 | 是 | 1 | 内存放不下时溢写磁盘,存序列化数据 |
DISK_ONLY | 否 | 是 | 是 | 1 | 只落磁盘,做序列化 |
OFF_HEAP | 否 | 否 | 是 | 1 | 存到堆外内存,需要外部系统支持 |
MEMORY_ONLY_2 | 是 | 否 | 否 | 2 | 每个分区存两份,分布在两个不同Executor |
MEMORY_AND_DISK_2 | 是 | 是 | 否 | 2 | 同上,支持磁盘溢写 |
2.1 从cache()说起:MEMORY_ONLY的适用与边界
MEMORY_ONLY是默认的cache()行为。数据以反序列化后的Java对象形式存储在Executor的堆内存里,读取速度最快,因为拿到的就是现成的对象,不需要额外做反序列化。它的适用前提非常明确:RDD的数据量必须能整体放进集群的可用内存。
这里有个非常容易踩的坑:你以为放得下,实际上放不下。JVM里一个Java对象占用的内存往往比它在磁盘上的原始大小大得多。比如你从HDFS读了一批文本文件,磁盘上是5GB,但解析成String对象放到JVM堆里,占用可能达到15GB到20GB——因为每个String对象有对象头、char[]数组的额外开销,还可能有重复的字符串对象没有池化。这种情况下用MEMORY_ONLY,数据刚缓存一部分,内存就被撑爆,后续分区缓存失败,Spark会直接放弃缓存这些分区,等下次用到时仍然走血缘重算,缓存形同虚设。
更恶心的情况是,它不会报错,也不会提醒你“缓存失败了”,只是默默地不再缓存后续分区。很多人在UI上看到Storage那一栏只有一部分数据,还以为是Spark没加载完,实际上是内存不够被放弃了。所以使用MEMORY_ONLY之前,最好先用rdd.rdd.getStorageLevel确认一下,或者观察Storage页面的缓存占比。
2.2 MEMORY_ONLY_SER与MEMORY_AND_DISK_SER:序列化带来的空间福音
如果你的RDD数据量偏大,但你又确实希望它能常驻内存,那么MEMORY_ONLY_SER是更务实的选择。它会把每个分区序列化成紧凑的字节数组再存入内存。同样是5GB原始文本,序列化后的对象体积可能只有原来JVM对象占用空间的四分之一甚至更少,因为去掉了对象头、指针、对齐填充这些额外开销。
代价也很明确:每次读取缓存数据都要做反序列化,CPU开销上升。而且默认的Java序列化机制效率偏低,序列化出来的字节数组还是比较大。所以使用_SER系列存储级别时,强烈建议把spark.serializer设置为org.apache.spark.serializer.KryoSerializer,并注册自定义类。Kryo序列化后的数据体积大约是Java序列化的1/10,序列化和反序列化速度也更快。这里多说一句,Kryo需要在提交任务前配置好,运行中途改配置不会生效,因为序列化器是在Driver初始化时确定的。
MEMORY_AND_DISK_SER则是在MEMORY_ONLY_SER基础上加了一道保险:内存放不下的分区,不是丢弃缓存,而是直接溢写(spill)到磁盘。这样一来,最坏情况下只是读取速度下降为磁盘IO,但至少避免了“缓存失效导致全量重算”的窘境。很多生产环境的任务,中间结果数据集不大不小——比内存大一点,又没大到必须完全走磁盘——这种场景下MEMORY_AND_DISK_SER就是最稳的选择。
2.3 副本后缀与OFF_HEAP:容错和堆外到底怎么回事
带_2后缀的存储级别,比如MEMORY_ONLY_2,意思是每个分区缓存两份副本,并且这两份副本会被尽量分布到不同的Executor节点上。它解决的是一个非常实际的问题:只要有一个Executor节点宕机,它上面缓存的全部数据就跟着丢了。如果只有一份副本,Spark要去重新计算这部分数据,那之前靠缓存省下来的时间又还回去了。相当于铜墙铁壁上开了一个洞。存两份副本,单个节点宕机通常不会影响缓存命中率。
但副本数翻倍也意味着存储成本翻倍,内存占用直接翻倍,所以一般只用于那些计算代价极高、节点稳定性又不太放心的关键中间结果。大多数场景下,一份副本加血缘重算做兜底,已经足够。
OFF_HEAP这个级别需要依赖额外的堆外内存管理系统,比如Tachyon/Alluxio这类外部存储服务。它把数据放到JVM堆之外的内存区域,好处是不受JVM GC影响,坏处是需要额外部署和运维一套外部系统。绝大多数业务场景用不上,我见过有人为了追求“技术先进”硬上堆外缓存,最后发现运维成本比它带来的收益高得多。这里我的建议是:先别碰OFF_HEAP,等你的内存压力确实大到GC成为瓶颈时,再考虑也不迟。
3. 存储级别怎么选:从数据特征到执行计划的决策链路
选存储级别没有银弹,但有清晰的决策链路。我在实际项目里一般按下面这个顺序来定,基本不会出错。
3.1 先回答三个问题:数据集多大、被复用几次、容错底线在哪
第一个问题:这个RDD有多大?估一个粗略的量级,是几百MB、几个GB、几十个GB,还是已经超出整个集群的内存总量。量级直接决定了能不能用纯内存方案。第二个问题:它会被复用几次?如果只被一个Action用到,缓存就是纯浪费——缓存写入本身也是有开销的。只有被多个Action复用,才值得考虑持久化。第三个问题:如果这条RDD真的丢了,重新计算需要多久?这个时间你可以直接测试:把缓存去掉跑一遍,记录耗时。如果重算只要几秒钟,那缓存的意义也不大;如果重算要几十分钟甚至几小时,那宁可多花点存储成本也要保住缓存。
这三个问题回答完,基本可以圈定一个大方向:小数据多次复用,直接MEMORY_ONLY;中等数据放不下内存,但可接受序列化开销,选MEMORY_ONLY_SER或MEMORY_AND_DISK_SER;超大且复用次数多的关键数据,选MEMORY_AND_DISK_SER甚至DISK_ONLY;如果数据只复用一次,直接不缓存。
3.2 序列化与压缩的实测影响
再讲一个很多人忽略的点:_SER不是“省内存”这么简单,它还改变了读取缓存数据的CPU开销比例。反序列化是需要消耗CPU的,所有缓存的记录被读取出来时都要从字节数组还原成Java对象。如果你后面的计算逻辑本身就是CPU密集型,序列化缓存带来的CPU压力会更明显。
我实测过一个场景:一个清洗后的DataFrame后续要做三次聚合,数据量大约8GB。用MEMORY_ONLY时,三次聚合总耗时17分钟;换成MEMORY_AND_DISK_SER(Kryo+压缩),内存占用从原来的接近爆炸降到很宽裕,但三次聚合总耗时变成了21分钟,多了4分钟,主要是反序列化开销。这个case里内存宽裕、任务能稳定跑完,那我会选后者——因为前者在真实生产环境下可能因为内存紧张导致GC风暴,反而更慢。所以这里没有绝对的优劣,关键是你要有个预期:_SER牺牲一点CPU,换取更多的内存空间和稳定性。
另外,如果要用磁盘溢写,建议开启spark.rdd.compress=true。这个参数默认就是true,但很多老集群改过,检查一下没坏处。它对序列化后的RDD数据做压缩再落盘,减少磁盘IO量。代价是写入和读取时多一层压缩/解压计算,但对于落磁盘的场景,省下的IO时间通常远大于压缩计算的时间。
3.3 按计算场景推荐的配置参考表
| 典型场景 | 推荐存储级别 | 理由 |
|---|---|---|
| 迭代式机器学习算法(如LR、KMeans的迭代循环) | MEMORY_ONLY | 每轮迭代都要复用同一份训练数据,读内存最快 |
| ETL中清洗后的中间结果,后续多个报表复用 | MEMORY_AND_DISK_SER(Kryo) | 数据量通常较大,序列化省内存,磁盘兜底防失效 |
| 大表join时的小表广播与缓存 | MEMORY_ONLY | 小表体积小,直接存对象最快 |
| 超大RDD,只做一次行动但分区不稳定 | MEMORY_AND_DISK | 不序列化,内存放不下就落盘,尽量保留计算好的分区 |
| 需要严格容错的关键中间数据 | MEMORY_AND_DISK_2 | 副本防止单点故障后缓存全丢 |
| 超大RDD但后续只用一次 | 不缓存 | 缓存写入的IO开销超过重算收益 |
4. 缓存与checkpoint:血缘保留与血缘切断的取舍
很多人会把RDD的persist()和checkpoint()放在一起比较,实际上它们解决的问题有交集,但底层逻辑完全不同。
persist()缓存的是当前RDD的计算结果,但它不会切断血缘关系。也就是说,即使缓存数据已经存在,RDD的依赖链、依赖的父RDD信息都还保留着。一旦缓存数据因为节点故障全部丢失,Spark依然能沿着血缘关系把整个RDD重新算出来。血缘在这里就是缓存失败的兜底方案。这个设计很聪明,但也带来一个问题:如果某个RDD的血缘链特别长,缓存的数据又丢了,那么重算时需要重新执行的祖父RDD们可能又多又费时。
checkpoint()则是把RDD的数据直接写到可靠存储里,通常是HDFS,然后切断血缘关系。执行checkpoint()之后,这个RDD不再保留对父RDD的依赖,它变成了一个“无父RDD”的状态。后面再对这个RDD做操作,无论是新Action还是失败后的任务重试,都直接从checkpoint目录里读取数据,不再回溯上游。
从存储位置来说,persist()的数据存放在Executor的BlockManager里,属于分布式内存和本地磁盘的临时数据,Application结束就没了。checkpoint()的数据落在HDFS上,有副本机制,集群重启后依然还在。从生命周期来说,persist()更像是一个“运行期优化”,而checkpoint()更像是一个“永久落盘”。
所以选择逻辑就很清晰了。如果你只是想让一个RDD在同一Application的多个Action之间快速复用,用persist()就够了,没必要上HDFS,毕竟HDFS读写是磁盘IO,比内存慢太多。如果你的血缘链极其长,比如经过几百次转换,或者是从一个有超多分区的宽依赖链条里衍生出来的,并且你已经明确知道这个RDD是后续所有计算的根节点,那么checkpoint()就是更优选择——它能彻底斩断重算链,避免“一个叶子节点失败,牵动整棵血缘树重新计算”的极端情况。
我常用的组合是:先persist()再checkpoint()。流程是这样——调用rdd.persist(StorageLevel.MEMORY_AND_DISK),然后执行一次Action(比如rdd.count())把它物化,接着执行rdd.checkpoint()。为什么要先persist?因为checkpoint本身也要把数据写出到HDFS,这个过程需要读取RDD数据。从内存缓存里读比从上游全量重算一遍快得多。先物化再checkpoint,等于是用内存换时间,让checkpoint的写盘过程不那么痛苦。注意,checkpoint()是懒执行的,必须有一个Action触发它才能真正执行,这也是很多人第一次用checkpoint时发现数据没写出来的原因。
5. 实际调优中踩过的坑与验证方法
这部分聊聊我实际调优过程中的几次“翻车”经历,以及对那些坑的复盘。这些经验在官方文档里不太会有人专门写,但对实战非常有价值。
5.1 缓存丢失导致重算:你以为的缓存命中可能根本没发生
有一次我在任务里对一个中间RDD执行了cache(),UI上Storage页面也显示有数据。但后续的一次task失败重试后,整个stage的时间突然暴涨。查了半天才发现——缓存的那个RDD所在的Executor在task重试期间恰好挂掉了,缓存数据跟着丢失。BlockManager发现缓存块丢失后,会通知Driver重新计算该RDD,而这个RDD的父级是一条很长的宽依赖链,于是一下子引发了“雪崩式”重算,把上游的所有stage全部重新跑了一遍。
这个坑的教训有三点。第一,重要且贵的中间数据不要只存一份,MEMORY_AND_DISK_2这种带副本的策略在关键节点是值得的。第二,宽依赖链比较长的RDD,如果后续将被复用,考虑checkpoint()而不是persist(),因为HDFS的副本机制比Executor本地存储可靠得多。第三,监控Executor的心跳和GC日志,如果节点频繁Full GC后失联,缓存数据丢失只是表象,根因是内存配置不合理。
5.2 缓存太多被LRU淘汰:无差别缓存反而拖垮性能
另一个常见的反面案例是“加了缓存反而更慢”。之前有个同学接到一个性能问题,上来就想用缓存解决,把任务里十几个RDD全部persist(MEMORY_ONLY)。结果数据量一上去,内存根本放不下,BlockManager按照LRU策略开始淘汰旧缓存块。有些RDD刚写完缓存,还没来得及被复用,就被淘汰了。等到真正复用它时,Spark发现缓存块不存在,只能重新计算。等于白跑了一轮缓存写入,还额外消耗了内存管理和GC时间。
这里我建议的做法是:用之前先问自己,这个RDD到底有没有被复用?复用了多少次?如果一个RDD只在一个Action里被用到,缓存它就是一个亏本买卖。你可以用rdd.getStorageLevel在代码里打点,观察执行计划里哪些地方读到了缓存,哪些地方没有。Spark UI的Storage页面也会显示每个RDD的缓存命中情况,如果你看到某个RDD的缓存数据被淘汰率很高,说明它不值得缓存,或者存储级别选小了。
5.3 用Spark UI的Storage页观察缓存命中率
验证缓存是否生效,最直接的办法就是看Spark UI。打开Storage(存储)页,你会看到所有被persist的RDD和DataFrame,包括它们的存储级别、已缓存分区数、缓存大小和内存占比。关键要看两点:一是缓存分区数是否是100%,如果有大量分区没有被缓存,说明内存不足,缓存被部分放弃,这时要换用MEMORY_AND_DISK_SER或者扩容;二是在运行过程中观察同一RDD在多个Stage中读取的速度——如果某个Stage完全没有shuffle但耗时很长,大概率是缓存没命中,正在走重算路径。
另外,看stage的“Input”和“Shuffle Read”指标也有帮助。正常从内存缓存读取数据时,Input不会特别高;如果缓存失效,重新计算时,那一个stage的Input会重新出现上游数据量,耗时也会成倍上升。通过这些UI指标,你能很直观地定位到“到底哪个RDD的缓存被浪费或失效了”。
5.4 及时调优缓存生命周期,不要等到任务结束
persist()的数据生命周期默认到整个Application结束,由JVM自己回收。但如果你在一个超长任务里对多个RDD做了缓存,又不在中间及时释放,内存会越积越满。避免这个问题的办法是用rdd.unpersist()主动标记缓存块为可删除状态。BlockManager会优先回收这些被你标记的缓存块,给后续的数据腾出空间。
注意unpersist()也是懒执行的,它向Driver发送消息,真正清理会在下一个时间点异步完成。如果你想确保它彻底清理完毕再继续,可以加一个rdd.unpersist(true),true表示阻塞等待清理完成。不过这种强制阻塞会拖慢任务,通常只在内存极度紧张时才用。
6. 选型决策的落地框架与最终建议
如果你看完上面这些还是觉得有点乱,我给你总结成一套可以直接照做的判断流程。拿到一个RDD,按下面的顺序问自己四层问题。
第一层,这个RDD会不会被复用?被复用几次?如果只有一次,直接跳过,不缓存。第二层,数据集到底多大?能不能全部放进集群可用内存?可以,走MEMORY_ONLY;不可以,继续往下。第三层,你是否愿意用CPU换空间?愿意,走MEMORY_AND_DISK_SER,并且用Kryo序列化;不愿意,走MEMORY_AND_DISK,但要有心理准备内存占用偏高。第四层,这笔数据丢了重算的代价有多大?代价大到无法接受,加副本_2后缀或直接上checkpoint()。
这套流程看起来简单,但实际能解决大部分场景的选型问题。它的核心就是一句话:持久化不是越强越好,而是刚好匹配数据的复用频率、体积和重算代价。数据小、复用多、重算贵,就用最贵的内存方案;数据大、复用一般、重算可接受,就用磁盘兜底方案;数据大且复用极频繁,还得仔细考虑序列化节约空间。
最后再分享一个小技巧。在提交Spark作业前,可以先在测试环境用小数据集把存储级别跑通,观察Storage页面核对缓存占比,同时开启--conf spark.kryo.registrationRequired=true强制Kryo注册检测,避免因为漏注册导致序列化异常。生产环境的大任务,我建议在代码里给持久化的RDD加上日志,打印出getStorageLevel和缓存的block数量,方便在UI之外留一条可检索的排查线索。Spark持久化策略没有一个绝对正确的标准答案,它更像是一道平衡题——在内存、磁盘、CPU、容错之间找到那个恰好适合你当前任务的临界点。