spark技术内幕深入解析spark内核架构设计与实现原理


大数据技术丛书 Spark技术内幕:深入解析Spark内核架构设计与实现原理 张安站 著 ISBN:978-7-111-50964-6 本书纸版由机械工业出版社于2015年出版,电子版由华章分社(北京华章图文信息有限公司,北京奥维博世图书发行有限公 司)全球范围内制作与发行。 版权所有,侵权必究 客服热线:+ 86-10-68995265 客服信箱:service@bbbvip.com 官方网址:www.hzmedia.com.cn 新浪微博 @华章数媒 腾讯微博 @yanfabook 微信公众号 华章电子书(微信号:hzebook) 目录 序 前言 第1章 Spark简介 1.1 Spark的技术背景 1.2 Spark的优点 1.3 Spark架构综述 1.4 Spark核心组件概述 1.4.1 Spark Streaming 1.4.2 MLlib 1.4.3 Spark SQL 1.4.4 GraphX 1.5 Spark的整体代码结构规模 第2章 Spark学习环境的搭建 2.1 源码的获取与编译 2.1.1 源码获取 2.1.2 源码编译 2.2 构建Spark的源码阅读环境 2.3 小结 第3章 RDD实现详解 3.1 概述 3.2 什么是RDD 3.2.1 RDD的创建 3.2.2 RDD的转换 3.2.3 RDD的动作 3.2.4 RDD的缓存 3.2.5 RDD的检查点 3.3 RDD的转换和DAG的生成 3.3.1 RDD的依赖关系 3.3.2 DAG的生成 3.3.3 Word Count的RDD转换和DAG划分的逻辑视图 3.4 RDD的计算 3.4.1 Task简介 3.4.2 Task的执行起点 3.4.3 缓存的处理 3.4.4 checkpoint的处理 3.4.5 RDD的计算逻辑 3.5 RDD的容错机制 3.6 小结 第4章 Scheduler模块详解 4.1 模块概述 4.1.1 整体架构 4.1.2 Scheduler的实现概述 4.2 DAGScheduler实现详解 4.2.1 DAGScheduler的创建 4.2.2 Job的提交 4.2.3 Stage的划分 4.2.4 任务的生成 4.3 任务调度实现详解 4.3.1 TaskScheduler的创建 4.3.2 Task的提交概述 4.3.3 任务调度具体实现 4.3.4 Task运算结果的处理 4.4 Word Count调度计算过程详解 4.5 小结 第5章 Deploy模块详解 5.1 Spark运行模式概述 5.1.1 local 5.1.2 Mesos 5.1.3 YARN 5.2 模块整体架构 5.3 消息传递机制详解 5.3.1 Master和Worker 5.3.2 Master和Client 5.3.3 Client和Executor 5.4 集群的启动 5.4.1 Master的启动 5.4.2 Worker的启动 5.5 集群容错处理 5.5.1 Master异常退出 5.5.2 Worker异常退出 5.5.3 Executor异常退出 5.6 Master HA实现详解 5.6.1 Master启动的选举和数据恢复策略 5.6.2 集群启动参数的配置 5.6.3 Curator Framework简介 5.6.4 ZooKeeperLeaderElectionAgent的实现 5.7 小结 第6章 Executor模块详解 6.1 Standalone模式的Executor分配详解 6.1.1 SchedulerBackend创建AppClient 6.1.2 AppClient向Master注册Application 6.1.3 Master根据AppClient的提交选择Worker 6.1.4 Worker根据Master的资源分配结果创建Executor 6.2 Task的执行 6.2.1 依赖环境的创建和分发 6.2.2 任务执行 6.2.3 任务结果的处理 6.2.4 Driver端的处理 6.3 参数设置 6.3.1 spark.executor.memory 6.3.2 日志相关 6.3.3 spark.executor.heartbeatInterval 6.4 小结 第7章 Shuffle模块详解 7.1 Hash Based Shuffle Write 7.1.1 Basic Shuffle Writer实现解析 7.1.2 存在的问题 7.1.3 Shuffle Consolidate Writer 7.1.4 小结 7.2 Shuffle Pluggable框架 7.2.1 org.apache.spark.shuffle.ShuffleManager 7.2.2 org.apache.spark.shuffle.ShuffleWriter 7.2.3 org.apache.spark.shuffle.ShuffleBlockManager 7.2.4 org.apache.spark.shuffle.ShuffleReader 7.2.5 如何开发自己的Shuffle机制 7.3 Sort Based Write 7.4 Shuffle Map Task运算结果的处理 7.4.1 Executor端的处理 7.4.2 Driver端的处理 7.5 Shuffle Read 7.5.1 整体流程 7.5.2 数据读取策略的划分 7.5.3 本地读取 7.5.4 远程读取 7.6 性能调优 7.6.1 spark.shuffle.manager 7.6.2 spark.shuffle.spill 7.6.3 spark.shuffle.memoryFraction和spark.shuffle.safetyFraction 7.6.4 spark.shuffle.sort.bypassMergeThreshold 7.6.5 spark.shuffle.blockTransferService 7.6.6 spark.shuffle.consolidateFiles 7.6.7 spark.shuffle.compress和spark.shuffle.spill.compress 7.6.8 spark.reducer.maxMbInFlight 7.7 小结 第8章 Storage模块详解 8.1 模块整体架构 8.1.1 整体架构 8.1.2 源码组织结构 8.1.3 Master和Slave的消息传递详解 8.2 存储实现详解 8.2.1 存储级别 8.2.2 模块类图 8.2.3 org.apache.spark.storage.DiskStore实现详解 8.2.4 org.apache.spark.storage.MemoryStore实现详解 8.2.5 org.apache.spark.storage.TachyonStore实现详解 8.2.6 Block存储的实现 8.3 性能调优 8.3.1 spark.local.dir 8.3.2 spark.executor.memory 8.3.3 spark.storage.memoryFraction 8.3.4 spark.streaming.blockInterval 8.4 小结 第9章 企业应用概述 9.1 Spark在百度 9.1.1 现状 9.1.2 百度开放云BMR的Spark 9.1.3 在Spark中使用Tachyon 9.2 Spark在阿里 9.3 Spark在腾讯 9.4 小结 序 Apache Spark项目的高速发展超出了很多人的预期。在2009年到2013年,Spark还是UC Berkeley大学AMPLab的一个研究项目, 因为其架构设计得简洁和高效,逐渐吸引了工业界和学术界的广泛关注。我还记得2013年2月份在Santa Clara召开的Strata Conference上,虽然是长达一整天的Spark技术培训,大厅里还是人满为患,大家都在认真学习这种新的计算框架,并被其高速 的性能所折服。尽管在随后的一年半时间内,主流的Hadoop厂商并没有接受这个新框架:Cloudera在忙着开发自己的Impala引 擎,Hortonworks经过评估后认为可以改造Map/Reduce来实现类似Spark的DAG机制,也就是后来的Tez,而MapR还在纠结是否 要全力投入Drill项目。但在2014年的夏天,第二次Spark Summit召开时,已经在Spark上积累大量开发者和用户,从互联网到传 统行业,甚至是生物神经学家都用Spark来分析脑活动的数据。在这次会议上,大部分的Hadoop厂商以及应用开发商开始接受 Spark,并宣布支持Spark作为Hadoop上的另一个计算引擎。自此以后,Spark的被接受程度飞速提高。到2014年10月份,几乎 所有的大数据厂商都宣布支持Spark,Spark作者们创办的DataBricks公司也宣布认证了50多个以Spark为基础的应用系统。而到 了2015年,大家在谈论的是Spark即将全面替代Hadoop中的Map/Reduce。 星环科技从2013年创业的第一天,就开始改造Spark引擎来开发批处理和交互式分析引擎。今天在星环的全系列产品中,已经 几乎看不到Map/Reduce计算框架。星环科技已经证明了在所有Map/Reduce擅长的领域,Spark计算引擎都可以更高效地执行, 性能可以提升数倍到数十倍,并且可以7x24稳定运行。这也从侧面证明了Spark引擎的潜力。 本书详细剖析了Spark核心引擎的源代码及其工作原理,内容翔实准确,也是我目前看到的一本比较全面解析Spark Core的不可 多得的好书。特别是有志于Spark内核开发的研发人员,仔细阅读本书并研读代码,将起到事半功倍的效果。 孙元浩 星环科技创始人兼CTO 2015年8月上海 前言 诞生于2005年的Hadoop解决了大数据的存储和计算问题,已经成为大数据处理的事实标准。但是,随着数据规模的爆炸式增 长和计算场景的丰富细化,使得Hadoop越来越难以满足用户的需求。针对不同的计算场景,开源社区和各大互联网公司也推 出了各种大数据分析的平台,旨在满足特定应用场景下的计算需求。但是,众多的平台使得用户不得不为平台开发类似的策 略,这增加了运维开发成本。 2009年诞生于AMPLab的Spark,它的设计目标就是为大数据的多种计算场景提供一个通用的计算引擎,同时解决大数据处理的 4V难题,即Volume(海量)、Velocity(快速)、Variety(多样)、Value(价值)。正如Spark的核心作者之一的Ion Stoica所 说,“The goal is to build a new generation of data analytics software,to be used across academia and industry。”Hadoop之父Doug Cutting也 说过,MapReduce引擎将被Spark替代(Use of MapReduce engine for Big Data projects will decline,replaced by Apache Spark)。可以 说,Spark自诞生之日起就得到了广泛的关注,也是近年来开源社区最活跃的项目之一。Spark的1.X版本的每次发布,都包含 了数百位贡献者的上千次提交。最新的版本是发布于2015年6月11日的1.4.0,是迄今为止Spark最大的一次版本发布,涵盖了 210位开发者的贡献。 Spark得到了众多大数据公司的支持,这些公司包括Hortonworks、IBM、Intel、Cloudera、MapR、Pivotal和星环科技;Spark也被 百度、阿里、腾讯、京东、携程、优酷土豆等互联网公司应用到多种不同的计算场景中,并且在实际的生产环境中获得了很多 收益。当前百度的Spark已应用于凤巢、大搜索、直达号、百度大数据等业务;阿里利用GraphX构建了大规模的图计算和图挖 掘系统,实现了很多生产系统的推荐算法;腾讯Spark集群达到8000台的规模,是当前已知的世界上最大的Spark集群。 但是,当前并没有一本系统介绍Spark内核实现原理的书,而Spark内核是Spark SQL、Spark Streaming、MLlib、GraphX等多个模 块的基础,这些模块最终的计算执行都是由内核模块完成的。为了在应用开发中做到游刃有余,在性能调优时做到有的放矢, 需要了解内核模块的实现原理。笔者从Spark发布版本0.8.1时开始关注Spark,并深入学习内核模块的架构实现原理。Spark在 1.0发布后,内核模块趋于稳定,虽然内核模块依旧会有不断地改进和完善,但是整体的设计思想和实现方法是不会变的,因 此笔者决定为Spark社区的用户和关注者写一本书,详细介绍Spark内核模块的实现原理。最终,笔者基于Spark 1.2.0版本完成 了本书。 写作是一件严肃的事情,同样是一份苦差事,尤其是在工作比较忙的时候。本书在半年前就完成了基本的框架,但是随后又对 本书进行了多次修改和完善。笔者认为,对一本架构分析的书,一个最基本的要求就是基于源码如实描述系统的实现,能做到 这点就是一本及格的书;如果能做到分析这个架构的好坏,指出架构改进的方案,那么就是一本质量比较好的书;如果能高屋 建瓴地进行再次抽象,指出类似架构不同实现的优劣,抽象出一些理论,那么这就是一本质量上乘,可以当作教科书的书。我 深知自己的能力水平,希望这本书最起码是一本及格的书,即能基于源码如实描述系统的实现,对那些希望深入学习Spark架 构实现的同仁有所帮助。 目标读者 本书适合大数据领域的架构师、运维人员,尤其是Spark领域的从业人员阅读,也适合作为研究生和高年级的本科生大数据领 域分布式架构具体实现原理的参考资料。 内容概述 第1章介绍了Spark的技术背景和特点,给出了架构的整体概述,并简单介绍了Spark的生态圈。 第2章介绍了Spark源码如何获取和学习环境如何搭建。 第3章是RDD的详细介绍,介绍了RDD的定义和Spark对于DAG的实现,最后通过RDD计算的详细介绍,讲解了Spark对于计算 的实现原理。 第4章详细介绍任务调度的实现,包括如何通过DAG来生成计算任务,最后通过“Word Count”来加深对这个实现过程的理解。 第5章介绍了Spark的运行模式,尤其是Standalone模式。Standalone是Spark自身实现的资源管理和调度的模块,这里会详细介绍 它的实现原理。 第6章是Executor模块的详细讲解。Executor是最终执行计算任务的单元,这章将详细介绍Executor的实现原理,包括Executor的分 配、Task在Executor的详细执行过程。 第7章详细介绍了Spark对于Shuffle的实现原理,包括基于Hash和基于排序的实现。除了详细阐述基于Hash和排序的Shuffle写和 Shuffle读之外,还介绍了Shuffle Pluggable框架,为需要实现特定Shuffle逻辑的读者介绍其实现原理。 第8章详细介绍了Spark的Storage模块,在详细介绍了模块的架构后详细解析了不同存储级别的实现细节。 第9章介绍了Spark在百度、腾讯和阿里等国内互联网领域的应用现状。 致谢 本书在写作的过程中,得到了很多朋友、同仁的帮助和支持,在此表示衷心感谢! 感谢七牛云的技术总监陈超先生、蘑菇街的资深架构师刘旭晖先生、百度公司的高级架构师柴华先生、Databricks软件工程师 连城先生在百忙之中为本书审稿,并提出了很多宝贵的修改意见。尤其感谢星环科技的创始人兼CTO孙元浩先生对本书的完成 给予了很多的支持,还在百忙之中为本书作序。感谢华为诺亚方舟实验室的董振华博士,在Spark上做机器学习方面给了我很 多指导。 感谢百度上海研发中心网页搜索部的同事们。在一年多的工作中,笔者得到了很多同事的指导、支持和帮助,尤其感谢曲晶 莹、吴永巍、汪韬、葛俊、刘桐仁、段雪涛、周波涛、马鑫云、李战平、杨大毛、朱晓阳、赵鹏程等。 感谢机械工业出版社的姚蕾编辑,她不但积极策划和推动本书的出版,还容忍我蜗牛般的写作速度;感谢她在这一年多的时间 中给予的理解与支持。感谢机械工业出版社的李艺编辑为本书做了非常辛苦和专业的编辑工作。 还要感谢我的家人一直以来对我的支持和宽容。感谢父亲、母亲和三个姐姐,你们是我漫漫求学路的最强大支柱和后盾;感谢 我的妻子王珊珊,不但在家庭方面付出很多,也为本书的顺利出版做出了很重要的贡献;感谢我的女儿,你的微笑是爸爸消除 疲惫的良药。 联系方式 由于本书的写作都是在业余时间完成,加上笔者自身水平有限,错误在所难免,敬请读者谅解,如果有任何问题,可以通过下 列方式与Spark的关注者和使用者进行交流沟通: Spark架构实现原理交流QQ群:473842835 Spark用户使用交流QQ群:473853269 也可以直接与笔者联系: 邮箱:anzhsoft@gmail.com 新浪微博:@anzhsoft 个人博客: http://blog.csdn.net/anzhsoft 第1章 Spark简介 Apache Spark是一种快速、通用、可扩展的大数据分析引擎。它是不断壮大的大数据分析解决方案家族中备受关注的明星成 员,为分布式数据集的处理提供了一个有效框架,并以高效的方式处理分布式数据集。Spark集批处理、实时流处理、交互式 查询与图计算于一体,避免了多种运算场景下需要部署不同集群带来的资源浪费。Spark在过去的一年中获得了极大关注,并 得到广泛应用,Spark社区也成为大数据领域和Apache软件基金会最活跃的项目之一,其活跃度甚至远超曾经只能望其项背的 Hadoop。 1.1 Spark的技术背景 无论是工业界还是学术界,都已经广泛使用高级集群编程模型来处理日益增长的数据,如MapReduce和Dryad。这些系统将分 布式编程简化为自动提供位置感知性调度、容错以及负载均衡,使得大量用户能够在商用集群上分析超大数据集。大多数现有 的集群计算系统都是基于非循环的数据流模型。即从稳定的物理存储(如分布式文件系统)中加载记录,记录被传入由一组确 定性操作构成的DAG(Directed Acyclic Graph,有向无环图),然后写回稳定存储。DAG数据流图能够在运行时自动实现任务 调度和故障恢复。 尽管非循环数据流是一种很强大的抽象方法,但仍然有些应用无法使用这种方式描述。这类应用包括:①机器学习和图应用中 常用的迭代算法(每一步对数据执行相似的函数);②交互式数据挖掘工具(用户反复查询一个数据子集)。基于数据流的框 架并不明确支持工作集,所以需要将数据输出到磁盘,然后在每次查询时重新加载,这会带来较大的开销。针对上述问 题,Spark实现了一种分布式的内存抽象,称为弹性分布式数据集 (Resilient Distributed Dataset,RDD)。它支持基于工作集的 应用,同时具有数据流模型的特点:自动容错、位置感知性调度和可伸缩性。RDD允许用户在执行多个查询时显式地将工作集 缓存在内存中,后续的查询能够重用工作集,这极大地提升了查询速度。 RDD提供了一种高度受限的共享内存模型,即RDD是只读记录分区的集合,只能通过在其他RDD执行确定的转换操作(如 map、join和groupBy)而创建,然而这些限制使得实现容错的开销很低。与分布式共享内存系统需要付出高昂代价的检查点和 回滚机制不同,RDD通过Lineage来重建丢失的分区:一个RDD中包含了如何从其他RDD衍生所必需的相关信息,从而不需要检 查点操作就可以重建丢失的数据分区。尽管RDD不是一个通用的共享内存抽象,但它具备了良好的描述能力、可伸缩性和可靠 性,能够广泛适用于数据并行类应用。 1.2 Spark的优点 Spark凭借什么在众多的大数据分析处理平台中脱颖而出呢?这得益于Spark的几个主要优点,具体如下所示。 第一个优点是速度。与Hadoop的MapReduce相比,Spark基于内存的运算要快100倍以上(如图1-1所示);而基于硬盘的运算也 要快10倍以上。Spark实现了高效的DAG执行引擎,可以通过基于内存来高效处理数据流。 图1-1 逻辑回归在Hadoop、Spark的运算速度对比 第二个优点就是易用。Spark支持Java、Python和Scala的API,还支持超过80种高级算法,使用户可以快速构建不同的应用。而 且Spark支持交互式的Python和Scala的shell,这意味着可以非常方便地在这些shell中使用Spark集群来验证解决问题的方法,而不 是像以前一样,需要打包、上传集群、验证等。这对于原型开发非常重要。 第三个优点是通用性。Spark提供了统一的解决方案。Spark可以用于批处理、交互式查询(通过Spark SQL)、实时流处理 (通过Spark Streaming)、机器学习(通过Spark MLlib)和图计算(通过Spark GraphX),如图1-2所示。这些不同类型的处理 都可以在同一个应用中无缝使用。Spark统一的解决方案非常具有吸引力,毕竟任何公司都想用统一的平台去处理遇到的问 题,减少开发和维护的人力成本和部署平台的物力成本。当然还有,作为统一的解决方案,Spark并没有以牺牲性能为代价。 相反,在性能方面,Spark具有很大的优势。 图1-2 Spark支持丰富的应用计算场景 第四个优点就是可融合性。Spark可以非常方便地与其他的开源产品进行融合。比如,Spark可以使用Hadoop的YARN和Apache Mesos作为它的资源管理和调度器,并且可以处理所有Hadoop支持的数据,包括HDFS、HBase和Cassandra等。这对于已经部署 Hadoop集群的用户特别重要,因为不需要做任何数据迁移就可以使用Spark的强大处理能力。Spark也可以不依赖于第三方的资 源管理和调度器,它实现了Standalone作为其内置的资源管理和调度框架,这样进一步降低了Spark的使用门槛,使得所有人都 可以非常容易地部署和使用Spark。此外,Spark还提供了在EC2上部署Standalone的Spark集群的工具。 1.3 Spark架构综述 Spark的整体架构如图1-3所示。其中,Driver是用户编写的数据处理逻辑,这个逻辑中包含用户创建的SparkContext。 SparkContext是用户逻辑与Spark集群主要的交互接口,它会和Cluster Manager交互,包括向它申请计算资源等。Cluster Manager 负责集群的资源管理和调度,现在支持Standalone、Apache Mesos和Hadoop的YARN。Worker Node是集群中可以执行计算任务 的节点。Executor是在一个Worker Node上为某应用启动的一个进程,该进程负责运行任务,并且负责将数据存在内存或者磁盘 上。Task是被送到某个Executor上的计算单元。每个应用都有各自独立的Executor,计算最终在计算节点的Executor中执行。 图1-3 整体架构图 用户程序从最开始的提交到最终的计算执行,需要经历以下几个阶段: 1)用户程序创建SparkContext时,新创建的SparkContext实例会连接到Cluster Manager。Cluster Manager会根据用户提交时设置的 CPU和内存等信息为本次提交分配计算资源,启动Executor。 2)Driver会将用户程序划分为不同的执行阶段,每个执行阶段由一组完全相同的Task组成,这些Task分别作用于待处理数据的 不同分区。在阶段划分完成和Task创建后,Driver会向Executor发送Task。 3)Executor在接收到Task后,会下载Task的运行时依赖,在准备好Task的执行环境后,会开始执行Task,并且将Task的运行状 态汇报给Driver。 4)Driver会根据收到的Task的运行状态来处理不同的状态更新。Task分为两种:一种是Shuffle Map Task,它实现数据的重新洗 牌,洗牌的结果保存到Executor所在节点的文件系统中;另外一种是Result Task,它负责生成结果数据。 5)Driver会不断地调用Task,将Task发送到Executor执行,在所有的Task都正确执行或者超过执行次数的限制仍然没有执行成功 时停止。 1.4 Spark核心组件概述 图1-2简单介绍了Spark支持的丰富的应用计算场景,这是通过在Spark Core上构建多个组件来完成的。比如Spark Streaming实现 了实时流处理;GraphX实现了图计算;MLlib实现了很多机器学习算法;Spark SQL实现了基于Spark的交互式查询。 1.4.1 Spark Streaming Spark Streaming基于Spark Core实现了可扩展、高吞吐和容错的实时数据流处理。现在支持的数据源有Kafka、Flume、Twitter、 ZeroMQ、Kinesis、HDFS、S3和TCP socket。处理后的结果可以存储到HDFS、Database或者Dashboard中,如图1-4所示。 图1-4 Spark Streaming支持的数据源 Spark Streaming是将流式计算分解成一系列短小的批处理作业。这里的批处理引擎是Spark,也就是把Spark Streaming的输入数据 按照批处理尺寸(如1秒)分成一段一段的数据(Stream),每一段数据都转换成Spark中的RDD,然后将Spark Streaming中对 DStream的转换操作变为针对Spark中对RDD的转换操作,将RDD经过操作变成中间结果保存在内存中。整个流式计算可以根据 业务的需求对中间的结果进行叠加,或者存储到外部设备。上述过程如图1-5所示。 图1-5 Spark Streaming的实现原理 Spark Streaming提供了一套高效、可容错的准实时大规模流式处理框架,它能和批处理及即时查询放在同一个软件栈中,降低 学习成本。对于熟悉Spark编程的用户,可以用较低的成本学习Spark Streaming编程。 1.4.2 MLlib MLlib是Spark对常用的机器学习算法的实现库,同时含有相关的测试和数据生成器,包括分类、回归、聚类、协同过滤、降维 (dimensionality reduction)以及底层基本的优化元素。 在Spark 1.2.0中,MLlib最大的改进是引入了称为spark.ml的机器学习工具包,支持了流水线的学习模式,即多个算法可以用不 同参数以流水线的形式运行。在工业界的机器学习应用部署过程中,流水线的工作模式是很常见的。新的ML工具包使用Spark 的SchemaRDD来表示机器学习的数据集合,提供了Spark SQL直接访问的接口。此外,在机器学习的算法方面,增加了两种基 于树的方法,即随机森林和梯度增强树。 现在,MLlib实现了许多常用的算法,与分类和回归相关的算法包括SVM、逻辑回归、线性回归、朴素贝叶斯分类、决策树 等;协同过滤实现了交替最小二乘法(Alternating Least Square,ALS);聚类实现了K-means、高斯混合(Gaussian mixture)、 Power Iteration Clustering(PIC)、Latent Dirichlet Allocation(LDA)和Streaming版本的K-means;降维实现了Singular Value Decomposition(SVD)和Principal Component Analysis(PCA);频繁模式挖掘(frequent pattern mining)实现了FP-growth。 1.4.3 Spark SQL 自从Spark 1.0版本的Spark SQL问世以来,它最常见的用途之一就是作为一个从Spark平台获取数据的渠道。早期用户比较喜爱 Spark SQL提供的从现有Apache Hive表以及流行的Parquet列式存储格式中读取数据的支持。之后,Spark SQL还增加了对其他格 式的支持,比如说JSON。到了Spark 1.2版本,Spark的原生资源与更多的输入源进行整合集成,这些新的整合将随着纳入新的 Spark SQL数据源API而成为可能。Spark SQL支持的数据源如图1-6所示。 图1-6 Spark SQL支持的数据源 数据源API通过Spark SQL提供了访问结构化数据的可插拔机制。这使数据源有了简便的途径进行数据转换并加入到Spark平台 中。由API提供的密集优化器集合意味着过滤和列修剪在很多情况下都会被运用于数据源。这些综合的优化极大地减少了需要 处理的数据量,因此能够显著提高Spark的工作效率。 数据源API的另一个优点就是不管数据的来源如何,用户都能够通过Spark支持的所有语言来操作这些数据。例如,那些用Scala 实现的数据源,pySpark用户不需要其他的库开发人员做任何额外的工作就可以直接使用。此外,Spark SQL可以使用单一接口 访问不同数据源的数据。总之,Spark 1.2提供的这些功能进一步统一了大数据分析的解决方案。 1.4.4 GraphX Spark GraphX是Spark提供的关于图和图并行计算的API,它集ETL、试探性分析和迭代式的图计算于一体,并且在不失灵活 性、易用性和容错性的前提下获得了很好的性能。现在GraphX已经提供了很多的算法,新的算法也在不断加入,而且很多的 算法都是由Spark的用户贡献的。 1.5 Spark的整体代码结构规模 Spark Core是Spark的核心,它体现了Spark的核心设计思想。Spark SQL、MLlib、GraphX和Spark Streaming都是基于Spark Core的 实现和衍生。在Spark 1.2.0版本中,Spark Core包含了近6.5万行Scala源码。其中,不同模块之间的代码统计见图1-7。仅代码规 模上,看起来好像远没有想象中的那么复杂;但是如果没有借助Scala语言的强大表现力,比如使用C++或者Java的话,代码规 模可能要大得多。还可以从另外一个角度来评估当前的代码规模,Spark的广泛应用导致新特性的不断加入,使得Spark单纯代 码的增长速度不断加快。 图1-7 Spark的整体代码结构图 第2章 Spark学习环境的搭建 本章主要介绍源码阅读环境的搭建,包括Spark源码的获取和编译,安装和部署以及简单的设置和使用。另外,通过订阅Spark 的邮件组user@spark.apache.org可以查看大家使用时遇到的问题和这些问题的解决方法;如果希望贡献社区,可以订阅邮件组 dev@spark.apache.org来了解社区关于开发的相关讨论。遇到问题或者希望更进一步地了解开发状态,可以访问Spark JIRA的主 页 https://issues.apache.org/jira/browse/SPARK ,了解Spark对于某个问题的持续改进,帮助更好地理解架构的演进。 2.1 源码的获取与编译 2.1.1 源码获取 可以到Spark官网的 http://spark.apache.org/downloads.html 页面下载最新的Spark源码。 Spark源码在Github上的主页地址是 https://github.com/apache/spark ,读者也可以通过git命令获取最新的源码:git clone https://github.com/apache/spark.git 。由于服务器在美国,因此下载的速度会比较慢,如果中途遇到失败,可以重复执行上述命令 直至成功。 2.1.2 源码编译 首先要安装JDK 6+和Scala 2.10.*或者2.11.*版本。截至本书截稿时,Kafka库和JDBC并不支持Scala 2.11的编译,因此请使用Scala 2.10。 1.maven编译 Spark支持多个版本的Hadoop,包括社区版和商业发行版,通过指定Hadoop的版本,可以使Spark使用不同版本的HDFS、YARN。现在可用的编译方 式如表2-1所示。 表2-1 可用编译方式 如果在编译过程中遇到如下错误: [ERROR] PermGen space -> [Help 1] [ERROR] Java heap space -> [Help 1] 这个错误是由于内存不足造成的。可以通过设置环境变量MAVEN_OPTS为maven分配更多的内存来解决: export MAVEN_OPTS="-Xmx4g -XX:MaxPermSize=1g -XX:ReservedCodeCacheSize=1g" 2.sbt编译 尽管maven是Spark官网推荐的编译方式,但是sbt的编译速度更胜一筹。因此对于Spark的开发者来说,sbt编译可能是更好的选择。由于sbt编译也是 基于maven的POM文件,因此sbt的编译参数与maven的编译参数是一致的。比如可以使用下面的命令进行编译: build/sbt -Pyarn -Phadoop-2.3 assembly 2.2 构建Spark的源码阅读环境 查看Spark源码最好的IDE就是IntelliJ IDEA,这个也是大部分Spark开发者都在使用的。IDEA分两个版本,一个是免费的社区 版,一个是收费的商业发行版。不过Apache的贡献者可以免费获得商业发行版的使用权。 当然使用Eclipse也是可以的,读者可以访问 http://scala-ide.org/ 下载和安装Eclipse,在这里就不再赘述。不过笔者认为使用Eclipse 阅读Spark源码的体验远不如使用IntelliJ IDEA,推荐大家都去尝试,即使您可能对Eclipse非常熟悉。 IDEA的社区版和商业发行版都可以从 https://www.jetbrains.com/idea/download/ 获得。IDEA有三个版本:Windows版、MAC OS版 和Linux版。下载后直接安装即可。 为了阅读和构建Spark的开发环境,需要为IDEA安装Scala插件。首先单击图2-1中红框内的“Configure”。接着单击图2-2中 的“Plugins”。然后单击左下角的“Install JetBrains plugin”(图2-3)。在新的界面图2-4中输入“Scala”回车,就可以看到Scala插件 了,直接单击右侧的“Install plugin”就可以安装了。 图2-1 单击“Configure”以安装Scala插件 图2-2 选择“Plugins” 图2-3 单击“Install JetBrains plugin” 图2-4 搜索并安装Scala 然后要为Spark生成IDEA所需要的工程文件,在源代码的根目录执行下面的命令: ./sbt/sbt?gen-idea 命令成功执行后,SBT的工程文件就生成了。接下来直接导入工程即可,如图2-5所示。 图2-5 导入工程 在选择了工程所在的根目录后,选择导入“SBT project”,如图2-6所示。 图2-6 选择导入“SBT project” 选择了图2-7所示的复选框后,单击“Finish”即可完成工程的建立。 完成导入后就可以开始阅读源码了。 图2-7 完成导入 2.3 小结 本章主要介绍了Spark学习环境的搭建,包括源码的获取和编译,Spark源码阅读环境的建立等。接下来,将逐步介绍Spark Core的各个模块的实现原理和设计思想。 第3章 RDD实现详解 RDD是Spark最基本也是最根本的数据抽象,它具备像MapReduce等数据流模型的容错性,并且允许开发人员在大型集群上执行 基于内存的计算。现有的数据流系统对两种应用的处理并不高效:一是迭代式算法,这在图应用和机器学习领域很常见;二是 交互式数据挖掘工具。这两种情况下,将数据保存在内存中能够极大地提高性能。为了有效地实现容错,RDD提供了一种高度 受限的共享内存,即RDD是只读的,并且只能通过其他RDD上的批量操作来创建。尽管如此,RDD仍然足以表示很多类型的计 算,包括MapReduce和专用的迭代编程模型(如Pregel)等。Spark实现的RDD在迭代计算方面比Hadoop快20多倍,同时还可以 在5~7秒内交互式地查询1TB数据集。 3.1 概述 Spark的目标是为基于工作集的应用(即多个并行操作重用中间结果的应用)提供抽象,同时保持MapReduce及其相关模型的优 势特性,即自动容错、位置感知性调度和可伸缩性。RDD比数据流模型更易于编程,同时基于工作集的计算也具有良好的描述 能力。 在这些特性中,最难实现的是容错性。一般来说,分布式数据集的容错性有两种方式:数据检查点和记录数据的更新。我们面 向的是大规模数据分析,数据检查点操作成本很高:需要通过数据中心的网络连接在机器之间复制庞大的数据集,而网络带宽 往往比内存带宽低得多,同时还需要消耗更多的存储资源(在内存中复制数据可以减少需要缓存的数据量,而存储到磁盘则会 降低应用程序速度)。所以,我们选择记录更新的方式。但是,如果更新太多,记录更新成本也不低。因此,RDD只支持粗粒 度转换,即在大量记录上执行的单个操作。将创建RDD的一系列转换记录下来(即Lineage),以便恢复丢失的分区。 虽然只支持粗粒度转换限制了编程模型,但RDD仍然可以很好地适用于很多应用,特别是支持数据并行的批量分析应用,包括 数据挖掘、机器学习、图算法等,因为这些程序通常都会在很多记录上执行相同的操作。RDD不太适合那些异步更新共享状态 的应用,例如并行Web网络爬虫。因此,Spark的目标是为大多数分析型应用提供有效的编程模型,而其他类型的应用则交给 专门的系统。 3.2 什么是RDD 什么是RDD?RDD是只读的、分区记录的集合。RDD只能基于在稳定物理存储中的数据集和其他已有的RDD上执行确定性操作 来创建。这些确定性操作称为转换,如map、filter、groupBy、join。RDD不需要物化。RDD含有如何从其他RDD衍生(即计算) 出本RDD的相关信息(即Lineage),因此在RDD部分分区数据丢失的时候可以从物理存储的数据计算出相应的RDD分区。 RDD支持基于工作集的应用,同时具有数据流模型的特点:自动容错、位置感知性调度和可伸缩性。RDD允许用户在执行多个 查询时显式地将工作集缓存在内存中,后续的查询能够重用工作集,这极大地提升了查询速度。 每个RDD有5个主要的属性: 1)一组分片(Partition),即数据集的基本组成单位。对于RDD来说,每个分片都会被一个计算任务处理,并决定并行计算的 粒度。用户可以在创建RDD时指定RDD的分片个数,如果没有指定,那么就会采用默认值。默认值就是程序所分配到的CPU Core的数目。图3-1描述了分区存储的计算模型,每个分配的存储是由BlockManager实现的。每个分区都会被逻辑映射成 BlockManager的一个Block,而这个Block会被一个Task负责计算。 2)一个计算每个分区的函数。Spark中RDD的计算是以分片为单位的,每个RDD都会实现compute函数以达到这个目的。 compute函数会对迭代器进行复合,不需要保存每次计算的结果。详情请参阅3.4.5节。 3)RDD之间的依赖关系。RDD的每次转换都会生成一个新的RDD,所以RDD之间就会形成类似于流水线一样的前后依赖关 系。在部分分区数据丢失时,Spark可以通过这个依赖关系重新计算丢失的分区数据,而不是对RDD的所有分区进行重新计 算。 4)一个Partitioner,即RDD的分片函数。当前Spark中实现了两种类型的分片函数,一个是基于哈希的HashPartitioner,另外一个 是基于范围的RangePartitioner。只有对于key-value的RDD,才会有Partitioner,非key-value的RDD的Parititioner的值是None。 Partitioner函数不但决定了RDD本身的分片数量,也决定了parent RDD Shuffle输出时的分片数量。 5)一个列表,存储存取每个Partition的优先位置(preferred location)。对于一个HDFS文件来说,这个列表保存的就是每个 Partition所在的块的位置。按照“移动数据不如移动计算”的理念,Spark在进行任务调度的时候,会尽可能地将计算任务分配到 其所要处理数据块的存储位置。 图3-1 RDD Partition的存储和计算模型 3.2.1 RDD的创建 可以通过两种方式创建RDD: 1)由一个已经存在的Scala集合创建。 2)由外部存储系统的数据集创建,包括本地的文件系统,还有所有Hadoop支持的数据集,比如HDFS、Cassandra、HBase、 Amazon S3等。 RDD创建后,就可以在RDD上进行数据处理。RDD支持两种操作:转换(trans-formation),即从现有的数据集创建一个新的数 据集;动作(action),即在数据集上进行计算后,返回一个值给Driver程序。例如,map就是一种转换,它将数据集每一个元 素都传递给函数,并返回一个新的分布式数据集表示结果。另一方面,reduce是一种动作,通过一些函数将所有元素叠加起 来,并将最终结果返回给Driver(还有一个并行的reduceByKey,能返回一个分布式数据集)。 图3-2描述了从外部数据源创建RDD,经过多次转换,通过一个动作操作将结果写回外部存储系统的逻辑运行图。整个过程的 计算都是在Worker中的Executor中运行。 图3-2 RDD创建、转换和动作的逻辑计算图 3.2.2 RDD的转换 RDD中的所有转换都是惰性的,也就是说,它们并不会直接计算结果。相反的,它们只是记住这些应用到基础数据集(例如一个文件)上的 转换动作。只有当发生一个要求返回结果给Driver的动作时,这些转换才会真正运行。这个设计让Spark更加有效率地运行。例如我们可以实 现:通过map创建的一个新数据集,并在reduce中使用,最终只返回reduce的结果给Driver,而不是整个大的新数据集。图3-3描述了RDD在进行 groupByRey时的内部RDD转换的实现逻辑图。图3-4描述了reduceByKey的实现逻辑图。 图3-3 RDD groupByKey的逻辑转换图 在groupByKey的操作中,会在MapPartitionsRDD做一次Shuffle,图3-3中设置的分片数量是3,因此ShuffledRDD会有3个分片,ShuffledRDD实际上 仅仅是从上游的任务中读取Shuffle的结果,因此图的箭头是指向上游的MapPartitionsRDD的。关于Shuffle的实现实际上要比图中展示得复杂得 多,具体的实现细节可以参阅第7章。 reduceByKey和groupByKey的实现差不多,它在Shuffle完成之后,需要做一次reduce。 图3-4 RDD reduceByKey的逻辑转换图 默认情况下,每一个转换过的RDD都会在它执行一个动作时被重新计算。不过也可以使用persist(或者cache)方法,在内存中持久化一个 RDD。在这种情况下,Spark将会在集群中保存相关元素,下次查询这个RDD时能更快访问它。也支持在磁盘上持久化数据集,或在集群间复 制数据集,这些选项将在下一节进行描述。 RDD支持的转换如表3-1所示。 表3-1 RDD支持的转换 3.2.3 RDD的动作 RDD支持的动作如表3-2所示。 表3-2 RDD支持的动作 3.2.4 RDD的缓存 Spark速度非常快的原因之一,就是在不同操作中在内存中持久化(或缓存)一个数据集。当持久化一个RDD后,每一个节点 都将把计算的分片结果保存在内存中,并在对此数据集(或者衍生出的数据集)进行的其他动作(action)中重用。这使得后 续的动作变得更加迅速(通常快10倍)。RDD相关的持久化和缓存,是Spark最重要的特征之一。可以说,缓存是Spark构建迭 代式算法和快速交互式查询的关键。 通过persist()或cache()方法可以标记一个要被持久化的RDD,一旦首次被触发,该RDD将会被保留在计算节点的内存中并 重用。实际上,cache()是使用persist()的快捷方法,它们的实现如下: /** Persist this RDD with the default storage level (`MEMORY_ONLY`). */ def persist(): this.type = persist(StorageLevel.MEMORY_ONLY) /** Persist this RDD with the default storage level (`MEMORY_ONLY`). */ def cache(): this.type = persist() 图3-5中,假设首先进行了RDD0→RDD1→RDD2的计算作业,那么计算结束时,RDD1就已经缓存在系统中了。在进行 RDD0→RDD1→RDD3的计算作业时,由于RDD1已经缓存在系统中,因此RDD0→RDD1的转换不会重复进行,计算作业只须进 行RDD1→RDD3的计算就可以了,因此计算速度可以得到很大提升。 图3-5 RDD缓存过的Partition可以加快下一次的计算速度 缓存有可能丢失,或者存储于内存的数据由于内存不足而被删除。RDD的缓存的容错机制保证了即使缓存丢失也能保证计算的 正确执行。通过基于RDD的一系列的转换,丢失的数据会被重算。RDD的各个Partition是相对独立的,因此只需要计算丢失的 部分即可,并不需要重算全部Partition。 3.2.5 RDD的检查点 RDD的缓存能够在第一次计算完成后,将计算结果保存到内存、本地文件系统或者Tachyon中。通过缓存,Spark避免了RDD上 的重复计算,能够极大地提升计算速度。但是,如果缓存丢失了,则需要重新计算。如果计算特别复杂或者计算耗时特别多, 那么缓存丢失对于整个Job的影响是不容忽视的。为了避免缓存丢失重新计算带来的开销,Spark又引入了检查点(checkpoint) 机制。 缓存是在计算结束后,直接将计算结果通过用户定义的存储级别(存储级别定义了缓存存储的介质,现在支持内存、本地文件 系统和Tachyon)写入不同的介质。而检查点不同,它是在计算完成后,重新建立一个Job来计算。为了避免重复计算,推荐先 将RDD缓存,这样就能保证检查点的操作可以快速完成。 用户可以通过调用org.apache.spark.rdd.RDD#checkpoint()来指定RDD需要检查点机制。 3.3 RDD的转换和DAG的生成 Spark会根据用户提交的计算逻辑中的RDD的转换和动作来生成RDD之间的依赖关系,同时这个计算链也就生成了逻辑上的 DAG。接下来以“Word Count”为例,详细描述这个DAG生成的实现过程。 Spark Scala版本的Word Count程序如下: 1 : val file = spark.textFile("hdfs://...") 2 : val counts = file.flatMap(line => line.split(" ")) 3 : .map(word => (word, 1)) 4 : .reduceByKey(_ + _) 5 : counts.saveAsTextFile("hdfs://...") file和counts都是RDD,其中file是从HDFS上读取文件并创建了RDD,而counts是在file的基础上通过flatMap、map和reduceByKey这 三个RDD转换生成的。最后,counts调用了动作saveAsTextFile,用户的计算逻辑就从这里开始提交的集群进行计算。那么上面 这5行代码的具体实现是什么呢? 1)行1:spark是org.apache.spark.SparkContext的实例,它是用户程序和Spark的交互接口。spark会负责连接到集群管理者,并根 据用户设置或者系统默认设置来申请计算资源,完成RDD的创建等。 spark.textFile("hdfs://...")就完成了一个org.apache.spark.rdd.HadoopRDD的创建,并且完成了一次RDD的转换:通过map转换到 一个org.apache.spark.rdd.MapPartitions-RDD。也就是说,file实际上是一个MapPartitionsRDD,它保存了文件的所有行的数据内 容。 2)行2:将file中的所有行的内容,以空格分隔为单词的列表,然后将这个按照行构成的单词列表合并为一个列表。最后,以 每个单词为元素的列表被保存到MapPartitionsRDD。 3)行3:将第2步生成的MapPartitionsRDD再次经过map将每个单词word转为(word,1)的元组。这些元组最终被放到一个 MapPartitionsRDD中。 4)行4:首先会生成一个MapPartitionsRDD,起到map端combiner的作用;然后会生成一个ShuffledRDD,它从上一个RDD的输出 读取数据,作为reducer的开始;最后,还会生成一个MapPartitionsRDD,起到reducer端reduce的作用。 5)行5:首先会生成一个MapPartitionsRDD,这个RDD会通过调用org.apache.spark.rdd.PairRDDFunctions#saveAsHadoopDataset向 HDFS输出RDD的数据内容。最后,调用org.apache.spark.SparkContext#runJob向集群提交这个计算任务。 RDD之间的关系可以从两个维度来理解:一个是RDD是从哪些RDD转换而来,也就是RDD的parent RDD(s)是什么;还有就是 依赖于parent RDD(s)的哪些Partition(s)。这个关系,就是RDD之间的依赖,org.apache.spark.Dependency。根据依赖于parent RDD(s)的Partitions的不同情况,Spark将这种依赖分为两种,一种是宽依赖,一种是窄依赖。 3.3.1 RDD的依赖关系 RDD和它依赖的parent RDD(s)的关系有两种不同的类型,即窄依赖(narrow dependency)和宽依赖(wide dependency)。 1)窄依赖指的是每一个parent RDD的Partition最多被子RDD的一个Partition使用,如图3-6所示。 2)宽依赖指的是多个子RDD的Partition会依赖同一个parent RDD的Partition,如图3-7所示。 图3-6 RDD的窄依赖 图3-7 RDD的宽依赖 接下来可以从不同类型的转换来进一步理解RDD的窄依赖和宽依赖的区别,如图3-8所示。 对于map和filter形式的转换来说,它们只是将Partition的数据根据转换的规则进行转化,并不涉及其他的处理,可以简单地认为 只是将数据从一个形式转换到另一个形式。对于union,只是将多个RDD合并成一个,parent RDD的Partition(s)不会有任何的 变化,可以认为只是把parent RDD的Partition(s)简单进行复制与合并。对于join,如果每个Partition仅仅和已知的、特定的 Partition进行join,那么这个依赖关系也是窄依赖。对于这种有规则的数据的join,并不会引入昂贵的Shuffle。对于窄依赖,由于 RDD每个Partition依赖固定数量的parent RDD(s)的Partition(s),因此可以通过一个计算任务来处理这些Partition,并且这些 Partition相互独立,这些计算任务也就可以并行执行了。 图3-8 RDD的窄依赖和宽依赖 对于groupByKey,子RDD的所有Partition(s)会依赖于parent RDD的所有Partition(s),子RDD的Partition是parent RDD的所有 Partition Shuffle的结果,因此这两个RDD是不能通过一个计算任务来完成的。同样,对于需要parent RDD的所有Partition进行join 的转换,也是需要Shuffle,这类join的依赖就是宽依赖而不是前面提到的窄依赖了。 所有的依赖都要实现trait Dependency[T]: abstract class Dependency[T] extends Serializable { def rdd: RDD[T] } 其中rdd就是依赖的parent RDD。 对于窄依赖的实现是: abstract class NarrowDependency[T](_rdd: RDD[T]) extends Dependency[T] { // 返回子 RDD 的 partitionId 依赖的所有的 parent RDD 的 Partition(s) def getParents(partitionId: Int): Seq[Int] override def rdd: RDD[T] = _rdd } 现在有两种窄依赖的具体实现,一种是一对一的依赖,即OneToOneDependency: class OneToOneDependency[T](rdd: RDD[T]) extends NarrowDependency[T](rdd) { override def getParents(partitionId: Int) = List(partitionId) } 通过getParents的实现不难看出,RDD仅仅依赖于parent RDD相同ID的Partition。 还有一个是范围的依赖,即RangeDependency,它仅仅被org.apache.spark.rdd.UnionRDD使用。UnionRDD是把多个RDD合成一个 RDD,这些RDD是被拼接而成,即每个parent RDD的Partition的相对顺序不会变,只不过每个parent RDD在UnionRDD中的Partition 的起始位置不同。因此它的getPartents如下: override def getParents(partitionId: Int) = { if(partitionId >= outStart && partitionId < outStart + length) { List(partitionId - outStart + inStart) } else { Nil } } 其中,inStart是parent RDD中Partition的起始位置,outStart是在UnionRDD中的起始位置,length就是parent RDD中Partition的数量。 宽依赖的实现只有一种:ShuffleDependency。子RDD依赖于parent RDD的所有Partition,因此需要Shuffle过程: class ShuffleDependency[K, V, C]( @transient _rdd: RDD[_ <: Product2[K, V]], val partitioner: Partitioner, val serializer: Option[Serializer] = None, val keyOrdering: Option[Ordering[K]] = None, val aggregator: Option[Aggregator[K, V, C]] = None, val mapSideCombine: Boolean = false) extends Dependency[Product2[K, V]] { override def rdd = _rdd.asInstanceOf[RDD[Product2[K, V]]] // 获取新的 shuffleId val shuffleId: Int = _rdd.context.newShuffleId() // 向 ShuffleManager 注册 Shuffle 的信息 val shuffleHandle: ShuffleHandle = _rdd.context.env.shuffleManager.registerShuffle( shuffleId, _rdd.partitions.size, this) _rdd.sparkContext.cleaner.foreach(_.registerShuffleForCleanup(this)) } 宽依赖支持两种Shuffle Manager,即org.apache.spark.shuffle.hash.HashShuffleManager(基于Hash的Shuffle机制)和 org.apache.spark.shuffle.sort.SortShuffleManager(基于排序的Shuffle机制)。 3.3.2 DAG的生成 原始的RDD(s)通过一系列转换就形成了DAG。RDD之间的依赖关系,包含了RDD由哪些Parent RDD(s)转换而来和它依赖 parent RDD(s)的哪些Partitions,是DAG的重要属性。借助这些依赖关系,DAG可以认为这些RDD之间形成了Lineage(血 统)。借助Lineage,能保证一个RDD被计算前,它所依赖的parent RDD都已经完成了计算;同时也实现了RDD的容错性,即如 果一个RDD的部分或者全部的计算结果丢失了,那么就需要重新计算这部分丢失的数据。 那么Spark是如何根据DAG来生成计算任务呢?首先,根据依赖关系的不同将DAG划分为不同的阶段(Stage)。对于窄依赖, 由于Partition依赖关系的确定性,Partition的转换处理就可以在同一个线程里完成,窄依赖被Spark划分到同一个执行阶段;对于 宽依赖,由于Shuffle的存在,只能在parent RDD(s)Shuffle处理完成后,才能开始接下来的计算,因此宽依赖就是Spark划分 Stage的依据,即Spark根据宽依赖将DAG划分为不同的Stage。在一个Stage内部,每个Partition都会被分配一个计算任务 (Task),这些Task是可以并行执行的。Stage之间根据依赖关系变成了一个大粒度的DAG,这个DAG的执行顺序也是从前向后 的。也就是说,Stage只有在它没有parent Stage或者parent Stage都已经执行完成后,才可以执行。这个过程可以查询第4章。 3.3.3 Word Count的RDD转换和DAG划分的逻辑视图 上文分析了在Word Count的RDD转换时,Spark生成了不同的RDD。这些RDD有的和用户逻辑直接显式对应,比如map操作会生 成一个org.apache.spark.rdd.Map-PartitionsRDD;而有的RDD则是和Spark的实现原理相关,是Spark隐式生成的,比如 org.apache.spark.rdd.ShuffledRDD,这个过程对于用户来说是透明的,用户只需要关心RDD的转换和动作即可。 RDD在创建子RDD的时候,会通过Dependency来定义它们之间的关系。通过Dependency,子RDD也可以获得它的parent RDD和 parent RDD的Partition。 RDD转换的细节如图3-9所示。 图3-9 “Word Count”的RDD转换 通过图3-9,可以清晰地看到Spark对于用户提交的Application所做的处理。用户定义的RDD被系统显式和隐式地转换成多个RDD 以及这些RDD之间的依赖,这些依赖构建了这些RDD的处理顺序及相互关系。关于这些RDD的转换时如何在计算节点上运行 的,请参阅第4章。 为了对图3-9有更加直观的理解,图3-10以一个有五个分片的输入文件为例,详细描述了“Word Count”的逻辑执行过程。之所以 称为逻辑执行过程,是因为具体的计算过程可能会有网络的交互,有频繁地将处理中间数据写入磁盘等过程。 图3-10 “Word Count”RDD的逻辑转换关系图 需要强调的一点是在转换操作reduceByKey时会触发一个Shuffle(洗牌)的过程。在Shuffle开始之前,有一个本地聚合的过程, 比如第三个分片的(e,1)(e,1)聚合成了(e,2)。Shuffle的结果是为下游的Task生成了三个分片,这三个分片就构成了 ShuffledRDD。之后在做了一个聚合之后,就生成了结果的RDD。关于Shuffle的具体实现过程,可以参阅第7章。 3.4 RDD的计算 3.4.1 Task简介 原始的RDD经过一系列转换后,会在最后一个RDD上触发一个动作,这个动作会生成一个Job。在Job被划分为一批计算任务 (Task)后,这批Task会被提交到集群上的计算节点去计算。计算节点执行计算逻辑的部分称为Executor。Executor在准备好 Task的运行时环境后,会通过调用org.apache.spark.scheduler.Task#run来执行计算。Spark的Task分为两种: 1)org.apache.spark.scheduler.ShuffleMapTask 2)org.apache.spark.scheduler.ResultTask 简单来说,DAG的最后一个阶段会为每个结果的Partition生成一个ResultTask,其余所有的阶段都会生成ShuffleMapTask。生成的 Task会被发送到已经启动的Executor上,由Executor来完成计算任务的执行,执行过程的实现在 org.apache.spark.executor.Executor.TaskRunner#run。第6章会介绍这一部分的实现原理和设计思想。 3.4.2 Task的执行起点 org.apache.spark.scheduler.Task#run会调用ShuffleMapTask或者ResultTask的runTask;runTask会调用RDD的 org.apache.spark.rdd.RDD#iterator。计算由此开始。 final def iterator(split: Partition, context: TaskContext): Iterator[T] = { if(storageLevel != StorageLevel.NONE) { // 如果存储级别不是 NONE ,那么先检查是否有缓存;没有缓存则要进行计算 SparkEnv.get.cacheManager.getOrCompute(this, split, context,storageLevel) } else { // 如果有 checkpoint ,那么直接读取结果;否则直接进行计算 computeOrReadCheckpoint(split, context) } } 其中,SparkEnv中包含了一个运行时节点所需要的所有的环境信息。cache-Manager是org.apache.spark.CacheManager,它负责调 用BlockManager来管理RDD的缓存,如果当前RDD原来计算过并且把结果缓存起来,那么接下来的运算都可以通过 BlockManager来直接读取缓存后返回。SparkEnv除了cacheManager,还包括以下重要的成员变量: 1)akka.actor.ActorSystem:运行在该节点的Actor System,其中运行在Driver上的名字是sparkDriver;运行在Executor上的是 sparkExecutor。 2)org.apache.spark.serializer.Serializer:序列化和发序列化的工具。 3)org.apache.spark.MapOutputTracker;保存Shuffle Map Task输出的位置信息。其中在Driver上的Tracer是 org.apache.spark.MapOutputTrackerMaster;而在Executor上的Tracker是org.apache.spark.MapOutputTrackerWorker,它会从 org.apache.spark.MapOutputTrackerMaster获取信息。 4)org.apache.spark.shuffle.ShuffleManager:Shuffle的管理者,其中Driver端会注册Shuffle的信息,而Executor端会上报和获取Shuffle 的信息。现阶段内置支持Hash Based Shuffle和Sort Based Shuffle,具体实现细节请参阅第7章。 5)org.apache.spark.broadcast.BroadcastManager:广播变量的管理者。 6)org.apache.spark.network.BlockTransferService:Executor读取Shuffle数据的Client。当前支持netty和nio,可以通过 spark.shuffle.blockTransferService来设置。具体详情可以参阅第7章。 7)org.apache.spark.storage.BlockManager:提供了Storage模块与其他模块的交互接口,管理Storage模块。 8)org.apache.spark.SecurityManager:Spark对于认证授权的实现。 9)org.apache.spark.HttpFileServer:可以提供HTTP服务的Server。当前主要用于Executor端下载依赖。 10)org.apache.spark.metrics.MetricsSystem:用于搜集统计信息。 11)org.apache.spark.shuffle.ShuffleMemoryManager:管理Shuffle过程中使用的内存。ExternalAppendOnlyMap和ExternalSorter都会从 ShuffleMemoryManager中申请内存,在数据spill到Disk后会释放内存。当然了,当Task退出时这个内存也会被回收。为了使得每 个thread都会比较公平地获取内存资源,避免一个thread申请了大量内存后造成其他的thread需要频繁地进行spill操作,它采取的 内存分配策略是:对于N个thread,每个thread可以至少申请1/(2*N)的内存,但是至多申请1/N。这个N是动态变化的,感兴 趣的读者可以查阅这个类的具体实现。 在用户创建org.apache.spark.SparkContext时会创建org.apache.spark.SparkEnv。 3.4.3 缓存的处理 如果存储级别不是NONE,那么先检查是否有缓存;没有缓存则要进行计算。什么是存储级别?从用户的角度来看就是缓存保 存到不同的存储位置,比如内存、硬盘、Tachyon;还有缓存的数据是否需要序列化等。详细的存储级别的介绍可以参阅第8 章。 cacheManager对Storage模块进行了封装,使得RDD可以更加简单地从Storage模块读取或者写入数据。RDD的每个Partition对应 Storage模块的一个Block,只不过Block是Partition经过处理后的数据。在系统实现的层面上,可以认为Partition和Block是一一对应 的。cacheManager会通过getOrCompute来判断当前的RDD是否需要进行计算。 首先,cacheManager会通过RDD的ID和当前计算的Partition的ID向Storage模块的BlockManager发起查询请求,如果能够获得Block 的信息,会直接返回Block的信息。否则,代表该RDD是需要计算的。这个RDD以前可能计算过并且被存储到了内存中,但是 后来由于内存紧张,这部分内存被清理了。在计算结束后,计算结果会根据用户定义的存储级别,写入BlockManager中。这 样,下次就可以不经过计算而直接读取该RDD的计算结果了。核心实现逻辑如下: def getOrCompute[T]( rdd: RDD[T], partition: Partition, context: TaskContext, storageLevel: StorageLevel): Iterator[T] = { // 获取 RDD 的 BlockId val key = RDDBlockId(rdd.id, partition.index) logDebug(s"Looking for partition $key") blockManager.get(key) match { // 向 BlockManager 查询是否有缓存 case Some(blockResult) => // 缓存命中 // 更新统计信息,将缓存作为结果返回 context.taskMetrics.inputMetrics = Some(blockResult.inputMetrics) new InterruptibleIterator(context, blockResult.data.asInstanceOf[Iterator[T]]) case None => // 没有缓存命中,需要计算 // 判断当前是否有线程在处理当前的 Partition ,如果有那么等待它结束后,直接从 Block // Manager 中读取处理结果如果没有线程在计算,那么 storedValues 就是 None ,否则 // 就是计算的结果 val storedValues = acquireLockForPartition[T](key) if (storedValues.isDefined) { // 已经被其他线程处理了,直接返回结果 return new InterruptibleIterator[T](context, storedValues.get) } // 需要计算 try { // 如果被 checkpoint 过,那么读取 checkpoint 的数据;否则调用 rdd 的 compute() 开始 // 计算 val computedValues = rdd.computeOrReadCheckpoint(partition,context) // Task 是在 Driver 端执行的话就不需要缓存结果,这个主要是为了 first() 或者 take() // 这种仅仅有一个执行阶段的任务的快速执行。这类任务由于没有 Shuffle 阶段,直接运行 // 在 Driver 端可能会更省时间 if (context.isRunningLocally) { return computedValues } // 将计算结果写入到 BlockManager val updatedBlocks = new ArrayBuffer[(BlockId, BlockStatus)] val cachedValues = putInBlockManager(key, computedValues, storageLevel, updatedBlocks) // 更新任务的统计信息 val metrics = context.taskMetrics val lastUpdatedBlocks = metrics.updatedBlocks.getOrElse( Seq[(BlockId, BlockStatus)]()) metrics.updatedBlocks = Some(lastUpdatedBlocks ++ updatedBlocks.toSeq) new InterruptibleIterator(context, cachedValues) } finally { loading.synchronized { loading.remove(key) // 如果有其他的线程在等待该 Partition 的处理结果,那么通知它们计算已经完成,结果已 // 经存到 BlockManager 中(注意前面那类不会写入 BlockManager 的本地任务) // loading.notifyAll() } } } } 3.4.4 checkpoint的处理 在缓存没有命中的情况下,首先会判断是否保存了RDD的checkpoint,如果有,则读取checkpoint。为了理解checkpoint的RDD是 如何读取计算结果的,需要先看一下checkpoint的数据是如何写入的。 首先在Job结束后,会判断是否需要checkpoint。如果需要,就调用org.apache.spark.rdd.RDDCheckpointData#doCheckpoint。 doCheckpoint首先为数据创建一个目录;然后启动一个新的Job来计算,并且将计算结果写入新创建的目录;接着创建一个 org.apache.spark.rdd.CheckpointRDD;最后,原始RDD的所有依赖被清除,这就意味着RDD的转换的计算链(compute chain)等 信息都被清除。这个处理逻辑中,数据写入的实现在org.apache.spark.rdd.CheckpointRDD$#writeToFile。简要的核心逻辑如下: // 创建一个保存 checkpoint 数据的目录 val path = new Path(rdd.context.checkpointDir.get, "rdd-" + rdd.id) val fs = path.getFileSystem(rdd.context.hadoopConfiguration) if (!fs.mkdirs(path)) { throw new SparkException("Failed to create checkpoint path " + path) } // 创建广播变量 val broadcastedConf = rdd.context.broadcast( new SerializableWritable(rdd.context.hadoopConfiguration)) // 开始一个新的 Job 进行计算,计算结果存入路径 path 中 rdd.context.runJob(rdd, CheckpointRDD.writeToFile[T](path.toString, broadcastedConf) _) // 根据结果的路径 path 来创建 CheckpointRDD val newRDD = new CheckpointRDD[T](rdd.context, path.toString) // 保存结果,清除原始 RDD 的依赖、 Partition 信息等 RDDCheckpointData.synchronized { cpFile = Some(path.toString) cpRDD = Some(newRDD) // RDDCheckpointData 对应的 CheckpointRDD rdd.markCheckpointed(newRDD) // 清除原始 RDD 的依赖, Partition cpState = Checkpointed // 标记 checkpoint 的状态为完成 } 至此,RDD的checkpoint完成,其中checkpoint的数据可以通过checkpointRDD的readFromFile读取。但是,上述逻辑在清除了RDD 的依赖后,并没有和check-pointRDD建立联系,那么Spark是如何确定一个RDD是否被checkpoint了,而且正确读取checkpoint的 数据呢? 答案就在org.apache.spark.rdd.RDD#dependencies的实现,它会首先判断当前的RDD是否已经Checkpoint过,如果有,那么RDD的 依赖就变成了对应的CheckpointRDD: privatedefcheckpointRDD: Option[RDD[T]]=checkpointData.flatMap(_.checkpointRDD) final def dependencies: Seq[Dependency[_]] = { checkpointRDD.map(r => List(new OneToOneDependency(r))).getOrElse { if (dependencies_ == null) { // 没有 checkpoint dependencies_ = getDependencies } dependencies_ } } 理解了Checkpoint的实现过程,接下来看一下computeOrReadCheckpoint的实现。前面提到了,它一共在两个地方被调 用,org.apache.spark.rdd.RDD#iterator和org.apache.spark.CacheManager#getOrCompute。它实现的逻辑比较简单,首先检查当前 RDD是否被Checkpoint过,如果有,读取Checkpoint的数据;否则开始计算。实现如下: private[spark] def computeOrReadCheckpoint(split: Partition, context: TaskContext) : Iterator[T] = { if (isCheckpointed) firstParent[T].iterator(split, context) else compute(split, context) } firstParent[T].iterator(split,context)会调用对应CheckpointRDD的iterator,最终调用到它的compute: override def compute(split: Partition, context: TaskContext): Iterator[T] = { val file=new Path(checkpointPath, CheckpointRDD.splitIdToFile(split.index)) CheckpointRDD.readFromFile(file, broadcastedConf, context) // 读取 Checkpoint 的数据 } 3.4.5 RDD的计算逻辑 RDD的计算逻辑在org.apache.spark.rdd.RDD#compute中实现。每个特定的RDD都会实现compute。比如前面提到的CheckpointRDD 的compute就是直接读取checkpoint数据。HadoopRDD就是读取指定Partition的数据。MapPartitionsRDD就是将用户的转换逻辑作用 到指定的Partition上。 3.5 RDD的容错机制 RDD实现了基于Lineage的容错机制。RDD的转换关系,构成了compute chain,可以把这个compute chain认为是RDD之间演化的 Lineage。在部分计算结果丢失时,只需要根据这个Lineage重算即可。 图3-11中,假如RDD2所在的计算作业先计算的话,那么计算完成后RDD1的结果就会被缓存起来。缓存起来的结果会被后续的 计算使用。图中的示意是说RDD1的Partition2缓存丢失。如果现在计算RDD3所在的作业,那么它所依赖的Partition0、1、3和4的 缓存都是可以使用的,无须再次计算。但是Partition2由于缓存丢失,需要从头开始计算,Spark会从RDD0的Partition2开始,重 新开始计算。 内部实现上,DAG被Spark划分为不同的Stage,Stage之间的依赖关系可以认为就是Lineage。关于DAG的划分可以参阅第4章。 提到Lineage的容错机制,不得不提Tachyon。Tachyon包含两个维度的容错,一个是Tachyon集群的元数据的容错,它采用了类似 于HDFS的Name Node的元数据容错机制,即将元数据保存到一个Image文件,并且保存了元数据变化的编辑日志(EditLog)。 另外一个是Tachyon保存的数据的容错机制,这个机制类似于RDD的Lineage,Tachyon会保留生成文件数据的Lineage,在数据丢 失时会通过这个Lineage来恢复数据。如果是Spark的数据,那么在数据丢失时Tachyon会启动Spark的Job来重算这部分内容。如 果是Hadoop产生的数据,那么重新启动相应的Map Reduce Job就可以。现在Tachyon的容错机制的实现还处于开发阶段,并不推 荐将这个机制应用于生产环境。不过,这并不影响Spark使用Tachyon。如果Spark保存到Tachyon的部分数据丢失,那么Spark会 根据自有的容错机制来重算这部分数据。 图3-11 RDD的部分缓存丢失的逻辑图 3.6 小结 RDD是Spark最基本,也是最根本的数据抽象。RDD是只读的、分区记录的集合。RDD只能基于在稳定物理存储中的数据集和 其他已有的RDD上执行确定性操作来创建。这些确定性操作称为转换,如map、filter、groupBy、join。RDD支持丰富的转换操 作,极大地简化了用户应用的编写。 RDD不需要物化。RDD含有如何从其他RDD衍生(即计算)出本RDD的相关信息(即Lineage),据此在RDD部分分区数据丢失 时可以通过物理存储的数据计算出相应的RDD分区。 第4章 Scheduler模块详解 Scheduler(任务调度)模块作为Spark Core的核心模块之一,充分地体现了与MapReduce完全不同的设计思想。Spark对于 DAG(Directed Acyclic Graph,有向无环图)的实现及不同执行阶段的划分和任务的提交执行,充分体现了其设计的优雅和高 效。本章主要介绍任务调度,即组成应用的多个Job之间如何分配计算资源。 4.1 模块概述 4.1.1 整体架构 任务调度模块主要包含两大部分,即DAGScheduler和TaskScheduler,它们负责将用户提交的计算任务按照DAG划分为不同的阶 段并且将不同阶段的计算任务提交到集群进行最终的计算。整个过程可以使用图4-1表示。 RDD Objects可以理解为用户实际代码中创建的RDD,这些代码逻辑上组成了一个DAG,支持复杂拓扑。Spark的易用性就体现 在这部分,它提供了基于RDD的多种转换和动作,使Spark的用户可以在基本不增加用户学习成本的前提下使用比较复杂的拓 扑来实现策略。用户实际编程的时候可以认为它处理的数据都可以存到内存中,而无须关心最终在集群中运行的任务是否整个 数据可以装载到内存中或者说究竟需要多少节点参与运算(如果对运算速度或者性能有要求,需要设置一些合适的参数,请具 体参阅第6.7.8章的性能调优部分)。 图4-1 任务调度逻辑视图 DAGScheduler主要负责分析用户提交的应用,并根据计算任务的依赖关系建立DAG,然后将DAG划分为不同的Stage(阶 段),其中每个Stage由可以并发执行的一组Task构成,这些Task的执行逻辑完全相同,只是作用于不同的数据。而且DAG在不 同的资源管理框架(即部署方式,包括Standalone、Mesos、YARN、Local、EC2等)下的实现是相同的。 在DAGScheduler将这组Task划分完成后,会将这组Task提交到TaskScheduler。TaskScheduler通过Cluster Manager在集群中的某个 Worker的Executor上启动任务。在Executor中运行的任务,如果缓存中没有计算结果,那么就需要开始计算,同时,计算的结果 会回传到Driver或者保存在本地。在不同的资源管理框架下,TaskScheduler的实现方式是有差别的,但是最重要的实现是 org.apache.spark.scheduler.TaskSchedulerImpl。对于Local、Standalone和Mesos来说,它们的TaskScheduler就是TaskSchedulerImpl;对 于YARN Cluster和YARN Client的TaskScheduler的实现也是继承自TaskSchedulerImpl。 4.1.2 Scheduler的实现概述 任务调度模块涉及的最重要的三个类是: 1)org.apache.spark.scheduler.DAGScheduler 2)org.apache.spark.scheduler.SchedulerBackend 3)org.apache.spark.scheduler.TaskScheduler 其中1就是前面提到的DAGScheduler的实现。 org.apache.spark.scheduler.SchedulerBackend是一个trait,作用是分配当前可用的资源,具体就是向当前等待分配计算资源的Task 分配计算资源(即Executor),并且在分配的Executor上启动Task,完成计算的调度过程。它使用reviveOffers完成上述的任务调 度。reviveOffers可以说是它最重要的实现。org.apache.spark.scheduler.cluster.CoarseGrainedSchedulerBackend是SchedulerBackend的一 个实现,同时YARN、Standalone和Mesos都是基于它加入了自身特有的逻辑。 org.apache.spark.scheduler.TaskScheduler也是一个trait,它的作用是为创建它的SparkContext调度任务,即从DAGScheduler接收不同 Stage的任务,并且向集群提交这些任务,并为执行特别慢的任务启动备份任务。TaskScheduler是以后实现多种任务调度器的基 础,不过当前的org.apache.spark.scheduler.TaskSchedulerImpl是唯一实现。TaskSchedulerImpl会在以下几种场景下调用 org.apache.spark.scheduler.Scheduler-Backend#reviveOffers: 1)有新任务提交时。 2)有任务执行失败时。 3)计算节点(即Executor)不可用时。 4)某些任务执行过慢而需要为它重新分配资源时。 图4-2描述了Standalone、YARN和Local部署方式的TaskScheduler和Scheduler-Backend的类图。 每个SchedulerBackend都会对应一个的唯一TaskScheduler,而它们都被Spark-Context创建和持有。 图4-2 模块类图 类图中没有包含Mesos的实现,实际上,Mesos的TaskScheduler也是TaskScheduler-Impl,而Mesos有两种SchedulerBackend,一个 就是“粗放型”(coarsegrained)的,即org.apache.spark.scheduler.cluster.mesos.CoarseMesos-SchedulerBackend,它继承自 org.apache.spark.scheduler.cluster.CoarseGrainedSchedulerBackend,并加入了Mesos调度自身特有的逻辑;另外一个就是“细粒 度”(fine-grained)的,即org.apache.spark.scheduler.cluster.mesos.Mesos-SchedulerBackend,它将每个Spark的Task映射到一个Mesos 的Task,从而将这个任务的执行与调度的控制权交给Mesos。 图4-3是任务调度的逻辑图,本章接下来将会详细讨论实现原理和设计思想。 图4-3 任务调度的逻辑图 4.2 DAGScheduler实现详解 前面提到过,DAGScheduler主要负责将用户的应用的DAG划分为不同的Stage(阶段),其中每个Stage由可以并发执行的一组 Task构成,这些Task的执行逻辑完全相同,只是作用于不同的数据。而且DAG在不同的资源管理框架(即部署方式,包括 Standalone,Mesos,YARN,Local,EC2等)下的实现是相同的。 以RDD的Action count为例,解释一下Spark的内部实现原理。首先,RDD的count实际上是调用SparkContext的 org.apache.spark.SparkContext#runJob来进行提交Job的: def count(): Long = sc.runJob(this, Utils.getIteratorSize _).sum SparkContext实现了很多runJob,这些函数的区别就是调用参数的不同,不过这些runJob最后都会调用DAGScheduler的runJob: dagScheduler.runJob(rdd, cleanedFunc, partitions, callSite, allowLocal, resultHandler, localProperties.get) 而DAGScheduler的runJob会开始对用户提交的Job进行处理,包括Stage的划分、Task的生成等。接下来,将从DAGScheduler的创 建,用户提交Job的处理,到结果的生成来详细讲解这个过程。 4.2.1 DAGScheduler的创建 TaskScheduler和DAGScheduler都是在SparkContext创建的时候创建的。其中,Taskcheduler是通过 org.apache.spark.SparkContext#createTaskScheduler创建的。而DAGScheduler是直接调用的它的构造函数创建。只不 过,DAGScheduler保存了TaskScheduler的引用,因此需要在TaskScheduler创建之后进行创建。 在SparkContext中创建DAGScheduler: dagScheduler = new DAGScheduler(this) 这个构造函数的实现是: def this(sc: SparkContext) = this(sc, sc.taskScheduler) 继续看this(sc,sc.taskScheduler)的实现: def this(sc: SparkContext, taskScheduler: TaskScheduler) = { this( sc, taskScheduler, sc.listenerBus, sc.env.mapOutputTracker.asInstanceOf[MapOutputTrackerMaster], sc.env.blockManager.master, sc.env) } this(sc,sc.taskScheduler)通过调用下面的构造函数完成DAGScheduler的创建: private[spark] class DAGScheduler( private[scheduler] val sc: SparkContext, private[scheduler] val taskScheduler: TaskScheduler, listenerBus: LiveListenerBus, mapOutputTracker: MapOutputTrackerMaster, blockManagerMaster: BlockManagerMaster, env: SparkEnv, clock: Clock = SystemClock) extends Logging { } 其中,MapOutputTrackerMaster是运行在Driver端管理Shuffle Map Task的输出的,下游的Task可以通过MapOutputTrackerMaster来 获取Shuffle输出的位置信息,具体的实现详情请参阅第7章。BlockManagerMaster也是运行在Driver端的,它管理整个Job的Block 的信息,具体的实现详情请参阅第8章。 DAGScheduler除了初始化用于保存集群状态信息的数据结构,还会创建一个 Actor-(org.apache.spark.scheduler.DAGSchedulerEventProcessActor,变量的名称为event-ProcessActor),这个Actor的主要职责就 是调用DAGScheduler的相应方法来处理DAGScheduler发送给它的各种消息。这个Actor的创建比较特殊,需要重点梳理一下。 由于要保证eventProcessActor在DAGScheduler创建完成之后就需要处于可用的状态,因此Spark引入了 org.apache.spark.scheduler.DAGSchedulerActorSupervisor,由它来完成eventProcessActor的创建。 DAGSchedulerActorSupervisor主要利用了Actor的监督策略,即它创建的子Actor在出现错误时是如何进行错误处理;如果它的子 Actor(就是eventProcessActor)出现错误,则取消DAGScheduler的所有Job,停止SparkContext,最终退出。DAGS- chedulerActorSupervisor只处理一种消息,即创建一个Actor: def receive = { case p: Props => sender ! context.actorOf(p) case _ => logWarning("received unknown message in DAGSchedulerActorSupervisor") } DAGSchedulerEventProcessActor 的创建过程: implicit val timeout = Timeout(30 seconds) val initEventActorReply = dagSchedulerActorSupervisor ? Props(new DAGSchedulerEventProcessActor(this)) eventProcessActor = Await.result(initEventActorReply, timeout.duration). asInstanceOf[ActorRef] 说明一下,向Actor发送消息!和?的区别是:!发送消息后立即返回;而?发送消息后会返回一个Future对象。因此 eventProcessActor是一个可用的Actor,到这里DAGScheduler已经可以接受外界的消息,开始提供服务了。 4.2.2 Job的提交 前面提到过,用户提交的Job最终会调用DAGScheduler的runJob,它又会调用submitJob。以RDD的动作count为例,调用过程如 下: 1) org.apache.spark.rdd.RDD#count 2) org.apache.spark.SparkContext#runJob 3) org.apache.spark.scheduler.DAGScheduler#runJob 4) org.apache.spark.scheduler.DAGScheduler#submitJob 5) org.apache.spark.scheduler.DAGSchedulerEventProcessActor#receive ( JobSubmitted ) 6) org.apache.spark.scheduler.DAGScheduler#handleJobSubmitted 调用栈3主要的实现就是调用submitJob来处理: val waiter = submitJob(rdd, func, partitions, callSite, allowLocal, resultHandler,properties) waiter.awaitResult() match { case JobSucceeded => { logInfo("Job %d finished: %s, took %f s".format (waiter.jobId, callSite.shortForm, (System.nanoTime - start) / 1e9)) } case JobFailed(exception: Exception) => logInfo("Job %d failed: %s, took %f s".format (waiter.jobId, callSite.shortForm, (System.nanoTime - start) / 1e9)) throw exception } 而submitJob首先会为这个Job生成一个Job ID,并且生成一个JobWaiter的实例来监听Job的执行情况: val waiter = new JobWaiter(this, jobId, partitions.size, resultHandler) JobWaiter会监听Job的执行状态,而Job是由多个Task组成的,因此只有Job的所有Task都成功完成,Job才标记为成功;任意一 个Task失败都会标记该Job失败,这是DAGScheduler通过调用org.apache.spark.scheduler.JobWaiter#jobFailed实现的。 对于近似估计的Job,DAGScheduler会调用runApproximateJob,除了JobWaiter换成了 org.apache.spark.partial.ApproximateActionListener,其余的逻辑是相同的。 最后,DAGScheduler会向eventProcessActor提交该Job: eventProcessActor ! JobSubmitted( jobId, rdd, func2, partitions.toArray, allowLocal, callSite, waiter, properties) eventProcessActor最终会调用org.apache.spark.scheduler.DAGScheduler#handleJob-Submitted来处理这次提交的Job。 4.2.3 Stage的划分 org.apache.spark.scheduler.DAGScheduler#handleJobSubmitted首先会根据RDD创建finalStage。finalStage,顾名思义,就是最后的那个Stage。然 后创建ActiveJob后提交计算任务。在讨论这个过程的实现之前,先看一下究竟什么是Stage。 用户提交的计算任务是一个由RDD构成的DAG,如果RDD在转换的时候需要做Shuffle,那么这个Shuffle的过程就将这个DAG分为了不同 的阶段(即Stage)。由于Shuffle的存在,不同的Stage是不能并行计算的,因为后面Stage的计算需要前面Stage的Shuffle的结果。而一个 Stage由一组完全独立的计算任务(即Task)组成,每个Task的运算逻辑完全相同,只不过每个Task都会处理其所对应的Partition。其 中,Partition的数量和Task的数量是一致的,即一个Partition会被该Stage的一个Task处理。 1.划分依据 那么RDD在哪种转换的时候需要Shuffle呢?这个取决于该RDD和它所依赖的RDD(s)的关系。RDD和它依赖的parent RDD(s)的关系有 两种不同的类型,即窄依赖(narrow dependency)和宽依赖(wide dependency)。 对于窄依赖,由于RDD每个Partition依赖固定数量的parent RDD的Partition,因此可以通过一个Task来处理这些Partition,而且这些Partition相 互独立,所以这些Task可以并行执行。 对于宽依赖,由于需要Shuffle,因此只有所有的parent RDD的Partition Shuffle完成,新的Partition才会形成,这样接下来的Task才可以继续处 理。因此,宽依赖可以认为是DAG的分界线,或者说Spark根据宽依赖将Job划分为不同的阶段(Stage)。 关于窄依赖和宽依赖的定义,请查看3.3.1小节。 2.划分过程 理解了窄依赖和宽依赖,下面以图4-4为例讲解Stage的具体划分过程。 图4-4 RDD划分示意 Stage的划分是从最后一个RDD开始的,也就是触发Action的那个RDD。图4-4中就是RDD G。首先,RDD G会从SparkContext的runJob开 始,通过以下的调用栈来开始Stage的划分: 1 ) org.apache.spark.SparkContext#runJob 2 ) org.apache.spark.scheduler.DAGScheduler#runJob 3 ) org.apache.spark.scheduler.DAGScheduler#submitJob 4 ) org.apache.spark.scheduler.DAGSchedulerEventProcessActor#receive ( JobSubmitted ) 5 ) org.apache.spark.scheduler.DAGScheduler#handleJobSubmitted handleJobSubmitted会开始Stage的划分。划分从RDD G的依赖开始,RDD G依赖于两个RDD,一个是RDD B,一个是RDD F。其中先处理 RDD B还是RDD F是随机的。这里首先处理RDD B,由于和RDD B的依赖是窄依赖,因此RDD G和RDD B可以划分到一个Stage(即Stage 3)。再处理RDD F,由于这个依赖是宽依赖,RDD F和RDD G被划分到不同的Stage(即Stage 2和Stage 3),其中RDD F所在的Stage 2是 RDD G所在的Stage 3的parent Stage。 接下来处理RDD B的依赖,由于它依赖的RDD A是宽依赖,因此它们属于不同的Stage(即Stage 1和Stage 3)。这样,RDD B和RDD G同 属于Stage 3,而这个Stage直接的parent Stage有两个,就是RDD A和RDD F分别属于的两个Stage(即Stage 1和Stage 2)。接下来的划分采 用相同的逻辑,递归进行。 最后,这个DAG被划分为三个Stage,即RDD A所在的Stage 1,RDD C、RDD D、RDD E、RDD F所在的Stage 2和RDD B及RDD G所在的 Stage 3。其中Stage 1和Stage 2是相互独立的,可以并发执行;Stage 3依赖于Stage 1和Stage 2,只有Stage 1和Stage 2完成计算,它才可以开 始计算。 那么Stage是如何计算的?以Stage 1为例,由于RDD A有三个Partition,因此它会生成三个org.apache.spark.scheduler.ShuffleMapTask,这些 Task会将结果写入到三个Partition,实现细节可以参阅第7章。Stage 2同样也是由ShuffleMapTask组成。不过Stage 3是由三个 org.apache.spark.scheduler.ResultTask构成的。 3.实现细节 handleJobSubmitted通过调用org.apache.spark.scheduler.DAGScheduler#newStage来创建finalStage,即上图中的Stage 3: finalStage = newStage(finalRDD, partitions.size, None, jobId, callSite) newStage首先会获取当前Stage的parent Stage,然后创建当前的Stage: val parentStages = getParentStages(rdd, jobId) val id = nextStageId.getAndIncrement() val stage = new Stage(id, rdd, numTasks, shuffleDep, parentStages, jobId, callSite) 通过调用getParentStages,图4-4中的Stage 1和Stage 2就创建出来了。然后根据它们来创建Stage 3。 getParentStages是划分Stage的核心实现。它负责生成当前RDD所在的Stage的parent Stage。它的实现是每遇到一个ShuffleDependency(宽依 赖的一种),就会生成一个parent Stage。下面是源码实现: private def getParentStages(rdd: RDD[_], jobId: Int): List[Stage] = { val parents = new HashSet[Stage] // 存储 parent stage val visited = new HashSet[RDD[_]] // 存储已经被访问到得 RDD // 存储需要被处理的 RDD , Stack 中的 RDD 都需要被处理 val waitingForVisit = new Stack[RDD[_]] def visit(r: RDD[_]) { // 广度优先遍历 rdd 生成的依赖树 if (!visited(r)) { visited += r for (dep <- r.dependencies) { // 逐个处理当前 RDD 依赖的 parent RDD dep match { case shufDep: ShuffleDependency[_, _, _] => // 在依赖是 ShuffleDependency 时需要生成新的 stage parents += getShuffleMapStage(shufDep, jobId) case _ => // 不是 ShuffleDependency ,那么就属于同一个 Stage waitingForVisit.push(dep.rdd) } } } } // 以输入的 rdd 作为第一个需要处理的 RDD 。然后从该 rdd 开始,顺序处理其 parent rdd waitingForVisit.push(rdd) while (!waitingForVisit.isEmpty) { // 只要 stack 不为空,则一直处理。 // 每次 visit 如果遇到 了 ShuffleDependency ,那么就会形成一个 Stage ,否则这些 RDD 属于 同一个 Stage visit(waitingForVisit.pop()) } parents.toList } getShuffleMapStage就是获取ShuffleDependency所依赖的Stage,如果没有则创建新的Stage: private def getShuffleMapStage(shuffleDep: ShuffleDependency[_, _, _], jobId: Int): Stage = { shuffleToMapStage.get(shuffleDep.shuffleId) match {// 根据 shuffleId 查找 Stage 是否存在 case Some(stage) => stage // 如果已经创建,则直接返回 case None => // 看是否该 Stage 的 Parent Stage 已经生成,如果没有,则生成它们 registerShuffleDependencies(shuffleDep, jobId) // 生成当前 rdd 所在的 Stage val stage = newOrUsedStage( shuffleDep.rdd, shuffleDep.rdd.partitions.size, shuffleDep, jobId, shuffleDep.rdd.creationSite) shuffleToMapStage(shuffleDep.shuffleId) = stage stage } } 上述代码中有两个调用比较重要:一个是registerShuffleDependencies,负责确认该Stage的parent Stage是否已经生成,如果没有则生成它 们;另外一个是newOrUsedStage,它也是生成一个Stage,不过如果这个Stage已存在,那么将恢复这个Stage的结果,从而避免了重复计 算。registerShuffleDependencies的实现如下: private def registerShuffleDependencies(shuffleDep: ShuffleDependency[_, _, _], jobId: Int) = { // 首先获取没有生成 Stage 的 ShuffleDependency val parentsWithNoMapStage = getAncestorShuffleDependencies(shuffleDep.rdd) while (!parentsWithNoMapStage.isEmpty) { val currentShufDep = parentsWithNoMapStage.pop() // 根据 ShuffleDependency 来生成 Stage val stage = newOrUsedStage( currentShufDep.rdd, currentShufDep.rdd.partitions.size, currentShufDep, jobId, currentShufDep.rdd.creationSite) shuffleToMapStage(currentShufDep.shuffleId) = stage } } getAncestorShuffleDependencies的实现和getParentStages差不过,只不过它遇到ShuffleDependency时首先会判断Stage是否已经存在,不存在则 把这个依赖作为返回值的一个元素,由调用者来完成Stage的创建。 newOrUsedStage的实现如下: val stage = newStage(rdd, numTasks, Some(shuffleDep), jobId, callSite) if (mapOutputTracker.containsShuffle(shuffleDep.shuffleId)) { //Stage 已经被计算过,从 mapOutputTracer 中获取计算结果 val serLocs = mapOutputTracker.getSerializedMapOutputStatuses(shuffle Dep.shuffleId) val locs = MapOutputTracker.deserializeMapStatuses(serLocs) for (i <- 0 until locs.size) { // 计算结果复制到 stage 中 stage.outputLocs(i) = Option(locs(i)).toList // locs(i) will be null if missing } // 保存 stage 可用结果的数量;对于不可用的部分,会被重新计算 stage.numAvailableOutputs = locs.count(_ != null) } else { // 向 mapOutputTracer 中注册该 Stage mapOutputTracker.registerShuffle(shuffleDep.shuffleId, rdd.partitions.size) } ShuffleMapTask的计算结果都会传给Driver端的mapOutputTracker,其他的Task可以通过查询它来获取这些结果。 mapOutputTracker.registerShuffle(shuffleDep.shuffleId,rdd.partitions.size)实现了存储这些元数据的占位,而ShuffleMapTask的结果通过调用 registerMapOutputs来保存计算结果。这个结果并不是真实的数据,而是这些数据所在的位置、大小等元数据信息,这样下游的Task就可 以通过这些元数据信息获取其需要处理的数据。这个实现详情可以参阅第7章。 4.2.4 任务的生成 现在让我们回到org.apache.spark.scheduler.DAGScheduler#handleJobSubmitted,生成finalStage后就会为该Job生成一个 org.apache.spark.scheduler.ActiveJob,并准备计算这个finalStage: val job = new ActiveJob(jobId, finalStage, func, partitions, callSite, listener, properties) 这个Job如果满足以下所有条件,那么它将以本地模式运行: 1)spark.localExecution.enabled设置为true 2)用户程序显式指定可以本地运行 3)finalStage没有parent Stage 4)仅有一个Partition 其中,3和4主要为了保证任务可以快速执行;如果有多个Stage或者多个Partition的话,本地运行可能会因为本机计算资源的局 限性而影响任务的计算速度。 除了本地运行的,handleJobSubmitted会调用submitStage来提交这个Stage,如果它的某些parent Stage没有提交,那么递归提交那 些未提交(或者叫未计算)的parent Stage。只有所有的parent Stage都计算完成后,才能提交它: private def submitStage(stage: Stage) { val jobId = activeJobForStage(stage) if (jobId.isDefined) { logDebug("submitStage(" + stage + ")") // 如果当前 stage 不再等待其 parent stage 的返回,不是正在运行,且没有提示失败,那么 // 就尝试提交它 if (!waitingStages(stage) && !runningStages(stage) && !failedStages(stage)) { val missing = getMissingParentStages(stage).sortBy(_.id) logDebug("missing: " + missing) if (missing == Nil) { // 如果所有的 parent stage 都已经完成,那么提交该 stage 所包含的 task logInfo("Submitting " + stage + " (" + stage.rdd + "), which has no missing parents") submitMissingTasks(stage, jobId.get) } else { for (parent <- missing) { // 有 parent stage 未完成,则递归提交它 submitStage(parent) } waitingStages += stage } } } else { // 无效的 stage ,直接停止它 abortStage(stage, "No active job for stage " + stage.id) } } 前面图4-4划分的Stage的提交逻辑如图4-5所示。三个Stage中,Stage1和Stage2是可以并行的,因此在有计算资源的情况下会首 先被提交,并且在这两个Stage都计算完成的情况下,提交Stage 3。最后的计算都是在计算节点的Executor中执行的。 图4-5 Stage提交顺序图 org.apache.spark.scheduler.DAGScheduler#submitMissingTasks会完成DAGScheduler最后的工作,向TaskScheduler提交Task。首先,它 先取得需要计算的Partition,对于最后的Stage,它对应的Task是ResultTask,因此判断该Partition的ResultTask是否已经结束,如果 结束那么就无需计算;对于其他的Stage,它们对应的Task都是ShuffleMapTask,因此只需要判断Stage是否有缓存的结果即可 (结果都会缓存到org.apache.spark.scheduler.Stage#outputLocs中)。 在判断出哪些Partition需要计算后,就会为每个Partition生成Task,然后这些Task会被封装到 org.apache.spark.scheduler.TaskSet#TaskSet,然后提交给TaskScheduler。从逻辑上,Stage包含了TaskSet,图4-5的划分结果的逻辑 视图就转换成了图4-6。 图4-6 Task被保存到TaskSet后的逻辑视图 TaskSet保存了Stage包含的一组完全相同的Task,每个Task的处理逻辑完全相同,不同的是处理数据,每个Task负责处理一个 Partition。对于一个Task来说,它从数据源获得逻辑,然后按照拓扑顺序,顺序执行。 TaskSet是一个数据结构,存储了这一组Task: private[spark] class TaskSet( val tasks: Array[Task[_]], val stageId: Int, val attempt: Int, val priority: Int, val properties: Properties) { val id: String = stageId + "." + attempt override def toString: String = "TaskSet " + id } 至此,DAGScheduler就完成了它的使命,然后它会等待TaskScheduler最终向集群提交这些Task,监听这些Task的状态。 4.3 任务调度实现详解 每个TaskScheduler都对应一个SchedulerBackend。其中,TaskScheduler负责Application的不同Job之间的调度,在Task执行失败时启 动重试机制,并且为执行速度慢的Task启动备份的任务。而SchedulerBackend负责与Cluster Manager交互,取得该Application分配 到的资源,并且将这些资源传给TaskScheduler,由TaskScheduler为Task最终分配计算资源。 4.3.1 TaskScheduler的创建 TaskScheduler和DAGScheduler都是在SparkContext创建的时候创建的。其中,TaskScheduler是通过 org.apache.spark.SparkContext#createTaskScheduler创建的。 org.apache.spark.SparkContext#createTaskScheduler会根据传入的Master的URL的规则判断集群的部署方式(或者说是资源管理方 式),比如是Standalone、Mesos、YARN或者是Local等。根据不同的部署方式,生成不同的TaskScheduler和SchedulerBackend。 org.apache.spark.scheduler.SchedulerBackend是一个trait,作用是分配当前可用的资源,具体就是向当前等待分配计算资源的Task 分配计算资源(即Executor),并且在分配的Executor上启动Task,完成计算的调度过程。它使用reviveOffers完成上述的任务调 度。reviveOffers可以说是它最重要的实现。 以Standalone模式为例,创建过程如下: val SPARK_REGEX = """spark://(.*)""".r master match { case SPARK_REGEX(sparkUrl) => val scheduler = new TaskSchedulerImpl(sc) val masterUrls = sparkUrl.split(",").map("spark://" + _) val backend = new SparkDeploySchedulerBackend(scheduler, sc, masterUrls) scheduler.initialize(backend) (backend, scheduler) 如果需要开启任务的推测执行,那么需要将spark.speculation设置为true,在TaskScheduler创建完成后,会周期性地调用 org.apache.spark.scheduler.TaskSetManager的checkSpeculatableTasks来检查是否有这种Task: sc.env.actorSystem.scheduler.schedule(SPECULATION_INTERVAL milliseconds, SPECULATION_INTERVAL milliseconds) { Utils.tryOrExit { checkSpeculatableTasks() } } 周期性的间隔是通过spark.speculation.interval设置的,单位是毫秒;默认是100毫秒。 4.3.2 Task的提交概述 DAGScheduler完成了对Stage的划分后,会按照顺序逐个将Stage通过下面的调用提交到TaskScheduler: org.apache.spark.scheduler.TaskSchedulerImpl#submitTasks 由submitTasks开始Task级别的资源调度。最终,这些Task会被分配Executor,运行在Worker上的Executor完成任务的最终执行。 这个过程详细的调用堆栈如下: 1)org.apache.spark.scheduler.TaskSchedulerImpl#submitTasks 2)org.apache.spark.scheduler.SchedulableBuilder#addTaskSetManager 3)org.apache.spark.scheduler.cluster.CoarseGrainedSchedulerBackend#reviveOffers 4)org.apache.spark.scheduler.cluster.CoarseGrainedSchedulerBackend. DriverActor#makeOffers 5)org.apache.spark.scheduler.TaskSchedulerImpl#resourceOffers 6)org.apache.spark.scheduler.cluster.CoarseGrainedSchedulerBackend. DriverActor#launchTasks 7)org.apache.spark.executor.CoarseGrainedExecutorBackend. receiveWithLogging#launchTask 8)org.apache.spark.executor.Executor#launchTask 这只是一个简化了的调用栈。每个调用可能还会调用很多其他的任务,接下来会详解每个调用的具体实现。这个简化的调用栈 只是为了说明任务是如何从TaskScheduler开始,最终提交的到Executor上去的。其中,调用栈1~6都是在Driver端的;7~8就到了 Executor上。8最终完成了任务的执行。关于7和8任务执行的详情,可以参阅第6章。本节主要讨论在Driver端的调度处理的实 现。 在调用栈1中,主要是将保存这组任务的TaskSet加入到一个TaskSetManager中: val manager = new TaskSetManager(this, taskSet, maxTaskFailures) TaskSetManager会根据数据的就近原则(locality aware)为Task分配计算资源,监控Task的执行状态并采取必要的措施,比如失 败重试,慢任务的推测性执行。 调用栈2对应的源码是: schedulableBuilder.addTaskSetManager(manager, manager.taskSet.properties) schedulableBuilder是Application级别的调度器,现在支持两种调度策略,FIFO(Fist In First Out,先进先出)和FAIR(公平调 度)。调度策略可以通过spark.scheduler.mode设置,默认是FIFO。schedulableBuilder会确定TaskSetManager的调度顺序,然后由 TaskSetManager根据就近原则来确定Task运行在哪个Executor上。 调用栈3和4并没有特别复杂的逻辑。 调用栈5就是响应CoarseGrainedSchedulerBackend的资源调度请求,为每个Task具体分配资源。它的输入是一个Executor的列表, 输出是org.apache.spark.scheduler.TaskDescription的二维数组。TaskDescription包含了Task ID、Executor ID和Task执行环境的依赖信 息等。省略了非关键代码的具体实现如下: def resourceOffers(offers: Seq[WorkerOffer]): Seq[Seq[TaskDescription]] = synchronized { // Mark each slave as alive and remember its hostname // Also track if new executor is added var newExecAvail = false for(o <- offers) { if (!executorsByHost.contains(o.host)) { // 有新的 Executor 加入 executorsByHost(o.host) = new HashSet[String]() executorAdded(o.executorId, o.host) newExecAvail = true } } // 为了避免将 Task 集中分配到某些机器,随机打散它们 val shuffledOffers = Random.shuffle(offers) // 存储分配好资源的 task val tasks = shuffledOffers.map(o => new ArrayBuffer[TaskDescription] (o.cores)) val availableCpus = shuffledOffers.map(o => o.cores).toArray // 非常重要,获取按照调度策略排序好的 TaskSetManager val sortedTaskSets = rootPool.getSortedTaskSetQueue for (taskSet <- sortedTaskSets) { if (newExecAvail) { taskSet.executorAdded() // 重新计算该 TaskSetManager 的就近原则 } }// 为从 rootPool 里获取的 TaskSetManager 列表分配资源。分配的原则是就近原则,优先分配顺 // 序 PROCESS_LOCAL 、 NODE_LOCAL 、 NO_PREF 、 RACK_LOCAL, ANY var launchedTask = false for (taskSet <- sortedTaskSets; maxLocality <- taskSet.myLocalityLevels) { do { launchedTask = false for (i <- 0 until shuffledOffers.size) {// 顺序遍历当前存在的 Executor val execId = shuffledOffers(i).executorId //Executor 的 ID val host = shuffledOffers(i).host //Executor 的 host 名字 if (availableCpus(i) >= CPUS_PER_TASK) { // 该 Executor 可以被分配任 // 务核心实现,通过调用 TaskSetManager 来为 Executor 分配 Task for (task <- taskSet.resourceOffer(execId, host, maxLocality)) { tasks(i) += task val tid = task.taskId taskIdToTaskSetId(tid) = taskSet.taskSet.id taskIdToExecutorId(tid) = execId executorsByHost(host) += execId availableCpus(i) -= CPUS_PER_TASK assert(availableCpus(i) >= 0) launchedTask = true } } } } while (launchedTask) } if (tasks.size > 0) { hasLaunchedTask = true } return tasks } 调用栈6就是将上个调用得到的tasks发送到Executor: executorData.executorActor!LaunchTask(newSerializableBuffer(serializedTask)) Executor在接到这个消息后,就会开始执行Task。这个部分的实现详情请参阅第6章。 4.3.3 任务调度具体实现 在org.apache.spark.scheduler.TaskSchedulerImpl#submitTasks中的关键调用是: schedulableBuilder.addTaskSetManager(manager, manager.taskSet.properties) 调度方式如果是FIFO,schedulableBuilder的实现是org.apache.spark.scheduler.FIFOSchedulableBuilder;调度方式如果是 FAIR,schedulableBuilder的实现就是org.apache.spark.scheduler.FairSchedulableBuilder。它们的创建方式如下: rootPool = new Pool("", schedulingMode, 0, 0) schedulableBuilder = { schedulingMode match { case SchedulingMode.FIFO => // 调度方式是 FIFO new FIFOSchedulableBuilder(rootPool) case SchedulingMode.FAIR => // 调度方式是 FAIR new FairSchedulableBuilder(rootPool, conf) } } schedulableBuilder.buildPools() 其中,org.apache.spark.scheduler.Pool包含了一组可以调度的实体。对于FIFO来说,rootPool包含了一组TaskSetManager;而对于 FAIR来说,rootPool包含了一组Pool,这些Pool构成了一棵调度树,其中这棵树的叶子节点就是TaskSetManager。 schedulableBuilder.buildPools()因调度方式而异,如果是FIFO,它的实现是空的,因为rootPool并没有包含Pool,而是直接包含 TaskSetManager:submitTasks直接将TaskSetManager添加到rootPool即可。 1.FIFO调度 采用FIFO任务调度的顺序是如何决定的?首先要保证Job ID较小的先被调度,如果是同一个Job,那么Stage ID小的先被调度 (同一个Job,可能多个Stage可以并行执行,比如Stage划分过程中图4-4中的Stage 1和Stage 2)。FIFO的调度逻辑如图4-7所 示。 图4-7 FIFO调度逻辑图 它的调度算法如下: private[spark] class FIFOSchedulingAlgorithm extends SchedulingAlgorithm { override def comparator(s1: Schedulable, s2: Schedulable): Boolean = { val priority1 = s1.priority // 这个 priority 实际上是 Job ID val priority2 = s2.priority // 同上 var res = math.signum(priority1 - priority2) // 首先比较 Job ID if (res == 0) { // 如果 Job ID 相同,那么比较 Stage ID val stageId1 = s1.stageId val stageId2 = s2.stageId res = math.signum(stageId1 - stageId2) } if (res < 0) { true } else { false } } } 2.FAIR调度 对于FAIR,它需要在rootPool的基础上根据配置文件来构建这课调度树。一个有效的配置文件如下: FIFO 1 2 FAIR 3 4 核心逻辑实现如下: override def buildPools() { var is: Option[InputStream] = None try { is = Option { // 以 spark.scheduler.allocation.file 设置文件名字创建 // FileInputStream schedulerAllocFile.map { f => new FileInputStream(f) }.getOrElse { // 若 spark.scheduler.allocation.file 没有设置,那么直接以 fairscheduler. xml 创建 FileInputStream Utils.getSparkClassLoader.getResourceAsStream(DEFAULT_SCHEDULER_FILE) } } // 以 is 对应的文件内容建立 Pool is.foreach { i => buildFairSchedulerPool(i) } } finally { is.foreach(_.close()) } // 创建名字为 "default" 的 Pool buildDefaultPool() } 对于FAIR,首先是挂到rootPool下面的pool先确定调度顺序,然后在每个pool内部使用相同的算法来确定TaskSetManager的调度 顺序,调度逻辑如图4-8所示。算法详细实现如下: 图4-8 FAIR调度逻辑图 private[spark] class FairSchedulingAlgorithm extends SchedulingAlgorithm { override def comparator(s1: Schedulable, s2: Schedulable): Boolean = { val minShare1 = s1.minShare val minShare2 = s2.minShare val runningTasks1 = s1.runningTasks val runningTasks2 = s2.runningTasks val s1Needy = runningTasks1 < minShare1 val s2Needy = runningTasks2 < minShare2 val minShareRatio1 = runningTasks1.toDouble / math.max(minShare1, 1.0). toDouble val minShareRatio2 = runningTasks2.toDouble / math.max(minShare2, 1.0). toDouble val taskToWeightRatio1 = runningTasks1.toDouble / s1.weight.toDouble val taskToWeightRatio2 = runningTasks2.toDouble / s2.weight.toDouble var compare:Int = 0 if (s1Needy && !s2Needy) { return true } else if (!s1Needy && s2Needy) { return false } else if (s1Needy && s2Needy) { compare = minShareRatio1.compareTo(minShareRatio2) } else { compare = taskToWeightRatio1.compareTo(taskToWeightRatio2) } if (compare < 0) { true } else if (compare > 0) { false } else { s1.name < s2.name } } } 确定了调度顺序,TaskScheduler就可以按照这个顺序将Task发送到Executor进行执行了。 4.3.4 Task运算结果的处理 1.Driver收到Executor的任务执行结果 Task在Executor执行完成时,会通过向Driver发送StatusUpdate的消息来通知Driver任务的状态更新为TaskState.FINISHED。 Driver首先会将任务的状态更新通知TaskScheduler,然后会在这个Executor上重新分配新的计算任务。TaskScheduler的实现在org.apache.spark.scheduler.Task-SchedulerImpl#statusUpdate: taskIdToTaskSetId.get(tid) match { case Some(taskSetId) => if (TaskState.isFinished(state)) { // 如果 Task 的状态是 FINISHED 或者 FAILED 或者 KILLED 或者 LOST // 该 Task 就认为执行结束,那么清理本地的数据结构 taskIdToTaskSetId.remove(tid) taskIdToExecutorId.remove(tid) } activeTaskSets.get(taskSetId).foreach { taskSet => if (state == TaskState.FINISHED) { // 任务成功完成 //TaskSetManager 标记该任务已经结束,注意这里不一定是成功结束的 taskSet.removeRunningTask(tid) // 处理任务的计算结果 taskResultGetter.enqueueSuccessfulTask(taskSet, tid, serializedData)} else if (Set(TaskState.FAILED, TaskState.KILLED, TaskState.LOST).contains(state)) { //TaskSetManager 标记该任务已经结束,注意这里不一定是成功结束的 taskSet.removeRunningTask(tid) // 处理任务失败的情况 taskResultGetter.enqueueFailedTask(taskSet, tid, state, serializedData) } } } 一个Task的状态只有是TaskState.FINISHED才标记它成功执行;其余的状态包括TaskState.FAILED、TaskState.KILLED和TaskState.LOST都是执行失败。 其中,处理每次结果都是由一个Daemon线程池负责,默认这个线程池由4个线程组成,可以通过spark.resultGetter.threads设置。 Executor在将结果回传到Driver时,会根据结果的大小使用不同的策略: 1)如果结果大于1GB,那么直接丢弃这个结果。这个是Spark1.2中新加的策略。可以通过spark.driver.maxResultSize来进行设置。 2)对于“较大”的结果,将其以tid为key存入org.apache.spark.storage.Block-Manager;如果结果不大,则直接回传给Driver。那么如何判定这个阈值呢? 这里的回传是直接通过AKKA的消息传递机制。因此这个大小首先不能超过这个机制设置的消息的最大值。这个最大值是通过spark.akka.frameSize设置的,单位是MByte,默认值是 10MB。除此之外,还有200KB的预留空间。因此这个阈值就是conf.getInt(""spark.akka.frameSize",10)*1024*1024–200*1024。 3)其他的直接通过AKKA回传到Driver。 2.处理任务成功执行的机制 理解了Executor回传的结果,就能理解Driver端对结果的处理了,处理成功完成Task的具体实现如下: def enqueueSuccessfulTask( taskSetManager: TaskSetManager, tid: Long, serializedData: ByteBuffer) { // 通过线程池来执行结果获取 getTaskResultExecutor.execute(new Runnable { override def run(): Unit = Utils.logUncaughtExceptions { try { val (result, size) = serializer.get().deserialize[TaskResult[_]] (serializedData) match { case directResult: DirectTaskResult[_] => // 结果是计算结果 // 确定大小符合要求 if (!taskSetManager.canFetchMoreResults(serializedData.limit())) { return } (directResult, serializedData.limit()) case IndirectTaskResult(blockId, size) => // 需要向远程的 Worker 网 // 络获取结果 // 确定大小符合要求 if (!taskSetManager.canFetchMoreResults(size)) { // 从远程的 Worker 删除该结果 sparkEnv.blockManager.master.removeBlock(blockId) return } logDebug("Fetching indirect task result for TID %s".format(tid)) scheduler.handleTaskGettingResult(taskSetManager, tid) // 从远程的 BlockManager 获取计算结果 valserializedTaskResult= sparkEnv.blockManager.getRemoteBytes(blockId) if (!serializedTaskResult.isDefined) { // 如果在 Executor 的任务执行完成和 Driver 端取结果之间, Executor 所在机 // 器出现故障或者其他错误,会导致获取结果失败 scheduler.handleFailedTask( taskSetManager, tid, TaskState.FINISHED, TaskResultLost) return } // 反序列化结果 val deserializedResult=serializer.get().deserialize[DirectTaskResult[_]]( serializedTaskResult.get) // 将远程的结果删除 sparkEnv.blockManager.master.removeBlock(blockId) (deserializedResult, size) } result.metrics.resultSize = size scheduler.handleSuccessfulTask(taskSetManager, tid, result) } catch { } } }) } scheduler.handleSuccessfulTask(taskSetManager,tid,result)负责处理获取到的计算结果,这个处理过程的调用栈如下: 1 ) org.apache.spark.scheduler.TaskSchedulerImpl#handleSuccessfulTask 2 ) org.apache.spark.scheduler.TaskSetManager#handleSuccessfulTask 如果 TaskSetManager 的所有 Task 都已经成功完成,那么从 rootPool 中删除它。 3 ) org.apache.spark.scheduler.DAGScheduler#taskEnded 4 ) org.apache.spark.scheduler.DAGScheduler#eventProcessActor 5 ) org.apache.spark.scheduler.DAGScheduler#handleTaskCompletion 核心的处理在调用栈5,它完成了计算结果的处理。对于ResultTask,通过调用org.apache.spark.scheduler.JobWaiter来告知调用者任务已经结束,核心逻辑如下: task match { case rt: ResultTask[_, _] => stage.resultOfJob match { case Some(job) => if (!job.finished(rt.outputId)) { job.finished(rt.outputId) = true job.numFinished += 1 // If the whole job has finished, remove it if (job.numFinished == job.numPartitions) { // 所有 Task 都已经结束,将该 Stage 标记为结束 markStageAsFinished(stage) cleanupStateForJobAndIndependentStages(job) listenerBus.post(SparkListenerJobEnd(job.jobId, JobSucceeded)) } // 对于 ResultTask ,会运行用户自定义的结果处理函数,因此可能会抛出异常 try { job.listener.taskSucceeded(rt.outputId, event.result) } catch { case e: Exception => // 直接标记为失败 job.listener.jobFailed(new SparkDriverExecutionException(e)) } }case None => // 应该有任务的推测执行,因此一个 Task 可能会运行多次 logInfo("Ignoring result from " + rt + " because its job has finished") } job.listener.taskSucceeded(rt.outputId,event.result)实现了对结果的进一步处理,这个处理逻辑是由用户自定义的,然后如果所有的Task都返回了,那么需要通知JobWaiter任务已经 能够结束: override def taskSucceeded(index: Int, result: Any): Unit = synchronized { // 调用用户逻辑处理结果 resultHandler(index, result.asInstanceOf[T]) finishedTasks += 1 if (finishedTasks == totalTasks) { 该 Job 结束了 _jobFinished = true jobResult = JobSucceeded // 会通知 org.apache.spark.scheduler.JobWaiter#awaitResult 任务结束 this.notifyAll() } } 对于ShuffleMapTask,首先需要将结果保存到Stage: val status = event.result.asInstanceOf[MapStatus] stage.addOutputLoc(smt.partitionId, status) 如果该Stage的所有Task都结束了,那么需要将整体结果注册到org.apache.spark.MapOutputTrackerMaster;这样下一个Stage的Task就可以通过它来获取Shuffle结果的元数据信息,进而 从Shuffle数据所在的节点获取数据了。 mapOutputTracker.registerMapOutputs( stage.shuffleDep.get.shuffleId, stage.outputLocs.map(list => if (list.isEmpty) null else list.head).toArray, changeEpoch = true) 如果在所有任务都返回的情况下,Stage还有部分数据是空的,则说明部分Task执行失败,需要重新提交这个Stage,重新计算丢失的部分;如果该Stage成功完成,那么就将可以提 交的Stage都提交了(即Stage没有parent Stage或者parent Stage都已经计算完成了): if (stage.outputLocs.exists(_ == Nil)) { // 部分 Task 失败,需要重新提交 submitStage(stage) } else { val newlyRunnable = new ArrayBuffer[Stage] for (stage <- waitingStages if getMissingParentStages(stage) == Nil) { newlyRunnable += stage // 可以提交的 Stage } waitingStages --= newlyRunnable runningStages ++= newlyRunnable for { stage <- newlyRunnable.sortBy(_.id) jobId <- activeJobForStage(stage) } { // 提交 Stage 的 Task submitMissingTasks(stage, jobId) } } 至此,一个Stage就执行完成了。如果Stage是最后一个Stage,即对应Result-Task的Stage,那么说明该Job执行完成了。 3.处理任务执行失败的容错机制 对于失败的Task,org.apache.spark.scheduler.TaskSchedulerImpl#statusUpdate会调用org.apache.spark.scheduler.TaskResultGetter#enqueueFailedTask来处理。这个和处理成功的Task一样共用 一个线程池来执行处理逻辑。相对处理成功的,这个逻辑要简单的多: def enqueueFailedTask(taskSetManager: TaskSetManager, tid: Long, taskState: TaskState, serializedData: ByteBuffer) { var reason : TaskEndReason = UnknownReason getTaskResultExecutor.execute(new Runnable { override def run(): Unit = Utils.logUncaughtExceptions { try { if (serializedData != null && serializedData.limit() > 0) { reason = serializer.get().deserialize[TaskEndReason]( serializedData, Utils.getSparkClassLoader) } } catch { // 处理异常 } scheduler.handleFailedTask(taskSetManager, tid, taskState, reason) } }) } 核心实现都在scheduler.handleFailedTask(taskSetManager,tid,taskState,reason),它首先会调用TaskSetManager来处理任务失败的情况,如果任务的失败次数没有超过阈值(阈值 可以通过spark.task.maxFailures设置,默认值是4),那么会重新提交任务。TaskSetManager逻辑如下: taskSetManager.handleFailedTask(tid, taskState, reason) if (!taskSetManager.isZombie && taskState != TaskState.KILLED) { // TaskSetManager 已经将失败的任务放入待运行的队列等待下一次的调度因此这里开始新 // 一轮的调度,以便失败的 Task 有机会获得资源 backend.reviveOffers() } TaskSetManager首先会根据失败的原因来采取不同的动作,比如如果是因为Task的结果发序列化失败,那么说明任务的执行是有问题的,这种任务即使重试也不会成功,因此这个 TaskSetManager会直接失败。如果这些任务需要重试,那么它会重新将这些任务标记为等待调度。核心的实现是: // 调用 DAGScheduler 级别的容错 sched.dagScheduler.taskEnded(tasks(index), reason, null, null, info, taskMetrics) // 标记为等待调度 addPendingTask(index) if (!isZombie && state != TaskState.KILLED) { numFailures(index) += 1 // 如果失败次数已经超过阈值,那么标记该 TaskSetManager 为失败 // 阈值可以通过 spark.task.maxFailures 设置,默认值是 4 if (numFailures(index) >= maxTaskFailures) { abort("Task %d in stage %s failed %d times, most recent failure: %s\n Driver stacktrace:" .format(index, taskSet.id, maxTaskFailures, failureReason)) return } } 其中,DAGScheduler级别的容错处理也在org.apache.spark.scheduler.DAGScheduler#handleTaskCompletion中。感兴趣的读者可以自己研究其实现。 4.4 Word Count调度计算过程详解 在3.3.3节中提到了“Word Count”中RDD转换的逻辑视图,但是由于当时没有提到Stage划分和任务调度,因此对这个逻辑视图是如 何分阶段转化为不同的任务没有涉及。而前面几个小节将用户提交的Stage的划分,任务的顺序提交和在Executor端的执行,最终 计算结果的处理和任务执行异常时的容错机制都做了详细解析。因此,图4-9中在图3-7的基础上,增加了RDD是如何通过依赖关 系查找所依赖的Parent RDD的Partition的,读者可以对照图4-9和4.2.3.3节的详解,以便理解这个逻辑图背后的实现原理。 对于前面提到的“Word Count”(图3-8),会生成两类Task,一个是Shuffle之前的Task,即ShuffleMapTask,由于初始的RDD有5个 Paritition,因此会有5个ShuffleMapTask生成,如图4-10所示;Shuffle之后的Task是需要输出结果的Task,因此它是ResultTask,由于 设定了Shuffle的输出分片数量是3个,因此这里会生成3个ResultTask,如图4-11所示。 生成的Task会被发送到已经启动的Executor上,由Executor来完成计算任务的执行,执行过程的实现在 org.apache.spark.executor.Executor.TaskRunner#run。后面在第6章中会介绍这一部分的实现原理和设计思想。 图4-9 “Word Count”的Stage划分和运算逻辑图 图4-10 “Word Count”中的ShuffleMapTask的逻辑运行图 图4-11 “Word Count”中的ResultTask的逻辑运行图 但是,Spark对于这个过程的实现却不像看上去那么简单,它要解决以下问题: 1)如何将用户逻辑转换成可以并发执行的任务? 2)如何定义和实现Shuffle? 3)如何传递数据,如何将数据传递到正确的任务上? 4)如何尽量避免数据在集群上的移动(即数据本地性,Data Locality),任务最好在数据所在的节点上执行? 5)如何为用户分配计算资源? 对于第一个问题,本章已经给出了答案;对于第二个和第三个问题,由第7章回答;对于第四个问题,是在任务调度的时候实现 的;最后一个问题,为用户分配计算资源,对于Standalone的方式来说,实际上在创建SparkContext的时候,Master就会开始为用户 提交的计算分配计算资源。在DAGScheduler将计算任务划分完成后,TaskScheduler就会在已经分配好的计算资源上启动计算任 务。 4.5 小结 要理解什么是Stage,首先要搞明白什么是Task。Task是在集群上运行的基本单位。一个Task负责处理RDD的一个Partition。RDD 的多个Patition会分别由不同的Task去处理。当然,这些Task的处理逻辑是完全一致的。这一组Task就组成了一个Stage。有两种 Task: 1 ) org.apache.spark.scheduler.ShuffleMapTask 2 ) org.apache.spark.scheduler.ResultTask ShuffleMapTask根据Task的partitioner将计算结果放到不同的bucket中。而ResultTask将计算结果发送回Driver Application。一个Job包 含了多个Stage,而Stage是由一组完全相同的Task组成的。最后的Stage包含了一组ResultTask。 在用户触发了一个动作后,比如count、collect,SparkContext会通过runJob的函数开始进行任务提交。最后会通过DAG的事件处 理器传递到DAGScheduler本身的handleJobSubmitted,它首先会划分Stage,提交Stage,然后提交Task。至此,Task就开始在集群 上运行了。 一个Stage的开始就是从外部存储或者shuffle结果中读取数据;一个Stage的结束就是由于发生shuffle或者生成结果时。 在DAGScheduler将用户提交的应用划分为不同的Stage后,TaskScheduler模块负责为Stage的Task分配计算资源,这个计算资源的 分配实际上是Cluster Manager职责。接下来的章节将以Standalone模式为例,分析Spark是如何为应用分配计算的。 第5章 Deploy模块详解 Spark的Cluster Manager有以下几种部署模式:Standalone,Mesos,YARN,EC2,Local。 Spark的Cluster Manager最开始的时候仅支持Mesos,后来为了方便Hadoop的用户加入了对YARN的支持,即使用YARN作为资源 的管理器和应用内的任务调度器。而Standalone模式的出现,更好地扩展了Spark的应用场景。本章将着重讲解Standalone部署方 式的详细实现,即Deploy模块的详细实现。 5.1 Spark运行模式概述 在4.3.1节介绍TaskScheduler的创建时,提到过不同的运行模式实际上实现了不同的SchedulerBackend和TaskScheduler: 1)org.apache.spark.scheduler.SchedulerBackend 2)org.apache.spark.scheduler.TaskScheduler 在SparkContext的创建过程中,会通过传入的Master URL的值来确定不同的运行模式,并且创建不同的SchedulerBackend和 TaskScheduler,这个创建过程的实现在org.apache.spark.SparkContext#createTaskScheduler。 5.1.1 local Master URL如果使用以下方式,那么就是以本地方式启动Spark: 1)local 使用一个工作线程来运行计算任务,不会重新计算失败的计算任务。 2)local[N]/local[*] 对于local[N],使用N个工作线程;对于local[*],工作线程的数量取决于本机的CPU Core的数目,保证逻辑上一个工作线程可以 使用一个CPU Core。和local一样,不会重新计算失败的计算任务。 3)local[threads,maxFailures] threads设置了工作线程的数目;maxFailures则设置了计算任务最大的失败重试次数。 4)local-cluster[numSlaves,coresPerSlave,memoryPerSlave] 伪分布式模式,本机会运行Master和Worker。其中numSlaves设置了Worker的数目;coresPerSlave设置了Worker所能使用的CPU Core的数目;memoryPerSlave设置了每个Worker所能使用的内存数。 对于前3种方式,内部实现是相同的,区别就是启动的工作线程数和计算失败时重试的次数不一样。对于local模式来 说,SchedulerBackend的实现是org.apache.spark.scheduler.local.LocalBackend。TaskScheduler的实现是 org.apache.spark.scheduler.TaskSchedulerImpl。local模式的系统架构图如图5-1所示。有一点要注意的是,LocalBackend也持有一个 Actor:org.apache.spark.scheduler.local.LocalActor,并通过这个Actor和Executor进行通信。这样Executor的实现就可以被各个运行 模式复用。 对于第4种伪分布式模式,实际上是在本地机器模拟了一个分布式环境,除了Master和Worker都运行在本机外,与Standalone模 式并无区别。整体架构如图5-2所示。SchedulerBackend的实现是org.apache.spark.scheduler.cluster.SparkDeploy-SchedulerBackend。 TaskScheduler的实现也是org.apache.spark.scheduler.Task-SchedulerImpl。需要强调的是,在Worker接到Master的LaunchExecutor消息 后,会创建一个实例org.apache.spark.deploy.worker.ExecutorRunner,由Executor-Runner来启动一个新的进程。这个进程的实现是 org.apache.spark.executor.Coarse-GrainedExecutorBackend。因此在图5-2中使用了虚线框来表示这是一个进程,而不是在Worker进 程内。还有一点要强调的是,local-cluster模式可以启动多个Worker,这一点并没有在图中标记出来。 图5-1 local模式 图5-2 local Cluster模式 5.1.2 Mesos Apache Mesos( http://mesos.apache.org )采用了Master/Slave的架构,主要由四部分组成:Mesos Master、Mesos Slave、 Framework(也称为Mesos Application)和Executor。并且Mesos通过ZooKeeper实现了Master的高可用性。整体架构如图5-3所示。 Mesos Master是整个系统的核心。它通过frameworks_manager管理接入的各个framework,通过slaves_manager管理所有的Slave,并 将Slave上的资源按照某种分配策略分配给framework。 图5-3 Mesos整体架构 Mesos Slave需要将自己的资源情况汇报给Master(当前主要的资源主要有CPU和Memory两种),负责接收并执行来自Mesos Master的命令,并且为运行在本节点的Task分配资源并且管理这些Task。当前Mesos也支持很多容器技术,可以很方便地做到各 个计算任务的资源隔离。 Framework指的是外部的计算框架,这些计算框架通过注册接入Mesos后,由Master进行统一管理和资源分配。要接入的外部框 架必须实现一个调度模块,该调度模块负责框架内部的任务调度,即外部框架在从Mesos获得了资源后,需要由这个调度模块 分配给本框架中的计算任务。换句话说,Mesos系统采用了双层调度框架: 1)由Mesos将资源分配给外部框架。 2)外部框架的调度模块将资源分配给外部框架的内部计算任务。 Executor主要用于启动外部框架内部的计算任务。由于不同的框架中启动任务的接口或者方式都不相同,因此Mesos规定外部框 架需要实现自己的Executor。这样Mesos就知道如何去启动不同框架下的计算任务。 Mesos的资源调度可以分为两类,即粗粒度调度和细粒度调度。 粗粒度的调度方式是每个Executor获得资源后就长期持有,直到应用程序退出才会释放资源。这种调度方式的优点就是减少了 资源调度的时间开销,缺点是由于其分配的资源被长期占有,在应用程序大部分的计算任务都已经完成的情况下,会造成资源 的浪费。尤其是有些计算任务出现长尾时,这个资源浪费的情况可能会变得不可接受。 而细粒度的资源调度是根据任务的实际需要动态申请的,任务完成后就会将资源还给系统,所以避免了粗粒度调度的资源浪费 问题。但是由于每次任务的调度都要从系统动态申请资源,调度的时间开销较大。特别是对于那些运行时间很短但是计算任务 数量又很多的应用程序来说,性能会受到较大影响。 Spark可以通过选项spark.mesos.coarse来设置是采用粗粒度的调度模式还是细粒度的调度模式。 1.粗粒度资源调度 Client在启动时会通过org.apache.spark.scheduler.cluster.mesos.CoarseMesosScheduler-Backend向Mesos Master获取资源。 CoarseMesosSchedulerBackend与Standalone模式一样,也是继承了org.apache.spark.scheduler.cluster.CoarseGrainedSchedulerBackend, 它还额外地实现了Mesos的资源调度接口,用于向Mesos资源调度框架注册和接受来自Mesos的资源分配,此外,它在得到分配 的资源后会通过Mesos框架远程启动org.apache.spark.executor.CoarseGrainedExecutorBackend。Executor的实现也和其他的模式保持 一致,都是org.apache.spark.executor.Executor。系统架构如图5-4所示。 图5-4 Mesos粗粒度模式 粗粒度模式是在Client启动时向Mesos Master申请全部的资源,并且在所有的计算任务结束后,再向Mesos归还资源。这一点并 没有在图中标记出来。 2.细粒度调度模式 细粒度的调度模式的SchedulerBackend的实现是org.apache.spark.scheduler.cluster.mesos.MesosSchedulerBackend,它和粗粒度模式一 样,也实现了Mesos的资源调度接口,向Mesos资源调度框架注册和接受来自Mesos的资源分配。区别就是它会按照任务来向 Mesos Master申请计算资源,并且在任务结束时归还资源。系统架构如图5-5所示。 图5-5 Mesos细粒度模式 5.1.3 YARN 与第一版Hadoop中经典的MapReduce引擎相比,YARN在可伸缩性、效率和灵活性上提供了明显的优势。YARN的整体架构如图5-6 所示。 ResourceManager(即资源管理器)全局管理所有应用程序计算资源的分配。它和每一台机器的NodeManager(即节点管理服务器)能 够管理应用在那台机器上的进程并能对计算进行组织。 每一个应用的ApplicationMaster则负责相应的调度和协调。ApplicationMaster是一个详细的框架库,它结合从ResourceManager获得的资 源和NodeManager协同工作来运行和监控任务。每一个应用的ApplicationMaster的职责有:向调度器索要适当的资源容器,运行任务, 跟踪应用程序的状态和监控它们的进程,处理任务的失败原因。 ResourceManager支持分层级的应用队列,这些队列享有集群一定比例的资源。从某种意义上讲它就是一个纯粹的调度器,它在执行 过程中不对应用进行监控和状态跟踪。同样,它也不能重启因应用失败或者硬件错误而运行失败的任务。 ResourceManager是基于应用程序对资源的需求进行调度的;每一个应用程序需要不同类型的资源因此就需要不同的容器。资源包 括:内存、CPU、磁盘、网络等。可以看出,这同现Mapreduce固定类型的资源使用模型有显著区别,它给集群的使用带来负面的影 响。资源管理器提供一个调度策略的插件,它负责将集群资源分配给多个队列和应用程序。调度插件可以基于现有的能力调度和公 平调度模型。 NodeManager是每一台机器框架的代理,是执行应用程序的容器,监控应用程序的资源使用情况(CPU、内存、硬盘、网络)并且向 调度器汇报。 图5-6 YARN整体架构 在Client提交了Job后,Application Master会向ResourceManager请求资源,在获得了资源后,Application Master会在NodeManager上启动 Container,运行计算任务,并且和Container保持联系,监控任务的运行状态等。 1.YARN Cluster模式 YARN Cluster模式,就是通过Hadoop YARN来调度Spark Application所需要的资源。整体架构图如图5-7所示。 用户提交的Application会通过YARN Client提交到YARN的主节点Resource-Manager上,ResourceManager会在一个工作节点上启动 ApplicationMaster(实现是org.apache.spark.deploy.yarn.ApplicationMaster)。启动了ApplicationMaster后,才算是完成了Spark Application的 提交。 ApplicationMaster在将自己注册成为一个YARN ApplicationMaster后,才会开始执行用户提交的Application。YARN Cluster模式的 TaskScheduler的实现是org.apache.spark.scheduler.cluster.YarnClusterScheduler。YarnClusterScheduler继承自 org.apache.spark.scheduler.TaskSchedulerImpl,额外实现的逻辑是确定ApplicationMaster初始化完成。ApplicationMaster会通过YARN ResourceManager和NodeManager的接口在集群中启动若干个容器,用于启动org.apache.spark.executor.CoarseGrainedExecutorBackend,之 后它会启动org.apache.spark.executor.Executor。 图5-7 YARN Cluster模式 2.YARN Client模式 该模式和YARN Cluster模式的区别在于,用户提交的Application的Spark-Context是在本机上运行,适合Application本身需要在本地进行 交互的场景;而YARN Cluster中,所有的计算都是在YARN的节点上运行的。系统的整体架构图如图5-8所示。 图5-8 YARN Client模式 SparkContext在初始化的过程中会创建org.apache.spark.scheduler.cluster.YarnClient-ClusterScheduler和 org.apache.spark.scheduler.cluster.YarnClientSchedulerBackend。Yarn-ClientSchedulerBackend继承自 org.apache.spark.scheduler.cluster.YarnSchedulerBackend,而它继承自org.apache.spark.scheduler.cluster.CoarseGrainedSchedulerBackend,可以 和ExecutorBackend通过AKKA进行通信。 YarnClientSchedulerBackend会启动一个org.apache.spark.deploy.yarn.Client,用于向ResourceManager提交Application,并且由 ApplicationMaster来负责在多个节点上启动Executor。 5.2 模块整体架构 Deploy模块采用的也是典型的Master/Slave架构,其中Master负责整个集群的资源调度和Application的管理。Slave(即Worker)接收 Master的资源分配调度命令后启动Executor,由Executor完成最终的计算任务。而Client则负责Application的创建和向Master注册 Application,并且负责接收来自Executor的状态更新和计算结果等。其整体架构如图5-9所示。 图5-9 Standalone模式的整体架构 Deploy模块主要包含3个子模块:Master、Worker、Client,它们之间的通信通过AKKA完成。对于Master和Worker,它们本身就是一 个Actor,因此可以直接通过AKKA实现通信。Client虽然本身不是一个Actor,这三者的主要职责如下: 1)Master:接收Worker的注册并管理所有的Worker,接收Client提交的Application,FIFO调度等待的Application并向Worker提交。 2)Worker:向Master注册自己,根据Master发送的Application配置进程环境,并启动StandaloneExecutorBackend。 3)Client:向Master注册并监控Application。当用户创建SparkContext时会实例化SparkDeploySchedulerBackend,而实例化 SparkDeploySchedulerBackend的同时会启动Client,通过向Client传递启动参数和Application有关信息,Client向Master发送请求注册 Application并且在计算节点上启动StandaloneExecutorBackend。 5.3 消息传递机制详解 Deploy模块之间主要通过AKKA通信,因此,通过对各个子模块之间消息通信协议的梳理,可以分析每个子模块的功能职责。 5.3.1 Master和Worker Master作为整个集群的管理者,需要Worker通过注册、汇报状态来维护整个集群的运行状态,并且通过这些状态来决定资源调度策略等。图5-10 是Worker、Master和Driver的注册逻辑图。 图5-10 Worker、Master和Driver的注册逻辑图 Worker向Master发送的消息主要包含三类: 1)注册:Worker启动时需要向Master注册,注册时需要汇报自身的信息。 2)状态汇报:汇报Executor和Driver的运行状态;在Master故障恢复时,需要汇报Worker上当前运行的Executor和Driver的信息。 3)报活心跳:Worker每隔指定周期会向Master发送报活的心跳。 Worker向Master发送的消息如表5-1所示。 表5-1 Worker向Master发送的消息 Master向Worker发送的消息除了响应Worker的注册外,还有一些控制命令,包括让Worker重新注册、让Worker启动Executor或者Driver、停止 Executor和Driver等。消息列表如表5-2所示。 表5-2 Master向Worker发送的消息 5.3.2 Master和Client 这里的Client分为Driver Client和AppClient。Driver Client向Master发送的消息如表5-3所示。 表5-3 Driver Client向Master发送的消息 而Master使用表5-4中列举的消息响应Driver Client的请求。 表5-4 Master响应Driver Client请求的消息 AppClient本身并不是一个Actor,但是它持有一个Actor:org.apache.spark.deploy.client.AppClient.ClientActor。Master与AppClient的交互实际上是Master与 ClientActor的交互。消息列表如表5-5所示。 表5-5 AppClient向Master发送的消息 Master向AppClient发送的消息如表5-6所示。 表5-6 Master向AppClient发送的消息 5.3.3 Client和Executor org.apache.spark.scheduler.cluster.CoarseGrainedSchedulerBackend.DriverActor负责,而Executor实际上对应于 org.apache.spark.executor.CoarseGrainedExecutorBackend。 Driver向Executor发送的消息分为两类: 1)启动Task,停止Task。 2)响应Executor注册的请求,回复成功还是失败。 Driver向Executor发送的消息的详细说明如表5-7所示。 表5-7 Driver向Executor发送的消息 Executor向Driver发送的消息如表5-8所示。 表5-8 Executor向Driver发送的消息 5.4 集群的启动 在Spark集群部署完成后,启动整个集群时会发生什么?本节就Master的启动和Worker的启动来分析架构实现和设计思想。 5.4.1 Master的启动 Master的实现是org.apache.spark.deploy.master.Master。它是一个Actor,因此Worker和AppClient可以直接和它通过AKKA进行通 信。一个集群可以部署多个Master,以达到高可用性的目的,因此它还实现了org.apache.spark.deploy.master.LeaderElectable以在 多个Master中选举出一个Leader。LeaderElectable是一个trait: @DeveloperApi trait LeaderElectable { def electedLeader() def revokedLeadership() } 从定义的接口可以清晰地看出它的作用。用户自定义的选举方式的Master也需要实现它。 除了选举机制,还要注意的是Master元数据持久化的方式。Master保存了整个集群的元数据,包含Worker、Application和Driver Client。Spark的Standalone模式支持以下几种方式的元数据持久化方式和选举机制: 1.ZooKeeper 实现了基于ZooKeeper的选举机制,元数据信息会持久化到ZooKeeper中。因此在Master故障后,ZooKeeper会在备份的Master中 选举出新的Master,新的Master在启动后会从ZooKeeper中获取元数据信息并且恢复这些数据。 2.FILESYSTEM 集群的元数据信息会保存在本地文件系统。而Master启动后则会立即成为Active的Master。如果不考虑机器本身的故障和在设置 了Master进程退出后能自动重启的前提下,这种方式也是可以接受的。 3.CUSTOM 这个是用户自定义的。如果需要自定义的机制,那么需要实现org.apache.spark.deploy.master.StandaloneRecoveryModeFactory,并 且将实现的类的名字配置到spark.deploy.recoveryMode.factory。主要实现的是两个接口: 1)def createPersistenceEngine():PersistenceEngine该接口实现了持久化数据和恢复数据,这些数据包括Worker、Application和 Driver Client。 2)def createLeaderElectionAgent(master:LeaderElectable):LeaderElectionAgent该接口实现了选举机制,即从几个Standby的 Master中选举出一个Master作为集群的管理者。 4.NONE 不会持久化集群的元数据,Master在启动后会立即接管集群的管理工作。 可以通过spark.deploy.recoveryMode进行设置。如果不设置,那么就默认是第4种方式NONE,即没有备份的Master,集群所有历 史的元数据信息在Master重启后都会丢失。 在Master的进程启动后,ZooKeeper方式的选举机制会根据自身策略来选举出Leader;对于FILESYSTEM和NONE方式,进程启 动后会立即成为Leader。这通过调用org.apache.spark.deploy.master.Master#electedLeader来实现: override def electedLeader() { self ! ElectedLeader } 而被选举为Leader的Master,会首先读取集群的元数据信息,如果有读到的数据,那么Master的状态就会变为 RecoveryState.RECOVERING,然后开始恢复数据和通知Worker、AppClient和Driver Client,Master已经更改,恢复结束后Master 的状态会变成RecoveryState.ALIVE。对于没有读取任何数据的Master,状态会立即变成RecoveryState.ALIVE。Master只有在状态 是RecoveryState.ALIVE时才可以对外服务,包括接受Worker、Application和Driver Client的注册和状态更新等。主要的代码逻辑如 下。 case ElectedLeader => { // 处理当前的 Master 被选举为 Leader 的消息 // 读取集群当前运行的 Application 、 Driver Client 和 Worker val (storedApps, storedDrivers, storedWorkers) = persistenceEngine.readPersistedData() // 如果所有的元数据都是空的,那么就不需要恢复 state = if (storedApps.isEmpty && storedDrivers.isEmpty && storedWorkers.isEmpty) { RecoveryState.ALIVE } else { RecoveryState.RECOVERING } logInfo("I have been elected leader! New state: " + state) if (state == RecoveryState.RECOVERING) { // 开始恢复集群之前的状态,恢复实际上就是通知 Application 和 Worker , //Master 已经更改 beginRecovery(storedApps, storedDrivers, storedWorkers) // 在 WORKER_TIMEOUT 毫秒后,尝试将恢复标记为完成 recoveryCompletionTask = context.system.scheduler.scheduleOnce(WORKER_TIMEOUT millis, self, CompleteRecovery) } } 对于已经有Application运行的集群来说,Master故障恢复的时候,是需要将Application、Worker和Driver Client的元数据恢复的。 恢复数据通过调用begin-Recovery实现。对于Application,Master会将它的状态设置为UNKNOWN后通知AppClient: app.state = ApplicationState.UNKNOWN app.driver ! MasterChanged(masterUrl, masterWebUiUrl) 对于Worker,Master会以类似的方式处理: worker.state = WorkerState.UNKNOWN worker.actor ! MasterChanged(masterUrl, masterWebUiUrl) Master在接到AppClient和Worker的响应后,会将它们的状态从UNKNOWN设置为正常的状态。 对于Driver Client,只有在分配给的Worker出现问题时才会重新调度这个Driver Client。因此这里的恢复只是将它加到Master在内 存中维护的Driver Client列表中。 由于这里的恢复只是以异步的方式通知AppClient和Worker,那么什么时候才会结束呢?首先,Master在接到AppClient的响应 (消息MasterChangeAcknowledged)和Worker的响应(消息WorkerSchedulerStateResponse)后,会查看是不是所有的Application和 Worker的状态都不是UNKNOWN了。如果确认都不是,则代表恢复已经完成。若有部分AppClient或者Worker确实出了问题, 长时间没有响应呢?Master还有第二个机制,即设置一个超时时间,如果超时之后仍有AppClient或者Worker未响应,那么 Master还是会认为恢复已经结束。默认超时时间为60秒。对于规模不同的集群,这个超时时间可以通过spark.worker.timeout来设 置,单位是秒。 Master在判定恢复已经结束时会调用completeRecovery(),由它来完成恢复的最终处理: def completeRecovery() { // 由于存在多个时机调用,因此需要加锁以确保只能执行一次 synchronized { if (state != RecoveryState.RECOVERING) { return } state = RecoveryState.COMPLETING_RECOVERY } // 将所有未响应的 Worker 和 Application 删除 workers.filter(_.state==WorkerState.UNKNOWN).foreach(removeWorker) apps.filter(_.state==ApplicationState.UNKNOWN).foreach(finishApplication)// 对于未分配 Worker 的 Driver Client (有可能 Worker 已经死掉), // 确定是否需要重新启动 drivers.filter(_.worker.isEmpty).foreach { d => logWarning(s"Driver ${d.id} was not found after master recovery") if (d.desc.supervise) {// 需要重新启动 Driver Client logWarning(s"Re-launching ${d.id}") relaunchDriver(d) } else {// 将没有设置重启的 Driver Client 删除 removeDriver(d.id, DriverState.ERROR, None) logWarning(s"Did not re-launch ${d.id} because it was not supervised") } } // 设置 Master 的状态为 ALIVE ,此后 Master 开始正常工作 state = RecoveryState.ALIVE schedule() // 开始新一轮的资源调度 logInfo("Recovery complete - resuming operations!") } 5.4.2 Worker的启动 Worker的启动相对于Master的启动要简单很多。Worker启动只会做一件事情,就是向Master注册。 在接到Worker的注册请求后,如果Master是Active的并且Worker没有注册过,那么Master会回复Worker消息RegisterWorker,表示 Worker注册成功;若注册失败,Master会回复消息RegisterWorkerFailed,Worker接到该消息后直接退出(由于Worker会重复多 次发送请求,因此退出前需要判断是否注册成功了,如果没有注册成功才会退出;如果已经注册成功了,那么忽略这个消 息)。 Worker在向Master注册的时候有重试机制,即在指定时间如果收不到Master的响应,那么Worker会重新发送注册请求。目前重 试的次数至多为16次。为了避免所有的Worker都在同一个时刻向Master发送注册请求,每次重试的时间间隔是随机的。而且前 6次的重试间隔在5~15秒,而后10次的重试间隔在30~90秒。 在Worker刚启动时,会调用org.apache.spark.deploy.worker.Worker#registerWithMaster来进行注册。第一次调用registerWithMaster的 时候除了向所有的Master发出注册请求,还要启动一个定时器,在超时的时候进行重新注册。 registered = false // 标记未注册,为 true 则代表注册成功 tryRegisterAllMasters() // 向所有的 Master 发出注册请求 connectionAttemptCount = 0 // 记录重试次数 registrationRetryTimer = Some { // 开启定时器,超时时间在 5 ~ 15 秒 context.system.scheduler.schedule(INITIAL_REGISTRATION_RETRY_INTERVAL, INITIAL_REGISTRATION_RETRY_INTERVAL, self, ReregisterWithMaster) } tryRegisterAllMasters()会向所有的Master发送注册请求: for (masterUrl <- masterUrls) { val actor = context.actorSelection(Master.toAkkaUrl(masterUrl)) actor ! RegisterWorker(workerId, host, port, cores, memory, webUi.boundPort, publicAddress) } 而定时器在超时后会向自己发送消息ReregisterWithMaster,这个消息由reregister-WithMaster()处理。如果在调用它的时候已经 注册成功了,那么它会取消定时器后返回;如果还未注册成功并且重试次数未到阈值,那么就会开始新一轮的注册。如果重试 次数达到6次,那么需要重置定时器,以便重试间隔增加到30~90秒。 如果超过阈值还未注册成功,那么Worker会直接退出。 注册成功后,Worker就可以对外服务了,即可以接收Master的指令等。 5.5 集群容错处理 容错(fault tolerance)指的是在一个系统的部分模块出现错误的情况下还能持续地对外提供服务;如果出现了服务质量的下 降,这个下降也是和出错的严重性成正比的。对于没有容错的系统,即使一个微小的错误也可能会导致整个服务停止。 对于一个集群来说,机器故障、网络故障等都被视为常态,尤其是当集群达到一定规模后,可能每天都会有物理故障导致某台 机器不能提供服务。因此对于分布式系统来说,应对这种场景的容错也是设计目标之一。接下来将从Master、Worker和 Executor的异常退出出发,讨论Spark是如何处理的。 提到容错,不得不提一下容灾(或者称为灾难恢复,disaster recovery)。容灾技术是通过在异地建立和维护一个备份系统,利 用地理上的分散性来保证数据对于灾难性事件的抵抗能力。容灾系统在实现中可以分为两个层次:数据容灾和应用容灾。数据 容灾指建立一个异地的数据系统,作为本地关键应用数据的一个备份。应用容灾是在数据容灾的基础上,在异地建立一套完整 的和本地生产系统相同的备份应用系统(可以是互为备份)。在灾难情况下,由远程系统迅速接管业务运行。 容错和容灾都是为了实现系统的高可用性,容错是在系统的部分模块出现问题时的错误恢复机制;容灾则是在整个系统的层 面,通过使用数据和应用的镜像,来实现服务的高可用性的。 5.5.1 Master异常退出 如果Master异常退出,此时新的计算任务就无法进行提交了。虽然老的计算任务可以继续运行,但是由于状态更新等都中断 了,很多功能也同时会受到影响。比如计算任务完成后的资源回收,这个回收指令是Master发送给Worker的。因此,Master的 异常退出,是一个非常严重的错误。 前面提到过集群可以部署多个Master,借助ZooKeeper的Leader选举机制选出一个Master作为集群的管理者,其他的都作为备 份。因此,在这种情况下Master的异常退出,ZooKeeper会在备份的Master中选择一个充当集群的管理者。这个被新选出来的 Master会首先从ZooKeeper中读取集群的元数据(包括Worker、Driver Client和Application的信息)进行数据恢复,然后告知 Worker和AppClient,Master已经更改的消息。在收到所有的Worker和AppClient的响应或者超时后,Master就会变成ACTIVE的状 态,并开始对外提供服务。因此,对于生产环境的系统,推荐使用这种方式,具体过程示意图如图5-11所示。 图5-11 Master的异常退出 对于使用FILESYSTEM方式的Master,推荐通过supervisor拉起Master进程。supervisor是用Python开发的一套通用的进程管理程 序,能将一个普通的命令行进程变为后台daemon,并监控进程状态,异常退出时能自动重启。因此,如果不是由于机器故障导 致的Master异常退出,那么Master在被supervisor重新拉起后,经过数据恢复和通知Worker和AppClient后,就可以正常对外服务 了。但是不推荐生产环境使用这种方式,因为从一个长远的角度看,机器出故障的概率非常高,因此如果出现这种问题,整个 集群可能会停服很久。 对于使用NONE方式的Master,如果也是通过supervisor拉起的话,那么和FILESYSTEM方式的唯一区别就是集群的元数据信息 全部丢失。 对于用户自定义的方式,就需要根据用户的逻辑和实现来决定。 5.5.2 Worker异常退出 对于一个集群来说,Worker的异常退出发生概率非常高。Worker退出时,集群是如何进行容错处理的呢? 1)Worker在退出前,会将所有运行在它上面的Executor和Driver Client删除。 executors.values.foreach(_.kill()) // 杀死所有的 Executor drivers.values.foreach(_.kill()) // 杀死所有的 Driver Client shuffleService.stop() // 停止 Shuffle 服务 2)Worker需要周期性地向Master发送心跳消息。这个周期就是spark.worker.timeout(默认值60秒)设置的1/4。由于Worker的异 常退出,使得它的心跳会超时,Master认为该Worker已经异常退出,那么Master会将该Worker上运行的所有Executor的状态标记 为丢失(ExecutorState.LOST),然后将这个状态更新通过消息ExecutorUpdated通知AppClient;对于该Worker上运行的Driver Client,如果它设置了需要重启(即设置了supervise),那么需要重新调度来重新启动这个Driver Client,否则直接将它删除,并 且将状态设置为DriverState.ERROR。上述逻辑的实现在类org.apache.spark.deploy.master.Master的成员removeWorker中,感兴趣的 读者可以通过代码来加深理解。 3)AppClient接到Master的StatusUpdate消息后会将状态更新汇报到org.apache.spark.scheduler.cluster.SparkDeploySchedulerBackend, 而它会根据消息内容来判断是否是Executor异常退出。接下来的调用栈: a ) org.apache.spark.scheduler.cluster.CoarseGrainedSchedulerBackend. DriverActor#removeExecutor b ) org.apache.spark.scheduler.TaskSchedulerImpl#executorLost 然后重新进行调度,Task会被分配新的Executor,完成最终的计算。 这个过程的示意图如图5-12所示。 图5-12 Worker的异常退出 Worker在退出的时候,会通过org.apache.spark.deploy.worker.ExecutorRunner杀死Executor。ExecutorRunner实际上会将以进程的方 式启动org.apache.spark.executor.CoarseGrained-ExecutorBackend。这个启动过程的实现在fetchAndRunExecutor。因此,Executor- Runner杀死Executor实际上就是杀死CoarseGrainedExecutorBackend的过程。 5.5.3 Executor异常退出 Executor模块负责运行Task计算任务,并将计算结果回传到Driver。Spark支持多种资源调度框架,这些资源框架在为计算任务分 配了资源后,最后都会使用Executor模块完成最终的计算。 在讲解Executor异常退出的容错机制前,需要先了解Executor的启动机制。Worker接收到Master的LaunchExecutor命令后,会创建 org.apache.spark.deploy.worker.Executor-Runner。 org.apache.spark.deploy.worker.ExecutorRunner#fetchAndRunExecutor会负责Driver通过AKKA通信。它在启动后,会首先向Driver注 册Executor: driver = context.actorSelection(driverUrl) driver ! RegisterExecutor(executorId, hostPort, cores) Driver是org.apache.spark.scheduler.cluster.CoarseGrainedSchedulerBackend::DriverActor的Reference。Driver在接到RegisterExecutor后, 会将Executor的信息保存在本地,并且调用CoarseGrainedSchedulerBackend.DriverActor#makeOffers实现在Executor上启动Task。回 到CoarseGrainedExecutorBackend,它在接到Driver的回应RegisteredExecutor后,会创建一个org.apache.spark.executor.Executor。至 此,Executor创建完毕。Coarse-Grained-Executor-Backend唯一对应一个Executor,它负责与Driver的通信,汇报Executor的状态。 而Executor负责Task最终的计算任务。 ExecutorRunner在启动了CoarseGrainedExecutorBackend后,会监控Executor的状态。如果Executor退出,它将通过 ExecutorStateChanged汇报给其所在的Worker,Worker会将这个消息转发到Master。由于Executor是异常退出,Master将会为该 Application分配新的Executor。如果失败次数超过10次,那么就会将这个Application标记为失败,具体过程如图5-13所示。 图5-13 Executor的异常退出 5.6 Master HA实现详解 Standalone是一个采用Master/Slave的典型架构,所以Master会出现单点故障(Single Point of Failure,SPOF)问题。在前面介绍 Master的启动时,已经介绍过Spark可以选用ZooKeeper来实现高可用性(High Availability,HA)。本节将详细讲解这个过程的实 现。 众所周知,ZooKeeper提供了一个Leader选举机制,利用这个机制可以保证虽然集群存在多个Master但是只有一个是Active的。 当Active的Master出现故障时,另外的一个Standby Master会被选举出来。由于集群的信息,包括Worker、Driver Client和 Application的信息都已经持久化到ZooKeeper中,因此在切换的过程中只会影响新Job的提交,对于正在进行的Job没有任何的影 响。加入ZooKeeper的集群整体架构如图5-14所示。 图5-14 基于ZooKeeper的整体架构图 5.6.1 Master启动的选举和数据恢复策略 除了集群搭建后的第一次启动,Master每次启动都会恢复集群当前的运行状态。这些状态包括当前正在运行的Application、Driver Client和Worker。5.3.1节 提到过,当前Standalone模式支持四种策略: 1)ZOOKEEPER 实现了基于ZooKeeper的选举机制,元数据信息会持久化到ZooKeeper中。因此在Master故障后,ZooKeeper会在Standby的Master中选举出新的Master,新 的Master在启动后会从ZooKeeper中获取元数据信息并且恢复这些数据。 2)FILESYSTEM 集群的元数据信息会保存在本地文件系统。而Master启动后会则立即成为了Active的Master。如果不考虑机器本身的故障和在设置了Master进程退出后能 自动重启的前提下,这种方式也是可以接受的。 3)CUSTOM 这个是用户自定义的。如果需要自定义的机制,那么需要实现org.apache.spark.deploy.master.StandaloneRecoveryModeFactory,并且将实现的类的名字配置 到spark.deploy.recoveryMode.factory。 4)NONE 不会持久化集群的元数据,Master在启动后会立即接管集群的管理工作。 这些策略是在spark.deploy.recoveryMode设置的,Master会在preStart()中根据这个设置来选择不同的选举机制和元数据持久化/恢复机制: val (persistenceEngine_, leaderElectionAgent_) = RECOVERY_MODE match { case "ZOOKEEPER" => // 使用 ZooKeeper 的方式 logInfo("Persisting recovery state to ZooKeeper") val zkFactory =new ZooKeeperRecoveryModeFactory(conf, Serialization-Extension (context.system)) (zkFactory.createPersistenceEngine(), zkFactory.createLeaderElectionAgent (this)) case "FILESYSTEM" => // 使用文件系统的方式 val fsFactory =new FileSystemRecoveryModeFactory(conf, Serialization- Extension(context.system)) (fsFactory.createPersistenceEngine(), fsFactory.createLeaderElectionAgent(this)) case "CUSTOM" => // 用户自定义 val clazz = Class.forName(conf.get("spark.deploy.recoveryMode.factory")) val factory = clazz.getConstructor(conf.getClass, Serialization.getClass) .newInstance(conf, SerializationExtension(context.system)) .asInstanceOf[StandaloneRecoveryModeFactory] (factory.createPersistenceEngine(), factory.createLeaderElectionAgent(this)) case _ => // 无 (new BlackHolePersistenceEngine(), new MonarchyLeaderAgent(this)) } persistenceEngine = persistenceEngine_ leaderElectionAgent = leaderElectionAgent_ 其中persistenceEngine通过persist(name:String,obj:Object)实现了元数据持久化,通过readPersistedData()来恢复元数据。leaderElectionAgent实现了 Leader的选举,对于FILESYSTEM 和NONE来说,它的实现是MonarchyLeaderAgent,在创建的时候就会把创建它的Master设置为Leader: private[spark] class MonarchyLeaderAgent(val masterActor: LeaderElectable) extends LeaderElectionAgent { masterActor.electedLeader() // 直接将传入的 Master 设置为 Leader } 而对于NONE来说,它不会持久化集群的任何数据,而是通过一个空的persistenceEngine来实现的。这种实现的好处是可以做到对外接口的统一。具体实 现是org.apache.spark.deploy.master.BlackHolePersistenceEngine。 将所有的策略抽象为持久化和选举两部分,从而实现了一个Pluggable(可插拔、可扩展的)框架。因此第三方可以很容易地实现自定义的策略。 Spark使用的并不是ZooKeeper的原生API,而是Apache Curator。Curator在ZooKeeper上做了一层很友好的封装。 5.6.2 集群启动参数的配置 为了使用ZooKeeper,需要设置相关的参数,可以通过在spark-env.sh中设置: spark.deploy.recoveryMode=ZOOKEEPER spark.deploy.zookeeper.url=zk_server_1:2181,zk_server_2:2181,zk_server_3:2181 spark.deploy.zookeeper.dir=/spark 或者通过以下方式设置: export SPARK_DAEMON_JAVA_OPTS="-Dspark.deploy.recoveryMode=ZOOKEEPER " export SPARK_DAEMON_JAVA_OPTS="${SPARK_DAEMON_JAVA_OPTS} -Dspark.deploy. zookeeper.url=zk_server1:2181,zk_server_2:2181,zk_server_3:2181" 各个参数的意义如表5-9所示。 表5-9 启动参数 5.6.3 Curator Framework简介 Standalone Master使用到的Curator主要是org.apache.curator.framework.Curator-Framework和org.apache.curator.framework.recipes.leader. {LeaderLatchListener,LeaderLatch}。在详细介绍具体实现前,本节会主要介绍Curator的基础知识,其中涵盖Spark实现用到的部分。 Curator Framework极大地简化了ZooKeeper的使用,它提供了high-level的API,并且基于ZooKeeper添加了很多特性,包括: 1)自动连接管理:连接到ZooKeeper的Client有可能会连接中断,Curator处理了这种情况,对于Client来说自动重连是透明的。 2)简洁的API:简化了原生态的ZooKeeper的方法,事件等;提供了一个简单易用的接口。 3)Recipe的实现(更多介绍请查阅 http://curator.apache.org/curator-recipes/index.html ): a)Leader选举 b)共享锁 c)缓存和监控 d)分布式队列 e)分布式优先队列 Curator Framework通过CuratorFrameworkFactory来创建线程安全的ZooKeeper的实例,即通过org.apache.curator.framework.CuratorFrameworkFactory#newClient 来创建ZooKeeper的实例,并可以传入不同的参数对实例进行控制。获取实例后,必须通过start()来启动这个实例,在结束时,需要调用close()。 Spark在org.apache.spark.deploy.master.SparkCuratorUtil中对它进行了进一步的封装,使得调用更加简单: def newClient(conf: SparkConf): CuratorFramework = { val ZK_URL = conf.get("spark.deploy.zookeeper.url") // 获取 ZooKeeper 的地址 val zk = CuratorFrameworkFactory.newClient(ZK_URL, ZK_SESSION_TIMEOUT_MILLIS, ZK_CONNECTION_TIMEOUT_MILLIS, new ExponentialBackoffRetry(RETRY_WAIT_MILLIS, MAX_RECONNECT_ATTEMPTS)) zk.start() zk } 对于传入的SparkConf,它只需要ZooKeeper的地址,即spark.deploy.zookeeper.url。其他的参数都是在SparkCuratorUtil硬编码的: val ZK_CONNECTION_TIMEOUT_MILLIS = 15000 val ZK_SESSION_TIMEOUT_MILLIS = 60000 val RETRY_WAIT_MILLIS = 5000 val MAX_RECONNECT_ATTEMPTS = 3 调用者只需要传入SparkConf就可以创建ZooKeeper的实例,进一步简化了Curator的使用。创建了ZooKeeper的实例后,就可以使用该实例进行读写操作 了。下面假设创建的实例为zk,来简单介绍一下这些接口的使用方式。 1)创建ZooKeeper的实例: val zk: CuratorFramework = SparkCuratorUtil.newClient(conf) 2)创建一个路径path: zk.create().creatingParentsIfNeeded().forPath(path) 3)获取一个路径为path的节点的数据: val fileData = zk.getData().forPath(path) 4)将一个序列化后的数据serialized写入path: zk.create().withMode(CreateMode.PERSISTENT).forPath(path, serialized) 5)删除一条数据(路径为path): zk.delete().forPath(path) 6)删除一个路径path: if (zk.checkExists().forPath(path) != null) { for (child <- zk.getChildren.forPath(path)) { zk.delete().forPath(path + "/" + child) // 删除子节点 } zk.delete().forPath(path) } 除了ZooKeeper作为一个元数据的存储引擎外,还需要关注它的选举机制的使用方式。主要需要关注两个Recipe: org.apache.curator.framework.recipes.leader.{Leader-LatchListener,LeaderLatch},它们都是由Java实现的。 首先看一下LeaderlatchListener,它在LeaderLatch状态变化的时候被通知: 1)在该节点被选为Leader的时候,会调用接口isLeader()。 2)在节点被剥夺Leader的时候,会调用接口notLeader()。 public interface LeaderLatchListener { public void isLeader(); public void notLeader(); } 但是由于通知是异步的,在接口被调用的时候,这个状态可能又会发生变化,因此需要重复确认LeaderLatch的hasLeadership()是否的确是true/false。这 一点将在接下来Spark的实现中得到体现。 LeaderLatch负责在众多连接到ZooKeeper集群的竞争者中选择一个Leader。Leader的选择机制可以由ZooKeeper的具体实现决定,LeaderLatch对它进行了很 好的封装。这里只需要了解LeaderLatch的用法即可。在创建了LeaderLatch的实例后,需要通过addListener“注册”LeaderLatchListener,从而在状态改变时通 知监听者。 public void addListener(LeaderLatchListener listener) { listeners.addListener(listener); } 最后,LeaderLatch需要调用start()来开始竞争以便成为Leader。 5.6.4 ZooKeeperLeaderElectionAgent的实现 借助Curator,Spark实现Master的HA就变得非常简单,ZooKeeperLeaderElection-Agent实现了接口LeaderLatchListener,在isLeader()确认所属的Master被选 为Leader后,向Master发送消息ElectedLeader,Master会在恢复了元数据后将自己的状态改为ALIVE。当noLeader()被调用时,它会向Master发送消息 RevokedLeadership,此时,Master直接退出。 接下来看一下具体的实现。在org.apache.spark.deploy.master.ZooKeeperLeaderElection-Agent创建的时候,会创建ZooKeeper的实例,同时创建LeaderLatch: zk = SparkCuratorUtil.newClient(conf) leaderLatch = new LeaderLatch(zk, WORKING_DIR) leaderLatch.addListener(this) // 它实现了 LeaderLatchListener leaderLatch.start() 在调用leaderLatch.start()后,就启动了Leader的竞争与选举。就如前面分析的,主要逻辑在isLeader和notLeader中,即状态变化时leaderLatch会调用 Listener的这两个接口。注意,接口实现的时候,需要再次确认状态是否再次改变了: override def isLeader() { synchronized { // 有可能状态已经再次改变,即 Leader 已经再次变化,因此需要再次确认 if (!leaderLatch.hasLeadership) { return } // 已经被选举为 Leader logInfo("We have gained leadership") updateLeadershipStatus(true) } } override def notLeader() { synchronized { // 有可能状态已经再次改变,即 Leader 已经再次变化,因此需要再次确认 if (leaderLatch.hasLeadership) { return } // 被剥夺 Leader logInfo("We have lost leadership") updateLeadershipStatus(false) } } updateLeadershipStatus的逻辑很简单,就是向Master发送消息。 def updateLeadershipStatus(isLeader: Boolean) { if (isLeader && status == LeadershipStatus.NOT_LEADER) { status = LeadershipStatus.LEADER masterActor.electedLeader() //Master 已经被选举为 Leader } else if (!isLeader && status == LeadershipStatus.LEADER) { status = LeadershipStatus.NOT_LEADER masterActor.revokedLeadership() //Master 已经被剥夺 Leader } } 为了解决Standalone模式下Master的单点故障问题,Spark采用了ZooKeeper提供的选举机制和作为集群元数据的存储引擎。Spark并没有采用ZooKeeper原 生的Java API,而是采用了Curator,一个对ZooKeeper进行了封装的框架。采用Curator后,Spark不用管理与ZooKeeper的连接,数据读和写的接口更加简 单,这些对于Spark来说都是透明的。也就是说,Spark仅仅使用了100行代码,就实现了Master的高可用性。 5.7 小结 通过对Deploy模块的分析来看,该模块相对来说比较简单,也没有特别复杂的逻辑结构。Standalone并不是为了替代更谈不上完善Mesos/YARN,正如前 面所说的,Deploy模块是为了降低使用门槛,使更多的用户能够使用Spark而实现的一种方案。 当然现阶段看来还略显简陋,比如Application的调度方式(FIFO)是否会造成小应用长时间等待大应用的结束,是否有更好的调度策略;资源的衡量标 准是否可以更多、更合理,而不单单是CPU数量,因为现实场景中有的应用是本地IO密集型,有的是网络IO密集型,这样就算CPU资源有富余,调度新 的Application也不一定会很有意义。 总的来说作为一种简单替代方式,Deploy模块对于推广Spark还是有积极意义的。 在为Application分配了计算资源后,Scheduler模块的TaskScheduler会根据不同的调度策略为Task分配Application得到计算资源Executor,并且将Task发送到 Executor。接下来的章节会分析Executor的详细实现。 第6章 Executor模块详解 Executor模块负责运行Task计算任务,并将计算结果回传到Driver。Spark支持多种资源调度框架,这些资源框架在为计算任务分配资源后,最后都会使用 Executor模块完成最终的计算。 图6-1是计算的一个顶层逻辑关系图。每个Spark的Application都是从Spark-Context开始的,它通过Cluster Manager和Worker上的Executor建立联系,由每个 Executor完成Application的部分计算任务。不同的Cluster Master,即资源调度框架的实现模式会有区别,但是任务的划分和调度都是由运行SparkContext端 的Driver完成的,资源调度框架在为Application分配资源后,将Task分配到计算的物理单元Executor去处理。本章将着重讲解Executor模块的实现。 图6-1 计算的顶层逻辑关系 6.1 Standalone模式的Executor分配详解 在Standalone模式下集群启动时,Worker会向Master注册,使得Master可以感知进而管理整个集群;Master通过借助Zookeeper,可以简单实现高可用性; 而应用方通过SparkContext这个与集群的交互接口,在创建SparkContext时就完成了Application的注册,由Master为其分配Executor;在应用方创建了RDD并 且在这个RDD上进行了很多的转换后,触发Action,通过DAGScheduler将DAG划分为不同的Stage,并将Stage转换为TaskSet交给TaskSchedulerImpl;再由 TaskSchedulerImpl通过SparkDeploySchedulerBackend的reviveOffers,最终向ExecutorBackend发送LaunchTask的消息;ExecutorBackend接收到消息后,启动 Task,开始在集群中启动计算。具体过程如图6-2所示。 图6-2 Standalone模式下Executor的分配序列图 SparkContext是用户应用和Spark集群的交换的主要接口,用户应用一般首先要创建它。如果你使用SparkShell,你不必显式地创建它,系统会自动创建一 个名为sc的SparkContext的实例。创建SparkContext的实例,主要工作是设置一些参数,比如Executor使用到的内存的大小。如果系统的配置文件已经设 置,那么就直接读取该配置;否则读取环境变量。如果都没有设置,那么取默认值为512M。当然,这个数值还是很保守的,特别是在内存已经不是那 么昂贵的今天。除了加载这些集群的参数,它完成了TaskScheduler和DAGScheduler的创建。关于TaskScheduler和DAGScheduler可以参阅4.3节。 6.1.1 SchedulerBackend创建AppClient SparkDeploySchedulerBackend是Standalone模式的SchedulerBackend。在org.apache.spark.scheduler.cluster.SparkDeploySchedulerBackend#start中会创建AppClient, 而AppClient可以向Standalone的Master注册Application,然后Master会通过Application的信息为它分配Worker,包括每个Worker上使用CPU core的数目等。 AppClient向Master注册Application时需要发送以下消息: RegisterApplication(appDescription: ApplicationDescription) ApplicationDescription包含一个Application的所有信息,包括: private[spark] class ApplicationDescription( val name: String, //Application 的名字,可以通过 spark.app.name 设置 val maxCores: Option[Int], // 最多可以使用 CPU core 的数量,可以通过 spark.cores.max 设置。每个 Executor 可以占用的内存 // 数,可以通过 spark.executor.memory 、 SPARK_EXECUTOR_MEMORY 或者 SPARK_MEM 设置,默 // 认值是 512MB val memoryPerSlave: Int, // Worker Node 拉起的 ExecutorBackend 进程的 Command 。在 Worker 接到 Master LaunchExecutor // 后会通过 ExecutorRunner 启动这个 Command 。 Command 包含了启动一个 Java 进程所需要的信息, // 包括启动的 ClassName 、所需参数、环境信息等 val command: Command, var appUiUrl: String, // Application 的 web ui 的 hostname:port 如果 spark.eventLog.enabled (默认为 false ) // 指定为 true 的话, eventLogFile 就设置为 spark.eventLog.dir 定义的目录 val eventLogFile: Option[String] = None) 其中,Command中比较重要的是Class Name和启动参数,在Standalone模式中,Class Name就是org.apache.spark.executor.CoarseGrainedExecutorBackend;而启 动参数是一个列表: val args = Seq(driverUrl, "{{EXECUTOR_ID}}", "{{HOSTNAME}}", "{{CORES}}", "{{APP_ID}}", "{{WORKER_URL}}") 除了driverUrl,这些参数值都是未知的,比如Executor ID,只有在分配了Executor时这个值才可用。因此在通过Command启动时,需要将这些参数替换成 真实分配的值: def substituteVariables(argument: String): String = argument match { case "{{WORKER_URL}}" => workerUrl case "{{EXECUTOR_ID}}" => execId.toString case "{{HOSTNAME}}" => host case "{{CORES}}" => cores.toString case "{{APP_ID}}" => appId case other => other } driverUrl是Driver的URL,Executor可以通过这个URL创建Driver Actor的Reference,然后通过这个Reference和Driver进行通信。这个Actor的实现是 org.apache.spark.scheduler.cluster.CoarseGrainedSchedulerBackend.DriverActor。 SparkDeploySchedulerBackend还实现了org.apache.spark.deploy.client.AppClientListener。 AppClientListener是一个trait,主要为了确保AppClient在某种情况下能及时通知SparkDeploySchedulerBackend某些状态更新。在以下情况中,AppClient会回 调相关函数以通知SparkDeploySchedulerBackend: 1)向Master成功注册Application,即成功连接到集群。 2)断开连接,如果当前SparkDeploySchedulerBackend::stop==false,那么可能原来的Master有故障了,待新的Master准备就绪后,会重新恢复原来的连接。 3)Application由于不可恢复的错误停止了,这个时候需要重新提交出错的TaskSet。 4)添加一个Executor,在这里仅仅实现了打印log,并没有额外的逻辑。 5)删除一个Executor,可能有两个原因:一个是Executor退出了,这里可以得到Executor的退出码;另一个是由于Worker的退出导致运行在它之上的 Executor退出。这两种情况需要不同的逻辑来处理。 SparkDeploySchedulerBackend创建AppClient时,会将自身作为一个参数传递给AppClient,这样AppClient在接到Master某些状态更新后,就能调用它来实现 状态更新的通知。一些实现细节描述如下。 org.apache.spark.scheduler.cluster.SparkDeploySchedulerBackend#start中创建AppClient: client = new AppClient(sc.env.actorSystem, masters, appDesc, this, conf) 注意第4个参数this。AppClient将它作为自己的数据成员listener: private[spark] class AppClient( actorSystem: ActorSystem, masterUrls: Array[String], appDescription: ApplicationDescription, listener: AppClientListener, conf: SparkConf) 在AppClient接到Master的某些响应后,会调用listener的相应接口,以Application被Master删除的消息(ApplicationRemoved)为例,通过调用 listener.dead(reason)就可以通知SparkDeploySchedulerBackend了。 总之,SparkDeploySchedulerBackend准备好Application相关的信息后,创建AppClient;AppClient通过调用SparkDeploySchedulerBackend的某些接口来通知 Driver某些状态更新;通过ApplicationDescription中携带的Driver Actor的URL,Executor可以通过AKKA和Driver进行通信。 6.1.2 AppClient向Master注册Application AppClient是Application和Master交互的接口。它包含一个实现为org.apache.spark.deploy.client.AppClient.ClientActor的成员变量actor。它负责了所有与Master的 交互。Master与actor主要的消息如下: 1)RegisteredApplication(appId_,masterUrl)=>//注:来自Master的成功注册Application的消息。 2)ApplicationRemoved(message)=>//注:来自Master的删除Application的消息。Application执行成功或者失败最终都会被删除。 3)ExecutorAdded(id:Int,workerId:String,hostPort:String,cores:Int,memory:Int)=>//注:来自Master的消息。 4)ExecutorUpdated(id,state,message,exitStatus)=>?//注:来自Master的Executor状态更新的消息,如果Executor是完成的状态,那么回调 SchedulerBackend的executorRemoved的函数。 5)MasterChanged(masterUrl,masterWebUiUrl)=>??//注:来自新竞选成功的Master。Master可以选择ZK实现HA,并且使用ZK来持久化集群的元数据 信息。因此在Master变成leader后,会恢复持久化的Application、Driver和Worker的信息。 6)StopAppClient=>//注:来自AppClient::stop()。 注册过程如图6-3所示。 图6-3 Application的注册 在actor的preStart里,actor就会向Master发起注册Application的请求: def tryRegisterAllMasters() { for (masterUrl <- masterUrls) { logInfo("Connecting to master " + masterUrl + "...") val actor = context.actorSelection(Master.toAkkaUrl(masterUrl)) actor ! RegisterApplication(appDescription) } } 如果超过20s没有接收到注册成功的消息,那么会重新注册;如果重试超过3次仍未成功,那么本次注册就失败了。实际上,除非连接不上Master,否则 注册都会成功的。 Master端在接到注册的请求后,首先会将Application放到自己维护的数据结构中: 1)apps+=app//HashSet[ApplicationInfo],保存了Master上所有的Application。 2)idToApp(app.id)=app//HashMap[String,ApplicationInfo],app.id是在Master端分配的,格式是“app-currentdate-nextAppNumber”,其中nextAppNumber是 Master启动以来注册的Application的总数-1,取四位数。 3)actorToApp(app.driver)=app//HashMap[ActorRef,ApplicationInfo],app.driver就是org.apache.spark.deploy.client.AppClient.ClientActor。 4)addressToApp(appAddress)=app//HashMap[Address,ApplicationInfo],appAddress=app.driver.path.address。 5)waitingApps+=app//ArrayBuffer[ApplicationInfo],等待被调度的Application。 在调用Master的PersistenceEngine持久化Application的元数据后,会将结果返回AppClient的actor;之后会开始新一轮的资源调度 org.apache.spark.deploy.master.Master#schedule。除了新加入的Application会开始资源调度外,资源的变动本身,比如新的Worker的加入,也会开始资源调 度。核心代码实现如下: case RegisterApplication(description) => { if (state == RecoveryState.STANDBY) { // Standby 的 Master 会忽略注册消息,如果 AppClient 发送请求到 Standby 的 Master , // 会触发超时机制(默认是 20s ),超时会重试 } else { logInfo("Registering app " + description.name) val app = createApplication(description, sender) // app 是 ApplicationInfo(now, newApplicationId(date), desc, date, // driver, defaultCores) , driver 就是 AppClient 的 actor // 保存到 Master 维护的成员变量中 /* apps += app; idToApp(app.id) = app actorToApp(app.driver) = app addressToApp(appAddress) = app waitingApps += app */ registerApplication(app) logInfo("Registered app " + description.name + " with ID " + app.id) persistenceEngine.addApplication(app) // 持久化 app 的元数据信息,可以选择持 // 久化到 ZooKeeper 、本地文件系统,或者不持久化 sender ! RegisteredApplication(app.id, masterUrl) schedule() // 为处于待分配资源的 Application 分配资源。在每次有新的 Application // 加入或者新的资源加入时都会调用 schedule 进行调度 } } 6.1.3 Master根据AppClient的提交选择Worker org.apache.spark.deploy.master.Master#schedule为处于待分配资源的Application分配资源。在每次有新的Application加入或者新的资源加入时都会调用schedule 进行调度。为Application分配资源选择Worker(Executor),现在有两种策略: 1)尽量打散,即将一个Application尽可能多地分配到不同的节点。可以通过设置spark.deploy.spreadOut来实现。默认值为true。 2)尽量集中,即一个Application尽量分配到尽可能少的节点。CPU密集型而内存占用比较少的Application适合使用这种策略。 对于同一个Application,它在一个Worker上只能拥有一个Executor;这并不代表一个物理节点只能部署一个Executor:可以通过在一个物理节点上部署多个 Worker来完成。 if (spreadOutApps) {// 尽量打散负载,如有可能,每个 executor 分配一个 core for (app <- waitingApps if app.coresLeft > 0) { // 可用的 Worker 的标准: State 是 ALIVE ,其上并没有该 Application 的 Executor , // 可用内存满足要求。在可用的 Worker 中,优先选择可用 core 数多的 val usableWorkers = workers.toArray.filter(_.state == WorkerState.ALIVE) .filter(canUse(app, _)).sortBy(_.coresFree).reverse val numUsable = usableWorkers.length // 记录与 usableWorkers 对应的 Worker 上可以使用多少 core val assigned = new Array[Int](numUsable) // Number of cores to give on each node // 待分配的 core 数,如果当前可用的 core 满足 Application 需求,那么全部分配;否则将 // 剩余的 core 分配给该 Application var toAssign = math.min(app.coresLeft, usableWorkers.map(_.coresFree).sum) var pos = 0 while (toAssign > 0) { if (usableWorkers(pos).coresFree - assigned(pos) > 0) { // 注意 usableWorkers 是按照剩余 core 数倒序排列的,因此拥有最多可用 core 的 // Worker 先被使用 toAssign -= 1 assigned(pos) += 1 } pos = (pos + 1) % numUsable } // 已经确定了分配策略,根据确定的在某个 Worker 上分配的 core 数来在 Worker 上启 // 动 Executor for (pos <- 0 until numUsable) { if (assigned(pos) > 0) { val exec = app.addExecutor(usableWorkers(pos), assigned(pos)) // 在 Worker 上启动 Executor launchExecutor(usableWorkers(pos), exec) app.state = ApplicationState.RUNNING } } } } else { // 尽可能多地利用 Worker 的 core ,将 Executor 的数量减少到最低,比如对于计算密集型 // 但是内存使用较少的 Application for (worker <- workers if worker.coresFree > 0 && worker.state == WorkerState.ALIVE) { for (app <- waitingApps if app.coresLeft > 0) { // 可用的 Worker 的标准: State 是 ALIVE ,其上并没有该 Application 的 Executor , // 可用内存满足要求 if (canUse(app, worker)) { val coresToUse = math.min(worker.coresFree, app.coresLeft) if (coresToUse > 0) { val exec = app.addExecutor(worker, coresToUse) // 在 Worker 上启动 Executor launchExecutor(worker, exec) app.state = ApplicationState.RUNNING } } } } } 在选择了Worker和确定了Worker上Executor需要的CPU Core数后,Master会调用?launchExecutor(worker:WorkerInfo,exec:ExecutorInfo)向Worker发送 请求,向AppClient的actor发送Executor已经添加的消息。同时会更新Master保存的Worker的信息,包括增加Executor、减少可用的CPU Core数和memory数 (注意,Worker上的资源信息并不是Worker主动上报到Master的,而是Master主动维护的,因为所有的资源分配都是由Master来完成的)。Master不会等 到真正在Worker上成功启动Executor后再更新Worker的信息。如果Worker启动Executor失败,那么它会发送FAILED的消息给Master,Master收到该消息时 再次更新Worker的信息即可,这种实现简化了逻辑。 def launchExecutor(worker: WorkerInfo, exec: ExecutorInfo) { logInfo("Launching executor " + exec.fullId + " on worker " + worker.id) // 更新 Worker 的信息,可用 core 数和 memory 数减去本次分配的 Executor 占用的,因此 Worker // 的信息无需 Worker 主动汇报 worker.addExecutor(exec) // 向 Worker 发送启动 Executor 的请求 worker.actor ! LaunchExecutor(masterUrl,exec.application.id, exec.id, exec.application.desc, exec.cores, exec.memory) // 向 AppClient 的 actor 发送 Executor 已经添加的消息 exec.application.driver ! ExecutorAdded(exec.id, worker.id, worker. hostPort, exec.cores, exec.memory) } 现在的分配方式比较简单,并没有考虑节点的当前总体负载。虽然这样的方式可以使节点上Executor的分配比较均匀,而且单纯静态地从Executor分配到 得CPU core数和memory数来看,负载是比较均衡的。但是从实际情况来看,可能有的Executor的CPU资源消耗比较大;而其他的Executor可能网络负载比 较高,因此会导致集群负载不均衡和资源的倾斜。这个需要从生产环境的数据得到反馈来进一步修正和细化分配策略,以达到更好的资源利用率。 6.1.4 Worker根据Master的资源分配结果创建Executor Worker接收到来自Master的LaunchExecutor的消息后,会创建org.apache.spark.deploy.worker.ExecutorRunner。 ExecutorRunner会将在org.apache.spark.scheduler.cluster.SparkDeployScheduler-Backend中准备好的org.apache.spark.deploy.ApplicationDescription以进程的形式启 动。当时以下几个参数还是未知的: val args = Seq(driverUrl, "{{EXECUTOR_ID}}", "{{HOSTNAME}}", "{{CORES}}", "{{WORKER_URL}}") 。 ExecutorRunner需要将它们替换成已经分配好的实际值: def substituteVariables(argument: String): String = argument match { case "{{WORKER_URL}}" => workerUrl case "{{EXECUTOR_ID}}" => execId.toString case "{{HOSTNAME}}" => host case "{{CORES}}" => cores.toString case "{{APP_ID}}" => appId case other => other } 通过command.arguments.map(substituteArguments)可以将其替换为真实值。 org.apache.spark.deploy.worker.ExecutorRunner#fetchAndRunExecutor会负责启动CoarseGrainedExecutorBackend。CoarseGrainedExecutorBackend会负责通过 AKKA和Driver通信。它在启动后,会首先向Driver注册Executor: driver = context.actorSelection(driverUrl) driver ! RegisterExecutor(executorId, hostPort, cores) Driver是一个Actor的Reference,实现是org.apache.spark.scheduler.cluster.CoarseGrainedSchedulerBackend::DriverActor的Reference。Driver在接到RegisterExecutor消 息后,会将Executor的信息保存在本地,并且调用CoarseGrainedSchedulerBackend.DriverActor#makeOffers实现在Executor上启动Task。具体的实现细节请参 阅4.3节。 回到CoarseGrainedExecutorBackend,它在接到Driver的回应RegisteredExecutor后,会创建一个org.apache.spark.executor.Executor。至此,Executor创建完毕。 Executor在Mesos、YARN和Standalone模式中都是相同的,不同的只是资源的分配管理方式。 6.2 Task的执行 org.apache.spark.scheduler.cluster.CoarseGrainedSchedulerBackend.DriverActor#launchTasks会将分配到Executor的Task进行分配: val executorData = executorDataMap(task.executorId) executorData.freeCores -= scheduler.CPUS_PER_TASK executorData.executorActor ! LaunchTask(new SerializableBuffer(serializedTask)) org.apache.spark.executor.CoarseGrainedExecutorBackend收到LaunchTask的消息后,会调用Executor的launchTask启动Task: case LaunchTask(data) => if (executor == null) { logError("Received LaunchTask command but executor was null") System.exit(1) } else { val ser = env.closureSerializer.newInstance() val taskDesc = ser.deserialize[TaskDescription](data.value) logInfo("Got assigned task " + taskDesc.taskId) executor.launchTask(this, taskDesc.taskId, taskDesc.name, taskDesc. serializedTask) } Executor会为这个Task生成一个TaskRunner(继承自Runnable);TaskRunner的run中会执行Task。TaskRunner最终会被放到一个ThreadPool中去执行。 val tr = new TaskRunner(context, taskId, taskName, serializedTask) runningTasks.put(taskId, tr) threadPool.execute(tr) 接下来将介绍org.apache.spark.executor.Executor.TaskRunner#run的实现。 6.2.1 依赖环境的创建和分发 在Driver端封装Task时,会将Task依赖的文件封装到Task中: val out = new ByteArrayOutputStream(4096) val dataOut = new DataOutputStream(out) // Write currentFiles dataOut.writeInt(currentFiles.size) for ((name, timestamp) <- currentFiles) { dataOut.writeUTF(name) dataOut.writeLong(timestamp) } // Write currentJars dataOut.writeInt(currentJars.size) for ((name, timestamp) <- currentJars) { dataOut.writeUTF(name) dataOut.writeLong(timestamp) } // Write the task itself and finish dataOut.flush() val taskBytes = serializer.serialize(task).array() out.write(taskBytes) ByteBuffer.wrap(out.toByteArray) 在Executor端,恢复Task的依赖和Task本身: val in = new ByteBufferInputStream(serializedTask) val dataIn = new DataInputStream(in) // Read task's files val taskFiles = new HashMap[String, Long]() val numFiles = dataIn.readInt() for (i <- 0 until numFiles) { taskFiles(dataIn.readUTF()) = dataIn.readLong() } // Read task's JARs val taskJars = new HashMap[String, Long]() val numJars = dataIn.readInt() for (i <- 0 until numJars) { taskJars(dataIn.readUTF()) = dataIn.readLong() } // Create a sub-buffer for the rest of the data, which is the serialized Task object val subBuffer = serializedTask.slice() // ByteBufferInputStream will have read just up to task (taskFiles, taskJars, subBuffer) 从序列化的Task中获取依赖的文件和依赖的位置信息后,org.apache.spark.executor.Executor会调用updateDependencies下载这些依赖: Utils.fetchFile(name, new File(SparkFiles.getRootDirectory), conf,env. securityManager, hadoopConf, timestamp, useCache = !isLocal) 依赖下载完成后,Executor就具备执行Task的能力了,接下来,Executor会通过TaskRunner执行任务。 6.2.2 任务执行 在准备好了Task的运行时环境后,Task就会执行: // 开始执行任务,并且记录任务的耗时情况 taskStart = System.currentTimeMillis() val value = task.run(taskId.toInt) // 执行 Task ,计算结果存入 value val taskFinish = System.currentTimeMillis() // 如果任务已经被杀掉,那么直接失败返回 if (task.killed) { throw new TaskKilledException } task.run(taskId.toInt)就是执行最终的计算,它首先为本次计算创建一个Task-Context。TaskContext会保留该Task的上下文,而且这个TaskContext是一个 Thread Local变量: private static ThreadLocal taskContext = new ThreadLocal(); 通过setTaskContext可以设置这个Context为特定Thread的上下文: static void setTaskContext(TaskContext tc) { taskContext.set(tc); } 而通过get()可以获取这个上下文: public static TaskContext get() { return taskContext.get(); } 比如在org.apache.spark.rdd.PairRDDFunctions#combineByKey中会通过它来获取当前Task的上下文。 除此之外,TaskContext还有一个重要的设置是用户可以设置Task结束时的回调函数,Task会在结束时调用这个回调函数来完成最终的处理。目前推荐使 用下面两种方式来完成这个回调函数的注册: public abstract TaskContext addTaskCompletionListener(TaskCompletionListener listener); public abstract TaskContext addTaskCompletionListener(final Function1 f); 而之前的方式已经被废弃(deprecated)了,不再推荐使用: public abstract void addOnCompleteCallback(final Function0 f); org.apache.spark.TaskContextImpl就是TaskContext的一个具体实现。addOnComplete-Callback其中的一个实现如下: override def addTaskCompletionListener(f: TaskContext => Unit): this.type = { onCompleteCallbacks += new TaskCompletionListener { override def onTaskCompletion(context: TaskContext): Unit = f(context) } this } 在Task执行结束时,会通过调用org.apache.spark.TaskContextImpl#markTaskCompleted来调用回调函数: completed = true // 标记 Task 完成为 true val errorMsgs = new ArrayBuffer[String](2) // 记录错误信息 // Process complete callbacks in the reverse order of registration onCompleteCallbacks.reverse.foreach { listener => try { listener.onTaskCompletion(this) // 执行回调函数 } catch { case e: Throwable => errorMsgs += e.getMessage // 发生异常,记录错误信息 logError("Error in TaskCompletionListener", e) } } if (errorMsgs.nonEmpty) {// 如果错误信息不为空,那么抛出异常 throw new TaskCompletionListenerException(errorMsgs) } 它的核心实现如下: // 为本次的任务生成 TaskContext context = new TaskContextImpl(stageId, partitionId, attemptId, runningLocally = false) // 设置上下文信息,实际上会调用 org.apache.spark.TaskContext#setTaskContext TaskContextHelper.setTaskContext(context) context.taskMetrics.hostname = Utils.localHostName() // 更新 metrics 信息 // 当前线程,在被打断的时候可以通过它来停止该线程 taskThread = Thread.currentThread() if (_killed) {// 如果当前 Task 被杀死,那么需要退出 Task 的执行 kill(interruptThread = false) } try { runTask(context) // 执行本次 Task } finally { // 任务结束,执行任务结束时的回调函数 context.markTaskCompleted() TaskContextHelper.unset() } runTask(context)在不同的Task会有不同的实现。现在有两种Task: 1)org.apache.spark.scheduler.ResultTask 对于最后一个Stage,会根据生成结果的Partition来生成与Partition数量相同的ResultTask。然后,ResultTask会将计算的结果汇报到Driver端。具体实现如下: // 获取用于反序列化的实例 val ser = SparkEnv.get.closureSerializer.newInstance() // 获取 rdd 和作用于 rdd 结果的函数 val (rdd, func) = ser.deserialize[(RDD[T], (TaskContext, Iterator[T]) => U)]( ByteBuffer.wrap(taskBinary.value), Thread.currentThread.getContextClassLoader) //Task 的 metrics 信息 metrics = Some(context.taskMetrics) // 调用 rdd.iterator 执行 rdd 上的计算。详细可以参阅第3章。 func(context, rdd.iterator(partition, context)) 2)org.apache.spark.scheduler.ShuffleMapTask 对于非最后的Stage,会根据每个Stage的Partition数量来生成ShuffleMapTask。ShuffleMapTask会根据下游Task的Partition数量和Shuffle的策略来生成一系列文 件。它的计算过程如下: a)val manager=SparkEnv.get.shuffleManager 从SparkEnv中获得Shuffle Manager,现在Spark支持Hash和Sort Based Shuffle,是内置的功能;此外Spark还支持额外的Shuffle Service。用户可以通过实现几个 类就可以使用自定义的Shuffle Service。 b)writer=manager.getWriter[Any,Any](dep.shuffleHandle,partitionId,context) 从Shuffle Manager里取得Writer,对于Hash Based Shuffle来说,writer就是org.apache.spark.shuffle.hash.HashShuffleWriter;对于Sort Based Shuffle来说,writer就是 org.apache.spark.shuffle.sort.SortShuffleWriter。 c)writer.write(rdd.iterator(partition,context).asInstanceOf[Iterator[_<:Product2[Any,Any]]]) 调用rdd开始计算,并且将计算结果通过writer写入文件系统。 d)return writer.stop(success=true).get 关闭writer,并将计算结果返回。详情请参阅第7章。 6.2.3 任务结果的处理 在Executor运行Task时,得到的计算结果会存入org.apache.spark.scheduler.Direct-TaskResult。 // value 中保存 Task 的计算结果 val value = task.run(taskId.toInt) val resultSer = env.serializer.newInstance() // 获得序列化工具 val valueBytes = resultSer.serialize(value) // 序列化结果 // 首先将结果直接放入 org.apache.spark.scheduler.DirectTaskResult val directResult = new DirectTaskResult(valueBytes, accumUpdates, task.metrics.orNull) val ser = env.closureSerializer.newInstance() val serializedDirectResult = ser.serialize(directResult) // 序列化结果 val resultSize = serializedDirectResult.limit // 序列化结果的大小 但是这个serializedDirectResult并不是直接回传到Driver。在将结果回传到Driver时,会根据结果的大小使用不同的策略: 1)如果结果大于1GB,那么直接丢弃这个结果。这个是Spark 1.2中新加入的策略。可以通过spark.driver.maxResultSize来进行设置。 2)对于“较大”的结果,将其以taskId为key存入org.apache.spark.storage.BlockManager;如果结果不大,则直接回传给Driver。那么如何判定这个阈值呢?这 里的回传是直接通过AKKA的消息传递机制。因此结果首先不能超过该机制设置的消息的最大值。这个最大值是通过spark.akka.frameSize设置的,单位是 MByte,默认值是10MB。除此之外,还有200KB的预留空间。因此这个阈值就是conf.getInt("spark.akka.frameSize",10)*1024*1024-200*1024。 3)其他情况则直接通过AKKA回传到Driver。 实现源码解析如下: val serializedResult = { if (maxResultSize > 0 && resultSize > maxResultSize) { // 如果结果的大小大于 1GB ,那么直接丢弃,可以在 spark.driver.maxResultSize 设置 ser.serialize(new IndirectTaskResult[Any](TaskResultBlockId(taskId), resultSize)) } else if (resultSize >= akkaFrameSize - AkkaUtils.reservedSizeBytes) { // 如果不能通过 Akka 的消息传递,那么放入 BlockManager 等待调用者以网络的形式来获取。 // Akka 的消息的默认大小可以通过 spark.akka.frameSize 来设置,默认值是 10MB val blockId = TaskResultBlockId(taskId) env.blockManager.putBytes( blockId, serializedDirectResult, StorageLevel.MEMORY_AND_DISK_SER) ser.serialize(new IndirectTaskResult[Any](blockId, resultSize)) } else { // 结果可以直接回传到 Driver serializedDirectResult } } // 通过 Akka 向 Driver 汇报本次 Task 的已经完成 execBackend.statusUpdate(taskId, TaskState.FINISHED, serializedResult) 而execBackend是org.apache.spark.executor.ExecutorBackend的一个实例,它实际上是Executor与Driver通信的接口: private[spark] trait ExecutorBackend { def statusUpdate(taskId: Long, state: TaskState, data: ByteBuffer) } TaskRunner会将Task执行的状态汇报给Driver(org.apache.spark.scheduler.cluster.CoarseGrainedSchedulerBackend.DriverActor)。而Driver会转给 org.apache.spark.scheduler.TaskSchedulerImpl#statusUpdate。 6.2.4 Driver端的处理 TaskRunner将Task的执行状态汇报给Driver后,Driver会转给org.apache.spark.scheduler.TaskSchedulerImpl#statusUpdate。在这里,不同的状态有不同的处理: 如果类型是TaskState.FINISHED,那么调用org.apache.spark.scheduler.TaskResultGetter#enqueueSuccessfulTask进行处理。 如果类型是TaskState.FAILED或者TaskState.KILLED或者TaskState.LOST,调用org.apache.spark.scheduler.TaskResultGetter#enqueueFailedTask进行处理。对于 TaskState.LOST,还需要将其所在的Executor标记为FAILED,并且根据更新后的Executor重新调度。 enqueueSuccessfulTask的逻辑也比较简单,即如果是IndirectTaskResult,那么需要通过blockId来获取结果: sparkEnv.blockManager.getRemoteBytes(blockId);如果是DirectTaskResult,那么结果就无需远程获取了。然后调用以下调用栈进行处理。核心逻辑是第5 个调用栈。 1 ) org.apache.spark.scheduler.TaskSchedulerImpl#handleSuccessfulTask 2 ) org.apache.spark.scheduler.TaskSetManager#handleSuccessfulTask 3 ) org.apache.spark.scheduler.DAGScheduler#taskEnded 4 ) org.apache.spark.scheduler.DAGScheduler#eventProcessActor 5 ) org.apache.spark.scheduler.DAGScheduler#handleTaskCompletion 如果Task是ShuffleMapTask,那么它需要将结果通过某种机制告诉下游的Stage,以便其可以作为下游Stage的输入。这个机制是怎么实现的? 实际上,对于ShuffleMapTask来说,其结果实际上是org.apache.spark.scheduler.MapStatus;序列化后存入DirectTaskResult或者IndirectTaskResult中。而 DAGScheduler#handleTaskCompletion通过下面的方式来获取这个结果: val status =event.result.asInstanceOf[MapStatus] 通过将这个status注册到org.apache.spark.MapOutputTrackerMaster,就完成了结果处理的过程: mapOutputTracker.registerMapOutputs( stage.shuffleDep.get.shuffleId, stage.outputLocs.map(list => if (list.isEmpty) null else list.head).toArray, changeEpoch = true) 而registerMapOutputs的处理也很简单,以shuffleId为key将MapStatus的列表存入带有时间戳的HashMap:TimeStampedHashMap[Int,Array[MapStatus]]()。 如果设置了cleanup的函数,那么这个HashMap会将超过一定时间(TTL,Time to Live)的数据清理掉。 6.3 参数设置 6.3.1 spark.executor.memory 它配置了Executor可以最多使用的内存大小,是通过设置Executor的JVM的Heap尺寸来实现的。由于一个集群的内存资源总归是有限的,而且这些内存会 被很多的应用共享,因此,设置一个合理的内存值是非常必要的。如果设置的过大,那么可能会导致部分任务由于分配不到资源而等待,延长整个应用 的执行时间;如果设置过小,那么就会产生频繁的垃圾回收和读写外部磁盘,同样影响性能。 Executor的内存是被其内部所有的任务共享,而每个Executor上可以支持的任务的数量取决于Executor所持有的CPU Core的数量。因此为了评估一个 Executor占用多少内存是合适的,需要了解每个任务的数据规模的大小和计算过程中所需要的临时内存空间的大小。实际上,要比较精确地计算出一个 任务所需要的内存空间还是非常困难的,首先,因为数据本身加载到内存中,由于有管理这些内存的额外内存开销,可能需要的真实内存是数据大小的 数倍;其次,任务计算过程中所需要的临时内存空间的大小会因为算法的不同而不同,这是比较难评估的。如果需要比较准确的评估数据集的大小的 话,可以将RDD cache在内存中,从BlockManager的日志中可以看到每个Cache分区的大小(实际上,这个大小也是一个估计值)。 如果内存比较紧张,就需要合理规划每个分区任务的数据规模,例如采用更多的分区,用增加任务数量(进而需要更多的批次来运算所有的任务)的方 式来减小每个任务所需处理的数据大小。 6.3.2 日志相关 如果spark.eventLog.enabled设置为true(默认为false),那么需要设置日志写入的目录spark.eventLog.dir,这样,日志就可以保存到本地,方便调试和问题 追踪。但是随着时间推移,节点的日志必须需要一个清除机制,否则日志很容易写满磁盘。通过表6-1的设置,可以设置日志清理的策略。 表6-1 日志相关的配置选项 6.3.3 spark.executor.heartbeatInterval Executor和Driver之间心跳的间隔,单位是毫秒。心跳主要是Executor向Driver汇报运行状态和Executor上报Task的统计信息(metric)。这个数值一般无须专 门设置。 6.4 小结 Executor模块负责运行Task计算任务,并将计算结果回传到Driver。Spark支持多种资源调度框架,这些资源框架在为计算任务分配了资源后,最后都会使 用Executor模块完成最终的计算。 本章以Standalone模式为例,解析用户提交的应用是如何分配到Executor资源的,接着详细介绍了Task在Executor执行的实现细节。 第7章 Shuffle模块详解 Shuffle,无疑是性能调优的一个重点,本章将从源码实现的角度,深入解析Spark Shuffle的实现细节;并且根据Shuffle实现迭代的历史,了解Spark的Shuffle 如何解决遇到的问题,这个对于互联网工程实践也具有很重要的指导意义:一个可用、可上线与完美的方案是有区别的,与其自己在线下不断“优化”, 不如将一个能够满足当前需要的实现先上线,并且根据需求或者应用场景的变化不断进行迭代、改进。这样,一个系统或者功能就会慢慢地发展起来。 Shuffle,翻译成中文就是洗牌。之所以需要Shuffle,还是因为具有某种共同特征的一类数据需要最终汇聚(aggregate)到一个计算节点上进行计算。这些 数据分布在各个存储节点上并且由不同节点的计算单元处理。以最简单的Word Count为例,其中数据保存在Node1、Node2和Node3;经过处理后,这些 数据最终会汇聚到Nodea、Nodeb处理,如图7-1所示。 这个数据重新打乱然后汇聚到不同节点的过程就是Shuffle。但是实际上,Shuffle过程可能会非常复杂: 1)数据量会很大,比如单位为TB或PB的数据分散到几百甚至数千、数万台机器上。 2)为了将这个数据汇聚到正确的节点,需要将这些数据放入正确的Partition,因为数据大小已经大于节点的内存,因此这个过程中可能会发生多次硬盘 续写。 图7-1 Word Count的Shuffle示意图 3)为了节省带宽,这个数据可能需要压缩,如何在压缩率和压缩解压时间中间做一个比较好的选择? 4)数据需要通过网络传输,因此数据的序列化和发序列化也变得相对复杂。 一般来说,每个Task处理的数据可以完全载入内存(如果不能,可以减小每个Partition的大小),因此Task可以做到在内存中计算。除非非常复杂的计算 逻辑,否则为了容错而持久化中间的数据是没有太大收益的,毕竟中间某个过程出错了可以从头开始计算。但是对于Shuffle来说,如果不持久化这个中 间结果,一旦数据丢失,就需要重新计算依赖的全部RDD,因此有必要持久化这个中间结果。 接下来会深入分析每个Shuffle Map Task结束时,数据是如何持久化(即Shuffle Write)以使得下游的Task可以获取到其需要处理的数据的(即Shuffle Read)。最后展望Shuffle接下来可能会有的改进。需要注意的是在Spark 0.8以后,Shuffle Write会将数据持久化到硬盘,虽然之后Shuffle Write不断进行演 进优化,但是数据落地到本地文件系统的实现并没有改变。 7.1 Hash Based Shuffle Write 在Spark 1.0以前,Spark只支持Hash Based Shuffle。因为在很多运算场景中并不需要排序,因此多余的排序只能使性能变差,比如Hadoop的Map Reduce就 是这么实现的,也就是Reducer拿到的数据都是已经排好序的。实际上Spark的实现很简单:每个Shuffle Map Task根据key的哈希值,计算出每个key需要写 入的Partition然后将数据单独写入一个文件,这个Partition实际上就对应了下游的一个Shuffle Map Task或者Result Task。因此下游的Task在计算时会通过网 络(如果该Task与上游的Shuffle Map Task运行在同一个节点上,那么此时就是一个本地的硬盘读写)读取这个文件并进行计算。 7.1.1 Basic Shuffle Writer实现解析 在Executor上执行Shuffle Map Task时,最终会调用org.apache.spark.scheduler.ShuffleMapTask的runTask。核心逻辑: val manager = SparkEnv.get.shuffleManager writer = manager.getWriter[Any, Any](dep.shuffleHandle, partitionId, context) writer.write(rdd.iterator(partition, context).asInstanceOf[Iterator[_<:Product2[Any, Any]]]) return writer.stop(success = true).get 这个逻辑比较简单,总结如下: 1)从SparkEnv中获得shuffleManager,就如前面提到的,Spark除了支持Hash和Sort Based Shuffle外,还支持external的Shuffle Service。用户可以通过实现几个 类就可以使用自定义的Shuffle。 2)从manager里取得Writer,在这里获得的是org.apache.spark.shuffle.hash.HashShuffleWriter。 3)调用rdd开始运算,运算结果通过Writer进行持久化,逻辑在org.apache.spark.shuffle.hash.HashShuffleWriter#write。开始时通过org.apache.spark.Shuffle- Dependency是否定义了org.apache.spark.Aggregator来确定是否需要做Map端的聚合。然后将原始结果或者聚合后的结果通过 org.apache.spark.shuffle.FileShuffleBlockManager#forMapTask的方法写入。写入完成后,会将元数据信息写入org.apache.spark.scheduler.MapStatus。然后下游的 Task可以通过这个MapStatus取得需要处理的数据。 下面我们来看一下步骤3的具体实现。首先ShuffledRDD会调用org.apache.spark.shuffle.hash.HashShuffleWriter#write进行写操作: val iter = if (dep.aggregator.isDefined) { if (dep.mapSideCombine) { // 如果需要做聚合,那么将聚合 records dep.aggregator.get.combineValuesByKey(records, context) } else {// 此处省略一些非关键代码 records } for (elem <- iter) { val bucketId = dep.partitioner.getPartition(elem._1)// 获得该 element 需要写入的 partition // 实际调用 org.apache.spark.shuffle.FileShuffleBlockManager.forMapTask 进行写入 shuffle.writers(bucketId).write(elem) } 而org.apache.spark.shuffle.FileShuffleBlockManager#forMapTask#writers的定义: val writers: Array[BlockObjectWriter] = if (consolidateShuffleFiles) { // 此处省略与本部分无关代码 } else { Array.tabulate[BlockObjectWriter](numBuckets) { bucketId => val blockId = ShuffleBlockId(shuffleId, mapId, bucketId) val blockFile = blockManager.diskBlockManager.getFile(blockId) // 如果 blockFile 已经存在,那么删除它并打印日志。代码已经忽略 blockManager.getDiskWriter(blockId, blockFile, serializer, bufferSize, writeMetrics) } } writers通过org.apache.spark.ShuffleDependency#partitioner#numPartitions来获得下游Partition的数量;而通过前文的分析我们知道,这个Partition的数量实际与下 游Task的数量相对应:从运行时角度来看,每个Partition对应一个Task,从而形成流水线来高效并发地处理数据。 下游的每个Partition都会对应于一个文件,文件名字的格式就是"shuffle_"+shuffleId+"_"+mapId+"_"+reduceId。其中reduceId和Partition的数量对应,比如有1000 个Partition,那么这个reduceId就是0~999。blockManager.getDiskWriter为每个文件创建一个org.apache.spark.storage.DiskBlockObjectWriter,DiskBlock- ObjectWriter可以直接向一个文件写入数据,如果文件已经存在,那么会以追加的方式写入。由于blockFile已经存在的话会被删除,因此这里只会创建一 个新的文件。 可以通过图7-2来加深上述过程的理解。 图7-2 基本的Hash Based Shuffle Write 7.1.2 存在的问题 由于每个Shuffle Map Task需要为每个下游的Task创建一个单独的文件,因此文件的数量就是number(shuffle_map_task)*number(following_task)。如果 Shuffle Map Task是1000,下游的Task是500,那么理论上会产生500000个文件(对于size为0的文件Spark有特殊的处理)。生产环境中Task的数量实际上会 更多,因此这个简单的实现会带来以下问题: 1)每个节点可能会同时打开多个文件,每次打开文件都会占用一定内存。假设每个Write Handler的默认需要100KB的内存,那么同时打开这些文件需要 50GB的内存,对于一个集群来说,还是有一定的压力的。尤其是如果Shuffle Map Task和下游的Task同时增大10倍,那么整体的内存就增长到5TB。 2)从整体的角度来看,打开多个文件对于系统来说意味着随机读,尤其是每个文件比较小但是数量非常多的情况。而现在机械硬盘在随机读方面的性 能特别差,非常容易成为性能的瓶颈。如果集群依赖的是固态硬盘,也许情况会改善很多,但是随机写的性能肯定不如顺序写的。 7.1.3 Shuffle Consolidate Writer 为了解决Shuffle过程产生文件过多的问题,在Spark 0.8.1中加入了Shuffle Consolidate Files机制。它的主要目标是减少Shuffle过程产生的文件。若使用这个功 能,则需要将spark.shuffle.consolidateFiles设置为true。在Spark 1.2.0中,这个功能还是没有成为默认选项。官方给出的说法是这个功能还欠稳定。接下来介 绍这个机制的实现原理。图7-3描述了Shuffle Consolidate Files的实现细节。 图7-3 Shuffle Consolidate Files 对于运行在同一个Core的Shuffle Map Task,第一个Shuffle Map Task会创建一个文件;之后的就会将数据追加到这个文件上而不是新建一个文件。因此文 件数量就从number(shuffle_map_task)*number(following_task)变成了number(cores)*number(following_task)。当然,如果每个Core都只运行一个 Shuffle Map Task,那么就和原来的机制一样了。但是Shuffle Map Task明显多于Core数量或者说每个Core都会运行多个Shuffle Map Task,所以这个实现能够 显著减少文件的数量。 而实现的细节就是不同的org.apache.spark.shuffle.FileShuffleBlockManager#forMapTask#writers的实现: val writers: Array[BlockObjectWriter] = if (consolidateShuffleFiles) { fileGroup = getUnusedFileGroup() // 获得没有使用的 FileGroup Array.tabulate[BlockObjectWriter](numBuckets) { bucketId => val blockId = ShuffleBlockId(shuffleId, mapId, bucketId) blockManager.getDiskWriter(blockId, fileGroup(bucketId), serializer, bufferSize, writeMetrics) } } else { // Basic Shuffle Writer 的实现 org.apache.spark.shuffle.FileShuffleBlockManager.ShuffleFileGroup可以理解成一个文件组,这个文件组的每个文件都对应一个Partition或者下游的Task。因此对 第一个Shuffle Map Task来说,它创建了一个文件;而接下来的Shuffle Map Task都是以追加的方式写这个文件。 blockManager.getDiskWriter为每个文件创建一个org.apache.spark.storage.DiskBlock-ObjectWriter,DiskBlockObjectWriter可以直接向一个文件写入数据,如果 文件已经存在那么会以追加的方式写入。 但是下游的Task如何区分文件不同的部分呢?在同一个Core上运行Shuffle Map Task相当于写了这个文件的不同的部分。答案就在 org.apache.spark.shuffle.FileShuffleBlockManager.ShuffleFileGroup#getFileSegmentFor。这将在7.5节详细解析。 7.1.4 小结 Shuffle Consolidate的机制虽然在某些场景下缓解了Shuffle过程产生文件过多的问题,但是还没有彻底解决7.1.2节中提出的两个问题,即同时打开多个文件 造成Writer Handler内存使用过大的问题和产生多个文件造成的随机读从而影响性能的问题。实际上,通过上面的分析,要解决Basic Shuffle Write的问题还 是比较困难。毕竟对于一个已经在很多生产环境中使用的平台,推倒重来要冒很大的风险;其次对于一个通用的平台,任何的性能测试都只是针对一部 分场景,谁也不能保证每个“优化”、“改进”都会给用户带来正收益等。 Spark采用了一个很聪明的做法,即建立另外一套Shuffle机制,让用户自己选择。Spark 1.0建立了Shuffle Pluggable的框架,通过实现一个接口来很容易地实 现第三方的external Shuffle Service。而Spark 1.1实现了Sort Based Shuffle;而Spark1.2里,Sort Based Shuffle取代Hash Based Shuffle成为Shuffle的默认选项。 接下来会解析Shuffle Pluggable框架和Sort Based Shuffle的实现原理。 7.2 Shuffle Pluggable框架 首先介绍一下需要实现的接口。框架的类图如图7-4所示。如果需要实现新的Shuffle机制,那么需要实现这些接口。 7.2.1 org.apache.spark.shuffle.ShuffleManager Driver和每个Executor都会持有一个ShuffleManager,这个ShuffleManager可以通过配置项spark.shuffle.manager指定,并且由SparkEnv创建。Driver中的 ShuffleManager负责注册Shuffle的元数据,比如shuffleId、Map Task的数量等。Executor中的ShuffleManager则负责读和写Shuffle的数据。 需要实现的函数及其功能说明如下: 1)由Driver注册元数据信息 def registerShuffle[K, V, C]( shuffleId: Int, numMaps: Int, dependency: ShuffleDependency[K, V, C]): ShuffleHandle 一般如果没有特殊的需求,可以使用下面的实现,实际上Hash Based Shuffle和Sort Based Shuffle都是这么实现的。 图7-4 Shuffle Pluggable框架类图 override def registerShuffle[K, V, C]( shuffleId: Int, numMaps: Int, dependency: ShuffleDependency[K, V, C]): ShuffleHandle = { new BaseShuffleHandle(shuffleId, numMaps, dependency) } 2)获得Shuffle Writer,根据Shuffle Map Task的ID为其创建Shuffle Writer。 def getWriter[K, V](handle: ShuffleHandle, mapId: Int, context: TaskContext): ShuffleWriter[K, V] 3)获得Shuffle Reader,根据shuffleId和Partition的ID为其创建Shuffle Reader。 def getReader[K, C]( handle: ShuffleHandle, startPartition: Int, endPartition: Int, context: TaskContext): ShuffleReader[K, C] 4)为数据成员shuffleBlockManager赋值,以保存实际的ShuffleBlockManager。 5)def unregisterShuffle(shuffleId:Int):Boolean,删除本地的Shuffle的元数据。 6)def stop():Unit,停止Shuffle Manager。 每个接口的具体实现的例子,可以参照org.apache.spark.shuffle.sort.SortShuffle-Manager和org.apache.spark.shuffle.hash.HashShuffleManager。 7.2.2 org.apache.spark.shuffle.ShuffleWriter Shuffle Map Task通过ShuffleWriter将Shuffle数据写入本地。这个Writer主要通过ShuffleBlockManager来写入数据,因此它的功能是比较轻量级的。 1)def write(records:Iterator[_<:Product2[K,V]]):Unit,写入所有的数据。需要注意的是如果需要在Map端做聚合,那么写入前需要将records做聚 合。 2)def stop(success:Boolean):Option[MapStatus],写入完成后提交本次写入。 对于Hash Based Shuffle,请查看org.apache.spark.shuffle.hash.HashShuffleWriter;对于Sort Based Shuffle,请查看org.apache.spark.shuffle.sort.SortShuffleWriter。 7.2.3 org.apache.spark.shuffle.ShuffleBlockManager 主要使用从本地读取Shuffle数据的功能。这些接口都是通过org.apache.spark.storage.BlockManager调用的。 1)def getBytes(blockId:ShuffleBlockId):Option[ByteBuffer],一般通过调用下一个接口实现,只不过将ManagedBuffer转换成了ByteBuffer。 2)def getBlockData(blockId:ShuffleBlockId):ManagedBuffer,核心读取逻辑。因为不同的实现,文件的组织方式可能是不一样的,比如Hash Based Shuffle从本地读取文件都是通过这个接口实现的,比如Sort Based Shuffle需要先通过读取Index索引文件获得每个Partition的起始位置后,才能读取真正的数 据文件。 3)def stop():Unit,停止该Manager。 对于Hash Based Shuffle,请查看org.apache.spark.shuffle.FileShuffleBlockManager;对于Sort Based Shuffle,请查看 org.apache.spark.shuffle.IndexShuffleBlockManager。 7.2.4 org.apache.spark.shuffle.ShuffleReader ShuffleReader实现了下游Task如何读取上游ShuffleMapTask的Shuffle输出的逻辑。这个逻辑比较复杂,简单来说就是通过org.apache.spark.MapOutputTracker 获得数据的位置信息,如果数据在本地则调用org.apache.spark.storage.BlockManager的getBlockData读取本地数据(实际上getBlockData最终会调用 org.apache.spark.shuffle.ShuffleBlockManager的getBlockData)。具体的Shuffle Read的逻辑请查看7.5节。 7.2.5 如何开发自己的Shuffle机制 通过Hash Based Shuffle和Sort Based Shuffle的源码,可以得出使用Spark Pluggable框架开发一个第三方的Shuffle Service是比较容易的;这个容易是指功能实 现方面。但是这个实现必须要考虑超大规模数据场景下的性能问题以及资源消耗问题。 7.3 Sort Based Write 在Spark 1.2.0中,Spark Core的一个重要的升级就是将默认的Hash Based Shuffle换成了Sort Based Shuffle,即spark.shuffle.manager从Hash换成了Sort,对应的 实现类分别是org.apache.spark.shuffle.hash.HashShuffleManager和org.apache.spark.shuffle.sort.SortShuffleManager。 这个方式的选择是在org.apache.spark.SparkEnv完成的: // Let the user specify short names for shuffle managers val shortShuffleMgrNames = Map( "hash" -> "org.apache.spark.shuffle.hash.HashShuffleManager", "sort" -> "org.apache.spark.shuffle.sort.SortShuffleManager") val shuffleMgrName = conf.get("spark.shuffle.manager", "sort") // 获得 Shuffle //Manager 的类型,默认为 sort val shuffleMgrClass = shortShuffleMgrNames.getOrElse(shuffleMgrName. toLowerCase, shuffleMgrName) val shuffleManager = instantiateClass[ShuffleManager](shuffleMgrClass) 那么Sort Based Shuffle“取代”Hash Based Shuffle作为默认选项的原因是什么? 正如前面提到的,Hash Based Shuffle的每个Mapper都需要为每个Reducer写一个文件,供Reducer读取,即需要产生M*R个数量的文件,如果Mapper和 Reducer的数量比较大,产生的文件数会非常多。Hash Based Shuffle设计的目标之一就是避免不需要的排序(Hadoop Map Reduce被人诟病的地方,很多不 需要Sort的地方的Sort导致了不必要的开销)。但是它在处理超大规模数据集的时候,产生了大量的Disk IO和内存的消耗,这无疑很影响性能。Hash Based Shuffle也在不断的优化中,正如前面讲到的Spark 0.8.1引入的File Consolidation在一定程度上解决了这个问题。为了更好地解决这个问题,Spark 1.1 引入了Sort Based Shuffle。首先,每个Shuffle Map Task不会为每个Reducer生成一个单独的文件;相反,它会将所有的结果写到一个文件里,同时会生成一 个Index文件,Reducer可以通过这个Index文件取得它需要处理的数据。避免产生大量文件的直接收益就是节省了内存的使用和顺序Disk IO带来的低延 时。节省内存的使用可以减少GC的风险和频率。而减少文件的数量可以避免同时写多个文件给系统带来的压力。 实现详解 Shuffle Map Task会按照key相对应的Partition ID进行Sort,其中属于同一个Partition的key不会Sort。因为对于不需要Sort的操作来说,这个Sort是负收益的; 要知道之前Spark刚开始使用Hash Based的Shuffle而不是Sort Based就是为了避免Hadoop Map Reduce对于所有计算都会Sort的性能损耗。对于那些需要Sort 的运算,比如sortByKey,这个Sort在Spark 1.2.0里还是由Reducer完成的。整个过程如图7-5所示。 图7-5 Sort Based Shuffle 如果这个过程内存不够用了,那么这些已经排序的内容会被写入到外部存储。然后在结束的时候将这些不同的文件进行归并排序。 为了便于下游的Task获取到其需要的Partition,这里会生成一个Index文件,去记录不同的Partition的位置信息。当然,org.apache.spark.storage.BlockManager 也实现了这种新的寻址方式。 核心实现的逻辑都在类org.apache.spark.shuffle.sort.SortShuffleWriter和它依赖的类中。下面简要分析它的实现: 1)对于每个Partition,创建一个scala.Array存储它所包含的key/value对。每个待处理的key/value对都会插入相应的scala.Array。 2)如果scala.Array的大小超过阈值,那么需要将这个内存的数据写入到外部存储。这个文件的开始部分会记录这个Partition的ID及这个文件保存了多少个 数据条目等信息。 3)最后需要将所有写入到外部存储的文件进行归并排序。同时打开的文件不能过多,过多会消耗大量的内存,增加内存溢出(Out of Memory,OOM) 或者垃圾回收的风险;也不能过少,过少就会影响性能,增大计算的延时。一般推荐每次同时打开10~100个文件。 4)在生成最后的数据文件时,需要同时生成Index索引文件。正如前面提到的,这个索引文件将记录不同Partition的起始位置。 当然,你可能还有这样的疑问,Hash Based Shuffle说白了就是根据key需要写入的org.apache.spark.HashPartitioner,为每个Reducer写入单独的Partition。只不 过对于同一个Core启动的Shuffle Map Task来说,如果选择spark.shuffle.consolidateFiles,第二个Shuffle Map Task会把结果追加到上一个文件中去。那么Sort的 逻辑完全可以整合到Hash Based Shuffle中去,为什么又要重新实现一种Shuffle Writer呢?我认为有以下几点原因: 1)Shuffle机制是所有类似计算模块的核心机制之一,要进行大规模的优化的风险非常高;比如一个看似简单的Consolidation机制在Spark 0.8.1就引入了, 但是Spark 1.2.0还是没有将其作为默认选项。 2)Hash Based Shuffle如果修改为Sort的逻辑,所谓的改进可能会影响原来已经稳定的Spark应用。比如一个应用在使用Hash Based Shuffle时性能是完全符合 预期的,那么迁移到Spark 1.2.0后,只需要将配置文件修改一下就可以完成这个无缝的迁移。 3)作为一个通用的计算平台,你的测试永远包含不了所有的场景。所以,还是留给用户去选择吧。 4)Sort机制还处于不断完善的阶段。因此,期待Sort在以后的版本中更加完善吧。 接下来从org.apache.spark.shuffle.sort.SortShuffleManager开始,介绍Sort based shuffle的具体实现。回忆一下在org.apache.spark.rdd.ShuffledRDD的计算,它会通 过SparkEnv的shuffleManger取得Reader: SparkEnv.get.shuffleManager.getReader(dep.shuffleHandle, split.index, split. index + 1, context).read().asInstanceOf[Iterator[(K, C)]] 如果是Sort Based Shuffle的话,那么这个Shuffle Manager就是org.apache.spark.shuffle.sort.SortShuffleManager。其Reader实际上就是Hash Based Shuffle的Reader。 7.4 Shuffle Map Task运算结果的处理 Shuffle Map Task运算结果的处理分为两部分,一个是在Executor端直接处理Task结果的;另一个是Driver端在接到Task运行结束的消息时对Shuffle Write的结 果进行处理,从而在调度下游的Task时,使其可以得到需要的数据。 7.4.1 Executor端的处理 在解析Basic Shuffle Writer时,我们知道Shuffle Map Task在Executor上运行时,最终会调用org.apache.spark.scheduler.ShuffleMapTask的runTask: override def runTask(context: TaskContext): MapStatus = { // 反序列化广播变量 taskBinary 得到 RDD val ser = SparkEnv.get.closureSerializer.newInstance() val (rdd, dep) = ser.deserialize[(RDD[_], ShuffleDependency[_, _, _])]( ByteBuffer.wrap(taskBinary.value), Thread.currentThread. getContextClassLoader) // 省略一些非核心代码 val manager = SparkEnv.get.shuffleManager // 获得 Shuffle Manager // 获得 Shuffle Writer writer = manager.getWriter[Any, Any](dep.shuffleHandle, partitionId, context) // 首先调用 rdd .iterator ,如果该 RDD 已经缓存过或者生成检查点了,那么直接读取结果,否 // 则开始计算,计算的结果将调用 Shuffle Writer 写入本地文件系统 writer.write(rdd.iterator(partition, context).asInstanceOf[Iterator[_ <: Product2[Any, Any]]]) // 返回数据的元数据信息,包括 location 和 size return writer.stop(success = true).get 那么,这个结果最终是如何处理的呢?特别是下游的Task是如何获取这些Shuffle的数据呢?还要从Task是如何开始执行讲起。在Worker上接收Task执行命 令的是org.apache.spark.executor.CoarseGrainedExecutorBackend。它在接收到Launch-Task的命令后,通过在Driver创建SparkContext时已经创建的 org.apache.spark.executor.Executor的实例的launchTask,启动Task: def launchTask( context: ExecutorBackend, taskId: Long, taskName: String,serializedTask: ByteBuffer) { val tr = new TaskRunner(context, taskId, taskName, serializedTask) runningTasks.put(taskId, tr) threadPool.execute(tr) // 开始在 Executor 中运行 } 最终Task的执行是在org.apache.spark.executor.Executor.TaskRunner#run。 在Executor运行Task时,得到的计算结果会存入org.apache.spark.scheduler.Direct-TaskResult。 // 开始执行 Task ,最终得到的是 org.apache.spark.scheduler.ShuffleMapTask#runTask // 返回的 org.apache.spark.scheduler.MapStatus val value = task.run(taskId.toInt) val resultSer = env.serializer.newInstance() // 获得序列化工具 val valueBytes = resultSer.serialize(value) // 序列化结果 // 首先将结果直接放入 org.apache.spark.scheduler.DirectTaskResult val directResult = new DirectTaskResult(valueBytes, accumUpdates, task. metrics.orNull) val ser = env.closureSerializer.newInstance() val serializedDirectResult = ser.serialize(directResult) // 序列化结果 val resultSize = serializedDirectResult.limit // 序列化结果的大小 在将结果回传到Driver时,会根据结果的大小使用不同的策略(本部分内容在第6章的第2节也有涉及,但是如果缺失会影响不阅读第6章的读者,因此这 里加以保留,已经阅读过第6章的读者可以跳过): 1)如果结果大于1GB,那么直接丢弃这个结果。这个是Spark 1.2中新加入的策略。可以通过spark.driver.maxResultSize来进行设置。 2)对于“较大”的结果,将其以taskId为key存入org.apache.spark.storage.Block-Manager;如果结果不大,则直接回传给Driver。那么如何判定这个阈值呢?这 里的回传是直接通过AKKA的消息传递机制。因此结果首先不能超过该机制设置的消息的最大值。这个最大值是通过spark.akka.frameSize设置的,单位是 MByte,默认值是10MB。除此之外,还有200KB的预留空间。因此这个阈值就是conf.getInt("spark.akka.frameSize",10)*1024*024-200*1024。 3)其他情况则直接通过AKKA回传到Driver。 实现源码解析如下: val serializedResult = { if (maxResultSize > 0 && resultSize > maxResultSize) { // 如果结果的大小大于 1GB ,那么直接丢弃,可以在 spark.driver.maxResultSize 设置 ser.serialize(new IndirectTaskResult[Any](TaskResultBlockId(taskId),resultSize)) } else if (resultSize >= akkaFrameSize - AkkaUtils.reservedSizeBytes) { // 如果不能通过 AKKA 的消息传递,那么放入 BlockManager 等待调用者以网络的形式来获取。 // AKKA 的消息的默认大小可以通过 spark.akka.frameSize 来设置,默认值是 10MB val blockId = TaskResultBlockId(taskId) env.blockManager.putBytes( blockId, serializedDirectResult, StorageLevel.MEMORY_AND_DISK_SER) ser.serialize(new IndirectTaskResult[Any](blockId, resultSize)) } else { // 结果可以直接回传到 Driver serializedDirectResult } } // 通过 AKKA 向 Driver 汇报本次 Task 的已经完成 execBackend.statusUpdate(taskId, TaskState.FINISHED, serializedResult) 而execBackend是org.apache.spark.executor.ExecutorBackend的一个实例,它实际上是Executor与Driver通信的接口: private[spark] trait ExecutorBackend { def statusUpdate(taskId: Long, state: TaskState, data: ByteBuffer) } TaskRunner会将Task执行的状态汇报给Driver(org.apache.spark.scheduler.cluster.CoarseGrainedSchedulerBackend.DriverActor)。而Driver会转给 org.apache.spark.scheduler.TaskSchedulerImpl#statusUpdate。 7.4.2 Driver端的处理 TaskRunner将Task的执行状态汇报给Driver后,Driver会转给org.apache.spark.scheduler.TaskSchedulerImpl#statusUpdate。在这里,不同的状态有不同的处理 (本部分内容在6.2.4节也有涉及,但为了不影响不阅读第6章的读者,因此这里的内容加以保留,已经阅读过第6章的读者可以跳过): 如果类型是TaskState.FINISHED,那么调用org.apache.spark.scheduler.TaskResultGetter#enqueueSuccessfulTask进行处理。 如果类型是TaskState.FAILED或者TaskState.KILLED或者TaskState.LOST,调用org.apache.spark.scheduler.TaskResultGetter#enqueueFailedTask进行处理。对于 TaskState.LOST,还需要将其所在的Executor标记为failed,并且根据更新后的Executor重新调度。 enqueueSuccessfulTask的逻辑也比较简单,即如果是IndirectTaskResult,那么需要通过blockId来获取结果: sparkEnv.blockManager.getRemoteBytes(blockId);如果是DirectTaskResult,那么结果就无需远程获取了。然后调用以下调用栈进行处理。核心逻辑是第5 个调用栈。 1)org.apache.spark.scheduler.TaskSchedulerImpl#handleSuccessfulTask 2)org.apache.spark.scheduler.TaskSetManager#handleSuccessfulTask 3)org.apache.spark.scheduler.DAGScheduler#taskEnded 4)org.apache.spark.scheduler.DAGScheduler#eventProcessActor 5)org.apache.spark.scheduler.DAGScheduler#handleTaskCompletion 如果task是ShuffleMapTask,那么它需要将结果通过某种机制告诉下游的Stage,以便于其可以作为下游Stage的输入。这个机制是怎么实现的? 实际上,对于ShuffleMapTask来说,其结果实际上是org.apache.spark.scheduler.MapStatus;其序列化后存入了DirectTaskResult或者IndirectTaskResult中。而 DAGScheduler#handleTaskCompletion通过下面的方式来获取这个结果: val status =event.result.asInstanceOf[MapStatus] 通过将这个status注册到org.apache.spark.MapOutputTrackerMaster,就完成了结果处理的过程: mapOutputTracker.registerMapOutputs( stage.shuffleDep.get.shuffleId, stage.outputLocs.map(list => if (list.isEmpty) null else list.head).toArray, changeEpoch = true) 而registerMapOutputs的处理也很简单,以shuffleID为key将MapStatus的列表存入带有时间戳的HashMap:TimeStampedHashMap[Int,Array[MapStatus]]()。 如果设置了cleanup的函数,那么这个HashMap会将超过一定时间(TTL,Time to Live)的数据清理掉。 7.5 Shuffle Read 回忆一下,每个Stage的上边界,要么需要从外部存储读取数据,要么需要读取上一个Stage的输出;而下边界,要么是需要写入本地文件系统(需要 Shuffle),以供child Stage读取,要么是最后一个Stage,需要输出结果。这里的Stage,在运行时就是可以以流水线的方式运行的一组Task,除了最后一个 Stage对应的是ResultTask,其余的Stage对应的都是Shuffle Map Task。 而除了需要从外部存储读取数据和RDD已经做过cache或者checkpoint的Task,一般Task都是从ShuffledRDD的Shuffle Read开始的。本节将详细讲解Shuffle Read的过程。 7.5.1 整体流程 Shuffle Read的整体架构如图7-6所示。org.apache.spark.rdd.ShuffledRDD#compute开始,通过调用org.apache.spark.shuffle.ShuffleManager的getReader方法,获取 到org.apache.spark.shuffle.ShuffleReader,然后调用其read()方法进行读取。在Spark 1.2.0中,不管是Hash Based Shuffle或者是Sort Based Shuffle,内置的 Shuffle Reader都是org.apache.spark.shuffle.hash.HashShuffleReader。核心实现: 图7-6 Shuffle Read的实现 override def read(): Iterator[Product2[K, C]] = { val ser = Serializer.getSerializer(dep.serializer) // 获取结果 val iter = BlockStoreShuffleFetcher.fetch(handle.shuffleId, start-Partition,context,ser) // 处理结果 val aggregatedIter: Iterator[Product2[K, C]] = if (dep.aggregator. isDefined) {// 需要聚合 if (dep.mapSideCombine) {// 需要 mapSide 的聚合 new InterruptibleIterator(context, dep.aggregator.get.combineCombiners- ByKey(iter, context)) } else {// 只需要 Reducer 端的聚合 new InterruptibleIterator(context, dep.aggregator.get.combineValues- ByKey(iter, context)) } } else { // 无需聚合操作 iter.asInstanceOf[Iterator[Product2[K, C]]].map(pair => (pair._1, pair._2)) } // Sort the output if there is a sort ordering defined. dep.keyOrdering match {// 判断是否需要排序 case Some(keyOrd: Ordering[K]) => // 对于需要排序的情况 // 使用 ExternalSorter 进行排序,注意如果 spark.shuffle.spill 是 false ,那么数 // 据是不会写入到硬盘的 val sorter = new ExternalSorter[K, C, C](ordering = Some(keyOrd), serializer = Some(ser)) sorter.insertAll(aggregatedIter) context.taskMetrics.memoryBytesSpilled += sorter.memoryBytesSpilled context.taskMetrics.diskBytesSpilled += sorter.diskBytesSpilled sorter.iterator case None => // 无需排序 aggregatedIter } } org.apache.spark.shuffle.hash.BlockStoreShuffleFetcher#fetch会获得数据,它首先会通过org.apache.spark.MapOutputTracker#getServerStatuses来获得数据的meta信 息,这个过程有可能需要向org.apache.spark.MapOutputTrackerMasterActor发送读请求,这个读请求是在org.apache.spark.MapOutputTracker#askTracker发出 的。在获得了数据的meta信息后,它会将这些数据存入Seq[(BlockManagerId,Seq[(BlockId,Long)])]中,然后调用 org.apache.spark.storage.ShuffleBlockFetcherIterator最终发起请求。ShuffleBlockFetcherIterator根据数据的本地性原则进行数据获取。如果数据在本地,那么会 调用org.apache.spark.storage.BlockManager#getBlockData进行本地数据块的读取。而对于shuffle类型的数据,会调用ShuffleManager的ShuffleBlockManager的 getBlockData。 如果数据在其他的Executor上,若用户使用的spark.shuffle.blockTransferService是netty,那么就会通过 org.apache.spark.network.netty.NettyBlockTransferService#fetchBlocks获取;如果使用的是nio,那么就会通过 org.apache.spark.network.nio.NioBlockTransferService#fetchBlocks获取。(注:netty和nio是两种远程读取方式,详细内容见7.5.4节。) 7.5.2 数据读取策略的划分 org.apache.spark.storage.ShuffleBlockFetcherIterator会通过splitLocalRemoteBlocks划分数据的读取策略:如果数据在本地,那么可以直接从BlockManager中获 取;如果需要从其他的节点上获取,则需要通过网络。由于Shuffle的数据量可能会很大,因此这里的网络读取分为以下几种策略: 1)每次最多启动5个线程到最多5个节点上读取数据。 2)每次请求的数据大小不会超过spark.reducer.maxMbInFlight(默认值为48MB)的五分之一。这样做的原因有以下几点: 1)避免占用目标机器过多带宽,在千兆网卡为主流的今天,带宽还是比较重要的。如果机器使用的是万兆网卡,那么可以通过设置 spark.reducer.maxMbInFlight来充分利用带宽。 2)请求数据可以平行化,这样请求数据的时间可以大大减少。请求数据的总时间就是请求中耗时最长的。这样可以缓解一个节点出现网络拥塞时的影 响。 主要实现过程如下: private[this] def splitLocalRemoteBlocks(): ArrayBuffer[FetchRequest] = { val targetRequestSize = math.max(maxBytesInFlight / 5, 1L) val remoteRequests = new ArrayBuffer[FetchRequest] for ((address, blockInfos) <- blocksByAddress) { if (address.executorId == blockManager.blockManagerId.executorId) { // Block 在本地,需要过滤大小为 0 的 Block localBlocks ++= blockInfos.filter(_._2 != 0).map(_._1) numBlocksToFetch += localBlocks.size } else { // 需要远程获取的 Block val iterator = blockInfos.iterator var curRequestSize = 0L var curBlocks = new ArrayBuffer[(BlockId, Long)] while (iterator.hasNext) { // blockId 是 org.apache.spark.storage.ShuffleBlockId ,格式: // "shuffle_" + shuffleId + "_" + mapId + "_" + reduceId val (blockId, size) = iterator.next() // 跳过空的 Block if (size > 0) { curBlocks += ((blockId, size)) remoteBlocks += blockId numBlocksToFetch += 1 curRequestSize += size } if (curRequestSize >= targetRequestSize) { // 当前总的 Size 已经可以批量放入一次 Request 中 remoteRequests += new FetchRequest(address, curBlocks) curBlocks = new ArrayBuffer[(BlockId, Long)] curRequestSize = 0 } } // 剩余的请求组成一次 Request if (curBlocks.nonEmpty) { remoteRequests += new FetchRequest(address, curBlocks) } } } remoteRequests } 7.5.3 本地读取 fetchLocalBlocks()负责本地Block的获取。在splitLocalRemoteBlocks中,已经将本地的Block列表存入了localBlocks:private[this]val localBlocks=new Array- Buffer[BlockId]()。具体过程如下: val iter = localBlocks.iterator while (iter.hasNext) { val blockId = iter.next() try { val buf = blockManager.getBlockData(blockId) shuffleMetrics.localBlocksFetched += 1 buf.retain() results.put(new SuccessFetchResult(blockId, 0, buf)) } catch { } } 而blockManager.getBlockData(blockId)的实现是: override def getBlockData(blockId: BlockId): ManagedBuffer = { if (blockId.isShuffle) { shuffleManager.shuffleBlockManager.getBlockData(blockId.asInstance- Of[ShuffleBlockId]) } 这就调用了ShuffleBlockManager的getBlockData方法。在Shuffle Pluggable框架中我们介绍了实现一个Shuffle Service之一就是要实现ShuffleBlockManager。 以Hash Based Shuffle为例,它的ShuffleBlockManager是org.apache.spark.shuffle.FileShuffleBlockManager。FileShuffleBlockManager有两种情况,一种是 consolidateFile的,这种需要根据Map ID和Reduce ID首先获得FileGroup的一个文件,然后根据在文件中的offset和size来获取需要的数据;如果是没有 consolidateFile,那么根据Shuffle Block ID直接读取整个文件就可以。 override def getBlockData(blockId: ShuffleBlockId): ManagedBuffer = { if (consolidateShuffleFiles) { val shuffleState = shuffleStates(blockId.shuffleId) val iter = shuffleState.allFileGroups.iterator while (iter.hasNext) { // 根据 Map ID 和 Reduce ID 获取 File Segment 的信息 val segmentOpt = iter.next.getFileSegmentFor(blockId.mapId, blockId.reduceId) if (segmentOpt.isDefined) { val segment = segmentOpt.get // 根据 File Segment 的信息,从 FileGroup 中找到相应的 File 和 Block 在文件中 // 的 offset 和 size return new FileSegmentManagedBuffer( transportConf, segment.file, segment.offset, segment.length) } } throw new IllegalStateException("Failed to find shuffle block: " + blockId) } else { val file = blockManager.diskBlockManager.getFile(blockId) // 直接获取文 // 件句柄 new FileSegmentManagedBuffer(transportConf, file, 0, file.length) } } 对于Sort Based Shuffle,它需要通过索引文件来获得数据块在数据文件中的具体位置信息,从而读取这个数据。 具体实现在org.apache.spark.shuffle.IndexShuffleBlockManager#getBlockData中。 override def getBlockData(blockId: ShuffleBlockId): ManagedBuffer = { // 根据 ShuffleID 和 MapID 从 org.apache.spark.storage.DiskBlockManager 获取索引文件 val indexFile = getIndexFile(blockId.shuffleId, blockId.mapId) val in = new DataInputStream(new FileInputStream(indexFile)) try { ByteStreams.skipFully(in, blockId.reduceId * 8) // 跳到本次 Block 的数据区 val offset = in.readLong() // 数据文件中的开始位置 val nextOffset = in.readLong() // 数据文件中的结束位置 new FileSegmentManagedBuffer( transportConf, getDataFile(blockId.shuffleId, blockId.mapId), offset, nextOffset - offset) } finally { in.close() } } 7.5.4 远程读取 现在支持两种远程读取的方式,一种是netty,一种是nio,可以通过spark.shuffle.blockTransferService来进行设置。 org.apache.spark.storage.ShuffleBlockFetcherIterator#sendRequest会向远程的节点发起读取Block的请求: shuffleClient.fetchBlocks(address.host,address.port,address.executorId, blockIds.toArray, new BlockFetchingListener { override def onBlockFetchSuccess(blockId: String,buf: Managed- Buffer): Unit = { // 请求成功,省略非关键代码 buf.retain() results.put(new SuccessFetchResult(BlockId(blockId), sizeMap (blockId), buf)) } override def onBlockFetchFailure(blockId: String, e: Throwable): Unit = { results.put(new FailureFetchResult(BlockId(blockId), e)) } } shuffleClient实际上在默认情况下(即spark.shuffle.service.enabled为false)就是blockTransferService: private[spark] val shuffleClient = if (externalShuffleServiceEnabled) { val transConf = SparkTransportConf.fromSparkConf(conf, numUsableCores) new ExternalShuffleClient(transConf, securityManager, securityManager. isAuthenticationEnabled()) } else { blockTransferService } blockTransferService 是在 SparkEnv 里创建的: val blockTransferService = conf.get("spark.shuffle.blockTransferService", "netty").toLowerCase match { case "netty" => new NettyBlockTransferService(conf, securityManager, numUsableCores) case "nio" => new NioBlockTransferService(conf, securityManager) } 接下来回头看一下org.apache.spark.network.netty.NettyBlockTransferService的fetchBlocks的实现:它会调用 org.apache.spark.network.shuffle.OneForOneBlockFetcher,OneForOneBlockFetcher持有org.apache.spark.network.client.TransportClient,它就是最终发送请求的 Handler。TransportClient的请求会被org.apache.spark.network.netty.NettyBlockRpcServer接收并处理,通过上述的网络调用,请求最终会传到远程节点的 BlockManager:由org.apache.spark.storage.BlockManager#getBlockData处理这个读取Block的请求。具体实现过程如图7-7所示。接下来的调用栈就和本地读 取一致了。 图7-7 Shuffle Read的详细实现 7.6 性能调优 通过上面的架构和源码实现的分析,不难得出Shuffle是Spark Core比较复杂的模块的结论。它也是非常影响性能的操作之一。因此,在这里整理了会影响 Shuffle性能的各项配置。尽管在前文已经解释过它大部分配置项的含义,但是由于这些参数的确非常重要,这里算是做一个详细的总结。 7.6.1 spark.shuffle.manager Spark 1.2.0官方版本支持两种方式的Shuffle,即Hash Based Shuffle和Sort Based Shuffle。其中在Spark 1.0之前仅支持Hash Based Shuffle。Spark 1.1引入了Sort Based Shuffle。Spark 1.2的默认Shuffle机制从Hash变成了Sort。如果需要Hash Based Shuffle,只需将spark.shuffle.manager设置成“hash”即可。 如果对性能有比较苛刻的要求,那么就要理解这两种不同的Shuffle机制的原理,结合具体的应用场景进行选择。 Hash Based Shuffle,就是根据Hash的结果,将各个Reducer Partition的数据写到单独的文件中去,写数据时不会有排序的操作。如果Reducer的Partition比较 多,会产生大量的磁盘文件。这会带来两个问题: 1)同时打开的文件比较多,那么大量的文件句柄和写操作分配的临时内存会非常大,会对内存的使用和GC带来很多压力。尤其是在Spark的YARN模式 下Executor分配的内存普遍比较小的时候,这个问题会更严重。 2)从整体来看,这些文件带来大量的随机读,读性能可能会遇到瓶颈。更加细节可以参见7.1节和7.6.6节。 Sort Based Shuffle会根据实际情况对数据采用不同的方式进行Sort。这个排序可能仅仅是按照Reducer的Partition进行排序,保证同一个Shuffle Map Task对应 的不同的Reducer Partition的数据都可以写到同一个数据文件,通过一个offset来标记不同Reducer Partition的分界。因此一个Shuffle Map Task仅仅会生成一个 数据文件(还有一个Index索引文件),从而避免了Hash Based Shuffle文件数量过多的问题。 选择Hash还是Sort,取决于内存、排序和文件操作等因素的综合影响。 对于不需要进行排序且Shuffle产生的文件数量不是特别多时,Hash Based Shuffle可能是更好的选择;因为Sort Based Shuffle会按照Reducer的Partition进行排 序。 而Sort Based Shuffle的优势就在于可扩展性,它的出现实际上很大程度上是解决Hash Based Shuffle的可扩展性的问题。由于Sort Based Shuffle还在不断地演 进中,因此它的性能会得到不断改善。 对于选择哪种Shuffle,如果性能要求苛刻,最好还是通过实际测试后再做决定。不过选择默认的Sort,可以满足大部分的场景需要。 7.6.2 spark.shuffle.spill 这个参数的默认值是true,用于指定Shuffle过程中如果内存中的数据超过阈值(参考spark.shuffle.memoryFraction的设置)时是否需要将部分数据临时写入外 部存储。如果设置为false,那么这个过程就会一直使用内存,会有内存溢出的风险。因此只有在确定内存足够使用时,才可以将这个选项设置为false。 Hash Based Shuffle的Shuffle Write过程中使用的org.apache.spark.util.collection.AppendOnlyMap就是全内存的方式,而 org.apache.spark.util.collection.ExternalAppend-OnlyMap对org.apache.spark.util.collection.AppendOnlyMap有了进一步的封装,在内存使用超过阈值时会将它写入 到外部存储,在最后的时候会对这些临时文件进行合并。 而Sort Based Shuffle Write使用到的org.apache.spark.util.collection.ExternalSorter也会有类似的写入。 对于Shuffle Read,如果需要做聚合,也可能在聚合的过程中将数据写入的外部存储。 7.6.3 spark.shuffle.memoryFraction和spark.shuffle.safetyFraction 在启用spark.shuffle.spill的情况下,spark.shuffle.memoryFraction决定了当Shuffle过程中使用的内存达到总内存多少比例的时候开始spill。在Spark 1.2.0里,这个 值是0.2。通过这个参数可以设置Shuffle过程占用内存的大小,它直接影响了写入到外部存储的频率和垃圾回收的频率。 如果写入到外部存储的频率太高,那么可以适当地增加spark.shuffle.memoryFraction来增加Shuffle过程的可用内存数,进而减少写入到外部存储的频率。当 然为了避免内存溢出,可能就需要减少RDD cache所用的内存,即需要减少spark.storage.memoryFraction的值;但是减少RDD cache所用的内存有可能会带 来其他影响,因此需要综合考量。 在Shuffle过程中,Shuffle占用的内存数是估计出来的,并不是每次新增的数据项都会计算一次占用的内存大小,这样做是为了降低时间开销。但是估计也 会有误差,因此存在实际使用的内存数比估算值要大的情况,因此参数spark.shuffle.safetyFraction作为一个保险系数降低实际Shuffle过程所需要的内存值, 可以降低实际内存超出用户配置值的风险。 7.6.4 spark.shuffle.sort.bypassMergeThreshold 这个配置的默认值是200,用于设置在Reducer的Partition数目少于多少的时候,Sort Based Shuffle内部不使用归并排序的方式处理数据,而是直接将每个 Partition写入单独的文件。这个方式和Hash Based的方式类似,区别就是在最后这些文件还是会合并成一个单独的文件,并通过一个Index索引文件来标记 不同Partition的位置信息。从Reducer来看,数据文件和索引文件的格式和内部是否做过归并排序是完全相同的。 这个可以看作Sort Based Shuffle在Shuffle量比较小的时候对于Hash Based Shuffle的一种折中。当然了它和Hash Based Shuffle一样,也存在同时打开文件过多 导致内存占用增加的问题。因此如果GC比较严重或者内存比较紧张,可以适当降低这个值。 7.6.5 spark.shuffle.blockTransferService 在Spark 1.2.0中这个配置的默认值是netty,而在之前的版本中是nio。它主要是用于在各个Executor之间传输Shuffle数据。netty的实现更加简洁,但实际上 用户不用太关心这个选项。除非有特殊需求,否则采用默认配置即可。 7.6.6 spark.shuffle.consolidateFiles 这个配置的默认值是false。主要是为了解决在Hash Based Shuffle过程中产生过多文件的问题。如果配置选项为true,那么对于同一个Core上运行的Shuffle Map Task不会产生一个新的Shuffle文件而是重用原来的。但是每个Shuffle Map Task还是需要产生下游Task数量的文件,因此它并没有减少同时打开文件的 数量。如果需要了解更多细节,可以阅读7.1节。 但是consolidateFiles的机制在Spark 0.8.1就引入了,到Spark 1.2.0还是没有稳定下来。从源码实现的角度看,实现源码是非常简单的,但是由于涉及本地文 件系统等限制,这个策略可能会带来各种各样的问题。由于它并没有减少同时打开文件的数量,因此不能减少由文件句柄带来的内存消耗。如果Shuffle 的文件数量非常大,那么是否打开这个选项最好还是通过实际测试后再决定。 7.6.7 spark.shuffle.compress和spark.shuffle.spill.compress 这两个参数的默认配置都是true。spark.shuffle.compress和spark.shuffle.spill.compress都是用来设置Shuffle过程中是否对Shuffle数据进行压缩。其中,前者针对 最终写入本地文件系统的输出文件;后者针对在处理过程需要写入到外部存储的中间数据,即针对最终的shuffle输出文件。 1.设置spark.shuffle.compress 如果下游的Task通过网络获取上游Shuffle Map Task的结果的网络IO成为瓶颈,那么就需要考虑将它设置为true:通过压缩数据来减少网络IO。由于上游 Shuffle Map Task和下游的Task现阶段是不会并行处理的,即上游Shuffle Map Task处理完成后,下游的Task才会开始执行。那么需要压缩的时间消耗就是 Shuffle Map Task压缩数据的时间+网络传输的时间+下游Task解压的时间;而不需要压缩的时间消耗仅仅是网络传输的时间。因此需要评估压缩解压时间 带来的时间消耗和因为数据压缩带来的时间节省。如果网络成为瓶颈,比如集群普遍使用的是千兆网络,那么将这个选项设置为true可能更合理;如果 计算是CPU密集型的,那么将这个选项设置为false可能更好。 2.设置spark.shuffle.spill.compress 如果设置为true,代表处理的中间结果在spill到本地硬盘时都会进行压缩,在将中间结果取回进行merge的时候,要进行解压。因此要综合考虑CPU由于引 入压缩、解压的消耗时间和Disk IO因为压缩带来的节省时间的比较。在Disk IO成为瓶颈的场景下,设置为true可能比较合适;如果本地硬盘是SSD,那 么设置为false可能比较合适。 7.6.8 spark.reducer.maxMbInFlight 这个参数用于限制一个Reducer Task向其他的Executor请求Shuffle数据时所占用的最大内存数,尤其是如果网卡是千兆和千兆以下的网卡时。默认值是 48MB。设置这个值需要综合考虑网卡带宽和内存。 7.7 小结 在Spark 0.6和0.7时,Shuffle的结果都需要先存储到内存中(有可能要写入磁盘),因此在大数据量的情况下,发生GC和OOM的概率非常大。因此在 Spark 0.8的时候,Shuffle的每个结果都会直接写入磁盘,并且为下游的每个Task都生成一个单独的文件。这样解决了Shuffle的结果都需要存入内存的问 题,但是又引入了另外一个问题:生成的小文件过多,尤其在每个文件的数据量不大而文件特别多的时候,大量的随机读会严重影响性能。Spark 0.8.1 为了解决0.8中引入的问题,引入了File Consolidation机制,这在一定程度上解决了这个问题。由此可见,Hash Based Shuffle在可扩展性方面的确有局限 性。而Spark1.0中引入的Shuffle Pluggable框架,为加入新的Shuffle机制和引入第三方的Shuffle机制奠定了基础。在Spark1.1的时候,引入了Sort Based Shuffle;并且在Spark1.2.0时,Sort Based Shuffle成为Shuffle的默认选项。但是,随着内存成本的不断下降和容量的不断上升,Spark Core会在未来重新将 Shuffle过程在内存中进行吗?我认为这个不太可能也没太大必要,如果用户对于性能有比较苛刻的要求而Shuffle的过程的确是性能优化的重点时,可以尝 试以下实现方式: 1)Worker的节点采用固态硬盘。 2)Woker的Shuffle结果保存到RAM Disk上。 3)根据自己的应用场景,实现自己的Shuffle机制。 我们知道只有Parent Stage的所有Task都完成了,下游的Reducer Task才能开始执行。这种实现逻辑比较简单,但是会影响性能。如果Reducer Task可以提 早启动而不是等待所有的上游Shuffle Map Task都执行完成,是不是性能会更好,集群的利用率会更高?更进一步,现在都是采用Reducer Task从其他的节 点上拉取数据(pull data),如果采用推送数据(push data)到Reducer Task呢? 第8章 Storage模块详解 Storage模块负责管理Spark计算过程中产生的数据,包括基于Disk的和基于Memory的。用户在实际编程中,面对的是RDD,可以将RDD的数据通过调用 org.apache.spark.rdd.RDD#cache将数据持久化;持久化的动作都是由Storage模块完成的,包括Shuffle过程中的数据,也都是由Storage模块管理的。可以 说,RDD实现用户的逻辑,而Storage管理用户的数据。在Driver端和Executor端,都会有Storage模块,那么它们功能的共同点和不同点是什么?本章将讲 解Storage模块的实现。 8.1 模块整体架构 8.1.1 整体架构 Storage模块采用的是Master/Slave的架构。Master负责整个Application的Block的元数据信息的管理和维护;而Slave需要将Block的更新等状态上报到Master, 同时接收Master的命令,比如删除一个RDD、Shuffle相关的数据或者是广播变量。而Master与Slave之间通过AKKA消息传递机制进行通信。 在SparkContext创建时,它会创建Driver端的SparkEnv,而SparkEnv会创建BlockManager,BlockManager创建的时候会持有一个BlockManagerMaster。 BlockManagerMaster会把请求转发给BlockManagerMasterActor来完成元数据的管理和维护。 而在Executor端,也存在一个BlockManager,它也会持有一个BlockManager-Master,只不过BlockManagerMaster会持有一个Driver端BlockManagerMasterActor 的Reference,因此Executor端的BlockManager就能通过这个Actor的Reference将Block的信息上报给Master。 BlockManager本身还持有一个BlockManagerSlaveActor,而这个Slave的Actor还会被上报到Master。Master会持有这个Slave Actor的Reference,并通过这个 Reference向Salve发送一些命令,比如删除Slave上的RDD、Shuffle相关的数据或者是广播变量。上面说的这些关系可以通过图8-1来表示。 图8-1并没有指出Master和Slave之间的心跳是如何维持的,即Master如何知道Slave是否已经下线呢?实际上,Master和Slave之间并没有专门的心跳,而是 通过Driver和Executor之间的心跳来间接完成的。 Master持有整个Application的Block的元数据信息,包括Block所在的位置,Block所占的存储空间大小(含三种类别:内存、Disk和Tachyon)。这些信息都 保存在org.apache.spark.storage.BlockManagerMasterActor的三个数据结构中: 1)private val blockManagerInfo=new mutable.HashMap[BlockManagerId,BlockManagerInfo]:保存了BlockManagerId到BlockManagerInfo的映射。 BlockManagerInfo保存了Slave节点的内存使用情况、Slave上的Block的状态、Slave上的BlockManagerSlaveActor的Reference(Master通过这个Reference可以向 Slave发送命令和查询请求)。 2)private val blockManagerIdByExecutor=new mutable.HashMap[String,BlockManagerId]:保存了Executor ID到BlockManagerId的映射。这样Master就可以通过 Executor ID快速查找到BlockManagerId。 图8-1 Storage模块架构图 3)private val blockLocations=new JHashMap[BlockId,mutable.HashSet[BlockManagerId]]:保存Block是在哪些BlockManager上的Hash Map;由于Block可能在多 个Slave上都有备份,因此注意Value是一个mutable.HashSet。通过查询blockLocations就可以找到某个Block所在的物理位置了。 那么BlockManagerMasterActor是如何维护和管理这些数据结构,进而实现管理整个Application的Block呢?实现过程如图8-2所示。 图8-2 BlockManager的注册实现逻辑图 BlockManger在创建后,需要向BlockManagerMasterActor发送消息RegisterBlock-Manager进行注册,具体实现代码在 org.apache.spark.storage.BlockManager#initialize: master.registerBlockManager(blockManagerId, maxMemory, slaveActor) 注册的信息包括blockManagerId(标识了Slave的Executor ID、Hostname和Port),节点的最大可用内存数,BlockManagerSlaveActor。 BlockManagerMasterActor接到注册请求后,会将Slave的信息保存到Master端: blockManagerIdByExecutor(id.executorId) = id blockManagerInfo(id) = new BlockManagerInfo( id, System.currentTimeMillis(), maxMemSize, slaveActor) 8.1.2 源码组织结构 org.apache.spark.storage.BlockManager是Storage模块与其他模块交互最主要的类,它提供了读和写Block的接口。这里的Block,实际上就对应了RDD中提到 的Partition,每一个Partition都会对应一个Block。每个Block由唯一的Block ID(org.apache.spark.storage.RDDBlockId)标识,格式 是"rdd_"+rddId+"_"+partitionId,如图8-3所示。 图8-3 RDD的Partition和Block的逻辑关系图 BlockManager会运行在Driver和每个Executor上。而运行在Driver上的BlockManger负责整个Application的Block的管理工作;运行在Executor上的BlockManger负 责管理该Executor上的Block,并且向Driver的BlockManager汇报Block的信息和接收来自它的命令。 模块的类图如图8-4所示。各个主要类的功能说明如下: 1)org.apache.spark.storage.BlockManager:提供了Storage模块与其他模块的交互接口,管理Storage模块。 2)org.apache.spark.storage.BlockManagerMaster:Block管理的接口类,主要通过调用org.apache.spark.storage.BlockManagerMasterActor来完成。 3)org.apache.spark.storage.BlockManagerMasterActor:在Driver节点上的Actor,负责track所有Slave节点的Block的信息。 4)org.apache.spark.storage.BlockManagerSlaveActor:运行在所有的节点上,接收来自org.apache.spark.storage.BlockManagerMasterActor的命令,比如删除某 个RDD的数据、删除某个Block、删除某个Shuffle数据、返回某些Block的状态等。 图8-4 Storage模块类图 5)org.apache.spark.storage.BlockObjectWriter:一个抽象类,可以将任何的JVM Object写入外部存储系统。注意,它不支持并发的写操作。 6)org.apache.spark.storage.DiskBlockObjectWriter:支持直接写入一个文件到Disk,并且还支持文件的追加。实际上它是 org.apache.spark.storage.BlockObjectWriter的一个实现。下面的类在需要写入数据到Disk时,就是通过它来完成的: a)org.apache.spark.util.collection.ExternalSorter b)org.apache.spark.shuffle.FileShuffleBlockManager 7)org.apache.spark.storage.DiskBlockManager:管理和维护了逻辑上的Block和存储在Disk上的物理的Block的映射。一般来说,一个逻辑的Block会根据它的 BlockId生成的名字映射到一个物理上的文件。这些物理文件会被hash到由spark.local.dir(或者通过SPARK_LOCAL_DIRS)设置的目录中。 8)org.apache.spark.storage.BlockStore:存储Block的抽象类。现在它的实现有: a)org.apache.spark.storage.DiskStore b)org.apache.spark.storage.MemoryStore c)org.apache.spark.storage.TachyonStore 9)org.apache.spark.storage.DiskStore:实现了存储Block到Disk上。其中写Disk是通过org.apache.spark.storage.DiskBlockObjectWriter实现的。 10)org.apache.spark.storage.MemoryStore:实现了存储Block到内存中。 11)org.apache.spark.storage.TachyonStore:实现了存储Block到Tachyon上。 12)org.apache.spark.storage.TachyonBlockManager:管理和维护逻辑上的Block和Tachyon文件系统上的文件之间的映射。这点和 org.apache.spark.storage.DiskBlockManager功能类似。 8.1.3 Master和Slave的消息传递详解 通过对消息传递协议的梳理,可以更加清楚地理解Storage模块在Driver和Executor之间的控制信息是如何传递的;还可以了解Storage模块管理Block的框架 和脉络。 这里的Master指的是org.apache.spark.storage.BlockManagerMasterActor,它运行在Driver端,通过保存Slave的Actor Reference向Slave发送消息;Slave指的是 org.apache.spark.storage.BlockManagerSlaveActor,它运行在Executor端,每个Executor都有一个,主要的功能是接收来自Master的命令,做一些清理工作并且 响应Master获取Block的状态的请求,同时Executor都会保存有org.apache.spark.storage.BlockManagerMasterActor的Actor Reference,Executor通过这个Reference 和Master进行通信。 1.Master到Slave消息详解 Slave有任何的Block的状态更新,都会主动通过Master Actor Reference上报到Master。而从Master到Slave的消息主要是由Master向Slave发送的控制信息或者 获取状态的请求。控制信息主要指删除Block、RDD相关的Block、广播变量相关的数据和Shuffle相关的数据。获取状态的请求主要是获取Block的状态信 息和匹配的Block ID等信息。 那么Master端是如何向Slave发送消息呢? 在Slave向Master发起注册BlockManager的请求中,每个BlockManager的信息都保存在org.apache.spark.storage.BlockManagerInfo中,因此通过BlockManagerInfo 就可以得到Slave Actor的Reference,进而向它发送消息。下面通过删除RDD的例子,来详细讲解这一个过程。在RDD调用了 org.apache.spark.rdd.RDD#unpersist,详细的调用栈如下: 1)org.apache.spark.SparkContext#unpersistRDD 相关代码: sc.unpersistRDD(id, blocking) 2)org.apache.spark.storage.BlockManagerMaster#removeRdd 相关代码: env.blockManager.master.removeRdd(rddId, blocking) 3)org.apache.spark.storage.BlockManagerMaster#askDriverWithReply 相关代码: val future = askDriverWithReply[Future[Seq[Int]]](RemoveRdd(rddId)) 4)org.apache.spark.util.AkkaUtils$#askWithReply(java.lang.Object,akka.actor.ActorRef,int,int,scala.concurrent.duration.FiniteDuration) 相关代码: AkkaUtils.askWithReply(message, driverActor, AKKA_RETRY_ATTEMPTS, AKKA_RETRY_INTERVAL_MS, timeout) 5)org.apache.spark.storage.BlockManagerMasterActor#receiveWithLogging 相关代码: case RemoveRdd(rddId) =>sender ! removeRdd(rddId) ) 6)org.apache.spark.storage.BlockManagerMasterActor#removeRdd: 这个比较复杂,首先需要删除这个RDD相关的元数据信息,然后删除Slave上的RDD的信息。 首先删除Master保存的RDD相关的元数据信息。 val blocks = blockLocations.keys.flatMap(_.asRDDId).filter(_.rddId == rddId) blocks.foreach { blockId =>val bms: mutable.HashSet[BlockManagerId] = block- Locations.get(blockId) bms.foreach(bm => blockManagerInfo.get(bm).foreach(_.removeBlock(blockId))) blockLocations.remove(blockId) } 其次删除Slave上的RDD的信息。 val removeMsg = RemoveRdd(rddId) Future.sequence( blockManagerInfo.values.map { bm => bm.slaveActor.ask(removeMsg)(akkaTimeout).mapTo[Int] }.toSeq ) 在Slave接到RemoveRdd的消息后,会调用BlockManager删除RDD: case RemoveRdd(rddId) => doAsync[Int]("removing RDD " + rddId, sender) { blockManager.removeRdd(rddId) } BlockManager端的实现: def removeRdd(rddId: Int): Int = { logInfo(s"Removing RDD $rddId") val blocksToRemove = blockInfo.keys.flatMap(_.asRDDId).filter(_.rddId == rddId) blocksToRemove.foreach { blockId => removeBlock(blockId, tellMaster = false) } blocksToRemove.size } 下面6个消息都是从Master Actor传到Slave Actor的简要说明。如果对实现感兴趣,可以深入阅读一下相关的实现,这里不再赘述。 1)RemoveBlock(blockId:BlockId):根据blockId删除该Executor上的Block,实际上通过调用org.apache.spark.storage.BlockManager#removeBlock删除。 2)RemoveRdd(rddId:Int):根据rddId删除该Executor上RDD所关联的所有Block,实际上通过调用org.apache.spark.storage.BlockManager#removeRdd删 除。 3)RemoveShuffle(shuffleId:Int):根据shuffleId删除该Executor上所有和该Shuffle相关的Block,需要通过两步实现: a)org.apache.spark.MapOutputTracker#unregisterShuffle b)org.apache.spark.shuffle.ShuffleManager#unregisterShuffle 4)RemoveBroadcast(broadcastId:Long,removeFromDriver:Boolean=true):根据broadcastId删除该Executor和该广播变量相关的所有Block,通过 org.apache.spark.storage.BlockManager#removeBroadcast实现。 5)GetBlockStatus(blockId:BlockId,_):根据blockId向Master返回该Block的Status。这个一般都是测试使用的,注意这个操作非常耗时。实现方式: org.apache.spark.storage.BlockManager#getStatus。 6)GetMatchingBlockIds(filter,_):根据filter向Master返回符合filter的所有BlockId。通过org.apache.spark.storage.BlockManager#getMatchingBlockIds实现。这 个和第5个消息一样,也非常耗时,一般都是在测试中使用的。 2.Slave到Master消息详解 首先强调一点,Slave Actor并不会主动向Master Actor发送消息。这里的Slave到Master的消息,主要还是由Slave节点上的BlockManager发出的。下面将通过 分析Slave如何向Master汇报某个Block的状态更新,来详细阐述实现细节。 BlockManager通过调用org.apache.spark.storage.BlockManager#reportBlockStatus来汇报一个Block的状态。那么什么情况下会汇报Block的状态呢?当然是Block 的状态改变的时候。比如通过org.apache.spark.storage.BlockManager#dropFromMemory将某个Block从内存中移出;比如通过 org.apache.spark.storage.BlockManager#remove-Block删除一个Block并且需要通知Master时;比如通过org.apache.spark.storage.BlockManager#dropOldBlocks移除 一个Block时;比如通过org.apache.spark.storage.BlockManager#doPut写入一个新的Block时。org.apache.spark.storage.BlockManager#reportBlockStatus之后的调 用栈如下: 1)org.apache.spark.storage.BlockManager#tryToReportBlockStatus 相关代码: val needReregister = !tryToReportBlockStatus(blockId, info, status, droppedMemorySize) 2)org.apache.spark.storage.BlockManagerMaster#updateBlockInfo 相关代码: master.updateBlockInfo( blockManagerId, blockId, storageLevel, inMemSize, onDiskSize, inTachyonSize) 3)org.apache.spark.storage.BlockManagerMaster#askDriverWithReply 相关代码: val res = askDriverWithReply[Boolean](UpdateBlockInfo(blockManagerId, blockId, storageLevel, memSize, diskSize, tachyonSize)) 4)org.apache.spark.util.AkkaUtils$#askWithReply(java.lang.Object,akka.actor.ActorRef,int,int,scala.concurrent.duration.FiniteDuration) 相关代码: AkkaUtils.askWithReply(message, driverActor, AKKA_ RETRY_ATTEMPTS, AKKA_RETRY_INTERVAL_MS, timeout) 5)org.apache.spark.storage.BlockManagerMasterActor#receiveWithLogging 相关代码: case UpdateBlockInfo( blockManagerId, blockId, storageLevel, deserializedSize, size, tachyonSize) => sender ! updateBlockInfo( blockManagerId, blockId, storageLevel, deserializedSize, size, tachyonSize) 6)org.apache.spark.storage.BlockManagerMasterActor#updateBlockInfo 相关实现:这个调用会进行Master端控制信息的更新。主要更新两个数据结构: a)blockManagerInfo中是blockManagerId为key,BlockManagerInfo为value的HashMap。 b)blockLocations中是blockId为key,mutable.HashSet[BlockManagerId]为value的HashMap。 更新blockManagerInfo: blockManagerInfo(blockManagerId).updateBlockInfo( blockId, storageLevel, memSize, diskSize, tachyonSize) 更新blockLocations: var locations: mutable.HashSet[BlockManagerId] = null if (blockLocations.containsKey(blockId)) { // 该 Block 是有信息更新或者有多个备份 locations = blockLocations.get(blockId) } else { // 新加入的 Block locations = new mutable.HashSet[BlockManagerId] blockLocations.put(blockId, locations) } if (storageLevel.isValid) { locations.add(blockManagerId) // 为该 Block 加入新的位置 } else { locations.remove(blockManagerId) // 删除无效的 Block 的位置 } // 删除在 Slave 上已经不存在的 Block if (locations.size == 0) { blockLocations.remove(blockId) } 一般来说,Slave向Master汇报信息获取查询信息,调用栈都是类似的: 1)org.apache.spark.storage.BlockManager 2)org.apache.spark.storage.BlockManagerMaster 3)org.apache.spark.storage.BlockManagerMasterActor 其中2~3实际上是需要通过网络的,即通过BlockManagerMaster持有的Master Actor Reference向Master Actor发送消息。 下面14个消息都是从Master Actor传到Slave Actor的简要说明。如果对实现感兴趣,可以深入阅读一下相关的实现,这里不再赘述。 1)RegisterBlockManager(blockManagerId:BlockManagerId,maxMemSize:Long,sender:ActorRef):由org.apache.spark.storage.BlockManagerMaster向 org.apache.spark.storage.BlockManagerMasterActor发起的注册。通过注册,Master Actor会保存该BlockManger所包含的Block等信息。 2)UpdateBlockInfo(var blockManagerId:BlockManagerId,var blockId:BlockId,var storageLevel:StorageLevel,var memSize:Long,var diskSize: Long,var tachyonSize:Long):向Master汇报Block的信息,Master会记录这些信息并且供Slave查询。 3)GetLocations(blockId:BlockId):获得某个Block所在的位置信息,返回由org.apache.spark.storage.BlockManagerId组成的列表(包含Executor ID,Executor所在的hostname和开放的port号)。注意:Block可能在多个节点上都有备份。 4)GetLocationsMultipleBlockIds(blockIds:Array[BlockId]):与GetLocations(blockId:BlockId)基本相同,区别是它可以一次获取多个Block的位置信 息。 5)GetPeers(blockManagerId:BlockManagerId):获得其他的BlockManager。这个在做Block的副本时会用到。BlockManager通过 org.apache.spark.storage.BlockManager#doPut去执行写Block的操作。在写Block的时候如果需要写副本,那么需要调用 org.apache.spark.storage.BlockManager#replicate。在复制的时候会获取其他的BlockManager的位置信息,相对于它自身,这些BlockManager就是它的副本 了。 6)GetActorSystemHostPortForExecutor(executorId:String):根据executorId获取Executor的Thread Dump,是由 org.apache.spark.SparkContext#getExecutorThreadDump发起的调用,它在获得了Executor的hostname和port后,会通过AKKA向Executor发送请求信息。实际上 获取Thead Dump最开始是由org.apache.spark.ui.exec.Executor-ThreadDumpPage发起的。 7)RemoveExecutor(execId:String):删除Master上保存的execId对应的Executor上的BlockManager的信息。 8)StopBlockManagerMaster:在停止org.apache.spark.storage.BlockManagerMaster时调用。它会停止Master的Actor。实际上这是Spark的Driver和Executor退出 的时候发起的停止动作。 9)GetMemoryStatus:获取所有Executor的内存使用的状态,包括使用的最大的内存大小、剩余的内存大小。 10)GetStorageStatus:返回每个Executor的Storage的状态,包括每个Executor最大可用的内存数和Block的信息。 11)GetBlockStatus(blockId:BlockId,askSlaves:Boolean=true):根据blockId获取Block的Status。如果askSlaves为true,那么需要到所有的Slave上查询结 果,因此可能会非常耗时。而且在某种情况下可能某些Block的状态并没有上报。推荐还是作为一个测试项使用。 12)GetMatchingBlockIds(filter:BlockId=>Boolean,askSlaves:Boolean=true):和GetBlockStatus类似,只不过这个是根据filter获取。 13)BlockManagerHeartbeat(blockManagerId:BlockManagerId):前面提到过Slave和Master的心跳的实现。实际上,Slave的BlockManager并不会主动发起 心跳,而是通过Executor和Driver之间的心跳来实现的。Driver在创建SparkContext的时候就会初始化org.apache.spark.HeartbeatReceiver,这是一个Actor,而 Executor启动时会创建这个Actor的Reference。然后Executor就通过Actor之间的通信来维持这个心跳。Driver端在收到心跳后,会调用 org.apache.spark.scheduler.TaskScheduler#executorHeartbeatReceived,而TaskScheduler会调用DAGScheduler向org.apache.spark.storage.BlockManagerMasterActor发 送心跳。 14)ExpireDeadHosts:删除心跳超时的BlockManager。心跳检测的线程是在Master Actor的preStart()里启动的,默认的超时时间是60秒。可以通过 spark.storage.blockManagerTimeoutIntervalMs来设置,注意时间单位是毫秒。 8.2 存储实现详解 8.2.1 存储级别 存储级别,对用户来说是RDD相关的持久化和缓存。这实际上也Spark最重要的特征之一。在每个节点都将RDD的Partition的数据保存在内存中时,后续 的计算将会变得非常快(通常会快10倍以上)。可以说,缓存是Spark构建迭代式算法和快速交互式查询的关键。 用户可以直接调用persist()或者cache()来标记一个RDD需要持久化。实际上,cache()是使用默认存储级别的快捷方法: /** Persist this RDD with the default storage level (`MEMORY_ONLY`). */ def persist(): this.type = persist(StorageLevel.MEMORY_ONLY) /** Persist this RDD with the default storage level (`MEMORY_ONLY`). */ def cache(): this.type = persist() 前面也介绍过,只有触发了一个Action后,计算才会提交到集群开始真正的运算。因此RDD只有经过一次Action后,才能将RDD缓存到内存中以供以后的 计算使用。这个缓存也有容错机制,如果某个缓存丢失了,那么会通过原来的计算过程进行重算。 如果用户有特殊需求,可以调用def persist(newLevel:StorageLevel)来设置需要的存储级别。不同的存储级别,可以选择持久化数据到Disk、Memory、 Tachyon;还可以选择数据是否需要序列化从而节省空间;甚至可以将数据远程复制到其他节点。 1.存储级别的定义 RDD的Partition和Storage模块的Block是一一对应的关系。表8-1列出了Storage模块支持的存储级别。为了和官网保持一致,在介绍存储级别相关的含义 时,都使用了Partition而不是Block。 表8-1 Storage模块支持的存储级别 读者可以通过 http://spark.apache.org/docs/latest/programming-guide.html#rdd-persistence 来获取最新的关于存储级别的说明。 实际上,上述的存储级别是通过org.apache.spark.storage.StorageLevel定义的。 object StorageLevel { val NONE = new StorageLevel(false, false, false, false) val DISK_ONLY = new StorageLevel(true, false, false, false) val DISK_ONLY_2 = new StorageLevel(true, false, false, false, 2) val MEMORY_ONLY = new StorageLevel(false, true, false, true) val MEMORY_ONLY_2 = new StorageLevel(false, true, false, true, 2) val MEMORY_ONLY_SER = new StorageLevel(false, true, false, false) val MEMORY_ONLY_SER_2 = new StorageLevel(false, true, false, false, 2) val MEMORY_AND_DISK = new StorageLevel(true, true, false, true) val MEMORY_AND_DISK_2 = new StorageLevel(true, true, false, true, 2) val MEMORY_AND_DISK_SER = new StorageLevel(true, true, false, false) val MEMORY_AND_DISK_SER_2 = new StorageLevel(true, true, false, false, 2) val OFF_HEAP = new StorageLevel(false, false, true, false) 各个参数的含义可以参见: class StorageLevel private( private var _useDisk: Boolean, private var _useMemory: Boolean, private var _useOffHeap: Boolean, private var _deserialized: Boolean, private var _replication: Int = 1) 2.选择合适的存储级别 Spark不同的存储级别是内存使用和CPU效率的折中。Spark官网建议按照以下步骤来选择合适的存储级别: 1)如果你的RDD可以和默认的存储级别有很好的契合,那么就无需任何特殊的设定了。默认的存储级别是CPU最高效的选项,也是运算能够最快完成 的选项。 2)如果不行,那么需要减少内存的使用,可以使用MEMORY_ONLY_SER。这时候需要选择一个合适的序列化方案,可以参考 http://spark.apache.org/docs/latest/tuning.html#data-serialization 。需要在空间效率和反序列化时所需要的CPU中做一个合适的选择。 3)尽量不要落到硬盘上,除非是计算逻辑非常复杂,或者是需要从一个超大规模的数据集过滤出一小部分数据。否则重新计算一个Partition的速度可能 和从硬盘读差不多(考虑到出错的概率和写硬盘的开销,因此采用失败重算要比读硬盘持久化的数据要好)。 4)如果你需要故障的快速恢复能力(比如使用Spark来处理Web的请求),那么可以考虑使用存储级别的多副本机制。实际上所有的存储级别都提供了 Partition数据丢失时的重算机制,只不过有备份的话可以让Application直接使用副本而无须等待重新计算丢失的Partition数据。 5)如果集群有大量的内存或者有很多的运行任务,则选择OFF_HEAP。现在处于试验阶段的OFF_HEAP有以下的优势: a)它使得多个Executor可以共享一个内存池。 b)它显著地减少了GC的开销。 c)缓存在内存中的数据即使是产生它的Executor异常退出了也不会丢失。 8.2.2 模块类图 org.apache.spark.storage.BlockStore:存储Block的抽象类。它的实现有: 1)org.apache.spark.storage.DiskStore 2)org.apache.spark.storage.MemoryStore 3)org.apache.spark.storage.TachyonStore 该模块的类图如图8-5所示。 图8-5 存储实现类图 首先通过org.apache.spark.storage.BlockStore来看一下实现类需要的接口名字及接口的功能。 private[spark] abstract class BlockStore(val blockManager: BlockManager) extends Logging { // 根据 StorageLevel 将 blockId 标识的 Block 的内容 bytes 写入系统 def putBytes(blockId: BlockId, bytes: ByteBuffer, level: StorageLevel): PutResult // 将 values 写入系统。如果 returnValues = true ,需要将结果写入 PutResult def putIterator( blockId: BlockId, values: Iterator[Any], level: StorageLevel, returnValues: Boolean): PutResult // 同上,只不过由 Iterator 编程 Array def putArray( blockId: BlockId, values: Array[Any], level: StorageLevel, returnValues: Boolean): PutResult // 获得 Block 的大小 def getSize(blockId: BlockId): Long // 获得 Block 的数据,返回类型 ByteBuffer def getBytes(blockId: BlockId): Option[ByteBuffer] // 获得 Block 的数据,返回类型 Iterator[Any] def getValues(blockId: BlockId): Option[Iterator[Any]] // 删除 Block , 成功返回 true ,否则 false def remove(blockId: BlockId): Boolean // 查询是否包含某个 Block def contains(blockId: BlockId): Boolean // 退出时清理回收资源 def clear() { } } 8.2.3 org.apache.spark.storage.DiskStore实现详解 DiskStore通过org.apache.spark.storage.DiskBlockManager来管理文件。前面介绍过,DiskBlockManager管理和维护了逻辑上的Block和存储在Disk上物理的 Block的映射。一般来说,一个逻辑的Block会根据它的blockId生成的名字映射到一个物理上的文件。这些物理文件会被hash到由spark.local.dir(或者 SPARK_LOCAL_DIRS)设置的目录中。 一般来说,DiskStore会通过blockId从DiskBlockManager获取一个文件句柄,然后通过这个文件句柄来读写文件: val file = diskManager.getFile(blockId) // 获取文件句柄 val channel = new FileOutputStream(file).getChannel while (bytes.remaining > 0) { channel.write(bytes) } channel.close() 那么DiskBlockManager是如何管理这些文件的呢? spark.local.dir(或者SPARK_LOCAL_DIRS)可以设置多个目录。DiskBlock-Manager会为Executor在每个目录下创建一个子目录,子目录的命名方式 是“spark-local-yyyyMMddHHmmss-xxxx”,其中,最后的xxxx是一个随机数,这个实现的逻辑在org.apache.spark.storage.DiskBlockManager#createLocalDirs中。 而每个“spark-local-yyyyMMddHHmmss-xxxx”目录下,会根据需要生成个数至多为spark.diskStore.subDirectories(默认值为64)的子目录,子目录以数字命名 (从00到文件个数)。图8-6表示了用户在spark.local.dir中设置了3个目录,每个目录下有64个子目录的目录布局。 图8-6 Block存储的目录布局 那么如何确定某个Block需要保存在哪个子目录呢?根据文件名的hash值取得其应该存放的一级目录(即“spark-local-yyyyMMddHHmmss-xxxx”),然后根 据该hash值取得其应该存放的二级目录(即一级目录的子目录)。主要的实现如下: val hash = Utils.nonNegativeHash(filename) val dirId = hash % localDirs.length val subDirId = (hash / localDirs.length) % subDirsPerLocalDir 需要注意的是二级目录不一定存在,因此在返回前需要确认,如果不存在则需要创建: var subDir = subDirs(dirId)(subDirId) if (subDir == null) { subDir = subDirs(dirId).synchronized { val old = subDirs(dirId)(subDirId) if (old != null) { old } else { val newDir = new File(localDirs(dirId), "%02x".format(subDirId)) if (!newDir.exists() && !newDir.mkdir()) { throw new IOException(s"Failed to create local dir in $newDir.") } subDirs(dirId)(subDirId) = newDir newDir } } } 再看DiskStore的实现就比较容易理解了。回忆一下在Hash Based Shuffle中,为了避免引入大量文件造成的内存压力和随机Disk IO的性能问题,引入了 Shuffle Consolidate Files机制。这里重要的是FileSegment的实现。那么如何读这个文件呢?答案就在这个函数的实现: def getBytes(segment: FileSegment): Option[ByteBuffer] = { // 根据文件句柄,开始的 offset 和要读取的长度来读取文件 getBytes(segment.file, segment.offset, segment.length) } 8.2.4 org.apache.spark.storage.MemoryStore实现详解 MemoryStore实际上是使用一个Hash Map来保存Block的数据: private val entries = new LinkedHashMap[BlockId, MemoryEntry](32, 0.75f, true) entries保存了Block的数据,因此Block的读取和写入都是围绕entries展开的。除了在内存紧张的情况下,新的缓存的加入可能需要将部分老的缓存清除的 逻辑外,这部分的实现还是很简单的。 所有对外提供的写入的接口,最终都是通过调用org.apache.spark.storage.MemoryStore的私有函数tryToPut来完成的。tryToPut会检查当前的内存是否可以容 下当前的请求,如果可以,则放入这个Hash Map,期间可能会淘汰老的缓存;如果内存超过了当前的最大内存,那么就会返回调用者,如果存储级别还 有Disk,那么会将当前的数据通过DiskStore写入Disk,否则缓存将不会被持久化,下次用的话还需要重新计算。 如何确定当前的内存足够呢?这个功能是在org.apache.spark.storage.MemoryStore的ensureFreeSpace实现的。 if (space > maxMemory) { // 无法载入内存 return ResultWithDroppedBlocks(success = false, droppedBlocks) } val actualFreeMemory = freeMemory - currentUnrollMemory if (actualFreeMemory < space) { // 如果可用内存不足以存储当期的 Block ,那么淘汰部分 Block val rddToAdd = getRddId(blockIdToAdd) val selectedBlocks = new ArrayBuffer[BlockId] var selectedMemory = 0L // 保证这个过程中 entries 不会改变,否则同时遍历 entrie 时会导致异常 entries.synchronized { val iterator = entries.entrySet().iterator() while (actualFreeMemory + selectedMemory < space && iterator.hasNext) { val pair = iterator.next() val blockId = pair.getKey if (rddToAdd.isEmpty || rddToAdd != getRddId(blockId)) { selectedBlocks += blockId // 这个 Block 将会被移出内存 selectedMemory += pair.getValue.size } } } if (actualFreeMemory + selectedMemory >= space) { for (blockId <- selectedBlocks) { // 根据 entries 装入 data ,此处忽略一些代码 val droppedBlockStatus = blockManager.dropFromMemory(blockId, data) droppedBlockStatus.foreach { status => droppedBlocks += ((blockId, status)) } } } 8.2.5 org.apache.spark.storage.TachyonStore实现详解 TachyonStore实现了Spark将缓存的数据放到Tachyon中。这个实现可以看作是实现了一个Tachyon客户端,通过这个客户端Spark可以读写Tachyon的数据。 而org.apache.spark.storage.TachyonStore作为BlockStore的一个实现,并没有复杂的逻辑,关于Tachyon文件的读取和写入都是通过 org.apache.spark.storage.TachyonBlockManager来完成的。 TachyonStore如何读取一个Block: override def getBytes(blockId: BlockId): Option[ByteBuffer] = { val file = tachyonManager.getFile(blockId) if (file == null || file.getLocationHosts.size == 0) { return None } val is = file.getInStream(ReadType.CACHE) try { val size = file.length val bs = new Array[Byte](size.asInstanceOf[Int]) ByteStreams.readFully(is, bs) Some(ByteBuffer.wrap(bs)) } catch {// 省略了异常处理的代码 } finally { is.close() } } 与读一个Block类似,也是从TachyonBlockManager获取Block的文件句柄,然后就可以写入了。其中,它生成的目录布局和 org.apache.spark.storage.DiskBlockManager有比较相似的风格,它为每个Executor生成不同的root dir: val storeDir = conf.get("spark.tachyonStore.baseDir", "/tmp_spark_tachyon") val appFolderName = conf.get("spark.tachyonStore.folderName") val tachyonStorePath = s"$storeDir/$appFolderName/${this.executorId}" 不过目录还是要加上spark-tachyon-yyyyMMddHHmmss-xxxx,然后在这个目录下面还是会有spark.tachyonStore.subDirectories(默认值也是64)个数的子目 录。 8.2.6 Block存储的实现 上面分析了DiskStore、MemoryStore和TachyonStore的详细实现,那么Block是如何存储的呢?实际上,org.apache.spark.storage.BlockManager对外屏蔽了存储 实现的方式,而是通过用户传入存储级别来确定最终的持久化方案。接下来,将通过对RDD部分计算过程的分析来阐明这个实现过程。 如果用户设置的存储级别不是StorageLevel.NONE,那么在计算的时候,会首先通过org.apache.spark.CacheManager来判断结果是否已经缓存,如果是,则 直接读取缓存,否则开始计算,并且将计算的结果根据存储级别进行缓存。相关代码: SparkEnv.get.cacheManager.getOrCompute(this, split, context, storageLevel) 如果org.apache.spark.CacheManager在缓存中没有找到计算需要的结果,那么它需要RDD开始计算: val computedValues = rdd.computeOrReadCheckpoint(partition, context) 而计算的结果会通过调用org.apache.spark.storage.BlockManager#putArray写入缓存: blockManager.putArray(key, arr, level, tellMaster = true, effectiveStorageLevel) tellMaster=true表示这个更新需要上报到Master;而effectiveStorageLevel表示了存储级别。而org.apache.spark.storage.BlockManager#putArray会调用 org.apache.spark.storage.BlockManager#doPut。doPut是一个BlockManager的私有接口,供自身调用,它会根据存储级别决定缓存存储的方式,比如是 Memory、Disk还是Tachyon;还有是否需要远程复制到其他的节点进行备份等。实际上BlockManager对外开放了三个接口来做数据的put: 1)org.apache.spark.storage.BlockManager#putIterator 2)org.apache.spark.storage.BlockManager#putArray 3)org.apache.spark.storage.BlockManager#putBytes 这些接口都会调用doPut来完成最终的处理。对于需要远程复制的,不同的调用将会有不同的处理: 1)如果调用来自org.apache.spark.storage.BlockManager#putBytes,那么在刚开始的时候就会启动复制的动作,因此这时候数据可以直接使用,这个由doPut 传入数据的类型是否是ByteBufferValues来确定: val replicationFuture = data match { case b: ByteBufferValues if putLevel.replication > 1 => // Duplicate doesn't copy the bytes, but just creates a wrapper val bufferView = b.buffer.duplicate() Future { replicate(blockId, bufferView, putLevel) } case _ => null } 2)如果来自其他的调用,因为复制需要获取数据,而这个数据有可能还需要序列化,因此这里的做法是调用BlockStore去缓存数据,并且对于需要远程 复制的请求,告知BlockStore调用者需要缓存数据,这样就可以通过这个数据进行远程复制了。以org.apache.spark.storage.BlockStore#putIterator为例,它的 参数是(blockId:BlockId,values:Iterator[Any],level:StorageLevel,returnValues:Boolean),最后一个参数就是表明调用者需要缓存的数据,这个数据 通过返回值org.apache.spark.storage.PutResult返回。 然后BlockManager会根据用户选择的存储级别来选择不同的BlockStore,并且确定是否需要结果。returnValues就是标记是否需要BlockStore返回结果。 val (returnValues, blockStore: BlockStore) = { if (putLevel.useMemory) { // Put it in memory first, even if it also has useDisk set to true; // We will drop it to disk later if the memory store can't hold it. (true, memoryStore) } else if (putLevel.useOffHeap) { // Use tachyon for off-heap storage (false, tachyonStore) } else if (putLevel.useDisk) { // Don't get back the bytes from put unless we replicate them (putLevel.replication > 1, diskStore) } else { assert(putLevel == StorageLevel.NONE) throw new BlockException( blockId, s"Attempted to put block $blockId without specifying storage level!") } } 对于同时使用Memory和Disk的存储级别,Spark并不一定会写Disk,只有当Memory不够用时才会写Disk。接下来会使用刚才通过存储级别获取的 BlockStore开始真正的写操作: val result = data match { case IteratorValues(iterator) => blockStore.putIterator(blockId, iterator, putLevel, returnValues) case ArrayValues(array) => blockStore.putArray(blockId, array, putLevel, returnValues) case ByteBufferValues(bytes) => bytes.rewind() blockStore.putBytes(blockId, bytes, putLevel) } 然后将状态汇报到Master: if (tellMaster) { reportBlockStatus(blockId, putBlockInfo, putBlockStatus) } 前面介绍过,这个reportBlockStatus会调用org.apache.spark.storage.BlockManager-Master,而请求会通过网络转发到 org.apache.spark.storage.BlockManagerMasterActor,完成状态的汇报。 对于ByteBufferValues类型的数据,数据在被BlockStore处理前就已经开始复制到其他的节点,那么对于其他的类型(ArrayValues和IteratorValues)呢? if (putLevel.replication > 1) { data match { case ByteBufferValues(bytes) => if (replicationFuture != null) { Await.ready(replicationFuture, Duration.Inf) } case _ => val remoteStartTime = System.currentTimeMillis // Serialize the block if not already done if (bytesAfterPut == null) { if (valuesAfterPut == null) { throw new SparkException( "Underlying put returned neither an Iterator nor bytes! This shouldn't happen.") } bytesAfterPut = dataSerialize(blockId, valuesAfterPut) } replicate(blockId, bytesAfterPut, putLevel) logDebug("Put block %s remotely took %s" .format(blockId, Utils.getUsedTimeMs(remoteStartTime))) } 8.3 性能调优 8.3.1 spark.local.dir 这个目录用于写中间数据,如RDD Cache、Shuffle时存储数据的位置,在8.2.3节已经详细介绍了它的使用方式,那么,设置该目录时需要注意什么呢? 首先,最基本的是我们可以配置多个路径(用逗号分隔)到多个磁盘上增加整体IO带宽。 其次,在8.2.3小节中也提到过,一个逻辑的Block会根据它的blockId生成的名字映射到一个物理上的文件。这些物理文件会被hash到由spark.local.dir(或 者SPARK_LOCAL_DIRS)设置的目录中。因此,如果存储设备的读写速度不一样,那么可以在较快的存储设备上配置更多的目录来增加它被使用的比 例,从而更好地利用快速存储设备。在Spark能够感知具体的存储设备类型前,这个变通方法可以取得一个不错的效果。 此外,还需要注意的是,在Spark 1.0以后,SPARK_LOCAL_DIRS(Standalone,Mesos)或者LOCAL_DIRS(YARN)参数会覆盖这个配置。比如YARN 模式的时候,Spark Executor的本地路径依赖于YARN的配置,而不取决于这个参数。 8.3.2 spark.executor.memory 本节与6.3.1节内容一致,这里不再赘述。 8.3.3 spark.storage.memoryFraction 如前文所说,spark.executor.memory决定了每个Executor可用内存的大小,那么spark.storage.memoryFraction则决定了在这部分内存中有多少可以用于Memory Store管理RDD Cache数据,多少内存用来满足任务运行时各种其他内存空间的需要。关于Memory Store的实现原理可参阅8.2.4节。 spark.executor.memory默认值为0.6,官方文档建议这个比值不要超过JVM Old Gen区域的比值。这也很容易理解,因为RDD Cache数据通常都是长期驻留 内存的,也就是说最终会被转移到Old Gen区域(如果该RDD还没有被删除的话),如果这部分数据允许的尺寸太大,势必把Old Gen区域占满,造成频 繁的全量的垃圾回收。 如何调整这个比值,取决于你的应用对数据的使用模式和数据的规模,粗略地来说,如果频繁发生全量的垃圾回收,可以考虑降低这个比值,这样RDD Cache可用的内存空间减少(剩下的部分Cache数据就需要通过Disk Store写到磁盘上了),虽然会带来一定的性能损失,但是腾出更多的内存空间用于执 行任务,减少全量的垃圾回收发生的次数,反而可能改善程序运行的整体性能。 8.3.4 spark.streaming.blockInterval 这个参数用来设置Spark Streaming里Stream Receiver生成Block的时间间隔,默认为200ms。具体表现为具体的Receiver所接收的数据,以相同的时间间隔, 同期性地从Buffer中生成一个StreamBlock放进队列,等待进一步被存储到BlockManager中供后续计算过程使用。从理论上来说,为了保证每个 StreamingBatch间隔里的数据是均匀的,这个时间间隔应该能被Batch的间隔时间长度整除。总体来说,如果内存大小够用,Streaming的数据来得及处理, 这个时间间隔的影响不大,当然,如果数据存储级别是Memory+Ser,即做了序列化处理,那么时间间隔的大小会影响序列化后数据块的大小,对于Java 的垃圾回收的行为会有一些影响。 此外,spark.streaming.blockQueueSize决定了在StreamBlock被存储到Block-Mananger之前,队列中最多可以容纳多少个StreamBlock。默认为10,因为这个队 列轮询的时间间隔是100ms,所以如果CPU不是特别繁忙的话,基本上应该没有问题。 8.4 小结 Storage模块负责管理Spark计算过程中产生的数据,包括基于Disk和基于Memory的数据。用户在实际编程中,面对的是RDD,可以将RDD的数据通过调 用org.apache.spark.rdd.RDD#cache将数据持久化;持久化的动作都是由Storage模块完成的。包括Shuffle过程中的数据,也都是由Storage模块管理的。可以 说,RDD实现了用户的逻辑,而Storage管理了用户的数据。 本章首先给出了Storage模块的整体架构,通过对Master端(即Driver端)和Slave端(即Executor端)的消息梳理,分析其在Driver端和Executor端的不同实 现,其中着重分析了DiskStore、MemoryStore和TachyonStore的实现。最后通过对用户可以配置参数的详细介绍,解析了这些参数的实际作用,为用户在 实际应用环境中的性能调优提供理论支撑。 第9章 企业应用概述 9.1 Spark在百度 百度构建了国内规模最大的Spark集群之一:实际生产环境,最大单集群规模1300台(包含数万核心和上百TB内存),公司内部同时还运行着大量的小 型Spark集群。百度分布式计算团队从2011年开始持续关注Spark,并于2014年将Spark正式引入百度分布式计算生态系统中,在国内率先面向开发者及企 业用户推出了支持Spark并兼容开源接口的大数据处理产品BMR。 9.1.1 现状 当前百度的Spark集群平均每天提交应用量多达数百,已应用于凤巢、大搜索、直达号、百度大数据等业务。Spark的三个优点使之在百度得到快速发 展: 1)快速高效。首先,Spark使用了线程池模式,任务调度效率很高;其次,Spark可以最大限度地利用内存,多轮迭代任务执行效率高。 2)API友好易用。这主要基于两个方面:第一,Spark支持多门编程语言,可以满足不同语言背景的人使用要求;第二,Spark的表达能力非常丰富,并 且封装了大量常用操作。 3)组件丰富。Spark生态圈当下已比较完善,在官方组件涵盖SQL、图计算、机器学习和实时计算的同时,还有很多第三方开发的优秀组件,足以应对 日常的数据处理需求。 9.1.2 百度开放云BMR的Spark BMR的全称是Baidu MapReduce,但是这个名称已经不能完全表示出这个平台:BMR是百度开放云的数据分析服务产品。BMR是基于百度多年大数据处 理分析经验,面向企业和开发者提供按需部署的Hadoop & Spark集群计算服务,让客户具备海量数据分析和挖掘能力,从而提升业务竞争力。 如图9-1所示,BMR基于BCC(百度云服务器),建立在HDFS和BOS(百度对象存储)分布式存储之上,其处理引擎包含了MapReduce和Spark,同时还 使用了HBase数据库。在此之上,系统集成了Pig、Hive、SQL、Streaming、GraphX、MLLib等专有服务。在系统的最上层,BMR提供了一个基于Web的控 制台,以及一个API形式的SDK。Scheduler在BMR中起到了管理作用,使用它可以编写比较复杂的作业流。 图9-1 BMR整体架构 类似于一般的云服务,BMR中的Spark同样随用随起,集群空闲即销毁,帮助用户节省预算。此外,集群创建可以在3~5分钟内完成,包含了完整的 Spark+HDFS+YARN堆栈。同时,BMR也提供Long Running模式,并有多种套餐可选。 在安全上,用户拥有虚拟的独立网络,在同一用户全部集群可互联的同时,BMR用户间网络被完全隔离。同时,BMR还支持动态扩容,节点规模可弹 性伸缩。除此之外,在实现Spark全组件支持的同时,BMR可无缝对接百度对象存储(BOS)服务,借力百度多年的存储研发经验,保证数据存储的高 可靠性。 9.1.3 在Spark中使用Tachyon 在Spark使用过程中,用户经常困扰于3个问题:首先,两个Spark实例通过存储系统来共享数据,这个过程中对磁盘的操作会显著降低性能;其次,因为 Spark崩溃所造成的数据丢失;最后,垃圾回收机制,如果两个Spark实例需要同样的数据,那么这个数据会被缓存两次,从而造成很大的内存压力,并 降低性能。而在百度,计算集群和存储集群往往不在同一个地理位置的数据中心,在大数据分析时,远程数据读取将带来非常高的延时,特别是ad-hoc 查询。因此,将Tachyon作为一个传输缓存层,百度通常会将之部署在计算集群上。首次查询时,数据会从远程存储取出,而在以后的查询中,数据就 会从本地的Tacnyon上读取,从而大幅改善了延时问题,如图9-2所示。 图9-2 Tachyon的整体架构 在百度,Tachyon的部署还处于初始阶段,大约部署了50台机器,主要服务于ad-hoc查询。但是相信随着Spark在百度的快速发展,Tachyon也一定会得到 更加广泛的应用。 9.2 Spark在阿里 阿里也是国内最早使用Spark的公司之一,同时也是最早在Spark中使用了YARN的公司之一。 值得一提的是,淘宝网络数据挖掘和计算团队负责人明风先生也是国内著名的Spark方面的专家。明风和他的团队针对淘宝的大数据和应用场景,在 MLlib、GraphX和Streaming三大块进行了广泛的模型训练和生产应用,并且取得了很好的效果。 尤其是他们利用GraphX构建了大规模的图计算和图挖掘系统,实现了很多生产环境中的推荐算法,包括(但不局限于)以下的计算场景:基于度分布的 中枢节点发现、基于最大连通图的社区发现、基于三角形计数的关系衡量、基于随机游走的用户属性传播等。可以说,淘宝技术团队在利用Spark来解 决多次迭代的机器学习算法、高计算复杂度的算法方面,在国内居于领先的位置。 此外,阿里积极拥抱并回馈开源社区,对Spark社区的各个Feature和PR选择性地进行跟进和贡献。同时,阿里的内部版本和社区版本也保持同步性和一 致性。阿里也在积极打造Spark周边的生产环境,包括MLStudio调度平台,使得Spark在阿里巴巴的应用更具推广性,可以满足大部分算法工程师和数据 科学家的需求。 9.3 Spark在腾讯 腾讯Spark集群已经达到8000台的规模,是当前已知的最大的Spark集群。每天运行了超过10000个作业,作业类型包括ETL、SparkSQL、机器学习、图计 算和流式计算。 腾讯的广点通是最早使用Spark的应用之一。腾讯大数据精准推荐借助Spark快速迭代的优势,围绕“数据+算法+系统”这套技术方案,实现了在“数据实 时采集、算法实时训练、系统实时预测”的全流程实时并行高维算法,最终成功应用于广点通pCTR投放系统上,支持每天上百亿的请求量。基于日志数 据的快速查询系统业务构建于Spark之上的Shark,利用其快速查询以及内存表等优势,承担了日志数据的实时查询工作。在性能方面,普遍比Hive高 2~10倍,如果使用内存表的功能,性能将会比Hive高百倍。 此外,腾讯使用千台规模的Spark集群来对千亿量级的节点对进行相似度计算,通过实验对比,性能是MapReduce的6倍以上,是GraphX的2倍以上。相似 度计算在信息检索、数据挖掘等领域有着广泛的应用,是目前推荐引擎中的重要组成部分。随着互联网用户数目和内容的爆炸性增长,对大规模数据进 行相似度计算的需求变得日益增强。在传统的MapReduce框架下进行相似度计算会引入大量的网络开销,导致性能低下。腾讯借助Spark对内存计算的支 持以及图划分的思想,大大降低了网络数据传输量;并通过在系统层次对Spark的改进优化,使其可以稳定地扩展至上千台规模。 9.4 小结 Spark在国内的应用越来越广泛,除了上面提到的百度、阿里和腾讯,现在京东、优酷土豆、携程等互联网公司也在使用Spark。除了Spark的使用,华 为、星环科技等公司也在做基于Spark的商业解决方案,尤其是星环科技,基于Spark做了很多二次开发。除此之外,英特尔上海有超过10个人的团队在 做Spark的开发,是Spark非常重要的贡献者。
还剩177页未读

继续阅读

下载pdf到电脑,查找使用更方便

pdf的实际排版效果,会与网站的显示效果略有不同!!

需要 10 金币 [ 分享pdf获得金币 ] 3 人已下载

下载pdf