背景

前几天思考了一个问题,在很多业务场景下,需要关注流量的来源或是某个业务哪个入口的流量最大,带来的效益最多,那么就涉及到流量的归因了。比如说,我是一个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可能来源于多个播放,这时候要根据不同业务场景去判断留下哪一个。

flink 过期状态删除根据哪个时间 flink状态清理 手动_etl

-- 代码实现
/*
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流是正常的累加结果。

flink 过期状态删除根据哪个时间 flink状态清理 手动_etl_02

踩坑二
主要体现在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自己手动控制状态过期清理。

flink 过期状态删除根据哪个时间 flink状态清理 手动_flink_03

踩坑三

JOIN任务JOIN不出来任何结果,发现三种不符合预期的监控表现。

  • 查看拓扑图,发现watermark已经超出当前时间了。
  • 查看监控输出QPS,一条也关联不上(输出QPS是0)。
  • 状态很快就被清理了。正常情况,双流都会保存下来30min,来等待迟到的数据。
    也就是说,在某个时候,他们都出现了未来时间,这样每个并行度发出的watermark可能正好都是未来时间,这样到了IntervalJoin时,watermark接收到的是未来时间戳,那么仔细翻查源码后发现,会根据日志时间判断是否小于Low WaterMark,如果小于就不会进行处理,并且清除掉存储在状态里面的Key。这样就解释的通。

解决方式:和业务沟通,需要将未来时间设置成当前时间,这样任务就能够正常跑了。

flink 过期状态删除根据哪个时间 flink状态清理 手动_实时归因_04

flink 过期状态删除根据哪个时间 flink状态清理 手动_flink_05

flink 过期状态删除根据哪个时间 flink状态清理 手动_kafka_06

flink 过期状态删除根据哪个时间 flink状态清理 手动_实时归因_07