Storm基础篇四—消息的可靠性保证

901 阅读14分钟

本文已参与「掘力星计划」,赢取创作大礼包,挑战创作激励金。

Guaranteeing Message Processing

Storm提供了几个不同级别的消息保证机制,包括:
best effort:尽力交付
at least once:至少执行一次
exactly once:通过Trident实现,exactly once指的是最终的处理结果是exactly once的,不是说对输入的数据只恰好处理一次。以计数为例,exactly once指的是写出的最终的结果与输入的数据一致,一条不多一条不少。
本文主要阐述了Storm如何保证消息的at least once。

什么是消息的完整性处理?

一个 tuple 可以在它的基础上创建数千个 tuples,考虑如下情况:有这样一个 word count 的 topology:

TopologyBuilder builder = new TopologyBuilder();
builder.setSpout("sentences", new KestrelSpout("kestrel.backtype.com",
                                               22133,
                                               "sentence_queue",
                                               new StringScheme()));
builder.setBolt("split", new SplitSentence(), 10)
        .shuffleGrouping("sentences");
builder.setBolt("count", new WordCount(), 20)
        .fieldsGrouping("split", new Fields("word"));

Topology 从一个 Kestrel 队列中读取句子,将句子拆分成单词,然后然后将它每个单词和该单词的数量发送出去。这种情况下,从 spout 中发出的 tuple 就会产生很多基于它创建的新 tuple:包括句子中单词的 tuple 和 每个单词的个数的 tuple。这些消息构成了这样一棵树,如下图所示:

Tuple tree

当 tuple 树发送完成,且树中的每个消息都得到处理,那么 storm 认为该 tuple 得到了完整性处理。相应的,如果在指定的超时时间内 tuple 树中有消息没有完成处理就意味着这个 tuple 失败了。这个超时时间可以通过Config.TOPOLOGY_MESSAGE_TIMEOUT_SECS参数在构建topology时配置,默认值为30s。

消息在得到完整性处理或者处理失败时会发生什么?

为了理解这个问题,我们先了解一下 tuple 的生命周期。下面时定义 spout 的接口(更多详情请见Javadoc):

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);
}

首先,通过调用spoutnextTuple方法,Storm 向Spout请求一个 tuple。Spout 会使用 open 方法中提供的SpoutOutputCollector 发送一个 tuple 到它的一个输出数据流中。当发送 tuple 的时候,Spout 会提供一个 “消息 id”,这个 id 会在后续过程中用于识别 tuple。例如,上面的 KestrelSpout 就是从一个 kestrel 队列中读取一条消息,然后再发送一条带有“消息 id”的消息,这个 id 是由 Kestrel 提供的。使用 SpoutOutputCollector 发送消息的格式一般如下:

_collector.emit(new Values("field1", "field2", 3) , msgId);

接下来,Tuple 发送至消费的 bolts 并且由 Storm 追踪它创建的消息树。如果 Storm 检测到一个 tuple 被完整处理,Storm 会根据 Spout 提供的“消息 id”调用最初发送 tuple 的 Spout 的 ack 方法。相反,如果一个 tuple 超时,Storm 会调用Spoutfail方法。Note: 对于特定的 tuple 响应ack 或者 fail 都只会由最初创建这个 tuple 的Spout任务来执行。所以如果一个Spout在集群中有许多的任务,但对于某个特定的 tuple,它也只会由创建它的那个任务而不是其他的任务来响应处理成功或失败。

我们再以 KestrlSpout 为例来看一下Spout需要做什么来保证消息的可靠性处理。当KestrelSpout从 Kestrel 队列中拿到一条消息,可以看作它“打开”了这条消息。也就是说,这条消息并没有从队列中拿出来,而是处于一个“挂起”状态等待消息完成的确认。当处于“挂起”状态时,消息不会发送给队列的其它消费者。此外,如果客户端断开连接,所以挂起状态的消息将放回队列中。当一条消息被“打开”时,Kestrel 会给客户端同时提供消息体的数据以及一个唯一的 id。当KestrelSpout在使用SpoutOutputCollector发送 tuple 的时候就会把这个唯一的 id 当作“消息 id”。一段时间后,当KestrelSpoutack或者fail方法被调用的时候,KestrelSpout 就会通过这个消息 id 向 Kestrel 请求将消息从队列中移除(ack)或者将消息重新放回队列(fail)。

Storm 的可靠性API What is Storm's reliability API?

作为用户,若需要利用 Storm 的可靠性,需要做如下两件事:

  1. 创建一个元组时(消息树上创建一个新节点)需要通知Storm。
  2. 当你处理完一个 tuple,你需要通知 Storm。 通过这两个操作,Storm 才能检测到 tuple 树什么时候处理完成并适时的调用ackfail方法。Storm API 提供了一种简明的方式来完成这两项任务。

为 tuple 树指定一个连接(添加一个子节点)的操作叫做 anchoring(锚定)。当你发射一个元组时锚定操作也同步完成。以下面一个 bolt 为例,这个 bolt 的功能是将句子 tuple 拆分为单词 tuple:

public class SplitSentence extends BaseRichBolt {
        OutputCollector _collector;

        public void prepare(Map conf, TopologyContext context, OutputCollector collector) {
            _collector = collector;
        }

        public void execute(Tuple tuple) {
            String sentence = tuple.getString(0);
            for(String word: sentence.split(" ")) {
                _collector.emit(tuple, new Values(word));
            }
            _collector.ack(tuple);
        }

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

通过指定输入 tuple 作为emit方法的第一个参数,每个单词 tuple 就被锚定了。当单词 tuple 被锚定,如果单词 tuple 在后续处理过程中失败了,作为这棵 tuple 树的根节点的原始 Spout tuple 就会被重新处理。相对应的,我们来看一下如果在emit时未指定输入的 tuple 会发生什么,如下所示:

_collector.emit(new Values(word));

这样发射单词 tuple 就叫做unanchored(非锚定)。如果 tuple 在下游处理失败,root tuple 将不会被重发。有时候发送这种“非锚定” tuple 也是必要的,这取决于你的拓扑对容错性的要求。

一个输出 tuple 可以被锚定到多个输入 tuple 上。这在流式连接或者聚合操作时非常有用。多锚定的 tuple 处理失败将导致多个对应的 tuple 重发。多锚定操作是通过指定一个 tuple 列表而不是单一的 tuple 来实现的。例如:

List<Tuple> anchors = new ArrayList<Tuple>();
anchors.add(tuple1);
anchors.add(tuple2);
_collector.emit(anchors, new Values(1, 2, 3));

多锚定操作会把输出 tuple 添加到多棵 tuple 树中。注意,多锚定也可能会打破树形结构而形成一个 tuple 的有向无环图(DAG),如下图所示:
Tuple DAG

tuple A 触发了 tuple B 和 tuple C,然后由B和C共同产生了 tuple C(简单举例“hello world”句子拆分为“hello”、“world”单词,然后又生成了“hello world!”)。Storm 程序对树形和DAGs同样支持(早期的 Storm 版本仅仅对树有效)

锚定就是指定 tuple 树的结构--下一步,当完成了一个 tuple 树的处理,Storm 的可靠性API会通过OutputCollectorackfail方法确认 tuple。例如上面所提的SplitSentence的例子,你就会发现输入 tuple 是在所有的单词 tuple 发送出去之后被 ack 的。

你可以使用OutputCollectorfail方法来使得位于 tuple 树根节点的 Spout tuple 立即失败。例如,你的应用可能从数据库客户端捕获一个异常,就通知storm 输入 tuple 处理失败。这个时候输入元组就不用等待超时就能更快的被处理。

每个待处理的 tuple 都必须显式地应答(ack 或 fail)。因为 Storm 是使用内存来跟踪每个 tuple 的,所以,如果你不对每个 tuple 进行应答,那么任务很快就会发生内存溢出。

Bolts 处理 tuple 的一种通用模式是在execute方法中读取输入 tuple、发送出基于输入 tuple 的新 tuple,然后在方法末尾对 tuple 进行应答。这些 Bolt 大多属于过滤器或者简单的处理函数一类。Storm z中有一个封装了这个模式的的简单接口BasicBolt。例如,如果使用 BasicBoltSplitSentence的例子可以这样写:

public class SplitSentence extends BaseBasicBolt {
        public void execute(Tuple tuple, BasicOutputCollector collector) {
            String sentence = tuple.getString(0);
            for(String word: sentence.split(" ")) {
                collector.emit(new Values(word));
            }
        }

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

这个实现比之前的实现更为简单,并且在语义上完全相同。发送到BasicOutputCollector的 tuple 会被自动锚定到输入 tuple 上,而且输入 tuple 会在 execute 方法结束的时候自动应答。

相应的,执行聚合或者连接操作的 Bolt 可能需要延迟应答 tuple,因为它需要等待一批 tuple 来完成某种结果计算。聚合和连接操作一般也会需要对他们的输出 tuple 进行多锚定。这些都超出了IBasicBolt简单模式的应用范围。

在 tuple 可以被重发的前提下,如何让我的应用正确的运行?

在软件设计中,这个问题的答案总是“视情况而定”。如果你真的需要exactly once的语义,可以使用Trident API。在某些情况下,比如在大量分析中,丢弃数据是可以的,可以通过将acker bolt的数量置为 0 Config.TOPOLOGY_ACKERS 来禁用容错功能。但在某些情况下,你希望确保所有内容至少被处理一次(at least once),并且没有任何数据丢失。

Storm是如何高效的实现可靠性的?

一个 Storm topology 有一组特殊的 “acker” 任务用于追踪每个 Spout 发射的 tuple 的 DAG。当 “acker” 看到一个 DAG 完成,它会向创建 tuple 的 spout 任务发送一条消息来确认消息。你可以通过配置Config.TOPOLOGY_ACKERS参数来设置 topology 的 “acker” 任务数。Storm默认为TOPOLOGY_ACKERS为每个worker分配一个 “acker” 任务。

理解 Storm 的可靠性实现的最好方式是通过了解 tuple 和 tuple DAG 的生命周期。当一个 tuple 在拓扑中被创建出来的时候,不管是在 Spout 中还是在 Bolt 中创建的,这个 tuple 都会被配置一个随机的 64 位 id。acker 就是使用这些 id 来跟踪每个 spout tuple 的 tuple DAG 的。

Spout tuple(为了避免混淆,暂称root tuple) 的 tuple 树中的每个 tuple 都知道 root tuple 的 id。当你在 bolt 中发送一个新 tuple 的时候,输入 tuple 中的所有 root tuple 的 id 都会被复制到新的 tuple 中。在 tuple 被 ack 的时候,它会通过回掉函数向合适的 acker 发送一条消息,这条消息显示了 tuple 树中发生的变化。也就是说,它会告诉 acker 这样一条消息:“在这个 tuple 树中,我的处理已经结束了,接下来这个就是被我锚定的新 tuple”。

举个栗子,如果 D tuple 和 E tuple 是由 C tuple 创建的,那么在 C 应答的时候 tuple 树就会发生如下变化:
What happens on an ack

由于在 D 和 E 添加到 tuple 树中的同时 C 已经从树中移除了,所以这个树并不会被过早地结束。

关于 Storm 如何跟踪 tuple 树还有一些细节。就如上面提到的,在一个 topology 中,你可以有任意个 acker 任务。这就导致了以下问题,当一个元组在拓扑中被ack时,它如何知道将该信息发送给哪个acker任务?

Storm 是使用哈希(mod)来将 spout tuple 匹配到 acker 任务上的。因为每个 tuple 都带有它所在树的所有 root tuple 的 id,所以他们会知道需要与哪个 acker 任务通信。

另一个细节是 acker 是如何知道它所跟踪的 spout tuple 是由哪个 Spout 任务处理的。实际上,在 Spout 任务发送新 tuple 的时候,它也会给对应的 acker 发送一条消息,告诉 acker 这个 spout tuple 是与它的任务 id 相关联的。随后,在 acker 观察到 tuple 树结束处理的时候,它就会知道向哪个 Spout 任务发送结束消息。

Acker 实际上并不会直接跟踪 tuple 树。对于一棵包含数万个 tuple 节点甚至更多的树,通过 ackers 追踪所有的 tuple 树 可能会将内存撑爆。所以,这里 acker 使用一个特殊的策略来实现跟踪的功能,使用这个方法对于每个 spout tuple 只需要占用固定大小的内存空间(大约 20 字节)。这个跟踪算法是 Storm 运行的关键,也是 Storm 的一个突破性技术。

一个 acker 任务存储spout tuple id映射到一组值的map。第一个值是创建 spout tuple 的任务id,后续将用于发送完成消息。第二个值是一个 64 bit的数字,称为“应答值”(ack val)。这个应答值是整个 tuple 树的一个完整的状态表述,而且它与树的大小无关。因为这个值仅仅是这棵树中所有被创建的或者被应答的 tuple 的 tuple id 进行异或运算的结果。

当一个 acker 任务观察到“应答值”变为 0 的时,它就知道这个 tuple 树已经被处理完成了。因为 tuple id 实际上是随机生成的 64 位数值,所以“应答值”恰巧为 0 是一种极小概率的事件。从理论上计算,在每秒一万次应答的情况下,需要 5000 万年才会发生一次错误。而且即使是这样,也仅会在 tuple 在拓扑中处理失败,才会发生数据丢失的情况。

相信你现在对这个可靠性算法有了一定理解,让我们再分析一下所有失败的情形,看看在这些情形下 Storm 是如何避免数据缺失的:

  • 由于任务线程宕掉而导致 tuple 没有被应答: 这种情况下,位于 tuple 树根节点的 spout tuple 会在任务超时后得到重新发送。
  • Acker 任务宕掉: 这种情况下,这个 acker 所追踪的所有 spout tuple 将超时重发。
  • Spout 任务宕掉: 这种情况下,Spout 任务的源头就会负责重新处理消息。例如,对于像 Kestrel 和 RabbitMQ 这样的消息队列就会在客户端断开连接时将所有的挂起状态的消息放回队列中。

综上所述,Storm 的可靠性机制是完全分布式,可扩展的并且容错的。

Tuning reliability(调整可靠性)

由于 acker 任务是轻量级的,所以在拓扑中你并不需要很多 acker 任务。你可以通过 Storm UI 监控他们的性能(acker 任务的 id 为 “__acker”)。如果发现观察结果存在问题,你可能就需要增加更多的 acker 任务。

如果消息的可靠性对你不是那么重要 —— 也就是说你不关心在失败情形下发生的 tuple 丢失 —— 那么你就可以通过不跟踪 tuple 树的处理来提升拓扑的性能。由于 tuple 树中的每个 tuple 都会带有一个应答消息,不追踪 tuple 树就会使得传输的消息的数量减半。同时,下游数据流中的 id 也会变少,这样可以降低网络带宽的消耗。

有三种方法可以去除 Storm 的可靠性机制:

  • 第一种方法是将Config.TOPOLOGY_ACKERS设置为 0,在这种情况下,Storm 会在 Spout 发送 tuple 之后立即调用 ack 方法,tuple 树就不会被跟踪。
  • 第二种方法是基于消息本身移除可靠性。你可以通过在 SpoutOutputCollector.emit 方法中省略消息 id 来关闭 spout tuple 的跟踪功能。
  • 最后,如果你不关心拓扑中的下游 tuple 是否会失败,你可以在发送 tuple 的时候选择发送“非锚定”的(unanchored)tuple。由于这些 tuple 不会被锚定到任何一个 spout tuple 上,所以在他们处理失败的时候也不会引起任何 spout tuple 的重新处理。

图解

一般情况下(示例如下图),spout 在发送msg的同时会将对应msgId发送至acker,bolt 处理完 tuplt 后会将对应的msgId发送给 acker,acker 会将收到的msgId进行 xor(异或),结果为 0 则该tuple处理完毕。如果有哪一步错误没有使得msg正确传递到叶子节点,那么 acker 不会收到能使XOR结果为0的ID,进而最终spout会超时重发msg。

image.png

有一篇写的很nice的博客,对Storm的Tuple确认问题进行了详细的图解,详情可见 使用神奇的xor(异或)解决Storm的Tuple确认问题

该博客仅为初学者自我学习的记录,粗浅之言,如有不对之处,恳请指正。如有不解之处,欢迎评论,一起学习!

参考资料

Storm Document -> Guaranteeing Message Processing

使用神奇的xor(异或)解决Storm的Tuple确认问题