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,一般需要避免清空数据这种情况!
解决方案流程图
- 初始化kafka连接参数
- 初始化hbase
- 从kafka连接参数获取到最新的topic-partition集合
- 从hbase获取到上批次的untiloffset集合
- 比较untiloffset集合与topic-partition集合,判断是否越界并纠正
- 初始化sparkStreamingContext
- 初始化kafka对应的DStream
- 得到DStream中rdd对应的offsets
- 处理数据
- 更新offset到hbase
代码实现
- 获取修正后的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
}
- 保存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)