Flume+Kafka+Storm+Redis實時分析系統基本架構


今天作者要在這裏通過一個簡單的電商網站訂單實時分析系統和大家一起梳理一下大數據環境下的實時分析系統的架構模型。當然這個架構模型只是實時分析技術的一 個簡單的入門級架構,實際生產環境中的大數據實時分析技術還涉及到很多細節的處理, 比如使用Storm的ACK機制保證數據都能被正確處理, 集羣的高可用架構, 消費數據時如何處理重複數據或者丟失數據等問題,根據不同的業務場景,對數據的可靠性要求以及系統的複雜度的要求也會不同。這篇文章的目的只是帶大家入個門,讓大家對實時分析技術有一個簡單的認識,並和大家一起做學習交流。
文章的最後還有Troubleshooting,分享了作者在部署本文示例程序過程中所遇到的各種問題和解決方案。

系統基本架構



整個實時分析系統的架構就是先由電商系統的訂單服務器產生訂單日誌, 然後使用Flume去監聽訂單日誌,並實時把每一條日誌信息抓取下來並存進Kafka消息系統中, 接着由Storm系統消費Kafka中的消息,同時消費記錄由Zookeeper集羣管理,這樣即使Kafka宕機重啓後也能找到上次的消費記錄,接着從上次宕機點繼續從Kafka的Broker中進行消費。但是由於存在先消費後記錄日誌或者先記錄後消費的非原子操作,如果出現剛好消費完一條消息並還沒將信息記錄到Zookeeper的時候就宕機的類似問題,或多或少都會存在少量數據丟失或重複消費的問題, 其中一個解決方案就是Kafka的Broker和Zookeeper都部署在同一臺機子上。接下來就是使用用戶定義好的Storm Topology去進行日誌信息的分析並輸出到Redis緩存數據庫中(也可以進行持久化),最後用Web APP去讀取Redis中分析後的訂單信息並展示給用戶。之所以在Flume和Storm中間加入一層Kafka消息系統,就是因爲在高併發的條件下, 訂單日誌的數據會井噴式增長,如果Storm的消費速度(Storm的實時計算能力那是最快之一,但是也有例外, 而且據說現在Twitter的開源實時計算框架Heron比Storm還要快)慢於日誌的產生速度,加上Flume自身的侷限性,必然會導致大量數據滯後並丟失,所以加了Kafka消息系統作爲數據緩衝區,而且Kafka是基於log File的消息系統,也就是說消息能夠持久化在硬盤中,再加上其充分利用Linux的I/O特性,提供了可觀的吞吐量。架構中使用Redis作爲數據庫也是因爲在實時的環境下,Redis具有很高的讀寫速度。

業務背景
各大電商網站在合適的時間進行各種促銷活動已是常態,在能爲網站帶來大量的流量和訂單的同時,對於用戶也有不小的讓利,必然是大家夥兒喜聞樂見的。在促銷活動期間,老闆和運營希望能實時看到訂單情況,老闆開心,運營也能根據實時的訂單數據調整運營策略,而讓用戶能實時看到網站的訂單數據,也會勾起用戶的購買慾。但是普通的離線計算系統已然不能滿足在高併發環境下的實時計算要求,所以我們得使用專門實時計算系統,如:Storm, Heron, Spark Stream等,去滿足類似的需求。
既然要分析訂單數據,那必然在訂單產生的時候要把訂單信息記錄在日誌文件中。本文中,作者通過使用log4j2,以及結合自己之前開發電商系統的經驗,寫了一個訂單日誌生成模擬器,代碼如下,能幫助大家隨機產生訂單日誌。下面所展示的訂單日誌文件格式和數據就是我們本文中的分析目標,本文的案例中用來分析所有商家的訂單總銷售額並找出銷售額錢20名的商家。

訂單數據格式:
orderNumber: XX | orderDate: XX | paymentNumber: XX | paymentDate: XX | merchantName: XX | sku: [ skuName: XX skuNum: XX skuCode: XX skuPrice: XX totalSkuPrice: XX;skuName: XX skuNum: XX skuCode: XX skuPrice: XX totalSkuPrice: XX;] | price: [ totalPrice: XX discount: XX paymentPrice: XX ]



訂單日誌生成程序:
使用log4j2將日誌信息寫入文件中,每小時滾動一次日誌文件
<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="INFO">
      <Appenders>
        <Console name="myConsole" target="SYSTEM_OUT">
          <PatternLayout pattern="%d{yyyy-MM-dd HH:mm:ss} [%t] %-5level %logger{36} - %msg%n"/>
        </Console>
    	<RollingFile name="myFile" fileName="/Users/guludada/Desktop/logs/app.log"
          filePattern="/Users/guludada/Desktop/logs/app-%d{yyyy-MM-dd-HH}.log">
        	<PatternLayout pattern="%d{yyyy-MM-dd HH:mm:ss} [%t] %-5level %logger{36} - %msg%n"/>
       		<Policies>
        		<TimeBasedTriggeringPolicy />
      		</Policies>
        </RollingFile>          		
      </Appenders>
      <Loggers>
        <Root level="Info">
          <AppenderRef ref="myConsole"/>
          <AppenderRef ref="myFile"/>
        </Root>
      </Loggers>
</Configuration>
生成器代碼:
package com.guludada.ordersInfo;

import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Random;

// Import log4j classes.
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;



public class ordersInfoGenerator {
	
	public enum paymentWays {
		Wechat,Alipay,Paypal
	}
	public enum merchantNames {
		優衣庫,天貓,淘寶,咕嚕大大,快樂寶貝,守望先峯,哈毒婦,Storm,Oracle,Java,CSDN,跑男,路易斯威登,
		暴雪公司,Apple,Sumsam,Nissan,Benz,BMW,Maserati
	}
	
	public enum productNames {
		黑色連衣裙, 灰色連衣裙, 棕色襯衫, 性感牛仔褲, 圓腳牛仔褲,塑身牛仔褲, 朋克衛衣,高腰闊腿休閒褲,人字拖鞋,
		沙灘拖鞋
	}
	
	float[] skuPriceGroup = {299,399,699,899,1000,2000};
	float[] discountGroup = {10,20,50,100};
	float totalPrice = 0;
	float discount = 0;
	float paymentPrice = 0;
	
	private static final Logger logger = LogManager.getLogger(ordersInfoGenerator.class);
	private int logsNumber = 1000;
	
	public void generate() {
				
		for(int i = 0; i <= logsNumber; i++) {			
			logger.info(randomOrderInfo());			
		}
	}
	
	public String randomOrderInfo() {
		
		SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");		
		Date date = new Date();		
		
		String orderNumber = randomNumbers(5) + date.getTime();
		
		String orderDate = sdf.format(date);
		
		String paymentNumber = randomPaymentWays() + "-" + randomNumbers(8);
		
		String paymentDate = sdf.format(date);
		
		String merchantName = randomMerchantNames();
		
		String skuInfo = randomSkus();
		
		String priceInfo = calculateOrderPrice();
		
		return "orderNumber: " + orderNumber + " | orderDate: " + orderDate + " | paymentNumber: " +
			paymentNumber + " | paymentDate: " + paymentDate + " | merchantName: " + merchantName + 
			" | sku: " + skuInfo + " | price: " + priceInfo;
	}
		
	private String randomPaymentWays() {
		
		paymentWays[] paymentWayGroup = paymentWays.values();
		Random random = new Random();
		return paymentWayGroup[random.nextInt(paymentWayGroup.length)].name();
	}
	
	private String randomMerchantNames() {
		
		merchantNames[] merchantNameGroup = merchantNames.values();
		Random random = new Random();
		return merchantNameGroup[random.nextInt(merchantNameGroup.length)].name();
	}
	
	private String randomProductNames() {
		
		productNames[] productNameGroup = productNames.values();
		Random random = new Random();
		return productNameGroup[random.nextInt(productNameGroup.length)].name();
	}
	
	
	private String randomSkus() {
		
		Random random = new Random();
		int skuCategoryNum = random.nextInt(3);
		
		String skuInfo ="[";
		
		totalPrice = 0;
		for(int i = 1; i <= 3; i++) {
			
			int skuNum = random.nextInt(3)+1;
			float skuPrice = skuPriceGroup[random.nextInt(skuPriceGroup.length)];
			float totalSkuPrice = skuPrice * skuNum;			
			String skuName = randomProductNames();
			String skuCode = randomCharactersAndNumbers(10);
			skuInfo += " skuName: " + skuName + " skuNum: " + skuNum + " skuCode: " + skuCode
					+ " skuPrice: " + skuPrice + " totalSkuPrice: " + totalSkuPrice + ";";		
			totalPrice += totalSkuPrice;
		}
		
		
		skuInfo += " ]";
		
		return skuInfo;
	}
	
	private String calculateOrderPrice() {
		
		Random random = new Random();
		discount = discountGroup[random.nextInt(discountGroup.length)];
		paymentPrice = totalPrice - discount;
		
		String priceInfo = "[ totalPrice: " + totalPrice + " discount: " + discount + " paymentPrice: " + paymentPrice +" ]";
		
		return priceInfo;
	}
	
	private String randomCharactersAndNumbers(int length) {
		
		String characters = "abcdefghijklmnopqrstuvwxyz0123456789";
		String randomCharacters = "";  
                Random random = new Random();  
                for (int i = 0; i < length; i++) {  
        	  randomCharacters += characters.charAt(random.nextInt(characters.length()));  
                }  
                return randomCharacters;  
	}
	
	private String randomNumbers(int length) {
		
		String characters = "0123456789";
		String randomNumbers = "";   
                Random random = new Random();  
                for (int i = 0; i < length; i++) {  
        	 randomNumbers += characters.charAt(random.nextInt(characters.length()));  
                }  
               return randomNumbers;		
	}
	
	public static void main(String[] args) {
		
		ordersInfoGenerator generator = new ordersInfoGenerator();
		generator.generate();
	}
}

收集日誌數據
採集數據的方式有多種,一種是通過自己編寫shell腳本或Java編程採集數據,但是工作量大,不方便維護,另一種就是直接使用第三方框架去進行日誌的採集,一般第三方框架的健壯性,容錯性和易用性都做得很好也易於維護。本文采用第三方框架Flume進行日誌採集,Flume是一個分佈式的高效的日誌採集系統,它能把分佈在不同服務器上的海量日誌文件數據統一收集到一個集中的存儲資源中,FlumeApache的一個頂級項目,與Kafka也有很好的兼容性。不過需要注意的是Flume並不是一個高可用的框架,這方面的優化得用戶自己去維護。

Flume
的agent是運行在JVM上的,所以各個服務器上的JVM環境必不可少。每一個Flume agent部署在一臺服務器上,Flume會收集web server產生的日誌數據,並封裝成一個個的事件發送給Flume Agent的Source,Flume Agent Source會消費這些收集來的數據事件(Flume Event)並放在Flume Agent Channel,Flume Agent Sink會從Channel中收集這些採集過來的數據,要麼存儲在本地的文件系統中要麼作爲一個消費資源分給下一個裝在分佈式系統中其它服務器上的Flume Agent進行處理。Flume提供了點對點的高可用的保障,某個服務器上的Flume Agent Channel中的數據只有確保傳輸到了另一個服務器上的Flume Agent Channel裏或者正確保存到了本地的文件存儲系統中,纔會被移除。

在本文中,Flume的Source我們選擇的是Exec Source,因爲是實時系統,直接通過tail 命令來監聽日誌文件,而在Kafka的Broker集羣端的Flume我們選擇Kafka Sink 來把數據下沉到Kafka消息系統中。

下圖是來自Flume官網裏的Flume拉取數據的架構圖:

    圖片來源:http://flume.apache.org/FlumeUserGuide.html

訂單日誌產生端的Flume配置文件如下:
agent.sources = origin
agent.channels = memorychannel
agent.sinks = target

agent.sources.origin.type = exec
agent.sources.origin.command = tail -F /export/data/trivial/app.log
agent.sources.origin.channels = memorychannel

agent.sources.origin.interceptors = i1
agent.sources.origin.interceptors.i1.type = static
agent.sources.origin.interceptors.i1.key = topic
agent.sources.origin.interceptors.i1.value = ordersInfo

agent.sinks.loggerSink.type = logger
agent.sinks.loggerSink.channel = memorychannel

agent.channels.memorychannel.type = memory
agent.channels.memorychannel.capacity = 10000

agent.sinks.target.type = avro
agent.sinks.target.channel = memorychannel
agent.sinks.target.hostname = 172.16.124.130
agent.sinks.target.port = 4545

Kafka消息系統端Flume配置文件
agent.sources = origin
agent.channels = memorychannel
agent.sinks = target

agent.sources.origin.type = avro
agent.sources.origin.channels = memorychannel
agent.sources.origin.bind = 0.0.0.0
agent.sources.origin.port = 4545

agent.sinks.loggerSink.type = logger
agent.sinks.loggerSink.channel = memorychannel

agent.channels.memorychannel.type = memory
agent.channels.memorychannel.capacity = 5000000
agent.channels.memorychannel.transactionCapacity = 1000000

agent.sinks.target.type = org.apache.flume.sink.kafka.KafkaSink
#agent.sinks.target.topic = bigdata
agent.sinks.target.brokerList=localhost:9092
agent.sinks.target.requiredAcks=1
agent.sinks.target.batchSize=100
agent.sinks.target.channel = memorychannel

這裏需要注意的是,在日誌服務器端的Flume agent中我們配置了一個interceptors,這個是用來爲Flume Event(Flume Event就是拉取到的一行行的日誌信息)的頭部添加key爲“topic”的K-V鍵值對,這樣這條抓取到的日誌信息就會根據topic的值去到Kafka中指定的topic消息池中,當然還可以爲Flume Event額外配置一個key爲“Key”的鍵值對,Kafka Sink會根據key“Key”的值將這條日誌信息下沉到不同的Kafka分片上,否則就是隨機分配。在Kafka集羣端的Flume配置裏,有幾個重要的參數需要注意,“topic”是指定抓取到的日誌信息下沉到Kafka哪一個topic池中,如果之前Flume發送端爲Flume Event添加了帶有topic的頭信息,則這裏可以不用配置;brokerList就是配置Kafka集羣的主機地址和端口;requireAcks=1是配置當下沉到Kafka的消息儲存到特定partition的leader中成功後就返回確認消息,requireAcks=0是不需要確認消息成功寫入Kafka中,requireAcks=-1是指不光需要確認消息被寫入partition的leander中,還要確認完成該條消息的所有備份;batchSize配置每次下沉多少條消息,每次下沉的數量越多延遲也高。

Kafka消息系統
這一部分我們將談談Kafka的配置和使用,Kafka在我們的系統中實際上就相當於起到一個數據緩衝池的作用, 有點類似於ActiveQ的消息隊列和Redis這樣的緩存區的作用,但是更可靠,因爲是基於log File的消息系統,數據不容易丟失,以及能記錄數據的消費位置並且用戶還可以自定義消息消費的起始位置,這就使得重複消費消息也可以得以實現,而且同時具有隊列和發佈訂閱兩種消息消費模式,十分靈活,並且與Storm的契合度很高,充分利用Linux系統的I/O提高讀寫速度等等。另一個要提的方面就是Kafka的Consumer是pull-based模型的,而Flume是push-based模型。push-based模型是儘可能大的消費數據,但是當生產者速度大於消費者時數據會被覆蓋。而pull-based模型可以緩解這個壓力,消費速度可以慢於生產速度,有空餘時再拉取那些沒拉取到的數據。

Kafka是一個分佈式的高吞吐量的消息系統,同時兼有點對點和發佈訂閱兩種消息消費模式Kafka主要由Producer,Consumer和Broker組成。Kafka中引入了一個叫“topic”的概念,用來管理不同種類的消息,不同類別的消息會記錄在到其對應的topic池中,而這些進入到topic中的消息會被Kafka寫入磁盤的log文件中進行持久化處理。Kafka會把消息寫入磁盤的log file中進行持久化
對於每一個topic裏的消息log文件,Kafka都會對其進行分片處理,而每一個消息都會順序寫入中log分片中,並且被標上“offset”的標量來代表這條消息在這個分片中的順序,並且這些寫入的消息無論是內容還是順序都是不可變的。所以Kafka和其它消息隊列系統的一個區別就是它能做到分片中的消息是能順序被消費的,但是要做到全局有序還是有侷限性的,除非整個topic只有一個log分片。並且無論消息是否有被消費,這條消息會一直保存在log文件中,當留存時間足夠長到配置文件中指定的retention的時間後,這條消息纔會被刪除以釋放空間。對於每一個Kafka的Consumer,它們唯一要存的Kafka相關的元數據就是這個“offset”值,記錄着Consumer在分片上消費到了哪一個位置。通常Kafka是使用Zookeeper來爲每一個Consumer保存它們的offset信息,所以在啓動Kafka之前需要有一個Zookeeper集羣;而且Kafka默認採用的是先記錄offset再讀取數據的策略,這種策略會存在少量數據丟失的可能。不過用戶可以靈活設置Consumer的“offset”的位置,在加上消息記錄在log文件中,所以是可以重複消費消息的。log的分片和它們的備份分散保存在集羣的服務器上,對於每一個partition,在集羣上都會有一臺這個partition存在服務器作爲leader,而這個partitionpartition的其它備份所在的服務器做爲follower,leader負責處理關於這個partition所有請求,而follower負責這個partition的其它備份的同步工作,當leader服務器宕機時,其中一個follower服務器就會被選舉爲新的leader。

一般的消息系統分爲兩種模式,一種是點對點的消費模式,也就是queuing模式,另一種是發佈訂閱模式,也就是publish-subscribe模式,而Kafka引入了一個Consumer Group的概念,使得其能兼有兩種模式。在Kafka中,每一個consumer都會標明自己屬於哪個consumer group,每個topic的消息都會分發給每一個subscribe了這個topic的所有consumer group中的一個consumer實例。所以當所有的consumers都在同一個consumer group中,那麼就像queuing的消息系統,一個message一次只被一個consumer消費。如果每一個consumer都有不同consumer group,那麼就像public-subscribe消息系統一樣,一個消息分發給所有的consumer實例。對於普通的消息隊列系統,可能存在多個consumer去同時消費message,雖然message是有序地分發出去的,但是由於網絡延遲的時候到達不同的consumer的時間不是順序的,這時就失去了順序性,解決方案是隻用一個consumer去消費message,但顯然不太合適。而對於Kafka來說,一個partiton只分發給每一個consumer group中的一個consumer實例,也就是說這個partition只有一個consumer實例在消費,所以可以保證在一個partition內部數據的處理是有序的,不同之處就在於Kafka內部消息進行了分片處理,雖然看上去也是單consumer的做法,但是分片機制保證了併發消費。如果要做到全局有序,那麼整個topic中的消息只有一個分片,並且每一個consumer group中只能有一個consumer實例。這實際上就是徹底犧牲了消息消費時的併發度。

Kafka的配置和部署十分簡單
1. 首先啓動Zookeeper集羣,Kafka需要Zookeeper集羣來幫助記錄每一個Consumer的offset
2. 爲集羣上的每一臺Kafka服務器單獨配置配置文件,比如我們需要設置有兩個節點的Kafka集羣,那麼節點1和節點2的最基本的配置如下:
config/server-1.properties:
    broker.id=1
    listeners=PLAINTEXT://:9093
    log.dir=export/data/kafka
    zookeeper.connect=localhost:2181
config/server-2.properties:
    broker.id=2
    listeners=PLAINTEXT://:9093
    log.dir=/export/data/kafka
    zookeeper.connect=localhost:2181
broker.id是kafka集羣上每一個節點的單獨標識,不能重複;listeners可以理解爲每一個節點上Kafka進程要監聽的端口,使用默認的就行; log.dir是Kafka的log文件(記錄消息的log file)存放目錄; zookeeper.connect就是Zookeeper的URI地址和端口。
3. 配置完上面的配置文件後,只要分別在節點上
輸入下面命令啓動Kafka進程就可以使用了
> bin/kafka-server-start.sh config/server-1.properties &
...
> bin/kafka-server-start.sh config/server-2.properties &
...

Storm實時計算框架
接下來開始介紹本篇文章要使用的實時計算框架StormStrom是一個非常快的實時計算框架,至於快到什麼程度呢?官網首頁給出的數據是每一個Storm集羣上的節點每一秒能處理一百萬條數據。相比Hadoop“Mapreduce”計算框架,Storm使用的是"Topology"Mapreduce程序在計算完成後最終會停下來,而Topology則是會永遠運行下去除非你顯式地使用“kill -9 XXX”命令停掉它。和大多數的集羣系統一樣,Storm集羣也存在着Master節點和Worker節點,在Master節點上運行的一個守護進程叫“Nimbus”,類似於Hadoop“JobTracker”的功能,負責集羣中計算程序的分發,任務的分發,監控任務和工作節點的運行情況等;Worker節點上運行的守護進程叫“Supervisor”,負責接收Nimbus分發的任務並運行,每一個Worker上都會運行着Topology程序的一部分,而一個Topology程序的運行就是由集羣上多個Worker一起協同工作的。值得注意的是NimubsSupervisor之間的協調工作也是通過Zookeeper來管理的,NimbusSupervisor自己本身在集羣上都是無狀態的,它們的狀態都保存在Zookeeper上,所以任何節點的宕機和動態擴容都不會影響整個集羣的工作運行,並支持fast-fail機制。

Storm
有一個很重要的對數據的抽象概念,叫做“Stream”,我們姑且稱之爲數據流,數據流Stream就是由之間沒有任何關係的鬆散的一個一個的數據元組“tuples”所組成的序列。要在Storm上做實時計算,首先你得有一個計算程序,這就是“Topology”,一個Topology程序由“Spout”“Bolt”共同組成。Storm就是通過Topology程序將數據流Stream通過可靠(ACK機制)的分佈式計算生成我們的目標數據流Stream,就比如說把婚戀網站上當日註冊的所有用戶信息數據流Stream通過Topology程序計算出月收入上萬年齡在30歲以下的新的用戶信息流Stream。在我們的文章中,Spout就是實現了特定接口的Java類,它相當於數據源,用於產生數據或者從外部接收數據;而Bolt就是實現了Storm Bolt接口的Java類,用於消費從Spout發送出來的數據流並實現用戶自定義的數據處理邏輯;對於複雜的數據處理,可以定義多個連續的Bolt去協同處理。最後在程序中通過SpoutBolt生成Topology對象並提交到Storm集羣上執行。

tuplesStorm的數據模型,,由值和其所對應的field所組成,比如說在SpoutBolt中定義了發出的元組的field爲:(name,age,gender),那麼從這個SpoutBolt中發出的數據流的每一個元組值就類似於(''咕嚕大大",27,"中性")Storm中還有一個Stream Group的概念,它用來決定從Spout或或或Bolt組件中發出的tuples接下來應該傳到哪一個組件中或者更準確地說在程序裏設置某個組件應該接收來自哪一個組件的tuples; 並且在Storm中提供了多個用於數據流分組的機制,比如說shuffleGrouping,用來將當前組件產生的tuples隨機分發到下一個組件中,或者 fieldsGrouping,根據tuplesfield值來決定當前組件產生的tuples應該分發到哪一個組件中。

另一部分需要了解的就是Stormtasksworkers的概念。每一個worker都是一個運行在物理機器上的JVM進程,每個worker中又運行着多個task線程,這些task線程可能是Spout任務也可能是Bolt任務,由Nimbus根據RoundRobin負載均衡策略來分配,而至於在整個Topology程序裏要起幾個Spout線程或Bolt線程,也就是tasks,由用戶在程序中設置併發度來決定。


Storm集羣的配置文件如下:
Storm的配置文件在項目的conf目錄下,也就是:conf/storm.yaml
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements.  See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership.  The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License.  You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

########### These MUST be filled in for a storm configuration
storm.zookeeper.servers:
  - "ymhHadoop"
  - "ymhHadoop2"
  - "ymhHadoop3"    

storm.local.dir: "/export/data/storm/workdir"
 
nimbus.host: "ymhHadoop"

supervisor.slots.ports:
  -6700
  -6701
  -6702
  -6703 
 
storm.zookeeper.servers自然就是用來配置我們熟悉的Zookeeper集羣中各個節點的URI地址和端口的
storm.local.dir
是用來配置storm節點相關文件的存儲目錄的,每一個storm集羣的節點在本地服務器上都要有一個目錄存儲少量的和該節點有關的一些信息。記得要開發這個目錄的讀寫權限哦
nimbus.host
自然就是用來指定nimbus服務器的URI
supervisor.slots.ports
這個是用來配置supervisor服務器啓動的worker所監聽的端口,每一個worker就是一個物理的JVM進程。

上面這些是基本配置,並且要嚴格按照上面的格式來,少一個空格都會報錯。
接下來就是將配置文件拷貝到集羣的各個機器上,然後在分別在nimbussupervisor機器上通過$bin/storm nimbus $bin/storm supervisor命令來啓動集羣上的機子。最後在nimbus上通過$bin/storm UI 命令可以啓動Storm提供的UI界面,功能十分強大,可以監控集羣上各個節點的運行狀態,提交Topology任務,監控Topology任務的運行情況等。這個UI界面可以通過http://{nimbus host}:8080的地址訪問到。


Redis數據庫
Redis是一個基於內存的多種數據結構的存儲工具,經常有人說Redis是一個基於key-value數據結構的緩存數據庫,這種說法必然是不準確的,Key-Value只是其中的一種數據結構的實現,Redis支持Stringshasheslistssetssorted sets等多種常見的數據結構,並提供了功能強大的範圍查詢,以及提供了INCRINCRBY,DECR,DECRBY等多種原子命令操作,保證在併發的環境下不會出現髒數據。雖然Redis是基於內存的數據庫,但也提供了多種硬盤持久化策略,比如說RDB策略,用來將某個時間點的Redis的數據快照存儲在硬盤中,或者是AOF策略,將每一個Redis操作命令都不可變的順序記錄在log文件中,恢復數據時就將log文件中的所有命令順序執行一遍等等。Redis不光可以作爲網站熱點數據的緩存服務器,還可以用來做數據庫,或者消息隊列服務器的broker等。在本文中選擇Redis作爲訂單分析結果的存儲工具,一方面是其靈活的數據結構和強大的數據操作命令,另一方面就是在大數據的實時計算環境下,需要Redis這樣的具備高速I/O的數據庫。

在本文的例子中,作者使用Sorted Sets數據結構來存儲各個商家的總訂單銷售額,Sorted Sets數據結構由Key, Scoreelement value 三部分組成,Set的數據結構保證同一個key中的元素值不會重複,而在Sorted Sets結構中是通過 Score來爲元素值排序,這很自然地就能將各個商家的總訂單銷售額設置爲Score,然後商家名稱爲element value,這樣就能根據總訂單銷售額來爲商家排序。在Storm程序中,我們通過Jedis API來調用Redis
$ZINCRBY KEY INCREMENT MEMBER
的命令來統計商家總銷售額, ZINCRBY是一個原子命令,能保證在Storm的併發計算的環境下,正確地增加某個商家的Score的值,也就是它們的訂單總銷售額。而對於兩個商家同名這種情況應該在業務系統中去避免而不應該由我們的數據分析層來處理。最後提一個小trips,就是如果所有商家的Score都設置成相同的分數,那麼Redis就會默認使用商家名的字母字典序來排序。


Kafka+Storm+Redis的整合
當數據被Flume拉取進Kafka消息系統中,我們就可以使用Storm來進行消費,Redis來對結果進行存儲。StormKafka有很好的兼容性,我們可以通過Kafka Spout來從Kafka中獲取數據;在Bolt處理完數據後,通過Jedis API在程序中將數據存儲在Redis數據庫中。

下面就是Kafka Spout和創建Topology的程序代碼:

BrokerHosts hosts = new ZkHosts("ymhHadoop:2181,ymhHadoop2:2181,ymhHadoop3:2181");
zkHosts
是用來指定Zookeeper集羣的節點的URI和端口,而Zookeeper集羣是用來記錄SpoutKafka消息消費的offset位置

spoutConfig.scheme = new SchemeAsMultiScheme(new StringScheme());
主要是用來將SpoutKafka拉取來的byte[]數組格式的數據轉化爲Stormtuples

package com.guludada.ordersanalysis;

import java.util.UUID;

import backtype.storm.Config;
import backtype.storm.LocalCluster;
import backtype.storm.StormSubmitter;
import backtype.storm.generated.AlreadyAliveException;
import backtype.storm.generated.InvalidTopologyException;
import backtype.storm.spout.SchemeAsMultiScheme;
import backtype.storm.topology.TopologyBuilder;
import backtype.storm.tuple.Fields;
import storm.kafka.Broker;
import storm.kafka.BrokerHosts;
import storm.kafka.KafkaSpout;
import storm.kafka.SpoutConfig;
import storm.kafka.StaticHosts;
import storm.kafka.StringScheme;
import storm.kafka.ZkHosts;
import storm.kafka.trident.GlobalPartitionInformation;

public class ordersAnalysisTopology {
	
	private static String topicName = "ordersInfo";
	private static String zkRoot = "/stormKafka/"+topicName;
	
	public static void main(String[] args) {
		
		BrokerHosts hosts = new ZkHosts("ymhHadoop:2181,ymhHadoop2:2181,ymhHadoop3:2181");

		
		SpoutConfig spoutConfig = new SpoutConfig(hosts,topicName,zkRoot,UUID.randomUUID().toString());
		spoutConfig.scheme = new SchemeAsMultiScheme(new StringScheme());
		KafkaSpout kafkaSpout = new KafkaSpout(spoutConfig);
		
		TopologyBuilder builder = new TopologyBuilder();        
		builder.setSpout("kafkaSpout",kafkaSpout);        
		builder.setBolt("merchantsSalesBolt", new merchantsSalesAnalysisBolt(), 2).shuffleGrouping("kafkaSpout");

		Config conf = new Config();
		conf.setDebug(true);
		
		if(args != null && args.length > 0) {
			conf.setNumWorkers(1);
			try {
				StormSubmitter.submitTopologyWithProgressBar(args[0], conf, builder.createTopology());
			} catch (AlreadyAliveException e) {
				// TODO Auto-generated catch block
				e.printStackTrace();
			} catch (InvalidTopologyException e) {
				// TODO Auto-generated catch block
				e.printStackTrace();
			}
			
		} else {
			
			conf.setMaxSpoutPending(3);
			
			LocalCluster cluster = new LocalCluster();
			cluster.submitTopology("ordersAnalysis", conf, builder.createTopology());
			
			
		}

	}
}

下面是Bolt程序,主要是用來處理從Kafka拉取到的訂單日誌信息, 並計算出所有商家的總訂單收入,然後使用Jedis API將計算結果存入到Redis數據庫中。

package com.guludada.domain;

import java.util.ArrayList;
import java.util.Date;

public class ordersBean {

	Date createTime = null;
	String number = "";
	String paymentNumber = "";
	Date paymentDate = null;
	String merchantName = "";
	ArrayList<skusBean> skuGroup = null;
	float totalPrice = 0;
	float discount = 0;
	float paymentPrice = 0;
	
	public Date getCreateTime() {
		return createTime;
	}
	public void setCreateTime(Date createTime) {
		this.createTime = createTime;
	}
	public String getNumber() {
		return number;
	}
	public void setNumber(String number) {
		this.number = number;
	}
	public String getPaymentNumber() {
		return paymentNumber;
	}
	public void setPaymentNumber(String paymentNumber) {
		this.paymentNumber = paymentNumber;
	}
	public Date getPaymentDate() {
		return paymentDate;
	}
	public void setPaymentDate(Date paymentDate) {
		this.paymentDate = paymentDate;
	}
	public String getMerchantName() {
		return merchantName;
	}
	public void setMerchantName(String merchantName) {
		this.merchantName = merchantName;
	}
	public ArrayList<skusBean> getSkuGroup() {
		return skuGroup;
	}
	public void setSkuGroup(ArrayList<skusBean> skuGroup) {
		this.skuGroup = skuGroup;
	}
	public float getTotalPrice() {
		return totalPrice;
	}
	public void setTotalPrice(float totalPrice) {
		this.totalPrice = totalPrice;
	}
	public float getDiscount() {
		return discount;
	}
	public void setDiscount(float discount) {
		this.discount = discount;
	}
	public float getPaymentPrice() {
		return paymentPrice;
	}
	public void setPaymentPrice(float paymentPrice) {
		this.paymentPrice = paymentPrice;
	}
	
	
}
本文例子中用不到skusbean,所以這裏作者就沒有寫委屈偷懶一下下
package com.guludada.domain;

public class skusBean {
      ………………
}

logInfoHandler用來過濾訂單的日誌信息,並保存到ordersBean和skusBean中,方便Bolt獲取日誌數據的各項屬性進行處理
package com.guludada.common;

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import com.guludada.domain.ordersBean;

public class logInfoHandler {
	
	SimpleDateFormat sdf_final = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
	
	public ordersBean getOrdersBean(String orderInfo) {
		
		ordersBean order = new ordersBean();
		
		//從日誌信息中過濾出訂單信息
		Pattern orderPattern = Pattern.compile("orderNumber:.+");
		Matcher orderMatcher = orderPattern.matcher(orderInfo);
		if(orderMatcher.find()) {
			
			String orderInfoStr = orderMatcher.group(0);
			String[] orderInfoGroup = orderInfoStr.trim().split("\\|");
			
			//獲取訂單號
			String orderNum = (orderInfoGroup[0].split(":"))[1].trim();
			order.setNumber(orderNum);
						
			//獲取創建時間
			String orderCreateTime = orderInfoGroup[1].trim().split(" ")[1] + " " + orderInfoGroup[1].trim().split(" ")[2];
			try {
				order.setCreateTime(sdf_final.parse(orderCreateTime));
			} catch (ParseException e) {
				// TODO Auto-generated catch block
				e.printStackTrace();
			}
			
			//獲取商家名稱
			String merchantName = (orderInfoGroup[4].split(":"))[1].trim();
			order.setMerchantName(merchantName);
			
			//獲取訂單總額
			String orderPriceInfo = (orderInfoGroup[6].split("price:"))[1].trim();
			String totalPrice = (orderPriceInfo.substring(2, orderPriceInfo.length()-3).trim().split(" "))[1];
			order.setTotalPrice(Float.parseFloat(totalPrice));
						
			return order;
						
		} else {
			return order;
		}
	}
}

package com.guludada.ordersanalysis;

import java.util.Map;

import com.guludada.common.logInfoHandler;
import com.guludada.domain.ordersBean;

import backtype.storm.task.OutputCollector;
import backtype.storm.task.TopologyContext;
import backtype.storm.topology.OutputFieldsDeclarer;
import backtype.storm.topology.base.BaseRichBolt;
import backtype.storm.tuple.Tuple;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;

public class merchantsSalesAnalysisBolt extends BaseRichBolt {
	
	private OutputCollector _collector;
	logInfoHandler loginfohandler;
	JedisPool pool;

	public void execute(Tuple tuple) {
		String orderInfo = tuple.getString(0);
		ordersBean order = loginfohandler.getOrdersBean(orderInfo);
		
		//store the salesByMerchant infomation into Redis
		Jedis jedis = pool.getResource();
		jedis.zincrby("orderAna:topSalesByMerchant", order.getTotalPrice(), order.getMerchantName());
	}

	public void prepare(Map arg0, TopologyContext arg1, OutputCollector collector) {
		this._collector = collector;
		this.loginfohandler = new logInfoHandler();
		this.pool = new JedisPool(new JedisPoolConfig(), "ymhHadoop",6379,2 * 60000,"12345");
		
	}

	public void declareOutputFields(OutputFieldsDeclarer arg0) {
		// TODO Auto-generated method stub
		
	}

}

Topology項目的Maven配置文件
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>com.guludada</groupId>
  <artifactId>Storm_OrdersAnalysis</artifactId>
  <packaging>war</packaging>
  <version>0.0.1-SNAPSHOT</version>
  <name>Storm_OrdersAnalysis Maven Webapp</name>
  <url>http://maven.apache.org</url>
  <dependencies>
    <dependency>
		<groupId>org.apache.storm</groupId>
		<artifactId>storm-core</artifactId>
		<version>0.9.6</version>
		<scope>provided</scope>
	</dependency>
	<dependency>
        <groupId>org.apache.storm</groupId>
        <artifactId>storm-kafka</artifactId>
        <version>0.9.6</version>
    </dependency>
    <dependency>
    	<groupId>org.apache.kafka</groupId>
        <artifactId>kafka_2.10</artifactId>
        <version>0.9.0.1</version>
            <exclusions>
                <exclusion>
                    <groupId>org.apache.zookeeper</groupId>
                    <artifactId>zookeeper</artifactId>
                </exclusion>
                <exclusion>
                    <groupId>log4j</groupId>
                    <artifactId>log4j</artifactId>
                </exclusion>
                <exclusion>
                    <groupId>org.slf4j</groupId>
    				<artifactId>slf4j-log4j12</artifactId>
                </exclusion>
            </exclusions>
    </dependency>
    <dependency>
	    <groupId>redis.clients</groupId>
	    <artifactId>jedis</artifactId>
	    <version>2.8.1</version>
	</dependency>	
  </dependencies>
  <build>
    <finalName>Storm_OrdersAnalysis</finalName>
    <plugins>
		<plugin>
			<artifactId>maven-assembly-plugin</artifactId>
			<configuration>
				<descriptorRefs>  
			    	<descriptorRef>jar-with-dependencies</descriptorRef>
			    </descriptorRefs>
			    <archive>
			       <manifest>
			         <mainClass>com.guludada.ordersanalysis.ordersAnalysisTopology</mainClass>
			       </manifest>
			     </archive>
			 </configuration>
		  </plugin>
	  </plugins>
  </build>
</project>

maven配置文件中配置了一個官方推薦的maven-assembly-plugin插件,用來幫助用戶方便地打包Topology程序的。只需要進入到項目的根路徑,然後運行
$mvn assembly:assembly
命令就可以打包好Topologyjar包了。

最後我帶大家梳理一下整個項目的部署流程
1. 
啓動Zookeeper
2.
啓動Kafka
3.
啓動Flume將程序拉取到Kafka
4.
啓動Storm集羣
5.
啓動Redis服務端  通過命令
$ src/redis-server
6.
提交打包好的Topology程序到Storm集羣中通過Storm UI 或者命令$storm jar path/to/allmycode.jar org.me.MyTopology arg1 arg2 arg3
7.
啓動RedisCLI客戶端查看結果通過命令
$ src/redis-cli --raw
$  zrange key 0 -1 withscores

如下圖:



Troubleshooting
  1. 在使用maven同時導入storm-core, storm-kaka和kafka的依賴包的時候可能會出現jar包衝突導致無法初始化Log4jLoggerFactory,並無法啓動Storm程序.解決方法也很簡單,按照紅字提示,把多餘的jar包移除就行了,通過在maven的pom文件中kafka的依賴設置部分加入下面的設置<exclusion><groupId>org.slf4j</groupId><artifactId>slf4j-log4j12</artifactId></exclusion>
  2. 第一次執行Storm建立Topology時,作者遇到了一個十分低級的問題,就是發現明明Kafka的topic裏有數據,可是Storm程序怎麼都無法讀取到數據,後來才從下面的文章中明白了問題的所在 http://m.blog.csdn.net/article/details?id=18615761  原因就在於Topology第一次啓動前還沒有在zookeeper中的zkRoot創建offset信息,Storm取不到offset信息就會使用默認的offset,也就是log文件中從最後一個元素開始讀取信息,所以之前在kafka中的數據都無法讀出來。Storm啓動後,再往broker中寫數據,這些後寫的數據就能正確被Storm處理。                                  
  3. 當Storm的topology傳到Nimbus的時候,或者說你的Storm程序剛開始啓動的時候可能會報關於JedisPool是一個無法序列化的對象而導致的錯誤:java.lang.RuntimeException:java.io.NotSerializableException: redis.clients.jedis.JedisPool 解決方案就是將Bolt類中外部的JedisPool初始化代碼放入Bolt的prepare()方法中,如本文的代碼示例所示
  4. 在Storm啓動並開始連接Redis的時候,會報出連接被拒絕,因爲Redis運行在protect mode模式下的錯誤。這是因爲Storm程序是遠程連接Redis的服務器端,如果Redis服務器端沒有設置密碼的話是拒絕遠程連接的。解決方法也十分簡單,關閉protect mode模式(強烈不推薦),或者使用下面命令爲Redis設置密碼就可以了$config set requirepass 123
  5. 向Storm提交Topology以後, Supervisor端會一直報“Kill XXXX No Such process”的錯誤,多數原因是提交的topology沒有正確被執行,而Storm的日記中不會顯示topology程序裏的錯誤。解決方法就是啓動Storm UI, 通過這個Storm自帶的UI界面查看topology的運行情況,並且程序中的錯誤也會在UI界面中顯示出來,能方便地查看topology程序的錯誤。

    6.kafka使用的時候的小問題:
        當在一臺機子上啓動kafka producer客戶端的時候,是無法在同一臺機子上繼續啓動kafka的consumer客戶端的,因爲這兩個進程可能佔用的同一個端口,需要在另外一臺機子上啓動kafka consumer程序,這樣就能看見正確的結果了

最後,感謝所有耐心看完這篇文章的人,樓主也深感自己的技術水平和語言表達還有很多需要提高的地方,希望能和大家一起交流學習共同進步,歡迎大家留下寶貴的意見和評論!還有再最後吐槽一下,CSDN的文章編輯器在我的MAC系統的火狐瀏覽器下十分十分十分十分難用,字體格式等根本不受控制,各種莫名其妙的BUG…………


 
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章