背景
前几天思考了一个问题,在很多业务场景下,需要关注流量的来源或是某个业务哪个入口的流量最大,带来的效益最多,那么就涉及到流量的归因了。比如说,我是一个bilibili up主,那么我想知道我的某个视频到底是首页推荐的流量比较多,还是用户搜索带来的比较多。我觉得得分为两种情况
- 应用埋点质量非常差的情况下,那么在一些APP或者H5发展之初,是不会太去注重埋点的质量,当流量密码时代到来了,才发现这是一个风口,埋点标准化改造就是一个必不可少的环节。那么在改造之前,就只能靠数据自己去归因,即按照时间窗口,根据用户的行为顺序将用户的行为串起来进行归因。
- 应用埋点质量非常好的情况下,这种就是埋点改造之后了,大量的用户信息可以在埋点中直接的体现,比如上面说的归因,埋点本身可以记录用户的行为路径的。
微信公众号:小满锅
归因实现方案
离线
针对归因的离线方案,现如今已经有很多的窗口实现方式。这里简单列个伪代码。
-- 通过模拟session的方式,对用户的行为进行归因。即对同一个连续会话窗口的KEY排序,然后归到一个元素上。
SELECT
FIRST_VALUE(refer) OVER(PARTITION BY KEY,session ORDER BY logtime) as refer
FROM
(
SELECT
SUM(IF(logtime - LAG(logtime)>1000, 1, 0)) OVER(PARTITION BY KEY ORDER BY logtime) as session -- 日志相差太大时间就是一个新的session
FROM [表名]
WHERE [过滤条件]
) t
实时
实时场景其实也可以类似的实现,但是要略微做一下修改。假设B站的分享没有带归因,但是播放带了,那我们需要看分享的来源的时候,就需要和播放归因。那由于Kakfa只能保证每个PARTITION写入的时间是顺序的,不能保证写入的logtime是顺序的(因为客户端时间无法保证一定是当前时间,某些用户设置当前时间,有的可能设置未来时间了),那么在DOAWNSTREAM去处理时,我们需要对SHARE和PLAY先LEFT JOIN,然后排序,LEFT JOIN是为了实现离线的某个action的过滤(就是说离线归因对SHARE和PLAY排序,最终选择action=share即可),LEFT JOIN之后的结果就是说我的某个share可能来源于多个播放,这时候要根据不同业务场景去判断留下哪一个。
-- 代码实现
/*
share流和play流需要自己在自己平台提前配置好watermark和eventtime
**/
-- 先创建一个LEFT JOIN之后的视图
CREATE VIEW share_join_play AS
(
SELECT
share.*,
play.*
FROM share
LEFT JOIN play
ON(
share.userid = play.userid
AND share.deviceid = play.deviceid
AND share.os = play.os
AND share.os_ver = play.os_ver
AND share.app_ver = play.app_ver
AND share.resource_type = play.resource_type
AND share.resource_id = play.resource_id
AND share.eventtime between play.eventtime - INTERVAL '15' MINUTE AND share.eventtime +INTERVAL '15' MINUTE
-- 关联share前后十分钟的播放即可
-- 关联10min后是为了避免埋点在某些情况下,前面的播放埋点可能没有上报或者出错,那就往后面这个资源的播放归,因为某些情况下可能用户没播放就开始分享。这个窗口的逻辑根据自己的业务场景去顶。
);
-- 根据JOIN之后的视图进行排序
CREATE VIEW share_attr_play AS
(
SELECT
share_refer
FROM
(
SELECT
play.refer AS share_refer, -- 将JOIN出来的所有可能refer,拿出来作为share的refer
-- 优先分享前的播放归因,且优先最先的播放归因。这个根据自己业务需求可以定义多个ROW_NUMBER
ROW_NUMBER() OVER(
PARTITION BY dt,userid,deviceid,os,os_ver,app_ver,resource_type,resource_id
ORDER BY IF(play.logtime - share.logtime < 0, 1, 0) DESC, ABS(play.logtime - share.logtime)
) AS rn
FROM share_join_play
) t
where rn = 1 -- 取第一个
);
-- 然后根据某些KEY去统计 这里统计每一天每个资源id的各个分享归因的次数
SELECT
share_refer, dt, resource_id, sum(1) as pv
FRON share_attr_play
GROUP BY share_refer, dt, resource_id
踩坑一
数据流的过程如下图,其中的结果可以尝试开启MiniBatch优化,和Local-GlobalAggr优化。
前面JOIN都没问题,直到RANK出来的结果,它是一个Retrace流,因为我们的数据有先来后到,日志时间控制不了的,比如有两条JOIN结果A和B,其中A的PLAY比SHARE时间早1秒,但是B的早5秒,由于数据延迟的原因,这个B数据可能晚来,那么在B来之前,这个RANK发到下游的结果应该是归到了A,所以下发了一个INSERT A。一旦B来了,那么就会下发DELETE A和INSERT B。这时候如果直接写入外部存储就会有问题。而GROUP BY恰好可以处理这种流,不过同样的,它下发的仍然是Retract流,一种是INSERT,一种是DELETE。
踩坑一:就是这里,写入的外部存储数据有问题,两种流不好区分。这里有两种处理方式
- 第一种是如果Flink本身支持识别这种的INSERT和DELETE流的话,可以再group by time window,每隔一分钟计算一次,DELETE代表-,INSERT代表+,然后sum一下。
- 第二种就是Flink平台自己将DELETE过滤掉,使用一种主键更新SET的外部存储,在这种情况下,每一个KEY只会由Flink的一个PARTITION发出,由它INSERT到外部主键更新的存储中,以一种覆盖的操作代替Flink的DELETE流,并且这个INSERT流是正常的累加结果。
踩坑二
主要体现在JOIN的那个地方。经过仔细排查,发现INTERVAL 15 MINUTE的时间窗口貌似没有将过期数据处理掉,由于GROUP BY需要一整天的状态,因此我设置了table.exec.state.ttl为24h,这样貌似导致join的窗口过期也时效了。导致一个没清理。我思考了两种方式,一种是去掉table.exec.state.ttl参数,但是不清楚Group by和rank时状态啥时候清理,目前没有明确的说法。于是我采用第二种,将两算子拆分开来,JOIN任务照常计算。RANK和Group by仍然加上table.exec.state.ttl自己手动控制状态过期清理。
踩坑三
JOIN任务JOIN不出来任何结果,发现三种不符合预期的监控表现。
- 查看拓扑图,发现watermark已经超出当前时间了。
- 查看监控输出QPS,一条也关联不上(输出QPS是0)。
- 状态很快就被清理了。正常情况,双流都会保存下来30min,来等待迟到的数据。
也就是说,在某个时候,他们都出现了未来时间,这样每个并行度发出的watermark可能正好都是未来时间,这样到了IntervalJoin时,watermark接收到的是未来时间戳,那么仔细翻查源码后发现,会根据日志时间判断是否小于Low WaterMark,如果小于就不会进行处理,并且清除掉存储在状态里面的Key。这样就解释的通。解决方式:和业务沟通,需要将未来时间设置成当前时间,这样任务就能够正常跑了。