将ODS层的数据通过清洗转换处理成DWD层的数据,保存成parquet格式
主要工作:
- 数据规范处理,将一些字段处理成同一的规范(时间,日期,空字符统一...)
- 将设备id,和用户账号同时为空的记录过滤
- 将分析用的一个关键字段缺失的记录过滤
- 过滤时间不符合的记录
- 将数据扁平化
- session分割(对App的用户两次操作的时间)
1,对于web端日志,按天然session分割,不需处理
2,对于app日志,由于使用了登录保持技术,导致app进入后台很长时间后,再恢复前台,依然是同一个session,不符合session分析定义,需要按事件间隔时间切割(30分钟)
3,对于wx小程序日志,与app类似,session有效期很长,需要按事件间隔时间切割(30分钟)
- 转换特定字段,例如gps,ip转换为位置信息(公司内部的位置词库)
- 维度集成,将某些字段转换业务信息
- 对数据进行业务分析之后,打上指定类型的标记
- 形成系统内部的用户唯一标识
这里主要说一下[形成系统内部的唯一标识],其余的都是常规的处理,不在此赘述
这里碰到的问题就是 如何确定用户的唯一标识以及技术方案
1.直接使用设备ID
-
同一用户在不同设备使用会被认为不同的用户,对后续的分析统计有影响。
-
不同用户在相同设备使用会被认为是一个用户,也对后续的分析统计有影响。
2.一个设备绑定一个用户
-
一个设备 ID 只能和一个登录 ID 关联,而事实上一台设备可能有多个用户使用。
-
一个登录 ID 只能和一个设备 ID 关联,而事实上一个用户可能用一个登录 ID 在多台设备上登录。
3.一个用户绑定多个设备
-
一个设备 ID 只能和一个登录 ID 关联,而事实上一台设备可能有多个用户使用。
-
一个设备 ID 一旦跟某个登录 ID 关联或者一个登录 ID 和一个设备 ID 关联,就不能解除(自动解除)。
而事实上,设备 ID 和登录 ID 的动态关联才应该是更合理的
4.动态绑定
方案设计
- 当一个设备从来没有登陆过的时候,只能使用设备id,从登陆之后这个设备就id会和第一次登陆的用户id绑定
- 如果一个设备一直被一个用户使用,那么就是和用户id绑定,如果偶尔出现一次其他用户使用这个设备,那么不会修改
- 但是如果这个设备有两个用户,设备和登陆次数多的用户绑定,如果一个用户有两个手机,并且这两个设备id都是和此用户绑定,那么就按照两个用户计算
具体实现
对用户的日志数据进行批处理,针对上面的设计生成用户全局唯一标识对照表,每天都将表中的数据和日志进行匹配校验,保证数据的有效和及时性,
最终形成一个根据设备ID匹配用户编号的表
设备号 | 用户列表(评分) |
设备1 | 用户1(300),用户2(900) |
设备2 | 用户2(200),用户3(400) |
设备3 | 用户1(700),用户3(200) |
通过设备编号,以及评分找到没有登陆账号情况时候最有可能的用户是谁,为每条记录绑定一个Guid,最后将文件保存成parquet文件导入到数据库中,每天对表中的设备号对应的用户进行评分更新,将设备和最新的用户进行绑定
1.找出日志中的每个设备,用户的最早登陆时间 |
2.对每个设备进行聚合开窗,对同一设备的不同登陆用户的顺序进行加权打分 |
3.加上之前的结果,求出上表 |
核心代码(已简化)
Logger.getLogger("org").setLevel(Level.WARN)
val spark = SparkSession.builder().master("local[*]").appName("用户唯一标识映射").getOrCreate()
val frame: DataFrame = spark.read.json("dw_etl\\data\\logdata\\event_log")
//需要对每个数据进行处理,生成初步的映射关系表
frame.printSchema()
import spark.implicits._
import org.apache.spark.sql.functions._
val infoto3col = frame.select("account", "deviceId", "timeStamp")
//将数据分两份,一份是有用户登陆的,另一份是没有用户登录的
val fun1=s=>{StringUtils.isBlank(s)}
spark.udf.register("isBlank",fun1)
//登陆账号不是空的
val accountIdIsNotNull = infoto3col.where("!isBlank(account)")
//将登陆账号为空的过滤掉
//然后对deviceId和accountID做分组聚合
//找出同一个设备,同一个用户的最早登陆时间
val devAndAccount = accountIdIsNotNull.groupBy('deviceId, 'account).agg(min('timeStamp) as 'ts)
//根据设备id进行开窗,通过登陆时间排序,进行差别打分
val spec = Window.partitionBy('deviceId).orderBy('ts)
val rowNumAndDevAndAccount = devAndAccount.select('deviceId, 'account, 'ts, row_number() over (spec) as 'rownum).orderBy('deviceId, 'account)
//对不同的登陆顺序进行加权评分
rowNumAndDevAndAccount.selectExpr("deviceId", "account", "ts", "rownum","100-(rownum-1)*10 as score").show()
//然后将此列表和之前的列表进行相加,求出得分最高的,就是该用户的可能性最大
输出结果如下:
+------------+-------+-------------+-----------+-----------+
| deviceId|account| ts | rownum | score |
+------------+-------+-------------+-----------+-----------+
|111111111111| 1221|1598866481882| 3| 80|
|111111111111| 1223|1598861484882| 2| 90|
|111111111111| 1224|1598861481882| 1| 100|
|aaaaaaaaaaaa| 1221|1598861483882| 1| 100|
|aaaaaaaaaaaa| 1224|1598864481882| 2| 90|
|bbbbbbbbbbbb| 1224|1598861482882| 1| 100|
|szqwdecx78RA| 1221|1598861487882| 1| 100|
|szqwdecx78RA| 1223|1598862481882| 2| 90|
|szqwdecx78RA| 1224|1598865481882| 3| 80|
+------------+-------+-------------+-----------+-----------+
改进之处
在处理日志新老访客的时候,主要使用的是对设备ID和全局唯一标识同时判断,如果该设备使用过公司产品,那么就指定为老访客,但是考虑到用户更换手机登录而误判为新用户,所以加上了全局唯一标识,同时满足就是新用户,在设计之初,使用的是广播变量对用户设备表进行广播,但是随着数据量越来越大,该表的体积也渐渐不适合进行广播,然后有使用的单例对象在Executor端进行初始化,但是并没有真正解决根本性的问题,所以使用了布隆过滤器,迅速提高了新老处理的速度
布隆过滤器存在一定的误差,有一个专门的公式计算,可以通过调节拟定长度和hash次数降低误差
如果大量得数据都需要进行序列化持久到下一个stag得时候,可以使用Kryo序列化,还有avro跨平台序列化方式,极大得减少了序列化文件的大小,降低了网络和磁盘压力
核心代码(简化)
public static void main(String[] args) {
// 数据集,可以存在内存中,也可以是一个输入流
String[] arr = "aa,bb,cc,dd,ee,abc,ddb,cce,xxy,xxa,abx,axy,aby,wr".split(",");
//使用的时候直接创建一个布隆过滤,然后拟定的数量和精度
BloomFilter<String> bloomFilter = BloomFilter.create(Funnels.stringFunnel(Charsets.UTF_8), 10000000, 0.00001);
// 映射数据进布隆过滤器,将
for(String s:arr){
bloomFilter.put(s);//将数据集重点饿数据放入到其中
}
// 使用布隆过滤器来判断一个字符串是否存在于参考数据集中
System.out.println(bloomFilter.mightContain("bbb"));
System.out.println(bloomFilter.mightContain("bcc"));
System.out.println(bloomFilter.mightContain("cce"));
System.out.println(bloomFilter.mightContain("xxx"));
}
IDmapping的总体思路
加载之前的idmapping数据,将今天最早登陆该设备的用户+100分,对于第二个登陆的+90,依次类推,给每个设备计算出登陆过的用户的所有的分数,然后根据分数的降序,求第一条,此时的guid就是对应的分数最高的,然后
首先对日志中的数据根据设备和用户联合分组,开窗取出最小的日期,(这样过滤掉了一个用户的相同行为)
然后开窗找出同一个设备id下的不同登陆顺序,打上行号,将100相减行号*10,得到的就是差别得分,返回得分最高的作为guid
这时候就形成了IDmapping
val preUidScore = spark.sql(
"""
|
|select
|deviceid,
|uid_score.account as account,
|uid_score.timestamp as ts,
|uid_score.score as score
|
|from preidmp lateral view explode(uid_list) tmp as uid_score
|
|""".stripMargin)
val todayUidScoreResult = spark.sql(
"""
|select
|deviceid,
|account,
|ts,
|score
|from
|(
|select
|
|deviceid,
|account,
|ts,
|score,
|row_number() over(partition by deviceid order by account desc) as rn
|
|from
|(
|select
|
|deviceid,
|account,
|min(ts) as ts,
|sum(score) as score
|
|from whole
|group by deviceid,account
|) o1
|) o2
|where !(rn>1 and account is null)
|
|""".stripMargin)
val finalResult = spark.sql(
"""
|select
| deviceid,
| collect_list(if(account is not null,struct(account,timestamp,score),null)) as uid_list,
| max(guid) as guid
|from
| (
| select
| deviceid,
| account,
| ts as timestamp,
| score,
| first_value(if(account is not null,account,deviceid)) over(partition by deviceid order by score desc,ts) as guid
| from
| tod
| ) o
|group by deviceid
|
|""".stripMargin)