Spark 从 0 到 1 学习(9) —— Spark Streaming + Kafka
文章目录
1. Kafka中的数据消费语义介绍
在消费 kafka 中的数据的时候,可以有三种语义的保证:
at most once
:至多一次,数据最多处理一次货这者没有被处理,有数据丢失的情况。at least once
:至少一次,数据最少被处理一次,有可能出现重复消费的问题。exactly once
:消费一次且仅一次
2. Kafka 的消费模式
Spark Streaming Kafka消费模式有2种:Receiver 模式和 Driect 模式。在 Spark2.x 后去掉了 Receiver 模式。下面我们分别来讲讲这两种模式。
2.1 SparkStreaming消费kafka整合介绍基于0.8版本整合方式
2.1.1 Receiver-based Approach(不推荐使用)
此方法使用 Receiver 接收数据。Receiver 是使用 Kafka 高级消费者 API 实现的。与所有接收器一样,从 Kafka 通过 Receiver 接收的数据存储在 Spark 执行器中,然后由 Spark Streaming 启动的作业处理数据。但是在默认配置下,此方法可能会在失败时丢失数据(请参阅接收器可靠性。为确保零数据丢失,必须在 Spark Streaming 中另外启用 Write Ahead Logs(在Spark 1.2中引入)。这将同步保存所有收到的 Kafka 将数据写入分布式文件系统(例如HDFS)上的预写日志,以便在发生故障时可以恢复所有数据,但是性能不好。
-
pom.xml 文件添加如下:
<properties> <spark.version>2.3.3</spark.version> </properties> <repositories> <repository> <id>cloudera</id> <url>https://repository.cloudera.com/artifactory/cloudera-repos</url> </repository> </repositories> <dependencies> <dependency> <groupId>org.scala-lang</groupId> <artifactId>scala-library</artifactId> <version>2.11.8</version> </dependency> <dependency> <groupId>org.apache.spark</groupId> <artifactId>spark-core_2.11</artifactId> <version>2.3.3</version> </dependency> <dependency> <groupId>org.apache.spark</groupId> <artifactId>spark-streaming_2.11</artifactId> <version>${spark.version}</version> </dependency> <dependency> <groupId>org.apache.spark</groupId> <artifactId>spark-sql_2.11</artifactId> <version>2.3.3</version> </dependency> <dependency> <groupId>org.apache.spark</groupId> <artifactId>spark-streaming-kafka-0-8_2.11</artifactId> <version>2.3.3</version> </dependency> <dependency> <groupId>org.apache.spark</groupId> <artifactId>spark-streaming-kafka-0-10_2.11</artifactId> <version>2.3.3</version> </dependency> </dependencies> <build> <plugins> <!-- 限制jdk版本插件 --> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.0</version> <configuration> <source>1.8</source> <target>1.8</target> <encoding>UTF-8</encoding> </configuration> </plugin> <!-- 编译scala需要用到的插件 --> <plugin> <groupId>net.alchim31.maven</groupId> <artifactId>scala-maven-plugin</artifactId> <version>3.2.2</version> <executions> <execution> <goals> <goal>compile</goal> <goal>testCompile</goal> </goals> </execution> </executions> </plugin> </plugins> </build>
-
核心代码:
import org.apache.spark.streaming.kafka._ val kafkaStream = KafkaUtils.createStream(streamingContext, [ZK quorum], [consumer group id], [per-topic number of Kafka partitions to consume])
-
代码演示
import org.apache.log4j.{ Level, Logger} import org.apache.spark.SparkConf import org.apache.spark.streaming.dstream.{ DStream, ReceiverInputDStream} import org.apache.spark.streaming.kafka.KafkaUtils import org.apache.spark.streaming.{ Seconds, StreamingContext} /** * sparkStreaming使用kafka 0.8API基于recevier来接受消息 */ object KafkaReceiver08 { def main(args: Array[String]): Unit = { Logger.getLogger("org").setLevel(Level.ERROR) //1、创建StreamingContext对象 val sparkConf= new SparkConf() .setAppName("KafkaReceiver08") .setMaster("local[2]") //开启WAL机制 .set("spark.streaming.receiver.writeAheadLog.enable", "true") val ssc = new StreamingContext(sparkConf,Seconds(2)) //需要设置checkpoint,将接受到的数据持久化写入到hdfs上 ssc.checkpoint("hdfs://node01:8020/wal") //2、接受kafka数据 val zkQuorum="hadoop102:2181,hadoop103:2181,hadoop104:2181" val groupid="KafkaReceiver08" val topics=Map("test" ->1) //(String, String) 元组的第一位是消息的key,第二位表示消息的value val receiverDstream: ReceiverInputDStream[(String, String)] = KafkaUtils.createStream(ssc,zkQuorum,groupid,topics) //3、获取kafka的topic数据 val data: DStream[String] = receiverDstream.map(_._2) //4、单词计数 val result: DStream[(String, Int)] = data.flatMap(_.split(" ")).map((_,1)).reduceByKey(_+_) //5、打印结果 result.print() //6、开启流式计算 ssc.start() ssc.awaitTermination() } }
-
创建kafka的topic并准备发送消息
cd /kafka_2.11-1.1.0/ bin/kafka-topics.sh --create --partitions 3 --replication-factor 2 --topic test --zookeeper hadoop102:2181,hadoop102:2181,hadoop102:2181 bin/kafka-console-producer.sh --broker-list node01:9092,node02:9092,node03:9092 --topic test
2.1.2 Direct Approach (No Receivers)
这种新的不基于 Receiver 的直接方式,是在 Spark 1.3 中引入的,从而能够确保更加健壮的机制。替代掉使用 Receiver 来接收数据后,这种方式会周期性地查询 Kafka,来获得每个 topic+partition 的最新的 offset,从而定义每个 batch 的 offset 的范围。当处理数据的 job 启动时,就会使用 Kafka 的简单 consumer api 来获取 Kafka 指定 offset 范围的数据。
这种方式有如下优点:
-
简化并行读取
如果要读取多个partition,不需要创建多个输入DStream然后对它们进行union操作。Spark会创建跟Kafka partition一样多的RDD partition,并且会并行从Kafka中读取数据。所以在Kafka partition和RDD partition之间,有一个一对一的映射关系。
-
高性能
如果要保证零数据丢失,在基于receiver的方式中,需要开启WAL机制。这种方式其实效率低下,因为数据实际上被复制了两份,Kafka自己本身就有高可靠的机制,会对数据复制一份,而这里又会复制一份到WAL中。而基于direct的方式,不依赖Receiver,不需要开启WAL机制,只要Kafka中作了数据的复制,那么就可以通过Kafka的副本进行恢复。
-
一次且仅一次的事务机制
基于receiver的方式,是使用Kafka的高阶API来在ZooKeeper中保存消费过的offset的。这是消费Kafka数据的传统方式。这种方式配合着WAL机制可以保证数据零丢失的高可靠性,但是却无法保证数据被处理一次且仅一次,可能会处理两次。因为Spark和ZooKeeper之间可能是不同步的。
-
降低资源
Direct不需要Receivers,其申请的Executors全部参与到计算任务中;而Receiver-based则需要专门的Receivers来读取Kafka数据且不参与计算。因此相同的资源申请,Direct 能够支持更大的业务。
-
降低内存
Receiver-based的Receiver与其他Exectuor是异步的,并持续不断接收数据,对于小业务量的场景还好,如果遇到大业务量时,需要提高Receiver的内存,但是参与计算的Executor并无需那么多的内存。而Direct 因为没有Receiver,而是在计算时读取数据,然后直接计算,所以对内存的要求很低。实际应用中我们可以把原先的10G降至现在的2-4G左右。
-
可用性更好
Receiver-based方法需要Receivers来异步持续不断的读取数据,因此遇到网络、存储负载等因素,导致实时任务出现堆积,但Receivers却还在持续读取数据,此种情况很容易导致计算崩溃。Direct 则没有这种顾虑,其Driver在触发batch计算任务时,才会读取数据并计算。队列出现堆积并不会引起程序的失败。
代码演示:
import kafka.serializer.StringDecoder
import org.apache.log4j.{
Level, Logger}
import org.apache.spark.SparkConf
import org.apache.spark.streaming.{
Seconds, StreamingContext}
import org.apache.spark.streaming.dstream.{
DStream, InputDStream, ReceiverInputDStream}
import org.apache.spark.streaming.kafka.KafkaUtils
/**
* sparkStreaming使用kafka 0.8API基于Direct直连来接受消息
* spark direct API接收kafka消息,从而不需要经过zookeeper,直接从broker上获取信息。
*/
object KafkaDirect08 {
def main(args: Array[String]): Unit = {
Logger.getLogger("org").setLevel(Level.ERROR)
//1、创建StreamingContext对象
val sparkConf= new SparkConf()
.setAppName("KafkaDirect08")
.setMaster("local[2]")
val ssc = new StreamingContext(sparkConf,Seconds(2))
//2、接受kafka数据
val kafkaParams=Map(
"metadata.broker.list"->"hadoop102:9092,hadoop103:9092,hadoop104:9092",
"group.id" -> "KafkaDirect08"
)
val topics=Set("test")
//使用direct直连的方式接受数据
val kafkaDstream: InputDStream[(String, String)] = KafkaUtils.createDirectStream[String,String,StringDecoder,StringDecoder](ssc,kafkaParams,topics)
//3、获取kafka的topic数据
val data: DStream[String] = kafkaDstream.map(_._2)
//4、单词计数
val result: DStream[(String, Int)] = data.flatMap(_.split(" "))
.map((_,1))
.reduceByKey(_+_)
//5、打印结果
result.print()
//6、开启流式计算
ssc.start()
ssc.awaitTermination()
}
}
要想保证数据不丢失,最简单的就是靠 checkpoint 的机制,但是 checkpoint 机制有个特点,如果代码升级了,checkpoint 机制就失效了。所以如果想实现数据不丢失,那么就需要自己管理 offset。
2.2 解决SparkStreaming与Kafka0.8版本整合数据不丢失方案
2.2.1 方案设计如下:
一般企业来说无论你是使用哪一套api去消费kafka中的数据,都是设置手动提交偏移量。
如果是自动提交偏移量(默认60s提交一次)这里可能会出现问题?
-
数据处理失败了,自动提交了偏移量。会出现数据的丢失。
-
数据处理成功了,自动提交偏移量成功(比较理想),但是有可能出现自动提交偏移量失败。会出现把之前消费过的数据再次消费,这里就出现了数据的重复处理。
自动提交偏移量风险比较高,可能会出现数据丢失或者数据被重复处理,一般来说就手动去提交偏移量,这里我们是可以去操作什么时候去提交偏移量,把偏移量的提交通过消费者程序自己去维护。
2.2.2 手动维护 offset,偏移量存入 Redis
-
redis 客户端
import org.apache.commons.pool2.impl.GenericObjectPoolConfig import redis.clients.jedis.JedisPool class RedisClient { val host = "192.168.0.122" val port = 6379 val timeOut = 3000 // 延迟加载,使用的时候才会创建 lazy val pool = new JedisPool(new GenericObjectPoolConfig(),host,port,timeOut) }
-
保存 offset 到 reids
package com.abcft.spark.streaming.kafka import org.apache.spark.{ SparkConf, TaskContext} import com.abcft.spark.redis.RedisClient import com.alibaba.fastjson.{ JSON, JSONObject} 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.dstream.InputDStream import org.apache.spark.streaming.kafka010.{ ConsumerStrategies, HasOffsetRanges, KafkaUtils, OffsetRange} import org.apache.spark.streaming.kafka010.LocationStrategies.PreferConsistent import org.apache.spark.streaming.{ Durations, Seconds, StreamingContext} import scala.collection.mutable /** * 使用redis来维护 offset */ object ManageOffsetUseRedis { lazy val redisClient = new RedisClient val bootstrapServer = "192.168.0.122:9092" val topic = "wc" val dbIndex = 1 val groupId = "wc-consumer" val autoOffsetReset = "earliest" def main(args: Array[String]): Unit = { val conf = new SparkConf(); conf.setAppName("ManageOffsetUseRedis") conf.setMaster("local") // 设置每个分区每秒读取多少条数据 conf.set("spark.streaming.kafka.maxRatePerPartition","10") val ssc = new StreamingContext(conf,Durations.seconds(5)) // 设置日志级别 ssc.sparkContext.setLogLevel("Error") ssc.checkpoint("E:/checkpoint/ManageOffsetUseRedis2/") /** * 从 redis 中获取消费者 offset */ // 当前 offset val currentOffset: mutable.Map[String, String] = getOffset(dbIndex,topic) currentOffset.foreach(x=>{ println(s" 初始读取到的offset: $x")}) // 转换成需要的类型 val frommOffsets = currentOffset.map(offsetMap => { new TopicPartition(topic, offsetMap._1.toInt) -> offsetMap._2.toLong }).toMap val kafkaParams = Map[String,Object] ( "bootstrap.servers" -> bootstrapServer, "key.deserializer" -> classOf[StringDeserializer], "value.deserializer" -> classOf[StringDeserializer], "group.id" -> groupId, "auto.offset.reset" -> autoOffsetReset ) /** * 将获取到的消费者 offset 传递给 SparkStreaming */ val stream: InputDStream[ConsumerRecord[String, String]] = KafkaUtils.createDirectStream( ssc, PreferConsistent, ConsumerStrategies.Assign[String, String](frommOffsets.keys.toList, kafkaParams, frommOffsets) ) stream.foreachRDD(rdd =>{ println("**** 业务处理完成2 ****") val offsetRanges = rdd.asInstanceOf[HasOffsetRanges].offsetRanges rdd.foreachPartition { iter => val o: OffsetRange = offsetRanges(TaskContext.get.partitionId) println(s"topic:${o.topic} partition:${o.partition} fromOffset:${o.fromOffset} untilOffset: ${o.untilOffset}") } //将当前批次最后的所有分区offsets 保存到 Redis中 saveOffset(offsetRanges) }) ssc.start() ssc.awaitTermination() ssc.stop() } def getOffset(db:Int,topic:String) = { val jedis = redisClient.pool.getResource jedis.select(db) val key = topic+":"+groupId val result = jedis.hgetAll(key) jedis.close() if (result.size() == 0) { result.put("0","0") result.put("1","0") result.put("2","0") } /** * java map 转 scala map */ import scala.collection.JavaConversions.mapAsScalaMap val offsetMap: scala.collection.mutable.Map[String,String] = result offsetMap } def saveOffset(offsetRange:Array[OffsetRange]) = { val jedis = redisClient.pool.getResource val key = topic+":"+groupId jedis.select(dbIndex) offsetRange.foreach(one =>{ jedis.hset(key,one.partition.toString,one.untilOffset.toString) }) jedis.close() } }
2.3 SparkStreaming与Kafka-0-10整合
-
支持0.10版本,或者更高的版本(推荐使用这个版本)
-
代码演示:
import org.apache.kafka.clients.consumer.ConsumerRecord import org.apache.kafka.common.serialization.StringDeserializer import org.apache.log4j.{ Level, Logger} import org.apache.spark.SparkConf import org.apache.spark.rdd.RDD import org.apache.spark.streaming.dstream.InputDStream import org.apache.spark.streaming.kafka010._ import org.apache.spark.streaming.{ Seconds, StreamingContext} object KafkaDirect10 { def main(args: Array[String]): Unit = { Logger.getLogger("org").setLevel(Level.ERROR) //1、创建StreamingContext对象 val sparkConf= new SparkConf() .setAppName("KafkaDirect10") .setMaster("local[2]") val ssc = new StreamingContext(sparkConf,Seconds(2)) //2、使用direct接受kafka数据 //准备配置 val topic =Set("test") val kafkaParams=Map( "bootstrap.servers" ->"hadoop102:9092,hadoop103:9092,hadoop104:9092", "group.id" -> "KafkaDirect10", "key.deserializer" -> classOf[StringDeserializer], "value.deserializer" -> classOf[StringDeserializer], "enable.auto.commit" -> "false" ) val kafkaDStream: InputDStream[ConsumerRecord[String, String]] = KafkaUtils.createDirectStream[String, String]( ssc, //数据本地性策略 LocationStrategies.PreferConsistent, //指定要订阅的topic ConsumerStrategies.Subscribe[String, String](topic, kafkaParams) ) //3、对数据进行处理 //如果你想获取到消息消费的偏移,这里需要拿到最开始的这个Dstream进行操作 //如果你对该DStream进行了其他的转换之后,生成了新的DStream,新的DStream不在保存对应的消息的偏移量 kafkaDStream.foreachRDD(rdd =>{ //获取消息内容 val dataRDD: RDD[String] = rdd.map(_.value()) //打印 dataRDD.foreach(line =>{ println(line) }) //4、提交偏移量信息,把偏移量信息添加到kafka中 val offsetRanges: Array[OffsetRange] =rdd.asInstanceOf[HasOffsetRanges].offsetRanges kafkaDStream.asInstanceOf[CanCommitOffsets].commitAsync(offsetRanges) }) //5、开启流式计算 ssc.start() ssc.awaitTermination() } }
3. SparkStreaming应用程序如何保证Exactly-Once
一个流式计算如果想要保证 Exactly-Once,那么首先要对这三个点有有要求:
- Source支持Replay (数据重放)。
- 流计算引擎本身处理能保证Exactly-Once。
- Sink支持幂等或事务更新
实现数据被处理且只被处理一次,就需要实现数据结果保存操作与偏移量保存操作在同一个事务中,或者你可以实现幂等操作。
也就是说如果要想让一个SparkStreaming的程序保证Exactly-Once,那么从如下三个角度出发:
- 接收数据:从Source中接收数据。
- 转换数据:用DStream和RDD算子转换。
- 储存数据:将结果保存至外部系统。
如果SparkStreaming程序需要实现Exactly-Once语义,那么每一个步骤都要保证Exactly-Once。
案例演示:
-
pom.xml添加内容如下
<dependency> <groupId>org.scalikejdbc</groupId> <artifactId>scalikejdbc_2.11</artifactId> <version>3.1.0</version> </dependency> <dependency> <groupId>org.scalikejdbc</groupId> <artifactId>scalikejdbc-config_2.11</artifactId> <version>3.1.0</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>5.1.39</version> </dependency>
-
代码开发
import org.apache.kafka.common.TopicPartition import org.apache.kafka.common.serialization.StringDeserializer import org.apache.spark.SparkConf import org.apache.spark.sql.SparkSession import org.apache.spark.streaming.kafka010.{ ConsumerStrategies, HasOffsetRanges, KafkaUtils, LocationStrategies} import org.apache.spark.streaming.{ Seconds, StreamingContext} import org.slf4j.LoggerFactory import scalikejdbc.{ ConnectionPool, DB, _} /** * SparkStreaming EOS: * Input:Kafka * Process:Spark Streaming * Output:Mysql * mysql支持事务操作: () * 保证EOS: * 1、偏移量自己管理,即enable.auto.commit=false,这里保存在Mysql中 * 2、使用createDirectStream * 3、事务输出: 结果存储与Offset提交在Driver端同一Mysql事务中 */ object SparkStreamingEOSKafkaMysqlAtomic { @transient lazy val logger = LoggerFactory.getLogger(this.getClass) def main(args: Array[String]): Unit = { val topic="topic1" val group="spark_app1" //Kafka配置 val kafkaParams= Map[String, Object]( "bootstrap.servers" -> "node1:6667,node2:6667,node3:6667", "key.deserializer" -> classOf[StringDeserializer], "value.deserializer" -> classOf[StringDeserializer], "auto.offset.reset" -> "latest", "enable.auto.commit" -> (false: java.lang.Boolean), "group.id" -> group) //在Driver端创建数据库连接池 ConnectionPool.singleton("jdbc:mysql://node3:3306/bigdata", "", "") val conf = new SparkConf().setAppName(this.getClass.getSimpleName.replace("$","")) val ssc = new StreamingContext(conf,Seconds(5)) //1)初次启动或重启时,从指定的Partition、Offset构建TopicPartition //2)运行过程中,每个Partition、Offset保存在内部currentOffsets = Map[TopicPartition, Long]()变量中 //3)后期Kafka Topic分区动扩展,在运行过程中不能自动感知 val initOffset=DB.readOnly(implicit session=>{ sql"select `partition`,offset from kafka_topic_offset where topic =${topic} and `group`=${group}" .map(item=> new TopicPartition(topic, item.get[Int]("partition")) -> item.get[Long]("offset")) .list().apply().toMap }) //CreateDirectStream //从指定的Topic、Partition、Offset开始消费 val sourceDStream =KafkaUtils.createDirectStream[String,String]( ssc, LocationStrategies.PreferConsistent, ConsumerStrategies.Assign[String,String](initOffset.keys,kafkaParams,initOffset) ) sourceDStream.foreachRDD(rdd=>{ if (!rdd.isEmpty()){ val offsetRanges = rdd.asInstanceOf[HasOffsetRanges].offsetRanges offsetRanges.foreach(offsetRange=>{ logger.info(s"Topic: ${offsetRange.topic},Group: ${group},Partition: ${offsetRange.partition},fromOffset: ${offsetRange.fromOffset},untilOffset: ${offsetRange.untilOffset}") }) //统计分析 //将结果收集到Driver端 val sparkSession = SparkSession.builder.config(rdd.sparkContext.getConf).getOrCreate() import sparkSession.implicits._ val dataFrame = sparkSession.read.json(rdd.map(_.value()).toDS) dataFrame.createOrReplaceTempView("tmpTable") val result=sparkSession.sql( """ |select | --每分钟 | eventTimeMinute, | --每种语言 | language, | -- 次数 | count(1) pv, | -- 人数 | count(distinct(userID)) uv |from( | select *, substr(eventTime,0,16) eventTimeMinute from tmpTable |) as tmp group by eventTimeMinute,language """.stripMargin ).collect() //在Driver端存储数据、提交Offset //结果存储与Offset提交在同一事务中原子执行 //这里将偏移量保存在Mysql中 DB.localTx(implicit session=>{ //结果存储 result.foreach(row=>{ sql""" insert into twitter_pv_uv (eventTimeMinute, language,pv,uv) value ( ${row.getAs[String]("eventTimeMinute")}, ${row.getAs[String]("language")}, ${row.getAs[Long]("pv")}, ${row.getAs[Long]("uv")} ) on duplicate key update pv=pv,uv=uv """.update.apply() }) //Offset提交 offsetRanges.foreach(offsetRange=>{ val affectedRows = sql""" update kafka_topic_offset set offset = ${offsetRange.untilOffset} where topic = ${topic} and `group` = ${group} and `partition` = ${offsetRange.partition} and offset = ${offsetRange.fromOffset} """.update.apply() if (affectedRows != 1) { throw new Exception(s"""Commit Kafka Topic: ${topic} Offset Faild!""") } }) }) } }) ssc.start() ssc.awaitTermination() } }