日活需求
1、需求分析&实现思路
1.1、用户首次登录趋势图
从项目的日志中获取用户的启动日志,如果是当日第一次启动,纳入统计。将统计结果保存到ES中,利用Kibana进行分析展示
1.2、实现思路
第一步:SparkStreaming 消费Kafka数据:Kafka作为数据来源,从kafka中获取日志,kafka中的日志类型有两种,启动和事件,我们这里统计日活,只获取启动日志即可;
第二步:使用redis 对以及完成首次登录的数据进行剔重:每个用户每天可能启动多次。要想计算日活,我们只需要把当前用户每天的第一次启动日志获取即可,所以要对启动日志进行去重,相当于做了一次清洗。
第三步:对剔重Jon过后的明细数据保存到ES中
第四步、利用 Kibana 进行展示
2、功能实现
2.1、创建maven 工程,导入相关依赖

<properties> <spark.version>3.0.0</spark.version> <scala.version>2.12.11</scala.version> <kafka.version>2.4.1</kafka.version> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding> <java.version>1.8</java.version> </properties> <dependencies> <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>1.2.62</version> </dependency> <dependency> <groupId>org.apache.spark</groupId> <artifactId>spark-core_2.12</artifactId> <version>${spark.version}</version> </dependency> <dependency> <groupId>org.apache.spark</groupId> <artifactId>spark-streaming_2.12</artifactId> <version>${spark.version}</version> </dependency> <dependency> <groupId>org.apache.kafka</groupId> <artifactId>kafka-clients</artifactId> <version>${kafka.version}</version> </dependency> <dependency> <groupId>org.apache.spark</groupId> <artifactId>spark-streaming-kafka-0-10_2.12</artifactId> <version>${spark.version}</version> </dependency> <dependency> <groupId>redis.clients</groupId> <artifactId>jedis</artifactId> <version>2.9.0</version> </dependency> <dependency> <groupId>io.searchbox</groupId> <artifactId>jest</artifactId> <version>5.3.3</version> </dependency> <dependency> <groupId>org.apache.logging.log4j</groupId> <artifactId>log4j-to-slf4j</artifactId> <version>2.11.0</version> </dependency> <dependency> <groupId>net.java.dev.jna</groupId> <artifactId>jna</artifactId> <version>4.5.2</version> </dependency> <dependency> <groupId>org.codehaus.janino</groupId> <artifactId>commons-compiler</artifactId> <version>3.0.16</version> </dependency> <dependency> <groupId>org.elasticsearch</groupId> <artifactId>elasticsearch</artifactId> <version>6.6.0</version> </dependency> </dependencies> <build> <plugins> <!-- 该插件用于将Scala代码编译成class文件 --> <plugin> <groupId>net.alchim31.maven</groupId> <artifactId>scala-maven-plugin</artifactId> <version>3.4.6</version> <executions> <execution> <!-- 声明绑定到maven的compile阶段 --> <goals> <goal>compile</goal> <goal>testCompile</goal> </goals> </execution> </executions> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-assembly-plugin</artifactId> <version>3.0.0</version> <configuration> <descriptorRefs> <descriptorRef>jar-with-dependencies</descriptorRef> </descriptorRefs> </configuration> <executions> <execution> <id>make-assembly</id> <phase>package</phase> <goals> <goal>single</goal> </goals> </execution> </executions> </plugin> </plugins> </build>
相关工具类参见之前的文章
2.2、SparkStreaming 消费kafka 数据
- 模拟日志程序运行生成启动和事件日志
- 请求交给Nginx进行处理
- Nginx反向代理三台处理日志的服务器
- 日志处理服务将日志写到Kafka的主题中
- 编写基本业务类,使用SparkStreming从Kafka主题中消费数据
- 目前只做打印输出
代码实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 | import java.lang import java.text.SimpleDateFormat import java.util.Date import com.alibaba.fastjson.{JSON, JSONObject} import org.apache.kafka.clients.consumer.ConsumerRecord import org.apache.kafka.common.TopicPartition import org.apache.spark.SparkConf import org.apache.spark.streaming.dstream.{DStream, InputDStream} import org.apache.spark.streaming.kafka 010 .{HasOffsetRanges, OffsetRange} import org.apache.spark.streaming.{Seconds, StreamingContext} import org.wdh 01 .gmall.realtime.bean.DauInfo import org.wdh 01 .gmall.realtime.util.{MyESutil, MyKafkaUtil, MyRedisUtil, OffsetManagerUtil} import redis.clients.jedis.Jedis import scala.collection.mutable.ListBuffer /** * 日活 */ object DauAPP { def main(args : Array[String]) : Unit = { //使用 SparkStreaming 消费 kafka 数据 val conf : SparkConf = new SparkConf().setAppName( "DauAPP" ).setMaster( "local[4]" ) val ssc : StreamingContext = new StreamingContext(conf, Seconds( 5 )) var topic = "gmall_start_0423" var groupId = "gmall_dau_0423" //从 redis 获取跑偏移量 val offsetMap : Map[TopicPartition, Long] = OffsetManagerUtil.getOffset(topic, groupId) var recordDStream : InputDStream[ConsumerRecord[String, String]] = null if (offsetMap ! = null && offsetMap.size > 0 ) { // //offsetMap!=null && offsetMap.size>0 表示非首次消费 //redis 存在当前消费者组的偏移量信息,那么从指定偏移量位置开始消费 recordDStream = MyKafkaUtil.getKafkaStream(topic, ssc, offsetMap, groupId) } else { // 如果redsi 没有存放偏移量信息,从开始最新位置开始消费 recordDStream = MyKafkaUtil.getKafkaStream(topic, ssc, groupId) } var offsetRanges : Array[OffsetRange] = Array.empty[OffsetRange] //获取采集周期消费 kafka 的起始偏移量和结束偏移量 val offsetDStream : DStream[ConsumerRecord[String, String]] = recordDStream.transform { rdd = > { // recordDStream 底层封装的是 KafkaRDD,混入了 HasOffsetRange 特质, // 其底层提供了 可以获取偏移量范围的方法 offsetRanges = rdd.asInstanceOf[HasOffsetRanges].offsetRanges rdd } } // jsonStr.print() //对读取的数据进行处理 val jsonDStream : DStream[JSONObject] = offsetDStream.map { record = > { val str : String = record.value() //转换为对象 val jsonObject : JSONObject = JSON.parseObject(str) //获取时间戳 val ts : lang.Long = jsonObject.getLong( "ts" ) //将 ts 转换为日期 小时 val dateStr : String = new SimpleDateFormat( "yyyy-MM-dd HH" ).format( new Date(ts)) //切分日期 和 小时 val date : Array[String] = dateStr.split( " " ) val dt : String = date( 0 ) val hr : String = date( 1 ) //向原有 json 增加两个字段,即 日期 & 小时 jsonObject.put( "dt" , dt) jsonObject.put( "hr" , hr) //返回新的 json jsonObject } } |
测试:启动 zk,kafka,Nginx,log 处理jar 程序,运行idea,在启动 模拟日志即可。
2.3、使用Redis进行剔重
- 利用Redis保存今天访问过系统的用户清单 即SparkStreaming从Kafka中读取到用户的启动日志之后,将用户的启动日志保存到Redis中,进行去重
- 根据保存反馈得到用户是否已存在 Redis的五大数据类型中,String和Set都可以完成去重功能,但是String管理不适合整体操作,比如设置失效时间或者获取当天用户等操作,所以我们项目中使用的是Set类型,处理批量管理以外,还可以根据saddAPI的返回结果判断用户是否已经存在
Key |
Value |
dau:2019-01-22 |
设备id |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 | /** * 对采集到的数据进行剔重 方式 1 :此处使用 redis Set进行天然剔重 * Redis 使用 set key:dau:2022-04-23 value:mid expire:3600*24 * 方式1 缺点 :采集周期每一条数据都要获取 jedis 连接,浪费资源 */ /* val filterDStream: DStream[JSONObject] = jsonDStream.filter { jsonObj => { //获取登录日期 val dt: String = jsonObj.getString("dt") //获取设备ID val mid: String = jsonObj.getJSONObject("common").getString("mid") //拼接 key val dauKey: String = "dau:" + dt //链接 redis 获取jedis val jedis: Jedis = MyRedisUtil.getJedisClient() //判断是否已经存在 :isFirst 表示是否添加成功:成功 1 表示第一次登录 失败 0 非第一次登录 val isFirst: lang.Long = jedis.sadd(dauKey, mid) if (jedis.ttl(dauKey) < 0) { //jedis.ttl(dauKey)<0 key 永久有效 //设置有效时间 一天 jedis.expire(dauKey, 3600 * 24) } //关闭链接 jedis.close() if (isFirst == 1L) { true } else { false } } }*/ /** * 对采集到的数据进行剔重 方式 2 :此处使用 redis Set进行天然剔重 * * Redis 使用 set key:dau:2022-04-23 value:mid expire:3600*24 * * 方式2 使用 mapPartition ,每一个分区获取一个 jedis 连接 */ val filterDStream 1 : DStream[JSONObject] = jsonDStream.mapPartitions { jsonObjItr = > { //以分区为单位处理数据 val jedis : Jedis = MyRedisUtil.getJedisClient() //声明一个集合;存放当前分区首次登录的数据 val listBuffer : ListBuffer[JSONObject] = new ListBuffer[JSONObject] //对分区数据进行遍历 for (jsonObj <- jsonObjItr) { //获取 jsonObj 相关属性 //获取日期 val dt : String = jsonObj.getString( "dt" ) //获取设备ID val mid : String = jsonObj.getJSONObject( "common" ).getString( "mid" ) //拼接key val dauKey : String = "dau:" + dt //判断是否首次登录 :isFirst 表示是否添加成功:成功 1 表示第一次登录 失败 0 非第一次登录 val isFirst : lang.Long = jedis.sadd(dauKey, mid) if (jedis.ttl(dauKey) < 0 ) { //jedis.ttl(dauKey)<0 key 永久有效 //设置有效时间 一天 jedis.expire(dauKey, 3600 * 24 ) } if (isFirst == 1 L) { listBuffer.append(jsonObj) } } //关闭链接 jedis.close() listBuffer.toIterator } } |
测试时需要在启动 redis 即可,此处建议使用方案2实现,必将频频繁创建redis 连接,过多消耗资源。
2.4、批量保存 ES
将去重后的结果保存的ElasticSearch中,以便后续业务操作
首先创建 ES 模板
PUT _template/gmall0423_dau_info_template { "index_patterns": ["gmall0423_dau_info*"], "settings": { "number_of_shards": 3 }, "aliases" : { "{index}-query": {}, "gmall0423_dau_info-query":{} }, "mappings": { "_doc":{ "properties":{ "mid":{ "type":"keyword" }, "uid":{ "type":"keyword" }, "ar":{ "type":"keyword" }, "ch":{ "type":"keyword" }, "vc":{ "type":"keyword" }, "dt":{ "type":"keyword" }, "hr":{ "type":"keyword" }, "mi":{ "type":"keyword" }, "ts":{ "type":"date" } } } } }
封装实体类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | package org.wdh 01 .gmall.realtime.bean /** * 样例类 * @param mid * @param uid * @param ar * @param ch * @param vc * @param dt * @param hr * @param mi * @param ts */ case class DauInfo( mid : String, //设备id uid : String, //用户id ar : String, //地区 ch : String, //渠道 vc : String, //版本 var dt : String, //日期 var hr : String, //小时 var mi : String, //分钟 ts : Long //时间戳 ) {} |
保存ES
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 | //将数据批量保存 ES filterDStream 1 .foreachRDD { rdd = > { //以分区为单位对数据进行处理 rdd.foreachPartition { jsonObjItr = > { val dauInfolist : List[(String, DauInfo)] = jsonObjItr.map { jsonObj = > { //每次处理的是一个json对象 将json对象封装为样例类 val commonJsonObj : JSONObject = jsonObj.getJSONObject( "common" ) val dauInfo : DauInfo = DauInfo( commonJsonObj.getString( "mid" ), commonJsonObj.getString( "uid" ), commonJsonObj.getString( "ar" ), commonJsonObj.getString( "ch" ), commonJsonObj.getString( "vc" ), jsonObj.getString( "dt" ), jsonObj.getString( "hr" ), "00" , //分钟我们前面没有转换,默认00 jsonObj.getLong( "ts" ) ) (dauInfo.mid, dauInfo) } }.toList //批量保存ES val dt : String = new SimpleDateFormat( "yyyy-MM-dd" ).format( new Date()) MyESutil.bulkInsert(dauInfolist, "gmall0423_dau_info_" + dt) } } //提交偏移量到 Redis OffsetManagerUtil.saveOffset(topic, groupId, offsetRanges) } } filterDStream 1 .count().print() ssc.start() ssc.awaitTermination() } } /** * 查看所有topic * bin/kafka-topic.sh --bootstrap-server hadoop201:9092 --list * 消费数据测试 * bin/kafka-console-consumer.sh --bootstrap-server hadoop201:9092 --topic gmall_start_0423 * jsonObject 样例数据 * {"dt":"2022-04-23","common":{"ar":"230000","uid":"149","os":"Android 10.0","ch":"xiaomi","md":"Xiaomi 9","mid":"mid_50","vc":"v2.1.134","ba":"Xiaomi"},"start":{"entry":"notice","open_ad_skip_ms":0,"open_ad_ms":5519,"loading_time":1737,"open_ad_id":20},"hr":"03","ts":1650656885000} * redis * /usr/local/bin/redis-server /etc/redis.conf * /usr/local/bin/redis-cli * 127.0.0.1:6379> keys * * 1) "dau:2022-04-23" * 127.0.0.1:6379> Smembers dau:2022-04-23 * redis 格式化所有数据 * flushall * ES 查看所有模板 * #查看所有idnex * GET /_cat/indices * #查看指定 idnex 内容 * GET /gmall0423_dau_info_2022-05-01/_search * */ |
启动 es & kiban 进行测试即可。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 无需6万激活码!GitHub神秘组织3小时极速复刻Manus,手把手教你使用OpenManus搭建本
· C#/.NET/.NET Core优秀项目和框架2025年2月简报
· 一文读懂知识蒸馏
· Manus爆火,是硬核还是营销?
· 终于写完轮子一部分:tcp代理 了,记录一下
2020-05-05 Redis (error) MISCONF Redis is configured to save RDB snapshots, but is currently not able to persist on disk. Commands that may modify 问题解决方法