Storm基礎(四)保證訊息處理

weixin_33936401發表於2017-04-01

原文連結:Guaranteeing Message Processing

本人原創翻譯,轉載請註明出處

Storm提供了幾種不同級別的保證訊息處理機制,包括best effort, at least once, 通過Trident實現的exactly once。這篇文章描述了Storm如何保證at least once處理。

一個訊息被完全處理(fully processed)究竟是什麼意思?

一個tuple從spout中發出可能觸發成千上萬個tuples的建立。例如,單詞計數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佇列中讀取句子,把句子拆分成單片語,然後每次emit一個單詞(如果單詞重複出現,那麼出現多少次emit多少次)。這解釋了一個tuple如何導致n個tuples被建立:句子中的每個單詞,都會成為一個單詞tuple和一個更新單詞計數的tuple。訊息樹大概像這樣:


3143746-bf019f392eccd1aa.png

Storm定義一個從spout發出的tuple被完全處理,當且僅當tuple樹已經為空並且樹中的每個訊息都已被處理。如果tuple沒有在給定的超時時間(timeout)內被完全處理,就定義為處理失敗。timeout可以使用Config.TOPOLOGY_MESSAGE_TIMEOUT_SECS來配置,預設是30秒。

當一個訊息被完全處理或沒有被完全處理時發生了什麼?

為了理解這個問題,讓我們看看tuple從spout開始的生命週期。作為參考,這裡是spouts實現的介面:

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

首先,Storm通過呼叫Spout的nextTuple方法來請求一個tuple。Spout使用SpoutOutputCollector(在open函式中提供)來emit一個tuple到某個輸出流。當emitting tuple的時候,Spout設定了一個"message id",後續會用來識別tuple。舉個例子,KestrelSpout從kestrel佇列中讀取訊息,由Kestrel給出id並設定為"message id",然後emit。像這樣發出訊息:

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

接下來,tuple被髮送給消費bolts,Storm負責維護訊息樹。如果Storm檢測到一個tuple被完全處理了,Storm會呼叫Spout的ack方法(攜帶引數message id)。同樣的,如果tuple處理超時,Storm將呼叫Spout的fail方法。注意,一個tuple只會被建立它的那個Spout任務acked或failed,如果Spout被叢集中的多個任務執行,tuple不會被非建立它的任務acked或failed。

再次以KestrelSpout為例來說明Spout如何保證訊息處理。當KestrelSpout從Kestrel佇列中取出一個訊息,它"opens"這個訊息,訊息並沒有真的從佇列中取下來,只是設定了一個掛起("pending")狀態,等待訊息處理完成的確認。處於掛起狀態的訊息不會被髮送給其他佇列消費者。此外,如果一個客戶端失去連線,它的所有掛起狀態的訊息會被放回佇列。KestrelSpout會給SpoutOutputCollector傳遞一個"message id"引數,稍後,KestrelSpout的ack和fail函式被呼叫,KestrelSpout會給Kestrel發一個帶"message id"的ack或fail訊息,進而將訊息移除或放回佇列。

什麼是Storm的可靠性API?

要想利用Storm的可靠效能力要做兩件事。首先,任何時候你在tuple樹中建立新的link都要通知Storm。其次,當你完成一個獨立tuple的處理時也要通知Storm。通過做這兩件事,Storm可以檢測tuple樹是否處理完畢並恰當的處理spout tuple的ack或fail。Storm的API提供了一種簡潔的方式來完成這些任務。

在tuple樹中指定一個link被稱作錨定(anchoring)。在你emit一個新的tuple時就同步完成了錨定。舉個例子,下面這個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通過指定輸入tuple為emit的第一個引數而錨定。由於單詞tuple已被錨定,在單詞tuple處理失敗的時候,tuple樹的根spout tuple將被重新傳輸。相反的,讓我們看看如果像這樣emit tuple會發生什麼:

_collector.emit(new Values(word));

這樣emit的單詞tuple沒有被錨定,如果tuple處理失敗,根tuple不會被重傳。取決於你的容錯需求,有時候以非錨定的方式emit tuple也是恰當的。

一個輸出tuple可以被錨定到多個輸入tuple,這對流連線或流聚合(streaming joins or aggregations)很有用。被多個輸入錨定的tuple處理失敗,會導致多個根tuple重傳。例子:

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

多錨定(Multi-anchoring)將把輸出tuple加入到多個tuple樹。注意,這可能會破壞樹的結構並且建立tuple 有向無環圖(DAGs)。例如:


3143746-6012945ea5c989dd.png
Tuple DAG

Storm的實現支援有向無環圖和樹。

錨定就是你如何說明tuple樹——下一個也是最後一個關於Storm可靠性API的點是當你處理完一個獨立的tuple時,如何說明tuple樹。通過呼叫OutputCollector的ack和fail來實現這個操作。如果你往回看例子SplitSentence,你會看到在所有單詞tuple被emit之後輸入tuple被確認了(acked)。

你可以使用OutputCollector 的fail方法來立即使根tuple(spout tuple)失敗。例如,你的應用也許會選擇捕獲資料庫客戶端的異常,顯式的使輸入tuple失敗。通過顯式的使tuple失敗,根tuple可以比等待超時更快的被重傳。

每個tuple都應該被ack或fail。Storm佔用了記憶體來跟蹤每一個tuple,如果不ack/fail每個tuple,任務可能最終會耗盡記憶體。

許多bolts使用了一種通用模式來讀取和發出輸入tuple,在execute方法的最後ack tuple。這些bolts歸類為過濾器和簡單函式(filters and simple functions)。Storm提供了一個BasicBolt介面封裝了這種模式,SplitSentence例子可以用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"));
        }        
    }

這種實現比之前的實現簡單,語義上一致。Tuples自動錨定到輸入tuple,execute方法完成時自動ack。
相反,實現聚合和連線的bolts可能會延遲ack,直到一組tuples處理完畢。聚合和連線一般也會多錨定(multi-anchor),IBasicBolt不能自動做這些。

如果tuples可以重傳,程式該如何正確工作?

軟體設計的一貫答案是“取決於”。如果你一定要一個答案,考慮使用Trident API。某些情況下,如要做很多分析並且可以容忍丟失資料,那麼可以通過設定acker bolts為0(Config.TOPOLOGY_ACKERS)來禁用容錯。但在有些情況下,你想要確保每個資料都被至少處理了一次並且沒有丟失。

Storm如何有效的實現可靠性?

Storm的topology有一些特殊的“acker”任務,負責追蹤每個spout tuple的tuples DAG,一旦acker發現DAG完成了,它就會發一個確認訊息給spout。你可以通過Config.TOPOLOGY_ACKERS設定acker任務的數量。Storm預設是每個worker有一個acker。

不管是spout還是bolt發出的tuple都有一個64位的id。每個tuple都知道tuple樹中的所有spout tuples的ids。當你發出一個新tuple時,老的tuple錨定的spout tuples ids被複制到新的tuple。當一個tuple被確認了,它會發一個tuple樹如何變更的訊息給acker任務,特別地,訊息可能像這樣:“我已經完成了tuple樹中這個spout tuple的處理,樹中有一些新的tuples以我為錨”。

例如,如果tuples “D”和“E”是基於tuple “C”而建立,當“C”確認時,tuple樹的變化如下:

3143746-4d0ad9e7e6edc9d1.png

由於在“D”和“E”建立的同時,“C”被從樹中移除了,樹永遠不會過早的(prematurely)完成。注:這句不是很理解

還有一些細節要提一下。之前提到可以有多個acker任務,那麼當一個tuple被確認時,如何知道由哪一個任務傳送確認資訊?

Storm使用mod hashing來對映spout tuple id到acker任務。由於每個tuple都攜帶了它所在所有樹中的spout tuple ids,因此知道該與哪個acker任務通訊。

另一個細節是acker任務如何跟蹤spout任務。當spout task發出一個新tuple,它只是簡單的傳送訊息到恰當的acker,告訴它為這個spout tuple負責。之後當一個acker發現樹已經完成,它就知道該給哪個任務id發完成資訊。

acker任務不會顯式追蹤tuples樹。對於有好幾萬節點(甚至更多)的大tuple樹,跟蹤所有的tuple樹可能會造成記憶體不夠用。ackers採用一種策略,對每個spout tuple只要求固定數量的記憶體(大約20位元組)。這個追蹤演算法是理解Storm工作的關鍵,也是Storm主要的突破之一。

acker任務儲存了一個spout tuple到一組值的map。第一個值是任務id,用來傳送完成資訊。第二個值是64位數字,名為“ack val”,這個值代表了整個tuple樹的狀態,無論樹多大多小。它只是簡單的把樹中所有已建立或確認的tuple ids做xor運算。

當一個acker任務發現“ack val”變成了0,它就知道tuple樹完成了。由於tuple ids是64位隨機數,“ack val”意外變成0的概率極小。用數學知識算一下,每秒10K個acks,大概要花50,000,000年才會發生一個錯誤。即使發生錯誤,也只是丟失資料。

現在你理解了可靠性演算法,讓我們過一遍失敗的情形,看看每種情形下Storm如何避免資料丟失:

  • 由於任務異常終止,tuple未被確認:這種情況下失敗tuple的樹根處的spout tuple將超時並重發。
  • acker任務異常終止:這種情況下,所有這個akcer跟蹤的spout tuples都會超時並重發。
  • spout任務異常終止:這種情況下,spout的源負責重發訊息。例如,客戶端失去連線時,像Kestrel和RabbitMQ這樣的佇列將把掛起的訊息重新放回佇列。

如你所見,Storm的可靠性機制是完全分散式、大規模和容錯的。

調教reliability

acker任務是輕量級的,所以在一個topology裡不需要很多個acker。你可以通過Storm UI(元件id“__acker”)跟蹤acker的效能,如果吞吐量不行,可以增加acker的數量。

如果可靠性對你不重要——你不關心丟失tuples,那麼你可以通過不追蹤tuple樹來增加效能。不追蹤tuple樹可以減半訊息傳輸的數量,另外,下游的tuple可以儲存更少的ids,節省了網路頻寬。

有三種方式可以移除可靠性。第一種是設定Config.TOPOLOGY_ACKERS為0。這種情況下,Storm會在spout發出tuple時立即呼叫ack方法。

第二種是移除訊息上的可靠性。你可以在呼叫SpoutOutputCollector.emit方法的時候不傳訊息id,這樣就關閉了對某個spout tuple的追蹤。

最後,如果你不關心下游tuples是否處理失敗,你可以在emit它們的時候不錨定它們。由於它們沒有錨定到任何spout tuples上,它們沒被確認不會導致任何spout tuples失敗。

相關文章