什么数仓:一个面向分析的,反映历史变化的数据仓库;
数仓的技术手段:
1)传统数仓一般都是采用关系型数据库软件;
2)大数据领域中则尚无一站式解决方案,通常需要用到很多技术组件来实现不同环节:
数据采集:flume
存储平台:hdfs
基础设施:hive
运算引擎:mapreduce/spark
资源调度:yarn
任务调度:azkaban/oozie
元数据管理:atlas(或自研系统)
OLAP引擎:kylin/presto (或clickhouse)
前端界面:superset(或自研javaweb系统)
分层原因
数据仓库中的数据表,往往是分层管理、分层计算的;
所谓分层,具体来说,就是将大量的数据表按照一定规则和定义来进行逻辑划分;
ODS层:对应着外部数据源ETL到数仓体系之后的表!
DWD层:数仓明细层;一般是对ODS层的表按主题进行加工和划分;本层中表记录的还是明细数据; DWS层:数仓服务层; ADS层:
应用层,主要是一些结果报表!
分层的意义:数据管理更明晰!运算复用度更高!需求开发更快捷!便于解耦底层业务(数据)变化!
分层详解
ODS层
数据内容:存放flume采集过来的原始日志
存储格式:以json格式文本文件存储
存储周期:3个月
DWD层
数据内容:对ODS层数据做ETL处理后的扁平化明细数据
存储格式:以orc / parquet文件格式存储
存储周期:6个月
DWS层
数据内容:根据主题分析需求,从DWD中轻度聚合后的数据
存储格式:以ORC/PARQUET文件格式存储
存储周期:1年
ADS层
数据内容:根据业务人员需求,从DWS计算出来的报表
存储格式:以ORC/PARQUET文件格式存储
存储周期:3年
DIM层
存储各种维表
ODS层
与原始日志数据保持完全一致
我们有APP端日志,PC端日志,微信小程序端日志,分别对应ODS的三个表
ODS.ACTION_APP_LOG
ODS.ACTION_WEB_LOG
ODS.ACTION_WXAPP_LOG
建表时,一般采用外部表;
表的数据文件格式:跟原始日志文件一致
分区:按天分区(视数据量和计算频度而定,如数据量大且需每小时计算一次,则可按小时粒度分区)
DWD层
建模思想
通常是对ODS层数据进行精细化加工处理
不完全星型模型
事实表中,不是所有维度都按维度主键信息存储(维度退化)
事实表
app_event_detail: APP-Event事件明细表
web_event_detail: WEB-Event事件明细表
wxapp_event_detail: 小程序-Event事件明细表
维度表
coupon_info
ad_info
campain_info
lanmu_info
page_info
page_type
pindao_info
promotion_location
huodong_info
miaosha_info
product
product_detail
product_type
shop_info
tuangou_info
user_info
DWS层
建模思想
最主要思路:按照分析主题,"汇总"各类数据成大宽表
也有一些做法是,将DWS层的表设计成“轻度聚合表”
主要表模型
流量会话聚合天/月表
日新日活维度聚合表
事件会话聚合天/月表
访客连续活跃区间表
新用户留存维度聚合表
运营位维度聚合表
渠道拉新维度聚合表
访客分布维度聚合表
用户事件链聚合表(支撑转化分析,高级留存分析等)
……更多
埋点日志在本项目中,有3大类:
日志生成在了公司的N台(5台)日志服务器中,现在需要使用flume采集到HDFS
a1.sources = r1
a1.channels = c1
a1.sinks = k1 k2
a1.sources.r1.channels = c1
a1.sources.r1.type = TAILDIR
a1.sources.r1.filegroups = g1 g2
a1.sources.r1.filegroups.g1 = /opt/data/logdata/app/event.*
a1.sources.r1.filegroups.g2 = /opt/data/logdata/wx/event.*
a1.sources.r1.headers.g1.datatype = app
a1.sources.r1.headers.g2.datatype = wx
a1.sources.r1.batchSize = 100
a1.sources.r1.interceptors = i1
a1.sources.r1.interceptors.i1.type = cn.doitedu.flume.interceptor.EventTimeStampInterceptor$EventTimeStampInterceptorBuilder
a1.sources.r1.interceptors.i1.headerName = timestamp
a1.sources.r1.interceptors.i1.timestamp_field = timeStamp
a1.sources.r1.interceptors.i1.to_encrypt_field = account
a1.channels.c1.type = file
a1.channels.c1.checkpointDir = /opt/data/flumedata/file-channel/checkpoint
a1.channels.c1.dataDirs = /opt/data/flumedata/file-channel/data
a1.sinks.k1.channel = c1
a1.sinks.k1.type = avro
a1.sinks.k1.hostname = hdp02.doitedu.cn
a1.sinks.k1.port = 41414
a1.sinks.k1.batch-size = 100
a1.sinks.k2.channel = c1
a1.sinks.k2.type = avro
a1.sinks.k2.hostname = hdp03.doitedu.cn
a1.sinks.k2.port = 41414
a1.sinks.k2.batch-size = 100
# 定义sink组及其配套的sink处理器
a1.sinkgroups = g1
a1.sinkgroups.g1.sinks = k1 k2
a1.sinkgroups.g1.processor.type = failover
a1.sinkgroups.g1.processor.priority.k1 = 5
a1.sinkgroups.g1.processor.priority.k2 = 1
a1.sinkgroups.g1.processor.maxpenalty = 10000
a1.sources = r1
a1.channels = c1
a1.sinks = k1
a1.sources.r1.channels = c1
a1.sources.r1.type = avro
a1.sources.r1.bind = 0.0.0.0
a1.sources.r1.port = 41414
a1.sources.r1.batchSize = 100
a1.channels.c1.type = file
a1.channels.c1.checkpointDir = /opt/data/flumedata/file-channel/checkpoint
a1.channels.c1.dataDirs = /opt/data/flumedata/file-channel/data
a1.sinks.k1.channel = c1
a1.sinks.k1.type = hdfs
a1.sinks.k1.hdfs.path = hdfs://hdp01.doitedu.cn:8020/logdata/%{datatype}/%Y-%m-%d/
a1.sinks.k1.hdfs.filePrefix = DoitEduData
a1.sinks.k1.hdfs.fileSuffix = .log
a1.sinks.k1.hdfs.rollInterval = 60
a1.sinks.k1.hdfs.rollSize = 268435456
a1.sinks.k1.hdfs.rollCount = 0
a1.sinks.k1.hdfs.batchSize = 100
a1.sinks.k1.hdfs.codeC = gzip
a1.sinks.k1.hdfs.fileType = CompressedStream
a1.sinks.k1.hdfs.useLocalTimeStamp = false
ODS:操作数据层
假如:公司用户规模1000万
平均日活400万
平均每天每个用户访问1.2次
每个用户平均每次访问时长10分钟
按经验,每个用户平均每 5~10 秒产生一条事件
则每次访问,将产生10分钟60秒/10 = 60条事件日志
则,每天产生的日志总条数: 400万1.2*60条 = 28800 *万=2.88亿条日志
每条日志大小平均为0.5k,则每日增量日志大小为:
28800万0.5k = 288005M= 144G
每月累积增量为:144G*30 = 4.3T
假如要存储1年的数据量,则1年的累计存储量为:51.6T
考虑,增长趋势: 预估每月增长20%
则1年的累计存储量为:接近100T
注:在这里也可以估算实时流式计算中的数据量,假如最高峰值时,每秒同时在线人数有10万,则在此峰值期间,每秒将有2万条日志产生
采集源:KAFKA
TOPIC:app_log, wx_log,web_log
采集工具:FLUME
4.1.2入库要求
普通文本文件,JSON数据格式,导入hive表后,要求可以很方便地select各个字段
4.2.1Json数据的hvie解析
由于原始数据是普通文本文件,而文件内容是json格式的一条一条记录
在创建hive表结构进行映射时,有两种选择:
1.将数据视为无结构的string
2.将数据按json格式进行映射(这需要外部工具包JsonSerde 的支持)
本项目采用方案2来进行建表映射
下载第三方JsonSerde工具包
JsonSerde 的 github 地址:https://github.com/rcongiu/Hive-JSON-Serde
JsonSerde 的 jar下载地址:http://www.congiu.net/hive-json-serde/
下载 json-serde-1.3.7-jar-with-dependencies.jar 并上传到 Hive的/lib库目录下
如果需要,也可以把本jar包安装到本地maven库
bin\mvn install:install-file -Dfile=d:/json-serde.1.3.8.jar
-DgroupId=“org.openx.data” -DartifactId=json-serde -Dversion=“1.3.8” -Dpackaging=jar
ROW FORMAT SERDE ‘org.apache.hadoop.hive.serde2.JsonSerDe’ 官方文档:
https://cwiki.apache.org/confluence/display/Hive/LanguageManual+DDL#LanguageManualDDL-JSON
4.2.2知识补充:hive建表完整写法
create table t3(
id int,
name string
)
PARTITIONED BY (dt string)
ROW FORMAT SERDE
'org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe'
WITH SERDEPROPERTIES (
'field.delim'=',',
'line.delim'='\n'
)
STORED AS INPUTFORMAT
'org.apache.hadoop.mapred.TextInputFormat'
OUTPUTFORMAT
'org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat'
TBLPROPERTIES(
'EXTERNAL'='TRUE',
'comment'='this is a ods table',
'orc.compress'='snappy'
)
;
4.2.3app事件日志建表
drop table if exists ods.app_event_log;
create external table ods.app_event_log
(
account string,
appId string,
appVersion string,
carrier string,
deviceId string,
deviceType string,
eventId string,
ip string,
latitude double,
longitude double,
netType string,
osName string,
osVersion string,
properties map,
releaseChannel string,
resolution string,
sessionId string,
`timeStamp` bigint
)
partitioned by (y string,m string,d string)
row format serde 'org.apache.hive.hcatalog.data.JsonSerDe'
stored as textfile
;
4.2.4web事件日志
drop table if exists ods.web_event_log;
create external table ods.web_event_log(
// TODO 自己补上
)
partitioned by (y string,m string,d string)
row format serde 'org.openx.data.jsonserde.JsonSerDe'
stored as textfile;
4.2.5wxapp事件日志
drop table if exists ods.wxapp_event_log;
create external table ods.wxapp_event_log(
// TODO 自己补上
)
partitioned by (y string,m string,d string)
row format serde 'org.openx.data.jsonserde.JsonSerDe'
stored as textfile;
4.3.1入库命令
load data inpath ‘/pa/th’ into table ods.app_event_log partition (y=’2020’,m=’06’,d=’22’);
load data inpath ‘/pa/th’ into table ods.web_event_log partition (y=’2020’,m=’06’,d=’22’);
load data inpath ‘/pa/th’ into table ods.wxapp_event_log partition (y=’2020’,m=’06’,d=’22’);
4.3.2脚本开发
用户信息,在业务系统的mysql数据库中
产品信息,在业务系统的mysql数据库中
活动信息,团购信息,秒杀信息等,在业务系统的mysql数据库中
频道,栏目信息,向app前端开发组发出需求,请求提供
由于本层数据ETL的需求较为复杂,用hive sql实现非常困难
因而此环节将开发spark程序来实现
清洗过滤
1,去除json数据体中的废弃字段(前端开发人员在埋点设计方案变更后遗留的无用字段):
2,过滤掉json格式不正确的(脏数据)
3,过滤掉日志中缺少关键字段(deviceid/properties/eventid/sessionid 缺任何一个都不行)的记录!
4,过滤掉日志中不符合时间段的记录(由于app上报日志可能的延迟,有数据延迟到达)
5,对于web端日志,过滤爬虫请求数据(通过useragent标识来分析)
数据解析
将json打平,解析成扁平格式
注:properties字段不用扁平化,转成Map类型存储即可
SESSION分割
1,对于web端日志,按天然session分割,不需处理
2,对于app日志,由于使用了会话保持策略,导致app进入后台很长时间后,再恢复前台,依然是同一个session,不符合session分析定义,需要按事件间隔时间切割(业内通用:30分钟)
3,对于wx小程序日志,与app类似,session有效期很长,需要按事件间隔时间切割(业内通用:30分钟)
数据规范处理
数据集成
1,将日志中的GPS经纬度坐标解析成省、市、县(区)信息;(为了方便后续的地域维度分析)
2,将日志中的IP地址解析成省、市、县(区)信息;(为了方便后续的地域维度分析)
注:app日志和wxapp日志,有采集到的用户事件行为时的所在地gps坐标信息
web日志则无法收集到用户的gps坐标,但可以收集到ip地址
gps坐标可以表达精确的地理位置,而ip地址只能表达准确度较低而且精度较低的地理位置
ID_MAPPING(全局用户标识生成)
为每一个用户每一条访问记录,标识一个全局唯一ID
(给匿名访问记录,绑定到一个id上)
选取合适的用户标识对于提高用户行为分析的准确性有非常大的影响,尤其是漏斗、留存、Session 等用户相关的分析功能。
因此,我们在进行任何数据接入之前,都应当先确定如何来标识用户。
新老访客标记
新访客,标记为1
老访客,标记为0
保存结果
最后,将数据输出为parquet格式,压缩编码用snappy
注:parquet和orc都是列式存储的文件格式,两者对于分析运算性的读取需求,都有相似优点
在实际性能测试中(读、写、压缩性能),ORC略优于PARQUET此处可以选择orc,也可以选择parquet,选择parquet的理由则是,parquet格式的框架兼容性更好,比如impala支持parquet,但不支持orc
GPS地理位置解析
gps坐标数据形如: (130.89892350983459, 38.239879283598)
怎样才能解析为地理位置: 河北省,石家庄市,裕华区
Geohash编码是一种地理位置编码技术,它可将一个gps坐标(含经、纬度)点,转化为一个字符串;
wx3y569
wx3y569
通过编码后得到的字符串,表达的是:包含被编码gps坐标点的一个矩形范围;
在地球经纬度范围内,不断通过二分来划分矩形范围,通过观察gps坐标点所落的范围,来反复生成0/1二进制码。
在满足精度要求后,将所得的二进制编码通过base32编码技术转成字符串码,如下所示:
字符串长度越长,表达的精度越高,矩形范围越小,越逼近原gps坐标点;
相反,长度越短,表达的精度越低,矩形范围越大;
geohash码的精确度对应表格:
gps坐标 转码成 geohash编码,这个算法不需要自己手写,有现成的工具包
maven依赖坐标:
ch.hsr
geohash
1.3.0
api调用示例:
String geohashcode = GeoHash.withCharacterPrecision(45.667, 160.876547, 6).toBase32();
高德地图开放API
IP地址地理位置解析
ip地址数据形如:202.102.36.87
怎样才能解析为地理位置: 江苏省,南京市,电信
通过算法是无法从ip地址算出地理位置的
需要ip和地理位置映射字典才有可能做到,类似如下数据:
将字典中的起始ip和结束ip,都设法转成整数,这样,ip地址段就变成了整数段
接下来,将未知区域的ip按照相同方法转换成整数,则能相对方便地查找到字典数据了
具体的搜索算法,可以使用二分查找算法
开源工具包ip2region(含ip数据库)
项目地址: https://gitee.com/lionsoul/ip2region
使用方法
引入jar包依赖:
org.lionsoul ip2region 1.7.2
Api调用代码
// 初始化配置参数
val config = new DbConfig
// 构造搜索器,dbFile是ip地址库字典文件所在路径
val searcher = new DbSearcher(config, “initdata/ip2region.db”)
// 使用搜索器,调用查找算法获取地理位置信息
val block = searcher.memorySearch(“39.99.177.94”)
println(block)
在登录状态下,日志中会采集到用户的登录id(account),可以做到用户身份的精确标识;
而在匿名状态下,日志中没有采集到用户的登录id
如何准确标识匿名状态下的用户,是一件棘手而又重要的事情;
困难原因
在事件日志中,对用户能产生标识作用的字段有:
app日志中,有deviceid,account
web日志中,有cookieid,ip,account
wxapp日志中,有openid,account
在现实中,一个用户,可能处于如下极其复杂的状态:
备选方案(了解)
1,只使用设备 ID
适合没有用户注册体系,或者极少数用户会进行多设备登录的产品,如工具类产品、搜索引擎、部分小型电商等。
这也是绝大多数数据分析产品唯一提供的方案。
同一用户在不同设备使用会被认为不同的用户,对后续的分析统计有影响。
不同用户在相同设备使用会被认为是一个用户,也对后续的分析统计有影响。
但如果用户跨设备使用或者多用户共用设备不是产品的常见场景的话,可以忽略上述问题。
2,关联设备 ID 和登录 ID(一对一)
成功关联设备 ID 和登录 ID 之后,用户在该设备 ID 上或该登录 ID 下的行为就会贯通,被认为是一个 全局 ID 发生的。在进行事件、漏斗、留存等用户相关分析时也会算作一个用户。
关联设备 ID 和登录 ID 的方法虽然实现了更准确的用户追踪,但是也会增加复杂度。
所以一般来说,我们建议只有当同时满足以下条件时,才考虑进行 ID 关联:
需要贯通一个用户在一个设备上注册前后的行为。
需要贯通一个注册用户在不同设备上登录之后的行为。
一个设备 ID 只能和一个登录 ID 关联,而事实上一台设备可能有多个用户使用。
一个登录 ID 只能和一个设备 ID 关联,而事实上一个用户可能用一个登录 ID 在多台设备上登录。
3,关联设备 ID 和登录 ID(多对一)
一个用户在多个设备上进行登录是一种比较常见的场景,比如 Web 端和 App 端可能都需要进行登录。支持一个登录 ID 下关联多设备 ID 之后,用户在多设备下的行为就会贯通,被认为是一个ID 发生的。
一个设备 ID 只能和一个登录 ID 关联,而事实上一台设备可能有多个用户使用。
一个设备 ID 一旦跟某个登录 ID 关联或者一个登录 ID 和一个设备 ID 关联,就不能解除(自动解除)。
而事实上,设备 ID 和登录 ID 的动态关联才应该是更合理的。
4,关联设备 ID 和登录 ID(动态修正)
基本原则,与方案3相同
修正之处,一个设备ID被绑定到某个登陆ID(A)之后,如果该设备在后续一段时间(比如一个月内)被一个新的登陆ID(B)更频繁使用,则该设备ID会被调整至绑定登陆ID(B)
咱们项目中,就采用最复杂的方案4
依赖管理
父工程通过dependencyManagement声明依赖的相关属性(版本),但并不会真正引入依赖;
子工程在引入dependencyManagement所声明的依赖时,不需要指定版本,直接继承dependencyManagement中声明的版本!
属性定义
通过properties标签可以声明自定义属性(属性名、属性值)
在pom中的别的地方就可以用${属性名}来引用属性的值!
传递依赖的排除
如果我们直接引入了依赖A/B/C,都有传递依赖D,而且对D依赖的版本各不相同!就有可能在运行时产生依赖冲突!可以通过排除掉一些传递依赖来避免冲突!
创建一个父工程;
父工程中引入公共的依赖和插件;
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>cn.doitedu</groupId>
<artifactId>data_tiger</artifactId>
<packaging>pom</packaging>
<version>1.0-SNAPSHOT</version>
<modules>
<module>dataware</module>
<module>userprofile</module>
<module>recommend</module>
<module>streamingprocess</module>
<module>common</module>
</modules>
<dependencies>
<dependency>
<groupId>org.apache.spark</groupId>
<artifactId>spark-sql_2.11</artifactId>
<version>2.2.0</version>
</dependency>
<dependency>
<groupId>org.scala-lang</groupId>
<artifactId>scala-library</artifactId>
<version>2.11.12</version>
</dependency>
</dependencies>
<pluginRepositories>
<pluginRepository>
<id>ali-plugin</id>
<url>http://maven.aliyun.com/nexus/content/groups/public/</url>
<snapshots>
<enabled>true</enabled>
</snapshots>
</pluginRepository>
</pluginRepositories>
<build>
<plugins>
<!-- 指定编译java的插件 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.5.1</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
</configuration>
</plugin>
<!-- 指定编译scala的插件 -->
<plugin>
<groupId>net.alchim31.maven</groupId>
<artifactId>scala-maven-plugin</artifactId>
<version>3.2.2</version>
<executions>
<execution>
<goals>
<goal>compile</goal>
<goal>testCompile</goal>
</goals>
<configuration>
<args>
<arg>-dependencyfile</arg>
<arg>${
project.build.directory}/.scala_dependencies</arg>
</args>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
编写demo代码,测试工程;
项目的码云git地址:
https://gitee.com/hunter_d/doyiee.git
然后在idea中,通过settings->plugin ,搜索gitee插件,并安装
项目的git地址: https://gitee.com/hunter_d/doit12_yiee.git
以后,每当服务器上的项目有更新,本地可以通过pull命令来拉取、同步
IP地址地理位置解析
String ip = "202.102.36.87";
DbConfig config = new DbConfig();
// 加载ip字典库文件为一个字节数组
File file = new File("initdata\\ip2region.db");
RandomAccessFile ra = new RandomAccessFile(file, "r");
byte[] b = new byte[(int)file.length()];
ra.readFully(b);
// 构造一个ip2region搜索器
DbSearcher dbSearcher = new DbSearcher(config, b);
DataBlock block = dbSearcher.memorySearch(ip);
String region = block.getRegion();
System.out.println(region);
GPS坐标地理位置解析
val geo = GeoHash.geoHashStringWithCharacterPrecision(lat,lng,6)这里插入代码片
时间戳解析
将时间戳解析成年月日时分秒,本处理需求也可以在入仓后用sql进行处理
步骤:
1.加载T日日志数据,抽取 设备id、登录账号、会话id、时间戳
2.根据设备id+登录账号account分组,计算每个设备上每个登录账号的登陆次数(评分)
3.加载T-1日的绑定评分结果
4.将T日评分表 full join T-1日评分表,根据情况进行取值
原则:两边都有,分数累加;
T-1有,T无,则分数衰减;
代码片段,完整代码见项目工程
object IdBind {
def main(args: Array[String]): Unit = {
Logger.getLogger("org").setLevel(Level.WARN)
val spark = SparkSession.builder()
.config("spark.sql.shuffle.partitions","2")
.enableHiveSupport() // 开启hive整合支持(同时,需要引入spark-hive的依赖;引入hadoop和hive的配置文件)
.appName("地理位置知识库加工")
.master("local")
.getOrCreate()
// 加载T日日志数据
val logDf = spark.read.table("ods17.app_action_log").where("dt='2020-10-07'")
logDf.createTempView("logdf")
// 计算T日的 设备->账号 绑定得分
val loginCnts = spark.sql(
"""
|
|select
|deviceid,
|if(account is null or trim(account)='',null,account) as account,
|-- count(distinct sessionid) as login_cnt,
|min(timestamp) as first_login_ts,
|count(distinct sessionid)*100 as bind_score
|from logdf
|group by deviceid,account
|
|""".stripMargin)
loginCnts.createTempView("today")
println("当天评分结果")
loginCnts.show(100)
// 加载 T-1的 绑定得分 (从hive的绑定评分表中加载)
// val bindScorePre = spark.read.parquet("dataware/data/idbind/output/day01")
val bindScorePre = spark.read.table("dwd17.id_account_bind").where("dt='2020-10-06'")
println("历史评分结果")
bindScorePre.show(100)
bindScorePre.createTempView("yestoday")
// 全外关联两个绑定得分表
// 并将结果写入hive表的当天分区(T-1日分区就无用了)
val combined = spark.sql(
"""
|
|insert into table dwd17.id_account_bind partition(dt='2020-10-07')
|
|select
|if(today.deviceid is null,yestoday.deviceid,today.deviceid) as deviceid,
|if(today.account is null,yestoday.account,today.account) as account,
|if(yestoday.first_login_ts is not null,yestoday.first_login_ts,today.first_login_ts) as first_login_ts,
|-- if(today.account is null,yestoday.login_cnt,today.login_cnt+yestoday.login_cnt) as login_cnt,
|if(today.account is null,yestoday.bind_score*0.9,today.bind_score+if(yestoday.bind_score is null,0,yestoday.bind_score)) as bind_score
|from
| today
|full join
| yestoday
|on today.deviceid=yestoday.deviceid and today.account=yestoday.account
|
|""".stripMargin)
spark.close()
}
}
7.6.1逻辑设计
从hive仓库的ods表读取源数据
从hive仓库读取维表geohash字典,并进行广播
从hive仓库读取全局用户id表,并进行广播
从hdfs读取ip地理位置字典,并进行广播
将每行数据封装成case class
按照要求进行过滤
提取gps,从geohash字典匹配省市区信息
如果gps匹配失败,则提取ip地址匹配省市区信息
如果account为空,则根据deviceid,去全局guid字典匹配account
7.6.2完整代码
代码片段,完整代码见项目工程
package cn.doitedu.dwetl
import java.text.SimpleDateFormat
import java.util.UUID
import ch.hsr.geohash.GeoHash
import cn.doitedu.dwetl.beans.AppLogBean
import cn.doitedu.dwetl.utils.Row2AppLogBean
import org.apache.commons.lang3.StringUtils
import org.apache.hadoop.conf.Configuration
import org.apache.hadoop.fs.{
FSDataInputStream, FileStatus, FileSystem, Path}
import org.apache.log4j.{
Level, Logger}
import org.apache.spark.SparkFiles
import org.apache.spark.rdd.RDD
import org.apache.spark.sql.{
DataFrame, Dataset, Row, SparkSession}
import org.lionsoul.ip2region.{
DbConfig, DbSearcher}
/**
* @author 涛哥
* @nick_name "deep as the sea"
* @contact qq:657270652 wx:doit_edu
* @site www.doitedu.cn
* @date 2021-01-14
* @desc ods层app端行为日志数据,处理为dwd明细表
*
* 目标表建表语句
CREATE TABLE dwd.event_app_detail (
account String ,
appid String ,
appversion String ,
carrier String ,
deviceid String ,
devicetype String ,
eventid String ,
ip String ,
latitude Double ,
longitude Double ,
nettype String ,
osname String ,
osversion String ,
properties Map ,
releasechannel String ,
resolution String ,
sessionid String ,
`timestamp` BIGINT ,
newsessionid String ,
country String ,
province String ,
city String ,
region String ,
guid String ,
isnew String
)
PARTITIONED BY (dt string)
STORED AS parquet
TBLPROPERTIES("parquet.compress"="snappy")
;
*
*/
object EventAppLog2DwdTable {
def main(args: Array[String]): Unit = {
if(args.size<3){
println(
"""
|
|wrong number of parameters
|usage:
| args(0) : T-1日
| args(1) : T日
| args(2) : T+1日
|
|""".stripMargin)
}
val DT_PRE = args(0)
val DT_CUR = args(1)
val DT_NEXT = args(2)
//Logger.getLogger("org").setLevel(Level.FATAL)
val spark = SparkSession.builder()
.appName("ods层app端行为日志数据,处理为dwd明细表")
//.master("local[*]")
.enableHiveSupport()
.getOrCreate()
import spark.implicits._
/**
* 加载各种字典数据,并广播
*/
// 1.geohash字典
val geodf: Dataset[Row] = spark.read.parquet("/dicts/geodict")
val geomap = geodf.rdd.map(row => {
val geohash: String = row.getAs[String]("geohash")
val province: String = row.getAs[String]("province")
val city: String = row.getAs[String]("city")
val region: String = row.getAs[String]("region")
(geohash, (province, city, region))
}).collectAsMap()
val bc1 = spark.sparkContext.broadcast(geomap)
// 2.ip2region.db字典
/*
// 添加缓存文件
spark.sparkContext.addFile("/dicts/ip2region/ip2region.db")
// 在算子中使用缓存文件
geodf.rdd.map(row=>{
val path = SparkFiles.get("ip2region.db")
new DbSearcher(new DbConfig(),path)
})
*/
// 自己读文件,存入一个字节数组,并广播
val fs = FileSystem.get(new Configuration())
val path = new Path("/dicts/ip2region/ip2region.db")
// 获取文件的长度(字节数)
val statuses: Array[FileStatus] = fs.listStatus(path)
val len = statuses(0).getLen
// 将字典文件,以字节形式读取并缓存到一个字节buffer中
val in: FSDataInputStream = fs.open(path)
val buffer = new Array[Byte](len.toInt)
in.readFully(0, buffer)
val bc2 = spark.sparkContext.broadcast(buffer)
// 3.设备账号关联评分字典
// val relation = spark.read.table("dwd.device_account_relation").where("dt='2021-01-10'")
//d01,c01,1000
//d01,c02,800
// 上面的数据,需要加工成: d01,c01 加工逻辑:求分组top1
val relation = spark.sql(
s"""
|
|select
| deviceid,
| account
| from
| (
| select
| deviceid,
| account,
| row_number() over(partition by deviceid order by score desc,last_time desc) as rn
| from dwd.device_account_relation
| where dt='${DT_CUR}'
| ) o
|where rn=1
|
|""".stripMargin)
val relationMap = relation.rdd.map(row => {
val deviceid = row.getAs[String]("deviceid")
val account = row.getAs[String]("account")
(deviceid, account)
}).collectAsMap()
val bc3 = spark.sparkContext.broadcast(relationMap)
// 3.历史设备、账号标识(用户判断新老访客)
val ids = spark.read.table("dwd.device_account_relation")
.where(s"dt='${DT_PRE}' ")
.selectExpr("explode (array(deviceid,account)) as id")
.map(row=>row.getAs[String]("id")).collect().toSet
val bc4 = spark.sparkContext.broadcast(ids)
/**
* 加载T日的ODS日志表数据
*/
val ods = spark.read.table("ods.event_app_log").where(s"dt='${DT_CUR}'")
val beanRdd = ods.rdd.map(row => {
Row2AppLogBean.row2AppLogBean(row)
})
/**
* 根据规则清洗过滤
*/
val filtered: RDD[AppLogBean] = beanRdd.filter(bean => {
var flag = true
// deviceid/properties/eventid/sessionid
if (!StringUtils.isNotBlank(bean.deviceid) && bean.properties != null && StringUtils.isNotBlank(bean.eventid) && StringUtils.isNotBlank(bean.sessionid)) flag = false
// 判断数据的时间是否正确
val sdf = new SimpleDateFormat("yyyy-MM-dd")
val validStart = sdf.parse(s"${DT_CUR}").getTime
val validEnd = sdf.parse(s"${DT_NEXT}").getTime
if (bean.timestamp < validStart || bean.timestamp >= validEnd) flag = false
flag
})
/**
* session分割,添加新的newsessionid字段
*/
val sessionSplitted: RDD[AppLogBean] = filtered.groupBy(bean => bean.sessionid).flatMapValues(iter => {
val sortedEvents = iter.toList.sortBy(bean => bean.timestamp)
var tmpSessionId = UUID.randomUUID().toString
for (i <- 0 until sortedEvents.size) {
sortedEvents(i).newsessionid = tmpSessionId
if (i < sortedEvents.size - 1 && sortedEvents(i + 1).timestamp - sortedEvents(i).timestamp > 30 * 60 * 1000) tmpSessionId = UUID.randomUUID().toString
}
sortedEvents
}).map(_._2)
//sessionSplitted.toDF.show(100,false)
// 验证切割效果
/*sessionSplitted.toDF.createTempView("tmp")
spark.sql(
"""
|
|select
|sessionid,count(distinct newsessionid) as cnt
|from tmp
|group by sessionid
|having count(distinct newsessionid) >1
|
|""".stripMargin).show(100,false)*/
/**
* 集成数据(地理位置)
*/
val aread: RDD[AppLogBean] = sessionSplitted.mapPartitions(iter => {
val geoDict: collection.Map[String, (String, String, String)] = bc1.value
val ip2RegionDb: Array[Byte] = bc2.value
val searcher = new DbSearcher(new DbConfig(), ip2RegionDb)
iter.map(bean => {
// 定义临时记录变量
var country: String = "UNKNOWN"
var province: String = "UNKNOWN"
var city: String = "UNKNOWN"
var region: String = "UNKNOWN"
// 查询GEO字典获取省市区信息
try {
val lat = bean.latitude
val lng = bean.longitude
val geo = GeoHash.geoHashStringWithCharacterPrecision(lat, lng, 5)
val area = geoDict.getOrElse(geo, ("UNKNOWN", "UNKNOWN", "UNKNOWN"))
country = "CN"
province = area._1
city = area._2
region = area._3
} catch {
case e: Exception => e.printStackTrace()
}
// 如果在geo字典中查询失败,则用ip地址再查询一次
if ("UNKNOWN".equals(province)) {
val block = searcher.memorySearch(bean.ip)
// 中国|0|上海|上海市|电信
try {
val split = block.getRegion.split("\\|")
country = split(0)
province = split(2)
city = split(3)
} catch {
case e: Exception => e.printStackTrace()
}
}
bean.country = country
bean.province = province
bean.city = city
bean.region = region
bean
})
})
/**
* guid绑定生成,新老访客标记
*/
val guided: RDD[AppLogBean] = aread.mapPartitions(iter => {
val deviceBindAccountDict = bc3.value
// guid绑定
iter.map(bean => {
var guid: String = null
// 如果该条数据中,有登录账号,则直接用该登录账号作为这条数据的全局用户标识
if (StringUtils.isNotBlank(bean.account)) {
guid = bean.account
}
// 如果该条数据中,没有登录账号,则用设备id去关联账号表中查找默认的账号,作为guid
else {
val findedAccount = deviceBindAccountDict.getOrElse(bean.deviceid, null)
// 如果查询到的结果为不为null,则用查询到的account作为guid,否则用deviceid作为guid
if(findedAccount != null) guid = findedAccount else guid=bean.deviceid
}
bean.guid = guid
bean
})
})
/**
* 新老访客
*/
val result = guided.mapPartitions(iter=>{
val idSet = bc4.value
iter.map(bean=>{
var isnew = "1"
if(idSet.contains(bean.deviceid) || idSet.contains(bean.account)) isnew = "0"
bean.isnew = isnew
bean
})
}).toDF()
/**
* 保存结果到目标表
*/
result.createTempView("result")
spark.sql(
"""
|
|insert into table dwd.event_app_detail partition(dt='2021-01-10')
|select
|account ,
|appid ,
|appversion ,
|carrier ,
|deviceid ,
|devicetype ,
|eventid ,
|ip ,
|latitude ,
|longitude ,
|nettype ,
|osname ,
|osversion ,
|properties ,
|releasechannel ,
|resolution ,
|sessionid ,
|timestamp ,
|newsessionid ,
|country ,
|province ,
|city ,
|region ,
|guid ,
|isnew
|
|from result
|
|""".stripMargin)
spark.close()
}
}