“会话”事件流

时间:2018-10-30 11:19:14

标签: sql session amazon-redshift event-stream

我有一个问题应该在SQL之外解决,但是由于业务限制需要在SQL内解决。

  • 所以,请不要让我在SQL之外的数据摄取时执行此操作,但这不是一个选择...


我有一系列具有4个主要属性的事件。...

  • 源设备
  • 事件的时间戳
  • 事件的“类型”
  • 事件的“有效负载” (表示各种数据类型的可怕VARCHAR)


我需要做的是将数据流分解为(我将其称为“会话”)

  • 每个会话都特定于设备(实际上是PARTITION BY device_id
  • 没有一个会话可以包含一个以上相同类型的事件


为简化示例,我将其限制为仅包括时间戳和event_type ...

 timestamp | event_type          desired_session_id
-----------+------------        --------------------
     0     |     1                      0
     1     |     4                      0
     2     |     2                      0
     3     |     3                      0

     4     |     2                      1
     5     |     1                      1
     6     |     3                      1
     7     |     4                      1

     8     |     4                      2

     9     |     4                      3
    10     |     1                      3

    11     |     1                      4
    12     |     2                      4

理想的最终输出可能是枢转最终结果...

device_id | session_id | event_type_1_timestamp | event_type_1_payload |  event_type_2_timestamp | event_type_2_payload ...

(但这还不是一成不变的,但是我将需要“知道”组成一个会话的事件,它们的时间戳以及它们的有效载荷是什么。有可能只需附加session_id只要我不“丢失”其他属性,输入的列就足够了。)


有:

  • 12种离散事件类型
  • 数十万个设备
  • 每个设备
  • 数十万个事件
  • 每个“会话”大约6至8个事件的“规范”
  • 但有时一个会话可能只有1个或全部12个

这些因素意味着半笛卡尔乘积之类的东西,虽然不尽人意,但可能是“唯一的方法”。


我一直在(脑海中)玩着分析功能和差距和岛屿类型的流程,但从来都无法做到。我总是回到某个地方,“我”希望我可以逐行携带一些标志,并根据需要将其重置...

在SQL中不起作用的伪代码...

flags = [0,0,0,0,0,0,0,0,0]
session_id = 0
for each row in stream
   if flags[row.event_id] == 0 then
       flags[row.event_id] = 1
   else
       session_id++
       flags = [0,0,0,0,0,0,0,0,0]
   row.session_id = session_id

对此的任何SQL解决方案都值得赞赏,但如果您还考虑到“同时发生”的事件,那么您将获得“加分” ...

If multiple events happen at the same timestamp
  If ANY of those events are in the "current" session
    ALL of those events go in to a new session
  Else
    ALL of those events go in to the "current" session

If such a group of event include the same event type multiple times
  Do whatever you like
  I'll have had enough by that point...
  But set the session as "ambiguous" or "corrupt" with some kind of flag?

3 个答案:

答案 0 :(得分:1)

我不确定100%是否可以在SQL中完成。但是我对可能有效的算法有一个想法:

  • 枚举每个事件的计数
  • 将每个点的最大计数作为事件(这是会话)的“分组”

所以:

select t.*,
       (max(seqnum) over (partition by device order by timestamp) - 1) as desired_session_id
from (select t.*,
             row_number() over (partition by device, event_type order by timestamp) as seqnum
      from t
     ) t;

编辑:

这个评论太长了。我认为这需要递归CTE(RBAR)。这是因为您无法进入单行并查看累积信息或相邻信息来确定该行是否应该开始新的会话。

当然,在某些情况下显而易见(例如,上一行具有相同事件)。而且,也有可能采用一些巧妙的方法来汇总先前的数据,从而使之成为可能。

编辑II:

如果没有递归CTE(RBAR),我认为这是不可能的。这不是一个数学上的证明,但这是我的直觉来自的地方。

想象一下,您正在从当前位置回溯4行,您有:

1
2
1
2
1  <-- current row

这是什么会议?它不是确定的。考虑:

e     s           vs        e     s          
1     1                     2     1    <-- row not in look back
1     2                     1     1
2     2                     2     2
1     3                     1     2
2     3                     2     3
1     4                     1     3

该值取决于进一步追溯。显然,该示例可以一直扩展到第一个事件。我认为没有办法“汇总”较早的值以区分这两种情况。

如果您可以确定地说给定事件是新会话的开始,则该问题可以解决。至少在某些情况下,这似乎需要完整的先验知识。显然在某些情况下这很容易-例如连续两个事件。但是,我怀疑这些是此类序列的“少数”。

也就是说,您对整个表的RBAR不太了解,因为您有device_id用于并行化。我不确定您的环境是否可以做到这一点,但是在BQ或Postgres中,我会:

  • 在每个设备上聚合以创建带有时间和事件信息的结构体数组。
  • 可以使用自定义代码遍历数组一次。
  • 通过重新加入原始表或取消嵌套逻辑来重新分配会话。

答案 1 :(得分:1)

基于讨论的UPD(未经检查/测试,粗略的想法):

WITH
trailing_events as (
    select *, listagg(event_type::varchar,',') over (partition by device_id order by ts rows between previous 12 rows and current row) as events
    from tbl
)
,session_flags as (
    select *, f_get_session_flag(events) as session_flag
    from trailing_events
)
SELECT
 *
,sum(session_flag::int) over (partition by device_id order by ts) as session_id
FROM session_flags

f_get_session_flag

create or replace function f_get_session_flag(arr varchar(max))
returns boolean
stable as $$
stream = arr.split(',')
flags = [0,0,0,0,0,0,0,0,0,0,0,0]
is_new_session = False
for row in stream:
   if flags[row.event_id] == 0:
       flags[row.event_id] = 1
       is_new_session = False
   else:
       session_id+=1
       flags = [0,0,0,0,0,0,0,0,0,0,0,0]
       is_new_session = True
return is_new_session
$$ language plpythonu;

上一个答案:

可以将标志复制为事件的运行计数和2的除法余数:

1 -> 1%2 = 1
2 -> 2%2 = 0
3 -> 3%2 = 1
4 -> 4%2 = 0
5 -> 5%2 = 1
6 -> 6%2 = 0

并连接到位掩码中(类似于伪代码中的flags数组)。唯一棘手的问题是何时将所有标志完全重置为零并启动新的会话ID,但我可能会非常接近。如果您的示例表名为t,并且具有tstype列,则脚本可能如下所示:

with
-- running count of the events
t1 as (
    select
     *
    ,sum(case when type=1 then 1 else 0 end) over (order by ts) as type_1_cnt
    ,sum(case when type=2 then 1 else 0 end) over (order by ts) as type_2_cnt
    ,sum(case when type=3 then 1 else 0 end) over (order by ts) as type_3_cnt
    ,sum(case when type=4 then 1 else 0 end) over (order by ts) as type_4_cnt
    from t
)
-- mask
,t2 as (
    select
     *
    ,case when type_1_cnt%2=0 then '0' else '1' end ||
     case when type_2_cnt%2=0 then '0' else '1' end ||
     case when type_3_cnt%2=0 then '0' else '1' end ||
     case when type_4_cnt%2=0 then '0' else '1' end as flags
    from t1
)
-- previous row's mask
,t3 as (
    select
     *
    ,lag(flags) over (order by ts) as flags_prev
    from t2
)
-- reset the mask if there is a switch from 1 to 0 at any position
,t4 as (
    select *
    ,case
        when (substring(flags from 1 for 1)='0' and substring(flags_prev from 1 for 1)='1')
        or (substring(flags from 2 for 1)='0' and substring(flags_prev from 2 for 1)='1')
        or (substring(flags from 3 for 1)='0' and substring(flags_prev from 3 for 1)='1')
        or (substring(flags from 4 for 1)='0' and substring(flags_prev from 4 for 1)='1')
        then '0000'
        else flags
     end as flags_override
    from t3
)
-- get the previous value of the reset mask and same event type flag for corner case 
,t5 as (
    select *
    ,lag(flags_override) over (order by ts) as flags_override_prev
    ,type=lag(type) over (order by ts) as same_event_type
    from t4
)
-- again, session ID is a switch from 1 to 0 OR same event type (that can be a switch from 0 to 1)
select
 ts
,type
,sum(case
 when (substring(flags_override from 1 for 1)='0' and substring(flags_override_prev from 1 for 1)='1')
        or (substring(flags_override from 2 for 1)='0' and substring(flags_override_prev from 2 for 1)='1')
        or (substring(flags_override from 3 for 1)='0' and substring(flags_override_prev from 3 for 1)='1')
        or (substring(flags_override from 4 for 1)='0' and substring(flags_override_prev from 4 for 1)='1')
        or same_event_type
        then 1
        else 0 end
 ) over (order by ts) as session_id
from t5
order by ts
;

您可以添加必要的分区并将其扩展到12种事件类型,此代码旨在在您提供的示例表上工作……这并不完美,如果您运行子查询,您会发现标志重置的频率更高超出了需要,但总的来说,它可以正常工作,除了会话ID 2的特殊情况(具有单个事件类型= 4的其他事件之后的另一个会话的结束),因此我在same_event_type中添加了一个简单的查询,将它用作新会话ID的另一个条件,希望可以在更大的数据集上使用。

答案 2 :(得分:0)

我决定使用的解决方案实际上是通过将实际会话推迟到用python编写的标量函数来有效地“在SQL中不要这样做”。

--
-- The input parameter should be a comma delimited list of identifiers
-- Each identified should be a "power of 2" value, no lower than 1
-- (1, 2, 4, 8, 16, 32, 64, 128, etc, etc)
--
-- The input '1,2,4,2,1,1,4' will give the output '0001010'
--
CREATE OR REPLACE FUNCTION public.f_indentify_collision_indexes(arr varchar(max))
RETURNS VARCHAR(MAX)
STABLE AS
$$
    stream = map(int, arr.split(','))
    state = 0
    collisions = []
    item_id = 1
    for item in stream:
        if (state & item) == (item):
            collisions.append('1')
            state = item
        else:
            state |= item
            collisions.append('0')
        item_id += 1

    return ''.join(collisions)
$$
LANGUAGE plpythonu;

注意:如果有数百种事件类型,我将不使用它;)


有效地,我按顺序传递了事件的数据结构,返回的是新会话开始的数据结构。

我选择了实际的数据结构,因此使SQL方面的事情变得尽可能简单。 (可能不是最好的,对其他想法很开放。)

INSERT INTO
    sessionised_event_stream
SELECT
    device_id,
    REGEXP_COUNT(
        LEFT(
            public.f_indentify_collision_indexes(
                LISTAGG(event_type_id, ',')
                    WITHIN GROUP (ORDER BY session_event_sequence_id)
                    OVER (PARTITION BY device_id)
            ),
            session_event_sequence_id::INT
        ),
        '1',
        1
    ) + 1
        AS session_login_attempt_id,
    session_event_sequence_id,
    event_timestamp,
    event_type_id,
    event_data
FROM
(
    SELECT
        *,
        ROW_NUMBER()
            OVER (PARTITION BY device_id
                      ORDER BY event_timestamp, event_type_id, event_data)
                AS session_event_sequence_id
    FROM
        event_stream
)
  1. 为事件设置确定的顺序(事件同时发生等)
    ROW_NUMBER() OVER (stuff) AS session_event_sequence_id

  2. 创建event_type_id的逗号分隔列表
    LISTAGG(event_type_id, ',') => '1,2,4,8,2,1,4,1,4,4,1,1'

  3. 使用python找出边界
    public.f_magic('1,2,4,8,2,1,4,1,4,4,1,1') => '000010010101'

  4. 对于序列中的第一个事件,计算直到“边界”中第一个字符并包括该字符的1的数目。对于序列中的第二个事件,请计算1的数量,直到并包括边界中的第二个字符,等等。
    event 01 = 1 => boundaries = '0' => session_id = 0
    event 02 = 2 => boundaries = '00' => session_id = 0
    event 03 = 4 => boundaries = '000' => session_id = 0
    event 04 = 8 => boundaries = '0000' => session_id = 0
    event 05 = 2 => boundaries = '00001' => session_id = 1
    event 06 = 1 => boundaries = '000010' => session_id = 1
    event 07 = 4 => boundaries = '0000100' => session_id = 1
    event 08 = 1 => boundaries = '00001001' => session_id = 2
    event 09 = 4 => boundaries = '000010010' => session_id = 2
    event 10 = 4 => boundaries = '0000100101' => session_id = 3
    event 11 = 1 => boundaries = '00001001010' => session_id = 3
    event 12 = 1 => boundaries = '000010010101' => session_id = 4

    REGEXP_COUNT( LEFT('000010010101', session_event_sequence_id), '1', 1 )

结果不是很快速,但是很健壮,仍然比我尝试过的其他选项要好。它的感觉是(也许,也许我不确定,警告,警告) 如果流中有100个项目,则LIST_AGG()被调用一次,而python UDF被称为100次。我可能错了。我已经看到Redshift做得更糟糕;)


伪代码,可能是更糟糕的选择。

Write some SQL that can find "the next session" from any given stream.

Run that SQL once storing the results in a temp table.
=> Now have the first session from every stream

Run it again using the temp table as an input
=> We now also have the second session from every stream

Keep repeating this until the SQL inserts 0 rows in to the temp table
=> We now have all the sessions from every stream

计算每个会话所花费的时间相对较少,并且实际上是重复向RedShift请求的开销所控制。这也意味着主要因素是“最长的流中有多少会话”(在我的案例中,0.0000001%的流是平均流的1000倍。)

在大多数 个体 情况下,python版本实际上速度较慢,但​​并不被那些令人讨厌的异常值所控制。这意味着python版本的整体完成速度比此处描述的“外部循环”版本快约10倍。它还使用存储桶加载了总计更多的CPU资源,但现在消耗时间是更重要的因素:)