本文主要介紹 Spark Streaming 應用開發中消費 Kafka 訊息的相關內容,文章著重突出了開發環境的配置以及手動管理 Kafka 偏移量的實現。
一、開發環境
1、元件版本
- CDH 叢集版本:6.0.1
- Spark 版本:2.2.0
- Kafka 版本:1.0.1
2、Maven 依賴
<!-- scala -->
<dependency>
<groupId>org.scala-lang</groupId>
<artifactId>scala-library</artifactId>
<version>2.11.8</version>
</dependency>
<!-- spark 基礎依賴 -->
<dependency>
<groupId>org.apache.spark</groupId>
<artifactId>spark-core_2.11</artifactId>
<version>2.2.0</version>
</dependency>
<!-- spark-streaming 相關依賴 -->
<dependency>
<groupId>org.apache.spark</groupId>
<artifactId>spark-streaming_2.11</artifactId>
<version>2.2.0</version>
</dependency>
<!-- spark-streaming-kafka 相關依賴 -->
<dependency>
<groupId>org.apache.spark</groupId>
<artifactId>spark-streaming-kafka-0-10_2.11</artifactId>
<version>2.2.0</version>
</dependency>
<!-- zookeeper 相關依賴 -->
<dependency>
<groupId>org.apache.zookeeper</groupId>
<artifactId>zookeeper</artifactId>
<version>3.4.5-cdh6.0.1</version>
</dependency>
複製程式碼
3、scala 編譯
在 pom.xml 的 build 節點下的 plugins 中新增 scala 編譯外掛
<plugin>
<groupId>org.scala-tools</groupId>
<artifactId>maven-scala-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>compile</goal>
<goal>testCompile</goal>
</goals>
</execution>
</executions>
<configuration>
<scalaVersion>${scala.version}</scalaVersion>
<args>
<arg>-target:jvm-1.5</arg>
</args>
</configuration>
</plugin>
複製程式碼
Maven 打包語句:
mvn clean scala:compile compile package
4、打包注意事項
由於 spark、spark-streaming、zookeeper 等均為大資料叢集中必備的元件,因此與之相關的依賴無需打包到最終的 jar 包中,可以將其 scope 設定為 provided 即可;否則最終的 jar 包會相當龐大。
二、Kafka 偏移量
1、偏移量(offset)
這裡的偏移量是指 kafka consumer offset,在 Kafka 0.9 版本之前消費者偏移量預設被儲存在 zookeeper 中(/consumers/<group.id>/offsets/<topic>/<partitionId>
),因此在初始化消費者的時候需要指定 zookeeper.hosts
。
隨著 Kafka consumer 在實際場景的不斷應用,社群發現舊版本 consumer 把位移提交到 ZooKeeper 的做法並不合適。ZooKeeper 本質上只是一個協調服務元件,它並不適合作為位移資訊的儲存元件,畢竟頻繁高併發的讀/寫操作並不是 ZooKeeper 擅長的事情。因此在 0.9 版本開始 consumer 將位移提交到 Kafka 的一個內部 topic(__consumer_offsets
)中,該主題預設有 50 個分割槽,每個分割槽 3 個副本。
2、訊息交付語義
- at-most-once:最多一次,訊息可能丟失,但不會被重複處理;
- at-least-once:至少一次,訊息不會丟失,但可能被處理多次;
- exactly-once:精確一次,訊息一定會被處理且只會被處理一次。
若 consumer 在訊息消費之前就提交位移,那麼便可以實現 at-most-once,因為若 consumer 在提交位移與訊息消費之間崩潰,則 consumer 重啟後會從新的 offset 位置開始消費,前面的那條訊息就丟失了;相反地,若提交位移在訊息消費之後,則可實現 at-least-once 語義。由於 Kafka 沒有辦法保證這兩步操作可以在同一個事務中完成,因此 Kafka 預設提供的就是 at-least-once 的處理語義。
3、offset 提交方式
預設情況下,consumer 是自動提交位移的,自動提交間隔是 5 秒,可以通過設定 auto.commit.interval.ms
引數可以控制自動提交的間隔。自動位移提交的優勢是降低了使用者的開發成本使得使用者不必親自處理位移提交;劣勢是使用者不能細粒度地處理位移的提交,特別是在有較強的精確一次處理語義時(在這種情況下,使用者可以使用手動位移提交)。
所謂的手動位移提交就是使用者自行確定訊息何時被真正處理完並可以提交位移,使用者可以確保只有訊息被真正處理完成後再提交位移。如果使用自動位移提交則無法保證這種時序性,因此在這種情況下必須使用手動提交位移。設定使用手動提交位移非常簡單,僅僅需要在構建 KafkaConsumer 時設定 enable.auto.commit=false
,然後呼叫 commitSync 或 commitAsync 方法即可。
三、使用 Zookeeper 管理 Kafka 偏移量
1、Zookeeper 管理偏移量的優勢
雖然說新版 kafka 中已經無需使用 zookeeper 管理偏移量了,但是使用 zookeeper 管理偏移量相比 kafka 自行管理偏移量有如下幾點好處:
- 可以使用 zookeeper 管理工具輕鬆檢視 offset 資訊;
- 無需修改 groupId 即可從頭讀取訊息;
- 特別情況下可以人為修改 offset 資訊。
藉助 zookeeper 管理工具可以對任何一個節點的資訊進行修改、刪除,如果希望從最開始讀取訊息,則只需要刪除 zk 某個節點的資料即可。
2、Zookeeper 偏移量管理實現
import org.I0Itec.zkclient.ZkClient
import org.apache.kafka.clients.consumer.ConsumerRecord
import org.apache.kafka.common.TopicPartition
import org.apache.spark.SparkConf
import org.apache.spark.streaming.StreamingContext
import org.apache.spark.streaming.kafka010.OffsetRange
import scala.collection.JavaConverters._
class ZkKafkaOffset(getClient: () => ZkClient, getZkRoot : () => String) {
// 定義為 lazy 實現了懶漢式的單例模式,解決了序列化問題,方便使用 broadcast
lazy val zkClient: ZkClient = getClient()
lazy val zkRoot: String = getZkRoot()
// offsetId = md5(groupId+join(topics))
// 初始化偏移量的 zk 儲存路徑 zkRoot
def initOffset(offsetId: String) : Unit = {
if(!zkClient.exists(zkRoot)){
zkClient.createPersistent(zkRoot, true)
}
}
// 從 zkRoot 讀取偏移量資訊
def getOffset(): Map[TopicPartition, Long] = {
val keys = zkClient.getChildren(zkRoot)
var initOffsetMap: Map[TopicPartition, Long] = Map()
if(!keys.isEmpty){
for (k:String <- keys.asScala) {
val ks = k.split("!")
val value:Long = zkClient.readData(zkRoot + "/" + k)
initOffsetMap += (new TopicPartition(ks(0), Integer.parseInt(ks(1))) -> value)
}
}
initOffsetMap
}
// 根據單條訊息,更新偏移量資訊
def updateOffset(consumeRecord: ConsumerRecord[String, String]): Boolean = {
val path = zkRoot + "/" + consumeRecord.topic + "!" + consumeRecord.partition
zkClient.writeData(path, consumeRecord.offset())
true
}
// 消費訊息前,批量更新偏移量資訊
def updateOffset(offsetRanges: Array[OffsetRange]): Boolean = {
for (offset: OffsetRange <- offsetRanges) {
val path = zkRoot + "/" + offset.topic + "!" + offset.partition
if(!zkClient.exists(path)){
zkClient.createPersistent(path, offset.fromOffset)
}
else{
zkClient.writeData(path, offset.fromOffset)
}
}
true
}
// 消費訊息後,批量提交偏移量資訊
def commitOffset(offsetRanges: Array[OffsetRange]): Boolean = {
for (offset: OffsetRange <- offsetRanges) {
val path = zkRoot + "/" + offset.topic + "!" + offset.partition
if(!zkClient.exists(path)){
zkClient.createPersistent(path, offset.untilOffset)
}
else{
zkClient.writeData(path, offset.untilOffset)
}
}
true
}
def finalize(): Unit = {
zkClient.close()
}
}
object ZkKafkaOffset{
def apply(cong: SparkConf, offsetId: String): ZkKafkaOffset = {
val getClient = () =>{
val zkHost = cong.get("kafka.zk.hosts", "127.0.0.1:2181")
new ZkClient(zkHost, 30000)
}
val getZkRoot = () =>{
val zkRoot = "/kafka/ss/offset/" + offsetId
zkRoot
}
new ZkKafkaOffset(getClient, getZkRoot)
}
}
複製程式碼
3、Spark Streaming 消費 Kafka 訊息
import scala.collection.JavaConverters._
object RtDataLoader {
def main(args: Array[String]): Unit = {
// 從配置檔案讀取 kafka 配置資訊
val props = new Props("xxx.properties")
val groupId = props.getStr("groupId", "")
if(StrUtil.isBlank(groupId)){
StaticLog.error("groupId is empty")
return
}
val kfkServers = props.getStr("kfk_servers")
if(StrUtil.isBlank(kfkServers)){
StaticLog.error("bootstrap.servers is empty")
return
}
val topicStr = props.getStr("topics")
if(StrUtil.isBlank(kfkServers)){
StaticLog.error("topics is empty")
return
}
// KAFKA 配置設定
val topics = topicStr.split(",")
val kafkaConf = Map[String, Object](
"bootstrap.servers" -> kfkServers,
"key.deserializer" -> classOf[StringDeserializer],
"value.deserializer" -> classOf[StringDeserializer],
"group.id" -> groupId,
"receive.buffer.bytes" -> (102400: java.lang.Integer),
"max.partition.fetch.bytes" -> (5252880: java.lang.Integer),
"auto.offset.reset" -> "earliest",
"enable.auto.commit" -> (false: java.lang.Boolean)
)
val conf = new SparkConf().setAppName("ss-kafka").setIfMissing("spark.master", "local[2]")
// streaming 相關配置
conf.set("spark.streaming.stopGracefullyOnShutdown","true")
conf.set("spark.streaming.backpressure.enabled","true")
conf.set("spark.streaming.backpressure.initialRate","1000")
// 設定 zookeeper 連線資訊
conf.set("kafka.zk.hosts", props.getStr("zk_hosts", "sky-01:2181"))
// 建立 StreamingContext
val sc = new SparkContext(conf)
sc.setLogLevel("WARN")
val ssc = new StreamingContext(sc, Seconds(5))
// 根據 groupId 和 topics 獲取 offset
val offsetId = SecureUtil.md5(groupId + topics.mkString(","))
val kafkaOffset = ZkKafkaOffset(ssc.sparkContext.getConf, offsetId)
kafkaOffset.initOffset(ssc, offsetId)
val customOffset: Map[TopicPartition, Long] = kafkaOffset.getOffset(ssc)
// 建立資料流
var stream:InputDStream[ConsumerRecord[String, String]] = null
if(topicStr.contains("*")) {
StaticLog.warn("使用正則匹配讀取 kafka 主題:" + topicStr)
stream = KafkaUtils.createDirectStream[String, String](ssc,
LocationStrategies.PreferConsistent,
ConsumerStrategies.SubscribePattern[String, String](Pattern.compile(topicStr), kafkaConf, customOffset))
}
else {
StaticLog.warn("待讀取的 kafka 主題:" + topicStr)
stream = KafkaUtils.createDirectStream[String, String](ssc,
LocationStrategies.PreferConsistent,
ConsumerStrategies.Subscribe[String, String](topics, kafkaConf, customOffset))
}
// 消費資料
stream.foreachRDD(rdd => {
// 訊息消費前,更新 offset 資訊
val offsetRanges = rdd.asInstanceOf[HasOffsetRanges].offsetRanges
kafkaOffset.updateOffset(offsetRanges)
//region 處理詳情資料
StaticLog.info("開始處理 RDD 資料!")
//endregion
// 訊息消費結束,提交 offset 資訊
kafkaOffset.commitOffset(offsetRanges)
})
ssc.start()
ssc.awaitTermination()
}
}
複製程式碼
4、注意事項
auto.offset.reset
對於 auto.offset.reset
個人推薦設定為 earliest,初次執行的時候,由於 __consumer_offsets
沒有相關偏移量資訊,因此訊息會從最開始的地方讀取;當第二次執行時,由於 __consumer_offsets
已經存在消費的 offset 資訊,因此會根據 __consumer_offsets
中記錄的偏移資訊繼續讀取資料。
此外,對於使用 zookeeper 管理偏移量而言,只需要刪除對應的節點,資料即可從頭讀取,也是非常方便。不過如果你希望從最新的地方讀取資料,不需要讀取舊訊息,則可以設定為 latest。
基於正則訂閱 Kafka 主題
基於正則訂閱主題,有以下好處:
- 無需羅列主題名,一兩個主題還好,如果有幾十個,羅列過於麻煩了;
- 可實現動態訂閱的效果(新增的符合正則的主題也會被讀取)。
stream = KafkaUtils.createDirectStream[String, String](ssc,
LocationStrategies.PreferConsistent,
ConsumerStrategies.SubscribePattern[String, String](Pattern.compile(topicStr), kafkaConf, customOffset))
複製程式碼
SparkStreaming 序列化問題
開發 SparkStreaming 程式的每個人都會遇到各種各樣的序列化問題,簡單來說:在 driver 中使用到的變數或者物件無需序列化,傳遞到 exector 中的變數或者物件需要序列化。因此推薦的做法是,在 exector 中最好只處理資料的轉換,在 driver 中對處理的結果進行儲存等操作。
stream.foreachRDD(rdd => {
// driver 程式碼執行區域
val offsetRanges = rdd.asInstanceOf[HasOffsetRanges].offsetRanges
kafkaOffset.updateOffset(offsetRanges)
// exector 程式碼執行區域
val resultRDD = rdd.map(xxxxxxxx)
//endregion
//對結果進行儲存
resultRDD.saveToES(xxxxxx)
kafkaOffset.commitOffset(offsetRanges)
})
複製程式碼
文中部分概念摘自《Kafka 實戰》,一本非常棒的書籍,推薦一下。
Any Code,Code Any!
掃碼關注『AnyCode』,程式設計路上,一起前行。