HKUST-Spark-大数据笔记-全-
HKUST Spark 大数据笔记(全)
001:什么是大数据
在本节课中,我们将要学习大数据的核心概念,包括其定义、关键特性以及不同类型数据的结构。我们将从多个维度理解“大数据”这一术语,并探讨其背后的技术挑战与机遇。

概述
“大数据”并没有一个单一的标准定义。例如,维基百科的定义是:任何因其规模、复杂性和速度而需要新的架构、技术、算法和分析来管理并从中提取价值和隐藏知识的数据集。然而,这个定义并不完美,因为它将“大数据”变成了一个不断移动的目标——一旦我们开发出新的技术来处理它,它就不再是“大数据”了。
尽管如此,这个定义确实揭示了大数据的某些特征。业界普遍认同大数据的“3V”特性,即体量、多样性和速度。

大数据的“3V”特性
以下是构成大数据核心的三个关键特性。

1. 体量
体量指的是数据的规模。在过去的几十年里,数据规模从兆字节、千兆字节增长到了太字节级别,并且其增长速度是指数级的。这不仅带来了绝对数据量的挑战,也带来了处理数据增长速度的挑战。

2. 多样性
多样性指的是数据的复杂性,即数据可以具有的各种格式、类型和结构。这包括数值数据、文本、图像、音频、视频序列、时间序列、社交媒体数据和多维数组等。

虽然这些数据类型本身并不新鲜,每种类型都已被研究多年,但新的变化在于:由于技术进步,我们现在能够在单一应用中生成和收集所有这些不同类型的数据,并将它们联系起来服务于一个统一的目标。这种协同效应能够提取出仅凭单一数据类型无法获得的知识。
3. 速度
速度指的是数据生成和处理的速率,通常与流式数据相关。流式算法和系统的研究早在20世纪80年代就已开始,并非全新概念。然而,在大数据时代,应用需求正推动着对更高效流式处理系统的进一步研究和投资。
数据的结构谱系
上一节我们介绍了数据的多样性,本节中我们来看看这些不同类型的数据如何根据其结构化程度进行分类。数据大体上可以根据其结构化程度分为三类。
结构化数据

结构化数据主要指关系型数据库中的数据,这是经典的数据模型。其核心特征是模式先行,即必须预先定义好数据的结构(称为“模式”),然后才能填入数据。
一个关键的设计思想是将数据拆分到多个表中,通过外键约束进行关联。这减少了数据冗余,降低了维护成本。例如,一个图书数据库可能将作者信息单独存放在作者表中,而在图书主表中只存放作者ID。
然而,查询时需要将多个表连接起来,这使得查询处理变得复杂。连接操作是关系型数据库中最重要且计算密集的操作之一。在SQL中,连接查询的示例如下:
SELECT authors.name
FROM authors, books
WHERE authors.author_id = books.author_id AND books.date > 1980;
这段代码查询了所有在1980年后出版过书籍的作者姓名。
非结构化数据
非结构化数据位于谱系的另一端,如文本、图像、视频等。这类数据没有预定义的模式,格式灵活,但信息密度通常较低。社交媒体帖子是典型的非结构化数据。
半结构化数据
半结构化数据是介于两者之间的折中方案,如JSON或XML文件。它具有一定的结构(例如键值对),但模式可以与数据一起存储,并且不同数据条目可以有不同的结构(模式),这提供了极大的灵活性。
一个JSON对象的例子如下:
{
"firstName": "John",
"lastName": "Smith",
"age": 25,
"address": {
"streetAddress": "21 2nd Street",
"city": "New York",
"state": "NY",
"postalCode": "10021"
},
"phoneNumbers": [
{ "type": "home", "number": "212 555-1234" },
{ "type": "fax", "number": "646 555-4567" }
]
}
半结构化数据在灵活性和处理效率之间提供了一个平滑的权衡点,因此在大数据领域越来越受欢迎。
总结与课程定位
本节课中我们一起学习了大数据的基本概念。总结来说,“3V”特性(体量、多样性、速度)是大数据的核心。尽管这些概念在“大数据”一词出现前就已存在研究,但该术语的流行推动了技术的整合与创新,使我们能够处理更复杂、规模更大的综合型应用。
最后需要说明的是,“大数据”和近年更流行的“数据科学”在技术内容上区别不大,前者更受计算机科学界青睐,后者则源于统计学界。本课程的重点在于 “如何” 实现——即如何使算法能够规模化地运行在海量数据上,并高效利用云计算资源。至于 “为什么” 要使用某种算法或模型,这将是数据挖掘、机器学习等其他课程的重点。

注:本教程根据香港科技大学《基于Spark的大数据计算》课程部分内容整理翻译而成,旨在为初学者提供清晰的概念导引。
002:并行编程的困境 💻
在本节课中,我们将要学习为什么处理大数据会面临技术挑战,特别是为什么传统的并行编程方式如此困难。我们将探讨处理器发展的物理极限、并行计算的基本概念,以及编写并行程序时常见的陷阱。
概述 📋
随着数据量的爆炸式增长,传统的单核处理器计算模式已无法满足需求。然而,转向并行计算的道路并非一帆风顺。本节将深入分析并行编程的核心困境,包括其技术根源和实际编程中令人沮丧的挑战。
技术背景:从纵向扩展到横向扩展 🔄
上一节我们介绍了大数据的规模挑战,本节中我们来看看技术层面是如何应对的。
一个重要的技术障碍是处理器(CPU)的原始速度已经触及物理极限。这大约从2005年开始发生。观察蓝色曲线,即时钟速度,它在2005年左右开始趋于平缓,尽管晶体管数量仍在增长。本质上,我们达到了提升速度的物理极限。
这并非因为技术上无法实现,而是经济原因。随着处理器速度提高,功耗并非线性增长,而是会消耗更多电力,更重要的是会产生更多热量。散热问题已成为高性能计算的主要难题。讽刺的是,人们需要花费更多能量在散热系统上,而非实际计算。因此,从经济角度讲,将速度提升到例如3GHz以上并不划算。这就是为什么大多数CPU的主频都低于3GHz。
然而,另一方面,我们拥有了多核处理器。你的手机可以轻松拥有4核或8核,台式机可以轻松拥有16核或更多。而单个GPU可能拥有数千个核心。最终,我们可以扩展到计算机集群,由上百台计算机组成,每台计算机又可以拥有多CPU(多核)和多GPU(数千核)。因此,并行计算的能力是巨大的。

所以,本质上,这是一种更经济的扩展计算能力的方式。这里有两个词略有区别:纵向扩展 和 横向扩展。
- 纵向扩展 指的是提升单个CPU的速度。
- 横向扩展 指的是保持单个核心的速度不变,但通过使用更多核心来扩展计算能力。
有人提到了摩尔定律。我仍然记得大约2010年时,我们领域旗舰杂志《ACM通讯》的一期封面故事,标题是“摩尔定律已死”,这不幸地基本属实。摩尔定律指的是CPU速度每18个月翻一番的现象。这一规律在2005年之前基本正确。2005年之后,CPU速度的指数级增长不再可持续,因此人们宣告了摩尔定律的终结。
这从根本上改变了人们对计算的看法。当摩尔定律有效时,你可以通过简单地等待18个月,让英特尔发布新CPU,就能轻松地纵向扩展你的计算能力,无需对程序做任何改动。然而,现在情况不同了。即使你再等10年,如果不更新你的代码或算法,你将无法获得任何速度提升。因此,走向并行或分布式计算是扩展计算能力的唯一途径。这意味着我们需要从根本上改变设计算法和系统的方式。
并行编程的固有难题 😫
不幸的是,并行计算或并行编程本质上是令人沮丧的。让我用一个简单的例子说明,如果处理不当,事情可能会在哪里出问题。
我们考虑一个只有两个线程并行运行的简单情况。顺便说一下,在并行世界里有一个术语叫 “易并行”。这意味着并行化非常容易,如果你不知道怎么做会让人尴尬。如果一个程序由没有数据共享的独立任务组成,那么它就是易并行的。这很容易,如果线程间没有通信或协调,并行编程就非常简单。
然而,更重要的任务是如何协调不同的线程,使它们能够协作完成同一个任务。在这种情况下,数据共享是不可避免的。这里有一个最简单的数据共享例子。
假设我们有一个共享变量 V,它是一个全局计数器,用于统计某些事件。两个线程都试图给这个计数器加1。如果你使用Python或Java,可以简单地写 V++(在C++中也是如此),一行代码就完成了。
然而,这并不是底层实际发生的情况。对于像C这样的高级编程语言,这一行代码实际上被编译成多条指令。因为 V 存储在内存中的某个地方,而不是寄存器里。所以CPU必须先从内存中获取这个变量。因此,这一行代码至少会被编译成三条指令(实际可能更多):
- 将变量
V从内存加载到本地寄存器。 - 将寄存器中的值加1。
- 将结果写回变量
V。
预期的结果当然是线程A给 V 加1,线程B也给 V 加1,当两个线程都完成后,V 应该增加了2。
但是,由于线程由两个不同的CPU核心独立执行,它们的执行速度可能不同。这些指令如何交错执行没有任何保证。如果线程A的指令执行在线程B的指令1和指令3之间呢?让我们看看会发生什么。
假设 V 的初始值是0。
- 线程B的指令1先执行,将值0加载到B的本地寄存器。
- 然后线程A的指令1执行,同样将值0加载到A的本地寄存器。
- 接着,它们都在本地寄存器中加1。
- 最后,它们将结果(都是1)写回内存。谁先写回并不重要。
当两个线程终止时,V 的值只增加了1,而不是预期的2。这就是所谓的 竞态条件。就像两个线程在赛跑。这就是问题可能发生的地方。
更令人沮丧的是,99%的时间它不会发生。因为CPU速度很快,只有当指令恰好在特定时刻交错时(例如A的指令在B的指令1和3之间执行),问题才会出现。所以大多数时候程序运行正常。然而,当你将程序部署给客户后,有时在深夜,他们会打电话说:“嘿,你的代码出错了。”当你赶到现场试图复现时,它又运行正常了。这就是我们所说的令人沮丧之处。这类错误非常容易犯,但极难检测和调试。这就是为什么并行编程非常困难。
据我所知,并行编程并非任何计算机科学项目的必修课。在我们的BDT项目中,我们确实有一门并行计算课程,但那是选修课。如果你感兴趣,当然欢迎选修以了解更多如何处理这些情况。
长话短说,防止这种情况发生的标准方法是通过操作系统和硬件支持来添加锁,不能仅靠纯软件实现。锁本质上是放在共享资源上的东西,在修改它之前先加锁。通过使用锁,可以防止竞态条件。
死锁:更复杂的困境 🔒
然而,假设有两个共享资源:资源1和资源2。同样有两个线程T0和T1,它们试图执行涉及这两个资源的操作。例如,这是一笔客户和卖家之间的交易,你需要从客户账户扣除10000美元,并将相同金额存入卖家账户。这是一笔涉及两个共享资源(变量)的交易,你需要在修改前锁定两者。
但是,如果发生以下情况:T0先获得了资源2的锁,而T1先获得了资源1的锁。然后T0试图获取资源1的锁,同时T1试图获取资源2的锁。接下来会发生什么?你进入了死锁状态。没有线程可以继续执行,因为每个线程都在等待另一个线程释放锁。
更糟糕的是,没有很好的解决方案。令人惊讶的是,尽管人们意识到这个问题多年,但就是没有好的解决办法。只能希望这种事情不常发生。确实,它们不常发生,因为死锁的发生要求线程必须以特定的顺序获取锁。
所以,人们基本上只能这样:解决死锁的最佳方法是,当意识到没有任何进展时,重启所有线程。这本质上是人们拥有的最佳解决方案。还有其他方案,例如强制规定顺序,要求所有线程必须先锁资源1,再锁资源2,通过强制规定锁的获取顺序来防止死锁。
然而,这非常昂贵。因为这个例子只有两个共享资源。如果我们谈论的是涉及百万个账户的交易,那就有百万个资源。在百万甚至十亿个账户上维持一个线性顺序非常昂贵,根本不具备可扩展性。因此,这本质上是一个非常困难且令人沮丧的问题。
总结与困境的根源 📝
总结一下,竞态条件和死锁非常难以调试,因为它们本质上是非确定性地发生的。因此,编写正确的并行程序非常困难。
更阻碍并行编程普及的是,存在太多不同的并行架构,为一个架构编写的程序无法轻松移植到另一个。例如,为多核编写的程序无法直接在集群上运行。为集群编写的程序也无法直接在GPU上运行。不同的架构需要不同的代码和库。本质上,你必须为每种不同的并行架构编写程序,这非常繁琐。
因此,如何为这些东西编程?直到今天,并行编程仍然是一项只有最优秀的程序员才能掌握的艰巨任务。事实上,我们稍后将讨论的整个MapReduce范式就是由谷歌发明的。谷歌已经是拥有最多优秀程序员的公司,即使他们也面临这个问题。只有极少数最优秀的程序员能够编写正确且高效的并行程序。这根本不具备可扩展性。他们有海量数据、众多任务和大量客户需要服务,他们确实需要比编写这种底层程序更简单的东西。
然而,我必须同时承认,如果你真的追求效率,效率是第一准则,那么像OpenMP或用于GPU的CUDA这样的工具仍然是最好的选择。如果你有一个非常专门化的任务,并且有优秀的程序员,那么为了追求效率,仍然应该使用这些底层的并行编程工具,你应该选修并行编程课程。
然而,对于大多数程序员,尤其是数据分析师,我们真的需要一种让事情变得更简单的方法。
云计算的推动力 ☁️
另一个主要驱动力是云计算。虽然我们会更正式地讨论云计算,但我相信你们都听说过甚至使用过云计算。云计算的核心理念是将计算资源作为公用事业来出售。
因此,拥有大量高性能设备不再是少数人的特权。每个人,无论是否有钱,都可以轻松获取大量计算资源。这也意味着普通用户可能也会感到需要横向扩展他们的程序。当然,普通用户甚至不懂C语言,我们怎么能指望他们用OpenMP编写并行程序呢?
本质上,这正是重新发明新计算范式的动机,旨在让并行计算变得容易得多。
本节课总结 🎯


本节课中我们一起学习了并行编程面临的核心困境。我们了解到,由于处理器速度触及物理极限,计算能力的扩展必须从“纵向扩展”转向“横向扩展”,即利用多核与分布式集群。然而,并行编程本身充满挑战,主要包括难以调试的竞态条件和死锁问题。此外,多样的硬件架构导致程序移植困难,使得编写正确、高效的并行程序门槛极高。正是这些困境催生了像Spark这样旨在简化分布式计算的新范式。
003:MapReduce编程模型
在本节课中,我们将要学习MapReduce编程模型。这是一种用于处理海量数据的并行计算框架,由Google提出,旨在解决并行计算中因线程运行速度不一致而导致的竞态条件问题。我们将了解其核心思想、工作原理,并通过具体例子学习如何使用它。

并行计算的问题与MapReduce的解决思路
上一节我们介绍了并行计算中存在的竞态条件问题。其根本原因是不同线程以不同速度运行,且彼此之间缺乏协调。
一个自然的想法是增加协调机制,同步所有线程的行为。然而,这种细粒度的同步在技术上实现起来非常困难,且成本高昂。为了效率,不同线程的运行速度必然存在差异,但这又会导致竞态条件。
Google提出的MapReduce框架巧妙地解决了这个问题。它在某种意义上引入了同步协调,但并非细粒度同步,而是一种粗粒度的同步步骤。接下来,我们看看MapReduce框架是如何工作的。
MapReduce模型概述
MapReduce模型将任何计算任务划分为多个轮次。每个轮次包含两个或三个阶段。

第一个阶段是Map阶段。此阶段将输入记录转换为中间键值对。这个阶段的所有任务可以并行且独立地执行,因为每个Map任务都独立地应用于每条记录,无需协调。

然后,所有中间键值对进入Shuffle阶段(或排序阶段)。在此阶段,所有具有相同键的键值对会被转发到同一个Reduce任务。

每个Reduce函数接收所有具有相同键的键值对,然后产生输出。输出可以是0条、1条或多条记录。这就完成了一轮MapReduce计算,结果可以进入下一轮。
这是一种粗粒度同步,因为每一轮只有一个同步点,即Shuffle阶段。Shuffle阶段必须等待所有Map任务完成后才能开始Reduce阶段,因此这是一个同步点。这种同步成本很高,在MapReduce编程中,最小化需要Shuffle的数据量是衡量程序优劣的首要标准。然而,MapReduce每一轮只有一个同步点,且通常程序不会运行太多轮次,因此能在保证效率的同时实现并行化。
Map和Reduce任务的定义
Map任务和Reduce任务都由用户代码定义。用户需要定义在Map和Reduce函数中执行什么操作。
对于Map函数,其输入是一个键值对,输出也是一个或多个键值对。本质上,它是一个一对多的转换。

对于Reduce函数,其输入是所有具有相同键的键值对。我们可以将其形式化地表示为:(key, [value1, value2, ...])。Reduce函数可以产生任意数量的键值对作为输出或下一轮的输入。
程序员无法控制Shuffle阶段的行为,它是固定的:总是将来自所有Map任务的、具有相同键的键值对分组,并以 (key, [value1, value2, ...]) 的形式传递给某个Reducer。

以下是处理粒度的总结:
- Map任务:以记录为单位运行,可能产生0个、1个或多个中间结果。
- Reduce任务:以一组具有相同键的记录为单位运行,可能产生0个、1个或多个输出。
实例解析:词频统计
现在让我们看一个具体例子,了解如何使用MapReduce完成任务。这个例子来自Google关于MapReduce的原始论文,是搜索引擎非常关心的任务:词频统计,即计算所有文档中每个单词的出现频率。
输入文件是所有从互联网爬取的HTML文件。首先,将每个文件分割成行,每行字符串作为一个输入记录。

Map函数接收一行文本,并产生多个中间键值对。对于这个问题,键就是单词本身,值是该单词在该行中出现的次数(通常为1)。例如,对于一行 "apple orange mango",Map函数会输出 (apple, 1), (orange, 1), (mango, 1)。

然后,所有键值对进入Shuffle阶段。此阶段将所有具有相同键的键值对分组。例如,所有 (apple, 1) 会被分到一组。
接着,这些分组后的数据(例如 (apple, [1, 1, 1, ...]))被发送到Reduce函数。Reduce函数的工作很简单:将列表中的所有值相加,得到该单词的总出现次数,并输出 (apple, 总数)。


因此,词频统计问题可以通过一轮MapReduce轻松解决。
实例解析:倒排索引
第二个例子是倒排索引。这是搜索引擎日常执行的另一项重要任务。搜索引擎需要根据搜索关键词,返回所有包含该关键词的网页列表。这需要一种称为倒排索引的数据结构,它将关键词映射到包含它的URL列表。
输入是一个庞大的(URL, 文档)对集合,其中文档可以看作字符串(正向索引)。
Map任务接收一个(URL, 文档)对,并为文档中的每个关键词发射一个(关键词, URL)键值对。
然后,所有(关键词, URL)对进入Shuffle阶段,所有包含相同关键词的URL会被分组到一起。
Reduce函数接收一个(关键词, [URL1, URL2, ...]),简单地将所有URL连接成一个列表并输出。这样,我们就得到了倒排索引。
在MapReduce中处理数据库查询


人们很快开始尝试使用MapReduce处理各种任务,包括关系数据库查询。假设数据存储在分布式文件系统中,我们想用MapReduce处理SQL查询,该怎么做?
让我们回顾之前提到的连接查询例子:在作者表和书籍表之间进行连接,找出1980年后出版过书籍的所有作者姓名。
我们需要编写两个Map函数(逻辑上),分别处理作者表和书籍表。实际上,我们可以写在一个函数里,通过判断输入来源来区分。
关键点是:对于作者表中的记录,Map函数以作者ID为键,以作者姓名为值输出。对于书籍表中的记录,Map函数同样以作者ID为键,以出版日期为值输出。
这样做的原因是,我们有一个基于作者ID的连接条件。在Shuffle阶段,所有具有相同作者ID的键值对会被分组到一起。因此,如果一个作者写了三本书,我们会得到来自书籍表的三个键值对和来自作者表的一个键值对,它们都会被送到同一个Reduce函数。
Reduce函数的工作是检查:在收到的值列表中(包含作者姓名和多个出版日期),是否有任何一个日期晚于1980年。如果是,则输出作者姓名(根据语义可能需要去重)。
然而,这不是最高效的方法,因为每条记录都会产生一个中间键值对,导致Shuffle阶段需要移动大量数据。一个优化方法是:将“出版日期晚于1980年”这个过滤条件从Reduce阶段提前到Map阶段。在Map阶段处理书籍表时,只输出出版日期晚于1980年的记录的键值对。这可以显著减少需要Shuffle的中间数据量,从而提高效率。
存储层:Google文件系统
MapReduce只处理计算部分,我们还需要处理存储问题:输入文件、输出结果以及两轮MapReduce之间的中间结果存储在哪里?Google为此提供了自己的解决方案:Google文件系统。
其核心思想是构建一个分布式文件系统。由于单个大磁盘在技术和性能上不可行(I/O是瓶颈),GFS使用大量廉价的商用机器,每台机器都有自己的磁盘。通过将数据分布存储在许多机器上,并行读写,从而获得高聚合I/O带宽,并且易于扩展。
但硬件(尤其是硬盘)故障是常态。当拥有成千上万台服务器时,每天都可能发生硬盘故障。因此,GFS的设计目标是在高度不可靠的硬件上构建高度可靠的存储系统。
GFS的设计要点如下:
- 大文件分块:文件被分割成固定大小的块(默认为64MB)。
- 通过复制实现可靠性:每个数据块会在三个或更多的块服务器上复制。即使丢失两个副本,数据仍然可用。用户还可以指定更高的冗余级别(例如跨不同数据中心)。
- 元数据管理:记录每个数据块及其副本位置的信息(即元数据)存储在一个主节点上。
- 适应的工作负载:文件通常是一次写入、多次读取,适合大数据批处理任务,其中带宽比延迟更重要。
总结

本节课中,我们一起学习了MapReduce编程模型。我们首先了解了它如何通过引入粗粒度的同步(Shuffle阶段)来解决并行计算中的竞态条件问题。然后,我们深入探讨了Map和Reduce阶段的工作原理,并通过词频统计和倒排索引两个经典例子,具体学习了如何编写MapReduce程序。我们还看到了如何将MapReduce思想应用于类似数据库查询的任务,并了解了优化Shuffle数据量的重要性。最后,我们介绍了支撑MapReduce计算的存储层——Google文件系统的基本设计思想,它通过数据分块和复制,在不可靠的硬件上构建了可靠的分布式存储。MapReduce模型为大规模数据批处理提供了一个清晰、强大的编程框架。
004:什么是Hadoop 🐘
在本节课中,我们将学习Hadoop,它是Google的MapReduce和GFS(Google文件系统)的开源实现。我们将了解Hadoop的架构、核心组件HDFS(Hadoop分布式文件系统)的工作原理,以及它如何与MapReduce执行引擎协同工作。课程最后,我们还会探讨关于MapReduce范式的一场重要学术辩论。

Hadoop的起源与概述

正如之前提到的,Google从未公开其MapReduce和GFS的源代码。因此,Google的具体实现方式至今仍是一个谜。在开源社区中,我们有了Hadoop。
Hadoop是Google的MapReduce和GFS的开源实现。它提供了一个简洁的编程抽象,用户只需提供两个函数:map和reduce。它同时提供了自动的并行化和分布式处理能力。更重要的是,这些并行化和容错机制对终端用户是透明的。
从终端用户的角度看,他只看到一个逻辑文件,尽管在底层,这个逻辑文件实际上有三个物理副本。节点和任务会自动失败并自动恢复。这回答了聊天中的一个问题:如果三个副本中的两个丢失了,数据块是否会再次复制两次?答案是肯定的。因为当一个节点永久失效时,系统会启动一个新节点来接管。元数据信息存储在主节点上,因此系统确切知道丢失的节点中存储了哪些数据块,可以从其他副本中获取数据来重建失效的节点。

关于主节点失效的问题,简单的解决方案是设置一个次级主节点。但这会引入协调问题,需要决定哪个主节点处于控制状态。这种协调通常由ZooKeeper这样的系统来处理。
Hadoop的命名与架构
Hadoop这个名字是一个完全虚构的词。它简短、易拼写、易发音,且没有实际含义,更重要的是之前没有被使用过。
实际上,Hadoop是两部分的结合。Google构建了两个独立的层:MapReduce执行引擎和GFS文件系统。然而,在Hadoop中,它被设计为同时完成这两项工作。
在具体实现中,每个工作节点扮演两个角色。在文件系统层,它们被称为数据节点(存储数据)和名称节点(存储文件名和元数据)。名称节点存储元数据,特别是数据块的位置信息。
同时,每个工作节点在MapReduce层也扮演角色。对于从节点,它们被称为任务追踪器;对于主节点,则被称为作业追踪器。一个MapReduce程序被称为一个作业,例如“词频统计”就是一个MapReduce作业。每个作业由许多任务组成,一个map是一个任务,一个reduce也是一个任务。任务由从节点(任务追踪器)执行,而主节点(作业追踪器)负责跟踪整个作业的进度,分配任务,并确保任务完成。如果某个任务失败,其他节点会接管。
HDFS详解
现在让我们更详细地了解Hadoop中这两层是如何构建的,先从文件系统层开始,即HDFS。
名称节点存储所有元数据。以下是一个简化的元数据表示示例:
文件名: /user/sample/data/part0
块列表:
- 块1: 副本数=2, 位置=[数据节点1, 数据节点3]
- 块2: 副本数=3, 位置=[数据节点2, 数据节点4, 数据节点5]
...
名称节点存储了一个目录,告诉你到哪里可以找到这些数据块的不同副本。数据节点有成百上千个,它们存储实际的数据。文件被分割成块,每个块默认复制三份,这个数量可以根据需求调整。副本越多,可靠性越高,但成本也越高。

数据节点通过周期性的心跳消息与名称节点通信,默认每3秒一次。如果名称节点在一定时间内(例如10秒)没有收到某个数据节点的心跳,就会认为该节点失效。名称节点知道失效节点上存储了哪些数据块,因此可以从其他副本中恢复数据,例如从其他节点获取块2和块5的副本来重建失效的数据节点。系统会维护一个空闲机器池,用于在机器失效时进行替换,这为工程师修复硬件提供了缓冲时间。

关于数据同步和更新的问题,需要明确HDFS或GFS是为写入一次、读取多次的场景设计的。文件很少被修改,通常只能重写整个文件,而不是部分更新。因此,频繁的事务更新(如电商网站)不是HDFS的目标场景。这类事务处理(TP)工作负载与HDFS擅长的分析处理(AP)工作负载非常不同。现代大规模数据库系统通常将TP和AP分离,例如阿里巴巴的OceanBase(TP)和AnalyticDB(AP)。在开源社区,Spark是流行的AP选择,而MongoDB是TP的例子。目前还没有一个完美的统一系统能同时高效支持两者。


HDFS数据流与容错
现在让我们看看数据流的概览,即用户程序的读请求是如何被处理的。

客户端首先访问名称节点,告知要访问哪个文件。名称节点返回文件的块ID列表及其位置信息。然后,客户端直接去这些数据节点获取数据。客户端可以并行读取数据。由于每个数据块有三个副本,协调算法会智能地进行负载均衡。例如,它可能指示客户端从不同节点读取不同的块,以实现平衡的流量分配。
数据节点每3秒发送一次心跳消息给名称节点,名称节点用心跳来检测数据节点失效。无响应超过设定时间即被视为失效。


关于数据平衡算法,它相当复杂,需要综合考虑负载均衡、通信成本和流量导向等多个因素。
在Hadoop 2.0中,默认使用三副本机制。从Hadoop 3.0开始,引入了更智能的纠删码技术。三副本的存储开销是200%(即只有1/3的存储是有效数据),这很浪费。纠删码,例如简单的奇偶校验码,可以降低开销。
奇偶校验码示例:假设有两个数据块A1=1101和A2=1000,奇偶校验块AP是这两个块的按位异或(XOR):1101 XOR 1000 = 0101。如果A1或A2中有一个丢失,可以通过另一个数据块和奇偶校验块恢复出来。这只需要50%的存储开销,远低于三副本的200%。但它的容错能力更差:三副本可以容忍两个服务器失效,而简单的奇偶校验只能容忍一个失效。
纠删码是奇偶校验的泛化,使用里德-所罗门算法等。它将N个数据单元通过线性变换生成M个校验单元。只要N+M个单元中有任意N个可用,就能恢复所有数据。例如,设置N=6(数据块),M=3(校验块),存储效率为6/(6+3)=66.7%,可以容忍最多3个失效。增加M可以提高存储效率,增加N可以提高容错能力,但计算成本也会随之增加。这与某些硬件RAID级别的理念类似,但这是在软件层面(Hadoop)实现的。
最后,名称节点是单点故障,因此需要添加次级名称节点以备不时之需。
Hadoop MapReduce 工作流
现在我们把所有部分结合起来。HDFS是文件系统层,在其之上是Hadoop MapReduce,即MapReduce的开源实现。这两层协同工作来完成一个MapReduce作业。


以词频统计为例。Map阶段类似于读取文件,因此可以将Map任务应用到输入数据块上。HDFS的均衡器会智能地将不同的Map任务路由到合适的Worker节点(数据节点),以便以均衡的方式读取数据(因为每个块有三个副本)。数据从HDFS获取后,剩余的处理完全在MapReduce执行引擎内完成。Map函数产生中间结果,然后经过Shuffle阶段将中间结果重新分配。Reducer接收这些中间键值对,执行用户定义的Reduce函数,并将最终结果写回HDFS。

在MapReduce层,作业追踪器是主节点,通常与HDFS的名称节点运行在同一个物理节点上。它接收用户作业,决定运行多少个任务(Map任务数量通常取决于输入数据的分块数),以及运行多少个Reduce任务。分配时会考虑数据本地性,以尽量减少Shuffle的数据量。
在这个例子中,一个MapReduce作业有5个Map任务和3个Reduce任务。这可以配置,也有自动分配过程。

类似于数据节点发送心跳,在执行MapReduce作业时,工作节点也会定期向作业追踪器发送进度报告。因为MapReduce作业可能运行很长时间,期间可能发生故障。如果一个Map任务失败,它会重新执行。如果一个Reduce任务失败,它也会重新执行。如果一个工作节点失败,情况稍复杂,因为Shuffle的中间数据不存储在HDFS中。如果一个Reduce工作节点失败,它可能还需要重新执行一些已完成的Map任务,以重新生成丢失的中间结果并发送给新的Reduce任务。
所有这些进度和完成状态都由主节点跟踪。最后,主节点故障由像ZooKeeper这样的系统处理。通常,除非在数百台机器的集群上运行或运行时间极长,否则主节点故障的几率较小。

MapReduce的争议与大数据生态演进
回顾我们讨论的内容,开源社区一直紧密跟随Google的脚步。Google有GFS,我们有HDFS;Google有MapReduce,我们有Hadoop MapReduce。后来Google发布了BigTable,开源社区就有了HBase和Hive(用于在HDFS上运行SQL查询)。还有用于更高效计算的Drill,用于大规模图处理的Pregel,以及开源的Giraph。可以说,Google在这个领域如同引领者。
这种情况一直持续到2008-2009年左右。数据库社区的两位大家——David DeWitt和Michael Stonebraker(后者是图灵奖得主)——在顶级数据库会议SIGMOD的一个讨论小组上,提出了一个颇具争议的观点:MapReduce是一个巨大的退步,而不是进步。他们指的是当时所有人都试图用MapReduce做一切事情(包括SQL处理)的现象。
他们提出的观点包括:
- 对某些计算是好的:他们承认MapReduce对于编写词频统计、构建倒排索引等特定类型的计算是个好主意。
- 编程范式的退步:对于大规模数据密集型应用,它是一个巨大的退步。
- 次优的实现:它强制进行全表扫描,而数据库社区花费巨大精力构建的索引(如B树、哈希表)在MapReduce中缺失,导致查找查询效率低下。
- 缺失现代DBMS功能:它缺少当前数据库管理系统(DBMS)中常见的许多功能,特别是事务处理、更新操作以及关系操作(如连接)。
- 缺乏新颖性:最关键的是,他们认为MapReduce并不新颖。早在1990年,图灵奖得主Leslie Valiant就发表了一篇关于批量同步并行模型的论文,其思想与MapReduce本质相同。Google在后来发表相关论文时才引用了Valiant的工作。

这场辩论实际上引发了革命。可以说,大数据领域的爆发正是在这场辩论之后。人们开始意识到,MapReduce可能不是唯一的方式。同时期出现的“NoSQL”运动最初常与MapReduce等同,但后来被重新诠释为“Not Only SQL”,旨在结合关系数据库40年的研究成果和分布式系统的优点。
在2008年辩论之后的几年里,大数据领域真正呈现爆炸式增长,涌现出许多新产品和新思想,利用不同系统和理念的优势,为我们提供了丰富多样的工具集。


总结

本节课中,我们一起学习了Hadoop,它是Google MapReduce和GFS的开源实现。我们了解了Hadoop的双层架构:底层的HDFS负责可靠的分布式存储,上层的MapReduce提供并行计算框架。我们探讨了HDFS通过副本和心跳机制实现容错,以及Hadoop 3.0引入的纠删码技术。最后,我们回顾了关于MapReduce范式价值的学术辩论,这场辩论促进了大数据生态系统的多元化发展,催生了Spark等更灵活、高效的计算框架。理解Hadoop是理解现代大数据技术栈的重要基石。
005:Hadoop与Spark对比 🆚


在本节课中,我们将要学习Hadoop与Spark这两个重要的大数据框架之间的核心区别。我们将从历史背景出发,分析它们各自的设计哲学、优缺点,并理解Spark如何针对Hadoop的局限性进行改进。
历史背景 📜
上一节我们介绍了MapReduce的基本概念。本节中,我们来看看其发展历程以及Spark诞生的背景。
大数据领域的发展似乎遵循着“偶数年定律”。2004年,Google发表了关于MapReduce的论文。2006年,Hadoop项目诞生。2008年,Hadoop成为Apache顶级项目。2010年,Spark项目启动。2012年,Spark成为Apache顶级项目。2014年,Spark开始流行。2016年,Spark持续发展。2020年也不例外,发生了许多重大事件。
MapReduce的局限性 ⚠️
上一节我们提到了MapReduce的一些优点。本节中,我们来深入探讨它的主要缺点。

MapReduce主要有两个方面的不足。
以下是第一个缺点,即编程模型方面:
- 编程模型过于受限。最初这被认为是优点,因为程序员只需定义两个函数。在早期,MapReduce仅用于少数特定的数据密集型任务,如词频统计、构建倒排索引等搜索引擎关心的任务。然而,当大量人群开始追随这一趋势,试图用MapReduce完成所有事情时,人们很快意识到,程序员只能定义两个函数这一点限制性太强。许多复杂任务要么难以实现,要么几乎不可能在MapReduce中实现。这属于编程方面的可表达性问题。


以下是第二个缺点,即性能方面:
- MapReduce速度慢。特别是当任务涉及多轮MapReduce时。我们上次看到的词频统计和构建倒排索引的例子,都只需要一轮MapReduce。对于这类任务,MapReduce表现尚可。然而,当人们开始尝试其他任务,特别是机器学习时,问题就出现了。许多机器学习任务本质上是迭代的,涉及多次循环,例如最广泛使用的梯度下降训练方法。当人们尝试使用MapReduce处理这些迭代任务时,它变得非常慢,慢到几乎无法忍受。根本原因在于它必须将中间结果写回磁盘。MapReduce最初被设计为单轮机制。如果想用它进行多轮迭代作业,所有中间结果都必须写回磁盘(如GFS或HDFS,它们本身具有冗余性,例如Hadoop 2.0默认使用3个副本)。这意味着每个中间结果都需要写入磁盘三次,并且在下一次迭代时再被读取回来。磁盘I/O在根本上比内存慢几个数量级,这为MapReduce作业造成了巨大的瓶颈。
专用系统的困境与Spark的登场 🚀
上一节我们了解了MapReduce的局限性。本节中,我们来看看为了解决这些问题而出现的专用系统,以及它们如何催生了Spark。
当许多构建在Hadoop之上的专用系统出现后,上述第二个问题(性能问题)变得更加严重。这些系统(如Google的Pregel及其开源版Giraph,用于图计算;Google的Dremel及其开源版Drill,用于交互式查询)主要是为了解决第一个问题(编程模型受限),使编程模型更灵活、更丰富,让人们更容易编写复杂程序。
然而,这些专用系统实际上让第二个问题(性能问题)变得更糟。因为这些系统最终会将用户程序编译成MapReduce作业,所有计算任务仍然由MapReduce引擎执行,并且不可避免地需要多轮运行,这使得性能问题更加突出。
最终,Spark登场了。Spark的设计初衷正是为了解决这两个问题:可编程性/易用性和效率/性能。
在效率方面,Spark尽可能避免使用磁盘,专注于内存计算。只要内存足够,Spark会不惜一切代价避免磁盘I/O。当然,由于容错性的考虑,某些磁盘使用是不可避免的,特别是输入和输出仍需驻留在HDFS这样的容错文件系统中。为了促进内存计算,Spark引入了有向无环图执行模型,这与内存计算配合得非常好。
在可编程性方面,Spark也更容易使用。回想一下,Hadoop MapReduce是用Java编写的,用户也只能用Java编写MapReduce作业。虽然Spark核心是用Scala/Java编写的,但在用户接口层面,它支持丰富的API,包括Java、Scala、Python和R这些对用户更友好的语言。此外,Spark还支持交互式Shell和交互式计算(如果使用Python或R)。而MapReduce只支持批处理作业提交。这在程序已经写好、调试完毕并准备在大数据集上运行时是可以接受的。但在开发阶段,当你需要修复错误、尝试不同技巧,或者先在小数据集上运行程序以获得初步想法时,交互性就变得非常重要。对于交互式数据分析,人们通常不会一开始就在非常大的数据集上运行程序,而是先在一些较小的样本数据集上运行,获得大致想法后再回去修改。因此,交互性对于易用性至关重要,而Spark使之成为可能。Spark的一个特别优点是,例如使用Python时,Spark可以被视为一个加载到Python中的额外库。你仍然可以使用Python的所有功能,Spark只是作为一个库引入,提供了将你的程序扩展到大规模数据集的所有功能。
实验结果表明,当内存充足时,Spark可以比MapReduce/Hadoop快100倍。即使内存不足,Spark仍然可以比MapReduce快10倍。在编程方面,用Spark编写的相同程序,其代码量可比MapReduce少2到5倍。代码量减少的另一个原因是Spark提供了更丰富的API。MapReduce只包含两个函数(映射和规约),而Spark除了map和reduce,还支持更多转换操作。
Spark的流行与发展趋势 📈

上一节我们看到了Spark的设计优势。本节中,我们来看看它的发展历程和市场接受度。
Spark的简短历史如下:最初于2009年在加州大学伯克利分校开发,2010年成为开源项目,随后成为Apache软件基金会所有大数据开源项目中最活跃的项目。许多公司正从Hadoop转向Spark,包括许多知名大公司。实际上,这些大公司通常构建自己的系统,不一定使用Spark,并且他们的系统通常不开源。然而,对于大多数中小型企业,他们直接使用Spark,或对Spark进行小的调整和修改以更好地满足自身需求。
在流行度方面,绿色曲线代表Hadoop,红色曲线代表Spark。不出所料,2014年之后,Spark的受欢迎程度显著超过了Hadoop。


核心技术理念:内存计算 💾

上一节我们了解了Spark的流行。本节中,我们来深入探讨其核心的技术理念。
从高层次的技术理念来看,Spark希望尽可能避免使用磁盘。当然,这对内存提出了更高的要求。然而,内存容量一直在稳步增长。如今,购买一台配备1TB内存的笔记本电脑已经很容易。在内存和磁盘之间,SSD也越来越便宜、容量越来越大。因此,许多人预测磁盘最终会消失。这里需要提到的是非易失性内存,它甚至比SSD更快。无论如何,这里的核心信息是,随着技术进步,磁盘正越来越成为一种永久存储介质,将从计算中消失。本质上,未来进行任何计算时,你都会将数据加载到内存中,所有操作都在内存中进行。只有在一天结束时,业务关闭后,才将数据写回磁盘进行归档存储。在早期,人们试图优化磁盘I/O;而在未来几年,磁盘可能会退出舞台,仅作为归档存储的介质。
下图展示了Spark的一般执行过程:我们仍然只从HDFS读取一次数据,然后可以在内存中执行多次迭代,中间结果也只存储在内存中。此外,如果我们在同一数据集上执行不同的任务,也只需要读取一次数据。读取一次后放入内存,内存也可以是分布式的。因此,即使单台机器的内存不足,当你从数百个节点汇集内存(也称为内存云)时,容量应该不是问题。一旦数据加载到内存中,你就可以在同一数据集上执行多个查询、运行多个计算作业。
关键差异总结 📊

上一节我们探讨了Spark的内存计算理念。本节中,我们通过一个表格来总结Hadoop与Spark的关键差异。
| 对比维度 | Hadoop (MapReduce) | Spark |
|---|---|---|
| 存储 | 中间结果存储在磁盘(HDFS) | 中间结果优先存储在内存,不足时溢写到磁盘 |
| 操作/编程性 | 仅支持 map 和 reduce 两种操作 |
支持丰富的转换操作,map和reduce是其中特例 |
| 执行模型 | 仅批处理 | 支持批处理、交互式和流处理 |
| 主要语言 | Java | Java, Scala, Python, R |

课程总结 🎯

本节课中,我们一起学习了Hadoop与Spark的对比。我们从MapReduce的局限性(编程模型受限和性能低下)出发,了解了专用系统如何加剧了性能问题,从而引出了Spark的诞生。Spark通过内存计算和有向无环图执行模型极大地提升了性能,同时通过丰富的API和多语言支持(特别是交互式Shell)显著改善了易用性和可编程性。最后,我们总结了二者在存储、操作、执行模型和语言支持等方面的关键差异。理解这些差异有助于我们根据具体任务需求选择合适的工具。
006:弹性分布式数据集与分区
概述
在本节课中,我们将要学习Spark中最核心的数据结构——弹性分布式数据集(RDD)。我们将了解RDD是什么、它是如何工作的、以及Spark如何通过延迟执行和分区等机制实现高效的大数据处理。
什么是RDD?
Spark编程的核心是弹性分布式数据集,简称RDD。事实上,描述Spark的第一篇论文标题就是关于RDD的。它是Spark的关键和核心部分。
Spark的理念是避免使用文件系统,因此需要一个数据结构来存储数据,这个数据结构就是RDD。从用户的角度看,RDD就是一个列表。当然,在底层实现上,它与Python列表有很大不同。
更准确地说,在Spark内部,每个RDD都被划分为多个分区。例如,一个巨大的列表RDD可能被划分为三个分区:P1、P2、P3。另一个RDD也可能被划分为三个分区。
RDD的创建与确定性转换
RDD的创建方式非常重要。RDD通过确定性的转换来创建。一旦一个RDD被定义,Spark就能精确地知道它是如何从源数据或其他RDD派生出来的。
以下是创建RDD的两种主要方法:
- 从持久化存储(如HDFS或本地文件系统)中的数据转换而来。
- 通过对其他RDD执行转换操作而来。
例如,可以从RDD1创建RDD2。这种转换是一对一的,即RDD1中的每个记录都会在RDD2中创建一个对应的记录。因此,这种转换可以按分区独立进行。这种转换必须是确定性的,即定义明确,不依赖于随机数或外部因素(如机器运行速度)。一旦源数据固定,转换结果就是固定的。
RDD的物化与执行模型
下一个要点同样重要:RDD不需要被“物化”。物化是一个术语,简单来说就是物理存在。
程序员可以为每个RDD指定分区数量,虽然如果不指定也会有默认值。通常,分区数量与性能之间存在权衡。分区越多,并行度越高,因为所有分区可以被多个工作节点并行处理。例如,一个转换操作可以在三个工作节点上并行处理三个分区。但如果只有三个分区而有五个工作节点,那么两个工作节点就会空闲。
但分区也不宜过多,因为分区需要由主节点管理。分区过多,管理开销也会增大。因此,这是一个权衡。经验法则是,分区数量应该是工作节点数量的2到4倍。原因在于工作节点的速度可能不同。Spark设计用于异构计算系统,即集群中的计算节点硬件可能不同。如果分区数与工作节点数相同,速度快的节点完成任务后会空闲,等待其他节点。如果有更多分区,主节点就能进行负载均衡,将已完成任务的节点分配新的分区。
示例:日志分析
现在让我们看一个例子,这个例子来自加州大学伯克利分校关于Spark的第一篇论文。这个例子也能帮助我们理解“物化”的含义。
在这个例子中,我们对初始数据集执行一系列转换。假设初始数据集是一个存储在HDFS中的日志文件。
以下是转换步骤:
- 第一步,将原始数据加载到Spark中作为一个RDD。
lines是我们创建的第一个RDD。从用户角度看,它是一个字符串列表,每个字符串对应日志文件中的一行。 - 第二步是过滤操作。我们只保留以“error”开头的行。这是一个一对一的转换(或一对零,因为不符合条件的行被过滤掉)。这给了我们第二个RDD,
errors。 - 第三步是另一个过滤操作。我们只保留包含“HDFS”子串的错误信息。这产生了第三个RDD(尽管代码中可能没有显式引用它,我们称之为
hdfs_errors)。 - 最后,我们可能通过制表符分割字符串并收集结果。
collect操作将结果以列表形式返回给用户。
这是一个非常简单的Spark作业,包含四个转换操作,涉及四个RDD。第一个转换是从HDFS读取文件。
理解“物化”
现在来解释“物化”的含义。在这个例子中,所有四个转换都是一对一的。这意味着不同分区的记录在整个计算任务中没有任何交互,不需要任何同步屏障。这实际上是个好消息,因为工作节点运行速度可能不同,如果强制同步,速度快的节点就需要等待,从而降低效率。
Spark的设计特别适合处理这种情况。实际运行时,文件的每个分区(块)被分配给一个工作节点。该节点会先将数据读入内存,然后立即开始执行后续的过滤和映射步骤。该节点无需等待其他节点,因为所有操作都是独立的。这最大限度地提高了效率,完全没有等待。
其结果是,虽然像errors这样的中间RDD在逻辑上存在于这个线性图中,但它可能从未在整个系统中的任何时间点完整地存在过。因为工作节点速度不同,当工作节点1计算完errors分区1时,分区2可能已经进入下一阶段,甚至中间结果已经被丢弃。因此,在任何时间点,我们都没有一个完整的errors RDD物理存在于系统中。当然,这个RDD的每个片段都会在某个时间点存在于某个工作节点上,但它们从未作为一个整体物理存在。这就是“物化”的含义。默认情况下,所有RDD都不被物化,这是为了最大效率,因为没有必要物化。
对于errors RDD的任何部分,一旦被计算出来,就会立即被送入下一阶段,然后这个中间片段就会被丢弃。这不仅提高了内存使用效率,也最小化了存储需求,因为Spark甚至不存储它。
执行总结与延迟执行
总结一下,执行是流水线化和并行的,不需要存储中间结果。这本质上就是我们所说的默认不物化RDD。
最后,这一点也非常重要:延迟执行。Spark允许用户与系统交互。用户可能逐行输入代码。当第一行代码提交给Spark时,Spark实际上不会做任何事情,不会去读取文件。
为什么Spark要等待?我们刚刚说过,执行是流水线化的,没有中间RDD被物化。如果Spark在输入第一行时就执行,那么它将不得不物化lines RDD并存储它,这就完全违背了不物化中间结果的目的。相反,Spark采取了一种“聪明”或“懒惰”的策略。它只在最终需要结果时才执行操作。
在这个例子中,最终“考试”是collect操作。因为read file、filter、map这些转换产生的是RDD,而RDD是内部数据结构,用户不能直接访问。只有当像collect这样的“行动”操作需要将结果返回给用户时,Spark才真正开始工作。
因此,输入前两行代码后,Spark实际上什么也不做,它只是构建这个线性执行图。只有在Spark看到collect操作后,它才开始流水线执行。这就是延迟执行的含义。
延迟执行示例
再举一个例子。假设执行图是一个有向无环图。例如,用户可能改变主意,不想收集包含“HDFS”的错误,而是想收集包含“GFS”的错误。那么代码会进行另一个过滤,产生另一个RDD(gfs_errors),然后对这个RDD执行collect。
现在这个线性图变成了一个有分支的树。如果只对gfs_errors执行collect,那么只有关键路径上的部分(即从源数据到gfs_errors的路径)会被计算和执行。而hdfs_errors和time_fields这两个RDD根本不会被计算,它们甚至不会被物化。这又是一个延迟执行的例子:Spark只做满足用户请求所必需的工作。
容错机制
现在我们来讨论容错机制。回忆一下,在MapReduce中,容错本质上由HDFS支持,每个中间结果都存储三份。这带来了很大的开销。在Spark中,执行图不仅有助于高效执行,也使容错更加高效。
我们要求每个转换都是确定性的。这意味着只要Spark知道执行图和初始数据,它就拥有推导出每个RDD、每个分区所需的所有信息。因此,它不必将任何中间结果存储到磁盘。
那么,如果一个工作节点失败了呢?如果处理某个RDD(例如errors)分区的节点失败,该分区数据丢失,Spark会通过仅对lines RDD的相应分区重新应用过滤操作来重建它。如果lines的分区也丢失了,就回退到文件系统。这里唯一的假设是输入文件被可靠地存储(可能有副本)。但中间结果只存储一次,数据可能丢失。然而,Spark拥有重新计算每个丢失部分所需的所有信息,所以实际上没有数据丢失。
当然,故障恢复的成本更高。如果执行图很长,Spark可能需要回到最开始重新计算所有丢失的部分。而在MapReduce中,这更便宜,因为它只需回到上一阶段。但MapReduce为此付出了很高的代价,即将每个中间块复制三份。MapReduce在应对工作节点故障方面过于激进。而Spark则秉承了“懒惰”的思想,不为容错主动做任何事情,但拥有所有必要的信息,且这些信息非常廉价(只是一个小的执行图)。只要有这个“配方”,我们总是可以从原始资源重新“制作食物”。
这种机制运行良好,还因为虽然故障时常发生,但在一个拥有数千台机器的集群中,绝对故障数量仍然很小。因此,这种容错机制可以高效得多。你肯定不希望仅仅因为一个节点故障,就让所有一千个节点都做三倍的工作。
转换与行动
以下是一些最常用的转换和行动操作列表。我们将在示例中看到它们。转换和行动的区别在于:转换将RDD转换为另一个RDD(输入和输出都是RDD),而行动则是从RDD转换为用户可访问的某种数据类型(如Python列表、整数,或外部存储系统如HDFS)。只有行动才会触发Spark开始工作、启动作业。转换只是构建执行图。
执行模型示例
记住,Spark不将任何中间结果存储到磁盘,它也可以通过只从磁盘读取一次数据来对相同数据执行多个任务。
例如,从文件系统读取文件到lines RDD。comments RDD是通过对lines应用过滤得到的。假设我只想要注释行。然后,我对lines执行count行动(计算行数),再对comments执行另一个行动。
执行图是这样的:从文件到lines,再从lines到comments。第一个执行(lines.count())会触发读取文件到内存,然后计数并返回给用户。当Spark看到第二个命令(comments.count())时,它会再次读取数据,因为默认没有物化。它会重新计算lines,然后执行转换。注意,这些都是流水线化的,lines从未被物化,然后返回计数。
有人可能会问:这和我之前说的“Spark可以读取一次数据然后执行多个操作”不是矛盾吗?这里文件被读取了两次,效率低下,这和MapReduce甚至一样了。是的,这是默认行为。因为默认情况下,Spark不知道你是否要对相同数据执行多个作业。为了节省计算和内存,它不物化中间结果。
然而,这只是默认行为。作为程序员,如果你知道将要执行多个作业,Spark提供了一种方法来实现高效处理:你可以显式地缓存或持久化任何特定的RDD。通过告诉Spark“lines这个RDD应该被缓存或物化”。我们经常互换使用缓存、持久化、物化这三个词,它们本质上是同一个意思。
持久化
如果Spark知道lines被标记为持久化,那么当第一个行动count执行时,文件被读入lines,并且因为lines被标记为持久化,它实际上会被存储在内存中,被物化。即使在第一个作业完成后,它仍然存在。
这样做的好处对用户是显而易见的。当用户执行另一个转换加行动时,第二次触发行动时,Spark就不必再次读取数据,而是直接对内存中的lines应用转换。只有从lines到comments再到count这两个步骤会被执行。注意,comments没有被缓存,所以在第二个行动完成后,comments永远不会被存储,只有lines仍然存在于内存中。
默认情况下,Spark不在内存中存储任何东西,不物化任何东西。然而,作为程序员,如果你期望某些数据会被重用,你可以显式地告诉Spark:第一次计算后请不要丢弃它。这就引出了持久化的概念。
当然,有方法可以取消持久化(unpersist)。缓存和持久化是同一件事,一个是另一个的别名。但取消持久化没有别名uncache,只能使用unpersist。
Spark可以物化任意数量的RDD。当然,这会使用更多内存。当内存耗尽时,它会将数据刷新到磁盘,这可能会减慢速度。因此,默认情况下,Spark不缓存任何东西。作为程序员,你应该决定缓存哪些RDD。如果内存大小是个问题,你可能需要选择最重要的RDD来缓存。
你可以使用persist或cache使RDD持久化,并且实际上可以指定不同的持久化级别。默认是仅内存,如果内存不足,它会被丢弃。还有一个选项是内存和磁盘,如果内存不足,RDD不会被丢弃,而是被刷新到磁盘,当然这会减慢速度。
有些RDD,即使你不缓存它们,Spark本身也会因为其重要性而缓存它们。

取消持久化的方法是unpersist。
如何决定持久化哪个RDD?作为程序员,你必须自己决定。如果你缓存太多,Spark会自动使用LRU(最近最少使用)策略丢弃数据。基本上,它会记住RDD最后一次被使用的时间,然后换出最早被使用的、最近最少使用的那个。


交互式Shell示例
这是一个Python Shell。你可以像在普通Python Shell中一样做任何事情。Spark引入的新对象主要是sc,即SparkContext。稍后我们会讨论Spark。如果只需要使用RDD,就使用sc,否则使用spark。目前,我们只使用sc。sc是进入Spark世界的入口点。
现在让我们做第一个例子。第一步,读取一个文本文件。让我们从本地文件系统读取一个文件。Spark的一个优点是,在笔记本电脑上编写的相同代码可以无需修改地迁移到云端。当然,这有点夸张,有一件事需要修改,那就是源文件的位置。在笔记本电脑上是本地文件,在云端则是文件系统中的某个位置。目前,让我们读取一个本地文件,比如README.md。
注意,如果我打错了文件名,比如readme,而正确的文件名是README.md,Spark似乎没有报告错误,因为这个文件甚至不存在。为什么?没错,就是因为延迟执行。因为每个转换只是构建执行图,它只告诉系统你的执行计划。系统接收你的命令,但直到你执行一个行动操作时,它才会真正执行。


假设我们执行一个行动,比如collect。collect将RDD转换为Python列表。正如预期的那样,我们收到了一个错误,说输入路径不存在。所以现在Spark意识到你的执行计划实际上是错误的。让我们修正它。




这次它工作了。它是一个字符串列表,每个字符串对应文本文件中的一行。textFile转换将文本文件转换为RDD。Spark提供了许多其他读取不同格式文件的方法,比如CSV文件或基于Parquet的文件,我们稍后会看到。目前是textFile。

让我们做一些其他的行动。count是另一个行动。结果是101。take返回RDD中指定数量的前几条记录。



让我们做另一个例子。lines包含“spark”的行。这是一个过滤操作。filter接受任何函数。因为我们使用Python,所以可以使用lambda函数这种内联方式定义函数。它有一个输入参数,返回一个布尔值。如果为真,则通过过滤;如果为假,则不通过。我们可以对lines进行过滤,然后执行collect。这将返回所有包含“spark”这个词的行。正如预期,它只包含有“spark”的行。
当然,你可以将它们链式调用在一起。它有19行包含“spark”这个词。
Map与Reduce操作
接下来要讨论的行动是map和reduce。Spark中的map与MapReduce中的map相同,可以执行任何一对一转换。
这个例子中,map也接受一个函数作为输入参数。这里我们使用lambda函数。输入是RDD中的每个元素,输出是你想如何转换它。这里,line是RDD中的每一行。split是Python的分割函数,它将字符串按空格分割成多个部分,形成一个字符串列表。然后我使用len函数。所以,这个map本质上将lines RDD中的每一行转换为其包含的单词数量。
在执行之前,我们先做collect。它把所有行转换成单词数量。第二个是reduce。然而,这个reduce与MapReduce中的reduce不同。MapReduce中的reduce应用于键值对。这里我们甚至没有键值对,我们只有数字RDD,整数RDD。Spark中的这个reduce是一个行动,不是转换。有一个转换叫做reduceByKey,它对应于MapReduce中的reduce。
这里的reduce是一个行动。它通过使用用户定义的函数,将整个RDD组合成一个元素。这个用户定义的函数必须有两个输入,产生一个输出。本质上,这个函数定义了两个元素如何组合成一个元素的规则。
这里,函数是max。它有a和b,返回较大的那个。你可能猜到了结果是什么。最终结果是整个RDD中的最大元素。它会反复地两两合并元素,使用这个用户定义的函数。最终,所有这些整数将被合并成一个,那就是整个RDD中的最大元素。

记住,任何不是RDD的东西都是一个行动,它将触发Spark执行操作。
我们讨论了reduce。用户应该定义一个函数,该函数本质上是一个二对一的函数,将两个元素组合成一个。在这个例子中,我们使用了max。当然,你也可以使用像加法这样的操作。那么这将返回总和。重要的是,你通过这个函数定义的操作符必须是可交换和可结合的。也就是说,操作符执行的顺序不应影响最终结果。max和加法都是可交换和可结合的。这是因为作为程序员,你无法控制事物如何组合。Spark只保证最终一切都会被合并,但具体以何种方式合并,你无法控制。如果你定义的东西不是可结合或可交换的,那么最终结果可能无法明确定义,每次运行可能都不同。

让我举一个例子。这个例子是求平均值。你返回(a + b) / 2。这个操作符,如果你仔细想想,它不是可交换的,也不是可结合的。作为一个简单的例子,假设我有三个数字:1, 2, 3。如果我以前两种方式组合,然后组合结果,我得到某个值。然而,如果它们以稍微不同的方式合并,比如先组合第一个和第二个,再与第三个组合,我会得到不同的值。所以这是一个顺序很重要的例子,操作符不是可结合的。
现在让我们看看实际操作。如果我坚持要这样做,这是一个结果。当然,如果我再次运行,结果相同,因为一旦分区固定,顺序也就固定了。我们可以通过指定不同的分区数量来手动改变顺序。之前我没有指定。默认情况下,读取文本文件时,我认为默认分区数是2,除非文件非常大。但我可以手动指定不同的分区数。例如,我告诉Spark这个lines RDD应该有3个分区。现在如果我再次执行,你可以看到结果不同了,尽管是同一组转换操作。我唯一改变的是分区数量。这是不好的,因为你的程序不应该依赖于分区数量。分区数量应该是一个只影响性能而不影响程序正确性的参数。这就是为什么我需要强调,对于reduce函数,操作符必须是可结合和可交换的。

另一个例子与缓存
这是另一个例子。这是一个将Python列表转换为RDD的机会。range会创建一个Python列表,然后将其转换为RDD。类似地,我想计算RDD中每个数字的平方根。如你所见,它立即返回,因为Spark使用延迟执行。只有当我们有一个行动时,事情才会开始运行。我的服务器相当快,你可能看不到延迟。





默认情况下,每次你对一个RDD运行行动时,它都会被重新计算。这意味着如果我再次对b执行count,那么Spark将再次为所有100,000个数字计算平方根,这可能是冗余的。所以,如果你知道一个RDD将被重用,你最好持久化或缓存它。



交互式Shell与独立提交模式
这是一个使用交互式Shell的例子。这对于交互式数据分析非常好。然而,当你完成程序并想在更大的数据集上运行时,最好使用独立版本。特别是,如果你在课堂上运行Spark集群,可能由许多用户共享,那么运行交互式Shell不是一个好做法,因为它会长时间占用集群资源。在课堂模式下,建议将作业作为批处理作业提交到集群。在这种情况下,我们可以使用spark-submit。

理解代码执行位置
现在,理解每行代码在哪里运行非常重要。否则,你将无法利用Spark或集群的计算能力。



假设我们有一个简单的任务:合并两个RDD。我们想将这两个RDD连接起来形成一个新的RDD。
记住,我们可以使用collect将RDD转换为Python列表。Python列表之间有连接操作符+。一个简单的实现可能是:首先将每个RDD收集到Python列表中,然后将它们连接起来形成一个新的Python列表,再将其转换回RDD。
当然,这是一种正确的方法。然而,它效率很低。事实上,它完全没有利用Spark的并行能力。因为任何行动都会将结果返回给用户。最终,collect会将RDD转换为Python列表,该列表必须存储在用户程序中。
实际上,我需要简要描述一下集群模式。在集群模式中,我们谈论三种不同类型的机器:工作节点(执行计算的计算节点)、集群管理器(主节点,负责将任务分配给工作节点,并在发生故障时负责容错),以及驱动程序或驱动机器。假设你在集群上运行Spark作业,这个驱动程序仍然在你的笔记本电脑上运行。因此,当你提交Spark作业时,你将其提交给集群管理器,集群管理器会将你的作业分配给工作节点,它们将共同存储RDD,每个工作节点可能有一个或多个分区。然而,在Python Shell中,你仍然在运行Python,除了那些RDD,其他所有东西仍然存储在你的本地笔记本电脑上。这也意味着每当你执行一个行动时,结果都会返回到你的驱动程序,这可能是一台性能不强的机器。
回到这个例子,第一个RDD的collect操作将收集RDD的所有分区,将它们组合成一个Python列表,返回到你的驱动程序。所以a是一个Python列表。除了RDD,所有东西都存储在本地驱动程序中。所以a和b都是驱动程序中的Python列表。然后你在本地进行连接,可能占用大量本地内存。然后你将其发送回集群形成一个新的RDD。这是一种非常低效的做法。
连接两个RDD的正确方法是使用提供的方法union。这将连接两个RDD。注意,这个union并不是集合论中的并集,我认为这是一个糟糕的命名,应该叫连接。它按原始顺序组合两个RDD,一个接一个。这将完全在工作节点上运行,不会给驱动程序带来任何负担。


回到Jupyter模式
我们已经演示了如何使用Shell,虽然方便,但仍然不够好,因为如果我犯了错误,我需要返回去修复,而且一旦关闭,一切都会丢失。因此,更好的方法是使用Jupyter。我准备了一系列Jupyter笔记本,你可以下载。URL是这个,当我们有其他Jupyter笔记本时,你只需更改此URL中的文件名。第一个示例笔记本是rdd.ipynb。
你可以使用Shift+Enter或Control+Enter执行任何单元格。所有示例数据文件,例如我使用的data/fruits,都可以在此URL下载。你只需更改文件名。这只是一个文本文件。类似地,yellow_tripdata.txt是另一个文本文件。


在第一个简单示例中,我只是从本地文件读取。本地文件实际上在这里。所有数据文件都在我的服务器上,fruits在这里。现在,稍后当我们在安装了HDFS的云上运行此程序时,你只需进行这一项更改:更改数据文件的URL。Spark已经支持HDFS。这就是为什么下载Spark时,你应该选择与Hadoop匹配的适当版本,以便以后可以从Hadoop安装中读取文件。你应该选择与Hadoop匹配的Spark版本。我们稍后在拥有云资源时会做这件事。
更多示例
这是一个map的例子。当然,当我运行它时,它不会打印任何东西,因为它只是一个转换。当我们执行collect时,它才开始运行。第一次运行时,需要等待一下。在这个转换中,我只是反转字符串,即水果名称。


现在是一个查看缓存或持久化如何影响事物的好例子。在这种情况下,fruits_reversed没有被缓存。所以这些行都出来了。因此,如果我修改源代码,比如我在这里加上“double apple”,然后如果我再次写入,你预计会发生什么?正如预期,整个东西被重新执行,因为没有缓存任何东西,并且这个更新后的文件被重新读取。





现在让我改回去。现在我将这一行缓存。我运行它。它仍然是“apple”。现在因为它被缓存了,如果我再次修改,比如改成“B apple”,我保存文件。我更改了这个文件。现在,如果我再次运行这个,你可以看到它仍然是“apple”反转。它没有读取我更新的文件。我更新的文件已经包含“B apple”。这意味着缓存确实在起作用。一旦你缓存了一个RDD并且它被计算过,那么它就不会被重新计算。



让我们做一些其他实验。如果我再次注释掉它,注释掉,再次注释掉。让我们运行。当然,是“apple”。它仍然是“apple”,对吧?发生了什么?我已经取消了持久化,所以没有缓存任何东西。为什么这样?因为cache实际上是一个标记。这个命令、这个方法本身不做任何事情,没有立即效果。它只是给RDD打上一个标记,以便下次Spark看到它时会缓存它或不缓存。因为之前我已经缓存了这个RDD,所以它已经被缓存了。除非你要求它这样做,否则它不会被踢出内存。因此,我必须手动取消持久化。现在我再次写入,现在是“B apple”。现在它没有被缓存。



好的,这就是缓存行为。让我们看一些其他例子。这是一个过滤的例子。这是一个lambda函数,如果字符串长度小于5则返回true。所以它将返回所有短的水果名称。
下一个是flatMap。这是我们还没有见过的。map总是一对一。如果你想做其他转换,可以使用flatMap。flatMap是一对任意,可以是一对多、一对零。当你想要扩展或收缩列表时,它很有用。这里的lambda函数以RDD中的一个元素作为输入,并产生一个列表作为输出。这个列表可以是空的,也可以包含任意数量的元素。效果是一对多的转换。
我在这里做的是,记住fruit是一个字符串,我将其转换为列表。实际上,我将一个字符串转换为一个小字符串列表,每个小字符串只是一个字符。然后这些列表将被连接起来形成新的RDD。在这个flatMap之后,我有一个更大的RDD,尽管每个元素只是一个字符。
下一个例子,union。如前所述,这是组合两个RDD的正确方法。然而,这不是集合的并集,因为它所做的只是连接两个RDD,不删除重复项。这里的“banana”出现了一次,又出现了第二次。另一方面,Spark中的intersection是真正的集合交集,它会删除重复项。所以“banana”只出现一次,尽管它在两个部分中都出现了。这就是为什么我说union应该改名为连接,这不是一个好名字。


如果你想要集合的并集,你必须先做union,然后做distinct。在union之后,顺序被保留。然而,distinct之后,顺序完全被打乱了。原来的顺序中“banana”在这里和那里,但在新列表中,“banana”在这里,顺序完全乱了。在union中,两个RDD的顺序都被保留了。然而,distinct打乱了顺序。为什么?从根本上说,因为它必须使用洗牌来删除重复项。
行动示例
以下是一些行动的例子。collect我们已经见过很多次了。count是另一个行动的例子。take类似于collect,但你可以指定数量,它总是返回RDD中的前K项,K是你指定的参数。
reduce,这也是我们讨论过的。这个程序做的是返回所有水果名称中使用的唯一字符。我们通过一个map加reduce来实现。这类似于我们之前寻找字符串RDD中最大单词数的例子。这里我将fruit(一个字符串)转换为一个集合。注意这是一个map,不是flatMap。我们不是将RDD扩展为字符RDD,而是将每个字符串转换为一个字符集合。然后我在这里执行reduce。这个reduce使用这个函数,即并集函数。注意,这不是我们之前使用的union,这是Python自己的集合并集操作。Python对union一词的使用在数学上是正确的,它确实是集合的并集,合并两个集合并删除重复项。最终,我们得到了字母集合,即不同的字母集合。
做同样事情的另一种方法是使用flatMap。flatMap是一对多的转换。我们将每个水果(每个字符串)转换为一个字符列表,所有这些字符将被连接成一个大RDD。在这个flatMap之后,我们有一个字符RDD,但有重复项,所以我们需要做distinct。然后我们做collect。这两种都是找到这个RDD中所有唯一字符的正确方法。
问题是哪种方式更好、更高效。稍后我们会讨论这个问题,因为这涉及到洗牌。一般来说,第二种方式更慢,第一种方式更快。因为reduce使用分治算法,比洗牌操作更高效。洗牌本质上需要大量通信,而reduce需要很少的通信。所以第一种方式是执行此任务的更好方法。

总结
在本节课中,我们一起学习了Spark的核心数据结构RDD,了解了它的分区机制、确定性转换、延迟执行、容错原理以及持久化策略。我们还通过多个示例代码,直观地感受了RDD的创建、转换和行动操作,并理解了正确使用union、reduce以及缓存机制对于编写高效Spark程序的重要性。掌握这些概念是进行大规模分布式数据处理的基础。
007:闭包
在本节课中,我们将要学习Spark编程中一个非常重要的概念——闭包。我们将通过具体的例子来理解闭包是什么,为什么它会导致程序行为与预期不符,以及如何正确地处理它。

概述

闭包是Spark(以及许多并行编程平台)中的一个核心概念。它指的是在执行分布式计算任务时,需要打包并发送到各个工作节点(Executor)的一组变量和函数。理解闭包对于编写正确、高效的Spark程序至关重要。
什么是闭包?
在深入探讨之前,我们先来看一个例子。这个例子试图使用一个全局变量来累加RDD中所有元素的值。
counter = 0
rdd = sc.parallelize(range(10))
def increment_counter(x):
global counter
counter += x
rdd.foreach(increment_counter)
print(counter) # 输出结果仍然是 0,而不是预期的 45
上面的代码定义了一个全局变量 counter 和一个RDD。foreach 是一个行动操作,它会将用户定义的函数 increment_counter 应用到RDD的每一个元素上。我们的意图是将RDD中每个数字(0到9)累加到全局计数器 counter 中。
然而,运行后 counter 的值仍然是0。这是为什么呢?这就引出了闭包的概念。
一个任务的闭包包含了执行器(Executor)为了在其分配的RDD分区上执行计算所必须可见的所有变量和方法。更精确地说,闭包包含两类信息:
- 函数:需要在工作节点端运行的代码。在这个例子中,就是
increment_counter函数的定义。 - 数据:特别是该函数所引用的所有全局变量。在这个例子中,就是全局变量
counter。
当行动操作(如 foreach)被触发时,闭包(包含函数代码和变量值)会被序列化并从驱动程序(Driver)发送到每个执行器。
关键点在于:发送到工作节点的变量是驱动程序端变量的副本。对于函数定义这没有问题,因为它们是只读的。但对于全局变量,每个工作节点获得的是其初始值(0)的副本。当它们执行 increment_counter(x) 时,是将自己分区内的数据 x 累加到自己的本地副本中。这些本地副本的更新不会传回驱动程序。因此,驱动程序打印的仍然是它自己那份原始的、值为0的 counter 变量。
如何解决?使用累加器
Spark官方文档建议使用累加器来解决这类跨节点的共享变量更新问题。
累加器是一种只能通过关联且可交换操作进行“累加”的变量。它由驱动程序通过 SparkContext.accumulator(initialValue) 创建。这明确告知Spark,这是一个特殊的累加器变量,而不是普通的全局变量。
from pyspark import SparkContext
sc = SparkContext()
counter = sc.accumulator(0)
rdd = sc.parallelize(range(10))

def increment_counter(x):
global counter
counter += x # 这里使用的是Spark重载的 `+=` 操作符

rdd.foreach(increment_counter)
print(counter.value) # 输出正确的累加结果 45
累加器的工作机制:
- 驱动程序创建累加器并设置初始值。
- 在用户自定义函数内部,使用
+=操作符(这是Spark为累加器重载的特殊操作)向累加器添加值。 - 只有驱动程序可以通过
.value方法获取累加器的最终值。
为什么必须用 +=,而不能用 = 赋值?
如果允许直接赋值(如 counter = counter + x),那么不同工作节点可能会给累加器写入不同的值,最终结果将无法确定。Spark通过限制只能进行“累加”操作,确保了结果的确定性。所有工作节点的本地累加会在后台安全地合并。
为什么工作节点不能读取累加器的值?
因为在任务执行过程中,其他节点可能还在向累加器添加值,此时读取的值是未完成的、未定义的。只有所有相关任务完成后,驱动程序读取的值才是完整且确定的。
为什么不推荐使用累加器?
尽管累加器解决了问题,但个人并不推荐频繁使用它,因为它与Spark的惰性求值模型交互时,容易导致非预期的行为。
考虑以下场景:
accum = sc.accumulator(0)
rdd = sc.parallelize(range(10))
def map_func(x):
global accum
accum += x
return x * x
mapped_rdd = rdd.map(map_func) # 这是一个转换操作,不会立即执行
print(accum.value) # 输出 0,因为 map 是惰性的,函数并未执行
# 触发一个行动操作
result = mapped_rdd.reduce(lambda a, b: a + b)
print(accum.value) # 输出 45,正确
print(result) # 输出 285 (0^2+1^2+...+9^2的和)
# 现在缓存这个RDD
mapped_rdd.cache()
# 再次触发一个行动操作(由于已缓存,map转换不会重新计算)
result2 = mapped_rdd.count()
print(accum.value) # 输出仍然是 45,没有变化
问题在于:
- 如果RDD没有持久化(缓存),那么每次在它之上触发新的行动操作时,导致它产生的转换操作(如
map)都可能被重新计算。每次重新计算,累加器都会再次被累加,导致其值翻倍或产生其他非预期增长。 - 如果RDD被持久化了,那么后续行动操作会直接读取缓存结果,而不会重新执行
map中的累加代码,此时累加器的值表现正常。 - 更复杂的是,即使你缓存了RDD,如果内存不足,Spark可能会将部分缓存数据丢弃或换出到磁盘。当这些数据再次被需要时,它们会被重新计算,这又会再次影响累加器的值。
因此,累加器的值可能变得不确定,它依赖于RDD是否被缓存、缓存是否被驱逐、任务是否因失败而重新执行等运行时因素。这对于需要确保确定性的程序来说是非常糟糕的。
建议:在几乎所有计算场景中,都可以使用Spark提供的其他确定性的操作来替代累加器,例如 reduce()、sum()、count() 等。只有在需要收集运行时统计信息(如计算某些事件被触发的次数,其本身就依赖于重执行行为)时,才考虑使用累加器。
闭包与随机数生成:一个实际案例
闭包问题不仅限于显式的全局变量,也可能隐藏在第三方库中。一个经典的例子是Spark官方示例中的蒙特卡洛方法计算π。
蒙特卡洛方法原理:在一个边长为1的正方形中,画一个1/4圆。随机向正方形内投点,点落在1/4圆内的概率等于该扇形面积(π/4)。因此,π ≈ 4 * (落在圆内的点数) / (总投点数)。
一个看似正确的实现:
import random
def inside(p):
x, y = random.random(), random.random()
return x*x + y*y < 1
count = sc.parallelize(range(1, n+1), numSlices) \
.filter(inside) \
.count()
pi = 4.0 * count / n
问题:这个实现的精度不会随着分区数(并行度)的增加而提高。因为 random.random() 使用的随机数种子是一个隐藏的全局状态。当闭包被序列化到各个工作节点时,每个节点都获得了相同的随机数种子,从而导致所有分区生成完全相同的随机数序列。这完全违背了并行计算增加随机性的初衷。
解决方案:使用 mapPartitionsWithIndex 为每个分区设置不同的随机数种子。

import random
import time
def inside_partition(idx, iterator):
# 使用分区索引来生成不同的种子
random.seed(int(time.time() * 1000) + idx)
for _ in iterator:
x, y = random.random(), random.random()
yield x*x + y*y < 1
n = 1000000
numSlices = 10
# 创建一个占位符RDD,每个分区有 n/numSlices 个元素
data = sc.parallelize(range(1, n+1), numSlices)
count = data.mapPartitionsWithIndex(inside_partition) \
.sum() # True会被当作1,False当作0
pi = 4.0 * count / n
mapPartitionsWithIndex(func) 允许我们在每个分区上执行函数 func,并且该函数接收两个参数:分区索引 idx 和包含该分区所有数据的迭代器 iterator。通过将分区索引加入到种子中,我们确保了不同分区使用不同的随机数流。

mapPartitions 系列函数:当需要对整个分区进行操作,而非单个元素时,mapPartitions 和 mapPartitionsWithIndex 非常有用。它们提供了更粗粒度的控制,常用于初始化分区级别的资源(如数据库连接)或执行分区级别的算法。
闭包与惰性求值:选择算法示例
最后,我们通过一个线性时间选择算法(寻找第K小元素)的例子,综合展示闭包和惰性求值可能带来的陷阱。
算法思路(分治法):
- 选取一个基准元素
x(例如第一个元素)。 - 将数组分为两部分:小于
x的left和大于x的right。x的位置记为mid(即它是第mid小的元素)。 - 如果
k == mid,则x即为所求。 - 如果
k < mid,则在left中递归寻找第k小元素。 - 如果
k > mid,则在right中递归寻找第k - mid - 1小元素。
一个直接的Spark实现可能如下:
def select(a, k):
while True:
x = a.first() # 行动操作,触发计算并获取基准值
a1 = a.filter(lambda z: z < x) # 转换操作
a2 = a.filter(lambda z: z > x) # 转换操作
mid = a1.count() # 行动操作,触发计算并获取左部分大小
if mid == k:
print(x)
break
elif k < mid:
a = a1
else:
a = a2
k -= (mid + 1)
这个实现有Bug! 问题在于惰性求值和闭包的相互作用。
在第二次循环中,a 被重新赋值为 a2。当我们计算新的 a1.count() 时,Spark需要重新计算从原始RDD到当前 a1 的整个血缘图。在计算 a.filter(lambda z: z < x) 时,闭包中捕获的 x 是当前循环中 x = a.first() 的值,而不是第一次循环时的值。这导致过滤条件错乱,最终得到错误结果。
修复方法:在定义新的RDD(a1, a2)后,立即通过行动操作(如 .count())将其物化(缓存结果),避免后续因血缘重算而使用错误的闭包变量值。
def select_fixed(a, k):
while True:
x = a.first()
a1 = a.filter(lambda z: z < x).cache() # 计划缓存
a2 = a.filter(lambda z: z > x).cache() # 计划缓存
mid = a1.count() # 行动操作,触发计算并物化a1
# 根据k的值,可能也需要触发a2的计算
# a2.count() # 如果后续可能走到else分支,这里需要触发
if mid == k:
print(x)
break
elif k < mid:
a = a1
else:
# 确保在x改变前,a2被物化
# 如果前面没有a2.count(),这里需要先触发一个行动操作,如 a2.first()
_ = a2.first()
a = a2
k -= (mid + 1)
这个例子深刻说明了,在包含循环和条件分支的Spark程序中,理解惰性求值和闭包捕获的时机对于写出正确代码至关重要。
总结
本节课我们一起学习了Spark中的核心概念——闭包。
- 闭包的本质:它是发送到工作节点的一组序列化数据,包含函数代码和其引用的变量值。工作节点操作的是这些变量的副本。
- 累加器的作用与局限:作为跨节点安全共享变量的工具,累加器必须通过特定的
+=操作更新,且只有驱动程序能读取最终值。但由于其与惰性求值、缓存、任务重试机制的交互,容易导致非确定性行为,因此应谨慎使用,优先考虑reduce、sum等确定性操作。 - 闭包的隐蔽性:问题可能隐藏在使用的库中,如随机数生成器的全局种子。使用
mapPartitionsWithIndex可以为每个分区提供独立的上下文。 - 惰性求值的陷阱:在循环或条件逻辑中,RDD的血缘重算可能导致闭包捕获到意外的变量值。通过适时地缓存或物化中间RDD,可以避免此类问题。

掌握闭包和惰性求值,是编写正确、高效Spark程序的关键一步。在后续课程中,我们将学习更高级的Spark SQL API,它提供了更友好、更少犯错的数据处理方式。
008:键值对RDD操作
在本节课中,我们将学习Spark中一个核心概念:键值对RDD。我们将了解如何创建键值对RDD,并探索一系列专门用于处理这类数据的“byKey”转换操作。通过具体的例子,如词频统计、点互信息计算、K-Means聚类和PageRank算法,我们将掌握如何利用键值对RDD来解决复杂的大数据问题。
键值对RDD简介
在之前的所有示例中,RDD中的元素都是单一类型的。然而,Spark也支持MapReduce范式,而MapReduce需要处理键值对。因此,Spark必须支持由键值对组成的RDD。在Python中,这很自然地得到了支持,因为Python有元组这种数据类型。
所以,如果你的RDD由包含两个元素的元组组成,那么Spark会自然地将其解释为键值对RDD。随后,就有一系列专门为键值对设计的转换方法可以使用。
词频统计示例
词频统计是MapReduce的经典示例。在Spark中,实现起来非常简单。
首先,从一个文本文件创建一个字符串RDD。然后,使用map操作将每一行文本转换为一个键值对元组,其中键是单词本身,值是初始计数1。这个元组RDD会被Spark解释为键值对RDD。
接下来,应用reduceByKey操作。Spark中的reduceByKey对应MapReduce中的Reduce阶段。它接受一个用户定义的函数作为输入,这个函数必须是结合律和交换律的。Spark会为每个键,使用这个函数将所有值归约成一个。最终结果是一个键值对RDD,其中键是唯一的单词,值是该单词出现的总次数。
以下是核心代码逻辑:
# 创建初始RDD
lines = sc.textFile("data.txt")
# 转换为键值对并归约
word_counts = lines.flatMap(lambda line: line.split(" ")) \
.map(lambda word: (word, 1)) \
.reduceByKey(lambda a, b: a + b)
其他ByKey操作
除了reduceByKey,Spark还支持许多其他针对键值对的操作。
例如,sortByKey操作可以按键对键值对进行排序。如果你想按值(例如词频)排序,可以使用sortBy转换操作,并指定一个返回排序依据字段的函数。
另一个重要的操作是join,它用于连接两个键值对RDD。join操作会基于相同的键,将两个RDD中的元组配对。如果某个键在一个RDD中出现n次,在另一个RDD中出现m次,那么连接结果会产生n*m个元组。
点互信息计算示例
点互信息是信息论和统计学中用于衡量两个事件关联性的指标。其公式定义为:
PMI(x, y) = log( P(x, y) / (P(x) * P(y)) )
其中,P(x)是事件x发生的概率,P(y)是事件y发生的概率,P(x, y)是它们同时发生的联合概率。
我们的目标是计算数据集中形容词和名词之间的PMI。数据是包含形容词-名词对的文本文件。
计算步骤如下:
- 数据准备:读取数据,过滤脏数据,得到(形容词,名词)对的RDD。
- 计算频率:
- 计算每个(形容词,名词)对的频率。
- 计算每个形容词的频率。
- 计算每个名词的频率。
- 计算PMI:有两种主要方法。
- 广播变量法:将形容词和名词的频率字典作为广播变量发送到所有工作节点。然后,通过
map操作,每个工作节点都可以查找本地字典来计算每个对的PMI值。 - 连接法:将形容词频率RDD和名词频率RDD分别与词对频率RDD进行
join操作,在连接后的数据中直接计算PMI。
- 广播变量法:将形容词和名词的频率字典作为广播变量发送到所有工作节点。然后,通过
广播变量法适用于被广播的数据集(如频率字典)较小的情况,因为它避免了数据混洗。连接法则在两个待连接数据集大小相当时更高效。
K-Means聚类算法示例
K-Means是一种经典的聚类算法,目标是将数据点划分为K个簇。算法步骤如下:
- 随机选择K个点作为初始簇中心。
- 迭代直至收敛:
- 分配步骤:对于每个数据点,找到距离其最近的簇中心,并将其分配给该簇。
- 更新步骤:对于每个簇,计算其所有分配点的均值,将该均值作为新的簇中心。
在Spark中实现K-Means:
- 读取数据,将每行转换为表示坐标的数值数组(向量),并缓存该RDD。
- 从数据中随机抽取K个点作为初始中心。
- 进入主循环:
- 使用
map操作,为每个数据点计算其最近的中心索引,并输出(中心索引, (数据点向量, 1))的键值对。 - 使用
reduceByKey操作,对每个中心索引,将其下的所有数据点向量相加,并累加计数。 - 使用另一个
map操作,将每个簇的总向量和除以点数,得到新的中心坐标。 - 计算新旧中心的总移动距离。如果距离小于阈值,则停止迭代。
- 使用
PageRank算法示例

PageRank是用于衡量网页重要性的算法。其核心思想是:一个网页的重要性取决于链接到它的其他网页的数量和质量。

算法迭代更新每个网页的Rank值,公式为:
PR(u) = (1-d)/N + d * Σ (PR(v) / L(v))
其中:
- PR(u)是页面u的PageRank值。
- d是阻尼因子(通常为0.85),表示用户继续点击链接的概率。
- N是网页总数。
- Σ 表示对所有链接到u的页面v求和。
- L(v)是页面v的出链数量。
在Spark中实现PageRank:
- 读取链接数据(源页面,目标页面),使用
groupByKey得到每个页面的出链列表RDD(links)。 - 初始化每个页面的Rank值为1.0,得到Rank RDD(
ranks)。 - 进行多次迭代:
- 将
links和ranks基于页面(键)进行join。 - 使用
flatMap操作,对于每个页面,将其当前的Rank值平均贡献给它的所有出链页面。 - 使用
reduceByKey操作,将所有页面收到的贡献值相加,得到新的Rank值(尚未加入阻尼因子)。 - 使用
mapValues操作,为每个新Rank值应用PageRank公式(加入阻尼因子和随机跳转项),更新ranksRDD。
- 将

连接与广播变量对比
在需要组合两个数据集的信息时(例如,用产品信息丰富交易记录),有两种常见方法:
- 连接:对两个键值对RDD使用
join操作。这会引起数据混洗,通信成本大致与两个RDD的大小之和成正比。 - 广播变量:将较小的那个RDD(如产品信息)收集到驱动节点,转换为字典,然后广播到所有工作节点。在
map操作中,通过查找广播的字典来组合信息。这种方法避免了混洗,但需要广播整个小数据集到每个工作节点。


选择哪种方法取决于两个数据集的相对大小。如果两个数据集大小相近,join可能更优。如果一个数据集远小于另一个,广播变量法通常更高效。
总结

本节课我们一起学习了Spark中键值对RDD的核心操作。我们了解了如何创建键值对RDD,并深入探讨了reduceByKey、sortByKey、join等关键转换操作。通过词频统计、PMI计算、K-Means聚类和PageRank算法这四个从简到繁的实例,我们掌握了如何设计并实现基于键值对的复杂数据处理流程。最后,我们还对比了连接操作与广播变量在不同场景下的适用性,为优化Spark作业性能提供了思路。键值对RDD是Spark表达力和功能性的重要体现,熟练掌握它是进行高效大数据计算的关键。
009:关系模型回顾 🗄️
在本节课中,我们将要学习关系数据库模型的基础知识,这是理解Spark SQL等现代数据处理框架的重要前提。我们将回顾SQL的核心概念、语法和操作,为后续学习Spark SQL打下坚实的基础。
关系数据库基础
上一节我们介绍了RDD是Spark的核心基础。本节中,我们来看看构建在RDD之上的第一个重要组件——Spark SQL。然而,当前的发展趋势是让SQL组件尽可能广泛。这个迁移过程仍在进行中。虽然现阶段你仍需了解RDD和Spark SQL,但趋势正朝着基于SQL的系统发展。因此,了解SQL至关重要。
尽管“大数据”概念提出后,“NoSQL”也随之兴起,但人们很快意识到SQL因其强大的功能而无法被完全抛弃。因此,“NoSQL”后来被重新诠释为“Not Only SQL”。事实上,关系型或结构化数据所承载的价值对于任何公司来说都是最重要的。至今,企业仍主要使用关系数据库作为其数据基础设施的核心组件。
首先,我们需要对SQL进行快速回顾。关系数据库本质上是一系列表格。表格也称为关系。表格有几个关键结构:表名、属性名(也称为列)以及行。本质上,它是一个二维数组。


表结构与约束
正如在第一讲中解释的,表的结构(模式)必须预先指定,因此这类数据被称为结构化数据。在关系数据库的情况下,模式包括表名和属性名。除了列名,类型也必须指定。另一个重要特性是主键。主键是可选的,但始终建议指定。主键在确保数据库完整性和提高性能方面都非常有用。
在完整性方面,主键有一个约束,即值必须唯一。数据库系统会自动确保此完整性约束。在性能方面,一旦某列被指定为主键,数据库就会在其上构建一个聚集索引。索引通常是B树或哈希表,其目的是允许数据库处理引擎高效地查找任何特定的元组。例如,如果在学生ID上有一个索引,那么给定任何学生ID,我都可以非常高效地检索该学生的所有其他属性。
但Spark没有索引。这是对Spark的主要批评之一。从这个意义上说,Spark不擅长查找查询。Spark只擅长批处理,即至少需要扫描整个数据库或整个数据的作业。稍后我们将讨论其他系统,如MongoDB,它支持索引。因此,如果你有只查看少量行的点查询,则不应使用Spark,而应使用MongoDB等其他系统。
另一个需要解释的术语是聚集索引。实际上有两种索引:一种是主索引或聚集索引,另一种是二级索引。区别在于索引是否是聚集的。如果索引是聚集的,意味着表是按此索引的顺序物理存储的。例如,如果学生ID是主键,那么数据库将构建一个聚集索引,这意味着在磁盘上,行将按索引顺序存储。这对于执行范围查询非常有效。显然,对于任何表,我们只能有一个聚集索引,因为行只有一种存储顺序。

表、行与集合语义

表有几个等效术语:元组、记录、行,它们都是指同一个东西。严格来说,表是元组的集合,但这仅在表有主键时才正确。如果表没有主键,则可能出现两个或多个完全相同的元组。历史上,当关系代数理论首次提出时,其原始模型基于集合语义。然而,当人们开始构建实际系统时,他们意识到维护集合语义可能非常昂贵,因为需要不断移除所有重复项。因此,在实际构建关系系统时,他们决定使用所谓的包语义。包在数学上称为多重集,它允许重复。实际上,重复的次数也会被存储。虽然这在技术上不完全正确,表应该是元组的包,但集合的某些特性(如顺序无关)仍然保留。

基本SQL查询:SELECT-FROM-WHERE
SQL查询的基本形式是SELECT-FROM-WHERE结构。SQL如此流行的原因之一是它可能是最容易学习的编程语言,它非常接近自然语言。另一个好处是它是一种声明性语言。用户只表达他们的意图,即他们想要什么,而不是如何实现。找出最优的查询计划是数据库系统的工作。这是SQL最吸引人的特性之一,也是其被广泛使用的原因。

让我们首先看一个简单查询的语法和语义:SELECT * FROM product WHERE category = ‘gadgets’。这个查询返回满足选择条件的两行。这也称为选择操作。选择操作总是选择行的子集。
下一个例子同时使用了选择和投影操作。WHERE子句中的条件仍然是选择条件,即选择行。投影则选择列的子集。前一个例子使用了SELECT *,意味着保留所有列。而这里只指定了四列中的三列,因此只保留这些列。这称为投影操作。
关系代数之所以称为代数,是因为它将表视为抽象数学域中的元素,并可以应用不同的操作。每个操作符都会返回同一域中的另一个元素。例如,应用选择和投影操作符将返回另一个可能具有不同模式的表。
另一个常用的字符串操作符是LIKE。它检查字符串是否匹配包含特殊符号的模式。

去除重复与排序
如前所述,默认情况下,关系数据库使用包语义或多重集,即不删除重复项。如果你想删除重复项,可以添加DISTINCT关键字。例如,这里只是一个投影,没有选择。投影后,如果不使用DISTINCT,则会出现两个“gadgets”,因为在原始表中,类别列有两行是“gadgets”。默认情况下,投影不会改变行数,每行都会在结果表中产生一个结果,但这会导致重复。如果你想删除重复项,可以使用DISTINCT关键字。重复项删除是可选的,默认不进行是出于效率考虑。





除了基本的FROM-WHERE子句外,还有两个子句:一个是ORDER BY,用于对结果排序。实际上,你可以指定多个列进行排序,首先按第一列排序,如果出现并列,则通过检查第二列来打破并列。





连接操作
接下来是最重要的关系操作符——连接。这是简单电子表格应用程序与关系数据库之间的关键区别。数据库的关键思想是将数据划分到不同的关系中。然而,当你想进行查询时,必须连接这些表,以便将来自不同地方的信息重新链接在一起。
虽然SQL中有显式的JOIN子句,但你不必使用它。还有一种隐式的方式,实际上更好。这里,我们并没有真正使用JOIN关键字,而是简单地将连接条件写在WHERE子句中。假设我们有两个表:product和company。查询要找到在日本制造且价格低于200美元的所有产品,并返回它们的名称和价格。我们只需从这两个表中选择产品名称和价格。然后,价格低于200美元和制造商在日本是两个选择条件,我们将其写在WHERE子句中。还有一个隐式的连接条件,即产品表中的制造商必须与公司名称匹配。这个隐式连接条件也写在WHERE子句中。这是一个连接查询的例子。


连接查询的结果语义是:来自两个关系的每一对元组都将被比较。如果它们满足所有条件(包括选择条件和连接条件),则这对元组将被输出。在这个例子中,有四对元组满足连接条件,但在这四对中,只有一对还通过了价格和国家这两个选择条件。因此,结果只有这一对。最后,因为我们对产品名称和价格进行了投影,所以只保留这两列。


连接查询的细节与歧义处理

这是另一个展示连接微妙之处的例子,仍然是相同的两个表。查询是找出制造某些“gadget”类别产品的所有国家。SQL语句如下:我们对这两个表进行连接,然后有一个选择条件,以及一个连接条件manufacturer = cname。然而,当你实际运行它时,连接查询的语义只是简单地比较来自每个表的所有元组对,然后机械地检查它们是否满足条件。在第一个例子中,有四对匹配的元组。这里的选择条件是category = ‘gadget’,这意味着这两对都通过了条件。然后我们对国家进行投影。最终结果是美国出现了两次,因为默认情况下没有重复项删除。这可能出乎意料。解决方案是添加DISTINCT。实际上,有一个更好的解决方案,我们稍后会看到,无需使用DISTINCT。



形式上,语义即用户的意图如下:用户希望系统执行以下操作,即通常可能涉及多个关系。用户的意图是一个看似昂贵且繁琐的循环:遍历每个关系的每个元组,然后对每种组合,检查WHERE子句中的条件(包括连接条件和其他条件)。如果所有条件都通过,则这个组合将被包含在结果中。这也意味着有一个快速检查:假设我删除了WHERE子句,只运行SELECT country FROM product, company,会发生什么?是的,将输出12个结果,实际上会有4个“USA”和8个“Japan”。这是因为每一对都会被检查,而且没有条件,所以所有对都会通过条件。当然,这并不一定是数据库执行查询的方式。如果数据库实际使用这种嵌套循环,效率会非常低。不同数据库系统的一大区别在于它们的优化器如何为不同查询找到最优的查询计划。SQL的美妙之处恰恰在于其语义与实际执行方式无关。



处理列名歧义
另一个有用的SQL结构是聚合,这对于分析查询非常有用。我们不想检索任何原始元组,而是想要一些简洁的统计信息,因此聚合非常有用。聚合可以通过在SELECT中使用聚合函数来完成。第一个例子是AVG,第二个是COUNT。这种聚合查询的语义是数据库应首先评估此查询,即执行标准的SELECT-FROM-WHERE(选择加投影),然后计算聚合。同样,这是语义,并不意味着数据库必须按这种方式执行。
通常,必须在聚合函数内指定列名。例如,AVG、SUM或MAX都需要指定列名。唯一的例外是COUNT,因为它只计算行数,指定哪一列并不重要。对于COUNT,你也可以简单地使用*,但也可以使用任何其他列名,这没有区别。除了COUNT,所有聚合函数都应用于单个属性。


然而,如果你想删除重复项,然后计算结果中不同元组的数量,则必须指定一个列名。例如,如果我们想找出1995年后制造的所有产品中不同类别的数量,那么我们应该再次添加DISTINCT关键字。语义仍然与之前相同:数据库首先运行没有聚合的查询,这将删除所有重复的元组,然后进行计数。
你可以在聚合或投影中进行更复杂的算术运算。在第一个例子中,我们试图在购买表中找出总销售额。语义是:对于每个元组,我们将两个属性相乘,然后将所有结果相加。这与第二个例子相同,只是我们还应用了一个选择,只考虑行的子集。


分组聚合
在实践中,我们通常不只想要一个单一的聚合值,而是希望为每个元组组进行计算。这称为GROUP BY。这实际上对应于MapReduce中的reduce函数,使用非常广泛。这是一个非常基础的操作。这需要另一个子句GROUP BY。这类似于聚合,只是我们有一个GROUP BY子句。允许将分组属性放在SELECT中。这种查询的语义如下:在具有四列的购买表中,我想计算每个产品的总销售额。这将为每个不同的产品返回一个总和。这可以很容易地实现为一个reduce程序:我们首先发出中间键值对,其中键是产品(分组属性),值是价格乘以数量。在reduce端,我们聚合所有产品的价格乘以数量,并将它们相加作为每个组的最终值。


这种查询的语义是:首先评估SELECT-FROM-WHERE,即执行连接。然后,根据分组属性进行分组。顺便说一下,分组属性可以有多个。在这种情况下,两行属于同一组当且仅当它们在所有分组属性上具有相同的值。最后,在将连接结果中的元组分到不同的组之后,我们计算聚合。这就是查询的语义。

在这个单表上,如果我们执行这个SELECT-FROM-WHERE-GROUP BY,分组属性是产品,产品中有两个不同的值:bagel和banana。然后我们将计算每个组的总销售额。

这是另一个例子,我们对相同的分组执行两个聚合。分组仅按产品进行,但对于每个组,我们计算两个聚合:一个总和,一个最大值。



HAVING子句

我想讨论的最后一个子句是HAVING。HAVING与WHERE非常相似。实际上,HAVING并不是必需的,因为每个HAVING子句都可以通过使用子查询来重写而不使用HAVING。但无论如何,让我们看看HAVING的用途。HAVING只能在有GROUP BY时使用,不能单独使用。让我们先看看这个例子,它与之前相同。这将返回一个有两列的表格:产品和总和。现在,假设我想进一步过滤结果,而这个条件是基于这个新列(总和)的。在这种情况下,我们不能使用WHERE,因为WHERE只应用于原始表。如果选择条件是基于由分组聚合创建的新列,那么我们必须使用HAVING。在HAVING子句中,你必须复制这个聚合表达式,然后应用任何条件。这表示我们只想保留那些聚合值大于30的组。然而,你可以使用子查询重写它:首先计算分组,然后进行另一个选择。在这种情况下,我们可以将总和重命名为total_sales,然后进行另一个选择,在那里可以用WHERE替换HAVING。这两种写法实际上是等价的。


SELECT子句的约束
现在我们已经介绍了几乎所有常用的五个子句:SELECT、FROM、WHERE、GROUP BY、HAVING。有一些约束:SELECT子句可以包含任何属性和聚合,但不能包含其他属性。这些属性是分组属性,但不能包含其他属性。例如,产品是一个分组属性,允许在SELECT中包含产品。然而,你不能包含例如日期。这是不允许的。这实际上很直观,因为我们正在进行分组,同一组中的所有元组在产品上具有相同的值,因此在输出时没有歧义。然而,同一组中的元组可能没有相同的日期,那么你想在这里放什么日期呢?因此,系统不允许在SELECT子句中包含任何其他属性。

C1是R1到RN中属性的任何条件。有两种条件:一种称为选择条件,另一种是连接条件,尽管系统并不区分它们。通常,如果一个条件只涉及同一表中的属性,我们称之为选择条件,因为它可以在表的基础上进行检查。如果一个条件涉及来自两个表的属性,则称为连接条件。

更准确地说,实际上有两种连接条件。第一种是我们之前看到的例子,是等值条件。这是最常用的,因为我们想要连接匹配的元组。另一种连接条件不那么常见,例如,假设产品价格小于股票价格。无论如何,这可能没有意义,但这也称为连接条件,但不是等值连接。这有时称为θ连接。θ连接本质上涉及任何不等式。θ连接处理起来更昂贵,因为必须检查所有可能组合。等值连接更常见,实际上,等值连接条件也称为自然连接,因为它们非常自然和直观。


复杂查询示例

现在让我们看几个更复杂的例子,将使用这个示例模式。实际上,如果你进入Microsoft Azure并创建数据库,那将是SQL Server。SQL Server是微软的数据库产品,如果你创建一个SQL Server实例,可以有一个遵循此模式的示例数据库。你可以看到,这对于公司的关系数据库来说是一个非常典型的设计,它们将信息组织到几个表中。例如,这是一个产品表,每个表都有一个主键。这实际上遵循了推荐做法:对于产品表,主键是ProductID。还有一个销售订单明细表。实际上,他们将销售订单组织成两级结构:销售订单头和销售订单明细。每个订单可能包含多个产品。销售订单头表包含每个订单的信息,明细表包含每个订单中的产品。销售订单明细表有一个外键,引用产品表中的主键。这是另一个约束。我们讨论过主键是唯一约束,即表中的所有值必须唯一。外键是表间约束。在这种情况下,外键约束是从ProductID列到产品表的ProductID列。这意味着销售订单明细表中任何元组的ProductID必须出现在产品表中。这实际上是有意义的,因为引用一个不存在的产品是没有意义的。如果发生这种情况,一定是出了问题。外键约束的好处是数据库将确保此约束得到满足。有两种操作会被阻止:第一种是,如果你想在订单明细表中插入一个引用不存在产品的订单明细元组,则此插入将被拒绝。另一种情况是,如果我们想从产品表中删除一个仍有订单明细引用的产品,即如果某个订单明细仍然需要此产品,则此删除将被拒绝。系统将防止这两种操作。

我想外键约束必须引用主键,不能引用非主键列。这里,所有外键约束都引用主键:ProductID此外键引用ProductID,CustomerID此外键引用客户表的主键,SalesOrderID引用销售订单头表的主键。这也是出于效率原因。请记住,系统将防止任何违反约束的操作,为了能够检查操作是否违反约束,它们需要索引。因此,要求外键约束必须引用主键。


复杂查询示例解析
让我们看一些基于这个更复杂模式的例子。第一个例子:我想找出每个订单的总价格,返回销售订单ID和总价格,列名应为total_price。这不太难,只是一个分组查询。答案在下一张幻灯片上,没有连接,只是一个简单的GROUP BY:选择SalesOrderID,然后对UnitPrice * OrderQty * (1 - UnitPriceDiscount)求和,并将其重命名为total_price,并按销售订单分组。为每个订单计算总销售额。
第二个例子:找出总价格大于10000的每个订单的总价格。请注意,这是对聚合值的条件,而不是对原始属性的条件。因此,这需要使用HAVING子句。这与第一个例子相同,但加上了HAVING子句。
第三个例子:找出每个订单中黑色产品的最终总价格,其中总价格大于10000。这个例子说明了HAVING和WHERE的区别。“黑色产品”是对原始属性的条件,因此应写在WHERE子句中,而总价格大于10000应写在HAVING子句中。这还需要连接,因为产品是否为黑色不在销售订单表中,而在产品表中。因此,这需要一个连接加上分组再加上HAVING,有点复杂。
我们对这两个表进行连接,连接条件是ProductID必须匹配。这里我们必须使用表名作为前缀来消除歧义。对于颜色,不需要,因为只有一个名为Color的列。其余部分相同。


内连接与外连接
我们讨论过连接。事实上,我们迄今为止看到的所有连接都称为内连接。内连接是默认情况,你不必明确说明它是内连接。但还有外连接。对于外连接,你必须明确指定。在讨论外连接之前,让我们看一个例子来激发为什么我们需要外连接而不是标准的内连接。

首先回顾一下,有两种指定连接的方式。第二种方式是我们迄今为止看到的,不需要显式使用JOIN关键字,而是简单地将连接条件写在WHERE子句中。第一种方式也可以,使用JOIN关键字显式连接两个表,然后ON关键字指定连接条件。这两种方式是等价的。然而,内连接的一个问题如下:这是产品表和购买表之间的连接,连接条件是名称必须匹配。但是,如果有一个产品从未售出,即一个产品根本没有出现在购买表中,那么请记住连接的语义:连接语义将尝试检查每一对。如果这个产品没有出现在一个表中,那么这个产品将不会出现在最终结果中。



当然,这取决于你的意图。在某些情况下,你不想让这个产品出现,那么这没问题。然而,在其他情况下,你想知道它们没有连接的事实,即对于这些产品,它们没有出现在另一侧。在这种情况下,如果你想知道这个事实,那么你不能使用内连接,内连接将无法告诉你这个事实。在这种情况下,我们使用外连接。与内连接不同,外连接是不对称的,因为我们要检查一侧的表是否出现在另一侧。因此,这是一个不对称的操作。所以有左外连接和右外连接的区别,实际上还有全外连接。让我们先看左外连接。在这个例子中,我们需要一个左外连接,因为我们想保留所有产品,即使它们没有出现在另一侧。这是一个左外连接。这里我们不能使用隐式写法,必须显式使用JOIN关键字和LEFT OUTER JOIN。

在这种情况下,第三个元组“One-click”没有出现在购买表中,“Gizmo”和“Camera”都出现了。如果我们进行内连接,就不会有这一行。然而,如果我们进行左外连接,这一行将被保留,即对于左侧的任何元组,它至少会产生一个结果。即使它不匹配任何东西,例如“One-click”不匹配任何东西,它仍然会出现。然而,另一表中对应的属性将为NULL。例如,“Camera”匹配两个,它仍然出现两次。实际上,内连接和外连接之间的唯一区别是,外连接确保左侧的所有内容至少出现一次。



这是一个应用示例:假设我们有两个表,产品和购买,与之前相同。我们想计算每个产品在九月份的总销售额。如果我们进行标准的连接加分组的查询,可能会出错。如果某个产品在九月份从未售出,那么我们将不知道。理想情况下,如果是这种情况,我们应该返回0。在这种情况下,如果我们用外连接替换,我们也会得到那些销售额为0的产品。


另一个例子:对于每个客户,我想找出购买的黑色产品的总数量。报告客户ID、名字、姓氏和总数量。这是一个相当复杂的例子,涉及多个表。对于每个客户,我们想找出所有黑色产品的总数量。有两个注意事项:第一,内连接是可交换的,顺序无关紧要;然而,外连接不是,因为外连接是不对称的,所以我们需要注意,不能交换两侧。连接从左到右处理,并由括号强制执行。对于这个复杂查询,我们可以先写一个查询计划或连接树。


我们首先连接产品表和订单明细表,这样我们就可以得到只包含黑色产品的订单明细,因为查询只对黑色产品感兴趣。然后,我们将订单明细表与订单头表连接,因为订单明细没有客户信息,客户ID在订单头中。最后,我们与客户表连接以报告客户的其他信息。这里我们想对最后一个连接使用外连接,因为如果客户没有购买任何黑色产品,我们想知道这个事实。如果我们使用内连接,那么这些客户根本不会出现,我们甚至无法知道他们存在。


这是遵循我们查询计划的SQL查询,看起来相当复杂。让我们一起阅读:我们首先执行一个连接,这是三个表之间的连接。因为内连接的顺序无关紧要,所以怎么写都可以。然后最后一个连接顺序很重要,我们确保客户在左侧,这个结果在右侧。最后,我们进行分组以计算总和。因为我们还想报告名字和姓氏,请记住,系统只允许你将属性放在这里,如果它也在GROUP BY子句中。虽然这看起来有点冗余,但如果两行具有相同的客户ID,它们也必须具有相同的名字,但这是一个要求。如果你想在SELECT子句中写FirstName、LastName,也必须将它们包含在GROUP BY中。这不太优雅,但这是一个要求。最后,我们进行排序。



外连接总结
总结一下外连接:外连接是不对称的,有左外连接和右外连接,还有全外连接,它包括两侧的元组,即使没有匹配。全外连接是对称的。
这是使用右外连接的替代答案。在SQL中,如果连接是外连接,那么你不必说OUTER,因为当你说LEFT或RIGHT时,它不可能是内连接。所以你可以省略OUTER关键字。这实际上与之前相同,只是我们使用了右连接,这样我们就不需要括号,因为如果没有括号,连接将按照它们在查询中出现的顺序从左到右处理。


本节课中我们一起学习了关系数据库模型的核心概念,包括表结构、约束(主键、外键)、SQL基本查询(SELECT-FROM-WHERE)、投影与选择、去除重复(DISTINCT)、排序(ORDER BY)、连接操作(内连接、外连接)、聚合函数(如SUM、AVG、COUNT)以及分组聚合(GROUP BY)和过滤分组结果(HAVING)。这些知识是理解和使用Spark SQL等大数据处理工具的基础。掌握这些SQL操作对于进行有效的数据查询和分析至关重要。
010:数据框
概述
在本节课中,我们将要学习Spark SQL的核心概念——数据框。我们将了解数据框是什么,它与RDD的关系,以及如何使用数据框API进行数据操作。我们还将探讨Spark SQL的优化机制,并学习如何将RDD与数据框相互转换。
数据框简介
上一节我们介绍了RDD,本节中我们来看看Spark SQL。人们发现,尽管RDD是对MapReduce的改进,但对于许多操作和程序来说,使用RDD编写代码仍然不够方便。人们仍然希望使用已经存在了半个多世纪的SQL语言,因为大家对编写SQL非常熟悉。因此,Spark社区在Spark之上构建了Spark SQL。
实际上,在开发Spark SQL的同时,Spark的其他组件,如Spark Streaming、机器学习库MLlib以及图处理库GraphX,也在并行开发。但后来我们会看到,Spark SQL正在成为另一个横向层,其他组件正逐渐从基于RDD的API迁移到基于Spark SQL的API。虽然这个过程尚未完成,但趋势是向基于Spark SQL的系统迁移。因此,掌握Spark SQL对于掌握其他组件非常重要。

对于RDD,基于RDD的Spark处理的入口点是SparkContext。如果你使用Jupyter或shell,sc已经为你创建好了;如果你编写标准应用程序,则需要自己创建SparkContext。
类似地,对于Spark SQL,入口点是SparkSession。如果你使用shell或Jupyter笔记本,这个SparkSession已经创建好了,你可以直接使用。然而,如果你创建独立的应用程序,则需要自己创建SparkSession。
数据框与行对象
Spark SQL中表的对应物是数据框。这个术语借鉴自Pandas和R。当然,底层的实现可能不同。Spark数据框是在RDD之上实现的。因此,尽管用户越来越少直接使用RDD,但RDD仍然是Spark SQL之下的核心组件。更准确地说,数据框就是由行对象组成的RDD。
那么,什么是行对象?行对象在概念上几乎与Python元组相同,它可以包含任意数量的组件。行对象与Python元组的区别在于,行中的不同组件有名称,因此它们对应于关系表中的一行。
以下是一个例子:
Row(name='Alice', age=11)
这个行有两个列或成员,一个叫name,另一个叫age。name的值是'Alice',age的值是11。可以说,行与Python字典非常相似,它们都是键值对。
当你将许多行组装成一个RDD,进而形成一个数据框时,所有这些行必须具有相同的列名集合。也就是说,如果你有一个由这种行组成的数据框,那么每一行都必须同时包含name和age。
你可以通过两种方式访问行的组件。这是Python语法:
- 使用方括号,并将组件名称作为字符串包含在内。
- 使用点号。
虽然第二种方式看起来更简洁,但有一个技术细节需要注意:如果行的名称与其自身的属性冲突,就会遇到问题。我们稍后会看到一个具体的例子。
数据框操作示例
现在让我们看一些数据框操作的例子。首先是行对象。
我们创建了一些行对象。打印行,这只是行内容转换为字符串。然后,你可以使用两种方法中的任何一种来访问行的成员,这两种方法都有效。
然而,如果我们添加另一个名为count的成员。使用点运算符的方式将不再有效。我试图打印这个count的值,期望得到1,但它返回了类似方法的东西。原因是count实际上是行对象内部的一个内置方法,而不是一个属性。因此,它返回了这个方法。如果你真的想访问这个字段,你只能使用第一种方法,即使用方括号并将成员名称作为字符串包含在引号内。这种方法总是有效的,因此也是访问行对象成员的推荐方式。
创建数据框
Spark提供了多种构建数据框的方法。这里我将使用read方法。read对象类有多种方法,允许你从各种文件类型读取数据。这里我们读取一个CSV文件。
有一些可选参数:
header:指示CSV文件有标题行,这样Spark可以从标题行推断列名。inferSchema:指示Spark推断模式。
让我们看一个例子。这个CSV文件是一个标准的CSV文件,实际上是一个文本文件,用逗号分隔。第一行是标题行。
如果我们读取它,对于数据框,我们没有collect方法,但有show方法。因为collect可能不安全,而show方法总是安全的,它只显示前20行。因此,即使数据框很大,使用show也是安全的。这也是一个动作。
回想一下,我们说数据框就是行对象的RDD。这也意味着数据框的操作也分为转换和动作。转换仍然是惰性的,只有动作才会触发计算。因此,这个read.csv实际上是一个转换,它将文件读入数据框。而这个show是一个动作,它会触发计算并读取文件。
什么是推断模式?因为CSV文件没有真正的类型信息,它只有列名。但数据框类似于关系表,要求指定每列的类型。如果你设置inferSchema为true,Spark将读取CSV文件两次。第一次扫描文件时,它会尝试为每一列确定最合适或最具体的类型。所谓最具体,意思是例如,字符串总是合适的,因为任何数据类型都可以表示为字符串。然而,对于像BuildingID这样的列,它们都是整数,如果你把它们当作字符串,那么某些仅适用于整数的操作将不可用。
因此,在第一次扫描之后,Spark决定整数是更具体的类型,它是一个更窄的类型,所以它会为每一列分配最具体的类型。对于第二列,你无法指定比字符串更具体的类型,所以是字符串。第三列是整数,最后两列是字符串。这就是我们所说的推断模式。你可以使用数据框的printSchema方法来打印模式。
还有一个条件叫做nullValue。如果你使用inferSchema并且它为true,这意味着你允许将null作为列的值。
这是从文件创建数据框的一种方式。实际上,除了CSV,还有文本文件,但文本文件只能返回一个单列的数据框,因为文本文件的每一行都是字符串。CSV可以返回多列的数据框,还有其他创建数据框的方法。
从RDD创建数据框
你也可以从RDD创建数据框,但条件是RDD必须是行对象的RDD。稍后我们将看到如何从RDD创建数据框。这里是从数据框创建RDD,这更容易,因为数据框是行对象的RDD,可以通过数据框的.rdd成员访问。数据框df的.rdd成员将返回对底层RDD的引用,即行对象的RDD。在这个RDD上,你可以执行任何RDD操作,例如take,这将返回前三个成员,它们都是行对象。
当然,如果你只在RDD上进行操作,那么你将不会使用任何Spark SQL的特性。因此,虽然这是可用的,但并不常用。事实上,建议尽可能直接使用数据框API。
数据框API
数据框API的设计旨在完全实现SQL能提供的所有功能。所有标准的SQL操作都已作为数据框API实现。
让我们看一些例子。第一个是投影。这通过select完成。所以你可以直接使用select。
当我们第一次看SQL时,它给人的感觉不是声明式语言,而是过程式语言,它告诉你想要执行的一系列转换操作序列。然而,稍后我们会看到,由于内部的Catalyst优化器,它实际上是声明式的。但现在,我们仍然可以以过程式的视角来看待数据框API。这只是一个操作,一个转换,即投影,尽管它叫做select,但实际上是在进行投影。对两列进行投影。
如果存在重复项,重复项不会被移除,这与SQL的语义相同。
这个例子中我们同时有选择和投影。选择通过where完成。所以我们进行两个转换。第一个转换是选择,即只保留那些country小于'USA'的行。这是两个字符串之间的比较,是字典序比较。然后,我们只对buildingID进行投影。
注意,Spark SQL中的where函数接受一个字符串作为输入参数,这个字符串对应于SQL中表达的条件。实际上,你可以在这个where条件中放入任何标准的SQL运算符甚至函数,因为这将被Spark SQL解析并转换为Spark中的相应操作。但接口仍然遵循SQL标准。
这个lit实际上是Spark SQL的一个函数。它的效果是创建一个新列。通常,投影只选择子列,但你也可以使用select来创建新列。创建新列有两种方式:一种是执行某些计算,另一种是放入一个常量。lit代表字面量,本质上意味着一个常量,它在所有行中具有相同的值。
使用数据框API重写SQL查询
现在,我们将使用数据框API重写我们在上半部分讲座中做的那些SQL查询,以此来练习这套方法。
我们使用相同的表集:customer、product、sales_order_details、sales_order_header,和之前一样。我已经将它们转换为CSV文件,以便你可以下载并使用。
让我们看这个SQL查询:SELECT productID, name, listprice FROM product WHERE color = 'black'。我们如何将其转换为Spark SQL?
SQL是声明式的。目前,至少Spark SQL看起来是过程式的,但让我们暂时接受这一点。也就是说,我们只指定我们想要执行的一系列转换序列。第一个转换是选择,我们只对黑色产品感兴趣。所以这是第一个转换,它将只保留color等于'black'的行。
注意,这里使用单个等号。因为这是SQL中的相等运算符,在SQL中相等是单个等号。双引号内的一切都是SQL,而不是Python。
第二步是投影,我们投影到这三个列上。然后我们调用一个动作,这将返回结果。
这是另一个展示如何创建新列的例子。之前,我们创建了一个具有相同值的新列。如果你想创建一个新列,该列是某些算术运算的结果,该怎么办?这是一个例子。
我们仍然只对黑色产品感兴趣。这里我还展示了编写此条件的不同方式。顺便说一下,where和filter是相同的,它们是彼此的别名,所以你可以使用任何一个。
在这个例子中,我们使用SQL表达式来指定条件。这里我使用Python表达式。所以现在这不是在双引号内。这将被解释为Python表达式。在Python表达式中,你不能通过使用字符串来引用列,你必须检索该列。这可以通过dataframe.columnname来完成。
同样,有两种方法可以做到这一点。你也可以使用这种形式。我认为这也有效。实际上,这种方式总是更安全,因为如果列名恰好与数据框的内部成员冲突,那么点号方式可能无效,但color没问题。
select是投影。在之前的例子中,字符串中的所有内容都被解释为SQL。所以我们可以直接指定列名。或者,你也可以直接检索该列。所以不使用引号。有两种方式。我展示了两种方式。两种都可以。点号格式或方括号。这是productID列,这是name列。你可以看到两者都服务于相同的目的。




现在假设我们想计算一个新列,它是原价的两倍。注意,你不能简单地写listprice * 2。这不会工作。为什么?因为每当你在select中写一个字符串时,它将被解释为一个列名。然而,这个列并不存在,listprice * 2不是一个列名。所以它会返回一个错误。因此,你必须使用Python方式。也就是说,这是从数据框中检索一个列。同样,你可以使用点号,也可以使用方括号,然后乘以2。




实际上发生的是,Spark重载了*运算符,使其可以应用于列和常量之间。它实际上还有另一个重载,可以用于两个列之间相乘。这将返回一个名为listprice * 2的新列。当然,最后一部分是可选的,这是为了使结果看起来更好。如果我们没有它,列的名称将是表达式,这看起来不太美观,特别是当你想将结果用于其他转换时。所以我们给它一个新名字doubleprice,这样我们就可以创建一个新列。

这个例子类似,只是我们还进行了一个选择,其中使用了更复杂的表达式作为选择条件。我们想检查这个是否大于某个值。我认为对于where,将其作为字符串写入是可以的。所以这有效。无论如何,它们也可以将其解释为SQL,但它们并不关心。所以为了简单起见,只需记住:对于where或filter,整个表达式可以是SQL,如果你写一个字符串。然而,对于select投影,当你写一个字符串时,它必须是一个列名。另一个例子也不起作用,因为这将解释为字符串乘以2,这不对应于任何列名。







排序与连接




下一个例子是order by。在SQL中,你只需有另一个子句。我们在选择和投影之后进行排序。orderBy默认是升序。如果你想要降序,可以使用第二个可选参数。


现在让我们看一些连接。这两个查询是等价的,只是第二个显式使用了join关键字。第一个使用隐式连接,即我们在where子句中写入连接条件。这是两个表order_detail和product之间的连接。我们有一个连接条件和一个选择条件。这是等值连接。这个等式表示这个表中的productID列必须与另一个表中的productID列匹配。这被称为自然连接。正如我之前提到的,这很自然,因为它自然出现在许多场景中。
Spark SQL的好消息是它支持自然连接。语法非常简单,实际上比SQL更容易。格式如下:这是第一个表,这是第二个表。我们执行连接。我们在这个列上执行自然连接。你只需要写一次列名,如果该列出现在两个表中并且你希望它们相等。在SQL中,你必须写两次,并且因为该列出现在两个表中,你必须通过添加表名前缀来消除歧义。这使得查询相当冗长,实际上是冗余的。Spark SQL去除了这种冗余。因此,每当一个列名出现在两个表中时,你只需要指定该列一次。
当然,你也必须指定它,因为即使两个表有相同的列,你可能不想在其上连接。所以,如果你想在这个列上连接,你必须指定连接列,但你不必像在SQL中那样指定两次。
这是一个连接。这将返回所有可以在此列上匹配的行对。然后之后我们进行过滤,过滤掉所有color不是黑色的结果,只保留那些color是黑色的。然后我们投影到所需的列上。
现在,事情开始变得有趣了。我已经剧透了。但无论如何,如果我们将过滤器移到select之后,这仍然有效。这看起来很奇怪,因为乍一看,它不应该工作。因为这是一个连接。当然,这个连接没有问题。然后选择也没问题,但在选择之后,color不再是结果中的一列。在投影属性中,color不是这五个属性之一。因此,我们预计会出现错误,因为在这个投影选择之后,没有名为color的列,所以你无法对color进行过滤。然而,它仍然正常工作,没有任何变化。这是怎么回事?
让我们尝试进一步分解。我们将做以下操作。我们在这里使用链式调用将所有内容链接在一起。我们甚至将它们分开。假设我们先进行连接,然后进行选择。然后打印出来,检查结果是否确实没有color。这得到了验证。在打印输出中,D1是连接加投影的结果,这五个列确实没有color。
现在,我执行D1.filter(color == 'black'),然后执行D2.show()。仍然没有问题,没有错误。也许一个猜测是,如果color不存在,它会忽略这个条件。这是一种可能的解释。让我们做点别的。实际上,它并不是忽略,它真的是在进行这个选择,使用的是原始的color列。你不能简单地在这里编造一些东西,那会导致错误。
这再次体现了SQL的优势。我们提到过,在任何关系数据库中,语言都是声明式的,优化器负责找到一个好的查询计划。在任何关系数据库中,都有一个叫做EXPLAIN的命令,允许用户查看查询计划。因为有时我们可能感兴趣,特别是DBA想知道我的查询是如何实际执行的,以便我可以优化性能。
类似的事情也发生在这里。Spark SQL有这个explain方法,它告诉你这个查询是如何执行的。如果我们对D2执行explain,我们会看到一些几乎难以阅读的东西。但无论如何,我们仍然可以从这一团混乱中提取关键词。它试图告诉你计划的结构。也就是说,每一个实际上都是表达式树中的一个节点,计划通常是一个树形操作符树,就像我们从小学学到的运算顺序一样。类似的事情也发生在这里,这代表一棵树,越深的节点越早执行。
记住,D2是按以下方式计算的:我们先进行连接,然后进行选择过滤,然后进行投影,再进行过滤。这至少是我们告诉Spark执行转换操作序列的方式。但是,如果你查看实际计划或物理计划,有时这被称为逻辑计划。逻辑上,我想做类似这样的事情,但这没有意义,因为在投影之后color不存在。然而,如果你查看物理计划,在叶子级别,当然有一些扫描,然后有一个过滤器。它们经常插入一些条件,但你可以忽略它。
然后这是销售订单的扫描。所以对于detail和product表,这是销售订单明细表,另一个扫描是产品表。这棵树有两个分支,一个分支读取一个文件,另一个分支读取另一个文件。
然后向上,执行这个。重要的是,这个过滤器在这个分支上,这个分支是产品表。在产品表上,Spark SQL没有先执行连接,而是先执行了过滤器。这里我们已经有了color == 'black'条件。这意味着Spark SQL真的在这里做了一些聪明的事情。
因为当我们写这个时,如果你足够敏锐,你已经知道这个操作顺序实际上不是最优的,因为即使第一种写法,我们在这里写的代码,即使这种写法也不是最优的,因为我们只对少数颜色是黑色的产品感兴趣,而我们却先进行了连接。实际上,我们在第一节课中也看到了这种例子,因为我们同时执行了连接和选择,我们知道应该先进行选择,这可以显著减少问题规模,因为连接非常昂贵,连接需要洗牌,减少连接中需要的数据量总是一个好主意。
最好的方式,如果我们真的要实现查询,实际上是先对产品进行过滤。我们应该写类似这样的东西:先过滤color == 'black'。这是更好的方式。我们应该这样做,这要好得多。我们先移除尽可能多的非黑色产品,然后进行连接,然后进行投影。这是最好的方式。
但幸运的是,这没关系。所以即使我们分两个阶段写,我们实际上使用D1指向中间结果,但计算D2的物理计划完全忽略了我们的指令。Spark几乎完全重写了我们的查询。它先执行了这个选择,然后还执行了投影,尽可能早地执行。这里的投影是,对于产品表,我们只保留productID和name,忽略其余部分。然后我们进行连接,然后再进行另一个投影。这在实践中非常有用,因为在实践中,所有表,例如产品表,可能有很多列,可能有一些地址、备注、电话号码等,这些都不需要。因此,它会尽可能早地执行投影,尽早移除不必要的列。
这解释了为什么它没有返回错误。这也意味着,在某种意义上它仍然是声明式的,Spark不会严格遵循你构建线性图的指令。这实际上是RDD和Spark SQL数据框之间的关键区别。对于RDD,Spark将严格遵循你命令它构建的线性图。然而,在Spark SQL中情况并非如此。
最后,这会导致错误。这里我们做的是,我先进行连接,然后进行选择。然后我物理地将中间结果作为CSV文件写入磁盘。我写入它,然后加载回来,然后进行过滤。这最终会导致错误,因为我如此固执地坚持你需要给我中间结果。中间结果,确实,如果我们遵循这个计划,中间结果D1没有color。当然,如果你如此严格,那么Spark将无法绕过,这将导致错误。
去重与聚合
这是distinct的例子。这实际上不需要指定任何东西。因此,这可以应用于任何数据框。这也赋予了过程式风格,即如果你想移除某些列的重复项,你需要先进行投影,然后对数据框执行distinct。在这种意义上,这个distinct类似于RDD的distinct,它应用于整个RDD或整个数据。
这是distinct count的例子。我们先进行选择,然后去重,然后计数。实际上,这与SQL相反。在SQL中,我们先计数,然后去重,然后是颜色。但无论如何,我认为这更易读,因为这更自然。我们先获取唯一的颜色,然后我想计数。我认为这实际上比标准SQL更符合逻辑。
这是一个group by的例子。我们想找到每个订单的总价,并返回orderID和总价,列名应为totalprice。这是SQL语句,这是对应的Spark SQL程序。我先计算这个聚合,这是分组聚合。
在Spark SQL中,我们这样做:我们先使用select创建这个列。我认为SQL更简洁。但在Spark SQL中,我们需要先创建这个新列。这可以通过使用select来完成。我在这里使用*,这并不重要,因为我们可以忽略它们。关键是我们正在按照这个表达式创建这个新列。这是一个列名乘以另一个列名,再乘以(1 - discount)。然后我给这个列一个新名字。这里给它一个新名字很重要,否则将无法引用它。
然后我进行groupBy。然后我进行求和,netprice指的是我刚创建的新列。最后,我重命名该列为totalprice。withColumnRenamed和alias方法用于重命名列,它们目的相同,但使用的场景略有不同。withColumnRenamed本质上是一个转换,它通过将列重命名为新名称将一个数据框转换为另一个数据框。alias实际上是一个函数,它不是转换,它是列的一个函数,这个表达式将返回一个列,然后你重命名该列。因此,alias函数只能在select链中使用。尽管它们本质上服务于相同的目的。
alias在以下情况下很有用:你已经有了整个列,你不需要使用withColumnRenamed,因为要使用withColumnRenamed,你需要旧列的名称,而这个列名非常长,实际上是listprice * (1 - discount),所以使用alias更容易。在第二种场景中,你不能使用alias,因为sumprice,这个groupBy没有select。实际上,这些例子很好地说明了它们各自设计的使用场景。
如果我们移除withColumnRenamed会怎样?没什么,只是结果不那么美观。在这种情况下,列名将是这个表达式。这只是对输出列名的最后修饰。
这个例子展示了having的效果。但在Spark SQL中没有having的对应物。不过,这不是问题,因为正如我之前提到的,having实际上与where相同,如果你使用子查询重写该查询。而这正是在Spark SQL中所做的。我们使用where。因为在groupBy之后,这只不过是另一个数据框,你可以对其应用数据框上可用的任何操作,包括过滤器where,这与having具有相同的效果。实际上,在将其重命名为totalprice之后,你可以在这里使用totalprice。
这是一个包含连接、分组和having的复杂例子。查询查找每个订单中黑色产品的总价,其中总价大于10000。它涉及两个表的连接、分组和having。
在Spark SQL中,写法如下。同样,有多种写法。这里我们试图对Spark友好。我们已经告诉Spark最好的方式,即我们先进行这个过滤。然后我们进行这个投影和过滤,然后将两个表连接起来,然后进行分组,再进行having。但如果我们稍后进行这些操作,也没关系。虽然这看起来效率较低,但实际上并不重要。因为就最终效果甚至性能而言,它们是相同的,因为它们将产生相同的查询计划,Spark会将这个条件提前。
复杂查询与优化
最后一个例子是我们在上半部分讲座中看到的最复杂的例子,我们进行了两次内连接,加上一次外连接,再加上分组和排序。这是SQL查询。我们首先通过内连接连接三个表,它们都是自然连接。这里还有一个过滤条件。然后我们进行左外连接。然后我们进行分组和排序。我们需要外连接的原因是我们想保留所有客户,即使他们没有购买任何东西。这就是为什么我们需要外连接。
在SQL中,这需要一个子查询。这里类似,在Spark SQL中,很难在一行中写完所有内容。所以我们将它分解为两部分。第一部分是连接,第二部分是外连接加上分组和其他所有操作。
对于连接和分组,观察结果是外连接、分组和外连接的顺序无关紧要。你可以先进行外连接然后分组,或者先分组然后进行外连接。它们具有相同的效果,因为外连接只连接客户表与其他表。实际上,先进行分组更高效,因为这个分组将不包括任何没有购买任何东西的客户。但是,只要最后一次连接是外连接,我们仍然可以保留所有客户,即使他们没有出现在其他表中。
希望这很清楚。本质上,我们先计算每个客户的总销售额。然后我们使用外连接来查找其名字和姓氏。外连接仅用于查找客户的姓名,不用于其他任何目的,所以我们可以先进行分组。
我们先进行两个内连接加上分组以及过滤条件。最后一次连接是左外连接。对于外连接,你必须明确告诉Spark SQL。通过指定第三个可选参数为'left_outer',我认为'left'也可以,'left'也指左外连接,类似于Spark SQL。然后我们进行排序,ascending=False,所以我们按降序排序。现在你可以看到,即使一个客户没有购买任何东西,他也会作为null出现在这里。
如果我们删除这个,如果最后一次连接也是内连接,会发生什么?我们将失去那些客户。
缓存与优化器的交互
当Spark SQL的优化与缓存机制交互时,会出现一些奇怪的现象。让我尝试重现观察到的现象。
这是和上次一样的例子。我们先进行连接,然后进行投影。连接是两个数据框detail和product之间的自然连接。我们投影到这五个列。确实,我们可以检查结果,这个连接结果确实只包含这五个列。
现在假设我缓存或持久化这个结果。这个表实际上缓存在内存中。让我执行它。当然,我需要先加载数据。然后我将运行这个连接,然后打印出来。这次我缓存这个表,这个表缓存在内存中。现在假设我做同样的事情,然后添加一个过滤器。正如我们上次看到的,因为color实际上不是这五个列之一,我们预计会出现错误,但实际上错误没有出现,因为Spark SQL很聪明,决定所有选择都应该被下推。如果我们查看物理计划,Spark SQL选择先运行这个过滤器,然后再进行连接。这也解释了为什么缓存似乎不起作用。
让我们验证一下。例如,我们取一行并修改源文件中的结果。我去修改它的单价。所有数据都可以下载,你可以自己尝试。我修改价格为29。这意味着缓存似乎不起作用,因为如果我们从这个已经在内存中的文件读取,那么我刚才在这里做的更改不应该反映出来,因为我执行并缓存了连接结果。然后下次我运行相同的查询时,D2是通过转换D1定义的结果,而D1实际上在内存中。然而,缓存似乎不起作用。
但让我试试别的。我唯一做的是更改过滤条件。所以如果你看线性图,它应该是相同的,只是过滤条件不同。现在再试一次。当然,我从头开始。运行这个并缓存结果。这是一个不同的订单明细ID。我知道单价是20多。我把它改成29,保存,重新读取。这次,缓存起作用了。我更改了源文件中这个特定行的数据,但查询结果中没有反映出来。所以这次缓存似乎起作用了。确实,如果我持久化,我可以再次运行,现在它反映了,因为它没有被缓存。
这似乎有点奇怪,因为线性图应该相同,因为转换集相同,唯一的区别是这个过滤条件。在之前的尝试中,过滤器是color == 'black'。第二个是orderqty > something。所以似乎这个微小的更改影响了查询计划,特别是缓存行为。
如果我们在哪里设置?让我们试试。这不应该有任何区别。这是20,让我们改变它。它仍然是20,缓存仍然有效。当然,这是不同的,这是一个新的。让我们再试一次,20。或者,那是一个不同的行。我必须去113。把它改回20。是的,仍然,每当过滤器是color == 'black'时,缓存就不起作用。每当过滤条件是第二个orderqty时,缓存就起作用。仍然。你想把这个改成where。当然,我需要。是的,仍然。缓存不起作用。实际上,where和filter是彼此的别名,没关系。所以filter和where是同一个东西。所以仍然,关键是这个条件。所以我认为这个条件改变了查询计划,当我们真正查看物理计划时,情况确实如此。这就是为什么explain会非常有帮助。
现在让我们看看。我们知道查询本质上相同,结构相同,唯一的区别是条件。那么这两个条件有什么特别之处?注意,第一个条件是color,它不是连接结果、缓存数据框中的一列。这个没有color。另一方面,第二个条件是orderqty,这是连接结果中的一列。这意味着Spark真的很聪明,决定何时使用缓存结果。每当缓存结果有用时,它就会使用它。否则,它就会忽略它。这就是为什么缓存似乎不起作用。确实,这可以通过检查查询计划来验证。
让我们看看第一个查询的计划,条件是color。我们上次看到,当是这种情况时,如果没有缓存,我们知道这不会导致错误,即使color不是连接结果中的一列,因为这个条件color被下推了。这个过滤器被一直推到底层,就在扫描CSV文件之后。当文件从磁盘读取时,Spark会尽早尝试过滤掉,应用过滤条件。这个计划不受影响,即使中间结果被缓存。你可以看到这是相同的计划。当然,很难说,但相信我,这是相同的计划,无论中间结果是否被缓存,这完全是相同的计划。
然而,现在让我们试试第二个。现在我将条件从color改为orderqty。同样,我们先做这个。我们不持久化中间结果。正如预期,和之前一样。也就是说,过滤条件也被一直推到底层叶子级别,紧接在文件扫描CSV之后。然而,如果中间结果被缓存,这次计划看起来不同了。现在让我们试着找条件在哪里。它不再被下推了。当中间结果没有被缓存时,这个条件一直在这里,紧接在文件扫描CSV之后。现在它实际上在连接之后。连接在下面。这是因为Spark看到连接结果缓存已经在内存中,不使用它会很愚蠢。如果你查看实际的物理操作符,这是一个内存关系,这是一个内存表扫描。所以甚至物理操作符也发生了变化,通过利用缓存结果。
本质上,Spark SQL会尽可能尝试使用缓存结果。这通过更改这个条件来说明。当条件是color时,中间结果无论如何都没用,所以缓存它没有帮助。当是第二个条件时,在这种情况下,中间结果是有用的,Spark SQL会尝试使用它。
用户定义函数与嵌套数据
Spark SQL中另一个有用的结构是UDF或用户定义函数。每当标准SQL函数不够用时,这很有用。在Spark中,你甚至可以做同样的事情,实际上更灵活。因为在SQL中定义UDF时,你必须使用标准SQL语言,这相当受限,例如,你不能在UDF内部进行循环。在这里,你可以做任何事情,因为你可以将Python函数定义为UDF,这给了你Python的全部能力。当然,这里我们只是通过一个非常简单的例子来说明。
假设我想定义一个奇怪的函数,它实际上是字符串长度加2。我只是想创建这个无意义的函数来说明如何定义和使用UDF。这个函数当然不存在于标准SQL中,但你可以通过导入这个方法然后创建你的UDF来定义UDF,本质上只需传递任何Python函数。唯一的要求是你需要指定返回类型,返回类型必须是SQL标准类型,而不是Python类型。所以这里不是Python的int,它是Spark SQL的SQL库中定义的整数类型,因为SQL中的数据类型与Python中的不完全相同。例如,在SQL中,甚至有货币等类型,这些在创建关系数据时有用。但无论如何,所有Python类型都受支持,但还有更多类型。
对于这个简单的例子,我只说返回类型是整数。实际上,即使在SQL中,整数也有不同精度,而Python不区分,Python的整数实际上可以有任意精度。在SQL中,不同的整数可能有不同的精度或范围。
无论如何,任何Python函数都可以用作UDF。在这个UDF被定义创建之后,你就可以在你的数据框API中使用它。例如,然后你可以使用strlen。注意,这个UDF函数返回一个UDF,所以你只能在select内部使用它,并且这个UDF的参数必须是一个列。这就是为什么我们使用df.country,这是国家列。你不能只说strlen('country'),这是一个字符串,不是一个列。在select内部使用UDF或任何标准Spark SQL函数时,输入必须是一个列。
这给出了预期的结果,strlen是国家名称的长度加2。如果你想在嵌入式SQL语句中使用你的UDF,你必须先注册它,类似于我们使用spark.udf.register。然后这个字符串给出SQL中UDF的名称。然后我可以在sql方法内写任何使用你的UDF的标准SQL语句。
数据框的优势
我们有三个问题示例,实际上RDD也可以实现相同的功能。事实上,所有数据框程序最终都会被翻译成RDD程序。因此,就功能而言,数据框API并没有真正给你任何额外的东西。问题是,使用Spark SQL有什么好处?我们已经看到了一些,还有其他好处。
第一个是可读性。因为在RDD中,你没有列,你仍然可以有列,因为你可以将RDD定义为元组的RDD,然后每个元组可以有多个字段。然而,区别在于在RDD中,作为程序员,你必须记住每一列代表什么,列没有名称。在数据框中,每一列都有一个名称,每一列都有一个类型。这使得整个事情更易读。
第二个是列式存储。这实际上是另一个重要的优化。所有原始数据库,至少传统的那些,实际上使用基于行的存储,即如果你在内存或磁盘上存储表,它是逐行存储的,你先存储第一行的所有字段,然后存储第二行,等等。这种存储格式对于更新有好处,例如,如果你想更新某一行,你可以直接去那里更新该行的不同字段。然而,这种存储对于分析查询效率低下。因为,尽管这不是最初的设计,但在实践中,当人们使用关系数据库时,列数可能变得非常多。最初可能只有五六列,但一段时间后,人们意识到,哦,我还需要添加生日,然后一段时间后,人们意识到我需要添加电话号码。所以人们不断添加列,表可能变得非常宽。实际上,如果你与从业者、DBA交谈,他们经常看到有数百列的表,这是由于初始设计后添加了许多列。当你在这些宽表上执行分析查询时,这种基于行的存储可能非常低效,因为对于分析查询,通常我们只对少数几列感兴趣。虽然在示例中我们经常看到SELECT *,但在实践中,人们不这样做,只对他们感兴趣的少数几列进行投影。因此,列式存储可以高效得多。也就是说,在内部,当数据框被存储时,它们先存储所有行的第一列,然后是所有行的第二列。当查询只涉及少数几列时,这可以高效得多。然而,这对于更新效率低下,因为如果你想更新一行,你需要去很多地方,更新所有没有在内存或磁盘上概念性存储的列。但这是你必须做出的权衡,因为Spark SQL是为分析查询设计的,我们不进行更新。实际上,Spark SQL不允许你进行更新。这就是为什么采用列式存储对它们来说非常自然。
我们已经看到了许多优化的例子。无论如何,这是优化器的整个流水线。实际上,它们遵循与标准关系数据库引擎相同的流水线。它们实际上试图重现相同的东西,尽管仍在进行中,尚未完全完成。所以在最开始,我们看到整个流水线同时接受SQL和数据框API。这意味着尽管允许用户同时使用SQL和数据框API来指定他们的意图、指定他们的查询,但唯一的区别只在初始阶段。之后,它们合并了。所以无论你是使用SQL还是数据框编写查询,它们都会经过相同的流水线。也就是说,在第一步,解析器已经知道生成计划,无论你使用哪种形式。
这是初始的,称为未解析的逻辑计划。然后它们将经历整个阶段,我不会详细介绍,因为这真的和关系数据库一样。但无论如何,我们将向你展示它们正在进行的一些优化,以便你可以理解某些事情,或者在编写查询时可以少一些顾虑。
第一个是一个非常简单的转换。顺便说一下,这些优化中的大多数都称为基于规则的优化。也就是说,它们将迭代地应用这些规则,直到没有更多规则可以应用。所有这些在某种意义上都是静态的,即优化是在不查看数据的情况下完成的,它们只查看查询。
这是一个简单的例子,称为常量折叠。它的意思是,它会尝试计算这些只涉及常量的表达式。这实际上是一个例子,说一个常量加另一个常量,你可以通过先计算这个来重写。每当应用此规则时,此规则仅在编译时应用,即在查询实际执行之前。也就是说,它们只计算一次1 + 2。它们不必为每一行都做。所以如果你不这样做,你将不得不为每一行计算整个表达式,因为这里的x代表一个属性或列。这显然非常浪费。所以常量折叠是一个非常容易但非常有效的规则。这只是一条规则,一条常量折叠规则,你可以想象还有其他规则,如减法、乘法等。所有规则都在Spark SQL中。
第二个是另一个例子。第一个是,字面量加字面量不是字面量,所以3 + 3可以简化为6。另一个是,任何东西加0等于它本身。所以x + 0可以简化为x。当然,还有另一个。所以这个没有被使用。它们将尝试迭代地应用这些转换规则,直到达到一个固定点,即没有更多规则可以应用。
Spark SQL的设计方式是,因为Spark是开源的,所以人们可以贡献新规则。这就是为什么Spark SQL多年来不断改进,每次更新,通常都会添加一些新规则。

这是另一个例子。这个稍微复杂一些。但无论如何,这说明了SQL中使用的数据类型。在Python中,我们只有一种整数,但在SQL中,可以有不同类型的整数。例如,它们有长整数,位数等等。这里的意图是,在Spark中,有一种数据类型叫做Decimal。这种十进制类型可以支持任意精度,其中精度是用户可以控制的参数。如果你想要一个由超过
011:机器学习与MLlib



概述
在本节课中,我们将学习Spark中的机器学习库MLlib。我们将了解其基本概念、核心组件以及如何使用它来构建一个完整的机器学习流程。
MLlib简介
Spark拥有两个版本的机器学习库。一个叫做MLlib,基于RDD。另一个简称为ML,基于DataFrame。实际上,从Spark 2.0开始,主要的机器学习API已经是基于DataFrame的ML库。基于RDD的API已进入维护模式,并预计在Spark 3.0中被移除。因此,建议仅使用基于DataFrame的API。
ML库构建在Spark SQL之上,而非直接基于RDD,这带来了许多之前从Spark SQL中看到的好处和特性。
机器学习流程概述
这不是一门机器学习课程,但为了建立共同的理解,让我们快速浏览一下典型的机器学习流程。
广义上讲,机器学习算法分为两大类:监督学习和无监督学习。
在监督学习中,数据带有标签。监督学习的例子包括预测、分类和回归等。它们通常遵循以下流程:我们有原始数据(如文本文档、图像等)及其标签,需要将它们转换为特征向量。因为所有机器学习算法都不能直接处理不同类型的数据,它们只处理向量,即特征向量。然后通过机器学习算法产生一个模型,这是训练的结果。在测试阶段,我们有一个新的文本、文档或图像,它将经历相同的过程,转换为特征向量,然后输入到模型中,模型会给出预期的标签。
在无监督学习中,流程大体相同,只是我们没有标签。其他步骤保持不变,即原始数据(文本文档)被转换为特征向量,然后通过机器学习算法产生模型,测试阶段的流程也相同。
实际上,Spark中的机器学习库在接口上与Python中流行的机器学习库Scikit-learn非常相似。它借鉴了Scikit-learn的许多特性,例如转换器、估计器等类型。如果你熟悉Scikit-learn,那么使用Spark的ML库会非常直接,同时还能享受到Spark的可扩展性优势。
MLlib的核心概念
ML库中有两种核心对象:转换器和估计器。
转换器将一个DataFrame转换为另一个DataFrame。这实际上与DataFrame或RDD中的转换操作相同。转换器的例子包括模型。模型将一个DataFrame转换为另一个DataFrame,具体来说,输入DataFrame必须包含ID和特征向量,输出DataFrame将包含一个名为“标签”的列。这用于测试阶段。另一个转换器是特征转换器,用于从原始数据到特征向量的转换步骤。在这种情况下,输入DataFrame可能包含文本等,输出通常是特征向量以及一个ID。
估计器的输入是一个DataFrame,输出是一个模型。例如,训练算法就是一个估计器。估计器的输出不是DataFrame,而是一个模型对象。模型本身主要由系数组成,例如神经网络中的系数或线性模型中的系数等,具体取决于不同的机器学习模型。
一个转换器必须实现一个名为transform的方法。通常,ML库已经提供了许多转换器,你可以直接使用。如果你想自己定义一个新的转换器,则必须实现transform方法。同样,对于估计器,库已经将许多训练算法实现为估计器。如果你想自己实现一个新的训练算法,则需要实现一个名为fit的方法,在其中实现训练算法。
转换器和估计器通常都有很多参数。有些人甚至声称机器学习是调整参数的艺术。为了使参数设置更方便,ML库将参数定义为一个对象,你可以为转换器和估计器设置参数。
逻辑回归示例
现在,让我们通过一个逻辑回归训练过程的例子来具体说明。你应该在机器学习课程中学过逻辑回归。为了本课程的目的,我们只需要知道这是一个非线性函数,它将输入数据映射到0到1之间的一个数。我们将任何大于0.5的值解释为概率大于0.5,并返回1,否则返回0。
训练流程如下:原始数据只是一个字符串,存储在DataFrame中,但这个DataFrame只有一个字符串列。第一步是特征提取,将原始数据转换为特征向量。这一步我们使用分词器。分词器将字符串分解为单词列表。这是第一步,实际上与split操作类似。在ML库中,分词器被定义为一个转换器,它将一个DataFrame转换为另一个DataFrame。
第二步是哈希转换。它对每个单词应用哈希函数,将每个字符串单词转换为整数。每个唯一的单词对应一个维度,因此这是一个非常高维的特征向量。向量的值是单词的频率,即该单词出现的次数。这也已经实现,称为HashingTF,是另一个转换器。
然后,从特征向量到模型,这是训练算法,是一个估计器。

一般来说,任何流水线都采用以下形式:初始数据在DataFrame中,然后经过一系列转换器列表,最后一步是估计器。估计器将返回一个模型。模型本身也是一个转换器。
当你组装整个流程时,整个流水线的输入是DataFrame,输出是一个模型(转换器),因此整个流水线可以被视为一个估计器。一旦模型被训练好,它实际上由相同的流水线步骤组成,只是最后一步被训练好的模型所取代。也就是说,所有的分词器、HashingTF都保持不变,它们仍然是转换器。最后一步的训练算法(估计器)将被训练好的模型取代。模型本身仍然是一个转换器,因为它以DataFrame作为输入并产生预测,这是另一个DataFrame。现在,流水线是转换器、转换器、转换器,每一步都是转换器。因此,整个流水线仍然是一个转换器,因为输入是DataFrame,输出也是DataFrame。在训练流水线中用训练模型(一个转换器)替换估计器。
分步示例
首先,我们看一个不使用原始数据的简单示例,假设数据已经是向量形式。我们先孤立地看每一步,然后将所有步骤组装在一起形成训练流水线和测试流水线。
向量在库中定义。密集向量就是数组,与NumPy数组相同。稀疏向量则实现为键值对字典。
以下是如何创建一个包含两列(标签和特征)的DataFrame。到目前为止,我们还没有真正使用机器学习库,只是使用了向量类型。
然后,我们创建一个逻辑回归实例。这个实例是一个估计器。任何训练算法以及任何模型都有很多参数。你可以在创建时指定参数,也可以稍后指定。这里展示的是在构造时设置参数的例子。每个参数实际上也有默认值,如果你不指定,它们将采用默认值。当然,不同的模型、不同的训练算法可能有不同的参数。对于逻辑回归,两个最重要的参数是最大迭代次数和正则化参数。
除了这两个,训练算法还有许多其他参数。你可以使用explainParams方法打印出所有参数及其默认值和文档。
现在,我们可以通过直接调用fit方法来训练模型。每个估计器、每个训练算法都必须实现这个fit方法。训练数据是这个DataFrame,它必须至少包含两列:标签和特征。你可以更改这些列的名称,它们在参数列表中。例如,有一个参数叫做featuresCol,这是特征列的名称,默认是“features”。如果你使用默认值,那么你的输入DataFrame必须有一个名为“features”的列。它还必须有一个名为“label”的列,除非你告诉训练算法你的数据有不同的列名。
在这个例子中,我们的数据有预期的列名,所以我们可以直接调用fit,这将返回一个模型。然后我们可以打印它,看到一些基本信息:我们有三个特征,两个类别(因为是逻辑回归),系数存储在这个coefficients成员中。你也可以打印它们,这是一个系数列表,因为我们有三个特征,所以有三个系数。
为了使事情更方便,你也可以使用Python字典来指定参数。这样,如果你经过几天的反复试验,找到了一组非常好的参数,你可以将其保存为Python字典,以后加载它,这样就不必手动记住所有你花费大量时间调整的参数。
你也可以更改输出列名。对于逻辑回归,输出是一个概率,默认情况下,它会在输出中添加一个名为“probability”的列。如果你不喜欢那个列名,可以将其更改为其他名称。这也是训练算法中的一个参数。
现在,你可以通过告诉fit方法使用这些新参数来再次训练,结果可能会略有不同,因为迭代次数和正则化参数都不同,你可能会看到不同的训练结果和系数。
接下来是测试阶段。准备一些测试数据,测试数据必须至少有两列:ID和特征。ID必须是唯一的。
现在,你可以在训练好的模型上调用transform方法。我们训练了两个模型,它们使用略有不同的参数进行训练。你可以将训练好的模型应用于相同的测试数据,这会给出略有不同的结果。虽然概率略有不同,但预测结果可能相同。
对于逻辑回归,它实际上返回一个具有三个新列的DataFrame:原始预测、概率和预测。原始预测实际上是使用系数进行线性变换的结果。概率是归一化后的值,总和为1。这是通过将原始预测取指数然后归一化得到的,是一个介于0和1之间的数。
这两个模型之间的另一个区别是列名不同,因为模型2是使用不同的参数训练的。模型知道输出列应该叫做“myProbability”而不是“probability”。你也可以更改这些名称。
组装完整流水线
这个例子只是一个步骤,我们假设数据已经是特征向量形式,这在实践中并非如此。因此我们需要流水线。流水线可以使事情非常方便。当然,你也不一定非要使用流水线,你总是可以手动逐步执行分词器、HashingTF和训练,但将它们组装到流水线中可以使事情变得容易得多,因为正如我们所看到的,训练流水线和训练后的流水线非常相似,只是最后一步不同。
现在让我们组装流水线。首先准备一些训练数据。数据有三列:ID、文本和标签。这里的文本只是字符串。我们需要将这些原始数据转换为特征向量。我们需要做两个步骤:分词器和HashingTF。两者都由机器学习库提供。
当你初始化任何转换器的实例时,记得指定输入和输出列。这就是你组装流水线的方式。对于这个分词器,它应该寻找名为“text”的列作为输入列,这确实存在。输出列应该叫做“words”。
我们可以检查每个步骤是否按预期工作。如果我们对训练数据调用transform方法,它将读取名为“text”的列并生成一个名为“words”的新列。它的作用只是将每个字符串分割成单词列表。实际上,这也可以通过直接使用DataFrame API来实现,即执行select和split操作。但使用分词器的好处是它可以被组装到流水线中,而直接使用DataFrame API则不行。
流水线中的第二步是哈希转换器。同样,当你创建这个转换器的实例时,你需要告诉ML库哪一列是输入列,哪一列是输出列。对于输入列,为了使事情更通用,我说输入列是“tokens”。输出列是“features”,因为这将在分词器之后立即调用。
我仍然可以通过手动调用这两个步骤来检查。首先进行分词,然后进行HashingTF。结果将有两列:“words”是分词器的结果,“features”是HashingTF的结果。HashingTF将返回一列,其类型是稀疏向量。稀疏向量具有以下形式:第一个是维度数,然后是非零维度的索引。也就是说,这些维度是非零的,其他所有维度都是零。这是一个非常稀疏的向量。实际上,这些只是这些单词的哈希值。你可以看到A、B、C、D有五个不同的单词,所以我们有五个非零维度。“spark”出现了两次,所以在这个维度上我们有一个值2,其他维度我们只有值1。
我可以获取这个结果的第一行,验证这确实是一个稀疏向量。当你使用show时,它会以这种漂亮的形式打印稀疏向量。但在底层,稀疏向量实际上就是一个字典,只是它还有额外的属性,比如维度数。
现在我们已经准备好组装整个流水线了。我们创建一个估计器,即训练算法,这实际上是流水线中的最后一个阶段。
要组装流水线,我们只需通过stages参数告诉它各个阶段是什么。这个列表的成员就是你之前创建的实例。需要注意的是,最后一个必须是估计器,而前面的阶段必须是转换器。回想一下,整个流水线仍然可以被视为一个估计器,因为它接受一个DataFrame并最终产生一个模型。事实上,只要最后一个是估计器,整个流水线就是估计器。
Spark ML将按顺序调用这些阶段。为什么这个流水线会按预期工作?正是因为你定义了分词器的输入和输出列。分词器的输入列是“text”,它存在于你的训练数据中。分词器的输出列将是HashingTF的输入列,而HashingTF的输出列将是训练算法的输入列。这就是它们如何组装以及如何按预期工作的。
然后,我们可以通过调用整个流水线的fit方法来训练。在示例中,我使用了两个标准转换,但你也可以创建自定义转换。当你创建自定义转换时,你必须定义自己的transform方法。也就是说,流水线将依次调用每个阶段的transform方法,然后调用最后一个阶段的fit方法。因此,每个阶段都必须有一个定义良好的transform方法。同样,如果你想实现自己的训练算法,你必须定义一个新的类,该类实现fit方法。
现在准备一些未标记的测试文档,包含ID和文本。
流水线的好处是,你不必显式调用transform方法。你只需调用整个流水线的fit方法,流水线将代表你调用这些阶段的transform方法。同样,在测试阶段,你只需调用整个流水线的transform方法。现在这个模型是一个流水线模型。因为这个模型是整个流水线fit方法的结果,所以它将继承所有阶段,所有转换阶段。这些阶段也包含在训练模型中。当你调用model.transform时,它将经历相同的阶段,除了最后一个。在最后一个阶段,它将调用训练好的逻辑回归模型。这就是我们在这里讨论的内容。
这是训练流水线。之前的阶段都被保留,只有最后一个阶段(训练算法)将被训练好的模型取代。好处很明显,你不必再次编写所有之前的阶段,因为它们在训练流水线和训练后的流水线中都保持不变。
让我们看一些例子。当你调用model.transform处理测试数据时,实际上可以看到所有这些列:words是分词器添加的列,features是HashingTF添加的列,其他列是由训练好的模型添加的。
希望这个框架与Scikit-learn包相同。不同之处在于,在这个训练算法内部,Spark ML库有自己的实现,它是高度并行的。也就是说,每次迭代,因为这里我们讨论的是逻辑回归,它使用梯度下降法,并且它们并行计算所有梯度。与Python的机器学习库相比,这具有更高的可扩展性。但无论如何,接口非常相似。

总结
在本节课中,我们一起学习了Spark的机器学习库MLlib。我们了解了其基于DataFrame的API是当前的主流。我们介绍了机器学习的基本流程,包括监督学习和无监督学习。我们深入探讨了MLlib的两个核心概念:转换器和估计器,并通过逻辑回归的例子详细说明了它们的使用方法。我们还学习了如何将多个步骤组装成一个流水线,从而简化训练和预测过程。最后,我们看到了Spark ML在保持与Scikit-learn相似接口的同时,通过并行计算提供了更高的可扩展性。
012:示例 - 查找素数
概述
在本节课中,我们将学习一个经典的数学问题——查找素数,并探索如何使用Spark并行化一个古老的算法(埃拉托斯特尼筛法)。我们将重点关注算法的并行化策略、Spark中数据分区的重要性,以及如何通过优化分区来提升计算性能。
算法原理:埃拉托斯特尼筛法
上一节我们介绍了查找素数的问题,本节中我们来看看解决该问题的经典算法。
该算法用于生成小于等于整数N的所有素数。其核心思想是逐步筛选掉合数(非素数)。
以下是算法的基本步骤:
- 列出从2到N的所有整数。
- 选取当前列表中最小的数(初始为2),它是一个素数。
- 从列表中剔除这个素数的所有倍数(除了它本身)。
- 对剩余列表中新的最小数重复步骤2和3,直到处理完所有数。
- 最终列表中剩下的所有数都是素数。
算法的正确性显而易见:第一个数2是素数,其所有倍数都是合数,应被剔除。接着,剩余列表中最小的数3也是素数,剔除其所有倍数。以此类推,最终剩下的便是所有素数。
本质上,这个算法不是直接“找到”素数,而是通过剔除所有合数来间接得到素数。
并行化思路
这个算法很容易并行化,因为每个步骤(即剔除某个素数的所有倍数)可以独立进行。
然而,在顺序版本中,我们可以跳过已经被标记为合数的数字(例如4、6、9等),以最小化总工作量。但在并行版本中,为了实现最佳并行时间(即最短的挂钟时间),我们可能允许一些冗余工作。例如,即使4已经被作为2的倍数剔除,我们可能仍然会独立地剔除4的所有倍数。


这引出了并行算法中两个非常重要的概念:深度和工作量。
- 深度:指从输入到输出的最长路径长度,决定了并行计算所需的最短时间(延迟)。
- 工作量:指完成计算所需的总操作量。
通常,为了获得更小的深度(更快的并行时间),我们可能需要接受稍大的工作量。这是一种权衡。对于涉及大量数据的计算,我们通常更倾向于优化深度,以最小化并行时间。
Spark实现:基础版本
现在,让我们看看如何使用Spark实现这个并行化的筛法。假设我们要查找所有小于等于N的素数,这里设N为5,000,000。
首先,我们创建一个包含从2到N所有整数的RDD。参数8指定了初始分区数,可以根据你的系统核心数调整。
n = 5000000
all_numbers = sc.parallelize(range(2, n+1), 8).cache()
接下来,我们需要找出所有的合数。我们通过一个flatMap操作来实现:将每个数x映射为其所有倍数(不包括x本身)的列表。
composite_numbers = all_numbers.flatMap(lambda x: range(2*x, n+1, x))
flatMap操作后,我们得到了一个包含大量可能重复的合数的RDD。最后,我们从所有数字的集合中减去合数集合,得到素数。
prime_numbers = all_numbers.subtract(composite_numbers)
prime_numbers.take(10) # 触发计算并打印前10个结果
这个实现虽然逻辑正确,但性能存在严重问题。
性能分析与问题定位
运行上述代码后,通过Spark UI(通常位于4040端口)可以观察作业执行情况。整个作业耗时约27秒,主要时间(25秒)花在了subtract操作阶段。
深入查看subtract阶段的任务详情,会发现一个关键问题:负载极端不均衡。在16个并行任务中,其中一个任务的处理时间远远超过其他任务,成为了整个计算的瓶颈。这意味着在大部分时间里,可能只有一个CPU核心在忙碌工作,其他核心处于空闲状态,造成了巨大的资源浪费。
为什么会出现这种不平衡呢?我们需要检查各个RDD的分区情况。
以下是检查分区大小的方法:
def partition_size(iterator):
yield sum(1 for _ in iterator)
print(all_numbers.mapPartitions(partition_size).collect())
print(composite_numbers.mapPartitions(partition_size).collect())
检查结果如下:
all_numbersRDD:分区是完美均衡的。composite_numbersRDD:分区严重不均衡。第一个分区包含了绝大多数数据,而最后四个分区甚至没有数据。
原因分析:初始的all_numbers RDD中的数据是按顺序递增分布的。当进行flatMap操作时,每个数字x被扩展为从2*x到n,步长为x的序列。对于前半部分分区中的较小数字(如2, 3, 4...),它们生成的倍数序列很长,且大部分小于n。而对于后半部分分区中的较大数字,它们生成的倍数序列可能很短,甚至第一个倍数2*x就已经大于n,导致没有数据输出。因此,flatMap转换后,数据分布变得高度倾斜。
虽然flatMap本身是窄依赖,各个分区独立工作,不构成瓶颈,但它产生的不均衡RDD严重影响了后续的subtract操作。subtract是一个宽依赖操作,需要Shuffle。在Shuffle过程中,数据需要根据键的哈希值重新分布到不同的分区进行处理。由于composite_numbers RDD中第一个分区的数据量极大,负责处理该分区数据的任务就需要处理海量的Shuffle数据,从而成为整个作业的瓶颈。


性能优化:重分区
问题的根源在于composite_numbers RDD的分区不均衡。解决方案是在进行昂贵的subtract操作之前,对这个RDD进行重分区,使其数据分布变得均衡。
优化后的代码如下:
n = 5000000
all_numbers = sc.parallelize(range(2, n+1), 8).cache()
composite_numbers = all_numbers.flatMap(lambda x: range(2*x, n+1, x))
# 关键优化:对合数RDD进行重分区,使其均衡
composite_numbers = composite_numbers.repartition(8)
prime_numbers = all_numbers.subtract(composite_numbers)
prime_numbers.take(10)
通过增加一个repartition(8)步骤,我们强制Spark将composite_numbers的数据打散并均匀分配到8个新分区中。这样,在后续的subtract Shuffle阶段,每个任务处理的数据量就大致相同了。
优化效果:总运行时间从27秒降低到了约10秒。虽然我们增加了一个额外的重分区阶段(耗时约5秒),但subtract阶段的耗时从25秒大幅降至4秒。总体性能得到显著提升。
这再次体现了深度与工作量的权衡:我们做了更多的工作(重分区),但减少了关键路径的深度(均衡了Shuffle负载),从而获得了更短的总体执行时间。
Spark分区机制深入
上一节我们通过重分区解决了性能问题,本节中我们更深入地理解Spark的分区机制。
分区的基本性质
- RDD中的数据总是被划分为多个分区。
- 一个分区内的数据永远不会跨越多台机器,但一台机器可以持有多个分区。
- 建议设置的分区数量是集群工作节点核心总数的2-3倍。这既有利于动态负载均衡(快的节点可以领取更多任务),又能避免分区过多导致的管理开销。
- 默认分区数由集群配置决定,但从HDFS读取文件时,分区数通常等于文件的块数。
分区器
RDD是分区的,但它可能有一个与之关联的分区器,也可能没有。
- 分区器定义了数据如何被分配到各个分区的规则。
- 当RDD从数据源(如Python列表)创建时,通常没有分区器,数据被任意划分。
- 在执行了某些基于键的转换(如
reduceByKey,groupByKey,sortByKey)后,结果RDD会获得一个分区器(分别是哈希分区器或范围分区器)。 - 窄依赖操作(如
map,filter)通常会丢失父RDD的分区器,因为Spark无法确定你的转换是否改变了键。 - 特殊的
mapValues和flatMapValues操作会保留父RDD的分区器,因为它们明确表示不修改键。
哈希分区与Shuffle
默认的分区器是哈希分区器。对于键值对RDD,Spark使用键的哈希码来决定其所属分区。具体公式为:partition = hash(key) % numPartitions。
Shuffle操作(如reduceByKey, join, repartition)的核心就是基于分区器对数据进行重新分布。它分为两个阶段:
- Map阶段:每个任务根据目标分区数构建一个哈希表,将数据放入对应的槽位,然后每个槽位的数据被发送到对应的目标工作节点。
- Reduce阶段:每个工作节点接收来自所有Map任务的、属于自己负责的分区槽位的数据,然后进行合并、去重或聚合等操作。
分区器的重要性:避免不必要的Shuffle
保留正确的分区器可以带来巨大的性能提升,因为它能帮助Spark识别某些操作是否可以避免Shuffle。
一个关键概念是依赖类型:
- 窄依赖:父RDD的每个分区最多被子RDD的一个分区使用。操作如
map,filter,union。这些操作可以流水线化执行,无需等待。 - 宽依赖:父RDD的一个分区可能被子RDD的多个分区使用。操作如
groupByKey,reduceByKey(通常)。这些操作需要Shuffle,是所有任务必须同步的屏障。
协同分区:如果两个要进行join操作的RDD具有相同数量的分区,并且使用相同的分区器(对范围分区器,边界也必须相同),那么它们的连接就可以作为窄依赖执行,无需Shuffle!因为具有相同键的数据已经通过分区器保证位于相同机器的相同分区序号上。
示例对比:
假设rddA和rddB都是哈希分区,且分区数相同。
rddA.join(rddB):这是一个窄依赖,无需Shuffle。rddA.map(...).join(rddB):如果map可能改变键,分区器丢失,此连接变为宽依赖,需要Shuffle。rddA.mapValues(...).join(rddB):使用mapValues保留了分区器,此连接仍是窄依赖,无需Shuffle。
因此,在编写代码时,应优先使用mapValues、flatMapValues来保留分区器,这能显著减少作业中的Shuffle阶段数量,从而提升性能。
应用示例:优化PageRank
让我们回顾PageRank的例子,看看分区器的影响。
在原始的PageRank实现中,迭代部分使用了普通的map:
for i in range(10):
# ranks 是 (page_id, rank) 的键值对RDD
contributions = links.join(ranks).flatMap(...) # links 是 (page_id, [neighbors])
ranks = contributions.reduceByKey(...).map(lambda x: (x[0], 0.15 + 0.85 * x[1]))
在这个版本中,ranks RDD经过map操作后丢失了哈希分区器。因此,在下一轮迭代的links.join(ranks)操作中,Spark无法确认两者是协同分区的,会将其视为宽依赖,触发Shuffle。每次迭代因此产生两个Shuffle阶段(join和reduceByKey各一个)。
优化后的版本使用mapValues:
for i in range(10):
contributions = links.join(ranks).flatMap(...)
ranks = contributions.reduceByKey(...).mapValues(lambda rank: 0.15 + 0.85 * rank)
mapValues保留了ranks RDD的分区器。由于links RDD也是通过groupByKey获得的哈希分区RDD,且分区数相同,因此links.join(ranks)是一个窄依赖,无需Shuffle。每次迭代只剩下reduceByKey一个Shuffle阶段。
效果:优化后,作业的Stage数量减少近半,运行时间也相应显著缩短。
多表连接顺序优化
在涉及多个DataFrame/数据集连接时,连接顺序对性能有巨大影响,因为Spark目前不会自动优化连接顺序。
场景:有三个表需要连接。
waybills(分区键:waybill_id)customers(分区键:customer_id)waybill_status(分区键:waybill_id)
目标:将三张表连接起来。
方案一(次优):
result = waybills.join(customers, "customer_id").join(waybill_status, "waybill_id")
waybills.join(customers):waybills表需要根据customer_id进行Shuffle才能与customers表连接。结果表按customer_id分区。- 结果表再与
waybill_status连接:两者分区键不同(customer_idvswaybill_id),需要再次Shuffle。
总共需要两次Shuffle。
方案二(更优):
result = waybills.join(waybill_status, "waybill_id").join(customers, "customer_id")
waybills.join(waybill_status):两者都按waybill_id分区,是协同分区的,无需Shuffle。结果表仍按waybill_id分区。- 结果表再与
customers连接:分区键不同(waybill_idvscustomer_id),需要一次Shuffle。
总共只需要一次Shuffle。
因此,在编写多表连接时,应尽量将使用相同分区键的表优先连接,以减少不必要的Shuffle操作。
总结
本节课中我们一起学习了以下核心内容:
- 算法并行化:将埃拉托斯特尼筛法并行化,理解了深度与工作量的权衡。
- Spark性能调优:通过分析Spark UI定位到数据分区不均衡是性能瓶颈,并使用
repartition解决了问题。 - 分区机制:深入理解了RDD分区、分区器、哈希分区原理以及Shuffle过程。
- 依赖类型:区分了窄依赖和宽依赖,认识到Shuffle是性能关键点。
- 保留分区器:学会了使用
mapValues等操作保留分区器,从而避免不必要的Shuffle,并通过PageRank示例看到了显著的性能提升。 - 连接顺序优化:在多表连接场景中,通过合理安排连接顺序来最小化Shuffle次数。

掌握数据分区和Shuffle的原理,是编写高效Spark程序的关键。始终关注数据的分布,并利用分区器来优化依赖关系,才能充分发挥分布式计算的威力。
013:作业调度
在本节课中,我们将要学习Spark在多用户环境下的作业调度机制。我们将探讨Spark如何管理并发提交的多个作业,以及如何通过用户级线程实现同一应用程序内多个作业的并行执行。此外,我们还将了解Spark在单个作业内的任务调度策略和内存管理的基本原理。
Spark的多用户环境与作业定义


Spark被设计为多用户环境。在实际工作中,通常是一个集群由所有用户共享。每个用户都可以提交作业,集群资源在这些并发提交的多个作业之间共享。因此,我们需要讨论Spark如何调度这些同时提交的作业。
一个作业是任何需要运行任务以完成的动作,例如 save 或 collect。如果多个并行作业是从独立的线程提交的,它们可以同时运行。
然而,在我们目前看到的所有示例中,由于我们只是单一用户,即使提交了多个作业,这些作业也是按顺序运行的。让我们通过例子来理解这一点。
顺序执行与并行执行的对比
上一节我们介绍了作业的定义,本节中我们来看看不同程序结构如何影响作业的执行顺序。
以下是两个典型示例的对比:
- PageRank示例:其代码结构是一个大的线性计算图。循环内部没有动作,仅用于构建计算图。整个程序中唯一的动作是最后的
top操作。因此,PageRank程序只有一个作业。 - K-Means聚类示例:在循环内部包含一个动作(如
collect)。因此,这个程序包含多个作业,但每个作业的计算图相对较小。

关键在于,任何动作在Spark中都是阻塞的。一旦Spark遇到一个动作,它会阻塞驱动程序,直到该动作完成并返回结果。这意味着在同一个驱动程序内,作业之间没有并行性,所有作业都是按顺序一个接一个地执行。
驱动程序本身是一个标准的顺序程序(如Python程序),只是在执行每个动作时,其分区会并行计算。
默认调度策略与资源利用
既然作业在单用户程序内是顺序执行的,那么Spark默认如何处理多用户并发提交的作业呢?

Spark调度器默认以先进先出的方式运行作业。即使多个用户向同一个Spark集群提交作业,这些作业也会按提交顺序依次评估。在前一个作业完成之前,下一个作业无法开始。
默认情况下,一个作业会尝试使用尽可能多的处理器(只要有足够的分区),这意味着一个作业可能会占用所有工作节点。
这引出了下一个问题:如果你是唯一用户,但你有多个作业希望并行运行,而每个作业本身无法充分利用所有集群资源(例如,集群有100台机器,每个作业只能利用10台),该怎么办?这就引入了用户级线程的概念。
使用用户级线程实现作业并行
上一节我们提到了资源利用的问题,本节中我们来看看如何通过用户级线程让同一用户的多个作业并行执行。
以下是通过Python多线程库实现并行提交作业的示例代码框架:
import threading
def do_job(seed):
# 这是一个标准的Spark作业,包含一个动作(如reduce)
rdd = sc.parallelize(range(1000), numSlices=12)
result = rdd.mapPartitionsWithIndex(your_function(seed)).reduce(...)
return result
# 创建线程列表
threads = []
for i in range(5):
t = threading.Thread(target=do_job, args=(i,))
threads.append(t)
t.start() # start() 是非阻塞的,会立即在后台启动线程
# 主程序等待所有线程完成
for t in threads:
t.join()
以下是上述代码的关键点说明:
threading.Thread:用于创建线程对象,将作业函数do_job作为目标。t.start():启动线程。此调用是非阻塞的,主程序可以继续执行,从而几乎同时启动所有作业线程。t.join():主程序调用此方法等待指定线程完成。这确保了主程序会等待所有并行作业执行完毕。
通过这种方式,多个作业得以几乎同时提交到集群。
并行作业的资源调度实践
我们通过一个计算圆周率的示例来观察并行作业的调度效果。我们创建5个相同的作业(仅随机种子不同),每个作业是一个独立的Spark任务。
场景一:每个作业使用全部48个分区
- 所有5个作业几乎同时提交。
- 但由于Spark默认的FIFO调度以及每个作业都请求所有资源,实际上只有第一个作业能占用全部48个工作节点并执行。
- 其余作业必须等待前一个作业完成后才能开始。因此,作业是顺序完成的。

场景二:每个作业仅使用12个分区
- 所有5个作业同样几乎同时提交。
- 由于每个作业只需12个资源,而总资源为48个,因此前4个作业可以并行执行。
- 第5个作业需要等待前4个中有作业释放资源后才能开始。
- 由于某些工作节点可能提前完成任务,它们可以更早地开始处理第5个作业,因此第5个作业的持续时间可能略短。
这个实验说明了通过控制每个作业的分区数(即资源需求),可以在同一应用程序内实现多个作业的并行,从而提高集群资源利用率。
作业内部的任务调度
现在让我们看看Spark如何在单个作业内部进行任务调度。
一个作业可以包含多个阶段。一个阶段只有在它的所有父阶段都完成后才能开始。这就是为什么最小化阶段数量对于减少等待时间很重要。
每个阶段包含多个任务,每个分区对应一个任务。每个分区内部可能有一系列转换操作形成的流水线。
Spark基于数据本地性将任务分配给机器,其原则是“移动计算而非移动数据”。它考虑不同级别的本地性优先级,包括:
PROCESS_LOCAL:数据在同一JVM进程中。NODE_LOCAL:数据在同一节点上。RACK_LOCAL:数据在同一机架内的服务器上。ANY:数据可能在网络中的任何位置。
在异构集群中,不同节点速度可能不同。Spark需要决定是否将慢速节点上未完成的任务重新分配给已空闲的快速节点。这涉及到数据通信成本与计算延迟的权衡。Spark会设置一个等待计时器,并依据一套复杂标准来决定是否进行任务重分配。通常,为了最小化通信开销,如果慢速节点只剩少量任务,Spark可能会选择等待而不是立即重分配。这些参数可以进行配置和优化。
Spark内存管理概述
最后,我们快速概述一下Spark的内存管理,因为内存是非常宝贵的资源。
Spark主要使用两种类型的内存:
- 执行内存:主要用于Shuffle操作。Shuffle需要在Map端和Reduce端构建哈希表,这可能消耗大量内存。
- 存储内存:主要用于缓存RDD。当你将一个RDD标记为
persist()时,它会被存入存储内存。
两者之间的管理策略是:执行内存可以驱逐存储内存(如果需要)。这意味着即使你缓存了一个RDD,它也不能保证始终驻留在内存中,可能会被换出到磁盘。但存储内存不能驱逐执行内存。
默认参数保证存储内存至少占用总内存的一半。不使用缓存的应用可以将全部内存用于执行,这也是为什么默认情况下缓存是关闭的。作为程序员,你需要明智地决定哪些RDD值得缓存。不必要的缓存会占用本可用于高效执行的内存。
总结

本节课中我们一起学习了Spark的作业调度机制。我们了解到:
- 在单用户顺序程序中,作业是串行执行的。
- Spark默认采用FIFO策略调度多用户提交的作业。
- 通过用户级线程(如Python的
threading)可以实现在同一应用程序内并行提交多个作业。 - 通过控制每个作业的分区数,可以影响并行作业对集群资源的利用方式。
- 在作业内部,Spark基于数据本地性进行任务调度,并在异构环境中权衡计算与通信成本。
- Spark的内存分为执行内存和存储内存,执行内存拥有更高优先级,缓存策略需要根据应用需求谨慎选择。
014:并行算法设计
在本节课中,我们将要学习如何为Spark这样的分布式系统设计并行算法。我们将探讨三种主要的技术:分治算法、图算法和流式算法。这些技术针对不同类型的数据和处理需求,是解决复杂并行问题的关键。
分治算法
上一节我们介绍了并行算法的背景,本节中我们来看看最基础的设计范式之一:分治算法。
分治是一种经典的算法设计技术,其核心思想是将一个大问题递归地分解为多个独立的子问题,分别解决,然后再将结果合并。在单机环境中,经典的例子是归并排序和快速排序。
然而,在像Spark这样拥有众多处理器(P个)的大数据系统中,传统的二分分治(如归并排序)效率不高。因为合并步骤(如合并两个有序序列)通常是顺序执行的,这会导致大量处理器闲置。
为了充分利用所有P个处理器,我们需要采用多路分治。这意味着在分解步骤,我们直接将问题划分为P个子问题,让每个处理器处理一个。挑战在于如何高效地合并P个子问题的结果。
以下是一些简单的多路分治例子:
- 求和与归约:Spark内部的
sum()和reduce()操作就是多路分治的完美体现。每个分区先计算本地和,然后将所有分区的部分和发送给驱动节点进行最终求和。- 代码示例:
rdd.sum()或rdd.reduce(lambda a, b: a + b)
- 代码示例:
前缀和问题
现在,我们来看一个不那么直观的例子:前缀和问题。给定一个数组 X = [x1, x2, ..., xn],我们需要计算输出数组 Y,其中 yi = x1 + x2 + ... + xi。
在顺序编程中,这是一个简单的线性时间算法。但在并行环境下,由于每个 yi 的计算都依赖于前一个结果,这似乎是一个固有的顺序问题。
我们可以通过一个两阶段的多路分治算法来并行化它:
- 阶段一:计算分区和与分区级前缀和
- 每个分区独立计算其内部所有元素的和(分区和)。
- 驱动节点收集所有分区和,并顺序计算这些分区和的前缀和。假设有P个分区,这个计算量很小(O(P))。
- 阶段二:并行计算最终前缀和
- 每个分区
i在计算其内部元素的前缀和时,起始值不是0,而是驱动节点传来的、前i-1个分区的总和(即分区级前缀和)。 - 这样,所有分区可以同时开始计算,互不依赖。
- 每个分区
这个算法虽然总计算量大约是顺序算法的两倍,但通过高度并行化,在大规模集群上能获得显著的加速。
以下是如何在Spark中实现前缀和算法:
# 假设 rdd 是输入的RDD
# 第一阶段:计算每个分区的和
partition_sums = rdd.mapPartitions(lambda iter: [sum(iter)]).collect()
# 在驱动程序中计算分区级前缀和
prefix_sums_at_partition_level = []
current_sum = 0
for s in partition_sums:
prefix_sums_at_partition_level.append(current_sum)
current_sum += s
# prefix_sums_at_partition_level 现在存储了每个分区应开始的偏移量

# 第二阶段:并行计算每个分区内的前缀和,并加上偏移量
def add_prefix(iter):
# 这里需要一个闭包或广播变量来获取 prefix_sums_at_partition_level
# 为简化,假设 offset 已通过其他方式传入
pass
# 具体实现需使用 mapPartitionsWithIndex
final_result_rdd = rdd.mapPartitionsWithIndex(lambda idx, iter: compute_local_prefix(idx, iter, prefix_sums_at_partition_level))
分治思想的应用
前缀和问题看似是人为设计的,但它实际上是许多并行算法的基石。以下是几个应用实例:
- 分配连续ID(
zipWithIndex):为RDD中的每个元素分配一个连续的、唯一的ID(0, 1, 2...)。这等价于输入全为1的数组进行前缀和计算。 - 查找首次出现:在大型数据集中查找某个关键词首次出现的位置。可以在每个分区内并行查找,然后由驱动节点汇总各分区的结果(是否找到及位置),并计算全局位置。
- 字符串字典序比较:比较两个超长字符串。先分区并行比较,驱动节点汇总结果,找到第一个不相等的分区即可判定大小。
- 单调性检查:检查一个序列是否单调递增或递减。每个分区先检查内部单调性并记录边界值,然后驱动节点检查相邻分区边界值是否满足单调条件。


以单调性检查为例,其Spark实现的核心思路如下:
def check_partition(iter):
it = list(iter) # 转换为列表以便获取首尾元素
if not it:
return (True, None, None) # 空分区
first = it[0]
last = it[-1]
is_monotonic = all(it[i] <= it[i+1] for i in range(len(it)-1)) # 检查递增
return (is_monotonic, first, last)
# 收集各分区结果
results = rdd.mapPartitions(check_partition).collect()
# 在驱动程序中合并结果
global_is_monotonic = True
for i in range(len(results)):
local_ok, first, last = results[i]
if not local_ok:
global_is_monotonic = False
break
if i > 0:
prev_ok, prev_first, prev_last = results[i-1]
if prev_last > first: # 检查分区间的边界
global_is_monotonic = False
break
图算法与流式算法
上一节我们深入探讨了分治算法,本节中我们简要介绍另外两类算法。
对于图数据,其结构(顶点和边紧密相连)使得简单的数据分区变得困难。因此,需要专门的图计算模型和库,例如Spark的GraphX或更高效的第三方库GraphFrames。这些工具提供了针对图遍历、PageRank等算法的优化抽象。
对于流式数据,数据以连续、无界的序列形式到达。我们需要在单次遍历中处理数据,并持续更新结果。Spark Streaming(以及其后的Structured Streaming)是Spark的流处理组件,它采用“微批次”模型,将流数据切成小批次,然后利用Spark核心引擎进行处理。
总结

本节课中我们一起学习了为Spark设计并行算法的核心思想。我们首先理解了在大数据环境下并行与分布式算法的混合模型特点。然后,我们重点掌握了多路分治这一关键技术,并通过前缀和这个经典问题及其多种应用(如分配ID、单调性检查),详细剖析了如何将看似顺序的问题转化为高效的并行计算。最后,我们概述了处理图数据和流式数据所需的特殊算法和工具。掌握这些基础技术,是构建复杂、高效大数据应用的重要一步。
015:样本排序算法详解
在本节课中,我们将要学习Spark中用于对RDD进行排序的核心算法——样本排序。这是一种基于采样思想的并行排序算法,旨在高效地处理大规模数据集。
排序算法概述
上一节我们介绍了Spark的内部机制,本节中我们来看看如何对一个RDD进行排序。排序是一个非常基础的算法。正如之前提到的,归并排序并不容易并行化,因为它需要使用多路分治策略。然而,我们将看到快速排序更容易并行化。
如果你还记得快速排序的工作原理:我们随机选取一个元素作为分割点,然后将每个元素与分割点进行比较,将数组分成两部分。小于分割点的元素放入左子问题,大于分割点的元素放入右子问题。接着,我们递归地对这两个子数组进行排序。这就是快速排序的工作方式。
与归并排序相比,快速排序的划分步骤并不总是完美平衡的。在归并排序中,划分步骤总是完美的,因为我们不关心划分时的顺序,稍后会合并它们。然而,在快速排序中,划分是随机的。如果你运气不好,分割点恰好是最小的元素,那么划分就会非常不平衡:左边没有元素,所有元素都在右边。不过,有很好的分析表明,在平均或期望情况下,算法的总成本仍然是 O(n log n)。
直观地说,这意味着如果你的分割点不是太差,即使不是最好的(最好的分割点是中位数,能将数组平分为两半),但也不是太坏,例如将数组按25%-75%的比例划分,这只是一个平均质量的分割点。当然,这不是完美平衡的。然而,当我们继续进行这种划分时,总的递归层数仍然是 log n,尽管不是以2为底,而是以4/3为底的对数。因此,你只会比完美平衡多出一些常数倍的层数。这超出了本课程的范围,但这是快速排序在平均情况下仍然表现良好的直观原因,前提是分割点是随机选择的。
然而,快速排序在平均情况下表现良好的事实,关键依赖于我们会多次执行这个过程:我们选取分割点将数组分成两半,然后递归地解决问题,直到只剩下一个元素。这依赖于我们多次执行此操作、选取多个分割点的事实。
多路分治与理想分割点
对于多路分治,我们不能简单地只选取一个分割点。理想情况下,假设我们神奇地可以选取 P-1 个完美的分割点。这里的“完美分割点”指的是,在使用这 P-1 个分割点后,我们可以将RDD分割成,例如,五个大小相等的部分。
假设我们神奇地拥有了这四个分割点。那么我们可以非常容易地对RDD进行排序,方法如下:
- 首先,我将这四个分割点广播给所有工作节点。
- 每个工作节点将比较其分区中的每个元素与这些分割点。例如,如果一个元素落在第一个和第二个分割点之间,那么工作节点就知道这个元素在排序后的RDD中应该属于分区1。如果一个元素大于所有四个分割点,那么它应该属于分区4。
- 本质上,通过将一个元素与分割点进行比较,工作节点就知道了它的目标目的地。
- 在为每个元素找到目标分区后,就只是一个洗牌操作。我们使用洗牌操作将每个元素路由到其目标分区。
- 因为我们假设这四个神奇的分割点是完美的,即目标RDD中的每个分区大小相等,所以这会得到一个平衡的分区。每个工作节点将拥有相同的工作量。
- 在洗牌之后,每个工作节点只需在其分区内部对元素进行排序,然后整个RDD就排序完成了。
本质上,这是快速排序的一个非常简化的版本,我们只有一层递归。在快速排序中,我们有多层递归;而在这里,我们假设有这些神奇的分割点,只需一层分治就可以对整个数组进行排序。
寻找分割点的挑战

当然,关键问题是如何找到这些神奇的分割点。我们希望它们能将数组R分割成相等的部分。本质上,我们希望第一个分割点是第20百分位数,第二个是第40百分位数,第三个是第60百分位数,第四个是第80百分位数。

我们如何找到这些百分位数?当然,我们可以通过排序来找到,但这又回到了最初的问题,我们并没有取得任何进展。所以,我们必须在不排序的情况下找到这些百分位数。这并不容易,实际上并不比排序本身简单。看起来我们又回到了起点,并没有真正取得进展。
然而,关键的观察是:我们并不需要完美的分割点。分割点稍微偏离一点是可以接受的。例如,第一个是18%,第二个是41%,第三个是61%,第四个是78%,稍微偏离一点。那么,如果它们稍微偏离,会有什么后果呢?没什么太严重的。最终,我们得到的RDD分区不是完美平衡的,但只要不是太不平衡,这也没关系。
这实际上就是我们上次在讨论Spark内部机制时看到的情况。我们讨论了哈希分区和范围分区。对于哈希分区,我们确切知道它是如何工作的:它使用哈希码对K取模。Spark严格遵循这个规则,我们确切知道任何给定分区会包含什么样的数据项。然而,对于范围分区,如果你还记得在sortByKey操作之后,你可以看到分区可能并不完美平衡。例如,有10个元素和4个分区,第一个分区有3个元素,第二、三个各有2个,第四个有1个。这并不是完美平衡的,因为完美平衡的分区大小最多相差1。这里不平衡的原因正是Spark使用的排序算法没有找到完美的分割点。
采样排序的核心思想
既然我们知道完美平衡不是必需的,这意味着我们可以使用不同的方法来寻找分割点,这就是通过采样。这也是为什么这种排序算法被称为样本排序的原因。它实际上是快速排序的一种推广。
然而,简单的随机采样效果并不好。考虑一个具体的例子:假设我的RDD中有1000个元素,我想选取9个分割点将其分成10份。如果我简单地随机采样9个分割点,实际上,有很好的分析表明,以高概率,其中两个分割点会非常接近。观察这10个间隔,平均长度应该是100。然而,可以证明,以高概率,最小的间隔会非常小,同时最大的间隔会非常大。实际上有一个公式:假设总共有n个元素,P个分区,平均间隔是 n / P,这也是平均分区大小。然而,以高概率,最大间隔的数量级是 O((n / P) * log P),比平均值大一个对数因子。
例如,如果n是一百万,P是100,那么log P大约是7或8。这意味着将有一个工作节点的负载是平均值的8倍,这将成为你计算的瓶颈。当分区数量更多时,情况会更糟。
为什么会有这个结果?这实际上源于一个经典的概率问题,称为球与箱子问题。假设我们有n个球和n个箱子。我把这n个球扔进n个箱子。我们知道,平均每个箱子有一个球。然而,以高概率,最大的箱子会有 log n 个球。更精确地说是 log n / log log n,但log log n非常小,我们通常忽略它。所以,如果你把n个球随机扔进n个箱子,很可能最大的箱子有log n个球,而平均值只有1。这通常被用作一个经典论据,说明在进行负载均衡时,最简单的随机分配策略并不公平。很可能一个工作节点的工作量远大于平均值。这证明了需要更复杂的负载均衡策略,不能简单地随机分配。轮询调度就好得多。
然而,在这里我们不能使用轮询调度,因为我们没有时间去找分割点。因此,随机选取P-1个分割点并不能得到良好的平衡结果。那么,人们怎么做呢?
改进的采样策略
这个想法实际上再次受到了快速排序的启发。在快速排序的文献中,最常用的版本是随机选取一个分割点。然而,在一些优化版本的快速排序中,他们不只随机选取一个分割点,而是选取三个,然后选择中间的那个作为分割点。这就是“三取一”技术。这会导致数组被更平衡地划分成两半。你可以想象为什么这样更好:生成三个0到1之间的随机数,然后返回中间的那个。中间那个随机数的分布看起来像一个钟形曲线,类似于正态分布,更集中在0.5附近。而如果只选一个,分布是均匀的,可能落在0到1之间的任何地方。本质上,同样的思想也解释了为什么“三取一”技术更好。在快速排序中,他们选取三个元素,使用中间的那个,丢弃最小和最大的。事实上,同样的思想也用在许多评分系统中,例如奥运会跳水比赛:有六名裁判,去掉两个最低分和两个最高分,然后取中间分数的平均值,这被认为更稳定,因为去除了异常值。
最后,回到排序问题。我们如何以更稳定的方式选择这些分割点呢?思路是采样更多。类似于快速排序,我们不只采样一个,而是采样三个,然后选中间的那个。在这里,如果你需要P-1个分割点,你就采样更多。这个对数因子实际上源于球与箱子问题:在标准的球与箱子问题中,如果你把n个球扔进n个箱子,最大的箱子会有log n个球,非常不平衡,因为平均值是1。然而,如果你将球的数量从n增加到 n log n,那么平均值就是log n。如果你把 n log n 个球随机扔进n个箱子,那么可以证明,以高概率,即使最大的箱子也只有大约 2 log n 个球。它仍然比平均值大,但只大一个常数因子2。
直观地说,这意味着如果你只有少量球(n个),随机扔到n个箱子里,结果很可能不平衡。然而,如果你有很多球(n log n个,是箱子数量的log n倍),随机分配后,分配结果很可能更平衡。这也回到了统计学中一些非常经典的理论,本质上与大数定律有关:当样本量增加时,许多统计特性会变得更稳定。本质上,这就是为什么我们要采样更多元素。
样本排序的完整步骤
现在,在采样了这么多元素之后(注意,这仍然是一个小数目,因为P只有几百,P乘以log P再乘以一个常数可能只有几千),我们从RDD中取出这个样本。然后,主节点对这个包含 P log P 个随机选取元素的样本进行排序。接着,主节点将样本完美地划分成P个分区。更精确地说,这意味着我们将选取样本中第 i * (样本大小 / P) 个元素作为分割点。因为我需要P个分区,所以将样本分成P份,每份有 (4 log P) / 2 个元素(这里4是一个示例常数)。也就是说,这些分割点将样本完美地平衡划分。我们希望,如果这些分割点能完美划分样本,那么它们也能较好地划分整个RDD(不一定完美,但接近)。
然后,这些被选作分割点,并被广播到所有机器。如前所述,每个工作节点将使用这些分割点对其分区内的元素进行划分,然后通过洗牌步骤将元素路由到目标分区。可以证明,以高概率,分区将是平衡的。更精确地说,每个分区最多包含平均大小的4倍(这只是一个最坏情况的上界,实际分布更平衡)。最后,我们对每个分区进行排序。
并行采样技术
最后,另一个技术问题是我们如何实际进行采样。如果我们只是从一个数组中采样,那很容易:如果有一个大小为N的数组,我只需生成一个随机索引然后直接访问。然而,这里我们没有数组,我们有一个已分区的RDD。
如果分区是平衡的,那么我们也可以很容易地进行采样。我们分两步进行:首先随机采样一个分区,然后要求该分区从其元素中随机返回一个元素。这假设每个分区都有大约 N / P 个元素。那么每个元素被采样的概率是 1 / P(即该分区被选中,并且该特定元素在该分区内被选中),采样将是均匀的。
如果分区不平衡,我们需要做更多工作。例如,如果初始RDD分区不平衡,假设有三个分区,大小分别为10、8和12。我们如何从这个RDD中采样元素?我需要先采样一个分区。然而,分区大小不同。我们应该做的是首先进行加权采样:首先计算整个RDD的大小(30),然后以 10/30 的概率选择分区0,以 8/30 的概率选择分区1,以 12/30 的概率选择分区2。我使用分区大小作为权重对分区进行加权采样。在下一步中,假设分区2被选中,那么我以相等的概率从该分区的12个元素中挑选一个。这将给我一个均匀的随机样本。
然而,这只能用于采样一个元素。对于排序,我们需要很多样本,我们需要 P log P 个元素的样本。我们不能一个一个地采样,这太慢了。思路是以批处理模式执行这两个步骤。我们首先决定需要采样分区 P log P 次,即我们需要这么多样本。但我们还不会立即执行第二步。也就是说,我们将所有样本的第一步批量处理在一起。本质上,我们为每个样本模拟第一步。
例如,再次使用这个例子,我有三个分区。假设我需要5个样本。我们不能一个一个地做,这太慢且是顺序的。我这样做:首先,主节点为所有五个样本模拟第一步。对于第一个样本,我需要决定它来自哪个分区,我仍然使用这些权重对分区进行采样。假设对于第一个样本,我决定它应该来自分区1。我记下1,但我还没有获取任何特定元素。然后我再次模拟这个过程:对于第二个样本,我决定它来自分区0;对于第三个,来自分区2;然后是0和1。也就是说,我首先决定我的五个样本应该由哪些分区贡献。在这个例子中,分区0将贡献2个,分区1将贡献2个,分区2将贡献1个。然后在下一步,我将要求我的工作节点为我获取这些样本。也就是说,工作节点0将负责从分区0中抽取2个样本,工作节点1负责从分区1中抽取2个样本,工作节点2负责从分区2中抽取1个样本。所有这些都可以并行完成。这就是我们如何能够高效地并行抽取大量样本。
总结

本节课中我们一起学习了Spark中使用的样本排序算法。我们从快速排序的并行化挑战讲起,引出了寻找平衡分割点的问题。通过分析,我们发现完美分割点并非必需,从而引入了采样思想。我们探讨了简单随机采样可能导致负载不平衡的问题,并借助“球与箱子”模型理解了其原因。接着,我们学习了通过增加采样数量(采样 P log P 个元素)并选取合适的分割点(如样本中的等分点)来获得近似平衡的分区。最后,我们介绍了在RDD这种分布式数据集上高效进行并行采样的批处理技术。样本排序算法巧妙地结合了采样、广播、洗牌和局部排序,实现了对大规模数据的高效并行排序。
016:最大子数组问题
在本节课中,我们将学习一个经典算法问题——最大子数组问题。我们将从暴力解法开始,逐步介绍分治算法,并最终展示如何在Spark上高效地实现一个并行解决方案。
概述
最大子数组问题的目标是:给定一个包含正数和负数的数组,找到一个连续子数组,使得其元素之和最大。这个问题在实际中有很多应用,例如分析公司多年来的净利润,以找出盈利最高的年份区间。
暴力解法
首先,我们来看最直观的暴力解法。该算法枚举所有可能的子数组区间,计算每个区间的和,然后找出最大值。
以下是暴力解法的核心思路:
- 枚举所有可能的起始索引
i和结束索引j(其中i <= j)。 - 对于每个区间
[i, j],计算其元素之和。 - 记录遇到的最大和。
这个算法的时间复杂度是 O(n³),因为存在 O(n²) 个区间,而计算每个区间的和需要 O(n) 时间。
我们可以通过前缀和进行一个小优化。首先计算所有以索引1开始的区间和(即前缀和),然后计算所有以索引2开始的区间和,依此类推。这样,计算每个新区间的和只需要常数时间,但总区间数仍是 O(n²),因此时间复杂度优化为 O(n²)。
二分分治算法
上一节我们介绍了暴力解法,本节中我们来看看如何使用分治策略更高效地解决这个问题。经典的二分分治算法步骤如下:
- 分解:将数组
A[low...high]从中间位置mid分成两个子数组A[low...mid]和A[mid+1...high]。 - 解决:递归地求解左右两个子数组中的最大子数组和。我们得到两个局部最优解。
- 合并:最大子数组可能跨越中点。这种情况下的子数组必然由左半部分的一个后缀和右半部分的一个前缀组成。因此,我们只需:
- 在左半部分
A[low...mid]中找到以mid结尾的最大后缀和。 - 在右半部分
A[mid+1...high]中找到以mid+1开头的最大前缀和。 - 将这两个和相加,即得到跨越中点的最大子数组和。
- 在左半部分
- 最终解:比较左半部分解、右半部分解和跨越中点的解,三者中的最大值即为整个数组的最大子数组和。
该算法的递归式为 T(n) = 2T(n/2) + O(n),根据主定理,其时间复杂度为 O(n log n)。
以下是该算法的伪代码描述:
function FindMaxCrossingSubarray(A, low, mid, high):
left-sum = -∞
sum = 0
for i = mid downto low:
sum = sum + A[i]
if sum > left-sum:
left-sum = sum
max-left = i
right-sum = -∞
sum = 0
for j = mid+1 to high:
sum = sum + A[j]
if sum > right-sum:
right-sum = sum
max-right = j
return (max-left, max-right, left-sum + right-sum)
function FindMaximumSubarray(A, low, high):
if high == low:
return (low, high, A[low]) // 只有一个元素
else:
mid = floor((low + high)/2)
(left-low, left-high, left-sum) = FindMaximumSubarray(A, low, mid)
(right-low, right-high, right-sum) = FindMaximumSubarray(A, mid+1, high)
(cross-low, cross-high, cross-sum) = FindMaxCrossingSubarray(A, low, mid, high)
if left-sum >= right-sum and left-sum >= cross-sum:
return (left-low, left-high, left-sum)
else if right-sum >= left-sum and right-sum >= cross-sum:
return (right-low, right-high, right-sum)
else:
return (cross-low, cross-high, cross-sum)
Kadane线性时间算法
虽然二分分治法将复杂度降到了 O(n log n),但存在一个更优的 O(n) 线性时间算法,称为Kadane算法。
该算法仅需遍历数组一次,核心思想是维护一个“当前子数组和”。遍历每个元素时,将其加到当前和中。如果当前和变为负数,则将其重置为0(因为一个负数的前缀和不可能对后续的最大子数组做出贡献)。在整个过程中,记录遇到的最大当前和。
以下是Kadane算法的伪代码:
function Kadane(A):
max_so_far = -∞
max_ending_here = 0
for i = 0 to n-1:
max_ending_here = max_ending_here + A[i]
if max_so_far < max_ending_here:
max_so_far = max_ending_here
if max_ending_here < 0:
max_ending_here = 0
return max_so_far
直观理解:将数组的前缀和想象成一条上下波动的曲线。最大子数组和对应的是这条曲线上某个“谷底”到后续某个“峰顶”的最大高度差。算法中“遇到负数则重置为0”的操作,相当于在遇到一个更低的“谷底”时,重新开始计算高度差,从而确保能找到全局最大的那个落差。
然而,Kadane算法是固有串行的,其中的重置操作(if max_ending_here < 0: max_ending_here = 0)难以直接并行化。
基于Spark的多路分治并行算法
上一节我们看到了高效的线性算法,但其串行特性限制了在分布式环境下的应用。本节我们将结合分治思想和Kadane算法,设计一个适合在Spark上运行的并行算法。
我们的目标是利用多路分治来减少递归层数,从而更高效地利用集群资源。假设我们将数据划分为 P 个分区(对应 P 个工作节点)。
算法步骤如下:
- 分区内求解:在每个分区上,直接运行串行的Kadane算法,找到该分区内的最大子数组和。这一步的时间复杂度为 O(n/P)。


-
计算分区辅助信息:为了处理跨越多个分区的子数组,我们需要为每个分区计算两个额外信息:
LM[i]:分区i中,以分区左边界开始的最大前缀和(即分区内的最大后缀和,但从右向左计算时概念上等同于从左开始的最大前缀和)。RM[i]:分区i中,以分区右边界结束的最大后缀和(即分区内的最大前缀和)。SUM[i]:分区i内所有元素的总和。
这些信息都可以在对每个分区的一次线性扫描中完成。
-
合并跨越分区的解:最大子数组可能跨越连续的几个分区,例如从分区
i开始,到分区j结束(i <= j)。对于任意一对(i, j),其候选的最大子数组和为:
LM[i] + SUM[i+1] + SUM[i+2] + ... + SUM[j-1] + RM[j]
其中,SUM[i+1] ... SUM[j-1]代表了被完全包含的中间分区的总和。
我们需要检查所有可能的(i, j)组合。虽然组合数量是 O(P²),但由于分区数P通常远小于数据总量n,这部分开销是可接受的。
以下是该算法在Spark中的实现框架:
# 假设 rdd 已经被划分为 P 个分区
# 1. 计算每个分区的总和 (SUM)
partition_sums = rdd.mapPartitions(lambda iter: [sum(iter)]).collect()
# 2. 在每个分区上计算内部最大和 (max_inside), LM, RM
def compute_partition_info(iterator):
# 将迭代器转换为列表以便多次遍历(或使用更高效的单次扫描方法)
data = list(iterator)
# 使用Kadane算法计算分区内最大子数组和 max_inside
max_inside = kadane(data)
# 计算 LM (本分区内从左边开始的最大前缀和)
lm = max_prefix_sum(data)
# 计算 RM (本分区内以右边结束的最大后缀和)
rm = max_suffix_sum(data)
return [(max_inside, lm, rm)]
partition_infos = rdd.mapPartitions(compute_partition_info).collect()
# 3. 收集所有分区信息后,在Driver端合并结果
best = max([info[0] for info in partition_infos]) # 先取分区内最优解
P = len(partition_sums)
prefix_sums_of_sums = [0] * (P+1)
for i in range(P):
prefix_sums_of_sums[i+1] = prefix_sums_of_sums[i] + partition_sums[i]
for i in range(P):
for j in range(i, P):
# 计算跨越分区 i 到 j 的子数组和
# 和 = LM[i] + (SUM[i+1]+...+SUM[j-1]) + RM[j]
cross_sum = partition_infos[i][1] # LM[i]
if j > i:
cross_sum += (prefix_sums_of_sums[j] - prefix_sums_of_sums[i+1]) # 中间分区总和
if j > i:
cross_sum += partition_infos[j][2] # RM[j]
else:
# 如果 i==j,RM已经包含在LM的计算中或需要特殊处理,这里简化为取LM和RM的较大值
cross_sum = max(partition_infos[i][1], partition_infos[i][2])
best = max(best, cross_sum)
print("最大子数组和为:", best)
算法复杂度分析:
- 步骤1(计算分区和):O(n/P)
- 步骤2(分区内计算):O(n/P)
- 步骤3(合并跨越分区解):O(P²)
由于P(分区数/机器数)通常很小,且n/P >> P,总体时间复杂度近似为 O(n/P),这达到了并行算法的理想加速比。
总结

本节课中我们一起学习了最大子数组问题。我们从最基础的 O(n³) 暴力解法出发,了解了 O(n²) 的优化暴力解。接着,我们深入探讨了 O(n log n) 的二分分治算法。然后,我们介绍了最优的 O(n) 线性时间 Kadane 算法,并分析了其串行特性。最后,为了在Spark这样的分布式计算框架上高效解决问题,我们设计了一个基于多路分治的并行算法。该算法先在每个分区上并行运行线性算法,再通过组合分区的局部最优解和边界信息(LM, RM, SUM)来找到全局最优解,实现了近乎线性的并行加速,是处理海量数据下此类问题的有效方法。
017:GraphFrames基础 🚀
在本节课中,我们将要学习一个专门用于图数据处理的重要库——GraphFrames。我们将了解它为何优于Spark内置的GraphX,学习其核心概念和基本操作,并通过一系列示例掌握如何使用它来构建、查询和分析图数据。
为何选择GraphFrames?🤔
在讨论图算法之前,我们需要介绍一个专门为图处理设计的库。图数据有其独特的特性,仅使用RDD或DataFrame难以轻松处理。Spark有自己的库叫GraphX。GraphX构建在RDD之上,属于旧模型。这个趋势与MLlib类似,旧版本基于RDD,而新版本基于Spark SQL的DataFrame,因为后者在灵活性和性能方面更具优势。基于Spark SQL的机器学习库要好得多。


实际上,GraphX也面临同样的情况。然而,我不太清楚原因,他们还没有这样做。GraphX仍然是一个直接构建在RDD之上的库,因此在性能和灵活性方面都不是很好。此外,它只支持Scala,没有Python接口。
幸运的是,有一个第三方包叫GraphFrames。GraphFrames构建在DataFrame之上。因此,我们将使用GraphFrames。但GraphFrames没有与Spark捆绑在一起,需要自行安装。安装说明也可以在课程网站上找到。如果安装GraphFrames遇到困难,可以联系助教。实际上,安装并不太难。
阅读文档时,他们提到最终计划是将GraphFrames集成到Spark中。但目前它们仍然是分开的。GraphFrames完全构建在DataFrame之上,没有对Spark内核做任何修改。这意味着,如果升级Spark,GraphFrames仍然可以工作。GraphFrames实际上适用于任何版本的Spark SQL。另一个好处是,当Spark SQL,特别是优化器,有任何进一步的改进时,所有好处都会被GraphFrames继承。然而,如果使用GraphX,则无法获得这些特性。

这就是为什么我们要使用GraphFrames。GraphFrames也有Python接口,这非常好。
GraphFrames的核心概念与优势 📊
除了性能,GraphFrames由于构建在DataFrame之上,继承了许多灵活性和表达性特性。因为在实际应用中,图数据不仅有结构,通常还有其他属性。
例如,这个例子展示了一个非常小的社交网络。在这个例子中,顶点代表用户,边代表他们的关系。可以想象,顶点和边都可能具有其他属性。例如,用户可能有姓名、年龄、地址、电话号码等。边可以代表不同类型的关係,如友谊、关注、合著关系或买卖关系。因此,顶点和边都可以有额外的属性。这使得DataFrame或关系模型非常适合存储这类图数据。这类图也被称为带标签的图,顶点和边都可以有额外的标签。
另一个例子是知识图谱。在知识图谱中,现实世界中的实体被建模为顶点,它们之间的关系(包括关系和属性)被建模为边。因此,知识图谱也可以存储为带标签的图。
创建GraphFrames图 🛠️
在任何使用GraphFrames的Jupyter笔记本中,你需要在第一行添加这个jar文件,以便可以从graphframes导入。
这个例子展示了如何在GraphFrames中创建这个社交网络图。任何GraphFrame都由两个DataFrame组成:一个用于顶点,另一个用于边。
顶点DataFrame本质上是一个特殊的DataFrame,它有一个指定的列叫id,以及任意数量的可选列。这些可选列可用于存储顶点的任何其他属性。id列是必需的,并且必须是唯一的。
实际上,在创建这个DataFrame时,我们根本没有使用GraphFrame。这只是创建常规DataFrame的标准方法。
类似地,对于边DataFrame,它本质上是一个特殊的DataFrame,有两个必需的列:src(源)和dst(目标)。这个DataFrame必须有这两列,以及任意数量的可选列,比如这里的relationship。
顺便说一下,GraphFrames中的所有图都是有向的。也就是说,源和目标之间有区别。从A到B的边并不意味着必须有从B到A的边。所有边都是有向的。如果你想建模无向图,你需要创建两个方向相反的边。
创建顶点DataFrame和边DataFrame后,你可以通过调用graphframes包的构造函数来创建GraphFrame,向其提供顶点DataFrame和边DataFrame。g将是一个GraphFrame。g.vertices和g.edges是GraphFrame的成员。它们仍然是常规的DataFrame,这意味着你可以在它们上应用任何标准的DataFrame操作。例如,你可以使用show()。


g.vertices和g.edges只是常规的DataFrame,这意味着你可以对它们使用任何DataFrame API。例如,这里我们使用过滤器,条件是src等于'a'。回想一下,这个条件遵循SQL语法,因此使用单个等号。这将给出从顶点a出发的所有出边。这实际上给出了顶点a的出度。
因为边也有可选列,你可以做更复杂的查询,比如这个。这将计算c的关注者数量。条件是dst是'c',并且relationship是'follow',然后进行计数。确实,c有两个关注者:b和f。

当然,这只是使用标准的DataFrame API。GraphFrames包有一些额外的特性。例如,它有outDegrees方法,可以给出所有顶点的出度。outDegrees本身是另一个DataFrame,包含两列:id和outDegree。类似地,inDegrees可以列出所有顶点的入度。

但如前所述,GraphFrames是在DataFrame之上实现的。因此,outDegrees和inDegrees实际上都被翻译成标准的DataFrame API。更准确地说,它们都没有被物化。当你创建GraphFrame时,这两个表实际上没有被计算。这实际上是有道理的,因为为什么要在你真正需要它们之前计算呢?这仍然遵循Spark通用的惰性计算思想。
当你创建GraphFrame时,这些DataFrame只是被定义,并没有被计算。只有当你对这些DataFrame调用某些操作(如show())时,才会触发outDegrees和inDegrees的计算。你实际上可以查看物理计划。本质上,它们执行扫描、投影和聚合。GraphFrames所做的,无非是定义inDegrees为一系列涉及groupBy、count和重命名列的DataFrame操作。
通过手动计算inDegrees,使用groupBy dst、count,然后重命名列,并通过检查物理计划,你可以看到这与inDegrees的物理计划完全相同。这实际上验证了我们的说法:GraphFrames中的inDegrees无非是这一系列DataFrame操作的包装器。
它们没有被物化,因为如前所述,它们希望受益于底层Spark SQL的所有优化。类似地,你可以进一步检查它们是否被物化。序列化本质上意味着它没有被缓存或物化。默认情况下,inDegrees只是一个DataFrame,没有被物化。你仍然可以缓存inDegrees,这只不过是另一个DataFrame,然后这次它被物化了。
当你调用g.cache()时,整个GraphFrame可以被物化,然后顶点DataFrame和边DataFrame都将被物化。然而,对于inDegrees和outDegrees,它们仍然没有被物化。
GraphFrames提供的另一个有用特性是图的triplets视图。这给出了图的完整画面,包括所有结构信息以及任何附加属性。它逐一列出所有边,除了ID,还列出所有附加列。现在你可以获得关于每条边的完整信息。

当然,这有很多冗余。例如,a出现了两次,这意味着所有其他实际列也被复制了。因此,默认情况下,triplets视图最初也没有被物化。当你对这个triplets视图调用一个操作时,它会执行一系列连接操作。例如,它将边DataFrame与顶点DataFrame连接,以检索所有这些附加列。你可以查看g.triplets.explain()来了解。你可以看到它是一系列连接,至少有两个连接,因为每条边有两个顶点。为了检索源顶点的其他属性,检索目标顶点的其他信息,这需要两次连接。

使用Motif进行模式查找 🔍
实际上,他们借用了生物信息学中的这个术语。在数学中,motif无非是一个子图。事实证明,这个应用在生物信息学中非常有用,例如给定一个蛋白质网络(这是一个图,实际上是一个带标签的图),顶点带有标签代表不同的蛋白质。他们希望找到特定的模式,并称之为motif。因此,motif本质上是在一个大图中寻找的特殊模式。这个模式包括结构以及标签,标签可以同时在顶点和边上。
在motif中,基本单位是一条边,边被表示为一个字符串。实际上,在GraphFrames中,他们将整个motif表示为一个大的字符串。他们使用字符串来表示在大图中寻找的模式。模式中的基本单位是边,表示如下:他们使用括号表示顶点,使用方括号表示边。这表示从顶点a到顶点b的一条边e。
模式被表示为一组边。边模式可以用分号连接。让我们看一些例子,使这个概念更具体。
作为第一个例子,假设我们正在寻找一个非常简单的模式:双向边。请记住,在GraphFrames中,图是有向的。从a到b的边并不意味着有从b到a的边。因此,如果你想表示双向边,你需要包含两条边,每条边一个方向。
现在假设我们想要找到所有这样的双向边,从a到b,以及从b到a。这可以很容易地实现。通过调用find方法进行motif查找。find是GraphFrames中的motif查找方法,它接受一个参数,即表示模式的字符串。它表示我的motif由两条边组成:一条从a到b,另一条从b到a。注意,边的标签被省略了。这意味着我们不关心标签。实际上,当我们在这里省略边或顶点的标签或名称时,意味着在输出或表示模式时不需要它。如果我们为顶点或边写一个名称,那么它将被包含在输出中。同时,我们也可以用它来表示motif的结构属性。
这里,当然,两个a表示它们必须代表同一个顶点。我们可以运行它。如果我们使用的图仍然是这个简单的示例图,你可以看到唯一的双向边是b到c这条。然而,这里返回了两个结果,它们实际上指的是同一个双向边。顺序被交换了。这实际上是有道理的,因为当你写这个模式时,a和b代表不同的顶点。在一个结果中,a是Charlie,另一个结果中a是Bob。它们确实可以被视为两个不同的结果,尽管逻辑上是同一个东西。
为了删除重复项或逻辑重复,我们可以添加一个过滤器。例如,这只是其中一个例子,你也可以使用a.id > b.id,或者使用a.name < b.name,任何打破平局的方法都有效。然而,使用name是危险的,因为如果两个人有相同的名字,你就会错过那些边。所以最好使用id作为打破平局的条件,因为id保证是唯一的。
当我们添加这个打破平局的条件时,我们就可以删除重复项,只留下一条边,其中a的id必须小于b的id。这是第一个简单的例子。
在第二个例子中,我们可以使用motif查找来找到所有三角形。更准确地说,因为图是有向的,边是有向的。我们寻找的更准确地说是定向三角形,即有一条从a到b的边,一条从b到c的边,以及一条从c回到a的边。这实际上是一个有向环。类似地,如果我们没有这个打破平局的条件,那么每个三角形将被找到三次,因为你可以旋转三角形三次。在这个例子中,唯一的三角形是a、e、b这个。这个三角形将被找到三次。你可以通过添加这个打破平局的条件来删除重复项:a必须小于d,并且a必须小于c,即a必须是最小的。这将删除所有重复项。类似地,你也可以要求a是最大的,或者c是最小的,或者c是最大的。任何打破平局的方法都有效。
Motif查找的实现原理 ⚙️
我之前说过,这是GraphFrames提供的一些新功能。然而,motif查找是如何实现的呢?你可以通过使用triangles.explain()来查看它是如何实现的。triangles是这个motif查找的结果。它是一个结果DataFrame。你可以看到它实际上只不过是另一个非常复杂的Spark查询。如果你真的深入研究,你可以看到它是由6次连接完成的。为什么是6次连接?本质上,他们实现这个三角形查找的方法是:取这个边关系,将其与自身连接,连接条件是第一条边的目标必须等于第二条边的源,第二条边的目标必须等于第三条边的源,然后第三条边的目标必须等于第一条边的源。对于三角形,他们需要三次连接,这称为自连接,因为它是将边关系与自身连接。
但为什么我们还需要另外三次连接?你可以查看输出。输出不仅告诉你三角形中三个顶点的ID,还告诉你所有其他属性,比如Alice的名字和年龄。这些信息并不存储在边关系中,而是存储在顶点数据中。因此,在找到三个顶点的ID之后,他们需要另外三次连接来检索与顶点相关的其他属性。如果你数一下,你应该能找到三次连接,总共六次连接。
对于三角形,为了形成一个三角形,他们将第一条边DataFrame与第二条边DataFrame连接,然后将结果与第三条边DataFrame连接。最后一次实际上不是通过连接执行的,而是通过一个相等性过滤器。即,第一条边的源必须等于第三条边的目标。因为在两次连接之后,我们已经将三个边关系连接在一起,我们已经找到了所有长度为3的路径。这是通过两次连接完成的。然后它检查这个长度为3的路径是否实际上形成一个环。最后一次不需要连接,它只是一个过滤器,是一个相等性检查。所以实际上是五次连接加上一个过滤条件。然后他们使用另外三次连接来检索其他条件。这就是三角形查找。
使用否定和复杂条件 🔧
接下来,他们还支持否定。一条边可以被否定,以表示该边不应出现在图中。他们可以使用这个来找到图中的特定模式。例如,如果我们想找到所有的单向边。即,你有一条从a到b的边,但没有从b回到a的边。在这种情况下,你可以找到所有的单向边。引入否定实际上非常重要,因为没有否定,某些模式你无法找到,例如单向边。更根本的是,可以证明,通过允许否定,你实际上可以实现整个关系代数。本质上,SQL中的所有功能都可以通过motif查找来处理。如果你学过数理逻辑,你会知道,通过允许并集、合取以及否定,你可以表达整个一阶逻辑。因此,任何可以用一阶逻辑表示的图模式都可以通过motif查找捕获。这就是为什么引入否定非常重要。
motif查找的强大之处在于,motif查找仍然只返回DataFrame。这意味着你可以在motif查找的结果上应用任何其他DataFrame操作。例如,如果我们想说我们想找到所有的双向边,其中其中一个顶点的年龄大于36。这可以按如下方式完成。首先找到所有的双向边,然后进行过滤,因为这个返回DataFrame,你可以在其上应用任何其他DataFrame操作。
更美妙的是,你实际上不必担心效率。实际上,这是一种非常糟糕的查询执行方式,因为我们可能有很多边,但也许只有少数人满足这个过滤条件。因此,如果我们先执行motif查找,然后再进行过滤,这可能会非常慢。这不是一种非常聪明的方式,因为motif查找,正如我们之前看到的,涉及很多连接。在这个例子中,涉及三次连接。每次连接都非常昂贵。因此,我们希望最小化连接的成本。
然而,因为find这个motif查找,无论它涉及多少连接,它仍然只是一个大的转换。它不是动作。过滤是另一个转换。唯一的动作是show()。因此,当你执行这一行时,这会将整个查询发送到Spark SQL。在Spark SQL中,接收查询后会解析查询,构建逻辑计划,然后优化它。这是一个谓词,我们知道谓词可以下推,可以下推到连接之上。这个find,虽然它不使用连接爆炸,但它只不过是一堆连接。这个谓词可以被下推到连接之上。这意味着优化后,Spark实际上会先运行这个过滤器。更准确地说,我们需要在边的两个副本之间进行连接。在第一个边关系副本上,目标将应用这个条件。在第二个边关系副本上,源将应用这个过滤条件。这个过滤函数实际上会被下推到两个DataFrame、两个关系上。
你可以实际检查这个来验证我们的猜想。过滤器在哪里?是的,过滤条件是age > 36。这里有一个,那里有另一个。这意味着你实际上不必担心查询是如何编写的。优化器将能够找到不一定最好但会尽力优化你的查询的方法。
下一个例子相当复杂。这也是一个很好的机会来介绍SQL中另一个有用的函数。在SQL中,有一个叫做CASE WHEN的东西。这非常强大,因为在SQL中,没有if-then,因为本质上,你只能对表中的所有行做相同的事情。你不能说我对某些行做某些事情,对其他行做不同的事情。那是不可能的。你只能对不同列做不同的事情。然而,所有行必须以相同的方式处理。但是,借助CASE WHEN关键字子句,你基本上可以根据行的值对不同行做不同的事情。类似地,这也被迁移到了Spark SQL,即when和otherwise函数。
这个任务如下:我们仍然给定一个存储在GraphFrames中的图,我们想要找到四个顶点的链。使得三个边中至少有两个是朋友关系。这是一个非常复杂的条件。我们首先要找到所有四个顶点的链a、b、c、d。这四个顶点将有三条边。在我们的图中,边有标签。在这个例子中,我们有两种类型的边:friend和follow。在三条边中,我希望至少两条是friend。第三条可以是friend或follow。这就是我们要寻找的模式。我认为这实际上是一个很好的例子,说明了如何将motif查找与Spark SQL提供的其他功能结合起来。
第一步很简单。第一步就是找到链。这就是motif查找的设计目的。所以我们说a到b,b到c,c到d。在这种情况下,我们还为边添加了变量名。因为稍后,我们的条件需要根据边上的标签进行检查。所以我们需要这些标签。我们需要这些边在motif查找的结果DataFrame中。因此,我们不仅需要这些顶点,还需要边e1、e2和e3的信息。这只是一个链。我们不希望a和d是同一个东西,否则我们就会找到一个环。所以我们也添加这个条件。实际上,在这个定义中,这并不精确。例如,用户可能还需要a和c应该不同。如果我们这里没有,我认为我们应该有,因为链的直观含义是你不会重复顶点。所以如果我们想要,我们可以添加a != c和b != d。a和b不能是同一个东西,因为a和b来自同一条边,图不应该有自环。当然,在某些特殊图中,自环也是允许的。但当我们说简单图时,我们不允许自环。因此,如果我们没有自环,那么这三个条件是我们唯一需要检查的。我认为这样可以确保链不会重复同一个顶点。
现在我们已经找到了所有四个顶点的链。下一步是根据这个条件进行检查。三条边中至少两条是朋友。当然,有不止一种方法可以做到这一点。我只是展示其中一种方式。这种方式更通用,可以更容易地扩展到类似的其他条件。
第一步是将边转换为一个指示器0或1,其中1表示这条边是朋友边,0表示这条边是其他边。然后我们将这些指示器相加,这样我们就可以计算三条边中有多少条是朋友边。
为此,我引入了这个函数friend_to_one。顾名思义,它将任何朋友边转换为1,否则为0。它被定义为一个lambda函数。当然,你也可以使用显式的def方式来定义friend_to_one,但这是更简洁的方式。friend_to_one是一个指向函数的变量。所以它是一个函数。在Python中,变量可以代表任何东西,甚至函数。这里的e将引用DataFrame中的一列。
让我们先忽略这个,看看如何使用friend_to_one。我们在select内部使用friend_to_one。当我们在select内部使用一个函数时,输入和输出都必须是一列。因此,输入,例如这个,我们只取e1。记住,chain4是这个motif查找的结果DataFrame,e1是它的列之一。e2和e3也是。这指的是代表第一条边e1的列。这是第二条,这是第三条。对于每一条边,我将其输入到friend_to_one函数中。所以这是输入列。输出列由when函数指定。实际上,when函数定义在pyspark.sql.functions包中。事实上,所有标准的SQL函数都已移植到Spark SQL中。
when函数有两个参数。第一个是一个条件,实际上是关于列的条件。条件是检查是否为朋友关系。所以e是一个列。为了检索子列,我们可以使用点格式。例如,你可以看到a.id。如果我想获取其他信息,比如e.relationship,那么我得到follow。你可以使用点来检索子字段、子列。这就是我们在这里的做法。这里e已经是一个列。e.relationship检索这个边列下的子列,我们检查它是否等于friend。如果这个条件为真,则返回1。这是when的第二个参数。然后你可以选择性地附加一个otherwise。实际上,when可以链式调用。所以如果你有if-else if-else,即如果你有三种或更多可能性,你可以使用多个when。
现在,这个friend_to_one函数将会转换。我提到过,这个函数可以用来对不同行做不同的事情。即,如果那行是朋友关系行,那么它就是1,否则变成0。然后我们将结果列重命名为f1、f2和f3。原因是如果你不重命名,在下一步中你将无法引用它。
在这之后,我们有了三个新列f1、f2、f3,因为我们保留了所有列,然后添加了三个新列。然后我们可以简单地将它们相加,这个和应该至少为2。然后我投影到四列a、b、c、d上。如果我们不投影,我们实际上可以查看整个结果。我们可以看到我们有a、e1、b、e2、c、e3、d,并且e1、e2、e3已经被转换为f1、f2、f3,它们都是0和1,表示是朋友关系还是关注关系。例如,这里friend、friend、follow,所以是1。
构建子图 🌲
Motif查找是构建子图的一种方式。但这并不是非常高效,因为正如我们所见,motif查找涉及大量连接。因此,motif查找只在你寻找特定小模式(如三角形、链或双向边等)时高效。如果你只是希望将图缩小为一个更小的图,那么应该使用子图构建。Motif查找是寻找一个可能出现多次的模式。子图指的是你只想通过关注一个较小的部分来使图变小。它只返回一个子图。在这种情况下,我们可以使用子图构建。
这实际上没有什么特别的。要构建一个子图,我们只需构建一个新的DataFrame,由选定的顶点和边组成。然而,必须小心,因为当你构造一个新的GraphFrame时,GraphFrame不会真正检查任何一致性。这将在以下示例中说明。
假设我只对用户年龄大于30且边类型为friend的子图感兴趣。我想找到所有老朋友的小型社交网络。这看起来很容易。首先,我过滤掉所有年龄大于30的顶点。然后我过滤掉所有关系为friend的边。然后,我简单地构建一个新的图。这看起来没问题。确实,如果我们看g2,g2是子图。确实,所有人都是老家伙,所有关系都是朋友。看起来很好。然而,这个图实际上是不一致的。图的不一致有两种方式。第一种不太严重,即一个顶点可能是孤立的。例如,g,这个可怜的家伙,没有边连接到他。确实,如果我们看这个,g上唯一的边是一条关注边。所以g变成了孤立的顶点。这种情况实际上没问题,因为在某些图中,孤立的顶点可能不是很有用,但不能说是不一致。
另一种不一致可能很严重,即一条边可能连接到不存在的顶点。例如,边e到d。你可以检查e到d这条边。d不存在,因为d是一个29岁的年轻人,他没有通过过滤。但GraphFrame在构造GraphFrame时不会检查。实际上,这个构造函数什么都不做。它只告诉GraphFrame,我现在正在构建一个新图,顶点存储在v2中,边存储在e2中。我不保证一致性。所以GraphFrame不负责检查我的数据是否一致。
这就是为什么必须采取措施确保一致性。如果你不这样做,你仍然可以运行某些操作。例如,你仍然可以计算入度。入度仍然有效,因为记住,入度只不过是对边关系进行groupBy。它甚至不看顶点DataFrame。所以入度会告诉你d仍然有入度1,即使d甚至不存在于顶点中。然而,这种不一致可能会在运行其他更复杂的操作或算法时导致问题。
那么我们如何清理,确保消除所有不一致?我们只关心第二种,即边必须连接到存在的顶点。方法是使用我们现在称为半连接的操作。这是一种我们尚未介绍的新型连接。虽然它被称为半连接,但它实际上是一个过滤器。它被称为连接,但它实际上是一个过滤器,只不过过滤条件是通过连接执行的。
半连接做什么?半连接类似于外连接,它也是不对称的。这就是为什么有左半连接和右半连接的区别。在左半连接中,这实际上是对左侧DataFrame的过滤。当我们执行e2.join(v2, ...)时,e2在左侧。半连接的结果必须是e2的子集。过滤条件表示为连接条件。即,e2中任何边的源必须匹配右侧DataFrame中某个顶点的id。
半连接与内连接或外连接的区别在于,输出不包括右侧的任何属性或列。例如,这里v2也有姓名和年龄,这些列将被忽略。我们唯一需要右侧关系或DataFrame的地方是检查这个连接条件,确保在右侧DataFrame中至少有一行满足这个连接条件。这就是为什么我们说,虽然它被称为连接,但更像一个过滤器。只不过这里的过滤条件更复杂。过滤条件是说,你必须匹配我右侧的至少一行。因此,这本质上确保了每条边的源必须出现在顶点DataFrame中。
然后我们接着进行另一个半连接,确保目标也出现在顶点DataFrame中。然后我们的图就是一致的。实际上,只有两条边通过了这个条件:a-e和a-b。另一条边涉及d,所以被移除了。
聊天中的几个问题:它就像EXISTS吗?是的,完全正确。在SQL中,有一个子句叫EXISTS。实际上,在SQL中也有半连接。但EXISTS也有效。第二个问题:我们可以使用左外连接然后使用过滤器吗?是的,那也有效。你可以使用左外连接,然后删除。实际上,在这种情况下,你可以直接使用内连接。你使用内连接,然后投影左侧的列。本质上,这意味着你也可以使用内连接。实际上,在关系代数中,只有内连接是必要的。外连接和半连接都可以被替换。它们可以被内连接加上其他标准操作(如投影)所替代。它们只是为了方便和性能而引入的,并不是真正必要的。
更复杂的子图构建示例 🧩
让我们看另一个更复杂的例子。我们想基于类型为follow、从老用户指向年轻用户的边来构建一个子图。当然,有不止一种方法可以做到这一点。这里我展示如何使用motif查找。当然,这里的motif非常简单,只有一条边,你不需要使用motif查找。实际上,当我们做find("(a)-[e]->(b)")时,这简单地返回三元组视图。我们也可以使用triplets。无论如何,这只是一个例子,展示你可以使用不同的方式达到相同的结果,甚至在性能方面它们都是相同的,因为它们对应于相同的查询计划。因此,我们不必太担心应该使用哪种特定方式,这正是Spark SQL的美妙之处。
我们简单地找到所有边(a)-[e]->(b),然后边类型必须是follow,并且源顶点的年龄必须大于目标顶点的年龄,即从老用户到年轻用户。然后我选择e.*。这本质上是一种选择列内所有子列的方法。这是所有关注边的结果。
在这个子图构建中,我对顶点没有任何限制。当然,如果顶点被移除,所有关联的边也应该被移除。这是出于一致性原因。这就是为什么我们只需要保留出现在边中的顶点。这是通过半连接加并集完成的。第一个半连接找到所有作为至少一条边的源出现的顶点。即,我找到所有边,与e4(所有幸存下来的边)进行连接,并且id必须匹配源。这是左半连接。我认为他们支持左半连接和右半连接。他们让这个功能非常用户友好。
第二个半连接做的是,我们还需要保留作为边目标出现的顶点。然后我执行并集,并去重,因为记住,并集不会删除重复项,它只是将两个DataFrame组合在一起。这实际上解决了第一种不一致,这不是必需的。即使我们不这样做,我们也可能有一些孤立的顶点。这不是大问题。但如果你想使数据更整洁,清理数据,那么你可以这样做,只保留出现在边中的顶点。现在我们可以构造图了。
这就是g4。然后我们可以查看子图的三元组视图的完整画面。即只有三条边,以及这些顶点。
总结 📝

在本节课中,我们一起学习了GraphFrames的基础知识。我们了解了GraphFrames相比GraphX的优势,包括其基于DataFrame的架构带来的灵活性、性能以及对Python的支持。我们学习了如何创建GraphFrame,如何查询顶点和边,以及如何使用outDegrees、inDegrees和triplets等内置方法。我们深入探讨了强大的motif查找功能,它允许我们使用声明性字符串模式在图中查找特定子结构,并支持否定和复杂条件。我们还学习了如何通过过滤顶点和边来构建子图,并确保图的一致性,使用了半连接等技术。最后,我们通过一系列从简单到复杂的示例,实践了如何使用GraphFrames进行实际的数据操作和分析。掌握GraphFrames是进行大规模图处理和分析的重要一步。
018:图算法与并行计算
在本节课中,我们将学习图算法,特别是如何在并行计算环境中处理图数据。我们将从广度优先搜索开始,探讨其实现与局限性,然后介绍一种用于处理长链图(如链表)的高效并行算法——列表排名算法,并了解其在树深度计算等场景中的应用。
图算法简介
上一节我们讨论了分治算法。然而,对于图数据,分割图结构非常困难。事实上,将一个图分割成两部分,同时最小化跨越边的数量,是一个已知的NP难问题。
如果随意分割一个图,可能会产生大量跨越边。这对于许多分治算法来说效率不高,因为理想情况下我们希望尽可能少地跨越边。但这个问题被证明是NP难的,因此非常困难。所以,人们必须研究处理图的其他方法。
当然,图算法领域有大量文献,而使其并行化更具挑战性。事实上,存在一个猜想,即某些图问题本质上是顺序执行的。理论家在讨论这种下界或负面结果时,通常指的是最坏情况。也就是说,你不能指望在所有情况下都高效地解决问题,总存在一些难以处理的困难实例。
其中一个被认为是本质上顺序执行的问题,就是广度优先搜索。
广度优先搜索
BFS即广度优先搜索。其过程是从一个源顶点开始,逐层探索其邻居。
例如,假设A是源顶点。从A开始执行BFS。A在第0层。然后在第1层,访问A的所有直接邻居(即一跳邻居),在这个例子中只有B和E。接着,从第1层的顶点出发,访问它们的一跳邻居,从而到达第2层。在这个例子中,B和E的邻居包括C、F和D。因此,C、F、D在第2层。然后迭代地进行这个过程,每次探索一层。
这个算法之所以称为“广度优先”,是因为我们希望逐层访问顶点。
BFS算法的性能取决于图的结构。如果图像一个长链,那么BFS可能效率很低。当图比较“浅”(即直径小)时,BFS效果更好。
图的一个重要参数是直径。图的直径定义为最长最短路径的长度。这有点令人困惑,它是最长的“最短路径”的长度。换句话说,你需要最多多少跳才能从图中任意一点到达另一点。
BFS的并行步骤数将等于图的直径。对于许多图,例如社交网络,有一个著名的“六度分隔”理论,声称社交网络的直径为6,意味着你最多通过6个人可以联系到世界上任何人。这表明许多现实世界中的图,尤其是社交网络,直径很小。因此,对于这类图,BFS是好的,并且易于并行化。
然而,也存在图直径非常大的情况,此时BFS会非常低效。但无论如何,让我们先看看低直径的情况。
在GraphFrames中实现BFS
BFS也能找到从源顶点出发的、跳数最短的路径。以下是我自己实现的BFS,尽管GraphFrames提供了更高效的内置BFS实现,可以直接调用。但作为一个示例,我将展示如何自己实现类似BFS的算法。假设你的项目是关于实现另一个图算法。
BFS实现起来并不复杂,但也不简单。你不能仅通过一个查询来完成,它必须有一个循环,因为我们需要逐层探索图。
第0层只包含一个顶点,即源顶点。我们通过select操作来实现,选择ID等于源顶点(例如‘A’)的顶点。这将返回一个DataFrame(尽管我们知道它只包含一个顶点),并将其放入一个Python列表中。这个列表由DataFrame组成,初始时只包含第0层。
实现BFS的一个重要操作是能够检查一个顶点是否已被访问过。例如,在探索完第2层后,需要进入第3层。当进入第3层时,假设D在第2层,D会尝试访问A,但A是源点,已经被访问过了。类似地,F会尝试访问C,而C也已在第2层被访问。在BFS中,一个顶点只有在第一次被访问时才被视为“已访问”。当你第二次尝试访问一个顶点时,这次尝试应该失败,因为第二次访问时,你不可能通过一条更短的路径到达它。第一次到达一个顶点必定对应着最短路径。
因此,我们需要记住所有已经访问过的顶点。我们使用一个名为visited的DataFrame,它只包含一列ID。初始时,只有源点被访问。
现在,我们进入循环。循环的每次迭代将探索一层。终止条件是最后一层为空,即没有更多顶点需要检查。
从当前层,我们想要获取所有一跳邻居。我们通过join操作来实现。当前层是最后一层的顶点ID列表。我们将其与所有边进行连接,条件是ID等于边的源点。这将返回一个包含多列的DataFrame,包括ID、目标顶点以及边的其他属性。这里我们不需要其他属性,但不必担心,Spark会通过列剪裁自动优化掉未使用的列。
现在,D1包含了所有一跳邻居(实际上是目标列)。我们投影出目标列,并赋予其新名称“ID”。然后,移除所有已经访问过的顶点。最后,执行distinct操作。为什么需要去重?因为连接操作中,如果两个不同的顶点试图到达同一个顶点,这个公共顶点会在结果的目标列中出现两次,所以需要去重。
现在,D2包含了这一层中新访问的所有顶点(无重复)。我们可以将其作为下一层添加到层列表中。对于已访问集合visited,我们执行union操作。这里不需要去重,因为我们已经保证了D2中的所有顶点都是新的(已减去已访问的顶点),这可以节省一次洗牌操作。
你可以看到,计算第0层非常高效快速,因为它不依赖于任何其他数据,图计算并未真正执行。对于第1层、第2层,需要更多时间。第3层为空,整个过程将停止。
然而,这种编写程序的方式效率不高,因为每一层都会多次重新计算。更好的方式是缓存中间DataFrame,例如缓存D2,可能visited也应该缓存,以避免多次重新计算同一层数据。
我们已经看到了如何实现BFS。同时我们也提到,如果图的直径小,BFS是高效的。在BFS中,我们逐层探索图,从一层到下一层。如果这是并行的,因为我们通过连接操作探索每一层,连接操作是洗牌操作,可以高效并行实现。然而,从一层到下一层,这是顺序执行的。你必须完成第i层才能进入第i+1层。因此,如果图的直径很大,BFS效率就不高。
事实上,一般来说,如何高效并行地进行BFS,或者更准确地说,如何在大直径图中高效并行地找到最短路径,这是一个非常困难的问题。
列表排名算法
然而,在极端情况下,当直径尽可能大时(图退化成一条单链),这个问题实际上可以通过一个相当巧妙的算法解决,称为列表排名。
在极端情况下,图退化成一条单链。源点是第一个节点,目标点是最后一个节点。我们想要找到路径。更准确地说,我们本质上需要遍历这个列表。
这实际上是计算机科学中的一个基本问题。如果你主修CS,你一定上过数据结构课程,我们用于任何程序的两个最基本的数据结构,一个叫链表,另一个叫数组。两者之间的区别在于,在链表中,你可以在列表中间高效插入元素,这可以在常数时间内完成。另一方面,在数组中插入元素成本很高,你必须移动插入元素之后的所有元素。而数组的好处是,你可以非常高效地进行查找。例如,我想去第1012个位置,你可以直接以常数时间到达。但这在链表中效率很低,你必须从头开始,一个一个遍历,直到到达所需位置。所以这两种结构之间存在权衡。
将一个数组转换为链表是简单的。你只需在数组中任意两个相邻元素之间建立指针,这甚至可以并行完成。将链表转换为数组,如果你想要顺序算法,这也很容易。你可以从头开始,逐个遍历列表,然后逐步构建数组。然而,如何并行地做这件事并不容易。如何用并行算法将链表转换为数组,这实际上是并行计算中最经典的算法之一,称为列表排名。
列表排名的问题如下:给定一个单链表和一组对象,我想将它们按顺序排列。本质上,要解决这个问题,足以找到每个元素的位置。也就是说,我需要找到这个位置编号。例如,源点位置为0,下一个是1,然后是2、3、4、5。如果我能够神奇地找到这些标签,那么我就完成了。然后,我可以使用排序将它们按顺序排列。在单机上,一旦有了位置编号,你可以直接将它们放入目标位置。在Spark或集群中,你可能需要进行排序,或者你可以通过洗牌操作将它们放到正确的位置。
但现在关键问题是如何找到这些位置编号。我将换一种方式来做。我不找0,1,2,3,4,5,我找每个顶点到目标点的距离。如果我能找到到目标点的距离,那么我可以很容易地找到位置编号。现在我将这个问题简化为为链表中的每个顶点找到到目标点的距离。
我们将通过迭代算法来完成,但这个迭代算法将是并行的。它会有几次迭代,但每次迭代中,我们可以并行地做很多事情。
更具体地说,我们如何做呢?我们首先初始化距离。如果下一个邻居是空(即目标点),则距离初始化为0。对于其他顶点,我初始化为1。当然,除了目标点外,这些初始距离都是错误的。
现在算法实际上非常简单,只有三行代码。我们在每个顶点上并行运行这三行代码。代码的意思是:如果这个节点有一个下一个邻居,我将做以下两步。第一步,我将把我邻居的距离加到我自己的距离上。然后,我将跳过这个邻居,即更新我的指针,使我的邻居指向我邻居的邻居。
例如,假设我们在第一个节点上运行这段代码。当然,它的下一个邻居不为空。所以,我把邻居的距离加到自己身上,所以这个距离变成2。然后我更新我的指针,使我的邻居指向我邻居的邻居(即下下个节点)。现在,我实际上是在修改我的图。我对每个节点并行地做同样的事情。
经过第一轮迭代后,我的链表看起来不再像链表,而是一个图。我再次运行整个过程,在所有节点上再次运行相同的代码。现在,步长加倍了。初始步长为1,第一轮后步长为2,然后4,然后8。每轮步长加倍。所以我们需要多少轮?log(n)轮。这是一个小数量的并行运行轮数。因此,这被认为是一个高效的算法。
然而,总工作量更大。总工作量是N乘以log(n),因为每轮我们为每个顶点做一些事情。这比朴素的顺序算法(遍历整个链表)要慢。同样,这并不奇怪,对于任何并行算法,我们总是做更多的工作,但换来的是更短的并行时间。朴素的顺序算法有O(n)的工作量,但运行时间也是O(n),因为你必须顺序地一个一个做,这在并行世界中效率很低。
这个算法为什么正确?关键观察是算法维持了一个不变式。初始时,我的链表看起来像这样,除了最后一个顶点外,所有距离都是1。我维持的不变式是:对于任何顶点,如果你将从该顶点可达的所有节点的距离加起来,那么它给出该顶点的正确距离。例如,从第一个顶点开始,它可以到达所有人,其距离是5。最后一个顶点不能到达任何人,其距离是0。这个不变式在初始时显然成立。现在只需要论证在每次操作后这个不变式仍然成立。因为代码所做的操作是:更新D1为D1 + D2,同时跳过D2,所以总和保持不变。因此,不变式仍然维持。在算法结束时,没有指针了,所以没有人可以到达任何其他人。再次代入不变式,意味着所有距离必须已经被正确计算出来。
在Spark中实现列表排名
在Spark中实现列表排名并不非常困难。首先,我初始化我的链表。为了说明,我使用整数作为元素的ID或标签。本质上,我将链表表示为一个图,尽管它是一个特殊的链。这些是边,意味着0指向5,1指向0,3指向4,等等。我故意省略了连续的标签,这些标签可以是任何整数、字符串等。特别地,我使用-1表示空,表示结束。
现在,我创建一个DataFrame作为边,源点和目标点如上。顶点是所有源点。我需要初始化距离。规则是:如果边的目标点是-1,这意味着这是最后一个顶点,初始距离D为0,否则为1。为了技术原因,我添加了一个额外的虚拟边,从一个标签为-1的虚拟顶点出发,距离也是0。这个虚拟顶点也会出现在顶点列表中。
现在,我进入循环。每次迭代对应一轮。我使用三元组视图,因为它为我们提供了源点和目标点的所有属性(包括D)。虽然在我们描述算法时,我们修改了链表,但DataFrame是不可变的,你无法就地更改DataFrame。更改的唯一方法是创建一个新的DataFrame。
如何创建新的DataFrame?我这样做:我有三元组视图,它包含ID以及其他属性(这里是D)。我选择源点的ID作为新DataFrame的ID。然后,我将源点和目标点的距离相加,作为新的D。这已经实现了算法应该做的事情。这里我没有显式检查“下一个邻居是否为空”的条件,我只是盲目地对每个顶点都这样做。为什么这样可行?因为在边数组中,当目标点是-1时,它的D总是0。所以即使我总是这样做,也没有任何影响。这就是为什么我们需要将虚拟顶点添加到顶点列表中,这样我就不必检查邻居是否为空。
我还需要更新所有指针。我无法更改它们,但可以创建一组新的边。新的边集是什么样的?它只是所有长度为2的路径:A到B,B到C。我保留A作为新源点,使C作为新目标点。当然,如果我只这样做,我可能会遗漏一种情况:从一个顶点到-1的边。所有这些边都会被删除,这不是我们想要的,因为我们需要记住这个顶点没有邻居。然而,如果我们移除这条边,我们将不知道这个顶点是否有邻居。所以我们需要保留所有目标点为-1的边。这样,在迭代中我们就不必检查条件了。


运行算法后,V得到以下结果。例如,顶点0的D值是1,确实,0到5,5是-1,所以0实际上是倒数第二个元素,距离是1。最后一个顶点是5。
列表排名的应用
列表排名不仅可以计算跳数距离,它本质上可以计算前缀和或后缀和。在这个算法中,当我初始化初始D时,我设置为0,1,1,1,1,1,但我可以设置为任何值。例如,我可以设置为2, -1, 4, -2, 1。然后如果我运行相同的算法,最终得到的是后缀和。类似地,你也可以计算前缀和。如果你计算了后缀和,你也可以计算总和,然后用总和减去后缀和得到前缀和。这是我们上次通过分治学习的前缀和。这可以被视为前缀和的图版本,其中数据以链表形式给出,而不是数组。如果数据以数组形式给出,我们使用分治;当数据以链表形式给出时,我们可以使用这个算法来计算前缀和。
作为一个应用,我们可以使用这个算法来查找树的深度。树的深度是最深节点的深度。例如,在这个例子中,深度是4。有些人使用树的高度,高度与深度是相同的概念,取决于你如何计算节点或边。在许多应用中,找到树的深度是有用的。如果树比较浅,你可以使用BFS,从根开始逐层遍历树,几轮之后你就完成了。但如果树非常深,BFS将会非常慢。但你可以使用列表排名,列表排名总是可以在log(n)轮内完成,无论树有多深。
我们究竟如何使用列表排名来计算树的深度?列表排名只适用于链表,但这里我们给定一棵树。如何将其转化为链表问题?技巧是使用所谓的欧拉回路技术。树的欧拉回路本质上就是中序遍历。我按中序遍历访问所有边。在这个遍历中,每条边被访问两次:一次向下,一次向上。向下对应第一次访问,向上方向对应整个子树遍历完成后返回。
我将这两次访问视为该边的两个副本。也就是说,每条边有两个副本:一个向下副本,一个向上副本。这是完全并行的,因为每条边独立地创建两个副本。
现在,我将在这些边的副本之间建立链接。规则非常简单且局部。对于一个节点,它有父节点和子节点。规则是:从我父节点来的边的向下副本,在链表中的邻居将是指向我第一个子节点的边的向下副本。这些边的副本将成为我链表中的节点,而它们之间的指针将成为链表中的边。
如果我对每个节点都这样做,我将得到一个大的链表,它精确地遵循我的中序遍历顺序。
最后,我如何为每个边副本分配初始D值?对于每个向下的树边,初始D为+1;对于每个向上的树边,初始D为-1。现在,如果我运行列表排名算法,最终与某条边关联的D值将对应于该边在树中的深度。这是因为在子树中,向下和向上的边会相互抵消,最终剩下的只是那些只被遍历过一次的边,其D值即为深度。
这就是欧拉回路技术,将树问题转化为链表问题。这样我就可以计算深度。这也可以用于并行解决树上的其他问题。
总结

本节课我们一起学习了图算法的基础。我们从广度优先搜索开始,了解了其逐层遍历的思想、在GraphFrames中的实现方式,以及其效率受图直径影响的特性。接着,我们探讨了对于长链或大直径图,BFS的局限性,并引入了一种高效的并行算法——列表排名算法。我们详细分析了该算法的原理、正确性证明及其在Spark中的实现。最后,我们看到了列表排名算法的强大之处,它不仅能处理链表排序,还能通过欧拉回路技术应用于树深度计算等问题,展示了将复杂图问题转化为可并行链表问题的巧妙思路。这些算法为在大数据环境下处理图结构提供了重要的工具和思想。
019:Pregel模型与图算法实现 🧮
在本节课中,我们将学习图计算中的Pregel模型,这是一种以顶点为中心的并行计算框架。我们将了解其核心概念,并通过GraphFrames库中的aggregateMessages操作和Pregel API来实现经典的图算法,如PageRank和广度优先搜索。
消息传递与聚合
在介绍Pregel模型之前,我们先了解GraphFrames提供的另一个基础操作:aggregateMessages。它允许我们沿着图的边在顶点之间传递消息,并对发送到同一顶点的消息进行聚合。


以下是使用aggregateMessages的一个示例。假设我们想计算每个顶点的所有邻居的年龄总和。
from pyspark.sql.functions import sum as sql_sum
# 假设 `graph` 是一个GraphFrame对象,顶点有“age”属性
result = graph.aggregateMessages(
aggCol=sql_sum(AM.msg), # 聚合函数:求和
sendToSrc=AM.src["age"], # 向源顶点发送其邻居的年龄
sendToDst=AM.dst["age"] # 向目标顶点发送其邻居的年龄
).withColumnRenamed("agg", "sum_ages")
result.show()
在这个例子中,对于图中的每一条边,系统会沿着两个方向(从源到目标,从目标到源)发送消息。消息的内容是邻居顶点的“age”属性值。aggregateMessages会收集所有发送到同一顶点的消息,并使用指定的聚合函数(这里是求和sql_sum)进行计算,最终为每个顶点生成一个名为“sum_ages”的新属性,即其所有邻居年龄的总和。
Pregel计算模型介绍
aggregateMessages操作本身不常用,它更多地被用作实现Pregel模型的底层工具。Pregel是Google提出的图处理系统,其核心是顶点中心计算模型。
在顶点中心计算模型中,程序以顶点为单位编写。调度器会在所有顶点上并行运行你的程序。每个顶点程序可以定义一些局部变量和一个二元状态(活跃或非活跃)。Pregel以轮次为单位执行程序,每一轮包含三个步骤:
- 聚合消息:每个顶点首先聚合从前一轮收到的消息。如果顶点处于非活跃状态但收到了消息,它将被激活。
- 更新顶点值:每个活跃顶点根据聚合后的消息更新其局部值。
- 发送消息:每个活跃顶点向其邻居发送新的消息。之后,顶点可以选择将自己设置为非活跃状态。
这个模型与MapReduce有相似之处,但也有两个关键区别:
- 粒度更细:Pregel在单个顶点级别进行计算,而MapReduce在任务(Worker)级别。
- 通信受限:在Pregel中,消息只能沿着图的边发送;而在MapReduce中,Reducer可以接收来自任意Mapper的数据。
这种设计特别适合图算法,因为图算法通常只需要在相邻顶点间交换信息。
使用Pregel模型实现PageRank
现在,让我们看一个具体例子:如何使用Pregel模型实现PageRank算法。
在PageRank的Pregel实现中,每个顶点的局部值是其当前的PageRank值。算法流程如下:
- 初始化:每个顶点的PageRank值设为1.0,所有顶点标记为活跃。
- 更新规则:在每一轮中,顶点将所有来自入边邻居的贡献值(即邻居的PageRank除以其出度)求和,乘以阻尼因子(如0.85),再加上一个常数项(如0.15),得到新的PageRank值。公式为:
new_rank = 0.85 * sum(incoming_messages) + 0.15。 - 发送消息:顶点将其新的PageRank值除以其出度,作为贡献值发送给所有出边邻居。
以下是使用GraphFrames的Pregel API实现PageRank的代码:
from graphframes import GraphFrame
from pyspark.sql.functions import coalesce, lit, sum as sql_sum
from pyspark.sql.functions import col
# 假设已有图g
# 首先为每个顶点计算并添加出度信息
g_out_degree = g.outDegrees
new_vertices = g.vertices.join(g_out_degree, “id”, how=“left_outer”).fillna(0, [“outDegree”])
new_g = GraphFrame(new_vertices, g.edges)
# 使用Pregel模型计算PageRank,迭代5轮
ranks = new_g.pregel \
.setMaxIter(5) \
.withVertexColumn(“rank”, # 新增的局部值列名
lit(1.0), # 初始值
# 更新公式:聚合消息求和后乘0.85,再加0.15。若无消息,则值为0.15。
coalesce(sql_sum(Pregel.msg()), lit(0.0)) * lit(0.85) + lit(0.15)) \
.sendMsgToDst(Pregel.src[“rank”] / Pregel.src[“outDegree”]) \
.aggMsgs(sql_sum(Pregel.msg())) \
.run()
# 查看结果
ranks.select(“id”, “rank”).show()
代码解析:
- 构建器模式:
.pregel返回一个构建器,我们可以链式调用方法来设置参数(如.setMaxIter(5)设置最大迭代次数)。这是一种灵活的API设计模式。 - 定义顶点列:
.withVertexColumn定义了顶点的局部变量。这里我们创建了一个名为“rank”的新列,初始值为1.0,并指定了其更新规则。 - 发送消息:
.sendMsgToDst定义了从源顶点发送到目标顶点的消息内容,即源顶点的当前rank除以其出度。 - 聚合消息:
.aggMsgs定义了如何聚合发送到同一顶点的多条消息,这里使用求和。 - 执行:
.run()启动Pregel计算,并返回最后一轮迭代后的顶点DataFrame。
在每一轮迭代中,系统内部执行三个步骤:
- 聚合上一轮发送到本顶点的所有消息。
- 根据聚合结果和更新规则,计算顶点“rank”列的新值。
- 根据
.sendMsgToDst规则,从每个顶点向邻居发送新的消息。
使用Pregel模型实现广度优先搜索
Pregel模型同样适用于其他图算法。让我们看看如何实现广度优先搜索。
在BFS的Pregel实现中,每个顶点维护两个局部变量:到源点的距离和活跃状态。算法逻辑如下:
- 初始化:源点的距离为0,活跃状态为
True;其他顶点距离为无穷大(可用一个很大的数表示),活跃状态为False。 - 更新规则:顶点聚合所有收到的距离消息,取最小值。如果这个最小值小于当前存储的距离,则更新距离,并将活跃状态设为
True;否则,保持原值,并将活跃状态设为False。 - 发送消息:只有活跃顶点会向其所有邻居发送消息,消息内容为
当前距离 + 1。 - 终止条件:当某一轮中没有顶点发送任何消息(即所有顶点都不活跃)时,算法终止。
以下是GraphFrames中的实现代码框架:
from pyspark.sql.functions import when, min as sql_min
# 假设源顶点ID是“source_id”
initial_distance = when(col(“id”) == lit(“source_id”), lit(0)).otherwise(lit(float(“inf”)))
initial_active = when(col(“id”) == lit(“source_id”), lit(True)).otherwise(lit(False))
bfs_result = g.pregel \
.withVertexColumn(“distance”, initial_distance, # 距离列及初始值
# 更新规则:取收到消息的最小值与当前距离的较小者
sql_min(Pregel.msg(), col(“distance”))) \
.withVertexColumn(“active”, initial_active, # 活跃状态列及初始值
# 更新规则:如果新距离小于旧距离,则为True,否则为False
when(sql_min(Pregel.msg(), col(“distance”)) < col(“distance”), lit(True)).otherwise(lit(False))) \
.sendMsgToDst(when(Pregel.src[“active”] == lit(True), Pregel.src[“distance”] + lit(1))) \
.aggMsgs(sql_min(Pregel.msg())) \
.run()
这个例子展示了如何在Pregel模型中定义多个局部变量(distance和active),并为它们分别指定初始值和更新规则。
扩展到单源最短路径算法
BFS算法计算的是边数最少的路径。如果图中的边带有权重(或长度),我们需要找到总权重最小的路径,这就是单源最短路径问题。
Dijkstra算法是解决非负权重图最短路径问题的经典算法,但它本质上是顺序执行的。而Bellman-Ford算法的思想与Pregel模型天然契合,它可以处理包含负权边(但不能有负权环)的图,并且易于并行化。
Bellman-Ford算法的Pregel实现与BFS几乎完全相同,只有一个关键区别:
- 在BFS中,顶点发送给邻居的消息是
当前距离 + 1。 - 在Bellman-Ford中,顶点发送给邻居的消息是
当前距离 + 该条边的权重。
因此,只需将BFS实现中发送消息的部分从 Pregel.src[“distance”] + lit(1) 修改为 Pregel.src[“distance”] + Pregel.edge[“weight”],就得到了Bellman-Ford算法。该算法会并行地让所有活跃顶点同时尝试更新其邻居,并迭代运行,直到没有距离更新发生为止。
虽然Bellman-Ford在最坏情况下的迭代次数可能较多,但对于许多实际图数据,它通常能在可接受的轮数内收敛,并且提供了良好的并行性。

本节课中我们一起学习了图计算的Pregel模型。我们从基础的aggregateMessages操作出发,理解了以顶点为中心的计算思想。然后,我们深入探讨了如何使用GraphFrames的Pregel API来实现PageRank和广度优先搜索算法,并了解了如何将其思路扩展到更复杂的单源最短路径问题。Pregel模型通过将计算限制在顶点局部和边通信之内,为大规模图数据的并行处理提供了强大而灵活的框架。
020:流处理入门与首个分析示例 🚀
在本节课中,我们将要学习Spark的流处理组件。流处理是处理高速、连续到达数据的关键技术,Spark为此提供了两种主要方式:基于RDD的Spark Streaming和基于Spark SQL的Structured Streaming。我们将重点介绍前者,并通过一个简单的单词计数示例来理解其核心概念和工作流程。
概述
Spark Streaming是Spark用于处理实时数据流的组件。与批处理不同,流处理需要在数据到达时立即进行计算,以避免错失业务机会。Spark Streaming采用了一种称为“微批处理”的方法,将连续的数据流切割成一系列小的、离散的RDD批次进行处理。
核心概念:离散化流
Spark Streaming的核心思想是将数据流视为一系列连续的RDD,称为离散化流。其基本公式可以表示为:

DStream = Seq[RDD[T]]
其中,每个RDD代表一个特定时间间隔内到达的数据。你可以定义这个时间间隔的长度(例如5秒)。间隔越长,吞吐量越高,但延迟也越大;间隔越短,延迟越低,但吞吐量可能下降。
第一个流处理程序:单词计数


下面,我们通过一个具体的例子来演示如何编写一个Spark Streaming应用程序。这个程序将从网络套接字读取文本流,并实时统计每个批次中单词的出现次数。
1. 初始化StreamingContext
任何Spark Streaming程序的入口点都是StreamingContext。与SparkContext不同,你需要手动创建它,并指定批处理的时间间隔。
from pyspark import SparkContext
from pyspark.streaming import StreamingContext
# 创建SparkContext
sc = SparkContext("local[2]", "NetworkWordCount")
# 创建StreamingContext,批处理间隔为5秒
ssc = StreamingContext(sc, 5)
代码解释:
local[2]表示在本地运行,并使用2个CPU核心。流处理至少需要一个核心来运行接收器,另一个核心用于驱动程序。- 第二个参数
5指定每个批处理间隔为5秒。
2. 定义输入源



接下来,我们需要定义数据流的来源。Spark Streaming支持多种输入源,如Kafka、Flume、HDFS以及本例中使用的网络套接字。

# 创建一个DStream,表示从本地主机9999端口接收的数据流
lines = ssc.socketTextStream("localhost", 9999)
为了模拟数据输入,我们需要在另一个终端启动一个Netcat服务器:
nc -lk 9999
之后,在这个终端输入的任何文本都将作为流数据发送到我们的Spark程序中。
3. 定义流计算逻辑
定义好输入后,我们就可以像操作RDD一样对DStream进行转换。以下代码实现了每个批次内的单词计数:
# 将每行文本拆分成单词
words = lines.flatMap(lambda line: line.split(" "))
# 将每个单词映射为 (word, 1) 的键值对
pairs = words.map(lambda word: (word, 1))
# 按单词聚合,统计每个批次内的数量
word_counts_per_batch = pairs.reduceByKey(lambda x, y: x + y)

重要说明:这些操作(flatMap, map, reduceByKey)都是转换操作。它们定义了计算逻辑,但并不会立即执行。在流处理中,我们称输出操作为“输出操作”而非“动作”,因为它们也不触发计算,只是定义了结果的输出方式。

4. 定义输出并启动应用
我们需要指定如何处理计算结果,然后启动流处理应用。
# 输出操作:打印每个批次的前10个元素到控制台
word_counts_per_batch.pprint()
# 启动流计算
ssc.start()
print(“流处理应用已启动”)
# 等待计算终止(这里为了演示,只等待60秒)
ssc.awaitTermination(60)
# 停止StreamingContext(但不停止SparkContext,以便后续重用)
ssc.stop(stopSparkContext=False)
print(“流处理应用已完成”)
关键点总结:
- 启动:
ssc.start()是启动后台流处理作业的唯一方法。 - 等待与停止:
awaitTermination()会阻塞驱动程序,等待流处理作业停止。在实际应用中,作业会长期运行;在开发时,我们设置一个超时时间。停止StreamingContext是良好习惯。 - 状态管理:一旦
StreamingContext启动,就不能再向其添加新的计算逻辑。停止后也不能重启,必须创建新的实例。
使用队列流进行测试
在开发时,从终端输入数据并不方便。Spark提供了queueStream,允许我们用一系列内存中的RDD来模拟数据流,非常适合测试。
以下是使用队列流的示例步骤:
首先,准备一批数据并将其分割成多个RDD,每个RDD模拟一个时间间隔到达的数据。
# 读取一个文件作为数据源
text_file = sc.textFile(“your_large_text_file.txt”)
# 将数据随机分割成5个RDD,模拟5个批次
rdd_queue = text_file.randomSplit([1,1,1,1,1], seed=123)
# 创建队列流DStream
lines = ssc.queueStream(rdd_queue)
然后,可以复用之前的单词计数逻辑。Spark会依次从队列中取出RDD,每隔5秒(或你设定的间隔)将一个RDD注入到lines这个DStream中,从而模拟真实的流式输入。
有状态计算:累计单词计数
上面的例子是无状态的,每个批次的处理与其他批次无关。然而,很多场景需要有状态计算,例如累计从流开始至今所有单词的总数。
Spark通过updateStateByKey操作支持有状态计算。它允许你定义一个函数,根据新批次的数据和当前的“状态”(也是一个键值对RDD)来更新状态。
以下是如何实现累计单词计数:
# 首先需要设置检查点目录来可靠地存储状态
ssc.checkpoint(“./checkpoint_dir”)
def update_function(new_values, running_count):
“””
new_values: 当前批次中,某个单词对应的计数列表(例如 [1, 1, 1])
running_count: 该单词之前累计的计数(初始为None)
“””
# 如果之前没有该单词的记录,则当前累计值设为0
current_sum = sum(new_values)
previous_sum = running_count[0] if running_count is not None else 0
return current_sum + previous_sum
# 初始的单词映射
pairs = words.map(lambda word: (word, 1))
# 应用updateStateByKey,传入状态更新函数
running_counts = pairs.updateStateByKey(update_function)
# 输出累计结果
running_counts.pprint()
状态更新过程解析:
假设第一个批次有单词 (‘a‘, 1), (‘a‘, 1), (‘b‘, 1)。
- 对于键
’a‘:new_values = [1, 1],running_count = None。函数返回2 (1+1) + 0 = 2。状态中’a‘的计数更新为2。 - 对于键
’b‘:new_values = [1],running_count = None。函数返回1 + 0 = 1。状态中’b‘的计数更新为1。
第二个批次到来 (‘a‘, 1), (‘c‘, 1)。
- 对于键
’a‘:new_values = [1],running_count = 2。函数返回1 + 2 = 3。状态更新为3。 - 对于键
’c‘:new_values = [1],running_count = None。函数返回1 + 0 = 1。状态中新增’c‘,计数为1。 - 键
’b‘未出现在新批次中,状态保持不变。
最终状态为:{‘a‘: 3, ‘b‘: 1, ‘c‘: 1}。通过这种方式,我们实现了跨批次的累计计数。
注意:有状态计算的状态会持续增长(例如,不重复单词数会越来越多),在长期运行的作业中需要考虑状态管理策略(如超时清除)。
总结
本节课我们一起学习了Spark Streaming的基础知识。我们首先了解了流处理的概念和Spark的微批处理模型,即将数据流离散化为DStream。接着,我们动手编写了第一个流处理程序——网络单词计数器,涵盖了从创建StreamingContext、定义输入源、进行转换操作到启动和停止作业的完整流程。我们还学习了使用queueStream方便地进行程序测试。最后,我们探讨了更高级的有状态计算,通过updateStateByKey函数实现了跨批次的累计单词计数,这是处理许多实时分析任务的关键。

通过本节的学习,你应该已经掌握了使用Spark Streaming构建基本实时应用程序的能力。记住,对于生产环境,你可能需要探索更稳定的Structured Streaming API以及Kafka等企业级数据源。
021:流式算法与窗口计算
在本节课中,我们将学习流式算法的核心概念,并通过几个经典示例(如缺失卡片谜题、蓄水池抽样和多数投票问题)来理解其工作原理。我们还将探讨如何在Spark Streaming中应用这些算法,并介绍滑动窗口这一重要概念。
流式算法概述
上一节我们介绍了流式处理的基本模型。本节中我们来看看什么是流式算法。
流式算法是一种使用有界空间的算法。其空间使用量不会增长,也不依赖于数据流的长度。
为了便于理解,首先给出一个非常简单的流式算法示例,它实际上是一个谜题。
缺失卡片谜题 🃏
假设有一副52张的扑克牌。我从中抽出一张,然后将剩下的51张牌交给你。
你只有一个非常基础的计算器,可以进行简单的算术运算,但记忆力很差,无法记住看到的每一张牌。
任务是通过仅一次遍历这51张牌,找出缺失的是哪张牌。
这正是流式模型。如果采用朴素算法,即记住看到的每一张牌,那么该算法所需的空间会随着流长度的增长而增长。因此,朴素算法不是流式算法。
但实际上,存在一种非常巧妙的算法,可以用恒定空间解决这个问题。
具体做法如下:
首先,将牌从1到52编号。每张牌由一个1到52的数字表示。
如果没有牌缺失,正确的总和将是 1 + 2 + ... + 52。
有一个众所周知的公式:总和 = n * (n + 1) / 2,其中 n = 52。
因此,正确总和为 52 * 53 / 2 = 1378。
如果缺失一张牌,我无法恢复总和,但可以计算差值。这个差值正好告诉我缺失了哪张牌。
我只需要计算剩余51张牌的总和,然后与正确的总和比较,就能知道缺失了哪张牌。
一个简单的扩展是:如果缺失两张牌怎么办?仅计算总和无法解决,因为只能知道两张缺失牌的数字之和。
另一个想法是:不仅计算总和,还计算乘积。然而,这会导致数字溢出,因为52的阶乘非常大。
技巧是计算平方和。同样有公式:平方和 = n * (n + 1) * (2n + 1) / 6。
这样,我维护两个变量:总和与平方和。然后得到两个方程,可以解出两个缺失的数字。
这仍然只需要两个变量,因此也是一个流式算法。
只要算法所需的空间不依赖于流的长度,它就被视为流式算法。
蓄水池抽样算法 🎲
第一个被认为是流式算法的著名算法是蓄水池抽样算法。这也是每个数据科学家都应该知道的算法之一。
它的用途是:从迄今为止流中的所有元素中,维护一个固定大小的样本。
该算法非常简单,最早出现在Donald Knuth的《计算机程序设计艺术》中。
算法步骤如下:
- 首先保留流中的前
s个元素。设n = s,代表当前流长度。 - 对于任何新元素:
- 首先递增
n。 - 每个元素应以概率
s / n被抽样。因此,我以这个概率抽样新元素。 - 如果新元素被抽中,当前样本数会变成
s + 1,过多。因此,我需要从当前样本中随机踢出一个元素。 - 否则,直接丢弃新元素。
- 首先递增
这就是蓄水池抽样算法。
举例说明:
假设样本大小 s = 3。前三个元素是 A, B, C。初始样本就是 [A, B, C]。
当 D 到达时,n = 4。以概率 3/4 抽样 D。
如果抽中 D,则需要以各 1/3 的概率用 D 替换 A、B 或 C 中的一个。假设 B 被替换,样本变为 [A, D, C]。
下一个元素 E 到达,n = 5。以概率 3/5 抽样 E。
假设未抽中 E,样本不变。
下一个元素 F 到达,n = 6。以概率 3/6 抽样 F。
如果抽中 F,则需要从当前样本 [A, D, C] 中随机踢出一个。假设 A 被踢出,样本变为 [F, D, C]。
需要提醒的是,互联网上许多关于蓄水池抽样的证明实际上是错误的。常见的错误证明只证明了每个元素被抽中的概率相同,但这只是随机抽样的必要条件,而非充分条件。
正确的证明依赖于 Fisher-Yates 洗牌算法。幸运的是,维基百科、Stack Overflow 等权威网站有正确的证明。
多数投票问题与空间有界计数 🗳️
回到我们的词频统计例子。在词频统计中,我不希望状态无限增长,而是希望限制状态大小。
但不幸的是,有一个负面结果表明这无法精确实现。如果你想记住每个单词的精确计数,状态必须增长。
因此,我们将寻求一个近似解决方案。我们将记住每个单词的近似计数。
这引出了文献中另一个有趣的谜题:多数投票问题。
这里严格来说不是一个流式算法,因为允许对序列进行两次遍历。
问题是:识别是否存在一个多数元素(即出现频率超过 n/2 的元素),如果存在则找出它。
例如,给定一个字母序列,询问是否存在多数元素。
这个问题最初由投票计数场景激发。标准算法是为每个候选人保留一个计数。但如果候选人很多,内存有限,无法为每个候选人保留计数。
令人惊讶的是,存在一个非常简洁优雅的算法,只使用一个计数器,通过两次遍历来解决这个问题。
算法描述如下(使用一个计数器):
- 第一次遍历:
- 初始化计数器
count = 0,候选元素candidate = null。 - 遍历每个元素
x:- 如果
count == 0,设candidate = x,count = 1。 - 否则,如果
x == candidate,则count++。 - 否则,
count--。
- 如果
- 初始化计数器
- 第二次遍历:
- 验证第一步得到的
candidate在序列中出现的次数是否真的超过n/2。
- 验证第一步得到的
断言:如果存在多数元素,那么它一定是在第一次遍历结束后存活下来的那个元素(即 candidate)。
但存活下来的元素不一定是多数元素。如果不存在多数元素,算法会在第二次遍历验证后报告“无多数元素”。
这个算法之所以有效,是因为每次 count-- 操作都相当于消除了两个不同的元素。因此,最多可以进行 n/2 次消除操作。如果一个元素出现超过 n/2 次,它一定能存活到最后。
然而,负面结果表明,如果只允许一次遍历,则无法用恒定内存空间精确解决多数问题。
Misra-Gries 近似频数估计算法 📊
虽然无法用一次遍历精确解决,但可以近似解决。这就是 Misra-Gries 算法。
该算法使用最多 k 个计数器,可以将误差控制在 n / (k + 1) 以内。k 越大,误差越小。
算法步骤如下(使用 k 个计数器):
- 维护一个最多包含
k个条目(元素-计数对)的列表。 - 对于流中每个新元素
x:- 如果
x已在列表中,则将其计数加1。 - 否则,如果列表中的条目数少于
k,则将(x, 1)加入列表。 - 否则(列表已满,有
k个条目),将列表中所有条目的计数减1,并移除计数变为0的条目。
- 如果
误差分析:每次“所有计数器减1”操作,相当于消除了 k + 1 个不同的元素。因此,最多可以进行 n / (k + 1) 次这样的操作。
对于任何元素,其真实计数至少等于计数器中的值,最多等于计数器值加上总的减1操作次数。因此,误差上界为 n / (k + 1)。
例如,如果 k = 100,误差约为 1%,这是一个非常精确的算法,但仍无法保持精确计数。
几点说明:
- 我们可以轻松跟踪实际的减1操作次数。上界是
n / (k + 1),但实际次数可能更少,取决于数据分布。 - 在许多真实数据集上,真实计数通常更接近上界(即
计数器值 + 减1操作次数)。直觉是,频繁出现的项很少被完全移除,但每次减1操作都会影响它,因此其上界估计更准确。
在 Spark Streaming 中实现并行化 ⚡
当我们在 Spark 中实现此算法时,意识到它是一个顺序算法,无法充分利用集群的计算能力。因此,我们需要设计一个并行版本,特别是适用于 Spark Streaming 微批处理模式的版本。
核心思想基于两个观察:
- 操作解耦:增加计数和减少计数操作可以解耦。我们可以先并行处理一批数据中的所有增量操作,然后再统一处理所需的减量操作。
- 并行执行:解耦后,增量和减量操作都可以并行执行。
具体实现思路:
- 增量阶段:与标准词频统计类似,对当前批次的数据进行聚合,更新状态中的计数器。
- 减量阶段:关键问题是“需要执行多少次减1操作?”。
- 假设我们有
m个计数器,预算为k。我们需要找到第k小的计数值(即阈值t)。 - 所有计数大于
t的计数器,将其计数减去t。 - 所有计数等于
t的计数器,部分需要减去t以将总条目数减少到k。 - 这本质上是一个选择问题(找出第
k小的元素),我们在课程开始时就讨论过。
- 假设我们有
- 结合这两个想法,我们可以在 Spark Streaming 中实现该算法。
在实现中,我们仍然使用状态来存储计数器。增量操作与之前相同。对于减量操作,我们不逐个进行,而是先对计数器RDD排序(或使用更高效的选择算法),找到第 k 小的值作为阈值,然后通过一个 updateStateByKey 类似的函数并行地对所有超过阈值的计数器进行削减。
这样,在每个批次处理后,状态大小可能会暂时超过 k(最多 k + 批次大小),但经过减量阶段后,会立即恢复到 k 左右。
滑动窗口计算 🪟
在许多应用中,我们并不关心整个历史,而只关心最近的数据。这通常通过滑动窗口来建模。

滑动窗口需要定义两个参数:
- 窗口长度:计算所覆盖的时间范围。
- 滑动间隔:每次窗口移动的时间间隔。

在 Spark Streaming 中,这两个参数必须是批次间隔的整数倍,因为批次是处理数据的最小单位。
例如,创建一个批次间隔为3秒的 StreamingContext。然后执行一个 reduceByWindow 操作,窗口长度为9秒,滑动间隔为3秒。这意味着每个窗口包含3个批次,并且每处理完一个批次(3秒),窗口就滑动一次,产生一个新的计算结果。
这非常适用于需要实时监控最近一段时间内指标的场景,如最近10分钟的网站访问量、错误率等。
总结

本节课中,我们一起学习了流式算法的核心特征——有界空间,并通过缺失卡片谜题理解了其思想。我们探讨了蓄水池抽样这一经典流式抽样算法。针对状态增长问题,我们分析了精确多数投票算法及其局限性,并深入学习了 Misra-Gries 近似频数估计算法,该算法能以可控的误差在恒定空间内估计频率。最后,我们讨论了如何将 Misra-Gries 算法并行化以适应 Spark Streaming 的微批处理模型,并介绍了用于处理近期数据的滑动窗口概念。这些算法和概念是构建高效、可扩展流式处理应用的基础。
022:结构化流处理 🚀
概述
在本节课中,我们将要学习Spark中的结构化流处理。我们将了解它如何基于Spark SQL和数据框,将流数据视为无界表,并允许我们使用熟悉的SQL语法来处理流数据。我们还将探讨不同的输出模式以及如何处理流处理中特有的“迟到数据”问题。
从RDD API到Spark SQL的迁移
上一节我们介绍了基于RDD的Spark Streaming。本节中我们来看看基于Spark SQL的新组件——结构化流处理。
结构化流处理基于数据框。其核心思想是将一个数据流建模为一个无界的表。这样,开发者就可以直接对流数据编写SQL语句。每当一个新的数据批次到达时,SQL查询就会被执行一次。
输出模式
以下是结构化流处理中几种不同的输出模式,用于控制查询结果如何更新。
完全模式
完全模式是最简单的一种。在每一个批次处理后,系统会执行Spark SQL查询,计算截至目前接收到的所有数据的结果,并输出完整的计算结果。
例如,在词频统计中:
- 第一个批次输入:“1 can 3 dogs”, 输出:
{can:1, dogs:3} - 第二个批次输入:“2 cats”, 输出:
{can:1, dogs:3, cats:2}

完全模式等同于包含整个历史的状态化词频统计。

代码示例
以下是核心代码示例,展示了结构化流处理的便利性:
lines = spark.readStream.text("source_path") # 读取流数据
words = lines.select(explode(split(lines.value, " ")).alias("word")) # 将一行文本拆分为多个词
wordCounts = words.groupBy("word").count() # 按词分组计数
query = wordCounts.writeStream.outputMode("complete").format("console").start() # 以完全模式输出
虽然 lines 是一个数据流,但你可以像操作静态数据框一样使用 select、groupBy 等操作。只要熟悉Spark SQL,就能轻松处理流数据。
追加模式
完全模式并非总是最佳选择,因为输出结果会不断增长,且相同的结果会被重复打印。追加模式则只输出新增的结果。
追加模式只能用于那些不会更改旧结果的查询。例如,一个简单的过滤操作(filter)可以使用追加模式,因为每个批次独立处理,新结果不会影响旧结果。
然而,如果查询包含聚合操作(如 count、sum),则不能使用追加模式,因为新数据到来会改变历史聚合结果。
更新模式
更新模式是另一种选择。在此模式下,系统会输出自上次触发后结果发生更新的行。这对于聚合查询很有用,你只会看到计数发生变化的词,而不是完整的列表。
处理迟到数据:事件时间与水位线
流处理系统一个特有的问题是数据可能迟到。这里涉及两个时间概念:
- 事件时间:事件实际发生的时间。
- 处理时间:系统处理该事件的时间。
迟到可能由多种原因造成,如数据源延迟、系统内部故障恢复等。
水位线机制
Spark结构化流处理使用水位线机制来优雅地处理迟到数据。水位线定义了系统能容忍的延迟阈值(例如10分钟)。
其保证是:
- 系统保证处理延迟小于10分钟的数据。
- 延迟超过10分钟的数据,不保证会被处理(系统会尽最大努力,但可能丢弃)。
这是一种单边保证,确保在可控延迟内的数据准确性,同时避免系统为等待可能永远不到的数据而无限期保留状态。
输出模式与迟到数据
处理迟到数据时,输出模式的选择至关重要:
- 更新模式:系统可以更新先前已输出的结果。例如,一个迟到的词会使该词的计数更新并再次输出。这能让你尽快看到结果,但需要意识到这些结果后续可能被更改。
- 追加模式:系统只输出确定不会再被更改的结果。这意味着必须等待水位线超过某个窗口的结束时间后,该窗口的结果才会被输出。结果一旦输出就是最终正确的,但你需要等待更长时间才能看到它们。
开发者可以根据应用对数据准确性和时效性的要求,在追加模式和更新模式之间进行选择。

总结
本节课中我们一起学习了Spark的结构化流处理。我们了解到它通过将流数据抽象为无界表,提供了基于Spark SQL的、声明式的流处理API。我们探讨了完全、追加和更新三种输出模式及其适用场景。最后,我们深入研究了流处理中的核心挑战——迟到数据,并学习了如何通过事件时间和水位线机制来管理它,以及不同输出模式在迟到数据处理上的不同行为。

浙公网安备 33010602011771号