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)