記Structured Streaming 2.3.1的OOM排查過程 原 薦

記Structured Streaming 2.3.1的OOM排查過程

緣起

最近在使用Structured Streaming開發一套自助配置SQL的來生成流式作業的平臺,在測試的過程中發現有些作業長時間運行後會有Executor端的OOM,起初以爲是代碼的問題,幾經review和重構代碼,都沒有解決,無奈開始了這次OOM的問題排查之路。

乾貨

出現的問題

Structured Streaming 作業長時間運行後,會出現如下問題

可以看到spark爲我們提供的統計信息,Task的GC時間佔到了Task執行時間的70%,起初以爲配置的內存不夠,但是反覆調大內存均出現此問題。

出現這種問題之後,緊接着就會出現Executor和Driver間心跳異常,或者Executor假死的狀態,一般出現這類假死、jvm沒有響應的問題大都可初步判斷爲是因爲Jvm的Full GC而造成的Stop the World現象。

緊接着再過一段時間之後,在Executor的日誌中會出現java.lang.OutOfMemoryError: Java heap space這類異常,導致Executor掛掉。

綜上現象,初步推測是因爲Executor端出現了內存泄漏。

收集信息

由於作業是從hermes平臺提交的,目前的hermes平臺還沒有提供提交Spark任務打印jvm的gc日誌的功能。故決定在線下集羣上自己配置一個Spark的客戶端,在spark-default.conf裏配置driver和executor運行時的jvm參數,使其在進行gc時將gc信息打印出來,配置如下:

spark.executor.extraJavaOptions  -verbose:gc -XX:+PrintGC -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:OnOutOfMemoryError='kill -9 %p'
spark.driver.extraJavaOptions  -verbose:gc -XX:+PrintGC -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:OnOutOfMemoryError='kill -9 %p'

這幾個參數的意思是配置jvm在發生gc時打印gc的詳情信息,當發生OOM異常時,使用kill -9 殺死jvm。

一段GC 日誌大概長這樣:

0.756: [Full GC (System) 0.756: [CMS: 0K->1696K(204800K), 0.0347096 secs] 11488K->1696K(252608K), [CMS Perm : 10328K->10320K(131072K)], 0.0347949 secs] [Times: user=0.06 sys=0.00, real=0.05 secs]  
1.728: [GC 1.728: [ParNew: 38272K->2323K(47808K), 0.0092276 secs] 39968K->4019K(252608K), 0.0093169 secs] [Times: user=0.01 sys=0.00, real=0.00 secs]  
2.642: [GC 2.643: [ParNew: 40595K->3685K(47808K), 0.0075343 secs] 42291K->5381K(252608K), 0.0075972 secs] [Times: user=0.03 sys=0.00, real=0.02 secs]  
4.349: [GC 4.349: [ParNew: 41957K->5024K(47808K), 0.0106558 secs] 43653K->6720K(252608K), 0.0107390 secs] [Times: user=0.03 sys=0.00, real=0.02 secs]  
5.617: [GC 5.617: [ParNew: 43296K->7006K(47808K), 0.0136826 secs] 44992K->8702K(252608K), 0.0137904 secs] [Times: user=0.03 sys=0.00, real=0.02 secs]  
7.429: [GC 7.429: [ParNew: 45278K->6723K(47808K), 0.0251993 secs] 46974K->10551K(252608K), 0.0252421 secs]

排查過程

等了好久終於又等到Executor假死的現象,通過yarn提供的日誌鏈接,看到這個Executor的GC日誌一直在刷,正在進行瘋狂的Full GC, 因爲Spark是運行在yarn集羣上的,所以只能委託公司的OP兄弟,把發生OOM但還沒掛掉的Executor的內存鏡像保存下來:

jmap -dump:live,format=b,file=dump.hprof $pid

將生成的dump.hprof文件下載到本地,使用java自帶的jvisualvm工具打開,將類名根據大小排序,得到如下圖:

可以看到byte[]類型的對象佔了將近1G的內存,明顯是發生了內存泄漏。雙擊這行:

發現除了絕大多數的字節數組都是65560長度,且內容全爲0,而且在右下側的窗口裏發現引用這些字節數組的類都是EPollArrayWrapper類,經過查找發現存在如下類型的類,其數量均爲14823,

  • sun.nio.ch.EPollArrayWrapper
  • sun.nio.ch.EPollSelectorImpl
  • sun.nio.ch.SelectorImpl (實現了 java.nio.channels.Selector)
  • org.apache.kafka.common.network.Selector
  • org.apache.kafka.clients.NetworkClient
  • org.apache.kafka.clients.consumer.internals.Fetcher
  • org.apache.kafka.clients.consumer.internals.ConsumerNetworkClient
  • org.apache.kafka.clients.consumer.internals.ConsumerCoordinator
  • org.apache.kafka.common.metrics.Metrics
  • org.apache.kafka.common.metrics.JmxReporter

這些都是kafka包裏的類,但是名沒有發現KafkaConsumer或者KafkaProducer類,而且從日誌中看,發現每個批次都會有KafkaConsumer被創建,於是懷疑是KafkaConsumer多次被創建,但是沒有回收乾淨而導致的內存泄漏,查看源碼,發現存在如下引用鏈:

每次創建KafkaConsumer並進行網絡通信後,都會把內部的一些監控信息註冊到MBeanServer中,這樣在MBeanServer中就存在瞭如上圖的引用鏈,但是在KafkaConsumer對象被回收的時候,並沒有調用其close方法,也就是並沒有回收這些對象,這樣就造成了內存泄漏。

那麼問題來了,爲什麼會創建如此之多的KafkaConsumer,Structured Streaming沒有複用KafkaConsumer的機制麼?這顯然是不可能的。 所以,我們需要查找在什麼情況下會需要額外的創建KafkaConsumer,以及爲什麼這些創建出來的KafkaConsumer沒有被調用close呢。

在Structured Streaming中,整合kafka的代碼在

<dependency>
  <groupId>org.apache.spark</groupId>
  <artifactId>spark-sql-kafka-0-10_2.11</artifactId>
  <version>2.3.1</version>
</dependency>

包的KafkaSourceRDD類中,通過添加日誌,我們定位到問題代碼在compute方法中。

首先介紹一下KafkaDataConsumer.acquire方法的作用,它會返回一個經過封裝的KafkaDataConsumer

  def acquire(
      topicPartition: TopicPartition,
      kafkaParams: ju.Map[String, Object],
      useCache: Boolean): KafkaDataConsumer = synchronized {
    val key = new CacheKey(topicPartition, kafkaParams)
    val existingInternalConsumer = cache.get(key)

    lazy val newInternalConsumer = new InternalKafkaConsumer(topicPartition, kafkaParams)

    if (TaskContext.get != null && TaskContext.get.attemptNumber >= 1) {
      // If this is reattempt at running the task, then invalidate cached consumer if any and
      // start with a new one.
      if (existingInternalConsumer != null) {
        // Consumer exists in cache. If its in use, mark it for closing later, or close it now.
        if (existingInternalConsumer.inUse) {
          existingInternalConsumer.markedForClose = true
        } else {
          existingInternalConsumer.close()
        }
      }
      cache.remove(key)  // Invalidate the cache in any case
      NonCachedKafkaDataConsumer(newInternalConsumer)
    } else if (!useCache) {
      // If planner asks to not reuse consumers, then do not use it, return a new consumer
      NonCachedKafkaDataConsumer(newInternalConsumer)
    } else if (existingInternalConsumer == null) {
      // If consumer is not already cached, then put a new in the cache and return it
      cache.put(key, newInternalConsumer)
      newInternalConsumer.inUse = true
      CachedKafkaDataConsumer(newInternalConsumer)
    } else if (existingInternalConsumer.inUse) {
      // If consumer is already cached but is currently in use, then return a new consumer
      NonCachedKafkaDataConsumer(newInternalConsumer)

    } else {
      // If consumer is already cached and is currently not in use, then return that consumer
      existingInternalConsumer.inUse = true
      CachedKafkaDataConsumer(existingInternalConsumer)
    }
  }

代碼中的useCache參數爲true,所以我們只看下面的三個分支就可以了:

  1. 看cache裏是否有指定分區的KafkaConsumer,沒有的話會創建一個,放到緩存中,並標記位正在使用的狀態
  2. 如果有的話,但是是正在被使用的狀態,會創建一個新的,不被緩存的
  3. 存在且爲可用狀態,直接標記爲正在使用

我們在compute方法中看到,在下面的else分支裏,當任務完成是,會回調迭代器的closeIfNeed方法,底層會調用到KafkaDataConsumer.release方法,針對被緩存的KafkaDataConsumer,將其狀態標記位可被使用的狀態,而針對不被緩存的KafkaDataConsumer,直接調用其close方法。這個邏輯在compute方法的else分支裏是沒有問題的。

問題出在compute的if(range.fromOffset == range.untilOffset)的時候,這裏直接返回了一個空的迭代器,而並沒有將上面獲取到的consumer關閉,這就造成了KafkaConsumer內對象的泄漏。

之後在github上找到了修複相關問題的提交: https://github.com/apache/spark/commit/14b50d7fee58d56cb8843994b1a423a6b475dcb5

修復了這個問題,修復的方法就是在返回空的迭代器之前將之前獲取到的consumer關閉即可。 但是修復的代碼是要發佈在2.3.2版本中的,所以我們只能將spark-sql-kafka的源碼包下載,集成到項目中來修復這個bug。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章