留存率是用于反映网站、互联网应用或网络游戏的运营情况的统计指标,其具体含义为在统计周期(周/月)内,每日活跃用户数在第N日仍启动该App的用户数占比的平均值。其中N通常取2、4、8、15、31,分别对应次日留存率、三日留存率、周留存率、半月留存率和月留存率。

留存率常用于反映用户粘性,当N取值越大、留存率越高时,用户粘性越高。

公式

新增用户留存率=新增用户中登录用户数/新增用户数*100%(一般统计周期为天)

新增用户数:在某个时间段(一般为第一整天)新登录应用的用户数;

登录用户数:登录应用后至当前时间,至少登录过一次的用户数;

第N日留存:指的是新增用户日之后的第N日依然登录的用户占新增用户的比例

第1日留存率(即“次留”):(当天新增的用户中,新增日之后的第1天还登录的用户数)/第一天新增总用户数;

第3日留存率:(当天新增的用户中,新增日之后的第3天还登录的用户数)/第一天新增总用户数;

第7日留存率:(当天新增的用户中,新增日之后的第7天还登录的用户数)/第一天新增总用户数;

第30日留存率:(当天新增的用户中,新增日之后的第30天还登录的用户数)/第一天新增总用户数;
  • 偷个懒,算个“1日留存率”

分析

公式: 第1日留存率(即“次留”):(当天新增的用户中,新增日之后的第1天还登录的用户数)/第一天新增总用户数

从公式简单推理,只需要拿到“前一天新增用户数” 和 “前一天新增用户数今日登录数”

  • 前一天新增用户数: 要获取新增的用户数,必须要有全量的用户列表,并且全量用户列表中应该有每个用户的注册时间(任务开始时,需要昨天的用户列表,计算当天的留存)

为了方便用户列表放到 mysql 中,表结构如下:

create table user_info
(
	id bigint auto_increment primary key,
	user_id bigint null,
	register_day varchar(10) null comment '注册时间,格式yyyy-MM-dd',
	create_time timestamp default CURRENT_TIMESTAMP null,
	update_time timestamp default CURRENT_TIMESTAMP null
)
comment '用户注册时间';

user_info 表,全量用户列表,有每个用户的注册时间,任务启动时,加载全量用户列表,并提取昨日注册用户

  • 新增日之后的第1天还登录的用户数: 已经有了昨日注册用户,这部分用户的登录就很容易获取

代码

flink 最近这几个版本用户界面的变化也挺多的,写个代码做个记录

任务 DAG 如下:

Python用户留存率 如何计算用户留存率_mysql

kafka source -> map 转换 json 成对象 -> 提取 timestamp -> 提取 watermark -> key 所以数据到一个 process -> window -> trigger -> process 计算 -> kafka sink 输出

Checkpoint 配置

// 每 1000ms 开始一次 checkpoint
env.enableCheckpointing(5 * 60 * 1000)
// 高级选项:
// 设置模式为精确一次 (这是默认值)
env.getCheckpointConfig.setCheckpointingMode(CheckpointingMode.EXACTLY_ONCE)

// 确认 checkpoints 之间的时间会进行 500 ms
env.getCheckpointConfig.setMinPauseBetweenCheckpoints(5 * 60 * 1000)

// Checkpoint 必须在一分钟内完成,否则就会被抛弃
env.getCheckpointConfig.setCheckpointTimeout(10 * 60 * 1000)

// 允许两个连续的 checkpoint 错误
env.getCheckpointConfig.setTolerableCheckpointFailureNumber(10)

// 同一时间只允许一个 checkpoint 进行
env.getCheckpointConfig.setMaxConcurrentCheckpoints(1)

// 使用 externalized checkpoints,这样 checkpoint 在作业取消后仍就会被保留
env.getCheckpointConfig.setExternalizedCheckpointCleanup(ExternalizedCheckpointCleanup.RETAIN_ON_CANCELLATION)

// storage path
val checkpointStorage = new FileSystemCheckpointStorage(checkpointPath)
env.getCheckpointConfig.setCheckpointStorage(checkpointStorage)
// rocksdb
env.setStateBackend(new EmbeddedRocksDBStateBackend(true))

Kafka Source/Sink

val kafkaSource = KafkaSource
      .builder[KafkaSimpleStringRecord]()
      .setBootstrapServers(bootstrapServer)
      .setGroupId("ra")
      .setTopics(topic)
      .setStartingOffsets(OffsetsInitializer.latest())
      .setDeserializer(new SimpleKafkaRecordDeserializationSchema())
      .build()

val kafkaSink = KafkaSink
      .builder[String]()
      .setBootstrapServers(bootstrapServer)
      .setKafkaProducerConfig(Common.getProp)
      .setRecordSerializer(KafkaRecordSerializationSchema.builder[String]()
        .setTopic(sinkTopic)
        .setKeySerializationSchema(new SimpleStringSchema())
        .setValueSerializationSchema(new SimpleStringSchema())
        .build()
      )
      .build()

Kafka Source 自定义了一个 反序列化器 解析 Kafka 数据,将 TopicPartition、Offset、Key、Value、timestamp 添加到对象

public class KafkaSimpleStringRecord implements Serializable {
    private static final long serialVersionUID = 4813439951036021779L;
    // kafka topic partition
    private final TopicPartition tp;
    // record kafka offset
    private final long offset;
    // record key
    private final String key;
    // record timestamp
    private final long timestamp;
    // record value
    private final String value;
}

Timestamp & Watermark

// default is IngestionTime, kafka source will add timestamp to StreamRecord,
// if not set assignAscendingTimestamps, use StreamRecord' timestamp, so is ingestion time
.assignAscendingTimestamps(userLog => DateTimeUtil.parse(userLog.ts).getTime)
// create watermark by all elements
.assignTimestampsAndWatermarks(new WatermarkStrategy[UserLog] {
override def createWatermarkGenerator(context: WatermarkGeneratorSupplier.Context): WatermarkGenerator[UserLog] = {
  new WatermarkGenerator[UserLog] {
    var watermark: Watermark = new Watermark(Long.MinValue)

    override def onEvent(event: UserLog, eventTimestamp: Long, output: WatermarkOutput): Unit = {
      val timestamp = DateTimeUtil.parse(event.ts).getTime
      watermark = new Watermark(timestamp - 1)
      output.emitWatermark(watermark)
    }

    override def onPeriodicEmit(output: WatermarkOutput): Unit = {
      output.emitWatermark(watermark)
    }
  }
}
})

从数据中提取 ts 字段转为 Long 类型,作为数据的 timestamp
自定义 WatermarkGenerator,从每个输入数据中提取 ts 字段,创建 watermark(Flink 内部会过滤 当前 watermark 小于上一个 watermark 的 watermark)

窗口

天的窗口和10 分钟的 trigger

.window(TumblingEventTimeWindows.of(Time.days(1), Time.hours(-8)))
.trigger(ContinuousEventTimeTrigger.of(Time.seconds(10 * 60)))

process 计算

open: 创建 状态对象
process: 从当日数据中计算昨日新增用户进入登录
clear: 每个窗口结束的时候,清空昨日新增用户map,将今日新增用户放入昨日新增用户map和全部用户map

open 方法

启动时调用,创建状态对象,连接 mysql,加载历史用户列表

/**
   * open: load all user and last day new user
   *
   * @param parameters
   */
  override def open(parameters: Configuration): Unit = {
    LOG.info("RetentionAnalyzeProcessFunction open")
    // create state
    allUserState = getRuntimeContext.getState(new ValueStateDescriptor[util.HashMap[String, Int]]("allUser", classOf[util.HashMap[String, Int]]))
    lastUserState = getRuntimeContext.getState(new ValueStateDescriptor[util.HashMap[String, Int]]("lastUser", classOf[util.HashMap[String, Int]]))
    currentUserState = getRuntimeContext.getState(new ValueStateDescriptor[util.HashMap[String, Int]]("currentUser", classOf[util.HashMap[String, Int]]))

    // connect mysql
    reconnect()
    // load history user
    loadUser()
  }

clear 方法

每个窗口结束的时候调用,用于处理窗口结束事件,在这里处理了一下 当天、昨日、全量用户map, side 当日用户

/**
   * output current day new user
   *
   * @param context
   */
  override def clear(context: Context): Unit = {
    val window = context.window
    LOG.info(String.format("window start : %s, end: %s, clear", DateTimeUtil.formatMillis(window.getStart, DateTimeUtil.YYYY_MM_DD_HH_MM_SS), DateTimeUtil.formatMillis(window.getEnd - 1, DateTimeUtil.YYYY_MM_DD_HH_MM_SS)))
    // clear last user, add current user as last/all user map
    lastUserMap.clear()
    lastUserMap.putAll(currentUser)
    allUserMap.putAll(currentUser)
    lastUserState.update(lastUserMap)
    allUserState.update(allUserMap)
    // export current user to mysql userInfo
    //    exportCurrentUser(window)
    val day = DateTimeUtil.formatMillis(window.getStart, DateTimeUtil.YYYY_MM_DD)
    context.output(sideTag, (day, currentUser))
    //    currentUser.keySet().forEach(item => {
    //      context.output(sideTag, (day, item))
    //    })
    // clear current user
    currentUser.clear()
  }

process 方法

创建一个 HashMap 用了存放昨日新增用户进入的登录情况,一个用户可以多次登录,所以用 map 重复的直接去掉

把不在 全量用户map 里的 user_id 放到 当天用户 map

// loop window element, find last user and current user
val it = elements.iterator
val lastUserLog = new util.HashMap[String, Int]
while (it.hasNext) {
  val userLog = it.next()
  if (lastUserMap.containsKey(userLog.userId)) {
    lastUserLog.put(userLog.userId, 1)
  }
  if (!allUserMap.containsKey(userLog.userId)) {
    currentUser.put(userLog.userId, 1)
  }
}

如果昨日用户map 为 null,留存率为 0,反之,用 “昨日新增用户今日登录数 / 昨日新增用户数”(取 double)

var str: String = null
var retention: Double = 0
if (!lastUserMap.isEmpty) {
  retention = lastUserLog.size().toDouble / lastUserMap.size()
}
str = day + "," + time + "," + allUserMap.size() + "," + lastUserMap.size() + "," + currentUser.size() + "," + retention
out.collect(str)

side 当日新增用户

写 mysql 有点慢,要比较长时间,就改用 side output 输出,还是慢(是 mysql 的原因,可以先写到 kafka,再转到 mysql),就这样了

// sink current day user to mysql, cost a lot time
val sideTag = new OutputTag[(String, util.HashMap[String, Int])]("side")
val jdbcSink = JdbcSink
  .sink("insert into user_info(user_id, login_day) values(?, ?)", new JdbcStatementBuilder[(String, String)] {
    override def accept(ps: PreparedStatement, element: (String, String)): Unit = {
      ps.setString(1, element._2)
      ps.setString(2, element._1)
    }
  }, JdbcExecutionOptions.builder()
    .withBatchSize(100)
    .withBatchIntervalMs(200)
    .withMaxRetries(5)
    .build(),
    new JdbcConnectionOptions.JdbcConnectionOptionsBuilder()
      .withUrl("jdbc:mysql://localhost:3306/venn?useUnicode=true&characterEncoding=utf8&useSSL=false&allowPublicKeyRetrieval=true")
      .withDriverName("com.mysql.cj.jdbc.Driver")
      .withUsername("root")
      .withPassword("123456")
      .build())

stream.getSideOutput(sideTag)
  .flatMap(new RichFlatMapFunction[(String, util.HashMap[String, Int]), (String, String)]() {
    override def flatMap(element: (String, util.HashMap[String, Int]), out: Collector[(String, String)]): Unit = {
      val day = element._1
      val keySet = element._2.keySet()
      keySet.forEach(item => {
        out.collect((day, item))
      })
    }
  })
  .addSink(jdbcSink)
  .name("jdbcSink")
  .uid("jdbcSink")

测试数据

{"category_id":22,"user_id":"257054","item_id":"22732","behavior":"pv","ts":"2022-04-12 22:56:51.000"}
{"category_id":83,"user_id":"506782","item_id":"83818","behavior":"pv","ts":"2022-04-12 22:57:01.000"}
{"category_id":15,"user_id":"196658","item_id":"15335","behavior":"pv","ts":"2022-04-12 22:57:11.000"}
{"category_id":78,"user_id":"715098","item_id":"78131","behavior":"pv","ts":"2022-04-12 22:57:21.000"}
{"category_id":0,"user_id":"374494","item_id":"0714","behavior":"pv","ts":"2022-04-12 22:57:31.000"}
{"category_id":41,"user_id":"651691","item_id":"41995","behavior":"fav","ts":"2022-04-12 22:57:41.000"}
{"category_id":55,"user_id":"725849","item_id":"55589","behavior":"pv","ts":"2022-04-12 22:57:51.000"}

注: 为了方便测试,在模拟数据中给每条数据都递增 10 s,并且视所有数据都是用户登录

输出结果

逗号分割,依次为: 日期,时间,全量用户数,昨日新增用户数,今日新增用户数,留存率
第一天执行时无昨日数据,流程率为 0
后续执行就有留存率

2022-04-25,12:00:00,0,0,873,0.0
2022-04-25,12:10:00,0,0,933,0.0
2022-04-25,12:20:00,0,0,993,0.0
.....
2022-04-26,00:40:00,5170,5170,240,1.9342359767891682E-4
2022-04-26,00:50:00,5170,5170,300,1.9342359767891682E-4
2022-04-26,01:00:00,5170,5170,359,3.8684719535783365E-4
2022-04-26,01:10:00,5170,5170,419,3.8684719535783365E-4

每天的数据,随着登录的用户越来越多,留存率会越来越大

  • 注: 每个窗口结束时会发现任务有积压,是写 mysql 比较慢,导致积压的,可以先写 kafka 再转 mysql

全部代码参考 Github flink-rookie