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

前段時間把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
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章