Spark-流式处理-全-

Spark 流式处理(全)

原文:zh.annas-archive.org/md5/d54caafbbbe7f16e6b11e4390e13943a

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

欢迎来到使用 Apache Spark 进行流处理

自 2009 年在加州大学伯克利分校由 Matei Zaharia 首次启动以来,Apache Spark 项目及其与 Apache Spark 相关的流处理技术取得了巨大的进展,这让人非常兴奋。Apache Spark 最初作为大数据处理的第一个统一引擎起步,并已发展成为所有大数据处理的事实标准。

使用 Apache Spark 进行流处理是对 Apache Spark 作为流处理引擎的概念、工具和能力的绝佳介绍。本书首先介绍了理解现代分布式处理所必需的核心 Spark 概念。然后探讨了不同的流处理架构以及它们之间的基本架构权衡。最后,它说明了 Apache Spark 中的结构化流处理如何轻松实现分布式流应用程序。此外,本书还涵盖了旧版 Spark Streaming(又称 DStream)API,用于构建具有传统连接器的流应用程序。

总之,本书涵盖了使用 Apache Spark 构建和操作流应用程序的所有必备知识!我们期待您将会构建的应用程序!

Tathagata Das

Spark Streaming 和 Structured Streaming 的共同创始人

Michael Armbrust

Spark SQL 和 Structured Streaming 的共同创始人

Bill Chambers

Spark:权威指南的合著者

2019 年 5 月

序言

本书适合谁阅读?

我们为那些对数据有亲和力并希望在流处理领域改进知识和技能的软件专业人士创建了这本书,他们已经熟悉或希望使用 Apache Spark 进行他们的流应用程序。

我们已经包含了一个全面介绍流处理背后概念的部分。这些概念是理解 Apache Spark 提供的两个流 API:结构化流处理和 Spark 流处理的基础。

我们深入探讨了这些 API,并提供了从我们的经验中得出的关于其特性、应用和实际建议的见解。

除了覆盖 API 及其实际应用外,我们还讨论了每位流处理从业者工具箱中应包含的几种高级技术。

所有级别的读者都将从本书的入门部分中受益,而更有经验的专业人士将从覆盖的高级技术中获得新的见解,并将获得关于如何进一步学习的指导。

我们没有对您对 Spark 所需的知识做任何假设,但不熟悉 Spark 数据处理能力的读者应该注意,在本书中,我们专注于其流处理能力和 API。如果您希望了解 Spark 的更广泛能力和生态系统,我们推荐 Bill Chambers 和 Matei Zaharia(O’Reilly)的 Spark: The Definitive Guide

本书使用的编程语言是 Scala。虽然 Spark 提供了 Scala、Java、Python 和 R 的绑定,但我们认为 Scala 是流应用程序的首选语言。尽管许多代码示例可以转换为其他语言,但某些领域,如复杂的状态计算,最好使用 Scala 编程语言。

安装 Spark

Spark 是由 Apache 基金会正式托管的开源项目,但主要在 GitHub 上进行开发。您也可以通过以下地址作为二进制预编译包下载:https://spark.apache.org/downloads.html

从那里开始,您可以在一个或多个机器上运行 Spark,我们将在稍后解释。各大 Linux 发行版都提供了包,这些包应该有助于安装。

出于本书的目的,我们使用的示例和代码与 Spark 2.4.0 兼容,除了输出和格式细节的小改动外,这些示例应该与未来的 Spark 版本保持兼容。

请注意,Spark 是一个在 Java 虚拟机(JVM)上运行的程序,您应该在任何 Spark 组件将运行的每台机器上安装并访问它。

要安装 Java 开发工具包(JDK),我们建议使用 OpenJDK,它在许多系统和架构上都有打包。

您也可以 安装 Oracle JDK

Spark 和任何 Scala 程序一样,可以在安装了 JDK 6 或更高版本的任何系统上运行。Spark 的推荐 Java 运行时版本取决于其版本:

  • 对于低于 2.0 版本的 Spark,建议使用 Java 7。

  • 对于 2.0 及以上版本的 Spark,建议使用 Java 8。

学习 Scala

本书中的示例均使用 Scala 语言编写。这是核心 Spark 的实现语言,但远非唯一可用的语言;截至本文撰写时,Spark 还提供了 Python、Java 和 R 的 API。

Scala 是当今功能最完整的编程语言之一,它既提供了函数式特性又提供了面向对象的特性。然而,它的简洁性和类型推断使其语法的基本元素易于理解。

作为初学者语言,Scala 具有许多优点,从教学的角度来看,其正则的语法和语义是最重要的之一。

伯恩·雷格内尔,隆德大学

因此,我们希望示例足够清晰,以便读者能够理解它们的含义。然而,对于希望通过书籍来学习语言并更为舒适的读者,我们建议使用Atomic Scala [Eckel2013]

《前路》

本书分为五个部分:

  • 第一部分详细阐述和深化了我们在前言中讨论的概念。我们涵盖了流处理的基本概念,实现流处理的一般架构蓝图,并深入研究了 Spark。

  • 在第二部分中,我们学习了结构化流处理,其编程模型以及如何实现从相对简单的无状态转换到高级有状态操作的流应用程序。我们还讨论了它与支持全天候运营的监控工具的集成,并探讨了当前正在开发中的实验领域。

  • 在第三部分中,我们学习了 Spark Streaming。与结构化流处理类似,我们学习了如何创建流应用程序,操作 Spark Streaming 作业,并将其与 Spark 中的其他 API 集成。我们在这一部分结束时还提供了性能调优的简要指南。

  • 第四部分介绍了高级流处理技术。我们讨论了使用概率数据结构和近似技术来解决流处理挑战,并研究了在线机器学习在 Spark Streaming 中的有限空间。

  • 最后,在第五部分中,我们将进入超越 Apache Spark 的流处理领域。我们将调查其他可用的流处理器,并提供进一步学习 Spark 和流处理的步骤。

我们建议您通过第一部分来理解支持流处理的概念。这将有助于在本书的其余部分使用统一的语言和概念。

第二部分,结构化流处理,和第三部分,Spark 流处理,遵循一致的结构。您可以选择先学习其中一个,以符合您的兴趣和最紧迫的优先事项:

  • 也许您正在启动一个新项目,想了解结构化流处理?可以查看!从第二部分开始。

  • 或者您可能正在进入使用 Spark 流处理的现有代码库,想更好地理解它?从第三部分开始。

第四部分 最初会深入探讨理解所讨论的概率结构所需的一些数学背景。我们喜欢把它称为“前路险峻,风景优美”。

第五部分 将把使用 Spark 进行流处理与其他可用的框架和库放在一起进行比较。这可能会帮助您在决定采用特定技术之前尝试一个或多个替代方案。

本书的在线资源通过提供笔记本和代码来补充您的学习体验,您可以自己使用和实验。或者,您甚至可以借用一段代码来启动自己的项目。在线资源位于https://github.com/stream-processing-with-spark

我们真诚地希望您享受阅读本书的乐趣,就像我们享受整理所有信息和捆绑体验一样。

参考文献

  • [Eckel2013] Eckel, Bruce 和 Dianne Marsh,Atomic Scala(Mindview LLC, 2013)。

  • [Odersky2016] Odersky, Martin, Lex Spoon, and Bill Venners,Scala 编程,第三版(Artima Press, 2016)。

本书使用的约定

本书采用以下排版约定:

斜体

表示新术语、网址、电子邮件地址、文件名和文件扩展名。

固定宽度

用于程序清单,以及在段落中引用程序元素,如变量或函数名称、数据库、数据类型、环境变量、语句和关键字。

固定宽度粗体

显示用户需要按照字面意思输入的命令或其他文本。

固定宽度斜体

显示应由用户提供值或根据上下文确定值替换的文本。

提示

此元素表示提示或建议。

注意

此元素表示一般说明。

警告

此元素表示警告或注意事项。

使用代码示例

本书的在线库包含了增强学习体验的补充材料,包括交互式笔记本、工作代码示例和一些项目,让您可以实验并获得有关所涵盖主题和技术的实用见解。它可以在https://github.com/stream-processing-with-spark*找到。

所包含的笔记本运行在 Spark Notebook 上,这是一个专注于使用 Scala 处理 Apache Spark 的开源、基于 Web 的交互式编码环境。其实时小部件非常适合处理流应用程序,因为我们可以将数据可视化,看到它在系统中的传递过程中发生的情况。

Spark Notebook 可以在https://github.com/spark-notebook/spark-notebook找到,并且预构建版本可以直接从它们的分发站点http://spark-notebook.io下载。

本书旨在帮助您完成工作。一般情况下,如果本书提供示例代码,您可以在您的程序和文档中使用它。除非您复制了代码的大部分,否则无需联系我们以获得许可。例如,编写一个使用本书多个代码块的程序不需要许可。出售或分发奥莱利书籍示例的 CD-ROM 需要许可。引用本书并引用示例代码回答问题不需要许可。将本书大量示例代码整合到产品文档中需要许可。

我们感谢,但不要求署名。署名通常包括标题、作者、出版商和 ISBN。例如:“使用 Apache Spark 进行流处理,作者 Gerard Maas 和 François Garillot(奥莱利)。版权 2019 年 François Garillot 和 Gerard Maas Images,978-1-491-94424-0。”

如果您觉得您使用的代码示例超出了公平使用或以上授予的权限,请随时通过permissions@oreilly.com联系我们。

奥莱利在线学习

注意

近 40 年来,奥莱利传媒已经为企业提供技术和商业培训、知识和洞察,帮助它们取得成功。

我们独特的专家和创新者网络通过书籍、文章、会议和我们的在线学习平台分享他们的知识和专业知识。奥莱利的在线学习平台为您提供按需访问实时培训课程、深入学习路径、交互式编码环境以及来自奥莱利和其他 200 多个出版商的大量文本和视频内容。有关更多信息,请访问http://oreilly.com

如何联系我们

请将关于本书的评论和问题发送给出版商:

  • 奥莱利传媒,公司

  • 北格拉文斯坦高速公路 1005 号

  • 加利福尼亚州塞巴斯托波尔 95472

  • 800-998-9938(美国或加拿大)

  • 707-829-0515(国际或本地)

  • 707-829-0104(传真)

我们有一个专门为本书设立的网页,上面列出了勘误、示例和任何额外信息。您可以访问这个网页,地址是http://bit.ly/stream-proc-apache-spark

要对本书提出评论或询问技术问题,请发送电子邮件至bookquestions@oreilly.com

获取有关我们的图书、课程、会议和新闻的更多信息,请访问我们的网站:http://www.oreilly.com

在 Facebook 上找到我们:http://facebook.com/oreilly

在 Twitter 上关注我们:http://twitter.com/oreillymedia

在 YouTube 上观看我们:http://www.youtube.com/oreillymedia

致谢

这本书从最初的 Spark Streaming 学习手册,发展成为 Apache Spark 流处理能力的全面资源。我们要感谢审阅人员提供的宝贵反馈,这些反馈帮助我们将本书引导到现在的形式。我们特别感谢来自 Datastax 的 Russell Spitzer、Facebook 的 Serhat Yilmaz 和 Klarrio 的 Giselle Van Dongen。

我们要感谢 Holden Karau 在起草初期为我们提供的帮助和建议,以及 Bill Chambers 在我们增加结构化流处理覆盖范围时的持续支持。

我们在 O’Reilly 的编辑 Jeff Bleiel,在从初期想法和版本的草稿进展到您手中的内容完成过程中,一直是耐心、反馈和建议的坚强支柱。我们也要感谢 Shannon Cutt,她是我们在 O’Reilly 的第一位编辑,在启动这个项目时提供了大量帮助。O’Reilly 的其他人员在许多阶段都帮助过我们,推动我们向前迈进。

我们感谢 Tathagata Das 在 Spark Streaming 早期阶段的许多交流,特别是当我们在推动框架能够实现的极限时。

来自 Gerard

我要感谢我的同事在 Lightbend 的支持和理解,他们在我同时兼顾书写和工作职责时给予了支持。特别感谢 Ray Roestenburg 在困难时刻的鼓励;Dean Wampler 一直支持我在这本书中的努力;以及 Ruth Stento 在写作风格上的出色建议。

特别感谢 Kurt Jonckheer、Patrick Goemaere 和 Lieven Gesquière,他们创造了这个机会,并给了我深入了解 Spark 的空间;还要感谢 Andy Petrella 创建了 Spark Notebook,更重要的是,他的热情和激情影响了我继续探索编程和数据交汇的领域。

最重要的是,我要无限感谢我的妻子 Ingrid,我的女儿 Layla 和 Juliana,以及我的母亲 Carmen。没有她们的爱、关怀和理解,我无法完成这个项目。

来自 François

在撰写这本书期间,我非常感激在瑞士电信和 Facebook 的同事们的支持;感谢 Chris Fregly、Paco Nathan 和 Ben Lorica 的建议和支持;感谢我的妻子 AJung 的一切。

第一部分:Apache Spark 流处理基础

本书的第一部分致力于构建关于流处理基础概念的坚实基础,以及作为流式引擎的 Apache Spark 的理论理解。

我们首先讨论了当今企业采用流处理技术和系统背后的动机因素(第一章)。然后,我们建立了流处理中常见的词汇和概念(第二章)。接下来,我们快速回顾了我们是如何走到当前技术水平的,我们讨论了不同的流处理架构(第三章),并概述了 Apache Spark 作为流式引擎的理论理解(第四章)。

此时,读者有机会直接跳转到更加实用导向的讨论,即第二部分的 Structured Streaming 或第三部分的 Spark Streaming。

对于那些希望在进入 API 和运行时之前深入了解的人,我们建议您继续阅读关于 Spark 分布式处理模型的内容,位于第五章,在这里我们介绍了核心概念,这些概念将帮助您更好地理解 Spark Streaming 和 Structured Streaming 所提供的不同实现、选项和特性。

在第六章,我们进一步深入了解 Spark 实现的弹性模型,以及它如何消除开发人员在实现可靠的流式应用程序方面的困扰,这些应用程序可以运行企业关键工作负载 24/7。

带着这些新知识,我们准备进入 Spark 的两个流式 API,在本书的后续部分进行探讨。

第一章:引入流处理

2011 年,马克·安德森(Marc Andreessen)著名地说过,“软件正在吞噬世界”,指的是在许多企业面临数字转型挑战的时代,数字经济蓬勃发展。成功的在线企业采用“在线”和“移动”运营模式,正在取代传统的“实体店”对手。

例如,想象一下在摄影店购买新相机的传统体验:我们会去店里转转,可能向店员询问几个问题,最终决定购买一个满足我们需求和期望的型号。完成购买后,店铺会记录信用卡交易,或者在现金支付时只会记录现金余额的变化,店长会知道他们库存中的特定相机型号减少了一个。

现在,让我们将这种体验带到在线环境中:首先,我们开始在网上搜索。我们访问了几家在线商店,我们在一个又一个商店之间留下了数字足迹。网站上的广告突然开始向我们展示我们查看的相机及其竞争对手的促销活动。最终,我们找到了一家在线商店提供给我们最好的交易并购买了相机。我们创建了一个账户。我们的个人数据被注册并与购买相关联。在我们完成购买时,我们被提供了其他人购买相同相机时可能喜欢的额外选项。我们的每一次数字交互,比如在网上搜索关键词,点击某些链接或花时间阅读特定页面,都会生成一系列事件,这些事件被收集并转化为业务价值,比如个性化广告或升级推荐。

评论安德森的话,2015 年,德里斯·布伊塔特(Dries Buytaert)说,“不,实际上,数据正在吞噬世界。”他的意思是,今天的颠覆性公司不再仅仅因为他们的软件而颠覆,而是因为他们收集的独特数据以及将这些数据转化为价值的能力。

流处理技术的采用是由企业日益增长的需求驱动的,以改善其在运营环境中对变化做出反应和适应的时间。这种随数据到来即时处理的方式提供了技术和战略上的优势。这种持续采用的示例包括互联网商务等行业,这些行业通过与客户的 24/7 互动创建了持续运行的数据管道,或者信用卡公司,分析交易在发生时以便检测和阻止欺诈活动。

另一个推动流处理的因素是,我们生成数据的能力远远超过我们理解数据的能力。我们不断增加个人和专业环境中计算能力设备的数量<破折号>电视机、连接汽车、智能手机、自行车电脑、智能手表、监控摄像机、恒温器等等。我们正在用设备围绕自己产生事件日志:代表设备历史上的行动和事件流的消息流。随着我们将这些设备越来越多地互联起来,我们创造了一种能够访问和因此分析这些事件日志的能力。这种现象为我们在近乎实时数据分析领域开启了令人难以置信的创意和创新的大爆发,前提是我们找到一种使分析成为可能的方法。在这个聚合事件日志的世界中,流处理提供了最节约资源的方式来促进数据流的分析。

毫不奇怪的是,数据正在吞噬这个世界,流数据也是如此。

在本章中,我们将使用 Apache Spark 开始我们的流处理之旅。为了让我们能够讨论 Spark 在流处理领域的能力,我们需要建立对流处理的共同理解,其应用及其挑战。在我们建立这种共同语言之后,我们将 Apache Spark 作为一个通用的数据处理框架引入,能够使用统一模型处理批处理和流处理工作负载的要求。最后,我们将重点介绍 Spark 的流处理能力,其中我们介绍了两个可用的 API:Spark Streaming 和 Structured Streaming。我们简要讨论它们的显著特点,以提供一个关于本书其余部分内容的预览。

什么是流处理?

流处理是一种从无界数据中提取信息的学科和相关技术集合。

在他的书籍流式系统中,Tyler Akidau 如下定义了无界数据:

一种数据集类型,大小是无限的(至少在理论上是这样)。

鉴于我们的信息系统建立在诸如内存和存储容量等有限资源的硬件上,它们不可能容纳无界数据集。相反,我们观察数据在处理系统中的接收形式,即随时间流逝事件的流。我们称之为数据

相比之下,我们将有界数据定义为已知大小的数据集。我们可以计算有界数据集中元素的数量。

批处理与流处理的比较

我们如何处理这两种类型的数据集?通过批处理,我们指的是对有界数据集进行的计算分析。在实际应用中,这意味着可以从某种形式的存储中作为整体获取和检索这些数据集。我们在计算过程开始时知道数据集的大小,并且该过程的持续时间是有限的。

相比之下,流处理关注数据到达系统时的处理。考虑到数据流的无界特性,流处理器需要持续运行,直至数据流不再提供新数据。理论上,这可能是永久的。

流处理系统应用编程和操作技术,以利用有限的计算资源处理潜在无限的数据流。

流处理中的时间概念

数据可以以两种形式出现:

  • 静态状态,以文件形式、数据库内容或其他某种记录形式存在

  • 运动中,作为连续生成的信号序列,如传感器的测量或移动车辆的 GPS 信号

我们已经讨论过,流处理程序是一种假设其输入可能无限大的程序。更具体地说,流处理程序假设其输入是一系列信号,长度不确定,随时间观察

从时间线的角度来看,静态数据是过去的数据:可以说,所有有界数据集,无论是存储在文件中还是包含在数据库中,最初都是随时间收集到某种存储中的数据流。用户数据库,上个季度所有订单,城市中出租车行程的 GPS 坐标等,都始于存储库中收集的各个事件。

尝试推理数据在运动中更具挑战性。原始生成数据和其可用于处理之间存在时间差。该时间差可能非常短,如在同一数据中心生成和处理的网络日志事件,或者更长,如汽车通过隧道时的 GPS 数据,该数据仅在车辆离开隧道后重新建立无线连接时才可用于处理。

我们可以观察到,事件生成和流处理系统处理时存在不同的时间线。这些时间线如此重要,以至于我们给它们特定的名称:

事件时间

事件创建的时间。时间信息由生成事件的设备的本地时钟提供。

处理时间

事件由流处理系统处理的时间。这是运行处理逻辑的服务器的时钟。通常与计算处理延迟或作为确定重复输出的标准相关。

当我们需要相互关联、排序或聚合事件时,这些时间线之间的区别变得非常重要。

不确定因素

在时间线上,静态数据关联于过去,而运动数据可以视为现在。但未来呢?这次讨论中最微妙的一点是,它对系统接收事件的吞吐量没有任何假设。

一般来说,流处理系统不要求输入定期产生、一次性产生或遵循特定的节奏。这意味着,由于计算通常有成本,预测峰值负载是一个挑战:将突然到达的输入元素与处理它们所需的计算资源匹配起来。

如果我们有足够的计算能力来应对突如其来的输入元素增加,我们的系统将如期产生结果,但如果我们没有为这种输入数据的突发增加做好计划,一些流处理系统可能会面临延迟、资源限制或故障。

处理不确定性是流处理的重要方面。

总之,流处理让我们从随时间交付的事件中提取信息。然而,当我们接收和处理数据时,我们需要处理事件时间的复杂性以及无界输入带来的不确定性。

我们为什么要面对额外的麻烦?在接下来的章节中,我们将概述一些使用案例,这些案例说明了流处理所增加的价值,并展示了它如何在数据流上提供更快速、可操作的洞察力,从而带来商业价值。

一些流处理的例子

流处理的应用范围与我们想象新的实时创新应用数据的能力一样广泛。以下使用案例是我们以一种或另一种方式参与的一个小样本,用来说明流处理的广泛应用领域:

设备监控

一家小型创业公司推出了一个基于云的物联网设备监视器,能够收集、处理和存储来自多达 1000 万台设备的数据。多个流处理器被部署用于支持应用程序的不同部分,从使用内存存储的实时仪表板更新,到连续数据聚合,如唯一计数和最小/最大测量。

故障检测

一家大型硬件制造商应用了复杂的流处理管道来接收设备指标。使用时间序列分析,可以检测潜在的故障,并自动发送纠正措施回到设备。

计费现代化

一家知名的保险公司将其计费系统迁移到了流处理管道。通过这个系统,现有的主机基础设施的批量导出数据被流式传输,以满足现有的计费流程,同时允许保险代理人的新实时流通过同样的逻辑进行服务。

车队管理

一家车队管理公司安装了能够实时报告受管控车辆的数据的设备,例如位置、电机参数和燃油水平,使其能够执行诸如地理限制的规则,并分析驾驶员在速度限制方面的行为。

媒体推荐

一家国家级媒体公司部署了一个流式处理管道,将新视频(如新闻报道)引入其推荐系统,使得视频几乎在被加入公司媒体库后立即对用户进行个性化推荐。之前的系统需要数小时才能完成同样的任务。

更快速的贷款

一家从事贷款服务的银行通过将多个数据流合并到一个流式应用程序中,将贷款批准时间从数小时减少到数秒。

这些用例的共同特点是企业需要在收到数据后的短时间内处理数据并生成可操作的见解。这个时间相对于用例来说不同:虽然对于贷款批准来说几分钟已经非常快速,但是要在设备故障时检测并在给定的服务级别阈值内采取纠正措施,可能需要毫秒级响应。

在所有情况下,我们可以认为数据尽可能新鲜时效果更好。

现在我们已经了解了流处理是什么,以及它如何被今天使用的一些示例所应用,是时候深入探讨支持其实现的概念了。

扩展数据处理

在讨论分布式计算在流处理中的影响之前,让我们快速浏览一下MapReduce,这个为可扩展和可靠数据处理奠定基础的计算模型。

MapReduce

编程分布式系统的历史在 2003 年 2 月经历了一个显著事件。杰夫·迪恩和桑杰·吉玛瓦特在多次重写谷歌的爬网和索引系统后,开始注意到一些可以通过一个共同接口公开的操作。这使他们开发出了MapReduce,一个在谷歌大型集群上进行分布式处理的系统。

之所以我们没有早些开发 MapReduce 的一部分原因可能是,当我们的规模较小时,我们的计算使用的机器较少,因此鲁棒性并不是一个很大的问题:周期性地检查一些计算并从检查点重新启动整个计算是可以接受的。但是一旦达到一定规模,这种方式就变得不可行了,因为你总是在重新启动事务而无法取得任何进展。

杰夫·迪恩,2013 年 8 月,给布拉德福德·F·里昂的电子邮件

MapReduce 首先是一种编程 API,其次是一组组件,使得编写分布式系统相对于其前身变得更加容易。

它的核心原则是两个函数:

Map

map 操作将一个要应用于集合每个元素的函数作为参数。通过分布式文件系统,集合的元素以分布方式读取,每个执行程序机器每次读取一个块。然后,所有驻留在本地块中的集合元素将该函数应用于它们,并且如果适用,执行程序将发出该应用的结果。

减少

减少操作接受两个参数:一个是中性元素,即如果传递一个空集合给reduce操作,它将返回的值。另一个是聚合操作,它接受聚合的当前值、集合的新元素,并将它们合并成一个新的聚合。

这两个高阶函数的组合足以表达我们在数据集上想要执行的每个操作。

从中汲取的教训:可扩展性和容错性

从程序员的角度来看,MapReduce 的主要优势包括:

  • 它有一个简单的 API。

  • 它提供了非常高的表现力。

  • 它显著减轻了将程序分布化的困难,从程序员的肩膀上转移到库设计者的肩膀上。特别是,弹性已内建于该模型中。

尽管这些特性使得模型变得吸引人,但 MapReduce 的主要成功在于其能够支持增长。随着数据量的增加和不断增长的业务需求导致更多的信息提取作业,MapReduce 模型展示了两个关键属性:

可扩展性

随着数据集的增长,可以向机器集群添加更多资源,以保持稳定的处理性能。

容错

系统能够持续运行并从部分故障中恢复。所有数据都是复制的。如果一个携带数据的执行程序崩溃,只需重新启动在崩溃执行程序上运行的任务即可。因为主节点跟踪该任务,这不会带来任何特别的问题,除了重新调度。

这两个特征的结合使得系统能够在根本不可靠的环境中持续支持工作负载,这些特性也是我们对流处理的要求

分布式流处理

与批处理一般情况下相比,使用 MapReduce 模型进行流处理的一个基本区别是,虽然批处理可以访问完整的数据集,但在流处理中,我们每次只能看到数据集的一小部分。

这种情况在分布式系统中变得更加严重;也就是说,为了将处理负载分布到一系列执行程序中,我们进一步将输入流分割成分区。每个执行程序只能看到完整流的部分视图。

分布式流处理框架的挑战在于提供一个抽象,隐藏用户不需要关心的复杂性,并允许我们将流作为一个整体来推理。

分布式系统中的有状态流处理

让我们想象一下,在总统选举期间我们正在统计选票。经典的批处理方法是等待所有选票都被投出,然后开始计数。尽管这种方法可以产生正确的最终结果,但因为在选举过程结束之前不知道(中间)结果,这将导致新闻过程非常乏味。

更令人兴奋的场景是,我们可以在每个选票投出时计算每位候选人的票数。在任何时刻,我们都可以通过参与者的部分计数看到当前的排名以及投票趋势。我们可以预测结果。

要实现这种情景,流处理器需要保持一个内部注册表,记录到目前为止看到的选票。为了保证计数的一致性,这个注册表必须能够从任何部分故障中恢复。确实,由于技术故障,我们不能要求公民们再次发出他们的选票。

此外,任何可能的故障恢复都不能影响最终结果。我们不能因为系统恢复不良的副作用而冒险宣布错误的获胜候选人。

这个场景说明了在分布式环境中运行的有状态流处理的挑战。有状态处理对系统提出了额外的负担:

  • 我们需要确保状态随时间得以保留。

  • 我们要求数据在部分系统故障的情况下,仍能保证一致性。

正如你将在本书中看到的,解决这些问题是流处理的重要方面。

现在我们更清楚了推动流处理流行以及这一学科挑战性因素的驱动力,我们可以介绍 Apache Spark。作为统一的数据分析引擎,Spark 提供了对批处理和流处理的数据处理能力,使其成为满足数据密集型应用需求的极佳选择,接下来我们将详细讨论。

介绍 Apache Spark

Apache Spark 是一个快速、可靠和容错的大规模数据处理分布式计算框架。

第一波:功能 API

在早期,Spark 的突破在于其对内存的新颖使用和表达功能 API。Spark 内存模型使用 RAM 在处理数据时缓存数据,处理速度比 Hadoop MapReduce 快 100 倍,后者是 Google MapReduce 的开源实现,用于批量工作负载。

其核心抽象——弹性分布式数据集(RDD),引入了一个丰富的函数式编程模型,将集群上的分布式计算复杂性抽象化。它引入了转换动作的概念,提供了比我们在 MapReduce 概述中讨论的 map 和 reduce 阶段更具表达力的编程模型。在这个模型中,许多可用的转换,如mapflatmapjoinfilter,表达了数据从一种内部表示到另一种的惰性转换,而急切操作称为动作,在分布式系统上材料化计算以生成结果。

第二波:SQL

Spark 项目历史上的第二个变革者是引入 Spark SQL 和数据框(后来是数据集,一个强类型的数据框)。从高层次来看,Spark SQL 为具有模式的任何数据集添加了 SQL 支持。它使我们可以像查询 SQL 数据库一样查询逗号分隔值(CSV)、Parquet 或 JSON 数据集。

这一演进还降低了用户采用的门槛。高级分布式数据分析不再是软件工程师的专属领域;现在数据科学家、业务分析师以及熟悉 SQL 的其他专业人员也可以轻松使用。从性能角度来看,SparkSQL 为 Spark 引入了查询优化器和物理执行引擎,使其在使用更少资源的同时运行速度更快。

统一引擎

如今,Spark 是一个统一的分析引擎,提供批处理和流处理功能,并支持多语言数据分析方法,在 Scala、Java、Python 和 R 语言中提供 API。

虽然在本书的上下文中,我们将关注 Apache Spark 的流处理功能,其批处理功能同样先进,并且与流处理应用程序高度互补。Spark 的统一编程模型意味着开发人员只需学习一种新的范式来处理批处理和流处理工作负载。

注意

在本书的过程中,我们将Apache SparkSpark互换使用。当我们希望强调项目或开源方面时,我们使用Apache Spark,而当我们指代技术总体时,我们使用Spark

Spark 组件

图 1-1 展示了 Spark 如何由一个核心引擎、构建在其之上的一组抽象(表示为水平层)、以及使用这些抽象来处理特定领域的库(垂直框)组成。我们已经突出显示了本书涵盖的领域,并将未涵盖的部分置灰。要了解更多关于 Apache Spark 其他领域的信息,我们推荐阅读 Bill Chambers 和 Matei Zaharia(O’Reilly)的Spark 权威指南,以及 Holden Karau 和 Rachel Warren(O’Reilly)的高性能 Spark

spas 0101

图 1-1. Spark 提供的抽象层(水平)和库(垂直)

作为 Spark 中的抽象层,我们有以下内容:

Spark Core

包含 Spark 核心执行引擎和一组低级函数 API,用于将计算分发到一组计算资源的集群,Spark 的术语中称为 执行器。其集群抽象允许将工作负载提交到 YARN、Mesos 和 Kubernetes,也可以使用自己的独立集群模式,在此模式下,Spark 作为一项专用服务在一组机器的集群中运行。其数据源抽象使其能够集成许多不同的数据提供者,例如文件、块存储、数据库和事件代理。

Spark SQL

实现了 Spark 的高级 DatasetDataFrame API,并在任意数据源之上增加了 SQL 支持。它还通过 Catalyst 查询引擎、项目 Tungsten 中的代码生成和内存管理引入了一系列性能改进。

基于这些抽象构建的库解决了大规模数据分析的不同领域:MLLib 用于机器学习,GraphFrames 用于图分析,以及本书关注的两个流处理 API:Spark Streaming 和 Structured Streaming。

Spark Streaming

Spark Streaming 是建立在核心 Spark 引擎的分布式处理能力之上的第一个流处理框架。它是在 2013 年 2 月的 Spark 0.7.0 版本中作为 alpha 发布,随着时间的推移逐步发展成为今天业界广泛采用的成熟 API,用于处理大规模数据流。

Spark Streaming 的概念基于一个简单而强大的前提:通过将连续的数据流转换为离散的数据集合,应用 Spark 的分布式计算能力进行流处理。这种流处理方法被称为 微批处理 模型;与大多数其他流处理实现中占主导地位的 逐元素处理 模型形成对比。

Spark Streaming 使用与 Spark 核心相同的函数式编程范式,但引入了一个新的抽象,离散流DStream,它暴露了一个编程模型,用于操作流中的底层数据。

结构化流处理

结构化流处理是建立在 Spark SQL 抽象之上的流处理器。它通过扩展 DatasetDataFrame API 添加了流处理能力。因此,它采用了基于模式的转换模型,这是其名称中 结构化 部分的来源,并继承了 Spark SQL 中实现的所有优化。

结构化流处理作为 Spark 2.0 中的实验性 API 在 2016 年 7 月引入。一年后,它在 Spark 2.2 版本中达到了 一般可用性,适合用于生产部署。作为一个相对新的开发,结构化流处理每个 Spark 的新版本都在快速演进。

结构化流处理使用声明性模型从流或一组流获取数据。要充分利用该 API,需要为流中的数据指定模式。除了支持DatasetDataFrame API 提供的一般转换模型外,它还引入了流特定功能,如支持事件时间、流连接以及与底层运行时的分离。最后一个特性为不同执行模型的运行时实现打开了大门。默认实现使用经典的微批处理方法,而较新的连续处理后端则为接近实时的连续执行模式提供了实验性支持。

结构化流处理(Structured Streaming)提供了一个统一的模型,将流处理带到与面向批处理应用程序相同的水平,消除了大量关于流处理推理的认知负担。

Where Next?

如果您立刻想要学习这两个 API 中的任何一个,您可以直接跳转到第二部分中的结构化流处理或者第三部分中的 Spark 流处理。

如果您对流处理不熟悉,我们建议您继续阅读本书的初始部分,因为我们会构建在讨论特定框架时使用的词汇和常见概念。

第二章:流处理模型

在本章中,我们将数据流的概念——即“移动中的数据”源——与允许我们表达流处理的编程语言原语和构造进行了桥接。

我们希望首先描述简单而基本的概念,然后再讨论 Apache Spark 如何表示它们。具体来说,我们想涵盖以下流处理的组件:

  • 数据源

  • 流处理管道

  • 数据汇

我们接下来展示这些概念如何映射到由 Apache Spark 实现的特定流处理模型。

接下来,我们描述具有状态的流处理,这是一种需要通过一些中间状态进行过去计算记录的流处理类型。最后,我们考虑了时间戳事件流以及解决诸如“如果事件的顺序和及时性不符合期望,我该怎么办?”等问题的基本概念。

源和汇

正如前文提到的,Apache Spark 在其两个流系统——结构化流和 Spark Streaming 中,是一个具有 Scala、Java、Python 和 R 编程语言 API 的编程框架。它只能操作进入使用此框架程序的运行时的数据,并且一旦数据被发送到另一个系统,它就停止操作该数据。

在数据静止的情况下,您可能已经熟悉这个概念:要在文件记录中存储的数据上操作,我们需要将该文件读入内存以便操作,一旦我们通过对这些数据进行计算生成了输出,我们就可以将该结果写入另一个文件。同样的原理适用于数据库——这是数据静止的另一个例子。

同样地,数据流可以在 Apache Spark 的流处理框架中作为流式数据源访问。在流处理的上下文中,从流中访问数据通常被称为消费流。这种抽象被呈现为一个接口,允许实现针对特定系统连接的实例:如 Apache Kafka、Flume、Twitter、TCP 套接字等。

同样地,我们将用于在 Apache Spark 控制之外写入数据流的抽象称为流式汇。Spark 项目本身提供了许多连接器,以及丰富的开源和商业第三方集成生态系统。

在图 2-1 中,我们以流处理系统中源和汇的概念作图说明。数据由处理组件从源消费,最终结果由汇生成。

spas 0201

图 2-1。简化的流模型

源和汇点的概念代表了系统的边界。在分布式框架中,对系统边界的标记是有意义的,因为这种分布式框架在我们的计算资源中可能有着高度复杂的足迹。例如,可以将一个 Apache Spark 集群连接到另一个 Apache Spark 集群,或者连接到另一个分布式系统,其中 Apache Kafka 就是一个常见的例子。在这种情况下,一个框架的汇点就是下游框架的源。这种链式连接通常被称为管道。源和汇点的名称对于描述数据从一个系统传递到下一个系统,以及我们在独立讨论每个系统时采用的视角,都是有用的。

从彼此定义的不可变流

在源和汇点之间,是流处理框架的可编程构造。我们暂不深入讨论这个主题的细节,你将在第二部分(Part II)和第三部分(Part III)中看到它们在结构化流和 Spark 流处理中的应用。但我们可以介绍一些概念,这些概念对于理解我们如何表达流处理是有用的。

Apache Spark 中的两个流 API 都采用了函数式编程的方法:它们声明对数据流进行的转换和聚合操作,假设这些流是不可变的。因此,对于给定的流,不可能改变其一个或多个元素。相反,我们使用转换来表达如何处理一个流的内容以获取派生数据流。这确保在程序的任何给定点,可以通过一系列明确声明的转换和操作来追溯任何数据流到其输入。因此,在 Spark 集群中的任何特定过程都可以仅通过程序和输入数据重建数据流的内容,使计算变得明确和可重现。

转换和聚合

Spark 广泛使用转换聚合。转换是表达对流中每个元素的处理方式的计算。例如,创建一个派生流,使其每个输入流元素都加倍,就对应一个转换操作。而聚合则产生依赖于许多元素,可能是流到目前为止的每个元素的结果。例如,收集输入流的前五个最大数值就是一个聚合操作。每 10 分钟计算某个读数的平均值也是聚合的一个例子。

另一种指代这些概念的方式是说,转换具有 窄依赖(为了生成输出的一个元素,你只需要输入的一个元素),而聚合具有 宽依赖(为了生成输出的一个元素,你需要观察到迄今为止遇到的许多输入流元素)。这种区别很有用。它让我们能够设想一种使用高阶函数产生结果的基本函数。

虽然 Spark Streaming 和 Structured Streaming 在表示数据流的方式上有所不同,但它们操作的 API 性质相似。它们都以一系列应用于不可变输入流的转换形式呈现自己,并产生一个输出流,可以是真正的数据流,也可以是输出操作(数据汇)。

窗口聚合

流处理系统通常依靠发生在实时的动作进行数据喂养:社交媒体消息、网页点击、电子商务交易、金融事件或传感器读数也是这类事件的常见例子。我们的流处理应用通常具有多个地方日志的集中视图,无论是零售位置还是共同应用的网页服务器。即使单独查看每个事务可能并不实用,甚至可能不切实际,我们可能对近期一段时间内事件的属性感兴趣;例如,过去的 15 分钟或过去的一小时,甚至两者都可能。

此外,流处理的核心思想是系统应该长时间运行,处理连续的数据流。随着这些事件不断发生,较旧的事件通常对你尝试完成的任何处理变得越来越不相关。

我们发现许多应用程序需要定期和周期性的基于时间的聚合,我们称之为 窗口

Tumbling 窗口

窗口聚合的最自然概念是“每隔一段时间执行一次分组函数”。例如,“每小时的最高和最低环境温度”或“每 15 分钟的总能量消耗(千瓦)”是窗口聚合的示例。注意这些时间段天然是连续且不重叠的。我们称这种固定时间段的分组,其中每个组跟随前一个组并且不重叠,为 tumbling 窗口

当我们需要在固定时间段内对数据进行聚合,并且每个时间段独立于前一段时,tumbling 窗口是一种常见的选择。图 2-2 展示了在元素流上的一个 10 秒 tumbling 窗口。这幅图说明了 tumbling 窗口的 tumbling 特性。

spas 0202

图 2-2. Tumbling 窗口

滑动窗口

滑动窗口是在一段时间内的聚合,其报告频率高于聚合周期本身。因此,滑动窗口是指带有两个时间规范的聚合:窗口长度和报告频率。通常读起来像“每隔 y 频率报告一个时间间隔 x 的分组函数”。例如,“过去一天的平均股票价格,每小时报告一次”。正如您可能已经注意到的那样,滑动窗口与平均函数的这种组合是滑动窗口的最广为人知的形式,通常被称为移动平均

图 2-3 展示了一个窗口大小为 30 秒,报告频率为 10 秒的滑动窗口。在插图中,我们可以观察到滑动窗口的一个重要特征:它们不适用于小于窗口大小的时间段。我们可以看到在时间 00:1000:20 没有报告的窗口。

spas 0203

图 2-3. 滑动窗口

虽然您在最终插图中看不到它,但绘制图表的过程揭示了一个有趣的特性:我们可以通过添加最新数据和删除过期元素来构建和维护一个滑动窗口,同时保持所有其他元素不变。

值得注意的是,滚动窗口是滑动窗口的一个特例,在这种情况下,报告频率等于窗口大小。

无状态和有状态处理

现在我们对 Apache Spark 中流处理系统的编程模型有了更好的了解,我们可以看看我们想要在数据流上应用的计算的性质。在我们的上下文中,数据流基本上是随时间观察到的长集合元素。实际上,结构化流推动了这一逻辑,认为数据流是记录的虚拟表,其中每一行对应一个元素。

有状态流

无论流是被视为连续扩展的集合还是表,这种方法为我们提供了对我们可能发现有趣的计算类型的一些见解。在某些情况下,重点放在对元素或元素组的连续和独立处理上:这些是我们希望基于众所周知的启发式处理某些元素的情况,例如来自事件日志的警报消息。

这种关注是完全合理的,但几乎不需要像 Apache Spark 这样的高级分析系统。更常见的是,我们对基于整个流的分析的新元素的实时反应感兴趣,例如在集合中检测异常值或从事件数据计算最近的聚合统计信息。例如,可能有趣的是在飞机发动机读数流中找到异常振动模式,这需要理解我们感兴趣的引擎类型的常规振动测量。

这种方法同时试图理解新数据和已经看到的数据的上下文,通常会导致有状态流处理。有状态流处理是一种学科,我们在输入数据流中观察到的新元素上计算出一些内容,并刷新帮助我们执行此计算的内部数据。

例如,如果我们正在尝试进行异常检测,我们希望用每个新的流元素更新的内部状态将是一个机器学习模型,而我们要执行的计算是判断输入元素是否应该分类为异常。

这种计算模式受到分布式流处理系统(如 Apache Spark)的支持,因为它可以利用大量的计算能力,并且是对实时数据做出反应的一种新的令人兴奋的方式。例如,我们可以计算输入数字的运行均值和标准差,并且如果一个新元素与这个均值的五个标准差之外,就输出一条消息。这是标记我们输入元素分布中特定极端异常值的一种简单但有用的方式。¹ 在这种情况下,流处理器的内部状态仅存储我们流的运行均值和标准差——即一对数字。

限制状态的大小

对于新手流处理领域的从业者来说,一个常见的陷阱是试图存储与输入数据流的大小成比例的大量内部数据。例如,如果您想要移除流中的重复记录,一种天真的方法是存储已经看到的每条消息,并将新消息与它们进行比较。这不仅会增加每个传入记录的计算时间,还会有无界的内存需求,最终会超出任何集群的限制。

这是一个常见的错误,因为流处理的前提是输入事件的数量没有限制,虽然在分布式 Spark 集群中可用的内存可能很大,但它始终是有限的。因此,中间状态表示可以非常有用,用来表达相对于全局数据流上观察到的元素进行操作的计算,但这是一种有风险的方法。如果选择有中间数据,您需要确保在任何给定时间可能存储的数据量严格限制在少于可用内存的上限内,而不考虑可能遇到的数据量。

示例:Scala 中的本地有状态计算

为了理解状态的概念,而不必深入复杂的分布式流处理,我们从 Scala 中的一个简单的非分布式流示例开始。

斐波那契序列经典上被定义为有状态流:它是以 0 和 1 开始的序列,然后由其前两个元素的和组成,如示例 2-1 所示。

示例 2-1. 有状态计算的斐波那契元素
scala> val ints = Stream.from(0)
ints: scala.collection.immutable.Stream[Int] = Stream(0, ?)

scala> val fibs = (ints.scanLeft((0, 1)){ case ((previous, current), index) =>
        (current, (previous + current))})

fibs: scala.collection.immutable.Stream[(Int, Int)] = Stream((0,1), ?)

scala> fibs.take(8).print
(0,1), (1,1), (1,2), (2,3), (3,5), (5,8), (8,13), (13,21), empty

Scala> fibs.map{ case (x, y) => x}.take(8).print
0, 1, 1, 2, 3, 5, 8, 13, empty

状态型流处理指的是任何需要查看过去信息以获取结果的流处理。在计算流的下一个元素时,保持一些状态信息是必要的。

在这里,这是在scanLeft函数的递归参数中实现的,在这里我们可以看到fibs每个元素都有两个元素的元组:所寻求的结果和下一个值。我们可以对元组列表fibs应用简单的转换,仅保留最左边的元素,从而获得经典的斐波那契数列。

强调的重要点是,为了获取第n个位置的值,我们必须处理所有n–1个元素,并在沿着流移动时保持中间的(i-1, i)元素。

但是,是否可能在纯粹无状态的情况下定义它,而不是参考其先前的值?

作为流变换的无状态斐波那契序列的定义

要将此计算表达为流,以整数作为输入并输出斐波那契序列,我们将其表达为一种流变换,使用无状态的map函数将每个数字转换为其斐波那契值。我们可以在示例 2-2 中看到此方法的实现。

示例 2-2. 无状态计算的斐波那契元素
scala> import scala.math.{pow, sqrt}
import scala.math.{pow, sqrt}

scala> val phi = (sqrt(5)+1) / 2
phi: Double = 1.618033988749895

scala> def fibonacciNumber(x: Int): Int =
  ((pow(phi,x) - pow(-phi,-x))/sqrt(5)).toInt
fibonacciNumber: (x: Int)Int

scala> val integers = Stream.from(0)
integers: scala.collection.immutable.Stream[Int] = Stream(0, ?)
scala> integers.take(10).print
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, empty

scala> val fibonacciSequence = integers.map(fibonacciNumber)
fibonacciSequence: scala.collection.immutable.Stream[Int] = Stream(0, ?)

scala>fibonacciSequence.take(8).print
0, 1, 1, 2, 3, 5, 8, 13, empty

这个相当反直觉的定义使用一个整数流,从单个整数(0)开始,然后定义斐波那契序列作为一个计算,它接收作为流的输入的整数n,并返回斐波那契序列的第n个元素作为结果。这使用了一个称为Binet 公式的浮点数公式,可以直接计算序列的第n个元素,而不需要先前的元素;也就是说,不需要知道流的状态。

注意我们如何在 Scala 中获取这个序列的有限数量的元素并打印它们,作为显式操作。这是因为我们的流元素的计算是惰性执行的,它仅在需要时调用我们的流评估,考虑到从最后的实体化点到原始源产生它们所需的元素。

无状态或有状态流处理

我们用一个相当简单的案例说明了状态型和无状态型流处理之间的区别,这个案例使用了两种方法来解决。虽然有状态的版本与定义非常相似,但它需要更多的计算资源来产生结果:它需要遍历流并在每一步保持中间值。

无状态版本虽然有点牵强,但采用了更简单的方法:我们使用无状态函数来获得结果。无论我们需要第 9 个还是第 999999 个斐波那契数,计算成本大致相同。

我们可以将这个想法推广到流处理。有状态的处理在资源使用上更为昂贵,并且在面对失败时也引入了一些问题:如果我们的计算在流处理过程中半途而废会发生什么?尽管一个安全的经验法则是在可用时选择无状态选项,但许多我们可以在数据流上提出的有趣问题通常是有状态的性质。例如:用户在我们网站上的会话持续多久?出租车在城市中使用的路径是什么?工业机器上的压力传感器的移动平均值是多少?

在整本书中,我们将看到,有状态的计算更为通用,但它们也带来了自身的约束。流处理框架的重要方面是提供处理这些约束的功能,并使用户能够创建业务需要的解决方案。

时间的影响

到目前为止,我们考虑了在产生数据流每个元素的结果时跟踪中间数据的优势,因为这使我们能够分析每个元素相对于它们所属的数据流的情况,只要我们保持这些中间数据在有界和合理的大小。现在,我们想考虑另一个流处理独特的问题,即对时间戳消息进行操作。

基于时间戳事件的计算

数据流中的元素始终具有处理时间。也就是说,根据定义,流处理系统观察来自数据源的新事件的时间。这个时间完全由处理运行时确定,并且完全独立于数据流元素的内容。

然而,对于大多数数据流,我们也谈论到事件时间的概念,即事件实际发生的时间。当系统感知事件的能力允许时,这个时间通常作为消息负载的一部分添加到数据流中。

时间戳是一个操作,它包括在生成消息的时刻添加时间记录,这将成为数据流的一部分。这是一种无处不在的实践,存在于最简单的嵌入式设备(只要它们有时钟)以及金融交易系统中最复杂的日志中。

时间戳作为时间概念的提供者

时间戳的重要性在于它允许用户考虑数据生成的时刻来推理其数据。

例如,如果我使用可穿戴设备记录了早晨的慢跑,并在回到家后将设备与手机同步,我希望看到我刚刚穿过森林时的心率和速度的详细信息,而不是把数据看作上传到某个云服务器的无时间序列值。正如我们所见,时间戳为数据提供了时间的上下文。

因此,由于事件日志在今天被分析的数据流中占据很大一部分,这些时间戳有助于理解特定系统在特定时间发生了什么。由于从创建数据的各种系统或设备传输数据到处理它的群集是一个易于发生不同形式故障的操作,某些事件可能会延迟、重新排序或丢失,所以这个完整的图像通常因此变得更难以捉摸。

常常,像 Apache Spark 这样的框架的用户希望在不妥协其系统的反应能力的情况下弥补这些风险。出于这种愿望,产生了以下的一个学科:

  • 明确标记的正确和重新排序的结果

  • 中间前景结果

根据流处理系统对数据流中的时间戳事件的最佳知识分类,可以反映出此分类是基于数据流提供的事件,并且有待于视图能够通过延迟的流元素的迟到来完成此视图。这个过程构成了事件时间处理的基础。

在 Spark 中,这个特性仅由结构化流处理原生支持。尽管 Spark Streaming 缺乏对事件时间处理的内置支持,但通过开发工作和一些数据整合过程,可以手动实现相同类型的基本功能,正如您将在第二十二章中看到的。

事件时间与处理时间

我们认识到有一个时间轴,在这个轴上创建了事件,并且在另一个时间轴上处理这些事件:

  • 事件时间指的是事件最初生成时的时间线。通常,生成设备上的时钟会在事件本身中放置一个时间戳,这意味着即使在传输延迟的情况下,所有来自同一来源的事件也可以按时间顺序排序。

  • 处理时间是事件被流处理系统处理的时间。这个时间仅在技术或实施层面上是相关的。例如,它可以用来为结果添加处理时间戳,从而区分重复值,即使这些输出值具有不同的处理时间。

想象我们有一系列随时间产生和处理的事件,如图 2-4 所示。

spas 0204

图 2-4. 事件与处理时间

让我们更仔细地看一下这个问题:

  • x 轴代表事件时间线,该轴上的点表示每个事件生成的时间。

  • y 轴是处理时间。图表区域上的每个点对应 x 轴中相应事件的处理时间。例如,创建于 00:08 的事件(x 轴上的第一个)在大约 00:12 左右被处理,这时其在 y 轴上的标记。

  • 对角线代表理想的处理时间。在一个理想的世界中,使用零延迟的网络,事件被创建后立即被处理。请注意,在那条线以下不可能有处理事件,因为这意味着事件在创建之前就已经被处理。

  • 对角线和处理时间之间的垂直距离是 交付延迟:事件生产和最终消费之间经过的时间。

在这个框架的指导下,现在让我们考虑一个 10 秒窗口聚合,如 图 2-5 所示。

spas 0205

图 2-5. 处理时间窗口

我们首先考虑在处理时间上定义的窗口:

  • 流处理器使用其内部时钟来测量 10 秒间隔。

  • 所有落在该时间间隔内的事件都属于窗口。

  • 在 图 2-5 中,水平线定义了这些 10 秒窗口。

我们还突出显示了时间间隔 00:30-00:40 对应的窗口。它包含两个事件,事件时间为 00:3300:39

在这个窗口中,我们可以欣赏到两个重要特征:

  • 窗口边界非常清晰,正如我们在高亮区域中看到的。这意味着窗口有明确定义的开始和结束。窗口关闭时,我们知道什么在里面,什么在外面。

  • 它的内容是任意的。它们与事件生成的时间无关。例如,尽管我们会认为 00:30-00:40 窗口会包含事件 00:36,但我们可以看到它已经不在结果集中,因为它迟到了。

现在让我们考虑在事件时间上定义的相同 10 秒窗口。在这种情况下,我们使用 事件创建时间 作为窗口聚合标准。图 2-6 展示了这些窗口如何与我们之前看到的处理时间窗口大不相同。在这种情况下,窗口 00:30-00:40 包含在那段时间内创建的所有事件。我们还可以看到,这个窗口没有自然的上界来定义窗口何时结束。在事件 00:36 创建晚了超过 20 秒。因此,要报告窗口 00:30-00:40 的结果,我们至少需要等到 01:00。如果一个事件被网络丢弃并永远不会到达,我们要等多久?为了解决这个问题,我们引入一个称为 水印 的任意截止时间,以处理这种开放边界的后果,如延迟、排序和去重。

spas 0206

图 2-6. 事件时间窗口

使用水印进行计算

正如我们已经注意到的,流处理生成周期性结果,通过分析其输入中观察到的事件。当具备使用事件消息中的时间戳的能力时,流处理器能够根据水印的概念将这些消息分成两类。

水印在任何给定时刻是我们在数据流上接受的最旧时间戳。任何比这个期望更旧的事件都不会被包括在流处理的结果中。流引擎可以选择以另一种方式处理它们,例如报告它们在延迟到达通道中。

然而,为了考虑可能的延迟事件,这个水印通常比我们预期事件传递的平均延迟要大得多。还要注意,这个水印是一个随时间单调增加的流动值,² 它随着数据流的观察时间而滑动一个延迟容忍窗口。

当我们将水印的概念应用于我们的事件时间图表时,如图 2-7 所示,我们可以欣赏到水印关闭了事件时间窗口定义留下的开放边界,提供了决定哪些事件属于窗口,哪些事件太迟以至于不应被考虑处理的标准。

spas 0207

图 2-7. 事件时间中的水印

当流的水印概念被定义后,流处理器可以在以下两种模式之一下运行:要么生成相对于水印之前所有事件的输出,此时输出是最终的,因为到目前为止已经观察到所有这些元素,而且再也不会考虑比这更旧的事件;要么生成相对于水印之前的数据的输出,同时可能随时在流中到达比水印更新的新的延迟元素,并且这些新数据可以改变输出结果。在后一种情况下,我们可以将输出视为临时的,因为新数据仍然可以改变最终结果,而在前一种情况下,结果是最终的,没有新数据能够改变它。

我们详细研究了如何在第十二章中具体表达和操作这种计算方式。

最后请注意,对于临时结果,我们正在存储中间值,并且以某种方式要求在延迟事件到达时修改它们的计算。这个过程需要一定的内存空间。因此,事件时间处理是另一种有状态计算的形式,并且受到相同的限制:为了处理水印,流处理器需要存储大量的中间数据,并因此消耗与水印长度 × 到达速率 × 消息大小大致相对应的大量内存。

另外,由于我们需要等待水印过期以确保我们拥有构成间隔的所有元素,因此使用水印并希望每个间隔有唯一最终结果的流处理过程必须将其输出延迟至水印的长度至少。

注意

我们想要概述事件时间处理,因为它是我们在第一章中提到的不对事件输入流的吞吐量做任何假设的特例。

在事件时间处理中,我们假设将水印设置为某个值是合适的。也就是说,我们只有在水印允许消息在创建时间和到达输入数据流时的顺序之间遇到的延迟时,基于事件时间处理的流计算的结果才是有意义的。

过小的水印会导致丢弃过多事件并且产生严重不完整的结果。过大的水印会延迟被认为是完整结果的输出时间过长,并增加流处理系统为保留所有中间事件而增加的资源需求。

用户需要确保选择一个适合他们所需的事件时间处理并且适合他们可用的计算资源的水印,这一点由用户自己负责。

概要

在本章中,我们探讨了流处理编程模型中独特的主要概念:

  • 数据源和接收器

  • 有状态处理

  • 事件时间处理

随着书籍的进展,我们探索了 Apache Spark 流式 API 中这些概念的实现。

¹ 由于切比雪夫不等式的帮助,我们知道在这个数据流上的警报应该以不到 5%的概率发生。

² 水印天生是非递减的。

第三章:流处理架构

分布式数据分析系统的实现必须处理计算资源池的管理,例如内部机器群集或预留的基于云的容量,以满足部门甚至整个公司的计算需求。由于团队和项目很少在时间上有相同的需求,如果几个团队共享计算资源,计算机群集最好是摊销的共享资源,这需要处理多租户问题。

当两个团队的需求不同时,给每个团队提供公平和安全的访问集群资源变得很重要,同时确保随着时间的推移最佳利用计算资源。

这一需求迫使使用大型集群的人们通过模块化来应对这种异构性,使得几个功能块成为数据平台中可互换的组成部分。例如,当我们将数据库存储作为功能块时,提供此功能的最常见组件是关系型数据库,如 PostgreSQL 或 MySQL,但是当流应用程序需要以非常高的吞吐量写入数据时,像 Apache Cassandra 这样的可扩展的列式数据库会是更好的选择。

在本章中,我们简要探讨了构成流数据平台架构的不同部分,并查看了处理引擎相对于其他组件在完整解决方案中的位置。在我们深入了解流架构的各个元素后,我们探索了两种用于处理流应用程序的架构风格:Lambda 架构和 Kappa 架构。

数据平台的组件

我们可以将数据平台视为标准组件的组合,这些组件预计对大多数利益相关者有用,并且专门的系统则为解决业务挑战而服务。

图 3-1 说明了这一难题的各个部分。

spas 0301

图 3-1. 数据平台的组成部分

从架构底部的裸金属级别到业务需求所需的实际数据处理,你可以找到以下内容:

硬件层级

在本地硬件安装、数据中心或潜在的虚拟化在同质云解决方案中(如 Amazon、Google 或 Microsoft 的 T 恤大小套餐),安装基本操作系统。

持久性层级

在这个基线基础设施之上,通常期望机器提供一个共享接口来存储其计算结果以及可能的输入。在这一层级上,你会找到诸如 Hadoop 分布式文件系统(HDFS)之类的分布式存储解决方案——还有许多其他分布式存储系统。在云上,这种持久性层由专门的服务提供,比如 Amazon Simple Storage Service(Amazon S3)或 Google Cloud Storage。

资源管理器

在持久性之后,大多数集群架构提供了一个单一的协商点,用于提交要在集群上执行的作业。这是资源管理器的任务,如 YARN 和 Mesos,以及更进化的云原生时代的调度程序,如 Kubernetes。

执行引擎

更高级别的是执行引擎,它负责执行实际的计算任务。其定义特征是它与程序员输入的接口并描述数据操作。Apache Spark、Apache Flink 或 MapReduce 就是此类的例子。

数据摄入组件

除了执行引擎,您还可以找到一个数据摄入服务器,可以直接插入该引擎。事实上,从分布式文件系统读取数据的旧做法经常被另一种可以实时查询的数据源所补充或甚至替代。消息系统或日志处理引擎如 Apache Kafka 在这个层次上被设置。

处理后的数据接收器

在执行引擎的输出端,您经常会找到一个高级数据接收器,它可能是另一个分析系统(在执行提取、转换和加载[ETL]作业的情况下),NoSQL 数据库或其他服务。

可视化层

我们应该注意,因为数据处理的结果只有在集成到更大的框架中才有用,所以这些结果通常被插入到可视化中。如今,由于被分析的数据迅速演变,这种可视化已经从旧的静态报告转向更实时的视觉界面,通常使用一些基于 Web 的技术。

在这种架构中,作为计算引擎的 Spark 专注于提供数据处理能力,并依赖于与图片的其他块具有功能接口。特别是,它实现了一个集群抽象层,允许它与 YARN、Mesos 和 Kubernetes 作为资源管理器进行接口,通过易于扩展的 API 提供连接到许多数据源,而新的数据源可以轻松添加,并集成了输出数据接收器,以向上游系统展示结果。

架构模型

现在我们将注意力转向具体架构中流处理和批处理之间的关联。特别是,我们将问自己一个问题,即如果我们有一个能够进行流处理的系统,那么批处理是否仍然相关,如果是,为什么呢?

在本章中,我们对比了两种流应用程序架构的概念:Lambda 架构 建议在并行运行的批处理对应应用程序中复制一个流应用程序,以获得互补的结果,而 Kappa 架构 则主张如果需要比较应用程序的两个版本,这两个版本都应该是流应用程序。我们将详细看看这些架构的意图,以及我们将检验的内容,尽管 Kappa 架构在一般情况下更容易和更轻量化实现,但仍可能存在需要 Lambda 架构的情况,以及为什么。

在流应用程序中使用批处理组件

通常情况下,如果我们开发一个批处理应用程序,它以周期性间隔运行成为一个流应用程序,我们已经提供了批处理数据集,并且表示这种周期性分析的批处理程序。在这个演化使用案例中,如前几章所述,我们希望演变成一个流应用程序,以获得一个更轻、更简单的应用程序,能够更快地提供结果。

在一个全新的应用程序中,我们可能也有兴趣创建一个参考批处理数据集:大多数数据工程师不仅仅是解决问题一次,而是重新审视他们的解决方案,并持续改进,特别是如果价值或收入与他们的解决方案的性能有关。出于这个目的,批处理数据集有设置基准的优势:一旦收集完毕,它就不再改变,可以用作“测试集”。确实,我们可以将批处理数据集重新播放到流系统中,以比较其性能与先前的迭代或已知基准。

在这种情况下,我们识别出批处理和流处理组件之间的三个交互级别,从最少混合到最多混合的批处理:

代码重用

通常源于参考批处理实现,寻求尽可能多地重新使用它,以避免重复努力。这是 Spark 突出的领域,因为可以特别容易地调用转换 Resilient Distributed Databases (RDDs) 和 DataFrames 的函数 — 它们共享大部分相同的 API,只有数据输入和输出的设置是不同的。

数据重用

在这种情况下,流应用程序从一个特征或数据源中定期准备的批处理作业中获得自身的数据。这是一种常见模式:例如,一些国际应用程序必须处理时间转换,而一个常见的陷阱是夏令时规则比预期更频繁地改变。在这种情况下,考虑这些数据作为我们的流应用程序自身依赖的新依赖源是很好的。

混合处理

应用程序在其生命周期内被理解为同时具有批处理和流处理组件。这种模式相对频繁地发生,出于希望管理应用程序提供的洞察力的精度以及处理应用程序自身版本控制和演进的意愿。

前两种用途是便利性的用途,但最后一个引入了一个新概念:使用批处理数据集作为基准。在接下来的小节中,我们看到这如何影响流应用程序的架构。

参考性数据流架构

在重放性和随时间性能分析的世界中,有两个历史悠久但相互冲突的建议。我们的主要关注点是如何测量和测试流应用程序的性能。在这样做时,我们的设置可能会发生两种变化:模型的性质(由于改进尝试的结果)和模型操作的数据(由于有机变化的结果)。例如,如果我们处理来自气象传感器的数据,我们可以预期数据中会有季节性变化模式。

为了确保我们能进行苹果与苹果的比较,我们已经确定,重播批处理数据集到我们流应用程序的两个版本是有用的:它确保我们没有看到真正反映数据变化的性能变化。理想情况下,在这种情况下,我们将测试我们在年度数据中的改进,确保我们在当前季节的优化不至于在六个月后损害性能。

然而,我们希望争辩说,与批处理分析的比较同样是必要的,超出使用基准数据集的使用——这正是架构比较发挥作用的地方。

Lambda 架构

Lambda 架构(图 3-2)建议定期执行批处理分析(例如每晚一次),并在数据流入时补充模型,直至能够基于整天的数据生成新版本的批处理分析。

它是由 Nathan Marz 在博客文章 “如何战胜 CAP 定理”.¹ 中作为此类引入的。它源于这样一个理念:我们希望强调数据分析精度以外的两个新颖点。

  • 数据分析的历史重放性非常重要

  • 由新数据产生的结果的可用性也是非常重要的一点。

spas 0302

图 3-2. Lambda 架构

这是一个有用的架构,但其缺点显而易见:这样的设置很复杂,需要维护两个用于相同目的的相同代码版本。即使 Spark 在让我们在批处理和流处理版本的应用程序之间重用大部分代码方面提供了帮助,应用程序的这两个版本在生命周期上是不同的,这可能看起来很复杂。

这个问题的另一个观点建议,保留能够向两个版本的流式应用程序(新的、改进的实验和旧的、稳定的工作马)提供相同数据集的能力,有助于我们解决方案的可维护性。

Kappa 架构

这种架构,如 图 3-3 所述,比较了两个流式应用程序,并且取消了任何批处理,指出如果需要读取批处理文件,则可以简单地组件可以逐记录地重放此文件的内容作为流式数据源。这种简单性仍然是一个巨大的优势,因为甚至是向这两个应用程序版本提供数据的代码也可以被重复使用。在这种被称为 Kappa 架构 的范式中([Kreps2014]),没有去重,并且心理模型更简单。

spas 0303

图 3-3. Kappa 架构

这引出了一个问题:批处理计算仍然重要吗?我们应该把我们的应用程序全部转换为全天候的流式处理吗?

我们认为 Lambda 架构中衍生出的一些概念仍然相关;事实上,在某些情况下它们非常有用,尽管这些情况并不总是容易理解。

仍然有一些用例是值得付出努力实现我们的分析的批处理版本,然后与我们的流式解决方案进行比较的。

流式与批处理算法比较

在选择流式应用的通用架构模型时,有两个重要的考虑因素需要考虑:

  • 流式算法有时在性质上完全不同

  • 流式算法不能保证在与批处理算法的比较中表现良好。

让我们在接下来的两个部分中使用激励性的例子来探讨这些想法。

流式算法在性质上有时完全不同

有时,很难从流式计算中推断出批处理,或者反过来,这两类算法具有不同的特性。这意味着乍看之下我们可能无法在这两种方法之间重复使用代码,但更重要的是,应该非常谨慎地比较这两种处理模式的性能特征。

为了使事情更加明确,让我们看一个例子:购买或租赁问题。在这种情况下,我们决定去滑雪。我们可以花$500 购买滑雪板,或者花$50 租借滑雪板。我们应该租借还是购买?

我们的直觉策略是先租借,看看我们是否喜欢滑雪。但假设我们确实喜欢:在这种情况下,我们最终会意识到我们花费的钱比如果我们一开始就买了滑雪板要多。

在这种计算的批处理版本中,我们“事后”进行,被告知我们一生中将要滑雪的总次数。在这个问题的流或在线版本中,我们被要求在每个离散的滑雪事件发生时做出决策(产生输出)。策略是根本不同的。

在这种情况下,我们可以考虑流算法的竞争比率。我们在最坏的输入上运行算法,然后将其“成本”与批算法在“事后”所做的决策进行比较。

在我们的买房或租房问题中,让我们考虑以下的流策略:我们租房直到租房支出与买房相等,这时我们选择买房。

如果我们滑雪九次或更少,我们是最优的,因为我们的支出与事后的支出相同。竞争比率为一。如果我们滑雪十次或更多,我们支付 $450 + $500 = $950。最坏的输入是接收到 10 个“滑雪旅行”决策事件,在这种情况下,批处理算法在事后将支付 $500。这种策略的竞争比率是(2 - 1/10)。

如果我们选择另一种算法,比如“总是第一次买”,那么最坏的输入是只滑雪一次,这意味着竞争比率是 $500 / $50 = 10。

注意

性能比率或竞争比率是算法返回的值距离最优解有多远的度量标准。如果一个算法在所有实例中的目标值不超过最优离线值的ρ倍,则形式上称为ρ-竞争算法。

更好的竞争比率更小,而竞争比率大于一显示流算法在某些输入上表现明显较差。很容易看出,在最坏的输入条件下,批处理算法,以严格更多信息事后进行,总是预计能表现更好(任何流算法的竞争比率大于一)。

流算法不能保证在与批处理算法的性能上表现良好

另一个不服管教的例子是装箱问题。在装箱问题中,一组不同大小或不同重量的物体必须装入若干个箱子或容器中,每个箱子或容器具有一定的体积或重量容量。挑战在于找到一种物体分配到箱子中的方式,使使用的容器数量最小化。

在计算复杂性理论中,该算法的离线版本被称为NP-hard。问题的简单变体是决策问题:知道这些物体是否能够装入指定数量的箱子中。它本身是 NP 完全的,这意味着(对于我们这里的目的)在计算上非常困难。

在实践中,这种算法被广泛使用,从实际货物在集装箱中的装运,到操作系统匹配各种大小的空闲内存块的内存分配请求的方式。

这些问题有许多变体,但我们想要关注在线版本与离线版本之间的区别——在线版本的算法以对象流作为输入,而离线版本的算法在甚至开始计算过程之前就可以检查整组输入对象。

在线算法按任意顺序处理项目,然后将每个项目放入第一个能容纳它的箱子中,如果没有这样的箱子存在,则打开一个新箱子并将项目放入其中。这种贪婪逼近算法总是允许将输入对象放入最多情况下是次优的箱子数中,这意味着我们可能会使用比必要更多的箱子。

一种更好的算法,仍然相对直观易懂,是首次适合减少策略,它通过首先按其大小降序排序要插入的项目,然后将每个项目插入列表中第一个具有足够剩余空间的箱子。该算法在 2007 年被证明非常接近最优算法,可以产生绝对最小的箱子数([Dosa2007])。

然而,首次适合减少策略依赖于我们可以在开始处理和将其放入箱子之前按大小降序对项目进行排序的想法。

现在,试图在在线装箱问题的情况下应用这样的方法,情况完全不同,因为我们处理的是一个无法排序的元素流。直觉上,因此很容易理解,在线装箱问题——在其操作时缺乏预见性——比离线装箱问题要困难得多。

警告

如果考虑流算法的竞争比,这种直觉事实上得到了证明支持。这是在线算法消耗的资源与通过最小化输入对象集合中可以打包的箱子数量的在线最优算法使用的资源之比。背包(或装箱)问题的这种竞争比实际上是任意糟糕的(也就是说,很大;见[Sharp2007]),这意味着总是可能遇到一种“糟糕”的序列,在这种序列中,在线算法的表现将与最优算法的表现有任意远的距离。

本节中涉及的更大问题是,不能保证流算法的表现优于批处理算法,因为这些算法必须在没有预见的情况下运行。特别是一些在线算法,包括背包问题,已经被证明在与其离线算法比较时存在任意大的性能比率。

这意味着,打个比方,我们有一个工人像批处理一样接收数据,好像一开始数据就在一个储藏室里,另一个工人则像流处理一样接收数据,好像数据在一个传送带上,然后无论我们的流处理工人多么聪明,总有办法以一种病态的方式将项目放在传送带上,使他完成的任务结果比批处理工人任意糟糕

从这次讨论中得出的要点有两个:

  • 流处理系统确实“更轻量”:它们的语义可以用富有表现力的术语表达许多低延迟分析。

  • 流式 API 鼓励我们使用流处理或在线算法来实现分析,其中启发式方法很遗憾地受限,正如我们之前看到的。

概要

总之,批处理处理即使已经过时的说法也是不准确的:批处理处理仍然相关,至少对于为流处理问题提供性能基准。任何负责任的工程师应该对批处理算法在“事后”运行与其流应用相同输入时的性能有一个良好的了解:

  • 如果当前的流处理算法有已知的竞争比率,并且结果表现可以接受,仅运行流处理可能就足够了。

  • 如果当前实施的流处理与批处理版本之间没有已知的竞争比率,定期运行批量计算是一个有价值的基准,可以用来评估其应用。

¹ 如果您想了解与 CAP 定理(也称为 Brewer 定理)的联系更多信息,我们建议您查阅原始文章。该定理集中描述了分布式计算中一些限制到数据处理系统的一个有限部分的基本限制。在我们的情况下,我们关注的是该约束的实际影响。

第四章:Apache Spark 作为流处理引擎

在 第三章 中,我们描绘了流数据平台的一般架构图,并确定了作为分布式处理引擎的 Spark 在大数据系统中的位置。

这种架构告诉我们,在处理流数据时,特别是使用 Apache Spark 进行流数据处理时,可以期待接口和与生态系统其余部分的链接。无论是在其 Spark Streaming 还是 Structured Streaming 形式中,流处理都是 Apache Spark 的另一种 执行模式

在本章中,我们将介绍使 Spark 成为流处理引擎的主要特性。

两种 API 的故事

正如我们在 “介绍 Apache Spark” 中提到的,Spark 提供了两种不同的流处理 API,即 Spark Streaming 和 Structured Streaming:

Spark Streaming

这是一个 API 和一组连接器,其中 Spark 程序以微批次的形式服务于从流中收集的数据,这些微批次在固定时间间隔内间隔,并执行给定的计算,并在每个间隔最终返回结果。

结构化流处理

这是一个 API 和一组连接器,建立在 SQL 查询优化器 Catalyst 的基础上。它提供了基于 DataFrame 和不断更新的无界表的连续查询概念,从流中获取新记录。

Spark 在这些方面提供的接口特别丰富,以至于本书大部分内容都在解释这两种处理流数据集的方式。需要注意的一点是,这两种 API 都依赖于 Spark 的核心功能,并在分布式计算、内存缓存和集群交互等低级特性上共享许多特性。

作为其 MapReduce 前身的一大进步,Spark 提供了丰富的操作符集,允许程序员表达复杂的处理,包括机器学习或事件时间操作。我们将在稍后更详细地讨论允许 Spark 实现这一功能的基本特性。

我们只想概述一下,这些接口在设计上与其批处理对应物一样简单——在 DStream 上操作感觉就像在 RDD 上操作,在流式 Dataframe 上操作看起来几乎与批处理类似。

Apache Spark 自称为统一引擎,为开发人员提供了一致的环境,无论他们想要开发批处理还是流处理应用程序。在这两种情况下,开发人员都可以利用分布式框架的全部能力和速度。

这种多功能性增强了开发的灵活性。在部署完整的流处理应用程序之前,程序员和分析师首先尝试在具有快速反馈循环的交互式环境中发现见解。Spark 提供了一个基于 Scala REPL(Read-Eval-Print-Loop)的内置 shell,可用作原型开发场所。还有几种笔记本实现可供选择,如 Zeppelin、Jupyter 或 Spark Notebook,它们将这种交互式体验带入用户友好的 Web 界面。这个原型开发阶段在开发的早期阶段至关重要,其速度也是如此。

如果您回顾 图 3-1 中的图表,您会注意到我们在图表中称为 results 的内容实际上是可操作的见解——通常意味着收入或成本节省——每次完全遍历一个循环(从业务或科学问题开始并结束)时生成。总结来说,这个循环是实验方法的一个粗略表示,经历观察、假设、实验、测量、解释和结论。

Apache Spark 在其流处理模块中,始终致力于谨慎管理切换到流应用程序的认知负荷。它还有其他一些重要的设计选择,对其流处理能力有着重要影响,从其内存存储开始。

Spark 的内存使用

Spark 提供了数据集片段的内存存储,必须从数据源初始加载。数据源可以是分布式文件系统或其他存储介质。Spark 的内存存储形式类似于数据缓存操作。

因此,Spark 内存存储中的 value 具有一个 base,即其初始数据源,以及应用于它的连续操作层。

失败恢复

发生故障时会发生什么?因为 Spark 精确知道首次用于摄取数据的数据源,也知道到目前为止对其执行的所有操作,因此它可以从头开始重建丢失数据片段,这是在崩溃执行器上的情况。显然,如果这种重建(在 Spark 的术语中称为 recovery)不需要完全从头开始,速度会更快。因此,Spark 提供了一种复制机制,与分布式文件系统相似。

然而,由于内存是如此宝贵但有限的资源,Spark 默认使缓存的存活时间很短。

延迟评估

正如您将在后续章节中详细了解的那样,在 Spark 的存储中,可以定义在值上执行的大部分操作都是延迟执行的,只有在执行最终的渴望输出操作时,才会触发 Spark 集群中的实际计算执行。值得注意的是,如果一个程序由一系列线性操作组成,前一个操作结果在下一个操作消耗其输入后会立即 消失

缓存提示

另一方面,如果我们有多个操作要在单个中间结果上执行,会发生什么?我们是否必须多次计算它?幸运的是,Spark 允许用户指定中间值的重要性,并且如何保护其内容以供以后使用。

图 4-1 展示了此类操作的数据流程。

spas 0401

图 4-1. 对缓存值的操作

最后,Spark 提供了在集群内存不足时将缓存溢出到二级存储的机会,将内存操作扩展到次级且明显较慢的存储,以保留数据处理过程的功能方面,当面对临时高峰负载时。

现在我们对 Apache Spark 的主要特性有了一个大致了解,让我们花些时间专注于 Spark 内部的一个设计选择,即延迟与吞吐量的权衡。

理解延迟

正如我们提到的,Spark Streaming 选择了微批处理。它在固定的时间间隔内生成一批元素,当该间隔“滴答”结束时,开始处理上个间隔收集到的数据。结构化流处理则采取了稍微不同的方式,它会尽可能缩小所讨论的间隔(上一个微批处理的处理时间),在某些情况下还提出了连续处理模式。然而,微批处理仍然是 Apache Spark 流处理中主导的内部执行模式。

微批处理的一个结果是,任何微批至少延迟了批处理的任何特定元素的处理时间。

首先,微批处理创建了基准延迟。目前还不清楚可以将这种延迟减小到多小,尽管大约一秒钟是较为常见的下限数值。对于许多应用程序来说,几分钟的延迟已经足够;例如:

  • 拥有仪表板,可以在过去几分钟内刷新您网站的关键性能指标

  • 提取社交网络中最近的热门话题

  • 计算一组家庭能耗趋势

  • 在推荐系统中引入新媒体

虽然 Spark 是一个平等机会的处理器,并延迟了所有数据元素(最多)一个批次才对其进行操作,但还存在一些其他流处理引擎,可以快速处理具有优先级的某些元素,确保它们的响应速度更快。如果对这些特定元素的响应时间至关重要,那么像 Apache Flink 或 Apache Storm 这样的替代流处理器可能更合适。但如果您只对平均快速处理感兴趣,比如监控系统时,Spark 提供了一个有趣的选择。

面向吞吐量的处理

总而言之,Spark 在流处理方面真正擅长的是面向吞吐量的数据分析。

我们可以将微批处理方法比作火车:它到达车站,等待一段时间的乘客,然后将所有上车的乘客一起运送到他们的目的地。尽管对于同样的行程,乘客可能选择驾车或出租车可以更快地从门到门出发,但火车的乘客批量确保更多旅客抵达他们的目的地。火车为相同的行程提供更高的吞吐量,但有些乘客必须等到火车启程。

Spark 核心引擎经过优化,适用于分布式批处理。在流处理上的应用确保了每单位时间可以处理大量数据。Spark 通过一次处理多个元素摊销了分布式任务调度的开销,并且正如我们在本章早期看到的那样,它利用内存技术、查询优化、缓存甚至代码生成来加速数据集的转换过程。

在使用 Spark 进行端到端应用时,一个重要的约束是接收处理数据的下游系统必须能够接受流处理过程提供的完整输出。否则,在突然的负载高峰面前,我们可能会出现应用程序瓶颈,从而导致级联故障。

Spark 的多语言 API

现在我们已经概述了 Apache Spark 的主要设计基础,这些基础对流处理有影响,即丰富的 API 和内存处理模型,定义在执行引擎的模型内。我们已经探讨了 Apache Spark 的特定流处理模式,从较高的角度来看,我们确定了微批处理的主导性使我们认为 Spark 更适合面向吞吐量导向的任务,其中更多的数据意味着更高的质量。现在,我们希望把注意力放在 Spark 突出的另一个方面:其编程生态系统。

Spark 最初是作为一个仅限于 Scala 的项目编写的。随着兴趣和采纳的扩展,支持不同用户档案的需求也随之增加,这些用户具有不同的背景和编程语言技能。在科学数据分析领域,Python 和 R 可以说是首选语言,而在企业环境中,Java 占据主导地位。

Spark 不仅仅是一个用于分布式计算的库,它已经成为一个多语言框架,用户可以使用 Scala、Java、Python 或 R 语言进行接口交互。开发语言仍然是 Scala,这也是主要创新的地方。

注意

长期以来,Java API 的覆盖范围与 Scala 相当同步,这要归功于 Scala 语言提供的出色 Java 兼容性。尽管在 Spark 1.3 及更早版本中,Python 在功能上落后,但现在大部分已经迎头赶上。最新增加的是 R,其功能完备性正在积极进行中。

这个多才多艺的接口让各种水平和背景的程序员都涌向 Spark,以满足他们自己的数据分析需求。对 Spark 开源项目的贡献的惊人且不断增长的丰富性证明了 Spark 作为一个联合框架的强大性。

然而,Spark 最好为其用户提供计算服务的方法不仅仅是让他们使用自己喜欢的编程语言。

快速实现数据分析

Spark 在开发流数据分析管道方面的优势不仅在于提供 Scala 中简洁的高级 API,还在于 Java 和 Python 中的兼容 API。它还提供了作为开发过程中的实用快捷方式的 Spark 的简单模型。

在 Spark 中实现组件复用是一个有价值的资产,如同使用 Java 生态系统中用于机器学习和许多其他领域的库。例如,Spark 让用户可以轻松地从斯坦福 CoreNLP 库中受益,从而避免编写分词器这样的痛苦任务。总之,这使您可以快速原型化您的流数据管道解决方案,快速获得第一批结果,以便在管道开发的每个步骤中选择合适的组件。

最后,通过 Spark 进行流处理让您受益于其容错模型,使您有信心故障机器不会使流应用程序陷入困境。如果您喜欢失败的 Spark 作业的自动重启,那么在运行 24/7 的流式操作时,您将会双倍欣赏这种弹性。

总之,Spark 是一个框架,尽管在延迟方面做出了一些妥协,但在构建数据分析管道时进行了优化:在丰富的环境中进行快速原型设计,并在不利条件下保持稳定的运行时性能是它认可并直接解决的问题,为用户提供了显著的优势。

欲了解更多关于 Spark 的信息

本书专注于流处理。因此,我们迅速介绍了与 Spark 相关的概念,特别是关于批处理的部分。最详细的参考资料分别是[Karau2015][Chambers2018]

在更低层次的方法上,Spark 编程指南 的官方文档也是另一种可访问的必读资料。

总结

在本章中,您了解了 Spark 及其来源。

  • 您已经看到了 Spark 如何通过关键的性能改进来扩展该模型,特别是在内存计算方面,以及如何通过新的高阶函数扩展 API。

  • 我们还考虑了 Spark 如何集成到大数据解决方案的现代生态系统中,包括其与其老大哥 Hadoop 相比,专注于更小的占用空间。

  • 我们专注于流式 API,特别是它们的微批处理方法的含义,适合的用途以及它们不适用的应用程序。

  • 最后,我们考虑了在 Spark 环境中的流处理,以及如何通过敏捷地构建流水线,加上可靠、容错的部署来实现最佳使用案例。

第五章:Spark 的分布式处理模型

作为一个分布式处理系统,Spark 依赖于计算资源的可用性和可寻址性来执行任意的工作负载。

尽管可以将 Spark 作为一个独立的分布式系统来解决一个特定的问题,但随着组织在其数据成熟度水平上的发展,通常需要部署一个完整的数据架构,正如我们在第三章中讨论的那样。

在本章中,我们希望讨论 Spark 与其计算环境的交互以及如何适应所选择环境的特性和约束。

首先,我们调查集群管理器的当前选择:YARN、Mesos 和 Kubernetes。集群管理器的范围超出了运行数据分析,因此有大量资源可以获取关于它们任何一个的深入知识。对于我们的目的,我们将提供 Spark 作为参考的集群管理器提供商的额外细节。

一旦您了解了集群管理器的角色及 Spark 如何与其交互,我们将探讨在分布式环境中容错性的各个方面以及 Spark 的执行模型如何在该上下文中运作。

有了这些背景,您将能够理解 Spark 提供的数据可靠性保证及其如何适用于流式执行模型。

运行 Apache Spark 与集群管理器

我们首先要讨论在一个集群上分布流处理的学科。这组机器具有一般目的,并且需要接收流应用程序的运行时二进制文件和启动脚本——这被称为配置。事实上,现代集群是自动管理的,并且包括大量的机器在多租户的情况下运行,这意味着许多利益相关者希望在一天中的不同时间访问和使用同一个集群。因此,这些集群由集群管理器管理。

集群管理器是一种软件,接收来自多个用户的利用请求,将其匹配到一些资源上,并代表用户预留这些资源一定的时间,并将用户应用程序放置在一些资源上供其使用。集群管理器角色的挑战包括非平凡的任务,如在可用机器池中为用户请求找到最佳位置或者在多个用户应用程序共享同一物理基础设施时安全地隔离用户应用程序。这些管理器能够发挥作用或者失效的一些考虑因素包括任务的碎片化、最佳位置、可用性、抢占和优先级。因此,集群管理本身就是一门学科,超出了 Apache Spark 的范围。相反,Apache Spark 利用现有的集群管理器将其工作负载分布到集群中。

集群管理器示例

一些流行的集群管理器示例包括以下内容:

  • Apache YARN,这是一个相对成熟的集群管理器,起源于 Apache Hadoop 项目。

  • Apache Mesos,这是一个基于 Linux 容器技术的集群管理器,最初是 Apache Spark 存在的原因。

  • Kubernetes,这是一个现代的集群管理器,诞生于面向服务的部署 API,实践中起源于 Google,并在 Cloud Native Computing Foundation 的旗下以其现代形式发展。

Spark 有时会让人感到困惑的地方在于,作为一个发行版,Apache Spark 包含了自己的集群管理器,这意味着 Apache Spark 有能力作为其特定的部署协调器。

在本章的其余部分,我们将看以下内容:

  • Spark 自己的集群管理器及其特殊用途意味着它在容错或多租户领域承担的责任比生产集群管理器如 Mesos、YARN 或 Kubernetes 少。

  • 分布式流应用程序期望的交付保证的标准级别如何不同,以及 Spark 如何满足这些保证。

  • 微批处理(microbatching),作为 Spark 处理流处理的独特因素,来自于大同步处理(BSP)十年前的模型,并为从 Spark Streaming 到 Structured Streaming 的演进路径铺平道路。

Spark 自己的集群管理器

Spark 有两个内部集群管理器:

本地集群管理器

用于测试目的的集群管理器(或资源管理器)的功能。它通过依赖于本地机器仅有少量可用核心的线程模型来复制分布式机器群的存在。这种模式通常不会引起很多困惑,因为它仅在用户的笔记本电脑上执行。

独立集群管理器

一个相对简单的、仅限于 Spark 的集群管理器,在资源分配的切片和切割能力方面相当有限。独立集群管理器持有并使得 Spark 执行器部署和启动的整个工作节点可用。它还期望执行器已经预先部署在那里,并且将.jar实际传送到新机器不在其范围之内。它有能力接管一定数量的执行器,这些执行器是其工作节点部署的一部分,并在其上执行任务。这个集群管理器对于 Spark 开发人员来说非常有用,提供了一个简单的资源管理解决方案,让您可以专注于在没有任何花哨功能的环境中改进 Spark。不建议将独立集群管理器用于生产部署。

简而言之,Apache Spark 是一个任务调度器,它调度的是任务,这些任务是从用户程序中提取的计算分布单元。Spark 还通过包括 Apache Mesos、YARN 和 Kubernetes 在内的集群管理器进行通信和部署,或者在某些情况下允许使用其自己的独立集群管理器。这种通信的目的是预留一定数量的执行器,这些执行器是 Spark 理解的等大小的计算资源单位,一种虚拟的“节点”。所讨论的预留资源可以由集群管理器提供,如下:

  • 有限的进程(例如,在某些 YARN 的基本用例中),其中进程的资源消耗受到计量,但默认情况下不会阻止它们访问彼此的资源。

  • 容器(例如,在 Mesos 或 Kubernetes 的情况下),其中容器是一种相对轻量级的资源预留技术,源自 Linux 内核的 cgroups 和命名空间,并通过 Docker 项目实现了它们的最流行版本。

  • 它们也可以是上述任一部署在虚拟机(VMs)上,这些虚拟机本身带有特定的核心和内存预留。

集群操作

详细描述这三种技术所涉及的不同隔离级别超出了本书的范围,但对于生产环境的设置非常值得探索。

请注意,在企业级生产集群管理领域,我们还会遇到作业队列、优先级、多租户选项和抢占等概念,这些都是集群管理器的领域,因此在专注于 Spark 的材料中很少讨论。

然而,对于您来说,理解您的集群管理器设置的具体细节是至关重要的,以了解如何在多个团队共享的机器集群上成为一个“好公民”。有许多关于如何运行适当的集群管理器的良好实践,而许多团队竞争其资源。关于这些建议,您应该咨询本章末尾列出的参考资料以及您的本地 DevOps 团队。

理解分布式系统中的弹性和容错能力

对于分布式应用程序来说,弹性和容错是绝对必要的:它们是我们能够完成用户计算的条件。如今,集群由理想情况下在其生命周期内接近峰值容量运行的廉价机器组成。

简而言之,硬件经常出现故障。一个弹性的应用程序可以在其分布式环境中处理延迟和非关键故障,并取得进展。一个容错的应用程序能够在其一个或多个节点意外终止的情况下成功完成其进程。

这种弹性尤其在流处理中尤为重要,因为我们安排的应用程序应该能够持续运行一段未确定的时间。这段未确定的时间通常与数据源的生命周期相关联。例如,如果我们正在运行一个零售网站,并分析用户访问网站时的交易和网站交互,我们可能有一个数据源将在我们业务的整个生命周期内可用,而我们希望这个时间非常长,如果我们的业务要成功的话。

因此,一个以流式方式处理我们数据的系统应该能够长时间不间断地运行。

流式计算中的“秀必须继续”的方法使得我们应用的容错和故障容忍特性变得更加重要。对于批处理作业,我们可以启动它,希望它能成功,如果需要更改或在失败的情况下重新启动。但对于在线流式 Spark 流水线,这不是一个合理的假设。

故障恢复

在容错的背景下,我们还希望了解从一个特定节点故障到恢复需要多长时间。实际上,流处理有一个特定的方面:数据源持续实时生成数据。要处理批处理计算的故障,我们总是有机会从头重新启动,并接受获取计算结果需要更长时间的事实。因此,容错的一个非常原始的形式是检测到部署的特定节点失败,停止计算,并从头重新启动。这个过程可能需要比我们为该计算预算的原始持续时间长两倍以上,但如果我们不赶时间,这仍然可以接受。

对于流处理,我们需要继续接收数据,因此在恢复的集群尚未准备好进行任何处理时,可能需要存储数据。这可能在高吞吐量时成为问题:如果我们尝试从头开始重新启动,我们不仅需要重新处理自应用程序开始以来观察到的所有数据——这本身就可能是一个挑战——而且在重新处理历史数据期间,我们需要继续接收并可能存储在我们试图赶上时生成的新数据。这种从头开始重新启动的模式对于流式处理来说是如此棘手,以至于我们将特别关注 Spark 在节点不可用或无功能情况下仅重新启动 最小 计算量的能力。

集群管理器对容错的支持

我们想强调为什么仍然重要理解 Spark 的容错保证,即使 YARN、Mesos 或 Kubernetes 的集群管理器中也有类似的功能。要理解这一点,我们可以考虑集群管理器在与能够报告故障并请求新资源以应对这些异常的框架紧密合作时帮助容错。Spark 具有这样的能力。

例如,生产集群管理器(如 YARN、Mesos 或 Kubernetes)具有通过检查节点上的端点并要求节点报告其自身的就绪状态和活跃状态来检测节点故障的能力。如果这些集群管理器检测到故障并且有备用容量,它们将用另一个节点替换该节点,以供 Spark 使用。这一特定操作意味着 Spark 执行器代码将在另一个节点上重新启动,然后尝试加入现有的 Spark 集群。

集群管理器本质上不具有对其保留的节点上运行的应用程序进行内省的能力。它的责任仅限于运行用户代码的容器。

那个责任边界是 Spark 弹性特性开始的地方。为了从失败节点中恢复,Spark 需要执行以下操作:

  • 确定该节点是否包含应以检查点文件形式再现的某些状态。

  • 理解在作业的哪个阶段节点应重新加入计算。

这里的目标是我们探索一下,如果一个节点被集群管理器替换,Spark 是否具有能力利用这个新节点,并将计算分布到它上面。

在这种背景下,我们关注 Spark 作为应用程序的责任,并在必要时强调集群管理器的能力:例如,节点可能由于硬件故障或其工作被更高优先级的作业简单地抢占而被替换。Apache Spark 对为什么毫不知情,而专注于如何

数据传递语义

正如您在流式模型中看到的,流式作业基于实时生成的数据操作,这意味着中间结果需要定期提供给流水线的消费者

这些结果由我们集群的某些部分产生。理想情况下,我们希望这些可观察结果与数据到达的实时性相一致。这意味着我们希望得到精确的结果,并希望尽快获得它们。然而,分布式计算也有自己的挑战,有时不仅包括个别节点的故障,如我们所提到的,还包括像网络分区这样的情况,其中集群的某些部分无法与该集群的其他部分进行通信,如图 5-1 所示。

spas 0501

图 5-1. 网络分区

Spark 是使用driver/executor架构设计的。一个特定的机器,driver,负责跟踪作业进度以及用户的作业提交,并且该程序的计算是随着数据的到达而发生的。然而,如果网络分区分隔了集群的某些部分,driver可能只能跟踪形成初始集群的执行器的一部分。在我们的分区的另一部分中,我们将找到完全能够运行但无法向driver报告其计算进程的节点。

这产生了一个有趣的情况,即那些“僵尸”节点不会接收新任务,但可能正在完成它们之前获得的某些计算片段。由于不知道分区的存在,它们将像任何执行器一样报告它们的结果。由于这些“僵尸”结果的报告有时不通过driver(为了避免driver成为瓶颈),这些结果的报告可能会成功。

由于driver,一个单一的记账点,并不知道那些僵尸执行器仍在运行并报告结果,它将重新安排丢失的执行器需要在新节点上完成的相同任务。这造成了一个“双答复”问题,即通过分区丢失的僵尸机器和承载重新安排任务的机器都报告了相同的结果。这带来了真实的后果:我们之前提到的流计算的一个例子是路由金融交易的任务。在这种情况下,双重提款或双重股票购买订单可能会产生巨大的后果。

引起不同处理语义的问题不仅仅是上述问题。另一个重要的原因是,当流处理应用的输出和状态检查点无法在一个原子操作中完成时,在检查点和输出之间发生故障将导致数据损坏。

因此,这些挑战导致了“至少一次”处理和“至多一次”处理之间的区别:

至少一次

这个处理确保了流的每个元素至少被处理一次或更多次。

至多一次

这个处理确保了流的每个元素最多被处理一次。

确切一次

这就是“至少一次”和“至多一次”的组合。

至少一次处理是我们想确保每个初始数据块都已处理的概念——它处理了我们之前谈到的节点故障。正如我们提到的,当流处理过程遭受部分失败时,需要替换一些节点或重新计算一些数据,我们需要重新处理丢失的计算单元,同时保持数据的摄入。如果不遵守至少一次处理,有可能在特定条件下丢失数据。

反对称概念称为最多一次处理。最多一次处理系统保证,将重复结果的僵尸节点与重新安排的节点一样以一致的方式处理,我们只保留一组结果。通过跟踪其结果所涉及的数据,我们能够确保丢弃重复的结果,从而得到最多一次处理的保证。我们实现这一点的方式依赖于作用于结果接收的“最后一英里”的幂等性概念。幂等性使得函数的应用两次(或更多次)于任何数据时,结果与第一次相同。这可以通过跟踪我们报告结果的数据,并在流处理输出处有一个记账系统来实现。

微批处理和逐个元素处理

在本节中,我们要讨论流处理的两种重要方法:批同步处理逐条记录处理

这一目标是将这两个概念连接到 Spark 用于流处理的两个 API:Spark Streaming 和 Structured Streaming。

微批处理:批量同步处理的一种应用

Spark Streaming,在 Spark 中流处理的更成熟模型,大致近似于所谓的批量同步并行(BSP)系统。

BSP 的要点在于它包括两个方面:

  • 异步工作的分割分布

  • 同步屏障,按固定间隔到达

分割是每个连续的流处理步骤要完成的工作分割成数量大致与可用于执行此任务的执行器数量成比例的并行块的概念。每个执行器接收其自己的工作块(或块),并单独工作,直到第二个元素到来。特定的资源负责跟踪计算的进度。在 Spark Streaming 中,这是一个“驱动程序”上的同步点,允许工作进入下一步。在这些预定的步骤之间,集群上的所有执行器都在做相同的事情。

请注意,在此调度过程中传递的是描述用户希望对数据执行的处理的函数。数据已经位于各个执行器上,通常在集群的生命周期内直接传递到这些资源。

这在 2016 年由 Heather Miller 称为“函数传递风格”(并在[Miller2016]中正式化):异步将安全函数传递给分布式、静态、不可变数据,在无状态容器中使用惰性组合子来消除中间数据结构。

进行进一步数据处理轮次的频率由时间间隔决定。这个时间间隔是一个任意的持续时间,以批处理时间来衡量;也就是说,在您的集群中作为“挂钟”时间观察所期望看到的内容。

对于流处理,我们选择在小的固定间隔内实现屏障,以更好地近似数据处理的实时概念。

逐条记录处理

相比之下,逐条记录处理通过流水线处理:它分析用户指定函数描述的整个计算,并将其部署为使用集群资源的流水线。然后,唯一剩下的问题就是通过各种资源流动数据,按照规定的流水线进行操作。请注意,在后一种情况下,计算的每个步骤在集群中的某个地方都有具体体现。

大多数按照这一范式运行的系统包括 Apache Flink,Naiad,Storm 和 IBM Streams。(您可以在第二十九章中进一步了解这些内容。)这并不一定意味着这些系统无法进行微批处理,而是表明它们的主要或最原生的操作模式,并说明它们对流水线处理过程的依赖通常是其核心。

这两者之间特定事件到达系统反应所需的最小延迟时间非常不同:微批处理系统的最小延迟时间因此是完成当前微批(批处理间隔)的接收所需的时间加上在数据落到的执行器上启动任务所需的时间(也称为调度时间)。另一方面,逐条记录处理系统可以在遇到感兴趣事件时立即作出反应。

微批处理与逐条处理之间的权衡

尽管其延迟较高,微批处理系统提供了显著的优势:

  • 它们能够在同步障碍边界上适应。如果一些执行器已经显示出不足或丢失数据,这种适应可能代表了从故障中恢复的任务。周期性的同步还可以为我们提供添加或移除执行器节点的机会,使我们能够根据我们观察到的集群负载通过数据源的吞吐量来增加或减少资源。

  • 我们的 BSP 系统有时可以更容易地提供强一致性,因为它们的批处理决策—指示特定数据批次的开始和结束—是确定性的并被记录下来。因此,任何计算都可以重新进行,并且第二次会产生相同的结果。

  • 在微批的开始时,我们可以将数据作为集合提供,这样我们可以执行有效的优化,为计算数据提供思路。利用每个微批次,我们可以考虑具体的情况,而不是一般处理,这适用于所有可能的输入。例如,我们可以在决定处理或丢弃每个微批之前进行采样或计算统计量。

更重要的是,即使是瞬时的微批也可以作为明确定义的元素来有效地指定批处理和流处理的编程方式。即使只有瞬时,微批看起来也像是静态数据。

将微批处理和逐条处理结合在一起

在像 Apache Flink 或 Naiad 这样的系统中实现的微批处理和逐条处理的结合仍然是研究的课题。¹.]

虽然它不能解决每一个问题,但由微批处理支持的结构化流处理并不会在 API 级别上暴露这种选择,允许独立于固定批处理间隔的演进。事实上,结构化流处理的默认内部执行模型是微批处理,具有动态批处理间隔。对于某些操作符,结构化流处理还实现了连续处理,这是我们在第十五章中涉及的内容。

动态批处理间隔

什么是动态批处理间隔?动态批处理间隔是指在流式处理的DataFrameDataset中,数据的重新计算包括对新接收到的数据进行更新。这种更新基于触发器,并且通常基于时间段进行。该时间段仍然是基于我们预期在整个集群内同步的固定世界时钟信号,代表了每个执行器共享的单一同步时间源。

然而,这个触发器也可以是“尽可能频繁”这一声明。这个声明只是一个新批次应该在前一个批次处理完之后立即启动的想法,给第一个批次一个合理的初始持续时间。这意味着系统将尽可能频繁地启动批次。在这种情况下,可以观察到的延迟接近于逐个处理的情况。这里的思想是,由此系统产生的微批次将收敛到最小可管理的大小,使得我们的流通过执行器计算更快地产生结果。一旦产生了那个结果,Spark 驱动程序将启动并安排一个新的查询。

结构化流处理模型

结构化流处理的主要步骤如下:

  1. 当 Spark 驱动程序触发新的批次时,处理从更新从数据源读取的数据账户开始,特别是获取最新批次的起始和结束的数据偏移量。

  2. 这之后是逻辑规划,构建要在数据上执行的连续步骤,然后是查询规划(步内优化)。

  3. 然后通过添加新的数据批次来更新我们试图刷新的连续查询的实际计算的启动和调度。

因此,从计算模型的角度来看,我们将看到 API 与 Spark Streaming 有显著的不同。

批处理间隔的消失

现在我们简要解释一下结构化流批处理的含义及其对操作的影响。

在结构化流处理中,我们使用的批处理间隔不再是一个计算预算。在 Spark Streaming 中,理念是,如果我们每两分钟产生一次数据,并且每两分钟将数据流入 Spark 的内存,我们应该在至少两分钟内对该批数据进行计算,并清除我们集群中的内存以便下一个微批次。理想情况下,数据流入的量与流出的量相同,并且我们集群的集体内存使用保持稳定。

使用结构化流,没有这种固定的时间同步,我们在集群中看到性能问题的能力更加复杂:一个不稳定的集群——即无法通过尽快完成计算来“清除”数据的集群——将看到不断增长的批处理时间,增长速度加快。我们可以预期,控制这个批处理时间将至关重要。

然而,如果我们的集群与数据吞吐量的大小正确匹配,那么具有尽可能频繁更新的许多优势。特别是,我们应该期望在我们结构化流集群中看到比以往保守批处理间隔时间更高粒度的非常频繁的结果。

¹ 最近由伯克利大学推出的一个与 Spark 相关的有趣项目名为 Drizzle,它使用“组调度”来形成一种类似于长寿命的流水线,跨多个批次持久存在,旨在创建接近连续的查询。参见 [Venkataraman2016

第六章:Spark 的弹性模型

在大多数情况下,流处理作业是长时间运行的作业。根据定义,随着时间推移观察和处理的数据流会导致连续运行的作业。当它们处理数据时,可能会累积中间结果,在数据离开处理系统后难以重现。因此,失败的成本相当高,并且在某些情况下,完全恢复是棘手的。

在分布式系统中,特别是依赖于商品硬件的系统中,故障是大小的函数:系统越大,任何时候某些组件发生故障的概率就越高。分布式流处理器需要在其操作模型中考虑到这种故障的可能性。

在本章中,我们将看到 Apache Spark 平台提供的弹性:它如何能够恢复部分故障以及在故障发生时通过系统传递数据时我们得到的保证种类。我们首先概述 Spark 的不同内部组件及其与核心数据结构的关系。有了这些知识,您可以进一步了解不同级别的故障对系统的影响以及 Spark 提供的恢复措施。

Spark 中的弹性分布式数据集

Spark 的数据表示建立在 弹性分布式数据集(RDDs)之上。RDDs 最早在 2011 年由论文“Resilient Distributed Datasets: A Fault-Tolerant Abstraction for In-Memory Cluster Computing” [Zaharia2011] 中提出,是 Spark 中的基础数据结构。正是在这个基础层面上,Spark 提供了强大的容错保证。

RDDs 由分区组成,这些分区是存储在单个节点上的数据段,并由 Spark 驱动程序跟踪,呈现给用户作为位置透明的数据结构。

我们在 图 6-1 中说明了这些组件,其中经典的 词频统计 应用程序被分解为组成 RDD 的不同元素。

spas 0601

图 6-1. 在分布式系统中表示的 RDD 操作

彩色块是数据元素,最初存储在分布式文件系统中,在图的最左侧表示。数据以分区形式存储,以彩色块的列形式展示在文件内部。每个分区被读取到一个执行器中,我们将其视为水平块。实际的数据处理发生在执行器内部。在那里,数据根据 RDD 级别描述的转换进行转换:

  • .flatMap(l => l.split(" ")) 将句子按空格分割为单词。

  • .map(w => (w,1)) 将每个单词转换为形如 (<word>, 1) 的元组,为计数做准备。

  • .reduceByKey(_ + _) 计算计数,使用 <word> 作为键,并将加法操作应用于附加的数字。

  • 最终结果通过使用相同的 reduce 操作将部分结果合并而得到。

RDDs 构成了 Spark 的编程核心。所有其他抽象,无论是批处理还是流处理,包括 DataFrameDataSetDStream,都是使用 RDD 创建的设施构建的,更重要的是,它们继承了相同的容错能力。我们在 “RDDs as the Underlying Abstraction for DStreams” 中对 RDD 编程模型进行了简要介绍。

RDD 的另一个重要特性是,Spark 会尽量将它们的数据保存在内存中,只要系统中有足够的容量。此行为可通过存储级别进行配置,并且可以通过调用缓存操作进行显式控制。

我们在这里提到这些结构,是为了说明 Spark 通过修改数据来跟踪用户计算的进展。确实,通过检查程序的控制流程(包括循环和可能的递归调用)来了解用户想要做什么有多么困难和容易出错。定义分布式数据集类型,让用户从其他数据源或从一个数据集创建另一个数据集,会更可靠。

在 图 6-2 中,我们展示了相同的 单词计数 程序,现在以用户提供的代码形式(左侧)和生成的内部 RDD 操作链形式展示。这种依赖链形成了一种特定类型的图,称为有向无环图(DAG)。DAG 通知调度程序,适当地称为 DAGScheduler,如何分发计算,也是失败恢复功能的基础,因为它表示内部数据及其依赖关系。

spas 0602

图 6-2. RDD 衍生线

当系统跟踪这些分布式数据集的有序创建时,它同时跟踪已完成的工作和尚未完成的工作。

Spark 组件

要理解 Spark 中容错机制的作用层次,有必要先浏览一下一些核心概念的术语。我们首先假设用户提供的程序被分成块并在各种机器上执行,就像我们在前一节中看到的,并如 图 6-3 所示。

spas 0603

图 6-3. Spark 术语

让我们详细讲解一下这些步骤,这些步骤在 图 6-3 中有所展示,这些步骤定义了 Spark 运行时的词汇表:

用户程序

Spark Streaming 中的用户应用程序由用户指定的 函数调用 组成,操作在弹性数据结构(如 RDD、DStream、流式 DataSet 等)上,分为 actionstransformations

转换后的用户程序

用户程序可能会进行调整,修改一些指定调用,使其更简单、更易理解的方式是 map-fusion。¹ 查询计划在 Spark SQL 中是一个类似但更高级的概念。

RDD

分布式、弹性数据集的逻辑表示。在图示中,我们看到初始 RDD 由三部分组成,称为分区。

分区

分区是可以独立加载的数据集的物理片段。

阶段

然后将用户的操作分组到阶段中,这些阶段的边界将用户操作分为必须分开执行的步骤。例如,需要在多个节点之间进行数据洗牌的操作,如两个不同上游操作结果的连接,标志着一个独特的阶段。Apache Spark 中的阶段是顺序执行的单位:它们一个接一个地执行。在任何给定时间,最多只能运行一个相互依赖的阶段。

作业

在定义这些阶段之后,Spark 应该采取哪些内部操作是明确的。事实上,在这个阶段,定义了一组相互依赖的作业。而作业,准确地说,是调度单位的词汇。它们从整个 Spark 集群的角度描述正在处理的工作,无论它是在队列中等待还是当前在多台机器上运行。(虽然它没有明确表示,在图 6-3 中,作业是完整的转换集。)

任务

根据它们的源数据在集群上的位置,作业可以被切割成任务,跨越分布式和单机计算之间的概念边界:任务是本地计算的单位,是作业的本地、执行器绑定部分的名称。

Spark 旨在确保所有这些步骤免受伤害,并在任何此过程中发生的任何事故的情况下快速恢复。这种关注反映在由上述概念结构化的容错设施中:在任务、作业、阶段或程序级别发生的重启和检查点操作。

Spark 的容错保证

现在我们已经看到组成 Spark 内部机制的“部件”,我们准备理解故障可以发生在许多不同的层次上。在本节中,我们看到 Spark 容错保证按“逐渐扩大的爆炸半径”组织,从较小的故障到较大的故障。我们将调查以下内容:

  • 如何通过重新启动减轻 Spark 任务失败

  • 如何通过洗牌服务减轻 Spark 阶段失败

  • 如何通过驱动程序重新启动减轻用户程序的编排者消失

当您完成本节时,您将对 Spark 在运行时提供的保证有一个清晰的心理图景,让您理解配置良好的 Spark 作业可以处理的故障场景。

任务失败恢复

当运行任务的基础设施出现故障或程序中的逻辑条件导致偶发的作业失败时(如OutOfMemory、网络、存储错误,或者与正在处理的数据质量相关的问题(例如解析错误、NumberFormatExceptionNullPointerException等常见异常)),任务可能会失败。

如果任务的输入数据已通过cache()persist()存储,并且所选的存储级别意味着数据的复制(查找设置以 _2 结尾的存储级别,例如 MEMORY_ONLY_SER_2),则任务不需要重新计算其输入,因为集群中的另一台机器上存在完整副本。然后,我们可以使用此输入重新启动任务。表 6-1 总结了 Spark 中可配置的不同存储级别及其在内存使用和复制因子方面的特性。

表 6-1. Spark 存储级别

级别 使用磁盘 使用内存 使用堆外存储 对象(反序列化) 复制的副本数量
NONE 1
DISK_ONLY X 1
DISK_ONLY_2 X 2
MEMORY_ONLY X X 1
MEMORY_ONLY_2 X X 2
MEMORY_ONLY_SER X 1
MEMORY_ONLY_SER_2 X 2
MEMORY_AND_DISK X X X 1
MEMORY_AND_DISK_2 X X X 2
MEMORY_AND_DISK_SER X X 1
MEMORY_AND_DISK_SER_2 X X 2
OFF_HEAP X 1

然而,如果没有持久性或者存储级别不能保证任务输入数据的存在副本,Spark 驱动程序将需要查询存储用户指定计算的 DAG,以确定哪些作业段需要重新计算。

因此,在缓存或存储级别上没有足够的预防措施时,一个任务的失败可能会触发多个其他任务的重新计算,直到阶段边界。

阶段边界意味着一个洗牌,而洗牌意味着中间数据将以某种方式被实现:正如我们所讨论的,洗牌将执行者转变为数据服务器,可以向任何其他充当目标执行者提供数据。

因此,这些执行者拥有导致洗牌的映射操作的副本。因此,参与洗牌的执行者具有导致其之前映射操作的副本。但如果你有一个即将崩溃的下游执行者,并能依赖于洗牌的上游服务器(它们为类似映射操作的输出提供服务),这将是一个救命稻草。如果情况相反呢:你需要面对一个上游执行者的崩溃?

阶段失败恢复

我们已经看到任务失败(可能由于执行器崩溃)是集群上发生的最频繁的事件,因此也是最重要的事件需要避免。反复发生的任务失败将导致包含该任务的阶段失败。这让我们进入第二个允许 Spark 抵抗任意阶段失败的设施:shuffle service

当此失败发生时,总是意味着数据的某种回滚,但是根据定义,shuffle 操作依赖于所有前面步骤中涉及的执行器。

自 Spark 1.3 开始,我们引入了shuffle service,它允许您处理通过集群分布的映射数据,具有良好的本地性,但更重要的是,通过不是 Spark 任务的服务器。这是一个用 Java 编写的外部文件交换服务,不依赖于 Spark,并且设计成比 Spark executor 更长时间运行的服务。这个额外的服务作为 Spark 所有集群模式中的一个单独进程连接,仅为执行器提供数据文件交换,以便在 shuffle 之前可靠地传输数据。通过 netty 后端高度优化,允许在传输数据时减少非常低的开销。因此,一旦执行器的映射任务执行完毕,就可以关闭执行器,此时 shuffle 服务已经拥有其数据的副本。由于数据传输更快,传输时间也大大减少,减少了执行器可能面临问题的脆弱时间。

驱动程序失败恢复

在了解了 Spark 如何从特定任务和阶段的失败中恢复后,我们现在可以看一下 Spark 提供的用于恢复驱动程序失败的功能。在 Spark 中,驱动程序具有至关重要的角色:它是块管理器的存放处,知道集群中每个数据块的位置。同时也是 DAG 存在的地方。

最后,驱动程序是作业调度状态、元数据和日志的存放地。因此,如果丢失驱动程序,则整个 Spark 集群可能会丢失其在计算中达到的阶段、实际计算内容以及服务它的数据,一箭双雕。

集群模式部署

Spark 实现了所谓的集群部署模式,允许驱动程序托管在集群上,而不是用户的计算机上。

部署模式有两种选项:在客户端模式下,驱动程序在提交应用程序的客户端的同一进程中启动。然而,在集群模式下,驱动程序是从集群内的一个工作进程中启动的,客户端进程在提交应用程序后立即退出,而不等待应用程序完成。

总之,这使得 Spark 能够自动重启驱动程序,以便用户可以以“点火并忘记”的方式启动作业,开始作业然后关闭笔记本电脑以赶上下一趟火车。每个 Spark 的集群模式都提供一个 Web UI,用户可以访问其应用程序的日志。另一个优势是驱动程序的失败并不标志着作业的结束,因为集群管理器将重新启动驱动程序进程。但这只允许从头开始恢复,因为计算的临时状态—之前存储在驱动程序机器上—可能已丢失。

检查点

为了避免在驱动程序崩溃时丢失中间状态,Spark 提供了检查点选项;即,定期记录应用程序状态的快照到磁盘上。sparkContext.setCheckpointDirectory()选项的设置应指向可靠的存储(例如,Hadoop 分布式文件系统[HDFS]),因为让驱动程序尝试从其本地文件系统重建中间 RDD 的状态是毫无意义的:这些中间 RDD 是在集群的执行者上创建的,因此不应需要与驱动程序进行任何交互来备份它们。

我们稍后会详细讨论关于检查点的话题,在第二十四章。同时,仍然有一个任何 Spark 集群的组件的潜在故障我们尚未解决:主节点。

概要

这次关于 Spark 核心容错和高可用模式的介绍应该让您对 Spark 提供的主要基本组件和设施有所了解。请注意,到目前为止,这些内容都不专门针对 Spark Streaming 或结构化 Streaming,但所有这些教训都适用于流式 API,因为它们需要提供长时间运行的、容错的,但性能高效的应用程序。

注意,这些设施反映了对特定集群中故障频率的不同关注点。这些设施反映了对特定集群中故障频率的不同关注点:

  • 通过设置通过 Zookeeper 保持最新的故障转移主节点等功能,真正关注的是在 Spark 应用程序设计中避免单点故障。

  • Spark Shuffle Service 的目的是避免在长列表的计算步骤末尾进行 Shuffle 步骤时出现任何问题,使整个过程因执行者的故障而变得脆弱。

后者是一个更频繁的发生。前者是关于处理每种可能的条件,后者更多关于确保平稳的性能和高效的恢复。

¹ 这个过程将l.map(foo).map(bar)转换为l.map((x) => bar(foo(x)))

附录 A. 第一部分的参考文献

  • [Armbrust2018] Armbrust, M.,T. Das,J. Torres,B. Yavuz,S. Zhu,R. Xin,A. Ghodsi,I. Stoica 和 M. Zaharia。“Structured Streaming:Apache Spark 中的声明式 API”,2018 年 5 月 27 日。https://stanford.io/2Jia3iY

  • [Bhartia2016] Bhartia, R.,“优化 Spark-Streaming 以高效处理 Amazon Kinesis Streams”,AWS Big Data 博客,2016 年 2 月 26 日。https://amzn.to/2E7I69h

  • [Chambers2018] Chambers, B.和 Zaharia, M.,《Spark:权威指南》。O’Reilly,2018 年。

  • [Chintapalli2015] Chintapalli, S.,D. Dagit,B. Evans,R. Farivar,T. Graves,M. Holderbaugh,Z. Liu,K. Musbaum,K. Patil,B. Peng 和 P. Poulosky。“在 Yahoo!进行流式计算引擎基准测试”,Yahoo!工程,2015 年 12 月 18 日。http://bit.ly/2bhgMJd

  • [Das2013] Das, Tathagata。“深入探讨 Spark Streaming”,Spark meetup,2013 年 6 月 17 日。http://bit.ly/2Q8Xzem

  • [Das2014] Das, Tathagata 和 Yuan Zhong。“使用动态批处理大小进行自适应流处理”,2014 年 ACM 云计算研讨会。http://bit.ly/2WTOuby

  • [Das2015] Das, Tathagata。“在 Spark Streaming 中提高容错性和零数据丢失”,Databricks 工程博客,2015 年 1 月 15 日。http://bit.ly/2HqH614

  • [Dean2004] Dean, Jeff 和 Sanjay Ghemawat。“MapReduce:大规模集群上简化数据处理”,OSDI San Francisco,2004 年 12 月。http://bit.ly/15LeQej

  • [Doley1987] Doley, D.,C. Dwork 和 L. Stockmeyer。“On the Minimal Synchronism Needed for Distributed Consensus,”《ACM 期刊》34(1) (1987): 77-97。http://bit.ly/2LHRy9K

  • [Dosa2007] Dósa, Gÿorgy. “First fit Decreasing Bin-Packing Algorithm 的紧密边界为 FFD(I)≤(11/9)OPT(I)+6/9。”《组合数学、算法、概率和实验方法》。Springer-Verlag,2007 年。

  • [Dunner2016] Dünner, C.,T. Parnell,K. Atasu,M. Sifalakis 和 H. Pozidis。“使用 Apache Spark 进行高性能分布式机器学习”,2016 年 12 月。http://bit.ly/2JoSgH4

  • [Fischer1985] Fischer, M. J.,N. A. Lynch 和 M. S. Paterson。“使用一个故障进程的分布式一致性的不可能性”,《ACM 期刊》32(2) (1985): 374–382。http://bit.ly/2Ee9tPb

  • [Gibbons2004] Gibbons, J.。“π的无限算法”,《美国数学月刊》113(4) (2006): 318-328。http://bit.ly/2VwwvH2

  • [Greenberg2015] Greenberg, David。《在 Mesos 上构建应用程序》。O’Reilly,2015 年。

  • [Halevy2009] Halevy, Alon,Peter Norvig 和 Fernando Pereira。“数据的不合理有效性”,《IEEE 智能系统》(2009 年 3 月/4 月)。http://bit.ly/2VCveD3

  • [Karau2015] Karau, Holden, Andy Konwinski, Patrick Wendell, and Matei Zaharia. 学习 Spark. O’Reilly, 2015 年.

  • [Kestelyn2015] Kestelyn, J. “Apache Kafka 与 Spark Streaming 的精准一次语义,” Cloudera 工程博客, 2015 年 3 月 16 日. http://bit.ly/2EniQfJ.

  • [Kleppmann2015] Kleppmann, Martin. “CAP 定理的批判,” arXiv.org:1509.05393, 2015 年 9 月. http://bit.ly/30jxsG4.

  • [Koeninger2015] Koeninger, Cody, Davies Liu, and Tathagata Das. “Spark Streaming 对 Kafka 集成的改进,” Databricks 工程博客, 2015 年 3 月 30 日. http://bit.ly/2Hn7dat.

  • [Kreps2014] Kreps, Jay. “质疑 Lambda 架构,” O’Reilly Radar, 2014 年 7 月 2 日. https://oreil.ly/2LSEdqz.

  • [Lamport1998] Lamport, Leslie. “兼职议会,” ACM Transactions on Computer Systems 16(2): 133–169. http://bit.ly/2W3zr1R.

  • [Lin2010] Lin, Jimmy, and Chris Dyer. 使用 MapReduce 进行数据密集型文本处理. Morgan & ClayPool, 2010 年. http://bit.ly/2YD9wMr.

  • [Lyon2013] Lyon, Brad F. “关于 Map Reduce 动机的沉思,” Nowhere Near Ithaca 博客, 2013 年 6 月, http://bit.ly/2Q3OHXe.

  • [Maas2014] Maas, Gérard. “调整 Spark Streaming 以提高吞吐量,” Virdata 工程博客, 2014 年 12 月 22 日. http://www.virdata.com/tuning-spark/.

  • [Marz2011] Marz, Nathan. “如何击败 CAP 定理,” Thoughts from the Red Planet 博客, 2011 年 10 月 13 日. http://bit.ly/2KpKDQq.

  • [Marz2015] Marz, Nathan, and James Warren. 大数据:可扩展实时数据系统的原理与最佳实践. Manning, 2015 年.

  • [Miller2016] Miller, H., P. Haller, N. Müller, and J. Boullier “函数传递:一种类型化的分布式函数式编程模型,” ACM SIGPLAN Conference on Systems, Programming, Languages and Applications: Software for Humanity, Onward! 2016 年 11 月: (82-97). http://bit.ly/2EQASaf.

  • [Nasir2016] Nasir, M.A.U. “流处理引擎的容错机制,” arXiv.org:1605.00928, 2016 年 5 月. http://bit.ly/2Mpz66f.

  • [Shapira2014] Shapira, Gwen. “使用 Spark Streaming 构建 Lambda 架构,” Cloudera 工程博客, 2014 年 8 月 29 日. http://bit.ly/2XoyHBS.

  • [Sharp2007] Sharp, Alexa Megan. “增量算法:解决变化世界中的问题,” 博士论文, 康奈尔大学, 2007 年. http://bit.ly/2Ie8MGX.

  • [Valiant1990] Valiant, L.G. “批同步并行计算机,” ACM 通信 33:8 (1990 年 8 月). http://bit.ly/2IgX3ar.

  • [Vavilapalli2013] Vavilapalli, et al. “Apache Hadoop YARN:另一种资源协调器,” ACM 云计算研讨会, 2013 年. http://bit.ly/2Xn3tuZ.

  • [Venkat2015] Venkat, B.,P. Padmanabhan,A. Arokiasamy 和 R. Uppalapati。“Spark Streaming 能否应对 Chaos Monkey?”Netflix 技术博客,2015 年 3 月 11 日。http://bit.ly/2WkDJmr

  • [Venkataraman2016] Venkataraman, S.,P. Aurojit,K. Ousterhout,A. Ghodsi,M. J. Franklin,B. Recht 和 I. Stoica。“Drizzle:规模化快速适应流处理”,加州大学伯克利分校技术报告,2016 年。http://bit.ly/2HW08Ot

  • [White2010] Tom White。“Hadoop:权威指南”,第 4 版,O'Reilly,2015 年。

  • [Zaharia2011] Zaharia, Matei, Mosharaf Chowdhury 等人。“弹性分布式数据集:内存集群计算的容错抽象”,UCB/EECS-2011-82。http://bit.ly/2IfZE4q

  • [Zaharia2012] Zaharia, Matei, Tathagata Das 等人。“离散化流:一种可扩展流处理的容错模型”,UCB/EECS-2012-259。http://bit.ly/2MpuY6c

第二部分:结构化流式处理

在这部分中,我们研究结构化流式处理。

我们从探索一个实际例子开始,这将帮助您建立对模型的直觉。然后,我们研究 API 并深入研究流处理的以下细节方面:

  • 使用源消耗数据

  • 使用丰富的流式 DataFrame/Dataset API 构建数据处理逻辑

  • 理解并处理事件时间

  • 处理流式应用程序中的状态

  • 学习关于任意状态的有状态转换

  • 使用 sink 将结果写入其他系统

最后,我们概述结构化流式处理的运行方面。

最后,我们探讨这一令人兴奋的新流式 API 的当前发展,并提供关于机器学习应用和近实时数据处理(连续流式处理)的实验性见解。

第七章:介绍结构化流处理

在数据密集型企业中,我们发现许多大型数据集:来自面向互联网服务器的日志文件、购物行为表以及带有传感器数据的 NoSQL 数据库,这些都是一些例子。所有这些数据集共享相同的基本生命周期:它们在某个时间点为空,并逐渐被到达的数据点填充,这些数据点被导向某种形式的次要存储。这个数据到达的过程不过是将数据流实体化到次要存储上。然后,我们可以使用我们喜爱的分析工具对这些静态数据集进行分析,使用称为批处理的技术,因为它们一次处理大块数据,并且通常需要大量时间来完成,从几分钟到几天不等。

Spark SQL中的Dataset抽象是分析静态数据的一种方式。它特别适用于结构化的数据;也就是说,它遵循了定义好的模式。Spark 中的Dataset API 结合了类似 SQL 的 API 的表达能力和类型安全的集合操作,这些操作类似于 Scala 集合和Resilient Distributed Dataset(RDD)编程模型。同时,Dataframe API 与 Python Pandas 和 R Dataframes 类似,扩展了 Spark 用户的范围,超越了最初的数据工程师核心,这些人习惯于在函数范式中开发。这种更高级的抽象旨在通过使用熟悉的 API,支持现代数据工程和数据科学实践,让更广泛的专业人士加入到大数据分析的行列中。

如果不必等待数据“安定下来”,而是可以在数据保持原始流形式时应用相同的Dataset概念,会怎样?

结构化流模型是DatasetSQL 导向模型的扩展,用于处理运动中的数据:

  • 数据来自source流,并假定具有定义的模式。

  • 事件流可以看作是附加到无界表的行。

  • 要从流中获取结果,我们将计算表达为对该表的查询。

  • 通过不断地将相同查询应用于更新的表,我们创建一个处理后事件的输出流。

  • 结果事件被提供给一个输出sink

  • Sink可以是存储系统、另一个流后端或准备消耗处理后数据的应用程序。

在这种模型中,我们理论上无界的表必须在具有定义资源限制的物理系统中实现。因此,模型的实现需要考虑和限制来处理潜在的无限数据流入。

为了解决这些挑战,结构化流引入了对DatasetDataFrame API 的新概念,例如支持事件时间、水印和确定存储过去数据时间长度的不同输出模式。

从概念上讲,结构化流处理模型模糊了批处理和流处理之间的界限,大大减少了处理快速移动数据分析的负担。

使用结构化流处理的第一步

在上一节中,我们学习了构成结构化流处理的高级概念,如数据源、数据汇和查询。现在,我们将从实际角度探索结构化流处理,以简化的网络日志分析用例为例进行说明。

在我们开始深入研究我们的第一个流处理应用程序之前,我们将看看如何将 Apache Spark 中的经典批处理分析应用到相同的用例中。

本练习有两个主要目标:

  • 首先,大多数,如果不是全部,流数据分析都是从研究静态数据样本开始的。从一个数据文件开始研究会更容易,可以直观地了解数据的外观,显示的模式类型以及定义我们从数据中提取所需知识的过程。通常,只有在定义和测试我们的数据分析作业之后,我们才会将其转换为可以将我们的分析逻辑应用于正在移动的数据的流处理过程。

  • 其次,从实际角度来看,我们可以欣赏 Apache Spark 如何通过统一的 API 简化从批量探索到流处理应用程序的过渡的许多方面。

这种探索将使我们能够比较和对比 Spark 中批处理和流处理 API,并向我们展示从一个模式转换到另一个模式所需的必要步骤。

在线资源

对于此示例,我们使用来自公共 1995 年 NASA Apache 网络日志的 Apache Web 服务器日志,最初来源于http://ita.ee.lbl.gov/html/contrib/NASA-HTTP.html

出于本练习的目的,原始日志文件已被拆分为每日文件,并且每个日志行都已格式化为 JSON。压缩的 NASA-weblogs 文件可以从https://github.com/stream-processing-with-spark下载。

下载这个数据集,并将其放在计算机上的一个文件夹中。

批处理分析

鉴于我们正在处理归档日志文件,我们可以一次性访问所有数据。在我们开始构建流处理应用程序之前,让我们简要地插曲一下,看看经典批量分析作业的样子。

在线资源

对于此示例,我们将在书籍的在线资源中使用 batch_weblogs 笔记本,位于https://github.com/stream-processing-with-spark][https://github.com/stream-processing-with-spark]。

首先,我们从我们解压缩它们的目录中加载以 JSON 编码的日志文件:

// This is the location of the unpackaged files. Update accordingly
val logsDirectory = ???
val rawLogs = sparkSession.read.json(logsDirectory)

接下来,我们声明数据的模式为一个case class,以使用类型化的Dataset API。按照数据集的正式描述(在NASA-HTTP)中,日志结构如下:

日志是一个 ASCII 文件,每个请求一行,包含以下列:

  • 发出请求的主机。如果可能,是主机名,否则是互联网地址(如果未查找到名称)。
  • 时间戳格式为“DAY MON DD HH:MM:SS YYYY”,其中 DAY 是星期几,MON 是月份名称,DD 是月份中的日期,HH:MM:SS 是使用 24 小时制的时间,YYYY 是年份。时区为–0400。
  • 给出的请求用引号括起来。
  • HTTP 回复代码。
  • 回复中的字节数。

将该模式转换为 Scala,我们有以下case class定义:

import java.sql.Timestamp
case class WebLog(host: String,
                  timestamp: Timestamp,
                  request: String,
                  http_reply: Int,
                  bytes: Long
                 )
注意

我们使用java.sql.Timestamp作为时间戳的类型,因为它在 Spark 内部得到支持,并且不需要其他选项可能需要的任何额外cast

我们使用先前的模式定义,将原始 JSON 转换为类型化数据结构:

import org.apache.spark.sql.functions._
import org.apache.spark.sql.types.IntegerType
// we need to narrow the `Interger` type because
// the JSON representation is interpreted as `BigInteger`
val preparedLogs = rawLogs.withColumn("http_reply",
                                      $"http_reply".cast(IntegerType))
val weblogs = preparedLogs.as[WebLog]

现在我们将数据结构化后,可以开始询问我们感兴趣的问题了。作为第一步,我们想知道我们的数据集中包含多少条记录:

val recordCount = weblogs.count
>recordCount: Long = 1871988

一个常见的问题是:“每天最受欢迎的 URL 是什么?”为了回答这个问题,我们首先将时间戳缩减到月份中的某一天。然后我们按照这个新的dayOfMonth列和请求 URL 进行分组,并在此聚合上进行计数。最后我们按降序排序以获取前面的 top URLs:

val topDailyURLs = weblogs.withColumn("dayOfMonth", dayofmonth($"timestamp"))
                          .select($"request", $"dayOfMonth")
                          .groupBy($"dayOfMonth", $"request")
                          .agg(count($"request").alias("count"))
                          .orderBy(desc("count"))

topDailyURLs.show()
+----------+----------------------------------------+-----+
|dayOfMonth|                                 request|count|
+----------+----------------------------------------+-----+
|        13|GET /images/NASA-logosmall.gif HTTP/1.0 |12476|
|        13|GET /htbin/cdt_main.pl HTTP/1.0         | 7471|
|        12|GET /images/NASA-logosmall.gif HTTP/1.0 | 7143|
|        13|GET /htbin/cdt_clock.pl HTTP/1.0        | 6237|
|         6|GET /images/NASA-logosmall.gif HTTP/1.0 | 6112|
|         5|GET /images/NASA-logosmall.gif HTTP/1.0 | 5865|
        ...

前几位的都是图片。现在怎么办?看到前几位的 URL 是站点上常用的图片并不罕见。我们真正感兴趣的是生成最多流量的内容页面。为了找到这些页面,我们首先过滤出html内容,然后继续应用我们刚学到的 top 聚合。

如我们所见,请求字段是引用的[HTTP_VERB] URL [HTTP_VERSION]序列。我们将提取 URL,并仅保留以.html.htm结尾或无扩展名(目录)的 URL。这是为了本例的简化:

val urlExtractor = """^GET (.+) HTTP/\d.\d""".r
val allowedExtensions = Set(".html",".htm", "")
val contentPageLogs = weblogs.filter {log =>
  log.request match {
    case urlExtractor(url) =>
      val ext = url.takeRight(5).dropWhile(c => c != '.')
      allowedExtensions.contains(ext)
    case _ => false
  }
}

有了这个仅包含.html.htm和目录的新数据集,我们继续应用与之前相同的top-k函数:

val topContentPages = contentPageLogs
  .withColumn("dayOfMonth", dayofmonth($"timestamp"))
  .select($"request", $"dayOfMonth")
  .groupBy($"dayOfMonth", $"request")
  .agg(count($"request").alias("count"))
  .orderBy(desc("count"))

topContentPages.show()
+----------+------------------------------------------------+-----+
|dayOfMonth|                                         request|count|
+----------+------------------------------------------------+-----+
|        13| GET /shuttle/countdown/liftoff.html HTTP/1.0"  | 4992|
|         5| GET /shuttle/countdown/ HTTP/1.0"              | 3412|
|         6| GET /shuttle/countdown/ HTTP/1.0"              | 3393|
|         3| GET /shuttle/countdown/ HTTP/1.0"              | 3378|
|        13| GET /shuttle/countdown/ HTTP/1.0"              | 3086|
|         7| GET /shuttle/countdown/ HTTP/1.0"              | 2935|
|         4| GET /shuttle/countdown/ HTTP/1.0"              | 2832|
|         2| GET /shuttle/countdown/ HTTP/1.0"              | 2330|
        ...

我们可以看到那个月份最受欢迎的页面是liftoff.html,对应于探索发现航天飞机发射的报道,详细记录在NASA 档案中。紧随其后的是countdown/,即发射前几天的倒计时页面。

流分析

在前一节中,我们探索了历史上 NASA 的网络日志记录。我们发现这些记录中的趋势事件发生在实际事件之后很久。

流分析的一个关键驱动因素是组织对及时信息的增加需求,这些信息可以帮助他们在多个不同层次上做出决策。

我们可以利用我们在使用面向批处理方法探索归档记录时学到的经验,创建一个流作业,将在发生事件时提供趋势信息。

我们观察到与批量分析不同的第一个区别是数据的来源。对于我们的流处理练习,我们将使用 TCP 服务器来模拟一个实时传递其日志的网络系统。模拟器将使用相同的数据集,但将其通过 TCP 套接字连接供给,这将成为我们分析的流。

在线资源

对于此示例,我们将使用书籍在线资源中的笔记本weblog_TCP_serverstreaming_weblogs,位于https://github.com/stream-processing-with-spark

连接到流

如果您回顾本章的介绍,结构化流定义了源和接收端概念作为消费流和生成结果的关键抽象。我们将使用TextSocketSource实现来通过 TCP 套接字连接到服务器。套接字连接由服务器的主机和它监听连接的端口定义。这两个配置元素是创建socket源所必需的:

val stream = sparkSession.readStream
  .format("socket")
  .option("host", host)
  .option("port", port)
  .load()

注意,创建流与静态数据源声明非常相似。不再使用read构建器,而是使用readStream构造,并向其传递流源所需的参数。在进行此练习的过程中以及稍后深入了解结构化流的细节时,API 基本上与静态数据的DataFrameDataset API 相同,但有一些您将详细了解的修改和限制。

准备流中的数据

socket源生成一个带有一列value的流DataFrame,该列包含从流接收的数据。有关详细信息,请参见“Socket 源”。

在批量分析案例中,我们可以直接加载数据作为 JSON 记录。对于Socket源,数据是纯文本。为了将原始数据转换为WebLog记录,我们首先需要一个模式。模式提供了将文本解析为 JSON 对象所需的信息。当我们谈论结构化流时,它就是结构

在为数据定义模式之后,我们继续创建一个Dataset,按以下步骤操作:

import java.sql.Timestamp
case class WebLog(host:String,
                  timestamp: Timestamp,
                  request: String,
                  http_reply:Int,
                  bytes: Long
                 )
val webLogSchema = Encoders.product[WebLog].schema ![1](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/st-proc-spk/img/1.png)
val jsonStream = stream.select(from_json($"value", webLogSchema) as "record") ![2](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/st-proc-spk/img/2.png)
val webLogStream: Dataset[WebLog] = jsonStream.select("record.*").as[WebLog] ![3](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/st-proc-spk/img/3.png)

1

case class定义中获取模式

2

使用 Spark SQL 内置的 JSON 支持将文本value转换为 JSON

3

使用Dataset API 将 JSON 记录转换为WebLog对象

通过这个过程,我们得到了一个WebLog记录的Streaming Dataset

对流数据集的操作

我们刚刚获得的webLogStream是类型为Dataset[WebLog]的流,就像我们在批处理分析作业中看到的一样。这个实例与批处理版本的不同之处在于webLogStream是一个流式Dataset

我们可以通过查询对象来观察到这一点:

webLogStream.isStreaming
> res: Boolean = true

在批处理作业的这一点上,我们正在对我们的数据创建第一个查询:我们的数据集中包含多少记录?当我们可以访问所有数据时,这是一个我们可以轻松回答的问题。然而,如何计算不断到达的记录数?答案是我们认为在静态Dataset上通常执行的一些操作,如计算所有记录数,对于Streaming Dataset来说并没有明确定义的含义。

正如我们可以观察到的,尝试在以下代码片段中执行count查询将导致AnalysisException

val count = webLogStream.count()
> org.apache.spark.sql.AnalysisException: Queries with streaming sources must
be executed with writeStream.start();;

这意味着我们在静态DatasetDataFrame上使用的直接查询现在需要两个层次的交互。首先,我们需要声明流的转换,然后我们需要启动流处理过程。

创建查询

什么是流行的网址?在什么时间范围内?现在,我们可以立即分析访问网站日志的流,不再需要等待一天或一个月(甚至在 NASA 网站日志的情况下超过 20 年),以获得流行网址的排名。随着趋势的展示,我们可以在更短的时间窗口内获得这些信息。

首先,为了定义我们感兴趣的时间段,我们在某个时间戳上创建一个窗口。结构化流的一个有趣特性是,我们可以在数据生成时的时间戳上定义时间间隔,也称为事件时间,而不是数据被处理时的时间。

我们的窗口定义将是五分钟的事件数据。鉴于我们的时间线是模拟的,这五分钟可能比实际时钟时间快得多或慢得多。通过这种方式,我们可以清楚地看到结构化流如何使用事件中的时间戳信息来跟踪事件时间线。

正如我们从批处理分析中学到的那样,我们应该提取 URL 并仅选择内容页面,比如.html、.htm或目录。在继续定义窗口查询之前,让我们先应用这些获得的知识:

// A regex expression to extract the accessed URL from weblog.request
val urlExtractor = """^GET (.+) HTTP/\d.\d""".r
val allowedExtensions = Set(".html", ".htm", "")

val contentPageLogs: String => Boolean = url => {
  val ext = url.takeRight(5).dropWhile(c => c != '.')
  allowedExtensions.contains(ext)
}

val urlWebLogStream = webLogStream.flatMap { weblog =>
  weblog.request match {
    case urlExtractor(url) if (contentPageLogs(url)) =>
      Some(weblog.copy(request = url))
    case _ => None
  }
}

我们已经将请求转换为仅包含访问的 URL 并过滤掉所有非内容页面。现在,我们定义窗口查询来计算热门趋势 URL:

val rankingURLStream = urlWebLogStream
    .groupBy($"request", window($"timestamp", "5 minutes", "1 minute"))
    .count()

启动流处理

到目前为止,我们所遵循的所有步骤都是为了定义流将经历的过程。但是目前还没有处理任何数据。

要启动结构化流作业,我们需要指定一个sink和一个output mode。这是结构化流引入的两个新概念:

  • sink定义了我们想要将结果数据实体化的位置;例如,可以是文件系统中的文件,内存中的表,或者是其他流系统如 Kafka。

  • output mode定义了我们希望交付结果的方式:我们是否希望每次看到所有数据,仅更新,还是只看到新记录?

这些选项提供给writeStream操作。它创建了开始流消费的流查询,实现在查询上声明的计算,并将结果生成到输出sink中。

我们稍后详细讨论所有这些概念。现在,让我们凭经验使用它们并观察结果。

对于我们在示例 7-1 中展示的查询,我们使用memory sink和输出模式complete,每次向结果添加新记录时都会有一个完全更新的表,以跟踪 URL 排名的结果。

示例 7-1 编写流到 sink
val query = rankingURLStream.writeStream
  .queryName("urlranks")
  .outputMode("complete")
  .format("memory")
  .start()

memory sink将数据输出到与给定的queryName选项中相同名称的临时表中。我们可以通过查询Spark SQL注册的表来观察到这一点:

scala> spark.sql("show tables").show()
+--------+---------+-----------+
|database|tableName|isTemporary|
+--------+---------+-----------+
|        | urlranks|       true|
+--------+---------+-----------+

在示例 7-1 中的表达式中,query的类型是StreamingQuery,它是一个处理查询生命周期的处理程序。

探索数据

鉴于我们在生产者端加速日志时间线,几秒钟后,我们可以执行下一个命令来查看第一个窗口的结果,如图 7-1 所示。

注意处理时间(几秒钟)与事件时间(数百分钟的日志)是如何分离的:

urlRanks.select($"request", $"window", $"count").orderBy(desc("count"))

spas 0701

图 7-1 URL 排名:按窗口查询结果

我们在第十二章中详细探讨事件时间。

摘要

在进入结构化流处理的初始阶段,您已经了解到流应用程序开发背后的过程。通过从过程的批处理版本开始,您对数据有了直观的理解,并利用这些见解,我们创建了作业的流处理版本。在这个过程中,您可以体会到结构化批处理和流处理 API 之间的密切联系,尽管我们也观察到在流处理上下文中一些通常的批处理操作不适用。

通过这个练习,我们希望增加您对结构化流处理的好奇心。您现在已经准备好通过本节的学习路径了。

第八章:结构化流处理编程模型

结构化流处理建立在构建在 Spark SQL 的 DataFrameDataset API 之上的基础上。通过扩展这些 API 以支持流式工作负载,结构化流处理继承了 Spark SQL 引入的高级语言特性以及底层优化,包括使用 Catalyst 查询优化器和 Project Tungsten 提供的低开销内存管理和代码生成。同时,结构化流处理在 Spark SQL 的所有支持语言绑定中可用,包括 Scala、Java、Python 和 R,尽管一些高级状态管理功能目前仅在 Scala 中可用。由于 Spark SQL 使用的中间查询表示形式,程序的性能在使用的语言绑定无关时是相同的。

结构化流处理引入了对事件时间的支持,覆盖了所有窗口和聚合操作,可以轻松编写使用事件生成时间而非进入处理引擎的时间(也称为处理时间)的逻辑。你在“时间的影响”中学习了这些概念。

结构化流处理在 Spark 生态系统中的可用性使得 Spark 能够统一经典批处理和基于流的数据处理的开发体验。

在本章中,我们通过按照通常需要创建结构化流作业的步骤顺序来审视结构化流处理的编程模型:

  • 初始化 Spark

  • 数据源:获取流数据

  • 声明我们希望应用于流数据的操作

  • 数据汇:输出生成的数据

初始化 Spark

Spark API 的可见统一部分是,SparkSession 成为了使用结构化流处理的批处理流处理应用程序的单一入口点。

因此,我们创建 Spark 作业的入口点与使用 Spark 批处理 API 相同:我们实例化一个 SparkSession,如示例 8-1 所示。

示例 8-1. 创建本地 Spark 会话
import org.apache.spark.sql.SparkSession

val spark = SparkSession
  .builder()
  .appName("StreamProcessing")
  .master("local[*]")
  .getOrCreate()

使用 Spark Shell

当使用 Spark shell 探索结构化流处理时,SparkSession 已经提供为 spark。我们不需要创建任何额外的上下文来使用结构化流处理。

数据源:获取流数据

在结构化流处理中,是一个抽象概念,允许我们从流数据生产者消费数据。源并非直接创建,而是sparkSession 提供了一个构建方法 readStream,它公开了用于指定流源(称为格式)及其配置的 API。

例如,示例 8-2 中的代码创建一个File流式数据源。我们使用format方法指定源的类型。schema方法允许我们为数据流提供模式,对于某些源类型(如File源)来说,这是必需的。

示例 8-2. 文件流式数据源
val fileStream = spark.readStream
  .format("json")
  .schema(schema)
  .option("mode","DROPMALFORMED")
  .load("/tmp/datasrc")

>fileStream:
org.apache.spark.sql.DataFrame = [id: string, timestamp: timestamp ... ]

每个源实现都有不同的选项,有些有可调参数。在示例 8-2 中,我们将选项mode设置为DROPMALFORMED。此选项指示 JSON 流处理器放弃任何不符合 JSON 格式或不匹配提供的模式的行。

在幕后,对spark.readStream的调用创建一个DataStreamBuilder实例。此实例负责通过构建器方法调用管理提供的不同选项。在此DataStreamBuilder实例上调用load(...)验证构建器提供的选项,如果一切正常,则返回流DataFrame

注意

我们可以欣赏到 Spark API 中的对称性:readStream提供了声明流源的选项,而writeStream让我们指定输出接收器和我们的进程所需的输出模式。它们是DataFrameAPI 中readwrite的对应物。因此,它们提供了一种记忆 Spark 程序中使用的执行模式的简便方法:

  • read/write:批处理操作

  • readStream/writeStream:流式操作

在我们的示例中,这个流DataFrame表示的是从监视提供的路径并处理该路径中的每个新文件作为 JSON 编码数据而产生的数据流。所有格式不正确的代码都将从此数据流中丢弃。

加载流式数据源是延迟执行的。我们得到的是流的表示,体现在流DataFrame实例中,我们可以用它来表达我们想要应用的一系列转换,以实现我们特定的业务逻辑。创建流DataFrame不会导致任何数据被消耗或处理,直到流被实现为止。这需要一个查询,如后面所示。

可用的数据源

自 Spark v2.4.0 起,支持以下流式数据源:

jsonorcparquetcsvtexttextFile

这些都是基于文件的流式数据源。基本功能是监视文件系统中的路径(文件夹)并原子地消费放置在其中的文件。然后将找到的文件由指定的格式化程序解析。例如,如果提供了json,则使用 Spark 的json读取器处理文件,使用提供的模式信息。

socket

建立与假定通过套接字连接提供文本数据的 TCP 服务器的客户端连接。

kafka

创建能够从 Kafka 检索数据的 Kafka 消费者。

rate

使用 rowsPerSecond 选项以给定速率生成行流。这主要是作为测试源使用的。

我们在 第十章 中详细介绍了源。

转换流数据

正如我们在上一节中看到的,调用 load 的结果是一个流式 DataFrame。在使用 source 创建了我们的流式 DataFrame 后,我们可以使用 DatasetDataFrame API 来表达我们想要应用于流数据的逻辑,以实现我们的特定用例。

警告

请记住,DataFrameDataset[Row] 的别名。虽然这看似是一个小的技术区别,但在从 Scala 等类型化语言使用时,Dataset API 提供了一个类型化的接口,而 DataFrame 的使用是无类型的。当从 Python 等动态语言使用结构化 API 时,DataFrame API 是唯一可用的 API。

在使用类型化 Dataset 上的操作时,性能会有影响。虽然 DataFrame API 中使用的 SQL 表达式可以被查询计划器理解并进一步优化,但是 Dataset 操作中提供的闭包对查询计划器来说是不透明的,因此可能比完全相同的 DataFrame 对应物运行得更慢。

假设我们正在使用传感器网络的数据,在 示例 8-3 中,我们从 sensorStream 中选择 deviceIdtimestampsensorTypevalue 字段,并仅筛选出传感器类型为 temperature 且其 value 高于给定 threshold 的记录。

示例 8-3. 过滤和投影
val highTempSensors = sensorStream
  .select($"deviceId", $"timestamp", $"sensorType", $"value")
  .where($"sensorType" === "temperature" && $"value" > threshold)

同样地,我们可以对数据进行聚合,并对组进行时间操作。示例 8-4 表明我们可以使用事件本身的 timestamp 信息来定义一个五分钟的时间窗口,每分钟滑动一次。我们在 第十二章 中详细讨论了事件时间。

这里需要理解的重点是,结构化流 API 在批处理分析中实际上与 Dataset API 几乎相同,但具有一些特定于流处理的额外规定。

示例 8-4. 按传感器类型的时间平均值
val avgBySensorTypeOverTime = sensorStream
  .select($"timestamp", $"sensorType", $"value")
  .groupBy(window($"timestamp", "5 minutes", "1 minute"), $"sensorType")
  .agg(avg($"value")

如果您对 Spark 的结构化 API 不熟悉,我们建议您先熟悉一下。详细介绍这个 API 超出了本书的范围。我们推荐参考 Spark: The Definitive Guide(O’Reilly,2018),由 Bill Chambers 和 Matei Zaharia 编写,作为一个全面的参考资料。

DataFrame API 上的流 API 限制

正如我们在前一章中提到的,一些标准 DataFrameDataset API 提供的操作在流上是没有意义的。

我们举了 stream.count 的例子,这在流上没有意义。一般来说,需要立即材料化底层数据集的操作是不允许的。

这些是流上不直接支持的 API 操作:

  • count

  • show

  • describe

  • limit

  • take(n)

  • distinct

  • foreach

  • sort

  • 多重堆叠聚合

在这些操作之外,部分支持流-流和静态流的join

理解限制

尽管某些操作,如countlimit,在流上没有意义,但其他一些流操作在计算上是困难的。例如,distinct就是其中之一。要在任意流中过滤重复项,需要记住到目前为止看到的所有数据,并将每个新记录与已看到的所有记录进行比较。第一个条件需要无限的内存,第二个具有O(n²)的计算复杂度,随着元素数量(n)的增加,这变得难以接受。

聚合流上的操作

一些不支持的操作在我们对流应用聚合函数后变得定义明确。虽然我们不能对流进行count,但我们可以计算每分钟接收的消息数量或某种类型设备的数量。

在 Example 8-5 中,我们定义了每分钟每个sensorType事件的count

Example 8-5. 按时间计数传感器类型
val avgBySensorTypeOverTime = sensorStream
  .select($"timestamp", $"sensorType")
  .groupBy(window($"timestamp", "1 minutes", "1 minute"), $"sensorType")
  .count()

同样,也可以在聚合数据上定义sort,尽管进一步限制为输出模式为complete的查询。我们将在“outputMode”中更详细地讨论输出模式。

流去重

我们讨论过,在任意流上执行distinct在计算上是困难的。但如果我们能定义一个键,告诉我们何时已经看到流中的元素,我们可以使用它来去除重复项:

stream.dropDuplicates("<key-column>")

工作解决方案

尽管某些操作不能像批量模型中那样直接支持,但有替代方法可以实现相同的功能:

foreach

尽管foreach不能直接在流上使用,但有一个foreach sink提供相同的功能。

指定在流输出定义中的汇聚。

show

尽管show需要立即实现查询,因此无法在流式Dataset上使用,但我们可以使用console sink 将数据输出到屏幕上。

汇聚:输出生成的数据

到目前为止,我们所做的所有操作——比如创建流并对其应用转换——都是声明性的。它们定义了从哪里消费数据以及我们想对其应用哪些操作。但到目前为止,系统中仍然没有数据流动。

在我们能够启动流之前,我们首先需要定义输出数据何处如何去:

  • Where 关联流式接收端:我们流式数据的接收端。

  • How 指的是输出模式:如何处理我们流中的结果记录。

从 API 角度来看,我们通过在流式DataFrameDataset上调用writeStream来实现流的材料化,如 Example 8-6 所示。

在流式 Dataset 上调用 writeStream 创建一个 DataStreamWriter。这是一个生成器实例,提供配置流处理过程输出行为的方法。

示例 8-6. 文件流式处理输出目的地
val query = stream.writeStream
  .format("json")
  .queryName("json-writer")
  .outputMode("append")
  .option("path", "/target/dir")
  .option("checkpointLocation", "/checkpoint/dir")
  .trigger(ProcessingTime("5 seconds"))
  .start()

>query: org.apache.spark.sql.streaming.StreamingQuery = ...

我们在 第十一章 中详细讨论了输出目的地。

format

format 方法允许我们通过提供内置输出目的地的名称或自定义输出目的地的完全限定名称来指定输出目的地。

从 Spark v2.4.0 开始,以下流式处理输出目的地可用:

console sink

打印到标准输出的输出目的地。它显示的行数可以通过选项 numRows 进行配置。

file sink

基于文件和特定格式的输出目的地,将结果写入文件系统。格式由提供的格式名称指定:csvhivejsonorcparquetavrotext

kafka sink

一个 Kafka 特定的生产者 sink,能够写入一个或多个 Kafka 主题。

memory sink

使用提供的查询名称作为表名创建内存表。此表接收流的结果的持续更新。

foreach sink

提供编程接口,逐个访问流内容的元素。

foreachBatch sink

foreachBatch 是一个编程接口,提供对每个结构化流执行的每个微批次对应的完整 DataFrame 的访问。

outputMode

outputMode 指定了记录如何添加到流查询输出的语义。支持的模式有 appendupdatecomplete

append

(默认模式)仅将最终记录添加到输出流。当来自输入流的新记录无法修改其值时,记录被视为最终。这种情况始终适用于线性转换,例如应用投影、过滤和映射后的转换。此模式确保每个生成的记录仅输出一次。

update

自上次触发以来添加了新的和更新的记录到输出流。update 仅在聚合的上下文中有意义,其中随着新记录的到达,聚合值会发生变化。如果多个输入记录更改单个结果,则在触发间隔之间的所有更改将被整理成一个输出记录。

complete

complete 模式输出流的完整内部表示。此模式还涉及到聚合,因为对于非聚合流,我们需要记住到目前为止所看到的所有记录,这是不切实际的。从实际角度来看,只有在对低基数条件下聚合值时(如按国家计数的访问者),才建议使用 complete 模式,因为我们知道国家的数量是有限的。

理解 append 语义

当流查询包含聚合时,最终定义变得不那么显而易见。在聚合计算中,新的传入记录可能在符合使用的聚合标准时改变现有的聚合值。根据我们的定义,我们不能在不知道其值最终的情况下使用append输出记录。因此,将append输出模式与聚合查询结合使用限制为仅在使用事件时间表达聚合且定义了watermark时有效。在这种情况下,当watermark过期后,append将输出一个事件,因此认为没有新的记录能够改变聚合值。因此,append模式下的输出事件将延迟聚合时间窗口加上水印偏移量。

queryName

使用queryName,我们可以为查询指定一个名称,一些接收器使用它,同时也显示在 Spark 控制台中的作业描述中,如图 8-1 所示。

spas 0801

图 8-1. Spark UI 中显示的已完成作业,展示了作业描述中的查询名称。

option

使用option方法,我们可以为流提供特定的键值对配置,类似于源的配置。每个接收器可以具有特定的配置,我们可以使用此方法进行定制。

我们可以添加尽可能多的.option(...)调用来配置接收器。

options

optionsoption的替代方案,它接受包含我们要设置的所有键值配置参数的Map[String, String]。这种替代方案更适合外部化配置模型,其中我们不预先知道要传递给接收器配置的设置。

trigger

可选的trigger选项允许我们指定生成结果的频率。默认情况下,结构化流处理将尽快处理输入并生成结果。指定触发器时,将在每个触发间隔产生输出。

org.apache.spark.sql.streaming.Trigger提供以下支持的触发器:

ProcessingTime(<interval>)

允许我们指定决定查询结果频率的时间间隔。

Once()

特定的Trigger允许我们执行一次流作业。它对于测试和将定义的流作业应用为单次批处理操作非常有用。

Continuous(<checkpoint-interval>)

这个触发器将执行引擎切换到实验性的continuous引擎,用于低延迟处理。checkpoint-interval参数指示数据弹性异步检查点的频率。不应与ProcessingTime触发器的batch interval混淆。我们在第十五章中探讨了这个新的执行选项。

start()

要实现流式计算,我们需要启动流处理过程。最后,start()将完整的作业描述实现为流式计算,并启动内部调度过程,从源消费数据、处理数据,并将结果生成到接收器。start()返回一个StreamingQuery对象,这是一个用于管理每个查询个体生命周期的控制句柄。这意味着我们可以在同一个sparkSession内独立地同时启动和停止多个查询。

总结

阅读完本章后,您应该对结构化流式编程模型和 API 有一个很好的理解。在本章中,您学到了以下内容:

  • 每个流处理程序都以定义源和当前可用源为开始。

  • 我们可以重复使用大部分熟悉的DatasetDataFrameAPI 来转换流数据。

  • 在流式模式下,一些常见的batchAPI 操作是没有意义的。

  • 接收器是流输出的可配置定义。

  • 流中输出模式和聚合操作之间的关系。

  • 所有转换都是惰性执行的,我们需要start我们的流程以使数据通过系统流动。

在下一章中,你将应用你新获得的知识来创建一个全面的流处理程序。之后,我们将深入探讨结构化流式处理 API 的特定领域,如事件时间处理、窗口定义、水印概念和任意状态处理。

第九章:结构化流实战

现在我们更好地理解了结构化流 API 和编程模型,在本章中,我们创建了一个小而完整的受物联网(IoT)启发的流程程序。

在线资源

作为示例,我们将使用书籍在线资源中的Structured-Streaming-in-action笔记本,位于https://github.com/stream-processing-with-spark

我们的用例将是作为流源从 Apache Kafka 消耗传感器读数流。

我们将会将传入的 IoT 传感器数据与包含所有已知传感器及其配置的静态参考文件进行关联。通过这种方式,我们会为每个传入的记录添加特定的传感器参数,以便处理报告的数据。然后,我们将所有处理正确的记录保存到 Parquet 格式的文件中。

Apache Kafka

Apache Kafka 是最流行的可扩展消息代理之一,用于在事件驱动系统中解耦生产者和消费者。它是基于分布式提交日志抽象的高度可伸缩的分布式流平台。它提供类似于消息队列或企业消息系统的功能,但在以下三个重要领域与其前身有所不同:

  • 运行在商品集群上,使其具有高度可伸缩性。

  • 容错数据存储确保数据接收和传递的一致性。

  • 拉取型消费者允许在不同的时间和速率下消费数据,从实时到微批到批处理,从而为各种应用程序提供数据提供的可能性。

您可以在http://kafka.apache.org找到 Kafka。

消费流源

我们程序的第一部分涉及创建流Dataset

val rawData = sparkSession.readStream
      .format("kafka")
      .option("kafka.bootstrap.servers", kafkaBootstrapServer)
      .option("subscribe", topic)
      .option("startingOffsets", "earliest")
      .load()

> rawData: org.apache.spark.sql.DataFrame

结构化流的入口是现有的 Spark Session(sparkSession)。正如您在第一行所看到的那样,创建流Dataset几乎与创建使用read操作的静态Dataset相同。sparkSession.readStream返回一个DataStreamReader,这是一个实现构建器模式以收集构建流源所需信息的类,使用流畅API。在该 API 中,我们找到了format选项,让我们指定我们的源提供程序,而在我们的情况下,这是kafka。随后的选项特定于源:

kafka.bootstrap.servers

指示要联系的引导服务器集的集合,作为逗号分隔的host:port地址列表

subscribe

指定要订阅的主题或主题集

startingOffsets

当此应用程序刚开始运行时应用的偏移复位策略

我们将在第十章中详细介绍 Kafka 流处理提供程序。

load() 方法评估 DataStreamReader 构建器,并创建一个 DataFrame 作为结果,正如我们在返回的值中看到的那样:

> rawData: org.apache.spark.sql.DataFrame

一个 DataFrameDataset[Row] 的别名,并且有已知的模式。创建后,您可以像常规的 Dataset 一样使用流 Dataset。这使得在结构化流中可以使用完整的 Dataset API,尽管某些例外情况存在,因为并非所有操作(例如 show()count())在流上下文中都有意义。

要以编程方式区分流 Dataset 和静态 Dataset,我们可以询问 Dataset 是否属于流类型:

rawData.isStreaming
res7: Boolean = true

我们还可以使用现有的 Dataset API 探索附加到其上的模式,正如在 示例 9-1 中所示。

示例 9-1. Kafka 架构
rawData.printSchema()

root
 |-- key: binary (nullable = true)
 |-- value: binary (nullable = true)
 |-- topic: string (nullable = true)
 |-- partition: integer (nullable = true)
 |-- offset: long (nullable = true)
 |-- timestamp: timestamp (nullable = true)
 |-- timestampType: integer (nullable = true)

一般而言,结构化流需要对消费流的模式进行明确声明。在 kafka 的特定情况下,结果 Dataset 的模式是固定的,与流的内容无关。它由一组字段组成,特定于 Kakfa sourcekeyvaluetopicpartitionoffsettimestamptimestampType,正如我们在 示例 9-1 中看到的那样。在大多数情况下,应用程序将主要关注流的实际负载所在的 value 字段的内容。

应用逻辑

回想一下,我们的工作意图是将传入的物联网传感器数据与包含所有已知传感器及其配置的参考文件相关联。这样,我们将丰富每个传入记录的特定传感器参数,以便解释报告的数据。然后,我们将所有处理正确的记录保存到一个 Parquet 文件中。来自未知传感器的数据将保存到一个单独的文件中,以供后续分析。

使用结构化流,我们的工作可以通过 Dataset 操作来实现:

val iotData = rawData.select($"value").as[String].flatMap{record =>
  val fields = record.split(",")
  Try {
    SensorData(fields(0).toInt, fields(1).toLong, fields(2).toDouble)
  }.toOption
}

val sensorRef = sparkSession.read.parquet(s"$workDir/$referenceFile")
sensorRef.cache()

val sensorWithInfo = sensorRef.join(iotData, Seq("sensorId"), "inner")

val knownSensors = sensorWithInfo
  .withColumn("dnvalue", $"value"*($"maxRange"-$"minRange")+$"minRange")
  .drop("value", "maxRange", "minRange")

在第一步中,我们将 CSV 格式的记录转换回 SensorData 条目。我们在从 value 字段提取的 String 类型的 Dataset[String] 上应用 Scala 函数操作。

然后,我们使用流 Dataset 对静态 Dataset 进行 inner join,以将传感器数据与相应的参考数据关联,使用 sensorId 作为键。

为了完成我们的应用程序,我们使用参考数据中的最小-最大范围来计算传感器读数的实际值。

写入流式接收器

我们流应用的最后一步是将丰富的物联网数据写入 Parquet 格式的文件。在结构化流中,write 操作至关重要:它标志着对流上声明的转换的完成,定义了一个 写模式,并在调用 start() 后,连续查询的处理将开始。

在结构化流处理中,所有操作都是对我们希望对流数据进行的操作的延迟声明。只有当我们调用start()时,流的实际消费才会开始,并且对数据的查询操作会实现为实际结果:

val knownSensorsQuery = knownSensors.writeStream
  .outputMode("append")
  .format("parquet")
  .option("path", targetPath)
  .option("checkpointLocation", "/tmp/checkpoint")
  .start()

让我们详细分解这个操作:

  • writeStream 创建一个构建器对象,在这里我们可以使用流畅接口配置所需写入操作的选项。

  • 通过format,我们指定了将结果材料化到下游的接收器。在我们的情况下,我们使用内置的FileStreamSink和 Parquet 格式。

  • mode 是结构化流处理中的一个新概念:理论上,我们可以访问到迄今为止在流中看到的所有数据,因此我们也有选项来生成该数据的不同视图。

  • 这里使用的append模式意味着我们的流处理计算产生的新记录会被输出。

调用start的结果是一个StreamingQuery实例。该对象提供了控制查询执行和请求有关我们正在运行的流查询状态信息的方法,正如示例 9-2 所示。

示例 9-2. 查询进度
knownSensorsQuery.recentProgress

res37: Array[org.apache.spark.sql.streaming.StreamingQueryProgress] =
Array({
  "id" : "6b9fe3eb-7749-4294-b3e7-2561f1e840b6",
  "runId" : "0d8d5605-bf78-4169-8cfe-98311fc8365c",
  "name" : null,
  "timestamp" : "2017-08-10T16:20:00.065Z",
  "numInputRows" : 4348,
  "inputRowsPerSecond" : 395272.7272727273,
  "processedRowsPerSecond" : 28986.666666666668,
  "durationMs" : {
    "addBatch" : 127,
    "getBatch" : 3,
    "getOffset" : 1,
    "queryPlanning" : 7,
    "triggerExecution" : 150,
    "walCommit" : 11
  },
  "stateOperators" : [ ],
  "sources" : [ {
    "description" : "KafkaSource[Subscribe[iot-data]]",
    "startOffset" : {
      "iot-data" : {
        "0" : 19048348
      }
    },
    "endOffset" : {
      "iot-data" : {
        "0" : 19052696
      }
    },
    "numInputRow...

在 示例 9-2 中,通过调用knownSensorsQuery.recentProgress,我们可以看到StreamingQueryProgress的结果。如果numInputRows有非零值,我们可以确定我们的作业正在消费数据。我们现在有一个正常运行的结构化流处理作业。

总结

希望通过这个实践章节,能够向你展示如何使用结构化流处理创建你的第一个非平凡应用程序。

阅读完本章后,你应该更好地理解了结构化流处理程序的结构以及如何处理流应用程序,从消费数据、使用DatasetDataFrames API 进行处理,到将数据输出到外部。此时,你几乎准备好迎接创建自己的流处理作业的挑战了。

在接下来的章节中,你将深入学习结构化流处理的不同方面。

第十章:结构化流源

前几章对结构化流编程模型提供了很好的概述,并展示了如何在实际中应用它。您还看到了源是每个结构化流程序的起点。在本章中,我们研究了源的一般特性,并更详细地审视了可用的源,包括它们的不同配置选项和操作模式。

理解源

在结构化流中,源是表示流数据提供程序的抽象。源接口背后的概念是,流数据是随时间持续流动的事件序列,可以看作是一个按单调递增计数器索引的序列。

图 10-1 显示了流中每个事件被认为具有不断增加的偏移量。

spas 1001

图 10-1. 流被视为事件索引序列

正如在 图 10-2 中所示的,偏移量用于从外部源请求数据,并指示已经消耗的数据。结构化流通过向外部系统请求当前偏移量并将其与上次处理的偏移量进行比较来知晓是否有数据需要处理。要处理的数据是通过获取 startend 之间的一个 批次 来请求的。通过提交给定的偏移量通知源已经处理了数据。源契约保证已处理的偏移量小于或等于提交的偏移量的所有数据,并且随后的请求只会规定大于该提交的偏移量。基于这些保证,源可以选择丢弃已处理的数据以释放系统资源。

spas 1002

图 10-2. 偏移量处理序列

让我们更详细地看一下在 图 10-2 中显示的基于偏移量处理的动态:

  1. t1 时,系统调用 getOffset 并获取 的当前偏移量。

  2. t2 时,系统通过调用 getBatch(start, end) 获取到最后已知偏移量的批次。请注意,此期间可能会有新数据到达。

  3. t3 时,系统 提交 偏移量,源删除相应记录。

这一过程不断重复,确保获取流数据。为了从可能的失败中恢复,偏移量通常会被 检查点 到外部存储。

除了基于偏移量的交互,源必须满足两个要求:为了可靠性,源必须以相同顺序可重放;并且源必须提供模式。

可靠的源必须是可重播的

在结构化流处理中,可重放性是指能够请求已经请求但尚未提交的流的部分。就像我们可以倒回正在观看的 Netflix 系列节目,以查看因为分心而错过的片段一样,数据源必须提供重播已请求但未提交流的能力。通过调用 getBatch 并指定我们想要重新接收的偏移范围来实现这一点。

当结构化流处理过程完全失败后,数据源仍然能够生成未提交的偏移范围时,源被认为是可靠的。在此失败恢复过程中,偏移量从其上次已知的检查点恢复,并从数据源重新请求。这要求支持数据安全存储在 streaming 过程外的实际流系统。通过要求数据源具备可重放性,结构化流处理将恢复责任委托给数据源。这意味着只有可靠的数据源与结构化流处理一起工作,以创建强大的端到端交付保证。

数据源必须提供模式

Spark 结构化 API 的一个定义特征是它们依赖于模式信息来处理不同层次的数据。与处理不透明的字符串或字节数组 blob 相对,模式信息提供了关于数据字段和类型的洞察力。我们可以使用模式信息来驱动从查询规划到数据的内部二进制表示、存储和访问的不同层次的优化。

数据源必须提供描述其生成数据的模式信息。一些数据源实现允许配置此模式,并使用此配置信息自动解析传入数据并将其转换为有效记录。事实上,许多基于文件的流式数据源(如 JSON 或逗号分隔值(CSV)文件)遵循这种模式,用户必须提供文件格式使用的模式以确保正确解析。其他一些数据源使用固定的内部模式来表达每条记录的元数据信息,并将有效载荷的解析留给应用程序。

从架构角度来看,创建基于模式驱动的流处理应用程序是可取的,因为它有助于全局理解数据如何在系统中流动,并推动多进程流水线的不同阶段的正式化。

定义模式

在结构化流处理中,我们重用 Spark SQL API 来创建模式定义。有几种不同的方法可以用来定义流内容的模式:通过编程方式、从 case class 定义中推断,或者从现有数据集加载:

在编程方式中

我们使用StructTypeStructField类来构建模式的表示。例如,要表示具有idtype位置坐标的跟踪车辆,我们可以构造以下相应的模式结构:

import org.apache.spark.sql.{StructType, StructField}_
import org.apache.spark.sql.types._

val schema = StructType(
  List(
    StructField("id", StringType, true),
    StructField("type", StringType, true),
    StructField("location", StructType(List(
        StructField("latitude", DoubleType, false),
        StructField("longitude", DoubleType, false)
        )), false)
    )
  )

StructField可以包含嵌套的StructType,从而可以创建任意深度和复杂度的模式。

通过推断

在 Scala 中,模式也可以使用任意组合的case class来表示。给定单个case classcase class层次结构,可以通过为case class创建Encoder并从该encoder实例获取模式来计算模式表示。

使用此方法,可以像下面这样获取先前示例中使用的相同模式定义:

import org.apache.spark.sql.Encoders

// Define the case class hierarchy
case class Coordinates(latitude: Double, longitude: Double)
case class Vehicle(id: String, `type`: String, location: Coordinates )
// Obtain the Encoder, and the schema from the Encoder
val schema = Encoders.product[Vehicle].schema

从数据集中提取

获取模式定义的一种实用方法是通过维护以 Parquet 等模式感知格式存储的样本数据文件。为了获取我们的模式定义,我们加载样本数据集,并从加载的DataFrame中获取模式定义:

val sample = spark.read.parquet(<path-to-sample>)
val schema = sample.schema

定义模式的编程方式功能强大,但需要付出努力,并且维护复杂,往往导致错误。在原型设计阶段加载数据集可能是实用的,但在某些情况下,需要保持样本数据集的最新状态,这可能会导致意外的复杂性。

尽管选择最佳方法可能因用例而异,但一般来说,在使用 Scala 时,我们更倾向于在可能的情况下使用推断方法。

可用的数据源

下面是当前在结构化流 Spark 分发中可用的源:

文件

允许摄入存储为文件的数据。在大多数情况下,数据转换为进一步在流模式中处理的记录。支持这些格式:JSON、CSV、Parquet、ORC 和纯文本。

Kafka

允许从 Apache Kafka 消费流数据。

套接字

一个 TCP 套接字客户端能够连接到 TCP 服务器并消费文本数据流。流必须使用 UTF-8 字符集进行编码。

速率

生成具有可配置生产率的内部生成的(timestamp, value)记录流。通常用于学习和测试目的。

正如我们在“理解数据源”中讨论的那样,当结构化流处理失败时,源提供从偏移量回放的能力时,认为这些源是可靠的。根据此标准,我们可以将可用的源分类如下:

可靠

文件源、Kafka 源

不可靠

套接字源、速率源

不可靠的源仅在可以容忍数据丢失时才可用于生产系统。

警告

流源 API 目前正在不断发展中。截至目前为止,没有稳定的公共 API 来开发自定义源。预计在不久的将来会发生变化。

在本章的后续部分中,我们将详细探讨当前可用的数据来源。作为生产就绪的来源,文件和 Kafka 来源具有许多我们将详细讨论的选项。套接字和速率来源在功能上有所限制,这将通过其简明的覆盖来表现出来。

文件来源

文件来源是一个简单的流数据来源,从监视的文件系统目录中读取文件。基于文件的交接是一种常用的方法,用于将基于批处理的过程与流式系统桥接起来。批处理过程以文件格式生成其输出,并将其放置在一个通用目录中,文件来源的适当实现可以捡起这些文件并将其内容转换为记录流,以进一步在流模式下处理。

指定文件格式

文件使用指定的格式进行读取,该格式由readStream构建器的.format(<format_name>)方法提供,或者通过DataStreamReader的专用方法指定要使用的格式;例如,readStream.parquet('/path/to/dir/')。当使用每种支持格式对应的专用方法时,方法调用应作为构建器的最后一次调用。

例如,示例 10-1 中的三种形式是等效的。

示例 10-1. 构建文件流
// Use format and load path
val fileStream = spark.readStream
  .format("parquet")
  .schema(schema)
  .load("hdfs://data/exchange")

// Use format and path options
val fileStream = spark.readStream
  .format("parquet")
  .option("path", "hdfs://data/exchange")
  .schema(schema)
  .load()

// Use dedicated method
val fileStream = spark.readStream
  .schema(schema)
  .parquet("hdfs://data/exchange")

截至 Spark v2.3.0,结构化流支持以下基于文件的格式。这些是静态DataFrameDataset和 SQL API 支持的相同文件格式:

  • CSV

  • JSON

  • Parquet

  • ORC

  • 文本

  • textFile

常见选项

无论具体格式如何,文件来源的一般功能是监视由其特定 URL 标识的共享文件系统中的目录。所有文件格式都支持一组通用选项,这些选项控制文件流入并定义文件的老化标准。

警告

由于 Apache Spark 是一个快速发展的项目,API 及其选项可能会在未来版本中发生变化。此外,在本节中,我们仅涵盖适用于流处理工作负载的最相关选项。要获取最新信息,请始终查看与您的 Spark 版本对应的 API 文档

这些选项可以设置给所有基于文件的来源:

maxFilesPerTrigger(默认:未设置)

指示每个查询触发器将消耗多少文件。此设置限制每个触发器处理的文件数量,从而帮助控制系统中的数据流入。

latestFirst(默认:false

当设置此标志为true时,较新的文件优先用于处理。当最新数据的优先级高于旧数据时,请使用此选项。

maxFileAge(默认:7 days

目录中为文件定义了一个年龄阈值。比阈值更老的文件将不符合处理条件,将被有效地忽略。这个阈值是相对于目录中最近的文件而不是系统时钟的。例如,如果maxFileAge2 days,并且最近的文件是昨天的,那么考虑文件是否太旧的阈值将会是三天前之前的。这种动态类似于事件时间上的水印。

fileNameOnly(默认为false

当设置为true时,如果两个文件具有相同的名称,则它们将被视为相同;否则,将考虑完整路径。

注意

当设置latestFirsttrue并配置了maxFilesPerTrigger选项时,将忽略maxFileAge,因为可能会出现这样一种情况,即为了处理而变得比阈值更老的文件。在这种情况下,无法设置老化策略。

常见文本解析选项(CSV、JSON)

一些文件格式,如 CSV 和 JSON,使用可配置的解析器将每个文件中的文本数据转换为结构化记录。上游进程可能会创建不符合预期格式的记录,这些记录被视为损坏。

流式系统的特点是其持续运行。当接收到坏数据时,流处理过程不应该失败。根据业务需求,我们可以丢弃无效记录或将被视为损坏的数据路由到单独的错误处理流程。

处理解析错误

以下选项允许配置解析器的行为,以处理那些被视为损坏的记录:

mode(默认为PERMISSIVE

控制解析过程中处理损坏记录的方式。允许的值包括PERMISSIVEDROPMALFORMEDFAILFAST

  • PERMISSIVE:损坏记录的值将插入由选项columnNameOfCorruptRecord配置的特殊字段中,该字段必须存在于模式中。所有其他字段将设置为null。如果字段不存在,则记录将被丢弃(与DROPMALFORMED的行为相同)。

  • DROPMALFORMED:丢弃损坏的记录。

  • FAILFAST:发现损坏记录时会抛出异常。在流处理中不推荐使用此方法,因为异常的传播可能会导致流处理失败并停止。

columnNameOfCorruptRecord(默认为“_corrupt_record”)

允许配置包含损坏记录的字符串值的特殊字段。还可以通过设置 Spark 配置中的spark.sql.columnNameOfCorruptRecord来配置此字段。如果同时设置了spark.sql.columnNameOfCorruptRecord和此选项,则此选项优先。

模式推断

inferSchema(默认为false

不支持模式推断。设置此选项将被忽略。提供模式是强制性的。

日期和时间格式

dateFormat (默认:"yyyy-MM-dd")

配置用于解析 date 字段的模式。自定义模式遵循在java.text.SimpleDateFormat中定义的格式。

timestampFormat (默认:"yyyy-MM-dd'T'HH:mm:ss.SSSXXX")

配置用于解析 timestamp 字段的模式。自定义模式遵循在java.text.SimpleDateFormat中定义的格式。

JSON 文件源格式

文件源的 JSON 格式支持我们消耗以 JSON 编码的文本文件,其中文件中的每一行都被期望是一个有效的 JSON 对象。使用提供的模式解析 JSON 记录。不符合模式的记录被视为无效,有多个选项可用于控制无效记录的处理。

JSON 解析选项

默认情况下,JSON 文件源期望文件内容遵循JSON Lines 规范。即,文件中的每一行对应一个符合指定模式的有效 JSON 文档。每行应以换行符(\n)分隔。还支持 CRLF 字符(\r\n),因为会忽略尾随空白。

我们可以调整 JSON 解析器的容错性,以处理不完全符合标准的数据。还可以更改处理被视为损坏的记录的行为。以下选项允许配置解析器的行为:

allowComments (默认:"false")

启用后,允许文件中的 Java/C++ 风格的注释,并且将忽略相应的行;例如:

// Timestamps are in ISO 8601 compliant format
{"id":"x097abba", "timestamp": "2018-04-01T16:32:56+00:00"}
{"id":"x053ba0bab", "timestamp": "2018-04-01T16:35:02+00:00"}

否则,在 JSON 文件中的注释将被视为损坏记录,并根据 mode 设置进行处理。

allowNumericLeadingZeros (默认:"false")

启用后,数字中的前导零将被允许(例如,00314)。否则,前导零将被视为无效的数值,相应的记录将被视为损坏,并且会根据 mode 设置进行处理。

allowSingleQuotes (默认:"true")

允许使用单引号作为字段的标记。启用后,单引号和双引号都被允许。无论此设置如何,引号字符不能嵌套,并且在值中使用时必须适当地转义;例如:

// valid record
{"firstname":"Alice", 'lastname': 'Wonderland'}
// invalid nesting
{"firstname":'Elvis "The King"', 'lastname': 'Presley'}
// correct escaping
{"firstname":'Elvis \"The King\"', 'lastname': 'Presley'}

allowUnquotedFieldNames (默认:"false")

允许未引用的 JSON 字段名(例如,{firstname:"Alice"})。注意,当使用此选项时,字段名中不能包含空格(例如,{first name:"Alice"} 被视为损坏,即使字段名与模式匹配)。请谨慎使用。

multiLine (默认:"false")

启用后,解析器将不再解析 JSON Lines,而是将每个文件的内容视为单个有效的 JSON 文档,并尝试将其内容解析为遵循定义模式的记录。

当文件的生产者只能输出完整的 JSON 文档作为文件时,请使用此选项。在这种情况下,使用顶层数组来分组记录,如示例 10-2 所示。

示例 10-2. 使用顶层数组分组记录
[
  {"firstname":"Alice", "last name":"Wonderland", "age": 7},
  {"firstname":"Coraline", "last name":"Spin"   , "age":15}
]

primitivesAsString (默认 false)

当启用时,原始值类型被视为字符串。这允许您读取具有混合类型字段的文档,但所有值都将作为String读取。

在示例 10-3 中,生成的 age 字段的类型为 String,包含 “Coraline”age="15" 值和 “Diana”age="unknown" 值。

示例 10-3. primitivesAsString 的使用
{"firstname":"Coraline", "last name":"Spin", "age": 15}
{"firstname":"Diana", "last name":"Prince", "age": "unknown"}

CSV 文件源格式

CSV 是一种流行的表格数据存储和交换格式,被企业应用广泛支持。File Source CSV 格式支持允许我们在结构化流应用程序中摄取和处理这些应用程序的输出。尽管“CSV”原来的名称表示值是用逗号分隔的,但分隔符通常可以自由配置。有许多配置选项可用于控制将数据从纯文本转换为结构化记录的方式。

在本节的其余部分,我们涵盖了最常见的选项,特别是那些与流处理相关的选项。关于格式相关的选项,请参考最新文档。以下是最常用的 CSV 解析选项:

CSV 解析选项

comment (默认:“” [禁用])

配置用于标记为注释行的字符;例如,当使用 option("comment","#") 时,我们可以解析以下包含注释的 CSV:

#Timestamps are in ISO 8601 compliant format
x097abba, 2018-04-01T16:32:56+00:00, 55
x053ba0bab, 2018-04-01T16:35:02+00:00, 32

header (默认:false)

需要提供模式,因此标题行将被忽略并且不起作用。

multiline (默认:false)

将每个文件视为一个跨越文件中所有行的记录。

quote (默认:" [双引号])

配置用于包围包含列分隔符的值的字符。

sep (默认:, [逗号])

配置用于分隔每行中字段的字符。

Parquet 文件源格式

Apache Parquet 是一种基于列的文件数据存储格式。内部表示将原始行拆分为列的块,并使用压缩技术进行存储。因此,需要特定列的查询无需读取完整文件,而是可以独立地访问和检索相关的片段。Parquet 支持复杂的嵌套数据结构,并保留数据的模式结构。由于其增强的查询功能、有效利用存储空间和保留模式信息,Parquet 是存储大型复杂数据集的流行格式。

模式定义

要从 Parquet 文件创建流源,只需提供数据的模式和目录位置。在流声明期间提供的模式对于流源定义的整个期间是固定的。

示例 10-4 展示了如何使用提供的模式从hdfs://data/folder文件夹创建基于 Parquet 的文件源。

示例 10-4. 构建 Parquet 源示例
// Use format and load path
val fileStream = spark.readStream
  .schema(schema)
  .parquet("hdfs://data/folder")

文本文件源格式

文件来源的文本格式支持纯文本文件的摄取。使用配置选项,可以逐行或整个文件作为单个文本块进行摄取。此来源产生的数据模式自然是StringType,无需指定。这是一个通用格式,我们可以用来摄取任意文本,从著名的词频统计到专有文本格式的自定义解析。

文本摄取选项

除了我们在 “常见选项” 中看到的文件源的常见选项之外,文本文件格式支持使用wholetext选项将文本文件作为整体读取:

wholetext(默认为 false

如果设置为 true,则将整个文件作为单个文本块读取。否则,使用标准的行分隔符(\n\r\n\r)将文本拆分成行,并将每行视为一个记录。

text 和 textFile

文本格式支持两种 API 替代方案:

text

返回一个动态类型的带有单个value字段的DataFrame,类型为StringType

textFile

返回一个静态类型的Dataset[String]

我们可以使用text格式规范作为终止方法调用或作为format选项。要获得静态类型的Dataset,必须将textFile作为流构建器调用的最后一步。示例 10-5 中的示例展示了具体的 API 使用方式。

示例 10-5. 文本格式 API 使用
// Text specified as format
>val fileStream = spark.readStream.format("text").load("hdfs://data/folder")
fileStream: org.apache.spark.sql.DataFrame = [value: string]

// Text specified through dedicated method
>val fileStream = spark.readStream.text("hdfs://data/folder")
fileStream: org.apache.spark.sql.DataFrame = [value: string]

// TextFile specified through dedicated method
val fileStream = spark.readStream.textFile("/tmp/data/stream")
fileStream: org.apache.spark.sql.Dataset[String] = [value: string]

Kafka 源

Apache Kafka 是基于分布式日志概念的发布/订阅(pub/sub)系统。Kafka 具有高度可扩展性,并且在消费者和生产者端处理数据时提供高吞吐量和低延迟的处理能力。在 Kafka 中,组织的单位是主题。发布者将数据发送到主题,订阅者从他们订阅的主题接收数据。这种数据交付是可靠的。Apache Kafka 已成为广泛应用于各种流处理用例的消息基础设施的热门选择。

Kafka 的结构化流源实现了订阅者角色,可以消费发布到一个或多个主题的数据。这是一个可靠的数据源。回顾我们在 “理解数据源” 中的讨论,这意味着即使部分或完全失败并重新启动流处理过程,数据交付语义也是有保证的。

设置 Kafka 源

要创建一个 Kafka 源,我们在 Spark 会话中使用 format("kafka") 方法和 createStream 构建器。连接到 Kafka 需要两个必需的参数:Kafka 代理的地址和我们想要连接的主题。

连接到的 Kafka 代理的地址通过选项 kafka.bootstrap.servers 提供,作为一个包含逗号分隔的 host:port 对列表的 String

示例 10-6 展示了一个简单的 Kafka 源定义。它通过连接位于 host1:port1host2:port2host3:port3 的代理订阅单个主题 topic1

示例 10-6. 创建一个 Kafka 源
>val kafkaStream = spark.readStream
  .format("kafka")
  .option("kafka.bootstrap.servers", "host1:port1,host2:port2,host3:port3")
  .option("subscribe", "topic1")
  .option("checkpointLocation", "hdfs://spark/streaming/checkpoint")
  .load()

kafkaStream: org.apache.spark.sql.DataFrame =
  [key: binary, value: binary ... 5 more fields]

>kafkaStream.printSchema

root
 |-- key: binary (nullable = true)
 |-- value: binary (nullable = true)
 |-- topic: string (nullable = true)
 |-- partition: integer (nullable = true)
 |-- offset: long (nullable = true)
 |-- timestamp: timestamp (nullable = true)
 |-- timestampType: integer (nullable = true)

>val dataStream = kafkaStream.selectExpr("CAST(key AS STRING)",
                                         "CAST(value AS STRING)")
                             .as[(String, String)]

dataStream: org.apache.spark.sql.Dataset[(String, String)] =
  [key: string, value: string]

该调用的结果是一个包含五个字段的 DataFramekeyvaluetopicpartitionoffsettimestamptimestampType。具有这些字段的模式固定了 Kafka 源。它提供了从 Kafka 获取的原始 keyvalues 以及每个消费记录的元数据。

通常,我们只对消息的 keyvalue 感兴趣。keyvalue 都包含一个二进制负载,在内部表示为 Byte Array。当使用 String 序列化器将数据写入 Kafka 时,我们可以通过将值强制转换为 String 来读取数据,如示例中的最后一个表达式所示。尽管基于文本的编码是一种常见做法,但这不是交换数据的最节省空间的方式。其他编码,如具有模式感知的 AVRO 格式,可能提供更好的空间效率,并附带嵌入模式信息的好处。

消息中的附加元数据,例如 topicpartitionoffset,可以在更复杂的场景中使用。例如,topic 字段将包含生成记录的 主题,可以用作标签或分辨器,以防我们同时订阅多个主题。

选择一个主题订阅方法

有三种不同的方法来指定我们想要消费的主题或主题列表:

  • subscribe

  • subscribePattern

  • assign

Kafka 源设置必须包含这些订阅选项中的一个且仅一个。它们提供不同的灵活性级别,以选择要订阅的主题和甚至分区:

subscribe

接受一个单一主题或逗号分隔的主题列表:topic1, topic2, ..., topicn。此方法订阅每个主题,并创建一个统一的流,包含所有主题的数据的并集;例如,.option("subscribe", "topic1,topic3")

subscribePattern

这类似于 subscribe 的行为,但主题使用正则表达式模式指定。例如,如果我们有主题 'factory1Sensors'、'factory2Sensors'、'street1Sensors'、'street2Sensors',我们可以通过表达式 .option("subscribePattern", "factory[\\d]+Sensors") 订阅所有“工厂”传感器。

assign

允许针对特定主题的特定分区进行精细化的指定以进行消费。在 Kafka API 中称为TopicPartition。通过 JSON 对象指定每个主题的分区,其中每个键是一个主题,其值是分区数组。例如,选项定义.option("assign", """{"sensors":[0,1,3]}""")将订阅主题sensors的分区 0、1 和 3。要使用此方法,我们需要关于主题分区的信息。我们可以通过 Kafka API 或配置获取分区信息。

配置 Kafka 源选项

结构化流处理中 Kafka 源有两类配置选项:专用源配置和直接传递给底层 Kafka 消费者的选项。

Kafka 源特定选项

下列选项配置了 Kafka 源的行为,特别是关于如何消费偏移量的配置:

startingOffsets(默认值:latest

可接受的值为earliestlatest或表示主题、它们的分区及给定偏移量关联的 JSON 对象。实际偏移量值始终为正数。有两个特殊的偏移量值:-2表示earliest-1表示latest;例如,""" {"topic1": { "0": -1, "1": -2, "2":1024 }} """

startingOffsets仅在首次启动查询时使用。所有后续重新启动将使用存储的checkpoint信息。要从特定偏移量重新启动流作业,需要删除checkpoint的内容。

failOnDataLoss(默认值:true

此标志指示在可能会丢失数据的情况下是否失败重新启动流查询。通常是当偏移量超出范围、主题被删除或主题重新平衡时。我们建议在开发/测试周期中将此选项设置为false,因为使用连续生产者停止/重新启动查询端往往会触发失败。在生产部署时将其设置回true

kafkaConsumer.pollTimeoutMs(默认值:512

在运行在 Spark 执行器上的分布式消费者从 Kafka 获取数据时,等待数据的轮询超时(毫秒)。

fetchOffset.numRetries(默认值:3

获取 Kafka 偏移量失败之前的重试次数。

fetchOffset.retryIntervalMs(默认值:10

偏移量获取重试之间的延迟(毫秒)。

maxOffsetsPerTrigger(默认值:未设置)

此选项允许我们为每个查询触发器设置总记录的速率限制。配置的限制将均匀分布在订阅主题的分区集合中。

Kafka 消费者选项

可以通过向配置键添加 'kafka.' 前缀来将配置选项传递到此源的底层 Kafka 消费者。

例如,要为 Kafka 源配置传输层安全性(TLS)选项,可以通过在源配置中设置 Kafka 消费者配置选项 security.protocol 来设置 kafka.security.protocol

示例 10-7 展示了如何使用此方法为 Kafka 源配置 TLS。

示例 10-7. Kafka 源 TLS 配置示例
val tlsKafkaSource = spark.readStream.format("kafka")
  .option("kafka.bootstrap.servers", "host1:port1, host2:port2")
  .option("subscribe", "topsecret")
  .option("kafka.security.protocol", "SSL")
  .option("kafka.ssl.truststore.location", "/path/to/truststore.jks")
  .option("kafka.ssl.truststore.password", "truststore-password")
  .option("kafka.ssl.keystore.location", "/path/to/keystore.jks")
  .option("kafka.ssl.keystore.password", "keystore-password")
  .option("kafka.ssl.key.password", "password")
  .load()
注意

要获取 Kafka 消费者配置选项的详尽列表,请参阅官方的 Kafka 文档

禁止的配置选项

并非所有标准消费者配置选项都可以使用,因为它们与源的内部处理过程冲突,该过程受我们在 “Kafka 源特定选项” 中看到的设置控制。

这些选项被禁止使用,如 表 10-1 所示。这意味着尝试使用任何这些选项都将导致 IllegalArgumentException

表 10-1. 禁止的 Kafka 选项

option 原因 备选方案
auto.offset.reset 在结构化流中管理偏移量 使用 startingOffsets 替代
enable.auto.commit 在结构化流中管理偏移量
group.id 每个查询内部管理唯一的组 ID
key.deserializer 负载始终表示为 Byte Array 可以通过编程方式将其反序列化为特定格式
value.deserializer 负载始终表示为 Byte Array 可以通过编程方式将其反序列化为特定格式
interceptor.classes 消费者拦截器可能会破坏内部数据表示

Socket 源

传输控制协议(TCP)是一种连接导向的协议,它使客户端和服务器之间能够进行双向通信。该协议支持互联网上许多高级通信协议,如 FTP、HTTP、MQTT 等。虽然应用层协议如 HTTP 在 TCP 连接之上添加了额外的语义,但许多应用程序仍通过 UNIX 套接字提供纯文本的 TCP 连接以传输数据。

Socket 源是一个 TCP 套接字客户端,能够连接到提供 UTF-8 编码的基于文本的数据流的 TCP 服务器。它使用提供的 hostport 作为必选的 options 连接到 TCP 服务器。

配置

要连接到 TCP 服务器,我们需要主机的地址和端口号。还可以配置 Socket 源以在接收到每行数据时添加时间戳。

这些是配置选项:

host(必选)

要连接的 TCP 服务器的 DNS 主机名或 IP 地址。

port(必选)

要连接的 TCP 服务器的端口号。

includeTimestamp(默认:false

当启用时,Socket 源会将到达时间戳添加到每行数据中。它还会更改此生成的模式,添加timestamp作为附加字段。

在示例 10-8 中,我们观察到此源提供的两种操作模式。通过hostport配置,生成的流DataFrame只有一个名为value的字段,类型为String。当我们将includeTimestamp标志设置为true时,生成的流DataFrame的模式包含字段valuetimestamp,其中value与之前相同为String类型,而timestampTimestamp类型。同时,请注意此源创建时打印的日志警告。

示例 10-8. Socket 源示例
// Only using host and port

>val stream = spark.readStream
  .format("socket")
  .option("host", "localhost")
  .option("port", 9876)
  .load()

18/04/14 17:02:34 WARN TextSocketSourceProvider:
The socket source should not be used for production applications!
It does not support recovery.

stream: org.apache.spark.sql.DataFrame = [value: string]

// With added timestamp information

val stream = spark.readStream
  .format("socket")
  .option("host", "localhost")
  .option("port", 9876)
  .option("includeTimestamp", true)
  .load()

18/04/14 17:06:47 WARN TextSocketSourceProvider:
The socket source should not be used for production applications!
It does not support recovery.

stream: org.apache.spark.sql.DataFrame = [value: string, timestamp: timestamp]

操作

Socket 源创建一个 TCP 客户端,连接到配置中指定的 TCP 服务器。此客户端在 Spark 驱动程序上运行。它将传入数据保存在内存中,直到查询消耗该数据并提交相应的偏移量。已提交偏移量的数据将被驱逐,在正常情况下保持内存使用稳定。

回顾“理解数据源”中的讨论,如果源能在失败和流程重启时重放未提交的偏移量,则认为该源是可靠的。但这个源不可靠,因为 Spark 驱动程序的失败将导致内存中的所有未提交数据丢失。

仅在对数据丢失可接受的情况下才应使用此源。

注意

一个常见的架构替代方案是,使用 Kafka 作为可靠的中间存储而不是直接使用 Socket 源连接到 TCP 服务器。可以使用强大的微服务来桥接 TCP 服务器和 Kafka。该微服务从 TCP 服务器收集数据,并以原子方式将其传送到 Kafka。然后,我们可以使用可靠的 Kafka 源来消费数据,并在结构化流中进一步处理。

Rate 源

Rate 源是一个内部流生成器,以可配置的频率生成一系列记录,单位为records/second。输出是一系列记录(timestamp, value),其中timestamp对应于记录生成时刻,而value是递增计数器:

> val stream = spark.readStream.format("rate").load()

stream: org.apache.spark.sql.DataFrame = [timestamp: timestamp, value: bigint]

此内容旨在用于基准测试和探索结构化流,因为它不依赖于外部系统的正常运行。正如前面的示例所示,这非常容易创建并完全自包含。

示例 10-9 中的代码创建了一个每秒 100 行的速率流,具有 60 秒的逐步增加时间。生成的DataFrame的模式包含两个字段:类型为Timestamptimestamp和模式级别为BigInt、内部表示为Longvalue

示例 10-9. Rate 源示例
> val stream = spark.readStream.format("rate")
  .option("rowsPerSecond", 100)
  .option("rampUpTime",60)
  .load()
stream: org.apache.spark.sql.DataFrame = [timestamp: timestamp, value: bigint]

选项

Rate source 支持几个选项,用于控制吞吐量和并行级别:

rowsPerSecond(默认:1)

每秒生成的行数。

rampUpTime(默认:0)

在流的开始阶段,记录的生成将逐渐增加,直到达到设定的时间。增长是线性的。

numPartitions(默认:默认的 Spark 并行级别)

要生成的分区数。更多的分区可以增加记录生成和下游查询处理的并行级别。

第十一章:结构化流处理 Sinks

在前一章中,您学习了 sources,这是结构化流处理用于获取处理数据的抽象。在处理完这些数据后,我们可能希望对其进行某些操作。我们可能希望将其写入数据库以供后续查询,写入文件以进行进一步的(批处理)处理,或者将其写入另一个流式后端以保持数据的流动。

在结构化流处理中,sinks 是表示如何将数据传输到外部系统的抽象概念。结构化流处理内置了几个数据源,并定义了一个 API,使我们能够创建自定义的 sinks 来传输数据到不原生支持的其他系统。

本章中,我们将研究 sinks 的工作原理,审查结构化流处理提供的 sinks 的详细信息,并探讨如何创建自定义 sinks 来将数据写入不受默认实现支持的系统。

理解 Sinks

Sinks 作为结构化流处理中内部数据表示与外部系统之间的输出适配器。它们为流处理生成的数据提供写入路径。此外,它们还必须关闭可靠数据传递的循环。

要参与端到端可靠的数据传递,sinks 必须提供幂等写入操作。幂等意味着多次执行操作的结果等同于仅执行一次操作。在从故障中恢复时,Spark 可能会重新处理在故障发生时部分处理的数据。在源的一侧,这是通过使用重放功能来完成的。回想一下“理解 Sources”,可靠的 sources 必须提供重新播放未提交数据的方法,基于给定的偏移量。同样,sinks 必须提供在将记录写入外部源之前删除重复记录的方法。

重播源和幂等 sink 的组合赋予了结构化流处理其 有效的仅一次 数据传递语义。无法实现幂等性要求的 sinks 将导致至多“至少一次”语义的端到端传递保证。无法从流处理过程的故障中恢复的 sinks 被视为“不可靠”,因为它们可能会丢失数据。

在下一节中,我们将详细介绍结构化流处理中可用的 sinks。

注意

第三方供应商可能会为其产品提供定制的结构化流处理 sinks。在将其中一个外部 sinks 集成到您的项目中时,请参考其文档以确定它们支持的数据传递保证。

可用的 Sinks

结构化流处理提供了几种输出,与支持的源相匹配,以及允许我们将数据输出到临时存储或控制台的输出。大致来说,我们可以将提供的输出分为可靠输出和学习/实验支持输出两类。此外,它还提供了一个可编程接口,允许我们与任意外部系统进行交互。

可靠输出

被视为可靠或适合生产的输出提供了明确定义的数据传输语义,并且对流处理过程的完全故障具有弹性。

提供的可靠输出如下:

文件输出

这将数据写入文件系统中的目录中的文件。它支持与文件源相同的文件格式:JSON、Parquet、逗号分隔值(CSV)和文本。

Kafka 输出

这将数据写入 Kafka,有效地保持数据“在移动”中。这是一个有趣的选择,可以将我们的处理结果与依赖 Kafka 作为数据主干的其他流处理框架集成。

用于实验的输出

提供以下输出以支持与结构化流处理的交互和实验。它们不提供故障恢复,因此在生产环境中使用这些输出是不鼓励的,因为可能导致数据丢失。

以下是非可靠输出:

内存输出

这将创建一个临时表,其中包含流查询的结果。生成的表可以在同一个 Java 虚拟机(JVM)进程内进行查询,从而允许集群内的查询访问流处理过程的结果。

控制台输出

这将查询的结果打印到控制台。在开发阶段,这对于直观地检查流处理结果非常有用。

输出 API

除了内置的输出,我们还可以选择以编程方式创建输出。这可以通过foreach操作实现,如其名称所示,它可以访问输出流的每个单独的结果记录。最后,可以直接使用sink API 开发自定义输出。

详细探讨输出

在本章的其余部分,我们探讨了每个输出的配置和可用选项。我们深入介绍了可靠输出,这应该提供了一个全面的应用视图,并且在你开始开发自己的应用程序时可以作为参考。

实验性输出在范围上有所限制,这也反映在后续覆盖的程度上。

在本章末尾,我们将查看自定义sink API 选项,并审查开发自己输出时需要考虑的事项。

提示

如果您处于结构化流处理的初探阶段,您可能希望跳过本节,并在忙于开发自己的结构化流处理作业时稍后再回来查看。

文件输出

文件是常见的系统边界。当作为流处理的接收端使用时,它们允许数据在流导向处理后变得静止。这些文件可以成为数据湖的一部分,也可以被其他(批处理)过程消耗,作为结合了流式和批处理模式的更大处理流水线的一部分。

可扩展、可靠和分布式的文件系统——如 HDFS 或像亚马逊简单存储服务(Amazon S3)这样的对象存储——使得可以将大型数据集以任意格式存储为文件。在本地模式运行时,可以在探索或开发时使用本地文件系统作为这个接收端。

文件接收端支持与文件源相同的格式:

  • CSV

  • JSON

  • Parquet

  • ORC

  • Text

注意

结构化流共享与批处理模式中使用的相同文件数据源实现。DataFrameWriter为每种文件格式提供的写入选项在流处理模式下也同样适用。在本节中,我们重点介绍最常用的选项。要获取最新的列表,请始终参考特定 Spark 版本的在线文档。

在深入探讨每种格式的细节之前,让我们先看一个通用的文件接收端示例,即示例 11-1 中的示例。

示例 11-1. 文件接收端示例
// assume an existing streaming dataframe df
val query = stream.writeStream
  .format("csv")
  .option("sep", "\t")
  .outputMode("append")
  .trigger(Trigger.ProcessingTime("30 seconds"))
  .option("path","<dest/path>")
  .option("checkpointLocation", "<checkpoint/path>")
  .start()

在这个例子中,我们使用csv格式将流结果写入到<dest/path>目标目录,使用TAB作为自定义分隔符。我们还指定了一个checkpointLocation,用于定期存储检查点元数据。

文件接收端仅支持append作为outputMode,并且在writeStream声明中可以安全地省略。尝试使用其他模式将导致查询启动时出现以下异常:org.apache.spark.sql.AnalysisException: Data source ${format} does not support ${output_mode} output mode;

使用文件接收端的触发器

我们在示例 11-1 中看到的另一个参数是使用trigger。当没有指定触发器时,结构化流会在上一个批次完成后立即启动新批次的处理。对于文件接收端,根据输入流的吞吐量,这可能会导致生成许多小文件。这可能对文件系统的存储能力和性能有害。

考虑示例 11-2。

示例 11-2. 使用文件接收端的速率源
val stream = spark.readStream.format("rate").load()
val query = stream.writeStream
  .format("json")
  .option("path","/tmp/data/rate")
  .option("checkpointLocation", "/tmp/data/rate/checkpoint")
  .start()

如果让此查询运行一段时间,然后检查目标目录,应该会观察到大量小文件:

$ ls -1
part-00000-03a1ed33-3203-4c54-b3b5-dc52646311b2-c000.json
part-00000-03be34a1-f69a-4789-ad65-da351c9b2d49-c000.json
part-00000-03d296dd-c5f2-4945-98a8-993e3c67c1ad-c000.json
part-00000-0645a678-b2e5-4514-a782-b8364fb150a6-c000.json
...

# Count the files in the directory
$ ls -1 | wc -l
562

# A moment later
$ ls -1 | wc -l
608

# What's the content of a file?
$ cat part-00007-e74a5f4c-5e04-47e2-86f7-c9194c5e85fa-c000.json
{"timestamp":"2018-05-13T19:34:45.170+02:00","value":104}

正如我们在第十章中学到的,速率源默认每秒生成一条记录。当我们查看一个文件中包含的数据时,确实可以看到单个记录。事实上,查询每次新数据可用时生成一个文件。尽管该文件的内容不多,文件系统在跟踪文件数方面会有一些开销。在 Hadoop 分布式文件系统(HDFS)中,每个文件无论内容如何都会占用一个块,并复制n次。考虑到典型的 HDFS 块大小为 128 MB,我们可以看到我们使用文件汇集器的简单查询可能会迅速耗尽存储空间。

trigger配置是为了帮助我们避免这种情况。通过为文件生成提供时间触发器,我们可以确保每个文件中有足够的数据量。

我们可以通过修改前面的示例来观察时间trigger的效果如下:

import org.apache.spark.sql.streaming.Trigger

val stream = spark.readStream.format("rate").load()
val query = stream.writeStream
  .format("json")
  .trigger(Trigger.ProcessingTime("1 minute")) // <-- Add Trigger configuration
  .option("path","/tmp/data/rate")
  .option("checkpointLocation", "/tmp/data/rate/checkpoint")
  .start()

让我们发出查询,并等待几分钟。当我们检查目标目录时,应该比以前少很多文件,并且每个文件应该包含更多的记录。每个文件中的记录数取决于DataFrame的分区:

$ ls -1
part-00000-2ffc26f9-bd43-42f3-93a7-29db2ffb93f3-c000.json
part-00000-3cc02262-801b-42ed-b47e-1bb48c78185e-c000.json
part-00000-a7e8b037-6f21-4645-9338-fc8cf1580eff-c000.json
part-00000-ca984a73-5387-49fe-a864-bd85e502fd0d-c000.json
...

# Count the files in the directory
$ ls -1 | wc -l
34

# Some seconds later
$ ls -1 | wc -l
42

# What's the content of a file?

$ cat part-00000-ca984a73-5387-49fe-a864-bd85e502fd0d-c000.json
{"timestamp":"2018-05-13T22:02:59.275+02:00","value":94}
{"timestamp":"2018-05-13T22:03:00.275+02:00","value":95}
{"timestamp":"2018-05-13T22:03:01.275+02:00","value":96}
{"timestamp":"2018-05-13T22:03:02.275+02:00","value":97}
{"timestamp":"2018-05-13T22:03:03.275+02:00","value":98}
{"timestamp":"2018-05-13T22:03:04.275+02:00","value":99}
{"timestamp":"2018-05-13T22:03:05.275+02:00","value":100}

如果您在个人计算机上尝试此示例,则分区数默认为当前核心数。在我们的情况下,我们有八个核心,并且我们观察到每个分区有七到八条记录。虽然这仍然是非常少的记录,但它显示了可以推广到实际场景的原理。

尽管在这种情况下基于记录数或数据大小的trigger可能更有趣,但目前仅支持基于时间的触发器。随着结构化流的发展,这可能会发生变化。

所有支持的文件格式的常见配置选项

在之前的示例中,我们已经看到了使用方法option来设置汇合器中的配置选项的用法。

所有支持的文件格式共享以下配置选项:

path

流查询将数据文件写入目标文件系统中的目录。

checkpointLocation

可靠文件系统中存储检查点元数据的目录。每次查询执行间隔都会写入新的检查点信息。

compression(默认值:无)

所有支持的文件格式都可以压缩数据,尽管可用的压缩codecs可能因格式而异。每种格式的具体压缩算法在其相应部分中显示。

注意

在配置文件汇总选项时,通常有必要记住,文件汇总写入的任何文件都可以使用相应的文件源读取。例如,当我们讨论 “JSON 文件源格式” 时,我们看到它通常期望文件的每一行是一个有效的 JSON 文档。同样,JSON 汇总格式将生成每行一个记录的文件。

常见的时间和日期格式化(CSV、JSON)

文本文件格式,如 CSV 和 JSON,接受日期和时间戳数据类型的自定义格式化:

dateFormat(默认:yyyy-MM-dd

配置用于格式化date字段的模式。自定义模式遵循 java.text.SimpleDateFormat 中定义的格式。

timestampFormat(默认:“yyyy-MM-dd’T’HH:mm:ss.SSSXXX”)

配置用于格式化timestamp字段的模式。自定义模式遵循 java.text.SimpleDateFormat 中定义的格式。

timeZone(默认:本地时区)

配置用于格式化时间戳的时区。

文件汇总的 CSV 格式

使用 CSV 文件格式,我们可以以广泛支持的表格格式编写数据,可以被许多程序读取,从电子表格应用程序到各种企业软件。

选项

在 Spark 中,CSV 支持多种选项来控制字段分隔符、引用行为和包含头部信息。此外,通用的文件汇总选项和日期格式化选项也适用于 CSV 汇总。

注意

在本节中,我们列出了最常用的选项。详细列表,请查看 在线文档

以下是 CSV 汇总的常用选项:

header(默认:false

一个标志,用于指示是否应将头部包含在生成的文件中。头部包含此流式DataFrame中字段的名称。

quote(默认:" [双引号])

设置用于引用记录的字符。引用是必要的,当记录可能包含分隔符字符时,如果没有引用,将导致记录损坏。

quoteAll(默认:false

用于指示是否应引用所有值或仅包含分隔符字符的标志。一些外部系统要求引用所有值。当使用 CSV 格式将生成的文件导入外部系统时,请检查该系统的导入要求以正确配置此选项。

sep(默认:, [逗号])

配置用于字段之间的分隔符字符。分隔符必须是单个字符。否则,查询在运行时启动时会抛出IllegalArgumentException

JSON 文件汇总格式

JSON 文件汇允许我们使用 JSON Lines 格式将输出数据写入文件。该格式将输出数据集中的每个记录转换为一个有效的 JSON 文档,并写入一行文本中。JSON 文件汇对称地与 JSON 源相对应。正如我们预期的那样,使用此格式编写的文件可以通过 JSON 源再次读取。

注意

当使用第三方 JSON 库读取生成的文件时,我们应该先将文件读取为文本行,然后将每行解析为表示一个记录的 JSON 文档。

选项

除了通用文件和文本格式选项之外,JSON 汇还支持这些特定的配置:

encoding(默认:UTF-8

配置用于编写 JSON 文件的字符集编码。

lineSep(默认:\n

设置要在 JSON 记录之间使用的行分隔符。

支持的compression选项(默认:none):nonebzip2deflategziplz4snappy

Parquet 文件汇格式

Parquet 文件汇支持常见的文件汇配置,不具有特定于格式的选项。

支持的compression选项(默认:snappy):nonegziplzosnappy

文本文件汇格式

文本文件汇写入纯文本文件。尽管其他文件格式会将流DataFrameDataset模式转换为特定的文件格式结构,文本汇期望的是一个展平的流Dataset[String]或带有单个value字段的流DataFrame,其类型为StringType

文本文件格式的典型用法是编写在 Structured Streaming 中原生不支持的自定义基于文本的格式。为了实现这一目标,我们首先通过编程方式将数据转换为所需的文本表示形式。然后,我们使用文本格式将数据写入文件。试图将任何复杂模式写入文本汇将导致错误。

选项

除了汇和基于文本的格式的通用选项之外,文本汇支持以下配置选项:

lineSep(默认:\n

配置用于终止每个文本行的行分隔符。

Kafka 汇

正如我们在“Kafka Source”中讨论的那样,Kafka 是一个发布/订阅(pub/sub)系统。虽然 Kafka 源充当订阅者,但 Kafka 汇是发布者的对应物。Kafka 汇允许我们将数据写入 Kafka,然后其他订阅者可以消费这些数据,从而继续一系列流处理器的链条。

下游消费者可能是其他流处理器,使用 Structured Streaming 或任何其他可用的流处理框架实现,或者是(微)服务,用于消费企业生态系统中的流数据来支持应用程序。

理解 Kafka 发布模型

在 Kafka 中,数据表示为通过主题交换的键-值记录。主题由分布式分区组成。每个分区按接收顺序维护消息。此顺序由偏移量索引,消费者根据偏移量指示要读取的记录。当将记录发布到主题时,它被放置在主题的一个分区中。分区的选择取决于键。支配原则是具有相同键的记录将落在同一个分区中。因此,Kafka 中的排序是部分的。来自单个分区的记录序列将按到达时间顺序排序,但在分区之间没有排序保证。

这个模型具有高度的可伸缩性,Kafka 的实现确保低延迟的读写,使其成为流数据的优秀载体。

使用 Kafka Sink

现在你已经了解了 Kafka 发布模型,我们可以看看如何实际地将数据生产到 Kafka。我们刚刚看到 Kafka 记录结构化为键-值对。我们需要以相同的形式结构化我们的数据。

在最小实现中,我们必须确保我们的流DataFrameDataset具有BinaryTypeStringTypevalue字段。这个要求的含义是,通常我们需要将数据编码成传输表示形式,然后再发送到 Kafka。

当未指定键时,结构化流将用null替换key。这使得 Kafka sink 使用循环分配来分配对应主题的分区。

如果我们想要保留对键分配的控制权,我们必须有一个key字段,也是BinaryTypeStringType。这个key用于分区分配,从而确保具有相等键的记录之间的有序性。

可选地,我们可以通过添加一个topic字段来控制记录级别的目标主题。如果存在,topic的值必须对应于 Kafka 主题。在writeStream选项上设置topic会覆盖topic字段中的值。

相关记录将被发布到该主题。这个选项在实现分流模式时非常有用,其中传入的记录被分类到不同的专用主题,以供后续处理消费。例如,将传入的支持票据分类到专门的销售、技术和故障排除主题中,这些主题由相应的(微)服务在下游消费。

在我们把数据整理成正确的形状之后,我们还需要目标引导服务器的地址,以便连接到代理。

在实际操作中,这通常涉及两个步骤:

  1. 将每个记录转换为名为value的单个字段,并可选择为每个记录分配一个键和一个主题。

  2. 使用writeStream构建器声明我们的流目标。

示例 11-3 展示了这些步骤的使用。

示例 11-3. Kafka sink 示例
// Assume an existing streaming dataframe 'sensorData'
// with schema: id: String, timestamp: Long, sensorType: String, value: Double

// Create a key and a value from each record:

val kafkaFormattedStream = sensorData.select(
  $"id" as "key",
  to_json(
    struct($"id", $"timestamp", $"sensorType", $"value")
  ) as "value"
)

// In step two, we declare our streaming query:

val kafkaWriterQuery = kafkaFormat.writeStream
  .queryName("kafkaWriter")
  .outputMode("append")
  .format("kafka") // determines that the kafka sink is used
  .option("kafka.bootstrap.servers", kafkaBootstrapServer)
  .option("topic", targetTopic)
  .option("checkpointLocation", "/path/checkpoint")
  .option("failOnDataLoss", "false") // use this option when testing
  .start()

当我们在记录级别添加topic信息时,必须省略topic配置选项。

在 Example 11-4 中,我们修改了先前的代码,将每个记录写入与sensorType匹配的专用主题。即所有humidity记录进入 humidity 主题,所有radiation记录进入 radiation 主题,依此类推。

Example 11-4. 将 Kafka 接收端写入不同主题
// assume an existing streaming dataframe 'sensorData'
// with schema: id: String, timestamp: Long, sensorType: String, value: Double

// Create a key, value and topic from each record:

val kafkaFormattedStream = sensorData.select(
  $"id" as "key",
  $"sensorType" as "topic",
  to_json(struct($"id", $"timestamp", $"value")) as "value"
)

// In step two, we declare our streaming query:

val kafkaWriterQuery = kafkaFormat.writeStream
  .queryName("kafkaWriter")
  .outputMode("append")
  .format("kafka") // determines that the kafka sink is used
  .option("kafka.bootstrap.servers", kafkaBootstrapServer)
  .option("checkpointLocation", "/path/checkpoint")
  .option("failOnDataLoss", "false") // use this option when testing
  .start()

请注意,我们已经移除了设置option("topic", targetTopic),并且为每个记录添加了一个topic字段。这导致每个记录被路由到与其sensorType对应的主题。如果我们保留设置option("topic", targetTopic),那么topic字段的值将不会起作用。option("topic", targetTopic)设置优先级更高。

选择编码方式

当我们仔细查看 Example 11-3 中的代码时,我们会看到我们通过将现有数据转换为其 JSON 表示来创建一个单一的value字段。在 Kafka 中,每个记录包含一个键和一个值。value字段包含记录的有效负载。为了向 Kafka 发送或接收任意复杂的记录,我们需要将该记录转换为一个单字段表示,以便将其放入value字段中。在结构化流中,必须通过用户代码完成从此传输值表示到实际记录的转换。理想情况下,我们选择的编码可以轻松转换为结构化记录,以利用 Spark 处理数据的能力。

常见的编码格式是 JSON。JSON 在 Spark 的结构化 API 中有原生支持,这一支持也扩展到了结构化流。正如我们在 Example 11-4 中看到的,我们通过使用 SQL 函数to_json来编写 JSON:to_json(struct($"id", $"timestamp", $"value")) as "value")

二进制表示例如 AVRO 和 ProtoBuffers 也是可能的。在这种情况下,我们将value字段视为BinaryType,并使用第三方库进行编码/解码。

我们撰写本文时,尚未内置对二进制编码的支持,但已宣布将在即将推出的版本中支持 AVRO。

警告

在选择编码格式时要考虑的一个重要因素是模式支持。在使用 Kafka 作为通信骨干的多服务模型中,通常会发现产生数据的服务使用与流处理器或其他消费者不同的编程模型、语言和/或框架。

为了确保互操作性,面向模式的编码是首选。具有模式定义允许在不同语言中创建工件,并确保生产的数据可以在后续被消费。

存储器接收端

Memory sink 是一个不可靠的输出端,将流处理的结果保存在内存中的临时表中。之所以被认为是不可靠的,是因为在流处理结束时将丢失所有数据,但在需要对流处理结果进行低延迟访问的场景中,它肯定是非常有用的。

由此输出端创建的临时表以查询名称命名。该表由流式查询支持,并将根据所选outputMode语义在每个触发器后更新。

结果表包含查询结果的最新视图,并可以使用经典的 Spark SQL 操作进行查询。查询必须在启动结构化流查询的同一进程(JVM)中执行。

由 Memory Sink 维护的表可以通过交互方式访问。这使得它成为与 Spark REPL 或笔记本等交互式数据探索工具理想的接口。

另一个常见用途是在流数据的顶部提供查询服务。这是通过将服务器模块(如 HTTP 服务器)与 Spark 驱动程序组合来完成的。然后,可以通过特定的 HTTP 端点调用来提供来自此内存表的数据。

示例 11-5 假设一个 sensorData 流数据集。流处理的结果被实例化到这个内存表中,该表在 SQL 上下文中可用作 sample_memory_query

示例 11-5. Memory sink example
val sampleMemoryQuery = sensorData.writeStream
  .queryName("sample_memory_query")    // this query name will be the SQL table name
  .outputMode("append")
  .format("memory")
  .start()

// After the query starts we can access the data in the temp table
val memData = session.sql("select * from sample_memory_query")
memData.count() // show how many elements we have in our table

输出模式

Memory sink 支持所有输出模式:AppendUpdateComplete。因此,我们可以将其与所有查询一起使用,包括聚合查询。Memory sink 与 Complete 模式的结合特别有趣,因为它提供了一个快速的、内存中可查询的存储,用于最新计算的完整状态。请注意,要支持 Complete 状态的查询,必须对有界基数键进行聚合,以确保处理状态的内存需求也在系统资源范围内受限。

控制台输出端(Console Sink)

对于所有喜欢在屏幕上输出“Hello, world!”的人,我们有控制台输出端。确实,控制台输出端允许我们将查询结果的一个小样本打印到标准输出。

它的使用仅限于交互式基于 shell 的环境中的调试和数据探索,比如spark-shell。正如我们预期的那样,这个输出端在没有将任何数据提交到另一个系统的情况下是不可靠的。

在生产环境中应避免使用控制台输出(Console sink),就像println在操作代码库中不被看好一样。

选项

下面是控制台输出端(Console sink)的可配置选项:

numRows(默认值:20

每个查询触发器显示的最大行数。

truncate(默认值:true

每一行单元格的输出是否应该被截断,有一个标志用来指示。

输出模式

从 Spark 2.3 开始,控制台输出端支持所有输出模式:AppendUpdateComplete

Foreach Sink

有时我们需要将流处理应用程序与企业中的遗留系统集成。此外,作为一个年轻的项目,结构化流中可用接收器的范围相当有限。

foreach接收器包括一个 API 和接收器定义,提供对查询执行结果的访问。它将结构化流的写入能力扩展到任何提供 Java 虚拟机(JVM)客户端库的外部系统。

ForeachWriter 接口

要使用Foreach接收器,我们必须提供ForeachWriter接口的实现。ForeachWriter控制写入操作的生命周期。其执行在执行器上分布,并且方法将针对流DataFrameDataset的每个分区调用,如示例 11-6 所示。

示例 11-6. ForeachWriter 的 API 定义
abstract class ForeachWriter[T] extends Serializable {

  def open(partitionId: Long, version: Long): Boolean

  def process(value: T): Unit

  def close(errorOrNull: Throwable): Unit

}

如我们在示例 11-6 中所见,ForeachWriter与流Dataset的类型[T]或在流DataFrame的情况下对应于spark.sql.Row绑定。其 API 包括三个方法:openprocessclose

open

每个触发间隔都会调用此方法,带有partitionId和唯一的version号。使用这两个参数,ForeachWriter必须决定是否处理所提供的分区。返回true将导致使用process方法中的逻辑处理每个元素。如果方法返回false,则跳过该分区的处理。

process

这提供对数据的访问,每次一个元素。应用于数据的函数必须产生副作用,比如将记录插入数据库,调用 REST API,或者使用网络库将数据通信到另一个系统。

close

该方法用于通知写入分区的结束。当此分区的输出操作成功终止时,error对象将为 null;否则,将包含一个Throwable。即使open返回false(表示不应处理该分区),也会在每个分区写入操作结束时调用close

此合同是数据传递语义的一部分,因为它允许我们移除可能已经被发送到接收器但由于结构化流的恢复场景重新处理的重复分区。为了使该机制正常工作,接收器必须实现某种持久化方式来记住已经看到的partition/version组合。

在实现了我们的ForeachWriter之后,我们使用惯用的writeStream方法声明一个接收器,并调用带有ForeachWriter实例的专用foreach方法。

ForeachWriter的实现必须是Serializable。这是强制性的,因为ForeachWriter在处理流式DatasetDataFrame的每个分区的每个节点上分布执行。在运行时,将为DatasetDataFrame的每个分区创建一个新的反序列化的ForeachWriter实例的副本。因此,我们可能不会在ForeachWriter的初始构造函数中传递任何状态。

让我们将所有这些放在一个小示例中,展示 Foreach 接收器的工作方式,并说明处理状态处理和序列化要求的微妙复杂性。

TCP Writer Sink: A Practical ForeachWriter Example

在这个示例中,我们将开发一个基于文本的 TCP 接收器,将查询结果传输到外部 TCP 套接字接收服务器。在这个示例中,我们将使用与 Spark 安装一起提供的spark-shell实用工具。

在示例 11-7 中,我们创建了一个简单的 TCP 客户端,可以连接并向服务器套接字写入文本,只要提供其hostport。请注意,这个类不是SerializableSocket本质上是不可序列化的,因为它们依赖于底层系统的 I/O 端口。

示例 11-7. TCP 套接字客户端
class TCPWriter(host:String, port: Int) {
  import java.io.PrintWriter
  import java.net.Socket
  val socket = new Socket(host, port)
  val printer = new PrintWriter(socket.getOutputStream, true)
  def println(str: String) = printer.println(str)
  def close() = {
    printer.flush()
    printer.close()
    socket.close()
  }
}

接下来,在示例 11-8 中,我们将在ForeachWriter实现中使用这个TCPWriter

示例 11-8. TCPForeachWriter 实现
import org.apache.spark.sql.ForeachWriter
class TCPForeachWriter(host: String, port: Int)
    extends ForeachWriter[RateTick] {

  @transient var writer: TCPWriter = _
  var localPartition: Long = 0
  var localVersion: Long = 0

  override def open(
      partitionId: Long,
      version: Long
    ): Boolean = {
    writer = new TCPWriter(host, port)
    localPartition = partitionId
    localVersion = version
    println(
      s"Writing partition [$partitionId] and version[$version]"
    )
    true // we always accept to write
  }

  override def process(value: RateTick): Unit = {
    val tickString = s"${v.timestamp}, ${v.value}"
    writer.println(
      s"$localPartition, $localVersion, $tickString"
    )
  }

  override def close(errorOrNull: Throwable): Unit = {
    if (errorOrNull == null) {
      println(
        s"Closing partition [$localPartition] and version[$localVersion]"
      )
      writer.close()
    } else {
      print("Query failed with: " + errorOrNull)
    }
  }
}

注意我们如何声明TCPWriter变量:@transient var writer:TCPWriter = _@transient表示这个引用不应该被序列化。初始值为null(使用空变量初始化语法_)。只有在调用open时,我们才创建TCPWriter的实例,并将其分配给我们的变量以供稍后使用。

还要注意process方法如何接受RateTick类型的对象。当我们有一个类型化的Dataset时,实现ForeachWriter会更容易,因为我们处理特定的对象结构,而不是流式 DataFrame的通用数据容器spark.sql.Row。在这种情况下,我们将初始流式DataFrame转换为类型化的Dataset[RateTick],然后继续到接收器阶段。

现在,为了完成我们的示例,我们创建一个简单的Rate数据源,并将产生的流直接写入我们新开发的TCPForeachWriter

case class RateTick(timestamp: Long, value: Long)

val stream = spark.readStream.format("rate")
                  .option("rowsPerSecond", 100)
                  .load()
                  .as[RateTick]

val writerInstance = new TCPForeachWriter("localhost", 9876)

val query = stream
      .writeStream
      .foreach(writerInstance)
      .outputMode("append")

在开始我们的查询之前,我们运行一个简单的 TCP 服务器来观察结果。为此,我们使用nc,这是一个在命令行中创建 TCP/UDP 客户端和服务器的有用*nix 命令。在这种情况下,我们使用监听端口9876的 TCP 服务器:

# Tip: the syntax of netcat is system-specific.
# The following command should work on *nix and on OSX nc managed by homebrew.
# Check your system documentation for the proper syntax.
nc -lk 9876

最后,我们开始我们的查询:

val queryExecution = query.start()

在运行nc命令的 shell 中,我们应该看到类似以下的输出:

5, 1, 1528043018, 72
5, 1, 1528043018, 73
5, 1, 1528043018, 74
0, 1, 1528043018, 0
0, 1, 1528043018, 1
0, 1, 1528043018, 2
0, 1, 1528043018, 3
0, 1, 1528043018, 4
0, 1, 1528043018, 5
0, 1, 1528043018, 6
0, 1, 1528043018, 7
0, 1, 1528043018, 8
0, 1, 1528043018, 9
0, 1, 1528043018, 10
0, 1, 1528043018, 11
7, 1, 1528043019, 87
7, 1, 1528043019, 88
7, 1, 1528043019, 89
7, 1, 1528043019, 90
7, 1, 1528043019, 91
7, 1, 1528043019, 92

在输出中,第一列是partition,第二列是version,后面是Rate源产生的数据。有趣的是,数据在分区内是有序的,比如我们的示例中的partition 0,但在不同分区之间没有排序保证。分区在集群的不同机器上并行处理。没有保证哪一个先到达。

最后,为了结束查询执行,我们调用stop方法:

queryExecution.stop()

这个例子的教训

在这个例子中,您已经看到了如何正确使用一个简约的socket客户端来输出流查询的数据,使用 Foreach sink。Socket 通信是大多数数据库驱动程序和许多其他应用程序客户端的底层交互机制。我们在这里展示的方法是一个常见的模式,您可以有效地应用它来写入各种提供基于 JVM 的客户端库的外部系统。简而言之,我们可以总结这个模式如下:

  1. ForeachWriter的主体中,创建一个@transient可变引用到我们的驱动类。

  2. open方法中,初始化到外部系统的连接。将此连接分配给可变引用。保证此引用将被单个线程使用。

  3. process中,将提供的数据元素发布到外部系统。

  4. 最后,在close中,我们终止所有连接并清理任何状态。

故障排除ForeachWriter序列化问题

在示例 11-8 中,我们看到我们需要一个未初始化的可变引用到TCPWriter@transient var writer:TCPWriter = _。这种看似复杂的结构是必要的,以确保我们只在ForeachWriter已经反序列化并在远程执行器上运行时实例化非可序列化类。

如果我们想探索在ForeachWriter实现中尝试包含非可序列化引用时发生的情况,我们可以像这样声明我们的TCPWriter实例:

import org.apache.spark.sql.ForeachWriter
class TCPForeachWriter(host: String, port: Int) extends ForeachWriter[RateTick] {

  val nonSerializableWriter:TCPWriter = new TCPWriter(host,port)
  // ... same code as before ...
}

尽管这看起来更简单、更熟悉,但当我们尝试使用此ForeachWriter实现运行查询时,我们会得到org.apache.spark.SparkException: Task not serializable。这会产生一个非常长的堆栈跟踪,其中包含对冒犯类的最佳努力指出。我们必须跟随堆栈跟踪,直到找到Caused by语句,如以下跟踪中所示:

Caused by: java.io.NotSerializableException: $line17.$read$$iw$$iw$TCPWriter
Serialization stack:
  - object not serializable (class: $line17.$read$$iw$$iw$TCPWriter,
      value: $line17.$read$$iw$$iw$TCPWriter@4f44d3e0)
  - field (class: $line20.$read$$iw$$iw$TCPForeachWriter,
      name: nonSerializableWriter, type: class $line17.$read$$iw$$iw$TCPWriter)
  - object (class $line20.$read$$iw$$iw$TCPForeachWriter,
      $line20.$read$$iw$$iw$TCPForeachWriter@54832ad9)
  - field (class: org.apache.spark.sql.execution.streaming.ForeachSink, name:
      org$apache$spark$sql$execution$streaming$ForeachSink$$writer,
      type: class org.apache.spark.sql.ForeachWriter)

正如本例在spark-shell中运行,我们发现一些奇怪的$$表示法,但当我们移除这些噪音时,我们可以看到非可序列化对象是object not serializable (class: TCPWriter),其引用是字段field name: nonSerializableWriter, type: class TCPWriter

ForeachWriter 实现中常见的序列化问题。希望通过本节的技巧,您能够在自己的实现中避免任何麻烦。但是,如果出现这种情况,Spark 将尽最大努力确定问题的源头。提供在堆栈跟踪中的这些信息对于调试和解决这些序列化问题非常有价值。

第十二章:基于事件时间的流处理

在 “时间的影响” 中,我们从一般角度讨论了流处理中时间的影响。

正如我们回忆的那样,事件时间处理 指的是从产生时的时间轴观察事件流,并从这个视角应用处理逻辑。当我们有兴趣分析事件数据随时间变化的模式时,有必要像在事件产生时观察它们一样处理事件。为此,我们需要设备或系统在事件产生时为其“盖上时间戳”。因此,特定事件绑定时间通常称为“时间戳”。我们使用这个时间作为我们的时间演变参考框架。

为了说明这个概念,让我们探讨一个熟悉的例子。考虑一个用于监控本地天气状况的天气站网络。一些远程站点通过移动网络连接,而其他一些站点则托管在志愿者家中,其可访问的互联网连接质量不同。天气监测系统不能依赖事件的到达顺序,因为这种顺序主要依赖于它们连接到的网络的速度和可靠性。相反,天气应用依赖于每个天气站为传递的事件标记时间戳。我们的流处理然后使用这些时间戳来计算基于时间的聚合,供天气预报系统使用。

流处理引擎使用事件时间的能力很重要,因为我们通常关注事件产生的相对顺序,而不是事件处理的顺序。在本章中,我们将了解结构化流处理如何无缝支持事件时间处理。

理解结构化流处理中的事件时间

在服务器端,时间的概念由运行任何给定应用程序的计算机的内部时钟控制。对于在机群上运行的分布式应用程序,使用诸如网络时间协议(NTP)等时钟同步技术和协议是强制性的做法,以将所有时钟调整到同一时间。其目的是,运行在一群计算机集群上的分布式应用程序的不同部分可以对事件的时间轴和相对顺序做出一致的决策。

然而,当数据来自外部设备,例如传感器网络、其他数据中心、手机或连接的汽车等,我们无法保证它们的时钟与我们集群中的机器对齐。我们需要从产生系统的角度解释传入事件的时间轴,而不是参考处理系统的内部时钟。图 12-1 描绘了这种场景。

spas 1201

图 12-1. 内部事件时间线

在图 12-1 中,我们展示了结构化流处理中时间处理的方式:

  • 在 x 轴上,我们有处理时间,即处理系统的时钟时间。

  • y 轴表示事件时间轴的内部表示。

  • 事件以圆圈表示,并且其对应的事件时间标签显示在旁边。

  • 事件到达时间对应于 x 轴上的时间。

随着事件进入系统,我们内部的时间概念也在不断前进:

  1. 第一个事件00:0800:07进入系统,“早”于机器时钟的视角。我们可以理解,内部时钟时间不影响我们对事件时间轴的感知。

  2. 事件时间轴前进到00:08

  3. 下一批事件,00:1000:1200:18到达进行处理。事件时间轴提升到00:18,因为这是到目前为止观察到的最大时间。

  4. 00:15进入系统。由于00:15早于当前内部时间的00:18,事件时间轴保持其当前值。

  5. 同样,接收到了00:1100:09。我们应该处理这些事件吗,还是已经太晚了?

  6. 处理下一组事件时,00:1400:2500:28,流式处理时钟增加到它们的最大值00:28

一般来说,结构化流处理通过保持在事件中声明为时间戳字段的单调递增上界来推断事件处理的时间轴。这个非线性时间轴是本章时间处理特性中使用的主要时钟。结构化流处理理解事件源时间流动的能力将事件生成与事件处理时间分离开来。例如,我们可以回放一周内的事件序列,并且结构化流处理能够为所有事件时间聚合产生正确的结果。如果时间由计算机时钟控制,这是不可能的。

使用事件时间

在结构化流处理中,我们可以利用内置的事件时间支持来进行两个方面的优化:基于时间的聚合和状态管理。

在这两种情况下,第一步都是确保我们的数据中有一个以正确格式存在的字段,以便结构化流处理能够将其理解为时间戳。

Spark SQL 支持java.sql.Timestamp作为Timestamp类型。对于其他基本类型,我们需要先将值转换为Timestamp,然后才能用于事件时间处理。在表 12-1 中,初始的ts字段包含给定类型的时间戳,并总结了如何获取相应的Timestamp类型。

表 12-1. 获取时间戳字段

ts基本类型 SQL 函数
Long $"ts".cast(TimestampType))
默认格式为yyyy-MM-dd HH:mm:ssString $"ts".cast(TimestampType)
使用默认格式yyyy-MM-dd HH:mm:ssString(备选) to_timestamp($"ts”)
使用自定义格式的String,例如dd-MM-yyyy HH:mm:ss to_timestamp($"ts", "dd-MM-yyyy HH:mm:ss")

处理时间

正如我们在本节介绍中讨论的那样,我们区分事件时间和处理时间处理之间的区别。事件时间指的是事件产生时的时间轴,与处理时间无关。相反,处理时间是事件流被处理引擎摄取时的时间轴,基于处理事件流的计算机时钟。这是事件进入处理引擎时的“现在”。

有些情况下,事件数据不包含时间信息,但我们仍然希望利用结构化流提供的本地基于时间的函数。在这些情况下,我们可以向事件数据添加处理时间时间戳,并将该时间戳用作事件时间。

继续使用相同的示例,我们可以使用current_timestamp SQL 函数添加处理时间信息:

// Lets assume an existing streaming dataframe of weather station readings
// (id: String, pressure: Double, temperature: Double)

// we add a processing-time timestamp
val timeStampEvents = raw.withColumn("timestamp", current_timestamp())

水印

在本章开头,我们学到外部因素可能影响事件消息的传递,因此,在使用事件时间进行处理时,我们无法保证顺序或传递。事件可能会延迟或根本不到达。延迟多久才算太晚?我们在考虑它们完整之前要保持部分聚合多长时间?为了回答这些问题,结构化流引入了水印的概念。水印是一个时间阈值,指定我们在声明事件太晚之前等待多长时间。超过水印视为过期的事件将被丢弃。

水印是基于内部时间表示的阈值计算的。正如我们可以在图 12-2 中看到的,水印线是从事件时间线推断出的时间线的一条移动线。在这个图表中,我们可以观察到所有落在“灰色区域”下方的事件都被认为“太晚”,并且在消费该事件流的计算中将不予考虑。

spas 1202

图 12-2. 内部事件时间轴上的水印

我们通过将timestamp字段与水印对应的时间阈值链接来声明水印。继续使用表 12-1 的示例,我们可以这样声明水印:

// Lets assume an existing streaming dataframe of weather station readings
// (id: String, ts:Long, pressure: Double, temperature: Double)

val timeStampEvents = raw.withColumn("timestamp", $"ts".cast(TimestampType))
                         .withWatermak("timestamp", "5 minutes")

基于时间窗口的聚合

我们对数据流想要提出的一个自然问题是在固定时间间隔内的聚合信息。由于流可能永无止境,所以在流处理上下文中,我们更感兴趣的是知道在 15 分钟间隔内“有多少个 X”。

使用事件时间处理,结构化流处理消除了在面对我们在本章讨论过的事件传递挑战时处理中间状态的通常复杂性。结构化流处理负责保持数据的部分聚合,并使用与选择的输出模式相对应的语义更新下游消费者。

定义基于时间的窗口

我们在“窗口聚合”中讨论了基于窗口的聚合概念,介绍了tumblingsliding窗口的定义。在结构化流处理中,内置的事件时间支持使得定义和使用此类基于窗口的操作变得简单。

从 API 的角度来看,窗口聚合使用window函数作为分组条件来声明。window函数必须应用于我们希望用作事件时间的字段。

在继续我们的气象站场景时,在示例 12-1 中,我们可以计算每 10 分钟全报告站点的平均压力。

示例 12-1. 计算总平均数
$>val perMinuteAvg = timeStampEvents
  .withWatermak("timestamp", "5 minutes")
  .groupBy(window($"timestamp", "1 minute"))
  .agg(avg($"pressure"))

$>perMinuteAvg.printSchema // let's inspect the schema of our window aggregation

root
 |-- window: struct (nullable = true)
 |    |-- start: timestamp (nullable = true)
 |    |-- end: timestamp (nullable = true)
 |-- pressureAvg: double (nullable = true)
 |-- tempAvg: double (nullable = true)

$>perMinuteAvg.writeStream.outputMode("append").format("console").start()
// after few minutes
+---------------------------------------------+-------------+-------------+
|window                                       |pressureAvg  |tempAvg      |
+---------------------------------------------+-------------+-------------+
|[2018-06-17 23:27:00.0,2018-06-17 23:28:00.0]|101.515516867|5.19433723603|
|[2018-06-17 23:28:00.0,2018-06-17 23:29:00.0]|101.481236804|13.4036089642|
|[2018-06-17 23:29:00.0,2018-06-17 23:30:00.0]|101.534757332|7.29652790939|
|[2018-06-17 23:30:00.0,2018-06-17 23:31:00.0]|101.472349471|9.38486237260|
|[2018-06-17 23:31:00.0,2018-06-17 23:32:00.0]|101.523849943|12.3600638827|
|[2018-06-17 23:32:00.0,2018-06-17 23:33:00.0]|101.531088691|11.9662189701|
|[2018-06-17 23:33:00.0,2018-06-17 23:34:00.0]|101.491889383|9.07050033207|
+---------------------------------------------+-------------+-------------+

在这个例子中,我们观察到窗口聚合的结果模式包含窗口期,用startend时间戳指示每个生成的窗口,以及相应的计算数值。

理解如何计算间隔

窗口间隔与开始小时对应的第二/分钟/小时/天对齐。例如,window($"timestamp", "15 minutes")将生成以小时开始对齐的 15 分钟间隔。

第一个间隔的开始时间是在过去,以调整窗口对齐而没有任何数据丢失。这意味着第一个间隔可能只包含通常间隔数据的一小部分。因此,如果我们每秒接收大约 100 条消息,那么在 15 分钟内我们预计会看到大约 90,000 条消息,而我们的第一个窗口可能只是这些数据的一小部分。

窗口中的时间间隔在开始时包含,结束时不包含。在间隔表示法中,这写作start-time, end-time)。根据之前定义的 15 分钟间隔,具有时间戳11:30:00.00的数据点将属于11:30-11:45的窗口间隔。

使用复合聚合键

在[示例 12-1 中,我们为压力和温度传感器计算了全局聚合值。我们还对每个气象站计算聚合值感兴趣。我们可以通过创建一个复合聚合键来实现这一点,其中我们将stationId添加到聚合条件中,就像我们在静态DataFrame API 中处理一样。示例 12-2 说明了我们如何做到这一点。

示例 12-2. 每个站点的平均数计算
$>val minuteAvgPerStation = timeStampEvents
  .withWatermak("timestamp", "5 minutes")
  .groupBy($"stationId", window($"timestamp", "1 minute"))
  .agg(avg($"pressure") as "pressureAvg", avg($"temp") as "tempAvg")

// The aggregation schema now contains the station Id
$>minuteAvgPerStation.printSchema
root
 |-- stationId: string (nullable = true)
 |-- window: struct (nullable = true)
 |    |-- start: timestamp (nullable = true)
 |    |-- end: timestamp (nullable = true)
 |-- pressureAvg: double (nullable = true)
 |-- tempAvg: double (nullable = true)

$>minuteAvgPerStation.writeStream.outputMode("append").format("console").start

+---------+-----------------------------------------+-----------+------------+
|stationId|window                                   |pressureAvg|tempAvg     |
+---------+-----------------------------------------+-----------+------------+
|d60779f6 |[2018-06-24 18:40:00,2018-06-24 18:41:00]|101.2941341|17.305931400|
|d1e46a42 |[2018-06-24 18:40:00,2018-06-24 18:41:00]|101.0664287|4.1361759034|
|d7e277b2 |[2018-06-24 18:40:00,2018-06-24 18:41:00]|101.8582047|26.733601007|
|d2f731cc |[2018-06-24 18:40:00,2018-06-24 18:41:00]|101.4787068|9.2916271894|
|d2e710aa |[2018-06-24 18:40:00,2018-06-24 18:41:00]|101.7895921|12.575678298|
   ...
|d2f731cc |[2018-06-24 18:41:00,2018-06-24 18:42:00]|101.3489804|11.372200251|
|d60779f6 |[2018-06-24 18:41:00,2018-06-24 18:42:00]|101.6932267|17.162540135|
|d1b06f88 |[2018-06-24 18:41:00,2018-06-24 18:42:00]|101.3705194|-3.318370333|
|d4c162ee |[2018-06-24 18:41:00,2018-06-24 18:42:00]|101.3407332|19.347538519|
+---------+-----------------------------------------+-----------+------------+
// ** output has been edited to fit into the page

Tumbling 和 Sliding 窗口

window是一个 SQL 函数,接受TimestampType类型的timeColumn和额外的参数来指定窗口的持续时间:

window(timeColumn: Column,
       windowDuration: String,
       slideDuration: String,
       startTime: String)

此方法的过载定义使slideDurationstartTime成为可选项。

此 API 允许我们指定两种窗口类型:滚动窗口和滑动窗口。可选的startTime可以延迟窗口的创建,例如当我们希望在流量稳定之后允许一个上升期之后。

滚动窗口

滚动窗口将时间段分割为不重叠的连续周期。当我们需要“每 15 分钟的总计”或“每小时每个发电机的生产水平”时,它们是自然的窗口操作。我们通过仅提供windowDuration参数来指定滚动窗口:

window($"timestamp", "5 minutes")

window定义每五分钟生成一个结果。

滑动窗口

与滚动窗口相比,滑动窗口是重叠的时间间隔。间隔的大小由windowDuration确定。在该间隔内流中的所有值都将考虑在内进行聚合操作。对于下一个切片,我们添加在slideDuration期间到达的元素,移除对应于最旧切片的元素,并对窗口内的数据应用聚合,每个slideDuration生成一个结果:

window($"timestamp", "10 minutes", "1 minute")

此窗口定义使用 10 分钟的数据每分钟生成一个结果。

值得注意的是,滚动窗口是滑动窗口的一种特例,其中windowDurationslideDuration具有相等的值:

window($"timestamp", "5 minutes", "5 minutes")

如果slideInterval大于windowDuration,使用这种情况会导致 Structured Streaming 抛出org.apache.spark.sql.AnalysisException错误是非法的。

时间间隔偏移

窗口定义的第三个参数称为startTime,提供了一种偏移窗口对齐的方式。在“理解如何计算间隔”中,我们看到窗口间隔对齐到上一个时间量级。startTime(在我们看来是一个误称)允许我们按照指定的时间偏移窗口间隔。

在以下窗口定义中,我们通过 2 分钟偏移一个 10 分钟窗口,滑动间隔为 5 分钟,结果是时间间隔如00:02-00:12, 00:07-00:17, 00:12-00:22, ...

window($"timestamp", "10 minutes", "5 minute", "2 minutes")

startTime必须严格小于slideDuration。如果提供了无效的配置,Structured Streaming 将抛出org.apache.spark.sql.AnalysisException错误。直观地说,鉴于slideDuration提供了报告窗口的周期性,我们可以偏移该周期仅一段小于该周期本身的时间。

记录去重

结构化流处理提供了一个内置函数,用于移除流中的重复记录。可以指定一个水印,确定何时安全丢弃先前见过的键。

基本形式相当简单:

val deduplicatedStream = stream.dropDuplicates(<field> , <field>, ...)

尽管如此,这种基本方法并不被鼓励,因为它要求您存储定义唯一记录的字段集合的所有接收到的值,这可能会导致无界的情况。

更健壮的替代方案是在dropDuplicates函数之前在流上指定水印:

val deduplicatedStream = stream
  .withWatermark(<event-time-field>, <delay-threshold>)
  .dropDuplicates(<field> , <field>, ...)

使用水印时,早于水印的键变得符合删除条件,使状态存储能够保持其存储需求有界。

摘要

在本章中,我们探讨了结构化流如何实现事件时间的概念以及 API 提供的使用事件数据中嵌入的时间的设施:

  • 我们学习了如何使用事件时间,以及在需要时如何回退到处理时间。

  • 我们探讨了水印,这是一个重要的概念,让我们能够确定哪些事件已经太迟,以及何时可能从存储中删除与状态相关的数据。

  • 我们看到了窗口操作的不同配置及其与事件时间的关联。

  • 最后,我们学习了关于去重函数如何使用水印来保持其状态有界的内容。

事件时间处理是结构化流中内置的一组强大特性,它封装了处理时间、顺序和延迟的复杂性,提供了易于使用的 API。

尽管如此,在某些情况下内置函数无法足够实现特定的有状态处理。对于这些情况,结构化流提供了高级函数来实现任意的有状态处理,正如我们在下一章中所见。

第十三章:高级有状态操作

第八章展示了如何使用结构化流的现有聚合函数轻松表达聚合操作,使用结构化 Spark API。第十二章展示了 Spark 内置支持使用事件流中的嵌入时间信息(所谓的事件时间处理)的有效性。

但是,在需要满足不被内置模型直接支持的自定义聚合条件时,我们需要研究如何进行高级有状态操作以解决这些情况。

结构化流提供了一个 API 来实现任意有状态处理。这个 API 由两个操作表示:mapGroupsWithStateflatMapGroupsWithState。这两个操作允许我们创建状态的自定义定义,设置该状态随时间推移如何演变的规则,确定其何时过期,并提供一种方法将此状态定义与传入数据结合以生成结果。

mapGroupsWithStateflatMapGroupsWithState之间的主要区别在于前者必须为每个处理过的组生成单个结果,而后者可能生成零个或多个结果。从语义上讲,这意味着当新数据始终导致新状态时应使用mapGroupsWithState,而在所有其他情况下应使用flatMapGroupsWithState

在内部,结构化流负责在操作之间管理状态,并确保其在整个流处理过程中的可用性和容错保存。

例如:车队管理

假设有一个车队管理解决方案,车队中的车辆具备无线网络功能。每辆车定期报告其地理位置以及诸如燃油水平、速度、加速度、方位、引擎温度等多个操作参数。利益相关者希望利用这些遥测数据流来实现一系列应用程序,帮助他们管理业务的运营和财务方面。

利用目前了解到的结构化流特性,我们已经能够实现许多用例,比如使用事件时间窗口监控每天行驶的公里数或通过应用过滤器查找低燃油警告的车辆。

现在,我们希望有一个行程的概念:从起点到终点的驾驶路段。单独来看,行程的概念有助于计算燃油效率或监控地理围栏协议的遵守情况。当分析成组时,行程信息可能揭示出运输模式、交通热点,以及与其他传感器信息结合时,甚至可以报告道路条件。从我们流处理的角度来看,我们可以将行程视为一个任意的时间窗口,当车辆开始移动时打开,当最终停止时关闭。我们在 第十二章 中看到的事件时间窗口聚合使用固定的时间间隔作为窗口条件,因此对于实现我们的行程分析没有帮助。

我们可以意识到,我们需要一个更强大的状态定义,不仅仅基于时间,还基于任意条件。在我们的例子中,这个条件是车辆正在行驶。

理解带状态操作

任意状态操作 mapGroupsWithStateflatMapGroupWithState 专门使用类型化的 Dataset API,可以使用 Scala 或 Java 绑定。

根据我们正在处理的数据和我们状态转换的要求,我们需要提供三种类型定义,通常编码为 case class(Scala)或 Java Bean(Java):

  • 输入事件 (I)

  • 要保持的任意状态 (S)

  • 输出 (O)(如果适用,则此类型可能与状态表示相同)

所有这些类型都必须可以编码为 Spark SQL 类型。这意味着应该有一个可用的 Encoder。通常的导入语句

import spark.implicits._

对于所有基本类型、元组和 case class,都足够。

有了这些类型,我们可以制定状态转换函数,实现我们的自定义状态处理逻辑。

mapGroupsWithState 要求此函数返回一个必需的单一值:

def mappingFunction(key: K, values: Iterator[I], state: GroupState[S]): O

flatMapGroupsWithState 要求此函数返回一个 Iterator,可能包含零个或多个元素:

def flatMappingFunction(
    key: K, values: Iterator[I], state: GroupState[S]): Iterator[O]

GroupState[S] 是由结构化流提供的包装器,在执行过程中用于管理状态 S。在函数内部,GroupState 提供对状态的变异访问以及检查和设置超时的能力。

警告

mappingFunction/flatMappingFunction 的实现必须是可序列化的。

在运行时,此函数通过 Java 序列化分发到集群的执行器上。这个要求也导致我们绝不能在函数体中包含任何本地状态,比如计数器或其他可变变量。所有管理的状态必须封装在 State 表示类中。

内部状态流

在图 13-1 中,我们展示了将输入数据(事件形式)与内部维护的状态结合起来生成结果的过程。在这个图表中,mappingFunction(用Σ表示)使用自定义逻辑处理这组元素,当与GroupState[S]管理的状态结合时,会产生一个结果。在这个示例中,我们使用了停止符号来表示超时。在MapGroupsWithState的情况下,超时也会触发事件的生成,并且应该清除状态。考虑到清除逻辑由编程逻辑控制,完全的状态管理责任在开发者手中。结构化流只提供基础构件。

spas 1301

图 13-1. 带状态动态的映射组

使用MapGroupsWithState

在“滑动窗口”中,我们看到如何基于时间窗口计算移动平均值。这种基于时间的窗口会独立于窗口中找到的元素数量产生结果。

现在,假设我们的需求是计算每个键接收的最后 10 个元素的移动平均值。我们不能使用时间窗口,因为我们不知道我们需要多长时间来获取所需数量的元素。相反,我们可以使用具有自定义状态的计数窗口来定义我们自己的基于计数的窗口。

在线资源

例如,我们将使用在线资源中的map_groups_with_state笔记本,位于http://github.com/stream-processing-with-spark

让我们从在“滑动窗口”中使用的相同流式数据集开始。WeatherEvent case class成为我们的输入类型(I):

// a representation of a weather station event
case class WeatherEvent(stationId: String,
  timestamp: Timestamp,
  location:(Double,Double),
  pressure: Double,
  temp: Double)

val weatherEvents: Dataset[WeatherEvents] = ...

接下来,我们定义状态(S)。我们想要的是在我们的状态中保留最新的n个元素并丢弃任何更旧的元素。这似乎是使用 FIFO(先进先出)集合(如Queue)的自然应用。新元素添加到队列的前面,我们保留最近的n个元素,并丢弃任何更旧的元素。

我们的状态定义变成了由Queue支持的FIFOBuffer,并带有几个辅助方法以便于其使用:

import scala.collection.immutable.Queue
case class FIFOBufferT extends Serializable {

  def add(element: T): FIFOBuffer[T] =
    this.copy(data = data.enqueue(element).take(capacity))

  def get: List[T] = data.toList

  def size: Int = data.size
}

接下来,我们需要定义输出类型(O),该类型是状态计算的结果。我们状态计算的期望结果是输入WeatherEvent中传感器值的移动平均值。我们还想知道用于计算的值的时间跨度。有了这些知识,我们设计我们的输出类型,WeatherEventAverage

import java.sql.Timestamp
case class WeatherEventAverage(stationId: String,
                               startTime: Timestamp,
                               endTime:Timestamp,
                               pressureAvg: Double,
                               tempAvg: Double)

有了这些定义,我们可以继续创建mappingFunction,它将现有状态和新元素组合成一个结果。我们可以在示例 13-1 中看到映射函数的实现。请记住,这个函数还负责通过GroupState包装器提供的函数更新内部状态。重要的是要注意,状态不能被更新为null值。试图这样做将抛出IllegalArgumentException。要删除状态,请使用state.remove()方法。

示例 13-1. 使用mapGroupsWithState进行基于计数的移动平均值
import org.apache.spark.sql.streaming.GroupState
def mappingFunction(
    key: String,
    values: Iterator[WeatherEvent],
    state: GroupState[FIFOBuffer[WeatherEvent]]
  ): WeatherEventAverage = {

  // the size of the window
  val ElementCountWindowSize = 10

  // get current state or create a new one if there's no previous state
  val currentState = state.getOption
    .getOrElse(
      new FIFOBufferWeatherEvent
    )

  // enrich the state with the new events
  val updatedState = values.foldLeft(currentState) {
    case (st, ev) => st.add(ev)
  }

  // update the state with the enriched state
  state.update(updatedState)

  // if we have enough data, create a WeatherEventAverage from the state
  // otherwise, make a zeroed record
  val data = updatedState.get
  if (data.size > 2) {
    val start = data.head
    val end = data.last
    val pressureAvg = data
      .map(event => event.pressure)
      .sum / data.size
    val tempAvg = data
      .map(event => event.temp)
      .sum / data.size
    WeatherEventAverage(
      key,
      start.timestamp,
      end.timestamp,
      pressureAvg,
      tempAvg
    )
  } else {
    WeatherEventAverage(
      key,
      new Timestamp(0),
      new Timestamp(0),
      0.0,
      0.0
    )
  }
}

现在,我们使用mappingFunction来声明流式Dataset的有状态转换:

import org.apache.spark.sql.streaming.GroupStateTimeout
val weatherEventsMovingAverage = weatherEvents
  .groupByKey(record => record.stationId)
  .mapGroupsWithState(GroupStateTimeout.ProcessingTimeTimeout)(mappingFunction)

请注意,我们首先在我们的领域中的键标识符中创建。在这个例子中,这是stationIdgroupByKey操作创建了一个中间结构,一个KeyValueGroupedDataset,它成为了[map|flatMap]GroupWithState操作的入口点。

除了映射函数之外,我们还需要提供一个超时类型。超时类型可以是ProcessingTimeTimeoutEventTimeTimeout。因为我们不依赖事件的时间戳来进行状态管理,所以我们选择了ProcessingTimeTimeout。我们稍后在本章中详细讨论超时管理。

最后,我们可以通过使用控制台接收器轻松观察查询结果:

val outQuery = weatherEventsMovingAverage.writeStream
  .format("console")
  .outputMode("update")
  .start()

+---------+-------------------+-------------------+------------+------------+
|stationId|startTime          |endTime            |pressureAvg |tempAvg     |
+---------+-------------------+-------------------+------------+------------+
|d1e46a42 |2018-07-08 19:20:31|2018-07-08 19:20:36|101.33375295|19.753225782|
|d1e46a42 |2018-07-08 19:20:31|2018-07-08 19:20:44|101.33667584|14.287718525|
|d60779f6 |2018-07-08 19:20:38|2018-07-08 19:20:48|101.59818386|11.990002708|
|d1e46a42 |2018-07-08 19:20:31|2018-07-08 19:20:49|101.34226429|11.294964619|
|d60779f6 |2018-07-08 19:20:38|2018-07-08 19:20:51|101.63191940|8.3239282534|
|d8e16e2a |2018-07-08 19:20:40|2018-07-08 19:20:52|101.61979385|5.0717571842|
|d4c162ee |2018-07-08 19:20:34|2018-07-08 19:20:53|101.55532969|13.072768358|
+---------+-------------------+-------------------+------------+------------+
// (!) output edited to fit in the page

使用 FlatMapGroupsWithState

我们之前的实现有一个缺陷。你能发现吗?

当我们开始处理流,并且在我们收集到我们认为需要计算移动平均值的所有元素之前,mapGroupsWithState操作会产生零值:

+---------+-------------------+-------------------+-----------+-------+
|stationId|startTime          |endTime            |pressureAvg|tempAvg|
+---------+-------------------+-------------------+-----------+-------+
|d2e710aa |1970-01-01 01:00:00|1970-01-01 01:00:00|0.0        |0.0    |
|d1e46a42 |1970-01-01 01:00:00|1970-01-01 01:00:00|0.0        |0.0    |
|d4a11632 |1970-01-01 01:00:00|1970-01-01 01:00:00|0.0        |0.0    |
+---------+-------------------+-------------------+-----------+-------+

正如我们之前提到的,mapGroupsWithState要求状态处理函数在每个触发间隔处理的每个组产生单个记录。当新数据的到来与每个键相对应自然地更新其状态时,这是很好的。

但是在某些情况下,我们的状态逻辑要求一系列事件发生后才能生成结果。在我们当前的示例中,我们需要n个元素才能开始计算它们的平均值。在其他情况下,一个单独的传入事件可能会完成多个临时状态,从而产生多个结果。例如,单个质量运输到达目的地可能会更新所有乘客的旅行状态,可能会为每个乘客产生一条记录。

flatMapGroupsWithStatemapGroupsWithState的泛化,其中状态处理函数生成一个结果的Iterator,该结果可能包含零个或多个元素。

让我们看看如何使用此函数来改进我们对n个元素的移动平均计算。

在线资源

对于此示例,我们将使用书籍在线资源中的mapgroupswithstate-n-moving-average笔记本,位于https://github.com/stream-processing-with-spark

我们需要更新映射函数以返回一个结果的Iterator。在我们的情况下,当我们没有足够的值来计算平均值时,这个Iterator将包含零个元素,否则包含一个值。我们修改后的函数看起来像示例 13-2。

示例 13-2。使用基于计数的移动平均数的flatMapGroupsWithState
import org.apache.spark.sql.streaming._
def flatMappingFunction(
    key: String,
    values: Iterator[WeatherEvent],
    state: GroupState[FIFOBuffer[WeatherEvent]]
  ): Iterator[WeatherEventAverage] = {

  val ElementCountWindowSize = 10

  // get current state or create a new one if there's no previous state
  val currentState = state.getOption
    .getOrElse(
      new FIFOBufferWeatherEvent
    )

  // enrich the state with the new events
  val updatedState = values.foldLeft(currentState) {
    case (st, ev) => st.add(ev)
  }

  // update the state with the enriched state
  state.update(updatedState)

  // only when we have enough data, create a WeatherEventAverage from the state
  // before that, we return an empty result.
  val data = updatedState.get
  if (data.size == ElementCountWindowSize) {
    val start = data.head
    val end = data.last
    val pressureAvg = data
      .map(event => event.pressure)
      .sum / data.size
    val tempAvg = data
      .map(event => event.temp)
      .sum / data.size
    Iterator(
      WeatherEventAverage(
        key,
        start.timestamp,
        end.timestamp,
        pressureAvg,
        tempAvg
      )
    )
  } else {
    Iterator.empty
  }
}

val weatherEventsMovingAverage = weatherEvents
  .groupByKey(record => record.stationId)
  .flatMapGroupsWithState(
    OutputMode.Update,
    GroupStateTimeout.ProcessingTimeTimeout
  )(flatMappingFunction)

使用flatMapGroupsWithState,我们不再需要生成人为的零记录。除此之外,我们的状态管理定义现在严格地要求有n个元素来生成结果。

输出模式

尽管mapflatMapGroupsWithState操作之间结果的基数差异可能看起来像一个小的实际 API 差异,但它在显而易见的变量产生结果之外具有更深远的影响。

正如我们在示例中所看到的,flatMapGroupsWithState需要额外指定输出模式。这是为了向下游过程提供有关状态操作的记录生成语义的信息。反过来,这有助于 Structured Streaming 计算下游接收器的允许输出操作。

flatMapGroupsWithState中指定的输出模式可以是以下之一:

update

这表明生成的记录是非最终的。它们是中间结果,可能随后通过新信息进行更新。在前面的示例中,键的新数据到达时会生成一个新的数据点。下游接收器必须使用update,并且不能跟随flatMapGroupsWithState操作的任何聚合。

append

这表示我们已经收集到了所有需要为一个组生成结果的信息,没有进一步的传入事件会改变该结果。下游接收器必须使用append模式来写入。鉴于flatMapGroupsWithState应用了一个最终记录,可以对该结果应用进一步的聚合。

随时间管理状态

管理随时间推移的状态的一个关键要求是确保我们有一个稳定的工作集。¹也就是说,我们的进程所需的内存随时间有界,并且保持在安全距离内,以允许波动。

在我们在第十二章中看到的基于时间窗口等托管有状态聚合中,Structured Streaming 内部管理机制以清除被认为过期的状态和事件,以限制内存使用量。当我们使用[map|flatMap]GroupsWithState提供的自定义状态管理能力时,我们还必须承担清除旧状态的责任。

幸运的是,Structured Streaming 暴露了时间和超时信息,我们可以用它来决定何时过期某些状态。第一步是决定要使用的时间参考。超时可以基于事件时间或处理时间,选择是针对特定 [map|flatMap]GroupsWithState 处理的状态全局设置的。

在调用 [map|flatMap]GroupsWithState 时指定超时类型。回顾移动平均数示例,我们配置了 mapGroupsWithState 函数以如下方式使用处理时间:

import org.apache.spark.sql.streaming.GroupStateTimeout
val weatherEventsMovingAverage = weatherEvents
  .groupByKey(record => record.stationId)
  .mapGroupsWithState(GroupStateTimeout.ProcessingTimeTimeout)(mappingFunction)

要使用事件时间,我们还需要声明水印定义。此定义由事件的时间戳字段和水印的配置滞后组成。如果我们想要在上一个示例中使用事件时间,我们会这样声明:

val weatherEventsMovingAverage = weatherEvents
  .withWatermark("timestamp", "2 minutes")
  .groupByKey(record => record.stationId)
  .mapGroupsWithState(GroupStateTimeout.EventTimeTimeout)(mappingFunction)

超时类型声明了全局的时间参考源。还有选项 GroupStateTimeout.NoTimeout,适用于不需要超时的情况。超时的实际值由每个个体组管理,使用 GroupState 中可用的方法来管理超时:state.setTimeoutDurationstate.setTimeoutTimestamp

要确定状态是否已过期,我们检查 state.hasTimedOut。当状态超时时,对于已超时组的值,将发出对 (flat)MapFunction 的调用,空迭代器。

让我们利用超时功能。继续我们的运行示例,我们首先要做的是提取状态转换为事件:

def stateToAverageEvent(
    key: String,
    data: FIFOBuffer[WeatherEvent]
  ): Iterator[WeatherEventAverage] = {
  if (data.size == ElementCountWindowSize) {
    val events = data.get
    val start = events.head
    val end = events.last
    val pressureAvg = events
      .map(event => event.pressure)
      .sum / data.size
    val tempAvg = events
      .map(event => event.temp)
      .sum / data.size
    Iterator(
      WeatherEventAverage(
        key,
        start.timestamp,
        end.timestamp,
        pressureAvg,
        tempAvg
      )
    )
  } else {
    Iterator.empty
  }
}

现在,我们可以利用这个新的抽象来转换我们的状态,无论是在超时的情况下,还是在通常数据进入的场景中。请注意在 Example 13-3 中如何使用超时信息来清除即将过期的状态。

示例 13-3. 在 flatMapGroupsWithState 中使用超时
import org.apache.spark.sql.streaming.GroupState
def flatMappingFunction(
    key: String,
    values: Iterator[WeatherEvent],
    state: GroupState[FIFOBuffer[WeatherEvent]]
  ): Iterator[WeatherEventAverage] = {
  // first check for timeout in the state
  if (state.hasTimedOut) {
    // when the state has a timeout, the values are empty
    // this validation is only to illustrate the point
    assert(
      values.isEmpty,
      "When the state has a timeout, the values are empty"
    )
    val result = stateToAverageEvent(key, state.get)
    // evict the timed-out state
    state.remove()
    // emit the result of transforming the current state into an output record
    result
  } else {
    // get current state or create a new one if there's no previous state
    val currentState = state.getOption.getOrElse(
      new FIFOBufferWeatherEvent
    )
    // enrich the state with the new events
    val updatedState = values.foldLeft(currentState) {
      case (st, ev) => st.add(ev)
    }
    // update the state with the enriched state
    state.update(updatedState)
    state.setTimeoutDuration("30 seconds")
    // only when we have enough data,
    // create a WeatherEventAverage from the accumulated state
    // before that, we return an empty result.
    stateToAverageEvent(key, updatedState)
  }
}

当超时实际上超时时

在结构化流处理中超时的语义确保在时钟超过水印之后才会超时。这符合我们对超时的直觉:我们的状态在设定的过期时间之前不会超时。

超时语义与常规直觉有所不同的地方在于超时事件实际发生的时间是在过期时间过去之后。

目前,超时处理绑定到接收新数据上。因此,一段时间内沉默的流并且不生成新触发器进行处理,也不会生成超时。当前的超时语义是根据事件性质定义的:超时事件将在状态过期后最终触发,没有任何关于超时事件何时触发的严格上界的保证。正式地说:超时事件触发的时间没有严格的上限。

警告

目前正在进行工作,使超时在没有新数据可用时也能触发。

总结

在本章中,我们了解了结构化流处理中的任意状态处理 API。我们探讨了mapGroupsWithStateflatMapGroupsWithState之间的细节和差异,包括它们产生的事件及其支持的输出模式。最后,我们还学习了超时设置,并了解了其语义。

尽管这个 API 的使用比常规的类 SQL 构造更复杂,但它为我们提供了一个强大的工具集,用于实现任意状态管理,以解决最苛刻的流式使用场景的开发需求。

¹ 工作集 是指进程在一段时间内用于运行的内存量。

第十四章:监控结构化流应用程序

应用程序监控是任何稳健部署的重要组成部分。监控通过收集和处理量化应用程序性能不同方面的度量指标,如响应能力、资源使用和任务特定指标,为应用程序的性能特征提供了随时间变化的见解。

流式应用对响应时间和吞吐量有严格要求。在像 Spark 这样的分布式应用中,我们需要在应用程序的生命周期中考虑的变量数量,会因在一组机器上运行而复杂化。在集群环境中,我们需要监控不同主机上的资源使用情况,如 CPU、内存和辅助存储,从每个主机的角度来看,以及运行应用程序的综合视图。

例如,想象一个运行在 10 个不同执行器上的应用程序。总内存使用指标显示增加了 15%,这可能在预期容忍范围内,但我们注意到增加来自单个节点。这种不平衡需要调查,因为当该节点内存耗尽时可能会导致故障。这也意味着可能存在工作分配不平衡造成瓶颈。如果没有适当的监控,我们首先不会观察到这种行为。

结构化流的运行度量可以通过三种不同的通道公开:

  • Spark 度量子系统

  • writeStream.start 操作返回的 StreamingQuery 实例

  • StreamingQueryListener 接口

正如我们在接下来的章节中详细说明的那样,这些接口提供了不同级别的详细信息和曝光,以满足不同的监控需求。

Spark 度量子系统

通过 Spark 核心引擎提供的 Spark 度量子系统,提供了一个可配置的度量收集和报告 API,具有可插拔的接收器接口,与本书前面讨论的流式接收器不同。Spark 自带多个这样的接收器,包括 HTTP、JMX 和逗号分隔值(CSV)文件。除此之外,还有一个 Ganglia 接收器,由于许可限制需要额外的编译标志。

HTTP 接收器默认启用。它由一个注册在与 Spark UI 相同端口的驱动程序主机上的端点的 servlet 实现。度量数据可通过 /metrics/json 端点访问。可以通过配置启用其他接收器。选择特定接收器由我们希望集成的监控基础设施驱动。例如,JMX 接收器是与 Kubernetes 集群调度程序中流行的度量收集器 Prometheus 集成的常见选项。

结构化流度量

要从结构化流作业获取指标,我们首先必须启用此类指标的内部报告。我们通过将配置标志spark.sql.streaming.metricsEnabled设置为true来实现,如此处所示:

// at session creation time
val spark = SparkSession
   .builder()
   .appName("SparkSessionExample")
   .config("spark.sql.streaming.metricsEnabled", true)
   .config(...)
   .getOrCreate()

// by setting the config value
spark.conf.set("spark.sql.streaming.metricsEnabled", "true")

// or by using the SQL configuration
spark.sql("SET spark.sql.streaming.metricsEnabled=true")

有了这个配置,报告的指标将包含每个在同一个SparkSession上下文中运行的流查询的三个额外指标:

inputRate-total

每个触发间隔摄入的消息总数

延迟

触发间隔的处理时间

processingRate-total

记录处理速度

StreamingQuery实例

正如我们在前面的结构化流示例中看到的那样,启动流查询的调用会产生一个StreamingQuery结果。让我们深入研究来自示例 13-1 的weatherEventsMovingAverage

val query = scoredStream.writeStream
        .format("memory")
        .queryName("memory_predictions")
        .start()

query: org.apache.spark.sql.streaming.StreamingQuery =
  org.apache.spark.sql.execution.streaming.StreamingQueryWrapper@7875ee2b

我们从query值的调用中获得的结果是一个StreamingQuery实例。StreamingQuery是实际在引擎中连续运行的流查询的处理程序。此处理程序包含用于检查查询执行和控制其生命周期的方法。一些有趣的方法包括:

query.awaitTermination()

阻止当前线程直到查询结束,无论是因为它停止了还是遇到了错误。此方法用于阻止main线程并防止其过早终止。

query.stop()

停止查询的执行。

query.exception()

检索查询执行中遇到的任何致命异常。当查询正常运行时,此方法返回None。查询停止后,检查此值可以告诉我们它是否失败以及失败的原因。

query.status()

显示查询当前正在执行的简要快照。

例如,检索运行查询的query.status会显示类似于以下结果:

$query.status
res: org.apache.spark.sql.streaming.StreamingQueryStatus =
{
  "message" : "Processing new data",
  "isDataAvailable" : true,
  "isTriggerActive" : false
}

即使在一切正常运行时,状态信息也不会透露太多信息,但在开发新作业时可能会有所帮助。当发生错误时,query.start()不会有任何提示。查看query.status()可能会显示存在问题,此时query.exception将返回导致问题的原因。

在示例 14-1 中,我们使用了错误的模式作为 Kafka sink 的输入。如果我们回忆起“Kafka Sink”,我们会发现 Kafka sink 需要输出流中的一个强制字段:value(甚至key是可选的)。在这种情况下,query.status提供了相关反馈来解决此问题。

示例 14-1. query.status显示流失败的原因
res: org.apache.spark.sql.streaming.StreamingQueryStatus =
{
  "message": "Terminated with exception: Required attribute 'value' not found",
  "isDataAvailable": false,
  "isTriggerActive": false
}

StreamingQueryStatus中的方法是线程安全的,这意味着可以从另一个线程并发调用它们而不会破坏查询状态。

获取带有StreamingQueryProgress的指标

出于监视目的,我们更感兴趣的是一组方法,它们提供有关查询执行指标的见解。StreamingQuery处理程序提供了两种这样的方法:

query.lastProgress

获取最近的StreamingQueryProgress报告。

query.recentProgress

检索最近的一系列StreamingQueryProgress报告。可以使用 Spark Session 中的配置参数spark.sql.streaming.numRecentProgressUpdates设置检索的progress对象的最大数量。如果未设置此配置,它将默认为最后 100 个报告。

正如我们可以在示例 14-2 中看到的,每个StreamingQueryProgress实例都提供了在每个触发器产生的查询性能全面快照。

示例 14-2. StreamingQueryProgress 示例
{
  "id": "639503f1-b6d0-49a5-89f2-402eb262ad26",
  "runId": "85d6c7d8-0d93-4cc0-bf3c-b84a4eda8b12",
  "name": "memory_predictions",
  "timestamp": "2018-08-19T14:40:10.033Z",
  "batchId": 34,
  "numInputRows": 37,
  "inputRowsPerSecond": 500.0,
  "processedRowsPerSecond": 627.1186440677966,
  "durationMs": {
    "addBatch": 31,
    "getBatch": 3,
    "getOffset": 1,
    "queryPlanning": 14,
    "triggerExecution": 59,
    "walCommit": 10
  },
  "stateOperators": [],
  "sources": [
    {
      "description": "KafkaSource[Subscribe[sensor-office-src]]",
      "startOffset": {
        "sensor-office-src": {
          "0": 606580
        }
      },
      "endOffset": {
        "sensor-office-src": {
          "0": 606617
        }
      },
      "numInputRows": 37,
      "inputRowsPerSecond": 500.0,
      "processedRowsPerSecond": 627.1186440677966
    }
  ],
  "sink": {
    "description": "MemorySink"
  }
}

从监控作业性能的角度来看,我们特别关注numInputRowsinputRowsPerSecondprocessedRowsPerSecond。这些自描述字段提供了关于作业性能的关键指标。如果我们有比查询处理的数据更多的数据,inputRowsPerSecond在持续时间较长的时间内将高于processedRowsPerSecond。这可能表明,为了达到可持续的长期性能,应增加分配给此作业的集群资源。

StreamingQueryListener 接口

监控是“Day 2 运维”关注的重点,我们需要自动收集性能指标以支持其他流程,例如容量管理、警报和运维支持。

我们在前一节看到的StreamingQuery处理程序提供的检查方法在我们在交互式环境中工作时非常有用,例如Spark shell或笔记本电脑,就像我们在本书的练习中使用的那样。在交互设置中,我们有机会手动取样StreamingQueryProgress的输出,以获取有关作业性能特征的初步想法。

然而,StreamingQuery方法并不友好于自动化。考虑到每次流触发器会产生一个新的进度记录,自动化从此接口收集信息的方法需要与流作业的内部调度耦合。

幸运的是,结构化流提供了StreamingQueryListener,这是一个基于监听器的接口,提供异步回调以报告流作业生命周期中的更新。

实现StreamingQueryListener

要连接到内部事件总线,我们必须提供StreamingQueryListener接口的实现,并将其注册到正在运行的SparkSession中。

StreamingQueryListener包含三种方法:

onQueryStarted(event: QueryStartedEvent)

当流查询启动时调用。event提供了查询的唯一id和一个runId,如果查询停止并重新启动,则runId会更改。此回调与查询的启动同步调用,不应阻塞。

onQueryTerminated(event: QueryTerminatedEvent)

当流查询停止时调用。event包含与启动事件相关联的idrunId字段。它还提供了一个exception字段,如果查询由于错误而失败,则包含一个exception

onQueryProgress(event: StreamingQueryProgress)

在每次查询触发时调用。event包含一个progress字段,其中封装了一个我们已从“使用 StreamingQueryProgress 获取指标”中了解的StreamingQueryProgress实例。该回调提供了我们监视查询性能所需的事件。

示例 14-3 展示了这种监听器的简化版本实现。这个chartListener在笔记本中实例化时,绘制每秒的输入和处理速率。

示例 14-3. 绘制流作业性能
import org.apache.spark.sql.streaming.StreamingQueryListener
import org.apache.spark.sql.streaming.StreamingQueryListener._
val chartListener = new StreamingQueryListener() {
  val MaxDataPoints = 100
  // a mutable reference to an immutable container to buffer n data points
  var data: List[Metric] = Nil

  def onQueryStarted(event: QueryStartedEvent) = ()

  def onQueryTerminated(event: QueryTerminatedEvent) = ()

  def onQueryProgress(event: QueryProgressEvent) = {
    val queryProgress = event.progress
    // ignore zero-valued events
    if (queryProgress.numInputRows > 0) {
      val time = queryProgress.timestamp
      val input = Metric("in", time, event.progress.inputRowsPerSecond)
      val processed = Metric("proc", time, event.progress.processedRowsPerSecond)
      data = (input :: processed :: data).take(MaxDataPoints)
      chart.applyOn(data)
    }
  }
}

定义了一个监听器实例后,必须使用SparkSession中的addListener方法将其附加到事件总线:

sparkSession.streams.addListener(chartListener)

运行这个chartListener对本书在线资源中的任意一个笔记本后,我们可以可视化输入和处理速率,如图 14-1 所示。

spas 1401

图 14-1. 输入和处理流速率

类似的监听器实现可以用于将度量报告发送到流行的监控系统,如 Prometheus、Graphite 或可查询数据库如 InfluxDB,这些系统可以轻松集成到 Grafana 等仪表板应用中。

第十五章:实验性领域:连续处理与机器学习

结构化流处理首次出现在 Spark 2.0 中作为一个实验性 API,提供了一个新的流处理模型,旨在简化我们思考流应用的方式。在 Spark 2.2 中,结构化流处理“毕业”成为适用于生产环境,表明这种新模型已准备好供行业采用。在 Spark 2.3 中,我们看到在流连接方面进一步改进,并引入了一个新的实验性连续执行模型,用于低延迟流处理。

与任何新的成功开发一样,我们可以期待结构化流处理保持快速进展。尽管行业采用将为重要特性提供进化反馈,但市场趋势如机器学习日益增长的流行性将推动未来发布的路线图。

在本章中,我们希望深入探讨一些正在开发中的领域,这些领域可能会成为未来版本的主流。

连续处理

连续处理是结构化流处理的一种替代执行模式,允许对单个事件进行低延迟处理。它作为 Spark v2.3 中的实验性功能被包含进来,并且仍在积极开发中,特别是在交付语义、有状态操作支持和监控领域。

理解连续处理

Spark 的初始流处理 API,Spark Streaming,旨在重用 Spark 的批处理能力。简而言之,数据流被分成小块交给 Spark 进行处理,使用其本机批处理执行模式的核心引擎。在短时间间隔内定期重复此过程,不断消耗输入流并以流式方式生成结果。这被称为微批处理模型,我们在第五章早期已经讨论过。在本书的后续部分讨论 Spark Streaming 时,我们将更详细地研究该模型的应用。现在要记住的重要部分是,称为批处理间隔的时间间隔定义是原始微批处理实现的关键。

结构化流处理中的微批处理

当引入结构化流处理时,发生了类似的演进。与引入替代处理模型不同,结构化流处理被嵌入到Dataset API 中,并重用了底层 Spark SQL 引擎的现有功能。因此,结构化流处理提供了统一的 API,与传统的批处理模式完全融合,并充分利用了 Spark SQL 引入的性能优化,如查询优化和 Tungsten 代码生成。

在这一努力中,底层引擎获得了额外的能力来支持流处理工作负载,如增量查询执行和随时间的弹性状态管理的支持。

在 API 表面上,结构化流处理避免了将时间概念作为显式用户参数。这使得可以实现基于事件时间的聚合,因为时间概念是从数据流中推断出来的。在内部,执行引擎仍然依赖微批处理架构,但时间的抽象允许创建具有不同执行模型的引擎。

离开固定时间微批处理的第一个执行模型是结构化流处理中的尽力而为执行,在未指定trigger时是默认模式。在尽力而为模式下,下一个微批处理会在上一个结束后立即开始。这创建了结果流的可观察连续性,并改善了底层计算资源的使用。

微批处理执行引擎采用任务调度模型。在集群上进行任务调度和协调非常昂贵,最小的可能延迟大约为 100 毫秒。

在 图 15-1 中,您可以观察到使用微批处理模型过滤传入的“圆形”并将其转换为“三角形”的过程。我们收集在特定间隔内到达的所有元素,并同时对它们应用我们的函数 f

spas 1501

图 15-1. 微批处理延迟

处理延迟是在流中事件到达和在sink中产生结果之间的时间间隔。正如我们所了解的,在微批处理模型中,延迟的上限是批处理间隔加上处理数据所需的时间,其中包括计算本身以及在集群的执行器中执行此类计算所需的协调。

引入连续处理:低延迟流处理模式

利用结构化流处理中的时间抽象,可以引入新的执行模式,而无需更改面向用户的 API。

在连续处理执行模式中,数据处理查询作为长时间运行的任务在执行器上持续执行。并行模型很简单:对于每个输入分区,集群的节点上会运行这样一个任务。该任务订阅输入分区并持续处理传入的各个事件。在连续处理查询部署下,集群中会创建一组任务的拓扑结构。

如图 15-2 所示,这种新的执行模型消除了微批处理延迟,并在每个元素处理完毕后立即生成结果。此模型类似于 Apache Flink。

spas 1502

图 15-2. 连续处理延迟

使用连续处理

使用连续处理执行模式所需的所有操作是指定Trigger.Continuous作为trigger并提供一个时间间隔给异步检查点函数,如我们在这个简约示例中所展示的:

import org.apache.spark.sql.streaming.Trigger

val stream = spark.readStream
    .format("rate")
    .option("rowsPerSecond", "5")
    .load()

val evenElements = stream.select($"timestamp", $"value").where($"value" % 2 === 0)

val query = evenElements.writeStream
    .format("console")
    .trigger(Trigger.Continuous("2 seconds"))
    .start()
警告

不要将Trigger.Continuous(<time-interval>)提供的时间间隔与微批次间隔混淆。这是异步检查点操作的时间间隔,由连续查询执行器完成。

限制

尽管在 API 级别没有变化,但在连续模式下支持的查询类型有一些限制。直觉上,连续模式与可以逐元素应用的查询一起工作。在 SQL 术语中,我们可以使用选择、投影和转换,包括 SQL 函数,除了聚合之外。在函数式术语中,我们可以使用filtermapflatMapmapPartitions

在使用聚合和窗口函数时,特别是处理基于事件的数据时,我们必须等待迟到和乱序数据的时间更长。窗口中的时间段及相关概念,如水印,并不能从这种执行模型的低延迟特性中受益。在这种情况下,推荐的方法是退回到基于微批次的引擎,将Trigger.Continuous(<checkpoint-interval>)替换为微批次触发器定义:Trigger.ProcessingTime(<trigger-interval>)

支持任意状态处理,例如[flat]mapGroupsWithState,目前正在开发中。

机器学习

随着可用数据量和其到达速度的增加,传统的信号理解技术成为从数据中提取可操作见解的主要障碍。

机器学习本质上是算法和统计分析技术的结合,用于从数据中学习,并利用这种学习为某些问题提供答案。机器学习使用数据来估计模型,即对世界某个方面的数学表示。一旦确定了模型,就可以在现有或新数据上查询以获得答案。

我们从数据中期望得到的答案的性质将机器学习算法的目标分为三类:

回归

我们希望在连续范围内预测一个值。示例:使用关于学生缺课次数和某课程学习小时数的数据,预测他们在期末考试中的成绩。

分类

我们希望将数据点分为几个类别之一。示例:给定一段文本样本,我们希望估计其语言。

聚类

给定一组元素,我们希望使用某种相似性概念将其划分为子集。示例:在一个在线葡萄酒商店,我们希望将具有相似购买行为的客户分组。

在学习过程中,我们还有监督的概念。当被训练的算法需要将一些观测映射到结果的数据时,我们称之为监督学习。回归和分类技术属于监督学习范畴。以我们之前的考试成绩为例,要建立我们的回归模型,我们需要一个包含学生历史表现数据的数据集,其中包括学生报告的考试成绩、缺席次数和学习时间。获取的数据是机器学习任务中最具挑战性的方面。

学习与利用

我们可以识别出机器学习技术应用中的两个阶段:

  • 学习阶段,其中数据被准备并用于估计模型。这也被称为训练学习

  • 利用阶段,估计模型在新数据上被查询。这个阶段被称为预测评分

机器学习的训练阶段通常使用历史数据集。这些数据集通常被清理并准备用于目标应用。机器学习方法还需要一个验证阶段,其中结果模型将针对已知结果的数据集进行评估,通常称为测试验证集。测试阶段的结果是报告学习模型在训练期间未见过的数据上表现如何的度量指标。

将机器学习模型应用于流数据

正如我们之前提到的,创建机器学习模型通常是一个基于批处理的过程,使用历史数据来训练统计模型。一旦该模型可用,就可以对新数据进行评分,以获取该模型训练的特定方面的估计值。

Apache Spark 统一的结构化API 在批处理、机器学习和流处理中使得将先前训练的模型应用于流式DataFrame变得简单。

假设模型已存储在磁盘上,该过程包括两个步骤:

  1. 加载模型。

  2. 使用其transform方法将模型应用于流式DataFrame

让我们通过一个例子来看看 API 的实际应用。

示例:使用环境传感器估计房间占用情况

在编写本书的这部分内容时,我们一直在使用传感器信息作为主题。到目前为止,我们已经利用传感器数据探索了结构化流处理的数据处理和分析能力。现在,假设我们在一系列房间中放置了这样的环境传感器,但我们不是追踪温度或湿度数据的变化,而是想利用这些信息驱动一个新颖的应用。我们希望通过使用传感器数据来估计某一时刻房间是否被占用。虽然单独的温度或湿度可能不足以确定房间是否在使用中,但也许这些因素的组合能够在一定程度上预测占用情况。

在线资源

对于本例,我们将在书籍的在线资源中使用occupancy_detection_modeloccupancy_streaming_prediction笔记本,位于https://github.com/stream-processing-with-spark

对于本例,我们将使用一个占用数据集,该数据集是为了回答该问题而收集的。该数据集包含以下模式:

 |-- id: integer (nullable = true)
 |-- date: timestamp (nullable = true)
 |-- Temperature: double (nullable = true)
 |-- Humidity: double (nullable = true)
 |-- Light: double (nullable = true)
 |-- CO2: double (nullable = true)
 |-- HumidityRatio: double (nullable = true)
 |-- Occupancy: integer (nullable = true)

训练数据集中的占用信息是通过房间的摄像头图像获得的,以确定其中是否有人员存在。

使用这些数据,我们训练了一个逻辑回归模型,用于估计占用情况,表示为二项结果[0,1],其中 0 = 未占用,1 = 占用。

注意

对于本例,我们假设训练好的模型已经在磁盘上可用。这个例子的完整训练阶段可以在书籍的在线资源中找到。

第一步是加载先前训练的模型:

$ import org.apache.spark.ml._
$ val pipelineModel = PipelineModel.read.load(modelFile)
>pipelineModel: org.apache.spark.ml.PipelineModel = pipeline_5b323b4dfffd

此调用会生成一个包含我们流水线各个阶段信息的模型。

通过调用model.stages,我们可以可视化这些阶段:

$ model.stages
res16: Array[org.apache.spark.ml.Transformer] =
    Array(vecAssembler_7582c780b304, logreg_52e582f4bdb0)

我们的流水线包括两个阶段:VectorAssemblerLogisticRegression分类器。VectorAssembler是一个转换器,将输入数据中选择的字段选择性地转换为数值Vector,作为模型的输入。LogisticRegression阶段是经过训练的逻辑回归分类器。它使用学习到的参数将输入Vector转换为三个字段,这些字段将添加到streaming DataFrame中:rawPredictionprobabilityprediction

对于我们的应用程序,我们感兴趣的是prediction值,该值将告诉我们房间是否正在使用(1)或未使用(0)。

下一步,如示例 15-1 所示,是将模型应用于流DataFrame

示例 15-1. 在结构化流中使用训练好的机器学习模型
// let's assume an existing sensorDataStream
$ val scoredStream = pipeline.transform(sensorDataStream)

// inspect the schema of the resulting DataFrame
$ scoredStream.printSchema
root
  |-- id: long (nullable = true)
  |-- timestamp: timestamp (nullable = true)
  |-- date: timestamp (nullable = true)
  |-- Temperature: double (nullable = true)
  |-- Humidity: double (nullable = true)
  |-- Light: double (nullable = true)
  |-- CO2: double (nullable = true)
  |-- HumidityRatio: double (nullable = true)
  |-- Occupancy: integer (nullable = true)
  |-- features: vector (nullable = true)
  |-- rawPrediction: vector (nullable = true)
  |-- probability: vector (nullable = true)
  |-- prediction: double (nullable = false)

此时,我们有一个包含原始流数据预测的流DataFrame

我们流预测的最后一步是处理预测数据。在这个例子中,我们将限制此步骤,使用内存接收器查询数据,以访问结果数据作为 SQL 表:

import org.apache.spark.sql.streaming.Trigger
val query = scoredStream.writeStream
        .format("memory")
        .queryName("occ_pred")
        .start()

// let the stream run for a while first so that the table gets populated
sparkSession.sql("select id, timestamp, occupancy, prediction from occ_pred")
            .show(10, false)

+---+-----------------------+---------+----------+
|id |timestamp              |occupancy|prediction|
+---+-----------------------+---------+----------+
|211|2018-08-06 00:13:15.687|1        |1.0       |
|212|2018-08-06 00:13:16.687|1        |1.0       |
|213|2018-08-06 00:13:17.687|1        |1.0       |
|214|2018-08-06 00:13:18.687|1        |1.0       |
|215|2018-08-06 00:13:19.687|1        |1.0       |
|216|2018-08-06 00:13:20.687|1        |0.0       |
|217|2018-08-06 00:13:21.687|1        |0.0       |
|218|2018-08-06 00:13:22.687|0        |0.0       |
|219|2018-08-06 00:13:23.687|0        |0.0       |
|220|2018-08-06 00:13:24.687|0        |0.0       |
+---+-----------------------+---------+----------+

鉴于我们使用测试数据集来驱动我们的流,我们还可以访问原始占用数据。在这个有限的样本中,我们可以观察到实际占用和预测大多数但不是所有时间都是准确的。

对于实际应用,我们通常有兴趣将此服务提供给其他应用程序。也许以基于 HTTP 的 API 形式,或通过发布/订阅消息交互。我们可以使用任何可用的接收器将结果写入其他系统以供进一步使用。

模型服务的挑战

训练好的机器学习模型很少是完美的。总是有机会用更多或更好的数据训练模型,或者调整其参数以提高预测准确性。随着训练好的模型不断发展,挑战在于在新模型可用时升级我们的流处理评分过程。

从训练阶段到应用中利用机器学习模型的生命周期管理过程通常被称为模型服务的广义概念。

模型服务包括将训练好的模型转移到生产平台并保持这些在线服务过程与最新训练好的模型保持更新的过程。

结构化流处理中的模型服务

在结构化流处理中,无法更新正在运行的查询。就像我们在示例 15-1 中看到的那样,在我们的流处理过程中将模型评分步骤作为转换包含其中。在启动相应的流查询之后,该声明成为部署的查询计划的一部分,并将在查询停止之前运行。因此,在结构化流处理中直接支持更新机器学习模型是不可能的。然而,可以创建一个管理系统,调用结构化流处理 API 停止、更新和重新启动查询,以便提供新模型。

模型服务的主题在 Spark 社区中是一个持续讨论的话题,并且肯定会在未来版本的 Spark 和结构化流处理中看到进展。

在线训练

在我们早些描述的机器学习过程中,我们区分了学习和评分阶段,其中学习步骤主要是离线过程。在流应用的上下文中,可以随着数据的到来训练机器学习模型。这也称为在线学习。当我们希望适应数据中的变化模式时,例如社交网络的兴趣变化或者金融市场的趋势分析时,在线学习尤为有趣。

在线学习提出了一套新的挑战,因为其实施要求每个数据点只被观察一次,并且必须考虑到观察到的数据总量可能是无限的。

在其当前形式下,结构化流处理不支持在线训练。正在努力实现一些(有限的)结构化流处理在线学习的形式,其中最显著的是霍尔登·卡劳和塞思·亨德里克森以及拉姆·斯里哈沙和弗拉德·费恩伯格

看起来在结构化流处理之上实施在线学习的早期尝试已经失去了动力。这种情况可能会在未来发生改变,因此请关注结构化流处理的新版本,以获取此领域的潜在更新。

第二部分参考文献

  • [Akidau2017_2] Tyler Akidau, Reuven Lax, 和 Slava Chernyak. Streaming Systems. O’Reilly, 2017.

  • [Armbrust2016] Michael Armbrust 和 Tathagata Das. Apache Spark 2.0. O’Reilly, 2016.

  • [Armbrust2017] Michael Armbrust. “使 Apache Spark 成为最快的开源流处理引擎,” Databricks 工程博客, 2017 年 6 月 6 日。http://bit.ly/2EbyN8q.

  • [Armbrust2017b] Michael Armbrust 和 Tathagata Das. “使用 Apache Spark 的结构化流处理进行轻松、可扩展、容错的流处理,” YouTube, 2017 年 6 月 12 日。https://youtu.be/8o-cyjMRJWg.

  • [Chambers2017] Bill Chambers 和 Michael Armbrust. “将 Apache Spark 的结构化流处理投入生产,” Databricks 工程博客, 2017 年 5 月 18 日。http://bit.ly/2E7LDoj.

  • [Damji2017] Jules Damji. “在 Databricks 上使用 Apache Spark 的结构化流处理与 Amazon Kinesis,” Databricks 工程博客, 2017 年 8 月 9 日。http://bit.ly/2LKTgat.

  • [Damji2017b] Jules Damji. “Bay Area Apache Spark Meetup 摘要,” Databricks 工程博客, 2017 年 5 月 26 日。http://bit.ly/2YwDuRW.

  • [Das2017] T. Das, M. Armbrust, T. Condie. “Apache Spark 2.1 中使用结构化流处理进行实时流 ETL,” Databricks 工程博客, 2017 年 2 月 23 日。http://bit.ly/30nCmBX.

  • [Das2017b] Tathagata Das. “Apache Spark 结构化流处理中的事件时间聚合和水印,” Databricks 工程博客, 2017 年 5 月 8 日。http://bit.ly/2VtcAZi.

  • [Karau2016] Holden Karau 和 Seth Hendrickson. “Spark 结构化流处理与机器学习,” StrataNY, 2016 年 11 月 22 日。http://bit.ly/2LXSFSG.

  • [Karau2017_2] Holden Karau. “扩展结构化流处理以支持 Spark ML,” O’Reilly Radar, 2016 年 9 月 19 日。https://oreil.ly/2IZwIQU.

  • [Khamar2017] Kunal Khamar, Tyson Condie 和 Michael Armbrust. “在 Apache Spark 2.2 中使用结构化流处理处理 Apache Kafka 中的数据,” Databricks 工程博客, 2017 年 4 月 26 日。http://bit.ly/2VwOxso.

  • [Koeninger2015_2] Cody Koeninger, Davies Liu, 和 Tathagata Das. “改进 Spark Streaming 对 Kafka 集成,” Databricks 工程博客, 2015 年 3 月 30 日。http://bit.ly/2Hn7dat.

  • [Lorica2016] Ben Lorica. “Apache Spark 2.0 带来的结构化流处理,” O’Reilly Data Show, 2016 年 5 月 19 日。https://oreil.ly/2Jq7s6P.

  • [Medasani2017] Guru Medsani 和 Jordan Hambleton. “使用 Apache Spark Streaming 管理 Apache Kafka 的偏移量,” Cloudera 工程博客, 2017 年 6 月 21 日。http://bit.ly/2w05Yr4.

  • [Narkhede2016] Neha Narkhede, Gwen Shapira, 和 Todd Palino. Kafka: The Definitive Guide. O’Reilly, 2016.

  • [Pointer2016] Ian Pointer,“Spark 的结构化流真正意味着什么”,InfoWorld,2016 年 4 月 7 日。http://bit.ly/2w3NEgW

  • [Sitaula2017] Sunil Sitaula,“Apache Kafka 在 Apache Spark 结构化流中的实时端到端集成”,Databricks Engineering 博客,2017 年 4 月 4 日。http://bit.ly/2JKkCeb

  • [Woodie2016] Alex Woodie,“Spark 2.0 将引入新的‘结构化流’引擎”,Datanami,2016 年 2 月 25 日。http://bit.ly/2Yu5XYEhttp://bit.ly/2EeuRnl

  • [Yavuz2017] Burak Yavuz 和 Tyson Condie,“为了节约 10 倍成本每天运行一次流处理作业”,Databricks Engineering 博客,2017 年 5 月 22 日。http://bit.ly/2BuQUSR

  • [Yavuz2017b] B. Yavuz, M. Armbrust, T. Das, T. Condie,“Apache Spark 2.1 中处理复杂数据格式的结构化流”,Databricks Engineering 博客,2017 年 2 月 23 日。http://bit.ly/2EeuRnl

  • [Zaharia2016] Matei Zaharia, Tathagata Das, Michael Armbrust, 和 Reynold Xin,“Apache Spark 中的结构化流:流处理的新高级 API”,Databricks Engineering 博客,2016 年 7 月 28 日。http://bit.ly/2bM8UAw

  • [Zaharia2016b] Matei Zaharia,“连续应用程序:Apache Spark 2.0 中流式处理的进化”,Databricks Engineering 博客,2016 年 7 月 28 日。http://bit.ly/2bvecOm

  • [Zaharia2017] Matei Zaharia, Tim Hunter, 和 Michael Armbrust,“在 Apache Spark 2.2 及其后续版本中扩展 Apache Spark 的用例”,YouTube,2017 年 6 月 6 日。https://youtu.be/qAZ5XUz32yM

第三部分:Spark Streaming

在这部分中,我们将学习 Spark Streaming。

Spark Streaming 是 Apache Spark 上首个提供的流 API,目前被全球许多公司生产使用。它基于核心 Spark 抽象提供强大且可扩展的功能 API。如今,Spark Streaming 已经成熟和稳定。

我们对 Spark Streaming 的探索从一个实际示例开始,让我们初步感受其 API 用法和编程模型。随着我们在这部分的深入,我们将探索在编程和执行健壮的 Spark Streaming 应用程序中涉及的不同方面:

  • 理解离散流(DStream)抽象

  • 使用 API 和编程模型创建应用程序

  • 使用流源和输出操作消费和生成数据

  • SparkSQL和其他库结合到流应用程序中

  • 了解容错特性及如何创建健壮的应用程序

  • 监控和管理流应用程序

完成这部分后,您将具备使用 Spark Streaming 设计、实现和执行流处理应用程序所需的知识。我们还将为第四部分做好准备,在那里我们将涵盖更高级的主题,如应用概率数据结构进行流处理和在线机器学习。

第十六章:介绍 Spark Streaming

Spark Streaming 是建立在 Spark 分布式处理能力之上的第一个流处理框架。如今,它提供了一个成熟的 API,在行业中被广泛采用来处理大规模数据流。

Spark 是一个通过设计在处理分布在一组机器上的数据方面非常出色的系统。Spark 的核心抽象化是弹性分布式数据集(RDD),其流畅的函数式 API 允许创建将分布式数据视为集合的程序。这种抽象化使我们能够通过对分布式数据集的转换来推理数据处理逻辑。通过这样做,它减少了以前创建和执行可扩展和分布式数据处理程序所需的认知负荷。

Spark Streaming 是基于简单而强大的前提创建的:通过将 Spark 的分布式计算能力应用于流处理,将连续的数据流转换为 Spark 可操作的离散数据集。

正如我们可以在图 16-1 中看到的那样,Spark Streaming 的主要任务是从流中获取数据,将其打包成小批次,并提供给 Spark 进行进一步处理。然后将输出产生到某个下游系统。

spas 1601

图 16-1。Spark 和 Spark Streaming 的实际应用

DStream 抽象化

而结构化流,你在第 II 部分中学到的,将其流能力构建在Spark SQL抽象化的DataFrameDataset之上,而 Spark Streaming 则依赖于更基础的 Spark RDD 抽象化。与此同时,Spark Streaming 引入了一个新概念:离散化流或 DStream。DStream 表示一种数据的离散块,这些块随时间表现为 RDD。正如我们可以在图 16-2 中看到的那样。

spas 1602

图 16-2。Spark Streaming 中的 DStreams 和 RDDs

DStream 抽象化主要是一个执行模型,当与函数式编程模型结合时,为我们提供了一个完整的框架来开发和执行流应用程序。

DStreams 作为编程模型

DStreams 的代码表示给了我们一个与 RDD API 一致的函数式编程 API,并增加了处理聚合、基于时间的操作和有状态计算的流特定函数。在 Spark Streaming 中,我们通过从其中一个原生实现(如 SocketInputStream)创建 DStream 或使用提供特定于流提供程序的 DStream 实现的众多连接器之一来消费流(这是 Kafka、Twitter 或 Kinesis 连接器为 Spark Streaming 提供的情况,仅举几例):

// creates a dstream using a client socket connected to the given host and port
val textDStream = ssc.socketTextStream("localhost", 9876)

获得了一个DStream的引用后,我们可以使用DStream API 提供的函数来实现我们的应用逻辑。例如,如果前面代码中的textDStream连接到一个日志服务器,我们可以统计错误发生的次数:

// we break down the stream of logs into error or info (not error)
// and create pairs of `(x, y)`.
// (1, 1) represents an error, and
// (0, 1) a non-error occurrence.
val errorLabelStream = textDStream.map{line =>
    if (line.contains("ERROR")) (1, 1) else (0, 1)
  }

然后,我们可以通过使用称为reduce的聚合函数计算总数并计算错误率:

// reduce by key applies the provided function for each key.
val errorCountStream = errorLabelStream.reduce {
    case ((x1,y1), (x2, y2))  => (x1+x2, y1+y2)
  }

要获取我们的错误率,我们执行一个安全的除法:

// compute the error rate and create a string message with the value
val errorRateStream = errorCountStream.map {case (errors, total) =>
    val errorRate = if (total > 0 ) errors.toDouble/total else 0.0
    "Error Rate:" + errorRate
  }

需要注意的是,到目前为止,我们一直在 DStream 上使用变换,但尚未进行数据处理。所有 DStream 上的变换都是惰性的。定义流处理应用程序逻辑的过程更好地看作是将在启动流处理后应用于数据的一组变换。因此,这是 Spark Streaming 将在来自源 DStream 的数据上重复执行的操作计划。DStreams 是不可变的。只有通过一系列变换,我们才能处理并从我们的数据中获取结果。

最后,DStream 编程模型要求变换必须以输出操作结束。这种特定操作指定了 DStream 的具体实现方式。在我们的情况下,我们有兴趣将此流计算的结果打印到控制台上:

// print the results to the console
errorRateStream.print()

总结来说,DStream 编程模型由对流载荷进行的变换的功能组合组成,由一个或多个输出操作实现,并由 Spark Streaming 引擎重复执行。

DStreams 作为执行模型

在前面介绍的 Spark Streaming 编程模型中,我们可以看到数据如何从其原始形式转换为我们预期的结果,作为一系列惰性功能变换。Spark Streaming 引擎负责将这些功能变换链转化为实际的执行计划。这是通过从输入流(s)接收数据,将数据收集到批次中,并及时将其传递给 Spark 来实现的。

等待数据的时间度量称为批处理间隔。通常是一个较短的时间,从大约两百毫秒到几分钟不等,具体取决于应用程序对延迟的要求。批处理间隔是 Spark Streaming 中的中心时间单位。在每个批处理间隔内,将前一个间隔对应的数据发送给 Spark 进行处理,同时接收新数据。这个过程在 Spark Streaming 作业处于活动和健康状态时重复进行。这种重复的微批处理操作的自然结果是,在批处理间隔的持续时间内,对批处理数据的计算必须完成,以便在新的微批处理到达时仍然有可用的计算资源。正如本书的这一部分将会介绍的那样,批处理间隔决定了 Spark Streaming 中大多数其他功能的时间。

总结一下,DStream 模型规定数据的连续流通过常规时间间隔(称为批处理间隔)离散化为微批次。在每个批处理间隔,Spark Streaming 将相应时间段内的数据以及要应用的功能转换交给 Spark 处理。然后,Spark 对数据进行计算并生成结果,通常发送到外部系统。Spark 最多有相同的批处理间隔时间来处理数据,否则会出现问题,稍后您将了解到。

Spark Streaming 应用程序的结构

为了对 Spark Streaming 应用程序的结构和编程模型有直观的理解,我们将从一个例子开始。

任何 Spark Streaming 应用程序都需要执行以下四项操作:

  • 创建一个Spark Streaming Context

  • 从数据源或其他 DStreams 定义一个或多个 DStreams

  • 定义一个或多个输出操作,以实现这些 DStream 操作的结果

  • 启动 Spark Streaming Context 以启动流处理。

出于我们例子的考虑,在它运行一段时间后我们将停止这个过程。

作业的行为是在定义流处理上下文实例之后并启动之间定义的操作中定义的。从这个意义上讲,在启动之前对上下文的操作定义了流应用程序的脚手架,其行为和执行将在流应用程序的整个持续期间内定义。

在定义阶段,将定义所有的 DStreams 及其转换,并且“连接”了 Spark Streaming 应用程序的行为。

请注意,一旦启动了 Spark Streaming Context,就不能向其添加新的 DStream,也不能结构上修改任何现有的 DStream。

创建 Spark Streaming Context

Spark Streaming Context 的目标是跟踪在 Spark 集群中创建、配置和处理 DStreams。因此,它根据每个间隔产生的 Spark RDD 创建作业,并跟踪 DStream 的血统。

为了更清楚地了解这个情况,我们将看看如何创建一个流上下文来托管我们的流。在 Spark shell 中,最简单的方法是在 Spark Context 周围包装一个流上下文,而在 shell 中可通过名称sc访问它:

scala> import org.apache.spark.streaming._
import org.apache.spark.streaming._

scala> val ssc = new StreamingContext(sc, Seconds(2))
ssc: org.apache.spark.streaming.StreamingContext =
      org.apache.spark.streaming.StreamingContext@77331101

流处理上下文负责启动延迟到对应的streamingContext实例上调用start()方法的摄入过程。

警告

初学者常见的错误是尝试通过在本地模式中分配一个单核(--master "local[1]")或者在单核虚拟机中启动 Spark Streaming 来测试,接收器消耗数据会阻塞同一机器上的任何 Spark 处理,导致流作业无法进展。

定义一个 DStream

现在让我们声明一个 DStream,它将监听任意本地端口上的数据。单独的 DStream 不会做任何事情。与需要 actions 来实现计算的 RDD 类似,DStreams 需要声明输出操作,以触发 DStream 转换执行的调度。在本例中,我们使用了 count 转换,它计算每个批次间隔接收到的元素数量:

scala> val dstream = ssc.socketTextStream("localhost", 8088)
dstream: org.apache.spark.streaming.dstream.ReceiverInputDStream[String] =
      org.apache.spark.streaming.dstream.SocketInputDStream@3042f1b

scala> val countStream = dstream.count()
countStream: org.apache.spark.streaming.dstream.DStream[Long] =
      org.apache.spark.streaming.dstream.MappedDStream@255b84a9

定义输出操作

对于这个示例,我们使用了 print,这是一个输出操作,每个批次间隔输出 DStream 中的少量元素样本:

scala> countStream.print()

现在,在另一个控制台中,我们可以循环遍历我们的姓氏常见文件,并通过使用一个小型 Bash 脚本将其发送到本地 TCP socket 中:

$ { while :; do cat names.txt; sleep 0.05; done; } | netcat -l -p 8088

这段代码遍历我们拥有的文件,并持续通过 TCP socket 无限发送。

启动 Spark Streaming 上下文

此时,我们已经创建了一个 DStream,声明了一个简单的 count 转换,并使用 print 作为输出操作来观察结果。我们还启动了我们的服务器套接字数据生产者,它正在循环遍历一个名字文件,并将每个条目发送到网络套接字。然而,我们看不到任何结果。要将在 DStream 上声明的转换物化为运行中的进程,我们需要启动 Streaming Context,表示为 ssc

scala> ssc.start()

...
\-------------------------------------------
Time: 1491775416000 ms
\-------------------------------------------
1086879

\-------------------------------------------
Time: 1491775420000 ms
\-------------------------------------------
956881

\-------------------------------------------
Time: 1491775422000 ms
\-------------------------------------------
510846

\-------------------------------------------
Time: 1491775424000 ms
\-------------------------------------------
0

\-------------------------------------------
Time: 1491775426000 ms
\-------------------------------------------
932714

停止流处理过程

在我们最初的 Spark Streaming 探索中的最后一步是停止流程。在停止 streamingContext 后,其范围内声明的所有 DStreams 都将停止,并且不再消耗数据:

scala> ssc.stop(stopSparkContext = false)

无法重新启动已停止的 streamingContext。要重新启动已停止的作业,需要重新执行从创建 streamingContext 实例开始的完整设置。

摘要

在本章中,我们介绍了 Spark Streaming 作为 Spark 中第一个也是最成熟的流处理 API。

  • 我们了解了 DStreams:Spark Streaming 中的核心抽象。

  • 我们探索了 DStream 的函数式 API,并在我们的第一个应用程序中应用它。

  • 我们掌握了 DStream 模型的概念以及如何通过称为间隔的时间度量来定义微批次。

在接下来的章节中,您将更深入地了解 Spark Streaming 中的编程模型和执行方面。

第十七章:Spark Streaming 编程模型

在第十六章中,您学习了 Spark Streaming 的核心抽象——DStream,以及它如何将微批执行模型与函数式编程 API 融合,以提供 Spark 上流处理的完整基础。

在本章中,我们探索了 DStream 抽象提供的 API,该 API 允许以流式方式实现任意复杂的业务逻辑。从 API 的角度来看,DStreams 将他们的大部分工作委托给 Spark 中的底层数据结构——弹性分布式数据集(RDD)。在我们深入了解 DStream API 的细节之前,我们将快速浏览 RDD 抽象。理解 RDD 的概念和 API 对理解 DStreams 的工作方式至关重要。

RDD 作为 DStreams 的基础抽象

Spark 在其 API 和库中只有一个数据结构作为基本元素:RDD。这是一个多态集合,表示一袋元素,其中要分析的数据表示为任意 Scala 类型。数据集分布在集群的执行器上,并使用这些机器进行处理。

注意

自引入 Spark SQL 以来,DataFrameDataset抽象已成为推荐的 Spark 编程接口。在最新版本中,只有库程序员需要了解 RDD API 及其运行时。尽管在接口级别上很少看到 RDD,但它们仍驱动核心引擎的分布式计算能力。

要理解 Spark 的工作原理,强烈建议掌握 RDD 级别编程的基本知识。在接下来的章节中,我们仅涵盖最显著的元素。如果想更深入地了解 RDD 编程模型,请参考[Karau2015]

在使用这些 RDD 时,主要是在 RDD 集合类型上调用函数。该 API 中的函数是高阶函数。从这个意义上讲,使用 Spark 编程核心涉及到函数式编程:实际上,当一个编程语言能够在任何地方定义函数时,它被认为是函数式的:作为参数、作为变量,或更一般地作为语法元素。但更重要的是,在编程语言理论层面上,只有当它能够将函数作为参数传递时,一个语言才成为函数式编程语言。在接下来的示例中,我们将看到 Spark 如何让你使用map的实现来通过将任意函数应用于每个单独元素来转换集合的所有值。

在以下示例中,我们读取一个包含人口普查数据中频繁姓氏的文本文件作为RDD[String](读作字符串的 RDD)。然后,我们使用map操作获取这些名字的长度,该操作将初始名称表示为String并转换为其长度:

scala> val names = sc.textFile("/home/learning-spark-streaming/names.txt")
names: org.apache.spark.rdd.RDD[String] =
      MapPartitionsRDD[1] at textFile at <console>:24
scala> names.take(10)
res0: Array[String] =
      Array(smith, johnson, williams, jones, brown, davis, miller,
            wilson, moore, taylor)
scala> val lengths = names.map(str => str.length )
lengths: org.apache.spark.rdd.RDD[Int] =
      MapPartitionsRDD[3] at map at <console>:27
scala> lengths.take(10)
res3: Array[Int] = Array(6, 6, 6, 8, 9, 7, 7, 7, 6, 6)

Spark 还为我们提供了 reduce 函数,它允许我们将集合的关键元素聚合成另一个结果,通过迭代组合获得。我们还将使用 count 操作,它计算 RDD 中元素的数量。让我们来看一下:

scala> val totalLength = lengths.reduce( (acc, newValue) => acc + newValue )
totalLength: Int = 606623
scala> val count = lengths.count()
count: Int = 88799
scala> val average = totalLength.toDouble / count
average: Double = 6.831417020461942

值得注意的是,reduce 要求 RDD 不为空。否则,它会抛出 java.lang.UnsupportedOperationException 异常,并显示消息:empty collection。虽然我们讨论的是处理大型数据集,这似乎是个极端情况,但在我们想要实时处理传入数据时,这变得必要。

为了克服这个限制,我们可以使用 fold,这是一个类似于 reduce 的聚合器。除了减少函数,fold 还允许我们定义一个初始的“零”元素,以便与聚合函数一起正常工作,即使 RDD 为空。

foldreduce 都使用了一个封闭于 RDD 类型的聚合函数。因此,我们可以对 Int 类型的 RDD 进行求和,或者根据度量计算 RDD 的笛卡尔坐标的 minmax。有些情况下,我们希望返回与 RDD 所表示的数据类型不同的类型。更一般的 aggregate 函数让你确定如何在中间步骤中组合不同的输入和输出类型:

scala> names.aggregate[TAB]
def aggregateU
 (seqOp: (U, T) => U, combOp: (U, U) => U)(implicit scala.reflect.ClassTag[U]): U

scala> names.fold[TAB]
   def fold(zeroValue: T)(op: (T, T) => T): T

此 API 的易用性使得 Spark RDD 被赋予了“终极 Scala 集合”的绰号。这个提到 Spark 原始实现编程语言的引用指向 Scala 的集合库,该库已经让我们能够在单台机器上享受到丰富 API 的函数式编程。它让我们可以从最初的 MapReduce 模型的基本 mapreduce 扩展我们的数据处理词汇。

Spark 的真正的天才之处在于,它复制了 Scala API 的易用性,并将其扩展到可以在计算资源集群上运行。RDD API 定义了两类广泛的函数:转换和操作。转换,如 mapfilter,让我们通过对其 parent 应用转换的结果创建新的 RDD 来处理 RDD 中包含的不可变数据。这些 RDD 转换链形成了一个指向 Spark 原始数据在哪里以及如何将其转换为期望结果的有向无环图或 DAG。所有的转换都是声明式和延迟执行的。这意味着它们不会导致数据实际被处理。要获取结果,我们需要通过发出 action 来实现转换链的材料化。操作触发了数据操作的分布式执行,并产生具体的结果。它可以是写入文件或在屏幕上打印样本。

还有其他的函数式操作,例如 flatMapgroupByzip 也是可用的。你还可以找到 RDD 的组合子,比如 joincogroup,允许你合并两个或多个现有的 RDD。

理解 DStream 转换

DStream 编程 API 包括转换或高阶函数,这些函数以另一个函数作为参数。在 API 层面上,DStream[T] 是一个强类型数据结构,表示类型为 T 的数据流。

DStreams 是不可变的。这意味着我们不能直接在其内容中进行突变。相反,我们通过对输入 DStream 应用一系列转换来实现我们预期的应用逻辑。每个转换都会创建一个新的 DStream,表示从父 DStream 转换后的数据。DStream 转换是惰性的,这意味着在系统需要实现结果之前,底层数据实际上不会被处理。对于 DStreams,这个实现过程是通过触发特定操作(称为输出操作)来产生结果到流式汇中。

注意

对于来自函数式编程背景的读者,显然可以将 DStream 转换视为纯函数,而输出操作则是产生结果到外部系统的具有副作用的函数。

让我们使用我们在介绍中早期使用的代码来复习这些概念:

val errorLabelStream = textDStream.map{ line =>
    if (line.contains("ERROR")) (1, 1) else (0, 1)
  }

这里,textDStream 包含文本行。使用 map 转换,我们对原始的 DStream[String] 中的每一行应用一个相当朴素的错误计数函数,结果得到一个新的 DStream[(Long, Long)],包含 long 元组。在这种情况下,map 是一个 DStream 转换,它接受一个可应用于其内容的函数,本例中是 String,以产生另一个转换后的 DStream。

DStreams 是一种流抽象,其中流的元素在时间维度上被分组为微批次,正如我们在 图 17-1 中展示的那样。每个微批次由一个 RDD 表示。在执行层面上,Spark Streaming 的主要任务是调度和管理数据块的及时收集和传递给 Spark。然后,Spark 核心引擎将应用程序逻辑中的编程操作序列应用于数据。

spas 1701

图 17-1. 映射到 Spark Streaming 上的流模型

回到如何在 API 中反映这一点,我们看到有像经典的 mapfilter 等操作符,这些操作符作用于单个元素。这些操作遵循分布式执行的相同原则,并且遵循批量 Spark 对应项的相同序列化约束。

还有一些操作符,如transformforeachRDD,它们操作的是 RDD 而不是单个元素。这些操作符由 Spark Streaming 调度器执行,并且提供给它们的函数在驱动程序的上下文中运行。在这些操作符的范围内,我们可以实现跨微批次边界的逻辑,如历史记录或维护应用程序级计数器。它们还提供对所有 Spark 执行上下文的访问。在这些操作符内部,我们可以与 Spark SQL、Spark ML 甚至管理流应用程序的生命周期进行交互。这些操作是重复流微批次调度、元素级转换和 Spark 运行时上下文之间真正的桥梁。

使用这种区分,我们可以观察到两种广义上的转换组:

以元素为中心的 DStream 转换

应用于流的单个元素的转换。

以 RDD 为中心的 DStream 转换。

适用于每个微批次的基础 RDD 的转换。

除了这一般分类外,我们还发现 DStream API 中的另外两类转换:

计数转换

专门的操作来统计流中的元素。

修改结构的转换

改变 DStream 的内部结构或组织但不改变内容的转换。

在本章的其余部分,我们详细研究这些转换。

以元素为中心的 DStream 转换

一般来说,DStream API 上的以元素为中心的转换与 RDD API 中同样可用的函数相对应,统一了 Spark 中批处理和流处理模式的开发体验。

最常用的转换如下所示:

注意

每个转换的签名都已经简化,去掉了隐式参数,以使签名更加简洁,适用时。

map

  mapU => U): DStream[U]

在 DStream 上的map函数接受一个函数T => U并将其应用于DStream[T]的每个元素,保持 RDD 结构不变。与 RDD 类似,它是进行大规模并行操作的适当选择,对于它的输入是否与数据的其余部分在特定位置上没有关系。

flatMap

  flatMapU => TraversableOnce[U]): DStream[U]

flatMap通常是map的伴侣,它不返回类型为U的元素,而是返回TraversableOnce容器类型的元素。这些容器在返回之前会合并成一个单一的容器。所有 Scala 集合都实现了TraversableOnce接口,使它们都可以作为此函数的目标类型使用。

flatMap的常见用例是当我们希望从单个元素创建零个或多个目标元素时。我们可以使用flatMap将记录展开为多个元素,或者与Option类型结合使用时,可以用它来过滤不符合某些条件的输入记录。

一个重要的备注是,这个版本的flatMap不遵循严格的单子定义,其定义应该是:flatMapU => DStream[U]): DStream[U]。这经常会让有函数编程背景的新手感到困惑。

mapPartitions

  mapPartitionsU => Iterator[U],
                   preservePartitioning: Boolean = false): DStream[U]

这个函数与 RDD 上定义的同名函数类似,允许我们直接在 RDD 的每个分区上应用map操作。结果是一个新的DStream[U],其中的元素被映射。与 RDD 上定义的mapPartitions调用一样,这个函数非常有用,因为它允许我们具有执行器特定的行为;即,某些逻辑不会为每个元素重复执行,而是每个处理数据的执行器执行一次。

一个经典的例子是初始化一个随机数生成器,然后在每个分区的处理中使用,可以通过Iterator[T]访问。另一个有用的情况是分摊昂贵资源的创建成本,例如服务器或数据库连接,并重复使用这些资源来处理每个输入元素。另一个优点是初始化代码直接在执行器上运行,使我们能够在分布式计算过程中使用不可序列化的库。

filter

filter(filterFunc: (T) => Boolean): DStream[T]

此函数根据作为参数传递的谓词选择 DStream 的一些元素。与map一样,谓词在 DStream 的每个元素上都被检查。请注意,如果在特定批处理间隔期间未接收到验证谓词的元素,则可能生成空 RDD。

glom

glom(): DStream[Array[T]]

这个函数与 RDD 上定义的同名函数类似,允许我们合并数组中的元素。实际上,作为 RDD 上glom调用的结果返回元素数组(与分区数相同),DStream 的等效函数返回对其每个组成 RDD 调用glom函数的结果。

reduce

  reduce(reduceFunc: (T, T) => T): DStream[T]

这是 RDD 上reduce函数的等效版本。它允许我们使用提供的聚合函数聚合 RDD 的元素。reduce接受两个参数的函数,累加器和 RDD 的新元素,并返回累加器的新值。因此,将reduce应用于DStream[T]的结果是相同类型T的 DStream,每个批处理间隔将包含仅一个元素的 RDD:累加器的最终值。特别需要注意的是,使用reduce时应谨慎:它不能单独处理空 RDD,在流应用程序中,始终可能发生空数据批次,例如数据生产或摄取停滞时。

我们可以根据这些不同的转换方式进行总结,根据它们对源 DStream 的操作类型。在 Table 17-1 中,我们可以看到是否有任何操作作用于整个 RDD 而不是逐个元素,以及它是否对输出 RDD 有约束。

表 17-1. 一些关键的 DStream 操作的计算模型和输出

操作 效果 输出 RDD 的结构
map ,filter 逐元素 未改变(与原始元素数量相同)
glom 按分区 与原始分区数相同的数组
mapPartitions 按分区 与原始 RDD 相同数量的分区
reduce ,fold 聚合 一个元素
flatMap 逐元素 输出容器的大小个元素

基于 RDD 的 DStream 转换

这些操作让我们直接访问 DStream 的底层 RDD。这些操作之所以特殊,是因为它们在 Spark 驱动程序的上下文中执行。因此,我们可以访问由 Spark Session(或 Spark context)提供的功能,以及驱动程序程序的执行上下文。在这个本地执行环境中,我们可以访问局部变量、数据存储或 API 调用外部服务,这可能会影响您希望处理数据的方式。

最常用的转换如下:

transform

transformU => RDD[U]): DStream[U]
transformU => RDD[U]): DStream[U]

transform 允许我们重用一个类型为 RDD[T] => RDD[U] 的转换函数,并将其应用于 DStream 的每个组成 RDD。它通常用于利用为批处理作业编写的一些处理,或更简单地在另一个上下文中,产生一个流处理过程。

transform 也有一个定时版本,签名为 (RDD[T], Time) => RDD[U]。正如我们将很快在 foreachRDD 操作中提到的,这对于将 DStream 中的数据与其所属批次的时间标记起来非常有用。

transformWith

transformWithU,V => RDD[V]
): DStream[V]
transformWithU,V => RDD[V]
): DStream[V]

transformWith 让我们可以使用任意转换函数将该 DStream 与另一个 DStream 结合起来。我们可以用它来实现两个 DStream 之间的自定义 join 函数,其中 join 函数不基于键。例如,我们可以应用一个相似性函数,并组合那些足够“接近”的元素。与 transform 类似,transformWith 提供了一个重载,提供对批次时间的访问,以提供一个时间戳机制的不同化入口数据。

计数

因为流的内容通常包含重要的基数数据,例如计算日志中的错误数量或推文中的哈希标签,因此计数是一个频繁操作,在 Spark 中已经被优化到足以支持特定的 API 函数。

有趣的是,尽管 count 在 RDD API 中是一个具有物化操作的函数,因为它直接产生一个结果,在 DStream API 中,它是一个转换操作,生成一个新的 DStream,其每个微批次间隔都带有计数。

Spark Streaming 为给定的 DStream 提供了几个计数函数,最好通过示例来理解。

假设我们有一个由名字按其首字母键控的 DStream,并且该 DStream “重复”自己:每个 RDD,在每个批次间隔中,由每个字母的 10 个不同名字组成:

val namesRDD: RDD[(Char, String)] = ...
val keyedNames: DStream[(Char, String)] =
    ConstantInputDStream(namesRDD, 5s)

这将导致 表 17-2 中显示的结果。

表 17-2. 计数操作

操作 返回类型 结果
keyedNames.count() DStream[Long] 260
keyedNames.countByWindow(60s) DStream[Long] 因为相同的 RDD 每次都重复,所以为 260
keyedNames.countByValue() DStream[((Char, String), Long)] 每个 260 个不同的(第一个字符,名称)对分别为 1
keyedNames.countByKey() DStream[(Char, Long)] 每个 26 个字母分别为 10
keyednames.countByValueAndWindow(60s) DStream[((Char, String), Long)] 每个 260 个不同的(第一个字符,名称)对分别为 12

改变结构的转换操作

前面的操作都是转换操作;也就是说,它们在将函数应用于流的内容后始终返回一个 DStream。还有其他一些不会转换流中数据,而是转换 DStream 结构以及在某些情况下通过 Spark 集群的数据流动:

repartition

repartition(int numPartitions): DStream[T]

repartition 会生成一个具有增加或减少底层 RDD 中分区的新 DStream。重新分区允许我们改变某些流计算的并行特性。例如,当少量输入流提供大量数据时,我们可能希望将其分发到一个大集群进行一些 CPU 密集型计算时增加分区。减少分区可能在写入数据库之前确保有少量分区有大量元素时很有用。注意,提供的参数是目标分区数量的绝对数。此操作是否会增加或减少分区取决于该 DStream 的原始分区数量,而原始分区数量又可能依赖于源的并行性。

与面向数据的转换不同,正确使用 repartition 需要了解输入并行性、分布式计算的复杂性以及流作业将运行的集群大小。

union

union(other: DStream[T]): DStream[T]

union 将两个相同类型的 DStreams 合并为一个流。结果是一个 DStream,其中包含两个输入 DStreams 中找到的元素。另一种用法是在 streamingContext 实例上调用 union 并传入一组要连接的 DStreams。这种形式允许我们一次性合并多个 DStreams:

unionT: DStream[T]

总结

在本章中,我们学习了 DStream 提供的 API,以实现流应用程序。我们看到了以下内容:

  • DStreams 是不可变的,我们通过转换操作来操作它们。

  • 转换是惰性的。它们需要通过特殊的输出操作来实现物化。

  • DStreams 提供了丰富的函数 API,允许对元素进行转换。

  • 一些转换会暴露底层的 RDD,从而提供对丰富的 Spark 核心 API 的全面访问。

在接下来的章节中,您将学习如何通过创建 DStreams 从外部系统获取数据。您还将了解到一组特定的操作,在 Spark Streaming 行话中称为输出操作,这些操作触发对我们数据流的转换执行,并能够将数据生成到其他系统或者辅助存储中。

第十八章:Spark Streaming 执行模型

当我们在第十六章开始我们的 Spark Streaming 之旅时,我们讨论了 DStream 抽象体现了这个流 API 提供的编程和操作模型。在了解了第十七章中的编程模型之后,我们准备理解 Spark Streaming 运行时背后的执行模型。

在本章中,您将了解批处理同步架构以及它如何为我们提供一个微批处理流模型的推理框架。然后,我们探讨了 Spark Streaming 如何使用接收器模型消费数据以及这种模型在数据处理可靠性方面提供的保证。最后,我们探讨了直接 API 作为传送流数据提供者的替代方法,该方法能够提供可靠的数据传输。

批处理同步架构

在第五章中,我们讨论了批处理同步并行BSP模型作为一个理论框架,允许我们推理如何在流的微批处理数据上进行分布式流处理。

Spark Streaming 遵循类似于批处理同步并行的处理模型:

  • 假定集群上的所有 Spark 执行器都有同步时钟;例如,通过网络时间协议(NTP)服务器同步。

  • 对于基于接收器的数据源,一个或多个执行器运行一个特殊的 Spark 作业,一个接收器。这个接收器负责消费流的新元素。它接收两个时钟周期:

    • 最频繁的时钟周期被称为块间隔。它标志着从流接收的元素应该分配到一个块中;也就是说,应该由单个执行器处理的流的部分,对于当前间隔。每个这样的块成为每个批处理间隔产生的 Resilient Distributed Dataset(RDD)的一个分区。

    • 第二个更少频繁的是批处理间隔。它标记着接收器应该在上一个时钟周期以来收集的流数据装配到一起,并为集群上的分布式处理生成一个 RDD。

  • 在使用直接方法时,只有批处理间隔的时钟周期是相关的。

  • 在所有处理过程中,与常规(批处理)Spark 作业一样,块被传递给块管理器,该组件确保放入 Spark 的任何数据块根据配置的持久性级别进行复制,以实现容错性。

  • 每个批处理间隔,上一个批处理间隔接收数据的 RDD 变得可用,并因此计划在本批处理期间进行处理。

图 18-1 展示了这些元素如何在概念上形成一个 DStream。

spas 1801

图 18-1。DStream 结构:块和批次

为了实现与严格的批量同步模型并发执行,这里的障碍是在批处理间隔到达新的RDD。但是在 Spark Streaming 中,这实际上并不是障碍,因为数据传递独立于集群在新批处理到达时的状态:Spark 的接收器不会等待集群完成接收数据才开始新批处理。

这不是设计上的错误;相反,这是 Spark Streaming 试图以最诚实的方式进行实时流处理的结果:尽管具有微批处理模型,但 Spark Streaming 承认流没有预定义的结束,并且系统需要持续接收数据。

这种相对简单模型的结果是,负责接收数据的 Spark Streaming 作业——接收器——需要作为集群中定期工作的作业进行调度。如果它崩溃,将在另一个执行程序上重新启动,继续数据的摄取而无需进一步中断。

接收器模型

正如我们之前暗示的,在 Spark Streaming 中,接收器是能够连续从输入流中收集数据的进程,而不考虑 Spark 集群处理状态的过程。

该组件是流源提供的数据与 Spark Streaming 数据处理引擎之间的适配器。作为适配器,它实现了外部流的特定 API 和语义,并使用内部契约传递该数据。图 18-2 展示了 DStream 数据流中接收器的角色。

spas 1802

图 18-2. 接收器模型

接收器 API

最基本的接收器包括三种方法:

def onStart()

启动来自外部源的数据接收。在实践中,onStart应异步启动入站数据收集过程并立即返回。

def store(...)

将一个或多个数据元素传递给 Spark Streaming。每当有新数据可用时,必须从onStart启动的异步过程中调用store

def stop(...)

停止接收过程。stop必须负责正确清理onStart启动的接收过程使用的任何资源。

该模型提供了一个通用接口,可以实现集成各种流数据提供者。注意通用性如何抽象出流系统的数据传递方法。我们可以实现一个始终连接的基于推送的接收器,如 TCP 客户端套接字,以及基于请求的拉取连接器,如与某些系统的 REST/HTTP 连接器。

接收器工作原理

接收器的任务是从流数据源收集数据并将其传递给 Spark Streaming。直观来说,这很容易理解:随着数据的到来,它们在批处理间隔的时间内被收集并打包成数据块。一旦完成每个批处理间隔时间段,收集的数据块就会交给 Spark 进行处理。

图 18-3 描述了这个事件序列的时间安排。在流处理过程开始时,接收器开始收集数据。在t0间隔结束时,第一个收集的块#0被传递给 Spark 进行处理。在时间t2时,Spark 正在处理在t1收集的数据块,而接收器正在收集对应于块#2的数据。

spas 1803

图 18-3. 接收器的操作

我们可以总结说,在任何时间点,Spark 正在处理上一个数据批次,而接收器正在收集当前间隔的数据。在 Spark 处理完一个批次(如图 18-3 中的#0)后,它可以被清理。RDD 将被清理的时间由 spark.cleaner.ttl 设置决定。

接收器的数据流

图 18-4 描述了这种情况下 Spark 应用程序的数据流。

spas 1804

图 18-4. Spark 接收器的数据流

在这个图中,我们可以看到数据摄入作为一个作业发生,它被转化为一个执行器上的单个任务。此任务处理与数据源的连接并启动数据传输。它由 Spark Context 管理,这是驱动器机器内的一个簿记对象。

在每个块间隔周期(在运行接收器的执行器上测量),这台机器将收到的上一个块间隔的数据分组成一个块。然后将这个块注册给 Block Manager,也存在于 Spark Context 的簿记中,驱动器上。此过程启动了数据复制,以确保 Spark Streaming 中的源数据按照存储级别指示的次数进行复制。

在每个批处理间隔周期(在驱动程序上测量),驱动程序会将接收到的上一个批处理间隔的数据进行分组,已正确复制的数据封装为一个 RDD。Spark 会将这个 RDD 注册给作业调度程序,从而启动对该特定 RDD 的作业调度——事实上,Spark Streaming 微批处理模型的整个核心在于重复调度用户定义的程序来处理连续的批处理 RDD 数据。

内部数据的弹性

接收器是独立的作业这一事实具有后果,特别是对于资源使用和数据传递语义。为执行其数据收集作业,接收器在执行器上消耗一个核心,无论需要做多少工作。因此,使用单个流接收器将导致数据摄取由执行器中的单个核心按顺序完成,这成为限制 Spark Streaming 可以摄取数据量的因素。

Spark 的复制基本单元是块:任何块可以位于一个或多个机器上(最多达到配置中指定的持久性级别,因此默认情况下最多为两个),只有当块达到该持久性级别时,它才能被处理。因此,只有当 RDD 的每个块都复制了,它才能被考虑用于作业调度。

在 Spark 引擎端,每个块都成为 RDD 的一个分区。数据分区和应用于数据的工作的组合成为一个任务。通常每个任务可以并行处理,通常在包含数据的执行器上本地处理。因此,我们可以期望从单个接收器获取的 RDD 的并行性水平正好是批处理间隔与块间隔的比率,如 Equation 18-1 中定义的那样。

Equation 18-1. 单接收器分区

numberpartitions = batchinterval blockinterval

在 Spark 中,任务并行性的通常经验法则是将任务数与可用执行器核心数的比率设置为两到三倍。对于我们的讨论,我们应该将块间隔设置为 Equation 18-2 中所示的值的三倍。

Equation 18-2. 块间隔调整

blockinterval = batchinterval 3*Sparkcores

接收器并行性

我们提到单个接收器将成为 Spark Streaming 可以处理的数据量的限制因素。

增加数据吞吐量的一个简单方法是在代码级别声明更多的 DStreams。每个 DStream 将附加到其自己的消费者上——因此每个消费者将在集群上消耗自己的核心。DStream 操作 union 允许我们合并这些流,确保我们从各个输入流产生单一的数据流水线。

假设我们并行创建 DStreams 并将它们放在一个序列中:

val inputDstreams: Seq[DStream[(K,V)]] = Seq.fill(parallelism: Int) {
... // the input Stream creation function
}
val joinedStream  = ssc.union(inputDstreams)
警告

创建的 DStreams 的union非常重要,因为这样可以将输入 DStream 的转换流水线数量减少到一个。如果不这样做,会导致阶段数量乘以消费者数量,从而造成不必要的开销。

通过这种方式,我们可以利用接收器并行性,这里用并行创建的 DStreams 的 #parallelism 因子来表示。

平衡资源:接收器与处理核心

每个创建的接收器在集群中消耗自己的核心,增加消费者并行性对于可用于集群处理的核心数有影响。

假设我们有一个 12 核心的集群,我们希望将其专用于我们的流分析应用程序。当使用单个接收器时,我们使用一个核心用于接收和九个核心用于处理数据。集群可能会未充分利用,因为单个接收器可能无法接收足够的数据以利用所有可用的处理核心。图 18-5 说明了这种情况,其中绿色节点用于处理,灰色节点保持空闲。

spas 1805

图 18-5. 单接收器分配

为了提高集群利用率,我们增加接收器的数量,正如我们刚才讨论的那样。在我们的假设场景中,使用四个接收器为我们提供了更好的资源分配,如图 18-6 所示。

spas 1806

图 18-6. 多接收器分配

批处理间隔由分析需求确定并保持不变。那么,块间隔应该是多少呢?嗯,同时并行摄取的四个 DStreams 必然会在每个块间隔内创建四倍于单个 DStream 的块数。因此,对于相同的块间隔,联合 DStream 的分区数将是原始情况的四倍。因此,我们不能使用相同的块间隔。相反,我们应该使用以下方案:

blockinterval = 4batchinterval 3Sparkcores

因为我们希望至少有三个分区,所以我们将这个数字向下舍入到最近的毫秒数。

一般来说,根据任意一组特征,我们应该采用以下方法:

blockinterval = receiversbatchinterval partitionspercoreSparkcores

系统中使用的总核心数如下所示:

总系统核心数=接收器数+Spark 核心数

通过预先写日志(WAL)实现零数据丢失

在 Spark v1.2 之前的原始接收器设计存在重大设计缺陷。当接收器为当前块收集数据时,该数据仅存储在接收器进程的内存缓冲区中。只有在块完成并交付后,数据才会在集群中复制。如果接收器失败,缓冲区中的数据将丢失且无法恢复,导致数据丢失。

为了防止数据丢失,接收器收集的数据会额外追加到可靠文件系统上的日志文件中。这被称为预先写日志(WAL),这是数据库设计中常用的组件,用于保证可靠和持久的数据接收。

WAL 是一个仅追加的结构,在数据传送进行处理之前将其写入。当已知数据正确处理时,其日志条目标记为已处理。在数据库世界中,等效的过程是事务提交,其中涉及的数据使得该日志也称为提交日志

在失败情况下,从 WAL 中回放数据,从最后一个注册提交之后的记录开始,以补偿接收器可能的数据丢失。WAL 和接收器的组合被称为可靠接收器。基于可靠接收器模型的流式数据源被称为可靠数据源

启用 WAL

要启用基于 WAL 的数据传递以确保零数据丢失,我们需要应用以下设置:

streamingContext.checkpoint(dir)

此目录用于检查点和预写日志。

spark.streaming.receiver.writeAheadLog.enable(默认值:false

设置为 true 以启用预写日志过程。

请注意,由于写入日志的工作量增加,流作业的总吞吐量可能会降低,而总资源使用量可能会增加。由于 WAL 写入可靠的文件系统,该文件系统的基础设施需要足够的资源来接受对日志的连续写入流,包括存储和处理能力。

无接收器或直接模型

Spark Streaming 致力于成为一个通用的流处理框架。在这个前提下,接收器模型提供了一个通用的、与数据源无关的合约,使得可以集成任何流式数据源。但是一些数据源允许直接消费模型,其中接收器作为数据传递过程中的中介变得不再必要。

Kafka 作为 Spark Streaming 作业的流后端越来越受欢迎,因此引起了额外的关注。在前一节中,我们了解到 WAL 作为解决接收器模型在面对故障时实现零数据丢失的解决方案。

Kafka 的核心是分布式提交日志的实现。当实现了 Kafka 的可靠接收器后,发现使用 WAL(写前日志)只是在重复 Kafka 已有的功能。此外,从 Kafka 消费数据到接收器甚至是不必要的。让我们回顾一下,接收器通过在 Spark 内存中复制块来处理数据冗余。Kafka 已经通过数据复制来保证数据可靠性,并提供了等效的数据耐久性保证。要从 Kafka 消费数据,只需跟踪已处理数据的偏移量,并计算批次间接收到的新数据的偏移量即可。对每个分区消费使用这两个偏移量足以启动一个 Spark 作业,直接消费由这两个偏移量确定的数据段并对其进行操作。当微批处理成功时,消费的偏移量被提交。

直连器模型更像是一个管理者,而不是一个数据经纪人。其角色是计算由 Spark 处理的数据段,并维护待处理数据与已处理数据的簿记。鉴于 Kafka 高性能和低延迟的数据传递特性,这种方法比基于接收器的实现更快,且需要的资源更少。

注意

对于 Kafka 的直连连接器的具体用法,请参阅第十九章。

摘要

到目前为止,我们已经看到了 Spark Streaming 执行模型的基础以及它处理流处理的基本原理:

  • 流是在数据源上看到的随时间聚合的数据。在每个块间隔上,会产生一个新的数据分区并复制。在每个批次间隔(块间隔的倍数)上,生成的数据会组装成 RDD,并可以安排作业在其上运行。

  • 调度由脚本中的用户定义函数完成,但也可以是某些内置功能的副产品(例如检查点)。调度本身具有固定的核心。

  • 创建 DStream 的最通用方式是接收器模型,它在执行器上连接输入源创建作业,消耗一个核心。在某些情况下,通过创建多个 DStream 可以增加并行性。

  • 资源分配和配置参数等因素影响流作业的整体性能,并有调整此类行为的选项。

  • 启用 WAL 可以防止潜在的数据丢失,但会增加额外的资源使用。

  • 对于提供高性能和持久数据传递保证的特定系统,如 Kafka,可以将接收器的责任降低到最小的簿记,以按照流系统的本机术语计算微批次间隔。这种模型称为直连模型,比将数据复制和复制到 Spark 集群内存中更节约资源且性能更高。

第十九章:Spark Streaming 数据源

正如您在 第二章 中学到的,流数据源是持续提供数据的数据提供者。在 Spark Streaming 中,数据源是在 Spark Streaming 作业上下文中运行的适配器,它们实现与外部流数据源的交互,并使用 DStream 抽象将数据提供给 Spark Streaming。从编程的角度来看,消费流数据源意味着使用相应源的实现创建一个 DStream。

在 “DStream 抽象” 中,我们看到了如何从网络套接字消费数据的示例。让我们在 示例 19-1 中重新访问该示例。

示例 19-1. 从套接字连接创建文本流
// creates a DStream using a client socket connected to the given host and port
val textDStream: DStream[String] = ssc.socketTextStream("localhost", 9876)

在 示例 19-1 中,我们可以看到流数据源的创建由专门的实现提供。在这种情况下,由ssc实例,即流上下文提供,结果是一个包含通过套接字传递的文本数据的DStream[String]。尽管每种数据源的实现方式不同,但这种模式对所有数据源都是相同的:创建一个数据源需要一个streamingContext,并且产生一个表示流内容的 DStream。流应用程序进一步在结果 DStream 上操作,以实现所需作业的逻辑。

数据源类型

作为通用的流处理框架,Spark Streaming 可以与各种流数据源集成。

根据操作模式分类,数据源有三种不同类型:

  • 基础

  • 基于接收器的

  • 直接数据源

基础数据源

基本数据源由streamingContext本地提供。它们主要作为示例或测试数据源提供,并且不提供故障恢复语义。因此,在生产系统中不推荐使用它们。

以下是基本的数据源:

文件数据源

用于监视文件系统目录并读取新文件。文件是在系统之间传递数据的广泛机制,特别是在从基于批处理的集成模型(如数据仓库和许多数据湖实现)发展而来的系统中。

队列数据源

本地于streamingContext的生产者-消费者队列,可用于将数据注入到 Spark Streaming 中。通常用于测试。

我们将讨论ConstantInputDStream,它虽然不是官方的数据源,但它执行与Queue数据源类似的功能,并且使用起来更加简单。

基于接收器的数据源

如我们在第十八章中讨论的,接收器是 Spark Streaming 中的特殊进程,负责从流源接收数据,并以 RDD 的形式可靠地将其传递给 Spark。接收器负责实现数据传输的可靠性,即使支持的数据源不能提供此类保证。为此,它们在集群内接收并复制数据,然后才能使其可用于 Spark 进行处理。

在这一类源中,我们还有可靠的接收器,通过使用预写日志(WAL)改进数据接收保证。

从编程模型的角度来看,每个接收器都与一个单一的 DStream 相关联,该 DStream 表示其接收器传递给 Spark Streaming 应用程序的数据。

在分布式环境中扩展接收器的数量,我们创建多个 DStream 实例来消费其数据。

鉴于接收模型是最初在 Spark Streaming 中实现的原始交互模型,所有从最初版本起支持的源都作为基于接收器的源可用,尽管其中许多已被推荐使用直接源取代。最常用的接收器包括:Socket、Kafka、Kinesis、Flume 和 Twitter。

接收器 API 还允许创建自定义源。这促进了 Spark Streaming 的第三方源的增长,也让我们可以创建自己的定制源,例如连接传统企业系统。

直接源

正如我们之前在第十八章中讨论的,有些源(如 Kafka)本地提供强大的数据传输保证,这使得接收器模型对于这些源的数据可靠性变得无关紧要。

直接模型,也称为无接收器模型,是一个轻量级的控制器,负责跟踪与从相应源消费数据相关的元数据,并从中计算微批次,将实际的数据传输和处理留给核心 Spark 引擎。这一简化的流程直接依赖于流源后端的数据传输语义和 Spark 的可靠计算模型。

使用直接方法实现的最流行的数据源是 Kafka 和 Kinesis。

常用的数据源

鉴于 Spark Streaming 的广泛采用,有许多开源和专有的可用源。在本章的其余部分,我们将介绍 Spark 项目提供的最受欢迎的源。

我们从基本源开始,FileQueue源,因为它们非常易于使用,可以提供低门槛的入门体验,用于开始在 Spark Streaming 中开发一些示例。

在回顾了内置的基本源之后,我们转向一个基于接收器的源的例子:socket源,这是一个实现 TCP 客户端套接字的源,可以连接到网络端口上的 TCP 服务器并接收数据。

接下来,我们讨论 Kafka 源,鉴于 Apache Kafka 可能是当前用于构建流系统的最流行的开源事件代理。鉴于 Kafka 的广泛使用,它具有详细和最新的在线覆盖其与 Spark Streaming 集成的内容。在本讨论中,我们强调使用模式作为采用的起点。

我们在这一章中提到了 Apache Bahir,您可以在那里找到更多关于 Spark Streaming 的资源。

文件源

文件源监视文件系统中的给定目录,并在发现目录中的新文件时处理它们。目标文件系统必须与运行 Spark Streaming 的分布式环境兼容和可寻址。常见的存储选择是 Hadoop 分布式文件系统(HDFS)。云块存储系统,如 Simple Storage Service(Amazon S3),也得到支持,尽管需要进一步测试其对报告新文件时的行为的影响。此源非常适用于桥接传统系统,这些系统通常以文件批次的形式交付其结果。

文件源以StreamingContext中的专用方法形式出现。StreamingContext提供了几个版本,具有不断增加的配置选项。

最简单的方法用于从文件系统目录路径加载文本文件流:

val stream: DStream[String] = ssc.textFileStream(path)

类似的方法用于加载包含固定长度记录的二进制文件流:

val stream: DStream[Array[Byte]] = ssc.binaryRecordsStream(path, recordLength)

对于自定义数据格式,文件源的一般形式采用K作为KeyClassV作为ValueClassF作为InputFormatClass的类型。所有这些类型都使用 Hadoop API 定义,该 API 提供了许多常用类型的可用实现。结果是一个与提供的类型定义对应的 key-value 对的 DStream:

val stream: DStream[(K,V)] = ssc.fileStream[K,V,F] (
    directory: String,
    filter: Path => Boolean,
    newFilesOnly: Boolean,
    conf: Configuration)

参数如下所示:

directory: String

要监视的新文件目录。

filter: Path => Boolean

用于评估要处理的文件的谓词。选择仅.log文件的过滤谓词示例如下:

filter = (p:Path) => p.getFileName.toString.endsWith(".log")

newFilesOnly: Boolean

用于指示在流处理开始时是否应考虑监视目录中的现有文件的标志。当newFilesOnlytrue时,将根据示例中指定的时间规则考虑目录中存在的文件。当为false时,将选择作业开始时在监视文件夹中存在的所有文件进行处理。

conf: Configuration

这是一个 Hadoop 配置实例。我们可以使用它来设置特定行为,如行尾字符或特定存储提供程序的凭据。例如,我们可以手动指定一个实现提供程序和凭据,以访问给定的 Amazon S3 存储桶,如下所示:

val s3Conf = new Configuration()
s3Conf.set("fs.s3.impl","org.apache.hadoop.fs.s3native.NativeS3FileSystem")
s3Conf.set("fs.s3.awsAccessKeyId",awsAccessKeyId)
s3Conf.set("fs.s3.awsSecretAccessKey", awsSecretAccessKey)

val stream = ssc.fileStream[K,V,F] (directory, filter, newFilesOnly, s3Conf)

工作原理

每个批次间隔时,文件源会检查监视目录的列表。所有在目录中找到的新文件都会被选中进行处理,作为 RDD 读取,并交给 Spark 进行处理。

文件源如何定义新文件值得特别关注:

  • 每个批次间隔时,会评估目录列表。

  • 文件的年龄由其最后修改时间戳确定。

  • 在处理窗口间隔内具有修改时间戳的文件被视为待处理的文件,并添加到已处理文件列表中。

  • 已处理的文件会在处理窗口间隔的长度内被记忆,这样已经处理过的文件就不会再次被选择。

  • 超过处理窗口间隔的文件会被忽略。如果文件之前已经被记忆,则会从记忆列表中移除,并变为遗忘。我们在图 19-1 中展示了这个过程。

spas 1901

图 19-1. Spark Streaming 文件源在 t0 时的记忆窗口

让我们更仔细地研究图 19-1 中发生的情况:

  • 当前批次时间被标记为 t0

  • 记忆窗口由 n 个微批次组成。

  • 文件 F1 和 F2 处于忽略区域。它们可能在过去已经处理过,但是 Spark Streaming 并不知情。

  • 文件 F3 和 F4 已经被处理,并且目前还在记忆中。

  • 文件 F5 和 F6 是新文件。它们被选中进行处理,并包含在记忆列表中。

当时间推进到下一个批次间隔时,如图 19-2 所示,我们可以观察到 F3 已经变老并成为忽略列表的一部分。新文件 F7 被选中进行处理,并包含在记忆列表中。

spas 1902

图 19-2. Spark Streaming 文件源在 t1 时的记忆窗口

只要 Spark Streaming 进程运行,此过程就会持续下去。我们可以通过设置 spark.streaming.minRememberDuration 来配置记忆窗口的长度,默认为 60 秒。需要注意的是,此过程假定文件系统时钟与运行 Spark Streaming 作业的执行器时钟是同步的。

记忆窗口以微批次的形式计算。尽管配置参数 spark.streaming.minRememberDuration 提供了时间间隔,实际窗口将计算为 ceiling(remember_duration/batch_interval)

例如,使用默认的记忆持续时间 60 秒和批次间隔 45 秒,记忆批次的数量将为 ceil(60/45) = 2。这意味着记忆期的实际持续时间为 90 秒。

警告

File source 不提供任何数据可靠性保证。在使用 File source 的流处理进程重新启动时,恢复语义将基于时钟时间和 Figure 19-2 中记忆窗口的动态。这意味着快速恢复可能会导致重复记录,因为已处理的文件再次符合条件;而如果恢复时间较长,未处理的文件可能会超过记忆窗口的点而变得无效,导致数据丢失。

对于稳健的基于文件的流集成,我们建议使用结构化流和其 File source。

Queue Source

Queue source 是一个程序化的数据源。它不接收来自外部系统的数据。相反,它提供一个生产者-消费者队列,允许创建作为消费者的 DStream,并可以从进程内部作为生产者提供数据。

作为基本数据源,Queue source 由 streamingContext 实例提供:

// ssc is an instance of SparkContext
val queueStream: InputDStream[T] = queueStreamT

这里是参数:

queue: Queue[RDD[T]]

类型为 RDD[T]scala.collection.mutable.Queue。这个队列必须事先创建。它可能已经填充了数据,或者数据可以稍后推送。

oneAtATime: Boolean

一个标志,指示如何处理来自队列的数据。当 oneAtATime = true 时,每个批次间隔将从队列中取出单个 RDD 元素进行处理。当 oneAtATime = false 时,每个批次间隔将一次性消费队列中所有可用的 RDD 元素。

defaultRDD: RDD[T]

一个 RDD 实例,用于在队列为空时提供处理。这个选项可以确保在生产者独立于消费者的情况下始终有数据。当队列为空时,有一个可用的重载,省略了这个参数,此时没有数据可用。

工作原理

Queue source 实现了使用 queue 作为中介的生产者-消费者模式。程序化生产者向队列添加 RDD 数据。消费者端实现了 DStream 接口,并将数据呈现给流处理系统。

Queue source 的主要用例是为 Spark Streaming 程序创建单元测试。测试数据被准备并添加到队列中。测试执行使用与 Queue source 关联的 DStream,并根据预期进行断言。

使用 Queue Source 进行单元测试

例如,假设我们想测试 streamWordCount,这是一个流实现的著名单词计数程序,用于统计数据集中单词的实例。

单词计数的流版本可能如下所示:

val streamWordCount: DStream[String] => DStream[(String, Long)] = stream =>
    stream.flatMap(sentence => sentence.split(","))
          .map(word => (word.trim, 1L))
          .reduceByKey((count1: Long, count2:Long) => count1 + count2)

这是单词计数计算的功能性表示。请注意,我们期望以函数的参数形式而不是给定的 DStream 实现为起点。通过这种方式,我们将 DStream 实例与处理过程分离,允许我们将 queueDStream 作为输入。

要创建queueDStream,我们需要一个queue和一些数据,其中数据必须已经是 RDD:

import scala.collection.mutable.Queue
val queue = new Queue[RDD[String]]() // the mutable queue instance
val data = List(
    "Chimay, Ciney, Corsendonck, Duivel, Chimay, Corsendonck ",
    "Leffe, Ciney, Leffe, Ciney, Grimbergen, Leffe, La Chouffe, Leffe",
    "Leffe, Hapkin, Corsendonck, Leffe, Hapkin, La Chouffe, Leffe"
  )
// we create a list of RDDs, each one containing a String of words
val rdds = data.map(sentence => sparkContext.parallelize(Array(sentence)))
// we enqueue the rdds
queue.enqueue(rdds:_*)

有了数据和队列,我们可以创建queueDStream

val testStream  = ssc.queueStream(queue = queue, oneAtATime = true)

接下来的步骤涉及一种从流输出中提取结果的方法。因为我们正在考虑使用queues,所以我们还使用它来捕获结果:

val queueOut = new Queue[Array[(String, Long)]]()

现在,我们准备定义我们测试的执行:

streamWordCount(testStream).foreachRDD(rdd => queueOut.enqueue(rdd.collect))
ssc.start()
ssc.awaitTerminationOrTimeout(3000) // 3 batch intervals of 1 second

最后,我们可以断言我们收到了期望的结果:

// first batch
assert(queueOut.dequeue.contains("Chimay" -> 2), "missing an expected element")
// second batch
assert(queueOut.dequeue.contains("Leffe" -> 4), "missing an expected element")

队列源的简单替代方法:ConstantInputDStream

ConstantInputDStream允许我们在每个批次间隔内向流提供单个 RDD 值。虽然它并非官方的,但ConstantInputDStream提供了类似于队列源的功能,并且设置起来要容易得多。虽然队列源允许我们以编程方式向流作业提供自定义数据的微批次,但ConstantInputDStream允许我们提供一个将在每个批次间隔内不断重放的单个 RDD:

// ssc is an instance of SparkContext
val constantStream: InputDStream[T] = new ConstantInputDStreamT

这些是参数:

ssc: StreamingContext

这是当前活动的StreamingContext实例。

rdd: RDD[T]

这是每个批次间隔内要重播的 RDD。

它的工作原理

在创建时提供给ConstantInputDStreamRDD实例将在每个批次间隔内重新播放。这创建了一个可以用于测试目的的恒定数据源。

ConstantInputDStream作为随机数据生成器

常常我们需要为测试或仿真目的生成一个随机数据集。正如我们刚学到的,ConstantInputDStream一次又一次地重复相同的 RDD。这种技术的关键在于函数就是值。我们不是创建数据 RDD,而是创建函数的 RDD,这些函数在每个批次间隔内评估,从而在我们的流应用程序中连续生成随机数据流。

对于这种技术,我们需要首先创建一个随机数据生成器,它是从Unit到我们期望类型的函数:() => T。在本例中,我们将生成一个传感器记录流。每个记录都是一个逗号分隔的String,其中包含一个id,一个timestamp和一个value

import scala.util.Random

val maxSensorId = 1000
// random sensor Id
val sensorId: () => Int = () =>  Random.nextInt(maxSensorId)
// random sensor value
val data: () => Double = () => Random.nextDouble
// current time as timestamp in milliseconds
val timestamp: () => Long = () => System.currentTimeMillis
// Generates records with Random data, with 10% of invalid records

val recordGeneratorFunction: () => String = { () =>
   if (Random.nextDouble < 0.9) {
     Seq(sensorId().toString, timestamp(), data()).mkString(",")
   } else {
     // simulate 10% crap data as well… real world streams are seldom clean
     "!!~corrupt~^&##$"
   }
}

通过这个recordGeneratorFunction,我们可以创建一个函数的RDD

// we assume `n` as the number of records delivered by each RDD
val n = 100
val nGenerators = Seq.fill(n)(recordGeneratorFunction)
val sensorDataGeneratorRDD = sparkContext.parallelize(nGenerators )
注意

这种方法的理念在于,在 Scala 中,函数本身就是值。RDD 是值的集合,因此我们可以创建函数的集合。

现在,我们可以使用sensorDataGeneratorRDD创建我们的ConstantInputDStream

import org.apache.spark.streaming.dstream.ConstantInputDStream
// ssc is an active streaming context
val stream: DStream[() => String] =
    new ConstantInputDStream(ssc, sensorDataGeneratorRDD)

注意DStream[() => String]的类型签名。要使值具体化,我们需要评估该函数。考虑到这种转换是 DStream 血统的一部分,它将在每个批次间隔内发生,有效地每次生成新的值:

val materializedValues = stream.map(generatorFunc => generatorFunc())

如果我们正在使用 Spark Shell,我们可以通过使用print输出操作并启动streamingContext来观察这些值:

materializedValues.print() // output operation
ssc.start

-------------------------------------------
Time: 1550491831000 ms
-------------------------------------------
581,1550491831012,0.22741105530053118
112,1550491831012,0.636337819187351
247,1550491831012,0.46327133256442854
!!~corrupt~^&##$
65,1550491831012,0.5154695043787045
634,1550491831012,0.8736169835370479
885,1550491831012,0.6434156134252232
764,1550491831012,0.03938150372641791
111,1550491831012,0.05571238399267886
!!~corrupt~^&##$

这种技术非常有用,可以在开发阶段的早期阶段获得测试数据,而无需花费时间和精力设置外部流系统。

Socket 源

Socket 源作为一个 TCP 客户端,并实现为一个基于接收器的源,接收器进程实例化和管理 TCP 客户端连接。它连接到运行在网络位置上的 TCP 服务器,由其 host:port 组合标识。

Socket 源作为 sparkContext 的一种方法可用。其一般形式如下:

// ssc is an instance of SparkContext
val stream: DStream[Type] =
  ssc.socketStreamType

它具有以下参数:

hostname: String

这是要连接的服务器的网络主机。

port: Int

要连接的网络端口。

converter: (InputStream) => Iterator[Type]

能够将输入流解码为指定目标类型的函数。

storageLevel: StorageLevel

用于接收此源接收的数据的 StorageLevel。一个推荐的起点是 StorageLevel.MEMORY_AND_DISK_SER_2,这是其他源的通用默认值。

也有一个简化版本,用于使用 UTF-8 字符集进行文本流编码。鉴于其简易性,这种替代方案是最常用的:

// ssc is an instance of SparkContext
val stream: DStream[String] = ssc.socketTextStream(host, port)

工作原理

Socket 源实现为一个基于接收器的进程,负责处理套接字连接和相关逻辑以接收数据流。

Socket 源通常用作测试源。由于可以简单地使用诸如 netcat 等命令行实用程序创建网络服务器的简易性,因此 Socket 源已成为 Spark Streaming 可用的许多基本示例的首选源。在这种情况下,通常在同一台机器上运行客户端和服务器,因此使用 localhost 作为 host 指定的常见做法,如以下代码片段所示:

// ssc is an instance of SparkContext
val textStream = ssc.socketTextStream("localhost", 9876)

值得注意的是,仅当 Spark 运行在本地模式时,使用 localhost 才有效。如果 Spark 运行在集群中,则 Socket 源接收器进程将托管在任意执行程序上。因此,在集群设置中,必须正确使用 IP 地址或 DNS 名称以便 Socket 源连接到服务器。

提示

Spark 项目中 Socket 源的实现位于 SocketInputDStream.scala,是开发自定义接收器的良好示例。

Kafka 源

在流平台方面,Apache Kafka 是可伸缩消息代理的最受欢迎选择之一。Apache Kafka 是基于分布式提交日志抽象的高度可伸缩的分布式流平台。

Kafka 实现了发布/订阅模式:客户端(在 Kafka 术语中称为生产者)将数据发布到代理中。消费者使用基于拉取的订阅,这使得不同的订阅者可以以不同的速度消费可用的数据。一个订阅者可能会在数据实时可用时消费数据,而另一个可能会选择随时间取得较大的数据块;例如,当我们想要生成过去一小时数据的报告时。这种特定的行为使得 Kafka 与 Spark Streaming 非常匹配,因为它与微批处理方法互补:更长的微批处理自然会包含更多数据,并且可以增加应用程序的吞吐量,而更短的批处理间隔则会改善应用程序的延迟,但会降低吞吐量。

Kafka 源作为一个单独的库可用,需要在项目的依赖项中导入它才能使用。

对于 Spark 2.4,这是要使用的依赖关系:

groupId = org.apache.spark
artifactId = spark-streaming-kafka-0-10_2.11
version = 2.4.0
警告

请注意,结构化流处理的 Kafka 集成是一个不同的库。确保您使用正确的依赖项来使用 API。

要创建 Kafka 直接流,我们在KafkaUtils中调用createDirectStream,这是此源的实现提供程序:

val stream: InputDStream[ConsumerRecord[K, V]] =
  KafkaUtils.createDirectStreamK, V

以下是类型参数:

K

消息键的类型。

V

消息值的类型。

并且这些是预期的参数:

ssc: StreamingContext

活动的流处理上下文。

locationStrategy: LocationStrategy

用于在执行器上为给定的(topic, partition)调度消费者的策略。选择如下:

PreferBrokers

尝试将消费者调度到与 Kafka brokers 相同的执行器上。这仅在 Spark 和 Kafka 运行在同一物理节点上的不太可能的情况下有效。

PreferConsistent

尝试保留给定的(topic, partition)的消费者-执行器映射。出于性能原因,这一点很重要,因为消费者实现了消息预取。

PreferFixed(map: java.util.Map[TopicPartition, String])

将特定的(topic, partition)组合放置在指定的执行器上。首选策略是LocationStrategies.PreferConsistent。其他两个选项仅在非常特定的情况下使用。注意,位置偏好是一个提示。实际上,分区的消费者可以根据资源的可用性放置在其他位置。

consumerStrategy: ConsumerStrategy[K, V]

消费者策略确定如何选择(topics, partitions)用于消费。有三种不同的策略可用:

Subscribe

订阅一系列命名主题。

SubscribePattern

订阅与提供的regex模式匹配的主题集合。

Assign

提供要消耗的(topics, partitions)的固定列表。请注意,使用此方法时,可能会跳过给定主题的任意分区。只有在要求调用这种严格策略时才使用。在这种情况下,最好计算(topic, partition)分配,而不是依赖静态配置。最常见的consumerStrategySubscribe。我们将在下一个示例中说明使用此策略的 Kafka 源的用法。

使用 Kafka 源

设置 Kafka 源需要我们定义一个配置,并在源的创建中使用该配置。此配置提供为configuration-name, valuemap。以下是配置中的必填元素:

bootstrap.servers

将 Kafka broker 的位置提供为逗号分隔的host:port列表。

key.deserializer

用于将二进制流反序列化为预期键类型的类。Kafka 已经实现了最常见的类型。

value.deserializer

类似于key.deserializer,但用于值。

group.id

要使用的 Kafka 消费者组名称。

auto.offset.reset

当新的消费者组订阅时,分区中的起始点。earliest开始消费主题中所有可用的数据,而latest忽略所有现有数据,并从组加入时的最后偏移开始消费记录。

有许多可以调整的底层 Kafka 消费者参数。要查看所有配置参数的列表,请参阅在线文档

import org.apache.spark.streaming.kafka010._

val preferredHosts = LocationStrategies.PreferConsistent
val topics = List("random")
import org.apache.kafka.common.serialization.StringDeserializer
val kafkaParams: Map[String, Object] = Map(
  "bootstrap.servers" -> "localhost:9092",
  "key.deserializer" -> classOf[StringDeserializer],
  "value.deserializer" -> classOf[StringDeserializer],
  "group.id" -> "randomStream",
  "auto.offset.reset" -> "latest",
  "enable.auto.commit" -> Boolean.box(true)
)

配置就绪后,我们可以继续创建直接的 Kafka 源:

import org.apache.kafka.common.TopicPartition
val offsets = Map(new TopicPartition("datatopic", 0) -> 2L)

val dstream = KafkaUtils.createDirectStreamString, String)

在本例中,我们使用offsets参数为我们的topic指定了初始偏移量,以说明此选项的用法。

如何工作

基于偏移量的 Kafka 直接流功能,这些偏移量是流中元素位置的索引。

数据传递的要点在于,Spark 驱动程序从 Apache Kafka 查询偏移量,并为每个批处理间隔决定偏移范围。收到这些偏移量后,驱动程序通过为每个分区启动任务来分发它们,从而实现 Kafka 分区和工作中的 Spark 分区之间的 1:1 并行性。每个任务使用其特定的偏移范围检索数据。驱动程序不会将数据发送给执行者;相反,它只发送他们用于直接消费数据的几个偏移量。因此,从 Apache Kafka 摄取数据的并行性要比传统的接收器模型好得多,后者每个流都由单台机器消耗。

这对于容错性也更有效,因为那些 DirectStream 中的执行器通过提交偏移量来确认其特定偏移量的数据接收情况。在发生故障时,新的执行器从已知的最新提交偏移量中获取分区数据。这种行为保证了至少一次的数据传递语义,因为输出操作符仍然可以提供已经看到的数据的重播。为了有效地实现恰好一次的语义,我们要求输出操作是幂等的。也就是说,多次执行操作与执行一次操作的结果相同。例如,使用确保记录唯一主键的数据库写入记录,这样如果记录被插入,我们将只找到一个实例。

如何找到更多资源

一些源自 Spark 代码库的起源,以及一些额外的贡献,都转移到了 Apache Bahir,这是一个为多个 Apache Spark 和 Apache Flink 扩展提供支持的项目的总库。

在这些扩展中,我们找到一系列 Spark Streaming 连接器,包括以下内容:

Apache CouchDB/Cloudant

一个 NoSQL 数据库

Akka

Google Cloud Pub/Sub 一个基于云的专有 pub/sub 系统

MQTT

一个轻量级的机器对机器/物联网(IoT)发布/订阅协议

Twitter

一个订阅来自这个流行社交网络推文的来源

ZeroMQ

一个异步消息传递库

要使用 Apache Bahir 库中的连接器,将相应的依赖项添加到项目构建定义中,并使用库提供的专用方法创建流。

第二十章:Spark Streaming 下沉

通过以 DStream 表示的源获取数据,并使用 DStream API 应用一系列转换来实现业务逻辑后,我们希望检查、保存或将结果生成到外部系统。

正如我们在 第二章 中回顾的那样,在我们的通用流处理模型中,负责将数据从流处理过程外部化的组件被称为 sink。在 Spark Streaming 中,使用所谓的 输出操作 实现 sink。

在本章中,我们将探讨 Spark Streaming 通过这些输出操作将数据生成到外部系统的能力和方式。

输出操作

输出操作在每个 Spark Streaming 应用程序中扮演了关键角色。它们需要触发对 DStream 的计算,并通过可编程接口提供对结果数据的访问。

在 图 20-1 中,我们展示了一个通用的 Spark Streaming 作业,它将两个流作为输入,转换其中一个,然后在将结果写入数据库之前将它们连接在一起。在执行时,以 输出操作 结束的 DStream 转换链成为一个 Spark 作业。

spas 2001

图 20-1. 一个 Spark Streaming 作业

该作业连接到 Spark Streaming 调度程序。反过来,调度程序在每个批处理间隔触发定义的作业的执行,如 图 20-2 所示。

spas 2002

图 20-2. Spark Streaming 调度程序

输出操作为以下内容提供了链接:

  • DStreams 上惰性转换的序列

  • Spark Streaming 调度程序

  • 我们将数据生成到的外部系统

从执行模型的角度来看,流程序中声明的每个输出操作都按照程序中声明的顺序连接到 Spark Streaming 调度程序。这种顺序保证了后续的输出操作会在前一个操作执行完成后触发。例如,在以下代码片段中,我们在将媒体资产存储到数据库之前将其添加到可搜索索引中。Spark Streaming 中输出操作的执行顺序保证了新媒体在通过搜索索引变得可搜索之前被添加到主数据库中:

// assume a mediaStream is a DStream[Media],
// Media is the type that contains the meta-data for new audio/video assets

// Save to the assets database
mediaStream.foreachRDD{mediaRDD => assetsDB.store(mediaRDD)}

// Add the new media to an ElasticSearch Index
mediaStream.saveToEs("assets/media")

每个 Spark Streaming 作业至少必须有一个输出操作。这是一个逻辑要求;否则,就无法实现转换或获取结果。此要求在运行时通过调用 sparkStreamingContext.start() 操作来执行。如果流作业未提供至少一个输出操作,将无法启动,并抛出以下错误:

scala> ssc.start()

17/06/30 12:30:16 ERROR StreamingContext:
    Error starting the context, marking it as stopped
java.lang.IllegalArgumentException:
    requirement failed: No output operations registered, so nothing to execute

输出操作,顾名思义,提供对声明在 DStream 上的计算结果数据的访问。我们使用它们来观察结果数据,将其保存到磁盘上,或将其提供给其他系统,例如将其存储在数据库中以供后续查询,或直接发送到在线监控系统进行观察。

总体来说,输出操作的功能是按照批处理间隔指定的时间间隔调度提供的操作。对于采用闭包的输出操作,闭包中的代码在每个批处理间隔都会在 Spark 驱动程序上执行,而不是在集群中分布执行。所有的输出操作都返回Unit;即,它们只执行具有副作用的操作,并且不可组合。流处理作业可以具有尽可能多的输出操作。它们是 DStreams 转换 DAG 的端点。

内置输出操作

Spark Streaming 核心库提供了一些输出操作。我们将在以下小节中看到它们。

print

print()将 DStream 的前几个元素输出到标准输出。当没有参数使用时,它将在每个流间隔中打印 DStream 的前 10 个元素,包括操作运行时的时间戳。还可以调用带有任意数量的print(num: Int),以获得每个流间隔中给定的最大元素数。

鉴于结果仅写入标准输出,print的实际用途仅限于探索和调试流计算,其中我们可以在控制台上看到 DStream 的前几个元素的连续日志。

例如,在我们在第二章中使用的名字网络流中调用print(),我们看到以下内容:

namesDStream.print()
ssc.start()

-------------------------------------------
Time: 1498753595000 ms
-------------------------------------------
MARSHALL
SMITH
JONES
BROWN
JOHNSON
WILLIAMS
MILLER
TAYLOR
WILSON
DAVIS
...

saveAsxyz

saveAsxyz系列的输出操作为流输出提供了基于文件的接收器。以下是可用的选项:

saveAsTextFiles(prefix, suffix)

将 DStream 的内容存储为文件系统中的文件。前缀和可选的后缀用于定位和命名目标文件系统中的文件。每个流间隔生成一个文件。每个生成的文件的名称将是prefix-<timestamp_in_milliseconds>[.suffix]

saveAsObjectFiles(prefix, suffix)

使用标准 Java 序列化将可序列化对象的 DStream 保存到文件中。文件生成的动态与saveAsTextFiles相同。

saveAsHadoopFiles(prefix, suffix)

将 DStream 保存为 Hadoop 文件。文件生成的动态与saveAsTextFiles相同。

foreachRDD

foreachRDD(func)是一个通用的输出操作,它提供对每个流间隔内 DStream 中底层 RDD 的访问。

所有前面提到的其他输出操作都使用foreachRDD来支持它们的实现。我们可以说foreachRDD是 Spark Streaming 的原生输出操作符,而所有其他输出操作都是由它派生出来的。

这是我们实现 Spark Streaming 结果的工具函数,可以说是最有用的本地输出操作。因此,它值得拥有自己的部分。

将 foreachRDD 用作可编程的 Sink

foreachRDD是与通过在 DStream 上声明的转换上处理的数据交互的主要方法。foreachRDD有两种方法重载:

foreachRDD(foreachFunc: RDD[T] => Unit)

作为参数传递的函数接收一个RDD并对其应用具有副作用的操作。

foreachRDD(foreachFunc: (RDD[T], Time) => Unit)

这是一个替代方案,我们还可以访问操作发生的时间,这可以用于区分数据的到达时间的视角。

注意

需要注意的是,foreachRDD方法提供的时间是指处理时间。正如我们在“时间的影响”中讨论的那样,这是流处理引擎处理事件的时间。类型为T的事件包含在RDD[T]中,可能包含有关事件生成时间的其他信息,我们理解为事件时间的时间域。

foreachRDD闭包的内部,我们可以访问 Spark Streaming 的两个抽象层:

Spark Streaming 调度程序

foreachRDD闭包中应用的函数(不操作 RDD)会在 Spark Streaming 调度程序的驱动程序中本地执行。这个层次对于迭代的记账、访问外部 Web 服务以丰富流数据、本地(可变)变量或本地文件系统非常有用。需要注意的是,在这个层次上,我们可以访问SparkContextSparkSession,从而可以与 Spark 的其他子系统(如 Spark SQL、DataFrames 和 Datasets)进行交互。

RDD 操作

应用于闭包函数提供的 RDD 的操作将在 Spark 集群中分布执行。在这个范围内允许所有常规的基于 RDD 的操作。这些操作将遵循典型的 Spark 核心流程,即序列化和在集群中分布执行。

foreachRDD中观察到的常见模式是两个闭包:外部作用域包含本地操作,内部闭包应用于在集群中执行的 RDD。这种二元性经常是混淆的根源,最好通过代码示例学习。

让我们考虑 Example 20-1 中的代码片段,在其中我们希望将传入的数据按照一组 alternatives(任何具体的分类)进行排序。这些备选项会动态变化,并由我们通过外部服务访问的外部 web 服务进行管理。在每个批次间隔中,会向外部服务查询要考虑的备选项集合。对于每个备选项,我们创建一个 formatter。我们使用这个特定的 formatter 来通过分布式 map 转换来转换相应的记录。最后,我们在过滤后的 RDD 上使用 foreachPartition 操作获取一个连接到数据库并存储记录的连接。由于 DB 连接不可序列化,因此我们需要在 RDD 上使用这个特定的构造来在每个执行器上获取本地实例。

注意

这是一个简化版的实际 Spark Streaming 生产作业,负责对许多不同客户的设备的物联网(IoT)数据进行排序。

示例 20-1. foreachRDD 内的 RDD 操作
dstream.foreachRDD{rdd => ![1](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/st-proc-spk/img/1.png)
    rdd.cache() ![2](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/st-proc-spk/img/2.png)
    val alternatives = restServer.get(“/v1/alternatives”).toSet ![3](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/st-proc-spk/img/3.png)
    alternatives.foreach{alternative =>  ![4](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/st-proc-spk/img/4.png)
      val filteredRDD = rdd.filter(element => element.kind == alternative) ![5](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/st-proc-spk/img/5.png)
      val formatter = new Formatter(alternative) ![6](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/st-proc-spk/img/6.png)
      val recordRDD = filteredRDD.map(element => formatter(element))
      recordRDD.foreachPartition{partition => ![7](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/st-proc-spk/img/7.png)
          val conn = DB.connect(server) ![8](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/st-proc-spk/img/8.png)
          partition.foreach(element => conn.insert(alternative, element) ![9](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/st-proc-spk/img/9.png)
      }
    }
    rdd.unpersist(true) ![10](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/st-proc-spk/img/10.png)
}

乍一看,这个 DStream 输出操作中发生了很多事情。让我们把它分解成更易消化的部分:

1

我们在 DStream 上声明了一个 foreachRDD 操作,并使用闭包表示法 f{ x => y } 提供内联实现。

2

我们从缓存提供的 RDD 开始我们的实现,因为我们将多次迭代其内容。(我们在 “缓存” 中详细讨论了缓存。)

3

我们访问 web 服务。这每个批次间隔发生一次,并且执行发生在驱动程序主机上。这在网络层面也很重要,因为执行器可能被“封闭”在私有网络中,而驱动程序可能具有防火墙访问基础设施中的其他服务。

4

我们开始一个循环,迭代接收集合中的值,使我们能够迭代地过滤 RDD 中包含的值。

5

这声明了对 RDD 的 filter 转换。这个操作是惰性的,并且将在集群中以分布式方式进行,当某些动作需要实现时。

6

我们获得一个对可序列化 Formatter 实例的本地引用,我们在第 7 行中用它来格式化上一步骤中过滤的记录。

7

我们在我们过滤后的 RDD 上使用 foreachPartition 操作。

8

我们需要在执行器上有一个本地的数据库驱动程序实例。

9

我们并行插入记录到数据库中。

10

最后,我们unpersist RDD以释放内存供下一次迭代使用。

在 图 20-3 中,我们强调了foreachRDD调用闭包内代码的不同执行范围。简而言之,所有不由 RDD 操作支持的代码在驱动程序上下文中以本地方式执行。对 RDD 调用的函数和闭包在集群中以分布方式执行。我们必须格外注意避免将非可序列化的代码从本地上下文传递到 RDD 执行上下文。这在 Spark Streaming 应用中经常是问题的根源。

spas 2003

图 20-3. foreachRDD闭包的范围

正如我们所看到的,foreachRDD是一个多才多艺的输出操作,我们可以混合和匹配本地和分布式操作,以获得我们计算的结果并在流处理逻辑处理后将数据推送到其他系统。

第三方输出操作

几个第三方库通过在 Scala 中使用库增强模式为特定目标系统添加了对 Spark Streaming 的支持,以添加输出操作到 DStreams。

例如,Datastax 的 Spark-Cassandra Connector 允许在 DStreams 上执行saveToCassandra操作,直接将流数据保存到目标 Apache Cassandra keyspace 和 table 中。由 Ben Fradet 开发的 spark-kafka-writer 提供了类似的构造,dStream.writeToKafka,用于将 DStream 写入 Kafka 主题。

Elasticsearch 通过相同的模式为 Spark Streaming 提供支持。Elasticsearch 库对DStream API 进行了增强,添加了saveToEs调用。更多信息可以在 Elastic 的 Spark 集成指南 中找到。

这些库的实现利用了 Scala 的隐式转换和foreachRDD在幕后提供了用户友好的高级 API,以针对特定的第三方系统,使用户免受与前面解释的foreachRDD中多级抽象相关的复杂细节的困扰。

更多连接器,请参见 SparkPackages

第二十一章:基于时间的流处理

正如我们之前提到的,并且如我们在之前的转换中展示的,Spark Streaming 提供了构建基于时间的数据聚合的能力。与结构化流不同,Spark Streaming 在这一领域的开箱即用能力仅限于处理时间,如果您还记得来自 “时间的影响” 的话,那是流引擎处理事件的时间。

在本章中,我们将探讨 Spark Streaming 的不同聚合能力。虽然它们受限于处理时间域,但它们提供丰富的语义,可以帮助以可伸缩和资源受限的方式处理数据。

窗口聚合

聚合在流数据处理中是一个频繁出现的模式,反映了数据生产者(在输入端)和数据消费者(在输出端)关注点的差异。

如 “Window Aggregations” 中所讨论的,通过时间的数据窗口的概念可以帮助我们创建跨越较长时间段的聚合。Spark Streaming API 提供了在该部分介绍的两个通用窗口概念的定义,滚动滑动窗口,并提供了专门的减少函数,用于在一段时间内执行给定聚合所需的中间内存的限制。

在接下来的页面中,我们将探索 Spark Streaming 的窗口能力:

  • 滚动窗口

  • 滑动窗口

  • 基于窗口的减少

Tumbling 窗口

在 Spark Streaming 中,最基本的窗口定义是对 DStreams 进行的 window(<time>) 操作。这个 DStream 转换创建了一个新的窗口化 DStream,可以进一步转换以实现我们想要的逻辑。

假设一个 hashtags 的 DStream,我们可以使用滚动窗口来实现这一点:

val tumblingHashtagFrequency = hashTags.window(Seconds(60))
                                       .map(hashTag => (hashTag,1))
                                       .reduceByKey(_ + _)

window 操作中,在 mapreduceByKey 步骤之前—我们现在知道它们只是简单地计数—我们正在重新编程我们的 DStream 的分段为 RDD。原始流 hashTags 严格按照批处理间隔进行分段:每批一个 RDD。

在这种情况下,我们正在配置新的 DStream,hashTags.window(Seconds(60)),每 60 秒包含一个 RDD。每当时钟滴答 60 秒,集群资源上都会创建一个新的 RDD,与同一窗口化 DStream 的先前元素无关。从这个意义上说,窗口是滚动的,如 “Tumbling Windows” 中所解释的:每个 RDD 都是由新的“新鲜”元素组成的。

窗口长度与批处理间隔

因为通过将原始流的几个 RDD 的信息合并为窗口流的单个 RDD 来创建窗口化流,窗口间隔必须是批处理间隔的倍数。

自然地,任何初始批处理间隔的倍数都可以作为参数传递。因此,这种分组流使用户可以查询最近一分钟、15 分钟或一小时的数据——更准确地说,是窗口化流计算运行时的第k个间隔。

一个让新用户感到惊讶的重要观察:窗口间隔与流式应用程序的启动时间对齐。例如,给定一个 30 分钟的窗口,批处理间隔为 2 分钟的 DStream,如果流处理作业从 10:11 开始,则窗口间隔将在 10:41、11:11、11:41、12:11、12:41 等时刻计算。

滑动窗口

尽管“在一场著名体育赛事中,每 10 分钟最受欢迎的标签是什么”这样的信息对法医学或未来预测很有趣,但在该事件期间通常不会提出这种问题。检测异常时,也不会使用滚动窗口作为相关的时间框架。在这种情况下,通常需要聚合,因为所观察的值经常有但通常很小的无意义波动,需要通过附加数据点提供的上下文进行分析。股票价格或组件温度具有不应单独观察的小波动。通过观察一系列最近事件,实际趋势变得可见。

在保持部分内容新鲜的情况下,通常有必要查看一种不同类型的聚合,它在相对较长的时间段内呈现数据——滑动窗口:

val slidingSums = hashTags.window(Seconds(60), Seconds(10))
                          .map(hashTag => (hashTag, 1))
                          .reduceByKey(_ + _)

在这个例子中,我们描述了一种不同类型的 DStream,用于计算最频繁的标签:这次,每 10 秒产生一个新的 RDD。在window函数的这种替代版本中,第一个参数名为windowDuration,确定窗口的长度,而第二个参数名为slideDuration,确定我们希望多久观察一次新数据窗口。

回到这个例子,结果窗口化的 DStream 将包含在最新 60 秒数据上的计算结果的 RDD,每 10 秒产生一次。

这种滑动视图使得实现监控应用程序成为可能,而通常在处理后,会看到此类流产生的数据被发送到仪表盘。在这种情况下,仪表盘的刷新率自然与滑动间隔相关联。

滑动窗口与批处理间隔对比

再次强调,由于输出函数的 RDD 是通过合并原始 DStream 的输入 RDD 获得的,因此很明显,滑动间隔需要是批处理间隔的倍数,而窗口间隔需要是滑动间隔的倍数。

例如,使用批处理间隔为 5 秒的流上下文,并且一个名为stream的基本 DStream,表达式stream.window(30, 9)是不合法的,因为滑动间隔不是批处理间隔的倍数。正确的窗口规范应该是stream.window(30, 10)。表达式stream.window(Seconds(40), Seconds(25))同样无效,尽管窗口持续时间和滑动间隔是批处理间隔的倍数。这是因为窗口间隔必须是滑动间隔的倍数。在这种情况下,stream.window(Seconds(50), Seconds(25))是正确的窗口持续时间和滑动间隔的定义。

简而言之,批处理间隔可以看作是窗口化 DStreams 时间间隔的“不可分割原子”。

最后,请注意,滑动窗口的长度必须小于窗口长度,以便计算有意义。如果不遵守这些约束之一,Spark 将输出运行时错误。

滑动窗口与翻滚窗口的区别

滑动窗口的特殊情况是滑动间隔等于窗口长度的情况。在这种情况下,您会注意到流与前面介绍的翻滚窗口情况相当,其中window函数只有windowDuration参数。这恰好对应于window函数在内部实现的语义。

使用窗口化与更长批处理间隔

您可能会想,在简单的翻滚窗口中,为什么需要使用窗口化流以滚动模式,而不是简单地增加批处理间隔:毕竟,如果用户希望按分钟汇总数据,这不正是批处理间隔的用途吗?这种方法有几个反驳的观点:

多重聚合需求

有时用户希望看到以不同增量计算的数据,这需要提取查看数据的两个特定频率。在这种情况下,因为批处理间隔是不可分割的,并且是其他窗口聚合的来源,最好将其设置为所需最小延迟的最大公约数(gcd)。在数学上,如果我们希望多个窗口的数据,比如持续时间为x, y 和 z,我们希望将我们的batch interval设置为它们的最大公约数gcd(x,y,z))。

安全性和局部性

批处理间隔不仅仅是将 DStream 划分为 RDD 的来源。对于基于接收器的数据源,批处理间隔还影响数据的复制和跨网络传输的方式。例如,如果我们有一个包含八台机器的集群,每台机器有四个核心,因为我们可能希望每个核心有大约两个分区,所以我们希望设置块间隔使得每个批次有 32 个块间隔。块间隔决定了 Spark 中接收到的数据在何时被块管理器视为需要复制的时钟周期。因此,当批处理间隔增长时,块间隔也应该相应增长,这样会使系统更容易受到崩溃的影响,可能会影响数据的接收(例如,如果接收机器宕机)。例如,如果批处理间隔为一小时,块间隔为两分钟,那么在接收器崩溃的情况下可能会最多损失两分钟的数据,这取决于数据的频率,可能是不合适的。我们可以通过使用可靠的接收器来减轻这种风险,这些接收器使用预写日志(WAL)来避免数据丢失,但这会增加额外的开销和存储成本。

对于需要一小时聚合的情况,基于每五分钟批处理间隔的源 DStream 使用的滚动窗口为大约 10 秒不到的块间隔,这将降低潜在的数据损失。

总结来说,保持合理大小的批处理间隔可以增加集群设置的弹性。

窗口缩减

在构建复杂的管道的末尾,我们经常希望看到数据的指标,这些指标本质上是依赖于不同时间概念的内容。例如,我们期望看到网站访客数量或者穿过十字路口的汽车数量,分别是过去 15 分钟、过去一小时和前一天的情况。

这三个信息都可以基于窗口化的 DStream 的计数进行计算,我们在本章已经见过了这种情况。尽管基于窗口的函数为我们提供了在不同时间段内生成聚合的基本功能,但它们也要求我们保留指定时间段内的所有数据。例如,要生成 24 小时的聚合,到目前为止我们所知道的窗口函数需要在存储(内存和/或磁盘,具体取决于 DStream 的配置)中保留 24 小时的数据。

想象一下,我们想要在 24 小时内计算用户访问我们网站的总数。我们不需要保留每个单独的记录 24 小时然后再计数。相反,我们可以使用一个运行计数,并在新数据到来时添加。这就是基于窗口的减少的直觉。假设的函数(假定为可结合的)应用于每个新的微批次数据,然后结果添加到窗口 DStream 维护的聚合中。与保留大量数据不同,我们将其作为系统进入的方式进行聚合,提供一种使用最小内存资源的可伸缩聚合。

Spark Streaming 中的窗口化减少函数族结合了一个减少函数和我们之前学习的窗口定义参数。接下来的部分讨论了几种这样的减少函数:

reduceByWindow

reduceByWindow采用一个减少函数,窗口持续时间和滑动持续时间。减少函数必须组合原始 DStream 的两个元素,并产生相同类型的新组合元素。可以省略slideDuration以生成长度为windowDuration的滚动窗口:

 def reduceByWindow(
      reduceFunc: (T, T) => T,
      windowDuration: Duration,
      slideDuration: Duration
    ): DStream[T]

reduceByKeyAndWindow

reduceByKeyAndWindow仅在成对的 DStream 中定义——一个(Key, Value)元组的 DStream。它需要与reduceByWindow类似的参数,但减少函数应用于 DStream 的值。这个操作可能比它的兄弟reduceByWindow更有用,因为我们可以使用键来指示我们正在处理哪些值:

def reduceByKeyAndWindow(
      reduceFunc: (V, V) => V,
      windowDuration: Duration,
      slideDuration: Duration
    ): DStream[(K, V)]

回到标签的例子,我们可以使用reduceByKeyAndWindow函数实现每日标签频率的累加:

val sumFunc: Long => Long => Long = x => y => x+y
val reduceAggregatedSums = hashTags.map(hashTag => (hashTag, 1))
                      .reduceByKeyAndwindow(sumFunc, Seconds(60), Seconds(10))

countByWindow

countByWindowreduceByWindow的一种特殊形式,我们只关心在时间窗口内元素的计数。它回答了这个问题:在给定的窗口中收到了多少事件?

  def countByWindow(
      windowDuration: Duration,
      slideDuration: Duration): DStream[Long]

countByWindow使用我们在“滑动窗口”中定义的windowDurationslideDuration参数。

countByValueAndWindow

countByValueAndWindow是刚才提到的countByWindow操作的分组变体。

def countByValueAndWindow(
      windowDuration: Duration,
      slideDuration: Duration,
      numPartitions: Int = ssc.sc.defaultParallelism)
      (implicit ord: Ordering[T] = null) : DStream[(T, Long)]

countByValueAndWindow将原始 DStream 中的值作为键来计数。这使得我们的标签例子变得相当简单:

val sumFunc: Long => Long => Long = x => y => x+y
val reduceAggregatedSums =
  hashTags.countByValueAndWindow(Seconds(60), Seconds(10))

在内部,它执行类似于我们之前示例的步骤:创建形如(value, 1L)的元组,然后在生成的 DStream 上使用reduceByKeyAndWindow。顾名思义,我们使用countByValueAndWindow来计算原始 DStream 中每个值的出现次数。

它使用我们在“滑动窗口”中定义的windowDurationslideDuration参数。

可逆窗口聚合

reduceByWindowreduceByKeyAndWindow函数包含一个额外的第四个参数作为可选参数。该参数称为逆减少函数。仅当您使用可逆的聚合函数时才重要,这意味着您可以从聚合中“减去”一个元素。

形式上,反函数invReduceFunc满足对于任何累积值 y 和元素 x:invReduceFunc(reduceFunc(x, y), x) = y

在幕后,这种可逆概念使得 Spark 可以简化我们的聚合计算,使 Spark 在每个新滑动窗口上计算几个滑动间隔的元素,而不是整个窗口的全部内容。

例如,假设我们每分钟以批处理间隔聚合整数计数,窗口为 15 分钟,每分钟滑动一次。如果您未指定逆减少函数,则需要在 DStream 上看到的数据上添加每 15 分钟的新计数以汇总数据。我们在图 21-1 中概述了这个过程。

spas 2101

图 21-1. 使用非可逆函数的 reduceByWindow 的聚合

这种方法有效,但是如果我们每分钟看到 100,000 个元素,则每分钟在窗口内求和 1.5 百万数据点——更重要的是,我们需要将这 1.5 百万元素存储在内存中。我们可以做得更好吗?

我们可以记住过去 15 分钟的计数,并考虑我们有新的一分钟数据进入。取得这些前 15 分钟的计数,我们可以减去该 15 分钟聚合中最旧一分钟的计数,因为计数(求和)函数是可逆的。我们减去最旧一分钟的计数,得到 14 分钟的聚合值,然后我们只需添加最新的一分钟数据,就得到了最近 15 分钟的数据。图 21-2 展示了这一过程的工作方式。

spas 2102

图 21-2. 使用可逆函数的 reduceByWindow 的聚合

这件事的有趣之处在于,我们不需要存储 1.5 百万数据点;相反,我们只需要每分钟的中间计数值——也就是说,15 个值。

正如您所看到的,对于窗口聚合来说,拥有可逆的减少函数可以非常有用。这也是我们可以使由reduceByWindow创建的各种 DStream 生成廉价的方法之一,使我们有一个出色的方式来在分析我们的流时共享信息和聚合值,即使是在长时间的聚合期间也是如此。

注意

对于翻滚窗口,聚合函数的反函数是无用的,因为每个聚合间隔完全不重叠。因此,如果您不使用滑动间隔,这个选项就不值得麻烦了!

流分片

最后,请注意 Spark 的 DStreams 还具有一个名为 slice 的选择函数,它返回在两个边界之间包括的 DStream 的特定子部分。您可以使用开始和结束 Time 指定边界,这对应于 Spark 的 org.apache.spark.streaming.Timeorg.apache.spark.streaming.Interval。两者都是使用毫秒作为基本单位的时间算术的简单重新实现,为用户留下了相当高的表达能力。

Spark 将通过让带有正确时间戳的元素通过来生成切片的 DStreams。还要注意,slice 会生成与 DStream 两个边界之间批处理间隔数相同的 RDD。

如果您的切片规范不完全适合批处理怎么办?

如果 RDD 的原始批次时间和切片的输出时间不完全匹配,那么如果开始和结束时间与原始 DStream 的批次间隔 ticks 不对齐,时间的任何变化将在 INFO 级别的日志中反映出来:

INFO Slicing from [fromTime] to [toTime]
  (aligned to [alignedFromTime] and [alignedToTime])

总结

在本章中,我们研究了 Spark Streaming 的能力,可以创建和处理来自 DStream 的数据窗口。现在您可以做到以下几点:

  • 使用 DStream API 表达翻滚窗口和滑动窗口

  • 计算窗口中的元素数量,包括使用数据中的键来分组计数

  • 创建基于窗口的计数和聚合

  • 使用优化的 reduce 版本,利用函数的反可逆性大幅减少内部内存使用量

窗口聚合允许我们观察数据在远远超过批处理间隔的时间段内的趋势。您刚学到的工具使您能够在 Spark Streaming 中应用这些技术。

第二十二章:任意的有状态流计算

到目前为止,我们已经看到 Spark Streaming 如何独立于过去的记录处理传入数据。在许多应用中,我们还对分析与旧数据点相关的数据的演变感兴趣。我们也可能对跟踪由接收到的数据点生成的变化感兴趣。也就是说,我们可能有兴趣使用我们已经看到的数据来构建系统的有状态表示。

Spark Streaming 提供了几个函数,让我们能够构建和存储关于先前看到的数据的知识,并使用该知识来转换新数据。

流的可扩展状态性

函数式程序员喜欢没有状态性的函数。这些函数返回与其函数定义之外的世界状态无关的值,只关心其输入值的值。

然而,一个函数可以是无状态的,只关心它的输入,但同时保持与其计算相关的受管值的概念,而不违反任何有关功能性的规则。其思想是,表示某些中间状态的这个值,在计算的一个或多个参数的遍历中使用,以在遍历参数结构时同时保持一些记录。

例如,在第十七章讨论的reduce操作会沿着给定为参数的任何 RDD 更新一个单一值:

val streamSums = stream.reduce {
  case (accum, x) => (accum + x)
}

在这里,计算每个 RDD 沿输入 DStream 的中间和是通过迭代 RDD 的元素从左到右进行的,并保持累加器变量更新——这是通过返回累加器新值的更新操作指定的(在括号内)。

updateStateByKey

有时计算一些依赖于流的先前元素的结果是有用的,这些先前元素发生在当前批次之前超过一个批次。此类情况的示例包括以下内容:

  • 流的所有元素的运行总和

  • 特定标记值的出现次数

  • 流中遇到的最高元素,给定流元素的特定顺序

这种计算通常可以被看作是一个大的reduce操作的结果,它会在整个流的遍历过程中更新某个计算状态的表示。在 Spark Streaming 中,这由updateStateByKey函数提供:

  def updateStateByKeyS: ClassTag => Option[S]
    ): DStream[(K, S)]

updateStateBykey是仅在键-值对 DStream 上定义的操作。它将一个状态更新函数作为参数。此状态更新函数应具有以下类型:

`Seq[V] -> Option[S] -> Option[S]`

此类型反映了更新操作如何接受一组新值类型V,这些值对应于当前批处理操作期间到达的给定键的所有,以及类型S表示的可选状态。然后,如果有要返回的状态S,则计算并返回新状态值为Some(state);如果没有新状态,则返回None,在这种情况下,删除与此键对应的存储状态的内部表示:

def updateFunction(Values: Seq[Int], runningCount: Option[Int]): Option[Int] = {
    val newCount = runningCount.getOrElse(0) + Values.filter(x => x >5).length
    if (newCount > 0)
      Some(newCount)
    else
      None
}

更新状态函数在每个批次上调用,在执行器处理此流的开始时遇到的所有键上。在某些情况下,这是在以前从未见过的新键上。这是更新函数的第二个参数(状态)为None的情况。在其他情况下,它将在此批次中未收到新值的键上,此时更新函数的第一个参数(新值)为Nil

最后,updateStateByKey函数仅在用户更新要求时返回值(即特定键的新状态快照)。这解释了函数返回类型中的Option:在前面的示例中,只有在实际遇到大于五的整数时,我们才会更新状态。如果特定键仅遇到小于五的值,则不会为此键创建状态,并相应地不进行更新。

图 22-1 描述了使用诸如updateStateByKey之类的状态计算时保留的内部状态动态。流的中间状态保留在内部状态存储中。在每个批次间隔中,内部状态与来自流的新数据使用updateFunc函数组合,产生一个当前状态计算结果的次级流。

spas 2201

图 22-1. 由updateStateByKey产生的数据流动态

状态流的强制检查点

请注意,在为此应用程序启动 Spark Streaming Context 时,Spark 可能会输出以下错误:

java.lang.IllegalArgumentException: requirement failed: 未设置检查点目录。请通过StreamingContext.checkpoint()设置。

这是因为由updateStateByKey在幕后创建的StateStream具有 RDDs,这些 RDDs 本质上各自依赖于先前的 RDD,这意味着在每个批次间隔内重新构建每个标签的部分总和链的唯一方法是重播整个流。这与容错性不兼容,因为我们需要保留接收到的每个记录以便能够在任意时间点重建状态。而不是保留所有记录,我们将状态的中间结果保存到磁盘中。如果处理此流的执行器之一崩溃,则可以从此中间状态中恢复。

幸运的是,错误告诉我们如何使用 ssc.checkpoint("path/to/checkpoint/dir") 来做到这一点。用共享文件系统中的目录替换作为参数传递的 String 的内容,该目录可由驱动程序和作业中的所有执行器访问。

updateStateByKey 的限制

我们迄今为止描述的 updateStateByKey 函数使我们能够使用 Spark Streaming 进行有状态编程。例如,它允许我们对用户会话的概念进行编码——对于这种应用程序,没有特定的批次间隔与之匹配。然而,这种方法存在两个问题。让我们更仔细地看看这些问题。

性能

第一个问题与性能有关:自应用程序框架启动以来,updateStateByKey 函数在遇到的每个键上都运行。这是个问题,因为即使在数据集相对稀疏的情况下——只要数据的种类很多,并且特别是键的种类很多——可以清楚地证明内存中表示的数据总量是无限增长的。

例如,如果在应用程序运行的开始时看到网站上的某个键或特定用户,更新该用户的状态以表示我们自应用程序开始以来(例如,上个月以来)未见过此特定个体的会话的相关性是什么?这对应用程序的好处并不明显。

内存使用

第二个问题是,因为状态不应无限增长,程序员必须自行管理内存的簿记——为每个键编写代码,以确定是否仍然有必要保留该特定元素的状态数据。这是一种需要手动进行内存管理的复杂性。

实际上,对于大多数有状态计算,处理状态是一个简单的操作:要么一个键仍然相关,例如某个用户在一定时间范围内访问了网站,要么在一段时间内未刷新。

引入带有 mapwithState 的有状态计算

mapWithState 是在 Spark 中进行有状态更新的更好模型,它克服了上述两个缺点:对每个键进行更新,并设置默认的超时时间以限制与计算一起创建的状态对象的大小。它是在 Spark 1.5 中引入的:

  def mapWithStateStateType: ClassTag, MappedType: ClassTag: MapWithStateDStream[K, V, StateType, MappedType]

mapWithState 需要您编写一个 StateSpec 函数,该函数操作包含键、可选值和 State 对象的状态规范。尽管这显然更复杂,因为涉及到几种显式类型,但它简化了许多元素:

  • 程序员逐个值操作,而不是作为列表

  • 更新函数可以访问键本身

  • 此更新仅在当前批次中具有新值的键上运行

  • 更新状态是对状态对象方法的命令式调用,而不是生成输出的隐式行为

  • 程序员现在可以独立于状态管理生成输出

  • 该函数具有自动超时功能

图 22-2 展示了使用 mapWithState 函数时的数据流程。

spas 2202

图 22-2. 使用 mapWithState 产生的数据流

如果您希望在每个批次间隔的每个键上看到状态的快照,请在通过 mapWithState 创建的特定 DStream 上调用 .snapshots 函数。

mapWithStateupdateStateByKey:何时使用哪一个

mapWithState 的性能更高且使用更方便,比 updateStateByKey 函数更好,并且通常是处理有状态计算的一个很好的默认选择。然而,一个注意点是,将数据从状态表示中推出(即刷新状态数据)的模型是特定的超时机制,不再由用户控制。因此,如果您希望保持状态处于新鲜条件下(例如,网页用户时间限定会话的点击数),则 mapWithState 尤为适合。对于那些绝对需要保证在长时间内保持小状态的特定情况,我们可以选择 updateStateByKey

一个例子是洪水监控:如果我们处理的是在河流附近特定位置报告水位的传感器,并且我们想要在观察期间保持观察到的最大值,那么使用 updateStateByKey 而不是 mapWithState 可能是有意义的。

使用 mapWithState,我们的流元素计算可以在接收到事件时开始,并在接收到最后几个结构事件后尽快完成。我们将在接下来的几页中看到一个示例。

使用 mapWithState

mapWithState 要求用户提供一个 StateSpec 对象,描述状态计算的工作原理。其中的核心部分是一个函数,该函数接受给定键的新值并返回一个输出,同时更新该键的状态。事实上,对于 StateSpec 的构建对象来说,这个函数是必需的。

这个 StateSpec 是由四种类型参数化的:

  • 键类型 K

  • 值类型 V

  • 状态类型 S,表示用于存储状态的类型

  • 输出类型 U

在其最一般的形式中,StateSpec 构建器 StateSpec.function 需要一个 (Time, K, Option[V], State[S]) => Option[U] 参数,或者如果您不需要 mapWithState 附带的批处理时间戳,则可以使用 (K, Option[V], State[S]) => Option[U]

在这个函数定义中涉及的状态类型可以看作是一个支持超时的可变单元。您可以使用 state.exists()state.get() 进行查询,或者像处理选项一样使用 state.getOption(),通过 state.isTimingOut() 检查是否超时,使用 state.remove() 进行擦除,或使用 state.update(newState: S) 进行更新。

假设我们正在监控一家带传感器的工厂,并且我们想要最后一批的平均温度以及检测异常温度的简单方法。对于这个练习,让我们定义异常温度为高于 80 度:

import org.apache.spark.streaming.State

case class Average(count: Int, mean: Float){
  def ingest(value: Float) =
    Average(count + 1, mean + (value - mean) / (count + 1))
}

def trackHighestTemperatures(sensorID: String,
    temperature: Option[Float],
    average: State[Average]): Option[(String, Float)] = {
  val oldMax = average.getOption.getOrElse(Average(0, 0f))
  temperature.foreach{ t => average.update(oldMax.ingest(t)) }
  temperature.map{
    case Some(t) if t >= (80) => Some(sensorID, t)
    case _ => None
  }
}

val highTempStateSpec = StateSpec.function(trackHighestTemperatures)
                                 .timeout(Seconds(3600))

在这个函数中,我们提取旧的最大值,并同时对最新值进行均值和阈值的聚合,将结果分别路由到状态更新和输出值。这使我们可以利用两个流:

  • temperatureStream.mapWithState(highTempStateSpec),用于跟踪发生的高温

  • temperatureStream.mapWithState(highTempStateSpec).stateSnapshots(),用于跟踪每个传感器的平均温度

如果传感器停止发射 60 分钟,则其状态会被自动移除,从而防止我们担心的状态存储爆炸。请注意,我们可以使用显式的 remove() 函数来实现这一点。

然而,这里存在一个问题:在传感器的前几个值中,我们将传感器的值与一个低默认值进行比较,这可能对每个传感器都不合适。我们可能会检测到温度峰值,读取可能对这个特定传感器来说是完全适当的值,只是因为我们还没有它的值。

在这种情况下,我们有机会为我们的传感器提供初始值,使用 highTempStateSpec.initialState(initialTemps: RDD[(String, Float)])

使用 mapWithState 进行事件时间流计算

mapWithState 的一个辅助好处是,它让我们能够有效且明确地在其 State 对象中存储过去的数据。这在执行事件时间计算时非常有用。

实际上,在流系统中“在线”看到的元素可能会无序到达、延迟到达,甚至与其他元素相比异常地快。因此,确保我们处理的数据元素确实是在特定时间生成的唯一方法是在生成时为它们打上时间戳。例如,在我们之前的例子中,我们试图检测温度的增幅的温度流中,如果某些事件以相反的顺序到达,则可能会混淆温度的增加和温度的减少。

注意

尽管结构化流本地支持事件时间计算,就像我们在 第十二章 中看到的那样,但是你可以使用这里描述的技术在 Spark Streaming 中以编程方式实现它。

然而,如果我们的目标是按顺序处理事件,我们需要能够通过读取流上数据元素的时间戳来检测和反转错序。为了进行这种重新排序,我们需要对我们的流上可能看到的延迟的数量级有一个概念(或者说上下限)。确实,如果没有这种重新排序范围的界限,我们将需要无限等待才能计算特定时间段的最终结果:我们始终可以收到另一个可能已被延迟的元素。

为了实际处理这个问题,我们将定义一个水印,即我们将等待滞后元素的最长时间。按照 Spark Streaming 时间概念,它应该是批处理间隔的倍数。超过这个水印后,我们将“封存”计算结果,并忽略延迟超过水印的元素。

警告

处理这种错序的自然方法可能是窗口化流:定义一个与水印相等的窗口间隔,并使其每次滑动一个批次,定义一个按时间戳排序元素的转换。

这在一定程度上是正确的,因为一旦超过第一个水印间隔,它将导致对排序元素的正确视图。然而,它要求用户接受等于水印的初始延迟才能看到计算结果。然而,对于像 Spark Streaming 这样已经因其微批处理方法而产生高延迟的系统来说,可能会看到一个比批处理间隔高一个数量级的水印延迟,这种延迟是不可接受的。

一个良好的事件时间流处理解决方案将允许我们基于流事件的临时视图进行计算,然后在延迟元素到达时更新此结果。

假设我们有一个循环缓冲区的概念,即一个固定大小为k的向量,它包含它接收到的最近的k个元素:

import scala.collection.immutable

object CircularBuffer {
  def empty[T](): CircularBuffer[T] = immutable.Vector.empty[T]
}

implicit class CircularBufferT extends Serializable {
  val maxSize = 4
  def get(): Vector[T] = v
  def addItem(item : T) : CircularBuffer[T]  =
    v.drop(Math.min(v.size, v.size - maxSize + 1)) :+ item
}

这个对象保持一个内部向量,至少有一个,最多有maxSize个元素,选择其中最近添加的元素。

现在假设我们正在跟踪最近四批次的平均温度,假设批处理间隔为五毫秒:

import org.apache.spark.streaming.State

def batch(t:Time): Long = (t.milliseconds % 5000)

def trackTempStateFunc(
  batchTime: Time,
  sensorName: String,
  value: Option[(Time, Float)],
  state: State[CB]): Option[(String, Time, Int)] = {

  value.flatMap { (t: Time, temperature: Float) =>
     if ( batch(t) <= batch(batchTime)) { // this element is in the past
      val newState: CB =
        state.getOption.fold(Vector((t, Average(1, temperature))): CB){ c =>
          val (before, hereOrAfter) =
            c.get.partition{case (timeStamp, _) => batch(timeStamp) < batch(t) }
          (hereOrAfter.toList match {
            case (tS, avg: Average) :: tl if (batch(tS) == batch(t)) =>
              (tS, avg.ingest(temperature)) ::tl
            case l@_ => (t, Average(1, temperature)) :: l
          }).toVector.foldLeft(before: CB){ case (cB, item) => cB.addItem(item)}
        }
      state.update(newState) // update the State
      // output the new average temperature for the batch that was updated!
      newState.get.find{ case (tS, avg) => batch(tS) == batch(t) }.map{
        case (ts, i) => (key, ts, i)
      }
    }
    else None // this element is from the future! ignore it.
  }
}

在这个函数中,我们的State是包含每个批次平均值的四个单元格集合。这里我们使用mapWithState的变体,该变体将当前批次时间作为参数。我们使用batch函数以使批处理比较变得合理,即如果t1t2在同一批次内,则我们期望batch(t1) == batch(t2)

我们从检查我们的新值及其事件时间开始。如果事件时间的批次超出当前批次时间,那么我们的壁钟或事件时间存在错误。对于这个例子,我们返回None,但我们也可以记录一个错误。如果事件是过去的,我们需要找出它属于哪个批次。为此,我们在我们的CircularBuffer状态的每个单元格的批次上使用 Scala 的分区函数,将来自我们元素之前批次的元素与来自相同批次或之后批次的元素分开。

然后,我们查看是否已为我们的事件批次初始化了平均值,我们应该在后面列表的头部找到它(感谢我们的分区)。如果有的话,我们将新的温度添加到其中;否则,我们将我们的单个元素取平均值。最后,我们将当前元素时间之前的批次和所有后置批次按顺序添加到其中。CircularBuffer本身确保如果超过我们的阈值(四个),则仅保留最新的元素。

作为最后一步,我们查找我们用新元素更新的单元格上的更新后的平均值(如果确实有的话,我们可能已经更新了一个过时的元素),如果是这样,则输出新的平均值。因此,我们可以在 RDD 的(String, (Time, Float))元素上创建mapWithState流(传感器名称作为键,时间戳温度作为值),更新我们收到的最后更新的平均值,从第一个批次开始。

在处理我们的CircularBuffer内容时,自然地,它使用了线性时间,这是我们通过这个例子想要达到的简单性的结果。然而,请注意,我们正在处理一个按时间戳排序的结构,不同的数据结构比如跳表将使我们在处理速度上获得很大提升,并使其可扩展。

总之,mapWithState凭借其强大的状态更新语义、简洁的超时语义以及snapshots()带来的多功能性,为我们提供了一个强大的工具,在几行 Scala 代码中表示基本的事件时间处理。

第二十三章:使用 Spark SQL

到目前为止,我们已经看到了 Spark Streaming 如何作为一个独立的框架来处理多源流,并生成可以进一步发送或存储以供后续使用的结果。

孤立的数据具有有限的价值。我们经常希望合并数据集以探索仅在合并来自不同来源的数据时才显现的关系。

在流数据的特定情况下,我们在每个批处理间隔看到的数据仅仅是潜在无限数据集的一个样本。因此,为了增加在给定时间点观察到的数据的价值,我们必须有办法将其与我们已经拥有的知识结合起来。这可能是我们在文件或数据库中拥有的历史数据,是基于前一天数据创建的模型,或者甚至是早期流数据。

Spark Streaming 的一个关键价值主张是其与其他 Spark 框架的无缝互操作性。这种 Spark 模块之间的协同作用增加了我们可以创建的面向数据的应用程序的范围,导致应用程序的复杂性低于自行组合任意——而且通常是不兼容——库的复杂性。这转化为增加的开发效率,进而提高了应用程序提供的业务价值。

在本章中,我们探讨了如何将 Spark Streaming 应用程序与 Spark SQL 结合使用。

注意

正如我们在第二部分中看到的,结构化流处理是 Spark 中使用 Spark SQL 抽象进行流处理的本地方法。本章描述的技术适用于当我们有一个 Spark Streaming 作业时,希望为特定目的使用 Spark SQL 函数。

要进行纯 Spark SQL 方法的流处理,请首先考虑结构化流处理。

Spark SQL

Spark SQL 是与结构化数据一起工作的 Spark 模块。它实现了传统数据库领域中通常找到的函数和抽象,如查询分析器、优化器和执行计划器,以在 Spark 引擎之上对任意结构化数据源进行类似表格的操作。

Spark SQL 引入了三个重要的特性:

  • 使用 SQL 查询语言来表示数据操作

  • Datasets,一种类型安全的数据处理领域特定语言(DSL),类似于 SQL

  • DataFrames,Datasets 的动态类型对应物

本章的目的是假设读者对 Spark SQL、Datasets 和 DataFrames 有所了解。要深入了解 Spark SQL,请参阅[Karau2015]

结合 Spark Streaming 和 Spark SQL,我们可以从 Spark Streaming 作业的上下文中获得 Spark SQL 的重要数据操作能力。我们可以使用从数据库加载的数据框架来高效地丰富传入流,或者使用可用的 SQL 函数进行高级摘要计算。我们还可以将传入或生成的数据写入支持的写入格式之一,如 Parquet、ORC,或通过 Java 数据库连接(JDBC)写入外部数据库。可能性是无限的。

正如我们在第二部分中看到的,Apache Spark 提供了一个使用 Dataset/DataFrame API 抽象和概念的本机流 API。当涉及到利用 Spark 的本机 SQL 功能时,结构化流应该是我们的首选。但是,在需要从 Spark Streaming 上下文中访问这些功能的情况下,也存在一些情况。在本章中,我们探讨了在需要将 Spark Streaming 与 Spark SQL 结合使用时可以使用的技术。

从 Spark Streaming 访问 Spark SQL 函数

通过增强 Spark Streaming 与 Spark SQL 的结合,最常见的用例是访问查询功能,并将结构化数据格式的写入访问到支持的格式,如关系数据库、逗号分隔值(CSV)和 Parquet 文件。

示例:将流数据写入 Parquet

我们的流数据集将包含我们运行的传感器信息,包括sensorId、时间戳和值。在这个自包含示例中,为了简化起见,我们将使用模拟真实物联网(IoT)用例的场景生成一个随机数据集。时间戳将是执行时间,每个记录将被格式化为来自逗号分隔值字段的字符串。

在线资源

对于这个例子,我们将使用书籍在线资源中的enriching-streaming-data笔记本,位于https://github.com/stream-processing-with-spark

我们还向数据中添加了一点真实世界的混乱:由于天气条件的影响,一些传感器发布了损坏的数据。

我们首先定义我们的数据生成函数:

import scala.util.Random
// 100K sensors in our system
val sensorId: () => Int = () =>  Random.nextInt(100000)
val data: () => Double = () => Random.nextDouble
val timestamp: () => Long = () => System.currentTimeMillis
val recordFunction: () => String = { () =>
  if (Random.nextDouble < 0.9) {
    Seq(sensorId().toString, timestamp(), data()).mkString(",")
  } else {
    "!!~corrupt~^&##$"
  }
}

> import scala.util.Random
> sensorId: () => Int = <function0>
> data: () => Double = <function0>
> timestamp: () => Long = <function0>
> recordFunction: () => String = <function0>
注意

我们使用一个特别的技巧,需要稍微注意一下。注意前面示例中的值是函数。我们不是创建文本记录的 RDD,而是创建生成记录函数的 RDD。然后,每次评估 RDD 时,记录函数将生成一个新的随机记录。这样我们就可以模拟出一个真实的随机数据负载,每个批次都会提供不同的数据集。

val sensorDataGenerator = sparkContext.parallelize(1 to 100)
                                      .map(_ => recordFunction)
val sensorData = sensorDataGenerator.map(recordFun => recordFun())

> sensorDataGenerator: org.apache.spark.rdd.RDD[() => String] =
  MapPartitionsRDD[1] at map at <console>:73
> sensorData: org.apache.spark.rdd.RDD[String] =
  MapPartitionsRDD[2] at map at <console>:74

我们抽样一些数据:

sensorData.take(5)

> res3: Array[String] = Array(
                !!~corrupt~^&##$,
                26779,1495395920021,0.13529198017496724,
                74226,1495395920022,0.46164872694412384,
                65930,1495395920022,0.8150752966356496,
                38572,1495395920022,0.5731793018367316
)

然后我们创建流处理上下文:

import org.apache.spark.streaming.StreamingContext
import org.apache.spark.streaming.Seconds

val streamingContext = new StreamingContext(sparkContext, Seconds(2))

我们的流源将是由生成记录的 RDD 提供的ConstantInputDStream。通过将ConstantInputDStream与生成记录的 RDD 结合使用,我们在示例中创建了一个自动生成的流,用于处理新鲜的随机数据。这种方法使示例自包含,不需要外部流生成过程:

import org.apache.spark.streaming.dstream.ConstantInputDStream
val rawDStream  = new ConstantInputDStream(streamingContext, sensorData)

我们必须为我们的流数据提供模式信息。

现在我们有了以两秒间隔处理的新数据的 DStream,我们可以开始专注于此示例的主要内容。首先,我们想要定义并应用于我们接收到的数据的模式。在 Scala 中,我们使用case class定义模式,如下所示:

case class SensorData(sensorId: Int, timestamp: Long, value: Double)

> defined class SensorData

现在,我们需要使用flatMap函数将模式应用于 DStream:

注意

我们使用flatMap而不是map,因为可能存在传入数据不完整或损坏的情况。

如果我们使用map,则需要为每个转换后的记录提供一个结果值。这对于无效记录是无法做到的。使用flatMap结合Option,我们可以将有效记录表示为Some(recordValue),无效记录表示为None。通过flatMap,内部的Option容器被展平,因此我们的结果流将仅包含有效的recordValue

在解析逗号分隔记录时,我们不仅防止丢失字段,还将数值类型解析为其期望的类型。周围的Try捕获可能由无效记录引起的任何NumberFormatException

import scala.util.Try
val schemaStream = rawDStream.flatMap{record =>
  val fields = record.split(",")
  // this Try captures exceptions related to corrupted input values
  Try {
    SensorData(fields(0).toInt, fields(1).toLong, fields(2).toDouble)
  }.toOption
}

> schemaStream: org.apache.spark.streaming.dstream.DStream[SensorData] =
    org.apache.spark.streaming.dstream.FlatMappedDStream@4c0a0f5

保存数据帧

有了模式流之后,我们可以继续将底层的 RDD 转换为数据帧(DataFrames)。我们在通用操作foreachRDD的上下文中执行此操作。此时无法使用转换,因为DStream[DataFrame]未定义。这也意味着我们希望应用于数据帧(或数据集)的任何进一步操作都需要包含在foreachRDD闭包的范围内,如下所示:

import org.apache.spark.sql.SaveMode.Append
schemaStream.foreachRDD{rdd =>
  val df = rdd.toDF()
  df.write.format("parquet").mode(Append).save("/tmp/iotstream.parquet")
}

最后,我们启动流处理过程:

streamingContext.start()

然后,我们可以检查目标目录,查看数据流入 Parquet 文件的情况。

现在,让我们转到 URL http://<spark-host>:4040 查看 Spark 控制台。在那里,您可以看到 SQL 和 Streaming 选项卡都存在,如图 23-1 所示。特别是,我们对 Streaming 选项卡感兴趣。

spas 2301

图 23-1. 将流保存为 Parquet

在图 23-1 中,您可以看到 Parquet 写入时间随时间快速增加。随着时间的推移,向 Parquet 文件追加操作变得更加昂贵。对于像 Spark Streaming 作业这样的长期运行进程,解决此限制的一种方法是定期向新文件追加。

在例子 23-1 中,我们每小时(3,600 秒)更改文件的后缀,从流作业启动的时刻开始计算。

示例 23-1. 为文件目的地添加时间戳
def ts: String = ((time.milliseconds - timeOrigin)/(3600 * 1000)).toString
df.write.mode(SaveMode.Append).format("parquet").save(s"${outputPath}-$ts")
注意

在流处理界面中,您可能会注意到输入速率图表仍然保持零平面。该图表从接收器实现中收集信息。因为我们使用的是 ConstantInputDStream,所以对于此图表,没有实际的传入记录计数。

处理静态数据

在设计和实现流处理应用程序时,通常会出现一个问题,即如何使用现有数据来丰富流中的事件。这些“静态”的数据可以是文件中的历史记录、查找表、数据库中的表,或者是我们可以用来增强“在途”数据的任何其他静态数据。

我们可以利用 Spark SQL 的能力加载“静态”数据集,这些数据集可以与传入的流数据进行合并。在这种情况下,Spark SQL 的一个关键优势是加载的数据是结构化的。这减少了流处理开发人员在执行连接操作之前需要投入的准备数据格式的工作量。

此示例说明了在 DataFrame 形式的固定数据集和我们应用程序连续消耗的流数据上使用 join 操作的用法。

使用连接操作来丰富输入流

在我们之前的示例中,我们处理了传入的传感器数据流,将其解析为有效的记录,并直接存储到 Parquet 文件中。但是要解释报告数据点中的值,我们首先需要了解传感器类型、其工作范围以及每个记录值的单位。

在我们的物联网应用程序中,传感器在部署之前会首先进行注册。注册过程捕获了我们需要的信息。幸运的是,这些信息以 CSV 文件的形式导出,我们可以在我们的流处理应用程序中导入,正如我们在 图 23-2 中所看到的。

注意

对于这个示例,我们只讨论与前一个程序的相关差异。完整的笔记本可在线查看,供您自行探索。

让我们定义一些常量,指定我们数据的位置:

val sensorCount = 100000
val workDir = "/tmp/learningsparkstreaming/"
val referenceFile = "sensor-records.parquet"
val targetFile = "enrichedIoTStream.parquet"

现在,我们从 Parquet 文件中加载参考数据(参见 图 23-2)。我们还将数据缓存到内存中,以提高我们流处理应用程序的性能:

val sensorRef = sparkSession.read.parquet(s"$workDir/$referenceFile")
sensorRef.cache()

spas 2302

图 23-2. 示例参考数据

接下来,我们将丰富流数据。有了模式流的基础,我们可以继续将底层 RDD 转换为 DataFrame。这一次,我们将使用参考数据添加特定的传感器信息。我们还将根据传感器的范围去规范化记录的值,这样我们就不需要在结果数据集中重复这些数据。

与以往一样,在通用操作 foreachRDD 的上下文中进行:

val stableSparkSession = sparkSession
import stableSparkSession.implicits._
import org.apache.spark.sql.SaveMode.Append
schemaStream.foreachRDD{ rdd =>
  val sensorDF = rdd.toDF()
  val sensorWithInfo = sensorDF.join(sensorRef, "sensorId")
  val sensorRecords =
    sensorWithInfo.withColumn(
      "dnvalue", $"value"*($"maxRange"-$"minRange")+$"minRange"
    ).drop("value", "maxRange", "minRange")
  sensorRecords.write
               .format("parquet")
               .mode(Append)
               .save(s"$workDir/$targetFile")
}
警告

stableSparkSession看似奇怪的结构是必要的,因为在 Spark 笔记本中,sparkSession引用是一个可变变量,我们不能从一个非稳定的引用中导入。

继续检查结果。我们可以同时使用当前的 Spark Session 与运行中的 Spark Streaming 作业来检查结果数据,如图 23-3 所示:

val enrichedRecords = sparkSession.read.parquet(s"$workDir/$targetFile")
enrichedRecords

spas 2303

图 23-3. 从 enrichedRecords DataFrame 中的样本

此时,我们可以看到记录计数在不断增加。我们可以对结果数据集执行count以查看流处理如何增加数据集。在两次执行之间等待片刻以观察差异:

enrichedRecords.count
>res33: Long = 45135
// ... wait few seconds ...
enrichedRecords.count
>res37: Long = 51167

连接优化

我们当前的解决方案有一个主要缺点:它会丢弃来自未注册传感器的传入数据。因为我们在过程开始时只加载一次参考数据,之后注册的传感器将被悄悄地丢弃。我们可以通过使用不同类型的join操作来改善这种情况。让我们记住,在foreachRDD中,我们可以充分利用其他 Spark 库的功能。在这种特殊情况下,我们使用的join操作来自 Spark SQL,并且我们可以利用该包中的选项来增强我们的流处理过程。特别是,我们将使用outer join 来区分系统已知的 IoT 传感器 ID 和未知的 IoT 传感器 ID。然后,我们可以将未知设备的数据写入一个单独的文件,以便后续对账。

程序的其余部分保持不变,除了foreachRDD调用之外,在此我们添加了新的逻辑,如示例 23-2 所示。

示例 23-2. 使用广播优化进行外连接
val stableSparkSession = sparkSession
import stableSparkSession.implicits._
import org.apache.spark.sql.SaveMode.Append
schemaStream.foreachRDD{ rdd =>
  val sensorDF = rdd.toDF()
  val sensorWithInfo = sensorRef.join(
    broadcast(sensorDF), Seq("sensorId"), "rightouter"
  )
  val sensorRecords =
    sensorWithInfo.withColumn(
      "dnvalue", $"value"*($"maxRange"-$"minRange")+$"minRange"
    ).drop("value", "maxRange", "minRange")
  sensorRecords.write
               .format("parquet")
               .mode(Append)
               .save(s"$workDir/$targetFile")
}

细心的人会注意到,我们对连接操作进行了两处改动。示例 23-3 专注于这些改动。

示例 23-3. 连接操作的详细信息
val sensorWithInfo = sensorRef.join(
  broadcast(sensorDF), Seq("sensorId"), "rightouter"
)

不同之处在于我们改变了连接的顺序。我们不再将传入数据与参考数据集连接,而是反过来。我们需要这种方向的改变有一个特定的原因:我们在连接表达式中添加了一个broadcast提示,以指示 Spark 执行广播连接

在 Hadoop 行话中被称为map-side joins的广播连接,在参与连接的两个数据集之间大小存在显著差异并且其中一个数据集足够小以至于能够发送到每个执行器的内存时非常有用。与执行 I/O 密集型的基于 shuffle 的连接不同,小数据集可以在每个执行器上作为内存中的查找表使用,以并行方式进行。这样可以减少 I/O 开销,从而提高执行速度。

在我们的场景中,我们交换了参与数据集的顺序,因为我们知道我们的参考数据集比每个间隔接收的设备数据要大得多。虽然我们的参考数据集包含我们所知道的每个设备的记录,但流数据只包含总体样本的一部分。因此,从性能上讲,最好将流中的数据广播以执行与参考数据的广播连接。

此代码片段中的最后一条备注是连接方向,由连接类型 rightouter 指定。此连接类型保留右侧的所有记录,即我们传入的传感器数据,并在匹配的连接条件下添加左侧的字段。在我们的情况下,这是匹配的 sensorId

我们将结果存储在两个文件中:一个是已知 SensorId 的丰富传感器数据(参见 图 23-4),另一个是我们当前不知道的传感器的原始数据(参见 图 23-5)。

spas 2304

图 23-4. 丰富记录样本

spas 2305

图 23-5. 未知设备记录样本

要深入讨论 Spark 中不同连接选项及其特性,请阅读 [Karau2017]

更新流应用程序中的参考数据集

在上一节中,您看到了我们如何加载静态数据集来丰富传入的数据流。

尽管某些数据集相当静态,例如去年的智能电网合成配置文件、物联网传感器的校准数据或人口分布在人口普查后的分布,我们通常也会发现,我们需要与流数据组合的数据集也在发生变化。这些变化可能比我们的流应用程序慢得多,因此不需要单独考虑为流。

在数字化进程中,组织通常拥有不同节奏的混合过程。诸如数据导出或每日报告等慢节奏输出过程是我们流处理系统的有效输入,需要定期更新,但不需要连续更新。

我们将探讨一种 Spark Streaming 技术,将静态数据集(“慢数据”)集成到我们的流数据(“快数据”)轨道中。

在其核心,Spark Streaming 是一个高性能调度和协调应用程序。为了确保数据完整性,Spark Streaming 按顺序调度流作业的不同输出操作。应用程序代码中声明的顺序在运行时成为执行顺序。

第十八章 讨论了 batch interval 及其如何提供数据收集和之前收集的数据提交到 Spark 引擎进行进一步处理的同步点。我们可以钩入 Spark Streaming 调度程序,以执行除流处理之外的 Spark 操作。

特别地,我们将使用一个 ConstantInputDStream 和一个空输入作为在流应用程序中调度额外操作的基本构建块。我们将空的 DStream 与通用操作 foreachRDD 结合使用,以便 Spark Streaming 处理我们应用程序上下文中所需的常规执行额外操作。

使用参考数据集增强我们的示例

为了更好地理解这一技术的动态,请继续我们的运行示例。

在前一节中,我们使用了一个包含系统已知传感器描述的参考数据集。该数据用于将流式传感器数据与其进一步处理所需的参数进行丰富。该方法的一个关键限制是,在启动流应用程序后,我们无法添加、更新或删除列表中存在的任何传感器。

在我们的示例上下文中,我们每小时获取该列表的更新。我们希望我们的流应用程序使用新的参考数据。

从 Parquet 文件加载参考数据

如前例所示,我们从 Parquet 文件加载参考数据。我们还将数据缓存到内存中,以提高流应用程序的性能。在这一步中唯一观察到的区别是,我们现在使用一个 variable 而不是 value 来保持对参考数据的引用。我们需要将此引用设置为可变的,因为随着流应用程序的运行,我们将使用新数据更新它:

var sensorRef: DataFrame = sparkSession.read.parquet(s"$workDir/$referenceFile")
sensorRef.cache()

设置刷新机制

为了定期加载参考数据,我们将 hook 到 Spark Streaming 调度程序上。我们只能通过 batch interval 来实现这一点,它作为所有操作的内部时钟基础。因此,我们将刷新间隔表示为基本批处理间隔的窗口。实际上,每 x 个批次,我们将刷新我们的参考数据。

我们使用一个 ConstantInputDStream 和一个空的 RDD。这确保我们始终有一个空的 DStream,其唯一的功能是通过 foreachRDD 函数让我们访问调度程序。在每个窗口间隔内,我们更新指向当前 DataFrame 的变量。这是一个安全的构造,因为 Spark Streaming 调度程序将按照每个 batch interval 应执行的调度操作的线性顺序执行。因此,新数据将可供使用它的上游操作使用。

我们使用缓存确保参考数据集仅在流应用程序中使用的间隔期间加载一次。重要的是,为了释放集群中的资源并确保从资源消耗的角度看,我们具有稳定的系统,需要 unpersist 先前缓存的过期数据:

import org.apache.spark.rdd.RDD
val emptyRDD: RDD[Int] = sparkContext.emptyRDD
val refreshDStream  = new ConstantInputDStream(streamingContext, emptyRDD)
val refreshIntervalDStream = refreshDStream.window(Seconds(60), Seconds(60))
refreshIntervalDStream.foreachRDD{ rdd =>
  sensorRef.unpersist(false)
  sensorRef = sparkSession.read.parquet(s"$workDir/$referenceFile")
  sensorRef.cache()
}

我们的刷新过程使用一个 60 秒的滚动窗口。

运行时影响

加载大型数据集会消耗大量时间和资源,因此会对操作产生影响。图 23-6 描述了处理时间中的周期性峰值,这些峰值对应于参考数据集的加载。

spas 2306

图 23-6. 加载参考数据对运行时有显著影响

通过以比流应用程序的频率低得多的速率调度加载参考数据,可以使成本在相对大量的微批次上摊销。然而,在规划集群资源时,你需要考虑这种额外负载,以确保长时间内的稳定执行。

总结

虽然 SQL 能力是结构化流处理的本地特性,但在本章中,你看到了如何从 Spark Streaming 中使用 Spark SQL 特性以及这种组合开放的可能性。你了解到了以下内容:

  • 从 Spark Streaming 的输出操作中访问 Spark SQL 上下文

  • 加载和重新加载静态数据以丰富我们的流数据

  • 如何使用不同的连接模式连接数据

第二十四章:Checkpointing

Checkpointing 的行为包括定期保存重新启动有状态流式应用程序所需的信息,而无需丢失信息,也无需重新处理到目前为止看到的所有数据。

Checkpointing 是处理有状态的 Spark Streaming 应用程序时特别需要注意的一个主题。没有 checkpointing,重新启动有状态的流式应用程序将要求我们重建状态,直到应用程序之前停止的那一点。在窗口操作的情况下,重建过程可能包括数小时的数据,这将需要更大的中间存储。更具挑战性的情况是在实现任意状态聚合时,正如我们在第二十二章中所见。如果没有 checkpoints,即使是简单的有状态应用程序,比如统计网站每个页面的访客数量,也需要重新处理所有过去的数据来重建其状态到一个一致的水平;这是一个从非常困难到不可能的挑战,因为系统中可能不再有所需的数据。

然而,checkpoint 并非免费。checkpoint 操作对流式应用程序提出了额外的要求,涉及维护 checkpoint 数据所需的存储以及此重复操作对应用程序性能的影响。

在本章中,我们讨论了设置和使用 Spark Streaming 应用程序中的 checkpointing 所需考虑的要点。我们从一个示例开始,以说明在程序中设置 checkpoint 的实际方面。然后,我们看看如何从 checkpoint 恢复,checkpointing 引入的操作成本,最后,我们讨论一些调整 checkpointing 性能的技术。

理解 Checkpoints 的使用

让我们考虑以下流式作业,该作业跟踪在线视频商店每小时播放的视频次数。它使用 mapWithState 来跟踪通过流传递的 videoPlayed 事件,并处理事件中嵌入的时间戳以确定基于时间的聚合。

在接下来的代码片段中,我们做出以下假设:

  • 数据流包括结构 VideoPlayed(video-id, client-id, timestamp)

  • 我们有一个类型为 DStream[VideoPlayed]videoPlayedDStream 可用。

  • 我们有一个具有以下签名的 trackVideoHits 函数:

// Data Structures
case class VideoPlayed(videoId: String, clientId: String, timestamp: Long)
case class VideoPlayCount(videoId: String, day: Date, count: Long)

// State Tracking Function
def trackVideoHits(videoId: String,
                   timestamp:Option[Long],
                   runningCount: State[VideoPlayCount]
                   ): Option[VideoPlayCount]

在线资源

我们将代码简化为理解 checkpointing 所需的元素。要探索完整示例,请访问书籍在线资源中名为 https://github.com/stream-processing-with-spark 的独立项目 checkpointed-video-stream

示例 24-1. 流式 checkpointing
import org.apache.spark.streaming.State
import org.apache.spark.streaming._

val streamingContext = new StreamingContext(spark.sparkContext, Seconds(10))
streamingContext.checkpoint("/tmp/streaming")

val checkpointedVideoPlayedDStream = videoPlayedDStream.checkpoint(Seconds(60))

// create the mapWithState spec
val videoHitsCounterSpec = StateSpec.function(trackVideoHits _)
                                    .timeout(Seconds(3600))

// Stateful stream of videoHitsPerHour
val statefulVideoHitsPerHour = checkpointedVideoPlayedDStream.map(videoPlay =>
  (videoPlay.videoId, videoPlay.timestamp)
).mapWithState(videoHitsCounterSpec)

// remove the None values from the state stream by "flattening" the DStream
val videoHitsPerHour = statefulVideoHitsPerHour.flatMap(elem => elem)

// print the top-10 highest values
videoHitsPerHour.foreachRDD{ rdd =>
  val top10 = rdd.top(10)(Ordering[Long].on((v: VideoPlayCount) => v.count))
  top10.foreach(videoCount => println(videoCount))
}

streamingContext.start()

如果我们运行这个示例,我们应该看到类似于以下的输出:

Top 10 at time 2019-03-17 21:44:00.0
=========================
video-935, 2019-03-17 23:00:00.0, 18
video-981, 2019-03-18 00:00:00.0, 18
video-172, 2019-03-18 00:00:00.0, 17
video-846, 2019-03-18 00:00:00.0, 17
video-996, 2019-03-18 00:00:00.0, 17
video-324, 2019-03-18 00:00:00.0, 16
video-344, 2019-03-17 23:00:00.0, 16
video-523, 2019-03-18 00:00:00.0, 16
video-674, 2019-03-18 00:00:00.0, 16
video-162, 2019-03-18 00:00:00.0, 16
=========================

在此作业执行时观察的更有趣的方面是 Spark UI,在那里我们可以看到每个批处理执行对前一阶段依赖关系的展开情况。

图 24-1 说明了第一次迭代(Job #0)如何仅依赖于由 makeRDD 操作标识的初始数据批次。

spas 2401

图 24-1. 观察初始有状态作业血统
注意

要在您自己的设置中查看此表示,请转至 Spark UI,位于 <host>:4040(如果在同一主机上运行多个作业,则为 4041、4042 等)。

然后,点击作业详情,跳转至页面 http://<host>:4040/jobs/job/?id=0,您可以展开DAG 可视化以显示可视化内容。

随着作业的进展,我们可以检查下一个执行的作业 job #1 的 DAG 可视化,如我们在图 24-2 中展示的那样。在那里,我们可以看到结果依赖于前一批次的 makeRDD 和当前数据批次,通过 mapWithState 阶段体现了有状态计算的作用。

spas 2402

图 24-2. 进化的有状态作业血统

如果我们查看 job #1stage 8 的详细信息,在图 24-3 中,我们可以欣赏当前结果如何依赖于前期和新数据的组合。

spas 2403

图 24-3. 进化的有状态作业血统:详细

此过程将随着每个新数据批次的到来而重复,创建一个越来越复杂的依赖图。在相同的图表中,我们可以看到旧阶段被跳过,因为结果仍然存储在内存中。但我们不能永远保留所有先前的结果。

检查点提供了解决此复杂性的解决方案。在每个检查点,Spark Streaming 存储流计算的中间状态,因此不再需要处理新数据之前的检查点之前的结果。

在图 24-4 中,我们可以看到同一流作业超过 300 次迭代后的状态。与将前 300 个结果存储在内存中不同,有状态计算结合检查点信息和最新数据批次,以获得最新微批次的结果。

spas 2404

图 24-4. 进化的有状态作业血统:检查点

DStreams 的检查点

要启用检查点,您需要设置两个参数:

streamingContext.checkpoint(<dir>)

设置流处理上下文的检查点目录。此目录应位于弹性文件系统上,例如 Hadoop 分布式文件系统(HDFS)。

dstream.checkpoint(<duration>)

将 DStream 的检查点频率设置为指定的duration

在 DStream 上设置持续时间是可选的。如果未设置,则默认为特定值,具体取决于 DStream 类型。对于MapWithStateDStream,默认为批处理间隔的 10 倍。对于所有其他 DStreams,默认为 10 秒或批处理间隔,以较大者为准。

检查点频率必须是批处理间隔的倍数,否则作业将因初始化错误而失败。直观地说,我们希望间隔频率是每n批处理间隔一次,其中n的选择取决于数据量以及作业故障恢复的要求的关键性。根据 Spark 文档的建议,一个经验法则是起始时每五到七个批处理间隔进行检查点。

警告

请注意,如果批处理间隔超过 10 秒,则保留默认值将在每个批处理上创建一个检查点,这可能会对流作业的性能产生负面影响。

当流处理操作依赖于先前状态时,状态处理需要检查点,因此保留所有必要数据可能会计算量大或根本不可能。这里的思路是,Spark Streaming 实现可能依赖于先前 RDD 的计算,无论是来自中间结果还是先前的源数据。正如我们在先前的例子中看到的那样,这构成了计算的衍生线。然而,正如我们也观察到的那样,在使用有状态计算时,该衍生线可能会变得很长甚至无限。

例如,计算从窗口化计算中获取的最大衍生线条长度的推理是考虑如果在最坏情况下由于部分数据丢失我们需要恢复整个计算所需的数据类型。给定持续时间为t的批处理间隔和n × t的窗口间隔,我们将需要最后的n个 RDD 数据来重新计算窗口。在窗口化计算的情况下,衍生线条的长度可能很长,但受到固定因子的限制。

当我们的计算衍生线可能是任意长时,从方便到必要使用检查点跳转。在任意有状态流的情况下,状态依赖于先前 RDD 的长度可以回溯到应用程序运行时的开始,这使得在有状态流上使用检查点是强制性的。

从检查点恢复

在我们迄今为止的讨论中,我们考虑了检查点在保存有状态流作业的中间状态中发挥的作用,以便进一步迭代可以引用该中间结果而不依赖作业的完整衍生线。这可能一直延续到接收到的第一个元素。

当我们处理故障和从故障中恢复时,检查点还有另一个同等重要的方面。让我们回到我们的初始示例并思考一分钟:“如果我的作业在任何时候失败会发生什么?”如果没有检查点,我们将需要重播过去至少一个小时的数据,以恢复每个视频播放的部分总数。现在想象一下,我们正在计算每日总数。然后,我们需要在新数据仍在到达进行处理时,重播一天的数据。

检查点中包含的信息使我们能够从最后一个已知状态恢复流处理应用程序,即上次检查点的状态。这意味着全面恢复仅需要重播几个批次的数据,而不是数小时或数天的记录。

Spark Streaming 应用程序在实现中必须支持从检查点进行恢复。特别是,我们需要使用特定方法来获取活动的 streamingContextstreamingContext 提供了 getActiveOrCreate(<dir>, <ctx-creation-function>) 方法,允许应用程序从存储在 <dir> 的现有检查点开始运行,如果检查点不可用,则使用 <ctx-creation-function> 函数创建新的 streamingContext

如果我们更新先前的示例并加入检查点恢复功能,我们的 streamingContext 创建应如下所示:

def setupContext(
  checkpointDir : String,
  sparkContext: SparkContext
  ): StreamingContext = {
// create a streamingContext and setup the DStream operations we saw previously
}

val checkpointDir = "/tmp/streaming"
val streamingContext = StreamingContext.getOrCreate(
  CheckpointDir,
  () => setupContext(CheckpointDir, spark.sparkContext)
)

streamingContext.start()
streamingContext.awaitTermination()

限制

检查点恢复仅适用于打包为 JAR 文件并使用 spark-submit 提交的应用程序。从检查点恢复仅限于编写检查点数据的相同应用程序逻辑,不能用于对正在运行的流处理应用程序进行升级。应用程序逻辑的更改将影响从检查点中的序列化形式重建应用程序状态的可能性,并且会导致重新启动失败。

检查点严重影响将流处理应用程序升级到新版本的可能性,并要求在新版本应用程序变得可用时进行特定的架构考虑以恢复有状态计算。

检查点成本

将检查点写入磁盘会增加流处理应用程序的执行时间成本,这在 Spark Streaming 中尤为关注,因为我们将批处理间隔视为计算预算。将可能的大型状态写入磁盘可能会很昂贵,特别是如果支持该应用程序的硬件相对较慢,这在使用 Hadoop 分布式文件系统(HDFS)作为后备存储时经常会出现。需要注意的是这是检查点的最常见情况:HDFS 是可靠的文件系统,磁盘驱动器提供的复制存储成本可管理。

Checkpointing 理想情况下应在可靠的文件系统上运行,以便在故障发生时能够通过从可靠存储读取数据迅速恢复流的状态。然而,考虑到写入 HDFS 可能较慢,我们需要面对一个事实,即 checkpointing 周期性地可能需要更长的运行时间,甚至可能比批处理间隔时间还要长。正如我们之前解释过的,批处理时间长于批处理间隔时间可能会带来问题。

Checkpoint 调整

由于 Spark 用户界面,您可以平均衡量需要多少额外时间来计算包含 checkpointing 的批处理间隔,与在不需要 checkpointing 的 RDD 中观察到的批处理时间相比。假设我们的批处理时间约为 30 秒,批处理间隔为一分钟。这是一个相对有利的情况:每个批处理间隔,我们只需花费 30 秒进行计算,而在接收数据的同时,我们有 30 秒的“空闲”时间,我们的处理系统在此期间处于空闲状态。

鉴于我们的应用要求,我们决定每五分钟进行一次检查点。现在我们决定每五分钟进行一次检查点,我们进行了一些测量并观察到我们的检查点批次需要四分钟的实际批处理时间。我们可以得出结论,在这种情况下,我们将需要大约三分半钟的时间来写入磁盘,考虑到在同一批次中,我们还扩展了 30 秒的计算时间。这意味着在这种情况下,我们将需要四个批次再次进行检查点。为什么会这样?

这是因为当我们实际花费三分半钟将文件写入磁盘时,事实上我们仍在接收数据,当这三分半钟结束时,我们已经接收到了三个半新的批次,因为我们的系统被阻塞等待检查点操作结束。因此,我们现在有了三个半(即四个)批次数据存储在我们的系统上,我们需要处理以追赶并再次达到稳定状态。现在,我们在正常批处理中的计算时间是 30 秒,这意味着我们每分钟的批次间隔都能够赶上一个新的批次,因此在四个批次内,我们将赶上接收到的数据。我们将在第五个批次间隔时再次进行检查点。在五个批次的检查点间隔下,我们实际上只是处于系统的稳定极限。

当然,前提是,在您的有状态流中编码的状态确实反映了随时间从输入源接收的数据量大小相对恒定的大小。状态的大小与随时间接收的数据量之间的关系可能更复杂,并且依赖于特定的应用程序和计算,这就是为什么通过检查点长度进行实验通常非常有用的原因。其思想是更大的检查点间隔将为我们的集群提供更多时间来赶上检查点期间丢失的时间,同样重要的是,如果我们设置的检查点间隔过高,那么如果发生崩溃导致数据丢失,我们可能会遇到一些难以赶上的集群问题。在这种情况下,我们确实需要系统加载检查点并重新处理自那个检查点以来看到的所有 RDD。

最后,请注意,任何 Spark 用户都应考虑更改任何 DStream 上的默认检查点间隔,因为它设置为 10 秒。这意味着在每个批处理上,如果自上次检查点以来的间隔大于检查点间隔,则 Spark 将进行检查点。因此,如果批处理间隔大于 10 秒,这将导致处理时间上的“锯齿”模式,如 图 24-5 所示,描述了每隔一个批次进行一次检查点(对大多数应用程序来说可能太频繁了)。

注意

对于检查点间隔调整的另一种选择是将数据写入一个接受非常高速数据的持久化目录。为此,您可以选择将检查点目录指向由非常快速硬件存储支持的 HDFS 集群路径,例如固态硬盘(SSD)。另一种选择是将其支持为内存,并在内存填满时将数据卸载到磁盘,例如由 Alluxio 执行的操作,这是最初作为 Tachyon 开发的项目,是 Apache Spark 的某个模块。通过这种简单方法来减少检查点时间和在检查点中丢失的时间通常是达到 Spark Streaming 稳定计算的最有效方式之一;当然,前提是这是可承受的。

spas 2405

图 24-5. 检查点“锯齿”性能模式

第二十五章:监控 Spark Streaming

流应用程序中的监控需要获得部署应用程序的操作信心,应包括应用程序使用的资源的整体视图,例如 CPU、内存和二级存储。作为分布式应用程序,要监控的因素数量将与集群部署中节点数量相乘。

为了管理这种复杂性,我们需要一个全面而智能的监控系统。它需要从参与流应用运行时的所有关键组件收集指标,并同时以易于理解和可消费的形式提供它们。

对于 Spark Streaming 而言,除了刚讨论的通用指标外,我们主要关注接收到的数据量、应用程序选择的批处理间隔以及每个微批处理的实际执行时间之间的关系。这三个参数之间的关系对于长期稳定的 Spark Streaming 作业至关重要。为确保我们的作业在稳定的边界内执行,我们需要将性能监控作为开发和生产过程的一个整体组成部分。

Spark 提供了几种监控接口,以满足该过程不同阶段的需求:

流式 UI

提供有关运行作业的关键指标的图表的 Web 界面

监控 REST API

一组 API,可通过 HTTP 接口被外部监控系统消费,以获取指标

指标子系统

允许将外部监控工具紧密集成到 Spark 中的可插拔服务提供者接口(SPI)

内部事件总线

Spark 中的发布/订阅子系统,其中编程订阅者可以接收有关集群上应用程序执行不同方面的事件

在本章中,我们探讨这些监控接口及其如何应用于流应用程序的生命周期的不同阶段。我们从流式 UI 开始,其中我们调查此接口提供的功能及其与运行中 Spark Streaming 作业不同方面的联系。流式 UI 是一个强大的视觉工具,我们可以在初始开发和部署阶段使用它,以更好地了解我们的应用程序从实用的角度如何运行。我们专门详述了使用流式 UI 的部分,重点关注性能方面。

在本章的其余部分,我们涵盖了 Spark Streaming 的不同监控集成能力。我们探索 REST API 和 Metrics Subsystem SPI 提供的 API,以向外部监控客户端公开内部指标。最后,我们描述了内部事件总线的数据模型和交互,这可以用于以编程方式访问 Spark Streaming 提供的所有指标,适用于需要最大灵活性以集成自定义监控解决方案的情况。

流式 UI

SparkUI 是一个网络应用程序,位于 Spark 驱动节点上,通常运行在端口 4040 上,除非同一节点上有其他 Spark 进程运行,否则将使用增加的端口(4041、4042 等),直到找到一个空闲端口。我们也可以通过配置键 spark.ui.port 来配置此端口。

我们所说的 流式 UI 是 Spark UI 中的一个选项卡,仅当启动 StreamingContext 时才会激活,如 图 25-1 所示。

spas 2501

图 25-1. 流式 UI

流式 UI 包括几个视觉元素,提供 Spark Streaming 作业性能的一览无余视图。根据图像中的数字线索,以下是组成 UI 的元素:

(1) 流式 UI 选项卡

这是 Spark UI 上的流式选项卡。单击它会打开该流式 UI。

(2) 基于时间的统计数据

总体统计行包括批处理间隔、此应用程序已运行的时间和启动时间戳。

(3) 批次和记录摘要

在时间信息旁边,我们找到已完成的批次总数以及处理的记录总数。

(4) 性能图表

图表标题在图表报告的表中指出了使用的数据。图表中表示的数据保存在循环缓冲区中。我们只看到最近的一千(1,000)个接收到的数据点。

(5) 输入速率图表

每个批次间隔接收的记录数量的时间序列表示,并且旁边还有一个分布直方图。

(6) 调度延迟图表

此图表报告了批次调度和处理之间的差异。

(7) 处理时间图表

处理每个批次所需时间(持续时间)的时间序列。

(8) 总延迟图表

这是调度延迟和处理时间的总和的时间序列。它提供了对 Spark Streaming 和 Spark 核心引擎的联合执行的视图。

(9) 活动批次

提供当前在 Spark Streaming 队列中的批次列表。它显示当前正在执行的批次或批次,以及在过载情况下可能存在的任何潜在积压批次。理想情况下,此列表中只有处理中的批次。如果在加载流式 UI 时当前批次已完成处理且下一批次尚未计划,则此列表可能为空。

(10) 已完成批次

最近处理的批次列表,以及指向该批次详细信息的链接。

使用流式 UI 理解作业性能

如“流式处理 UI”中讨论的,流式处理作业的主屏幕包含四个图表,展示了当前和最近的性能快照。默认情况下,UI 显示最后 1,000 个处理间隔,这意味着我们能够查看的时间段是间隔 × 批处理间隔,因此对于批处理间隔为两秒的作业,我们可以看到大约最近半小时的指标(2 × 1,000 = 2,000 秒,或 33.33 分钟)。我们可以使用 spark.ui.retainedJobs 配置参数来配置记忆间隔的数量。

输入速率图表

顶部的输入速率图表显示了应用程序正在维持的输入负载。所有图表共享一个公共时间轴。我们可以想象一条竖直线通过所有图表,这将作为一个参考,用于将不同的指标与输入负载相关联,正如在图 25-2 中所示。图表线的数据点可点击,并将链接到图表下方出现的相应作业详细信息行。正如我们稍后将探讨的,这种导航功能对于追踪我们可以在图表上观察到的某些行为的起源非常有帮助。

spas 2502

图 25-2. 流式处理 UI:度量的相关性

调度延迟图表

UI 中的下一个图表是调度延迟图表。这是一个关键的健康指标:对于一个在其时间和资源约束内运行良好的应用程序,这个度量指标将始终保持在零上。小的周期性干扰可能指向定期支持过程,例如快照。

创建异常负载的窗口操作也可能影响这个度量。重要的是要注意,延迟将显示在紧随需要超过批处理间隔完成的批次后面的批次上。这些延迟不会与输入速率显示相关。由数据输入高峰引起的延迟将与输入速率的高峰相关联,并带有偏移。图 25-3 显示了输入速率图中的高峰比调度延迟的相应增加早。因为这个图表代表了一个延迟,我们看到系统在开始“消化”数据过载后才会显示这些效果。

spas 2503

图 25-3. 流式处理 UI:调度延迟

处理时间图表

此图表显示了图 25-4 中数据处理部分的执行时间。此执行发生在 Spark 集群上,因此这是实际数据处理在(可能的)分布式环境中表现的主要指标。图表的一个重要方面是水位线,其水平对应批处理间隔时间。让我们迅速回顾一下,批处理间隔是每个微批处理的时间,也是我们处理前一间隔到达的数据的时间。低于此水位线的处理时间被视为稳定的。偶尔超过此线的峰值可能是可以接受的,如果作业有足够的空间从中恢复。如果作业始终高于此线,则会利用内存和/或磁盘中的存储资源构建积压。如果可用存储资源耗尽,则作业最终会崩溃。此图表通常与输入速率图表高度相关,因为作业的执行时间通常与每个批处理间隔接收的数据量有关。

spas 2504

图 25-4. 流式 UI:处理时间

总延迟图表

总延迟图表是系统端到端延迟的图形化表示。总延迟包括 Spark Streaming 收集、调度和提交每个微批处理以进行处理的时间,以及 Spark 应用作业逻辑并生成结果的时间。此图表提供了系统性能的整体视图,并揭示了作业执行过程中可能发生的任何延迟。至于调度延迟图表,总延迟指标的持续增加是一个值得关注的原因,可能表明高负载或其他条件(如增加的存储延迟)对作业性能产生负面影响。

批次详情

当我们向下滚动到构成流式 UI 主屏幕的图表下方时,我们会发现两个表格:活跃批次和已完成批次。这些表格的列对应我们刚刚学习的图表:输入大小、调度延迟、处理时间以及输出操作计数器。除了这些字段,已完成批次还显示了相应微批次的总延迟。

注意

输出操作 指的是注册要在微批次上执行的输出操作数量。这涉及作业的代码结构,不应与并行性指标混淆。正如我们所记得的,输出操作(如 print()foreachRDD)是触发 DStream 惰性执行的操作。

活跃批次

包含关于 Spark Streaming 调度队列中微批次的信息。对于一个健康的作业,该表最多包含一行:当前正在执行的批次。此条目指示微批次中包含的记录数及执行开始前的任何延迟。处理时间在微批次完成之前是未知的,因此在活动批次表上从未显示该指标。

当此表中存在多行时,表示作业超出了批次间隔并且新的微批次排队等待执行,形成执行积压。

已完成批次

批次执行完成后,其在活动批次表中对应的条目会转移到已完成批次表中。在此转换中,处理时间字段填充其执行时间,并且总延迟也会被确定并包括在内。

每个条目由时间戳标识,标记为批次时间。此标签还提供了链接,指向提供此批次执行的 Spark 作业的详细信息。

批次详情的链接值得进一步探索。正如前几章所解释的,Spark Streaming 模型基于微批次。批次详情页面提供了每个批次执行的洞察,分解为构成批次的不同作业。Figure 25-5 展示了结构。批次定义为按照应用程序代码中定义的顺序执行的输出操作序列。每个输出操作包含一个或多个作业。本页面总结了这种关系,显示了每个作业的持续时间以及任务概述中的并行级别。

spas 2505

图 25-5. 流式 UI:批次详情

作业按作业 ID 列出,并提供指向 Spark UI 中作业页面的链接。这些是核心引擎执行的普通 Spark 作业。通过点击,我们可以探索执行情况,包括阶段、分配的执行器和执行时间统计信息。

注意

要在本地使用流式 UI,本书的在线资源中提供了两个笔记本:

kafka-data-generator.snb

此笔记本用于按秒产生可配置数量的记录,这些记录发送到本地 Kafka 主题。

kafka-streaming-data.snb

该笔记本从相同主题消费数据,将数据与参考数据集进行连接,并将结果写入本地文件。

通过尝试生产者速率,我们可以观察流式 UI 的行为,并体验稳定和过载情况。这是将流式应用程序推向生产时的良好练习,因为它有助于理解其性能特征并确定应用程序在何时执行正确的负载阈值。

监控 REST API

监控 REST API 将作业的流式指标公开为一组预定义的 HTTP 终端,这些终端以 JSON 格式的对象传送数据。这些对象可以被外部监控和警报应用程序消耗,以将 Spark Streaming 作业与某些外部监控系统集成。

使用监控 REST API

监控 REST API 由 Spark 驱动节点提供,端口与 Spark UI 相同,并挂载在 /api/v1 终点:http://<driver-host>:<ui-port>/api/v1

/api/v1/applications/:app-id 资源提供有关提供的应用程序 ID app-id 的信息。首先必须通过调用 /api/v1/applications 查询此 id,以构建特定应用程序的 URL。在以下 URL 中,我们将这个变量应用程序 ID 称为 app-id

注意,对于运行中的 Spark Streaming 上下文,只会有一个当前应用程序 ID。

警告

监控 REST API 从 Spark 版本 2.2 开始仅支持 Spark Streaming。对于较早版本的 Spark,请考虑在下一节中进一步解释的度量 servlet。

监控 REST API 提供的信息

与运行中的 Spark Streaming 上下文对应的资源位于 /api/v1/applications/:app-id/streaming

表 25-1 总结了此终端提供的子资源。

表 25-1. 流处理资源的子资源

资源 含义 对应的 UI 元素
/statistics 一组汇总的指标(有关详细信息,请参阅后续章节) 流处理 UI 图表
/receivers 在这个流处理作业中实例化的所有接收器列表 输入速率图表上的接收器摘要
/receivers/:stream-id 根据提供的 stream-id 索引的接收器的详细信息 点击打开输入速率图表上的接收器
/batches 当前保存的所有批次列表 图表下的批次列表
/batches/:batch-id/operations 输出操作 点击批处理列表中的一个批处理
/batches/:batch-id/operations/:output-op-id 给定批次中相应输出操作的详细信息 在批处理详细页面下的操作详情中点击

从监控的角度来看,我们应该额外关注 statistics 对象。它包含了我们需要监视的关键性能指标,以确保流处理作业的健康运行:/api/v1/applications/:app-id/ streaming/statistics

表 25-2 展示了statistics端点提供的不同数据列表、它们的类型以及涉及的指标简要描述。

表 25-2. 流处理资源的统计对象

类型 描述
startTime 字符串 以 ISO 8601 格式编码的时间戳
batchDuration 数字 (长整型) 批处理间隔的持续时间(以毫秒为单位)
numReceivers Number (Long) 注册接收器的计数
numActiveReceivers Number (Long) 当前活动接收器的计数
numInactiveReceivers Number (Long) 当前不活动接收器的计数
numTotalCompletedBatches Number (Long) 从流处理作业启动以来已完成的批次计数
numRetainedCompletedBatches Number (Long) 目前保留的批次计数,我们仍然存储其信息
numActiveBatches Number (Long) 流处理上下文执行队列中批次的计数
numProcessedRecords Number (Long) 当前运行作业处理的记录总和
numReceivedRecords Number (Long) 当前运行作业接收的记录总和
avgInputRate Number (Double) 最近保留的批次的输入速率的算术平均值
avgSchedulingDelay Number (Double) 最近保留的批次的调度延迟的算术平均值
avgProcessingTime Number (Double) 最近保留的批次的处理时间的算术平均值
avgTotalDelay Number (Double) 最近保留的批次的总延迟的算术平均值

在将监控工具集成到 Spark Streaming 中时,我们特别关注 avgSchedulingDelay,并确保它不会随时间增长。许多监控应用程序使用 增长率(或类似)测量指标随时间的变化。由于 API 提供的值是最近保留的批次(默认为 1,000)的平均值,设置关于此指标的警报应考虑小幅增长的变化。

指标子系统

在 Spark 中,指标子系统是一个 SPI,允许实现指标接收器以将 Spark 与外部管理和监控解决方案集成。

提供一些内置实现,例如以下内容:

控制台

将指标信息记录到作业的标准输出

HTTP Servlet

使用 HTTP/JSON 传递指标

CSV

以逗号分隔值 (CSV) 格式将指标传递到配置目录中的文件

JMX

通过 Java 管理扩展 (JMX) 启用指标报告

日志

将指标信息传递到应用程序日志中

Graphite、StatsD

将指标转发至 Graphite/StatsD 服务

Ganglia

将指标传递到现有的 Ganglia 部署(请注意,由于许可限制,使用此选项需要重新编译 Spark 二进制文件)

指标子系统报告 Spark 和 Spark Streaming 进程的最新值。这为我们提供了对性能指标的原始访问,可以向远程性能监控应用程序提供数据,并在流处理过程中的异常发生时进行准确及时的报警。

流处理作业的特定指标可以在键 <app-id>.driver.<application name>.StreamingMetrics.streaming 下找到。

如下所示:

  • lastCompletedBatch_processingDelay

  • lastCompletedBatch_processingEndTime

  • lastCompletedBatch_processingStartTime

  • lastCompletedBatch_schedulingDelay

  • lastCompletedBatch_submissionTime

  • lastCompletedBatch_totalDelay

  • lastReceivedBatch_processingEndTime

  • lastReceivedBatch_processingStartTime

  • lastReceivedBatch_records

  • lastReceivedBatch_submissionTime

  • receivers

  • retainedCompletedBatches

  • runningBatches

  • totalCompletedBatches

  • totalProcessedRecords

  • totalReceivedRecords

  • unprocessedBatches

  • waitingBatches

尽管不直接提供,您可以通过简单的算术运算获取 lastCompletedBatch_processingTimelastCompletedBatch_processingEndTime - lastCompletedBatch_processingStartTime

在这个 API 中,跟踪作业稳定性的关键指标是 lastCompletedBatch_processingDelay,我们期望它接近零并且随时间稳定。对最近 5 到 10 个值的移动平均值应该消除由小延迟引入的噪声,并提供一个可靠的度量标准,以触发警报或呼叫。

内部事件总线

本章讨论的所有度量接口都有一个共同的真实数据来源:它们都通过专用的 StreamingListener 实现从内部 Spark 事件总线消耗数据。

Spark 使用多个内部事件总线向订阅客户端传递有关正在执行的 Spark 作业的生命周期事件和元数据。这种接口主要由内部 Spark 消费者使用,以某种处理过的形式提供数据。Spark UI 就是这种交互的最显著例子。

对于那些现有的高级接口无法满足我们需求的情况,可以开发自定义监听器并注册它们来接收事件。要创建自定义的 Spark Streaming 监听器,我们扩展 org.apache.spark.streaming.scheduler.StreamingListener trait。该 trait 对所有回调方法都有一个无操作的默认实现,因此扩展它只需要覆盖我们自定义的指标处理逻辑所需的回调方法。

请注意,此内部 Spark API 标记为 DeveloperApi。因此,其定义(如类和接口)可能会在未公开通知的情况下更改。

注意

您可以在在线资源中探索自定义的 Spark Streaming 监听器实现。笔记本 kafka-streaming-with-listener 扩展了之前使用的 Kafka 笔记本,使用自定义笔记本监听器将所有事件传递到可直接在笔记本中显示的 TableWidget 中。

与事件总线交互

StreamingListener 接口由一个带有多个回调方法的 trait 组成。每个方法都由通知过程调用,使用 StreamingListenerEvent 的子类实例来传递相关信息给回调方法。

StreamingListener 接口

在以下概述中,我们突出显示这些数据事件中最有趣的部分:

onStreamingStarted

def onStreamingStarted(
  streamingStarted:StreamingListenerStreamingStarted):Unit

当流处理作业启动时调用此方法。StreamingListenerStreamingStarted 实例具有一个字段 time,其中包含流处理作业启动时的时间戳(以毫秒为单位)。

接收器事件

所有与接收器生命周期相关的回调方法共享一个名为 ReceiverInfo 的通用类,描述正在报告的接收器。每个事件报告类都将有一个单独的 receiverInfo: ReceiverInfo 成员。ReceiverInfo 类属性中包含的信息将取决于报告事件的相关接收器信息。

onReceiverStarted

def onReceiverStarted(
  receiverStarted: StreamingListenerReceiverStarted): Unit

当接收器已启动时调用此方法。StreamingListenerReceiverStarted 实例包含描述为此流处理作业启动的接收器的 ReceiverInfo 实例。请注意,此方法仅适用于基于接收器的流模型。

onReceiverError

def onReceiverError(
  receiverError: StreamingListenerReceiverError): Unit

当现有接收器报告错误时调用此方法。与 onReceiverStarted 调用类似,提供的 StreamingListenerReceiverError 实例包含一个 ReceiverInfo 对象。在此 Receiver Info 实例中,我们将找到关于错误的详细信息,例如错误消息和发生时间戳。

onReceiverStopped

def onReceiverStopped(
  receiverStopped: StreamingListenerReceiverStopped): Unit

这是 onReceiverStarted 事件的对应项。当接收器已停止时触发此事件。

批处理事件

这些事件与批处理生命周期相关,从提交到完成。请注意,在 DStream 上注册的每个输出操作都会导致独立的作业执行。这些作业被分组成批次,一起顺序提交到 Spark 核心引擎执行。此监听器接口的这一部分在批次提交和执行生命周期后触发事件。

由于这些事件将在每个微批处理的处理后触发,因此它们的报告频率至少与相关 StreamingContext 的批次间隔一样频繁。

receiver 回调接口的相同实现模式一致,所有与批处理相关的事件都报告一个带有单个 BatchInfo 成员的容器类。每个报告的 BatchInfo 实例包含对应于报告回调的相关信息。

BatchInfo 还包含此批次中注册的输出操作的 Map,由 OutputOperationInfo 类表示。此类包含每个单独输出操作的时间、持续时间和最终错误的详细信息。我们可以利用这些数据点将批处理的总执行时间拆分为导致在 Spark 核心引擎上执行单个作业的不同操作所需的时间:

onBatchSubmitted

def onBatchSubmitted(
  batchSubmitted: StreamingListenerBatchSubmitted)

当一批作业提交给 Spark 进行处理时,将调用此方法。由StreamingListenerBatchSubmitted报告的BatchInfo对象包含了批处理提交时间的时间戳。此时,可选值processingStartTimeprocessingEndTime都被设置为None,因为这些值在批处理周期的此阶段是未知的。

onBatchStarted

def onBatchStarted(batchStarted: StreamingListenerBatchStarted): Unit

当一个单独作业的处理刚刚开始时,将调用此方法。由提供的StreamingListenerBatchStarted实例嵌入的BatchInfo对象包含了填充的processingStartTime

onBatchCompleted

def onBatchCompleted(batchCompleted: StreamingListenerBatchCompleted): Unit

当批处理完成时,将调用此方法。提供的BatchInfo实例将完全填充有批处理的总体时间信息。OutputOperationInfo的映射还将包含每个输出操作执行的详细时间信息。

输出操作事件

StreamingListener回调接口的此部分提供了由批次提交触发的每个作业执行级别的信息。由于可能有多个输出操作注册到同一批次中,此接口可能以比StreamingContext的批处理间隔更高的频率触发。任何接收此接口提供的数据的客户端都应该调整为接收这样大量的事件。

这些方法报告的事件包含了一个OutputOperationInfo实例,该实例提供了关于正在报告的输出操作的进一步时间详细信息。此OutputOperationInfo是刚刚在与批处理相关的事件中看到的BatchInfo对象的outputOperationInfo中包含的相同数据结构。对于仅对每个作业的时间信息感兴趣,但不需要实时了解执行生命周期的情况,您应该参考由onBatchCompleted提供的事件(正如刚刚所描述的)。

这里是您可以在作业执行期间注入自己处理的回调。

onOutputOperationStarted

def onOutputOperationStarted(
  outputOperationStarted: StreamingListenerOutputOperationStarted): Unit

当一个作业的处理(对应于一个输出操作)开始时,将调用此方法。可以通过batchTime属性将单个作业关联回其对应的批次。

onOutputOperationCompleted

def onOutputOperationCompleted(
  outputOperationCompleted: StreamingListenerOutputOperationCompleted): Unit

当单个作业的处理完成时,将调用此方法。请注意,没有回调来通知单个作业的失败。OutputOperationInfo实例包含一个名为failureReason的属性,它是一个Option[String]。在作业失败的情况下,此选项将被填充为Some(error message)

注册StreamingListener

在我们开发了自定义的StreamingListener之后,我们需要将其注册以消费事件。流监听总线由StreamingContext托管,后者提供了一个注册调用来添加StreamingListener特性的自定义实现。

假设我们实现了一个LogReportingStreamingListener,将所有事件转发到一个日志框架。示例 25-1 展示了如何在StreamingContext中注册我们的自定义监听器。

示例 25-1. 自定义监听器注册
val streamingContext = new StreamingContext(sc, Seconds(10))
val customLogStreamingReporter = new LogReportingStreamingListener(...)
streamingContext.addStreamingListener(customLogStreamingReporter)

摘要

在本章中,您学习了观察和持续监控运行中的 Spark Streaming 应用程序的不同方法。考虑到作业的性能特征是保证其稳定部署到生产环境的关键因素,性能监控是您在开发周期的早期阶段应执行的活动。

Streaming UI 作为 Spark UI 中的一个选项,非常适合对流式应用程序进行交互式监控。它提供高级图表,跟踪流作业的关键性能指标,并提供详细视图的链接,您可以深入调查组成批处理的各个作业的级别。这是一个能够产生可操作洞见的积极过程,了解流作业的性能特征。

一旦作业被持续执行,显然我们无法 24/7 进行人工监控。通过消费基于 REST 的接口,您可以与现有的监控和警报工具集成,该接口报告的数据与 Streaming UI 或 Metrics Subsystem 中可用的数据相当,后者提供了标准的可插拔外部接口,供监控工具使用。

对于需要完全可定制解决方案的特定用例,可以实现自定义StreamingListener,并将其注册到应用程序的StreamingContext中,以实时获取最精细粒度的执行信息。

这种广泛的监控方案确保您的 Spark Streaming 应用程序部署可以与生产基础设施中的通用监控和警报子系统共存,并遵循企业内部的质量流程。

第二十六章:性能调优

分布式流应用程序的性能特征通常受其运行中的内部和外部因素之间复杂关系的制约。

外部因素与应用程序执行环境绑定,如构成集群的主机及连接它们的网络。每个主机提供诸如 CPU、内存和存储等资源,具有特定的性能特征。例如,我们可能有磁盘通常速度较慢但提供低成本存储的磁盘,或者提供高成本每存储单位较快访问的快速固态驱动器(SSD)阵列。或者我们可能正在使用云存储,其性能受网络容量和可用互联网连接的限制。同样,数据生产者通常不受流应用程序控制之外。

在内部因素方面,我们考虑实施的算法复杂性、分配给应用程序的资源以及决定应用程序行为的特定配置。

在本章中,我们首先努力深入了解 Spark Streaming 的性能因素。然后,我们调查几种可以应用于调优现有作业性能的策略。

Spark Streaming 的性能平衡

在 Spark Streaming 中进行性能调优有时可能会很复杂,但始终始于批处理间隔和批处理时间的简单平衡。我们可以将批处理时间视为完成所有接收数据及相关簿记处理所需的时间成本,而批处理间隔则是我们分配的预算。就像财务类比一样,一个健康的应用程序将在分配的预算内完成其处理成本。虽然在一些特定时刻压力增大时可能会超出预算,但我们必须看到从长期来看,我们的平衡是得以保持的。一个长期超出这种时间预算平衡的应用程序将导致系统性失败,通常由于资源耗尽导致应用程序崩溃。

批处理间隔与处理延迟之间的关系

一般来说,流应用程序的一个重要限制是数据摄入不会停止。在 Spark Streaming 中,数据摄入是在固定的间隔内发生的,并且没有任何方法可以随意关闭它。因此,如果在新的批处理间隔开始时作业队列还没有空,新数据又被插入系统中,Spark Streaming 需要在处理新数据之前完成先前作业的处理。

只有一个作业在运行时,我们可以看到以下情况:

  • 如果批处理时间仅暂时大于批处理间隔,但一般情况下,Spark 能够在批处理间隔内处理一个批次,Spark Streaming 最终将赶上并清空作业(RDD)队列。

  • 另一方面,如果迟延是系统性的,并且集群平均需要超过一个批处理间隔来处理一个微批次,Spark Streaming 将在每个批处理间隔上平均接受更多数据,而无法从其存储管理中删除。最终,集群将耗尽资源并崩溃。

然后,我们需要考虑当那些多余数据积累稳定一段时间后会发生什么。默认情况下,代表输入到系统的数据的 RDD 被放置在集群机器的内存中。在那个内存中,原始数据——源 RDD——需要复制,这意味着随着数据被输入到系统中,在每个块间隔上逐渐为了容错性创建第二个副本。因此,暂时的时间内,直到处理该 RDD 的数据,该数据在系统执行者的内存中存在两个副本。在接收者模型中,因为数据始终在接收者上存在一个副本,所以这台机器承担了大部分的内存压力。

一个作业失败的最后时刻

最终,如果我们在系统中某个地方添加了太多的数据,我们最终会溢出几个执行者的内存。在接收者模型中,这可能是接收者执行者因为OutOfMemoryError而崩溃。接下来发生的是集群中的另一台机器将被指定为新的接收者,并开始接收新数据。因为那个接收者内存中的一些块由于崩溃而丢失,它们现在只存在于集群中的单一副本中,这意味着在处理这些数据之前会触发对这些数据的重复。因此,集群中现有的执行者将承受先前的内存压力——在崩溃期间丢失的数据没有固有的缓解。少数执行者将忙于复制数据,而一个新的执行者将再次接受数据。但请记住,如果在崩溃之前我们的集群包含 N 个执行者,现在由 N - 1 个执行者组成,并且在处理相同的数据摄入节奏时可能较慢——更不用说大多数执行者现在忙于数据复制而不是像往常一样的处理。我们在崩溃之前观察到的批处理时间现在只能更高,并且特别是高于批处理间隔。

总之,平均批处理时间高于批处理间隔可能会在整个集群中引发级联崩溃。因此,在考虑批处理间隔作为集群正常运行期间可能要执行的所有操作的时间预算时,保持 Spark 的平衡非常重要。

注意

通过将spark.streaming.concurrent.jobs设置为比您的 Spark 配置中的值大的值,您可以取消仅允许一个作业在给定时间执行的约束。然而,这可能存在风险,因为它可能会导致资源竞争,并且可能会使调试是否系统中有足够的资源来处理摄入数据变得更加困难。

深入探讨:调度延迟和处理延迟

许多因素可能会影响批处理时间。当然,首要的约束是要在数据上执行的分析——作业本身的逻辑。该计算的运行时间可能取决于数据的大小,也可能不取决于数据中的值。

这种纯计算时间在处理延迟的名义下被考虑进去,它是运行作业所花时间和设置作业所花时间之间的差异。

另一方面,调度延迟考虑的是在获取作业定义(通常是一个闭包),序列化它并发送到需要处理它的执行器所需的时间。自然地,这种任务的分发意味着一些开销——并非全部用于计算的时间,因此明智的做法是不要将我们的工作负载分解为太多的小作业,并调整并行性,使其与我们集群上的执行器数量相匹配。最后,调度延迟还考虑了作业迟到,如果我们的 Spark Streaming 集群在其队列中积累了作业。它正式定义为作业(RDD)进入作业队列和 Spark Streaming 实际开始计算之间的时间。

影响调度延迟的另一个重要因素是地域设置,特别是spark.locality.wait,它规定在向数据相关任务的最本地放置等待多长时间之前升级到下一个地域级别。以下是地域级别:

PROCESS_LOCAL

同一进程的 Java 虚拟机(JVM)。这是最高的地域级别。

NODE_LOCAL

同一执行器机器。

NO_PREF

无地域偏好。

RACK_LOCAL

同一服务器机架。

ANY

这是最低的地域级别,通常是由于无法获取任何上面级别的地域而导致的。

处理时间中的检查点影响

还有其他因素可能出乎意料地会影响批处理时间,特别是检查点。如第二十四章中讨论的那样,检查点是在处理有状态流时必需的保障,以避免在故障恢复时发生数据丢失。它使用中间计算值在磁盘上进行存储,以便在故障发生时,不需要重新计算从数据源以来看到的流中的值所依赖的数据,而只需从最后一个检查点的时间开始重新计算。检查点操作由 Spark 结构化地编程为周期性作业,因此,制作检查点所花费的时间实际上被视为处理延迟的一部分,而不是调度延迟。

对于有状态流的常规检查点,通常在语义上和保护数据大小方面,检查点的持续时间可能远远超过批处理间隔的时间。检查点的持续时间长达 10 个批处理间隔并不罕见。因此,在确保平均批处理时间小于批处理间隔时,有必要考虑检查点。检查点对平均批处理时间的贡献如下:

checkpointingdelay batchinterval * c h e c k p o i n t i n g d u r a t i o n

这应该添加到非检查点作业期间观察到的平均计算时间中,以了解真实批处理时间的概念。或者,另一种处理方式是计算在我们的预算(批处理间隔和批处理时间之间的差异)中没有检查点的剩余时间,并在函数中调整检查点间隔:

c h e c k p o i n t i n g d e l a y c h e c k p o i n t i n g d u r a t i o n / ( b a t c h i n t e r v a l - batchprocessingtime * )

当*标记批处理时间的测量时,不使用检查点。

影响作业性能的外部因素

最后,如果所有这些因素都已考虑进去,但您仍然注意到作业处理延迟出现波动,我们确实需要注意的另一个方面是集群上的变化条件。

例如,我们集群中共存的其他系统可能会影响我们共享的处理资源:已知 Hadoop 分布式文件系统(HDFS)在其较旧版本中存在有限制并发磁盘写入的错误。¹ 因此,我们可能正在以非常稳定的速率运行集群,同时,一个可能与 Spark 无关的不同作业可能需要大量使用磁盘。这可能会影响以下内容:

  • 数据在可靠接收模型中的接收,在使用写前日志(WAL)时

  • 检查点时间

  • 我们流处理中涉及将数据保存到磁盘的操作。

为了通过磁盘使用减轻作业受外部影响的问题,我们可以采取以下措施:

  • 使用 Alluxio 等分布式内存缓存

  • 将结构化的小数据保存在 NoSQL 数据库中,而不是保存在文件中,以减少磁盘压力。

  • 避免将更多磁盘密集型应用与 Spark 放置在一起,除非绝对必要

磁盘访问只是可能影响我们通过资源共享与集群的作业的一种潜在瓶颈。另一种可能性可能是网络饥饿,或者更普遍地说,存在无法通过我们的资源管理器监视和调度的工作负载。

如何提高性能?

在前一节中,我们讨论了可以影响 Spark Streaming 作业性能的内在和外在因素。

假设我们处于这样一种情况:我们开发了一个作业,并观察到某些影响性能和因此作业稳定性的问题。采取的第一步将是深入了解我们作业的不同性能指标,也许可以使用“使用流式 UI 理解作业性能”中概述的技术。

我们将这些信息作为比较基准,并指导使用以下的一个或多个不同策略。

调整批处理间隔

经常提到的一个策略是延长批处理间隔。这种方法可能有助于改善一些并行性和资源使用问题。例如,如果我们将批处理间隔从一分钟增加到五分钟,那么我们每五分钟只需序列化一次作业中的组件任务,而不是每分钟一次,从而减少五倍。

尽管如此,我们流的批次将表示通过“线上”看到的五分钟数据,而不是一分钟数据,因为大多数不稳定性问题是由于我们的资源分配不足以支持我们流的吞吐量所导致的,批处理间隔对于这种不平衡可能改变很少。更重要的是,我们希望实现的批处理间隔在我们的分析中通常具有很高的语义值;例如,正如我们在第二十一章中看到的,它限制了我们可以在聚合流上创建的窗口和滑动间隔。只有在最后一种情况下,我们才应该考虑改变这些分析语义以适应处理约束。

一种更具吸引力的策略是减少一般的低效率,例如使用快速序列化库或实现具有更好性能特性的算法。我们还可以通过增加或替换我们的分布式文件系统为内存缓存,如Alluxio,来加快磁盘写入速度。当这些措施不足以满足需求时,我们应该考虑通过调整块间隔来增加集群资源,从而通过相应增加使用的分区数来将流分布到更多执行器上。

通过固定速率节流来限制数据入口

如果绝对不能获取更多资源,我们需要考虑减少必须处理的数据元素数量。

自版本 1.3 起,Spark 包含了一个固定速率的限流功能,允许其接受最大数量的元素。我们可以通过在 Spark 配置中添加 spark.streaming.receiver.maxRate 来设置每秒元素的值。请注意,对于基于接收器的消费者,此限制在块创建时生效,并且如果达到了限制,它将简单地拒绝从数据源读取更多元素。

对于 Kafka 直连器,有一个专门的配置 spark.streaming.kafka.maxRatePerPartition,设置每个分区中主题的最大速率限制,以记录每秒。在使用此选项时,请注意总速率将如下:

m a x R a t e P e r P a r t i t i o n p a r t i t i o n s p e r t o p i c b a t c h i n t e r v a l

请注意,这种行为本身并不包括任何信号传递;Spark 只会在批次间隔结束时允许有限数量的元素,并在下一个批次间隔开始时继续读取新元素。这对将数据馈送到 Spark 的系统有影响:

  • 如果这是一个拉取式系统,例如 Kafka、Flume 等,输入系统可以计算读取的元素数量,并以自定义方式管理溢出数据。

  • 如果输入系统更加朴素地是一个缓冲区(文件缓冲区、TCP 缓冲区),它将在几个块间隔后溢出(因为我们的流比限流的吞吐量大),并且会定期刷新(删除)。

由于 Spark 中的限流机制,可以表现出读取元素时的一些“抖动”,因为 Spark 会读取每个元素,直到底层的 TCP 或文件缓冲区,用作“延迟”元素的队列,达到容量并整体刷新。这样做的效果是输入流被分隔成大量处理元素的间隔,其中夹杂着定期大小的“空洞”(丢失的元素)(例如,一个 TCP 缓冲区)。

回压

我们描述的基于队列的系统通过固定速率限流存在一个缺点,即使我们整个流水线都不易理解效率低下的原因。事实上,我们考虑了一个数据源(例如 TCP 套接字),它由从外部服务器读取数据(例如 HTTP 服务器)组成,进入本地系统级队列(TCP 缓冲区),然后 Spark 将此数据传送到应用级缓冲区(Spark Streaming 的 RDD)。除非我们使用与 Spark Streaming 接收器绑定的监听器,否则很难检测和诊断系统是否拥挤,以及拥挤发生在哪里。

如果外部服务器意识到我们的 Spark Streaming 集群拥塞,它可以决定根据该信号做出反应,并使用自己的方法延迟或选择要发送到 Spark 的入站元素。更重要的是,它可以使拥塞信息沿着流向上传回其依赖的数据生产者,调用管道的每个部分意识到并帮助处理拥塞。这还允许任何监控系统更好地查看拥塞在我们系统中的发生位置,从而有助于资源管理和调优。

上游流动量化信号关于拥塞的信息被称为背压。这是一个连续的信号,明确表示了在特定时刻我们期望系统(这里是我们的 Spark Streaming 集群)处理多少元素。与节流相比,背压信号有一个优势,因为它被设置为动态信号,根据元素的流入以及 Spark 队列的状态而变化。因此,如果没有拥塞,它不会影响系统,并且不需要调整任意限制,避免配置错误(如果限制过于严格,则资源未充分利用;如果限制过于宽松,则会溢出)的相关风险。

此方法自 Spark 1.5 版本起可用,简言之,可提供动态节流。

动态节流

在 Spark Streaming 中,默认使用比例积分微分(PID)控制器来动态调节节流,它通过观察错误信号来调节,这个错误信号是指最新批处理间隔内的摄入速率(以每秒元素数计)与处理速率之间的差异。我们可以将这个错误视为当前时刻进入 Spark 的元素数与离开 Spark 的元素数之间的不平衡(“即时”被调整为完整批处理间隔)。

然后,PID 控制器旨在通过考虑以下因素来调节下一个批处理间隔中的摄入元素数:

  • 比例项(此时的错误)

  • 积分或“历史”项(过去所有错误的总和;这里指队列中未处理元素的数量)

  • 导数或“速度”项(过去元素数量减少的速率)

然后,PID 试图计算一个理想数,具体取决于这三个因素。

在 Spark 中,基于背压的动态节流可以通过在 Spark 配置中将spark.streaming.backpressure.enabled设置为true来启用。另一个变量spark.streaming.backpressure.initialRate决定了节流初始应预期的每秒处理元素数。您应将其设置为略高于流吞吐量的最佳估计,以允许算法“预热”。

注意

关注反压来处理管道系统中的拥塞问题受到了反应流规范的启发,这是一个与实现无关的 API,旨在实现一个关于这种方法优势的宣言,得到了包括 Netflix、Lightbend 和 Twitter 在内的多个行业参与者的支持。

调整反压 PID

PID 调优是一个成熟且广泛的主题,超出了本书的范围,但 Spark Streaming 用户应该对其用途有直觉。比例项有助于处理当前错误的快照,积分项帮助系统处理到目前为止累积的错误,导数项帮助系统避免在系统快速修正时过冲或在面对流元素吞吐量急剧增加时欠修正。

PID 的每个术语都有一个附加的权重因子,介于 0 和 1 之间,适合经典的 PID 实现。以下是您需要在 Spark 配置中设置的参数:

spark.streaming.backpressure.pid.proportional
spark.streaming.backpressure.pid.integral
spark.streaming.backpressure.pid.derived

默认情况下,Spark 实现了一个比例-积分控制器,比例权重为 1,积分权重为 0.2,导数权重为 0。这在 Spark Streaming 应用程序中提供了一个合理的默认值,其中流的吞吐量相对于批处理间隔变化比较慢,并且更容易解释:Spark 的目标是不超过最后一个处理速率,并且在每个批次中有一个用于处理五分之一迟到元素的“缓冲区”。然而,请注意,如果面对变化快速且吞吐量不规则的流,您可能需要考虑使用非零导数项。

自定义速率估算器

PID 估算器并非是我们可以在 Spark 中实现的唯一速率估算器。它是RateEstimator trait 的一个实现,可以通过将spark.streaming.backpressure.rateEstimator的值设置为您的类名来交换特定的实现。请记住,您需要在 Spark 类路径中包含相关类;例如,通过spark-submit--jars参数。

RateEstimator trait 是一个可序列化的 trait,需要一个单独的方法:

  def compute(
      time: Long,
      elements: Long,
      processingDelay: Long,
      schedulingDelay: Long): Option[Double]
}

此函数应返回流连接到此RateEstimator应每秒摄取的记录数的估计值,考虑到最新批次的大小和完成时间的更新。您可以自由贡献替代实现。

替代动态处理策略的注意事项

在 Spark 中,动态或静态的节流表达在InputDStream类中,这包括接收模型的ReceiverInputDStream和 Kafka 直接接收器的DirectKafkaInputDStream。目前,这些实现都有一种简单的处理多余元素的方式:它们既不从输入源读取(ReceiverInputDStream),也不从主题消费(DirectKafkaInputDStream)。

但是,在InputDStream接收到的背压信号的应用程序生命周期内,可以合理地提出几种可能的替代实现。我们可以想象诸如取第一个、最大的或最小的元素,或者是一个随机样本的策略。

不幸的是,这些类的rateController: RateController成员是protected[streaming]的,但该成员具有一个getLatestRate函数,该函数允许 DStream 实现在任何时刻接收到相关限制。因此,任何自定义 DStream 的实现都可以从速率控制的非公开但开放源代码的方法中汲取灵感,以更好地处理拥塞情况。

缓存

在 Spark Streaming 中,缓存是一种功能,当良好地操作时,可以显著加快应用程序执行的计算。这似乎是违反直觉的,因为在作业运行之前,表示计算输入中存储的数据的基础 RDD 实际上被复制了两次。

然而,在你的应用程序的生命周期内,可能存在一个非常长的流水线,将你的计算从这些基础 RDD 到一些非常精细和结构化的数据表示,通常涉及键-值元组。在应用程序执行的计算结束时,你可能正在考虑将计算输出的某些部分分发到各种输出:例如数据存储或数据库,如 Cassandra。这种分发通常涉及查看在上一批次间隔期间计算的数据,并找出应将输出数据的哪些部分放在哪里。

对于这种情况的典型用例是查看结构化输出数据的 RDD 中的键(计算中的最后一个 DStream),以确定根据这些键将计算结果放置在 Spark 之外的确切位置。另一个用例是仅查找上一批次接收到的 RDD 中的某些特定元素。事实上,你的 RDD 实际上可能是依赖于不仅是最后一批数据,而是从应用程序启动以来接收的许多先前事件的计算输出。你的流水线的最后一步可能总结了系统的状态。在那个输出结构化结果的 RDD 上查找,我们可能正在寻找通过某些标准的某些元素,将新结果与先前值进行比较,或者将数据分发给不同的组织实体,列举几种情况。

例如,想象一下异常检测。您可能会对值(定期监视的用户或元素)计算一些指标或特征。其中一些特征可能会显示出一些问题或需要生成一些警报。为了将这些输出到警报系统,您需要在当前查看的数据 RDD 中找到通过某些标准的元素。为此,您将会迭代结果 RDD。除了警报外,您可能还希望发布应用程序的状态,例如,用于提供数据可视化或仪表板,以通知您正在调查的系统的更一般特征。

这个思维实验的要点是设想在输出 DStream 上计算涉及到为管道最终结果的每个 RDD 进行多次操作,尽管它非常结构化且可能从输入数据中减少了大小。为此,在多次迭代发生之前使用缓存来存储该最终 RDD 非常有用。

当你在缓存的 RDD 上进行多次迭代时,第一次迭代与非缓存版本花费的时间相同,而每个后续迭代只需花费少量时间。这是因为虽然 Spark Streaming 的基础数据被缓存在系统中,但在应用程序定义的潜在非常长的流水线中,需要逐步从该基础数据中恢复中间步骤。在处理数据时,每次迭代都需要检索详细数据,如下所示:

dstream.foreachRDD{ (rdd)  =>
  rdd.cache()
  keys.foreach{ (key) =>
    rdd.filter(elem=> key(elem) == key).saveAsFooBar(...)
  }
  rdd.unpersist()
}

因此,如果您的 DStream 或对应的 RDD 被多次使用,将它们缓存会显著加快处理速度。但非常重要的是不要过度使用 Spark 的内存管理,并假设 DStream 的 RDD 在批处理间隔后会自然地从缓存中移除。在每个 DStream 的 RDD 迭代结束时,请务必考虑取消持久化 RDD,以便让它从缓存中移除。

否则,Spark 将需要进行相对聪明的计算,以尝试理解应保留哪些数据片段。这种特定的计算可能会减慢应用程序的结果或限制其可访问的内存。

最后要考虑的一点是,不应过度使用cache操作。如果缓存数据没有足够多次使用,cache操作的成本可能超过其带来的好处。总结一下,cache是一个提升性能的函数,应谨慎使用。

预测执行

Apache Spark 处理流或批处理执行中的落后作业时,使用 推测执行。该机制利用了 Spark 处理将同一任务同时放入每个工作节点的队列的特点。因此,估计每个工作节点完成一个任务应该需要大致相同的时间。如果不是这种情况,通常是由于以下两个原因之一:

  • 我们的数据集可能受到数据倾斜的影响,其中少数任务集中了大部分的计算。在某些情况下这是正常的,³ 但在大多数情况下是一个不好的情况,我们需要通过重新分配输入(例如通过洗牌)来改善。

  • 或者某个特定执行器由于硬件故障或者共享集群中的负载过载而运行缓慢。

如果 Spark 检测到异常长的执行时间并且有可用资源,它可以重新启动当前运行缓慢的任务到另一个节点。这个推测任务(假设原始任务出了问题)将要么首先完成并取消旧任务,要么在前者返回后立即被取消。总体来说,“乌龟和兔子”的竞争能够实现更好的完成时间和资源利用。

推测执行响应四个配置参数,详见表 26-1。

表 26-1. 推测执行配置参数

选项 默认 含义
spark.speculation false 如果设置为“true”,则执行任务的推测执行
spark.speculation.interval 100ms Spark 检测任务进行推测的频率
spark.speculation.multiplier 1.5 任务比中位数慢多少倍才考虑进行推测
spark.speculation.quantile 0.75 特定阶段启用推测执行前完成的任务比例

¹ 您可以参考 HDFS-7489 来了解这些微妙的并发问题的一个示例。

² Alluxio 最初名为 Tachyon,是 Spark 代码库的一部分,这表明它的功能与 Spark 数据处理非常互补。

³ 例如,在异常检测推断中,探测到异常值的执行器有时还需要额外的警报工作,这是常规节点职责的额外负担。

第三部分参考资料

  • [Armbrust2015] Armbrust, Michael, Reynold S. Xin, Cheng Lian, Yin Huai, Davies Liu, Joseph K. Bradley, Xiangrui Meng, 等. “Spark SQL: Spark 中的关系数据处理,” SIGMOD, 2015. http://bit.ly/2HSljk6.

  • [Chintapalli2015_3] Chintapalli, S., D. Dagit, B. Evans, R. Farivar, T. Graves, M. Holderbaugh, Z. Liu, 等. “在 Yahoo! 进行流计算引擎的基准测试,” Yahoo! 工程, 2015 年 12 月 18 日. http://bit.ly/2HSkpnG.

  • [Das2016] Das, T. 和 S. Zhu. “Apache Spark Streaming 中更快的有状态流处理,” Databricks 工程博客, 2016 年 2 月 1 日. http://bit.ly/2wyLw0L.

  • [Das2015_3] Das, T., M Zaharia, 和 P. Wendell. “深入 Apache Spark Streaming 的执行模型,” Databricks 工程博客, 2015 年 7 月 30 日. http://bit.ly/2wBkPZr.

  • [Greenberg2015_3] Greenberg, David. 在 Mesos 上构建应用. O’Reilly, 2015.

  • [Hammer2015] Hammer 实验室团队, “使用 Graphite 和 Grafana 监控 Spark,” Hammer Lab 博客, 2015 年 2 月 27 日. http://bit.ly/2WGdLJE.

  • [Karau2017] Karau, Holden, 和 Rachel Warren. 高性能 Spark 第一版. O’Reilly, 2017.

  • [Kestelyn2015_3] Kestelyn, J. “从 Apache Kafka 实现 Spark Streaming 的精确一次处理,” Cloudera 工程博客, 2015 年 3 月 16 日. http://bit.ly/2IetkPA.

  • [Koeninger2015_3] Koeninger, Cody, Davies Liu, 和 Tathagata Das. “Spark Streaming 对 Kafka 集成的改进,” Databricks 工程博客, 2015 年 3 月 30 日. http://bit.ly/2QPEZIB.

  • [Krishnamurthy2010] Krishnamurthy, Sailesh. “连续流的连续分析,” SIGMOD, 2010. http://bit.ly/2wyMES3.

  • [Medasani2017_3] Medasani, Guru, 和 Jordan Hambleton. “Apache Kafka 在 Apache Spark Streaming 中的偏移管理,” Cloudera 工程博客, 2017 年 6 月 21 日. http://bit.ly/2EOEObs.

  • [Noll2014] Noll, Michael. “集成 Kafka 和 Spark: 代码示例和现状,” 个人博客, 2014 年 10 月 1 日. http://bit.ly/2wz4QLp.

  • [SparkMonDocs] “监控与仪表化,” Apache Spark 在线文档. http://bit.ly/2EPY889.

  • [Valiant1990_3] Valiant, L.G. “批同步并行计算机.” ACM 通讯 33:8 (1990 年 8 月). http://bit.ly/2IgX3ar.

  • [Vavilapalli2013_3] Vavilapalli 等人. “Apache Hadoop YARN: 又一个资源协调器,” SOCC 2013. http://bit.ly/2Xn3tuZ.

  • [White2009] White, Tom. “小文件问题,” Cloudera 工程博客, 2009 年 2 月 2 日. http://bit.ly/2WF2gSJ.

  • [White2010_3] White, Tom. Hadoop: 完全指南. O’Reilly, 2010.

第四部分:高级 Spark Streaming 技术

在本部分中,我们将检查一些更高级的应用,您可以使用 Spark Streaming 创建这些应用,即近似算法和机器学习算法。¹

近似算法为推动 Spark 的可扩展性提供了一扇窗口,并且在数据吞吐量超过部署能力时,提供了一种优雅降级的技术。在本部分中,我们涵盖:

  • 哈希函数及其在草图构建块中的使用

  • HyperLogLog 算法,用于计算不同元素的数量

  • Count-Min-Sketch 算法,用于回答关于结构顶部元素的查询

我们还介绍了 T-Digest,这是一个有用的估计器,允许我们使用聚类技术对值分布进行简洁表示。

机器学习模型提供了一些新技术,能够在不断变化的数据流上产生相关且准确的结果。在接下来的章节中,我们将看到如何调整已知的批处理算法,例如朴素贝叶斯分类、决策树和 K-Means 聚类,使其适应流式处理。这将使我们依次涵盖:

  • 在线朴素贝叶斯

  • Hoeffding 树

  • 在线K-均值聚类

这些算法将形成他们在批处理形式下处理 Spark 中的流的补充,在[Laserson2017]中。这将为您提供强大的技术,用于对数据流的元素进行分类或聚类。

¹ 虽然我们重点介绍 Spark Streaming,但我们描述的一些算法(如 HyperLogLog)已经作为 Spark 的一部分内置函数存在,例如 approxCountDistinct,它们作为 DataFrames API 的一部分。

第二十七章:流式逼近与采样算法

当涉及到随时间产生观察数据摘要时,流处理面临特殊的挑战。因为我们只有一次机会观察流中的值,即使在有界数据集上被认为简单的查询在想要在数据流上回答同样的问题时也变得复杂。

问题的关键在于这些查询如何要求一个全局摘要的形式,或者一个最大值结果,例如观察整个数据集:

  • 流中所有不同元素的计数(摘要)

  • 流中k个最高的元素(全局最大值)

  • 流中k个最频繁的元素(全局最大值)

自然而然地,当数据来自流时,一次性看到整个数据集是困难的。这类查询可以通过存储整个流,然后将其视为数据批处理来简单回答。但这种存储不仅不总是可能的,而且是一种笨拙的方法。正如你将看到的,我们可以构建简明的数据表示来反映我们流的主要数值特征。这种简洁性有一个代价,即返回的答案的准确性: 这些数据结构及其操作它们的算法返回近似结果,具有特定的误差界限。总结如下:

  • 精确算法更精确,但资源消耗非常大

  • 近似算法不够精确,但我们愿意接受稍微不那么精确的结果,而不是承担额外的资源成本。

在本节中,我们研究了将近似算法和采样技术应用于通过有限资源对观察到的数据流中的元素进行全局问题的总结的应用。首先,我们探讨了在面对大量数据时实时响应与精确性之间的张力。然后,我们介绍了我们需要理解的三种覆盖的近似方法的哈希和素描的概念:

HyperLogLog(HLL)

用于计数不同元素

CountMinSketch(CMS)

用于计数元素频率

T-Digest

用于近似观察元素的频率直方图

我们以概述不同的采样方法及其在 Spark 中的支持结束本章。

精确性、实时性和大数据

分布式计算,在处理连续数据流时通常被认为是一种特殊的问题,因为它受三角概念的限制:

  • 产生结果的精确性

  • 实时发生的计算

  • 大数据的计算

让我们详细看看这些概念:

精确性

首先,我们可以看到精确计算是对我们从数据中提问要求得出精确数值答案的反映。例如,如果我们正在监控来自网站的数据,我们可能希望通过分析网站产生的交互、事件和日志来了解当前独立用户的数量。

实时处理

第二个方面是分析的新鲜度或延迟性。在这个背景下,延迟性指的是从数据第一次可用到我们能够得出一些见解之间的时间。回到网站访问者的例子,我们可以在一天结束时询问“独立用户”的问题。我们会分析网站在过去 24 小时内产生的日志,并尝试计算在那段时间内访问的独立用户数量。这样的计算可能需要几分钟到几小时才能得出结果,这取决于我们需要处理的数据量。我们认为这是一种高延迟的方法。我们也可以随时询问“网站上有多少用户”,并期望立即得到答案。但是,由于浏览器与 Web 服务器的交互是及时的查询,所谓的“立即”实际上是指“我们在精心选择的短暂时间内来代表浏览会话的长度内有多少独立访问者?”

大数据

我们需要解决的第三个问题是,我们处理的数据有多大?我们是在看一个本地体育俱乐部的网站,每次只有几个用户在访问,还是在看亚马逊这样的大型零售商的网站,每时每刻都迎来成千上万的访问者?

精确性、实时性和大数据三角形

精确性、实时处理和数据量的概念可以用三角形表示,如图 27-1 所示。这个三角形反映出,在数据量增加时,实现精确性和新鲜度(接近实时结果)是矛盾的:这三个需求很少能同时满足。直到最近,分布式数据处理的分析通常只关注这个三角形的两个方面。

spas 2701

图 27-1. 精确结果、实时处理和大数据的三角形

例如,在网站监控领域,我们过去专注于精确和实时处理,设计了系统,使我们能够快速了解有多少用户访问我们的网站,并提供确切的独立用户数量的答案。

这是流处理的常规工作。近年来,由于大数据的吸引力,它已经落后了。越来越多的在线企业通常将可伸缩性放在首位,因为当一个大型网站扩展时,其维护者仍然希望得到非常精确的关于访问用户数量的结果,因此常常需要分析大量访问者的数量。然后,他们需要认清答案的计算可能需要大量时间,有时比收集这些数据所花费的时间还长。

这通常通过诸如 Apache Spark 的分析框架来处理非常庞大网站的日志。基于周期性(例如,月均值)的估算可能足以进行长期容量规划,但随着企业需要更快地对不断变化的环境做出反应,我们对问题的快速答案越来越感兴趣。如今,在弹性云基础设施的时代,商业网站维护者希望能够实时了解条件是否发生变化,因为他们可以据此做出反应,我们即将看到。

大数据与实时

要解决这个难题,我们需要转向三角形的第三边,介于大数据和实时顶点之间。

例如,Walmart 是一个商业零售网站,2018 年每月约有 3 亿个独立用户访问。然而,在 2012 年黑色星期五,访问 Walmart 网站以享受特别销售的用户数量在几小时内就翻了一番,正如在 图 27-2 中所示,这种情况并不可预测。网站基础设施处理这一新用户涌入所需的资源突然比预期的要高得多。

spas 2702

图 27-2. 2012 年黑色星期五 Walmart.ca 的流量(图片由 Lightbend, Inc. 提供)。

可伸缩性问题对我们实时生成答案的能力有影响。在 Walmart 网站上的黑色星期五统计不同用户的网络会话比其他任何一天都要花费更长的时间。

在这种情况下,与延迟的确切答案相比,我们更希望有一个快速的近似答案更有用。近似答案更容易计算,但仍然具有操作价值:一个好的网站管理员将为网站提供资源,以匹配网站访问者的数量的大致范围 —— 用户数量的小误差会导致成本增加(未使用的资源),而这些成本将会被年度最繁忙的在线购物日的收入增加所补偿。

如果资源不足,管理员还可以通过例如接纳控制(即将一部分用户重定向到静态页面,并以高效的方式为这些用户提供服务,而不是让所有用户遭受慢速、无法使用的网站)来更优雅地降低服务的质量。管理员所需的仅仅是一个大概的估计。

近似算法

近似算法在可扩展性上比传统的精确算法要好得多,我们将在接下来的页面上详细量化这一点。

在本节中,我们将介绍多种近似算法,这些算法可以在实时数据流上计算结果,包括允许我们计算不同用户数量的算法,允许我们得到数据流中数值直方图的概念的算法,以及得到数据流中经常遇到的数值的近似的算法。

哈希和草图:简介

哈希函数是一个将任意大小的输入映射到固定大小输出的函数。它们在计算机科学应用中被广泛使用,用例包括加密应用、哈希表和数据库。其中之一是作为几个对象的等价或接近等价的代表。

让我们回到哈希函数的定义。哈希函数将其输入域的对象(例如,任意长的String元素)映射到固定大小的输出域。让我们将这个输出域考虑为 32 位整数的空间。一个有趣的特性是这些整数很好地标识这些字符串:如果我们能够处理 32 位整数,我们只需进行少量计算就能确定两个大字符串是否相等。

在哈希函数的设计中,我们经常说我们希望它们具有碰撞抵抗力,这意味着对于随机选择的两个不同输入文档来说,它们产生相同的哈希是不可能的。

警告

我们对碰撞抵抗力的定义是放松的,因为密码学安全的哈希函数与我们的上下文无关。在这个情况下,我们要求敌手难以有意找到两个产生相同哈希值的不同文档。

当然,给每个可能的字符串编号并不是简单的事情:在 Java 虚拟机(JVM)上,我们只能表示 4,294,967,295 个 32 位整数(大约 40 亿个),而String元素却比这更多。

实际上,如果我们将超过五十亿个不同的文档映射到具有四十亿大小的 32 位整数空间中,那么至少有十亿个文档会导致我们的哈希函数重新使用它已经与先前文档关联的整数。我们称这种将两个不同文档映射到相同哈希的映射为碰撞。无论函数如何,当我们的函数用于比其固定大小输出域更大的输入集时,这些碰撞总会发生。

然而,由于我们的应用处理从非常大的数据集中提取的文档,我们希望我们要么有一个足够大的哈希函数,要么永远不会比较那些会导致碰撞的几个元素(即,文档相等的误判)。

我们将哈希值视为特定元素身份的标记,选择良好的哈希函数意味着我们将节省大量计算时间,因为我们比较的是哈希值而不是整个文档,并且碰撞概率降低到基数比较。

哈希概率碰撞

严格来说,由大小为N的域表示的k个键发生碰撞的概率是通过生成k个唯一整数的概率来计算的,如下所示:

1 - i=1 k-1 (N-i) N 1 - e -k(k-1) 2N

在接下来的几节中,我们将看看如何利用这一点,通过非常小的表示来观察我们将在流中观察到的元素集合。然而,牢记需要控制哈希碰撞的必要性和良好哈希函数创建的映射的伪随机性始终是有用的。

计数不同元素:HyperLogLog

在许多分析任务中存在对大量数据中不同元素进行计数的需求,这通常是因为这种唯一性是数据中离散“情况”的标记:例如网站日志中用户会话的不同、商业注册中的交易数量等。但在统计学中,它也被称为数据集的第一个“瞬间”,“瞬间”指的是流中元素频率分布的各种指标。

在 DStream 中计算所有不同元素的天真版本将要求我们存储观察到的每个单独不同元素的多重集。然而,对于以流方式到来的数据来说,这是不实际的:不同元素的数量可能是无限的。

幸运的是,自 1983 年以来,我们知道一种概率计算流中不同元素的方法,即 Flageolet-Martin 算法。此后,它已逐步扩展为 LogLog 计数、HyperLogLog 算法以及一种特定的混合实现,HyperLogLog++,其中的一种版本在 Spark 中通过 DataSets 和 RDDs 的approxCountDistinct API 中使用。

所有这些算法都继承自 Flajolet-Martin 算法的核心思想,我们现在将对其进行解释。然而,首先,我们从一个例子开始。

角色扮演练习:如果我们是系统管理员

我们可以考虑用来计算我们的 DStream 元素数量的第一件事是对这些元素进行哈希。让我们假设我们是一个系统管理员,计算客户访问的不同 URL 数量,以确定缓存代理的大小。如果我们只考虑现代网络中最常见的 URL,其可能的中位长度为 77 个 ASCII 字符,¹ 并且如果我们实际存储所有可能性,我们会看到平均每个有效访问的 URL 占用 616 位(一个 ASCII 字符是 1 字节或 8 位)。

让我们正确规模化我们的哈希函数。通过第 172 个字符,我们将达到 URL 长度的第 97 百分位数,² 这意味着可能的数据集大小为 8¹⁷²,这被 log_2(8¹⁷²) = 516 位所表示。但当然,并不是所有 172 个 ASCII 字符的组合都是正确的。根据前面提到的资源,从 78,764 个域中有 6,627,999 个公共唯一 URL,这意味着我们可以预期每个域名大约有 84 个 URL。为了安全起见(我们引用的文章是 2010 年的),我们将这个数字乘以 10,并将其与现有网站数(截至本文撰写时约为 12 亿)进行比较。因此,我们可以预期最多可能有 9600 亿个 URL 的数据集。这可以由大约 40 位的哈希值充分表示。因此,我们可以选择 64 位大小的哈希值,而不是之前考虑的 600 位,这将数据存储每个不同流元素的数量减少了 10 倍。³

我们计数方法的运作方式是通过考虑我们的哈希值的二进制表示的统计特性来实现的。作为一个一般的前提条件,让我们考虑在重复的硬币翻转中连续获得四个反面的概率有多么不可能。鉴于硬币翻转具有二元结果,获得这样的翻转的概率是 1/2⁴。一般来说,期望获得四个连续反面,我们预计需要重复四次硬币翻转实验 2⁴次,这是 16 次(总共 64 次翻转)。我们注意到这如何轻松推广到n个连续反面。我们哈希函数的二进制表示方式也以类似的方式运作,因为遇到的不同数的分布是均匀的。因此,如果我们从我们的哈希值的二进制表示中选择一个长度为k比特的特定数字,我们可以预期我们需要看到 2^(k)个样本(唯一流元素)才能达到它。⁴

现在让我们考虑一个特定的位序列。任意地,我们可以决定考虑一个以非零高阶位为前导的长序列的零。[⁵] 让我们把一个数的二进制表示中的末尾(较低阶位)零称为尾部。例如,12 的尾部(二进制为 1100)是两,8 的尾部(二进制为 1000)是三,10 的尾部(1010)是一。因此,我们可以推断,如果我们观察到一个至少为k的尾部大小的数,2^(k)是我们必须从集合中抽取样本达到该尾部长度的期望数。由于我们哈希函数的均匀性让我们将哈希化输入视为独立抽样,我们可以得出结论:如果我们观察到流元素的哈希中的最大尾部大小为k,那么 2^k 是流中不同元素数量的良好估计 ——虽然非常不稳定。

然而,这个方法存在两个问题:

  • 由于这是一种随机方法来计算流中元素的数量,它对异常值非常敏感。也就是说,虽然非常不太可能,我们可能会遇到流元素的一个非常长的尾部作为流的第一个元素,并且大大高估流中不同元素的数量。

  • 此外,由于我们对流元素的哈希的最长尾部估计的增量被用作我们估计的指数,我们只能生成 2 的幂次估计:2、4、8,…,1,024、2,048 等。

缓解这个问题的方法是并行使用几个成对独立的哈希函数:如果这些哈希函数是独立的,我们可以在特定实验中防止一个特定的哈希函数给出异常结果的风险。但是这样做在计算时间上是昂贵的(我们需要计算每个扫描元素的多个哈希值),更糟糕的是,这将需要大量成对独立的哈希函数,而我们并不一定知道如何构造。然而,我们可以通过将输入流分成p个子集来模拟使用多个哈希函数,这些子集由相同的函数处理,使用元素的哈希的固定大小前缀作为该哈希所属桶的指示器。

比较这些桶中估计的尾部大小的微妙之处不容小觑;它们对估计精度的影响是显著的。HyperLogLog 算法在图 27-3 中使用调和平均来补偿每个桶中可能存在的异常值,同时保留返回非 2 的幂次估计的能力。因此,对于 64 位哈希,我们可以使用p个桶,每个桶有(64-p)位。在这些桶中,唯一需要存储的数字是迄今为止遇到的最长尾部的大小,可以用 log2(64-p)表示。

spas 2703

图 27-3. HyperLogLog 算法的数据流

总之,我们的存储使用量为 2 p ( l o g 2 ( l o g 2 ( N / 2 p ) ) ) ,其中 N 是我们试图测量的基数。我们想要测量的基数(潜在的 9600 亿个 URL)与实际存储的数据量 4 log2(60) = 24 位之间的显著差距令人惊叹。

注意

另一方面,LogLog 计数算法及其特定变体 HyperLogLog++,它由 Spark 和以下示例使用,虽然从相同的思想出发,但实际上有很大不同。特别是,HyperLogLog 是一种混合算法,用线性计数代替 LogLog 计数算法处理小基数。此外,实现通常会选择散列长度和索引长度。我们希望我们的解释有助于传达基本算法的思想,但使用更高级的实现意味着有更多的参数和选择。

因此,详细查看所使用实现的文档,与文献进行比较,看看参数是否适合您的用例非常重要。

在我们将使用的 streamlib 实现中,相对精确度约为 1.054/sqrt(2^p),其中 p 是我们用于确定元素应落入的桶的输入位数(因此我们的桶数为 2^(p))。HyperLogLog++ 还有第二个可选参数,在 HyperLogLogPlus(p, sp) 中,其中 sp > p 会触发寄存器的稀疏表示。对于 n < m 的情况,它可能会减少内存消耗并增加准确性,但仅当基数较小时,超出大数据案例的范围。

在 Spark 中实际使用的 HyperLogLog

现在让我们在 Spark 中创建一个独立元素计数器。我们首先导入 streamlib 库,并添加以下依赖项:

  groupId: com.clearspring.analytics
  artifactId: stream
  version: 2.7.0

当这一步骤完成后,我们可以按照以下方式创建 HyperLogLog++ 计数器的实例:

val HLL = new HyperLogLogPlus(12, 0);

要理解这个调用,我们需要查阅 HyperLogLog 的构造函数,它告诉我们:

[此实现] 有两种表示模式:稀疏和正常有两种基于当前模式的程序。正常 模式类似于 HLL,但具有一些新的偏差校正。稀疏 模式是线性计数。

构造函数的参数是 HyperLogLog(p, sp),其中 p 是我们的哈希函数的桶数。如果 sp = 0,则 Hyperloglog 处于普通模式,如果 sp > 0,则对于小基数,它以稀疏模式工作,作为混合算法。如果 sp 不为零,则 p 应该在 4 和 sp 之间,而 sp 应该在 p 和 32 之间,以确定用于线性计数的位数。如果被测基数过大,则集合的表示将转换为 HyperLogLog。

我们的 HyperLogLog 的相对精度大约是 1.054/sqrt(2^p),这意味着为了达到 2% 的相对精度,我们应该使用 19 位索引来为我们的桶进行索引。因此,我们可以预期有 2¹⁹ 个桶,即 4,096 个。

我们可以使用 hll.offer() 向计数器添加元素,并使用 hll.cardinality() 获取结果。有趣的是,两个兼容的 HyperLogLog 计数器(相同的 psp)可以使用 hll.addAll(hll2) 合并,这意味着它们对于 Spark 中许多 reduce 函数非常合适,比如下面这些:

  • 我们的 DStreams 上 aggregateByKey 类函数及其窗口对应函数

  • 累加器依赖 merge 函数来 reduce 每个执行器上注册的不同本地计数。

为了实际说明使用 HyperLogLog 计算基数的用途,我们将考虑一个简单的网站案例。假设我们有一个媒体网站,我们想知道当前流行的内容是什么。此外,我们还想知道任何时间点的独立访问者人数。尽管我们可以使用滑动窗口计算趋势,就像我们在 第二十一章 中学到的那样,我们将利用自定义累加器来跟踪独立访问者的数量。累加器是可合并的数据结构,让我们能够在某些逻辑的分布式执行过程中跟踪特定的指标。在 Spark 中,有内置的累加器来计数整数和长整型。如果我们想知道网站上所有内容的点击次数,我们可以使用 longAccumulator。还有一个 API 可以创建自定义累加器,我们将利用它来创建基于 HyperLogLog 的累加器,以跟踪独立用户的数量。

在线资源

这个累加器的功能代码可以在这本书附带的代码库中找到,位于https://github.com/stream-processing-with-spark/HLLAccumulator。在这一节中,我们只讨论实现的关键特性。

要创建一个新的HyperLogLogAccumulator,我们使用之前解释过的参数p来调用构造函数。注意,我们已经提供了一个合理的默认值。还要注意,累加器是有类型的。这种类型代表了我们将要跟踪的对象类型。在幕后,所有不同类型都被视为Object,因为我们只关注它们的哈希码:

class HLLAccumulatorT extends AccumulatorV2[T, Long]
    with Serializable

要向累加器添加元素,我们使用要添加的对象调用方法add。然后,这将调用HyperLogLog实现中已讨论的哈希方法的offer方法。

override def add(v: T): Unit = hll.offer(v)

我们想要强调的最后一个方法是merge。合并能力对于在并行执行中协调部分计算至关重要。请注意,合并两个HyperLogLog实例类似于计算集合的并集,结果表示将包含两部分的公共和非公共元素:

override def merge(other: AccumulatorV2[T, Long]): Unit = other match {
  case otherHllAcc: HLLAccumulator[T] => hll.addAll(otherHllAcc.hll)
  case _ => throw new UnsupportedOperationException(
      s"Cannot merge ${this.getClass.getName} with ${other.getClass.getName}")
}

在拥有HyperLogLogAccumulator之后,我们可以在我们的 Spark Streaming 作业中使用它。

正如前面提到的,我们的流处理作业跟踪媒体网站上包含不同类别博客的内容流行度。核心逻辑是通过维护一个按路径分区的 URL 点击寄存器来跟踪哪些文章受欢迎。为了跟踪唯一访问者,我们使用累加器,这些累加器作为主要流处理逻辑的并行通道进行维护。

我们使用了简化的视图流表示形式,该形式包含点击的timestampuserId和注册视图的path

case class BlogHit(timestamp: Long, userId: String, path: String)

我们的流上下文具有两秒的批处理间隔,以保持数据的定期更新:

@transient val ssc = new StreamingContext(sparkContext, Seconds(2))
注意

在此声明中使用@transient可以防止非可序列化流上下文被闭包捕获时进行序列化,这在流应用中非常重要。

我们将创建并注册我们的自定义累加器。该过程与SparkContext提供的内置累加器稍有不同。要使用自定义累加器,我们首先创建一个本地实例,然后将其注册到SparkContext,以便 Spark 可以在分布式计算过程中跟踪它:

import learning.spark.streaming.HLLAccumulator
val uniqueVisitorsAccumulator = new HLLAccumulator[String]()
sc.register(uniqueVisitorsAccumulator, "unique-visitors")

我们利用最近学到的滑动窗口知识,创建一个视图,展示网站流量的最新趋势。在将点击信息分解为 URL 计数之前,我们还将userId添加到累加器中,以注册点击的userId

首先,我们将users提供给累加器。我们可能会认为可以通过简单的集合操作来计算批处理中的唯一用户数,但为了做到这一点,我们需要记住长时间内见过的所有用户。这让我们回到了支持使用概率数据结构的论点,因为我们不需要记住所有见过的用户,而只需要记住它们哈希的 HyperLogLog 组合:

clickStream.foreachRDD{rdd =>
        rdd.foreach{
	  case BlogHit(ts, user, url) => uniqueVisitorsAccumulator.add(user)
	}
        val currentTime =
	   // get the hour part of the current timestamp in seconds
	  (System.currentTimeMillis / 1000) % (60*60*24)
   val currentUniqueVisitors = uniqueVisitorsAccumulator.value
   uniqueUsersChart.addAndApply(Seq((currentTime, currentUniqueVisitors)))
}
警告

再次强调,本例中在foreachRDD的执行上下文中有不同之处;在此情况下,将数据提供给累加器的过程发生在rdd.foreach操作中,这意味着它将在集群中分布执行。

紧接着,我们访问累加器的值来更新我们的图表。此调用在驱动程序中执行。驱动程序是唯一可以读取累加器的执行上下文。

此示例中使用的图表也位于驱动程序机器上。它们的后备数据也在本地更新。

接下来,我们使用滑动窗口分析我们的网站流量趋势。在这里,我们使用了一个相当短的窗口持续时间。这仅用于提供的笔记本中的示例,因为我们可以很快观察到变化。在生产环境中,这个参数需要根据应用程序的上下文进行调整:

@transient val trendStream = clickStream
        .map{case BlogHit(ts, user, url) =>  (url,1)}
        .reduceByKeyAndWindow(
          (count:Int, agg:Int) => count + agg, Minutes(5), Seconds(2))

trendStream.foreachRDD{rdd =>
        val top10URLs =
          // display top10URLs
          rdd.map(_.swap).sortByKey(ascending = false).take(10).map(_.swap)
}

在提供的笔记本中,我们可以执行此示例并观察两个图表如何更新,同时计算我们系统中唯一用户的排名。

提示

Databricks 博客中有一篇关于在 Spark 中实现近似算法的文章,包括近似计数。尽管我们建议在流处理中使用此算法,而 Spark 只在 Dataset API 的一部分中提供它,但这里选择的实现与 Spark 使用的实现相同:Streamlib 的 HyperLogLog++。因此,《Apache Spark 中的近似算法:HyperLogLog 和分位数》(http://bit.ly/2UUQmiJ)中提供的见解同样适用于此处:

总之,使用 approxCountDistinct 时,您应该记住以下几点:

  • 当所请求的结果误差较高(> 1%)时,近似不同计数非常快速,并且以计算精确结果成本的一小部分返回结果。事实上,对于目标误差为 20%或 1%,性能几乎相同。
  • 对于更高的精度,算法遇到瓶颈并开始比精确计数花费更多时间。

请注意,无论运行时间如何,HyperLogLog 算法使用大小为 loglog(N)的存储空间,其中N是我们想要报告的基数。对于无限大小的数据流,这是其主要优势。

计算元素频率:计数最小化草图

在许多应用中正确确定流的最频繁元素的问题非常有用,特别是涉及元素、用户或项目的“长尾”问题。它包括以下内容:

  • 确定零售网站上最受欢迎的商品。

  • 计算最易变的股票。

  • 理解哪些 TCP 流向我们的网络发送了最多的流量,暗示可能有攻击。

  • 计算频繁的 Web 查询以填充 Web 缓存。

这个问题可以通过存储我们在流中看到的每个不同元素的计数以及每个元素的副本来轻松解决。然后我们对数据集进行排序并输出前几个元素。这个方法有效,但代价是O ( n l o g ( n ) )次操作和线性存储成本。我们能做得更好吗?

在确切性约束下,答案是否定的,即在数据和次线性空间的单次数据通行中找到频率大于n/k的最大元素的问题没有解决算法,其中n是元素数量,k 是频率选择器,这在(Alon1999 Prop.3.7)中形式化表达。然而,为大数据流计算频率分配线性空间是不可行的。这再次展示了近似可以提供帮助的地方。

引入布隆过滤器。

计数-最小草图的原则是受到称为布隆过滤器的一种更简单的近似集表示的启发。这意味着要学习如何使用计数-最小草图对元素进行排名,我们首先要学会使用布隆过滤器确定它们是否为集合的成员。

布隆过滤器的工作原理如下:为了表示一个集合,我们选择存储每个元素的哈希值,而不是使用集合中所有对象的全面表示。但这仍然使得存储与集合中元素数量线性相关。为了实现这一目标,我们选择仅存储一个常数个数的指示哈希值,而不考虑集合元素的数量。为此,我们将每个元素哈希为m位的字,并将所有这些元素的哈希值叠加在一起。集合的代表S的第i位如果任何元素的哈希值中的位被设置为 1,则S的第i位也被设置为 1。

这给了我们一个大量误报但没有假阴性的指示器:如果我们拿一个新元素z,想知道它是否在集合中,我们会查看h(z)并检查h(z)的所有非零位在S中的相同位位置也是非零的。如果S中任何这样的位是零(而对应的h(z)是一),z不可能是集合的一部分,因为它的任何非零位都会将相应的叠加位翻转为一。否则,如果在S中所有非零位也在h(z)中是非零的,我们知道z极有可能存在于这个集合中——尽管我们无法保证,任何哈希h(z’)包含的数字z’比其二进制表示中的h(z)多一个,都可能遇到,而不是z

使用这种方案的碰撞概率是1 - (1-1/m) n,如果n是插入元素的数量,我们通过使用k这样的叠加并行来改进为1 - (1-1/m) kn,以减少碰撞的概率。这是使用独立的哈希函数来保证独立概率乘积的控制存储空间,对于过滤器方案的总位数为(km)。布隆“过滤器”的称呼因其用于创建一个查询引擎,高概率地回答查询是否在某些问题集合中而得名,“返回”(或放过)只有非问题查询的意图。

在 Spark 中使用布隆过滤器

虽然不专门与 Streaming 相关,布隆过滤器在 Spark 中可以用于各种目的,并且当试图用它们来过滤列表元素并有偏向于误报时特别有用。

您也可以使用 streamlib 库进行此操作:

import com.clearspring.analytics.stream.membership.BloomFilter

val numElements: Int = 10000
val maxFalsePosProbability: Double = 0.001

val bloom = BloomFilter(numElements, maxFalsePosProbability)

这个库包括一个辅助功能,允许您计算最佳的哈希函数数量,以便在您愿意接受的最大误报概率的情况下使用——这最终成为布隆过滤器的最简构造方法。当我们建立了这个“不良”元素的过滤器之后,我们可以用它来过滤流元素:

val badWords = scala.io.Source.fromFile("prohibited.txt")
  .getLines
  .flatMap(_.split("\\W+"))

for (word <- badWords) bloom.add(word)

val filteredStream = myStream.filter{
    (w: String) => !bloom.isPresent(w)
}

如果您的目标是哈希速度的性能,并且您愿意使用 Scala 的解决方案,请看一看 Alexandr Nitkin 的实现([Nitkin2016]),它与 Twitter 的 Algebird 和 Google 的 Guava 中的实现相比具有优势。

使用 Count-Min Sketch 计算频率

创建一个初始化为零的整数数组,宽度为w,深度为d。取 d 个成对独立的哈希函数,h 1 , . . . , h d,并将每个函数与表的每一行关联,这些函数应产生在范围[1..w]内的值。当看到新值时,对表的每一行,使用相应的哈希函数对该值进行哈希,并递增指示的数组槽中的计数器。图 27-4 说明了此过程。

spas 2704

图 27-4。构建 count-min 草图

如果您想知道给定值的实例已经看到了多少次的估计,请像以前一样哈希该值并查找每行中给出的计数器值。取这些值中的最小值作为您的估计。

这样一来,我们将得到一个大于我们正在查找的元素x的实际频率计数的数字,因为每个哈希都将递增我们将查看的草图中的每个位置的计数器。如果我们幸运的话,x将是唯一一个递增这些计数器的元素,每个计数器的估计值将是x的频率计数。如果我们不那么幸运,会发生一些碰撞,一些计数器会高估x的出现次数。因此,我们取所有计数器的最小值:我们的估计是有偏的,因为它们只是高估了我们正在寻找的真实值,很明显,最不偏的估计是最小的那个。

count-min 草图具有相对误差ε,并且有一个概率(1 - δ),只要我们设置w = ⌈e/ε⌉和d = ⌈ln 1/δ⌉,其中e是欧拉数([Cormode2003])。

使用 Spark,这同样是 streamlib 库的一部分:

import com.clearspring.analytics.stream.frequency.CountMinSketch

val sketch = new CountMinSketch(epsOfTotalCount, confidence)

// or val sketch = new CountMinSketch(depth, width)

count-min 草图要求您为此构建指定相对误差和置信度(1 - δ),并尝试为您设置宽度和深度的值,尽管您可以指定显式宽度和深度的草图大小。如果您指定了宽度和深度,您应该提供整数,而对于概率论参数,构造函数参数预计将是双精度。

转换如下表所示:

草图的宽度 草图的深度 相对误差 相对误差的置信水平
符号 w d ε (1 - δ)
如何计算 e/ε⌉ ⌈ln 1/δ⌉ e / w 1 - 1 / 2^(d)
Streamlib 的近似 ⌈2 / ε⌉ ⌈-log(1 - δ) / log(2)⌉ 2 / w 1 - 1 / 2^(d)

count-min 草图是可加的并且可合并的,这使得它成为updateStateByKey函数的理想候选:

dStream.updateStateByKey((
  elems: List[Long], optSketch: Option[CountMinSketch]) => {
  val sketch = optSketch.getOrElse(new CountMinSketch(16, 8))
  elems.foreach((e) => sketch.add(e))
  sketch
})

草图还具有允许您添加Stringbyte[]参数的功能。

注意

Apache Spark 在 Spark 2.0 中引入了一个计数最小草图,位于org.apache.spark.util.sketch包下。

这个实现是从 streamlib 中纯粹地重用的,唯一的例外是哈希函数,为了将 Scala 的 Object 转换为CountMinSketch的一个入口类型(long、string、byte[]),添加了一些便利方法,并且重用了 Scala 作为默认的内部 32 位MurmurHash哈希函数。

因此,Spark 的计数最小草图可以与我们在此展示的草图互换使用。

等级和分位数:T-Digest

当评估在数据流中观察到的值的分布时,重点关注的是这些值分布的图像,如图 27-5 所示。

spas 2705

图 27-5. 适合用 T-Digest 表示的分布

如果在最近的流样本上计算,该分布允许诊断数据流性质的变化,而探索性分析则是各种聚合统计信息(平均数、中位数等)的全面图像。通过查看累积分布函数(CDF)来接近该分布通常很容易,它代表了在我们的流上观察到的任意值X,有多少数据点等于或小于X。对累积分布函数的粗略看法也很容易直观理解:分位数的概念。

分位数是百分比和值之间的映射,这样,在数据流上观察到的值的百分比正好是低于该百分比图像的值。例如,第 50 个百分位数(即中位数)是这样一个值,使得一半的数据点都低于它。

因此,即使这种视图有些粗略,对我们流中数据点的累积分布函数(CDF)有一个良好的视角也是很有用的。代表 CDF 的一种紧凑表示方式,同时还允许我们快速计算 CDF 的表示,是一种摘要,特别是 T-Digest,它解决了并行计算分位数的难题。请注意,这并不是完全不重要的。因为分位数与平均数相反,并不容易聚合,而联合的平均数可以使用每个联合组件的元素数量的总和和计数来计算,这对于中位数并不适用。T-Digest 通过将分位数的表示方法转化为一个压缩问题来进行处理,而不是通过表示数据流的 CDF 中的每个点,它通过一组点的重心来总结,这些点被选择为这些 CDF 中各个位置上我们期望看到的“压缩率”的聪明选择。图 27-6 描述了一种可能的质心分布。

spas 2706

图 27-6. 用 T-Digest 的质心近似的CDF

例如,假设我们的分布符合高斯假设或是高斯混合物,它非常接近最高和最低百分位数的值,压缩率非常低,因为那些百分位数处的值只有很少的表现。相反,大约在第 50 百分位数处,我们可以期望在该中位数周围的值有很多表示,因此我们进行的近似替换它们的平均值不会特别昂贵,既不会在信息丢失方面也不会在实际意义方面。

T-Digest 是由 Ted Dunning 发明的数据结构([Dunning2013]),首先通过随机放置的质心来近似分布的子集,然后当特定质心附加的点过多时,会分裂这些近似的子集。然而,达到多少点的限制取决于与该质心相关联的值。这确保了该数据结构在 CDF 的边缘处较低,在 CDF 的中位数周围相对较高。这确保了该数据结构既紧凑又反映了我们感兴趣的高精度的 CDF 值。

Spark 中的 T-Digest

遵循本章的常见做法,我们指向 stream-lib 库中 T-Digest 实现的部分:

import com.clearspring.analytics.stream.quantile.TDigest

val compression: Double = 100
val digest = TDigest(compression)

实例化一个新的 T-Digest 数据结构需要一个压缩参数,指示我们希望如何平衡我们分布的压缩表示的准确性和大小。对于任何分位数,准确性的相对误差几乎总是小于压缩的倒数的三倍,对于极端分位数,预期的误差要低得多。然而,我们将用于跟踪我们分布的质心的数量将按压缩的五倍数量。

T-Digest 具有基本函数,使其成为 Accumulator 对象的核心(如之前与 HyperLogLog 见过的),或者是一个聚合计算的核心:添加单个元素和可合并性。

dStream.updateStateByKey((elems: List[Double], optSketch: Option[TDigest]) => {
  val digest = optSketch.getOrElse(new Digest(100))
  elems.foreach((e) => digest.add(e))
  digest
})

我们还可以使用 merge 函数,带有压缩和其他 TDigest 实例的列表,将它们融合成一个所需的最终压缩的单一实例。当然,参数摘要的压缩总和应大于或等于所需的最终压缩。

最后,我们还可以使用 TDigest 的两个查询函数。digest.cdf(value: Double) 返回所有样本中小于或等于 value 的近似分数,而 digest.quantile(fraction: Double) 返回最小值 v,使得大约 fraction 的样本值小于 v

减少元素数量:采样

Spark 提供了多种方法,用于对数据进行采样,这对数据分析以及保证分布式应用程序的性能非常有用。

在时间约束下的数据分析中,抽样可以使较小的数据集上运行更重的算法成为可能。原始数据集的主导趋势也将出现在样本中,前提是所使用的抽样技术不会引入任何偏差。

作为性能调优工具,我们可以使用抽样来减少不需要观察数据集每个元素的特定应用程序的负载。

Spark 的内置抽样功能直接表现为 RDDs API 的函数。因此,在流处理的语义中,可以在微批次级别应用 DStream 操作,这些操作提供对流的 RDD 级别的访问,如 transformforeachRDD,我们在第十七章中看到的。

警告

截至本文写作时,抽样在结构化流处理中不受支持。尝试将样本函数应用于流式 DataFrame 或 Dataset 将导致错误:

org.apache.spark.sql.AnalysisException:
Sampling is not supported on streaming DataFrames/Datasets;

随机抽样

对 RDD 进行抽样的第一个和最简单的选项是 RDD.sample 函数,可以调整为在每个微批次 RDD 中抽样一部分,分别使用泊松或伯努利试验来实现此抽样:

// let's assume that dstream is a `DStream[Data]`
// create a random sampling
val sampledStream = dstream.transform{rdd =>
    rdd.sample(withReplacement, fraction,seed)
  }

这里是参数:

withReplacement: Boolean

指示样本元素是否可以多次抽样的标志。

fraction: Double

概率值在范围 [0,1] 内,表示样本中选择一个元素的机会。

seed: Long

在我们希望重复结果的情况下使用的种子值。如果未指定,默认为 Utils.random.nextLong

随机抽样特别有用,因为它保持了抽样的公平性,因此当我们将抽样的 RDD 的联合视为数据流的单个样本时,它还保留了更多的统计属性。

如果按百分比抽样,不均匀大小的批次将不均匀地对最终样本做出贡献,因此保留我们将尝试从样本中挖掘的数据的统计属性。然而,请注意,使用标志 withReplacement = false 进行无替换抽样仅限于单个 RDD 的范围内,这意味着DStream的概念中无替换没有意义。

分层抽样

分层抽样允许按类对 RDD 进行抽样,其中不同类别的数据记录使用键-值对的键标记。

Spark Streaming 可以利用 RDD API 使用分层抽样,如下例所示:

// let's assume that dstream is a `DStream[(K,V)]`
// create a stratified sampling
val sampledStream = dstream.transform{rdd =>
    rdd.sampleByKey(withReplacement, fraction,seed)
  }

API 与随机抽样函数相同,只是它仅在 RDD[(K,V)] 上操作:包含键-值对的 RDD。

但是,如果我们对数据集的每个类别中的元素数量没有概念,采用按类别抽样就特别难以实施。因此,我们可以两次遍历数据集并提供精确的样本,或者使用重复抽样的统计法则,在每条记录上抛硬币决定是否进行抽样,这种技术称为分层抽样sampleByKeyExact函数使用后者技术,而两次遍历抽样则称为sampleByKeyExact

在 DStream 上,sampleByKeyExact的确切类别定义将在每个微批次上计算,并且不会在整个流中保持稳定。

请注意,在批量分析和流分析中,类别抽样的作用同样重要;特别是在通过增强等方法来纠正数据集中的偏差和解决类别不平衡问题时,它是一种有价值的工具。

¹ 这个长度,作为一个粗略估计,是通过Supermind对大型网络抓取中找到的 URL 平均长度计算而得。

² 同上。

³ http://www.internetlivestats.com/total-number-of-websites/

⁴ 请注意我们哈希函数的一致性是多么重要——如果没有它,我们哈希中的位值就不再独立于其前后位值,很可能导致我们的硬币翻转失去独立性。这意味着我们不能再把 2^(k)作为达到我们选择的k位数所需的样本数的良好估计。

⁵ 当然,这是大端记法。

第二十八章:实时机器学习

在本章中,我们探讨了如何构建在线分类和聚类算法。所谓在线,意味着这些算法能够在接收到新数据时实时学习并生成最优的分类和聚类结果。

注意

本章假设您对机器学习算法有基本的理解,包括监督学习和无监督学习的概念,以及分类与聚类算法的区别。如果您希望快速复习基本的机器学习概念,我们建议阅读[Conway2012]

在接下来的几节中,我们将解释几种以在线方式执行的机器学习算法的训练过程,这些数据来自流式数据。在此之前,让我们承认,大多数行业中机器学习模型的实现已经有一个在线组件:它们以批处理方式进行训练(数据处于静止状态),并将其与在线推理连接起来,例如从数据流中读取示例并使用先前训练过的模型对流式数据进行评分(或预测)。

从这个意义上讲,大多数机器学习算法已经在流式处理环境中部署,并且 Spark 提供了使这一任务更加简单的功能,无论是 Spark 的批处理和流处理 API 之间的简单兼容性(我们在前几章中已经讨论过),还是旨在简化部署的外部项目,例如MLeap

让这种架构工作的挑战——批量训练后进行在线推理——主要依赖于在足够多的机器上部署模型的副本来处理请求的涌入。一般的横向扩展是一个重要的工程话题,但比本书关注的范围更广。我们更感兴趣的是在不允许或不鼓励批量训练模式的条件下训练机器学习模型的挑战。这可能出现的原因有几个:

  • 数据量很大,批量训练只能以较低的频率进行。

  • 数据的变化频繁,导致模型很快过时。

  • 正如我们将在 Hoeffding 树中看到的那样,训练不需要看到所有数据就能产生准确的结果。

在本章中,我们探讨了两种在线分类算法和一种聚类算法。这些算法都有明确的批处理替代方案,因此很好地展示了从批处理到流式处理在机器学习训练中的转换方式。

我们首先接触的分类算法是多项式朴素贝叶斯。它是基本垃圾邮件过滤器最常用的分类器,也是当你想使用离散特征(例如文本分类的单词计数)时的适当分类器。

然后,我们研究了霍夫丁树,这是决策树的在线版本。决策树的逻辑依赖于通过反复决定最重要的特征来对数据集进行分类。但在在线环境中,我们应该能够学习到哪些最显著的输入特征来对数据集进行分类,尽管只是查看其片段。在本章中,我们演示了通过依赖强大的统计界限,算法如何经济快速地实现这一目标。

最后,我们看到了 K-Means 聚类算法的在线适应版,该算法在 Spark Streaming 中本地提供,将数据分桶为一组固定的群组。在线版本使用权重衰减来减少旧示例的影响,并采用修剪无关群组的技术,以确保始终在新数据上提供最相关的结果。

本章应该为您提供了强大的在线技术,这些技术应该作为在线学习背景下应用机器学习技术的初始点。

使用朴素贝叶斯进行流式分类

Naive Bayes 方法是一组基于贝叶斯定理并采用“朴素”独立性假设的监督学习算法。在本节中,我们详细研究了一个用于自然语言文档分类的分类器,使用了这种技术,并展示了如何实现一个不需要深入语言表示的高效分类器。

多项式朴素贝叶斯实现了多类分布数据的朴素贝叶斯算法。它是文本分类中使用的两种经典朴素贝叶斯变体之一,另一种是伯努利模型。

在我们探索多项式朴素贝叶斯时,我们使用了一个简单的表示法,其中数据表示为单词计数向量。这意味着文档被表示为单词的混合袋,其中一个袋子是一个允许重复元素的集合,仅反映了文档中出现的单词以及它们出现的次数,而忽略了单词的顺序。

让我们称这些文档的集合为 D,其类别由 C 给出。C 表示分类中的不同类别。例如,在电子邮件垃圾过滤的经典案例中,C 有两个类别:

  • S(垃圾邮件)和

  • H(正常邮件,非垃圾邮件)。

我们将 D 分类为具有最高后验概率 P(C|D) 的类别,这被理解为 给定文档 D,类别 C 的概率。我们可以使用贝叶斯定理重新表达这一点,你可以在 方程 28-1 中看到。

方程 28-1. 贝叶斯定理

P ( C | D ) = P(D|C)P(C) P(D)

为了选择我们文档D的最佳类C,我们表达P(C|D)并尝试选择最大化这个表达式的类。应用贝叶斯定理,我们得到公式 28-1,它用P ( D | C ) P ( C )表示为分数的形式。这是我们尝试寻找最大值的量,取决于C——因为当初始分数达到最大值时,它也达到最大值,所以我们可以去掉分母P(D)的常数。

我们的模型使用特征向量来表示文档,其组成部分对应于单词类型。如果我们有一个包含|V|个单词类型的词汇表V,那么特征向量的维度为 d = |V|。在多项式文档模型中,一个文档由具有整数元素的特征向量表示,其值为文档中该单词的频率。在这种意义上,将单词计数表示为x 1 ... x n,我们想研究P ( x 1 x 2 ... x n | c )。假设这些特征彼此独立,这只是计算每个P ( x i | c )。总之,我们试图找到达到P ( c j ) P ( x | c j )的最大值的类c j

对于每个类别,根据训练数据估算出现该类别时观察到单词的概率,方法是计算该类别文档集合中每个单词的相对频率。当然,如果我们对特定(单词,类别)对没有见过训练文档,就会出现问题。在这种情况下,我们使用称为Laplace 平滑因子的东西,它表示对于该单词的频率保证相对于词汇量的大小是最小的。分类器还需要先验概率,¹ 这些可以通过训练集中类别的频率直接估算出来。

streamDM 简介

在我们讨论如何为分类训练贝叶斯模型之前,我们想先了解我们将用于此目的的 streamDM 库的结构。

在 streamDM 中,模型操作于标记的Example。换句话说,内部流表示为 Spark Streaming DStream,包含我们的内部实例数据结构Example。一个Example包括输入和输出Instance的元组以及一个权重;Instance可以根据流的输入格式包含不同的数据结构(例如,逗号分隔值[CSV]文本格式中的稠密实例,LibSVM 格式中的稀疏实例和文本实例)。

Example类包括以下内容:

  • input: Instance(data, x_j)

  • output: Instance(labels, y_j)

  • weight: Double

在我们的示例中,我们使用一个方便的函数Example.fromArff()创建Example,它设置默认权重(=1)并读取我们的 ARFF 格式文件。所有操作都在Example上进行;这允许进行任务设计,而无需模型实现者了解实例实现的详细信息。

ARFF 文件格式

ARFF 是属性-关系文件格式的缩写。它是 CSV 文件格式的扩展,使用头部提供关于列中数据类型的元数据,并且是 Weka 机器学习软件套件的一个支柱。某些组织功能展示的方式受到它的启发。您可以在Weka wiki上找到更多信息。

任务是一组通常有序的块的组合,就像任何学习过程设置中那样:

  • 一个 StreamReader(读取和解析示例并创建流)

  • 一个 Learner(从输入流提供train方法)

  • 一个 Model(用于学习者的数据结构和方法集)

  • 一个Evaluator(评估预测)

  • 一个 StreamWriter(管理流的输出)

streamDM 将此流程打包成预定义任务,并通过预序评估方法连接,我们即将详细介绍它。

实践中的朴素贝叶斯

多项式朴素贝叶斯分类器实际上是一类密切相关的算法。我们将重点放在 streamDM 库中的一个发现²,这是[McCallum1998]的一个实现。

它使用基于三步骤预顺序评估方法的 streamDM 实现。预顺序评估方法,或者交错的测试-训练方法,是传统批处理方法的替代方案,清晰地将训练、验证和评分阶段分开。

预顺序评估 Figure 28-1 是专门为流式设置设计的,每个样本的作用是两个目的,样本按到达顺序依次分析,并立即变得不可用。样本实际上只看一次。

这种方法包括使用每个样本来测试模型,这意味着进行预测,然后使用同一个样本来训练模型(部分拟合)。这样模型总是在它尚未见过的样本上进行测试。

spas 2801

图 28-1. 使用预顺序评估进行模型训练和评估

streamDM 集成了FileReader,用于从一个完整数据文件中读取数据,模拟数据流。它与 ARFF 文件兼容。

训练电影评论分类器

我们可以从MEKA 项目下载 IMDB 电影评论数据集的带标签版本,按电影流派分类。

文件的开头列出了包括的属性,以二进制形式开始,接着是评论的词向量编码。

@relation 'IMDB: -C 28'

@attribute Sci-Fi {0,1}
@attribute Crime {0,1}
@attribute Romance {0,1}
@attribute Animation {0,1}
@attribute Music {0,1}
...

这里方括号内的名义规范对应于 IMDB 给电影的类别,然后是词向量模型。我们可以在文件的后面看到一个评论的实际表示:

{3 1,15 1,28 1,54 1,123 1,151 1,179 1,229 1,296 1,352 1,436 1,461 1,531 1,609
  1,712 1,907 1,909 1,915 1,968 1,1009 1,1018 1,1026 1}

这篇评论提供了电影分类的想法,接着是评论中关键词的词向量。

streamDM 带有一个命令行功能,连接任务中所有类别(我们之前详细介绍过)。它使得调用我们的MultinomialNaiveBayes模型变得简单,可以从编译检出的 streamDM 存储库中的streamDM/scripts启动:

./spark.sh "EvaluatePrequential
 -l (org.apache.spark.streamdm.classifiers.bayes.MultinomialNaiveBayes
 -c 28 -f 1001 -l 1)
 -s (FileReader -f /<dowload-path>/IMDB-F.arff -k 100 -d 60)" 2> /tmp/error.log

模型构造函数期望三个参数:

  • 每个示例中的特征数量(默认为 3),选项为-c

  • 每个示例中的类别数量(默认为 2),选项为-c

  • 一个拉普拉斯平滑因子用于处理零频率问题,选项为-l

模型输出了生成数据的混淆矩阵。在一个缩小的示例中,我们可以得到,例如:

2.159,0.573,0.510,0.241,0.328,0.589,104,100,327,469
2.326,0.633,0.759,0.456,0.570,0.574,243,77,290,390
2.837,0.719,NaN,0.000,NaN,0.719,0,0,281,719
3.834,0.490,0.688,0.021,0.041,0.487,11,5,505,479
4.838,0.567,0.571,0.018,0.036,0.567,8,6

引入决策树

在分类问题中,我们通常处理一组N个训练样本,其形式为(x, y),其中y是类别标签,x是属性向量( x = x 1 , x 2 , . . x n )。我们的目标是从这些样本中产生一个模型y = f(x),该模型能够高精度地预测未来样本x的类别y

最有效和广泛使用的分类方法之一是决策树学习。这种学习方式生成决策树模型,其中每个节点包含对属性的测试(分割),每个节点的分支对应于测试的可能结果,并且每个叶子包含一个类别预测。这样,算法产生一系列的分割,例如在图 28-2 中所示的那种。

spas 2802

图 28-2. 用于汽车交易的决策树

对于一个示例x,其标签y = DT(x)的结果来自将示例从根节点传递到叶节点,测试每个节点的适当属性,并跟随与示例中属性值对应的分支。决策树通过递归地将叶节点替换为测试节点来训练,从根节点开始。在给定节点测试的属性是通过比较所有可用属性并根据某些启发式度量(通常称为信息增益)选择最佳属性来确定的。

信息增益是一个统计指标,非正式地衡量我们在观察潜在决策变量(“输入电子邮件中是否包含Viagra?”)时学到的关于最终分类变量(“这封电子邮件是否为垃圾邮件?”)的信息。它捕捉到基于哪些变量做决策是最好的,首先是那些提供关于分类结果最多信息的变量。

Spark 实现的决策树依赖于ID3 算法(迭代分割器,Quinlan1986),并依赖于信息增益。

提示

决策树分类器是机器学习中众所周知的决策算法。赫夫丁树是决策树的流式扩展,其在概率理论中有良好的基础。

如果你对决策树不熟悉,我们建议你查看《Spark 高级分析》的第四章(Laserson2017),该书对此进行了详尽的阐述。

这些学习者利用每个训练样本来选择每个分割中最好的属性,这种策略要求在整个训练过程中始终可用所有样本。这使得那些经典的决策树学习过程批处理算法不适用于仅以小增量提供数据的流式环境。

赫夫丁树

霍夫丁树³([多明戈斯 2000])解决了这个问题,并能够在严格的时间和内存约束条件下从流数据中学习,而无需在内存中保存所有先前看到的数据。它们通过指出,在选择任何给定节点的分割属性时,仅使用数据流的一个小样本就足够了,从而实现了这一点。因此,在根节点选择分割属性时,只需使用数据流中最先到达的几个示例;随后的示例通过树的感应部分,直至它们达到叶子节点,然后在那里选择分割属性,并依此类推。

为了确定每个决策所需的示例数量,此算法使用称为霍夫丁界限的统计结果。关于霍夫丁界限的非正式描述是,如果你对n个随机变量上的一个函数进行重新取样时,函数的变化不大,那么该函数在接近其均值时将具有高概率。这个结果帮助我们精确地理解,在数据集中观察到的值样本的行为如何反映整个分布。

具体而言,这意味着我们逐个观察的值——流的样本——将反映整个流的情况:我们用估计器替换决策树的频率计数器。无需实例窗口,因为估计器分别保留足够的统计数据。特别是,某些特征的显著性从数据中显现出来,就像检查整个数据集时一样。

这意味着我们清楚地知道我们需要看到多少值才能自信地决定我们的决策树应该如何创建——它告诉我们,通过看到少量值就可以学习分类!

霍夫丁界限

对于实值随机变量rn次独立观察,霍夫丁界限确保,在置信度 1 - δ下,r的真实均值至少为V - ε,其中V是样本的观察均值:

ϵ = R 2 ln(l/δ) 2n

这个界限的有趣之处在于,这是真实的,不受生成观察的概率分布的限制,这使我们可以将其应用于任何流。这种广泛适用性的代价是,该界限比依赖于分布的界限更为保守(即,需要更多的观察才能达到相同的δ和ε)。⁴

与任何决策树一样,我们想决定是否应该“扩展”一个叶子节点,这决定了在这个叶子节点分割的请求子组。

让我们称G(X_i)为选择测试属性(例如信息增益)的启发式度量。在叶子看到n个样本后,Xa是具有最佳启发式度量的属性,Xb是具有第二最佳启发式度量的属性。让∆G = G(Xa) - G(Xb)成为观察到的启发式值之间差异的新随机变量。将 Hoeffding 界限应用于∆G,我们看到如果∆G > ε(使用我们选择的δ设置界限),我们可以自信地说G(Xa)G(Xb)之间的差异大于零,并选择Xa作为新的分割属性。

算法草图随后通过将每个示例(x, y)使用临时决策树分类到叶子节点l。对于x中的每个值x_i,使得X_iX_l,然后增加这个特征和类别的示例计数器,并尝试使用到目前为止在l中看到的示例中的多数类别来标记叶子l。如果这些不全是同一类别,则尝试通过计算每个属性X_i ∈ X_l的分割启发式G_l(X_i)来进行分割。如果第一个和第二个属性之间的启发式结果差异大于使用 Hoeffding 界限计算的ε,则我们将l替换为在此第一个属性上分割的内部节点,并更新我们所使用的树。

这些计数是计算大多数启发式度量所需的充分统计量;这使得算法在内存使用上很节俭。

Hoeffding Trees 启发法

该算法还实现了一些边缘启发法:

  • 在内存压力下,Hoeffding Trees 会停用最不具有前景的叶子节点,以便为新叶子节点腾出空间;当数据容易获取时,可以稍后重新激活这些节点。

  • 它还采用一种绑定机制,避免在选择实际差异微小的属性时花费过多时间。事实上,它在 ∆G < ε < τ(其中 τ 是用户提供的绑定阈值)时宣布平局,并选择Xa作为分割属性。

  • 通过在每个节点考虑一个“null”属性X_0来进行预修剪,该属性表示不对节点进行分割。因此,只有在置信度为 1 - δ时,找到的最佳分割按G的标准优于不分割时,才会进行分割。

注意,对于到达叶子的每个m(用户提供的值)个示例,分割和平局测试仅执行一次。这是因为该过程不太可能在任何给定示例后做出决策,因此为每个示例执行这些计算是浪费的。这使得 Hoeffding Trees 特别适合于微批处理,正如在 Spark Streaming 中所做的那样。

Hoeffding Trees 在 Spark 中的实际运用

在 Spark 中实现 Hoeffding Trees 的实现可以在 streamDM 库中找到。与朴素贝叶斯分类器类似,它对比通常的 Spark Streaming 处理 API 有稍微不同的回应。我们可以使用 streamDM 默认的任务设置,如朴素贝叶斯示例中所述。这样的模型评估设置在 streamDM 中是可用的,并在 GitHub 上进行了文档化。这里,我们概述了算法训练的手动构建过程。

Hoeffding Tree 模型需要初始化实例的规范,称为 ExampleSpecificationExampleSpecification 包含有关数据中输入和输出特征的信息。它包含一个指向输入 InstanceSpecification 和一个输出 InstanceSpecification 的引用。这些 InstanceSpecification 元素包含有关特征的信息,区分数值和分类特征。

这里呈现的 Hoeffding Tree 实现适用于 ARFF 文件格式。我们可以为著名的鸢尾花数据集创建特征,这是一个包含四个特征的鸢尾花物种分类数据集:鸢尾花样本的花瓣和萼片的长度和宽度:

val inputIS = new InstanceSpecification()
val columnNames = List(
  "sepal length", "sepal width",
  "petal length", "petal width")
for (part <- range(0, columnNames.length))
  inputIS.addInput(part, columnNames(part), new NumericSpecification)
val outputIS = new InstanceSpecification()
val classFeature = new NominalFeatureSpecification(
  Array("setosa", "versicolor", "virginica"))
outputIS.setFeatureSpecification(0, classFeature)
outputIS.setName(0, "class")

特征规范是通过将数值特征分隔来完成的,以帮助分割算法组织它应如何在这个变量上进行歧视。然后,算法的设置就更简单了:

val exampleSpec = new ExampleSpecification(inputIS, outputIS)

val hTree = new HoeffdingTree

hTree.init(exampleSpec)

hTree.trainIncremental(exampleStream: DStream[Example]): DStream[(Example, Double)]

hTree.predict(queryStream: DStream[Example]): DStream[(Example, Double)]

模型的训练首先进行。在用适当的示例训练完模型后,我们可以使用 predict 方法查询单个记录,该方法操作的是一个 String。从朴素贝叶斯的示例中记住,Example 是在 Instance 类层次结构之上的包装器。它包含一个输入 Instance 和一个输出 Instance 的引用,并为特征和标签提供设置器和获取器。

在线 K-Means 流式聚类

在机器学习中,聚类是在无监督方式下根据这些点之间的相似性概念来对集合元素进行分组的实践。最著名的聚类算法是 K-means,在本节中,我们研究其在线适应性。本节的其余部分将展示如何将无监督算法适应流式上下文。

K-Means 聚类

K-means 聚类是一种参数化的无监督算法,用于将度量空间中的数据点分组成具有特定 k 个聚类,其中 k 是预定义的整数。每个聚类由一个质心(该度量空间中的一个点)标识,该质心具有质心是分配给它的数据点的重心⁵的属性。

它通过连续运行几个时期来运行,交替关注点与质心的附着关系一方面,以及重新定位质心另一方面。在第一阶段,将每个数据点与最接近它的质心关联起来,每个质心关联的数据点集形成其聚类。后续阶段包括选择并重新设置每个聚类的质心,通过选择该质心聚类中所有点的重心来进行。例如,如果我们有 100 个点和 10 个质心,我们可以最初在空间中随机设置这 10 个质心的位置。然后,我们将所有 100 个点分配给它们可能的 10 个最接近的质心之一,然后我们交替开始为每个组重新计算质心应该在哪里。这种重新计算是一个重心计算。

现在,最小化总体距离是k-均值算法的核心。如果我们希望收敛到理想的聚类,算法仅依赖于允许自己在获取错误度量和质心初始位置之前执行多少轮次。

在线数据和K-均值

在传统的K-均值算法中,我们在进行理想质心计算之前给定(并操作)整个数据集。另一方面,如果我们需要按顺序操作,逐点考虑并仅观察一次,则需要采用不同的方法。

例如,假设我们考虑每个 RDD 上出现的点作为我们数据集的一部分。我们有固定的k,以便知道我们真正想要将数据集分为,比如,10 个聚类。但是,我们仍然不希望精确理解最后一个 RDD 仅作为我们数据集的完整典范。我们对此进行调整的方式是,让最近的 RDD——即最新的数据批次——通过更强的重要性修改我们的质心分配和位置。我们将使用权重的概念定量化这种重要性数值化。

在线K-均值算法(Shindler2011)包括使用称为遗忘性的概念来调整K-均值。这意味着让我们在数据集中看到的最旧的点对质心位置的影响小于最近的点,反映了我们聚类应该是对最近传输的数据点的最佳聚类的准确快照。

在实际操作中,这意味着在质心下分配点的输出是我们看到的最后几批数据的新鲜表示。为此,我们引入了一个权重(w < 1)或衰减的概念,伴随着每一个单独的点。这非常重要,因为我们在K-means 的基本批量解释中看到的质心的计算自然延伸到加权计算。该权重反映了我们通过衰减因子将每个批次中的点相乘。因为我们在每个批次上都这样做,任何特定点的权重都会根据自从从数据源的线上读取该点以来经过的批次数以指数方式减少。

这个因素可以在 Spark 中通过将衰减因子表达为在线K-means 算法的浮点数来表达,或者可以表达为半衰期。半衰期表示一个点在降低数据集一半在算法视图中的批次之后应该存活多少批次。实际上,我们考虑的是每个点的权重在看到那个特定时间点的批次之后如何减半。

小贴士

算法的参数化的另一种选择是计算我们已经看到的点的数量。事实上,Spark 为确定特定点的年龄(因此在算法中应用的衰减)所应用的批量概念,并不总是最合适的,因为我们知道吞吐量变化很大的流数据的简单数量和广泛性更好地指示结果的新近性,而不是事件到达的时间戳。

衰减簇的问题

无论如何,这种遗忘性让我们可以拥有质心,这些质心携带了越来越少点的重心。现在,由于我们对这些点有了分配,我们可以根据最近到达该簇的点重新定位质心。但是我们如何处理这样一个事实:在经过一定时间后,某些质心往往会完全被遗忘?例如,让我们考虑一个在笛卡尔有界平面上运行的数据流。如果在开始时,我们主要是在特定有界平面的左上象限或空间的特定区域中有大部分点,那么很早就会分配一个或多个质心给该区域。

过一段时间后,让我们假设数据流的点内容移动到空间的右下象限。在这种情况下,我们在线K-means 的演化意味着在新区域中放置质心,而不是在左上象限。随着给定聚类的点向下和向右移动,质心也会随之移动,但仍在同一聚类中。如果在任何给定的点上,我们创建了一个使点位置出现间隙的情况,我们无法将单个点分配给仍然位于左上象限的旧聚类。那么,这个聚类,虽然点数非常少,但仍然有“旧”点与之相关联。这是不理想的,因为它“消耗”了一个质心来聚合那些已经不相关于当前数据流的旧点。

解决此问题的方法是分析那些权重非常小以至于可以忽略不计的衰减聚类。在使用K-means 时,我们在问题的一般形式中有K个可能的质心。由于这个不变性,我们需要通过比较“死亡”(低权重)聚类的总权重与“最强”聚类中最大权重的总和来检查这些聚类。我们能够确定一个聚类是否“死亡”的依据是,比较点数乘以最轻质心的衰减因子的总权重与最重质心的总权重。如果这些质心之间的相对差异超过 1 千万⁶,Spark 将放弃衰减的质心,并将“最重”的质心分成两个质心。

在图 28-3 中,聚类 A 和 B 从左上角开始,逐渐向右下角移动,随着最近的点占据了大部分聚类的权重。然而,当新点到达的位置存在不连续的间隙(红色标记)时,新点会被分配到另一个不同的聚类 C,并开始扰乱其质心(灰色)。我们希望这些新点能够形成一个新的聚类在左下角,有红色的质心。但由于 A 仍然作为一个聚类存在,无论其聚合权重多小(由于衰减因子),这是不可能的。

spas 2803

图 28-3. “死亡”聚类的影响

此操作在每个微批次中发生,将帮助算法找到一个新的质心位置,更好地对应数据流中点的精确分配,而不管在数据流的历史中点是否发生了大的移动。相对差异不可配置,因此建议确保使用大的衰减因子,这将使得 Spark Streaming 能够在相对较长的数据流中找出它应该在杀死这些衰减的质心并将其移动时非常敏感。

注意

在特定分配集群的测试上进行实践,删除一个“坏”聚类(根据某种优良性的概念),并将最大的聚类“分裂”成两个,这是将基本 K-means 想法增强为更强大算法的有效和系统的方法之一。

在这些改进中,我们可以提到 G-means 算法,它包括对一个候选的 K-means 聚类进行测试,使用高斯性测试检查聚类中点的位置是否服从高斯假设,这对各种类型的数据都有效。如果在数据中未达到高斯假设,G-means 认为正在寻找的聚类数目过低。然后,该算法分割此不良聚类并增加 k,即算法试图在数据中找到的聚类数目。

您可以在 [Hammerly2003] 找到更多参考资料,并在 SMILE 库中查看 [Li2017] 的实现。

使用 Spark Streaming 的流式 K-Means

自 Spark 1.2 起,Spark Streaming 中提供了流式 K-means 模型。它使用两个数据流:一个用于训练,另一个用于预测。K-means 模型使用构建器模式初始化:

val trainingData = ...
val testData = ...

val model = new StreamingKMeans()
  .setK(3)
  .setDecayFactor(1.0)
  .setRandomCenters(5, 0.0)

model.trainOn(trainingData)
model.predictOnValues(testData.map(lp => (lp.label, lp.features))).print()

在这个例子中,我们设定了三个中心点,初始的加权随机中心点的权重为 0,并且数据是五维的(这是我们需要在数据中找到的一个维度)。衰减因子 1.0 表示了对之前质心的遗忘,标记了相对于旧数据而言分配给新数据的权重。默认情况下,这个值为 1 不会进行任何衰减——假设数据流中的值变化缓慢。我们也可以通过传递批次间隔的数量来设置,之后一批点的影响降至其值的一半,使用 setHalfLife(batches: Double, durationUnit: String) 方法。

训练数据应该包含每个点的格式化形式为[x_1, _x_2, …, _xn],每个测试数据点应该格式化为(y, [x_1, _x_2, …, _xn]),其中y是这个点的标签。注意,model.trainOn并不会直接创建输出:实际上,这个过程会在没有其他说明的情况下改变model对象。这就是为什么我们在测试数据集上使用 Spark Streaming 的打印功能,这让我们能够在测试数据集更新时见证新的分类。注意,可以调用StreamingKMeans上的latestModel()函数来访问模型的最新版本。

¹ 在这个背景下,先验概率是指通过计算监督标签暗示的类别(例如,垃圾邮件,正常邮件)频率来表示的事件概率的更广义概念。

² streamDM很遗憾并没有作为一个包发布,考虑到它侵占了org.apache.spark的命名空间,在例子中我们可以看到这一点。

³ 有时也被称为非常快速决策树(VFDT),源自介绍它们的论文中使用的名称。

⁴ Hoeffding 界限也经常被称为加法 Chernoff 界限。注意它需要一定范围内的有界数值R

⁵ 质心是在任何给定时刻注意力聚焦集群中每个点的距离最小化的点。

⁶ 这是当前 Streaming K-means 实现中的硬编码常量,不幸的是。

附录 D. 第四部分的参考文献

  • [Alon1999] Alon, N., T. Matias, and M. Szegedy. “近似频率矩的空间复杂度。” 第二十八届 ACM 理论计算机科学年会论文集 (1999). http://bit.ly/2W7jG6c.

  • [Bifet2015] Bifet, A., S. Maniu, J. Qian, G. Tian, C. He, W. Fan. “StreamDM:Spark Streaming 中的高级数据挖掘”,数据挖掘研讨会 (ICDMW),2015 年 11 月。http://bit.ly/2XlXBSV.

  • [Carter1979] Carter, J. Lawrence, and Mark N. Wegman. “哈希函数的通用类”,计算机与系统科学期刊 18 (1979). http://bit.ly/2EQkkzf.

  • [Conway2012] Conway, D., and J. White. 黑客的机器学习。O’Reilly,2012 年。

  • [Cormode2003] Cormode, Graham, and S. Muthukrishnan. 改进的数据流摘要:Count-Min Sketch 及其应用。提交给 Elsevier Science 的预印本,2003 年 12 月。http://bit.ly/2HSmSPe.

  • [Domingos2000] Domingos, Pedro, and Geoff Hulten. “挖掘高速数据流。” 第六届 ACM SIGKDD 国际知识发现与数据挖掘会议论文集 (2000). http://bit.ly/315nSqH.

  • [Dunning2013] Dunning, Ted, and Otmar Erti. “使用 t-Digest 计算极其精确的分位数。” http://bit.ly/2IlpZhs.

  • [Flajolet2007] Flajolet, Philippe, Éric Fusy, Olivier Gandouet, and Frédéric Meunier. “HyperLogLog:一种近乎最优基数估计算法的分析”,离散数学与理论计算机科学 (2007). http://bit.ly/2MFI6V5.

  • [Freeman2015] Freeman, Jeremy. “介绍 Apache Spark 1.2 中的流式 k-means”,Databricks Engineering 博客,2015 年 1 月 28 日。http://bit.ly/2WlFn7k.

  • [Heule2013] Heule, Stefan, Marc Nunkesser, and Alexander Hall. “HyperLogLog 实践中的应用:一种最先进的基数估计算法的算法工程化”,EDBT/ICDT,2013 年 3 月。http://bit.ly/2Wl6wr9.

  • [Hammerly2003] Hammerly, G., and G. Elkan. “学习 k-means 中的 k”,NIPS,2003 年。http://bit.ly/2IbvfUY.

  • [Hulten2001] Hulten, Geoff, Laurie Spencer, and Pedro Domingos. “挖掘时间变化的数据流”,第七届 ACM SIGKDD 国际知识发现与数据挖掘会议论文集 (2001). http://bit.ly/2WqQIU0.

  • [Hunter2016] Hunter, Tim, Hossein Falaki, and Joseph Bradley. “Apache Spark 中的近似算法:HyperLogLog 和分位数”,Databricks Engineering 博客,2016 年 5 月 19 日。http://bit.ly/2EQFCg3.

  • [Kirsch2006] Kirsch, Adam, and Michael Mitzenmacher. “Less Hashing, Same Performance:构建更好的 Bloom Filter” LNCS 4168 (2006). http://bit.ly/2WFCyh2.

  • [Laserson2017] U. Laserson,S. Ryza,S. Owen 和 J. Wills,“Spark 高级分析”,第二版。O’Reilly,2017 年。

  • [Li2017] 李海峰,“统计机器智能与学习引擎”,版本 1.3。http://bit.ly/2Xn1mY3

  • [McCallum1998] Andrew McCallum 和 Kamal Nigam,“朴素贝叶斯文本分类的事件模型比较”,AAAI-98 文本分类学习研讨会,1998 年。

  • [Ni2017] Yun Ni,Kelvin Chu 和 Joseph Bradley,“在 Uber 工程中的规模滥用检测:局部敏感哈希”,Databricks 工程博客。http://bit.ly/2KpSMnF

  • [Nitkin2016] Alexandr Nitkin,“Scala 中的布隆过滤器,JVM 最快”,个人博客,2016 年。http://bit.ly/2XlZT4t

  • [Quinlan1986] J. R. Quinlan,“决策树的归纳”,机器学习 1, 1(1986 年 3 月):81–106。http://bit.ly/2EPZKia

  • [Shindler2011] M. Shindler,A. Wong 和 A.W. Meyerson,“大数据集的快速准确 k-means”,NIPS(2011)。http://bit.ly/2WFGWww

  • [Ziakas2018] Christos Ziakas,“Spark 流式平台中数据流决策树的实现”,2018 年 9 月。http://bit.ly/2Z1gZow

第五部分:超越 Apache Spark

在这部分中,我们希望将 Apache Spark 的流处理引擎放在更广泛的视角内进行审视。我们从与分布式流处理行业其他相关项目的详的区别,解释 Spark 的起源及其独特之处。

我们提供了对其他分布式处理引擎的简要描述和重点比较,包括以下内容:

Apache Apache Storm

分布式处理的历史里程碑,至今在系统中留有遗产

Apache Flink

一个分布式流处理引擎,是 Spark 最活跃的竞争对手

Apache Kafka Streams

一个可靠的分布式日志和流连接器,快速发展分析能力

我们还涉及主要云服务提供商(亚马逊和微软)的云服务,以及谷歌云数据流的集中引擎。

在你详细了解 Apache Spark 流处理抱负的潜力和挑战之后,我们将讨论如何参与 Apache Spark 的社区和流处理生态系统,提供贡献、讨论和成长于流分析实践的参考资料。

第二十九章:其他分布式实时流处理系统

正如本书所示,流处理对于每个数据导向型企业都是至关重要的技术。在处理流数据的任务中,有许多流处理堆栈可供选择,既有专有的也有开源的。它们在功能、API 和延迟与吞吐量之间的平衡中提供不同的权衡。

遵循“适合工作的正确工具”的原则,应当对每个新项目的要求进行比较和对比,以作出正确的选择。

此外,云的演进重要性不仅仅是作为基础设施提供者,还创建了一种新的服务类别,将系统功能作为托管服务(软件即服务 [SAAS])提供。

在本章中,我们将简要概述当前维护的最相关的开源流处理器,如 Apache Storm、Apache Flink、Apache Beam 和 Kafka Streams,并概述主要云提供商在流处理领域的提供情况。

Apache Storm

Apache Storm 是由 Nathan Marz 在 BackType 创建的开源项目。后来在 Twitter 上使用并于 2011 年开源,由 Java 和 Closure 代码组成。它是一个开源的、分布式的、实时计算系统。它是第一个快速、可扩展且部分容错的“大数据”流引擎,当时被视为“流式处理中的 Hadoop”。凭借这一背景,它还激发了许多流处理系统和引擎,包括 Apache Spark Streaming。Storm 是与编程语言无关的,并保证数据至少会被处理一次,尽管在容错性方面存在边缘案例的限制。

Storm 是专为大规模实时分析和流处理、在线机器学习和持续计算而设计的,其非常灵活的编程模型使其能够胜任各种任务。它是第一个广受欢迎的实时分布式流处理系统。Storm 有其特定的术语,需要先介绍其基础知识,以便能够直观地理解其编程 API 的层次。特别是,编写一个 Storm 任务体现了部署拓扑的概念。但在我们深入讨论拓扑之前,需要了解 Storm 中流的传输内容。

处理模型

在 Storm 中,流是元组的流,这是其主要数据结构。Storm 元组是一个具有命名值列表的列表,其中每个值可以是任何类型。元组是动态类型的:字段的类型无需声明。这些无界序列的元组代表了我们感兴趣的流。该流的数据流由拓扑的边表示,我们将定义顶点的确切内容。为了使这些元组具有意义,流中的内容通过模式(schema)进行定义。该模式跟踪在一个图中,这个图就是 Storm 拓扑,它代表计算。在该拓扑中,图的节点大致可以分为两种类型:

喷口

喷口是流的来源,也是数据的起源。这是我们表示拓扑的有向图始终开始的地方。喷口的一个例子可以是重播的日志文件,一个消息系统,一个 Kafka 服务器等。该喷口创建元组,这些元组被发送到其他节点。其中一个其他节点可以是一个螺栓。

螺栓

一个螺栓处理输入流,可能产生新的输出流。它可以执行任何操作,包括过滤,流连接或累积中间结果。它还可以接收多个流,进行聚合,从数据库读取和写入等操作。特别地,它可以是流系统计算的终点,这意味着没有其他节点消耗该螺栓的输出。在这种情况下,该最终节点被称为汇点(sink)。因此,我们可以说拓扑中的所有汇点都是螺栓,但并非所有螺栓都是汇点。

Storm 拓扑

在 Storm 中,拓扑结构是由喷口(spouts)和螺栓(bolts)组成的网络,其中最后一层的螺栓是汇点(sinks)。它们是应用逻辑的容器,类似于 Spark 中的作业,但会持续运行。因此,一个 Storm 拓扑可能包括几个日志文件生成器,如 Web 服务器,每个都可以发送到一个分割螺栓,该螺栓通过一些基本的 ETL 规则进行预处理和数据消息化,选择感兴趣的元素。在这两个螺栓中,我们可以进行连接,统计不规则元素,并通过时间戳将它们连接起来,以了解这些值 Web 服务器事件的时间顺序。这些可以发送到一个最终螺栓,将其副作用发送到特定的仪表板,指示可能在分布式 Web 服务中发生的错误和事件,如警报。

Storm 集群

在这个上下文中,一个 Storm 集群由三个元素管理:拓扑的定义,也就是作业的定义,被传递给一个名为 Nimbus 的调度器,它处理部署拓扑的 Supervisor。Nimbus 守护进程是集群的调度器,负责管理集群上存在的拓扑。这与 YARN API 中的 JobTracker 概念类似。此外,Supervisor 守护进程是一个生成工作者的组件。它类似于 Hadoop 的 TaskTracker 概念,并且由 Supervisor 生成的工作者可以接收 Storm 拓扑中特定元素的实例。

与 Spark 比较

当比较 Apache Spark 和 Apache Storm 时,我们可以看到 Spark Streaming 受到 Apache Storm 及其组织的影响,尤其是在将资源管理器推迟到跨池的 Spark 流执行器之中的目的。

Storm 有一个优势,它处理元组是逐个处理,而不是按时间索引的微批处理。当它们被接收时,它们立即被推送到螺栓和拓扑的直接计算图中的新工作者。另一方面,要管理一个拓扑,我们需要描述我们预期的螺栓复制中的并行性。通过这种方式,我们在直接指定我们希望在图的每个阶段中实现的分布时,会有更多的工作。

对于 Spark Streaming 作业的更简单和直接的分布范例是其优势,其中 Spark Streaming(特别是在其动态分配模式下)将尽力以一种对非常高吞吐量有意义的方式部署我们程序的连续阶段。这种范例上的差异经常作为与其他系统比较 Apache Spark 流处理方法的主要亮点,但需要注意的是 Spark 正在进行连续处理。

这也是为什么几个基准测试([Chintapallu2015])表明,尽管 Storm 通常提供比 Spark Streaming 更低的延迟,但总体而言,等效的 Spark Streaming 应用的吞吐量更高。

Apache Flink

Apache Flink,最初称为StratoSphere[Alexandrov2014]),是一种来自柏林工业大学及其附属大学的流处理框架。

Flink([Carbone2017])是第一个支持无序处理的开源引擎,考虑到 MillWheel 和 Cloud Dataflow 的实现是 Google 的私有产品。它提供了 Java 和 Scala API,使其看起来与 Apache Spark 的 RDD/DStream 功能 API 类似:

val counts: DataStream[(String, Int)] = text
      // split up the lines in pairs (2-tuples) containing: (word,1)
      .flatMap(_.toLowerCase.split("\\W+"))
      .filter(_.nonEmpty)
      .map((_, 1))
      // group by the tuple field "0" and sum up tuple field "1"
      .keyBy(0)
      .sum(1)

类似 Google Cloud Dataflow(在本章后面提到),它使用数据流编程模型

数据流编程

数据流编程 是一种将计算建模为数据流图的编程类型的概念名称。它与函数式编程密切相关,强调数据的移动,并将程序建模为一系列连接的操作。明确定义的输入和输出通过操作连接在一起,操作之间视为彼此的黑盒子。

这是由 MIT 在 1960 年代的 Jack Dennis 及其研究生首创的。Google 随后将此名称重用于其云编程 API。

流优先框架

Flink 是一种逐条流处理框架,它还提供快照来保护计算免受故障影响,尽管它缺乏同步批处理边界。如今,这个非常完整的框架比结构化流处理提供了更低级别的 API,但如果低延迟是关注点的话,它是对 Spark Streaming 的一个引人注目的替代选择。Flink 在 Scala 和 Java 中提供 API。¹

与 Spark 比较

与这些替代框架相比,Apache Spark 保持其主要优势,即紧密集成的高级数据处理 API,批处理和流处理之间的变化最小。

随着结构化流处理的发展,Apache Spark 已经赶上了时间查询代数的丰富功能(事件时间,触发器等),这些功能 Dataflow 拥有而 Spark Streaming 曾经缺乏。然而,结构化流处理保持了与 Apache Spark 中已经确立的批 DataSet 和 Dataframe API 的高度兼容性。

结构化流处理通过无缝扩展 Spark 的 DataSet 和 Dataframe API,添加了流处理功能,这是其主要价值所在:可以在流数据上进行计算,几乎不需要专门的培训,并且认知负荷很小。

这种集成的一个最有趣的方面是,通过 Catalyst 的查询规划器在结构化流处理中运行流数据集查询,从而优化用户查询,并使流计算比使用类似数据流系统编写的计算 less error prone。同时请注意,Flink 有一个类似于 Apache Spark Tungsten 的系统,允许它管理其自己的堆外内存段,利用强大的低级 JIT 优化。([Hueske2015], [Ewen2015])

最后,请注意,Apache Spark 也是关于调度研究的对象,这表明对于像 Spark 这样的系统,未来将会有更好的低延迟。它可以重复使用跨微批次的调度决策。([Venkataraman2016])

总结一下,作为生态系统,Apache Spark 在继续流处理性能方面表现出非常强的论点,特别是在与批处理分析交换代码相关的背景下,而 Apache Beam 作为与其他流计算开发方式接口的平台,似乎是一个有趣的平台,用于开发“一次编写,任意集群运行”的这种开发方式。

Kafka Streams

要在其他处理系统中继续此导览,我们必须提到年轻的 Kafka Streams 库。

Kafka Streams(Kreps2016),于 2016 年推出,是 Apache Kafka 项目内集成的流处理引擎。

Kafka Streams 提供 Java 和 Scala API,我们可以使用这些 API 编写具有流处理功能的客户端应用程序。而 Spark、Storm 和 Flink 是框架,它们接受作业定义并在集群上管理其执行,而 Kafka Streams 则是一个库。它作为依赖项包含在应用程序中,并提供 API 供开发人员使用,以增加流处理功能。Kafka Streams 还为名为 KSQL 的流式 SQL 查询语言提供后端支持。

Kafka Streams 编程模型

Kafka Streams 通过提供由有状态数据存储支持的流视图,利用了流-表二元性。它提出了表是聚合流的观点。这一观点根植于我们在第二部分中看到的 Structured Streaming 模型中的同一基本概念。使用 Kafka Streams,您可以从一次处理一个事件中受益。其处理支持包括分布式连接、聚合和有状态处理。窗口和事件时间处理也可用,以及 Kafka 提供的丰富的分布式处理和容错保证,使用偏移回放(重新处理)。

与 Spark 比较

Spark 模型和 Kafka Streams 之间的关键区别在于,Kafka Streams 被用作客户端应用程序范围内的客户端库,而 Spark 是一个分布式框架,负责在集群中的工作协调。在现代应用架构方面,Kafka Streams 应用可以被视为使用 Kafka 作为数据后端的微服务。它们的可伸缩性模型基于副本——运行应用程序的多个副本——并且它绑定于正在消费的主题的分区数。

Kafka Streams 的主要用途是为客户端应用程序“增加”流处理功能,或创建简单的流处理应用程序,这些应用程序使用 Kafka 作为它们的数据源和接收端。

然而,Kafka Streams 的劣势在于没有围绕 Apache Spark 开发的围绕流处理的丰富生态系统,如具有 MLlib 的机器学习能力或与广泛数据源支持的外部交互。此外,Kafka Streams 也不具备与 Spark 提供的批处理库和批处理处理的丰富互动。因此,单纯依赖 Kafka Streams 会难以构想未来复杂的机器学习管道,无法充分利用大量科学计算库的优势。

在云上

Spark 的表达性编程模型和先进的分析能力可以在云上使用,包括主要厂商的提供:亚马逊、微软和谷歌。在本节中,我们简要介绍了 Spark 流处理能力在云基础设施上及其与本地云功能的结合方式,以及在相关情况下与云提供商自有的专有流处理系统的比较。

亚马逊 Kinesis 在 AWS 上

亚马逊 Kinesis 是亚马逊网络服务(AWS)的流处理传输平台。它具有丰富的语义来定义流数据的生产者和消费者,以及用于与这些流端点创建的流水线连接器。我们在 第十九章 中提到了 Kinesis,在那里我们描述了 Kinesis 与 Spark Streaming 之间的连接器。

Kinesis 和 Structured Streaming 之间有连接器,提供两种方式:

  • Databricks 版本的 Spark 原生提供给用户,可在 AWS 和微软 Azure 云上使用。

  • 在 JIRA 下的开源连接器 Spark-18165,提供了一种轻松从 Kinesis 流式传输数据的方式。

这些连接器是必要的,因为按设计,Kinesis 除了在 AWS 分析 上覆盖较简单的基于 SQL 的查询外,并未提供全面的流处理范式。因此,Kinesis 的价值在于让客户从经过实战验证的 Kinesis SDK 客户端生成的强大源和汇聚中实现自己的处理。使用 Kinesis,可以利用 AWS 平台的监控和限流工具,获得在 AWS 云上即可用的生产就绪流传输。更多详细信息可以在 [Amazon2017] 中找到。

亚马逊 Kinesis 与 Structured Streaming 之间的开源连接器是 Qubole 工程师的贡献,您可以在 [Georgiadis2018] 找到。该库已在 Spark 2.2 上进行开发和测试,允许 Kinesis 成为 Spark 生态系统的完整成员,让 Spark 用户定义任意复杂的分析处理。

最后,请注意,尽管 Kinesis 连接器用于 Spark Streaming 是基于旧的接收器模型,这带来了一些性能问题。而这个 Structured Streaming 客户端在其实现上更加现代化,但尚未迁移到 Spark 2.3.0 引入的数据源 API 的第 2 版本。Kinesis 是 Spark 生态系统中一个欢迎易于贡献更新其实现质量的区域。

总结一下,AWS 中的 Kinesis 是一种流传输机制,引入了生产和消费流,并将它们连接到特定的端点。但是,它的内置分析能力有限,这使得它与流分析引擎(如 Spark 的流模块)互补。

Microsoft Azure Stream Analytics

Azure Streaming Analytics([Chiu2014])是 Microsoft Azure 上的云平台,灵感来源于 DryadLINQ,这是一个 Microsoft 研究项目,用于使用逻辑处理计划编译语言查询,适用于流处理。它提供了一个高级 SQL-like 语言来描述用户查询,同时也可通过实验性 JavaScript 函数让用户定义一些自定义处理。其目标是使用这种类 SQL 语言表达高级的面向时间的流查询。

就这一点而言,它与 Structured Streaming 类似,支持多种时间处理模式。除了通常的分析函数——包括聚合、最后和第一个元素、转换、日期和时间函数——Azure Stream Analytics 还支持窗口聚合和时间连接。

时间连接是 SQL 连接,其中包含对匹配事件的时间约束。在连接时使用的谓词可以允许用户表达,例如,两个连接的事件必须具有按时间延迟有限的时间戳。这种丰富的查询语言得到了 Microsoft 的大力支持,他们尝试在 2016 年左右([Chen2016])在 Spark Streaming 1.4 上重新实现它。

这项工作尚未完成,因此在今天的生产环境中,它在 Azure Streaming Analytics 中的整合还不够完善。Structured Streaming 已经追赶上了这些特性,现在作为其内部连接设施的一部分,也提供了时间连接作为本地特性。

因此,Azure Stream Analytics 曾经在实现复杂的基于时间的查询方面具有优势,但现在提供的本地设施比 Structured Streaming 少,后者除了类 SQL 查询外,在其 Streaming Dataset API 中还提供了丰富的处理能力。

因此,对于在 Microsoft Cloud 上进行高级流处理项目而言,部署 Spark 在 Azure 上似乎是更为健壮的方法。选项包括 HDInsight 管理的 Spark、Azure 上的 Databricks,或使用 Azure 的本地资源配置能力,提供托管的 Kubernetes(AKS)以及虚拟机。

Apache Beam/Google Cloud Dataflow

现代流处理系统有许多种,包括但不限于 Apache Flink,Apache Spark,Apache Apex,Apache Gearpump,Hadoop MapReduce,JStorm,IBM Streams 和 Apache Samza。Apache Beam 是由谷歌领导的开源项目,旨在管理这个流处理系统行业的存在,同时提供与谷歌云数据流计算引擎的良好集成。让我们解释一下这是如何实现的。

在 2013 年,谷歌拥有另一个名为 MillWheel 的内部云流处理系统[Akidau2013]。当时机成熟,要给它一次翻新,并将其与成熟的云服务进行连接,以便向公众开放,MillWheel 就变成了谷歌云数据流(Google Cloud Dataflow)[Akidau2014],在容错性和事件触发领域增加了几个关键的流处理概念。关于此还有更多内容,你可以在[Akidau2017]中找到。

但是,当我们已经列出了所有这些其他替代方案之后,为什么要提供另一种选择呢?是否可能在一个 API 下实现一个系统,它在所有这些计算引擎下都可以运行流处理?

那个 API 变成了一个开源项目,Apache Beam,旨在提供一个供流处理使用的单一编程 API,可以连接到我们之前提到的所有流计算引擎中的任何一个,以及 Apex,Gearpump,MapReduce,JStorm,IBM Streams 和 Samza。所有这些计算引擎都作为 Apache Beam 的后端插件(或"runners")公开,旨在成为流处理的通用语言

例如,要计算 30 分钟窗口的整数索引总和,我们可以使用以下方式:

PCollection<KV<String, Integer>> output = input
  .apply(Window
  .into(FixedWindows.of(Duration.standardMinutes(30)))
  .triggering(AfterWatermark.pastEndOfWindow()))
  .apply(Sum.integersPerKey());

在这里,我们通过关键字对整数进行求和,使用一个固定的 30 分钟窗口,并在水印通过窗口末尾时触发sum输出,这反映了"我们估计窗口已完成"的时刻。

注意

需要注意的一点是,与结构化流式处理不同,触发器和输出与查询本身并不独立。在 Dataflow(以及 Beam 中),窗口还必须选择输出模式和触发器,这使标识符的语义与其运行时特性混淆在一起。在结构化流式处理中,即使对于逻辑上不使用窗口的查询,也可以拥有这些,从而使概念的分离更加简单。

Apache Beam 提供了 Java SDK 中的 API,以及 Python SDK 和一个更小但仍然显著的 Go SDK,以及一些 SQL 原始查询。它允许一种单一的语言支持一系列在流处理中通用可适应的概念,并希望可以运行在几乎每个流计算引擎上。除了我们在之前章节中看到的经典聚合操作,例如基于窗口的分片和事件时间处理,beam API 还允许在处理时间中包含触发器,包括计数触发器,允许延迟,以及事件时间触发器。

但 Beam 的亮点在于提供不同流处理系统之间的可移植性,集中一个单一的 API(遗憾的是没有 Scala SDK)用于几乎可以在任何地方运行的流处理程序。这是令人兴奋的,但请注意,当在特定计算引擎下执行时,它只具备此计算引擎及其“运行器”插件在实现 Apache Beam API 全部功能方面的能力。

特别是,Apache Spark 的计算引擎暴露了 Spark Streaming 的能力,而不是 Structured Streaming 的能力。截至本文撰写时,它尚未完全实现有状态流或任何事件时间处理,因为这些功能在仅限于 Spark Streaming 中或者“运行器”插件尚未跟上 Spark Streaming 自身变化的情况下受到限制。因此,用 Apache Beam 表达您的程序通常是一个稍微落后于 Structured Streaming 表达能力的游戏,同时 Apache Beam 的 Spark 运行器正在追赶。

当然,Spark 生态系统仅通过与其他相关流处理项目的协作才变得更强大,因此我们当然鼓励您这样做,以帮助为 Apache Beam 的 Spark 运行器贡献力量,以便使用 Beam API 的项目能够从 Spark 流处理引擎的效率提升中受益。

总之,Apache Beam 是一个旨在提供非常表现力强大的流处理模型的开源项目。它是一个在 Google Cloud Dataflow 中高效实现的 API,允许您在 Google Cloud 上运行此类程序以及众多相关流处理系统,但需要注意它们并非都具备相同的能力。建议参考 Apache Beam 能力矩阵,了解差异概览。

但请注意,Google Cloud 也允许在节点或 Kubernetes 上运行本机 Apache Spark,在实践中,如果您知道将在支持轻松部署 Apache Spark 的系统上运行程序,则可能不需要切换到 Beam API。如果需要支持 Google Cloud 和其他流处理系统作为部署系统,Apache Beam 可能是个不错的选择。

¹ 为了保持 Scala 和 Java API 之间的一定一致性,一些允许在 Scala 中表达高级表现力的特性已从批处理和流处理的标准 API 中剔除。如果您希望享受完整的 Scala 经验,可以选择通过隐式转换选择扩展功能。

第三十章:展望未来

Apache Spark 是一个快速发展的项目。

我们已经看到,Spark Streaming 是建立在弹性分布式数据集(RDDs)及每位程序员都习惯使用的常规 Java、Scala 或 Python 对象之上的较老且相对低级别的 API。Spark Streaming 已经在许多生产级应用中经过了实战验证和部署。我们可以认为它是一个稳定的 API,其中大部分工作主要集中在维护上。

结构化流处理是建立在 Spark 的 Dataset 和 Dataframe API 之上的,充分利用了 Apache Spark 通过 Spark SQL 引入的卓越优化工作,例如 Catalyst 引擎以及来自 Tungsten 项目的代码生成和内存管理。从这个意义上讲,结构化流处理是 Apache Spark 流处理的未来,也是可预见未来主要开发工作的重点。因此,结构化流处理正在提供如连续处理等令人兴奋的新发展。

我们需要提到,结构化流处理是流处理的一个较新的框架,因此较少成熟,特别是在本书的机器学习章节中已经详细阐述。在进行重视机器学习的项目时,牢记这一点非常重要。鉴于当前对机器学习的兴趣,我们预计未来版本的 Spark 将在这一领域带来改进,并支持更多的流模式算法。我们希望为您提供了所有元素,以便对这两个 API 的提供进行准确评估。

还有一个问题我们想要解答:如何在这个领域继续学习和进步。

保持关注

Apache Spark 最强大的一面之一始终是它的社区。

作为一个开源项目,Apache Spark 在将个人和公司的贡献整合成为全面而一致的代码库方面非常成功,正如在 图 30-1 中所展示的。

spas 3001

图 30-1. Spark 贡献时间线

Apache Spark 的 GitHub 页面证明了其稳定的开发步伐,每个发布都有超过 200 名开发人员贡献,并且总贡献者人数超过数千人。

有几个成熟的渠道可以与社区保持联系。

在 Stack Overflow 上寻求帮助

Stack Overflow,这个著名的问答社区,是讨论与 Spark 相关问题的非常活跃的地方。建议在提问之前先在这个网站上搜索现有的答案,因为很可能之前的人已经有过相同或类似的疑问。

在邮件列表上开始讨论

Apache Spark 社区一直严重依赖于两个邮件列表,核心开发人员和 Apache Spark 的创建者定期致力于帮助用户和其他贡献者。用户邮件列表,user@spark.apache.org,适用于试图找到最佳使用 Spark 方法的读者,而开发者邮件列表,dev@spark.apache.org,则服务于那些正在改进 Spark 框架本身的人。

你可以免费在线了解如何订阅这些邮件列表的最新详细信息,for free online

参加会议

Spark Summit 是由 Databricks 推广的两年一度的会议周期。除了专注于 Spark 的会议议程之外,这个会议还提供了一个让 Spark 开发者与社区及彼此见面的场所。你可以在 online 找到更多信息。

参加 Meetups

如果你住在技术足迹大的城市附近,请考虑参加 user groups or meetups。它们通常是免费的,是早期预览会议演讲或更亲密的演示和 Apache Spark 应用案例的绝佳机会。

阅读书籍

我们之前提到过 Matei Zaharia 和 Spark 项目的其他创始人在 2015 年出版的书籍 Learning Spark 是建立在理解 Apache Spark 功能的良好起点。一般来说,O'Reilly 出版的关于 Spark 的多篇文献都是我们强烈推荐的。我们只想提一下 Matei Zaharia 和 Bill Chambers 在 2017 年出版的 Spark: The Definitive Guide,作为对 Spark 平台最新演进的必读内容。

在更加理论的一面,你可能会发现自己在抽象层面上寻找关于流算法和机器学习更深入的知识,然后再使用 Apache Spark 实现这些概念的更多内容。在这个领域有太多的材料,我们无法详尽推荐,但我们可以提到 Alex Smola 在伯克利进行的 2012 年的 course on data streams at Berkeley 是一个很好的入门点,附有丰富的参考文献。

参与 Apache Spark 项目的贡献

当你想把你的算法探险成果贡献给开源社区时,你会发现 Apache Spark 的开发组织如下:

Spark 的开发工作流程包括用于更大规模开发的设计文档,您将在之前提到的资源中找到它们,这些资源为您提供了一个了解开发过程的绝佳窗口。另一种了解 Apache Spark 开发工作的方法是观看 Holden Karau 的视频,她是 Apache Spark 的开发者和 PMC 成员,直播她的拉取请求审查甚至编码会话。您将在以下地方找到这种独特的“一天中的 Spark 开发者生活”体验:

所有这些资源都应该为您提供工具,不仅可以掌握使用 Apache Spark 进行流处理,还可以为每天使这个系统变得更好的集体努力提供手段。

我们希望您喜欢这本书!

附录 E. 第五部分的参考资料

  • [Akidau2013] Akidau, Tyler, Alex Balikov, Kaya Bekiroglu, Slava Chernyak, Josh Haberman, Reuven Lax, Sam McVeety 等人。“MillWheel:互联网规模的容错流处理。”VLDB 会议论文,Vol. 6, No. 11 (2013 年 8 月)。http://bit.ly/2KpYXYT

  • [Akidau2014] Akidau, Tyler, Robert Bradshaw, Craig Chambers, Slava Chernyak, Rafael J. Fernandez-Moctezum, Reuven Lax, Sam McVeety 等人。“数据流模型:在海量、无界、乱序数据处理中平衡正确性、延迟和成本的实用方法。”VLDB Endowment 论文集,Vol. 8, No. 12 (2014 年 9 月)。http://bit.ly/316UGQj

  • [Akidau2016] Akidau, Tyler。“为什么选择 Apache Beam?Google 的视角”,Google Cloud 大数据与机器学习博客,2016 年 5 月 3 日。http://bit.ly/2Kmwmny

  • [Akidau2017] Akidau, Tyler, Slava Chernyak 和 Reuven Lax。Streaming Systems。O’Reilly,2017 年。

  • [Alexandrov2014] Alexandrov, A., R. Bergmann, S. Ewen 等人。“Stratosphere 平台用于大数据分析。”VLDB Journal,23 (2014): 939。http://bit.ly/2JSQu15

  • [Amazon2017] Amazon Web Services。“AWS 上的流数据解决方案与 Amazon Kinesis”,白皮书,2017 年 7 月。http://bit.ly/2MpQSpZ

  • [Carbone2017] Carbone, P., S. Ewen, G. Fóra, S. Haridi, S. Richter 和 K. Tzoumas。“Apache Flink 中的状态管理。”VLDB Endowment 论文集,10: 12 (2017): 1718-1729。http://bit.ly/2wAW7s6

  • [Chen2016] 陈仲。“Spark Streaming 和 Azure Stream Analytics”,Azure Stream Analytics 团队博客,2015 年 6 月 16 日。http://bit.ly/2QPBwtz

  • [Chintapalli2016] Chintapalli, S., D. Dagit, B. Evans, R. Farivar, T. Graves, M. Holderbaugh, Z. Liu 等人。“在 Yahoo! 进行流计算引擎基准测试”,Yahoo! 工程博客,2015 年 12 月。http://bit.ly/2HSkpnG

  • [Chiu2014] Chiu, Oliver。“宣布 Azure Stream Analytics 用于实时事件处理”,Microsoft Azure 博客,2014 年 10 月 29 日。http://bit.ly/2Kp8FuL

  • [Ewen2015] Ewen, S.;“Apache Flink 中的堆外内存和好奇的 JIT 编译器”,Flink 博客,2015 年 9 月。http://bit.ly/2Xo83sO

  • [Georgiadis2018] Georgiadis, Georgios, Vikram Agrawal 等人。“Kinesis 结构化流连接器”,Github,2018 年 3 月。http://bit.ly/2wxS3Jf

  • [Hueske2015] Hüske, F.。“位与字节的游戏”,Flink 博客,2015 年 5 月。http://bit.ly/318oThA

  • [Kreps2016] Kreps, Jay。“介绍 Kafka Streams:简化流处理”,Confluent 博客,2016 年 3 月 10 日。http://bit.ly/2WlkjOh

  • [Marz2014] Marz, Nathan。"Apache Storm 的历史和经验教训," Thoughts from the Red Planet 博客, 2014 年 10 月 6 日。http://bit.ly/2JVEMme.

  • [Noll2018] Noll, Michael。"关于 Kafka 和流处理中的流与表," 个人博客, 2018 年 4 月 5 日。http://bit.ly/2wvOR0A.

  • [Tejada2018] Tejada, Zoiner, Mike Wasson, Mary McCready, Christopher Bennage, 和 Romit Girdhar。"在 Azure 中选择流处理技术," Azure 架构中心, 2018 年 10 月 24 日。http://bit.ly/2HStQnt.

posted @ 2025-11-19 09:21  绝不原创的飞龙  阅读(8)  评论(0)    收藏  举报