大数据Spark实战第二集 Spark数据结构\运行环境和计算框架

86 篇文章 49 订阅

Spark 抽象、架构与运行环境

本课时我们进入:“Spark 抽象、架构与运行环境”的学习。从这个模块开始,我们会开始学习 Spark 的具体技术,本模块的内容主要包含两部分:

  • Spark 背后的工程实现;
  • Spark 的基础编程接口。

注意,本模块的内容对于工程师来说比较重要,需要扎实掌握。

我将从 3 个方面对本课时的内容进行讲解,主要是:

  • Spark 架构;
  • Spark 抽象;
  • Spark 运行环境。
Spark 架构

前面讲过,在生产环境中,Spark 往往作为统一资源管理平台的用户,向统一资源管理平台提交作业,作业提交成功后,Spark 的作业会被调度成计算任务,在资源管理系统的容器中运行。在集群运行中的 Spark 架构是典型的主从架构,如下面这张图所示。这里稍微插一句,所有分布式架构无外乎两种,一种是主从架构(master/slave),另一种是点对点架构(p2p)。

我们先来看看 Spark 架构,在运行时,Driver 无疑是主节点,而 Executor 是从节点,当然,这 3 个 Executor 分别运行在资源管理系统中的 3 个容器中。

1.png

在 Spark 的架构中,Driver 主要负责作业调度工作,Executor 主要负责执行具体的作业计算任务,Driver 中的 SparkSession 组件,是 Spark 2.0 引入的一个新的组件,曾经我们熟悉的 SparkContext、SqlContext、HiveContext 都是 SparkSession 的成员变量。

因此,用户编写的 Spark 代码是从新建 SparkSession 开始的。其中 SparkContext 的作用是连接用户编写的代码与运行作业调度以及任务分发的代码。当用户提交作业启动一个 Driver 时,会通过 SparkContext 向集群发送命令,Executor 会遵照指令执行任务。一旦整个执行过程完成,Driver 就会结束整个作业。这么说稍微有点抽象,你可以通过下面这张图更细致的感受这个过程。

2.png

比起前面那张图,该图更像是调大了放大镜倍数的展示结果,能让我们将 Driver 与 Executor 之间的运行过程看得更加清楚。

  • 首先,Driver 会根据用户编写的代码生成一个计算任务的有向无环图(Directed Acyclic Graph,DAG),这个有向无环图是 Spark 区别 Hadoop MapReduce 的重要特征;
  • 接着,DAG 会根据 RDD(弹性分布式数据集,图中第 1 根虚线和第 2 根虚线中间的圆角方框)之间的依赖关系被 DAG Scheduler 切分成由 Task 组成的 Stage,这里的 Task 就是我们所说的计算任务,注意这个 Stage 不要翻译为阶段,这是一个专有名词,它表示的是一个计算任务的集合
  • 最后 TaskScheduler 会通过 ClusterManager 将 Task 调度到 Executor 上执行。

可以看到,Spark 并不会直接执行用户编写的代码,而用户代码的作用只是告诉 Spark 要做什么,也就是一种“声明”。看到这里,或许你可以大致明白 Spark 的执行流程,但可能对于一些概念还是会有些不清楚,这个问题,我们将通过下面的内容进行解答。

Spark 抽象

当用户编写好代码向集群提交时,一个作业就产生了,作业的英文是 job,在 YARN 中,则喜欢把作业叫 application,它们是一个意思。Driver 会根据用户的代码生成一个有向无环图,下面这张图就是根据用户逻辑生成的一个有向无环图。

3.png

仔细看这张图,可以大概反推出计算逻辑:A 和 C 都是两张表,在分别进行分组聚合和筛选的操作后,做了一次 join 操作。

在上图中,灰色的方框就是我们所说的分区(partition),它和计算任务是一一对应的,也就是说,有多少个分区,就有多少个计算任务,显然的,一个作业,会有多个计算任务,这也是分布式计算的意义所在,我们可以通过设置分区数量来控制每个计算任务的计算量。在 DAG 中,每个计算任务的输入就是一个分区,一些相关的计算任务所构成的任务集合可以被看成一个 Stage,这里"相关"指的是某个标准,我们后面会讲到。RDD 则是分区的集合(图中 A、B、C、D、E),用户只需要操作 RDD 就可以构建出整个 DAG,从某种意义上来说,它就是为了掩盖上面的概念而存在的。

在明白上面的概念后,我们来看看 Executor,一个 Executor 同时只能执行一个计算任务,但一个 Worker(物理节点)上可以同时运行多个 Executor。Executor 的数量决定了同时处理任务的数量,一般来说,分区数远大于 Executor 数量才是合理的。

所以同一个作业,在计算逻辑不变的情况下,分区数和 Executor 的数量很大程度上决定了作业运行的时间。

Spark 运行环境

在上个课时讲到如何部署 Spark 时,已经讲到了 Spark 的运行环境,这里主要聊聊如何基于某个运行环境初始化 SparkSession。我们先来看看 Scala 版本,我们在前面准备好的 Scala 项目中,写下如下代码:

import org.apache.spark.sql.SparkSession

val spark = SparkSession
.builder()
.master(“yarn-client”)
.appName(“New SS”)
.config(“spark.executor.instances”, “10”)
.config(“spark.executor.memory”, “10g”)
.getOrCreate()

import spark.implicits._

执行到这里,SparkSession 就初始化完成了,后面用户就可以开始实现自己的数据处理逻辑,不过你可能已经注意到了,在代码中,我们通过配置指明了 Spark 运行环境时的 YARN,并且是以 yarn-client 的方式提交作业(YARN 还支持 yarn-cluster 的方式,区别在与前者 Driver 运行在客户端,后者 Driver 运行在 YARN 的 Container 中)。

另外值得注意的一点是,我们一共申请了 10 个 Executor,每个 10g,不难算出一共 100g。按照前面的结论,是不是改成 100 个 Executor,每个 1g,作业执行速度会大大提升呢?这个问题的答案是不确定。因为在总量不变的情况下,每个 Executor 的资源减少为原来的十分之一,那么 Executor 有可能无法胜任单个计算任务的计算量(或许能,但是完成速度大大降低),这样你就不得不提升分区数来降低每个计算任务的计算量,所以完成作业的总时间有可能保持不变,也有可能还会增加,当然,也有可能降低。

看到这里,你可能已经对作业的性能调参有点感觉了,其实和机器学习的调参类似,都是在一定约束下(这里就是资源),通过超参数的改变,来实现某个目标(作业执行时间)的最优化。当然,这里要特别说明的是,此处为了简化,只考虑了 Executor 的资源,没有考虑 Driver 所需的资源,另外资源也简化为一个维度:内存,而没有考虑另一个维度 CPU。

最后来看看 Python 版代码:

from pyspark.sql import SparkSession

spark = SparkSession
.builder
.master(“yarn-client”)
.appName(“New SS”)
.config(“spark.executor.instances”, “10”)
.config(“spark.executor.memory”, “10g”)
.getOrCreate()

小结

本课时主要向你介绍了 Spark 的架构,以及作业执行的原理,第 2 部分向你介绍了 Spark 的几个关键抽象,也就是关键概念,最后一部分,我们做了一点点实践:初始化 SparkSession,为后面的学习打下基础。最后再留一个思考题,如何通过配置指定每个Executor所需的CPU资源。

另外,在上面的代码中,我们都是通过硬编码来指定配置,同 Java 一样,Spark 也支持直接通过命令行参数进行配置,而这也是官方推荐的。


Spark 核心数据结构:弹性分布式数据集 RDD

这个课时我们将进入:“Spark 核心数据结构:弹性分布式数据集 RDD”的学习,今天的课程内容有两个:RDD 的核心概念以及实践环节:如何创建 RDD。

RDD 的核心概念

RDD 是 Spark 最核心的数据结构,RDD(Resilient Distributed Dataset)全称为弹性分布式数据集,是 Spark 对数据的核心抽象,也是最关键的抽象,它实质上是一组分布式的 JVM 不可变对象集合,不可变决定了它是只读的,所以 RDD 在经过变换产生新的 RDD 时,(如下图中 A-B),原有 RDD 不会改变。

弹性主要表现在两个方面:

  • 在面对出错情况(例如任意一台节点宕机)时,Spark 能通过 RDD 之间的依赖关系恢复任意出错的 RDD(如 B 和 D 可以算出最后的 RDD),RDD 就像一块海绵一样,无论怎么挤压,都像海绵一样完整;
  • 在经过转换算子处理时,RDD 中的分区数以及分区所在的位置随时都有可能改变。

图片1.png

每个 RDD 都有如下几个成员:

  • 分区的集合;
  • 用来基于分区进行计算的函数(算子);
  • 依赖(与其他 RDD)的集合;
  • 对于键-值型的 RDD 的散列分区器(可选);
  • 对于用来计算出每个分区的地址集合(可选,如 HDFS 上的块存储的地址)。

如下图所示,RDD_0 根据 HDFS 上的块地址生成,块地址集合是 RDD_0 的成员变量,RDD_1由 RDD_0 与转换(transform)函数(算子)转换而成,该算子其实是 RDD_0 内部成员。从这个角度上来说,RDD_1 依赖于 RDD_0,这种依赖关系集合也作为 RDD_1 的成员变量而保存。

图片2.png

在 Spark 源码中,RDD 是一个抽象类,根据具体的情况有不同的实现,比如 RDD_0 可以是 MapPartitionRDD,而 RDD_1 由于产生了 Shuffle(数据混洗,后面的课时会讲到),则是 ShuffledRDD。

下面我们来看一下 RDD 的源码,你也可以和前面对着看看:

// 表示RDD之间的依赖关系的成员变量
@transient private var deps: Seq[Dependency[_]]
// 分区器成员变量
@transient val partitioner: Option[Partitioner] = None
// 该RDD所引用的分区集合成员变量
@transient private var partitions_ : Array[Partition] = null
// 得到该RDD与其他RDD之间的依赖关系
protected def getDependencies: Seq[Dependency[_]] = deps
// 得到该RDD所引用的分区
protected def getPartitions: Array[Partition]
// 得到每个分区地址
protected def getPreferredLocations(split: Partition): Seq[String] = Nil
// distinct算子
def distinct(numPartitions: Int)(implicit ord: Ordering[T] = null): RDD[T] = 
withScope  {
    map(x => (x, null)).reduceByKey((x, y) => x, numPartitions).map(_._1)
}

其中,你需要特别注意这一行代码:

@transient private var partitions_ : Array[Partition] = null

它说明了一个重要的问题,RDD 是分区的集合,本质上还是一个集合,所以在理解时,你可以用分区之类的概念去理解,但是在使用时,就可以忘记这些,把其当做是一个普通的集合。为了再加深你的印象,我们来理解下模块 1 中 01 课时的 4 行代码:

val list: List[Int] = List(1,2,3,4,5)
println(list.map(x => x + 1).filter { x => x > 1}.reduce(_ + _))
......
val list: List[Int] = spark.sparkContext.parallelize(List(1,2,3,4,5))
println(list.map(x => x + 1).filter { x => x > 1}.reduce(_ + _))

实践环节:创建 RDD

我一直强调,Spark 编程是一件不难的工作,而事实也确实如此。在上一课时我们讲解了创建 SparkSession 的代码,现在我们可以通过已有的 SparkSession 直接创建 RDD。在创建 RDD 之前,我们可以将 RDD 的类型分为以下几类:

  • 并行集合;
  • 从 HDFS 中读取;
  • 从外部数据源读取;
  • PairRDD。

了解了 RDD 的类型,接下来我们逐个讲解它们的内容:

并行化集合

这种 RDD 纯粹是为了学习,将内存中的集合变量转换为 RDD,没太大实际意义。

//val spark: SparkSession = .......
val rdd = spark.sparkcontext.parallelize(Seq(1, 2, 3))
从 HDFS 中读取

这种生成 RDD 的方式是非常常用的,

//val spark: SparkSession = .......
val rdd = spark.sparkcontext.textFile("hdfs://namenode:8020/user/me/wiki.txt")
从外部数据源读取

Spark 从 MySQL 中读取数据返回的 RDD 类型是 JdbcRDD,顾名思义,是基于 JDBC 读取数据的,这点与 Sqoop 是相似的,但不同的是 JdbcRDD 必须手动指定数据的上下界,也就是以 MySQL 表某一列的最值作为切分分区的依据。

//val spark: SparkSession = .......
val lowerBound = 1
val upperBound = 1000
val numPartition = 10
val rdd = new JdbcRDD(spark.sparkcontext,() => {
       Class.forName("com.mysql.jdbc.Driver").newInstance()
       DriverManager.getConnection("jdbc:mysql://localhost:3306/db", "root", "123456")
   },
   "SELECT content FROM mysqltable WHERE ID >= ? AND ID <= ?",
   lowerBound, 
   upperBound, 
   numPartition,
   r => r.getString(1)
)

既然是基于 JDBC 进行读取,那么所有支持 JDBC 的数据库都可以通过这种方式进行读取,也包括支持 JDBC 的分布式数据库,但是你需要注意的是,从代码可以看出,这种方式的原理是利用多个 Executor 同时查询互不交叉的数据范围,从而达到并行抽取的目的。但是这种方式的抽取性能受限于 MySQL 的并发读性能,单纯提高 Executor 的数量到某一阈值后,再提升对性能影响不大。

上面介绍的是通过 JDBC 读取数据库的方式,对于 HBase 这种分布式数据库来说,情况有些不同,HBase 这种分布式数据库,在数据存储时也采用了分区的思想,HBase 的分区名为 Region,那么基于 Region 进行导入这种方式的性能就会比上面那种方式快很多,是真正的并行导入。

//val spark: SparkSession = .......
val sc = spark.sparkcontext
val tablename = "your_hbasetable"
val conf = HBaseConfiguration.create()
conf.set("hbase.zookeeper.quorum", "zk1,zk2,zk3")
conf.set("hbase.zookeeper.property.clientPort", "2181")
conf.set(TableInputFormat.INPUT_TABLE, tablename)
val rdd= sc.newAPIHadoopRDD(conf, classOf[TableInputFormat],
classOf[org.apache.hadoop.hbase.io.ImmutableBytesWritable],
classOf[org.apache.hadoop.hbase.client.Result]) 
// 利用HBase API解析出行键与列值
rdd_three.foreach{case (_,result) => {
    val rowkey = Bytes.toString(result.getRow)
    val value1 = Bytes.toString(result.getValue("cf".getBytes,"c1".getBytes))
}

值得一提的是 HBase 有一个第三方组件叫 Phoenix,可以让 HBase 支持 SQL 和 JDBC,在这个组件的配合下,第一种方式也可以用来抽取 HBase 的数据,此外,Spark 也可以读取 HBase 的底层文件 HFile,从而直接绕过 HBase 读取数据。说这么多,无非是想告诉你,读取数据的方法有很多,可以根据自己的需求进行选择。

通过第三方库的支持,Spark 几乎能够读取所有的数据源,例如 Elasticsearch,所以你如果要尝试的话,尽量选用 Maven 来管理依赖。

PairRDD

PairRDD 与其他 RDD 并无不同,只不过它的数据类型是 Tuple2[K,V],表示键值对,因此这种 RDD 也被称为 PairRDD,泛型为 RDD[(K,V)],而普通 RDD 的数据类型为 Int、String 等。这种数据结构决定了 PairRDD 可以使用某些基于键的算子,如分组、汇总等。PairRDD 可以由普通 RDD 转换得到:

//val spark: SparkSession = .......
val a = spark.sparkcontext.textFile("/user/me/wiki").map(x => (x,x))

小结

本课时带你学习完了 Spark 最核心的概念 RDD,本质上它可以看成是一个分布式的数据集合,它的目的就是隔离分布式数据集的复杂性,你也自己尝试了几种类型的 RDD。在实际情况中,大家经常会遇到从外部数据源读取成为RDD,如果理解了读取的本质,那么无论是什么数据源都能够轻松应对了。

这里我要给你留个思考题:如何指定你创建的 RDD 的分区数?

算子:如何构建你的数据管道?

通过前面一系列课时的学习,我想你基本已经了解进入编程环节前需要掌握的内容,那么本课时就带你正式进入 Spark 编程的学习。

Spark 编程风格主要有函数式,核心是基于数据处理的需求,用算子与 RDD 构建出一个数据管道,管道的开始是输入,管道的末尾是输出。而管道就是声明的处理逻辑,可以说是描述了一种映射方式。

不同种类的算子

RDD 算子主要分为两类,一类为转换(transform)算子,一类为行动(action)算子,转换算子主要负责改变 RDD 中数据、切分 RDD 中数据、过滤掉某些数据等,并按照一定顺序组合。Spark 会将转换算子放入一个计算的有向无环图中,并不立刻执行,当 Driver 请求某些数据时,才会真正提交作业并触发计算,而行动算子就会触发 Driver 请求数据。这种机制与函数式编程思想的惰性求值类似。这样设计的原因首先是避免无谓的计算开销,更重要的是 Spark 可以了解所有执行的算子,从而设定并优化执行计划。

RDD 转换算子大概有 20~30 多个,按照 DAG 中分区与分区之间的映射关系来分组,有如下 3 类:

  • 一对一,如 map;

  • 多对一,如 union;

  • 多对多,如 groupByKey。

而按照 RDD 的结构可以分为两种:

  • Value 型 RDD;

  • Key-Value 型 RDD(PairRDD)。

按照转换算子的用途,我将其分为以下 4 类:

  • 通用类;

  • 数学/统计类;

  • 集合论与关系类;

  • 数据结构类。

在介绍算子时,并没有刻意区分 RDD 和 Pair RDD,你可以根据 RDD 的泛型来做判断,此外,通常两个功能相似的算子,如 groupBy 与 groupByKey,底层都是先将 Value 型 RDD 转换成 Key Value 型 RDD,再直接利用 Key Value 型 RDD 完成转换功能,故不重复介绍。

在学习算子的时候,你千万不要觉得这是一个多么高深的东西,首先,对于声明式编程来说,编程本身难度不会太大。其次,我在这里给你交个底,几乎所有的算子,都可以用 map、reduce、filter 这三个算子通过组合进行实现,你在学习完本课时之后,可以试着自己做做。下面,我们就选取这 4 类中有代表性、常用的算子进行介绍。

1. 通用类

这一类可以满足绝大多数需要,特别适合通用分析型需求。

def map[U: ClassTag](f: T => U): RDD[U]
def map(self, f, preservesPartitioning=False)

这里第 1 行为算子的 Scala 版,第 2 行为算子的 Python 版,后面同理,不再做特别说明。

map 算子是最常用的转换算子,它的作用是将原 RDD 分区中 T 类型的数据元素转换成 U 类型,并返回为一个新 RDD。map 算子会作用于分区内的每个元素,如下图所示。

1.png

当然 T 和 U 也可以是同一个类型,具体的转换逻辑由自定义函数 f 完成,可能你会不太适应这种函数直接作为算子的参数,下面以 map 算子为例:

>>> rdd = sc.parallelize(["b", "a", "c"])
>>> sorted(rdd.map(lambda x: (x, 1)).collect())
[('a', 1), ('b', 1), ('c', 1)]

这是 Python 版的,逻辑很简单,对单词进行处理,注意看这里不光是用函数作为参数,而且是用 Python 的匿名函数 lambda 表达式的写法,这种匿名声明的方式是比较常用的。如果是 Scala 版的,匿名函数的写法则更为简单:

rdd.map { x => (x,1) }.collect()

Scala 还有种更简单的写法:

rdd.map ((_,1)).collect()

这种写法我是这样看的,这么写当然更为简洁,但是如果你暂时理解不了,没必要去深究它,直接用上一种写法就好了。

def filter(f: T => Boolean): RDD[T]
def filter(self, f)

filter算子可以通过用户自定义规则过滤掉某些数据,f 返回值为 true 则保留,false 则丢弃,如图:

2.png

该算子作用之后,可能会造成大量零碎分区,不利于后面计算过程,需要在这之前进行合并。

def reduceByKey(func: (V, V) => V): RDD[(K, V)]
def reduceByKey(self, func, numPartitions=None, partitionFunc=portable_hash)

reduceByKey 算子执行的是归约操作,针对相同键的数据元素两两进行合并。在合并之前,reduceByKey 算子需要将相同键的元素分发到一个分区中去,分发规则可以自定义,分发的分区数量也可以自定义,所以该算子还可以接收分区器或者分区数作为参数,分区器在没有指定时,采用的是 RDD 内部的哈希分区器,如下图:

3.png

def groupByKey(): RDD[(K, Iterable[V])]
def groupByKey(self, numPartitions=None, partitionFunc=portable_hash)

groupByKey 在统计分析中很常用到,是分组计算的前提,它默认按照哈希分区器进行分发,将同一个键的数据元素放入到一个迭代器中供后面的汇总操作做准备,它的可选参数分区数、分区器,如下图:

4.png

def flatMap[U: ClassTag](f: T => TraversableOnce[U]): RDD[U]
def flatMap(self, f, preservesPartitioning=False)

flatMap 算子的字面意思是“展平”,flatMap 算子的函数 f 的作用是将 T 类型的数据元素转换为元素类型为 U 的集合,如果处理过程到此为止,我们将 RDD_1 的一个分区看成一个集合的话,分区数据结构相当于集合的集合,由于集合的集合是有层次的 你可以理解为一个年级有多个班级,而这种数据结构就不是“平”的,所以 flatMap 算子还做了一个操作:将集合的集合合并为一个集合。如下图:

5.png

2. 数学/统计类

这类算子实现的是某些常用的数学或者统计功能,如分层抽样等。

def sampleByKey(withReplacement: Boolean, fractions: Map[K, Double], seed: Long = Utils.random.nextLong): RDD[(K, V)]
def sampleByKey(self, withReplacement, fractions, seed=None)

分层抽样是将数据元素按照不同特征分成不同的组,然后从这些组中分别抽样数据元素。Spark 内置了实现这一功能的算子 sampleByKey,withReplacement 参数表示此次抽样是重置抽样还是不重置抽样,所谓重置抽样就是“有放回的抽样”,单次抽样后会放回。fractions 是每个键的抽样比例,以 Map 的形式提供。seed 为随机数种子,一般设置为当前时间戳。

3. 集合论与关系类

这类算子主要实现的是像连接数据集这种功能和其他关系代数的功能,如交集、差集、并集、笛卡儿积等。

def cogroup[W](other: RDD[(K, W)]): RDD[(K, (Iterable[V], Iterable[W]))]
def cogroup(self, other, numPartitions=None)

cogroup 算子是很多算子的基础,如 intersection、join 等。简单来说,cogroup 算子相当于多个数据集一起做 groupByKey 操作,生成的 Pair RDD 的数据元素类型为 (K, (Iterable[V], Iterable[W])),其中第 1 个迭代器为当前键在 RDD_0 中的分组结果,第 2 个迭代器为 RDD_1 的结果,如下图:

6.png

def union(other: RDD[T]): RDD[T]
def union(self, other)

union 算子将两个同类型的 RDD 合并为一个 RDD,类似于求并集的操作。如下图:

7.png

4. 数据结构类

这类算子主要改变的是 RDD 中底层的数据结构,即 RDD 中的分区。在这些算子中,你可以直接操作分区而不需要访问这些分区中的元素。在 Spark 应用中,当你需要更高效地控制集群中的分区和分区的分发时,这些算子会非常有用。通常,我们会根据集群状态、数据规模和使用方式有针对性地对数据进行重分区,这样可以显著提升性能。默认情况下,RDD 使用散列分区器对集群中的数据进行分区。一般情况下,集群中的单个节点会有多个数据分区。数据分区数一般取决于数据量和集群节点数。如果作业中的某个计算任务地输入在本地,我们将其称为数据的本地性,计算任务会尽可能地根据本地性优先选择本地数据。

def partitionBy(partitioner: Partitioner): RDD[(K, V)]:
def partitionBy(self, numPartitions, partitionFunc=portable_hash)

partitionBy 会按照传入的分发规则对 RDD 进行重分区,分发规则由自定义分区器实现。

def coalesce(numPartitions: Int, shuffle: Boolean = false, partitionCoalescer: Option [Partition Coalescer] = Option.empty)(implicit ord: Ordering[T] = null): RDD[T]
def repartition(num Partitions: Int)(implicit ord: Ordering[T] = null): RDD[T]

coalesce 会试图将 RDD 中分区数变为用户设定的分区数(numPartitions),从而调整作业的并行程度。如果用户设定的分区数(100)小于 RDD 原有分区数(1000),则会进行本地合并,而不会进行 Shuffle;如果用户设定的分区数大于 RDD 原有分区数,则不会触发操作。如果需要增大分区数,则需要将 shuffle 参数设定为 true,这样数据就会通过散列分区器将数据进行分发,以达到增加分区的效果。

还有一种情况,当用户设置分区数为 1 时,如果 shuffle 参数为 false,会对某些节点造成极大的性能负担,用户可以设置 shuffle 参数为 true 来汇总分区的上游计算过程并行执行。repartition 是 coalesce 默认开启 shuffle 的简单封装。另外,你应该能够注意到,大部分转换算子,都提供了 numPartitions 这个可选参数,意味着在作业流程的每一步,你都可以细粒度地控制作业的并行度,从而提高执行时的性能,但这里你需要注意,提交作业后 Executor 的数量是一定的。

行动算子

行动算子从功能上来说作为一个触发器,会触发提交整个作业并开始执行。从代码上来说,它与转换算子的最大不同之处在于:转换算子返回的还是 RDD,行动算子返回的是非 RDD 类型的值,如整数,或者根本没有返回值。

行动算子可以分为 Driver 和分布式两类。

  • Driver:这种算子返回值通常为 Driver 内部的内存变量,如 collect、count、countByKey 等。这种算子会在远端 Executor 执行计算完成后将结果数据传回 Driver。这种算子的缺点是,如果返回的数据太大,很容易会突破 Driver 内存限制,因此使用这种算子作为作业结束需要谨慎,主要还是用于调试与开发场景

  • 分布式:与前一类算子将结果回传到 Driver 不同,这类算子会在集群中的节点上“就地”分布式执行,如 saveAsTextFile。这是一种最常用的分布式行动算子。

我们先来看看第一种:

def reduce(f: (T, T) => T): T
def reduce(self, f)

与转换算子 reduce 类似,会用函数参数两两进行归约,直到最后一个值,返回值类型与 RDD 元素相同。

def foreach(f: T => Unit): Unit
def foreach(self, f)

foreach 算子迭代 RDD 中的每个元素,并且可以自定义输出操作,通过用户传入的函数,可以实现打印、插入到外部存储、修改累加器等迭代所带来的副作用。

还有一些算子类型如 count、reduce、max 等,从字面意思也很好理解,就不逐个介绍了。

以下算子为分布式类型的行动算子:

def saveAsTextFile(path: String): Unit
def saveAsTextFile(self, path, compressionCodecClass=None)

该算子会将 RDD 输出到外部文件系统中,例如 HDFS。这个在实际应用中也比较常用。

下面来看看几个比较特殊的行动算子,在计算过程中,用户可能会经常使用到同一份数据,此时就可以用到 Spark 缓存技术,也就是利用缓存算子将 RDD 进行缓存,从而加速 Spark 作业的执行速度。Spark 缓存算子也属于行动算子,也就是说会触发整个作业开始计算,想要缓存数据,你可以使用 cache 或者 persist 算子,它们是行动算子中仅有的两个返回值为 RDD 的算子。事实上,Spark 缓存技术是加速 Spark 作业执行的关键技术之一,尤其是在迭代计算的场景,效果非常好。

缓存需要尽可能地将数据放入内存。如果没有足够的内存,那么驻留在内存的当前数据就有可能被移除,例如 LRU 策略;如果数据量本身已经超过可用内存容量,这时由于磁盘会代替内存存储数据,性能会下降。

def persist(newLevel: StorageLevel): this.type 
def cache(): this.type
def unpersist(blocking: Boolean = true): this.type

其中,cache() = persist(MEMORY_ONLY),Spark 在作业执行过程中会采用 LRU 策略来更新缓存,如果用户想要手动移除缓存的话,也可以采用 unpersist 算子手动释放缓存。其中 persist 可以选择存储级别,选项如下:

8.png

如果内存足够大,使用 MEMORY_ONLY 无疑是性能最好的选择,想要节省点空间的话,可以采取 MEMORY_ONLY_SER,可以序列化对象使其所占空间减少一点。DISK是在重算的代价特别昂贵时的不得已的选择。MEMORY_ONLY_2 与 MEMORY_AND_DISK_2 拥有最佳的可用性,但是会消耗额外的存储空间。

小结

本课时介绍了常用的转换算子与行动算子,其中最核心的当然属于通用类的转换算子,其余算子也是非常常用的。另外没有覆盖到的,你可以在使用中通过查阅文档进行学习,这也是学习 Spark 的必经之路。

另外,前面讲到 RDD 是一种分布式集合,那么对于复杂的计算,如果用集合这种形式的数据结构完成计算,未免太复杂、太底层了,也不利于代码的可读性,所以在第 3 个模块中,会学习 Spark 的高级编程接口 DataFrame 和 Spark SQL,如果你是工程师的话,RDD 与算子这种数据处理方式无疑是需要掌握的。

练习题

最后,鉴于本课时的内容需要实践加以巩固,所以特地给你留了 3 道习题:

练习题 1:join 算子其实是 cogroup 和 flatMap 算子组合实现的,现在你自己能实现 join 算子吗?当然你也可以阅读 Spark 源码找到 join 算子的实现方法,也算你对!

练习题 2:用 Spark 算子实现对 1TB 的数据进行排序?这个问题,在第 11 课时,会揭晓答案,但现在还是需要你进行编码。

练习题 3:

SELECT a,  COUNT(b)  FROM t WHERE c > 100 GROUP BY b

这是一句很简单的 SQL,你能用算子将它实现吗?

这 3 道题,你一定要仔细思考,并动手编码,最后别忘了一定要用行动算子触发计算。最后再次强调,由于本课时由专栏的方式呈现,需要你在理解专栏后自己再消化一遍,而要吃透本课时的内容,唯一的办法就是动手编码,所以上面 3 道题请你务必完成。

最后再来回顾一下今天的内容:我们介绍了 Spark 常用的转换算子和行动算子,简言之,转换算子用来声明逻辑,而行动算子用来触发计算。如果你对那种匿名函数作为参数的算子还有些陌生,没关系,下个课时我会对这种编程风格进行解构,让你彻底地理解这种编程风格。


函数式编程思想:你用什么声明,你在声明什么?

今天带给你的内容是“函数式编程:你用什么声明?你在声明什么?”,在上个课时我们学习了 Spark 编程,Spark 编程风格是典型的函数式,很多开发人员在接触到这种编程风格时会有些陌生,本课时的主要目的就对这种编程风格进行一种解构,破除这种风格的神秘感,从理论上降低这种编程风格的难度。当然了,由于函数式编程包含了丰富的理论与实践,所以我们无法面面俱到的进行讲解,而是以标题的两个问题作为切入点来达到本课时的目的,本课时的主要内容有:

  • 函数式编程与声明式编程。
  • 你用什么声明?
  • 你在声明什么?
  • 函数式编程语言的特点。

函数式编程与命令式编程

在 Spark 诞生之初,就有人诟病为什么 AMP 实验室选了一个如此小众的语言:Scala,很多人还将原因归结为这是由于学院派的高冷,但事实证明,选择 Scala 是非常正确的,Scala 很多特性与 Spark 本身理念非常契合,可以说天生一对。Scala 背后所代表的函数式编程思想也变得越来越为人所知。函数式编程思想早在 50 多年前就被提出,但当时的硬件性能太弱,并不能发挥出这种思想的优势。目前多核 CPU 大行其道,函数式编程在并发方面的优势也逐渐显示出了威力。这就好像在 Java 在被发明之初,总是有人说消耗内存太多、运行速度太慢,但是随着硬件性能的翻倍,Java 无疑是一种非常好的选择。

函数式编程属于声明式编程的一种,与声明式编程相对的是命令式编程,命令式编程是按照“程序是一系列改变状态的命令”来建模的一种建模风格,而函数式编程思想是“程序是表达式和变换,以数学方程的形式建立模型,并且尽可能避免可变状态”。函数式编程会有一些类别的操作(算子),如映射、过滤或者归约,每一种都有不同的函数作为代表,如 filter、map、reduce。这些函数实现的是低阶变换(这里就是前面讲的算子),而用户定义的函数将作为这些函数的参数(这里可以理解为高阶变换)来实现整个方程。

命令式编程将计算机程序看成动作的序列,程序运行的过程就是求解的过程,这就好比,阅读一段命令式编程风格的代码,如果不阅读到最后一行,一般来说无法确定程序的目的,这和题目求解过程有异曲同工之妙,在命令式编程中,解题过程由状态的转换来完成,而状态就是我们经常说的变量。而函数式编程则是从结果入手,用户通过函数定义了从最初输入到最终输出的映射关系,从这个角度上来说,用户编写代码描述了用户的最终结果(我想要什么),而并不关心(或者说不需要关心)求解过程,所以函数式编程绝对不会去操作某个具体的值,也就无所谓变量了。

举一个声明式编程的例子,这是用户编写的代码:

SELECT class_no, COUNT(*) FROM student_info GROUP BY class_no

由于 SQL 是很典型的声明式编程,用户只需要告诉 SQL 引擎统计每个班的人数,至于底层是怎么执行的,用户不需要关心。在《数据库系统概论》(第五版)提到:数据库会把用户提交的 SQL 查询转化为等价的扩展关系代数表达式,用户用函数式编程的思想进行编码,其实就是直接描述这个关系代数表达式。

说了这么多函数式与声明式,让我们来看个例子,有一个数据清洗的任务,需要将姓氏集合中的单字符姓名(脏数据)去掉,并将首字母大写,最后再拼成一个逗号分隔的字符串,先来看看命令式的实现(Python 版):

family_names = ["ann","bob","c","david"]
clean_family_names = []
for i in range(len(family_names)):
    family_name = family_names[i]
    if (len(family_name) > 1):
        clean_family_names.append(family_name.capitalize())
print clean_family_names

再来看看函数式(Scala 版)的实现:

val familyNames = List("ann","bob","c","david")
println(
        familyNames.filter(p => p.length() > 1).
        map(f => f.capitalize).
        reduce((a,b) => a + "," + b).toString()
)

从这个例子中我们可以看出,在命令式编程的版本中,只执行了一次循环,在函数式编程的版本里,循环执行了 3 次(filter、map、reduce),每一次只完成一种逻辑(用户编写的匿名函数),从性能上来说,当然前者更为优秀,这说明了在硬件性能羸弱时,函数式的缺点会被放大,但我们也看到了,在函数式编程的版本中不用维护外部状态 i,这种方式对于并行计算场景非常友好。

你用什么声明

在开始学习编程时,我相信当你看到这个代码时,内心某种程度上是抗拒的:

x = x + 1

原因很简单,这个等式在数学上是不成立的。随着我们对某种编程语言理解程度加深,这种感觉很快就消失了。当提到函数式编程,我们还是习惯于将函数式编程中的“函数式”与某种编程语言的函数进行对应理解,但很遗憾,这么做对于理解函数式没什么帮助。而我们最初的感觉才是对的,也就是说,这里的函数式是指我们早已选择刻意遗忘的数学含义上的函数 f(x)。

其实,第 1 个问题的答案已经呼之欲出了。你用什么声明?很显然,函数式就是用函数进行声明的一种编程风格。这里的函数指的是数学含义上的函数。

我们回过头来,看看 08 课时中的算子组成的管道。前面讲过,可以这样来理解:算子实现的是低阶变换,而用户定义的函数将作为这些算子的参数,也就是高阶函数(比如map 算子的函数参数)。这么理解当然没错,但是还是不够直接,我们可以试着从我们初中数学就学过的复合函数的角度来理解。

在严格的函数式编程中,所有函数都遵循数学函数的定义,必须有自变量(入参),必须有因变量(返回值)。用户定义的逻辑以高阶函数(high order)的形式体现,即用户可以将自定义函数以参数形式传入其他低阶函数中。其实从数学的角度上来说,这是很自然的,如下是一个数学表达式:

y = sqrt (x + b)

括号中的函数 f1:x + b 作为参数传给函数 f2 = sqrt(x) ,这是初中的复合函数的用法。相对于高阶函数,函数式语言一般会提供一些低阶函数用于构建整个流程,这些低阶函数都是无副作用的,非常适合并行计算。高阶函数可以让用户专注于业务逻辑,而不需要去费心构建整个数据流。

到了这一步,我相信你能够重新看待你编写的 Spark 代码,你其实就是在用f(x)来声明你计算逻辑,只是这个f(x)可以看成是一个比较复杂的复合函数而已。你编写的代码就是在描述这个f(x),所以说,严格意义上,一个作业的所有代码,都可以用一行代码写完,也就是一个等式来表示。

你在声明什么

这个问题很有意思,我先不正面回答,先来和你聊聊一个小学奥数的话题:定义新运算,定义新运算是小学奥赛的一个考点,也就是说,同学们,只要你小学上过奥数,那么大概率应该是学习过这个内容的,我们来看看百度百科对定义新运算的定义:

定义新运算是指用一个符号和已知运算表达式表示一种新的运算。定义新运算是一种特别设计的计算形式,它使用一些特殊的运算符号,这是与四则运算中的加减乘除符号是不一样的。新定义的算式中有括号的,要先算括号里的,但它在没有转化前,是不适合于各种运算的。

小学六年级奥数中体现的问题,解题方法较简单。解答定义新运算,关键是要正确地理解新定义运算的算式含义。然后严格按照新定义运算的计算程序,将数值代入,转化为常规的四则运算算式进行计算。

定义新运算是一种特殊设计的运算形式,它使用的是一些特殊的运算符号,如:*、Δ 等,这是与四则运算中的加减乘除不同的。 这里有一个问题:当 a≥b=b 时 ab=bxb 当 a<b=a 时 ab=a 当 x=2 时,求 (1x)-(3x) 的值。

解题方法如下:
3△2=3+2+6=11
5△5=5+5+25=35
设 ab=﹙a+b﹚÷3
则 6
﹙5*4﹚=3

相信你能够完全理解上面所说的内容。这里注意,前面一直说的算子,它的英文是 operator,大多数资料翻译为算子,看起来信达雅,但其实掩盖了 operator 的本质,operator 如果直译应该被翻译为运算符。你编写的 Spark 代码与新运算的定义不谋而合,也是用一些已知的运算符(Spark 算子)在定义一种新运算。

现在,结合用运算符和定义新运算的概念,我想你应该能够回答这个问题:你在声明什么?答案很简单:用运算符定义一种新运算

函数式编程语言的一些特点

作为函数式编程思想的实现,函数式编程语言都有一些有趣的共性,另外,如果你把 Spark 看成一门函数式编程语言的话,你会发现,这些共性仍然存在

低阶函数与核心数据结构

如果使用低阶函数与高阶函数来完成我们的程序,这时其实就是将程序控制权让位于语言,而我们专注于业务逻辑。这样做的好处还在于,有利于程序优化,享受免费的性能提升午餐。比如语言开发者专注于优化低阶函数,而应用开发者则专注于优化高阶函数。低阶函数是复用的,因此当低阶函数性能提升时,程序不需要改一行代码就能免费获得性能提升。此外,函数式编程语言通常只提供几种核心数据结构,供开发者选择,它希望开发者能基于这些简单的数据结构组合出复杂的数据结构,这与低阶函数的思想是一致的,很多函数式编程语言的特性会着重优化低阶函数与核心数据结构。但这与面向对象的命令式编程是不一样的,在 OOP 中,面向对象编程的语言鼓励开发者针对具体问题建立专门的数据结构。

通过前几个课时的内容可以看到,Spark 的核心数据结构只有一个,就是 RDD,而其他函数式编程语言,如 Scala,核心数据结构也非常少。

惰性求值

惰性求值(lazy evaluation)是函数式编程语言常见的一种特性,通常指尽量延后求解表达式的值,这样可以对开销大的计算按需计算,利用惰性求值的特性可以构建无限大的集合。惰性求值可以用闭包来实现。Spark 也是采用了惰性求值来触发计算。

函数记忆

由于在函数式编程中,函数本身是无状态的,因此给定入参,一定能得到一定的结果。基于此,函数式语言会对函数进行记忆或者缓存,以斐波那契数列举例,首先用尾递归来实现对斐波那契数列求和,Python 代码如下:

def Fibonacci ( n ):
   if n == 0 :
      res = 0
   elif num == 1:
      res = 1
   else:
      res = Fibonacci ( n - 1 ) + Fibonacci ( n - 2 )
return res

当 n 等于 4 时,程序执行过程是:

Fibonacci (4)
Fibonacci (3)
Fibonacci (2)
Fibonacci (1)
Fibonacci (0)
Fibonacci (1)
Fibonacci (2)
Fibonacci (1)
Fibonacci (0)

为了求 Fibonacci (4),我们执行了 1 次 Fibonacci(3),2 次 Fibonacci(2),3 次 Fibonacci(1),2 次 Fibonacci(0),一共 8 次计算,在函数式编程语言中,执行过程是这样的:

Fibonacci (4)
Fibonacci (3)
Fibonacci (2)
Fibonacci (1)
Fibonacci (0)

一共只用 4 次计算就可求得 Fibonacci(4),后面执行的 Fibonacci(0)、Fibonacci(1) 由于函数式编程语言已经缓存了结果,因此不会重复计算。

在这里,你可以与 cache 算子代表的缓存机制联系起来,Spark 允许用户主动缓存。

小结

本课时的内容偏理论,但是我相信能够引起你一些深入思考,有些时候,务实之后的务虚会收获更多。这里给你留一个思考题:用函数式风格的代码实现 a - b 的逻辑,逻辑非常简单,难点在于用函数式风格来表达。考虑这道题的特殊性,可能大多数不习惯这么表达,所以我把答案附在下面。

List(5).zip(List(4)).map(f => {f._1 - f._2}).foreach(println(_))

你可以思考下这么写的优点与缺点,欢迎与我在学习群中互动。


共享变量:如何在数据管道中使用中间结果?

今天我将为你讲解:如何用共享变量在数据管道中使用中间结果。共享变量是 Spark 中进阶特性之一,一共有两种:

  • 广播变量;
  • 累加器。

这两种变量可以认为是在用算子定义的数据管道外的两个全局变量,供所有计算任务使用。在 Spark 作业中,用户编写的高阶函数会在集群中的 Executor 里执行,这些 Executor 可能会用到相同的变量,这些变量被复制到每个 Executor 中,而 Executor 对变量的更新不会传回 Driver。

在计算任务中支持通用的可读写变量一般是低效的,即便如此,Spark 还是提供了两类共享变量:广播变量(broadcast variable)与累加器(accumulator)。当然,对于分布式变量,如果不加限制会出现一致性的问题,所以共享变量是两种非常特殊的变量。

  • 广播变量:只读;
  • 累加器:只能增加。

广播变量

广播变量类似于 MapReduce 中的 DistributeFile,通常来说是一份不大的数据集,一旦广播变量在 Driver 中被创建,整个数据集就会在集群中进行广播,能让所有正在运行的计算任务以只读方式访问。广播变量支持一些简单的数据类型,如整型、集合类型等,也支持很多复杂数据类型,如一些自定义的数据类型。

广播变量为了保证数据被广播到所有节点,使用了很多办法。这其实是一个很重要的问题,我们不能期望 100 个或者 1000 个 Executor 去连接 Driver,并拉取数据,这会让 Driver 不堪重负。Executor 采用的是通过 HTTP 连接去拉取数据,类似于 BitTorrent 点对点传输。这样的方式更具扩展性,避免了所有 Executor 都去向 Driver 请求数据而造成 Driver 故障。

Spark 广播机制运作方式是这样的:Driver 将已序列化的数据切分成小块,然后将其存储在自己的块管理器 BlockManager 中,当 Executor 开始运行时,每个 Executor 首先从自己的内部块管理器中试图获取广播变量,如果以前广播过,那么直接使用;如果没有,Executor 就会从 Driver 或者其他可用的 Executor 去拉取数据块。一旦拿到数据块,就会放到自己的块管理器中。供自己和其他需要拉取的 Executor 使用。这就很好地防止了 Driver 单点的性能瓶颈,如下图所示。

图片1.png

下面来看看如何在 Spark 作业中创建、使用广播变量。代码如下:

scala> val rdd_one = sc.parallelize(Seq(1,2,3))
rdd_one: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[101] at
parallelize at <console>:25
    scala> val i = 5
    i: Int = 5
scala> val bi = sc.broadcast(i)
bi: org.apache.spark.broadcast.Broadcast[Int] = Broadcast(147)
scala> bi.value
res166: Int = 5
scala> rdd_one.take(5)
res164: Array[Int] = Array(1, 2, 3)
scala> rdd_one.map(j => j + bi.value).take(5)
res165: Array[Int] = Array(6, 7, 8)

在用户定义的高阶函数中,可以直接使用广播变量的引用。下面看一个集合类型的广播变量:

scala> val rdd_one = sc.parallelize(Seq(1,2,3))
    rdd_one: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[109] at
parallelize at <console>:25
scala> val m = scala.collection.mutable.HashMap(1 -> 2, 2 -> 3, 3 -> 4)
    m: scala.collection.mutable.HashMap[Int,Int] = Map(2 -> 3, 1 -> 2, 3 -> 4)
scala> val bm = sc.broadcast(m)
bm:
org.apache.spark.broadcast.Broadcast[scala.collection.mutable.HashMap[Int,I
nt]] = Broadcast(178)
scala> rdd_one.map(j => j * bm.value(j)).take(5)
res191: Array[Int] = Array(2, 6, 12)

该例中,元素乘以元素对应值得到最后结果。广播变量会持续占用内存,当我们不需要的时候,可以用 unpersist 算子将其移除,这时,如果计算任务又用到广播变量,那么就会重新拉取数据,如下:

    ...
scala> val rdd_one = sc.parallelize(Seq(1,2,3))
rdd_one: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[101] at
parallelize at <console>:25
scala> val k = 5
k: Int = 5
scala> val bk = sc.broadcast(k)
bk: org.apache.spark.broadcast.Broadcast[Int] = Broadcast(163)
scala> rdd_one.map(j => j + bk.value).take(5)
res184: Array[Int] = Array(6, 7, 8)
scala> bk.unpersist
scala> rdd_one.map(j => j + bk.value).take(5)
res186: Array[Int] = Array(6, 7, 8)

你还可以使用 destroy 方法彻底销毁广播变量,调用该方法后,如果计算任务中又用到广播变量,则会抛出异常:

scala> val rdd_one = sc.parallelize(Seq(1,2,3))
rdd_one: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[101] at
parallelize at <console>:25
scala> val k = 5
k: Int = 5
scala> val bk = sc.broadcast(k)
bk: org.apache.spark.broadcast.Broadcast[Int] = Broadcast(163)
scala> rdd_one.map(j => j + bk.value).take(5)
res184: Array[Int] = Array(6, 7, 8)
scala> bk.destroy
scala> rdd_one.map(j => j + bk.value).take(5)
17/05/27 14:07:28 ERROR Utils: Exception encountered
org.apache.spark.SparkException: Attempted to use Broadcast(163) after it
was destroyed (destroy at <console>:30)
at org.apache.spark.broadcast.Broadcast.assertValid(Broadcast.scala:144)
at
org.apache.spark.broadcast.TorrentBroadcast$$anonfun$writeObject$1.apply$mc
V$sp(TorrentBroadcast.scala:202)
at org.apache.spark.broadcast.TorrentBroadcast$$anonfun$wri

广播变量在一定数据量范围内可以有效地使作业避免 Shuffle,使计算尽可能本地运行,Spark 的 Map 端连接操作就是用广播变量实现的。

为了让你更好地理解上面那句话的意思,我再举一个比较典型的场景,我们希望对海量的日志进行校验,日志可以简单认为是如下的格式:
表 A:校验码,内容

也就是说,我们需要根据校验码的不同,对内容采取不同规则的校验,而检验码与校验规则的映射则存储在另外一个数据库:
表 B:校验码,规则

这样,情况就比较清楚了,如果不考虑广播变量,我们有这么两种做法:

  1. 直接使用 map 算子,在 map 算子中的自定义函数中去查询数据库,那么有多少行,就要查询多少次数据库,这样性能非常差。
  2. 先将表 B 查出来转化为 RDD,使用 join 算子进行连接操作后,再使用 map 算子进行处理,这样做性能会比前一种方式好很多,但是会引起大量的 Shuffle 操作,对资源消耗不小。

当考虑广播变量后,我们有了这样一种做法(Python 风格伪代码):

###表A
tableA = spark.sparkcontext.textFrom('/path')
###广播表B
validateTable = spark.sparkcontext.broadcast(queryTable())
###验证函数,在验证函数中会取得对应的校验规则进行校验
def validate(validateNo,validateTable ):
......
##统计校验结果
validateResult = tableA.map(validate).reduceByKey((lambda x , y: x + y))
....

这样,相当于先将小表进行广播,广播到每个 Executor 的内存中,供 map 函数使用,这就避免了 Shuffle,虽然语义上还是 join(小表放内存),但无论是资源消耗还是执行时间,都要远优于前面两种方式。

累加器

与广播变量只读不同,累加器是一种只能进行增加操作的共享变量。如果你想知道记录中有多少错误数据,一种方法是针对这种错误数据编写额外逻辑,另一种方式是使用累加器。用法如下:

    ...
scala> val acc1 = sc.longAccumulator("acc1")
acc1: org.apache.spark.util.LongAccumulator = LongAccumulator(id: 10355,
name: Some(acc1), value: 0)
scala> val someRDD = tableRDD.map(x => {acc1.add(1); x})
someRDD: org.apache.spark.rdd.RDD[String] = MapPartitionsRDD[99] at map at
<console>:29
scala> acc1.value
res156: Long = 0 /*there has been no action on the RDD so accumulator did
not get incremented*/
scala> someRDD.count
res157: Long = 351
scala> acc1.value
res158: Long = 351
scala> acc1
res145: org.apache.spark.util.LongAccumulator = LongAccumulator(id: 10355,
name: Some(acc1), value: 351)

上面这个例子用 SparkContext 初始化了一个长整型的累加器。LongAccumulator 方法会将累加器变量置为 0。行动算子 count 触发计算后,累加器在 map 函数中被调用,其值会一直增加,最后定格为 351。Spark 内置的累加器有如下几种。

  • LongAccumulator:长整型累加器,用于求和、计数、求均值的 64 位整数。
  • DoubleAccumulator:双精度型累加器,用于求和、计数、求均值的双精度浮点数。
  • CollectionAccumulator[T]:集合型累加器,可以用来收集所需信息的集合。

所有这些累加器都是继承自 AccumulatorV2,如果这些累加器还是不能满足用户的需求,Spark 允许自定义累加器。如果需要某两列进行汇总,无疑自定义累加器比直接编写逻辑要方便很多,例如:

图片2.png

这个表只有两列,需要统计 A 列与 B 列的汇总值。下面来看看根据上面的逻辑如何实现一个自定义累加器。代码如下:

	import org.apache.spark.util.AccumulatorV2
	import org.apache.spark.SparkConf
	import org.apache.spark.SparkContext
	import org.apache.spark.SparkConf
<span class="hljs-comment">// 构造一个保存累加结果的类</span>
<span class="hljs-function"><span class="hljs-keyword">case</span> class <span class="hljs-title">SumAandB</span><span class="hljs-params">(A: Long, B: Long)</span>
 
class FieldAccumulator extends AccumulatorV2[SumAandB,SumAandB] </span>{

<span class="hljs-keyword">private</span> <span class="hljs-keyword">var</span> A:Long = <span class="hljs-number">0L</span>
<span class="hljs-keyword">private</span> <span class="hljs-keyword">var</span> B:Long = <span class="hljs-number">0L</span>
    <span class="hljs-comment">// 如果A和B同时为0,则累加器值为0</span>
    override def isZero: Boolean = A == <span class="hljs-number">0</span> &amp;&amp; B == <span class="hljs-number">0L</span>
    <span class="hljs-comment">// 复制一个累加器</span>
    <span class="hljs-function">override def <span class="hljs-title">copy</span><span class="hljs-params">()</span>: FieldAccumulator </span>= {
        val newAcc = <span class="hljs-keyword">new</span> FieldAccumulator
        newAcc.A = <span class="hljs-keyword">this</span>.A
        newAcc.B = <span class="hljs-keyword">this</span>.B
        newAcc
    }
    <span class="hljs-comment">// 重置累加器为0</span>
    <span class="hljs-function">override def <span class="hljs-title">reset</span><span class="hljs-params">()</span>: Unit </span>= { A = <span class="hljs-number">0</span> ; B = <span class="hljs-number">0L</span> }
    <span class="hljs-comment">// 用累加器记录汇总结果</span>
    <span class="hljs-function">override def <span class="hljs-title">add</span><span class="hljs-params">(v: SumAandB)</span>: Unit </span>= {
        A += v.A
        B += v.B
    }
    <span class="hljs-comment">// 合并两个累加器</span>
    <span class="hljs-function">override def <span class="hljs-title">merge</span><span class="hljs-params">(other: AccumulatorV2[SumAandB, SumAandB])</span>: Unit </span>= {
        other match {
        <span class="hljs-keyword">case</span> o: FieldAccumulator =&gt; {
            A += o.A
            B += o.B}
        <span class="hljs-keyword">case</span> _ =&gt;
        }
    }
    <span class="hljs-comment">// 当Spark调用时返回结果</span>
    override def value: SumAandB = SumAandB(A,B)
}

凡是有关键字 override 的方法,均是重载实现自己逻辑的方法。累加器调用方式如下:

package com.spark.examples.rdd

import org.apache.spark.SparkConf
import org.apache.spark.SparkContext

class Driver extends App{

val conf = new SparkConf
val sc = new SparkContext(conf)
val filedAcc = new FieldAccumulator
sc.register(filedAcc, " filedAcc “)
// 过滤掉表头
val tableRDD = sc.textFile(“table.csv”).filter(_.split(”,“)(0) != “A”)
tableRDD.map(x => {
val fields = x.split(”,")
val a = fields(1).toInt
val b = fields(2).toLong
filedAcc.add(SumAandB (a, b))
x
}).count
}

最后计数器的结果为(3100, 31)。

小结

本课时主要介绍了 Spark 的两种共享变量,注意体会广播变量最后介绍的 map 端 join 的场景,这在实际使用中非常普遍。另外广播变量的大小,按照我的经验,要根据 Executor 和 Worker 资源来确定,几十兆、一个 G 的广播变量在大多数情况不会有什么问题,如果资源充足,那么1G~10G 以内问题也不大。

最后我要给你留一个思考题,请你对数据集进行空行统计。你可以先用普通算子完成后,再用累加器的方式完成,并比较两者的执行效率 ,如果有条件的,可以在生产环境中用真实数据集比较下两者之间的差异,差异会更明显。


计算框架的分布式实现:剖析 Spark Shuffle 原理

今天我将为你讲解计算框架的分布式实现:剖析 Spark Shuffle 原理。我们在前面几个课时,或多或少地提到了 Shuffle, Shuffle 一般被翻译为数据混洗,是类 MapReduce 分布式计算框架独有的机制,也是这类分布式计算框架最重要的执行机制。本课时主要从两个层面讲解 Shuffle,主要分为:

  • 逻辑层面;

  • 物理层面。

逻辑层面主要从 RDD 的血统机制出发,从 DAG 的角度来讲解 Shuffle,另外也会讲解 Spark 容错机制,而物理层面是从执行角度来剖析 Shuffle 是如何发生的。

RDD 血统与 Spark 容错

在 DAG 中,最初的 RDD 被称为基础 RDD,后续生成的 RDD 都是由算子以及依赖关系生成的,也就是说,无论哪个 RDD 出现问题,都可以由这种依赖关系重新计算而成。这种依赖关系被称为 RDD 血统(lineage)。血统的表现形式主要分为宽依赖(wide dependency)与窄依赖(narrow dependency),如下图所示:

图片1.png

窄依赖的准确定义是:子 RDD 中的分区与父 RDD 中的分区只存在一对一的映射关系,而宽依赖则是子 RDD 中的分区与父 RDD 中的分区存在一对多的映射关系,那么从这个角度来说,map、 filter、 union 等就是窄依赖,而 groupByKey、 coGroup 就是典型的宽依赖,如下图所示:

图片2.png

图片3.png

宽依赖还有个名字,叫 Shuffle 依赖,也就是说宽依赖必然会发生 Shuffle 操作,在前面也提到过 Shuffle 也是划分 Stage 的依据。而窄依赖由于不需要发生 Shuffle,所有计算都是在分区所在节点完成,它类似于 MapReduce 中的 ChainMapper。所以说,在你自己的 DAG 中,如果你选取的算子形成了宽依赖,那么就一定会触发 Shuffle

当 RDD 中的某个分区出现故障,那么只需要按照这种依赖关系重新计算即可,窄依赖最简单,只涉及某个节点内的计算,而宽依赖,则会按照依赖关系由父分区计算而得到,如下图所示:

图片4.png

如果 P1_0 分区发生故障,那么按照依赖关系,则需要 P0_0 与 P0_1 的分区重算,如果 P0_0与 P0_1 没有持久化,就会不断回溯,直到找到存在的父分区为止。当计算逻辑复杂时,就会引起依赖链过长,这样重算的代价会极其高昂,所以用户可以在计算过程中,适时调用 RDD 的 checkpoint 方法,保存当前算好的中间结果,这样依赖链就会大大缩短。RDD 的血统机制就是 RDD 的容错机制。

Spark 的容错主要分为资源管理平台的容错和 Spark 应用的容错, Spark 应用是基于资源管理平台运行,所以资源管理平台的容错也是 Spark 容错的一部分,如 YARN 的 ResourceManager HA 机制。在 Spark 应用执行的过程中,可能会遇到以下几种失败的情况:

  • Driver 报错;

  • Executor 报错;

  • Task 执行失败。

Driver 执行失败是 Spark 应用最严重的一种情况,标志整个作业彻底执行失败,需要开发人员手动重启 Driver;Executor 报错通常是因为 Executor 所在的机器故障导致,这时 Driver 会将执行失败的 Task 调度到另一个 Executor 继续执行,重新执行的 Task 会根据 RDD 的依赖关系继续计算,并将报错的 Executor 从可用 Executor 的列表中去掉;Spark 会对执行失败的 Task 进行重试,重试 3 次后若仍然失败会导致整个作业失败。在这个过程中,Task 的数据恢复和重新执行都用到了 RDD 的血统机制。

Spark Shuffle

很多算子都会引起 RDD 中的数据进行重分区,新的分区被创建,旧的分区被合并或者被打碎,在重分区的过程中,如果数据发生了跨节点移动,就被称为 Shuffle,在 Spark 中, Shuffle 负责将 Map 端(这里的 Map 端可以理解为宽依赖的左侧)的处理的中间结果传输到 Reduce 端供 Reduce 端聚合(这里的 Reduce 端可以理解为宽依赖的右侧),它是 MapReduce 类型计算框架中最重要的概念,同时也是很消耗性能的步骤。Shuffle 体现了从函数式编程接口到分布式计算框架的实现。与 MapReduce 的 Sort-based Shuffle 不同,Spark 对 Shuffle 的实现方式有两种:Hash Shuffle 与 Sort-based Shuffle,这其实是一个优化的过程。在较老的版本中,Spark Shuffle 的方式可以通过 spark.shuffle.manager 配置项进行配置,而在最新的 Spark 版本中,已经去掉了该配置,统一称为 Sort-based Shuffle。

Hash Shuffle

在 Spark 1.6.3 之前, Hash Shuffle 都是 Spark Shuffle 的解决方案之一。 Shuffle 的过程一般分为两个部分:Shuffle Write 和 Shuffle Fetch,前者是 Map 任务划分分区、输出中间结果,而后者则是 Reduce 任务获取到的这些中间结果。Hash Shuffle 的过程如下图所示:

图片5.png

在图中,Shuffle Write 发生在一个节点上,该节点用来执行 Shuffle 任务的 CPU 核数为 2,每个核可以同时执行两个任务,每个任务输出的分区数与 Reducer(这里的 Reducer 指的是 Reduce 端的 Executor)数相同,即为 3,每个分区都有一个缓冲区(bucket)用来接收结果,每个缓冲区的大小由配置 spark.shuffle.file.buffer.kb 决定。这样每个缓冲区写满后,就会输出到一个文件段(filesegment),而 Reducer 就会去相应的节点拉取文件。这样的实现很简单,但是问题也很明显。主要有两个:

  1. 生成的中间结果文件数太大。理论上,每个 Shuffle 任务输出会产生 R 个文件( R为Reducer 的个数),而 Shuffle 任务的个数往往由 Map 任务个数 M 决定,所以总共会生成 M * R 个中间结果文件,而往往在一个作业中 M 和 R 都是很大的数字,在大型作业中,经常会出现文件句柄数突破操作系统限制。

  2. 缓冲区占用内存空间过大。单节点在执行 Shuffle 任务时缓存区大小消耗为 m * R * spark.shuffle.file.buffer.kb,m 为该节点运行的 Shuffle 任务数,如果一个核可以执行一个任务,m 就与 CPU 核数相等。这对于动辄有 32、64 物理核的服务器来说,是笔不小的内存开销。

为了解决第一个问题, Spark 推出过 File Consolidation 机制,旨在通过共用输出文件以降低文件数,如下图所示:

图片6.png

每当 Shuffle 任务输出时,同一个 CPU 核心处理的 Map 任务的中间结果会输出到同分区的一个文件中,然后 Reducer 只需一次性将整个文件拿到即可。这样,Shuffle 产生的文件数为 C(CPU 核数)* R。 Spark 的 FileConsolidation 机制默认开启,可以通过 spark.shuffle.consolidateFiles 配置项进行配置。

Sort-based Shuffle

在 Spark 先后引入了 Hash Shuffle 与 FileConsolidation 后,还是无法根本解决中间文件数太大的问题,所以 Spark 在 1.2 之后又推出了与 MapReduce 一样(你可以参照《Hadoop 海量数据处理》(第 2 版)的 Shuffle 相关章节)的 Shuffle 机制: Sort-based Shuffle,才真正解决了 Shuffle 的问题,再加上 Tungsten 计划的优化, Spark 的 Sort-based Shuffle 比 MapReduce 的 Sort-based Shuffle 青出于蓝。如下图所示:

图片7.png

每个 Map 任务会最后只会输出两个文件(其中一个是索引文件),其中间过程采用的是与 MapReduce 一样的归并排序,但是会用索引文件记录每个分区的偏移量,输出完成后,Reducer 会根据索引文件得到属于自己的分区,在这种情况下,Shuffle 产生的中间结果文件数为 2 * M(M 为 Map 任务数)。

在基于排序的 Shuffle 中, Spark 还提供了一种折中方案——Bypass Sort-based Shuffle,当 Reduce 任务小于 spark.shuffle.sort.bypassMergeThreshold 配置(默认 200)时,Spark Shuffle 开始按照 Hash Shuffle 的方式处理数据,而不用进行归并排序,只是在 Shuffle Write 步骤的最后,将其合并为 1 个文件,并生成索引文件。这样实际上还是会生成大量的中间文件,只是最后合并为 1 个文件并省去排序所带来的开销,该方案的准确说法是 Hash Shuffle 的Shuffle Fetch 优化版。

Spark 在1.5 版本时开始了 Tungsten 计划,也在 1.5.0、 1.5.1、 1.5.2 的时候推出了一种 tungsten-sort 的选项,这是一种成果应用,类似于一种实验,该类型 Shuffle 本质上还是给予排序的 Shuffle,只是用 UnsafeShuffleWriter 进行 Map 任务输出,并采用了要在后面介绍的 BytesToBytesMap 相似的数据结构,把对数据的排序转化为对指针数组的排序,能够基于二进制数据进行操作,对 GC 有了很大提升。但是该方案对数据量有一些限制,随着 Tungsten 计划的逐渐成熟,该方案在 1.6 就消失不见了。

从上面整个过程的变化来看, Spark Shuffle 也是经过了一段时间才趋于成熟和稳定,这也正像学习的过程,不用一蹴而就,贵在坚持。

习题讲解

最后我们在这里公布 08 课时的第 2 道练习题答案,从这道题你可以看出 Shuffle 的方式,对性能影响非常大。

首先来回顾下这道题:
练习题 2:用 Spark 算子实现对 1TB 的数据进行排序?

关于这道题的解法,你可能很自然地想到了归并排序的原理,首先每个分区对自己分区进行排序,最后汇总到一个分区内进行全排序,如下图所示:

图片8.png

可想而知,最后 1TB 的数据都会汇总到 1 个 Executor,就算这个 Executor 分配到的资源再充足,面对这种情况,无疑也是以失败告终。所以这道题的解法应该是另一种方案,首先数据会按照键的区间进行分发,也就是 Shuffle,如 [0,100000]、 [100000,200000)和 [200000,300000],每个分区没有交集。照此规则分发后,分区内再进行排序,就可以在满足性能要求的前提下完成全排序,如下图:

图片9.png

这种方式的全排序无疑实现了计算的并行化,很多测试性能的场景也用这种方式对 1TB 的数据进行排序,目前世界纪录是腾讯在 2016 年达到的 98.8 秒。对于这种排序方式,Spark 也将其封装为 sortByKey 算子,它采用的分区器则是 RangePartitioner。

小结

Spark Shuffle 是 Spark 最重要的机制,作为大数据工程师的你,有必要深入了解,Shuffle机制也是面试喜欢问到的一个问题,前面三张关于Shuffle的图大家一定要吃透。另外, Spark 作业的性能问题往往出现在 Shuffle 上,在上个课时中,我们也是通过广播变量而避免了 Shuffle,从而得到性能的提升,所以掌握了 Spark Shuffle 能帮助你有针对性地进行调优。

最后给你出一个思考题:还是在 08 课时第 2 道思考题的基础上,如果数据并没有均匀分布,那么很可能某个分区的数据会异常多,同样会导致作业失败,对于这种数据倾斜的情况,你认为有没有办法避免呢?


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

办公模板库 素材蛙

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值