Spark Cache的几点思考

Spark涵盖了大数据领域内的离线批处理、流式计算、机器学习和图计算等不同的场景,已经成为大数据计算领域首选的计算框架。由于spark框架的应用越来越广,针对spark任务的优化成为必不可少的一项技能,其中cache是一种简单而有效的方式。相信用spark开发的人都知道如何使用cache,但你真的对cache的以下几个问题的答案都非常清楚吗?

  • 为什么要cache?
  • 如何使用cache?
  • 什么时候cache?
  • 选用什么cache级别?
  • DISK cache是否有意义?

下面是我对这些问题个人的理解总结,如有理解失误之处,请指正!

一、为什么要cache?

Spark计算框架的一大优势就是对迭代计算的强大支持。由于spark中的RDD都是只读不可变的对象,也就是RDD的每一个transformation操作都会产生一个新的RDD。所以Spark任务中的一个优化原则就是避免创建重复的RDD而尽量复用同一个RDD。

如果像编写单机程序一样,以为复用RDD只需要在不同的迭代计算中引用同一个RDD即可,在查看spark UI中的任务日志时会发现同一份输入数据可能被多次重复读取。这与spark的RDD计算原理有关:spark中一个job是由RDD的一连串transformation操作和一个action操作组成。只有当执行到action操作代码时才会触发生成真正的job,从而根据action操作需要的RDD及其依赖的所有RDD转换操作形成实际的任务。也就是会从源头输入数据开始执行整个计算过程,并没有如我们想的单机程序那样达到RDD复用的目的。

为了达到RDD复用的目的,就需要对想要复用的RDD进行cache,RDD的缓存与释放都是需要我们显示操作的。

二、如何使用cache?

spark的cache使用简单,只需要调用cache或persist方法即可,而且可以看到两个方法实际都是调用的都是persist方法。

def cache(): this.type = persist()
def persist(): this.type = persist(StorageLevel.MEMORY_ONLY)

cache的使用虽然非常简单,但还是有几点需要注意的:

cache之后一定不能直接去接算子。因为cache后有算子的话,它每次都会重新触发这个计算过程,从而导致cache失效。
cache操作需要当第一个使用到它的job执行后才会生效,而不是cache后马上可用,这是spark框架的延迟计算导致的。可能粗想起来也不会有什么问题,但是不正确的使用unpersist操作,也可能会导致cache失效。如下例子所示,在action操作之前就把缓存释放掉:

val data = sc.textFile(“data.csv”).flatMap(_.split(“,”)).cache() 
val data1 = data.map(word => (word, 1)).reduceByKey(_ + _) 
val data2 = data.map(word => (word, word.length)).reduceByKey(_ + _) 
data.unpersist() 
val wordCount1 = data1.count() 
val wordCount2 = data2.count()

如何释放cache缓存:unpersist,它是立即执行的。persist是lazy级别的(没有计算),unpersist是eager级别的。RDD cache的生命周期是application级别的,也就是如果不显示unpersist释放缓存,RDD会一直存在(虽然当内存不够时按LRU算法进行清除),如果不正确地进行unpersist,让无用的RDD占用executor内存,会导致资源的浪费,影响任务的效率。

三、什么时候cache?

使用spark cache非常简单,但应该什么时候cache可能就不是很清楚了。最粗暴的方式是只要RDD有被复用就使用cache,但显然这不是最优的方式。总的来说在以下三种情况下应该cache:

在迭代循环中重用RDD
在一个spark任务中多次重用RDD
当重新生成RDD的分区数据成本很高时
这三种情况说的还是很抽象,其实总的来说就是权衡cache与否的代价:不cache则多次使用RDD时会将RDD及其依赖的所有RDD都重新计算一次。cache则重用的RDD只会计算一次,但是会占用executor的内存资源。那是否应该cache就是把重新计算RDD的时间资源与缓存RDD的内存资源之间进行权衡。这两者间的权衡没有一定的规则,不同的任务类型不同的业务场景的原则都不一样。在无法明确权衡时,最好也最直接的方式是用实际任务进行实验,实践才是检验真理的最好方式。

四、选用什么cache级别?

针对占用内存不是很大的中间计算结果优先采用MEMORY_ONLY,它的性能最好(前提是内存要足够),也是RDD的默认策略。如果中间结果数据量稍大,可以采用MEMORY_ONLY_SER策略进行补充。但在实际的生产环境中,大多数情况下数据量都是超出内存容量的,这可能会导致JVM的OOM内存溢出异常。

如果内存无法容纳中间数据,那么建议使用MEMORY_AND_DISK_SER策略,该策略会优先将数据缓存在内存中,只有当内存不够时才会把数据写入磁盘。另外对于分布式任务,IO资源往往比CPU资源更加紧张,序列化后的数据可以节省内存和磁盘的空间开销。

通常很少使用DISK_ONLY级别,它表示数据量已经非常大,远大于内存的容量。这个时候需要慎重权衡重新计算RDD的消耗和从磁盘加载RDD的消耗。

除非对于高可用性的任务,否则不建议使用后缀为_2的级别。因为在内存中复制多份数据很难有足够的内存资源满足,而对于HDFS文件本身已经有多备份保证数据的可靠性。

对于实际缓存的效果,可以查看spark UI中的storage页面,里面详细描述了缓存的每个RDD的数据缓存分布情况。
Spark的持久化级别有如下几种:

  • MEMORY_ONLY

使用未序列化的Java对象格式,将数据保存在内存中。如果内存不够存放所有的数据,则数据可能就不会进行持久化。那么下次对这个RDD执行算子操作时,那些没有被持久化的数据,需要从源头处重新计算一遍。如果RDD中数据量比较大时,会导致JVM的OOM内存溢出异常。这是RDD的默认持久化级别。

  • MEMORY_AND_DISK

使用未序列化的Java对象格式,优先尝试将数据保存在内存中。如果内存不够存放所有的数据,会将数据写入磁盘文件中,下次对这个RDD执行算子时,持久化在磁盘文件中的数据会被读取出来使用。这是DataFrame的默认持久化级别。

  • MEMORY_ONLY_SER/MEMORY_AND_DISK_SER

基本含义同MEMORY_ONLY/MEMORY_AND_DISK。唯一的区别是,会将RDD中的数据进行序列化,RDD的每个partition会被序列化成一个字节数组。这种方式更加节省内存,从而可以避免持久化的数据占用过多内存导致频繁GC。

这两种策略都是对MEMORY_ONLY/MEMORY_AND_DISK策略的补充。

  • DISK_ONLY

使用未序列化的Java对象格式,将数据全部写入磁盘文件中。

  • MEMORY_ONLY_2/MEMORY_AND_DISK_2 etc..

对于上述任意一种持久化策略,如果加上后缀_2,代表的是将每个持久化的数据,都复制一份副本,并将副本保存到其他节点上。这种基于副本的持久化机制主要用于进行容错。假如某个节点挂掉,节点的内存或磁盘中的持久化数据丢失了,那么后续对RDD计算时还可以使用该数据在其他节点上的副本。如果没有副本的话,就只能将这些数据从源头处重新计算一遍了。

五、DISK cache是否有意义?

在持久化级别中有将数据存储到磁盘中,粗看起来有些奇怪,因为大部分情况下可能源数据就是在HDFS中的。如果把数据缓存到磁盘上,在再次使用的过程中还是需要重新读入,这是否还有意义?对于DISK缓存的数据有两点需要注意:

  1. 是否cache的权衡转变为重新计算RDD的计算代价与从磁盘加载RDD的代价两者间的权衡。
  2. RDD的cache是分区级别的,并且缓存是将数据写到executor对应的worker节点的本地目录中。

也就是对于DISK的缓存还是有意义的,首先是当内存中无法容纳的分区数据才会被写入磁盘,其次写入的磁盘是worker节点的本地目录,当重新读取时相当于是读取本地磁盘数据。而且磁盘空间往往不是瓶颈,完全可以给worker节点配置足够的磁盘容量。

如果从这个思路出发,缓存数据的效率肯定比读取源数据的效率更高,那是否可以把所有输入数据进行缓存,从而将spark cache当做一个大的内存数据库呢?之所以有这个想法是因为在实际的业务场景中,同一部门中使用的源数据都是一致的,并且在不同业务团队不同的spark应用中会被反复使用到,那是否可以将源数据统一进行缓存,为同一部门中不同业务不同任务统一提供缓存数据,从而大大提高任务的处理效率。查询资料发现现在已经有Apache Ignite(高性能、集成化和分布式的内存计算和事务平台)支持这种思路,但这种解决方案需要重新部署Apache Ignite集群进行支持。另一种是将spark任务设计成server/client模式,所有任务都提交到server端运行,从而在application间进行数据复用。