文章目录
一、日活需求概述
- 什么叫日活
- 通常 打开应用的用户即为活跃用户,不考虑用户的使用情况。每天一台设备打开多次会被计为一个活跃用户。 也就是只需要统计第一次打开即可
- 游戏用户:每天打开(登录)游戏的用户数(针对游戏D A U的定义)
思路:
1.每个3秒计算一次
2.upstateByKey不用
3.spark-streaming 不计算具体日活
把每个设备每天 第一次 的启动记录写入es中
4.如果知道这个设备启动时,这个设备是第一次?
过滤掉设备的当天第二次及以后的启动记录
设备1 启动,把设备id保存到 内存集合[]
设备1 启动 去内存集合判断 如果出现程序重启,内存数据丢失,数据会重复
redis的集合:Set
如果第一次写返回1 再写一次返回0
把设备id存入到redis会不会内存爆掉?
用了那么就没有爆掉过
计算一下;
一个日活10个字节,1000万日活,则一亿个字节
1 000 000 000
g m k
一天最多一到两个G
二、搭建实时处理模块
前期准备:
2.1 创建module
使用spark-streaming来搭建实时模块
module名称为gmall-realtime
2.2 pom.xml文件中导入依赖、创建需要的package
<dependency>
<groupId>org.apache.spark</groupId>
<artifactId>spark-core_2.12</artifactId>
<version>3.0.0</version>
</dependency>
<dependency>
<groupId>org.apache.spark</groupId>
<artifactId>spark-streaming_2.12</artifactId>
<version>3.0.0</version>
</dependency>
<dependency>
<groupId>org.apache.spark</groupId>
<artifactId>spark-streaming-kafka-0-10_2.12</artifactId>
<version>3.0.0</version>
</dependency>
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Z0BGqL3L-1605285685270)(https://i.loli.net/2020/11/10/jHlRz27BWxPJwYD.png)]
目录结构:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8YTUTY1O-1605285685271)(https://i.loli.net/2020/11/10/DiAFGQMaUubvokc.png)]
2.3 添加需要的配置文件
2.3.1 log4j.properties
在resource目录下添加log4j.properties
log4j.appender.atguigu.MyConsole=org.apache.log4j.ConsoleAppender
log4j.appender.atguigu.MyConsole.target=System.err
log4j.appender.atguigu.MyConsole.layout=org.apache.log4j.PatternLayout
log4j.appender.atguigu.MyConsole.layout.ConversionPattern=%d{yyyy-MM-dd HH:mm:ss} %10p (%c:%M) - %m%n
log4j.rootLogger=error,atguigu.MyConsole
2.4 添加需要的工具类
2.4.1 添加消费kafka数据工具类
package com.atguigu.realtime.util
import org.apache.kafka.common.serialization.StringDeserializer
import org.apache.spark.streaming.StreamingContext
import org.apache.spark.streaming.kafka010.ConsumerStrategies.Subscribe
import org.apache.spark.streaming.kafka010.KafkaUtils
import org.apache.spark.streaming.kafka010.LocationStrategies.PreferConsistent
/**
* Author atguigu
* Date 2020/11/10 10:29
*/
object MyKafkaUtil {
var kafkaParams = Map[String, Object](
"bootstrap.servers" -> "hadoop162:9092,hadoop163:9092,hadoop164:9092",
"key.deserializer" -> classOf[StringDeserializer],
"value.deserializer" -> classOf[StringDeserializer],
// 如果你保存了offset, 就从你保存的位置开始消费, 如果没有, 则从最新的
"auto.offset.reset" -> "latest",
//改成true,自动保存,默认false,如果是false每次都从最新的开始,一启动都是最新的,
//启动的过程中会不会继续往下走?第一次消费100个,下一次消费还是从最新的开始吗?不是的
//从kafka消费数据,位置的配置只有启动了之后第一次消费有效,以后消费都是继续往后消费了
//如果你上一次消费后把他保留下来,下次消费就从保留下的位置开始
"enable.auto.commit" -> (true: java.lang.Boolean)
)
//对kafka消费数据的封装
def getKafkaStream(ssc:StreamingContext,groupId:String,topic:String)={
//注意:这里的map是不可变map,不能修改,如果修改就是创建一个新的集合,这里无所谓,都行
kafkaParams += "group.id" -> groupId
KafkaUtils
.createDirectStream[String, String](
ssc,
PreferConsistent,
Subscribe[String, String](Set(topic), kafkaParams)
)
.map(_.value())
}
}
2.4.2 添加获取redis客户端工具类
package com.atguigu.realtime.util
import redis.clients.jedis.Jedis
object MyRedisUtil {
def getClient = new Jedis( "hadoop162", 6379)
}
2.5 添加需要用到的样例类
把启动日志用样例类(StartupLog)进行封装.
启动日志字段比较丰富, 在样例类中我们只保留了我们感兴趣的字段. 可以根据需要添加其他字段到样例类中
package com.atguigu.realtime.bean
import java.text.SimpleDateFormat
import java.util.Date
case class StartupLog(mid: String,
uid: String,
ar: String,
ba: String,
ch: String,
md: String,
os: String,
vc: String,
ts: Long,
var logDate: String = null, // 年月日 2020-07-15
var logHour: String = null) { //小时 10
private val date = new Date(ts)
logDate = new SimpleDateFormat("yyyy-MM-dd").format(date)
logHour = new SimpleDateFormat("HH").format(date)
}
/*
{
"common":{
"ar":"440000",
"ba":"iPhone",
"ch":"Appstore",
"md":"iPhone X",
"mid":"mid_26",
"os":"iOS 13.2.9",
"uid":"477",
"vc":"v2.1.134"
},
"start":{
"entry":"icon",
"loading_time":1925,
"open_ad_id":6,
"open_ad_ms":8828,
"open_ad_skip_ms":1129
},
"ts":1597319770000
}
*/
主要代码实现:
2.6 创建DauApp
package com.atguigu.realtime.app
import java.lang
import com.atguigu.realtime.bean.StartupLog
import org.json4s.JsonAST.JObject
import com.atguigu.realtime.util.{MyKafkaUtil, MyRedisUtil}
import org.apache.spark.SparkConf
import org.apache.spark.streaming.dstream.DStream
import org.apache.spark.streaming.{Seconds, StreamingContext}
import org.json4s.JValue
import org.json4s.jackson.JsonMethods
import redis.clients.jedis.Jedis
object DauApp {
。。。。。。
。。。。。。
。。。。。。
}
2.6.1 从kafka获取日志数据
思路:
消费kafka数据的工具类【MyKafkaUtil】中定义了一个getKafkaStream()方法,
object MyKafkaUtil{
var kafkaParams = Map[String, Object]
def getKafkaStream(ssc:StreamingContext,groupId:String,topic:String)
该方法传入三个参数:streamingContext,消费者组以及消费主题
}
获取的数据是json格式的,需要封装解析
*/
主函数:
def main(args: Array[String]): Unit = {
//1.创建StreamingContext
val conf: SparkConf = new SparkConf().setMaster("local[2]").setAppName("DauApp")
val ssc: StreamingContext = new StreamingContext(conf, Seconds(3))
//2.从kafka获取一个流
val sourceStream: DStream[String] = MyKafkaUtil.getKafkaStream(ssc, "DauApp", "gmall_startup_topic")
//5.流要输出(output)foreachRDD
sourceStream.print
//6.启动流
ssc.start()
//7.防止主线程退出
ssc.awaitTermination()
}
测试:
测试是否能够正确获取数据:
1.启动zk,kafka
2.在/opt/software/mock/mock_log/目录下执行log.sh脚本,开始日志采集
3.启动idea程序从kafka获取启动日志流
4.在/opt/software/mock/mock_log/目录下执行日志生成jar包模拟日志生成
5.获取到json格式的数据
获取的数据是json格式的,需要封装解析
2.6.2 封装日志数据为样例类、同时解析json格式数据
思路:
//添加需要用到的样例类工具startupLog
把数据格式解析成StartupLog格式
定义了一个def parseToStartupLog(sourceStream: DStream[String])方法{
传入从kafka获取的一个流
JsonMethods.parse()方法将该流封装成Jvalue
通过 \ 方法获取不同的common,ts
将common与ts用merge合并,因为ts跟common的格式不同,合并前,要将ts装成一个JObject
再调用extract截取这两个字段对应的值
}
代码:
/**
* 把数据格式解析成StartupLog格式
* @param sourceStream
* @return
*/
def parseToStartupLog(sourceStream: DStream[String]) = {
sourceStream.map(jsonString => {
val value: JValue = JsonMethods.parse(jsonString)
val jCommon: JValue = value \ "common"
val jTs: JValue = value \ "ts"
implicit val f = org.json4s.DefaultFormats
jCommon.merge(JObject("ts" -> jTs)).extract[StartupLog]
})
}
主函数:
def main(args: Array[String]): Unit = {
//1.创建StreamingContext
val conf: SparkConf = new SparkConf().setMaster("local[2]").setAppName("DauApp")
val ssc: StreamingContext = new StreamingContext(conf, Seconds(3))
//2.从kafka获取一个流
val sourceStream: DStream[String] = MyKafkaUtil.getKafkaStream(ssc, "DauApp", "gmall_startup_topic")
//获取的数据是json格式的,需要封装解析
//3.把字符串类型的数据解析成样例类
val startupLogStream: DStream[StartupLog] = parseToStartupLog(sourceStream)
//4.流要输出(output)foreachRDD
startupLogStream.print
//5.启动流
ssc.start()
//6.防止主线程退出
ssc.awaitTermination()
}
测试:
测试是否能够正确获取数据:
1.启动zk,kafka
2.在/opt/software/mock/mock_log/目录下执行log.sh脚本,开始日志采集
3.启动idea程序
4.在/opt/software/mock/mock_log/目录下执行日志生成jar包模拟日志生成
5.获取到StartupLog格式的数据
获取的数据有可能有重复的,需要对数据进行去重处理
2.6.3 使用redis对数据进行去重
思路:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-zC7emBbd-1605285685272)(https://i.loli.net/2020/11/10/OgtTxN5hj2WPAGn.png)]
去重方法1 待优化:
缺点: 每条日志都要建立一个到redis的连接, 连接数过多, 对redis压力过大.
//定义一个distinct方法
def distinct(startupLogStream: DStream[StartupLog]) {
把已经解析成StartupLog格式的数据【startupLogStream】传入,返回一个结果
用redis去重
1.获取redis的客户端
2.把mid的值写入到redis的set中, 为方便给set设置过期时间, 每天一个set, 所以key要体现出来日期.
Key可以这么设计: mids:2020-09-01,log里面有日期
3.再次在key中写入mid,就是写入某天产生的mid,会返回一个值,要么是0,要么是1,如果返回只是1表示 存入成功,否则是0,表示存入失败(不是第一次)
}
/**
* 对传入的流去重
* @param startupLogStream
* @return 去重后的流
*/
def distinct(startupLogStream: DStream[StartupLog]) = {
startupLogStream.filter(log => {
//返回true,第一次启动保留下来,返回false,不是第一次启动,过滤掉
//思路:把设备id存入到redis的set集合,如果返回只是1表示存入成功,否则是0,表示存入失败(不是第一次)
val client: Jedis = MyRedisUtil.getClient
val key = "mids:" + log.logDate //表示时间
val r: lang.Long = client.sadd(key, log.mid) //存入某天的mid
client.close()
r == 1 //如果mid是首次向redis存入则返回1
})
}
去重方法2
优化: 应该1个分区创建一个到redis的连接. 使用mapPartiions完成去重
def distinct_2(startupLogStream: DStream[StartupLog]) = {
1.可以使用mapPartitions算子
startupLogStream.mapPartitions((it: Iterator[StartupLog]){
把当前分区所有的数据(Iterator)传过来, 然后需要返回一个新的迭代器
1)获取redis客户端
2)对it进行过滤{
时间作为key值
给key值saddmid,判断是否等于1
}
--这里的过滤用的是scala集合的过滤,纯粹scala集合中的高阶函数
--优化前的过滤用的是sparkStreaming的过滤方法,分布式里的过滤
3)将redis客户端关闭
4.)返回一个迭代器,因为mapPartitions要求返回一个迭代器
}
2.可以使用transform
startupLogStream.transform(rdd => {
操作的是rdd
--复制
将刚才的代码从.mapPartitions((){
1)2)3)4)
})
})
}
--扩展:
transform和foreachRDD的区别:
RDD的个数都是一样的,都是3秒一个RDD
都是转换算子
1.transform:转换算子,没有用行动算子是没办法触发的,是转换算子,所以最终要返回流(返回RDD)
写代码直接能看到rdd就是在driver端执行,
隔了一层就是在executor执行
--driver和executor在不同电脑上,所以在driver定义的变量不能在executor修改,因为executor接收到的driver端的数据是序列化了的
写在distinct这个方法的代码,只跟随distinct执行一次
而rdd内的是一个批次执行一次
rdd里的算子时每个分区每批次执行一次
2.foreachRDD:本身是行动算子,没有返回值的,只要写代码就可以,不需要有返回值
/**
* 对传入的流去重
*
* @param startupLogStream
* @return 去重后的流
*/
def distinct_2(startupLogStream: DStream[StartupLog]) = {
/*startupLogStream.mapPartitions((it: Iterator[StartupLog]) => {
// 把当前分区所有的数据(Iterator)传过来, 然后需要返回一个新的迭代器
val client: Jedis = MyRedisUtil.getClient
val filerIt = it.filter(log => {
val key = "mids:" + log.logDate
client.sadd(key, log.mid) == 1
})
client.close()
filerIt
4.)
})*/
//代码1: driver 只执行一次
startupLogStream.transform(rdd => {
// 代码2: driver 一个批次执行一次
rdd.mapPartitions(it => {
//代码3 executor 每个批次每个分区执行一次
//获取redis客户端
val client: Jedis = MyRedisUtil.getClient
val filerIt = it.filter(log => {
//将时间作为key值
val key = "mids:" + log.logDate
//判断mid是否第一次写入
client.sadd(key, log.mid) == 1
})
client.close()
filerIt
})
})
}
主函数:
def main(args: Array[String]): Unit = {
//1.创建StreamingContext
val conf: SparkConf = new SparkConf().setMaster("local[2]").setAppName("DauApp")
val ssc: StreamingContext = new StreamingContext(conf, Seconds(3))
//2.从kafka获取一个流
val sourceStream: DStream[String] = MyKafkaUtil.getKafkaStream(ssc, "DauApp", "gmall_startup_topic")
//获取的数据是json格式的,需要封装解析
//3.把字符串类型的数据解析成样例类
val startupLogStream: DStream[StartupLog] = parseToStartupLog(sourceStream)
//4.去重
val result: DStream[StartupLog] = distinct_2(startupLogStream)
//5.流要输出(output)foreachRDD
result.print
//6.启动流
ssc.start()
//7.防止主线程退出
ssc.awaitTermination()
}
测试:
测试是否能够正确获取数据:
1.启动zk,kafka
2.在/opt/software/mock/mock_log/目录下执行log.sh脚本,开始日志采集
3.启动idea程序
4.在/opt/software/mock/mock_log/目录下执行日志生成jar包模拟日志生成
redis-server 启动redis服务
在/opt/module/redis-3.2.12目录下开redis-cli客户端
keys * --能查看有哪些key
SMEMBERS 需要查看到key --能查看这个key,即是这一天有哪些mid
2.6.4 去重后的数据写入到es
思路:
1.创建es的工具类
2.把日活明细写入到es
创建index模板:
PUT _template/gmall_dau_info_template
{
"index_patterns": [
"gmall_dau_info*"
],
"settings": {
"number_of_shards": 3
},
"aliases": {
"{index}-query": {
},
"gmall_dau_info-query": {
}
},
"mappings": {
"_doc": {
"properties": {
"mid": {
"type": "keyword"
},
"uid": {
"type": "keyword"
},
"ar": {
"type": "keyword"
},
"ba": {
"type": "keyword"
},
"ch": {
"type": "keyword"
},
"md": {
"type": "keyword"
},
"os": {
"type": "keyword"
},
"vc": {
"type": "keyword"
},
"ts": {
"type": "date"
},
"logDate": {
"type": "keyword"
},
"logHour": {
"type": "keyword"
}
}
}
}
}
方法:
object MyEsUtil {
//1.先创建es客户端
val factory: JestClientFactory = new JestClientFactory
val config: HttpClientConfig = new HttpClientConfig.Builder("http://hadoop162:9200")
.connTimeout(1000*10)
.readTimeout(1000*10)
.maxTotalConnection(100)
.multiThreaded(true)
.build()
factory.setHttpClientConfig(config)
//2.批量插入封装
def insertBulk(defaultIndex:String,sources:Iterator[Object])={
val client = factory.getObject
val bulkBuilder: Bulk.Builder = new Bulk.Builder()
.defaultIndex(defaultIndex)
.defaultType("_doc")
//方法一:
/*sources
.map(source =>{
new Index.Builder(source).build()
})
.foreach(bulkBuilder.addAction)*/
//方法一升级版
source.map({
case (id:String,s) =>
val index = new Index.Builder(source)
.id(id)
.build()
case s =>
val index = new Index.Builder(s)
.build()
}).foreach(bulkBuilder.addAction(_))
//方法二:
/*
for (source <- sources){
val index = new Index.Builder(source).build()
bulkBuilder.addAction(index)
}
*/
//方法二升级版:
/*
for (source <- sources){
source match {
case (id:String,s) =>
val index = new Index.Builder(source)
.id(id)
.build()
bulkBuilder.addAction(index)
case s =>
val index = new Index.Builder(s)
.build()
bulkBuilder.addAction(index)
}
}
*/
client.execute(bulkBuilder.build())
client.shutdownClient()
}
主函数:
def main(args: Array[String]): Unit = {
//1.创建StreamingContext
val conf: SparkConf = new SparkConf().setMaster("local[2]").setAppName("DauApp")
val ssc: StreamingContext = new StreamingContext(conf, Seconds(3))
//2.从kafka获取一个流
val sourceStream: DStream[String] = MyKafkaUtil.getKafkaStream(ssc, "DauApp", "gmall_startup_topic")
//获取的数据是json格式的,需要封装解析
//3.把字符串类型的数据解析成样例类
val startupLogStream: DStream[StartupLog] = parseToStartupLog(sourceStream)
//4.去重
val result: DStream[StartupLog] = distinct_2(startupLogStream)
//5.流要输出(output)foreachRDD
/*println("foreachRDD外")执行一次*/
result.foreachRDD(rdd =>{
/*println("foreachRDD内") 三秒钟执行一次*/
//把rdd数据写入到es
rdd.foreachPartitions(it => {
//创建到es的连接
//写入 TODO
/*println("写入到es成功") 每个分区执行一次,有两个分区就每三秒执行两次*/
val today = LocalDate.now().toString
MyEsUtil.insertBulk(s"gmall_dau_info_$today",it)
//关闭连接
})
})
//6.启动流
ssc.start()
//7.防止主线程退出
ssc.awaitTermination()
}
三、精准一次性消费k a f k a数据
3.1 消费k a f k a数据的3种语义
①精准一次性消费
是指消息一定会被处理且只会被处理一次。不多不少就一次处理。如果达不到精准一次性消费,可能会达到另外两种情况
②至少一次消费
主要是保证数据不会丢失,但有可能存在数据重复问题
③最多一次消费
主要是保证数据不会重复,但有可能存在数据丢失问题,如果同时解决了数据丢失和数据重复问题,那么就实现了精准一次消费的语义了
3.2 重复或丢失的原因
①数据何时会丢失
比如实时计算任务进行计算,到数据结果存盘之前,进程崩溃,假设在进程崩溃前k a f k a调整了偏移量,那么k a f k a就会认为数据已经被处理过,即使进程重启,k a f k a也会从新的偏移量开始,所以之前没有保存的数据就被丢失掉了。
②数据何时会重复
如果数据计算结果已经存盘了,在k a f k a调整偏移量之前,进程崩溃,那么k a f k a会认为数据没有被消费,进程重启,会重新从旧的偏移量开始,那么数据就会被2次消费,又会被存盘,数据就被存了2遍,造成数据重复。
3.3 如何实现精准一次性消费
①偏移量存储到checkpoint中
如果开启spark的checkpoint, offset则会存储在checkpoint中.
这个很容易开启, 但是有缺点:
'缺点1:'
输出可能重复. 如果要实现输出不重复, 则输出数据的存储系统要是幂等的.
'缺点2:'
如果源码发生了更改,则不能从以前的checkpoint中读取数据. 所以有可能丢失数据.
②使用k a f k a自己来保存偏移量
可以使用kafka提供的commit API把offset存储在kafka的一个特殊的topic中. 可以使用stream.asInstanceOf[CanCommitOffsets].commitAsync(offsetRanges)提交offset,
但是stream必须是从kafka直接出来的流, 不能经过任何的转换. 而且, 输出系统也必须是幂等的.
③自己保存偏移量
-
a、自己保存偏移量+事务处理
把偏移量和数据保存在支持事务的数据库中, 让他们处于同一个事务中: 要么同时成功, 要么同时失败 '限制:' 但是就是数据和偏移量必须都要放在某一个关系型数据库中,无法使用其他功能强大的nosql数据库。如果保存的数据量较大一个数据库节点不够,多个节点的话,还要考虑分布式事务的问题。
-
b、自己保存偏移量+幂等处理
1.--如何保证数据不丢失? 把握提交偏移量的时机,等数据保存成功之后,再提交偏移量,则数据不丢失。 2.--如何保证数据不重复? 如果数据已经保存,偏移量提交之前系统崩溃,则重启之后数据会重复保存,使用支持幂等性的系统可以避免数据重复
3.4 具体实现方案
我们采用自已保存偏移量+幂等处理
① 偏移量可以保存在mysql,redis,zookeeper中,追求速度保存在redis中
使用hash来存储每个分区的offset
key value(hash)
"offset:groupid:topic" field value
partiton_id offset
partiton_id offset
...
② 日活明细数据保存在es中, 存储document的时候, 如果document 的id相同, 则后面的会替换前面的, 所以是支持幂等的.
3.4.1 改进MykafkaUtil_1
思路:
kafkaParams +="auto.offset.reset" -> "earliest" //如果没有读到上次的位置,则会从最早的位置开始消费
kafkaParams += "enable.auto.commit" -> (false:java.lang.Boolean) //需要手动维护offsets
代码:
package com.atguigu.realtime.util
import org.apache.kafka.common.TopicPartition
import org.apache.kafka.common.serialization.StringDeserializer
import org.apache.spark.streaming.StreamingContext
import org.apache.spark.streaming.kafka010.ConsumerStrategies.Subscribe
import org.apache.spark.streaming.kafka010.KafkaUtils
import org.apache.spark.streaming.kafka010.LocationStrategies.PreferConsistent
/**
* Author atguigu
* Date 2020/11/10 10:29
*/
object MyKafkaUtil_1 {
var kafkaParams = Map[String, Object](
"bootstrap.servers" -> "hadoop162:9092,hadoop163:9092,hadoop164:9092",
"key.deserializer" -> classOf[StringDeserializer],
"value.deserializer" -> classOf[StringDeserializer],
"auto.offset.reset" -> "latest", //如果你保存了offset,就从你保存的位置开始消费,如果没有,则从最新的
"enable.auto.commit" -> (true: java.lang.Boolean)
)
//对kafka消费数据的封装
//写一个方法启动从哪开始消费
def getKafkaStream(ssc: StreamingContext,
groupId: String,
topic: String,
offsets: Map[TopicPartition, Long]) ={ //这里将offsets设置成了一个不可变的Map
kafkaParams += "group.id" -> groupId
kafkaParams +="auto.offset.reset" -> "earliest" //如果没有读到上次的位置,则会从最早的位置开始消费
kafkaParams += "enable.auto.commit" -> (false:java.lang.Boolean) //需要手动维护offsets
KafkaUtils
.createDirectStream[String, String](
ssc,
PreferConsistent,
Subscribe[String, String](Set(topic), kafkaParams,offsets)
)
//.map(_.value()) 只有从kafka直接得到的流才有offset的信息,map之后就没了
}
//此方法出来的是kafka中读取出来的流
}
3.4.2 增加OffsetManager工具类
1)思路:
该工具类是对offset的管理,读取和保存
1.读取:
需要在实时启动时从redis读取上一次结束后的offset,然后开始消费数据,只需读取一次即可
2.保存:
没消费一批次的数据,成功保存后,在手动保存消费过的offset
2)解析
①将offset存入redis的方法——saveOffsets
"offset在redis中的存储格式:"
/*
key value(hash)
"offset:groupid:topic" field value
partiton_id offset
partiton_id offset
...
*/
def saveOffsets(offsetRanges: ListBuffer[OffsetRange], groupId: String, topic: String) = {
//1.获取redis客户端
val client: Jedis = MyRedisUtil.getClient
"获取key值"
//2.将主函数传过来的groupid和topicp拼接得到key
val key = s"offset:${groupId}:${topic}"
//3.将存储偏移量的集合解析
val fieldAndvalue = offsetRanges
.map(offsetRange => {
"获取value值"
//4.解析出集合中的分区和偏移量组成一个元组,作为value
offsetRange.partition.toString -> offsetRange.untilOffset.toString
})
//5.转成一个map集合,里面是元组,可以转
.toMap
.asJava
//6.将key和value存入redis ————> redis端用的是java的map,这里需要使用隐式转换将scala中的map转成java中的map
client.hmset(key,fieldAndvalue)
println("保存偏移量 topic_partition-> offset: " + fieldAndvalue)
//7.关闭redis客户端
client.close()
}
②从redis读取offset的方法readOffsets
def readOffsets(groupId: String, topic: String) = {
"key值"
//1.获取需要读取哪个分区哪个主题的偏移量
val key = s"offset:${groupId}${topic}"
//2.获取redis客户端
val client = MyRedisUtil.getClient
println("读取开始的offset")
val topicPartitionAndOffset: Map[TopicPartition, Long] = client
"初始偏移量"
//3.获取该分区的这个主题的所有数据(包含数据和偏移量)
.hgetAll(key)
//4.将得到的java的Map集合转成scala的Map集合 ————>存到时候存到是一个hashmap,读的时候也是读一个hashmap,得到key对应的value,是一个java的map
.asScala
.map {
case (partition, offset) =>
//5.将key转化成topicpartition,value转化成long类型 ————>得到的是一个scala的可变的Map集合
new TopicPartition(topic, partition.toInt) -> offset.toLong
}
//6.将获取的偏移量转成不可变的Map集合 ————>因为kafka接收时用的是一个不可变的Map接收的
.toMap
//打印出初始偏移量
println("初始偏移量:" + topicPartitionAndOffset)
//7.关闭redis客户端
client.close()
//8.返回这个流数据 ————>有日志和偏移量的数据
topicPartitionAndOffset
}
如何获取kafka流数据中的分区,主题和偏移量
HasOffsetRanges里有offset的范围,是一个叫做offsetRanges的属性,得到的offsetRange是一个数组,遍历一下,得到offsetrange
//一个offsetRange封装的是一个分区的数据,表示一个分区和这个分区消费的一个偏移量
//遍历这个List集合的数组
for(offsetRange <- offsetRanges){
//叫做起始消费位置
offsetRange.fromOffset
//封装了topic和partition
val partition: TopicPartition = offsetRange.topicPartition()
//从上次消费结束那里开始消费 是哪个分区的呢?所以这个数组里面肯定还有一个对应的分区
val offset: Long = offsetRange.untilOffset
//得到这个分区 那是哪个topic的呢?肯定有一个topic属性
val partition: Int = offsetRange.partition
val topic: String = offsetRange.topic
3)代码:
package com.atguigu.realtime.util
import org.apache.kafka.common.TopicPartition
import org.apache.spark.streaming.kafka010.OffsetRange
import redis.clients.jedis.Jedis
import scala.collection.JavaConverters._
import scala.collection.mutable.ListBuffer
/*
key value(hash)
"offset:groupid:topic" field value
partiton_id offset
partiton_id offset
...
*/
object OffsetManager {
/**
* 保存offset到redis
*/
def saveOffsets(offsetRanges: ListBuffer[OffsetRange], groupId: String, topic: String) = {
//1.获取redis的客户端
val client: Jedis = MyRedisUtil.getClient
//2.将主函数传入的groupid和topic拼接得到key
val key = s"offset:${groupId}:${topic}"
//3.将存储偏移量的集合解析
val fieldAndvalue = offsetRanges
.map(offsetRange => {
//4.解析出集合中的分区和偏移量组成一个元组,作为value
offsetRange.partition.toString -> offsetRange.untilOffset.toString
})
//5.转成一个map集合,里面是元组,可以转
.toMap
//6.下一步存入redis使用的hmset方法的map是java的map,我们需要scala的map,需要转一下,插入一个隐式转换
.asJava
//7.将key和value存入redis
client.hmset(key, fieldAndvalue)
println("保存偏移量 topic_partition-> offset: " + fieldAndvalue)
//8.关闭redis客户端
client.close()
}
/**
* 从redis读上次消费到了哪个offset
*/
def readOffsets(groupId: String, topic: String) = {
//1.获取需要读取哪个分区哪个主题的偏移量
val key = s"offset:${groupId}${topic}"
//2.获取redis客户端
val client = MyRedisUtil.getClient
println("读取开始的offset")
val topicPartitionAndOffset: Map[TopicPartition, Long] = client
//3.获取该分区的这个主题的所有数据
.hgetAll(key)
//4.将得到的java的Map集合转成scala的Map集合 ————>存到时候存到是一个hashmap,读的时候也是读一个hashmap,得到key对应的value,是一个java的map
.asScala
//5.将key转化成topicpartition,value转化成long类型 ————>得到的是一个scala的可变的Map集合
.map {
case (partition, offset) =>
new TopicPartition(topic, partition.toInt) -> offset.toLong
}
//6.将获取的偏移量转成不可变的Map集合 ————>因为kafka接收时用的是一个不可变的Map接收的
.toMap
//打印出初始偏移量
println("初始偏移量:" + topicPartitionAndOffset)
//7.关闭redis客户端
client.close()
//8.返回这个流数据 ————>有日志和偏移量的数据
topicPartitionAndOffset
}
}
3.4.3 改进后的DauApp
思路:
启动的时候从r e d i s读取一次保存的offsets,每3秒保存一次
offset跟消费者组还有主题有关系:
1.读offset,将来要消费很多topic,那么这个offset要读哪些topic呢?这是需要传好几个参数取告诉它我们要读哪些topic
2.一个topic将来有好几个消费者去读,一个消费者存在一个消费者组里,一个消费者组里又有好几个消费者,这个组里的消费者再消费的时候会分工
3.然后可能会有不同的消费者组去读topic,这些消费者组每个人都有自己不同的offset,
4.这里进行offset的读存操作时,需要传主题跟组的参数
5.groupId ,topic这两个参数代表一个消费者组中这一个消费者的偏移量
1)解析
//消费者组和主题
val groupId = "DauApp_1"
val topic = "gmall_startup_topic"
//main
val conf: SparkConf = new SparkConf().setMaster("local[2]").setAppName("DauApp_1")
val ssc: StreamingContext = new StreamingContext(conf, Seconds(3))
①读取k a f k a中的流数据读取offset
"获取起始偏移量offset"
//1.先调用OffsetManager工具类的readOffsets方法读取初始偏移量 ————>返回值是 Map[TopicPartition, Long]
val offsets: Map[TopicPartition, Long] = OffsetManager.readOffsets(groupId,topic)
//2.再设置一个List集合来存储读取到的offsetRanges
val offsetRanges = ListBuffer.empty[OffsetRange]
//3.调用MyKafkaUtil_1工具类的getKafkaStream方法来获取kafka的流数据(InputDStream流),每隔三秒把所有数据存到一个rdd里,这个rdd里有数据和offset数据
//得到的sourceStream是DStream[ConsumerRecord[String, String]]类型
val sourceStream = MyKafkaUtil_1
"传起始偏移量offset"
//给getKafkaStream方法传入消费者组,主题和读取到的起始偏移量
.getKafkaStream(ssc, groupId, topic, offsets)
.transform(rdd=>{ //这里没3秒执行一次
//4. 强转,必须是从kafka直接得到的那个流中的rdd 【driver】
val newOffsetRanges: Array[OffsetRange] = rdd.asInstanceOf[HasOffsetRanges]
"获取新的偏移量offset"
//5.调用offsetRanges方法获取这个流,即这个分区的这个主题中的偏移量,用一个数组接收
.offsetRanges
//将newOffsetRanges数组存入到OffsetRanges集合
//6.接收前,向将可变集合清空
offsetRanges.clear()
//7.将获取的newOffsetRanges新的偏移量的数组存入OffsetRanges集合里
offsetRanges ++= newOffsetRanges
//9.返回的rdd就是上次消费的位置,因为trainsfrom是转换算子,所以需要有返回值
rdd
})
//获取sourceStream的value值
.map(_.value())
②将数据封装成样例类,解析成json数据
val startupLogStream: DStream[StartupLog] = parseToStartupLog(sourceStream)
③使用redis进行去重
val result: DStream[StartupLog] = distinct_2(startupLogStream)
④每批保存一次,先存数据,再存offsets:
result.foreachRDD(rdd=>{
- 先存数据:
//将executor的数据拉倒driver端
rdd.foreachPartition(it =>{
//获取今天的日期
val today: String = LocalDate.now().toString
//给每条数据加上id就不会重复
MyEsUtil.insertBulk(s"gmall_dau_info_$today", it.map(log => (log.mid, log)))
})
<span style = "color:yellow;font-weight:900">再存offset</span>
ConsumerRecord里面的rdd里有这次消费到了哪些个offsets
"存入新的偏移量offset"
//调用 OffsetManager的saveOffsets方法,传入这个消费者组的这个主题的偏移量
OffsetManager.saveOffsets(offsetRanges,groupId,topic)
})
⑤启动流,防止线程退出
//启动流
ssc.start()
//防止主线程退出
ssc.awaitTermination()
2)代码:
package com.atguigu.realtime.app
import java.time.LocalDate
import com.atguigu.realtime.bean.StartupLog
import com.atguigu.realtime.util.{MyEsUtil, MyKafkaUtil_1, MyRedisUtil, OffsetManager}
import org.apache.kafka.common.TopicPartition
import org.apache.spark.SparkConf
import org.apache.spark.streaming.dstream.DStream
import org.apache.spark.streaming.kafka010.{HasOffsetRanges, OffsetRange}
import org.apache.spark.streaming.{Seconds, StreamingContext}
import org.json4s.JValue
import org.json4s.JsonAST.JObject
import org.json4s.jackson.JsonMethods
import redis.clients.jedis.Jedis
import scala.collection.mutable.ListBuffer
object DauApp_1 {
//消费者组和主题
val groupId = "DauApp_1"
val topic = "gmall_startup_topic"
************************************************************主函数******************************************************************
def main(args: Array[String]): Unit = {
val conf: SparkConf = new SparkConf().setMaster("local[2]").setAppName("DauApp_1")
val ssc: StreamingContext = new StreamingContext(conf, Seconds(3))
"读取kafka的流中的数据,同时获取偏移量"
//1.先调用OffsetManager工具类的readOffsets方法读取初始偏移量
val offsets: Map[TopicPartition, Long] = OffsetManager.readOffsets(groupId, topic)
//2.再设置一个List集合来存储读取到的offsetRanges
val offsetRanges = ListBuffer.empty[OffsetRange] //在Driver创建的
//3.调用MyKafkaUtil_1工具类的getKafkaStream方法来获取kafka的流数据(InputDStream流),每隔三秒把所有数据存到一个rdd里,这个rdd里有数据和offset数据
val sourceStream= MyKafkaUtil_1
//给getKafkaStream方法传入消费者组,主题和读取到的起始偏移量
.getKafkaStream(ssc, groupId, topic, offsets)
// 如何拿到流里的rdd呢
.transform(rdd => { //这里没3秒执行一次,每次执行多个分区,所以是偏移量的集合
//4.强转,必须是从kafka直接得到的那个流中的rdd 这里也是driver
val newOffsetRanges: Array[OffsetRange] = rdd.asInstanceOf[HasOffsetRanges]
//5.调用offsetRanges方法获取这个流,即这个分区的这个主题中的中的偏移量,用一个数组接收
.offsetRanges
//接收前,将可变集合清空
offsetRanges.clear()
//将新获取的offset存入offsert集合里
offsetRanges ++= newOffsetRanges
//6.返回的rdd就是上次消费的位置,因为trainsfrom是转换算子,所以需要有返回值
rdd
})
//7.获取这个Map集合的value值
.map(_.value())
"将数据格式化处理写成json格式"
//8.格式化数据
val startupLogStream= parseToStartupLog(sourceStream)
"去重"
//9.调用distinct_2方法去重
val result: DStream[StartupLog] = distinct_2(startupLogStream)
//10.保存offsets应该每个批次保存一次,先写数据,再保存offsets //写数据跟写offset放一起
result.foreachRDD(rdd => {
"先写数据到es,在写offset到redis"
//11.先写数据到es
rdd.foreachPartition(it => {
//12.获取今天的日期
val today: String = LocalDate.now().toString
//13.给每条数据加上id就不会重复
MyEsUtil.insertBulk(s"gmall_dau_info_$today", it.map(log => (log.mid, log)))
})
"保存"
//再保存offset到redis
//ConsumerRecord里面的rdd里有这次消费到了哪些个offsets
//13.调用 OffsetManager类的saveOffsets方法,传入这个消费者组的这个主题的偏移量
OffsetManager.saveOffsets(offsetRanges, groupId, topic)
})
//14.启动流
ssc.start()
//15.防止主线程退出
ssc.awaitTermination()
}
**************************************************************方法******************************************************************
/**
* 把数据格式解析成StartupLog格式
*
* @param sourceStream
* @return
*/
def parseToStartupLog(sourceStream: DStream[String]) = {
sourceStream.map(jsonString => {
val value: JValue = JsonMethods.parse(jsonString)
val jCommon: JValue = value \ "common"
val jTs: JValue = value \ "ts"
implicit val f = org.json4s.DefaultFormats
jCommon.merge(JObject("ts" -> jTs)).extract[StartupLog]
})
}
/**
* 对传入的流去重
*
* @param startupLogStream
* @return 去重后的流
*/
def distinct_2(startupLogStream: DStream[StartupLog]) = {
startupLogStream.mapPartitions((it: Iterator[StartupLog]) => {
// 把当前分区所有的数据(Iterator)传过来, 然后需要返回一个新的迭代器
val client: Jedis = MyRedisUtil.getClient
val filerIt = it.filter(log => {
val key = "mids:" + log.logDate
client.sadd(key, log.mid) == 1
})
client.close()
filerIt
})
}
}
//思路
1.从redis读取上次保存的offset,启动时候,读一次
val offset = OffsetManager.readOffsets()
强转,必须是kafka直接
读取到这次消费的offset记录
每次向可变集合插入数据的时候,先清理
创建一个可变数组,用来装消费的offset的记录
2.保存offset应该每个批次保存一次,先写数据,在保存offsets
①、先写es
②、保存offset到redis,如何知道这次消费到了那些个offset
创建一个OffsetManager方法
注意:
1.可变map和不可变map
2.kafka没有key怎么发数据,轮循
3.hash是key相等值覆盖