Flume+Spark+Hive+Spark SQL離線分析系統

花和尚也有春天發表於2018-09-18

前段時間把Scala和Spark一起學習了,所以藉此機會在這裡做個總結,順便和大家一起分享一下目前最火的分散式計算技術Spark!當然Spark不光是可以做離線計算,還提供了許多功能強大的元件,比如說,Spark Streaming 元件做實時計算,和Kafka等訊息系統也有很好的相容性;Spark Sql,可以讓使用者通過標準SQL語句操作從不同的資料來源中過來的結構化資料;還提供了種類豐富的MLlib庫方便使用者做機器學習等等。Spark是由Scala語言編寫而成的,Scala是執行在JVM上的面向函式的程式語言,它的學習過程簡直反人類,可讀性就我個人來看,也不是能廣為讓大眾接受的語言,但是它功能強大,熟練後能極大提高開發速度,對於實現同樣的功能,所需要寫的程式碼量比Java少得多得多,這都得益於Scala的語言特性。本文借鑑作者之前寫的另一篇關於Hadoop離線計算的文章,繼續使用那篇文章中點選流分析的案例,只不過MapReduce部分改為由Spark離線計算來完成,同時,你會發現做一模一樣的日誌清洗任務,相比上一篇文章,程式碼總數少了非常非常多,這都是Scala語言的功勞。本篇文章在Flume部分的內容和之前的Hadoop離線分析文章的內容基本一致,Hive部分新加了對Hive資料倉儲的簡單說明,同時還補充了對HDFS的說明和配置,並且新加了大量對Spark框架的詳細介紹,文章的最後一如既往地新增了Troubleshooting段落,和大家分享作者在部署時遇到的各種問題,讀者們可以有選擇性的閱讀。

PS:本文Spark說明部分的最後一段非常重要,作者總結了Spark在叢集環境下不得忽略的一些特性,所有使用Spark的使用者都應該要重點理解。或者讀者們可以直接閱讀官方文件加深理解:http://spark.apache.org/docs/latest/programming-guide.html

Spark離線分析系統架構圖

這裡寫圖片描述
整個離線分析的總體架構就是使用Flume從FTP伺服器上採集日誌檔案,並儲存在Hadoop HDFS檔案系統上,再接著用Spark的RDDs操作函式清洗日誌檔案,最後使用Spark SQL配合HIVE構建資料倉儲做離線分析。任務的排程使用Shell指令碼完成,當然大家也可以嘗試一些自動化的任務排程工具,比如說AZKABAN或者OOZIE等。 
分析所使用的點選流日誌檔案主要來自Nginx的access.log日誌檔案,需要注意的是在這裡並不是用Flume直接去生產環境上拉取nginx的日誌檔案,而是多設定了一層FTP伺服器來緩衝所有的日誌檔案,然後再用Flume監聽FTP伺服器上指定的目錄並拉取目錄裡的日誌檔案到HDFS伺服器上(具體原因下面分析)。從生產環境推送日誌檔案到FTP伺服器的操作可以通過Shell指令碼配合Crontab定時器來實現。

網站點選流資料

 
圖片來源:http://webdataanalysis.net/data-collection-and-preprocessing/weblog-to-clickstream/#comments

一般在WEB系統中,使用者對站點的頁面的訪問瀏覽,點選行為等一系列的資料都會記錄在日誌中,每一條日誌記錄就代表著上圖中的一個資料點;而點選流資料關注的就是所有這些點連起來後的一個完整的網站瀏覽行為記錄,可以認為是一個使用者對網站的瀏覽session。比如說使用者從哪一個外站進入到當前的網站,使用者接下來瀏覽了當前網站的哪些頁面,點選了哪些圖片連結按鈕等一系列的行為記錄,這一個整體的資訊就稱為是該使用者的點選流記錄。這篇文章中設計的離線分析系統就是收集WEB系統中產生的這些資料日誌,並清洗日誌內容儲存分散式的HDFS檔案儲存系統上,接著使用離線分析工具HIVE去統計所有使用者的點選流資訊。 
本系統中我們採用Nginx的access.log來做點選流分析的日誌檔案。access.log日誌檔案的格式如下:

樣例資料格式: 
124.42.13.230 - - [18/Sep/2013:06:57:50 +0000] “GET /shoppingMall?ver=1.2.1 HTTP/1.1” 200 7200 “http://www.baidu.com.cn” “Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 5.1; Trident/4.0; BTRS101170; InfoPath.2; .NET4.0C; .NET4.0E; .NET CLR 2.0.50727)”

格式分析: 
1. 訪客ip地址:124.42.13.230 
2. 訪客使用者資訊: - - 
3. 請求時間:[18/Sep/2013:06:57:50 +0000] 
4. 請求方式:GET 
5. 請求的url:/shoppingMall?ver=1.10.2 
6. 請求所用協議:HTTP/1.1 
7. 響應碼:200 
8. 返回的資料流量:7200 
9. 訪客的來源url:http://www.baidu.com.cn 
10. 訪客所用瀏覽器:Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 5.1; Trident/4.0; BTRS101170; InfoPath.2; .NET4.0C; .NET4.0E; .NET CLR 2.0.50727)

HDFS

Apache Hadoop是用來支援海量資料分散式計算的軟體框架,它具備高可靠性,高穩定性,動態擴容,運用簡單的計算模型(MapReduce)在叢集上進行分散式計算,並支援海量資料的儲存。Apache Hadoop主要包含4個重要的模組,一個是 Hadoop Common,支援其它模組執行的通用元件;Hadoop Distributed File System(HDFS), 分散式檔案儲存系統;Hadoop Yarn,負責計算任務的排程和叢集上資源的管理;Hadoop MapReduce,基於Hadoop Yarn的分散式計算框架。在本文的案例中,我們主要用到HDFS作為點選流資料儲存,分散式計算框架我們將採用Spark RDDs Operations去替代MapReduce。

要配置Hadoop叢集,首先需要配置Hadoop daemons, 它是所有其它Hadoop元件執行所必須的守護程式, 它的配置檔案是

etc/hadoop/hadoop-env.sh

# set to the root of your Java installation
export JAVA_HOME=/usr/java/latest

Hadoop的執行需要Java開發環境的支援,一定要顯示地標明叢集上所有機器的JDK安裝目錄,即使你自己本機的環境已經配置好了JAVA_HOME,因為Hadoop是通過SSH來啟動守護程式的,即便是NameNode啟動自己本機的守護程式;如果不顯示配置JDK安裝目錄,那麼Hadoop在通過SSH啟動守護程式時會找不到Java環境而報錯。

在本文的案例中,我們只使用Hadoop HDFS元件,所以我們只需要配置HDFS的守護程式,NameNode daemons,SecondaryNameNode daemons以及DataNode daemons,它們的配置檔案主要是core-site.xml和hdfs-site.xml:

etc/hadoop/core-site.xml
<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet type="text/xsl" href="configuration.xsl"?>

<configuration>
   <property>
      <name>fs.defaultFS</name>
      <value>hdfs://ymhHadoop:9000</value>
   </property>
   <property>
       <name>hadoop.tmp.dir</name>
       <value>/root/apps/hadoop/tmp</value>
   </property>
</configuration>

fs.defaultFS屬性是指定用來做NameNode的主機URI;而hadoop.tmp.dir是配置Hadoop依賴的一些系統執行時產生的檔案的目錄,預設是在/tmp/${username}目錄下的,但是系統一重啟這個目錄下的檔案就會被清空,所以我們重新指定它的目錄

etc/hadoop/hdfs-site.xml

<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet type="text/xsl" href="configuration.xsl"?>

<configuration>
   <property>
      <name>dfs.replication</name>
      <value>1</value>
   </property>
    <property>
      <name>dfs.namenode.name.dir</name>
      <value>/your/path</value>
   </property>
   <property>
      <name>dfs.blocksize</name>
      <value>268435456</value>
   </property>
   <property>
      <name>dfs.datanode.data.dir</name>
      <value>/your/path</value>
   </property>

</configuration>

dfs.replication 是配置每一份在HDFS系統上的檔案有幾個備份;dfs.namenode.name.dir 是配置使用者自定義的目錄儲存HDFS的業務日誌和名稱空間日誌,也就是操作日誌,叢集發生故障時可以通過這份檔案來恢復資料。dfs.blocksize,定義HDFS最大的檔案分片是多大,預設256M,我們不需要改動;dfs.datanode.data.dir, 用來配置DataNode中的資料Blocks應該儲存在哪個檔案目錄下。

最後把配置檔案拷貝到叢集的所有機子上,接下來就是啟動HDFS叢集,如果是第一次啟動,記得一定要格式化整個HDFS檔案系統

$HADOOP_PREFIX/bin/hdfs namenode -format <cluster_name>
  • 接下來就是通過下面的命令分別啟動NameNode和DataNode
$HADOOP_PREFIX/sbin/hadoop-daemon.sh --config $HADOOP_CONF_DIR --script hdfs start namenode
$HADOOP_PREFIX/sbin/hadoop-daemons.sh --config $HADOOP_CONF_DIR --script hdfs start datanode

收集使用者資料

網站會通過前端JS程式碼或伺服器端的後臺程式碼收集使用者瀏覽資料並儲存在網站伺服器中。一般運維人員會在離線分析系統和真實生產環境之間部署FTP伺服器,並將生產環境上的使用者資料每天定時傳送到FTP伺服器上,離線分析系統就會從FTP服務上採集資料而不會影響到生產環境。 
採集資料的方式有多種,一種是通過自己編寫shell指令碼或Java程式設計採集資料,但是工作量大,不方便維護,另一種就是直接使用第三方框架去進行日誌的採集,一般第三方框架的健壯性,容錯性和易用性都做得很好也易於維護。本文采用第三方框架Flume進行日誌採集,Flume是一個分散式的高效的日誌採集系統,它能把分佈在不同伺服器上的海量日誌檔案資料統一收集到一個集中的儲存資源中,Flume是Apache的一個頂級專案,與Hadoop也有很好的相容性。不過需要注意的是Flume並不是一個高可用的框架,這方面的優化得使用者自己去維護。 
Flume的agent是執行在JVM上的,所以各個伺服器上的JVM環境必不可少。每一個Flume agent部署在一臺伺服器上,Flume會收集web server 產生的日誌資料,並封裝成一個個的事件傳送給Flume Agent的Source,Flume Agent Source會消費這些收集來的資料事件並放在Flume Agent Channel,Flume Agent Sink會從Channel中收集這些採集過來的資料,要麼儲存在本地的檔案系統中要麼作為一個消費資源分發給下一個裝在分散式系統中其它伺服器上的Flume進行處理。Flume提供了點對點的高可用的保障,某個伺服器上的Flume Agent Channel中的資料只有確保傳輸到了另一個伺服器上的Flume Agent Channel裡或者正確儲存到了本地的檔案儲存系統中,才會被移除。 
本系統中每一個FTP伺服器以及Hadoop的name node伺服器上都要部署一個Flume Agent;FTP的Flume Agent採集Web Server的日誌並彙總到name node伺服器上的Flume Agent,最後由hadoop name node伺服器將所有的日誌資料下沉到分散式的檔案儲存系統HDFS上面。 
需要注意的是Flume的Source在本文的系統中選擇的是Spooling Directory Source,而沒有選擇Exec Source,因為當Flume服務down掉的時候Spooling Directory Source能記錄上一次讀取到的位置,而Exec Source則沒有,需要使用者自己去處理,當重啟Flume伺服器的時候如果處理不好就會有重複資料的問題。當然Spooling Directory Source也是有缺點的,會對讀取過的檔案重新命名,所以多架一層FTP伺服器也是為了避免Flume“汙染”生產環境。Spooling Directory Source另外一個比較大的缺點就是無法做到靈活監聽某個資料夾底下所有子資料夾裡的所有檔案裡新追加的內容。關於這些問題的解決方案也有很多,比如選擇其它的日誌採集工具,像logstash等。

FTP伺服器上的Flume配置檔案如下:

    agent.channels = memorychannel  
    agent.sinks = target  

    agent.sources.origin.type = spooldir  
    agent.sources.origin.spoolDir = /export/data/trivial/weblogs  
    agent.sources.origin.channels = memorychannel  
    agent.sources.origin.deserializer.maxLineLength = 2048  

    agent.sources.origin.interceptors = i2  
    agent.sources.origin.interceptors.i2.type = host  
    agent.sources.origin.interceptors.i2.hostHeader = hostname  

    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  

這裡有幾個引數需要說明,Flume Agent Source可以通過配置deserializer.maxLineLength這個屬性來指定每個Event的大小,預設是每個Event是2048個byte。Flume Agent Channel的大小預設等於於本地伺服器上JVM所獲取到的記憶體的80%,使用者可以通過byteCapacityBufferPercentage和byteCapacity兩個引數去進行優化。 
需要特別注意的是FTP上放入Flume監聽的資料夾中的日誌檔案不能同名,不然Flume會報錯並停止工作,最好的解決方案就是為每份日誌檔案拼上時間戳。

在Hadoop伺服器上的配置檔案如下:

    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 = hdfs  
    agent.sinks.target.channel = memorychannel  
    agent.sinks.target.hdfs.path = /flume/events/%y-%m-%d/%H%M%S  
    agent.sinks.target.hdfs.filePrefix = data-%{hostname}  
    agent.sinks.target.hdfs.rollInterval = 60  
    agent.sinks.target.hdfs.rollSize = 1073741824  
    agent.sinks.target.hdfs.rollCount = 1000000  
    agent.sinks.target.hdfs.round = true  
    agent.sinks.target.hdfs.roundValue = 10  
    agent.sinks.target.hdfs.roundUnit = minute  
    agent.sinks.target.hdfs.useLocalTimeStamp = true  
    agent.sinks.target.hdfs.minBlockReplicas=1  
    agent.sinks.target.hdfs.writeFormat=Text  
    agent.sinks.target.hdfs.fileType=DataStream  

round, roundValue,roundUnit三個引數是用來配置每10分鐘在hdfs裡生成一個資料夾儲存從FTP伺服器上拉取下來的資料。使用者分別在日誌檔案伺服器及HDFS伺服器端啟動如下命令,便可以一直監聽是否有新日誌產生,然後拉取到HDFS檔案系統中:

$ nohup bin/flume-ng agent -n $your_agent_name -c conf -f conf/$your_conf_name &

Spark

Spark是最近特別火的一個分散式計算框架,最主要原因就是快!和男人不一樣,在大資料領域,一個框架會不會火,快是除了可靠性之外一個最重要的話語權,幾乎所有新出的分散式框架或即將推出的新版本的MapReduce都在強調一點,我很快。Spark官網上給出的資料是Spark程式和中間資料執行在記憶體上時計算速度是Hadoop的100倍,即使在磁碟上也是比Hadoop快10倍。 
每一個Spark程式都是提供了一個Driver程式來負責執行使用者提供的程式,這個Driver程式會生成一個SparkContext,負責和Cluster Manager(可以是Spark自己提供的叢集管理工具,也可以是Hadoop 的資源排程工具 Yarn)溝通,Cluster負責協調和排程叢集上的Worker Node資源,當Driver獲取到叢集上Worker Node資源後,就會向Worker Node的Executor傳送計算程式(通過Jar或者python檔案),接著再向Exectutor傳送計算任務去執行,Executor會啟動多個執行緒並行執行計算任務,同時還會根據需求在Worker Node上快取計算過程中的中間資料。需要注意的雖然Worker Node上可以啟動多個物理JVM來執行不同Spark程式的Executor,但是不同的Spark程式之間不能進行通訊和資料交換。另一方面,對於Cluster Manager來說,不需要知道Spark Driver的底層,只要Spark Driver和Cluster Manager能互相通訊並獲取計算資源就可以協同工作,所以Spark Driver能較為方便地和各種資源排程框架整合,比如Yarn,Mesos等。 
這裡寫圖片描述
圖片來源:http://spark.apache.org/docs/latest/cluster-overview.html

Spark就是通過Driver來傳送使用者的計算程式到叢集的工作節點中,然後去平行計算資料,這其中有一個很重要的Spark專有的資料模型叫做RDD(Resilient 
distributed dataset), 它代表著每一個計算階段的資料集合,這些資料集合可以繼續它所在的工作節點上,或者通過“shuffle”動作在叢集中重新分發後,進行下一步的平行計算,形成新的RDD資料集。這些RDD有一個最重要的特點就是可以平行計算。RDD最開始有兩種方式進行建立,一種是從Driver程式中的Scala Collections建立而來(或者其它語言的Collections),將它們轉化成RDD然後在工作結點中併發處理,另一種就是從外部的分散式資料檔案系統中建立RDD,如HDFS,HBASE或者任何實現了Hadoop InputFormat介面的物件。

對於Driver程式中的Collections資料,可以使用parallelize()方法將資料根據叢集節點數進行切片(partitions),然後傳送到叢集中併發處理,一般一個節點一個切片一個task進行處理,使用者也可以自定義資料的切片數。而對於外部資料來源的資料,Spark可以從任何基於Hadoop框架的資料來源建立RDD,一般一個檔案塊(blocks)建立一個RDD切片,然後在叢集上平行計算。

在Spark中,對於RDDs的計算操作有兩種型別,一種是Transformations,另一種是Actions。Transformations相當於Hadoop的Map元件,通過對RDDs的併發計算,然後返回新的RDDs物件;而actions則相當於Hadoop的Reduce元件,通過計算(我們這裡說的計算就是function)彙總之前Transformation操作產生的RDDs物件,產生最終結果,然後返回到Driver程式中。特別需要說明的是,所有的Transformations操作都是延遲計算的(lazy), 它們一開始只會記錄這個Transformations是用在哪一個RDDs上,並不會開始執行計算,除非遇到了需要返回最終結果到Driver程式中的Action操作,這時候Transformations才會開始真正意義上的計算。所以使用者的Spark程式最後一步都需要一個Actions型別的操作,否則這個程式並不會觸發任何計算。這麼做的好處在於能提高Spark的執行效率,因為通過Transformations操作建立的RDDs物件最終只會在Actions型別的方法中用到,而且只會返回包含最終結果的RDDs到Driver中,而不是大量的中間結果。有時候,有些RDDs的計算結果會多次被重複呼叫,這就觸發多次的重複計算,使用者可以使用persist()或者cache()方法將部分RDDs的計算結果快取在整個叢集的記憶體中,這樣當其它的RDDs需要之前的RDDs的計算結果時就可以直接從叢集的記憶體中獲得,提高執行效率。

在Spark中,另外一個需要了解的概念就是“Shuffle”,當遇到類似“reduceByKey”的Actions操作時,會把叢集上所有分片的RDDs都讀一遍,然後在叢集之間相互拷貝並全部收集起來,統一計算這所有的RDDs,獲得一個整體的結果而不再是單個分片的計算結果,接著再重新分發到叢集中或者傳送回Driver程式。在Shuffle過程中,Spark會產生兩種型別的任務,一種是Map task,用於匹配本地分片需要shuffle的資料並將這些資料寫入檔案中,然後Reduce task就會讀取這些檔案並整合所有的資料。 所以說”Shuffle”過程會消耗許多本地磁碟的I/O資源,記憶體資源,網路I/O,附帶還會產生許多的序列化過程。通常,repartition型別的操作,比如:repartitions和coalesce,ByKey型別的操作,比如:reduceByKey,groupByKey,join型別的操作,如:cogroup和join等,都會產生Shuffle過程。

接下來,來談一談Spark在叢集環境下的一些特性,這部分內容非常非常重要,請大家一定要重點理解。首先,讀者們一定要記住,Spark是通過Driver把使用者打包提交的Spark程式序列化以後,分發到叢集中的工作節點上去執行,對於計算結果的彙總是返回到Driver端,也就是說通常使用者都是從Driver伺服器上獲取到最終的計算結果!在這個大前提下我們來探討下面幾個問題: 
1. 關於如何正確地將函式傳入RDD operation中,有兩種推薦的方式,一種就是直接傳函式體,另一種是在伴生物件中建立方法,然後通過類名.方法名的方式傳入;如下面的程式碼所示

object DateHandler {
  def parseDate(s: String): String = { ... }
}

rdd.map(DateHandler.parseDate)

錯誤的傳函式的方式如下:

Class MySpark {
 def parseDate(s: String): String = { ... }
 def rddOperation(rdd:RDD[String]):RDD[String] = {rdd.map(x => this.parseDate(x))}
}
…………
val myspark = new MySpark
myspark.rddOperation(sc.rdd)
這樣子的傳遞方式會把整個mySpark物件序列化後傳到叢集中,會造成不必要的記憶體開支。
因為向map中傳入的“this.parseDate(x)”是一個物件例項和它裡面的函式。

當在RDD operation中訪問類中的變數時,也會造成傳遞整個物件的開銷,比如:

Class MySpark {
 val myVariable
 def rddOperation(rdd:RDD[String]):RDD[String] = {rdd.map(x => x + myVariable)}
}
這樣也相當於x => this.x + myVariable,又關聯了這個物件例項,
解決方法就是把這個類的變數傳入方法內部做區域性變數,
就會從訪問物件中的變數變為訪問區域性變數值
def rddOperation(rdd:RDD[String]):RDD[String] = {val _variable = this.myVariable;rdd.map(x => x + _variable)}

2.第二個特別需要注意的問題就是在RDD operations中去更改一個全域性變數, 
在叢集環境中也是很容易出現錯誤的,注意下面的程式碼:

var counter = 0
var rdd = sc.parallelize(data)

// Wrong: Don't do this!!
rdd.foreach(x => counter += x)

println("Counter value: " + counter)

這段程式碼最終返回的結果還是0。這是因為這段程式碼連同counter是序列化後分發到叢集上所有的節點機器上,不同的節點上擁有各自獨立的counter,並不會是原先Driver上counter的引用,並且統計的值也不一樣,最後統計結果也不會返回給Driver去重新賦值。Driver主機上的counter還是它原來的值,不會發生任何變化。如果需要在RDD operations中操作全域性變數,就需要使用accumulator()方法,這是一個執行緒安全的方法,能在併發環境下原子性地改變全域性變數的值。

3.對於叢集環境下的Spark,第三個重要的是如何去合理地列印RDDs中的值。如果只是使用rdd.foreach(println()) 或者 rdd.map(println())是行不通的,一定要記住,程式會被分送到叢集的工作節點上各自執行,println方法呼叫的也是工作節點上的輸入輸出介面,而使用者獲取資料和計算結果都是在Driver主機上的,所以是無法看到這些列印的結果。解決方法之一就是列印前將所有資料先返回Driver,如rdd.collect().foreach(println),但是這可能會讓Driver瞬間耗光記憶體,因為collect操作將叢集上的所有資料全部一次性返回給Driver。較為合理的操作為使用take() 方法先獲取部分資料,然後再列印,如:rdd.take(100).foreach(println)。 
4. 另外需要補充說明的是foreach(func)這個Action操作,它的作用是對叢集上每一個datasets元素執行傳入的func方法,這個func方法是在各個工作節點上分別執行的。雖然foreach是action操作,但是它並不是先全部將資料返回給Driver然後再在Driver上執行func方法,它返回的給Driver的Unit,這點要特別注意。所以foreach(func)操作裡傳入的func函式對Driver中的全域性變數的操作或者列印資料等操作對於Driver來說都是無效的,這個func函式只執行在工作節點上。 
5. 最後要提的是Spark的共享變數,其中一個共享變數就是使用accumulator方法封裝的變數,而另一個共享變數就是廣播變數(Broadcast Variables)。在談廣播變數之前,大家需要了解一個概念叫“stage”,每次進行shuffle操作之前的所有RDDs的操作都屬於同一個stage。所以每次在shuffle操作時,上一個stage計算的結果都會被Spark封裝成廣播變數,並通過一定的高效演算法將這些計算結果在叢集上的每個節點裡都快取上一份,並且是read-only的,這樣當下一個stage的任務再次需要之前stage的計算結果時就不用再重新計算了。使用者可以自定義廣播變數,一般是在某個stage的datasets需要被後續多個stage的任務重複使用的情況下設定會比較有意義。

日誌清洗

當Flume從日誌伺服器上獲取到Nginx訪問日誌並拉取到HDFS系統後,我們接下來要做的就是使用Spark進行日誌清洗。 
首先是啟動Spark叢集,Spark目前主要有三種叢集部署方式,一種是Spark自帶Standalone模式做為cluster manager,另外兩種分別是Yarn和Mesos作為cluster manager。在Yarn的部署方式下,又細分了兩種提交Spark程式的模式,一種是cluster模式,Driver程式直接執行在Application Master上,並直接由Yarn管理,當程式完成初始化工作後相關的客戶端程式就會退出;另一種是client模式,提交程式後,Driver一直執行在客戶端程式中並和Yarn的Application Master通訊獲取工作節點資源。在Standalone的部署方式下,也同樣是細分了cluster模式和client模式的Spark程式提交方式,cluster模式下Driver是執行在工作節點的程式中,一旦完成提交程式的任務,相關的客戶端程式就會退出;而client模式中,Driver會一直執行在客戶端程式中並一直向console輸出執行資訊。本文案例中,使用Standalone模式部署Spark叢集,同時我們選擇手動部署的方式來啟動Spark叢集:

//啟動 master 節點 啟動完後可以通過 localhost:8080 訪問Spark自帶的UI介面
./sbin/start-master.sh

//啟動 Worker 節點 
./sbin/start-slave.sh spark://HOST:PORT

//然後通過spark-submit script 提交Spark程式
//預設是使用client模式執行,也可以手動設定成 cluster模式
//--deploy-mode cluster
$bin/spark-submit --class com.guludada.Spark_ClickStream.VisitsInfo --master spark://ymhHadoop:7077 --executor-memory 1G --total-executor-cores 2 /export/data/spark/sparkclickstream.jar

下面是清洗日誌的Spark程式碼,主要是過濾掉無效的訪問日誌資訊:

package com.guludada.Spark_ClickStream

import scala.io.Source
import java.text.SimpleDateFormat;
import java.util.Locale;
import org.apache.spark.SparkContext
import org.apache.spark.SparkConf
import java.util.Date;

class WebLogClean extends Serializable {

  def weblogParser(logLine:String):String =  {

      //過濾掉資訊不全或者格式不正確的日誌資訊
      val isStandardLogInfo = logLine.split(" ").length >= 12;

      if(isStandardLogInfo) {

        //過濾掉多餘的符號
        val newLogLine:String = logLine.replace("- - ", "").replaceFirst("""\[""", "").replace(" +0000]", "");
        //將日誌格式替換成正常的格式
        val logInfoGroup:Array[String] = newLogLine.split(" ");
        val oldDateFormat = logInfoGroup(1);
        //如果訪問時間不存在,也是一個不正確的日誌資訊
        if(oldDateFormat == "-") return ""
        val newDateFormat = WebLogClean.sdf_standard.format(WebLogClean.sdf_origin.parse(oldDateFormat)) 
        return newLogLine.replace(oldDateFormat, newDateFormat)

      } else {

        return ""

      }
  }
}

object WebLogClean {

   val sdf_origin = new SimpleDateFormat("dd/MMM/yyyy:HH:mm:ss",Locale.ENGLISH);
   val sdf_standard = new SimpleDateFormat("yyyy-MM-dd-HH:mm:ss");
   val sdf_hdfsfolder = new SimpleDateFormat("yy-MM-dd");

   def main(args: Array[String]) {

    val curDate = new Date(); 
    val weblogclean = new WebLogClean
    val logFile = "hdfs://ymhHadoop:9000/flume/events/"+WebLogClean.sdf_hdfsfolder.format(curDate)+"/*" // Should be some file on your system
    val conf = new SparkConf().setAppName("WebLogCleaner").setMaster("local")
    val sc = new SparkContext(conf)
    val logFileSource = sc.textFile(logFile,1).cache()

    val logLinesMapRDD = logFileSource.map(x => weblogclean.weblogParser(x)).filter(line => line != "");
    logLinesMapRDD.saveAsTextFile("hdfs://ymhHadoop:9000/spark_clickstream/cleaned_log/"+WebLogClean.sdf_hdfsfolder.format(curDate)) 

  }

}

經過清洗後的日誌格式如下: 
這裡寫圖片描述

接著為每一條訪問記錄拼上sessionID

package com.guludada.Spark_ClickStream

import org.apache.spark.SparkContext
import org.apache.spark.SparkConf
import java.text.SimpleDateFormat
import java.util.UUID;
import java.util.Date;

class WebLogSession {

}

object WebLogSession {

   val sdf_standard = new SimpleDateFormat("yyyy-MM-dd-HH:mm:ss");
   val sdf_hdfsfolder = new SimpleDateFormat("yy-MM-dd");

   //自定義的將日誌資訊按日誌建立的時間升序排序
   def dateComparator(elementA:String ,elementB:String):Boolean = {     
     WebLogSession.sdf_standard.parse(elementA.split(" ")(1)).getTime < WebLogSession.sdf_standard.parse(elementB.split(" ")(1)).getTime
   }

   import scala.collection.mutable.ListBuffer
   def distinctLogInfoBySession(logInfoGroup:List[String]):List[String] = {

       val logInfoBySession:ListBuffer[String] = new ListBuffer[String]
       var lastRequestTime:Long = 0;
       var lastSessionID:String = "";

       for(logInfo <- logInfoGroup) {

         //某IP的使用者第一次訪問網站的記錄做為該使用者的第一個session日誌
         if(lastRequestTime == 0) {

           lastSessionID = UUID.randomUUID().toString();
           //將該次訪問日誌記錄拼上sessionID並放進按session分類的日誌資訊陣列中
           logInfoBySession += lastSessionID + " " +logInfo
           //記錄該次訪問日誌的時間,並使用者和下一條訪問記錄比較,看時間間隔是否超過30分鐘,是的話就代表新Session開始
           lastRequestTime = sdf_standard.parse(logInfo.split(" ")(1)).getTime

         } else {

           //當前日誌記錄和上一次的訪問時間相比超過30分鐘,所以認為是一個新的Session,重新生成sessionID
           if(sdf_standard.parse(logInfo.split(" ")(1)).getTime - lastRequestTime >= 30 * 60 * 1000) {
               //和上一條訪問記錄相比,時間間隔超過了30分鐘,所以當做一次新的session,並重新生成sessionID
               lastSessionID = UUID.randomUUID().toString();
               logInfoBySession += lastSessionID + " " +logInfo
               //記錄該次訪問日誌的時間,做為一個新session開始的時間,並繼續和下一條訪問記錄比較,看時間間隔是否又超過30分鐘
               lastRequestTime = sdf_standard.parse(logInfo.split(" ")(1)).getTime

           } else { //當前日誌記錄和上一次的訪問時間相比沒有超過30分鐘,所以認為是同一個Session,繼續沿用之前的sessionID

               logInfoBySession += lastSessionID + " " +logInfo
           }           
         }         
       }
       return logInfoBySession.toList
   }

   def main(args: Array[String]) {



      val curDate = new Date(); 
      val logFile = "hdfs://ymhHadoop:9000/spark_clickstream/cleaned_log/"+WebLogSession.sdf_hdfsfolder.format(curDate) // Should be some file on your system
      val conf = new SparkConf().setAppName("WebLogSession").setMaster("local")
      val sc = new SparkContext(conf)
      val logFileSource = sc.textFile(logFile, 1).cache()

      //將log資訊變為(IP,log資訊)的tuple格式,也就是按IP地址將log分組
      val logLinesKVMapRDD = logFileSource.map(line => (line.split(" ")(0),line)).groupByKey();
      //對每個(IP[String],log資訊[Iterator<String>])中的日誌按時間的升序排序
      //(其實這一步沒有必要,本來Nginx的日誌資訊就是按訪問先後順序記錄的,這一步只是為了演示如何在Scala語境下進行自定義排序) 
      //排完序後(IP[String],log資訊[Iterator<String>])的格式變為log資訊[Iterator<String>]
      val sortedLogRDD = logLinesKVMapRDD.map(_._2.toList.sortWith((A,B) => WebLogSession.dateComparator(A,B)))

      //將每一個IP的日誌資訊按30分鐘的session分類並拼上session資訊
      val logInfoBySessionRDD = sortedLogRDD.map(WebLogSession.distinctLogInfoBySession(_))
      //將List中的日誌資訊拆分成單條日誌資訊輸出
      val logInfoWithSessionRDD =  logInfoBySessionRDD.flatMap(line => line).saveAsTextFile("hdfs://ymhHadoop:9000/spark_clickstream/session_log/"+WebLogSession.sdf_hdfsfolder.format(curDate))

   } 
}

拼接上sessionID的日誌如下所示: 
這裡寫圖片描述

最後一步就是根據SessionID來整理使用者的瀏覽資訊,程式碼如下:

package com.guludada.Spark_ClickStream

import org.apache.spark.SparkContext
import org.apache.spark.SparkConf
import java.text.SimpleDateFormat
import java.util.Date;

class VisitsInfo {

}

object VisitsInfo {

  val sdf_standard = new SimpleDateFormat("yyyy-MM-dd-HH:mm:ss");
  val sdf_hdfsfolder = new SimpleDateFormat("yy-MM-dd");

   //自定義的將日誌資訊按日誌建立的時間升序排序
   def dateComparator(elementA:String ,elementB:String):Boolean = {     
     WebLogSession.sdf_standard.parse(elementA.split(" ")(2)).getTime < WebLogSession.sdf_standard.parse(elementB.split(" ")(2)).getTime
   }

   import scala.collection.mutable.ListBuffer
   def getVisitsInfo(logInfoGroup:List[String]):String = {

     //獲取使用者在該次session裡所訪問的頁面總數
     //先用map函式將某次session裡的所有訪問記錄變成(url,logInfo)元組的形式,然後再用groupBy函式按url分組,最後統計共有幾個組
    val visitPageNum = logInfoGroup.map(log => (log.split(" ")(4),log)).groupBy(x => x._1).count(p => true)

    //獲取該次session的ID
    val sessionID = logInfoGroup(0).split(" ")(0)

    //獲取該次session的開始時間
    val startTime = logInfoGroup(0).split(" ")(2)

    //獲取該次session的結束時間
    val endTime = logInfoGroup(logInfoGroup.length-1).split(" ")(2)

    //獲取該次session第一次訪問的url
    val entryPage = logInfoGroup(0).split(" ")(4)

    //獲取該次session最後一次訪問的url
    val leavePage = logInfoGroup(logInfoGroup.length-1).split(" ")(4)

    //獲取該次session的使用者IP
    val IP = logInfoGroup(0).split(" ")(1)

    //獲取該次session的使用者從哪個網站過來
    val referal = logInfoGroup(0).split(" ")(8)

     return sessionID + " " + startTime + " " + endTime + " " + entryPage + " " + leavePage + " " + visitPageNum + " " + IP + " " + referal;

   }

   def main(args: Array[String]) {

      val curDate = new Date();      
      val logFile = "hdfs://ymhHadoop:9000/spark_clickstream/session_log/"+WebLogSession.sdf_hdfsfolder.format(curDate) // Should be some file on your system
      val conf = new SparkConf().setAppName("VisitsInfo").setMaster("local")
      val sc = new SparkContext(conf)
      val logFileSource = sc.textFile(logFile,1).cache()

      //將log資訊變為(session,log資訊)的tuple格式,也就是按session將log分組
      val logLinesKVMapRDD = logFileSource.map(line => (line.split(" ")(0),line)).groupByKey();
      //對每個(session[String],log資訊[Iterator<String>])中的日誌按時間的升序排序
      //排完序後(session[String],log資訊[Iterator<String>])的格式變為log資訊[Iterator<String>]
      val sortedLogRDD = logLinesKVMapRDD.map(_._2.toList.sortWith((A,B) => VisitsInfo.dateComparator(A,B)))

      //統計每一個單獨的Session的相關資訊
      sortedLogRDD.map(VisitsInfo.getVisitsInfo(_)).saveAsTextFile("hdfs://ymhHadoop:9000/spark_clickstream/visits_log/"+WebLogSession.sdf_hdfsfolder.format(curDate))

   }
}

最後整理出來的日誌資訊的格式和示例圖: 
SessionID 訪問時間 離開時間 第一次訪問頁面 最後一次訪問的頁面 訪問的頁面總數 IP Referal 
Session1 2016-05-30 15:17:00 2016-05-30 15:19:00 /blog/me /blog/others 5 192.168.12.130 www.baidu.com 
Session2 2016-05-30 14:17:00 2016-05-30 15:19:38 /home /profile 10 192.168.12.140 www.178.com 
Session3 2016-05-30 12:17:00 2016-05-30 15:40:00 /products /detail 6 192.168.12.150 www.78dm.com

這裡寫圖片描述

Hive

Hive是一個資料倉儲,讓使用者可以使用SQL語言操作分散式儲存系統中的資料。在客戶端,使用者可以使用如何關係型資料庫一樣的建表SQL語句來建立資料倉儲的資料表,並將HDFS中的資料匯入到資料表中,接著就可以使用Hive SQL語句非常方便地對HDFS中的資料做一些增刪改查的操作;在底層,當使用者輸入Hive Sql語句後,Hive會將SQL語句傳送到它的Driver程式中的語義分析器進行分析,然後根據Hive SQL的語義轉化為對應的Hadoop MapReduce程式來對HDFS中資料來進行操作;同時,Hive還將表的表名,列名,分割槽,屬性,以及表中的資料的路徑等後設資料資訊都儲存在外部的資料庫中,如:Mysql或者自帶的Derby資料庫等。 
Hive中主要由以下幾種資料模型組成: 
1. Databases,相當於名稱空間的作用,用來避免同名的表,檢視,列名的衝突,就相當於管理同一類別的一組表的庫。具體的表現為HDFS中/user/hive/warehouse/中的一個目錄。 
2. Tables,是具有同一模式的資料的抽象,簡單點來說就是傳統關係型資料庫中的表。具體的表現形式為Databases下的子目錄,裡面儲存著表中的資料塊檔案,而這些檔案是從經過MapReduce清洗後的貼源資料檔案塊拷貝過來的,也就是使用Hive SQL 中的Load語句,Load語句就是將原先HDFS系統中的某個路徑裡的資料拷貝到/user/hive/warehouse/路徑裡的過程,然後通過Mysql中儲存的後設資料資訊將這些資料和Hive的表對映起來。 
3. Partitions,建立表時,使用者可以指定以某個Key值來為表中的資料分片。從Tables的層面來講,Partition就是表中新加的一個虛擬欄位,用來為資料分類,在HDFS檔案系統中的體現就是這個表的資料分片都按Key來劃分並進入到不同的目錄中,但是Hive不會保證屬於某個Key的內容就一定會進入到某個分片中,因為Hive無法感知,所以需要使用者在插入資料時自己要將資料根據key值劃分到所對應的資料分片中,這樣在以後才能提高查詢效率。 
4. Buckets(Clusters),是指每一個分片上的資料根據表中某個列的hash值組織在一起,也就是進入到同一個桶中,這樣能提升資料查詢的效率。分桶最大的意義在於增加join的效率。比如 select user.id, user.name,admin.tele from user join admin on user.id=admin.id, 已經根據id將資料分進不同的桶裡,兩個資料表join的時候,只要把hash結果相同的桶直接相連就行,提高join的效率。一般兩張表的分桶數量要一致,才能達到join的最高效率,如果是倍數關係,也會提高join的效率但沒有一致數量的分桶效率高,如果不是倍數關係分桶又不一致,那麼效率和沒分桶沒什麼區別。

Spark SQL

在作者之前的Hadoop文章裡,使用MapReduce清洗完日誌檔案後,在Hive的客戶端中使用Hive SQL去構建對應的資料倉儲並對資料進行分析。和之前不同的是,在本篇文章中, 作者使用的是Spark SQL去對Hive資料倉儲進行操作。因為文章篇幅有限,下面只對Spark SQL進行一個簡單的介紹,更多具體的內容讀者們可以去閱讀官方文件。

Spark SQL是Spark專案中專門用來處理結構化資料的一個模組,使用者可以通過SQL,DataFrames API,DataSets API和Spark SQL進行互動。Spark SQL可以通過標準的SQL語句對各種資料來源中的資料進行操作,如Json,Parquet等,也可以通過Hive SQL操作Hive中的資料;DataFrames是一組以列名組織的資料結構,相當於關係型資料庫中的表,DataFrames可以從結構化的資料檔案中建立而來,如Json,Parquet等,也可以從Hive中的表,外部資料庫,RDDs等建立出來;Datasets是Spark1.6後新加入的API,類似於RDDs,可以使用Transformations和Actions API 運算元據,同時提供了很多執行上的優化,並且用Encoder來替代Java Serialization介面進行序列化相關的操作。

DataFrames可以通過RDDs轉化而來,其中一種轉化方式就是通過case class來定義DataFrames中的列結構,也可以說是表結構,然後將RDDs中的資料轉化為case class物件,接著通過反射機制獲取到case class對錶結構的定義並轉化成DataFrames物件。轉化成DF物件後,使用者可以方便地使用DataFrames提供的“domain-specific”操作語言來操作裡面的資料,亦或是將DataFrames物件註冊成其對應的表,然後通過標準SQL語句來操作裡面的資料。總之,Spark SQL提供了多樣化的資料結構和操作方法讓我們能以SQL語句方便地對資料進行操作,減少運維和開發成本,十分方便和強大!

而在本案例裡,我們將使用星型模型來構建資料倉儲的ODS(OperationalData Store)層。 
Visits資料分析 
頁面具體訪問記錄Visits的事實表和維度表結構 
這裡寫圖片描述

接下來啟動spark shell,然後使用Spark SQL去操作Hive資料倉儲

$bin/spark-shell --jars lib/mysql-connector-java-5.0.5.jar

在spark shell順序執行如下命令操作Hive資料倉儲,在此過程中,大家會發現執行速度比在Hive客戶端中快很多,原因就在於使用Spark SQL去操作Hive,其底層使用的是Spark RDDs去操作HDFS中的資料,而不再是原來的Hadoop MapReduce。

//建立HiveContext物件,並且該物件繼承了SqlContext
val sqlContext = new org.apache.spark.sql.hive.HiveContext(sc)

//在資料倉儲中建立Visits資訊的貼源資料表:
sqlContext.sql("create table visitsinfo_spark(session string,startdate string,enddate string,entrypage string,leavepage string,viewpagenum string,ip string,referal string) partitioned by(inputDate string) clustered by(session) sorted by(startdate) into 4 buckets row format delimited fields terminated by ' '")

//將HDFS中的資料匯入到HIVE的Visits資訊貼源資料表中
sqlContext.sql("load data inpath '/spark_clickstream/visits_log/16-07-18' overwrite into table visitsinfo_spark partition(inputDate='2016-07-27')")

這裡寫圖片描述

//  根據具體的業務分析邏輯建立ODS層的Visits事實表,並從visitsinfo_spark的貼源表中匯入資料
sqlContext.sql("create table ods_visits_spark(session string,entrytime string,leavetime string,entrypage string,leavepage string,viewpagenum string,ip string,referal string) partitioned by(inputDate string) clustered by(session) sorted by(entrytime) into 4 buckets row format delimited fields terminated by ' '")

sqlContext.sql("insert into table ods_visits_spark partition(inputDate='2016-07-27') select vi.session,vi.startdate,vi.enddate,vi.entrypage,vi.leavepage,vi.viewpagenum,vi.ip,vi.referal from visitsinfo_spark as vi where vi.inputDate='2016-07-27'")

//建立Visits事實表的時間維度表並從當天的事實表裡匯入資料
sqlContext.sql("create table ods_dim_visits_time_spark(time string,year string,month string,day string,hour string,minutes string,seconds string) partitioned by(inputDate String) clustered by(year,month,day) sorted by(time) into 4 buckets row format delimited fields terminated by ' '")

// 將“訪問時間”和“離開時間”兩列的值合併後再放入時間維度表中,減少資料的冗餘
sqlContext.sql("insert overwrite table ods_dim_visits_time_spark partition(inputDate='2016-07-27') select distinct ov.timeparam, substring(ov.timeparam,0,4),substring(ov.timeparam,6,2),substring(ov.timeparam,9,2),substring(ov.timeparam,12,2),substring(ov.timeparam,15,2),substring(ov.timeparam,18,2) from (select ov1.entrytime as timeparam from ods_visits_spark as ov1 union select ov2.leavetime as timeparam from ods_visits_spark as ov2) as ov")

這裡寫圖片描述

//建立visits事實表的URL維度表並從當天的事實表裡匯入資料
sqlContext.sql("create table ods_dim_visits_url_spark(pageurl string,host string,path string,query string) partitioned by(inputDate string) clustered by(pageurl) sorted by(pageurl) into 4 buckets row format delimited fields terminated by ' '")

//將每個session的進入頁面和離開頁面的URL合併後存入到URL維度表中
sqlContext.sql("insert into table ods_dim_visits_url_spark partition(inputDate='2016-07-27') select distinct ov.pageurl,b.host,b.path,b.query from (select ov1.entrypage as pageurl from ods_visits_spark as ov1 union select ov2.leavepage as pageurl from ods_visits_spark as ov2 ) as ov lateral view parse_url_tuple(concat('https://localhost',ov.pageurl),'HOST','PATH','QUERY') b as host,path,query")

//將每個session從哪個外站進入當前網站的資訊存入到URL維度表中
sqlContext.sql("insert into table ods_dim_visits_url_spark partition(inputDate='2016-07-27') select distinct ov.referal,b.host,b.path,b.query from ods_visits_spark as ov lateral view parse_url_tuple(substr(ov.referal,2,length(ov.referal)-2),'HOST','PATH','QUERY') b as host,path,query")

這裡寫圖片描述

//查詢訪問網站頁面最多的前20個session的資訊
sqlContext.sql("select * from ods_visits_spark as ov sort by viewpagenum desc").show()

這裡寫圖片描述

Troubleshooting

使用Flume拉取檔案到HDFS中會遇到將檔案分散成多個1KB-5KB的小檔案的問題

需要注意的是如果遇到Flume會將拉取過來的檔案分成很多份1KB-5KB的小檔案儲存到HDFS上,那麼很可能是HDFS Sink的配置不正確,導致系統使用了預設配置。spooldir型別的source是將指定目錄中的檔案的每一行封裝成一個event放入到channel中,預設每一行最大讀取1024個字元。在HDFS Sink端主要是通過rollInterval(預設30秒), rollSize(預設1KB), rollCount(預設10個event)3個屬性來決定寫進HDFS的分片檔案的大小。rollInterval表示經過多少秒後就將當前.tmp檔案(寫入的是從channel中過來的events)下沉到HDFS檔案系統中,rollSize表示一旦.tmp檔案達到一定的size後,就下沉到HDFS檔案系統中,rollCount表示.tmp檔案一旦寫入了指定數量的events就下沉到HDFS檔案系統中。

使用Flume拉取到HDFS中的檔案格式錯亂

這是因為HDFS Sink的配置中,hdfs.writeFormat屬性預設為“Writable”會將原先的檔案的內容序列化成HDFS的格式,應該手動設定成hdfs.writeFormat=“text”; 並且hdfs.fileType預設是“SequenceFile”型別的,是將所有event拼成一行,應該該手動設定成hdfs.fileType=“DataStream”,這樣就可以是一行一個event,與原檔案格式保持一致

啟動Spark任務的時候會報任務無法序列化的錯誤

這裡寫圖片描述
而這個錯誤的主要原因是Driver向worker通過RPC通訊傳送的任務無法序列化,很有可能就是使用者在使用transformations或actions方法的時候,向這個方法中傳入的函式裡包含不可序列化的物件,如上面的程式中 logFileSource.map(x => weblogclean.weblogParser(x)) 向map中傳入的函式包含不可序列化的物件weblogclean,所以要將該物件的相關類變為可序列化的類,通過extends Serializable的方法解決

在分散式環境下如何設定每個使用者的SessionID

可以使用UUID,UUID是分散式環境下唯一的元素識別碼,它由日期和時間,時鐘序列,機器識別碼(一般為網路卡MAC地址)三部分組成。這樣就保證了每個使用者的SessionID的唯一性。

使用maven編譯Spark程式時報錯

在使用maven編譯Spark程式時會報錯,[ERROR] error: error while loading CharSequence, class file ‘/Library/Java/JavaVirtualMachines/jdk1.8.0_77.jdk/Contents/Home/jre/lib/rt.jar(java/lang/CharSequence.class)’ is broken 
如圖: 
這裡寫圖片描述
主要原因是Scala 2.10 和 JDK1.8的版本衝突問題,解決方案只能是將JDK降到1.7去編譯

要在Spark中使用HiveContext,配置完後啟動spark-shell報錯

要在Spark中使用HiveContext,將所需的Hive配置檔案拷貝到Spark專案的conf目錄下,並且把連線資料庫的Driver包也放到了Spark專案中的lib目錄下,然後啟動spark-shell報錯,主要還是找不到CLASSPATH中的資料庫連線驅動包,如下圖:
這裡寫圖片描述
這裡寫圖片描述
目前作者想到的解決方案比較笨拙:就是啟動spark-shell的時候顯示地告訴驅動jar包的位置

$bin/spark-shell --jars lib/mysql-connector-java-5.0.5.jar

 原文參考:https://blog.csdn.net/ymh198816/article/details/52014315

相關文章