SparkStreaming实时数仓——日活

文章目录

一、日活需求概述

  • 什么叫日活
    • 通常 打开应用的用户即为活跃用户,不考虑用户的使用情况。每天一台设备打开多次会被计为一个活跃用户。 也就是只需要统计第一次打开即可
    • 游戏用户:每天打开(登录)游戏的用户数(针对游戏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,这些消费者组每个人都有自己不同的offset4.这里进行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.hashkey相等值覆盖
  • 0
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值