更新实战项目内容
This commit is contained in:
406
note/实战项目/基于 flink 的电商用户行为数据分析【5】基于埋点日志数据的网络流量统计.md
Normal file
406
note/实战项目/基于 flink 的电商用户行为数据分析【5】基于埋点日志数据的网络流量统计.md
Normal file
@@ -0,0 +1,406 @@
|
||||
## 前言
|
||||
在[《基于flink的电商用户行为数据分析【3】| 实时流量统计》](https://alice.blog.csdn.net/article/details/110212749)这篇文章中,博主为大家介绍了基于服务器 log 的热门页面浏览量统计。 最后通过运行结果的验证,我们发现,从 web 服务器 log 中得到的 url,往往更多的是请求某个资源地址(`/*.js`、`/*.css`),如果要针对页面进行统计往往还需要进行过滤。而在实际电商应用中,**相比每个单独页面的访问量,我们可能更加关心整个电商网站的网络流量**。这个指标,除了合并之前每个页面的统计结果之外,还可以通过统计埋点日志数据中的“pv”行为来得到....
|
||||
|
||||

|
||||
|
||||
|
||||
***
|
||||
### 网站总浏览量(PV)的统计
|
||||
衡量网站流量一个最简单的指标,就是网站的**页面浏览量**(Page View,PV)。用户每次打开一个页面便记录1次PV,多次打开同一页面则浏览量累计。一般来说,PV与来访者的数量成正比,但是PV并不直接决定页面的真实来访者数量,如同一个来访者通过不断的刷新页面,也可以制造出非常高的PV。
|
||||
|
||||
|
||||
我们知道,用户浏览页面时,会从浏览器向网络服务器发出一个请求(Request),网络服务器接到这个请求后,会将该请求对应的一个网页(Page)发送给浏览器,从而产生了一个PV。所以我们的统计方法,可以是**从web服务器的日志中去提取对应的页面访问**然后统计,就向上一节中的做法一样;也可以**直接从埋点日志中提取用户发来的页面请求**,从而统计出总浏览量。
|
||||
|
||||
所以,接下来我们用UserBehavior.csv作为数据源,实现一个网站总浏览量的统计。我们可以设置滚动时间窗口,实时统计每小时内的网站PV。
|
||||
|
||||

|
||||
在src/main/scala下创建 `PageView.scala` 文件,具体代码如下:
|
||||
|
||||
```scala
|
||||
object PageView {
|
||||
|
||||
case class UserBehavior(userId: Long, itemId: Long, categoryId: Int, behavior: String, timestamp: Long)
|
||||
|
||||
def main(args: Array[String]): Unit = {
|
||||
|
||||
// 创建 流处理的 环境
|
||||
val env: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment
|
||||
// 设置时间语义为 eventTime -- 事件创建的时间
|
||||
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)
|
||||
// 设置程序的并行度
|
||||
env.setParallelism(1)
|
||||
|
||||
// 读取文本数据
|
||||
env.readTextFile("YOUR_PATH\\UserBehavior.csv")
|
||||
// 对文本数据进行封装处理
|
||||
.map(data => {
|
||||
|
||||
val dataArray: Array[String] = data.split(",")
|
||||
// 将数据封装进 UserBehavior
|
||||
UserBehavior(dataArray(0).toLong,dataArray(1).toLong,dataArray(2).toInt,dataArray(3),dataArray(4).toLong)
|
||||
})
|
||||
// 设置水印
|
||||
.assignAscendingTimestamps(_.timestamp * 1000)
|
||||
// 过滤出 "pv" 数据
|
||||
.filter(_.behavior == "pv")
|
||||
// 求和
|
||||
.map(x => ("pv",1))
|
||||
.keyBy(_._1)
|
||||
// 设置TimeWindow,每一小时做一次聚合
|
||||
.timeWindow(Time.seconds(60 * 60))
|
||||
.sum(1)
|
||||
.print()
|
||||
|
||||
// 执行程序
|
||||
env.execute("Page View Job")
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
程序运行的结果:
|
||||

|
||||
|
||||
|
||||
### 网站独立访客数(UV)的统计
|
||||
在上节的例子中,我们统计的是**所有用户对页面的所有浏览行为**,也就是说,**同一用户的浏览行为会被重复统计**。而在实际应用中,我们往往还会关注,在一段时间内到底**有多少不同的用户访问了网站**。
|
||||
|
||||
另外一个统计流量的重要指标是网站的**独立访客数**(Unique Visitor,UV)。**UV指的是一段时间(比如一小时)内访问网站的总人数**,1天内同一访客的多次访问只记录为一个访客。通过`IP`和`cookie`一般是判断UV值的两种方式。<font color='gray'>当客户端第一次访问某个网站服务器的时候,网站服务器会给这个客户端的电脑发出一个Cookie,通常放在这个客户端电脑的C盘当中。在这个Cookie中会分配一个独一无二的编号,这其中会记录一些访问服务器的信息,如访问时间,访问了哪些页面等等。当你下次再访问这个服务器的时候,服务器就可以直接从你的电脑中找到上一次放进去的Cookie文件,并且对其进行一些更新,但那个独一无二的编号是不会变的。</font>
|
||||
|
||||
当然,对于UserBehavior 数据源来说,我们直接可以根据userId来区分不同的用户。
|
||||
|
||||
在src/main/scala下创建`UniqueVisitor.scala`文件,具体代码如下:
|
||||
|
||||
```scala
|
||||
object UniqueVisitor {
|
||||
|
||||
case class UserBehavior(userId: Long, itemId: Long, categoryId: Int, behavior: String, timestamp: Long)
|
||||
case class UvCount(windowEnd: Long, count: Long)
|
||||
|
||||
def main(args: Array[String]): Unit = {
|
||||
|
||||
// 创建 流处理的 环境
|
||||
val env: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment
|
||||
// 设置时间语义为 eventTime -- 事件创建的时间
|
||||
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)
|
||||
// 设置程序的并行度
|
||||
env.setParallelism(1)
|
||||
|
||||
// 读取文本数据
|
||||
env.readTextFile("YOUR_PATH\\UserBehavior.csv")
|
||||
// 对文本数据进行封装处理
|
||||
.map(data => {
|
||||
|
||||
val dataArray: Array[String] = data.split(",")
|
||||
// 将数据封装进 UserBehavior
|
||||
UserBehavior(dataArray(0).toLong,dataArray(1).toLong,dataArray(2).toInt,dataArray(3),dataArray(4).toLong)
|
||||
})
|
||||
// 设置水印
|
||||
.assignAscendingTimestamps(_.timestamp * 1000)
|
||||
// 过滤出 "pv" 数据
|
||||
.filter(_.behavior == "pv")
|
||||
// 设置窗口大小为一个小时
|
||||
.timeWindowAll(Time.seconds(60 * 60))
|
||||
.apply(new UvCountByWindow())
|
||||
.print()
|
||||
|
||||
// 执行程序
|
||||
env.execute("Page View Job")
|
||||
}
|
||||
|
||||
class UvCountByWindow extends AllWindowFunction[UserBehavior,UvCount,TimeWindow]{
|
||||
|
||||
override def apply(window: TimeWindow, input: Iterable[UserBehavior], out: Collector[UvCount]): Unit = {
|
||||
|
||||
// 初始化一个Set集合,用于将存储的用户id数据进行去重
|
||||
var idSet: Set[Long] = Set[Long]()
|
||||
|
||||
for ( userBehavior <- input){
|
||||
idSet += userBehavior.userId
|
||||
}
|
||||
|
||||
// 输出结果
|
||||
out.collect(UvCount(window.getEnd,idSet.size))
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
程序运行的结果:
|
||||
|
||||

|
||||
到了这一步,让我们想想,还有没有更好的方案?
|
||||
|
||||
|
||||

|
||||
|
||||
### 使用布隆过滤器的 UV 统计
|
||||
在上节的例子中,我们把所有数据的userId都存在了窗口计算的状态里,在窗口收集数据的过程中,状态会不断增大。一般情况下,只要不超出内存的承受范围,这种做法也没什么问题;但如果我们遇到的数据量很大呢?
|
||||
|
||||
把所有数据暂存放到内存里,显然不是一个好注意。我们会想到,可以利用**redis这种内存级k-v数据库**,为我们做一个缓存。但如果我们遇到的情况非常极端,数据大到惊人呢?比如上亿级的用户,要去重计算UV。
|
||||
|
||||
如果放到redis中,亿级的用户id(每个20字节左右的话)可能需要几G甚至几十G的空间来存储。当然放到redis中,用集群进行扩展也不是不可以,但明显代价太大了。
|
||||
|
||||
一个更好的想法是,其实我们不需要完整地存储用户ID的信息,只要知道他在不在就行了。所以其实我们可以进行压缩处理,用一位(bit)就可以表示一个用户的状态。这个思想的具体实现就是**布隆过滤器**`(Bloom Filter)`。
|
||||
|
||||
本质上**布隆过滤器**是一种数据结构,比较巧妙的概率型数据结构(probabilistic data structure),特点是**高效地插入和查询**,可以用来告诉你 “某样东西一定不存在或者可能存在”。
|
||||
|
||||
它本身是一个很长的二进制向量,既然是二进制的向量,那么显而易见的,存放的不是0,就是1。<font color='Tomato'>相比于传统的 List、Set、Map 等数据结构,它更高效、占用空间更少,但是缺点是其返回的结果是概率性的,而不是确切的</font>
|
||||
|
||||
我们的目标就是,利用某种方法(一般是Hash函数)把每个数据,对应到一个位图的某一位上去;如果数据存在,那一位就是1,不存在则为0。
|
||||
|
||||
接下来我们就来具体实现一下。
|
||||
|
||||
注意这里我们用到了redis连接存取数据,所以需要加入redis客户端的依赖:
|
||||
|
||||
```xml
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>redis.clients</groupId>
|
||||
<artifactId>jedis</artifactId>
|
||||
<version>2.8.1</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
```
|
||||
|
||||
在src/main/scala下创建UniqueVisitor.scala文件,具体代码如下:
|
||||
|
||||
```scala
|
||||
object UvWithBloomFilter {
|
||||
|
||||
// 定义样例类,用于封装数据
|
||||
case class UserBehavior(userId: Long, itemId: Long, categoryId: Int, behavior: String, timestamp: Long)
|
||||
|
||||
case class UvCount(windowEnd: Long, count: Long)
|
||||
|
||||
def main(args: Array[String]): Unit = {
|
||||
|
||||
// 创建 流处理的 环境
|
||||
val env: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment
|
||||
// 设置时间语义为 eventTime -- 事件创建的时间
|
||||
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)
|
||||
// 设置程序的并行度
|
||||
env.setParallelism(1)
|
||||
|
||||
// 读取文本数据
|
||||
env.readTextFile("YOUR_PATH\\UserBehavior.csv")
|
||||
// 对文本数据进行封装处理
|
||||
.map(data => {
|
||||
val dataArray: Array[String] = data.split(",")
|
||||
// 将数据封装进 UserBehavior
|
||||
UserBehavior(dataArray(0).toLong, dataArray(1).toLong, dataArray(2).toInt, dataArray(3), dataArray(4).toLong)
|
||||
})
|
||||
// 设置水印 [ 升序时间戳 ]
|
||||
.assignAscendingTimestamps(_.timestamp * 1000)
|
||||
// 只统计 "pv" 数据
|
||||
.filter(_.behavior == "pv")
|
||||
.map(data => ("dummyKey", data.userId))
|
||||
.keyBy(_._1)
|
||||
// 设置窗口大小为一个小时
|
||||
.timeWindow(Time.hours(1))
|
||||
// 我们不应该等待窗口关闭才去做 Redis 的连接 -》 数据量可能很大,窗口的内存放不下
|
||||
// 所以这里使用了 触发窗口操作的API -- 触发器 trigger
|
||||
.trigger(new MyTrigger())
|
||||
.process(new UvCountWithBloom())
|
||||
.print()
|
||||
|
||||
// 执行程序
|
||||
env.execute("uv with bloom Job")
|
||||
|
||||
}
|
||||
|
||||
// 自定义窗口触发器
|
||||
class MyTrigger() extends Trigger[(String, Long), TimeWindow] {
|
||||
// 如果事件是基于 processTime 触发
|
||||
override def onProcessingTime(time: Long, window: TimeWindow, ctx: Trigger.TriggerContext): TriggerResult = {
|
||||
|
||||
TriggerResult.CONTINUE
|
||||
}
|
||||
|
||||
// 如果事件是基于 eventTime 触发
|
||||
override def onEventTime(time: Long, window: TimeWindow, ctx: Trigger.TriggerContext): TriggerResult = {
|
||||
|
||||
|
||||
TriggerResult.CONTINUE
|
||||
}
|
||||
|
||||
// 收尾工作
|
||||
override def clear(window: TimeWindow, ctx: Trigger.TriggerContext): Unit = {}
|
||||
|
||||
// 每来一个元素就触发
|
||||
override def onElement(element: (String, Long), timestamp: Long, window: TimeWindow, ctx: Trigger.TriggerContext): TriggerResult = {
|
||||
|
||||
// 每来一条数据,就直接触发窗口操作,并清空所有窗口状态
|
||||
TriggerResult.FIRE_AND_PURGE
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 定义一个布隆过滤器
|
||||
class Bloom(size: Long) extends Serializable {
|
||||
// 位图的总大小
|
||||
private val cap = if (size > 0) size else 1 << 27
|
||||
|
||||
// 定义 hash 函数
|
||||
def hash(value: String, seed: Int) = {
|
||||
|
||||
var result: Long = 0L
|
||||
for (i <- 0 until value.length) {
|
||||
result = result * seed + value.charAt(i)
|
||||
}
|
||||
result & (cap - 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义窗口处理函数
|
||||
class UvCountWithBloom() extends ProcessWindowFunction[(String, Long), UvCount, String, TimeWindow] {
|
||||
|
||||
// 创建 redis 连接
|
||||
lazy val jedis = new Jedis("node02", 6379)
|
||||
|
||||
lazy val bloom = new Bloom(1 << 29)
|
||||
|
||||
override def process(key: String, context: Context, elements: Iterable[(String, Long)], out: Collector[UvCount]): Unit = {
|
||||
// 位图的存储方式, key 是 windowEnd,value 是 bitmap
|
||||
val storeKey: String = context.window.getEnd.toString
|
||||
var count = 0L
|
||||
// 把每个窗口的 uv count 值也存入 redis 表,存放内容为(windowEnd > uvCount),所以要先从 redis 中读取
|
||||
if (jedis.hget("count", storeKey) != null) {
|
||||
count = jedis.hget("count", storeKey).toLong
|
||||
}
|
||||
|
||||
// 用 布隆过滤器 判断当前用户是否已经存在
|
||||
// 因为是每来一条数据就判断一次,所以我们就可以直接用last获取到这条数据
|
||||
val userId: String = elements.last._2.toString
|
||||
// 计算哈希
|
||||
val offset: Long = bloom.hash(userId, 61)
|
||||
// 定义一个标志位,判断 redis 位图中有没有这一位
|
||||
val isExist: lang.Boolean = jedis.getbit(storeKey, offset)
|
||||
|
||||
if (!isExist) {
|
||||
// 如果不存在,位图对应位置1,count + 1
|
||||
jedis.setbit(storeKey, offset, true)
|
||||
jedis.hset("count", storeKey, (count + 1).toString)
|
||||
out.collect(UvCount(storeKey.toLong, count + 1))
|
||||
} else {
|
||||
// 输出到 flink
|
||||
out.collect(UvCount(storeKey.toLong, count))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
```
|
||||
|
||||
程序运行的效果如下所示:
|
||||

|
||||
可以发现,我们改进之后的程序,不再是把所有需要统计的数据都放到本地内存里进行计算,而是来一条数据,我们就输出,然后利用布隆过滤器进行判断,并将最新的结果存入Redis。
|
||||
|
||||
等到程序运行完毕,我们打开 `redis`,输入`hgetall count`查看统计的最终结果,可以发现跟我们之前统计的结果是一致的。
|
||||

|
||||
要是嫌利用 redis 的 `bitmap` 自己手动实现一个简单的布隆过滤器过程繁琐的话,我们也可以利用<font color='Tomato'>Flink官方实现的布隆过滤器</font>来实现。具体代码见下:
|
||||
|
||||
```scala
|
||||
/*
|
||||
* @Author: Alice菌
|
||||
* @Date: 2020/12/5 18:29
|
||||
* @Description:
|
||||
// uv: unique visitor
|
||||
// 有多少用户访问过网站;pv按照userid去重
|
||||
// 滑动窗口:窗口长度1小时,滑动距离5秒钟,每小时用户数量1亿
|
||||
// 大数据去重的唯一解决方案:布隆过滤器
|
||||
// 布隆过滤器的组成:bit数组,哈希函数
|
||||
*/
|
||||
object UvByBloomFilterWithoutRedis {
|
||||
|
||||
case class UserBehavior(userId: Long,
|
||||
itemId: Long,
|
||||
categoryId: Long,
|
||||
behavior: String,
|
||||
timestamp: Long)
|
||||
|
||||
def main(args: Array[String]): Unit = {
|
||||
|
||||
val env: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment
|
||||
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)
|
||||
env.setParallelism(1)
|
||||
|
||||
val stream: DataStream[String] = env.readTextFile("G:\\idea arc\\BIGDATA\\project\\src\\main\\resources\\UserBehavior.csv")
|
||||
.map(line => {
|
||||
val arr: Array[String] = line.split(",")
|
||||
UserBehavior(arr(0).toLong, arr(1).toLong, arr(2).toLong, arr(3), arr(4).toLong * 1000)
|
||||
})
|
||||
.filter(_.behavior.equals("pv")) // 只处理 pv 数据
|
||||
.assignAscendingTimestamps(_.timestamp) // 分配升序时间戳
|
||||
.map(r => ("key", r.userId)) // 对每个元素做处理
|
||||
.keyBy(_._1) // 分到同一组操作
|
||||
.timeWindow(Time.hours(1)) // 设置滑动窗口时间
|
||||
.aggregate(new UvAggFunc, new UvProcessFunc) // 自定义预聚合
|
||||
|
||||
// 打印结果
|
||||
stream.print()
|
||||
// 执行任务
|
||||
env.execute()
|
||||
|
||||
}
|
||||
|
||||
// 直接用聚合算子【count,布隆过滤器】
|
||||
class UvAggFunc extends AggregateFunction[(String,Long),(Long,BloomFilter[lang.Long]),Long]{
|
||||
|
||||
override def createAccumulator(): (Long, BloomFilter[lang.Long]) = (0,BloomFilter.create(Funnels.longFunnel(), 100000000, 0.01))
|
||||
|
||||
override def add(value: (String, Long), accumulator: (Long, BloomFilter[lang.Long])): (Long, BloomFilter[lang.Long]) = {
|
||||
|
||||
var bloom: BloomFilter[lang.Long] = accumulator._2
|
||||
var uvCount: Long = accumulator._1
|
||||
|
||||
// 通过布隆过滤器判断是否存在,不存在则 +1
|
||||
if (!bloom.mightContain(value._2)){
|
||||
bloom.put(value._2)
|
||||
uvCount += 1
|
||||
}
|
||||
(uvCount,bloom)
|
||||
}
|
||||
|
||||
override def getResult(accumulator: (Long, BloomFilter[lang.Long])): Long = accumulator._1 // 返回 count
|
||||
|
||||
override def merge(a: (Long, BloomFilter[lang.Long]), b: (Long, BloomFilter[lang.Long])): (Long, BloomFilter[lang.Long]) = ???
|
||||
|
||||
}
|
||||
|
||||
class UvProcessFunc extends ProcessWindowFunction[Long,String,String,TimeWindow]{
|
||||
|
||||
override def process(key: String, context: Context, elements: Iterable[Long], out: Collector[String]): Unit = {
|
||||
|
||||
// 拿到 Windows 的开始和结束时间
|
||||
val start: Timestamp = new Timestamp(context.window.getStart)
|
||||
val end: Timestamp = new Timestamp(context.window.getEnd)
|
||||
out.collect(s"窗口开始时间为:$start 到 $end 的 uv 为 ${elements.head}")
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
程序的运行结果:
|
||||

|
||||
***
|
||||
|
||||
### 小结
|
||||
本期文章,为大家讲解了**在基于flink的电商用户行为数据分析的项目中,如何基于埋点日志数据实现网络流量统计的功能**。一共介绍了3种不同的实现方式,其中光统计 UV 就有3种解决方案!文章中已将完整代码贴出,对代码有任何疑问的小伙伴均可加我微信私聊,交流学习!你知道的越多,你不知道的也越多,我是Alice,我们下一期见!
|
||||
|
||||
**受益的朋友记得三连支持小菌!**
|
||||
|
||||
|
||||
>**文章持续更新,可以微信搜一搜「 猿人菌 」第一时间阅读,思维导图,大数据书籍,大数据高频面试题,海量一线大厂面经…期待您的关注!**
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
336
note/实战项目/基于 flink 的电商用户行为数据分析【6】APP市场推广统计.md
Normal file
336
note/实战项目/基于 flink 的电商用户行为数据分析【6】APP市场推广统计.md
Normal file
@@ -0,0 +1,336 @@
|
||||
## 前言
|
||||
本篇是flink 的「电商用户行为数据分析」的第6篇文章,为大家带来的是**市场营销商业指标统计分析**之**APP市场推广统计**的内容,通过本期内容的学习,你同样能够学会处理一些特定场景领域下的方法。话不多说,我们直入正题!
|
||||
|
||||

|
||||
***
|
||||
## 模块创建和数据准备
|
||||
继续在`UserBehaviorAnalysis`下新建一个**maven module**作为子项目,命名为`MarketAnalysis`。
|
||||
|
||||
这个模块中我们没有现成的数据,所以会用自定义的测试源来产生测试数据流,或者直接用生成测试数据文件。
|
||||
|
||||
## APP市场推广统计
|
||||
随着智能手机的普及,在如今的电商网站中已经有越来越多的用户来自移动端,相比起传统浏览器的登录方式,手机APP成为了更多用户访问电商网站的首选。**对于电商企业来说,一般会通过各种不同的渠道对自己的APP进行市场推广,而这些渠道的统计数据(比如,不同网站上广告链接的点击量、APP下载量)就成了市场营销的重要商业指标**。
|
||||
|
||||
首先我们考察分渠道的市场推广统计。在src/main/scala下创建`AppMarketingByChannel.scala`文件。由于没有现成的数据,所以我们需要**自定义一个测试源**来生成用户行为的事件流。
|
||||
|
||||
## 自定义测试数据源
|
||||
定义一个源数据的样例类`MarketingUserBehavior`,再定义一个`SourceFunction`,用于产生用户行为源数据,命名为`SimulatedEventSource`:
|
||||
|
||||
```scala
|
||||
// 定义一个输入数据的样例类 保存电商用户行为的样例类
|
||||
case class MarketingUserBehavior(userId: String, behavior: String, channel: String, timestamp: Long)
|
||||
|
||||
// 定义一个输出结果的样例类 保存 市场用户点击次数
|
||||
case class MarketingViewCount(windowStart: String, windowEnd: String, channel: String, behavior: String, count: Long)
|
||||
|
||||
// 自定义数据源
|
||||
class SimulateEventSource extends RichParallelSourceFunction[MarketingUserBehavior] {
|
||||
|
||||
// 定义是否运行的标识符
|
||||
var running: Boolean = true
|
||||
// 定义渠道的集合
|
||||
val channelSet: Seq[String] = Seq("AppStore", "XiaomiStore", "HuaweiStore", "weibo", "wechat", "tieba")
|
||||
// 定义用户行为的集合
|
||||
val behaviorTypes: Seq[String] = Seq("BROWSE", "CLICK", "PURCHASE", "UNINSTALL")
|
||||
// 定义随机数发生器
|
||||
val rand: Random.type = Random
|
||||
|
||||
// 重写 run 方法
|
||||
override def run(ctx: SourceFunction.SourceContext[MarketingUserBehavior]): Unit = {
|
||||
|
||||
// 获取到 Long类型的最大值
|
||||
val maxElements: Long = Long.MaxValue
|
||||
// 设置初始值
|
||||
var count: Long = 0L
|
||||
|
||||
// 随机生成所有数据
|
||||
while (running && count < maxElements) {
|
||||
|
||||
// 生成一个随机数
|
||||
val id: String = UUID.randomUUID().toString
|
||||
// 获取随机行为
|
||||
val behaviorType: String = behaviorTypes(rand.nextInt(behaviorTypes.size))
|
||||
// 获取随机渠道
|
||||
val channel: String = channelSet(rand.nextInt(channelSet.size))
|
||||
// 获取到当前的系统时间
|
||||
val ts: Long = System.currentTimeMillis()
|
||||
// 输出生成的用户行为的事件流
|
||||
ctx.collect(MarketingUserBehavior(id, behaviorType, channel, ts))
|
||||
// count + 1
|
||||
count += 1
|
||||
// 设置休眠的时间
|
||||
TimeUnit.MICROSECONDS.sleep(10L)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
override def cancel(): Unit = running = false
|
||||
}
|
||||
```
|
||||
|
||||
## 分渠道统计
|
||||
另外定义一个窗口处理的输出结果样例类 `MarketingViewCount`,并自定义 `ProcessWindowFunction`进行处理,完整代码如下:
|
||||
|
||||
```scala
|
||||
import java.sql.Timestamp
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
import org.apache.flink.streaming.api.TimeCharacteristic
|
||||
import org.apache.flink.streaming.api.functions.source.{RichParallelSourceFunction, SourceFunction}
|
||||
import org.apache.flink.streaming.api.scala.function.ProcessWindowFunction
|
||||
import org.apache.flink.streaming.api.scala.{StreamExecutionEnvironment, _}
|
||||
import org.apache.flink.streaming.api.windowing.time.Time
|
||||
import org.apache.flink.streaming.api.windowing.windows.TimeWindow
|
||||
import org.apache.flink.util.Collector
|
||||
|
||||
import scala.util.Random
|
||||
|
||||
/*
|
||||
* @Author: Alice菌
|
||||
* @Date: 2020/12/7 17:32
|
||||
* @Description:
|
||||
电商用户行为数据分析: 市场营销商业指标统计分析
|
||||
APP市场推广统计 - - > 分渠道统计
|
||||
*/
|
||||
object AppMarketingByChannel {
|
||||
|
||||
// 定义一个输入数据的样例类 保存电商用户行为的样例类
|
||||
case class MarketingUserBehavior(userId: String, behavior: String, channel: String, timestamp: Long)
|
||||
|
||||
// 定义一个输出结果的样例类 保存 市场用户点击次数
|
||||
case class MarketingViewCount(windowStart: String, windowEnd: String, channel: String, behavior: String, count: Long)
|
||||
|
||||
// 自定义数据源
|
||||
class SimulateEventSource extends RichParallelSourceFunction[MarketingUserBehavior] {
|
||||
|
||||
// 定义是否运行的标识符
|
||||
var running: Boolean = true
|
||||
// 定义渠道的集合
|
||||
val channelSet: Seq[String] = Seq("AppStore", "XiaomiStore", "HuaweiStore", "weibo", "wechat", "tieba")
|
||||
// 定义用户行为的集合
|
||||
val behaviorTypes: Seq[String] = Seq("BROWSE", "CLICK", "PURCHASE", "UNINSTALL")
|
||||
// 定义随机数发生器
|
||||
val rand: Random.type = Random
|
||||
|
||||
// 重写 run 方法
|
||||
override def run(ctx: SourceFunction.SourceContext[MarketingUserBehavior]): Unit = {
|
||||
|
||||
// 获取到 Long类型的最大值
|
||||
val maxElements: Long = Long.MaxValue
|
||||
// 设置初始值
|
||||
var count: Long = 0L
|
||||
|
||||
// 随机生成所有数据
|
||||
while (running && count < maxElements) {
|
||||
|
||||
// 生成一个随机数
|
||||
val id: String = UUID.randomUUID().toString
|
||||
// 获取随机行为
|
||||
val behaviorType: String = behaviorTypes(rand.nextInt(behaviorTypes.size))
|
||||
// 获取随机渠道
|
||||
val channel: String = channelSet(rand.nextInt(channelSet.size))
|
||||
// 获取到当前的系统时间
|
||||
val ts: Long = System.currentTimeMillis()
|
||||
// 输出生成的用户行为的事件流
|
||||
ctx.collect(MarketingUserBehavior(id, behaviorType, channel, ts))
|
||||
// count + 1
|
||||
count += 1
|
||||
// 设置休眠的时间
|
||||
TimeUnit.MICROSECONDS.sleep(10L)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
override def cancel(): Unit = running = false
|
||||
}
|
||||
|
||||
def main(args: Array[String]): Unit = {
|
||||
|
||||
// 创建流处理的环境
|
||||
val env: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment
|
||||
// 设置并行度
|
||||
env.setParallelism(1)
|
||||
// 设置时间特征为事件时间
|
||||
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)
|
||||
|
||||
env.addSource(new SimulateEventSource()) // 添加数据源
|
||||
.assignAscendingTimestamps(_.timestamp) // 设置水印
|
||||
.filter(_.behavior != "UNINSTALL") // 过滤掉 卸载 的数据
|
||||
.map(data => {
|
||||
((data.channel, data.behavior), 1L)
|
||||
})
|
||||
.keyBy(_._1) //以渠道和行为作为key分组
|
||||
.timeWindow(Time.hours(1), Time.seconds(1)) // 设置滑动窗口,窗口大小为1h,滑动距离为1s
|
||||
.process(new MarketingCountByChannel) // 调用自定义处理方法
|
||||
.print() // 输出结果
|
||||
|
||||
// 执行程序
|
||||
env.execute("app marketing by channel job")
|
||||
|
||||
}
|
||||
|
||||
// 自定义处理函数
|
||||
class MarketingCountByChannel() extends ProcessWindowFunction[((String, String), Long), MarketingViewCount, (String, String), TimeWindow] {
|
||||
|
||||
override def process(key: (String, String), context: Context, elements: Iterable[((String, String), Long)], out: Collector[MarketingViewCount]): Unit = {
|
||||
|
||||
// 根据 context 对象分别获取到 Long 类型的 窗口的开始和结束时间
|
||||
//context.window.getStart是长整形 所以new 一个 变成String类型
|
||||
val startTs: String = new Timestamp(context.window.getStart).toString
|
||||
val endTs: String = new Timestamp(context.window.getEnd).toString
|
||||
|
||||
// 获取到 渠道
|
||||
val channel: String = key._1
|
||||
// 获取到 行为
|
||||
val behaviorType: String = key._2
|
||||
// 获取到 次数
|
||||
val count: Int = elements.size
|
||||
|
||||
// 输出结果
|
||||
out.collect(MarketingViewCount(startTs, endTs, channel, behaviorType, count))
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 运行效果
|
||||
|
||||

|
||||
|
||||
## 不分渠道(总量)统计
|
||||
同样我们还可以考察不分渠道的市场推广统计,这样得到的就是所有渠道推广的**总量**。在src/main/scala下创建`AppMarketingStatistics.scala`文件,代码如下:
|
||||
|
||||
```scala
|
||||
import java.sql.Timestamp
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
import org.apache.flink.streaming.api.TimeCharacteristic
|
||||
import org.apache.flink.streaming.api.functions.source.{RichParallelSourceFunction, SourceFunction}
|
||||
import org.apache.flink.streaming.api.scala.function.ProcessWindowFunction
|
||||
import org.apache.flink.streaming.api.scala.{StreamExecutionEnvironment, _}
|
||||
import org.apache.flink.streaming.api.windowing.time.Time
|
||||
import org.apache.flink.streaming.api.windowing.windows.TimeWindow
|
||||
import org.apache.flink.util.Collector
|
||||
|
||||
import scala.util.Random
|
||||
/*
|
||||
* @Author: Alice菌
|
||||
* @Date: 2020/12/10 22:45
|
||||
* @Description:
|
||||
电商用户行为数据分析: 市场营销商业指标统计分析
|
||||
APP市场推广统计 - - > 不分渠道(总量)统计
|
||||
*/
|
||||
object AppMarketingStatistics {
|
||||
|
||||
// 定义一个输入数据的样例类 保存电商用户行为的样例类
|
||||
case class MarketingUserBehavior(userId: String, behavior: String, channel: String, timestamp: Long)
|
||||
|
||||
// 定义一个输出结果的样例类 保存 市场用户点击次数
|
||||
case class MarketingViewCount(windowStart: String, windowEnd: String, count: Long)
|
||||
|
||||
def main(args: Array[String]): Unit = {
|
||||
|
||||
// 定义流处理环境
|
||||
val env: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment
|
||||
// 设置并行度
|
||||
env.setParallelism(1)
|
||||
// 设置时间特征为事件时间
|
||||
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)
|
||||
|
||||
env.addSource(new SimulateEventSource) // 添加数据源
|
||||
.assignAscendingTimestamps(_.timestamp)
|
||||
.filter(_.behavior != "UNINSTALL")
|
||||
.map(data => {
|
||||
("key",1L) // 因为这里我们不分渠道,所以我们就将key值固定,将所有数据放入到同一个组
|
||||
})
|
||||
.keyBy(_._1)
|
||||
.timeWindow(Time.hours(1),Time.seconds(1)) // 设置滑动窗口,窗口大小为1h,滑动距离为1s
|
||||
.process(new MarketingCountByChannel) // 调用自定义处理方法
|
||||
.print() // 输出结果
|
||||
|
||||
// 执行程序
|
||||
env.execute("app marketing by channel job")
|
||||
|
||||
}
|
||||
|
||||
|
||||
// 自定义数据源
|
||||
class SimulateEventSource extends RichParallelSourceFunction[MarketingUserBehavior] {
|
||||
|
||||
// 定义是否运行的标识符
|
||||
var running: Boolean = true
|
||||
// 定义渠道的集合
|
||||
val channelSet: Seq[String] = Seq("AppStore", "XiaomiStore", "HuaweiStore", "weibo", "wechat", "tieba")
|
||||
// 定义用户行为的集合
|
||||
val behaviorTypes: Seq[String] = Seq("BROWSE", "CLICK", "PURCHASE", "UNINSTALL")
|
||||
// 定义随机数发生器
|
||||
val rand: Random.type = Random
|
||||
|
||||
// 重写 run 方法
|
||||
override def run(ctx: SourceFunction.SourceContext[MarketingUserBehavior]): Unit = {
|
||||
|
||||
// 获取到 Long类型的最大值
|
||||
val maxElements: Long = Long.MaxValue
|
||||
// 设置初始值
|
||||
var count: Long = 0L
|
||||
|
||||
// 随机生成所有数据
|
||||
while (running && count < maxElements) {
|
||||
// 生成一个随机数
|
||||
val id: String = UUID.randomUUID().toString
|
||||
// 获取随机行为
|
||||
val behaviorType: String = behaviorTypes(rand.nextInt(behaviorTypes.size))
|
||||
// 获取随机渠道
|
||||
val channel: String = channelSet(rand.nextInt(channelSet.size))
|
||||
// 获取到当前的系统时间
|
||||
val ts: Long = System.currentTimeMillis()
|
||||
// 输出生成的用户行为的事件流
|
||||
ctx.collect(MarketingUserBehavior(id, behaviorType, channel, ts))
|
||||
// count + 1
|
||||
count += 1
|
||||
// 设置休眠的时间
|
||||
TimeUnit.MICROSECONDS.sleep(10L)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
override def cancel(): Unit = running = false
|
||||
}
|
||||
|
||||
|
||||
// 自定义处理函数
|
||||
class MarketingCountByChannel() extends ProcessWindowFunction[(String, Long), MarketingViewCount, String, TimeWindow] {
|
||||
|
||||
override def process(key: String, context: Context, elements: Iterable[(String, Long)], out: Collector[MarketingViewCount]): Unit = {
|
||||
|
||||
// 根据 context 对象分别获取到 Long 类型的 窗口的开始和结束时间
|
||||
//context.window.getStart是长整形 所以new 一个 变成String类型
|
||||
val startTs: String = new Timestamp(context.window.getStart).toString
|
||||
val endTs: String = new Timestamp(context.window.getEnd).toString
|
||||
|
||||
// 获取到 次数
|
||||
val count: Int = elements.size
|
||||
|
||||
// 输出结果
|
||||
out.collect(MarketingViewCount(startTs, endTs,count))
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 运行效果
|
||||
|
||||

|
||||
***
|
||||
## 小结
|
||||
本期关于介绍flink 电商用户行为数据分析之**APP市场推广统计**的文章就到这里,主要为大家介绍了在自定义数据源的基础上,如何分渠道和不分渠道计算APP市场推广的数据 。考虑到部分小伙伴对于中间的部分代码有疑问,所以我每行都写上了注释,因此详细的过程笔者就不在这里详细赘述了。看了注释仍有疑惑的小伙伴们欢迎添加我的个人微信询问,互相学习,共同进步!**你知道的越多,你不知道的也越多**,我是Alice,我们下一期见!
|
||||
|
||||
**受益的朋友记得三连支持小菌!**
|
||||
|
||||
|
||||
>**文章持续更新,可以微信搜一搜「 猿人菌 」第一时间阅读,思维导图,大数据书籍,大数据高频面试题,海量一线大厂面经…期待您的关注!**
|
||||
|
||||

|
||||
242
note/实战项目/基于 flink 的电商用户行为数据分析【7】页面广告分析.md
Normal file
242
note/实战项目/基于 flink 的电商用户行为数据分析【7】页面广告分析.md
Normal file
@@ -0,0 +1,242 @@
|
||||
本篇是flink 的「电商用户行为数据分析」的第 7 篇文章,为大家带来的是**市场营销商业指标统计分析**之**页面广告分析**的内容。通过本期内容,我们可以实现**页面广告点击量统计**和**黑名单过滤**的功能。
|
||||
|
||||

|
||||
|
||||
***
|
||||
|
||||
## 页面广告分析
|
||||
电商网站的市场营销商业指标中,除了自身的APP推广,还会考虑到页面上的广告投放(包括自己经营的产品和其它网站的广告)。所以**广告相关的统计分析,也是市场营销的重要指标**。
|
||||
|
||||
对于广告的统计,最简单也最重要的就是页面广告的点击量,<font color='Tomato'>**网站往往需要根据广告点击量来制定定价策略和调整推广方式,而且也可以借此收集用户的偏好信息**</font>。更加具体的应用是,我**们可以根据用户的地理位置进行划分,从而总结出不同省份用户对不同广告的偏好,这样更有助于广告的精准投放**。
|
||||
|
||||
|
||||
## 页面广告点击量统计
|
||||
接下来我们就进行页面广告按照省份划分的点击量的统计。在src/main/scala下创建`AdStatisticsByGeo.scala`文件。同样由于没有现成的数据,我们定义一些测试数据,放在AdClickLog.csv中,用来生成用户点击广告行为的事件流。
|
||||

|
||||
|
||||
|
||||
|
||||
在代码中我们首先定义源数据的样例类`AdClickLog`,以及输出统计数据的样例类`CountByProvince`。主函数中先以 province 进行 keyBy ,然后开一小时的时间窗口,滑动距离为5秒,统计窗口内的点击事件数量。具体代码实现如下:
|
||||
|
||||
```scala
|
||||
import java.sql.Timestamp
|
||||
|
||||
import org.apache.flink.streaming.api.TimeCharacteristic
|
||||
import org.apache.flink.streaming.api.scala._
|
||||
import org.apache.flink.streaming.api.scala.function.ProcessWindowFunction
|
||||
import org.apache.flink.streaming.api.windowing.time.Time
|
||||
import org.apache.flink.streaming.api.windowing.windows.TimeWindow
|
||||
import org.apache.flink.util.Collector
|
||||
/*
|
||||
* @Author: Alice菌
|
||||
* @Date: 2020/12/11 10:52
|
||||
* @Description:
|
||||
页面广告点击量统计 (开一小时的时间窗口,滑动距离为5秒)
|
||||
*/
|
||||
object AdStatisticsByGeo {
|
||||
|
||||
// 定义输入数据样例类
|
||||
case class AdClickEvent(userId:Long,adId:Long,province:String,city:String,timestamp:Long)
|
||||
// 定义输出数据样例类
|
||||
case class AdCountByProvince(province:String,windowEnd:String,count:Long)
|
||||
|
||||
def main(args: Array[String]): Unit = {
|
||||
|
||||
// 设置流处理的环境
|
||||
val env: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment
|
||||
// 设置程序的并行度
|
||||
env.setParallelism(1)
|
||||
// 设置时间特征为事件时间
|
||||
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)
|
||||
|
||||
env.readTextFile("YOUR_PATH\\AdClickLog.csv")
|
||||
.map(data => {
|
||||
// 样例数据:561558,3611281,guangdong,shenzhen,1511658120
|
||||
val dataArray: Array[String] = data.split(",")
|
||||
AdClickEvent(dataArray(0).toLong,dataArray(1).toLong,dataArray(2),dataArray(3),dataArray(4).toLong)
|
||||
})
|
||||
.assignAscendingTimestamps(_.timestamp * 1000L) // 添加水印
|
||||
.keyBy(_.province) // 按照 province 分组
|
||||
.timeWindow(Time.hours(1),Time.seconds(5)) // 设置窗口的大小为1h,滑动距离为5s
|
||||
.process(new AdCount) // 开窗聚合统计
|
||||
.print() // 输 出 结 果
|
||||
|
||||
// 执行程序
|
||||
env.execute("ad analysis job")
|
||||
|
||||
}
|
||||
|
||||
class AdCount() extends ProcessWindowFunction[AdClickEvent,AdCountByProvince,String,TimeWindow]{
|
||||
|
||||
override def process(key: String, context: Context, elements: Iterable[AdClickEvent], out: Collector[AdCountByProvince]): Unit = {
|
||||
|
||||
// 因为我们是按照 province 进行分组
|
||||
// 所以这里直接根据 key 就能获取到 province
|
||||
val province: String = key
|
||||
// 将 窗口结束的时间戳 转换为 String 时间字符串
|
||||
val windowEnd: String = new Timestamp(context.window.getEnd).toString
|
||||
// 获取窗口元素的个数
|
||||
val count: Int = elements.size
|
||||
// 输出元素
|
||||
out.collect(AdCountByProvince(province,windowEnd,count))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### 运行结果
|
||||

|
||||
|
||||
|
||||
## 黑名单过滤
|
||||
上节我们进行的点击量统计,**同一用户的重复点击是会叠加计算的**。在实际场景中,同一用户确实可能反复点开同一个广告,这也说明了用户对广告更大的兴趣;**但是如果用户在一段时间非常频繁地点击广告,这显然不是一个正常行为,有刷点击量的嫌疑**。所以我们可以对一段时间内(比如一天内)的用户点击行为进行**约束**,**如果对同一个广告点击超过一定限额(比如100次),应该把该用户加入黑名单并报警,此后其点击行为不应该再统计**。
|
||||
|
||||
|
||||
具体代码实现如下:
|
||||
|
||||
|
||||
```scala
|
||||
import java.sql.Timestamp
|
||||
|
||||
import org.apache.flink.api.common.functions.AggregateFunction
|
||||
import org.apache.flink.api.common.state.{ValueState, ValueStateDescriptor}
|
||||
import org.apache.flink.streaming.api.TimeCharacteristic
|
||||
import org.apache.flink.streaming.api.functions.KeyedProcessFunction
|
||||
import org.apache.flink.streaming.api.scala._
|
||||
import org.apache.flink.streaming.api.scala.function.WindowFunction
|
||||
import org.apache.flink.streaming.api.windowing.time.Time
|
||||
import org.apache.flink.streaming.api.windowing.windows.TimeWindow
|
||||
import org.apache.flink.util.Collector
|
||||
/*
|
||||
* @Author: Alice菌
|
||||
* @Date: 2020/12/11 11:37
|
||||
* @Description:
|
||||
黑名单过滤
|
||||
*/
|
||||
object AdAnalysisByProvinceBlack {
|
||||
|
||||
// 定义输入输出样例类
|
||||
case class AdClickEvent(userId:Long,adId:Long,province:String,city:String,timestamp:Long)
|
||||
case class AdCountByProvince(province:String,windowEnd:String,count:Long)
|
||||
|
||||
//定义侧输出流报警信息样例类
|
||||
case class BlackListWarning(userId:Long,adId:Long,msg:String)
|
||||
|
||||
def main(args: Array[String]): Unit = {
|
||||
|
||||
// 定义流处理环境
|
||||
val env: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment
|
||||
// 设置并行度
|
||||
env.setParallelism(1)
|
||||
// 设置时间特征为事件时间
|
||||
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)
|
||||
|
||||
val adLogStream: DataStream[AdClickEvent] = env.readTextFile("YOUR_PATH\\AdClickLog.csv")
|
||||
.map(data => {
|
||||
// 样例数据:561558,3611281,guangdong,shenzhen,1511658120
|
||||
val dataArray: Array[String] = data.split(",")
|
||||
AdClickEvent(dataArray(0).toLong, dataArray(1).toLong, dataArray(2), dataArray(3), dataArray(4).toLong)
|
||||
})
|
||||
.assignAscendingTimestamps(_.timestamp * 1000L ) // 设置水印
|
||||
|
||||
//定义刷单行为 过滤操作
|
||||
val filterBlackListStream: DataStream[AdClickEvent] = adLogStream // 设置水印
|
||||
.keyBy(data =>(data.userId, data.adId)) // 按照用户 和 广告id进行分组)
|
||||
.process(new FilterBlackList(100L))
|
||||
|
||||
// 按照 province分组开窗聚合统计
|
||||
val adCountStream: DataStream[AdCountByProvince] = filterBlackListStream
|
||||
.keyBy(_.province)
|
||||
.timeWindow(Time.hours(1), Time.seconds(5)) // 设置窗口大小为1h , 滑动距离为5s
|
||||
.aggregate(new AdCountAgg(), new AdCountResult())
|
||||
|
||||
// 打印结果
|
||||
adCountStream.print()
|
||||
// 打印测输出流的数据
|
||||
filterBlackListStream.getSideOutput(new OutputTag[BlackListWarning]("blacklist")).print("blacklist")
|
||||
|
||||
// 执行程序
|
||||
env.execute("as analysis job")
|
||||
|
||||
}
|
||||
|
||||
// 实现自定义 ProcessFunction
|
||||
class FilterBlackList(maxClickCount:Long) extends KeyedProcessFunction[(Long,Long),AdClickEvent,AdClickEvent]{
|
||||
|
||||
// 定义一个状态,需要保存当前用户对当前广告的点击量 count
|
||||
lazy val countState:ValueState[Long] = getRuntimeContext.getState(new ValueStateDescriptor[Long]("count",classOf[Long]))
|
||||
// 定义一个标识位,用来表示用户是否已经在黑名单中
|
||||
lazy val isSendState:ValueState[Boolean] = getRuntimeContext.getState(new ValueStateDescriptor[Boolean]("is-sent",classOf[Boolean]))
|
||||
|
||||
override def processElement(value: AdClickEvent, ctx: KeyedProcessFunction[(Long, Long), AdClickEvent, AdClickEvent]#Context, out: Collector[AdClickEvent]): Unit = {
|
||||
// 取出状态数据
|
||||
val curCount: Long = countState.value()
|
||||
|
||||
// 如果是第一个数据,那么注册第二天0点的定时器,用于清空状态
|
||||
if (curCount == 0){
|
||||
val ts: Long = (ctx.timerService().currentProcessingTime() / (1000*60*60*24) + 1) * (1000*60*60*24)
|
||||
ctx.timerService().registerProcessingTimeTimer(ts)
|
||||
}
|
||||
// 判断 count 值是否达到上限,如果达到,并且之前没有输出过报警信息,那么则报警
|
||||
if (curCount > maxClickCount){
|
||||
if (!isSendState.value()){
|
||||
// 侧输出数据
|
||||
ctx.output(new OutputTag[BlackListWarning]("blacklist"),BlackListWarning(value.userId,value.adId,"click over"+maxClickCount+"times today"))
|
||||
// 更新黑名单状态
|
||||
isSendState.update(true)
|
||||
}
|
||||
// 如果达到上限,则不再进行后续的操作,即此后其点击行为不应该再统计
|
||||
return
|
||||
}
|
||||
|
||||
// count 值 + 1
|
||||
countState.update(curCount + 1)
|
||||
// 输出数据
|
||||
out.collect(value)
|
||||
|
||||
}
|
||||
|
||||
// 0 点触发定时器,直接清空状态
|
||||
override def onTimer(timestamp: Long, ctx: KeyedProcessFunction[(Long, Long), AdClickEvent, AdClickEvent]#OnTimerContext, out: Collector[AdClickEvent]): Unit = {
|
||||
countState.clear()
|
||||
isSendState.clear()
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义预聚合函数
|
||||
class AdCountAgg() extends AggregateFunction[AdClickEvent,Long,Long]{
|
||||
override def createAccumulator(): Long = 0L
|
||||
|
||||
override def add(value: AdClickEvent, accumulator: Long): Long = accumulator + 1
|
||||
|
||||
override def getResult(accumulator: Long): Long = accumulator
|
||||
|
||||
override def merge(a: Long, b: Long): Long = a + b
|
||||
}
|
||||
|
||||
// 自定义窗口函数,第一个参数就是预聚合函数最后输出的值,Long
|
||||
class AdCountResult() extends WindowFunction[Long,AdCountByProvince,String,TimeWindow]{
|
||||
|
||||
override def apply(key: String, window: TimeWindow, input: Iterable[Long], out: Collector[AdCountByProvince]): Unit = {
|
||||
|
||||
out.collect(AdCountByProvince(key,new Timestamp(window.getEnd).toString,input.head))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
```
|
||||
### 运行结果
|
||||

|
||||
***
|
||||
## 小结
|
||||
|
||||
本期关于介绍**flink 电商用户行为数据分析**之**页面广告分析**的文章就到这里,考虑到部分小伙伴对于中间的部分代码有疑问,所以我每行都写上了注释,因此详细的过程笔者就不在这里详细赘述了。看了注释仍有疑惑的小伙伴们欢迎添加我的个人微信询问,**互相学习,共同进步**!**你知道的越多,你不知道的也越多**,我是Alice,我们下一期见!
|
||||
|
||||
**受益的朋友记得三连支持小菌!**
|
||||
|
||||
|
||||
>**文章持续更新,可以微信搜一搜「 猿人菌 」第一时间阅读,思维导图,大数据书籍,大数据高频面试题,海量一线大厂面经…期待您的关注!**
|
||||
|
||||

|
||||
|
||||
517
note/实战项目/基于 flink 的电商用户行为数据分析【8】订单支付实时监控.md
Normal file
517
note/实战项目/基于 flink 的电商用户行为数据分析【8】订单支付实时监控.md
Normal file
@@ -0,0 +1,517 @@
|
||||
本篇是flink 的「电商用户行为数据分析」的第 8 篇文章,为大家带来的是**市场营销商业指标统计分析**之**订单支付实时监控**的内容!通过本期内容,我们可以实现通过使用**CEP**和**Process Function**来实现`订单支付实时监控`的功能,还能学会通过**connect** 和 **join**来实现`flink双流join`的功能,可谓干货满满!受益的朋友记得三连支持一下 ~
|
||||
|
||||

|
||||
***
|
||||
|
||||
## 订单支付实时监控
|
||||
在电商网站中,**订单的支付作为直接与营销收入挂钩的一环,在业务流程中非常重要**。对于订单而言,**为了正确控制业务流程,也为了增加用户的支付意愿,网站一般会设置一个支付失效时间,超过一段时间不支付的订单就会被取消**。另外,**对于订单的支付,我们还应保证用户支付的正确性,这可以通过第三方支付平台的交易数据来做一个实时对账**。在接下来的内容中,我们将实现这两个需求。
|
||||
|
||||
|
||||
## 模块创建和数据准备
|
||||
同样地,在UserBehaviorAnalysis下新建一个 maven module作为子项目,命名为`OrderTimeoutDetect`。在这个子模块中,我们同样将会用到 **flink** 的 **CEP** 库来实现事件流的模式匹配,所以需要在pom文件中引入CEP的相关依赖:
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>org.apache.flink</groupId>
|
||||
<artifactId>flink-cep-scala_${scala.binary.version}</artifactId>
|
||||
<version>${flink.version}</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
同样,在src/main/目录下,将默认源文件目录java改名为scala。
|
||||
|
||||
|
||||
## 代码实现
|
||||
在电商平台中,**最终创造收入和利润的是用户下单购买的环节**;更具体一点,是用户真正完成支付动作的时候。**用户下单的行为可以表明用户对商品的需求,但在现实中,并不是每次下单都会被用户立刻支付**。**当拖延一段时间后,用户支付的意愿会降低。所以为了让用户更有紧迫感从而提高支付转化率,同时也为了防范订单支付环节的安全风险,电商网站往往会对订单状态进行监控,设置一个失效时间(比如15分钟),如果下单后一段时间仍未支付,订单就会被取消**。
|
||||
|
||||
|
||||
### 使用CEP实现
|
||||
|
||||
我们首先还是利用**CEP**库来实现这个功能。我们先将事件流按照订单号**orderId**分流,然后定义这样的一个**事件模式**:在15分钟内,事件“create”与“pay”非严格紧邻:
|
||||
|
||||
```scala
|
||||
// 1、 定义一个匹配事件序列的模式
|
||||
val orderPayPattern = Pattern
|
||||
.begin[OrderEvent]("create").where(_.eventType == "create") // 首先是订单的 create 事件
|
||||
.followedBy("pay").where(_.eventType == "pay") // 后面来的是订单的 pay 事件
|
||||
.within(Time.minutes(15)) // 间隔 15 分钟
|
||||
```
|
||||
这样调用.select方法时,就可以同时获取**到匹配出的事件**和**超时未匹配的事件**了。
|
||||
|
||||
在src/main/scala下继续创建`OrderTimeout.scala`文件,新建一个单例对象。定义样例类**OrderEvent**,这是输入的订单事件流;另外还有**OrderResult**,这是输出显示的订单状态结果。订单数据也本应该从UserBehavior日志里提取,由于`UserBehavior.csv`中没有做相关埋点,我们从另一个文件`OrderLog.csv`中读取登录数据。
|
||||
|
||||

|
||||
**完整代码如下:**
|
||||
|
||||
```scala
|
||||
import java.util
|
||||
|
||||
import org.apache.flink.cep.scala.pattern.Pattern
|
||||
import org.apache.flink.cep.scala.{CEP, PatternStream}
|
||||
import org.apache.flink.cep.{PatternSelectFunction, PatternTimeoutFunction}
|
||||
import org.apache.flink.streaming.api.TimeCharacteristic
|
||||
import org.apache.flink.streaming.api.functions.timestamps.BoundedOutOfOrdernessTimestampExtractor
|
||||
import org.apache.flink.streaming.api.scala.{StreamExecutionEnvironment, _}
|
||||
import org.apache.flink.streaming.api.windowing.time.Time
|
||||
/*
|
||||
* @Author: Alice菌
|
||||
* @Date: 2020/12/13 15:46
|
||||
* @Description:
|
||||
|
||||
*/
|
||||
object OrderTimeoutWithOutCep {
|
||||
|
||||
// 定义输入的订单事件样例类
|
||||
case class OrderEvent(orderId:Long,eventType:String,eventTime:Long)
|
||||
// 定义输出的订单检测结果样例类
|
||||
case class OrderResult(orderId:Long,resultMsg:String)
|
||||
|
||||
def main(args: Array[String]): Unit = {
|
||||
|
||||
// 定义流处理环境
|
||||
val env: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment
|
||||
// 设置程序并行度
|
||||
env.setParallelism(1)
|
||||
// 设置时间特征为事件时间
|
||||
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)
|
||||
// 从文件中读取数据,并转换成样例类
|
||||
val orderEventStream: DataStream[OrderEvent] = env.readTextFile("YOUR_PATH\\OrderLog.csv")
|
||||
.map(data => {
|
||||
// 样例数据: 34729,pay,sd76f87d6,1558430844
|
||||
val dataArray: Array[String] = data.split(",")
|
||||
OrderEvent(dataArray(0).toLong, dataArray(1), dataArray(3).toLong)
|
||||
}) // 处理数据
|
||||
.assignTimestampsAndWatermarks(new BoundedOutOfOrdernessTimestampExtractor[OrderEvent](Time.seconds(3)) {
|
||||
override def extractTimestamp(element: OrderEvent): Long = element.eventTime * 1000L
|
||||
}) // 设置时间戳
|
||||
|
||||
// 1、 定义一个匹配事件序列的模式
|
||||
val orderPayPattern = Pattern
|
||||
.begin[OrderEvent]("create").where(_.eventType == "create") // 首先是订单的 create 事件
|
||||
.followedBy("pay").where(_.eventType == "pay") // 后面来的是订单的 pay 事件
|
||||
.within(Time.minutes(15)) // 间隔 15 分钟
|
||||
|
||||
// 2、 将 pattern 应用在按照 orderId分组的数据流上
|
||||
val patterStream: PatternStream[OrderEvent] = CEP.pattern(orderEventStream.keyBy(_.orderId), orderPayPattern)
|
||||
|
||||
// 3、定义一个侧输出流标签,用来标明超时事件的侧输出流
|
||||
val orderTimeOutputTag: OutputTag[OrderResult] = new OutputTag[OrderResult]("order time out")
|
||||
|
||||
// 4、调用select方法,提取匹配事件和超时事件,分别进行处理转换输出
|
||||
val result: DataStream[OrderResult] = patterStream
|
||||
.select(orderTimeOutputTag, new OrderTimeOutSelect(), new OrderPaySelect())
|
||||
|
||||
// 5、打印输出
|
||||
result.print("payed")
|
||||
result.getSideOutput(orderTimeOutputTag).print("timeout")
|
||||
|
||||
// 执行程序
|
||||
env.execute("order timeout detect job")
|
||||
|
||||
}
|
||||
|
||||
// 自定义超时处理函数
|
||||
class OrderTimeOutSelect() extends PatternTimeoutFunction[OrderEvent,OrderResult]{
|
||||
override def timeout(pattern: util.Map[String, util.List[OrderEvent]], timeoutTimestamp: Long): OrderResult = {
|
||||
val timeOutOrderId: Long = pattern.get("create").iterator().next().orderId
|
||||
OrderResult(timeOutOrderId,"timeout at" + timeoutTimestamp)
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义匹配处理函数
|
||||
class OrderPaySelect() extends PatternSelectFunction[OrderEvent,OrderResult]{
|
||||
override def select(pattern: util.Map[String, util.List[OrderEvent]]): OrderResult = {
|
||||
|
||||
val payedOrderId: Long = pattern.get("pay").get(0).orderId
|
||||
OrderResult(payedOrderId,"pay successfully")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**运行结果:**
|
||||
|
||||

|
||||
### 使用Process Function实现
|
||||
我们同样可以利用Process Function,自定义实现检测订单超时的功能。为了简化问题,我们只考虑超时报警的情形,在pay事件超时未发生的情况下,输出超时报警信息。
|
||||
|
||||
一个简单的思路是,可以在订单的 create 事件到来后注册定时器,15分钟后触发;然后再用一个布尔类型的Value状态来作为标识位,表明pay事件是否发生过。如果pay事件已经发生,状态被置为true,那么就不再需要做什么操作;而如果pay事件一直没来,状态一直为false,到定时器触发时,就应该输出超时报警信息。
|
||||
|
||||
具体代码实现如下:
|
||||
|
||||
```scala
|
||||
import org.apache.flink.api.common.state.{ValueState, ValueStateDescriptor}
|
||||
import org.apache.flink.streaming.api.TimeCharacteristic
|
||||
import org.apache.flink.streaming.api.functions.KeyedProcessFunction
|
||||
import org.apache.flink.streaming.api.functions.timestamps.BoundedOutOfOrdernessTimestampExtractor
|
||||
import org.apache.flink.streaming.api.scala._
|
||||
import org.apache.flink.streaming.api.windowing.time.Time
|
||||
import org.apache.flink.util.Collector
|
||||
|
||||
|
||||
/*
|
||||
* @Author: Alice菌
|
||||
* @Date: 2020/12/23 19:35
|
||||
* @Description:
|
||||
|
||||
*/
|
||||
object OrderTimeout {
|
||||
|
||||
// 定义输入的订单事件样例类
|
||||
case class OrderEvent(orderId: Long, eventType: String, eventTime: Long)
|
||||
|
||||
// 定义输出的订单检测结果样例类
|
||||
case class OrderResult(orderId: Long, resultMsg: String)
|
||||
|
||||
def main(args: Array[String]): Unit = {
|
||||
// 定义流处理环境
|
||||
val env: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment
|
||||
// 设置程序并行度
|
||||
env.setParallelism(1)
|
||||
// 设置时间特征为事件时间
|
||||
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)
|
||||
// 读取输入的订单数据流
|
||||
val orderEventStream: DataStream[OrderEvent] = env.readTextFile("YOUR_PATH\\OrderLog.csv")
|
||||
.map(data => {
|
||||
// 示例数据: 34729,pay,sd76f87d6,1558430844
|
||||
val dataArray: Array[String] = data.split(",")
|
||||
OrderEvent(dataArray(0).toLong, dataArray(1), dataArray(3).toLong)
|
||||
})
|
||||
// 设置水印
|
||||
.assignTimestampsAndWatermarks(new BoundedOutOfOrdernessTimestampExtractor[OrderEvent](Time.seconds(3)) {
|
||||
override def extractTimestamp(element: OrderEvent): Long = element.eventTime * 1000L
|
||||
})
|
||||
|
||||
// 自定义 Process Function,做精细化的流程控制
|
||||
val orderResultStream: DataStream[OrderResult] = orderEventStream
|
||||
.keyBy(_.orderId)
|
||||
.process(new OrderPayMatchDetect())
|
||||
|
||||
// 打印输出
|
||||
orderResultStream.print("payed")
|
||||
orderResultStream.getSideOutput(new OutputTag[OrderResult]("timeout")).print("timeout")
|
||||
|
||||
// 执行程序
|
||||
env.execute("order timeout without cep job")
|
||||
}
|
||||
|
||||
class OrderPayMatchDetect() extends KeyedProcessFunction[Long,OrderEvent,OrderResult]{
|
||||
// 定义状态,用来保存是否来过 create 和 pay 事件的标识位,以及定时器的时间戳
|
||||
lazy val isPayState:ValueState[Boolean] = getRuntimeContext.getState(new ValueStateDescriptor[Boolean]("is-payed", classOf[Boolean]))
|
||||
lazy val isCreateState:ValueState[Boolean] = getRuntimeContext.getState(new ValueStateDescriptor[Boolean]("is-created", classOf[Boolean]))
|
||||
|
||||
// 定义一个状态,保存每次定时器的时间戳
|
||||
lazy val timerTsState:ValueState[Long] = getRuntimeContext.getState(new ValueStateDescriptor[Long]("timer-ts", classOf[Long]))
|
||||
|
||||
// 定义一个侧输出流
|
||||
val orderTimeOutputTag = new OutputTag[OrderResult]("timeout")
|
||||
|
||||
override def processElement(value: OrderEvent, ctx: KeyedProcessFunction[Long, OrderEvent, OrderResult]#Context, out: Collector[OrderResult]): Unit = {
|
||||
|
||||
// 取出当前的状态
|
||||
val isPayed: Boolean = isPayState.value()
|
||||
val isCreated: Boolean = isCreateState.value()
|
||||
val timeTs: Long = timerTsState.value()
|
||||
|
||||
// 判断当前事件的类型,分成不同的情况讨论:
|
||||
// 情况1: 来的是 create,要继续判断之前是否有 pay 来过
|
||||
if (value.eventType == "create"){
|
||||
// 情况 1.1 : 如果已经pay过,匹配成功,输出到主流,清空状态
|
||||
if (isPayed){
|
||||
out.collect(OrderResult(value.orderId,"payed successfully"))
|
||||
// 清除状态
|
||||
isPayState.clear()
|
||||
timerTsState.clear()
|
||||
// 删除定时器
|
||||
ctx.timerService().deleteEventTimeTimer(timeTs)
|
||||
}
|
||||
// 情况 1.2:如果没有pay过,那么就注册一个15分钟后的定时器,开始等待
|
||||
else{
|
||||
val ts: Long = value.eventTime * 1000L + 15 * 60 *1000L
|
||||
// 设置一个15分钟的定时器
|
||||
ctx.timerService().registerEventTimeTimer(ts)
|
||||
|
||||
timerTsState.update(ts)
|
||||
isCreateState.update(true)
|
||||
}
|
||||
}
|
||||
|
||||
// 情况2:来的是pay,要继续判断是否来过 create
|
||||
else if (value.eventType == "pay"){
|
||||
// 情况2.1 : 如果 create 已经来过,匹配成功,要继续判断间隔时间是否超过了15分钟
|
||||
if (isCreated){
|
||||
// 情况 2.1.1:如果没有超时,正常输出结果到主流
|
||||
if (value.eventTime * 1000L < timeTs){
|
||||
out.collect(OrderResult(value.orderId,"payed successfully"))
|
||||
}else{
|
||||
// 情况2.1.2: 如果已经超时,那么输出 timeout 报警到侧输出流
|
||||
ctx.output(orderTimeOutputTag,OrderResult(value.orderId,"payed but already timeout"))
|
||||
}
|
||||
// 无论哪种情况,都已经有了输出,清空状态
|
||||
isCreateState.clear()
|
||||
timerTsState.clear()
|
||||
ctx.timerService().deleteEventTimeTimer(timeTs)
|
||||
}
|
||||
// 情况2.2 :如果 create 没来,需要等待乱序 create,注册一个当前pay时间戳的定时器
|
||||
else{
|
||||
val ts: Long = value.eventTime * 1000L
|
||||
// 设置定时器
|
||||
ctx.timerService().registerEventTimeTimer(ts)
|
||||
// 更新状态
|
||||
timerTsState.update(ts)
|
||||
isPayState.update(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override def onTimer(timestamp: Long, ctx: KeyedProcessFunction[Long, OrderEvent, OrderResult]#OnTimerContext, out: Collector[OrderResult]): Unit = {
|
||||
// 定时器触发,需要判断是哪种情况
|
||||
if (isPayState.value()){
|
||||
// 如果 pay 过,那么说明 create没来,可能出现了数据丢失异常的情况
|
||||
ctx.output(orderTimeOutputTag,OrderResult(ctx.getCurrentKey,"already payed but not found created log"))
|
||||
}else{
|
||||
// 如果 没有 pay过,那么说明真正 15 分钟 超时 [提交了订单,但是超过了15分钟仍未支付]
|
||||
ctx.output(orderTimeOutputTag,OrderResult(ctx.getCurrentKey,"order timeout"))
|
||||
}
|
||||
|
||||
// 清空状态
|
||||
isPayState.clear()
|
||||
isCreateState.clear()
|
||||
timerTsState.clear()
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**运行结果:**
|
||||
|
||||
|
||||

|
||||
|
||||
## 来自两条流的订单交易匹配
|
||||
对于订单支付事件,用户支付完成其实并不算完,我们还得确认平台账户上是否到账了。而往往这会来自不同的日志信息,所以我们要同时读入两条流的数据来做合并处理。这里我们利用`connect`将两条流进行连接,然后用自定义的**CoProcessFunction**进行处理。
|
||||
|
||||
具体代码如下:
|
||||
|
||||
```scala
|
||||
import org.apache.flink.api.common.state.{ValueState, ValueStateDescriptor}
|
||||
import org.apache.flink.streaming.api.TimeCharacteristic
|
||||
import org.apache.flink.streaming.api.functions.co.CoProcessFunction
|
||||
import org.apache.flink.streaming.api.functions.timestamps.BoundedOutOfOrdernessTimestampExtractor
|
||||
import org.apache.flink.streaming.api.scala._
|
||||
import org.apache.flink.streaming.api.windowing.time.Time
|
||||
import org.apache.flink.util.Collector
|
||||
|
||||
/*
|
||||
* @Author: Alice菌
|
||||
* @Date: 2020/12/13 15:57
|
||||
* @Description:
|
||||
来自两条流的订单交易匹配 ( connect 实现 )
|
||||
*/
|
||||
object OrderPayTxMatch {
|
||||
|
||||
// 输入输出的样例类
|
||||
case class ReceiptEvent(txId:String, payChannel:String, timestamp:Long)
|
||||
case class OrderEvent(orderId:Long, eventType:String, txId:String, eventTime:Long)
|
||||
|
||||
def main(args: Array[String]): Unit = {
|
||||
// 创建流处理的环境
|
||||
val env: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment
|
||||
// 设置程序并行度
|
||||
env.setParallelism(1)
|
||||
// 设置时间特征为事件时间
|
||||
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)
|
||||
|
||||
// 从 OrderLog.csv 文件中读取数据 ,并转换成样例类
|
||||
val orderEventStream: KeyedStream[OrderEvent, String] = env.readTextFile("G:\\idea arc\\BIGDATA\\project\\src\\main\\resources\\OrderLog.csv")
|
||||
.map(data => {
|
||||
// 样例数据 : 34731,pay,35jue34we,1558430849
|
||||
val dataArray: Array[String] = data.split(",")
|
||||
OrderEvent(dataArray(0).toLong,dataArray(1),dataArray(2),dataArray(3).toLong)
|
||||
})
|
||||
.assignTimestampsAndWatermarks(new BoundedOutOfOrdernessTimestampExtractor[OrderEvent](Time.seconds(3)) {
|
||||
override def extractTimestamp(element: OrderEvent): Long = element.eventTime * 1000L
|
||||
}) // 为数据流中的元素分配时间戳
|
||||
.filter(_.eventType != "") // 只过滤出pay事件
|
||||
.keyBy(_.txId) // 根据 订单id 分组
|
||||
|
||||
// 从 ReceiptLog.csv 文件中读取数据 ,并转换成样例类
|
||||
val receiptStream: KeyedStream[ReceiptEvent, String] = env.readTextFile("YOUR_PATH\\ReceiptLog.csv")
|
||||
.map(data => {
|
||||
// 样例数据: 3hu3k2432,alipay,1558430848
|
||||
val dataArray: Array[String] = data.split(",")
|
||||
ReceiptEvent(dataArray(0), dataArray(1), dataArray(2).toLong)
|
||||
})
|
||||
.assignAscendingTimestamps(_.timestamp * 1000L) // 设置水印
|
||||
.keyBy(_.txId) // 根据 txId 进行分组
|
||||
|
||||
// connect 连接两条流,匹配事件进行处理
|
||||
val resultStream: DataStream[(OrderEvent, ReceiptEvent)] = orderEventStream.connect(receiptStream)
|
||||
.process(new OrderPayTxDetect())
|
||||
|
||||
// 定义侧输出流
|
||||
val unmatchedPays: OutputTag[OrderEvent] = new OutputTag[OrderEvent]("unmatched-pays")
|
||||
val unmatchedReceipts: OutputTag[ReceiptEvent] = new OutputTag[ReceiptEvent]("unmatched-receipts")
|
||||
|
||||
// 打印输出
|
||||
resultStream.print("matched")
|
||||
resultStream.getSideOutput(unmatchedPays).print("unmatched-pays")
|
||||
resultStream.getSideOutput(unmatchedReceipts).print("unmatched-receipts")
|
||||
env.execute("order pay tx match job")
|
||||
|
||||
}
|
||||
|
||||
// 定义 CoProcessFunction,实现两条流数据的匹配检测
|
||||
class OrderPayTxDetect() extends CoProcessFunction[OrderEvent,ReceiptEvent,(OrderEvent,ReceiptEvent)]{
|
||||
|
||||
// 定义两个 ValueState,保存当前交易对应的支付事件和到账事件
|
||||
lazy val payState: ValueState[OrderEvent] = getRuntimeContext.getState(new ValueStateDescriptor[OrderEvent]("pay", classOf[OrderEvent]))
|
||||
lazy val receiptState: ValueState[ReceiptEvent] = getRuntimeContext.getState(new ValueStateDescriptor[ReceiptEvent]("receipt", classOf[ReceiptEvent]))
|
||||
|
||||
//定义侧输出流
|
||||
val unmatchedPays: OutputTag[OrderEvent] = new OutputTag[OrderEvent]("unmatched-pays")
|
||||
val unmatchedReceipts: OutputTag[ReceiptEvent] = new OutputTag[ReceiptEvent]("unmatched-receipts")
|
||||
|
||||
override def processElement1(pay: OrderEvent, ctx: CoProcessFunction[OrderEvent, ReceiptEvent, (OrderEvent, ReceiptEvent)]#Context, out: Collector[(OrderEvent, ReceiptEvent)]): Unit = {
|
||||
// pay 来了,考察有没有对应的 receipt 来过
|
||||
val receipt: ReceiptEvent = receiptState.value()
|
||||
if (receipt != null){
|
||||
// 如果已经有 receipt,正常输出到主流
|
||||
out.collect((pay,receipt))
|
||||
receiptState.clear()
|
||||
}else{
|
||||
// 如果 receipt 还没来,那么把 pay 存入状态,注册一个定时器等待 5 秒
|
||||
payState.update(pay)
|
||||
ctx.timerService().registerEventTimeTimer(pay.eventTime * 1000L + 5000L)
|
||||
}
|
||||
}
|
||||
|
||||
override def processElement2(receipt: ReceiptEvent, ctx: CoProcessFunction[OrderEvent, ReceiptEvent, (OrderEvent, ReceiptEvent)]#Context, out: Collector[(OrderEvent, ReceiptEvent)]): Unit = {
|
||||
//receipt来了,考察有没有对应的pay来过
|
||||
val pay: OrderEvent = payState.value()
|
||||
if (pay != null) {
|
||||
//如果已经有pay,那么正常匹配,输出到主流
|
||||
out.collect((pay, receipt))
|
||||
payState.clear()
|
||||
}else{
|
||||
// 如果 pay 还没来,那么把 receipt 存入状态,注册一个定时器等待 3 秒
|
||||
receiptState.update(receipt)
|
||||
ctx.timerService().registerEventTimeTimer(receipt.timestamp * 1000L + 3000L)
|
||||
}
|
||||
}
|
||||
|
||||
// 定时触发, 有两种情况,所以要判断当前有没有pay和receipt
|
||||
override def onTimer(timestamp: Long, ctx: CoProcessFunction[OrderEvent, ReceiptEvent, (OrderEvent, ReceiptEvent)]
|
||||
#OnTimerContext, out: Collector[(OrderEvent, ReceiptEvent)]): Unit = {
|
||||
|
||||
//如果 pay 不为空,说明receipt没来,输出unmatchedPays
|
||||
if (payState.value() != null){
|
||||
ctx.output(unmatchedPays,payState.value())
|
||||
}
|
||||
|
||||
if (receiptState.value() != null){
|
||||
ctx.output(unmatchedReceipts,receiptState.value())
|
||||
}
|
||||
|
||||
// 清除状态
|
||||
payState.clear()
|
||||
receiptState.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**运行结果:**
|
||||

|
||||
对于flink的双流join通过`connect`的做法,肯定会有小伙伴觉得过程比较冗复杂,那还有没有其他的方法也能实现类似的效果呢?
|
||||

|
||||
当然是有的,下面就为大家展示另一种通过`intervalJoin`方法实现的方式:
|
||||
|
||||
```scala
|
||||
import org.apache.flink.streaming.api.TimeCharacteristic
|
||||
import org.apache.flink.streaming.api.functions.co.ProcessJoinFunction
|
||||
import org.apache.flink.streaming.api.functions.timestamps.BoundedOutOfOrdernessTimestampExtractor
|
||||
import org.apache.flink.streaming.api.scala._
|
||||
import org.apache.flink.streaming.api.windowing.time.Time
|
||||
import org.apache.flink.util.Collector
|
||||
|
||||
/*
|
||||
* @Author: Alice菌
|
||||
* @Date: 2020/12/12 20:23
|
||||
* @Description:
|
||||
来自两条流的订单交易匹配 ( JOIN 实现 )
|
||||
*/
|
||||
object OrderPayTxMatchWithJoin {
|
||||
|
||||
// 输入输出的样例类
|
||||
case class ReceiptEvent(txId:String, payChannel:String, timestamp:Long)
|
||||
case class OrderEvent(orderId:Long, eventType:String, txId:String, eventTime:Long)
|
||||
|
||||
def main(args: Array[String]): Unit = {
|
||||
// 创建流处理的环境
|
||||
val env: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment
|
||||
// 设置程序并行度
|
||||
env.setParallelism(1)
|
||||
// 设置时间特征为事件时间
|
||||
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)
|
||||
|
||||
// 从 OrderLog.csv 文件中读取数据 ,并转换成样例类
|
||||
val orderEventStream: KeyedStream[OrderEvent, String] = env.readTextFile("YOUR_PATH\\OrderLog.csv")
|
||||
.map(data => {
|
||||
// 样例数据 : 34731,pay,35jue34we,1558430849
|
||||
val dataArray: Array[String] = data.split(",")
|
||||
OrderEvent(dataArray(0).toLong,dataArray(1),dataArray(2),dataArray(3).toLong)
|
||||
})
|
||||
.assignTimestampsAndWatermarks(new BoundedOutOfOrdernessTimestampExtractor[OrderEvent](Time.seconds(3)) {
|
||||
override def extractTimestamp(element: OrderEvent): Long = element.eventTime * 1000L
|
||||
}) // 为数据流中的元素分配时间戳
|
||||
.filter(_.eventType != "") // 只过滤出pay事件
|
||||
.keyBy(_.txId) // 根据 订单id 分组
|
||||
|
||||
// 从 ReceiptLog.csv 文件中读取数据 ,并转换成样例类
|
||||
val receiptStream: KeyedStream[ReceiptEvent, String] = env.readTextFile("YOUR_PATH\\ReceiptLog.csv")
|
||||
.map(data => {
|
||||
// 样例数据: 3hu3k2432,alipay,1558430848
|
||||
val dataArray: Array[String] = data.split(",")
|
||||
ReceiptEvent(dataArray(0), dataArray(1), dataArray(2).toLong)
|
||||
})
|
||||
.assignAscendingTimestamps(_.timestamp * 1000L) // 设置水印
|
||||
.keyBy(_.txId) // 根据 txId 进行分组
|
||||
|
||||
// 使用 join 连接两条流
|
||||
val resultStream: DataStream[(OrderEvent, ReceiptEvent)] = orderEventStream
|
||||
.intervalJoin(receiptStream)
|
||||
.between(Time.seconds(-5), Time.seconds(3))
|
||||
.process(new OrderPayTxDetectWithJoin())
|
||||
|
||||
resultStream.print()
|
||||
env.execute("order pay tx match with join job")
|
||||
|
||||
}
|
||||
|
||||
// 自定义 ProcessJoinFunction
|
||||
class OrderPayTxDetectWithJoin() extends ProcessJoinFunction[OrderEvent,ReceiptEvent,(OrderEvent,ReceiptEvent)]{
|
||||
override def processElement(left: OrderEvent, right: ReceiptEvent, ctx: ProcessJoinFunction[OrderEvent, ReceiptEvent, (OrderEvent, ReceiptEvent)]#Context, collector: Collector[(OrderEvent, ReceiptEvent)]): Unit = {
|
||||
|
||||
collector.collect((left,right))
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**虽然这种方法看似代码简单了不少,但是也存在局限性。只能匹配对应上的,不能输出没有匹配上的。**
|
||||

|
||||
***
|
||||
## 小结
|
||||
好了,当你看到这里的时候,意味着**电商用户行为数据分析**暂时完结了,不对,下一篇文章会为大家再总结一些**电商常见指标**的干货,敬请期待!!!**考虑到部分小伙伴对于中间的部分代码有疑问,所以我每行都写上了注释,因此详细的过程笔者就不在这里详细赘述了**。看了注释仍有疑惑的小伙伴们欢迎添加我的个人微信询问,**互相学习,共同进步**!你知道的越多,你不知道的也越多,我是Alice,我们下一期见!
|
||||
|
||||
|
||||
> **文章持续更新,可以微信搜一搜「 猿人菌 」第一时间阅读,思维导图,大数据书籍,大数据高频面试题,海量一线大厂面经…期待您的关注!**
|
||||
|
||||
|
||||
|
||||

|
||||
|
||||
164
note/实战项目/基于 flink 的电商用户行为数据分析【9】电商常见指标汇总 + 项目总结.md
Normal file
164
note/实战项目/基于 flink 的电商用户行为数据分析【9】电商常见指标汇总 + 项目总结.md
Normal file
@@ -0,0 +1,164 @@
|
||||
本篇是flink 的「电商用户行为数据分析」的第 9 篇文章,也是该系列的最后一篇,为大家带来**电商常见的指标汇总**和**对前8篇文章做一个的阶段性的总结**,并融入一些**我自己的思考**,希望大家能够从中受益,感谢阅读!
|
||||

|
||||
***
|
||||
|
||||
## 电商指标整理
|
||||
### 有关"人"的指标
|
||||

|
||||
- 客服
|
||||
|
||||
|指标名词|名词解释 |
|
||||
|--|--|
|
||||
| 询单量 | 下单前来询问客服的客户总数 |
|
||||
| 询单转换率 |(转化率= 成单数/来访量转化率)影响的因素有:宝贝描述(宝贝图片优化和描述很大程度上决定了转化率的高低。其次是店铺的整体布局和设计。)、销售目标(买家都有从众心理,商铺的定价和定位有待调查和确认,主流的消费群体应该是首选销售目标。)、宝贝的评价(评价对于店铺的存在是致命的,没有信誉便放在之后考虑是很多淘宝买家的心理。)、客服(客服是店铺窗口,好的客服相当于销售成功了一半,对于客服的严格要求是必不可少的。)|
|
||||
|平均接待时长| 平均客服接待客户总的时长 |
|
||||
|DSR评分|DSR就是卖家服务评级系统。就比如我们在淘宝、京东等电商平台卖商品,收到货会要求我们评价评分,DSR评分就是选取连续六个月内的买家给予该项评分的总和除以连续六个月内买家给与该评分的次数。淘宝店铺中DSR评分是淘宝店铺动态评分。淘宝店铺动态评分是指在淘宝网交易成功后,买家可以对本次交易的卖家进行如下三项评分:宝贝与描述相符、卖家的服务态度、物流服务的质量。|
|
||||
|
||||
- 用户
|
||||
|
||||
-- 流量(用户)
|
||||
|
||||
|指标名词 | 名词解释 ||
|
||||
|--|--|--|
|
||||
| 免费流量 |(1)搜索流量;搜索流量涉及的提升维度很多,如全店关键词布局,标题,产品架构等,都是细致功夫。手淘首页手淘首页流量的入口有很多,就是付费流量中提及的生活研究所/爱逛街/必买清单/淘立拍/有好货/有好店/猜你喜欢等,都是(2)手淘首页;流量来源,其中流量最大的可操作性最强的,是猜你喜欢。(3)主动访问;如直接访问、购物车、宝贝收藏、已买到商品等。(4)新品流量;有一个可以利用的规则,在这里说一下,就是大家都知道的淘宝对店铺新品的扶持流量。一件商品在刚上架的时候,淘宝平台会有一定的流量扶持,但是由于一件商品的扶持流量比较小,不会很明显。这时候就可以利用大量的上货,利用淘宝的扶持流量发展自身,想要利用这个规则的话,一次性上架几件宝贝肯定是不行的,最好是一次性上架数百的商品,这样的话,店铺自身就会有比较大的流量。但是一次性上架上百的商品,还是每天都上架数百的商品,完全靠人工的话,几乎不可能完成这个任务。这种情况下只能靠一些软件来采集上传商品,以达到一直不断的获取淘宝的扶持流量的目的。这种大量铺货的模式在店铺前期可以做,等到店铺有比较稳定的流量转化的时候,就可以用精细化运营技术来经营店铺了。 |
|
||||
|付费流量 |(1)平台广告;联盟按销售额付佣金,如淘宝客。(2)搜索定向基于平台访客搜索行为,如直通车,同时,直通车也可以人群定向的,下面不再重复提及。(3)人群定向;基于平台访客浏览与购买行为,如钻展,品销宝,淘积木,内容渠道。钻展/品销/淘积木大家应该都比较清楚,这里特别说明一下内容渠道,淘系的内容渠道,如有好货/生活研究所/必买清单/爱逛街…等等,都是基于访客标签个性化展现,这些渠道其实是可以获得大量免费流量的,只要产品足够优质,平台或者达人会主动且免费推,但不能全部指望免费,偶尔联系精准达人付费一下,收获流量与转化率双高,也是不错的。(4)硬广;包断某时段的固定位置,如2012年前淘宝首页首屏焦点图是可以每天16万买到,还送登录页面左侧广告等平台免费资源,这就是传说中的电商红利,当没有了红利,只有土豪才能买硬广了,上次看到的土豪就是科颜氏,买断天猫/淘宝首页第一屏。|
|
||||
|UV| unique Visitor,指访问某个站点或点击某条新闻的不同IP地址的人数。|
|
||||
|PV|page View,即页面浏览量|
|
||||
|VV|访问次数,访客从进入网站到离开网站的一系列活动记为一次访问,也称会话(session),1次访问(会话)可能包含多个PV。|
|
||||
|流量深度(PV/UV)|平均每个独立访客产生的PV。人均浏览页数=浏览次数/独立访客。体现网站对访客的吸引程度。|
|
||||
|PV/UV|停留时长|用户在一个商品页面停留的时间|
|
||||
|ROI|投资回报率;投资回报率(ROI)是指通过投资而应返回的价值,即企业从一项投资活动中得到的经济回报。(投资回报率(ROI)=年利润或年均利润/投资总额×100%)|
|
||||
|来源转换率|指用户通过什么渠道进入该页面,比如:APP,广告,直通车…|
|
||||
|跳失率|指统计时间内,访客中没有发生点击行为的人数/访客数,即 1-点击人数/访客数。该值越低表示流量的质量越好。多天的跳失率为各天跳失率的日均值。简单地说,就是访客只访问一个页面就离开了。一个较高的跳失率是不利于店铺转化率提升以及店铺的发展的。|
|
||||
|
||||
|
||||
-- 成交用户
|
||||
|
||||
|指标名词 |名词解释 |
|
||||
|--|--|
|
||||
| 新用户数 | 第一次购买商品的用户|
|
||||
|老用户数 |不是大于一次购买商品的用户|
|
||||
|活跃用户数|指那些会时不时地光顾下网站,并为网站带来一些价值的用户数量|
|
||||
|沉睡用户数|沉睡用户定义,是指有一段时间没有使用、访问的用户数。例如:移动互联网产品常把90天活跃度作为一个评判节点,如果一个用户90天之内没有任何活跃行为,就会被判定为沉睡用户。|
|
||||
|复购率|再次消费的用户数量/总用户数量x100%比如母婴店有1000个会员,当月有100个会员来店再次消费,则回头率为10%。|
|
||||
|客单价|一段时间内的销售额/客户数。客单价的本质是:在一定时期内,每位顾客消费的平均价格|
|
||||
|连带率|销售件数/交易次数反映的是顾客平均单次消费的产品件数|
|
||||
|RFM|RFM模型,包含三个指标:(1)最近一次消费 (Recency):最近一次消费意指上一次购买的时候——顾客上一次是几时来店里、上一次根据哪本邮购目录购买东西、什么时候买的车,或在你的超市买早餐最近的一次是什么时候。(2)消费频率 (Frequency):消费频率是顾客在限定的期间内所购买的次数。我们可以说最常购买的顾客,也是满意度最高的顾客。如果相信品牌及商店忠诚度的话,最常购买的消费者,忠诚度也就最高。增加顾客购买的次数意味着从竞争对手处偷取市场占有率,由别人的手中赚取营业额。(3)消费金额 (Monetary):指的是一段时间(通常是1年)内的消费金额|
|
||||
|
||||
### 有关"货"的指标
|
||||

|
||||
- 进货
|
||||
|
||||
| 指标名词 | 名词解释 |
|
||||
|--|--|
|
||||
|备货SKU数 | 指仓库中实际储存的货物规格、颜色、款式的数量。 SKU,英文全称为 stock keeping unit,定义为保存库存控制的最小可用单位,例如纺织品中一个SKU通常表示:规格、颜色、款式。 STOCK KEEP UNIT.这是客户拿到商品放到仓库后给商品编号,归类的一种方法. 通常是SKU#是多少多少这样子. 还有的译为存货单元\库存单元\库存单位\货物存储单位\存货保存单位\单元化单位\单品\品种,基于业务还有的是最小零售单位\最小销售单位\最小管理单位\库存盘点单位等;专业物流术语解释为“货格”。|
|
||||
|备货品类数|指仓库中实际储存的货物种类。|
|
||||
|平均每款SKU数|一般是基于品类或者平台来进行统计。|
|
||||
|平均每款备货数量 |平均每款备货量=总备货量/备货品类数|
|
||||
|品类采销比|指采购商品种类和销售种类的比例|
|
||||
|价格带采销比|指采购商品价格和销售价格的比例|
|
||||
|尺码采销比|指采购尺码和销售尺码的比例|
|
||||
|
||||
- 销售
|
||||
|
||||
| 指标名词 | 名词解释 |
|
||||
|--|--|
|
||||
| 销售结构(品类/价格带/折扣带) | 价格带(Price Zone )指各个商品品种销售价格的上限与下限之间的范围。在店铺内,为了满足顾客对既丰富又有效的商品构成的需要,有必要减少销售格层,并缩小价格带。如果销售价格的种类很多,则必然导致顾客不需要的商品增加,使顾客选择商品成为困难,并失去了商店的特性。 |
|
||||
|畅滞销|指市场的产品上因为一些原因不受消费者欢迎而导致销售速度极慢。其特征为:购买量为零;售价等于或低于成本;简单再生产难以为继;|
|
||||
|动销率|动销,即拉动销售,指在营销的渠道终端,通过一系列的营销组合手段,提高单店/单点销售业绩的方式。促销是动销的方式之一,动销的手段和方式远超出促销的范畴。(1)动销率越高不一定越好(2)动销率等于100%也不一定就是正常,动销率小于100%也不一定就是滞销商品惹得祸。(3)实际工作中不能仅仅被百分比所迷惑,只看数据的表面,不透过表面找到问题的实质。 动销率计算公式为(商品动销率=动销品种数 /仓库总品种数×100%)|
|
||||
|售罄率|指一定时间段某种货品的销售占总进货的比例,是根据一批进货销售多少比例才能收回销售成本和费用的一个考核指标,便于确定货品销售到何种程度可以进行折扣销售清仓处理的一个合理尺度。售罄率反映了产品的销售速度–是否受欢迎,要充分关注新货上市的售罄率,发现问题研究问题,及时采取措施. (售罄率=实际销售货品成本/总进货成本)或者(售罄率=实际销售货品/总进货零售价)|
|
||||
|
||||
- 库存
|
||||
|
||||
| 指标名词 | 名词解释 |
|
||||
|--|--|
|
||||
| 周转率/天数 | 存货周转率(次数)是指一定时期内企业销售成本与存货平均资金占用额的比率,是衡量和评价企业购入存货、投入生产、销售收回等各环节管理效率的综合性指标 |
|
||||
|库存金额|指的是存货按成本计价的金额|
|
||||
|库存数量|指仓库中实际储存的货物数量|
|
||||
|库存结构(年份/品类/价格)|指仓库中的货物记录的年份,品类和价格|
|
||||
|有效库存比|要计算有效库存比首先需要定义有效库存的标准,有效库存定义是能给门店带来价值的商品的库存。从定义来看残次商品、过季商品和没有销售的商品肯定都不属于有效库存商品。不过在实际的分析过程中有效库存的确定会复杂很多,首先无效库存包括残次商品、过季商品、冻销商品、甚至是虚库存,滞销商品。对于滞销商品需要确定一个标准将将有销售的商品分成有效库存和无效库存,这个标准一般以周销售量或月销售量来衡量,并且渠道不同标准是不一样的。例如某款衣服某周销售了2件,2件对于单个专卖店来说这可能就是有效库存,但是对于一个区域或总公司来说销售2件的商品肯定不是有效库存,因为产生的价值不大,需要提高标准。 (有效库存比=有效库存金额/总库存金额×100%)|
|
||||
|可销天数|指库存里面的总数量可以销售多少天。 有2种核算方式:1.(库存可销天数 = 库存总数量 / 日均销售数量) 2.(库存可销天数 = 库存总成本 / 日均销售成本)|
|
||||
|
||||
- 售后
|
||||
|
||||
|指标名词| 名词解释 |
|
||||
|--|--|
|
||||
|退货率(整体/单款) | 指产品售出后由于各种原因被退回的数量与同期售出的产品总数量之间的比率。有2种计算方式 1.(退货率=退货批次/出货总批次×100%)2.(退货率=退货总数量/出货总数量×100%) |
|
||||
|
||||
### 有关"场"的指标
|
||||

|
||||
现在的电子商务:
|
||||
|
||||
1、大多买家通过搜索找到所买物品,而非电商网站的内部导航,搜索关键字更为重要;
|
||||
|
||||
2、电商商家通过推荐引擎来预测买家可能需要的商品。推荐引擎以历史上具有类似购买记录的买家数据以及用户自身的购买记录为基础,向用户提供推荐信息;
|
||||
|
||||
3、电商商家时刻优化网站性能,如A/B Test划分来访流量,并区别对待来源不同的访客,进而找到最优的产品、内容和价格;
|
||||
|
||||
4、购买流程早在买家访问网站前,即在社交网络、邮件以及在线社区中便已开始,即长漏斗流程(以一条推文、一段视频或一个链接开始,以购买交易结束)。
|
||||
|
||||
「相关数据指标」:**关键词和搜索词**、**推荐接受率**、**邮件列表/短信链接点入率**
|
||||
|
||||
|
||||
## 电商8类基本指标
|
||||
1)总体运营指标:从流量、订单、总体销售业绩、整体指标进行把控,起码对运营的电商平台有个大致了解,到底运营的怎么样,是亏是赚。
|
||||

|
||||
|
||||
|
||||
2)站流量指标:即对访问你网站的访客进行分析,基于这些数据可以对网页进行改进,以及对访客的行为进行分析等等。
|
||||

|
||||
|
||||
|
||||
3)销售转化指标:分析从下单到支付整个过程的数据,帮助你提升商品转化率。也可以对一些频繁异常的数据展开分析。
|
||||

|
||||
4)客户价值指标:这里主要就是分析客户的价值,可以建立RFM价值模型,找出那些有价值的客户,精准营销等等。
|
||||

|
||||
5)商品类指标:主要分析商品的种类,那些商品卖得好,库存情况,以及可以建立关联模型,分析哪些商品同时销售的几率比较高,而进行捆绑销售,有点像啤酒和尿不湿的故事。
|
||||

|
||||
6 ) 市场营销活动指标,主要监控某次活动给电商网站带来的效果,以及监控广告的投放指标。
|
||||

|
||||
7)风控类指标:分析卖家评论,以及投诉情况,发现问题,改正问题
|
||||
|
||||

|
||||
8)市场竞争指标:主要分析市场份额以及网站排名,进一步进行调整
|
||||

|
||||
> 以上总共从8个方面来阐述如何对电商平台进行数据分析,当然,具体问题具体分析,每个公司的侧重点也有所差异,所以如何分析还需因地制宜。
|
||||
|
||||
|
||||
## 项目回顾和总结
|
||||
本次基于flink 的电商用户行为数据分析项目组成模块如下:
|
||||
|
||||

|
||||
这些指标的具体开发在之前的8篇文章中都有陆续介绍,我们在这里可以对其进行一个分类。其中**统计类**的开发套路有迹可循,无非就是将数据集进行读取,然后经过map封装成样例类,可能还会有filter过滤,keyBy分组的操作,接着就是开时间窗,做聚合。如果遇到稍复杂一点的情况,例如求每个时间范围内的topN,我们按照每个窗口结束的时间`indowEnd`进行分组,再做一个process自定义Function即可。
|
||||
|
||||
在后面的模块中,我们开发需要针对业务流程中的一些状态做**检测**和**输出警告**。跟时间相关的,我们就需要使用`processFunction`定义定时器。如果是正常的状态逻辑,我们就需要使用到状态编程,自定义一些状态,总体来讲,就是一套这样的处理规则。对于统计类指标的开发,如果我们不想用DataStreamAPI,想用更高级的API,也可以考虑用 **tableAPI** 和 **flinkSQL**,将需要计算的指标提取出来,做一个聚合即可。如果是对事件,逻辑,风控进行管理,往往我们可以定义CEP复杂事件处理去做定义。
|
||||
|
||||
|
||||
## 项目收获
|
||||
首先谈谈为什么我会尝试去追B站的视频,来学习这个所谓的**基于 flink 的电商用户行为数据分析**项目。主要还是因为自己在平时的工作中,flink接触到的内容不多。而近几年flink社区的发展又非常迅猛,前几天才刚推出**flink1.12.0,流批一体真正统一运行**。所以说,大数据未来几年的发展,flink大势所趋!
|
||||
|
||||
通过这次项目的学习,让我这个大数据萌新对于flink又有了更深的认知。尤其是之前没有在意过flink的CEP编程,但是在一些复杂场景下,使用CEP却是真的能提高我们开发的效率,否则自己写逻辑代码要写到吐....另外,对于一些其他的含义,例如时间窗口,水印,以及各种不同的自定义处理函数,都让我加深了印象。尤其是现在再去看之前写的flink代码,果然是顺眼了很多(让我臭个美,顺便截个图)。
|
||||
|
||||

|
||||
|
||||
我相信一定会有小伙伴看到这里,也想跟这个项目。这里我先投出网页视频链接:`https://www.bilibili.com/video/BV1yV411f7ZR`,其中项目源码我已经放到github上了,欢迎小伙伴们前来围观。
|
||||
|
||||
> https://github.com/Alice-czxy/FlinkECUserBehaviorAnalysis/
|
||||
|
||||
有留言之前几期文章内容的小伙伴都应该知道,我写的代码基本每行都有注释,所以不用担心看不懂哈,如果看了注释还不太理解,欢迎加微信交流哈~
|
||||
|
||||
|
||||
## 之后的计划
|
||||
这个项目只是我自学的一个小阶段,接下来,我会去自学一项最近很火的 技术——ClickHouse,到时候学习做的笔记或者好的资料我都会贡献出来。但是暂时的文章我不会去写这个,因为我现在能写的内容太多了,档期完全排不过来,一大堆已经有了思路还未动笔的文章等着我去解除封印! 好了,本篇文章over,很感兴趣坚持看到这里的你们 |ू・ω・` ) 你知道的越多,你不知道的也越多!我是Alice,我们下一期见!
|
||||
|
||||
|
||||
>**文章持续更新,可以微信搜一搜「 猿人菌 」第一时间阅读,思维导图,大数据书籍,大数据高频面试题,海量一线大厂面经…期待您的关注!**
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
134
note/实战项目/基于flink的电商用户行为数据分析【1】 项目整体介绍.md
Normal file
134
note/实战项目/基于flink的电商用户行为数据分析【1】 项目整体介绍.md
Normal file
@@ -0,0 +1,134 @@
|
||||
## 前言
|
||||
愉悦的一周又要开始了,本周菌哥打算用几期文章为大家分享一个之前在B站自学的一个项目——**基于flink的电商用户行为数据分析**。本期我们先对项目整体功能和模块做一个介绍。
|
||||
|
||||

|
||||
|
||||
***
|
||||
正式介绍项目整体之前,我们来探讨一下批处理和流处理技术。
|
||||
|
||||
## 批处理 VS 流处理
|
||||
批处理和流处理代表了现在大数据处理领域两种完全不同的数据处理方式。
|
||||
|
||||
下面两组分别列出的分别是批处理和流处理的"代表作"。
|
||||

|
||||
左边Hadoop和Spark是“批处理”的代表作,其中Spark可以被认为是**批处理**的“**巅峰之作**”,已经非常成熟,并且社区也非常广泛,应用的领域也很多。
|
||||
|
||||
右边Storm和Flink是“流处理”的代表作,其中Storm是流处理的“先锋”,但它本身有很多问题。Storm是首次真正意义上实现“流处理”,来一个数据就处理一个。但它随之带来的一个问题就是,在大数据应用场景下,**吞吐量不够**。另外如果数据出现**乱序**,Storm也处理不了。而Flink的引入,在Storm的基础上,完美的解决了着这两个问题。Flink可以说,是目前“流处理”的一个高峰!
|
||||
|
||||
那具体“批处理”和“流处理”有哪些特点,我们来做一个对比:
|
||||
|
||||
|
||||
### 批处理
|
||||
|
||||
- 批处理主要操作大容量静态数据集,并在计算过程完成后返回结果。可以认为,处理的是用一个固定时间间隔分组的数据点集合。批处理模式中使用的数据集通常符合下列特征:
|
||||
|
||||
|
||||
|
||||
-- **有界**:批处理数据集代表数据的有限集合
|
||||
|
||||
-- **持久**:数据通常始终存储在某种类型的持久存储位置中
|
||||
|
||||
-- **大量**:<font color='gray'>**批处理操作通常是处理极为海量数据集的唯一方法**</font>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
### 流处理
|
||||
|
||||
- 流处理可以对**随时**进入系统的数据进行计算。流处理方式**无需针对整个数据集执行操作**,而是对通过系统传输的每个数据项执行操作。流处理中的数据集是“**无边界**”的,这就产生了几个重要的影响:
|
||||
|
||||
|
||||
-- 可以处理**几乎无限量**的数据,但**同一时间只能处理一条数据**,不同记录间只维持**最少量**的状态。
|
||||
|
||||
-- 处理工作是基于**事件**的,除非明确停止否则没有“尽头”。
|
||||
|
||||
-- 处理结果**立刻可用**,并会随着新数据的抵达**继续更新**。
|
||||
|
||||
|
||||

|
||||
|
||||
很好,回顾了批处理和流处理的区别之后,我们直接进入项目的整体介绍!
|
||||
|
||||
|
||||
|
||||
## 项目整体介绍
|
||||
|
||||

|
||||
|
||||
电商平台中的用户行为**频繁且较复杂**,系统上线运行一段时间后,可以收集到大量的用户行为数据,进而利用<font color=' '>**大数据技术进行深入挖掘和分析,得到感兴趣的商业指标并增强对风险的控制**</font>。
|
||||
|
||||
电商用户行为数据多样,整体可以分为**用户行为习惯数据**和**业务行为数据**两大类。用户的行为习惯数据包括了**用户的登录方式**、**上线的时间点及时长**、**点击和浏览页面**、**页面停留时间**以及**页面跳转**等等,我们可以从中进行**流量统计和热门商品**的统计,也可以**深入挖掘用户**的特征;这些数据往往可以从web服务器日志中直接读取到。而**业务行为数据**就是用户在电商平台中针对每个业务(通常是某个具体商品)所作的操作,我们一般会在业务系统中相应的位置**埋点**,然后**收集日志进行分析**。业务行为数据又可以简单分为两类:一类是能够明显地**表现出用户兴趣的行为**,比如对商品的**收藏**、**喜欢**、**评分**和**评价**,我们可以从中对数据进行深入分析,得到**用户画像**,进而对用户给出**个性化的推荐商品列表**,这个过程往往会用到机器学习相关的算法;另一类则是**常规的业务操作**,但需要着重关注一些**异常状况以做好风控**,比如登录和订单支付。
|
||||
|
||||

|
||||
## 项目主要模块
|
||||
基于对电商用户行为数据的基本分类,我们可以发现主要有以下三个分析方向:
|
||||
|
||||
1、热门统计
|
||||
|
||||
利用用户的点击浏览行为,进行流量统计、近期热门商品统计等。
|
||||
|
||||
2、偏好统计
|
||||
|
||||
利用用户的偏好行为,比如收藏、喜欢、评分等,进行用户画像分析,给出个性化的商品推荐列表。
|
||||
|
||||
3、风险控制
|
||||
|
||||
利用用户的常规业务行为,比如登录、下单、支付等,分析数据,对异常情况进行报警提示。
|
||||
|
||||
> 总结:
|
||||
> - 统计分析
|
||||
> -- 点击、浏览
|
||||
> -- 热门商品、近期热门商品、分类热门商品、流量统计
|
||||
> - 统计分析
|
||||
> -- 收藏、喜欢、评分、打标签
|
||||
> -- 用户画像,推荐列表(结合特征工程和机器学习算法)
|
||||
> - 风险控制
|
||||
> -- 下订单、支付、登录
|
||||
> -- 刷单监控,订单失效监控,恶意登录(短时间内频繁登录失败)监控
|
||||
|
||||
大的方面,我们可以将其分为**实时统计分析**和**业务流程及风险控制**领域
|
||||

|
||||
但本项目限于数据,我们只实现热门统计和风险控制中的部分内容,将包括以下四大模块:**实时热门商品统计**、**实时流量统计**、**恶意登录监控**和**订单支付失效监控**。
|
||||
|
||||

|
||||
项目模块设计,可以参考这张图:
|
||||

|
||||
由于对实时性要求较高,我们会用**flink**作为数据处理的框架。在项目中,我们将综合运用**flink的各种API**,基于**EventTime**去处理基本的业务需求,并且灵活地使用底层的**processFunction**,基于**状态编程**和**CEP**去处理更加复杂的情形。
|
||||
|
||||
|
||||
## 数据源解析
|
||||
|
||||
我们准备了一份淘宝用户行为数据集,保存为csv文件。本数据集包含了淘宝上某一天随机一百万用户的所有行为(包括**点击**、**购买**、**收藏**、**喜欢**)。数据集的每一行表示一条用户行为,由**用户ID**、**商品ID**、**商品类目ID**、**行为类型**和**时间戳**组成,并以逗号分隔。关于数据集中每一列的详细描述如下:
|
||||
|
||||
|
||||
| 字段名 | 数据类型|说明|
|
||||
|--|--|--|
|
||||
|userId| Long|加密后的用户ID|
|
||||
|itemId|Long|加密后的商品ID
|
||||
|categoryId|Int|加密后的商品所属类别ID
|
||||
|behavior|String|用户行为类型,包括(‘pv’, ‘’buy, ‘cart’, ‘fav’)
|
||||
|timestamp|Long|行为发生的时间戳,单位秒
|
||||
|
||||
另外,我们还可以拿到**web服务器的日志数据**,这里以apache服务器的一份log为例,每一行日志记录了访问者的IP、userId、访问时间、访问方法以及访问的url,具体描述如下:
|
||||
|
||||
|字段名 | 数据类型 |说明|
|
||||
|--|--|--|
|
||||
| ip | String|访问的 IP|
|
||||
|userId|Long|访问的 user ID
|
||||
|eventTime|Long|访问时间
|
||||
|method|String|访问方法 GET/POST/PUT/DELETE
|
||||
|url|String|访问的 url|
|
||||
|
||||
由于行为数据有限,在实时热门商品统计模块中可以使用UserBehavior数据集,而对于恶意登录监控和订单支付失效监控,我们只以示例数据来做演示。
|
||||
|
||||

|
||||
## 小结
|
||||
本期内容为大家回顾了关于批处理和流处理的特性及“代表作”分析,然后对于项目整体和主要模块做了一个说明。至此,基于flink的电商用户行为数据分析【1】| 项目整体介绍的内容就到这里,从下一期开始,我们就要正式步入实际需求,去完成功能模块的开发。
|
||||
|
||||
**你知道的越多,你不知道的也越多**。我是Alice,我们下一期见!受益的朋友记得**三连**支持小菌!
|
||||
|
||||
|
||||
>**文章持续更新,可以微信搜一搜「 猿人菌 」第一时间阅读,思维导图,大数据书籍,大数据高频面试题,海量一线大厂面经…期待您的关注!**
|
||||
|
||||

|
||||
570
note/实战项目/基于flink的电商用户行为数据分析【2】实时热门商品统计.md
Normal file
570
note/实战项目/基于flink的电商用户行为数据分析【2】实时热门商品统计.md
Normal file
@@ -0,0 +1,570 @@
|
||||
## 前言
|
||||
在上一期内容中,菌哥已经为大家介绍了**电商用户行为数据分析**的主要功能和模块介绍。本期内容,我们需要介绍的是**实时热门商品统计**模块的功能开发。
|
||||
|
||||
|
||||

|
||||
|
||||
***
|
||||
首先要实现的是实时热门商品统计,我们将会基于**UserBehavior**数据集来进行分析。
|
||||
|
||||
|
||||

|
||||
项目主体用Scala编写,采用IDEA作为开发环境进行项目编写,采用maven作为项目构建和管理工具。首先我们需要搭建项目框架。
|
||||
|
||||
## 创建Maven项目
|
||||
### 项目框架搭建
|
||||
打开IDEA,创建一个maven项目,命名为`UserBehaviorAnalysis`。由于包含了多个模块,我们可以以UserBehaviorAnalysis作为父项目,并在其下建一个名为`HotItemsAnalysis`的子项目,用于**实时统计热门top N商品**。
|
||||
|
||||
在UserBehaviorAnalysis下新建一个 maven module作为子项目,命名为HotItemsAnalysis。
|
||||
|
||||
父项目只是为了规范化项目结构,方便依赖管理,本身是不需要代码实现的,所以UserBehaviorAnalysis下的src文件夹可以删掉。
|
||||
|
||||
### 声明项目中工具的版本信息
|
||||
|
||||
我们整个项目需要的工具的不同版本可能会对程序运行造成影响,所以应该在最外层的UserBehaviorAnalysis中声明所有子模块共用的版本信息。
|
||||
|
||||
在pom.xml中加入以下配置:
|
||||
|
||||
```shell
|
||||
<properties>
|
||||
<flink.version>1.7.2</flink.version>
|
||||
<scala.binary.version>2.11</scala.binary.version>
|
||||
<kafka.version>2.2.0</kafka.version>
|
||||
</properties>
|
||||
```
|
||||
|
||||
### 添加项目依赖
|
||||
对于整个项目而言,所有模块都会用到flink相关的组件,所以我们在UserBehaviorAnalysis中引入公有依赖:
|
||||
|
||||
```bash
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.apache.flink</groupId>
|
||||
<artifactId>flink-scala_${scala.binary.version}</artifactId>
|
||||
<version>${flink.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.flink</groupId>
|
||||
<artifactId>flink-streaming-scala_${scala.binary.version}</artifactId>
|
||||
<version>${flink.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.kafka</groupId>
|
||||
<artifactId>kafka_${scala.binary.version}</artifactId>
|
||||
<version>${kafka.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.flink</groupId>
|
||||
<artifactId>flink-connector-kafka_${scala.binary.version}</artifactId>
|
||||
<version>${flink.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
```
|
||||
同样,对于maven项目的构建,可以引入公有的插件:
|
||||
|
||||
```bash
|
||||
<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>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>
|
||||
```
|
||||
|
||||
在HotItemsAnalysis子模块中,我们并没有引入更多的依赖,所以不需要改动pom文件。
|
||||
|
||||
### 数据准备
|
||||
在src/main/目录下,可以看到已有的默认源文件目录是java,我们可以将其改名为scala。将数据文件UserBehavior.csv复制到资源文件目录src/main/resources下,我们将从这里读取数据。
|
||||
|
||||
至此,我们的准备工作都已完成,接下来可以写代码了。
|
||||
|
||||
## 模块代码实现
|
||||
我们将实现一个“实时热门商品”的需求,可以将“实时热门商品”翻译成程序员更好理解的需求:**每隔5分钟输出最近一小时内点击量最多的前N个商品**。将这个需求进行分解我们大概要做这么几件事情:
|
||||
|
||||
- 抽取出业务时间戳,告诉Flink框架基于业务时间做窗口
|
||||
- 过滤出点击行为数据
|
||||
- 按一小时的窗口大小,每5分钟统计一次,做滑动窗口聚合(Sliding Window)
|
||||
- 按每个窗口聚合,输出每个窗口中点击量前N名的商品
|
||||
|
||||
### 程序主体
|
||||
在src/main/scala下创建**HotItems.scala**文件,新建一个单例对象。定义样例类**UserBehavior**和**ItemViewCount**,在main函数中创建StreamExecutionEnvironment 并做配置,然后从UserBehavior.csv文件中读取数据,并包装成UserBehavior类型。代码如下:
|
||||
|
||||
```scala
|
||||
/*
|
||||
1. @Author: Alice菌
|
||||
2. @Date: 2020/11/23 10:38
|
||||
3. @Description:
|
||||
电商用户行为数据分析:热门商品实时统计
|
||||
*/
|
||||
object HotItems {
|
||||
|
||||
// 定义样例类,用于封装数据
|
||||
case class UserBehavior(userId:Long,itemId:Long,categoryId:Int,behavior:String,timeStamp:Long)
|
||||
// 中间输出的商品浏览量的样例类
|
||||
case class ItemViewCount(itemId:Long,windowEnd:Long,count:Long)
|
||||
|
||||
def main(args: Array[String]): Unit = {
|
||||
|
||||
// 定义流处理环境
|
||||
val env: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment
|
||||
// 为了打印到控制台的结果不乱序,我们配置全局的并发为1,这里改变并发对结果正确性没有影响
|
||||
env.setParallelism(1)
|
||||
// 设置时间特征为事件时间
|
||||
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)
|
||||
// 读取文本文件,以 Window 为例
|
||||
val stream: DataStream[String] = env.readTextFile("YOUR_PATH\\resources\\UserBehavior.csv")
|
||||
// 对读取到的数据源进行处理
|
||||
stream.map(data =>{
|
||||
val dataArray: Array[String] = data.split(",")
|
||||
// 将数据封装到新建的样例类中
|
||||
UserBehavior(dataArray(0).trim.toLong,dataArray(1).trim.toLong,dataArray(2).trim.toInt,dataArray(3).trim,dataArray(4).trim.toLong)
|
||||
})
|
||||
// 设置waterMark(水印) -- 处理乱序数据
|
||||
.assignAscendingTimestamps(_.timeStamp * 1000)
|
||||
|
||||
// 执行程序
|
||||
env.execute("HotItems")
|
||||
```
|
||||
这里注意,我们需要统计业务时间上的每小时的点击量,所以要基于**EventTime**来处理。那么如何让Flink按照我们想要的**业务时间**来处理呢?这里主要有两件事情要做。
|
||||
|
||||
第一件是告诉Flink我们现在按照EventTime模式进行处理,Flink默认使用ProcessingTime处理,所以我们要显式设置如下:
|
||||
|
||||
```scala
|
||||
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime);
|
||||
```
|
||||
|
||||
第二件事情是指定如何获得业务时间,以及生成Watermark。**Watermark是用来追踪业务事件的概念,可以理解成EventTime世界中的时钟,用来指示当前处理到什么时刻的数据了**。由于我们的数据源的数据已经经过整理,没有乱序,即事件的时间戳是单调递增的,所以可以将每条数据的业务时间就当做Watermark。这里我们用 `assignAscendingTimestamps`来实现时间戳的抽取和`Watermark`的生成。
|
||||
|
||||
>注:真实业务场景一般都是乱序的,所以一般不用**assignAscendingTimestamps**,而是使用**BoundedOutOfOrdernessTimestampExtractor**。
|
||||
|
||||
```scala
|
||||
.assignAscendingTimestamps(_.timestamp * 1000)
|
||||
```
|
||||
|
||||
这样我们就得到了一个带有时间标记的数据流了,后面就能做一些窗口的操作。
|
||||
|
||||
|
||||
### 过滤出点击事件
|
||||
在开始窗口操作之前,先回顾下需求“每隔5分钟输出过去一小时内点击量最多的前N个商品”。由于原始数据中存在点击、购买、收藏、喜欢各种行为的数据,但是我们只需要统计点击量,所以先使用**filter**将点击行为数据过滤出来。
|
||||
|
||||
```scala
|
||||
.filter(_.behavior == "pv")
|
||||
```
|
||||
|
||||
### 设置滑动窗口,统计点击量
|
||||
由于要每隔5分钟统计一次最近一小时每个商品的点击量,所以窗口大小是一小时,每隔5分钟滑动一次。即分别要统计[09:00, 10:00), [09:05, 10:05), [09:10, 10:10)…等窗口的商品点击量。是一个常见的滑动窗口需求(Sliding Window)。
|
||||
|
||||
|
||||

|
||||

|
||||
|
||||
```scala
|
||||
.keyBy("itemId")
|
||||
.timeWindow(Time.minutes(60), Time.minutes(5))
|
||||
.aggregate(new CountAgg(), new WindowResultFunction());
|
||||
```
|
||||
|
||||

|
||||
|
||||
|
||||
我们使用`.keyBy("itemId")`对商品进行分组,使用`.timeWindow(Time size, Time slide)`对每个商品做滑动窗口(1小时窗口,5分钟滑动一次)。然后我们使用 `.aggregate(AggregateFunction af, WindowFunction wf) `做**增量的聚合**操作,它能使用AggregateFunction提前聚合掉数据,减少state的存储压力。较之 `.apply(WindowFunction wf) `会将窗口中的数据都存储下来,最后一起计算要高效地多。这里的CountAgg实现了AggregateFunction接口,功能是**统计窗口中的条数,即遇到一条数据就加一**。
|
||||
|
||||

|
||||
|
||||
```scala
|
||||
// COUNT统计的聚合函数实现,每出现一条记录就加一
|
||||
class CountAgg extends AggregateFunction[UserBehavior, Long, Long] {
|
||||
override def createAccumulator(): Long = 0L
|
||||
override def add(userBehavior: UserBehavior, acc: Long): Long = acc + 1
|
||||
override def getResult(acc: Long): Long = acc
|
||||
override def merge(acc1: Long, acc2: Long): Long = acc1 + acc2
|
||||
}
|
||||
```
|
||||
|
||||
聚合操作.`aggregate(AggregateFunction af, WindowFunction wf)`的第二个参数**WindowFunction**将每个key每个窗口聚合后的结果带上其他信息进行输出。我们这里实现的WindowResultFunction将<主键商品ID,窗口,点击量>封装成了**ItemViewCount**进行输出。
|
||||
|
||||
|
||||
```scala
|
||||
// 商品点击量(窗口操作的输出类型)
|
||||
case class ItemViewCount(itemId: Long, windowEnd: Long, count: Long)
|
||||
```
|
||||
|
||||
代码如下:
|
||||
|
||||
```scala
|
||||
// 自定义窗口函数,包装成 ItemViewCount输出
|
||||
class WindowResult() extends WindowFunction[Long,ItemViewCount,Long,TimeWindow] {
|
||||
|
||||
override def apply(key: Long, window: TimeWindow, input: Iterable[Long], out: Collector[ItemViewCount]): Unit = {
|
||||
|
||||
// 在前面的步骤中,我们根据商品 id 进行了分组,次数的key就是 商品编号
|
||||
val itemId: Long = key
|
||||
// 获取 窗口 末尾
|
||||
val windowEnd: Long = window.getEnd
|
||||
// 获取点击数大小 【累加器统计的结果 】
|
||||
val count: Long = input.iterator.next()
|
||||
|
||||
// 将获取到的结果进行上传
|
||||
out.collect(ItemViewCount(itemId,windowEnd,count))
|
||||
}
|
||||
}
|
||||
```
|
||||
现在我们就得到了每个商品在每个窗口的点击量的数据流。
|
||||
|
||||
为了帮助大家理解,以上几步体现出来的核心思想,小菌这里贴出一张图帮助大家回顾
|
||||
|
||||

|
||||
|
||||
|
||||
### 计算最热门 TopN 商品
|
||||
为了统计**每个窗口下最热门的商品**,我们需要再次按窗口进行分组,这里根据`ItemViewCount`中的`windowEnd`进行`keyBy()`操作。然后使用**ProcessFunction**实现一个自定义的TopN函数TopNHotItems来计算点击量排名前3名的商品,并将排名结果格式化成字符串,便于后续输出。
|
||||
|
||||

|
||||
|
||||
```scala
|
||||
// 按每个窗口聚合
|
||||
.keyBy(_.windowEnd)
|
||||
// 输出每个窗口中点击量前N名的商品
|
||||
.process(new TopNHotItems(3))
|
||||
```
|
||||
|
||||
ProcessFunction是Flink提供的一个**low-level** API,用于实现更高级的功能。它主要提供了定时器`timer`的功能(支持**EventTime**或**ProcessingTime**)。本案例中我们将利用timer来判断何时收齐了某个window下所有商品的点击量数据。由于Watermark的进度是全局的,在processElement方法中,每当收到一条数据ItemViewCount,我们就注册一个windowEnd+1的定时器(**Flink框架会自动忽略同一时间的重复注册**)。windowEnd+1的定时器被触发时,意味着收到了windowEnd+1的Watermark,即收齐了该windowEnd下的所有商品窗口统计值。我们在`onTimer()`中处理将收集的所有商品及点击量进行排序,选出TopN,并将排名信息格式化成字符串后进行输出。
|
||||
|
||||
|
||||
|
||||
|
||||
这里我们还使用了`ListState<ItemViewCount>`来存储收到的每条ItemViewCount消息,**保证在发生故障时,状态数据的不丢失和一致性**。ListState是Flink提供的类似Java List接口的State API,它集成了框架的**checkpoint**机制,自动做到了**exactly-once**的语义保证。
|
||||
|
||||

|
||||
|
||||
代码如下:
|
||||
```scala
|
||||
// 自定义 process function,排序处理数据
|
||||
class TopNHotItems(nSize:Int) extends KeyedProcessFunction[Long,ItemViewCount,String] {
|
||||
|
||||
// 定义一个状态变量 list state,用来保存所有的 ItemViewCont
|
||||
private var itemState: ListState[ItemViewCount] = _
|
||||
|
||||
// 在执行processElement方法之前,会最先执行并且只执行一次 open 方法
|
||||
override def open(parameters: Configuration): Unit = {
|
||||
// 初始化状态变量
|
||||
itemState = getRuntimeContext.getListState(new ListStateDescriptor[ItemViewCount]("itemState", classOf[ItemViewCount]))
|
||||
}
|
||||
|
||||
// 每个元素都会执行这个方法
|
||||
override def processElement(value: ItemViewCount, ctx: KeyedProcessFunction[Long, ItemViewCount, String]#Context, collector: Collector[String]): Unit = {
|
||||
// 每一条数据都存入 state 中
|
||||
itemState.add(value)
|
||||
// 注册 windowEnd+1 的 EventTime Timer, 延迟触发,当触发时,说明收齐了属于windowEnd窗口的所有商品数据,统一排序处理
|
||||
ctx.timerService().registerEventTimeTimer(value.windowEnd + 100)
|
||||
}
|
||||
|
||||
// 定时器触发时,会执行 onTimer 任务
|
||||
override def onTimer(timestamp: Long, ctx: KeyedProcessFunction[Long, ItemViewCount, String]#OnTimerContext, out: Collector[String]): Unit = {
|
||||
|
||||
// 已经收集到所有的数据,首先把所有的数据放到一个 List 中
|
||||
val allItems: ListBuffer[ItemViewCount] = new ListBuffer()
|
||||
|
||||
import scala.collection.JavaConversions._
|
||||
|
||||
for (item <- itemState.get()) {
|
||||
allItems += item
|
||||
}
|
||||
|
||||
// 将状态清除
|
||||
itemState.clear()
|
||||
|
||||
// 按照 count 大小 倒序排序
|
||||
val sortedItems: ListBuffer[ItemViewCount] = allItems.sortBy(_.count)(Ordering.Long.reverse).take(nSize)
|
||||
|
||||
// 将数据排名信息格式化成 String,方便打印输出
|
||||
val result: StringBuilder = new StringBuilder()
|
||||
result.append("======================================================\n")
|
||||
// 触发定时器时,我们多设置了0.1秒的延迟,这里我们将时间减去0.1获取到最精确的时间
|
||||
result.append("时间:").append(new Timestamp(timestamp - 100)).append("\n")
|
||||
|
||||
// 每一个商品信息输出 (indices方法获取索引)
|
||||
for( i <- sortedItems.indices){
|
||||
val currentTtem: ItemViewCount = sortedItems(i)
|
||||
result.append("No").append(i + 1).append(":")
|
||||
.append("商品ID=").append(currentTtem.itemId).append(" ")
|
||||
.append("浏览量=").append(currentTtem.count).append(" ")
|
||||
.append("\n")
|
||||
}
|
||||
|
||||
result.append("======================================================\n")
|
||||
|
||||
// 设置休眠时间
|
||||
Thread.sleep(1000)
|
||||
// 收集数据
|
||||
out.collect(result.toString())
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
这部分的内容也可以通过流程图来表示:
|
||||
|
||||

|
||||
|
||||
|
||||
最后我们可以在main函数中将结果打印输出到控制台,方便实时观测:
|
||||
|
||||
```scala
|
||||
.print();
|
||||
```
|
||||
至此整个程序代码全部完成,我们直接运行main函数,就可以在控制台看到不断输出的各个时间点统计出的热门商品。
|
||||
|
||||

|
||||
## 完整代码
|
||||
最终的完整代码如下:
|
||||
|
||||
```scala
|
||||
import java.sql.Timestamp
|
||||
|
||||
import com.hypers.HotItems.{ItemViewCount, UserBehavior}
|
||||
import org.apache.flink.api.common.functions.AggregateFunction
|
||||
import org.apache.flink.api.common.state.{ListState, ListStateDescriptor}
|
||||
import org.apache.flink.configuration.Configuration
|
||||
import org.apache.flink.streaming.api.TimeCharacteristic
|
||||
import org.apache.flink.streaming.api.functions.KeyedProcessFunction
|
||||
import org.apache.flink.streaming.api.scala._
|
||||
import org.apache.flink.streaming.api.scala.function.WindowFunction
|
||||
import org.apache.flink.streaming.api.windowing.time.Time
|
||||
import org.apache.flink.streaming.api.windowing.windows.TimeWindow
|
||||
import org.apache.flink.util.Collector
|
||||
import scala.collection.mutable.ListBuffer
|
||||
|
||||
/*
|
||||
* @Author: Alice菌
|
||||
* @Date: 2020/11/23 10:38
|
||||
* @Description:
|
||||
电商用户行为数据分析:热门商品实时统计
|
||||
*/
|
||||
object HotItems {
|
||||
|
||||
// 定义样例类,用于封装数据
|
||||
case class UserBehavior(userId:Long,itemId:Long,categoryId:Int,behavior:String,timeStamp:Long)
|
||||
// 中间输出的商品浏览量的样例类
|
||||
case class ItemViewCount(itemId:Long,windowEnd:Long,count:Long)
|
||||
|
||||
def main(args: Array[String]): Unit = {
|
||||
|
||||
// 定义流处理环境
|
||||
val env: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment
|
||||
// 设置并行度
|
||||
env.setParallelism(1)
|
||||
// 设置时间特征为事件时间
|
||||
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)
|
||||
// 读取文本文件
|
||||
val stream: DataStream[String] = env.readTextFile("G:\\idea arc\\BIGDATA\\project\\src\\main\\resources\\UserBehavior.csv")
|
||||
// 对读取到的数据源进行处理
|
||||
stream.map(data =>{
|
||||
val dataArray: Array[String] = data.split(",")
|
||||
// 将数据封装到新建的样例类中
|
||||
UserBehavior(dataArray(0).trim.toLong,dataArray(1).trim.toLong,dataArray(2).trim.toInt,dataArray(3).trim,dataArray(4).trim.toLong)
|
||||
})
|
||||
// 设置waterMark(水印) -- 处理乱序数据
|
||||
.assignAscendingTimestamps(_.timeStamp * 1000)
|
||||
// 过滤出 “pv”的数据 -- 过滤出点击行为数据
|
||||
.filter(_.behavior == "pv")
|
||||
// 因为需要统计出每种商品的个数,这里先对商品id进行分组
|
||||
.keyBy(_.itemId)
|
||||
// 需求: 统计近1小时内的热门商品,每5分钟更新一次 -- 滑动窗口聚合
|
||||
.timeWindow(Time.hours(1),Time.minutes(5))
|
||||
// 预计算,统计出每种商品的个数
|
||||
.aggregate(new CountAgg(),new WindowResult())
|
||||
// 按每个窗口聚合
|
||||
.keyBy(_.windowEnd)
|
||||
// 输出每个窗口中点击量前N名的商品
|
||||
.process(new TopNHotItems(3))
|
||||
.print("HotItems")
|
||||
|
||||
// 执行程序
|
||||
env.execute("HotItems")
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义预聚合函数,来一个数据就加一
|
||||
class CountAgg() extends AggregateFunction[UserBehavior,Long,Long]{
|
||||
|
||||
// 定义累加器的初始值
|
||||
override def createAccumulator(): Long = 0L
|
||||
|
||||
// 定义累加规则
|
||||
override def add(value: UserBehavior, accumulator: Long): Long = accumulator + 1
|
||||
|
||||
// 定义得到的结果
|
||||
override def getResult(accumulator: Long): Long = accumulator
|
||||
|
||||
// 合并的规则
|
||||
override def merge(a: Long, b: Long): Long = a + b
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* WindowFunction [输入参数类型,输出参数类型,Key值类型,窗口类型]
|
||||
* 来处理窗口中的每一个元素(可能是分组的)
|
||||
*/
|
||||
// 自定义窗口函数,包装成 ItemViewCount输出
|
||||
class WindowResult() extends WindowFunction[Long,ItemViewCount,Long,TimeWindow] {
|
||||
|
||||
override def apply(key: Long, window: TimeWindow, input: Iterable[Long], out: Collector[ItemViewCount]): Unit = {
|
||||
|
||||
// 在前面的步骤中,我们根据商品 id 进行了分组,次数的key就是 商品编号
|
||||
val itemId: Long = key
|
||||
// 获取 窗口 末尾
|
||||
val windowEnd: Long = window.getEnd
|
||||
// 获取点击数大小 【累加器统计的结果】
|
||||
val count: Long = input.iterator.next()
|
||||
|
||||
// 将获取到的结果进行上传
|
||||
out.collect(ItemViewCount(itemId,windowEnd,count))
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义 process function,排序处理数据
|
||||
class TopNHotItems(nSize:Int) extends KeyedProcessFunction[Long,ItemViewCount,String] {
|
||||
|
||||
// 定义一个状态变量 list state,用来保存所有的 ItemViewCont
|
||||
private var itemState: ListState[ItemViewCount] = _
|
||||
|
||||
// 在执行processElement方法之前,会最先执行并且只执行一次 open 方法
|
||||
override def open(parameters: Configuration): Unit = {
|
||||
// 初始化状态变量
|
||||
itemState = getRuntimeContext.getListState(new ListStateDescriptor[ItemViewCount]("itemState", classOf[ItemViewCount]))
|
||||
}
|
||||
|
||||
// 每个元素都会执行这个方法
|
||||
override def processElement(value: ItemViewCount, ctx: KeyedProcessFunction[Long, ItemViewCount, String]#Context, collector: Collector[String]): Unit = {
|
||||
// 每一条数据都存入 state 中
|
||||
itemState.add(value)
|
||||
// 注册 windowEnd+1 的 EventTime Timer, 延迟触发,当触发时,说明收齐了属于windowEnd窗口的所有商品数据,统一排序处理
|
||||
ctx.timerService().registerEventTimeTimer(value.windowEnd + 100)
|
||||
}
|
||||
|
||||
// 定时器触发时,会执行 onTimer 任务
|
||||
override def onTimer(timestamp: Long, ctx: KeyedProcessFunction[Long, ItemViewCount, String]#OnTimerContext, out: Collector[String]): Unit = {
|
||||
|
||||
// 已经收集到所有的数据,首先把所有的数据放到一个 List 中
|
||||
val allItems: ListBuffer[ItemViewCount] = new ListBuffer()
|
||||
|
||||
import scala.collection.JavaConversions._
|
||||
|
||||
for (item <- itemState.get()) {
|
||||
allItems += item
|
||||
}
|
||||
|
||||
// 将状态清除
|
||||
itemState.clear()
|
||||
|
||||
// 按照 count 大小 倒序排序
|
||||
val sortedItems: ListBuffer[ItemViewCount] = allItems.sortBy(_.count)(Ordering.Long.reverse).take(nSize)
|
||||
|
||||
// 将数据排名信息格式化成 String,方便打印输出
|
||||
val result: StringBuilder = new StringBuilder()
|
||||
result.append("======================================================\n")
|
||||
// 触发定时器时,我们多设置了0.1秒的延迟,这里我们将时间减去0.1获取到最精确的时间
|
||||
result.append("时间:").append(new Timestamp(timestamp - 100)).append("\n")
|
||||
|
||||
// 每一个商品信息输出 (indices方法获取索引)
|
||||
for( i <- sortedItems.indices){
|
||||
val currentTtem: ItemViewCount = sortedItems(i)
|
||||
result.append("No").append(i + 1).append(":")
|
||||
.append("商品ID=").append(currentTtem.itemId).append(" ")
|
||||
.append("浏览量=").append(currentTtem.count).append(" ")
|
||||
.append("\n")
|
||||
}
|
||||
|
||||
result.append("======================================================\n")
|
||||
|
||||
// 设置休眠时间
|
||||
Thread.sleep(1000)
|
||||
// 收集数据
|
||||
out.collect(result.toString())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
为了让小伙伴们更好理解,菌哥基本每行代码都写上了注释,就冲这波细节,还不给安排一波三连😎开个玩笑,回到主题上,我们再来讨论一个问题。
|
||||
|
||||
实际生产环境中,我们的数据流往往是从Kafka获取到的。**如果要让代码更贴近生产实际,我们只需将source更换为Kafka即可**:
|
||||
|
||||
```scala
|
||||
val properties = new Properties()
|
||||
properties.setProperty("bootstrap.servers", "localhost:9092")
|
||||
properties.setProperty("group.id", "consumer-group")
|
||||
properties.setProperty("key.deserializer",
|
||||
"org.apache.kafka.common.serialization.StringDeserializer")
|
||||
properties.setProperty("value.deserializer",
|
||||
"org.apache.kafka.common.serialization.StringDeserializer")
|
||||
properties.setProperty("auto.offset.reset", "latest")
|
||||
```
|
||||
|
||||
当然,根据实际的需要,我们还可以将Sink指定为Kafka、ES、Redis或其它存储,这里就不一一展开实现了。
|
||||
|
||||
|
||||
|
||||
|
||||
## 参考
|
||||
> https://www.bilibili.com/video/BV1y54y127h2?from=search&seid=5631307517601819264
|
||||
|
||||
|
||||
|
||||
## 小结
|
||||
本期内容主要为大家分享了如何基于flink在电商用户行为分析项目中对**实时热门商品统计**模块进行开发的过程。下一期我们会介绍项目中另一个模块**实时流量统计**的功能开发,敬请期待!你知道的越多,你不知道的也越多,我是Alice,我们下一期见!
|
||||
|
||||
**受益的朋友记得三连支持小菌!**
|
||||
|
||||
|
||||
>**文章持续更新,可以微信搜一搜「 猿人菌 」第一时间阅读,思维导图,大数据书籍,大数据高频面试题,海量一线大厂面经…期待您的关注!**
|
||||
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
256
note/实战项目/基于flink的电商用户行为数据分析【3】实时流量统计.md
Normal file
256
note/实战项目/基于flink的电商用户行为数据分析【3】实时流量统计.md
Normal file
@@ -0,0 +1,256 @@
|
||||
## 前言
|
||||
在上一期内容中,菌哥已经为大家介绍了实时热门商品统计模块的功能开发的过程(👉[基于flink的电商用户行为数据分析【2】| 实时热门商品统计](https://alice.blog.csdn.net/article/details/110024317))。本期文章,我们要学习的是**实时流量统计**模块的开发过程。
|
||||
|
||||
|
||||

|
||||
|
||||
***
|
||||
|
||||
## 模块创建和数据准备
|
||||
在UserBehaviorAnalysis下新建一个 maven module作为子项目,命名为`NetworkFlowAnalysis`。在这个子模块中,我们同样并没有引入更多的依赖,所以也不需要改动pom文件。
|
||||
|
||||
在src/main/目录下,将默认源文件目录java改名为scala。将apache服务器的日志文件`apache.log`复制到资源文件目录`src/main/resources`下,我们将从这里读取数据。
|
||||
|
||||

|
||||
|
||||
## 代码实现
|
||||
我们现在要实现的模块是 “**实时流量统计**”。对于一个电商平台而言,**用户登录的入口流量**、**不同页面的访问流量**都是值得分析的重要数据,而这些数据,可以简单地从web服务器的日志中提取出来。我们在这里实现最基本的“**页面浏览数**”的统计,也就是读取服务器日志中的每一行log,统计在一段时间内用户访问url的次数。
|
||||
|
||||
具体做法为:**每隔5秒,输出最近10分钟内访问量最多的前N个URL**。可以看出,这个需求与之前“实时热门商品统计”非常类似,所以我们完全可以借鉴此前的代码。
|
||||
|
||||
具体分析如下:
|
||||
|
||||
> **热门页面**
|
||||
> - 基本需求
|
||||
> -- 从 web 服务器的日志中,统计实时的热门访问页面
|
||||
> -- 统计每分钟的ip访问量,取出访问量最大的5个地址,每5秒更新一次
|
||||
> - 解决思路
|
||||
> -- 将 apache 服务器日志中的时间,转换为时间戳,作为 Event Time
|
||||
> -- 构建滑动窗口,窗口长度为1分钟,滑动距离为5秒
|
||||
>
|
||||
>
|
||||
> **PV 和 UV**
|
||||
> - 基本需求
|
||||
> -- 从埋点日志中,统计实时的 PV 和 UV
|
||||
> -- 统计每小时的访问量(PV),并且对用户进行去重(UV)
|
||||
> - 解决思路
|
||||
> -- 统计埋点日志中的 pv 行为,利用 Set 数据结构进行去重
|
||||
> -- 对于超大规模的数据,可以考虑用布隆过滤器进行去重
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
在src/main/scala下创建NetworkFlow.scala文件,新建一个单例对象。定义样例类`ApacheLogEvent`,这是输入的日志数据流;另外还有`UrlViewCount`,这是窗口操作统计的输出数据类型。在main函数中创建StreamExecutionEnvironment 并做配置,然后从apache.log文件中读取数据,并包装成ApacheLogEvent类型。
|
||||
|
||||
```scala
|
||||
// 输入 log 数据样例类
|
||||
case class ApacheLogEvent(ip: String, userId: String, eventTime: Long, method: String, url: String)
|
||||
|
||||
// 中间统计结果样例类
|
||||
case class UrlViewCount(url: String, windowEnd: Long, count: Long)
|
||||
```
|
||||
|
||||
需要注意的是,原始日志中的时间是`“dd/MM/yyyy:HH:mm:ss”`的形式,需要定义一个`DateTimeFormat`将其转换为我们需要的时间戳格式:
|
||||
|
||||
```scala
|
||||
.map(line => {
|
||||
val linearray = line.split(" ")
|
||||
val sdf = new SimpleDateFormat("dd/MM/yyyy:HH:mm:ss")
|
||||
val timestamp = sdf.parse(linearray(3)).getTime
|
||||
ApacheLogEvent(linearray(0), linearray(2), timestamp,
|
||||
linearray(5), linearray(6))
|
||||
})
|
||||
```
|
||||
因为后面部分的逻辑可以说与实时商品统计部分的逻辑是一样的,所以这里小菌就不再带着大家一步步去分析了,完整代码如下:
|
||||
|
||||
```scala
|
||||
import java.sql.Timestamp
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util
|
||||
|
||||
import org.apache.flink.api.common.functions.AggregateFunction
|
||||
import org.apache.flink.api.common.state.{ListState, ListStateDescriptor}
|
||||
import org.apache.flink.streaming.api.TimeCharacteristic
|
||||
import org.apache.flink.streaming.api.functions.KeyedProcessFunction
|
||||
import org.apache.flink.streaming.api.functions.timestamps.BoundedOutOfOrdernessTimestampExtractor
|
||||
import org.apache.flink.streaming.api.scala.{DataStream, StreamExecutionEnvironment}
|
||||
import org.apache.flink.streaming.api.scala._
|
||||
import org.apache.flink.streaming.api.scala.function.WindowFunction
|
||||
import org.apache.flink.streaming.api.windowing.time.Time
|
||||
import org.apache.flink.streaming.api.windowing.windows.TimeWindow
|
||||
import org.apache.flink.util.Collector
|
||||
|
||||
import scala.collection.mutable.ListBuffer
|
||||
|
||||
/*
|
||||
* @Author: Alice菌
|
||||
* @Date: 2020/11/23 14:16
|
||||
* @Description:
|
||||
电商用户行为数据分析:实时流量统计
|
||||
<每隔5秒,输出最近10分钟内访问量最多的前N个URL>
|
||||
*/
|
||||
object NetworkFlow {
|
||||
|
||||
// 输入 log 数据样例类
|
||||
case class ApacheLogEvent(ip: String, userId: String, eventTime: Long, method: String, url: String)
|
||||
|
||||
// 中间统计结果样例类
|
||||
case class UrlViewCount(url: String, windowEnd: Long, count: Long)
|
||||
|
||||
def main(args: Array[String]): Unit = {
|
||||
|
||||
// 创建 流处理的 环境
|
||||
val env: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment
|
||||
// 设置时间语义为 eventTime -- 事件创建的时间
|
||||
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)
|
||||
// 设置任务并行度
|
||||
env.setParallelism(1)
|
||||
// 读取文件数据
|
||||
val stream: DataStream[String] = env.readTextFile("G:\\idea arc\\BIGDATA\\project\\src\\main\\resources\\apache.log")
|
||||
|
||||
// 对 stream 数据进行处理
|
||||
stream.map(data => {
|
||||
val dataArray: Array[String] = data.split(" ")
|
||||
// 因为日志文件中的数据格式是 17/05/2015:10:05:03
|
||||
// 所以我们这里用DataFormat对时间进行转换
|
||||
val simpleDateFormat: SimpleDateFormat = new SimpleDateFormat("dd/MM/yyyy:HH:mm:ss")
|
||||
val timestamp: Long = simpleDateFormat.parse(dataArray(3).trim).getTime
|
||||
// 将解析的数据存放至我们定义好的样例类中
|
||||
ApacheLogEvent(dataArray(0).trim, dataArray(1).trim, timestamp, dataArray(5).trim, dataArray(6).trim)
|
||||
})
|
||||
.assignTimestampsAndWatermarks(new BoundedOutOfOrdernessTimestampExtractor[ApacheLogEvent](Time.seconds(60)) {
|
||||
override def extractTimestamp(element: ApacheLogEvent): Long = element.eventTime
|
||||
})
|
||||
// 因为我们需要统计出每种url的出现的次数,故这里将 url 作为 key 进行分组
|
||||
.keyBy(_.url)
|
||||
// 滑动窗口聚合 -- 每隔5秒,输出最近10分钟内访问量最多的前N个URL
|
||||
.timeWindow(Time.minutes(10), Time.seconds(5))
|
||||
// 预计算,统计出每个 URL 的访问量
|
||||
.aggregate(new CountAgg(),new WindowResult())
|
||||
// 根据窗口结束时间进行分组
|
||||
.keyBy(_.windowEnd)
|
||||
// 输出每个窗口中访问量最多的前5个URL
|
||||
.process(new TopNHotUrls(5)) //
|
||||
.print()
|
||||
|
||||
|
||||
// 执行程序
|
||||
env.execute("network flow job")
|
||||
|
||||
}
|
||||
|
||||
// 自定义的预聚合函数
|
||||
class CountAgg() extends AggregateFunction[ApacheLogEvent, Long, Long] {
|
||||
override def createAccumulator(): Long = 0L
|
||||
|
||||
override def add(value: ApacheLogEvent, accumulator: Long): Long = accumulator + 1
|
||||
|
||||
override def getResult(accumulator: Long): Long = accumulator
|
||||
|
||||
override def merge(a: Long, b: Long): Long = a + b
|
||||
|
||||
}
|
||||
// 自定义的窗口处理函数
|
||||
class WindowResult() extends WindowFunction[Long, UrlViewCount, String, TimeWindow] {
|
||||
|
||||
override def apply(url: String, window: TimeWindow, input: Iterable[Long], out: Collector[UrlViewCount]): Unit = {
|
||||
// 输出结果
|
||||
out.collect(UrlViewCount(url, window.getEnd, input.iterator.next()))
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义 process function,实现排序输出
|
||||
class TopNHotUrls(nSize: Int) extends KeyedProcessFunction[Long, UrlViewCount, String] {
|
||||
|
||||
// 定义一个状态列表,保存结果
|
||||
lazy val urlState: ListState[UrlViewCount] = getRuntimeContext.getListState( new ListStateDescriptor[UrlViewCount]( "urlState", classOf[UrlViewCount] ) )
|
||||
|
||||
override def processElement(value: UrlViewCount, ctx: KeyedProcessFunction[Long, UrlViewCount, String]#Context, collector: Collector[String]): Unit = {
|
||||
|
||||
// 将数据添加至 状态 列表中
|
||||
urlState.add(value)
|
||||
// 根据窗口结束时间windowEnd,设置定时器
|
||||
ctx.timerService().registerEventTimeTimer(value.windowEnd + 1)
|
||||
|
||||
}
|
||||
|
||||
override def onTimer(timestamp: Long, ctx: KeyedProcessFunction[Long, UrlViewCount, String]#OnTimerContext, out: Collector[String]): Unit = {
|
||||
|
||||
// 新建一个ListBuffer,用于存放状态列表中的数据
|
||||
val allUrlViews: ListBuffer[UrlViewCount] = new ListBuffer[UrlViewCount]()
|
||||
// 获取到状态列表
|
||||
val iter: util.Iterator[UrlViewCount] = urlState.get().iterator()
|
||||
|
||||
while ( iter.hasNext ) {
|
||||
allUrlViews += iter.next()
|
||||
}
|
||||
|
||||
// 清除状态
|
||||
urlState.clear()
|
||||
|
||||
// 按照 count 大小排序
|
||||
val sortedUrlViews: ListBuffer[UrlViewCount] = allUrlViews.sortWith(_.count > _.count).take(nSize)
|
||||
|
||||
// 格式化成String打印输出
|
||||
val result: StringBuilder = new StringBuilder()
|
||||
|
||||
result.append("=========================================\n")
|
||||
// 触发定时器时,我们设置了一个延迟时间,这里我们减去延迟
|
||||
result.append("时间: ").append(new Timestamp(timestamp - 1)).append("\n")
|
||||
|
||||
for ( i <- sortedUrlViews.indices){
|
||||
val currentUrlView: UrlViewCount = sortedUrlViews(i)
|
||||
// 拼接打印结果
|
||||
result.append("No").append(i+1).append(":")
|
||||
.append(" URL=").append(currentUrlView.url).append(" ")
|
||||
.append(" 流量=").append(currentUrlView.count).append("\n")
|
||||
|
||||
}
|
||||
|
||||
result.append("=========================================\n")
|
||||
|
||||
// 设置休眠时间
|
||||
Thread.sleep(1000)
|
||||
|
||||
// 输出结果
|
||||
out.collect(result.toString())
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
|
||||
## 运行效果
|
||||

|
||||
为了让小伙伴们更好理解,菌哥基本每行代码都写上了注释,就冲这波细节,还不给安排一波三连😎开个玩笑,回到主题上,我们再来讨论一个问题。
|
||||
|
||||
实际生产环境中,我们的数据流往往是从Kafka获取到的。如果要让代码更贴近生产实际,我们只需将source更换为Kafka即可:
|
||||
|
||||
```scala
|
||||
val properties = new Properties()
|
||||
properties.setProperty("bootstrap.servers", "localhost:9092")
|
||||
properties.setProperty("group.id", "consumer-group")
|
||||
properties.setProperty("key.deserializer",
|
||||
"org.apache.kafka.common.serialization.StringDeserializer")
|
||||
properties.setProperty("value.deserializer",
|
||||
"org.apache.kafka.common.serialization.StringDeserializer")
|
||||
properties.setProperty("auto.offset.reset", "latest")
|
||||
```
|
||||
|
||||
当然,根据实际的需要,我们还可以将Sink指定为Kafka、ES、Redis或其它存储,这里就不一一展开实现了。
|
||||
|
||||
## 参考
|
||||
> https://www.bilibili.com/video/BV1y54y127h2?from=search&seid=5631307517601819264
|
||||
|
||||
|
||||
## 小结
|
||||
本期内容主要为大家分享了如何基于flink在电商用户行为分析项目中对**实时流量统计**模块进行开发的过程,这个跟上一期介绍的**实时热门商品统计**功能非常类似,对本期内容不太理解的小伙伴可以多研究上一期的精彩内容~下一期我们会介绍项目中**恶意登录监控**的功能开发,敬请期待!你知道的越多,你不知道的也越多,我是Alice,我们下一期见!
|
||||
|
||||
受益的朋友记得三连支持小菌!
|
||||
|
||||
>**文章持续更新,可以微信搜一搜「 猿人菌 」第一时间阅读,思维导图,大数据书籍,大数据高频面试题,海量一线大厂面经…期待您的关注!**
|
||||
|
||||

|
||||
406
note/实战项目/基于flink的电商用户行为数据分析【4】恶意登录监控.md
Normal file
406
note/实战项目/基于flink的电商用户行为数据分析【4】恶意登录监控.md
Normal file
@@ -0,0 +1,406 @@
|
||||
## 前言
|
||||
在上一期内容中,菌哥已经为大家介绍了实时热门商品统计模块的功能开发的过程(👉[基于flink的电商用户行为数据分析【3】| 实时流量统计](https://alice.blog.csdn.net/article/details/110212749))。本期文章,我们需要学习的是**恶意登录监控**模块功能的开发过程。
|
||||
|
||||

|
||||
|
||||
## 模块创建和数据准备
|
||||
继续在`UserBehaviorAnalysis`下新建一个 maven module作为子项目,命名为`LoginFailDetect`。在这个子模块中,我们将会用到flink的`CEP`库来实现**事件流的模式匹配**,所以需要在pom文件中引入CEP的相关依赖:
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>org.apache.flink</groupId>
|
||||
<artifactId>flink-cep-scala_${scala.binary.version}</artifactId>
|
||||
<version>${flink.version}</version>
|
||||
</dependency>
|
||||
```
|
||||
同样,在src/main/目录下,将默认源文件目录java改名为scala。
|
||||
|
||||
|
||||
## 代码实现
|
||||
对于网站而言,**用户登录并不是频繁的业务操作**。如果一个用户短时间内频繁登录失败,就有可能是出现了程序的恶意攻击,比如密码暴力破解。因此我们考虑,应该对用户的登录失败动作进行统计,具体来说,**如果同一用户(可以是不同IP)在2秒之内连续两次登录失败,就认为存在恶意登录的风险,输出相关的信息进行报警提示**。这是电商网站、也是几乎所有网站**风控**的基本一环。
|
||||
|
||||
|
||||
所以我们可以思考一下解决方案:
|
||||
>- 基本需求
|
||||
>-- 用户在短时间内频繁登录失败,有程序恶意攻击的可能
|
||||
>-- 同一用户(可以是不同IP)在2秒内连续两次登录失败,需要报警
|
||||
>
|
||||
>- 解决思路
|
||||
>-- 将用户的登录失败行为存入 ListState,设定定时器2秒后触发,查看 ListState 中有几次失败登录
|
||||
>-- 更加准确的检测,可以使用 **CEP** 库实现事件流的**模式匹配**
|
||||
|
||||
既然现在思路清楚了,那我们就尝试将方案落地。
|
||||
|
||||
## 状态编程
|
||||
由于同样引入了时间,我们可以想到,最简单的方法其实与之前的热门统计类似,只需要**按照用户ID分流**,然后遇到登录失败的事件时将其保存在**ListState**中,然后**设置一个定时器**,2秒后触发。定时器触发时**检查状态中的登录失败事件个数**,如果大于等于2,那么就**输出报警信息**。
|
||||
|
||||
在src/main/scala下创建`LoginFail.scala`文件,新建一个单例对象。定义样例类`LoginEvent`,这是输入的登录事件流。登录数据本应该从UserBehavior日志里提取,由于UserBehavior.csv中没有做相关埋点,我们从另一个文件`LoginLog.csv`中读取登录数据。
|
||||
|
||||

|
||||
|
||||
代码如下:
|
||||
|
||||
```scala
|
||||
object LoginFailOne {
|
||||
|
||||
// 输入的登录事件样例类
|
||||
case class LoginEvent( userId:Long,ip:String,eventType:String,eventTime:Long)
|
||||
|
||||
// 输出的报警信息样例类
|
||||
case class Warning( userId:Long,firstFailTime:Long,lastFailTime:Long,warningMsg:String)
|
||||
|
||||
def main(args: Array[String]): Unit = {
|
||||
|
||||
// 创建流环境
|
||||
val env: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment
|
||||
// 设置并行度
|
||||
env.setParallelism(1)
|
||||
// 设置时间特征为事件时间
|
||||
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)
|
||||
|
||||
// 读取csv文件
|
||||
env.readTextFile("G:\\LoginLog.csv")
|
||||
.map(data => {
|
||||
// 将文件中的数据封装成样例类
|
||||
val dataArray: Array[String] = data.split(",")
|
||||
LoginEvent(dataArray(0).toLong, dataArray(1), dataArray(2), dataArray(3).toLong)
|
||||
})
|
||||
// 设置 WaterMark 水印
|
||||
.assignTimestampsAndWatermarks(new BoundedOutOfOrdernessTimestampExtractor[LoginEvent](Time.seconds(5)) {
|
||||
override def extractTimestamp(element: LoginEvent): Long = element.eventTime * 1000
|
||||
})
|
||||
// 以用户id为key,进行分组
|
||||
.keyBy(_.userId)
|
||||
// 计算出同一个用户2秒内连续登录失败超过2次的报警信息
|
||||
.process(new LoginWarning(2))
|
||||
.print()
|
||||
|
||||
// 执行程序
|
||||
env.execute("login fail job")
|
||||
|
||||
|
||||
}
|
||||
|
||||
// 自定义处理函数,保留上一次登录失败的事件,并可以注册定时器 [键的类型,输入元素的类型,输出元素的类型]
|
||||
class LoginWarning(maxFailTimes:Int) extends KeyedProcessFunction[Long,LoginEvent,Warning]{
|
||||
|
||||
// 定义 保存登录失败事件的状态
|
||||
lazy val loginFailState: ListState[LoginEvent] = getRuntimeContext.getListState( new ListStateDescriptor[LoginEvent]("loginfail-state", classOf[LoginEvent]) )
|
||||
|
||||
override def processElement(value: LoginEvent, ctx: KeyedProcessFunction[Long, LoginEvent, Warning]#Context, out: Collector[Warning]): Unit = {
|
||||
|
||||
// 判断当前登录状态是否为 fail
|
||||
if (value.eventType == "fail"){
|
||||
// 判断存放失败事件的state是否有值,没有值则创建一个2秒后的定时器
|
||||
if (!loginFailState.get().iterator().hasNext){
|
||||
// 注册一个定时器,设置在 2秒 之后
|
||||
ctx.timerService().registerEventTimeTimer((value.eventTime + 2) * 1000L)
|
||||
}
|
||||
// 把新的失败事件添加到 state
|
||||
loginFailState.add(value)
|
||||
}else{
|
||||
// 如果登录成功,清空状态重新开始
|
||||
loginFailState.clear()
|
||||
}
|
||||
}
|
||||
|
||||
override def onTimer(timestamp: Long, ctx: KeyedProcessFunction[Long, LoginEvent, Warning]#OnTimerContext, out: Collector[Warning]): Unit = {
|
||||
// 触发定时器的时候,根据状态的失败个数决定是否输出报警
|
||||
val allLoginFailEvents: ListBuffer[LoginEvent] = new ListBuffer[LoginEvent]()
|
||||
|
||||
val iter: util.Iterator[LoginEvent] = loginFailState.get().iterator()
|
||||
|
||||
// 遍历状态中的数据,将数据存放至 ListBuffer
|
||||
while ( iter.hasNext ){
|
||||
allLoginFailEvents += iter.next()
|
||||
}
|
||||
|
||||
//判断登录失败事件个数,如果大于等于 maxFailTimes ,输出报警信息
|
||||
if (allLoginFailEvents.length >= maxFailTimes){
|
||||
out.collect(Warning(allLoginFailEvents.head.userId,
|
||||
allLoginFailEvents.head.eventTime,
|
||||
allLoginFailEvents.last.eventTime,
|
||||
"在2秒之内连续登录失败" + allLoginFailEvents.length + "次"))
|
||||
}
|
||||
|
||||
// 清空状态
|
||||
loginFailState.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
程序运行结果:
|
||||

|
||||
我们可以到`LoginLog.csv`来验证结果
|
||||

|
||||
貌似看到这里感觉我们的程序写的没有错,事实真的是这样的吗?
|
||||

|
||||
那好,现在我改一个数据,把`1558430844`秒的登录状态改成`success`
|
||||

|
||||
然后重新运行一下程序,看看会发生什么?
|
||||

|
||||

|
||||
我了个乖乖,什么情况,现在连结果都没了?
|
||||
|
||||
仔细看代码,才发现我们的思路是没错的,但是还是有 逻辑Bug !
|
||||

|
||||
|
||||
不管一个用户之前连续登录失败多少次,只要中间成功一次,之前的记录就被清空了!
|
||||
|
||||
|
||||

|
||||
|
||||
## 状态编程的改进
|
||||
上一节的代码实现中我们可以看到,**直接把每次登录失败的数据存起来、设置定时器一段时间后再读取,这种做法尽管简单,但和我们开始的需求还是略有差异的**。这种做法<font color='Tomato'>**只能隔2秒之后去判断一下这期间是否有多次失败登录,而不是在一次登录失败之后、再一次登录失败时就立刻报警**</font>。这个需求如果严格实现起来,相当于要判断任意紧邻的事件,是否符合某种模式。
|
||||
|
||||
于是我们可以想到,这个需求其实可以不用定时器触发,直接在状态中存取上一次登录失败的事件,每次都做判断和比对,就可以实现最初的需求。
|
||||
|
||||
上节的代码MatchFunction中删掉onTimer,processElement改为:
|
||||
|
||||
```scala
|
||||
// 自定义处理函数,保留上一次登录失败的事件 [键的类型,输入元素的类型,输出元素的类型]
|
||||
class LoginWarning(maxFailTimes:Int) extends KeyedProcessFunction[Long, LoginEvent, Warning] {
|
||||
|
||||
// 定义 保存登录失败事件的状态
|
||||
lazy val loginFailState: ListState[LoginEvent] = getRuntimeContext.getListState(new ListStateDescriptor[LoginEvent]("loginfail-state", classOf[LoginEvent]))
|
||||
|
||||
override def processElement(value: LoginEvent, ctx: KeyedProcessFunction[Long, LoginEvent, Warning]#Context, out: Collector[Warning]): Unit = {
|
||||
// 首先按照type做筛选,如果success直接清空,如果fail再做处理
|
||||
if(value.eventType == "fail"){
|
||||
// 先获取之前失败的事件
|
||||
val iter: util.Iterator[LoginEvent] = loginFailState.get().iterator()
|
||||
if (iter.hasNext){
|
||||
// 如果之前已经有失败的事件,就做判断,如果没有就把当前失败事件保存进state
|
||||
val firstFailEvent: LoginEvent = iter.next()
|
||||
// 判断两次失败事件间隔小于2秒,输出报警信息
|
||||
if (value.eventTime < firstFailEvent.eventTime + 2){
|
||||
out.collect(Warning( value.userId,firstFailEvent.eventTime,value.eventTime,"在2秒内连续两次登录失败。"))
|
||||
}
|
||||
|
||||
// 更新最近一次的登录失败事件,保存在状态里
|
||||
loginFailState.clear()
|
||||
loginFailState.add(value)
|
||||
|
||||
}else{
|
||||
// 如果是第一次登录失败,之前把当前记录 保存至 state
|
||||
loginFailState.add(value)
|
||||
}
|
||||
}else{
|
||||
// 当前登录状态 不为 fail,则直接清除状态
|
||||
loginFailState.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
这次我们基于上述已经修改过的`LoginLog.csv`文件,重新运行程序,发现此时是有结果的。
|
||||

|
||||
那现在的程序还会有Bug吗?
|
||||

|
||||
当然还有会,例如我们去掉了定时器,如果运行过程中**数据处理乱序**,同一个用户每次登录失败的时间相差距离过大,可能很长一段时间都不会有该用户的报警信息。当然,还有其他的问题,我们放在下面一小节来说!
|
||||
|
||||
|
||||
## CEP编程
|
||||
上一节我们通过对状态编程的改进,去掉了定时器,在process function中做了更多的逻辑处理,实现了最初的需求。不过这种方法里有很多的条件判断,而我们目前仅仅实现的是检测“**连续2次登录失败**”,这是最简单的情形。如果需要检测更多次,**内部逻辑显然会变得非常复杂**。那有什么方式可以方便地实现呢?
|
||||
|
||||
|
||||
很幸运,flink为我们提供了**CEP**`(Complex Event Processing,复杂事件处理)`库,用于**在流中筛选符合某种复杂模式的事件**。
|
||||
|
||||
为了担心小伙伴们对于 **CEP** 这个 “新事物”感到陌生,我们先来补一补`CEP`的内容!
|
||||
|
||||

|
||||
### 什么是复杂事件处理CEP
|
||||
|
||||
>- 复杂事件处理(Complex Event Processing,CEP)
|
||||
>- Flink CEP是在 Flink 中实现的复杂事件处理(CEP)库
|
||||
>- CEP 允许**在无休止的事件流中检测事件模式,让我们有机会掌握数据中重要的部分**
|
||||
>- 一个或多个由简单事件构成的事件流通过一定的规则匹配,然后输出用户想得到的数据 —— 满足规则的复杂事件
|
||||
|
||||
### CEP特点
|
||||
|
||||
如果我们想从一堆图形中找到符合预期的结果,就可以根据某个规则去进行匹配,如下图所示:
|
||||

|
||||
|
||||
> - 目标:从有序的简单事件流中发现一些高阶特征
|
||||
> - 输入:一个或多个由简单事件构成的事件流
|
||||
> - 处理:识别简单事件之间的内在联系,多个符合一定规则的简单事件构成复杂事件
|
||||
> - 输出:满足规则的复杂事件
|
||||
|
||||
|
||||
### Pattern API
|
||||
|
||||
> - **处理事件的规则,被叫做“模式”(Pattern)**
|
||||
> - **Flink CEP 提供了 Pattern API,用于对输入流数据进行复杂事件规则定义,用来提取符合规则的事件序列**
|
||||
>
|
||||
> - **个体模式(Individual Patterns)**
|
||||
> -- 组成复杂规则的每一个单独的模式定义,就是“个体模式”
|
||||

|
||||
>- **组合模式(Combining Patterns,也叫模式序列)**
|
||||
-- 很多个体模式组合起来,就形成了整个的模式序列
|
||||
-- 模式序列必须以一个“初始模式”开始:
|
||||

|
||||
>- **模式组(Groups of patterns)**
|
||||
-- 将一个模式序列作为条件嵌套在个体模式里,成为一组模式
|
||||
>
|
||||
|
||||
### 个体模式(Individual Patterns)
|
||||
- 个体模式可以包括“单例(singleton)模式”和“循环(looping)模式”
|
||||
- 单例模式只接收一个事件,而循环模式可以接收多个
|
||||
|
||||
> ★ 量词(Quantifier)
|
||||
> - 可以在一个个体模式后追加量词,也就是指定循环次数
|
||||
> 
|
||||
### 个体模式的条件
|
||||
> ★ 条件(Condition)
|
||||
> -- 每个模式都需要指定触发条件,作为模式是否接受事件进入的判断依据
|
||||
> -- CEP 中的个体模式主要通过调用 `.where() ` .`or()` 和 `.until() `来指定条件
|
||||
> -- 按不同的调用方式,可以分成以下几类
|
||||
> <br>
|
||||
> ★简单条件(Simple Condition)
|
||||
> -- 通过 `.where() `方法对事件中的字段进行判断筛选,决定是否接受该事件
|
||||
> 
|
||||
> ★组合条件(Combining Condition)
|
||||
> -- 将简单条件进行合并;`.or()` 方法表示或逻辑相连,`where `的直接组合就是 AND
|
||||
> 
|
||||
> ★ 终止条件(Stop Condition)
|
||||
> -- 如果使用了 `oneOrMore` 或者 `oneOrMore.optional`,建议使用 `.until() `作为终止条件,以便清理状态
|
||||
> <br>
|
||||
> ★ 迭代条件(Iterative Condition)
|
||||
> -- 能够对模式之前所有接收的事件进行处理
|
||||
> -- 调用` .where( (value, ctx) => {...} )`,可以调用 `ctx.getEventsForPattern(“name”) `
|
||||
> 提示: name可以是当前个体模式的名称,这个方法可以将之前匹配好的事件从状态中都拿出来,再做具体的判断,使用。一般在比较复杂的场景才会用到。
|
||||
|
||||
|
||||
### 模式序列
|
||||
>
|
||||
>- **不同的“近邻”模式**
|
||||

|
||||
>- **严格近邻**(Strict Contiguity)
|
||||
-- 所有事件按照严格的顺序出现,中间没有任何不匹配的事件,由 **.next()** 指定
|
||||
-- 例如对于模式`a next b`,事件序列 `[a, c, b1, b2] `没有匹配
|
||||
>- **宽松近邻**( Relaxed Contiguity )
|
||||
>-- 允许中间出现不匹配的事件,由 **.followedBy()** 指定
|
||||
-- 例如对于模式`a followedBy b`,事件序列` [a, c, b1, b2] 匹配为 {a, b1}`
|
||||
>- **非确定性宽松近邻**( Non-Deterministic Relaxed Contiguity )
|
||||
>-- 进一步放宽条件,之前已经匹配过的事件也可以再次使用,由 **.followedByAny()** 指定
|
||||
>-- 例如对于模式`a followedByAny b`,事件序列 `[a, c, b1, b2]` 匹配为` {a, b1},{a, b2}`
|
||||
><br>
|
||||
>- 除以上模式序列外,还可以定义“**不希望出现某种近邻关系**”:
|
||||
>-- **.notNext()** —— 不想让某个事件严格紧邻前一个事件发生
|
||||
>-- **.notFollowedBy()** —— 不想让某个事件在两个事件之间发生
|
||||
>- 需要注意:
|
||||
>-- 所有模式序列必须以 **.begin()** 开始
|
||||
>-- 模式序列不能以 **.notFollowedBy()** 结束
|
||||
>-- **“not” 类型的模式不能被 optional 所修饰**
|
||||
>-- 此外,还可以为模式指定**时间约束**,用来要求在多长时间内匹配有效
|
||||
>
|
||||
### 模式的检测
|
||||
> - 指定要查找的模式序列后,就可以将其应用于输入流以检测潜在匹配
|
||||
> - 调用 CEP.pattern(),给定输入流和模式,就能得到一个 PatternStream
|
||||

|
||||
|
||||
### 匹配事件的提取
|
||||
|
||||
>- 创建 `PatternStream` 之后,就可以应用` select `或者 `flatselect `方法,从检测到的事件序列中提取事件了
|
||||
>- select() 方法需要输入一个 select function 作为参数,每个成功匹配的事件序列都会调用它
|
||||
>- select() 以一个 `Map[String,Iterable [IN]] `来接收匹配到的事件序列,其中 key 就是每个模式的名称,而 value 就是所有接收到的事件的 **Iterable** 类型
|
||||
>
|
||||
### 超时事件的提取
|
||||
> - 当一个模式通过 `within` 关键字定义了检测窗口时间时,部分事件序列可能因为超过窗口长度而被丢弃;为了能够处理这些超时的部分匹配,`select `和` flatSelect API` 调用允许指定超时处理程序。
|
||||
>- 超时处理程序会接收到目前为止由模式匹配到的所有事件,由一个 `OutputTag `定义接收到的超时事件序列。
|
||||

|
||||
|
||||
接下来我们就需要基于**CEP**来完成这个模块的实现。
|
||||
|
||||

|
||||
|
||||
|
||||
相关的pom文件我们已经在最开始的时候到导入了,现在在src/main/scala下继续创建`LoginFailWithCep.scala`文件,新建一个单例对象。样例类`LoginEvent`由于在LoginFail.scala已经定义,我们在同一个模块中就不需要再定义。
|
||||
|
||||
具体代码如下:
|
||||
|
||||
```scala
|
||||
object LoginFailWithCep {
|
||||
// 输入的登录事件样例类
|
||||
case class LoginEvent(userId: Long, ip: String, eventType: String, eventTime: Long)
|
||||
|
||||
// 输出的报警信息样例类
|
||||
case class Warning(userId: Long, firstFailTime: Long, lastFailTime: Long, warningMsg: String)
|
||||
|
||||
def main(args: Array[String]): Unit = {
|
||||
|
||||
// 1、创建流环境
|
||||
val env: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment
|
||||
// 设置并行度
|
||||
env.setParallelism(1)
|
||||
// 设置时间特征为事件时间
|
||||
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)
|
||||
|
||||
// 构建数据
|
||||
val loginEventStream: KeyedStream[LoginEvent, Long] = env.readTextFile("G:\\idea arc\\BIGDATA\\project\\src\\main\\resources\\LoginLog.csv")
|
||||
.map(data => {
|
||||
// 将文件中的数据封装成样例类
|
||||
val dataArray: Array[String] = data.split(",")
|
||||
LoginEvent(dataArray(0).toLong, dataArray(1), dataArray(2), dataArray(3).toLong)
|
||||
})
|
||||
// 设置水印,防止数据乱序
|
||||
.assignTimestampsAndWatermarks(new BoundedOutOfOrdernessTimestampExtractor[LoginEvent](Time.seconds(3)) {
|
||||
override def extractTimestamp(element: LoginEvent): Long = element.eventTime * 1000
|
||||
})
|
||||
// 以用户id为key,进行分组
|
||||
.keyBy(_.userId)
|
||||
|
||||
// 定义匹配的模式
|
||||
val loginFailPattern: Pattern[LoginEvent, LoginEvent] = Pattern.begin[LoginEvent]("begin")
|
||||
.where(_.eventType == "fail")
|
||||
.next("next")
|
||||
.where(_.eventType == "fail")
|
||||
.within(Time.seconds(2)) // 通过 within 关键字定义了检测窗口时间时间
|
||||
|
||||
// 将 pattern 应用到 输入流 上,得到一个 pattern stream
|
||||
val patternStream: PatternStream[LoginEvent] = CEP.pattern(loginEventStream,loginFailPattern)
|
||||
|
||||
// 用 select 方法检出 符合模式的事件序列
|
||||
val loginFailDataStream: DataStream[Warning] = patternStream.select(new LoginFailMatch())
|
||||
|
||||
// 将匹配到的符合条件的事件打印出来
|
||||
loginFailDataStream.print("warning")
|
||||
|
||||
// 执行程序
|
||||
env.execute("login fail with cep job")
|
||||
|
||||
}
|
||||
|
||||
// 自定义 pattern select function
|
||||
// 当检测到定义好的模式序列时就会调用,输出报警信息
|
||||
class LoginFailMatch() extends PatternSelectFunction[LoginEvent,Warning]{
|
||||
|
||||
override def select(map: util.Map[String, util.List[LoginEvent]]): Warning = {
|
||||
// 从 map 中可以按照模式的名称提取对应的登录失败事件
|
||||
val firstFail: LoginEvent = map.get("begin").iterator().next()
|
||||
val secondFail: LoginEvent = map.get("next").iterator().next()
|
||||
|
||||
Warning( firstFail.userId,firstFail.eventTime,secondFail.eventTime,"在2秒内连续2次登录失败。")
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
运行结果:
|
||||

|
||||
可以发现也是符合我们预期的效果~
|
||||
|
||||
## 小结
|
||||
本期关于介绍**恶意登录监控**功能开发的文章肝了笔者近五个小时的时间,期望受益的朋友们能来发一键三连,多多支持一下作者。在上一期,我们介绍**实时流量统计**模块中,只介绍了基于**服务器log的热门页面浏览量统计**,下一期我们将介绍基于**埋点日志数据的网络流量统计**,分别介绍`网站总浏览量(PV)的统计`,`网站独立访客数(UV)的统计`还有使用到`使用布隆过滤器的UV统计`,感兴趣的朋友们可以关注加星标,第一时间获取每日的大数据干货哦~你知道的越多,你不知道的也越多,我是Alice,我们下一期见!
|
||||
|
||||
**受益的朋友记得三连支持小菌!**
|
||||
|
||||
>**文章持续更新,可以微信搜一搜「 猿人菌 」第一时间阅读,思维导图,大数据书籍,大数据高频面试题,海量一线大厂面经…期待您的关注!**
|
||||
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user