JStorm/storm可以保证从spout发出的每条消息可以被完全处理,什么叫完全处理?
为了帮助理解,我们参考storm官网的几张图和例子说明这个原理,下面是个wordCount的例子,我们从spout发出来一条消息,这个消息就是一行文字,被下游的bolt切分处理,加工,然后再往后发,count bolt统计每个单子计数。
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"));
从spout读出来的一行文字,被切分成一个单词,那么这些单词每个都与spout的那条消息(一行文字)关联,所谓完全处理,不光是这行文字被成功发射给splitbolt,还得保证这句话的每个单词都被count正确的处理掉。同样的,你的拓扑逻辑如果有更多层,那么一条消息加工处理后的关联tuple都是要被成功处理,才认为spout发出的消息被完全处理。这种从spout发出的消息以及基于它产生的(加工处理出来的)关联的消息,组成一个消息树,jstorm就是要保证这个消息树的全部消息被处理。
如果在一个可配置的时间内,一个spout的tuple的子树的消息没有被完全处理,那么就会超时,导致失败。
Config.TOPOLOGY_MESSAGE_TIMEOUT_SECS配置项,默认30s
那么jstorm提供的这种机制,用户怎么用呢,其实很简单,这种机制加到编程框架中了,
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);
}
这个是spout的接口,注意到ack和fail接口了吗,你可以简单的认为,如果消息树被完全处理,那么会回调用户的spout的ack方法,反之会回调fail方法。
下面仔细梳理一下spout的框架流程:
首先,框架会调用你spout的nextTuple
方法(open初始化完成后,open提供了一个SpoutOutputCollector
对象让你发射消息到下游),发射消息的时候,你如果使用acker机制,那么需要给每个消息配上一个message id(业务id,不为空)一块发出去,这个业务id的作用后面会提到。
_collector.emit(new Values("field1", "field2", 3) , msgId);
这里有必要提一下,消息树被完全处理后,ack和fail消息不会跨spout的task传递,即你的spout task可能有多个,那么从哪个task发出的消息,对应的ack和failed消息还是会发给你谁,而不会在同一个component 的全部task之间共享。
通常spout如果从分布式消息队列消费,发给下游bolt处理,会在spout内存中缓存这些数据,如果消息树被成功处理,那么ack方法就会从缓存中清掉这条消息,或者failed之后,从缓存中重发这条信息。
spout调用SpoutOutputCollector
发射消息时,其实框架在后面做一件事情,就是给你发射的这条信息做一个link,或者说跟你这条消息唯一关联的记录,叫做anchoring,什么叫anchoring,还是看这个splitbolt
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"));
}
}
_collector.emit(tuple, new Values(word));
大家注意到这句没有,通常没有ack的时候,发射数据要么指定streamid,要么直接发射values list,即
_collector.emit(new Values(word));
但是splitbolt 连上游过来的整个tuple都发射出去了!
这是什么意思?
这就是关键所在,前面说的又是link,又是anchoring啥的,说白了就是建立消息的血缘关系,即框架要知道A这条消息,以及由A直接产生的其它子消息的关联,这个相当于是父子关系,spout发出来的消息是root根消息,然后每个处理节点,建立一级父子关系,就这样一级一级构造只有,框架就知道了整个消息树的关系。
jstorm还支持建立多对多的血缘关系,即指定的上游关联tuple可以是多个,是个list。
这种功能在实时join以及流的聚合方面很有用,实时join之后,比如两条消息根据时间戳join成一条,那么这一条的上游其实是两条消息。
List<Tuple> anchors = new ArrayList<Tuple>();
anchors.add(tuple1);
anchors.add(tuple2);
_collector.emit(anchors, new Values(1, 2, 3));
collector.ack(tuple);
bolt里的OutputCollector 除了可以ack一个tuple,还可以直接fail掉一个tuple,比如这个bolt处理完数据正要写数据库做持久化存储,但是突然捕获到一个数据库客户端的exception,那么就可以选择fail这条消息,然后spout那边就会执行fail 方法,重放这条消息,即重新来一遍,这样要比bolt缓存这个计算结果等到客户端正常之后再写要靠谱得多,因为那样bolt要做容错的逻辑要更复杂。
开发过程中,bolt一般流程就是先发射消息,然后ack这个消息,jstorm框架提供了一个BasicBolt
封装了这个流程,你就不用每次开发都做这些重复的事情了,举个例子:
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
把关联父子消息、ack input tuple都给做了,你就不用管了。
基于acker机制,能做到的是发出的消息被追踪到,被完全处理,但是至于消息怎么重发,怎么保证绝对的exactly once,那么这取决于程序设计,可以参考一下http://storm.apache.org/documentation/Transactional-topologies.html
了解acker怎么用,基本原理之后,我们看看这个东西是怎么实现的。
用户提交了作业之后,如果开启了acker,那么框架其实会悄悄地改变你的拓扑,即给你增加几个acker bolt,没错,acker其实就是一个bolt task
你可以通过配置文件Config.TOPOLOGY_ACKER_EXECUTORS指定有几个acker bolt在你的拓扑中,默认开启acker之后,TOPOLOGY_ACKER_EXECUTORS这个数值,如果你不设置,那么就是等于你使用的worker数,即槽位数。后面会提到,当你的消息数特别多的时候,你的acker bolt数目也要跟上,要不然ack不过来。
我们看下一个tuple的生命周期,不管是在spout里emit还是bolt里emit的消息,框架都会给这个消息加一个64位的随机数当做id,其实是<root_id,randomID>这样的一个结构,即每个tuple除了上游给他创建了一个随机64位id外还带有一个不变的root_id,来自spout task
当在bolt里执行
OutputCollector.ack(tuple);
的时候,这个bolt task会选择一个acker(发给哪个acker,下面会讲)干了什么事情呢?它会告诉acker bolt:“我处理完了(我和我的上游),我产生哪些tuple”这样acker bolt就会给消息树不需要关注节点(认为成功处理的节点)打个X
比如c产生d和两条消息,那么c完成ack之后,acker bolt眼里的这颗消息树,就成这样了,当整个消息树的节点全部打X了,说明这个消息被完全处理了
这时候acker会通知spout task
有时候一个topology里不止1个acker,那么spout和bolt的消息该发给哪个acker bolt,其实大家可以想一下,要满足2点要求:
1、能够让acker bolt负载均衡
2、同一个消息树的ack信息都发给同一个acker bolt
jstorm使用的是取模hash算法,只需要对spout的tuple 64位id取模就行了。这样基本上可以满足上面2点要求,因为spout tuple的id会透传给下游的全部消息树节点,因此,bolt也会正确路由到那个acker bolt.
另一个实现细节是,acker怎么知道把消息处理结果发给哪个spout task?实际上,spout emit一个消息的时候,就会按照消息id 取模找到对应的acker bolt,发给这个bolt一个初始化消息,初始化消息中包含了自己的task id,acker根据bolt发来的消息,构建一颗消息树,然后不断地打叉,最终完全处理后,再找到这个spout,发消息,出发ack或者fail 方法。
看到这里,我们可能会想,如果我们自己设计acker bolt,这个acker bolt可能会接收很多spout的很多消息树,并且建立spout task id跟消息树的关系,并且能够检测,编辑这个消息树状态,那么我们是不是需要缓存整个真实的消息树,显然不能,因为内存放不下,试想spout一般都是从外部消息队列抽取消息,然后一层一层加工,整体消息可能放大数倍数十倍,等于是让你把集群内部的消息全部装到bolt内存里,显然我们需要更好的办法。
那么怎么才能使用很小的空间、开销完成这件事情?
acker bolt弄了一个map来做这件事情,key就是spout tuple id即消息树的root id(64)位的,一个root id代表一个消息树;value则是一个value对,第一个value是task id(spout),这些都属于元数据,第二个value就牛逼了,是一个64位的数字,这个64位的数字代表了一棵消息树的状态,就像上面的那个图一样。
当整个value变成0了,说明,消息树被“完全处理”了,就找这个pair的第一个value,发消息就行了。
什么意思,具体怎么用的?
为了更好的说明这个过程,举个例子: spout -> bolt1/bolt2 -> bolt3
开启了acker机制的作业,上游发给下游的tuple其实是TupleImplExt对象,它带有一个MessageId对象,这种命名非常容易误导大家,这个MessageId跟刚才说的spout发出业务id更是没关系!我们把它理解成一个pair结构就行了
1). spout发射一条消息,生成root_id,由于这个值不变,我们就用root_id来标识。 spout -> bolt1的MessageId = <root_id, 1>
spout -> bolt2的MessageId =<root_id, 2>
spout -> acker的MessageId = <root_id, 1^2>
2). bolt1收到消息后,生成如下消息: bolt1 -> bolt3的MessageId = <root_id, 3>
bolt1 -> acker的MessageId = <root_id, 1^3>
3). 同样,bolt2收到消息后,生成如下消息: bolt2 -> bolt3的MessageId = <root_id, 4>
bolt2 -> acker的MessageId = <root_id, 2^4>
4). bolt3收到消息后,生成如下消息: bolt3 -> acker的MessageId = <root_id, 3>
bolt3 -> acker的MessageId = <root_id, 4>
5). acker中总共收到以下消息: <root_id, 1^2>
<root_id, 1^3>
<root_id, 2^4>
<root_id, 3>
<root_id, 4>
所有的值进行异或之后,即为1^2^1^3^2^4^3^4
= 0。
注意2/3,bolt发给下游的id还是root_id + 随机数,root_id是透传给每一个下游task的,但是每个task发给acker的消息,则是tuple自身的id异或发给下游的tuple id的结果,最后一层检测到没有下游task的时候,直接发给acker收到的tuple的id。
acker收到root_id对应的消息后,都会直接跟缓存的结果异或,通过这种方式判断是否完全被处理。
而spout 里提到的messageid其实可以理解成业务id,spout靠这个id去消息源头replay消息用的,但是本质上跟acker的消息id没有什么关系,但是没有这个messageid又不行,首先通过它要是null,那么不ack消息了,同时acker给spout task反馈你这个消息成功还是失败的时候要有这个messageid,因为通常spout会用这个messageid识别这条消息,而不是root id。
下面看一下几种情况下acker机制怎么保证数据被完全处理的。
1、task挂了,没ack那条消息
这种情况下会因为30s超时,调用spout task的fail方法。用户需要在fail方法中实现replay逻辑
2、acker bolt如果挂了,那么acker追踪的全部消息树会被重放,即调用调用spout task的fail方法。
3、spout task 挂了,那么这种情况下看你的重放机制,如果从外部消息系统重放,那么没关系,如果spout内存缓存了待重放数据,那么就会丢失那部分数据。
最后提一下,为什么bolt不及时ack会导致oom,BoltCollector 有一个
RotatingMap<Tuple, Long> pending_acks;用于anchoring,ack之后会把相应的tuple remove掉,相当于TimeCacheMap,但是如果一直没有从这里remove或者调用rotate方法,那么消息会一直憋在里面不会被释放掉。