Spark性能调优(原理篇)

1.开篇词 | 前言

2020年6月,Spark正式发布了新版本,从2.4直接跨越到了3.0。这次大版本升级的亮点就在于性能优化,它添加了诸如自适应查询执行(AQE)、动态分区裁剪(DPP)、扩展的Join Hints等特性。

Spark已经成为了各大头部互联网公司的标配,在海量数据处理上,扮演着不可获取的关键角色。 比如,字节跳动基于Spark构建的数据仓库去服务几乎所有的产品线,包括抖音、今日头条、西瓜视频、火山视频等。再比如,百度基于Spark推出BigSQL,为海量用户提供次秒级的即席查询。

可以预见的是,这次版本升级带来的新特性,会让Spark在未来5到10年继续雄霸大数据生态圈。

在日增数据量以TB、甚至PB为单位计数的当下,想要在小时级别完成海量数据处理,不做性能调优简直是天方夜谭。

性能导向的应用开发

遵循这套方法论,开发者可以按图索骥地去开展性能调优工作,做到有的放矢、事半功倍
性能导向的spark应用开发

专栏划分

结合方法论,专栏划分成3个部分:原理篇、性能篇和实战篇

原理篇:聚焦Spark底层原理。Spark的原理非常多,我们会聚焦那些和性能调优息息相关的核心概念,包括RDD、DAG、调度系统、存储系统和内存管理

性能篇: 性能篇分为两部分。
一部分讲解性能调优的通用技巧,包括应用开发的基本原则、配置项的设置、Shuffle的优化、以及资源利用率的提升。
另一部分会专注于数据分析领域、借助如Tungsten、AQE这样的Spark内置优化项和数据关联这样的典型场景,来聊聊Spark SQL中的调优方法和技巧。

2.开篇词 | 性能调优的抓手

尽管Spark自身运行高效,但是作为开发者,我们仍需要对应用进行性能调优。

但是性能调优应该从哪里切入呢?面对成百上千行代码,近百个Spark配置项,如何找到优化的抓手。

性能调优的本质

在ETL场景中,我们需要对数据进行各式各样的转换,有的时候,因为业务需求太复杂,我们往往还需要自定义UDF(User Defined Functions)来实现特定的转换逻辑。但是,无论是Databricks的官方博客,还是网上浩如烟海的Spark技术文章,都警告我们尽量不要用自定义UDF来实现业务逻辑,要尽可能的使用Spark内置的SQL Functions。

但是有时候我们花费了大量时间用SQL functions去替代自定义udf重构业务代码,却发现ETL作业端到端的执行性能并没有什么显著的提升。调优的时间没少花,却没啥效果

为什么用SQL Functions重构UDF没有像书本上说的那样奏效呢?

是因为这条建议不对吗?不是的。通过对比查询计划,我们能够明显的看到UDF与SQL Functions的区别。Spark SQL的Catalyst Optimizer能够明确的感知到SQL Functions每一步在做什么,因此有足够的优化空间。相反,UDF里面封装的计算逻辑对于Catalyst Optimizer来说就是个黑盒,除了把UDF塞到闭包里去,也没什么别的工作可做。

那么是因为UDF相比SQL Functions没有性能开销吗?也不是,实际上在单测中对比udf实现和sql functions实现的运行时间,通常情况下,UDF实现相比SQL Functions会慢3%-5%不等,UDF的性能开销还是有的。

原因是因为:UDF很可能不是最短的那块木板。

**根据木桶理论,最短的木板决定了木桶的容量。因此,对于一只有短板的木桶,其他木板调节的再高也无济于事。最短的木板才是木桶容量的瓶颈。**对于ETL应用这只木桶来说,UDF到SQL Functions的调优之所以对执行性能的影响微乎其微,根本原因在于他不是最短的那块木板。换句话说,ETL应用端到端执行性能的瓶颈不是开发者自定义的UDF。

结合上面的分析,性能调优的本质可以归纳为4点:

  • 性能调优不是一锤子买卖,补齐一个短板,其他板子可能会成为新的短板。因此,它是一个动态的、持续不断的过程。
  • 性能调优的手段和方法是否高效,取决于它针对的是木桶的长板还是瓶颈。针对瓶颈,事半功倍;针对长板,事倍功半。
  • 性能调优的方法和技巧,没有一定之规,也不是一成不变,随着木桶短板的此消彼长需要相应的动态切换。
  • 性能调优的过程收敛于一种所有木板齐平、没有瓶颈的状态。

所以我们就能解释,为什么一些网上的调优方法对于我们自己的任务效果不大,很有可能是这些优化方式没有触及到我们的瓶颈。

定位性能瓶颈的途径有哪些

我们可以通过运行时诊断来定位性能瓶颈。

运行时诊断的方法很多,比如:对于任务的执行情况,Spark UI提供了丰富的可视化面板,来展示DAG、Stages划分、执行计划、Executor负载均衡情况、GC时间、内存缓存消耗等等详尽的运行时状态数据;
对于硬件资源消耗,开发者可以利用Ganglia或者系统级监控工具,如top、vmstat、iostat、iftop等等来实时监测硬件的资源利用率;
特别的,针对GC开销,开发者可以将GC log导入到JVM可视化工具,从而一览任务执行过程中GC的频率和幅度。

性能调优的方法与手段

Spark的性能调优可以从应用代码和Spark配置项这2个层面展开

应用代码是指从代码开发的角度,如何以性能为导向进行应用开发。哪怕两份完全一致的代码,在性能上也会有很大的差异。因此我们需要知道,开发阶段有哪些常规操作、常见误区、从而尽量避免在代码中留下性能隐患。

Spark配置项,Spark官网上罗列了近百个配置项,看的人眼花缭乱。但并不是所有的配置项都和性能调优息息相关,因此我们需要对它们进行甄别、归类。

性能调优的终结

性能调优的本质告诉我们:性能调优是一个动态、持续不断的过程,在这个过程中,调优的手段需要随着瓶颈的此消彼长而相应的切换。那么问题就是,性能调优到底什么时候收敛?

性能调优的最终目的,是在所有参与计算的硬件资源之间寻求协同与平衡,让硬件资源达到一种平衡、无瓶颈的状态。
以大数据服务公司Qubole的案例为例,他们在Spark上集成机器学习框架XGBoost来进行模型训练,在相同的硬件资源、相同的数据源、相同的计算任务中对比不同配置下的执行性能。
从下表可以看出,执行性能最好的训练任务并不是把CPU利用率压榨到100%,以及把内存设置到最大的配置组合。而是哪些硬件资源配置最均衡的计算任务。
性能调优的终结

3.RDD

RDD为何如此重要

RDD作为Spark对于分布式数据模型的抽象,是构建Spark分布式内存计算引擎的基石。很多Spark核心概念与核心组件,如DAG和调度系统都衍生自RDD。因此,深入理解RDD有利于我们更全面、系统的学习Spark的工作原理。

深入理解RDD

从薯片生产过程理解 RDD

RDD,全称Resilient Distributed Datassets,翻译过来就是弹性数据集。本质上,它是对于数据模型的抽象,用于囊括所有内存中和磁盘中的分布式数据实体。
rdd举例
以上图,土豆生产过程为例。刚从地里挖出来的土豆、清洗后的土豆、生薯片、烤熟的薯片等,就像是Spark中RDD对于不同数据集合的抽象。
沿着流水线的方向,每一种食材形态都是在前一种食材之上用相同的加工方法进行处理的到的。每种食材形态都依赖于前一种食材,就像是RDD中dependencies属性记录的依赖关系(记录上一个RDD),而不同环节的加工方法,对应的刚好就是RDD的compute属性(作用在RDD上的计算函数,具体操作细节由用户传入)。

接下来我们从上到下的看,每一颗土豆就类似RDD的一个数据分片,3颗土豆一起对应的就是RDD的partitions属性。
带泥土豆经过清洗、切片和烘培之后,按照大小个被分发到下游的3条流水线上,这三条流水线上承载的RDD假设记为shuffledBackedChipsRDD。很明显,这个RDD对于partitions的划分是有讲究的,根据尺寸的不同,即食薯片会被划分到不同的数据分片中。像这种数据分片的划分规则,对应的就是RDD中的partitioner属性。
在分布式运行环境中,partitioner属性定义了RDD所封装的分布式数据集如何划分成数据分片。

总的来说,可以发现,薯片生产的流程和Spark分布式计算是一一对应的,可以总结为6点:

  • 土豆工坊的每条流水线就像是分布式环境中的计算节点(这里有3个)
  • 不同的食材形态,如带泥土豆、土豆切片、烘培的土豆片等等,对应的就是不同形态的RDD
  • 每一种食材形态都会依赖上一种形态,如烤熟的土豆片依赖上一个步骤的生土豆切片。这种依赖关系对应的就是RDD中的dependencies属性。
  • 不同环节的加工方法对应RDD的compute属性。
  • 同一种食材形态在不同流水线上的具体实物,就是RDD的partitions属性。
  • 食材按照什么规则配分配到哪条流水线,对应的就是RDD的partitioner属性。

接下来,我们来一本正经的聊聊RDD。

RDD的核心特征和属性

通过上面的例子,可以知道RDD具有4大属性,分别是partitions、partitioner、dependencies和compute属性。正因为有了这4大属性的存在,才让RDD具有分布式和容错性这两大最突出的特性。

partitions、partitioner属性(横向)

在分布式运行环境中,RDD封装的数据在物理上散落在不同计算节点的内存或者磁盘中。这些散落的数据被称为数据分片,RDD的分区规则决定了哪些数据分片应该散落到哪些节点中去。RDD只是这些实际数据的一个抽象集合,也就是说,RDD并不实际存储数据,它只是实际数据的抽象。RDD的partitions属性对应着RDD分布式数据实体中所有的数据分片,而partitioner属性则定义了划分数据分片的分区规则(默认是hashPartitioner,可选rangePartitioner,或者自己实现自定义partitioner),如按哈希取模或是按区间划分等。

partitions和partitioner属性刻画的是RDD在跨节点方向上的横向扩展,所以可以把他们看作是RDD的"横向属性"。

dependencies、compute属性(纵向)

在Spark中,任何一个RDD都不是凭空产生的,每个RDD都是基于一种计算逻辑从数据源or父RDD中转换而来的。RDD的dependencies属性记录了生成这个RDD父依赖(或父RDD),compute方法则封装了作用在当前RDD上的计算逻辑(具体的计算逻辑由用户实现)。

基于数据源和转换逻辑,无论RDD有什么差池(如节点宕机造成部分数据分片丢失),都可以通过该RDD的dependencies属性定位到其父RDD,然后通过在对其父RDD执行compute封装的计算逻辑再次得到当前的RDD。(注意RDD不保存数据,恢复数据也只能从checkpoint恢复,一般宽依赖会自动制作一个checkpoint)。
dependencies&compute
由dependencies和compute属性提供的容错能力,为Spark分布式内存计算的稳定性打下了坚实的基础。这也正是RDD命名中Resilient的由来。观察上图可以发现,不同的RDD通过dependencies和compute属性链接到一起,逐渐向纵深延展,构建了一张越来越深的有向无环图,这就是DAG。

由此可见,dependencies属性和compute属性负责RDD在纵深方向上的延展,因此可以把他们视为纵向属性。

总的来说,RDD的4大属性可以划分为两类:横向属性和纵向属性。其中,横向属性锚定数据分片实体,并规定了数据分片在分布式集群中如何分布;纵向属性用于在纵深方向构建DAG,通过提供重构RDD的容错能力保证内存计算的稳定性。
RDD的属性

讲解RDD的目的是:认清RDD是一个分布式数据实体,而不是单机上一个数据集的事实。在考虑问题时,从全节点数据范围来考虑,跳出单机思维模式。

4.DAG与流水线:到底什么叫内存计算

先举两个反例

反例1:缓存的滥用

无论是RDD,还是DataFrame,为了保证数据能比较方便的失败重试,凡是能产生数据集的地方。开发同学一律用cache()进行缓存,结果就是应用的执行性能奇差无比。那么Spark不是基于内存计算的嘛?为什么把数据缓存到内存里去,性能反而更差了?

反例2:Shuffle的泛滥

我们都知道,Shuffle是Spark中的性能杀手,在开发应用时要尽可能的避免Shuffle操作。但是很多初学者都没有足够的动力来重构代码避免Shuffle,这些同学的想法往往是:能把业务功能实现就不错了,对费了半天劲消除shuffle获得的性能收益不以为然。

这两个反例的根本原因都是开发者对Spark的内存计算理解的还不够透彻。所以我们接下来说说Spark的内存计算有哪些含义?

第一层含义:分布式数据缓存

内存计算的第一层含义:众所周知的分布式数据缓存。

RDD cache确实是Spark分布式计算引擎的一大亮点,它允许我们把缓存以不同的存储级别存到不同的存储介质中(如内存、磁盘等)

但是注意只有频繁访问的数据集才有必要做cache,对于一次性访问的数据集,cache不但不能提升执行效率,反而会产生额外的性能开销,让结果适得其反。

接下来,重点说说内存计算的第二层含义:Stage内存的流水线式计算模式。

在Spark中,内存计算有两层含义:第一层含义就是众所周知的分布式数据缓存。第二层含义是Stage内的流水线式计算模式。为了充分利用流水线式计算模式,我们就要消除Spark运行的卡点:Shuffle,或者优化Shuffle。 我们重点关注第二层含义:流水线式计算即可

第二层含义:Stage内的流水线式计算模式

什么是DAG?

DAG全称Direct Acyclic Graph,中文叫有向无环图。任何一种图都包含两种基本元素:顶点(Vertex)和边(Edge),顶点通常用来表示实体,而边则表示实体间的关系。在Spark的DAG中,顶点时一个个RDD,边则是RDD之间通过dependencies属性构成的父子关系。

还是以土豆生产为例:
DAG土豆生产例子
DAG土豆生产例子在Spark的开发模型下,应用开发实际上就是灵活运用算子实现业务逻辑的过程。开发者在分布式数据集如RDD、DataFrame或DataSet之上调用算子、封装计算逻辑,这个过程会衍生出新的子RDD。与此同时,子RDD会把dependencies属性设值为父RDD,把compute属性设置为算子封装的计算逻辑。以此类推,在子RDD之上,开发者还会调用其他算子,衍生出新的RDD,如此往复便有了DAG。

因此,从开发者的视角出发,DAG的构建是通过在分布式数据集上不停调用算子来完成的。

Stages的划分

如果用一句话来概括从DAG到Stages的转化过程,那就是:以Action算子(即一个job的终点)为起点,从后往前深度遍历回溯DAG,以Shuffle操作为边界去划分Stages。
划分stage例子
以上图薯片生产为例,有两个地方需要分发数据。1:薯片简单处理完后按照尺寸大小分发到下游的对应流水线上。2:不同调料粉分发到对应的流水线上,用于和对应型号的薯片混合。

这时候的问题就是:费了半天劲,把DAG变成Stages有啥用呢?还真有用,内存计算的第二层含义,就隐匿于从DAG划分的一个一个Stage中。为了弄清楚Stage内的流水线式计算模式,我们先从Hadoop MapReduce计算模型开始说起。

Stage中的内存计算

Spark的基于内存的计算模型不是凭空产生的,是前人踩过Hadoop MapReduce的坑后反思出来的。
mr
MapReduce提供两类计算抽象,分别是Map和Reduce:Map抽象允许开发者通过实现map接口来定义数据处理逻辑,Reduce抽象用于封装数据聚合逻辑。**MapReduce计算模型最大的问题在于,所有m/r操作之间的数据交换都以磁盘为媒介。**例如,两个Map操作之间的计算,以及Map与Reduce操作之间的计算都是利用本地磁盘来交换数据的。这种频繁的磁盘IO必定会拖累用户应用端到端的执行性能。

相比起来Spark的流水线式计算模式有什么不同呢?还是以生产薯片为例:主要就看刚才的Stage0。这一阶段包含了3个操作,清洗、切片和烘培。这三个操作一气呵成的在内存中计算完成。
流水线式计算
此时你可能会想,那么是否Spark相比MR只是简单的把各算子之间的数据交换由磁盘换到了尽可能在内存中进行呢?(这里用尽可能是因为shuffle的话还是要用到磁盘的)

试想一下,如果一个土豆经过了清洗,然后把计算后的中间结果缓存到内存中,然后再进行切片,再把计算后的中间结果缓存到内存中,供下一个算子计算。这个过程就和开发者在代码中滥用RDD cache如出一辙了(写一个算子,就rdd.cache()一下)。采用这种方式的话,spark的计算效率不会见得比mr高多少,尤其是当一个stage中算子比较多的情况下。

那Spark的流水线式计算模式到底是啥样呢?在Spark中,**流水线计算模式是指:在同一个Stagen内部,所有算子融合为一个函数,Stage的输出结果由这个函数一次性作用在输入数据集而产生。**这就是Spark内存计算的第二层含义–流水线式计算。以下图为例:
spark流水线式计算
如上图所示,流水线计算并不是逐步进行凑走,每一步操作后生成一份中间结果数据缓存在内存中。而是在内存计算中,所有步骤clean、slice、bake都会被捏合在一起构成一个函数。这个函数一次性的作用在输入数据集中,在内存中不产生任何中间数据形态。

因此所谓内存计算,不仅仅是将数据缓存在内存中,更重要的是通过计算的融合大幅度提升数据在内存中的计算效率,从而在整体上提升应用的执行性能。

这也就是与其说spark的基于内存运算的(其实任何计算都要在内存中进行),不如说spark是充分利用起来了内存计算。

此时我们也能感受到了:消除shuffle会带来多大的性能收益。

由于计算的融合只会发生在Stage内部,而Shuffle是切割Stage的边界。因此一旦发生Shuffle,内存计算的代码融合就会中断。所以我们应该尽可能的避免Shuffle,让应用代码尽可能多的部分融合为一个函数,从而提升计算效率。

减少数据倾斜的小tips:

一个小tips:如果a表left join b表只是为了扩展字段,那么我们可以用union all+group by&sum来替代
一个小tips:count(distinct)用加盐去盐,两阶段聚合来减少数据倾斜问题
一个小tips:如果a表left join b表,在一些关联键上发生了数据倾斜,可以把a表的数据集分为大key和非大key两部分,对大key单独处理(加盐),然后left join膨胀n倍(n同加盐系数一致)的b表,再做去盐的一共两次关联。最后和非大key left join b表的部分union all到一起即可。注意大key部分关联,由于数据膨胀,注意去重以保证结果的正确性。

5.调度系统:数据不动代码动

Spark的调度系统是如何工作的?

Spark调度系统的核心职责是:先将用户构建的DAG转化为分布式任务,结合分布式集群资源的可用性,基于调度规则依次把分布式任务分发到执行器。

Spark调度系统的工作流程包含以下5个步骤:
1.将DAG拆分为不同的运行阶段Stages
2.创建分布式任务Tasks和管道式任务组TaskSet
3.获取集群内可用的硬件资源情况
4.按照调度规则决定优先调度哪些任务/组
5.依次将分布式任务分发到执行器Executor
现代化流水线例子

调度

系统中的核心组件有哪些

Spark调度系统中包含3个核心组件,分别是DAGScheduler、TaskScheduler和SchedulerBackend。 这三个组件都运行在Driver进程中,它们通力合作将用户构建的DAG转化成分布式任务,再将这些任务分发到集群中的Executors去执行。
调度组件

1.DAGScheduler

DAGScheduler的主要职责有二:一是把用户DAG拆分成Stages。二是在Stage内创建计算任务Tasks,这些任务囊括了用户通过组合不同算子实现的数据转换逻辑。

不过,如果我们给集群中处于繁忙或者是饱和状态的Executors分发了任务,执行效果会大打折扣。因此,在分发任务之前,调度系统得先判断哪些节点的计算资源空闲,然后再把任务分发过去。 SchedulerBackend就是做这个的。

2.SchedulerBackend

SchedulerBackend是对资源调度器的封装与抽象,为了支持多样的资源调度模式比如Standalone、YARN和Mesos,SchedulerBackend提供了对应的实现类。在运行时,Spark根据用户提供的MasterURL,来决定实例化哪种实现类的对象。比如 – master spark://ip:host(Standalone模式)、-- master yarn(YARN模式)

对于集群中可用的计算资源,SchedulerBackend会用一个叫做ExecutorDataMap的数据结构,来记录每一个计算节点中Executors的资源状态。ExecutorDataMap是一种HashMap,它的key是标记Executor的字符串,value是一种叫做ExecutorData的数据结构、ExecutorData用于封装Executor的资源状态,如RPC地址、主机地址、可用CPU核数和满配CPU核数等等。它相当于是Executor的"资源画像"。
executorDataMap
总的来说,对内,SchedulerBackend用ExecutorData对Executor进行资源画像;对外,SchedulerBackend以WorkerOffer为粒度提供计算资源,WorkerOffer封装了Executor ID、主机地址和CPU核数,用来表示一份可用于调度任务的空闲资源。

到此为止,要调度的计算任务有了,就是DAGScheduler通过Stages创建的Tasks;可用于调度任务的计算资源也有了,即SchedulerBackend提供的一个又一个的WorkerOffer。如果从供需的角度看待任务调度,DAGScheduler就是需求端,SchedulerBackend就是供给端。

3.TaskScheduler

左边有需求,右边有供给,如果把Spark调度系统看作是一个交易市场的话,那么中间还需要有个中介来帮他们对接意愿、撮合交易,从而最大限度的提升资源配置的效率。在Spark调度系统中,这个中介就是TaskScheduler。TaskScheduler的职责是:基于既定的规则与策略达成供需双方的匹配与撮合。
TaskScheduler流程图
TaskScheduler的核心是任务调度的规则和策略,TaskScheduler的调度策略分为两个层次:一个是不同Stages之间的调度优先级,一个是Stages内不同任务的调度优先级。

首先,对于两个或多个Stages,如果它们彼此之间不存在依赖关系、互相独立,在面对同一份可用计算资源的时候,它们之间就会存在竞争关系。这个时候,先调度谁,或者说谁优先享受这份计算资源,就得按照既定的规则办事了。

对于这种Stages之间的任务调度,TaskScheduler提供了2种调度模式,分别是FIFO(先到先得)和FAIR(公平调度)。 FIFO非常好理解,在这种模式下,Stages按照被创建的时间顺序来依次消费可用计算资源。在FAIR公平调度模式下,哪个Stage优先被调度,取决于用户在配置文件fairscheduler.xml中的定义。

在配置文件中,Spark允许用户定义不同的调度池,每个调度池可以指定不同的调度优先级,用户在开发过程中可以关联不同的作业与调度池的对应关系,这样不同Stages的调度就直接和开发者的意愿挂钩,也就能享受不同的优先级待遇了。

说完了不同Stages之间的调度优先级,我们再来说说一个Stage内部不同任务之间的调度优先级,Stage内部的任务调度相对来说简单很多。当TaskScheduler接收到来自SchedulerBackend的WorkerOffer之后,TaskScheduler会优先挑选那些满足本地性级别要求的subtask任务进行分发。 本地性级别有4种:Process local < Node local < Rack local < Any。从左到右分别是进程本地性、节点本地性、机架本地性和跨机架本地性。从左到右,计算任务访问所需数据的效率越来越差。

进程本地性表示计算所需的输入数据就在某一个Executor进程内,因此把这样的计算任务调度到目标进程最划算。同理,如果数据源还未加载到Executor进程内,而是存储在某一计算节点的磁盘中,那么把任务调度到目标节点上也是一个不错的选择。再次,如果我们无法确定输入源在那台机器,但可以肯定他一定在某一个机架上,本地性级别就会退化到Rack local。这就是Spark计算向存储靠拢的特点,这个特性是由Spark的任务调度系统决定的。

DAGScheduler划分Stages、创建分布式任务的过程中,会为每一个任务指定本地性级别,本地性级别中会记录该任务有意向的计算节点地址,甚至是Executor进程ID。换句话说,任务自带调度意愿,它通过本地性级别告诉TaskScheduler自己更愿意被调度到哪里去。

由此可见,Spark调度系统的原则是尽可能的让数据呆在原地、保持不动,同时尽可能的把承载计算任务的代码分发到离数据最近的地方,从而最大限度的降低分布式系统中的网络开销。 毕竟,分发代码的开销要比分发数据的代价低太多,这也是"数据不动代码动"这个说法的由来。

总的来说,TaskScheduler根据本地性级别遴选出待计算任务后,先对这些任务进行序列化。然后,交给SchedulerBackend,SchedulerBackend根据ExecutorData中记录的RPC地址和主机地址,再将序列化的任务通过网络分发到目的主机的Executor进程中去。最后Executor接收到任务之后,把任务交给内置的线程池,线程池中的多线程则并发的在不同数据分片之上执行任务中封装的数据处理函数,从而实现分布式计算。

6.存储系统:空间换时间or时间换空间

Spark存储系统是为谁服务的?

Spark存储系统用于存储3个方面的数据,分别是RDD缓存、Shuffle中间文件、广播变量。

1.RDD缓存 指的是将RDD以缓存的形式物化到内存或磁盘的过程。 对于一些计算成本和访问频率都比较高的RDD来说,缓存有两个好处:一是通过截断DAG,可以降低失败重试的计算开销;二是通过对缓存内容的访问,可以有效减少从头计算的次数,从整体上提升作业端到端的执行性能。

2.Shuffle中间文件: 我们先来简单理解一下Shuffle的计算过程,Shuffle的计算过程可以分为2个阶段:

  • Map阶段:Shuffle writer按照Reducer的分区规则将中间数据写入本地磁盘
  • Reduce阶段:Shuffle reader从各个节点下载数据分片,并根据需要进行聚合计算。注意应为Shuffle read需要从各个节点上拉取数据,如果上游小文件过多,磁盘io速度和网络传输速度又慢,就可能导致任务因为shuffle.read超时报错。
    shuffle read

Shuffle中间文件实际上就是Shuffle Map阶段的输出结果,这些结果会以文件的形式暂存于本地磁盘。在Shuffle Reduce阶段,Reducer通过网络拉取这些中间文件用于聚合计算,如求和、计数等。在集群范围内,Reducer想要拉取属于自己的那部分中间数据,就必须要知道这些数据都存储在哪些节点,以及什么位置。而这些关键的元信息 就是由Spark存储系统保存并维护的。 因此没有存储系统,Shuffle是玩不转的。

3.广播变量:广播变量往往用于在集群范围内分发访问频率较高的小数据。利用存储系统,广播变量可以在Executors进程范畴内保存全量数据。 这样一来,对于同一Executors内的所有计算任务,应用就能够以Process local的本地性级别,来共享广播变量中携带的全量数据。

这3个服务对象是Spark应用性能调优的有力抓手,而它们又和存储系统有着密切的联系,因此想要有效运用这3个方面的调优技巧,我们就必须对spark的存储系统有足够的理解。

存储系统的基本组件有哪些?

与调度系统类似,Spark存储系统是一个囊括了众多组件的复合系统,如BlockManager、BlockManagerMaster、MemoryStore、DiskStore和DiskBlockManager等等。

其中BlockManager是最重要的组件,它在Executors端负责统一管理和协调数据的本地存取与跨节点传输。

  • 对外,BlockManager与Driver端的BlockManagerMaster通信,不仅定期向BlockManagerMaster汇报本地数据元信息,还会不定时按需拉取全局数据存储状态。另外,不同Executors的BlockManager之间也会以Server/Client模式跨节点推送和拉取数据块。
  • 对内,BlockManager通过组合存储系统内部组件的功能来实现数据的存取、收发。

那么,对于RDD缓存、Shuffle中间文件和广播变量这3个服务对象来说,BlockManager又是如何存储的呢?Spark存储系统提供了两种存储抽象:MemoryStore和DiskStore。BlockManager正式用它们来分别管理数据在内存和磁盘中的存取。

其中,广播变量的全量数据存储在Executors进程中,因此它由MemoryStore管理。Shuffle中间文件往往会落盘到本地节点,所以这些文件的落盘和访问就要经过DiskStore。相比之下,RDD缓存会稍微复杂一点,由于RDD缓存支持内存缓存和磁盘缓存两种模式(具体的缓存级别由我们调用rdd.cache()时指定)。因此对于rdd缓存来说需要视情况而定,缓存在内存中的数据会由MemoryStore管理,缓存在磁盘上的数据则交给DiskStore管理。

有了MemoryStore和DiskStore,我们暂时解决了数据存在哪的问题,但是这些数据该以什么形式存储到MemoryStore和DiskStore呢?对于数据的存储形式,Spark存储系统支持两种类型:对象值(Object Values)和字节数组(Byte Array)。 它们之间可以相互转换,其中,对象值压缩为字节数组的过程叫做序列化,而字节数组还原成原始对象值的过程叫做反序列化。

对象值这种存储形式的优点是拿来即用,所见即所得,缺点是所需的存储空间较大。相比之下,序列化字节数组的空间利用率要高得多,不过我们如果需要其中存储的对象就需要先进行反序列化。

由此可见,对象值和字节数组之间存在着一种博弈关系, 也就是所谓的以空间换时间或者以时间换空间。两者应该如何取舍决定于具体的应用常见。核心原则是:如果想省空间,可以优先考虑字节数组;如果想以最快的速度访问对象,那么对象数组更直接一点。 不过,这种选择的烦恼只存在于MemoryStore中,DiskStore因为涉及到数据落盘,所以只能存储序列化后的字节数组。毕竟凡是落盘的东西 都需要先进行序列化。

透过RDD缓存看MemoryStore

接下来,我们接着说说MemoryStore和DiskStore这两个组件是怎么管理内存和磁盘数据的。

刚刚我们提到,MemoryStore同时支持对象值和字节数组这两种不同的数据形式,并且统一采用MemoryEntry数据抽象对它们进行封装。

MemoryEntry有两个子实现类:DeserializedMemoryEntry和SerializedMemoryEntry,分别用于封装原始对象值和序列化之后的字节数组。DeserializedMemoryEntry用Array[T]来存储对象值序列,其中T是对象类型,而SerializedMemoryEntry使用ByteBuffer来存储序列化后的字节序列。

得益于MemoryEntry对对象值和字节数组的统一封装,MemoryStore能够借助一种高效的数据结构来统一存储与访问数据块:LinkedHashMap[BlockId,MemoryEntry] 链式hash表,其中key为BlockId,Value是MemoryEntry的链式哈希字典。在这个字典中,一个Block对应一个MemoryEntry。显然,这里的MemoryEntry既可以是DeserializedMemoryEntry,也可以是SerializedMemoryEntry。有了这个字典,我们就可以通过BlockId方便的查找和定位MemoryEntry,实现数据块的快速存取。

接下来,为了更好的理解,我们以RDD缓存为例,来看看存储系统是如何利用这些数据结构,把RDD封装的数据实体缓存到内存里去。

在RDD的语境下,我们往往用数据分片(Partitions/Splits)来表示一份分布式数据,但在存储系统的语境下,我们经常会用数据块(Blocks)来表示数据存储的基本单位。但在逻辑关系上,RDD的数据分片和存储系统的Block是一一对应的,也就是说一个RDD的数据分片会被物化成一个内存或磁盘上的Block。

因此,如果用一句话来概括缓存RDD的过程,就是将RDD计算数据的迭代器(Iterator)进行物化的过程。
具体来说,分为三步走。
rdd缓存
第一步:既然要把数据内容缓存下来,就需要先把RDD的迭代器展开成实实在在的数据值。因此,先通过调用putIteratorAsValues或是putIteratorAsBytes方法,通过RDD迭代器遍历数据,把需要缓存的数据块中的数据暂存到一个叫做ValuesHolder的数据结构里,这一步,通常叫做"Unroll" 。

第二步:为了节省内存开销,我们可以在存储数据值的ValuesHolder上分别直接调用toArray或者是toByteBuffer操作,把ValuesHolder转换为MemoryEntry数据结构。 需要注意的是,这一步的转换不涉及内存拷贝,也不产生额外的内存开销,因此Spark官方把这一步叫做"从Unroll memory 到Storage memory的Transfer"。

第三步:这些包含RDD数据值的MemoryEntry和与之对应的BlockId(缓存的是rdd:rdd.cache(),rdd的每个分区对应的又是一个个block,所以这里为了找到物理存储系统中的数据块,key存的是block),会被一起存入Key为BlockId、Value是MemoryEntry引用的链式哈希字典中。 因此,LinkedHashMap[BlockId,MemoryEntry]中缓存的是关于rdd数据缓存的元数据,MemoryEntry才是真正保存RDD数据实体的存储单元。并且MemoryEntry引用指向的RDD缓存存储在的位置是java虚拟机的堆内存上(Storage Memory)。 总结来说,大面积占用内存的不是哈希字典,而是一个又一个的MemoryEntry。

总的来说,RDD数据分片、Block和MemoryEntry三者之间是一一对应的,当所有的RDD数据分片都物化为MemoryEntry,并且所有的(Block ID,MemoryEntry)对都记录到LinkedHashMap字典之后,RDD就完成了数据缓存到内存的过程。

这里的问题是:“如果内存空间不足以容纳整个RDD怎么办?” Spark会按照LRU策略逐一清除字典中最近、最久未使用的Block以及对应的MemoryEntry。可以联想一下一个task中处理多个分区数据时,为什么执行的这么慢,因为内存和磁盘之间需要频繁的换入换出。

透过Shuffle看DiskStore

相比MemoryStore,DiskStore不需要那么多的中间数据结构才能完成数据的存取。DiskStore中数据的存取本质上就行字节序列与磁盘文件之间的转换,它通过putBytes方法把字节序列存入磁盘文件,再通过getBytes的方法读入磁盘文件。

不过,要想完成两者之间的转换,像数据块与文件的对应关系、文件路径等等这些元数据时必不可少的。MemoryStore采用了链式哈希字典来维护类似的元数据,DiskStore并没有自己维护这些元数据,而是请了DiskBlockManager这个给力的帮手。

DiskBlockManager的主要职责就是,记录逻辑物理块Block与磁盘文件系统中物理文件的对应关系,每个Block都对应一个磁盘文件。 同理,每个磁盘文件都有一个与之对应的Block ID,类似货架上的每一件货物都有唯一的ID标识。

DiskBlockManager在初始化的时候,首先根据配置项spark.local.dir在磁盘的对应文件上创建文件目录。然后,在spark.local.dir指定的所有目录下分别创建子目录,子目录的个数由配置项spark.diskStore.subDirectories控制,默认是64。这些目录均用于存储通过DiskStore物化的数据文件,比如RDD缓存文件、Shuffle中间结果文件等。

DiskBlockManager2
接下来,我们以Shuffle中间文件为例,来说说DiskStore与DiskBlockManager的交互过程。

Spark默认采用SortShuffleManager来管理Stages间的数据分发,在Shuffle write过程中,有3类结果文件:temp_shuffle_XXX、shuffle_XXX.data和shuffle_XXX.index。Data文件存储分区数据,它是由temp文件合并而来的,index文件记录data文件内不同分区的offset,temp文件即每次溢写的小文件,最后将被删除。Shuffle中间文件指的就是data文件和index文件。

在Shuffle write的不同阶段,Shuffle manager通过BlockManager调用DiskStore的putBytes方法将数据块写入文件。文件由DiskBlockManager创建,文件名就是PutBytes方法中的Block ID,这些文件将会以temp_shuffle或shuffle开头,保存在spark.local.dir目录下的子目录里。

在Shuffle read阶段,Shuffle manager再次通过BlockManager调用DiskStore的getBytes方法,读取data文件和index文件,经文件内容转化为数据块,最终这些数据块会通过网络分发到Reducer端进行聚合计算。

小结

掌握存储系统可以概括为三步:
第一步,我们要明确存储系统的服务对象:分别是RDD缓存、Shuffle中间文件和广播变量。

  • RDD缓存:一些计算成本和访问频率较高的RDD,可以以缓存的形式物化到内存或磁盘中。这样一来,既可以避免DAG频繁回溯的计算开销,也能有效提升端到端的执行性能,
  • Shuffle:Shuffle中间文件的位置信息,都是由Spark存储系统保存并维护的。
  • 广播变量:利用存储系统,广播变量可以在Executors进程范围内保存全量数据,让任务以Process local的本地性级别,来共享广播变量中携带的全量数据。

第二步,我们要搞清楚存储系统中的两个重要组件:MemoryStore和DiskStore。其中,MemoryStore用来管理数据在内存中的存取,DiskStore用来管理数据在磁盘中的存取。

对于存储系统的3个服务对象来说,广播变量由MemoryStore管理,Shuffle中间结果文件的落盘和访问要经由DiskStore,而RDD缓存因为同时支持内存缓存和磁盘缓存两种模式,所以两种组件都有可能用到。

最后,我们还要理解MemoryStore和DiskStore的工作原理:

MemoryStore支持对象值和字节数组,统一采用MemoryEntry数据抽象对它们进行封装。对象值和字节数组之间存在着一种博弈关系,即"以空间换时间"还是"以时间换空间",两者的取舍要看具体的应用场景。

DiskStore则利用DiskBlockManager维护的数据块与磁盘文件的对应关系,来完成字节序列与磁盘文件之间的转换。

7.内存管理基础:Spark如何高效利用有限的内存空间

Spark是如何充分利用内存的?不同的内存区域之间的关系是什么?他们又是如何划分的?

内存的管理模式

在管理方式上,Spark区分了堆内内存(On-heap Memory)堆外内存(Off-heap Memory)。
其中,堆内内存的申请与释放统一由JVM代劳。 比如说,Spark需要内存来实例化对象,JVM负责从堆内内存分配空间并创建对象,然后把对象的引用返回。最后由Spark保存引用,同时记录内存消耗。反过来也是一样,Spark申请删除对象 同时记录可用内存,也交给JVM完成,JVM把要回收的对象标记为"待删除"后,通过垃圾回收(Garbage Collection,GC)机制将对象清除并真正释放内存。
jvm申请/释放内存
在这样的管理模式下,由于待删除对象需要等待GC才能真正清除内存空间,所以Spark对内存的释放是有延迟的。因此,当Spark尝试估算当前可用内存时,很有可能会高估当前堆内的可用内存空间。

堆外内存则不同,Spark通过调用Unsate api的allocateMemory和freeMemory方法直接在操作系统内存中、释放内存空间,这和C++管理内存的方式很像。 这样的内存管理方式自然不需要垃圾回收机制,就免去了它带来的频繁扫描和垃圾回收带来的性能开销。更重要的是,对当前空间的申请和释放可以精准计算,因此Spark对堆外可用内存的估算会更精确,对内存的利用率也会更有把握。(但是堆内内存的管理可以全部交给JVM完成,比较省心。)

接下来用一个小例子讲一下Spark的内存管理。

地主招租(上):土地划分

有一个地主叫黄四郎,家有良田千顷。黄四郎天天养尊处优,不会亲自下田干活,不过这么多田地不能都空着,所以他想了个办法:收租子

黄四郎把田地分为两块:一块叫"托管田",一块叫"自管田"。

庄稼丰收之后,田地需要翻土、整平、晾晒,来年才能种下一茬庄稼。托管田就是丰收之后,黄四郎派人帮你完成这些琐事,你只管种田。自管田就是除了庄稼你自己种,秋收之后的田地你也自己收拾。

这里托管田和自管田类比的是JVM的堆内内存和堆外内存。

毫无疑问的是,对租户来说托管田更省心一些,自管田更麻烦。但是相比自管田,托管田的成本也更高。
jvm堆内内存和堆外内存
这里黄四郎的托管田就是JVM的堆内内存,自管田类比的是堆外内存,田地的翻土、整平这些操作实际上就是JVM中的GC。

内存区域的划分

黄四郎的故事先讲到这里,我们先回到Spark的内存管理上。现在,我们知道了Spark使用的内存用到了JVM的堆内内存和堆外内存两种模式,那么Spark是怎么划分内存区域的呢?

我们先说堆外内存。Spark把堆外内存划分成了两块区域:一块用于执行分布式任务,如Shuffle、Sort、Aggregate等操作,这部分内存叫做Execution Memory; 一块用于缓存RDD和广播变量等数据,它被叫做Storage Memory。

堆内内存的划分方式和堆外差不多,Spark也会划分出用于执行和缓存的两份内存空间。不仅如此,Spark还在堆内会划分出一片叫做User Memory 的内存空间,它用于存储开发者自定义的数据结构。
spark内存管理
除此以外,Spark在堆内还会预留出一小部分内存空间,叫做Reserved Memory, 它被用来存储各种Spark内部对象,比如存储系统中的BlockManager、DiskBlockManager等等。

对于性能调优来说,我们能有比较大的发挥空间的是:Execution Memory、Storage Memory和User Memory。一般预留内存(Reserved Memory)我们是不动的,因为这块内存仅服务于Spark内部对象,业务应用不会染指。
spark内存空间划分

执行内存与缓存内存

在所有的内存区域中,最重要的无疑就是Execution Memory和Storage Memory了。内存计算对应的两层含义:1.数据集的缓存。 2.Stage内的流水线计算。 对应的也分别是Storage Memory和Execution Memory。

在Spark 1.6版本之前,Execution Memory和Storage Memory内存区域的空间划分是静态的,一旦空间划分完毕,不同内存区域的用途就固定了。也就是说,即使没有缓存任何RDD或是广播变量,Storage Memory区域的空闲内存也不能被Execution Memory强占,用来执行Shuffle中的映射、排序或聚合等操作。因此,宝贵的内存资源就这么白白浪费掉了。

考虑到上述静态内存划分的潜在的空间浪费,在1.6版本之后,Spark推出了统一内存管理模式。统一内存管理值的是Execution Memory和Storage Memory之间可以相互转化, 尽管两个区域由配置项 spark.memory.storageFraction划定了初始大小,但在运行时,结合任务负载的是实际情况,Storage Memory区域可能用于任务执行(如Shuffle),Execution Memory区域也可能用于存储RDD缓存。

我们都知道的是,执行任务相比缓存任务,一般在内存抢占上都有着更高的优先级。但是为什么是这样呢?本着打破砂锅问到底的精神,我们来探索一下深层次的原因。

首先,执行任务主要分为两类:一类是Shuffle Map阶段的数据转换、映射、排序、聚合、归并等操作;另一类是Shuffle Reduce阶段的数据排序和聚合操作。它们所设计的数据结构,都需要消耗执行内存。

我们先假设,如果执行内存和缓存任务在内存抢占上都遵循公平公正公开的原则,不管谁抢占了对方的内存,当对方有需要时都会立即释放。假设刚开始双方的预设比例分别是50%,但因为缓存任务在应用中比较靠后的位置,所以执行内存先占据了80%的内存空间,当缓存任务追上后,执行内存释放30%的内存空间还给缓存任务。

这种情况下会发生什么? 假设集群中共有80个CPU,也就是集群在任意时刻的并行计算能力都是80个分布式任务。在抢占了80%内存的情况下,80个CPU可以充分利用,每个CPU的计算负责都是比较饱满的。

但是,由于有30%的内存要还给缓存任务,这就意味着有30个并行的执行任务将没有内存可用,也就是说会有30个CPU一直处在I/O wait的状态,没法干活! 宝贵的CPU计算资源就这么被白白浪费掉了,即使暴殄天物。

因此,相比缓存任务,执行任务的抢占优先级一定要更高,其实就是希望充分的压榨CPU。对资源的优先级做区分的话 CPU>内存>磁盘。

但是,即使执行任务的抢占优先级更高,在内存抢占的时候也要遵循着一定的规则,接下来我们以地主招租下篇的故事为例,说说Execution Memory和Storage Memory在内存分配上遵循的一些规则。

地主招租(下):租地协议

黄四郎招租的协议贴出去没多久,有两个人过来租地。一个是黄五郎,是黄四郎的远房亲戚,来投奔黄四郎。一个是张麻子。张麻子打算把地租过来种小麦、玉米这些的庄稼,黄五郎不这么想,他想把田地租过来种棉花、咖啡这类经济作物。

黄四郎看到两个人租地,想多关照一下自己的侄子,又不想打击麻子的积极性。需要想个万全之策。

黄四郎想了个办法:丈量土地后,在中间划一道线,一边归黄五郎,一边归张麻子。两人原本在自己划定的田地里耕作。但是进度快的人,把自己的田地种满了以后,可以跨过分界线,去占用对方还空着的田地。

并且,如果麻子种地勤快,干活也快,先占了黄五郎的地,种上了小麦 玉米。五郎后来居上,想要收回自己的地,那么没说的,张麻子得把多占的地让出来,不管庄稼熟没熟,麻子都得把地铲平,还给五郎种棉花和咖啡。
例子
反过来,如果五郎更勤快,先占了麻子的地,麻子后来居上想要收回。这个时候,麻子不能强行收回自己的地,五郎有权继续占用麻子的地,直到地上种的棉花、咖啡都丰收了,五郎才把多占的地让出来。

黄五郎听了很开心,张麻子不爽。但是由于黄五郎和黄四郎的亲戚关系,也不好多说什么,只能心想:反正我勤快点,先把地种满了就行了。于是,三人击掌为誓,达成协议。

地主招租的故事先讲到这里。不难发现,黄五郎的地类比的是Execution Memory,张麻子的地类比的是Storage Memory。他们之间的协议就其实就是Execution Memory和Storage Memory之间的抢占规则,一共可以总结为3条:

  • 如果对方的内存空间有空闲,双方就都可以抢占。
  • 对于RDD缓存任务抢占的执行内存,当执行任务有内存需要时,RDD缓存任务必须立即归还抢占的内存,涉及的RDD缓存数据要么落盘、要么清除。
  • 对于分布式计算任务抢占的Storage Memory内存空间,即便RDD缓存任务有收回内存的需要,也要等到任务执行完毕后才能释放。
    RDD执行/缓存内存

从代码看内存消耗

说完了理论部分,我们用一个小例子直观感受一下,应用中代码的不同部分都消耗了哪些内存区域。

示例代码的目的是读取words.csv文件,然后对其中指定的单词进行统计计数。

val dict:List[String] = List("spark","scala")
val words:RDD[String] = sparkContext.textFile("~/words.csv")
val keywords:RDD[String] = words.filter(word => dict.contains(word))
keywords.cache
keywords.count
keywords.map((_,1)).reduceByKey(_ + _).collect

接下来我们逐行分析:

第一行定义了dict字典,这个字典在Driver端生成,它在后续的RDD调用种会随着任务一起分发到Executor端。

第二行读取words.csv文件并生成RDD words。

第三行很关键,用dict字典对words进行过滤,此时dict已经被分发到了Executor端。Executor将其存储在堆内存中,用于对words数据分片中的字符串进行过滤。Dict字典属于开发者自定义数据结构,因此,Executor将其存储在User Memory区域。

第四行和第五行用cache和count对keywords RDD进行缓存,以备后续频繁访问。分布式数据集的缓存占用的正是Storage Memory内存区域。

在最后一行代码中,我们在keywords上调用reduceByKey对单词分别进行计数,reduceByKey算子会引入Shuffle,而Shuffle过程中涉及到的内部数据结构,如映射、排序、聚合等操作所仰仗的Buffer、Array和HashMap等,都会消耗Execution Memory区域中的内存。
代码消耗内存一览

  • 8
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值