文章目录

  • 背景
  • 基础
  • 提出问题
  • 分析问题
  • 解决问题
  • 总结

背景

最近大数据项目中,碰到了个问题,在做漏斗分析时分析性能常常跟不上,22 亿数据量往往需要 10s 以上才能返回想要的结果。推测应该是分析实施的方式有问题,导致资源使用过量,性能跟不上。

基础

有序漏斗分析

漏斗分析是⼀套流程式数据分析,它能够科学反映⽤户⾏为状态以及从起点到终点各阶段⽤户转化率情况的重要分析模型。漏⽃分析模型已经⼴泛应⽤于⽤户⾏为分析和 APP 数据分析的流量监控、产品⽬标转化等⽇常数据运营与数据分析的⼯作中。由于最后分析的图形类似于一个漏斗形状,所以通常把这种分析叫做漏斗分析。

漏⽃分析最常⽤的是转化率和流失率两个互补型指标。⽤⼀个简单的例⼦来说明,假如有 100 ⼈访问某电商⽹站,有 30 ⼈点击注册,有 10 ⼈注册成功。这个过程共有三步,第⼀步到第⼆步的转化率为 30%,流失率为 70%,第⼆步到第三步转化率为 33%,流失率 67%;整个过程的转化率为 10%,流失率为 90%。 该模型就是经典的漏⽃分析模型。

因此,有序漏斗需要满足所有用户事件链上的操作都是逡巡时间先后关系的,且漏斗事件不能有断层,触达当前事件层的用户也需要经历前面的事件层。

那么来了解一下 ClickHouse 一般做漏斗分析时的做法:

一般的,ClickHouse 做有序漏斗分析会使用到 windowFunnel 函数,该函数的语法为:

windowFunnel(window, [mode])(timestamp, cond1, cond2, ..., condN)

其中:

  • window — 滑动窗户的大小,单位是秒。
  • mode - 这是一个可选的参数。
    • ‘strict’ - 当 ‘strict’ 设置时,windowFunnel() 仅对唯一值应用匹配条件。
  • timestamp — 包含时间的列。 数据类型支持: 日期, 日期时间 和其他无符号整数类型(请注意,即使时间戳支持 UInt64 类型,它的值不能超过Int64最大值,即2^63-1)。
  • cond — 事件链的约束条件。 UInt8 类型。

返回值为 Integer 类型,滑动时间窗口内连续触发条件链的最大数目。

举个栗子:

我们有用户行为表如下:

CREATE TABLE behavior
(`uid` Int32,`event_type` String,`time` DateTime
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(time)
ORDER BY uid
SETTINGS index_granularity = 8192;

然后我们伪造一些用户从登录,到浏览,再到添加购物车,最后购买的数据行为的日志数据:

-- 伪造登录数据(2022-01-01 ~ 2022-01-08)
insert into behavior
select tupleElement(b, 1) uid, tupleElement(b, 2) event_type, tupleElement(b, 3) time
from (with(select groupArray(b) from (select * from generateRandom('b UInt16') limit 100000)) as uid,(select groupArray('登录') from numbers(100000)) as event_type,(select groupArray(a)from (select *from generateRandom('a Datetime64(0)')where a between toDateTime('2022-01-01') and toDateTime('2022-01-08')limit 100000)) as timeselect arrayJoin(arrayZip(uid, event_type, time)) as b);-- 伪造浏览数据(2022-01-09 ~ 2022-01-16)
insert into behavior
select tupleElement(b, 1) uid, tupleElement(b, 2) event_type, tupleElement(b, 3) time
from (with(select groupArray(b) from (select * from generateRandom('b UInt16') limit 50000)) as uid,(select groupArray('浏览') from numbers(50000)) as event_type,(select groupArray(a)from (select *from generateRandom('a Datetime64(0)')where a between toDateTime('2022-01-09') and toDateTime('2022-01-16')limit 50000)) as timeselect arrayJoin(arrayZip(uid, event_type, time)) as b);-- 伪造添加购物车数据(2022-01-17 ~ 2022-01-22)
insert into behavior
select tupleElement(b, 1) uid, tupleElement(b, 2) event_type, tupleElement(b, 3) time
from (with(select groupArray(b) from (select * from generateRandom('b UInt16') limit 30000)) as uid,(select groupArray('添加购物车') from numbers(30000)) as event_type,(select groupArray(a)from (select *from generateRandom('a Datetime64(0)')where a between toDateTime('2022-01-17') and toDateTime('2022-01-22')limit 30000)) as timeselect arrayJoin(arrayZip(uid, event_type, time)) as b);-- 伪造购买数据(2022-01-23 ~ 2022-01-31)
insert into behavior
select tupleElement(b, 1) uid, tupleElement(b, 2) event_type, tupleElement(b, 3) time
from (with(select groupArray(b) from (select * from generateRandom('b UInt16') limit 20000)) as uid,(select groupArray('购买') from numbers(20000)) as event_type,(select groupArray(a)from (select *from generateRandom('a Datetime64(0)')where a between toDateTime('2022-01-23') and toDateTime('2022-01-31')limit 20000)) as timeselect arrayJoin(arrayZip(uid, event_type, time)) as b);

这样,我们就有了一个月的假数据,其中 :

​ 2022-01-01 ~ 2022-01-08 的数据为 登录 数据;

​ 2022-01-09 ~ 2022-01-16 的数据为 浏览 数据;

​ 2022-01-17 ~ 2022-01-22 的数据为 添加购物车 数据;

​ 2022-01-23 ~ 2022-01-31 的数据为 购买 数据;

总数据量为 20 万。

然后我们就可以使用 windowFunnel 函数进行漏斗中的 uv 统计了。

with(select groupArray(num)from (select level, count() as numfrom (select uid,windowFunnel((select toUInt64(toUnixTimestamp(toDateTime('2022-01-31') - toDateTime('2022-01-01')))))(time,event_type = '登录',event_type = '浏览',event_type = '添加购物车',event_type = '购买') as levelfrom behaviorwhere time between toDate('2022-01-01') and toDate('2022-01-31')group by uid)group by levelorder by level)) as total_num
select total_num[2] + total_num[3] + total_num[4] + total_num[5] as login_num,total_num[3] + total_num[4] + total_num[5]                as browse_num,total_num[4] + total_num[5]                               as add_cart_num,total_num[5]                                              as buy_num;

最终可以统计出,各漏斗步骤的用户数如下:

  • 登录:51140
  • 登录 + 浏览:27205
  • 登录 + 浏览 + 添加购物车:10069
  • 登录 + 浏览 + 添加购物车 + 购买:2688

执行时间:156ms


提出问题

在数据量较少(单节点低于2000万条)的时候,windowFunnel 函数的执行效率还是可以接受的。

但是当数据量较大时,该函数的性能出现了瓶颈,在 3 个月的日志量(22亿条数据左右),查询时间甚至能够达到 15~20s 左右,用户体验非常不好。在高并发场景下甚至出现了请求超时的问题。


分析问题

  1. 基于元数据的查询

从 SQL 语句上来看,查询的主表是基于元数据表进行的操作,也就是说,windowFunnel 函数只能基于元数据进行统计,并不能在元数据的基础上进行预汇聚——比如按天进行数据汇聚从而减轻最终查询时的数据基数。

  1. 查询条件预置

对于实时查询来说,查询条件时变化多端的,做预聚合的前提是基于查询条件(WHERE 或 GROUP BY子句) 中的维度的,而对于 windowFunnel 函数,步骤条件、步骤执行顺序等条件均在函数内设置,无法外置,因此预聚合实施无法完成。


解决问题

从分析问题中可以看到,基于 windowFunnel 函数是否能够完成预聚合成为了大数据量条件下的查询的巨大瓶颈。传统做法很简单,无外乎就是做分布式计算,增加计算节点,使每个节点的数据量基数摊薄变小,这样速度就快了。但这次需要考虑的是预聚合,在原有资源保持不变的情况下进行预聚合,最后从预聚合的结果中进行查询统计,因此抛弃 windowFunnel 函数势在必行。


  1. 分别对各个行为动作统计人员列表,通过获取行为的人员交集,取基数获取 uv,从而计算转化率。

直接看代码更直观:

with(select groupBitmapState(uid) from behavior where event_type = '登录') as login,(select groupBitmapState(uid) from behavior where event_type = '浏览') as browse,(select groupBitmapState(uid) from behavior where event_type = '添加购物车') as add_cart,(select groupBitmapState(uid) from behavior where event_type = '购买') as buy
select bitmapCardinality(login)                                                 as login_num,bitmapAndCardinality(login, browse)                                      as browse_num,bitmapAndCardinality(bitmapAnd(login, browse), add_cart)                 as add_cart_num,bitmapAndCardinality(bitmapAnd(bitmapAnd(login, browse), add_cart), buy) as buy_num;

with 子句中分别获取了 4 个行为动作的人员列表( uid 列表( bitmap 类型)),在 select 中依次对 4 个动作的人员列表取交集,然后计算基数( -Cardinality 后缀的函数即为获取基数的函数,获取基数即为获取个数的意思)。

最终可以统计出,各漏斗步骤的用户数如下:

  • 登录:51140
  • 登录 + 浏览:27205
  • 登录 + 浏览 + 添加购物车:10069
  • 登录 + 浏览 + 添加购物车 + 购买:2688

执行时间:345ms

结果与 windowFunnel 函数的执行结果一致。

这样,我们就可以将 with 中的内容预聚合,比如每天对前一天的元数据执行一次,将结果(Bitmap)保存到预聚合表中,真正查询时只需要对 Bitmap 做取交集获取基数的操作就可以了,而且预聚合后数据量会急剧减少,性能肯定杠杠的,开心!


细心的同学应该发现了问题:

Bitmap 的与(And)操作是不区分前后顺序的,也就是说,bitmapAndCardinality(login, browse) 与 bitmapAndCardinality(browse, login) 是等价的,这样就很尴尬,比如,可以设置一个打乱的行为动作(浏览→登录→添加购物车→购买)来验证一下结果:

果然:

windowFunnel 函数的执行结果:

  • 登录:51140
  • 登录 + 浏览:27204
  • 登录 + 浏览 + 添加购物车:10068
  • 登录 + 浏览 + 添加购物车 + 购买:2687

位图计算结果:

  • 登录:51140
  • 登录 + 浏览:27205
  • 登录 + 浏览 + 添加购物车:10069
  • 登录 + 浏览 + 添加购物车 + 购买:2688

完蛋了,数据产生不一致了。

由此可见,如果想要用 Bitmap 来进行操作,元数据必须要从用户操作和业务上保证严格意义上的顺序,也就是说,用户只有这一条行为操作路径,否则统计会产生严重的误差。

用实际数据跑了一下两种方法,结果果然差距很大:

漏斗步骤 windowFunnel 函数 Bitmap 方法
1 4319 4319
2 1523 4314
3 1445 4314
4 11 36

因此,这个方向是走不通的了。


  1. 既然第一个方法的问题出现在了行为顺序上面,那可以考虑一下行为顺序的一致性上。

从元数据中获取用户的执行顺序还是比较简单的:

select uid,groupArray(event) as user_events
from (select uid,multiIf(event_type = '登录', 1,event_type = '浏览', 2,event_type = '添加购物车', 3,event_type = '购买', 4,0) as eventfrom behaviorwhere time between toDate('2022-01-01') and toDate('2022-01-31')and event_type in ('登录', '浏览', '添加购物车', '购买')order by time)
group by uid;

比如,我们将登录、浏览、添加购物车、购买的行为分别定义为1、2、3、4,那么就可以从元数据中构建出每个用户的行为数组 user_events。

比如其中的数据可以为:[1, 2, 3, 4],或者 [1, 3, 4] 或者 [1, 2] 等等。

其中不乏有些复杂操作顺序,比如 [1, 2, 2, 3, 2, 3, 4] 等等。

有了每个用户的操作顺序,接下来就可以对操作顺序与我们预置的操作顺序相匹配,如果包含就做 uv 统计。

比如我们的漏斗顺序为 1 → 2 → 3 → 4 ,那么每个漏斗过程需要匹配的预置操作顺序就分别为:

[1]

[1, 2]

[1, 2, 3]

[1, 2, 3, 4]

接下来就需要对元数据的用户行为数据进行合并清洗了。


A. 先要对用户行为数据做相邻项合并操作,使用 arrayCompact 函数:

selectuid,arrayCompact(user_events) a
from(selectuid,groupArray(event) as user_eventsfrom..)

这样做的目的是将类似于 [1, 2, 2, 3, 2, 3, 4] 这样的数据合并成 [1, 2, 3, 2, 3, 4] 。


B. 其次,用户的行为操作在一段时间内(比如一天)有可能会重复进行,比如登录了多次,每次的行为数据都不相同,这样就需要对整个行为操作分割成多个子行为,比如 [1, 2, 2, 3, 2, 3, 4, 1, 2, 2, 2, 3, 1, 2, 3, 2, 3, 1, 3, 4, 2, 3, 2, 2, 1, 2, 1] 需要合并分割为这样的数组:

[[1, 2, 3, 2, 3, 4], // => [1, 2, 2, 3, 2, 3, 4][1, 2, 3],          // => [1, 2, 2, 2, 3][1, 2, 3, 2, 3],    // => [1, 2, 3, 2, 3][1, 3, 4, 2, 3, 2], // => [1, 3, 4, 2, 3, 2, 2][1, 2],             // => [1, 2][1]                 // => [1]
]

经过观察,实际上数组的分割都是由行为的第一步开始做分割的,因此,可以使用 arraySplit 函数完成:

selectuid,arraySplit(x -> x = 1, arrayCompact(user_events)) a
from(selectuid,groupArray(event) as user_eventsfrom..)

C. 然后,对于每个子数组,需要将中间的重复操作项去除,只保留第一次操作的记录即可,比如 [1, 2, 3, 2, 3] 去重后仅剩 [1, 2, 3],再比如 [1, 3, 4, 2, 3, 2, 2] 去重后仅剩 [1, 3, 4, 2]。而能够完成这个操作的函数即为 arrayDistinct,之后再使用 arrayMap 函数对大数组遍历,判断小数组中是否包含预置项,并使用 arraySum 函数合计个数是否大于 0,即可得到 uv 了。

select countIf(uid, login_times > 0)    login_uv,countIf(uid, view_times > 0)     view_uv,countIf(uid, add_cart_times > 0) add_cart_uv,countIf(uid, buy_times > 0)      buy_uv
from (select uid,arraySum(arrayMap(x -> hasSubstr(arrayDistinct(x), [1]),arraySplit(x -> x = 1, arrayCompact(user_events)))) login_times,arraySum(arrayMap(x -> hasSubstr(arrayDistinct(x), [1, 2]),arraySplit(x -> x = 1, arrayCompact(user_events)))) view_times,arraySum(arrayMap(x -> hasSubstr(arrayDistinct(x), [1, 2, 3]),arraySplit(x -> x = 1, arrayCompact(user_events)))) add_cart_times,arraySum(arrayMap(x -> hasSubstr(arrayDistinct(x), [1, 2, 3, 4]),arraySplit(x -> x = 1, arrayCompact(user_events)))) buy_timesfrom (select uid,groupArray(event) as user_eventsfrom (select uid,multiIf(event_type = '登录', 1,event_type = '浏览', 2,event_type = '添加购物车', 3,event_type = '购买', 4,0) as eventfrom behaviorwhere time between toDate('2022-01-01') and toDate('2022-01-31')and event_type in ('登录', '浏览', '添加购物车', '购买')order by time)group by uid))

其中的 hasSubstr 函数,即为判断数组中是否包含指定数组的函数。

到此为止,第二个方案就可以完成了。

实施后的统计结果为:

  • 登录:51140
  • 登录 + 浏览:27204
  • 登录 + 浏览 + 添加购物车:10068
  • 登录 + 浏览 + 添加购物车 + 购买:2687

执行时间:224ms

果然,保证了行动顺序后,统计结果与 windowFunnel 的统计结果一致了,而且从执行时间上来看,还是比较理想的,而且如果能够预聚合,执行速度还会再快一些。


另外,有趣的是,把这个方法放到真实数据中执行,执行结果除了第一步相同外,后面几步的数字均比 windowFunnel 要小,对比数据后发现,原因是 windowFunnel 的结果是不准确的,也就是说,新的方法反而比 windowFunnel 要更精确,误差更小。

总结

有人可能会提问了,对于漏斗步骤是无法固定的,这样在查询时如何能把不需要的步骤剔除?或者只保留需要的步骤?

——这时可以对 groupArray 之后的数组使用 arrayFilter 函数,只拿到自己想要的那部分数据就可以了。

对于 ClickHouse 来说,函数的灵活运用是统计的基础,尤其对于数组、位图函数的应用。

大数据的计算,实现性能的提升,除了增加计算节点,摊薄每个节点的计算数据量以外,最重要的还是要做指标数据的预聚合,这样数据才能够沉淀下来,形成数据仓库,好能够向数据集市提供预聚合数据。如果所有统计只能够从元数据中处理,那么大数据分析过程将会产生性能和数据瓶颈,重复计算将频繁发生,这会失去大数据计算的优势。只有预聚合的数据量越小,后面的统计速度才能越快。

比如上面的例子中,根据业务需要不同,可以选择计算到什么程度作为预聚合的结果,比如groupArray 之后,比如 arrayCompact 之后,比如 arraySplit 之后,比如在 arrayDistinct 或 arrayMap 之后,甚至于在 arraySum 之后。

在 ClickHouse 中使用函数实现有序漏斗分析相关推荐

  1. ClickHouse 实现有序漏斗分析与数据可视化

    Clickhouse 实现漏斗分析与数据可视化 1.前言 2.环境准备 2.1.测试表 2.2.插入伪造的样例数据 2.2.1.插入伪造的登录数据 2.2.2.插入伪造的浏览数据 2.2.3.插入伪造 ...

  2. python 遍历函数用法_python中enumerate函数遍历元素用法分析

    本文实例讲述了python中enumerate函数遍历元素用法.分享给大家供大家参考,具体如下: enumerate函数用于遍历序列中的元素以及它们的下标 示例代码如下: i = 0 seq = [' ...

  3. Python程序中各函数间调用关系分析与可视化

    中国大学MOOC"Python程序设计基础"免费学习地址 2020年秋季学期Python教材推荐与选用参考 推荐图书: <Python程序设计(第3版)>,(ISBN: ...

  4. OpenCV中initUndistortRectifyMap函数存在bug原因探究

    原文首发于公众号「3D视觉工坊」:OpenCV中initUndistortRectifyMap函数存在bug原因探究. 最近在运行如下一段代码时,生成的mapx和mapy有点异常. 代码片段如下: # ...

  5. opencv mat 修改_OpenCV中initUndistortRectifyMap函数存在bug原因探究

    点击上方"3D视觉工坊",选择"星标" 干货第一时间送达 3D视觉工坊的第52篇文章 最近在运行如下一段代码时,生成的mapx和mapy有点异常. 代码片段如下 ...

  6. php中substr函数用法,关于substr函数的详细介绍

    本文实例讲述了PHP中字符串长度的截取用法.分享给大家供大家参考,具体如下:php中提供了很多使用函数,其中字符串的截取函数也不例外,而且功能也非常强大.<?php //文件编码格式为UTF-8 ...

  7. 【用户画像】ClickHouse中的数据类型、表引擎介绍及使用、项目几个问题的解决办法

    文章目录 一 数据类型 1 整型 2 浮点型 3 布尔型 4 Decimal 型 5 字符串 (1)String (2)FixedString(N) 6 时间类型 7 数组 8 元组 二 三个小问题 ...

  8. 《APUE》中的函数整理

    第1章 unix基础知识 1. char *strerror(int errnum) 该函数将errnum(就是errno值)映射为一个出错信息字符串,返回该字符串指针.声明在string.h文件中. ...

  9. hashmap中的key是有序的么_HashMap?面试?我是谁?我在哪

    (给ImportNew加星标,提高Java技能) 转自:卓庆森 https://www.cnblogs.com/zhuoqingsen/p/8577646.html 现在是晚上11点了,学校屠猪馆的自 ...

最新文章

  1. linux平台下QtCreator中集成Valgrind系列工具
  2. 2018年技术展望--中文版
  3. Python的itertools.product 方法
  4. html在线编辑器 asp.net,ASP.NET网站使用Kindeditor富文本编辑器配置步骤
  5. nacos服务配置中心演示
  6. 2-3树与2-3-4树【转载】
  7. linux 编程 调度,Linux的进程线程及调度
  8. 深度学习 --- 受限玻尔兹曼机RBM(MCMC和Gibbs采样)
  9. XenApp_XenDesktop_7.6实战篇之十七:XenApp应用程序交付
  10. 计算机主机前耳机没声音,电脑前面耳机没声音的解决办法 电脑前面插耳机没声音怎么办...
  11. 傅里叶变换与Matlab
  12. 小米原装系统镜像列表
  13. 资料:高等数学学习笔记——高等数学(二)学习笔记汇总
  14. mysql bug frash_MySQL Flush导致的等待问题
  15. 入门3D游戏建模,是选择角色建模还是场景建模,看完你来选
  16. 学习指南者的第二天——代码解析
  17. python产品发布会_阅读虫师django开发发布会系统以及django开发Web接口分享
  18. arduino ssd1306屏幕打印信息(已测可用)
  19. Day03_JavaGuava
  20. Keil4添加STC系列单片机

热门文章

  1. 浅谈Python3函数命名空间与作用域
  2. 本草纲目pdf彩图版下载_本草纲目下载|本草纲目彩色图集精编珍藏版下载pdf高清版下载_最火手机站...
  3. VVIC-API接口:item_search_img - 按图搜索商品
  4. 第五章 键盘与显示器接口技术
  5. matlab函数之diag
  6. 编程新技术实务实验三:Android手机拨号器开发
  7. java支持wmv的播放器_支持wmv、mpg、mov、avi格式的网页视频播放代码
  8. 记录小米 4,锤子手机上播放系统铃声失败的问题
  9. jmeter乱码request请求体乱码
  10. python培训费用大概多少 学习技巧有哪些?