Kafka手动提交offset和幂等性设置(精确一次性消费)

1. 精确一次消费

1.2 定义

精确一次消费(Exactly-once) 是指消息一定会被处理且只会被处理一次。不多不少就一次处理。
如果达不到精确一次消费,可能会达到另外两种情况:
至少一次消费(at least once),主要是保证数据不会丢失,但有可能存在数据重复问题。
最多一次消费 (at most once),主要是保证数据不会重复,但有可能存在数据丢失问题。
如果同时解决了数据丢失和数据重复的问题,那么就实现了精确一次消费的语义了。

1.2 问题如何产生

数据何时会丢失: 比如实时计算任务进行计算,到数据结果存盘之前,进程崩溃,假设在进程崩溃前kafka调整了偏移量,那么kafka就会认为数据已经被处理过,即使进程重启,kafka也会从新的偏移量开始,所以之前没有保存的数据就被丢失掉了。
数据何时会重复: 如果数据计算结果已经存盘了,在kafka调整偏移量之前,进程崩溃,那么kafka会认为数据没有被消费,进程重启,会重新从旧的偏移量开始,那么数据就会被2次消费,又会被存盘,数据就被存了2遍,造成数据重复。

1.3 如何解决

1.3.1 利用关系型数据库的事务进行处理。

出现丢失或者重复的问题,核心就是偏移量的提交与数据的保存,不是原子性的。如果能做成要么数据保存和偏移量都成功,要么两个失败。那么就不会出现丢失或者重复了。
这样的话可以把存数据和偏移量放到一个事务里。这样就做到前面的成功,如果后面做失败了,就回滚前面那么就达成了原子性。
问题与限制
但是这种方式有限制就是数据必须都要放在某一个关系型数据库中,无法使用其他功能强大的nosql数据库。如果保存的数据量较大一个数据库节点不够,多个节点的话,还要考虑分布式事务的问题。

1.3.2 手动提交偏移量+幂等性处理

如果能够同时解决数据丢失和数据重复问题,就等于做到了精确一次消费。
那各个击破。
首先解决数据丢失问题,办法就是要等数据保存成功后再提交偏移量,所以就必须手工来控制偏移量的提交时机。
但是如果数据保存了,没等偏移量提交进程挂了,数据会被重复消费。怎么办?那就要把数据的保存做成幂等性保存。即同一批数据反复保存多次,数据不会翻倍,保存一次和保存一百次的效果是一样的。如果能做到这个,就达到了幂等性保存,就不用担心数据会重复了。
难点
话虽如此,在实际的开发中手动提交偏移量其实不难,难的是幂等性的保存,有的时候并不一定能保证。所以有的时候只能优先保证的数据不丢失。数据重复难以避免。即只保证了至少一次消费的语义。

1.4 手动提交偏移

流程
在这里插入图片描述
为什么用redis保存偏移量:

本身kafka 0.9版本以后consumer的偏移量是保存在kafka的__consumer_offsets主题中。但是如果用这种方式管理偏移量,有一个限制就是在提交偏移量时,数据流的元素结构不能发生转变,即提交偏移量时数据流,必须是InputDStream[ConsumerRecord[String, String]] 这种结构。但是在实际计算中,数据难免发生转变,或聚合,或关联,一旦发生转变,就无法在利用以下语句进行偏移量的提交:
xxDstream.asInstanceOf[CanCommitOffsets].commitAsync(offsetRanges)

所以实际生产中通常会利用zookeeper,redis,mysql等工具对偏移量进行保存。

1.5 手动提交偏移量方法的管理类

该管理类,封装了从Redis中获取和写入偏移量的方法

import java.util
import org.apache.kafka.common.TopicPartition
import org.apache.spark.streaming.kafka010.OffsetRange
import redis.clients.jedis.Jedis

object OffsetManager {

    // Redis中读取offset
    def getOffset(topicName: String, groupId: String) = {
        // Redis type? hase key?offset:[topic]:[groupId]  field? partiton_id value offset
        val jedis: Jedis = RedisUtil.getJedisClient
        val offsetKey = "offset:" + topicName + ":" + groupId
        val offsetMap = jedis.hgetAll(offsetKey)
        jedis.close()

        import scala.collection.JavaConversions._
        val kafkaOffsetMap = offsetMap.map {
            case (partitionid, offset) =>
                println("加载分区偏移量:" + partitionid + ":" + offset)
                (new TopicPartition(topicName, partitionid.toInt), offset.toLong)
        }.toMap
        kafkaOffsetMap
    }

    // 向Redis写入Offset
    def saveOffset(topicName: String, groupId: String, offsetRangers: Array[OffsetRange]) = {
        val jedis: Jedis = RedisUtil.getJedisClient
        val offsetKey = "offset:" + topicName + ":" + groupId
        val offsetMap = new util.HashMap[String, String]()
        // 转换结构,offsetRangers =>offsetMap
        for (offset <- offsetRangers) {
            val partition = offset.partition
            val utilOffset = offset.untilOffset
            offsetMap.put(partition + "", utilOffset + "")
            println("写入分区:" + partition + ":" + offset.fromOffset + "---->" + offset.untilOffset)
        }

        if (offsetMap != null && offsetMap.size() > 0) {
            jedis.hmset(offsetKey, offsetMap)
        }

        jedis.close()
    }
}

1.6 实现手动提交偏移量

import java.text.SimpleDateFormat
import java.util.Date
import com.alibaba.fastjson.JSON
import com.atguigu.gmall0105.realtime.bean.DauInfo
import com.atguigu.gmall0105.realtime.util.{MyEsUtil, MyKafkaUtil, OffsetManager, RedisUtil}
import org.apache.kafka.clients.consumer.ConsumerRecord
import org.apache.spark.SparkConf
import org.apache.spark.streaming.dstream.InputDStream
import org.apache.spark.streaming.kafka010.{HasOffsetRanges, OffsetRange}
import org.apache.spark.streaming.{Seconds, StreamingContext}


object DauApp {

    def main(args: Array[String]): Unit = {

        val sparkConf: SparkConf = new SparkConf().setAppName("dau_app").setMaster("local[*]")
        val ssc = new StreamingContext(sparkConf, Seconds(5))

        val topic = "GMALL_STARTUP_0105"
        val groupId = "DAU_GROUP"

        //手动提交kafka偏移量  获取偏移量判断
        val kafkaOffsetMap = OffsetManager.getOffset(topic, groupId)
        var recordInputStream: InputDStream[ConsumerRecord[String, String]] = null
        if (kafkaOffsetMap != null && kafkaOffsetMap.size > 0) {
            recordInputStream = MyKafkaUtil.getKafkaStream(topic, ssc, kafkaOffsetMap, groupId)
        } else {
            recordInputStream = MyKafkaUtil.getKafkaStream(topic, ssc)
        }

        // 将读取该批次后的数据的偏移量写入Redis
        // 得到本批次的偏移量的结束位置,用于写入偏移量
        var offsetRangers = Array.empty[OffsetRange]
        val inputGetOffsetDstream = recordInputStream.transform { rdd =>
            // 获取该批次的流的offset偏移量区间,类时3-9 表示 从3 读取到偏移量9的位置
            offsetRangers = rdd.asInstanceOf[HasOffsetRanges].offsetRanges
            rdd
        }

        //        val recordInputStream: InputDStream[ConsumerRecord[String, String]] = MyKafkaUtil.getKafkaStream(topic, ssc)

        //        recordInputStream.map(_.value()).print

        val jsonObjDstream = inputGetOffsetDstream.map { record =>
            val jsonString = record.value()
            val jsonObj = JSON.parseObject(jsonString)
            val ts = jsonObj.getLong("ts")
            val dateHourString: String = new SimpleDateFormat("yyyy-MM-dd HH").format(new Date(ts))
            val dateHour: Array[String] = dateHourString.split(" ")

            jsonObj.put("dt", dateHour(0))
            jsonObj.put("hr", dateHour(1))
            jsonObj
        }

        // redis进行去重
        // 该种写法导致过滤每条数据都会连接一次redis以及关闭一次redis性能不会好
        //        jsonObjDstream.filter { jsonObi =>
        //            val dt = jsonObi.getString("dt")
        //            val mid = jsonObi.getJSONObject("common").getString("mid")
        //            val jedis = RedisUtil.getJedisClient
        //            val dauKey = "dau:" + dt
        //            val isNew = jedis.sadd(dauKey, mid)
        //            jedis.close()
        //            if (isNew == 1L) {// 如果返回1表示之前没有,0表示之前已经有了
        //                true
        //            } else {
        //                false
        //            }
        //        }
        val filterObjDstream = jsonObjDstream.mapPartitions { records =>
            val redis = RedisUtil.getJedisClient

            val filterDstream = records.filter { record =>
                val dt: String = record.getString("dt")
                val mid = record.getJSONObject("common").getString("mid")
                val dauKey = "dau:" + dt
//                println("dauKey" + dauKey)
                val isNew = redis.sadd(dauKey, mid)
                redis.expire(dauKey, 3600 * 24) // 今天的最后一条数据24小时后失效

                val a = if (isNew == 1) {
                    true
                } else {
                    false
                }
                println("a: " + a)
                a
            }
            redis.close()
            filterDstream
        }

        filterObjDstream.foreachRDD { rdd =>
            rdd.foreachPartition { jsonItr =>
                val list = jsonItr.toList
                val dauList = list.map { jsonObj =>
                    val commonJSONObj = jsonObj.getJSONObject("common")
                    DauInfo(commonJSONObj.getString("mid")
                        , commonJSONObj.getString("uid")
                        , commonJSONObj.getString("ar")
                        , commonJSONObj.getString("ch")
                        , commonJSONObj.getString("vc")
                        , jsonObj.getString("dt")
                        , jsonObj.getString("hr")
                        , "00"
                        , jsonObj.getLong("ts")
                    )
                }

                val dt = new SimpleDateFormat("yyyy-MM-dd").format(new Date())
                MyEsUtil.bulkDoc(dauList, "gmall_dau0105_info_" + dt)
            }

            println("提交偏移量1")
            OffsetManager.saveOffset(topic, groupId, offsetRangers)
            println("提交偏移量2")

        }
        // 偏移量提交

        ssc.start()
        ssc.awaitTermination()
    }
}

1.7 不产生重复数据

上述方法实现了,数据从kafka到处理后结果到达数据库中至少一次被保存目的,但由于某些原因可能导致offset没来的及写入Redis集群挂掉了,导致再次启动会产生重复数据。对于当前使用ES可以再向ES插入数时利用ES的幂等性机制,实现ES中不出现重复消费的数据,即:在插入ES时,指定id(put操作是幂等性的,相同id会覆盖之前的数据,post不是幂等性的,会自动生成一个新的id插入ES)实现幂等性机制。
其实,对于任何数据库只要更具主键进行插入或更新数据都能实现幂等性,这样就能事项精确一次性消费了。

1.8 获取Kafka数据源工具

import java.util.Properties
import org.apache.kafka.clients.consumer.ConsumerRecord
import org.apache.kafka.common.TopicPartition
import org.apache.kafka.common.serialization.StringDeserializer
import org.apache.spark.streaming.StreamingContext
import org.apache.spark.streaming.dstream.InputDStream
import org.apache.spark.streaming.kafka010.{ConsumerStrategies, KafkaUtils, LocationStrategies}

object MyKafkaUtil {
    private val properties: Properties = PropertiesUtil.load("config.properties")
    val broker_list = properties.getProperty("kafka.broker.list")

    // kafka消费者配置
    var kafkaParam = collection.mutable.Map(
        "bootstrap.servers" -> broker_list, //用于初始化链接到集群的地址
        "key.deserializer" -> classOf[StringDeserializer],
        "value.deserializer" -> classOf[StringDeserializer],
        //用于标识这个消费者属于哪个消费团体
        "group.id" -> "gmall_consumer_group",
        //如果没有初始化偏移量或者当前的偏移量不存在任何服务器上,可以使用这个配置属性
        //可以使用这个配置,latest自动重置偏移量为最新的偏移量
        "auto.offset.reset" -> "latest",
        //如果是true,则这个消费者的偏移量会在后台自动提交,但是kafka宕机容易丢失数据
        //如果是false,会需要手动维护kafka偏移量
        "enable.auto.commit" -> (true: java.lang.Boolean)
    )


    // LocationStrategies:新版本的kafka API是预先取一部分数据(默认64K?)到executor中缓存
    // 所以通过该对象指定executor消费topic分区情况
    // LocationStrategies.PreferConsistent:持续的在所有Executor之间平均分配分区
    // LocationStrategies.PreferBrokers:如果kafka brokers和executors是在同一个hosts中,优先将同在一个hosts的配对
    // LocationStrategies.PreferFixed:如果kafka 分区有很大的偏斜,可以指定某个hosts处理某个分区

    // ConsumerStrategies:设置如何获取topics
    // ConsumerStrategies.Subscribe:以集合的形式指定topic
    // ConsumerStrategies.SubscribePattern :以正则的的形式指定topic
    // ConsumerStrategies.Assign  :可以指定topic的分区
    // 如果上面三种方法,不能满足需求,可以继承ConsumerStrategies自定义

    // 创建DStream,返回接收到的输入数据
    def getKafkaStream(topic: String, ssc: StreamingContext): InputDStream[ConsumerRecord[String, String]] = {
        val dStream = KafkaUtils.createDirectStream[String, String](
            ssc,
            LocationStrategies.PreferConsistent,
            ConsumerStrategies.Subscribe[String, String](Array(topic),
                kafkaParam))
        dStream
    }


    def getKafkaStream(topic: String, ssc: StreamingContext, groupId: String): InputDStream[ConsumerRecord[String, String]] = {
        kafkaParam("group.id") = groupId
        val dStream = KafkaUtils.createDirectStream[String, String](ssc, LocationStrategies.PreferConsistent, ConsumerStrategies.Subscribe[String, String](Array(topic), kafkaParam))
        dStream
    }

    def getKafkaStream(topic: String, ssc: StreamingContext, offsets: Map[TopicPartition, Long], groupId: String): InputDStream[ConsumerRecord[String, String]] = {
        kafkaParam("group.id") = groupId
        val dStream = KafkaUtils.createDirectStream[String, String](ssc, LocationStrategies.PreferConsistent, ConsumerStrategies.Subscribe[String, String](Array(topic), kafkaParam, offsets))
        dStream
    }
}

获取配置文件

import java.io.InputStreamReader
import java.util.Properties

object PropertiesUtil {

    def main(args: Array[String]): Unit = {
        // 从resources文件读取配置信息
        val properties: Properties = PropertiesUtil.load("config.properties")

        println(properties.getProperty("kafka.broker.list"))
    }

    def load(propertieName:String): Properties ={
        val prop=new Properties();
        prop.load(new InputStreamReader(Thread.currentThread().getContextClassLoader.getResourceAsStream(propertieName) , "UTF-8"))
        prop
    }
}
  • 0
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值