最近大数据项目中,碰到了个问题,在做漏斗分析时分析性能常常跟不上,22 亿数据量往往需要 10s 以上才能返回想要的结果。推测应该是分析实施的方式有问题,导致资源使用过量,性能跟不上。
有序漏斗分析
漏斗分析是⼀套流程式数据分析,它能够科学反映⽤户⾏为状态以及从起点到终点各阶段⽤户转化率情况的重要分析模型。漏⽃分析模型已经⼴泛应⽤于⽤户⾏为分析和 APP 数据分析的流量监控、产品⽬标转化等⽇常数据运营与数据分析的⼯作中。由于最后分析的图形类似于一个漏斗形状,所以通常把这种分析叫做漏斗分析。
漏⽃分析最常⽤的是转化率和流失率两个互补型指标。⽤⼀个简单的例⼦来说明,假如有 100 ⼈访问某电商⽹站,有 30 ⼈点击注册,有 10 ⼈注册成功。这个过程共有三步,第⼀步到第⼆步的转化率为 30%,流失率为 70%,第⼆步到第三步转化率为 33%,流失率 67%;整个过程的转化率为 10%,流失率为 90%。 该模型就是经典的漏⽃分析模型。
因此,有序漏斗需要满足所有用户事件链上的操作都是逡巡时间先后关系的,且漏斗事件不能有断层,触达当前事件层的用户也需要经历前面的事件层。
那么来了解一下 ClickHouse 一般做漏斗分析时的做法:
一般的,ClickHouse 做有序漏斗分析会使用到 windowFunnel 函数,该函数的语法为:
windowFunnel(window, [mode])(timestamp, cond1, cond2, ..., condN)
其中:
返回值为 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 time
select 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 time
select 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 time
select 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 time
select 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 num
from (select uid,
windowFunnel((select toUInt64(toUnixTimestamp(
toDateTime('2022-01-31') - toDateTime('2022-01-01')))))(time,
event_type = '登录',
event_type = '浏览',
event_type = '添加购物车',
event_type = '购买') as level
from behavior
where time between toDate('2022-01-01') and toDate('2022-01-31')
group by uid)
group by level
order 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;
最终可以统计出,各漏斗步骤的用户数如下:
执行时间:156ms
在数据量较少(单节点低于2000万条)的时候,windowFunnel 函数的执行效率还是可以接受的。
但是当数据量较大时,该函数的性能出现了瓶颈,在 3 个月的日志量(22亿条数据左右),查询时间甚至能够达到 15~20s 左右,用户体验非常不好。在高并发场景下甚至出现了请求超时的问题。
从 SQL 语句上来看,查询的主表是基于元数据表进行的操作,也就是说,windowFunnel 函数只能基于元数据进行统计,并不能在元数据的基础上进行预汇聚——比如按天进行数据汇聚从而减轻最终查询时的数据基数。
对于实时查询来说,查询条件时变化多端的,做预聚合的前提是基于查询条件(WHERE 或 GROUP BY子句) 中的维度的,而对于 windowFunnel 函数,步骤条件、步骤执行顺序等条件均在函数内设置,无法外置,因此预聚合实施无法完成。
从分析问题中可以看到,基于 windowFunnel 函数是否能够完成预聚合成为了大数据量条件下的查询的巨大瓶颈。传统做法很简单,无外乎就是做分布式计算,增加计算节点,使每个节点的数据量基数摊薄变小,这样速度就快了。但这次需要考虑的是预聚合,在原有资源保持不变的情况下进行预聚合,最后从预聚合的结果中进行查询统计,因此抛弃 windowFunnel 函数势在必行。
直接看代码更直观:
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 后缀的函数即为获取基数的函数,获取基数即为获取个数的意思)。
最终可以统计出,各漏斗步骤的用户数如下:
执行时间:345ms
结果与 windowFunnel 函数的执行结果一致。
这样,我们就可以将 with 中的内容预聚合,比如每天对前一天的元数据执行一次,将结果(Bitmap)保存到预聚合表中,真正查询时只需要对 Bitmap 做取交集获取基数的操作就可以了,而且预聚合后数据量会急剧减少,性能肯定杠杠的,开心!
细心的同学应该发现了问题:
Bitmap 的与(And)操作是不区分前后顺序的,也就是说,bitmapAndCardinality(login, browse) 与 bitmapAndCardinality(browse, login) 是等价的,这样就很尴尬,比如,可以设置一个打乱的行为动作(浏览→登录→添加购物车→购买)来验证一下结果:
果然:
windowFunnel 函数的执行结果:
位图计算结果:
完蛋了,数据产生不一致了。
由此可见,如果想要用 Bitmap 来进行操作,元数据必须要从用户操作和业务上保证严格意义上的顺序,也就是说,用户只有这一条行为操作路径,否则统计会产生严重的误差。
用实际数据跑了一下两种方法,结果果然差距很大:
漏斗步骤 | windowFunnel 函数 | Bitmap 方法 |
---|---|---|
1 | 4319 | 4319 |
2 | 1523 | 4314 |
3 | 1445 | 4314 |
4 | 11 | 36 |
因此,这个方向是走不通的了。
从元数据中获取用户的执行顺序还是比较简单的:
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 event
from behavior
where 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 函数:
select
uid,
arrayCompact(user_events) a
from
(
select
uid,
groupArray(event) as user_events
from
..
)
这样做的目的是将类似于 [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 函数完成:
select
uid,
arraySplit(x -> x = 1, arrayCompact(user_events)) a
from
(
select
uid,
groupArray(event) as user_events
from
..
)
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_times
from (
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 event
from behavior
where time between toDate('2022-01-01') and toDate('2022-01-31')
and event_type in ('登录', '浏览', '添加购物车', '购买')
order by time
)
group by uid
)
)
其中的 hasSubstr 函数,即为判断数组中是否包含指定数组的函数。
到此为止,第二个方案就可以完成了。
实施后的统计结果为:
执行时间:224ms
果然,保证了行动顺序后,统计结果与 windowFunnel 的统计结果一致了,而且从执行时间上来看,还是比较理想的,而且如果能够预聚合,执行速度还会再快一些。
另外,有趣的是,把这个方法放到真实数据中执行,执行结果除了第一步相同外,后面几步的数字均比 windowFunnel 要小,对比数据后发现,原因是 windowFunnel 的结果是不准确的,也就是说,新的方法反而比 windowFunnel 要更精确,误差更小。
有人可能会提问了,对于漏斗步骤是无法固定的,这样在查询时如何能把不需要的步骤剔除?或者只保留需要的步骤?
——这时可以对 groupArray 之后的数组使用 arrayFilter 函数,只拿到自己想要的那部分数据就可以了。
对于 ClickHouse 来说,函数的灵活运用是统计的基础,尤其对于数组、位图函数的应用。
大数据的计算,实现性能的提升,除了增加计算节点,摊薄每个节点的计算数据量以外,最重要的还是要做指标数据的预聚合,这样数据才能够沉淀下来,形成数据仓库,好能够向数据集市提供预聚合数据。如果所有统计只能够从元数据中处理,那么大数据分析过程将会产生性能和数据瓶颈,重复计算将频繁发生,这会失去大数据计算的优势。只有预聚合的数据量越小,后面的统计速度才能越快。
比如上面的例子中,根据业务需要不同,可以选择计算到什么程度作为预聚合的结果,比如groupArray 之后,比如 arrayCompact 之后,比如 arraySplit 之后,比如在 arrayDistinct 或 arrayMap 之后,甚至于在 arraySum 之后。