Storm-应用指南-全-
Storm 应用指南(全)
原文:Storm Applied
译者:飞龙
第一章. 介绍 Storm
本章涵盖
-
什么是 Storm
-
大数据的定义
-
大数据工具
-
Storm 在大数据图景中的位置
-
使用 Storm 的原因
Apache Storm 是一个分布式、实时计算框架,它使得处理无界数据流变得容易。Storm 可以与你的现有队列和持久化技术集成,消费数据流并在许多方式上对这些流进行处理/转换。
仍在关注我们吗?你们中的一些人可能觉得自己很聪明,因为你们知道这意味着什么。其他人可能在寻找合适的动态 GIF 来表达你的挫败感。这个描述中有很多内容,所以如果你现在还没有完全理解它的所有含义,请不要担心。我们已经在本章的剩余部分致力于阐明我们确切的意思。
要理解 Storm 是什么以及何时应该使用它,你需要了解 Storm 在大数据领域中的位置。它可以与哪些技术一起使用?它可以替代哪些技术?能够回答这些问题需要一些背景知识。
1.1. 什么是大数据?
要谈论大数据以及 Storm 在大数据领域中的位置,我们需要对“大数据”的含义有一个共同的理解。关于大数据有很多定义。每个定义都有其独特的见解。这是我们的定义。
1.1.1. 大数据的四个 V
通过考虑大数据的四个不同属性来理解大数据:数据量、速度、多样性和真实性.^([1])
数据量
数据量是大数据最明显的属性——也是当人们听到这个术语时首先想到的。每天从各种来源不断生成数据:由社交媒体中的人生成数据、软件自身生成数据(网站跟踪、应用程序日志等),以及用户生成数据,如维基百科,只是数据来源的一小部分。
当人们想到数据量时,谷歌、Facebook 和 Twitter 等公司会浮现在脑海中。当然,所有这些公司都处理着大量的数据,我们确信你们可以列出其他公司,但那些没有这种数据量的公司呢?有很多其他公司,仅从数据量的定义来看,并不拥有大数据,但这些公司却在使用 Storm。为什么?这就是第二个 V,速度,发挥作用的地方。
速度
速度处理数据流入系统的速度,无论是数据量还是它是连续的数据流。数据量(可能只是网站上的几个链接,访客正在点击)可能相对较小,但流入你系统的速度可能相当高。速度很重要。如果你处理数据不够快,无法提供价值,那么你有多少数据都无关紧要。它可能只有几个太字节;它可能是由 500 万个 URL 组成的较小数据量。所有重要的是你能否在数据过时之前从中提取意义。
到目前为止,我们已经有了量和速度,它们处理数据量和流入系统的速度。在许多情况下,数据也会来自多个来源,这让我们来到了下一个 V:多样性。
多样性
为了增加多样性,让我们退一步,看看如何从数据中提取意义。通常,这可能涉及从几个来源获取数据并将它们组合成可以讲述故事的东西。然而,当你开始时,你可能有一些数据在谷歌分析中,也许有一些在只读日志中,也许还有一些在关系型数据库中。你需要将这些数据全部整合起来,并塑造出你可以用来深入挖掘并从以下问题中提取有意义的答案的东西:
-
Q: 我的最佳客户是谁?
-
A: 新墨西哥州的野狗。
-
Q: 他们通常购买什么?
-
A: 有些是油漆,但大多是大型重物。
-
Q: 我能否单独查看每位客户并找到其他人喜欢的产品,然后将这些产品推广给他们?
-
A: 这取决于你多快能将你的各种数据转化为你可以使用和操作的东西。
就像我们没有足够的担忧一样,大量数据以快速的速度从各种来源进入我们的系统,我们还得担心进入我们系统中的数据准确性。最后的 V 处理这个问题:真实性。
真实性
真实性涉及进入和出去数据的准确性。有时,我们需要我们的数据非常准确。其他时候,“足够接近”的估计就足够了。许多允许进行高保真估计同时保持低计算需求的算法(如超对数)通常用于大数据。例如,确定一个极其成功的网站的精确平均页面浏览时间可能不是必需的;一个足够接近的估计就足够了。这些准确性和资源之间的权衡是大数据系统的常见特征。
定义了量、速度、多样性和真实性这些属性后,我们为大数据建立了一些一般性的边界。我们的下一步是探索在这些边界内可用于处理数据的各种工具。
1.1.2. 大数据工具
存在许多工具来解决大数据的各种特性(体积、速度、多样性和真实性)。在给定的大数据生态系统中,不同的工具可以单独使用或组合使用,用于不同的目的:
-
数据处理— 这些工具用于执行某种形式的计算并从数据集中提取智能。
-
数据传输— 这些工具用于收集和摄取数据到数据处理系统(或在不同系统组件之间传输数据)。它们有多种形式,但最常见的是消息总线(或队列)。示例包括 Kafka、Flume、Scribe 和 Scoop。
-
数据存储— 这些工具用于在处理的不同阶段存储数据集。它们可能包括分布式文件系统,如 Hadoop 分布式文件系统 (HDFS) 或 GlusterFS,以及 NoSQL 数据存储,如 Cassandra。
我们将重点关注数据处理工具,因为 Storm 是一个数据处理工具。要理解 Storm,你需要了解各种数据处理工具。它们主要分为两大类:批处理和流处理。最近,两者之间出现了一种混合形式:流处理中的微批处理。
批处理
暂时考虑一个单一的数据点:对网站的一次唯一点击。现在想象在相同的时间段内发生的数十万次其他点击。所有这些点击一起形成一个批次——要一起处理的数据点集合。图 1.1 提供了数据流入面向批次的工具的概述。
图 1.1. 批处理器和数据如何流入其中

处理网站的日志文件以提取有关访客行为的例子是一个优秀的批处理问题。我们有一个固定的数据池,我们将对其进行处理以获得结果。这里需要注意的重要一点是,工具作用于数据批次。这个批次可能是一个小的数据段,也可能整个数据集。在处理数据批次时,你能够从整个批次中得出一个整体概述,而不是单个数据点。之前关于了解访客行为的例子不能基于单个数据点进行;你需要基于其他数据点(即,其他访问的 URL)的上下文。换句话说,批处理允许你连接、合并或聚合不同的数据点。这就是为什么批处理经常用于机器学习算法。
批量处理的另一个特点是,其结果通常只有在整个批次完成处理之后才能获得。早期数据点的结果只有在整个过程完成后才会变得可用。你的批次越大,你能够进行的合并、聚合和连接就越多,但这也带来了成本。批次越大,你需要等待的时间就越长,才能从其中获取有用的信息。如果答案的即时性很重要,流处理可能是一个更好的解决方案。
流处理
流处理器对无界的数据流进行操作,而不是对数据点的批次进行操作。图 1.2 说明了数据如何流入流处理系统。
图 1.2. 流处理器及其数据流入方式

流处理器持续地摄取新的数据(称为“流”)。对流处理的需求通常源于对结果即时性的需求。但这并不总是如此,也绝对不是流处理的强制要求。这就是为什么我们有未界定的数据流不断输入到流处理器中。这些数据通常通过消息总线从其源头导向流处理器,以便在数据仍然“热”的时候就能获得结果。与批量处理不同,流中流动的数据点没有明确的开始或结束;它是连续的。
这些系统通过一次处理一个数据点来实现即时性。大量数据点正在流过流,当你一次处理一个数据点并且并行进行时,在数据创建和结果可用之间实现亚秒级延迟是非常容易的。想想对推文流进行情感分析。为了实现这一点,你不需要将任何传入的推文与其他同时发生的推文关联起来,因此你可以一次处理一个推文。当然,你可能需要一些通过使用历史推文创建的训练集来获取的上下文数据。但由于这个训练集不需要由当前发生的推文组成,可以避免昂贵的当前数据聚合,你可以在一次处理一个推文的同时继续操作。因此,在流处理应用程序中,与批量系统不同,你将在每个数据点完成处理时获得结果。
但流处理不仅限于一次处理一个数据点。最著名的例子之一是 Twitter 的“趋势话题”。趋势话题是通过考虑每个时间窗口内的推文来在滑动时间窗口内计算的。通过比较当前窗口的推文主题与之前的窗口,可以观察到趋势。显然,由于在时间框架内处理推文批次,这会在一次处理一个数据点的基础上增加延迟(因为每个推文不能被视为完成处理,直到它所属的时间窗口结束)。同样,其他形式的缓冲、连接、合并或聚合可能在流处理期间增加延迟。在这种聚合中,引入的延迟和可达到的精度之间总是存在权衡。更大的时间窗口(或在连接、合并或聚合操作中的更多数据)可能在某些算法中确定结果的准确性——以延迟为代价。通常在流系统中,我们保持在毫秒、秒或最多几分钟的处理延迟内。超出这个范围的用例更适合批处理。
我们刚刚考虑了使用流系统对推文的两个用例。以推文形式通过 Twitter 系统流动的数据量是巨大的,Twitter 需要能够告诉用户他们所在地区现在都在谈论什么。想想看。Twitter 不仅需要以高容量运行,还需要以高速度(即低延迟)运行。Twitter 有一个巨大的、永无止境的推文流进入,它必须能够实时提取人们正在谈论的内容。这是一项艰巨的工程壮举。事实上,第三章就是围绕一个类似这种趋势话题想法的用例构建的。
流中的微批处理
在过去几年中,出现了一些专为与趋势话题等示例一起使用而构建的工具。这些微批处理工具与流处理工具类似,因为它们都处理无界的数据流。但与允许你访问其中每个数据点的流处理器不同,微批处理处理器以一种方式将传入的数据分组到批次中,并一次给你一个批次。这种方法使得微批处理框架不适合一次处理一个数据点的问题。你也在放弃处理单个数据点时相关的超低延迟。但它们使得在流中处理数据批次变得容易一些。
1.2. Storm 如何融入大数据场景
那么,Storm 在这个所有内容中处于什么位置呢?回到我们最初的定义,我们说:
Storm 是一个分布式、实时计算框架,使得处理无界数据流变得容易。
Storm 是一个流处理工具,简单明了。它将无限期运行,监听数据流,并在从流中接收到数据时“做些什么”。Storm 也是一个分布式系统;它允许我们轻松地添加机器,以便实时处理尽可能多的数据。此外,Storm 还附带一个名为 Trident 的框架,允许您在流中进行微批处理。
什么是实时?
当我们在整本书中使用“实时”这个术语时,我们究竟指的是什么?好吧,从技术角度来说,“近实时”更为准确。在软件系统中,实时约束被定义为设置系统响应特定事件的操作截止时间。通常,这种延迟在毫秒级别(或者至少是亚秒级),对最终用户来说没有可感知的延迟。在 Storm 的上下文中,实时(亚秒级)和近实时(取决于用例,几秒或几分钟)的延迟都是可能的。
那么我们最初定义中的第二句话是什么意思呢?
暴风雨可以与您现有的队列和持久化技术集成,消费数据流并以多种方式处理/转换这些流。
正如我们将在整本书中向您展示的那样,Storm 在灵活性方面极为出色,流源可以是任何东西——通常这意味着一个队列系统,但 Storm 并不限制您的流来自哪里(在我们的几个用例中,我们将使用 Kafka 和 RabbitMQ)。对于 Storm 产生的流转换结果也是如此。我们已经看到许多案例,其中结果被持久化到某个数据库中以便稍后访问。但结果也可能被推送到另一个队列,供另一个系统(甚至可能是另一个 Storm 拓扑)处理。
重点是您可以将 Storm 插入到现有的架构中,这本书将提供用例来说明您如何做到这一点。图 1.3 展示了分析推文流的一个假设场景。
图 1.3. Storm 在系统内使用的示例

这个高级假设解决方案正是如此:假设的。我们想展示 Storm 可以在系统中的哪个位置,以及批处理和流处理工具共存的可能性。
那么与 Storm 一起使用的不同技术呢?图 1.4 对此问题提供了一些启示。该图显示了在这个架构中可以使用的某些技术的少量样本。它说明了 Storm 在可以与之工作的技术以及可以插入系统中的位置方面的灵活性。
图 1.4. Storm 与其他技术一起使用的方法

对于我们的队列系统,我们可以从包括 Kafka、Kestrel 和 RabbitMQ 在内的一系列技术中进行选择。数据库的选择也是如此:Redis、Cassandra、Riak 和 MySQL 只是在众多选项中触及了表面。而且看,我们甚至在我们的解决方案中包含了一个 Hadoop 集群,用于执行我们“每日热门话题”报告所需的批量计算。
希望你开始更清楚地了解 Storm 的位置以及它可以与什么一起使用。包括 Hadoop 在内的一系列技术都可以在系统中与 Storm 一起工作。等等,我们刚刚告诉你 Storm 可以与 Hadoop 一起工作吗?
1.2.1. 暴风雨与常规嫌疑人
在许多工程师之间的对话中,Storm 和 Hadoop 经常被放在同一句话中。我们不会从工具开始,而是从你可能会遇到的问题类型开始,通过考虑每个工具的特点来展示最适合的工具。很可能会选择多个工具,因为没有单个工具适合所有问题。实际上,在适当的条件下,工具甚至可以结合使用。
以下对各种大数据工具的描述以及与 Storm 的比较旨在引起人们对它们与 Storm 独特不同的注意。但不要仅凭此信息就选择一个工具而放弃另一个。
Apache Hadoop
Hadoop 以前是批量处理系统的同义词。但随着 Hadoop v2 的发布,它不仅仅是一个批量处理系统——它是一个大数据应用的平台。其批量处理组件被称为 Hadoop MapReduce。它还附带了一个作业调度器和集群资源管理器,称为 YARN。另一个主要组件是 Hadoop 分布式文件系统,HDFS。许多其他大数据工具正在构建中,它们利用 YARN 来管理集群,并将 HDFS 作为数据存储后端。在本书的剩余部分,当我们提到 Hadoop 时,我们指的是其 MapReduce 组件,我们将明确提及 YARN 和 HDFS。
图 1.5 展示了数据如何被输入到 Hadoop 进行批量处理。数据存储是分布式文件系统,HDFS。一旦确定了与当前问题相关的数据批次,MapReduce 过程就会在每个批次上运行。当 Map-Reduce 过程运行时,它将代码移动到数据所在的节点。这通常是批量作业的一个特性。批量作业已知可以处理非常大的数据集(从千兆到拍字节并不罕见),在这些情况下,将代码移动到分布式文件系统中的数据节点并在这些节点上执行代码更容易,从而通过数据本地性实现了显著的效率提升。
图 1.5. Hadoop 及数据流入方式

Storm
Storm 作为一个实时计算的一般框架,允许你以 Hadoop 无法实现的方式在数据上运行增量函数。图 1.6 展示了数据是如何进入 Storm 的。
图 1.6. Storm 及其数据流

Storm 属于我们之前讨论的流处理工具类别。它保持了该类别的所有特征,包括低延迟和快速处理。事实上,它的速度不会比这更快。
与 Hadoop 将代码移动到数据不同,Storm 将数据移动到代码。这种行为在流处理系统中更有意义,因为数据集在事先是未知的,这与批量作业不同。此外,数据集是连续通过代码流动的。
此外,Storm 提供了一个定义良好的框架,用于在发生故障时进行有价值的、保证的消息处理。Storm 自带其自己的集群资源管理系统,但雅虎已经进行了非官方的工作,使 Storm 能够在 Hadoop v2 的 YARN 资源管理器上运行,以便资源可以与 Hadoop 集群共享。
Apache Spark
Spark 属于与 Hadoop MapReduce 相同的批量处理工具系列。它也运行在 Hadoop 的 YARN 资源管理器上。Spark 有趣的地方在于它允许在内存中缓存中间(或最终)结果(如有需要,溢出到磁盘)。这种能力对于在相同数据集上重复运行的过程非常有用,并且可以以算法上有意义的方式利用之前的计算。
Spark Streaming
Spark Streaming 与 Storm 一样处理无界的数据流。但它在某种程度上与 Storm 不同,因为 Spark Streaming 不属于我们之前讨论的流处理工具类别;相反,它属于微批处理工具类别。Spark Streaming 建立在 Spark 之上,并且需要将流中的数据流入表示为批次才能运行。在这方面,它与 Storm 的 Trident 框架相当,而不是 Storm 本身。因此,Spark Streaming 无法支持 Storm 的单次处理语义支持的低延迟,但在性能方面应该与 Trident 相当。
Spark 的缓存机制在 Spark Streaming 中也是可用的。如果你需要缓存,你将不得不在你的 Storm 组件内部维护自己的内存缓存(这并不困难,而且相当常见),但 Storm 并没有提供任何内置的支持来做这件事。
Apache Samza
Samza 是来自 LinkedIn 团队的一个年轻的流处理系统,它可以直接与 Storm 进行比较。然而,你会注意到一些差异。虽然 Storm 和 Spark/Spark Streaming 可以在自己的资源管理器下运行,也可以在 YARN 下运行,但 Samza 是专门为在 YARN 系统上运行而构建的。
Samza 有一个简单且易于推理的并行模型;Storm 有一个并行模型,让您可以在更细粒度的水平上微调并行性。在 Samza 中,您工作流程中的每个步骤都是一个独立的实体,您使用 Kafka 连接这些实体。在 Storm 中,所有步骤都通过一个内部系统(通常是 Netty 或 ZeroMQ)连接起来,从而实现更低的延迟。Samza 的优势在于它有一个 Kafka 队列,可以作为检查点,并允许多个独立的消费者访问该队列。
如我们之前所提到的,这不仅仅是在这些各种工具之间进行权衡并选择一个。很可能是您可以使用批处理工具和流处理工具结合使用。实际上,使用以批处理为导向的系统与以流为导向的系统相结合是 Nathan Marz 所著的大数据(Manning, 2015)一书中讨论的主题,而 Nathan Marz 是 Storm 的原始作者。
1.3. 您为什么想使用 Storm
现在我们已经解释了 Storm 在大数据领域中的位置,接下来让我们讨论一下为什么您会想要使用 Storm。正如我们将在整本书中展示的那样,Storm 具有一些基本特性,使其成为一个吸引人的选择:
-
它可以应用于广泛的用例。
-
它与多种技术配合良好。
-
它是可扩展的。Storm 使您能够轻松地将工作分解为一系列线程、一系列 JVM 或一系列机器——所有这些都不需要更改您的代码以进行这种扩展(您只需更改一些配置)。
-
它保证它会至少处理您给出的每一块输入一次。
-
它非常健壮——您甚至可以说它是容错的。在 Storm 内部有四个主要组件,在各个时期,我们不得不关闭其中的任何一个,同时继续处理数据。
-
它对编程语言没有限制。如果您可以在 JVM 上运行它,您就可以轻松地在 Storm 上运行它。即使您不能在 JVM 上运行它,如果您可以从*nix 命令行调用它,您可能可以使用它与 Storm 一起使用(尽管在这本书中,我们将限制自己在 JVM 上,特别是 Java 上)。
我们认为您会同意这听起来很令人印象深刻。Storm 已经成为我们首选的工具包,不仅用于扩展,还用于容错和保证消息处理。我们有许多 Storm 拓扑(执行特定任务的 Storm 代码块),这些拓扑可以轻松地作为 Python 脚本在单台机器上运行。但如果该脚本崩溃,它在可恢复性方面并不比 Storm 强;Storm 将重新启动并从我们的崩溃点继续工作。没有凌晨 3 点的紧急通知,没有早上 9 点的向工程副总裁解释为什么某件事失败的解释。关于 Storm 的伟大之处之一是,您是为了容错而来,但留在了易于扩展的环境中。
带着这些知识,您现在可以继续学习 Storm 的核心概念。对这些概念的良好理解将为我们在这本书中讨论的所有其他内容奠定基础。
1.4. 摘要
在本章中,您了解到
-
Storm 是一个无限运行的流处理工具,它监听数据流并对这些数据流进行某种类型的处理。Storm 可以与许多现有技术集成,使其成为许多流处理需求的可行解决方案。
-
最好通过考虑其四个主要属性来定义大数据:数据量(数据量)、速度(数据流入系统的速度)、种类(不同类型的数据)和真实性(数据的准确性)。
-
处理大数据主要有三种工具类型:批处理、流处理和流内的微批处理。
-
Storm 的一些优点包括其可扩展性、至少处理每条消息一次的能力、其健壮性以及能够用任何编程语言进行开发的能力。
第二章. 核心 Storm 概念
本章涵盖
-
核心 Storm 概念和术语
-
第一个 Storm 项目的基本代码
一旦你理解了 Storm 的核心概念,它们就很简单,但这种理解可能很难获得。第一天遇到“执行器”和“任务”的描述可能很难理解。你需要一次记住太多概念。在这本书中,我们将逐步介绍概念,并尽量减少你需要一次思考的概念数量。这种方法通常意味着解释并不完全“正确”,但在你的旅程的这个阶段,它将足够准确。随着你逐渐拼凑起不同的碎片,我们会指出我们早期的定义可以如何扩展。
2.1. 问题定义:GitHub 提交计数仪表板
让我们从在一个应该熟悉的领域开始工作:GitHub 的源代码控制。大多数开发者都熟悉 GitHub,因为他们在个人项目、工作或与其他开源项目互动时使用过它。
假设我们想要实现一个仪表板,显示针对任何仓库的最活跃开发者的运行计数。这个计数有一些实时要求,即在仓库的任何更改发生后必须立即更新。GitHub 请求的仪表板可能看起来像图 2.1。
图 2.1. 对一个仓库更改计数仪表板的草图

该仪表板相当简单。它包含了一个列表,列出了每个向仓库提交过代码的开发者的电子邮件,以及他们各自提交的总数。在我们深入探讨如何使用 Storm 设计解决方案之前,让我们进一步分解问题,从将要使用的数据的角度来看。
2.1.1. 数据:起始点和结束点
对于我们的场景,我们将说 GitHub 提供了一个任何仓库提交的实时流。每个提交作为一个包含提交 ID、一个空格和提交该提交的开发者电子邮件的单个字符串进入流。以下列表显示了流中的 10 个单独提交的样本。
列表 2.1. GitHub 提交流的示例提交数据
b20ea50 nathan@example.com
064874b andy@example.com
28e4f8e andy@example.com
9a3e07f andy@example.com
cbb9cd1 nathan@example.com
0f663d2 jackson@example.com
0a4b984 nathan@example.com
1915ca4 derek@example.com
这个数据流为我们提供了数据的起点。我们需要从这个实时流到一个显示每个电子邮件地址提交数运行计数的 UI。为了简化,让我们说我们只需要维护一个内存映射,其中电子邮件地址是键,提交数是值。这个映射在代码中可能看起来像这样:
Map<String, Integer> countsByEmail = new HashMap<String, Integer>();
现在我们已经定义了数据,下一步就是定义我们需要采取的步骤,以确保我们的内存映射正确地反映了提交数据。
2.1.2. 问题分解
我们知道我们想要从提交消息的流中转换到内存中的电子邮件/提交计数映射,但我们还没有定义如何实现。在这个阶段,将问题分解为一系列较小的步骤有助于。我们定义这些步骤为接受输入、对输入进行计算并产生输出的组件。这些步骤应该提供一种从起点到终点的方法。我们为这个问题提出了以下组件:
-
一个组件从实时提交流中读取并生成单个提交消息
-
一个组件接受单个提交消息,从中提取开发者的电子邮件,并生成一封电子邮件
-
一个组件接受开发者的电子邮件并更新一个内存映射,其中键是电子邮件,值是该电子邮件的提交次数
在本章中,我们将问题分解为几个组件。在下一章中,我们将更详细地介绍如何将问题映射到 Storm 域。但在我们继续之前,看看图 2.2,它说明了组件、它们接受的输入和产生的输出。
图 2.2. 将提交计数问题分解为一系列步骤,具有定义明确的输入和输出
![02fig02.jpg]
图 2.2 展示了我们将实时提交流转换为存储每个电子邮件提交计数的解决方案。我们有三个组件,每个组件都有单一的目的。现在我们已经对如何解决这个问题有了很好的理解,让我们在 Storm 的背景下构建我们的解决方案。
2.2. Storm 的基本概念
为了帮助您理解 Storm 的核心概念,我们将介绍 Storm 中使用的常见术语。我们将在我们的示例设计中这样做。让我们从 Storm 中最基本的组件开始:拓扑。
2.2.1. 拓扑结构
让我们退一步,从我们的例子中理解什么是拓扑。想象一个简单的线性图,一些节点通过有向边连接。现在想象每个节点代表一个单独的过程或计算,每条边代表一个计算的结果作为输入传递给下一个计算。图 2.3 更清楚地说明了这一点。
图 2.3. 拓扑是一个图,节点代表计算,边代表计算的结果。
![02fig03_alt.jpg]
Storm 拓扑是一个计算图,节点代表一些单个计算,边代表节点之间传递的数据。然后我们向这个计算图输入数据以实现某个目标。这究竟意味着什么呢?让我们回到我们的仪表板示例,看看我们说的是什么。
通过查看问题的模块化分解,我们能够从拓扑的定义中识别出每个组件。图 2.4 说明了这种关联;这里有很多内容需要消化,所以请慢慢来。
图 2.4. 将设计映射到 Storm 拓扑的定义

我们在拓扑定义中提到的每个概念都可以在我们的设计中找到。实际的拓扑由节点和边组成。这个拓扑随后由提交的持续实时流驱动。我们的设计非常适合 Storm 框架。现在你已经了解了什么是拓扑,我们将深入了解构成拓扑的各个组件。
2.2.2. 元组
我们拓扑中的节点以元组的形式相互发送数据。一个元组是有序值列表,其中每个值都被分配了一个名称。一个节点可以创建并(可选地)向图中任意数量的节点发送元组。将元组发送给任意数量的节点进行处理的过程称为发射元组。
需要注意的是,尽管元组中的每个值都有一个名称,但这并不意味着元组是名称-值对的列表。名称-值对的列表意味着背后可能有一个映射,并且名称实际上是元组的一部分。这两个陈述都不正确。元组是有序值列表,Storm 提供了为列表中的值分配名称的机制;我们将在本章后面讨论这些名称是如何分配的。
当我们在本书其余部分中的图中显示元组时,与值关联的名称很重要,因此我们确定了一个包括名称和值的约定(图 2.5)。
图 2.5. 在本书中显示元组的格式

在掌握了显示元组的标准格式后,让我们确定我们的拓扑中的两种元组类型:
-
包含提交 ID 和开发者电子邮件的提交消息
-
开发者电子邮件
我们需要为这些分配一个名称,所以我们现在使用“提交”和“电子邮件”(关于如何在代码中实现这一点的更多细节将在后面介绍)。图 2.6 展示了我们的拓扑中元组的流动情况。
图 2.6. 拓扑中的两种元组:一个用于提交消息,另一个用于电子邮件

元组内值的类型是动态的,不需要声明。但是 Storm 需要知道如何序列化这些值,以便在拓扑中的节点之间发送元组。Storm 已经知道如何序列化原始类型,但对于你定义的任何自定义类型,它将需要自定义序列化器,并在没有自定义序列化器的情况下回退到标准的 Java 序列化。
我们很快就会看到所有这些代码,但现在的重点是理解术语和概念之间的关系。在掌握了元组的概念之后,我们可以继续学习 Storm 的核心抽象:流。
2.2.3. 流
根据 Storm 维基百科,流是“元组的无界序列。”这是对什么是流的很好解释,也许可以补充一点。流是拓扑中两个节点之间元组的无界序列。一个拓扑可以包含任意数量的流。除了拓扑中第一个从数据源读取的节点之外,节点可以接受一个或多个流作为输入。节点随后通常会对输入元组进行一些计算或转换,并发出新的元组,从而创建一个新的输出流。这些输出流然后作为其他节点的输入流,依此类推。
在我们的 GitHub 提交计数拓扑中,有两个流。第一个流从持续读取提交的节点开始。该节点发出一个包含提交的元组到另一个提取电子邮件的节点。第二个流从提取提交中的电子邮件的节点开始。该节点通过发出只包含电子邮件的新流来转换其输入流(包含提交)。结果输出流作为更新内存映射的节点的输入。您可以在图 2.7 中看到这些流。
图 2.7. 识别我们的拓扑中的两个流

我们的 Storm GitHub 场景是一个简单链式流(多个流连接在一起)的例子。
复杂流
流可能并不总是像我们的拓扑中那样简单。以图 2.8 中的例子为例。这张图显示了一个包含四个不同流的拓扑。第一个节点发出一个元组,被两个不同的节点消费;这导致了两个独立的流。然后,每个节点都会向它们自己的新输出流发出元组。
图 2.8. 包含四个流的拓扑

在创建、拆分和再次连接流的数量方面,组合是无限的。本书后面的例子将深入探讨更复杂的流链,以及为什么以这种方式设计拓扑是有益的。现在,我们将继续我们的简单例子,并转向拓扑的流来源。
2.2.4. 泄露
Spout是拓扑中流的来源。Spout 通常从外部数据源读取数据并将元组发射到拓扑中。Spout 可以监听消息队列中的传入消息,监听数据库中的变化,或监听任何其他数据源。在我们的例子中,spout 正在监听 Storm 仓库中提交的实时流(图 2.9)。
图 2.9. Spout 从提交消息的流中读取。

喷发器不执行任何处理;它们只是作为流源,从数据源读取并将元组发射到拓扑中的下一个节点类型:螺栓。
2.2.5. 螺栓
与仅用于监听数据流的目的的喷发器不同,螺栓从其输入流接受一个元组,对该元组执行一些计算或转换——可能是过滤、聚合或连接——然后可选地向其输出流(或多个输出流)发射新的元组(或多个元组)。
我们例子中的螺栓如下:
-
一个从提交中提取开发者电子邮件的螺栓— 这个螺栓从其输入流接受包含提交 ID 和电子邮件的元组。它转换输入流,并向其输出流发射只包含电子邮件地址的新元组。
-
一个更新提交计数电子邮件映射的螺栓— 这个螺栓从其输入流接受包含电子邮件地址的元组。因为这个螺栓更新内存映射并不会发射新的元组,所以它不会产生输出流。
这两个螺栓都在图 2.10 中展示。
图 2.10. 螺栓对提交消息及其中的相关电子邮件进行处理。

我们例子中的螺栓非常简单。随着你在书中继续前进,你会创建执行更复杂转换的螺栓,有时甚至从多个输入流读取并产生多个输出流。不过,我们在这里有点超前了。首先,你需要了解螺栓和喷发器在实际中的工作方式。
在幕后螺栓和喷发器是如何工作的
在图 2.9 和 2.10 中,喷发器和螺栓都被显示为单个组件。从逻辑角度来看这是正确的。但是,当涉及到喷发器和螺栓在实际中的工作方式时,情况就更加复杂。在一个运行拓扑中,通常有大量每种类型的喷发器/螺栓实例并行执行计算。参见图 2.11,其中提取提交中的电子邮件和更新电子邮件计数的螺栓各自在三个不同的实例上运行。注意一个螺栓的单个实例是如何向另一个螺栓的单个实例发射元组的。
图 2.11. 通常有多个特定螺栓的实例向多个另一个螺栓的实例发射元组。

图 2.11 展示了两个螺栓实例之间元组发送的几种可能场景。实际上,情况更像是图 2.12,其中左侧的每个螺栓实例都在向右侧的多个不同螺栓实例发射元组。
图 2.12. 一个螺栓的单独实例可以向任何数量的另一个螺栓的实例发射。

理解喷发器和螺栓实例的分解非常重要,因此让我们暂停一下,总结一下在深入探讨最终概念之前你所知道的内容:
-
一个 topology 由 nodes 和 edges 组成。
-
Nodes 代表 spouts 或 bolts。
-
Edges 代表这些 spout 和 bolt 之间的元组流。
-
一个 tuple 是值的有序列表,其中每个值都被分配了一个名称。
-
一个 stream 是 spout 和 bolt 或两个 bolt 之间的无界元组序列。
-
一个 spout 是拓扑中流的来源,通常监听某种实时数据流。
-
一个 bolt 接受来自 spout 或另一个 bolt 的元组流,通常对这些输入元组执行某种计算或转换。然后,bolt 可以选择性地发射新的元组,这些元组作为拓扑中另一个 bolt 的输入流。
-
每个 spout 和 bolt 都将有一个或多个单独的实例,这些实例并行执行所有这些处理。
这是一大堆内容,所以在继续之前请确保你已经消化了这些内容。准备好了吗?很好。在我们进入实际代码之前,让我们再解决一个重要概念:流分组。
2.2.6. Stream grouping
到现在为止,你知道流是一个无界的元组序列,在 spout 和 bolt 或两个 bolt 之间。流分组定义了元组如何在那些 spout 和 bolt 的实例之间发送。我们这是什么意思?让我们退一步,看看我们的提交计数拓扑。在我们的 GitHub 提交计数拓扑中,我们有两个流。这些流中的每一个都将有自己的流分组定义,告诉 Storm 如何在 spout 和 bolt 的实例之间发送单个元组(图 2.13)。
图 2.13. 拓扑中的每个流都将有自己的流分组。

Storm 默认提供几种流分组。我们将在本书中介绍这些分组中的大多数,从本章中两种最常见的分组开始:shuffle grouping 和 fields grouping。
Shuffle grouping
我们 spout 和第一个 bolt 之间的流使用 shuffle grouping。一个 shuffle grouping 是一种流分组,其中元组随机发射到 bolt 实例,如图 2.14 所示。
图 2.14. 在我们的 spout 和第一个 bolt 之间使用 shuffle grouping

在这个例子中,我们不在乎元组是如何传递到我们 bolt 实例的,所以我们选择 shuffle grouping 来随机分配元组。使用 shuffle grouping 可以保证每个 bolt 实例应该接收相对相等数量的元组,从而在所有 bolt 实例之间分配负载。shuffle grouping 分配是随机进行的,而不是轮询,因此不能保证分配的精确平等。
这种分组在许多基本情况下都很有用,在这些情况下您没有关于如何将数据传递给 bolt 的特殊要求。但有时您会遇到一些场景,在这些场景中,根据您的需求,将元组发送到随机 bolt 实例将不起作用——就像我们在螺栓提取电子邮件和更新电子邮件之间发送元组的场景一样。我们需要不同类型的流分组来完成这项工作。
字段分组
提取电子邮件的 bolt 和更新电子邮件的 bolt 之间的流需要使用字段分组。字段分组确保具有特定字段名相同值的元组始终被发射到 bolt 的同一实例。为了理解为什么字段分组对我们第二个流是必要的,让我们看看使用内存映射来跟踪每个电子邮件提交数量的后果。
每个 bolt 实例都将有自己的电子邮件/提交计数对映射,因此确保相同的电子邮件发送到同一 bolt 实例对于确保所有 bolt 实例中每个电子邮件的计数准确是必要的。字段分组正好提供了这一点(图 2.15)。
图 2.15. 使用字段分组来处理将具有单独内存映射的每个螺栓实例的螺栓。

在这个例子中,使用内存映射来实现电子邮件计数决策导致了需要字段分组。我们本可以使用跨 bolt 实例共享的资源来消除这一需求。我们将在第三章及以后探讨类似的设计和实现考虑因素,但就目前而言,让我们将重点转移到我们需要使拓扑运行起来的代码上。
2.3. 在 Storm 中实现 GitHub 提交计数仪表板
现在我们已经涵盖了 Storm 中的所有重要概念,是时候开始编写我们拓扑的代码了。本节将从单个 spout 和 bolt 的代码开始,并介绍相关的 Storm 接口和类。其中一些接口和类您将直接使用,而另一些则不会;无论如何,理解 Storm API 的整体层次结构将使您对拓扑及其相关代码有更全面的理解。
在我们介绍了 spout 和 bolt 的代码之后,我们将讨论将所有这些放在一起所需的代码。如果您还记得我们之前的讨论,我们的拓扑包含流和流分组。spout 和 bolt 的代码只是其中一部分——您仍然需要定义元组在拓扑组件之间发射的位置和方式。在讨论构建拓扑所需的代码时,您将遇到一些 Storm 的配置选项,其中大部分将在本书的后面部分进行更详细的介绍。
最后,在我们通过在拓扑中定义流和流分组连接好一切之后,我们将向您展示如何在本地运行您的拓扑,让您可以测试一切是否按预期工作。但在我们深入所有这些代码之前,让我们为您设置一个基本的 Storm 项目。
2.3.1. 设置 Storm 项目
将 Storm JAR 文件添加到您的类路径以进行开发的最简单方法是使用 Apache Maven。
注意
您可以在storm.apache.org/documentation/Creating-a-new-Storm-project.html找到设置 Storm 的其他方法,但 Maven 无疑是其中最简单的。有关 Maven 的信息,请访问maven.apache.org/。
将下一列表中的代码添加到您的项目 pom.xml 文件中。
列表 2.2. pom.xml

一旦您将这些添加到 pom.xml 文件中,您应该拥有编写代码和在本地的开发机器上运行 Storm 拓扑所必需的所有依赖项。
2.3.2. 实现喷口
因为拓扑中的数据入口是喷口(spout),所以我们将从这里开始编码。在深入细节之前,让我们先检查 Storm 中喷口的一般接口和类结构。图 2.16 解释了这个类层次结构。
图 2.16. Storm 的喷口类层次结构

在这个设计中,喷口通过 GitHub API 监听对特定 GitHub 项目的提交实时流,并发出包含整个提交信息的元组,如图图 2.17 所示。
图 2.17. 喷口监听提交信息流并为每个提交信息发出一个元组。

设置一个喷口以监听实时流需要一些工作,我们认为这会分散对基本代码的理解。因此,我们将采取作弊的方式,通过让我们的喷口持续读取一个包含提交信息的文件来模拟实时流,并为文件中的每一行发出一个元组。不用担心;在后面的章节中,我们将连接喷口到实时流,但现在我们的重点是基础知识。changelog.txt 文件将位于我们的喷口类旁边,并包含预期格式的提交信息列表(如下所示)。
列表 2.3. 我们简单数据源的摘录:changelog.txt
b20ea50 nathan@example.com
064874b andy@example.com
28e4f8e andy@example.com
9a3e07f andy@example.com
cbb9cd1 nathan@example.com
0f663d2 jackson@example.com
0a4b984 nathan@example.com
1915ca4 derek@example.com
一旦我们定义了数据源,我们就可以转向喷口的实现,如下一列表所示。
列表 2.4. CommitFeedListener.java


我们的 spout 中正在进行很多事情。我们首先通过扩展BaseRichSpout,这给了我们三个需要重写的方法。这些方法中的第一个是declareOutputFields。记得在章节开头我们提到过我们会讨论 Storm 如何为元组分配名称吗?嗯,这里就是。declareOutputFields方法是我们定义这个 spout 发射的元组中值的名称的地方。为发射的元组值定义名称是通过Fields类完成的,其构造函数接受多个字符串;每个字符串是发射元组中值的名称。Fields构造函数中名称的顺序必须与通过Values类在元组中发射的值的顺序相匹配。因为我们的 spout 发射的元组包含单个值,所以我们向Fields传递了一个单个参数,commit。
下一个我们需要重写的方法是open;这是我们将 changelog.txt 的内容读入我们的字符串列表中的地方。如果我们为处理实时数据源(如消息队列)的 spout 编写代码,这就是我们放置连接到该数据源代码的地方。你将在第三章开始时看到更多关于这个的内容。第三章。
我们需要重写的最后一个方法是nextTuple。这是 Storm 在准备好让 spout 读取和发射一个新的元组时调用的方法,通常根据 Storm 的周期性调用。在我们的例子中,每次调用nextTuple时,我们都会为列表中的每个值发射一个新的元组,但对于从实时数据源读取的东西,例如消息队列,只有当有新的数据可用时,才会发射新的元组。
你还会注意到一个名为SpoutOutputCollector的类。输出收集器是你在 spout 和 bolt 中都会经常看到的东西。它们负责发射和失败元组。
现在我们知道了我们的 spout 如何从数据源获取提交消息并为每个提交消息发射新的元组,我们需要实现将这些提交消息转换成电子邮件到提交计数映射的代码。
2.3.3. 实现 bolt
我们已经实现了作为流源头的 spout,现在该转向 bolt 了。图 2.18 解释了 Storm 中 bolt 的一般接口和类结构。
图 2.18. Bolt 的 Storm 类层次结构

你会在 图 2.18 中注意到,bolt 的类层次结构比 spout 的稍微复杂一些。原因是 Storm 为那些具有极其简单实现(IBasicBolt/BaseBasicBolt)的 bolt 提供了额外的类。这些类接管了通常由 IRichBolt 可用的责任,因此使得 bolt 的简单实现更加简洁。IBasicBolt 的简单性是以牺牲通过 IRichBolt 可用的丰富功能集的流畅性为代价的。我们将在 第四章 中详细说明 BaseRichBolt 和 Base-BasicBolt 之间的区别,并解释何时使用任一。在本章中,我们将使用 BaseBasicBolt,因为 bolt 的实现相当直接。
要回顾我们的设计,请记住,在我们的拓扑中有两个 bolt(见图 2.19)。一个 bolt 接受包含完整提交消息的元组,从中提取电子邮件,并发射包含电子邮件的元组。第二个 bolt 维护一个电子邮件到提交计数的内存映射。
图 2.19. 我们拓扑中的两个 bolt:第一个 bolt 从提交消息中提取电子邮件,第二个 bolt 维护一个电子邮件到提交计数的内存映射。

让我们看看这些 bolt 的代码,从下一列表中的 EmailExtractor.java 开始。
列表 2.5. EmailExtractor.java

EmailExtractor.java 的实现相当小,这也是我们决定扩展 BaseBasicBolt 的主要原因。如果你稍微深入一点查看代码,你会注意到它与我们的 spout 代码有一些相似之处,即我们声明这个 bolt 发射的元组中值的名称的方式。在这里,我们定义了一个名为 email 的单个字段。
就 bolt 的 execute 方法而言,我们只是在空白处拆分字符串以获取电子邮件,并发射一个包含该电子邮件的新元组。还记得我们之前在 spout 实现中提到的输出收集器吗?这里我们也有类似的东西,即 BasicOutputCollector,它发射这个元组,将其发送到拓扑中的下一个 bolt,即电子邮件计数器。
电子邮件计数器中的代码结构与 EmailExtractor.java 类似,但设置和实现稍微多一点,如下一列表所示。
列表 2.6. EmailCounter.java


再次,我们决定扩展 BaseBasicBolt。尽管 EmailCounter.java 比 EmailExtractor.java 更复杂,但我们仍然可以通过 BaseBasicBolt 获取的功能来实现。你会注意到的一个区别是我们重写了 prepare 方法。这个方法在 Storm 准备 bolt 执行之前被调用,是我们为 bolt 执行任何设置的方法。在我们的情况下,这意味着实例化内存映射。
谈到内存映射,你会发现这是一个针对单个 bolt 实例的私有成员变量。这应该让你想起我们在 2.2.6 节中提到的事情,这也是我们被迫在两个 bolt 之间的流中使用 fields 分组的理由。
所以,我们现在有了我们的 spout 和两个 bolt 的代码。接下来是什么?我们需要以某种方式告诉 Storm 流的位置,并为每个流标识流分组。我们想象你很渴望运行这个拓扑并看到它的实际效果。这就是将所有组件连接起来发挥作用的地方。
2.3.4. 将所有组件连接起来形成拓扑结构
我们 spout 和 bolt 的实现本身并没有什么用处。我们需要构建拓扑,定义 spout 和 bolt 之间的流和流分组。之后,我们希望能够运行一个测试来确保一切按预期工作。Storm 提供了你完成这个任务所需的所有类。这些类包括以下内容:
-
TopologyBuilder— 这个类用于拼接 spout 和 bolt,定义它们之间的流和流分组。 -
Config— 这个类用于定义拓扑级别的配置。 -
StormTopology— 这个类是TopologyBuilder构建的,也是提交给集群运行的内容。 -
LocalCluster— 这个类在本地机器上模拟 Storm 集群,允许我们轻松地运行拓扑进行测试。
在对这些类有了基本理解之后,我们将构建拓扑并将其提交给本地集群,如下一列表所示。
列表 2.7. LocalTopologyRunner.java


你可以将主方法想象成分为三个部分。第一部分是我们构建拓扑并告诉 Storm 流的位置,以及为这些流标识流分组。下一部分是创建配置。在我们的例子中,我们已经打开了调试日志。本书后面还会介绍更多配置选项。最后一部分是将配置和构建的拓扑提交给本地集群以运行。在这里,我们运行本地集群 10 分钟,持续发出我们的 changelog.txt 文件中每个提交消息的 tuple。这应该在拓扑中提供足够的活动。
如果我们通过java -jar运行 LocalTopologyRunner.java 的主方法,我们会在控制台看到调试日志消息飞快地闪过,显示我们的 spout 发出的 tuple 和我们的 bolt 处理的 tuple。就这样;你已经构建了你的第一个拓扑!在掌握了基础知识之后,我们仍需要解决本章中提到的某些问题。我们将从解决一些好的拓扑设计实践开始,这些实践将在第三章中介绍。
2.4. 概述
在本章中,你学习了以下内容
-
拓扑是一个图,其中节点代表单个进程或计算,边代表一个计算的结果作为另一个计算的输入。
-
元组是有序值列表,其中列表中的每个值都分配了一个名称。元组代表两个组件之间传递的数据。
-
两个组件之间元组的流动称为流。
-
发射器充当流的来源;它们唯一的目的就是从源读取数据并将其元组发出到输出流。
-
螺栓是拓扑中核心逻辑所在之处,执行如过滤器、聚合、连接和与数据库通信等操作。
-
集合器和螺栓(称为组件)作为单个或多个实例执行,向其他螺栓实例发出元组。
-
元组在组件的各个实例之间流动的方式由流分组定义。
-
为您的发射器和螺栓实现代码只是其中一部分;您还需要将它们连接起来并定义流和流分组。
-
在本地模式下运行拓扑是测试您的拓扑是否正常工作的最快方式。
第三章. 拓扑设计
本章涵盖
-
将问题分解以适应 Storm 结构
-
与不可靠的数据源一起工作
-
集成外部服务和数据存储
-
理解 Storm 拓扑中的并行性
-
遵循拓扑设计的最佳实践
在上一章中,我们通过构建一个简单的拓扑来计算提交到 GitHub 项目的提交次数,从而开始了我们的实践。我们将它分解成了 Storm 的两个主要组件——spouts 和 bolts,但我们没有关注为什么这样做。本章通过向你展示如何使用 Storm 来思考和设计解决方案,扩展了这些基本的 Storm 概念。你将学习到帮助你设计出良好设计的策略:一个表示当前问题工作流程的模型。
此外,了解可伸缩性(或工作单元的并行化)是如何内置到 Storm 中的也很重要,因为它会影响你在拓扑设计中所采取的方法。我们还将探讨提高拓扑速度的策略。
在阅读本章后,你不仅能够轻松地分解问题并看到它如何在 Storm 中适用,而且你还能确定 Storm 是否是解决该问题的正确解决方案。本章将为你提供对拓扑设计的坚实基础,以便你能够设想大数据问题的解决方案。
让我们从探索如何接近拓扑设计开始,然后根据我们概述的步骤分解一个现实场景。
3.1. 接近拓扑设计
拓扑设计的步骤可以分解为以下五个步骤:
-
定义问题/形成概念性解决方案—**这一步旨在对正在解决的问题有一个清晰的理解。它还作为一个地方来记录对任何潜在解决方案(包括与速度有关的要求,这是大数据问题中的常见标准)的要求。这一步涉及建模一个解决方案(不是实现),该解决方案解决了问题的核心需求。
-
将解决方案映射到 Storm—**在这一步中,你遵循一系列原则,将提出的解决方案分解成一种方式,以便你能够设想它如何映射到 Storm 原语(即 Storm 概念)。在这一阶段,你将为你自己的拓扑设计出一个方案。这个方案将在接下来的步骤中根据需要进行调整和优化。
-
实现初始解决方案—**在这个阶段,每个组件都将被实现。
-
扩展拓扑—**在这一步中,你将调整 Storm 为你提供的旋钮,以便以规模运行此拓扑。
-
根据观察调整—**最后,你将根据运行时的观察行为调整拓扑。这一步可能涉及为了实现规模而进行的额外调整,以及可能为了效率而需要的设计变更。
让我们将这些步骤应用到现实世界的问题中,以展示如何完成每个步骤。我们将使用社会热图来完成,它包含与拓扑设计相关的几个具有挑战性的主题。
3.2. 问题定义:社会热图
想象一下这个场景:现在是周六晚上,你和朋友们在酒吧里喝酒,享受美好的生活。你喝完了第三杯,开始觉得需要换换环境。也许去一个不同的酒吧?选择太多了——你甚至不知道怎么选择?作为一个社交达人,当然你希望最终能去最受欢迎的酒吧。你不想去你所在地区杂志上被评为最佳的地方。那已经是上周的事情了。你想要的是现在正在发生的事情,而不是上周,甚至不是上一个小时。你是潮流的引领者。你有责任让你的朋友们玩得开心。
好吧,也许那不是你。但这代表的是平均社会网络用户吗?现在我们能做些什么来帮助这个人呢?如果我们能以图形形式展示这个人正在寻找的答案,那就太理想了——一张能够快速传达酒吧活动密度最高的地区的热点地图。热图可以识别像纽约或旧金山这样的大城市中的普通地区,通常在挑选热门酒吧时,最好有几个彼此靠近的选择,以防万一。
热图的其他案例研究
哪些问题通过使用热图进行可视化会受益?一个好的候选者应该允许你使用热图的强度来模拟一组数据点相对于区域内(地理或其他)其他点的相对重要性:
-
加利福尼亚野火蔓延、东海岸即将来临的飓风或疾病的爆发可以通过热图进行模拟和表示,以警告居民。
-
在选举日,你可能想知道
-
哪个政治选区有最多的选民投票?你可以通过模拟投票数来反映强度,在热图上描绘出来。
-
你可以通过将政党、候选人或问题模拟为不同的颜色来描绘哪个政党/候选人/问题获得了最多的选票,颜色的强度反映了选票的数量。
-
我们已经提供了一个一般的问题定义。在继续前进之前,让我们形成一个概念性的解决方案。
3.2.1. 概念性解决方案的形成
我们应该从哪里开始?多个社交网络都包含了签到概念。假设我们能够访问一个收集所有这些网络酒吧签到的数据喷泉。这个喷泉将为每个签到发射一个酒吧的地址。这为我们提供了一个起点,但也要有一个目标在心中。假设我们的目标是带有热图覆盖的地理地图,以识别最受欢迎的酒吧所在的社区。图 3.1 展示了我们提出的解决方案,我们将从不同场所转换多个签到信息以在热图中显示。
图 3.1. 使用签到信息构建酒吧热图

我们需要在 Storm 中建模的解决方案,变成了将签到信息(或聚合)转换成可以绘制在热图上的数据集的方法。
3.3. 将解决方案映射到 Storm 的准则
最佳的起点是思考通过这个系统流动的数据的本质。当我们更好地理解数据流中包含的奇特之处时,我们可以更适应这个系统可能面临的实际要求。
3.3.1. 考虑数据流强加的要求
我们有一个喷泉式地发射酒吧地址的检查流。但这个签到流并不能可靠地代表每个去过酒吧的用户。签到并不等同于在某个地点的物理存在。更好的想法是将其视为现实生活的样本,因为并非每个用户都会签到。但这让我们质疑签到数据是否真的有助于解决这个问题。在这个例子中,我们可以安全地假设酒吧的签到与那些地点的人数成比例。
因此,我们知道以下内容:
-
签到是现实场景的样本,但并不完整。
-
它们是成比例的代表性。
注意
让我们假设数据量足够大,可以弥补数据丢失,并且任何数据丢失都是间歇性的,不足以造成服务中断的明显影响。这些假设帮助我们描绘了一个与不可靠数据源合作的情况。
我们对我们数据流的第一个洞察:一个成比例的代表性但可能不完整的签到流。接下来是什么?我们知道我们的用户希望尽快收到关于活动最新趋势的通知。换句话说,我们有一个严格的速度要求:尽可能快地将结果呈现给用户,因为数据的价值会随时间而降低。
从对数据流的考虑中可以看出,我们不必过于担心数据丢失。我们可以得出这个结论,因为我们知道我们的输入数据集是不完整的,所以不需要达到某些任意、微小的精度程度的准确性。但它是成比例的代表性,这对于确定流行度来说已经足够了。结合速度的要求,我们知道只要我们能快速将最近的数据提供给用户,他们就会满意。即使发生数据丢失,过去的结果也会很快被替换。
这种场景直接映射到在 Storm 中处理不可靠数据源的概念。在不可靠的数据源中,你无法重试失败的操作;数据源可能没有重放数据点的能力。在我们的案例中,我们通过签到方式抽样现实生活,这模拟了不完整数据集的可用性。
相反,可能存在你与可靠数据源一起工作的情形——这种数据源有能力重放失败的数据点。但也许准确性不如速度重要,你可能不想利用可靠数据源的重放能力。那么近似值可能同样可以接受,而你通过选择忽略它提供的任何可靠性措施,将可靠数据源当作不可靠数据源来处理。
备注
我们将在第四章中介绍可靠数据源以及容错性。
定义了数据源之后,下一步是确定单个数据点如何通过我们提出的解决方案流动。我们将在下一节探讨这个话题。
3.3.2. 将数据点表示为元组
我们下一步是确定通过这个流流动的各个数据点。通过考虑开始和结束是很容易完成这个任务的。我们从一个由活跃酒吧的街道地址组成的一系列数据点开始。我们还需要知道签到发生的时间。因此,我们的输入数据点可以表示如下:
[time="9:00:07 PM", address="287 Hudson St New York NY 10013"]
这是签到发生的时间和地址。这将是我们由 spout 发出的输入元组。如您从第二章中回忆的那样,元组是 Storm 表示数据点的原始数据结构,而spout是元组流的来源。
我们的目标是构建一个显示酒吧最新活动的热图。因此,我们需要最终得到表示地图上及时坐标的数据点。我们可以将一个时间间隔(例如,如果我们想要 15 秒的增量,可以说从晚上 9:00:00 到 9:00:15)附加到该间隔内发生的一组坐标上。然后在热图显示的点,我们可以选择最新的可用时间间隔。地图上的坐标可以通过纬度和经度来表示(例如,纽约,纽约的纬度为 40.7142° N,经度为 74.0064° W)。将 40.7142° N,74.0064° W 表示为(40.7142,-74.0064)是标准形式。但是,可能会有多个坐标表示在时间窗口内的多个签到。因此,我们需要一个时间间隔的坐标列表。然后我们的最终数据点开始看起来像这样:
[time-interval="9:00:00 PM to 9:00:15 PM",
hotzones=List((40.719908,-73.987277),(40.72612,-74.001396))]
这是一个包含时间间隔和两个不同酒吧对应签到的最终数据点。
如果在那个时间间隔内同一酒吧有两次或更多签到怎么办?那么那个坐标将被重复。我们如何处理这种情况?一个选择是记录该坐标在该时间窗口内的出现次数。这涉及到根据一些任意但有用的精度确定坐标的相同性。为了避免所有这些,让我们在多个签到的时间间隔内保留任何坐标的副本。通过将相同坐标的多个倍数添加到热图中,我们可以让地图生成器利用多个出现次数作为热度级别(而不是使用出现次数来达到这个目的)。
我们的目标数据点将看起来像这样:
[time-interval="9:00:00 PM to 9:00:15 PM",
hotzones=List((40.719908,-73.987277),
(40.72612,-74.001396),
(40.719908,-73.987277))]
注意,第一个坐标是重复的。这是我们最终元组,将以热图的形式提供。按时间间隔分组坐标列表有以下优点:
-
允许我们通过使用 Google Maps API 轻松构建热图。我们可以通过在常规 Google 地图上添加热图覆盖来实现这一点。
-
让我们回到过去,查看任何特定时间间隔的热图。
拥有输入数据点和最终数据点只是问题的一部分;我们仍然需要确定如何从 A 点到 B 点。
3.3.3. 确定拓扑结构的步骤
我们设计 Storm 拓扑的方法可以分为三个步骤:
-
确定输入数据点以及它们如何表示为元组。
-
确定解决问题所需的最终数据点以及它们如何表示为元组。
-
通过创建一系列操作来连接输入元组和最终元组,以填补它们之间的差距。
我们已经知道我们的输入和期望的输出:
输入元组:
[time="9:00:07 PM", address="287 Hudson St New York NY 10013"]
最终元组:
[time-interval="9:00:00 PM to 9:00:15 PM",
hotzones=List((40.719908,-73.987277),
(40.72612,-74.001396),
(40.719908,-73.987277))]
在某个过程中,我们需要将酒吧地址转换为这些最终元组。图 3.2 展示了我们如何将这些操作分解成一系列。
图 3.2. 通过一系列操作将输入元组转换为最终元组

让我们看看这些步骤是如何映射到 Storm 原语(我们使用Storm 原语和Storm 概念这两个术语互换)的。
作为 spout 和 bolt 的操作
我们创建了一系列操作,将输入元组转换为最终元组。让我们看看这四个操作是如何映射到 Storm 原语的:
-
Checkins— 这将是输入元组进入拓扑的来源,因此在 Storm 的概念中,这将是我们的 spout。在这种情况下,因为我们使用的是一个不可靠的数据源,我们将构建一个没有重试失败能力的 spout。我们将在第四章中讨论重试失败。 -
GeocodeLookup— 这将接收我们的输入元组,并通过查询 Google Maps Geocoding API 将街道地址转换为地理坐标。这是拓扑中的第一个 bolt。 -
HeatMapBuilder— 这是拓扑中的第二个 bolt,它将在内存中保持一个数据结构,将每个传入的元组映射到一个时间间隔,从而按时间间隔分组签到。当每个时间间隔完全过去后,它将发出与该时间间隔相关的坐标列表。 -
Persistor— 我们将在我们的拓扑中使用这个第三个也是最后一个 bolt 来将我们的最终元组保存到数据库中。
图 3.3 提供了设计映射到 Storm 概念的说明。
图 3.3. 热图设计映射到 Storm 概念
![03fig03_alt.jpg]
到目前为止,我们已经讨论了元组、spout 和 bolt。在图 3.3 中,有一件事我们没有讨论,那就是每个流的流分组。当我们下一节讨论拓扑的代码时,我们将更详细地介绍每个分组。
3.4. 设计的初始实现
设计完成后,我们准备着手实现每个组件的实现。就像我们在第二章中做的那样,我们将从 spout 和 bolt 的代码开始,并以将它们全部连接起来的代码结束。稍后,我们将调整这些实现以提高效率或解决它们的一些不足。
3.4.1. Spout:从源读取数据
在我们的设计中,spout 监听社交签到的大流量,并为每个单独的签到发出一个元组。图 3.4 提供了我们在拓扑设计中的位置提醒。
图 3.4. spout 监听社交签到的大流量,并为每个签到发出一个元组。
![03fig04.jpg]
为了本章的目的,我们将使用一个文本文件作为签到数据源。为了将这个数据集输入到我们的 Storm 拓扑中,我们需要编写一个从该文件读取并为每行发出一个元组的 spout。文件 checkins.txt 将位于我们的 spout 类旁边,并包含按预期格式列出的签到列表(见以下列表)。
列表 3.1. 我们简单数据源 checkins.txt 的摘录
1382904793783, 287 Hudson St New York NY 10013
1382904793784, 155 Varick St New York NY 10013
1382904793785, 222 W Houston St New York NY 10013
1382904793786, 5 Spring St New York NY 10013
1382904793787, 148 West 4th St New York NY 10013
下一个列表显示了读取此检查文件泄漏的泄漏实现。由于我们的输入元组是时间和地址,我们将时间表示为 Long(毫秒级 Unix 时间戳),将地址表示为 String,在文本文件中以逗号分隔这两个。
列表 3.2. Checkins.java


由于我们将此视为不可靠的数据源,因此泄漏保持简单;它不需要跟踪哪些元组失败以及哪些元组成功,以便提供容错性。这不仅简化了泄漏实现,还减少了 Storm 需要内部进行的记录工作,从而加快了速度。当不需要容错性并且我们可以定义一个服务级别协议(SLA),允许我们随意丢弃数据时,不可靠的数据源可以是有益的。它更容易维护,并且提供了更少的故障点。
3.4.2. 螺栓:连接到外部服务
拓扑中的第一个螺栓将接收由 Checkins 泄露的元组中的地址数据点,并通过查询谷歌地图地理编码服务将该地址转换为坐标。图 3.5 突出了我们目前正在实现的螺栓。
图 3.5. 地理编码查找螺栓接受社交检查并检索与该检查相关的坐标。

这个螺栓的代码可以在列表 3.3 中看到。我们使用来自code.google.com/p/geocoder-java/的谷歌地理编码 Java API 来检索坐标。
列表 3.3. GeocodeLookup.java


我们有意使与谷歌地理编码 API 的交互保持简单。在实际实现中,我们应该处理地址可能无效的错误情况。此外,谷歌地理编码 API 在这种方式下使用时施加配额,这个配额相当小,对于大数据应用来说不实用。对于这样一个大数据应用,如果你想使用它们作为地理编码的提供者,你需要从谷歌获得一个具有更高配额的访问级别。其他可以考虑的方法包括在数据中心本地缓存地理编码结果,以避免对谷歌 API 进行不必要的调用。
现在我们有了每个检查的时间地理坐标。我们取我们的输入元组
[time="9:00:07 PM", address="287 Hudson St New York NY 10013"]
并将其转换成这样:
[time="9:00 PM", geocode="40.72612,-74.001396"]
这个新的元组将被发送到通过时间间隔维护检查组群的螺栓,我们现在将探讨这一点。
3.4.3. 螺栓:内存中收集数据
接下来,我们将构建表示热图的数据结构。图 3.6 展示了我们在设计中的位置。
图 3.6. 热图构建螺栓接受包含时间和地理编码的元组,并发出包含时间间隔和地理编码列表的元组。

这里适合哪种数据结构?我们从这个 bolt 的前一个 GeocodeLookup bolt 接收元组,形式为 [time="9:00 PM", geocode= "40.72612,-74.001396"]。我们需要按时间间隔对这些进行分组——让我们假设是 15 秒的间隔,因为我们想每 15 秒显示一个新的热图。我们的最终元组需要以 [time-interval="9:00:00 PM to 9:00:15 PM", hotzones= List((40.719908,-73.987277),(40.72612,-74.001396),(40.719908,-73.987277))] 的形式存在。
要按时间间隔分组地理坐标,我们可以在内存中维护一个数据结构,并将传入的元组收集到该数据结构中,这些数据结构由时间间隔隔离。我们可以将其建模为一个映射:
Map<Long, List<LatLng>> heatmaps;
此映射的键是我们间隔开始的时间。我们可以省略时间间隔的结束,因为每个间隔长度相同。值将是落入该时间间隔的坐标列表(包括重复项——重复项或更接近的坐标将表示热图上的热点区域或强度)。
让我们分三步开始构建热图:
-
将传入的元组收集到内存映射中。
-
配置此 bolt 接收给定频率的信号。
-
将经过的时间间隔的聚合热图发射到
Persistorbolt 以保存到数据库。
让我们逐个查看每个步骤,然后我们可以将所有内容组合起来,从下一个列表开始。
列表 3.4. HeatMapBuilder.java: 第 1 步,将传入的元组收集到内存映射中

进入的元组所属的绝对时间间隔是通过将签到时间除以间隔长度来选择的——在本例中为 15 秒。例如,如果签到时间是下午 9:00:07.535,那么它应该落在下午 9:00:00.000–9:00:15.000 的时间间隔内。我们在这里提取的是该时间间隔的开始,即下午 9:00:00.000。
现在我们正在收集所有元组到一个热图中,我们需要定期检查它,并从完成的时间间隔中发射坐标,以便它们可以通过下一个 bolt 持久化存储。
检查元组
有时你需要定期触发一个动作,比如聚合一批数据或将一些写入操作刷新到数据库中。Storm 有一个名为 tick tuples 的功能来处理这种情况。Tick tuples 可以配置为以用户定义的频率接收,当配置后,bolt 上的 execute 方法将以给定频率接收 tick tuple。你需要检查元组以确定它是否是这些系统发出的 tick tuples 之一,或者它是否是一个普通元组。拓扑中的普通元组将通过默认流流动,而 tick tuples 则通过系统 tick 流流动,这使得它们很容易被识别。以下列表显示了在 HeatMapBuilder bolt 中配置和处理 tick tuples 的代码。
列表 3.5. HeatMapBuilder.java: 第 2 步,配置接收给定频率的信号

查看代码在列表 3.5,你会注意到 tick 元组是在 bolt 级别配置的,如getComponentConfiguration实现所示。相关的 tick 元组将只发送到这个 bolt 的实例。
tick 元组的发射频率
我们配置了 tick 元组以每 60 秒的频率发射。这并不意味着它们会精确地每 60 秒发射一次;这是尽力而为的方式。发送到 bolt 的 tick 元组将排队在等待被该 bolt 的execute()方法消费的其他元组后面。如果 bolt 因为处理常规元组流的延迟而落后,它可能不会以发射频率处理 tick 元组。
现在让我们使用这个 tick 元组作为信号来选择已经过去的时间段,我们不再期望有新的坐标进入,并从这个 bolt 中发射它们,以便下一个 bolt 可以接收(参见下一列表)。
列表 3.6. HeatMapBuilder.java:步骤 3,发射经过时间间隔的聚合 HeatMap


步骤 1、2 和 3 提供了一个完整的HeatMapBuilder实现,展示了如何使用内存中的映射来维护状态,以及如何使用 Storm 的内置 tick 元组在特定时间间隔发射元组。随着这个实现的完成,让我们继续持久化由HeatMapBuilder发射的元组的结果。
线程安全
我们正在将坐标收集到一个内存映射中,但我们创建它作为一个常规HashMap的实例。Storm 非常可扩展,有多个元组进入并添加到这个映射中,我们也会定期从这个映射中删除条目。像这样修改内存数据结构是否是线程安全的?
是的,它是线程安全的,因为execute()一次只处理一个元组。无论是我们的常规元组流还是 tick 元组,只有一个 JVM 执行线程会通过并处理这个 bolt 实例中的代码。所以在一个特定的 bolt 实例中,永远不会有多线程通过它。
这是否意味着你永远不会在 bolt 的范围内担心线程安全?不,在某些情况下你可能需要。
有一种情况与元组在发送到 bolt 之间在不同线程上序列化时的值有关。例如,当你不复制内存中的数据结构而直接发射它,并且它在不同线程上序列化时,如果在序列化过程中改变了该数据结构,你会得到一个Concurrent-ModificationException。理论上,所有发射到OutputCollector的东西都应该防范此类场景。一种方法是将发射的任何值都设置为不可变的。
另一个案例是,您可能可以使用 bolt 的execute()方法创建自己的线程。例如,如果您不是使用 tick tuples,而是启动了一个定期发出热图的背景线程,那么您需要关注线程安全性,因为您将有自己的线程和 Storm 的执行线程都通过您的 bolt 运行。
3.4.4. Bolt:持久化到数据存储
我们有表示热图的最终元组。在这个阶段,我们准备将数据持久化到某个数据存储中。我们的基于 JavaScript 的 Web 应用程序可以从这个数据存储中读取热图值,并与 Google Maps API 交互,从这些计算值中构建地理可视化。图 3.7 说明了我们设计中的最终 bolt。
图 3.7. Persistor bolt 接受一个包含时间间隔和地理编码列表的元组,并将这些数据持久化到数据存储中。

由于我们根据时间间隔存储和访问热图,因此使用键值数据模型进行存储是有意义的。在本案例研究中,我们将使用 Redis,但任何支持键值模型的数据存储都足够(例如 Membase、Memcached 或 Riak)。我们将使用时间间隔作为键,将热图本身作为坐标列表的 JSON 表示来存储热图。我们将使用 Jedis 作为 Redis 的 Java 客户端,并使用 Jackson JSON 库将热图转换为 JSON。
NoSQL 和其他与 Storm 一起使用的数据存储
检查可用于处理大数据集的各种 NoSQL 和数据存储解决方案超出了本书的范围,但请确保在选择数据存储解决方案时从正确的起点开始。
人们通常会考虑他们可用的各种选项,并问自己,“我应该选择这些 NoSQL 解决方案中的哪一个?”这是错误的方法。相反,你应该问自己关于你正在实施的功能以及它们对任何数据存储解决方案提出的要求的问题。
你应该问自己,你的用例是否需要一个支持以下功能的数据存储:
-
随机读取或随机写入
-
顺序读取或顺序写入
-
高读取吞吐量或高写入吞吐量
-
数据一旦写入是否改变或保持不变
-
适合您数据访问模式的存储模型
-
列/列族导向
-
键值
-
文档导向
-
模式/无模式
-
-
是否更希望一致性或可用性
一旦你确定了你的需求组合,很容易找出哪些可用的 NoSQL、NewSQL 或其他解决方案适合你。没有一种 NoSQL 解决方案适合所有问题。也没有一种完美的数据存储适合与 Storm 一起使用——它取决于用例。
因此,让我们看看写入这个 NoSQL 数据存储的代码(见以下列表)。
列表 3.7. Persistor.java


与 Redis 一起工作很简单,它作为我们的用例的良好存储。但对于更大规模的应用和数据集,可能需要不同的数据存储。需要注意的是,因为我们正在处理一个不可靠的数据流,所以我们只是记录在保存到数据库时可能发生的任何错误。一些错误可能可以重试(例如,超时),而在与可靠数据流一起工作时,我们会考虑如何重试它们,正如你将在第四章中看到的。
3.4.5. 定义组件之间的流分组
在第二章中,你学习了两种将拓扑内部组件连接到彼此的方法——洗牌分组和字段分组。为了回顾:
-
你使用洗牌分组以随机但均匀分布的方式将一个组件的输出元组分布到下一个组件。
-
当你想确保具有选定字段相同值的元组总是流向下一个螺栓的同一实例时,你使用字段分组。
对于Checkins/GeocodeLookup和HeatMapBuilder/Persistor之间的流,简单的洗牌分组应该足够。
但我们需要将GeocodeLookup螺栓发出的整个元组流发送到HeatMapBuilder螺栓。如果来自GeocodeLookup的不同元组最终被发送到不同的HeatMapBuilder实例,那么我们就无法将它们分组到时间间隔中,因为它们将分布在不同的HeatMapBuilder实例中。这就是全局分组发挥作用的地方。全局分组将确保整个元组流都流向一个特定的HeatMapBuilder实例。具体来说,整个流将流向具有最低任务 ID 的HeatMapBuilder实例(由 Storm 内部分配的 ID)。现在我们所有的元组都在一个地方,我们可以轻松地确定任何元组所属的时间间隔并将它们分组到相应的间隔中。
注意
你可以选择不使用全局分组,而是一个HeatMapBuilder螺栓的单例实例与洗牌分组。这也会保证一切都会流向同一个HeatMapBuilder实例,因为只有一个。但我们更喜欢在代码中明确表达,使用全局分组清楚地传达了这里期望的行为。全局分组也稍微便宜一些,因为它不需要像洗牌分组那样选择一个随机实例来发射。
让我们看看我们如何在代码中定义这些流分组,以构建和运行我们的拓扑。
3.4.6. 在本地集群模式下运行拓扑结构
我们几乎完成了。我们只需要将所有东西连接起来,并在本地集群模式下运行拓扑,就像我们在第二章中做的那样。但在本章中,我们将偏离将所有代码放在单个 LocalTopologyRunner 类中的做法,将代码分成两个类:一个用于构建拓扑,另一个用于运行它。这是一个常见的做法,虽然你在这章中可能看不到立即的好处,但希望在第四章和第五章中你会看到我们为什么这样做的原因。
下面的列表展示了构建拓扑结构的代码。
列表 3.8. HeatmapTopologyBuilder.java

在定义了构建拓扑的代码之后,下一个列表展示了如何实现 LocalTopologyRunner。
列表 3.9. LocalTopologyRunner.java

现在我们有一个工作的拓扑。我们从我们的发射器读取提交数据,最后,我们将按时间间隔分组的坐标持久化到 Redis 中,并完成热图拓扑的实现。我们剩下要做的就是使用 JavaScript 应用程序从 Redis 中读取数据,并使用 Google Maps API 的热图叠加功能来构建可视化。
这个简单的实现将会运行,但它会扩展吗?它足够快吗?让我们深入挖掘并找出答案。
3.5. 扩展拓扑结构
让我们回顾一下到目前为止的情况。我们有一个类似图 3.8 中所示的工作拓扑,用于我们的服务。
图 3.8. 热图拓扑

它存在一些问题。就目前而言,这个拓扑以串行方式运行,一次处理一个提交。这不是 Web 规模——这是 Apple IIe 规模。如果我们将其投入实际使用,一切都会陷入停滞,我们最终会得到不满意的客户、不满意的运维团队,以及可能的不满意的投资者。
什么是 Web 规模?
当一个系统可以简单地增长而不需要停机来满足由网络效应带来的需求时,它就是 Web 规模的。当每个满意的用户告诉他们的 10 个朋友关于你的热图时,服务和需求会呈指数增长。这种需求的增长被称为 Web 规模。
我们需要同时处理多个提交,因此我们将并行性引入到我们的拓扑中。使 Storm 如此吸引人的一个特性是并行化工作流程(如我们的热图)的简便性。让我们再次查看拓扑的各个部分,并讨论它们如何可以并行化。我们将从提交开始。
3.5.1. 理解 Storm 中的并行性
Storm 有一些额外的原语,它们可以作为调整其扩展方式的旋钮。如果你不触碰它们,拓扑仍然可以工作,但所有组件将以更或更少的线性方式运行。这可能适合只有少量数据流通过它们的拓扑。对于像热图拓扑这样的东西,它将从大口径的水管接收数据,我们希望解决其中的瓶颈。在本节中,我们将查看处理扩展的两个原语。我们将在下一章稍后考虑更多的扩展原语。
并行度提示
我们知道我们将会需要快速处理许多检查点,因此我们想要并行化处理检查点的发射器。图 3.9 给出了我们正在工作的拓扑部分的想法。
图 3.9. 将我们的并行化更改集中在 Checkins 发射器上

Storm 允许你在定义任何发射器或螺栓时提供并行度提示。在代码中,这涉及到将
builder.setSpout("checkins", new Checkins());
to
builder.setSpout("checkins", new Checkins(), 4);
我们提供给 setSpout 的附加参数是并行度提示。这听起来有点复杂:并行度提示。那么,什么是并行度提示呢?目前,我们可以这样说,并行度提示告诉 Storm 应该创建多少个检查点发射器。在我们的例子中,这导致创建了四个发射器实例。这不仅仅是这样,但我们稍后会谈到。
现在我们运行我们的拓扑时,我们应该能够以四倍的速度处理检查点——但是仅仅在我们的拓扑中引入更多的发射器和螺栓是不够的。拓扑中的并行性既涉及输入也涉及输出。Checkins 发射器现在可以一次处理更多的检查点,但 GeocodeLookup 螺栓仍然以串行方式处理。同时将四个检查点传递给单个 GeocodeLookup 实例是不会奏效的。图 3.10 展示了我们造成的问题。
图 3.10. 四个 Checkins 实例向一个 GeocodeLookup 实例发射元组,导致 GeocodeLookup 实例成为瓶颈。

目前,我们面临的情况类似于马戏团的丑角汽车表演,许多丑角都试图同时通过同一个门挤进一辆车。这个瓶颈需要解决;让我们尝试并行化地理编码查找螺栓。我们可以像处理检查点一样并行化地理编码螺栓。从这一点
builder.setBolt("geocode-lookup", new GeocodeLookup());
to this
builder.setBolt("geocode-lookup", new GeocodeLookup(), 4);
肯定会很有帮助。现在我们有一个 GeocodeLookup 实例对应于每个 Checkins 实例。但是 GeocodeLookup 将会比接收检查点并将其传递给我们的螺栓花费更长的时间。所以也许我们可以做点像这样的事情:
builder.setBolt("geocode-lookup", new GeocodeLookup(), 8);
现在如果 GeocodeLookup 比处理检查点花费两倍的时间,元组应该能够在我们系统中平稳地流动,从而产生 图 3.11。
图 3.11. 四个 Checkins 实例向八个 GeocodeLookup 实例发射元组

我们在这里取得了进展,但还有其他事情需要考虑:当我们的服务变得更受欢迎时会发生什么?我们需要能够继续扩展以跟上不断增长的流量,而不会使我们的应用程序离线,或者至少不会经常离线。幸运的是,Storm 提供了一种方法来实现这一点。我们之前对并行性提示做了大致的定义,但说还有更多内容。好吧,现在我们就在这里。这个并行性提示映射到我们尚未覆盖的两个 Storm 概念:执行器和任务。
执行器和任务
那么,执行器和任务是什么?真正理解这个问题的答案需要更深入地了解 Storm 集群及其各个部分。尽管我们直到第五章才会学习有关 Storm 集群的详细信息,但我们可以提前向你展示 Storm 集群的某些部分,这将帮助你理解执行器和任务在扩展拓扑中的作用。
到目前为止,我们知道我们的 spouts 和 bolts 每个都在作为一个或多个实例运行。这些实例都在某个地方运行,对吧?肯定有一些机器(物理或虚拟)实际上在执行我们的组件。我们将这个机器称为工作节点,尽管工作节点不是在 Storm 集群上运行的唯一类型的节点,但它是在 spouts 和 bolts 中执行逻辑的节点。由于 Storm 运行在 JVM 上,因此每个工作节点都在 JVM 上执行我们的 spouts 和 bolts。图 3.12 显示了到目前为止的情况。
图 3.12. 工作节点是一个运行 JVM 的物理或虚拟机器,该 JVM 执行 spouts 和 bolts 中的逻辑。

工作节点还有一些其他内容,但对你来说现在重要的是要理解它运行了执行我们的 spout 和 bolt 实例的 JVM。因此,我们再次提出问题:执行器和任务是什么?执行器是 JVM 上的一个执行线程,而任务是运行在执行线程中的我们的 spouts 和 bolt 的实例。图 3.13 说明了这种关系。
图 3.13. 执行器(线程)和任务(spouts/bolts 的实例)在 JVM 上运行。

真的是这么简单。执行器是 JVM 中的一个执行线程。任务是运行在那个执行线程中的 spout 或 bolt 的实例。在讨论本章的可伸缩性时,我们指的是改变执行器和任务的数量。Storm 通过改变工作节点和 JVM 的数量提供额外的扩展方式,但我们将在第六章和第七章中介绍这些内容。
让我们回到我们的代码,重新审视这代表什么,从并行性提示的角度来看。将并行性提示设置为 8,就像我们在GeocodeLookup中做的那样,告诉 Storm 创建八个执行器(线程)并运行八个GeocodeLookup的任务(实例)。这可以通过以下代码看到:
builder.setBolt("geocode-lookup", new GeocodeLookup(), 8)
默认情况下,并行性提示会将执行者和任务的数量设置为相同的值。我们可以使用setNumTasks()方法覆盖任务的数量,如下所示:
builder.setBolt("geocode-lookup", new GeocodeLookup(), 8).setNumTasks(8)
为什么提供将任务数量设置为与执行者数量不同的能力?在我们回答这个问题之前,让我们退一步,重新审视我们是如何到达这里的。我们正在讨论如何在将来不关闭热图的情况下对其进行扩展。最容易的方法是什么?答案是:增加并行性。幸运的是,Storm 提供了一个有用的功能,允许我们通过动态增加执行者(线程)的数量来增加运行中的拓扑的并行性。你将在第六章中了解更多关于如何做到这一点的方法。第六章。
这对我们运行在八个线程上的八个实例的GeocodeLookup螺栓意味着什么?嗯,每个实例将花费大部分时间等待网络 I/O。我们怀疑这意味着GeocodeLookup将成为未来的争用源,并需要扩展。我们可以通过以下方式允许这种可能性:
builder.setBolt("geocode-lookup", new GeocodeLookup(), 8).setNumTasks(64)
现在我们有 64 个GeocodeLookup的任务(实例)在八个执行者(线程)上运行。由于我们需要增加GeocodeLookup的并行性,我们可以继续增加执行者的数量,直到最大 64 个,而无需停止我们的拓扑。我们重复:无需停止拓扑。正如我们之前提到的,我们将在后面的章节中详细介绍如何做到这一点,但这里的关键点是执行者(线程)的数量可以在运行中的拓扑中动态更改。
Storm 将并行性分解为执行者和任务两个不同的概念,以处理像我们的GeocodeLookup螺栓这样的情况。为了说明原因,让我们回到字段分组的定义:
字段分组是一种流分组类型,其中具有特定字段名相同值的元组总是被发射到螺栓的相同实例。
在那个定义中隐藏着我们的答案。字段分组通过在固定数量的螺栓上持续散列元组来工作。为了保持具有相同值的键流向相同的螺栓,螺栓的数量不能改变。如果改变了,元组就会开始流向不同的螺栓。这就会违背我们通过字段分组试图实现的目的。
在Checkins发射器和GeocodeLookup螺栓上配置执行者和任务很容易,以便在以后的时间点进行扩展。有时,我们的设计部分可能不适合扩展。让我们看看下一个问题。
3.5.2. 调整拓扑以解决设计中的瓶颈
HeatMapBuilder 是下一个要讨论的。之前我们在增加 Checkins spout 的并行性提示时遇到了 GeocodeLookup 的瓶颈。但通过相应地增加 GeocodeLookup bolt 的并行性,我们能够轻松解决这个问题。在这里我们无法这样做。由于 HeatMapBuilder 是通过全局分组与前面的 bolt 连接的,因此增加其并行性没有意义。因为全局分组规定每个元组都发送到 HeatMapBuilder 的一个特定实例,增加其并行性没有任何效果;只有一个实例会积极处理流。我们拓扑设计中固有的瓶颈。
这是使用全局分组带来的缺点。使用全局分组,我们牺牲了扩展能力,引入了人为的瓶颈,以便在一个特定的 bolt 实例中看到整个元组流。
那么,我们能做什么呢?我们是否无法在拓扑中并行化这一步骤?如果我们不能并行化这个 bolt,那么并行化后续 bolt 几乎没有意义。这是瓶颈点。它不能与当前设计并行化。当我们遇到这类问题时,最佳方法是退一步,看看我们能否通过改变拓扑设计来达到我们的目标。
我们不能并行化 HeatMapBuilder 的原因是所有元组都需要进入同一个实例。所有元组都必须进入同一个实例,因为我们需要确保每个落入任何给定时间间隔的元组都能被分组在一起。所以如果我们能确保每个落入给定时间间隔的元组都进入同一个实例,我们就可以有多个 HeatMapBuilder 实例。
目前,我们使用 HeatMapBuilder bolt 做两件事:
-
确定给定元组所属的时间间隔
-
按时间间隔分组元组
如果我们能将这两个操作移动到单独的 bolt 中,我们就能更接近我们的目标。让我们看看下一个列表中 HeatMapBuilder bolt 部分是如何确定元组所属的时间间隔的。
列表 3.10. 在 HeatMapBuilder.java 中确定元组的时间间隔
public void execute(Tuple tuple,
BasicOutputCollector outputCollector) {
if (isTickTuple(tuple)) {
emitHeatmap(outputCollector);
} else {
Long time = tuple.getLongByField("time");
LatLng geocode = (LatLng) tuple.getValueByField("geocode");
Long timeInterval = selectTimeInterval(time);
List<LatLng> checkins = getCheckinsForInterval(timeInterval);
checkins.add(geocode);
}
}
private Long selectTimeInterval(Long time) {
return time / (15 * 1000);
}
HeatMapBuilder 从 GeocodeLookup 接收签到时间和地理坐标。让我们将这个从 GeocodeLookup 发射的元组中提取时间间隔的简单任务移动到另一个 bolt 中。这个 bolt——让我们称它为 TimeIntervalExtractor——可以发射一个时间间隔和一个坐标,这些可以被 HeatMapBuilder 捕获,如下列所示。
列表 3.11. TimeIntervalExtractor.java

引入 TimeIntervalExtractor 需要修改 HeatMapBuilder。我们不再从输入元组中检索时间,而是需要更新该 bolt 的 execute() 方法以接受一个时间间隔,如下一个列表所示。
列表 3.12. 在 HeatMapBuilder.java 中更新 execute() 方法以使用预计算的时间间隔
@Override
public void execute(Tuple tuple,
BasicOutputCollector outputCollector) {
if (isTickTuple(tuple)) {
emitHeatmap(outputCollector);
} else {
Long timeInterval = tuple.getLongByField("time-interval");
LatLng geocode = (LatLng) tuple.getValueByField("geocode");
List<LatLng> checkins = getCheckinsForInterval(timeInterval);
checkins.add(geocode);
}
}
我们拓扑中的组件现在包括以下内容:
-
Checkinsspout,它输出时间和地址 -
GeocodeLookupbolt,它输出时间和地理坐标 -
TimeIntervalExtractorbolt,它输出时间间隔和地理坐标 -
HeatMapBuilderbolt,它输出时间间隔以及一组地理坐标 -
Persistorbolt,它不输出任何内容,因为它是我们拓扑中的最后一个 bolt
图 3.14 显示了反映这些变更的更新拓扑设计。
图 3.14. 包含TimeIntervalExtractor bolt 的更新拓扑

现在我们将HeatMapBuilder连接到TimeIntervalExtractor时,不需要使用全局分组。
我们已经预先计算了时间间隔,现在我们需要确保同一个HeatMapBuilder bolt 实例接收给定时间间隔内的所有值。不同的时间间隔是否发送到不同的实例无关紧要。我们可以使用字段分组来实现这一点。字段分组允许我们根据指定的字段对值进行分组,并将所有带有该给定值的元组发送到特定的 bolt 实例。我们所做的是将元组分割成时间间隔,并将每个段发送到不同的HeatMapBuilder实例,从而通过并行运行段来实现并行化。图 3.15 显示了我们的 spout 和 bolt 之间的更新流分组。
图 3.15. 更新拓扑流分组

让我们看看需要添加到HeatmapTopologyBuilder中的代码,以便结合我们的新TimeIntervalExtractor bolt,并更改到适当的流分组,如列表 3.13 所示。
列表 3.13. 新增到HeatmapTopologyBuilder.java的 bolt

如列表所示,我们已经完全移除了全局分组,现在我们使用一系列洗牌分组,其中包含一个针对时间间隔的单字段分组。
全局分组
我们通过将全局分组替换为字段分组来扩展了这个 bolt,在经过一些小的设计变更后。那么全局分组是否适合任何需要实际扩展的现实世界场景呢?不要小看全局分组;当它在正确的节点部署时,确实起到了有用的作用。
在这个案例研究中,我们在聚合点使用了全局分组(按时间间隔分组坐标)。当在聚合点使用时,它确实不会扩展,因为我们迫使它处理更大的数据集。但如果我们使用聚合后的全局分组,它将处理较小的元组流,我们就不需要像聚合前那样大的扩展。
如果你需要查看整个元组流,全局分组非常有用。首先你需要以某种方式聚合它们(洗牌分组用于随机聚合元组集或字段分组用于聚合选定的元组集),然后对聚合使用全局分组以获得完整的图景:
builder.setBolt("aggregation-bolt", new AggregationBolt(), 10)
.shuffleGrouping("previous-bolt");
builder.setBolt("world-view-bolt", new WorldViewBolt())
.globalGrouping("aggregation-bolt");
在这种情况下,AggregationBolt 可以进行扩展,并将流裁剪成更小的集合。然后 WorldViewBolt 可以通过在来自 AggregationBolt 的已聚合元组上使用全局分组来查看完整的流。我们不需要扩展 WorldViewBolt,因为它正在查看一个更小的数据集。
并行化 TimeIntervalExtractor 是简单的。首先,我们可以给它与 Checkins spout 相同的并行级别——与 GeocodeLookup bolt 不同,我们不需要等待外部服务:
builder.setBolt("time-interval-extractor", new TimeIntervalExtractor(), 4)
.shuffleGrouping("geocode-lookup");
接下来,我们可以清除拓扑中的麻烦瓶颈:
builder.setBolt("heatmap-builder", new HeatMapBuilder(), 4)
.fieldsGrouping("time-interval-extractor", new Fields("time-interval"));
最后,我们解决 Persistor 的问题。在某种程度上,这与 GeocodeLookup 类似,因为我们预计我们以后需要对其进行扩展。因此,我们需要比执行器更多的任务,原因与我们在之前的 GeocodeLookup 讨论中提到的相同:
builder.setBolt("persistor", new Persistor(), 1)
.setNumTasks(4)
.shuffleGrouping("heatmap-builder");
图 3.16 展示了刚刚应用的并行化变化。
图 3.16. 并行化拓扑中的所有组件

看起来我们已经完成了这个拓扑的扩展...还是吗?我们已经为拓扑中的每个组件(即每个发射器和 bolt)配置了并行性。每个 bolt 或 spout 都可以配置为并行,但这并不一定意味着它将以扩展的方式运行。让我们看看原因。
3.5.3. 调整拓扑结构以解决数据流中固有的瓶颈
我们已经并行化了拓扑中的每个组件,这与我们使用的每个分组(洗牌分组、字段分组和全局分组)如何影响我们拓扑中元组的流动的技术定义是一致的。不幸的是,它仍然没有有效地实现并行。
尽管我们能够通过上一节中的更改并行化 HeatMapBuilder,但我们忘记考虑的是我们数据流的性质如何影响并行化。我们将流经流的元组分组为 15 秒的段,这是我们问题的根源。对于给定的 15 秒窗口,所有落入该窗口的元组都将通过 HeatMapBuilder bolt 的一个实例。确实,通过我们做出的设计更改,HeatMapBuilder 在技术上可以并行化,但它实际上还没有实现并行。流经你的拓扑的数据流的形状可能会隐藏难以发现的扩展问题。始终质疑数据通过你的拓扑流动的影响是明智的。
我们如何进行并行化?我们正确地按时间间隔分组,因为这是我们热图生成的依据。我们需要在时间间隔下增加一个额外的分组级别;我们可以细化我们的高级解决方案,以便通过时间间隔和城市来提供热图。当我们按城市增加一个额外的分组级别时,我们将有一个给定时间间隔的多个数据流,它们可能通过 HeatmapBuilder 的不同实例流动。为了添加这个额外的分组级别,我们首先需要在 GeocodeLookup 的输出元组中添加城市作为一个字段,如下所示。
列表 3.14. 在 GeocodeLookup.java 的输出元组中添加城市作为一个字段


GeocodeLookup 现在将其输出元组中的城市作为一个字段。我们需要更新 TimeIntervalExtractor 以读取和发射此值,如下所示。
列表 3.15. 在 TimeIntervalExtractor.java 中传递城市字段
public class TimeIntervalExtractor extends BaseBasicBolt {
@Override
public void declareOutputFields(OutputFieldsDeclarer declarer) {
declarer.declare(new Fields("time-interval", "geocode", "city"));
}
@Override
public void execute(Tuple tuple,
BasicOutputCollector outputCollector) {
Long time = tuple.getLongByField("time");
LatLng geocode = (LatLng) tuple.getValueByField("geocode");
String city = tuple.getStringByField("city");
Long timeInterval = time / (15 * 1000);
outputCollector.emit(new Values(timeInterval, geocode, city));
}
}
最后,我们需要更新我们的 HeatmapTopologyBuilder,以便在 TimeIntervalExtractor 和 HeatMapBuilder 之间的字段分组基于时间间隔和城市字段,如下所示。
列表 3.16. 在 HeatmapTopologyBuilder.java 中添加了二级分组

现在我们有一个拓扑,它不仅在技术上并行化,而且实际上也在并行运行。我们在这里做了一些更改,所以让我们更新一下我们的拓扑和图 3.17 中元组的转换。
图 3.17. 将城市添加到 GeocodeLookup 发射的元组中,并使 TimeIntervalExtractor 在其发射的元组中传递城市

我们已经涵盖了并行化 Storm 拓扑的基本知识。我们在这里采取的方法是基于我们对每个拓扑组件如何工作的理解而做出的有根据的猜测。在并行化这个拓扑方面还有更多的工作可以做,包括额外的并行化原语和基于观察指标实现最佳调优的方法。我们将在本书的适当位置讨论它们。在本章中,我们构建了正确设计 Storm 拓扑所需的并行性理解。拓扑的可扩展性在很大程度上取决于拓扑底层组件分解和设计。
3.6. 拓扑设计范式
让我们回顾一下我们是如何设计热图拓扑的:
-
我们检查了我们的数据流,并确定我们的输入元组基于我们开始时的内容。然后我们确定了我们需要达到目标(最终元组)的结果元组。
-
我们创建了一系列操作(作为 bolt),将输入元组转换为最终元组。
-
我们仔细检查了每个操作,以了解其行为,并通过基于我们对行为理解的有根据的猜测进行扩展(调整其执行器/任务)。
-
在我们不能再扩展的点,我们重新思考了我们的设计,并将拓扑重构为可扩展的组件。
这是一个很好的拓扑设计方法。在创建拓扑时,大多数人没有考虑到可扩展性,陷入了这个陷阱是很常见的。如果我们不早点做,而把可扩展性的问题留到以后,你为了重构或重新设计拓扑所需要做的工作量将增加一个数量级。
过早优化是万恶之源。
唐纳德·克努特
作为工程师,我们在早期讨论性能考虑因素时喜欢引用唐纳德·克努特的这句话。在大多数情况下,这确实是正确的,但让我们看看完整的引文,以给我们更多的背景,了解克努特博士试图说什么(而不是我们工程师通常用来表达观点的简短引语):
你应该忘记关于小效率的事情,比如说 97%的时间;过早优化是万恶之源。
你不是在尝试实现小效率——你是在处理大数据。你做的每一个效率提升都很重要。一个小的性能瓶颈可能会在大数据集上工作时,导致无法达到所需的性能服务等级协议。如果你在建造赛车,你需要从第一天开始就考虑性能。如果你没有从一开始就为性能而建造,你不能在以后重构引擎来提高它。所以步骤 3 和步骤 4 是设计拓扑的关键部分。
这里唯一的缺点是对问题域的了解不足。如果你的问题域知识有限,那么如果你试图过早地扩展它,可能会对你不利。当我们说“对问题域的了解”时,我们指的是系统中流动的数据的性质以及你操作中的固有瓶颈。在你对它有很好的理解之前,推迟可扩展性的问题总是可以的。类似于构建专家系统,当你真正理解了问题域时,你可能不得不放弃你的初始解决方案并重新开始。
3.6.1. 通过分解为功能组件进行设计
让我们观察我们是如何分解拓扑中一系列操作的(图 3.18)。
图 3.18. 热图拓扑设计作为一系列功能组件

我们通过给每个螺栓分配一个特定的责任,将拓扑结构分解成单独的螺栓。这与单一责任原则相一致。我们在每个螺栓中封装了特定的责任,每个螺栓中的所有内容都与它的责任紧密相关,与其他内容无关。换句话说,每个螺栓代表一个功能整体。
这种设计方法有很多价值。给每个 bolt 分配单一职责使其易于独立处理。它还使得在不干扰拓扑其余部分的情况下轻松扩展单个 bolt,因为并行性是在 bolt 级别调整的。无论是扩展还是解决问题,当你能够聚焦于单个组件时,从这种专注中获得的效率提升将使你能够从以这种方式设计组件的努力中获得收益。
3.6.2. 在重分区点按组件分解设计
将问题分解为其组成部分的方法略有不同。与之前讨论的将问题分解为功能组件的方法相比,这种方法在性能方面提供了显著的改进。使用这种模式,我们不是将问题分解为其最简单的可能功能组件,而是从不同组件之间的分离点(或连接点)的角度思考。换句话说,我们考虑不同 bolt 之间的连接点。在 Storm 中,不同的流分组是不同 bolt 之间的标记(因为分组定义了来自一个 bolt 的输出元组如何分配到下一个 bolt)。
在这些点上,拓扑中流动的元组流会被重分区。在流重分区期间,元组的分配方式会改变。这实际上是流分组的函数。图 3.19(Figure 3.19)通过重分区的点说明了我们的设计。
图 3.19. 以重分区点为依据的 HeatMap 拓扑设计

使用这种拓扑设计模式,我们努力将拓扑内部的重分区数量最小化。每次进行重分区时,元组将从网络中的一个 bolt 发送到另一个 bolt。由于多种原因,这是一个昂贵的操作:
-
拓扑在分布式集群中运行。当元组被发出时,它们可能穿越集群,这可能会产生网络开销。
-
每次发出元组时,都需要在接收点进行序列化和反序列化。
-
分区数量越多,所需的资源就越多。每个 bolt 都需要一定数量的 executors 和 tasks,以及一个用于所有传入元组的队列。
注意
我们将在后面的章节中讨论 Storm 集群的组成以及支持 bolt 的内部机制。
对于我们的拓扑,我们如何最小化分区数量?我们将不得不合并几个 bolt。要做到这一点,我们必须弄清楚每个功能组件有什么不同之处,使其需要自己的 bolt(以及 bolt 带来的资源):
-
Checkins(spout)—4 executors (读取文件) -
GeocodeLookup—8 executors, 64 tasks (调用外部服务) -
TimeIntervalExtractor—4 executors (内部计算;转换数据) -
HeatMapBuilder—4 个执行器(内部计算;聚合元组) -
Persistor—1 个执行器,4 个任务(写入数据存储)
现在进行分析:
-
GeocodeLookup和Persistor与外部实体交互,与该外部实体交互所花费的时间将决定执行器和任务如何分配给这两个 bolt。我们不太可能能够迫使这些 bolt 的行为适应另一个。也许其他某些东西可能能够适应其中一个所需的资源。 -
HeatMapBuilder按时间区间和城市对地理坐标进行聚合。与其他相比,它有些独特,因为它在内存中缓冲数据,并且只有在时间区间过去之后才能进行下一步。它足够奇特,以至于将其与其他合并需要仔细考虑。 -
Checkins是一个 spout,通常你不会修改一个 spout 以包含涉及计算的运算。此外,由于 spout 负责跟踪已发射的数据,我们很少在其中执行任何计算。但是,与适应初始元组相关的一些事情(如解析、提取和转换)确实适合 spout 的职责。 -
这就留下了
TimeIntervalExtractor。这很简单——它所做的只是将一个“时间”条目转换成一个“时间区间”。我们将其从HeatMapBuilder中提取出来,因为我们需要在HeatMapBuilder之前知道时间区间,以便我们可以按时间区间进行分组。这使我们能够扩展HeatMapBuilderbolt。TimeIntervalExtractor完成的工作在技术上可以在HeatMapBuilder之前的任何时刻发生:-
如果我们将
TimeIntervalExtractor与GeocodeLookup合并,它需要适应分配给GeocodeLookup的资源。尽管它们有不同的资源配置,但TimeIntervalExtractor的简单性将允许它适应分配给GeocodeLookup的资源。从纯粹理想主义的角度来看,它们也适合——这两个操作都是数据转换(从时间到时间区间,从地址到地理坐标)。其中一个是极其简单的,而另一个则需要使用外部服务的网络开销。 -
我们能否将
TimeIntervalExtractor与Checkinsspout 合并?它们具有完全相同的资源配置。此外,将“时间”转换为“时间区间”是 bolt 中可以合理地在 spout 中执行的操作类型之一。答案是响亮的肯定。这引出了一个问题,即GeocodeLookup是否也可以与Checkinsspout 合并。尽管GeocodeLookup也是一个数据转换器,但由于它依赖于外部服务,这是一个更重量级的计算,这意味着它不适合在 spout 中发生的动作。
-
我们是否应该将TimeIntervalExtractor与GeocodeLookup或Checkins源合并?从效率的角度来看,两者都可以,这是正确的答案。我们会将其与源合并,因为我们更喜欢将外部服务交互与像TimeIntervalExtractor这样的简单任务保持清晰。我们将让你在你的拓扑中进行必要的更改来实现这一点。
你可能会想知道为什么在这个例子中我们选择不将HeatMapBuilder与Persistor合并。HeatMapBuilder定期(每当它收到一个 tick 元组时)发出聚合的地理坐标,在发出时,它可以修改为将值写入数据存储(Persistor的责任)。虽然从概念上讲这是合理的,但它改变了组合螺栓的可观察行为。组合的HeatMapBuilder/Persistor在接收到的两种类型的元组上表现非常不同。来自流的常规元组将以低延迟执行,而写入数据存储的 tick 元组将具有相对较高的延迟。如果我们要监控和收集关于这个组合螺栓性能的数据,将很难隔离观察到的指标并就如何进一步调整做出明智的决定。这种延迟不平衡的性质使得它非常不优雅。
通过考虑流的重分区点来设计拓扑,并尝试最小化它们,这将使你在具有较高性能可能性的拓扑结构中,以最低的延迟使用资源。
3.6.3. 最简单的功能组件与最少数量的分区
我们讨论了两种拓扑设计的方法。哪一个更好?只要仔细考虑哪些操作可以组合成一个螺栓,拥有最少数量的分区将提供最佳性能。
通常不是非此即彼。作为一个 Storm 初学者,你应该始终从设计最简单的功能组件开始;这样做可以让你轻松地推理不同的操作。此外,如果你从具有多个职责的更复杂组件开始,如果设计错误,将很难将其分解成更简单的组件。
你始终可以从最简单的功能组件开始,然后逐步将不同的操作组合在一起以减少分区数量。反过来操作要困难得多。随着你在与 Storm 合作方面获得更多经验并发展对拓扑设计的直觉,你将能够从最少数量的分区开始。
3.7. 摘要
在本章中,你学习了
-
如何将问题分解为适合 Storm 拓扑的构造
-
如何将串行运行的拓扑转换为并行
-
如何在设计中发现问题并进行精炼和重构
-
关注数据流对拓扑结构限制的影响的重要性
-
两种不同的拓扑设计方法以及两者之间的微妙平衡
这些设计指南是构建 Storm 顶点的最佳实践。在本书的后续部分,您将了解到为什么这些设计决策极大地有助于对 Storm 进行调优以实现最佳性能。
第四章. 创建健壮的拓扑
本章涵盖
-
保证消息处理
-
容错性
-
重放语义
到目前为止,我们已经定义了许多 Storm 的核心概念。在这个过程中,我们实现了两个独立的拓扑,每个都在本地集群中运行。本章也不例外,我们将为一个新的场景设计和实现另一个拓扑。但我们要解决的问题对保证元组被处理和保持容错性有更严格的要求。为了帮助我们满足这些要求,我们将介绍一些与可靠性和故障相关的新概念。您将了解 Storm 为我们提供的处理故障的工具,我们还将深入了解我们可以对数据处理做出的各种保证。掌握了这些知识,我们将准备好进入世界并创建生产质量的拓扑。
4.1. 可靠性的要求
在上一章中,我们的热图应用需要快速处理大量时间敏感的数据。此外,仅仅采样其中一部分数据就能为我们提供所需的信息:在特定地理区域内当前机构的流行度近似值 现在。如果我们未能在一个短暂的时间窗口内处理一个特定的元组,它就失去了其价值。热图就是关于 现在 的。我们不需要保证每条消息都被处理——大多数 就足够了。
但有些领域对此是严格不可接受的;每个元组都是神圣的。在这些场景中,我们需要保证每个元组都被处理。在这里,可靠性比及时性更重要。如果我们必须为 30 秒、10 分钟或一个小时(或达到某个合理的阈值)重试一个元组,它在我们第一次尝试时和在重试时在我们的系统中具有相同的价值。我们需要可靠性。
Storm 提供了保证每个元组被处理的能力。这作为我们可以信赖的可靠性度量,以确保功能的准确实现。在较高层次上,Storm 通过跟踪哪些元组被成功处理以及哪些没有,然后重放失败的元组直到它们成功,来提供可靠性。
4.1.1. 支持可靠性的拼图碎片
Storm 有许多组成部分需要协同工作才能提供可靠性:
-
一个可靠的数据源,以及相应的可靠 spout
-
一个锚定的元组流
-
一个拓扑结构,在处理每个元组时会识别它,或者在出现故障时通知你
-
具有容错性的 Storm 集群基础设施
在本章中,我们将探讨这三个组件如何组合起来以实现可靠性。然后第五章将向您介绍 Storm 集群,并讨论它如何提供容错性。
4.2. 问题定义:信用卡授权系统
当你考虑使用 Storm 来解决你领域内的问题时,花时间思考你需要的处理保证;这是“在 Storm 中思考”的重要部分。让我们深入一个具有可靠性要求的问题。
想象一下,我们运营着一个大型电子商务网站,负责向人们运送实物商品。我们知道,在我们网站上提交的订单中,绝大多数都成功授权支付,只有一小部分被拒绝。在传统的电子商务中,用户需要完成的订单步骤越多,失去销售的风险就越高。当我们订单提交时立即进行账单处理,我们就会失去生意。将账单处理作为一个独立的、“离线”操作来处理,可以提高转化率并直接影响我们的底线。我们还需要这个离线账单流程能够很好地扩展,以支持像假日(想想亚马逊)或闪购(想想 Gilt)这样的高峰期。
这是一个需要可靠性的场景。每个订单在发货前都必须被授权。如果在尝试授权的过程中遇到问题,我们应该重试。简而言之,我们需要保证消息处理。让我们看看这样一个系统可能的样子,同时考虑到我们如何结合重试特性。
4.2.1. 具有重试特性的概念解决方案
该系统仅处理与已提交订单相关的信用卡授权。我们的系统不处理客户提交订单;这发生在管道的早期。
上游和下游系统的假设
分布式系统由不同系统之间的交互定义。对于我们的用例,我们可以假设以下情况:
-
同一个订单永远不会被发送到我们的系统超过一次。这是由处理客户订单的上游系统保证的。
-
提交订单的上游系统会将订单放入队列,我们的系统将从队列中取出订单以便进行授权。
-
一个独立的下游系统将处理已处理的订单,如果信用卡被授权,则履行订单,或者通知客户信用卡被拒绝。
拥有这些假设,我们可以继续进行一个范围有限但很好地映射到我们想要涵盖的 Storm 概念的设计。
概念解决方案的形成
让我们从订单如何通过我们的系统开始。当需要授权订单的信用卡时,采取以下步骤:
-
从消息队列中取出订单。
-
通过调用外部信用卡授权服务尝试授权信用卡。
-
如果服务调用成功,更新数据库中的订单状态。
-
如果它失败了,我们可以稍后再尝试。
-
通知一个独立的下游系统订单已被处理。
这些步骤在图 4.1 中得到了说明。
图 4.1. 电子商务信用卡授权流程的概念解决方案

我们已经有了基本流程。定义我们问题的下一步是查看我们的拓扑中正在处理的数据点;有了这些知识,我们可以确定元组中传递的内容。
4.2.2. 定义数据点
在定义了事务流程后,我们可以查看涉及的数据。数据流从从队列中拉取的 JSON 格式的传入订单开始(见以下列表)。
列表 4.1. 订单 JSON
{
"id":1234,
"customerId":5678,
"creditCardNumber":1111222233334444,
"creditCardExpiration":"012014",
"creditCardCode":123,
"chargeAmount":42.23
}
此 JSON 将被转换为 Java 对象,我们的系统将内部处理这些序列化的 Java 对象。下一个列表定义了此类。
列表 4.2. Order.java
public class Order implements Serializable {
private long id;
private long customerId;
private long creditCardNumber;
private String creditCardExpiration;
private int creditCardCode;
private double chargeAmount;
public Order(long id,
long customerId,
long creditCardNumber,
String creditCardExpiration,
int creditCardCode,
double chargeAmount) {
this.id = id;
this.customerId = customerId;
this.creditCardNumber = creditCardNumber;
this.creditCardExpiration = creditCardExpiration;
this.creditCardCode = creditCardCode;
this.chargeAmount = chargeAmount;
}
...
}
这种以数据点和对其执行操作的组件来定义问题的方法应该很熟悉;这正是我们在创建拓扑时在 第二章 和 第三章 中分解问题的方法。我们现在需要将此解决方案映射到 Storm 可以使用的组件,以构建我们的拓扑。
4.2.3. 将解决方案映射到 Storm 并具有重试特性
现在我们已经有一个基本的设计,并且已经确定了将通过我们的系统流动的数据,我们可以将我们的数据和组件映射到 Storm 概念。我们的拓扑将包含三个主要组件,一个 spout 和两个 bolt:
-
RabbitMQSpout— 我们的数据源将从队列中消费消息,其中每个消息都是表示订单的 JSON,并发射包含序列化Order对象的元组。我们将使用 RabbitMQ 作为我们的队列实现——因此得名。我们将在本章后面讨论保证消息处理时深入了解此 spout 的细节。 -
AuthorizeCreditCard— 如果信用卡被授权,此 bolt 将将订单状态更新为“准备发货”。如果信用卡被拒绝,此 bolt 将将订单状态更新为“拒绝”。无论状态如何,此 bolt 都将向流中的下一个 bolt 发射包含Order的元组。 -
ProcessedOrderNotification— 一个通知单独系统订单已处理的 bolt。
除了 spout、bolt 和元组外,我们还需要定义元组在各个组件之间发射时的流分组。以下将使用以下流分组:
-
在
RabbitMQSpout和AuthorizeCreditCardbolt 之间进行洗牌分组 -
在
AuthorizeCreditCardbolt 和ProcessedOrder-Notificationbolt 之间进行洗牌分组
在 第二章 中,我们使用字段分组确保相同的 GitHub 提交者电子邮件被路由到相同的 bolt 实例。在 第三章 中,我们使用字段分组确保按时间间隔相同的地理坐标分组被路由到相同的 bolt 实例。我们不需要相同的保证;任何给定的 bolt 实例都可以处理任何给定的元组,因此洗牌分组就足够了。
我们刚才讨论的所有 Storm 概念都在 图 4.2 中展示。
图 4.2. 电子商务信用卡授权映射到 Storm 概念

在了解我们的拓扑结构后,我们将先介绍我们两个螺栓的代码,然后再讨论保证消息处理及其实现所需的条件。稍后我们将讨论 spout 的代码。
4.3. 基本螺栓实现
本节将涵盖我们两个螺栓的代码:AuthorizeCreditCard和ProcessedOrderNotification。了解每个螺栓内部发生的事情将在我们讨论第 4.4 节中的保证消息处理时提供一些背景。
我们将RabbitMQSpout的实现留到保证消息处理部分的末尾,因为 spout 中的大部分代码都是针对重试失败的元组。对保证消息处理的完整理解将帮助您专注于 spout 代码的相关部分。
让我们从查看我们的拓扑中的第一个螺栓AuthorizeCreditCard开始。
4.3.1. AuthorizeCreditCard实现
AuthorizeCreditCard螺栓从RabbitMQSpout接受一个Order对象。然后,这个螺栓尝试通过调用外部服务来授权信用卡。根据授权尝试的结果,我们的数据库中的订单状态将被更新。之后,这个螺栓将发出包含它接收到的Order对象的元组。图 4.3 说明了我们在拓扑中的位置。
图 4.3. AuthorizeCreditCard螺栓接受来自RabbitMQSpout的输入元组,无论信用卡是否被授权都会发出元组。

下一个列表展示了此螺栓的代码。
列表 4.3. AuthorizeCreditCard.java

一旦账单被批准或拒绝,我们就准备好通知下游系统已处理的订单;这个代码将在接下来的ProcessedOrder-Notification中展示。
4.3.2. ProcessedOrderNotification实现
我们流中的第二个也是最后一个螺栓ProcessedOrderNotification接受来自AuthorizeCreditCard螺栓的Order,并通知外部系统订单已被处理。这个螺栓不会发出任何元组。图 4.4 显示了这个最终的螺栓在拓扑中的位置。
图 4.4. ProcessedOrderNotification螺栓接受来自AuthorizeCreditCard螺栓的输入元组,并在不发出元组的情况下通知外部系统。

下面的列表显示了此螺栓的代码。
列表 4.4. ProcessedOrderNotification.java

在下游系统被通知处理顺序之后,我们的拓扑结构就没有其他事情可做了,因此这就是我们 bolt 实现结束的地方。目前我们有一个明确的解决方案(除了 spout,我们将在下一节讨论)。我们在这个章节中提出的设计/实现步骤与我们在第二章和第三章中采取的步骤是一致的。
与那些章节相比,这个实现的不同之处在于确保所有元组都被拓扑结构中的所有 bolt 处理的要求。处理金融交易与 GitHub 提交计数或社交媒体签到热图有很大的不同。还记得在 4.1.1 节中提到的支持可靠性的拼图碎片吗?
-
一个对应可靠 spout 的可靠数据源
-
一个锚定的元组流
-
一个拓扑结构在处理每个元组时会承认它,或者在出现故障时通知我们
-
具有容错能力的 Storm 集群基础设施
我们已经到了可以开始处理前三部分内容的时候了。那么,我们的实现将如何改变以提供这些部分呢?令人惊讶的是,它不会改变!我们 bolt 的代码已经设置好了以支持 Storm 中的保证消息处理。让我们详细看看 Storm 是如何做到这一点的,以及接下来看一下我们的可靠RabbitMQSpout。
4.4. 保证消息处理
什么是消息,Storm 又是如何保证消息被处理的?消息与元组同义,Storm 有确保从 spout 发出的元组被拓扑结构完全处理的能力。所以如果元组在流中某个点失败,Storm 知道发生了故障,并且可以重新播放该元组,从而确保它被处理。Storm 文档通常使用“保证消息处理”这个短语,本书中我们也将使用这个短语。
如果你想开发可靠的拓扑结构,理解保证消息处理是至关重要的。获得这种理解的第一步是知道元组完全处理或失败的含义。
4.4.1. 元组状态:完全处理与失败
从 spout 发出的元组可能导致下游 bolt 发出许多额外的元组。这创建了一个元组树,其中 spout 发出的元组作为根。Storm 为 spout 发出的每个元组创建和跟踪一个元组树。当该元组的树中的所有叶子都被标记为已处理时,Storm 将认为从 spout 发出的元组已经完全处理。为了确保 Storm 可以创建和跟踪元组树,你需要使用 Storm API 完成以下两项操作:
-
确保在从 bolt 发出新元组时锚定输入元组。这是 bolt 表示“好的,我正在发出一个新元组,同时这里也有初始输入元组,这样你可以在这两个元组之间建立联系”的方式。
-
确保你的螺栓在完成处理一个输入元组时通知 Storm。这被称为确认,这是螺栓告诉 Storm 的方式,“嘿,Storm,我已经处理完这个元组了,所以请随意在元组树中将其标记为已处理。”
Storm 将拥有创建和跟踪元组树所需的一切。
有向无环图和元组树
虽然我们称之为元组树,但实际上它是一个有向无环图(DAG)。有向图是一组由边连接的节点集,其中边有方向。DAG 是有向图,你不能从一个节点开始,通过一系列边最终回到同一个节点。Storm 的早期版本只与树一起工作;尽管 Storm 现在支持 DAG,但术语“元组树”仍然存在。
在一个理想的世界里,你可以在这里停止,由喷发器发出的元组将始终被完全处理,没有任何问题。不幸的是,软件的世界并不总是理想的;你应该预料到失败。我们的元组也不例外,将在以下两种情况下被视为失败:
-
元组树中的所有叶子节点在特定时间内都没有被标记为已处理(已确认)。这个时间框架可以在拓扑级别通过
TOPOLOGYMESSAGETIMEOUT_SECS设置进行配置,默认为 30 秒。以下是如何在构建你的拓扑时覆盖此默认值:Config config = new Config(); config.setMessageTimeoutSecs(60);. -
在螺栓中手动失败一个元组,这会触发元组树的立即失败。
我们不断提到“元组树”这个词,所以让我们在我们的拓扑中遍历元组树的生命周期,以展示它是如何工作的。
与爱丽丝一起进入兔子洞...或者一个元组
图 4.5 从展示我们的喷发器发出一个元组后元组树的初始状态开始。我们有一个只有一个根节点的树。
图 4.5. 元组树的初始状态

流中的第一个螺栓是AuthorizeCreditCard螺栓。这个螺栓将执行授权并发出一个新的元组。图 4.6 显示了发出后的元组树。
图 4.6. 在AuthorizeCreditCard螺栓发出一个元组后,元组树

我们需要在AuthorizeCreditCard螺栓中确认输入元组,这样 Storm 才能将该元组标记为已处理。图 4.7 显示了执行此确认后的元组树。
图 4.7. 在AuthorizeCreditCard螺栓确认其输入元组后,元组树

一旦AuthorizeCreditCard螺栓发出一个元组,它就会到达ProcessedOrderNotification螺栓。这个螺栓不会发出元组,因此不会将元组添加到元组树中。但我们需要确认输入元组,因此告诉 Storm 这个螺栓已经完成了处理。图 4.8 显示了执行此确认后的元组树。此时,元组被认为是完全处理的。
图 4.8. 在ProcessedOrderNotification螺栓确认其输入元组后,元组树

在心中有一个清晰的元组树定义后,让我们继续讨论我们 bolt 中需要的锚定和确认的代码。我们还将讨论失败的元组和我们需要注意的各种错误类型。
4.4.2. 在我们的 bolt 中锚定、确认和失败元组
在我们的 bolt 中实现元组的锚定、确认和失败有两种方式:隐式和显式。我们之前提到,我们的 bolt 实现已经为保证消息处理而设置好了。这是通过隐式锚定、确认和失败来完成的,我们将在下面讨论。
隐式锚定、确认和失败
在我们的实现中,所有的 bolt 都扩展了BaseBasicBolt抽象类。使用BaseBasicBolt作为我们的基类的美妙之处在于,它自动为我们提供了锚定和确认。以下列表检查了 Storm 是如何做到这一点的:
-
锚定(Anchoring)—**在
BaseBasicBolt实现的execute()方法中,我们将发射一个元组传递给下一个 bolt。在发射的这个点上,提供的BasicOutputCollector将承担将输出元组锚定到输入元组的责任。在AuthorizeCreditCardbolt 中,我们发射订单。这个发出的订单元组将自动锚定到进入的订单元组:outputCollector.emit(new Values(order)); -
确认(Acking)—**当
BaseBasicBolt实现的execute()方法完成时,发送给它的元组将被自动确认。 -
失败(Failing)—**如果在
execute()方法中发生失败,处理方式是通过抛出FailedException或ReportedFailed-Exception来通知BaseBasicBolt。然后BaseBasicBolt将负责将该元组标记为失败。
使用BaseBasicBolt通过隐式锚定、确认和失败来跟踪元组状态很容易。但BaseBasicBolt并不适合每个用例。它通常只在单个元组进入 bolt 并且立即从这个 bolt 发出单个对应元组的用例中才有帮助。这就是我们的信用卡授权拓扑的情况,所以它在这里工作。但对于更复杂的例子,这还不够。这就是显式锚定、确认和失败发挥作用的地方。
显式锚定、确认和失败
当我们有一些 bolt 执行更复杂的任务时
-
在多个输入元组上聚合(折叠)
-
联合多个进入的流(我们不会在本章中介绍多个流,但在热图章节中,第三章,当我们除了默认流外还有一个 tick 元组流时,我们确实有两个流通过一个 bolt)
然后,我们将不得不超越 BaseBasicBolt 提供的功能。当行为可预测时,BaseBasicBolt 是合适的。当你需要程序性地决定一个元组批次何时完成(例如在聚合时)或在运行时决定何时将两个或多个流连接起来时,你需要程序性地决定何时锚定、确认或失败。在这些情况下,你需要使用 BaseRichBolt 作为基类而不是 BaseBasicBolt。以下列表显示了在扩展 BaseRichBolt 的 bolt 的实现中需要做什么:
-
锚定—**要显式锚定,我们需要在 bolt 的
execute方法中将输入元组传递到outputCollector的emit()方法:outputCollector.emit(new Values(order))变为outputCollector.emit(tuple, new Values(order))。 -
确认—**要显式确认,我们需要在 bolt 的
execute方法中调用outputCollector的ack方法:outputCollector.ack(tuple)。 -
失败—**这是通过在 bolt 的
execute方法中调用outputCollector的fail方法来实现的:throw new FailedException()变为outputCollector.fail(tuple);
虽然我们不能为所有用例使用 BaseBasicBolt,但我们可以使用 BaseRichBolt 来完成 BaseBasicBolt 可以做的所有事情,并且更多,因为它提供了更精细的控制,以确定何时以及如何锚定、确认或失败。我们的信用卡授权拓扑可以用 BaseBasicBolt 来表达,以实现所需的可靠性,但也可以用 BaseRichBolt 轻松编写。以下列表重写了我们的信用卡授权拓扑中的一个 bolt:
列表 4.5. 在 AuthorizeCreditCard.java 中的显式锚定和确认


有一个需要注意的事项是,使用 BaseBasicBolt 时,我们会在每次调用 execute() 方法时获得一个 BasicOutputCollector。但使用 BaseRichBolt 时,我们需要通过在 bolt 初始化时通过 prepare() 方法提供的 OutputCollector 来维护元组状态。BasicOutputCollector 是 OutputCollector 的简化版本;它封装了一个 OutputCollector,但通过一个更简单的接口隐藏了更精细的功能。
另一点需要注意的事项是,当使用 BaseRichBolt 时,如果我们没有将输出的元组锚定到输入的元组上,那么从那个点开始将不再有任何可靠性。BaseBasicBolt 代表你进行了锚定:
-
已锚定—
outputCollector.emit(tuple, new Values(order)); -
未锚定—
outputCollector.emit(new Values(order));
在讨论了锚定和确认之后,让我们继续讨论不那么直接的事情:处理错误。本身失败一个元组是容易的;知道何时可以重试错误则需要一些思考。
处理失败和知道何时重试
我们已经涵盖了与保证消息处理相关的许多概念。我们已经很好地掌握了锚定和确认。但我们还没有解决我们想要如何处理失败的问题。我们知道我们可以通过抛出FailedException/ReportedFailedException(当使用BaseBasicBolt时)或在OutputCollector上调用fail(当使用BaseRichBolt时)来使元组失败。让我们在AuthorizeCreditCardbolt 的上下文中看看这个问题,如下所示。我们只显示了execute()方法中包含显式失败的更改。
列表 4.6. 在AuthorizeCreditCard.execute()中锚定、确认和失败

以这种方式使元组失败将导致整个元组树从 spout 开始重放。这是保证消息处理的关键,因为这是重试机制的主要触发器。了解何时应该使元组失败很重要。这看起来很明显,但元组应该在它们是可重试的(可以重试)时失败。那么问题就变成了什么可以/应该重试。以下列表讨论了各种类型的错误:
-
已知错误— 这些可以分为两组:
-
可重试— 对于已知的特定可重试错误(例如,在连接到服务时发生的套接字超时异常),我们希望使元组失败,以便它被重放和重试。
-
不可重试— 对于已知无法安全重试的错误(如对 REST API 的 POST 请求)或者当重试没有意义时(如处理 JSON 或 XML 时的
ParseException),你不应该使元组失败。当你遇到这些不可重试的错误时,你不需要使元组失败,而是需要确认该元组(不发射新的元组),因为你不想触发它的重放机制。我们建议在这里进行某种形式的记录或报告,这样你将知道你的拓扑中出现了错误。 -
未知错误— 通常,未知或意外的错误将占观察到的错误的一小部分,因此通常会将它们失败并重试。在你看到它们一次之后,它们就变成了已知错误(假设有记录),你可以对它们采取行动,无论是作为可重试的还是不可重试的已知错误。
-
注意
在 Storm 拓扑中记录错误数据可能很有用,正如你在第六章中看到的,当我们讨论度量时。
这使得我们在 bolt 中关于锚定、确认和失败的讨论告一段落。现在,是时候转换方向,转向 spout。我们提到,当重放机制被激活时,重放从 spout 开始,并逐步进行。让我们看看它是如何工作的。
4.4.3. Spout 在保证消息处理中的作用
到目前为止,我们的焦点一直集中在我们在 bolts 中需要做什么来实现保证消息处理。本节将完成序列,并讨论 spout 在保证它发射的元组被完全处理或失败时重放所起的作用。下一个列表显示了来自 第二章 的 spout 接口。
列表 4.7. ISpout.java 接口

怎样让 spout 保证消息被处理?这里有一个提示:ack
和 fail
方法与此有关。以下步骤给出了一个更完整的图景,说明了在 spout 发射元组之前和该元组被完全处理或失败之后会发生什么:
-
Storm 通过在 spout 上调用
nextTuple
来请求元组。 -
spout 使用
SpoutOutputCollector将元组发射到其流中的一个。 -
当发射元组时,Spout 提供一个
messageId,用于识别该特定的元组。这看起来可能像这样:spoutOutputCollector.emit(tuple, messageId);. -
元组被发送到下游的 bolts,Storm 跟踪由这些消息创建的元组树。记住,这是通过 bolts 中的锚定和确认来完成的,以便 Storm 可以构建树并标记已处理的叶子。
-
如果 Storm 检测到元组已被完全处理,它将在原始 spout 任务上调用
ack
方法,并带有 spout 提供给 Storm 的消息 ID。 -
如果元组超时或消费 bolts 之一明确地失败了元组(例如在我们的
AuthorizeCreditCardbolt 中),Storm 将在原始 spout 任务上调用fail
方法,并带有 spout 提供给 Storm 的消息 ID。
步骤 3、5 和 6 是从 spout 视角保证消息处理的关键。一切始于发射元组时提供 messageId。不这样做意味着 Storm 无法跟踪元组树。如果需要,您应该在 ack 方法中添加代码以执行对完全处理元组所需的任何清理。您还应该在 fail 方法中添加代码以重放元组。
Storm 确认任务
Storm 使用特殊的“确认”任务来跟踪元组树,以确定 spout 元组是否已被完全处理。如果一个确认任务看到元组树是完整的,它将向最初发射元组的 spout 发送消息,导致该 spout 的 ack 方法被调用。
看起来我们需要编写一个支持所有这些标准的 spout 实现。在前一章中,我们介绍了不可靠数据源的概念。不可靠数据源将无法支持确认或失败。一旦该数据源将消息交给你的 spout,它就假设你已经承担了该消息的责任。另一方面,可靠数据源会将消息传递给 spout,但直到你提供了某种形式的确认,它才不会假设你已经承担了这些消息的责任。此外,可靠数据源将允许你失败任何给定的元组,并保证你以后能够重放它。简而言之,可靠数据源将支持步骤 3、5 和 6。
要展示可靠数据源的能力如何与 spout API 结合,最好的方式是使用一个常用的数据源来实现解决方案。Kafka、RabbitMQ 和 Kestrel 都是与 Storm 一起广泛使用的。Kafka 是基础设施工具箱中的一个宝贵工具,它与 Storm 配合得很好,我们将在第九章中详细介绍。现在我们选择使用 RabbitMQ,它非常适合我们的用例。
一个可靠的 spout 实现
让我们回顾一个基于 RabbitMQ 的 spout 实现,它将为我们提供这个用例所需的全部可靠性。[¹] 请记住,我们的主要兴趣不是 RabbitMQ,而是如何通过一个良好实现的 spout 和可靠数据源提供确保消息处理。如果你不熟悉 RabbitMQ 客户端 API 的底层,不要担心;我们在下一个列表中强调了你需要关注的重要部分。
¹ 你可以在 GitHub 上找到 RabbitMQ spout 实现的更健壮、可配置和性能更好的版本,网址为
github.com/ppat/storm-rabbitmq。
列表 4.8. RabbitMQSpout.java


Storm 为你提供了工具,确保你的 spout 发出的元组在 Storm 基础设施中传输过程中得到完全处理。但为了确保消息处理生效,你必须使用一个可靠的数据源,该数据源具有重放元组的能力。此外,spout 实现必须利用其数据源提供的重放机制。如果你想在拓扑中成功实现确保消息处理,理解这一点是至关重要的。
从 spout 发出锚定与非锚定元组
我们在前面章节中创建的拓扑没有充分利用保证消息处理或容错。我们可能在那章中使用了BaseBasicBolt,这可能会给我们带来隐式的锚定和确认,但那些章节中的元组并非来自可靠的数据源。由于这些数据源的不可靠性,当我们从 spout 发射元组时,它们是通过outputCollector.emit(new Values(order))发送的“未锚定”。如果你从 spout 开始不锚定输入元组,那么你不能保证它们会被完全处理。这是因为回放总是从 spout 开始的。因此,决定不锚定发射元组应该始终是一个有意识的决策,就像我们在热图示例中所做的那样。
我们现在准备编写一个健壮的拓扑并将其介绍给世界。我们已经涵盖了支持可靠性的三个拼图碎片:
-
一个具有相应可靠 spout 的数据源
-
一个锚定的元组流
-
一个在处理每个元组时确认或通知失败的情况的拓扑
在继续讨论第五章(chapter 5)以讨论拼图的最后一部分——Storm 集群——之前,让我们谈谈回放语义以及我们当前的拓扑实现是否足够满足我们的需求。
4.5. 回放语义
拼图中支持可靠性的每一个碎片都扮演着关键角色,如果我们想要构建一个健壮的拓扑,它们都是必要的。但是,当你考虑流在通过你的拓扑时的回放特性时,你将开始认识到,在事件处理方面,Storm 提供了不同级别的可靠性保证。当我们意识到我们的流满足不同的需求时,我们可以为可靠性分配不同的语义。让我们来看看这些不同级别的可靠性。
4.5.1. Storm 中的可靠性级别
与我们在第三章(chapter 3)中仔细检查数据流时看到的不同类型的扩展问题类似,当我们仔细检查我们的拓扑设计时,我们看到了不同级别的可靠性。在这里,我们确定了三个可靠性的级别:
-
至多一次处理
-
至少一次处理
-
精确一次处理
让我们进一步阐述我们所说的每个这些概念。
至多一次处理
当你想要保证没有任何单个元组被处理超过一次时,你会使用至多一次处理。在这种情况下,永远不会发生回放。如果它成功了,那很好,但如果它失败了,元组将被丢弃。无论如何,这种语义不提供所有操作都将被处理的可靠性保证,这是你可以选择的简单语义。我们在前面的章节中使用了至多一次处理,因为那些用例没有规定需要可靠性。我们可能在之前的章节中使用了BaseBasicBolt(具有自动锚定和确认),但我们没有在从 spout 首次发射元组时锚定元组。
在 Storm 中实现这种可靠性不需要做任何特殊的事情,但这并不是我们下一个可靠性级别的真实情况。
至少一次处理
当你想确保每个元组至少被成功处理一次时,可以使用至少一次处理。如果一个元组被重放多次,并且由于某种原因它成功超过一次,在这个重放语义下这是可以接受的。你主要关心的是它必须成功,即使这意味着要做重复的工作。
要在 Storm 中实现至少一次处理,你需要一个可靠的 spout 和一个可靠的数据源,以及一个带有已确认或失败元组的锚定流。这使我们达到了最严格的可靠性级别。
一次且仅一次处理
一次且仅一次处理与至少一次处理相似,因为它可以保证每个元组都成功处理。但一次且仅一次处理特别注意确保一旦元组被处理,它就再也不能被处理。
与至少一次处理一样,你需要一个可靠的 spout 和一个可靠的数据源,以及一个带有已确认或失败元组的锚定流。但使这个级别与至少一次处理区分开来的是,你还需要在 bolt(s)中添加逻辑来确保元组只被处理一次。
要理解每种类型处理对系统提出的要求,重要的是要了解从我们最严格的选项中产生的微妙之处和问题:即一次且仅一次。
4.5.2. 检查 Storm 拓扑中的一次且仅一次处理
在那个简单的短语一次且仅一次背后隐藏着很多复杂性。这意味着你必须能够知道你是否已经完成了一个工作单元,这反过来意味着你必须做以下事情:
-
做这个工作单元。
-
记录你已经完成了这个工作单元。
此外,这两个步骤必须作为一个原子操作执行——你不能先做工作然后未能记录结果。你需要能够在一步中完成工作并记录已经完成。如果你可以完成工作但在记录工作已完成之前出现故障,那么你实际上并没有一次且仅一次——你只有通常一次。绝大多数时候,工作只会做一次,但偶尔,它会被做多次。这是一个极其严格的资格要求。
至少一次处理与这两个步骤相同,但这些操作不需要原子性地发生。如果在执行工作单元期间或之后立即发生故障,可以重新做工作并重新尝试记录结果。如果重新做工作是不允许的,那么你需要添加一个重要要求:工作单元的最终结果必须是幂等的。一个动作如果是幂等的,那么在执行多次后,它对主题的第一次执行之后就没有额外的效果。例如:
-
“将 x 设置为 2”是一个幂等操作。
-
“将变量 x 加 2”不是一个幂等操作。
具有外部副作用(如发送电子邮件)的操作显然是非幂等的。重复该工作单元会发送多于一个的电子邮件,这肯定不是你想要做的。
如果你的工作单元是非幂等的,那么你必须回退到至多一次处理。你想要完成这个工作单元,但这个工作结果不被重复比实际完成工作更重要。
4.5.3. 检查我们的拓扑中的可靠性保证
我们如何在拓扑中提供更严格的可靠性程度?我们甚至需要这样做吗,或者我们已经处于足够好的状态?为了回答这些问题,识别我们的拓扑当前处于何种可靠性水平是有意义的。
确定当前的可靠性水平
我们拓扑中有哪种处理类型?我们有保证的消息处理,这样如果发生故障,我们会重试元组。这排除了至多一次作为我们的语义。这是好的。我们当然希望向人们收取我们为他们运输的货物的费用。
我们是具有精确一次语义还是至少一次语义?让我们来分析一下。我们的“工作单元”是向客户的信用卡收费并更新订单状态。这可以在以下列表中看到。
列表 4.9. 检查 AuthorizeCreditCard.java 的 execute() 方法

问题是这样的:这两个步骤是一个原子操作吗?答案是:不是。我们有可能向用户的信用卡收费,但不更新订单状态。在收费信用卡(
)和更改订单状态(
,
)之间,可能会发生一些事情:
-
我们的过程可能会崩溃。
-
数据库可能无法记录结果。
这意味着我们没有精确一次语义;我们有至少一次。从我们目前的状态来看,这是有问题的。重试一个元组可能导致向客户的卡重复收费。我们能做些什么来减少这种危险?我们知道精确一次对我们来说是不可能的,但我们应该能够使至少一次更安全。
在授权订单时提供更好的至少一次处理
在使我们的至少一次处理更安全时,我们想要问的第一个问题是我们的操作是否可以做成幂等的。答案可能是:不可以。我们需要外部信用卡服务的帮助。如果我们能提供订单 ID 作为唯一的交易标识符,并且服务会抛出一个错误,例如 DuplicateTransactionException,那么我们可以更新我们的记录以表明订单已准备好发货并继续处理。处理此类错误可以在以下列表中看到。
列表 4.10. 更新 AuthorizeCreditCard.java 以处理 DuplicateTransactionException

没有外部合作,我们能做得最好的是什么?如果我们的进程在向客户扣款和记录扣款之间崩溃,我们除了接受它有时会发生并准备以非技术方式(如客户服务响应退款请求)应对它之外,别无他法。从现实的角度来看,如果我们的系统稳定,这应该是一个相对罕见的事件。
对于“记录系统不可用”的情况,我们可以采取一种部分预防措施。在尝试扣款之前,我们可以验证存储更新订单状态的数据库是否可用。这种方法可以减少出现以下情况的可能性:我们扣款后,由于数据库故障而未能更新订单状态。
通常,这是一个好的做法。如果你在拓扑中计算非幂等结果,并将“完成”存储起来,那么在开始你的工作单元时,确保你将能够记录它。这个检查可以在下一个列表中看到。
列表 4.11. 更新AuthorizeCreditCard.java以在处理前检查数据库可用性

因此,我们已经提高了可靠性,但我们感觉我们还能做得更好。回顾我们的步骤,我们有以下内容:
-
授权信用卡。
-
更新订单状态。
-
通知外部系统变化。
看起来我们还需要做更多工作来解决步骤 3。
在所有步骤中提供至少一次处理。
如果我们成功完成了前两个步骤,但在执行第三个步骤时遇到故障怎么办?可能我们的进程崩溃了;可能我们的元组在通知外部系统之前超时了。无论如何,它已经发生了,Storm 将会重新播放该元组。那么我们如何应对这种场景呢?
在处理信用卡之前,我们应该确保记录系统可用(就像我们之前做的那样)并验证订单状态尚未“准备发货”。如果订单未准备发货,则按正常流程进行。这可能是我们第一次尝试此订单,数据库正在运行。如果订单已准备发货,那么我们可能在“更新订单状态”和“通知外部系统”步骤之间遇到了故障。在这种情况下,我们希望跳过再次扣款并直接通知外部系统变化。
如果我们控制这个外部系统,那么我们可以请求发送相同的订单多次,这是一个幂等操作,后续尝试将被丢弃。如果不这样做,我们之前遇到的关于信用卡处理缺乏幂等性的问题也适用。
我们的概念框架中的步骤有所改变;步骤 2 是新的:
-
从消息队列中取出订单。
-
确定订单是否已标记为“准备发货”,并执行以下两项操作之一:
-
如果订单已被标记为“准备发货”,则跳到步骤 6。
-
如果订单尚未标记为“准备好发货”,继续到步骤 3。
-
-
通过调用外部信用卡授权服务尝试授权信用卡。
-
如果服务调用成功,更新订单状态。
-
如果失败,我们可以在稍后重试。
-
通知一个单独的下游系统订单已被处理。
这些更新步骤在 图 4.9 中得到说明,新步骤被突出显示。
图 4.9. 电子商务信用卡授权流程的概念解决方案,增加了一步以提供更好的至少一次处理

我们可以将这个概念解决方案以几种方式映射到我们的拓扑中:
-
添加一个新的 bolt 来执行状态验证步骤。我们可以将其命名为
VerifyOrderStatus。 -
在
AuthorizeCreditCardbolt 中执行状态验证步骤。
我们将选择第二个选项并更新 AuthorizeCreditCard bolt 以执行验证步骤。我们将把添加新的 VerifyOrderStatus bolt 作为你的练习。以下列表显示了 AuthorizeCreditCard 的更新代码。
列表 4.12. 更新 AuthorizeCreditCard.java 以在处理前检查订单状态

就这样,我们完成了。或者,我们真的完成了吗?我们在这里遗漏了一些东西。即使“完成”只是意味着我们检查了订单是否准备好发货并且没有做任何事情,我们仍然需要在处理完订单后始终通知外部系统。这个更新的代码可以在下一个代码列表中看到;我们只需要在“处理”订单时发出一个元组。
列表 4.13. 更新 AuthorizeCreditCard.java 以在订单“处理”时发出元组

这使我们达到了一个我们感到舒适的解决方案。尽管我们还没有能够实现精确一次处理,但我们通过在 AuthorizeCreditCard bolt 中包含一些额外的逻辑,已经能够实现更好的至少一次处理。在设计具有可靠性要求的拓扑时,你应遵循此过程。你需要绘制出你的基本概念问题,然后确定你的语义,至少一次或最多一次。如果是至少一次,开始考虑所有可能失败的方式,并确保你解决了这些问题。
4.6. 摘要
在本章中,你学习了
-
在 Storm 中你可以达到的不同可靠程度
-
最多一次处理
-
至少一次处理
-
一次精确处理
-
-
不同的问题需要不同级别的可靠性,作为开发者,了解你的问题域的可靠性要求是你的工作。
-
Storm 通过四个主要部分支持可靠性:
-
一个对应可靠源头的可靠数据源
-
一个锚定的元组流
-
一个在处理每个元组时承认它或通知失败的网络拓扑
-
具有容错能力的 Storm 集群基础设施(将在下一章中讨论)
-
-
Storm 能够通过跟踪该元组的元组树来确定由 spout 发出的元组是否已完全处理。
-
为了使 Storm 能够跟踪元组树,你必须将输入元组锚定到输出元组,并确认任何输入元组。
-
通过超时或手动方式使元组失败将触发 Storm 的重试机制。
-
元组应该因为已知/可重试的错误和未知错误而失败。元组不应该因为已知/不可重试的错误而失败。
-
为了真正实现保证消息处理,spout 必须在连接到可靠数据源时显式处理和重试失败。
第五章. 从本地到远程拓扑结构的迁移
本章涵盖
-
Storm 集群
-
Storm 集群内的容错性
-
Storm 集群安装
-
在 Storm 集群上部署和运行拓扑结构
-
Storm UI 及其作用
想象以下场景。你被分配去实现一个 Storm 拓扑结构,用于对公司系统内记录的事件进行实时分析。作为一名负责任的开发者,你决定将这本书作为开发拓扑结构的指南。你使用了在第二章 chapters 2 中介绍的核心 Storm 组件来构建它。你在第三章 chapters 3 中学习到的拓扑设计模式的基础上,确定每个 bolt 应该包含什么逻辑,并遵循第四章 chapters 4 中的步骤,为进入你的拓扑结构的所有元组提供至少一次处理。你准备将拓扑结构连接到接收日志事件的队列,并让它正常运行。接下来你该做什么?
你可以在本地运行你的拓扑结构,就像在第二章 chapters 2、第三章 chapters 3 和第四章 chapters 4 中所做的那样,但这样做并不能扩展到你期望的数据量和速度。你需要能够将你的拓扑结构部署到专为处理生产级数据而构建的环境中。这就是“远程”(也称为“生产”)Storm 集群发挥作用的地方——一个专为处理生产级数据需求而构建的环境。
注意
正如你在第一章 chapters 1 中所学的,容量指的是进入你系统的数据量,速度指的是这些数据流经你系统的速度。
到目前为止,我们在单个进程中运行拓扑结构并模拟 Storm 集群已经满足了我们的需求,这对于开发和测试目的很有用。但本地模式不支持在第三章 chapters 3 中讨论的扩展性,也不支持我们在第四章 chapters 4 中了解到的第一类保证处理。实际上的 Storm 集群对于这两者都是必需的。
本章将首先解释 Storm 集群的各个部分及其所扮演的角色,随后将进行关于 Storm 如何提供容错性的问答环节。然后,我们将继续介绍如何安装 Storm 集群,并在安装的集群上部署和运行你的拓扑结构。我们还将介绍一个重要的工具,你可以使用它来确保你的拓扑结构健康:Storm UI。在这个过程中,我们将预览到将在第六章 chapters 6 和第七章 chapters 7 中涉及到的调整和故障排除主题。
所有这一切都始于 Storm 集群,因此让我们从第三章 chapters 3 中讨论的工作节点讨论展开。
5.1. Storm 集群
第三章(kindle_split_011.html#ch03)简要介绍了工作节点及其运行 JVM 的方式,而 JVM 又运行执行器和任务。在本节中,我们将深入探讨,从 Storm 集群的整体结构开始。一个 Storm 集群由两种类型的节点组成:主节点和工作节点。主节点运行一个名为 Nimbus 的守护进程,而工作节点每个运行一个名为 Supervisor 的守护进程。图 5.1 展示了包含一个主节点和四个工作节点的 Storm 集群。Storm 只支持单个主节点,而你的集群可能根据需要拥有不同数量的工作节点(我们将在第六章和第七章中介绍如何确定这个数量)。
图 5.1. Nimbus 和 Supervisor 及其在 Storm 集群内的责任

主节点可以被视为控制中心。除了图 5.1 中列出的责任外,这里还可以运行 Storm 集群中可用的任何命令——例如activate、deactivate、rebalance或kill(本章后面将详细介绍这些命令)。工作节点是 Spout 和 Bolt 中的逻辑执行的地方。
Storm 集群的另一个重要部分是Zookeeper。Storm 依赖于 Apache Zookeeper^([1])来协调 Nimbus 和 Supervisor 之间的通信。任何需要协调 Nimbus 和 Supervisor 之间的状态都保存在 Zookeeper 中。因此,如果 Nimbus 或 Supervisor 出现故障,一旦它们恢复,就可以从 Zookeeper 中恢复状态,使 Storm 集群像什么都没发生一样继续运行。
图 5.2 展示了集成到 Storm 集群中的 Zookeeper 节点集群。我们已从该图中移除工作进程,以便你可以专注于 Zookeeper 如何作为 Nimbus 和 Supervisor 之间通信协调者的位置。
图 5.2. Zookeeper 集群及其在 Storm 集群中的作用

在本书的剩余部分,每次提到“Storm 集群”时,我们指的是主节点、工作节点和 Zookeeper 节点。
尽管主节点和 Zookeeper 是 Storm 集群的重要组成部分,但我们将暂时将焦点转移到工作节点上。工作节点是 Spout 和 Bolt 处理发生的地方,因此它们是我们第六章和第七章中许多调整和故障排除工作的中心。
注意
第六章和第七章将解释你何时可能想要增加工作节点上运行的工作进程数量,以及何时以及如何达到收益递减的点。这些章节还将讨论工作进程内的调整,因此解释工作进程的各个部分是有意义的。
5.1.1. 工作节点的结构
如前所述,每个工作节点都有一个负责管理工作进程并保持其运行状态的守护进程。如果守护进程注意到某个工作进程已关闭,它将立即重启它。那么,工作进程究竟是什么呢?我们提到它是一个 JVM,但正如你在第三章 中所知,它还有更多内容。
每个工作进程执行拓扑的一部分。这意味着每个工作进程属于特定的拓扑,并且每个拓扑将在一个或多个工作进程中运行。通常,这些工作进程在 Storm 集群中的多台机器上运行。
在第三章中,你学习了关于执行器(线程)和任务(数据源/螺栓的实例)的内容。我们讨论了工作进程(JVM)如何运行一个或多个执行器(线程),每个线程执行一个或多个数据源/螺栓的实例(任务)。图 5.3 说明了这个概念。
图 5.3. 工作进程由一个或多个执行器组成,每个执行器由一个或多个任务组成。

这里是一些关键要点:
-
工作进程是一个 JVM。
-
执行器是 JVM 中的一个执行线程。
-
任务是在 JVM 执行线程中运行的数据源或螺栓的实例。
理解这些映射对于调整和故障排除非常重要。例如,第六章 回答了为什么你可能希望每个执行器有多个任务等问题,因此理解执行器与其任务之间的关系是至关重要的。
为了使关于工作节点、工作进程、执行器和任务的讨论完整,让我们在第四章中提到的信用卡授权拓扑的背景下介绍它们。
5.1.2. 在信用卡授权拓扑的背景下介绍工作节点
在本节中,我们将展示一个假设的信用卡授权拓扑配置,以帮助你在图中的工作进程、执行器和任务数量与实现它们的代码之间建立联系。这个假设配置可以在图 5.4 中看到。

图 5.4 中的设置将通过以下列表中的代码实现。
列表 5.1. 我们假设的 Storm 集群配置

当我们在Config中设置numWorkers时,我们正在配置运行此拓扑所需的 worker 进程。我们实际上并没有强迫两个 worker 进程最终都运行在同一个 worker 节点上,如图 5.4 所示。图 5.4。Storm 会根据集群中哪些 worker 节点有空闲槽位来决定它们最终的位置。
并行性 vs. 并发性:有什么区别?
并行性是指两个线程同时执行。并发性是指至少有两个线程在某种计算上取得进展。并发性不一定意味着两个线程是同时执行的——可能使用时间切片等技术来模拟并行性。
在重新审视了工作节点故障分解之后,让我们看看 Storm 是如何在集群的各个部分提供容错性的。
5.2. Storm 集群内的快速失败容错哲学
记得在第四章中讨论的可靠性难题的四个部分吗?
-
一个具有相应可靠 spout 的可靠数据源
-
一个锚定的元组流
-
一个在处理每个元组时确认或通知失败的拓扑
-
一个具有容错能力的 Storm 集群基础设施
我们终于到了讨论最后一部分的时候了,那就是具有容错能力的 Storm 集群基础设施。Storm 集群的组件在设计时已经考虑了容错性。解释 Storm 如何处理容错性最简单的方法就是以“当x发生时,Storm 会做什么?”的形式回答问题。关于容错性的最重要问题在表 5.1 中得到了解答。
表 5.1. 容错性问题及答案
| 问题 | 答案 |
|---|---|
| 如果一个工作节点挂了? | Supervisor 会重启它,并将新的任务分配给它。所有在死亡时未完全确认的元组将由 spout 完全重放。这就是为什么 spout 需要支持重放(可靠的 spout)并且数据源也需要是可靠的(支持重放)。 |
| 如果一个工作节点连续启动失败? | Nimbus 会将任务重新分配给另一个工作节点。 |
| 如果运行 worker 节点的一台实际机器挂了? | Nimbus 会将该机器上的任务重新分配给健康的机器。 |
| 如果 Nimbus 挂了? | 因为 Nimbus 是在监控下运行的(使用 daemontools 或 monit 等工具),它应该会自动重启并继续处理,就像什么都没发生一样。 |
| 如果一个 Supervisor 挂了? | 因为 Supervisor 也是在监控下运行的(使用 daemontools 或 monit 等工具),它们应该会像什么都没发生一样自动重启。 |
| Nimbus 是否是单点故障? | 不一定。Supervisor 和 worker 节点将继续处理,但你将失去将 worker 重新分配到其他机器或部署新拓扑的能力。 |
你可以看到,Storm 在保持快速失败哲学方面做得很好,即这个基础设施中的每一部分都可以重启,并将重新校准自己继续运行。如果在失败过程中元组处于中间状态,它们将自动失败。
无论失败的单元是实例(任务)、线程(执行器)、JVM(工作进程)还是 VM(工作节点),在每个层面上都设有安全措施,以确保一切都能自动重启(因为所有内容都在监督下运行)。
我们已经讨论了 Storm 集群在并行性和容错性方面的优势。你是如何着手搭建并运行这样一个集群的呢?
5.3. 安装 Storm 集群
Storm 维基对如何设置 Storm 集群做了很好的描述。维基上找到的步骤包括以下内容:
-
查找有关设置 Zookeeper 的信息,以及一些有关维护 Zookeeper 集群的实用技巧。
-
在主节点和工作节点上安装所需的 Storm 依赖项。
-
将 Storm 发布版本下载并解压到主节点和工作节点上。
-
通过 storm.yaml 文件配置主节点和工作节点。
-
使用 Storm 脚本在监督下启动 Nimbus 和监督者守护进程。
我们将在下一部分更详细地介绍这些步骤。
注意
在监督下运行一个进程意味着什么?这意味着某个监督进程管理着正在运行的进程。因此,如果被“监督”的进程失败,监督进程可以自动重启失败的进程。这是在 Storm 中提供容错性的关键要素。
5.3.1. 设置 Zookeeper 集群
设置 Zookeeper 集群的步骤超出了本书的范围。你可以在 Apache Zookeeper 项目页面上找到如何安装 Zookeeper 的详细解释:zookeeper.apache.org。遵循这些步骤来搭建并运行你的集群。
在运行 Zookeeper 时请注意以下几点:
-
Zookeeper 被设计为“快速失败”,这意味着如果发生它无法恢复的错误,它将关闭。在 Storm 集群中这并不理想,因为 Zookeeper 协调 Nimbus 和监督者之间的通信。因此,我们必须有一个监督进程来管理 Zookeeper 实例,以便如果 Zookeeper 实例发生故障,整个集群可以继续处理请求。监督进程将处理重启任何失败的 Zookeeper 服务器,使 Zookeeper 集群能够自我修复。
-
由于 Zookeeper 是一个长期运行的过程,其事务日志可能会变得相当大。这最终会导致 Zookeeper 运行空间耗尽。因此,设置某种类型的进程来压缩(甚至存档)这些日志中产生的数据至关重要。
5.3.2. 在主节点和工作节点上安装所需的 Storm 依赖项
下一步是将所需的 Storm 依赖项安装到您为运行 Nimbus 和监督者而指定的机器上。表 5.2 列出了这些依赖项。
表 5.2. Storm 主节点和工作节点的外部依赖
| 依赖项 | 为什么需要它 | 下载链接 |
|---|---|---|
| Java 6+ | Storm 在 JVM 上运行,最新的 Storm 版本在 Java 6 上运行。 | www.oracle.com/us/technologies/java/overview/index.html |
| Python 2.6.6 | Storm 的标准命令行工具是围绕 Java 包装的 Python。 | www.python.org/downloads/ |
一旦将所需的依赖项安装到托管 Nimbus 和监督者的每台机器上,您就可以将这些机器安装 Storm。
5.3.3. 在主节点和工作节点上安装 Storm
目前 Storm 安装可以在 storm.apache.org/downloads.html 找到。对于本书,我们使用了 apache-storm-0.9.3。您应该将 Storm 版本的 zip 文件下载到每个节点,并在每台机器上的某个位置提取 zip 文件的内容。位置由您决定;例如,/opt/storm 是一个示例。图 5.5 显示了在 /opt/storm 目录中提取的内容。
图 5.5. Storm 版本 zip 文件的提取内容

本图中有两个文件是我们本章特别感兴趣的:/opt/storm/bin/storm 和 /opt/storm/conf/storm.yaml。接下来让我们讨论 storm.yaml 及其用途。
5.3.4. 通过 storm.yaml 配置主节点和工作节点
Storm 版本包含一个 conf/storm.yaml 文件,用于配置 Storm 守护进程。此文件覆盖了在 defaults.yaml 中找到的配置设置。2 您可能希望覆盖至少一些值;许多默认值将机器名指向“localhost”。表 5.3 列出了您可能想要覆盖的一些初始配置选项,以便使您的 Storm 集群启动并运行。
² 您可以在
github.com/apache/storm/blob/master/conf/defaults.yaml找到 defaults.yaml。
表 5.3. 您可能想要覆盖的 storm.yaml 属性,以用于您的 Storm 安装
| 属性 | 描述 | 默认值 |
|---|---|---|
| storm.zookeeper.servers | 为您的 Storm 集群指定的 Zookeeper 集群中的主机列表。 | storm.zookeeper.servers: - "localhost" |
| storm.zookeeper.port | 如果您的 Zookeeper 集群使用的端口与默认端口不同,则需要此端口。 | storm.zookeeper.port: 2181 |
| storm.local.dir | Nimbus 和 Supervisor 守护进程将用于存储少量状态信息的目录。您必须在将运行 Nimbus 和 worker 的每台机器上创建这些目录并赋予它们适当的权限。 | storm.local.dir: "storm-local" |
| java.library.path | Java 安装的位置。 | java.library.path: "/usr/local/lib:/opt/local/lib:/usr/lib" |
| nimbus.host | Nimbus 机器的主机名。 | nimbus.host: "localhost" |
| supervisor.slots.ports | 对于每个工作机器,用于接收消息的端口。可用的端口号数量将决定 Storm 在每个工作机器上运行的 worker 进程数量。 | supervisor.slots.ports: – 6700
– 6701
– 6702
– 6703 |
您需要更新集群中每个节点的配置。如果您有一个包含多个工作节点的集群,这样做可能会变得繁琐。因此,我们建议使用像 Puppet^([3])这样的外部工具来自动化每个节点的部署和配置。
5.3.5. 在监督模式下启动 Nimbus 和 Supervisors
如前所述,在监督模式下运行守护进程是设置 Storm 集群的关键步骤。监督进程使我们的系统具有容错能力。这究竟意味着什么?为什么需要这样做?
Storm 是一个快速失败的系统,这意味着任何遇到意外错误的 Storm 进程都会停止。Storm 被设计成任何进程都可以在任何点安全停止,并在进程重启时恢复。在监督模式下运行这些进程允许它们在发生故障时重新启动。因此,您的拓扑不受 Storm 守护进程故障的影响。要在监督模式下运行 Storm 守护进程,请执行以下命令:
-
启动 Nimbus— 在主机器上以监督模式运行
bin/storm nimbus。 -
启动 supervisors— 在每个工作机器上以监督模式运行
bin/storm supervisor。 -
Storm UI— 在主机器上以监督模式运行
bin/storm ui。
运行 Storm 守护进程是设置 Storm 集群的最后一步。当一切正常运行时,您的集群准备开始接受拓扑。让我们看看您如何将拓扑运行在 Storm 集群上。
5.4. 将您的拓扑运行在 Storm 集群上
在前面的章节中,我们已经在本地运行了我们的拓扑。这种方法对于学习 Storm 的基本原理是可行的。但如果你想获得 Storm 提供的益处(尤其是在保证消息处理和并行性方面),则需要一个远程的 Storm 集群。在本节中,我们将通过从第四章中的信用卡授权拓扑中提取一些代码,并执行以下操作来展示如何做到这一点:
-
重新审视连接拓扑组件的代码
-
展示在本地模式下运行该拓扑的代码
-
展示在远程 Storm 集群上运行该拓扑的代码
-
展示如何打包和部署该代码到远程 Storm 集群
5.4.1. 重新审视如何组合拓扑组件
在我们深入到在本地模式和远程集群上运行拓扑的代码之前,让我们快速回顾一下从第四章,信用卡授权拓扑,连接拓扑组件的代码,以提供一些背景。我们已经在第 5.1.2 节中展示了一些这段代码,但接下来的列表以更结构化的格式展示了它。
列表 5.2. 用于构建信用卡授权拓扑的CreditCardTopologyBuilder.java
public class CreditCardTopologyBuilder {
public static StormTopology build() {
TopologyBuilder builder = new TopologyBuilder();
builder.setSpout("rabbitmq-spout", new RabbitMQSpout(), 1);
builder.setBolt("check-status", new VerifyOrderStatus(), 1)
.shuffleGrouping("rabbitmq-spout")
.setNumTasks(2);
builder.setBolt("authorize-card", new AuthorizeCreditCard(), 1)
.shuffleGrouping("check-status")
.setNumTasks(2);
builder.setBolt("notification", new ProcessedOrderNotification(), 1)
.shuffleGrouping("authorize-card")
.setNumTasks(1);
return builder.createTopology();
}
}
我们将构建拓扑的代码封装在CreditCardTopologyBuilder.java中,因为这段代码不会改变,无论我们是在本地模式还是在 Storm 集群上运行。这是我们从第三章开始做的事情,这种方法的优点是它允许我们从多个地方调用构建拓扑的代码,而无需重复代码。
现在我们已经有了构建拓扑的代码,我们将向您展示如何运行这个构建好的拓扑。
5.4.2. 在本地模式下运行拓扑
本地模式在开发拓扑时很有用。它允许您在本地机器上模拟一个 Storm 集群,这样您就可以快速开发和测试您的拓扑。这提供了在代码更改和在实际运行拓扑中测试该更改之间快速周转的好处。尽管如此,本地模式也有一些缺点:
-
您无法实现与远程 Storm 集群相同的并行性。这使得在本地模式下测试并行性更改变得困难,甚至可能不可能。
-
当 Nimbus 尝试将 spouts 和 bolts 的实例序列化到各个工作节点时,本地模式不会揭示潜在的序列化问题。
下面的列表展示了具有main()方法的LocalTopologyRunner类,该方法接受我们在列表 5.2 中构建的拓扑并在本地运行它。
列表 5.3. 运行本地集群上拓扑的LocalTopologyRunner.java

列表中的代码应该对您来说很熟悉。我们尚未解决的是将拓扑提交到远程 Storm 集群的代码。幸运的是,这段代码并没有太大的不同。让我们看看。
5.4.3. 在远程 Storm 集群上运行拓扑
在远程运行您的拓扑的代码与本地运行类似。唯一的区别是提交拓扑到集群的代码。您可能还需要不同的配置,因为本地模式不支持远程集群支持的一些功能(并行性和保证消息处理)。下一个列表展示了这段代码在一个我们称之为RemoteTopologyRunner的类中。
列表 5.4. RemoteTopologyRunner,用于将拓扑提交到远程集群

您会注意到唯一的区别是配置略有不同,使用 StormSubmitter.submitTopology 而不是 LocalCluster.submitTopology。
注意
我们将我们的拓扑的构建、本地运行和远程运行封装在三个不同的类中(CreditCardTopologyBuilder、LocalTopologyRunner 和 RemoteTopologyRunner)。虽然您可以按照您喜欢的任何方式设置代码,但我们发现这种分割效果很好,并且我们在所有拓扑中都使用它。
现在我们已经编写了在远程集群上运行拓扑的代码,让我们将注意力转移到将代码物理上传到 Storm 集群以便运行。
5.4.4. 将拓扑部署到远程 Storm 集群
“将拓扑部署到 Storm 集群”是什么意思?通过“部署”,我们指的是将包含拓扑编译代码的 JAR 文件物理复制到集群中,以便运行。您需要从配置了 Storm 的机器上部署您的拓扑。图 5.6 提供了 Storm 发布版 zip 文件提取内容的复习。
图 5.6. Storm 发布版 zip 文件提取的内容

您需要确保更新 /opt/storm/conf/storm.yaml 文件,以便将 nimbus.host 属性设置为正确位置。我们对此步骤中的 /opt/storm/bin/storm 文件也很感兴趣:这是您将运行以将拓扑 JAR 部署到远程集群的可执行文件。图 5.7 显示了您将运行的部署拓扑的命令。您会注意到在图中,我们通过 /opt/storm/bin/storm 引用了 storm 可执行文件的全路径。如果您不想这样做,将 /opt/storm/bin 添加到您的 PATH 中,您可以从机器上的任何位置直接引用 storm 命令。
图 5.7. 将拓扑部署到 Storm 集群的命令

执行 图 5.7 中的命令后,您的拓扑将在 Storm 集群上启动并运行。一旦您的拓扑开始运行,您如何知道它实际上正在按预期工作并处理数据?这就是您需要查看 Storm UI 的地方,接下来我们将讨论 Storm UI。
5.5. Storm UI 及其在 Storm 集群中的作用
Storm UI 是查找 Storm 集群和单个拓扑诊断的中心位置。如 5.3.5 节 中所述,在 Nimbus 上运行命令 /bin/storm ui 将启动 Storm UI。defaults.yaml 中的两个属性影响 Storm UI 的查找位置:
-
nimbus.host— Nimbus 机器的主机名 -
ui.port— 用来提供 Storm UI 的端口号(默认为 8080)
一旦运行起来,请在网页浏览器中输入 http://{nimbus.host}:{ui.port} 以访问 Storm UI。
Storm UI 有几个部分:
-
集群摘要屏幕
-
单个拓扑摘要屏幕
-
每个喷发器和螺栓的屏幕
每个屏幕都显示了与 Storm 集群不同部分相关的信息,具有不同粒度级别。集群概要屏幕与整个 Storm 集群相关,如图 5.8 所示。
图 5.8. 集群概要屏幕显示了整个 Storm 集群的详细信息。

点击特定的拓扑链接(如图 5.8 中的 github-commit-count)将带您进入拓扑概要屏幕。此屏幕显示与特定拓扑相关的信息,如图 5.9 所示。
图 5.9. 拓扑概要屏幕显示了特定拓扑的详细信息。

让我们更详细地探讨每个屏幕。
5.5.1. Storm UI:Storm 集群概要
Storm 集群概要由四个部分组成,如图 5.10 所示。
图 5.10. Storm UI 的集群概要屏幕

本屏幕的每个部分将在以下部分中更详细地解释。
集群概要
集群概要部分提供了一个小但有用的集群概览。你会在图 5.11 中注意到术语槽位。一个槽位对应一个工作进程,因此有两个槽位被使用的集群意味着该集群上有两个工作进程正在运行。图 5.11 提供了本节中每个列的更多详细信息。
图 5.11. Storm UI 的集群概要屏幕上的集群概要部分

拓扑概要
拓扑概要列出了部署到集群的所有拓扑。图 5.12 提供了本节中你看到的信息的更多详细信息。
图 5.12. Storm UI 的集群概要屏幕上的拓扑概要部分

管理员概要
管理员概要列出了集群中的所有管理员。同样,你会在图 5.13 中注意到术语槽位。这对应于特定管理员节点上的一个工作进程。图 5.13 提供了本节中你看到的信息的更多详细信息。
图 5.13. Storm UI 的集群概要屏幕上的管理员概要部分

Nimbus 配置
Nimbus 配置列出了在 defaults.yaml 中定义的配置以及在 storm.yaml 中覆盖的任何值。图 5.14 提供了本节中你看到的信息的更多详细信息。
图 5.14. Storm UI 的集群概要屏幕上的 Nimbus 配置部分

在介绍了集群概要屏幕后,让我们深入了解单个拓扑的屏幕看起来是什么样子。您可以通过点击拓扑列表中的给定拓扑名称来访问此屏幕。
5.5.2. Storm UI:单个拓扑概要
单个拓扑概要屏幕的部分可以在图 5.15 中看到。
图 5.15。Storm UI 拓扑摘要屏幕

下面的章节将更详细地解释此屏幕的每个部分。
拓扑摘要
拓扑摘要提供了对正在观察的拓扑的简要但有用的概述。图 5.16 提供了关于此部分每个单独列的更多细节。
图 5.16。Storm UI 拓扑摘要屏幕上的拓扑摘要部分

拓扑操作
拓扑操作部分提供了一个用户界面来激活、停用、重新平衡和终止您的拓扑。图 5.17 更详细地描述了这些操作。
图 5.17。Storm UI 拓扑摘要屏幕上的拓扑操作部分

拓扑统计
拓扑统计部分在拓扑级别提供了一些通用统计数据。这些统计数据可以显示为所有时间、过去 10 分钟、过去 3 小时或过去一天。所选的时间间隔也应用于喷发器和螺栓统计部分,这些部分将在下面描述。图 5.18 提供了关于此部分看到的信息的更多细节。
图 5.18。Storm UI 拓扑摘要屏幕上的拓扑统计部分

喷发器统计
喷发器部分显示了拓扑中所有喷发器的统计数据。这些统计数据是拓扑统计部分所选时间窗口内的(所有时间、过去 10 分钟、过去 3 小时或过去一天)。图 5.19 提供了关于此部分看到的信息的更多细节。
图 5.19。Storm UI 拓扑摘要屏幕上的喷发器部分

螺栓统计
螺栓部分显示了拓扑中所有螺栓的统计数据。这些统计数据是拓扑统计部分所选时间窗口内的(所有时间、过去 10 分钟、过去 3 小时或过去一天)。图 5.20 提供了直到容量列的更多细节。图 5.21 提供了剩余列的更多细节。
图 5.20。Storm UI 拓扑摘要屏幕上的螺栓部分,直到容量列

图 5.21。Storm UI 拓扑摘要屏幕上的螺栓部分,剩余列

拓扑配置
拓扑配置列出了正在查看的特定拓扑中定义的配置。图 5.22 提供了关于您在此部分看到的信息的更多细节。
图 5.22。Storm UI 拓扑摘要屏幕上的拓扑配置部分

从拓扑摘要屏幕,您可以深入了解单个喷发器或螺栓。在拓扑摘要屏幕上,通过单击喷发器或螺栓名称来访问单个喷发器或螺栓。
5.5.3。Storm UI:单个喷发器/螺栓摘要
在 UI 中,一个单独的 bolt 包含六个部分,如图 5.23 所示。
图 5.23. Storm UI 中的 bolt 摘要屏幕

组件摘要
组件摘要部分显示了被观察的 bolt 或 spout 的一些高级信息。图 5.24 提供了更多细节。
图 5.24. Storm UI 中 bolt 的组件摘要部分

Bolt 统计
Bolt 统计部分提供了与拓扑摘要中 Bolts 部分所看到的大部分相同的信息,但信息仅限于单个 bolt(见图 5.25)。
图 5.25. Storm UI 中的 bolt 统计部分

输入统计
输入统计部分显示了 bolt 正在消费的元组的统计信息。这些统计信息与特定的流相关;在这种情况下是“默认”流。图 5.26 对此部分进行了更详细的说明。
图 5.26. Storm UI 中 bolt 的输入统计部分

输出统计
输出统计部分显示了 bolt 发出的元组的统计信息(见图 5.27)。
图 5.27. Storm UI 中 bolt 的输出统计部分

Executors
Executors 部分显示了运行特定 bolt 实例的所有 executors 的统计信息。我们将此部分分为两个图。显示了第一部分,图 5.29 显示了第二部分。
图 5.28. Storm UI 中 bolt 的 Executors 部分,通过容量列

图 5.29. Storm UI 中 bolt 的 Executors 部分,剩余列

错误
错误部分显示了此 bolt 所经历的错误历史,如图 5.30 所示。
图 5.30. Storm UI 中 bolt 的错误部分

Storm UI 提供了丰富的信息,使你能够清楚地了解你的拓扑在生产中的运行情况。使用 Storm UI,你可以快速判断你的拓扑是否健康,或者是否有问题。你应该能够轻松地发现你的拓扑遇到的问题,同时也能够快速识别其他问题,例如瓶颈。
如你所能想象,一旦你将你的拓扑部署到生产 Storm 集群中,你的开发者工作并没有结束。一旦部署,你将进入一个全新的世界,确保你的拓扑尽可能高效地运行。这就是调优和故障排除的世界。我们将在接下来的两章中专注于这些任务。
本章通过解释 Storm 集群的各个部分以及每个部分的作用,为调优奠定了基础。我们还详细解释了你在调优和故障排除过程中将使用的首选工具:Storm UI。
5.6. 摘要
在本章中,你学习了以下内容:
-
Storm 集群由 Nimbus 组成,它作为控制中心,以及执行 spouts 和 bolts 实例中的逻辑的管理员组成。
-
运行 Storm 集群的同时需要有一个 Zookeeper 集群,因为它在 Nimbus/管理员之间协调通信,同时维护状态。
-
管理员运行工作进程(JVMs),这些工作进程再运行执行器(线程)和任务(spouts/bolts 的实例)。
-
如何安装一个 Storm 集群,包括必须设置的键配置选项,以便使集群运行。
-
如何将你的拓扑部署到 Storm 集群,以及如何在集群上运行它们实际上与本地运行没有太大区别。
-
Storm UI 是什么,以及 Storm 生态系统的不同部分如何映射到 Storm UI 的不同屏幕。
-
Storm UI 的每个部分提供什么信息,以及这些信息如何有助于调整和调试你的拓扑。
第六章:Storm 中的调优
本章涵盖
-
调优 Storm 拓扑
-
处理 Storm 拓扑中的延迟
-
使用 Storm 内置的指标收集 API
到目前为止,我们已经尽可能地为您提供了对 Storm 概念的温和介绍。现在是时候提高难度了。在本章中,我们将讨论在你将拓扑部署到 Storm 集群后作为开发者的生活。你以为拓扑部署后你的工作就结束了,不是吗?再想想!一旦你部署了拓扑,你需要确保它尽可能高效地运行。这就是为什么我们投入了两个章节来讨论调优和故障排除。
我们将简要回顾 Storm UI,因为这将是你用来确定你的拓扑是否运行高效的最重要的工具。然后我们将概述一个可重复的过程,你可以使用它来识别瓶颈并解决这些瓶颈。我们的调优课程并没有结束——我们还需要讨论快速代码的最大敌人之一:延迟。我们将通过介绍 Storm 的指标收集 API 以及介绍我们自己的几个自定义指标来结束讨论。毕竟,确切地知道你的拓扑在做什么是理解如何使其更快的重要部分。
注意
在本章中,我们在运行调优示例时让你从 GitHub 检查源代码。要检查此代码,请运行以下命令:git checkout [tag],将[tag]替换为我们指定的代码版本。GitHub 存储库位于github.com/Storm-Applied/C6-Flash-sale-recommender。
在我们深入探讨这些主题之前,让我们通过一个将在本章中作为示例案例的使用场景来设定场景:每日特价!重生。
6.1. 问题定义:每日特价!重生
下面是这个故事的来龙去脉。我们为一家新兴的闪购网站工作。每天,我们都会在短时间内推出一些商品,并观察流量涌入。随着时间的推移,每天的销售数量一直在增长,对于客户来说,找到他们感兴趣的销售变得越来越困难。我们公司另一个团队已经建立了一个在线的“找到我的销售!”推荐系统。“找到我的销售!”会缩小客户可能感兴趣的产品数量。它从客户提供的某些基本信息开始,但也结合了购买历史、浏览历史等等,试图将客户最可能感兴趣的销售放在他们面前。我们的网站通过 HTTP API 与该系统交互,我们传递一个客户标识符,然后返回一个推荐标识符列表。然后我们可以回过头来查找这些销售的详细信息,并在网站上向客户展示。
这对公司来说是一大福音,并帮助推动了卓越的增长。同时,我们有一个过时的“每日特价!”电子邮件,它从公司早期关于即将到来的销售的信息中幸存下来。最初,每封电子邮件只有一个销售信息非常有效。最终,它被改为每天在我们的客户收件箱中获取一个合理的即将到来的销售信息。随着时间的推移,电子邮件的有效性有所下降。早期的测试表明,问题是电子邮件的内容已经不再相关。每天都有很多销售,简单的启发式方法没有挑选出高度相关的销售信息发送;它只挑选出适度相关的销售信息。
我们被分配了一个新的任务:制作一封电子邮件来替代每日特价活动!这封电子邮件将在每天发送给客户,包含下一天 Find My Sales!系统标记为对客户感兴趣的销售信息。我们希望使用 Find My Sale!系统来提高相关性和点击率,以及最终在网站上的销售额。尽管如此,还有一些需要注意的地方。Find My Sale!是一个纯在线系统,目前推荐的销售额与其外部 HTTP 接口有些混乱。在我们考虑重写它之前,我们希望验证我们的想法,即更相关的每日特价活动电子邮件将对业务产生重大影响(团队中的一些成员认为当前的电子邮件已经足够好,提高相关性不会导致流量有大幅增长)。
6.1.1. 构建概念解决方案
我们着手设计一个处理电子邮件创建的解决方案。它消费客户信息的输入流,并实时调用 Find My Sale!以找到任何即将到来的闪购活动,这些活动可能会引起客户的兴趣。(我们不得不稍微修改 Find My Sale!——通常它只考虑活跃的销售,但我们已经将其修改为考虑活跃时间范围的日期范围。)然后我们查找有关这些销售的信息,并将其存储起来,以便另一个执行电子邮件发送的过程。图 6.1 给出了这个设计的一般概述。
图 6.1. Find My Sale!拓扑:其组件和数据点

设计相当直接;它有四个组件,与两个外部服务和数据库进行通信。有了这个设计在手,让我们将注意力转向它是如何映射到 Storm 概念的。
6.1.2. 将解决方案映射到 Storm 概念
在 Storm 的术语中,这种设计映射到一个相对简单的拓扑结构。我们有一个喷嘴发射客户信息,然后将其传递给“查找我的销售”bolt,该 bolt 与外部服务交互。找到的销售信息会与客户信息一起发射到一个查找销售信息的 bolt,该 bolt 将信息与客户信息一起发射到一个持久化 bolt,以便另一个进程可以在稍后提取信息用于发送电子邮件。图 6.2 展示了将设计映射到这些 Storm 概念。
图 6.2. 将“查找我的销售”设计映射到 Storm 概念

我们的设计映射到 Storm 概念的模式与第二章到第四章中找到的模式相似。我们有一个喷嘴作为元组的来源,有三个 bolt 对这些元组进行转换。现在我们将向您展示这个设计的首次代码实现。
6.2. 初始实现
在我们进入设计的实现之前,重要的是要记住以下将在后续代码中频繁引用的几个接口:
-
TopologyBuilder— 提供了指定 Storm 执行拓扑的 API -
OutputCollector— 发射和失败元组的核心 API
我们将从FlashSaleTopologyBuilder开始,它负责连接我们的喷嘴和 bolt(见以下列表)。所有构建拓扑的工作都由这个类处理,无论我们如何运行它:在本地模式或在远程集群中部署。
列表 6.1. FlashSaleTopologyBuilder.java
public class FlashSaleTopologyBuilder {
public static final String CUSTOMER_RETRIEVAL_SPOUT = "customer-retrieval";
public static final String FIND_RECOMMENDED_SALES = "find-recommended-sales";
public static final String LOOKUP_SALES_DETAILS = "lookup-sales-details";
public static final String SAVE_RECOMMENDED_SALES = "save-recommended-sales";
public static StormTopology build() {
TopologyBuilder builder = new TopologyBuilder();
builder.setSpout(CUSTOMER_RETRIEVAL_SPOUT, new CustomerRetrievalSpout())
.setMaxSpoutPending(250);
builder.setBolt(FIND_RECOMMENDED_SALES, new FindRecommendedSales(), 1)
.setNumTasks(1)
.shuffleGrouping(CUSTOMER_RETRIEVAL_SPOUT);
builder.setBolt(LOOKUP_SALES_DETAILS, new LookupSalesDetails(), 1)
.setNumTasks(1)
.shuffleGrouping(FIND_RECOMMENDED_SALES);
builder.setBolt(SAVE_RECOMMENDED_SALES, new SaveRecommendedSales(), 1)
.setNumTasks(1)
.shuffleGrouping(LOOKUP_SALES_DETAILS);
return builder.createTopology();
}
}
现在我们已经看到如何通过FlashSaleTopologyBuilder将拓扑中的所有组件组合在一起,我们将更详细地介绍每个单独的组件,从喷嘴开始。
6.2.1. 喷嘴:从数据源读取
数据将通过喷嘴流入我们的拓扑。这种数据以单个客户 ID 的形式出现,如图 6.3 所示。
图 6.3. 喷嘴为每个接收到的客户 ID 发射一个元组。

但与其他拓扑一样,我们将为了快速启动而作弊。目前,我们将喷嘴生成数据,每当调用其nextTuple()方法时,而不是将其连接到真实的消息队列,如以下列表所示。
列表 6.2. CustomerRetrievalSpout.nextTuple生成客户 ID
...
@Override
public void nextTuple() {
new LatencySimulator(1, 25, 10, 40, 5).simulate(1000);
int numberPart = idGenerator.nextInt(9999999) + 1;
String customerId = "customer-" + Integer.toString(numberPart);
outputCollector.emit(new Values(customerId));
}
...
如果我们将我们的拓扑结构部署到实际的生产环境中,客户检索的喷嘴将会连接到一个消息总线,例如 Kafka 或 RabbitMQ。我们会将需要处理的客户列表保存在队列中,如果我们的拓扑结构完全崩溃或以其他方式停止,我们可以重新启动并从上次停止的地方继续。我们的数据流有一个持久的家,它独立于处理它的系统。
此外,如果我们决定不想以批处理方式执行此操作,我们就必须将其转换为实时系统。使用 Storm 和我们的设计,我们正在以流的形式处理我们的数据,但以批处理的方式启动运行。我们将流处理“如何”与批处理导向的“何时”分开。任何时候我们想要,我们都可以将这个系统从当前的批处理系统形式转换为实时系统,而无需更改我们的拓扑中的任何内容。
在我们进入本章的主要内容之前,让我们逐一检查我们的螺栓并确定重要的逻辑部分。
6.2.2. 螺栓:查找推荐销售
找到推荐销售记录的螺栓在其输入元组中接受一个客户 ID,并发出包含两个值的元组:客户 ID 和销售 ID 列表。为了检索销售 ID,它调用外部服务。图 6.4 说明了我们在拓扑中的位置。
图 6.4. FindRecommendedSales螺栓在其输入元组中接受一个客户 ID,并发出包含一个客户 ID 和销售 ID 列表的元组。

下一个列表显示了此螺栓的实现。
列表 6.3. FindRecommendedSales.java

我们从client.findSalesFor调用中只得到销售标识符列表。为了发送我们的电子邮件,我们需要有关产品和销售的一些额外信息。这就是我们的下一个螺栓发挥作用的地方。
6.2.3. 螺栓:查找每个销售的详细信息
为了发送包含每个销售详细信息的有意义电子邮件,我们需要查找每个推荐销售的详细信息。执行此操作的螺栓接受包含客户 ID 和销售 ID 列表的元组,通过调用外部服务查找每个销售的详细信息,并发出包含客户 ID 和包含每个销售详细信息的Sale对象列表的元组(见图 6.5)。
图 6.5. 查找销售详细信息的螺栓在其输入元组中接受客户 ID 和销售 ID 列表,并发出包含客户 ID 和包含每个销售详细信息的Sale对象列表的元组。

下面的列表显示了LookupSalesDetails螺栓的实现。
列表 6.4. LookupSalesDetails.java


与之前那个螺栓相比,最大的不同之处在于这个螺栓可以同时成功和失败。我们可能会尝试查找十个销售记录,得到九个,但一个也得不到。为了处理这种更复杂的成功定义,我们扩展了BaseRichBolt并手动确认元组。只要我们能从输入元组中获取到的销售 ID 中至少查找到一个销售记录,我们就将其视为成功并继续。我们的主要优先级是尽可能多地及时发送电子邮件。
这引导我们到达最后一个螺栓,我们将结果保存到数据库中,以便通过另一个进程发送。
6.2.4. 螺栓:保存推荐的销售记录
保存推荐销售的 bolt 接受包含客户 ID 和每个销售的详细信息的Sale对象列表的输入元组。然后它将数据持久化到数据库中,以便稍后处理,因为它是我们拓扑中的最后一个 bolt,所以不发出元组(见图 6.6)。
图 6.6. 保存推荐销售 bolt 接受包含客户 ID 和销售对象列表的输入元组,并将该信息持久化到数据库中。

下一个列表显示了SaveRecommendedSales的实现。
列表 6.5. SaveRecommendedSales.java

我们在这里也使用了之前两个 bolt 中使用的相同模式。这是我们的逻辑。看起来都很合理。想象一下,我们已经对我们的拓扑及其工作进行了测试,但它离投入生产还远。它会足够快吗?很难说。让我们看看我们如何找到答案。
6.3. 调优:我想跑得快
如何对拓扑进行调优?一开始这可能看起来像是一项艰巨的任务,但 Storm 为我们提供了工具,可以帮助我们快速识别瓶颈,从而采取措施缓解这些瓶颈。使用 Storm UI 和指标收集 API,您有可用的工具来建立一个可重复的过程,用于调优您的拓扑。
6.3.1. Storm UI:您的调优首选工具
理解 Storm UI 是至关重要的,因为它是主要的工具,将给我们反馈,了解您的调优努力是否产生了效果。图 6.7 提供了对 Storm UI 拓扑摘要屏幕的快速回顾。
图 6.7. Storm UI 的拓扑摘要屏幕

如您所回忆的,单个拓扑在 UI 中有七个部分:
-
拓扑摘要— 显示整个拓扑的状态、运行时间和分配给整个拓扑的工作者、执行者和任务数量。
-
拓扑操作— 允许您从 UI 直接停用、重新平衡或终止您的拓扑。
-
拓扑统计— 显示整个拓扑在四个时间窗口中的高级统计数据;其中一个窗口是所有时间。
-
Spouts (All time)— 显示您在所有时间内的 spout(s)的统计数据。这包括执行者和任务的数量;由 spout(s)发出的、确认的和失败的元组数量;以及与 spout(s)关联的最后一个错误(如果有)。
-
Bolts (All time)— 显示您在所有时间内的 bolt(s)的统计数据。这包括执行者和任务的数量;由 bolt(s)发出的、确认的和失败的元组数量;一些与延迟和 bolt(s)的繁忙程度相关的指标;以及与 bolt(s)关联的最后一个错误(如果有)。
-
可视化— 显示 spouts、bolt(s)的连接方式以及所有流之间元组的流动。
-
拓扑配置— 显示为您的拓扑设置的配置选项。
我们将专注于 UI 的 Bolts 部分进行调优课程。在我们开始确定需要调整什么以及如何调整之前,我们需要为我们的拓扑定义一组基准数字。
定义你的服务等级协议(SLA)
在你开始分析你的拓扑是否是一个精细调优的机器之前,问问自己对你来说“足够快”意味着什么。你需要达到什么样的速度?暂时想想 Twitter 的热门话题。如果处理每条推文需要八个小时,那么这些话题就不会像网站上那样热门。在时间上,SLA 可能相对灵活,比如“在一小时内”,但在数据流上可能非常严格。事件不能超过某个点;某个地方有一个队列,保留着所有将要处理的数据。在设置了一定的最高水位线之后,我们需要以尽可能快的速度消费数据,否则我们可能会达到队列限制,或者更糟糕的是,引发内存不足错误。
对于我们的用例,我们以批量方式处理流数据,我们的 SLA 是不同的。我们需要在电子邮件发出之前完全处理所有数据。“足够快”有几个简单的指标:1)它是否按时完成?2)随着我们每天处理更多数据,它是否会继续按时完成?
让我们的服务等级协议(SLA)变得更加真实一些。在发送之前,处理所有这些电子邮件(比如说 60 分钟)需要一段时间。我们希望每天早上 8 点开始发送。对于即将到来的日子,可以输入交易直到晚上 11 点,我们只能在之后开始处理。这给我们从开始到必须完成的时间提供了八个小时。目前我们有 2000 万客户——这意味着为了勉强达到我们的目标,我们需要每秒处理大约 695 个客户。这已经很接近了;我们决定在第一次尝试中,我们需要有信心在七小时内完成。这意味着每秒 794 个客户,考虑到我们的增长,我们希望迅速将完成时间缩短到三小时以内,这样我们就不必担心调整一段时间了。为了做到这一点,我们需要每秒处理 1,852 个客户。
6.3.2. 建立基准性能数字集
是时候深入开发基本的 Storm 调优技能了,这些技能可以用来使拓扑逐渐变快。在我们的源代码中,你可以找到 Find My Sale!拓扑的 0.0.1 版本。要查看该特定版本,请使用以下命令:
git checkout 0.0.1
在我们调整时,我们需要注意一个主要类:FlashSaleTopology-Builder。这是我们构建拓扑并设置每个组件并行性的地方。让我们再次看看它的构建方法,以刷新您的记忆:
public static StormTopology build() {
TopologyBuilder builder = new TopologyBuilder();
builder.setSpout(CUSTOMER_RETRIEVAL_SPOUT, new CustomerRetrievalSpout())
.setMaxSpoutPending(250);
builder.setBolt(FIND_RECOMMENDED_SALES, new FindRecommendedSales(), 1)
.setNumTasks(1)
.shuffleGrouping(CUSTOMER_RETRIEVAL_SPOUT);
builder.setBolt(LOOKUP_SALES_DETAILS, new LookupSalesDetails(), 1)
.setNumTasks(1)
.shuffleGrouping(FIND_RECOMMENDED_SALES);
builder.setBolt(SAVE_RECOMMENDED_SALES, new SaveRecommendedSales(), 1)
.setNumTasks(1)
.shuffleGrouping(LOOKUP_SALES_DETAILS);
return builder.createTopology();
}
注意,我们在setBolt的调用中创建了一个执行者,在setNumTasks中为每个螺栓创建了一个任务。这将给我们一个基本的基础线,了解我们的拓扑性能。接下来,我们将将其部署到远程集群,然后用一些客户数据运行 10-15 分钟,从 Storm UI 收集基本数据。图 6.8 显示了这一点,重要的部分被突出显示并标注。
图 6.8.确定我们的调整课程中 Storm UI 的重要部分

现在,我们有一个有用的界面来显示与我们的拓扑相关的指标以及一组基准性能数字。调整过程的下一步是确定我们的拓扑中的瓶颈并采取相应措施。
6.3.3.识别瓶颈
我们在第一次运行这些指标后能看到什么?让我们聚焦于容量。对于我们的两个螺栓,它的值相当高。find-recommended-sales螺栓的值为 1.001,而lookup-sales-details螺栓的值在 0.7 左右徘徊。1.001 的值表明find-recommended-sales存在瓶颈。我们需要增加其并行度。鉴于lookup-sales-details的值为 0.7,很可能在不打开lookup-sales-details的情况下打开find-recommended-sales只会将其变成一个新的瓶颈。我们的直觉告诉我们它们应该同时调整。另一方面,save-recommended-sales的值非常低,为 0.07,可能在未来相当长一段时间内都不会成为瓶颈。
接下来,我们将猜测我们可能希望将并行度提高到多高,将任务数量设置为那个值,然后再次发布。我们也会向您展示那次运行的统计数据,以便您可以看到在不改变执行者数量的情况下改变任务数量不会产生任何影响。
您可以通过执行此命令来查看代码的 0.0.2 版本:
git checkout 0.0.2
唯一的重要变化是在FlashSaleTopologyBuilder中:
public static StormTopology build() {
TopologyBuilder builder = new TopologyBuilder();
builder.setSpout(CUSTOMER_RETRIEVAL_SPOUT, new CustomerRetrievalSpout())
.setMaxSpoutPending(250);
builder.setBolt(FIND_RECOMMENDED_SALES, new FindRecommendedSales(), 1)
.setNumTasks(32)
.shuffleGrouping(CUSTOMER_RETRIEVAL_SPOUT);
builder.setBolt(LOOKUP_SALES_DETAILS, new LookupSalesDetails(), 1)
.setNumTasks(32)
.shuffleGrouping(FIND_RECOMMENDED_SALES);
builder.setBolt(SAVE_RECOMMENDED_SALES, new SaveRecommendedSales(), 1)
.setNumTasks(8)
.shuffleGrouping(LOOKUP_SALES_DETAILS);
return builder.createTopology();
}
为什么螺栓任务为 32、32 和 8?当我们完成时,我们可能不需要超过 16、16 和 4,但作为第一次尝试,选择双倍的数量是明智的。有了这个变化,我们不需要多次发布拓扑。我们只需发布版本 0.0.2,并在我们的 Nimbus 节点上使用rebalance命令来调整运行拓扑的并行度。
发布后,我们让它运行大约 10-15 分钟。如您所见,UI 中唯一有意义的变化是每个螺栓的任务数量。
接下来我们做什么?让我们首先通过运行rebalance命令将find-recommended-sales和lookup-sales-details螺栓的并行度翻倍。
注意
本章中使用的rebalance命令的形式是storm rebalance topology-name –e [bolt-name]=[number-of-executors]。这个命令将重新分配给定 bolt 的执行器,允许我们在运行时增加给定 bolt 的并行性。所有rebalance命令都假设我们在 Nimbus 节点上运行,并且我们在PATH中具有 Storm 命令。
我们将运行一个rebalance命令,等待变化出现在 UI 中,然后运行第二个rebalance命令:
storm rebalance flash-sale -e find-recommended-sales=4
storm rebalance flash-sale -e lookup-sales-details=4
好的,我们的重新平衡已完成。10 分钟过去了——让我们看看我们得到了什么(图 6.9)。
图 6.9. Storm UI 显示在尝试增加我们前两个 bolt 的并行性后的容量变化最小。

这里有一件可能会让你感到惊讶的事情。我们增加了find-recommended-sales bolt 的并行性,但容量没有变化。它和之前一样忙碌。这怎么可能呢?从 spout 流入的元组流没有受到影响;我们的 bolt 是一个瓶颈。如果我们使用真实的队列,消息就会在那个队列上积压。注意save-recommended-sales bolt 的容量指标也上升到了大约 0.3。这仍然相当低,所以我们不必担心它成为瓶颈。
让我们再次尝试,这次将两个 bolt 的并行性都加倍。这肯定会在那个队列上留下痕迹:
storm rebalance flash-sale -e find-recommended-sales=8
storm rebalance flash-sale -e lookup-sales-details=8
让我们假设重新平衡已完成,并且我们已经等待了 10 分钟(图 6.10)。
图 6.10. Storm UI 显示将我们前两个 bolt 的执行器数量加倍后的容量变化最小

find-recommended-sales和lookup-sales-details的容量都没有变化。我们 spout 后面的队列肯定积压得很严重。尽管如此,save-recommended-sales的容量几乎翻倍了。如果我们提高我们前两个 bolt 的并行性,这可能会成为我们的瓶颈,所以我们也提高一下。再次,将我们前两个 bolt 的并行性加倍,然后将save-recommended-sales bolt 使用的并行性增加到四倍:
storm rebalance flash-sale -e find-recommended-sales=16
storm rebalance flash-sale -e lookup-sales-details=16
storm rebalance flash-sale -e save-recommended-sales=4
经过三次重新平衡命令和 10 分钟的等待,我们有了图 6.11。
图 6.11. Storm UI 显示我们拓扑中所有三个 bolt 的容量有所提高

太棒了!我们终于取得了一些进展,在容量方面也有了相当不错的提升。现在,喷嘴的数量(一个)可能成为我们的限制因素。在一个连接到真实消息队列的拓扑中,我们会检查消息流是否满足我们的服务级别协议(SLA)。在我们的用例中,我们不在乎消息的积压,但我们关心处理所有消息所需的时间。如果我们的工作从开始到结束需要太长时间,我们可以增加喷嘴的并行性,并采取我们刚刚向您展示的调整步骤。在我们的小型测试拓扑中,模拟喷嘴并行性超出了我们的范围,但您可以自由尝试去模拟它。这可能是一项有益的练习。
在执行器级别与工作者级别增加并行性
到目前为止,我们还没有触及工作者的并行性。一切都在单个工作者和单个喷嘴上运行,我们不需要超过一个工作者。我们的建议是在单个工作者上使用执行器进行扩展,直到您发现增加执行器不再有效。我们刚才用于扩展 bolt 的基本原则也可以应用于喷嘴和工作者。
6.3.4. 喷嘴:控制数据流入拓扑的速度
如果我们在调整过程中仍然没有达到我们的 SLA,那么是时候开始考虑如何控制数据流入我们拓扑的速度了:对喷嘴并行性的控制。有两个因素起作用:
-
喷嘴的数量
-
每个喷嘴将允许在我们的拓扑中活跃的最大元组数
注意
在我们开始之前,记得在第四章中我们讨论了保证消息处理以及 Storm 如何使用元组树来跟踪从喷嘴发出的元组是否被完全处理?在这里,当我们提到元组未确认/活跃时,我们指的是尚未被标记为完全处理的元组树。
这两个因素,喷嘴的数量和最大活跃元组数,是相互关联的。我们将从第二个点开始讨论,因为它更为复杂。Storm 的喷嘴有一个称为最大喷嘴挂起的概念。最大喷嘴挂起允许您设置在任何给定时间可以未确认的最大元组数。在FlashSaleTopologyBuilder代码中,我们设置最大喷嘴挂起值为 250:
builder
.setSpout(CUSTOMER_RETRIEVAL_SPOUT, new CustomerRetrievalSpout())
.setMaxSpoutPending(250);
通过将该值设置为 250,我们确保每个喷嘴任务在给定时间可以有 250 个元组未确认。如果我们有两个喷嘴实例,每个实例有两个任务,那么将是:
2 个喷嘴 x 2 个任务 x 250 最大喷嘴挂起 = 1000 个可能的未确认元组
当在您的拓扑中设置并行性时,确保最大喷嘴挂起数量不是瓶颈非常重要。如果可能未确认的元组数量低于您为拓扑设置的总体并行性,那么它可能是一个瓶颈。在这种情况下,我们有以下
-
16 个
find-recommended-salesbolt -
16 个
lookup-sales-detailsbolt -
4 个
saved-recommended-salesbolt
每次我们可以处理 36 个元组。
在这个例子中,使用单个 spout,我们可能的最大未确认元组数,250,大于基于我们的并行化可以处理的元组数,36,因此我们可以放心地说,最大 spout 挂起不会造成瓶颈(图 6.12)。
图 6.12。因为最大 spout 挂起数大于我们一次可以处理的元组总数,所以它不是瓶颈。

如果最大 spout 挂起数可以造成瓶颈,为什么还要设置它呢?如果没有它,元组将继续流入你的拓扑,无论你是否能够跟上处理它们。最大 spout 挂起数允许我们控制我们的摄入速率。如果没有控制我们的摄入速率,可能会使我们的拓扑被涌入的数据淹没,导致崩溃。最大 spout 挂起数让我们在拓扑前建立一道堤坝,施加反向压力,避免被淹没。我们建议,尽管最大 spout 挂起数是可选的,但你总是应该设置它。
当试图提高性能以满足 SLA 时,我们可以通过增加 spout 并行度或增加最大 spout 挂起数来增加数据摄入速率。如果我们允许的最大活动元组数增加了四倍,我们预计消息离开我们的队列的速度会增加(可能不会增加四倍,但肯定会增加)。如果这导致我们任何 bolt 的容量指标返回到一或接近一,我们会再次调整 bolt,并重复使用 spout 和 bolt,直到我们达到 SLA。如果调整 spout 和 bolt 的并行度未能提供额外的收益,我们会尝试调整工作线程的数量,看看我们是否现在受限于我们运行的 JVM,需要跨 JVM 进行并行化。这个基本方法可以反复应用,在许多情况下,我们可以根据这个方法满足我们的 SLA。
如果你正在调整拓扑中的外部服务,请记住以下要点:
-
与外部服务(如 SOA 服务、数据库或文件系统)交互时,很容易在拓扑中提高并行度到一个足够高的水平,但该外部服务的限制会阻止你的容量进一步提升。在你开始调整与外界交互的拓扑中的并行度之前,请确保你对该服务有良好的指标。我们可以不断调整
find-recommended-sales螺栓的并行度,直到它使“找到我的销售!”服务陷入瘫痪,在它无法处理的大量流量下崩溃。 -
第二点是关于延迟。这一点更为微妙,需要更长的解释和一些背景信息,所以在我们到达那里之前,让我们检查我们的并行度变化。
你可以通过执行以下命令来查看我们在调整示例中此阶段的代码版本:
git checkout 0.0.3
6.4. 延迟:当外部系统耗时长时
让我们谈谈快速代码的最大敌人:延迟。延迟通常定义为系统的一部分等待从系统的另一部分获得响应的时间段。访问你电脑上的内存、访问硬盘驱动器和通过网络访问另一个系统都会有延迟。不同的交互有不同的延迟级别,理解你系统中的延迟是调整你的拓扑结构的关键之一。
6.4.1. 在你的拓扑结构中模拟延迟
如果你查看这个拓扑结构的代码,你会在 Database.java 的代码中找到类似这样的东西:
private final LatencySimulator latency = new LatencySimulator(
20, 10, 30, 1500, 1);
public void save(String customerId, List<Sale> sale, int timeoutInMillis) {
latency.simulate(timeoutInMillis);
}
如果你没有看过代码,不要担心。我们在这里会涵盖所有重要的部分。LatencySimulator是我们使这个拓扑结构在与外部服务交互时表现得像真实的一个方法。你与之交互的任何东西都会表现出延迟,从你电脑上的主内存到你必须从中读取的网络化文件系统。不同的系统将表现出不同的延迟特性,我们的LatencySimulator试图以简单的方式模拟这些特性。
让我们分解其五个构造函数参数(见图 6.13)。
图 6.13. LatencySimulator构造函数参数说明

注意,我们不是用基本平均值来表示延迟,这很少是延迟工作的方式。你通常会得到相当一致的反应时间,突然间这些反应时间会因为各种因素而剧烈变化:
-
外部服务正在进行垃圾回收事件。
-
某处的网络交换机暂时过载。
-
你的同事编写了一个失控的查询,目前正占用数据库的大部分 CPU。
注意
在我们的日常工作岗位上,我们几乎所有的系统都在 JVM 上运行,我们使用 Coda Hale 的优秀 Metrics 库^([1])以及 Netflix 的出色 Hystrix 库^([2])来测量我们系统的延迟并相应调整。
表 6.1 显示了我们的拓扑结构交互的各种系统的延迟。查看表格,我们可以看到在这些服务中,从最佳请求到最差请求有很大的差异。但真正引人注目的是我们经常受到延迟的影响。有时,数据库的延迟比其他任何服务都要长,但与FlashSaleRecommendationService相比,后者遇到高延迟期要高一个数量级。也许我们可以在那里解决一些问题。
表 6.1. 外部服务的延迟
| 系统 | 低点 | 低变异性 | 高点 | 高变异性 | 高百分比 |
|---|---|---|---|---|---|
| FlashSaleRecommendationService | 100 | 50 | 150 | 1000 | 10 |
| FlashSaleService | 50 | 50 | 100 | 200 | 5 |
| 数据库 | 20 | 10 | 30 | 1500 | 1 |
当你查看FindRecommendedSalesbolt 时,你会看到:
private final static int TIMEOUT = 200;
...
@Override
public void prepare(Map config, TopologyContext context) {
client = new FlashSaleRecommendationClient(TIMEOUT);
}
我们为每个客户端查找推荐设置了 200 毫秒的超时。这是一个不错的数字,200,但我们是如何确定这个数字的呢?当我们试图让拓扑工作的时候,这看起来可能是正确的。在图 6.14 中,看看“最后错误”列。你会发现我们所有的 bolt 都在经历超时。这是有道理的。我们只等待 200 毫秒来获取推荐,但根据表 6.1,每十个请求中有一个会达到高于正常的延迟,可能需要从 150 毫秒到 1049 毫秒的时间来返回结果,而九个请求将返回少于 150 毫秒。这种情况可能有两个主要原因:外在和内在。
图 6.14. Storm UI 显示我们每个 bolt 的最后错误

6.4.2. 延迟的外在和内在原因
一个外在的原因是与数据几乎没有关系的原因。我们遇到高延迟是因为网络问题或垃圾回收事件,或者是一些应该随时间流逝而解决的问题。下次我们重试那个请求时,我们的情况可能不同。
一个内在的原因是与可能导致延迟的数据相关的某个原因。在我们的例子中,为某些客户提供推荐销售可能需要更长的时间。无论我们在这个 bolt 中失败多少次并再次尝试,我们都不会为这些客户提供推荐销售。这只会花费太长时间。内在原因可以与外在原因结合;它们不是相互排斥的。
这都很好,但这与我们的拓扑有什么关系呢?嗯,当我们与外部服务交互时,我们可以考虑延迟,并尝试在不增加并行性的情况下提高我们的吞吐量。让我们更聪明地处理我们的延迟。
好吧,我们在这里做推荐,所以我们宣布经过调查,我们已经发现我们的FlashSaleRecommendationService的方差是基于客户的。某些客户查找起来会慢一些:
-
我们可以在 125 毫秒内为其中 75%生成推荐。
-
对于另外 15%,大约需要 125-150 毫秒。
-
最后的 10%通常需要至少 200 毫秒,有时长达 1500 毫秒。
这些是延迟的内在差异。有时,由于一个外在事件,这些“快速”查找中的一个可能会花费更长的时间。对于表现出这种问题的服务,我们有一个策略效果很好,那就是在超时上有硬上限的初始查找尝试。在这个例子中,我们可以使用 150 毫秒,如果失败了,就将其发送到同一个 bolt 的较不并行化的实例,该实例的超时会更长。最终结果是,我们处理大量消息的时间减少了——我们实际上是在对外在延迟宣战。如果 90%的请求超过 150 毫秒,那可能是因为
-
这是一个存在内在问题的客户。
-
外部问题,如停止世界的垃圾收集正在产生影响。
使用这种策略的效果可能会有所不同,所以在使用之前请进行测试。不考虑警告,让我们看看一种可以实现的方法。查看我们代码的 0.0.4 版本
git checkout 0.0.4
并查看以下列表,以了解 FindRecommendedSales 和 FlashSaleTopologyBuilder 中的更改。
列表 6.6. FindRecommendedSales.java 带有重试逻辑

查看在 FlashSaleTopologyBuilder 中发生的事情:
builder
.setSpout(CUSTOMER_RETRIEVAL_SPOUT, new CustomerRetrievalSpout())
.setMaxSpoutPending(250);
builder
.setBolt(FIND_RECOMMENDED_SALES_FAST, new FindRecommendedSales(), 16)
.addConfiguration("timeout", 150)
.setNumTasks(16)
.shuffleGrouping(CUSTOMER_RETRIEVAL_SPOUT);
builder
.setBolt(FIND_RECOMMENDED_SALES_SLOW, new FindRecommendedSales(), 16)
.addConfiguration("timeout", 1500)
.setNumTasks(16)
.shuffleGrouping(FIND_RECOMMENDED_SALES_FAST,
FindRecommendedSales.RETRY_STREAM)
.shuffleGrouping(FIND_RECOMMENDED_SALES_SLOW,
FindRecommendedSales.RETRY_STREAM);
builder
.setBolt(LOOKUP_SALES_DETAILS, new LookupSalesDetails(), 16)
.setNumTasks(16)
.shuffleGrouping(FIND_RECOMMENDED_SALES_FAST,
FindRecommendedSales.SUCCESS_STREAM)
.shuffleGrouping(FIND_RECOMMENDED_SALES_SLOW,
FindRecommendedSales.SUCCESS_STREAM);
builder
.setBolt(SAVE_RECOMMENDED_SALES, new SaveRecommendedSales(), 4)
.setNumTasks(4)
.shuffleGrouping(LOOKUP_SALES_DETAILS);
我们之前有一个单独的 FindRecommendedSales bolt,现在我们有两个:一个用于“快速”查找,另一个用于“慢速”。让我们更仔细地看看快速的那个:
builder
.setBolt(FIND_RECOMMENDED_SALES_FAST, new FindRecommendedSales(), 16)
.addConfiguration("timeout", 150)
.setNumTasks(16)
.shuffleGrouping(CUSTOMER_RETRIEVAL_SPOUT);
它与我们的先前 FindRecommendedSales bolt 相同,只是增加了一个功能:
.addConfiguration("timeout", 150)
这是我们在 bolt 的 prepare() 方法中用来初始化 FindRecommendationSalesClient 超时值的超时值(以毫秒为单位)。每个通过快速 bolt 的元组将在 150 毫秒后超时,并在重试流上发出。以下是 FindRecommendedSales bolt 的“慢”版本:
builder
.setBolt(FIND_RECOMMENDED_SALES_SLOW, new FindRecommendedSales(), 16)
.addConfiguration("timeout", 1500)
.setNumTasks(16)
.shuffleGrouping(FIND_RECOMMENDED_SALES_FAST,
FindRecommendedSales.RETRY_STREAM)
.shuffleGrouping(FIND_RECOMMENDED_SALES_SLOW,
FindRecommendedSales.RETRY_STREAM);
注意,它有一个 1500 毫秒的超时时间:
.addConfiguration("timeout", 1500)
这是基于该客户内在原因我们决定应该等待的最长时间。
那两个 shuffle 分组发生了什么?
.shuffleGrouping(FIND_RECOMMENDED_SALES_FAST,
FindRecommendedSales.RETRY_STREAM)
.shuffleGrouping(FIND_RECOMMENDED_SALES_SLOW,
FindRecommendedSales.RETRY_STREAM);
我们将慢速的 FindRecommendedSales bolt 连接到两个不同的流:来自快速和慢速版本的 FindRecommendedSales bolts 的重试流。每当 bolt 的任何版本发生超时,它将在重试流上发出,并以较慢的速度重试。
我们必须对我们的拓扑进行一次更大的更改来整合这一点。我们的下一个 bolt,LookupSalesDetails,必须从两个 FindRecommendedSales bolts 的成功流中获取元组,无论是慢速还是快速:
builder.setBolt(LOOKUP_SALES_DETAILS, new LookupSalesDetails(), 16)
.setNumTasks(16)
.shuffleGrouping(FIND_RECOMMENDED_SALES_FAST,
FindRecommendedSales.SUCCESS_STREAM)
.shuffleGrouping(FIND_RECOMMENDED_SALES_SLOW,
FindRecommendedSales.SUCCESS_STREAM);
我们也可以考虑将这种模式应用到更下游的其他 bolt 上。重要的是要权衡由此产生的额外复杂性以及可能的性能提升。像往常一样,一切都是关于权衡。
让我们回顾一个先前的决定。还记得 LookupSalesDetails 中的代码,它可能导致某些销售详情没有被查询到吗?
@Override
public void execute(Tuple tuple) {
String customerId = tuple.getStringByField("customer");
List<String> saleIds = (List<String>) tuple.getValueByField("sales");
List<Sale> sales = new ArrayList<Sale>();
for (String saleId: saleIds) {
try {
Sale sale = client.lookupSale(saleId);
sales.add(sale);
} catch (Timeout e) {
outputCollector.reportError(e);
}
}
if (sales.isEmpty()) {
outputCollector.fail(tuple);
} else {
outputCollector.emit(new Values(customerId, sales));
outputCollector.ack(tuple);
}
}
我们为了速度做出了权衡。我们愿意接受偶尔在向每个客户推荐销售数量上损失精度,而不是通过电子邮件确保我们达到服务级别协议(SLA)。但是这个决定会产生什么影响?有多少销售没有发送给客户?目前,我们没有任何洞察。幸运的是,Storm 自带一些内置的度量能力,我们可以利用。
6.5. Storm 的度量收集 API
在 Storm 0.9.x 系列版本发布之前,度量指标就像西部荒野。你可以在 UI 中查看拓扑级别的度量指标,但如果你需要业务级别或 JVM 级别的度量指标,你需要自己实现。现在随 Storm 一起提供的度量 API 是获取可以解决我们当前困境(理解我们在LookupSalesDetailsbolt 中丢失了多少精确度)的度量指标的一个绝佳方式。
6.5.1. 使用 Storm 的内置 CountMetric
要在源代码中跟踪,请运行以下命令:
git checkout 0.0.5
下一个列表显示了我们对LookupSalesDetailbolt 所做的更改。
列表 6.7. 带有度量的LookupSalesDetails.java


我们在prepare()方法中创建了并注册了两个CountMetric实例:一个用于跟踪成功查找详细信息的销售数量,另一个用于跟踪失败次数。
6.5.2. 设置度量消费者
现在我们有一些基本的原始数据要记录,但要获取这些数据,我们必须设置一个消费者。度量消费者实现了IMetricsConsumer接口,它充当 Storm 和外部系统(如 Statsd 或 Riemann)之间的桥梁。在这个例子中,我们将使用提供的LoggingMetricsConsumer。当一个拓扑在本地模式下运行时,LoggingMetricsConsumer最终会被导向标准输出(stdout)以及其他日志输出。我们可以通过向我们的LocalTopologyRunner添加以下内容来设置它:
Config config = new Config();
config.setDebug(true);
config.registerMetricsConsumer(LoggingMetricsConsumer.class, 1);
假设我们在时间窗口内成功查看了 350 个销售记录:
244565 [Thread-16-__metricsbacktype.storm.metric.LoggingMetricsConsumer]
INFO backtype.storm.metric.LoggingMetricsConsumer - 1393581398
localhost:1 22:lookup-sales-details sales-looked-up 350
在远程集群上,LoggingMetricsConsumer将信息级别消息写入 Storm 日志目录中名为 metrics.log 的文件。我们还通过以下添加启用了度量日志记录,以便在将拓扑部署到集群时:
public class RemoteTopologyRunner {
...
private static Config createConfig(Boolean debug) {
...
Config config = new Config();
...
config.registerMetricsConsumer(LoggingMetricsConsumer.class, 1);
...
}
}
Storm 的内置度量指标很有用。但如果你需要比内置的更多呢?幸运的是,Storm 提供了实现自定义度量指标的能力,这样你可以创建针对特定需求的度量指标。
6.5.3. 创建自定义 SuccessRateMetric
我们有原始度量数据,但我们想将它们聚合起来,然后自己进行数学计算以确定成功率。我们更关心的是成功率,而不是原始的成功和失败次数。Storm 没有内置的度量指标供我们使用,但很容易创建一个类来为我们记录这些信息。下面的列表介绍了SuccessRateMetric。
列表 6.8. SuccessRateMetric.java

将代码更改为使用这个新的自定义度量指标很简单(见下一个列表)。
列表 6.9. 使用我们的新自定义度量指标的LookupSalesDetails.java


一切几乎都和以前一样。我们记录一个度量(只是类型不同)并向其报告我们的成功和失败情况。记录的输出更接近我们想要了解的内容:
124117 [Thread-16-__metricsbacktype.storm.metric.LoggingMetricsConsumer]
INFO backtype.storm.metric.LoggingMetricsConsumer - 1393581964
localhost:1 32:lookup-sales-details sales-lookup-success-rate
98.13084112149532
你可以亲自尝试:
git checkout 0.0.5
mvn clean verify -P local-cluster
警告!输出会很多。
6.5.4. 创建自定义 MultiSuccessRateMetric
到目前为止,我们已经进入生产阶段,业务人员对连续几天的情况感到满意——直到他们想要知道客户之间保真度的分布。换句话说,我们需要按客户记录成功和失败情况。
幸运的是,有一个名为MultiCountMetric的 Storm 度量标准,它正好做这件事——除了它使用CountMetrics而不是SuccessRateMetrics。但这很容易处理——我们只需从它创建一个新的度量标准:
git checkout 0.0.6
下面的列表显示了新的度量标准:MultiSuccessRateMetric。
列表 6.10. MultiSuccessRateMetric.java

这个类很简单;我们在散列表中存储单个SuccessRateMetric。我们将使用客户 ID 作为键,并能够跟踪每个客户的成功和失败情况。正如你在下一个列表中可以看到的,我们需要做的更改很小。
列表 6.11. LookupSalesDetails.java 使用新的 MultiSuccessRateMetric


现在我们正在以对业务人员有用的方式记录度量标准:
79482 [Thread-16-__metricsbacktype.storm.metric.LoggingMetricsConsumer]
INFO backtype.storm.metric.LoggingMetricsConsumer - 1393582952
localhost:4 24:lookup-sales-details sales-lookup-success-rate
{customer-7083607=100.0, customer-7461335=80.0, customer-2744429=100.0,
customer-3681336=66.66666666666666, customer-8012734=100.0,
customer-7060775=100.0, customer-2247874=100.0, customer-3659041=100.0,
customer-1092131=100.0, customer-6121500=100.0, customer-1886068=100.0,
customer-3629821=100.0, customer-8620951=100.0, customer-8381332=100.0,
customer-8189083=80.0, customer-3720160=100.0, customer-845974=100.0,
customer-4922670=100.0, customer-8395305=100.0,
customer-2611914=66.66666666666666, customer-7983628=100.0,
customer-2312606=100.0, customer-8967727=100.0,
customer-552426=100.0, customer-9784547=100.0, customer-2002923=100.0,
customer-6724584=100.0, customer-7444284=80.0, customer-5385092=100.0,
customer-1654684=100.0, customer-5855112=50.0, customer-1299479=100.0}
日志消息提供了一个新度量标准的示例:一个客户 ID 列表,每个 ID 都有一个相关的成功率。以下是列表中的一位幸运客户,其成功率为 100%:
customer-2247874=100.0
通过这些数据,我们对有多少客户收到了他们全部可能的闪购套装有了更深入的了解。
6.6. 摘要
在本章中,你了解到
-
拓扑结构的所有基本计时信息都可以在 Storm UI 中找到。
-
为你的拓扑结构建立一组基线性能数字是调整过程中的基本第一步。
-
瓶颈由 spout/bolt 的高容量表示,可以通过增加并行性来解决。
-
增加并行性最好分小步骤进行,这样你可以更好地理解每次增加的效果。
-
与数据相关(内在)以及与数据无关(外在)的延迟可能会降低你的拓扑结构的吞吐量,可能需要解决。
-
如果你想真正了解你的拓扑结构是如何运行的,度量标准(无论是内置的还是自定义的)都是必不可少的。
第七章. 资源竞争
本章涵盖
-
Storm 集群中工作进程的竞争
-
工作进程(JVM)内的内存竞争
-
工作节点上的内存竞争
-
工作节点 CPU 竞争
-
工作节点网络/套接字输入/输出(I/O)竞争
-
工作节点磁盘 I/O 竞争
在第六章中,我们讨论了在单个拓扑级别进行调优。调优是一项重要的技能,当你将拓扑部署到生产环境中时,它将为你提供良好的服务。但它只是更大图景中的一小部分。你的拓扑将不得不与 Storm 集群中的各种其他拓扑共存。其中一些拓扑将消耗大量 CPU 进行复杂的数学计算,一些将消耗大量的网络带宽,等等,涉及各种资源。
在本章中,我们将介绍在 Storm 集群中可能发生竞争的各种资源类型,并解释如何解决每个问题。我们希望没有单个 Storm 集群会有如此多的竞争问题,因此我们放弃了我们通常的案例研究格式,转而采用更合适的食谱方法。快速浏览本章,以获得对竞争类型的一般了解,然后在开始遇到问题时,参考与你相关的任何部分。
本章的前三个食谱专注于解决随后提出的几种类型的竞争的常见解决方案。我们建议首先阅读这三个食谱,因为这将帮助你更好地理解当我们讨论特定类型竞争的解决方案时我们在说什么。
在本章中,当我们讨论可能发生竞争的资源时,我们使用某些术语。当你看到某些术语时,了解我们正在引用的 Storm 部署的哪个部分是很重要的。图 7.1 突出了这些资源,其中关键术语以粗体显示。其中大部分你应该已经很熟悉了,但如果不是,确保在继续前进之前花时间研究这些术语以及各种组件之间的关系。
图 7.1. Storm 集群中各种类型的节点以及作为工作进程及其部分的工人节点

定义了术语后,让我们开始我们的“常见解决方案”食谱中的第一个,即更改运行在工作节点上的工作进程(JVM)的数量。现在解决这些“常见解决方案”食谱将为以后提供良好的参考,并使我们能够专注于为什么每个解决方案对于特定场景都是好的。
在讨论操作系统级别的竞争时选择操作系统
每个人在管理、维护和诊断 Storm 集群中的问题的经验都会有所不同。我们试图涵盖主要问题和您将需要的某些工具。但您的情况可能与我们遇到的任何情况都不同。您的集群配置可能在机器数量、每台机器的 JVM 数量等方面有所不同。没有人能给您提供如何设置您的集群的答案。我们能做的最好的事情是向您提供调整可能出现问题的指南。因为我们正在解决操作系统级别存在的许多问题,并且因为您可以在许多操作系统上运行 Storm,所以我们决定专注于一个特定的操作系统家族:基于 Linux 的。
本章讨论的操作系统级工具应该在每种 Linux 变体中都可用。此外,这些工具应该存在于任何 Unix 类型的操作系统(如 Solaris 或 FreeBSD)中,或者有等效的工具。对于那些考虑使用 Windows 的用户,您将不得不做更多的工作来将这些想法转换到您的操作系统上,但一般原则适用。重要的是要注意,我们关于工具使用的讨论远非详尽——它是为了为你提供一个基础来构建。为了管理和诊断生产集群中的问题,您将需要了解更多关于您正在运行的工具和操作系统。手册页、搜索引擎、Storm 邮件列表、IRC 频道以及您友好的本地操作人员都是您应该依赖的、学习更多知识的优秀资源。
7.1. 在工人节点上更改运行的工人进程数量
在本章的几个食谱中,解决所讨论的争用问题的一个方案是改变在工人节点上运行的工人进程的数量(图 7.2)。
图 7.2. 工人节点上运行的许多工人进程

在某些情况下,这意味着增加工人进程的数量,而在其他情况下,这意味着减少工人进程的数量。这是一个如此常见的解决方案,以至于我们决定将其分解为单独的食谱,这样您就可以在遇到它作为解决方案时随时参考这一部分。
7.1.1. 问题
您正在经历一个争用,您需要增加或减少在工人节点上运行的工人进程的数量。
7.1.2. 解决方案
工人节点上运行的工人进程的数量由每个工人节点 storm.yaml 配置文件中的supervisor.slots.ports属性定义。该属性定义了每个工人进程将使用哪些端口来监听消息。下面的列表显示了该属性的默认设置。
列表 7.1. supervisor.slots.ports的默认设置
supervisor.slots.ports
- 6701
- 6702
- 6703
- 6704
要增加可以在工人节点上运行的工人进程的数量,为要添加的每个工人进程添加一个端口到这个列表中。对于减少工人进程的数量,情况相反:为要移除的每个工人进程移除一个端口。
更新此属性后,你需要重新启动工作节点上的 Supervisor 进程以生效更改。如果你像我们在第五章安装过程中所做的那样,将 Storm 安装到 /opt/storm,这将需要终止 Supervisor 进程,并使用以下命令重新启动:
/opt/storm/bin storm supervisor
重新启动后,Nimbus 将会意识到配置的更新,并且只向此列表中定义的端口发送消息。
7.1.3. 讨论
Storm 默认为每个工作节点分配四个工作进程,每个工作进程分别监听端口 6701、6702、6703 和 6704。当你刚开始构建集群时,这通常已经足够好了,所以不必担心立即尝试找出最佳配置。但如果你确实需要添加端口,请确保使用 Linux 上的 netstat 等工具检查你想要添加的端口是否已被占用。
另一个需要考虑的是你集群中工作节点的数量。如果需要广泛更改,更新配置并在数百甚至数十个节点上重启 Supervisor 进程是一项繁琐且耗时的任务。因此,我们推荐使用像 Puppet (puppetlabs.com) 这样的工具来自动化每个节点的部署和配置。
7.2. 更改分配给工作进程(JVMs)的内存量
在本章的一些食谱中,解决所讨论的竞争问题的方法之一是改变工作节点上工作进程(JVMs)分配的内存量。
在某些情况下,这意味着增加分配的内存量,而在其他情况下则意味着减少内存。无论解决方案的原因是什么,更改此设置的步骤都是相同的,这就是为什么我们专门为它编写了一个单独的食谱。
7.2.1. 问题
你正在经历一个需要增加或减少工作节点上工作进程(worker processes)使用的内存量的竞争(contention)。
7.2.2. 解决方案
可以在各个工作节点 storm.yaml 配置文件中的 worker.childopts 属性中更改分配给工作节点上所有工作进程(JVMs)的内存量。该属性接受任何有效的 JVM 启动选项,提供了为工作节点上的 JVM 设置初始内存分配池(-Xms)和最大内存分配池(-Xmx)启动选项的能力。以下列表显示了这将如何看起来,仅关注与内存相关的参数。
列表 7.2. 在 storm.yaml 中设置 worker.childopts
worker.childopts: "...
-Xms512m
-Xmx1024m
..."
重要的是要意识到更改此属性将更新特定工作节点上的所有工作进程。在更新此属性后,您需要重新启动工作节点上的 Supervisor 进程以生效更改。如果您像我们在第五章中的安装演练那样将 Storm 安装到/opt/storm,这将需要终止 Supervisor 进程,然后使用以下命令重新启动:
/opt/storm/bin storm supervisor
重新启动后,工作节点上的所有工作进程(JVM)都应使用更新的内存设置运行。
7.2.3. 讨论
在增加 JVM 大小时,需要注意的一点是确保工作节点(机器/虚拟机)本身有足够的资源来支持这种大小增加。如果工作节点没有足够的内存来支持您设置的–Xmx值,您需要在更改分配给 JVM 的内存量之前更改实际机器/虚拟机的大小。
我们强烈推荐遵循的另一个技巧是将–Xms和–Xmx设置为相同的值。如果这些值不同,JVM 将管理堆,有时增加有时减少堆大小,具体取决于堆的使用情况。我们发现这种堆管理的开销是不必要的,因此建议将两者设置为相同的值以消除任何堆管理开销。除了更高效之外,这种策略还有额外的优点,即更容易理解 JVM 内存使用情况,因为堆大小是 JVM 生命周期的固定常数。
7.3.确定拓扑正在执行的工作节点/进程
本章中的许多配方都涉及工作节点和工作进程级别的竞争。通常,这些竞争将以拓扑在 Storm UI 中抛出错误、吞吐量降低或完全没有吞吐量的形式表现出来。在这些所有场景中,您很可能会需要确定特定拓扑正在执行的工作节点和工作进程。
7.3.1. 问题
您有一个有问题的拓扑,需要确定该拓扑正在执行的工作节点和工作进程。
7.3.2. 解决方案
要这样做,请查看 Storm UI。您应该从查看特定拓扑的 UI 开始。我们建议检查 Bolts 部分,看看是否有任何异常。如图 7.3 所示,其中一个 bolt 存在问题。
图 7.3.在 Storm UI 中诊断特定拓扑的问题

在确定了有问题的 bolt 之后,您现在想了解更多关于该 bolt 发生的事情的详细信息。为此,请在 UI 中单击该 bolt 的名称以获取该 bolt 的更详细视图。从这里,将您的注意力转向单个 bolt 的 Executors 和 Errors 部分(图 7.4)。
图 7.4. 查看特定 bolt 的 Storm UI 中的 Executors 和错误部分,以确定 bolt 遇到的问题类型,同时确定 bolt 正在执行的工作节点和工作进程

对于单个 bolt 的 Executors 部分特别有趣;这告诉你 bolt 正在哪些工作节点和工作进程上执行。从这里,根据正在经历的竞争类型,你可以采取必要的步骤来识别和解决问题。
7.3.3. 讨论
Storm UI 是你的朋友。熟悉它的各种屏幕。通常,我们在诊断任何类型的竞争时首先查看的地方。能够快速识别出有问题的拓扑、bolt、工作节点和工作进程在我们的经验中非常有价值。
尽管 Storm UI 是一个强大的工具,但它可能并不总是显示你需要的信息。这时,额外的监控就能发挥作用。这可以通过监控单个工作节点或你 bolt 代码中的自定义指标来实现,从而让你更深入地了解 bolt 的性能。关键在于,你不应该仅仅依赖 Storm UI。要采取其他措施确保全面覆盖。毕竟,问题不是“是否”会出问题,而是“何时”出问题。
7.4. Storm 集群中的工作进程竞争
当你安装一个 Storm 集群时,你会在所有工作节点上安装一个固定数量的可用工作进程。每次你将新的拓扑部署到集群中时,你都会指定该拓扑应该消耗多少个工作进程。很容易陷入这样的情况:你部署了一个需要一定数量工作进程的拓扑,但你无法获得这些工作进程,因为它们都已经分配给了现有的拓扑。这使得相关的拓扑变得无用,因为它没有工作进程就无法处理数据。图 7.5 说明了这一点。
图 7.5. 示例 Storm 集群,其中所有工作进程都已分配给拓扑。

图 7.5 展示了我们亲身体验过多次的问题。幸运的是,这个问题很容易检测;可以通过查看 Storm UI 的集群摘要页面(图 7.6)来找到。
图 7.6. Storm UI:没有空闲槽位可能意味着你的拓扑正遭受槽位竞争。

7.4.1. 问题
根据 Storm UI,你注意到一个拓扑没有处理任何数据,或者吞吐量突然下降,且没有空闲槽位可用。
7.4.2. 解决方案
核心问题是,你有固定数量的工作进程可以分配给请求它们的拓扑。你可以通过以下策略解决这个问题:
-
减少现有拓扑使用的工作进程数量
-
增加集群中工作进程的总数
减少现有拓扑使用的工作进程数量
这是为集群中的其他拓扑释放槽位的最快和最简单的方法。但这可能或可能不取决于现有拓扑的 SLAs。如果你可以在不违反 SLA 的情况下减少拓扑使用的工作进程数量,我们建议这种方法。
拓扑请求的工作进程数量在构建和提交拓扑到 Storm 集群的代码中指定。下面的列表显示了此代码。
列表 7.3. 配置拓扑的工作进程数量

如果你的服务等级协议(SLAs)不允许你减少集群中任何拓扑使用的槽位数,你将不得不向集群中添加新的工作进程。
增加集群中工作进程的总数
有两种方法可以增加集群中工作进程的总数。一种是通过在第 7.1 节中列出的步骤向你的工作节点添加更多的工作进程。但如果你的工作节点没有资源来支持额外的 JVMs,这就不起作用了。如果是这种情况,你将需要向你的集群添加更多的工作节点,从而增加工作进程池。
如果你能够的话,我们建议添加新的工作节点。这种方法对现有拓扑的影响最小,因为向现有节点添加工作进程可能会引起其他类型的竞争,然后必须解决这些竞争。
7.4.3. 讨论
工作进程竞争可能有各种原因,其中一些是自致的,而另一些则不是。场景包括以下:
-
你部署了一个配置为消耗比集群中可用槽位更多的拓扑的工作进程。
-
你部署了一个没有可用槽位的拓扑到你的集群中。
-
一个工作节点宕机,从而减少了可用槽位的数量,可能会造成现有拓扑之间的竞争。
在部署新拓扑时,始终了解集群中可用的资源非常重要。如果你忽略了集群内的可用资源,你很容易通过部署消耗过多资源的东西来影响集群中的每个拓扑。
7.5. 工作进程(JVM)内的内存竞争
正如你使用固定数量的工作进程安装 Storm 集群一样,你也为每个工作进程(JVM)设置了一个固定数量的内存,它可以增长并使用。内存的数量限制了可以在该 JVM 上启动的线程(执行器)的数量——每个线程需要一定量的内存(在 64 位 Linux JVM 上默认为 1 MB)。
JVM 竞争可能在每个拓扑结构的基础上成为一个问题。你的 bolts、spouts、threads 等使用的内存组合可能会超过分配给它们运行的 JVMs 的内存(图 7.7)。
图 7.7. 工作进程、执行器与 JVM 的映射,以及 spouts/bolts 的线程和实例,以及同一 JVM 中争夺内存的线程/实例

JVM 竞争通常表现为内存不足(OOM)错误和/或过长的垃圾回收(GC)暂停。OOM 错误将出现在 Storm 日志和 Storm UI 中,通常以 java.lang.OutOfMemory-Error: Java heap space 开头的堆栈跟踪。
获得 GC 问题的可见性需要一点更多的设置,但这在 JVM 和 Storm 配置中都是容易支持的。JVM 提供了跟踪和记录 GC 使用情况的启动选项,而 Storm 提供了一种为您的 worker 进程指定 JVM 启动选项的方法。storm.yaml 中的 worker.childopts 属性是您指定这些 JVM 选项的地方。以下列表显示了工作节点中的示例 storm.yaml 配置。
列表 7.4. 为工作进程设置 GC 日志记录

一个值得注意的有趣项目是 –Xloggc 设置的值。记住,每个工作节点可以有多个工作进程。worker.childopts 属性适用于节点上的所有工作进程,因此指定一个常规日志文件名将生成所有工作进程合并的一个日志文件。为每个工作进程生成单独的日志文件将使跟踪每个 JVM 的 GC 使用情况更容易。Storm 提供了一种记录特定工作进程日志的机制;ID 变量对于工作节点上的每个工作进程都是唯一的。因此,您可以将 "%ID%" 字符串添加到 GC 日志文件名中,您将为每个工作进程获得一个单独的 GC 日志文件。
首次阅读 GC 日志可能会有些令人畏惧,所以我们将快速浏览一个教程,概述 列表 7.4 中的选项将在相关日志中产生什么。此列表显示了包含小(年轻代)和主要收集(持久代)的 GC 周期的示例输出。完全有可能不是每个 GC 日志语句都会包含主要收集统计信息,因为主要收集并不在每次 GC 周期中发生。但为了完整性,我们希望包括两者。
Java 代际垃圾回收
Java 使用所谓的代际垃圾回收。这意味着内存被划分为不同的“代”,随着对象经历足够的 GC 事件,它们会被提升到更老的代。对象将开始于所谓的年轻代,如果它们在年轻代中经历了足够的 GC 事件,最终会被提升到持久代。年轻代对象引用的集合称为小回收;持久代对象的集合称为大回收。
列表 7.5. 样本 GC 日志输出
2014-07-27T16:29:29.027+0500: 1.342: Application time: 0.6247300 seconds
2014-07-27T16:29:29.027+0500: 1.342: [GC 1.342: [DefNew: 8128K->8128K(8128K),
0.0000505 secs] 1.342: [Tenured: 18154K->2311K(24576K), 0.1290354 secs]
26282K->2311K(32704K), 0.1293306 secs]
2014-07-27T16:29:29.037+0500: 1.353: Total time for which application threads
were stopped: 0.0107480 seconds
让我们分析一下这个输出中的每一部分。图 7.8 显示了第一行,包含自上次 GC 以来应用程序运行的时间长度。
图 7.8. GC 日志输出显示–XX:+PrintGCDateStamps、–XX:+PrintGCTimeStamps和–XX:+PrintGCApplicationConcurrentTime的输出

下一行是–XX:+PrintGCDetails选项的结果,并分解成几个图,以便更好地解释所表示的内容。为了使图更简单,我们排除了日期/时间戳。图 7.9 显示了年轻代次要收集的 GC 细节。
图 7.9. GC 日志输出显示年轻代内存的垃圾回收细节

旧生代主要收集的 GC 细节显示在图 7.10 中。图 7.11 显示了–XX:+PrintGCDetails输出的最后部分,它显示了整体堆的值以及整个 GC 周期所花费的时间。
图 7.10. GC 日志输出显示旧生代内存的主要垃圾回收细节

图 7.11. GC 日志输出显示整个堆的值和完整的 GC 时间

在覆盖了 GC 输出的第一行和第二行之后,输出的最后一行很简单;–XX:+PrintGCApplicationStoppedTime选项会导致如下类似的行:2014-07-27T16:29:29.037+0500: 1.353: 应用程序线程停止的总时间:0.0107480 秒。这提供了对由于 GC 而暂停的应用程序持续时间的更高级别的描述。
就这么简单。一开始看起来令人畏惧的事情,当你将其分解成更小的部分时,就会很容易解释。能够阅读这些日志将极大地帮助你在调试 Storm 中的 JVM 竞争问题时,以及在任何运行在 JVM 上的应用程序中。通过了解如何设置和阅读 GC 日志,以及知道如何找到 OOM 错误,您将能够确定您的拓扑是否正在经历 JVM 竞争。
7.5.1. 问题
您的 spouts 和/或 bolts 正在尝试消耗比分配给 JVM 的内存更多的内存,导致 OOM 错误和/或长时间的 GC 暂停。
7.5.2. 解决方案
您可以通过以下几种方式来解决这个问题:
-
通过增加拓扑中使用的 worker 进程数量
-
通过增加 JVM 的大小
增加拓扑中使用的 worker 进程数量
请参阅第 7.1 节中的步骤。通过向拓扑中添加一个 worker 进程,您将减少该拓扑所有 worker 进程的平均负载。这应该会导致每个 worker 进程(JVM)的内存占用更小,希望消除 JVM 内存竞争。
增加 JVM(worker 进程)的大小
有关如何操作的步骤,请参阅第 7.2 节。因为增加 JVM 的大小可能需要你改变它们运行的机器/VM 的大小,所以我们建议如果你可以的话,采用“增加工作进程”的解决方案。
7.5.3. 讨论
在 JVM 之间进行交换和平衡内存一直是我们在 Storm 中遇到的最大挑战之一。不同的拓扑会有不同的内存使用模式。随着时间的推移,我们已经从每个工作节点有四个工作进程,每个使用 500 MB 内存,转变为每个工作节点有两个工作进程,每个使用 1 GB 内存。
我们的拓扑具有足够的并行度,每个线程的内存成本使得在 500 MB 时进行调优变得有困难。每个工作进程 1 GB 的内存,我们大多数拓扑都有足够的余量。有些接近那个限制,所以我们开始将负载更分散到多个工作节点上。
如果你一开始没有做对,不要担心。我们已经在生产环境中运行 Storm 两年了,并且随着我们的拓扑改变、增长和扩展,我们仍在调整每个工作进程的内存量和每台机器的工作进程数量。只需记住,这是一个永无止境的过程,因为你的集群和拓扑的形状在不断变化。
当增加分配给 JVM 的内存时,要小心;一般来说,当你越过某些关键点时,你会注意到垃圾收集时间的变化——500 MB、1 GB、2 GB 和 4 GB 都是我们的 GC 时间跳跃的点。这更多的是艺术而不是科学,所以带上你的耐心。没有什么比通过增加 JVM 内存大小来解决 OOM 问题,却发现它明显影响了 GC 时间更令人沮丧的。
7.6. 工作节点上的内存竞争
就像单个 JVM 有有限的可用内存一样,整个工作节点也是如此。除了运行你的 Storm 工作进程(JVMs)所需的内存外,你还需要内存来运行 Supervisor 进程以及在工作节点上运行的任何其他进程,而不进行交换(图 7.12)。
图 7.12. 工作节点有固定数量的内存,这些内存被其工作进程以及在该工作节点上运行的任何其他进程使用。

如果一个工作节点正在经历内存竞争,那么该工作节点将会进行交换。交换是小小的死亡,如果你关心延迟和吞吐量,就需要避免它。在使用 Storm 时,每个工作节点都需要有足够的内存,以便工作进程和操作系统不进行交换。如果你想保持一致的性能,你必须避免使用 Storm 的 JVMs 进行交换。
在 Linux 中,你可以使用sar(系统活动报告)命令来监控这一点。这是一个基于 Linux 的系统统计命令,它收集并显示所有系统活动和统计信息。我们以sar [option] [interval [count]]的格式运行这个命令(图 7.13)。
图 7.13. sar命令分解

可以传递各种选项来显示特定类型的统计信息。对于诊断工作节点内存争用,我们使用–S选项来报告交换空间利用率的统计信息。图 7.14 展示了交换空间利用率的输出。
图 7.14. sar –S 1 3命令报告交换空间利用率的输出

关于操作系统争用的说明
避免操作系统级别的争用的唯一方法是完全避开它!我们这是什么意思呢?让我们来解释一下。
如果您在每个工作节点上运行单个工作进程,那么在该节点上遇到工作进程之间的争用是不可能的。这可以使在集群内保持一致的性能变得容易得多。我们知道不止一个开发团队选择了这种方法。如果可能的话,我们建议您认真考虑走这条路。
如果您不在虚拟化环境中运行,这根本无法开始。如果您在单个物理机器上运行单个操作系统实例的“裸机”上运行,成本太高了。在虚拟化环境中,您将使用更多资源来做这件事。假设您的操作系统安装需要n GB 的磁盘空间,并且需要 2 GB 的内存来有效运行。如果您在您的集群上有八个工作节点,并且每个节点分配四个工作节点,您将使用n * 2 GB 的磁盘空间和 4 GB 的内存来在您的集群节点上运行操作系统。如果您要在每个节点上运行单个工作节点,那么这会激增到n * 8 GB 的磁盘空间和 16 GB 的内存。这在相当小的集群中是一个四倍的增长。想象一下,如果您有一个由 16、32、128 个或更多节点组成的集群,这将导致额外的使用量。如果您在像亚马逊网络服务(AWS)这样的环境中运行,您按节点付费,成本会迅速增加。因此,我们建议只有在您在硬件成本相对固定且您有额外的磁盘和内存资源的私有虚拟化环境中运行时才采用这种方法。
如果这个有限的场景不符合您的描述,请不要担心;我们在接下来的几页中有很多提示可以帮助您。即使它符合您的描述,您也仍然需要熟悉以下材料,因为单个拓扑结构仍然会遇到这些问题。
7.6.1. 问题
您的工作节点正在交换,因为对该节点内存的争用。
7.6.2. 解决方案
这是您如何解决这个问题:
-
增加每个工作节点可用的内存。这意味着根据您如何配置您的集群,给物理机器或虚拟机更多的内存。
-
降低工作进程使用的总内存。
降低工作进程使用的总内存
通过以下两种方式之一降低工作进程使用的总内存。第一种是减少每个工人节点的工作进程数量。参见第 7.1 节中的适当步骤。减少总工作进程数量将降低剩余进程组合的整体内存占用。
第二种方法是减小你的 JVMs 的大小。参见第 7.2 节中的这些步骤。不过,在降低现有 JVM 分配的内存时要小心,以避免在 JVM 内引入内存竞争。
7.6.3. 讨论
我们的解决方案是始终选择增加每台机器可用的内存。这是最简单的解决方案,其产生的后果也最容易理解。如果你内存紧张,降低内存使用可能有效,但你会面临我们之前讨论的每个 JVM 的 GC 和 OOM 问题。简而言之,如果你有多余的内存,请在每台机器上增加内存。
7.7. 工作节点 CPU 竞争
当对 CPU 周期的需求超过可用量时,就会发生工作节点 CPU 竞争。在使用 Storm 时这是一个问题,也是 Storm 集群中竞争的主要来源之一。如果你的 Storm 拓扑的吞吐量低于预期,你可能想检查运行拓扑的工作节点,看看是否存在 CPU 竞争。
在 Linux 中,你可以使用sar命令来监控这个问题,通过传递选项–u来显示所有 CPU 的实时 CPU 利用率。图 7.15 展示了 CPU 利用率以及你需要关注的列。
图 7.15. 输出 sar –u 1 3 以报告 CPU 利用率

7.7.1. 问题
你的拓扑吞吐量低,根据运行sar命令的结果,你看到存在 CPU 竞争。
7.7.2. 解决方案
为了解决这个问题,你有以下几种选择:
-
增加机器可用的 CPU 数量。这仅在虚拟化环境中才可行。
-
升级到更强大的 CPU(例如亚马逊网络服务(AWS)类型的环境)。
-
通过减少每个工作节点的工作进程数量来将 JVM 负载分散到更多的工人节点上。
将 JVM 负载分散到更多的工人节点上
为了将工作进程(JVM)负载分散到更多的工人节点上,你需要减少每个工人节点上运行的工作进程数量(参见第 7.1 节中的这些步骤)。减少每个工人节点上的工作进程数量会导致每个工人节点上完成的处理(CPU 请求)减少。当你尝试这个解决方案时,可能会遇到两种情况。第一种是你集群中有未使用的工作进程,因此可以减少现有节点上的工作进程数量,从而分散负载(图 7.16)。
图 7.16. 在有未使用工作进程的集群中减少每个工作节点的工作进程数量

第二种情况是,你没有未使用的工作进程,因此需要添加工作节点来减少每个工作节点的工作进程数量(图 7.17)。
图 7.17. 在没有未使用工作进程的集群中减少每个工作节点的工作进程数量,导致添加更多工作节点

减少每个工作节点的工作进程数量是减少每个节点请求的 CPU 周期的有效方法。你只需要意识到可用的和正在使用的资源,并在你的特定场景中相应地采取行动。
7.7.3. 讨论
如果你像我们一样运行自己的私有云,第一个选项是一个很好的选择。你的 Storm 节点正在不同的主机机器上运行,每个机器有x个可用的 CPU(在我们的情况下,16 个)。当我们最初开始使用 Storm 时,我们的计算需求很低,我们为每个节点分配了最多两个核心。最终这变得有问题,我们改为四个,然后是八个。大多数时候,每个节点并没有使用所有的 CPU,但需要时它就在那里。
你可以通过升级到更强大的 CPU 和/或更多可用核心,在 AWS 和其他托管解决方案中遵循相同的模式。但你会遇到限制。在单个物理盒子上运行的那么多虚拟机中,CPU 时间有限。如果你达到那个点或无法扩展 CPU,将负载分配到更多机器是你的唯一选择。
到目前为止,我们从未以这种方式解决过 CPU 使用问题(但我们以这种方式解决了别人的问题)。有时,我们以完全不同的方式解决了问题。结果发现,有一次我们的问题是由于一个错误,导致拓扑在紧密循环中反复无用地烧毁 CPU。这始终是你应该首先检查的,但以“你确定你没有搞砸吗?”开始讨论似乎不是一种友好的方式。
7.8. 工作节点 I/O 竞争
工作节点上的 I/O 竞争可以分为以下两类:
-
磁盘 I/O 竞争,从文件系统读取和写入
-
网络套接字 I/O 竞争,通过套接字从网络读取和写入
这两种类型的竞争对于某些类别的 Storm 拓扑来说是常见问题。确定你是否经历这两种竞争的第一步是确定工作节点是否在总体上经历 I/O 竞争。一旦你做到了,你就可以深入了解你的工作节点正在遭受的确切类型的 I/O 竞争。
确定你的集群中的工作节点是否经历 I/O 竞争的一种方法是通过运行带有 –u 选项的 sar 命令来显示实时 CPU 使用率。这是我们用于 第 7.7 节 中 CPU 竞争的相同命令,但这次我们将关注输出中的不同列(图 7.18)。
图 7.18. sar –u 1 3 的输出,用于报告 CPU 利用率和,特别是 I/O 等待时间

一个健康的拓扑结构,使用大量的 I/O,不应该花费大量时间等待资源可用。这就是为什么我们使用 10.00% 作为你开始经历性能下降的阈值。
你可能会认为区分套接字/网络和磁盘 I/O 竞争是一项困难的任务,但你会惊讶于你的直觉经常能引导你做出正确的选择。让我们来解释一下。
如果你了解在特定工作节点上运行哪些拓扑结构(第 7.3 节 讨论了如何确定这一点),你就知道它们会使用大量的网络资源或磁盘 I/O,并且你会看到 iowait 问题,你可能会安全地假设这两个问题中的哪一个是你遇到的问题。这里有一个简单的测试可以帮助你确定:如果你看到令人烦恼的 I/O 竞争迹象,首先尝试确定你是否遭受了套接字/网络 I/O 竞争。如果你没有,那么你很可能遭受的是磁盘 I/O 竞争。尽管这不一定总是情况,但它可以在你学习行业工具时带你走很长的路。
让我们深入探讨每个 I/O 竞争,以便你更全面地了解我们所讨论的内容。
7.8.1. 网络/套接字 I/O 竞争
如果你的拓扑结构通过网络与外部服务交互,网络/套接字 I/O 竞争很可能是你集群的问题。根据我们的经验,这种竞争的主要原因是为打开套接字分配的所有端口都被使用了。
大多数 Linux 安装将默认为每个进程最多 1024 个打开文件/套接字。在一个 I/O 密集型的拓扑结构中,很容易迅速达到这个限制。我们已经编写了每个工作节点打开数千个套接字的拓扑结构。为了确定你操作系统的限制,你可以检查 /proc 文件系统来查看你的进程限制。为了做到这一点,你首先需要知道你的进程 ID。一旦你做到了,你就可以获取该进程的所有限制列表。以下列表显示了如何使用 ps 和 grep 命令来查找你的进程 ID(即 PID),然后如何从 /proc 文件系统中获取你的进程限制。
列表 7.6. 确定资源限制

如果你遇到了这个限制,你的拓扑结构的 Storm UI 应该在“最后错误”列中显示一个异常,表明已达到最大打开文件限制。这很可能是以 java.net.SocketException: Too many open files 开头的堆栈跟踪。
处理饱和的网络链路和网络/套接字 I/O 密集型拓扑
我们从未见过饱和的网络链路,但我们知道在理论上这是可能的,所以我们在这里提到它,而不是为它专门写一个完整的配方。根据你的操作系统,你可以使用各种工具来确定你的网络链路是否饱和。对于 Linux,我们推荐使用iftop。
对于饱和的网络链路,你可以做两件事:1) 获取更快的网络或 2) 降低每个工作节点上的工作进程数量,以便将负载分散到更多的机器上;只要你的本地网络过载,而不是整个网络过载,这种方法就会有效。
问题
你的拓扑正在经历吞吐量降低或完全没有吞吐量,并且你看到错误,表明达到了打开套接字数量的限制。
解决方案
解决这个问题的几种方法如下:
-
增加工作节点上的可用端口
-
向集群中添加更多工作节点
为了增加可用端口,你需要在大多数 Linux 发行版的/etc/security/limits.conf文件中进行编辑。你可以添加如下所示的行:
* soft nofile 128000
* hard nofile 25600
这些设置将设置每个用户打开文件的数量上的硬限制和软限制。作为 Storm 用户,我们关注的值是软限制。我们不建议超过 128k。如果你这样做,那么作为一个经验法则(直到你了解更多关于 Linux 上打开文件数量的软/硬限制),我们建议将硬限制设置为软限制的两倍。请注意,你需要超级用户权限来更改limits.conf,并且你需要重新启动系统以确保它们生效。
增加集群中的工作节点数量将为你提供更多的端口。如果你没有资源添加更多的物理机器或虚拟机,你将不得不尝试第一种解决方案。
讨论
我们遇到的第一真正争用问题是每台机器上可用的套接字数量。我们使用了很多,因为我们的许多拓扑需要调用外部服务来查找从初始传入数据中不可用的额外信息。拥有大量的可用套接字是必须的。在你尽可能增加每个节点上的可用套接字之前,不要在其他机器上添加更多的工作进程。一旦你做到了这一点,你也应该看看你的代码。
你是否一直在打开和关闭套接字?如果你能保持连接打开,那就这么做。有一个美妙的东西叫做TCP_WAIT。这是一个 TCP 连接在关闭后会保持打开状态,等待任何散乱的数据。如果你在一个慢速网络链路上(就像 TCP 最初设计时许多人那样),这是一个美妙的主意,有助于防止数据损坏。如果你在一个快速的现代局域网中,这会让你发疯。你可以通过各种操作系统特定的方法调整你的 TCP 堆栈,以降低你在TCP_WAIT中逗留的时间,但当你进行大量的网络调用时,即使这样也不够。要聪明:尽可能少地打开和关闭连接。
7.8.2. 磁盘 I/O 竞争
磁盘 I/O 竞争会影响你向磁盘写入的速度。这可能是 Storm 的问题,但应该极其罕见。如果你正在向日志写入大量数据或将计算输出存储在本地文件系统上的文件中,可能会出现这个问题,但这种情况不太可能。
如果你有一个将数据写入磁盘的拓扑,并且注意到其吞吐量低于你的预期,你应该检查运行在工作节点上的拓扑是否正在经历磁盘 I/O 竞争。对于 Linux 安装,你可以运行一个名为iotop的命令来查看相关工作节点的磁盘 I/O 使用情况。此命令显示系统中进程/线程当前的 I/O 使用情况表,其中最密集的 I/O 进程/线程列在前面。图 7.19 显示了该命令及其相关输出,以及我们感兴趣的输出部分。
图 7.19. iotop命令的输出以及确定工作节点是否经历磁盘 I/O 竞争时需要注意的事项

问题
你有一个读取/写入磁盘的拓扑,看起来它运行在工作节点上正经历磁盘 I/O 竞争。
解决方案
为了解决这个问题
-
减少写入磁盘的数据量。这可能意味着在拓扑中减少数据量。也可能意味着如果多个工作进程在同一工作节点上对磁盘有需求,那么在每个工作节点上放置较少的工作进程。
-
获取更快的磁盘。这可能包括使用 RAM 磁盘。
-
如果你正在向 NFS 或其他网络文件系统写入,请立即停止。向 NFS 写入很慢,如果你这么做,你将为自己设置磁盘 I/O 竞争。
讨论
慢速磁盘 I/O 令人沮丧。它让我们发疯。最糟糕的是,快速磁盘并不便宜。我们在 Storm 工作节点上运行的磁盘相当慢。我们将快速磁盘留给我们真正需要速度的地方:Elasticsearch、Solr、Riak、RabbitMQ 以及我们基础设施中类似的写密集型部分。如果你正在向磁盘写入大量数据,而你又没有快速磁盘,你将不得不接受它作为瓶颈。如果不投入资金解决这个问题,你几乎无能为力。
7.9. 摘要
在本章中,你学习了以下内容:
-
在拓扑级别之上存在多种竞争类型,因此能够监控运行在工作节点上的操作系统如 CPU、I/O 和内存使用情况很有帮助。
-
对于你集群中机器/虚拟机的操作系统监控工具有一定的熟悉程度很重要。在 Linux 中,这些包括
sar、netstat和iotop。 -
了解常见的 JVM 启动选项很有价值,例如
–Xms、-Xmx以及与 GC 日志相关的选项。 -
虽然 Storm UI 是诊断许多类型竞争的出色工具,但拥有机器/虚拟机级别的其他类型监控也很明智,以便你知道是否有问题发生。
-
在你的单个拓扑中包含自定义指标/监控将为你提供 Storm UI 可能无法提供的宝贵见解。
-
在增加运行在工作节点上的工作进程数量时要小心,因为这可能会在节点级别引入内存和/或 CPU 竞争。
-
在减少运行在工作节点上的工作进程数量时要小心,因为这可能会影响你的拓扑吞吐量,同时也会在你的集群中引入对工作进程的竞争。
第八章. Storm 内部机制
本章涵盖
-
执行器在底层是如何工作的
-
元组如何在执行器之间传递
-
Storm 的内部缓冲区
-
Storm 内部缓冲区的溢出和调整
-
路由和任务
-
Storm 的调试日志输出
到这里,我们已经介绍了四个章节,涵盖了在生产环境中使用 Storm。我们解释了如何使用 Storm UI 来理解你的拓扑中正在发生的事情,如何使用这些信息来调整你的拓扑,以及如何诊断和治疗跨拓扑竞争问题。我们已经探索了许多你可以有效使用的工具。在本章中,我们将介绍另一个:对 Storm 内部机制的深入了解。
为什么我们认为这很重要呢?好吧,在前三章中,我们为你提供了处理可能遇到的问题的工具和策略,但我们无法预知你可能会遇到的所有可能的问题。每个 Storm 集群都是独特的;你硬件和代码的组合很可能会遇到我们从未见过的,也许其他人也没有见过的挑战。你对 Storm 工作原理的理解越深入,你处理这类问题的能力就越强。本章的意图,与上一章不同,并不是提供特定问题的解决方案。
要成为 Storm 调优、调试问题、为最大效率设计拓扑,以及运行生产系统所涉及的其他无数任务的专家,你需要对你所使用的工具有深入的理解。我们旨在本章带你深入了解构成 Storm 的抽象。我们不会带你深入到底层,因为 Storm 是一个活跃的项目,正在积极开发中,其中很多开发工作都在核心部分进行。但有一个比我们之前所覆盖的任何抽象都更深的层次,我们将努力让你熟悉这个层次。我们无法告诉你如何部署从本章获得的知识,但我们知道,直到你牢固掌握本章主题的内部机制,你不会掌握 Storm。
注意
本章中使用的某些术语并不直接对应于 Storm 源代码中的术语,但与精神相符。这是故意的,因为重点应该放在内部机制的工作方式上,而不是它们的命名上。
为了专注于 Storm 的内部机制而不是新用例的细节,我们将重新引入一个老朋友,即第二章中的提交计数拓扑 chapter 2。让我们快速回顾一下这个拓扑,以防你忘记了。
8.1. 重新审视提交计数拓扑
提交计数拓扑提供了一个简单的拓扑(一个 Spout 和两个 Bolt),我们可以用它来解释在特定用例的上下文中 Storm 的内部结构,但又不至于陷入用例的细节。话虽如此,为了教学目的,我们还会在这个拓扑结构中添加一些额外的限定条件。但在我们讨论这些限定条件之前,让我们快速回顾一下拓扑结构本身。
8.1.1. 回顾拓扑设计
如您从第二章中回忆的那样,提交计数拓扑被分解为(1)一个 Spout,它将从源读取提交,以及(2)两个 Bolt,它们将分别从提交消息中提取电子邮件并存储每个电子邮件的计数。所有这些都可以在图 8.1 中看到。
图 8.1. 提交计数拓扑设计和 Spout 与 Bolt 之间的数据流

这个设计简单易懂。因此,它为我们提供了一个很好的场景,可以深入研究 Storm 的内部结构。在本章中,我们将做的一件事是展示部署到远程 Storm 集群上的拓扑结构,而不是在本地模式下运行。让我们讨论为什么需要这样做,以及我们的拓扑结构在部署到具有多个工作节点的远程集群时可能看起来是什么样子。
8.1.2. 将拓扑视为在远程 Storm 集群上运行
将拓扑结构展示为在远程 Storm 集群上运行对于本章至关重要,因为我们要讨论的 Storm 内部结构只存在于远程集群设置中。为此,我们将说我们的拓扑结构运行在两个工作节点上。这样做允许我们解释当元组在同一个工作进程(JVM)内的组件之间传递时以及跨工作进程(从一个 JVM 到另一个 JVM)时会发生什么。图 8.2 展示了两个工作节点以及每个 Spout 和 Bolt 执行的具体位置。这张图应该看起来很熟悉,因为我们曾在第五章中提供了一个类似的假设配置,即信用卡授权拓扑结构。
图 8.2. 提交计数拓扑在两个工作节点上运行,其中一个工作进程执行 Spout 和 Bolt,另一个工作进程执行 Bolt

8.1.3. 集群中 Spout 和 Bolt 之间的数据流
让我们追踪一个元组通过拓扑结构的流动,就像我们在第二章中所做的那样。但与从图 8.1 的视角展示数据流不同,我们将从数据在我们 Spout 和 Bolt 实例之间、执行器和工作进程之间传递的视角展示它(图 8.3)。
图 8.3. 将拓扑结构中的数据流分解为六个部分,每个部分都突出了执行器内部的不同之处或数据在执行器之间传递的方式

图 8.3 很好地说明了元组如何在单个 JVM(工作进程)内的实例之间以及在不同工作节点之间流动,包括数据流向完全不同的 JVM(工作进程)。将 图 8.3 想象为元组在组件之间传递的 10,000 英尺视图。本章的目标是深入探讨 图 8.3 中发生的事情,所以让我们按照我们场景中执行器内部和之间的数据流进行操作。
8.2. 深入了解执行器的细节
在前面的章节中,我们将执行器描述为在 JVM 上运行的单个线程。这至今为止都对我们很有帮助。在我们日常对自身拓扑的推理中,我们通常也会将执行器抽象到这个层面。但是,执行器不仅仅是单个线程。让我们从我们运行的读取数据源数据的 spout 实例的执行器开始讨论我们所说的这个意思。
8.2.1. 提交消息监听 spout 的执行器详细信息
数据通过提交消息监听 spout 进入提交计数拓扑,该 spout 监听包含单个提交消息的数据流。图 8.4 展示了我们在拓扑中的数据流位置。
图 8.4. 专注于流入 spout 的数据

这个执行器比单个线程要复杂一些。它实际上是两个线程和一个队列。第一个线程就是我们所说的 主线程,主要负责运行用户提供的代码;在这个例子中,就是我们在 nextTuple 中编写的代码。第二个线程是我们将要称为 发送线程 的线程,我们将在下一节中简要讨论它,它负责将元组传递到拓扑中的下一个 bolt。
除了两个线程外,我们还有一个用于将发出的元组从执行器中传输出去的单个队列。将这个队列想象成一个执行后-spout 函数。这个队列是为了在执行器之间实现高性能消息传递而设计的。它是通过让队列实现依赖于一个称为 LMAX Disruptor 的库来实现的。图 8.5 更详细地展示了我们 spout 的执行器,其中有两个线程和一个队列。
¹ LMAX Disruptor 是一个高性能的线程间消息库。它是一个开源项目,可在
lmax-exchange.github.io/disruptor找到。
图 8.5. Spout 从包含提交消息的队列中读取消息,并将这些消息转换为元组。执行器上的主线程处理发出的元组,将它们传递到执行器的输出队列。

图 8.5 中的插图涵盖了喷泉实例读取的数据和主线程接收喷泉发出的元组并将其放置在输出队列上的情况。没有涵盖的是,一旦发出的元组被放置在输出队列上会发生什么。这就是发送线程发挥作用的地方。
8.2.2. 在同一 JVM 上的两个执行器之间传输元组
我们的元组已经被放置在喷泉的输出 disruptor 队列中。现在怎么办?在我们深入探讨这里发生的事情之前,让我们看一下图 8.6,它显示了我们在拓扑数据流中的位置。
图 8.6. 聚焦于同一 JVM 内传递元组

一旦数据被放置在喷泉的输出 disruptor 队列中,发送线程将从这个输出 disruptor 队列中读取该元组,并通过一个传输函数将其发送到适当的执行器(们)。
由于提交数据源监听器喷泉和电子邮件提取器螺栓都在同一个 JVM 上,这个传输函数将在执行器之间执行一个本地传输。当发生本地传输时,执行器的发送线程直接将输出元组发布给下一个执行器。这里的两个执行器都在同一个 JVM 上,因此在发送过程中几乎没有开销,这使得这种传输函数非常快。这更详细地在本节图 8.7 中说明。
图 8.7. 对提交数据源监听器喷泉和电子邮件提取器螺栓之间元组本地传输的更详细查看

我们的第一个螺栓的执行器是如何直接接收元组的?这将在下一节中介绍,我们将分解电子邮件提取器螺栓的执行器。
8.2.3. 电子邮件提取器螺栓的执行器详细信息
到目前为止,我们已经涵盖了我们的喷泉从数据源中读取提交消息并为每个单独的提交消息发出一个新元组。我们现在处于第一个螺栓,即电子邮件提取器,准备处理传入元组的状态。图 8.8 突出了我们在数据流中的位置。
图 8.8. 聚焦于发射元组的螺栓

由于我们已经涵盖了喷泉的执行器,您可能对我们的螺栓执行器有一些了解。喷泉和螺栓之间的唯一真正区别是,螺栓的执行器有一个额外的队列:处理传入元组的队列。这意味着我们的螺栓执行器有一个传入的 disruptor 队列和一个主线程,该线程从传入的 disruptor 队列中读取一个元组并处理该元组,从而产生零个或多个要发出的元组。这些发出的元组被放置在输出 disruptor 队列中。图 8.9 详细说明了这些细节。
图 8.9. 我们的螺栓执行器,具有两个线程和两个队列

一旦电子邮件提取 bolt 处理完元组,它就准备好发送到下一 bolt,即提交计数 bolt。我们已经讨论了当元组在提交 feed 监听 spout 和电子邮件提取 bolt 之间发送时会发生什么。这是在本地传输中发生的。但是,当在电子邮件提取 bolt 和提交计数 bolt 之间发送数据时,我们处于不同的情境。bolt 实例在不同的 JVM 上运行。让我们接下来讨论这个场景中会发生什么。
8.2.4. 在不同 JVM 之间传输元组
如我们之前提到的,电子邮件提取 bolt 和提交计数 bolt 在不同的 JVM 上运行。图 8.10 显示了我们的拓扑数据流中我们所在的确切位置。
图 8.10. 专注于在 JVM 之间发送元组

当元组被发送到在单独 JVM 上运行的 executor 时,发送 executor 的发送线程将执行一个传输函数,执行我们所说的远程传输。远程传输比本地传输更复杂。当 Storm 需要从一个 JVM 向另一个 JVM 发送元组时会发生什么?这个过程的第一步是将我们的元组序列化以进行传输。根据你的元组,这可能是一个相当昂贵的操作。在序列化元组时,Storm 试图查找该对象的 Kryo 序列化器并准备好传输。如果没有 Kryo 序列化器,Storm 将回退到标准的 Java 对象序列化。Kryo 序列化比 Java 序列化更高效,所以如果你关心从你的拓扑中提取每一分性能,你将希望为你的元组注册自定义序列化器。
一旦元组被序列化以进行跨 JVM 传输,我们的 executor 的发送/传输线程将其发布到另一个 disruptor 队列。这个队列是我们整个 JVM 的传输队列。任何时候,这个 JVM 上的 executor 需要将元组传输到其他 JVM 上的 executor 时,那些序列化的元组将被发布到这个队列。
一旦元组进入这个队列,另一个线程,即工作进程的发送/传输线程,将其取走,并通过 TCP 将其发送到目标 JVM。
在目标 JVM 上,另一个线程,即工作进程的接收线程,正在等待接收元组,然后将其传递给另一个函数,即接收函数。工作接收函数,就像 executor 的传输函数一样,负责将元组路由到正确的 executor。接收线程将我们的元组发布到传入的 disruptor 队列中,在那里它可供 executor 的主线程进行处理。整个过程可以在图 8.11 中看到。
图 8.11. 在不同 JVM 的 executors 之间远程传输元组时发生的步骤

在我们的提交计数示例中,我们的电子邮件提取螺栓从一个提交中提取了一个电子邮件地址,例如 sean@example.com,并将其放置在执行器的传输队列中,执行器的发送线程将其拾取并传递给传输函数。在那里,它被序列化以进行传输,并放置在工作者的传输队列中。然后另一个线程拾取传输并将其通过 TCP 发送到我们的第二个工作者,接收线程接受它并通过接收函数将其路由到正确的执行器,将其放置在该执行器的传入 disruptor 队列中。
关于 Netty 的一些话
在本节中,我们使用了 TCP 这个术语来讨论构成 Storm 集群的 JVM 之间的连接。截至 Storm 的当前版本,网络传输由 Netty 提供,这是一个旨在简化构建高性能异步网络应用程序的强大框架。它拥有丰富的设置,允许你调整其性能。
对于标准的 Storm 安装,你不应该需要调整 Storm 提供的任何 Netty 设置。如果你发现自己遇到了 Netty 性能问题,就像任何其他设置一样,在改变之前准备好测量前后变化。
提供足够的信息,让你能够自信地调整 Netty,超出了本书的范围。如果你对学习 Netty 感兴趣,我们强烈建议你阅读 Netty 贡献者 Norman Maurer 所著的 Netty in Action(Manning,2015)。
8.2.5. 电子计数螺栓的执行器细节
这个螺栓的执行器与我们的前一个螺栓的执行器类似,但由于这个螺栓不发射元组,执行器的发送线程不需要做任何工作。图 8.12 突出了我们在执行器之间元组流中的位置。
图 8.12. 关注一个不发射元组的螺栓

这个执行器内部发生的事情的细节可以在图 8.13 中看到。注意步骤数量减少了,因为我们在这个螺栓中不发射元组。
图 8.13. 电子计数螺栓的执行器,主线程从传入的 disruptor 队列中拉取一个元组并将其发送到螺栓进行处理

我们的数据现在已经从起始的 spout 流经电子计数螺栓。它的生命周期几乎结束。它将被反序列化并处理,并且该电子邮件地址的计数将被更新。我们的电子计数螺栓不会发射新的元组——它确认其传入的元组。
8.3. 路由和任务
在本书中,我们有时只解释了一些内容,后来又通过省略来承认我们撒了谎,以便解释一个概念的基本原理。因此,我们到目前为止在本章中的解释也是如此。我们省略了一个非常重要的对话部分。但不用担心;现在你已经掌握了 Storm 的核心部分,我们可以讨论 任务和路由。
在第三章中,我们介绍了执行器和任务。图 8.14 应该看起来很熟悉——这是分解工作节点作为一个运行执行器并带有任务(spout/bolt 实例)的 JVM 的图,但更新了你对执行器如何工作的当前理解。
图 8.14. 一个工作进程及其内部线程和队列,以及执行器及其内部线程、队列和任务

让我们更深入地探讨一下任务。正如我们在第三章中所述,执行器可以有一个或多个任务,其中执行器负责“执行”任务中用户逻辑。当执行器有多个任务(图 8.15)时,这是如何工作的?
图 8.15. 具有多个任务的执行器

这就是路由介入的地方。在这个上下文中,“路由”指的是工作进程的接收线程(远程传输)或执行器的发送线程(本地传输)如何将一个元组发送到其正确的下一个位置(任务)。这是一个多步骤的过程,通过具体的例子会更容易理解。我们将使用电子邮件提取器作为例子。图 8.16 展示了电子邮件提取器的主线程运行execute方法并发出一个元组之后发生的情况。
图 8.16. 确定发出的元组的目标任务时采取的步骤

图 8.16 应该看起来有些熟悉。它包括我们一直在讨论的一些内部队列和线程,以及确定哪个任务应该执行发出的元组时采取的步骤的注释。该图引用了一个任务 ID 和元组对,它以TaskMessage类型对象的格式出现:
public class TaskMessage {
private int _task;
private byte[] _message;
...
}
这就结束了我们对 Storm 内部队列的解释。现在,我们将继续讨论这些队列可能发生的溢出以及解决这种溢出的一些方法。
8.4. 知道 Storm 内部队列何时溢出
在相对较短的时间内,我们已经涵盖了大量的内容。到现在,你应该已经对构成执行器的内容有了相当的了解。但在我们深入调试日志的细节之前,我们想让你回顾一下我们之前讨论的 Storm 内部三个队列。
8.4.1. 内部队列的各种类型以及它们可能发生的溢出
在我们讨论执行器时,我们确定了 Storm 内部三个队列:
-
执行器的输入队列
-
执行器的输出队列
-
工作节点上存在的输出队列
我们喜欢谈论故障排除和可能出错的情况,因此我们提出问题:要使每个队列溢出需要什么?
好吧。花一分钟时间。我们会等待。对于一个队列要溢出,任何产生并进入队列的数据都必须以比其被消费更快的速度生成。我们想要关注的是生产者和消费者之间的关系。我们将从查看执行器的输入队列开始。
执行器的输入队列
此队列接收来自拓扑中 preceding spout/bolt 的元组。如果 preceding spout/bolt 产生元组的速率比消费 bolt 处理它们的速率快,你将遇到溢出问题。
元组将遇到的下一个队列是执行器的输出传输队列。
执行器的输出传输队列
这个有点复杂。这个队列位于执行器的主线程(执行用户逻辑)和负责将元组路由到下一个任务的传输线程之间。为了使这个队列出现拥堵,你需要以比元组路由、序列化等更快的速度处理传入的元组。这是一个相当高的要求——我们实际上从未遇到过这种情况——但我们确信有人遇到过。
如果我们处理的是要传输到另一个 JVM 的元组,我们将遇到第三个队列,即工作进程的输出传输队列。
工作进程的输出传输队列
此队列接收来自工作进程中所有执行器、旨在发送到另一个不同工作进程的元组。考虑到工作进程内部有足够的执行器产生需要通过网络发送到其他工作进程的元组,你可能会溢出此缓冲区。但你可能需要付出很大努力才能做到这一点。
如果你开始溢出这些缓冲区之一,会发生什么?Storm 将溢出的元组放置在(希望是)临时的溢出缓冲区中,直到给定队列上有空间。这将导致吞吐量下降,并可能导致拓扑停止运行。如果你使用的是将元组均匀分配到任务中的 shuffle 分组,这应该是一个问题,你可以使用第六章(chapter 6)中的调整技术或第七章(chapter 7)中的故障排除技巧来解决。
如果你没有在任务之间均匀分配元组,宏观层面上的问题将更难发现,并且第六章(chapters 6)和第七章(chapters 7)中的技术可能无法帮助你。那么你该怎么办?首先,你需要知道如何判断缓冲区是否溢出以及可以采取哪些措施。这正是 Storm 的调试日志能提供帮助的地方。
8.4.2. 使用 Storm 的调试日志诊断缓冲区溢出
要查看 Storm 的内部缓冲区是否溢出,最佳位置是 Storm 日志中的调试日志输出。图 8.17 展示了 Storm 日志文件中的一个示例调试条目。
图 8.17. 执行器实例的调试日志输出快照

在图 8.17 中,我们突出显示了与发送/接收队列相关的行,分别提供了每个队列的指标。让我们更详细地看看这些行。
图 8.18 中的示例显示了两个几乎不会溢出的队列,但应该很容易判断它们是否溢出。假设你正在使用洗牌分组来将元组均匀地分配到 bolt 和任务中,检查任何 bolt 的任何任务的值应该足以确定你接近容量有多近。如果你使用的是不会均匀分配元组到 bolt 和任务的分组,你可能会更难快速发现问题。不过,一点自动化的日志分析应该能帮你找到需要的地方。日志条目的模式已经建立,提取每个条目并查找达到或接近容量的值,将是一个构建和使用适当工具的问题。
图 8.18. 分解发送/接收队列指标的调试日志输出行

现在你已经知道如何确定 Storm 的内部队列中是否有溢出,我们将向你展示一些停止溢出的方法。
8.5. 解决内部 Storm 缓冲区溢出问题
你可以通过四种主要方式之一来解决内部 Storm 缓冲区溢出问题。这些选项不是非此即彼的——你可以根据需要混合使用,以解决问题:
-
调整生产到消费的比例
-
增加所有拓扑的缓冲区大小
-
增加给定拓扑的缓冲区大小
-
设置最大 spout 挂起数
让我们逐一介绍,首先是调整生产到消费的比例。
8.5.1. 调整生产到消费的比例
减少元组的生成速度或增加消费速度是处理缓冲区溢出的最佳选择。你可以降低生产者的并行度或提高消费者的并行度,直到问题消失(或者变成另一个问题!)!除了调整并行度之外,还可以检查消费 bolt(在execute方法内部)中的用户代码,找到使其运行更快的方法。
对于执行器缓冲区相关的问题,有很多原因说明调整并行度并不能解决问题。除了洗牌分组之外的其他流分组可能会导致一些任务处理比其他任务多得多的数据,导致它们的缓冲区比其他任务更活跃。如果分布特别不均匀,你可能会因为添加大量消费者来处理最终是数据分布问题的情况而出现内存问题。
当处理溢出的工作传输队列时,“增加并行性”意味着添加更多的工作进程,从而(希望)降低执行器到工作进程的比例,减轻工作传输队列的压力。然而,数据分布问题仍然可能出现。如果你添加了另一个工作进程后,大多数元组都绑定到同一工作进程的任务上,那么你并没有获得任何好处。
当你不均匀地分配元组时,调整生产到消费的比例可能会很困难,而你获得的所有收益可能会因为输入数据形状的变化而丢失。尽管你可能通过调整比例获得一些效果,但如果你不依赖于 shuffle 分组,我们其他三个选项中可能更有助于你。
8.5.2. 增加所有拓扑的缓冲区大小
我们会诚实地告诉你:这是一种用大炮打苍蝇的方法。每个拓扑都需要增加缓冲区大小的可能性很低,你可能不想在整个集群中更改缓冲区大小。话虽如此,也许你有一个非常好的理由。你可以通过调整以下 storm.yaml 中的值来更改拓扑的默认缓冲区大小:
-
所有执行器的输入队列的默认大小可以通过
topology.executor.receive.buffer.size的值进行更改 -
所有执行器的输出队列的默认大小可以通过
topology.executor.send.buffer.size的值进行更改 -
工作进程的输出传输队列的默认大小可以通过
topology.transfer.buffer.size的值进行更改
需要注意的是,你设置任何 disruptor 队列缓冲区大小的值都必须是 2 的幂——例如,2、4、8、16、32 等等。这是 LMAX disruptor 强制要求的。
如果你不想更改所有拓扑的缓冲区大小,并且需要更细粒度的控制,那么增加单个拓扑的缓冲区大小可能是你想要的选项。
8.5.3. 增加特定拓扑的缓冲区大小
单个拓扑可以覆盖集群的默认值,并为任何 disruptor 队列设置自己的大小。这是通过在提交拓扑时传递给StormSubmitter的Config类来完成的。与前面的章节一样,我们一直在将此代码放置在RemoteTopologyRunner类中,如下所示。
列表 8.1. RemoteTopologyRunner.java带有增加缓冲区大小的配置
publc class RemoteTopologyRunner {
public static void main(String[] args) {
...
Config config = new Config();
...
config.put(Config.TOPOLOGY_EXECUTOR_RECEIVE_BUFFER_SIZE,
new Integer(16384));
config.put(Config.TOPOLOGY_EXECUTOR_SEND_BUFFER_SIZE,
new Integer(16384));
config.put(Config.TOPOLOGY_TRANSFER_BUFFER_SIZE,
new Integer(32));
StormSubmitter.submitTopology("topology-name",
config,
topology);
}
}
这就带我们来到了我们的最后一个选项(这也是你应该熟悉的):设置最大 spout 挂起数。
8.5.4. 最大 spout 挂起数
我们在第六章中讨论了最大 spout 挂起数。如您所回忆的那样,最大 spout 挂起数限制了任何给定的 spout 在任何时候在拓扑中保持活跃的元组数量。这如何帮助防止缓冲区溢出?让我们尝试一些数学计算:
-
单个 spout 的最大 spout 挂起数为 512。
-
最小的干扰器具有 1024 的缓冲区大小。512 < 1024
假设你的所有 bolt 不会创建比它们消耗更多的元组,那么在拓扑中不可能有足够的元组在游戏中以溢出任何给定的缓冲区。如果你有一些 bolt 消耗单个元组但发射可变数量的元组,这个数学可能会变得复杂。这里有一个更复杂的例子:
-
单个 spout 的最大 spout 待处理数量为 512。
-
最小的干扰器具有 1024 的缓冲区大小。
我们的一个 bolt 只接受一个元组并发射 1 到 4 个元组。这意味着我们的 spout 在某个时间点将发射的 512 个元组可能导致拓扑中从 512 到 2048 个元组在游戏中。或者换句话说,我们可能会遇到缓冲区溢出问题。不考虑缓冲区溢出,设置 spout 的最大 spout 待处理值是一个好主意,并且应该始终这样做。
在解决了处理缓冲区溢出的四种解决方案之后,我们将把注意力转向调整这些缓冲区的大小,以便在 Storm 拓扑中获得最佳性能。
8.6. 调整缓冲区大小以获得性能提升
许多博客文章都在流传,详细介绍了基于部分更改 Storm 内部干扰器缓冲区大小的性能指标。在这个章节中,我们不应该不讨论这个性能调整方面。但首先,有一个警告:Storm 有许多内部组件,其配置通过 storm.yaml 和程序性方式公开。我们在第 8.5 节中提到了一些。如果你找到一个设置但不知道它做什么,不要更改它。先进行研究。在一般上了解你正在更改的内容,并考虑它可能如何影响吞吐量、内存使用等。在你能够监控你更改的结果并验证你得到了期望的结果之前,不要更改任何内容。
最后,请记住 Storm 是一个复杂的系统,每次额外的更改都是基于之前的更改。你可能有两个不同的配置更改——让我们称它们为 A 和 B——它们独立地导致期望的性能变化,但结合在一起会导致退化的变化。如果你按照 A 然后是 B 的顺序应用它们,你可能会认为 B 是一个较差的更改。但这可能并不正确。让我们提出一个假设场景来展示我们的意思:
-
改变 A 导致吞吐量提高 5%。
-
改变 B 会导致吞吐量提高 10%。
-
改变 A 和 B 会导致吞吐量下降 2%。
理想情况下,你应该使用更改 B,而不是更改 A,以获得最佳性能。务必独立测试更改。准备以累加的方式测试,将更改 B 应用到已经包含 A 的现有配置中,以及将 B 应用到“标准”Storm 配置中。
所有这些假设都是基于你需要从你的拓扑中榨取每一丝性能。我们给你透露一个秘密:我们很少这样做。我们花足够的时间在给定的拓扑中获取可接受的性能,然后结束工作,继续其他任务。我们怀疑你们大多数人也会这样做。这是一个合理的做法,但我们仍然觉得,如果你正在增加你的 Storm 使用量,了解各种内部机制,开始调整、设置和理解它们如何影响性能是很重要的。阅读关于它是回事——亲自体验则是完全不同的。
这就结束了我们对 Storm 内部机制的章节。我们希望你在了解 Storm 内部缓冲区“幕后”发生的事情、这些缓冲区可能如何溢出、如何处理溢出以及一些关于如何进行性能调整的想法方面找到了一些价值。接下来,我们将转换话题,介绍 Storm 的高级抽象:Trident。
8.7. 概述
在本章中,你了解到
-
执行器不仅仅是单个线程,它由两个线程(主/发送者)以及两个中断队列(输入/输出)组成。
-
在同一 JVM 上的执行器之间发送元组既简单又快速。
-
工作进程有自己的发送/传输线程、输出队列和接收线程,用于处理在 JVM 之间发送元组。
-
每个内部队列(缓冲区)都可能溢出,导致你的 Storm 拓扑中的性能问题。
-
每个内部队列(缓冲区)都可以配置以解决任何潜在的溢出问题。
第九章. Trident
本章涵盖
-
Trident 及其为何有用
-
将 Trident 操作和流作为一系列批处理元组
-
Kafka,其设计以及如何与 Trident 相匹配
-
实现 Trident 拓扑
-
使用 Storm 的分布式远程过程调用(DRPC)功能
-
通过 Storm UI 将原生 Storm 组件映射到 Trident 操作
-
扩展 Trident 拓扑
在《Storm 应用》中我们已经走得很远了。早在第二章中,我们就介绍了 Storm 的基本抽象:bolt、spout、元组和流。在前六章中,我们深入探讨了这些基本抽象,涵盖了高级主题,如保证消息处理、流分组、并行性以及更多。第七章提供了一种烹饪法来识别各种类型的资源竞争,而第八章则带你进入 Storm 基本抽象之下的抽象层次。理解所有这些概念对于掌握 Storm 至关重要。
在本章中,我们将介绍 Trident,这是位于 Storm 基本抽象之上的高级抽象,并讨论它如何允许你用“是什么”而不是“怎么做”来表述拓扑。我们将在一个最终用例的背景下解释 Trident:一个互联网广播应用程序。但与我们在前几章中一样,我们从解释 Trident 开始。因为 Trident 是一个高级抽象,我们在设计用例解决方案之前理解这个抽象是有意义的,因为这种理解可能会影响我们互联网广播拓扑的设计。
本章将从解释 Trident 及其核心操作开始。然后,我们将讨论 Trident 如何处理流作为一系列批次,这与原生的 Storm 拓扑不同,以及为什么 Kafka 是 Trident 拓扑的完美匹配。到那时,我们将为我们的互联网广播应用程序制定一个设计,然后是相关的实现,其中包括 Storm 的 DRPC 功能。一旦我们有了实现,我们将讨论扩展 Trident 拓扑。毕竟,Trident 只是一个抽象,但最终结果仍然是一个必须调整和优化以实现最佳性能的拓扑。
不再拖延,我们将向您介绍 Trident,这是位于 Storm 基本抽象之上的抽象。
9.1. 什么是 Trident?
Trident 是建立在 Storm 基本抽象之上的抽象。它允许你用“是什么”(声明式)而不是“怎么做”(命令式)来表述拓扑。为了实现这一点,Trident 提供了诸如连接、聚合、分组、函数和过滤器等操作,并提供在任意数据库或持久化存储上执行有状态、增量处理的原语。如果你熟悉像 Pig 或 Cascading 这样的高级批处理工具,Trident 的概念对你来说将是熟悉的。
使用 Storm 表达计算,用你想要完成的“是什么”而不是“如何”来表示,这意味着什么?我们将通过查看我们在第二章 [kindle_split_010.html#ch02] 中构建的 GitHub 提交计数拓扑以及将其与这个拓扑的 Trident 版本进行比较来回答这个问题。你可能还记得,第二章 [kindle_split_010.html#ch02] 中 GitHub 提交计数拓扑的目标是从包含电子邮件的提交消息流中读取,并跟踪每个电子邮件的提交计数。
第二章 以如何按电子邮件计数提交消息的术语描述了 GitHub 提交计数拓扑。这是一个机械的、命令式的过程。下面的列表显示了构建此拓扑的代码。
列表 9.1. 构建 GitHub 提交计数 Storm 拓扑

通过查看这个拓扑的构建方式,你可以看到我们
将一个 spout 分配给拓扑以监听提交消息,
定义我们的第一个 bolt 从每个提交消息中提取电子邮件,
告诉 Storm 元组如何在我们的 spout 和第一个 bolt 之间发送,
定义我们的第二个 bolt,它保持电子邮件数量的运行计数,并以
结尾,在那里我们告诉 Storm 元组如何在我们的两个 bolt 之间发送。
这同样是一个机械的过程,一个特定于“我们如何”解决提交计数问题的过程。列表中的代码易于理解,因为拓扑本身并不复杂。但是,当查看更复杂的 Storm 拓扑时,可能就不是这样了;在更高层次上理解正在执行的操作可能会变得困难。
这就是 Trident 如何帮助的地方。通过其“连接”、“分组”、“聚合”等概念,我们在比 bolt 或 spout 更高的层次上表达计算,这使得理解正在执行的操作变得更容易。让我们通过查看 GitHub 提交计数拓扑的 Trident 版本来展示我们的意思。注意在下面的列表中,代码更多地以我们正在做的“是什么”而不是“如何”执行来表达。
列表 9.2. 构建 GitHub 提交计数 Trident 拓扑

一旦你理解了 Trident 的概念,理解我们的计算就比如果我们用 spouts 和 bolts 来表示它要容易得多。即使没有对 Trident 有太多的理解,我们也可以看到我们
创建一个从 spout 来的流,对于流中的每个条目
,我们将commit字段分割成多个email字段条目,将类似的电子邮件分组
,并持久化电子邮件的数量
。
如果我们遇到这个列表中的代码,与迄今为止使用的 Storm 原语等价的代码相比,我们会更容易理解正在发生的事情。我们以更接近纯“是什么”的水平表达我们的计算,其中混合了更少的“如何”。
列表中的代码涉及了 Trident 的一些抽象,这些抽象可以帮助你编写表达“是什么”而不是“如何做”的代码。让我们看看 Trident 提供的操作的全范围。
9.1.1. 不同类型的 Trident 操作
我们对使用 Trident 以“是什么”而不是“如何做”的方式来表达我们的代码有一个模糊的概念。在前一节的代码中,我们有一个 Trident spout 发出一个流,该流将被一系列 Trident 操作转换。这些操作的组合构成了一个 Trident 拓扑。
这听起来与建立在 Storm 原始操作(spouts 和 bolts)之上的 Storm 拓扑相似,但不同的是,我们用 Trident spout 替换了 Storm spout,用 Trident 操作替换了 Storm bolts。这种直觉是不正确的。重要的是要理解 Trident 操作并不直接映射到 Storm 原始操作。在原生的 Storm 拓扑中,你在一个执行你的操作(s)的 bolt 中编写你的代码。你得到的是一个 bolt 的执行单元,你可以在其中自由地做任何你想做的事。但与 Trident 不同,你没有这种灵活性。你提供了一系列标准操作,并需要找出如何将你的问题映射到这些标准操作之一或多个,很可能是将它们链接在一起。
可用的不同 Trident 操作很多,你可以使用它们来实现你的功能。从高层次来看,它们可以列出如下:
-
函数— 对进入的元组进行操作并发出一个或多个相应的元组。
-
过滤器— 决定保留或过滤掉从流中进入的元组。
-
分割— 分割流将导致多个具有相同数据和字段的流。
-
合并— 只有当流具有相同的字段(相同的字段名称和相同数量的字段)时,才能合并流。
-
连接— 连接用于具有大部分不同字段的不同流,除了一个或多个用于连接的公共字段(类似于 SQL 连接)。
-
分组— 在分区内部按特定字段(s)进行分组(关于分区的更多内容稍后讨论)。
-
聚合— 对元组集进行计算。
-
状态更新器— 将元组或计算值持久化到数据存储。
-
状态查询— 查询数据存储。
-
重新分区— 通过对特定字段(类似于字段分组)进行哈希或以随机方式(类似于洗牌分组)重新分区流。通过某些特定字段进行重新分区与分组不同,因为重新分区发生在所有分区上,而分组发生在单个分区内。
将你的问题表示为一系列这些操作,可以使你比本地 Storm 原语允许的更高层次地思考和推理。这也使得将这些不同操作连接在一起的 Trident API 感觉更像是一种领域特定语言(DSL)。例如,假设你有一个需要将计算结果保存到数据存储的步骤。在那个步骤中,你会连接一个状态更新器操作。无论该状态更新器操作是写入 Cassandra、Elasticsearch 还是 Redis,这都完全无关紧要。实际上,你可以有一个写入 Redis 的状态更新器操作,并在不同的 Trident 拓扑之间共享它。
希望你已经开始理解 Trident 提供的抽象类型。现在不必担心这些各种操作是如何实现的。当我们深入研究我们的互联网广播拓扑的设计和实现时,我们很快就会介绍这一点。但在我们开始设计该拓扑之前,我们需要覆盖一个额外的主题:Trident 如何处理流。这与本地 Storm 拓扑处理流的方式根本不同,并将影响我们互联网广播拓扑的设计。
9.1.2. Trident 流作为一系列批次
Trident 拓扑与本地 Storm 拓扑之间的一个基本区别是,在 Trident 拓扑中,流被处理为元组批次,而在本地 Storm 拓扑中,流被处理为一系列单个元组。这意味着每个 Trident 操作处理一个元组批次,而每个本地 Storm bolt 在单个元组上执行。图 9.1 提供了这个概念的说明。
图 9.1。Trident 拓扑在元组批次流上操作,而本地 Storm 拓扑在单个元组流上操作。

因为 Trident 将流视为元组批次,所以它属于第一章中讨论的微批处理工具类别。正如你从那一章中回忆起来的,微批处理是批处理和流处理之间的混合体。
这种在 Trident 中将流视为一系列批次的根本区别,是为什么在 Trident 中存在操作而不是 bolt 的原因。我们以流及其可以应用于该流的操作系列来思考。在第 9.1.1 节中讨论的操作将修改流中流动的元组或流本身。为了理解 Trident,你必须理解 Trident 中的流和操作。
接下来,我们将讨论一个非常适合与 Trident 一起使用的消息队列实现。它与 Trident 的需求如此接近,以至于它被捆绑在 Storm 中,以便与 Trident 拓扑一起使用。
9.2. Kafka 及其在 Trident 中的作用
当涉及到作为输入源的消息队列时,Storm 与 Apache Kafka 保持着独特的关系。这并不意味着不能使用其他消息队列技术。我们在整本书中都非常小心地指出 Storm 如何与多种不同的技术一起使用,例如 RabbitMQ 和 Kestrel。是什么让 Kafka 与其他消息代理实现不同?这归结于 Kafka 创建过程中的核心架构决策。为了帮助您理解为什么 Kafka 与 Trident 非常匹配,我们将简要讨论 Kafka 的设计,然后讨论该设计的哪些特性与 Trident 非常吻合。
9.2.1. 拆解 Kafka 的设计
本节将简要探讨 Kafka 的设计,但仅限于您理解为什么它与 Storm 和 Trident 相关。
注意
在本章中,我们使用了一些标准的 Kafka 术语。其中两个更常用的术语是 1) 主题,它是一个特定类别的消息源,2) 代理,它是一个服务器/节点,通常在 Kafka 集群中运行的一个多个节点之一。
Kafka 网站以两种方式描述自己,这两种方式都作为为什么设计适合 Trident 的线索:
-
它是一个发布-订阅消息代理,重新构想为一个 分布式提交日志。
-
它是一个分布式、分区、复制的提交日志服务,提供消息系统的功能,但具有独特的设计。
让我们逐一讨论这些内容,因为理解这些基本的设计决策将帮助您了解 Kafka 如何与 Trident 对齐。
分区以分发 Kafka 主题
当消息生产者向 Kafka 主题写入消息时,它会将该主题的特定分区中的给定消息写入。一个 分区 是一个有序的、不可变的消息序列,它不断地被追加。一个主题可以有多个分区,这些分区可以分布在多个 Kafka 代理上。消息消费者将读取每个分区,以查看整个主题。图 9.2 展示了一个主题如何分布到多个分区。
图 9.2. Kafka 主题的分布,作为多个 Kafka 代理上的分区组

通过对主题进行分区,Kafka 获得了将单个主题扩展到单个代理(节点)之外的能力,无论是读取还是写入。每个分区还可以进行复制以提供容错性。这意味着如果您为分区有 n 个副本,您可以在不遭受任何数据丢失的情况下丢失多达 n – 1 个副本。
在 Trident 中,理解多个分区以及能够扩展这些分区是非常重要的概念。正如您将在本章后面看到的那样,这与 Trident 读取流数据的拓扑方式非常吻合。但在我们继续前进之前,我们应该更详细地阐述 Kafka 如何存储消息。
将存储建模为提交日志
Kafka 用于主题内消息的存储模型在性能和功能特性方面都提供了许多优势。我们从上一节中了解到,分区是文件系统上有序、不可变的消息序列。这代表了一个提交日志。分区内的每条消息都被分配了一个顺序标识符,称为偏移量,它标记了每条消息在提交日志中的存储位置。
Kafka 还维护分区内的消息顺序,因此当单个消费者从分区读取时,可以保证强顺序。从特定分区读取消息的消息消费者将维护其当前位置的引用,这被称为该消费者在提交日志中的偏移量。图 9.3 说明了多个分区的偏移量。
图 9.3。一个分区包含一个不可变、有序的消息序列,其中读取这些消息的消费者维护其读取位置的偏移量。

Kafka 在消费者推进偏移量后不会丢弃消息;它们会被保留在日志中一段时间(例如 24 小时或 7 天)。一旦这个时间间隔过去,Kafka 将压缩日志并清除任何较旧的条目。
您现在应该对 Kafka 的设计工作原理有一个大致的了解。主题充当特定类别的消息源。然后,这个主题可以被分解成多个分区,这些分区是不可变、有序的消息序列。这些分区可以分布在 Kafka 集群的不同代理上。我们现在将详细阐述一些关于功能和性能方面的设计优势。
Kafka 设计的功能和性能优势
此设计的功能优势包括以下内容:
-
由于消息不会立即被丢弃,并且消费者决定何时或何时不推进其提交日志中的偏移量,因此很容易从 Kafka 重新播放任何消息。
-
类似地,如果您的消费者长时间落后,并且由于某些消费截止日期的要求,不再有消费这些排队消息的意义,那么通过将偏移量向前推进一大步到新的读取位置来跳过所有过期的消息就变得容易了。
-
如果您的消费者以批量的方式处理消息,并且需要一次性完成整个批次或者根本不完成,这可以通过一次性推进一个分区中一系列消息的偏移量来实现。
-
如果您有不同需要订阅同一主题中相同消息的应用程序,消费者可以轻松地从该主题的相同分区集中读取这些不同的应用程序。这是因为消息在第一个消费者处理完毕后并不会被丢弃,而是消费者控制其自己的提交日志中的偏移量。
-
另一方面,如果你想确保只有单个消费者消费每条消息,你可以通过将单个消费者实例固定到特定主题的特定分区来实现。
性能优势包括以下方面:
-
无论你的消息总线最终是由消息生产者还是消息消费者造成的瓶颈,这个瓶颈都可以通过增加分区数量来轻松解决。
-
提交日志的顺序和不可变特性,以及消费者偏移量推进模式的顺序特性(在大多数情况下),为我们带来了许多性能提升:
-
磁盘访问通常成本较高,但在大多数情况下,这是由于大多数应用程序中普遍存在的随机访问特性。由于 Kafka 从头开始设计就是为了利用文件系统中数据的顺序访问,现代操作系统将通过预读缓存和写后缓存来高效地利用这一点,从而在性能提升上取得大幅进步。
-
Kafka 充分利用了操作系统的磁盘缓存。这使得 Kafka 能够避免在进程内维护昂贵的缓存,并免受垃圾收集压力的影响。
-
我们对 Kafka 的一般设计和它提供的优势,包括功能性和性能相关的优势,已经有了相当的了解。现在是时候确定 Kafka 如何与 Trident 兼容,使其成为 Trident 如此优秀的选择,以至于它现在与 Storm 捆绑在一起。
9.2.2. Kafka 与 Trident 的兼容性
你可能能够想象到 Storm 如何从 Kafka 的功能性和性能优势中受益。Kafka 提供的性能优势比其竞争对手高出一个数量级。仅凭这一点,Kafka 就成为了原生 Storm 的首选消息总线。但是,当与 Trident 一起使用时,它作为消息实现的优秀选择的原因就很明显了:
-
由于 Trident 在流中执行微批处理,它依赖于能够原子性地管理一个元组批次。通过允许 Trident 推进其消费者偏移量,Kafka 支持这一功能。
-
消息不会被丢弃,因此通过回滚偏移量,你可以从任何时间点(直到 Kafka 的日志过期时间间隔)重新播放消息。这使得 Kafka 能够作为一个可靠的数据源,你可以在此基础上构建一个可靠的 spout,无论是对于 Trident 还是原生 Storm。
-
正如我们稍后将要看到的,Trident 可以使用 Kafka 分区作为 Trident 拓扑内部并行化的主要手段。
-
为 Kafka 实现的 Storm spout 可以在 Zookeeper 中维护不同分区的消费者偏移量,因此当你的 Storm 或 Trident 拓扑重启或重新部署时,你可以从上次停止的地方继续处理。
让我们暂停一下,看看我们已经涵盖了哪些内容。到目前为止,你应该理解以下内容:
-
Trident 在 Storm 的原始操作之上提供了一个抽象层,允许你编写表达“做什么”而不是“如何做”的代码。
-
Trident 流被处理为一组元组的批次,而不是单个元组,一次一个。
-
Trident 有操作,而不是螺栓,可以应用于流。这些操作包括函数、过滤器、拆分、合并、连接、分组、聚合、状态更新器、状态查询和重新分区。
-
Kafka 是适用于 Trident 顶点的理想队列实现。
我们终于准备好深入我们的用例,并将所有这些 Trident 原则应用于我们的设计和实现。当我们通过用例时,请尝试记住 Trident 的操作以及它是如何处理流的,因为这将有助于指导我们的设计。
9.3. 问题定义:互联网广播
假设我们想要创办一家互联网广播公司。我们希望对艺术家通过我们的互联网广播平台流过的音乐支付公平的版税。为此,我们决定跟踪艺术家个人歌曲的播放次数。这些计数可以稍后用于报告和分配版税。除了支付版税外,我们还有相当雄心勃勃,希望能够查询/报告用户偏好的音乐类型,以便在用户使用我们的应用程序时提供最佳体验。
我们的用户将在各种设备和网页上收听我们的互联网广播。这些应用程序将收集“播放日志”并将这些信息发送给我们,以便从我们的 Trident spout 流入我们的拓扑。
拿着这个问题定义,让我们来看看起始和结束的数据点,就像我们在前面的章节中所做的那样。
9.3.1. 定义数据点
对于我们的场景,每个播放日志将作为包含艺术家、歌曲标题和与歌曲相关的标签列表的 JSON 流入我们的拓扑。下面的列表提供了一个单个播放日志的示例。
列表 9.3. 样本播放日志流条目
{
"artist": "The Clash",
"title": "Clampdown",
"tags": ["Punk","1979"]
}
播放日志 JSON 给我们提供了数据的起点。我们希望持久化三种不同类型的计数:按艺术家、标题和标签的计数。Trident 提供了一个 TridentState 类,我们将用它来完成这个任务。我们将在稍后更深入地了解 TridentState——现在重要的是你要理解我们开始的数据以及我们想要达到的目标。
数据定义后,下一步是定义一系列步骤,我们需要从播放日志的流到存储在 TridentState 实例中的计数。
9.3.2. 将问题分解为一系列步骤
我们已经确定我们将从一个播放日志开始,以艺术家、标题和标签的计数结束。在形成一个概念解决方案时,我们需要确定我们开始和结束之间的所有步骤。
记得我们之前在讨论用例设计时提到要记住各种 Trident 操作吗?这就是我们将查看这些操作并确定哪些在我们的场景中合理的地方。我们最终得到以下结果:
-
一个发出 Trident 流的喷口。记住,Trident 流由元组批次组成,而不是单个元组。
-
一个将传入的播放日志反序列化(分割)成艺术家、标题和标签元组批次的函数。
-
分别为艺术家、标题和标签分别计数的功能。
-
Trident 状态通过艺术家、标题和标签分别持久化计数。
这些步骤在图 9.4 中得到了说明,该图说明了我们的设计目标。接下来,我们需要实现将应用于包含播放日志的元组批次流的 Trident 操作的代码。
图 9.4. 互联网广播应用的 Trident 拓扑

9.4. 将互联网广播设计实现为 Trident 拓扑
到目前为止,我们已经准备好实现一个符合我们在图 9.4 中建立的设计目标的 Trident 拓扑。当你开始通过实现进行时,你会注意到我们拓扑的大部分代码都在拓扑构建器类(TopologyBuilder)中处理。虽然我们确实实现了一些用于操作的函数,但你将在TopologyBuilder中看到以“what”而不是“how”的形式表达代码。
让我们从我们的拓扑的喷口开始。幸运的是,对于我们的需求,Storm 自带了一个内置的喷口实现,我们可以使用它来节省一些时间。
9.4.1. 使用 Trident Kafka 喷口实现喷口
我们将使用官方 Storm 发行版中包含的 Trident Kafka 喷口。图 9.5 显示了在拓扑中此 Trident Kafka 喷口将如何使用。
图 9.5. Trident Kafka 喷口将用于处理传入的播放日志。

虽然这个喷口的实现细节超出了本章的范围,但我们将展示如何在下一个列表中TopologyBuilder类中连接这个喷口的代码。
列表 9.4. 在TopologyBuilder中连接TransactionalTridentKafkaSpout

现在,我们有一个将发出播放日志批次的喷口实现。下一步是实现我们的第一个操作,该操作将批次的每个元组的 JSON 转换为艺术家、标题和标签的单独元组批次。
9.4.2. 反序列化播放日志并为每个字段创建单独的流
在设计中要实施的下一步是获取进入的play-log元组批次,并为我们要计数的每个字段(艺术家、标题和标签)发出元组批次。图 9.6 说明了输入元组批次、我们的操作和输出元组批次,每个批次都在单独的流上发出。
图 9.6. 将 JSON 反序列化为 Trident 元组批次的操作,针对艺术家、标题和标签字段

观察图示,你可以看到我们需要做两件事:1) 将 JSON 转换为艺术家、标题和标签字段的单个元组,2) 为这些字段中的每个字段创建一个单独的流。对于第一个任务,我们将查看each操作。
Trident 提供了一个可以应用于每个元组的each操作。each操作可以与函数或过滤器一起使用。在我们的场景中,一个each函数似乎是一个合适的选择,因为我们正在将 JSON 转换为艺术家、标题和标签的 Trident 元组。如果我们需要出于某种原因过滤掉任何数据,那么过滤器将是一个更合适的选择。
实现每个函数
函数接收一组输入字段并发出零个或多个元组。如果它不发出任何内容,则原始元组被过滤掉。当使用each函数时,输出元组的字段被附加到输入元组上。以下列表提供了为我们拓扑实现each函数的代码。
列表 9.5. TopologyBuilder.java中的each函数用于反序列化播放日志

新的流将包含play-log、artist、title和tags字段。each函数LogDeserializer通过为Base-Function抽象类提供一个实现来构建,并将输入元组中的 JSON 字符串反序列化到所需的输出。实现BaseFunction类似于在原生 Storm 中实现Base-BasicBolt。以下列表显示了实现。
列表 9.6. LogDeserializer.java


投影
当你定义一个each函数为stream.each(inputFields, function, output-Fields)时,只有原始流中的一部分字段(由inputFields表示)被发送到函数中(其余的函数内部无法访问)。这被称为投影。投影使得避免人们通常遇到的问题变得极其容易,即向函数发送了不必要的字段。
你也可以在流上使用project(..)方法来移除操作后挂留的任何不必要的字段。在我们的例子中,我们在LogDeserializer操作后流中有一个play-log字段,我们不再需要原始的 JSON。最好将其删除;保留不必要的内存数据会影响效率(尤其是在 Trident 中,因为我们把流当作一系列批次来处理,这涉及到在 JVM 中比常规 Storm 拓扑保留更多的数据):
playStream = playStream.project(new Fields("artist", "title", "tags"));
如我们之前提到的,我们必须做两件事:1) 将 JSON 转换为单独的元组,我们现在已经做到了,2) 为这些字段中的每个字段创建一个单独的流。让我们看看第二个任务。
分割流和分组字段
如果我们现在结束实现,我们将有一个包含四个值元组批次的单个流。这是因为LogDeserializer中的以下原因:
collector.emit(new Values(logEntry.getArtist(),
logEntry.getTitle(),
logEntry.getTags()));
图 9.7 展示了我们现在所在的位置与我们的目标位置。
图 9.7. 我们希望从包含多个值的元组的流移动到包含单个值的多个流。

幸运的是,拆分流很容易。我们从拆分起源点持有多个流引用,然后继续对这些引用应用不同的 Trident 操作,如下一列表所示。
列表 9.7. 将来自LogDeserializer的流拆分为三个独立的流

我们有创建单独流的代码,但那些流中没有什么内容。它们只是对起源playStream的引用。我们需要将每个流与我们要拆分的字段相关联。这正是按字段名称对元组进行分组发挥作用的地方。
按字段名称对元组进行分组
Trident 提供了一个groupBy操作,我们可以用它来对具有相同字段名的元组进行分组。groupBy操作首先重新分区流,使得具有相同选定字段值的元组落在同一个分区中。在每个分区内部,它然后将具有相等组字段的元组分组在一起。执行这些groupBy操作的代码在下一列表中。
列表 9.8. 在三个拆分流中对艺术家、标题和标签进行分组

ListSplitter是一个以类似LogDeserializer的方式实现的each函数。不同之处在于ListSplitter将tags列表拆分为单个tag元组。
现在我们已经拆分了流并对每个artist、title和tag字段进行了分组,我们准备计算这些字段的计数。
9.4.3. 计算并持久化艺术家、标题和标签的计数
下一步是对artist、title和tag元组进行聚合,以便计算每个的计数。图 9.8 提醒我们在拓扑设计中的位置。
图 9.8. 对每个artist、title和tag值进行计数并将这些值持久化到存储中

根据图 9.8,这里基本上有两个步骤:1) 对每个流按值聚合元组以执行计数,2) 持久化计数。让我们先看看三种不同的聚合元组的方法,并确定最适合我们场景的方法。
为执行计数选择聚合器实现
有三种方法可以聚合元组,每种方法都有自己的接口来定义如何实现:
-
![图片]()
一个
CombinerAggregator对每个元组调用init
方法,然后使用 combine
方法将每个元组的 init值合并并返回一个结果。如果没有元组要聚合,它返回zero
值。 -
![]()
一个
ReducerAggregator只对聚合调用一次init方法,然后对每个元组和当前值调用reduce
。 -
Aggregator public interface Aggregator<T> extends Operation { T init(Object batchId, TridentCollector collector); void aggregate(T state, TridentTuple tuple, TridentCollector collector); void complete(T state, TridentCollector collector); }Aggregator是一个更底层的抽象接口,用于实现更复杂的聚合。请参阅 Storm 文档以获取更多信息。
大多数情况下,你会使用 CombinerAggregator 或 ReducerAggregator。如果整个聚合的初始值不依赖于任何单个元组,那么你就必须使用 ReducerAggregator。否则,我们建议使用 CombinerAggregator,因为它性能更好。
CombinerAggregator 相对于 ReducerAggregator 的优势
当你使用基于 ReducerAggregator 或 Aggregator 的实现运行聚合操作时,流会发生重新分区,以便所有分区都合并成一个,并在该分区上进行聚合。但如果你使用基于 CombinerAggregator 的实现(就像我们使用 Count 一样),Trident 将在当前分区上执行部分聚合,然后重新分区流为一个流,并通过进一步聚合部分聚合的元组来完成聚合。这要高效得多,因为 fewer tuples have to cross the wire during the repartition. CombinerAggregator 应该始终优先考虑,因为这个原因;你唯一需要求助于 ReducerAggregator 的时候是当你需要用一个与元组无关的初始值来初始化聚合。
对于我们的场景,让我们使用一个名为 Count 的内置聚合器,它实现了 Combiner-Aggregator。这是一个简单的实现,它将允许我们在分组内计数艺术家、标题和标签。下一个列表显示了 Count 的实现。
列表 9.9. 实现 CombinerAggregator.java 的内置 Count.java

我们知道我们将使用 Count 类来执行实际的计数,但我们仍然需要在我们的 TopologyBuilder 中将 Count 实例连接起来。让我们看看如何做到这一点。
选择一个聚合操作与我们的聚合器实现一起使用
Trident 提供了三种方法来使用聚合器与流:
-
partitionAggregate— 这个操作承担了聚合元组的单一责任,并且仅在单个分区内工作。这个操作的结果是一个包含聚合结果元组的Stream。设置partition-Aggregate的代码如下:Stream aggregated = stream.partitionAggregate(new Count(), new Fields("output")); -
aggregate— 这个操作承担着聚合元组的单一职责,并在单个元组批次的所有分区中工作。操作的结果是一个包含聚合结果元组的Stream。设置aggregate的代码如下:Stream aggregated = stream.aggregate(new Count(), new Fields("output")); -
persistentAggregate— 这个操作跨越多个批次,承担着聚合元组和持久化结果的职责。它将聚合结果持久化到由<state-factory>管理的数据存储中。状态工厂是 Trident 用于与数据存储一起工作的抽象。因为它与状态一起工作,所以persistentAggregate可以在批次之间工作。它是通过从流中聚合当前批次,然后将该值与数据存储中的当前值聚合来实现的。这个操作产生了一个可以查询的TridentState。设置persistentAggregate的代码如下:TridentState aggregated = stream.persistentAggregate(<state-factory>, new Count(), new Fields("output"));
在这个列表中,Count聚合器可以被替换为任何CombinerAggregator、ReducerAggregator或Aggregator实现。
哪种聚合操作最适合我们的需求?让我们从partition-Aggregate开始。我们知道partitionAggregate在单个分区内部工作,因此我们必须弄清楚我们是否需要在单个分区内部进行聚合。我们已经对一个字段(艺术家、标题和标签)的元组应用了groupBy操作来分组,然后在整个批次中计算该组内的元组数量。这意味着我们正在跨分区操作,这使得partitionAggregate不是我们的选择。
接下来是aggregate操作。aggregate操作在元组批次的所有分区中工作,这正是我们所需要的。但如果我们决定使用aggregate,我们还需要应用另一个操作来持久化聚合结果。因此,如果我们决定承担更多的工作并构建更多内容,允许我们在批次之间聚合并持久化结果,aggregate就可以工作。
我们感觉对于我们的场景,可能有一个更好的选择,这让我们想到了persistent-Aggregate。仅从名称上,我们就有一种感觉,这可能正是我们需要进行的操作。我们需要对计数进行聚合,并将这些聚合结果持久化。因为persistentAggregate与状态一起工作,因此可以在批次之间工作,这使得它非常适合我们的场景。此外,persistentAggregate为我们留下了一个可以查询的TridentState对象,这使得我们很容易构建之前在问题定义中讨论的各种报告。
我们已经决定使用persistentAggregate作为我们的解决方案,但在完成之前,我们还需要定义最后一个部分。让我们再次看看persistent-Aggregate的代码:
TridentState aggregated = stream.persistentAggregate(<state-factory>,
new Count(),
new Fields("output"));
我们仍然需要一个<state-factory>,我们将在下一节讨论。
处理状态
在 Trident 中处理状态时,我们需要一个 StateFactory 的实现。这个 StateFactory 作为一种抽象,知道如何查询和更新数据存储。在我们的场景中,我们将选择与 Trident 一起捆绑的 MemoryMapState.Factory。MemoryMapState.Factory 与内存中的 Map 一起工作,目前它完全可以满足我们的需求。连接这个工厂的代码可以在下面的列表中看到。
列表 9.10. 使用 persistentAggregate 操作在 TopologyBuilder.java 中更新/持久化计数


这就完成了我们 Trident 拓扑的基本实现。我们现在已经拥有了所有感兴趣字段的内存计数:artist、title 和 tag。现在我们已经完成了;准备好继续前进,对吧?嗯,还不完全是。我们不想让你带着这些无法访问的内存计数离开。让我们看看如何实现对这些计数的访问。这将以 Storm 的 DRPC 功能的形式出现。
9.5. 通过 DRPC 访问持久化的计数
现在我们有了按艺术家、标题和标签计数的 TridentState 对象,我们可以查询这些状态对象来构建我们需要的报告。我们希望我们的报告应用程序在 Storm 之外,因此这个报告应用程序需要能够查询这个拓扑以获取它所需的数据。我们将利用分布式远程过程调用(DRPC)来实现这个目的。
在 Storm DRPC 中,客户端将使用 Storm DRPC 服务器调用一个 DRPC 请求,该服务器将通过将请求发送到相应的 Storm 拓扑并等待该拓扑的响应来协调请求。一旦它收到响应,它将把这个响应传达给调用客户端。这实际上通过并行查询多个艺术家或标签并汇总结果来充当一个分布式查询。
本节涵盖了实现我们的查询计数解决方案所需的 Storm DRPC 的三个部分:
-
创建一个 DRPC 流
-
将 DRPC 状态查询应用于流
-
使用 DRPC 客户端通过 Storm 进行 DRPC 调用
我们将从 DRPC 流开始我们的解释。
9.5.1. 创建一个 DRPC 流
当 Storm DRPC 服务器接收到一个请求时,它需要将其路由到我们的拓扑。为了我们的拓扑能够处理这个传入的请求,它需要一个 DRPC 流。Storm DRPC 服务器将把任何传入的请求路由到这个流。DRPC 流被赋予一个名称,这个名称是我们想要执行的这个分布式查询的名称。DRPC 服务器将根据这个名称确定要路由到哪个拓扑(以及该拓扑中的哪个流)。下面的列表显示了如何创建一个 DRPC 流。
列表 9.11. 创建一个 DRPC 流
topology.newDRPCStream("count-request-by-tag")
DRPC 服务器接受以文本形式传递给 DRPC 函数的参数,并将这些参数与请求一起转发到该 DRPC 流。我们需要将这些文本参数解析成我们可以在 DRPC 流中使用的格式。以下列表定义了我们的 count-request-by-tag DRPC 流的参数合同,即我们想要查询的标签的逗号分隔列表。
列表 9.12. 定义 DRPC 流参数的合同
topology.newDRPCStream("count-request-by-tag")
.each(new Fields("args"),
new SplitOnDelimiter(","),
new Fields("tag"));
列表 9.12 引用了名为 SplitOnDelimiter 的每个函数,因此让我们看看该类的实现,如下列表所示。
列表 9.13. SplitOnDelimiter.java

这为我们提供了一个基本的 DRPC 流来工作。下一步是将状态查询应用于此流。
9.5.2. 将 DRPC 状态查询应用于流
我们想要执行的针对此 DRPC 请求的状态查询是按给定标签参数计算播放日志的数量。在我们继续之前,让我们刷新一下如何计算标签的 TridentState 的记忆,如下列表所示。
列表 9.14. 创建导致 TridentState 的 counts-by-tag 流
TridentState countsByTag = playStream
.each(new Fields("tags"),
new ListSplitter(),
new Fields("tag"))
.project(new Fields("tag"))
.groupBy(new Fields("tag"))
.persistentAggregate(new MemoryMapState.Factory(),
new Count(),
new Fields("tag-count"));
我们使用标签作为键和计数作为值,将按给定标签的计数存储在内存映射中。现在我们只需要查找作为 DRPC 查询参数接收到的标签的计数。这是通过 DRPC 流上的 stateQuery 操作实现的。stateQuery 操作的解释可以在 图 9.9 中看到。
图 9.9. 分析 stateQuery 操作

如图所示,我们选择的 QueryFunction 需要知道如何通过 TridentState 对象访问数据。幸运的是,Storm 内置了一个 MapGet 查询函数,它可以与我们的 MemoryMapState 实现一起工作。
但实现这个状态查询并不像在我们的 DRPC 流末尾添加 stateQuery 操作那么简单。原因是我们在原始播放流中,使用 tag 字段上的 groupBy 操作重新分区了流。为了将 count-request-by-tag 请求从 DRPC 流发送到包含所需标签的 TridentState 的同一分区,我们还需要在 DRPC 流上应用一个 groupBy 操作,在相同的标签字段上。下一个列表提供了相应的代码。
列表 9.15. 通过查询状态源查找 counts-by-tag

现在我们已经得到了我们想要的每个标签的计数结果。我们可以在 DRPC 流中停止这里并完成。可选地,我们可以附加一个额外的 each 操作来过滤掉空计数(即尚未在播放流中遇到的标签),但我们将空值留给 DRPC 调用者处理。
这将我们带到最后一步:能够通过 DRPC 客户端与 Storm 进行通信。
9.5.3. 使用 DRPC 客户端调用 DRPC 调用
向此拓扑发送 DRPC 请求可以通过在您的客户端应用程序中包含 Storm 作为依赖项,并使用 Storm 内置的 DRPC 客户端来完成。一旦这样做,您就可以使用类似于下一列表中的代码来发送实际的 DRPC 请求。
列表 9.16. 执行 DRPC 请求

DRPC 请求是通过 Thrift 协议进行的,因此您需要处理与 Thrift 相关的错误(通常是连接相关)以及DRPCExecutionException错误(通常是功能相关)。就是这样。我们没有让您失望。现在您有一个拓扑,它可以维护artist、title和tag等不同字段的计数状态,并且您能够查询该状态。我们已经使用 Trident 和 Storm DRPC 构建了一个完全功能化的拓扑。
或者就这样了吗?如果您从前面的章节中学到了什么,那就是一旦您部署了拓扑,作为开发者的工作还没有结束。这里也是同样的情况。第 9.6 节讨论了如何使用 Storm UI 来识别在幕后创建的 spout 和 bolt,以将 Trident 操作映射到 Storm 原语。第 9.7 节将接着讨论扩展 Trident 拓扑。
9.6. 将 Trident 操作映射到 Storm 原语
回想一下,在本章的开头我们讨论了如何基于我们在本书中逐渐熟悉的 Storm 原语构建 Trident 拓扑。随着我们的用例完成,让我们看看 Storm 是如何将我们的 Trident 拓扑转换为 bolt 和 spout 的。我们将首先查看没有我们的 DRPC spout,我们的拓扑是如何映射到 Storm 原语的。为什么不一次性查看所有内容呢?我们觉得首先处理核心的 Trident 流,然后再添加 DRPC 流,这样更容易理解到底发生了什么。
没有我们的 DRPC spout,我们的TopologyBuilder代码可以在以下列表中看到。
列表 9.17. 没有 DRPC 流的TopologyBuilder.java
public TopologyBuilder {
public StormTopology build() {
TridentTopology topology = new TridentTopology();
Stream playStream = topology
.newStream("play-spout", buildSpout())
.each(new Fields("play-log"),
new LogDeserializer(),
new Fields("artist", "title", "tags"))
.each(new Fields("artist", "title"),
new Sanitizer(new Fields("artist", "title")));
TridentState countByArtist = playStream
.project(new Fields("artist"))
.groupBy(new Fields("artist"))
.persistentAggregate(new MemoryMapState.Factory(),
new Count(),
new Fields("artist-count"));
TridentState countsByTitle = playStream
.project(new Fields("title"))
.groupBy(new Fields("title"))
.persistentAggregate(new MemoryMapState.Factory(),
new Count(),
new Fields("title-count"));
TridentState countsByTag = playStream
.each(new Fields("tags"),
new ListSplitter(),
new Fields("tag"))
.project(new Fields("tag"))
.groupBy(new Fields("tag"))
.persistentAggregate(new MemoryMapState.Factory(),
new Count(),
new Fields("tag-count"));
return topology.build();
}
...
}
当我们的 Trident 拓扑被转换为 Storm 拓扑时,Storm 会以高效的方式将我们的 Trident 操作打包到 bolt 中。一些操作将被组合到同一个 bolt 中,而其他操作将是独立的。Storm UI 提供了一个视图,展示了这种映射是如何进行的(图 9.10)。
图 9.10. 在 Storm UI 中分解为 spout 和 bolt 的我们的 Trident 拓扑

如您所见,我们有一个 spout 和六个 bolt。其中两个 bolt 的名称中包含“spout”,另外四个分别标记为 b-0 到 b-3。我们可以在那里看到一些组件,但我们不知道它们如何与我们的 Trident 操作相关联。
而不是试图解开名称背后的神秘,我们将向您展示一种使识别组件更容易的方法。Trident 有一个命名操作,它将我们指定的名称分配给一个操作。如果我们给拓扑中的每个操作集合命名,我们的代码最终会像下面列表中的那样。
列表 9.18. TopologyBuilder.java带有命名操作
public TopologyBuilder {
public StormTopology build() {
TridentTopology topology = new TridentTopology();
Stream playStream = topology
.newStream("play-spout", buildSpout())
.each(new Fields("play-log"),
new LogDeserializer(),
new Fields("artist", "title", "tags"))
.each(new Fields("artist", "title"),
new Sanitizer(new Fields("artist", "title")))
.name("LogDeserializerSanitizer");
TridentState countByArtist = playStream
.project(new Fields("artist"))
.groupBy(new Fields("artist"))
.name("ArtistCounts")
.persistentAggregate(new MemoryMapState.Factory(),
new Count(),
new Fields("artist-count"));
TridentState countsByTitle = playStream
.project(new Fields("title"))
.groupBy(new Fields("title"))
.name("TitleCounts")
.persistentAggregate(new MemoryMapState.Factory(),
new Count(),
new Fields("title-count"));
TridentState countsByTag = playStream
.each(new Fields("tags"),
new ListSplitter(),
new Fields("tag"))
.project(new Fields("tag"))
.groupBy(new Fields("tag"))
.name("TagCounts")
.persistentAggregate(new MemoryMapState.Factory(),
new Count(),
new Fields("tag-count"));
return topology.build();
}
...
}
如果我们查看我们的 Storm UI,发生的事情就变得更加明显了(图 9.11)。
图 9.11. 在 Storm UI 上命名每个操作后显示的我们的 Trident 拓扑

我们可以看到,我们的b-3螺栓是日志反序列化和净化。而我们的b-0、b-1和b-2螺栓分别对应标题、标签和艺术家计数。考虑到使用名称提供的清晰度,我们建议您始终为分区命名。
日志反序列化螺栓的名称是怎么回事?LogDeserializer-Sanitizer-ArtistCounts-LogDeserializerSanitizer-TitleCounts-Log-Deserializer-Sanitizer-TagCounts——多么冗长!但它确实为我们提供了大量信息。名称表明我们从日志反序列化和净化器获取数据,并将其输入到艺术家计数、标题计数和标签计数中。这不是最优雅的发现机制,但比仅仅 b-0 要好。
在获得这种额外的清晰度后,看看图 9.12,它说明了我们的 Trident 操作是如何映射到螺栓中的。现在让我们添加回带有相关名称的 DRPC 流。这个代码在下一个列表中。
图 9.12. 我们 Trident 操作如何映射到螺栓中

列表 9.19. 带有命名操作的 DRPC 流
topology.newDRPCStream("count-request-by-tag")
.name("RequestForTagCounts")
.each(new Fields("args"),
new SplitOnDelimiter(","),
new Fields("tag"))
.groupBy(new Fields("tag"))
.name("QueryForRequest")
.stateQuery(countsByTag,
new Fields("tag"),
new MapGet(),
new Fields("count"));
添加带有命名操作的 DRPC 流导致出现图 9.13 中看到的 Storm UI。
图 9.13. 包含 Trident 拓扑和 DRPC 流的命名操作的 Storm UI

发生了什么变化?嗯...
我们日志净化器螺栓现在是 b-2 而不是 b-3。这非常重要。当您更改拓扑中螺栓的数量时,不能依赖自动生成的螺栓名称保持不变。
命名螺栓的数量从 4 个增加到 5 个,并且这些螺栓的名称也发生了变化。
我们有一些未命名的螺栓。螺栓名称变更发生了什么情况?我们添加了 DRPC 喷嘴后,映射到 Storm 原语和名称相应地发生了变化。图 9.14 显示了 Trident/DRPC 操作最终映射到螺栓的过程。
图 9.14. Trident 和 DRPC 流以及操作如何映射到螺栓中

注意“标签计数”和“请求查询”是如何映射到同一个螺栓,并且名称已经相应调整。好吧,那么那些未命名的螺栓怎么办?我们之所以在 UI 的螺栓部分看到一些组件被命名为喷嘴,是因为 Storm 运行在螺栓中的 Trident 喷嘴。记住,Trident 喷嘴与本地 Storm 喷嘴不同。此外,Trident 拓扑还有其他协调器,允许我们将传入的流视为一系列批次。当我们将 DRPC 喷嘴添加到拓扑中并更改其映射方式时,Storm 引入了它们。
通过几行额外的代码,很容易识别 Storm 如何将 Trident 操作映射到本地 Storm 组件。添加名称是关键,这将节省你很多麻烦。现在你已经了解了如何通过名称和 Storm UI 将本地 Storm 组件映射到 Trident 操作,让我们将注意力转向本章的最后一个主题:扩展 Trident 拓扑。
9.7. 扩展 Trident 拓扑
让我们谈谈并行单位。当处理螺栓和喷嘴时,我们交换执行者和任务。它们构成了组件之间并行的主要手段。当使用 Trident 时,我们仍然与它们一起工作,但只是作为 Trident 操作映射到这些原语。当使用 Trident 时,我们实现并行的主要方法是分区。
9.7.1. 用于并行的分区
使用 Trident,我们通过分区流并在每个分区上并行应用我们的操作,在一个或多个工作进程中处理数据流。如果我们拓扑中有五个分区和三个工作进程,我们的工作将以类似 图 9.15 中所示的方式分布。
图 9.15. 分区分布在 storm 工作进程(JVM)上,并并行操作

与 Storm 不同,在 Storm 中我们想象并行性是跨一系列工作进程分散执行者,而在这里我们想象并行性是一系列分区被分散到一系列工作进程中。我们通过调整分区数量来扩展 Trident 拓扑。
9.7.2. Trident 流中的分区
分区从 Trident 喷嘴开始。Trident 喷嘴(与 Storm 喷嘴大不相同)会发出一个流,然后对这个流应用一系列 Trident 操作。这个流被分区以提供拓扑的并行性。Trident 将这个分区流分解成一系列小批量,包含数千个元组到数百万个元组,具体取决于你的输入吞吐量。图 9.16 展示了两个 Trident 操作之间或 Trident 喷嘴和第一个 Trident 操作之间的 Trident 流的放大视图。
图 9.16. 两个操作之间带有一系列批次的分区流

如果并行度从 spout 开始,并且我们调整分区数来控制并行度,我们如何调整 spout 上的分区数?我们调整我们订阅的 Kafka 主题的分区数。如果我们有一个 Kafka 主题的分区,那么我们的拓扑将从一个分区开始。如果我们把 Kafka 主题增加到有三个分区,那么我们的 Trident 拓扑中的分区数将相应改变(图 9.17)。
图 9.17. Kafka 主题分区及其与 Trident 流中分区的关系

从这里,我们的三个分区的流可以通过各种操作进一步分区。让我们从谈论从 spout 有三个分区的话题退一步,回到只有一个分区;在学习我们 Trident 拓扑中的并行度时,这将使其他一切更容易推理。
在 Trident 拓扑中,将存在自然的分区点。需要改变分区的地方基于正在应用的运算。在这些点上,您可以调整每个结果分区的并行度。我们在拓扑中使用的 groupBy 操作会导致重新分区。我们每个 groupBy 操作都导致了一种重新分区,我们可以向其提供并行度提示,如下面的列表所示。
列表 9.20. 在重新分区点指定并行度
public static StormTopology build() {
TridentTopology topology = new TridentTopology();
Stream playStream =
topology.newStream("play-spout", buildSpout())
.each(new Fields("play-log"),
new LogDeserializer(),
new Fields("artist", "title", "tags"))
.each(new Fields("artist", "title"),
new Sanitizer(new Fields("artist", "title")))
.name("LogDeserializerSanitizer");
TridentState countByArtist = playStream
.project(new Fields("artist"))
.groupBy(new Fields("artist"))
.name("ArtistCounts")
.persistentAggregate(new MemoryMapState.Factory(),
new Count(),
new Fields("artist-count"))
.parallelismHint(4);
TridentState countsByTitle = playStream
.project(new Fields("title"))
.groupBy(new Fields("title"))
.name("TitleCounts")
.persistentAggregate(new MemoryMapState.Factory(),
new Count(),
new Fields("title-count"))
.parallelismHint(4);
TridentState countsByTag = playStream
.each(new Fields("tags"),
new ListSplitter(),
new Fields("tag"))
.project(new Fields("tag"))
.groupBy(new Fields("tag"))
.name("TagCounts")
.persistentAggregate(new MemoryMapState.Factory(),
new Count(),
new Fields("tag-count"))
.parallelismHint(4);
topology.newDRPCStream("count-request-by-tag")
.name("RequestForTagCounts")
.each(new Fields("args"),
new SplitOnDelimiter(","),
new Fields("tag"))
.groupBy(new Fields("tag"))
.name("QueryForRequest")
.stateQuery(countsByTag,
new Fields("tag"),
new MapGet(),
new Fields("count"));
return topology.build();
}
在这里,我们给我们的最后三个 bolt 分别分配了四个并行度。这意味着它们各自使用四个分区。我们能够为它们指定并行度,因为它们之间以及它们之前的 bolts 之间由于 groupBy 和 persistentAggregate 操作发生了自然的重新分区。我们没有为前两个 bolts 指定任何并行度提示,因为它们之间以及它们之前的 spouts 之间没有进行任何内在的重新分区。因此,它们以与 spouts 相同的分区数运行。图 9.18 展示了这种配置在 Storm UI 中的样子。
图 9.18. 将四个并行度提示应用于我们的 Trident 拓扑中的 groupBy 操作的结果

强制重新分区
除了由于 groupBy 操作导致的分区自然变化之外,我们还有能力强制 Trident 进行重新分区操作。此类操作将在分区改变时导致元组在网络中传输。这将对性能产生负面影响。除非您能验证重新分区后的并行度提示确实导致了整体吞吐量的增加,否则您应该避免仅为了改变并行度而进行重新分区。
这使我们来到了 Trident 的结尾。在本章中,你学到了很多,所有这些都建立在这本书前八章所奠定的基础之上。希望这个基础只是你 Storm 冒险的开始,我们的目标是让你在使用 Storm 解决任何问题时,继续对这些技能进行优化和调整。
9.8. 摘要
在本章中,你了解到
-
Trident 允许你专注于解决问题的“是什么”,而不是“如何”。
-
Trident 利用操作在元组批次上运行,这与在单个元组上运行的 Storm bolts 原生操作不同。
-
Kafka 是一个分布式消息队列实现,它与 Trident 在分区上对元组批次进行操作的运行方式完美匹配。
-
Trident 操作并不直接映射到 spouts 和 bolts,因此始终命名你的操作是很重要的。
-
Storm DRPC 是执行针对由 Storm 拓扑计算出的持久状态的分布式查询的有用方式。
-
扩展 Trident 拓扑与扩展原生 Storm 拓扑大不相同,并且是在分区上进行的,而不是设置 spouts 和 bolts 的确切实例。
后记
恭喜你,你已经读到了这本书的结尾。接下来你该去哪里?答案取决于你是如何到达这里的。如果你从头到尾阅读了这本书,我们建议你在参考各个章节的同时实施自己的拓扑结构,直到你觉得自己“掌握了 Storm”。我们犹豫着说“掌握 Storm”,因为我们不确定你是否会觉得自己掌握了 Storm。它是一个强大而复杂的野兽,掌握是一个棘手的事情。
如果你采取了更迭代的阅读方法,慢慢地工作并通过前进获得专业知识,那么后记中接下来的所有内容都是为你准备的。如果你采取了从头到尾的方法,不要担心;一旦你觉得自己掌握了 Storm,后记就会等着你。以下是我们希望你在我们离开后继续 Storm 之旅时知道的所有事情。
你是对的,你不知道这一点
我们已经在生产中使用 Storm 有一段时间了,我们仍然在不断地学习新事物。如果你觉得自己不知道一切,不要担心。利用你所知道的知识来完成你需要完成的事情。随着你的进步,你会学到更多。分析瘫痪在 Storm 中可能是一个真实的事情。
有很多知识需要了解
我们并没有涵盖 Storm 的每一个角落和缝隙。深入研究官方文档,加入 IRC 频道,并加入邮件列表。Storm 是一个不断发展的项目。在本书即将付印的时候,它甚至还没有达到 1.0 版本。如果你正在使用 Storm 进行业务关键流程,确保你知道如何保持最新。以下是我们认为你应该关注的一些事情:
-
Yarn 上的风暴
-
Mesos 上的 Storm
什么是 Yarn?什么是 Mesos?这实际上是一本完整的书。现在,我们只需说它们是集群资源管理器,可以让你与其他技术如 Hadoop 共享 Storm 集群资源。这是一个粗略的简化。我们强烈建议你在计划在生产中运行大型 Storm 集群时检查 Yarn 和 Mesos。这些项目中正在进行许多令人兴奋的事情。
指标和报告
Storm 的指标支持相当年轻。我们怀疑随着时间的推移它会变得更加健壮。此外,Storm 的最新版本引入了一个 REST API,允许你以编程方式访问 Storm UI 中的信息。这在外部自动化或监控场景中并不特别令人兴奋。但它为以易于访问的方式向外界暴露 Storm 内部发生的事情的更多信息开辟了一条途径。如果通过该 API 公开更多信息来构建一些真正酷的东西,我们一点也不感到惊讶。
Trident 相当强大
我们用一章介绍了 Trident。我们花了很多时间讨论我们应该涵盖多少关于 Trident 的内容。这从什么都不涵盖到几章不等。我们决定用一章来让你开始使用 Trident。为什么?好吧,我们考虑过完全不介绍 Trident。你可以快乐地使用 Storm 而永远不需要接触 Trident。我们不认为它是 Storm 的核心部分,而是你可以在其上构建的许多抽象之一(更多内容将在后面讨论)。即使如此,基于反馈,我们意识到我们不能完全不介绍它,因为每个早期审稿人都将 Trident 提出为必须涵盖的主题。
我们考虑过用三章来介绍 Trident,就像我们用三章介绍了核心 Storm (第二章到第四章),并以同样的方式介绍它。如果我们写一本关于 Trident 的书,我们会采取那种方法,但那些章节的大部分内容将与第二章到第四章中的内容相似。毕竟,Trident 是在 Storm 之上的一个抽象。我们决定用一章来介绍 Trident,因为我们觉得只要你能理解 Trident 的基础知识,其他一切都会顺理成章。我们没有涵盖许多 Trident 操作,但它们都以我们已涵盖的方式操作。如果你认为 Trident 对于你的问题比核心 Storm 更好,我们认为我们已经给了你足够的信息去深入研究和使用 Trident。
我什么时候应该使用 Trident?
仅在你需要时使用 Trident。与核心 Storm 相比,Trident 增加了大量的复杂性。由于抽象层较少,因此使用核心 Storm 调试问题更容易。核心 Storm 也比 Trident 快得多。如果你真的关心速度,那么优先考虑核心 Storm。你为什么可能需要使用 Trident?
-
“是什么”而不是“如何”对你来说非常重要。
- 使用核心 Storm 难以追踪你计算的重要算法细节,但使用 Trident 则非常清晰。如果你的流程完全是关于算法的,而且在核心 Storm 中难以看到发生了什么,维护将会很困难。
-
你需要精确一次处理。
- 正如我们在第四章中讨论的那样,精确一次处理非常难以实现;有些人甚至认为这是不可能的。我们不会走那么远。我们只能说,在某些情况下,这是不可能的。即使可能,正确实现它也可能很困难。Trident 可以帮助你构建一个精确一次处理系统。你也可以用核心 Storm 做到这一点,但你需要做更多的工作。
-
你需要维护状态。
- 再次强调,你可以使用 Storm 的核心功能来完成这项工作,但 Trident 在维护状态方面表现良好,而 DRPC 提供了一种获取该状态的好方法。如果你的工作负载更侧重于创建可查询的数据池,而不是数据管道(将输入转换为输出并将该输出馈送到另一个数据管道),那么 Trident 状态与 DRPC 结合可以帮助你实现这一目标。
抽象!到处都是抽象!
Trident 并不是在 Storm 上运行的唯一抽象。我们在 GitHub 上看到了许多项目来来去去,试图在 Storm 的基础上构建。说实话,其中大多数并不那么有趣。如果你在拓扑结构中重复进行相同类型的工作,也许你也会创建自己的 Storm 抽象来简化特定的流程。目前最有趣的 Storm 抽象是来自 Twitter 的 Algebird (github.com/twitter/algebird)。
Algebird 是一个 Scala 库,它允许你编写可以“编译”并在 Storm 或 Hadoop 上运行的抽象代数代码。这有什么有趣之处呢?你可以编写各种算法,然后在批处理和流式处理环境中重用它们。如果你问我们,这真的很酷。即使你不需要编写可重用的代数,如果你对在 Storm 上构建抽象感兴趣,我们也建议你查看这个项目;你可以从中学到很多东西。
这就是我们所能提供的全部内容。祝你好运;我们支持你!Sean,Matt 和 Peter 敬上。




浙公网安备 33010602011771号