《从零开始学Storm》试读:在这一章,你将学会如何在Storm拓扑的不同组件之间传输元组,以及如何部署拓扑到一个运行中的Storm集群。
3.1 什么是拓扑 要使用Storm做实时计算,首先需要创建所谓的“拓扑(Topology)”。一个拓扑是一个有向图的计算。在一个拓扑中的每个节点包含处理逻辑,节点之间的连接显示数据应该如何在节点之间传递。 拓扑的运行是很简单的。首先,打包所有的代码和依赖到一个单独的大jar包中。然后,运行如下命令: storm jar all-my-code.jar backtype.storm.MyTopology arg1 arg2 该命令使用参数arg1和arg2来运行all-my-code.jar包里面的类backtype.storm.MyTopology。类的主要功能是定义了拓扑,并将它提交到Nimbus。storm jar命令部分负责连接Nimbus和上传jar包。 因为拓扑的定义是Thrift结构,而Nimbus是一个Thrift服务,所以可以使用任何编程语言来创建并提交Topology。上面的命令是基于JVM语言实现的最简单方法。 3.2 TopologyBuilder TopologyBuilder是构建拓扑的类,用于指定执行的拓扑。拓扑底层是Thrift结构,由于Thrift API非常冗长,使用TopologyBuilder可以极大地简化建立拓扑的过程。 TopologyBuilder的公有方法如图3.1所示。 图3.1 TopologyBuilder的公有方法 创建和提交拓扑的过程如下:首先,使用new关键字创建一个TopologyBuilder对象,然后调用setSpout方法设置Spout,接着调用setBolt方法设置Bolt,最后调用createTopology方法返回StormTopology对象给submitTopology方法作为输入参数。 创建并提交Topology到Storm集群的完整代码如下: // 创建TopologyBuilder对象 TopologyBuilder builder = new TopologyBuilder(); // 添加一个id为“1”,并行度为5的TestWordSpout对象 builder.setSpout("1", new TestWordSpout(true), 5); // 添加一个id为“2”,并行度为3的TestWordSpout对象 builder.setSpout("2", new TestWordSpout(true), 3); // 添加一个id为“3”,并行度为3的TestWordCounter对象 // 对id为“1”的组件按“word”字段进行分组 // 对id为“2”的组件按“word”字段进行分组 builder.setBolt("3", new TestWordCounter(), 3) .fieldsGrouping("1", new Fields("word")) .fieldsGrouping("2", new Fields("word")); // 添加一个id为“4”,并行度为1的TestGlobalCount对象 // 对id为“1”的组件按全局分组 builder.setBolt("4", new TestGlobalCount(),1) .globalGrouping("1"); Map conf = new HashMap(); // 创建HashMap对象 conf.put(Config.TOPOLOGY_WORKERS, 4); // 设置Worker的数量为4 // 提交拓扑 StormSubmitter.submitTopology("mytopology", conf, builder.createTopology()); 在本地模式(进程中)下运行完全相同的拓扑的代码如下: TopologyBuilder builder = new TopologyBuilder(); // 创建TopologyBuilder对象 // 添加一个id为“1”,并行度为5的TestWordSpout对象 builder.setSpout("1", new TestWordSpout(true), 5); // 添加一个id为“2”,并行度为3的TestWordSpout对象 builder.setSpout("2", new TestWordSpout(true), 3); // 添加一个id为“3”,并行度为3的TestWordCounter对象 // 对id为“1”的组件按“word”字段进行分组 // 对id为“2”的组件按“word”字段进行分组 builder.setBolt("3", new TestWordCounter(), 3) .fieldsGrouping("1", new Fields("word")) .fieldsGrouping("2", new Fields("word")); // 添加一个id为“4”,并行度为1的TestGlobalCount对象 // 对id为“1”的组件按全局分组 builder.setBolt("4", new TestGlobalCount(),1) .globalGrouping("1"); Map conf = new HashMap(); // 创建HashMap对象 conf.put(Config.TOPOLOGY_WORKERS, 4); // 设置Worker的数量为4 conf.put(Config.TOPOLOGY_DEBUG, true); // 设置调试模式为true LocalCluster cluster = new LocalCluster(); // 创建LocalCluster对象 // 提交拓扑 cluster.submitTopology("mytopology", conf, builder.createTopology()); Utils.sleep(10000); // 线程睡眠10秒,即拓扑可以运行10秒 cluster.shutdown(); // 关闭拓扑 3.3 流分组 3.3.1 什么是流分组 流分组是拓扑定义的一部分,为每个Bolt指定应该接收哪个流作为输入。流分组定义了流/元组如何在Bolt的任务之间进行分发。 在集群中,Spout和Bolt并行执行许多任务。如果你在任务层看拓扑是怎样执行的,则会看到流分组的示意图,如图3.2所示。 图3.2 流分组的示例图 当Bolt A的任务发送元组到Bolt B时,它应该发送给Bolt B的哪一个任务呢?“流分组”回答了这个问题,告诉Storm如何在任务集之间发送元组。 在设计拓扑的时候,需要做的一件最重要的事情,就是定义数据如何在组件之间进行交换(流如何被Bolt消耗)。一个流分组指定每个Bolt消耗哪个流,流将如何被消耗。 一个节点可以发出多个数据流,流分组允许我们有选择地接收流。 在深入研究不同类型的流分组之前,让我们先来看看storm-starter项目的WordCountTopology拓扑。WordCountTopology从一个Spout中读取句子,WordCountBolt统计单词的总次数。WordCountTopology的定义代码如下: TopologyBuilder builder = new TopologyBuilder(); builder.setSpout("sentences", new RandomSentenceSpout(), 5); builder.setBolt("split", new SplitSentence(), 8) .shuffleGrouping("sentences"); builder.setBolt("count", new WordCount(), 12) .fieldsGrouping("split", new Fields("word")); SplitSentence为它接收的每个句子的每个单词发送一个元组,WordCount就在内存中保持一个单词到计数的映射。WordCount每次收到一个词,就会更新其状态并发送新单词的计数。 Storm内置了7种流分组方式,通过实现CustomStreamGrouping接口可以实现自定义的流分组。 3.3.2 不同的流分组方式 InputDeclarer接口定义了不同的流分组方式。每当TopologyBuilder的setBolt方法被调用就返回该对象,用于声明一个Bolt的输入流,以及这些流应该如何分组。InputDeclarer接口的完整定义代码如下: public interface InputDeclarer<T extends InputDeclarer> { // 字段分组 public T fieldsGrouping(String componentId, Fields fields); public T fieldsGrouping(String componentId, String streamId, Fields fields); // 全局分组 public T globalGrouping(String componentId); public T globalGrouping(String componentId, String streamId); // 随机分组 public T shuffleGrouping(String componentId); public T shuffleGrouping(String componentId, String streamId); // 本地或者随机分组 public T localOrShuffleGrouping(String componentId); public T localOrShuffleGrouping(String componentId, String streamId); // 无分组 public T noneGrouping(String componentId); public T noneGrouping(String componentId, String streamId); // 广播分组 public T allGrouping(String componentId); public T allGrouping(String componentId, String streamId); // 直接分组 public T directGrouping(String componentId); public T directGrouping(String componentId, String streamId); // 自定义分组 public T customGrouping(String componentId, CustomStreamGrouping grouping); public T customGrouping(String componentId, String streamId, CustomStreamGrouping grouping); public T grouping(GlobalStreamId id, Grouping grouping); } 从InputDeclarer接口中可以看出,流分组的方式主要有fieldsGrouping(字段分组)、globalGrouping(全局分组)、shuffleGrouping(随机分组)、localOrShuffleGrouping(本地或者随机分组)和noneGrouping(无分组)、allGrouping(广播分组)、directGrouping(直接分组)、customGrouping(自定义分组)这8种不同的流分组方式。每个InputDeclarer实例可以有不止一个源,每个源可以用不同的流分组方式来分组。 1. 随机分组 随机分组(Shuffle Grouping)是最常用的流分组方式,它随机地分发元组到Bolt上的任务,这样能保证每个任务得到相同数量的元组。 随机分组执行原子操作,这是非常有用的,例如数学运算。但是,如果操作不能被随机分发的话,应该考虑使用其他的分组方式,例如,在单词统计(WordCount)例子中,需要计算单词,就不适合使用随机分组。 2. 字段分组 字段分组(Fields Grouping)根据指定字段对流进行分组。例如,如果流是按user-id字段进行分组,具有相同user-id的元组总是被分发到相同的任务,具有不同user-id的元组可能被分发到不同的任务。 字段分组是实现流连接和关联,以及大量其他的用例的基础。在实现上,字段分组使用取模散列来实现。 3. 广播分组 广播分组(All Grouping)是指流被发送到所有Bolt的任务中。使用这个分组方式时要小心。 4. 全局分组 全局分组(Global Grouping)是指全部流都发送到Bolt的同一个任务中,再具体一点,是发送给ID最小的任务。 5. 无分组 假定你不关心流是如何分组的,则可以使用这种分组方式。目前这种分组和随机分组是一样的效果,有一点不同的是Storm会把这个Bolt放到Bolt的订阅者的同一个线程中执行。 6. 直接分组 直接分组(Direct Grouping)是一种特殊的分组。这种方式的流分组意味着由元组的生产者决定元组的消费者的接收元组的任务。直接分组只能在已经声明为直接流(Direct Stream)的流中使用,并且元组必须使用emitDirect方法来发射。Bolt通过TopologyContext对象或者OutputCollector类的emit方法的返回值,可以得到其消费者的任务id列表(List<Integer>)。 7. 本地或者随机分组 如果目标Bolt在同一工作进程存在一个或多个任务,元组会随机分配给这些任务。否则,该分组方式与随机分组方式是一样的。 8. 自定义分组 可以自定义流分组的方式,通过实现CustomStreamGrouping接口来创建自定义的流分组。 CustomStreamGrouping接口的定义如下: public interface CustomStreamGrouping extends Serializable { void prepare(WorkerTopologyContext context, GlobalStreamId stream, List<Integer> targetTasks); List<Integer> chooseTasks(int taskId, List<Object> values); } CustomStreamGrouping接口主要有两个方法:prepare和chooseTasks。CustomStreamGrouping接口的具体实现,可以参考如下的代码类: storm.trident.partition.GlobalGrouping storm.trident.partition.IdentityGrouping storm.trident.partition.IndexHashGrouping backtype.storm.testing.NGrouping 让我们来看一个简单的自定义流分组的实现,它来自storm.trident.partition. GlobalGrouping。GlobalGrouping是Trident中实现全局分组功能的自定义流分组类。 GlobalGrouping的类定义代码如下: public class GlobalGrouping implements CustomStreamGrouping { List<Integer> target; @Override public void prepare(WorkerTopologyContext context, GlobalStreamId stream, List<Integer> targets) { List<Integer> sorted = new ArrayList<Integer>(targets); Collections.sort(sorted); target = Arrays.asList(sorted.get(0)); } @Override public List<Integer> chooseTasks(int i, List<Object> list) { return target; } } 自定义流分组的使用是很简单的。假设对ExclamationTopology使用自定义流分组。ExclamationTopology的“exclaim2”Bolt原来是对“exclaim1”Bolt使用随机分组,代码如下: builder.setBolt("exclaim2", new ExclamationBolt(), 2) .shuffleGrouping("exclaim1"); 现在,修改为“exclaim2”Bolt对“exclaim1”Bolt使用自定义流分组GlobalGrouping,代码如下: builder.setBolt("exclaim2", new ExclamationBolt(), 2) .customGrouping("exclaim1", new GlobalGrouping()); 3.4 一个简单的拓扑 下面来看一个简单的拓扑,这是来自storm-starter项目的ExclamationTopology类。ExclamationTopology的定义如下: TopologyBuilder builder = new TopologyBuilder(); builder.setSpout("words", new TestWordSpout(), 10); builder.setBolt("exclaim1", new ExclamationBolt(), 3) .shuffleGrouping("words"); builder.setBolt("exclaim2", new ExclamationBolt(), 2) .shuffleGrouping("exclaim1"); ExclamationTopology包含一个Spout和两个Bolt。Spout发射单词,每个Bolt在输入处追加字符串 “!!!”。节点排列在一条线上:Spout发送给第一个Bolt,第一个Bolt发送给第二个Bolt。如果Spout发送Tuple["bob"]和["john"],那么第二个Bolt将发送单词["bob!!!!!!"]和["john!!!!!!"]。 这段代码使用setSpout和setBolt方法定义节点。这些方法将一个用户指定的id、一个包含了处理逻辑的对象、大量的并行度需要节点作为输入。在这个例子中,Spout给定id“words”和Bolt给定id“exclaim1”和“exclaim2”。 对象包含了处理逻辑,实现了Spout的IRichSpout接口,Bolt的IRichBolt接口。 最后一个参数确定想要多少个节点并行,是可选的。它表明有多少线程应该执行跨集群的组件。如果忽略它,Storm只会对该节点分配一个线程。 setBolt返回一个InputDeclarer对象,用于定义Bolt的输入。在这里,组件“exclaim1”声明,它想读取“words”组件随机分组发送的所有Tuple,“exclaim2”组件声明,它想要读取“exclaim1”组件随机分组发送的所有Tuple。“随机分组”意味着Tuple应该从输入任务到Bolt的任务进行随机分配。有很多方法可对组件之间的数据进行分组。 如果希望组件“exclaim2”读取“words”和“exclaim1”两个组件发送的所有的Tuple,可以编写组件“exclaim2”的定义如下: builder.setBolt("exclaim2", new ExclamationBolt(), 5) .shuffleGrouping("words") .shuffleGrouping("exclaim1"); 正如你可以看到的,输入声明可以被链接到指定Bolt的多个来源。 让我们研究一下这个拓扑的Spout和Bolt的实现。Spout负责发送新消息到Topology。在此Topology的TestWordSpout每100毫秒从列表["nathan", "mike", "jackson", "golda", "bertels"]中随机发送一个的单词作为一个元组。在TestWordSpout中的nextTuple()的实现类似这样: public void nextTuple() { Utils.sleep(100); final String[] words = new String[] {"nathan", "mike", "jackson", "golda", "bertels"}; final Random rand = new Random(); final String word = words[rand.nextInt(words.length)]; _collector.emit(new Values(word)); } 正如可以看到的,实现非常简单。 ExclamationBolt追加字符串“!!!”作为它的输入。让我们看看ExclamationBolt类的完整实现: public static class ExclamationBolt implements IRichBolt { OutputCollector _collector; public void prepare(Map conf, TopologyContext context, OutputCollector collector) { _collector = collector; } public void execute(Tuple tuple) { _collector.emit(tuple, new Values(tuple.getString(0) + "!!!")); _collector.ack(tuple); } public void cleanup() { } public void declareOutputFields(OutputFieldsDeclarer declarer) { declarer.declare(new Fields("word")); } public Map getComponentConfiguration() { return null; } } prepare方法为Bolt提供一个OutputCollector对象,用于从这个Bolt发射Tuple。Tuple可以随时从Bolt发射,从Bolt的prepare、execute或者cleanup方法,甚至是在另一个线程的异步方法发射。这个prepare方法实现简单地把OutputCollector对象作为一个实例变量保存,使得稍后可以在execute方法中使用。 execute方法从一个Bolt的输入接收一个Tuple。这个ExclamationBolt从元组中取出第一个字段,把字符串“!!!”追加到它后面,然后发射出一个新的元组。如果要实现一个订阅多个输入来源的Bolt,通过使用Tuple.getSourceComponent方法,可以找出Tuple来自哪个组件。 在execute方法中有一些其他的东西,即输入元组作为第一个传递参数来发射,输入元组在最后一行确认。这些是Storm的可靠API的一部分,保证没有数据丢失。 当Bolt被关闭时,cleanup方法被调用来清理任何已经打开的资源。但不能保证这个方法会被集群调用,例如,如果节点上运行的任务被取消了,就没有办法调用该方法。cleanup方法适用于当在本地模式(一个Storm集群是在进程中模拟)下运行拓扑,希望能够在没有遭受资源泄漏的情况下,运行和杀死一些拓扑。 declareOutputFields方法声明ExclamationBolt类发射一个字段名为“word”的一元组。 getComponentConfiguration方法允许你配置关于这个组件如何运行的很多参数。 cleanup和getComponentConfiguration方法往往不需要在一个Bolt中实现。可以通过使用一个基类BaseRichBolt更简洁地定义Bolt,这个基类在适当的地方提供了默认的实现。ExclamationBolt可以通过继承BaseRichBolt类,写得更简洁一些: public static class ExclamationBolt extends BaseRichBolt { OutputCollector _collector; public void prepare(Map conf, TopologyContext context, OutputCollector collector) { _collector = collector; } public void execute(Tuple tuple) { _collector.emit(tuple, new Values(tuple.getString(0) + "!!!")); _collector.ack(tuple); } public void declareOutputFields(OutputFieldsDeclarer declarer) { declarer.declare(new Fields("word")); } } 3.5 在本地模式下运行拓扑 下面,我们看看如何在本地模式下运行ExclamationTopology,以及它的工作原理。 Storm有两种操作模式,即本地模式和分布式模式。在本地模式下,Storm通过模拟工作节点的线程在进程中执行完成。本地模式对于测试和开发拓扑是有用的。当你在storm-starter中运行拓扑,它们将在本地模式下运行,可以看到每个组件发射的消息。 在分布式模式下,Storm如同一个集群一样运转。当你提交一个拓扑到主控节点时,也提交了运行拓扑所需的所有代码。主控节点会好好分发你的代码,分配Worker去运行你的拓扑。如果Worker们瘫痪了,主控节点会在别的地方重新分配它们。 在本地模式下运行ExclamationTopology的代码如下: Config conf = new Config(); conf.setDebug(true); conf.setNumWorkers(2); LocalCluster cluster = new LocalCluster(); cluster.submitTopology("test", conf, builder.createTopology()); Utils.sleep(10000); cluster.killTopology("test"); cluster.shutdown(); 首先,创建了一个LocalCluster对象,定义了一个进程内的集群。然后提交拓扑到这个虚拟集群,这等同于提交拓扑到分布的集群。它通过调用submitTopology方法提交一个拓扑到LocalCluster,并把运行中的拓扑的名称、配置和拓扑本身作为输入参数。 名称用来识别拓扑,以便你可以在以后杀死它。一个拓扑将不停地运行下去,直到你杀死它。 配置用于优化运行的拓扑的各个方面。下面指定的两个配置是很常见的。 (1)TOPOLOGY_WORKERS(参考setNumWorkers方法) 该配置指定想要分配多少进程到集群去执行拓扑。拓扑中的每个组件将执行尽可能多的线程。分配到一个特定组件的线程的数量是通过setBolt和setSpout方法配置的。这些线程存在于工作进程中。每个工作流程包含它自身在内的某些组件的某些线程。例如,可能在所有组件指定有300个线程,在配置里面指定了50个工作进程。每个工作进程将执行6个线程,每个线程都可能属于一个不同的组件。你可以调优Storm 拓扑的性能,通过调整每个组件的并行度,以及线程应该在其里面运行的工作进程的数量。 (2)TOPOLOGY_DEBUG(参考setDebug方法) 当该值设置成真时,每一个组件发射的每一个消息都让Storm记录到日志中。这在本地模式下测试拓扑是非常有用的,但当在集群上运行拓扑时,你可能想保持这个选项关闭。 还可以为拓扑设置许多其他的配置。可以在Config的Java文档中找到各种配置的详细信息。 3.6 在生产集群上运行拓扑 在生产集群上运行拓扑,与在本地模式下运行拓扑不同,其步骤如下。 定义拓扑。如果使用的是Java语言,可以使用TopologyBuilder类来定义。 使用StormSubmitter提交拓扑到集群。StormSubmitter需要拓扑的名称、拓扑配置Config对象、TopologyBuilder对象作为输入参数。示例代码如下: Config conf = new Config(); conf.setNumWorkers(20); conf.setMaxSpoutPending(5000); StormSubmitter.submitTopology("mytopology", conf, topology); 创建一个包含代码和所有依赖包(除Storm之外,因为Storm的jar包会添加到Worker节点的类路径上)的jar包。 如果使用Maven,则只需要在pom.xml文件中添加如下几行代码,就可以使用maven-assembly-plugin打包插件的功能,把所有依赖的jar都一起打包。 <plugin> <artifactId>maven-assembly-plugin</artifactId> <configuration> <descriptorRefs> <descriptorRef>jar-with-dependencies</descriptorRef> </descriptorRefs> <archive> <manifest> <mainClass>com.path.to.main.Class</mainClass> </manifest> </archive> </configuration> </plugin> 然后运行mvn assembly:assembly命令进行打包,并得到打包好的jar文件。 注意,请确认已经排除了Storm的jar包,因为集群的类路径上已经存在Storm的jar包了。 使用Storm客户端提交拓扑到集群。 一般,使用Storm客户端的storm jar命令提交jar包到集群。 storm jar命令需要指定jar包的路径、执行拓扑类的名称与输入参数,命令用法如下: storm jar path/to/allmycode.jar org.me.MyTopology arg1 arg2 arg3 其中,path/to/allmycode.jar是jar包的路径,org.me.MyTopology是拓扑类的完整类名,arg1、arg2、arg3是拓扑类的输入参数。 为了使得Storm客户端能够与Storm集群进行通信,需要在~/.storm/storm.yaml文件中配置Nimbus节点的主机名或者IP地址。假如Nimbus节点的主机名为node200,则~/.storm/storm.yaml里面的内容如下: nimbus.host: "node200" 3.6.1 常见的配置 可以为每个拓扑设置大量的配置。一个可以设置的所有配置的列表可以在这个类(backtype.storm.Config)找到。那些前缀为TOPOLOGY的属性可以被特定拓扑所覆盖,其他的是集群配置,不能被覆盖。下面是一些常见的拓扑设置。 (1)Config.TOPOLOGY_WORKERS 这个设置执行topology的工作进程的数量。例如,如果你将这个参数设置为25,则将会有25个Java进程跨集群执行所有的任务。如果你有一个跨拓扑中的所有组件的组合150并行度,每个工作进程将有6个任务作为线程运行。 (2)Config.TOPOLOGY_ACKERS 这是设置任务的数量,该任务将跟踪元组树,当Spout元组已经完全处理时进行检测。Acker是Storm的可靠性模型不可或缺的一部分,你可以在2.5小节“可靠性机制——保证消息处理”阅读到关于它们的更多信息。 (3)Config.TOPOLOGY_MAX_SPOUT_PENDING 这是设置一次可以在Spout任务等待Spout元组的最大数量。等待意味着元组还尚未确认(acked)或失败(failed)。强烈推荐设置这个配置项防止队列溢出。 (4)Config.TOPOLOGY_MESSAGE_TIMEOUT_SECS 这是一个Spout元组在它被认为是失败前必须完全完成的最大超时时间。这个值默认为30秒,这对于大多数拓扑来说是足够的。关于Storm的可靠性模型如何工作可查阅“可靠性机制——保证消息处理”小节以获得更多信息。 (5)Config.TOPOLOGY_SERIALIZATIONS 可以使用这个配置注册更多的序列化器,这样就可以在元组里面使用自定义类型。 3.6.2 杀死拓扑 希望杀死一个拓扑,只需要运行如下的命令: storm kill {stormname} stormname参数是提交拓扑时使用的名称,使用storm kill命令,就可以杀死拓扑。 Storm不会立即杀死拓扑。反而,它使所有的Spout无效,这样它们不会发送新的Tuple。Storm等待若干秒后,该时间由Config.TOPOLOGY_MESSAGE_TIMEOUT_SECS配置项的值决定,摧毁所有的Worker。这就给拓扑足够的时间来完成已存在的Tuple的处理工作。 3.6.3 更新运行中的拓扑 更新运行中的拓扑,目前唯一的方法是先杀死当前拓扑,然后启动一个新的拓扑。一个计划中的特性是实现一个storm swap命令,用一个新的拓扑交换一个运行中的拓扑,保证最小的停机时间,但是两个拓扑不可能在同一时间处理元组。 3.6.4 监控拓扑 监视拓扑的最好方法是使用Storm UI。Storm UI提供了有错误发生的任务和吞吐量的细粒度统计、每个运行中拓扑的每个组件的延迟性能等信息。 也可以查阅集群节点上面的工作日志。 3.7 拓扑的常见模式 Storm拓扑中的一些常见模式主要有: 流连接 批处理 BasicBolt 内存中缓存与字段的组合 计算top N 高效保存最近更新对象缓存的TimeCacheMap 分布式RPC的CoordinatedBolt与KeyedFairBolt 3.7.1 流连接(Stream Join) 流连接基于一些常用字段,把两个或者更多的数据流结合到一起,形成一个新的数据流。拿数据库的表连接与流连接进行对比,一个普通数据库连接有有限的输入和清晰的语义,而一个流连接可以有无限的输入,并且对于应该连接什么在语义上是不明确的。 每个应用的连接类型是不同的,一些应用使用两个流来连接所有元组——不管经过多长时间,另一些应用希望对于每个连接字段每次连接恰好一个元组。在所有这些连接类型中,常见的模式是以相同的方式划分多个输入流。在Storm里对输入流在相同的字段使用字段分组,例如: builder.setBolt("join", new MyJoiner(), parallelism) .fieldsGrouping("1", new Fields("joinfield1", "joinfield2")) .fieldsGrouping("2", new Fields("joinfield1", "joinfield2")) .fieldsGrouping("3", new Fields("joinfield1", "joinfield2")); 当然,不同的流不需要有相同的字段名字。 3.7.2 批处理(Batching) 通常因为效率或者其他原因,希望对一组元组进行批处理而不是单独地处理。例如,可能想要批量更新到数据库或者做一些排序的流连接。 如果想要在数据处理时具有可靠性,正确的方法是当Bolt等待做批处理时,在一个实例变量中保存元组的引用。一旦你做批处理操作,可以ack到你已经保存引用的所有元组。 如果Bolt发射元组,那么你可能想要使用多锚来保证可靠性。这一切都取决于具体的应用。 3.7.3 BasicBolt 许多Bolt遵循读取一个输入元组类似的模式,根据输入元组发射零个或多个元组,然后在execute方法的最后立即ack输入元组。此模式相匹配的Bolt是一些函数和过滤器之类的东西。这是一个常见的模式,Storm提供了接口,名称为IBasicBolt,自动为你实现这种模式。 3.7.4 内存中缓存与字段的组合 在Storm的Bolt内存中保留缓存,是很常见的做法。当你把缓存和一个字段分组进行合并时,缓存就会变得特别大。例如,假如有一个Bolt,扩展短URL到长URL,如bit.ly、t.co等。你可以保留一个短URL到长URL的扩展的LRU缓存,避免反复做相同的HTTP请求,从而提高性能。假设组件urls发射短URL,组件expand扩展短URL到长URL并保留一个内部缓存。在以下代码片段中,可以思考一下两者的区别: builder.setBolt("expand", new ExpandUrl(), parallelism) .shuffleGrouping(1); builder.setBolt("expand", new ExpandUrl(), parallelism) .fieldsGrouping("urls", new Fields("url")); 第二种方法比第一种方法更有效,因为相同的URL总是到相同的任务。这样可以避免重复跨任务中的缓存,使短URL更有可能命中缓存。 3.7.5 流的top N 在Storm里,一个常见的持续计算是一些排序的“流的top N”。假设有一个Bolt发出元组["value", "count"],希望另一个Bolt发射出基于统计的top N元组。最简单的方法是有一个Bolt在流中做全局分组,并且在内存中维护top N的列表。 这种方法显然不能扩展到很大的流,因为整个流都要通过一个任务,单任务的计算能力是有限的。一个更好的方法是跨流的分区并行做很多top N的计算,然后合并那些top N到一起,得到全局的top N。其模式看起来就像这样: builder.setBolt("rank", new RankObjects(), parallellism) .fieldsGrouping("objects", new Fields("value")); builder.setBolt("merge", new MergeObjects()) .globalGrouping("rank"); 这种模式行得通,因为第一个Bolt进行字段分组,给出需要的分区,这在语义上是正确的。这种模式在storm-starter中的一个例子如下: package storm.starter; import backtype.storm.Config; import backtype.storm.testing.TestWordSpout; import backtype.storm.topology.TopologyBuilder; import backtype.storm.tuple.Fields; import storm.starter.bolt.IntermediateRankingsBolt; import storm.starter.bolt.RollingCountBolt; import storm.starter.bolt.TotalRankingsBolt; import storm.starter.util.StormRunner; public class RollingTopWords { private static final int DEFAULT_RUNTIME_IN_SECONDS = 60; private static final int TOP_N = 5; private final TopologyBuilder builder; private final String topologyName; private final Config topologyConfig; private final int runtimeInSeconds; public RollingTopWords() throws InterruptedException { builder = new TopologyBuilder(); topologyName = "slidingWindowCounts"; topologyConfig = createTopologyConfiguration(); runtimeInSeconds = DEFAULT_RUNTIME_IN_SECONDS; wireTopology(); } private static Config createTopologyConfiguration() { Config conf = new Config(); conf.setDebug(true); return conf; } private void wireTopology() throws InterruptedException { String spoutId = "wordGenerator"; String counterId = "counter"; String intermediateRankerId = "intermediateRanker"; String totalRankerId = "finalRanker"; builder.setSpout(spoutId, new TestWordSpout(), 5); builder.setBolt(counterId, new RollingCountBolt(9, 3), 4).fieldsGrouping(spoutId, new Fields("word")); builder.setBolt(intermediateRankerId, new IntermediateRankingsBolt(TOP_N), 4).fieldsGrouping(counterId, new Fields("obj")); builder.setBolt(totalRankerId, new TotalRankingsBolt(TOP_N)).globalGrouping(intermediateRankerId); } public void run() throws InterruptedException { StormRunner.runTopologyLocally(builder.createTopology(), topologyName, topologyConfig, runtimeInSeconds); } public static void main(String[] args) throws Exception { new RollingTopWords().run(); } } 3.7.6 高效保存最近更新缓存对象的TimeCacheMap(已弃用) 有时程序员会希望在内存中保持最近“活跃”的缓存对象,而已经不活动的对象在一段时间后会自动到期。TimeCacheMap是实现这种功能的一种高效数据结构,它提供了钩子,这样当一个对象过期失效,可以插入回调函数。 TimeCacheMap用于清除在配置的秒数内没有更新的到期主键。使用该算法将花费expirationSecs到expirationSecs×(1 + 1 / (numBuckets-1))之间的时间来实际清除到期的消息。运行get、put、remove、containsKey、size操作只花费O(numBuckets)的时间。这种设计的优点是,过期线程只锁定对象O(1)时间,意味着对象本质上总可以进行get/put操作。 目前,该类backtype.storm.utils.TimeCacheMap<K,V>已经弃用。 3.7.7 分布式RPC的CoordinatedBolt与KeyedFairBolt 在Storm上构建分布式RPC应用有两种常见的模式,它们封装在CoordinatedBolt和KeyedFairBolt中,属于Storm代码库附带的“标准库”的一部分。 CoordinatedBolt封装了包含你的逻辑的Bolt,当你的Bolt已收到所有元组,就会为任何给定的请求计算出结果。它大量使用直接流(Direct Stream)来做到这一点。 KeyedFairBolt也封装了包含你的逻辑的Bolt,并且保证你的拓扑可以同时处理多个DRPC调用,而不是串行地一次处理一个。 3.8 本地模式与StormSubmitter的对比 现在,已经使用一个名为LocalCluster的工具在本地计算机上运行Topology。在计算机上运行Storm基础设施,可以很容易地运行与调试不同的Topology。但如果你想要提交你的Topology到运行中的Storm集群呢?Storm的一个有趣特性是,它很容易发送你的Topology去运行在一个真正的集群中。你需要做的是将LocalCluster改为StormSubmitter,实现submitTopology方法,submitTopology方法负责发送Topology到集群。 可以在下面的代码中看到变化: // LocalCluster cluster = new LocalCluster(); // cluster.submitTopology("Count-Word-Topology-With-Refresh-Cache", conf, builder.createTopology()); StormSubmitter.submitTopology("Count-Word-Topology-With-Refresh-Cache", conf, builder.createTopology()); // Thread.sleep(1000); // cluster.shutdown(); 当使用StormSubmitter时,不能在代码中控制集群,这和LocalCluster是不一样的。 接下来,需要打包源代码到一个jar文件中。当运行Storm客户端命令提交Topology时,会发送该jar文件。如果你使用Maven,唯一需要做的就是到源代码文件夹下运行以下命令: mvn package 一旦生成了jar文件,就可以使用storm jar命令来提交Topology。语法如下: Storm jar allmycode.jar org.me.MyTopology arg1 arg2 arg3 在这个例子中,在Topology源代码项目文件夹下运行如下命令: storm jar target/Topologies-0.0.1-SNAPSHOT.jar countword.TopologyMain src/main/resources/words.txt 使用完这些命令,就会提交Topology到集群中。 为了停止或者杀死Storm,可以运行如下命令: storm kill Count-Word-Topology-With-Refresh-Cache Topology的名字必须具有唯一性。 本地模式(Local mode) 本地模式在进程中模拟了一个Storm集群,用于开发和测试Topology。在本地模式下运行Topology类似于在集群上运行Topology。 只需使用LocalCluster类就可以创建一个进程内的集群,例如: import backtype.storm.LocalCluster; LocalCluster cluster = new LocalCluster(); 然后,可以使用LocalCluster对象的submitTopology方法来提交Topology。就像在StormSubmitter中相应的方法一样,submitTopology方法需要一个名字、一个Topology配置和Topology对象。然后,你可以使用killTopology方法,将Topology名称作为参数,杀死一个Topology。 关闭一个本地集群,只需要简单地调用: cluster.shutdown(); 1. 常见的本地模式的配置 acktype.storm.Config类用来配置Storm,它的继承关系如下: java.lang.Object └java.util.AbstractMap<K,V> └java.util.HashMap<java.lang.String,java.lang.Object> └backtype.storm.Config All Implemented Interfaces: java.io.Serializable, java.lang.Cloneable, java.util.Map<java.lang.String, java.lang.Object> 2. Config.TOPOLOGY_MAX_TASK_PARALLELISM 这个配置项是组件产生线程数量的上限。通常生产环境的拓扑并行度很大(数以百计的线程),可以尝试在本地模式下测试拓扑,找出不合理负荷的地方。这个配置项使你可以很容易地控制并行度。 3. Config.TOPOLOGY_DEBUG 当设置为true时,每次从Spout或者Bolt发送元组,Storm都会写进日志,这对于调试程序是非常有用的。 3.9 多语言协议(Multi-Language Protocol) 本节将解释Storm多语言协议,适用于Storm 0.7.1及以后的版本,0.7.1之前的版本使用不同的协议,有兴趣的读者可以参考下列链接中的英文原文: https://github.com/nathanmarz/storm/wiki/Storm-multi-language-protocol- (versions-0.7.0-and-below) 支持多种语言是通过ShellBolt、ShellSpout、ShellProcess类实现的,这些类实现了IBolt、ISpout接口,可使用Java的ProcessBuilder类调用shell脚本或程序执行协议。 输出字段(Output field)是Thrift定义拓扑的一部分。这意味着当你在Java中使用多语言时,需要创建一个继承ShellBolt实现IRichBolt接口的Bolt,并且在该Bolt的declareOutputFields方法中声明字段。ShellSpout也是一样。 通过STDIN和STDOUT执行脚本或程序,可以实现一个简单的协议。所有处理的数据交换都是以JSON编码的,因此支持几乎任何可能的语言。 在集群上运行一个Shell组件提交给主控节点,shell脚本必须在jar包的resources目录中。 但是,如果是在本地机器上进行开发或测试,只需要resources目录在类路径(classpath)中即可。 1. 协议(Protocol) 协议的两端用行读机制,所以一定要从输入去掉换行符,并把它们添加到输出。 所有JSON的输入输出被终止,通过单行含有“end”。请注意,此分隔符本身不是JSON编码。 2. 初始握手(Initial Handshake) 初始握手对于这两种类型的shell组件是相同的: STDIN:设置信息。这是一个包含Storm配置、拓扑上下文、PID目录的JSON对象。 { "conf": { "topology.message.timeout.secs": 3, // etc }, "context": { "task->component": { "1": "example-spout", "2": "__acker", "3": "example-bolt" }, "taskid": 3 }, "pidDir": "..." } 你的脚本应该在这个目录创建一个以PID命名的空文件。例如,PID为1234,所以在目录中创建一个名为1234的空文件。这个文件使Supervisor知道PID,以后它可以用来关闭进程。 STDOUT:PID的JSON对象类似是这样的: {"PID":1234 } shell组件将记录PID到日志中。接下来会发生什么取决于组件的类型。 3. Spouts Shell Spout是同步的。其他发生在“while(true)”循环中: STDIN:next、ack或者fail命令。 “next”是ISpout的nextTuple相等。它看起来像: {"command": "next"} “ack”看起来像: {"command": "ack", "id": "1231231"} “fail”看起来像: {"command": "fail", "id": "1231231"} STDOUT:Spout的前面命令的结果。这可以是一个emit序列和log。 “emit”看起来像: { "command": "emit", // The id for the tuple. Leave this out for an unreliable emit. The id can // be a string or a number. "id": "1231231", // The id of the stream this tuple was emitted to. Leave this empty to emit to default stream. "stream": "1", // If doing an emit direct, indicate the task to send the tuple to "task": 9, // All the values in this tuple "tuple": ["field1", 2, 3] } 如果不做直接emit,你将马上在STDIN收到发射元组的任务id作为JSON数组。 “log”会在Worker的日志中记录一条消息。它看起来像: { "command": "log", // the message to log "msg": "hello world!" } STDOUT:“sync”命令结束emit序列和log。它看起来像: {"command": "sync"} 在sync命令后,ShellSpout不会读取输出,直到它发送另一个next、ack或fail命令。 注意,类似于ISpout,Worker中所有的Spout在next、ack或者fail命令之后被锁定,直到sync命令。对于ISpout,如果对next没有发射元组,你应该在sync之前sleep少量时间。ShellSpout不会自动为你sleep。 4. Bolts Shell Bolt协议是异步的。你会在STDIN中获得元组只要元组可用,你可以在任何时间emit、ack、fail和log,并写到STDOUT。 STDIN:一个元组。这是一个JSON编码结构,是这样的: { // The tuple's id - this is a string to support languages lacking 64-bit precision "id": "-6955786537413359385", // The id of the component that created this tuple "comp": "1", // The id of the stream this tuple was emitted to "stream": "1", // The id of the task that created this tuple "task": 9, // All the values in this tuple "tuple": ["snow white and the seven dwarfs", "field2", 3] } STDOUT:ack、fail、emit、log.。 “emit”看起来像: { "command": "emit", // The ids of the tuples this output tuples should be anchored to "anchors": ["1231231", "-234234234"], // The id of the stream this tuple was emitted to. Leave this empty to emit to default stream. "stream": "1", // If doing an emit direct, indicate the task to send the tuple to "task": 9, // All the values in this tuple "tuple": ["field1", 2, 3] } 如果不做直接emit,你将收到的任务id,从STDIN上发出的元组的任务id,作为一个JSON数组。注意,由于shell bolt协议的异步特性,当你在emit后读取,你可能接收不到任务id。然而,你可以为前一次emit替换读取任务id或者处理一个新的元组。你将收到与相应emit相同顺序的任务id列表。 “ack”看起来像: { "command": "ack", // the id of the tuple to ack "id": "123123" } “fail”看起来像: { "command": "fail", // the id of the tuple to fail "id": "123123" } “fail”会在Worker日志中记录消息。它看起来像: { "command": "log", // the message to log "msg": "hello world!" } Storm 0.7.1版本不再需要shell bolt做“sync”操作。 3.10 使用非JVM语言操作Storm 3.10.1 支持的非Java语言 Storm支持如下的非Java的DSL(Domain Specific Language,领域特定语言)。 Scala DSL,项目主页为:https://github.com/velvia/ScalaStorm。 JRuby DSL,项目主页为:http://github.com/colinsurprenant/storm-jruby。 Clojure DSL,项目主页为:http://github.com/nathanmarz/storm/wiki/Clojure-DSL。 io-storm,项目主页为:https://github.com/gphat/io-stormPerl。 3.10.2 对Storm使用非Java语言 对Storm使用非Java语言分为两部分:使用非Java语言创建拓扑以及使用非Java语言实现Spout和Bolt。 使用非Java创建拓扑是很容易的,因为拓扑是Thrift结构的,可以参考storm.thrift。 使用非Java语言实现Spout和Bolt会调用多语言组件或者shelling。 这里有一个规范的协议:Multilang protocol。 Thrift结构允许你显式定义多语言组件作为一个程序和脚本,例如python和文件实现你的Bolt的文件。 在Java中,你会覆盖ShellBolt或者ShellSpout来创建多语言组件。 注意,输出字段声明(output fields declaration)中发生的thrift结构,所以在Java中创建多语言组件如下。 在Java中声明字段,通过在ShellBolt的构造函数中指定它,使用其他语言来处理代码。 多语言在stdin/stdout中使用json消息与子流程进行通信。 Storm由ruby、python和fancy的适配器来实现协议。 python支持发射(Emitting)、锚定(Anchoring)、确认(Acking)和日志记录(Logging)操作。 “storm shell”命令使得构建jar和上传到Nimbus变得容易。 可使用Nimbus的主机名、端口和jar文件的id调用你的程序 3.10.3 实现非Java DSL的笔记 正确的起点是src/storm.thrift。因为Storm拓扑是Thrift结构,Nimbus是一个Thrift守护进程,你可以使用任何语言创建和提交拓扑。 当你为Spout和Bolt创建Thrift结构时,应该在ComponentObject结构体中指定Spout或者Bolt。 union ComponentObject { 1: binary serialized_java; 2: ShellComponent shell; 3: JavaObject java_object; } 对于一个非Java的DSL,你应该会使用“2”和“3”。ShellComponent允许你指定一个脚本运行该组件,例如,python代码。JavaObject允许你为组件指定本地java的Spout和Bolt,Storm将使用反射来创建Spout或者Bolt。 “storm shell”命令将帮助你提交一个拓扑。它的用法如下: storm shell resources/ python topology.py arg1 arg2 storm shell然后会把“resources/”打包到jar文件,并上传jar到Nimbus。调用你的Topology.py的脚步命令如下: python topology.py arg1 arg2 {nimbus-host} {nimbus-port} {uploaded-jar-location} 然后,可以使用Thrift API连接到Nimbus,提交拓扑,传送{uploaded-jar-location}到submitTopology方法。下面是submitTopology的定义。 void submitTopology(1: string name, 2: string uploadedJarLocation, 3: string jsonConf, 4: StormTopology topology) throws (1: AlreadyAliveException e, 2: InvalidTopologyException ite); 3.11 Hook Storm提供了Hook(钩子),使用它可以在Storm内部插入自定义代码来运行任意数量的事件。可以通过继承BaseTaskHook类创建一个Hook,为要捕获的事件重写适当的方法。有两种方法来注册你的钩子: 在Spout的open()方法或者Bolt的prepare()方法中,使用TopologyContext#addTaskHook。 通过在Storm的配置中使用topology.auto.task.hooks配置。这些钩子在每个Spout或者Bolt中自动注册,它们对于在自定义的监控系统中进行集成是很有用的。 3.12 本章小结 本章介绍了Storm拓扑的相关内容,包括什么是Storm拓扑、TopologyBuilder及其使用、流分组、拓扑定义的例子,以及如何在本地模式与生产集群上运行拓扑、拓扑的常见模式等。
回试读目录 ZooKeeper是针对分布式应用的高性能协调服务,是高效可靠的协同工作系统,它提供的功能包括配置维护、名字服务、分布式同步、组服务等。Storm使用ZooKeeper存储各个节点的状态信息。 本章介绍了ZooKeeper的定义,ZooKeeper的下载、部署、配置与运行,ZooKeeper的本地模式实例,ZooKeeper的数据模型,ZooKeeper的命令行操作范例,以及Storm在ZooKeeper中的目录结构等内容。