網址: http://xumingming.sinaapp.com/736/twitter-storm-transactional-topolgoy/
本文翻譯自: https://github.com/nathanmarz/storm/wiki/Transactional-topologies
概述
Storm通過保證每個tuple至少被處理一次來提供可靠的數據處理。關於這一點最常被問到的問題就是“既然tuple可能會被重寫發射(replay), 那麼我們怎麼在storm上面做統計個數之類的事情呢?storm有可能會重複計數吧?”
Storm 0.7.0引入了Transactional Topology, 它可以保證每個tuple”被且僅被處理一次”, 這樣你就可以實現一種非常準確,非常可擴展,並且高度容錯方式來實現計數類應用。
跟Distributed RPC類似, transactional topology其實不能算是storm的一個特性,它其實是用storm的底層原語spout, bolt, topology, stream等等抽象出來的一個特性。
這篇文章解釋了事務性topology是怎樣的一種抽象,怎樣使用它的api,同時也討論了有關它實現的一些細節。
概念
讓我們一步步地建立transactional topology的抽象。我們先提出一種最簡單的抽象方式, 然後一步步的完善改進,最後介紹storm代碼裏面所使用的抽象方式。
第一個設計: 最簡單的抽象方法
事務性topology背後的核心概念是要在處理數據的提供一個強順序性。這種強順序性最簡單的表現、同時也是我們第一個設計就是:我們每次只處理一個tuple, 除非這個tuple處理成功,否則我們不去處理下一個tuple。
每一個tuple都跟一個transaction id相關聯。如果這個tuple處理失敗了,然後需要重寫發射,那麼它會被重新發射 — 並且附着同樣的transaction id。這裏說的trasaction id其實就是一個數字, 來一個tuple,它就遞增一個。所以第一個tuple的transaction id是1, 第二個tuple的transaction id是2,等等等等。
tuple的強順序性使得我們即使在tuple重發的時候也能夠實現“一次而且只有一次”的語義。 讓我們看個例子:
比如你想統一個stream裏面tuple的總數。那麼爲了保證統計數字的準確性,你在數據庫裏面不但要保存tuple的個數, 還要保存這個數字所對應的最新的transaction id。 當你的代碼要到數據庫裏面去更新這個數字的時候,你要判斷只有當新的transaction id跟數據庫裏面保存的transaction id不一樣的時候纔去更新。考慮兩種情況:
- 數據庫裏面的transaction id跟當前的transaction id不一樣: 由於我們transaction的強順序性,我們知道當前的tuple肯定沒有統計在數據庫裏面。所以我們可以安全地遞增這個數字,並且更新這個transaction id.
- 數據庫裏面的transaction id一樣: 那麼我們知道當前tuple已經統計在數據庫裏面了,那麼可以忽略這個更新。這個tuple肯定之前在更新了數據庫之後,反饋給storm的時候失敗了(ack超時之類的)。
這個邏輯以及事務的強順序性保證數據庫裏面的個數(count)即使在tuple被重發的時候也是準確的。這個主意(保存count + transaction-id)是Kafka的開發者在這個設計文檔裏面提出來的。
更進一步來說,這個topology可以在一個事務裏面更新很多不同的狀態,並且可以到達”一次而且只有一次的邏輯”。如果有任何失敗,那麼已經成功的更新你再去更新它會忽略,失敗的更新你去再次更新它則會接受。比如,如果你在處理一個url流,你可以更新每個url的轉發次數, 同時更新每個domain下url的轉發次數。
這個簡單設計有一個很大的問題, 那就是你需要等待一個tuple完全處理成功之後才能去處理下一個tuple。這個性能是非常差的。這個需要大量的數據庫調用(只要每個tuple一個數據庫調用), 而且這個設計也沒有利用到storm的並行計算能力, 所以它的可擴展能力是非常差的。
第二個設計
與每次只處理一個tuple的簡單方案相比, 一個更好的方案是每個transaction裏面處理一批tuple。所以如果你在做一個計數應用, 那麼你每次更新到總數裏面的是這一整個batch的tuple數量。如果這個batch失敗了,那麼你重新replay這整個batch。相應地, 我們不是給每個tuple一個transaction id而是給整個batch一個transaction id,batch與batch之間的處理是強順序性的, 而batch內部是可以並行的。下面這個是設計圖:
所以如果你每個batch處理1000個tuple的話, 那麼你的應用將會少1000倍的數據庫調用。同時它利用了storm的並行計算能力(每個batch內部可以並行)
雖然這個設計比第一個設計好多了, 它仍然不是一個完美的方案。topology裏面的worker會花費大量的時間等待計算的其它部分完成。 比如看下面的這個計算。
在bolt 1完成它的處理之後, 它需要等待剩下的bolt去處理當前batch, 直到發射下一個batch。
第三個設計(storm採用的設計)
一個我們需要意識到的比較重要的問題是,爲了實現transactional的特性,在處理一批tuples的時候,不是所有的工作都需要強順序性的。比如,當做一個全局計數應用的時候, 整個計算可以分爲兩個部分。
- 計算這個batch的局部數量。
- 把這個batch的局部數量更新到數據庫裏面去。
其中第二步在多個batch之前需要保證強的順序性, 但是第一步並不許要, 所以我們可以把第一步並行化。所以當第一個batch在更新它的個數進入數據庫的時候,第2到10個batch可以開始計算它們的局部數量了。
Storm通過把一個batch的計算分成兩個階段來實現上面所說的原理:
- processing階段: 這個階段很多batch可以並行計算。
- commit階段: 這個階段各個batch之間需要有強順序性的保證。所以第二個batch必須要在第一個batch成功提交之後才能提交。
這兩個階段合起來稱爲一個transaction。許多batch可以在processing階段的任何時刻並行計算,但是隻有一個batch可以處在commit階段。如果一個batch在processing或者commit階段有任何錯誤, 那麼整個transaction需要被replay。
設計細節
當使用Transactional Topologies的時候, storm爲你做下面這些事情:
1) 管理狀態: Storm把所有實現Transactional Topologies所必須的狀態保存在zookeeper裏面。 這包括當前transaction id以及定義每個batch的一些元數據。
2) 協調事務: Storm幫你管理所有事情, 以幫你決定在任何一個時間點是該proccessing還是該committing。
3) 錯誤檢測: Storm利用acking框架來高效地檢測什麼時候一個batch被成功處理了,被成功提交了,或者失敗了。Storm然後會相應地replay對應的batch。你不需要自己手動做任何acking或者anchoring — storm幫你搞定所有事情。
4) 內置的批處理API: Storm在普通bolt之上包裝了一層API來提供對tuple的批處理支持。Storm管理所有的協調工作,包括決定什麼時候一個bolt接收到一個特定transaction的所有tuple。Storm同時也會自動清理每個transaction所產生的中間數據。
5) 最後,需要注意的一點是Transactional Topologies需要一個可以完全重發(replay)一個特定batch的消息的隊列系統(Message Queue)。Kestrel之類的技術做不到這一點。而Apache的Kafka對於這個需求來說是正合適的。storm-contrib裏面的storm-kafka實現了這個。
一個基本的例子
你可以通過使用TransactionalTopologyBuilder來創建transactional topology. 下面就是一個transactional topology的定義, 它的作用是計算輸入流裏面的tuple的個數。這段代碼來自storm-starter裏面的TransactionalGlobalCount。
1
2
3
4
5
6
7
8
|
MemoryTransactionalSpout
spout = new
MemoryTransactionalSpout( DATA,
new
Fields( "word" ),
PARTITION_TAKE_PER_BATCH); TransactionalTopologyBuilder
builder = new
TransactionalTopologyBuilder( "global-count" ,
"spout" ,
spout, 3 ); builder.setBolt( "partial-count" ,
new
BatchCount(), 5 ) .shuffleGrouping( "spout" ); builder.setBolt( "sum" ,
new
UpdateGlobalCount()) .globalGrouping( "partial-count" ); |
TransactionalTopologyBuilder
接受如下的參數
- 這個transaction topology的id
- spout在整個topology裏面的id。
- 一個transactional spout。
- 一個可選的這個transactional spout的並行度。
topology的id是用來在zookeeper裏面保存這個topology的當前進度的,所以如果你重啓這個topology, 它可以接着前面的進度繼續執行。
一個transaction topology裏面有一個唯一的TransactionalSpout
, 這個spout是通過TransactionalTopologyBuilder
的構造函數來制定的。在這個例子裏面,MemoryTransactionalSpout
被用來從一個內存變量裏面讀取數據(DATA)。第二個參數制定數據的fields,
第三個參數指定每個batch的最大tuple數量。關於如何自定義TransactionalSpout
我們會在後面介紹。
現在說說 bolts。這個topology並行地計算tuple的總數量。第一個bolt:BatchBolt
,隨機地把輸入tuple分給各個task,然後各個task各自統計局部數量。第二個bolt:UpdateBlobalCount
,
用全局grouping來從彙總這個batch的總的數量。然後再把總的數量更新到數據庫裏面去。
下面是BatchCount
的定義:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
|
public
static
class
BatchCount extends
BaseBatchBolt { Object
_id; BatchOutputCollector
_collector; int
_count = 0 ; @Override public
void
prepare(Map conf, TopologyContext context, BatchOutputCollector
collector, Object id) { _collector
= collector; _id
= id; } @Override public
void
execute(Tuple tuple) { _count++; } @Override public
void
finishBatch() { _collector.emit( new
Values(_id, _count)); } @Override public
void
declareOutputFields(OutputFieldsDeclarer declarer) { declarer.declare( new
Fields( "id" ,
"count" )); } } |
storm會爲每個batch創建這個一個BatchCount
對象。而這些BatchCount
是運行在BatchBoltExecutor
裏面的。而BatchBoltExecutor
負責創建以及清理這個對象的實例。
這個對象的prepare方法接收如下參數:
- 包含storm config信息的map。
- TopologyContext
- OutputCollector
- 這個batch的id。而在Transactional Topologies裏面, 這個id則是一個TransactionAttempt對象。
這個batch bolt的抽象在DRPC裏面也可以用, 只是id的類型不一樣而已。BatchBolt其實真的接收一個id類型的參數 — 它是一個java模板類,所以如果你只是想在transactioinal topology裏面使用這個BatchBolt,你可以這樣定義:
1
2
3
|
public
abstract
class
BaseTransactionalBolt extends
BaseBatchBolt<TransactionAttempt> { } |
在transaction topology裏面發射的所有的tuple都必須以TransactionAttempt
作爲第一個field,
然後storm可以根據這個field來判斷哪些tuple屬於一個batch。所以你在發射tuple的時候需要滿足這個條件。
TransactionAttempt
包含兩個值: 一個transaction id,一個attempt id。transaction
id的作用就是我們上面介紹的對於每個batch是唯一的,而且不管這個batchreplay多少次都是一樣的。attempt id是對於每個batch唯一的一個id, 但是對於統一個batch,它replay之後的attempt id跟replay之前就不一樣了, 我們可以把attempt id理解成replay-times, storm利用這個id來區別一個batch發射的tuple的不同版本。
transaction id對於每個batch加一, 所以第一個batch的transaction id是”1″, 第二個batch是”2″,以此類推。
execute方法會爲batch裏面的每個tuple執行一次,你應該把這個batch裏面的狀態保持在一個本地變量裏面。對於這個例子來說, 它在execute方法裏面遞增tuple的個數。
最後, 當這個bolt接收到某個batch的所有的tuple之後, finishBatch方法會被調用。這個例子裏面的BatchCount類會在這個時候發射它的局部數量到它的輸出流裏面去。
下面是UpdateGlobalCount
類的定義。
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
|
public
static
class
UpdateGlobalCount extends
BaseTransactionalBolt implements
ICommitter { TransactionAttempt
_attempt; BatchOutputCollector
_collector; int
_sum = 0 ; @Override public
void
prepare(Map conf, TopologyContext
context, BatchOutputCollector
collector, TransactionAttempt
attempt) { _collector
= collector; _attempt
= attempt; } @Override public
void
execute(Tuple tuple) { _sum+=tuple.getInteger( 1 ); } @Override public
void
finishBatch() { Value
val = DATABASE.get(GLOBAL_COUNT_KEY); Value
newval; if (val
== null
|| !val.txid.equals(_attempt.getTransactionId()))
{ newval
= new
Value(); newval.txid
= _attempt.getTransactionId(); if (val== null )
{ newval.count
= _sum; }
else
{ newval.count
= _sum + val.count; } DATABASE.put(GLOBAL_COUNT_KEY,
newval); }
else
{ newval
= val; } _collector.emit( new
Values(_attempt, newval.count)); } @Override public
void
declareOutputFields(OutputFieldsDeclarer declarer) { declarer.declare( new
Fields( "id" ,
"sum" )); } } |
UpdateGlobalCount
是Transactional Topologies相關的類, 所以它繼承自BaseTransactionalBolt
。在execute方法裏面, UpdateGlobalCount
累積這個batch的計數,
比較有趣的是finishBatch方法。
首先, 注意這個bolt實現了ICommitter
接口。這告訴storm要在這個事務的commit階段調用finishBatch
方法。所以對於finishBatch的調用會保證強順序性(順序就是transaction
id的升序), 而相對來說execute方法在任何時候都可以執行,processing或者commit階段都可以。另外一種把bolt標識爲commiter的方法是調用TransactionalTopologyBuilder
的setCommiterBolt
來添加Bolt(而不是setBolt)。
UpdateGlobalCount
裏面finishBatch方法的邏輯是首先從數據庫中獲取當前的值,並且把數據庫裏面的transaction
id與當前這個batch的transaction id進行比較。如果他們一樣, 那麼忽略這個batch。否則把這個batch的結果加到總結果裏面去,並且更新數據庫。
關於transactional topology的更深入的例子可以卡看storm-starter裏面的TransactionalWords類, 這個類裏面會在一個事務裏面更新多個數據庫。
Transactional Topology API
這一節介紹Transaction topology API
Bolts
在一個transactional topology裏面最多有三種類型的bolt:
- BasicBolt: 這個bolt不跟batch的tuple打交道,它只基於單個tuple的輸入來發射新的tuple。
- BatchBolt: 這個bolt處理batch在一起的tuples。對於每一個tuple調用execute方法。而在整個batch處理完成的時候調用finishBatch方法
-
被標記成Committer的BatchBolt: 和普通的BatchBolt的唯一的區別是finishBatch這個方法被調用的時機。作爲committer的BatchBolt的finishBatch方法在commit階段調用。一個batch的commit階段由storm保證只在前一個batch成功提交之後纔會執行。並且它會重試直到topology裏面的所有bolt在commit完成提交。有兩個方法可以讓一個普通BatchBolt變成committer: 1) 實現ICommitter接口
2) 通過TransactionalTopologyBuilder的setCommitterBolt方法把BatchBolt添加到topology裏面去。
Processing phase vs. commit phase in bolts
爲了搞清除processing階段與commit階段的區別, 讓我們看個例子:
在這個topology裏面只有用紅線標出來的是committers。
在processing階段, bolt A會處理從spout發射出來的整個batch。並且發射tuple給bolt B和bolt C。Bolt B是一個committer, 所以它會處理所有的tuple, 但是不會調用
finishBatch
方法。Bolt C同樣也不會調用finishBatch
方法, 它的原因是:它不知道它有沒有從Bolt B接收到所有的tuple。(因爲Bolt B還在等着事務提交)最後Bolt D會接收到Bolt C在調用execute方法的時候發射的所有的tuple。當batch提交的時候, Bolt B上的
finishBatch
被調用。Bolt C現在可以判斷它接收到了所有的tuple, 所以可以調用finishBatch
了。最後Bolt D接收到了它的所有的tuple所以就調用finishBatch了。要注意的是,雖然Bolt D是一個committer, 它在接收到整個batch的tuple之後不需要等待第二個commit信號。因爲它是在commit階段接收到的整個batch,它會調用finishBatch來完成整個事務。
Acking
注意, 你不需要顯式地去做任何的acking或者anchoring。storm在背後都做掉了。(storm對transactional topolgies裏面的acking機制進行了高度的優化)
Failing a transaction
在使用普通bolt的時候, 你可以通過調用OutputCollector的fail方法來fail這個tuple所在的tuple樹。由於Transactional Topologies把acking框架從用戶的視野裏面隱藏掉了, 它提供一個不同的機制來fail一個batch(從而使得這個batch被replay)。只要拋出一個FailedException就可以了。跟普通的異常不一樣, 這個異常只會導致當前的batch被replay, 而不會使整個進程crash掉。
Transactional spout
TransactionalSpout接口跟普通的Spout接口完全不一樣。一個TransactionalSpout的實現一個batch一個batch的tuple, 而且必須保證同一個batch的transaction id始終一樣。
在transactional topology中運行的時候, transactional spout看起來是這樣的一個結構:
在圖的左邊的coordinator是一個普通的storm的spout — 它一直爲事務的batch發射tuple。Emitter則像一個普通的storm bolt,它負責爲每個batch實際發射tuple。emitter以all grouping的方式訂閱coordinator的”batch emit”流。
由於TransactionalSpout發射的tuple可能需要會被replay, 因此需要具有冪等性(否則多次replay同一個tuple會使得最後的結果不對), 爲了實現冪等性,需要保存Transactional Spout的少量的狀態,這個狀態是保存在ZooKeeper裏面的。
關於如何實現一個
TransactionalSpout
的細節可以參見Javadoc。Partitioned Transactional Spout
一種常見的TransactionalSpout是那種從多個queue broker奪取數據然後再發射的tuple。比如TransactionalKafkaSpout是這樣工作的。
IPartitionedTransactionalSpout
把這些管理每個分區的狀態以保證可以replay的冪等性的工作都自動化掉了。更多可以參考Javadoc配置
Transactional Topologies有兩個重要的配置:
-
Zookeeper: 默認情況下,transactional topology會把狀態信息保存在主zookeeper裏面(協調集羣的那個)。你可以通過這兩個配置來指定其它的zookeeper:”
transactional.zookeeper.servers
” 和 “transactional.zookeeper.port
“。 -
同時活躍的batch數量:你必須設置同時處理的batch數量。你可以通過”
topology.max.spout.pending
” 來指定, 如果你不指定,默認是1。
實現
Transactional Topologies的實現是非常優雅的。管理提交協議,檢測失敗並且串行提交看起來很複雜,但是使用storm的原語來進行抽象是非常簡單的。
-
transactional topology裏面的spout是一個子topology, 它由一個spout和一個bolt組成。
- spout是協調者,它只包含一個task。
- bolt是發射者
- bolt以all grouping的方式訂閱協調者的輸出。
- 元數據的序列化用的是kryo。
- 協調者使用acking框架來決定什麼時候一個batch被成功執行完成,然後去決定一個batch什麼時候被成功提交。
-
狀態信息被以
RotatingTransactionalState
的形式保存在zookeeper裏面了。 - commiting bolts以all grouping的方式訂閱協調者的commit流。
-
CoordinatedBolt被用來檢測一個bolt是否收到了一個特定batch的所有tuple。
- 這一點上面跟DRPC裏面是一樣的。
-
對於commiting bolt來說, 他會一直等待, 知道從coordinator的commit流裏面接收到一個tuple之後,它纔會調用
finishBatch
方法。 -
所以在沒有從coordinator的commit流接收到一個tuple之前,committing bolt不可能調用
finishBolt
方法。
-
Zookeeper: 默認情況下,transactional topology會把狀態信息保存在主zookeeper裏面(協調集羣的那個)。你可以通過這兩個配置來指定其它的zookeeper:”