app活跃设备数以及留存率,越来越受到大家的关注,从网站用户到客户端产品,游戏产品,无线APP产品,都非常重视这一指标,留存率成为衡量一个产品是否健康成长的重要指标之一。

概述

最近两个月一直在做用户行为分析统计,期间多次对实现方案进行调整优化,走了很多弯路,总体目标力求简单粗暴!当然,这段时间生活也变得异常充实,几乎到了”寝食难安”的地步,晚上睡觉在想解决方案,有时想到方案会立刻起来测试。总体来说,两点体会。

  1. 基本上没有什么问题是无法解决的,区别在于实现的是否足够优雅!
  2. 如果你担心某个问题会发生,不要心存侥幸,它一定会发生!

闲言少叙,书归正传!
ps: 作为第三方apm提供商,是没有权限获取某个用户在该app的注册信息的。因此,这里的用户分析概念仅针对用户手持设备如手机而言. 直接看架构设计

关于app活跃设备数、新增、留存

app活跃设备

如果某个用户手机装载了某个mobile app,那么该用户的手机设备则称之为该app的一个活跃设备。为了区分不同用户的手机设备,为每个设备生成一个唯一id标识,这里称之为设备did。作为第三方apm服务提供方,是没有权限获取用户在该app的注册信息的,而对于app厂商来说可直接通过用户账号区分不同的用户。因此这里引入了设备did概念。这样,便可以根据需求对不同的app进行活跃设备数统计。根据需求有以下几个统计维度:

  1. app日/周/月活跃设备数统计,即key:timestamp+app_id, value:count(distinct did)
  2. app不同版本的日/周/月活跃设备数统计,即key:timestamp+app_id+app_version_id, value:count(distinct did)

新增设备

所谓新增设备,即第一次装载某app的用户手持设备。但是,如果某个app的同个用户在不同的手持设备进行登陆,因为apm提供方无法根据用户账号进行区分不同用户,因此如果此时将新增设备视为app新增用户则会出现误差。这里除了为该新增设备生成唯一did之外,还要再做一个新增标记,记为fresh_flag:1.这样便可以统计某个app的日/周/月新增设备数。

设备留存

所谓留存,比如某个用户在今天安装了微信,那么该用户安装微信的手机则视为微信app的一个新增设备。如果该用户明天登陆了微信,那么该手机设备称之为1日后留存。同理,对于周留存、月留存,即该用户在此后一周或一个月的某个时刻登陆了。根据具体的统计需求可能会有1/2/3/4…周/月后留存。

活跃设备消息

这里的活跃设备消息指的是活跃设备数统计时针对的消息实体。定期上传到收集服务器.消息可能包括以下几个字段:

  1. app_id:为不同app生成的唯一id,比如微信/美团app
  2. app_version_id: app不同版本的 id
  3. timestamp: 设备登陆时间
  4. did
  5. fresh_flag: 1.新增 2.非新增

统计维度

活跃设备数统计

  1. timestamp+app_id维度app级别的活跃设备统计,根据时间维度的不同具体可分为日/周/月活
  2. timestamp+app_id+app_version_id维度的app版本级别日/周/月活

新增设备数统计

  1. timestamp+app_id+fresh_flag维度app级别的活跃设备统计,根据时间维度的不同具体可分为日/周/月新增活
  2. timestamp+app_id+app_version_id+fresh_flag维度app版本级别日/周/月新增活

留存统计

  1. timestamp+app_id维度app级别的留存设备统计,根据时间维度的不同具体可分为日/周/月留存率
  2. timestamp+app_id+app_version_id维度的app版本级别日/周/月留存率

根据产品需求有以下几个时间区间的留存率:

  1. 1/2/3/4/5/6/7/14/30 日后app级别和app版本级别日留存
  2. 1/2/3/4/5/6/7 周后app级别和app版本级别周留存
  3. 1/2/3/4/5/6/7 月后app级别和app版本级别月留存

原有架构

以前只有日活和月活统计功能,主要通过HyperLogLog算法实现统计。虽然HyperLogLog存在一定误差,但基本在可接受范围。这里主要描述对于用户行为分析功能原有架构存在的各种问题.

  1. HyperLogLog算法本身是一种基数估计算法,无法做到精确统计
  2. 对于新增以及留存率这种精确统计需求,很显然通过HyperLogLog无法实现。即便实现了误差率也很难控制。
  3. 由于历史原因,现有架构的活跃设备统计与其他业务耦合度较高。(这里不做具体阐述)
  4. 统计效率问题。原有架构时时将活跃设备消息导出到本地文件,按天分目录。在第二天扫描前一天的活跃设备文件生成HyperLogLog中间结果,再通过HyperLogLog合并完成日活月活统计。由于数据量较大,往往要到次日下午才能统计出前一日的活跃设备统计结果。

架构设计

设计目标:

  1. 在不影响现有业务的前提下,足够简单
  2. 节约成本
  3. 精确统计
  4. 保证统计准确性的前提下,尽可能提升统计效率
  5. 伸缩性,在数据量大量增长的情况下可通过动态增加处理节点,满足处理需求
  6. 健壮性,在依赖的组件中部分组件出现状况的情况下,保证数据不丢失
  7. 方便迁移、程序扩展

存储架构

想要实现精确统计,别无他法,只能在原始消息的基础上进行聚合统计。那么,首先要解决的是数据存储问题。目前活跃设备消息按天导出,每天大概有几十G文件大小。考虑月留存有9月后留存,原始数据则至少要存储10个月之久。不过考虑到目前的产品需求,活跃设备统计时间粒度最细到天,因此通过消息按天去重则可以过滤掉很多重复消息。同理,对于周/月活统计,同样可以通过将时间truncate到周一和每月第一天日期,这样又可以节省很大一部分存储开销。初步设计以下三张表:

  1. 日活表 timestamp(truncate to 当天零点时间), app_id, app_version_id, did, fresh_flag
  2. 周活表 timestamp(truncate to 所在周第一天零点), app_id, app_version_id, did, fresh_flag
  3. 月活表 timestamp(truncate to 所在月第一天零点), app_id, app_version_id, did, fresh_flag

基于去重考虑,首先想到的是hbase,因为hbase能达到自动去重的效果。但是,hbase不太适合做维度统计。此外,hbase rowkey的设计关乎统计的执行效率。即便有impala这种架构在hbase之上的组件,实际测试之后发现统计性能不太理想。权衡之后,最终选择了GreenPlum。主要有以下两方面原因:

  1. 灵活的查询统计支持,除了支持group by这种维度聚合需求,同时支持join操作。数据量在二十亿级情况下,实际测试能达到分钟级别以下响应。
  2. 入库性能较高,通过batch copy的方式一万条消息在秒级完成

处理架构

  1. data collector程序接收到一条活跃设备消息,按did hash路由到kafka topic不同分区。方便后期去重。
  2. dc-active-devices-etl程序消费kafka中的消息进行去重,GP入库
  3. dc-active-devices-etl处理流程图:
    etl

关于dc-active-devices-etl处理程序:

  1. 对于消息去重,测试了三种去重方案。
    (1)redis bloom filter去重准确率较高,但是效率不高,单条消息平均在几十毫秒左右。
    (2)hbase去重,处理较为复杂,需要执行查询以及入库动作。通过batch的方式处理性能基本能满足需求。但相对redis来说不够简单,稳定性也有待商榷。
    (3)redis sadd set结构,去重准确性以及效率都比较高。但需要较高的存储成本。稳定性有待后续继续观察。
  2. 对于单个活跃设备消息,如果当天已经重复那么肯定当周当月也已经重复。因此建议先进行天去重。
  3. 考虑到GP入库可能会出现失败的情形,当出现入库失败时将数据导出到指定目录,而后定时扫描失败目录,尝试重新入库。
  4. 由于GP统计时使用的是distinct方式,因此即使redis去重失败,GP中出现重复消息也不影响统计结果。消息去重的最终目的是节约存储成本。
  5. 当程序处理压力较大时,可适当增加kafka分区数以及dc-active-devices-etl节点数
  6. 对于kafka之所以按did路由,主要是为了方便本地缓存去重,减轻redis压力。如果不做本地缓存,则可以采用kakfa默认路由方式,这样kafka各个分区的消息量会比较均匀。(在消息量很大的情况下,其实本地缓存去重优化比较有限,同时也要考虑内存使用情况)

统计架构

  1. 活跃设备统计以及新增统计比较简单,group 操作即可。新增统计只需附加where条件fresh_flag=1
  2. 留存统计,可通过表自身join实现。比如下面的2016-11-02日所有app版本级别新增在2016-11-03的一日后留存统计sql
    1
    2
    3
    select fresh.mobile_app_id, fresh.mobile_version_id, count(distinct fresh.did) as retention_count from MOB_APP_VERSION_USAGE_STAT_DAY fresh
    join MOB_APP_VERSION_USAGE_STAT_DAY normal on fresh.did = normal.did where fresh.timestamp = '2016-11-02' and fresh.flag = 1 and normal.timestamp = '2016-11-03'
    group by fresh.mobile_app_id, fresh.mobile_version_id

一些建议

  1. 由于业务的特殊性,gp中的三个时间粒度的天、周、月数据表,可通过day/week/month进行分区。这样可以很大程度上优化查询效率。gp只需扫描相应时间分区即可。
  2. 考虑到减轻gp压力,所有大数据量的sql查询统计尽量以串行方式执行。
  3. 考虑到查询失败的情况,需要程序支持失败重试机制。
  4. 对于gp统计的结果可写入到mysql供其他业务组使用,尽量避免其他业务组直接查询gp.
  5. 尽量不要对gp进行update操作,update时gp锁住整个更新表,进而阻塞其他程序对该表的入库操作。
  6. gp支持表中单个字段的索引,这样可以在一定程度上优化查询效率

总结

两个月的时间,尝试了多种方案,这里就不再一一表述了。让组件只做它最擅长的事情,力求简单直接.说了一大堆,其实核心就是GreenPlum而已.一个问题总会有很多种解决方式,遇到问题换种思路说不定会豁然开朗。在初期设计时,day/week/month表我设计了另外27个留存字段,redis缓存了新增did以及新增日期,这样以后该did再上线时根据上线日期和新增日期相差的天数便可时时计算出留存。这种思路存在了很长时间,而在遇到gp时发现效果并不不理想。主要原因该种实现方式需要对gp进行时时更新。同时在留存统计时也不太方便。后来换个思路通过join操作实现,豁然开朗.当然,对于app开发厂商来说只需统计自己的app活跃用户即可,实现方案也相对较多,区别在于数据量级的不同。

其他

目前该项目刚上线,对于亿条记录的day表,每天需要统计的日活/日新增/日留存以及其他统计共18条sql,在三分钟之内完成统计。

Comments