Spark 从 0 到 1 学习(9) —— Spark Streaming + Kafka
文章目录
- Spark 从 0 到 1 学习(9) —— Spark Streaming + Kafka
- 1. Kafka中的数据消费语义介绍
- 2. Kafka 的消费模式
- 2.1 SparkStreaming消费kafka整合介绍基于0.8版本整合方式
- 2.1.1 Receiver-based Approach(不推荐使用)
- 2.1.2 Direct Approach (No Receivers)
- 2.2 解决SparkStreaming与Kafka0.8版本整合数据不丢失方案
- 2.2.1 方案设计如下:
- 2.2.2 手动维护 offset,偏移量存入 Redis
- 2.3 SparkStreaming与Kafka-0-10整合
- 3. SparkStreaming应用程序如何保证Exactly-Once
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()
}
}