SparkStreaming与Kafka整合
官网:http://spark.apache.org/docs/latest/streaming-kafka-integration.html
从官网的介绍当中我们也可以发现,Spark Streaming有2种方式去接收kafka的数据:
- 使用receiver去接收数据,同时使用的是kafka的high level api
- 另外一种方式是没有使用receiver的,即direct方式,是从Spark 1.3开始
本文将针对这两种方式进行详解,其中在direct模式中还会涉及对offset的管理与维护
receiver模式
这种方式使用receiver来接收kafka的数据,receiver是通过kafka的high-level consumer api来进行实现的;通过receiver从kafka接收数据并存储到Spark executors,然后Spark Streaming job开始处理数据
官网相关介绍:
http://spark.apache.org/docs/latest/streaming-kafka-0-8-integration.html#approach-1-receiver-based-approach
基于receiver方式的图解
从上图中我们可以发现:
- receiver是跑在executor中的(只在一个executor中运行),在executor中会有一个专门的task来运行接收数据,且是常驻的
- 这种方式,默认的Storage level为MEMORY_AND_DISK_SER_2
由于是该策略,因此会将数据存放在2个地方,然后会有一堆task来进行处理数据 - 使用的是kafka高阶api,kafka对应的offset信息是存储在ZK中的
- 数据存在哪个executor上会和Driver进行汇报
后面Driver可以根据数据存在的地方,来发放task到对应节点来进行运行
数据丢失问题
基于上述的实现会存在一个问题:
task接收数据,并存储offset到zk中去,那么当driver挂了,由于driver挂了,相应的executor也会被随之干掉,那么在executor中的数据也就丢失了(当这部分数据还没处理完的时候)
解决方案:
为了保证数据不丢失,就需要额外开启WAL机制,该机制在Spark 1.2版本中出来的
WAL机制即先将日志记录下来,会将所有接受到的数据都写到日志中去(存储到HDFS上)
这也driver端恢复之后,可以通过HDFS上的WAL进行数据的恢复
WAL参数:spark.streaming.receiver.writeAheadLog.enable
思考几个问题:
- 为什么Storage level是MEMORY_AND_DISK_SER_2?
设置为2,就是考虑到数据会丢失的场景;但是从上述的案例中我们可以发现,即使是2也很难保证数据不丢失 - 开启了WAL机制后,还有必要设置为2么?
答案是没有必要的;如果开启了WAL机制,接收的数据就已经被写入到HDFS上去了,HDFS本身就有副本,因此这个Storagelevel直接用MEMORY_AND_DISK_SER就行了
数据吞吐量问题
在开启了WAL机制后,的确是解决了数据丢失的问题,但是随之带来了个问题:
- 写完HDFS的WAL之后,再去更新zk中的offset,这样整体的吞吐量肯定是下降
- 当中引入了HDFS,整体的链路拉长了,吞吐量会急剧下降
receiver方式存在的其余问题
- 在程序失败恢复时,有可能出现数据部分落地,但是程序失败,未更新offset的情况(即数据写入成功,offset没有保存成功),这样就可能会导致数据重复消费
- 由于这里的offset是kafka自己维护在zk中的,因此如果想要保证精准一次的消费语义,就需要保证数据写入操作和offset保存操作的原子性,要么一起成功要么一起失败;或者保证数据写入操作的幂等性
receiver方式的注意点
- Kafka的topic是有partition的;假设1个topic对应了3个partition,那么RDD的并行度不是3的,kafka与rdd的partition是不等于的;增加topic的partition,只会多增加几个线程去处理接收数据,但是并不会提高Spark处理数据时候的并行度
- 多个Kafka的input DStream被创建,即有多个不同的group和topic,这样做目的是为了增加receiver的数量,进而提高接收数据的并行度
receiver方式的总结
- 会存在数据丢失的情况,可以用WAL机制解决
- WAL机制导致了数据延迟现象的产生
- 好处在于offset,我们不需要关注,使用的高阶api,直接kafka自己维护就搞定了
direct模式
Spark Streaming与kafka的对接采用direct方式,有如下几个特点:
- 不需要Receiver
- topic的partition和RDD的partition是1:1
- 自己手工维护offset
基于direct的整合[代码]
LocationStrategies的选择
新的Kafka Consumer API会提前fetch消息放入buffer中,之后将会分发数据信息到可用的executor上面;在很多场景下会选择使用PreferConsistent:
- 如果executor和kafka的broker在同一个节点上,应该采取PreferBrokers(可能性不大)
- 否则则选用PreferConsistent,数据尽量均匀的分布到各个executor上面去
管理offset的三种方式
checkpoint
生产上不建议使用这种方式
kafka itself
使用Kafka提供的api来进行维护offset,核心提交代码就一行:
stream.asInstanceOf[CanCommitOffsets].commitAsync(offsetRanges)
注意点:
- 使用Kafka自身维护offset,对应的offset信息存在__consumer_offsets这个topic中
- 必须在业务逻辑处理完成之后,再进行提交offset信息,因为kafka并不是事务型的,所以我们的输出必须保证幂等性
代码示例:
object StreamingKafkaDirectV2 {
def main(args: Array[String]): Unit = {
val ssc = ContextUtils.getStreamingContext(this.getClass.getSimpleName, 5)
val kafkaParams = Map[String, Object](
"bootstrap.servers" -> "localhost:9093,localhost:9094,localhost:9095",
"key.deserializer" -> classOf[StringDeserializer],
"value.deserializer" -> classOf[StringDeserializer],
"group.id" -> "use_a_separate_group_id_for_each_stream",
"auto.offset.reset" -> "earliest", //每次从头开始消费数据
"enable.auto.commit" -> (false: java.lang.Boolean)
)
val topics = Array("huhu_offset")
val stream = KafkaUtils.createDirectStream[String, String](
ssc,
PreferConsistent, //数据尽量均匀的分布到各个executor上面去
Subscribe[String, String](topics, kafkaParams)
)
stream.foreachRDD(rdd => {
// 获取当前批次的offset数据
val offsetRanges = rdd.asInstanceOf[HasOffsetRanges].offsetRanges
offsetRanges.foreach(x => {
println(s"${x.topic} ${x.partition} ${x.fromOffset} ${x.untilOffset}")
})
// 使用Kafka的api来维护offset
stream.asInstanceOf[CanCommitOffsets].commitAsync(offsetRanges)
})
ssc.start()
ssc.awaitTermination()
}
}
借助三方存储
借助redis来存储offset数据信息,代码示例:
object StreamingKafkaDirectV3 {
def main(args: Array[String]): Unit = {
val ssc = ContextUtils.getStreamingContext(this.getClass.getSimpleName, 5)
val groupId = "huhu_group"
val topic = "huhu_offset"
val kafkaParams = Map[String, Object](
"bootstrap.servers" -> "localhost:9093,localhost:9094,localhost:9095",
"key.deserializer" -> classOf[StringDeserializer],
"value.deserializer" -> classOf[StringDeserializer],
"group.id" -> groupId,
"auto.offset.reset" -> "earliest", //每次从头开始消费数据
"enable.auto.commit" -> (false: java.lang.Boolean)
)
val topics = Array(topic)
// 从保存offset的地方去获取已经提交的offset记录信息
val jedis = RedisUtils.getJedis
val offsets = jedis.hgetAll(topics(0) + "_" + groupId)
var fromOffsets = Map[TopicPartition, Long]()
import scala.collection.JavaConversions._
offsets.map(x => {
fromOffsets += new TopicPartition(topics(0), x._1.toInt) -> x._2.toLong
})
val stream = KafkaUtils.createDirectStream[String, String](
ssc,
PreferConsistent, // 数据尽量均匀的分布到各个executor上面去
Subscribe[String, String](topics, kafkaParams, fromOffsets) // 需要增加每次开始消费的offset参数
)
stream.foreachRDD(rdd => {
if (!rdd.isEmpty()) {
println(rdd.partitions.size)
// 获取当前批次的offset数据
val offsetRanges = rdd.asInstanceOf[HasOffsetRanges].offsetRanges
offsetRanges.foreach(x => {
println(s"${x.topic} ${x.partition} ${x.fromOffset} ${x.untilOffset}")
})
// TODO.. 处理具体的业务逻辑
// 提交offset到redis
val jedis = RedisUtils.getJedis
offsetRanges.foreach(x => {
val topicGroupId = x.topic + "_" + groupId
jedis.hset(topicGroupId, x.partition+"", x.untilOffset+"")
})
jedis.close()
} else {
println("当前批次没有数据")
}
})
ssc.start()
ssc.awaitTermination()
}
}