(前言)什么是序列化?
序列化听起来很神秘,其实很简单.例如你有一个对象A,带有属性A1,A2,并且带有方法C1,C2. 在某些时候,可能希望保存这个对象.例如存成文件,或者传输到其他地方去.然后在需要时候可以随时恢复这个对象,那该怎么办呢.实际可以看到,只需要把A1和A2的值保存下来,恢复的时候新建一个A对象,然后对A1和A2赋值.这样就能得到和原来的A一样的对象了.这个过程就是序列化. 当然实际的过程中涉及到属性是对象的时候,情况会复杂一些.但是基本的原理是一样的.
所以,序列化生成的内容只包括属性,并不会实例化方法.那么了解什么是序列化,应该就清楚知道为什么不能序列化抽象类了.
(一)理解spark闭包
什么叫闭包: 跨作用域访问函数变量。又指的一个拥有许多变量和绑定了这些变量的环境的表达式(通常是一个函数),因而这些变量也是该表达式的一部分。
Spark闭包的问题引出:
在spark中实现统计List(1,2,3)的和。如果使用下面的代码,程序打印的结果不是6,而是0。这个和我们编写单机程序的认识有很大不同。为什么呢?
object Test {
def main(args:Array[String]):Unit = {
val conf = new SparkConf().setAppName("test");
val sc = new SparkContext(conf)
val rdd = sc.parallelize(List(1,2,3))
var counter = 0
//warn: don't do this
rdd.foreach(x => counter += x)
println("Counter value: "+counter)
sc.stop()
}
}
问题分析:
counter是在foreach函数外部定义的,也就是在driver程序中定义,而foreach函数是属于rdd对象的,rdd函数的执行位置是各个worker节点(或者说worker进程),main函数是在driver节点上(或者说driver进程上)执行的,所以当counter变量在driver中定义,被在rdd中使用的时候,出现了变量的“跨域”问题,也就是闭包问题。
问题解释:
对于上面程序中的counter变量,由于在main函数
和在rdd对象的foreach函数
是属于不同“闭包”的,所以,传进foreach中的counter是一个副本,初始值为0。foreach中叠加的是counter的副本,不管副本如何变化,都不会影响到main函数中的counter,所以最终打印出来的counter为0.
问题延伸:
当用户提交了一个用scala语言写的Spark程序,Spark框架会调用哪些组件呢?首先,这个Spark程序本身就是一个“Application”,程序里面的mian函数就是“Driver Program”, 前面已经讲到它的作用,只是,dirver程序的可能运行在客户端,也有可有可能运行在spark集群中,这取决于spark作业提交时参数的选定,比如,yarn-client和yarn-cluster就是分别运行在客户端和spark集群中。在driver程序中会有RDD对象的相关代码操作,比如下面代码的newRDD.map():
class Test{
def main(args: Array[String]) {
val sc = new SparkContext(new SparkConf())
val newRDD = sc.textFile("")
newRDD.map(data => {
//do something
println(data.toString)
})
}
}
涉及到RDD的代码,比如上面RDD的map操作,它们是在Worker节点上面运行的,所以spark会透明地帮用户把这些涉及到RDD操作的代码传给相应的worker节点。如果在RDD map函数中调用了在函数外部定义的对象,因为这些对象需要通过网络从driver所在节点传给其他的worker节点,所以要求这些类是可序列化的,比如在Java或者scala中实现Serializable类,除了java这种序列化机制,还可以选择其他方式,使得序列化工作更加高效。worker节点接收到程序之后,在spark资源管理器的指挥下运行RDD程序。不同worker节点之间的运行操作是并行的。
在worker节点上所运行的RDD中代码的变量是保存在worker节点上面的,在spark编程中,很多时候用户需要在driver程序中进行相关数据操作之后把该数据传给RDD对象的方法以做进一步处理,这时候,spark框架会自动帮用户把这些数据通过网络传给相应的worker节点。除了这种以变量的形式定义传输数据到worker节点之外,spark还另外提供了两种机制,分别是broadcast和accumulator。相比于变量的方式,在一定场景下使用broadcast比较有优势,因为所广播的数据在每一个worker节点上面只存一个副本,而在spark算子中使用到的外部变量会在每一个用到它的task中保存一个副本,即使这些task在同一个节点上面。所以当数据量比较大的时候,建议使用广播而不是外部变量。
(二)spark闭包与序列化
Spark的官方文档再三强调那些将要作用到RDD上的操作,不管它们是一个函数还是一段代码片段,它们都是“闭包”,Spark会把这个闭包分发到各个worker节点上去执行,这里涉及到了一个容易被忽视的问题:闭包的“序列化”。
显然,闭包是有状态的(什么叫做状态??),这主要是指它牵涉到的那些自由变量以及自由变量依赖到的其他变量。所以,在将一个简单的函数或者一段代码片段(就是闭包)传递给类似RDD.map这样的操作前,Spark需要检索闭包内所有的涉及到的变量(包括传递依赖的变量),正确地把这些变量序列化之后才能传递到worker节点并反序列化去执行。如果在涉及到的所有的变量中有任何不支持序列化或没有指明如何序列化自己时,你就会遇到这样的错误:
org.apache.spark.SparkException: Task not serializable
在下面的例子中,我们从kafka中持续地接收json消息,并在spark-streaming中将字符串解析成对应的实体:
object App {
private val config = ConfigFactory.load("my-streaming.conf")
case class Person (firstName: String,lastName: String)
def main(args: Array[String]) {
val zkQuorum = config.getString("kafka.zkQuorum")
val myTopic = config.getString("kafka.myTopic")
val myGroup = config.getString("kafka.myGroup")
val conf = new SparkConf().setAppName("my-streaming")
val ssc = new StreamingContext(conf, Seconds(1))
val lines = KafkaUtils.createStream(ssc, zkQuorum, myGroup, Map(myTopic -> 1))
//this val is a part of closure, and it's not serializable!
implicit val formats = DefaultFormats
def parser(json: String) = parse(json).extract[Person].firstName
lines.map(_._2).map(parser).print
....
ssc.start()
ssc.awaitTerminationOrTimeout(10000)
ssc.stop()
}
}
这段代码在执行时就会报如下错误:
org.apache.spark.SparkException: Task not serializable
Caused by: java.io.NotSerializableException: org.json4s.DefaultFormats$
问题的症结就在于:闭包没有办法序列化。在这个例子里,闭包的范围是:函数parser以及它所依赖的一个隐式参数: formats , 而问题就出在这个隐式参数上, 它的类型是DefaultFormats,这个类没有提供序列化和反序列自身的说明,所以Spark无法序列化formats,进而无法将task推送到远端执行。
隐式参数formats是为extract准备的,它的参数列表如下:
org.json4s.ExtractableJsonAstNode#extract[A](implicit formats: Formats, mf: scala.reflect.Manifest[A]): A = ...
找到问题的根源之后就好解决了。实际上我们根本不需要序列化formats, 对我们来说,它是无状态的。所以,我们只需要把它声明为一个全局静态的变量就可以绕过序列化。所以改动的方法就是简单地把implicit val formats = DefaultFormats
的声明从方法内部迁移到App Object的字段位置上即可。
object App {
private val config = ConfigFactory.load("my-streaming.conf")
case class Person (firstName: String,lastName: String)
//As Object field, global, static, no need to serialize
implicit val formats = DefaultFormats
def main(args: Array[String]) {
val zkQuorum = config.getString("kafka.zkQuorum")
val myTopic = config.getString("kafka.myTopic")
val myGroup = config.getString("kafka.myGroup")
val conf = new SparkConf().setAppName("my-streaming")
val ssc = new StreamingContext(conf, Seconds(1))
val lines = KafkaUtils.createStream(ssc, zkQuorum, myGroup, Map(myTopic -> 1))
def parser(json: String) = parse(json).extract[Person].firstName
lines..map(_._2).map(parser).print
....
ssc.start()
ssc.awaitTerminationOrTimeout(10000)
ssc.stop()
}
}
这里再提供另外一个很好的例子:
这个例子很好演示了解决类似问题的方案:“把类成员变量拷贝一份到闭包中” ,不然整个对象都需要被序列化!
最后我们来总结一下应该如何正确的处理Spark Task闭包的序列化问题。首先你需要对Task涉及的闭包的边界要有一个清晰的认识,要尽量地控制闭包的范围和牵涉到的自由变量,一个非常值得警惕的地方是:尽量不要在闭包中直接引用一个类的成员变量和函数,这样会导致整个类实例被序列化。这样的例子在Spark文档中也有提及,如下:
class MyClass {
def func1(s: String): String = { ... }
def doStuff(rdd: RDD[String]): RDD[String] = { rdd.map(func1) }
}
然后,一个好的组织代码的方式是:除了那些很短小的函数,尽量把复杂的操作封装到全局单一的函数体:全局静态方法或者函数对象
如果确实需要某个类的实例参与到计算过程中,则要作好相关的序列化工作。
比如RDD里的计算调用了别的组件类里的方法(比如hbase里的put方法),那么序列化时,会将该方法所属的对象的所有变量都序列化的,可能有些根本没有实现序列化导致直接报错。也就是spark的api没有做到用户无感知,在使用一些全局方法时还需自己控制。简单点的做法就是:能定义在计算函数内的方法就定义在里面。