Flink计算天级别PV,UV案例及问题分析

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的状态!

  • 3
    点赞
  • 33
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 4
    评论
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

雾岛与鲸

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值