Scala 和 Spark 大数据分析(三)
原文:
annas-archive.org/md5/39eecc62e023387ee8c22ca10d1a221a译者:飞龙
第五章:应对大数据——Spark 登场
对于正确问题的近似答案,比对于近似问题的精确答案更有价值。
- 约翰·图基
在本章中,你将学习数据分析和大数据;我们将看到大数据带来的挑战以及如何应对这些挑战。你将学习分布式计算以及函数式编程提出的方法;我们将介绍 Google 的 MapReduce、Apache Hadoop,最后是 Apache Spark,并展示它们如何采纳这些方法和技术。
简而言之,本章将涵盖以下主题:
-
数据分析简介
-
大数据简介
-
使用 Apache Hadoop 的分布式计算
-
Apache Spark 来了
数据分析简介
数据分析是应用定性和定量技术来检查数据的过程,目的是提供有价值的洞察。通过各种技术和概念,数据分析可以为探索数据提供手段,即探索性数据分析(EDA),并对数据得出结论,即验证性数据分析(CDA)。EDA 和 CDA 是数据分析的基本概念,理解这两者之间的区别非常重要。
EDA 涉及用于探索数据的各种方法、工具和技术,目的是在数据中发现模式以及数据中各元素之间的关系。CDA 涉及用于根据假设和统计技术或对数据的简单观察,提供对特定问题的洞察或结论的方法、工具和技术。
一个快速的例子来帮助理解这些概念是关于一个杂货店,它要求你提供提高销售和顾客满意度的方法,同时保持低运营成本。
以下是一个拥有各类商品货架的杂货店:
假设所有的超市销售数据都存储在某个数据库中,并且你可以访问过去 3 个月的数据。通常情况下,企业会存储多年的数据,因为你需要一定时间范围的数据来建立假设或观察到某些模式。在这个例子中,我们的目标是根据客户购买产品的方式,优化商品在各个过道的摆放。一个假设是,客户通常购买那些既在眼平线位置,又相互靠近的商品。例如,如果牛奶在商店的一角,而酸奶在商店的另一角,一些客户可能只会选择牛奶或酸奶,然后直接离开商店,这将导致销售损失。更严重的影响可能是客户选择另一个商店,因为那里的商品摆放更好,他们会觉得在这个商店里很难找到东西。一旦产生这种感觉,它也会传递给朋友和家人,最终导致糟糕的社交形象。这种现象在现实世界中并不少见,它导致一些企业成功,而另一些则失败,尽管两者在产品和价格上非常相似。
解决这个问题的方法有很多种,从客户调查到专业统计学家,再到机器学习科学家。我们的方法将是仅通过销售交易记录来理解我们能得出的信息。
以下是交易记录可能的样子:
以下是你可以作为 EDA 步骤的一部分进行的操作:
-
计算每天购买的平均产品数量 = 每天销售的所有产品总数 / 该天的收据总数。
-
对前述步骤进行重复,分别针对最近 1 周、1 个月和 1 季度进行分析。
-
尝试理解周末和平日之间是否存在差异,以及一天中的不同时间段(早晨、正午、傍晚)是否有区别。
-
为每个产品创建一个列表,列出所有其他产品,看看哪些产品通常会一起购买(同一张收据)
-
对 1 天、1 周、1 个月和 1 季度重复前述步骤。
-
尝试通过交易次数(按降序排序)来确定哪些产品应该放得更近。
完成前述的 6 个步骤后,我们可以尝试为 CDA 得出一些结论。
假设这是我们得到的输出:
| 项目 | 星期几 | 数量 |
|---|---|---|
| 牛奶 | 星期天 | 1244 |
| 面包 | 周一 | 245 |
| 牛奶 | 周一 | 190 |
在这种情况下,我们可以指出,牛奶在周末购买得更多,因此最好在周末增加牛奶产品的数量和种类。看看下面的表格:
| 项目 1 | 项目 2 | 数量 |
|---|---|---|
| 牛奶 | 鸡蛋 | 360 |
| 面包 | 奶酪 | 335 |
| 洋葱 | 西红柿 | 310 |
在这种情况下,我们可以指出,牛奶和鸡蛋通常会被更多的客户在一次购买中选购,其次是面包和奶酪。因此,我们建议商店重新排列过道和货架,将牛奶和鸡蛋放得更近。
我们得出的两个结论是:
-
牛奶在周末购买量更多,因此最好在周末增加牛奶产品的数量和种类。
-
牛奶和鸡蛋在一次购买中被更多的顾客购买,其次是面包和奶酪。因此,我们建议商店重新调整货架和过道,将牛奶和鸡蛋放得更近一些。
结论通常会在一段时间内进行跟踪,以评估效果。如果即使在采用前两个建议 6 个月后销售没有显著影响,那么我们就可以认定这些建议没有带来良好的投资回报率(ROI)。
类似地,你也可以针对利润率和定价优化进行一些分析。这就是为什么你通常会看到单一商品的价格高于同类多个商品的平均价格。例如,买一瓶洗发水需要12。
想一想你可以为杂货店探索并推荐的其他方面。例如,你能仅仅通过这些产品没有特别的关联性——比如口香糖、杂志等,来推测哪些产品应当放在收银台附近吗?
数据分析项目支持各种商业用途。例如,银行和信用卡公司分析取款和消费模式以防止欺诈和身份盗窃。广告公司分析网站流量,识别那些有较高转化为顾客可能性的潜在客户。百货商店分析顾客数据,以判断更优惠的折扣是否有助于提升销售。手机运营商可以找出定价策略。有线电视公司不断寻找那些可能会流失的客户,除非提供某种优惠或促销价格来留住他们。医院和制药公司分析数据,以开发更好的产品,发现处方药的问题或评估处方药的效果。
数据分析过程中的内容
数据分析应用不仅仅是分析数据。在进行任何分析之前,还需要花时间和精力收集、整合和准备数据,检查数据质量,然后开发、测试和修订分析方法。数据一旦准备好,数据分析师和科学家们就可以利用统计方法(如 SAS)或使用 Spark ML 的机器学习模型对数据进行探索和分析。数据本身由数据工程团队准备,而数据质量团队则负责检查收集的数据。数据治理也是一个需要考虑的因素,以确保数据的正确收集和保护。另一个不太为人所知的角色是数据管家,他们专注于理解数据的每个细节,准确了解数据的来源、所有的转换过程以及业务对某一列或数据字段的真正需求。
业务中的不同实体可能以不同方式处理地址,如123 N Main St与123 North Main Street。但是,我们的分析依赖于获取正确的地址字段;否则,上述两个地址将被视为不同,导致我们的分析准确性降低。
分析过程始于根据分析师可能需要的数据仓库中的数据进行数据收集,收集组织中各种数据(如销售、市场营销、员工、薪资、HR 等)。数据管理员和治理团队在这里非常重要,确保收集到正确的数据,并且任何被认为是机密或私密的信息不会被意外导出,即使最终用户都是员工。
社会保障号码或完整地址可能不适合包含在分析中,因为这可能会给组织带来许多问题。
必须建立数据质量流程,以确保收集和处理的数据是正确的,并且能够满足数据科学家的需求。在这一阶段,主要目标是发现和解决可能影响分析需求准确性的数据质量问题。常用的技术包括数据概况分析和数据清洗,以确保数据集中的信息一致,并删除任何错误和重复记录。
来自不同源系统的数据可能需要使用各种数据工程技术进行合并、转换和规范化,例如分布式计算或 MapReduce 编程、流处理或 SQL 查询,然后将数据存储在 Amazon S3、Hadoop 集群、NAS 或 SAN 存储设备上,或者传统的数据仓库,如 Teradata。数据准备或工程工作涉及使用技术来操作和组织数据,以满足计划中的分析需求。
一旦我们准备好数据并检查数据质量,并且数据可供数据科学家或分析师使用,实际的分析工作就开始了。数据科学家可以使用预测建模工具和语言(如 SAS、Python、R、Scala、Spark、H2O 等)构建分析模型。该模型最初会在部分数据集上运行,以测试其在训练阶段中的准确性。训练阶段通常会进行多次迭代,这是任何分析项目中都很常见的。经过模型层面的调整,或者有时需要回到数据管理员处获取或修复正在收集或准备的一些数据,模型的输出会越来越好。最终,当进一步调整不再显著改变结果时,就达到了稳定状态;此时,我们可以认为该模型已准备好投入生产使用。
现在,模型可以在生产模式下针对完整数据集进行运行,并根据我们训练模型的方式生成结果或输出。无论是统计分析还是机器学习,构建分析时做出的选择直接影响模型的质量和目的。你无法仅凭杂货销售数据判断亚洲人是否比墨西哥人买更多牛奶,因为这需要额外的、来自人口统计数据的元素。同样,如果我们的分析重点是客户体验(产品的退货或换货),那么它基于的技术和模型与我们试图关注收入或向客户推销的模型是不同的。
你将在后续章节中看到各种机器学习技术。
分析应用可以通过多个学科、团队和技能组合来实现。分析应用可以用于生成报告,也可以自动触发业务动作。例如,你可以简单地创建每日销售报告,并在每天早上 8 点通过电子邮件发送给所有经理。但你也可以与业务流程管理应用程序或一些定制的股票交易应用程序集成,执行一些操作,如买入、卖出或对股市活动进行提醒。你还可以考虑引入新闻文章或社交媒体信息,以进一步影响决策的制定。
数据可视化是数据分析中的一个重要部分,当你面对大量的指标和计算时,理解数字变得非常困难。相反,越来越多地依赖于商业智能(BI)工具,如 Tableau、QlikView 等,来探索和分析数据。当然,大规模的可视化,例如显示全国所有 Uber 车辆或显示纽约市水供应的热力图,需要构建更多的定制应用或专门的工具。
在各行各业,不同规模的组织一直面临着数据管理和分析的挑战。企业一直在努力寻找一种务实的方法来捕捉有关客户、产品和服务的信息。当公司只有少数几个客户并且他们只购买几种商品时,这并不难。这不是一个大挑战。但随着时间的推移,市场中的公司开始增长,情况变得更加复杂。现在,我们有品牌信息和社交媒体,有通过互联网买卖的商品。我们需要提出不同的解决方案。网站开发、组织、定价、社交网络和细分市场;我们正在处理许多不同的数据,这使得在处理、管理、组织数据以及试图从数据中获得见解时变得更加复杂。
大数据简介
如前一节所述,数据分析结合了技术、工具和方法,以探索和分析数据,从而为企业提供可量化的成果。结果可能是简单地选择一种颜色来粉刷店面,或者是更复杂的客户行为预测。随着企业的增长,越来越多种类的分析方法开始出现在视野中。在 1980 年代或 1990 年代,我们能得到的只是 SQL 数据仓库中可用的数据;而如今,许多外部因素都在发挥重要作用,影响着企业的运营方式。
Twitter、Facebook、Amazon、Verizon、Macy's 和 Whole Foods 等公司都在利用数据分析运营业务,并基于此做出许多决策。想一想他们正在收集什么样的数据,收集了多少数据,以及他们可能如何使用这些数据。
让我们看一下之前提到的杂货店例子。如果商店开始扩展业务,开设数百家分店,显然,销售交易的数据将需要在比单个商店大 100 倍的规模上进行收集和存储。但此时,任何企业都不再是独立运作的。外部有大量的信息,包括本地新闻、推特、Yelp 评论、客户投诉、调查活动、其他商店的竞争、人口变化以及当地经济状况等等。所有这些额外的数据都能帮助更好地理解客户行为和收入模型。
例如,如果我们发现关于商店停车设施的负面情绪在增加,那么我们可以分析这种情况并采取纠正措施,比如验证停车位,或者与城市公共交通部门谈判,提供更频繁的列车或公交服务,以提高到达的便利性。
这种数量庞大且多样化的数据,虽然提供了更好的分析能力,但也对企业 IT 组织提出了挑战,要求它们存储、处理和分析所有数据。事实上,看到 TB 级别的数据并不罕见。
每天,我们都会创造超过 2 亿亿字节的数据(2 Exa 字节),而且估计超过 90%的数据仅在过去几年内生成。
1 KB = 1024 字节
1 MB = 1024 KB
1 GB = 1024 MB
1 TB = 1024 GB ~ 1,000,000 MB
1 PB = 1024 TB ~ 1,000,000 GB ~ 1,000,000,000 MB
1 EB = 1024 PB ~ 1,000,000 TB ~ 1,000,000,000 GB ~ 1,000,000,000,000 MB
自 1990 年代以来,大量数据的出现以及对这些数据的理解和利用需求,催生了大数据这一术语。
大数据这一术语,跨越了计算机科学和统计学/计量经济学领域,可能源于 1990 年代中期硅图形公司(Silicon Graphics)午餐桌上的讨论,其中约翰·马谢(John Mashey)是重要人物。
2001 年,Doug Laney,当时是咨询公司 Meta Group Inc(后被 Gartner 收购)的分析师,提出了 3Vs(多样性、速度和体积)的概念。现在,我们使用 4Vs 而不是 3Vs,增加了数据的真实性(Veracity)这一项。
大数据的 4 个 V
以下是大数据的 4 个 V,用于描述大数据的特性。
数据种类
数据可以来自天气传感器、汽车传感器、人口普查数据、Facebook 更新、推文、交易、销售和营销。数据格式既有结构化数据也有非结构化数据,数据类型也各不相同;二进制、文本、JSON 和 XML。
数据的速度
数据可以来自数据仓库、批量模式文件存档、近实时更新或您刚刚预订的 Uber 行程的即时实时更新。
数据量
数据可以被收集和存储一个小时、一整天、一整月、一整年,甚至长达 10 年。许多公司的数据量正在增长,达到数百 TB。
数据的真实性
数据可以被分析以获得可操作的洞察,但由于来自不同数据源的大量各种类型的数据被分析,确保数据的正确性和准确性证明非常困难。
以下是大数据的 4 个 V:
为了理解所有这些数据并将数据分析应用于大数据,我们需要扩展数据分析的概念,以更大规模地操作,处理大数据的 4 个 V。这不仅改变了分析数据时使用的工具、技术和方法,还改变了我们处理问题的方式。如果 1999 年某个企业使用 SQL 数据库来处理数据,那么现在处理同一企业的数据,我们需要一个可扩展且能够适应大数据领域细微差别的分布式 SQL 数据库。
大数据分析应用通常包括来自内部系统和外部来源的数据,例如天气数据或由第三方信息服务提供商编制的消费者人口统计数据。此外,随着用户希望对通过 Spark 的 Spark Streaming 模块或其他开源流处理引擎(如 Flink 和 Storm)将数据传入 Hadoop 系统进行实时分析,流分析应用在大数据环境中变得越来越普遍。
早期的大数据系统主要部署在本地,特别是在收集、组织和分析海量数据的大型组织中。然而,云平台供应商,如亚马逊 Web 服务(AWS)和微软,已经使得在云中设置和管理 Hadoop 集群变得更加容易,像 Cloudera 和 Hortonworks 这样的 Hadoop 供应商也支持其大数据框架的分发版本在 AWS 和 Microsoft Azure 云上运行。现在,用户可以在云中快速启动集群,根据需要运行,并在使用完毕后将其下线,按需计费,无需持续的软件许可。
大数据分析项目中可能遇到的潜在陷阱包括缺乏内部分析技能,以及招聘经验丰富的数据科学家和数据工程师填补空缺的高昂成本。
涉及的数据量及其多样性可能会导致数据管理问题,涉及数据质量、一致性和治理等领域;此外,使用不同平台和数据存储在大数据架构中可能会导致数据孤岛问题。与此同时,将 Hadoop、Spark 和其他大数据工具整合到一个有凝聚力的架构中,以满足组织的大数据分析需求,对于许多 IT 和分析团队来说是一项具有挑战性的任务,他们必须找出合适的技术组合并将其拼接在一起。
使用 Apache Hadoop 进行分布式计算
我们的世界充满了各种设备,从智能冰箱、智能手表、手机、平板电脑、笔记本电脑,到机场的自助服务机、为你提供现金的 ATM 机,等等。我们能够做出几年前我们无法想象的事情。Instagram、Snapchat、Gmail、Facebook、Twitter 和 Pinterest 是我们现在已经习以为常的一些应用,几乎无法想象没有这些应用的一天。
随着云计算的出现,我们只需几次点击,就能在 AWS、Azure(微软)或 Google Cloud 等平台上启动数百甚至数千台机器,利用庞大的资源实现各种业务目标。
云计算引入了 IaaS、PaaS 和 SaaS 的概念,使我们能够构建和运营可扩展的基础设施,以服务于各种类型的使用场景和业务需求。
IaaS(基础设施即服务)- 提供可靠的托管硬件,无需数据中心、电源线、空调等设施。
PaaS(平台即服务)- 在 IaaS 基础上,提供托管的平台,如 Windows、Linux、数据库等。
SaaS(软件即服务)- 在 SaaS 基础上,提供托管服务,如 SalesForce、Kayak.com 等,供所有人使用。
在幕后是高度可扩展的分布式计算世界,它使得存储和处理 PB(PetaBytes)级别的数据成为可能。
1 ExaByte = 1024 PetaBytes (5000 万部蓝光电影)
1 PetaByte = 1024 Tera Bytes (50,000 部蓝光电影)
1 TeraByte = 1024 Giga Bytes (50 部蓝光电影)
1 部蓝光电影的平均光盘大小约为 20 GB
现在,分布式计算的范式并不是一个真正的新话题,几十年来,它在研究机构以及一些商业公司中以某种形式得到追求。大规模并行处理(MPP)是几十年前在多个领域(如海洋学、地震监测和太空探索)使用的一种范式。一些公司,如 Teradata,也实施了 MPP 平台并提供了商业产品和应用程序。最终,像 Google 和 Amazon 等科技公司推动了可扩展分布式计算的细分领域,进入了一个新的进化阶段,这最终导致了伯克利大学创建了 Apache Spark。
Google 发布了关于 Map Reduce(MR)和 Google 文件系统(GFS)的论文,将分布式计算的原理传递给了每个人。当然,也需要给予 Doug Cutting 应有的荣誉,他通过实现 Google 白皮书中的概念,并向世界介绍了 Hadoop,使这一切成为可能。
Apache Hadoop 框架是一个用 Java 编写的开源软件框架。该框架提供的两个主要功能是存储和处理。在存储方面,Apache Hadoop 框架使用 Hadoop 分布式文件系统(HDFS),该文件系统基于 2003 年 10 月发布的 Google 文件系统论文。在处理或计算方面,框架依赖于 MapReduce,该框架基于 2004 年 12 月发布的 Google 关于 MR 的论文。
MapReduce 框架从 V1(基于作业跟踪器和任务跟踪器)发展到 V2(基于 YARN)。
Hadoop 分布式文件系统(HDFS)
HDFS 是一个基于软件的文件系统,使用 Java 实现,运行在本地文件系统之上。HDFS 的主要概念是将文件分割成块(通常为 128 MB),而不是将文件视为整体处理。这使得许多功能成为可能,如分布式存储、数据复制、故障恢复,以及更重要的,使用多台机器对这些块进行分布式处理。
块大小可以是 64 MB、128 MB、256 MB 或 512 MB,根据需求选择。对于一个 1 GB 的文件,使用 128 MB 的块,计算方式为 1024 MB / 128 MB = 8 块。如果考虑复制因子为 3,则总共有 24 块。
HDFS 提供了一个具有容错性和故障恢复功能的分布式存储系统。HDFS 主要有两个组件:名称节点和数据节点。名称节点包含文件系统所有内容的所有元数据。数据节点与名称节点连接,并依赖名称节点获取有关文件系统内容的所有元数据。如果名称节点不知道任何信息,数据节点将无法为任何需要读写 HDFS 的客户端提供服务。
以下是 HDFS 架构:
NameNode 和 DataNode 是 JVM 进程,因此任何支持 Java 的机器都可以运行 NameNode 或 DataNode 进程。只有一个 NameNode(如果计算 HA 部署,第二个 NameNode 也会存在),但是有数百甚至上千个 DataNode。
不建议拥有上千个 DataNode,因为所有 DataNode 的操作会在实际生产环境中倾向于压倒 NameNode,尤其是在有大量数据密集型应用的情况下。
集群中只有一个 NameNode,这极大简化了系统的架构。NameNode 是所有 HDFS 元数据的仲裁者和存储库,任何想要读写数据的客户端都必须首先联系 NameNode 以获取元数据信息。数据不会直接通过 NameNode 流动,这使得 1 个 NameNode 能够管理数百个 DataNode(PB 级数据)。
HDFS 支持传统的层次化文件组织结构,具有类似于大多数其他文件系统的目录和文件。您可以创建、移动和删除文件和目录。NameNode 维护文件系统的命名空间,并记录所有更改和文件系统的状态。应用程序可以指定 HDFS 应该维护的文件副本数量,这些信息也由 NameNode 存储。
HDFS 旨在以分布式的方式可靠地存储非常大的文件,这些文件分布在大型数据节点集群中的多台机器上。为了应对复制、容错以及分布式计算,HDFS 将每个文件存储为一系列块。
NameNode 做出所有有关块复制的决策。这主要依赖于来自集群中每个 DataNode 的块报告,块报告会定期在心跳间隔期间发送。块报告包含 DataNode 上所有块的列表,NameNode 随后将其存储在元数据存储库中。
NameNode 将所有元数据存储在内存中,并处理所有来自客户端的读写请求。然而,由于这是维护所有 HDFS 元数据的主节点,因此保持一致且可靠的元数据是至关重要的。如果这些信息丢失,HDFS 上的内容将无法访问。
为此,HDFS NameNode 使用一个称为 EditLog 的事务日志,它会持久记录文件系统元数据发生的每一个更改。创建新文件时会更新 EditLog,移动文件、重命名文件或删除文件时也是如此。整个文件系统命名空间,包括块到文件的映射以及文件系统属性,都存储在一个名为 FsImage 的文件中。NameNode 也将所有内容存储在内存中。当 NameNode 启动时,它会加载 EditLog,并且 FsImage 会初始化自身以设置 HDFS。
然而,数据节点并不了解 HDFS,它们仅依赖于存储的数据块。数据节点完全依赖于 NameNode 执行任何操作。即使客户端要连接以读取或写入文件,也是 NameNode 告诉客户端应该连接到哪里。
HDFS 高可用性
HDFS 是主从集群,NameNode 作为主节点,而数百甚至数千个 DataNode 作为从节点,由主节点管理。这在集群中引入了单点故障(SPOF)的问题,如果主 NameNode 由于某种原因出现故障,整个集群将无法使用。HDFS 1.0 支持额外的主节点称为辅助 NameNode,用于帮助集群的恢复。它通过维护文件系统所有元数据的副本来实现,但不是一个高度可用的系统,需要手动干预和维护工作。HDFS 2.0 通过添加全面支持高可用性(HA)将其提升到了一个新的水平。
HA 的工作方式是使用两个 NameNode,以主备模式运行,其中一个是活动的,另一个是待机的。当主 NameNode 发生故障时,备用 NameNode 将接管主节点的角色。
下图展示了主备 NameNode 的部署方式:
HDFS 联邦
HDFS 联邦是使用多个 NameNode 来扩展文件系统命名空间的一种方式。与第一个 HDFS 版本不同,后者仅使用单个 NameNode 管理整个集群,随着集群规模的增长,这种管理方式无法很好地扩展。HDFS 联邦可以支持规模显著更大的集群,并通过多个联合的 NameNode 水平扩展 NameNode 或名称服务。请看下图:
HDFS 快照
Hadoop 2.0 还增加了一项新功能:使用快照(只读副本和写时复制)拍摄数据节点上存储的文件系统(数据块)。使用快照,您可以在不干扰其他常规 HDFS 操作的情况下无缝地拍摄目录,利用 NameNode 的数据块元数据。快照创建是即时的。
下面是关于如何在特定目录上工作的快照工作示例:
HDFS 读取
客户端连接到 NameNode,并根据文件名询问文件的位置。NameNode 查找文件的块位置并返回给客户端。然后客户端可以连接到数据节点并读取所需的块。NameNode 不参与数据传输。
下面是客户端读取请求的流程。首先,客户端获取位置信息,然后从数据节点拉取数据块。如果某个数据节点在中途失败,客户端则从另一个数据节点获取该块的副本。
HDFS 写入
客户端连接到 NameNode,并请求 NameNode 允许其写入 HDFS。NameNode 查找信息并规划使用哪些块、哪些 DataNode 来存储这些块,以及使用什么复制策略。NameNode 不处理任何数据,它只是告诉客户端该写到哪里。一旦第一个 DataNode 接收到块,基于复制策略,NameNode 会告诉第一个 DataNode 在哪里进行复制。因此,客户端接收到的块会发送到第二个 DataNode(复制块应该写入的位置),然后第二个 DataNode 会将其发送到第三个 DataNode(如果复制因子为 3 的话)。
以下是一个客户端写请求的流程。首先,客户端获取位置,然后写入第一个 DataNode。接收块的 DataNode 会将块复制到应该存储块副本的其他 DataNode。这一过程适用于从客户端写入的所有块。如果在中途某个 DataNode 发生故障,块会按照 NameNode 的指示被复制到另一个 DataNode。
到目前为止,我们已经看到了 HDFS 如何通过使用块、NameNode 和 DataNode 提供分布式文件系统。一旦数据达到 PB 级别存储,实际上处理数据也变得非常重要,以服务于业务的各种用例。
MapReduce 框架是在 Hadoop 框架中创建的,用于执行分布式计算。我们将在下一节进一步探讨这一点。
MapReduce 框架
MapReduce(MR)框架使你能够编写分布式应用程序,以可靠且容错的方式处理来自像 HDFS 这样的文件系统的大量数据。当你想使用 MapReduce 框架处理数据时,它通过创建一个作业来运行,这个作业在框架上执行所需的任务。
MapReduce 作业通常通过将输入数据分割到运行Mapper任务的工作节点上以并行方式工作。在此过程中,HDFS 级别的故障或 Mapper 任务的失败都会被自动处理,从而实现容错。一旦 Mapper 完成,结果将通过网络复制到其他运行Reducer任务的机器上。
理解这个概念的一个简单方法是,假设你和你的朋友们要把一堆水果分类到箱子里。为此,你希望把每个人分配一个任务,让他们处理一篮原料水果(全部混在一起),并将水果分开放入不同的箱子。每个人然后都按同样的方法处理这篮水果。
最终,你会得到来自所有朋友的一大堆水果箱。然后,你可以指派一组人把相同种类的水果放在同一个箱子里,称重并封箱以便运输。
下图描述了通过不同种类的水果来分类水果篮子的概念:
MapReduce 框架由一个资源管理器和多个节点管理器组成(通常节点管理器与 HDFS 的数据节点共存)。当应用程序需要运行时,客户端启动应用程序主控器,然后与资源管理器协商,在集群中以容器的形式获取资源。
容器代表了分配给单个节点上的 CPU(核心)和内存,用于运行任务和进程。容器由节点管理器监督,并由资源管理器调度。
容器示例:
1 个核心 + 4 GB 内存
2 个核心 + 6 GB 内存
4 个核心 + 20 GB 内存
一些容器被分配为 Mappers,另一些容器被分配为 Reducers;这一切都由应用程序主控器与资源管理器共同协调。这个框架叫做另一个资源协商器(YARN)
以下是 YARN 的示意图:
一个经典的例子,展示了 MapReduce 框架的工作原理,就是词频统计示例。以下是处理输入数据的各个阶段,首先将输入数据拆分到多个工作节点上,最后生成单词的输出计数:
尽管 MapReduce 框架在全球范围内非常成功,并且被大多数公司采用,但由于其数据处理方式,它会遇到一些问题。为了让 MapReduce 更易于使用,出现了多种技术,如 Hive 和 Pig,但复杂性依然存在。
Hadoop MapReduce 有多个限制,诸如:
-
由于基于磁盘的处理导致性能瓶颈
-
批处理无法满足所有需求
-
编程可能冗长且复杂
-
由于资源重复使用较少,任务调度较慢
-
没有很好的方式进行实时事件处理
-
机器学习通常需要较长时间,因为机器学习通常涉及迭代处理,而 MapReduce 处理速度太慢,无法满足这一需求。
Hive 是由 Facebook 创建的,作为 MapReduce 的 SQL 类似接口。Pig 是由 Yahoo 创建的,作为 MapReduce 的脚本接口。此外,还使用了多个增强技术,如 Tez(Hortonworks)和 LLAP(Hive2.x),它们通过内存优化来绕过 MapReduce 的局限性。
在下一节中,我们将介绍 Apache Spark,它已经解决了一些 Hadoop 技术的局限性。
这里介绍 Apache Spark
Apache Spark 是一个统一的分布式计算引擎,能够在不同的工作负载和平台之间运行。Spark 可以连接到不同的平台,并使用多种范式(如 Spark 流处理、Spark ML、Spark SQL 和 Spark GraphX)处理不同的数据工作负载。
Apache Spark 是一个快速的内存数据处理引擎,具有优雅和表达性强的开发 API,允许数据工作者高效地执行需要快速交互访问数据集的流式机器学习或 SQL 工作负载。Apache Spark 由 Spark 核心和一组库组成。核心是分布式执行引擎,Java、Scala 和 Python API 提供了分布式应用程序开发的平台。建立在核心之上的附加库支持流式处理、SQL、图形处理和机器学习等工作负载。例如,Spark ML 旨在用于数据科学,其抽象使得数据科学变得更加容易。
Spark 提供实时流处理、查询、机器学习和图形处理。在 Apache Spark 之前,我们必须使用不同的技术来处理不同类型的工作负载,一个用于批量分析,一个用于交互式查询,一个用于实时流处理,另一个用于机器学习算法。然而,Apache Spark 可以只使用 Apache Spark 来处理所有这些工作负载,而不必使用多个不总是集成的技术。
使用 Apache Spark,可以处理所有类型的工作负载,Spark 还支持 Scala、Java、R 和 Python 作为编写客户端程序的手段。
Apache Spark 是一个开源分布式计算引擎,相比 MapReduce 模式具有显著优势:
-
尽可能使用内存处理
-
用于批处理和实时工作负载的通用引擎
-
与 YARN 和 Mesos 兼容
-
与 HBase、Cassandra、MongoDB、HDFS、Amazon S3 和其他文件系统及数据源兼容良好
Spark 于 2009 年在伯克利创建,源于一个旨在构建 Mesos(支持不同类型集群计算系统的集群管理框架)的项目。请看以下表格:
| 版本 | 发布日期 | 里程碑 |
|---|---|---|
| 0.5 | 2012-10-07 | 第一个可用于非生产环境的版本 |
| 0.6 | 2013-02-07 | 各种更改的版本发布 |
| 0.7 | 2013-07-16 | 各种更改的版本发布 |
| 0.8 | 2013-12-19 | 各种更改的版本发布 |
| 0.9 | 2014-07-23 | 各种更改的版本发布 |
| 1.0 | 2014-08-05 | 第一个生产就绪、向后兼容的版本发布。包括 Spark Batch、Streaming、Shark、MLLib、GraphX |
| 1.1 | 2014-11-26 | 各种更改的版本发布 |
| 1.2 | 2015-04-17 | 结构化数据、SchemaRDD(后续发展为 DataFrames) |
| 1.3 | 2015-04-17 | 提供统一的 API 来读取结构化和半结构化数据源 |
| 1.4 | 2015-07-15 | SparkR、DataFrame API、Tungsten 改进 |
| 1.5 | 2015-11-09 | 各种更改的版本发布 |
| 1.6 | 2016-11-07 | 引入了 Dataset DSL |
| 2.0 | 2016-11-14 | DataFrames 和 Datasets API 作为机器学习的基础层,结构化流、SparkR 改进。 |
| 2.1 | 2017-05-02 | 事件时间水印、ML、GraphX 改进 |
2.2 版本已于 2017-07-11 发布,包含了若干改进,尤其是结构化流处理(Structured Streaming)现在已进入 GA 阶段。
Spark 是一个分布式计算平台,具有以下几个特点:
-
通过简单的 API 透明地在多个节点上处理数据
-
弹性地处理故障
-
根据需要将数据溢出到磁盘,但主要使用内存
-
支持 Java、Scala、Python、R 和 SQL API
-
相同的 Spark 代码可以独立运行,也可以在 Hadoop YARN、Mesos 和云中运行
Scala 特性,如隐式转换、高阶函数、结构化类型等,允许我们轻松构建 DSL 并将其与语言集成。
Apache Spark 不提供存储层,而是依赖于 HDFS 或 Amazon S3 等。因此,即使 Apache Hadoop 技术被 Apache Spark 替代,HDFS 仍然是必需的,以提供可靠的存储层。
Apache Kudu 提供了一个替代 HDFS 的方案,且 Apache Spark 与 Kudu 存储层已实现集成,进一步解耦了 Apache Spark 和 Hadoop 生态系统。
Hadoop 和 Apache Spark 都是流行的大数据框架,但它们并不完全相同。Hadoop 提供分布式存储和 MapReduce 分布式计算框架,而 Spark 是一个数据处理框架,依赖于其他技术提供的分布式数据存储。
由于数据处理方式的不同,Spark 通常比 MapReduce 快得多。MapReduce 使用磁盘操作处理数据拆分,而 Spark 在数据集上的操作效率远高于 MapReduce,Spark 性能提升的主要原因是高效的堆外内存处理,而不是仅依赖基于磁盘的计算。
如果你的数据操作和报告需求大多数是静态的,并且可以接受使用批处理处理,你可能会选择 MapReduce,但如果需要对流数据进行分析或处理需求需要多阶段的处理逻辑,你可能会选择 Spark。
Spark 堆栈有三层。底层是集群管理器,可以是独立模式、YARN 或 Mesos。
使用本地模式时,你不需要集群管理器来进行处理。
在中间,集群管理器之上是 Spark 核心层,它提供所有底层 API,用于任务调度和与存储的交互。
在顶部是运行在 Spark 核心之上的模块,例如 Spark SQL 提供交互式查询,Spark streaming 用于实时分析,Spark ML 用于机器学习,Spark GraphX 用于图形处理。
三个层次如下:
如前图所示,各种库,如 Spark SQL、Spark streaming、Spark ML 和 GraphX 都位于 Spark 核心之上,核心是中间层。底层展示了各种集群管理器的选项。
现在让我们简要了解一下每个组件:
Spark 核心
Spark 核心是 Spark 平台的底层通用执行引擎,所有其他功能都是在其上构建的。Spark 核心包含运行作业所需的基本 Spark 功能,并且其他组件也需要这些功能。它提供内存计算和对外部存储系统数据集的引用,最重要的是弹性分布式数据集(RDD)。
此外,Spark 核心包含访问各种文件系统的逻辑,如 HDFS、Amazon S3、HBase、Cassandra、关系型数据库等。Spark 核心还提供支持网络、安防、调度和数据洗牌的基本功能,用于构建一个具有高可扩展性和容错能力的分布式计算平台。
我们在第六章,开始使用 Spark - REPL 和 RDDs,以及第七章,特殊 RDD 操作中详细介绍了 Spark 核心。
基于 RDD 构建的数据帧和数据集,并通过 Spark SQL 引入,现在在许多使用场景中已成为比 RDD 更为常见的选择。尽管 RDD 在处理完全非结构化数据时仍然更具灵活性,但在未来,数据集 API 可能最终会成为核心 API。
Spark SQL
Spark SQL 是 Spark 核心之上的一个组件,引入了一种新的数据抽象——SchemaRDD,它为结构化和半结构化数据提供支持。Spark SQL 提供了使用 Spark 和 Hive QL 支持的 SQL 子集操作大型分布式结构化数据的功能。Spark SQL 通过数据帧和数据集简化了结构化数据的处理,且性能远超以往,是 Tungsten 计划的一部分。Spark SQL 还支持从各种结构化格式和数据源中读取和写入数据,如文件、parquet、orc、关系型数据库、Hive、HDFS、S3 等。Spark SQL 提供了一个查询优化框架——Catalyst,用于优化所有操作,以提高速度(与 RDDs 相比,Spark SQL 的速度快了好几倍)。Spark SQL 还包括一个 Thrift 服务器,外部系统可以通过 Spark SQL 使用经典的 JDBC 和 ODBC 协议查询数据。
我们在第八章,引入一点结构 - Spark SQL中详细介绍了 Spark SQL。
Spark 流处理
Spark 流处理利用 Spark 核心的快速调度能力,通过从 HDFS、Kafka、Flume、Twitter、ZeroMQ、Kinesis 等各种数据源摄取实时流数据来执行流处理分析。Spark 流处理使用数据的微批处理方式进行数据分块处理,并使用一个称为 DStreams 的概念,Spark 流处理可以像 Spark 核心 API 中的常规 RDD 一样对 RDD 进行转换和操作。Spark 流处理操作可以使用多种技术自动从故障中恢复。Spark 流处理可以与其他 Spark 组件结合,在单个程序中统一实时处理、机器学习、SQL 和图形操作。
我们在第九章中详细介绍了 Spark 流处理,Stream Me Up, Scotty - Spark Streaming。
此外,新的结构化流处理 API 使得 Spark 流处理程序更类似于 Spark 批处理程序,同时也允许在流数据上进行实时查询,这在 Spark 2.0+之前的 Spark 流处理库中是非常复杂的。
Spark GraphX
GraphX 是一个基于 Spark 的分布式图处理框架。图是由顶点和连接它们的边组成的数据结构。GraphX 提供了构建图的功能,图表示为 Graph RDD。它提供了一个 API,用于表达图计算,能够通过使用 Pregel 抽象 API 来建模用户定义的图。它还为该抽象提供了优化的运行时。GraphX 还包含图论中最重要算法的实现,如 PageRank、连通组件、最短路径、SVD++等。
我们在第十章中详细介绍了 Spark GraphX,Everything is Connected - GraphX。
一个新的模块 GraphFrames 正在开发中,它使得使用基于 DataFrame 的图形更容易进行图处理。GraphX 对于 RDD 就像 GraphFrames 对于 DataFrame/数据集一样。此外,目前 GraphFrames 与 GraphX 是分开的,预计将来会支持 GraphX 的所有功能,届时可能会切换到 GraphFrames。
Spark ML
MLlib 是一个分布式机器学习框架,位于 Spark 核心之上,处理用于转换 RDD 格式数据集的机器学习模型。Spark MLlib 是一个机器学习算法库,提供各种算法,如逻辑回归、朴素贝叶斯分类、支持向量机(SVMs)、决策树、随机森林、线性回归、交替最小二乘法(ALS)和 K-means 聚类。Spark ML 与 Spark 核心、Spark 流处理、Spark SQL 和 GraphX 紧密集成,提供一个真正集成的平台,可以处理实时或批处理数据。
我们在第十一章中详细介绍了 Spark ML,Learning Machine Learning - Spark MLlib and ML。
此外,PySpark 和 SparkR 也可以作为与 Spark 集群交互并使用 Python 和 R API 的手段。Python 和 R 的集成真正为数据科学家和机器学习建模人员打开了 Spark,因为数据科学家通常使用的最常见语言是 Python 和 R。这就是 Spark 支持 Python 和 R 集成的原因,以避免学习 Scala 这种新语言的高昂成本。另一个原因是可能存在大量用 Python 和 R 编写的现有代码,如果我们能够利用其中的一些代码,将提升团队的生产力,而不是从头开始重新构建所有内容。
笔记本技术如 Jupyter 和 Zeppelin 正在越来越受到欢迎并被广泛使用,它们使得与 Spark 的互动变得更加简便,特别是在 Spark ML 中尤为有用,因为在该领域通常需要进行大量的假设和分析。
PySpark
PySpark 使用基于 Python 的 SparkContext 和 Python 脚本作为任务,然后通过套接字和管道执行进程,在基于 Java 的 Spark 集群与 Python 脚本之间进行通信。PySpark 还使用 Py4J,这是一个流行的库,集成在 PySpark 中,可以让 Python 动态与基于 Java 的 RDD 进行交互。
必须在所有运行 Spark 执行器的工作节点上安装 Python。
以下是 PySpark 如何通过在 Java 处理和 Python 脚本之间通信来工作的方式:
SparkR
SparkR 是一个 R 包,提供了一个轻量级的前端接口,用于从 R 中使用 Apache Spark。SparkR 提供了一个分布式数据框架实现,支持选择、过滤、聚合等操作。SparkR 还支持使用 MLlib 进行分布式机器学习。SparkR 使用基于 R 的 SparkContext 和 R 脚本作为任务,然后通过 JNI 和管道执行进程,在基于 Java 的 Spark 集群与 R 脚本之间进行通信。
必须在所有运行 Spark 执行器的工作节点上安装 R。
以下是 SparkR 如何通过在 Java 处理和 R 脚本之间通信来工作的方式:
总结
我们探索了 Hadoop 和 MapReduce 框架的演变,并讨论了 YARN、HDFS 概念、HDFS 的读写操作、关键特性以及挑战。然后,我们讨论了 Apache Spark 的演变,Apache Spark 最初为何被创建,以及它能为大数据分析和处理的挑战带来何种价值。
最后,我们还简单了解了 Apache Spark 中的各个组件,即 Spark Core、Spark SQL、Spark Streaming、Spark GraphX 和 Spark ML,以及 PySpark 和 SparkR,它们是将 Python 和 R 语言代码与 Apache Spark 集成的手段。
现在我们已经了解了大数据分析、Hadoop 分布式计算平台的空间以及演变过程,还了解了 Apache Spark 的发展,并对 Apache Spark 如何解决一些挑战有了一个高层次的概述,我们已经准备好开始学习 Spark,并了解如何在我们的应用场景中使用它。
在下一章中,我们将更深入地探讨 Apache Spark,并开始了解其内部运作原理,详细内容请参考第六章,开始使用 Spark - REPL 和 RDDs。
第六章:开始使用 Spark – REPL 和 RDDs
“所有这些现代技术只会让人们试图同时做所有事情。”
- 比尔·沃特森(Bill Watterson)
在本章中,您将学习 Spark 的工作原理;然后,您将了解 RDD(弹性分布式数据集),它是 Apache Spark 的基本抽象,您会发现它们实际上是暴露类似 Scala API 的分布式集合。接下来,您将看到如何下载 Spark,并通过 Spark shell 在本地运行它。
简而言之,本章将覆盖以下主题:
-
更深入地了解 Apache Spark
-
Apache Spark 安装
-
RDDs 介绍
-
使用 Spark shell
-
动作与转换
-
缓存
-
数据加载与保存
更深入地了解 Apache Spark
Apache Spark 是一个快速的内存数据处理引擎,具有优雅且表达力强的开发 API,能够让数据工作者高效地执行流处理、机器学习或 SQL 工作负载,这些工作负载需要快速的交互式数据访问。Apache Spark 由 Spark 核心和一组库组成。核心是分布式执行引擎,Java、Scala 和 Python API 提供了分布式应用程序开发的平台。
在核心之上构建的其他库允许处理流数据、SQL、图形处理和机器学习的工作负载。例如,SparkML 是为数据科学设计的,它的抽象使数据科学变得更容易。
为了规划和执行分布式计算,Spark 使用作业的概念,这些作业通过阶段和任务在工作节点上执行。Spark 由一个 Driver 组成,Driver 协调跨工作节点集群的执行。Driver 还负责跟踪所有工作节点以及当前正在执行的任务。
让我们更深入地了解一下各个组件。关键组件是 Driver 和执行器,它们都是 JVM 进程(Java 进程):
-
Driver:Driver 程序包含应用程序和主程序。如果您使用的是 Spark shell,那么它将成为 Driver 程序,Driver 会在集群中启动执行器并控制任务的执行。
-
执行器:接下来是执行器,它们是运行在集群工作节点上的进程。在执行器内部,个别任务或计算会被执行。每个工作节点中可能有一个或多个执行器,并且每个执行器内部可能包含多个任务。当 Driver 连接到集群管理器时,集群管理器会分配资源来运行执行器。
集群管理器可以是独立集群管理器、YARN 或 Mesos。
Cluster Manager 负责在构成集群的计算节点之间调度和分配资源。通常,这由一个管理进程来完成,它了解并管理一个资源集群,并将资源分配给如 Spark 这样的请求进程。我们将在接下来的章节中进一步讨论三种不同的集群管理器:standalone、YARN 和 Mesos。
以下是 Spark 在高层次上如何工作的:
Spark 程序的主要入口点被称为 SparkContext。SparkContext 位于 Driver 组件内部,代表与集群的连接,并包含运行调度器、任务分配和协调的代码。
在 Spark 2.x 中,新增了一个名为 SparkSession 的变量。SparkContext、SQLContext 和 HiveContext 现在是 SparkSession 的成员变量。
当你启动 Driver 程序时,命令会通过 SparkContext 发出到集群,接着 executors 会执行这些指令。一旦执行完成,Driver 程序也完成了任务。此时,你可以发出更多命令并执行更多的作业。
维护并重用 SparkContext 是 Apache Spark 架构的一个关键优势,不像 Hadoop 框架,在 Hadoop 中每个 MapReduce 作业、Hive 查询或 Pig 脚本在每次执行任务时都会从头开始处理,而且还需要使用昂贵的磁盘而不是内存。
SparkContext 可用于在集群上创建 RDD、累加器和广播变量。每个 JVM/Java 进程中只能激活一个 SparkContext。在创建新的 SparkContext 之前,必须先 stop() 当前激活的 SparkContext。
Driver 解析代码,并将字节级代码序列化后传递给 executors 执行。当我们执行计算时,计算实际上会在每个节点的本地级别完成,使用内存中的处理。
解析代码和规划执行的过程是由 Driver 进程实现的关键方面。
以下是 Spark Driver 如何在集群中协调计算的过程:
有向无环图 (DAG) 是 Spark 框架的秘密武器。Driver 进程为你尝试运行的代码片段创建一个 DAG,然后,DAG 会通过任务调度器分阶段执行,每个阶段通过与 Cluster Manager 通信来请求资源以运行 executors。DAG 代表一个作业,一个作业被拆分为子集,也叫阶段,每个阶段以任务的形式执行,每个任务使用一个核心。
一个简单作业的示意图以及 DAG 如何被拆分成阶段和任务的过程如下图所示;第一张图展示了作业本身,第二张图展示了作业中的阶段和任务:
以下图表将作业/DAG 分解为阶段和任务:
阶段的数量以及阶段的组成由操作的类型决定。通常,任何转换操作都会与之前的操作属于同一个阶段,但每个像 reduce 或 shuffle 这样的操作都会创建一个新的执行阶段。任务是阶段的一部分,直接与执行器上执行操作的核心相关。
如果你使用 YARN 或 Mesos 作为集群管理器,当需要处理更多工作时,可以使用动态 YARN 调度程序来增加执行器的数量,并且可以终止空闲的执行器。
因此,驱动程序管理整个执行过程的容错性。一旦驱动程序完成作业,输出可以写入文件、数据库或直接输出到控制台。
请记住,驱动程序中的代码本身必须是完全可序列化的,包括所有变量和对象。
经常看到的例外是不可序列化的异常,这是由于从块外部包含全局变量所导致的。
因此,驱动程序进程负责整个执行过程,同时监控和管理所使用的资源,如执行器、阶段和任务,确保一切按计划运行,并在发生任务失败或整个执行器节点失败等故障时进行恢复。
Apache Spark 安装
Apache Spark 是一个跨平台框架,可以在 Linux、Windows 和 Mac 机器上部署,只要机器上安装了 Java。在这一节中,我们将介绍如何安装 Apache Spark。
Apache Spark 可以从 spark.apache.org/downloads.html 下载
首先,让我们看看在机器上必须具备的前提条件:
-
Java 8+(强制要求,因为所有 Spark 软件都作为 JVM 进程运行)
-
Python 3.4+(可选,仅在需要使用 PySpark 时使用)
-
R 3.1+(可选,仅在需要使用 SparkR 时使用)
-
Scala 2.11+(可选,仅用于为 Spark 编写程序)
Spark 可以通过三种主要的部署模式进行部署,我们将一一查看:
-
Spark 独立模式
-
YARN 上的 Spark
-
Mesos 上的 Spark
Spark 独立模式
Spark 独立模式使用内置调度程序,不依赖于任何外部调度程序,如 YARN 或 Mesos。要在独立模式下安装 Spark,你需要将 Spark 二进制安装包复制到集群中的所有机器上。
在独立模式下,客户端可以通过 spark-submit 或 Spark shell 与集群交互。在这两种情况下,驱动程序与 Spark 主节点通信以获取工作节点,在那里可以为此应用启动执行器。
多个客户端与集群交互时,会在工作节点上创建自己的执行器。此外,每个客户端都会有自己的驱动程序组件。
以下是使用主节点和工作节点的 Spark 独立部署:
现在我们来下载并安装 Spark,以独立模式在 Linux/Mac 上运行:
- 从链接下载 Apache Spark:
spark.apache.org/downloads.html:
- 将包解压到本地目录中:
tar -xvzf spark-2.2.0-bin-hadoop2.7.tgz
- 切换到新创建的目录:
cd spark-2.2.0-bin-hadoop2.7
-
通过执行以下步骤设置
JAVA_HOME和SPARK_HOME的环境变量:JAVA_HOME应该是你安装 Java 的路径。在我的 Mac 终端中,设置如下:
export JAVA_HOME=/Library/Java/JavaVirtualMachines/
jdk1.8.0_65.jdk/Contents/Home/
-
SPARK_HOME应该是新解压的文件夹。在我的 Mac 终端中,设置如下:
export SPARK_HOME= /Users/myuser/spark-2.2.0-bin-
hadoop2.7
-
运行 Spark shell 查看是否有效。如果无法正常工作,检查
JAVA_HOME和SPARK_HOME环境变量:./bin/spark-shell -
现在你会看到如下的 shell 界面:
-
你将看到 Scala/Spark shell,接下来你可以与 Spark 集群进行交互:
scala>
现在,我们有一个连接到自动设置的本地集群并运行 Spark 的 Spark-shell。这是最快的在本地机器上启动 Spark 的方式。然而,你仍然可以控制 worker/执行器,并且可以连接到任何集群(独立模式/YARN/Mesos)。这就是 Spark 的强大之处,它使你能够从交互式测试快速过渡到集群上的测试,随后将作业部署到大型集群上。无缝的集成带来了很多好处,这是使用 Hadoop 和其他技术无法实现的。
如果你想了解所有的设置,可以参考官方文档:spark.apache.org/docs/latest/。
有多种方法可以启动 Spark shell,如下面的代码片段所示。我们将在后面的章节中看到更多选项,并更详细地展示 Spark shell:
- 本地机器上的默认 shell 自动将本地机器指定为主节点:
./bin/spark-shell
- 本地机器上的默认 shell 指定本地机器为主节点,并使用
n个线程:
./bin/spark-shell --master local[n]
- 本地机器上的默认 shell 连接到指定的 Spark 主节点:
./bin/spark-shell --master spark://<IP>:<Port>
- 本地机器上的默认 shell 以客户端模式连接到 YARN 集群:
./bin/spark-shell --master yarn --deploy-mode client
- 本地机器上的默认 shell 以集群模式连接到 YARN 集群:
./bin/spark-shell --master yarn --deploy-mode cluster
Spark 驱动程序也有一个 Web UI,帮助你了解有关 Spark 集群的所有信息,包括运行的执行器、作业和任务、环境变量以及缓存。当然,最重要的用途是监控作业。
启动本地 Spark 集群的 Web UI,网址为 http://127.0.0.1:4040/jobs/
以下是 Web UI 中的 Jobs 标签页:
以下是显示集群所有执行器的标签页:
Spark 在 YARN 上
在 YARN 模式下,客户端与 YARN 资源管理器通信并获取容器来运行 Spark 执行。你可以将其视为为你部署的一个类似于迷你 Spark 集群的东西。
多个客户端与集群交互时,会在集群节点(节点管理器)上创建它们自己的执行器。同时,每个客户端都会有自己的驱动程序组件。
使用 YARN 运行时,Spark 可以运行在 YARN 客户端模式或 YARN 集群模式下。
YARN 客户端模式
在 YARN 客户端模式下,Driver 运行在集群外的节点上(通常是客户端所在的位置)。Driver 首先联系资源管理器请求资源来运行 Spark 任务。资源管理器分配一个容器(容器零)并回应 Driver。Driver 随后在容器零中启动 Spark 应用程序主节点。Spark 应用程序主节点接着在资源管理器分配的容器中创建执行器。YARN 容器可以位于集群中由节点管理器控制的任何节点上。因此,所有资源分配都由资源管理器管理。
即使是 Spark 应用程序主节点也需要与资源管理器通信,以获取后续容器来启动执行器。
以下是 Spark 的 YARN 客户端模式部署:
YARN 集群模式
在 YARN 集群模式中,Driver 运行在集群内的节点上(通常是应用程序主节点所在的地方)。客户端首先联系资源管理器请求资源来运行 Spark 任务。资源管理器分配一个容器(容器零)并回应客户端。客户端接着将代码提交给集群,并在容器零中启动 Driver 和 Spark 应用程序主节点。Driver 与应用程序主节点和 Spark 应用程序主节点一起运行,然后在资源管理器分配的容器中创建执行器。YARN 容器可以位于集群中由节点管理器控制的任何节点上。因此,所有资源分配都由资源管理器管理。
即使是 Spark 应用程序主节点也需要与资源管理器通信,以获取后续容器来启动执行器。
以下是 Spark 的 YARN 集群模式部署:
YARN 集群模式中没有 Shell 模式,因为 Driver 本身是在 YARN 内部运行的。
Mesos 上的 Spark
Mesos 部署与 Spark 独立模式类似,Driver 与 Mesos Master 进行通信,后者分配执行器所需的资源。如同在独立模式中,Driver 然后与执行器进行通信以运行任务。因此,在 Mesos 部署中,Driver 首先与 Master 通信,然后在所有 Mesos 从节点上获取容器的请求。
当容器分配给 Spark 任务时,Driver 会启动执行器并在执行器中运行代码。当 Spark 任务完成且 Driver 退出时,Mesos Master 会收到通知,所有 Mesos 从节点上的容器形式的资源将被回收。
多个客户端与集群交互,在从节点上创建各自的执行器。此外,每个客户端将有其自己的 Driver 组件。客户端模式和集群模式都可以使用,就像 YARN 模式一样。
以下是基于 Mesos 的 Spark 部署示意图,展示了Driver如何连接到Mesos 主节点,该节点还管理着所有 Mesos 从节点的资源:
RDD 简介
弹性分布式数据集(RDD)是一个不可变的、分布式的对象集合。Spark 的 RDD 具有弹性或容错性,这使得 Spark 能够在发生故障时恢复 RDD。不可变性使得 RDD 一旦创建后就是只读的。转换操作允许对 RDD 进行操作,创建新的 RDD,但原始的 RDD 在创建后永远不会被修改。这使得 RDD 免受竞争条件和其他同步问题的影响。
RDD 的分布式特性之所以有效,是因为 RDD 仅包含对数据的引用,而实际的数据则分布在集群中各个节点的分区内。
从概念上讲,RDD 是一个分布式的数据集合,分布在集群的多个节点上。为了更好地理解 RDD,我们可以把它看作是一个跨机器分布的大型整数数组。
RDD 实际上是一个跨集群分区的数据集,这些分区的数据可以来自HDFS(Hadoop 分布式文件系统)、HBase 表、Cassandra 表、Amazon S3 等。
在内部,每个 RDD 具有五个主要属性:
-
分区列表
-
计算每个分区的函数
-
其他 RDD 的依赖关系列表
-
可选地,键值型 RDD 的分区器(例如,声明 RDD 是哈希分区的)
-
可选地,计算每个分区时的首选位置列表(例如,HDFS 文件的块位置)
请看以下图示:
在程序中,驱动程序将 RDD 对象视为对分布式数据的句柄。它类似于指向数据的指针,而不是直接使用实际数据,当需要时通过它来访问实际数据。
默认情况下,RDD 使用哈希分区器将数据分配到集群中。分区的数量与集群中节点的数量无关。可能出现的情况是,集群中的单个节点拥有多个数据分区。数据分区的数量完全取决于集群中节点的数量以及数据的大小。如果查看任务在节点上的执行情况,运行在工作节点上执行器上的任务可能处理的数据既可以是同一本地节点上的数据,也可以是远程节点上的数据。这就是数据的本地性,执行任务会选择最本地的数据。
本地性会显著影响作业的性能。默认的本地性偏好顺序如下所示:
PROCESS_LOCAL > NODE_LOCAL > NO_PREF > RACK_LOCAL > ANY
无法保证每个节点会得到多少个分区。这会影响每个执行器的处理效率,因为如果一个节点上有太多的分区来处理多个分区,那么处理所有分区所花费的时间也会增加,导致执行器的核心负载过重,从而拖慢整个处理阶段,进而减慢整个作业的速度。实际上,分区是提高 Spark 作业性能的主要调优因素之一。请参考以下命令:
class RDD[T: ClassTag]
让我们进一步了解在加载数据时 RDD 的表现。以下是一个示例,展示了 Spark 如何使用不同的工作节点加载数据的不同分区或切片:
无论 RDD 是如何创建的,初始的 RDD 通常称为基础 RDD,随后通过各种操作创建的任何 RDD 都是该 RDD 的血统的一部分。记住这一点非常重要,因为容错和恢复的秘密在于驱动程序维护了 RDD 的血统,并能够执行这些血统以恢复丢失的 RDD 块。
以下是一个示例,展示了多个 RDD 是如何作为操作结果被创建的。我们从基础 RDD开始,它包含 24 个元素,然后派生出另一个 RDD carsRDD,它只包含匹配“汽车”这一项的元素(3):
在这种操作过程中,分区的数量不会发生变化,因为每个执行器会在内存中应用过滤转换,从而生成与原始 RDD 分区对应的新 RDD 分区。
接下来,我们将了解如何创建 RDD。
RDD 创建
RDD 是 Apache Spark 中使用的基本对象。它们是不可变的集合,代表数据集,并具有内置的可靠性和故障恢复能力。由于其特性,RDD 在任何操作(如转换或动作)后都会创建新的 RDD。RDD 还会存储血统信息,用于从故障中恢复。在上一章中,我们也看到了一些关于如何创建 RDD 以及可以应用于 RDD 的操作的细节。
可以通过几种方式创建 RDD:
-
并行化一个集合
-
从外部源读取数据
-
转换一个现有的 RDD
-
流式 API
并行化一个集合
并行化一个集合可以通过在驱动程序中调用 parallelize() 来实现。驱动程序在尝试并行化一个集合时,会将集合拆分为多个分区,并将这些数据分区分发到集群中。
以下是一个使用 SparkContext 和 parallelize() 函数从数字序列创建 RDD 的示例。parallelize() 函数本质上是将数字序列拆分成一个分布式集合,也就是所谓的 RDD。
scala> val rdd_one = sc.parallelize(Seq(1,2,3))
rdd_one: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[0] at parallelize at <console>:24
scala> rdd_one.take(10)
res0: Array[Int] = Array(1, 2, 3)
从外部源读取数据
创建 RDD 的第二种方法是通过从外部分布式源读取数据,例如 Amazon S3、Cassandra、HDFS 等。例如,如果你是从 HDFS 创建 RDD,那么 HDFS 中的分布式块将被 Spark 集群中的各个节点读取。
Spark 集群中的每个节点本质上都在执行自己的输入输出操作,每个节点独立地从 HDFS 块中读取一个或多个块。通常,Spark 会尽最大努力将尽可能多的 RDD 放入内存中。它具有通过启用 Spark 集群中的节点避免重复读取操作(例如从可能与 Spark 集群远程的 HDFS 块中读取)来减少输入输出操作的能力,称为缓存。在 Spark 程序中有许多缓存策略可供使用,我们将在后续的缓存章节中讨论。
以下是通过 Spark Context 和 textFile() 函数从文本文件加载的文本行的 RDD。textFile 函数将输入数据作为文本文件加载(每个换行符 \n 终止的部分会成为 RDD 中的一个元素)。该函数调用还会自动使用 HadoopRDD(在下一章节中介绍)来根据需要检测并加载数据,以多分区的形式分布在集群中。
scala> val rdd_two = sc.textFile("wiki1.txt")
rdd_two: org.apache.spark.rdd.RDD[String] = wiki1.txt MapPartitionsRDD[8] at textFile at <console>:24
scala> rdd_two.count
res6: Long = 9
scala> rdd_two.first
res7: String = Apache Spark provides programmers with an application programming interface centered on a data structure called the resilient distributed dataset (RDD), a read-only multiset of data items distributed over a cluster of machines, that is maintained in a fault-tolerant way.
现有 RDD 的转换
RDD 本质上是不可变的,因此,可以通过对任何现有的 RDD 应用转换来创建新的 RDD。Filter 是转换的一个典型示例。
以下是一个简单的整数 rdd,并通过将每个整数乘以 2 进行转换。我们再次使用 SparkContext 和 parallelize 函数,将整数序列分发到各个分区形式的 RDD 中。然后,使用 map() 函数将 RDD 转换为另一个 RDD,通过将每个数字乘以 2。
scala> val rdd_one = sc.parallelize(Seq(1,2,3))
rdd_one: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[0] at parallelize at <console>:24
scala> rdd_one.take(10)
res0: Array[Int] = Array(1, 2, 3)
scala> val rdd_one_x2 = rdd_one.map(i => i * 2)
rdd_one_x2: org.apache.spark.rdd.RDD[Int] = MapPartitionsRDD[9] at map at <console>:26
scala> rdd_one_x2.take(10)
res9: Array[Int] = Array(2, 4, 6)
流处理 API
RDD 还可以通过 Spark Streaming 创建。这些 RDD 称为离散化流 RDD(DStream RDD)。
我们将在 第九章 中进一步讨论,Stream Me Up, Scotty - Spark Streaming。
在下一节中,我们将创建 RDD,并使用 Spark-Shell 探索一些操作。
使用 Spark shell
Spark shell 提供了一种简单的方法来进行数据的交互式分析。它还使你能够通过快速尝试各种 API 来学习 Spark API。此外,它与 Scala shell 的相似性以及对 Scala API 的支持,使你能够快速适应 Scala 语言构造,并更好地使用 Spark API。
Spark shell 实现了 读取-评估-打印-循环(REPL)的概念,允许你通过键入代码与 shell 进行交互,代码会被立即评估。结果会打印到控制台,而无需编译,从而构建可执行代码。
在你安装了 Spark 的目录中运行以下命令以启动:
./bin/spark-shell
Spark shell 启动时,自动创建 SparkSession 和 SparkContext 对象。SparkSession 作为 Spark 可用,SparkContext 作为 sc 可用。
spark-shell 可以通过多种选项启动,如下段代码所示(最重要的选项已加粗):
./bin/spark-shell --help
Usage: ./bin/spark-shell [options]
Options:
--master MASTER_URL spark://host:port, mesos://host:port, yarn, or local.
--deploy-mode DEPLOY_MODE Whether to launch the driver program locally ("client") or
on one of the worker machines inside the cluster ("cluster")
(Default: client).
--class CLASS_NAME Your application's main class (for Java / Scala apps).
--name NAME A name of your application.
--jars JARS Comma-separated list of local jars to include on the driver
and executor classpaths.
--packages Comma-separated list of maven coordinates of jars to include
on the driver and executor classpaths. Will search the local
maven repo, then maven central and any additional remote
repositories given by --repositories. The format for the
coordinates should be groupId:artifactId:version.
--exclude-packages Comma-separated list of groupId:artifactId, to exclude while
resolving the dependencies provided in --packages to avoid
dependency conflicts.
--repositories Comma-separated list of additional remote repositories to
search for the maven coordinates given with --packages.
--py-files PY_FILES Comma-separated list of .zip, .egg, or .py files to place
on the PYTHONPATH for Python apps.
--files FILES Comma-separated list of files to be placed in the working
directory of each executor.
--conf PROP=VALUE Arbitrary Spark configuration property.
--properties-file FILE Path to a file from which to load extra properties. If not
specified, this will look for conf/spark-defaults.conf.
--driver-memory MEM Memory for driver (e.g. 1000M, 2G) (Default: 1024M).
--driver-Java-options Extra Java options to pass to the driver.
--driver-library-path Extra library path entries to pass to the driver.
--driver-class-path Extra class path entries to pass to the driver. Note that
jars added with --jars are automatically included in the
classpath.
--executor-memory MEM Memory per executor (e.g. 1000M, 2G) (Default: 1G).
--proxy-user NAME User to impersonate when submitting the application.
This argument does not work with --principal / --keytab.
--help, -h Show this help message and exit.
--verbose, -v Print additional debug output.
--version, Print the version of current Spark.
Spark standalone with cluster deploy mode only:
--driver-cores NUM Cores for driver (Default: 1).
Spark standalone or Mesos with cluster deploy mode only:
--supervise If given, restarts the driver on failure.
--kill SUBMISSION_ID If given, kills the driver specified.
--status SUBMISSION_ID If given, requests the status of the driver specified.
Spark standalone and Mesos only:
--total-executor-cores NUM Total cores for all executors.
Spark standalone and YARN only:
--executor-cores NUM Number of cores per executor. (Default: 1 in YARN mode,
or all available cores on the worker in standalone mode)
YARN-only:
--driver-cores NUM Number of cores used by the driver, only in cluster mode
(Default: 1).
--queue QUEUE_NAME The YARN queue to submit to (Default: "default").
--num-executors NUM Number of executors to launch (Default: 2).
If dynamic allocation is enabled, the initial number of
executors will be at least NUM.
--archives ARCHIVES Comma separated list of archives to be extracted into the
working directory of each executor.
--principal PRINCIPAL Principal to be used to login to KDC, while running on
secure HDFS.
--keytab KEYTAB The full path to the file that contains the keytab for the
principal specified above. This keytab will be copied to
the node running the Application Master via the Secure
Distributed Cache, for renewing the login tickets and the
delegation tokens periodically.
你还可以将 Spark 代码作为可执行的 Java jar 提交,这样作业就会在集群中执行。通常,只有当你使用 shell 达到一个可行的解决方案时,才会这样做。
使用 ./bin/spark-submit 提交 Spark 作业到集群(本地、YARN 和 Mesos)。
以下是 Shell 命令(最重要的命令已加粗):
scala> :help
All commands can be abbreviated, e.g., :he instead of :help.
:edit <id>|<line> edit history
:help [command] print this summary or command-specific help
:history [num] show the history (optional num is commands to show)
:h? <string> search the history
:imports [name name ...] show import history, identifying sources of names
:implicits [-v] show the implicits in scope
:javap <path|class> disassemble a file or class name
:line <id>|<line> place line(s) at the end of history
:load <path> interpret lines in a file
:paste [-raw] [path] enter paste mode or paste a file
:power enable power user mode
:quit exit the interpreter
:replay [options] reset the repl and replay all previous commands
:require <path> add a jar to the classpath
:reset [options] reset the repl to its initial state, forgetting all session entries
:save <path> save replayable session to a file
:sh <command line> run a shell command (result is implicitly => List[String])
:settings <options> update compiler options, if possible; see reset
:silent disable/enable automatic printing of results
:type [-v] <expr> display the type of an expression without evaluating it
:kind [-v] <expr> display the kind of expression's type
:warnings show the suppressed warnings from the most recent line which had any
使用 spark-shell,我们现在加载一些数据作为 RDD:
scala> val rdd_one = sc.parallelize(Seq(1,2,3))
rdd_one: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[0] at parallelize at <console>:24
scala> rdd_one.take(10)
res0: Array[Int] = Array(1, 2, 3)
如你所见,我们正在逐个运行命令。或者,我们也可以将命令粘贴进去:
scala> :paste
// Entering paste mode (ctrl-D to finish)
val rdd_one = sc.parallelize(Seq(1,2,3))
rdd_one.take(10)
// Exiting paste mode, now interpreting.
rdd_one: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[10] at parallelize at <console>:26
res10: Array[Int] = Array(1, 2, 3)
在下一节中,我们将深入探讨操作。
操作与转换
RDD 是不可变的,每个操作都会创建一个新的 RDD。现在,你可以在 RDD 上执行的两种主要操作是 转换 和 操作。
转换改变 RDD 中的元素,比如拆分输入元素、过滤掉某些元素,或者进行某种计算。可以按顺序执行多个转换;然而在规划阶段,执行并不会发生。
对于转换,Spark 将它们添加到计算的 DAG 中,只有当 driver 请求某些数据时,这个 DAG 才会被执行。这被称为 懒惰 评估。
懒惰评估的原理在于,Spark 可以查看所有的转换并计划执行,利用 Driver 对所有操作的理解。例如,如果一个过滤转换在其他转换后立即应用,Spark 会优化执行,使得每个 Executor 在每个数据分区上高效地执行这些转换。现在,只有当 Spark 等待执行某些操作时,这种优化才有可能。
操作是实际触发计算的操作。直到遇到一个操作,Spark 程序中的执行计划才会以 DAG 形式创建并保持不变。显然,执行计划中可能包含各种各样的转换操作,但在执行操作之前,什么也不会发生。
以下是对一些任意数据进行的各种操作的示意图,我们的目标只是移除所有的笔和自行车,只保留并统计汽车**。** 每个 print 语句都是一个操作,它触发 DAG 执行计划中所有转换步骤的执行,直到该点为止,如下图所示:
例如,针对有向无环图(DAG)的变换执行count操作时,会触发从基本 RDD 到所有变换的执行。如果执行了另一个操作,那么就会有一条新的执行链发生。这就是为什么在有向无环图的不同阶段进行缓存会大大加速程序下一次执行的原因。执行优化的另一种方式是重用上次执行中的 shuffle 文件。
另一个例子是collect操作,它会将所有节点的数据收集或拉取到驱动程序。你可以在调用collect时使用部分函数,选择性地拉取数据。
变换
变换通过将变换逻辑应用于现有 RDD 中的每个元素,创建一个新的 RDD。某些变换函数涉及拆分元素、过滤元素以及执行某种计算。多个变换可以按顺序执行。然而,在计划阶段不会发生实际执行。
变换可以分为四类,如下所示。
常见变换
常见变换是处理大多数通用用途的变换函数,它将变换逻辑应用于现有的 RDD,并生成一个新的 RDD。聚合、过滤等常见操作都被称为常见变换。
常见变换函数的示例包括:
-
map -
filter -
flatMap -
groupByKey -
sortByKey -
combineByKey
数学/统计变换
数学或统计变换是处理一些统计功能的变换函数,通常会对现有的 RDD 应用某些数学或统计操作,生成一个新的 RDD。抽样就是一个很好的例子,在 Spark 程序中经常使用。
这些变换的示例包括:
-
sampleByKey -
`randomSplit`
集合理论/关系变换
集合理论/关系变换是处理数据集连接(Join)以及其他关系代数功能(如cogroup)的变换函数。这些函数通过将变换逻辑应用于现有的 RDD,生成一个新的 RDD。
这些变换的示例包括:
-
cogroup -
join -
subtractByKey -
fullOuterJoin -
leftOuterJoin -
rightOuterJoin
基于数据结构的变换
基于数据结构的转换是操作 RDD 底层数据结构和 RDD 分区的转换函数。在这些函数中,你可以直接操作分区,而不需要直接处理 RDD 内部的元素/数据。这些函数在任何复杂的 Spark 程序中都至关重要,尤其是在需要更多控制分区和分区在集群中的分布时。通常,性能提升可以通过根据集群状态和数据大小、以及具体用例需求重新分配数据分区来实现。
这种转换的例子有:
-
partitionBy -
repartition -
zipwithIndex -
coalesce
以下是最新 Spark 2.1.1 版本中可用的转换函数列表:
| 转换 | 含义 |
|---|---|
map(func) | 返回一个新的分布式数据集,通过将源数据集中的每个元素传递给函数func来生成。 |
filter(func) | 返回一个新数据集,包含那些func返回 true 的源数据集元素。 |
flatMap(func) | 类似于 map,但每个输入项可以映射到 0 个或多个输出项(因此func应该返回一个Seq而不是单一项)。 |
mapPartitions(func) | 类似于 map,但在 RDD 的每个分区(块)上分别运行,因此当在类型为T的 RDD 上运行时,func必须是类型Iterator<T> => Iterator<U>。 |
mapPartitionsWithIndex(func) | 类似于mapPartitions,但还会向func提供一个整数值,表示分区的索引,因此当在类型为T的 RDD 上运行时,func必须是类型(Int, Iterator<T>) => Iterator<U>。 |
sample(withReplacement, fraction, seed) | 从数据中按给定比例fraction抽取样本,支持有放回或无放回抽样,使用给定的随机数生成种子。 |
union(otherDataset) | 返回一个新数据集,包含源数据集和参数数据集的联合元素。 |
intersection(otherDataset) | 返回一个新 RDD,包含源数据集和参数数据集的交集元素。 |
distinct([numTasks])) | 返回一个新数据集,包含源数据集中的不同元素。 |
| groupByKey([numTasks]) | 当在一个(K, V)对数据集上调用时,返回一个(K, Iterable<V>)对的数据集。注意:如果你是为了对每个键执行聚合操作(如求和或平均)而进行分组,使用reduceByKey或aggregateByKey会带来更好的性能。
注意:默认情况下,输出的并行度取决于父 RDD 的分区数。你可以传递一个可选的numTasks参数来设置不同数量的任务。
| reduceByKey(func, [numTasks]) | 当在 (K, V) 对数据集上调用时,返回一个 (K, V) 对数据集,其中每个键的值使用给定的 reduce 函数 func 进行聚合,func 必须是类型 (V, V) => V 的函数。与 groupByKey 类似,reduce 任务的数量可以通过可选的第二个参数进行配置。 |
|---|---|
aggregateByKey(zeroValue)(seqOp, combOp, [numTasks]) | 当在 (K, V) 对数据集上调用时,返回一个 (K, U) 对数据集,其中每个键的值使用给定的合并函数和中性 零 值进行聚合。允许聚合值类型与输入值类型不同,同时避免不必要的内存分配。与 groupByKey 类似,reduce 任务的数量可以通过可选的第二个参数进行配置。 |
sortByKey([ascending], [numTasks]) | 当在 (K, V) 对数据集上调用时,其中 K 实现了排序,返回一个按照键升序或降序排序的 (K, V) 对数据集,排序顺序由布尔值 ascending 参数指定。 |
join(otherDataset, [numTasks]) | 当在类型为 (K, V) 和 (K, W) 的数据集上调用时,返回一个 (K, (V, W)) 类型的数据集,其中包含每个键的所有元素对。支持外连接,可以通过 leftOuterJoin、rightOuterJoin 和 fullOuterJoin 实现。 |
cogroup(otherDataset, [numTasks]) | 当在类型为 (K, V) 和 (K, W) 的数据集上调用时,返回一个 (K, (Iterable<V>, Iterable<W>)) 类型的元组数据集。此操作也称为 groupWith。 |
cartesian(otherDataset) | 当在类型为 T 和 U 的数据集上调用时,返回一个 (T, U) 对数据集(所有元素对)。 |
pipe(command, [envVars]) | 将 RDD 的每个分区通过一个 shell 命令进行处理,例如 Perl 或 bash 脚本。RDD 元素会被写入进程的 stdin,输出到其 stdout 的行将作为字符串 RDD 返回。 |
coalesce(numPartitions) | 将 RDD 中的分区数量减少到 numPartitions。在对大数据集进行过滤后,这对于更高效地运行操作非常有用。 |
repartition(numPartitions) | 随机重新洗牌 RDD 中的数据,以创建更多或更少的分区,并在分区之间进行平衡。这会将所有数据通过网络进行洗牌。 |
repartitionAndSortWithinPartitions(partitioner) | 根据给定的分区器重新分区 RDD,并在每个结果分区内按键对记录进行排序。与调用 repartition 后再进行排序相比,这种方法更高效,因为它可以将排序操作推到 shuffle 机制中。 |
我们将演示最常见的转换操作:
map 函数
map 将转换函数应用于输入分区,以生成输出 RDD 中的输出分区。
如下所示,我们可以将一个文本文件的 RDD 映射为包含文本行长度的 RDD:
scala> val rdd_two = sc.textFile("wiki1.txt")
rdd_two: org.apache.spark.rdd.RDD[String] = wiki1.txt MapPartitionsRDD[8] at textFile at <console>:24
scala> rdd_two.count
res6: Long = 9
scala> rdd_two.first
res7: String = Apache Spark provides programmers with an application programming interface centered on a data structure called the resilient distributed dataset (RDD), a read-only multiset of data items distributed over a cluster of machines, that is maintained in a fault-tolerant way.
scala> val rdd_three = rdd_two.map(line => line.length)
res12: org.apache.spark.rdd.RDD[Int] = MapPartitionsRDD[11] at map at <console>:2
scala> rdd_three.take(10)
res13: Array[Int] = Array(271, 165, 146, 138, 231, 159, 159, 410, 281)
以下图表解释了map()是如何工作的。你可以看到,RDD 的每个分区在新的 RDD 中都生成一个新分区,本质上是在 RDD 的所有元素上应用转换:
flatMap 函数
flatMap()对输入分区应用转换函数,生成输出 RDD 中的输出分区,就像map()函数一样。然而,flatMap()还会将输入 RDD 元素中的任何集合扁平化。
flatMap() on a RDD of a text file to convert the lines in the text to a RDD containing the individual words. We also show map() called on the same RDD before flatMap() is called just to show the difference in behavior:
scala> val rdd_two = sc.textFile("wiki1.txt")
rdd_two: org.apache.spark.rdd.RDD[String] = wiki1.txt MapPartitionsRDD[8] at textFile at <console>:24
scala> rdd_two.count
res6: Long = 9
scala> rdd_two.first
res7: String = Apache Spark provides programmers with an application programming interface centered on a data structure called the resilient distributed dataset (RDD), a read-only multiset of data items distributed over a cluster of machines, that is maintained in a fault-tolerant way.
scala> val rdd_three = rdd_two.map(line => line.split(" "))
rdd_three: org.apache.spark.rdd.RDD[Array[String]] = MapPartitionsRDD[16] at map at <console>:26
scala> rdd_three.take(1)
res18: Array[Array[String]] = Array(Array(Apache, Spark, provides, programmers, with, an, application, programming, interface, centered, on, a, data, structure, called, the, resilient, distributed, dataset, (RDD),, a, read-only, multiset, of, data, items, distributed, over, a, cluster, of, machines,, that, is, maintained, in, a, fault-tolerant, way.)
scala> val rdd_three = rdd_two.flatMap(line => line.split(" "))
rdd_three: org.apache.spark.rdd.RDD[String] = MapPartitionsRDD[17] at flatMap at <console>:26
scala> rdd_three.take(10)
res19: Array[String] = Array(Apache, Spark, provides, programmers, with, an, application, programming, interface, centered)
以下图表解释了flatMap()是如何工作的。你可以看到,RDD 的每个分区在新的 RDD 中都生成一个新分区,本质上是在 RDD 的所有元素上应用转换:
filter 函数
filter对输入分区应用转换函数,以在输出 RDD 中生成过滤后的输出分区。
Spark:
scala> val rdd_two = sc.textFile("wiki1.txt")
rdd_two: org.apache.spark.rdd.RDD[String] = wiki1.txt MapPartitionsRDD[8] at textFile at <console>:24
scala> rdd_two.count
res6: Long = 9
scala> rdd_two.first
res7: String = Apache Spark provides programmers with an application programming interface centered on a data structure called the resilient distributed dataset (RDD), a read-only multiset of data items distributed over a cluster of machines, that is maintained in a fault-tolerant way.
scala> val rdd_three = rdd_two.filter(line => line.contains("Spark"))
rdd_three: org.apache.spark.rdd.RDD[String] = MapPartitionsRDD[20] at filter at <console>:26
scala>rdd_three.count
res20: Long = 5
以下图表解释了filter是如何工作的。你可以看到,RDD 的每个分区在新的 RDD 中都生成一个新分区,本质上是在 RDD 的所有元素上应用 filter 转换。
请注意,应用 filter 时,分区不会改变,并且有些分区可能为空。
coalesce
coalesce对输入分区应用transformation函数,将输入分区合并成输出 RDD 中的更少分区。
如以下代码片段所示,这就是我们如何将所有分区合并为单个分区:
scala> val rdd_two = sc.textFile("wiki1.txt")
rdd_two: org.apache.spark.rdd.RDD[String] = wiki1.txt MapPartitionsRDD[8] at textFile at <console>:24
scala> rdd_two.partitions.length
res21: Int = 2
scala> val rdd_three = rdd_two.coalesce(1)
rdd_three: org.apache.spark.rdd.RDD[String] = CoalescedRDD[21] at coalesce at <console>:26
scala> rdd_three.partitions.length
res22: Int = 1
以下图表解释了coalesce是如何工作的。你可以看到,一个新的 RDD 是从原始 RDD 创建的,本质上通过根据需要合并分区来减少分区数量:
repartition
repartition对输入分区应用transformation函数,以便将输入重新分配到输出 RDD 中的更多或更少的分区。
如以下代码片段所示,这就是我们如何将一个文本文件的 RDD 映射到具有更多分区的 RDD:
scala> val rdd_two = sc.textFile("wiki1.txt")
rdd_two: org.apache.spark.rdd.RDD[String] = wiki1.txt MapPartitionsRDD[8] at textFile at <console>:24
scala> rdd_two.partitions.length
res21: Int = 2
scala> val rdd_three = rdd_two.repartition(5)
rdd_three: org.apache.spark.rdd.RDD[String] = MapPartitionsRDD[25] at repartition at <console>:26
scala> rdd_three.partitions.length
res23: Int = 5
以下图表解释了repartition是如何工作的。你可以看到,一个新的 RDD 是从原始 RDD 创建的,本质上通过根据需要合并/拆分分区来重新分配分区:
操作
Action 触发整个DAG(有向无环图)的转换,该转换通过运行代码块和函数来实现。所有操作现在都按照 DAG 的指定进行执行。
有两种操作类型:
- Driver:一种操作是驱动程序操作,如 collect、count、count by key 等。每个这样的操作都会在远程执行器上执行一些计算,并将数据拉回到驱动程序。
基于驱动程序的操作存在一个问题:对大数据集执行操作时,可能会轻易使驱动程序的内存超负荷,导致应用程序崩溃,因此应谨慎使用涉及驱动程序的操作。
- 分布式:另一种操作是分布式操作,它在集群的节点上执行。
saveAsTextFile就是这种分布式操作的一个示例。这是最常见的操作之一,因为该操作具备分布式处理的优点。 |
以下是最新版本 Spark 2.1.1 中可用的操作函数列表:
| 操作 | 含义 |
|---|---|
reduce(func) | 使用函数func(该函数接受两个参数并返回一个结果)对数据集的元素进行聚合。该函数应该是交换律和结合律的,以便可以正确并行计算。 |
collect() | 将数据集中的所有元素作为数组返回到驱动程序中。通常在过滤或其他操作之后有用,这些操作返回一个足够小的子集数据。 |
count() | 返回数据集中的元素数量。 |
first() | 返回数据集中的第一个元素(类似于take(1))。 |
take(n) | 返回数据集的前n个元素组成的数组。 |
takeSample(withReplacement, num, [seed]) | 返回一个包含数据集中num个随机样本的数组,可以选择是否允许替代,且可选地预先指定随机数生成器的种子。 |
takeOrdered(n, [ordering]) | 返回 RDD 的前n个元素,使用它们的自然顺序或自定义比较器。 |
saveAsTextFile(path) | 将数据集的元素作为文本文件(或一组文本文件)写入本地文件系统、HDFS 或任何其他 Hadoop 支持的文件系统中的指定目录。Spark 会调用每个元素的toString方法,将其转换为文件中的一行文本。 |
saveAsSequenceFile(path)(Java 和 Scala) | 将数据集的元素作为 Hadoop SequenceFile 写入本地文件系统、HDFS 或任何其他 Hadoop 支持的文件系统中的指定路径。此操作仅适用于实现 Hadoop 的Writable接口的键值对类型的 RDD。在 Scala 中,对于那些可以隐式转换为Writable的类型,也可以使用该操作(Spark 提供了基本类型如Int、Double、String等的转换)。 |
saveAsObjectFile(path)(Java 和 Scala) | 使用 Java 序列化将数据集的元素写入简单格式,随后可以使用SparkContext.objectFile()加载。 |
countByKey() | 仅适用于类型为(K, V)的 RDD。返回一个包含每个键计数的(K, Int)键值对的哈希映射。 |
foreach(func) | 对数据集的每个元素执行一个函数func。这通常用于副作用,例如更新累加器(spark.apache.org/docs/latest/programming-guide.html#accumulators)或与外部存储系统交互。注意:在foreach()外部修改累加器以外的变量可能会导致未定义的行为。有关更多详细信息,请参见理解闭包(spark.apache.org/docs/latest/programming-guide.html#understanding-closures-a-nameclosureslinka)了解更多信息。 |
reduce
reduce()对 RDD 中的所有元素应用 reduce 函数,并将结果发送到 Driver。
以下是说明此功能的示例代码。你可以使用SparkContext和 parallelize 函数从一个整数序列创建 RDD。然后,你可以使用reduce函数对 RDD 中的所有数字进行求和。
由于这是一个动作,运行reduce函数时,结果会立即打印出来。
以下是从一个小的数字数组构建简单 RDD 并对 RDD 进行 reduce 操作的代码:
scala> val rdd_one = sc.parallelize(Seq(1,2,3,4,5,6))
rdd_one: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[26] at parallelize at <console>:24
scala> rdd_one.take(10)
res28: Array[Int] = Array(1, 2, 3, 4, 5, 6)
scala> rdd_one.reduce((a,b) => a +b)
res29: Int = 21
以下图示为reduce()的示例。Driver 在执行器上运行 reduce 函数并最终收集结果。
count
count()只是简单地计算 RDD 中元素的数量,并将其发送到 Driver。
以下是此函数的示例。我们通过 SparkContext 和 parallelize 函数从一个整数序列创建了一个 RDD,然后在 RDD 上调用 count 来打印 RDD 中元素的数量。
scala> val rdd_one = sc.parallelize(Seq(1,2,3,4,5,6))
rdd_one: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[26] at parallelize at <console>:24
scala> rdd_one.count
res24: Long = 6
以下是count()的示例。Driver 请求每个执行器/任务计算任务处理的分区中元素的数量,然后将所有任务的计数相加,最终在 Driver 层进行汇总。
collect
collect()只是简单地收集 RDD 中的所有元素,并将其发送到 Driver。
这里展示了一个示例,说明 collect 函数的本质。当你在 RDD 上调用 collect 时,Driver 将通过提取 RDD 的所有元素将它们收集到 Driver 中。
在大规模 RDD 上调用 collect 会导致 Driver 出现内存溢出问题。
以下是收集 RDD 内容并显示的代码:
scala> rdd_two.collect
res25: Array[String] = Array(Apache Spark provides programmers with an application programming interface centered on a data structure called the resilient distributed dataset (RDD), a read-only multiset of data items distributed over a cluster of machines, that is maintained in a fault-tolerant way., It was developed in response to limitations in the MapReduce cluster computing paradigm, which forces a particular linear dataflow structure on distributed programs., "MapReduce programs read input data from disk, map a function across the data, reduce the results of the map, and store reduction results on disk. ", Spark's RDDs function as a working set for distributed programs that offers a (deliberately) restricted form of distributed shared memory., The availability of RDDs facilitates t...
以下是collect()的示例。使用 collect,Driver 从所有分区中提取 RDD 的所有元素。
Caching
缓存使得 Spark 可以在计算和操作过程中持久化数据。事实上,这也是 Spark 中加速计算的最重要的技术之一,尤其是在处理迭代计算时。
缓存通过尽可能多地存储 RDD 在内存中来工作。如果内存不足,则按 LRU 策略将当前存储的数据驱逐出去。如果要求缓存的数据大于可用内存,则性能将下降,因为将使用磁盘而不是内存。
您可以使用persist()或cache()将 RDD 标记为已缓存。
cache()只是persist(MEMORY_ONLY)`的同义词。
persist可以使用内存或磁盘或两者:
persist(newLevel: StorageLevel)
以下是存储级别的可能值:
| 存储级别 | 含义 |
|---|---|
MEMORY_ONLY | 将 RDD 作为反序列化的 Java 对象存储在 JVM 中。如果 RDD 不适合内存,则某些分区将不会被缓存,并且在每次需要时会即时重新计算。这是默认级别。 |
MEMORY_AND_DISK | 将 RDD 作为反序列化的 Java 对象存储在 JVM 中。如果 RDD 不适合内存,则存储不适合的分区在磁盘上,并在需要时从那里读取。 |
MEMORY_ONLY_SER(Java 和 Scala) | 将 RDD 存储为序列化的 Java 对象(每个分区一个字节数组)。这通常比反序列化对象更节省空间,特别是在使用快速序列化器时,但读取时更消耗 CPU。 |
MEMORY_AND_DISK_SER(Java 和 Scala) | 类似于MEMORY_ONLY_SER,但将不适合内存的分区溢出到磁盘,而不是每次需要时即时重新计算它们。 |
DISK_ONLY | 仅将 RDD 分区存储在磁盘上。 |
MEMORY_ONLY_2,MEMORY_AND_DISK_2等。 | 与前述级别相同,但将每个分区复制到两个集群节点。 |
OFF_HEAP(实验性) | 类似于MEMORY_ONLY_SER,但将数据存储在堆外内存中。这需要启用堆外内存。 |
选择存储级别取决于情况
-
如果 RDD 可以放入内存中,请使用
MEMORY_ONLY,因为这是执行性能最快的选项。 -
如果使用可序列化对象,请尝试
MEMORY_ONLY_SER以使对象更小。 -
除非计算成本高昂,否则不应使用
DISK。 -
如果可以,使用复制存储来获得最佳的容错能力,即使需要额外的内存。这将防止丢失分区的重新计算,以获得最佳的可用性。
unpersist()只需释放已缓存的内容。
以下是使用不同类型存储(内存或磁盘)调用persist()函数的示例:
scala> import org.apache.spark.storage.StorageLevel
import org.apache.spark.storage.StorageLevel
scala> rdd_one.persist(StorageLevel.MEMORY_ONLY)
res37: rdd_one.type = ParallelCollectionRDD[26] at parallelize at <console>:24
scala> rdd_one.unpersist()
res39: rdd_one.type = ParallelCollectionRDD[26] at parallelize at <console>:24
scala> rdd_one.persist(StorageLevel.DISK_ONLY)
res40: rdd_one.type = ParallelCollectionRDD[26] at parallelize at <console>:24
scala> rdd_one.unpersist()
res41: rdd_one.type = ParallelCollectionRDD[26] at parallelize at <console>:24
以下是我们通过缓存获得的性能改进的示例。
首先,我们将运行代码:
scala> val rdd_one = sc.parallelize(Seq(1,2,3,4,5,6))
rdd_one: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[0] at parallelize at <console>:24
scala> rdd_one.count
res0: Long = 6
scala> rdd_one.cache
res1: rdd_one.type = ParallelCollectionRDD[0] at parallelize at <console>:24
scala> rdd_one.count
res2: Long = 6
您可以使用 WebUI 查看所显示的改进,如下面的屏幕截图所示:
加载和保存数据
加载数据到 RDD 和将 RDD 保存到输出系统都支持多种不同的方法。我们将在本节中介绍最常见的方法。
加载数据
可以通过使用SparkContext来加载数据到 RDD。其中一些最常见的方法是:。
-
textFile -
wholeTextFiles -
load从 JDBC 数据源加载
textFile
可以使用textFile()将 textFiles 加载到 RDD 中,每一行成为 RDD 中的一个元素。
sc.textFile(name, minPartitions=None, use_unicode=True)
以下是使用textFile()将textfile加载到 RDD 的示例:
scala> val rdd_two = sc.textFile("wiki1.txt")
rdd_two: org.apache.spark.rdd.RDD[String] = wiki1.txt MapPartitionsRDD[8] at textFile at <console>:24
scala> rdd_two.count
res6: Long = 9
wholeTextFiles
wholeTextFiles()可以用来将多个文本文件加载到一个包含<filename, textOfFile>对的 RDD 中,表示文件名和文件的完整内容。当加载多个小文本文件时,这非常有用,并且与textFile API 不同,因为使用wholeTextFiles()时,文件的完整内容作为单个记录加载:
sc.wholeTextFiles(path, minPartitions=None, use_unicode=True)
以下是使用wholeTextFiles()将textfile加载到 RDD 的示例:
scala> val rdd_whole = sc.wholeTextFiles("wiki1.txt")
rdd_whole: org.apache.spark.rdd.RDD[(String, String)] = wiki1.txt MapPartitionsRDD[37] at wholeTextFiles at <console>:25
scala> rdd_whole.take(10)
res56: Array[(String, String)] =
Array((file:/Users/salla/spark-2.1.1-bin-hadoop2.7/wiki1.txt,Apache Spark provides programmers with an application programming interface centered on a data structure called the resilient distributed dataset (RDD), a read-only multiset of data
从 JDBC 数据源加载
你可以从支持Java 数据库连接(JDBC)的外部数据源加载数据。使用 JDBC 驱动程序,你可以连接到关系型数据库,如 Mysql,并将表的内容加载到 Spark 中,具体请参见以下代码示例:
sqlContext.load(path=None, source=None, schema=None, **options)
以下是从 JDBC 数据源加载的示例:
val dbContent = sqlContext.load(source="jdbc", url="jdbc:mysql://localhost:3306/test", dbtable="test", partitionColumn="id")
保存 RDD
将数据从 RDD 保存到文件系统可以通过以下两种方式完成:
-
saveAsTextFile -
saveAsObjectFile
以下是将 RDD 保存到文本文件的示例
scala> rdd_one.saveAsTextFile("out.txt")
还有许多加载和保存数据的方式,特别是在与 HBase、Cassandra 等系统集成时。
总结
在本章中,我们讨论了 Apache Spark 的内部结构,RDD 是什么,DAG 和 RDD 的血统,转换和动作。我们还了解了 Apache Spark 的各种部署模式,包括独立模式、YARN 和 Mesos 部署。我们还在本地机器上做了本地安装,并查看了 Spark shell 以及如何与 Spark 进行交互。
此外,我们还讨论了如何将数据加载到 RDD 中并将 RDD 保存到外部系统,以及 Spark 卓越性能的秘诀——缓存功能,以及如何使用内存和/或磁盘来优化性能。
在下一章中,我们将深入探讨 RDD API 以及它如何在第七章中工作,特殊 RDD 操作。
第七章:特殊的 RDD 操作
"本来应该是自动的,但实际上你必须按这个按钮。"
- 约翰·布鲁纳
在本章中,你将学习如何根据不同的需求调整 RDD,并且了解这些 RDD 提供的新功能(以及潜在的风险!)。此外,我们还将探讨 Spark 提供的其他有用对象,如广播变量和累加器。
简而言之,本章将涵盖以下主题:
-
RDD 的类型
-
聚合
-
分区和洗牌
-
广播变量
-
累加器
RDD 的类型
弹性分布式数据集 (RDDs) 是 Apache Spark 中使用的基本对象。RDD 是不可变的集合,代表数据集,并具有内建的可靠性和故障恢复能力。RDD 的特点是每次操作(如转换或动作)都会创建新的 RDD,并且它们还存储继承链,继承链用于故障恢复。在前一章中,我们已经看到了一些关于如何创建 RDD 以及可以应用于 RDD 的操作类型的细节。
以下是一个简单的 RDD 继承示例:
让我们再次从一个简单的 RDD 开始,通过创建一个由数字序列组成的 RDD:
scala> val rdd_one = sc.parallelize(Seq(1,2,3,4,5,6))
rdd_one: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[28] at parallelize at <console>:25
scala> rdd_one.take(100)
res45: Array[Int] = Array(1, 2, 3, 4, 5, 6)
前面的示例展示了整数类型的 RDD,任何对该 RDD 执行的操作都会生成另一个 RDD。例如,如果我们将每个元素乘以 3,结果如下面的代码片段所示:
scala> val rdd_two = rdd_one.map(i => i * 3)
rdd_two: org.apache.spark.rdd.RDD[Int] = MapPartitionsRDD[29] at map at <console>:27
scala> rdd_two.take(10)
res46: Array[Int] = Array(3, 6, 9, 12, 15, 18)
让我们再做一个操作,将 2 加到每个元素上,并打印出所有三个 RDD:
scala> val rdd_three = rdd_two.map(i => i+2)
rdd_three: org.apache.spark.rdd.RDD[Int] = MapPartitionsRDD[30] at map at <console>:29
scala> rdd_three.take(10)
res47: Array[Int] = Array(5, 8, 11, 14, 17, 20)
一个有趣的事情是使用 toDebugString 函数查看每个 RDD 的继承链:
scala> rdd_one.toDebugString
res48: String = (8) ParallelCollectionRDD[28] at parallelize at <console>:25 []
scala> rdd_two.toDebugString
res49: String = (8) MapPartitionsRDD[29] at map at <console>:27 []
| ParallelCollectionRDD[28] at parallelize at <console>:25 []
scala> rdd_three.toDebugString
res50: String = (8) MapPartitionsRDD[30] at map at <console>:29 []
| MapPartitionsRDD[29] at map at <console>:27 []
| ParallelCollectionRDD[28] at parallelize at <console>:25 []
以下是在 Spark Web UI 中显示的继承链:
RDD 不需要与第一个 RDD(整数类型)保持相同的数据类型。以下是一个 RDD,它写入了不同数据类型的元组(字符串,整数)。
scala> val rdd_four = rdd_three.map(i => ("str"+(i+2).toString, i-2))
rdd_four: org.apache.spark.rdd.RDD[(String, Int)] = MapPartitionsRDD[33] at map at <console>:31
scala> rdd_four.take(10)
res53: Array[(String, Int)] = Array((str7,3), (str10,6), (str13,9), (str16,12), (str19,15), (str22,18))
以下是 StatePopulation 文件的 RDD,其中每个记录都转换为 upperCase。
scala> val upperCaseRDD = statesPopulationRDD.map(_.toUpperCase)
upperCaseRDD: org.apache.spark.rdd.RDD[String] = MapPartitionsRDD[69] at map at <console>:27
scala> upperCaseRDD.take(10)
res86: Array[String] = Array(STATE,YEAR,POPULATION, ALABAMA,2010,4785492, ALASKA,2010,714031, ARIZONA,2010,6408312, ARKANSAS,2010,2921995, CALIFORNIA,2010,37332685, COLORADO,2010,5048644, DELAWARE,2010,899816, DISTRICT OF COLUMBIA,2010,605183, FLORIDA,2010,18849098)
以下是前一个转换的图示:
Pair RDD
Pair RDD 是由键值对组成的 RDD,非常适合用于聚合、排序和连接数据等场景。键和值可以是简单的类型,如整数和字符串,或者更复杂的类型,如案例类、数组、列表以及其他类型的集合。基于键值的可扩展数据模型提供了许多优势,并且是 MapReduce 范式的基本概念。
创建 PairRDD 可以通过对任何 RDD 应用转换来轻松实现,将其转换为键值对的 RDD。
让我们使用 SparkContext 将 statesPopulation.csv 读入 RDD,SparkContext 可用 sc 来表示。
以下是一个基本的州人口 RDD 示例,以及同一 RDD 拆分记录为州和人口的元组(对)后的 PairRDD 的样子:
scala> val statesPopulationRDD = sc.textFile("statesPopulation.csv") statesPopulationRDD: org.apache.spark.rdd.RDD[String] = statesPopulation.csv MapPartitionsRDD[47] at textFile at <console>:25
scala> statesPopulationRDD.first
res4: String = State,Year,Population
scala> statesPopulationRDD.take(5)
res5: Array[String] = Array(State,Year,Population, Alabama,2010,4785492, Alaska,2010,714031, Arizona,2010,6408312, Arkansas,2010,2921995)
scala> val pairRDD = statesPopulationRDD.map(record => (record.split(",")(0), record.split(",")(2)))
pairRDD: org.apache.spark.rdd.RDD[(String, String)] = MapPartitionsRDD[48] at map at <console>:27
scala> pairRDD.take(10)
res59: Array[(String, String)] = Array((Alabama,4785492), (Alaska,714031), (Arizona,6408312), (Arkansas,2921995), (California,37332685), (Colorado,5048644), (Delaware,899816), (District of Columbia,605183), (Florida,18849098))
以下是前一个示例的图示,展示了 RDD 元素如何转换为 (key - value) 对:
DoubleRDD
DoubleRDD 是一个由双精度值集合构成的 RDD。由于这一特性,可以对 DoubleRDD 使用许多统计函数。
以下是 DoubleRDD 的示例,其中我们从一组双精度数字创建了一个 RDD:
scala> val rdd_one = sc.parallelize(Seq(1.0,2.0,3.0))
rdd_one: org.apache.spark.rdd.RDD[Double] = ParallelCollectionRDD[52] at parallelize at <console>:25
scala> rdd_one.mean
res62: Double = 2.0
scala> rdd_one.min
res63: Double = 1.0
scala> rdd_one.max
res64: Double = 3.0
scala> rdd_one.stdev
res65: Double = 0.816496580927726
以下是 DoubleRDD 的示意图,展示了如何在 DoubleRDD 上运行 sum() 函数:
SequenceFileRDD
SequenceFileRDD 是从 SequenceFile 创建的,SequenceFile 是 Hadoop 文件系统中的一种文件格式。SequenceFile 可以是压缩的或未压缩的。
Map Reduce 过程可以使用 SequenceFiles,SequenceFiles 是键和值的对。键和值是 Hadoop 可写数据类型,如 Text、IntWritable 等。
以下是一个 SequenceFileRDD 的示例,展示了如何写入和读取 SequenceFile:
scala> val pairRDD = statesPopulationRDD.map(record => (record.split(",")(0), record.split(",")(2)))
pairRDD: org.apache.spark.rdd.RDD[(String, String)] = MapPartitionsRDD[60] at map at <console>:27
scala> pairRDD.saveAsSequenceFile("seqfile")
scala> val seqRDD = sc.sequenceFileString, String
seqRDD: org.apache.spark.rdd.RDD[(String, String)] = MapPartitionsRDD[62] at sequenceFile at <console>:25
scala> seqRDD.take(10)
res76: Array[(String, String)] = Array((State,Population), (Alabama,4785492), (Alaska,714031), (Arizona,6408312), (Arkansas,2921995), (California,37332685), (Colorado,5048644), (Delaware,899816), (District of Columbia,605183), (Florida,18849098))
以下是前面示例中看到的 SequenceFileRDD 的示意图:
CoGroupedRDD
CoGroupedRDD 是一个将其父 RDD 进行 cogroup 操作的 RDD。两个父 RDD 必须是 pairRDD 才能工作,因为 cogroup 操作本质上会生成一个包含共同键和值列表的 pairRDD,值列表来自两个父 RDD。请看以下代码片段:
class CoGroupedRDD[K] extends RDD[(K, Array[Iterable[_]])]
以下是 CoGroupedRDD 的示例,我们创建了两个 pairRDD 的 cogroup,其中一个包含州和人口的值对,另一个包含州和年份的值对:
scala> val pairRDD = statesPopulationRDD.map(record => (record.split(",")(0), record.split(",")(2)))
pairRDD: org.apache.spark.rdd.RDD[(String, String)] = MapPartitionsRDD[60] at map at <console>:27
scala> val pairRDD2 = statesPopulationRDD.map(record => (record.split(",")(0), record.split(",")(1)))
pairRDD2: org.apache.spark.rdd.RDD[(String, String)] = MapPartitionsRDD[66] at map at <console>:27
scala> val cogroupRDD = pairRDD.cogroup(pairRDD2)
cogroupRDD: org.apache.spark.rdd.RDD[(String, (Iterable[String], Iterable[String]))] = MapPartitionsRDD[68] at cogroup at <console>:31
scala> cogroupRDD.take(10)
res82: Array[(String, (Iterable[String], Iterable[String]))] = Array((Montana,(CompactBuffer(990641, 997821, 1005196, 1014314, 1022867, 1032073, 1042520),CompactBuffer(2010, 2011, 2012, 2013, 2014, 2015, 2016))), (California,(CompactBuffer(37332685, 37676861, 38011074, 38335203, 38680810, 38993940, 39250017),CompactBuffer(2010, 2011, 2012, 2013, 2014, 2015, 2016))),
以下是通过为每个键创建值对来对 pairRDD 和 pairRDD2 进行 cogroup 操作的示意图:
ShuffledRDD
ShuffledRDD 根据键对 RDD 元素进行洗牌,从而将相同键的值积累到同一执行器上,以便进行聚合或合并逻辑。一个很好的例子是查看在 PairRDD 上调用 reduceByKey() 时发生的情况:
class ShuffledRDD[K, V, C] extends RDD[(K, C)]
以下是对 pairRDD 执行 reduceByKey 操作,以按州聚合记录的示例:
scala> val pairRDD = statesPopulationRDD.map(record => (record.split(",")(0), 1))
pairRDD: org.apache.spark.rdd.RDD[(String, Int)] = MapPartitionsRDD[82] at map at <console>:27
scala> pairRDD.take(5)
res101: Array[(String, Int)] = Array((State,1), (Alabama,1), (Alaska,1), (Arizona,1), (Arkansas,1))
scala> val shuffledRDD = pairRDD.reduceByKey(_+_)
shuffledRDD: org.apache.spark.rdd.RDD[(String, Int)] = ShuffledRDD[83] at reduceByKey at <console>:29
scala> shuffledRDD.take(5)
res102: Array[(String, Int)] = Array((Montana,7), (California,7), (Washington,7), (Massachusetts,7), (Kentucky,7))
下图展示了根据键进行洗牌,将相同键(State)的记录发送到同一分区的过程:
UnionRDD
UnionRDD 是两个 RDD 进行联合操作后的结果。联合操作仅仅是创建一个包含两个 RDD 中所有元素的新 RDD,如以下代码片段所示:
class UnionRDDT: ClassTag extends RDDT
UnionRDD by combining the elements of the two RDDs:
scala> val rdd_one = sc.parallelize(Seq(1,2,3))
rdd_one: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[85] at parallelize at <console>:25
scala> val rdd_two = sc.parallelize(Seq(4,5,6))
rdd_two: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[86] at parallelize at <console>:25
scala> val rdd_one = sc.parallelize(Seq(1,2,3))
rdd_one: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[87] at parallelize at <console>:25
scala> rdd_one.take(10)
res103: Array[Int] = Array(1, 2, 3)
scala> val rdd_two = sc.parallelize(Seq(4,5,6))
rdd_two: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[88] at parallelize at <console>:25
scala> rdd_two.take(10)
res104: Array[Int] = Array(4, 5, 6)
scala> val unionRDD = rdd_one.union(rdd_two)
unionRDD: org.apache.spark.rdd.RDD[Int] = UnionRDD[89] at union at <console>:29
scala> unionRDD.take(10)
res105: Array[Int] = Array(1, 2, 3, 4, 5, 6)
以下图展示了两个 RDD 进行联合操作后,来自 RDD 1 和 RDD 2 的元素如何被合并到一个新的 RDD UnionRDD 中:
HadoopRDD
HadoopRDD 提供了从 Hadoop 1.x 库中的 MapReduce API 读取存储在 HDFS 中的数据的核心功能。HadoopRDD 是默认使用的,当从任何文件系统加载数据到 RDD 时,可以看到它:
class HadoopRDD[K, V] extends RDD[(K, V)]
当从 CSV 文件加载州人口记录时,底层的基础 RDD 实际上是 HadoopRDD,如以下代码片段所示:
scala> val statesPopulationRDD = sc.textFile("statesPopulation.csv")
statesPopulationRDD: org.apache.spark.rdd.RDD[String] = statesPopulation.csv MapPartitionsRDD[93] at textFile at <console>:25
scala> statesPopulationRDD.toDebugString
res110: String =
(2) statesPopulation.csv MapPartitionsRDD[93] at textFile at <console>:25 []
| statesPopulation.csv HadoopRDD[92] at textFile at <console>:25 []
下图展示了通过将文本文件从文件系统加载到 RDD 中创建 HadoopRDD 的示例:
NewHadoopRDD
NewHadoopRDD 提供了读取存储在 HDFS、HBase 表、Amazon S3 中的数据的核心功能,使用的是来自 Hadoop 2.x 的新 MapReduce API。libraries.NewHadoopRDD 可以读取多种不同格式的数据,因此它可以与多个外部系统进行交互。
在 NewHadoopRDD 之前,HadoopRDD 是唯一可用的选项,它使用的是 Hadoop 1.x 中的旧 MapReduce API。
class NewHadoopRDDK, V
extends RDD[(K, V)]
NewHadoopRDD takes an input format class, a key class, and a value class. Let's look at examples of NewHadoopRDD.
最简单的例子是使用 SparkContext 的 wholeTextFiles 函数来创建 WholeTextFileRDD。现在,WholeTextFileRDD 实际上扩展了 NewHadoopRDD,如下所示的代码片段:
scala> val rdd_whole = sc.wholeTextFiles("wiki1.txt")
rdd_whole: org.apache.spark.rdd.RDD[(String, String)] = wiki1.txt MapPartitionsRDD[3] at wholeTextFiles at <console>:31
scala> rdd_whole.toDebugString
res9: String =
(1) wiki1.txt MapPartitionsRDD[3] at wholeTextFiles at <console>:31 []
| WholeTextFileRDD[2] at wholeTextFiles at <console>:31 []
让我们看另一个例子,在这个例子中,我们将使用 SparkContext 中的 newAPIHadoopFile 函数:
import org.apache.hadoop.mapreduce.lib.input.KeyValueTextInputFormat
import org.apache.hadoop.io.Text
val newHadoopRDD = sc.newAPIHadoopFile("statesPopulation.csv", classOf[KeyValueTextInputFormat], classOf[Text],classOf[Text])
聚合
聚合技术允许你以任意方式组合 RDD 中的元素来执行某些计算。事实上,聚合是大数据分析中最重要的部分。如果没有聚合,我们就无法生成报告和分析,例如 按人口最多的州,这似乎是给定过去 200 年所有州人口数据集时提出的一个逻辑问题。另一个简单的例子是需要计算 RDD 中元素的数量,这要求执行器计算每个分区中的元素数量,并将其发送给 Driver,Driver 再将这些子集相加,从而计算 RDD 中的元素总数。
在本节中,我们的主要焦点是聚合函数,这些函数用于通过键收集和合并数据。正如本章前面所看到的,PairRDD 是一个 (key - value) 对的 RDD,其中 key 和 value 是任意的,可以根据具体应用场景进行自定义。
在我们关于州人口的例子中,PairRDD 可以是 <State, <Population, Year>> 的对,这意味着 State 被作为键,<Population, Year> 的元组被作为值。这种将键和值分解的方式可以生成如 按州的人口最多年份 等聚合结果。相反,如果我们的聚合是围绕年份进行的,例如 按年份的人口最多的州,我们可以使用一个 pairRDD,其中包含 <Year, <State, Population>> 的对。
以下是生成 pairRDD 的示例代码,数据来源于 StatePopulation 数据集,既有以 State 作为键,也有以 Year 作为键的情况:
scala> val statesPopulationRDD = sc.textFile("statesPopulation.csv")
statesPopulationRDD: org.apache.spark.rdd.RDD[String] = statesPopulation.csv MapPartitionsRDD[157] at textFile at <console>:26
scala> statesPopulationRDD.take(5)
res226: Array[String] = Array(State,Year,Population, Alabama,2010,4785492, Alaska,2010,714031, Arizona,2010,6408312, Arkansas,2010,2921995)
接下来,我们可以生成一个 pairRDD,使用 State 作为键,<Year, Population> 的元组作为值,如以下代码片段所示:
scala> val pairRDD = statesPopulationRDD.map(record => record.split(",")).map(t => (t(0), (t(1), t(2))))
pairRDD: org.apache.spark.rdd.RDD[(String, (String, String))] = MapPartitionsRDD[160] at map at <console>:28
scala> pairRDD.take(5)
res228: Array[(String, (String, String))] = Array((State,(Year,Population)), (Alabama,(2010,4785492)), (Alaska,(2010,714031)), (Arizona,(2010,6408312)), (Arkansas,(2010,2921995)))
如前所述,我们还可以使用 Year 作为键,<State, Population> 的元组作为值,生成一个 PairRDD,如以下代码片段所示:
scala> val pairRDD = statesPopulationRDD.map(record => record.split(",")).map(t => (t(1), (t(0), t(2))))
pairRDD: org.apache.spark.rdd.RDD[(String, (String, String))] = MapPartitionsRDD[162] at map at <console>:28
scala> pairRDD.take(5)
res229: Array[(String, (String, String))] = Array((Year,(State,Population)), (2010,(Alabama,4785492)), (2010,(Alaska,714031)), (2010,(Arizona,6408312)), (2010,(Arkansas,2921995)))
现在我们将探讨如何在 <State, <Year, Population>> 的 pairRDD 上使用常见的聚合函数:
-
groupByKey -
reduceByKey -
aggregateByKey -
combineByKey
groupByKey
groupByKey将 RDD 中每个键的所有值组合成一个单一的序列。groupByKey还允许通过传递分区器来控制生成的键值对 RDD 的分区。默认情况下,使用HashPartitioner,但可以作为参数传入自定义分区器。每个组内元素的顺序无法保证,甚至每次评估结果 RDD 时可能会不同。
groupByKey是一个代价高昂的操作,因为需要大量的数据洗牌。reduceByKey或aggregateByKey提供了更好的性能。我们将在本节稍后讨论这一点。
groupByKey可以通过使用自定义分区器或直接使用默认的HashPartitioner来调用,如以下代码片段所示:
def groupByKey(partitioner: Partitioner): RDD[(K, Iterable[V])]
def groupByKey(numPartitions: Int): RDD[(K, Iterable[V])]
按照当前的实现,groupByKey必须能够在内存中保存任何键的所有键值对。如果某个键有太多值,就可能导致OutOfMemoryError。
groupByKey通过将所有分区的元素发送到基于分区器的分区中,从而将相同键的所有(key-value)对收集到同一个分区中。一旦完成,就可以轻松地进行聚合操作。
下面是调用groupByKey时发生情况的示意图:
reduceByKey
groupByKey涉及大量的数据洗牌,而reduceByKey通过不使用洗牌将所有PairRDD的元素发送,而是使用本地的 combiner 先在本地做一些基本聚合,然后再像groupByKey那样发送结果元素。这样大大减少了数据传输量,因为我们不需要传输所有内容。reduceBykey通过使用结合性和交换性的 reduce 函数合并每个键的值来工作。当然,首先会...
在每个映射器上本地执行合并操作,然后将结果发送到归约器。
如果你熟悉 Hadoop MapReduce,这与 MapReduce 编程中的 combiner 非常相似。
reduceByKey可以通过使用自定义分区器或直接使用默认的HashPartitioner来调用,如以下代码片段所示:
def reduceByKey(partitioner: Partitioner, func: (V, V) => V): RDD[(K, V)]
def reduceByKey(func: (V, V) => V, numPartitions: Int): RDD[(K, V)]
def reduceByKey(func: (V, V) => V): RDD[(K, V)]
reduceByKey通过根据partitioner将所有分区的元素发送到指定的分区,以便将相同键的所有(key-value)对收集到同一个分区。但在洗牌之前,首先会进行本地聚合,减少需要洗牌的数据量。一旦完成,最终分区中就可以轻松地进行聚合操作。
以下图示说明了调用reduceBykey时发生的情况:
aggregateByKey
aggregateByKey与reduceByKey非常相似,唯一不同的是aggregateByKey在分区内和分区之间聚合时提供了更多的灵活性和定制性,允许处理更复杂的用例,例如在一次函数调用中生成所有<Year, Population>对以及每个州的总人口。
aggregateByKey 通过使用给定的合并函数和中立的初始/零值来聚合每个键的值。
该函数可以返回不同的结果类型 U,而不是此 RDD 中值的类型 V,这是最大的区别。因此,我们需要一个操作将 V 合并成 U,另一个操作用于合并两个 U。前者操作用于合并分区内的值,后者用于合并分区间的值。为了避免内存分配,允许这两个函数修改并返回其第一个参数,而不是创建一个新的 U:
def aggregateByKeyU: ClassTag(seqOp: (U, V) => U,
combOp: (U, U) => U): RDD[(K, U)]
def aggregateByKeyU: ClassTag(seqOp: (U, V) => U,
combOp: (U, U) => U): RDD[(K, U)]
def aggregateByKeyU: ClassTag(seqOp: (U, V) => U,
combOp: (U, U) => U): RDD[(K, U)]
aggregateByKey 通过在分区内执行聚合操作,作用于每个分区的所有元素,然后在合并分区时应用另一种聚合逻辑来工作。最终,所有相同 Key 的 (key - value) 对都会收集到同一个分区中;然而,聚合的方式以及生成的输出不像 groupByKey 和 reduceByKey 那样固定,而是使用 aggregateByKey 时更加灵活和可定制的。
以下图示说明了调用 aggregateByKey 时发生的情况。与 groupByKey 和 reduceByKey 中将计数相加不同,在这里我们为每个 Key 生成值的列表:
combineByKey
combineByKey 和 aggregateByKey 非常相似;实际上,combineByKey 内部调用了 combineByKeyWithClassTag,而 aggregateByKey 也会调用它。和 aggregateByKey 一样,combineByKey 也是通过在每个分区内应用操作,然后在合并器之间进行操作来工作的。
combineByKey 将 RDD[K,V] 转换为 RDD[K,C],其中 C 是在键 K 下收集或合并的 V 列表。
调用 combineByKey 时,期望有三个函数。
-
createCombiner将V转换为C,其中C是一个包含一个元素的列表 -
mergeValue用于将V合并为C,通过将V附加到列表末尾 -
mergeCombiners用于将两个C合并为一个
在 aggregateByKey 中,第一个参数只是一个零值,但在 combineByKey 中,我们提供了一个初始函数,该函数将当前值作为参数。
combineByKey 可以通过自定义分区器调用,也可以像以下代码片段那样使用默认的 HashPartitioner:
def combineByKeyC => C, mergeCombiners: (C, C) => C, numPartitions: Int): RDD[(K, C)]
def combineByKeyC => C, mergeCombiners: (C, C) => C, partitioner: Partitioner, mapSideCombine: Boolean = true, serializer: Serializer = null): RDD[(K, C)]
combineByKey 通过在分区内执行聚合操作,作用于每个分区的所有元素,然后在合并分区时应用另一种聚合逻辑来工作。最终,所有相同 Key 的 (key - value) 对都将收集到同一个分区中,但聚合的方式以及生成的输出不像 groupByKey 和 reduceByKey 那样固定,而是更加灵活和可定制的。
以下图示说明了调用 combineByKey 时发生的情况:
groupByKey、reduceByKey、combineByKey 和 aggregateByKey 的比较
让我们考虑一个 StatePopulation RDD 的例子,它生成一个 <State, <Year, Population>> 的 pairRDD。
如前一节所见,groupByKey 会通过生成键的哈希码进行 HashPartitioning,然后洗牌数据,将每个键的值收集到同一个分区中。这显然会导致过多的洗牌。
reduceByKey 通过使用本地合并器逻辑改进了 groupByKey,从而减少了在洗牌阶段发送的数据量。结果与 groupByKey 相同,但性能更好。
aggregateByKey 的工作方式与 reduceByKey 非常相似,但有一个重大区别,这使得它在三者中最为强大。aggregateByKey 不需要在相同的数据类型上操作,并且可以在分区内进行不同的聚合,同时在分区之间也可以进行不同的聚合。
combineByKey 在性能上与 aggregateByKey 非常相似,除了用于创建合并器的初始函数不同。
使用哪个函数取决于你的用例,但如果不确定,请参考本节的 聚合 部分,以选择适合你用例的正确函数。另外,密切关注下一部分,因为 分区和洗牌 会在其中讨论。
以下是显示四种按州计算总人口的方法的代码。
步骤 1. 初始化 RDD:
scala> val statesPopulationRDD = sc.textFile("statesPopulation.csv").filter(_.split(",")(0) != "State")
statesPopulationRDD: org.apache.spark.rdd.RDD[String] = statesPopulation.csv MapPartitionsRDD[1] at textFile at <console>:24
scala> statesPopulationRDD.take(10)
res27: Array[String] = Array(Alabama,2010,4785492, Alaska,2010,714031, Arizona,2010,6408312, Arkansas,2010,2921995, California,2010,37332685, Colorado,2010,5048644, Delaware,2010,899816, District of Columbia,2010,605183, Florida,2010,18849098, Georgia,2010,9713521)
步骤 2. 转换为 pair RDD:
scala> val pairRDD = statesPopulationRDD.map(record => record.split(",")).map(t => (t(0), (t(1).toInt, t(2).toInt)))
pairRDD: org.apache.spark.rdd.RDD[(String, (Int, Int))] = MapPartitionsRDD[26] at map at <console>:26
scala> pairRDD.take(10)
res15: Array[(String, (Int, Int))] = Array((Alabama,(2010,4785492)), (Alaska,(2010,714031)), (Arizona,(2010,6408312)), (Arkansas,(2010,2921995)), (California,(2010,37332685)), (Colorado,(2010,5048644)), (Delaware,(2010,899816)), (District of Columbia,(2010,605183)), (Florida,(2010,18849098)), (Georgia,(2010,9713521)))
步骤 3. groupByKey - 对值进行分组,然后加总人口数:
scala> val groupedRDD = pairRDD.groupByKey.map(x => {var sum=0; x._2.foreach(sum += _._2); (x._1, sum)})
groupedRDD: org.apache.spark.rdd.RDD[(String, Int)] = MapPartitionsRDD[38] at map at <console>:28
scala> groupedRDD.take(10)
res19: Array[(String, Int)] = Array((Montana,7105432), (California,268280590), (Washington,48931464), (Massachusetts,46888171), (Kentucky,30777934), (Pennsylvania,89376524), (Georgia,70021737), (Tennessee,45494345), (North Carolina,68914016), (Utah,20333580))
步骤 4. reduceByKey - 简单地通过添加人口数来减少每个键的值:
scala> val reduceRDD = pairRDD.reduceByKey((x, y) => (x._1, x._2+y._2)).map(x => (x._1, x._2._2))
reduceRDD: org.apache.spark.rdd.RDD[(String, Int)] = MapPartitionsRDD[46] at map at <console>:28
scala> reduceRDD.take(10)
res26: Array[(String, Int)] = Array((Montana,7105432), (California,268280590), (Washington,48931464), (Massachusetts,46888171), (Kentucky,30777934), (Pennsylvania,89376524), (Georgia,70021737), (Tennessee,45494345), (North Carolina,68914016), (Utah,20333580))
步骤 5. aggregateByKey - 对每个键下的人口进行聚合并加总:
Initialize the array
scala> val initialSet = 0
initialSet: Int = 0
provide function to add the populations within a partition
scala> val addToSet = (s: Int, v: (Int, Int)) => s+ v._2
addToSet: (Int, (Int, Int)) => Int = <function2>
provide funtion to add populations between partitions
scala> val mergePartitionSets = (p1: Int, p2: Int) => p1 + p2
mergePartitionSets: (Int, Int) => Int = <function2>
scala> val aggregatedRDD = pairRDD.aggregateByKey(initialSet)(addToSet, mergePartitionSets)
aggregatedRDD: org.apache.spark.rdd.RDD[(String, Int)] = ShuffledRDD[41] at aggregateByKey at <console>:34
scala> aggregatedRDD.take(10)
res24: Array[(String, Int)] = Array((Montana,7105432), (California,268280590), (Washington,48931464), (Massachusetts,46888171), (Kentucky,30777934), (Pennsylvania,89376524), (Georgia,70021737), (Tennessee,45494345), (North Carolina,68914016), (Utah,20333580))
步骤 6. combineByKey - 在分区内进行合并,然后合并合并器:
createcombiner function
scala> val createCombiner = (x:(Int,Int)) => x._2
createCombiner: ((Int, Int)) => Int = <function1>
function to add within partition
scala> val mergeValues = (c:Int, x:(Int, Int)) => c +x._2
mergeValues: (Int, (Int, Int)) => Int = <function2>
function to merge combiners
scala> val mergeCombiners = (c1:Int, c2:Int) => c1 + c2
mergeCombiners: (Int, Int) => Int = <function2>
scala> val combinedRDD = pairRDD.combineByKey(createCombiner, mergeValues, mergeCombiners)
combinedRDD: org.apache.spark.rdd.RDD[(String, Int)] = ShuffledRDD[42] at combineByKey at <console>:34
scala> combinedRDD.take(10)
res25: Array[(String, Int)] = Array((Montana,7105432), (California,268280590), (Washington,48931464), (Massachusetts,46888171), (Kentucky,30777934), (Pennsylvania,89376524), (Georgia,70021737), (Tennessee,45494345), (North Carolina,68914016), (Utah,20333580))
正如你所见,所有四种聚合方法都得到了相同的结果。只是它们的工作方式不同。
分区与洗牌
我们已经看到 Apache Spark 如何比 Hadoop 更好地处理分布式计算。我们还了解了它的内部工作原理,主要是被称为 弹性分布式数据集(RDD)的基本数据结构。RDD 是不可变的集合,表示数据集,具有内置的可靠性和故障恢复能力。RDD 操作数据时,并非作为单一的整体数据,而是以分区的方式在集群中管理和操作数据。因此,数据分区的概念对 Apache Spark 作业的正常运行至关重要,并且会对性能以及资源利用产生重要影响。
RDD 由数据的分区组成,所有操作都在 RDD 的数据分区上执行。像转换这样的操作是由执行器在特定数据分区上执行的函数。然而,并非所有操作都可以通过仅在相应执行器上对数据分区执行孤立操作来完成。像聚合(在前面的章节中提到的)这样的操作需要数据在集群中移动,这一过程称为 洗牌。在本节中,我们将深入探讨分区和洗牌的概念。
让我们通过执行以下代码来查看一个简单的整数 RDD。Spark Context 的 parallelize 函数从整数序列创建一个 RDD。然后,使用 getNumPartitions() 函数,我们可以获得这个 RDD 的分区数。
scala> val rdd_one = sc.parallelize(Seq(1,2,3))
rdd_one: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[120] at parallelize at <console>:25
scala> rdd_one.getNumPartitions
res202: Int = 8
如下图所示,可以将 RDD 可视化,图中展示了 RDD 中的 8 个分区:
分区数量非常重要,因为这个数量直接影响将运行 RDD 转换的任务数。如果分区数量太小,那么我们会在大量数据上仅使用少数 CPU/核心,导致性能变慢并使集群资源未得到充分利用。另一方面,如果分区数量太大,那么你将使用比实际需要更多的资源,并且在多租户环境中,可能会导致其他作业(无论是你自己还是团队中的其他人)资源不足。
分区器
RDD 的分区是通过分区器来完成的。分区器为 RDD 中的元素分配一个分区索引。同一分区中的所有元素将具有相同的分区索引。
Spark 提供了两种分区器:HashPartitioner 和 RangePartitioner。除了这两种,你还可以实现一个自定义的分区器。
哈希分区器
HashPartitioner 是 Spark 中的默认分区器,通过计算每个 RDD 元素键的哈希值来工作。所有具有相同哈希值的元素将被分配到相同的分区,如以下代码片段所示:
partitionIndex = hashcode(key) % numPartitions
以下是字符串 hashCode() 函数的示例,以及我们如何生成 partitionIndex:
scala> val str = "hello"
str: String = hello
scala> str.hashCode
res206: Int = 99162322
scala> val numPartitions = 8
numPartitions: Int = 8
scala> val partitionIndex = str.hashCode % numPartitions
partitionIndex: Int = 2
默认的分区数量要么来自 Spark 配置参数 spark.default.parallelism,要么来自集群中的核心数。
以下图示说明了哈希分区是如何工作的。我们有一个包含 a、b 和 e 三个元素的 RDD。通过使用字符串的 hashcode,我们可以根据设置的 6 个分区数量计算每个元素的 partitionIndex:
范围分区器
RangePartitioner 通过将 RDD 分成大致相等的区间来工作。由于区间需要知道每个分区的起始和结束键,因此在使用 RangePartitioner 之前,RDD 需要先进行排序。
RangePartitioning首先需要根据 RDD 为分区设定合理的边界,然后创建一个从键 K 到partitionIndex(元素所在分区的索引)之间的函数。最后,我们需要根据RangePartitioner重新分区 RDD,以根据我们确定的范围正确地分配 RDD 元素。
以下是如何使用PairRDD的RangePartitioning的示例。我们还可以看到,在使用RangePartitioner对 RDD 重新分区后,分区如何发生变化:
import org.apache.spark.RangePartitioner
scala> val statesPopulationRDD = sc.textFile("statesPopulation.csv")
statesPopulationRDD: org.apache.spark.rdd.RDD[String] = statesPopulation.csv MapPartitionsRDD[135] at textFile at <console>:26
scala> val pairRDD = statesPopulationRDD.map(record => (record.split(",")(0), 1))
pairRDD: org.apache.spark.rdd.RDD[(String, Int)] = MapPartitionsRDD[136] at map at <console>:28
scala> val rangePartitioner = new RangePartitioner(5, pairRDD)
rangePartitioner: org.apache.spark.RangePartitioner[String,Int] = org.apache.spark.RangePartitioner@c0839f25
scala> val rangePartitionedRDD = pairRDD.partitionBy(rangePartitioner)
rangePartitionedRDD: org.apache.spark.rdd.RDD[(String, Int)] = ShuffledRDD[130] at partitionBy at <console>:32
scala> pairRDD.mapPartitionsWithIndex((i,x) => Iterator(""+i + ":"+x.length)).take(10)
res215: Array[String] = Array(0:177, 1:174)
scala> rangePartitionedRDD.mapPartitionsWithIndex((i,x) => Iterator(""+i + ":"+x.length)).take(10)
res216: Array[String] = Array(0:70, 1:77, 2:70, 3:63, 4:71)
以下图示说明了前面示例中提到的RangePartitioner:
洗牌(Shuffling)
无论使用何种分区器,许多操作都会导致数据在 RDD 的分区之间重新分配。新分区可以被创建,或者多个分区可以被合并。所有用于重新分区所需的数据移动过程都被称为洗牌(Shuffling),这是编写 Spark 作业时需要理解的一个重要概念。洗牌可能会导致很大的性能延迟,因为计算不再保存在同一执行器的内存中,而是执行器通过网络交换数据。
一个好的例子是我们在聚合(Aggregations)部分中看到的groupByKey()示例。显然,为了确保所有相同键的值都收集到同一个执行器上以执行groupBy操作,大量的数据在执行器之间流动。
洗牌(Shuffling)还决定了 Spark 作业的执行过程,并影响作业如何被拆分成阶段(Stages)。正如我们在本章和上一章中所看到的,Spark 持有一个 RDD 的有向无环图(DAG),该图表示了 RDD 的血统关系,Spark 不仅利用这个血统来规划作业的执行,还可以从任何执行器丢失中恢复。当一个 RDD 正在进行转换时,系统会尽力确保操作在与数据相同的节点上执行。然而,我们经常使用连接操作(join)、聚合(reduce)、分组(group)或其他聚合操作,这些操作往往会有意或无意地导致重新分区。这个洗牌过程反过来决定了数据处理中的某一阶段何时结束以及新的阶段何时开始。
以下图示说明了 Spark 作业如何被拆分成多个阶段。这个示例展示了一个pairRDD在执行groupByKey之前,先经过过滤和使用 map 转换的过程,最后再通过map()进行一次转换:
我们的洗牌操作越多,作业执行过程中就会有更多的阶段,从而影响性能。Spark Driver 使用两个关键方面来确定这些阶段。这是通过定义 RDD 的两种依赖关系类型来完成的,分别是窄依赖(narrow dependencies)和宽依赖(wide dependencies)。
窄依赖(Narrow Dependencies)
当一个 RDD 可以通过简单的一对一转换(如 filter() 函数、map() 函数、flatMap() 函数等)从另一个 RDD 派生时,子 RDD 被认为是基于一对一关系依赖于父 RDD。这种依赖关系被称为窄依赖,因为数据可以在包含原始 RDD/父 RDD 分区的同一节点上进行转换,而不需要通过其他执行器之间的网络传输任何数据。
窄依赖处于作业执行的同一阶段。
下图展示了窄依赖如何将一个 RDD 转换为另一个 RDD,并对 RDD 元素应用一对一转换:
宽依赖
当一个 RDD 可以通过在网络上传输数据或交换数据来重新分区或重新分发数据(使用函数,如 aggregateByKey、reduceByKey 等)从一个或多个 RDD 派生时,子 RDD 被认为依赖于参与 shuffle 操作的父 RDD。这种依赖关系被称为宽依赖,因为数据不能在包含原始 RDD/父 RDD 分区的同一节点上进行转换,因此需要通过其他执行器之间的网络传输数据。
宽依赖会在作业执行过程中引入新的阶段。
下图展示了宽依赖如何将一个 RDD 转换为另一个 RDD,并在执行器之间进行数据交换:
广播变量
广播变量是跨所有执行器共享的变量。广播变量在驱动程序中创建一次,然后在执行器中只读。虽然广播简单数据类型(如 Integer)是易于理解的,但广播的概念远不止于简单变量。整个数据集可以在 Spark 集群中进行广播,以便执行器能够访问广播的数据。所有在执行器中运行的任务都可以访问广播变量。
广播使用各种优化方法使广播的数据对所有执行器可用。这是一个需要解决的重要挑战,因为如果广播的数据集的大小很大,你不能指望数百或数千个执行器连接到驱动程序并拉取数据集。相反,执行器通过 HTTP 连接拉取数据,最新的方式类似于 BitTorrent,其中数据集像种子一样在集群中分发。这使得广播变量的分发方式更加可扩展,而不是让每个执行器逐个从驱动程序拉取数据,这可能会导致当执行器数量较多时,驱动程序出现故障。
驱动程序只能广播它所拥有的数据,不能通过引用广播 RDD。这是因为只有驱动程序知道如何解释 RDD,而执行器只知道它们所处理的特定数据分区。
如果深入了解广播的工作原理,会发现机制是首先由 Driver 将序列化对象分割成小块,然后将这些小块存储在 Driver 的 BlockManager 中。当代码被序列化并在执行器上运行时,每个执行器首先尝试从自己内部的 BlockManager 获取对象。如果广播变量已经被获取,它会找到并使用该变量。如果不存在,执行器会通过远程获取来从 Driver 和/或其他执行器拉取小块。一旦获取到小块,它会将这些小块存储到自己的 BlockManager 中,准备供其他执行器使用。这可以防止 Driver 成为发送多个广播数据副本(每个执行器一个副本)的瓶颈。
以下图示演示了广播在 Spark 集群中的工作原理:
广播变量既可以创建,也可以销毁。我们将探讨广播变量的创建与销毁方法。此外,我们还将讨论如何从内存中移除广播变量。
创建广播变量
创建广播变量可以使用 Spark Context 的 broadcast() 函数,适用于任何数据类型的可序列化数据/变量。
让我们来看一下如何广播一个 Integer 变量,并在执行器上执行的转换操作中使用该广播变量:
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)
广播变量不仅可以在原始数据类型上创建,如下一个示例所示,我们将从 Driver 广播一个 HashMap。
以下是一个简单的整数 RDD 转换示例,通过查找 HashMap,将每个元素与另一个整数相乘。RDD 1,2,3 被转换为 1 X 2 , 2 X 3, 3 X 4 = 2,6,12:
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,Int]] = Broadcast(178)
scala> rdd_one.map(j => j * bm.value(j)).take(5)
res191: Array[Int] = Array(2, 6, 12)
清理广播变量
广播变量会占用所有执行器的内存,且根据广播变量中包含的数据大小,这可能会在某个时刻导致资源问题。确实有方法可以从所有执行器的内存中移除广播变量。
对广播变量调用 unpersist() 会将广播变量的数据从所有执行器的内存缓存中移除,以释放资源。如果该变量再次被使用,数据会重新传输到执行器,以便再次使用。然而,Driver 会保留这部分内存,因为如果 Driver 没有数据,广播变量就不再有效。
接下来,我们将讨论如何销毁广播变量。
以下是如何在广播变量上调用 unpersist() 的示例。调用 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$mcV$sp(TorrentBroadcast.scala:202)
at org.apache.spark.broadcast.TorrentBroadcast$$anonfun$wri
因此,广播功能可以大大提高 Spark 作业的灵活性和性能。
累加器
累加器是跨执行器共享的变量,通常用于向 Spark 程序中添加计数器。如果你有一个 Spark 程序,并希望了解错误或总记录数,或者两者的数量,你可以通过两种方式来实现。一种方法是添加额外的逻辑来单独计数错误或总记录数,但当处理所有可能的计算时,这会变得复杂。另一种方法是保持逻辑和代码流程大体不变,直接添加累加器。
累加器只能通过累加值来更新。
以下是使用 Spark Context 创建和使用长整型累加器的示例,使用 longAccumulator 函数将新创建的累加器变量初始化为零。由于累加器在 map 转换中使用,累加器的值会增加。操作结束时,累加器的值为 351。
scala> val acc1 = sc.longAccumulator("acc1")
acc1: org.apache.spark.util.LongAccumulator = LongAccumulator(id: 10355, name: Some(acc1), value: 0)
scala> val someRDD = statesPopulationRDD.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)
有许多内置的累加器可以用于不同的使用场景:
-
LongAccumulator:用于计算 64 位整数的总和、计数和平均值 -
DoubleAccumulator:用于计算双精度浮点数的总和、计数和平均值。 -
CollectionAccumulator[T]:用于收集一组元素
所有前面的累加器都是建立在 AccumulatorV2 类的基础上的。通过遵循相同的逻辑,我们可以构建非常复杂和定制的累加器,以供项目中使用。
我们可以通过扩展 AccumulatorV2 类来构建自定义累加器。以下是实现所需函数的示例。代码中的 AccumulatorV2[Int, Int] 表示输入和输出都为整数类型:
class MyAccumulator extends AccumulatorV2[Int, Int] {
//simple boolean check
override def isZero: Boolean = ??? //function to copy one Accumulator and create another one override def copy(): AccumulatorV2[Int, Int] = ??? //to reset the value override def reset(): Unit = ??? //function to add a value to the accumulator override def add(v: Int): Unit = ??? //logic to merge two accumulators override def merge(other: AccumulatorV2[Int, Int]): Unit = ??? //the function which returns the value of the accumulator override def value: Int = ???
}
接下来,我们将看一个自定义累加器的实际例子。我们将再次使用 statesPopulation CSV 文件作为示例。我们的目标是使用自定义累加器累计年份总和和人口总和。
第 1 步:导入包含 AccumulatorV2 类的包:
import org.apache.spark.util.AccumulatorV2
第 2 步:定义一个包含年份和人口的 Case 类:
case class YearPopulation(year: Int, population: Long)
第 3 步:StateAccumulator 类继承自 AccumulatorV2:
class StateAccumulator extends AccumulatorV2[YearPopulation, YearPopulation] {
//declare the two variables one Int for year and Long for population
private var year = 0
private var population:Long = 0L
//return iszero if year and population are zero
override def isZero: Boolean = year == 0 && population == 0L
//copy accumulator and return a new accumulator
override def copy(): StateAccumulator = {
val newAcc = new StateAccumulator
newAcc.year = this.year
newAcc.population = this.population
newAcc
}
//reset the year and population to zero
override def reset(): Unit = { year = 0 ; population = 0L }
//add a value to the accumulator
override def add(v: YearPopulation): Unit = {
year += v.year
population += v.population
}
//merge two accumulators
override def merge(other: AccumulatorV2[YearPopulation, YearPopulation]): Unit = {
other match {
case o: StateAccumulator => {
year += o.year
population += o.population
}
case _ =>
}
}
//function called by Spark to access the value of accumulator
override def value: YearPopulation = YearPopulation(year, population)
}
第 4 步:创建一个新的 StateAccumulator,并在 SparkContext 中注册:
val statePopAcc = new StateAccumulator
sc.register(statePopAcc, "statePopAcc")
第 5 步:将 statesPopulation.csv 读取为 RDD:
val statesPopulationRDD = sc.textFile("statesPopulation.csv").filter(_.split(",")(0) != "State")
scala> statesPopulationRDD.take(10)
res1: Array[String] = Array(Alabama,2010,4785492, Alaska,2010,714031, Arizona,2010,6408312, Arkansas,2010,2921995, California,2010,37332685, Colorado,2010,5048644, Delaware,2010,899816, District of Columbia,2010,605183, Florida,2010,18849098, Georgia,2010,9713521)
第 6 步:使用 StateAccumulator:
statesPopulationRDD.map(x => {
val toks = x.split(",")
val year = toks(1).toInt
val pop = toks(2).toLong
statePopAcc.add(YearPopulation(year, pop))
x
}).count
第 7 步。现在,我们可以检查 StateAccumulator 的值:
scala> statePopAcc
res2: StateAccumulator = StateAccumulator(id: 0, name: Some(statePopAcc), value: YearPopulation(704550,2188669780))
在本节中,我们研究了累加器及如何构建自定义累加器。因此,通过前面示例的展示,你可以创建复杂的累加器来满足你的需求。
总结
在本章中,我们讨论了多种类型的 RDD,例如 shuffledRDD、pairRDD、sequenceFileRDD、HadoopRDD 等。我们还介绍了三种主要的聚合方式,groupByKey、reduceByKey 和 aggregateByKey。我们探讨了分区是如何工作的,并解释了为什么合理规划分区对于提升性能至关重要。我们还讨论了洗牌过程及其与狭义依赖和广义依赖的概念,这些是 Spark 作业被划分为多个阶段的基本原则。最后,我们介绍了广播变量和累加器的相关概念。
RDD 的灵活性是其真正的力量,它使得适应大多数用例并执行必要的操作以实现目标变得非常容易。
在下一章中,我们将转向 RDDs 之上,Tungsten 计划所增加的更高层次的抽象——DataFrames 和 Spark SQL,以及它们如何在第八章中汇聚,引入一些结构 – Spark SQL。