You are on page 1of 63

Table

of Contents
Introduction 1.1
概览 1.2
Spark Core 1.3
SparkContext 1.3.1
理解RDD 1.3.2
combineByKey操作 1.3.3
实现PageRank算法 1.3.4
内存管理 1.3.5
Spark SQL 1.4
DataFrame 1.4.1
UDF与UDAF 1.4.2
DataSources 1.4.3
External DataSources 1.4.4
Spark SQL的性能调优 1.4.5
Catalyst 1.4.6
Rollup函数 1.4.7
Spark Streaming 1.5
Spark的运维 1.6
Spark的部署 1.6.1

1
Introduction

Spark in Action
记录学习Spark的心得体会,以及在项目中运用Spark的实战经验。内容会不断延伸,包括
Spark Core、Spark SQL、Spark Streaming以及Machine Learning等方面的知识。

关于作者
张逸,现为BigEye Tech公司联合创始人,架构师,主要从事BI、BigData方面的研发工作。
主要基于Spark与NoSQL、RMDBS进行数据分析和建模,并提供分析结果的可视化。之前就
职于ThoughtWorks,作为首席咨询师,主要为客户提供组织的敏捷转型、过程改进、系统架
构监理、领域设计、代码质量提升等咨询工作。

微信公众号为「逸言」。

2
概览

概览

Spark的发展
对于一个具有相当技术门槛与复杂度的平台,Spark从诞生到正式版本的成熟,经历的时间如
此之短,让人感到惊诧。2009年,Spark诞生于伯克利大学AMPLab,最开初属于伯克利大学
的研究性项目。它于2010年正式开源,并于2013年成为了Apache基金项目,并于2014年成
为Apache基金的顶级项目,整个过程不到五年时间。

由于Spark出自伯克利大学,使其在整个发展过程中都烙上了学术研究的标记,对于一个在数
据科学领域的平台而言,这也是题中应有之义,它甚至决定了Spark的发展动力。Spark的核
心RDD(resilient distributed datasets),以及流处理,SQL智能分析,机器学习等功能,都
脱胎于学术研究论文,如下所示:

Discretized Streams: Fault-Tolerant Streaming Computation at Scale. Matei Zaharia,


Tathagata Das, Haoyuan Li, Timothy Hunter, Scott Shenker, Ion Stoica. SOSP 2013.
November 2013.
Shark: SQL and Rich Analytics at Scale. Reynold Xin, Joshua Rosen, Matei Zaharia,
Michael J. Franklin, Scott Shenker, Ion Stoica. SIGMOD 2013. June 2013.
Discretized Streams: An Efficient and Fault-Tolerant Model for Stream Processing on
Large Clusters. Matei Zaharia, Tathagata Das, Haoyuan Li, Scott Shenker, Ion Stoica.
HotCloud 2012. June 2012.
Shark: Fast Data Analysis Using Coarse-grained Distributed Memory (demo). Cliff
Engle, Antonio Lupher, Reynold Xin, Matei Zaharia, Haoyuan Li, Scott Shenker, Ion
Stoica. SIGMOD 2012. May 2012. Best Demo Award.
Resilient Distributed Datasets: A Fault-Tolerant Abstraction for In-Memory Cluster
Computing. Matei Zaharia, Mosharaf Chowdhury, Tathagata Das, Ankur Dave, Justin
Ma, Murphy McCauley, Michael J. Franklin, Scott Shenker, Ion Stoica. NSDI 2012. April
2012. Best Paper Award and Honorable Mention for Community Award.
Spark: Cluster Computing with Working Sets. Matei Zaharia, Mosharaf Chowdhury,
Michael J. Franklin, Scott Shenker, Ion Stoica. HotCloud 2010. June 2010.

在大数据领域,只有深挖数据科学领域,走在学术前沿,才能在底层算法和模型方面走在前
面,从而占据领先地位。Spark的这种学术基因,使得它从一开始就在大数据领域建立了一定
优势。无论是性能,还是方案的统一性,对比传统的Hadoop,优势都非常明显。Spark提供
的基于RDD的一体化解决方案,将MapReduce、Streaming、SQL、Machine Learning、
Graph Processing等模型统一到一个平台下,并以一致的API公开,并提供相同的部署方案,
使得Spark的工程应用领域变得更加广泛。

3
概览

Spark的代码活跃度
从Spark的版本演化看,足以说明这个平台旺盛的生命力以及社区的活跃度。尤其在2013年
来,Spark进入了一个高速发展期,代码库提交与社区活跃度都有显著增长。以活跃度论,
Spark在所有Apache基金会开源项目中,位列前三。相较于其他大数据平台或框架而言,
Spark的代码库最为活跃,如下图所示:

从2013年6月到2014年6月,参与贡献的开发人员从原来的68位增长到255位,参与贡献的公
司也从17家上升到50家。在这50家公司中,有来自中国的阿里、百度、网易、腾讯、搜狐等
公司。当然,代码库的代码行也从原来的63,000行增加到175,000行。下图为截止2014年
Spark代码贡献者每个月的增长曲线:

4
概览

下图则显示了自从Spark将其代码部署到Github之后的提交数据,一共有8471次提交,11个分
支,25次发布,326位代码贡献者。

目前的Spark版本为1.1.0。在该版本的代码贡献者列表中,出现了数十位国内程序员的身影。
这些贡献者的多数工作主要集中在Bug Fix上,甚至包括Example的Bug Fix。由于1.1.0版本极
大地增强了Spark SQL和MLib的功能,因此有部分贡献都集中在SQL和MLib的特性实现上。
下图是Spark Master分支上最近发生的仍然处于Open状态的Pull Request:

可以看出,由于Spark仍然比较年轻,当运用到生产上时,可能发现一些小缺陷。而在代码整
洁度方面,也随时在对代码进行着重构。例如,淘宝技术部在2013年就开始尝试将Spark on
Yarn应用到生产环境上。他们在执行数据分析作业过程中,先后发现了DAGSchedular的内存
泄露,不匹配的作业结束状态等缺陷,从而为Spark库贡献了几个比较重要的Pull Request。
具体内容可以查看淘宝技术部的博客文章:《Spark on Yarn:几个关键Pull Request》。

Spark的社区活动
Spark非常重视社区活动,组织也极为规范,定期或不定期地举行与Spark相关的会议。会议
分为两种,一种为Spark Summit,影响力巨大,可谓全球Spark顶尖技术人员的峰会。目前,
已经于2013年和2014年在San Francisco连续召开了两届Summit大会。2015年,Spark

5
概览

Summit将分别在New York与San Francisco召开。

在2014年的Spark Summit大会上,我们看到除了伯克利大学以及Databricks公司自身外,演
讲者都来自最早开始运用和尝试Spark进行大数据分析的公司,包括最近非常火的音乐网站
Spotify,全球最大专注金融交易的Sharethrough,专业大数据平台MapR、Cloudera,云计算
的领先者Amazon,以及全球超大型企业IBM、Intel、SAP等。

除了影响力巨大的Spark Summit之外,Spark社区还不定期地在全球各地召开小型的Meetup
活动。Spark Meetup Group已经遍布北美、欧洲、亚洲和大洋洲。在中国,北京Spark
Meetup已经召开了两次,并将于今年10月26日召开第三次Meetup。届时将有来自Intel中国研
究院、淘宝、TalkingData、微软亚洲研究院、Databricks的工程师进行分享。下图为Spark
Meetup Groups在全球的分布图:

Spark的现在和未来
Spark的特色在于它首先为大数据应用提供了一个统一的平台。从数据处理层面看,模型可以
分为批处理、交互式、流处理等多种方式;而从大数据平台而言,已有成熟的Hadoop、
Cassandra、Mesos以及其他云的供应商。Spark整合了主要的数据处理模型,并能够很好地
与现在主流的大数据平台集成。下图展现了Spark的这一特色:

这样的一种统一平台带来的优势非常明显。对于开发者而言,只需要学习一个平台,降低了
学习曲线。对于用户而言,可以很方便地将Spark应用运行在Hadoop、Mesos等平台上面,
满足了良好的可迁移性。统一的数据处理方式,也可以简化开发模型,降低平台的维护难

6
概览

度。

Spark为大数据提供了通用算法的标准库,这些算法包括MapReduce、SQL、Streaming、
Machine Learning与Graph Processing。同时,它还提供了对Scala、Python、Java(支持
Java 8)和R语言的支持:

在最新发布的1.1.0版本中,对Spark SQL和Machine Learning库提供了增强。Spark SQL能


够更加有效地在Spark中加载和查询结构型数据,同时还支持对JSON数据的操作,并提供了
更加友好的Spark API。在Machine Learning方面,已经包含了超过15种算法,包括决策树、
SVD、PCA,L-BFGS等。下图展现了Spark当前的技术栈:

7
概览

在2014年的Spark Summit上,来自Databricks公司的Patrick Wendell展望了Spark的未来。他


在演讲中提到了Spark的目标,包括:

Empower data scientists and engineers


Expressive, clean APIs
Unified runtime across many environments
Powerful standard libraries

在演讲中,他提到在Spark最近的版本中,最重要的核心组件为Spark SQL。接下来的几次发
布,除了在性能上更加优化(包括代码生成和快速的Join操作)外,还要提供对SQL语句的扩
展和更好的集成(利用SchemaRDD与Hadoop、NoSQL以及RDBMS的集成)。在将来的版
本中,要为MLLib增加更多的算法,这些算法除了传统的统计算法外,还包括学习算法,并提
供与R语言更好的集成,从而能够为数据科学家提供更好的选择,根据场景来选择Spark和
R。

Spark的发展会结合硬件的发展趋势。首先,内存会变得越来越便宜,256GB内存以上的机器
会变得越来越常见,而对于硬盘,则SSD硬盘也将慢慢成为服务器的标配。由于Spark是基于
内存的大数据处理平台,因而在处理过程中,会因为数据存储在硬盘中,而导致性能瓶颈。
随着机器内存容量的逐步增加,类似HDFS这种存储在磁盘中的分布式文件系统将慢慢被共享
内存的分布式存储系统所替代,诸如同样来自伯克利大学的AMPLab实验室的Tachyon就提供
了远超HDFS的性能表现。因此,未来的Spark会在内部的存储接口上发生较大的变化,能够
更好地支持SSD、以及诸如Tachyon之类的共享内存系统。事实上,在Spark的最近版本里,
已经开始支持Tachyon了。

根据Spark的路线图,Databricks会在近三个月陆续发布1.2.0和1.3.0版本。其中,1.2.0版本
会对存储方面的API进行重构,在1.3.0之上的版本,则会推出结合Spark和R的SparkR。除了
前面提到的SQL与MLLib之外,未来的Spark对于Streaming、GraphX都有不同程度的增强,
并能够更好地支持YARN。

8
概览

Spark的应用
目前,Spark的正式版本得到了部分Hadoop主流厂商的支持,如下企业或平台发布的Hadoop
版本中,都包含了Spark:

这说明业界已经认可了Spark,Spark也被许多企业尤其是互联网企业广泛应用到商业项目
中。根据Spark的官方统计,目前参与Spark的贡献以及将Spark运用在商业项目的公司大约有
80余家。在国内,投身Spark阵营的公司包括阿里、百度、腾讯、网易、搜狐等。在San
Francisco召开的Spark Summit 2014大会上,参会的演讲嘉宾分享了在音乐推荐
(Spotify)、实时审计的数据分析(Sharethrough)、流在高速率分析中的运用
(Cassandra)、文本分析(IBM)、客户智能实时推荐(Graphflow)等诸多在应用层面的
话题,这足以说明Spark的应用程度。

但是,整体而言,目前开始应用Spark的企业主要集中在互联网领域。制约传统企业采用
Spark的因素主要包括三个方面。首先,取决于平台的成熟度。传统企业在技术选型上相对稳
健,当然也可以说是保守。如果一门技术尤其是牵涉到主要平台的选择,会变得格外慎重。
如果没有经过多方面的验证,并从业界获得成功经验,不会轻易选定。其次是对SQL的支
持。传统企业的数据处理主要集中在关系型数据库,而且有大量的遗留系统存在。在这些遗
留系统中,多数数据处理都是通过SQL甚至存储过程来完成。如果一个大数据平台不能很好
地支持关系型数据库的SQL,就会导致迁移数据分析业务逻辑的成本太大。其三则是团队与
技术的学习曲线。如果没有熟悉该平台以及该平台相关技术的团队成员,企业就会担心开发
进度、成本以及可能的风险。

Spark在努力解决这三个问题。随着1.0.2版本的发布,Spark得到了更多商用案例的验证。
Spark虽然依旧保持年轻的活力,但已经具备堪称成熟的平台功能。至于SQL支持,Spark非
常。在1.0.2版本发布之前,就认识到基于HIVE的Shark存在的不足,从而痛下决心,决定在
新版本中抛弃Shark,而决定引入新的SQL模块。如今,在Spark 1.1.0版本中,Spark SQL的
支持已经相对完善,足以支持企业应用中对SQL迁移的需求。关于Spark的学习曲线,主要的
学习内容还是在于对RDD的理解。由于Spark为多种算法提供了统一的编程模型、部署模式,
搭建了一个大数据的一体化方案,倘若企业的大数据分析需要应对多种场景,那么,Spark这
样的架构反而使得它的学习曲线更低,同时还能降低部署成本。Spark可以很好地与
Hadoop、Cassandra等平台集成,同时也能部署到YARN上。如果企业已经具备大数据分析
的能力,原有掌握的经验仍旧可以用到Spark上。虽然Spark是用Scala编写,官方也更建议用

9
概览

户调用Scala的API,但它同时也提供了Java和Python的接口,非常体贴地满足了Java企业用
户或非JVM用户。如果抱怨Java的冗赘,则Spark新版本对Java 8的支持让Java API变得与
Scala API同样的简洁而强大,例如经典的字数统计算法在Java 8中的实现:

JavaRDD<String> lines = sc.textFile("data.txt”);


JavaRDD<Integer> lineLengths = lines.map(s -> s.length());
int totalLength = lineLengths.reduce((a, b) -> a + b);

显然,随着Spark的逐渐成熟,并在活跃社区的推动下,它所提供的强大功能一定能得到更多
技术团队和企业的青睐。相信在不远的将来会有更多传统企业开始尝试使用Spark。

10
Spark Core

Spark Core

11
SparkContext

SparkContext
SparkContext是Spark的入口,可以通过SparkConf来创建它。配置SparkConf时有许多选
项,包括应用程序名称、Master、内存大小等。例如:

val sparkConf = new SparkConf().setAppName("FromPostgreSql")


.setMaster("local[4]")
.set("spark.executor.memory", "2g")
val sc = new SparkContext(sparkConf)

如果是在编程环境下,我们通常会在main()函数下创建SparkContext。当执行完毕后,可以调
用stop()方法来终止它。

我曾经尝试在一个Actor中去创建SparkContext,例如:

class SparkJobActor extends Actor {


def receive = {
case query:QueryMessage =>
val sparkConf = new SparkConf().setAppName("MortSpark")
.setMaster("local[*]")
.set("spark.executor.memory", "2g")
val sc = new SparkContext(sparkConf)

//...
}
}

但是在执行到创建SparkContext时,系统就抛出了错误,未能成功创建SparkContext。

12
理解RDD

理解RDD
与许多专有的大数据处理平台不同,Spark建立在统一抽象的RDD之上,使得它可以以基本一
致的方式应对不同的大数据处理场景,包括MapReduce,Streaming,SQL,Machine
Learning以及Graph等。这即Matei Zaharia所谓的“设计一个通用的编程抽象(Unified
Programming Abstraction)。这正是Spark这朵小火花让人着迷的地方。

要理解Spark,就需得理解RDD。

RDD是什么?
RDD,全称为Resilient Distributed Datasets,是一个容错的、并行的数据结构,可以让用户
显式地将数据存储到磁盘和内存中,并能控制数据的分区。同时,RDD还提供了一组丰富的
操作来操作这些数据。在这些操作中,诸如map、flatMap、filter等转换操作实现了monad模
式,很好地契合了Scala的集合操作。除此之外,RDD还提供了诸如join、groupBy、
reduceByKey等更为方便的操作(注意,reduceByKey是action,而非transformation),以
支持常见的数据运算。

通常来讲,针对数据处理有几种常见模型,包括:Iterative Algorithms,Relational Queries,


MapReduce,Stream Processing。例如Hadoop MapReduce采用了MapReduces模型,
Storm则采用了Stream Processing模型。RDD混合了这四种模型,使得Spark可以应用于各种
大数据处理场景。

RDD作为数据结构,本质上是一个只读的分区记录集合。一个RDD可以包含多个分区,每个
分区就是一个dataset片段。RDD可以相互依赖。如果RDD的每个分区最多只能被一个Child
RDD的一个分区使用,则称之为narrow dependency;若多个Child RDD分区都可以依赖,则
称之为wide dependency。不同的操作依据其特性,可能会产生不同的依赖。例如map操作会
产生narrow dependency,而join操作则产生wide dependency。

Spark之所以将依赖分为narrow与wide,基于两点原因。

首先,narrow dependencies可以支持在同一个cluster node上以管道形式执行多条命令,例


如在执行了map后,紧接着执行filter。相反,wide dependencies需要所有的父分区都是可用
的,可能还需要调用类似MapReduce之类的操作进行跨节点传递。

其次,则是从失败恢复的角度考虑。narrow dependencies的失败恢复更有效,因为它只需要
重新计算丢失的parent partition即可,而且可以并行地在不同节点进行重计算。而wide
dependencies牵涉到RDD各级的多个Parent Partitions。下图说明了narrow dependencies与
wide dependencies之间的区别:

13
理解RDD

本图来自Matei Zaharia撰写的论文An Architecture for Fast and General Data Processing on


Large Clusters。图中,一个box代表一个RDD,一个带阴影的矩形框代表一个partition。

RDD如何保障数据处理效率?
RDD提供了两方面的特性persistence和patitioning,用户可以通过persist与patitionBy函数来
控制RDD的这两个方面。RDD的分区特性与并行计算能力(RDD定义了parallerize函数),使得
Spark可以更好地利用可伸缩的硬件资源。若将分区与持久化二者结合起来,就能更加高效地
处理海量数据。例如:

input.map(parseArticle _).partitionBy(partitioner).cache()

partitionBy函数需要接受一个Partitioner对象,如:

val partitioner = new HashPartitioner(sc.defaultParallelism)

RDD本质上是一个内存数据集,在访问RDD时,指针只会指向与操作相关的部分。例如存在
一个面向列的数据结构,其中一个实现为Int的数组,另一个实现为Float的数组。如果只需要
访问Int字段,RDD的指针可以只访问Int数组,避免了对整个数据结构的扫描。

RDD将操作分为两类:transformation与action。无论执行了多少次transformation操作,RDD
都不会真正执行运算,只有当action操作被执行时,运算才会触发。而在RDD的内部实现机制
中,底层接口则是基于迭代器的,从而使得数据访问变得更高效,也避免了大量中间结果对
内存的消耗。

在实现时,RDD针对transformation操作,都提供了对应的继承自RDD的类型,例如map操作
会返回MappedRDD,而flatMap则返回FlatMappedRDD。当我们执行map或flatMap操作时,
不过是将当前RDD对象传递给对应的RDD对象而已。例如:

14
理解RDD

def map[U: ClassTag](f: T => U): RDD[U] = new MappedRDD(this, sc.clean(f))

这些继承自RDD的类都定义了compute函数。该函数会在action操作被调用时触发,在函数内
部是通过迭代器进行对应的转换操作:

private[spark]
class MappedRDD[U: ClassTag, T: ClassTag](prev: RDD[T], f: T => U)
extends RDD[U](prev) {

override def getPartitions: Array[Partition] = firstParent[T].partitions

override def compute(split: Partition, context: TaskContext) =


firstParent[T].iterator(split, context).map(f)
}

RDD对容错的支持
支持容错通常采用两种方式:数据复制或日志记录。对于以数据为中心的系统而言,这两种
方式都非常昂贵,因为它需要跨集群网络拷贝大量数据,毕竟带宽的数据远远低于内存。

RDD天生是支持容错的。首先,它自身是一个不变的(immutable)数据集,其次,它能够记住
构建它的操作图(Graph of Operation),因此当执行任务的Worker失败时,完全可以通过操
作图获得之前执行的操作,进行重新计算。由于无需采用replication方式支持容错,很好地降
低了跨网络的数据传输成本。

不过,在某些场景下,Spark也需要利用记录日志的方式来支持容错。例如,在Spark
Streaming中,针对数据进行update操作,或者调用Streaming提供的window操作时,就需要
恢复执行过程的中间状态。此时,需要通过Spark提供的checkpoint机制,以支持操作能够从
checkpoint得到恢复。

针对RDD的wide dependency,最有效的容错方式同样还是采用checkpoint机制。不过,似乎
Spark的最新版本仍然没有引入auto checkpointing机制。

总结
RDD是Spark的核心,也是整个Spark的架构基础。它的特性可以总结如下:

它是不变的数据结构存储
它是支持跨集群的分布式数据结构
可以根据数据记录的key对结构进行分区
提供了粗粒度的操作,且这些操作都支持分区
它将数据存储在内存中,从而提供了低延迟性

15
理解RDD

16
combineByKey操作

combineByKey操作
在数据分析中,处理Key,Value的Pair数据是极为常见的场景,例如我们可以针对这样的数
据进行分组、聚合或者将两个包含Pair数据的RDD根据key进行join。从函数的抽象层面看,
这些操作具有共同的特征,都是将类型为RDD[(K,V)]的数据处理为RDD[(K,C)]。这里的V和C
可以是相同类型,也可以是不同类型。这种数据处理操作并非单纯的对Pair的value进行
map,而是针对不同的key值对原有的value进行联合(Combine)。因而,不仅类型可能不
同,元素个数也可能不同。

Spark为此提供了一个高度抽象的操作combineByKey。该方法的定义如下所示:

def combineByKey[C](createCombiner: V => C,


mergeValue: (C, V) => C,
mergeCombiners: (C, C) => C,
partitioner: Partitioner,
mapSideCombine: Boolean = true,
serializer: Serializer = null): RDD[(K, C)] = {
//实现略
}

函数式风格与命令式风格不同之处在于它说明了代码做了什么(what to do),而不是怎么做
(how to do)。combineByKey函数主要接受了三个函数作为参数,分别为createCombiner、
mergeValue、mergeCombiners。这三个函数足以说明它究竟做了什么。理解了这三个函
数,就可以很好地理解combineByKey。

combineByKey是将RDD[(K,V)]combine为RDD[(K,C)],因此,首先需要提供一个函数,能够
完成从V到C的combine,称之为combiner。如果V和C类型一致,则函数为V => V。倘若C是
一个集合,例如Iterable[V],则createCombiner为V => Iterable[V]。

mergeValue则是将原RDD中Pair的Value合并为操作后的C类型数据。合并操作的实现决定了
结果的运算方式。所以,mergeValue更像是声明了一种合并方式,它是由整个combine运算
的结果来导向的。函数的输入为原RDD中Pair的V,输出为结果RDD中Pair的C。

最后的mergeCombiners则会根据每个Key所对应的多个C,进行归并。

让我们将combineByKey想象成是一个超级酷的果汁机。它能同时接受各种各样的水果,然后
聪明地按照水果的种类分别榨出不同的果汁。苹果归苹果汁,橙子归橙汁,西瓜归西瓜汁。
我们为水果定义类型为Fruit,果汁定义为Juice,那么combineByKey就是将RDD[(String,
Fruit)]combine为RDD[(String, Juice)]。

注意,在榨果汁前,水果可能有很多,即使是相同类型的水果,也会作为不同的RDD元素:

17
combineByKey操作

("apple", apple1), ("orange", orange1), ("apple", apple2)

combine的结果是每种水果只有一杯果汁(只是容量不同罢了):

("apple", appleJuice), ("orange", orangeJuice)

这个果汁机由什么元件构成呢?首先,它需要一个元件提供将各种水果榨为各种果汁的功
能;其次,它需要提供将果汁进行混合的功能;最后,为了避免混合错误,还得提供能够根
据水果类型进行混合的功能。注意第二个函数和第三个函数的区别,前者只提供混合功能,
即能够将不同容器的果汁装到一个容器中,而后者的输入已有一个前提,那就是已经按照水
果类型放到不同的区域,果汁机在混合果汁时,并不会混淆不同区域的果汁。

果汁机的功能类似于groupByKey+foldByKey操作。它可以调用combineByKey函数:

case class Fruit(kind: String, weight: Int) {


def makeJuice:Juice = Juice(weight * 100)
}
case class Juice(volumn: Int) {
def add(j: Juice):Juice = Juice(volumn + j.volumn)
}
val apple1 = Fruit("apple", 5)
val apple2 = Fruit("apple", 8)
val orange1 = Fruit("orange", 10)

val fruit = sc.parallelize(List(("apple", apple1) , ("orange", orange1) , ("apple", ap


ple2)))
val juice = fruit.combineByKey(
f => f.makeJuice,
(j:Juice,f) => j.add(f.makeJuice),
(j1:Juice,j2:Juice) => j1.add(j2)
)

执行juice.collect,结果为:

Array[(String, Juice)] = Array((orange,Juice(1000)), (apple,Juice(1300)))

RDD中有许多针对Pair RDD的操作在内部实现都调用了combineByKey函数。例如
groupByKey:

18
combineByKey操作

class PairRDDFunctions[K, V](self: RDD[(K, V)])


(implicit kt: ClassTag[K], vt: ClassTag[V], ord: Ordering[K] = null)
extends Logging
with SparkHadoopMapReduceUtil
with Serializable {
def groupByKey(partitioner: Partitioner): RDD[(K, Iterable[V])] = {
val createCombiner = (v: V) => CompactBuffer(v)
val mergeValue = (buf: CompactBuffer[V], v: V) => buf += v
val mergeCombiners = (c1: CompactBuffer[V], c2: CompactBuffer[V]) => c1 ++= c2
val bufs = combineByKey[CompactBuffer[V]](
createCombiner, mergeValue, mergeCombiners, partitioner, mapSideCombine=false
)
bufs.asInstanceOf[RDD[(K, Iterable[V])]]
}
}

groupByKey函数针对PairRddFunctions的RDD[(K, V)]按照key对value进行分组。它在内部调
用了combineByKey函数,传入的三个函数分别承担了如下职责:

createCombiner是将原RDD中的K类型转换为Iterable[V]类型,实现为CompactBuffer。
mergeValue实则就是将原RDD的元素追加到CompactBuffer中,即将追加操作(+=)视为
合并操作。
mergeCombiners则负责针对每个key值所对应的Iterable[V],提供合并功能。

再例如,我们要针对科目对成绩求平均值:

val scores = sc.parallelize(List(("chinese", 88.0) , ("chinese", 90.5) , ("math", 60.0


), ("math", 87.0)))

平均值并不能一次获得,而是需要求得各个科目的总分以及科目的数量。因此,我们需要针
对scores进行combine,从(String, Float)combine为(String, (Float, Int))。调用combineByKey
函数后,我们可以再通过map来获得平均值。代码如下:

val avg = scores.combineByKey(


(v) => (v, 1),
(acc: (Float, Int), v) => (acc._1 + v, acc._2 + 1),
(acc1:(Float, Int), acc2:(Float, Int)) => (acc1._1 + acc2._1, acc1._2 + acc2._2)
).map{ case (key, value) => (key, value._1 / value._2.toFloat) }

除了可以进行group、average之外,根据传入的函数实现不同,我们还可以利用
combineByKey完成诸如aggregate、fold等操作。这是一个高度的抽象,但从声明的角度来
看,却又不需要了解过多的实现细节。这正是函数式编程的魅力。

19
combineByKey操作

20
实现PageRank算法

实现PageRank算法
吴军博士在《数学之美》中深入浅出地介绍了由Google的佩奇与布林提出的PageRank算法,
这是一种民主表决式网页排名技术。书中提到PageRank的核心思想为:“在互联网上,如果
一个网页被很多其他网页所链接,说明它受到普遍的承认和信赖,那么它的排名就高。”同
时,该算法还要对来自不同网页的链接区别对待,排名越高的网页,则其权重会更高,即所
谓网站贡献的链接权更大。

例如网页Y被X1,X2,X3,X4四个网页所链接,且这四个网页的权重分别为0.001,0.01,
0.02,0.04,则网页Y的Rank值=0.01+0.02+0.03+0.04=0.071。但问题是,如何获得
X1,X2,X3,X4这些网页的权重呢?答案是权重等于这些网页自身的Rank。然而,这些网页的
Rank又是通过链接它的网页的权重计算而来,于是就陷入了“鸡与蛋”的怪圈。解决办法是为
所有网页设定一个相同的Rank初始值,然后利用迭代的方式来逐步求解。

在《数学之美》第10章的延伸阅读中,有更详细的算法介绍,有兴趣的同学可以自行翻阅。
下面是PageRank的简单执行步骤:

首先假定所有网页的初始Rank值为1/N,N为所有网页的数量。
开始迭代。每次迭代,则页面p会将r/n的值发送给所有链接了p页面的邻居页面。其中,r
为当前页面的rank值,n为链接了当前页面的邻居页面数。该值实则就是当前页面p这次
迭代的贡献者(contribution)。
每次迭代结束时,都对最终获得的contributions进行求和。假设每个contribution为c(i),
则可以通过公式α/N + (1-α)∑c(i)获得每个页面的rank值。其中,α是一个常数值,可以认
为是一个调优参数(tuning parameter),N为所有页面的数量。

究竟应该迭代多少次呢?由于PageRank实则是线性代数中的矩阵计算,佩奇和拉里已经证明
了这个算法是收敛的。当两次迭代获得结果差异非常小,接近于0时,就可以停止迭代计算。
《数学之美》中提到:“一般来讲,只要10次左右的迭代基本上就收敛了。”

我们将初始值进一步简化为1.0,并且将α/N的值设置为0.15,则公式α/N + (1-α)∑c(i)就变成
0.15 + 0.85*∑c(i),那么在Spark中的实现为:

21
实现PageRank算法

val sc = new SparkContext(...)


// 假定邻居页面的List存储为Spark objectFile
val links = sc.objectFile[(String, Seq[String])]("links")
.partitionBy(new HashPartitioner(100))
.persist()

//设置页面的初始rank值为1.0
var ranks = links.mapValues(_ => 1.0)

//迭代10次
for (i <- 0 until 10) {
val contributions = links.join(ranks).flatMap {
case (pageId, (links, rank)) =>
//注意此时的links为模式匹配获得的值,类型为Seq[String],并非前面读取出来的页面List
links.map(dest => (dest, rank / links.size))
}
//简化了的rank计算公式
ranks = contributions.reduceByKey(_ + _).mapValues(0.15 + 0.85 * _)
}
ranks.saveAsTextFile("ranks")

这段代码来自于Learning Spark: Lightning-fast big data analytics一书第4章。它充分地展现了


Spark在进行数据分析的优雅与强大。虽然是简化了的PageRank算法,但如此精简的代码量
仍然值得称赞。此外,该实现的性能比较Hadoop而言,也有显著提升。在Matei的论文An
Architecture for Fast and General Data Processing on Large Clusters中,给出了这样的性能
benchmark比较,如下图所示:

图:Hadoop和Spark关于PageRank算法的性能比较(2014年)

注意,图中比较了Basic Spark与Spark+Controlled Partitioning,后者实则是通过分区等性能


调优手段改进算法的性能。在前面的代码段中,我们也看到在读取页面List时,通过了
partitionBy()函数传递了一个HashPartitioner。不要小看这段代码,实则它隐藏了许多与性能
相关的tips,Learning Spark: Lightning-fast big data analytics一书对此做了深入介绍,性能改
进点包括:

在迭代中,links每次都与ranks进行了join操作,这是非常影响性能的。由于links的内容
是不会变的(static dataset),因此在对它进行分区后,迭代中就不再需要针对它跨网络

22
实现PageRank算法

进行shuffle了。当links的数据量非常大时,这一优化对性能的提升是非常明显的。
将分区了的RDD持久化到内存中,这一做法极为关键,理由同前。
注意ranks的创建是针对links执行mapValues()而来。ranks需要和links执行join操作。由
于mapValues()是一个transform操作,links是ranks的parent RDD,相比join两个完全无
关的RDD而言,二者的join操作会更加高效。
在迭代中,对contributions执行了reduceByKey()后,紧跟着执行了mapValues()。由于
reduceByKey是hash-partition,它又是mapValues的parent RDD,因而mapValues()计算
后的RDD即ranks也是hash-partition。而ranks又会在下一个迭代中与前面hash-partition
的links进行join操作。join的两个RDD都处于同一分区,效率更高。

此外,对分区后的RDD应尽量调用mapValue()函数,而非map()函数。从Spark对mapValues
的实现以及注释可以看到,mapValues()函数会保持原有RDD的分区:

/**
* Pass each value in the key-value pair RDD through a map function without changing
the keys;
* this also retains the original RDD's partitioning.
*/
def mapValues[U](f: V => U): RDD[(K, U)] = {
val cleanF = self.context.clean(f)
new MappedValuesRDD(self, cleanF)
}

flatMapValues()与mapValues()相似,也会保持原有RDD的分区,所以为了尽可能地满足与分
区有关的性能优化,应合理考虑对RDD操作的选择。

23
内存管理

内存管理
从Spark 1.6版本开始,Spark采用Unified Memory Management这样一种新的内存管理模
型。

Spark中的内存使用分为两部分:执行(execution)与存储(storage)。执行内存主要用于
shuffles、joins、sorts和aggregations,存储内存则用于缓存或者跨节点的内部数据传输。

在Spark 1.6之前,这两部分内存的分配是静态的,以配置的方式进行设置,对应的管理类
为 StaticMemoryManager 。这种管理方式的缺陷不言自明,因为它不能根据不同的数据处理场
景调整内存的比例,在内存使用和性能方面都存在局限性。

Unified Memory Management in Spark 1.6一文列举了这种内存管理方式的限制:

There are no sensible defaults that apply to all workloads


Tuning memory fractions requires user expertise of internals
Applications that do not cache use only a small fraction of available memory

旧有(1.6版本之前)的内存管理
概念上,内存空间被分成了三块独立的区域,每块区域的内存容量是按照JVM堆大小的固定
比例进行分配的:

Execution:在执行shuffle、join、sort和aggregation时,用于缓存中间数据。通
过 spark.shuffle.memoryFraction 进行配置,默认为0.2。
Storage:主要用于缓存数据块以提高性能,同时也用于连续不断地广播或发送大的任务
结果。通过`spark.storage.memoryFraction进行配置,默认为0.6。
Other:这部分内存用于存储运行Spark系统本身需要加载的代码与元数据,默认为0.2。

无论是哪个区域的内存,只要内存的使用量达到了上限,则内存中存储的数据就会被放入到
硬盘中,从而清理出足够的内存空间。这样一来,由于与执行或存储相关的数据在内存中不
存在,就会影响到整个系统的性能,导致I/O增长,或者重复计算。

多数情况下,Execution和Storage的内存容量未必会同时达到上限,但由于它们的容量是按照
百分比配置的,若比例设置不合理,很可能导致出现使用率不平衡的情况。就好比贫富不
均,自然会带来财富(内存空间)的浪费。

对于JVM的内存管理,我们还要考虑Out Of Memory(OOM)的情形,例如突然出现不可预
知的超大的数据项,就可能导致内存不够。为避免这种情况,我们就不能将内存都分配给
Spark的这三块内存空间,就好似设计电梯,必须要保证实际的承重要远大于规定的安全承重

24
内存管理

值。毕竟这种配置的方式很难保证准确估算,以满足各种复杂的数据分析场景。于是Spark提
供了一个safe fraction,以便于为内存使用提供一个安全的缓存空间。Execution与Storage内
存空间的safe fraction分别通过如下三个配置项配置:

spark.shuffle.safeFraction (默认值为0.8)

spark.storage.safeFraction (默认值为0.9)

spark.storage.unrollFraction (默认值为0.2)

以默认设置而论,用于执行的内存空间只占整个JVM堆容量的 0.2*0.8=16% ,正常情况下,内


存的利用率极低,为安全故,却又必须预留出更多的内存避免内存溢出。

Execution的内存管理
Execution内存进一步为多个运行在JVM中的任务分配内存。与整个内存分配的方式不同,这
块内存的再分配是动态分配的。在同一个JVM下,倘若当前仅有一个任务正在执行,则它可
以使用当前可用的所有Execution内存。

Spark提供了如下Manager对这块内存进行管理:

ShuffleMemoryManager :它扮演了一个中央决策者的角色,负责决定分配多少内存给哪些

任务。一个JVM对应一个 ShuffleMemoryManager 。
TaskMemoryManager :记录和管理每个任务的内存分配,它实现为一个page table,用以

跟踪堆(heap)中的块,侦测当异常抛出时可能导致的内存泄露。在其内部,调用
了 ExecutorMemoryManager 去执行实际的内存分配与内存释放。一个任务对应一
个 TaskMemoryManager 。
ExecutorMemoryManager :用于处理on-heap和off-heap的分配,实现为弱引用的池允许被

释放的page可以被跨任务重用。一个JVM对应一个 ExecutorMemeoryManager 。

内存管理的执行流程大约如下:

当一个任务需要分配一块大容量的内存用以存储数据时,首先会请求 ShuffleMemoryManager ,
告知:我想要X个字节的内存空间。如果请求可以被满足,则任务就会要
求 TaskMemoryManager 分配X字节的空间。一旦 TaskMemoryManager 更新了它内部的page
table,就会要求 ExecutorMemoryManager 去执行内存空间的实际分配。

这里有一个内存分配的策略。假定当前的active task数据为N,那么每个任务可以
从 ShuffleMemoryManager 处获得多达1/N的执行内存。分配内存的请求并不能完全得到保证,
例如内存不足,这时任务就会将它自身的内存数据释放。根据操作的不同,任务可能重新发
出请求,又或者尝试申请小一点的内存块。

25
内存管理

To avoid excessive spilling, a task does not spill unless it has acquired up to 1/(2N) of
the total memory. If there is not enough free memory to acquire even up to 1/(2N), the
request will block until other tasks spill and free their shares. Otherwise, new incoming
tasks may spill constantly while existing jumbo tasks continue to occupy much of the
memory without spilling.

释放(spill)任务的内存数据需要谨慎,因为它可能会影响系统的分析性能。为了避免出现过
度的内存数据清理操作,Spark规定:除非任务已经获得了整个内存空间的1/(2N)空间,否则
不会执行清理操作。倘若没有足够的空闲内存空间,当前任务的请求会被阻塞,直到其他任
务清理或释放了它们的内存数据。

例如,一个executor启动了唯一一个任务A(即此时的N值为1),那么该任务可以获得当前所
有可用的内存空间。当任务B也启动后,N值变为2。由于没有足够的空闲内存,任务B会被阻
塞。此时,A开始释放内存空间,当任务B获得了1/(2N)=1/4的内存空间后,所有任务都可以
执行空间的释放(spill)了。

注意,如果不是任务A无法从管理器那里获得内存,它是不会执行spill操作的。在上述例子
中,从一开始任务A就获得所有可用的内存空间,因而在它不执行spill操作的时刻,其他所有
新任务都无法得到想要的内存空间(处于饥饿状态),因为已有任务(即任务A)使用的内存
空间已经超出了它们能够平分的空间。

spill的操作是由配置项 spark.shuffle.spill 控制的,默认值为true,用于指定Shuffle过程中


如果内存中的数据超过阈值,是否需要将部分数据临时写入外部存储。如果设置为false,这
个过程就会一直使用内存,可能导致OOM。

如果Spill的频率太高,可以适当地增加spark.shuffle.memoryFraction来增加Shuffle过程的可
用内存数,进而减少Spill的频率。当然,为了避免OOM,可能就需要减少RDD cache所用的
内存(即Storage Memory)。

Storage的存储管理
Storage内存由更加通用的 BlockManager 管理。如前所说,Storage内存的主要功能是用于缓
存RDD Partitions,但也用于将容量大的任务结果传播和发送给driver。

Spark提供了Storage Level来指定块的存放位置:Memory、Disk或者Off-Heap。Storage
Level同时还可以指定存储时是否按照序列化的格式。当Storage Level被设置
为 MEMORY_AND_DISK_SER 时,内存中的数据以字节数组(byte array)形式存储,当这些数据被
存储到硬盘中时,不再需要进行序列化。若设置为该Level,则evict数据会更加高效。

Cache中的数据不会一直存在,所以会在合适的时候被Evict(可以理解抹去数据)。Spark主
要采用的Evict策略为LRU,且该策略仅针对内存中的数据块。不过,倘若一个RDD块已经在
Cache中存在,那么Spark永远不会为了缓存该RDD块的额外的块而将这个已经存在的RDD块
抹掉。这是显而易见的,倘若抹掉了这个RDD块,又何必去缓存它额外的块呢?

26
内存管理

如果 BlockManager 接收到的数据以迭代器(Iterator)形式组成,且这个Block最终需要保存到
内存中,则 BlockManager 会将迭代器展开(Unrolling),这就意味着需要耗费比迭代器更多
的内存,甚至可能该迭代器代表的数组需要的容量会超过内存空间,故而 BlockManager 只能
逐步地展开迭代器以避免OOM,在展开时,需要定期地去检查内存空间是否足够。

Unrollong使用的内存倘若不够,会从Storage Memory中借用。这个借用者其实是很霸道的,
倘若当前没有Block,他可以借走所有的Storage Memory空间。如果Storage Memory已有
block使用,他还会鹊巢鸠占,强制将内存中的block抹去,唯一约束他的
是 spark.storage.unrollFraction 配置项(默认为0.2),也就是说他抹去的内存按照这个配置
的比例计算,也不是肆无忌惮的。

1.6版本的内存管理

新的配置项
到了1.6版本,Execution Memory和Storage Memory之间支持跨界使用。当执行内存不够
时,可以借用存储内存,反之亦然。

1.6版本的实现方案支持借来的存储内存随时都可以释放,但借来的执行内存却不能如此。

新的版本引入了新的配置项:

spark.memory.fraction(默认值为0.75):用于设置存储内存和执行内存占用堆内存的比
例。若值越低,则发生spill和evict的频率就越高。注意,设置比例时要考虑Spark自身需
要的内存量。
spark.memory.storageFraction(默认值为0.5):显然,这是存储内存所
占 spark.memory.fraction 设置比例内存的大小。当整体的存储容量超过该比例对应的容
量时,缓存的数据会被evict。
spark.memory.useLegacyMode(默认值为false):若设置为true,则使用1.6版本前的
内存管理机制。此时,如下五项配置均生效:
spark.storage.memoryFraction
spark.storage.safetyFraction
spark.storage.unrollFraction
spark.shuffle.memoryFraction
spark.shuffle.safetyFraction

如何应对内存压力
当内存不足时,我们需要Evict内存中已有的数据,但是这需要考虑Evict数据的成本。对于存
储内存,eviction的成本取决于设置的Storage Level。如果设置为 MEMORY_ONLY 就意味着一旦
内存中的数据被evict,当再次需要的时候就需要重新计算,以此而推,设置为 ​

27
内存管理

EMORY_AND_DISK_SER 自然成本最低,因为内容可以从硬盘中直接获取,无需重新计算,也不需

要重新序列化内容,因而唯一的损耗是I/O。

执行内存的Evict就完全不同了,由于被Evict的数据都被spill到磁盘中了,故而执行内存存储
的数据无需重新计算,且执行数据是以压缩格式存储的,故而降低了序列化的成本。

但是,这并不意味着Evict执行内存的成本就一定低于存储内存。根据执行内存的本质,在数
据分析过程中,常常需要将spilled的执行内存读回,这就需要维护一个引用。如果不考虑重计
算的成本,Evict执行内存的成本甚至要远远高于存储内存。

正是因为这个原因,实现存储内存的Eviction相对更容易,只需要使用现有的Eviction机制清
除掉对应的数据块即可。

MemoryManager
1.6版本的内存管理主要由类 MemoryManager 承担。这是一个抽象类,提供的主要方法包括:

def acquireExecutionMemory(numBytes:Long):Long

def acquireStorageMemory(blockId:BlockId,numBytes:Long):Long

def releaseExecutionMemory(numBytes:Long):Unit

def releaseStorageMemory(numBytes:Long):Unit

继承这个抽象类的子类包括 StaticMemoryManager 、 UnifiedMemoryManager 。前者就是1.6版本


之前的内存管理器,后者则实现了最新的内存管理机制。

阅读 UnifiedMemoryManager 类中的主要方
法 acquireExecutionMemory 与 acquireStorageMemory ,其执行流程还是非常清楚
的。 acquireExecutionMemory 方法的实现如下:

/**
* Try to acquire up to `numBytes` of execution memory for the current task and retu
rn the
* number of bytes obtained, or 0 if none can be allocated.
*
* This call may block until there is enough free memory in some situations, to make
sure each
* task has a chance to ramp up to at least 1 / 2N of the total memory pool (where N
is the # of
* active tasks) before it is forced to spill. This can happen if the number of task
s increase
* but an older task had a lot of memory already.
*/
override private[memory] def acquireExecutionMemory(
numBytes: Long,

28
内存管理

taskAttemptId: Long,
memoryMode: MemoryMode): Long = synchronized {
assert(onHeapExecutionMemoryPool.poolSize + storageMemoryPool.poolSize == maxMemor
y)
assert(numBytes >= 0)
memoryMode match {
case MemoryMode.ON_HEAP =>

/**
* Grow the execution pool by evicting cached blocks, thereby shrinking the st
orage pool.
*
* When acquiring memory for a task, the execution pool may need to make multi
ple
* attempts. Each attempt must be able to evict storage in case another task j
umps in
* and caches a large block between the attempts. This is called once per atte
mpt.
*/
def maybeGrowExecutionPool(extraMemoryNeeded: Long): Unit = {
if (extraMemoryNeeded > 0) {
// There is not enough free memory in the execution pool, so try to reclai
m memory from
// storage. We can reclaim any free memory from the storage pool. If the s
torage pool
// has grown to become larger than `storageRegionSize`, we can evict block
s and reclaim
// the memory that storage has borrowed from execution.
val memoryReclaimableFromStorage =
math.max(storageMemoryPool.memoryFree, storageMemoryPool.poolSize - stor
ageRegionSize)
if (memoryReclaimableFromStorage > 0) {
// Only reclaim as much space as is necessary and available:
val spaceReclaimed = storageMemoryPool.shrinkPoolToFreeSpace(
math.min(extraMemoryNeeded, memoryReclaimableFromStorage))
onHeapExecutionMemoryPool.incrementPoolSize(spaceReclaimed)
}
}
}

/**
* The size the execution pool would have after evicting storage memory.
*
* The execution memory pool divides this quantity among the active tasks even
ly to cap
* the execution memory allocation for each task. It is important to keep this
greater
* than the execution pool size, which doesn't take into account potential mem
ory that
* could be freed by evicting storage. Otherwise we may hit SPARK-12155.
*
* Additionally, this quantity should be kept below `maxMemory` to arbitrate f
airness

29
内存管理

* in execution memory allocation across tasks, Otherwise, a task may occupy m


ore than
* its fair share of execution memory, mistakenly thinking that other tasks ca
n acquire
* the portion of storage memory that cannot be evicted.
*/
def computeMaxExecutionPoolSize(): Long = {
maxMemory - math.min(storageMemoryUsed, storageRegionSize)
}

onHeapExecutionMemoryPool.acquireMemory(
numBytes, taskAttemptId, maybeGrowExecutionPool, computeMaxExecutionPoolSize
)

case MemoryMode.OFF_HEAP =>


// For now, we only support on-heap caching of data, so we do not need to inte
ract with
// the storage pool when allocating off-heap memory. This will change in the f
uture, though.
offHeapExecutionMemoryPool.acquireMemory(numBytes, taskAttemptId)
}
}

以上代码中,最重要的实现放在函数 maybeGrowExecutionPool 中。这个方法会判断是否需要增


加执行内存,倘若事先设置的执行内存空间没有足够可用的内存,就会尝试从存储内存中借
用。倘若存储内存的空间已经大于 storageRegionSize 设置的值,就需要根据借用的内存大小
把存储内存中的存储块evict。

调整内存大小以及Evict存储块,是在 maybeGrowExecutionPool 函数内部中调


用 StorageMemoryPool 的函数 shrinkPoolToFreeSpace 来完成的:

30
内存管理

def shrinkPoolToFreeSpace(spaceToFree: Long): Long = lock.synchronized {


// First, shrink the pool by reclaiming free memory:
val spaceFreedByReleasingUnusedMemory = math.min(spaceToFree, memoryFree)
decrementPoolSize(spaceFreedByReleasingUnusedMemory)
val remainingSpaceToFree = spaceToFree - spaceFreedByReleasingUnusedMemory
if (remainingSpaceToFree > 0) {
// If reclaiming free memory did not adequately shrink the pool, begin evicting
blocks:
val spaceFreedByEviction = memoryStore.evictBlocksToFreeSpace(None, remainingSpa
ceToFree)
// When a block is released, BlockManager.dropFromMemory() calls releaseMemory()
, so we do
// not need to decrement _memoryUsed here. However, we do need to decrement the
pool size.
decrementPoolSize(spaceFreedByEviction)
spaceFreedByReleasingUnusedMemory + spaceFreedByEviction
} else {
spaceFreedByReleasingUnusedMemory
}
}

31
Spark SQL

Spark SQL
Spark SQL主要用于结构型数据处理,它的前身为Shark,在Spark 1.3.0版本后才成长为正式
版,可以彻底摆脱之前Shark必须依赖HIVE的局面。与过去的Shark相比,一方面Spark SQL
提供了强大的DataFrame API,另一方面则是利用Catalyst优化器,并充分利用了Scala语言
的模式匹配与quasiquotes,为Spark提供了更好的查询性能。

在Databricks工程师撰写的论文《Spark SQL: Relational Data Processing in Spark》中,给


出了Spark SQL与Shark以及Impala三者间的性能对比,如下图所示:

Michael Armbrust、Yin Huai等人写的博客《Deep Dive into Spark SQL’s Catalyst


Optimizer》简单介绍了Catalyst的优化机制。

就我看来,随着Spark SQL引入DataFrame以及它对多种数据源的支持,在多数大数据分析场
景下,Spark SQL会逐渐取代Spark原来基于RDD的编程模式。

Spark SQL利用SchemaRDD为结构型数据的访问提供了统一接口,包括对Hive表、parquet
文件、csv文件以及Json文件:

根据DataStax给出的Supported Syntax of Spark SQL,指出了Spark SQL支持的语法:

SELECT [DISTINCT] [column names]|[wildcard]


FROM [kesypace name.]table name
[JOIN clause table name ON join condition]
[WHERE condition]
[GROUP BY column name]
[HAVING conditions]
[ORDER BY column names [ASC | DSC]]

如果使用join进行查询,则支持的语法为:

32
Spark SQL

SELECT statement
FROM statement
[JOIN | INNER JOIN | LEFT JOIN | LEFT SEMI JOIN | LEFT OUTER JOIN | RIGHT JOIN | RIGHT
OUTER JOIN | FULL JOIN | FULL OUTER JOIN]
ON join condition

33
DataFrame

DataFrame
对于结构型的DataSet,DataFrame提供了更方便更强大的操作运算。事实上,我们可以简单
地将DataFrame看做是对RDD的一个封装或者增强,使得Spark能够更好地应对诸如数据表、
JSON数据等结构型数据样式(Schema),而不是传统意义上多数语言提供的集合数据结
构。在一个数据分析平台中增加对DataFrame的支持,其实也是题中应有之义。诸如R语言、
Python的数据分析包pandas都支持对Data Frame数据结构的支持。事实上,Spark
DataFrame的设计灵感正是基于R与Pandas。

DataFrame本质上是一个分布式集合,即组合了Row对象的RDD。Row内部的数据结构由一
组命名了的列(named columns)组成。如果观察Row的定义,可以看到定义在其中的schema
属性,它的类型为StructType。

@Experimental
class DataFrame private[sql](
@transient val sqlContext: SQLContext,
@DeveloperApi @transient val queryExecution: SQLContext#QueryExecution)
extends RDDApi[Row] with Serializable

trait Row extends Serializable {


/**
* Schema for the row.
*/
def schema: StructType = null
}

@DeveloperApi
case class StructType(fields: Array[StructField]) extends DataType with Seq[StructField
]

以上代码为Spark 1.3,可以看到DataFrame并没有继承自RDD,而是继承了一个名为
RDDApi的trait。该trait提供了与RDD非常相似的Api:

34
DataFrame

private[sql] trait RDDApi[T] {

def cache(): this.type

def persist(): this.type

def persist(newLevel: StorageLevel): this.type

def unpersist(): this.type

def unpersist(blocking: Boolean): this.type

def map[R: ClassTag](f: T => R): RDD[R]

def flatMap[R: ClassTag](f: T => TraversableOnce[R]): RDD[R]

def mapPartitions[R: ClassTag](f: Iterator[T] => Iterator[R]): RDD[R]

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

def foreachPartition(f: Iterator[T] => Unit): Unit

def take(n: Int): Array[T]

def collect(): Array[T]

def collectAsList(): java.util.List[T]

def count(): Long

def first(): T

def repartition(numPartitions: Int): DataFrame

def coalesce(numPartitions: Int): DataFrame

def distinct: DataFrame


}

为了理解方便,我们可以将DataFrame视为数据库中的一张表。Spark SQL可以将结构型数据
文件(Json、Parquet)、Hive表、外部数据源以及现有的RDD创建为一个DataFrame。

例如我们通过外部Json文件创建一个DataFrame:

val dataFrame = sqlContext.load("/example/data.json", "json")


dataFrame.show()

在加载结构数据文件时,默认的类型为Parquet,此时可以在加载时不用指定文件类型:

35
DataFrame

val dataFrame = sqlContext.load("/example/person.parquet")

当然,你也可以不调用load()方法,而是根据数据源选择对应的方法,例如加载json文件可以
调用jsonFile()方法,加载parquet文件,调用parquetFile()方法。

针对数据操作,DataFrame提供了对应的DSL风格的APIs,例如常见的select、filter、
groupBy和join()等。例如:

young = users.filter(users.age < 21)


young = users.select(young.name, young.gender)
young.groupBy("gender").count
young.join(logs, logs.userId == users.userId, "left_outer")

当然,为了更好地和关系型数据库操作契合,Spark SQL支持以SQL方式操作数据。这可以通
过调用SqlContext的sql()方法来完成。在执行SQL之前,需要讲加载的数据注册为临时表:

val dataFrame = sqlContext.load("example/person.parquet")


dataFrame.registerTempTable("person")
dataFrame.sql("SELECT count(*) FROM person")

注册的临时表为SQLContext所使用,在程序退出后就会消失。

DataFrame提供了和RDD相似的lazy机制,将作用于它之上的操作分为Transform与Action两
大类,只有在调用DataFrame的Action操作,才会真正被执行计算。例如,在上述代码中,并
没有真正执行select语句,只有当调用诸如show,collect或者save等操作时,才会真正执行。
采用这种方式,可以使执行的计算能够被更好地优化。 若从编码角度思考,Spark SQL优势
在于两种编程风格可以兼而有之,鱼与熊掌二者兼得,从而使得编码更加自如而赏心悦目。
例如,我们可以先通过SQL语句的执行方式获得DataFrame,由于DataFrame本身是一个
Monad,我们又可以对其调用map、filter等操作。例如我们可以将每一行数据map为一个
tuple:

val firstPerson = dataFrame.sql("SELECT * FROM person)


.map(row => (row.getLong(0), row.getString(1), row.getStrin
g(2))
.first()

由于DataFrame中存储的是Row对象,在map时可以根据各个列的类型选择调用Row的
getXXX()方法。如果开发语言选择Python,则可以只通过传入下表来获得。Scala和Java的静
态语言特性存在一定的限制,虽然在Scala中可以通过调用get(i: Int): Any获得对应列的值,但
仍然需要安全的类型转换。

集合转换为DataFrame

36
DataFrame

我们也可以通过调用parallelize()方法结合集合创建需要分析的数据,并创建数据表的
Schema。例如,创建一个具有三列(Field)两行的表:

import org.apache.spark.sql._
import org.apache.spark.sql.types._

val row1 = Row("Bruce Zhang", "developer", 38 )


val row2 = Row("Zhang Yi", "engineer", 39)
val table = List(row1, row2)
val rows = sc.parallelize(table)

val schema = StructType(Array(StructField("name", StringType, true),StructField("role"


, StringType, true), StructField("age", IntegerType, true)))
sqlContext.createDataFrame(rows, schema).registerTempTable("employees")

sqlContext.sql("SELECT * FROM employees").collectAsList()

Spark SQL UDFs


UDFs即User-Defined Functions,它使得我们可以注册自己编写的函数,用以丰富SQL执
行。通过SQLContext可以得到udf来注册函数。在Scala中,它支持正常定义的scala方法、函
数或者表达式。例如,针对前面的DataFrame,我们注册一个获取字符串长度的函数。有三
种方式:

//方式1:使用表达式
sqlContext.udf.register("lenOfStr", (_:String).length)

//方式2:使用方法
def len(s: String) = s.length
sqlContext.udf.register("lenOfStr", len _)

//方式3:使用函数
val length: String => Int = _.length
sqlContext.udf.register("lenOfStr", length)

sqlContext.sql("SELECT lenOfStr('name') FROM employees LIMIT 10")

join操作
DataFrame之间可以直接进行join。Spark SQL会默认为DataFrame的列命名为_n,其中n的
值从1开始。join的关键字就可以利用这个默认的列名(attribute),例如:

37
DataFrame

val df1 = sqlContext.createDataFrame(Seq((1, "a"), (2, "b"), (3, "b"), (4, "b")))
val df2 = sqlContext.createDataFrame(Seq((1, 10), (2, 20), (3, 30), (4, 40)))

df1.join(df2, df1("_1") === df2("_1")).printSchema

连接后,打印出来的schema如下所示:

root
|-- _1: integer (nullable = false)
|-- _2: string (nullable = true)
|-- _1: integer (nullable = false)
|-- _2: integer (nullable = false)

由于默认的列名都采用了同样的命名规则,在join时都需要指定DataFrame。为了避免列名的
混淆,并简化调用,DataFrame还支持别名的形式:

df1.as('a).join(df2.as('b), $"a._1" === $"b._1")

或者为列名重命名:

df1.join(df2.withColumnRenamed("_1", "id"), $"_1" === "id")

从Spark 1.4后,DataFrame增加了一个新的join()方法重载,支持直接传入列名的方式,简化
了如上实现方式。新增的join()方法定义为:

def join(right: DataFrame, usingColumn: String): DataFrame

前面的例子就可以简化为:

df1.join(df2, "_1")

缓存
DataFrame通过cache()方法提供了缓存功能,但它并非立即执行的。调用cache()方法后,若
第一次执行action操作,此时缓存的执行被触发,但本次执行并没有享受到缓存的福利;只有
当执行第二次时,执行的操作才会从缓存中取出,而非再度访问数据源,除非缓存已失效。
例如:

38
DataFrame

val df = sqlContext.sql("select c1, sum(c2) from T1, T2 where T1.key=T2.key group by c


1")
df.cache() // 缓存执行后的dataframe,但它是延迟执行
df.registerAsTempTable("my_result")

sqlContext.sql("select * from my_result where c1=1").collect // 第一次查询时,缓存被触发


sqlContext.sql("select * from my_result where c1=1").collect // 直接从缓存中查询

Spark SQL还支持对特定表的缓存:

sqlContext.cacheTable("T1")
sqlContext.cacheTable("T2")

对表进行缓存时,给定的表名必须与注册的表名保持一致。注意表名是大小写敏感的。如果
给定错误的表名,则会提示:

java.lang.RuntimeException: Table Not Found: Employees1


at scala.sys.package$.error(package.scala:27)
at org.apache.spark.sql.catalyst.analysis.SimpleCatalog$$anonfun$1.apply(Catalog.sca
la:111)
at org.apache.spark.sql.catalyst.analysis.SimpleCatalog$$anonfun$1.apply(Catalog.sca
la:111)
at scala.collection.MapLike$class.getOrElse(MapLike.scala:128)
at scala.collection.AbstractMap.getOrElse(Map.scala:59)
at org.apache.spark.sql.catalyst.analysis.SimpleCatalog.lookupRelation(Catalog.scala
:111)
at org.apache.spark.sql.SQLContext.table(SQLContext.scala:945)
at org.apache.spark.sql.CacheManager.cacheTable(CacheManager.scala:51)
at org.apache.spark.sql.SQLContext.cacheTable(SQLContext.scala:215)
... 49 elided

我们可以通过Spark Application UI去查看当前的存储状况。假定我们已经在SQLContext中注


册了表Employees,然后对该表进行缓存:

sqlContext.cacheTable("Employees")

查看UI,可以发现存储状况显示为空,说明数据并没有被缓存:

39
DataFrame

只有当执行了真正触发了任务执行的方法,例如collect()后,才能看到存储信息中有了缓存的
信息:

缓存的名称为In-Memory table Employees,同时还可以看到缓存分区、内存容量、磁盘容量


等消息。打开该缓存,还能看到更加详细的信息:

当我们再执行uncacheTable()方法后,则可观察到缓存的内容消失了。

我们很少对一个表的所有列感兴趣,我们可以挑选出需要的列(类似数据清洗)作为一个单
独的表进行缓存,从而减小内存的压力,并提升数据处理性能。缓存在内存中的数据仍然保
持了DataFrame的数据结构。

40
UDF与UDAF

UDF与UDAF
在数据分析领域中,没有人能预见所有的数据运算,以至于将它们都内置好,一切准备完
好,用户只需要考虑用,万事大吉。扩展性是一个平台的生存之本,一个封闭的平台如何能
够拥抱变化?在对数据进行分析时,无论是算法也好,分析逻辑也罢,最好的重用单位自然
还是:函数。

故而,对于一个大数据处理平台而言,倘若不能支持函数的扩展,确乎是不可想象的。Spark
首先是一个开源框架,当我们发现一些函数具有通用的性质,自然可以考虑contribute给社
区,直接加入到Spark的源代码中。我们欣喜地看到随着Spark版本的演化,确实涌现了越来
越多对于数据分析师而言称得上是一柄柄利器的强大函数,例如博客文章《Spark 1.5
DataFrame API Highlights: Date/Time/String Handling, Time Intervals, and UDAFs》介绍了
在1.5中为DataFrame提供了丰富的处理日期、时间和字符串的函数;以及在Spark SQL 1.4中
就引入的Window Function。

然而,针对特定领域进行数据分析的函数扩展,Spark提供了更好地置放之处,那就是所谓
的“UDF(User Defined Function)”。

UDF的引入极大地丰富了Spark SQL的表现力。一方面,它让我们享受了利用Scala(当然,
也包括Java或Python)更为自然地编写代码实现函数的福利,另一方面,又能精简SQL(或
者DataFrame的API),更加写意自如地完成复杂的数据分析。尤其采用SQL语句去执行数据
分析时,UDF帮助我们在SQL函数与Scala函数之间左右逢源,还可以在一定程度上化解不同
数据源具有歧异函数的尴尬。想想不同关系数据库处理日期或时间的函数名称吧!

用Scala编写的UDF与普通的Scala函数没有任何区别,唯一需要多执行的一个步骤是要让
SQLContext注册它。例如:

def len(bookTitle: String):Int = bookTitle.length

sqlContext.udf.register("len", len _)

val booksWithLongTitle = sqlContext.sql("select title, author from books where len(tit


le) > 10")

编写的UDF可以放到SQL语句的fields部分,也可以作为where、groupBy或者having子句的一
部分。

既然是UDF,它也得保持足够的特殊性,否则就完全与Scala函数泯然众人也。这一特殊性不
在于函数的实现,而是思考函数的角度,需要将UDF的参数视为数据表的某个列。例如上
面 len 函数的参数 bookTitle ,虽然是一个普通的字符串,但当其代入到Spark SQL的语句
中,实参 title 实际上是表中的一个列(可以是列的别名)。

41
UDF与UDAF

当然,我们也可以在使用UDF时,传入常量而非表的列名。让我们稍稍修改一下刚才的函
数,让长度10作为函数的参数传入:

def lengthLongerThan(bookTitle: String, length: Int): Boolean = bookTitle.length > len


gth

sqlContext.udf.register("longLength", lengthLongerThan _)

val booksWithLongTitle = sqlContext.sql("select title, author from books where longLen


gth(title, 10)")

若使用DataFrame的API,则可以以字符串的形式将UDF传入:

val booksWithLongTitle = dataFrame.filter("longLength(title, 10)")

DataFrame的API也可以接收Column对象,可以用 $ 符号来包裹一个字符串表示一个
Column。 $ 是定义在SQLContext对象implicits中的一个隐式转换。此时,UDF的定义也不相
同,不能直接定义Scala函数,而是要用定义在 org.apache.spark.sql.functions 中的udf方法
来接收一个函数。这种方式无需register:

import org.apache.spark.sql.functions._

val longLength = udf((bookTitle: String, length: Int) => bookTitle.length > length)

import sqlContext.implicits._
val booksWithLongTitle = dataFrame.filter(longLength($"title", $"10"))

注意,代码片段中的 sqlContext 是之前已经实例化的SQLContext对象。

不幸,运行这段代码会抛出异常:

cannot resolve '10' given input columns id, title, author, price, publishedDate;

因为采用 $ 来包裹一个常量,会让Spark错以为这是一个Column。这时,需要定义在
org.apache.spark.sql.functions中的 lit 函数来帮助:

val booksWithLongTitle = dataFrame.filter(longLength($"title", lit(10)))

普通的UDF却也存在一个缺陷,就是无法在函数内部支持对表数据的聚合运算。例如,当我
要对销量执行年度同比计算,就需要对当年和上一年的销量分别求和,然后再利用同比公式
进行计算。此时,UDF就无能为力了。

该UDAF(User Defined Aggregate Function)粉墨登场的时候了。

42
UDF与UDAF

Spark为所有的UDAF定义了一个父类 UserDefinedAggregateFunction 。要继承这个类,需要实


现父类的几个抽象方法:

def inputSchema: StructType

def bufferSchema: StructType

def dataType: DataType

def deterministic: Boolean

def initialize(buffer: MutableAggregationBuffer): Unit

def update(buffer: MutableAggregationBuffer, input: Row): Unit

def merge(buffer1: MutableAggregationBuffer, buffer2: Row): Unit

def evaluate(buffer: Row): Any

可以将 inputSchema 理解为UDAF与DataFrame列有关的输入样式。例如年同比函数需要对某


个可以运算的指标与时间维度进行处理,就需要在 inputSchema 中定义它们。

def inputSchema: StructType = {


StructType(StructField("metric", DoubleType) :: StructField("timeCategory", DateTy
pe) :: Nil)
}

代码创建了拥有两个 StructField 的 StructType 。 StructField 的名字并没有特别要求,完


全可以认为是两个内部结构的列名占位符。至于UDAF具体要操作DataFrame的哪个列,取决
于调用者,但前提是数据类型必须符合事先的设置,如这里的 DoubleType 与 DateType 类型。
这两个类型被定义在 org.apache.spark.sql.types 中。

bufferSchema 用于定义存储聚合运算时产生的中间数据结果的Schema,例如我们需要存储

当年与上一年的销量总和,就需要定义两个 StructField :

def bufferSchema: StructType = {


StructType(StructField("sumOfCurrent", DoubleType) :: StructField("sumOfPrevious",
DoubleType) :: Nil)
}

dataType 标明了UDAF函数的返回值类型, deterministic 是一个布尔值,用以标记针对给

定的一组输入,UDAF是否总是生成相同的结果。

顾名思义, initialize 就是对聚合运算中间结果的初始化,在我们这个例子中,两个求和的


中间值都被初始化为0d:

43
UDF与UDAF

def initialize(buffer: MutableAggregationBuffer): Unit = {


buffer.update(0, 0.0)
buffer.update(1, 0.0)
}

update 函数的第一个参数为bufferSchema中两个Field的索引,默认以0开始,所以第一行就

是针对“sumOfCurrent”的求和值进行初始化。

UDAF的核心计算都发生在 update 函数中。在我们这个例子中,需要用户设置计算同比的时


间周期。这个时间周期值属于外部输入,但却并非 inputSchema 的一部分,所以应该从UDAF
对应类的构造函数中传入。我为时间周期定义了一个样例类,且对于同比函数,我们只要求
输入当年的时间周期,上一年的时间周期可以通过对年份减1来完成:

case class DateRange(startDate: Timestamp, endDate: Timestamp) {


def in(targetDate: Date): Boolean = {
targetDate.before(endDate) && targetDate.after(startDate)
}
}

class YearOnYearBasis(current: DateRange) extends UserDefinedAggregateFunction {


def update(buffer: MutableAggregationBuffer, input: Row): Unit = {
if (current.in(input.getAs[Date](1))) {
buffer(0) = buffer.getAs[Double](0) + input.getAs[Double](0)
}
val previous = DateRange(subtractOneYear(current.startDate), subtractOneYear(curre
nt.endDate))
if (previous.in(input.getAs[Date](1))) {
buffer(1) = buffer.getAs[Double](0) + input.getAs[Double](0)
}
}
}

update 函数的第二个参数 input: Row 对应的并非DataFrame的行,而是被inputSchema投影

了的行。以本例而言,每一个input就应该只有两个Field的值。倘若我们在调用这个UDAF函
数时,分别传入了销量和销售日期两个列的话,则 input(0) 代表的就是销量, input(1) 代
表的就是销售日期。

merge 函数负责合并两个聚合运算的buffer,再将其存储到 MutableAggregationBuffer 中:

def merge(buffer1: MutableAggregationBuffer, buffer2: Row): Unit = {


buffer1(0) = buffer1.getAs[Double](0) + buffer2.getAs[Double](0)
buffer1(1) = buffer1.getAs[Double](1) + buffer2.getAs[Double](1)
}

最后,由 evaluate 函数完成对聚合Buffer值的运算,得到最后的结果:

44
UDF与UDAF

def evaluate(buffer: Row): Any = {


if (buffer.getDouble(1) == 0.0)
0.0
else
(buffer.getDouble(0) - buffer.getDouble(1)) / buffer.getDouble(1) * 100
}

假设我们创建了这样一个简单的DataFrame:

val conf = new SparkConf().setAppName("TestUDF").setMaster("local[*]")


val sc = new SparkContext(conf)
val sqlContext = new SQLContext(sc)

import sqlContext.implicits._

val sales = Seq(


(1, "Widget Co", 1000.00, 0.00, "AZ", "2014-01-01"),
(2, "Acme Widgets", 2000.00, 500.00, "CA", "2014-02-01"),
(3, "Widgetry", 1000.00, 200.00, "CA", "2015-01-11"),
(4, "Widgets R Us", 2000.00, 0.0, "CA", "2015-02-19"),
(5, "Ye Olde Widgete", 3000.00, 0.0, "MA", "2015-02-28")
)

val salesRows = sc.parallelize(sales, 4)


val salesDF = salesRows.toDF("id", "name", "sales", "discount", "state", "saleDate"
)
salesDF.registerTempTable("sales")

那么,要使用之前定义的UDAF,则需要实例化该UDAF类,然后再通过udf进行注册:

val current = DateRange(Timestamp.valueOf("2015-01-01 00:00:00"), Timestamp.valueO


f("2015-12-31 00:00:00"))
val yearOnYear = new YearOnYearBasis(current)

sqlContext.udf.register("yearOnYear", yearOnYear)
val dataFrame = sqlContext.sql("select yearOnYear(sales, saleDate) as yearOnYear f
rom sales")
dataFrame.show()

在使用上,除了需要对UDAF进行实例化之外,与普通的UDF使用没有任何区别。但显然,
UDAF更加地强大和灵活。如果Spark自身没有提供符合你需求的函数,且需要进行较为复杂
的聚合运算,UDAF是一个不错的选择。

通过Spark提供的UDF与UDAF,你可以慢慢实现属于自己行业的函数库,让Spark SQL变得
越来越强大,对于使用者而言,却能变得越来越简单。

45
UDF与UDAF

46
DataSources

DataSources
随着Spark SQL的正式发布,以及它对DataFrame的支持,它可能会取代HIVE成为越来越重
要的针对结构型数据进行分析的平台。在博客文章What’s new for Spark SQL in Spark 1.3
中,Databricks的工程师Michael Armbrust着重介绍了改进了的Data Source API。

我们在对结构型数据进行分析时,总不可避免会遭遇多种数据源的情况。这些数据源包括
Json、CSV、Parquet、关系型数据库以及NoSQL数据库。我们自然希望能够以统一的接口
来访问这些多姿多态的数据源。

Parquet
Apache Parquet是被主要用于Hadoop生态系统中的列式存储格式,它与数据模型、编程语言
以及数据处理框架无关。Parquet在数据压缩、列式数据展现等方面具有非常大的优势。
Parquet文件schema的设计灵感来自Dremel的论文。

Parquet格式可以简单地分为四个层次,分别为File、Row Group、Column Chunk和Page。


一个File通常包含一到多个Row Group。Row Group是一种对数据的逻辑水平分区,从而将数
据分为多个行(Row)。一个Row Group包含多个Column Chunk,其中,每个Column
Chunk都是针对一个特定列的数据块。一个Column Chunk又被分解为多个Page。为了压缩和
编码的方便,Parquet将Page作为最小的不可分割单元。

为了支持对Parquet数据的高效编码,Parquet提供了元数据文件,元数据包含了所有列元数
据开始的位置。元数据会在写入数据之后被写入,而在读取Parquet数据时,会首先读取文件
的元数据找到所要处理的column chunk。在读取Row Group包含的column chunk时,以顺序
方式读取。下图是Parquet官方网站给出的文件格式示意图:

47
DataSources

为了支持对Parquet数据的高效编码,Parquet提供了元数据文件,元数据包含了所有列元数
据开始的位置。元数据会在写入数据之后被写入,而在读取Parquet数据时,会首先读取文件
的元数据找到所要处理的column chunk。在读取Row Group包含的column chunk时,以顺序
方式读取。下图是Parquet官方网站给出的文件格式示意图:

DataFrame提供了saveAsParquetFile()方法,可以将DataFrame的内容写到parquet文件中。
例如:

val row1 = Row("Bruce Zhang", "developer", 38 )


val row2 = Row("Zhang Yi", "engineer", 39)
val table = List(row1, row2)
val rows = sc.parallelize(table)

import org.apache.spark.sql.types._

val schema = StructType(Array(StructField("name", StringType, true),StructField("role"


, StringType, true), StructField("age", IntegerType, true)))
sqlContext.createDataFrame(rows, schema).registerTempTable("employee")

sqlContext.sql("select * from employee").saveAsParquetFile("employee.parquet")

48
DataSources

saveAsParquetFile()方法接受的是路径名,可以是本地路径,也可以是HDFS的路径。因而,
这里生成的是employee.parquet目录,parquet数据文件和元数据文件皆放在这个目录下:

49
External DataSources

External DataSources

访问JDBC
如果我们要通过JDBC访问关系型数据库,例如PostgreSQL,则可以通过Spark SQL提供的
JDBC来访问,前提是需要PostgreSQL的driver。方法是在build.sbt中添加对应版本的driver依
赖。例如:

libraryDependencies ++= {
val sparkVersion = "1.3.0"
Seq(
"org.apache.spark" %% "spark-core" % sparkVersion,
"org.apache.spark" %% "spark-sql" % sparkVersion,
"org.postgresql" % "postgresql" % "9.4-1201-jdbc41"
)
}

根据Spark SQL的官方文档,在调用Data Sources API时,可以通过SQLContext加载远程数


据库为Data Frame或Spark SQL临时表。加载时,可以传入的参数(属性)包括:url、
dbtable、driver、partitionColumn、lowerBound、upperBound与numPartitions。

PostgreSQL Driver的类名为org.postgresql.Driver。由于属性没有user和password,因此要将
它们作为url的一部分。假设我们要连接的数据库服务器IP为192.168.1.110,端口为5432,用
户名和密码均为test,数据库为demo,要查询的数据表为tab_users,则访问PostgreSQL的
代码如下所示:

object PostgreSqlApp {
def main(args: Array[String]): Unit = {
val sparkConf = new SparkConf().setAppName("FromPostgreSql").setMaster("local[2]")
val sc = new SparkContext(sparkConf)
val sqlContext = new SQLContext(sc)

val query = "(SELECT * FROM tab_users) as USERS"


val url = "jdbc:postgresql://192.168.1.110:5432/demo?user=test&password=test"
val users = sqlContext.load("jdbc", Map(
"url" -> url,
"driver" -> "org.postgresql.Driver",
"dbtable" -> query
))

users.foreach(println)
}
}

50
External DataSources

上面的代码将查询语句直接放在query变量中,并传递给SQLContext用以加载。注意在上述
的query值中,必须要为执行的sql语句添加别名,如代码中的USERS,否则会抛出异常:

com.mysql.jdbc.exceptions.jdbc4.MySQLSyntaxErrorException: Every derived table


must have its own alias

另一种方式是直接传递表名,然后通过调用registerTempTable()方法来注册临时表,并调用
sql()方法执行查询:

object PostgreSqlApp {
def main(args: Array[String]): Unit = {
val sparkConf = new SparkConf().setAppName("FromPostgreSql").setMaster("local[2]")
val sc = new SparkContext(sparkConf)
val sqlContext = new SQLContext(sc)

val url = "jdbc:postgresql://192.168.1.110:5432/demo?user=test&password=test"


val dataFrame = sqlContext.load("jdbc", Map(
"url" -> url,
"driver" -> "org.postgresql.Driver",
"dbtable" -> "tab_users"
))

dataFrame.registerTempTable("USERS")
val users = sqlContext.sql("select * from USERS")
users.foreach(println)
}
}

如果查询牵涉到多张表,例如对表进行join,则需要利用SQLContext的load方法分别将这多张
表加载到DataFrame中。注意这种load工作其实仅仅是加载了对应表的schema。

如果查询的SQL语句为:

select t1._salory as salory,


t1._name as employeeName,
t2._name as locationName
from mock_employees t1
inner join mock_locations t2
on t1._location_id = t2._id
where t1._salary > t2._max_price

则实现为:

51
External DataSources

val employeeDF = sqlContext.load("jdbc", Map(


"url" -> url,
"driver" -> "com.mysql.jdbc.Driver",
"dbtable" -> "mock_employees"
))

val locationDF = sqlContext.load("jdbc", Map(


"url" -> url,
"driver" -> "com.mysql.jdbc.Driver",
"dbtable" -> "mock_locations"
))

employeeDF.registerTempTable("Employees")
locationDF.registerTempTable("Locations")

val result = sqlContext.sql(


"""
|select t1._salary as salary,
|t1._name as employeeName,
|t2._name locationName
|from Employees t1
|inner join Locations t2
|on t1._location_id = t2._id
|where t1._salary > t2._max_price
""".stripMargin
)

当然,我们也可以直接将sql语句作为load()方法中dbtable的值传入,实现代码为:

val query =
"""
|(select t1._salary as salary,
|t1._name as employeeName,
|t2._name locationName
|from mock_employees t1
|inner join mock_locations t2
|on t1._location_id = t2._id
|where t1._salary > t2._max_price) as EMP
""".stripMargin
val dataFrame = sqlContext.load("jdbc", Map(
"url" -> url,
"driver" -> "com.mysql.jdbc.Driver",
"dbtable" -> query
))

52
External DataSources

可以调用DataFrame的queryExecution来查看执行计划,发现前者的执行计划与后者有天壤之
别。第一种方式的queryExecution结果:

第二种方式的queryExecution结果:

显然,第一种方式运用catalyst对其进行了一定程度的优化,例如建立了filter、join等执行计
划,后者则保持了sql语句的原样,本质上讲就成了对JDBC的一个包装,因而无法享受到
catalyst优化器带来的福利。

53
External DataSources

Spark SQL的DataFrame自身提供了可以操作数据的API,也支持使用SQL语法。例如,下面
两段代码的运行结果除了count列名稍有不同外,schema与数据完全是一样的:

//使用SQL语法
val emps = sqlContext.sql("SELECT name, count(*) FROM Employees WHERE salary > 4000.0
GROUP BY name")

//使用DataFrame的API
emps.filter(emps("salary") > 4000.0).groupBy("name").count

使用Spark-Shell
Spark提供的shell工具Spark-Shell对于探索样本数据的算法非常有帮助。自从Spark SQL成为
正式版本后,启动Spark-Shell后除了实例化了SparkContext之外,还实例化SQLContext,默
认名为sqlContext。在Spark Shell中,可以自如地操作sqlContext,利用Spark SQL去访问结
构数据,获得DataFrame。

当我们需要通过JDBC访问外部数据库如MySQL、PostgreSQL时,需要将对应的JDBC
Driver在启动Spark Shell之前将其放入到classpath中,否则,在Spark-Shell中输入如下语句
时,会抛出ClassNotFoundException,提示找不到对应的driver class:

val employeeDF = sqlContext.load("jdbc", Map(


"url" -> url,
"driver" -> "com.mysql.jdbc.Driver",
"dbtable" -> "mock_employees"
))

解决方案是在SPARK_HOME/bin/compute-classpath.sh中将数据库驱动追加到classpath下。
我们可以考虑在SPARK_HOME下创建一个libs目录,专门用于存放程序需要的外部依赖jar
包。例如,把MySQL的驱动程序jar包拷贝到该目录下,然后在compute-classpath.sh脚本中
增加如下配置:

appendToClasspath "$FWDIR/libs/mysql-connector-java-5.1.35.jar"

Spark SQL的优势
对比传统方式访问JDBC/ODBC服务器,Spark SQL可以为多个程序提供缓存功能,从而提升
程序的性能。Spark SQL还为SQL语句的执行提供了优化的执行计划。

54
External DataSources

55
Spark SQL的性能调优

Spark SQL的性能
Spark SQL提供对表的缓存功能,可以极大地改善程序的性能。Spark SQL采用在内存中的列
式存储来缓存数据,这样的结构不仅可以有效地利用缓存空间,还使得在缓存之后的后续查
询可以仅依赖于数据的子集。

Spark SQL还采用了一种“pull down”的方式将查询的一部分往下拉到查询引擎中,这样可以避


免读取数据库的整个Dataset。

Spark SQL还提供了与性能调优有关的选项:

选项 默认值 说明
若设置为true,Spark
SQL会将每个查询都编
译为Java字节码。当查
spark.sql.codegen false 询量较大时,这样的设
置能够改进查询性能,
但不适用于查询量小的
情形。
设置为true时,会自动
spark.sql.inMemoryColumnarStorage.compressed true 压缩内存中的列式存
储。
需要视存储数据量而
spark.sql.inMemoryColumnarStorage.batchSize 1000 定,若此值设置过大,
可能导致内存溢出。
可以设置多种编码方
式,除了snappy外,还
spark.sql.parquet.compression.codec snappy 可以设置为
uncompressed,gzip
和lzo。

这些选项可以放在配置文件中,也可以以编程方式设置SQLContext。例如:

val sqlContext = new SQLContext(sc)


sqlContext.setConf("spark.sql.codegen", true)
sqlContext.setConf("spark.sql.inMemoryColumnarStorage.batchSize", "10000")

注意,当把spark.sql.codegen设置为true时,由于需要初始化编译器,在第一次执行查询时,
可能会影响查询性能。

56
Catalyst

Catalyst
Catalyst是为Spark SQL提供的一个优化器,基于Scala的函数式编程元素。它提供的语法解
析功能通过创建表达式树对语法进行解析,每种数据类型和操作都可以视为表达式树的一种
节点。这些节点皆继承自TreeNode抽象类。

57
Rollup函数

Rollup函数
在对数据进行小计或合计运算时,rollup和cube一样,算是常用的操作了。Spark的
DataFrame提供了rollup函数支持此功能。

假设准备了如下数据:

trait SalesDataFrameFixture extends DataFrameFixture


with SparkSqlSupport {
implicit class StringFuncs(str: String) {
def toTimestamp = new Timestamp(Date.valueOf(str).getTime)
}

import sqlContext.implicits._

val sales = Seq(


(1, "Widget Co", 1000.00, 0.00, "广东省", "深圳市", "2014-02-01".toTimestamp),
(2, "Acme Widgets", 1000.00, 500.00, "四川省", "成都市", "2014-02-11".toTimestamp),
(3, "Acme Widgets", 1000.00, 500.00, "四川省", "绵阳市", "2014-02-12".toTimestamp),
(4, "Acme Widgets", 1000.00, 500.00, "四川省", "成都市", "2014-02-13".toTimestamp),
(5, "Widget Co", 1000.00, 0.00, "广东省", "广州市", "2015-01-01".toTimestamp),
(6, "Acme Widgets", 1000.00, 500.00, "四川省", "泸州市", "2015-01-11".toTimestamp),
(7, "Widgetry", 1000.00, 200.00, "四川省", "成都市", "2015-02-11".toTimestamp),
(8, "Widgets R Us", 3000.00, 0.0, "四川省", "绵阳市", "2015-02-19".toTimestamp),
(9, "Widgets R Us", 2000.00, 0.0, "广东省", "深圳市", "2015-02-20".toTimestamp),
(10, "Ye Olde Widgete", 3000.00, 0.0, "广东省", "深圳市", "2015-02-28".toTimestamp),
(11, "Ye Olde Widgete", 3000.00, 0.0, "广东省", "广州市", "2015-02-28".toTimestamp)
)

val saleDF = sqlContext.sparkContext.parallelize(sales, 4).toDF("id", "name", "sales"


, "discount", "province", "city", "saleDate")
}

注册临时表,并执行SQL语句:

saleDF.registerTempTable("sales")

val dataFrame = sqlContext.sql("select province,city,sales from sales")


dataFrame.show

执行的结果如下:

58
Rollup函数

| province |city | sales |


|----------|-----|-------|
| 广东省| 深圳市|1000.0|
| 四川省| 成都市|1000.0|
| 四川省| 绵阳市|1000.0|
| 四川省| 成都市|1000.0|
| 广东省| 广州市|1000.0|
| 四川省| 泸州市|1000.0|
| 四川省| 成都市|1000.0|
| 四川省| 绵阳市|3000.0|
| 广东省| 深圳市|2000.0|
| 广东省| 深圳市|3000.0|
| 广东省| 广州市|3000.0|

对该DataFrame执行rollup:

val resultDF = dataFrame.rollup($"province", $"city").agg(Map("sales" -> "sum"))


resultDF.show

在这个例子中,rollup操作相当于对dataFrame中的province与city进行分组,并在此基础上针
对sales进行求和运算,故而获得的结果为:

|province|city|sum(sales)|
|--------|----|----------|
| null|null| 18000.0|
| 广东省|null| 10000.0|
| 广东省| 深圳市| 6000.0|
| 四川省|null| 8000.0|
| 四川省| 成都市| 3000.0|
| 四川省| 绵阳市| 4000.0|
| 广东省| 广州市| 4000.0|
| 四川省| 泸州市| 1000.0|

操作非常简单,然而遗憾地是并不符合我们产品的场景,因为我们需要根据某些元数据直接
组装为Spark SQL的sql语句。在Spark的hiveContext中,支持这样的语法:

hiveContext.sql("select province, city, sum(sales) from sales group by province, city


with rollup")

可惜,SQLContext并不支持这一功能。我在Spark User Mailing List中咨询了这个问题。Intel


的Cheng Hao(Spark的一位非常活跃的contributer)告诉了我为何不支持的原因。因为在
Spark SQL 1.x版本中,对SQL语法的解析采用了Scala的Parser机制。这种实现方式较弱,对
语法的解析支持不够。Spark的Issue #5080尝试提供此功能,然而并没有被合并到Master
中。Spark并不希望在1.x版本的SQLParser中添加新的关键字,它的计划是在Spark 2.0中用
HQL Parser来替代目前较为简陋的SQL Parser。

59
Rollup函数

如果希望在sql中使用rollup,那么有三个选择:

使用HQLContext;
pull #5080的代码,自己建立一个Spark的分支;
等待Spark 2.0版本发布。

60
Spark Streaming

Spark Streaming
GitBook allows you to organize your book into chapters, each chapter is stored in a separate
file like this one.

61
Spark的运维

Spark的部署

62
Spark的部署

Spark的部署

Spark Standalone Mode


一旦编译了Spark源代码,就可以部署Spark。要要启动一个standalone的master server,可
以进入到当前版本的Spark主目录下,输入如下命令:

./sbin/start-master.sh

启动时,还可以根据需要传入参数,指定host、port、内存等。启动后,默认情况下,可以通
过访问http://localhost:8080看到启动后的Spark Master Server概况,例如:

63

You might also like