什么是留存,好比在20200701这天操做了“点击banner”的用户有100个,这部分用户在20200702这天操做了“点击app签到”的有20个,那么对于分析时间是20200701,且“点击banner”的用户在第二天“点击app签到”的留存率是20%。javascript
关于用户留存模型是各大商业数据分析平台必不可少的功能,企业通常用该模型衡量用户的活跃状况,也是能直接反应产品功能价值的直接指标;如,boss想要了解商城改版后,对用户加购以及后续下单状况的影响等。以下图,这就是一个典型的留存分析功能:
java
问题
一般实现上述需求的传统作法是多表关联,了解clickhouse的攻城狮都清楚,多表关联简直就是clickhouse的天敌;如一张用户行为日志表中至少包含:用户id、行为事件、操做时间、地点属性等,想分析20200909日河南省注册用户第二天的下单状况,那么SQL通常会这么写:算法
select count(distinct t1.uid) r1, count(distinct t2.uid) r2 from( select uid from action_log where day='20200909' and action='login' and province='河南省') as t1 left join( select uid from action_log where day='20200910' and action='order' and province='河南省') as t2using uid
这种方式书写简单、好理解,可是性能会不好,在超大数据集上进行运算是不只仅影响用户体验,还会因长期占有物理资源而拖垮整个clickhouse上的业务。sql
解决方法有两种:shell
使用clickhouse自带的retention函数数据库
Roaringbitmap 经过对数据进行压缩和位运算提升查询性能小程序
Roaringbitmap
经过Roaringbitmap进行用户行为分析是腾讯广告业务中经常使用的一种实现方案,点击查看 ,文章中内容较多这里挑选干货进行讲解:数组
bitmap能够理解为一个长度很长且只存储0/1数字的集合,如某个用户经过特定的哈希算法映射到位图内时,那么该位置就会被置为1,不然为0;经过这种方式对数据进行压缩,空间利用率可提示数十倍,数据能够很容易被系统cache,大大减小了IO操做。微信
在查询以前须要先对数据进行预处理,这里额外构建两张表,用来存储用户的位图信息。session
用户行为日志表:
table_oper_bit
向位图表插入数据,原始数据十几亿,插入后结果只有几万行,并且随着数据范围的再扩大,位图表的数据增量变化也不会很明显
用户基本信息表:table_attribute_bit
同理table_attribute_bit插入后数据也获得了极大的压缩,最终数据以下图:
应用案例
a. 操做了某个行为的用户在后续某一天操做了另外一个行为的留存:
如“20200701点击了banner的用户在第二天点击app签到的留存人数”,就能够用如下的sql快速求解:
b. 操做了某个行为而且带有某个属性的用户在后续的某一天操做了另外一个行为的留存:
如“20200701点击了banner且来自广东/江西/河南的用户在第二天点击app签到的留存人数”:
c. 操做了某个行为而且带有某几个属性的用户在后续的某一天操做了另外一个行为的留存:
如“20200701点击了banner、来自广东且新进渠道是小米商店的用户在第二天点击app签到的留存人数”:
其中bitmapCardinality用来计算位图中不重复数据个数,在大数据量下会有必定的数据偏差,bitmapAnd用来计算两个bitmap的与操做,即返回同时出如今两个bitmap中用户数量
查询速度
clickhouse集群现状:12核125G内存机器10台。
clickhouse版本:20.4.7.67。
查询的表都存放在其中一台机器上。
测试了查询在20200701操做了行为oper_name_1(用户数量级为3000+w)的用户在后续7天内天天操做了另外一个行为oper_name_2(用户数量级为2700+w)的留存数据(用户重合度在1000w以上),耗时0.2秒左右
该方法的确比较灵活,不只仅能解决留存问题,还有不少关于事件分析的需求等待咱们去探索;然而它的缺点是操做复杂,且不支持对实时数据的分析
retention
经过上面的例子不难看出,腾讯的作法虽然提高了查询的性能,可是操做过于复杂,不便于用户理解和后期的维护;关于这些痛点易企秀数仓这边作法是采用retention进行实现
retention function是clickhouse中高级聚合函数,较bitmap的方式实现留存分析会更加简单、高效;语法以下:
retention(cond1, cond2, ..., cond32);# cond 为判断条件# 支持最长32个参数的输入,也就是说 至少支持一个完整天然月的留存分析查询
其中知足条件1的数据会置为1,以后的每个表达式成立的前提都要创建在条件1成立的基础之上,这正好符合咱们对留存模型的定义
那么咱们还以上面的3个场景为例方便对比说明:
20200701点击了banner的用户在第二天点击app签到的留存人数
SELECT sum(r[1]) AS r1, sum(r[2]) AS r2, r2/r1FROM(SELECT uid, retention(date = '20200701' and type='点击banner', date = '20200702' and type='点击app签到' ) AS rFROM action_logWHERE date IN ('20200701', '20200702')GROUP BY uid )
20200701点击了banner且来自广东/江西/河南的用户在第二天点击app签到的留存人数
SELECT sum(r[1]) AS r1, sum(r[2]) AS r2, r2/r1FROM(SELECT uid, retention(date = '20200701' and type='点击banner', date = '20200702' and type='点击app签到' ) AS rFROM action_logWHERE date IN ('20200701', '20200702') and province IN ('广东', '江西', '河南')GROUP BY uid )
按照上面的方式第三个场景也能很快实现,这里留给你们去尝试...
不过该方式与bitmap比也有缺陷,那就是若是用户日志表中不存储用户属性信息时,就须要与用户属性表进行关联查询,两张大表关联,查询性能会至关慢。
什么是有序漏斗,有序漏斗须要知足全部用户事件链上的操做都是逡巡时间前后关系的,且漏斗事件不能有断层,触达当前事件层的用户也须要经历前面的事件层
接上一章智能路径分析,假设咱们已经获得了触达支付购买的路径有 “首页->详情页->购买页->支付“ 和 “搜索页->详情页->购买页->支付“ 两个主要路径,可是咱们不清楚哪条路径转化率高,那么这个时候漏斗分析就派上用场了
漏斗模型是一个倒置的金字塔形状,主要用来分析页面与页面 功能模块以前的转化状况,下面一层都是基于紧邻的上一层转化而来的,也就是说前一个条件是后一个条件成立的基础;解决此类场景clickhouse提供了一个名叫windowFunnel的函数来实现:
windowFunnel(window)(timestamp, cond1, cond2, ..., condN)
window:
窗口大小,从第一个事件开始,日后推移一个窗口大小来提取事件数据
timestamp:
能够是时间或时间戳类型,用来对时事件进行排序
cond:
每层知足的事件
为了便于你们理解,这里举个简单的栗子:
CREATE TABLE test.action( `uid` Int32, `event_type` String, `time` datetime)ENGINE = MergeTree()PARTITION BY uidORDER BY xxHash32(uid)SAMPLE BY xxHash32(uid)SETTINGS index_granularity = 8192
插入测试数据
insert into action values(1,'浏览','2020-01-02 11:00:00');insert into action values(1,'点击','2020-01-02 11:10:00');insert into action values(1,'下单','2020-01-02 11:20:00');insert into action values(1,'支付','2020-01-02 11:30:00');
insert into action values(2,'下单','2020-01-02 11:00:00');insert into action values(2,'支付','2020-01-02 11:10:00');
insert into action values(1,'浏览','2020-01-02 11:00:00');
insert into action values(3,'浏览','2020-01-02 11:20:00');insert into action values(3,'点击','2020-01-02 12:00:00');
insert into action values(4,'浏览','2020-01-02 11:50:00');insert into action values(4,'点击','2020-01-02 12:00:00');
insert into action values(5,'浏览','2020-01-02 11:50:00');insert into action values(5,'点击','2020-01-02 12:00:00');insert into action values(5,'下单','2020-01-02 11:10:00');
insert into action values(6,'浏览','2020-01-02 11:50:00');insert into action values(6,'点击','2020-01-02 12:00:00');insert into action values(6,'下单','2020-01-02 12:10:00');
已30分钟做为一个时间窗口,看下windowFunnel返回了什么样的数据
SELECT user_id, windowFunnel(1800)(time, event_type = '浏览', event_type = '点击', event_type = '下单', event_type = '支付') AS levelFROM ( SELECT time, event_type, uid AS user_id FROM action)GROUP BY user_id
┌─user_id─┬─level─┐│ 3 │ 1 ││ 2 │ 0 ││ 5 │ 2 ││ 1 │ 4 ││ 6 │ 3 │└─────────┴───────┘
这里level只记录了路径中最后一次事件所属的层级,若是直接对level分组统计就会丢失以前的层级数据,致使漏斗不能呈现金字塔状
模型
继续使用上面的测试数据,经过数组的高阶函数对上述结果数据进行二次加工处理以获取完整漏斗展现效果。
案例
分析"2020-01-02"这天 路径为“浏览->点击->下单->支付”的转化状况。
SELECT level_index,count(1) FROM( SELECT user_id, arrayWithConstant(level, 1) levels, arrayJoin(arrayEnumerate( levels )) level_index FROM ( SELECT user_id, windowFunnel(1800)( time, event_type = '浏览', event_type = '点击' , event_type = '下单', event_type = '支付' ) AS level FROM ( SELECT time, event_type , uid as user_id FROM test.action WHERE toDate(time) = '2020-01-02' ) GROUP BY user_id ))group by level_indexORDER BY level_index
为何要有路径分析,举个最简单的例子,你的领导想要知道用户在完成下单前的一个小时都作了什么?绝大多数人拿到这个需求的作法就是进行数据抽样观察以及进行一些简单的问卷调参工做,这种方式不但费时费力还不具备表明性,那么这个时候你就须要一套用户行为路径分析的模型做为支撑,才能快速帮组你找到最佳答案
clickhouse是我见过最完美的OLAP数据库,它不只将性能发挥到了极致,还在数据分析层面作了大量改进和支撑,为用户提供了大量的高级聚合函数和基于数组的高阶lambda函数。
企业中经常使用的路径分析模型通常有两种:
已经明确了要分析的路径,须要看下这些访问路径上的用户数据:关键路径分析
不肯定有哪些路径,可是清楚目标路径是什么,须要知道用户在指定时间范围内都是经过哪些途径触达目标路径的:智能路径分析
关键路径分析
由于咱们接下来要经过sequenceCount完成模型的开发,因此须要先来了解一下该函数的使用:
sequenceCount(pattern)(timestamp, cond1, cond2, ...)
该函数经过pattern指定事件链,当用户行为彻底知足事件链的定义是会+1;其中time时间类型或时间戳,单位是秒,若是两个事件发生在同一秒时,是没法准确区分事件的发生前后关系的,因此会存在必定的偏差。
pattern支持3中匹配模式:
(?N):表示时间序列中的第N个事件,从1开始,最长支持32个条件输入;如,(?1)对应的是cond1
(?t op secs):插入两个事件之间,表示它们发生时须要知足的时间条件(单位为秒),支持 >=, >, <, <= 。例如上述SQL中,(?1)(?t<=15)(?2)即表示事件1和2发生的时间间隔在15秒之内,期间可能会发生若干次非指定事件。
.*:表示任意的非指定事件。
例如,boos要看在会员购买页超过10分钟才下单的用户数据 那么就能够这么写
SELECT count(1) AS c1, sum(cn) AS c2FROM ( SELECT u_i, sequenceCount('(?1)(?t>600)(?2)')(toDateTime(time), act = '会员购买页', act = '会员支付成功') AS cn FROM app.scene_tracker WHERE day = '2020-09-07' GROUP BY u_i)WHERE cn >= 1
┌──c1─┬──c2─┐│ 102 │ 109 │└─────┴─────┘
根据上面数据能够看出完成支付以前在会员购买页停留超过10分钟的用户有100多个,那么是什么缘由致使用户迟迟不愿下单,接下来咱们就可使用智能路径针对这100个用户展开分析,看看他们在此期间都作了什么。
智能路径分析
智能路径分析模型比较复杂,但同时支持的分析需求也会更加复杂,如分析给按期望的路径终点、途经点和最大事件时间间隔,统计出每条路径的用户数,并按照用户数对路径进行倒序排列
虽然clickhouse没有提供现成的分析函数支持到该场景,可是能够经过clickhouse提供的高阶数组函数进行曲线救国,大体SQL以下:
方案一
SELECT result_chain, uniqCombined(user_id) AS user_countFROM ( WITH toDateTime(maxIf(time, act = '会员支付成功')) AS end_event_maxt, arrayCompact(arraySort( x -> x.1, arrayFilter( x -> x.1 <= end_event_maxt, groupArray((toDateTime(time), (act, page_name))) ) )) AS sorted_events, arrayEnumerate(sorted_events) AS event_idxs, arrayFilter( (x, y, z) -> z.1 <= end_event_maxt AND (z.2.1 = '会员支付成功' OR y > 600), event_idxs, arrayDifference(sorted_events.1), sorted_events ) AS gap_idxs, arrayMap(x -> x + 1, gap_idxs) AS gap_idxs_, arrayMap(x -> if(has(gap_idxs_, x), 1, 0), event_idxs) AS gap_masks, arraySplit((x, y) -> y, sorted_events, gap_masks) AS split_events SELECT user_id, arrayJoin(split_events) AS event_chain_, arrayCompact(event_chain_.2) AS event_chain, hasAll(event_chain, [('pay_button_click', '会员购买页')]) AS has_midway_hit, arrayStringConcat(arrayMap( x -> concat(x.1, '#', x.2), event_chain ), ' -> ') AS result_chain FROM ( SELECT time,act,page_name,u_i as user_id FROM app.scene_tracker WHERE toDate(time) >= '2020-09-30' AND toDate(time) <= '2020-10-02' AND user_id IN (10266,10022,10339,10030) ) GROUP BY user_id HAVING length(event_chain) > 1)WHERE event_chain[length(event_chain)].1 = '会员支付成功' AND has_midway_hit = 1 GROUP BY result_chainORDER BY user_count DESC LIMIT 20;
实现思路:
将用户的行为用groupArray函数整理成<时间, <事件名, 页面名>>的元组,并用arraySort函数按时间升序排序;
利用arrayEnumerate函数获取原始行为链的下标数组;
利用arrayFilter和arrayDifference函数,过滤出原始行为链中的分界点下标。分界点的条件是路径终点或者时间差大于最大间隔;
利用arrayMap和has函数获取下标数组的掩码(由0和1组成的序列),用于最终切分,1表示分界点;
调用arraySplit函数将原始行为链按分界点切分红单次访问的行为链。注意该函数会将分界点做为新链的起始点,因此前面要将分界点的下标加1;
调用arrayJoin和arrayCompact函数将事件链的数组打平成多行单列,并去除相邻重复项。
调用hasAll函数肯定是否所有存在指定的途经点。若是要求有任意一个途经点存在便可,就换用hasAny函数。固然,也能够修改WHERE谓词来排除指定的途经点。
将最终结果整理成可读的字符串,按行为链统计用户基数,完成。
方案二
不设置途经点,且仅以用户最后一次到达目标事件做为参考
SELECT result_chain, uniqCombined(user_id) AS user_count FROM ( select u_i as user_id, arrayStringConcat( #获取访问路径字符串 arrayCompact( #相邻事件去重 arrayMap( b - > tupleElement(b, 1), arraySort( #对用户事件进行排序获得用户日志的前后顺序 y - > tupleElement(y, 2), arrayFilter( (x, y) - > y - x.2 > 3600 #找到目标节点前1小时内的全部事件 arrayMap( (x, y) - > (x, y), groupArray(e_t), groupArray(time) ), arrayWithConstant( length(groupArray(time)), maxIf(time, e_t = '会员支付成功') #设置目标节点 ) ) ) ) ), '->' ) result_chain from bw.scene_tracker where toDate(time) >= '2020-09-30' AND toDate(time) <= '2020-10-02' AND user_id IN (10266,10022,10339,10030) group by u_i ) tab GROUP BY result_chain ORDER BY user_count DESC LIMIT 20;
简单说一下上面用到的几个高阶函数:
arrayJoin
能够理解为行转列操做
SELECT arrayJoin([1, 2, 3, 4]) AS data
┌─data─┐│ 1 ││ 2 ││ 3 ││ 4 │└──────┘
uniqCombined
clickhouse中的高性能去重统计函数,相似count(distinct field),数据量比较小的时候使用数组进行去重,中的数据使用set集合去重,当数据量很大时会使用hyperloglog方式进行j近似去重统计;若是想要精度更改可使用uniqCombined64支持64位bit
SELECT uniqCombined(data)FROM ( SELECT arrayJoin([1, 2, 3, 1, 4, 2]) AS data)
┌─uniqCombined(data)─┐│ 4 │└────────────────────┘
arrayCompact
对数组中的数据进行相邻去重,用户重复操做的事件只记录一次
SELECT arrayCompact([1, 2, 3, 3, 1, 1, 4, 2]) AS data
┌─data──────────┐│ [1,2,3,1,4,2] │└───────────────┘
arraySort
对数组中的数据按照指定列进行升序排列;降序排列参考arrayReverseSort
SELECT arraySort(x -> (x.1), [(1, 'a'), (4, 'd'), (2, 'b'), (3, 'c')]) AS data
┌─data──────────────────────────────┐│ [(1,'a'),(2,'b'),(3,'c'),(4,'d')] │└───────────────────────────────────┘
arrayFilter
只保留数组中知足条件的数据
SELECT arrayFilter(x -> (x > 2), [12, 3, 4, 1, 0]) AS data
┌─data─────┐│ [12,3,4] │└──────────┘
groupArray
将分组下的数据聚合到一个数组集合中,相似hive中的collect_list函数
SELECT a.2, groupArray(a.1)FROM ( SELECT arrayJoin([(1, 'a'), (4, 'a'), (3, 'a'), (2, 'c')]) AS a)GROUP BY a.2
┌─tupleElement(a, 2)─┬─groupArray(tupleElement(a, 1))─┐│ c │ [2] ││ a │ [1,4,3] │└────────────────────┴────────────────────────────────┘
arrayEnumerate
或取数组的下标掩码序列
SELECT arrayEnumerate([1, 2, 3, 3, 1, 1, 4, 2]) AS data
┌─data──────────────┐│ [1,2,3,4,5,6,7,8] │└───────────────────┘
arrayDifference
参数必须是数值类型;计算数组中相邻数字的差值,第一个值为0
SELECT arrayDifference([3, 1, 1, 4, 2]) AS data
┌─data──────────┐│ [0,-2,0,3,-2] │└───────────────┘
arrayMap
对数组中的每一列进行处理,并返回长度相同的新数组
SELECT arrayMap(x -> concat(toString(x.1), ':', x.2), [(1, 'a'), (4, 'a'), (3, 'a'), (2, 'c')]) AS data
┌─data──────────────────────┐│ ['1:a','4:a','3:a','2:c'] │└───────────────────────────┘
arraySplit
按照规则对数组进行分割
SELECT arraySplit((x, y) -> y, ['a', 'b', 'c', 'd', 'e'], [1, 0, 0, 1, 0]) AS data
┌─data──────────────────────┐│ [['a','b','c'],['d','e']] │└───────────────────────────┘
## 遇到下标为1时进行分割,分割点为下一个 数组的起始点;注意,首项为1仍是0不影响结果
has
判断数组中是否包含某个数据
SELECT has([1, 2, 3, 4], 2) AS data
┌─data─┐│ 1 │└──────┘
hasAll
判断数组中是否包含指定子集
SELECT hasAll([1, 2, 3, 4], [4, 2]) AS data
┌─data─┐│ 1 │└──────┘
---
SELECT hasAll([1, 2, 3, 4], [0, 2]) AS data
┌─data─┐│ 0 │└──────┘
arrayStringConcat
将数组转为字符串,须要注意的是,这里的数组项须要是字符串类型
SELECT arrayStringConcat(['a', 'b', 'c'], '->') AS data
┌─data────┐│ a->b->c │└─────────┘
arrayWithConstant
以某个值进行填充生成数组
SELECT arrayWithConstant(4, 'abc') AS data
┌─data──────────────────────┐│ ['abc','abc','abc','abc'] │└───────────────────────────┘