背景
ClickHouse
作为性能卓越的OLAP引擎,有丰富的数据分析函数。公司增长分析侧使用 ClickHouse
的 windowFunnel
函数进行自定义漏斗和路径分析。
但是,使用中, 存在这样的一个现象。例如:
用户123的行为纪录如下:
uid event event_time 123 A 10 123 B 10 用户123在10秒的时刻做了A事件,同样也在10秒的时刻做了事件B (虽然,这种情况不太符合逻辑,但是数据确实存在这样的情况)
想计算 A → B 这样路径的漏斗,使用 windowFunnel
函数进行计算,发现每次查询数据会出现不一致。下图显示,funnel有时候为2,有时候为1
select t.uid,
windowFunnel(20) (event_time, event='A', event='B') as funnel
from (
select
123 as uid,
'A' as event,
10 as event_time
union all
select
123 as uid,
'B' as event,
10 as event_time
) t group by t.uid
原因分析
公司ClickHouse
集群是19.17版本,找了对应版本的源码 https://github.com/ClickHouse/ClickHouse/blob/19.17/src/AggregateFunctions/AggregateFunctionWindowFunnel.h
windowFunnel
函数使用中,例如:
windowFunnel(20, ['strict']) (event_time, event='A', event='B', event='C')
-
20
表示漏斗间隔的窗口时间大小, 单位是秒 -
strict
表示是否忽略重复事件情况,不填默认忽略 -
event_time
表示使用 event_time字段作为时间判断 -
event='A', event='B', event='C'
表示 漏斗顺序为A -> B -> C
返回结果:如果用户只完成A, 则返回1;如果用户完成到B, 则返回2;如果用户完成到C, 则返回3。
具体过程
1. windowFunnel 函数参数识别:
(20, ['strict'])
对应 arguments
; (event_time, event='A', event='B', event='C')
对应 params
AggregateFunctionWindowFunnel(const DataTypes & arguments, const Array & params)
: IAggregateFunctionDataHelper>(arguments, params)
{
events_size = arguments.size() - 1;
window = params.at(0).safeGet();
strict = 0;
for (size_t i = 1; i < params.size(); ++i)
{
String option = params.at(i).safeGet();
if (option.compare("strict") == 0)
strict = 1;
else
throw Exception{"Aggregate function " + getName() + " doesn't support a parameter: " + option, ErrorCodes::BAD_ARGUMENTS};
}
}
2. windowFunnel 函数把每个用户的事件明细构成一个Pair<事件时间,事件索引>
对象:
其中,(event_time, event='A', event='B', event='C')
对应 const IColumn ** columns
事件时间: 从表中每行纪录中获取 event_time
字段
事件索引:按照 C, B, A
的顺序遍历,依次匹配每行纪录。
例如,用户123事件纪录明细为:
event | event_time | pair |
---|---|---|
A | 10 | (10, 1) |
C | 12 | (12, 3) |
B | 11 | (11, 2) |
C | 13 | (13, 3) |
void add(AggregateDataPtr place, const IColumn ** columns, const size_t row_num, Arena *) const override
{
const auto timestamp = assert_cast *>(columns[0])->getData()[row_num];
// reverse iteration and stable sorting are needed for events that are qualified by more than one condition.
for (auto i = events_size; i > 0; --i)
{
auto event = assert_cast *>(columns[i])->getData()[row_num];
if (event)
this->data(place).add(timestamp, i);
}
}
3. windowFunnel函数把每个Pair
进行merge
并排序:
排序规则:
对于每个Pair
对象,比较事件时间戳。但是如果出现两个事件的时间戳相同,则会出现随机排序。
struct ComparePairFirst final
{
template
bool operator()(const std::pair & lhs, const std::pair & rhs) const
{
return lhs.first < rhs.first;
}
};
4. 对排序之后的数据,进行漏斗分析的统计计算:
以第2步的数据为例,Pair
排序后原始数据如下:
event | event_time | pair |
---|---|---|
A | 10 | (10, 1) |
B | 11 | (11, 2) |
C | 12 | (12, 3) |
C | 13 | (13, 3) |
计算时,会构建一个 events_timestamp
的数组, 大小为全部漏斗路径的事件个数, 初始值都为 -1, events_timestamp = [-1, -1, -1]
同时, 假设 strict
默认都是 false
- 从第一个事件
A
开始遍历,event_index = 1 -1 = 0
, 此时events_timestamp[0]
被赋值,第一次循环完成
events_timestamp = [10, -1, -1]
- 遍历第二个事件
B
,event_index = 2 -1 = 1
, 由于events_timestamp[0] >0
, 并且事件间隔小于20
秒。events_timestamp[1] = events_timestamp[0] = 10
, 第二次循环完成
events_timestamp = [10, 10, -1]
- 遍历第三个事件
C
,event_index = 3 -1 = 2
, 由于events_timestamp[1] >0
, 并且事件间隔小于20
秒。events_timestamp[2] = events_timestamp[1] = 10
, 第三次循环完成
events_timestamp = [10, 10, -1]
跳出循环,返回结果 = 3
【如果,全部匹配完都不等于漏斗事件个数,就返回events_timestamp数组大于0的个数,表示该用户完成到第几步】
// Loop through the entire events_list, update the event timestamp value
// The level path must be 1---2---3---...---check_events_size, find the max event level that statisfied the path in the sliding window.
// If found, returns the max event level, else return 0.
// The Algorithm complexity is O(n).
UInt8 getEventLevel(const Data & data) const
{
if (data.size() == 0)
return 0;
if (events_size == 1)
return 1;
const_cast(data).sort();
/// events_timestamp stores the timestamp that latest i-th level event happen withing time window after previous level event.
/// timestamp defaults to -1, which unsigned timestamp value never meet
/// there may be some bugs when UInt64 type timstamp overflows Int64, but it works on most cases.
std::vector events_timestamp(events_size, -1);
for (const auto & pair : data.events_list)
{
const T & timestamp = pair.first;
const auto & event_idx = pair.second - 1;
if (event_idx == 0)
events_timestamp[0] = timestamp;
else if (strict && events_timestamp[event_idx] >= 0)
{
return event_idx + 1;
}
else if (events_timestamp[event_idx - 1] >= 0 && timestamp <= events_timestamp[event_idx - 1] + window)
{
events_timestamp[event_idx] = events_timestamp[event_idx - 1];
if (event_idx + 1 == events_size)
return events_size;
}
}
for (size_t event = events_timestamp.size(); event > 0; --event)
{
if (events_timestamp[event - 1] >= 0)
return event;
}
return 0;
}
修改建议
分析windowFunnel
执行过程后,我们可以知道,由于在Pair
排序的时候,没有考虑事件时间相同的情况,导致出现事件时间相同时,排序会出现随机。
修改建议:
增加Pair
对象事件索引的比较,如果事件时间相同,则比较事件索引,漏斗上层事件排在漏斗下层事件的前面
struct ComparePairFirst final
{
template
bool operator()(const std::pair & lhs, const std::pair & rhs) const
{
if (lhs.first < rhs.first) return true;
else if (lhs.first == rhs.first) return lhs.second < rhs.second;
else return false;
}
};
测试效果
申请了测试机, 修改后,重新编译,多次执行结果相同