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)

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值