许多初学者 Spark 程序员在尝试将 Spark 应用程序分解为 Java 类时遇到“任务不可序列化”异常。有许多帖子 指导开发人员如何解决这个问题。此外,还有对 Spark 的出色概述。尽管如此,我认为查看 Spark 源代码以了解任务在何处以及如何被序列化以及抛出此类异常以更好地理解这些指令是值得的。
这篇文章的组织如下:
- 在第 1 节中,我简要回顾了Spark 架构和 Spark 运行模式。
- 在第 2 节中,我回顾了 RDD 是什么以及我们可以对其进行哪些操作。
- 在第 3 节中,我回顾了 Spark 如何根据逻辑计划创建物理计划。
- 在第 4 节中,我将演示如何执行物理计划以及Spark 任务在何处以及如何进行序列化。
- 最后,在第 5 节中,我将所有这些总结为避免例外的简单规则。
让我们回顾一下 Spark 架构和术语。
1. Spark 架构和运行模式
Apache Spark是一个统一的计算引擎和一组用于在计算机集群上进行并行数据处理的库(详细阐述请参考JG Perrin 的“ Spark in Action ”和D. Chambers 和 M. Zaharia的“ Spark: The Definitive Guide ”) . 从算法上讲,并行计算是在作业中完成的。作业由阶段组成,阶段由任务组成。任务是最小的单个执行单元的抽象(稍后会详细介绍)。
在软件架构方面,每个 Spark 应用程序都由一个Driver和一组分布式工作进程(Executors)组成(见图 1)。Driver 运行我们应用程序的 main() 方法,它是创建Spark 上下文(为简洁起见为 sc)的地方。Driver 在我们集群中的一个节点上运行,或者在我们的客户端上运行,并使用集群管理器调度作业的执行方式。此外,Driver 会在 Executor 之间分析、安排和分配工作。
图 1:Spark 架构
Executor 是一个执行任务的分布式进程。每个 Spark 应用程序的 Executor 在 Spark 应用程序的生命周期内都保持活跃。执行器处理一个 Spark 作业的所有数据。此外,Executor 将结果存储在内存中,并仅在 Driver 明确指示时将结果保存到磁盘。最后,Executor 将计算结果返回给 Driver。每个Worker Node可能有一个或多个 Executor。
为了处理大量数据,Spark 将数据划分为多个分区。为了有效地处理数据,Spark 尝试在同一个分区上进行尽可能多的计算。当绝对需要合并多个分区时,Spark 会将分区写入磁盘(稍后会详细介绍)。
典型的 Spark 应用程序遵循以下步骤:
- 应用程序初始化Spark Context的一个实例。
- 驱动程序要求集群管理器分配必要的资源来运行应用程序。
- 集群管理器启动执行器。
- Driver 运行我们的 Spark 代码。
- Executor 运行代码并将结果发送回 Driver。
- Spark Context 停止,所有 Executor 都关闭,Cluster manager 回收资源。
Spark 应用程序可以在 3 种模式下运行:本地、客户端和集群。在本地模式下,所有 Spark 组件都在单台机器上运行。该模式通过单台机器上的线程实现并行。在 Client 模式下,客户端机器维护 Driver,集群维护 Executor。在集群模式下,用户将 jar 文件提交给集群管理器。除了 Executor 进程之外,管理器随后还会在工作节点上启动 Driver。
为了研究这些组件如何工作和序列化任务,让我们回顾一下弹性分布式数据结构 (RDD),即 Spark 主要数据抽象,如何在内部发挥作用。
2. RDD:初级抽象
根据Spark 源代码文档,RDD 表示可以并行操作的元素的不可变分区集合。元素(或记录)只是程序员选择的 Java 或 Scala 对象。数据集和数据帧包装 RDD。与 RDD 相比,Datasets 和 DataFrames 包含记录,其中每条记录都是具有已知模式的结构化行。
每个 RDD 包含以下内容:
- 分区列表(实际上是分区索引:索引访问的实际数据存储在每个节点中)
- 计算每个拆分的函数(拆分是每个节点中的一块分区数据)
- 对其他 RDD 的依赖列表
- 可选的,用于键值 RDD 的分区器(此处未讨论)
- (可选)计算每个拆分的首选位置列表(相同)
RDD 可以进行转换和操作(图 2)。转换接受一个 RDD 并返回另一个 RDD。一个动作接受一个 RDD 并且不返回一个 RDD。例如,一个操作可以将 RDD 保存到磁盘,或者返回 RDD 中的行数。例如,该RDD.count()
操作返回 RDD 中的元素数。该操作在内部调用 Spark 上下文来初始化新作业:
def count(): Long = sc.runJob(this, Utils.getIteratorSize _).sum
这里,第一个参数是当前 RDD,第二个是处理分区的函数。我们将在后面的部分中详细讨论这一点。让我们回到转换。
图 2:RDD 操作
有狭窄和广泛的转变。在窄转换中,计算单个分区中的记录所需的所有数据元素都位于父 RDD 的单个分区中。在广泛的转换中,计算单个分区中的记录所需的所有数据元素可能存在于父 RDD 的许多分区中,因此需要对分区进行打乱以完成转换。转换是惰性的:它们只有在我们调用动作时才会执行。让我们看看 RDD 转换是如何在幕后工作的。
所有 RDD 都扩展了一个抽象类 RDD。此类包含所有 RDD 可用的基本操作。例如,让我们看看该RDD.map(...)
方法是如何工作的(图 3)。让我们rddIn
成为字符串的集合(每个都由带有“”分隔符的单词组成)。我们想将此集合映射到一个集合rddOut = rddIn.map(x->x.split(" ").length)
。
图 3:一些 RDD 操作
当调用RDD.map(f)
where时f=x->x.split(" ").length
,Spark 上下文会清除回调f
(图 3A)。根据SparkContext.scala 文档,sc.clean(f)
清理回调的闭包并使回调准备好被序列化并发送到任务。清理器从回调的外部范围中删除未使用的变量并更新 REPL 变量(命令行变量;不是我们在这篇文章中关注的)。sc.clean(f)
来电ClosureCleaner.ensureSerializable(f)。在那里可能会抛出可怕的“Task not serializable”异常(图 3C)。
如果回调是可序列化的,则rddIn.map(f)
返回rddOut = new MapPartitionsRDD(rddIn, f)
,其中rddIn
成为 的父 RDD rddOut
。 方法在(的父 RDDrddOut.compute
上执行回调 f ;图 3B)。转变完成。rddIn
rddOut
到目前为止,我们看到只有在转换回调和回调的闭包不可序列化时,Spark 应用程序才会抛出“Task not serializable”异常。因此,如果我们将 Spark 作业拆分为 POJO,作业将毫无例外地运行,即使 POJO 的字段不可序列化,只要不可序列化的字段不会干扰转换回调及其闭包。
很容易看出多个变换是如何形成有向无环图的。每个转换都会创建一个新的 RDD,其中父 RDD 是正在转换的 RDD。不产生 RDD 的动作是 DAG 的叶子。这种 RDD 的 DAG 称为逻辑执行计划 (有关详细信息和更多示例,请参阅 Jacek Laskowski 的“ RDD Lineage — Logical Execution Plan ”)。让我们继续看看如何从逻辑计划创建物理执行计划,以检查制定的序列化规则是否成立。
3. 物理计划:工作、阶段和任务
为了计算逻辑计划,驱动程序从逻辑计划中创建一组作业、阶段和任务。这样的集合称为物理执行计划。什么是工作、阶段和任务?让我们从工作开始。
实际的作业类称为ActiveJob(图 4)。该类具有 a jobId: Int
、 a finalStage: Stage
、 alistener: JobListener
和其他一些字段。仅针对计算过程的“叶子”(或最终)阶段(并且没有 RDD)跟踪作业(稍后会详细介绍)。有两种阶段。
图 4:ActiveJob 类(为简洁起见,省略了其他字段和方法)
第一种 Stage 被称为ShuffleMapStage
,第二种是ResultStage
。这两个阶段的区别如图 5 所示(详见“什么是 Spark 的 Shuffle ”)。在这里,系统将数据库 1 中的数据上传到 3 个分区中。然后数据经过一个窄变换(map),然后是一个宽变换(reduceByKey)。最后,转换后的数据被保存到另一个数据库 2。因此,转换包含在 ShuffleMapStage 中,阶段结束于宽(或随机)转换。另一方面,ResultStage 由一个动作组成:保存。
图 5:简单的两阶段 Spark 作业示例
让我们看一下ShuffleMapStage
(图6)。根据源代码文档:
“ShuffleMapStages 是执行 DAG 中的中间阶段,为 shuffle 生成数据。它们发生在每个 shuffle 操作之前,并且可能在此之前包含多个流水线操作(例如 map 和 filter)。执行时,它们保存 map 输出文件,以后可以由reduce任务获取。'shuffleDep'字段描述了每个阶段所属的shuffle。
该依赖包含一个父 RDD、一个序列化器、一个聚合器、一个 writeProcessor 等(完成该阶段所需的方法)。此外,该阶段包含id:Int
、rdd: RDD
和父阶段parents: List[Stage]
、mapStageJobs: Seq[ActiveJob]
。这个阶段可以被多个作业调用。
图 6:Spark Stages(为简洁起见,省略了其他字段、方法和 Stage 构造函数参数)
对于ResultStage
(图 6),ResultStage 源代码文档说:
“ResultStages 在 RDD 的某些分区上应用一个函数来计算一个动作的结果。ResultStage 对象捕获要执行的函数‘func’,它将应用于每个分区,以及一组分区 ID,‘partitions。 ' 对于 first() 和 lookup() 等操作,某些阶段可能不会在 RDD 的所有分区上运行。”
此外,该阶段包含id:Int
、partitions: Array[Int]
(这些是分区索引)rdd: RDD
、 和父阶段parents: List[Stage]
,activeJob: ActiveJob
(此结果阶段的活动作业)。最后,还有提到func: (TaskContext, Iterator[_]) => _
的适用于每个分区。请注意,阶段和作业都没有直接引用任何任务。
有两种任务 -ShuffleMapTask
和ResultTask
(图 7)。根据ShuffleMapTask 源代码文档:
“ShuffleMapTask 将 RDD 的元素分成多个桶(基于 ShuffleDependency 中指定的分区器)。”
ResultTask将任务输出发送回驱动程序应用程序。这两个任务都有一个stageId: Int
, taskBinary: Broadcast[Array[Byte]]
, 一个要执行的二进制文件, 和partition: Partition
, 任务关联的分区。ResultTask 也有一个outputId: Int
字段,即任务在其作业中的索引。有关这些元素的更多详细信息, 请参阅“Apache Spark 3.3.0 的内部结构” 。
图 7:Spark 任务(为简洁起见,省略了其他字段、方法和任务构造函数参数)
为了回答我们的序列化问题,我们需要研究taskBinary
驱动程序是如何产生序列化的,因为驱动程序会根据逻辑计划创建任务。
4. 作业执行
在第 2 节中,我们看到了一个简单的示例,说明RDD.count()
操作如何调用 spark 上下文 (SC) 来启动新作业。让我们看看一般情况下接下来会发生什么(图 8)。
图 8. Spark 作业工作流程(省略的方法参数是 (...),省略的代码片段是 //---//)
因此,SC 调用DAGScheduler.runJob(rdd, func, partitions: Seq[Int],...)
,其中func
处理分区。然后是具有相同 rdd、func 和 partitions的DAGScheduler
调用。submitJob
接下来,该submitJob
方法发出一个JobSubmittedEvent
:事件被doOnReceive
监听器捕获。侦听器调用handleJobSubmitted
具有相同 rdd、func 和 partitions 的方法。正是在这种方法中创建了最后一个阶段,并从此阶段创建了一个新的 ActiveJob。接下来,将最后阶段提交给该submitStage
方法。反过来,此方法在submitMissingTasks(finalStage,..)
逻辑计划的所有父级上递归调用该方法。在这里,创建任务。
该DAGScheduler.submitMissingTasks
方法创建taskBinary
了阶段外的 RDD 和阶段处理函数(图 9A)。对于这个,方法调用closureSerialer.serialize(stage.rdd, stage.func)
,closureSerializer
我们熟悉的在哪里SparkEnv.get.closureSerializer.newInstance()
。
图 9:submitMissedTasks 和 launchTasks 方法(省略的方法参数是 (...),省略的代码片段是 //--//)
正如我们已经看到的,如果操作回调及其闭包是可序列化的,则序列化程序不会抛出“任务未序列化异常”。任务序列化后,该submitMissingTasks
方法会TaskSet
从这些中创建一个taskBinary
,并将该集合提交给 a taskScheduler
(图 8)。
接下来,我描述在本地模式下会发生什么。taskScheduler.submitTasks
调用localSchedulerBackend.reviveOffers()
来TaskSetManager.prepareLaunchingTasks(...)
创建TaskDescription
一个. 描述包含serializedTask: ByteBuffer
运行任务的数据。最后,这些 taskDescriptions 被提交到Executor.launchTask(executorBackend.task)
.
在 Executor 端(图 9B),ExecutorTaskRunner
使用提交的 taskDescription 创建一个实例。任务运行器在 Spark 线程池中执行。就是这个!让我们总结一下我们对 Spark 序列化的了解。
5. Spark中的任务序列化
因此,我们看到为了对 RDD 进行计算,Spark Driver 序列化了两件事:RDD 数据和RDD 操作(转换或动作)回调(以及回调的闭包)。您可能希望再次参考 Michael Knopf 撰写的这篇文章的第一篇链接文章,了解 JVM 如何处理闭包和序列化对象。显然,数据应该是可序列化的。至于回调(及其闭包),我们可能需要 Spark 工具对数据进行 CRUD 操作。工具包括:SparkSession、SparkContext、HadoopConfiguration、HiveContext等。在这些对象中,只有SparkSession是可序列化的,可以在自定义POJO中设置为类字段。那么,我们如何实际使用其他工具呢?
一种方法是将它们用作类方法中的局部变量。例如:
数据集<行> 变换(数据集<行> ds){
<行> 变换(数据集<行> ds){
SparkContext sparkContext = 这个。火花会话。火花上下文();
SparkContext sparkContext = 这个。火花会话。火花上下文();
HiveContext hiveContext = new HiveContext ( sparkContext );
HiveContext hiveContext = new HiveContext ( sparkContext );
JavaRDD < Row > resultRDD = //使用 hiveContext 的转换到这里
JavaRDD < Row > resultRDD = //使用 hiveContext 的转换到这里
返回 结果RDD。toDS ();
返回 结果RDD。toDS ();
}
是的,每次调用方法时都会创建变量,但是这样我们就摆脱了“任务不可序列化”异常。此外,一些配置可以通过SerializableConfiguration
实用程序进行序列化。
请注意,有时您可能有一个带有不可序列化字段的 POJO,例如 SparkContext,并且应用程序仍在运行。例如,数据只是从一个数据库上传并保存到另一个数据库中而无需任何转换。应用程序运行良好,因为没有要序列化的转换或操作回调!
所以,序列化规则是:
- 在 POJO 方法中使用不可序列化的对象作为局部变量。
- 在lambda-expressions中,不要引用不可序列化的对象,因为 Driver 会尝试序列化这些对象以构建闭包。
- 如果可能,请通过类似 SerializableConfiguration 的实用程序序列化对象。
结论
在这篇文章中,我演示了 Spark 如何序列化任务以执行它们。我列出了简单的规则来避免“任务不可序列化”异常。此外,我还介绍了一个 Spark 应用程序甚至可以使用不可序列化的 POJO 字段的情况。希望在第 2 部分见到您,我将介绍设计模式,以灵活且可维护的方式构建 Spark 应用程序。
原文地址:Big Data Processing in Apache Spark: Serialization - DZone Big Data