Kafka+Spark Streaming管理offset的兩種方法

Kafka配合Spark Streaming是大數據領域常見的黃金搭檔之一,主要是用于數據實時入庫或分析。

為了應對可能出現的引起Streaming程序崩潰的異常情況,我們一般都需要手動管理好Kafka的offset,而不是讓它自動提交,即需要將enable.auto.commit設為false。只有管理好offset,才能使整個流式系統最大限度地接近exactly once語義。

管理offset的流程

下面這張圖能夠簡要地說明管理offset的大致流程。


offset管理流程
  • 在Kafka DirectStream初始化時,取得當前所有partition的存量offset,以讓DirectStream能夠從正確的位置開始讀取數據。
  • 讀取消息數據,處理并存儲結果。
  • 提交offset,并將其持久化在可靠的外部存儲中。

圖中的“process and store results”及“commit offsets”兩項,都可以施加更強的限制,比如存儲結果時保證冪等性,或者提交offset時采用原子操作。

圖中提出了4種offset存儲的選項,分別是HBase、Kafka自身、HDFS和ZooKeeper。綜合考慮實現的難易度和效率,我們目前采用過的是Kafka自身與ZooKeeper兩種方案。

Kafka自身

在Kafka 0.10+版本中,offset的默認存儲由ZooKeeper移動到了一個自帶的topic中,名為__consumer_offsets。Spark Streaming也專門提供了commitAsync() API用于提交offset。使用方法如下。

stream.foreachRDD { rdd =>
  val offsetRanges = rdd.asInstanceOf[HasOffsetRanges].offsetRanges
  // 確保結果都已經正確且冪等地輸出了
  stream.asInstanceOf[CanCommitOffsets].commitAsync(offsetRanges)
}

上面是Spark Streaming官方文檔中給出的寫法。但在實際上我們總會對DStream進行一些運算,這時我們可以借助DStream的transform()算子。

        var offsetRanges: Array[OffsetRange] = Array.empty[OffsetRange]

        stream.transform(rdd => {
            // 利用transform取得OffsetRanges
            offsetRanges = rdd.asInstanceOf[HasOffsetRanges].offsetRanges
            rdd
        }).mapPartitions(records => {
            var result = new ListBuffer[...]()
            // 處理流程
            result.toList.iterator
        }).foreachRDD(rdd => {
            if (!rdd.isEmpty()) {
                // 數據入庫
                session.createDataFrame...
            }
            // 提交offset
            stream.asInstanceOf[CanCommitOffsets].commitAsync(offsetRanges)
        })

特別需要注意,在轉換過程中不能破壞RDD分區與Kafka分區之間的映射關系。亦即像map()/mapPartitions()這樣的算子是安全的,而會引起shuffle或者repartition的算子,如reduceByKey()/join()/coalesce()等等都是不安全的。

另外需要注意的是,HasOffsetRangesKafkaRDD的一個trait,而CanCommitOffsetsDirectKafkaInputDStream的一個trait。從spark-streaming-kafka包的源碼中,可以看得一清二楚。

private[spark] class KafkaRDD[K, V](
    sc: SparkContext,
    val kafkaParams: ju.Map[String, Object],
    val offsetRanges: Array[OffsetRange],
    val preferredHosts: ju.Map[TopicPartition, String],
    useConsumerCache: Boolean
) extends RDD[ConsumerRecord[K, V]](sc, Nil) with Logging with HasOffsetRanges

private[spark] class DirectKafkaInputDStream[K, V](
    _ssc: StreamingContext,
    locationStrategy: LocationStrategy,
    consumerStrategy: ConsumerStrategy[K, V],
    ppc: PerPartitionConfig
  ) extends InputDStream[ConsumerRecord[K, V]](_ssc) with Logging with CanCommitOffsets {

這就意味著不能對stream對象做transformation操作之后的結果進行強制轉換(會直接報ClassCastException),因為RDD與DStream的類型都改變了。只有RDD或DStream的包含類型為ConsumerRecord才行。

ZooKeeper

雖然Kafka將offset從ZooKeeper中移走是考慮到可能的性能問題,但ZooKeeper內部是采用樹形node結構存儲的,這使得它天生適合存儲像offset這樣細碎的結構化數據。并且我們的分區數不是很多,batch間隔也相對長(20秒),因此并沒有什么瓶頸。

Kafka中還保留了一個已經標記為過時的類ZKGroupTopicDirs,其中預先指定了Kafka相關數據的存儲路徑,借助它,我們可以方便地用ZooKeeper來管理offset。為了方便調用,將存取offset的邏輯封裝成一個類如下。

class ZkKafkaOffsetManager(zkUrl: String) {
    private val logger = LoggerFactory.getLogger(classOf[ZkKafkaOffsetManager])

    private val zkClientAndConn = ZkUtils.createZkClientAndConnection(zkUrl, 30000, 30000);
    private val zkUtils = new ZkUtils(zkClientAndConn._1, zkClientAndConn._2, false)

    def readOffsets(topics: Seq[String], groupId: String): Map[TopicPartition, Long] = {
        val offsets = mutable.HashMap.empty[TopicPartition, Long]
        val partitionsForTopics = zkUtils.getPartitionsForTopics(topics)

        // /consumers/<groupId>/offsets/<topic>/<partition>
        partitionsForTopics.foreach(partitions => {
            val topic = partitions._1
            val groupTopicDirs = new ZKGroupTopicDirs(groupId, topic)

            partitions._2.foreach(partition => {
                val path = groupTopicDirs.consumerOffsetDir + "/" + partition
                try {
                    val data = zkUtils.readData(path)
                    if (data != null) {
                        offsets.put(new TopicPartition(topic, partition), data._1.toLong)
                        logger.info(
                            "Read offset - topic={}, partition={}, offset={}, path={}",
                            Seq[AnyRef](topic, partition.toString, data._1, path)
                        )
                    }
                } catch {
                    case ex: Exception =>
                        offsets.put(new TopicPartition(topic, partition), 0L)
                        logger.info(
                            "Read offset - not exist: {}, topic={}, partition={}, path={}",
                            Seq[AnyRef](ex.getMessage, topic, partition.toString, path)
                        )
                }
            })
        })

        offsets.toMap
    }

    def saveOffsets(offsetRanges: Seq[OffsetRange], groupId: String): Unit = {
        offsetRanges.foreach(range => {
            val groupTopicDirs = new ZKGroupTopicDirs(groupId, range.topic)
            val path = groupTopicDirs.consumerOffsetDir + "/" + range.partition
            zkUtils.updatePersistentPath(path, range.untilOffset.toString)
            logger.info(
                "Save offset - topic={}, partition={}, offset={}, path={}",
                Seq[AnyRef](range.topic, range.partition.toString, range.untilOffset.toString, path)
            )
        })
    }
}

這樣,offset就會被存儲在ZK的/consumers/[groupId]/offsets/[topic]/[partition]路徑下。當初始化DirectStream時,調用readOffsets()方法獲得offset。當數據處理完成后,調用saveOffsets()方法來更新ZK中的值。

為什么不用checkpoint

Spark Streaming的checkpoint機制無疑是用起來最簡單的,checkpoint數據存儲在HDFS中,如果Streaming應用掛掉,可以快速恢復。

但是,如果Streaming程序的代碼改變了,重新打包執行就會出現反序列化異常的問題。這是因為checkpoint首次持久化時會將整個jar包序列化,以便重啟時恢復。重新打包之后,新舊代碼邏輯不同,就會報錯或者仍然執行舊版代碼。

要解決這個問題,只能將HDFS上的checkpoint文件刪掉,但這樣也會同時刪掉Kafka的offset信息,就毫無意義了。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,119評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,382評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,038評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,853評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,616評論 6 408
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,112評論 1 323
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,192評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,355評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,869評論 1 334
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,727評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,928評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,467評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,165評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,570評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,813評論 1 282
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,585評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,892評論 2 372

推薦閱讀更多精彩內容