ClickHouse WindowFunnel 函数修改建议

背景

ClickHouse 作为性能卓越的OLAP引擎,有丰富的数据分析函数。公司增长分析侧使用 ClickHousewindowFunnel 函数进行自定义漏斗和路径分析。

但是,使用中, 存在这样的一个现象。例如:

用户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
Bug复现

原因分析

公司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;
    }
};

测试效果

申请了测试机, 修改后,重新编译,多次执行结果相同


测试结果

你可能感兴趣的:(ClickHouse WindowFunnel 函数修改建议)