流彙總(stream join)是指將具有共同元組(tuple)欄位的資料流(兩個或者多個)彙總形成一個新的資料流的過程。
從定義上看,流彙總和SQL中表的彙總(table join)很像,但是二者有明顯的區別:table join的輸入是有限的,並且join的語義是非常明確的;而流彙總的語義是不明確的並且輸入資料流是無限的。
資料流的彙總類型跟具體的應用有關。一些應用把兩個流發出的所有的tuple都彙總起來——不管多長時間;而另外一些應用則只會彙總一些特定的tuple。而另外一些應用的彙總邏輯又可能完全不一樣。而這些彙總類型裡面最常見的類型是把所有的輸入資料流進行一樣的劃分,這個在storm裡面用fields grouping在相同欄位上進行grouping就可以實現。
下面是對storm-starter(代碼見:https://github.com/nathanmarz/storm-starter)中有關兩個流的彙總的範例程式碼剖析:
先看一下入口類SingleJoinExample。
(1)這裡首先建立了兩個發射源spout,分別是genderSpout和ageSpout:
FeederSpout genderSpout = new FeederSpout(new Fields("id", "gender")); FeederSpout ageSpout = new FeederSpout(new Fields("id", "age")); TopologyBuilder builder = new TopologyBuilder(); builder.setSpout("gender", genderSpout); builder.setSpout("age", ageSpout);
其中genderSpout包含兩個tuple欄位:id和gender,ageSpout包含兩個tuple欄位:id和age(這裡流彙總就是通過將相同id的tuple進行彙總,得到一個新的輸出資料流,包含id、gender和age欄位)。
(2)為了不同的資料流中的同一個id的tuple能夠落到同一個task中進行處理,這裡使用了storm中的fileds grouping在id欄位上進行分組劃分:
builder.setBolt("join", new SingleJoinBolt(new Fields("gender", "age"))) .fieldsGrouping("gender", new Fields("id")) .fieldsGrouping("age", new Fields("id"));
從中可以看到,SingleJoinBolt就是真正進行流彙總的地方。下面我們來看看:
(1)SingleJoinBolt構造時接收一個Fileds對象,其中傳進的是彙總後將要被輸出的欄位(這裡就是gender和age欄位),儲存到變數_outFileds中。
(2)接下來看看完成SingleJoinBolt的構造後,SingleJoinBolt在真正開始接收處理tuple之前所做的準備工作(代碼見prepare方法):
a)首先,將儲存OutputCollector對象,建立TimeCacheMap對象,設定逾時回調介面,用於tuple處理失敗時fail訊息;緊接著記錄資料來源的個數:
_collector = collector; int timeout = ((Number) conf.get(Config.TOPOLOGY_MESSAGE_TIMEOUT_SECS)).intValue(); _pending = new TimeCacheMap<List<Object>, Map<GlobalStreamId, Tuple>>(timeout, new ExpireCallback()); _numSources = context.getThisSources().size();
b)遍曆TopologyContext中不同資料來源,得到所有資料來源(這裡就是genderSpout和ageSpout)中公用的Filed欄位,儲存到變數_idFields中(例子中就是id欄位),同時將_outFileds中欄位所在資料來源記錄下來,儲存到一張HashMap中_fieldLocations,以便彙總後擷取對應的欄位值。
Set<String> idFields = null; for(GlobalStreamId source: context.getThisSources().keySet()) { Fields fields = context.getComponentOutputFields(source.get_componentId(), source.get_streamId()); Set<String> setFields = new HashSet<String>(fields.toList()); if(idFields==null) idFields = setFields; else idFields.retainAll(setFields); for(String outfield: _outFields) { for(String sourcefield: fields) { if(outfield.equals(sourcefield)) { _fieldLocations.put(outfield, source); } } } } _idFields = new Fields(new ArrayList<String>(idFields)); if(_fieldLocations.size()!=_outFields.size()) { throw new RuntimeException("Cannot find all outfields among sources"); }
(3)好了,下面開始兩個spout流的彙總過程了(代碼見execute方法):
首先,從tuple中擷取_idFields欄位,如果不存在於等待被處理的隊列_pending中,則加入一行,其中key是擷取到的_idFields欄位,value是一個空的HashMap<GlobalStreamId, Tuple>對象,記錄GlobalStreamId到Tuple的映射。
List<Object> id = tuple.select(_idFields); GlobalStreamId streamId = new GlobalStreamId(tuple.getSourceComponent(), tuple.getSourceStreamId()); if(!_pending.containsKey(id)) { _pending.put(id, new HashMap<GlobalStreamId, Tuple>()); }
從_pending隊列中,擷取當前GlobalStreamId streamId對應的HashMap對象parts中:
Map<GlobalStreamId, Tuple> parts = _pending.get(id);
如果streamId已經包含其中,則拋出異常,接收到同一個spout中的兩條一樣id的tuple,否則將該streamid加入parts中:
if(parts.containsKey(streamId)) throw new RuntimeException("Received same side of single join twice"); parts.put(streamId, tuple);
如果parts已經包含了彙總資料來源的個數_numSources時,從_pending隊列中移除這條記錄,然後開始構造彙總後的結果欄位:依次遍曆_outFields中各個欄位,從_fieldLocations中取到這些outFiled欄位對應的GlobalStreamId,緊接著從parts中取出GlobalStreamId對應的outFiled,放入彙總後的結果中。
if(parts.size()==_numSources) { _pending.remove(id); List<Object> joinResult = new ArrayList<Object>(); for(String outField: _outFields) { GlobalStreamId loc = _fieldLocations.get(outField); joinResult.add(parts.get(loc).getValueByField(outField)); }
最後通過_collector將parts中存放的tuple和彙總後的輸出結果發射出去,並ack這些tuple已經處理成功。
_collector.emit(new ArrayList<Tuple>(parts.values()), joinResult); for(Tuple part: parts.values()) { _collector.ack(part); }
}
否則,繼續等待兩個spout流中這個streamid都到齊後再進行彙總處理。
(4)最後,聲明一下輸出欄位(代碼見declareOutputFields方法):
declarer.declare(_outFields);