PV(访问量):即Page View, 即页面浏览量或点击量,用户每次刷新即被计算一次。
UV(独立访客):即Unique Visitor,访问您网站的一台电脑客户端为一个访客。00:00-24:00内相同的客户端只被计算一次。
环境
flink 1.10.0 + kafka 0.11
需求: 实时计算当天零点起,到当前时间的uv
一、模拟测试数据
由于是测试一天的窗口,所以我们将数据的事件时间进行调整,每次增加一个小时,这样平均一天就只发送了12条数据,便于观察最终的测试结果。
好了,上代码:
import java.util.{Calendar, Properties}
import net.sf.json.JSONObject
import org.apache.kafka.clients.producer.{KafkaProducer, ProducerRecord}
import yunqing.stream.window.dayWindow.KafkaUtil.{calendar, sdf}
/**
* 数据测试
*/
object PvUvKafkaUtil {
val brokerList: String = "127.0.0.1:9092"
val topic: String = "test_puv"
def getCreateTime: Long = {
calendar.add(Calendar.HOUR,1) //一天为24h (每次累加1小时)
calendar.getTimeInMillis
}
def main(args: Array[String]): Unit = {
val props = new Properties()
props.put("bootstrap.servers", brokerList)
props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer")
props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer")
val producer = new KafkaProducer[String,String](props)
val range = new scala.util.Random
var i = 0
while(true) {
val json: JSONObject = new JSONObject()
json.put("user_id", range.nextInt(5))
// json.put("timestamp", Calendar.getInstance.getTimeInMillis)
json.put("timestamp", getCreateTime)
json.put("action", "click")
val message: String = json.toString
val msg = new ProducerRecord[String,String](topic, message)
producer.send(msg)
producer.flush()
println(s"send data: $message")
Thread.sleep(3000)
}
print("send success")
}
}
二、构建flink流式处理环境
val env: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)
配置checkpoint:
// checkpoint设置
env.enableCheckpointing(60000) // 1分钟做一次checkpoint
val checkpointConfig: CheckpointConfig = env.getCheckpointConfig
checkpointConfig.setCheckpointingMode(CheckpointingMode.EXACTLY_ONCE) // 精确一次
checkpointConfig.setMinPauseBetweenCheckpoints(30000) // checkpoint的间隔时间
checkpointConfig.setCheckpointTimeout(10000) // checkpoint的超时时间
// canal时保留checkpoint
checkpointConfig.enableExternalizedCheckpoints(CheckpointConfig.ExternalizedCheckpointCleanup.RETAIN_ON_CANCELLATION)
配置状态后端:
// 设置statebackend(状态后端) 为 rockdb (这里的stateBackend声明类型为StateBackend,env.setStateBackend(stateBackend)不会报不推荐使用)
val stateBackend: StateBackend = new RocksDBStateBackend("hdfs://stream-cluster/user/xxx/flink/flink-savepoints")
env.setStateBackend(stateBackend)
三、创建kafka数据源
val properties = new Properties()
properties.setProperty("bootstrap.servers", "127.0.0.1:9092")
properties.setProperty(FlinkKafkaConsumerBase.KEY_PARTITION_DISCOVERY_INTERVAL_MILLIS, "10")
properties.setProperty("group.id", "test_group") // 设置消费者组, 否则状态存储报错
val consumer = new FlinkKafkaConsumer[String]("test_puv", new SimpleStringSchema, properties)
//设置从最早的offset消费
// consumer.setStartFromEarliest()
// 从最新的开始消费,测试使用
consumer.setStartFromLatest()
四、流式计算逻辑
解析json数据,设置水位线:
case class UserClick(userId:String, timestamp:Long, action:String)
val dataStream: DataStream[UserClick] = env.addSource(consumer)
.name("log_user_action")
.map(data => {
val record: JSONObject = JSON.parseObject(data)
UserClick(record.getString("user_id"),
record.getLong("timestamp"),
record.getString("action"))
})
// 乱序数据处理: 设置水位线
val userClickStream: DataStream[UserClick] = dataStream
.assignTimestampsAndWatermarks(
new BoundedOutOfOrdernessTimestampExtractor[UserClick](Time.seconds(30)) {
override def extractTimestamp(value: UserClick): Long = {
value.timestamp
}
})
pv, uv计算逻辑:
这里,设置一天的窗口,使用触发器每20s触发一次。
定义TimeEvictor.of(Time.seconds(0), true) 剔除每条计算过的元素。
val resultStream: DataStream[(String, String, Int)] =
// 按照userId 和 日期 分区, 配置过期状态清除 TTL
// 注意这里啊,状态是基于key的
userClickStream.keyBy(_.userId)
//.windowAll(TumblingEventTimeWindows.of(Time.days(1), Time.hours(-8)))
.window(TumblingEventTimeWindows.of(Time.days(1), Time.hours(-8)))
.trigger(ContinuousEventTimeTrigger.of(Time.seconds(20)))
.evictor(TimeEvictor.of(Time.seconds(0), true))
.process(new MyProcessWindowFunction)
由于存在全局去重及分组操作,flink内部必然要维护一定的状态信息,那么这些状态信息肯定不是要一直保存的,比如uv,我们只需要更新今天,最多昨天的状态,这个点之前的状态要删除的,不能让他白白占着内存,而导致任务内存消耗巨大,甚至因oom而挂掉。
这里需要设置相应的TTL, 进行过期状态清除操作。
为了实现pv, uv的计算以及跨天状态清除的操作,这里我们维护了三个状态,他们分别的含义是:
- uvState:MapState[String, String] : key 的 uv 状态
- pvState:ValueState[Int] : key 的 pv 状态
- windowStartState:ValueState[Long] : key 的窗口开始状态,进行跨天判断,执行状态清除操作
接下来我们实现自己的 ProcessFunction:
class MyProcessWindowFunction extends ProcessWindowFunction[UserClick, (String,String, Int), String, TimeWindow]{
// 自定义状态管理(我们自定义的这些状态,都是Keyed State)
@transient // 不要序列化
var uvState:MapState[String, String] = _
@transient
var pvState:ValueState[Int] = _
// 存储窗口开始时间戳,用以清空状态,从头计算
@Transient
var windowStartState:ValueState[Long] = _
override def open(parameters: Configuration): Unit = {
val uvStateDesc: MapStateDescriptor[String, String] = new MapStateDescriptor[String, String]("uv", classOf[String], classOf[String])
val pvStateDesc: ValueStateDescriptor[Int] = new ValueStateDescriptor[Int]("pv", classOf[Int])
val windowStartStateDesc: ValueStateDescriptor[Long] = new ValueStateDescriptor[Long]("windowStart", classOf[Long])
// 1、创建TTLConfig (过期状态清除)
val ttlConfig: StateTtlConfig = StateTtlConfig
.newBuilder(Time.days(2)) // 这是state的存活时间, 保存两天的状态
.setUpdateType(StateTtlConfig.UpdateType.OnCreateAndWrite) //设置过期时间更新方式
.setStateVisibility(StateTtlConfig.StateVisibility.NeverReturnExpired) //永远不要返回过期的状态
// .cleanupInRocksdbCompactFilter(1000)//处理完1000个状态查询时候,会启用一次CompactFilter
.build()
// 2、开启TTL
uvStateDesc.enableTimeToLive(ttlConfig)
pvStateDesc.enableTimeToLive(ttlConfig)
uvState = this.getRuntimeContext.getMapState(uvStateDesc)
pvState = this.getRuntimeContext.getState(pvStateDesc)
windowStartState = this.getRuntimeContext.getState(windowStartStateDesc)
}
override def process(key: String,
context: Context,
elements: Iterable[UserClick],
out: Collector[(String, String, Int)]): Unit = {
// 计算状态初始化
val windowStart: Long = windowStartState.value()
// 流的第一条数据,触发
if(windowStart == 0) {
windowStartState.update(context.window.getStart)
}
// 跨窗口,clear状态, 更新 windowStartState
if(windowStart != context.window.getStart) {
println(s"当前key:$key windowStartState: $windowStart 数据日期: ${DateUtils.getDateToString(windowStart)} 状态跨天,clear 计算状态...")
// 窗口开始时间戳
// val start: Long = context.window.getStart
// 窗口结束时间戳
// val end: Long = context.window.getEnd
// println(s"开始 start: $start 结束 end: $end ")
// 我这里的状态按理来说,每天计算都会执行一个清空的操作,应该不需要TTL的操作
// 需要探索一下状态clear 和 TTL 的区别???
pvState.clear()
uvState.clear()
// 更新为新的窗口状态
windowStartState.update(context.window.getStart)
}
var pv: Int = 0
val iterator: Iterator[UserClick] = elements.iterator
while(iterator.hasNext) {
pv = pv + 1
val userClick: UserClick = iterator.next()
val userId: String = userClick.userId
uvState.put(userId, null)
}
var uv: Int = 0
val uvIterator: util.Iterator[String] = uvState.keys().iterator()
while(uvIterator.hasNext) {
val next: String = uvIterator.next()
uv = uv + 1
}
val value: Int = pvState.value()
// println(s"pv 状态初始值: $value")
// 这里的状态的初始默认值为0, 对于scala里面的Int不需要做判空操作,初始值就是0
if(value == 0) {
pvState.update(pv)
} else {
pvState.update(value + pv)
}
out.collect((key, "pv", pvState.value()))
out.collect((key, "uv", uv))
}
}
下面还有一个问题需要思考,由于我们使用的是事件时间,事件时间假如事件严重超时了,比如,我们状态保留时间设置的是两天,两天之后状态清除,那么这时候来了事件时间刚刚好是两天之前的,由于已经没有状态就会重新计算uv覆盖已经生成的值,就导致值错误了,这个问题如何解决呢?
这里我们可以使用延时数据处理和侧输出,进行相应的处理。
上代码:
val delayOutputTag: OutputTag[UserClick] = OutputTag[UserClick]("delay-side-output")
val resultStream: DataStream[(String, String, Int)] =
// 按照userId 和 日期 分区, 配置过期状态清除 TTL
// 注意这里啊,状态是基于key的
userClickStream.keyBy(_.userId)
//.windowAll(TumblingEventTimeWindows.of(Time.days(1), Time.hours(-8)))
.window(TumblingEventTimeWindows.of(Time.days(1), Time.hours(-8)))
.trigger(ContinuousEventTimeTrigger.of(Time.seconds(20)))
.evictor(TimeEvictor.of(Time.seconds(0), true))
.allowedLateness(Time.seconds(4))
.sideOutputLateData(delayOutputTag)
.process(new MyProcessWindowFunction)
// 延迟数据
resultStream.getSideOutput(delayOutputTag).print("slide")
总结
Window 算子:是可以设置并行度的,作用于KeyedStream
WindowAll 算子:并行度始终为1, 作用于DataStream
触发器存在四种状态:
1、CONTINUE: 什么也不做
2、FIRE: 触发计算
3、PURGE: 清除窗口中的数据
4、FIRE_AND_PURGE: 触发计算并清除窗口中的数据
注意: 一旦一个触发器决定一个窗口已经准备好进行处理,它将触发并返回FIRE或者FIRE_AND_PURGE。
如果是FIRE的话,将保持window中的内容,FIRE_AND_PURGE的话,会清除window的内容。
默认情况下,预实现的触发器仅仅是FIRE,不会清除window的状态!