Storm-蓝图-全-

Storm 蓝图(全)

原文:zh.annas-archive.org/md5/770BD43D187DC246E15A42C26D059632

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

对及时可行的信息的需求正在推动软件系统在更短的时间内处理越来越多的数据。此外,随着连接设备数量的增加,以及这些设备应用于越来越广泛的行业,这种需求变得越来越普遍。传统的企业运营系统被迫处理最初只与互联网规模公司相关的数据规模。这一巨大的转变迫使更传统的架构和方法崩溃,这些架构和方法曾将在线交易系统和离线分析分开。相反,人们正在重新想象从数据中提取信息的含义。框架和基础设施也在发展以适应这一新愿景。

具体来说,数据生成现在被视为一系列离散事件。这些事件流与数据流相关,一些是操作性的,一些是分析性的,但由一个共同的框架和基础设施处理。

风暴是实时流处理最流行的框架。它提供了在高容量、关键任务应用中所需的基本原语和保证。它既是集成技术,也是数据流和控制机制。许多大公司都将风暴作为其大数据平台的支柱。

使用本书的设计模式,您将学会开发、部署和操作能够处理数十亿次交易的数据处理流。

《风暴蓝图:分布式实时计算模式》涵盖了广泛的分布式计算主题,不仅包括设计和集成模式,还包括技术立即有用和常用的领域和应用。本书通过真实世界的例子向读者介绍了风暴,从简单的风暴拓扑开始。示例逐渐复杂,引入了高级风暴概念以及更复杂的部署和运营问题。

本书涵盖的内容

第一章,“分布式词频统计”,介绍了使用风暴进行分布式流处理的核心概念。分布式词频统计示例演示了更复杂计算所需的许多结构、技术和模式。在本章中,我们将对风暴计算结构有基本的了解。我们将建立开发环境,并了解用于调试和开发风暴应用的技术。

第二章,“配置风暴集群”,深入探讨了风暴技术栈以及设置和部署到风暴集群的过程。在本章中,我们将使用 Puppet provisioning 工具自动化安装和配置多节点集群。

第三章,“Trident 拓扑和传感器数据”,涵盖了 Trident 拓扑。Trident 在风暴之上提供了更高级的抽象,抽象了事务处理和状态管理的细节。在本章中,我们将应用 Trident 框架来处理、聚合和过滤传感器数据以检测疾病爆发。

第四章,“实时趋势分析”,介绍了使用风暴和 Trident 的趋势分析技术。实时趋势分析涉及识别数据流中的模式。在本章中,您将与 Apache Kafka 集成,并实现滑动窗口来计算移动平均值。

第五章,“实时图分析”,涵盖了使用 Storm 进行图分析,将数据持久化到图数据库并查询数据以发现关系。图数据库是将数据存储为图结构的数据库,具有顶点、边和属性,并主要关注实体之间的关系。在本章中,您将使用 Twitter 作为数据源,将 Storm 与流行的图数据库 Titan 整合。

第六章,“人工智能”,将 Storm 应用于通常使用递归实现的人工智能算法。我们揭示了 Storm 的一些局限性,并研究了适应这些局限性的模式。在本章中,使用分布式远程过程调用DRPC),您将实现一个 Storm 拓扑,能够为同步查询提供服务,以确定井字游戏中的下一步最佳移动。

第七章,“集成 Druid 进行金融分析”,演示了将 Storm 与非事务系统集成的复杂性。为了支持这样的集成,本章介绍了一种利用 ZooKeeper 管理分布式状态的模式。在本章中,您将把 Storm 与 Druid 整合,Druid 是一个用于探索性分析的开源基础设施,用于提供可配置的实时分析金融事件的系统。

第八章,“自然语言处理”,介绍了 Lambda 架构的概念,将实时和批处理配对,创建一个用于分析的弹性系统。在第七章,“集成 Druid 进行金融分析”的基础上,您将整合 Hadoop 基础设施,并研究 MapReduce 作业,以在主机故障时在 Druid 中回填分析。

第九章,“在 Hadoop 上部署 Storm 进行广告分析”,演示了将现有的在 Hadoop 上运行的 Pig 脚本批处理过程转换为实时 Storm 拓扑的过程。为此,您将利用 Storm-YARN,它允许用户利用 YARN 来部署和运行 Storm 集群。在 Hadoop 上运行 Storm 允许企业 consoliolidate operations and utilize the same infrastructure for both real time and batch processing.

第十章,“云中的 Storm”,涵盖了在云服务提供商托管环境中运行和部署 Storm 的最佳实践。具体来说,您将利用 Apache Whirr,一组用于云服务的库,来部署和配置 Storm 及其支持技术,以在通过亚马逊网络服务AWS弹性计算云EC2)提供的基础设施上进行部署。此外,您将利用 Vagrant 创建用于开发和测试的集群环境。

您需要本书的什么

以下是本书使用的软件列表:

章节编号 需要的软件
1 Storm(0.9.1)
2 Zookeeper(3.3.5)Java(1.7)Puppet(3.4.3)Hiera(1.3.1)
3 三叉戟(通过 Storm 0.9.1)
4 Kafka(0.7.2)OpenFire(3.9.1)
5 Twitter4J(3.0.3)Titan(0.3.2)Cassandra(1.2.9)
6 没有新软件
7 MySQL(5.6.15)Druid(0.5.58)
8 Hadoop(0.20.2)
9 Storm-YARN(1.0-alpha)Hadoop(2.1.0-beta)
10 Whirr(0.8.2)Vagrant(1.4.3)

这本书是为谁准备的

Storm Blueprints: Patterns for Distributed Real-time Computation通过描述基于真实示例应用的广泛适用的分布式计算模式,使初学者和高级用户都受益。本书介绍了 Storm 和 Trident 中的核心原语以及成功部署和操作所需的关键技术。

尽管该书主要关注使用 Storm 进行 Java 开发,但这些模式适用于其他语言,书中描述的技巧、技术和方法适用于架构师、开发人员、系统和业务运营。

对于 Hadoop 爱好者来说,这本书也是对 Storm 的很好介绍。该书演示了这两个系统如何相互补充,并提供了从批处理到实时分析世界的潜在迁移路径。

该书提供了将 Storm 应用于各种问题和行业的示例,这应该可以转化为其他面临处理大型数据集的问题的领域。因此,解决方案架构师和业务分析师将受益于这些章节介绍的高级系统架构和技术。

约定

在本书中,您会发现一些文本样式,用于区分不同类型的信息。以下是一些这些样式的示例,以及它们的含义解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:“所有 Hadoop 配置文件都位于$HADOOP_CONF_DIR中。例如,此示例的三个关键配置文件是:core-site.xmlyarn-site.xmlhdfs-site.xml。”

一块代码设置如下:

<configuration>
    <property>
        <name>fs.default.name</name>
        <value>hdfs://master:8020</value>
    </property>
</configuration>

当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体显示:

13/10/09 21:40:10 INFO yarn.StormAMRMClient: Use NMClient to launch supervisors in container.  
13/10/09 21:40:10 INFO impl.ContainerManagementProtocolProxy: Opening proxy : slave05:35847 
13/10/09 21:40:12 INFO yarn.StormAMRMClient: Supervisor log: http://slave05:8042/node/containerlogs/container_1381197763696_0004_01_000002/boneill/supervisor.log 
13/10/09 21:40:14 INFO yarn.MasterServer: HB: Received allocated containers (1) 13/10/09 21:40:14 INFO yarn.MasterServer: HB: Supervisors are to run, so queueing (1) containers... 
13/10/09 21:40:14 INFO yarn.MasterServer: LAUNCHER: Taking container with id (container_1381197763696_0004_01_000004) from the queue. 
13/10/09 21:40:14 INFO yarn.MasterServer: LAUNCHER: Supervisors are to run, so launching container id (container_1381197763696_0004_01_000004) 
13/10/09 21:40:16 INFO yarn.StormAMRMClient: Use NMClient to launch supervisors in container.  13/10/09 21:40:16 INFO impl.ContainerManagementProtocolProxy: Opening proxy : dlwolfpack02.hmsonline.com:35125 
13/10/09 21:40:16 INFO yarn.StormAMRMClient: Supervisor log: http://slave02:8042/node/containerlogs/container_1381197763696_0004_01_000004/boneill/supervisor.log

任何命令行输入或输出都是这样写的:

hadoop fs -mkdir /user/bone/lib/
hadoop fs -copyFromLocal ./lib/storm-0.9.0-wip21.zip /user/bone/lib/

新术语重要单词以粗体显示。例如,屏幕上看到的单词,菜单或对话框中的单词等,会在文本中以这种方式出现:“在页面顶部的筛选器下拉菜单中选择公共图像。”

注意

警告或重要说明会出现在这样的框中。

提示

技巧和窍门看起来像这样。

第一章:分布式单词计数

在本章中,我们将介绍使用 Storm 创建分布式流处理应用程序涉及的核心概念。我们通过构建一个简单的应用程序来计算连续句子流的运行单词计数来实现这一点。单词计数示例涉及许多用于更复杂计算所需的结构、技术和模式,但它简单且易于理解。

我们将从 Storm 的数据结构概述开始,然后实现组成完整 Storm 应用程序的组件。在本章结束时,您将对 Storm 计算的结构、设置开发环境以及开发和调试 Storm 应用程序的技术有了基本的了解。

本章涵盖以下主题:

  • Storm 的基本构造 - 拓扑、流、喷口和螺栓

  • 设置 Storm 开发环境

  • 实现基本的单词计数应用程序

  • 并行化和容错

  • 通过并行化计算任务进行扩展

介绍 Storm 拓扑的元素 - 流、喷口和螺栓

在 Storm 中,分布式计算的结构被称为拓扑,由数据流、喷口(流生产者)和螺栓(操作)组成。Storm 拓扑大致类似于 Hadoop 等批处理系统中的作业。然而,批处理作业具有明确定义的起点和终点,而 Storm 拓扑会永远运行,直到明确终止或取消部署。

介绍 Storm 拓扑的元素 - 流、喷口和螺栓

Storm 拓扑

Storm 中的核心数据结构是元组。元组只是具有命名值(键值对)的列表,而流是元组的无界序列。如果您熟悉复杂事件处理CEP),您可以将 Storm 元组视为事件

喷口

喷口代表数据进入 Storm 拓扑的主要入口点。喷口充当连接到数据源的适配器,将数据转换为元组,并将元组作为流发出。

正如您将看到的,Storm 提供了一个简单的 API 来实现喷口。开发喷口主要是编写代码以从原始来源或 API 中获取数据。潜在的数据来源包括:

  • 来自基于 Web 或移动应用程序的点击流

  • Twitter 或其他社交网络的信息源

  • 传感器输出

  • 应用程序日志事件

由于喷口通常不实现任何特定的业务逻辑,它们通常可以在多个拓扑中重复使用。

螺栓

螺栓可以被视为您计算的运算符函数。它们接受任意数量的流作为输入,处理数据,并可选择发出一个或多个流。螺栓可以订阅喷口或其他螺栓发出的流,从而可以创建一个复杂的流转换网络。

螺栓可以执行任何想象得到的处理,就像喷口 API 一样,螺栓接口简单而直接。螺栓通常执行的典型功能包括:

  • 过滤元组

  • 连接和聚合

  • 计算

  • 数据库读取/写入

介绍单词计数拓扑的数据流

我们的单词计数拓扑(如下图所示)将由一个连接到三个下游螺栓的喷口组成。

介绍单词计数拓扑的数据流

单词计数拓扑

句子喷口

SentenceSpout类将简单地发出一个单值元组流,键名为"sentence",值为字符串(句子),如下面的代码所示:

{ "sentence":"my dog has fleas" }

为了保持简单,我们的数据源将是一个静态的句子列表,我们将循环遍历,为每个句子发出一个元组。在现实世界的应用程序中,喷口通常会连接到动态来源,例如从 Twitter API 检索的推文。

介绍拆分句子螺栓

拆分句子螺栓将订阅句子 spout 的元组流。对于接收到的每个元组,它将查找"sentence"对象的值,将该值拆分为单词,并为每个单词发出一个元组:

{ "word" : "my" }
{ "word" : "dog" }
{ "word" : "has" }
{ "word" : "fleas" }

介绍单词计数螺栓

单词计数螺栓订阅SplitSentenceBolt类的输出,持续计算它见过特定单词的次数。每当它接收到一个元组时,它将增加与单词关联的计数器并发出一个包含单词和当前计数的元组:

{ "word" : "dog", "count" : 5 }

介绍报告螺栓

报告螺栓订阅WordCountBolt类的输出,并维护所有单词及其对应计数的表,就像WordCountBolt一样。当它接收到一个元组时,它会更新表并将内容打印到控制台。

实现单词计数拓扑

现在我们已经介绍了基本的 Storm 概念,我们准备开始开发一个简单的应用程序。目前,我们将在本地模式下开发和运行 Storm 拓扑。Storm 的本地模式在单个 JVM 实例中模拟了一个 Storm 集群,使得在本地开发环境或 IDE 中开发和调试 Storm 拓扑变得容易。在后面的章节中,我们将向您展示如何将在本地模式下开发的 Storm 拓扑部署到完全集群化的环境中。

设置开发环境

创建一个新的 Storm 项目只是将 Storm 库及其依赖项添加到 Java 类路径的问题。然而,正如您将在第二章中了解到的那样,配置 Storm 集群,将 Storm 拓扑部署到集群环境中需要对编译类和依赖项进行特殊打包。因此,强烈建议您使用构建管理工具,如 Apache Maven、Gradle 或 Leinengen。对于分布式单词计数示例,我们将使用 Maven。

让我们开始创建一个新的 Maven 项目:

$ mvn archetype:create -DgroupId=storm.blueprints 
-DartifactId=Chapter1 -DpackageName=storm.blueprints.chapter1.v1

接下来,编辑pom.xml文件并添加 Storm 依赖项:

<dependency>
    <groupId>org.apache.storm</groupId>
    <artifactId>storm-core</artifactId>
    <version>0.9.1-incubating</version>
</dependency>

然后,使用以下命令构建项目来测试 Maven 配置:

$ mvn install

注意

下载示例代码

您可以从www.packtpub.com的帐户中下载您购买的所有 Packt 图书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packtpub.com/support并注册,以便直接通过电子邮件接收文件。

Maven 将下载 Storm 库及其所有依赖项。有了项目设置好了,我们现在准备开始编写我们的 Storm 应用程序。

实现句子 spout

为了简化问题,我们的SentenceSpout实现将通过创建一个静态的句子列表来模拟数据源。每个句子都作为一个单字段元组发出。完整的 spout 实现在示例 1.1中列出。

示例 1.1:SentenceSpout.java

public class SentenceSpout extends BaseRichSpout {

    private SpoutOutputCollector collector;
    private String[] sentences = {
        "my dog has fleas",
        "i like cold beverages",
        "the dog ate my homework",
        "don't have a cow man",
        "i don't think i like fleas"
    };
    private int index = 0;

    public void declareOutputFields(OutputFieldsDeclarer declarer) {
        declarer.declare(new Fields("sentence"));
    }

    public void open(Map config, TopologyContext context, 
            SpoutOutputCollector collector) {
        this.collector = collector;
    }

    public void nextTuple() {
        this.collector.emit(new Values(sentences[index]));
        index++;
        if (index >= sentences.length) {
            index = 0;
        }
        Utils.waitForMillis(1);
    }
}

BaseRichSpout类是ISpoutIComponent接口的方便实现,并为我们在这个例子中不需要的方法提供了默认实现。使用这个类可以让我们只关注我们需要的方法。

declareOutputFields()方法在所有 Storm 组件(spouts 和 bolts)必须实现的IComponent接口中定义,并用于告诉 Storm 组件将发出哪些流以及每个流的元组将包含哪些字段。在这种情况下,我们声明我们的 spout 将发出一个包含单个字段("sentence")的元组的单个(默认)流。

open()方法在ISpout接口中定义,并在初始化 spout 组件时调用。open()方法接受三个参数:包含 Storm 配置的映射,提供有关拓扑中放置的组件的信息的TopologyContext对象,以及提供发出元组方法的SpoutOutputCollector对象。在这个例子中,我们在初始化方面不需要做太多,所以open()实现只是将对SpoutOutputCollector对象的引用存储在一个实例变量中。

nextTuple()方法代表任何 spout 实现的核心。Storm 调用此方法请求 spout 向输出收集器发出元组。在这里,我们只发出当前索引处的句子,并增加索引。

实现拆分句子螺栓

SplitSentenceBolt的实现在示例 1.2中列出。

示例 1.2 - SplitSentenceBolt.java

public class SplitSentenceBolt extends BaseRichBolt{
    private OutputCollector collector;

    public void prepare(Map config, TopologyContext context,
 OutputCollector collector) {
        this.collector = collector;
    }

    public void execute(Tuple tuple) {
        String sentence = tuple.getStringByField("sentence");
        String[] words = sentence.split(" ");
        for(String word : words){
            this.collector.emit(new Values(word));
        }
    }

    public void declareOutputFields(OutputFieldsDeclarer declarer) {
        declarer.declare(new Fields("word"));
    }
}

BaseRichBolt类是另一个方便的类,它实现了IComponentIBolt接口。扩展此类使我们不必实现我们不关心的方法,并让我们专注于我们需要的功能。

IBolt接口定义的prepare()方法类似于ISpoutopen()方法。这是您在螺栓初始化期间准备资源(例如数据库连接)的地方。与SentenceSpout类一样,SplitSentenceBolt类在初始化方面不需要太多,因此prepare()方法只是保存对OutputCollector对象的引用。

declareOutputFields()方法中,SplitSentenceBolt类声明了一个包含一个字段("word")的元组流。

SplitSentenceBolt类的核心功能包含在IBolt定义的execute()方法中。每次螺栓从其订阅的流接收元组时,都会调用此方法。在这种情况下,它查找传入元组的“句子”字段的值作为字符串,将该值拆分为单词,并为每个单词发出一个新元组。

实现单词计数螺栓

WordCountBolt类(示例 1.3)实际上是维护单词计数的拓扑组件。在螺栓的prepare()方法中,我们实例化了一个HashMap<String,Long>的实例,该实例将存储所有单词及其相应的计数。在prepare()方法中实例化大多数实例变量是常见做法。这种模式背后的原因在于拓扑部署时,其组件 spouts 和 bolts 会被序列化并通过网络发送。如果一个 spout 或 bolt 在序列化之前实例化了任何不可序列化的实例变量(例如在构造函数中创建),将抛出NotSerializableException,拓扑将无法部署。在这种情况下,由于HashMap<String,Long>是可序列化的,我们可以安全地在构造函数中实例化它。然而,一般来说,最好将构造函数参数限制为基本类型和可序列化对象,并在prepare()方法中实例化不可序列化的对象。

declareOutputFields()方法中,WordCountBolt类声明了一个元组流,其中包含接收到的单词和相应的计数。在execute()方法中,我们查找接收到的单词的计数(必要时将其初始化为0),增加并存储计数,然后发出由单词和当前计数组成的新元组。将计数作为流发出允许拓扑中的其他螺栓订阅该流并执行其他处理。

示例 1.3 - WordCountBolt.java

public class WordCountBolt extends BaseRichBolt{
    private OutputCollector collector;
    private HashMap<String, Long> counts = null;

    public void prepare(Map config, TopologyContext context, 
            OutputCollector collector) {
        this.collector = collector;
        this.counts = new HashMap<String, Long>();
    }

    public void execute(Tuple tuple) {
        String word = tuple.getStringByField("word");
        Long count = this.counts.get(word);
        if(count == null){
            count = 0L;
        }
        count++;
        this.counts.put(word, count);
        this.collector.emit(new Values(word, count));
    }

    public void declareOutputFields(OutputFieldsDeclarer declarer) {
        declarer.declare(new Fields("word", "count"));
    }
}

实现报告螺栓

ReportBolt类的目的是生成每个单词的计数报告。与WordCountBolt类一样,它使用HashMap<String,Long>对象记录计数,但在这种情况下,它只存储从计数螺栓接收到的计数。

到目前为止,我们编写的报告 bolt 与其他 bolt 之间的一个区别是它是一个终端 bolt - 它只接收元组。因为它不发出任何流,所以declareOutputFields()方法为空。

报告 bolt 还引入了IBolt接口中定义的cleanup()方法。当 bolt 即将关闭时,Storm 会调用此方法。我们在这里利用cleanup()方法作为在拓扑关闭时输出最终计数的便捷方式,但通常,cleanup()方法用于释放 bolt 使用的资源,如打开的文件或数据库连接。

在编写 bolt 时,要牢记IBolt.cleanup()方法的一点是,当拓扑在集群上运行时,Storm 不保证会调用它。我们将在下一章讨论 Storm 的容错机制时讨论这背后的原因。但是在这个示例中,我们将在开发模式下运行 Storm,其中保证会调用cleanup()方法。

ReportBolt类的完整源代码在示例 1.4 中列出。

示例 1.4 - ReportBolt.java

public class ReportBolt extends BaseRichBolt {

    private HashMap<String, Long> counts = null;

    public void prepare(Map config, TopologyContext context, OutputCollector collector) {
        this.counts = new HashMap<String, Long>();
    }

    public void execute(Tuple tuple) {
        String word = tuple.getStringByField("word");
        Long count = tuple.getLongByField("count");
        this.counts.put(word, count);
    }

    public void declareOutputFields(OutputFieldsDeclarer declarer) {
        // this bolt does not emit anything
    }

    public void cleanup() {
        System.out.println("--- FINAL COUNTS ---");
        List<String> keys = new ArrayList<String>();
        keys.addAll(this.counts.keySet());
        Collections.sort(keys);
        for (String key : keys) {
            System.out.println(key + " : " + this.counts.get(key));
        }
        System.out.println("--------------");
    }
}

实现单词计数拓扑

现在我们已经定义了组成我们计算的 spout 和 bolts,我们准备将它们连接到一个可运行的拓扑中(参考示例 1.5)。

示例 1.5 - WordCountTopology.java

public class WordCountTopology {

    private static final String SENTENCE_SPOUT_ID = "sentence-spout";
    private static final String SPLIT_BOLT_ID = "split-bolt";
    private static final String COUNT_BOLT_ID = "count-bolt";
    private static final String REPORT_BOLT_ID = "report-bolt";
    private static final String TOPOLOGY_NAME = "word-count-topology";

    public static void main(String[] args) throws Exception {

        SentenceSpout spout = new SentenceSpout();
        SplitSentenceBolt splitBolt = new SplitSentenceBolt();
        WordCountBolt countBolt = new WordCountBolt();
        ReportBolt reportBolt = new ReportBolt();

        TopologyBuilder builder = new TopologyBuilder();

        builder.setSpout(SENTENCE_SPOUT_ID, spout);
        // SentenceSpout --> SplitSentenceBolt
        builder.setBolt(SPLIT_BOLT_ID, splitBolt)
                .shuffleGrouping(SENTENCE_SPOUT_ID);
        // SplitSentenceBolt --> WordCountBolt
        builder.setBolt(COUNT_BOLT_ID, countBolt)
                .fieldsGrouping(SPLIT_BOLT_ID, new Fields("word"));
        // WordCountBolt --> ReportBolt
        builder.setBolt(REPORT_BOLT_ID, reportBolt)
                .globalGrouping(COUNT_BOLT_ID);

        Config config = new Config();

        LocalCluster cluster = new LocalCluster();

        cluster.submitTopology(TOPOLOGY_NAME, config, builder.createTopology());
        waitForSeconds(10);
        cluster.killTopology(TOPOLOGY_NAME);
        cluster.shutdown();
    }
}

Storm 拓扑通常在 Java 的main()方法中定义和运行(或者如果拓扑正在部署到集群,则提交)。在这个示例中,我们首先定义了字符串常量,它们将作为我们 Storm 组件的唯一标识符。我们通过实例化我们的 spout 和 bolts 并创建TopologyBuilder的实例来开始main()方法。TopologyBuilder类提供了一种流畅的 API,用于定义拓扑中组件之间的数据流。我们首先注册了句子 spout 并为其分配了一个唯一的 ID:

builder.setSpout(SENTENCE_SPOUT_ID, spout);

下一步是注册SplitSentenceBolt并订阅SentenceSpout类发出的流:

builder.setBolt(SPLIT_BOLT_ID, splitBolt)
                .shuffleGrouping(SENTENCE_SPOUT_ID);

setBolt()方法使用TopologyBuilder类注册一个 bolt,并返回一个BoltDeclarer的实例,该实例公开了定义 bolt 的输入源的方法。在这里,我们将为SentenceSpout对象定义的唯一 ID 传递给shuffleGrouping()方法来建立关系。shuffleGrouping()方法告诉 Storm 对SentenceSpout类发出的元组进行洗牌,并将它们均匀分布在SplitSentenceBolt对象的实例之间。我们将在 Storm 的并行性讨论中很快详细解释流分组。

下一行建立了SplitSentenceBolt类和WordCountBolt类之间的连接:

builder.setBolt(COUNT_BOLT_ID, countBolt)
                .fieldsGrouping(SPLIT_BOLT_ID, new Fields("word"));

正如您将了解的那样,有时候有必要将包含特定数据的元组路由到特定的 bolt 实例。在这里,我们使用BoltDeclarer类的fieldsGrouping()方法,以确保所有包含相同"word"值的元组都被路由到同一个WordCountBolt实例。

定义我们数据流的最后一步是将WordCountBolt实例发出的元组流路由到ReportBolt类。在这种情况下,我们希望WordCountBolt发出的所有元组都路由到单个ReportBolt任务。这种行为由globalGrouping()方法提供,如下所示:

builder.setBolt(REPORT_BOLT_ID, reportBolt)
                .globalGrouping(COUNT_BOLT_ID);

随着我们定义的数据流,运行单词计数计算的最后一步是构建拓扑并将其提交到集群:

Config config = new Config();

LocalCluster cluster = new LocalCluster();

        cluster.submitTopology(TOPOLOGY_NAME, config, builder.createTopology());
        waitForSeconds(10);
        cluster.killTopology(TOPOLOGY_NAME);
        cluster.shutdown();

在这里,我们使用 Storm 的LocalCluster类在本地模式下运行 Storm,以模拟在本地开发环境中完整的 Storm 集群。本地模式是一种方便的方式来开发和测试 Storm 应用程序,而不需要部署到分布式集群中的开销。本地模式还允许您在 IDE 中运行 Storm 拓扑,设置断点,停止执行,检查变量并以更加耗时或几乎不可能的方式对应用程序进行分析,而不需要部署到 Storm 集群。

在这个例子中,我们创建了一个LocalCluster实例,并使用拓扑名称、backtype.storm.Config的实例以及TopologyBuilder类的createTopology()方法返回的Topology对象调用了submitTopology()方法。正如你将在下一章中看到的,用于在本地模式部署拓扑的submitTopology()方法与用于在远程(分布式)模式部署拓扑的方法具有相同的签名。

Storm 的Config类只是HashMap<String, Object>的扩展,它定义了一些 Storm 特定的常量和方便的方法,用于配置拓扑的运行时行为。当一个拓扑被提交时,Storm 将其预定义的默认配置值与传递给submitTopology()方法的Config实例的内容合并,结果将传递给拓扑 spouts 和 bolts 的open()prepare()方法。在这个意义上,Config对象代表了一组对拓扑中所有组件都是全局的配置参数。

现在我们准备运行WordCountTopology类。main()方法将提交拓扑,在其运行时等待十秒,终止(取消部署)拓扑,最后关闭本地集群。当程序运行完成时,您应该看到类似以下的控制台输出:

--- FINAL COUNTS ---
a : 1426
ate : 1426
beverages : 1426
cold : 1426
cow : 1426
dog : 2852
don't : 2851
fleas : 2851
has : 1426
have : 1426
homework : 1426
i : 4276
like : 2851
man : 1426
my : 2852
the : 1426
think : 1425
-------------- 

在 Storm 中引入并行性

回顾一下介绍中提到的,Storm 允许计算通过将计算分成多个独立的任务并行执行在集群中的多台机器上进行水平扩展。在 Storm 中,任务简单地是在集群中某处运行的 spout 或 bolt 的实例。

要理解并行性是如何工作的,我们必须首先解释在 Storm 集群中执行拓扑涉及的四个主要组件:

  • 节点(机器):这些只是配置为参与 Storm 集群并执行拓扑部分的机器。Storm 集群包含执行工作的一个或多个节点。

  • 工作者(JVMs):这些是在节点上运行的独立 JVM 进程。每个节点配置为运行一个或多个工作者。一个拓扑可以请求分配给它一个或多个工作者。

  • 执行器(线程):这些是在工作者 JVM 进程中运行的 Java 线程。可以将多个任务分配给单个执行器。除非明确覆盖,否则 Storm 将为每个执行器分配一个任务。

  • 任务(bolt/spout 实例):任务是 spout 和 bolt 的实例,其nextTuple()execute()方法由执行器线程调用。

WordCountTopology 并行性

到目前为止,在我们的单词计数示例中,我们并没有显式地使用 Storm 的并行性 API;相反,我们允许 Storm 使用其默认设置。在大多数情况下,除非被覆盖,否则 Storm 将默认大多数并行性设置为一个因子。

在更改我们拓扑的并行性设置之前,让我们考虑一下我们的拓扑将如何在默认设置下执行。假设我们有一台机器(节点),已经为拓扑分配了一个 worker,并允许 Storm 为每个执行器分配一个任务,我们的拓扑执行将如下所示:

WordCountTopology 并行性

拓扑执行

正如您所看到的,我们唯一的并行性是在线程级别。每个任务在单个 JVM 内的不同线程上运行。我们如何增加并行性以更有效地利用我们手头的硬件呢?让我们从增加分配给运行我们拓扑的工作进程和执行器的数量开始。

向拓扑添加工作进程

分配额外的工作进程是增加拓扑的计算能力的一种简单方法,Storm 提供了通过 API 和纯配置来实现这一点的方法。无论我们选择哪种方法,我们的组件 spouts 和 bolts 都不需要改变,可以原样重用。

在之前的单词计数拓扑的版本中,我们介绍了Config对象,在部署时传递给submitTopology()方法,但基本上没有使用。要增加分配给拓扑的工作进程数量,我们只需调用Config对象的setNumWorkers()方法:

    Config config = new Config();
    config.setNumWorkers(2);

这将为我们的拓扑分配两个工作进程,而不是默认的一个。虽然这将为我们的拓扑增加计算资源,但为了有效利用这些资源,我们还需要调整拓扑中执行器的数量以及每个执行器的任务数量。

配置执行器和任务

正如我们所见,Storm 默认为拓扑中定义的每个组件创建一个任务,并为每个任务分配一个执行器。Storm 的并行性 API 通过允许你设置每个任务的执行器数量以及每个执行器的任务数量来控制这种行为。

在定义流分组时,通过设置并行性提示来配置给定组件分配的执行器数量。为了说明这个特性,让我们修改我们的拓扑定义,使SentenceSpout并行化,分配两个任务,并且每个任务分配自己的执行器线程:

builder.setSpout(SENTENCE_SPOUT_ID, spout, 2);

如果我们使用一个工作进程,我们拓扑的执行现在看起来像下面这样:

配置执行器和任务

两个 spout 任务

接下来,我们将设置拆分句子的 bolt 以四个任务执行,每个任务有两个执行器。每个执行器线程将被分配两个任务来执行(4/2=2)。我们还将配置单词计数 bolt 以四个任务运行,每个任务都有自己的执行器线程:

builder.setBolt(SPLIT_BOLT_ID, splitBolt, 2)
              .setNumTasks(4)
                .shuffleGrouping(SENTENCE_SPOUT_ID);

builder.setBolt(COUNT_BOLT_ID, countBolt, 4)
                .fieldsGrouping(SPLIT_BOLT_ID, new Fields("word"));

有了两个工作进程,拓扑的执行现在看起来像下面的图表:

配置执行器和任务

使用多个工作进程的并行性

随着拓扑并行性的增加,运行更新的WordCountTopology类应该会产生每个单词的更高总计数:

--- FINAL COUNTS ---
a : 2726
ate : 2722
beverages : 2723
cold : 2723
cow : 2726
dog : 5445
don't : 5444
fleas : 5451
has : 2723
have : 2722
homework : 2722
i : 8175
like : 5449
man : 2722
my : 5445
the : 2727
think : 2722
--------------

由于 spout 会无限发出数据,并且只有在拓扑被终止时才会停止,实际的计数会根据您的计算机速度和其他正在运行的进程而变化,但您应该会看到发出和处理的单词数量总体上增加。

重要的是要指出,增加工作进程的数量在本地模式下运行拓扑时没有任何效果。在本地模式下运行的拓扑始终在单个 JVM 进程中运行,因此只有任务和执行器并行性设置才会产生任何效果。Storm 的本地模式提供了对集群行为的一个不错的近似,并且对开发非常有用,但在移动到生产环境之前,您应该始终在真正的集群环境中测试您的应用程序。

理解流分组

根据之前的例子,你可能会想知道为什么我们没有费心增加ReportBolt的并行性。答案是这样做没有任何意义。要理解原因,你需要理解 Storm 中流分组的概念。

流分组定义了流的元组在拓扑中的 bolt 任务之间如何分布。例如,在单词计数拓扑的并行化版本中,SplitSentenceBolt类在拓扑中被分配了四个任务。流分组确定了哪个任务会接收给定的元组。

风暴定义了七种内置的流分组:

  • 随机分组:这会随机分发元组到目标 bolt 任务的任务,以便每个 bolt 都会收到相同数量的元组。

  • 字段分组:根据分组中指定字段的值将元组路由到 bolt 任务。例如,如果流根据"word"字段分组,具有相同"word"字段值的元组将始终路由到同一个 bolt 任务。

  • 全部分组:这会将元组流复制到所有 bolt 任务中,以便每个任务都会收到元组的副本。

  • 全局分组:这会将流中的所有元组路由到单个任务,选择具有最低任务 ID 值的任务。请注意,当使用全局分组时,在 bolt 上设置并行性提示或任务数量是没有意义的,因为所有元组都将路由到同一个 bolt 任务。全局分组应谨慎使用,因为它会将所有元组路由到单个 JVM 实例,可能会在集群中创建瓶颈或压倒特定的 JVM/机器。

  • 无分组:无分组在功能上等同于随机分组。它已被保留以供将来使用。

  • 直接分组:使用直接分组,源流通过调用emitDirect()方法决定哪个组件将接收给定的元组。它只能用于已声明为直接流的流。

  • 本地或随机分组:本地或随机分组类似于随机分组,但会在同一工作进程中运行的 bolt 任务之间随机传输元组,如果有的话。否则,它将回退到随机分组的行为。根据拓扑的并行性,本地或随机分组可以通过限制网络传输来提高拓扑性能。

除了预定义的分组,您还可以通过实现CustomStreamGrouping接口来定义自己的流分组:

public interface CustomStreamGrouping extends Serializable {

void prepare(WorkerTopologyContext context, 
GlobalStreamId stream, List<Integer> targetTasks);

List<Integer> chooseTasks(int taskId, List<Object> values); 
}

prepare()方法在运行时调用,以使用分组实现可以用来决定如何将元组分组到接收任务的信息。WorkerTopologyContext对象提供有关拓扑的上下文信息,GlobalStreamId对象提供有关正在分组的流的元数据。最有用的参数是targetTasks,它是需要考虑的所有任务标识符的列表。通常,您会希望将targetTasks参数存储为一个实例变量,以便在chooseTasks()方法的实现中进行参考。

chooseTasks()方法返回应将元组发送到的任务标识符列表。它的参数是发出元组的组件的任务标识符和元组的值。

为了说明流分组的重要性,让我们在拓扑中引入一个 bug。首先修改SentenceSpoutnextTuple()方法,使其只发出每个句子一次:

public void nextTuple() {
        if(index < sentences.length){
            this.collector.emit(new Values(sentences[index]));
            index++;
        }
        Utils.waitForMillis(1);
    }

现在运行拓扑以获得以下输出:

--- FINAL COUNTS ---
a : 2
ate : 2
beverages : 2
cold : 2
cow : 2
dog : 4
don't : 4
fleas : 4
has : 2
have : 2
homework : 2
i : 6
like : 4
man : 2
my : 4
the : 2
think : 2
--------------

现在将CountBolt参数上的字段分组更改为随机分组,并重新运行拓扑:

builder.setBolt(COUNT_BOLT_ID, countBolt, 4)
                .shuffleGrouping(SPLIT_BOLT_ID);

输出应该如下所示:

--- FINAL COUNTS ---
a : 1
ate : 2
beverages : 1
cold : 1
cow : 1
dog : 2
don't : 2
fleas : 1
has : 1
have : 1
homework : 1
i : 3
like : 1
man : 1
my : 1
the : 1
think : 1
--------------

我们的计数不准确,因为CountBolt参数是有状态的:它会维护每个单词的计数。在这种情况下,我们的计算准确性取决于在组件被并行化时基于元组内容进行分组的能力。我们引入的 bug 只有在CountBolt参数的并行性大于一时才会显现。这凸显了使用不同并行性配置测试拓扑的重要性。

提示

一般来说,您应该避免在 bolt 中存储状态信息,因为每当一个 worker 失败和/或其任务被重新分配时,该信息将丢失。一种解决方案是定期将状态信息快照到持久存储中,例如数据库,以便在任务重新分配时可以恢复。

保证处理

Storm 提供了一个 API,允许您保证喷嘴发出的元组被完全处理。到目前为止,在我们的示例中,我们并不担心失败。我们已经看到,喷嘴流可以被分割,并且可以根据下游螺栓的行为在拓扑中生成任意数量的流。在发生故障时会发生什么?例如,考虑一个将信息持久化到基于数据库的元组数据的螺栓。我们如何处理数据库更新失败的情况?

喷嘴的可靠性

在 Storm 中,可靠的消息处理始于喷嘴。支持可靠处理的喷嘴需要一种方式来跟踪它发出的元组,并准备好在下游处理该元组或任何子元组失败时重新发出元组。子元组可以被认为是源自喷嘴的元组的任何派生元组。另一种看待它的方式是将喷嘴的流视为元组树的主干(如下图所示):

喷嘴的可靠性

元组树

在前面的图中,实线代表喷嘴发出的原始主干元组,虚线代表从原始元组派生的元组。结果图表示元组。通过可靠处理,树中的每个螺栓都可以确认(ack)或失败一个元组。如果树中的所有螺栓都确认从主干元组派生的元组,喷嘴的ack方法将被调用以指示消息处理已完成。如果树中的任何螺栓明确失败一个元组,或者如果元组树的处理超过了超时期限,喷嘴的fail方法将被调用。

Storm 的ISpout接口定义了可靠性 API 中涉及的三种方法:nextTupleackfail

public interface ISpout extends Serializable {
    void open(Map conf, TopologyContext context, SpoutOutputCollector collector);
    void close();
    void nextTuple();
    void ack(Object msgId);
    void fail(Object msgId);
}

正如我们之前所看到的,当 Storm 请求喷嘴发出一个元组时,它会调用nextTuple()方法。实现可靠处理的第一步是为出站元组分配一个唯一的 ID,并将该值传递给SpoutOutputCollectoremit()方法:

collector.emit(new Values("value1", "value2") , msgId);

分配元组的消息 ID 告诉 Storm,喷嘴希望在元组树完成或在任何时候失败时接收通知。如果处理成功,喷嘴的ack()方法将使用分配给元组的消息 ID 进行调用。如果处理失败或超时,喷嘴的fail方法将被调用。

螺栓的可靠性

参与可靠处理的螺栓的实现涉及两个步骤:

  1. 在发出派生元组时锚定到传入的元组。

  2. 确认或失败已成功或不成功处理的元组。

锚定到元组意味着我们正在创建一个链接,使传入的元组和派生的元组之间建立联系,以便任何下游的螺栓都应该参与元组树,确认元组,失败元组,或允许其超时。

您可以通过调用OutputCollector的重载的emit方法将锚定到元组(或元组列表):

collector.emit(tuple, new Values(word));

在这里,我们将锚定到传入的元组,并发出一个新的元组,下游的螺栓应该承认或失败。emit方法的另一种形式将发出未锚定的元组:

collector.emit(new Values(word));));

未锚定的元组不参与流的可靠性。如果未锚定的元组在下游失败,它不会导致原始根元组的重播。

成功处理一个元组并可选地发出新的或派生的元组后,处理可靠流的螺栓应该确认传入的元组:

this.collector.ack(tuple);

如果元组处理失败,以至于喷嘴必须重播(重新发出)元组,螺栓应该显式失败元组:

this.collector.fail(tuple)

如果元组处理因超时或显式调用OutputCollector.fail()方法而失败,将通知发出原始元组的喷嘴,从而允许它重新发出元组,您很快就会看到。

可靠的字数统计

为了进一步说明可靠性,让我们从增强SentenceSpout类开始,使其支持保证交付。它将需要跟踪所有发出的元组,并为每个元组分配一个唯一的 ID。我们将使用HashMap<UUID, Values>对象来存储待处理的元组。对于我们发出的每个元组,我们将分配一个唯一的标识符,并将其存储在我们的待处理元组映射中。当我们收到确认时,我们将从待处理列表中删除元组。在失败时,我们将重放元组:

public class SentenceSpout extends BaseRichSpout {

    private ConcurrentHashMap<UUID, Values> pending;
    private SpoutOutputCollector collector;
    private String[] sentences = {
        "my dog has fleas",
        "i like cold beverages",
        "the dog ate my homework",
        "don't have a cow man",
        "i don't think i like fleas"
    };
    private int index = 0;

    public void declareOutputFields(OutputFieldsDeclarer declarer) {
        declarer.declare(new Fields("sentence"));
    }

    public void open(Map config, TopologyContext context, 
            SpoutOutputCollector collector) {
        this.collector = collector;
        this.pending = new ConcurrentHashMap<UUID, Values>();
    }

    public void nextTuple() {
        Values values = new Values(sentences[index]);
        UUID msgId = UUID.randomUUID();
        this.pending.put(msgId, values);
        this.collector.emit(values, msgId);
        index++;
        if (index >= sentences.length) {
            index = 0;
        }
        Utils.waitForMillis(1);
    }

    public void ack(Object msgId) {
        this.pending.remove(msgId);
    }

    public void fail(Object msgId) {
        this.collector.emit(this.pending.get(msgId), msgId);
    }    
}

修改螺栓以提供保证的处理只是简单地将出站元组锚定到传入的元组,然后确认传入的元组:

public class SplitSentenceBolt extends BaseRichBolt{
    private OutputCollector collector;

    public void prepare(Map config, TopologyContext context, OutputCollector collector) {
        this.collector = collector;
    }

    public void execute(Tuple tuple) {
        String sentence = tuple.getStringByField("sentence");
        String[] words = sentence.split(" ");
        for(String word : words){
            this.collector.emit(tuple, new Values(word));
        }
        this.collector.ack(tuple);
    }

    public void declareOutputFields(OutputFieldsDeclarer declarer) {
        declarer.declare(new Fields("word"));
    }
}

总结

在本章中,我们使用 Storm 的核心 API 构建了一个简单的分布式计算应用程序,并涵盖了 Storm 的大部分功能集,甚至没有安装 Storm 或设置集群。Storm 的本地模式在生产力和开发便利性方面非常强大,但要看到 Storm 的真正力量和水平可伸缩性,您需要将应用程序部署到一个真正的集群中。

在下一章中,我们将步入安装和设置集群 Storm 环境以及在分布式环境中部署拓扑的过程。

第二章:配置 Storm 集群

在这一章中,您将深入了解 Storm 技术栈、其软件依赖关系以及设置和部署到 Storm 集群的过程。

我们将从在伪分布模式下安装 Storm 开始,其中所有组件都位于同一台机器上,而不是分布在多台机器上。一旦您了解了安装和配置 Storm 所涉及的基本步骤,我们将继续使用 Puppet 配置工具自动化这些过程,这将大大减少设置多节点集群所需的时间和精力。

具体来说,我们将涵盖:

  • 组成集群的各种组件和服务

  • Storm 技术栈

  • 在 Linux 上安装和配置 Storm

  • Storm 的配置参数

  • Storm 的命令行界面

  • 使用 Puppet 配置工具自动化安装

介绍 Storm 集群的解剖

Storm 集群遵循类似于 Hadoop 等分布式计算技术的主/从架构,但语义略有不同。在主/从架构中,通常有一个主节点,可以通过配置静态分配或在运行时动态选举。Storm 使用前一种方法。虽然主/从架构可能会被批评为引入单点故障的设置,但我们将展示 Storm 对主节点故障具有一定的容错性。

Storm 集群由一个主节点(称为nimbus)和一个或多个工作节点(称为supervisors)组成。除了 nimbus 和 supervisor 节点外,Storm 还需要一个 Apache ZooKeeper 的实例,它本身可能由一个或多个节点组成,如下图所示:

介绍 Storm 集群的解剖

nimbus 和 supervisor 进程都是 Storm 提供的守护进程,不需要从单独的机器中隔离出来。事实上,可以在同一台机器上运行 nimbus、supervisor 和 ZooKeeper 进程,从而创建一个单节点伪集群。

了解 nimbus 守护程序

nimbus 守护程序的主要责任是管理、协调和监视在集群上运行的拓扑,包括拓扑部署、任务分配以及在失败时重新分配任务。

将拓扑部署到 Storm 集群涉及提交预打包的拓扑 JAR 文件到 nimbus 服务器以及拓扑配置信息。一旦 nimbus 收到拓扑归档,它会将 JAR 文件分发给必要数量的 supervisor 节点。当 supervisor 节点收到拓扑归档时,nimbus 会为每个 supervisor 分配任务(spout 和 bolt 实例)并向它们发出信号,以生成执行分配任务所需的工作节点。

Nimbus 跟踪所有 supervisor 节点的状态以及分配给每个节点的任务。如果 nimbus 检测到特定的 supervisor 节点未能心跳或变得不可用,它将重新分配该 supervisor 的任务到集群中的其他 supervisor 节点。

如前所述,nimbus 在严格意义上并不是单点故障。这一特性是因为 nimbus 并不参与拓扑数据处理,而仅仅管理拓扑的初始部署、任务分配和监视。事实上,如果 nimbus 守护程序在拓扑运行时死机,只要分配任务的 supervisors 和 workers 保持健康,拓扑将继续处理数据。主要的警告是,如果 nimbus 宕机时 supervisor 失败,数据处理将失败,因为没有 nimbus 守护程序将失败的 supervisor 任务重新分配到另一个节点。

与监督守护程序一起工作

supervisor 守护程序等待来自 nimbus 的任务分配,并生成和监视工作进程(JVM 进程)来执行任务。supervisor 守护程序和它生成的工作进程都是单独的 JVM 进程。如果由 supervisor 生成的工作进程由于错误意外退出(甚至如果进程被强制使用 UNIX 的kill -9或 Windows 的taskkill命令终止),supervisor 守护程序将尝试重新生成工作进程。

此时,您可能想知道 Storm 的可靠交付功能如何适应其容错模型。如果一个 worker 甚至整个 supervisor 节点失败,Storm 如何保证在故障发生时正在处理的元组的交付?

答案在于 Storm 的元组锚定和确认机制。启用可靠交付后,路由到失败节点上的任务的元组将不会被确认,并且原始元组最终将在超时后由 spout 重新播放。这个过程将重复,直到拓扑已经恢复并且正常处理已经恢复。

介绍 Apache ZooKeeper

ZooKeeper 提供了在分布式环境中维护集中信息的服务,使用一小组基本原语和组服务。它具有简单而强大的分布式同步机制,允许客户端应用程序监视或订阅单个数据或数据集,并在创建、更新或修改数据时接收通知。使用常见的 ZooKeeper 模式或配方,开发人员可以实现分布式应用程序所需的许多不同构造,如领导者选举、分布式锁和队列。

Storm 主要使用 ZooKeeper 来协调任务分配、worker 状态和集群中 nimbus 和 supervisor 之间的拓扑指标等状态信息。Nimbus 和 supervisor 节点之间的通信主要通过 ZooKeeper 的状态修改和监视通知来处理。

Storm 对 ZooKeeper 的使用设计上相对轻量,并不会产生沉重的资源负担。对于较重的数据传输操作,例如拓扑 JAR 文件的一次性(在部署时)传输,Storm 依赖于 Thrift 进行通信。正如我们将看到的,拓扑中组件之间的数据传输操作——在性能最重要的地方——是在低级别处理并针对性能进行了优化。

使用 Storm 的 DRPC 服务器

Storm 应用程序中常见的模式涉及利用 Storm 的并行化和分布式计算能力,其中客户端进程或应用程序在请求-响应范式中提交请求并同步等待响应。虽然这样的范式似乎与典型 Storm 拓扑的高度异步、长寿命的特性相悖,但 Storm 包括了一种事务能力,可以实现这样的用例。

使用 Storm 的 DRPC 服务器

为了启用这个功能,Storm 使用了额外的服务(Storm DRPC)和一个专门的 spout 和 bolt,它们共同提供了高度可扩展的分布式 RPC 功能。

Storm 的 DRPC 功能的使用是完全可选的。只有当 Storm 应用程序利用此功能时,才需要 DRPC 服务器节点。

介绍 Storm UI

Storm UI 是一个可选的,但非常有用的服务,它提供了一个基于 Web 的 GUI,用于监视 Storm 集群并在一定程度上管理运行中的拓扑。Storm UI 为给定的 Storm 集群及其部署的拓扑提供统计信息,在监视和调整集群和拓扑性能时非常有用。

介绍 Storm UI

Storm UI 只报告从 nimbus thrift API 获取的信息,并不向 Storm 集群提供任何其他功能。Storm UI 服务可以随时启动和停止,而不会影响任何拓扑或集群功能,在这方面它是完全无状态的。它还可以配置为启动、停止、暂停和重新平衡拓扑,以便进行简单的管理。

介绍 Storm 技术栈

在我们开始安装 Storm 之前,让我们先看看 Storm 和拓扑构建的技术。

Java 和 Clojure

Storm 在 Java 虚拟机上运行,并且大致上由 Java 和 Clojure 的组合编写。Storm 的主要接口是用 Java 定义的,核心逻辑大部分是用 Clojure 实现的。除了 JVM 语言,Storm 还使用 Python 来实现 Storm 可执行文件。除了这些语言,Storm 还是一种高度多语言友好的技术,部分原因是它的一些接口使用了 Apache Thrift。

Storm 拓扑的组件(spouts 和 bolts)可以用安装它的操作系统支持的几乎任何编程语言编写。JVM 语言实现可以本地运行,其他实现可以通过 JNI 和 Storm 的多语言协议实现。

Python

所有 Storm 守护程序和管理命令都是从一个用 Python 编写的单个可执行文件运行的。这包括 nimbus 和 supervisor 守护程序,以及我们将看到的所有部署和管理拓扑的命令。因此,在参与 Storm 集群的所有机器上以及用于管理目的的任何工作站上都需要安装一个正确配置的 Python 解释器。

在 Linux 上安装 Storm

Storm 最初设计为在类 Unix 操作系统上运行,但从版本 0.9.1 开始,它也支持在 Windows 上部署。

为了我们的目的,我们将使用 Ubuntu 12.04 LTS,因为它相对容易使用。我们将使用服务器版本,默认情况下不包括图形用户界面,因为我们不需要也不会使用它。Ubuntu 12.04 LTS 服务器可以从releases.ubuntu.com/precise/ubuntu-12.04.2-server-i386.iso下载。

接下来的指令在实际硬件和虚拟机上同样有效。为了学习和开发的目的,如果你没有准备好的网络计算机,使用虚拟机会更加方便。

虚拟化软件可以在 OSX,Linux 和 Windows 上轻松获得。我们推荐以下任何一种软件选项:

  • VMWare(OSX,Linux 和 Windows)

这个软件需要购买。它可以在www.vmware.com上获得。

  • VirtualBox(OSX,Linux 和 Windows)

这个软件是免费提供的。它可以在www.virtualbox.org上获得。

  • Parallels Desktop(OSX)

这个软件需要购买。它可以在www.parallels.com上获得。

安装基本操作系统

你可以从 Ubuntu 安装光盘(或光盘镜像)启动,并按照屏幕上的指示进行基本安装。当Package Selection屏幕出现时,选择安装 OpenSSH Server 选项。这个软件包将允许你使用ssh远程登录服务器。在其他情况下,除非你选择对硬件进行特定修改,否则可以接受默认选项。

在 Ubuntu 下,默认情况下,主要用户将具有管理(sudo)权限。如果你使用不同的用户账户或 Linux 发行版,请确保你的账户具有管理权限。

安装 Java

首先,安装 JVM。已知 Storm 可以与来自开源 OpenJDK 和 Oracle 的 Java 1.6 和 1.7 JVM 一起工作。在这个例子中,我们将更新 apt 存储库信息并安装 Java 1.6 的 OpenJDK 发行版:

sudo apt-get update
sudo apt-get --yes install openjdk-6-jdk

ZooKeeper 安装

对于我们的单节点伪集群,我们将在所有其他 Storm 组件旁边安装 ZooKeeper。Storm 目前需要版本 3.3.x,因此我们将安装该版本而不是最新版本,使用以下命令:

sudo apt-get --yes install zookeeper=3.3.5* zookeeperd=3.3.5*

这个命令将安装 ZooKeeper 二进制文件以及启动和停止 ZooKeeper 的服务脚本。它还将创建一个定期清除旧的 ZooKeeper 事务日志和快照文件的 cron 作业,如果不定期清除,这些文件将迅速占用大量磁盘空间,因为这是 ZooKeeper 的默认行为。

风暴安装

Storm 的二进制发行版可以从 Storm 网站(storm.incubator.apache.org)下载。二进制存档的布局更适合开发活动,而不是运行生产系统,因此我们将对其进行一些修改,以更紧密地遵循 UNIX 约定(例如将日志记录到/var/log而不是 Storm 的主目录)。

我们首先创建一个 Storm 用户和组。这将允许我们以特定用户而不是默认或根用户运行 Storm 守护进程:

sudo groupadd storm
sudo useradd --gid storm --home-dir /home/storm --create-home --shell /bin/bash storm

接下来,下载并解压 Storm 分发版。我们将在/usr/share中安装 Storm,并将特定版本的目录链接到/usr/share/storm。这种方法可以让我们轻松安装其他版本,并通过更改单个符号链接来激活(或恢复)新版本。我们还将 Storm 可执行文件链接到/usr/bin/storm

sudo wget [storm download URL]
sudo unzip -o apache-storm-0.9.1-incubating.zip -d /usr/share/
sudo ln -s /usr/share/apache-storm-0.9.1-incubating /usr/share/storm
sudo ln -s /usr/share/storm/bin/storm /usr/bin/storm

默认情况下,Storm 将日志信息记录到$STORM_HOME/logs而不是大多数 UNIX 服务使用的/var/log目录。要更改这一点,执行以下命令在/var/log/下创建storm目录,并配置 Storm 将其日志数据写入那里:

sudo mkdir /var/log/storm
sudo chown storm:storm /var/log/storm

sudo sed -i 's/${storm.home}\/logs/\/var\/log\/storm/g' /usr/share/storm/log4j/storm.log.properties

最后,我们将 Storm 的配置文件移动到/etc/storm并创建一个符号链接,以便 Storm 可以找到它:

sudo mkdir /etc/storm
sudo chown storm:storm /etc/storm
sudo mv /usr/share/storm/conf/storm.yaml /etc/storm/
sudo ln -s /etc/storm/storm.yaml /usr/share/storm/conf/storm.yaml

安装了 Storm 后,我们现在可以配置 Storm 并设置 Storm 守护进程,使它们可以自动启动。

运行 Storm 守护进程

所有 Storm 守护进程都是设计为失败快速的,这意味着每当发生意外错误时,进程将停止。这允许各个组件安全失败并成功恢复,而不影响系统的其他部分。

这意味着 Storm 守护进程需要在它们意外死机时立即重新启动。这种技术称为在监督下运行进程,幸运的是有许多可用的实用程序来执行这个功能。事实上,ZooKeeper 也是一个失败快速的系统,而 ZooKeeper Debian 发行版(Ubuntu 是基于 Debian 的发行版)中包含的基于 upstart 的init脚本提供了这个功能——如果 ZooKeeper 进程在任何时候异常退出,upstart 将确保它重新启动,以便集群可以恢复。

虽然 Debian 的 upstart 系统非常适合这种情况,但其他 Linux 发行版上也有更简单的选择。为了简化事情,我们将使用大多数发行版上都可以找到的 supervisor 软件包。不幸的是,supervisor 名称与 Storm 的 supervisor 守护进程的名称冲突。为了澄清这一区别,我们将在文本中将非 Storm 进程监督守护进程称为supervisord(注意末尾添加的d),即使示例代码和命令将使用正确的名称而不添加d

在基于 Debian 的 Linux 发行版中,supervisord软件包被命名为 supervisor,而其他发行版如 Red Hat 使用 supervisord 这个名字。要在 Ubuntu 上安装它,请使用以下命令:

sudo apt-get --yes install supervisor

这将安装并启动 supervisord 服务。主配置文件将位于/etc/supervisor/supervisord.conf。Supervisord 的配置文件将自动包括/etc/supervisord/conf.d/目录中与模式*.conf匹配的任何文件,并且这就是我们将放置config文件以便在 supervision 下运行 Storm 守护进程的地方。

对于我们想要在监督下运行的每个 Storm 守护进程命令,我们将创建一个包含以下内容的配置文件:

  • 用于监督服务的唯一(在 supervisord 配置中)名称。

  • 运行的命令。

  • 运行命令的工作目录。

  • 命令/服务是否应在退出时自动重新启动。对于失败快速的服务,这应该始终为 true。

  • 将拥有该进程的用户。在这种情况下,我们将使用 Storm 用户运行所有 Storm 守护进程作为进程所有者。

创建以下三个文件以设置 Storm 守护进程自动启动(并在意外故障时重新启动):

  • /etc/supervisord/conf.d/storm-nimbus.conf

使用以下代码创建文件:

[program:storm-nimbus]
command=storm nimbus
directory=/home/storm
autorestart=true
user=storm
  • /etc/supervisord/conf.d/storm-supervisor.conf

使用以下代码创建文件:

[program:storm-supervisor]
command=storm supervisor
directory=/home/storm
autorestart=true
user=storm
  • /etc/supervisord/conf.d/storm-ui.conf

使用以下代码创建文件:

[program:storm-ui]
command=storm ui
directory=/home/storm
autorestart=true
user=storm

创建了这些文件后,使用以下命令停止并启动 supervisord 服务:

sudo /etc/init.d/supervisor stop
sudo /etc/init.d/supervisor start

supervisord 服务将加载新的配置并启动 Storm 守护进程。等待一两分钟,然后通过在 Web 浏览器中访问以下 URL(用实际机器的主机名或 IP 地址替换localhost)来验证 Storm 伪集群是否已启动并运行:

http://localhost:8080

这将启动 Storm UI 图形界面。它应指示集群已经启动,有一个监督节点正在运行,有四个可用的工作槽,并且没有拓扑正在运行(我们稍后将向集群部署拓扑)。

如果由于某种原因 Storm UI 没有启动或未显示集群中的活动监督员,请检查以下日志文件以查找错误:

  • Storm UI:检查/var/log/storm下的ui.log文件以查找错误

  • Nimbus:检查/var/log/storm下的nimbus.log文件以查找错误

  • Supervisor:检查/var/log/storm下的supervisor.log文件以查找错误

到目前为止,我们一直依赖默认的 Storm 配置,该配置默认使用localhost作为许多集群主机名参数的值,例如 ZooKeeper 主机以及 nimbus 主节点的位置。这对于单节点伪集群是可以的,其中所有内容都在同一台机器上运行,但是设置真正的多节点集群需要覆盖默认值。接下来,我们将探讨 Storm 提供的各种配置选项以及它们对集群及其拓扑行为的影响。

配置 Storm

Storm 的配置由一系列 YAML 属性组成。当 Storm 守护进程启动时,它会加载默认值,然后加载storm.yaml(我们已经将其符号链接到/etc/storm/storm.yaml)文件在$STORM_HOME/conf/下,用默认值替换找到的任何值。

以下列表提供了一个最小的storm.yaml文件,其中包含您必须覆盖的条目:

# List of hosts in the zookeeper cluster
storm.zookeeper.servers:
 - "localhost"

# hostname of the nimbus node
nimbus.host: "localhost"

# supervisor worker ports
supervisor.slots.ports:
 - 6700
 - 6701
 - 6702
 - 6703

# where nimbus and supervisors should store state data
storm.local.dir: "/home/storm"

# List of hosts that are Storm DRPC servers (optional)
# drpc.servers:
#    - "localhost"

强制设置

以下设置是配置工作的多主机 Storm 集群的强制设置。

  • storm.zookeeper.servers:此设置是 ZooKeeper 集群中主机名的列表。由于我们在与其他 Storm 守护进程相同的机器上运行单节点 ZooKeeper,因此localhost的默认值是可以接受的。

  • nimbus.host:这是集群 nimbus 节点的主机名。工作节点需要知道哪个节点是主节点,以便下载拓扑 JAR 文件和配置。

  • supervisor.slots.ports: 此设置控制在 supervisor 节点上运行多少个工作进程。它被定义为工作进程将监听的端口号列表,列出的端口号数量将控制 supervisor 节点上可用的工作槽位数量。例如,如果我们有一个配置了三个端口的三个 supervisor 节点的集群,那么集群将有总共九个(3 * 3 = 9)工作槽位。默认情况下,Storm 将使用端口 6700-6703,每个 supervisor 节点有四个槽位。

  • storm.local.dir: nimbus 和 supervisor 守护程序都存储少量临时状态信息以及工作进程所需的 JAR 和配置文件。此设置确定 nimbus 和 supervisor 进程将存储该信息的位置。此处指定的目录必须存在,并具有适当的权限,以便进程所有者(在我们的情况下是 Storm 用户)可以读取和写入该目录。该目录的内容必须在集群运行期间持久存在,因此最好避免使用/tmp,因为其中的内容可能会被操作系统删除。

可选设置

除了对操作集群必需的设置之外,还有一些其他设置可能需要覆盖。Storm 配置设置遵循点分命名约定,其中前缀标识了设置的类别;这在下表中有所体现:

前缀 类别
storm.* 通用配置
nimbus.* Nimbus 配置
ui.* Storm UI 配置
drpc.* DRPC 服务器配置
supervisor.* Supervisor 配置
worker.* Worker 配置
zmq.* ZeroMQ 配置
topology.* 拓扑配置

要查看可用的默认配置设置的完整列表,请查看 Storm 源代码中的defaults.yaml文件(github.com/nathanmarz/storm/blob/master/conf/defaults.yaml)。以下是一些经常被覆盖的设置:

  • nimbus.childopts (默认值: "-Xmx1024m"): 这是在启动 nimbus 守护程序时将添加到 Java 命令行的 JVM 选项列表。

  • ui.port (默认值: 8080): 这指定了 Storm UI web 服务器的监听端口。

  • ui.childopts (默认值: "-Xmx1024m"): 这指定了在启动 Storm UI 服务时将添加到 Java 命令行的 JVM 选项。

  • supervisor.childopts (默认值: "-Xmx1024m"): 这指定了在启动 supervisor 守护程序时将添加到 Java 命令行的 JVM 选项。

  • worker.childopts (默认值: "-Xmx768m"): 这指定了在启动 worker 进程时将添加到 Java 命令行的 JVM 选项。

  • topology.message.timeout.secs (默认值: 30): 这配置了元组在被确认(完全处理)之前的最长时间(以秒为单位),在此时间内未确认的元组将被视为失败(超时)。将此值设置得太低可能会导致元组被重复重放。要使此设置生效,必须配置 spout 以发出锚定元组。

  • topology.max.spout.pending (默认值: null): 默认值为 null 时,Storm 将从 spout 尽可能快地流出元组。根据下游 bolt 的执行延迟,默认行为可能会使拓扑不堪重负,导致消息超时。将此值设置为大于 0 的非 null 数字将导致 Storm 暂停从 spout 流出元组,直到未完成的元组数量下降到该数字以下,从而限制了 spout 的流量。在调整拓扑性能时,此设置与topology.message.timeout.secs一起是最重要的两个参数之一。

  • topology.enable.message.timeouts(默认值:true):这设置了锚定元组的超时行为。如果为 false,则锚定元组不会超时。谨慎使用此设置。在将其设置为 false 之前,请考虑修改topology.message.timeout.secs。要使此设置生效,必须配置一个 spout 以发射锚定元组。

Storm 可执行文件

Storm 可执行文件是一个多用途命令,用于从启动 Storm 守护程序到执行拓扑管理功能,例如将新的拓扑部署到集群中,或者在开发和测试阶段以本地模式运行拓扑。

Storm 命令的基本语法如下:

storm [command] [arguments...]

在工作站上设置 Storm 可执行文件

对于运行连接到远程集群的 Storm 命令,您需要在本地安装 Storm 分发版。在工作站上安装分发版很简单;只需解压 Storm 分发版存档,并将 Storm bin 目录($STORM_HOME/bin)添加到您的PATH环境变量中。接下来,在~/.storm/下创建storm.yaml文件,其中包含一行告诉 Storm 在哪里找到要与之交互的集群的 nimbus 服务器:

Sample: ~/.storm/storm.yaml file.
nimbus.host: "nimbus01."

提示

为了使 Storm 集群正常运行,必须正确设置 IP 地址名称解析,可以通过 DNS 系统或/etc下的hosts文件进行设置。

虽然在 Storm 的配置中可以使用 IP 地址代替主机名,但最好使用 DNS 系统。

守护程序命令

Storm 的守护程序命令用于启动 Storm 服务,并且应该在监督下运行,以便在发生意外故障时重新启动。启动时,Storm 守护程序从$STORM_HOME/conf/storm.yaml读取配置。此文件中的任何配置参数都将覆盖 Storm 的内置默认值。

Nimbus

用法:storm nimbus

这将启动 nimbus 守护程序。

Supervisor

用法:storm supervisor

这将启动监督守护程序。

UI

用法:storm ui

这将启动提供用于监视 Storm 集群的基于 Web 的 UI 的 Storm UI 守护程序。

DRPC

用法:storm drpc

这将启动 DRPC 守护程序。

管理命令

Storm 的管理命令用于部署和管理在集群中运行的拓扑。管理命令通常从 Storm 集群外的工作站运行。它们与 nimbus Thrift API 通信,因此需要知道 nimbus 节点的主机名。管理命令从~/.storm/storm.yaml文件中查找配置,并将 Storm 的 jar 附加到类路径上。唯一必需的配置参数是 nimbus 节点的主机名:

nimbus.host: "nimbus01"

Jar

用法:storm jar topology_jar topology_class [arguments...]

jar命令用于将拓扑提交到集群。它运行topology_classmain()方法,并使用指定的参数上传topology_jar文件到 nimbus 以分发到集群。一旦提交,Storm 将激活拓扑并开始处理。

拓扑类中的main()方法负责调用StormSubmitter.submitTopology()方法,并为拓扑提供一个在集群中唯一的名称。如果集群中已经存在具有该名称的拓扑,则jar命令将失败。通常的做法是在命令行参数中指定拓扑名称,以便在提交时为拓扑命名。

Kill

用法:storm kill topology_name [-w wait_time]

kill命令用于取消部署。它会杀死名为topology_name的拓扑。Storm 将首先停用拓扑的喷口,持续时间为拓扑配置的topology.message.timeout.secs,以允许所有正在处理的元组完成。然后,Storm 将停止工作进程,并尝试清理任何保存的状态。使用-w开关指定等待时间将覆盖topology.message.timeout.secs为指定的间隔。

kill命令的功能也可以在 Storm UI 中使用。

停用

用法:storm deactivate topology_name

deactivate命令告诉 Storm 停止从指定拓扑的喷口流元组。

也可以从 Storm UI 停用拓扑。

激活

用法:storm activate topology_name

activate命令告诉 Storm 从指定拓扑的喷口恢复流元组。

也可以从 Storm UI 重新激活拓扑。

重新平衡

用法:storm rebalance topology_name [-w wait_time] [-n worker_count] [-e component_name=executer_count]...

rebalance命令指示 Storm 在集群中重新分配任务,而无需杀死和重新提交拓扑。例如,当向集群添加新的监督节点时,可能需要这样做——因为它是一个新节点,现有拓扑的任何任务都不会分配给该节点上的工作进程。

rebalance命令还允许您使用-n-e开关更改分配给拓扑的工作进程数量,并分别更改分配给给定任务的执行器数量。

运行rebalance命令时,Storm 将首先停用拓扑,等待配置的时间以完成未完成的元组处理,然后在监督节点之间均匀重新分配工作进程。重新平衡后,Storm 将拓扑返回到其先前的激活状态(也就是说,如果它被激活了,Storm 将重新激活它,反之亦然)。

以下示例将使用等待时间为 15 秒重新平衡名为wordcount-topology的拓扑,为该拓扑分配五个工作进程,并分别设置sentence-spoutsplit-bolt使用 4 和 8 个执行线程:

storm rebalance wordcount-topology -w 15 -n 5 -e sentence-spout=4 -e split-bolt=8

Remoteconfvalue

用法:storm remoteconfvalue conf-name

remoteconfvalue命令用于查找远程集群上的配置参数。请注意,这适用于全局集群配置,并不考虑在拓扑级别进行的个别覆盖。

本地调试/开发命令

Storm 的本地命令是用于调试和测试的实用程序。与管理命令一样,Storm 的调试命令读取~/.storm/storm.yaml并使用这些值来覆盖 Storm 的内置默认值。

REPL

用法:storm repl

repl命令打开一个配置了 Storm 本地类路径的 Clojure REPL 会话。

类路径

用法:storm classpath

classpath命令打印 Storm 客户端使用的类路径。

本地配置值

用法:storm localconfvalue conf-name

localconfvalue命令从合并配置中查找配置键,即从~/.storm/storm.yaml和 Storm 的内置默认值中查找。

向 Storm 集群提交拓扑

现在我们有了一个运行中的集群,让我们重新审视之前的单词计数示例,并修改它,以便我们可以将其部署到集群,并在本地模式下运行。之前的示例使用了 Storm 的LocalCluster类在本地模式下运行:

LocalCluster cluster = new LocalCluster();
            cluster.submitTopology(TOPOLOGY_NAME, config, builder.createTopology());

向远程集群提交拓扑只是使用 Storm 的StormSubmitter类的方法,该方法具有相同的名称和签名:

StormSubmitter.submitTopology(TOPOLOGY_NAME, config, builder.createTopology());

在开发 Storm 拓扑时,通常不希望更改代码并重新编译它们以在本地模式和部署到集群之间切换。处理这种情况的标准方法是添加一个 if/else 块,根据命令行参数来确定。在我们更新的示例中,如果没有命令行参数,我们在本地模式下运行拓扑;否则,我们使用第一个参数作为拓扑名称并将其提交到集群,如下面的代码所示:

public class WordCountTopology {

    private static final String SENTENCE_SPOUT_ID = "sentence-spout";
    private static final String SPLIT_BOLT_ID = "split-bolt";
    private static final String COUNT_BOLT_ID = "count-bolt";
    private static final String REPORT_BOLT_ID = "report-bolt";
    private static final String TOPOLOGY_NAME = "word-count-topology";

    public static void main(String[] args) throws Exception {

        SentenceSpout spout = new SentenceSpout();
        SplitSentenceBolt splitBolt = new SplitSentenceBolt();
        WordCountBolt countBolt = new WordCountBolt();
        ReportBolt reportBolt = new ReportBolt();

        TopologyBuilder builder = new TopologyBuilder();

        builder.setSpout(SENTENCE_SPOUT_ID, spout, 2);
        // SentenceSpout --> SplitSentenceBolt
        builder.setBolt(SPLIT_BOLT_ID, splitBolt, 2)
                .setNumTasks(4)
                .shuffleGrouping(SENTENCE_SPOUT_ID);
        // SplitSentenceBolt --> WordCountBolt
        builder.setBolt(COUNT_BOLT_ID, countBolt, 4)
                .fieldsGrouping(SPLIT_BOLT_ID, new Fields("word"));
        // WordCountBolt --> ReportBolt
        builder.setBolt(REPORT_BOLT_ID, reportBolt)
                .globalGrouping(COUNT_BOLT_ID);

        Config config = new Config();
        config.setNumWorkers(2);

        if(args.length == 0){
            LocalCluster cluster = new LocalCluster();
            cluster.submitTopology(TOPOLOGY_NAME, config, builder.createTopology());
            waitForSeconds(10);
            cluster.killTopology(TOPOLOGY_NAME);
            cluster.shutdown();
        } else{
            StormSubmitter.submitTopology(args[0], config, builder.createTopology());
        }
    }
}

要将更新的单词计数拓扑部署到运行的集群中,首先在第二章源代码目录中执行 Maven 构建:

mvn clean install

接下来,运行storm jar命令来部署拓扑:

storm jar ./target/Chapter1-1.0-SNAPSHOT.jar storm.blueprints.chapter1.WordCountTopology wordcount-topology

当命令完成时,您应该在 Storm UI 中看到拓扑变为活动状态,并能够点击拓扑名称进行详细查看和查看拓扑统计信息。

将拓扑提交到 Storm 集群

自动化集群配置

到目前为止,我们已经从命令行手动配置了单节点伪集群。虽然这种方法在小集群中当然有效,但随着集群规模的增加,它将很快变得不可行。考虑需要配置由数十、数百甚至数千个节点组成的集群的情况。配置任务可以使用 shell 脚本自动化,但即使是基于 shell 脚本的自动化解决方案在可扩展性方面也是值得怀疑的。

幸运的是,有许多技术可用于解决大量受管服务器的配置和配置问题。Chef 和 Puppet 都提供了一种声明性的配置方法,允许您定义状态(即安装了哪些软件包以及它们如何配置)以及机器的(例如,Apache web 服务器类机器需要安装 Apache httpd守护程序)。

自动化服务器的配置和配置过程是一个非常广泛的主题,远远超出了本书的范围。为了我们的目的,我们将使用 Puppet 并利用其功能的一个子集,希望它能够提供对该主题的基本介绍,并鼓励进一步探索。

Puppet 的快速介绍

Puppet (puppetlabs.com)是一个 IT 自动化框架,它帮助系统管理员使用灵活的声明性方法管理大型网络基础设施资源。

Puppet 的核心是描述基础设施资源期望状态的清单概念。在 Puppet 术语中,状态可以包括以下内容:

  • 安装了哪些软件包

  • 哪些服务正在运行,哪些没有

  • 软件配置细节

Puppet 清单

Puppet 使用声明性基于 Ruby 的 DSL 来描述文件集合中的系统配置,这些文件集合称为清单。ZooKeeper 的一个示例 Puppet 清单如下所示:

    package { 'zookeeper':
        ensure => "3.3.5*",
    }
    package { 'zookeeperd':
        ensure => "3.3.5*",
        require => Package["zookeeper"],
    }

    service { 'zookeeperd':
        ensure => 'running',
        require => Package["zookeeperd"],
    }

这个简单的清单可以用来确保 ZooKeeper 作为服务安装并且服务正在运行。第一个软件包块告诉 Puppet 使用操作系统的软件包管理器(例如,Ubuntu/Debian 的 apt-get,Red Hat 的 yum 等)来确保安装 zookeeper 软件包的 3.3.5 版本。第二个软件包块确保安装了 zookeeperd 软件包;它要求 zookeeper 软件包已经安装。最后,service块告诉 Puppet 应该确保 zookeeperd 系统服务正在运行,并且该服务需要 zookeeperd 软件包已安装。

为了说明 Puppet 清单如何转换为已安装的软件和系统状态,让我们安装 Puppet 并使用前面的示例来安装和启动 zookeeperd 服务。

要获取 Puppet 的最新版本,我们需要配置 apt-get 以使用 Puppet 实验室存储库。执行以下命令来这样做并安装最新版本的 puppet:

wget http://apt.puppetlabs.com/puppetlabs-release-precise.deb
sudo dpkg -i puppetlabs-release-precise.deb
sudo apt-get update

接下来,将前面的示例清单保存到名为init.pp的文件中,并使用 Puppet 应用该清单:

sudo puppet apply init.pp

命令完成后,检查 zookeeper 服务是否实际在运行:

service zookeeper status

如果我们手动停止 zookeeper 服务并重新运行puppet apply命令,Puppet 不会再次安装包(因为它们已经存在);然而,它会重新启动 zookeeper 服务,因为清单中定义的状态将服务定义为运行

Puppet 类和模块

虽然独立的 Puppet 清单使得定义单个资源的状态变得容易,但当您管理的资源数量增加时,这种方法很快就会变得难以控制。

幸运的是,Puppet 有类和模块的概念,可以更好地组织和隔离特定的配置细节。

考虑一种 Storm 的情况,我们有多个节点类。例如,Storm 集群中的一个节点可能是 nimbus 节点、supervisor 节点或两者兼有。Puppet 类和模块提供了一种区分多个配置角色的方法,您可以混合和匹配以轻松定义执行多个角色的网络资源。

为了说明这种能力,让我们重新审视一下我们用来安装 zookeeper 包的清单,并重新定义它为一个可以被重复使用并包含在多个类类型和清单中的类:

class zookeeper {

    include 'jdk'

    package { 'zookeeper':
        ensure => "3.3.5*",
    }
    package { 'zookeeperd':
        ensure => "3.3.5*",
        require => Package["zookeeper"],
    }

    service { 'zookeeperd':
        ensure => 'running',
        require => Package["zookeeperd"],
    }
}

在前面的示例中,我们重新定义了 zookeeper 清单为一个puppet类,可以在其他类和清单中使用。在第二行,zookeeper类包含另一个类jdk,它将包含一个资源的类定义,该资源将包含需要 Java JDK 的机器的状态。

Puppet 模板

Puppet 还利用了 Ruby ERB 模板系统,允许您为将在 Puppet 应用清单文件时填充的各种文件定义模板。Puppet ERB 模板中的占位符是将在 Puppet 运行时评估和替换的 Ruby 表达式和结构。ERB 模板中的 Ruby 代码可以完全访问清单文件中定义的 Puppet 变量。

考虑以下 Puppet 文件声明,用于生成storm.yaml配置文件:

    file { "storm-etc-config":
        path => "/etc/storm/storm.yaml",
        ensure => file,
        content => template("storm/storm.yaml.erb"),
        require => [File['storm-etc-config-dir'], File['storm-share-symlink']],
    }

此声明告诉 Puppet 从storm.yaml.erb模板创建文件storm.yaml,放在/etc/storm/下:

storm.zookeeper.servers:
<% @zookeeper_hosts.each do |host| -%>
     - <%= host %>
<% end -%>

nimbus.host: <%= @nimbus_host %>

storm.local.dir: <%= @storm_local_dir %>

<% if @supervisor_ports != 'none' %>
supervisor.slots.ports:
<% @supervisor_ports.each do |port| -%>
    - <%= port %>
<% end -%>
<% end %>

<% if @drpc_servers != 'none' %>
<% @drpc_servers.each do |drpc| -%>
    - <%= drpc %>
<% end -%>
<% end %>

模板中的条件逻辑和变量扩展允许我们定义一个可以用于许多环境的单个文件。例如,如果我们正在配置的环境没有任何 Storm DRPC 服务器,那么生成的storm.yaml文件的drpc.servers部分将被省略。

使用 Puppet Hiera 管理环境

我们简要介绍了 Puppet 清单、类和模板的概念。此时,您可能想知道如何在 puppet 类或清单中定义变量。在puppet类或清单中定义变量非常简单;只需在清单或类定义的开头定义如下:

$java_version = "1.6.0"

一旦定义,java_version变量将在整个类或清单定义以及任何 ERB 模板中可用;然而,这里存在一个可重用性的缺点。如果我们硬编码诸如版本号之类的信息,实际上就限制了我们的类的重用,使其固定在一个硬编码的值上。如果我们能够将所有可能频繁更改的变量外部化,使配置管理更易于维护,那将更好。这就是 Hiera 发挥作用的地方。

介绍 Hiera

Hiera 是一个键值查找工具,已集成到 Puppet 框架的最新版本中。Hiera 允许您定义键值层次结构(因此得名),使得父定义源中的键可以被子定义源覆盖。

例如,考虑这样一种情况,我们正在为将参与 Storm 集群的多台机器定义配置参数。所有机器将共享一组常见的键值,例如我们想要使用的 Java 版本。因此,我们将在一个名为“common.yaml”的文件中定义这些值。

从那里开始,事情开始分歧。我们可能有单节点伪集群的环境,也可能有多节点的环境。因此,我们希望将特定于环境的配置值存储在诸如“single-node.yaml”和“cluster.yaml”之类的单独文件中。

最后,我们希望将真实的特定于主机的信息存储在遵循命名约定“[hostname].yaml”的文件中。

介绍 Hiera

Puppet 的 Hiera 集成允许您这样做,并使用内置的 Puppet 变量来适当地解析文件名。

第二章源代码目录中的示例演示了如何实现这种组织形式。

一个典型的common.yaml文件可能定义了所有主机共有的全局属性,如下所示:

storm.version: apache-storm-0.9.1-incubating

# options are oracle-jdk, openjdk
jdk.vendor: openjdk
# options are 6, 7, 8
jdk.version: 7

在环境级别,我们可能希望区分独立集群配置,这种情况下,cluster.yaml文件可能如下所示:

# hosts entries for name resolution (template params for /etc/hosts)
hosts:
   nimbus01: 192.168.1.10
   zookeeper01: 192.168.1.11
   supervisor01: 192.168.1.12
   supervisor02: 192.168.1.13
   supervisor04: 192.168.1.14

storm.nimbus.host: nimbus01

storm.zookeeper.servers:
     - zookeeper01

storm.supervisor.slots.ports:
     - 6700
     - 6701
     - 6702
     - 6703
     - 6705

最后,我们可能希望在使用命名约定[hostname].yaml 的文件中定义特定于主机的参数,并定义应该应用于该节点的 Puppet 类。

对于nimbus01.yaml,请使用以下代码:

# this node only acts as a nimus node
classes:
    - nimbus

对于zookeeper01.yaml,请使用以下代码:

# this node is strictly a zookeeper node
classes:
    - zookeeper

我们只是触及了 Puppet 和 Hiera 可能性的表面。第二章源代码目录包含了有关如何使用 Puppet 自动化部署和配置任务的其他示例和文档。

总结

在这一章中,我们已经介绍了在单节点(伪分布式)配置以及完全分布式多节点配置中安装和配置 Storm 所需的步骤。我们还向您介绍了用于部署和管理运行拓扑的 Storm 守护程序和命令行实用程序。

最后,我们简要介绍了 Puppet 框架,并展示了如何使用它来管理多个环境配置。

我们鼓励您探索附带下载中包含的附加代码和文档。

在下一章中,我们将介绍 Trident,这是一个在 Storm 之上用于事务和状态管理的高级抽象层。

第三章:Trident 拓扑结构和传感器数据

在本章中,我们将探讨 Trident 拓扑结构。Trident 在 Storm 之上提供了一个更高级的抽象。Trident 抽象了事务处理和状态管理的细节。具体来说,Trident 将元组批处理成一组离散的事务。此外,Trident 提供了允许拓扑对数据执行操作的抽象,如函数、过滤器和聚合。

我们将使用传感器数据作为示例,以更好地理解 Trident。通常,传感器数据形成从许多不同位置读取的流。一些传统的例子包括天气或交通信息,但这种模式延伸到各种来源。例如,运行在手机上的应用程序会生成大量的事件信息。处理来自手机的事件流是传感器数据处理的另一个实例。

传感器数据包含许多设备发出的事件,通常形成一个永无止境的流。这是 Storm 的一个完美用例。

在本章中,我们将涵盖:

  • Trident 拓扑结构

  • Trident 喷泉

  • Trident 操作-过滤器和函数

  • Trident 聚合器-组合器和减少器

  • Trident 状态

审查我们的用例

为了更好地理解 Trident 拓扑结构以及使用传感器数据的 Storm,我们将实现一个 Trident 拓扑结构,用于收集医疗报告以识别疾病的爆发。

拓扑结构将处理包含以下信息的诊断事件:

纬度 经度 时间戳 诊断代码(ICD9-CM)
39.9522 -75.1642 2013 年 3 月 13 日下午 3:30 320.0(血友病性脑膜炎)
40.3588 -75.6269 2013 年 3 月 13 日下午 3:50 324.0(颅内脓肿)

每个事件将包括发生地点的全球定位系统(GPS)坐标。纬度和经度以十进制格式指定。事件还包含 ICD9-CM 代码,表示诊断和事件的时间戳。完整的 ICD-9-CM 代码列表可在以下网址找到:

www.icd9data.com/ .

为了检测疫情爆发,系统将计算在指定时间段内特定疾病代码在地理位置内的发生次数。为了简化这个例子,我们将每个诊断事件映射到最近的城市。在一个真实的系统中,你很可能会对事件进行更复杂的地理空间聚类。

同样,对于这个例子,我们将按小时自纪元以来对发生次数进行分组。在一个真实的系统中,你很可能会使用滑动窗口,并计算相对移动平均值的趋势。

最后,我们将使用一个简单的阈值来确定是否有疫情爆发。如果某个小时的发生次数大于某个阈值,系统将发送警报并派遣国民警卫队。

为了保持历史记录,我们还将持久化每个城市、小时和疾病的发生次数。

介绍 Trident 拓扑结构

为了满足这些要求,我们需要在我们的拓扑中计算发生的次数。在使用标准 Storm 拓扑时,这可能会有挑战,因为元组可能会被重放,导致重复计数。正如我们将在接下来的几节中看到的那样,Trident 提供了解决这个问题的基本方法。

我们将使用以下拓扑:

介绍 Trident 拓扑结构

前述拓扑的代码如下:

public class OutbreakDetectionTopology {

    public static StormTopology buildTopology() {
    TridentTopology topology = new TridentTopology();
    DiagnosisEventSpout spout = new DiagnosisEventSpout();
    Stream inputStream = topology.newStream("event", spout);
    inputStream
    // Filter for critical events.
.each(new Fields("event"), new DiseaseFilter()))

            // Locate the closest city
         .each(new Fields("event"),
               new CityAssignment(), new Fields("city"))

         // Derive the hour segment
         .each(new Fields("event", "city"),
               new HourAssignment(), new Fields("hour",
               "cityDiseaseHour"))

         // Group occurrences in same city and hour
         .groupBy(new Fields("cityDiseaseHour"))

         // Count occurrences and persist the results.
         .persistentAggregate(new OutbreakTrendFactory(),
                              new Count(),
                              new Fields("count"))

         .newValuesStream()

         // Detect an outbreak
         .each(new Fields("cityDiseaseHour", "count"),
               new OutbreakDetector(), new Fields("alert"))

         // Dispatch the alert
         .each(new Fields("alert"),
               new DispatchAlert(), new Fields());

}
}

前面的代码显示了不同 Trident 函数之间的连接。首先,DiagnosisEventSpout函数发出事件。然后,DiseaseFilter函数对事件进行过滤,过滤掉我们不关心的疾病发生。之后,事件与CityAssignment函数中的城市相关联。然后,HourAssignment函数为事件分配一个小时,并向元组添加一个键,该键包括城市、小时和疾病代码。然后,我们按照这个键进行分组,这使得在拓扑中的persistAggregate函数步骤中对这些计数进行计数和持久化。然后,这些计数传递给OutbreakDetector函数,该函数对计数进行阈值处理,当超过阈值时发出警报。最后,DispatchAlert函数接收警报,记录一条消息,并终止程序。在接下来的部分中,我们将更深入地研究每个步骤。

介绍 Trident spout

让我们首先看一下拓扑中的 spout。与 Storm 相比,Trident 引入了批次的概念。与 Storm 的 spout 不同,Trident 的 spout 必须以批次形式发出元组。

每个批次都有自己独特的事务标识符。spout 根据其合同的约束确定批次的组成。spout 有三种类型的合同:非事务性事务性不透明

非事务性 spout 对批次的组成不提供任何保证,并且可能重叠。两个不同的批次可能包含相同的元组。事务性 spout 保证批次不重叠,并且相同的批次始终包含相同的元组。不透明 spout 保证批次不重叠,但批次的内容可能会改变。

这在以下表中表示出来:

Spout 类型 批次可能重叠 批次内容可能改变
非事务性 X X
不透明 X
事务性

spout 的接口如下代码片段所示:

public interface ITridentSpout<T> extends Serializable {

   BatchCoordinator<T> getCoordinator(String txStateId,
                              Map conf, TopologyContext context);
   Emitter<T> getEmitter(String txStateId, Map conf,
                         TopologyContext context);

   Map getComponentConfiguration();

   Fields getOutputFields();
}

在 Trident 中,spout 实际上并不发出元组。相反,工作在BatchCoordinatorEmitter函数之间进行分解。Emitter函数负责发出元组,而BatchCoordinator函数负责批处理管理和元数据,以便Emitter函数可以正确重播批次。

TridentSpout函数只是提供了对BatchCoordinatorEmitter函数的访问器方法,并声明了 spout 将发出的字段。以下是我们示例中的DiagnosisEventSpout函数的列表:

public class DiagnosisEventSpout implements ITridentSpout<Long> {
 private static final long serialVersionUID = 1L;
 SpoutOutputCollector collector;
 BatchCoordinator<Long> coordinator = new DefaultCoordinator();
 Emitter<Long> emitter = new DiagnosisEventEmitter();

 @Override
 public BatchCoordinator<Long> getCoordinator(
         String txStateId, Map conf, TopologyContext context) {
     return coordinator;
 }

 @Override
 public Emitter<Long> getEmitter(String txStateId, Map conf,
                                TopologyContext context) {
     return emitter;
 }

 @Override
 public Map getComponentConfiguration() {
     return null;
 }

 @Override
 public Fields getOutputFields() {
     return new Fields("event");
 }
}

如前面代码中的getOutputFields()方法所示,在我们的示例拓扑中,spout 发出一个名为event的单个字段,其中包含DiagnosisEvent类。

BatchCoordinator类实现了以下接口:

public interface BatchCoordinator<X> {
   X initializeTransaction(long txid, X prevMetadata);
   void success(long txid);
   boolean isReady(long txid);
   void close();
}

BatchCoordinator类是一个通用类。通用类是重播批次所需的元数据。在我们的示例中,spout 发出随机事件,因此元数据被忽略。然而,在现实世界的系统中,元数据可能包含组成批次的消息或对象的标识符。有了这些信息,不透明和事务性的 spout 可以遵守它们的合同,并确保批次的内容不重叠,并且在事务性 spout 的情况下,批次内容不会改变。

BatchCoordinator类被实现为一个在单个线程中运行的 Storm Bolt。Storm 将元数据持久化在 Zookeeper 中。它在每个事务完成时通知协调器。

对于我们的示例,如果我们不进行协调,那么在DiagnosisEventSpout类中使用的协调如下:

public class DefaultCoordinator implements BatchCoordinator<Long>,
                                              Serializable {
   private static final long serialVersionUID = 1L;
private static final Logger LOG = 
             LoggerFactory.getLogger(DefaultCoordinator.class);

@Override
public boolean isReady(long txid) {
   return true;
}

@Override
public void close() {
}

@Override
public Long initializeTransaction(long txid,
                                  Long prevMetadata) {
   LOG.info("Initializing Transaction [" + txid + "]");
   return null;
   }

@Override
public void success(long txid) {
   LOG.info("Successful Transaction [" + txid + "]");
}
}

Trident spout 的第二个组件是Emitter函数。Emitter函数使用收集器发出元组,执行 Storm spout 的功能。唯一的区别是它使用TridentCollector类,并且元组必须包含在由BatchCoordinator类初始化的批次中。

Emitter函数的接口如下代码片段所示:

public interface Emitter<X> {
void emitBatch(TransactionAttempt tx, X coordinatorMeta,
               TridentCollector collector);
void close();
}

如前面的代码所示,Emitter函数只有一个任务-为给定的批次发出元组。为此,函数被传递了由协调器构建的批次的元数据,事务的信息以及收集器,Emitter函数使用它来发出元组。DiagnosisEventEmitter类的列表如下:

public class DiagnosisEventEmitter implements Emitter<Long>, Serializable {

private static final long serialVersionUID = 1L;
AtomicInteger successfulTransactions = new AtomicInteger(0);

@Override
public void emitBatch(TransactionAttempt tx, Long
                coordinatorMeta, TridentCollector collector) {
   for (int i = 0; i < 10000; i++) {
       List<Object> events = new ArrayList<Object>();
       double lat = 
             new Double(-30 + (int) (Math.random() * 75));
       double lng = 
             new Double(-120 + (int) (Math.random() * 70));
       long time = System.currentTimeMillis();
       String diag = new Integer(320 + 
                       (int) (Math.random() * 7)).toString();
       DiagnosisEvent event = 
                    new DiagnosisEvent(lat, lng, time, diag);
       events.add(event);
       collector.emit(events);
   }
}

@Override
public void success(TransactionAttempt tx) {
   successfulTransactions.incrementAndGet();
}

@Override
public void close() {
}
}

工作是在emitBatch()方法中执行的。在这个示例中,我们将随机分配一个纬度和经度,大致保持在美国境内,并且我们将使用System.currentTimeMillis()方法来为诊断的时间戳。

在现实生活中,ICD-9-CM 代码在 000 到 999 之间稀疏地填充了一个范围。在这个示例中,我们将只使用 320 到 327 之间的诊断代码。这些代码如下所示:

代码 描述
320 细菌性脑膜炎
321 由其他生物引起的脑膜炎
322 未指明原因的脑膜炎
323 脑炎、脊髓炎和脑脊髓炎
324 颅内和脊髓脓肿
325 静脉窦血栓性静脉炎和静脉炎
326 颅内脓肿或化脓感染的后遗症
327 有机性睡眠障碍

其中一个诊断代码被随机分配给了事件。

在这个示例中,我们将使用一个对象来封装诊断事件。同样地,我们可以将每个组件作为元组中的单独字段发出。对象封装和元组字段的使用之间存在一种平衡。通常,将字段数量保持在可管理的范围内是一个好主意,但也有道理将用于控制流和/或分组的数据作为元组中的字段包含进来。

在我们的示例中,DiagnosisEvent类是拓扑操作的关键数据。该对象如下代码片段所示:

public class DiagnosisEvent implements Serializable {
    private static final long serialVersionUID = 1L;
    public double lat;
    public double lng;
    public long time;
    public String diagnosisCode;

    public DiagnosisEvent(double lat, double lng,
                       long time, String diagnosisCode) {
   super();
   this.time = time;
   this.lat = lat;
   this.lng = lng;
   this.diagnosisCode = diagnosisCode;
    }
}

该对象是一个简单的 JavaBean。时间以长变量的形式存储,这是自纪元以来的时间。纬度和经度分别以双精度存储。diagnosisCode类以字符串形式存储,以防系统需要能够处理不基于 ICD-9 的其他类型的代码,比如字母数字代码。

此时,拓扑能够发出事件。在实际实现中,我们可能会将拓扑集成到医疗索赔处理引擎或电子健康记录系统中。

引入 Trident 操作-过滤器和函数

现在我们已经生成了事件,下一步是添加实现业务流程的逻辑组件。在 Trident 中,这些被称为操作。在我们的拓扑中,我们使用了两种不同类型的操作:过滤器和函数。

通过Stream对象上的方法将操作应用于流。在这个示例中,我们在Stream对象上使用以下方法:

public class Stream implements IAggregatableStream {
public Stream each(Fields inputFields, Filter filter) {
...
}

public IAggregatableStream each(Fields inputFields,
Function function,
Fields functionFields){
   ...
}

public GroupedStream groupBy(Fields fields) {
   ...
   }

public TridentState persistentAggregate(
StateFactory stateFactory,
CombinerAggregator agg, 
Fields functionFields) {
        ...
}
}

请注意,前面代码中的方法返回Stream对象或TridentState的形式,可以用来创建额外的流。通过这种方式,操作可以使用流畅的 Java 链接在一起。

让我们再来看一下我们示例拓扑中的关键线路:

   inputStream.each(new Fields("event"), new DiseaseFilter())
      .each(new Fields("event"), new CityAssignment(),
               new Fields("city"))

      .each(new Fields("event", "city"),
               new HourAssignment(),
             new Fields("hour", "cityDiseaseHour"))

      .groupBy(new Fields("cityDiseaseHour"))

      .persistentAggregate(new OutbreakTrendFactory(),
              new Count(), new Fields("count")).newValuesStream()

      .each(new Fields("cityDiseaseHour", "count"),
               new OutbreakDetector(), new Fields("alert"))

      .each(new Fields("alert"), new DispatchAlert(),
               new Fields());

通常,通过声明一组输入字段和一组输出字段,也称为函数字段,来应用操作。在前面代码的拓扑的第二行声明,我们希望CityAssignment在流中的每个元组上执行。从该元组中,CityAssignment将操作event字段并发出一个标记为city的函数字段,该字段将附加到元组中。

每个操作都有略有不同的流畅式语法,这取决于操作需要的信息。在接下来的部分中,我们将介绍不同操作的语法和语义的细节。

引入 Trident 过滤器

我们拓扑中的第一条逻辑是一个过滤器,它会忽略那些不相关的疾病事件。在这个例子中,系统将专注于脑膜炎。从之前的表中,脑膜炎的唯一代码是 320、321 和 322。

为了根据代码过滤事件,我们将利用 Trident 过滤器。Trident 通过提供BaseFilter类来使这变得容易,我们可以对不关心的元组进行子类化以过滤元组。BaseFilter类实现了Filter接口,如下代码片段所示:

public interface Filter extends EachOperation {
    boolean isKeep(TridentTuple tuple);
}

要过滤流中的元组,应用程序只需通过扩展BaseFilter类来实现这个接口。在这个例子中,我们将使用以下过滤器来过滤事件:

public class DiseaseFilter extends BaseFilter {
private static final long serialVersionUID = 1L;
private static final Logger LOG = 
LoggerFactory.getLogger(DiseaseFilter.class);

@Override
public boolean isKeep(TridentTuple tuple) {
   DiagnosisEvent diagnosis = (DiagnosisEvent) tuple.getValue(0);
   Integer code = Integer.parseInt(diagnosis.diagnosisCode);
   if (code.intValue() <= 322) {
       LOG.debug("Emitting disease [" + 
diagnosis.diagnosisCode + "]");
       return true;
   } else {
       LOG.debug("Filtering disease [" + 
diagnosis.diagnosisCode + "]");
       return false;
   }
}
}

在前面的代码中,我们将从元组中提取DiagnosisEvent类并检查疾病代码。由于所有的脑膜炎代码都小于或等于 322,并且我们不发出任何其他代码,我们只需检查代码是否小于 322 来确定事件是否与脑膜炎有关。

Filter操作中返回True将导致元组流向下游操作。如果方法返回False,元组将不会流向下游操作。

在我们的拓扑中,我们使用each(inputFields, filter)方法将过滤器应用于流中的每个元组。我们的拓扑中的以下一行将过滤器应用于流:

   inputStream.each(new Fields("event"), new DiseaseFilter())

引入 Trident 函数

除了过滤器,Storm 还提供了一个通用函数的接口。函数类似于 Storm 的 bolt,它们消耗元组并可选择发出新的元组。一个区别是 Trident 函数是增量的。函数发出的值是添加到元组中的字段。它们不会删除或改变现有字段。

函数的接口如下代码片段所示:

public interface Function extends EachOperation {
void execute(TridentTuple tuple, TridentCollector collector);
}

与 Storm 的 bolt 类似,函数实现了一个包含该函数逻辑的单个方法。函数实现可以选择使用TridentCollector来发出传入函数的元组。这样,函数也可以用来过滤元组。

我们拓扑中的第一个函数是CityAssignment函数,代码如下:

public class CityAssignment extends BaseFunction {
private static final long serialVersionUID = 1L;
private static final Logger LOG = LoggerFactory.getLogger(CityAssignment.class);

private static Map<String, double[]> CITIES = 
                        new HashMap<String, double[]>();

    { // Initialize the cities we care about.
        double[] phl = { 39.875365, -75.249524 };
        CITIES.put("PHL", phl);
        double[] nyc = { 40.71448, -74.00598 };
        CITIES.put("NYC", nyc);
        double[] sf = { -31.4250142, -62.0841809   };
        CITIES.put("SF", sf);
        double[] la = { -34.05374, -118.24307 };
        CITIES.put("LA", la);
    }

    @Override
    public void execute(TridentTuple tuple, 
TridentCollector collector) {
       DiagnosisEvent diagnosis = 
                           (DiagnosisEvent) tuple.getValue(0);
       double leastDistance = Double.MAX_VALUE;
       String closestCity = "NONE";

       // Find the closest city.
       for (Entry<String, double[]> city : CITIES.entrySet()) {
          double R = 6371; // km
          double x = (city.getValue()[0] - diagnosis.lng) * 
             Math.cos((city.getValue()[0] + diagnosis.lng) / 2);
          double y = (city.getValue()[1] - diagnosis.lat);
          double d = Math.sqrt(x * x + y * y) * R;
          if (d < leastDistance) {
          leastDistance = d;
          closestCity = city.getKey();
          }
      }

      // Emit the value.
      List<Object> values = new ArrayList<Object>();
      Values.add(closestCity);
      LOG.debug("Closest city to lat=[" + diagnosis.lat + 
                "], lng=[" + diagnosis.lng + "] == ["
                + closestCity + "], d=[" + leastDistance + "]");
      collector.emit(values);
    }
}

在这个函数中,我们使用静态初始化器来创建我们关心的城市的地图。对于示例数据,该函数有一个包含费城(PHL)、纽约市(NYC)、旧金山(SF)和洛杉矶(LA)坐标的地图。

execute()方法中,函数循环遍历城市并计算事件与城市之间的距离。在真实系统中,地理空间索引可能更有效。

一旦函数确定了最近的城市,它会在方法的最后几行发出该城市的代码。请记住,在 Trident 中,函数不是声明它将发出哪些字段,而是在操作附加到流时作为函数调用中的第三个参数声明字段。

声明的函数字段数量必须与函数发出的值的数量对齐。如果它们不对齐,Storm 将抛出IndexOutOfBoundsException

我们拓扑中的下一个函数HourAssignment用于将时间戳转换为自纪元以来的小时,然后可以用于在时间上对事件进行分组。HourAssignment的代码如下:

public class HourAssignment extends BaseFunction {
private static final long serialVersionUID = 1L;
private static final Logger LOG =    
               LoggerFactory.getLogger(HourAssignment.class);

@Override
public void execute(TridentTuple tuple,
                   TridentCollector collector) {
   DiagnosisEvent diagnosis = (DiagnosisEvent) tuple.getValue(0);
   String city = (String) tuple.getValue(1);

   long timestamp = diagnosis.time;
   long hourSinceEpoch = timestamp / 1000 / 60 / 60;

   LOG.debug("Key =  [" + city + ":" + hourSinceEpoch + "]");
   String key = city + ":" + diagnosis.diagnosisCode + ":" + 

                hourSinceEpoch;

   List<Object> values = new ArrayList<Object>();
   values.add(hourSinceEpoch);
   values.add(key);
   collector.emit(values);
}
}

我们通过发出小时以及由城市、诊断代码和小时组成的复合键来略微重载此函数。实际上,这充当了每个聚合计数的唯一标识符,我们将在详细讨论。

我们拓扑中的最后两个函数检测爆发并通知我们。OutbreakDetector类的代码如下:

public class OutbreakDetector extends BaseFunction {
    private static final long serialVersionUID = 1L;
    public static final int THRESHOLD = 10000;

    @Override
    public void execute(TridentTuple tuple,
                         TridentCollector collector) {
   String key = (String) tuple.getValue(0);
   Long count = (Long) tuple.getValue(1);

   if (count > THRESHOLD) {
       List<Object> values = new ArrayList<Object>();
       values.add("Outbreak detected for [" + key + "]!");
       collector.emit(values);
   }
}
}

此函数提取特定城市、疾病和小时的计数,并查看是否超过了阈值。如果是,它会发出一个包含警报的新字段。在上述代码中,请注意,这个函数实际上充当了一个过滤器,但由于我们想要向包含警报的元组添加一个额外的字段,因此实现为函数。由于过滤器不会改变元组,我们必须使用一个允许我们不仅过滤而且添加新字段的函数。

我们拓扑中的最后一个函数只是分发警报(并终止程序)。此拓扑的清单如下:

public class DispatchAlert extends BaseFunction {
    private static final long serialVersionUID = 1L;

    @Override
    public void execute(TridentTuple tuple, 
                     TridentCollector collector) {
   String alert = (String) tuple.getValue(0);
   Log.error("ALERT RECEIVED [" + alert + "]");
   Log.error("Dispatch the national guard!");
   System.exit(0);
   }
}

这个函数很简单。它只是提取警报,记录消息,并终止程序。

介绍 Trident 聚合器-组合器和减少器

与函数类似,聚合器允许拓扑结构组合元组。与函数不同,它们替换元组字段和值。有三种不同类型的聚合器:CombinerAggregatorReducerAggregatorAggregator

CombinerAggregator

CombinerAggregator用于将一组元组组合成一个单一字段。它具有以下签名:

public interface CombinerAggregator {
   T init (TridentTuple tuple);
   T combine(T val1, T val2);
   T zero();
}

Storm 对每个元组调用init()方法,然后重复调用combine()方法,直到分区被处理。传递到combine()方法的值是部分聚合,是通过调用init()返回的值的组合结果。分区将在后续会话中更详细地讨论,但分区实际上是流元组的子集,驻留在同一主机上。在处理元组的值后,Storm 将组合这些值的结果作为单个新字段发出。如果分区为空,则 Storm 会发出zero()方法返回的值。

ReducerAggregator

ReducerAggregator具有稍微不同的签名:

public interface ReducerAggregator<T> extends Serializable {
    T init();
    T reduce(T curr, TridentTuple tuple);
}

Storm 调用init()方法来检索初始值。然后,对每个元组调用reduce(),直到分区完全处理。传递到reduce()方法的第一个参数是累积的部分聚合。实现应返回将元组合并到该部分聚合中的结果。

Aggregator

最一般的聚合操作是AggregatorAggregator的签名如下:

public interface Aggregator<T> extends Operation {
    T init(Object batchId, TridentCollector collector);
    void aggregate(T val, TridentTuple tuple,
TridentCollector collector);
 void complete(T val, TridentCollector collector);
}

Aggregator接口的aggregate()方法类似于Function接口的execute()方法,但它还包括一个值的参数。这允许Aggregator在处理元组时累积一个值。请注意,使用Aggregator,由于收集器被传递到aggregate()方法和complete()方法中,您可以发出任意数量的元组。

在我们的示例拓扑中,我们利用了一个名为Count的内置聚合器。Count的实现如下代码片段所示:

public class Count implements CombinerAggregator<Long> {
    @Override
    public Long init(TridentTuple tuple) {
        return 1L;
    }

    @Override
    public Long combine(Long val1, Long val2) {
        return val1 + val2;
    }

    @Override
    public Long zero() {
        return 0L;
    }
}

在我们的示例拓扑中,我们应用了分组和计数来计算特定城市附近特定小时内疾病发生的次数。实现这一目标的具体行为如下:

.groupBy(new Fields("cityDiseaseHour"))
.persistentAggregate(new OutbreakTrendFactory(), 
   new Count(), new Fields("count")).newValuesStream()

回想一下,Storm 将流分区到可用的主机上。这在下图中显示:

Aggregator

groupBy()方法强制对数据进行重新分区。它将所有具有相同命名字段值的元组分组到同一分区中。为此,Storm 必须将相似的元组发送到同一主机。以下图表显示了根据我们的groupBy()方法对前述数据进行的重新分区:

聚合器

重新分区后,在每个分区内的每个组上运行aggregate函数。在我们的示例中,我们按城市、小时和疾病代码(使用键)进行分组。然后,在每个组上执行Count聚合器,进而为下游消费者发出发生次数。

引入 Trident 状态

现在我们已经得到了每个聚合的计数,我们希望将该信息持久化以供进一步分析。在 Trident 中,持久化首先从状态管理开始。Trident 具有一级状态的原始形式,但与 Storm API 一样,它对存储为状态或状态如何持久化做出了一些假设。在最高级别,Trident 公开了一个State接口,如下所示:

public interface State {
   void beginCommit(Long transactionId); 
   void commit(Long transactionId);
}

如前所述,Trident 将元组分组为批处理。每个批处理都有自己的事务标识符。在前面的接口中,Trident 在状态被提交时通知State对象,以及何时应完成提交。

与函数一样,在Stream对象上有一些方法将基于状态的操作引入拓扑。更具体地说,Trident 中有两种类型的流:StreamGroupedStreamGroupedStream是执行groupBy操作的结果。在我们的拓扑中,我们通过HourAssignment函数生成的键进行分组。

Stream对象上,以下方法允许拓扑读取和写入状态信息:

public class Stream implements IAggregatableStream {
    ...
    public Stream stateQuery(TridentState state, Fields inputFields,
            QueryFunction function, Fields functionFields) {
   ...
 }

public TridentState partitionPersist(StateFactory stateFactory,
Fields inputFields, StateUpdater updater,
Fields functionFields) {
   ...
}

public TridentState partitionPersist(StateSpec stateSpec,
Fields inputFields, StateUpdater updater,
Fields functionFields) {
   ...
}

public TridentState partitionPersist(StateFactory stateFactory,
Fields inputFields, StateUpdater updater) {
   ...
   }

public TridentState partitionPersist(StateSpec stateSpec,
Fields inputFields, StateUpdater updater) {
    ...
}
...
}

stateQuery()方法从状态创建输入流,partitionPersist()方法的各种变种允许拓扑从流中的元组更新状态信息。partitionPersist()方法在每个分区上操作。

除了Stream对象上的方法之外,GroupedStream对象允许拓扑从一组元组中聚合统计信息,并同时将收集到的信息持久化到状态。以下是GroupedStream类上与状态相关的方法:

public class GroupedStream implements IAggregatableStream,
GlobalAggregationScheme<GroupedStream> {
...
   public TridentState persistentAggregate(
StateFactory stateFactory, CombinerAggregator agg,
Fields functionFields) {
...
}

public TridentState persistentAggregate(StateSpec spec,
CombinerAggregator agg, Fields functionFields) {
...
}

public TridentState persistentAggregate(
StateFactory stateFactory, Fields inputFields,
CombinerAggregator agg, Fields functionFields) {
...
}

public TridentState persistentAggregate(StateSpec spec,
Fields inputFields, CombinerAggregator agg,
Fields functionFields) {
...
}

public TridentState persistentAggregate(
StateFactory stateFactory, Fields inputFields,
ReducerAggregator agg, Fields functionFields) {
...
}

public TridentState persistentAggregate(StateSpec spec, Fields inputFields, ReducerAggregator agg, Fields functionFields) {
...
}

public Stream stateQuery(TridentState state, Fields inputFields,
QueryFunction function, Fields functionFields) {
...
}    

public TridentState persistentAggregate(
StateFactory stateFactory, ReducerAggregator agg,
Fields functionFields) {
...
}

public TridentState persistentAggregate(StateSpec spec,
ReducerAggregator agg, Fields functionFields) {
...
}    

public Stream stateQuery(TridentState state,
   QueryFunction function, Fields functionFields) {
...
}
}

像基本的Stream对象一样,stateQuery()方法从状态创建输入流。各种persistAggregate()的变种允许拓扑从流中的元组更新状态信息。请注意,GroupedStream方法采用Aggregator,它首先应用然后将信息写入State对象。

现在让我们考虑将这些函数应用到我们的示例中。在我们的系统中,我们希望按城市、疾病代码和小时持久化发生次数。这将使报告类似于以下表格:

疾病 城市 日期 时间 发生次数
细菌性脑膜炎 旧金山 2013 年 3 月 12 日 下午 3:00 12
细菌性脑膜炎 旧金山 2013 年 3 月 12 日 下午 4:00 50
细菌性脑膜炎 旧金山 2013 年 3 月 12 日 下午 5:00 100
天花 纽约 2013 年 3 月 13 日 下午 5:00 6

为了实现这一点,我们希望持久化我们在聚合中生成的计数。我们可以使用groupBy函数返回的GroupedStream接口(如前所示),并调用persistAggregate方法。具体来说,以下是我们在示例拓扑中进行的调用:

 persistentAggregate(new OutbreakTrendFactory(), 
   new Count(), new Fields("count")).newValuesStream()

要理解持久化,我们首先将关注此方法的第一个参数。Trident 使用工厂模式生成State的实例。OutbreakTrendFactory是我们的拓扑提供给 Storm 的工厂。OutbreakTrendFactory的清单如下:

public class OutbreakTrendFactory implements StateFactory {
private static final long serialVersionUID = 1L;

@Override
public State makeState(Map conf, IMetricsContext metrics,
int partitionIndex, int numPartitions) {
   return new OutbreakTrendState(new OutbreakTrendBackingMap());
}
}

工厂返回 Storm 用于持久化信息的State对象。在 Storm 中,有三种类型的状态。每种类型在下表中描述:

状态类型 描述
非事务性 对于没有回滚能力的持久性机制,更新是永久的,提交被忽略。
重复事务 对于幂等性的持久性,只要批次包含相同的元组。
不透明事务 更新基于先前的值,这使得持久性对批次组成的更改具有弹性。

为了支持在分布式环境中对批次进行重播的计数和状态更新,Trident 对状态更新进行排序,并使用不同的状态更新模式来容忍重播和故障。这些在以下部分中描述。

重复事务状态

对于重复事务状态,最后提交的批处理标识符与数据一起存储。只有在应用的批处理标识符是下一个顺序时,状态才会更新。如果它等于或低于持久标识符,则更新将被忽略,因为它已经被应用过了。

为了说明这种方法,考虑以下批次序列,其中状态更新是该键出现次数的聚合计数,如我们的示例中所示:

批次 # 状态更新
1
2
3

然后批次按以下顺序完成处理:

1 à 2 à 3 à 3 (重播)

这将导致以下状态修改,其中中间列是批次标识符的持久性,指示状态中最近合并的批次:

完成的批次 # 状态
1
2
3
3 (重播)

请注意,当批次 #3 完成重播时,它对状态没有影响,因为 Trident 已经在状态中合并了它的更新。为了使重复事务状态正常工作,批次内容在重播之间不能改变。

不透明状态

重复事务状态所使用的方法依赖于批次组成保持不变,如果系统遇到故障,则可能不可能。如果喷口从可能存在部分故障的源发出,那么初始批次中发出的一些元组可能无法重新发出。不透明状态允许通过存储当前状态和先前状态来改变批次组成。

假设我们有与前面示例中相同的批次,但是这次当批次 3 重播时,聚合计数将不同,因为它包含了不同的元组集,如下表所示:

批次 # 状态更新
1
2
3
3 (重播)

对于不透明状态,状态将如下更新:

完成的批次 # 批次已提交 先前状态 当前状态
1 1 {}
2 2
3 (应用) 3
3 (重播) 3

请注意,不透明状态存储了先前的状态信息。因此,当批次 #3 被重播时,它可以使用新的聚合计数重新转换状态。

也许你会想为什么我们会重新应用已经提交的批次。我们关心的情景是,状态更新成功,但下游处理失败。在我们的示例拓扑中,也许警报发送失败了。在这种情况下,Trident 会重试批次。现在,在最坏的情况下,当喷口被要求重新发出批次时,一个或多个数据源可能不可用。

在 Transactional spout 的情况下,它需要等待直到所有的源再次可用。不透明的 Transactional spout 将能够发出可用的批次部分,处理可以继续进行。由于 Trident 依赖于对状态的批次的顺序应用,因此至关重要的是不要延迟任何一个批次,因为这会延迟系统中的所有处理。

鉴于这种方法,状态的选择应该基于 spout,以保证幂等行为,不会过度计数或损坏状态。以下表格显示了保证幂等行为的可能配对:

Spout 类型 非事务状态 不透明状态 重复事务状态
非事务 spout
不透明 spout X
事务 spout X X

幸运的是,Storm 提供了地图实现,可以将持久性层屏蔽在状态管理的复杂性之外。具体来说,Trident 提供了State实现,可以维护额外的信息,以遵守先前概述的保证。这些对象的命名很合适:NonTransactionalMapTransactionalMapOpaqueMap

回到我们的示例,由于我们没有事务保证,我们选择使用NonTransactionalMap作为我们的State对象。

OutbreakTrendState对象如下代码片段所示:

public class OutbreakTrendState extends NonTransactionalMap<Long> {
protected OutbreakTrendState(
OutbreakTrendBackingMap outbreakBackingMap) {
   super(outbreakBackingMap);
}
}

如前面的代码所示,要利用MapState对象,我们只需传递一个支持映射。在我们的示例中,这是OutbreakTrendBackingMap。该对象的代码如下:

public class OutbreakTrendBackingMap implements IBackingMap<Long> {
    private static final Logger LOG = 
LoggerFactory.getLogger(OutbreakTrendBackingMap.class);
 Map<String, Long> storage = 
new ConcurrentHashMap<String, Long>();

 @Override
 public List<Long> multiGet(List<List<Object>> keys) {
    List<Long> values = new ArrayList<Long>();
    for (List<Object> key : keys) {
        Long value = storage.get(key.get(0));
        if (value==null){
            values.add(new Long(0));
        } else {
            values.add(value);
        }
    }
    return values;
}

@Override
public void multiPut(List<List<Object>> keys, List<Long> vals) {
    for (int i=0; i < keys.size(); i++) {
        LOG.info("Persisting [" + keys.get(i).get(0) + "] ==> [" 
+ vals.get(i) + "]");
        storage.put((String) keys.get(i).get(0), vals.get(i));
    }
}
}

在我们的示例拓扑中,我们实际上并不持久化值。我们只是把它们放在ConcurrentHashMap中。显然,这在多个主机上是行不通的。然而,BackingMap是一个巧妙的抽象。只需改变我们传递给MapState对象构造函数的支持映射实例,就可以改变持久性层。我们将在后面的章节中看到这一点。

执行拓扑

OutbreakDetectionTopology类有以下主要方法:

public static void main(String[] args) throws Exception {
    Config conf = new Config();
    LocalCluster cluster = new LocalCluster();
    cluster.submitTopology("cdc", conf, buildTopology());
    Thread.sleep(200000);
    cluster.shutdown();
}

执行此方法将拓扑提交到本地集群。spout 将立即开始发出诊断事件,Count聚合器将收集。OutbreakDetector类中的阈值设置得很快就会超过阈值,此时程序将终止,并显示以下一系列命令:

INFO [Thread-18] DefaultCoordinator.success(31) | Successful Transaction [8]
INFO [Thread-18] DefaultCoordinator.initializeTransaction(25) | Initializing Transaction [9]
...
INFO [Thread-24] OutbreakTrendBackingMap.multiPut(34) | Persisting [SF:320:378951] ==> [10306]
INFO [Thread-24] OutbreakTrendBackingMap.multiPut(34) | Persisting [PHL:320:378951] ==> [893]
INFO [Thread-24] OutbreakTrendBackingMap.multiPut(34) | Persisting [NYC:322:378951] ==> [1639]
INFO [Thread-24] OutbreakTrendBackingMap.multiPut(34) | Persisting [SF:322:378951] ==> [10254]
INFO [Thread-24] OutbreakTrendBackingMap.multiPut(34) | Persisting [SF:321:378951] ==> [10386]
...
00:04 ERROR: ALERT RECEIVED [Outbreak detected for [SF:320:378951]!]
00:04 ERROR: Dispatch the National Guard!

请注意,协调器在批次成功完成时会收到通知,几个批次后,阈值被超过,系统会用错误消息Dispatch the National Guard!指示我们。

摘要

在本章中,我们创建了一个拓扑,处理诊断信息以识别异常情况,这可能表明有疫情爆发。这些相同的数据流可以应用于任何类型的数据,包括天气、地震信息或交通数据。我们运用了 Trident 中的基本原语来构建一个系统,即使批次被重放,也能够计数事件。在本书的后面,我们将利用这些相同的结构和模式来执行类似的功能。

第四章:实时趋势分析

在本章中,我们将介绍使用 Storm 和 Trident 的趋势分析技术。实时趋势分析涉及识别数据流中的模式,例如识别特定事件的发生率或计数达到一定阈值时。常见的例子包括社交媒体中的热门话题,例如特定标签在 Twitter 上变得流行,或者识别搜索引擎中的热门搜索词。Storm 最初是一个在 Twitter 数据上执行实时分析的项目,并且提供了许多用于分析计算所需的核心原语。

在前几章中,spout 实现主要是使用静态样本数据或随机生成的数据的模拟。在本章中,我们将介绍一个开源的 spout,它从队列(Apache Kafka)发出数据,并支持 Trident spout 事务的所有三种类型(非事务、重复事务和不透明事务)。我们还将实现一种简单的通用方法,用于使用流行的日志框架填充 Kafka 队列,从而使您能够快速开始对现有应用程序和数据进行实时分析,几乎不需要进行任何源代码修改。

在本章中,我们将涵盖以下主题:

  • 将日志数据记录到 Apache Kafka 并将其流式传输到 Storm

  • 将现有应用程序的日志数据流式传输到 Storm 进行分析

  • 实施指数加权移动平均 Trident 函数

  • 使用 XMPP 协议与 Storm 发送警报和通知

使用案例

在我们的用例中,我们有一个应用程序或一组应用程序(网站,企业应用程序等),它们使用流行的 logback 框架(logback.qos.ch)将结构化消息记录到磁盘(访问日志,错误等)。目前,对该数据进行分析的唯一方法是使用类似 Hadoop 的东西批处理处理文件。该过程引入的延迟大大减慢了我们的反应时间;从日志数据中获取的模式通常要在特定事件发生后数小时,有时甚至数天后才出现,错失了采取响应行动的机会。更希望在模式出现后立即被主动通知,而不是事后才得知。

这个用例代表了一个常见的主题,并在许多业务场景中有广泛的应用,包括以下应用:

  • 应用程序监控:例如,在某些网络错误达到一定频率时通知系统管理员

  • 入侵检测:例如,检测到失败的登录尝试增加等可疑活动

  • 供应链管理:例如,识别特定产品销售量的激增,并相应调整及时交付

  • 在线广告:例如,识别热门趋势和动态更改广告投放

架构

我们的应用程序的架构如下图所示,并将包括以下组件:

架构

源应用程序

源应用程序组件是使用 logback 框架记录任意日志消息的任何应用程序。对于我们的目的,我们将创建一个简单的应用程序,以在特定间隔记录结构化消息。但是,正如您将看到的,任何现有应用程序使用 logback 或 slf4j 框架都可以通过简单的配置更改来替换。

logback Kafka appender

logback 框架具有扩展机制,允许您向其配置添加附加器。logback 附加器只是一个接收日志事件并对其进行处理的 Java 类。最常用的附加器是几个FileAppender子类之一,它们只是将日志消息格式化并写入磁盘上的文件。其他附加器实现将日志数据写入网络套接字、关系数据库和 SMTP 以进行电子邮件通知。为了我们的目的,我们将实现一个将日志消息写入 Apache Kafka 队列的附加器。

Apache Kafka

Apache Kafka (kafka.apache.org) 是一个开源的分布式发布-订阅消息系统。Kafka 专门设计和优化用于高吞吐量、持久的实时流。与 Storm 一样,Kafka 被设计为在通用软件上水平扩展,以支持每秒数十万条消息。

Kafka spout

Kafka spout 从 Kafka 队列中读取数据并将其发射到 Storm 或 Trident 拓扑。Kafka spout 最初由 Nathan Marz 编写,并且仍然是 GitHub 上 storm-contrib 项目的一部分 (github.com/nathanmarz/storm-contrib)。Kafka spout 的预构建二进制文件可从clojars.org Maven 存储库 (clojars.org/storm/storm-kafka) 获取。我们将使用 Kafka spout 从 Kafka 队列中读取消息并将其流入我们的拓扑。

我们的拓扑将由一系列内置和自定义的 Trident 组件(函数、过滤器、状态等)组成,用于检测源数据流中的模式。当检测到模式时,拓扑将向一个函数发出元组,该函数将向 XMPP 服务器发送 XMPP 消息,以通过即时消息 (IM) 通知最终用户。

XMPP 服务器

可扩展消息和出席协议 (XMPP) (xmpp.org) 是一种基于 XML 的即时消息、出席信息和联系人列表维护的标准。许多即时消息客户端,如 Adium(用于 OSX)(adium.im)和 Pidgin(用于 OSX、Linus 和 Windows)(www.pidgin.im)支持 XMPP 协议,如果您曾经使用过 Google Talk 进行即时消息传递,那么您已经使用过 XMPP。

我们将使用开源的 OpenFire XMPP 服务器 (www.igniterealtime.org/projects/openfire/),因为它易于设置并且与 OSX、Linux 和 Windows 兼容。

安装所需软件

我们将首先安装必要的软件:Apache Kafka 和 OpenFire。虽然 Kafka 是一个分布式消息系统,但作为单节点安装,甚至作为开发环境的一部分本地安装都可以正常工作。在生产环境中,您需要根据扩展需求设置一个或多个机器的集群。OpenFire 服务器不是一个集群系统,可以安装在单个节点或本地。

安装 Kafka

Kafka 依赖于 ZooKeeper 来存储某些状态信息,就像 Storm 一样。由于 Storm 对 ZooKeeper 的负载相对较轻,在许多情况下可以接受在 Kafka 和 Storm 之间共享相同的 ZooKeeper 集群。由于我们已经在第二章中介绍了 ZooKeeper 的安装,配置 Storm 集群,这里我们只介绍与 Kafka 一起提供的本地 ZooKeeper 服务器的运行,适用于开发环境。

首先从以下网站下载 Apache Kafka 的 0.7.x 版本:

kafka.apache.org/downloads.html

接下来,解压源分发并将现有目录更改为以下目录:

tar -zxf kafka-0.7.2-incubating-src.tgz
cd kafka-0.7.2-incubating-src

Kafka 是用 Scala JVM 语言(www.scala-lang.org)编写的,并使用sbtScala Build Tool)(www.scala-sbt.org)进行编译和打包。幸运的是,Kafka 源代码分发包括sbt,可以使用以下命令构建:

./sbt update package

在启动 Kafka 之前,除非您已经运行了 ZooKeeper 服务,否则您需要使用以下命令启动与 Kafka 捆绑的 ZooKeeper 服务:

./bin/zookeeper-server-start.sh ./config/zookeeper.properties

最后,在一个单独的终端窗口中,使用以下命令启动 Kafka 服务:

./bin/kafka-server-start.sh ./config/server.properties

Kafka 服务现在可以使用了。

安装 OpenFire

OpenFire 可作为 OSX 和 Windows 的安装程序以及各种 Linux 发行版的软件包提供,并且可以从以下网站下载:

www.igniterealtime.org/downloads/index.jsp

要安装 OpenFire,请下载适用于您操作系统的安装程序,并按照以下网站上找到的适当安装说明进行操作:

www.igniterealtime.org/builds/openfire/docs/latest/documentation/index.html

介绍示例应用程序

应用组件是一个简单的 Java 类,使用Simple Logging Facade for JavaSLF4J)(www.slf4j.org)记录消息。我们将模拟一个应用程序,开始以相对较慢的速率生成警告消息,然后切换到以更快的速率生成警告消息的状态,最后返回到慢速状态,如下所示:

  • 每 5 秒记录一次警告消息,持续 30 秒(慢速状态)

  • 每秒记录一次警告消息,持续 15 秒(快速状态)

  • 每 5 秒记录一次警告消息,持续 30 秒(慢速状态)

该应用程序的目标是生成一个简单的模式,我们的风暴拓扑可以识别并在出现特定模式和状态变化时发送通知,如下面的代码片段所示:

public class RogueApplication {
    private static final Logger LOG = LoggerFactory.getLogger(RogueApplication.class);

    public static void main(String[] args) throws Exception {
        int slowCount = 6;
        int fastCount = 15;
        // slow state
        for(int i = 0; i < slowCount; i++){
            LOG.warn("This is a warning (slow state).");
            Thread.sleep(5000);
        }
        // enter rapid state
        for(int i = 0; i < fastCount; i++){
            LOG.warn("This is a warning (rapid state).");
            Thread.sleep(1000);
        }
        // return to slow state
        for(int i = 0; i < slowCount; i++){
            LOG.warn("This is a warning (slow state).");
            Thread.sleep(5000);
        }
    }
}

将日志消息发送到 Kafka

logback 框架提供了一个简单的扩展机制,允许您插入附加的附加器。在我们的情况下,我们想要实现一个可以将日志消息数据写入 Kafka 的附加器。

Logback 包括ch.qos.logback.core.AppenderBase抽象类,使得实现Appender接口变得容易。AppenderBase类定义了一个抽象方法如下:

  abstract protected void append(E eventObject);

eventObject参数表示日志事件,并包括事件日期、日志级别(DEBUGINFOWARN等)以及日志消息本身等属性。我们将重写append()方法,将eventObject数据写入 Kafka。

除了append()方法之外,AppenderBase类还定义了两个我们需要重写的附加生命周期方法:

 public void start();
 public void stop();

start()方法在 logback 框架初始化期间调用,stop()方法在去初始化时调用。我们将重写这些方法来建立和拆除与 Kafka 服务的连接。

KafkaAppender类的源代码如下所示:

public class KafkaAppender extends AppenderBase<ILoggingEvent> {

    private String topic;
    private String zookeeperHost;
    private Producer<String, String> producer;
    private Formatter formatter;

    // java bean definitions used to inject
    // configuration values from logback.xml
    public String getTopic() {
        return topic;
    }

    public void setTopic(String topic) {
        this.topic = topic;
    }

    public String getZookeeperHost() {
        return zookeeperHost;
    }

    public void setZookeeperHost(String zookeeperHost) {
        this.zookeeperHost = zookeeperHost;
    }

    public Formatter getFormatter() {
        return formatter;
    }

    public void setFormatter(Formatter formatter) {
        this.formatter = formatter;
    }

    // overrides
    @Override
    public void start() {
        if (this.formatter == null) {
            this.formatter = new MessageFormatter();
        }
        super.start();
        Properties props = new Properties();
        props.put("zk.connect", this.zookeeperHost);
        props.put("serializer.class", "kafka.serializer.StringEncoder");
        ProducerConfig config = new ProducerConfig(props);
        this.producer = new Producer<String, String>(config);
    }

    @Override
    public void stop() {
        super.stop();
        this.producer.close();
    }

    @Override
    protected void append(ILoggingEvent event) {
        String payload = this.formatter.format(event);
        ProducerData<String, String> data = new ProducerData<String, String>(this.topic, payload);
        this.producer.send(data);
    }

}

正如您将看到的,这个类中的 JavaBean 风格的访问器允许我们在 logback 框架初始化时通过依赖注入配置相关值。zookeeperHosts属性的 setter 和 getter 用于初始化KafkaProducer客户端,配置它以发现已在 ZooKeeper 注册的 Kafka 主机。另一种方法是提供一个静态的 Kafka 主机列表,但为了简单起见,使用自动发现机制更容易。topic属性用于告诉KafkaConsumer客户端应该从哪个 Kafka 主题读取。

Formatter属性有些特殊。这是一个我们定义的接口,提供了处理结构化(即可解析的)日志消息的扩展点,如下面的代码片段所示:

public interface Formatter {
    String format(ILoggingEvent event);
}

Formatter实现的工作是将ILoggingEvent对象转换为可被消费者处理的机器可读字符串。下面的简单实现只是返回日志消息,丢弃任何额外的元数据:

public class MessageFormatter implements Formatter {

    public String format(ILoggingEvent event) {
        return event.getFormattedMessage();
    }
}

以下的 logback 配置文件展示了 appender 的使用。这个例子没有定义自定义的Formatter实现,所以KafkaAppender类将默认使用MessageFormatter类,只会将日志消息数据写入 Kafka 并丢弃日志事件中包含的任何额外信息,如下面的代码片段所示:

<?xml version="1.0" encoding="UTF-8" ?>
<configuration>
    <appender name="KAFKA"
        class="com.github.ptgoetz.logback.kafka.KafkaAppender">
        <topic>mytopic</topic>
        <zookeeperHost>localhost:2181</zookeeperHost>
    </appender>
    <root level="debug">
        <appender-ref ref="KAFKA" />
    </root>
</configuration>

我们正在构建的 Storm 应用程序是时间敏感的:如果我们正在跟踪每个事件发生的速率,我们需要准确知道事件发生的时间。一个天真的方法是当数据进入我们的拓扑时,简单地使用System.currentTimeMillis()方法为事件分配一个时间。然而,Trident 的批处理机制不能保证元组以与接收到的速率相同的速率传递到拓扑。

为了应对这种情况,我们需要在事件发生时捕获事件的时间并在写入 Kafka 队列时包含在数据中。幸运的是,ILoggingEvent类包括一个时间戳,表示事件发生时距离纪元的毫秒数。

为了包含ILoggingEvent中包含的元数据,我们将创建一个自定义的Formatter实现,将日志事件数据编码为 JSON 格式,如下所示:

public class JsonFormatter implements Formatter {
    private static final String QUOTE = "\"";
    private static final String COLON = ":";
    private static final String COMMA = ",";

    private boolean expectJson = false;

    public String format(ILoggingEvent event) {
        StringBuilder sb = new StringBuilder();
        sb.append("{");
        fieldName("level", sb);
        quote(event.getLevel().levelStr, sb);
        sb.append(COMMA);
        fieldName("logger", sb);
        quote(event.getLoggerName(), sb);
        sb.append(COMMA);
        fieldName("timestamp", sb);
        sb.append(event.getTimeStamp());
        sb.append(COMMA);
        fieldName("message", sb);
        if (this.expectJson) {
            sb.append(event.getFormattedMessage());
        } else {
            quote(event.getFormattedMessage(), sb);
        }

        sb.append("}");
        return sb.toString();
    }

    private static void fieldName(String name, StringBuilder sb) {
        quote(name, sb);
        sb.append(COLON);
    }

    private static void quote(String value, StringBuilder sb) {
        sb.append(QUOTE);
        sb.append(value);
        sb.append(QUOTE);
    }

    public boolean isExpectJson() {
        return expectJson;
    }

    public void setExpectJson(boolean expectJson) {
        this.expectJson = expectJson;
    }
}

JsonMessageFormatter类的大部分代码使用java.lang.StringBuilder类从ILoggingEvent对象创建 JSON。虽然我们可以使用 JSON 库来完成工作,但我们生成的 JSON 数据很简单,添加额外的依赖只是为了生成 JSON 会显得过度。

JsonMessageFormatter公开的一个 JavaBean 属性是expectJson布尔值,用于指定传递给Formatter实现的日志消息是否应被视为 JSON。如果设置为False,日志消息将被视为字符串并用双引号括起来,否则消息将被视为 JSON 对象({...})或数组([...])。

以下是一个示例的 logback 配置文件,展示了KafkaAppenderJsonFormatter类的使用:

<?xml version="1.0" encoding="UTF-8" ?>
<configuration>
    <appender name="KAFKA"
        class="com.github.ptgoetz.logback.kafka.KafkaAppender">
        <topic>foo</topic>
        <zookeeperHost>localhost:2181</zookeeperHost>
        <!-- specify a custom formatter -->
        <formatter class="com.github.ptgoetz.logback.kafka.formatter.JsonFormatter">
            <!-- 
            Whether we expect the log message to be JSON encoded or not.
            If set to "false", the log message will be treated as a string, and wrapped in quotes. Otherwise it will be treated as a parseable JSON object.
            -->
            <expectJson>false</expectJson>
        </formatter>
    </appender>
	<root level="debug">
		<appender-ref ref="KAFKA" />
	</root>
</configuration>

由于我们正在构建的分析拓扑更关注事件时间而不是消息内容,我们生成的日志消息将是字符串,因此我们将expectJson属性设置为False

介绍日志分析拓扑

有了将日志数据写入 Kafka 的手段,我们准备将注意力转向实现一个 Trident 拓扑来执行分析计算。拓扑将执行以下操作:

  1. 接收并解析原始 JSON 日志事件数据。

  2. 提取并发出必要的字段。

  3. 更新指数加权移动平均函数。

  4. 确定移动平均是否越过了指定的阈值。

  5. 过滤掉不代表状态改变的事件(例如,速率移动超过/低于阈值)。

  6. 发送即时消息(XMPP)通知。

拓扑结构如下图所示,三叉戟流操作位于顶部,流处理组件位于底部:

介绍日志分析拓扑

Kafka spout

创建日志分析拓扑的第一步是配置 Kafka spout,将从 Kafka 接收的数据流入我们的拓扑,如下所示:

        TridentTopology topology = new TridentTopology();

        StaticHosts kafkaHosts = KafkaConfig.StaticHosts.fromHostString(Arrays.asList(new String[] { "localhost" }), 1);
        TridentKafkaConfig spoutConf = new TridentKafkaConfig(kafkaHosts, "log-analysis");
        spoutConf.scheme = new StringScheme();
        spoutConf.forceStartOffsetTime(-1);
        OpaqueTridentKafkaSpout spout = new OpaqueTridentKafkaSpout(spoutConf);

        Stream spoutStream = topology.newStream("kafka-stream", spout);

这段代码首先创建了一个新的TridentTopology实例,然后使用 Kafka Java API 创建了一个 Kafka 主机列表,用于连接(因为我们在本地运行单个、非集群的 Kafka 服务,所以我们指定了一个主机:localhost)。接下来,我们创建了TridentKafkaConfig对象,将主机列表和唯一标识符传递给它。

我们的应用程序写入 Kafka 的数据是一个简单的 Java 字符串,因此我们使用 Storm-Kafka 内置的StringScheme类。StringScheme类将从 Kafka 读取数据作为字符串,并将其输出到名为str的元组字段中。

默认情况下,在部署时,Kafka spout 将尝试从 Kafka 队列中上次离开的地方读取状态信息。这种行为可以通过调用TridentKafkaConfig类的forceOffsetTime(long time)方法来覆盖。时间参数可以是以下三个值之一:

  • -2(最早的偏移):spout 将倒带并从队列的开头开始读取

  • -1(最新的偏移):spout 将快进并从队列的末尾读取

  • 以毫秒为单位的时间:给定特定日期的毫秒数(例如,java.util.Date.getTime()),spout 将尝试从那个时间点开始读取

在设置好 spout 配置之后,我们创建了一个Opaque Transactional Kafka spout 的实例,并设置了相应的 Trident 流。

JSON 项目函数

来自 Kafka spout 的数据流将包含一个字段(str),其中包含来自日志事件的 JSON 数据。我们将创建一个 Trident 函数来解析传入的数据,并输出或投影请求的字段作为元组值,如下面的代码片段所示:

public class JsonProjectFunction extends BaseFunction {

    private Fields fields;

    public JsonProjectFunction(Fields fields) {
        this.fields = fields;
    }

    public void execute(TridentTuple tuple, TridentCollector collector) {
        String json = tuple.getString(0);
        Map<String, Object> map = (Map<String, Object>)  
            JSONValue.parse(json);
        Values values = new Values();
        for (int i = 0; i < this.fields.size(); i++) {
            values.add(map.get(this.fields.get(i)));
        }
        collector.emit(values);
    }

}

JsonProjectFunction构造函数接受一个Fields对象参数,该参数将确定要作为要查找的键名称列表从 JSON 中发出的值。当函数接收到一个元组时,它将解析元组的str字段中的 JSON,迭代Fields对象的值,并从输入 JSON 中发出相应的值。

以下代码创建了一个Fields对象,其中包含要从 JSON 中提取的字段名称列表。然后,它从 spout 流创建了一个新的Stream对象,选择str元组字段作为JsonProjectFunction构造函数的输入,构造了JsonProjectFunction构造函数,并指定从 JSON 中选择的字段也将从函数中输出:

        Fields jsonFields = new Fields("level", "timestamp", "message", "logger");
        Stream parsedStream = spoutStream.each(new Fields("str"), new JsonProjectFunction(jsonFields), jsonFields);

考虑到以下 JSON 消息是从 Kafka spout 接收到的:

{
  "message" : "foo",
  "timestamp" : 1370918376296,
  "level" : "INFO",
  "logger" : "test"
}

这意味着该函数将输出以下元组值:

[INFO, 1370918376296, test, foo]

计算移动平均

为了计算日志事件发生的速率,而无需存储过多的状态,我们将实现一个函数,执行统计学中所谓的指数加权移动平均

移动平均计算经常用于平滑短期波动,并暴露时间序列数据中的长期趋势。移动平均的最常见的例子之一是在股票市场价格波动的图表中使用,如下面的屏幕截图所示:

计算移动平均

移动平均的平滑效果是通过在计算中考虑历史值来实现的。移动平均计算可以以非常少量的状态执行。对于时间序列,我们只需要保留上一个事件的时间和上一个计算的平均值。

在伪代码中,计算看起来像以下代码片段:

diff = currentTime - lastEventTime
currentAverage = (1.0 - alpha) * diff + alpha * lastAverage

上述计算中的alpha值是介于01之间的常量值。alpha值确定随时间发生的平滑程度。alpha值越接近1,历史值对当前平均值的影响就越大。换句话说,alpha值越接近0,平滑效果就越小,移动平均值就越接近当前值。alpha值越接近1,效果就相反。当前平均值受到的波动影响就越小,历史值在确定当前平均值时的权重就越大。

添加一个滑动窗口

在某些情况下,我们可能希望打折历史值以减少它们对移动平均值的影响,例如,如果在接收事件之间经过了很长时间,我们可能希望重置平滑效果。在低 alpha 值的情况下,这可能是不必要的,因为平滑效果很小。然而,在高 alpha 值的情况下,抵消平滑效果可能是可取的。

考虑以下示例。

我们有一个(例如网络错误等)偶尔发生的事件。偶尔会出现小的频率波动,但通常没关系。因此,我们希望消除小的波动。我们希望被通知的是如果发生了持续的波动。

如果事件平均每周发生一次(远低于我们的通知阈值),但有一天在一个小时内发生了多次(超过我们的通知阈值),高 alpha 的平滑效果可能会抵消波动,以至于永远不会触发通知。

为了抵消这种影响,我们可以在移动平均值计算中引入滑动窗口的概念。由于我们已经在跟踪上一个事件的时间和当前平均值,因此实现滑动窗口就像在以下伪代码中所示的那样简单:

if (currentTime - lastEventTime) > slidingWindowInterval
    currentAverage = 0
end if

指数加权移动平均的实现如下所示:

public class EWMA implements Serializable {

    public static enum Time {
        MILLISECONDS(1), SECONDS(1000), MINUTES(SECONDS.getTime() * 60), HOURS(MINUTES.getTime() * 60), DAYS(HOURS
                .getTime() * 24), WEEKS(DAYS.getTime() * 7);

        private long millis;

        private Time(long millis) {
            this.millis = millis;
        }

        public long getTime() {
            return this.millis;
        }
    }

    // Unix load average-style alpha constants
    public static final double ONE_MINUTE_ALPHA = 1 - Math.exp(-5d / 60d / 1d);
    public static final double FIVE_MINUTE_ALPHA = 1 - Math.exp(-5d / 60d / 5d);
    public static final double FIFTEEN_MINUTE_ALPHA = 1 - Math.exp(-5d / 60d / 15d);

    private long window;
    private long alphaWindow;
    private long last;
    private double average;
    private double alpha = -1D;
    private boolean sliding = false;

    public EWMA() {
    }

    public EWMA sliding(double count, Time time) {
        return this.sliding((long) (time.getTime() * count));
    }

    public EWMA sliding(long window) {
        this.sliding = true;
        this.window = window;
        return this;
    }

    public EWMA withAlpha(double alpha) {
        if (!(alpha > 0.0D && alpha <= 1.0D)) {
            throw new IllegalArgumentException("Alpha must be between 0.0 and 1.0");
        }
        this.alpha = alpha;
        return this;
    }

    public EWMA withAlphaWindow(long alphaWindow) {
        this.alpha = -1;
        this.alphaWindow = alphaWindow;
        return this;
    }

    public EWMA withAlphaWindow(double count, Time time) {
        return this.withAlphaWindow((long) (time.getTime() * count));
    }

    public void mark() {
        mark(System.currentTimeMillis());
    }

    public synchronized void mark(long time) {
        if (this.sliding) {
            if (time - this.last > this.window) {
                // reset the sliding window
                this.last = 0;
            }
        }
        if (this.last == 0) {
            this.average = 0;
            this.last = time;
        }
        long diff = time - this.last;
        double alpha = this.alpha != -1.0 ? this.alpha : Math.exp(-1.0 * ((double) diff / this.alphaWindow));
        this.average = (1.0 - alpha) * diff + alpha * this.average;
        this.last = time;
    }

    public double getAverage() {
        return this.average;
    }

    public double getAverageIn(Time time) {
        return this.average == 0.0 ? this.average : this.average / time.getTime();
    }

    public double getAverageRatePer(Time time) {
        return this.average == 0.0 ? this.average : time.getTime() / this.average;
    }

}

EWMA实现定义了三个有用的常量alpha值:ONE_MINUTE_ALPHAFIVE_MINUTE_ALPHAFIFTEEN_MINUTE_ALPHA。这些对应于 UNIX 中用于计算负载平均值的标准alpha值。alpha值也可以手动指定,或者作为alpha窗口的函数。

该实现使用流畅的构建器API。例如,您可以创建一个具有一分钟滑动窗口和等效于 UNIX 一分钟间隔的alpha值的EWMA实例,如下面的代码片段所示:

EWMA ewma = new EWMA().sliding(1.0, Time.MINUTES).withAlpha(EWMA.ONE_MINUTE_ALPHA);

mark()方法用于更新移动平均值。如果没有参数,mark()方法将使用当前时间来计算平均值。因为我们想要使用日志事件的原始时间戳,我们重载mark()方法以允许指定特定时间。

getAverage()方法以毫秒为单位返回mark()调用之间的平均时间。我们还添加了方便的getAverageIn()方法,它将返回指定时间单位(秒,分钟,小时等)的平均值。getAverageRatePer()方法返回特定时间测量中mark()调用的速率。

正如您可能注意到的那样,使用指数加权移动平均值可能有些棘手。找到合适的 alpha 值以及可选滑动窗口的正确值在很大程度上取决于特定用例,并且找到正确的值在很大程度上是一个反复试验的问题。

实现移动平均函数

要在 Trident 拓扑中使用我们的EWMA类,我们将创建 Trident 的BaseFunction抽象类的子类,命名为MovingAverageFunction,它包装了一个EWMA实例,如下面的代码片段所示:

public class MovingAverageFunction extends BaseFunction {
    private static final Logger LOG = LoggerFactory.getLogger(BaseFunction.class);

    private EWMA ewma;
    private Time emitRatePer;

    public MovingAverageFunction(EWMA ewma, Time emitRatePer){
        this.ewma = ewma;
        this.emitRatePer = emitRatePer;
    }

    public void execute(TridentTuple tuple, TridentCollector collector) {
        this.ewma.mark(tuple.getLong(0));
        LOG.debug("Rate: {}", this.ewma.getAverageRatePer(this.emitRatePer));
        collector.emit(new Values(this.ewma.getAverageRatePer(this.emitRatePer)));
    }
}

MovingAverage.execute()方法获取传入元组的第一个字段的Long值,使用该值调用mark()方法来更新当前平均值,并发出当前平均速率。Trident 中的函数是累加的,这意味着它们将值添加到流中的元组中。因此,例如,考虑传入我们函数的元组如下代码片段所示:

[INFO, 1370918376296, test, foo]

这意味着在处理后,元组可能看起来像下面的代码片段:

[INFO, 1370918376296, test, foo, 3.72234]

在这里,新值代表了新的平均速率。

为了使用该函数,我们创建了一个EWMA类的实例,并将其传递给MovingAverageFunction构造函数。我们使用each()方法将该函数应用于流,选择timestamp字段作为输入,如下面的代码片段所示:

        EWMA ewma = new EWMA().sliding(1.0, Time.MINUTES).withAlpha(EWMA.ONE_MINUTE_ALPHA);
        Stream averageStream = parsedStream.each(new Fields("timestamp"),
                new MovingAverageFunction(ewma, Time.MINUTES), new Fields("average"));

阈值过滤

对于我们的用例,我们希望能够定义一个触发通知的速率阈值。当超过阈值时,我们还希望在平均速率再次低于该阈值时收到通知(即恢复正常)。我们可以使用额外的函数和简单的 Trident 过滤器的组合来实现这个功能。

函数的作用是确定平均速率字段的新值是否越过了阈值,并且它是否代表了与先前值的变化(即它是否从低于阈值变为高于阈值或反之)。如果新的平均值代表了状态变化,函数将发出布尔值True,否则它将发出False。我们将利用该值来过滤掉不代表状态变化的事件。我们将在ThresholdFilterFunction类中实现阈值跟踪功能,如下面的代码片段所示:

public class ThresholdFilterFunction extends BaseFunction {
    private static final Logger LOG = LoggerFactory.getLogger(ThresholdFilterFunction.class);

    private static enum State {
        BELOW, ABOVE;
    }

    private State last = State.BELOW;
    private double threshold;

    public ThresholdFilterFunction(double threshold){
        this.threshold = threshold;
    }

    public void execute(TridentTuple tuple, TridentCollector collector) {
        double val = tuple.getDouble(0);
        State newState = val < this.threshold ? State.BELOW : State.ABOVE;
        boolean stateChange = this.last != newState;
        collector.emit(new Values(stateChange, threshold));
        this.last = newState;
        LOG.debug("State change? --> {}", stateChange);
    }
}

ThresholdFilterFunction类定义了一个内部枚举来表示状态(高于阈值或低于阈值)。构造函数接受一个双精度参数,用于建立我们要比较的阈值。在execute()方法中,我们获取当前速率值,并确定它是低于还是高于阈值。然后,我们将其与上一个状态进行比较,看它是否已经改变,并将该值作为布尔值发出。最后,我们将内部的高于/低于状态更新为新计算的值。

通过ThresholdFilterFunction类后,输入流中的元组将包含一个新的布尔值,我们可以使用它来轻松过滤掉不触发状态变化的事件。为了过滤掉非状态变化的事件,我们将使用一个简单的BooleanFilter类,如下面的代码片段所示:

public class BooleanFilter extends BaseFilter {

    public boolean isKeep(TridentTuple tuple) {
        return tuple.getBoolean(0);
    }
}

BooleanFilter.isKeep()方法只是从元组中读取一个字段作为布尔值并返回该值。任何包含输入值为False的元组将被过滤出结果流。

以下代码片段说明了ThresholdFilterFuncation类和BooleanFilter类的用法:

        ThresholdFilterFunction tff = new ThresholdFilterFunction(50D);
        Stream thresholdStream = averageStream.each(new Fields("average"), tff, new Fields("change", "threshold"));

        Stream filteredStream = thresholdStream.each(new Fields("change"), new BooleanFilter());

第一行创建了一个具有阈值50.0ThresholdFilterFunction实例。然后,我们使用averageStream作为输入创建了一个新的流,并选择average元组字段作为输入。我们还为函数添加的字段分配了名称(changethreshold)。最后,我们应用BooleanFilter类创建一个新的流,该流将只包含代表阈值比较变化的元组。

此时,我们已经有了实现通知所需的一切。我们创建的filteredStream将只包含代表阈值状态变化的元组。

使用 XMPP 发送通知

XMPP 协议提供了即时消息标准中所期望的所有典型功能:

  • 花名册(联系人列表)

  • 在线状态(知道其他人何时在线以及他们的可用状态)

  • 用户之间的即时消息

  • 群聊

XMPP 协议使用 XML 格式进行通信,但有许多高级客户端库可以处理大部分低级细节,并提供简单的 API。我们将使用 Smack API(www.igniterealtime.org/projects/smack/),因为它是最直接的 XMPP 客户端实现之一。

以下代码片段演示了使用 Smack API 向另一个用户发送简单即时消息的用法:

        // connect to XMPP server and login
        ConnectionConfiguration config = new
            ConnectionConfiguration("jabber.org");
        XMPPConnection client = new XMPPConnection(config);
        client.connect();
        client.login("username", "password");

        // send a message to another user
        Message message =
           new Message("myfriend@jabber.org", Type.normal);
        message.setBody("How are you today?");
        client.sendPacket(message);

该代码连接到jabber.org的 XMPP 服务器,并使用用户名和密码登录。在幕后,Smack 库处理与服务器的低级通信。当客户端连接并进行身份验证时,它还向服务器发送了一个出席消息。这允许用户的联系人(在其 XMPP 花名册中列出的其他用户)收到通知,表明该用户现在已连接。最后,我们创建并发送一个简单的消息,地址为"myfriend@jabber.org"

基于这个简单的例子,我们将创建一个名为XMPPFunction的类,当它接收到 Trident 元组时,会发送 XMPP 通知。该类将在prepare()方法中建立与 XMPP 服务器的长连接。此外,在execute()方法中,它将根据接收到的元组创建一个 XMPP 消息。

为了使XMPPFunction类更具可重用性,我们将引入MessageMapper接口,该接口定义了一种方法,用于将 Trident 元组的数据格式化为适合即时消息通知的字符串,如下所示的代码片段所示:

public interface MessageMapper extends Serializable {
    public String toMessageBody(TridentTuple tuple);
}

我们将在XMPPFunction类中委托消息格式化给一个MessageMapper实例,如下所示的代码片段所示:

public class XMPPFunction extends BaseFunction {
    private static final Logger LOG = LoggerFactory.getLogger(XMPPFunction.class);

    public static final String XMPP_TO = "storm.xmpp.to";
    public static final String XMPP_USER = "storm.xmpp.user";
    public static final String XMPP_PASSWORD = "storm.xmpp.password";
    public static final String XMPP_SERVER = "storm.xmpp.server";

    private XMPPConnection xmppConnection;
    private String to;
    private MessageMapper mapper;

    public XMPPFunction(MessageMapper mapper) {
        this.mapper = mapper;
    }

    @Override
    public void prepare(Map conf, TridentOperationContext context) {
        LOG.debug("Prepare: {}", conf);
        super.prepare(conf, context);
        this.to = (String) conf.get(XMPP_TO);
        ConnectionConfiguration config = new ConnectionConfiguration((String) conf.get(XMPP_SERVER));
        this.xmppConnection = new XMPPConnection(config);
        try {
            this.xmppConnection.connect();
            this.xmppConnection.login((String) conf.get(XMPP_USER), (String) conf.get(XMPP_PASSWORD));
        } catch (XMPPException e) {
            LOG.warn("Error initializing XMPP Channel", e);
        }
    }

    public void execute(TridentTuple tuple, TridentCollector collector) {
        Message msg = new Message(this.to, Type.normal);
        msg.setBody(this.mapper.toMessageBody(tuple));
        this.xmppConnection.sendPacket(msg);

    }

}

XMPPFunction类首先定义了几个字符串常量,用于从传递给prepare()方法的 Storm 配置中查找值,然后声明了实例变量,当函数激活时我们将填充这些变量。该类的构造函数接受一个MessageMapper实例作为参数,该实例将在execute()方法中用于格式化通知消息的正文。

prepare()方法中,我们查找XMPPConnection类的配置参数(serverusernameto address等),并打开连接。当部署使用此函数的拓扑时,XMPP客户端将发送出席数据包,其他用户如果在其花名册(好友列表)中有配置的用户,则会收到通知,指示该用户现在在线。

我们通知机制的最后一个必要部分是实现一个MessageMapper实例,将元组的内容格式化为人类可读的消息正文,如下所示的代码片段所示:

public class NotifyMessageMapper implements MessageMapper {

    public String toMessageBody(TridentTuple tuple) {
        StringBuilder sb = new StringBuilder();
        sb.append("On " + new Date(tuple.getLongByField("timestamp")) + " ");
        sb.append("the application \"" + tuple.getStringByField("logger") + "\" ");
        sb.append("changed alert state based on a threshold of " + tuple.getDoubleByField("threshold") + ".\n");
        sb.append("The last value was " + tuple.getDoubleByField("average") + "\n");
        sb.append("The last message was \"" + tuple.getStringByField("message") + "\"");
        return sb.toString();
    }
}

最终的拓扑结构

现在我们已经拥有构建日志分析拓扑所需的所有组件,如下所示:

public class LogAnalysisTopology {

    public static StormTopology buildTopology() {
        TridentTopology topology = new TridentTopology();

        StaticHosts kafkaHosts = KafkaConfig.StaticHosts.fromHostString(Arrays.asList(new String[] { "localhost" }), 1);
        TridentKafkaConfig spoutConf = new TridentKafkaConfig(kafkaHosts, "log-analysis");
        spoutConf.scheme = new StringScheme();
        spoutConf.forceStartOffsetTime(-1);
        OpaqueTridentKafkaSpout spout = new OpaqueTridentKafkaSpout(spoutConf);

        Stream spoutStream = topology.newStream("kafka-stream", spout);

        Fields jsonFields = new Fields("level", "timestamp", "message", "logger");
        Stream parsedStream = spoutStream.each(new Fields("str"), new JsonProjectFunction(jsonFields), jsonFields);

        // drop the unparsed JSON to reduce tuple size
        parsedStream = parsedStream.project(jsonFields);

        EWMA ewma = new EWMA().sliding(1.0, Time.MINUTES).withAlpha(EWMA.ONE_MINUTE_ALPHA);
        Stream averageStream = parsedStream.each(new Fields("timestamp"),
                new MovingAverageFunction(ewma, Time.MINUTES), new Fields("average"));

        ThresholdFilterFunction tff = new ThresholdFilterFunction(50D);
        Stream thresholdStream = averageStream.each(new Fields("average"), tff, new Fields("change", "threshold"));

        Stream filteredStream = thresholdStream.each(new Fields("change"), new BooleanFilter());

        filteredStream.each(filteredStream.getOutputFields(), new XMPPFunction(new NotifyMessageMapper()), new Fields());

        return topology.build();
    }

    public static void main(String[] args) throws Exception {
        Config conf = new Config();
        conf.put(XMPPFunction.XMPP_USER, "storm@budreau.local");
        conf.put(XMPPFunction.XMPP_PASSWORD, "storm");
        conf.put(XMPPFunction.XMPP_SERVER, "budreau.local");
        conf.put(XMPPFunction.XMPP_TO, "tgoetz@budreau.local");

        conf.setMaxSpoutPending(5);
        if (args.length == 0) {
            LocalCluster cluster = new LocalCluster();
            cluster.submitTopology("log-analysis", conf, buildTopology());

        } else {
            conf.setNumWorkers(3);
            StormSubmitter.submitTopology(args[0], conf, buildTopology());
        }
    }
}

然后,buildTopology()方法创建 Kafka spout 和我们的 Trident 函数和过滤器之间的所有流连接。然后,main()方法将拓扑提交到集群:如果拓扑在本地模式下运行,则提交到本地集群,如果在分布式模式下运行,则提交到远程集群。

我们首先配置 Kafka spout 以从我们的应用程序配置为写入日志事件的相同主题中读取。因为 Kafka 会持久化它接收到的所有消息,并且因为我们的应用程序可能已经运行了一段时间(因此记录了许多事件),我们告诉 spout 通过调用forceStartOffsetTime()方法并使用值-1来快进到 Kafka 队列的末尾。这将避免重放我们可能不感兴趣的所有旧消息。使用值-2将强制 spout 倒带到队列的开头,并使用毫秒级的特定日期将强制它倒带到特定时间点。如果没有调用forceFromStartTime()方法,spout 将尝试通过在 ZooKeeper 中查找偏移量来恢复上次离开的位置。

接下来,我们设置JsonProjectFunction类来解析从 Kafka 接收到的原始 JSON,并发出我们感兴趣的值。请记住,Trident 函数是可加的。这意味着我们的元组流,除了从 JSON 中提取的所有值之外,还将包含原始未解析的 JSON 字符串。由于我们不再需要这些数据,我们调用Stream.project()方法,提供我们想要保留的字段列表。project()方法对于将元组流减少到只包含基本字段非常有用,尤其是在重新分区具有大量数据的流时非常重要。

现在生成的流只包含我们需要的数据。我们使用一个滑动窗口为一分钟的EWMA实例,并配置MovingAverageFunction类以发出每分钟的当前速率。我们使用值50.0创建ThresholdFunction类,因此每当平均速率超过或低于每分钟 50 个事件时,我们都会收到通知。

最后,我们应用BooleanFilter类,并将生成的流连接到XMPPFunction类。

拓扑的main()方法只是用所需的属性填充一个Config对象,并提交拓扑。

运行日志分析拓扑

要运行分析拓扑,首先确保 ZooKeeper、Kafka 和 OpenFire 都已经按照本章前面概述的步骤启动并运行。然后,运行拓扑的main()方法。

当拓扑激活时,storm XMPP 用户将连接到 XMPP 服务器并触发存在事件。如果您使用 XMPP 客户端登录到同一服务器,并且在好友列表中有storm用户,您将看到它变为可用。如下面的屏幕截图所示:

运行日志分析拓扑

接下来,运行RogueApplication类并等待一分钟。您应该收到即时消息通知,指示已超过阈值,随后将收到一条指示返回正常(低于阈值)的消息,如下面的屏幕截图所示:

运行日志分析拓扑

摘要

在本章中,我们通过创建一个简单但功能强大的拓扑介绍了实时分析,该拓扑可以适应各种应用程序。我们构建的组件是通用的,可以轻松地在其他项目中重用和扩展。最后,我们介绍了一个真实世界的 spout 实现,可以用于多种目的。

虽然实时分析的主题非常广泛,而且诚然,我们在本章中只能触及表面,但我们鼓励您探索本书其他章节中提出的技术,并考虑如何将它们纳入您的分析工具箱中。

在下一章中,我们将通过构建一个应用程序,将 Storm 处理的数据持续写入图形数据库,向您介绍 Trident 的分布式状态机制。

第五章:实时图分析

在本章中,我们将介绍使用 Storm 进行图分析,将数据持久化到图数据库并查询数据以发现关系。图数据库是将数据存储为顶点、边和属性的图结构的数据库,主要关注实体之间的关系。

随着 Twitter、Facebook 和 LinkedIn 等社交媒体网站的出现,社交图已经变得无处不在。分析人与人之间的关系、他们购买的产品、他们做出的推荐,甚至他们使用的词语,都可以被分析以揭示传统数据模型难以发现的模式。例如,当 LinkedIn 显示你与另一个人相隔四步时,基于你的网络,当 Twitter 提供关注的人的建议时,或者当亚马逊建议你可能感兴趣的产品时,它们都在利用他们对你的关系图的了解。图数据库就是为这种关系分析而设计的。

在本章中,我们将构建一个应用程序,摄取 Twitter firehose 的一个子集(Twitter 用户发布的所有推文的实时源),并根据每条消息的内容,在图数据库中创建节点(顶点)和关系(边),然后进行分析。在 Twitter 中最明显的图结构是基于用户之间的关注/被关注关系,但是我们可以通过超越这些显式关系来推断出额外的关系。通过查看消息的内容,我们可以使用消息元数据(标签、用户提及等)来识别例如提到相同主题或发布相关标签的用户。在本章中,我们将涵盖以下主题:

  • 基本图数据库概念

  • TinkerPop 图形 API

  • 图数据建模

  • 与 Titan 分布式图数据库交互

  • 编写由图数据库支持的 Trident 状态实现

用例

今天的社交媒体网站捕获了大量的信息。许多社交媒体服务,如 Twitter、Facebook 和 LinkedIn,主要基于人际关系:你关注谁,与谁交友,或者与谁有业务联系。除了明显和显式的关系之外,社交媒体互动还会产生一组持久的隐式连接,这些连接很容易被忽视。例如,对于 Twitter 来说,明显的关系包括关注的人和被关注的人。不太明显的关系是通过使用服务而可能无意中创建的连接。你在 Twitter 上直接给某人发过私信吗?如果是,那么你们之间就建立了连接。发过 URL 的推文吗?如果是,也是一种连接。在 Facebook 上点赞产品、服务或评论吗?连接。甚至在推文或帖子中使用特定词语或短语也可以被视为创建连接。通过使用那个词,你正在与它建立连接,并且通过反复使用它,你正在加强那个连接。

如果我们将数据视为“一切都是连接”,那么我们可以构建一个结构化的数据集并对其进行分析,以揭示更广泛的模式。如果 Bob 不认识 Alice,但 Bob 和 Alice 都发推文相同的 URL,我们可以从这个事实推断出一个连接。随着我们的数据集增长,其价值也将随着网络中连接的数量增加而增长(类似于梅特卡夫定律:en.wikipedia.org/wiki/Metcalfe's_law)。

当我们开始查询我们的数据集时,将很快意识到将数据存储在图数据库中的价值,因为我们可以从不断增长的连接网络中获取模式。我们进行的图分析适用于许多现实世界的用例,包括以下内容:

  • 定向广告

  • 推荐引擎

  • 情感分析

架构

我们应用程序的架构相对简单。我们将创建一个 Twitter 客户端应用程序,读取 Twitter firehose 的子集,并将每条消息作为 JSON 数据结构写入 Kafka 队列。然后,我们将使用 Kafka spout 将数据输入到我们的 storm 拓扑中。最后,我们的 storm 拓扑将分析传入的消息并填充图数据库。

架构

Twitter 客户端

Twitter 提供了一个全面的 RESTful API,除了典型的请求-响应接口外,还提供支持长连接的流 API。Twitter4J Java 库 (twitter4j.org/) 完全兼容最新版本的 Twitter API,并通过清晰的 Java API 处理所有底层细节(连接管理、OAuth 认证和 JSON 解析)。我们将使用 Twitter4J 连接到 Twitter 流 API。

Kafka spout

在前一章中,我们开发了一个 Logback Appender 扩展,使我们能够轻松地将数据发布到 Kafka 队列,并且我们使用了 Nathan Marz 的 Kafka spout (github.com/nathanmarz/storm-contrib) 来消费 Storm 拓扑中的数据。虽然使用 Twitter4J 和 Twitter 流 API 编写 Storm spout 会很容易,但使用 Kafka 和 Kafka Spout 可以给我们提供事务性、精确一次语义和内置的容错性,否则我们将不得不自己实现。有关安装和运行 Kafka 的更多信息,请参阅第四章 实时趋势分析

Titan 分布式图数据库

Titan 是一个优化用于存储和查询图结构的分布式图数据库。像 Storm 和 Kafka 一样,Titan 数据库可以作为集群运行,并且可以水平扩展以容纳不断增加的数据量和用户负载。Titan 将其数据存储在三种可配置的存储后端之一:Apache Cassandra、Apache HBase 和 Oracle Berkely 数据库。存储后端的选择取决于 CAP 定理的哪两个属性是期望的。就数据库而言,CAP 定理规定分布式系统不能同时满足以下所有保证:

  • 一致性:所有客户端看到当前数据,无论修改如何

  • 可用性:系统在节点故障时仍然按预期运行

  • 分区容错性:系统在网络或消息故障时仍然按预期运行

Titan 分布式图数据库

对于我们的用例,一致性对我们的应用程序并不重要。我们更关心的是可伸缩性和容错性。如果我们看一下 CAP 定理三角形,在前面的图中显示,就会清楚地看到 Cassandra 是首选的存储后端。

图数据库简介

图是一个对象(顶点)的网络,它们之间有定向连接(边)。下图说明了一个简单的社交图,类似于在 Twitter 上找到的图:

图数据库简介

在这个例子中,用户由顶点(节点)表示,关系表示为边(连接)。请注意,图中的边是有向的,允许额外的表达度。例如,这允许表达 Bob 和 Alice 互相关注,Alice 关注 Ted 但 Ted 不关注 Alice。如果没有有向边,这种关系将更难建模。

许多图数据库遵循属性图模型。属性图通过允许一组属性(键值对)分配给顶点和边来扩展基本图模型,如下图所示:

图数据库简介

在图模型中将属性元数据与对象和关系关联起来,为图算法和查询提供了强大的支持元数据。例如,将Follows边缘添加since属性将使我们能够有效地查询在特定年份开始关注特定用户的所有用户。

与关系数据库相比,图数据库中的关系是显式的,而不是隐式的。图数据库中的关系是完整的数据结构,而不是暗示的连接(即外键)。在底层,图数据库的基础数据结构经过了大量优化,用于图遍历。虽然在关系数据库中完全可以对图进行建模,但通常比图中心模型效率低。在关系数据模型中,遍历图结构可能会涉及连接许多表,因此计算成本高昂。在图数据库中,遍历节点之间的链接是一个更自然的过程。

访问图 - TinkerPop 堆栈

TinkerPop 是一组专注于图技术的开源项目,如数据库访问、数据流和图遍历。Blueprints 是 TinkerPop 堆栈的基础,是一个通用的 Java API,用于与属性图进行交互,方式与 JDBC 提供关系数据库的通用接口类似。堆栈中的其他项目在该基础上添加了额外的功能,以便它们可以与实现 Blueprints API 的任何图数据库一起使用。

访问图 - TinkerPop 堆栈

TinkerPop 堆栈的组件包括以下内容:

  • Blueprints:图 API Blueprints 是一组接口,提供对属性图数据模型的访问。可用于包括 Titan、Neo4J、MongoDB 等图数据库的实现。

  • Pipes:数据流处理管道是一个用于定义和连接各种数据操作的数据流框架。使用 Pipes 的基本操作与 Storm 中的数据处理非常相似。Pipes 数据流是有向无环图DAG),就像 Storm 拓扑结构一样。

  • Gremlin:Gremlin 是一种图遍历语言。它是用于图遍历、查询、分析和操作的基于 Java 的领域特定语言DSL)。Gremlin 分发版附带了一个基于 Groovy 的 shell,允许对 Blueprints 图进行交互式分析和修改。

  • Frames:Frames 是一个对象到图映射框架,类似于 ORM,但专为图设计。

  • Furnace:Furnace 项目旨在为 Blueprints 属性图提供许多常见图算法的实现。

  • Rexster:Rexster 是一个通过 REST API 和二进制协议公开 Blueprints 图的图服务器。

对于我们的目的,我们将专注于使用 Blueprints API 从 Storm 拓扑中填充图以及使用 Gremlin 进行图查询和分析。

使用 Blueprints API 操作图

Blueprints API 非常简单。以下代码清单使用 Blueprints API 创建了前面图表中所示的图:

    Graph graph = new TinkerGraph();

    Vertex bob = graph.addVertex(null);
    bob.setProperty("name", "Bob");
    bob.setProperty("born", 1980);
    bob.setProperty("state", "Vermont");

    Vertex alice = graph.addVertex(null);
    alice.setProperty("name", "Alice");
    alice.setProperty("born", 1965);
    alice.setProperty("state", "New York");

    Vertex ted = graph.addVertex(null);
    ted.setProperty("name", "Ted");
    ted.setProperty("born", 1970);
    ted.setProperty("state", "Texas");

    Edge bobToAlice = graph.addEdge(null, bob, alice, "Follows");
    bobToAlice.setProperty("since", 2012);

    Edge aliceToBob = graph.addEdge(null, alice, bob, "Follows");
    aliceToBob.setProperty("since", 2011);

    Edge aliceToTed = graph.addEdge(null, alice, ted, "Follows");
    aliceToTed.setProperty("since", 2010);

    graph.shutdown();

代码的第一行实例化了com.tinkerpop.blueprints.Graph接口的实现。在这种情况下,我们创建了一个内存中的玩具图(com.tinkerpop.blueprints.impls.tg.TinkerGraph)进行探索。稍后,我们将演示如何连接到分布式图数据库。

提示

您可能想知道为什么我们将null作为参数传递给addVertex()addEdge()方法的第一个参数。这个参数实质上是对底层 Blueprints 实现提供对象的唯一 ID 的建议。将null作为 ID 传递只是让底层实现为新对象分配一个 ID。

使用 Gremlin shell 操作图

Gremlin 是建立在 Pipes 和 Blueprints API 之上的高级 Java API。除了 Java API 外,Gremlin 还包括基于 Groovy 的 API,并附带一个交互式 shell(或 REPL),允许您直接与 Blueprints 图交互。Gremlin shell 允许您创建和/或连接到 shell,并查询几乎任何 Blueprints 图。以下代码清单说明了执行 Gremlin shell 的过程:

./bin/gremlin.sh

         \,,,/
         (o o)
-----oOOo-(_)-oOOo-----
gremlin>
gremlin> g.V('name', 'Alice').outE('Follows').count()
==>2

除了查询图之外,使用 Gremlin 还可以轻松创建和操作图。以下代码清单包括将创建与前面图示相同的图的 Gremlin Groovy 代码,是 Java 代码的 Groovy 等价物:

g = new TinkerGraph()
bob = g.addVertex()
bob.name = "Bob"
bob.born = 1980
bob.state = "Vermont"
alice = g.addVertex()
alice.name = "Alice"
alice.born=1965
alice.state = "New York"
ted = g.addVertex()
ted.name = "Ted"
ted.born = 1970
ted.state = "Texas"
bobToAlice = g.addEdge(bob, alice, "Follows")
bobToAlice.since = 2012
aliceToBob = g.addEdge(alice, bob, "Follows")
aliceToBob.since = 2011
aliceToTed = g.addEdge(alice, ted, "Follows")
aliceToTed.since = 2010

一旦我们构建了一个拓扑图来填充图并准备好分析图数据,您将在本章后面学习如何使用 Gremlin API 和 DSL。

软件安装

我们正在构建的应用程序将利用 Apache Kafka 及其依赖项(Apache ZooKeeper)。如果您还没有这样做,请根据第二章中“ZooKeeper 安装”部分的说明设置 ZooKeeper 和 Kafka,以及第四章中“安装 Kafka”部分的说明,进行配置风暴集群和实时趋势分析。

Titan 安装

要安装 Titan,请从 Titan 的下载页面(github.com/thinkaurelius/titan/wiki/Downloads)下载 Titan 0.3.x 完整包,并使用以下命令将其提取到方便的位置:

wget http://s3.thinkaurelius.com/downloads/titan/titan-all-0.3.2.zip
unzip titan-all-0.3.2.zip

Titan 的完整分发包包括运行 Titan 所需的一切支持的存储后端:Cassandra、HBase 和 BerkelyDB。如果您只对使用特定存储后端感兴趣,还有特定于后端的分发。

注意

Storm 和 Titan 都使用 Kryo(code.google.com/p/kryo/)库进行 Java 对象序列化。在撰写本文时,Storm 和 Titan 使用不同版本的 Kryo 库,这将在两者同时使用时引起问题。

为了正确启用 Storm 和 Titan 之间的序列化,需要对 Titan 进行补丁,将 Titan 分发中的kryo.jar文件替换为 Storm 提供的kryo.jar文件:

cd titan-all-0.3.2/lib
rm kryo*.jar
cp $STORM_HOME/lib/kryo*.jar ./

此时,您可以通过运行 Gremlin shell 来测试安装:

$ cd titan
$ ./bin/gremlin.sh
 \,,,/
 (o o)
-----oOOo-(_)-oOOo-----
gremlin> g = GraphOfTheGodsFactory.create('/tmp/storm-blueprints')
==>titangraph[local:/tmp/storm-blueprints]
gremlin> g.V.map
==>{name=saturn, age=10000, type=titan}
==>{name=sky, type=location}
==>{name=sea, type=location}
==>{name=jupiter, age=5000, type=god}
==>{name=neptune, age=4500, type=god}
==>{name=hercules, age=30, type=demigod}
==>{name=alcmene, age=45, type=human}
==>{name=pluto, age=4000, type=god}
==>{name=nemean, type=monster}
==>{name=hydra, type=monster}
==>{name=cerberus, type=monster}
==>{name=tartarus, type=location}
gremlin>

GraphOfTheGodsFactory是 Titan 中包含的一个类,它将使用样本图创建和填充一个 Titan 数据库,该图表示罗马万神殿中角色和地点之间的关系。将目录路径传递给create()方法将返回一个 Blueprints 图实现,具体来说是一个使用 BerkelyDB 和 Elasticsearch 组合作为存储后端的com.thinkaurelius.titan.graphdb.database.StandardTitanGraph实例。由于 Gremlin shell 是一个 Groovy REPL,我们可以通过查看g变量的类轻松验证这一点:

gremlin> g.class.name
==>com.thinkaurelius.titan.graphdb.database.StandardTitanGraph

设置 Titan 以使用 Cassandra 存储后端

我们已经看到 Titan 支持不同的存储后端。探索所有三个选项超出了本章的范围(您可以在thinkaurelius.github.io/titan/了解有关 Titan 及其配置选项的更多信息),因此我们将专注于使用 Cassandra(cassandra.apache.org)存储后端。

安装 Cassandra

为了下载和运行 Cassandra,我们需要执行以下命令:

wget http://www.apache.org/dyn/closer.cgi?path=/cassandra/1.2.9/apache-cassandra-1.2.9-bin.tar.gz
tar -zxf ./cassandra-1.2.9.bin.tar.gz
cd cassandra-1.2.9
./bin/cassandra -f

Cassandra 分发的默认文件将创建一个在本地运行的单节点 Cassandra 数据库。如果在启动过程中出现错误,您可能需要通过编辑${CASSANDRA_HOME}/conf/cassandra.yaml和/或${CASSANDRA_HOME}/conf/log4j-server.properties文件来配置 Cassandra。最常见的问题通常与在/var/lib/cassandra(默认情况下,Cassandra 存储其数据的位置)和/var/log/cassandra(默认 Cassandra 日志位置)上缺乏文件写入权限有关。

使用 Cassandra 后端启动 Titan

要使用 Cassandra 运行 Titan,我们需要配置它连接到我们的 Cassandra 服务器。创建一个名为storm-blueprints-cassandra.yaml的新文件,内容如下:

storage.backend=cassandra
storage.hostname=localhost

正如你可能推测的那样,这配置 Titan 连接到本地运行的 Cassandra 实例。

注意

对于这个项目,我们可能不需要实际运行 Titan 服务器。由于我们使用的是 Cassandra,Storm 和 Gremlin 应该能够在没有任何问题的情况下共享后端。

有了 Titan 后端配置,我们准备创建我们的数据模型。

图数据模型

我们数据模型中的主要实体是 Twitter 用户。当发布一条推文时,Twitter 用户可以执行以下关系形成的操作:

  • 使用一个单词

  • 提及一个标签

  • 提及另一个用户

  • 提及 URL

  • 转推另一个用户

图数据模型

这个概念非常自然地映射到图模型中。在模型中,我们将有四种不同的实体类型(顶点):

  • 用户:这代表了一个 Twitter 用户账户

  • 单词:这代表推文中包含的任何单词

  • URL:这代表推文中包含的任何 URL

  • 标签:这代表推文中包含的任何标签

关系(边)将包括以下操作:

  • 提及用户:使用此操作,用户提及另一个用户

  • 转推用户:使用此操作,用户转推另一个用户的帖子

  • 关注用户:使用此操作,用户关注另一个用户

  • 提及标签:使用此操作,用户提及一个标签

  • 使用单词:使用此操作,用户在推文中使用特定的单词

  • 提及 URL:使用此操作,用户推文特定的 URL

用户顶点模拟了用户的 Twitter 账户信息,如下表所示:

用户 [顶点]
类型
用户
名称
位置

URL 顶点提供了唯一 URL 的参考点:

URL [顶点]
类型

标签顶点允许我们存储唯一的标签:

标签 [顶点]
类型

我们在单词顶点中存储单个单词:

单词 [顶点]
类型

提及用户边用于用户对象之间的关系:

提及用户 [边]
用户

提及 URL边表示用户和 URL 对象之间的关系:

提及 URL [边]
用户

连接到 Twitter 流

为了连接到 Twitter API,我们必须首先生成一组 OAuth 令牌,这将使我们的应用程序能够与 Twitter 进行身份验证。这是通过创建一个与您的账户关联的 Twitter 应用程序,然后授权该应用程序访问您的账户来完成的。如果您还没有 Twitter 账户,请立即创建一个并登录。登录到 Twitter 后,按照以下步骤生成 OAuth 令牌:

  1. 前往dev.twitter.com/apps/new,如果需要,请登录。

  2. 为你的应用程序输入一个名称和描述。

  3. 在我们的情况下,输入一个应用程序的 URL 是不重要的,因为我们不是在创建一个像移动应用程序那样会被分发的应用程序。在这里输入一个占位符 URL 是可以的。

  4. 提交表单。下一页将显示您的应用程序的 OAuth 设置的详细信息。请注意消费者密钥消费者密钥的值,因为我们需要这些值用于我们的应用程序。

  5. 在页面底部,点击创建我的访问令牌按钮。这将生成一个 OAuth 访问令牌和一个密钥,允许应用程序代表您访问您的帐户。我们也需要这些值用于我们的应用程序。不要分享这些值,因为它们会允许其他人以您的身份进行认证。

设置 Twitter4J 客户端

Twitter4J 客户端被分解为许多不同的模块,可以根据我们的需求组合在一起。对于我们的目的,我们需要core模块,它提供了基本功能,如 HTTP 传输、OAuth 和对基本 Twitter API 的访问。我们还将使用stream模块来访问流 API。这些模块可以通过添加以下 Maven 依赖项包含在项目中:

    <dependency>
      <groupId>org.twitter4j</groupId>
      <artifactId>twitter4j-core</artifactId>
      <version>3.0.3</version>
    </dependency>
    <dependency>
      <groupId>org.twitter4j</groupId>
      <artifactId>twitter4j-stream</artifactId>
      <version>3.0.3</version>
    </dependency>

OAuth 配置

默认情况下,Twitter4J 将在类路径中搜索twitter4j.properties文件,并从该文件加载 OAuth 令牌。这样做的最简单方法是在 Maven 项目的resources文件夹中创建该文件。将之前生成的令牌添加到这个文件中:

oauth.consumerKey=[your consumer key]
oauth.consumerSecret=[your consumer secret]
oauth.accessToken=[your access token]
oauth.accessTokenSecret=[your access token secret]

我们现在准备使用 Twitter4J 客户端连接到 Twitter 的流 API,实时消费推文。

TwitterStreamConsumer 类

我们的 Twitter 客户端的目的很简单;它将执行以下功能:

  • 连接到 Twitter 流 API

  • 请求通过一组关键字过滤的推文流

  • 根据状态消息创建一个 JSON 数据结构

  • 将 JSON 数据写入 Kafka 以供 Kafka spout 消费

TwitterStreamConsumer类的main()方法创建一个TwitterStream对象,并注册StatusListener的一个实例作为监听器。StatusListener接口用作异步事件处理程序,每当发生与流相关的事件时就会通知它:

    public static void main(String[] args) throws TwitterException, IOException {

        StatusListener listener = new TwitterStatusListener();
        TwitterStream twitterStream = new TwitterStreamFactory().getInstance();
        twitterStream.addListener(listener);

        FilterQuery query = new FilterQuery().track(args);
        twitterStream.filter(query);

    }

注册监听器后,我们创建一个FilterQuery对象来根据一组关键字过滤流。为了方便起见,我们使用程序参数作为关键字列表,因此过滤条件可以很容易地从命令行更改。

TwitterStatusListener 类

TwitterStatusListener类在我们的应用程序中承担了大部分的重活。StatusListener类定义了几个回调方法,用于在流的生命周期中可能发生的事件。我们主要关注onStatus()方法,因为这是每当有新推文到达时调用的方法。以下是TwitterStatusListener类的代码:

    public static class TwitterStatusListener implements StatusListener {
        public void onStatus(Status status) {

            JSONObject tweet = new JSONObject();
            tweet.put("user", status.getUser().getScreenName());
            tweet.put("name", status.getUser().getName());
            tweet.put("location", status.getUser().getLocation());
            tweet.put("text", status.getText());

            HashtagEntity[] hashTags = status.getHashtagEntities();
            System.out.println("# HASH TAGS #");
            JSONArray jsonHashTags = new JSONArray();
            for (HashtagEntity hashTag : hashTags) {
                System.out.println(hashTag.getText());
                jsonHashTags.add(hashTag.getText());
            }
            tweet.put("hashtags", jsonHashTags);

            System.out.println("@ USER MENTIONS @");
            UserMentionEntity[] mentions = status.getUserMentionEntities();
            JSONArray jsonMentions = new JSONArray();
            for (UserMentionEntity mention : mentions) {
                System.out.println(mention.getScreenName());
                jsonMentions.add(mention.getScreenName());
            }
            tweet.put("mentions", jsonMentions);

            URLEntity[] urls = status.getURLEntities();
            System.out.println("$ URLS $");
            JSONArray jsonUrls = new JSONArray();
            for (URLEntity url : urls) {
                System.out.println(url.getExpandedURL());
                jsonUrls.add(url.getExpandedURL());
            }
            tweet.put("urls", jsonUrls);

            if (status.isRetweet()) {
                JSONObject retweetUser = new JSONObject();
                retweetUser.put("user", status.getUser().getScreenName());
                retweetUser.put("name", status.getUser().getName());
                retweetUser.put("location", status.getUser().getLocation());
                tweet.put("retweetuser", retweetUser);
            }
            KAFKA_LOG.info(tweet.toJSONString());
        }

        public void onDeletionNotice(StatusDeletionNotice statusDeletionNotice) {
        }

        public void onTrackLimitationNotice(int numberOfLimitedStatuses) {

            System.out.println("Track Limitation Notice: " + numberOfLimitedStatuses);
        }

        public void onException(Exception ex) {
            ex.printStackTrace();
        }

        public void onScrubGeo(long arg0, long arg1) {
        }

        public void onStallWarning(StallWarning arg0) {

        }
    }

除了状态消息的原始文本之外,Status对象还包括方便的方法,用于访问所有相关的元数据,例如包含在推文中的用户信息、标签、URL 和用户提及。我们的onStatus()方法的大部分内容在最终通过 Logback Kafka Appender 将其记录到 Kafka 队列之前构建 JSON 结构。

Twitter 图拓扑

Twitter 图拓扑将从 Kafka 队列中读取原始推文数据,解析出相关信息,然后在 Titan 图数据库中创建节点和关系。我们将使用 Trident 的事务机制实现一个 trident 状态实现,以便批量执行持久性操作,而不是为每个接收到的元组单独写入图数据库。

这种方法提供了几个好处。首先,对于支持事务的图数据库,比如 Titan,我们可以利用这个能力提供额外的一次性处理保证。其次,它允许我们执行批量写入,然后进行批量提交(如果支持)来处理整个批处理的元组,而不是对每个单独的元组进行写入提交操作。最后,通过使用通用的 Blueprints API,我们的 Trident 状态实现将在很大程度上对基础图数据库实现保持不可知,从而可以轻松地替换任何 Blueprints 图数据库后端。

Twitter graph topology

拓扑的第一个组件包括我们在第七章中开发的JSONProjectFunction集成 Druid 进行金融分析,它简单地解析原始 JSON 数据,提取我们感兴趣的信息。在这种情况下,我们主要关注消息的时间戳和 Twitter 状态消息的 JSON 表示。

JSONProjectFunction 类

以下是一个解释JSONProjectFunction类的代码片段:

public class JsonProjectFunction extends BaseFunction {

    private Fields fields;

    public JsonProjectFunction(Fields fields) {
        this.fields = fields;
    }

    public void execute(TridentTuple tuple, TridentCollector collector) {
        String json = tuple.getString(0);
        Map<String, Object> map = (Map<String, Object>) JSONValue.parse(json);
        Values values = new Values();
        for (int i = 0; i < this.fields.size(); i++) {
            values.add(map.get(this.fields.get(i)));
        }
        collector.emit(values);
    }

}

实现 GraphState

拓扑的核心将是一个 Trident 状态实现,负责将 Trident 元组转换为图结构并将其持久化。回想一下,Trident 状态实现由三个组件组成:

  • StateFactoryStateFactory接口定义了 Trident 用来创建持久State对象的方法。

  • State:Trident State接口定义了在 Trident 批处理分区写入到后端存储之前和之后调用的beginCommit()commit()方法。如果写入成功(即,所有元组都被处理而没有错误),Trident 将调用commit()方法。

  • StateUpdaterStateUpdater接口定义了updateState()方法,用于更新状态,假设有一批元组。Trident 将三个参数传递给这个方法:要更新的State对象,代表批处理的TridentTuple对象列表,以及可以用来可选地发出额外元组的TridentCollector实例作为状态更新的结果。

除了 Trident 提供的这些抽象,我们还将介绍两个额外的接口,支持任何 Blueprints 图数据库的使用(GraphFactory),并隔离任何特定用例的业务逻辑(GraphTupleProcessor)。在深入研究 Trident 状态实现之前,让我们快速看一下这些接口。

GraphFactory

GraphFactory接口的合同很简单:给定一个代表风暴和拓扑配置的Map对象,返回一个com.tinkerpop.blueprints.Graph实现。

GraphFactory.java
public interface GraphFactory {
    public Graph makeGraph(Map conf);
}

这个接口允许我们通过提供makeGraph()方法的实现来简单地插入任何兼容 Blueprints 的图实现。稍后,我们将实现这个接口,返回到 Titan 图数据库的连接。

GraphTupleProcessor

GraphTupleProcessor接口在 Trident 状态实现和任何特定用例的业务逻辑之间提供了一个抽象。

public interface GraphTupleProcessor {

    public void process(Graph g, TridentTuple tuple, TridentCollector collector);

}

给定一个图对象、TridentTupleTridentCollector,操作图并可选择发出额外的元组是GraphTupleProcessor的工作。在本章后面,我们将实现这个接口,根据 Twitter 状态消息的内容填充图。

GraphStateFactory

Trident 的StateFactory接口代表了状态实现的入口点。当使用状态组件的 Trident 拓扑(通过Stream.partitionPersist()Stream.persistentAggregate()方法)初始化时,Storm 调用StateFactory.makeState()方法为每个批处理分区创建一个状态实例。批处理分区的数量由流的并行性确定。Storm 通过numPartitionspartitionIndex参数将这些信息传递给makeState()方法,允许状态实现在必要时执行特定于分区的逻辑。

在我们的用例中,我们不关心分区,所以makeState()方法只是使用GraphFactory实例来实例化一个用于构建GraphState实例的Graph实例。

GraphStateFactory.java
public class GraphStateFactory implements StateFactory {

    private GraphFactory factory;

    public GraphStateFactory(GraphFactory factory){
        this.factory = factory;
    }

    public State makeState(Map conf, IMetricsContext metrics, int partitionIndex, int numPartitions) {
        Graph graph = this.factory.makeGraph(conf);
        State state = new GraphState(graph);
        return state;
    }

}

GraphState

我们的GraphState类提供了State.beginCommit()State.commit()方法的实现,当批处理分区即将发生和成功完成时将被调用。在我们的情况下,我们重写commit()方法来检查内部的Graph对象是否支持事务,如果是,就调用TransactionalGraph.commit()方法来完成事务。

注意

如果在 Trident 批处理中出现故障并且批处理被重播,State.beginCommit()方法可能会被多次调用,而State.commit()方法只会在所有分区状态更新成功完成时被调用一次。

GraphState类的代码片段如下:

GraphState.java
public class GraphState implements State {

    private Graph graph;

    public GraphState(Graph graph){
        this.graph = graph;
    }

    @Override
    public void beginCommit(Long txid) {}

    @Override
    public void commit(Long txid) {
        if(this.graph instanceof TransactionalGraph){
            ((TransactionalGraph)this.graph).commit();
        }
    }

    public void update(List<TridentTuple> tuples, TridentCollector collector, GraphTupleProcessor processor){
        for(TridentTuple tuple : tuples){
            processor.process(this.graph, tuple, collector);
        }
    }

}

GraphState.update()方法在调用State.beginCommit()State.commit()方法之间进行事务的核心处理。如果update()方法对所有批处理分区都成功,Trident 事务将完成,并且将调用State.commit()方法。

请注意,实际更新图状态的update()方法只是GraphState类的一个公共方法,而不是被覆盖。正如您将看到的,我们将有机会在我们的StateUpdater实现中直接调用这个方法。

GraphUpdater

GraphUpdater类实现了 Storm 将调用的updateState()方法(在批处理失败/重播的情况下可能会重复调用)。StateUpdater.updateState()方法的第一个参数是我们用来调用GraphState.update()方法的 Java 泛型类型实例。

GraphUpdater.java
public class GraphUpdater extends BaseStateUpdater<GraphState> {

    private GraphTupleProcessor processor;

    public GraphUpdater(GraphTupleProcessor processor){
        this.processor = processor;
    }

    public void updateState(GraphState state, List<TridentTuple> tuples, TridentCollector collector) {
        state.update(tuples, collector, this.processor);
    }

}

实现 GraphFactory

我们之前定义的GraphFactory接口创建了一个 TinkerPop 图实现,其中Map对象表示了一个 Storm 配置。以下代码说明了如何创建由 Cassandra 支持的TitanGraph

TitanGraphFactory.java
public class TitanGraphFactory implements GraphFactory {

    public static final String STORAGE_BACKEND = "titan.storage.backend";
    public static final String STORAGE_HOSTNAME = "titan.storage.hostname";

    public Graph makeGraph(Map conf) {
        Configuration graphConf = new BaseConfiguration();
        graphConf.setProperty("storage.backend", conf.get(STORAGE_BACKEND));
        graphConf.setProperty("storage.hostname", conf.get(STORAGE_HOSTNAME));

        return TitanFactory.open(graphConf);
    }
}

实现 GraphTupleProcessor

为了用从 Twitter 状态消息中获取的关系填充图数据库,我们需要实现GraphTupleProcessor接口。以下代码说明了解析 Twitter 状态消息的 JSON 对象并创建带有"mentions"关系的"user""hashtag"顶点。

TweetGraphTupleProcessor.java
public class TweetGraphTupleProcessor implements GraphTupleProcessor {
    @Override
    public void process(Graph g, TridentTuple tuple, TridentCollector collector) {
        Long timestamp = tuple.getLong(0);
        JSONObject json = (JSONObject)tuple.get(1);

        Vertex user = findOrCreateUser(g, (String)json.get("user"), (String)json.get("name"));

        JSONArray hashtags = (JSONArray)json.get("hashtags");
        for(int i = 0; i < hashtags.size(); i++){
            Vertex v = findOrCreateVertex(g, "hashtag", ((String)hashtags.get(i)).toLowerCase());
            createEdgeAtTime(g, user, v, "mentions", timestamp);
        }

    }
}

将所有内容放在一起 - TwitterGraphTopology 类

创建我们的最终拓扑包括以下步骤:

  • 从 Kafka 喷嘴中消耗原始 JSON

  • 提取和投影我们感兴趣的数据

  • 构建并连接 Trident 的GraphState实现到我们的流

TwitterGraphTopology 类

让我们详细看一下 TwitterGraphTopology 类。

public class TwitterGraphTopology {
    public static StormTopology buildTopology() {
        TridentTopology topology = new TridentTopology();

        StaticHosts kafkaHosts = StaticHosts.fromHostString(Arrays.asList(new String[] { "localhost" }), 1);
        TridentKafkaConfig spoutConf = new TridentKafkaConfig(kafkaHosts, "twitter-feed");
        spoutConf.scheme = new StringScheme();
        spoutConf.forceStartOffsetTime(-2);
        OpaqueTridentKafkaSpout spout = new OpaqueTridentKafkaSpout(spoutConf);

        Stream spoutStream = topology.newStream("kafka-stream", spout);

        Fields jsonFields = new Fields("timestamp", "message");
        Stream parsedStream = spoutStream.each(spoutStream.getOutputFields(), new JsonProjectFunction(jsonFields), jsonFields);
        parsedStream = parsedStream.project(jsonFields);
        // Trident State
        GraphFactory graphFactory = new TitanGraphFactory();
        GraphUpdater graphUpdater = new GraphUpdater(new TweetGraphTupleProcessor());

        StateFactory stateFactory = new GraphStateFactory(graphFactory);
        parsedStream.partitionPersist(stateFactory, parsedStream.getOutputFields(), graphUpdater, new Fields());

        return topology.build();
    }

    public static void main(String[] args) throws Exception {
        Config conf = new Config();
        conf.put(TitanGraphFactory.STORAGE_BACKEND, "cassandra");
        conf.put(TitanGraphFactory.STORAGE_HOSTNAME, "localhost");

        conf.setMaxSpoutPending(5);
        if (args.length == 0) {
            LocalCluster cluster = new LocalCluster();
            cluster.submitTopology("twitter-analysis", conf, buildTopology());

        } else {
            conf.setNumWorkers(3);
            StormSubmitter.submitTopology(args[0], conf, buildTopology());
        }
    }
}

要运行应用程序,首先执行TwitterStreamConsumer类,传入您想要用来查询 Twitter firehose 的关键字列表。例如,如果我们想要构建一个讨论大数据的用户图,我们可以使用bigdatahadoop作为查询参数:

java TwitterStreamConsumer bigdata hadoop

TwitterStreamConsumer类将连接到 Twitter Streaming API 并开始将数据排队到 Kafka。运行TwitterStreamConsumer应用程序后,我们可以部署TwitterGraphTopology来开始填充 Titan 数据库。

TwitterStreamConsumerTwitterGraphTopology运行一段时间。根据查询使用的关键词的流行程度,数据集可能需要一些时间才能增长到一个有意义的水平。然后我们可以使用 Gremlin shell 连接到 Titan 来分析图查询中的数据。

使用 Gremlin 查询图形

要查询图形,我们需要启动 Gremlin shell 并创建连接到本地 Cassandra 后端的TitanGraph实例:

$ cd titan
$ ./bin/gremlin.sh
          \,,,/
         (o o)
-----oOOo-(_)-oOOo-----
gremlin> conf = new BaseConfiguration()
gremlin> conf.setProperty('storage.backend', 'cassandra')
gremlin> conf.setProperty('storage.hostname', 'localhost')
gremlin> g = TitanFactory.open(conf)

g变量现在包含一个我们可以使用来发出图遍历查询的Graph对象。以下是一些示例查询,您可以使用它们来开始:

  • 要查找所有发推#hadoop 标签的用户,并显示他们这样做的次数,请使用以下代码:
gremlin> g.V('type', 'hashtag').has('value', 'hadoop').in.userid.groupCount.cap

  • 要计算#hadoop 标签被发推文的次数,请使用以下代码:
gremlin> g.V.has('type', 'hashtag').has('value', 'java').inE.count()

Gremlin DSL 非常强大;覆盖完整 API 可能需要填满整整一章(甚至一本整书)。要进一步探索 Gremlin 语言,我们鼓励您探索以下在线文档:

总结

在本章中,我们通过创建一个监视 Twitter firehose 子集并将信息持久化到 Titan 图数据库以供进一步分析的拓扑图,向您介绍了图数据库。我们还演示了通过使用早期章节的通用构建块(如 Logback Kafka appender)来重复使用通用组件。

虽然图数据库并非适用于每种用例,但它们代表了您多语言持久性工具库中的强大武器。多语言持久性是一个经常用来描述涉及多种数据存储类型(如关系型、键值、图形、文档等)的软件架构的术语。多语言持久性是关于为正确的工作选择正确的数据库。在本章中,我们向您介绍了图形数据模型,并希望激发您探索图形可能是支持特定用例的最佳数据模型的情况。在本书的后面,我们将创建一个 Storm 应用程序,将数据持久化到多个数据存储中,每个存储都有特定的目的。

第六章:人工智能

在之前的章节中,我们看到了一种模式,它将使用 Storm 进行实时分析与使用 Hadoop 进行批处理相结合。在本章中,我们将朝着另一个方向前进。我们将把 Storm 纳入一个操作系统中,这个系统必须实时响应最终用户的查询。

Storm 的典型应用集中在永无止境的数据流上。数据通常被排队,并由持久拓扑尽可能快地处理。系统包括一个队列,以容纳不同数量的负载。在轻负载时,队列为空。在重负载时,队列将保留数据以供以后处理。

即使是未经训练的眼睛也会认识到这样的系统并不能提供真正的实时数据处理。Storm 监视元组超时,但它专注于 spout 发出数据后元组的处理时间。

为了更完全地支持实时场景,必须从接收数据到响应交付的时间监控超时和服务级别协议(SLA)。如今,请求通常通过基于 HTTP 的 API 接收,并且响应时间 SLA 必须在亚秒级别。

HTTP 是一种同步协议。它经常引入一个像队列这样的异步机制,使系统变得复杂,并引入额外的延迟。因此,当通过 HTTP 公开功能和函数时,我们通常更喜欢与涉及的组件进行同步集成。

在本章中,我们将探讨 Storm 在暴露 Web 服务 API 的架构中的位置。具体来说,我们将构建世界上最好的井字游戏人工智能(AI)系统。我们的系统将包括同步和异步子系统。系统的异步部分将不断工作,探索游戏状态的最佳选项。同步组件公开了一个 Web 服务接口,根据游戏状态返回可能的最佳移动。

本章涵盖以下主题:

  • Storm 中的递归

  • 分布式远程过程调用(DRPC)

  • 分布式读写前范式

为我们的用例设计

人工智能世界的“hello world”是井字游戏。遵循传统,我们也将以此作为我们的主题游戏,尽管架构和方法远远超出了这个简单的例子(例如,全球热核战争;对于其他用例,请参考约翰·巴德姆的《战争游戏》)。

井字游戏是一个 X 和 O 的两人游戏。棋盘是一个 3 x 3 的网格。一个玩家有符号 O,另一个有符号 X,并且轮流进行。在一个回合中,玩家将他们的符号放在网格中的任何空单元格中。如果通过放置他们的符号,完成了三个连续符号的水平、垂直或对角线,那个玩家就赢了。如果所有单元格都填满了而没有形成三个连线,那么游戏就是平局。

为交替轮次的游戏开发人工智能程序的常见方法是递归地探索游戏树,寻找对当前玩家评估最佳的游戏状态(或对对手更糟糕的状态)。游戏树是一个节点为游戏状态的树结构。节点的直接子节点是通过从该节点的游戏状态进行合法移动而可以达到的游戏状态。

井字游戏的一个示例游戏树如下图所示:

为我们的用例设计

遍历游戏树寻找最佳移动的最简单算法是极小化极大化(Minimax)算法。该算法对每个棋盘进行递归评分,并返回找到的最佳分数。对于这个算法,我们假设对手的好分数对于当前玩家来说是坏分数。因此,该算法实际上在最大化和最小化当前棋盘的分数之间交替。极小化极大化算法可以用以下伪代码总结:

miniMax (board, depth, maximizing)
   if (depth <= 0) 
      return score (board)
   else
      children = move(board)
      if (maximizing)
         bestValue = -∞
      for (child : children)
         value = miniMax (child, depth-1, false)
         if (value > bestValue)
            bestValue = value
         end
end
return bestValue
      else // minimizing
         bestValue = ∞
      for (child : children)
         value = miniMax (child, depth-1, false)
         if (value < bestValue)
            bestValue = value
         end
end
return bestValue
end
end

客户端使用游戏状态、深度和布尔变量调用算法,该变量指示算法是否应该寻求最大化或最小化得分。在我们的用例中,游戏状态由棋盘完全封装,棋盘是一个部分填充有 X 和 O 的 3 x 3 网格。

该算法是递归的。代码的前几行是基本情况。这确保了算法不会无休止地递归。这取决于深度变量。在交替轮次的游戏中,深度表示算法应该探索多少轮。

在我们的用例中,风暴拓扑结构不需要跟踪深度。我们将让风暴拓扑结构无休止地探索(或直到从“移动”方法返回没有新棋盘为止)。

通常,每个玩家都会被分配一定的时间,并且必须在规定的时间内进行移动。由于我们更可能有焦躁不安的人类玩家与人工智能竞争,让我们假设系统需要在 200 毫秒内做出响应。

在算法检查基本情况之后,它调用“move()”方法,该方法返回所有可能移动的棋盘。然后算法循环遍历所有可能的子棋盘。如果最大化,算法找到导致最高得分的子棋盘。如果最小化,算法找到导致最低得分的棋盘。

提示

Negamax 算法通过交替得分的符号更简洁地实现了相同的目标。此外,在现实场景中,我们可能会应用 Alpha-Beta 剪枝,该剪枝试图修剪探索的树的分支。算法只考虑落在阈值内的分支。在我们的用例中,这是不必要的,因为搜索空间小到足以完全探索。

在我们简单的用例中,可以枚举整个游戏树。在更复杂的游戏中,比如国际象棋,游戏树是无法枚举的。在极端情况下,比如围棋,专家们已经计算出合法棋盘的数量超过 2 x 10170。

Minimax 算法的目标是遍历游戏树并为每个节点分配得分。在我们的风暴拓扑结构中,对于任何非叶节点的得分只是其后代的最大值(或最小值)。对于叶节点,我们必须将游戏状态解释为相应的得分。在我们简单的用例中,有三种可能的结果:我们赢了,对手赢了,或者游戏是平局。

然而,在我们的同步系统中,我们很可能在到达叶节点之前就用完了时间。在这种情况下,我们需要根据当前棋盘状态计算得分。评分启发式通常是开发 AI 应用程序最困难的部分。

对于我们简单的用例,我们将通过考虑网格中的线来计算任何棋盘的得分。有八条线需要考虑:三条水平线,三条垂直线和两条对角线。每条线根据以下表格对得分有贡献:

状态 得分
--- ---
当前玩家三排一个 +1000
当前玩家两排一个 +10
当前玩家一排一个 +1
对手三排一个 -1000
对手两排一个 -10
对手一排一个 -1

前面的表格仅在线中剩余的单元格为空时适用。虽然有改进前面的启发式,但对于这个例子来说已经足够了。而且,由于我们希望风暴能够持续处理我们的游戏树,我们希望不要太依赖启发式。相反,我们将直接依赖叶子得分的最小值(或最大值),这将始终是赢(+1000),输(-1000)或平局(0)。

最后,有了方法、算法和评分函数,我们就能继续进行架构和设计了。

建立架构

审查前面的算法,有许多有趣的设计和架构考虑,特别是考虑到 Storm 当前的状态。该算法需要递归。我们还需要一种同步处理请求的方法。Storm 中的递归是一个不断发展的话题,虽然 Storm 提供了一种与拓扑同步交互的方法,但结合对递归的需求,这带来了一些独特和有趣的挑战。

审查设计挑战

最初,原生 Storm 提供了一种服务异步过程调用的机制。这个功能就是分布式远程过程调用DRPC)。DRPC 允许客户端通过直接向拓扑提交数据来向拓扑发出请求。使用 DRPC,一个简单的 RPC 客户端充当 spout。

随着 Trident 的出现,DRPC 在原生 Storm 中已经被弃用,现在只在 Trident 中得到官方支持。

尽管已经进行了一些探索性工作,探讨了递归/非线性 DRPC,这正是我们在这里需要的,但这并不是一个主流功能(groups.google.com/forum/#!topic/storm-user/hk3opTiv3Kc)。

此外,这项工作将依赖于 Storm 中已弃用的类。因此,我们需要找到替代手段来创建一个递归结构,而不依赖于 Storm。

一旦我们找到一种构造来实现递归,我们需要能够同步调用相同的功能。寻求利用 Storm 提供的功能意味着将 DRPC 调用纳入我们的架构中。

实现递归

如果我们将我们的算法直接映射到 Storm 构造中,我们会期望一种允许流将数据反馈到自身的方法。我们可以想象一个类似以下逻辑数据流的拓扑:

实现递归

BoardSpout函数在currentBoard字段中发出一个棋盘(例如,3 x 3 数组),并使用名为parents的第二个字段来存储所有父节点。parents字段最初将为空。

isLeaf过滤器决定这是否是一个结束状态(例如,胜利、失败或平局)。如果currentBoard字段不是一个结束状态,GenerateBoards函数会发出所有新的棋盘,用子棋盘替换currentBoard字段的值,并将currentBoard字段添加到parents字段中的节点列表中。GenerateBoards函数可以通过 spout 将元组发回,也可以直接进入isLeaf过滤器,绕过 spout。

如果isLeaf过滤器确定这是一个结束状态,我们需要对currentBoard字段进行评分,然后更新所有父节点以反映新的分数。ScoreFunction计算棋盘的得分,并将其持久化到GameTree State中。

为了更新父节点,我们遍历每个父节点,并查询该节点的当前最大值(或最小值)。如果子节点的得分是新的最大值(或最小值),那么我们将持久化新值。

提示

这只是一个逻辑数据流。构建这样的拓扑不仅是不可能的,而且基于以下部分描述的原因也不建议这样做。

您已经可以看到,这个数据流并不像我们的伪代码那样直接。在 Trident 和 Storm 中有一些约束,这些约束迫使我们引入额外的复杂性,而且并非所有在数据流中表达的操作都在 Storm/Trident 中可用。让我们更仔细地检查这个数据流。

访问函数的返回值

首先,注意到我们被迫维护自己的调用堆栈,以父节点列表的形式,因为 Storm 和 Trident 没有任何机制可以访问拓扑中下游函数的结果。在经典递归中,递归方法调用的结果立即在函数内部可用,并且可以并入该方法的结果。因此,前面的数据流类似于对问题的更迭方法。

不可变元组字段值

其次,在前面的数据流中,我们调用了一个神奇的能力来替换字段的值。我们在GenerateBoards函数中进行了递归发出。用新的棋盘替换currentBoard字段是不可能的。此外,将currentBoard字段添加到父节点列表中将需要更新parents字段的值。在 Trident 中,元组是不可变的。

前期字段声明

为了解决元组的不可变性,我们可以始终向元组添加额外的字段——每个递归层都要添加一个字段——但 Trident 要求在部署之前声明所有字段。

递归中的元组确认

在考虑这个数据流中的元组确认时,我们还有其他问题。在什么时候确认触发处理的初始元组?从逻辑数据流的角度来看,直到该节点的所有子节点都被考虑并且游戏树状态反映了这些分数之前,初始元组都不应该被确认。然而,计算任何非平凡游戏的大部分游戏树子部分的处理时间很可能会超过任何元组超时。

输出到多个流

拓扑的另一个问题是从isLeaf过滤器发出的多条路径。目前,在 Trident 中没有办法在多个流中输出。增强功能可以在issues.apache.org/jira/browse/STORM-68找到。

正如我们将看到的,您可以通过在两个流上分叉并将决策作为过滤器影响这一点。

写入前读取

最后,因为我们无法访问返回值,更新父节点分数需要一个读取前写入的范式。这在任何分布式系统中都是一种反模式。以下序列图演示了在缺乏锁定机制的情况下读取前写入构造中出现的问题:

写入前读取

在上图中,有两个独立操作的线程。在我们的用例中,当多个子节点同时完成并尝试同时解析父节点的最大分数时,就会发生这种情况。

第一个线程正在解析子节点的分数为7。第二个线程正在解析子节点的分数为15。它们都在解析同一个节点。在过程结束时,新的最大值应该是15,但由于线程之间没有协调,最大分数变成了7

第一个线程读取节点的当前最大分数,返回5。然后,第二个线程从状态中读取,也收到5。两个线程将当前最大值与它们各自的子节点分数进行比较,并用新值更新最大值。由于第二个线程的更新发生在第一个之后,结果是父节点的最大值不正确。

在下一节中,我们将看到如何正确解决前面的约束,以产生一个功能性的系统。

解决挑战

为了适应前面部分概述的约束,我们将拓扑分成两部分。第一个拓扑将执行实际的递归。第二个拓扑将解析分数。这在下图中显示:

解决挑战

系统分为两个拓扑:“递归拓扑”和“评分拓扑”。递归拓扑尝试枚举系统中的所有棋盘。评分拓扑尝试对递归拓扑枚举的所有棋盘进行评分。

为了影响递归,我们在系统中引入了两个队列。第一个队列,“工作队列”,包含我们需要访问的节点列表。递归拓扑通过“工作喷口”从该队列中获取。如果节点不是叶子节点,拓扑将排队子节点的棋盘。工作队列上的消息格式如下:

(board, parents[])

每个board都是一个 3x3 的数组。parents数组包含所有父棋盘。

如果节点是叶子节点,棋盘将使用相同的消息格式排队到“评分队列”上。评分拓扑通过“评分喷口”从评分队列中读取。评分函数对节点进行评分。棋盘必然是叶子节点,因为这是排队进行评分的唯一类型的节点。然后,评分函数对当前节点和每个父节点发出一个元组。

然后我们需要更新状态。由于我们之前概述的竞争条件,查询和写入范式被封装在一个函数中。在接下来的设计中,我们将演示如何适应读写之前引入的竞争条件。

然而,在我们继续设计之前,请注意,因为我们引入了队列,我们清楚地划定了可以确认元组的线路。在第一个拓扑中,当以下情况之一为真时,元组被确认:

  • 拓扑已经枚举并排队了节点的后代

  • 拓扑已经将节点排队进行评分

在第二个拓扑中,当当前棋盘及其所有父节点都已更新以反映叶子节点中的值时,元组被确认。

还要注意的是,在处理过程中我们不需要引入新的字段或改变现有字段。第一个拓扑中使用的唯一字段是boardparents。第二个拓扑相同,但添加了一个额外的字段来捕获分数。

还要注意,我们分叉了从工作喷口出来的流。这是为了适应我们不能从单个函数中发出多个流的事实。相反,GenerateBoardsIsEndGame都必须确定游戏是否已经结束并做出相应反应。在GenerateBoards中,元组被过滤以避免无限递归。在IsEndGame中,元组被传递以进行评分。当函数能够发出到不同的流时,我们将能够将此函数合并为一个单一的“决策”过滤器,选择元组应该继续的流。

实施架构

现在让我们深入了解实现的细节。为了举例说明,以下代码假设拓扑在本地运行。我们使用内存队列而不是持久队列,并使用哈希映射作为我们的存储机制。在真正的生产实现中,我们很可能会使用诸如 Kafka 之类的持久队列系统和诸如 Cassandra 之类的分布式存储机制。

数据模型

我们将深入研究每个拓扑,但首先,让我们看看数据模型。为了简化,我们将游戏逻辑和数据模型封装到两个类中:BoardGameState

以下是Board类的列表:

public class Board implements Serializable {
public static final String EMPTY = ' ';
   public String[][] board = { { EMPTY, EMPTY, EMPTY },
{ EMPTY, EMPTY, EMPTY }, { EMPTY, EMPTY, EMPTY } };

public List<Board> nextBoards(String player) {
        List<Board> boards = new ArrayList<Board>();
        for (int i = 0; i < 3; i++) {
            for (int j = 0; j < 3; j++) {
                if (board[i][j].equals(EMPTY)) {
                    Board newBoard = this.clone();
                    newBoard.board[i][j] = player;
                    boards.add(newBoard);
                }
            }
        }
        return boards;
    }

    public boolean isEndState() {
        return (nextBoards('X').size() == 0 
|| Math.abs(score('X')) > 1000);
    }

    public int score(String player){
        return scoreLines(player) – 
            scoreLines(Player.next(player));
    }

    public int scoreLines(String player) {
        int score = 0;
        // Columns
        score += scoreLine(board[0][0], board[1][0], board[2][0], player);
        score += scoreLine(board[0][1], board[1][1], board[2][1], player);
        score += scoreLine(board[0][2], board[1][2], board[2][2], player);

        // Rows
        score += scoreLine(board[0][0], board[0][1], board[0][2], player);
        score += scoreLine(board[1][0], board[1][1], board[1][2], player);
        score += scoreLine(board[2][0], board[2][1], board[2][2], player);

       // Diagonals
        score += scoreLine(board[0][0], board[1][1], board[2][2], player);
        score += scoreLine(board[2][0], board[1][1], board[0][2], player);
        return score;
    }

    public int scoreLine(String pos1, String pos2, String pos3, String player) {
        int score = 0;
        if (pos1.equals(player) && pos2.equals(player) && pos3.equals(player)) {
            score = 10000;
        } else if ((pos1.equals(player) && pos2.equals(player) && pos3.equals(EMPTY)) ||
                (pos1.equals(EMPTY) && pos2.equals(player) && pos3.equals(player)) ||
                (pos1.equals(player) && pos2.equals(EMPTY) && pos3.equals(player))) {
            score = 100;
        } else {
            if (pos1.equals(player) && pos2.equals(EMPTY) && pos3.equals(EMPTY) ||
                    pos1.equals(EMPTY) && pos2.equals(player) && pos3.equals(EMPTY) ||
                    pos1.equals(EMPTY) && pos2.equals(EMPTY) && pos3.equals(player)){
                score = 10;
            }
        }
        return score;
    }
...
    public String toKey() {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < 3; i++) {
            for (int j = 0; j < 3; j++) {
                sb.append(board[i][j]);
            }
        }
        return sb.toString();
    }
}

Board类提供了三个主要函数。Board类封装了棋盘本身,作为成员变量以多维字符串数组的形式存在。然后它提供将生成子棋盘的函数(例如,nextBoards()),确定游戏是否已经结束(例如,isEndState()),最后,提供一个计算棋盘得分的方法,当提供了一个玩家时(例如,nextBoards(player)及其支持方法)。

还要注意Board类提供了一个toKey()方法。这个键唯一地表示了棋盘,这是我们在访问我们的持久性机制时将使用的唯一标识符。在这种情况下,唯一标识符只是棋盘网格中的值的串联。

为了完全表示游戏状态,我们还需要知道当前轮到哪个玩家。因此,我们有一个封装了棋盘和当前玩家的高级对象。这是GameState对象,其清单如下所示:

public class GameState implements Serializable {
private Board board;
    private List<Board> history;
    private String player;

...

    public String toString(){
        StringBuilder sb = new StringBuilder('GAME [');
        sb.append(board.toKey()).append(']');
        sb.append(': player(').append(player).append(')\n');
        sb.append('   history [');
        for (Board b : history){
            sb.append(b.toKey()).append(',');
        }
        sb.append(']');
        return sb.toString();
    }
}

在这个类中没有什么特别令人惊讶的,除了history变量。这个成员变量跟踪了这条游戏树路径上的所有先前的棋盘状态。这是更新游戏树以获得叶节点得分所需的面包屑路径。

最后,我们用Player类表示游戏中的玩家,如下所示:

public class Player {
    public static String next(String current){
        if (current.equals('X')) return 'O';
        else return 'X';
    }
}

检查递归拓扑

有了之前概述的数据模型,我们可以创建一个递归下棋树的拓扑结构。在我们的实现中,这是RecursiveTopology类。拓扑的代码如下所示:

public class RecursiveTopology {

    public static StormTopology buildTopology() {
        LOG.info('Building topology.');
        TridentTopology topology = new TridentTopology();

        // Work Queue / Spout
        LocalQueueEmitter<GameState> workSpoutEmitter = 
new LocalQueueEmitter<GameState>('WorkQueue');
        LocalQueueSpout<GameState> workSpout = 
new LocalQueueSpout<GameState>(workSpoutEmitter);
        GameState initialState = 
new GameState(new Board(),
new ArrayList<Board>(), 'X');
        workSpoutEmitter.enqueue(initialState);

        // Scoring Queue / Spout
        LocalQueueEmitter<GameState> scoringSpoutEmitter = 
new LocalQueueEmitter<GameState>('ScoringQueue');

        Stream inputStream = 
topology.newStream('gamestate', workSpout);

        inputStream.each(new Fields('gamestate'),
new isEndGame())
                .each(new Fields('gamestate'),
                    new LocalQueuerFunction<GameState>(scoringSpoutEmitter),
 new Fields(''));

        inputStream.each(new Fields('gamestate'),
new GenerateBoards(),
new Fields('children'))
            .each(new Fields('children'),
                    new LocalQueuerFunction<GameState>(workSpoutEmitter),
                    new Fields());

        return topology.build();
    }
...
}

第一部分配置了工作和评分的内存队列。输入流是从一个单个 spout 配置的,该 spout 从工作队列中工作。这个队列被初始化为初始游戏状态。

然后将流分叉。叉的第一个叉齿仅用于终局棋盘,然后将其传递到评分队列。叉的第二个叉齿生成新的棋盘并将后代排队。

队列交互

对于这个示例实现,我们使用了内存队列。在真实的生产系统中,我们会依赖 Kafka spout。LocalQueueEmitter类的清单如下所示。请注意,队列是BlockingQueue实例的实例,位于一个映射内,将队列名称链接到BlockingQueue实例。这是一个方便的类,用于测试使用单个队列作为输入和输出的拓扑(即递归拓扑):

public class LocalQueueEmitter<T> implements Emitter<Long>, Serializable {
public static final int MAX_BATCH_SIZE=1000;
public static AtomicInteger successfulTransactions = 
new AtomicInteger(0);
    private static Map<String, BlockingQueue<Object>> queues =
 new HashMap<String, BlockingQueue<Object>>();
private static final Logger LOG = 
LoggerFactory.getLogger(LocalQueueEmitter.class);
    private String queueName;

    public LocalQueueEmitter(String queueName) {
        queues.put(queueName, new LinkedBlockingQueue<Object>());
        this.queueName = queueName;
    }

    @Override
    public void emitBatch(TransactionAttempt tx,
 Long coordinatorMeta, TridentCollector collector) {
        int size=0;
        LOG.debug('Getting batch for [' +
 tx.getTransactionId() + ']');
        while (getQueue().peek() != null && 
size <= MAX_BATCH_SIZE) {
            List<Object> values = new ArrayList<Object>();
            try {
                LOG.debug('Waiting on work from [' +
 this.queueName + ']:[' + 
getQueue().size() + ']');
                values.add(getQueue().take());
                LOG.debug('Got work from [' + 
this.queueName + ']:[' + 
getQueue().size() + ']');
            } catch (InterruptedException ex) {
                // do something smart
            }
            collector.emit(values);
            size++;
        }
        LOG.info('Emitted [' + size + '] elements in [' + 
            tx.getTransactionId() + '], [' + getQueue().size()
+ '] remain in queue.');
    }
...
    public void enqueue(T work) {
        LOG.debug('Adding work to [' + this.queueName +
 ']:[' + getQueue().size() + ']');
        if (getQueue().size() % 1000 == 0)
            LOG.info('[' + this.queueName + '] size = [' + 
			getQueue().size() + '].');
        this.getQueue().add(work);
    }

    public BlockingQueue<Object> getQueue() {
        return LocalQueueEmitter.queues.get(this.queueName);
    }
...
}

该类中的主要方法是Emitter接口的emitBatch实现。这只是在队列中有数据且未达到最大批量大小时读取。

还要注意,该类提供了一个enqueue()方法。enqueue()方法由我们的LocalQueueFunction类用于完成递归。LocalQueueFunction类的清单如下所示:

public class LocalQueuerFunction<T>  extends BaseFunction {
    private static final long serialVersionUID = 1L;
    LocalQueueEmitter<T> emitter;

    public LocalQueuerFunction(LocalQueueEmitter<T> emitter){
        this.emitter = emitter;
    }

    @SuppressWarnings('unchecked')
    @Override
    public void execute(TridentTuple tuple, TridentCollector collector) {
        T object = (T) tuple.get(0);
        Log.debug('Queueing [' + object + ']');
        this.emitter.enqueue(object);
    }
}

请注意,函数实际上是使用 spout 使用的emitter函数实例化的。这允许函数直接将数据排入 spout。同样,这种构造在开发递归拓扑时很有用,但是真实的生产拓扑很可能会使用持久存储。没有持久存储,存在数据丢失的可能性,因为元组在处理(递归)完成之前就被确认。

函数和过滤器

现在,我们将注意力转向与此拓扑特定的函数和过滤器。首先是一个简单的过滤器,用于过滤出终局棋盘。IsEndGame过滤器的代码如下所示:

public class IsEndGame extends BaseFilter {
...
    @Override
    public boolean isKeep(TridentTuple tuple) {
        GameState gameState = (GameState) tuple.get(0);
        boolean keep = (gameState.getBoard().isEndState());
        if (keep){
            LOG.debug('END GAME [' + gameState + ']');
        }
        return keep;
    }
}

请注意,如果 Trident 支持从单个函数向不同流发出元组,则此类是不必要的。在以下IsEndGame函数的清单中,它执行相同的检查/过滤功能:

public class GenerateBoards extends BaseFunction {

    @Override
    public void execute(TridentTuple tuple,
TridentCollector collector) {
        GameState gameState = (GameState) tuple.get(0);
        Board currentBoard = gameState.getBoard();
        List<Board> history = new ArrayList<Board>();
        history.addAll(gameState.getHistory());
        history.add(currentBoard);

        if (!currentBoard.isEndState()) {
            String nextPlayer = 
			Player.next(gameState.getPlayer());
            List<Board> boards = 
			gameState.getBoard().nextBoards(nextPlayer);
            Log.debug('Generated [' + boards.size() + 
'] children boards for [' + gameState.toString() +
']');
            for (Board b : boards) {
                GameState newGameState = 
new GameState(b, history, nextPlayer);
                List<Object> values = new ArrayList<Object>();
                values.add(newGameState);
                collector.emit(values);
            }
        } else {
            Log.debug('End game found! [' + currentBoard + ']');
        }
    }
}

该函数将当前棋盘添加到历史列表中,然后排队一个新的GameState对象,带有子棋盘位置。

提示

或者,我们可以将IsEndGame实现为一个函数,添加另一个字段来捕获结果;然而,使用这个作为一个例子来激励函数内部具有多个流能力更有建设性。

以下是递归拓扑的示例输出:

2013-12-30 21:53:40,940-0500 | INFO [Thread-28] IsEndGame.isKeep(20) | END GAME [GAME [XXO X OOO]: player(O)
   history [         ,      O  ,    X O  ,    X OO ,X   X OO ,X O X OO ,XXO X OO ,]]
2013-12-30 21:53:40,940-0500 | INFO [Thread-28] IsEndGame.isKeep(20) | END GAME [GAME [X OXX OOO]: player(O)
   history [         ,      O  ,    X O  ,    X OO ,X   X OO ,X O X OO ,X OXX OO ,]]
2013-12-30 21:53:40,940-0500 | INFO [Thread-28] LocalQueueEmitter.enqueue(61) | [ScoringQueue] size = [42000]

检查评分拓扑

评分拓扑结构更直接,因为它是线性的。复杂的方面是状态的更新,以避免读写竞争条件。

拓扑结构的代码如下:

public static StormTopology buildTopology() {
TridentTopology topology = new TridentTopology();

GameState exampleRecursiveState =
 GameState.playAtRandom(new Board(), 'X');
LOG.info('SIMULATED STATE : [' + exampleRecursiveState + ']');

// Scoring Queue / Spout
LocalQueueEmitter<GameState> scoringSpoutEmitter = 
new LocalQueueEmitter<GameState>('ScoringQueue');
scoringSpoutEmitter.enqueue(exampleRecursiveState);
LocalQueueSpout<GameState> scoringSpout = 
new LocalQueueSpout<GameState>(scoringSpoutEmitter);

Stream inputStream = 
topology.newStream('gamestate', scoringSpout);

inputStream.each(new Fields('gamestate'), new IsEndGame())
                .each(new Fields('gamestate'),
                        new ScoreFunction(),
                        new Fields('board', 'score', 'player'))
                .each(new Fields('board', 'score', 'player'), 
new ScoreUpdater(), new Fields());
return topology.build();
}

只有两个函数:ScoreFunctionScoreUpdaterScoreFunction 为历史上的每个棋盘评分并发出该得分。

ScoreFunction 的列表如下代码片段所示:

public class ScoreFunction extends BaseFunction {

@Override
public void execute(TridentTuple tuple, 
TridentCollector collector) {
        GameState gameState = (GameState) tuple.get(0);
        String player = gameState.getPlayer();
        int score = gameState.score();

        List<Object> values = new ArrayList<Object>();
        values.add(gameState.getBoard());
        values.add(score);
        values.add(player);
        collector.emit(values);

        for (Board b : gameState.getHistory()) {
            player = Player.next(player);
            values = new ArrayList<Object>();
            values.add(b);
            values.add(score);
            values.add(player);
            collector.emit(values);
        }
    }
}

该函数简单地为当前棋盘评分并为当前棋盘发出一个元组。然后,该函数循环遍历玩家,为每个棋盘发出元组,并在每轮中交换玩家。

最后,我们有ScoreUpdater 函数。同样,我们为示例保持简单。以下是该类的代码:

public class ScoreUpdater extends BaseFunction {
...
private static final Map<String, Integer> scores =
 new HashMap<String, Integer>();
private static final String MUTEX = 'MUTEX';

@Override
public void execute(TridentTuple tuple,
TridentCollector collector) {
    Board board = (Board) tuple.get(0);
    int score = tuple.getInteger(1);
    String player = tuple.getString(2);
    String key = board.toKey();
    LOG.debug('Got (' + board.toKey() + ') => [' + score +
 '] for [' + player + ']');

    // Always compute things from X's perspective
    // We'll flip things when we interpret it if it is O's turn.
    synchronized(MUTEX){
         Integer currentScore = scores.get(key);
         if (currentScore == null ||
(player.equals('X') && score > currentScore)){
                updateScore(board, score);
            } else if (player.equals('O') &&
score > currentScore){
                updateScore(board, score);
            }
        }
    }

    public void updateScore(Board board, Integer score){
        scores.put(board.toKey(), score);
        LOG.debug('Updating [' + board.toString() + 
']=>[' + score + ']');
    }
}

解决读写问题

请注意,在前面的代码中,我们使用互斥锁来对得分的更新进行排序,从而消除了之前提到的竞争条件。这仅在我们在单个/本地 JVM 中运行时才有效。当此拓扑结构部署到真实集群时,这将不起作用;但是,我们有一些选项来解决这个问题。

分布式锁定

正如我们在其他章节中看到的,可以利用分布式锁定机制,例如 ZooKeeper。在这种方法中,ZooKeeper 提供了一种在多个主机之间维护互斥锁的机制。这当然是一种可行的方法,但分布式锁定会带来性能成本。每个操作都会产生开销,以适应现实中可能是不经常发生的情况。

过时时重试

可能有用的另一种模式是过时时重试方法。在这种情况下,除了数据之外,我们还会拉回一个版本号、时间戳或校验和。然后,我们执行条件更新,包括版本/时间戳/校验和信息在一个子句中,如果元数据发生了变化(例如,在 SQL/CQL 范式中将WHERE子句添加到UPDATE语句中),则更新将失败。如果元数据发生了变化,表示我们基于的值现在已经过时,我们应该重新选择数据。

显然,这些方法之间存在权衡。在重试中,如果存在大量争用,一个线程可能需要重试多次才能提交更新。然而,使用分布式锁定时,如果单个线程被卡住、与服务器失去通信或完全失败,可能会遇到超时问题。

提示

最近,在这个领域已经有了一些进展。我建议您查看 Paxos 和 Cassandra 在以下 URL 中使用该算法来影响条件更新:

在我们的简单情况中,我们非常幸运,实际上可以直接将逻辑合并到更新中。考虑以下 SQL 语句:

UPDATE gametree SET score=7 WHERE
boardkey = '000XX OXX' AND score <=7;

由于我们已经解决了读写问题,拓扑结构适合对递归拓扑结构排队的所有棋盘进行评分。该拓扑结构为终局状态分配一个值,并将该值传播到游戏树上,将适当的得分与相应的游戏状态持久化。在真实的生产系统中,我们将从 DRPC 拓扑结构访问该状态,以便能够提前多回合。

执行拓扑结构

以下是评分拓扑结构的示例输出:

2013-12-31 13:19:14,535-0500 | INFO [main] ScoringTopology.buildTopology(29) | SIMULATED LEAF NODE : [
---------
|X||O||X|
---------
|O||O||X|
---------
|X||X||O|
---------
] w/ state [GAME [XOXOOXXXO]: player(O)
 history [         ,  X      , OX      , OX  X   , OX  X  O, OX  XX O, OXO XX O, OXO XXXO, OXOOXXXO,]]
2013-12-31 13:19:14,536-0500 | INFO [main] LocalQueueEmitter.enqueue(61) | [ScoringQueue] size = [0].
2013-12-31 13:19:14,806-0500 | INFO [main] ScoringTopology.main(52) | Topology submitted.
2013-12-31 13:19:25,566-0500 | INFO [Thread-24] DefaultCoordinator.initializeTransaction(25) | Initializing Transaction [1]
2013-12-31 13:19:25,570-0500 | DEBUG [Thread-30] LocalQueueEmitter.emitBatch(37) | Getting batch for [1]
2013-12-31 13:19:25,570-0500 | DEBUG [Thread-30] LocalQueueEmitter.emitBatch(41) | Waiting on work from [ScoringQueue]:[1]
2013-12-31 13:19:25,570-0500 | DEBUG [Thread-30] LocalQueueEmitter.emitBatch(43) | Got work from [ScoringQueue]:[0]
2013-12-31 13:19:25,571-0500 | DEBUG [Thread-30] LocalQueueEmitter.emitBatch(41) | Waiting on work from [ScoringQueue]:[0]
2013-12-31 13:19:25,571-0500 | INFO [Thread-28] IsEndGame.isKeep(20) | END GAME [GAME [XOXOOXXXO]: player(O)
 history [         ,  X      , OX      , OX  X   , OX  X  O, OX  XX O, OXO XX O, OXO XXXO, OXOOXXXO,]]
...
 ScoreUpdater.updateScore(43) | Updating [
---------
| ||O||X|
---------
|O|| ||X|
---------
|X||X||O|
---------
]=>[0]
2013-12-31 13:19:25,574-0500 | DEBUG [Thread-28] ScoreUpdater.execute(27) | Got ( OXOOXXXO) => [0] for [X]
2013-12-31 13:19:25,574-0500 | DEBUG [Thread-28] ScoreUpdater.updateScore(43) | Updating [
---------
| ||O||X|
---------
|O||O||X|
---------
|X||X||O|
---------
]=>[0]

它正在解决列表开头显示的平局叶节点。之后,您可以看到该值在那之后通过父节点传播,更新这些节点的当前得分。

枚举游戏树

将递归拓扑与评分拓扑相结合的最终结果是一组拓扑不断协作,以尽可能多地枚举问题空间。很可能,这个过程将与启发式算法相结合,只存储关键节点。此外,我们将使用启发式算法修剪搜索空间,以减少我们需要评估的板的数量。然而,无论如何,我们都需要通过接口与系统进行交互,以确定在当前游戏状态下的最佳移动。这将是我们下一节要解决的问题。

分布式远程过程调用(DRPC)

现在我们有一个功能正常的递归拓扑,它将不断寻求计算整个游戏树,让我们来看看同步调用。Storm 提供的 DRPC 功能已被移植到 Trident,并在 Storm 中已被弃用。这是在本例中使用 Trident 的主要动机。

使用 DRPC,您构建拓扑的方式与异步情况下的方式非常相似。以下图表显示了我们的 DRPC 拓扑:

分布式远程过程调用(DRPC)

DRPC 客户端充当一个喷口。客户端的输出经过ArgsFunction,它规范化输入,以便我们可以重用现有的函数:GenerateBoardsScoreFunction。然后,我们使用.groupBy(state)并使用Aggregator类的FindBestMove来聚合结果。然后,我们执行一个简单的投影,只将最佳移动返回给客户端。

提示

您可能还想看一下 Spring Breeze,它允许您将 POJO 连接到 Storm 拓扑中。这是另一种获得重用的方法,因为这些相同的 POJO 可以通过 Web 服务公开而不引入 DRPC。

github.com/internet-research-network/breeze

首先,我们将看一下拓扑的代码:

public static void main(String[] args) throws Exception {
final LocalCluster cluster = new LocalCluster();
final Config conf = new Config();

LocalDRPC client = new LocalDRPC();
TridentTopology drpcTopology = new TridentTopology();

drpcTopology.newDRPCStream('drpc', client)
                .each(new Fields('args'),
new ArgsFunction(),
new Fields('gamestate'))
                .each(new Fields('gamestate'),
new GenerateBoards(),
new Fields('children'))
                .each(new Fields('children'),
new ScoreFunction(),
new Fields('board', 'score', 'player'))
                .groupBy(new Fields('gamestate'))
                .aggregate(new Fields('board', 'score'),
new FindBestMove(), new Fields('bestMove'))
                .project(new Fields('bestMove'));

cluster.submitTopology('drpcTopology', conf,
         drpcTopology.build());

Board board = new Board();
board.board[1][1] = 'O';
board.board[2][2] = 'X';
board.board[0][1] = 'O';
board.board[0][0] = 'X';
LOG.info('Determining best move for O on:' + 
               board.toString());
LOG.info('RECEIVED RESPONSE [' + 
client.execute('drpc', board.toKey()) + ']');
}

对于这个例子,我们使用了一个LocalDRPC客户端。这作为newDRPCStream调用的参数传入,这是 DRPC 拓扑的关键。从那里开始,拓扑函数就像一个普通的拓扑一样运行。

通过client.execute()方法,您可以看到实际的远程过程调用发生。目前,该方法的签名仅接受和返回字符串。有一个未解决的增强请求来更改这个签名。您可以在issues.apache.org/jira/browse/STORM-42找到该增强请求。

由于当前签名只接受字符串,我们需要对输入进行编组。这发生在ArgsFunction中,如下面的代码片段所示:

    @Override
    public void execute(TridentTuple tuple, 
TridentCollector collector) {
        String args = tuple.getString(0);
        Log.info('Executing DRPC w/ args = [' + args + ']');
        Board board = new Board(args);
        GameState gameState = 
new GameState(board, new ArrayList<Board>(), 'X');
        Log.info('Emitting [' + gameState + ']');

        List<Object> values = new ArrayList<Object>();
        values.add(gameState);
        collector.emit(values);
    }

我们对client.execute()的调用的第二个参数是一个包含我们输入的字符串。在这种情况下,您可以在拓扑代码中看到我们传入了板的键。这是一个 3x3 的网格,其中单元格被串联为一个字符串。为了将该字符串编组为一个板,我们向Board类添加了一个解析字符串为板的构造函数,如下面的代码片段所示:

    public Board(String key) {
        for (int i = 0; i < 3; i++) {
            for (int j = 0; j < 3; j++) {
                this.board[i][j] = '' + key.charAt(i*3+j);
            }
        }
    }

在 DRPC 拓扑中应用的下两个函数演示了通过利用 DRPC 作为同步接口可以实现的重用。在这种情况下,我们是独立利用这些函数,但您可以想象您也可以重用更复杂的数据流。

使用GenerateBoard函数,我们发出当前板的所有子板。然后,ScoreFunction对每个板进行评分。

与评分拓扑一样,ScoreFunction的输出是boardscoreplayer的三元组。这些是每个子板的分数。为了确定我们的下一个最佳移动,我们只需要最大化(或最小化)这个值。这可以通过一个简单的Aggregator来实现。我们创建了一个名为FindBestMove的聚合函数,如下面的代码片段所示:

public class FindBestMove extends BaseAggregator<BestMove> {
    private static final long serialVersionUID = 1L;

    @Override
    public BestMove init(Object batchId, 
TridentCollector collector) {
        Log.info('Batch Id = [' + batchId + ']');
        return new BestMove();
    }

    @Override
    public void aggregate(BestMove currentBestMove, 
TridentTuple tuple, TridentCollector collector) {  
        Board board = (Board) tuple.get(0);
        Integer score = tuple.getInteger(1);
        if (score > currentBestMove.score){
            currentBestMove.score = score;
            currentBestMove.bestMove = board;
        }
    }

    @Override
    public void complete(BestMove bestMove, 
TridentCollector collector) {
        collector.emit(new Values(bestMove));        
    }

}

这个聚合扩展了BaseAggregator,它是一个 Java 泛型。在这种情况下,我们希望发出最佳的移动,结合它的得分。因此,我们使用BestMove类参数化BaseAggregator类,它的简单定义如下:

public class BestMove {
    public Board bestMove;
    public Integer score = Integer.MIN_VALUE;

    public String toString(){
        return bestMove.toString() + '[' + score + ']';
    }
}

如果你回忆一下,对于聚合,Trident 最初调用init()方法,该方法返回初始的聚合值。在我们的情况下,我们只是用最差的移动来初始化BestMove类。注意BestMove类的得分变量被初始化为绝对最小值。然后,Trident 调用aggregate()方法,允许函数将元组合并到聚合值中。聚合也可以在这里发出值,但由于我们只关心最终的最佳移动,所以我们不从aggregate()方法中发出任何东西。最后,Trident 在所有元组的值都被聚合后调用complete()方法。在这个方法中,我们发出最终的最佳移动。

以下是拓扑结构的输出:

2013-12-31 13:53:42,979-0500 | INFO [main] DrpcTopology.main(43) | Determining best move for O on:
---------
|X||O|| |
---------
| ||O|| |
---------
| || ||X|
---------

00:00  INFO: Executing DRPC w/ args = [XO  O   X]
00:00  INFO: Emitting [GAME [XO  O   X]: player(X)
 history []]
00:00  INFO: Batch Id = [storm.trident.spout.RichSpoutBatchId@1e8466d2]
2013-12-31 13:53:44,092-0500 | INFO [main] DrpcTopology.main(44) | RECEIVED RESPONSE [[[
---------
|X||O|| |
---------
| ||O|| |
---------
| ||O||X|
---------
[10000]]]]

在这个例子中,轮到 O 方了,他或她有一个得分机会。你可以看到拓扑正确地识别了得分机会,并将其作为最佳移动返回(带有适当的得分值)。

远程部署

我们展示的是 DRPC 拓扑的本地调用。要调用远程拓扑,你需要启动 DRPC 服务器。你可以通过执行带有drpc参数的 Storm 脚本来实现这一点,如下面的代码片段所示:

bin/storm drpc

Storm 集群将连接到 DRPC 服务器接收调用。为了做到这一点,它需要知道 DRPC 服务器的位置。这些位置在storm.yaml文件中指定如下:

drpc.servers: 
- 'drpchost1 ' 
- 'drpchost2'

配置好服务器并启动 DRPC 服务器后,拓扑就像任何其他拓扑一样被提交,DRPC 客户端可以从任何需要大规模同步分布式处理的 Java 应用程序中使用。要从本地 DRPC 客户端切换到远程客户端,唯一需要更改的是 DRPC 客户端的实例化。你需要使用以下行:

DRPCClient client = new DRPCClient('drpchost1', 3772);

这些参数指定了 DRPC 服务器的主机和端口,并应与 YAML 文件中的配置匹配。

总结

在本章中,我们处理了一个人工智能用例。在这个领域中有许多问题利用了树和图数据结构,而对于这些数据结构最合适的算法通常是递归的。为了演示这些算法如何转化为 Storm,我们使用了 Minimax 算法,并使用 Storm 的构造实现了它。

在这个过程中,我们注意到了 Storm 中的一些约束条件,使得它比预期的更加复杂,我们也看到了能够绕过这些约束条件并产生可工作/可扩展系统的模式和方法。

此外,我们介绍了 DRPC。DRPC 可以用于向客户端公开同步接口。DRPC 还允许设计在同步和异步接口之间重用代码和数据流。

将同步和异步拓扑与共享状态结合起来,不仅对于人工智能应用而言是一个强大的模式,对于分析也是如此。通常,新数据持续在后台到达,但用户通过同步接口查询这些数据。当你将 DRPC 与其他章节介绍的 Trident 状态能力结合起来时,你应该能够构建一个能够满足实时分析用例的系统。

在下一章中,我们将 Storm 与非事务实时分析系统 Druid 集成。我们还将更深入地研究 Trident 和 ZooKeeper 的分布式状态管理。

第七章:集成 Druid 进行金融分析

在本章中,我们将扩展 Trident 的使用,创建一个实时金融分析仪表板。该系统将处理金融消息,以在不同粒度上随时间提供股票定价信息。该系统将展示与非事务性系统的集成,使用自定义状态实现。

在前面的例子中,我们使用 Trident 来统计随时间变化的事件总数。对于分析数据的简单用例来说已经足够了,但是架构设计并不灵活。要引入新的维度需要 Java 开发和部署新代码。

传统上,数据仓库技术和商业智能平台用于计算和存储维度分析。数据仓库作为On-line Analytics Processing (OLAP)系统的一部分部署,与On-line Transaction Processing (OLTP)分开。数据传播到 OLAP 系统,但通常有一定的滞后。这对于回顾性分析是足够的,但在需要实时分析的情况下不够。

同样,其他方法使用批处理技术来赋予数据科学家能力。数据科学家使用诸如 PIG 之类的语言来表达他们的查询。然后,这些查询编译成在大量数据集上运行的作业。幸运的是,它们在分布式处理的平台上运行,如 Hadoop,但这仍然引入了相当大的延迟。

这两种方法对于金融系统来说都不够,金融系统无法承受分析数据的可用性出现滞后。仅仅启动批处理作业的开销可能对金融系统实时需求造成太大延迟。

在本章中,我们将扩展我们对 Storm 的使用,以提供一个灵活的系统,只需要很少的工作就可以引入新的维度,同时提供实时分析。这意味着数据摄入和维度分析的可用性之间只有很短的延迟。

在本章中,我们将涵盖以下主题:

  • 自定义状态实现

  • 与非事务性存储的集成

  • 使用 ZooKeeper 进行分布式状态

  • Druid 和实时聚合分析

用例

在我们的用例中,我们将利用金融系统中股票订单的信息。利用这些信息,我们将随时间提供定价信息,这些信息可以通过REpresentational State Transfer (REST)接口获得。

金融行业中的规范消息格式是Financial Information eXchange (FIX)格式。该格式的规范可以在www.fixprotocol.org/找到。

一个 FIX 消息的示例如下:

23:25:1256=BANZAI6=011=135215791235714=017=520=031=032=037=538=1000039=054=155=SPY150=2151=010=2528=FIX.4.19=10435=F34=649=BANZAI52=20121105-

FIX 消息本质上是键值对流。ASCII 字符 01,即Start of Header (SOH),分隔这些键值对。FIX 将键称为标签。如前面的消息所示,标签由整数标识。每个标签都有一个关联的字段名和数据类型。要查看标签类型的完整参考,请转到www.fixprotocol.org/FIXimate3.0/en/FIX.4.2/fields_sorted_by_tagnum.html

我们用例中的重要字段显示在以下表格中:

标签 ID 字段名 描述 数据类型
11 CIOrdID 这是消息的唯一标识符。 字符串
35 MsgType 这是 FIX 消息的类型。 字符串
44 价格 这是每股股票的股价。 价格
55 符号 这是股票符号。 字符串

FIX 是 TCP/IP 协议的一层。因此,在实际系统中,这些消息是通过 TCP/IP 接收的。为了与 Storm 轻松集成,系统可以将这些消息排队在 Kafka 中。然而,在我们的示例中,我们将简单地摄取一个填满 FIX 消息的文件。FIX 支持多种消息类型。有些用于控制消息(例如,登录,心跳等)。我们将过滤掉这些消息,只传递包含价格信息的类型到分析引擎。

集成非事务系统

为了扩展我们之前的示例,我们可以开发一个配置框架,允许用户指定他们想要对事件进行聚合的维度。然后,我们可以在我们的拓扑中使用该配置来维护一组内存数据集来累积聚合,但任何内存存储都容易出现故障。为了解决容错性,我们可以将这些聚合持久存储在数据库中。

我们需要预期并支持用户想要执行的所有不同类型的聚合(例如,总和,平均,地理空间等)。这似乎是一项重大的努力。

幸运的是,有实时分析引擎的选项。一个流行的开源选项是 Druid。以下文章摘自他们在static.druid.io/docs/druid.pdf找到的白皮书:

Druid 是一个开源的、实时的分析数据存储,支持对大规模数据集进行快速的自由查询。该系统结合了列导向的数据布局、共享无内容架构和先进的索引结构,允许对十亿行表进行任意探索,延迟在亚秒级。Druid 可以水平扩展,是 Metamarkets 数据分析平台的核心引擎。

从上述摘录中,Druid 正好符合我们的要求。现在,挑战是将其与 Storm 集成。

Druid 的技术堆栈自然地适应了基于 Storm 的生态系统。与 Storm 一样,它使用 ZooKeeper 在其节点之间进行协调。Druid 还支持与 Kafka 的直接集成。对于某些情况,这可能是合适的。在我们的示例中,为了演示非事务系统的集成,我们将直接将 Druid 与 Storm 集成。

我们将在这里简要介绍 Druid。但是,有关 Druid 的更详细信息,请参阅以下网站:

github.com/metamx/druid/wiki

Druid 通过其实时节点收集信息。根据可配置的粒度,实时节点将事件信息收集到永久存储在深度存储机制中的段中。Druid 持久地将这些段的元数据存储在 MySQL 中。节点识别新段,根据规则为该段识别计算节点,并通知计算节点拉取新段。代理节点坐在计算节点前面,接收来自消费者的REST查询,并将这些查询分发给适当的计算节点。

因此,将 Storm 与 Druid 集成的架构看起来与以下图表所示的类似:

集成非事务系统

如前图所示,涉及三种数据存储机制。MySQL数据库是一个简单的元数据存储库。它包含所有段的所有元数据信息。深度存储机制包含实际的段信息。每个段包含根据配置文件中定义的维度和聚合而基于特定时间段的事件的合并索引。因此,段可以很大(例如,2GB 的 blob)。在我们的示例中,我们将使用 Cassandra 作为我们的深度存储机制。

最后,第三种数据存储机制是 ZooKeeper。ZooKeeper 中的存储是瞬态的,仅用于控制信息。当一个新的段可用时,Master 节点会在 ZooKeeper 中写入一个临时节点。Compute 节点订阅相同的路径,临时节点触发 Compute 节点拉取新的段。在成功检索段后,Compute 节点会从 ZooKeeper 中删除临时节点。

对于我们的示例,事件的整个序列如下:

集成非事务性系统

前面的图表展示了从 Storm 下游的事件处理。在许多实时分析引擎中,重要的是要认识到无法撤销事务。分析系统被高度优化以处理速度和聚合。牺牲的是事务完整性。

如果重新审视 Trident 的状态分类,有三种不同的状态:事务性、不透明和非事务性。事务状态要求每个批次的内容随时间保持不变。不透明事务状态可以容忍随时间变化的批次组合。最后,非事务状态无法保证确切的一次语义。

总结storm.trident.state.State对象的 Javadoc,有三种不同类型的状态:

非事务状态 在这种状态下,提交被忽略。无法回滚。更新是永久的。
重复事务状态 只要所有批次都是相同的,系统就是幂等的。
不透明事务状态 状态转换是增量的。在重播事件中,先前的状态与批次标识符一起存储以容忍批次组合的变化。

重要的是要意识到,将状态引入拓扑实际上会将任何写入存储的顺序化。这可能会对性能产生重大影响。在可能的情况下,最好的方法是确保整个系统是幂等的。如果所有写入都是幂等的,那么你根本不需要引入事务性存储(或状态),因为架构自然容忍元组重播。

通常,如果状态持久性由你控制架构的数据库支持,你可以调整架构以添加额外的信息来参与事务:重复事务的最后提交批次标识符和不透明事务的上一个状态。然后,在状态实现中,你可以利用这些信息来确保你的状态对象与你正在使用的 spout 类型相匹配。

然而,这并不总是适用,特别是在执行计数、求和、平均值等聚合的系统中。Cassandra 中的计数器机制正是具有这种约束。无法撤销对计数器的增加,也无法使增加幂等。如果元组被重播,计数器将再次递增,你很可能在系统中过度计数元素。因此,任何由 Cassandra 计数器支持的状态实现都被视为非事务性的。

同样,Druid 是非事务性的。一旦 Druid 消费了一个事件,该事件就无法撤销。因此,如果 Storm 中的一个批次被 Druid 部分消费,然后重新播放批次,或者组合发生变化,聚合维度分析就无法恢复。因此,考虑 Druid 和 Storm 之间的集成,以及我们可以采取的步骤来解决重播的问题,以及这种耦合的力量,这是很有趣的。

简而言之,要将 Storm 连接到 Druid,我们将利用事务 spout 的特性,以最小化连接到非事务状态机制(如 Druid)时的过度计数的风险。

拓扑结构

有了架构概念,让我们回到用例。为了将重点放在集成上,我们将保持拓扑的简单。以下图表描述了拓扑结构:

拓扑结构

FIX 喷口发出包含简单 FIX 消息的元组。然后过滤器检查消息的类型,过滤包含定价信息的股票订单。然后,这些经过过滤的元组流向DruidState对象,它是与 Druid 连接的桥梁。

这个简单拓扑的代码如下所示:

public class FinancialAnalyticsTopology {

    public static StormTopology buildTopology() {
    TridentTopology topology = new TridentTopology();
    FixEventSpout spout = new FixEventSpout();
    Stream inputStream = 
topology.newStream("message", spout);
    inputStream.each(new Fields("message"),
new MessageTypeFilter())
        .partitionPersist(new DruidStateFactory(),
new Fields("message"), new DruidStateUpdater());
    return topology.build();
    }

}

喷口

FIX 消息格式有许多解析器。在喷口中,我们将使用 FIX 解析器,这是一个 Google 项目。关于这个项目的更多信息,您可以参考code.google.com/p/fixparser/

就像前一章一样,喷口本身很简单。它只是返回一个协调器和一个发射器的引用,如下面的代码所示:

package com.packtpub.storm.trident.spout;

@SuppressWarnings("rawtypes")
public class FixEventSpout implements ITridentSpout<Long> {
    private static final long serialVersionUID = 1L;
    SpoutOutputCollector collector;
    BatchCoordinator<Long> coordinator = new DefaultCoordinator();
    Emitter<Long> emitter = new FixEventEmitter();
    ...
    @Override
    public Fields getOutputFields() {
        return new Fields("message");
    }
}

如前面的代码所示,Spout声明了一个单一的输出字段:message。这将包含Emitter生成的FixMessageDto对象,如下面的代码所示:

package com.packtpub.storm.trident.spout;

public class FixEventEmitter implements Emitter<Long>,
Serializable {
    private static final long serialVersionUID = 1L;
    public static AtomicInteger successfulTransactions = 
new AtomicInteger(0);
    public static AtomicInteger uids = new AtomicInteger(0);

    @SuppressWarnings("rawtypes")
    @Override
    public void emitBatch(TransactionAttempt tx,
    Long coordinatorMeta, TridentCollector collector) {
    InputStream inputStream = null;
    File file = new File("fix_data.txt");
    try {
        inputStream = 
new BufferedInputStream(new FileInputStream(file));
        SimpleFixParser parser = new SimpleFixParser(inputStream);
        SimpleFixMessage msg = null;
        do {
        msg = parser.readFixMessage();
        if (null != msg) {
            FixMessageDto dto = new FixMessageDto();
            for (TagValue tagValue : msg.fields()) {
                if (tagValue.tag().equals("6")) { // AvgPx
                    // dto.price = 
//Double.valueOf((String) tagValue.value());
                    dto.price = new Double((int) (Math.random() * 100));
                } else if (tagValue.tag().equals("35")) {
                    dto.msgType = (String)tagValue.value();
                } else if (tagValue.tag().equals("55")) {
                   dto.symbol = (String) tagValue.value();
                } else if (tagValue.tag().equals("11")){
                   // dto.uid = (String) tagValue.value();
                   dto.uid = Integer.toString(uids.incrementAndGet());
                }
            }
            new ObjectOutputStream(
            new ByteArrayOutputStream()).writeObject(dto);
                List<Object> message = new ArrayList<Object>();
                message.add(dto);
                collector.emit(message);
        }
    } while (msg != null);
    } catch (Exception e) {
        throw new RuntimeException(e);
    } finally {
        IoUtils.closeSilently(inputStream);
    }
    }

    @Override
    public void success(TransactionAttempt tx) {
        successfulTransactions.incrementAndGet();
    }

    @Override
    public void close() {
    }
}

从前面的代码中,您可以看到我们为每个批次重新解析文件。正如我们之前所述,在实时系统中,我们可能会通过 TCP/IP 接收消息,并将它们排队在 Kafka 中。然后,我们将使用 Kafka 喷口发出这些消息。这是一个偏好问题;但是,为了完全封装 Storm 中的数据处理,系统很可能会排队原始消息文本。在这种设计中,我们将在一个函数中解析文本,而不是在喷口中。

尽管这个“喷口”只适用于这个例子,但请注意每个批次的组成是相同的。具体来说,每个批次包含文件中的所有消息。由于我们的状态设计依赖于这一特性,在一个真实的系统中,我们需要使用TransactionalKafkaSpout

过滤器

与喷口一样,过滤器也很简单。它检查msgType对象并过滤掉不是填单的消息。填单实际上是股票购买收据。它们包含了该交易执行的平均价格和所购买股票的符号。以下代码是这种消息类型的过滤器:

package com.packtpub.storm.trident.operator;

public class MessageTypeFilter extends BaseFilter {
    private static final long serialVersionUID = 1L;

    @Override
    public boolean isKeep(TridentTuple tuple) {
        FixMessageDto message = (FixMessageDto) tuple.getValue(0);
    if (message.msgType.equals("8")) {
        return true;
    }
    return false;
    }
}

这为我们提供了一个很好的机会来指出 Storm 中可序列化性的重要性。请注意,在前面的代码中,过滤器操作的是一个FixMessageDto对象。使用SimpleFixMessage对象可能更容易,但SimpleFixMessage不可序列化。这在本地集群上运行时不会造成任何问题。然而,在 Storm 中进行数据处理时,元组在主机之间交换,元组中的所有元素都必须是可序列化的。

提示

开发人员经常对不可序列化的元组中的数据对象进行更改。这会导致下游部署问题。为了确保元组中的所有对象保持可序列化,添加一个验证对象可序列化的单元测试。这个测试很简单,使用以下代码:

new ObjectOutputStream(
new ByteArrayOutputStream()).
writeObject(YOUR_OBJECT);

状态设计

现在,让我们继续讨论这个例子最有趣的方面。为了将 Druid 与 Storm 集成,我们将在我们的拓扑中嵌入一个实时的 Druid 服务器,并实现必要的接口将元组流连接到它。为了减轻连接到非事务性系统的固有风险,我们利用 ZooKeeper 来持久化状态信息。这种持久化不会防止由于故障而导致的异常,但它将有助于确定故障发生时哪些数据处于风险之中。

高级设计如下所示:

状态设计

在高层次上,Storm 通过使用工厂在 worker JVM 进程中创建状态对象。为批次中的每个分区创建一个状态对象。状态工厂对象确保在返回任何状态对象之前,实时服务器正在运行,并在服务器未运行时启动服务器。然后状态对象缓冲这些消息,直到 Storm 调用 commit。当 Storm 调用 commit 时,状态对象解除 Druid Firehose的阻塞。这向 Druid 发送信号,表明数据已准备好进行聚合。然后,在 commit 方法中阻塞 Storm,而实时服务器通过Firehose开始拉取数据。

为了确保每个分区最多被处理一次,我们将分区标识符与每个分区关联起来。分区标识符是批次标识符和分区索引的组合,可以唯一标识一组数据,因为我们使用了事务性 spout。

Firehose将标识符持久化在ZooKeeper中以维护分区的状态。

ZooKeeper中有三种状态:

状态 描述
inProgress 这个Zookeeper路径包含了 Druid 正在处理的分区标识符。
Limbo 这个Zookeeper路径包含了 Druid 完全消耗但可能尚未提交的分区标识符。
完成 这个Zookeeper路径包含了 Druid 成功提交的分区标识符。

在处理批次时,Firehose将分区标识符写入 inProgress 路径。当 Druid 完全拉取了 Storm 分区的全部数据时,分区标识符被移动到Limbo,我们释放 Storm 继续处理,同时等待 Druid 的提交消息。

收到 Druid 的提交消息后,Firehose将分区标识符移动到Completed路径。此时,我们假设数据已写入磁盘。然而,在磁盘故障的情况下,我们仍然容易丢失数据。但是,如果我们假设可以使用批处理重建聚合,那么这很可能是可以接受的风险。

以下状态机捕捉了处理的不同阶段:

状态设计

如图所示,在缓冲消息聚合消息之间存在一个循环。主控制循环在这两种状态之间快速切换,将其时间分配给 Storm 处理循环和 Druid 聚合循环。这些状态是互斥的:系统要么在聚合一个批次,要么在缓冲下一个批次。

第三种状态是当 Druid 将信息写入磁盘时触发的。当发生这种情况(稍后我们将看到),Firehose会收到通知,我们可以更新我们的持久化机制,以指示批次已安全处理。在调用 commit 之前,Druid 消耗的批次必须保持在Limbo中。

Limbo中,不能对数据做任何假设。Druid 可能已经聚合了记录,也可能没有。

在发生故障时,Storm 可能利用其他TridentState实例来完成处理。因此,对于每个分区,Firehose必须执行以下步骤:

  1. Firehose必须检查分区是否已经完成。如果是,那么分区是一个重播,可能是由于下游故障。由于批次保证与之前相同,可以安全地忽略,因为 Druid 已经聚合了其内容。系统可能会记录警告消息。

  2. Firehose必须检查分区是否处于悬空状态。如果是这种情况,那么 Druid 完全消耗了分区,但从未调用 commit,或者在调用 commit 之后但在Firehose更新ZooKeeper之前系统失败了。系统应该发出警报。它不应该尝试完成批处理,因为它已被 Druid 完全消耗,我们不知道聚合的状态。它只是返回,使 Storm 可以继续进行下一批处理。

  3. Firehose必须检查分区是否正在进行中。如果是这种情况,那么由于某种原因,在网络的某个地方,分区正在被另一个实例处理。这在普通处理过程中不应该发生。在这种情况下,系统应该为该分区发出警报。在我们简单的系统中,我们将简单地继续进行,留待离线批处理来纠正聚合。

在许多大规模实时系统中,用户愿意容忍实时分析中的轻微差异,只要偏差不经常发生并且可以很快得到纠正。

重要的是要注意,这种方法成功的原因是我们使用了事务性 spout。事务性 spout 保证每个批次具有相同的组成。此外,为了使这种方法有效,批处理中的每个分区必须具有相同的组成。只有在拓扑中的分区是确定性的情况下才成立。有了确定性的分区和事务性 spout,即使在重放的情况下,每个分区也将包含相同的数据。如果我们使用了洗牌分组,这种方法就不起作用。我们的示例拓扑是确定性的。这保证了批处理标识符与分区索引结合表示了随时间一致的数据集。

实施架构

有了设计之后,我们可以将注意力转向实施。实施的序列图如下所示:

实施架构

前面的图实现了设计中显示的状态机。一旦实时服务器启动,Druid 使用hasMore()方法轮询StormFirehose对象。与 Druid 的合同规定,Firehose对象的实现应该在数据可用之前阻塞。当 Druid 在轮询而Firehose对象在阻塞时,Storm 将元组传递到DruidState对象的消息缓冲区中。当批处理完成时,Storm 调用DruidState对象的commit()方法。在那时,PartitionStatus 被更新。分区被放置在进行中,并且实现解除StormFirehose对象的阻塞。

Druid 开始通过nextRow()方法从StormFirehose对象中拉取数据。当StormFirehose对象耗尽分区的内容时,它将分区置于悬空状态,并将控制权释放给 Storm。

最后,当在 StormFirehose 上调用 commit 方法时,实现会返回一个Runnable,这是 Druid 用来通知 Firehose 分区已持久化的方式。当 Druid 调用run()时,实现会将分区移动到完成状态。

DruidState

首先,我们将看一下风暴方面的情况。在上一章中,我们扩展了NonTransactionalMap类以持久化状态。这种抽象使我们免受顺序批处理细节的影响。我们只需实现IBackingMap接口来支持multiGetmultiPut调用,超类就会处理其余部分。

在这种情况下,我们需要比默认实现提供的更多对持久化过程的控制。相反,我们需要自己实现基本的State接口。以下类图描述了类层次结构:

DruidState

正如图中所示,DruidStateFactory类管理嵌入式实时节点。可以提出一个论点,认为更新程序管理嵌入式服务器。然而,由于每个 JVM 应该只有一个实时服务器实例,并且该实例需要在任何状态对象之前存在,因此嵌入式服务器的生命周期管理似乎更自然地适合工厂。

以下代码片段包含了DruidStateFactory类的相关部分:

public class DruidStateFactory implements StateFactory {
    private static final long serialVersionUID = 1L;
    private static final Logger LOG = 
LoggerFactory.getLogger(DruidStateFactory.class);
    private static RealtimeNode rn = null;

    private static synchronized void startRealtime() {
    if (rn == null) {
        final Lifecycle lifecycle = new Lifecycle();
        rn = RealtimeNode.builder().build();
        lifecycle.addManagedInstance(rn);
        rn.registerJacksonSubtype(
        new NamedType(StormFirehoseFactory.class, "storm"));

        try {
            lifecycle.start();
        } catch (Throwable t) {

        }
    }
    }

    @Override
    public State makeState(Map conf, IMetricsContext metrics,
        int partitionIndex, int numPartitions) {
            DruidStateFactory.startRealtime();
            return new DruidState(partitionIndex);
    }
}

不详细介绍,前面的代码如果尚未启动实时节点,则启动一个实时节点。此外,它将StormFirehoseFactory类注册到该实时节点。

工厂还实现了来自 Storm 的StateFactory接口,允许 Storm 使用此工厂创建新的State对象。State对象本身非常简单:

public class DruidState implements State {
private static final Logger LOG = 
LoggerFactory.getLogger(DruidState.class);
private Vector<FixMessageDto> messages = 
new Vector<FixMessageDto>();
    private int partitionIndex;

public DruidState(int partitionIndex){
    this.partitionIndex = partitionIndex;
}

@Override
    public void beginCommit(Long batchId) {
}

@Override
public void commit(Long batchId) {
    String partitionId = batchId.toString() + "-" + partitionIndex;
    LOG.info("Committing partition [" + 
        partitionIndex + "] of batch [" + batchId + "]");
    try {
        if (StormFirehose.STATUS.isCompleted(partitionId)) {
        LOG.warn("Encountered completed partition [" 
            + partitionIndex + "] of batch [" + batchId 
                + "]");
        return;
    } else if (StormFirehose.STATUS.isInLimbo(partitionId)) {
        LOG.warn("Encountered limbo partition [" + partitionIndex 
                 + "] of batch [" + batchId + 
                 "] : NOTIFY THE AUTHORITIES!");
        return;
    } else if (StormFirehose.STATUS.isInProgress(partitionId)) {
              LOG.warn("Encountered in-progress partition [\" + 
              partitionIndex + \"] of batch [" + batchId + 
              "] : NOTIFY THE AUTHORITIES!");
        return;
    }
    StormFirehose.STATUS.putInProgress(partitionId);
    StormFirehoseFactory.getFirehose()
        .sendMessages(partitionId, messages);
    } catch (Exception e) {
            LOG.error("Could not start firehose for [" + 
                      partitionIndex + "] of batch [" + 
                      batchId + "]", e);
    }
    }

public void aggregateMessage(FixMessageDto message) {
    messages.add(message);
}
}

如前面的代码所示,State对象是一个消息缓冲区。它将实际的提交逻辑委托给Firehose对象,我们将很快进行检查。然而,在这个类中有一些关键的行,实现了我们之前概述的故障检测。

State对象上commit()方法中的条件逻辑检查 ZooKeeper 状态,以确定此分区是否已成功处理(inCompleted),未能提交(inLimbo)或在处理过程中失败(inProgress)。当我们检查DruidPartitionStatus对象时,我们将更深入地了解状态存储。

还要注意的是,commit()方法由 Storm 直接调用,但aggregateMessage()方法由更新程序调用。即使 Storm 不应该同时调用这些方法,我们还是选择使用线程安全的向量。

DruidStateUpdater 代码如下:

public class DruidStateUpdater implements StateUpdater<DruidState> {
...
@Override
public void updateState(DruidState state, 
List<TridentTuple> tuples, TridentCollector collector) {
for (TridentTuple tuple : tuples) {
   	   FixMessageDto message = (FixMessageDto) tuple.getValue(0);
      state.aggregateMessage(message);
   }
}
}

如前面的代码所示,更新程序只是简单地循环遍历元组,并将它们传递给状态对象进行缓冲。

实现 StormFirehose 对象

在我们转向 Druid 实现的一侧之前,我们可能应该退一步,更详细地讨论一下 Druid。Druid 的数据源是通过一个规范文件进行配置的。在我们的示例中,这是realtime.spec,如下面的代码所示:

[{
    "schema": {
        "dataSource": "stockinfo",
        "aggregators": [
            { "type": "count", "name": "orders"},
            { "type": "doubleSum", "fieldName": "price", "name":"totalPrice" }
        ],
        "indexGranularity": "minute",
        "shardSpec": {"type": "none"}
    },

    "config": {
        "maxRowsInMemory": 50000,
        "intermediatePersistPeriod": "PT30s"
    },

    "firehose": {
        "type": "storm",
        "sleepUsec": 100000,
        "maxGeneratedRows": 5000000,
        "seed": 0,
        "nTokens": 255,
        "nPerSleep": 3
    },

    "plumber": {
        "type": "realtime",
        "windowPeriod": "PT30s",
        "segmentGranularity": "minute",
        "basePersistDirectory": "/tmp/example/rand_realtime/basePersist"
    }
}]

对于我们的示例,在前面的规范文件中,重要的元素是schemafirehoseschema元素定义了数据和 Druid 应该对该数据执行的聚合。在我们的示例中,Druid 将计算我们在orders字段中看到股票符号的次数,并跟踪totalPrice字段中支付的总价格。totalPrice字段将用于计算随时间变化的股票价格平均值。此外,您需要指定一个indexGranularity对象,该对象指定索引的时间粒度。

firehose元素包含Firehose对象的配置。正如我们在StateFactory接口中看到的,实现在实时服务器启动时向 Druid 注册了一个FirehoseFactory类。该工厂被注册为Jackson子类型。当解析实时规范文件时,JSON 中firehose元素中的类型用于链接回适用于数据流的适当FirehoseFactory

有关 JSON 多态性的更多信息,请参考以下网站:

wiki.fasterxml.com/JacksonPolymorphicDeserialization

有关规范文件的更多信息,请参考以下网站:

github.com/metamx/druid/wiki/Realtime

现在,我们可以把注意力转向 Druid 实现的一侧。Firehose是必须实现的主要接口,以将数据贡献到 Druid 实时服务器中。

我们的StormFirehoseFactory类的代码如下:

@JsonTypeName("storm")
public class StormFirehoseFactory implements FirehoseFactory {
    private static final StormFirehose FIREHOSE = 
    new StormFirehose();
    @JsonCreator
    public StormFirehoseFactory() {
    }

    @Override
    public Firehose connect() throws IOException {
        return FIREHOSE;
    }

    public static StormFirehose getFirehose(){
        return FIREHOSE;
    }
}

工厂实现很简单。在这种情况下,我们只返回一个静态的单例对象。请注意,该对象带有@JsonTypeName@JsonCreator注解。如前面的代码所述,JacksonFirehoseFactory对象注册的手段。因此,@JsonTypeName指定的名称必须与规范文件中指定的类型一致。

实现的核心在StormFirehose类中。在这个类中,有四个关键方法,我们将逐一检查:hasMore()nextRow()commit()sendMessages()

sendMessages()方法是进入StormFirehose类的入口点。这实际上是 Storm 和 Druid 之间的交接点。该方法的代码如下:

public synchronized void sendMessages(String partitionId, 
                     List<FixMessageDto> messages) {
    BLOCKING_QUEUE = 
    new ArrayBlockingQueue<FixMessageDto>(messages.size(), 
    false, messages);
    TRANSACTION_ID = partitionId;
    LOG.info("Beginning commit to Druid. [" + messages.size() + 
    "] messages, unlocking [START]");
    synchronized (START) {
        START.notify();
    }
    try {
        synchronized (FINISHED) {
        FINISHED.wait();
        }
    } catch (InterruptedException e) {
        LOG.error("Commit to Druid interrupted.");
    }
    LOG.info("Returning control to Storm.");
}

该方法是同步的,以防止并发问题。请注意,它除了将消息缓冲区复制到队列中并通知hasMore()方法释放批处理外,不做任何其他操作。然后,它会阻塞等待 Druid 完全消耗批处理。

然后,流程继续到nextRow()方法,如下所示:

    @Override
    public InputRow nextRow() {
        final Map<String, Object> theMap = 
        Maps.newTreeMap(String.CASE_INSENSITIVE_ORDER);
        try {
        FixMessageDto message = null;
        message = BLOCKING_QUEUE.poll();

        if (message != null) {
        LOG.info("[" + message.symbol + "] @ [" +
         message.price + "]");
        theMap.put("symbol", message.symbol);
        theMap.put("price", message.price);
        }

        if (BLOCKING_QUEUE.isEmpty()) {
        STATUS.putInLimbo(TRANSACTION_ID);
        LIMBO_TRANSACTIONS.add(TRANSACTION_ID);
        LOG.info("Batch is fully consumed by Druid. " 
        + "Unlocking [FINISH]");
        synchronized (FINISHED) {
            FINISHED.notify();

        }
        }
    } catch (Exception e) {
        LOG.error("Error occurred in nextRow.", e);
        System.exit(-1);
    }
    final LinkedList<String> dimensions = 
    new LinkedList<String>();
    dimensions.add("symbol");
    dimensions.add("price");
    return new MapBasedInputRow(System.currentTimeMillis(), 
                                dimensions, theMap);
    }

该方法从队列中取出一条消息。如果不为空,则将数据添加到一个映射中,并作为MapBasedInputRow方法传递给 Druid。如果队列中没有剩余消息,则释放前面代码中检查的sendMessages()方法。从 Storm 的角度来看,批处理已完成。Druid 现在拥有数据。但是,从系统的角度来看,数据处于悬而未决状态,因为 Druid 可能尚未将数据持久化到磁盘。在硬件故障的情况下,我们有丢失数据的风险。

然后 Druid 将轮询hasMore()方法,如下所示:

@Override
public boolean hasMore() {
    if (BLOCKING_QUEUE != null && !BLOCKING_QUEUE.isEmpty())
        return true;
    try {
        synchronized (START) {
        START.wait();
        }
    } catch (InterruptedException e) {
        LOG.error("hasMore() blocking interrupted!");
    }
    return true;
}

由于队列为空,该方法将阻塞,直到再次调用sendMessage()

现在只剩下一个谜题的部分,commit()方法。它在以下代码中显示:

    @Override
    public Runnable commit() {
	List<String> limboTransactions = new ArrayList<String>();
	LIMBO_TRANSACTIONS.drainTo(limboTransactions);
	return new StormCommitRunnable(limboTransactions);
    }

这个方法返回Runnable,在 Druid 完成持久化消息后被调用。尽管Firehose对象中的所有其他方法都是从单个线程调用的,但Runnable是从不同的线程调用的,因此必须是线程安全的。因此,我们将悬而未决的事务复制到一个单独的列表中,并将其传递给Runnable对象的构造函数。如下代码所示,Runnable除了将事务移动到Zookeeper中的已完成状态外,什么也不做。

public class StormCommitRunnable implements Runnable {
    private List<String> partitionIds = null;

    public StormCommitRunnable(List<String> partitionIds){
        this.partitionIds = partitionIds;
    }

    @Override
    public void run() {
    try {
        StormFirehose.STATUS.complete(partitionIds);
    } catch (Exception e) {
        Log.error("Could not complete transactions.", e);
    }
}
}

在 ZooKeeper 中实现分区状态

现在我们已经检查了所有的代码,我们可以看一下状态如何在 ZooKeeper 中持久化。这使得系统能够协调分布式处理,特别是在发生故障时。

该实现利用 ZooKeeper 来持久化分区处理状态。ZooKeeper 是另一个开源项目。更多信息,请参考zookeeper.apache.org/

ZooKeeper 维护一个节点树。每个节点都有一个关联的路径,就像文件系统一样。实现使用 ZooKeeper 通过一个叫做 Curator 的框架。更多信息,请参考curator.incubator.apache.org/

通过 Curator 连接到 ZooKeeper 时,您提供一个命名空间。实际上,这是应用数据存储在其中的顶级节点。在我们的实现中,命名空间是stormdruid。然后应用在其中维护三个路径,用于存储批处理状态信息。

路径对应于设计中描述的状态,如下所示:

  • /stormdruid/current:这对应于当前状态

  • /stormdruid/limbo:这对应于悬而未决的状态

  • /stormdruid/completed:这对应于已完成的状态

在我们的实现中,所有关于分区状态的 ZooKeeper 交互都通过DruidPartitionStatus类运行。

该类的代码如下:

public class DruidBatchStatus {
    private static final Logger LOG = 
LoggerFactory.getLogger(DruidBatchStatus.class);
    final String COMPLETED_PATH = "completed";
    final String LIMBO_PATH = "limbo";
    final String CURRENT_PATH = "current";
    private CuratorFramework curatorFramework;

    public DruidBatchStatus() {
    try {
curatorFramework = 
    CuratorFrameworkFactory.builder()
    .namespace("stormdruid")
    .connectString("localhost:2181")
    .retryPolicy(new RetryNTimes(1, 1000))
    .connectionTimeoutMs(5000)
            .build();
        curatorFramework.start();

        if (curatorFramework.checkExists()
    .forPath(COMPLETED_PATH) == null) {
        curatorFramework.create().forPath(COMPLETED_PATH);
        }

    }catch (Exception e) {
        LOG.error("Could not establish connection to Zookeeper", 
                  e);
    }
    }

    public boolean isInLimbo(String paritionId) throws Exception {
        return (curatorFramework.checkExists().forPath(LIMBO_PATH + "/" + paritionId) != null);
    }

    public void putInLimbo(Long paritionId) throws Exception {
    curatorFramework.inTransaction().
        delete().forPath(CURRENT_PATH + "/" + paritionId)
        .and().create().forPath(LIMBO_PATH + "/" + 
                                paritionId).and().commit();
    }
}

出于空间考虑,我们只显示了构造函数和与 limbo 状态相关的方法。在构造函数中,客户端连接到 ZooKeeper 并创建了前面代码中描述的三个基本路径。然后,它提供了查询方法来测试事务是否正在进行中、处于 limbo 状态或已完成。它还提供了将事务在这些状态之间移动的方法。

执行实现

够了,不要再看代码了,让我们进行演示吧!我们使用FinancialAnalyticsTopology类的主方法启动拓扑。为了更好的演示,我们引入了零到一百之间的随机价格。(参考Emitter代码。)

一旦拓扑启动,您将看到以下输出:

2014-02-16 09:47:15,479-0500 | INFO [Thread-18] DefaultCoordinator.initializeTransaction(24) | Initializing Transaction [1615]
2014-02-16 09:47:15,482-0500 | INFO [Thread-22] DruidState.commit(28) | Committing partition [0] of batch [1615]
2014-02-16 09:47:15,484-0500 | INFO [Thread-22] StormFirehose.sendMessages(82) | Beginning commit to Druid. [7996] messages, unlocking [START]
2014-02-16 09:47:15,511-0500 | INFO [chief-stockinfo] StormFirehose.nextRow(58) | Batch is fully consumed by Druid. Unlocking [FINISH]
2014-02-16 09:47:15,511-0500 | INFO [Thread-22] StormFirehose.sendMessages(93) | Returning control to Storm.
2014-02-16 09:47:15,513-0500 | INFO [Thread-18] DefaultCoordinator.success(30) | Successful Transaction [1615] 

您可以从多个维度对处理进行审查。

使用 ZooKeeper 客户端,您可以检查事务的状态。看一下下面的列表;它显示了事务/批处理标识符及其状态:

[zk: localhost:2181(CONNECTED) 50] ls /stormdruid/current
[501-0]
[zk: localhost:2181(CONNECTED) 51] ls /stormdruid/limbo
[486-0, 417-0, 421-0, 418-0, 487-0, 485-0, 484-0, 452-0, ...
[zk: localhost:2181(CONNECTED) 82] ls /stormdruid/completed
[zk: localhost:2181(CONNECTED) 52] ls /stormdruid/completed
[59-0, 321-0, 296-0, 357-0, 358-0, 220-0, 355-0,

对于警报和监控,请注意以下内容:

  • 如果current路径中有多个批处理,那么应该发出警报

  • 如果limbo中有不连续的批处理标识符,或者明显落后于当前标识符,应该发出警报

要清理 ZooKeeper 中的状态,您可以执行以下代码:

zk: localhost:2181(CONNECTED) 83] rmr /stormdruid

要监控段的传播,您可以使用 MySQL 客户端。使用默认模式,您可以通过以下代码从prod_segments表中选择出段:

mysql> select * from prod_segments;

审查分析

现在,我们一直在等待的时刻到了;我们可以通过 Druid 提供的 REST API 看到随时间变化的平均股价。要使用 REST API,不需要运行一个完整的 Druid 集群。您只能查询单个嵌入式实时节点看到的数据,但每个节点都能够处理请求,这使得测试更容易。使用 curl,您可以使用以下命令查询实时节点:

curl -sX POST "http://localhost:7070/druid/v2/?pretty=true" -H 'content-type: application/json'  -d @storm_query

curl语句的最后一个参数引用一个文件,该文件的内容将作为POST请求的正文包含在其中。该文件包含以下细节:

{
    "queryType": "groupBy",
    "dataSource": "stockinfo",
    "granularity": "minute",
    "dimensions": ["symbol"],
    "aggregations":[
        { "type": "longSum", "fieldName": "orders",
         "name": "cumulativeCount"},
        { "type": "doubleSum", "fieldName": "totalPrice",
         "name": "cumulativePrice" }
    ],
    "postAggregations":[
    {  "type":"arithmetic",
        "name":"avg_price",
        "fn":"/",
        "fields":[ {"type":"fieldAccess","name":"avgprice",
        "fieldName":"cumulativePrice"},
                   {"type":"fieldAccess","name":"numrows",
        "fieldName":"cumulativeCount"}]}
    ],
    "intervals":["2012-10-01T00:00/2020-01-01T00"]
}

Druid 中有两种聚合类型。索引过程中发生的聚合和查询时发生的聚合。索引期间发生的聚合在规范文件中定义。如果你还记得,我们在规范文件中有两种聚合:

"aggregators": [
{ "type": "count", "name": "orders"},
   { "type": "doubleSum", "fieldName": "price",
"name": "totalPrice" }
],

我们正在聚合的事件有两个字段:symbolprice。前面的聚合是在索引时间应用的,并引入了两个额外的字段:totalPriceorders。请记住,totalPrice是该时间段内每个事件的价格总和。orders字段包含了该时间段内事件的总数。

然后,在执行查询时,Druid 根据groupBy语句应用了第二组聚合。在我们的查询中,我们按分钟对symbol进行分组。然后聚合引入了两个新字段:cumulativeCountcumulativePrice。这些字段包含了前面聚合的总和。

最后,我们引入了一个postaggregation方法来计算该时间段的平均值。该postaggregation方法将两个累积字段进行除法(“fn”:“/”),得到一个新的avg_price字段。

向运行中的服务器发出curl语句会得到以下响应:

[ {
  "version" : "v1",
  "timestamp" : "2013-05-15T22:31:00.000Z",
  "event" : {
    "cumulativePrice" : 3464069.0,
    "symbol" : "MSFT",
    "cumulativeCount" : 69114,
    "avg_price" : 50.12108979367422
  }
}, {
  "version" : "v1",
  "timestamp" : "2013-05-15T22:31:00.000Z",
  "event" : {
    "cumulativePrice" : 3515855.0,
    "symbol" : "ORCL",
    "cumulativeCount" : 68961,
    "avg_price" : 50.98323690201708
  }
...
 {
  "version" : "v1",
  "timestamp" : "2013-05-15T22:32:00.000Z",
  "event" : {
    "cumulativePrice" : 1347494.0,
    "symbol" : "ORCL",
    "cumulativeCount" : 26696,
    "avg_price" : 50.47550194785736
  }
}, {
  "version" : "v1",
  "timestamp" : "2013-05-15T22:32:00.000Z",
  "event" : {
    "cumulativePrice" : 707317.0,
    "symbol" : "SPY",
    "cumulativeCount" : 13453,
    "avg_price" : 52.576897346316805
  }
} ]

自从我们更新了代码以生成零到一百之间的随机价格,平均价格大约是五十。(哇呼!)

总结

在本章中,我们更加深入地了解了 Trident State API。我们创建了StateStateUpdater接口的直接实现,而不是依赖于默认实现。具体来说,我们实现了这些接口来弥合事务型 spout 和非事务型系统(即 Druid)之间的差距。虽然在非事务型存储中无法确保精确一次语义,但我们已经采取了机制来在系统遇到问题时发出警报。显然,一旦失败,我们可以使用批处理机制来重建任何可疑的聚合段。

为了未来的调查,建立 Storm 和 Druid 之间的幂等接口将是有益的。为了做到这一点,我们可以在 Storm 中为每个批次发布一个单独的段。由于在 Druid 中段的传播是原子的,这将为我们提供一种机制,将每个批次原子地提交到 Druid 中。此外,批次可以并行处理,从而提高吞吐量。Druid 支持日益扩大的查询类型和聚合机制。它非常强大,Storm 和 Druid 的结合是非常强大的。

第八章:自然语言处理

有些人认为随着对实时分析和数据处理的需求增加,Storm 最终会取代 Hadoop。在本章中,我们将看到 Storm 和 Hadoop 实际上是如何互补的。

尽管 Storm 模糊了传统 OLTP 和 OLAP 之间的界限,但它可以处理大量交易,同时执行通常与数据仓库相关的聚合和维度分析。通常情况下,您仍然需要额外的基础设施来执行历史分析,并支持整个数据集的临时查询。此外,批处理通常用于纠正 OLTP 系统无法在故障发生时确保一致性的异常情况。这正是我们在 Storm-Druid 集成中遇到的情况。

出于这些原因,批处理基础设施通常与实时基础设施配对使用。Hadoop 为我们提供了这样一个批处理框架。在本章中,我们将实现一个支持历史和临时分析的架构,通过批处理。

本章涵盖以下主题:

  • CAP 定理

  • Lambda 架构

  • OLTP 和 OLAP 集成

  • Hadoop 简介

激发 Lambda 架构

首先,从逻辑角度来看,让我们看一下 Storm-Druid 集成。Storm,特别是 Trident,能够执行分布式分析,因为它隔离了状态转换。为了做到这一点,Storm 对状态的基础持久性机制做出了一些假设。Storm 假设持久性机制既是一致的又是可用的。具体来说,Storm 假设一旦进行了状态转换,新状态就会被共享,在所有节点上保持一致,并立即可用。

根据 CAP 定理,我们知道任何分布式系统要同时提供以下三个保证是困难的:

  • 一致性:所有节点上的状态相同

  • 可用性:系统可以对查询做出成功或失败的响应

  • 分区容错性:系统在通信丢失或部分系统故障的情况下仍能做出响应

越来越多的 Web 规模架构集成了对一致性采取宽松态度的持久性机制,以满足可用性和分区容错性的要求。通常,这些系统这样做是因为在大型分布式系统中提供整个系统的事务一致性所需的协调变得不可行。性能和吞吐量更重要。

Druid 也做出了同样的权衡。如果我们看一下 Druid 的持久性模型,我们会看到几个不同的阶段:

激发 Lambda 架构

首先,Druid 通过Firehose接口消耗数据并将数据放入内存。其次,数据被持久化到磁盘,并通过Runnable接口通知Firehose实现。最后,这些数据被推送到深度存储,使数据对系统的其他部分可用。

现在,如果我们考虑不一致数据对容错性的影响,我们会发现数据在持久存储之前是有风险的。如果我们丢失了某个节点,我们就会失去该节点上所有数据的分析,因为我们已经确认了元组。

解决这个问题的一个明显的方法是在承认 Storm 中的元组之前将段推送到深度存储。这是可以接受的,但它会在 Storm 和 Druid 之间创建一个脆弱的关系。具体来说,批处理大小和超时需要与段大小和 Druid 的段推送到深度存储的时间保持一致。换句话说,我们的事务处理系统的吞吐量将受到限制,并与我们用于分析处理的系统密切相关。最终,这很可能是我们不想要的依赖关系。

然而,我们仍然希望进行实时分析,并愿意容忍在部分系统故障的情况下,这些分析可能会缺少一部分数据。从这个角度来看,这种集成是令人满意的。但理想情况下,我们希望有一种机制来纠正和恢复任何故障。为此,我们将引入离线批处理机制,以在发生故障时恢复和纠正数据。

为了使这项工作,我们将首先在将数据发送到 Druid 之前持久化数据。我们的批处理系统将离线从持久性机制中读取数据。批处理系统将能够纠正/更新系统在实时处理期间可能丢失的任何数据。通过结合这些方法,我们可以在实时处理中实现所需的吞吐量,并且分析结果准确,直到系统发生故障,并且有一种机制可以在发生故障时纠正这些分析。

分布式批处理的事实标准是 Hadoop。因此,我们将在这里使用 Hadoop 进行历史(即非实时)分析。以下图表描述了我们将在这里使用的模式:

激励 Lambda 架构

前面的模式显示了我们如何成功地集成 OLTP 和 OLAP 系统,同时在大部分情况下提供一致和完整的实时高吞吐量、可用性和分区分析。它同时提供了解决部分系统故障的机制。

这种方法填补的另一个空白是能够将新的分析引入系统。由于 Storm-Druid 集成侧重于实时问题,因此没有简单的方法将新的分析引入系统。 Hadoop 填补了这个空白,因为它可以在历史数据上运行以填充新的维度或执行额外的聚合。

Storm 的原始作者 Nathan Marz 将这种方法称为Lambda 架构

检查我们的用例

现在,让我们将这种模式应用到自然语言处理NLP)领域。在这个用例中,我们将搜索 Twitter 上与短语(例如“Apple Jobs”)相关的推文。然后系统将处理这些推文,试图找到最相关的单词。使用 Druid 来聚合这些术语,我们将能够随时间趋势最相关的单词。

让我们更详细地定义问题。给定搜索短语p,使用 Twitter API,我们将找到最相关的一组推文T。对于T中的每条推文t,我们将计算每个单词w的出现次数。我们将比较推文中该单词的频率与英文文本样本E中该单词的频率。然后系统将对这些单词进行排名,并显示前 20 个结果。

从数学上讲,这相当于以下形式:

查看我们的用例

在这里,语料库C中单词w的频率如下:

检查我们的用例

由于我们只关心相对频率,并且T中的单词总数和E中的单词总数在所有单词中都是恒定的,我们可以在方程中忽略它们,从而降低问题的复杂性,简化为以下形式:

检查我们的用例

对于分母,我们将使用以下链接中的免费可用单词频率列表:

invokeit.wordpress.com/frequency-word-lists/

我们将使用 Storm 来处理 Twitter 搜索的结果,并使用计数信息为分母来丰富元组。然后 Druid 将对分子进行计数,并使用后聚合函数来执行实际的相关性计算。

实现 Lambda 架构

对于这个用例,我们专注于一个分布式计算模式,它将实时处理平台(即 Storm)与分析引擎(即 Druid)集成起来;然后将其与离线批处理机制(即 Hadoop)配对,以确保我们拥有准确的历史指标。

虽然这仍然是重点,但我们试图实现的另一个关键目标是持续可用性和容错。更具体地说,系统应该能够容忍节点或者甚至数据中心的永久丢失。为了实现这种可用性和容错,我们需要更多地关注持久性。

在一个实时系统中,我们会使用分布式存储机制进行持久化,理想情况下是支持跨数据中心复制的存储机制。因此,即使在灾难情况下,一个数据中心完全丢失,系统也能够在不丢失数据的情况下恢复。在与持久存储交互时,客户端将要求一个一致性级别,该级别在事务中复制数据到多个数据中心。

在这次讨论中,假设我们使用 Cassandra 作为我们的持久化机制。对于 Cassandra,具有可调一致性的写入将使用EACH_QUORUM一致性级别。这确保了数据的副本一致地写入到所有数据中心。当然,这会在每次写入时引入数据中心间通信的开销。对于不太关键的应用程序,LOCAL_QUORUM可能是可以接受的,它避免了数据中心间通信的延迟。

使用 Cassandra 等分布式存储引擎的另一个好处是,可以为离线/批处理设置一个单独的环/集群。然后 Hadoop 可以使用该环作为输入,使系统能够重新摄入历史数据而不影响事务处理。考虑以下架构图:

实现 Lambda 架构

在上图中,我们有两个物理数据中心,每个数据中心都有一个为 Storm 提供事务处理的 Cassandra 集群。这确保了拓扑中的任何写入都会实时复制到数据中心,无论是在元组被确认之前(如果我们使用EACH_QUORUM一致性)还是在懒惰地(如果我们使用LOCAL_QUORUM)。

此外,我们有第三个虚拟数据中心支持离线批处理。Ring 3是一个 Cassandra 集群,物理上与Ring 1相邻,但在 Cassandra 中配置为第二个数据中心。当我们运行 Hadoop 作业处理历史指标时,我们可以使用LOCAL_QUORUM。由于本地四分位数试图在本地数据中心内获得共识,来自 Hadoop 的读取流量不会跨越到我们的事务处理集群。

总的来说,如果你的组织有数据科学家/数据管理者在对数据进行分析,部署这种模式是一个很好的选择。通常,这些工作对数据要求很高。将这种工作负载与事务系统隔离开是很重要的。

此外,和我们在系统中容忍故障的能力一样重要的是,这种架构使我们能够在数据摄入时没有的情况下引入新的分析。Hadoop 可以使用新的分析配置运行所有相关的历史数据,以填充新的维度或执行额外的聚合。

为我们的用例设计拓扑

在这个例子中,我们将再次使用 Trident,并在前一章中构建的拓扑的基础上进行扩展。Trident 拓扑如下所示:

为我们的用例设计拓扑

TwitterSpout 定期针对 Twitter API 进行搜索,将返回的 tweets 发射到 Trident 流中。TweetSplitterFunction 然后解析 tweets,并为每个单词发射一个元组。WordFrequencyFunction 为每个单词的元组添加来自英语语言的随机样本的计数。最后,我们让 Druid 消费这些信息,以执行随时间的聚合。Druid 将数据分区为时间切片,并像之前描述的那样持久化数据。

在这种情况下,因为持久化机制是我们解决容错/系统故障的手段,所以持久化机制应该分发存储,并提供一致性和高可用性。此外,Hadoop 应该能够使用持久化机制作为 map/reduce 作业的输入。

由于其可调整的一致性和对 Hadoop 的支持,Cassandra 是这种模式的理想持久化机制。由于 Cassandra 和多语言持久化已在其他地方进行了介绍,我们将保持这个例子简单,并使用本地文件存储。

实施设计

让我们首先从 spout 开始,逐步分析实时部分,直到 Druid 持久化。拓扑很简单,模仿了我们在前几章中编写的拓扑。

以下是拓扑的关键行:

TwitterSpout spout = new TwitterSpout();
Stream inputStream = topology.newStream("nlp", spout);
try {
inputStream.each(new Fields("tweet"), new TweetSplitterFunction(), new Fields("word"))
          .each(new Fields("searchphrase", "tweet", "word"), new WordFrequencyFunction(), new Fields("baseline"))
          .each(new Fields("searchphrase", "tweet", "word", "baseline"), new PersistenceFunction(), new Fields())	
          .partitionPersist(new DruidStateFactory(), new Fields("searchphrase", "tweet", "word", "baseline"), new DruidStateUpdater());
} catch (IOException e) {
throw new RuntimeException(e);
}
return topology.build();

最后,在解析和丰富之后,元组有四个字段,如下表所示:

字段名称 用途
searchphrase 这个字段包含正在被摄取的搜索短语。这是发送到 Twitter API 的短语。在现实中,系统很可能会同时监视多个搜索短语。在这个系统中,这个值是硬编码的。
tweet 这个字段包含在搜索 Twitter API 时返回的 tweets。searchphrasetweet 之间是一对多的关系。
word 解析后,这个字段包含在 tweets 中找到的单词。tweetword 之间是一对多的关系。
baseline 这个字段包含普通抽样文本中与单词相关的计数。wordbaseline 之间是一对一的关系。

TwitterSpout/TweetEmitter

现在,让我们来看看 spout/emitter。在这个例子中,我们将使用 Twitter4J API,Emitter 函数不过是该 API 和 Storm API 之间的薄胶层。如前所示,它只是使用 Twitter4J 调用 Twitter API,并将所有结果作为一个批次在 Storm 中发射。

在更复杂的情况下,一个可能还会接入 Twitter Firehose 并使用队列来缓冲实时更新,然后将其发射到 Storm 中。以下是 spout 的 Emitter 部分的关键行:

   query = new Query(SEARCH_PHRASE);
   query.setLang("en");
   result = twitter.search(query);
   ...
   for (Status status : result.getTweets()) {
       List<Object> tweets = new ArrayList<Object>();
       tweets.add(SEARCH_PHRASE);
       tweets.add(status.getText());
       collector.emit(tweets);
   }

函数

本节涵盖了拓扑中使用的函数。在这个例子中,所有的函数都可以有副作用(例如持久化),或者它们可以为元组添加字段和值。

TweetSplitterFunction

tweet 经过的第一个函数是 TweetSplitterFunction。这个函数简单地解析 tweet,并为 tweet 中的每个单词发射一个元组。该函数的代码如下:

@Override
public void execute(TridentTuple tuple, TridentCollector collector) {
String tweet = (String) tuple.getValue(0);
LOG.debug("SPLITTING TWEET [" + tweet + "]");
Pattern p = Pattern.compile("[a-zA-Z]+");
Matcher m = p.matcher(tweet);
List<String> result = new ArrayList<String>();
   while (m.find()) {
       String word = m.group();
       if (word.length() > 0) {
         List<Object> newTuple = new ArrayList<Object>();
         newTuple.add(word);
         collector.emit(newTuple);
       }
   }
}

在一个更复杂的 NLP 系统中,这个函数将不仅仅是通过空格分割推文。NLP 系统很可能会尝试解析推文,为单词分配词性并将它们与彼此关联起来。尽管即时消息和推文通常缺乏解析器训练的传统语法结构,系统仍可能使用诸如单词之间距离之类的基本关联。在这种系统中,系统使用 n-gram 频率而不是单词频率,其中每个 n-gram 包括多个单词。

要了解 n-gram 的使用,请访问books.google.com/ngrams

WordFrequencyFunction

现在我们转向WordFrequencyFunction。这个函数用baseline计数丰富了元组。这是单词在随机抽样文本中遇到的次数。

该函数的关键代码如下所示:

public static final long DEFAULT_BASELINE = 10000;
private Map<String, Long> wordLikelihoods = 
new HashMap<String, Long>();

public WordFrequencyFunction() throws IOException {
File file = new File("src/main/resources/en.txt");
BufferedReader br = new BufferedReader(new FileReader(file));
String line;
while ((line = br.readLine()) != null) {
String[] pair = line.split(" ");
   long baseline = Long.parseLong(pair[1]);
   LOG.debug("[" + pair[0] + "]=>[" + baseline + "]");
   wordLikelihoods.put(pair[0].toLowerCase(), baseline);
   i++;
}
br.close();
}

@Override
public void execute(TridentTuple tuple,
TridentCollector collector) {
String word = (String) tuple.getValue(2);
Long baseline = this.getLikelihood(word);
List<Object> newTuple = new ArrayList<Object>();
newTuple.add(baseline);
collector.emit(newTuple);
}

public long getLikelihood(String word){
Long baseline = this.wordLikelihoods.get(word);
if (baseline == null)
return DEFAULT_BASELINE;
else
   return baseline;
}

代码中的构造函数将单词计数加载到内存中。 en.txt的文件格式如下:

you 4621939
the 3957465
i 3476773
to 2873389
...
of 1531878
that 1323823
in 1295198
is 1242191
me 1208959
what 1071825

每行包含单词和该单词的频率计数。同样,由于我们只关心相对计数,因此无需考虑语料库中的总计数。但是,如果我们正在计算真实的可能性,我们还需要考虑总体单词计数。

函数的execute方法很简单,只是将基线计数添加到元组中。但是,如果我们检查从HashMap类中检索计数的方法,注意它包括一个DEFAULT_BASELINE。这是系统遇到原始语料库中没有的单词时使用的值。

由于 Twitter 动态包含许多缩写词、首字母缩写词和其他通常在标准文本中找不到的术语,DEFAULT_BASELINE成为一个重要的配置参数。在某些情况下,独特的单词很重要,因为它们涉及到searchphrase字段。其他单词是异常的,因为样本语料库与目标语料库不同。

理想情况下,原始基线计数应该来自分析的目标相同来源。在这种情况下,最好使用整个Twitter Firehose计算单词和 n-gram 计数。

PersistenceFunction

我们不会在这里详细介绍完整的多数据中心 Cassandra 部署。相反,对于这个例子,我们将保持简单并使用本地文件存储。 PersistenceFunction的代码如下:

@Override
public void execute(TridentTuple tuple, 
   TridentCollector collector) {
writeToLog(tuple);
collector.emit(tuple);
}

synchronized public void writeToLog(TridentTuple tuple) {
DateTime dt = new DateTime();
DateTimeFormatter fmt = ISODateTimeFormat.dateTime();
StringBuffer sb = new StringBuffer("{ ");
sb.append(String.format("\"utcdt\":\"%s\",", fmt.print(dt)));
sb.append(String.format("\"searchphrase\":\"%s\",", tuple.getValue(0)));
sb.append(String.format("\"word\":\"%s\",", tuple.getValue(2)));
sb.append(String.format("\"baseline\":%s", tuple.getValue(3)));
sb.append("}");
BufferedWriter bw;
try {
bw = new BufferedWriter(new FileWriter("nlp.json", true));
bw.write(sb.toString());
   bw.newLine();
   bw.close();
} catch (IOException e) {
   throw new RuntimeException(e);
}
}

在上述代码中,该函数只是以 Druid 期望在 Hadoop 索引作业中使用的本机格式保存元组。这段代码效率低下,因为我们每次都要打开文件进行写入。或者,我们可以实现额外的StateFactoryState对象来持久化元组;然而,由于这只是一个例子,我们可以容忍低效的文件访问。

另外,请注意我们在这里生成了一个时间戳,但没有与元组一起重新发出。理想情况下,我们会生成一个时间戳并将其添加到元组中,然后由 Druid 在下游使用以对齐时间分区。在这个例子中,我们将接受这种差异。

提示

即使这个函数根本不丰富元组,它仍然必须重新发出元组。由于函数也可以充当过滤器,函数有义务声明哪些元组被传递到下游。

该函数将以下行写入nlp.json文件:

{ "utcdt":"2013-08-25T14:47:38.883-04:00","searchphrase":"apple jobs","word":"his","baseline":279134}
{ "utcdt":"2013-08-25T14:47:38.884-04:00","searchphrase":"apple jobs","word":"annual","baseline":839}
{ "utcdt":"2013-08-25T14:47:38.885-04:00","searchphrase":"apple jobs","word":"salary","baseline":1603}
{ "utcdt":"2013-08-25T14:47:38.886-04:00","searchphrase":"apple jobs","word":"from","baseline":285711}
{ "utcdt":"2013-08-25T14:47:38.886-04:00","searchphrase":"apple jobs","word":"Apple","baseline":10000}

检查分析

Druid 集成与上一章中使用的相同。简而言之,此集成包括StateFactoryStateUpdaterState实现。然后,State实现与StormFirehoseFactory实现和 Druid 的StormFirehose实现进行通信。在此实现的核心是StormFirehose实现,它将元组映射到 Druid 的输入行。此方法的清单如下所示:

@Override
public InputRow nextRow() {
   final Map<String, Object> theMap =
Maps.newTreeMap(String.CASE_INSENSITIVE_ORDER);
try {
TridentTuple tuple = null;
   tuple = BLOCKING_QUEUE.poll();
   if (tuple != null) {
String phrase = (String) tuple.getValue(0);
      String word = (String) tuple.getValue(2);
      Long baseline = (Long) tuple.getValue(3);
      theMap.put("searchphrase", phrase);
      theMap.put("word", word);
      theMap.put("baseline", baseline);
}

   if (BLOCKING_QUEUE.isEmpty()) {
      STATUS.putInLimbo(TRANSACTION_ID);
      LIMBO_TRANSACTIONS.add(TRANSACTION_ID);
      LOG.info("Batch is fully consumed by Druid. Unlocking [FINISH]");
      synchronized (FINISHED) {
          FINISHED.notify();
      }
   }
} catch (Exception e) {
LOG.error("Error occurred in nextRow.", e);
}
final LinkedList<String> dimensions = new LinkedList<String>();
dimensions.add("searchphrase");
dimensions.add("word");
return new MapBasedInputRow(System.currentTimeMillis(), 
dimensions, theMap); 
}

查看此方法时,有两个关键数据结构:theMapdimensions。第一个包含行的数据值。第二个包含该行的维度,这是 Druid 用来执行聚合的,也决定了您可以针对数据运行哪些查询。在这种情况下,我们将使用searchphraseword字段作为维度。这将允许我们在查询中执行计数和分组,我们马上就会看到。

首先,让我们看一下用于摄取数据的 Druid 配置。我们将主要使用与上一章中使用的嵌入式实时服务器相同的配置。段将被推送到 Cassandra 进行深度存储,而 MySQL 用于编写段元数据。

以下是runtime.properties中的关键配置参数:

druid.pusher.cassandra=true
druid.pusher.cassandra.host=localhost:9160 
druid.pusher.cassandra.keyspace=druid
druid.zk.service.host=localhost
druid.zk.paths.base=/druid
druid.host=127.0.0.1
druid.database.segmentTable=prod_segments
druid.database.user=druid
druid.database.password=druid
druid.database.connectURI=jdbc:mysql://localhost:3306/druid
druid.zk.paths.discoveryPath=/druid/discoveryPath
druid.realtime.specFile=./src/main/resources/realtime.spec
druid.port=7272
druid.request.logging.dir=/tmp/druid/realtime/log

此配置指向realtime.spec文件,该文件指定了实时服务器执行的分析的详细信息。以下是此用例的realtime.spec文件:

[{
    "schema": {
        "dataSource": "nlp",
        "aggregators": [
            { "type": "count", "name": "wordcount" },
            { "type": "max", "fieldName": "baseline", 
name" : "maxbaseline" }
        ],
        "indexGranularity": "minute",
        "shardSpec": {"type": "none"}
    },

    "config": {
        "maxRowsInMemory": 50000,
        "intermediatePersistPeriod": "PT30s"
    },

    "firehose": {
        "type": "storm",
        "sleepUsec": 100000,
        "maxGeneratedRows": 5000000,
        "seed": 0,
        "nTokens": 255,
        "nPerSleep": 3
    },

    "plumber": {
        "type": "realtime",
        "windowPeriod": "PT10s",
        "segmentGranularity": "minute",
        "basePersistDirectory": "/tmp/nlp/basePersist"
    }
}]

除了时间粒度,我们还在此文件中指定了聚合器。这告诉 Druid 如何在行之间聚合指标。没有聚合器,Druid 无法合并数据。在此用例中,有两个聚合器:wordcountmaxbaseline

wordcount字段计算具有相同维度值的行的实例。回顾StormFirehose实现,两个维度是searchphraseword。因此,Druid 可以合并行,添加一个名为wordcount的字段,其中将包含该单词在该searchphrase和时间片段中找到的实例总数。

maxbaseline字段包含该单词的基线。实际上,每行的值都是相同的。我们只是使用max作为一个方便的函数,将该值传播到我们在查询系统时可以使用的聚合中。

现在,让我们来看看查询。以下是我们用来检索最相关单词的查询:

{
     "queryType": "groupBy",
     "dataSource": "nlp",
     "granularity": "minute",
     "dimensions": ["searchphrase", "word"],
     "aggregations":[
        { "type": "longSum", "fieldName":"wordcount", 
"name": "totalcount"},
        { "type": "max", "fieldName":"maxbaseline", 
"name": "totalbaseline"}
     ],
     "postAggregations": [{
       "type": "arithmetic",
       "name": "relevance",
       "fn": "/",
       "fields": [
            { "type": "fieldAccess", "fieldName": "totalcount" },
            { "type": "fieldAccess", "fieldName": "totalbaseline" }
       ]
     }],
     "intervals":["2012-10-01T00:00/2020-01-01T00"]
 }

查询需要与realtime.spec文件对齐。在查询的底部,我们指定我们感兴趣的时间间隔。在文件的顶部,我们指定我们感兴趣的维度,然后是允许 Druid 将行折叠以匹配所请求的粒度的聚合。在此用例中,聚合与我们实时索引数据时执行的聚合完全匹配。

具体来说,我们引入了totalcount字段,其中包含wordcount的总和。因此,它将包含观察到的该wordsearchphrase组合的实例总数。此外,我们使用baseline进行相同的技巧来传递该值。

最后,在此查询中,我们包括一个后聚合,它将聚合结果组合成相关分数。后聚合将观察到的推文总数除以基线频率。

以下是一个简单的 Ruby 文件,用于处理查询结果并返回前 20 个单词:

...
url="http://localhost:7272/druid/v2/?pretty=true"
response = RestClient.post url, File.read("realtime_query"), :accept => :json, :content_type => 'appplication/json'
#puts(response)
result = JSON.parse(response.to_s)

word_relevance = {}
result.each do |slice|
  event = slice['event']
  word_relevance[event['word']]=event['relevance']
end

count = 0
word_relevance.sort_by {|k,v| v}.reverse.each do |word, relevance|
  puts("#{word}->#{relevance}")
  count=count+1
  if(count == 20) then
    break
  end
end

请注意,我们用于访问服务器的 URL 是嵌入式实时服务器的端口。在生产中,查询会通过代理节点进行。

执行此脚本将产生以下代码片段:

claiming->31.789579158316634
apple->27.325982081323225
purchase->20.985449735449734
Jobs->20.618
Steve->17.446
shares->14.802238805970148
random->13.480033984706882
creation->12.7524115755627
Apple->12.688
acts->8.82582081246522
prevent->8.702687877125618
farmer->8.640522875816993
developed->8.62642740619902
jobs->8.524986566362172
bottles->8.30523560209424
technology->7.535137701804368
current->7.21418826739427
empire->6.924050632911392

提示

如果更改您正在捕获的维度或指标,请务必删除实时服务器用于缓存数据的本地目录。否则,实时服务器可能会重新读取旧数据,这些数据没有需要满足查询的维度和/或指标;此外,查询将失败,因为 Druid 无法找到必需的指标或维度。

批处理/历史分析

现在,让我们把注意力转向批处理机制。为此,我们将使用 Hadoop。虽然完整描述 Hadoop 远远超出了本节的范围,但我们将在 Druid 特定设置的同时对 Hadoop 进行简要概述。

Hadoop 提供了两个主要组件:分布式文件系统和分布式处理框架。分布式文件系统的名称是Hadoop 分布式文件系统HDFS)。分布式处理框架称为 MapReduce。由于我们选择在假设的系统架构中利用 Cassandra 作为存储机制,我们将不需要 HDFS。但是,我们将使用 Hadoop 的 MapReduce 部分来将处理分布到所有历史数据中。

在我们的简单示例中,我们将运行一个读取我们PersistenceFunction中编写的本地文件的本地 Hadoop 作业。Druid 附带了一个我们将在本示例中使用的 Hadoop 作业。

Hadoop

在我们开始加载数据之前,有必要简要介绍一下 MapReduce。尽管 Druid 预先打包了一个方便的 MapReduce 作业来适应历史数据,但一般来说,大型分布式系统将需要自定义作业来对整个数据集执行分析。

MapReduce 概述

MapReduce 是一个将处理分为两个阶段的框架:map 阶段和 reduce 阶段。在 map 阶段,一个函数被应用于整个输入数据集,每次处理一个元素。每次应用map函数都会产生一组元组,每个元组包含一个键和一个值。具有相似键的元组然后通过reduce函数组合。reduce函数通常会发出另一组元组,通过组合与键相关联的值。

MapReduce 的经典“Hello World”示例是单词计数。给定一组包含单词的文档,计算每个单词的出现次数。(讽刺的是,这与我们的 NLP 示例非常相似。)

以下是 Ruby 函数,用于表达单词计数示例的mapreduce函数。map函数如下代码片段所示:

def map(doc)
   result = []
doc.split(' ').each do |word|
result << [word, 1]
   end
   return result
end

给定以下输入,map函数产生以下输出:

map("the quick fox jumped over the dog over and over again")
 => [["the", 1], ["quick", 1], ["fox", 1], ["jumped", 1], ["over", 1], ["the", 1], ["dog", 1], ["over", 1], ["and", 1], ["over", 1], ["again", 1]]

相应的reduce函数如下代码片段所示:

def reduce(key, values)
   sum = values.inject { |sum, x| sum + x }
   return [key, sum]
end

然后,MapReduce 函数将为每个键分组值,并将它们传递给前面的reduce函数,如下所示,从而得到总的单词计数:

reduce("over", [1,1,1])
 => ["over", 3]

Druid 设置

有了 Hadoop 作为背景,让我们来看看我们为 Druid 设置的情况。为了让 Druid 从 Hadoop 作业中获取数据,我们需要启动MasterCompute节点(也称为Historical节点)。为此,我们将创建一个目录结构,该目录结构的根目录包含 Druid 自包含作业,子目录包含 Master 和 Compute 服务器的配置文件。

此目录结构如下代码片段所示:

druid/druid-indexing-hadoop-0.5.39-SNAPSHOT.jar
druid/druid-services-0.5.39-SNAPSHOT-selfcontained.jar
druid/config/compute/runtime.properties
druid/config/master/runtime.properties
druid/batchConfig.json

Master 和 Compute 节点的运行时属性与实时节点基本相同,但有一些显著的区别。它们都包括用于缓存段的设置,如下所示的代码片段:

# Path on local FS for storage of segments; 
# dir will be created if needed
druid.paths.indexCache=/tmp/druid/indexCache
# Path on local FS for storage of segment metadata; 
# dir will be created if needed
druid.paths.segmentInfoCache=/tmp/druid/segmentInfoCache

另外,请注意,如果您在同一台机器上运行 Master 和 Compute 服务器,您需要更改端口,以避免冲突,如下所示:

druid.port=8082

Druid 将所有服务器组件及其依赖项打包到一个单独的自包含 JAR 文件中。使用这个 JAR 文件,您可以使用以下命令启动 Master 和 Compute 服务器。

对于 Compute 节点,我们使用以下代码片段:

java -Xmx256m -Duser.timezone=UTC -Dfile.encoding=UTF-8 \
-classpath ./druid-services-0.5.39-SNAPSHOT-selfcontained.jar:config/compute \
com.metamx.druid.http.ComputeMain

对于 Master 节点,我们使用以下代码片段:

java -Xmx256m -Duser.timezone=UTC -Dfile.encoding=UTF-8 \
-classpath ./druid-services-0.5.39-SNAPSHOT-selfcontained.jar:config/compute \
com.metamx.druid.http.ComputeMain

一旦两个节点都运行起来,我们就可以使用 Hadoop 作业加载数据。

HadoopDruidIndexer

在我们的服务器正常运行后,我们可以检查 Druid MapReduce 作业的内部。HadoopDruidIndexer函数使用一个类似realtime.spec文件的 JSON 配置文件。

文件在启动 Hadoop 作业时通过命令行指定,如下面的代码片段所示:

java -Xmx256m -Duser.timezone=UTC -Dfile.encoding=UTF-8 \
-Ddruid.realtime.specFile=realtime.spec -classpath druid-services-0.5.39-SNAPSHOT-selfcontained.jar:druid-indexing-hadoop-0.5.39-SNAPSHOT.jar \
com.metamx.druid.indexer.HadoopDruidIndexerMain batchConfig.json

以下是我们在这个例子中使用的batchConfig.json文件:

{
  "dataSource": "historical",
  "timestampColumn": "utcdt",
  "timestampFormat": "iso",
  "dataSpec": {
    "format": "json",
    "dimensions": ["searchphrase", "word"]
  },
  "granularitySpec": {
    "type":"uniform",
    "intervals":["2013-08-21T19/PT1H"],
    "gran":"hour"
  },
  "pathSpec": { "type": "static",
                "paths": "/tmp/nlp.json" },
  "rollupSpec": {
            "aggs": [ { "type": "count", "name": "wordcount" },
                         { "type": "max", "fieldName": "baseline", 
                                       "name" : "maxbaseline" } ],
      "rollupGranularity": "minute"},
      "workingPath": "/tmp/working_path",
  "segmentOutputPath": "/tmp/segments",
  "leaveIntermediate": "false",
  "partitionsSpec": {
    "targetPartitionSize": 5000000
  },
  "updaterJobSpec": {
    "type":"db",
    "connectURI":"jdbc:mysql://localhost:3306/druid",
    "user":"druid",
    "password":"druid",
    "segmentTable":"prod_segments"
  }
}

许多配置看起来很熟悉。特别感兴趣的两个字段是pathSpecrollupSpec字段。pathSpec字段包含了由PersistenceFunction编写的文件的位置。rollupSpec字段包含了我们在事务处理期间在realtime.spec文件中包含的相同聚合函数。

另外,请注意指定了时间戳列和格式,这与我们在持久化文件中输出的字段相一致:

{ "utcdt":"2013-08-25T14:47:38.883-04:00","searchphrase":"apple jobs","word":"his","baseline":279134}
{ "utcdt":"2013-08-25T14:47:38.884-04:00","searchphrase":"apple jobs","word":"annual","baseline":839}
{ "utcdt":"2013-08-25T14:47:38.885-04:00","searchphrase":"apple jobs","word":"salary","baseline":1603}
{ "utcdt":"2013-08-25T14:47:38.886-04:00","searchphrase":"apple jobs","word":"from","baseline":285711}
{ "utcdt":"2013-08-25T14:47:38.886-04:00","searchphrase":"apple jobs","word":"Apple","baseline":10000}

HadoopDruidIndexer函数加载前述配置文件,并执行map/reduce函数来构建索引。如果我们更仔细地查看该作业,我们可以看到它正在运行的具体函数。

Hadoop 作业是使用 Hadoop 作业类启动的。Druid 运行了一些作业来索引数据,但我们将专注于IndexGeneratorJob。在IndexGeneratorJob中,Druid 使用以下行配置作业:

job.setInputFormatClass(TextInputFormat.class);
job.setMapperClass(IndexGeneratorMapper.class);
job.setMapOutputValueClass(Text.class);
...
job.setReducerClass(IndexGeneratorReducer.class);
job.setOutputKeyClass(BytesWritable.class);
job.setOutputValueClass(Text.class);
job.setOutputFormatClass(IndexGeneratorOutputFormat.class);
FileOutputFormat.setOutputPath(job,config.makeIntermediatePath());
config.addInputPaths(job);
config.intoConfiguration(job);
...
job.setJarByClass(IndexGeneratorJob.class);
job.submit();

几乎所有 Hadoop 作业都设置了上述属性。它们为处理的每个阶段设置了输入和输出类以及实现MapperReducer接口的类。

有关 Hadoop 作业配置的完整描述,请访问以下网址:hadoop.apache.org/docs/r0.18.3/mapred_tutorial.html#Job+Configuration

作业配置还指定了输入路径,指定了要处理的文件或其他数据源。在对config.addInputPaths的调用中,Druid 将pathSpec字段中的文件添加到 Hadoop 配置中进行处理,如下面的代码片段所示:

  @Override
  public Job addInputPaths(HadoopDruidIndexerConfig config, 
Job job) throws IOException {
    log.info("Adding paths[%s]", paths);
    FileInputFormat.addInputPaths(job, paths);
    return job;
  }

您可以看到,Druid 只支持FileInputFormat的实例。作为读者的练习,可以尝试增强DruidHadoopIndexer函数,以支持直接从 Cassandra 读取,就像在假设的架构中设想的那样。

回顾作业配置,Druid 使用的Mapper类是IndexGeneratorMapper类,而Reducer类是IndexGeneratorReducer类。

让我们首先看一下IndexGeneratorMapper类中的map函数。IndexGeneratorMapper类实际上是从HadoopDruidIndexerMapper继承的,其中包含了map方法的实现,将其委托给IndexGeneratorMapper类来发出实际的值,就像我们在下面的代码中看到的那样。

HadoopDruidIndexerMapper中,我们看到map方法的实现如下:

@Override
protected void map(LongWritable key, Text value, Context context
  ) throws IOException, InterruptedException
  {
    try {
      final InputRow inputRow;
      try {
        inputRow = parser.parse(value.toString());
      }
      catch (IllegalArgumentException e) {
        if (config.isIgnoreInvalidRows()) {
          context.getCounter(HadoopDruidIndexerConfig.IndexJobCounters.INVALID_ROW_COUNTER).increment(1);
          return; // we're ignoring this invalid row
        } else {
          throw e;
        }
      }
      if(config.getGranularitySpec().bucketInterval(new DateTime(inputRow.getTimestampFromEpoch())).isPresent()) {
        innerMap(inputRow, value, context);
      }
    }
    catch (RuntimeException e) {
      throw new RE(e, "Failure on row[%s]", value);
    }
  }

我们可以看到超类map方法处理无法解析的行,将它们标记为无效,并检查行是否包含执行map所需的必要数据。具体来说,超类确保行包含时间戳。map需要时间戳,因为它将数据分区为时间片(即桶),就像我们在对innerMapabstract方法调用中看到的那样,如下所示:

@Override
protected void innerMap(InputRow inputRow,
        Text text,
        Context context
    ) throws IOException, InterruptedException{

 // Group by bucket, sort by timestamp
final Optional<Bucket> bucket = getConfig().getBucket(inputRow);

if (!bucket.isPresent()) {
throw new ISE("WTF?! No bucket found for row: %s", inputRow);
}

context.write(new SortableBytes(
              bucket.get().toGroupKey(),
              Longs.toByteArray(inputRow.getTimestampFromEpoch())
          ).toBytesWritable(),text);
}

该方法中的关键行以及任何基于 Hadoop 的map函数中的关键行是对context.write的调用,它从map函数中发出元组。在这种情况下,map函数发出的是SortableBytes类型的键,它表示度量的桶和从输入源读取的实际文本作为值。

在此时,映射阶段完成后,我们已解析了文件,构建了我们的存储桶,并将数据分区到这些存储桶中,按时间戳排序。然后,通过调用reduce方法处理每个存储桶,如下所示:

@Override
protected void reduce(BytesWritable key, Iterable<Text> values,
final Context context
    ) throws IOException, InterruptedException{
SortableBytes keyBytes = SortableBytes.fromBytesWritable(key);
Bucket bucket = Bucket.fromGroupKey(keyBytes.getGroupKey()).lhs;

final Interval interval =
config.getGranularitySpec().bucketInterval(bucket.time).get();
final DataRollupSpec rollupSpec = config.getRollupSpec();
final AggregatorFactory[] aggs = rollupSpec.getAggs().toArray(
          new AggregatorFactory[rollupSpec.getAggs().size()]);

IncrementalIndex index = makeIncrementalIndex(bucket, aggs);
...
for (final Text value : values) {
context.progress();
   final InputRow inputRow =
index.getSpatialDimensionRowFormatter()
.formatRow(parser.parse(value.toString()));
        allDimensionNames.addAll(inputRow.getDimensions());
      ...
IndexMerger.persist(index, interval, file, 
index = makeIncrementalIndex(bucket, aggs);
      ...
   }
   ...
);
...
serializeOutIndex(context, bucket, mergedBase,
 Lists.newArrayList(allDimensionNames));
...
}

正如您所看到的,reduce方法包含了分析的核心内容。它根据汇总规范中的聚合和批处理配置文件中指定的维度构建索引。该方法的最后几行将段写入磁盘。

最后,当您运行DruidHadoopIndexer类时,您将看到类似以下代码片段的内容:

2013-08-28 04:07:46,405 INFO [main] org.apache.hadoop.mapred.JobClient -   Map-Reduce Framework
2013-08-28 04:07:46,405 INFO [main] org.apache.hadoop.mapred.JobClient -     Reduce input groups=1
2013-08-28 04:07:46,405 INFO [main] org.apache.hadoop.mapred.JobClient -     Combine output records=0
2013-08-28 04:07:46,405 INFO [main] org.apache.hadoop.mapred.JobClient -     Map input records=201363
2013-08-28 04:07:46,405 INFO [main] org.apache.hadoop.mapred.JobClient -     Reduce shuffle bytes=0
2013-08-28 04:07:46,406 INFO [main] org.apache.hadoop.mapred.JobClient -     Reduce output records=0
2013-08-28 04:07:46,406 INFO [main] org.apache.hadoop.mapred.JobClient -     Spilled Records=402726
2013-08-28 04:07:46,406 INFO [main] org.apache.hadoop.mapred.JobClient -     Map output bytes=27064165
2013-08-28 04:07:46,406 INFO [main] org.apache.hadoop.mapred.JobClient -     Combine input records=0
2013-08-28 04:07:46,406 INFO [main] org.apache.hadoop.mapred.JobClient -     Map output records=201363
2013-08-28 04:07:46,406 INFO [main] org.apache.hadoop.mapred.JobClient -     Reduce input records=201363
2013-08-28 04:07:46,433 INFO [main] com.metamx.druid.indexer.IndexGeneratorJob - Adding segment historical_2013-08-28T04:00:00.000Z_2013-08-28T05:00:00.000Z_2013-08-28T04:07:32.243Z to the list of published segments
2013-08-28 04:07:46,708 INFO [main] com.metamx.druid.indexer.DbUpdaterJob - Published historical_2013-08-28T04:00:00.000Z_2013-08-28T05:00:00.000Z_2013-08-28T04:07:32.243Z
2013-08-28 04:07:46,754 INFO [main] com.metamx.druid.indexer.IndexGeneratorJob - Adding segment historical_2013-08-28T04:00:00.000Z_2013-08-28T05:00:00.000Z_2013-08-28T04:07:32.243Z to the list of published segments
2013-08-28 04:07:46,755 INFO [main] com.metamx.druid.indexer.HadoopDruidIndexerJob - Deleting path[/tmp/working_path/historical/2013-08-28T040732.243Z]

请注意,添加的段名为historical。要查询由historical /批处理机制加载的数据,请更新查询以指定历史数据源,并使用计算节点的端口。如果一切加载正确,您将收到我们最初在实时服务器上看到的聚合结果;示例如下:

{
  "version" : "v1",
  "timestamp" : "2013-08-28T04:06:00.000Z",
  "event" : {
    "totalcount" : 171,
    "totalbaseline" : 28719.0,
    "searchphrase" : "apple jobs",
    "relevance" : 0.005954246317768724,
    "word" : "working"
  }
}

现在,如果我们定期安排 Hadoop 作业运行,历史索引将滞后于实时索引,但将持续更新索引,纠正错误并解决任何系统故障。

总结

在本章中,我们看到将批处理机制与 Storm 等实时处理引擎配对,提供了更完整和强大的整体解决方案。

我们研究了实施 Lambda 架构的方法。这种方法提供了由批处理系统支持的实时分析,可以对分析进行追溯性修正。此外,我们还看到了如何配置多数据中心系统架构,以将离线处理与事务系统隔离开来,并通过分布式存储提供持续可用性和容错性。

本章还介绍了 Hadoop,并以 Druid 的实现为例。

在下一章中,我们将采用现有的利用 Pig 和 Hadoop 的批处理过程,并演示将其转换为实时系统所需的步骤。同时,我们还将演示如何使用 Storm-YARN 将 Storm 部署到 Hadoop 基础架构上。

第九章:在 Hadoop 上部署风暴进行广告分析

在前两章中,我们看到了如何将 Storm 与实时分析系统集成。然后我们扩展了该实现,支持批处理的实时系统。在本章中,我们将探讨相反的情况。

我们将研究一个批处理系统,计算广告活动的有效性。我们将把建立在 Hadoop 上的系统转换成实时处理系统。

为此,我们将利用雅虎的 Storm-YARN 项目。Storm-YARN 项目允许用户利用 YARN 来部署和运行 Storm 集群。在 Hadoop 上运行 Storm 允许企业 consoli 操作并利用相同的基础设施进行实时和批处理。

本章涵盖以下主题:

  • Pig 简介

  • YARN(Hadoop v2 的资源管理)

  • 使用 Storm-YARN 部署 Storm

审查用例

在我们的用例中,我们将处理广告活动的日志,以确定最有效的广告活动。批处理机制将使用 Pig 脚本处理单个大型平面文件。Pig 是一种高级语言,允许用户执行数据转换和分析。Pig 类似于 SQL,并编译成通常部署和运行在 Hadoop 基础设施上的 map/reduce 作业。

在本章中,我们将把 Pig 脚本转换成拓扑,并使用 Storm-YARN 部署该拓扑。这使我们能够从批处理方法过渡到能够摄取和响应实时事件的方法(例如,点击横幅广告)。

在广告中,印象是代表广告显示在用户面前的广告事件,无论是否被点击。对于我们的分析,我们将跟踪每个印象,并使用一个字段来指示用户是否点击了广告。

每一行的平面文件包含四个字段,描述如下:

字段 描述
cookie 这是来自浏览器的唯一标识符。我们将使用它来表示系统中的用户。
campaign 这是代表特定广告内容集的唯一标识符。
产品 这是正在广告的产品的名称。
点击 这是布尔字段,表示用户是否点击了广告:如果用户点击了广告,则为 true;否则为 false。

通常,广告商会为产品运行广告活动。一个广告活动可能有一组特定的内容与之相关联。我们想计算每个产品的最有效广告活动。

在这种情况下,我们将通过计算不同点击次数占总体印象的百分比来计算广告活动的有效性。我们将以以下格式提供报告:

产品 广告活动 不同点击次数 印象
X Y 107 252

印象的数量只是产品和广告活动的印象总数。我们不区分印象,因为我们可能多次向同一用户展示相同的广告以获得单次点击。由于我们很可能按印象付费,我们希望使用印象的总数来计算驱动兴趣所需的成本。兴趣表示为点击。

建立架构

我们在上一章中提到了 Hadoop,但主要关注了 Hadoop 中的 map/reduce 机制。在本章中,我们将做相反的事情,关注Hadoop 文件系统HDFS)和Yet Another Resource NegotiatorYARN)。我们将利用 HDFS 来分阶段数据,并利用 YARN 来部署将托管拓扑的 Storm 框架。

Hadoop 内的最新组件化允许任何分布式系统使用它进行资源管理。在 Hadoop 1.0 中,资源管理嵌入到 MapReduce 框架中,如下图所示:

建立架构

Hadoop 2.0 将资源管理分离为 YARN,允许其他分布式处理框架在 Hadoop 伞下管理的资源上运行。在我们的情况下,这允许我们在 YARN 上运行 Storm,如下图所示:

建立架构

如前图所示,Storm 实现了与 MapReduce 相同的功能。它提供了分布式计算的框架。在这个特定的用例中,我们使用 Pig 脚本来表达我们想要对数据执行的 ETL/分析。我们将将该脚本转换为执行相同功能的 Storm 拓扑,然后我们将检查执行该转换涉及的一些复杂性。

为了更好地理解这一点,值得检查 Hadoop 集群中的节点以及在这些节点上运行的进程的目的。假设我们有一个如下图所示的集群:

建立架构

图中显示了两个不同的组件/子系统。第一个是 YARN,这是 Hadoop 2.0 引入的新资源管理层。第二个是 HDFS。让我们首先深入研究 HDFS,因为自 Hadoop 1.0 以来它并没有发生太大变化。

检查 HDFS

HDFS 是一个分布式文件系统。它在一组从节点上分发数据块。NameNode 是目录。它维护目录结构和指示哪些节点具有什么信息的元数据。NameNode 本身不存储任何数据,它只协调分布式文件系统上的 创建、读取、更新和删除(CRUD)操作。存储发生在运行 DataNode 进程的每个从节点上。DataNode 进程是系统中的工作马。它们彼此通信以重新平衡、复制、移动和复制数据。它们对客户端的 CRUD 操作做出反应和响应。

检查 YARN

YARN 是资源管理系统。它监视每个节点的负载,并协调将新作业分配给集群中的从节点。 ResourceManager 收集来自 NodeManagers 的状态信息。ResourceManager 还为客户端的作业提交提供服务。

YARN 中的另一个抽象概念是 ApplicationMaster。ApplicationMaster 管理特定应用程序的资源和容器分配。ApplicationMaster 与 ResourceManager 协商分配资源。一旦分配了资源,ApplicationMaster 就会与 NodeManagers 协调实例化 容器。容器是实际执行工作的进程的逻辑持有者。

ApplicationMaster 是一个特定于处理框架的库。Storm-YARN 提供了在 YARN 上运行 Storm 进程的 ApplicationMaster。HDFS 分发 ApplicationMaster 以及 Storm 框架本身。目前,Storm-YARN 需要外部 ZooKeeper。当应用程序部署时,Nimbus 启动并连接到 ZooKeeper。

以下图表描述了通过 Storm-YARN 在 Hadoop 基础设施上运行 Storm:

检查 YARN

如前图所示,YARN 用于部署 Storm 应用程序框架。在启动时,Storm Application Master 在 YARN 容器内启动。然后,它创建了一个 Storm Nimbus 和 Storm UI 的实例。

之后,Storm-YARN 在单独的 YARN 容器中启动监督员。这些监督员进程中的每一个都可以在其容器内生成工作人员。

应用程序主节点和 Storm 框架都是通过 HDFS 分发的。Storm-YARN 提供了命令行实用程序来启动 Storm 集群,启动监督者,并配置 Storm 以进行拓扑部署。我们将在本章后面看到这些设施。

为了完成建筑图,我们需要分层处理批处理和实时处理机制:分别是 Pig 和 Storm 拓扑。我们还需要描述实际数据。

通常会使用诸如 Kafka 之类的排队机制来为 Storm 集群排队工作。为了简化,我们将使用存储在 HDFS 中的数据。以下描述了我们在使用案例中使用 Pig、Storm、YARN 和 HDFS,为了清晰起见省略了基础设施的元素。为了充分实现从 Pig 转换到 Storm 的价值,我们将转换拓扑以从 Kafka 而不是 HDFS 中获取数据,如下图所示:

检查 YARN

如前图所示,我们的数据将存储在 HDFS 中。虚线表示用于分析的批处理过程,实线表示实时系统。在每个系统中,以下步骤都会发生:

步骤 目的 Pig 等效 Storm-Yarn 等效
1 处理框架已部署 MapReduce 应用程序主节点已部署并启动 Storm-YARN 启动应用程序主节点并分发 Storm 框架
2 特定的分析已启动 Pig 脚本被编译为 MapReduce 作业并提交为一个作业 拓扑被部署到集群
3 资源已保留 在 YARN 容器中创建 Map 和 reduce 任务 监督者与工作人员一起实例化
4 分析从存储中读取数据并执行分析 Pig 从 HDFS 中读取数据 Storm 通常从 Kafka 中读取工作,但在这种情况下,拓扑从一个平面文件中读取它

Pig 和 Trident 之间也可以进行类比。Pig 脚本编译成 MapReduce 作业,而 Trident 拓扑编译成 Storm 拓扑。

有关 Storm-YARN 项目的更多信息,请访问以下网址:

github.com/yahoo/storm-yarn

配置基础设施

首先,我们需要配置基础设施。由于 Storm 将在 YARN 基础设施上运行,我们将首先配置 YARN,然后展示如何配置 Storm-YARN 以部署在该集群上。

Hadoop 基础设施

要配置一组机器,您需要在它们中的每一台上都有一个 Hadoop 的副本或者可以访问到的副本。首先,下载最新的 Hadoop 副本并解压缩存档。在本例中,我们将使用版本 2.1.0-beta。

假设您已将存档解压缩到/home/user/hadoop,在集群中的每个节点上添加以下环境变量:

export HADOOP_PREFIX=/home/user/hadoop
export HADOOP_YARN_HOME=/home/user/hadoop
export HADOOP_CONF_DIR=/home/user/hadoop/etc/Hadoop

将 YARN 添加到执行路径中,如下所示:

export PATH=$PATH:$HADOOP_YARN_HOME/bin

所有 Hadoop 配置文件都位于$HADOOP_CONF_DIR中。本例中的三个关键配置文件是:core-site.xmlyarn-site.xmlhdfs-site.xml

在本例中,我们假设有一个名为master的主节点和四个名为slave01-04的从节点。

通过执行以下命令行来测试 YARN 配置:

$ yarn version
You should see output similar to the following:
Hadoop 2.1.0-beta
Subversion https://svn.apache.org/repos/asf/hadoop/common -r 1514472
Compiled by hortonmu on 2013-08-15T20:48Z
Compiled with protoc 2.5.0
From source with checksum 8d753df8229fd48437b976c5c77e80a
This command was run using /Users/bone/tools/hadoop-2.1.0-beta/share/hadoop/common/hadoop-common-2.1.0-beta.jar

配置 HDFS

根据架构图,要配置 HDFS,您需要启动 NameNode,然后连接一个或多个 DataNode。

配置 NameNode

要启动 NameNode,您需要指定主机和端口。通过使用以下元素在core-site.xml文件中配置主机和端口:

<configuration>
    <property>
        <name>fs.default.name</name>
        <value>hdfs://master:8020</value>
    </property>
</configuration>

另外,配置 NameNode 存储其元数据的位置。此配置存储在hdfs-site.xml文件中的dfs.name.dir变量中。

为了使示例简单,我们还将在分布式文件系统上禁用安全性。为此,我们将dfs.permissions设置为False。在进行这些编辑之后,HDFS 配置文件看起来像以下代码片段:

<configuration>
   <property>
       <name>dfs.name.dir</name>
       <value>/home/user/hadoop/name/data</value>
   </property>
   <property>
       <name>dfs.permissions</name>
       <value>false</value>
   </property>
</configuration>

在启动 NameNode 之前的最后一步是格式化分布式文件系统。使用以下命令进行此操作:

hdfs namenode -format <cluster_name>

最后,我们准备启动 NameNode。使用以下命令:

$HADOOP_PREFIX/sbin/hadoop-daemon.sh --config $HADOOP_CONF_DIR --script hdfs start namenode

启动的最后一行将指示日志的位置:

starting namenode, logging to /home/user/hadoop/logs/hadoop-master.hmsonline.com.out

提示

尽管消息如此,但日志实际上将位于另一个具有相同名称但后缀为log而不是out的文件中。

还要确保您在配置中声明的名称目录存在;否则,您将在日志文件中收到以下错误:

org.apache.hadoop.hdfs.server.common.InconsistentFSStateException: Directory /home/user/hadoop-2.1.0-beta/name/data is in an inconsistent state: storage directory does not exist or is not accessible.

使用以下代码片段验证 NameNode 是否已启动:

boneill@master:~-> jps
30080 NameNode

此外,您应该能够在 Web 浏览器中导航到 UI。默认情况下,服务器在端口 50070 上启动。在浏览器中导航到http://master:50070。您应该看到以下截图:

配置 NameNode

点击Live Nodes链接将显示可用的节点以及每个节点的空间分配,如下截图所示:

配置 NameNode

最后,从主页,您还可以通过点击浏览文件系统来浏览文件系统。

配置 DataNode

一般来说,最容易在集群中的节点之间共享核心配置文件。数据节点将使用core-site.xml文件中定义的主机和端口来定位 NameNode 并连接到它。

此外,每个 DataNode 需要配置本地存储的位置。这在hdfs-site.xml文件中的以下元素中定义:

<configuration>
   <property>
       <name>dfs.datanode.data.dir</name>
       <value>/vol/local/storage/</value>
   </property>
</configuration>

如果这个位置在从节点上是一致的,那么这个配置文件也可以共享。设置好后,您可以使用以下命令启动 DataNode:

$HADOOP_PREFIX/sbin/hadoop-daemon.sh --config $HADOOP_CONF_DIR --script hdfs start datanode

再次使用jps验证 DataNode 是否正在运行,并监视任何错误日志。在几分钟内,DataNode 应该会出现在 NameNode 的Live Nodes屏幕上,就像之前显示的那样。

配置 YARN

HDFS 已经运行起来了,现在是时候把注意力转向 YARN 了。与我们在 HDFS 中所做的类似,我们将首先运行 ResourceManager,然后通过运行 NodeManager 来连接从节点。

配置 ResourceManager

ResourceManager 有各种子组件,每个子组件都充当需要在其上运行的主机和端口的服务器。所有服务器都在yarn-site.xml文件中配置。

对于这个例子,我们将使用以下 YARN 配置:

<configuration>
   <property>
       <name>yarn.resourcemanager.address</name>
       <value>master:8022</value>
   </property>
   <property>
       <name>yarn.resourcemanager.admin.address</name>
       <value>master:8033</value>
   </property>
   <property>
       <name>yarn.resourcemanager.resource-tracker.address</name>
        <value>master:8025</value>
   </property>
   <property>
       <name>yarn.resourcemanager.scheduler.address</name>
       <value>master:8030</value>
   </property>
   <property>
       <name>yarn.acl.enable</name>
       <value>false</value>
   </property>
   <property>
       <name>yarn.nodemanager.local-dirs</name>
       <value>/home/user/hadoop_work/mapred/nodemanager</value>
       <final>true</final>
   </property>
   <property>
     <name>yarn.nodemanager.aux-services</name>
     <value>mapreduce.shuffle</value>
   </property>
</configuration>

在前面的配置文件中,前四个变量分配了子组件的主机和端口。将yarn.acl.enable变量设置为False会禁用 YARN 集群上的安全性。yarn.nodemanager.local-dirs变量指定了 YARN 将数据放置在本地文件系统的位置。

最后,yarn.nodemanager.aux-services变量在 NodeManager 的运行时内启动一个辅助服务,以支持 MapReduce 作业。由于我们的 Pig 脚本编译成 MapReduce 作业,它们依赖于这个变量。

像 NameNode 一样,使用以下命令启动 ResourceManager:

$HADOOP_YARN_HOME/sbin/yarn-daemon.sh --config $HADOOP_CONF_DIR start resourcemanager

再次使用jps检查进程是否存在,监视异常日志,然后您应该能够导航到默认运行在端口 8088 上的 UI。

UI 显示在以下截图中:

配置 ResourceManager

配置 NodeManager

NodeManager 使用相同的配置文件(yarn-site.xml)来定位相应的服务器。因此,在集群中的节点之间可以安全地复制或共享该文件。

使用以下命令启动 NodeManager:

$HADOOP_YARN_HOME/sbin/yarn-daemon.sh --config $HADOOP_CONF_DIR start nodemanager

在所有 NodeManagers 向 ResourceManager 注册之后,您将能够在 ResourceManager UI 中点击Nodes后看到它们,如下截图所示:

配置 NodeManager

部署分析

有了 Hadoop,我们现在可以专注于我们将用于分析的分布式处理框架。

使用 Pig 基础设施执行批量分析

我们将要检查的第一个分布式处理框架是 Pig。Pig 是一个用于数据分析的框架。它允许用户用简单的高级语言表达分析。然后这些脚本编译成 MapReduce 作业。

尽管 Pig 可以从几个不同的系统(例如 S3)中读取数据,但在本例中,我们将使用 HDFS 作为我们的数据存储机制。因此,我们分析的第一步是将数据复制到 HDFS 中。

为此,我们发出以下 Hadoop 命令:

hadoop fs -mkdir /user/bone/temp
hadoop fs -copyFromLocal click_thru_data.txt /user/bone/temp/

上述命令创建了一个数据文件目录,并将点击数据文件复制到该目录中。

要执行 Pig 脚本对该数据,我们需要安装 Pig。为此,我们只需下载 Pig 并在配置了 Hadoop 的机器上展开存档。在这个例子中,我们将使用版本 0.11.1。

就像我们在 Hadoop 中所做的那样,我们将向我们的环境添加以下环境变量:

export PIG_CLASSPATH=/home/user/hadoop/etc/hadoop
export PIG_HOME=/home/user/pig
export PATH=PATH:$HOME/bin:$PIG_HOME/bin:$HADOOP_YARN_HOME/bin

PIG_CLASSPATH变量告诉 Pig 在哪里找到 Hadoop。

在您的环境中有了这些变量之后,您应该能够使用以下命令测试您的 Pig 安装:

boneill@master:~-> pig
2013-10-07 23:35:41,179 [main] INFO  org.apache.pig.Main - Apache Pig version 0.11.1 (r1459641) compiled Mar 22 2013, 02:13:53
...
2013-10-07 23:35:42,639 [main] INFO  org.apache.pig.backend.hadoop.executionengine.HExecutionEngine - Connecting to hadoop file system at: hdfs://master:8020
grunt>

默认情况下,Pig 将读取 Hadoop 配置并连接到分布式文件系统。您可以在先前的输出中看到。它连接到我们的分布式文件系统hdfs://master:8020

通过 Pig,您可以与 HDFS 进行交互,方式与常规文件系统相同。例如,lscat都可以像以下代码片段中所示那样工作:

grunt> ls /user/bone/temp/
hdfs://master:8020/user/bone/temp/click_thru_data.txt<r 3>	157

grunt> cat /user/bone/temp/click_thru_data.txt
boneill campaign7 productX true
lisalis campaign10 productX false
boneill campaign6 productX true
owen campaign6 productX false
collin campaign7 productY true
maya campaign8 productY true
boneill campaign7 productX true
owen campaign6 productX true
olive campaign6 productX false
maryanne campaign7 productY true
dennis campaign7 productY true
patrick campaign7 productX false
charity campaign10 productY false
drago campaign7 productY false

使用 Storm-YARN 基础设施执行实时分析

现在我们已经为批处理工作建立了基础设施,让我们利用完全相同的基础设施进行实时处理。Storm-YARN 使得重用 Hadoop 基础设施进行 Storm 变得容易。

由于 Storm-YARN 是一个新项目,最好是根据源代码构建并使用README文件中的说明创建分发,该文件位于以下 URL:

github.com/yahoo/storm-yarn

构建分发后,您需要将 Storm 框架复制到 HDFS。这允许 Storm-YARN 将框架部署到集群中的每个节点。默认情况下,Storm-YARN 将在 HDFS 上启动用户目录中的 Storm 库作为 ZIP 文件。Storm-YARN 在其分发的lib目录中提供了一个兼容的 Storm 的副本。

假设您在 Storm-YARN 目录中,您可以使用以下命令将 ZIP 文件复制到正确的 HDFS 目录中:

hadoop fs -mkdir /user/bone/lib/
hadoop fs -copyFromLocal ./lib/storm-0.9.0-wip21.zip /user/bone/lib/

然后,您可以通过 Hadoop 管理界面浏览文件系统来验证 Storm 框架是否在 HDFS 中。您应该看到以下截图:

使用 Storm-YARN 基础设施执行实时分析

在 HDFS 上暂存了 Storm 框架后,下一步是为 Storm-YARN 配置本地 YAML 文件。与 Storm-YAML 一起使用的 YAML 文件是 Storm-YAML 和 Storm 的配置。YAML 文件中的 Storm 特定参数将传递给 Storm。

以下代码片段显示了 YAML 文件的示例:

master.host: "master"
master.thrift.port: 9000
master.initial-num-supervisors: 2
master.container.priority: 0
master.container.size-mb: 5120
master.heartbeat.interval.millis: 1000
master.timeout.secs: 1000
yarn.report.wait.millis: 10000
nimbusui.startup.ms: 10000

ui.port: 7070

storm.messaging.transport: "backtype.storm.messaging.netty.Context"
storm.messaging.netty.buffer_size: 1048576
storm.messaging.netty.max_retries: 100
storm.messaging.netty.min_wait_ms: 1000
storm.messaging.netty.max_wait_ms: 5000

storm.zookeeper.servers:
     - "zkhost"

许多参数都是自描述的。但特别注意最后一个变量。这是 ZooKeeper 主机的位置。尽管现在可能并非总是如此,但目前 Storm-YARN 假设您有一个预先存在的 ZooKeeper。

提示

要监视 Storm-YARN 是否仍然需要预先存在的 ZooKeeper 实例,请查看以下链接中提供的信息:

github.com/yahoo/storm-yarn/issues/22

使用 HDFS 中的 Storm 框架和配置的 YAML 文件,启动 YARN 上的 Storm 的命令行如下:

storm-yarn launch ../your.yaml --queue default -appname storm-yarn-2.1.0-deta-demo --stormZip lib/storm-0.9.0-wip21.zip

您指定 YAML 文件的位置,YARN 队列,应用程序的名称以及 ZIP 文件的位置,相对于用户目录,除非指定了完整路径。

提示

YARN 中的队列超出了本讨论的范围,但默认情况下,YARN 配置了一个默认队列,该队列在上述命令行中使用。如果您在现有集群上运行 Storm,请检查 YARN 配置中的capacity-scheduler.xml以查找潜在的队列名称。

执行上述命令行后,您应该会在 YARN 管理屏幕上看到应用程序部署,如下截图所示:

使用 Storm-YARN 基础设施进行实时分析

单击应用程序显示应用程序主管部署的位置。检查 Application Master 的节点值。这就是您将找到 Storm UI 的地方,如下截图所示:

使用 Storm-YARN 基础设施进行实时分析

再深入一级,您将能够看到 Storm 的日志文件,如下截图所示:

使用 Storm-YARN 基础设施进行实时分析

幸运的话,日志将显示 Nimbus 和 UI 成功启动。检查标准输出流,您将看到 Storm-YARN 启动监督者:

13/10/09 21:40:10 INFO yarn.StormAMRMClient: Use NMClient to launch supervisors in container.  
13/10/09 21:40:10 INFO impl.ContainerManagementProtocolProxy: Opening proxy : slave05:35847 
13/10/09 21:40:12 INFO yarn.StormAMRMClient: Supervisor log: http://slave05:8042/node/containerlogs/container_1381197763696_0004_01_000002/boneill/supervisor.log 
13/10/09 21:40:14 INFO yarn.MasterServer: HB: Received allocated containers (1) 13/10/09 21:40:14 INFO yarn.MasterServer: HB: Supervisors are to run, so queueing (1) containers... 
13/10/09 21:40:14 INFO yarn.MasterServer: LAUNCHER: Taking container with id (container_1381197763696_0004_01_000004) from the queue. 
13/10/09 21:40:14 INFO yarn.MasterServer: LAUNCHER: Supervisors are to run, so launching container id (container_1381197763696_0004_01_000004) 
13/10/09 21:40:16 INFO yarn.StormAMRMClient: Use NMClient to launch supervisors in container.  13/10/09 21:40:16 INFO impl.ContainerManagementProtocolProxy: Opening proxy : dlwolfpack02.hmsonline.com:35125 
13/10/09 21:40:16 INFO yarn.StormAMRMClient: Supervisor log: http://slave02:8042/node/containerlogs/container_1381197763696_0004_01_000004/boneill/supervisor.log

上述输出中的关键行已经突出显示。如果导航到这些 URL,您将看到各自实例的监督者日志。回顾我们用于启动 Storm-YARN 的 YAML 文件,注意我们指定了以下内容:

 master.initial-num-supervisors: 2

使用托管 ApplicationMaster 的节点导航到 UI,然后导航到用于启动的 YAML 文件中指定的 UI 端口(ui.port: 7070)。

在浏览器中打开http://node:7070/,其中 node 是 Application Master 的主机。您应该会看到熟悉的 Storm UI,如下截图所示:

使用 Storm-YARN 基础设施进行实时分析

基础设施现在已经准备就绪。要在 YARN 上终止 Storm 部署,可以使用以下命令:

./storm-yarn shutdown -appId application_1381197763696_0002

在上述语句中,appId参数对应于分配给 Storm-YARN 的appId参数,并且在 Hadoop 管理屏幕上可见。

提示

Storm-YARN 将使用本地 Hadoop 配置来定位主 Hadoop 节点。如果您是从不属于 Hadoop 集群的机器启动的,您将需要使用 Hadoop 环境变量和配置文件配置该机器。具体来说,它通过 ResourceManager 启动。因此,您需要在yarn-site.xml中配置以下变量:

yarn.resourcemanager.address

执行分析

有了批处理和实时基础设施,我们可以专注于分析。首先,我们将看一下 Pig 中的处理,然后将 Pig 脚本转换为 Storm 拓扑。

执行批量分析

对于批量分析,我们使用 Pig。Pig 脚本通过计算点击次数和总曝光次数之间的不同客户数量的比率来计算活动的有效性。

Pig 脚本如下所示:

click_thru_data = LOAD '../click_thru_data.txt' using PigStorage(' ')
  AS (cookie_id:chararray,
      campaign_id:chararray,
      product_id:chararray,
      click:chararray);

click_thrus = FILTER click_thru_data BY click == 'true';
distinct_click_thrus = DISTINCT click_thrus;
distinct_click_thrus_by_campaign = GROUP distinct_click_thrus BY campaign_id;
count_of_click_thrus_by_campaign = FOREACH distinct_click_thrus_by_campaign GENERATE group, COUNT($1);
-- dump count_of_click_thrus_by_campaign;

impressions_by_campaign = GROUP click_thru_data BY campaign_id;
count_of_impressions_by_campaign = FOREACH impressions_by_campaign GENERATE group, COUNT($1);
-- dump count_of_impressions_by_campaign;

joined_data = JOIN count_of_impressions_by_campaign BY $0 LEFT OUTER, count_of_click_thrus_by_campaign BY $0 USING 'replicated';
-- dump joined_data;

result = FOREACH joined_data GENERATE $0 as campaign, ($3 is null ? 0 : $3) as clicks, $1 as impressions, (double)$3/(double)$1 as effectiveness:double;
dump result;

让我们更仔细地看一下上述代码。

第一个LOAD语句指定了数据的位置和用于加载数据的模式。通常,Pig 加载非规范化数据。数据的位置是一个 URL。在本地模式下操作时,如前所示,这是一个相对路径。在 MapReduce 模式下运行时,URL 很可能是 HDFS 中的位置。在针对亚马逊网络服务AWS)运行 Pig 脚本时,这很可能是一个 S3 URL。

Load语句之后的后续行中,脚本计算了所有不同的点击次数。在第一行中,它过滤了仅在该列中为True的行的数据集,这表示印象导致了点击次数。过滤后,行被过滤为仅包含不同条目。然后,按广告系列对行进行分组,并计算每个广告系列的不同点击次数。这项分析的结果存储在别名count_of_click_thrus_by_campaign中。

然后,在后续行中计算了问题的第二个维度。不需要过滤,因为我们只想要按广告系列计算印象的计数。这些结果存储在别名count_of_impressions_by_campaign中。

执行 Pig 脚本会产生以下输出:

(campaign6,2,4,0.5)
(campaign7,4,7,0.5714285714285714)
(campaign8,1,1,1.0)
(campaign10,0,2,)

输出中的第一个元素是广告系列标识符。接着是所有不同的点击次数和总印象次数。最后一个元素是效果,即所有不同的点击次数与总印象次数的比率。

执行实时分析

现在,让我们将批处理分析转化为实时分析。对 Pig 脚本的严格解释可能会导致以下拓扑:

Stream inputStream = topology.newStream("clickthru", spout);
Stream click_thru_stream = inputStream.each(
new Fields("cookie", "campaign", "product", "click"), 
new Filter("click", "true"))
.each(new Fields("cookie", "campaign", "product", "click"), 
new Distinct())
                .groupBy(new Fields("campaign"))              
                .persistentAggregate(
new MemoryMapState.Factory(), new Count(), 
new Fields("click_thru_count"))
                .newValuesStream();

Stream impressions_stream = inputStream.groupBy(
new Fields("campaign"))
                .persistentAggregate(
new MemoryMapState.Factory(), new Count(), 
new Fields("impression_count"))
                .newValuesStream();

topology.join(click_thru_stream, new Fields("campaign"),
impressions_stream, new Fields("campaign"), 
  new Fields("campaign", "click_thru_count", "impression_count"))
                .each(new Fields("campaign", 
"click_thru_count", "impression_count"), 
new CampaignEffectiveness(), new Fields(""));

在前述拓扑中,我们将流分成两个独立的流:click_thru_streamimpressions_streamclick_thru_stream包含不同印象的计数。impressions_stream包含印象的总计数。然后使用topology.join方法将这两个流连接起来。

前述拓扑的问题在于连接。在 Pig 中,由于集合是静态的,它们可以很容易地连接。Storm 中的连接是基于每个批次进行的。这不一定是个问题。然而,连接也是内连接,这意味着只有在流之间存在对应元组时才会发出记录。在这种情况下,我们正在从click_thru_stream中过滤记录,因为我们只想要不同的记录。因此,该流的基数小于impressions_stream的基数,这意味着在连接过程中会丢失元组。

提示

对于离散集合,诸如连接之类的操作是明确定义的,但不清楚如何将它们的定义转化为无限事件流的实时世界。有关更多信息,请访问以下 URL:

相反,我们将使用 Trident 的状态构造来在流之间共享计数。

这在以下图表中显示了更正后的拓扑:

执行实时分析

此拓扑的代码如下:

StateFactory clickThruMemory = new MemoryMapState.Factory();
ClickThruSpout spout = new ClickThruSpout();
Stream inputStream = topology.newStream("clithru", spout);
TridentState clickThruState = inputStream.each(
new Fields("cookie", "campaign", "product", "click"),
new Filter("click", "true"))
   .each(new Fields("cookie", "campaign", "product", "click"),
new Distinct())
   .groupBy(new Fields("campaign"))
   .persistentAggregate(clickThruMemory, new Count(),
new Fields("click_thru_count"));

inputStream.groupBy(new Fields("campaign"))
.persistentAggregate(new MemoryMapState.Factory(),
new Count(), new Fields("impression_count"))
.newValuesStream()
.stateQuery(clickThruState, new Fields("campaign"),
new MapGet(), new Fields("click_thru_count"))
.each(new Fields("campaign", "impression_count",
      "click_thru_count"),
new CampaignEffectiveness(), new Fields(""));

让我们先看看 spout。它简单地读取文件,解析行,并发出元组,如下面的代码片段所示:

public class ClickThruEmitter
implements Emitter<Long>, Serializable {
...
@Override
public void emitBatch(TransactionAttempt tx,
Long coordinatorMeta, TridentCollector collector) {
     File file = new File("click_thru_data.txt");
     try {
         BufferedReader br = 
new BufferedReader(new FileReader(file));
         String line = null;
         while ((line = br.readLine()) != null) {
          String[] data = line.split(" ");
          List<Object> tuple = new ArrayList<Object>();
          tuple.add(data[0]); // cookie
          tuple.add(data[1]); // campaign
          tuple.add(data[2]); // product
          tuple.add(data[3]); // click
          collector.emit(tuple);
         }
         br.close();
     } catch (Exception e) {
         throw new RuntimeException(e);
     }
}
     ...
}

在真实系统中,前述 spout 很可能会从 Kafka 队列中读取。或者,如果我们想要重新创建批处理机制正在执行的操作,spout 可以直接从 HDFS 中读取。

提示

有一些关于可以从 HDFS 读取的 spout 的初步工作;请查看以下 URL 以获取更多信息:

github.com/jerrylam/storm-hdfs

为了计算所有点击次数的不同计数,拓扑首先过滤流,仅保留导致点击次数的印象。

此过滤器的代码如下:

public class Filter extends BaseFilter {
    private static final long serialVersionUID = 1L;
    private String fieldName = null;
    private String value = null;

    public Filter(String fieldName, String value){
        this.fieldName = fieldName;
        this.value = value;        
    }

    @Override
    public boolean isKeep(TridentTuple tuple) {
        String tupleValue = tuple.getStringByField(fieldName); 
        if (tupleValue.equals(this.value)) {
          return true;
        }
        return false;
    }
}

然后,流仅过滤出不同的点击次数。在这个例子中,它使用内存缓存来过滤不同的元组。实际上,这应该使用分布式状态和/或分组操作来将相似的元组定向到同一主机。没有持久存储,该示例最终会在 JVM 中耗尽内存。

提示

正在积极研究算法来近似数据流中的不同集合。有关Streaming Quotient FilterSQF)的更多信息,请查看以下网址:

www.vldb.org/pvldb/vol6/p589-dutta.pdf

对于我们的示例,Distinct函数显示在以下代码片段中:

public class Distinct extends BaseFilter {
    private static final long serialVersionUID = 1L;
    private Set<String> distincter = Collections.synchronizedSet(new HashSet<String>());

    @Override
    public boolean isKeep(TridentTuple tuple) {        
        String id = this.getId(tuple);
   return distincter.add(id);
    }

    public String getId(TridentTuple t){
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < t.size(); i++){
           sb.append(t.getString(i));
        }
        return sb.toString();
    }
}

一旦获得所有不同的点击量,Storm 会使用persistAggregate调用将该信息持久化到 Trident 状态中。这通过使用Count运算符来折叠流。在示例中,我们使用了 MemoryMap。然而,在实际系统中,我们很可能会应用分布式存储机制,如 Memcache 或 Cassandra。

处理初始流的结果是一个包含按广告系列标识符分组的所有不同点击量的TridentState对象。连接两个流的关键行如下所示:

.stateQuery(clickThruState, new Fields("campaign"),
new MapGet(), new Fields("click_thru_count"))

这将将初始流中开发的状态合并到第二个流中开发的分析中。实际上,第二个流查询状态机制以获取该广告系列的所有不同点击量,并将其作为字段添加到在此流程中处理的元组中。然后可以利用该字段进行效果计算,该计算封装在以下类中:

public class CampaignEffectiveness extends BaseFunction {
    private static final long serialVersionUID = 1L;

    @Override
    public void execute(TridentTuple tuple, TridentCollector collector) {
   String campaign = (String) tuple.getValue(0);
        Long impressions_count = (Long) tuple.getValue(1);
        Long click_thru_count = (Long) tuple.getValue(2);
        if (click_thru_count == null) 
            click_thru_count = new Long(0);
        double effectiveness = (double) click_thru_count / (double) impressions_count;
   Log.error("[" + campaign + "," + String.valueOf(click_thru_count) + "," + impressions_count + ", " + effectiveness + "]");
   List<Object> values = new ArrayList<Object>();
   values.add(campaign);
   collector.emit(values);
    }
}

如前面的代码所示,该类通过计算包含总计数的字段与状态查询引入的字段之间的比率来计算效果。

部署拓扑

要部署前面的拓扑,必须首先使用以下命令检索 Storm-YAML 配置:

storm-yarn getStormConfig ../your.yaml --appId application_1381197763696_0004 --output output.yaml

前面的命令与指定的 Storm-YARN 应用程序实例交互,以检索可以使用标准机制部署拓扑的storm.yaml文件。只需将output.yaml文件复制到适当的位置(通常为~/.storm/storm.yaml),然后使用标准的storm jar命令进行部署,如下所示:

storm jar <appJar>

执行拓扑

执行前面的拓扑将产生以下输出:

00:00 ERROR: [campaign10,0,2, 0.0]
00:00 ERROR: [campaign6,2,4, 0.5]
00:00 ERROR: [campaign7,4,7, 0.5714285714285714]
00:00 ERROR: [campaign8,1,1, 1.0]

请注意,这些值与 Pig 发出的值相同。如果让拓扑运行,最终会看到效果得分逐渐降低,如下面的输出所示:

00:03 ERROR: [campaign10,0,112, 0.0]
00:03 ERROR: [campaign6,2,224, 0.008928571428571428]
00:03 ERROR: [campaign7,4,392, 0.01020408163265306]
00:03 ERROR: [campaign8,1,56, 0.017857142857142856]

这是有道理的,因为我们现在有了一个实时系统,它不断地消耗相同的印象事件。由于我们只计算所有不同的点击量,并且整个点击量集已经在计算中被考虑,效果将继续下降。

总结

在本章中,我们看到了一些不同的东西。首先,我们看到了将利用 Pig 的批处理机制转换为在 Storm 中实现的实时系统的蓝图。我们看到了直接翻译该脚本将不起作用的原因,因为实时系统中联接的限制,传统的联接操作需要有限的数据集。为了解决这个问题,我们使用了带有分叉流的共享状态模式。

其次,也许最重要的是,我们研究了 Storm-YARN;它允许用户重用 Hadoop 基础设施来部署 Storm。这不仅为现有的 Hadoop 用户提供了快速过渡到 Storm 的途径,还允许用户利用 Hadoop 的云机制,如亚马逊的弹性 Map ReduceEMR)。使用 EMR,Storm 可以快速部署到云基础设施,并根据需求进行扩展。

最后,作为未来的工作,社区正在探索直接在 Storm 上运行 Pig 脚本的方法。这将允许用户直接将其现有的分析移植到 Storm 上。

要监视这项工作,请访问cwiki.apache.org/confluence/display/PIG/Pig+on+Storm+Proposal.

在下一章中,我们将探讨使用 Apache Whirr 在云中自动部署 Storm。虽然没有明确提到,但下一章中的技术可以用于云部署。

第十章:云中的 Storm

在本章中,我们将向您介绍在云提供商的托管环境中部署和运行 Storm。

在第二章 配置 Storm 集群中,您已经了解了在集群环境中设置 Storm 所需的步骤,随后的章节涵盖了 Kafka、Hadoop 和 Cassandra 等辅助技术的安装和配置。虽然大多数安装都相对简单,但即使是维护规模适中的集群的成本——无论是物理资产要求还是配置和维护环境所需的时间——都很容易成为一个负担,甚至是对分布式计算技术采用的直接阻碍。

幸运的是,今天有许多云托管提供商提供按需动态配置多机计算环境的服务。大多数云托管提供商提供各种服务和选项,以满足大多数用户的需求,从单个小型服务器到由数百甚至数千台机器组成的大规模基础设施。事实上,高知名度的互联网内容提供商之间的一个常见趋势是选择云托管提供商而不是内部数据中心。

使用云提供商的一个关键好处是能够根据需要和按需部署和取消部署计算资源。例如,网上零售商可能会在节假日季节前提供额外的服务器和资源,以满足需求,在高峰期后缩减。此外,正如我们将看到的,云提供商提供了一种成本效益的方法来测试和原型化分布式应用程序。

我们将从使用云提供商为 Storm 集群进行配置开始。本章后面,我们将向您展示如何在工作站上的完全集群环境中为测试 Storm 应用程序进行本地虚拟化 Storm 实例的配置和管理。

在本章中,我们将涵盖以下主题:

  • 使用Amazon Web ServicesAWS弹性计算云EC2)配置虚拟机

  • 使用 Apache Whirr 自动配置和部署 Storm 集群到 EC2

  • 使用 Vagrant 在本地环境中启动和配置虚拟化 Storm 集群

介绍 Amazon 弹性计算云(EC2)

Amazon EC2 是亚马逊提供的许多远程计算服务的核心部分。EC2 允许用户按需租用托管在亚马逊网络基础设施上的虚拟计算资源。

我们将从设置 EC2 账户并在亚马逊的 EC2 基础设施上手动启动虚拟机开始。

设置 AWS 账户

建立 AWS 账户很容易,但需要一个 Amazon 账户。如果您还没有 Amazon 账户,请在www.amazon.com/上注册一个。

建立了您的 Amazon 账户后,您可以在aws.amazon.com/上设置 AWS 账户。

AWS 管理控制台

AWS 管理控制台充当了亚马逊提供的所有云服务的主要管理界面。我们主要关注 EC2 服务,因此让我们从登录到 EC2 管理控制台开始,如下面的屏幕截图所示:

AWS 管理控制台

创建 SSH 密钥对

在启动任何 EC2 实例之前,您将需要一个密钥对。要创建新的密钥对,请单击密钥对链接以打开密钥对管理器,如下面的屏幕截图所示:

创建 SSH 密钥对

您将被提示为密钥对命名。输入名称,然后单击按钮。此时,根据您使用的浏览器,您将被提示下载您的私人证书文件,或者文件将自动下载。

非常重要的是,您要确保保管好这个文件,因为该密钥将给您对使用该密钥启动的任何 EC2 映像的完全管理员访问权限。在下载私钥后立即更改其文件权限,以确保它不是公开可读的;例如,使用 UNIX,使用以下命令:

chmod 400 my-keyfile.pem

许多 SSH 客户端将查看密钥文件的权限并发出警告,或拒绝使用公开可读的密钥文件。

手动启动 EC2 实例

创建了密钥对后,您就可以启动 EC2 实例了。

启动 EC2 机器的第一步是选择Amazon Machine ImageAMI)。AMI 是一个虚拟设备模板,可以在 Amazon EC2 上作为虚拟机运行。

亚马逊提供了许多流行操作系统发行版的 AMI,例如 Red Hat、Ubuntu 和 SUSE。对于我们的目的,我们将使用 Ubuntu Server 实例,如下截图所示:

手动启动 EC2 实例

选择了 AMI 后,您将被提示选择一个实例类型。实例类型代表具有不同内存(RAM)、CPU 核心、存储和 I/O 性能的虚拟硬件配置文件。亚马逊按小时收取运行实例的费用,价格从最弱实例类型(t1.micro)的几美分每小时到最强实例类型(hs1.8xlarge)的几美元每小时不等。您选择的类型将取决于您的用例和预算。例如,t1.micro实例(一个 CPU,0.6 GB RAM 和低 I/O 性能)可用于测试目的,但显然不适用于大规模生产负载。

选择实例类型后,您可以通过单击审阅并启动按钮,审阅实例详细信息,然后单击启动来启动虚拟机。然后,您将被提示选择一个用于远程登录和管理实例的密钥对。几分钟后,您的实例将如下截图所示启动并运行:

手动启动 EC2 实例

登录到 EC2 实例

当您启动一个实例时,EC2 将使用您在设置过程中选择的密钥对预配置 SSH,从而允许您远程登录到该机器。要远程登录到实例,您将需要之前下载的私钥文件以及分配给实例的公共 DNS 名称(或公共 IP 地址)。您可以在 EC2 管理控制台中找到这些信息,方法是单击实例并查看详细信息。

现在您可以使用以下命令连接到实例:

ssh -i [keypair] [username]@[public DNS or IP]

例如,以“ubuntu”用户身份使用my-keypair.pem私钥文件进行连接:

ssh -i my-keypair.pem ubuntu@ec2-54-200-221-254.us-west-2.compute.amazonaws.com

Ubuntu 用户在远程主机上具有管理员权限,使您能够按照自己的喜好配置机器。

在这一点上,您可以安装 Storm 或任何其他您喜欢的服务。然而,手动配置大于微小规模集群的实例将很快变得耗时且难以管理。在下一节中,我们将介绍一种自动化此过程的方法,作为更可扩展工作流程的一部分。

介绍 Apache Whirr

Apache Whirr 项目(whirr.apache.org)提供了一个 Java API 和一组 shell 脚本,用于在云提供商(如 Amazon EC2 和 Rackspace)上安装和运行各种服务。Whirr 允许您根据节点数量定义集群的布局,以及控制每个节点上运行哪些服务。Whirr 还配备了一组用于执行管理操作的脚本,例如启动新集群、启动和停止集群以及终止集群。

Whirr 最初是一组用于在 Amazon EC2 上运行 Hadoop 的 shell 脚本,后来发展成包含基于 Apache jclouds (jclouds.apache.org)项目的 Java API,这使其能够支持多个云提供商。Whirr 还扩展到支持许多其他分布式计算服务,如 Cassandra、Elastic Search、HBase、Pig 等。

安装 Whirr

首先下载最新版本并在您用于启动和管理集群的计算机上解压缩:

wget http://www.apache.org/dist/whirr/whirr-0.8.2/whirr-0.8.2.tar.gz
tar -zxf whirr-0.8.2.tar.gz

为了方便起见,将 Whirr 的bin目录添加到系统的PATH环境变量中,这样您就可以从任何目录运行 Whirr 命令,如下所示:

WHIRR_HOME=/Users/tgoetz/whirr-0.8.2
export PATH=$PATH:$WHIRR_HOME/bin

Whirr 使用 SSH 与云实例通信,因此我们将为其创建一个专用密钥对。Whirr 要求密钥没有空密码,如下命令所示:

ssh-keygen -t rsa -P '' -f ~/.ssh/id_rsa_whirr

为了让 Whirr 与您的云提供商账户进行交互,它需要知道您的凭据。对于 EC2,这包括您的 EC2 访问密钥 ID 和您的 EC2 秘密访问密钥。如果您的 AWS 账户是新的,您需要生成新的凭据;否则,您应该已经将您的凭据下载到一个安全的位置。要生成一组新的 EC2 凭据,请执行以下步骤:

  1. 登录到AWS 管理控制台

  2. 点击导航栏右上部的名称,然后选择安全凭据

  3. 展开标题为访问密钥(访问密钥 ID 和秘密访问密钥)的部分,然后点击创建新的访问密钥按钮。

  4. 点击下载密钥文件将您的凭据下载到安全的位置。

您下载的密钥文件将以以下格式包含您的访问密钥 ID 和秘密访问密钥:

AWSAccessKeyId=QRIXIUUTWRXXXXTPW4UA
AWSSecretKey=/oA7m/XW+x1eGQiyxxxTsU+rxRSIxxxx3EbM1yg6

Whirr 为指定云凭据提供了三种选项:命令行参数、集群配置文件或本地凭据文件(~/.whirr/credentials)。我们将使用最后一个选项,因为它是最方便的,如下所示:

mkdir ~/.whirr
echo "PROVIDER=aws-ec2" > ~/.whirr/credentials
echo "IDENTITY=[your EC2 Access Key ID]" >> ~/.whirr/credentials
echo "CREDENTIAL=[your EC2 Secret Access Key]" >> ~/.whirr/credentials

使用 Whirr 配置 Storm 集群

既然我们已经安装了 Whirr,让我们把注意力转向集群配置。Whirr 的配置文件或配方只是包含 Whirr 属性的 Java 属性文件,这些属性定义了集群中节点和服务的布局。

让我们首先看一下启动 3 节点 ZooKeeper 集群所需的最小配置:

whirr.cluster-name=zookeeper
whirr.instance-templates=3 zookeeper

whirr.cluster-name属性只是为集群分配一个唯一标识符,并且在运行管理命令时使用,比如列出集群中的主机或销毁集群。

whirr.instance-template属性定义了集群中节点的数量以及每个节点上运行的服务。在上面的示例中,我们定义了一个由三个节点组成的集群,每个节点分配了 ZooKeeper 角色。

只需定义这两个属性,我们就有足够的信息告诉 Whirr 如何启动和管理 ZooKeeper 集群。Whirr 将对其他所有内容使用默认值。但是,通常有一些选项您可能想要覆盖。例如,我们希望 Whirr 使用我们之前创建的专用密钥对,如下面的代码片段所示:

whirr.private-key-file=${sys:user.home}/.ssh/id_rsa_whirr
whirr.public-key-file=${whirr.private-key-file}.pub

接下来,我们将使用以下代码片段配置 Whirr 所需的硬件规格和我们的集群应该托管的区域:

whirr.image-id=us-east-1/ami-55dc0b3c
whirr.hardware-id=t1.micro
whirr.location=us-east-1

whirr.image-id属性是特定于提供商的,指定要使用的机器映像。在这里,我们指定了一个 Ubuntu 10.04 64 位 AMI。

由于我们只是在测试 Whirr,我们选择了最小(也是最便宜)的实例类型:t1.micro。最后,我们指定我们希望我们的集群部署在us-east-1区域。

要获取公共 AMI 的完整列表,请执行以下步骤:

  1. 从 EC2 管理控制台,从右上角的下拉菜单中选择一个区域。

  2. 在左侧导航窗格中,点击AMIs

  3. 从页面顶部的筛选下拉菜单中,选择公共镜像

Whirr 最彻底地测试了 Ubuntu Linux 映像。虽然其他操作系统可能有效,但如果遇到问题,请尝试使用 Ubuntu 映像再次尝试。

启动集群

我们的 ZooKeeper 集群的配置文件现在如下所示的代码片段:

whirr.cluster-name=zookeeper
whirr.instance-templates=3 zookeeper
whirr.private-key-file=${sys:user.home}/.ssh/id_rsa_whirr
whirr.public-key-file=${whirr.private-key-file}.pub
whirr.image-id=us-east-1/ami-55dc0b3c
whirr.hardware-id=t1.micro
whirr.location=us-east-1

如果我们将这些属性保存到名为zookeeper.properties的文件中,然后可以使用以下命令启动集群:

whirr launch-cluster --config zookeeper.properties

当命令完成时,Whirr 将输出创建的实例列表,以及可用于连接到每个实例的 SSH 命令。

您可以使用以下 SSH 命令登录实例:

[zookeeper]: ssh -i /Users/tgoetz/.ssh/id_rsa_whirr -o "UserKnownHostsFile /dev/null" -o StrictHostKeyChecking=no storm@54.208.197.231 
[zookeeper]: ssh -i /Users/tgoetz/.ssh/id_rsa_whirr -o "UserKnownHostsFile /dev/null" -o StrictHostKeyChecking=no storm@54.209.143.46
[zookeeper]: ssh -i /Users/tgoetz/.ssh/id_rsa_whirr -o "UserKnownHostsFile /dev/null" -o StrictHostKeyChecking=no storm@54.209.22.63

要销毁集群,请使用与启动相同的选项运行whirr destroy-cluster

当您完成集群后,可以使用以下命令终止所有实例:

whirr destroy-cluster --config zookeeper.properties

介绍 Whirr Storm

Whirr Storm 项目(github.com/ptgoetz/whirr-storm)是用于配置 Storm 集群的 Whirr 服务实现。Whirr Storm 支持所有 Storm 守护程序的配置,以及对 Storm 的storm.yaml配置文件的完全控制。

设置 Whirr Storm

要安装 Whirr Storm 服务,只需将 JAR 文件放在$WHIRR_HOME/lib目录中,如下所示:

wget http://repo1.maven.org/maven2/com/github/ptgoetz/whirr-storm/1.0.0/whirr-storm-1.0.0.jar -P $WHIRR_HOME/lib

接下来,通过运行Whirr命令而不带参数来验证安装,以打印 Whirr 可用的实例角色列表。现在列表应该包括 Whirr Storm 提供的角色,如下面的代码片段所示:

$ whirr
…
 storm-drpc
 storm-logviewer
 storm-nimbus
 storm-supervisor
 storm-ui

集群配置

在我们之前的 Whirr 示例中,我们创建了一个由三个节点组成的集群,每个节点只具有 ZooKeeper 角色。Whirr 允许您为节点分配多个角色,这是我们需要为 Storm 集群做的。在我们深入配置 Whirr 为 Storm 做准备之前,让我们看一下 Whirr Storm 定义的不同角色,如下表所示:

角色 描述
storm-nimbus 这是运行 Nimbus 守护程序的角色。每个集群只应分配一个节点给此角色。
storm-supervisor 这是运行监督者守护程序的角色。
storm-ui 这是运行 Storm UI Web 服务的角色。
storm-logviewer 这是运行 Storm 日志查看器服务的角色。此角色只应分配给同时具有storm-supervisor角色的节点。
storm-drpc 这是运行 Storm DRPC 服务的角色。
zookeeper 这个角色由 Whirr 提供。具有此角色的节点将成为 ZooKeeper 集群的一部分。在 Storm 集群中,您必须至少有一个 ZooKeeper 节点,对于多节点 ZooKeeper 集群,节点的数量应该是奇数。

要在 Whirr 配置中使用这些角色,我们以以下格式在whirr.instance-template属性中指定它们:

whirr.instance-templates=[# of nodes] [role 1]+[role 2],[# of nodes] [role 3]+[role n]

例如,要创建一个单节点伪集群,其中所有 Storm 的守护程序都在一台机器上运行,我们将使用以下值作为whirr.instance-template

whirr.instance-template=1 storm-nimbus+storm-ui+storm-logviewer+storm-supervisor+zookeeper

如果我们想要创建一个多节点集群,其中一个节点运行 Nimbus 和 Storm UI,三个节点运行监督者和日志查看器守护程序,以及一个 3 节点的 ZooKeeper 集群,我们将使用以下配置:

whirr.instance-templates=1 storm-nimbus+storm-ui,3 storm-supervisor+storm-logviewer, 3 zookeeper

自定义 Storm 的配置

Whirr Storm 将生成一个storm.yaml配置文件,其中包含nimbus.hoststorm.zookeeper.serversdrpc.servers的值,这些值是根据集群中节点的主机名自动计算的,以及它们被分配的角色。除非特别覆盖,所有其他 Storm 配置参数将继承默认值。请注意,如果尝试覆盖nimbus.hoststorm.zookeeper.serversdrpc.servers的值,Whirr Storm 将忽略它并记录警告消息。

提示

尽管 Whirr Storm 将自动计算和配置集群的nimbus.host值,但在本地运行命令时,您仍需要告诉 Storm 可执行文件 Nimbus 主机的主机名。最简单的方法是,如果您有多个集群,可以使用-c标志指定 nimbus 的主机名,如下所示:

Storm <command> [arguments] –c nimbus.host=<nimbus hostname>

其他 Storm 配置参数可以通过在 Whirr 配置文件中添加以whirr-storm为前缀的键的属性来指定。例如,要为topology.message.timeout.secs参数设置一个值,我们将其添加到 Whirr 配置文件中,如下所示:

whirr-storm.topology.message.timeout.secs=30

上述代码将导致storm.yaml中的以下行:

topology.message.timeout.secs: 30

接受值列表的配置参数可以在 Whirr 配置文件中表示为逗号分隔的列表,例如supervisor.slots.ports的以下配置:

whirr-storm.supervisor.slots.ports=6700,6701,6702,6703

上述代码将产生以下 YAML:

supervisor.slots.ports:
    - 6700
    - 6701
    - 6702
    - 6703

自定义防火墙规则

在 EC2 上启动新的机器实例时,默认情况下,大多数网络端口都会被防火墙阻止。要在实例之间启用网络通信,必须显式配置防火墙规则,以允许特定端口之间的主机之间的入站和出站。

默认情况下,Whirr Storm 将自动创建安全组和防火墙规则,以便 Storm 组件进行通信,例如打开 Nimbus Thrift 端口进行拓扑提交,并在NimbusSupervisor节点之间打开端口 2181,以及 ZooKeeper 节点如下图所示:

自定义防火墙规则

然而,在许多情况下,Storm 的 worker 进程将需要与任意端口上的其他服务进行通信。例如,如果您有一个从外部队列消耗数据的 spout,或者一个写入数据库的 bolt,您将需要额外的防火墙规则来启用该交互。

考虑这样一个情景,我们有一个 spout 从 Kafka 队列中读取数据,并将数据流式传输到一个写入 Cassandra 数据库的 bolt。在这种情况下,我们将使用以下whirr.instance-template值设置我们的集群:

whirr.instance-templates=3 kafka,3 cassandra,1 storm-nimbus,3 storm-supervisor, 3 zookeeper

有了这个设置,我们需要一个防火墙配置,允许每个 Supervisor/worker 节点连接到每个 Kafka 节点的 9092 端口,以及每个 Cassandra 节点的 9126 端口,如下图所示:

自定义防火墙规则

对于这种情况,Whirr Storm 具有配置属性whirr.storm.supervisor.firewall-rules,允许您在集群中的其他节点上打开任意端口。属性值是一个逗号分隔的角色-端口对的列表,如下面的代码片段所示:

whirr.storm.supervisor.firewall-rules=[role1]:[port1],[role2]:[port2]

例如,要为我们的场景设置规则,我们将使用以下设置:

whirr.storm.supervisor.firewall-rules=cassandra:9160,kafka:9092

此配置将指示 Whirr Storm 创建防火墙规则,允许每个 Supervisor 节点连接到每个 Cassandra 节点的 9160 端口,以及每个 Supervisor 节点连接到每个 Kafka 节点的 9092 端口。

介绍 Vagrant

Vagrant (www.vagrantup.com)是一个类似于 Apache Whirr 的工具,它旨在帮助以一种简单和可重复的方式提供虚拟机实例。但是,Whirr 和 Vagrant 在一个关键方面有所不同。Whirr 的主要目的是实现基于云的提供,而 Vagrant 更专注于使用诸如 VirtualBox 和 VMWare 等虚拟化软件的本地虚拟化。

Vagrant 支持多个虚拟机提供程序,包括 VirtualBox (www.virtualbox.org)和 VMWare (www.vmware.com)。在本章中,我们将介绍如何使用 Vagrant 与 VirtualBox,因为它是免费的,并且得到了 Vagrant 的良好支持。

在使用 Vagrant 之前,您必须安装 VirtualBox 的 4.x 版本(Vagrant 尚不支持 5.x 版本)。我们在第二章配置 Storm 集群中介绍了 VirtualBox 的安装,并且不会在此重复这些说明。安装 VirtualBox 主要只是运行安装程序,但如果遇到问题,请参考第二章配置 Storm 集群中的说明。

安装 Vagrant

Linux 软件包和 OS X 和 Windows 的 Vagrant 安装程序可在 Vagrant 网站上找到(www.vagrantup.com/downloads.html)。请确保安装最新版本的 Vagrant,因为它将包括最新的更新和错误修复。安装过程将更新系统的PATH变量以包括 Vagrant 可执行文件。您可以通过打开终端并输入vagrant --version来验证安装:

$ vagrant --version
Vagrant 1.3.5

如果命令因任何原因失败,请查阅 Vagrant 网站以获取常见问题的解决方案。

启动您的第一个虚拟机

使用 Vagrant 启动虚拟机涉及两个步骤。首先,您可以使用以下命令初始化一个新的 Vagrant 项目:

$ vagrant init precise64 http://files.vagrantup.com/precise64.box

A `Vagrantfile` has been placed in this directory. You are now
ready to `vagrant up` your first virtual environment! Please read
the comments in the Vagrantfile as well as documentation on
`vagrantup.com` for more information on using Vagrant.

vagrant init命令的两个参数是 Vagrant boxnameURL。Vagrant box 是专门为 Vagrant 打包的虚拟机映像。由于 Vagrant box 可能相当大(超过 300 MB),Vagrant 将其存储在本地磁盘上,而不是每次都下载。 name参数只是为 box 提供标识符,以便在其他 Vagrant 配置中重复使用,而URL参数告诉 Vagrant 有关 box 的下载位置。

下一步是启动虚拟机,如下所示:

$ vagrant up

如果在本地磁盘上找不到vagrant init命令中指定的 Vagrant box,Vagrant 将会下载它。然后,Vagrant 将克隆虚拟机,启动它,并配置网络,以便从主机机器轻松访问。当命令完成时,将在后台运行一个运行 Ubuntu 12.04 LTS 64 位的 VirtualBox 虚拟机。

然后,您可以使用 SSH 命令登录到机器:

$ vagrant ssh
Welcome to Ubuntu 12.04 LTS (GNU/Linux 3.2.0-23-generic x86_64)

 * Documentation:  https://help.ubuntu.com/
Welcome to your Vagrant-built virtual machine.
Last login: Fri Sep 14 06:23:18 2012 from 10.0.2.2
vagrant@precise64:~$

Vagrant 用户具有管理权限,因此您可以自由地对虚拟机进行任何操作,例如安装软件包和修改文件。完成虚拟机后,您可以使用vagrant destroy命令关闭它并删除所有痕迹:

$ vagrant destroy
Are you sure you want to destroy the 'default' VM? [y/N] y
[default] Forcing shutdown of VM...
[default] Destroying VM and associated drives...

Vagrant 提供了用于操作的其他管理命令,例如暂停、恢复和关闭虚拟机。要了解 Vagrant 提供的命令的概述,请运行vagrant --help命令。

Vagrantfile 和共享文件系统

当我们运行vagrant init命令时,Vagrant 会在我们运行命令的目录中创建一个名为Vagrantfile的文件。该文件描述了项目所需的机器类型以及如何配置和设置这些机器。Vagrantfiles 使用 Ruby 语法编写,即使您不是 Ruby 开发人员,也很容易学习。Vagrant 文件的初始内容将是最小的,并且主要由文档注释组成。删除注释后,我们的 Vagrant 文件看起来像以下代码片段:

VAGRANTFILE_API_VERSION = "2"

Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
  config.vm.box = "precise64"
  config.vm.box_url = "http://files.vagrantup.com/precise64.box"
end

如您所见,该文件只包含我们传递给vagrant init命令的 box 名称和 URL。随后,我们将在构建 Vagrant 项目以配置虚拟化 Storm 集群时进行扩展。

当您使用vagrant up启动机器时,默认情况下 Vagrant 将在虚拟机上创建一个共享文件夹(/vagrant),该文件夹将与项目目录(包含Vagrantfile的目录)的内容同步。您可以通过登录虚拟机并列出该目录的内容来验证此功能

$ vagrant ssh
vagrant@precise64:~$ ls /vagrant/
Vagrantfile

这是我们将存储所有配置脚本和数据文件的地方。虚拟机的vagrant destroy命令会删除所有痕迹,但不会影响项目目录的内容。这使我们能够存储持久的项目数据,这些数据将始终可用于我们的虚拟机。

Vagrant 配置

Vagrant 支持使用 shell 脚本、Puppet 和 Chef 进行配置。我们将使用 shell provisioner,因为它是最容易开始的,除了基本的 shell 脚本之外不需要任何额外的知识。

为了说明 Vagrant shell provisioning 的工作原理,我们将修改我们的 Vagrant 项目,以在 Vagrant 虚拟机中安装 Apache web 服务器。我们将首先创建一个简单的 shell 脚本,使用 Ubuntu 的 APT 软件包管理器安装 Apache2。将以下脚本保存为install_apache.sh,放在与Vagrantfile相同的目录中:

#!/bin/bash
apt-get update
apt-get install -y apache2

接下来,我们将修改我们的Vagrantfile,在 Vagrant 配置虚拟机时执行我们的脚本,添加以下行:

config.vm.provision "shell", path: "install_apache.sh"

最后,配置端口转发,使主机上端口 8080 的请求转发到客户(虚拟)机上的端口 8080:

config.vm.network "forwarded_port", guest: 80, host: 8080

我们完整的 Vagrantfile 现在应该如下所示:

VAGRANTFILE_API_VERSION = "2"

Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
  config.vm.box = "precise64"
  config.vm.box_url = "http://files.vagrantup.com/precise64.box"
  config.vm.provision "shell", path: "install_apache.sh"
  config.vm.network "forwarded_port", guest: 80, host: 8080
end

如果您的虚拟机仍在运行,请立即通过运行vagrant destroy来关闭它,然后执行vagrant up来启动一个新的虚拟机。当 Vagrant 完成时,您应该能够通过将浏览器指向主机机器上的http://localhost:8080来查看默认的 Apache 页面。

使用 Vagrant 配置多机集群

为了使用 Vagrant 模拟虚拟化的 Storm 集群,我们需要一种方法来在单个 Vagrant 项目中配置多台机器。幸运的是,Vagrant 支持多台机器的语法,这使得将现有的单机项目转换为多机配置变得容易。

对于我们的多机设置,我们将定义两台名为www1www2的虚拟机。为了避免主机机器上的端口冲突,我们将主机端口 8080 转发到www1上的端口 80,将主机端口 7070 转发到www2上的端口 80,如下面的代码片段所示:

VAGRANTFILE_API_VERSION = "2"

Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|

  config.vm.define "www1" do |www1|
    www1.vm.box = "precise64"
    www1.vm.box_url = "http://files.vagrantup.com/precise64.box"
    www1.vm.provision "shell", path: "apache.sh"
    www1.vm.network "forwarded_port", guest: 80, host: 8080
  end

  config.vm.define "www2" do |www2|
    www2.vm.box = "precise64"
    www2.vm.box_url = "http://files.vagrantup.com/precise64.box"
    www2.vm.provision "shell", path: "apache.sh"
    www2.vm.network "forwarded_port", guest: 80, host: 7070
  end

end

使用多机设置,运行vagrant up而不带参数将启动Vagrantfile中定义的每台机器。这种行为也适用于 Vagrant 的其他管理命令。要控制单个机器,将该机器的名称添加到命令中。例如,如果我们只想启动www1机器,我们将使用以下命令:

vagrant up www1

同样,要销毁虚拟机,我们将使用以下命令:

vagrant destroy www1

创建 Storm 配置脚本

在第二章配置风暴集群中,我们介绍了在 Ubuntu Linux 上手动安装 Storm 及其依赖项。我们可以利用我们在第二章配置风暴集群中使用的命令,将它们用于创建 Vagrant 配置脚本,以自动化本来需要手动进行的过程。如果您不理解配置脚本中使用的某些命令,请参考第二章配置风暴集群,以获得更深入的解释。

ZooKeeper

ZooKeeper 已经预打包在大多数 Linux 平台上,这使得我们的安装脚本变得简单,让软件包管理器完成大部分工作。以下是安装 ZooKeeper 的命令行:

install-zookeeper.sh

安装 ZooKeeper 的命令如下:

apt-get update
apt-get --yes install zookeeper=3.3.5* zookeeperd=3.3.5*

风暴

Storm 安装脚本稍微复杂,因为它没有预打包,必须手动安装。我们将采用第二章中使用的命令,配置 Storm 集群,将它们组装成一个脚本,并将它们参数化,以便脚本期望一个 Storm 版本字符串作为参数。这将允许我们在不修改安装脚本的情况下轻松切换不同的 Storm 版本,如下面的代码片段所示:

install-storm.sh

apt-get update
apt-get install -y unzip supervisor openjdk-6-jdk

/etc/init.d/supervisor stop

groupadd storm
useradd --gid storm --home-dir /home/storm --create-home --shell /bin/bash storm

unzip -o /vagrant/$1.zip -d /usr/share/
chown -R storm:storm /usr/share/$1
ln -s /usr/share/$1 /usr/share/storm
ln -s /usr/share/storm/bin/storm /usr/bin/storm

mkdir /etc/storm
chown storm:storm /etc/storm

rm /usr/share/storm/conf/storm.yaml
cp /vagrant/storm.yaml /usr/share/storm/conf/
cp /vagrant/cluster.xml /usr/share/storm/logback/
ln -s /usr/share/storm/conf/storm.yaml /etc/storm/storm.yaml 

mkdir /var/log/storm
chown storm:storm /var/log/storm

install-storm.sh脚本利用了 Vagrant 共享目录(/vagrant)的存在。这使我们可以将storm.yamllogback.xml文件放在Vagrantfile旁边的一个便利位置。

storm.yaml文件中,我们将使用主机名而不是 IP 地址,并让 Vagrant 配置名称解析,如下面的代码片段所示:

storm.yaml

storm.zookeeper.servers:
    - "zookeeper"

nimbus.host: "nimbus"

# netty transport
storm.messaging.transport: "backtype.storm.messaging.netty.Context"
storm.messaging.netty.buffer_size: 16384
storm.messaging.netty.max_retries: 10
storm.messaging.netty.min_wait_ms: 1000
storm.messaging.netty.max_wait_ms: 5000

drpc.servers:
  - "nimbus"

Supervisord

install-storm.sh脚本安装了 supervisord 服务,但我们仍然需要配置它来管理 Storm 守护程序。我们将编写一个脚本,用一个服务名称作为参数生成 supervisord 配置,而不是为每个服务创建单独的配置文件,如下面的代码片段所示:

configure-supervisord.sh

echo [program:storm-$1] | sudo tee -a /etc/supervisor/conf.d/storm-$1.conf
echo command=storm $1 | sudo tee -a /etc/supervisor/conf.d/storm-$1.conf
echo directory=/home/storm | sudo tee -a /etc/supervisor/conf.d/storm-$1.conf
echo autorestart=true | sudo tee -a /etc/supervisor/conf.d/storm-$1.conf
echo user=storm | sudo tee -a /etc/supervisor/conf.d/storm-$1.conf

configure-supervisord.sh脚本期望一个表示要管理的 Storm 服务的参数。例如,要为 Nimbus 守护程序生成 supervisord 配置,您可以使用以下命令调用脚本:

sh configure-supervisord.sh nimbus

Storm Vagrantfile

对于我们的 Storm 集群,我们将创建一个具有一个 ZooKeeper 节点、一个 Nimbus 节点和一个或多个 Supervisor 节点的集群。由于Vagrantfile是用 Ruby 编写的,我们可以访问许多 Ruby 语言特性,这将允许我们使配置文件更加健壮。例如,我们将使 Supervisor 节点的数量易于配置。

storm.yaml文件中,我们使用了主机名而不是 IP 地址,这意味着我们的机器必须能够将名称解析为 IP 地址。Vagrant 没有管理/etc/hosts文件中条目的功能,但幸运的是,有一个 Vagrant 插件可以做到这一点。在深入研究 Storm 集群的Vagrantfile之前,使用以下命令安装vagrant-hostmanager插件(github.com/smdahlen/vagrant-hostmanager):

vagrant plugin install vagrant-hostmanager

vagrant-hostmanager插件将为我们集群中的所有机器设置主机名解析。它还有一个选项,可以在主机和虚拟机之间添加名称解析。

接下来,让我们看一下完整的Vagrantfile,并逐行讲解它:

require 'uri'
# Configuration
STORM_DIST_URL = "https://dl.dropboxusercontent.com/s/dj86w8ojecgsam7/storm-0.9.0.1.zip"
STORM_SUPERVISOR_COUNT = 2
STORM_BOX_TYPE = "precise64"
# end Configuration

STORM_ARCHIVE = File.basename(URI.parse(STORM_DIST_URL).path)
STORM_VERSION = File.basename(STORM_ARCHIVE, '.*')

# Vagrantfile API/syntax version. Don't touch unless you know what you're doing!
VAGRANTFILE_API_VERSION = "2"
Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|

  config.hostmanager.manage_host = true
  config.hostmanager.enabled = true
  config.vm.box = STORM_BOX_TYPE

  if(!File.exist?(STORM_ARCHIVE))
    `wget -N #{STORM_DIST_URL}`
  end

  config.vm.define "zookeeper" do |zookeeper|
    zookeeper.vm.network "private_network", ip: "192.168.50.3"
    zookeeper.vm.hostname = "zookeeper"
    zookeeper.vm.provision "shell", path: "install-zookeeper.sh"
  end

  config.vm.define "nimbus" do |nimbus|
    nimbus.vm.network "private_network", ip: "192.168.50.4"
    nimbus.vm.hostname = "nimbus"
    nimbus.vm.provision "shell", path: "install-storm.sh", args: STORM_VERSION
    nimbus.vm.provision "shell", path: "config-supervisord.sh", args: "nimbus"
    nimbus.vm.provision "shell", path: "config-supervisord.sh", args: "ui"
    nimbus.vm.provision "shell", path: "config-supervisord.sh", args: "drpc"
    nimbus.vm.provision "shell", path: "start-supervisord.sh"
  end

  (1..STORM_SUPERVISOR_COUNT).each do |n|
    config.vm.define "supervisor#{n}" do |supervisor|
      supervisor.vm.network "private_network", ip: "192.168.50.#{4 + n}"
      supervisor.vm.hostname = "supervisor#{n}"
      supervisor.vm.provision "shell", path: "install-storm.sh", args: STORM_VERSION
      supervisor.vm.provision "shell", path: "config-supervisord.sh", args: "supervisor"
      supervisor.vm.provision "shell", path: "config-supervisord.sh", args: "logviewer"
      supervisor.vm.provision "shell", path: "start-supervisord.sh"
    end
  end
end

文件的第一行告诉 Ruby 解释器要求uri模块,我们将用它来进行 URL 解析。

接下来,我们设置一些变量,表示 Storm 分发存档的 URL、我们想要的 Supervisor 节点数量,以及我们虚拟机的 Vagrant 盒子类型的名称。这些变量预期由用户更改。

通过使用 Ruby 的FileURI类解析分发 URL,将STORM_ARCHIVESTORM_VERSION的值设置为 Storm 分发的文件名和版本名称。这些值将作为参数传递给配置脚本。

接下来,我们进入主要的 Vagrant 配置部分。我们首先配置vagrant-hostmanager插件如下:

  config.hostmanager.manage_host = true
  config.hostmanager.enabled = true

在这里,我们告诉vagrant-hostmanager插件管理主机和虚拟机之间的主机名解析,并且它也应该管理虚拟机上的/etc/hosts文件。

接下来的块检查 Storm 分发存档是否已经被下载;如果没有,它将使用wget命令进行下载,如下面的代码片段所示:

  if(!File.exist?(STORM_ARCHIVE))
    `wget -N #{STORM_DIST_URL}`
  end

上述代码将把 Storm 存档下载到与Vagrantfile相同的目录中,因此可以被/vagrant共享目录中的配置脚本访问。

接下来的两个代码块配置了 ZooKeeper 和 Nimbus,相对简单。它们包含了我们之前没有见过的两个新指令:

    zookeeper.vm.network "private_network", ip: "192.168.50.3"
    zookeeper.vm.hostname = "zookeeper"

zookeeper.vm.network指令告诉 Vagrant 使用 VirtualBox 主机网络适配器为虚拟机分配特定的 IP 地址。接下来的一行告诉 Vagrant 将虚拟机的主机名设置为特定值。最后,我们调用适用于每个节点的配置脚本。

最后一个代码块配置了监督节点。Ruby 代码创建了一个循环,从1STORM_SUPERVISOR_COUNT的值进行迭代,并允许您设置集群中监督节点的数量。它将根据STORM_SUPERVISOR_COUNT变量指定的监督节点数量动态设置虚拟机名称、主机名和 IP 地址。

启动 Storm 集群

Vagrantfile中定义了我们的集群,并且我们的配置脚本已经就位,我们准备使用vagrant up启动 Vagrant 集群。由于有四台机器,每台上需要安装大量软件,这将需要一些时间。

一旦 Vagrant 完成启动集群,您应该能够从主机机器上查看 Storm UI,网址为http://nimbus:8080。要向集群提交拓扑,可以使用以下命令:

storm jar myTopology.jar com.example.MyTopology my-topology -c nimbus.host=nimbus

总结

在本章中,我们只是初步介绍了在云环境中部署 Storm 的方法,但希望为您介绍了许多可能性,从将其部署到托管的云环境(如 Amazon EC2)到将其部署到您的工作站上的本地云提供商,甚至是内部的虚拟化服务器。

我们鼓励您更深入地探索云托管提供商(如 AWS)以及虚拟化选项(如 Vagrant),以更好地为您的 Storm 部署选择做准备。在第二章介绍的手动安装程序和本章介绍的技术之间,您应该已经具备了找到最适合您需求的开发、测试和部署解决方案的能力。

posted @ 2024-05-21 12:53  绝不原创的飞龙  阅读(5)  评论(0编辑  收藏  举报