Spark streaming消費Kafka的正確姿勢

王知無發表於2019-03-26

前言

在遊戲專案中,需要對每天千萬級的遊戲評論資訊進行詞頻統計,在生產者一端,我們將資料按照每天的拉取時間存入了Kafka當中,而在消費者一端,我們利用了spark streaming從kafka中不斷拉取資料進行詞頻統計。本文首先對spark streaming嵌入kafka的方式進行歸納總結,之後簡單闡述Spark streaming+kafka在輿情專案中的應用,最後將自己在Spark Streaming+kafka的實際優化中的一些經驗進行歸納總結。(如有任何紕漏歡迎補充來踩,我會第一時間改正^v^)

Spark streaming接收Kafka資料

用spark streaming流式處理kafka中的資料,第一步當然是先把資料接收過來,轉換為spark streaming中的資料結構Dstream。接收資料的方式有兩種:1.利用Receiver接收資料,2.直接從kafka讀取資料。

基於Receiver的方式

這種方式利用接收器(Receiver)來接收kafka中的資料,其最基本是使用Kafka高階使用者API介面。對於所有的接收器,從kafka接收來的資料會儲存在spark的executor中,之後spark streaming提交的job會處理這些資料。如下圖:
Receiver圖形解釋
在使用時,我們需要新增相應的依賴包:

<dependency><!-- Spark Streaming Kafka -->
    <groupId>org.apache.spark</groupId>
    <artifactId>spark-streaming-kafka_2.10</artifactId>
    <version>1.6.3</version>
</dependency>複製程式碼

而對於Scala的基本使用方式如下:

import org.apache.spark.streaming.kafka._

 val kafkaStream = KafkaUtils.createStream(streamingContext, 
     [ZK quorum], [consumer group id], [per-topic number of Kafka partitions to consume])複製程式碼

還有幾個需要注意的點:

  • 在Receiver的方式中,Spark中的partition和kafka中的partition並不是相關的,所以如果我們加大每個topic的partition數量,僅僅是增加執行緒來處理由單一Receiver消費的主題。但是這並沒有增加Spark在處理資料上的並行度。
  • 對於不同的Group和topic我們可以使用多個Receiver建立不同的Dstream來並行接收資料,之後可以利用union來統一成一個Dstream。
  • 如果我們啟用了Write Ahead Logs複製到檔案系統如HDFS,那麼storage level需要設定成 StorageLevel.MEMORY_AND_DISK_SER,也就是KafkaUtils.createStream(..., StorageLevel.MEMORY_AND_DISK_SER)

直接讀取方式

在spark1.3之後,引入了Direct方式。不同於Receiver的方式,Direct方式沒有receiver這一層,其會週期性的獲取Kafka中每個topic的每個partition中的最新offsets,之後根據設定的maxRatePerPartition來處理每個batch。其形式如下圖:
Spark streaming消費Kafka的正確姿勢
這種方法相較於Receiver方式的優勢在於:

  • 簡化的並行:在Receiver的方式中我們提到建立多個Receiver之後利用union來合併成一個Dstream的方式提高資料傳輸並行度。而在Direct方式中,Kafka中的partition與RDD中的partition是一一對應的並行讀取Kafka資料,這種對映關係也更利於理解和優化。
  • 高效:在Receiver的方式中,為了達到0資料丟失需要將資料存入Write Ahead Log中,這樣在Kafka和日誌中就儲存了兩份資料,浪費!而第二種方式不存在這個問題,只要我們Kafka的資料保留時間足夠長,我們都能夠從Kafka進行資料恢復。
  • 精確一次:在Receiver的方式中,使用的是Kafka的高階API介面從Zookeeper中獲取offset值,這也是傳統的從Kafka中讀取資料的方式,但由於Spark Streaming消費的資料和Zookeeper中記錄的offset不同步,這種方式偶爾會造成資料重複消費。而第二種方式,直接使用了簡單的低階Kafka API,Offsets則利用Spark Streaming的checkpoints進行記錄,消除了這種不一致性。

以上主要是對官方文件[1]的一個簡單翻譯,詳細內容大家可以直接看下官方文件這裡不再贅述。

不同於Receiver的方式,是從Zookeeper中讀取offset值,那麼自然zookeeper就儲存了當前消費的offset值,那麼如果重新啟動開始消費就會接著上一次offset值繼續消費。而在Direct的方式中,我們是直接從kafka來讀資料,那麼offset需要自己記錄,可以利用checkpoint、資料庫或檔案記錄或者回寫到zookeeper中進行記錄。
示例中KafkaManager是一個通用類,而KafkaCluster是kafka原始碼中的一個類,由於包名許可權的原因我把它單獨提出來,ComsumerMain簡單展示了通用類的使用方法,在每次建立KafkaStream時,都會先從zooker中檢視上次的消費記錄offsets,而每個batch處理完成後,會同步offsets到zookeeper中。

Spark向kafka中寫入資料

上文闡述了Spark如何從Kafka中流式的讀取資料,下面我整理向Kafka中寫資料。與讀資料不同,Spark並沒有提供統一的介面用於寫入Kafka,所以我們需要使用底層Kafka介面進行包裝。
最直接的做法我們可以想到如下這種方式:

input.foreachRDD(rdd =>
  // 不能在這裡建立KafkaProducer
  rdd.foreachPartition(partition =>
    partition.foreach{
      case x:String=>{
        val props = new HashMap[String, Object]()
        props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, brokers)
        props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG,
          "org.apache.kafka.common.serialization.StringSerializer")
        props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG,
          "org.apache.kafka.common.serialization.StringSerializer")
        println(x)
        val producer = new KafkaProducer[String,String](props)
        val message=new ProducerRecord[String, String]("output",null,x)
        producer.send(message)
      }
    }
  )
) 複製程式碼

但是這種方式缺點很明顯,對於每個partition的每條記錄,我們都需要建立KafkaProducer,然後利用producer進行輸出操作,注意這裡我們並不能將KafkaProducer的新建任務放在foreachPartition外邊,因為KafkaProducer是不可序列化的(not serializable)。顯然這種做法是不靈活且低效的,因為每條記錄都需要建立一次連線。如何解決呢?

  1. 首先,我們需要將KafkaProducer利用lazy val的方式進行包裝如下:
import java.util.concurrent.Future
import org.apache.kafka.clients.producer.{ KafkaProducer, ProducerRecord, RecordMetadata }
class KafkaSink[K, V](createProducer: () => KafkaProducer[K, V]) extends Serializable {
  /* This is the key idea that allows us to work around running into
     NotSerializableExceptions. */
  lazy val producer = createProducer()
  def send(topic: String, key: K, value: V): Future[RecordMetadata] =
    producer.send(new ProducerRecord[K, V](topic, key, value))
  def send(topic: String, value: V): Future[RecordMetadata] =
    producer.send(new ProducerRecord[K, V](topic, value))
}

object KafkaSink {
  import scala.collection.JavaConversions._
  def apply[K, V](config: Map[String, Object]): KafkaSink[K, V] = {
    val createProducerFunc = () => {
      val producer = new KafkaProducer[K, V](config)
      sys.addShutdownHook {
        // Ensure that, on executor JVM shutdown, the Kafka producer sends
        // any buffered messages to Kafka before shutting down.
        producer.close()
      }
      producer
    }
    new KafkaSink(createProducerFunc)
  }
  def apply[K, V](config: java.util.Properties): KafkaSink[K, V] = apply(config.toMap)
}複製程式碼
  1. 之後我們利用廣播變數的形式,將KafkaProducer廣播到每一個executor,如下:
// 廣播KafkaSink
val kafkaProducer: Broadcast[KafkaSink[String, String]] = {
  val kafkaProducerConfig = {
    val p = new Properties()
    p.setProperty("bootstrap.servers", Conf.brokers)
    p.setProperty("key.serializer", classOf[StringSerializer].getName)
    p.setProperty("value.serializer", classOf[StringSerializer].getName)
    p
  }
  log.warn("kafka producer init done!")
  ssc.sparkContext.broadcast(KafkaSink[String, String](kafkaProducerConfig))
}複製程式碼

這樣我們就能在每個executor中愉快的將資料輸入到kafka當中:

//輸出到kafka
segmentedStream.foreachRDD(rdd => {
  if (!rdd.isEmpty) {
    rdd.foreach(record => {
      kafkaProducer.value.send(Conf.outTopics, record._1.toString, record._2)
      // do something else
    })
  }
})複製程式碼

Spark streaming+Kafka應用

WeTest輿情監控對於每天爬取的千萬級遊戲玩家評論資訊都要實時的進行詞頻統計,對於爬取到的遊戲玩家評論資料,我們會生產到Kafka中,而另一端的消費者我們採用了Spark Streaming來進行流式處理,首先利用上文我們闡述的Direct方式從Kafka拉取batch,之後經過分詞、統計等相關處理,回寫到DB上(至於Spark中DB的回寫方式可參考我之前總結的博文:Spark踩坑記——資料庫(Hbase+Mysql)),由此高效實時的完成每天大量資料的詞頻統計任務。

Spark streaming+Kafka調優

Spark streaming+Kafka的使用中,當資料量較小,很多時候預設配置和使用便能夠滿足情況,但是當資料量大的時候,就需要進行一定的調整和優化,而這種調整和優化本身也是不同的場景需要不同的配置。

合理的批處理時間(batchDuration)

幾乎所有的Spark Streaming調優文件都會提及批處理時間的調整,在StreamingContext初始化的時候,有一個引數便是批處理時間的設定。如果這個值設定的過短,即個batchDuration所產生的Job並不能在這期間完成處理,那麼就會造成資料不斷堆積,最終導致Spark Streaming發生阻塞。而且,一般對於batchDuration的設定不會小於500ms,因為過小會導致SparkStreaming頻繁的提交作業,對整個streaming造成額外的負擔。在平時的應用中,根據不同的應用場景和硬體配置,我設在1~10s之間,我們可以根據SparkStreaming的視覺化監控介面,觀察Total Delay來進行batchDuration的調整,如下圖:
Spark streaming消費Kafka的正確姿勢

合理的Kafka拉取量(maxRatePerPartition重要)

對於Spark Streaming消費kafka中資料的應用場景,這個配置是非常關鍵的,配置引數為:spark.streaming.kafka.maxRatePerPartition。這個引數預設是沒有上線的,即kafka當中有多少資料它就會直接全部拉出。而根據生產者寫入Kafka的速率以及消費者本身處理資料的速度,同時這個引數需要結合上面的batchDuration,使得每個partition拉取在每個batchDuration期間拉取的資料能夠順利的處理完畢,做到儘可能高的吞吐量,而這個引數的調整可以參考視覺化監控介面中的Input Rate和Processing Time,如下圖:
Spark streaming消費Kafka的正確姿勢
Spark streaming消費Kafka的正確姿勢

快取反覆使用的Dstream(RDD)

Spark中的RDD和SparkStreaming中的Dstream,如果被反覆的使用,最好利用cache(),將該資料流快取起來,防止過度的排程資源造成的網路開銷。可以參考觀察Scheduling Delay引數,如下圖:
Spark streaming消費Kafka的正確姿勢

設定合理的GC

長期使用Java的小夥伴都知道,JVM中的垃圾回收機制,可以讓我們不過多的關注與記憶體的分配回收,更加專注於業務邏輯,JVM都會為我們搞定。對JVM有些瞭解的小夥伴應該知道,在Java虛擬機器中,將記憶體分為了初生代(eden generation)、年輕代(young generation)、老年代(old generation)以及永久代(permanent generation),其中每次GC都是需要耗費一定時間的,尤其是老年代的GC回收,需要對記憶體碎片進行整理,通常採用標記-清楚的做法。同樣的在Spark程式中,JVM GC的頻率和時間也是影響整個Spark效率的關鍵因素。在通常的使用中建議:

--conf "spark.executor.extraJavaOptions=-XX:+UseConcMarkSweepGC"複製程式碼

設定合理的CPU資源數

CPU的core數量,每個executor可以佔用一個或多個core,可以通過觀察CPU的使用率變化來了解計算資源的使用情況,例如,很常見的一種浪費是一個executor佔用了多個core,但是總的CPU使用率卻不高(因為一個executor並不總能充分利用多核的能力),這個時候可以考慮讓麼個executor佔用更少的core,同時worker下面增加更多的executor,或者一臺host上面增加更多的worker來增加並行執行的executor的數量,從而增加CPU利用率。但是增加executor的時候需要考慮好記憶體消耗,因為一臺機器的記憶體分配給越多的executor,每個executor的記憶體就越小,以致出現過多的資料spill over甚至out of memory的情況。

設定合理的parallelism

partition和parallelism,partition指的就是資料分片的數量,每一次task只能處理一個partition的資料,這個值太小了會導致每片資料量太大,導致記憶體壓力,或者諸多executor的計算能力無法利用充分;但是如果太大了則會導致分片太多,執行效率降低。在執行action型別操作的時候(比如各種reduce操作),partition的數量會選擇parent RDD中最大的那一個。而parallelism則指的是在RDD進行reduce類操作的時候,預設返回資料的paritition數量(而在進行map類操作的時候,partition數量通常取自parent RDD中較大的一個,而且也不會涉及shuffle,因此這個parallelism的引數沒有影響)。所以說,這兩個概念密切相關,都是涉及到資料分片的,作用方式其實是統一的。通過spark.default.parallelism可以設定預設的分片數量,而很多RDD的操作都可以指定一個partition引數來顯式控制具體的分片數量。
在SparkStreaming+kafka的使用中,我們採用了Direct連線方式,前文闡述過Spark中的partition和Kafka中的Partition是一一對應的,我們一般預設設定為Kafka中Partition的數量。

使用高效能的運算元

這裡參考了美團技術團隊的博文,並沒有做過具體的效能測試,其建議如下:

  • 使用reduceByKey/aggregateByKey替代groupByKey
  • 使用mapPartitions替代普通map
  • 使用foreachPartitions替代foreach
  • 使用filter之後進行coalesce操作
  • 使用repartitionAndSortWithinPartitions替代repartition與sort類操作

使用Kryo優化序列化效能

這個優化原則我本身也沒有經過測試,但是好多優化文件有提到,這裡也記錄下來。
在Spark中,主要有三個地方涉及到了序列化:

  • 在運算元函式中使用到外部變數時,該變數會被序列化後進行網路傳輸(見“原則七:廣播大變數”中的講解)。
  • 將自定義的型別作為RDD的泛型型別時(比如JavaRDD,Student是自定義型別),所有自定義型別物件,都會進行序列化。因此這種情況下,也要求自定義的類必須實現Serializable介面。
  • 使用可序列化的持久化策略時(比如MEMORY_ONLY_SER),Spark會將RDD中的每個partition都序列化成一個大的位元組陣列。

對於這三種出現序列化的地方,我們都可以通過使用Kryo序列化類庫,來優化序列化和反序列化的效能。Spark預設使用的是Java的序列化機制,也就是ObjectOutputStream/ObjectInputStream API來進行序列化和反序列化。但是Spark同時支援使用Kryo序列化庫,Kryo序列化類庫的效能比Java序列化類庫的效能要高很多。官方介紹,Kryo序列化機制比Java序列化機制,效能高10倍左右。Spark之所以預設沒有使用Kryo作為序列化類庫,是因為Kryo要求最好要註冊所有需要進行序列化的自定義型別,因此對於開發者來說,這種方式比較麻煩。

以下是使用Kryo的程式碼示例,我們只要設定序列化類,再註冊要序列化的自定義型別即可(比如運算元函式中使用到的外部變數型別、作為RDD泛型型別的自定義型別等):

// 建立SparkConf物件。
val conf = new SparkConf().setMaster(...).setAppName(...)
// 設定序列化器為KryoSerializer。
conf.set("spark.serializer", "org.apache.spark.serializer.KryoSerializer")
// 註冊要序列化的自定義型別。
conf.registerKryoClasses(Array(classOf[MyClass1], classOf[MyClass2]))複製程式碼

結果

經過種種除錯優化,我們最終要達到的目的是,Spark Streaming能夠實時的拉取Kafka當中的資料,並且能夠保持穩定,如下圖所示:
Spark streaming消費Kafka的正確姿勢

當然不同的應用場景會有不同的圖形,這是本文詞頻統計優化穩定後的監控圖,我們可以看到Processing Time這一柱形圖中有一Stable的虛線,而大多數Batch都能夠在這一虛線下處理完畢,說明整體Spark Streaming是執行穩定的。

參考文獻

  1. Spark Streaming + Kafka Integration Guide
  2. DirectStream、Stream的區別-SparkStreaming原始碼分析02
  3. Spark效能優化指南——基礎篇
  4. Spark的效能調優
  5. How to write to Kafka from Spark Streaming


福利部分:

《大資料成神之路》

《幾百TJava和大資料資源下載》


Spark streaming消費Kafka的正確姿勢



相關文章