问题:统计活跃用户的近7天、30天留存率?
这个是数据仓库开发同学基本都会遇到的问题,属于留存类问题,实现方式也有很多种类,但是在大数据场景下的效率差距很大,因此整理自己写过四种输出留存的方式和对比下优劣。
本文整理工作过程中输出留存的几种方式,分为三类有join方式、窗口函数方式、bitmap方式,若今后还遇到其他方式后续会补充,另外文中sql都是购物的pv中间表作为例子,将上述问题集体化为:访问购物业务的用户在7日和30日后的留存情况,并且附带结果截图,验证每种方式殊途同归。
1.join方式
实现思路是拿用户前一段时间访问记录根据需要维度关联后续一段时间的访问记录,结果得到每个用户的一段时间内的访问日期数。优点:可以同时输出访问频次等其他指标,缺点:刷数慢,在大数据情况下需要join和聚合操作,运行速度慢。
1.1 静态分区
首先固定某一天时间如'2020-09-03' 的访问购物业务的用户作为(a表),然后根据dpid关联后续'2020-09-04' 到 '2020-09-10'之间访问的用户(b表),得到固定日期用户在后续时间内的回访天数,若每个dpid 的回访天数大于0 则表示在后续期间内有回访。具体代码如下:这种低效方式自己居然用了快两年了。
select
hp_cal_dt
,count(distinct if(revisit_days>0,dpid,null)) *1.0 /count(distinct dpid)
from
(
select
a.hp_cal_dt
,a.dpid
-- 回访天数
,count(distinct case when b.hp_cal_dt between '2020-09-04' and '2020-09-10' then b.hp_cal_dt else null end) revisit_days
from
(
select dpid ,hp_cal_dt
from table a
where hp_cal_dt between '2020-09-03' and '2020-09-03'
and platform_id=2
and coalesce(dpid,'')<>''
group by 1,2
) a
left outer join
(
select dpid ,hp_cal_dt
from table a
where hp_cal_dt between '2020-09-04' and '2020-09-10'
and platform_id=2
and coalesce(dpid,'')<>''
group by 1,2
) b
on a.dpid=b.dpid
group by 1,2
) a
group by 1
1.2 动态分区
静态分区的留存写法一次只能输出一天的留存数据并且刷数缓慢,因此对上述方式做扩展,a表和b表都是一段时间内访问购物流量的用户,关联方式和静态分区一样,然后做出一个标记每个dpid时候在7天内是否有回访的标记字段 ,与静态分区写法最大不同是输出日期是动态。
select
hp_cal_dt
,count(distinct if(is_revist_7d=1,dpid,null)) *1.0 /count(distinct dpid)
from
(
select
a.hp_cal_dt
,a.dpid
,max(case when datediff(b.hp_cal_dt,a.hp_cal_dt) between 1 and 7 then 1 else 0 end) is_revist_7d
from
(
select dpid ,hp_cal_dt
from table a
where hp_cal_dt between date_add('2020-09-10',-10) and '2020-09-10'
and platform_id=2
and coalesce(dpid,'')<>''
group by 1,2
) a
left outer join
(
select dpid ,hp_cal_dt
from table a
where hp_cal_dt between date_add('2020-09-10',-10) and '2020-09-10'
and platform_id=2
and coalesce(dpid,'')<>''
group by 1,2
) b
on a.dpid=b.dpid
group by 1,2
) a
group by 1
2.开窗函数
动态分区的方式可以解决一部分的刷数问题,但是动态分区输出一个月或者时间跨度大的留存数据 或者 当业务场景需要留存的粒度需要不同维度的信息关联,在join时候会产生大量的shuffle,导致整个表运行缓慢,因此需要用开窗函数的方式,可以避免的join方式输出留存的写法。具体思路是找到每一个当天访问的用户在之后最近一次访问的日期,两个日期的差距小于7或者30天,则标识为回访。具体代码如下:
select
hp_cal_dt
,count(distinct if(datediff(last_hp_cal_dt,hp_cal_dt)>= 1 and datediff(last_hp_cal_dt,hp_cal_dt)<=7 ,dpid,null) ) *1.0/count(distinct dpid) as revisit_7d_ratio
from
(
select
dpid
,hp_cal_dt
-- 找到用户之后最近一次访问的日期
,lead(hp_cal_dt,1,'9999-12-31') over(partition by dpid order by hp_cal_dt) as last_hp_cal_dt
from
(
select dpid ,hp_cal_dt
from xx.dw_pv_d
where hp_cal_dt between date_add('2020-09-10',-30) and '2020-09-10'
and platform_id=2
and coalesce(dpid,'')<>''
group by 1,2
) a
) b
group by 1
3.bitmap方式
bitmap方式很早知道陆老师实现过,爬了爬代码没看懂,并且实现方式用了udf方式,一直想复现学会很遗憾还是没看懂,最近看了另外的一种bitmap思想感觉比较容易懂,动手操作了下感觉效率很高尤其的大量数据情况下,主要都是用原生的基本函数实践,后续可能还有优化空间会继续优化,并且输出留存代码比较比之前两种方式都方便和灵活。
bitmap概念:bitmap 就是用一个 bit 位来标记某个元素,而数组下标是该元素,该元素是否存在时用 bit 位的 1,0 表示,好处是大大节省了空间。
实现媒介:
通过数组下标表示距离天数的思想,将最近 31 天访问活跃用户用 0 1 串存储下来,形成一个字符串,每一个下标表示距离最新一天数据的天数差值,第一位下标为 1,表示距离今天最新一天数据间隔为 1 天,最后一个字符表示距离今天31天
dpid |
bitmap_str |
计算日期 |
---|---|---|
430e1d91bd0e48358714930d5b963168a1559130835560417220 |
0010000101011000000100000010000 |
2020-09-10 |
5366801702994917910 |
0010000000010000000000000001000 |
2020-09-10 |
19611506617687796450 |
0010000000000000001100011100011 |
2020-09-10 |
37423309784516516910 |
1010000100100111100000000000000 |
2020-09-10 |
61341411986802557510 |
1110000100000011010000000000000 |
2020-09-10 |
目标模型schema : dpid、bitmap_str(31位)、hp_cal_dt 计算日期
bitmap_str说明
bitmap_str中1标识对应下标(距离今天n天前)有活跃,0标识不活跃;
模型的实现方式
目标:制作一个用户粒度,字段是dpid和近31天访问明细bitmap字符串的模型。
1.模型的前31天初始化集合,一次性将31天用户访问记录写到模型中,然后一天一天滚动累计。
2.最新一天需要统计时,需要拿前一天的集合表,剔除掉相对今天来说第 31 天前的数据,然后每个集合字段将最后一位删除掉。
3.拿第 2 步处理后的前一天 (下面用 a 表替代) full join 最新一天的增量数据表(下面用 b 表替代)关联
-- a 表是近31天访问记录,b表每天增量表,dpid有三种情况
--1.a表、b表 都在, 1拼接a表访问记录
--2.a表不在、b表在,1拼接30个0的字符
--3.a表在、b表不在,0拼接a表访问记录
测试模型
INSERT OVERWRITE TABLE `${target.table}` PARTITION (hp_cal_dt='$now.date')
-- 初始化每个dpid的近31天访问记录的bitmap
#if $isDELTARELOAD
select
dpid,reverse(lpad(cast(bin(dpid_bitmap) as string),31,0) ) as bitmap_str
from
(
select
dpid, sum(cast(power(2,datediff('$now.date',hp_cal_dt)) as bigint)) as dpid_bitmap
from
(
select dpid ,hp_cal_dt
from mart_shplat.dpdw_platform_shopping_reduced_pv_d
where hp_cal_dt between date_add('$now.date',-30) and '$now.date'
and platform_id=2
and coalesce(dpid,'')<>''
group by 1,2
) a
group by 1
) a;
#else
-- 增量更新最新一天访问记录
select
if(a.dpid is null ,b.dpid,a.dpid) as dpid
-- a 表是近31天访问记录,b表每天增量表,dpid有三种情况
--1.a表、b表 都在, 1拼接a表访问记录
--2.a表不在、b表在,1拼接30个0的字符
--3.a表在、b表不在,0拼接a表访问记录
,case
when a.dpid=b.dpid then concat('1',substr(bitmap_str,1,30))
when a.dpid is null then concat('1','000000000000000000000000000000')
when b.dpid is null then concat('0',substr(bitmap_str,1,30))
end as dpid_bitmap
from mart_shplat_test.dpmid_platform_shopping_mtshop_coop_online_d a
full outer join
(
select
dpid ,hp_cal_dt
from mart_shplat.dpdw_platform_shopping_reduced_pv_d
where hp_cal_dt = '$now.date'
and platform_id=2
group by 1,2
) b
on a.dpid=b.dpid
where a.hp_cal_dt = '$now.delta(days=1).date'
;
#end if
##TargetDDL##
## 目标表的表结构定义,只有执行SQL前表不存在时,才会根据ddl建表。请确保SQL中insert的字段和目标表数目、顺序一致!!
CREATE TABLE IF NOT EXISTS `${target.table}`
(
dpid string ,
bitmap_str string
)
COMMENT "购物dpid留存bitmap方式"
PARTITIONED BY (hp_cal_dt string COMMENT "日期")
STORED AS ORC;
bitmap字符串方式计算留存方式比较灵活,有多种写法,以下提供一种参考
select
date_add(hp_cal_dt,-7) as dt
,datediff(hp_cal_dt,'2020-09-03') as dtdiff
,sum(if(substr(bitmap_str,8,1)='1',1,0)) as bef_8d_visit_cnt
,sum(if(substr(bitmap_str,8,1)='1' and length(regexp_replace(substr(bitmap_str,1,7),'0',''))>0 ,1,0) ) as revisit_cnt
-- 近七日留存率
,sum(if(substr(bitmap_str,8,1)='1' and length(regexp_replace(substr(bitmap_str,1,7),'0',''))>0 ,1,0) ) *1.0 /sum(if(substr(bitmap_str,8,1)='1',1,0)) as revisit_7d_ratio
from mart_shplat_test.dpmid_platform_shopping_mtshop_coop_online_d
where hp_cal_dt = '2020-09-10'
group by 1,2
结论:几种方式计算的留存结果是一致的,但是个人最倾向于最后一种方式,优点 :1.减少了大量shuffle计算 如没有 distinct 和 join 逻辑(生成bitmap_str字段之后) ;2.扩展性强 和join 、窗口函数方式功能一样,还能实现30天以上更长时间的留存率计算,根据业务场景留存粒度也可以自由添加维度(dpid+页面等);3.大大压缩了存储,若用户极端状态31天都有活跃,将其压缩为一个长度的31的字符串表示 4、不需要借助udf函数。
1.bitmap 方式扩展到超过31天以上留存设计,可以看到本文设计的bitmap思路是将每个dpid的访问记录存成一个bigint类型的数据,然后转换成字符串 ,因此主要瓶颈在于bigint的范围(一个 bigint8 个字节共 8*8 = 64 位),所以存储访问记录超过60天,bigint可能有溢出风险,可以采用bigint数组方式存储访问记录 bitmap_arr[bigint,bigint.........],数组的第一个下标表示1-128天访问记录,第二个下标表示129-256天的访问记录.......。这种方式需要借助udf函数,后续待更新