论Apache Spark中大数据处理的某些方面——序列化

208 阅读14分钟

许多初学Spark的程序员在尝试将Spark应用分解成Java类时,会遇到 "任务不可序列化 "的异常。有很多帖子 指导开发者如何解决这个问题。此外,还有一些关于Spark的优秀概述。尽管如此,我认为还是值得看看Spark的源代码,看看任务在哪里以及如何被序列化,并抛出这样的异常,以便更好地理解这些指令。

这篇文章的组织结构如下。

  • 在第1节,我简要回顾了Spark架构和Spark运行模式
  • 在第2节中,我回顾了什么是RDD以及我们可以对其进行哪些操作。
  • 在第3节中,我回顾了Spark如何从逻辑计划中创建一个物理计划。
  • 在第4节中,我演示了物理计划是如何被执行的,以及Spark任务是在哪里和如何被序列化的
  • 最后,在第5节中,我将所有这些总结为简单的规则,以避免例外。

让我们回顾一下Spark的架构和术语。

1.Spark架构和运行模式

Apache Spark是一个统一的计算引擎和一套用于在计算机集群上进行并行数据处理的库(详细阐述请参考J-G Perrin的Sparkin Action》和D:D. Chambers和M. Zaharia的 "Spark:The Definitive Guide")。在算法上,并行计算是在作业中完成的。一个作业由阶段组成,而一个阶段由任务组成。任务是最小的单个执行单元的抽象(后面会有更多关于这些的内容)。

软件架构方面,每个Spark应用都由一个驱动和一组分布式工作进程(Executor)组成(见图1)。驱动程序运行我们的应用程序的main()方法,它是创建Spark Context(为简洁起见,sc)的地方。司机运行在我们集群中的一个节点上,或在我们的客户端上,并通过集群管理器来安排作业的执行方式。同时,驱动程序还分析、安排和分配各执行器的工作。 image.png 图1:Spark架构

执行者是一个执行任务的分布式进程。每个Spark应用的执行器在Spark应用的生命周期内保持活力。执行器处理一个Spark作业的所有数据。同时,Executor将结果存储在内存中,只有在Driver特别指示的情况下才将结果持久化到磁盘上。最后,执行者将计算结果返回给驱动。每个工作节点可以有一个或多个执行器。

为了处理大量的数据,Spark将数据划分为多个分区。为了有效地处理数据,Spark试图在同一个分区上做尽可能多的计算。当绝对需要合并多个分区时,Spark会将分区写入磁盘(后面会有更多介绍)。

一个典型的Spark应用遵循以下步骤。

  1. 应用程序初始化一个Spark Context的实例。
  2. 驱动程序要求集群管理器分配必要的资源来运行该应用程序。
  3. 集群管理器启动执行器。
  4. 驱动程序运行我们的Spark代码。
  5. 执行器运行代码并将结果反馈给驱动。
  6. Spark Context被停止,所有的Executors被关闭,集群管理器重新获得资源。

一个Spark应用程序可以在3种模式下运行。本地模式、客户端模式和集群模式。在本地模式下,所有的Spark组件都在一台机器上运行。该模式通过该单机上的线程实现并行化。在客户端模式下,客户端机器维护驱动程序,而集群维护执行器。在集群模式下,用户提交一个jar文件给集群管理器。管理器然后在一个工作节点上启动驱动程序,此外还有执行器进程。

为了研究这些组件如何工作和序列化任务,让我们回顾一下弹性分布式数据结构(RDD),即Spark的主要数据抽象,是如何在内部运作的。

2.RDD:主要抽象

根据Spark的源代码文档,RDD代表了一个不可变的、可并行操作的元素分区集合。这些元素(或记录)只是程序员选择的Java或Scala对象。数据集和DataFrames包裹着RDD。与RDDs相反,Datasets和DataFrames包含记录,其中每个记录都是具有已知模式的结构化行。

每个RDD都包含以下内容。

  1. 一个分区列表(实际上是分区索引:由索引访问的实际数据存储在每个节点中)。
  2. 一个计算每个分割的函数(一个分割是每个节点中的一大块分割的数据)。
  3. 一个对其他RDDs的依赖性列表
  4. 可选的,一个用于键值RDDs的分区器(此处不作讨论)
  5. 可选的,计算每个分割的首选位置列表(相同的)

RDD可以进行转换和操作(图2)。一个转换接受一个RDD并返回另一个RDD。一个动作接受一个RDD,但不返回一个RDD。例如,一个动作可以将一个RDD保存到磁盘,或者返回RDD中的行数。例如,RDD.count() 动作返回 RDD 中的元素数量。在内部,该动作调用Spark上下文来初始化一个新作业。

def count(): Long = sc.runJob(this, Utils.getIteratorSize _).sum

这里,第一个参数是当前的RDD,第二个参数是一个处理分区的函数。我们将在后面的章节中详细讨论这个问题。让我们再来看看转换。 image.png 图 2: RDD 操作

有狭义和广义的转换。在狭义转换中,计算单个分区中的记录所需的所有数据元素都位于父RDD的单个分区中。 在广义转换中,计算单个分区中的记录所需的所有数据元素可能存在于父RDD的许多分区中,因此分区需要被洗牌以完成转换。转换是懒惰的:只有当我们调用一个动作时,它们才会被执行。让我们看看RDD转换是如何工作的。

所有的RDD都扩展了一个抽象类RDD。该类包含所有RDD可用的基本操作。例如,让我们看看RDD.map(...) 方法是如何工作的(图3)。让我们的rddIn 是一个字符串的集合(每个字符串都由带有""分隔线的单词组成)。我们想把这个集合映射到一个集合rddOut = rddIn.map(x->x.split(" ").length)image.png 图 3: 一些 RDD 操作

RDD.map(f) 当调用f=x->x.split(" ").length 的地方,Spark Context会清理回调f (图3A)。 根据SparkContext.scala文档sc.clean(f) ,清理回调的闭包,使回调准备好被序列化并发送给任务。清理器从回调的外部范围中删除未使用的变量,并更新 REPL 变量(命令行变量;在这篇文章中我们不关心)。sc.clean(f) 调用 ClosureCleaner.ensureSerializable(f).在这里,可能会抛出一个可怕的 "任务不可序列化 "的异常(图 3C)。

如果回调是可序列化的,rddIn.map(f) 返回rddOut = new MapPartitionsRDD(rddIn, f) ,其中rddIn 成为rddOut 的父 RDD。rddOut.compute 方法在rddInrddOut 的父 RDD;图 3B)上执行回调 f。转换完成。

到目前为止,我们看到,**只有当转换回调和回调的闭包不可序列化时,Spark应用程序才会抛出 "任务不可序列化 "异常。**由此可见,如果我们把一个Spark作业分割成POJO,即使POJO的字段不可序列化,只要不可序列化的字段不干扰转换回调及其闭包,该作业运行时也不会出现异常情况。

我们很容易看到多个转换是如何形成一个直接无环图的。每个转换都会创建一个新的 RDD,其中父 RDD 是被转换的那个。没有产生RDD的行为是DAG的叶子。这样一个RDD的DAG被称为逻辑执行计划 (详见Jacek Laskowski的"RDD脉络--逻辑执行计划"和更多例子)。让我们继续往下看,如何从逻辑计划中创建物理执行计划,以检查制定的序列化规则是否成立。

3.物理计划:工作、阶段和任务

为了计算逻辑计划,驱动程序从逻辑计划中创建了一组作业、阶段和任务。这样一个集合被称为物理执行计划。什么是作业、阶段和任务?让我们从作业开始。

实际的作业类被称为ActiveJob(图4)。该类有一个jobId: Int ,一个finalStage: Stage ,一个listener: JobListener ,还有一些其他字段。 工作只对计算过程的 "叶子"(或最终)阶段(没有RDDs)进行跟踪(后面会有更多介绍)。 有两种类型的阶段。 image.png 图4:ActiveJob类(为简洁起见,其他字段和方法被省略了)

第一种阶段被称为ShuffleMapStage,第二种阶段被称为ResultStage 。图5说明了这两种阶段的区别(详见"什么是Spark的Shuffle")。这里,系统从数据库1上传数据到3个分区。然后,数据经过狭义转换(map),再经过广义转换(reduceByKey)。最后,转换后的数据被保存到另一个数据库2。因此,转换包括在ShuffleMapStage中,该阶段在广义(或shuffle)转换中结束。另一方面,ResultStage是由一个动作组成的:保存。 image.png 图5:一个简单的两阶段Spark作业的例子

让我们来看看一个ShuffleMapStage (图6)。 根据源代码文档

"ShuffleMapStages是执行DAG中的中间阶段,为shuffle产生数据。它们出现在每个shuffle操作之前,并且在这之前可能包含多个流水线操作(例如map和filter)。当执行时,它们会保存地图输出文件,以后可以被还原任务获取。'shuffleDep'字段描述了每个阶段是洗牌的一部分。"

依赖关系包含一个父RDD、一个序列化器、一个聚合器、一个writeProcessor等(完成该阶段所需的方法)。同时,该阶段包含id:Int,rdd: RDD, 和父阶段parents: List[Stage],mapStageJobs: Seq[ActiveJob] 。这个阶段可以被多个作业调用。 image.png 图6:Spark阶段(为了简洁起见,省略了其他字段、方法和阶段构造函数参数)

对于一个ResultStage (图6),ResultStage的源代码文档中说。

"ResultStages在RDD的一些分区上应用一个函数来计算一个动作的结果。ResultStage 对象捕获了要执行的函数,'func',它将被应用于每个分区,以及分区 ID 的集合,'partitions'。有些阶段可能不会在RDD的所有分区上运行,比如first()和lookup()等动作。"

另外,该阶段包含id:Intpartitions: Array[Int] (这些是分区索引),rdd: RDD ,以及父阶段parents: List[Stage]activeJob: ActiveJob (该结果阶段的活动工作)。最后,还有提到的func: (TaskContext, Iterator[_]) => _ ,在每个分区上应用。请注意,阶段和工作都没有直接提到任何任务。

有两种任务 -ShuffleMapTaskResultTask (图7)。

"一个ShuffleMapTask将一个RDD的元素分成多个桶(基于ShuffleDependency中指定的分区器)。"

一个ResultTask将任务输出发回给驱动应用程序。两个任务都有一个stageId: InttaskBinary: Broadcast[Array[Byte]] ,一个要执行的二进制文件,以及partition: Partition ,该任务所关联的分区。一个ResultTask也有一个outputId: Int 字段,即其工作中的任务索引。关于这些元素的更多细节,请参见"Apache Spark 3.3.0的内部"image.png 图7:Spark任务(为了简洁起见,省略了其他字段、方法和任务构造器参数)

为了回答我们的序列化问题,我们需要研究这个taskBinary 是如何由Driver产生的,因为Driver是从一个逻辑计划中创建任务的。

4.任务执行

在第2节中,我们看到了一个简单的例子,即RDD.count() 动作如何调用spark context(SC)来启动一个新的作业。让我们看看在一般情况下接下来会发生什么(图8)。 image.png image.png

图8.火花作业的工作流程(省略的方法参数是(...),省略的代码是//---//)。

因此,SC调用DAGScheduler.runJob(rdd, func, partitions: Seq[Int],...) ,其中func 处理分区。然后,DAGScheduler 调用submitJob ,使用相同的rdd、func和分区。接下来,submitJob 方法发布了一个JobSubmittedEvent :该事件被doOnReceive 监听器捕获。倾听者用同样的rdd、func和分区调用handleJobSubmitted 方法。正是在这个方法中,最后阶段被创建,一个新的ActiveJob从这个阶段被创建。接下来,最终阶段被提交给submitStage 方法。这个方法反过来又递归地调用逻辑计划中所有父级阶段的submitMissingTasks(finalStage,..)方法。在这里,任务被创建。

DAGScheduler.submitMissingTasks 方法从阶段RDDs和阶段procession函数中创建taskBinary (图9A)。为此,该方法调用closureSerialer.serialize(stage.rdd, stage.func) ,其中closureSerializer 是我们熟悉的SparkEnv.get.closureSerializer.newInstance()image.png 图9:submitMissedTasks和 launchTasks方法(省略的方法参数是(...),省略的代码部分是//--//)

正如我们已经看到的,如果操作回调及其闭包是可序列化的,序列化器就不会抛出 "任务未序列化的异常"。 TaskSet 在任务被序列化后,submitMissingTasks 方法从这些taskBinary ,并将该集合提交给一个taskScheduler (图8)。

接下来,我将描述在本地模式下所发生的事情。taskScheduler.submitTasks 调用localSchedulerBackend.reviveOffers() 来调用TaskSetManager.prepareLaunchingTasks(...) 来创建一个TaskDescription 。该描述包含serializedTask: ByteBuffer 和运行任务的数据。最后,这些任务描述被提交给Executor.launchTask(executorBackend.task)

在执行者一方(图9B),执行者用提交的任务描述创建一个TaskRunner 的实例。该任务运行器在Spark线程池中执行。这就是了!让我们总结一下我们学到的关于Spark序列化的内容。

5.Spark中的任务序列化

所以,我们看到,为了在RDD上进行计算,Spark驱动会序列化两样东西:RDD数据RDD操作(转换或动作)回调(以及回调的闭合)。你不妨再参考一下本文第一篇链接的Michael Knopf关于JVM如何处理闭包和序列化对象的文章。 很明显,数据应该是可序列化的。至于回调(和它的闭包),我们可能需要Spark工具来对数据进行CRUD操作。这些工具包括。SparkSession、SparkContext、HadoopConfiguration、HiveContext,等等。在这些对象中,只有SparkSession是可序列化的,并且可以在自定义POJO中设置为一个类字段。那么,我们如何实际地使用其他工具呢?

一种方法是将它们作为类方法中的局部变量。比如说。

Scala

Dataset<Row> transform(Dataset<Row> ds){
	SparkContext sparkContext = this.sparkSession.sparkContext();
    HiveContext hiveContext = new HiveContext(sparkContext);
  	JavaRDD<Row> resultRDD = //transformations that use the hiveContext go here
  return resultRDD.toDS();
} 

是的,这些变量在每次调用方法时都会被创建,但这样我们就可以摆脱 "任务不可序列化 "的异常。另外,一些配置可能会通过SerializableConfiguration 工具来实现可序列化。

请注意,有时你可能有一个POJO,其中有不可序列化的字段,如SparkContext,而应用程序仍然运行。例如,数据被简单地从一个数据库上传并保存到另一个数据库中,没有任何转换。 应用程序运行得很好,因为没有任何转换或动作回调需要序列化!

所以,序列化规则是。

  1. 在POJO方法中使用不可序列化的对象作为局部变量。
  2. lambda表达式中,不要引用不可序列化的对象,因为Driver会尝试序列化这些对象来建立一个闭包。
  3. 如果可能的话,通过类似SerializableConfiguration的工具来序列化对象。

结论

在这篇文章中,我演示了Spark如何序列化任务来执行它们。我列出了简单的规则,以避免 "任务不可序列化 "的异常。此外,我还介绍了一种情况,即Spark应用程序即使在不可序列化的POJO字段中也可以工作。希望能在第二部分见到你,在那里我将介绍以灵活和可维护的方式构建Spark应用程序的设计模式。