本文翻譯自《Getting Started With Storm》譯者:吳京潤 編輯:郭蕾 方騰飛
正如書中之前所提到的,使用Storm程式設計,可以通過呼叫ack和fail方法來確保一條訊息的處理成功或失敗。不過當元組被重發時,會發生什麼呢?你又該如何砍不會重複計算?
Storm0.7.0實現了一個新特性——事務性拓撲,這一特性使訊息在語義上確保你可以安全的方式重發訊息,並保證它們只會被處理一次。在不支援事務性拓撲的情況下,你無法在準確性,可擴充套件性,以空錯性上得到保證的前提下完成計算。
NOTE:事務性拓撲是一個構建於標準Storm spout和bolt之上的抽象概念。
設計
在事務性拓撲中,Storm以並行和順序處理混合的方式處理元組。spout並行分批建立供bolt處理的元組(譯者注:下文將這種分批建立、分批處理的元組稱做批次)。其中一些bolt作為提交者以嚴格有序的方式提交處理過的批次。這意味著如果你有每批五個元組的兩個批次,將有兩個元組被bolt並行處理,但是直到提交者成功提交了第一個元組之後,才會提交第二個元組。 NOTE: 使用事務性拓撲時,資料來源要能夠重發批次,有時候甚至要重複多次。因此確認你的資料來源——你連線到的那個spout——具備這個能力。 這個過程可以被描述為兩個階段: 處理階段 純並行階段,許多批次同時處理。 提交階段 嚴格有序階段,直到批次一成功提交之後,才會提交批次二。 這兩個階段合起來稱為一個Storm事務。 NOTE: Storm使用zookeeper儲存事務後設資料,預設情況下就是拓撲使用的那個zookeeper。你可以修改以下兩個配置引數鍵指定其它的zookeeper——transactional.zookeeper.servers和transactional.zookeeper.port。
事務實踐
下面我們要建立一個Twitter分析工具來了解事務的工作方式。我們從一個Redis資料庫讀取tweets,通過幾個bolt處理它們,最後把結果儲存在另一個Redis資料庫的列表中。處理結果就是所有話題和它們的在tweets中出現的次數列表,所有使用者和他們在tweets中出現的次數列表,還有一個包含發起話題和頻率的使用者列表。 這個工具的拓撲見圖8-1。 圖8-1 拓撲概覽
正如你看到的,TweetsTransactionalSpout會連線你的tweet資料庫並向拓撲分發批次。UserSplitterBolt和HashTagSplitterBolt兩個bolt,從spout接收元組。UserSplitterBolt解析tweets並查詢使用者——以@開頭的單詞——然後把這些單詞分發到名為users的自定義資料流組。HashtagSplitterBolt從tweet查詢#開頭的單詞,並把它們分發到名為hashtags的自定義資料流組。第三個bolt,UserHashtagJoinBolt,接收前面提到的兩個資料流組,並計算具名使用者的一條tweet內的話題數量。為了計數並分發計算結果,這是個BaseBatchBolt(稍後有更多介紹)。
最後一個bolt——RedisCommitterBolt——接收以上三個bolt的資料流組。它為每樣東西計數,並在對一個批次完成處理時,把所有結果儲存到redis。這是一種特殊的bolt,叫做提交者,在本章後面做更多講解。
用TransactionalTopologyBuilder構建拓撲,程式碼如下:
TransactionalTopologyBuilder builder= new TransactionalTopologyBuilder("test", "spout", new TweetsTransactionalSpout()); builder.setBolt("users-splitter", new UserSplitterBolt(), 4).shuffleGrouping("spout"); buildeer.setBolt("hashtag-splitter", new HashtagSplitterBolt(), 4).shuffleGrouping("spout"); builder.setBolt("users-hashtag-manager", new UserHashtagJoinBolt(), r) .fieldsGrouping("users-splitter", "users", new Fields("tweet_id")) .fieldsGrouping("hashtag-splitter", "hashtags", new Fields("tweet_id")); builder.setBolt("redis-commiter", new RedisCommiterBolt()) .globalGrouping("users-splitter", "users") .globalGrouping("hashtag-splitter", "hashtags") .globalGrouping("user-hashtag-merger");
接下來就看看如何在一個事務性拓撲中實現spout。
Spout
一個事務性拓撲的spout與標準spout完全不同。
public class TweetsTransactionalSpout extends BaseTransactionalSpout<TransactionMetadata>{
正如你在這個類定義中看到的,TweetsTransactionalSpout繼承了帶範型的BaseTransactionalSpout。指定的範型型別的物件是事務後設資料集合。它將在後面的程式碼中用於從資料來源分發批次。
在這個例子中,TransactionMetadata定義如下:
public class TransactionMetadata implements Serializable { private static final long serialVersionUID = 1L; long from; int quantity; public TransactionMetadata(long from, int quantity) { this.from = from; this.quantity = quantity; } }
該類的物件維護著兩個屬性from和quantity,它們用來生成批次。
spout的最後需要實現下面的三個方法:
@Override public ITransactionalSpout.Coordinator<TransactionMetadata> getCoordinator( Map conf, TopologyContext context) { return new TweetsTransactionalSpoutCoordinator(); } @Override public backtype.storm.transactional.ITransactionalSpout.Emitter<TransactionMetadata> getEmitter(Map conf, TopologyContext contest) { return new TweetsTransactionalSpoutEmitter(); } @Override public void declareOutputFields(OuputFieldsDeclarer declarer) { declarer.declare(new Fields("txid", "tweet_id", "tweet")); }
getCoordinator方法,告訴Storm用來協調生成批次的類。getEmitter,負責讀取批次並把它們分發到拓撲中的資料流組。最後,就像之前做過的,需要宣告要分發的域。
RQ類
為了讓例子簡單點,我們決定用一個類封裝所有對Redis的操作。
public class RQ { public static final String NEXT_READ = "NEXT_READ"; public static final String NEXT_WRITE = "NEXT_WRITE"; Jedis jedis; public RQ() { jedis = new Jedis("localhost"); } public long getavailableToRead(long current) { return getNextWrite() - current; } public long getNextRead() { String sNextRead = jedis.get(NEXT_READ); if(sNextRead == null) { return 1; } return Long.valueOf(sNextRead); } public long getNextWrite() { return Long.valueOf(jedis.get(NEXT_WRITE)); } public void close() { jedis.disconnect(); } public void setNextRead(long nextRead) { jedis.set(NEXT_READ, ""+nextRead); } public List<String> getMessages(long from, int quantity) { String[] keys = new String[quantity]; for (int i = 0; i < quantity; i++) { keys[i] = ""+(i+from); } return jedis.mget(keys); } }
仔細閱讀每個方法,確保自己理解了它們的用處。
協調者Coordinator
下面是本例的協調者實現。
public static class TweetsTransactionalSpoutCoordinator implements ITransactionalSpout.Coordinator<TransactionMetadata> { TransactionMetadata lastTransactionMetadata; RQ rq = new RQ(); long nextRead = 0; public TweetsTransactionalSpoutCoordinator() { nextRead = rq.getNextRead(); } @Override public TransactionMetadata initializeTransaction(BigInteger txid, TransactionMetadata prevMetadata) { long quantity = rq.getAvailableToRead(nextRead); quantity = quantity > MAX_TRANSACTION_SIZE ? MAX_TRANSACTION_SIZE : quantity; TransactionMetadata ret = new TransactionMetadata(nextRead, (int)quantity); nextRead += quantity; return ret; } @Override public boolean isReady() { return rq.getAvailableToRead(nextRead) > 0; } @Override public void close() { rq.close(); } }
值得一提的是,在整個拓撲中只會有一個提交者例項。建立提交者例項時,它會從redis讀取一個從1開始的序列號,這個序列號標識要讀取的tweet下一條。
第一個方法是isReady。在initializeTransaction之前呼叫它確認資料來源已就緒並可讀取。此方法應當相應的返回true或false。在此例中,讀取tweets數量並與已讀數量比較。它們之間的不同就在於可讀tweets數。如果它大於0,就意味著還有tweets未讀。
最後,執行initializeTransaction。正如你看到的,它接收txid和prevMetadata作為引數。第一個引數是Storm生成的事務ID,作為批次的惟一性標識。prevMetadata是協調器生成的前一個事務後設資料物件。
在這個例子中,首先確認有多少tweets可讀。只要確認了這一點,就建立一個TransactionMetadata物件,標識讀取的第一個tweet(譯者注:物件屬性from),以及讀取的tweets數量(譯者注:物件屬性quantity)。
後設資料物件一經返回,Storm把它跟txid一起儲存在zookeeper。這樣就確保了一旦發生故障,Storm可以利用分發器(譯者注:Emitter,見下文)重新傳送批次。
Emitter
建立事務性spout的最後一步是實現分發器(Emitter)。實現如下:
public static class TweetsTransactionalSpoutEmitter implements ITransactionalSpout.Emitter<TransactionMetadata> { </pre> <pre> RQ rq = new RQ();</pre> <pre> public TweetsTransactionalSpoutEmitter() {}</pre> <pre> @Override public void emitBatch(TransactionAttempt tx, TransactionMetadata coordinatorMeta, BatchOutputCollector collector) { rq.setNextRead(coordinatorMeta.from+coordinatorMeta.quantity); List<String> messages = rq.getMessages(coordinatorMeta.from, <span style="font-family: Georgia, `Times New Roman`, `Bitstream Charter`, Times, serif; font-size: 13px; line-height: 19px;">coordinatorMeta.quantity); </span> long tweetId = coordinatorMeta.from; for (String message : messages) { collector.emit(new Values(tx, ""+tweetId, message)); tweetId++; } } @Override public void cleanupBefore(BigInteger txid) {} @Override public void close() { rq.close(); }</pre> <pre> }
分發器從資料來源讀取資料並從資料流組傳送資料。分發器應當問題能夠為相同的事務id和事務後設資料傳送相同的批次。這樣,如果在處理批次的過程中發生了故障,Storm就能夠利用分發器重複相同的事務id和事務後設資料,並確保批次已經重複過了。Storm會在TransactionAttempt物件裡為嘗試次數增加計數(譯者注:attempt id)。這樣就能知道批次已經重複過了。
在這裡emitBatch是個重要方法。在這個方法中,使用傳入的後設資料物件從redis得到tweets,同時增加redis維持的已讀tweets數。當然它還會把讀到的tweets分發到拓撲。
Bolts
首先看一下這個拓撲中的標準bolt:
public class UserSplitterBolt implements IBasicBolt{ private static final long serialVersionUID = 1L; @Override public void declareOutputFields(OutputFieldsDeclarer declarer) { declarer.declareStream("users", new Fields("txid","tweet_id","user")); } @Override public Map<String, Object> getComponentConfiguration() { return null; } @Override public void prepare(Map stormConf, TopologyContext context) {} @Override public void execute(Tuple input, BasicOutputCollector collector) { String tweet = input.getStringByField("tweet"); String tweetId = input.getStringByField("tweet_id"); StringTokenizer strTok = new StringTokenizer(tweet, " "); HashSet<String> users = new HashSet<String>(); while(strTok.hasMoreTokens()) { String user = strTok.nextToken(); //確保這是個真實的使用者,並且在這個tweet中沒有重複 if(user.startsWith("@") && !users.contains(user)) { collector.emit("users", new Values(tx, tweetId, user)); users.add(user); } } } @Override public void cleanup(){} }
正如本章前面提到的,UserSplitterBolt接收元組,解析tweet文字,分發@開頭的單詞————tweeter使用者。HashtagSplitterBolt的實現也非常相似。
public class HashtagSplitterBolt implements IBasicBolt{ private static final long serialVersionUID = 1L; @Override public void declareOutputFields(OutputFieldsDeclarer declarer) { declarer.declareStream("hashtags", new Fields("txid","tweet_id","hashtag")); } @Override public Map<String, Object> getComponentConfiguration() { return null; } @Override public void prepare(Map stormConf, TopologyContext context) {} @Oerride public void execute(Tuple input, BasicOutputCollector collector) { String tweet = input.getStringByField("tweet"); String tweetId = input.getStringByField("tweet_id"); StringTokenizer strTok = new StringTokenizer(tweet, " "); TransactionAttempt tx = (TransactionAttempt)input.getValueByField("txid"); HashSet<String> words = new HashSet<String>(); while(strTok.hasMoreTokens()) { String word = strTok.nextToken(); if(word.startsWith("#") && !words.contains(word)){ collector.emit("hashtags", new Values(tx, tweetId, word)); words.add(word); } } } @Override public void cleanup(){} }
現在看看UserHashTagJoinBolt的實現。首先要注意的是它是一個BaseBatchBolt。這意味著,execute方法會操作接收到的元組,但是不會分發新的元組。批次完成時,Storm會呼叫finishBatch方法。
public void execute(Tuple tuple) { String source = tuple.getSourceStreamId(); String tweetId = tuple.getStringByField("tweet_id"); if("hashtags".equals(source)) { String hashtag = tuple.getStringByField("hashtag"); add(tweetHashtags, tweetId, hashtag); } else if("users".equals(source)) { String user = tuple.getStringByField("user"); add(userTweets, user, tweetId); } }
既然要結合tweet中提到的使用者為出現的所有話題計數,就需要加入前面的bolts建立的兩個資料流組。這件事要以批次為單位程式,在批次處理完成時,呼叫finishBatch方法。
@Override public void finishBatch() { for(String user:userTweets.keySet()){ Set<String> tweets = getUserTweets(user); HashMap<String, Integer> hashtagsCounter = new HashMap<String, Integer>(); for(String tweet:tweets){ Set<String> hashtags=getTweetHashtags(tweet); if(hashtags!=null){ for(String hashtag:hashtags){ Integer count=hashtagsCounter.get(hashtag); if(count==null){count=0;} count++; hashtagsCounter.put(hashtag,count); } } } for(String hashtag:hashtagsCounter.keySet()){ int count=hashtagsCounter.get(hashtag); collector.emit(new Values(id,user,hashtag,count)); } } }
這個方法計算每對使用者-話題出現的次數,併為之生成和分發元組。
你可以在GitHub上找到並下載完整程式碼。(譯者注:https://github.com/storm-book/examples-ch08-transactional-topologies這個倉庫裡沒有程式碼,誰知道哪裡有程式碼麻煩說一聲。)
提交者bolts
我們已經學習了,批次通過協調器和分發器怎樣在拓撲中傳遞。在拓撲中,這些批次中的元組以並行的,沒有特定次序的方式處理。
協調者bolts是一類特殊的批處理bolts,它們實現了IComh mitter或者通過TransactionalTopologyBuilder呼叫setCommiterBolt設定了提交者bolt。它們與其它的批處理bolts最大的不同在於,提交者bolts的finishBatch方法在提交就緒時執行。這一點發生在之前所有事務都已成功提交之後。另外,finishBatch方法是順序執行的。因此如果同時有事務ID1和事務ID2兩個事務同時執行,只有在ID1沒有任何差錯的執行了finishBatch方法之後,ID2才會執行該方法。
下面是這個類的實現
public class RedisCommiterCommiterBolt extends BaseTransactionalBolt implements ICommitter { public static final String LAST_COMMITED_TRANSACTION_FIELD = "LAST_COMMIT"; TransactionAttempt id; BatchOutputCollector collector; Jedis jedis; @Override public void prepare(Map conf, TopologyContext context, BatchOutputCollector collector, TransactionAttempt id) { this.id = id; this.collector = collector; this.jedis = new Jedis("localhost"); } HashMap<String, Long> hashtags = new HashMap<String,Long>(); HashMap<String, Long> users = new HashMap<String, Long>(); HashMap<String, Long> usersHashtags = new HashMap<String, Long>(); private void count(HashMap<String, Long> map, String key, int count) { Long value = map.get(key); if(value == null){value = (long)0;} value += count; map.put(key,value); } @Override public void execute(Tuple tuple) { String origin = tuple. getSourceComponent(); if("sers-splitter".equals(origin)) { String user = tuple.getStringByField("user"); count(users, user, 1); } else if("hashtag-splitter".equals(origin)) { String hashtag = tuple.getStringByField("hashtag"); count(hashtags, hashtag, 1); } else if("user-hashtag-merger".quals(origin)) { String hashtag = tuple.getStringByField("hashtag"); String user = tuple.getStringByField("user"); String key = user + ":" + hashtag; Integer count = tuple.getIntegerByField("count"); count(usersHashtags, key, count); } } @Override public void finishBatch() { String lastCommitedTransaction = jedis.get(LAST_COMMITED_TRANSACTION_FIELD); String currentTransaction = ""+id.getTransactionId(); if(currentTransaction.equals(lastCommitedTransaction)) {return;} Transaction multi = jedis.multi(); multi.set(LAST_COMMITED_TRANSACTION_FIELD, currentTransaction); Set<String> keys = hashtags.keySet(); for (String hashtag : keys) { Long count = hashtags.get(hashtag); multi.hincrBy("hashtags", hashtag, count); } keys = users.keySet(); for (String user : keys) { Long count =users.get(user); multi.hincrBy("users",user,count); } keys = usersHashtags.keySet(); for (String key : keys) { Long count = usersHashtags.get(key); multi.hincrBy("users_hashtags", key, count); } multi.exec(); } @Override public void declareOutputFields(OutputFieldsDeclarer declarer) {} }
這個實現很簡單,但是在finishBatch有一個細節。
... multi.set(LAST_COMMITED_TRANSACTION_FIELD, currentTransaction); ...
在這裡向資料庫儲存提交的最後一個事務ID。為什麼要這樣做?記住,如果事務失敗了,Storm將會盡可能多的重複必要的次數。如果你不確定已經處理了這個事務,你就會多算,事務拓撲也就沒有用了。所以請記住:儲存最後提交的事務ID,並在提交前檢查。
分割槽的事務Spouts
對一個spout來說,從一個分割槽集合中讀取批次是很普通的。接著這個例子,你可能有很多redis資料庫,而tweets可能會分別儲存在這些redis資料庫裡。通過實現IPartitionedTransactionalSpout,Storm提供了一些工具用來管理每個分割槽的狀態並保證重播的能力。
下面我們修改TweetsTransactionalSpout,使它可以處理資料分割槽。
首先,繼承BasePartitionedTransactionalSpout,它實現了IPartitionedTransactionalSpout。
public class TweetsPartitionedTransactionalSpout extends BasePartitionedTransactionalSpout<TransactionMetadata> { ... }
然後告訴Storm誰是你的協調器。
public static class TweetsPartitionedTransactionalCoordinator implements Coordinator { @Override public int numPartitions() { return 4; } @Override public boolean isReady() { return true; } @Override public void close() {} }
在這個例子裡,協調器很簡單。numPartitions方法,告訴Storm一共有多少分割槽。而且你要注意,不要返回任何後設資料。對於IPartitionedTransactionalSpout,後設資料由分發器直接管理。
下面是分發器的實現:
public static class TweetsPartitionedTransactionalEmitter implements Emitter<TransactionMetadata> { PartitionedRQ rq = new ParttionedRQ(); @Override public TransactionMetadata emitPartitionBatchNew(TransactionAttempt tx, BatchOutputCollector collector, int partition, TransactionMetadata lastPartitioonMeta) { long nextRead; if(lastPartitionMeta == null) { nextRead = rq.getNextRead(partition); }else{ nextRead = lastPartitionMeta.from + lastPartitionMeta.quantity; rq.setNextRead(partition, nextRead); //移動遊標 } long quantity = rq.getAvailableToRead(partition, nextRead); quantity = quantity > MAX_TRANSACTION_SIZE ? MAX_TRANSACTION_SIZE : quantity; TransactionMetadata metadata = new TransactionMetadata(nextRead, (int)quantity); emitPartitionBatch(tx, collector, partition, metadata); return metadata; } @Override public void emitPartitionBatch(TransactionAttempt tx, BatchOutputCollector collector, int partition, TransactionMetadata partitionMeta) { if(partitionMeta.quantity <= 0){ return; } List<String> messages = rq.getMessages(partition, partitionMeta.from, partitionMeta.quantity); long tweetId = partitionMeta.from; for (String msg : messages) { collector.emit(new Values(tx, ""+tweetId, msg)); tweetId++; } } @Override public void close() {} }
這裡有兩個重要的方法,emitPartitionBatchNew,和emitPartitionBatch。對於emitPartitionBatchNew,從Storm接收分割槽引數,該引數決定應該從哪個分割槽讀取批次。在這個方法中,決定獲取哪些tweets,生成相應的後設資料物件,呼叫emitPartitionBatch,返回後設資料物件,並且後設資料物件會在方法返回時立即儲存到zookeeper。
Storm會為每一個分割槽傳送相同的事務ID,表示一個事務貫穿了所有資料分割槽。通過emitPartitionBatch讀取分割槽中的tweets,並向拓撲分發批次。如果批次處理失敗了,Storm將會呼叫emitPartitionBatch利用儲存下來的後設資料重複這個批次。
NOTE: 完整的原始碼請見:https://github.com/storm-book/examples-ch08-transactional-topologies(譯者注:原文如此,實際上這個倉庫裡什麼也沒有)
模糊的事務性拓撲
到目前為止,你可能已經學會了如何讓擁有相同事務ID的批次在出錯時重播。但是在有些場景下這樣做可能就不太合適了。然後會發生什麼呢?
事實證明,你仍然可以實現在語義上精確的事務,不過這需要更多的開發工作,你要記錄由Storm重複的事務之前的狀態。既然能在不同時刻為相同的事務ID得到不同的元組,你就需要把事務重置到之前的狀態,並從那裡繼續。
比如說,如果你為收到的所有tweets計數,你已數到5,而最後的事務ID是321,這時你多數了8個。你要維護以下三個值——previousCount=5,currentCount=13,以及lastTransactionId=321。假設事物ID321又發分了一次,而你又得到了4個元組,而不是之前的8個,提交器會探測到這是相同的事務ID,它將會把結果重置到previousCount的值5,並在此基礎上加4,然後更新currentCount為9。
另外,在之前的一個事務被取消時,每個並行處理的事務都要被取消。這是為了確保你沒有丟失任何資料。
你的spout可以實現IOpaquePartitionedTransactionalSpout,而且正如你看到的,協調器和分發器也很簡單。
public static class TweetsOpaquePartitionedTransactionalSpoutCoordinator implements IOpaquePartitionedTransactionalSpout.Coordinator { @Override public boolean isReady() { return true; } } public static class TweetsOpaquePartitionedTransactionalSpoutEmitter implements IOpaquePartitionedTransactionalSpout.Emitter<TransactionMetadata> { PartitionedRQ rq = new PartitionedRQ(); @Override public TransactionMetadata emitPartitionBatch(TransactionAttempt tx, BatchOutputCollector collector, int partion, TransactionMetadata lastPartitonMeta) { long nextRead; if(lastPartitionMeta == null) { nextRead = rq.getNextRead(partition); }else{ nextRead = lastPartitionMeta.from + lastPartitionMeta.quantity; rq.setNextRead(partition, nextRead);//移動遊標 } long quantity = rq.getAvailabletoRead(partition, nextRead); quantity = quantity > MAX_TRANSACTION_SIZE ? MAX_TRANSACTION_SIZE : quantity; TransactionMetadata metadata = new TransactionMetadata(nextRead, (int)quantity); emitMessages(tx, collector, partition, metadata); return metadata; } private void emitMessage(TransactionAttempt tx, BatchOutputCollector collector, int partition, TransactionMetadata partitionMeta) { if(partitionMeta.quantity <= 0){return;} List<String> messages = rq.getMessages(partition, partitionMeta.from, partitionMeta.quantity); long tweetId = partitionMeta.from; for(String msg : messages) { collector.emit(new Values(tx, ""+tweetId, msg)); tweetId++; } } @Override public int numPartitions() { return 4; } @Override public void close() {} }
最有趣的方法是emitPartitionBatch,它獲取之前提交的後設資料。你要用它生成批次。這個批次不需要與之前的那個一致,你可能根本無法建立完全一樣的批次。剩餘的工作由提交器bolts藉助之前的狀態完成。
文章轉自 併發程式設計網-ifeve.com