HBase管理offset,解決kafka頭越界或尾越界問題(HBase存儲offset可以更換爲Mysql、Redis、Zookeeper)

HBase管理offset,解決kafka頭越界或尾越界問題(HBase存儲offset可以更換爲Mysql、Redis、Zookeeper

什麼是越界?

越界包括頭越界或尾越界。指的程序消費的是kafka offset不在kafka的隊列裏面,可能是數據過期或者kafka數據被清除
在這裏插入圖片描述###

頭越界的原因

數據過期:指的是kafka中存儲的數據會在一定時間內過期,比如數據的過期時間設置爲7天。
如果此時Spark、Flink、Java等程序掛掉一段時間,時間比較長的情況下,kafka中該Topic的分區目前最早的offset > HBase中上次消費的untillOffset,如果繼續就會出現從上次HBase的untillOffset繼續消費,kafka中這條數據過期了,就會出現頭越界問題

解決思路: 此種情況下需要從kafka中最新的offset開始消費,此時untillOffset -> kafka最新的offset ,這段時間內的數據會丟失!原因是數據都過期了,再怎麼想辦法都沒用,要想要這段時間內的數據,只能上游重發,下游做冪等,不在此文討論範圍內。

尾越界的原因

kafka該topic的數據被清空!此時offset會從0開始,HBase中存儲的untillOffset就可能會大於kafka中最晚的offset,一般需要避免清空數據這種情況!

解決方案流程圖

在這裏插入圖片描述

  1. 初始化kafka連接參數
  2. 初始化hbase
  3. 從kafka連接參數獲取到最新的topic-partition集合
  4. 從hbase獲取到上批次的untiloffset集合
  5. 比較untiloffset集合與topic-partition集合,判斷是否越界並糾正
  6. 初始化sparkStreamingContext
  7. 初始化kafka對應的DStream
  8. 得到DStream中rdd對應的offsets
  9. 處理數據
  10. 更新offset到hbase

代碼實現

  1. 獲取修正後的offset位置
 /**
    * 推薦方案
    * 啓動時從HBase獲取offSet,生成DStream
    *
    * 1.初始化kafka連接參數
    * 2.初始化hbase
    * 3.從kafka連接參數獲取到最新的topic-partition集合
    * 4.從hbase獲取到上批次的untiloffset集合
    * 5.比較untiloffset集合與topic-partition集合,判斷是否越界並糾正
    * 6.初始化sparkStreamingContext
    * 7.初始化kafka對應的DStream
    * 8.得到DStream中rdd對應的offsets
    * 9.處理數據
    * 10.更新offset到hbase
    *
    * @param ssc Spark Streaming上下文
    * @param topics 待消費的topic集合
    * @param properties 配置參數
    * @return 返回DStream 泛型是ConsumerRecord[String, String]
    */
  def createDirectStreamFromHBase(ssc: StreamingContext, topics: Set[String], properties: Map[String, String]): InputDStream[ConsumerRecord[String, String]] = {
    val startTime = System.currentTimeMillis()
    var isNewApp: Boolean = true
    val earlistTPOffSet = new ConcurrentHashMap[TopicPartition, Long]()
    val latestTPOffsets = new ConcurrentHashMap[TopicPartition, Long]()
    val currentTPOffsets = new ConcurrentHashMap[TopicPartition, Long]()

    //起一個consumer連接kafka生產者
    val offSetReset = properties.getOrElse("kafka.offsetReset", "earliest")
    //設置Kafka參數
    val kafkaParams = createKafkaParamMap(properties, offSetReset)
    val jkafkaParams: java.util.Map[String, Object] = JavaConversions.mapAsJavaMap(kafkaParams)

    //hbase中沒有該groupId的topic 分區信息時 從最早或最晚開始消費
    val reset = kafkaParams.getOrElse("auto.offset.reset", "latest").asInstanceOf[String]
    logger.info("HBase中無記錄時重置策略: " + reset)


    //HBase表主鍵前綴:appName|消費者組id
    val appName = properties("app.name")
    val rowPrefix = appName + "|" + properties("kafka.group.id")
    //獲取offset存儲表
    val tableName = HbaseTableEnum.KAFKA_OFFSET.getTableName
    val tableConfig = HBaseUtils.getTable(tableName)
    //從HBase存儲表獲取上一批次存儲的topic-partition-offset:currentTPOffset,並獲取最新的topic-partition

    topics.par.foreach(topic => {
      logger.info("當前處理Topic:" + topic)
      //創建KafkaConsumer
      val consumer: Consumer[String, String] = new KafkaConsumer[String, String](jkafkaParams)
      var isTopicExists = true
      //當前topic 多分區信息
      val currentTPOffset = getOffSetsFromHBase(tableConfig, rowPrefix, topic)
      //如果分區信息存在 則非新消費者組消費此topic
      if (currentTPOffset.length > 0) {
        isNewApp = false
        logger.info("當前Topic以前被消費過,Topic:" + topic)
      } else {
        logger.info("當前Topic以前未被消費過,Topic:" + topic)
      }

      val tps = new util.ArrayList[TopicPartition]()
      //獲取topic metadata信息
      val partitionInfo = consumer.partitionsFor(topic)

      //生成 topic分區list
      try {
        JavaConversions.asScalaBuffer(partitionInfo).foreach(p => {
          val topicPartition = new TopicPartition(p.topic(), p.partition())
          tps.add(topicPartition)
        })
      } catch {
        case e: Exception => {
          isTopicExists = false
          if (e.getStackTrace.length > 0 && e.getStackTrace.apply(0).toString.contains("convert.Wrappers$JListWrapper")) {
            logger.error(topic + ":" + e.getMessage + " 請檢查Topic是否存在! ", e)
          } else {
            logger.error(topic + ":" + e.getMessage, e)
          }
        }
      }

      //topic存在
      if (isTopicExists) {
        //訂閱topic
        consumer.assign(tps)
        //指定超時時間 拉取(poll)一定時間段broker中可消費的數據 //防止併發操作 源碼分析:https://blog.csdn.net/m0_37343985/article/details/83478256
        consumer.poll(Duration.ofSeconds(100))
        //指定消費位置到HBase存儲的offset
        consumer.seekToBeginning(tps)

        //獲取Kafka中消費的offset
        JavaConversions.asScalaBuffer(tps).foreach(tp => {
          val earliestOffset = consumer.position(tp)
          earlistTPOffSet.put(tp, earliestOffset)
          currentTPOffsets.put(tp, earliestOffset)
        })
        //獲取latestTPOffsets
        consumer.seekToEnd(tps)
        JavaConversions.asScalaBuffer(tps).foreach(tp => {
          val latestOffset = consumer.position(tp)
          latestTPOffsets.put(tp, latestOffset)
        })

        //判斷currentTPOffset是否越界並修正 currentTPOffset:HBase中存儲的消費記錄 untilOffset:最後一次結束的offset
        for (offsetRange <- currentTPOffset) {
          val tp = new TopicPartition(topic, offsetRange.partition)
          //該Topic的某分區kafka中最早的offset
          val eOffSet = earlistTPOffSet.get(tp)
          //該Topic的某分區kafka中最晚的offset
          val lOffSet = latestTPOffsets.get(tp)
          //之前被消費過的記錄(存在於Hbase)
          val pOffset = offsetRange.untilOffset
          //如果之前消費的offset < kafka中最早的offset,意味着kafka分區中部分數據過期丟失了,取目前kafka中的最早記錄開始消費(警告:可能存在取到最早的offset但是依舊過期)。目前存在問題的都是這種
          if (pOffset < eOffSet) {
            currentTPOffsets.put(tp, eOffSet)
            logger.error("Offset頭越界 topic[" + topic + "] partition[" + offsetRange.partition + "] fromOffsets[" + pOffset + "] earlistOffset[" + eOffSet + "]")
          } else if (pOffset > lOffSet) {
            //如果消費的offset > kafka中最晚的offset,代表出現了尾越界,此時需要根據配置動態(最早或最晚)選擇消費的offset
            //當一個topic數據被清除(清除可以是kafka生產者清除topic中的原信息,或者是刪除了topic後重新創建)時,保存的offset(offset 保存在客戶端)信息並沒有被清除
            if (reset == "latest") {
              //選擇最晚開始消費
              currentTPOffsets.put(tp, lOffSet)
            } else {
              //選擇最早開始消費
              currentTPOffsets.put(tp, eOffSet)
            }
            logger.error("Offset尾越界 topic[" + topic + "] partition[" + offsetRange.partition + "] fromOffsets[" + pOffset + "] latestOffset[" + lOffSet + "]")
          } else {
            currentTPOffsets.put(tp, pOffset)
          }
        }
        consumer.close()
      }
    })
    tableConfig.close()

    //從修正後的當前offset開啓流通道
    var fromOffsets: scala.collection.mutable.Map[TopicPartition, Long] = null
    if (isNewApp) {
      //如果完全是新的應用,從最早或最晚(根據配置)開始消費
      if (reset == "latest") {
        fromOffsets = JavaConversions.mapAsScalaMap(latestTPOffsets)
      } else {
        fromOffsets = JavaConversions.mapAsScalaMap(earlistTPOffSet)
      }
      logger.warn("當前應用是全新的應用名,會根據配置動態選擇從最新或最晚開始消費!!!當前選擇是" + reset )
    } else {
      fromOffsets = JavaConversions.mapAsScalaMap(currentTPOffsets)
    }

    //該方法將會創建和kafka分區一樣的rdd個數,而且會從kafka並行讀取。
    //比如讀取一個kafka消息的輸入流 每個Receiver(運行在Executor上),加上inputDStream 會佔用一個core/slot
    val stream = KafkaUtils.createDirectStream[String, String](
      ssc,
      PreferConsistent,
      Subscribe[String, String](topics, kafkaParams, fromOffsets)
    )
    val endTime = System.currentTimeMillis()
    logger.warn("從Hbase獲取offset時間:" + (endTime - startTime) + "ms")
    stream
  }
  1. 保存offset
  /**
    * 保存多個DStream的offset到HBase
    * @param stream kafka消費的stream list
    * @param properties 配置文件CaseConf
    */
  def saveAllOffSetToHBase(stream:List[DStream[ConsumerRecord[String, String]]], properties: Map[String, String]) : Unit= {
    //獲取kafka 消費者組id
    val appName = properties("app.name")
    val rowPrefix = appName + "|" + properties("kafka.group.id")
    //OffsetRange
    var offsetRanges: Array[OffsetRange] = Array[OffsetRange]()
    //遍歷DStream 每個DStream都是一個Kafka分區
    for (dStream <- stream) {
      //foreachRDD本身是transform算子,在drive中執行,此函數應該將每個RDD(每個批次)中的數據推送到外部系統
      //其函數中通常要有action算子,函數func中包含RDD操作,這將強制計算流RDD。
      //即每個interval產生且僅產生一個RDD,每隔一段時間就會產生一個RDD time是該批次RDD產生的時間
      dStream.foreachRDD { (rdd, time) =>
        var rddTime = time.toString().substring(0,13)
        //yyyyMMddHHmm
        rddTime =  TimeUtils.timeStampToString1(rddTime).substring(0,14)
        //使用 asInstanceOf 將對象轉換爲指定類型
        offsetRanges = rdd.asInstanceOf[HasOffsetRanges].offsetRanges
        //保存單個offSet到HBase
        saveOffSetsToHBase(offsetRanges, rowPrefix, rddTime)
      }
    }
  }

  /**
    * 保存單個offSet到HBase,並記錄offset到hbase日誌裏面。參見saveAllOffSetToHbase
    * @param offSets topic分區的offset數組
    * @param rowPrefix kafka groupId
    */
  def saveOffSetsToHBase(offSets: Array[OffsetRange],rowPrefix: String, rddTime:String) : Unit = {
    //獲取保存offset的Hbase表名
    val tableName = HbaseTableEnum.KAFKA_OFFSET.getTableName
    //獲取Hbase表
    val table = HBaseUtils.getTable(tableName)
    //遍歷保存的offset數組
    for (offSet <- offSets) {
      //設置AION_offset表的rowkey:groupId+topic名 列名:分區號 列值:起始offset+當前offset  rowKey如:80234706|paaslogApp-lb50-83-service-Deployment-content-pool-service  column=t:0, timestamp=1591781340442, value=137873|137873
      HBaseUtils.setData(table, rowPrefix + "|" + offSet.topic, "t", offSet.partition.toString, offSet.fromOffset + "|" + offSet.untilOffset)
      //記錄offset消費記錄日誌
      //HBaseUtils.setData(table,"LOG" + "|" + rowPrefix + "|" + offSet.topic + "|" + rddTime,"t", offSet.partition.toString, offSet.fromOffset + "|" + offSet.untilOffset)
    }
    table.close()
  }

調用方式舉例

import com.licf.bigdata.spark.service.SparkService
import com.licf.bigdata.spark.util._
import com.licf.bigdata.spark.util.constant.HadoopConstant.LOCAL_FLAG
import org.apache.kafka.clients.consumer.ConsumerRecord
import org.apache.log4j.Logger
import org.apache.spark.streaming.dstream.DStream
import org.apache.spark.streaming.{Seconds, StreamingContext}


/**
  * 使用的是at least once
  * @author 李燦峯 2020-07-03
  */
object AionLogCollectorToES {
  val logger: Logger = Logger.getLogger(getClass)

  /**
    * 運行入口函數
    *
    * @param args 參數1:配置文件地址
    */
  def main(args: Array[String]): Unit = {
    var caseConf: CaseConf = null
    //判斷是否是本地環境
    if (LOCAL_FLAG) {
      //本地調試登錄到hadoop
      HadoopLoginUtils.loginHadoop()
      caseConf = new CaseConf()
    } else {
      caseConf = new CaseConf(args(0))
    }
    //創建Spark Streaming上下文
    val ssc = createContext(caseConf)
    //啓動Spark Streaming
    ssc.start()
    //一直運行,除非人爲干預再停止
    ssc.awaitTermination()
  }

  /**
    * 創建Streaming rdd,解析原始日誌並存入到ES
    *
    * @param caseConf 配置文件
    * @return
    */
  def createContext(caseConf: CaseConf): StreamingContext = {
    //RDD 批次間隔
    val batchInterval = caseConf.get("batch.interval").toLong
    //kafka消費的topic
    val topicSet = caseConf.get("kafka.input.topics").split(",").toSet
    //獲取spark Streaming上下文 10s一個批次
    val ssc = new StreamingContext(StreamingUtils.getSparkConf(caseConf), Seconds(batchInterval))
    //打印Spark配置
    StreamingUtils.printSparkConf(ssc.sparkContext)

    // dev/st環境目前只能用Rt方法創建Stream,生產上使用FromHBase方法 cache下不會產生重複計算的問題
    //Direct模式相對Recevier模式簡化了並行度,生成的DStream的並行度與讀取的topic的partition一一對應,有多少個topic partition就會生成多少個task; 反覆使用的Dstream需要緩存起來
    val kafkaDStream = StreamingUtils.createDirectStreamFromHBase(ssc, topicSet, caseConf.getAll)

    //真實處理邏輯
    SparkService.process(kafkaDStream, caseConf)

    // topic分區的offset提交到Hbase
    StreamingUtils.saveAllOffSetToHBase(List[DStream[ConsumerRecord[String, String]]](kafkaDStream), caseConf.getAll)
    ssc
  }

關注下面兩句即可(調用 + 保存)
val kafkaDStream = StreamingUtils.createDirectStreamFromHBase(ssc, topicSet, caseConf.getAll)

StreamingUtils.saveAllOffSetToHBase(ListDStream[ConsumerRecord[String, String]], caseConf.getAll)

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