Clojure實戰(5):Storm實時計算框架 | Ji ZHANG's Blog
http://shzhangji.com/blog/2013/04/22/cia-storm/
Storm簡介
上一章介紹的Hadoop工具能夠對海量數據進行批量處理,采用分布式的并行計算架構,只需使用其提供的MapReduce API編寫腳本即可。但隨著人們對數據實時性的要求越來越高,如實時日志分析、實時推薦系統等,Hadoop就無能為力了。
這時,Storm誕生了。它的設計初衷就是提供一套分布式的實時計算框架,實現低延遲、高并發的海量數據處理,被譽為“Realtime Hadoop”。它提供了簡單易用的API接口用于編寫實時處理腳本;能夠和現有各類消息系統整合;提供了HA、容錯、事務、RPC等高級特性。
Storm的官網是:storm-project.net,它的Wiki上有非常詳盡的說明文檔。
Storm與Clojure
Storm的主要貢獻者Nathan Marz和徐明明都是活躍的Clojure開發者,因此在Storm框架中也提供了原生的Clojure DSL。本文就將介紹如何使用這套DSL來編寫Storm處理腳本。
Storm集群的安裝配置這里不會講述,具體請參考這篇文檔。下文的腳本都運行在“本地模式”之下,因此即使不搭建集群也可以運行和調試。
Storm腳本的組件
Storm腳本的英文名稱叫做“Storm Topology”,直譯過來是“拓撲結構”。這個腳本由兩大類組建構成,Spout
和Bolt
,分別可以有任意多個。他們之間以“數據流”的方式連接起來,因此整體看來就像一張拓撲網絡,因此得名Topology
。
Spout
數據源節點,是整個腳本的入口。Storm會不斷調用該節點的nextTuple()
方法來獲取數據,分發給下游Bolt
節點。nextTuple()
方法中可以用各種方式從外部獲取數據,如逐行讀取一個文件、從消息隊列(ZeroMQ、Kafka)中獲取消息等。一個Storm腳本可以包含多個Spout
節點,從而將多個數據流匯聚到一起進行處理。
Bolt
數據處理節點,它是腳本的核心邏輯。它含有一個execute()
方法,當接收到消息時,Storm會調用這個函數,并將消息傳遞給它。我們可以在execute()
中對消息進行過濾(只接收符合條件的數據),或者進行聚合(統計某個條件的數據出現的次數)等。處理完畢后,這個節點可以選擇將處理后的消息繼續傳遞下去,或是持久化到數據庫中。
Bolt
同樣是可以有多個的,且能夠前后組合。Bolt C
可以同時收取Bolt A
和Bolt B
的數據,并將處理結果繼續傳遞給Bolt D
。
此外, 一個Bolt可以產生多個實例 ,如某個Bolt
包含復雜耗時的計算,那在運行時可以調高其并發數量(實例的個數),從而達到并行處理的目的。
Tuple
Tuple
是消息傳輸的基本單元,一條消息即一個Tuple
。可以將其看做是一個HashMap
對象,它能夠包含任何可序列化的數據內容。對于簡單的數據類型,如整型、字符串、Map等,Storm提供了內置的序列化支持。而用戶自定義的數據類型,可以通過指定序列化/反序列化函數來處理。
Stream Grouping
想象一個Spout
連接了兩個Bolt
(或一個Bolt
的兩個實例),那數據應該如何分發呢?你可以選擇輪詢(ShuffleGrouping
),或是廣播(GlobalGrouping
)、亦或是按照某一個字段進行哈希分組(FieldGrouping
),這些都稱作為Stream Grouping
。
示例:WordCount
下面我們就來實現一個實時版的WordCount腳本,它由以下幾個組件構成:
sentence-spout:從已知的一段文字中隨機選取一句話發送出來;
split-bolt:將這句話按空格分割成單詞;
count-bolt:統計每個單詞出現的次數,每五秒鐘打印一次,并清零。
依賴項和配置文件
首先使用lein new
新建一個項目,并修改project.clj
文件:
1
2
3
4
5
6
7
(defproject cia-storm "0.1.0-SNAPSHOT"
...
:dependencies [[org.clojure/clojure "1.4.0"]
[org.clojure/tools.logging "0.2.6"]]
:profiles {:dev {:dependencies [[storm "0.8.2"]]}}
:plugins [[lein2-eclipse "2.0.0"]]
:aot [cia-storm.wordcount])
其中:profiles
表示定義不同的用戶配置文件。Leiningen有類似于Maven的配置文件體系(profile),每個配置文件中可以定義project.clj
所支持的各種屬性,執行時會進行合并。lein
命令默認調用:dev
、:user
等配置文件,可以使用lein with-profiles prod run
來指定配置文件。具體可以參考這份文檔。
這里將[storm "0.8.2"]
依賴項定義在了:dev
配置下,如果直接定義在外層的:dependencies
下,那在使用lein uberjar
進行打包時,會將storm.jar
包含在最終的Jar包中,提交到Storm集群運行時就會報沖突。而lein uberjar
默認會跳過:dev
配置,所以才這樣定義。
:aot
表示Ahead Of Time
,即預編譯。我們在Clojure實戰(3)中提過:gen-class
這個標識表示為當前.clj
文件生成一個.class
文件,從而能夠作為main
函數使用,因此也需要在project.clj
中添加:main
標識,指向這個.clj
文件的命名空間。如果想為其它的命名空間也生成對應的.class
文件,就需要用到:aot
了。它的另一個用處是加速Clojure程序的啟動速度。
sentence-spout
1
2
3
4
5
6
7
8
9
10
11
12
13
14
(ns cia-storm.wordcount
...
(:use [backtype.storm clojure config]))
(defspout sentence-spout ["sentence"]
[conf context collector]
(let [sentences ["a little brown dog"
"the man petted the dog"
"four score and seven years ago"
"an apple a day keeps the doctor away"]]
(spout
(nextTuple []
(Thread/sleep 1000)
(emit-spout! collector [(rand-nth sentences)])))))
defspout
是定義在backtype.storm.clojure
命名空間下的宏,可以點此查看源碼。以下是各個部分的說明:
sentence-spout
是該組件的名稱。
["sentence"]
表示該組件輸出一個字段,名稱為“sentence”。
[conf context collector]
用于接收Storm框架傳入的參數,如配置對象、上下文對象、下游消息收集器等。
spout
表示開始定義數據源組件需要用到的各類方法。它實質上是生成一個實現了ISpout接口的對象,從而能夠被Storm框架調用。
nextTuple
是ISpout接口必須實現的方法之一,Storm會不斷調用這個方法,獲取數據。這里使用Thread#sleep
函數來控制調用的頻率。
emit-spout!
是一個函數,用于向下游發送消息。
ISpout還有open、ack、fail等函數,分別表示初始化、消息處理成功的回調、消息處理失敗的回調。這里我們暫不深入討論。
split-bolt
1
2
3
4
5
6
7
8
(defbolt split-bolt ["word"] {:prepare true}
[conf context collector]
(bolt
(execute [tuple]
(let [words (.split (.getString tuple 0) " ")]
(doseq [w words]
(emit-bolt! collector [w])))
(ack! collector tuple))))
defbolt
用于定義一個Bolt組件。整段代碼的結構和defspout
是比較相似的。bolt
宏會實現為一個IBolt對象,execute
是該接口的方法之一,其它還有prepare
和cleanup
。execute
方法接收一個參數tuple
,用于接收上游消息。
ack!
是execute
中必須調用的一個方法。Storm會對每一個組件發送出來的消息進行追蹤,上游組件發出的消息需要得到下游組件的“確認”(ACKnowlege),否則會一直堆積在內存中。對于Spout而言,如果消息得到確認,會觸發ISpout#ack
函數,否則會觸發ISpout#fail
函數,這時Spout可以選擇重發或報錯。
代碼中比較怪異的是{:prepare true}
。defspout
和defbolt
有兩種定義方式,即prepare和非prepare。兩者的區別在于:
參數不同,prepare方式下接收的參數是[conf context collector]
,非prepare方式下,defspout
接收的是[collector]
,defbolt
是[tuple collector]`。
prepare方式下需要調用spout
和bolt
宏來編寫組件代碼,而非prepare方式則不需要——defspout
會默認生成nextTuple()
函數,defbolt
默認生成execute(tuple)
。
只有prepare方式下才能指定ISpout#open
、IBolt#prepare
等函數,非prepare不能。
defspout
默認使用prepare方式,defbolt
默認使用非prepare方式。
因此,split-bolt
可以按如下方式重寫:
1
2
3
4
5
6
(defbolt split-bolt ["word"]
[tuple collector]
(let [words (.split (.getString tuple 0) " ")]
(doseq [w words]
(emit-bolt! collector [w]))
(ack! collector tuple)))
prepare方式可以用于在組件中保存狀態,具體請看下面的計數Bolt。
count-bolt
1
2
3
4
5
6
7
8
(defbolt count-bolt [] {:prepare true}
[conf context collector]
(let [counts (atom {})]
(bolt
(execute [tuple]
(let [word (.getString tuple 0)]
(swap! counts (partial merge-with +) {word 1}))
(ack! collector tuple)))))
原子(Atom)
atom
是我們遇到的第一個可變量(Mutable Variable),其它的有Ref、Agent等。Atom是“原子”的意思,我們很容易想到原子性操作,即同一時刻只有一個線程能夠修改Atom的值,因此它是處理并發的一種方式。這里我們使用Atom來保存每個單詞出現的數量。以下是Atom的常用操作:
1
2
3
4
5
6
7
8
9
10
11
user=> (def cnt (atom 0))
user=> (println @cnt) ; 使用@符號獲取Atom中的值。
0
user=> (swap! cnt inc) ; 將cnt中的值置換為(inc @cnt),并返回該新的值
1
user=> (println @cnt)
1
user=> (swap! cnt + 10) ; 新值為(+ @cnt 10)
11
user=> (reset! cnt 0) ; 歸零
0
需要注意的是,(swap! atom f arg ...)
中的f
函數可能會被執行多次,因此要確保它沒有副作用(side-effect,即不會產生其它狀態的變化)。
再來解釋一下(partial merge-with +)
。merge-with
函數是對map類型的一種操作,表示將一個或多個map合并起來。和merge
不同的是,merge-with
多接收一個f
函數(merge-with [f & maps]
),當鍵名重復時,會用f
函數去合并它們的值,而不是直接替代。
partial
可以簡單理解為給函數設定默認值,如:
1
2
3
4
5
6
user=> (defn add [a b] (+ a b))
user=> (add 5 10)
15
user=> (def add-5 (partial add 5))
user=> (add-5 10)
15
這樣一來,(swap! counts (partial merge-with +) {word 1})
就可理解為:將counts
這個Atom中的值(一個map類型)和{word 1}
這個map進行合并,如果單詞已存在,則遞增1。
線程(Thread)
為了輸出統計值,我們為count-bolt增加prepare方法:
1
2
3
4
5
6
7
8
9
10
11
12
...
(bolt
(prepare [conf context collector]
(.start (Thread. (fn []
(while (not (Thread/interrupted))
(logging/info
(clojure.string/join ", "
(for [[word count] @counts]
(str word ": " count))))
(reset! counts {})
(Thread/sleep 5000)))))))
...
這段代碼的功能是:在Bolt開始處理消息之前啟動一個線程,每隔5秒鐘將(atom counts)
中的單詞出現次數打印出來,并對其進行清零操作。
這里我們直接使用了Java的Thread類型。讀者可能會覺得好奇,Thread類型的構造函數只接收實現Runnable接口的對象,Clojure的匿名函數直接支持嗎?我們做一個簡單測試:
1
2
3
4
5
user=> (defn greet [name] (println "Hi" name))
user=> (instance? Runnable greet)
true
user=> (instance? Runnable #(+ 1 %))
true
logging
命名空間對應的依賴是[org.clojure/tools.logging "0.2.6"]
,需要將其添加到project.clj
中,它是對log4j組件的包裝。這里之所以沒有使用println
輸出到標準輸出,是為了將該腳本上傳到Storm集群中運行時也能查看到日志輸出。
定義和執行Topology
各個組件已經定義完畢,下面讓我們用它們組成一個Topology:
1
2
3
4
5
6
7
8
9
(defn mk-topology []
(topology
{"sentence" (spout-spec sentence-spout)}
{"split" (bolt-spec {"sentence" :shuffle}
split-bolt
:p 3)
"count" (bolt-spec {"split" ["word"]}
count-bolt
:p 2)}))
topology
同樣是Clojure DSL定義的宏,它接收兩個map作為參數,一個用于定義使用到的Spout,一個則是Bolt。該map的鍵是組件的名稱,該名稱用于確定各組件之間的關系。
spout-spec
和bolt-spec
則定義了組件在Topology中更具體的參數。如”split”使用的是split-bolt
這個組件,它的上游是”sentence”,使用shuffleGrouping來對消息進行分配,:p 3
表示會啟動3個split-bolt
實例。
“count”使用count-bolt
組件,上游是”split”,但聚合方式采用了fieldGrouping,因此列出了執行哈希運算時使用的消息字段(word)。為何要使用fieldGrouping?因為我們會開啟兩個count-bolt
,如果采用shuffleGrouping,那單詞“a”第一次出現的消息會發送給一個count-bolt
,第二次出現會發送給另一個count-bolt
,這樣統計結果就會錯亂。如果指定了:p 1
,即只開啟一個count-bolt
實例,就不會有這樣的問題。
本地模式和Cluster模式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
(ns cia-storm.wordcount
(:import [backtype.storm StormSubmitter LocalCluster])
...
(:gen-class))
(defn run-local! []
(let [cluster (LocalCluster.)]
(.submitTopology cluster
"wordcount" {} (mk-topology))
(Thread/sleep 30000)
(.shutdown cluster)))
(defn submit-topology! [name]
(StormSubmitter/submitTopology
name {TOPOLOGY-WORKERS 3} (mk-topology)))
(defn -main
([]
(run-local!))
([name]
(submit-topology! name)))
我們為WordCount生成一個類,它的main
函數在沒有命令行參數時會以本地模式執行Topology,若傳遞了參數(即指定了腳本在Cluster運行時的名稱),則提交至Cluster。
這里直接使用了Storm的Java類,對參數有疑惑的可以參考Javadoc。TOPOLOGY-WORKERS
是在backtype.storm.config
命名空間中定義的,我們在前面的代碼中:use
過了。Storm這個項目是用Java和Clojure混寫的,所以查閱代碼時還需仔細一些。
運行結果
首先我們直接用lein
以本地模式運行該Topology:
1
2
3
4
5
$ lein run -m cia-storm.wordcount
6996 [Thread-18] INFO cia-storm.wordcount - doctor: 17, the: 31, a: 29, an: 17, ago: 13, seven: 13, and: 13
6998 [Thread-21] INFO cia-storm.wordcount - four: 13, keeps: 17, away: 17, score: 13, petted: 7, brown: 12, little: 12, years: 13, man: 7, apple: 17, dog: 19, day: 17
11997 [Thread-18] INFO cia-storm.wordcount - ago: 6, seven: 6, and: 6, doctor: 7, an: 7, the: 39, a: 28
11998 [Thread-21] INFO cia-storm.wordcount - four: 6, keeps: 7, away: 7, score: 6, petted: 16, brown: 21, little: 21, years: 6, man: 16, apple: 7, dog: 37, day: 7
Cluster模式需要搭建本地集群,可以參考這篇文檔。下文使用的storm
命令則需要配置~/.storm/storm.yaml
文件,具體請參考這篇文章。
1
2
3
4
5
6
7
8
9
$ lein do clean, compile, uberjar
$ storm jar target/cia-storm-0.1.0-SNAPSHOT-standalone.jar cia_storm.wordcount wordcount
$ cd /path/to/storm/logs
$ tail worker-6700.log
2013-05-11 21:26:15 wordcount [INFO] four: 9, keeps: 15, away: 15, score: 9, petted: 16, brown: 9, little: 9, years: 9, man: 16, apple: 15, dog: 25, day: 15
2013-05-11 21:26:20 wordcount [INFO] four: 10, keeps: 9, away: 9, score: 10, petted: 18, brown: 13, little: 13, years: 10, man: 18, apple: 9, dog: 31, day: 9
$ tail worker-6701.log
2013-05-11 21:27:10 wordcount [INFO] ago: 12, seven: 12, and: 12, doctor: 11, a: 31, an: 11, the: 25
2013-05-11 21:27:15 wordcount [INFO] ago: 14, seven: 14, and: 14, doctor: 11, the: 43, a: 19, an: 11
小結
這一章我們簡單介紹了Storm的設計初衷,它是如何通過分布式并行運算解決實時數據分析問題的。Storm目前已經十分穩定,且仍處于活躍的開發狀態。它的一些高級特性如DRPC、Trident等,還請感興趣的讀者自行研究。
本文使用的WordCount示例代碼:https://github.com/jizhang/cia-storm。