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
}
}