Kafka 偏移量管理實現精確一次語義在Spark&Flink中的技術實踐-kafka商業應用實戰

凱新的技術社群發表於2019-03-10

本套技術專欄是作者(秦凱新)平時工作的總結和昇華,並深度整理大量網上資源和專業書籍。通過從真實商業環境抽取案例進行總結和分享,並給出商業應用的調優建議和叢集環境容量規劃等內容,請持續關注本套部落格。QQ郵箱地址:1120746959@qq.com,如有任何學術交流,可隨時聯絡。

1 Kafka 偏移量

1.1 Kafka 0.9 之前版本

這裡的偏移量是指 kafka consumer offset,在 Kafka 0.9 版本之前消費者偏移量預設被儲存在 zookeeper 中(/consumers/<group.id>/offsets//),因此在初始化消費者的時候需要指定 zookeeper.hosts。

1.2 Kafka 0.9 之後版本

隨著 Kafka consumer 在實際場景的不斷應用,社群發現舊版本 consumer 把位移提交到 ZooKeeper 的做法並不合適。ZooKeeper 本質上只是一個協調服務元件,它並不適合作為位移資訊的儲存元件,畢竟頻繁高併發的讀/寫操作並不是 ZooKeeper 擅長的事情。因此在 0.9 版本開始 consumer 將位移提交到 Kafka 的一個內部 topic(__consumer_offsets)中,該主題預設有 50 個分割槽,每個分割槽 3 個副本。

1.3 訊息處理語義

  • at-most-once:最多一次,訊息可能丟失,但不會被重複處理;
  • at-least-once:至少一次,訊息不會丟失,但可能被處理多次;
  • exactly-once:精確一次,訊息一定會被處理且只會被處理一次。
  • 若 consumer 在訊息消費之前就提交位移,那麼便可以實現 at-most-once,因為若 consumer 在提交位移與訊息消費之間崩潰,則 consumer 重啟後會從新的 offset 位置開始消費,前面的那條訊息就丟失了;相反地,
  • 若提交位移在訊息消費之後,則可實現 at-least-once 語義。由於 Kafka 沒有辦法保證訊息處理成功與位移提交在同一個事務中完成,若訊息消費成功了,也提交位移了,但是處理失敗了,因此 Kafka 預設提供的就是 at-least-once 的處理語義。

1.4 kafka offset 提交方式

  • 預設情況下,consumer 是自動提交位移的,自動提交間隔是 5 秒,可以通過設定 auto.commit.interval.ms 引數可以控制自動提交的間隔。

    自動位移提交的優勢是降低了使用者的開發成本使得使用者不必親自處理位移提交;劣勢是使用者不能細粒度地處理位移的提交,特別是在有較強的精確一次處理語義時(在這種情況下,使用者可以使用手動位移提交)。

  • 手動位移提交就是使用者自行確定訊息何時被真正處理完並可以提交位移,使用者可以確保只有訊息被真正處理完成後再提交位移。如果使用自動位移提交則無法保證這種時序性,因此在這種情況下必須使用手動提交位移。

    設定使用手動提交位移非常簡單,僅僅需要在構建 KafkaConsumer 時設定 enable.auto.commit=false,然後呼叫 commitSync 或 commitAsync 方法即可。

2 Spark 位移處理方式

2.1 auto.offset.reset設定思路

  • 對於 auto.offset.reset 個人推薦設定為 earliest,初次執行的時候,由於 __consumer_offsets 沒有相關偏移量資訊,因此訊息會從最開始的地方讀取;當第二次執行時,由於 __consumer_offsets 已經存在消費的 offset 資訊,因此會根據 __consumer_offsets 中記錄的偏移資訊繼續讀取資料。

  • 此外,對於使用 zookeeper 管理偏移量而言,只需要刪除對應的節點,資料即可從頭讀取,也是非常方便。不過如果你希望從最新的地方讀取資料,不需要讀取舊訊息,則可以設定為 latest。

       earilist:提交過分割槽,從Offset處讀取,如果沒有提交過offset,從頭讀取
       latest:提交過分割槽,從Offset處讀取,沒有從最新的資料開始讀取
       None:如果沒有提交offset,就會報錯,提交過offset,就從offset處讀取
    複製程式碼

2.2 訂閱 Kafka 主題

  • 基於正則訂閱主題,有以下好處:

      無需羅列主題名,一兩個主題還好,如果有幾十個,羅列過於麻煩了;
      可實現動態訂閱的效果(新增的符合正則的主題也會被讀取)。
    
      stream = KafkaUtils.createDirectStream[String, String](ssc,
              LocationStrategies.PreferConsistent,
              ConsumerStrategies.SubscribePattern[String, String](Pattern.compile(topicStr), kafkaConf, customOffset))
    複製程式碼
  • LocationStrategies 分配分割槽策略,LocationStrategies:根據給定的主題和叢集地址建立consumer

        建立DStream,返回接收到的輸入資料
        LocationStrategies.PreferConsistent:持續的在所有Executor之間勻分配分割槽 (均勻分配,選中的每一個Executor都會分配 partition)
        LocationStrategies.PreferBrokers: 如果executor和kafka brokers 在同一臺機器上,選擇該executor。
        LocationStrategies.PreferFixed: 如果機器不是均勻的情況下,可以指定特殊的hosts。當然如果不指定,採用 LocationStrategies.PreferConsistent模式
    複製程式碼
  • 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)
      })
    複製程式碼

2.3 使用老式zookeeper手動管理位移程式碼分析

  • Zookeeper 偏移量管理ZkKafkaOffset實現,藉助 zookeeper 管理工具可以對任何一個節點的資訊進行修改、刪除,如果希望從最開始讀取訊息,則只需要刪除 zk 某個節點的資料即可。

      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)
        }
      }
    複製程式碼
  • Spark Streaming 消費 Kafka 訊息

      第一步:val customOffset: Map[TopicPartition, Long] = kafkaOffset.getOffset(ssc)
      第二步:stream = KafkaUtils.createDirectStream[String, String](ssc,
              LocationStrategies.PreferConsistent,
              ConsumerStrategies.Subscribe[String, String](topics, kafkaConf, customOffset))
      第三步:處理後,kafkaOffset.commitOffset(offsetRanges)
    
      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()
        }
      }
    複製程式碼

3 Flink 位移處理方式

3.1 Flink 消費者精確到一次語義

  • setStartFromGroupOffsets()【預設消費策略】 預設讀取上次儲存的offset資訊 如果是應用第一次啟動,讀取不到上次的offset資訊,則會根據這個引數auto.offset.reset的值來進行消費資料

  • setStartFromEarliest() 從最早的資料開始進行消費,忽略儲存的offset資訊

  • setStartFromLatest() 從最新的資料進行消費,忽略儲存的offset資訊

  • setStartFromSpecificOffsets(Map<KafkaTopicPartition, Long>) 從指定位置進行消費。

  • 當checkpoint機制開啟的時候,KafkaConsumer會定期把kafka的offset資訊還有其他operator的狀態資訊一塊儲存起來。當job失敗重啟的時候,Flink會從最近一次的checkpoint中進行恢復資料,重新消費kafka中的資料。

  • 為了能夠使用支援容錯的kafka Consumer,需要開啟checkpoint env.enableCheckpointing(5000); // 每5s checkpoint一次

  • Kafka Consumers Offset 自動提交有以下兩種方法來設定,可以根據job是否開啟checkpoint來區分:

    (1) Flink Checkpoint關閉時: 可以通過Kafka下面兩個Properties引數配置

      enable.auto.commit
      auto.commit.interval.ms
    複製程式碼

    (2) Checkpoint開啟時:當執行checkpoint的時候才會儲存offset,這樣保證了kafka的offset和checkpoint的狀態偏移量保持一致。可以通過這個引數設定

      setCommitOffsetsOnCheckpoints(boolean)
    複製程式碼

    這個引數預設就是true。表示在checkpoint的時候提交offset, 此時,kafka中的自動提交機制就會被忽略。

          //獲取Flink的執行環境
          StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
    
          //checkpoint配置
          env.enableCheckpointing(5000);
          env.getCheckpointConfig().setCheckpointingMode(CheckpointingMode.EXACTLY_ONCE);
          env.getCheckpointConfig().setMinPauseBetweenCheckpoints(500);
          env.getCheckpointConfig().setCheckpointTimeout(60000);
          env.getCheckpointConfig().setMaxConcurrentCheckpoints(1);
          env.getCheckpointConfig().enableExternalizedCheckpoints(CheckpointConfig.ExternalizedCheckpointCleanup.RETAIN_ON_CANCELLATION);
    
          //設定statebackend
          env.setStateBackend(new RocksDBStateBackend("hdfs://hadoop100:9000/flink/checkpoints",true));
    
          String topic = "kafkaConsumer";
          Properties prop = new Properties();
          prop.setProperty("bootstrap.servers","SparkMaster:9092");
          prop.setProperty("group.id","kafkaConsumerGroup");
    
          FlinkKafkaConsumer011<String> myConsumer = new FlinkKafkaConsumer011<>(topic, new SimpleStringSchema(), prop);
    
          myConsumer.setStartFromGroupOffsets();//預設消費策略
          myConsumer.setCommitOffsetsOnCheckpoints(true);
          DataStreamSource<String> text = env.addSource(myConsumer);
    
          text.print().setParallelism(1);
    
          env.execute("StreamingFromCollection");
    複製程式碼
  • Flink KafkaConsumer允許配置向 Kafka brokers(或者向Zookeeper)提交offset的行為。需要注意的是,Flink Kafka Consumer並不依賴於這些提交回Kafka或Zookeeper的offset來保證容錯。這些被提交的offset只是意味著Flink將消費的狀態暴露在外以便於監控。

  • FlinkKafkaConsumer提供了一套健壯的機制保證了在高吞吐量的情況下exactly-once的消費Kafka的資料,它的API的使用與配置也比較簡單,同時也便於監控。

  • barrier可以理解為checkpoint之間的分隔符,在它之前的data屬於前一個checkpoint,而在它之後的data屬於另一個checkpoint。同時,barrier會由source(如FlinkKafkaConsumer)發起,並混在資料中,同資料一樣傳輸給下一級的operator,直到sink為止。如果barrier已經被sink收到,那麼說明checkpoint已經完成了(這個checkpoint的狀態為completed並被存到了state backend中),它之前的資料已經被處理完畢並sink。

  • Flink非同步記錄checkpoint的行為是由我們的來配置的,只有當我們設定了enableCheckpointing()時,Flink才會在checkpoint完成時(整個job的所有的operator都收到了這個checkpoint的barrier才意味這checkpoint完成,具體參考我們對Flink checkpoint的介紹)將offset記錄起來並提交,這時候才能夠保證exactly-once。

3.2 Flink 生產者精確到一次語義

  • Kafka Producer的容錯-Kafka 0.9 and 0.10

      如果Flink開啟了checkpoint,針對FlinkKafkaProducer09和FlinkKafkaProducer010 可以提供 at-least-once的語義,還需要配置下面兩個引數:
      setLogFailuresOnly(false)
      setFlushOnCheckpoint(true)
      注意:建議修改kafka 生產者的重試次數retries【這個引數的值預設是0】
    複製程式碼
  • Kafka Producer的容錯-Kafka 0.11,如果Flink開啟了checkpoint,針對FlinkKafkaProducer011 就可以提供 exactly-once的語義,但是需要選擇具體的語義

      具體的語義設定方式 
      Semantic.NONE
      Semantic.AT_LEAST_ONCE【預設】
      Semantic.EXACTLY_ONCE
    
      checkpoint配置
      StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
      env.enableCheckpointing(5000);
      env.getCheckpointConfig().setCheckpointingMode(CheckpointingMode.EXACTLY_ONCE);
      env.getCheckpointConfig().setMinPauseBetweenCheckpoints(500);
      env.getCheckpointConfig().setCheckpointTimeout(60000);
      env.getCheckpointConfig().setMaxConcurrentCheckpoints(1);
      env.getCheckpointConfig().enableExternalizedCheckpoints(CheckpointConfig.ExternalizedCheckpointCleanup.RETAIN_ON_CANCELLATION);
      
      //第一種解決方案,設定FlinkKafkaProducer011裡面的事務超時時間
      //設定事務超時時間
      //prop.setProperty("transaction.timeout.ms",60000*15+"");
    
      //第二種解決方案,設定kafka的最大事務超時時間,主要是kafka的配置檔案設定。
    
      //FlinkKafkaProducer011<String> myProducer = new FlinkKafkaProducer011<>(brokerList, topic, new SimpleStringSchema());
    
      //使用僅一次語義的kafkaProducer
      FlinkKafkaProducer011<String> myProducer = new FlinkKafkaProducer011<>(topic, new KeyedSerializationSchemaWrapper<String>(new SimpleStringSchema()), prop, FlinkKafkaProducer011.Semantic.EXACTLY_ONCE);
      
      text.addSink(myProducer);
    複製程式碼

4 總結

本套技術專欄是作者(秦凱新)平時工作的總結和昇華,並深度整理大量網上資源和專業書籍。通過從真實商業環境抽取案例進行總結和分享,並給出商業應用的調優建議和叢集環境容量規劃等內容,請持續關注本套部落格。QQ郵箱地址:1120746959@qq.com,如有任何學術交流,可隨時聯絡。

本文在這裡一直困擾我如何Kafka 偏移量管理實現精確一次語義,如果你有幸閱讀到本篇內容,且有最好的解決方案,望能夠給我留言,期待更好的解決方案。

秦凱新 於浪潮

相關文章