当使用sparkstreaming处理流式数据的时候,它的数据源搭档大部分都是Kafka,尤其是在互联网公司颇为常见。
当他们集成的时候我们需要重点考虑就是如果程序发生故障,或者升级重启,或者集群宕机,它究竟能否做到数据不丢不重呢?
也就是通常我们所说的高可靠和稳定性,通常框架里面都带有不同层次的消息保证机制,一般来说有三种就是:
at most once 最多一次
at least once 最少一次
exactly once 准确一次
在sparkstreaming如何做到数据不丢失呢?
(1)使用checkpoint
(2)自己维护kafka偏移量
checkpoint配合kafka能够在特定环境下保证不丢不重,注意为什么要加上特定环境呢,这里有一些坑,checkpoint是对sparkstreaming运行过程中的元数据和每次rdds的数据状态保存到一个持久化系统中,当然这里面也包含了offset,一般是HDFS,S3,如果程序挂了,或者集群挂了,下次启动仍然能够从checkpoint中恢复,从而做到生产环境的7*24高可用。
但是checkpoint的最大的弊端在于,一旦你的流式程序代码或配置改变了,或者更新迭代新功能了,这个时候,你先停旧的sparkstreaming程序,然后新的程序打包编译后执行运行,会发现两种情况:
(1)启动报错,反序列化异常
(2)启动正常,但是运行的代码仍然是上一次的程序的代码。
为什么会出现上面的两种情况?
这是因为checkpoint第一次持久化的时候会把整个相关的jar给序列化成一个二进制文件,每次重启都会从里面恢复,但是当你新的程序打包之后序列化加载的仍然是旧的序列化文件,这就会导致报错或者依旧执行旧代码。
有的同学可能会说,既然如此,直接把上次的checkpoint删除了,不就能启动了吗?
确实是能启动,但是一旦你删除了旧的checkpoint,新启动的程序,只能从kafka的smallest或者largest的偏移量消费,默认是从最新的,如果是最新的,而不是上一次程序停止的那个偏移量就会导致有数据丢失,如果是老的,那么就会导致数据重复。
不管怎么样搞,都有问题。
https://spark.apache.org/docs/2.1.0/streaming-programming-guide.html#upgrading-application-code
针对这种问题,spark官网给出了2种解决办法:
(1)旧的不停机,新的程序继续启动,两个程序并存一段时间消费。 评价:仍然有丢重复消费的可能
(2)停机的时候,记录下最后一次的偏移量,然后新恢复的程序读取这个偏移量继续工作,从而达到不丢消息。 评价:官网没有给出具体怎么操作,只是给了个思路
第二种思路是正确的,但还需要自己维护一个offset状态,这样以来checkpoint这个功能只能在程序写好之后不允许再次变动,但可以重启的情况保证高可靠。
但实际情况是大多数公司的代码都会频繁迭代和升级,与checkpoint刚好相悖,这样以来checkpoint的作用便显的有点没用了,既然还是需要自己维护offset状态,那么不用checkpoint也罢,完全自己维护offset状态到zk中即可。所以果断弃用checkpoint,采用自己维护offset。其原理如下:
首次启动,先从zk中找是否有上次存储的偏移量,如果没有就从最新的消费,然后保存偏移量至zk中。
如果从zk中找到了偏移量,那么就从指定的偏移量处开始消费处理,每个批处理处理完毕后,都会更新新的offset到zk中,这样以来无论是程序故障,还是宕机,再次启动后都会从上次的消费的偏移量处继续开始消费,而且程序的升级或功能改动新版本的发布都能正常运行, 并做到了消息不丢。
需要注意的是,虽然上游能够做到准确一次的消费,但是下游的落地存储输出,比如写入hbase,redis,mysql,es等等如果失败了,整条消息依旧会失败,这个完全要靠自己的设计了,要么记录log,针对特定数据记录,如果失败定期 重新打入kafka走程序恢复或者手动恢复。
或者设计存储的时候,有复合主键,把偏移量提前,就算重复消费,但主键一样,最终只会有一条数据落地,这个要分场景和具体业务结合使用了。
回到主题,自己维护kafka的offset状态,如何做?下面给出一个使用kafka管理offset的例子。
版本环境:
spark 2.4.3
kafka 0.10.1
注意:
(1) 关闭自动提交offset (采用手动提交)
import java.util.Properties
import org.apache.kafka.clients.consumer.{Consumer, ConsumerRecords, KafkaConsumer}
import org.apache.kafka.common.TopicPartition
import org.apache.kafka.common.serialization.StringDeserializer
import org.apache.spark.streaming.kafka010._
import org.apache.spark.streaming.{Seconds, StreamingContext}
import org.apache.spark.{SparkConf, TaskContext}
import scala.collection.JavaConverters._
import scala.collection.mutable
/**
* 最新版
* 使用kafka010:
* 支持分区自动检测:
* 详细查看spark streaming源码---DirectKafkaInputDStream的compute方法
*
* 使用主要jar版本:
* spark-streaming_2.11 2.4.3
* spark-streaming-kafka-0-10_2.11 2.4.3
* kafka-clients 0.10.1.1 (不可使用0.10以下版本)
* 注意 spark-streaming_2.11 和 spark-streaming-kafka-0-10_2.11 的版本必须要一致
* 否则异常: Exception in thread "main" java.lang.AbstractMethodError
*/
object kafka010NamedRDD {
def main(args: Array[String]) {
// 创建一个批处理时间是2s的context 要增加环境变量
val sparkConf = new SparkConf()
.setAppName("DirectKafkaWordCount")
.setMaster("local[*]")
val ssc = new StreamingContext(sparkConf, Seconds(5))
// 使用broker和topic创建DirectStream
val topicsSet: Set[String] = "test_stream".split(",").toSet
val kafkaParams: Map[String, Object] = Map[String, Object]("bootstrap.servers" -> "127.0.0.1:9092",
"key.deserializer"->classOf[StringDeserializer],
"value.deserializer"-> classOf[StringDeserializer],
"group.id"->"test4", //消费者组
"auto.offset.reset" -> "latest", //从最新的开始消费
"enable.auto.commit"->(false: java.lang.Boolean)) //关闭自动提交offset(必须关闭,手动提交)
// 没有接口提供 offset
val messages = KafkaUtils.createDirectStream[String, String](
ssc,
LocationStrategies.PreferConsistent,
ConsumerStrategies.Subscribe[String, String](
topicsSet, //topic列表
kafkaParams, //kafka相关配置参数
getLastOffsets(kafkaParams ,topicsSet))) //最后一次提交的offset
// 用来存储窗口中最近的一个kafkardd携带的offset信息
var A:mutable.HashMap[String,Array[OffsetRange]] = new mutable.HashMap()
//transform操作中可以使用多个算子对DStream进行操作
val trans = messages.transform(r => {
//获取offset信息
val offsetRanges = r.asInstanceOf[HasOffsetRanges].offsetRanges
A += ("rdd1" -> offsetRanges)
r
}).countByWindow(Seconds(10), Seconds(5)) //返回基于滑动窗口的DStream中的元素的数量(窗口长度,滑动间隔)
trans.foreachRDD(rdd=> {
if(!rdd.isEmpty()) {
val offsetRanges = A("rdd1") //.asInstanceOf[HasOffsetRanges].offsetRanges
rdd.foreachPartition { _ =>
val o: OffsetRange = offsetRanges(TaskContext.get.partitionId)
println(s"${o.topic} ${o.partition} ${o.fromOffset} ${o.untilOffset}")
}
println(rdd.count())
println(offsetRanges)
// 手动提交offset ,前提是禁止自动提交(异步提交offset到kafka)
messages.asInstanceOf[CanCommitOffsets].commitAsync(offsetRanges)
}
// A.-("rdd1")
})
// 启动流
ssc.start()
ssc.awaitTermination()
}
/**
* 获取最新的offset
* @param kafkaParams kafka的配置参数
* @param topics topic名称
* @return
*/
def getLastOffsets(kafkaParams : Map[String, Object],topics:Set[String]): Map[TopicPartition, Long] ={
val props = new Properties()
props.putAll(kafkaParams.asJava)
//定义一个消费者
val consumer = new KafkaConsumer[String, String](props)
//通过subscribe()方法订阅主题具有消费者自动均衡的功能。
// 在多线程情况下,多个消费者进程根据分区分配策略自动分配消费者线程与分区的关系,
// 当一个消费者组的消费者发生增减变化,分区分配会自动调整,以实现消费负载均衡及故障自动转移。
consumer.subscribe(topics.asJavaCollection)
paranoidPoll(consumer)
val map = consumer.assignment().asScala.map { tp => //topic分区
println(tp +" --- " + consumer.position(tp))
tp -> consumer.position(tp)
}.toMap
println(map)
consumer.close()
map
}
def paranoidPoll(c: Consumer[String, String]): Unit = {
val msgs: ConsumerRecords[String, String] = c.poll(0)
if (!msgs.isEmpty) {
// position should be minimum offset per topicpartition
msgs.asScala.foldLeft(Map[TopicPartition, Long]()) { (acc, m) =>
val tp = new TopicPartition(m.topic, m.partition)
val off = acc.get(tp).map(o => Math.min(o, m.offset)).getOrElse(m.offset)
acc + (tp -> off)
}.foreach { case (tp, off) =>
//将消费者起始位置重置到指定偏移量位置
c.seek(tp, off)
}
}
}
}
使用zk维护offset也是比较不错的选择,如果将checkpoint存储在HDFS上,每隔几秒都会向HDFS上进行一次写入操作而且大部分都是小文件,且不说写入性能怎么样,就小文件过多,对整个Hadoop集群都不太友好。因为只记录偏移量信息,所以数据量非常小,zk作为一个分布式高可靠的的内存文件系统,非常适合这种场景。