Spark Streaming
隨著大數據技術的不斷發展,人們對于大數據的實時性處理要求也在不斷提高,傳統 的 MapReduce 等批處理框架在某些特定領域,例如實時用戶推薦、用戶行為分析這 些應用場景上逐漸不能滿足人們對實時性的需求,因此誕生了一批如 S3、Samza、 Storm、Flink等流式分析、實時計算框架。
Spark 由于其內部優秀的調度機制、快速的分布式計算能力,能夠以極快的速度進行 迭代計算。正是由于具有這樣的優勢,Spark 能夠在某些程度上進行實時處理, Spark Streaming 正是構建在此之上的流式框架。
Spark Streaming 概述
什么是Spark Streaming
Spark Streaming 類似于Apache Storm(來一條數據處理一條,延遲低、響應快、吞吐量低),用于流式數據處理。Spark Streaming 具有高吞吐量和容錯能力強等特點,支持數據源有很多,例如Kafka(最重要的數據源)、Flume、Twitter和TCP套接字,數據輸入后可用高度抽象API,如map、reduce、join、window等進行運算,處理結果能保存在很多地方,如HDFS、數據庫等,Spark Streaming能與MLlib已經Graphx融合
Spark Streaming 與Spark 基于RDD的概念比較類似,Spark Streaming使用離散流(Discretized Stream)作為抽象表示,稱為DStream,DStream是隨著時間推移而收到的數據的序列。在內部,每個時間區間收到的數據都作為RDD存在,DStream是由這些RDD所組成的序列
DStream可以從各種輸入源創建,比如Flume、Kafka或者HDFS。創建出來的DStream支持兩種操作
- 轉化操作,會生成一個新的DStream
- 輸出操作(output operation),把數據寫入外部系統
DStream提供了許多與RDD所支持的操作相類似的操作支持,還增加了時間相關的新操作,比如滑動窗口。
Spark Streaming架構
Spark Streaming 使用 mini-batch架構,把流式計算當作一系列連續的小規模批處理來對待,Spark Streaming 從各種輸入源中讀取數據,并把數據分組為小的批次,新的批次按均勻的時間間隔創建處理,在每個時間區間開始的時候,一個新的批次就創建出來,在該區間內接收到的數據都會被添加到這個批次中。在時間區間結束時,批次停止增長。時間區間的大小是有批次間隔這個參數覺得決定的,批次間隔一般設在500毫秒到幾秒之間,有開發者配置。每個輸入批次都形成一個RDD,以 Spark 作業的方式處理并生成其他的 RDD。 處理的結果可以以批處理的方式傳給外部系統。
Spark Streaming 的編程抽象時離散化流,也就是DStream,是一個RDD序列,每個RDD代表數據流中的一個時間片的內的數據。
應用于DStream上的轉換操作,都會轉換為底層RDD上的操作,如對行DStream中的每個RDD應用flatMap操作以生成單詞DStream的RDD
這些底層的RDD轉換是由Spark引擎完成的。DStream操作隱藏了大部分這些細節, 為開發人員提供了更高級別的API以方便使用。
Spark Streaming為每個輸入源啟動對應的接收器。接收器運行在Executor中,從輸入源收集數據并保存為 RDD。默認情況下接收到的數據后會復制到另一個Executor中,進行容錯; Driver 中的 StreamingContext 會周期性地運行 Spark 作業來處理這些數據。
Spark Streaming運行流程
客戶端提交Spark Streaming 作業后啟動Driver,Driver啟動Receiver,Receiver接收數據源的數據
每個作業保護多個Executor,每個Executor以線程的方式運行task,Spark Streaming至少包含一個receiver task(一般情況下)
Receiver接收數據后生成Block,并把BlockId匯報給Driver,然后備份到另外一個Executor上
ReceiverTracker維護Reciver匯報的BlockId
Driver定時啟動JobGenerator,根據DStream的關系生成邏輯RDD,然后創建JobSet,叫個JobScheduler
JobScheduler負責調度JobSet,交給DAGScheduler,DAGScheduler根據邏輯RDD,生成相應的Stages,每個stage包含一到多個Task,將TaskSet提交給TaskSchedule。
TaskScheduler負責把Task調度到Executor上,并維護Task運行狀態。
總結:
- 提交完spark作業后,driver就會去啟動Receiver取接收數據,Receiver接收數據的同時,會將數據備份到另一個節點,Receiver接收到數據會回報給Driver的,然后
Spark Streaming優缺點
EOS:exactly onece semantic 處理且僅處理一次
與傳統流式框架相比,Spark Streaming最大的不同點在于它對待數據是粗粒度的處理方式,記一次處理一小批數據,而其他框架往往采用細粒度的處理模式,即依次處理一條數據,Spark Streaming這樣的設計即為其帶來了優點,也帶來的確定
優點
- Spark Streaming 內部實現和調度方式高度依賴Spark的DAG調度器和RDD,這就決定了Spark Streaming的設計初衷必須是粗粒度方式的,同時,由于Spark 內部調度器足夠快速和高效,可以快速地處理小批量數據,這就獲得準實時的特性
- Spark Streaming 的粗粒度執行方式使其確保“處理且僅處理一次”的特性(EOS),同時也可以方便地實現容錯回復機制
- 由于Spark Streaming的DStream本質是RDD在流式數據上的抽象,因此基于RDD的各種操作也有相應的基于DStream的版本,這樣就大大降低了用戶對于新框架的學習成本,在了解Spark的情況下,用戶將很容易使用Spark Streaming
- 由于DStream是在RDD上抽象,那么也跟容易與RDD進行交互操作,在需要將流式數據和批處理數據結合進行分析的情況下,將會變得非常方便
缺點
- Spark Streaming 的粗粒度處理方式也造成了不可避免的延遲。在細粒度處理方式下,理想情況下每一條記錄都會被實時處理,而在Spark Streaming中,數據需要匯總到一定的量后在一次性處理,這就增加了數據處理的延遲,這種延遲是由框架的設計引入的,并不是由網絡或其他情況造成的
Structured Streaming
Spark Streaming計算邏輯是把數據按時間劃分為DStream,存在以下問題:
- 框架自身只能根據Batch Time單元進行數據處理,很難處理基于Event time(即時間戳)的數據,很難處理延遲、亂序的數據
- 流式和批量處理的API完全不一致,兩種使用場景中,程序代碼還是需要一定的轉換
- 端到端的數據容錯保障邏輯需要用戶自己構建,難以處理增量更新和持久化存儲等一致性問題
基于以上問題,提出了下一代Structure Streaming。將數據映射為一張無界長度的表,通過表的計算,輸出結果映射為另一張表。
以結構化的方式去操作流式數據,簡化了實時計算過程,同時還復用了Catalyst引擎來優化SQL操作此外還能支持增量計算和基于event time 的計算。
DStream基礎數據源
基礎數據源包括:文件數據流、socket數據流、RDD隊列流;這些數據源主要用于測試。
引入依賴:
<dependency>
<groupId>org.apache.spark</groupId>
<artifactId>spark-streaming_2.12</artifactId>
<version>${spark.version}</version>
</dependency>
文件數據流
文件數據流:通過textFileStream(directory)方法進行讀取HDFS兼容的文件系統文件
/**
* Create an input stream that monitors a Hadoop-compatible filesystem
* for new files and reads them as text files (using key as LongWritable, value
* as Text and input format as TextInputFormat). Files must be written to the
* monitored directory by "moving" them from another location within the same
* file system. File names starting with . are ignored.
* @param directory HDFS directory to monitor for new file
*/
def textFileStream(directory: String): DStream[String] = withNamedScope("text file stream") {
fileStream[LongWritable, Text, TextInputFormat](directory).map(_._2.toString)
}
Spark Streaming 將會監控directory目錄,并不斷處理移動進來的文件
- 不支持嵌套目錄
- 文件需要相同的數據格式
- 文件進入directory的方式需要通過移動或者重命名來實現
- 一旦文件移動進目錄,則不能在修改,即使修改了也不會讀取新數據
- 文件流不需要接收器(receiver),不需要單獨分配CPU核
package com.hhb.spark.streaming
import org.apache.spark.SparkConf
import org.apache.spark.streaming.{Seconds, StreamingContext}
/**
* @description: 只能監控啟動后的新增的文件
* @date: 2020-11-19 10:28
**/
object FileDStream {
def main(args: Array[String]): Unit = {
//1 創建SparkConf
val conf: SparkConf = new SparkConf().setMaster("local[*]").setAppName(this.getClass.getCanonicalName.init)
//2 初始化入口,設置任務5秒鐘執行一次
val ssc = new StreamingContext(conf, Seconds(5))
ssc.sparkContext.setLogLevel("warn")
//3、4、5
//3 讀取本地文件,創建DStream
val linesDStream = ssc.textFileStream("/Users/baiwang/myproject/spark/data/log/")
//4 DStream轉換
val wordDStream = linesDStream.flatMap(_.split("\\s+"))
val wordCountDStream = wordDStream.map((_, 1)).reduceByKey(_ + _)
//5 輸出DStream到屏幕
wordCountDStream.print()
//6 啟動作業
ssc.start()
//啟動作業后,等待,作業以5秒一次的頻率運行
ssc.awaitTermination()
}
}
Socket數據流
Spark Streaming 可以通過Socket端口監聽并接受數據,然后進行相應的處理
在linux120上執行nc程序
nc -lk 9999
# yum install nc
隨后可以在nc窗口中隨意輸入一下單詞,監聽窗口會自動獲得單詞數據流信息,在監聽窗口每隔x秒就會打印出詞頻的統計信息,可以在屏幕上出現結果
備注:使用local[*]可能出現問題
如果給虛擬機配置的cpu數為1,使用local[*]也只會啟動一個線程,該線程用于receiver task,此時沒有資源處理接受打到的數據。
【現象:程序正常執行,不會打印時間戳,屏幕上也不會有其他有效信息】
源碼:
/**
* Creates an input stream from TCP source hostname:port. Data is received using
* a TCP socket and the receive bytes is interpreted as UTF8 encoded `\n` delimited
* lines.
* @param hostname Hostname to connect to for receiving data
* @param port Port to connect to for receiving data
* @param storageLevel Storage level to use for storing the received objects
* (default: StorageLevel.MEMORY_AND_DISK_SER_2)
* @see [[socketStream]]
*/
def socketTextStream(
hostname: String,
port: Int,
//監控到的數據,默認存內存,內存存不下放到磁盤,并對數據進行備份
storageLevel: StorageLevel = StorageLevel.MEMORY_AND_DISK_SER_2
): ReceiverInputDStream[String] = withNamedScope("socket text stream") {
socketStream[String](hostname, port, SocketReceiver.bytesToLines, storageLevel)
}
注意:DStream的 StorageLevel 是 MEMORY_AND_DISK_SER_2;
package com.hhb.spark.streaming
import org.apache.spark.SparkConf
import org.apache.spark.streaming.{Seconds, StreamingContext}
/**
* @description:
* @author: huanghongbo
* @date: 2020-11-19 15:35
**/
object SocketDStream {
def main(args: Array[String]): Unit = {
//初始化StreamingContext
val conf = new SparkConf().setMaster("local[*]").setAppName(this.getClass.getCanonicalName.init)
val ssc = new StreamingContext(conf, Seconds(5))
//接受數據,轉換成socketStream
//鏈接服務器,需要在服務安裝nc 執行命令nc -lk 9999,然后輸入數據
// val socketDStream = ssc.socketTextStream("linux120", 9999)
//鏈接本地
val socketDStream = ssc.socketTextStream("localhost", 9999)
//數據轉換
val wordDStream = socketDStream.flatMap(_.split("\\s+"))
val wordCountDStream = wordDStream.map((_, 1)).reduceByKey(_ + _)
//數據輸出
wordCountDStream.print()
//啟動
ssc.start()
ssc.awaitTermination()
}
}
SocketServer程序(單線程),監聽本機指定端口,與socket連接后可發送信息:
package com.hhb.spark.streaming
import java.io.PrintWriter
import java.net.ServerSocket
/**
* @description:
* @author: huanghongbo
* @date: 2020-11-19 16:14
**/
object OneThreadSocket {
def main(args: Array[String]): Unit = {
val arr: Array[String] = "Hello World Hello Hadoop Hello spark kafka hive zookeeper hbase flume sqoop".split("\\s+")
val n = arr.length
val port = 9999
val random = scala.util.Random
val server = new ServerSocket(port)
val socket = server.accept()
println("鏈接成功,鏈接地址:" + socket.getInetAddress)
while (true) {
val writer = new PrintWriter(socket.getOutputStream)
writer.println(arr(random.nextInt(n)) + " " + arr(random.nextInt(n)))
writer.flush()
Thread.sleep(100)
}
}
}
SocketServer程序(多線程)
RDD隊列流
調試Spark Streaming應用程序的時候,可使用streamingContext.queueStream(queueOfRDD) 創建基于RDD隊列的DStream
源碼:
/**
* Create an input stream from a queue of RDDs. In each batch,
* it will process either one or all of the RDDs returned by the queue.
*
* @param queue Queue of RDDs. Modifications to this data structure must be synchronized.
* @param oneAtATime Whether only one RDD should be consumed from the queue in every interval
* @tparam T Type of objects in the RDD
*
* @note Arbitrary RDDs can be added to `queueStream`, there is no way to recover data of
* those RDDs, so `queueStream` doesn't support checkpointing.
*/
def queueStream[T: ClassTag](
queue: Queue[RDD[T]],
//表示一次只處理一個RDD
oneAtATime: Boolean = true
): InputDStream[T] = {
queueStream(queue, oneAtATime, sc.makeRDD(Seq.empty[T], 1))
}
備注:
- oneAtTime :缺省為true,一次處理一個RDD,設為false,一次處理全部的RDD
- RDD隊列流可以使用local[1]
- 涉及到同時出隊和入隊操作,所以需要同步
每秒創建一個RDD(RDD存放1-100的整數),Streaming每隔1秒就對數據進行處 理,計算RDD中數據除10取余的個數。
package com.hhb.spark.streaming
import org.apache.spark.SparkConf
import org.apache.spark.rdd.RDD
import org.apache.spark.streaming.{Seconds, StreamingContext}
import scala.collection.mutable
/**
* @description:
* @date: 2020-11-19 16:20
**/
object RDDDStream {
def main(args: Array[String]): Unit = {
//初始化ssc,沒個一秒處理一次數據
val conf = new SparkConf().setAppName(this.getClass.getCanonicalName.init).setMaster("local[*]")
//由于RDD是一秒產生一個,所以執行實現需要小于一秒,否則產生完RDD后直接停止了,
// 例如設置10秒,還沒等執行,程序已經結束
val ssc = new StreamingContext(conf, Seconds(1))
//組裝RDD的對流
val queue = mutable.Queue[RDD[Int]]()
// 讀取RDD生成DStream
val rddDStream = ssc.queueStream(queue)
//具體的業務處理邏輯
val countDStream = rddDStream.map(x => (x % 10, 1)).reduceByKey(_ + _)
//輸出
countDStream.print()
//啟動
ssc.start()
//每秒產生一個RDD
val arr = 1 to 100
for (i <- 1 to 5) {
queue.synchronized {
queue += ssc.sparkContext.makeRDD(arr.map(_ * i))
}
Thread.sleep(1000)
}
ssc.stop()
}
}
DStream 轉換操作
DStream上的操作與RDD類似,分為Transformations(轉換) 和 Output Operations(輸出)兩種,池外轉換操作中還有一些比較特殊的方法,如:updateStateByKey、transform以及各種Window相關的操作
Transformation | Meaning |
---|---|
map(func) | 將源DStream中的每個元素通過一個函數func從 而得到新的DStreams |
flatMap(func) | 和map類似,但是每個輸入的項可以被映射為0 或更多項 |
filter(func) | 選擇源DStream中函數func判為true的記錄作為 新DStreams |
repartition(numPartitions) | 通過創建更多或者更少的partition來改變此 DStream的并行級別 |
union(otherStream) | 聯合源DStreams和其他DStreams來得到新 DStream |
count() | 統計源DStreams中每個RDD所含元素的個數得 到單元素RDD的新DStreams |
reduce(func) | 通過函數func(兩個參數一個輸出)來整合源 DStreams中每個RDD元素得到單元素RDD的 DStreams。這個函數需要關聯從而可以被并行 計算 |
countByValue() | 對于DStreams中元素類型為K調用此函數,得到 包含(K,Long)對的新DStream,其中Long值表明 相應的K在源DStream中每個RDD出現的頻率 |
reduceByKey(func, [numTasks]) | 對(K,V)對的DStream調用此函數,返回同樣(K,V) 的新DStream,新DStream中的對應V為使用 reduce函數整合而來。默認情況下,這個操作使 用Spark默認數量的并行任務(本地模式為2,集 群模式中的數量取決于配置參數 spark.default.parallelism)。也可以傳入可選 的參數numTasks來設置不同數量的任務 |
join(otherStream, [numTasks]) | 兩DStream分別為(K,V)和(K,W)對,返回(K,(V,W)) 對的新DStream |
cogroup(otherStream, [numTasks]) | 兩DStream分別為(K,V)和(K,W)對,返回(K, (Seq[V],Seq[W])對新DStreams |
transform(func) | 將RDD到RDD映射的函數func作用于源DStream 中每個RDD上得到新DStream。這個可用于在 DStream的RDD上做任意操作 |
updateStateByKey(func) | 得到”狀態”DStream,其中每個key狀態的更新是 通過將給定函數用于此key的上一個狀態和新值 而得到。這個可用于保存每個key值的任意狀態 數據 |
備注:
- 在DStream與RDD上的轉換操作非常類似(無狀態操作)
- DStream有自己特殊的操作(窗口操作、追蹤狀態變化操作)
- 在DStream上的轉換操作比RDD上的轉換操作少
DStream的轉化操作可以分為無狀態(stateless)和有狀態(stateful)兩種:
- 無狀態轉換操作,每個批次處理不依賴之前批次的數據,常見的RDD轉化操作,例如map、filter、reduceByKey等
- 有狀態轉化操作。需要使用之前批次的數據 或者 中間結果來計算當前批次的數據。有狀態轉化操作包括:基于滑動窗口的轉化操作 或 追蹤狀態變化的轉化操作
無狀態轉換
無狀態轉化操作就是把簡單的RDD轉化操作應用到每個批次上,也就是轉化DStream中的每一個RDD。創建的無狀態轉換包括:map、flatMap、filter、repartition、reduceByKey、groupByKey;直接作用在DStream上。重要的轉換操作:transform。通過對源DStream的每個RDD應用RDD-to-RDD函 數,創建一個新的DStream。支持在新的DStream中做任何RDD操作。
/**
* Create a new DStream in which each RDD is generated by applying a function on RDDs of
* the DStreams.
*/
def transform[T: ClassTag](
dstreams: Seq[DStream[_]],
transformFunc: (Seq[RDD[_]], Time) => RDD[T]
): DStream[T] = withScope {
new TransformedDStream[T](dstreams, sparkContext.clean(transformFunc))
}
這是一個功能強大的函數,它可以允許開發者直接操作其內部的RDD。也就是說開 發者,可以提供任意一個RDD到RDD的函數,這個函數在數據流每個批次中都被調 用,生成一個新的流。
示例:黑名單過濾
假設:arr1為黑名單數據(自定義),true表示數據生效,需要被過濾掉;false表示數據 未生效
val arr1 = Array(("spark", true), ("scala", false))
假設:流式數據格式為"time word",需要根據黑名單中的數據對流式數據執行過濾操 作。如"2 spark"要被過濾掉
1 hadoop
2 spark
3 scala
4 java
5 hive
結果:"2 spark" 被過濾
方法一:使用外鏈接
package com.hhb.spark.streaming
import org.apache.spark.SparkConf
import org.apache.spark.streaming.dstream.ConstantInputDStream
import org.apache.spark.streaming.{Seconds, StreamingContext}
/**
* @description:ConstantInputDStream 主要用于流式計算的測試
* @date: 2020-11-20 10:09
**/
object BlackListFilter1 {
def main(args: Array[String]): Unit = {
val conf = new SparkConf().setMaster("local[*]").setAppName(this.getClass.getCanonicalName.init)
val ssc = new StreamingContext(conf, Seconds(5))
//準備數據
val arr: Array[(String, Int)] = "Hello World Hello Hadoop Hello spark kafka hive zookeeper hbase flume sqoop hello scala".split("\\s+").map(_.toLowerCase).zipWithIndex
val data = arr.map { case (k, v) => v + " " + k }
val rdd = ssc.sparkContext.makeRDD(data)
// 黑名單
val blackList = Array(("spark", true), ("scala", false),("hbase", true),("hello", true))
val blackListRDD = ssc.sparkContext.makeRDD(blackList)
//new ConstantInputDStream[String](ssc, rdd) =》 1 hello
// new ConstantInputDStream[String](ssc, rdd).map(value => (value.split("\\s+")(1), value)) => (hello,(1 hello))
val rddDStream = new ConstantInputDStream[String](ssc, rdd).map(value => (value.split("\\s+")(1), value))
val resultDStream = rddDStream.transform { rdd =>
// 通過leftOuterJoin操作既保留了左側RDD的所有內容,又獲得了內容是否在黑名單中
rdd.leftOuterJoin(blackListRDD)
// (hello,(hello 1,option(none)),(spark,(spark 3,option(true)))
.filter { case (_, (_, rightValue)) => rightValue.getOrElse(false) != true }
.map { case (_, (leftValue, _)) => leftValue }
}
resultDStream.print()
// 啟動流式作業
ssc.start()
ssc.awaitTermination()
}
}
方案二:使用SQL或者DSL
package com.hhb.spark.streaming
import org.apache.spark.SparkConf
import org.apache.spark.sql.SparkSession
import org.apache.spark.streaming.dstream.ConstantInputDStream
import org.apache.spark.streaming.{Seconds, StreamingContext}
/**
* @description:使用SQL或者DSL
* @author: huanghongbo
* @date: 2020-11-20 10:39
**/
object BlackListFilter2 {
def main(args: Array[String]): Unit = {
val conf = new SparkConf().setAppName(this.getClass.getCanonicalName.init).setMaster("local[*]")
val ssc = new StreamingContext(conf, Seconds(2))
ssc.sparkContext.setLogLevel("warn")
//準備數據
val arr: Array[(String, Int)] = "Hello World Hello Hadoop Hello spark kafka hive zookeeper hbase flume sqoop hello scala".split("\\s+").map(_.toLowerCase).zipWithIndex
val arrRDD = ssc.sparkContext.makeRDD(arr).map { case (k, v) => v + " " + k }
val constantDStream = new ConstantInputDStream[String](ssc, arrRDD)
// 黑名單
val blackList = Array(("spark", true), ("scala", false), ("hbase", true), ("hello", true))
val blackListRDD = ssc.sparkContext.makeRDD(blackList)
val valueDStream = constantDStream.map(line => (line.split("\\s+")(1), line))
valueDStream.transform { rdd =>
val spark = SparkSession.builder.config(rdd.sparkContext.getConf).getOrCreate()
import spark.implicits._
val wordDF = rdd.toDF("word", "line")
val blackListDF = blackListRDD.toDF("word1", "flag")
wordDF.join(blackListDF, $"word" === $"word1", "left")
.filter("flag == false or flag is null")
.select("line")
.rdd
}.print()
ssc.start()
ssc.awaitTermination()
}
}
方案三:直接過濾,沒有shuffle
package com.hhb.spark.streaming
import org.apache.spark.SparkConf
import org.apache.spark.streaming.{Seconds, StreamingContext}
import org.apache.spark.streaming.dstream.ConstantInputDStream
/**
* @description: 直接過濾,沒有shuffle
* @author: huanghongbo
* @date: 2020-11-20 10:39
**/
object BlackListFilter3 {
def main(args: Array[String]): Unit = {
val conf = new SparkConf().setAppName(this.getClass.getCanonicalName.init).setMaster("local[*]")
val ssc = new StreamingContext(conf, Seconds(2))
ssc.sparkContext.setLogLevel("warn")
//準備數據
val arr: Array[(String, Int)] = "Hello World Hello Hadoop Hello spark kafka hive zookeeper hbase flume sqoop hello scala".split("\\s+").map(_.toLowerCase).zipWithIndex
val arrRDD = ssc.sparkContext.makeRDD(arr).map { case (k, v) => v + " " + k }
val constantDStream = new ConstantInputDStream[String](ssc, arrRDD)
// 黑名單
val blackList = Array(("spark", true), ("scala", false), ("hbase", true), ("hello", true))
.filter { case (_, v) => v }
.map { case (k, _) => k }
constantDStream.map(line => (line.split("\\s+")(1), line))
.filter { case (word, _) => !blackList.contains(word) }
.map { case (_, line) => line }
.print()
ssc.start()
ssc.awaitTermination()
}
}
有狀態轉換
有狀態轉化的主要有兩種操作:窗口操作、狀態跟蹤操作
窗口操作
Window Operations 可以設計窗口大小 和 滑動窗口間隔。來動態的獲取當前Streaming的狀態。基于窗口的操作會在一個比StreamingContext和batchDuration(批次間隔)更長的時間范圍內,通過整合多個批次的結果,計算整個窗口的結果
基于窗口的操作需要兩個參數:
- 窗口長度(windowDuration)。控制每次計算最近的多少個批次的數據
- 滑動間隔(slideDuration)。用來控制對新的DStream進行計算的間隔
兩者都必須是StreamingContext中批次間隔(batchDuration)的整數倍
每秒發送一個數字:
package com.hhb.spark.streaming
import java.io.PrintWriter
import java.net.ServerSocket
/**
* @description:
* @date: 2020-11-20 14:31
**/
object NumSocket {
def main(args: Array[String]): Unit = {
val server = new ServerSocket(9999)
val socket = server.accept()
var i = 0
println("服務注冊:" + socket.getInetAddress)
while (true) {
i += 1
val writer = new PrintWriter(socket.getOutputStream)
writer.println(i)
writer.flush()
Thread.sleep(1000)
}
}
}
案例一:
觀察窗口的數據
觀察batchDuration、windowDuration、slideDuration三者之間的關系
使用窗口相關的操作
package com.hhb.spark.streaming
import org.apache.spark.SparkConf
import org.apache.spark.streaming.{Seconds, StreamingContext}
/**
* @description:
* @date: 2020-11-20 14:31
**/
object WindowDemo {
def main(args: Array[String]): Unit = {
//初始化
val conf = new SparkConf().setMaster("local[*]").setAppName(this.getClass.getCanonicalName.init)
//每5秒生成一個RDD
val ssc = new StreamingContext(conf, Seconds(5))
ssc.sparkContext.setLogLevel("error")
val lines = ssc.socketTextStream("localhost", 9999)
lines.foreachRDD { (rdd, time) =>
println(rdd.id + " time: " + time)
println("*" * 15)
rdd.foreach(x => print(x + "\t"))
}
// 窗口長度為20s,每隔10s滑動一次
lines.reduceByWindow(_ + " " + _, Seconds(20), Seconds(10))
.print()
println("=" * 15)
//對數據進行求和
lines.map(_.toInt).window(Seconds(20), Seconds(10)).reduce(_ + _).print()
//對數據進行求和
lines.map(_.toInt).reduceByWindow(_ + _, Seconds(20), Seconds(10)).print()
ssc.start()
ssc.awaitTermination()
}
}
案例二:熱點搜索詞實時統計。每隔 10 秒,統計最近20秒的詞出現的次數
package com.hhb.spark.streaming
import org.apache.spark.SparkConf
import org.apache.spark.streaming.{Seconds, StreamingContext}
/**
* @description:
* @author: huanghongbo
* @date: 2020-11-20 15:12
**/
object HotWordStats {
def main(args: Array[String]): Unit = {
//初始化StreamingContext
val conf = new SparkConf().setMaster("local[*]").setAppName(this.getClass.getCanonicalName.init)
val ssc = new StreamingContext(conf, Seconds(5))
ssc.sparkContext.setLogLevel("error")
// 通過reduceByKeyAndWindow算子, 每隔10秒統計最近20秒的詞出現的次數 ,后 3個參數:窗口時間長度、滑動窗口時間、分區
val wordDStream = ssc.socketTextStream("localhost", 9999).flatMap(_.split("\\s+")).map((_, 1))
wordDStream.reduceByKeyAndWindow((x: Int, y: Int) => x + y, Seconds(20), Seconds(10)).print()
println("*" * 20)
//設置檢查點,檢查點具有容錯機制。生產環境中應設置到HDFS
ssc.checkpoint("data/checkpoint/")
// 這里需要checkpoint的支持
wordDStream.reduceByKeyAndWindow(_ + _, _ - _, Seconds(20), Seconds(10)).print()
ssc.start()
ssc.awaitTermination()
}
}
狀態追蹤(updateStateByKey)
UpdateStateByKey的主要功能:
- 為Streaming中每一個key維護一份state狀態,state類型可以是任意類型的,可以是自定義對象;更新函數也可以是自定義的
- 通過更新函數對該key的狀態不斷更新,對于每個新的batch而言,Spark Streaming會在使用updateStateKey的時候為已經存在的key進行state的狀態更新
- 使用updateStateByKey時要開啟checkpoint功能
源碼:
/**
* Return a new "state" DStream where the state for each key is updated by applying
* the given function on the previous state of the key and the new values of each key.
* In every batch the updateFunc will be called for each state even if there are no new values.
* Hash partitioning is used to generate the RDDs with Spark's default number of partitions.
* @param updateFunc State update function. If `this` function returns None, then
* corresponding state key-value pair will be eliminated.
* @tparam S State type
*/
def updateStateByKey[S: ClassTag](
updateFunc: (Seq[V], Option[S]) => Option[S]
): DStream[(K, S)] = ssc.withScope {
updateStateByKey(updateFunc, defaultPartitioner())
}
流式程序啟動后計算wordcount的累計值,將每個批次的結果保存到文件
package com.hhb.spark.streaming
import org.apache.spark.SparkConf
import org.apache.spark.streaming.{Seconds, StreamingContext}
/**
* @description:
* @author: huanghongbo
* @date: 2020-11-20 17:56
**/
object StateTracker1 {
def main(args: Array[String]): Unit = {
val conf = new SparkConf().setMaster("local[*]").setAppName(this.getClass.getCanonicalName.init)
val ssc = new StreamingContext(conf, Seconds(5))
ssc.sparkContext.setLogLevel("error")
ssc.checkpoint("data/checkpoint")
//準備數據
val wordDStream = ssc.socketTextStream("localhost", 9999).flatMap(_.split("\\s+")).map((_, 1))
// def updateStateByKey[S: ClassTag](updateFunc: (Seq[V]value的類型, Option[S]) => Option[S] 返回值
//定義狀態更新函數
//函數常量定義,返回值類型是Some(Int),表示的含義是最新的狀態
//函數的功能是將當前時間間隔產生的key的value的集合,加到上一個狀態,得到最新狀態
val updateFunc = (currVale: Seq[Int], preValue: Option[Int]) => {
//通過Spark內部的reduceByKey按key規約,然后這里傳入某key當前批次的Seq,在計算當前批次的總和
val currSum = currVale.sum
//以前已經累加的值
val preSum = preValue.getOrElse(0)
Option(currSum + preSum)
}
val resultDStream = wordDStream.updateStateByKey[Int](updateFunc)
resultDStream.cache()
resultDStream.print()
// 把DStream保存到文本文件中,會生成很多的小文件。一個批次生成一個目錄
resultDStream.repartition(1).saveAsTextFiles("data/output1/")
ssc.start()
ssc.awaitTermination()
}
}
統計全局的key的狀態,但是就算沒有數據輸入,也會在每一個批次的時候返回之前的key的狀態。這樣的確定:如果數據量很大的話,checkpoint數據會占用較大的存儲,而且效率也不高。
mapWithState:也是用于全局統計key的狀態,如果沒有數據輸入,便不會返回之前key的狀態,有一點增量的感覺,這樣做的好處是,只關心那些已經發生變化的key,對于沒有數據輸入,則不會返回那些沒有變化的key的數據,即使數據量很大,checkpoint也不會像updateStateByKey那樣,占用太多的存儲
package com.hhb.spark.streaming
import org.apache.spark.SparkConf
import org.apache.spark.streaming.{Seconds, State, StateSpec, StreamingContext}
/**
* @description:
* @author: huanghongbo
* @date: 2020-11-20 17:56
**/
object StateTracker2 {
def main(args: Array[String]): Unit = {
val conf = new SparkConf().setMaster("local[*]").setAppName(this.getClass.getCanonicalName.init)
val ssc = new StreamingContext(conf, Seconds(5))
ssc.sparkContext.setLogLevel("error")
ssc.checkpoint("data/checkpoint")
//準備數據
val wordDStream = ssc.socketTextStream("localhost", 9999).flatMap(_.split("\\s+")).map((_, 1))
//(KeyType, Option[ValueType], State[StateType]) => MappedType
//(key的類型,輸入的數據,中間匯總)=> 返回值類型
def func(key: String, option: Option[Int], state: State[Int]): (String, Int) = {
val sum = option.getOrElse(0) + state.getOption().getOrElse(0)
state.update(sum)
(key, sum)
}
val spec = StateSpec.function(func _)
//只統計本批次出現的數據,不會把所有數據匯總
val resultDStream = wordDStream.mapWithState(spec)
//.stateSnapshots() 以快照的形式,把所有的數據都顯示出來,結果類似于updateStateByKey
.stateSnapshots()
resultDStream.cache()
resultDStream.print()
// 把DStream保存到文本文件中,會生成很多的小文件。一個批次生成一個目錄
resultDStream.repartition(1).saveAsTextFiles("data/output2/")
ssc.start()
ssc.awaitTermination()
}
}
總結:mapWithState按照時間線在每一個批次間隔返回之前的發生改變的或者新的key的狀態,不發生變化的不返回;updateStateByKey統計的是全局Key的狀態,就算沒有數據輸入也會在每個批次的時候返回之前的Key的狀態。也就是說,mapWithState統計的是一個批次的數據,updateStateByKey統計的是全局的key
DStream 輸出操作
輸出操作定義 DStream 的輸出操作。與 RDD 中的惰性求值類似,如果一個DStream及其派生出的 DStream 都沒有被執行輸出操作,那么這些 DStream 就都不會被求值。如果 StreamingContext 中沒有設定輸出操作,整個流式作業不會啟動。
Output Operation | Meaning |
---|---|
print() | 在運行流程序的Driver上,輸出DStream中每一批次數據最開始的10個元素,用于開發和調試 |
saveAsTestFile(prefix,[suffix]) | 以text文件形成存儲DStream的內容,每一批次的存儲文件名基于參數中的prefix和suffix |
saveAsObjectFiles(prefix,[suffix]) | 以 Java 對象序列化的方式將Stream中的數據保 存為 Sequence Files。每一批次的存儲文件名基 于參數中的為"prefix-TIME_IN_MS[.suffix]" |
saveAsHadoopFiles(prefix, [suffix]) | 將Stream中的數據保存為 Hadoop files。每一批 次的存儲文件名基于參數中的為"prefix- TIME_IN_MS[.suffix]" |
foreachRDD(func) | 最通用的輸出操作。將函數 func 應用于 DStream 的每一個RDD上 |
通用的輸出操作foreachRDD,用來對DStream中的RDD進行任意計算,在foreachRDD中,可以重用Spark RDD 中所有的Action操作,需要注意的是:
- 鏈接不要定義在Driver
- 鏈接定義在RDD的foreach算子中,則遍歷RDD的每個元素時都創建鏈接,得不償失
- 應該在RDD的foreachPartition中定義鏈接,每個分區創建一個鏈接
- 可以考慮使用連接池
與Kafka整合
針對不同的spark、kafka版本,集成處理數據的方式分為兩種:Receiver Approach 和Direct Approach,不同集成版本處理方式的支持,可參考下圖:
對Kafka的支持分為兩個版本08(在高版本中將被廢棄)、010,兩個版本不兼容。
Kafka-08接口
Receiver based Approach
基于Receiver的方式使用kafka舊版消費者高階API實現。對于所有的Receiver,通過Kafka接收的數據被存儲于Spark的Executors上,底層是寫入BlockManager中,默認200ms生成一個block(spark.streaming.blockInterval)。然后由spark Streaming提交的job構建BlockRDD,最終以Spark Core任務的形式運行,對應的Receiver方式,有以下幾點需要注意:
- Receiver 作為一個常駐線程調度到Executor上運行,占用一個CPU
- Receiver個數由KafkaUtil.createStream調用次數來決定,一次一個Receiver。
- Kafka中的topic分區并不能關聯產生在spark streaming中的rdd分區,增加在KafkaUtils.createStream()中的指定的topic分區數,僅僅增加了單個receiver消費的topic線程數,他不會正價處理數據中并行的Spark數量【即:topicMap[topic,num_threads]中,value對應的數值應該是每個topic對應的消費線程數】
- receiver默認200ms生成一個block,可根據數據量大小調整block生成周期,一個block對應RDD分區。
- receiver接收的數據會放入到BlockManager,每個Executor都會有一個BlockManager實例,由于數據本地性,那些存在Receiver的Executor會被調度執行更多的Task,就會導致某些executor比較空閑
- 默認情況下,Receiver是可能丟失數據的,可以通過設置spark.streaming.receiver.writeAheadLog.enable為true開啟預寫日志機制,將數據先寫入到一個可靠的分布式文件系統(如HDFS),確保數據不丟失,但會損失一定性能。
Kafka-08接口(Receiver方式):
- Offset保持在ZK中,系統管理
- 對應kafka的版本0.8.2.1+
- 接口底層實現使用Kafka舊版消費者高階API
- DStream底層實現為BlockRDD
Kafka-08接口(Receiver with WAL)
- 增強了故障恢復的能力
- 接收的數據與Dirver的元數據保存到HDFS
- 增加了流式應用處理的延遲
Direct Approach
Direct Approach是 Spark Streaming不使用Receiver集成kafka的方式,在企業生產環境中使用較多。相較于Receiver,有以下特點:
不使用Receiver。減少不必要的CPU占用,減少了Receiver接收數據寫入BlockManager,然后運行時再通過BlockId、網絡傳輸、磁盤讀取等來獲取數據的整個過程,提高了效率;無需WAL,進一步減少磁盤IO
-
Direct方式生成了RDD時KafkaRDD,他的分區數與Kafka分區數保存一致,便于控制并行度
注意:在Shuffle或Repartition操作后生成的RDD,這種對應關系會失效
-
可以手動維護offset,實現Exactly once 語義(處理僅處理一次)
Direct Approach.png
Kafka-010接口
Spark Streaming與kafka 0.10的整合,和0.8版本的 Direct 方式很像。Kafka的分區 和Spark的RDD分區是一一對應的,可以獲取 offsets 和元數據,API 使用起來沒有 顯著的區別。
添加依賴:
<!-- sparkStreaming 結合 kafka -->
<dependency>
<groupId>org.apache.spark</groupId>
<artifactId>spark-streaming-kafka-0-10_2.12</artifactId>
<version>${spark.version}</version>
</dependency>
不要手動添加 org.apache.kafka 相關的依賴,如kafka-clients。spark-streaming- kafka-0-10已經包含相關的依賴了,不同的版本會有不同程度的不兼容。
使用kafka010接口從Kafka中獲取數據
- Kafka集群
- Kakfa生產者發送數據
- Spark Streaming程序接收數據
package com.hhb.spark.streaming.kafka
import java.util.Properties
import org.apache.kafka.clients.producer.{KafkaProducer, ProducerConfig, ProducerRecord}
import org.apache.kafka.common.serialization.StringSerializer
/**
* @description:
* @author: huanghongbo
* @date: 2020-11-23 11:08
**/
object KafkaProducer {
def main(args: Array[String]): Unit = {
val brokers = "linux121:9092,linux122:9092,linux123:9092"
val topic = "topicA"
val prop = new Properties()
prop.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, brokers)
prop.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, classOf[org.apache.kafka.common.serialization.StringSerializer])
prop.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, classOf[org.apache.kafka.common.serialization.StringSerializer])
// 創建生產者
val producer = new KafkaProducer[String, String](prop)
//向生產者里面發送信息
for (i <- 0 to 1000000) {
val record = new ProducerRecord[String, String](topic, i.toString, i.toString)
producer.send(record)
println(s"i -> $i")
Thread.sleep(100)
}
producer.close()
}
}
消費:
package com.hhb.spark.streaming.kafka
import org.apache.kafka.clients.consumer.ConsumerConfig
import org.apache.kafka.common.serialization.StringDeserializer
import org.apache.spark.SparkConf
import org.apache.spark.streaming.kafka010.{ConsumerStrategies, KafkaUtils, LocationStrategies}
import org.apache.spark.streaming.{Seconds, StreamingContext}
/**
* @description:
* @author: huanghongbo
* @date: 2020-11-23 12:35
**/
object KafkaDStream1 {
def main(args: Array[String]): Unit = {
val conf = new SparkConf().setAppName(this.getClass.getCanonicalName.init).setMaster("local[*]")
val ssc = new StreamingContext(conf, Seconds(2))
ssc.sparkContext.setLogLevel("warn")
val topics = Array("topicA")
val groupId = "groupId"
//定義Kafka相關參數
val param: Map[String, Object] = getKafkaParam(groupId)
val kafkaDStream = KafkaUtils.createDirectStream(
ssc,
LocationStrategies.PreferConsistent,
ConsumerStrategies.Subscribe[String, String](topics, param)
)
kafkaDStream.foreachRDD((rdd, time) =>
println(s"rdd.count=>${rdd.count()},time=> ${time}")
)
ssc.start()
ssc.awaitTermination()
}
/**
* 定義Kafka相關參數
*
* @return
*/
def getKafkaParam(groupId: String): Map[String, Object] = {
Map[String, Object](
ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG -> "linux121:9092,linux122:9092,linux123:9092",
ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG -> classOf[org.apache.kafka.common.serialization.StringDeserializer],
ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG -> classOf[org.apache.kafka.common.serialization.StringDeserializer],
ConsumerConfig.GROUP_ID_CONFIG -> groupId,
ConsumerConfig.AUTO_OFFSET_RESET_CONFIG -> "earliest",
ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG -> (false: java.lang.Boolean)
)
}
}
LocationStrategies(本地策略)
- LocationStrategies.PreferBrokers :如果Executor在Kafka集群中的某些節點上,可以使用這種策略,那么Executor中的數據都會來之當前broker節點
- LocationStrategies.PreferConsistent: 大多數情況下使用的策略,將Kafka分區均勻分布在Spark集群的Executor上
- LocationStrategies.PreferFixed:如果節點之間的分區明顯分布不均,使用這種策略。通過一個Map指定將topic分區分布在哪些節點中
ConsumerStrategies(消費策略)
- ConsumerStrategies.Subscribe,用來訂閱一組Topic
- ConsumerStrategies.SubscribePattern,使用正則來指定感興趣的topic
- ConsumerStrategies.Assign,指定固定分區的集合
這三種策略都有重載構造函數,允許指定特定分區的起始偏移量;使用 Subscribe 或 SubscribePattern 在運行時能實現分區自動發現。
kafka相關命令:
# 創建Topic
kafka-topics.sh --create --zookeeper localhost:2181/myKafka --topic topicA --partitions 3 --replication-factor 3
# 顯示topic信息
kafka-topics.sh --zookeeper localhost:2181/myKafka --topic topicA --describe
# 檢查 topic 的最大offset
kafka-run-class.sh kafka.tools.GetOffsetShell --broker-list linux121:9092,linux122:9092,linux123:9092 --topic topicA --time -1
# 檢查消費者的offset
kafka-consumer-groups.sh --bootstrap-server linux121:9092 --describe --group groupId
# 重置消費者offset
kafka-consumer-groups.sh --bootstrap-server linux121:9092,linux122:9092,linux123:9092 --group groupId --reset-offsets --execute --to-offset 0 --topic topicA
Offset管理
Spark Streaming集成Kafka,允許Kafka中讀取一個或者多個topic數據,一個Kafka Topic包含一個或多個分區,每個分區中的消息順序存儲,并使用offset來標記消息的位置,開發者可以在SparkStreaming應用中通過offset來控制數據的讀取位置。
Offsets管理對于保證流式應用在整個生命周期中數據的連貫性是非常重要的,如果在應用停止或報錯退出之前沒有將offset持久化保存,該信息就會丟失,那么Spark Streaming就沒有辦法從上次停止或報錯的位置繼續消費Kafka中的消息。
獲取偏移量(Obtaining Offsets)
Spark Streaming與kafka整合時,允許獲取其消費的 offset ,具體方法如下:
package com.hhb.spark.streaming.kafka
import org.apache.kafka.clients.consumer.ConsumerConfig
import org.apache.spark.SparkConf
import org.apache.spark.streaming.kafka010.{ConsumerStrategies, HasOffsetRanges, KafkaUtils, LocationStrategies}
import org.apache.spark.streaming.{Seconds, StreamingContext}
/**
* @description:
* @author: huanghongbo
* @date: 2020-11-23 12:35
**/
object KafkaDStream1 {
def main(args: Array[String]): Unit = {
val conf = new SparkConf().setAppName(this.getClass.getCanonicalName.init).setMaster("local[*]")
val ssc = new StreamingContext(conf, Seconds(2))
ssc.sparkContext.setLogLevel("warn")
val topics = Array("topicA")
val groupId = "groupId"
//定義Kafka相關參數
val param: Map[String, Object] = getKafkaParam(groupId)
val kafkaDStream = KafkaUtils.createDirectStream(
ssc,
LocationStrategies.PreferConsistent,
ConsumerStrategies.Subscribe[String, String](topics, param)
)
//topic:topicA,partition:1,from: 0,to:90
//topic:topicA,partition:0,from: 0,to:122
//topic:topicA,partition:2,from: 0,to:98
//topic:topicA,partition:1,from: 90,to:90
//topic:topicA,partition:0,from: 122,to:122
//topic:topicA,partition:2,from: 98,to:98
kafkaDStream.foreachRDD { rdd =>
val ranges = rdd.asInstanceOf[HasOffsetRanges].offsetRanges
ranges.foreach { range =>
println(s"topic:${range.topic},partition:${range.partition},from: ${range.fromOffset},to:${range.untilOffset}")
}
}
// kafkaDStream.foreachRDD((rdd, time) =>
// println(s"rdd.count=>${rdd.count()},time=> ${time}")
// )
ssc.start()
ssc.awaitTermination()
}
/**
* 定義Kafka相關參數
*
* @return
*/
def getKafkaParam(groupId: String): Map[String, Object] = {
Map[String, Object](
ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG -> "linux121:9092,linux122:9092,linux123:9092",
ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG -> classOf[org.apache.kafka.common.serialization.StringDeserializer],
ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG -> classOf[org.apache.kafka.common.serialization.StringDeserializer],
ConsumerConfig.GROUP_ID_CONFIG -> groupId,
ConsumerConfig.AUTO_OFFSET_RESET_CONFIG -> "earliest",
ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG -> (false: java.lang.Boolean)
)
}
}
注意:對HasOffsetRanges的類型轉換只有在對createDirectStream調用的第一個方法中完成才會成功,而不是在隨后的方法鏈中。RDD分區和Kafka分區之間的對應關系在shuffle或重分區后丟失,如reduceByKey或Window。
存儲偏移量(Storing Offsets)
在Streaming 程序失敗的情況下,kafka交付語義取決于如何以及何時存儲偏移量。Spark 輸出操作的語義為 at-least-once
如果要失效EOS語義(Exactly Once Semantics),必須在冪等的輸出之后存儲偏移量或者將存儲偏移量與輸出放在一個事務中,可以安裝增加可靠性(和代碼復雜度)的順序使用一下選項來存儲偏移量
-
CheckPoint
CheckPoint是對Spark Steaming運行過程中的元數據和每個RDDs的數據狀態保存到一個持久化系統中,這里也包含了offset,一般是HDFS、S3,如果應用程序或者集群掛了,可以迅速恢復,如果Streamging 程序的代碼變了,重新打包執行就會出翔反序列化異常的問題。這是因為CheckPoint首次持久化時會講整個jar序列化,以便重啟時恢復,重新打包之后,新舊代碼邏輯不同,就會報錯或仍然執行舊版代碼。要解決這個問題,只能將HDFS上的checkpoint文件刪除,但是這樣也會刪除Kafka的offset信息
-
Kafka
默認情況下,消費者定期自動提交偏移量,它將偏移量存儲在一個特殊的Kafka主題中(__consumer_offsets)。但在某些情況下,這將導致問題,因為消息可能已經 被消費者從Kafka拉去出來,但是還沒被處理。可以將 enable.auto.commit 設置為 false ,在 Streaming 程序輸出結果之后,手動 提交偏移到kafka。與檢查點相比,使用Kafka保存偏移量的優點是無論應用程序代碼如何更改,偏移量 仍然有效。
stream.foreachRDD { rdd => val offsetRanges = rdd.asInstanceOf[HasOffsetRanges].offsetRanges // 在輸出操作完成之后,手工提交偏移量;此時將偏移量提交到 Kafka 的消息隊列中 stream.asInstanceOf[CanCommitOffsets].commitAsync(offsetRanges) }
與HasOffsetRanges一樣,只有在createDirectStream的結果上調用時,轉換到 CanCommitOffsets才會成功,而不是在轉換之后。commitAsync調用是線程安全 的,但必須在輸出之后執行。
-
自定義存儲
Offsets可以通過多種方式來管理,但是一般來說遵循下面的步驟:
- 在 DStream 初始化的時候,需要指定每個分區的offset用于從指定位置讀取數據
- 讀取并處理消息
- 處理完之后存儲結果數據
- 用虛線圈存儲和提交offset,強調用戶可能會執行一系列操作來滿足他們更加嚴格的語義要求。這包括冪等操作和通過原子操作的方式存儲offset
- 將 offsets 保存在外部持久化數據庫如 HBase、Kafka、HDFS、ZooKeeper、 Redis、MySQL ... ...
可以將 Offsets 存儲到HDFS中,但這并不是一個好的方案。因為HDFS延遲有點高, 此外將每批次數據的offset存儲到HDFS中還會帶來小文件問題;
可以將 Offset 存儲到保存ZK中,但是將ZK作為存儲用,也并不是一個明智的選擇, 同時ZK也不適合頻繁的讀寫操作;
Redis管理的Offset
要想將Offset保存到外部存儲中,關鍵要實現以下幾個功能:
- Streaming程序啟動時,從外部存儲獲取保存的Offsets(執行一次)
- 在foreachRDD中,每個批次數據處理之后,更新外部存儲的offsets(多次執行)
案例一:使用自定義的offsets,從kafka讀數據;處理完數據后打印offsets
package com.hhb.spark.streaming.kafka
import org.apache.kafka.clients.consumer.ConsumerConfig
import org.apache.kafka.common.TopicPartition
import org.apache.kafka.common.serialization.StringDeserializer
import org.apache.spark.SparkConf
import org.apache.spark.streaming.kafka010.{ConsumerStrategies, HasOffsetRanges, KafkaUtils, LocationStrategies}
import org.apache.spark.streaming.{Seconds, StreamingContext}
/**
* @description: 使用自定義offset,從kafka讀數據,處理完數據后打印offsets
* @date: 2020-11-23 16:07
**/
object KafkaDStream2 {
def main(args: Array[String]): Unit = {
val conf = new SparkConf().setMaster("local[*]").setAppName(this.getClass.getCanonicalName.init)
val ssc = new StreamingContext(conf, Seconds(2))
ssc.sparkContext.setLogLevel("warn")
val topics = Array("topicA")
val groupId = "groupId"
val param: Map[String, Object] = getKafkaMap(groupId)
val offsets: Map[TopicPartition, Long] = Map(
new TopicPartition(topics(0), 0) -> 50,
new TopicPartition(topics(0), 1) -> 60,
new TopicPartition(topics(0), 2) -> 80,
)
val kafkaDStream = KafkaUtils.createDirectStream(
ssc,
LocationStrategies.PreferConsistent,
ConsumerStrategies.Subscribe[String, String](topics, param, offsets)
)
kafkaDStream.foreachRDD { (rdd, times) =>
println(s"rdd.count = ${rdd.count()},time=>${times}")
val ranges = rdd.asInstanceOf[HasOffsetRanges].offsetRanges
ranges.foreach { range =>
println(s"partition=>${range.partition},topic => ${range.topic},from=> ${range.fromOffset} ,to=>${range.untilOffset}")
}
}
ssc.start()
ssc.awaitTermination()
}
def getKafkaMap(groupId: String): Map[String, Object] = {
Map[String, Object](
ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG -> "linux121:9092,linux122:9092,linux123:9092",
ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG -> classOf[StringDeserializer],
ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG -> classOf[StringDeserializer],
ConsumerConfig.GROUP_ID_CONFIG -> groupId,
ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG -> (false: java.lang.Boolean)
)
}
}
案例二:根據 key 從 Redis 獲取offsets,根據該offsets從kafka讀數據;處理完數據 后將offsets保存到 Redis
Redis管理的Offsets:
1、數據結構選擇:Hash;key、field、value
Key:kafka:topic:TopicName:groupid
Field:partition
Value:offset
2、從 Redis 中獲取保存的offsets
3、消費數據后將offsets保存到redis
工具類(Redis讀取、保存offset)
package com.hhb.spark.streaming.kafka
import java.util
import java.util.{HashSet, Set}
import org.apache.kafka.common.TopicPartition
import org.apache.spark.streaming.kafka010.OffsetRange
import redis.clients.jedis.{HostAndPort, Jedis, JedisCluster, JedisPool, JedisPoolConfig}
/**
* @description:
* @author: huanghongbo
* @date: 2020-11-23 16:59
**/
object OffsetsRedisUtils {
private val config = new JedisPoolConfig
val jedisClusterNode: util.Set[HostAndPort] = new util.HashSet[HostAndPort]
jedisClusterNode.add(new HostAndPort("59.110.241.53", 7001))
jedisClusterNode.add(new HostAndPort("59.110.241.53", 7002))
jedisClusterNode.add(new HostAndPort("59.110.241.53", 7003))
jedisClusterNode.add(new HostAndPort("59.110.241.53", 7004))
jedisClusterNode.add(new HostAndPort("59.110.241.53", 7005))
jedisClusterNode.add(new HostAndPort("59.110.241.53", 7006))
jedisClusterNode.add(new HostAndPort("59.110.241.53", 7007))
//最大鏈接
config.setMaxTotal(30)
//最大空閑
config.setMaxIdle(10)
private val topicKeyPrefix = "kafka:topic"
private def getJcd() = new JedisCluster(jedisClusterNode, config)
//獲取key信息
private def getKey(topic: String, groupId: String): String = {
topicKeyPrefix + ":" + topic + ":" + groupId
}
def getOffset(topics: Array[String], groupId: String): Map[TopicPartition, Long] = {
val cluster = getJcd()
val result = topics.map { topic =>
import scala.collection.JavaConverters._
//獲取該topic、groupId下所有的信息
cluster.hgetAll(getKey(topic, groupId))
//轉換成scala
.asScala
//再次遍歷,將分區,offset 轉換成(new TopicPartition(topic, k.toInt), v.toLong)結構
.map { case (k, v) => (new TopicPartition(topic, k.toInt), v.toLong) }
}
cluster.close()
//壓平數據后轉map
result.flatten.toMap
}
//自己的方法
def saveOffset(ranges: Array[OffsetRange], groupId: String): Unit = {
val cluster = getJcd()
ranges.foreach { range =>
val key = getKey(range.topic, groupId)
println(s"key=>${key},part=>${range.partition.toString},offset=>${range.untilOffset.toString}")
cluster.hset(key, range.partition.toString, range.untilOffset.toString)
}
cluster.close()
}
//老師的方法
def saveOffset2(ranges: Array[OffsetRange], groupId: String): Unit = {
val cluster = getJcd()
val result: Map[String, Array[(String, String)]] = ranges.map(range => (range.topic, (range.partition.toString -> range.untilOffset.toString)))
.groupBy(_._1)
.map { case (topic, buffer) => (topic, buffer.map(_._2)) }
result.map { r =>
import scala.collection.JavaConverters._
val offsets = r._2.toMap.asJava
cluster.hmset(getKey(r._1, groupId), offsets)
}
}
}
KafkaDStream(從kafka獲取數據,使用 Redis 保存offsets)
package com.hhb.spark.streaming.kafka
import org.apache.kafka.clients.consumer.ConsumerConfig
import org.apache.kafka.common.TopicPartition
import org.apache.kafka.common.serialization.StringDeserializer
import org.apache.spark.SparkConf
import org.apache.spark.streaming.{Seconds, StreamingContext}
import org.apache.spark.streaming.kafka010.{ConsumerStrategies, HasOffsetRanges, KafkaUtils, LocationStrategies}
/**
* @description:
* @author: huanghongbo
* @date: 2020-11-23 16:59
**/
object KafkaDStream3 {
def main(args: Array[String]): Unit = {
val conf = new SparkConf().setMaster("local[*]").setAppName(this.getClass.getCanonicalName.init)
val ssc = new StreamingContext(conf, Seconds(2))
ssc.sparkContext.setLogLevel("warn")
val topics = Array("topicA")
val groupId = "groupId"
val param: Map[String, Object] = getKafkaMap(groupId)
val offsets: Map[TopicPartition, Long] = OffsetsRedisUtils.getOffset(topics, groupId)
val kafkaDStream = KafkaUtils.createDirectStream(
ssc,
LocationStrategies.PreferConsistent,
ConsumerStrategies.Subscribe[String, String](topics, param, offsets)
)
kafkaDStream.foreachRDD { (rdd, times) =>
if(!rdd.isEmpty()){
//數據處理邏輯,這里只是輸出
println(s"rdd.count = ${rdd.count()},time=>${times}")
val ranges = rdd.asInstanceOf[HasOffsetRanges].offsetRanges
OffsetsRedisUtils.saveOffset(ranges, groupId)
}
}
ssc.start()
ssc.awaitTermination()
}
def getKafkaMap(groupId: String): Map[String, Object] = {
Map[String, Object](
ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG -> "linux121:9092,linux122:9092,linux123:9092",
ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG -> classOf[StringDeserializer],
ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG -> classOf[StringDeserializer],
ConsumerConfig.GROUP_ID_CONFIG -> groupId,
ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG -> (false: java.lang.Boolean)
)
}
}